feat(easy-mode): refine guided setup flow and spotlight positioning
- Enforce completion of each selection, input, and configuration step - Align OAuth, API access, and agent setup guidance with actual UI actions - Prevent guidance cards from overlapping highlighted areas - Improve bilingual step descriptions and completion states
This commit is contained in:
1 parent
d93d8a38e4
commit
7bf860055a
6 files changed
+1118
-90
No files matched your search
+13
-6
@@ -271,8 +271,9 @@ function AppContent() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="app-shell">
|
||||
<aside className="sidebar">
|
||||
<div className={`app-shell${active === "easy" ? " app-shell-easy-mode" : ""}`}>
|
||||
{active !== "easy" ? (
|
||||
<aside className="sidebar">
|
||||
<div className="sidebar-brand" title="CLI Proxy API GUI">
|
||||
<img src={appLogo} alt="" className="brand-mark brand-logo" />
|
||||
<div>
|
||||
@@ -323,8 +324,7 @@ function AppContent() {
|
||||
<div className="sidebar-bottom">
|
||||
<button
|
||||
type="button"
|
||||
className={`sidebar-easy-entry${active === 'easy' ? ' active' : ''}`}
|
||||
aria-current={active === 'easy' ? 'page' : undefined}
|
||||
className="sidebar-easy-entry"
|
||||
onClick={() => select('easy')}
|
||||
>
|
||||
<span>{t('app.nav.easy')}</span>
|
||||
@@ -412,13 +412,20 @@ function AppContent() {
|
||||
<ExternalLink size={13} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
</aside>
|
||||
) : null}
|
||||
|
||||
<div className="workspace">
|
||||
<main className="content">
|
||||
{isAlwaysAvailablePage(activePage.id) || coreRunning ? (
|
||||
activePage.id === 'easy' ? (
|
||||
<EasyModePage onExit={() => select('home')} />
|
||||
<EasyModePage
|
||||
onExit={() => select('home')}
|
||||
theme={theme}
|
||||
setTheme={setTheme}
|
||||
locale={locale}
|
||||
setLocale={setLocale}
|
||||
/>
|
||||
) : (
|
||||
<ActivePage />
|
||||
)
|
||||
|
||||
@@ -38,7 +38,7 @@ export const en: Record<MessageKey, string> = {
|
||||
'easyMode.steps.label': 'Connection wizard steps',
|
||||
'easyMode.steps.current': 'Step {current} of {total}',
|
||||
'easyMode.steps.step1Description': 'Sign in or connect an API',
|
||||
'easyMode.steps.step2Description': 'Select a client and model, then start',
|
||||
'easyMode.steps.step2Description': 'Select a client and model, then apply the configuration',
|
||||
'easyMode.overview.coreTitle': 'How to use',
|
||||
'easyMode.overview.providerTitle': 'Connect a model',
|
||||
'easyMode.overview.agentTitle': 'Connect an agent',
|
||||
@@ -55,18 +55,18 @@ export const en: Record<MessageKey, string> = {
|
||||
'easyMode.step3.step2': 'Choose an installed agent client.',
|
||||
'easyMode.step3.step3': 'Apply the configuration, then launch the client.',
|
||||
'easyMode.guide.title': 'How to use',
|
||||
'easyMode.guide.description': 'Choose how to connect first. After the model is connected, choose the model your agent should use from “Available models”.',
|
||||
'easyMode.guide.description': 'Choose a connection method, complete the connection, then select a client and model and apply the configuration.',
|
||||
'easyMode.guide.step1Title': 'Choose how to connect',
|
||||
'easyMode.guide.step1Description': 'You can use OAuth to sign in to your own account and use its quota, or use API Access to connect to any platform.',
|
||||
'easyMode.guide.step2Title': 'Connect the model',
|
||||
'easyMode.guide.step2Description': 'Use OAuth to authorize an account, or enter the Base URL and API key provided by a third-party platform. After saving, the model appears in “Available models”.',
|
||||
'easyMode.guide.step2Description': 'OAuth requires account authorization. API access requires a Base URL and API key, fetching models, and saving at least one model before client setup.',
|
||||
'easyMode.guide.step3Title': 'Choose a model and connect an agent',
|
||||
'easyMode.guide.step3Description': 'Choose a model from “Available models”, select an installed agent client, and apply the configuration.',
|
||||
'easyMode.navigation.back': 'Previous',
|
||||
'easyMode.navigation.next': 'Next',
|
||||
'easyMode.status.ready': 'Ready',
|
||||
'easyMode.status.connectedModels': '{count} available models detected',
|
||||
'easyMode.status.noModelsYet': 'No models connected yet. Please sign in or add an API key first.',
|
||||
'easyMode.status.connectedModels': '{count} connected sources detected',
|
||||
'easyMode.status.noModelsYet': 'No source connected yet. Sign in or add an API key first.',
|
||||
'easyMode.oauth.loggedIn': 'Signed In',
|
||||
'easyMode.oauth.relogin': 'Re-login',
|
||||
'easyMode.oauth.loggingIn': 'Waiting for browser authorization...',
|
||||
|
||||
@@ -36,7 +36,7 @@ export const zhCN = {
|
||||
'easyMode.steps.label': '接入向导步骤',
|
||||
'easyMode.steps.current': '第 {current} 步 / 共 {total} 步',
|
||||
'easyMode.steps.step1Description': '先登录账号或接入 API',
|
||||
'easyMode.steps.step2Description': '选择客户端和模型后启动',
|
||||
'easyMode.steps.step2Description': '选择客户端和模型并应用配置',
|
||||
'easyMode.overview.coreTitle': '使用指导',
|
||||
'easyMode.overview.providerTitle': '接入模型',
|
||||
'easyMode.overview.agentTitle': '接入智能体',
|
||||
@@ -53,18 +53,18 @@ export const zhCN = {
|
||||
'easyMode.step3.step2': '选择一个已安装的智能体客户端。',
|
||||
'easyMode.step3.step3': '应用配置修改,然后启动对应的客户端。',
|
||||
'easyMode.guide.title': '使用指导',
|
||||
'easyMode.guide.description': '先确定连接方式,完成模型连接后,再从“可用模型”列表中选择智能体要使用的模型。',
|
||||
'easyMode.guide.description': '先确定连接方式并完成接入,再选择客户端和模型并应用配置。',
|
||||
'easyMode.guide.step1Title': '选择连接方式',
|
||||
'easyMode.guide.step1Description': '可选择两种方式接入\n1.OAuth登录自己的订阅账号,使用自己账号的额度\n2.API URL的方式接入',
|
||||
'easyMode.guide.step2Title': '连接模型',
|
||||
'easyMode.guide.step2Description': '这里可以通过 OAuth 完成账号授权,也可以填写第三方平台提供的 Base URL 和 API 密钥。保存后,模型会出现在“可用模型”列表中。',
|
||||
'easyMode.guide.step2Description': 'OAuth 需要完成账号授权;API 需要填写 Base URL 和 API Key、获取模型并保存至少一个模型。完成后才能进入客户端配置。',
|
||||
'easyMode.guide.step3Title': '选择模型并接入智能体',
|
||||
'easyMode.guide.step3Description': '在“可用模型”列表中选定模型,再选择已安装的智能体客户端并应用配置。',
|
||||
'easyMode.navigation.back': '上一步',
|
||||
'easyMode.navigation.next': '下一步',
|
||||
'easyMode.status.ready': '已就绪',
|
||||
'easyMode.status.connectedModels': '已检测到 {count} 个可用模型',
|
||||
'easyMode.status.noModelsYet': '尚未连接模型,请先登录账号或添加 API',
|
||||
'easyMode.status.connectedModels': '已检测到 {count} 个已接入来源',
|
||||
'easyMode.status.noModelsYet': '尚未接入来源,请先登录账号或添加 API',
|
||||
'easyMode.oauth.loggedIn': '已登录',
|
||||
'easyMode.oauth.relogin': '重新登录',
|
||||
'easyMode.oauth.loggingIn': '等待浏览器授权...',
|
||||
|
||||
@@ -631,9 +631,10 @@ function AgentModelPicker({
|
||||
|
||||
type AgentsPageProps = {
|
||||
embedded?: boolean;
|
||||
onConfigurationApplied?: () => void;
|
||||
};
|
||||
|
||||
export function AgentsPage({ embedded = false }: AgentsPageProps = {}) {
|
||||
export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsPageProps = {}) {
|
||||
const { t } = useI18n();
|
||||
const [selected, setSelected] = useState<AgentClientId>(readSelectedAgentClient);
|
||||
const [activeSubpage, setActiveSubpage] = useState<AgentSubpageId>(DEFAULT_AGENT_SUBPAGE);
|
||||
@@ -1206,6 +1207,7 @@ export function AgentsPage({ embedded = false }: AgentsPageProps = {}) {
|
||||
}
|
||||
await reloadStatusesAfterAction();
|
||||
setOauthConfigurationDraft(null);
|
||||
onConfigurationApplied?.();
|
||||
} catch (requestError) {
|
||||
if (!handleOAuthLoginError(requestError, 'apply')) {
|
||||
setConfigurationError(String(requestError));
|
||||
@@ -1223,6 +1225,7 @@ export function AgentsPage({ embedded = false }: AgentsPageProps = {}) {
|
||||
try {
|
||||
await invoke<AgentConfigActionResult>('install_pi_provider', { model });
|
||||
await reloadStatusesAfterAction();
|
||||
onConfigurationApplied?.();
|
||||
} catch (requestError) {
|
||||
setConfigurationError(String(requestError));
|
||||
} finally {
|
||||
@@ -1255,6 +1258,7 @@ export function AgentsPage({ embedded = false }: AgentsPageProps = {}) {
|
||||
try {
|
||||
await invoke<AgentConfigActionResult>('repair_pi_provider', { model });
|
||||
await reloadStatusesAfterAction();
|
||||
onConfigurationApplied?.();
|
||||
} catch (requestError) {
|
||||
setConfigurationError(String(requestError));
|
||||
} finally {
|
||||
|
||||
+584
-73
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
@@ -11,13 +12,17 @@ import {
|
||||
ArrowRight,
|
||||
Check,
|
||||
CheckCircle2,
|
||||
ChevronDown,
|
||||
Languages,
|
||||
LoaderCircle,
|
||||
Moon,
|
||||
Sun,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useI18n } from "../i18n";
|
||||
import appLogo from "../assets/logo.jpg";
|
||||
import { useI18n, languageOptions, type AppLocale } from "../i18n";
|
||||
import {
|
||||
isRecord,
|
||||
managementApi,
|
||||
readBoolean,
|
||||
readString,
|
||||
responseList,
|
||||
} from "../services/managementApi";
|
||||
@@ -27,6 +32,7 @@ import {
|
||||
type ModelOption,
|
||||
type ModelProvider,
|
||||
} from "../services/modelService";
|
||||
import { type AppTheme } from "../theme";
|
||||
import { AgentsPage } from "./AgentsPage";
|
||||
|
||||
import codexIcon from "../assets/icons/codex.svg";
|
||||
@@ -86,10 +92,18 @@ const isDeepSeekRecord = (record: Record<string, unknown>) => {
|
||||
|
||||
export function EasyModePage({
|
||||
onExit,
|
||||
theme,
|
||||
setTheme,
|
||||
locale,
|
||||
setLocale,
|
||||
}: {
|
||||
onExit?: () => void;
|
||||
theme?: AppTheme;
|
||||
setTheme?: (theme: AppTheme) => void;
|
||||
locale?: AppLocale;
|
||||
setLocale?: (locale: AppLocale) => void;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const { t, locale: currentLocale, setLocale: setI18nLocale } = useI18n();
|
||||
|
||||
const [activeStep, setActiveStep] = useState<SetupStep>(1);
|
||||
const [authMethod, setAuthMethod] = useState<AuthMethod>("oauth");
|
||||
@@ -123,6 +137,30 @@ export function EasyModePage({
|
||||
const [apiSaving, setApiSaving] = useState(false);
|
||||
const [apiSaveNotice, setApiSaveNotice] = useState<string | null>(null);
|
||||
|
||||
const [guideChoice, setGuideChoice] = useState<AuthMethod | null>(null);
|
||||
const [guideOAuthProvider, setGuideOAuthProvider] = useState<OAuthProviderId | null>(null);
|
||||
const [guideOAuthCompleted, setGuideOAuthCompleted] = useState(false);
|
||||
const [guideApiSaved, setGuideApiSaved] = useState(false);
|
||||
const [guideApiFormatSelected, setGuideApiFormatSelected] = useState(false);
|
||||
const [guideApiBaseEdited, setGuideApiBaseEdited] = useState(false);
|
||||
const [guideApiKeyEdited, setGuideApiKeyEdited] = useState(false);
|
||||
const [guideApiModelsFetched, setGuideApiModelsFetched] = useState(false);
|
||||
const [guideApiModelSelected, setGuideApiModelSelected] = useState(false);
|
||||
const [guideAgentConfigured, setGuideAgentConfigured] = useState(false);
|
||||
|
||||
// 新手聚焦指导状态(默认关闭,点击开启)
|
||||
const [guideActive, setGuideActive] = useState(false);
|
||||
const [guideStep, setGuideStep] = useState<number>(1);
|
||||
const [spotlightRect, setSpotlightRect] = useState<{
|
||||
top: number;
|
||||
left: number;
|
||||
width: number;
|
||||
height: number;
|
||||
} | null>(null);
|
||||
const guideTooltipRef = useRef<HTMLElement | null>(null);
|
||||
const [guideCardPosition, setGuideCardPosition] = useState<{ top: number; left: number } | null>(null);
|
||||
const [langMenuOpen, setLangMenuOpen] = useState(false);
|
||||
|
||||
const refreshSourceStatus = useCallback(async () => {
|
||||
setLoadingSources(true);
|
||||
try {
|
||||
@@ -137,7 +175,6 @@ export function EasyModePage({
|
||||
gemini: 0,
|
||||
codex: 0,
|
||||
};
|
||||
const allFetchedModels: ModelOption[] = [];
|
||||
|
||||
const configPayload = await managementApi.get("/config");
|
||||
const recordsBySection: Record<ApiManagementSection, Record<string, unknown>[]> = {
|
||||
@@ -155,20 +192,8 @@ export function EasyModePage({
|
||||
? sourceList.filter((record) => !isDeepSeekRecord(record))
|
||||
: sourceList;
|
||||
counts[section.id] = list.length;
|
||||
list.forEach((item) => {
|
||||
if (Array.isArray(item.models)) {
|
||||
item.models.forEach((m) => {
|
||||
const name = typeof m === "string" ? m : readString(m, "name", "id");
|
||||
if (name && !allFetchedModels.some((x) => x.name === name)) {
|
||||
allFetchedModels.push({ name });
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
setApiCounts(counts);
|
||||
|
||||
setAvailableModels(allFetchedModels);
|
||||
} catch (e) {
|
||||
console.warn("Failed to refresh source status", e);
|
||||
} finally {
|
||||
@@ -181,7 +206,7 @@ export function EasyModePage({
|
||||
return () => {
|
||||
if (oauthPollTimer.current) window.clearInterval(oauthPollTimer.current);
|
||||
};
|
||||
}, []);
|
||||
}, [refreshSourceStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!oauthNotice) return undefined;
|
||||
@@ -199,7 +224,34 @@ export function EasyModePage({
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [apiSaveNotice]);
|
||||
|
||||
const isOAuthLoggedIn = (providerId: OAuthProviderId) => {
|
||||
const norm = providerId === "claude" ? "claude" : providerId === "codex" ? "codex" : providerId;
|
||||
return authFiles.some((f) => {
|
||||
const p = readString(f, "provider", "type").toLowerCase();
|
||||
return p.includes(norm) || (norm === "codex" && p.includes("openai")) || (norm === "claude" && p.includes("anthropic"));
|
||||
});
|
||||
};
|
||||
|
||||
const totalLoggedInOAuth = oauthProviders.filter((p) => isOAuthLoggedIn(p.id)).length;
|
||||
const totalApiProviders = Object.values(apiCounts).reduce((a, b) => a + b, 0);
|
||||
const hasConnectedSource = totalLoggedInOAuth > 0 || totalApiProviders > 0;
|
||||
const connectedSourceCount = totalLoggedInOAuth + totalApiProviders;
|
||||
const guideConnectedSourceCount = Math.max(connectedSourceCount, guideOAuthCompleted || guideApiSaved ? 1 : 0);
|
||||
const setupStepStatus = t("easyMode.steps.current", {
|
||||
current: activeStep,
|
||||
total: 2,
|
||||
});
|
||||
|
||||
const handleAuthMethodSelect = (method: AuthMethod) => {
|
||||
setAuthMethod(method);
|
||||
if (guideActive && guideStep === 1) setGuideChoice(method);
|
||||
};
|
||||
|
||||
const handleStartOAuth = async (provider: OAuthProviderId) => {
|
||||
if (guideActive && guideStep === 2) {
|
||||
setGuideOAuthProvider(provider);
|
||||
setGuideOAuthCompleted(false);
|
||||
}
|
||||
if (oauthPollTimer.current) window.clearInterval(oauthPollTimer.current);
|
||||
setOauthLoggingIn(provider);
|
||||
setOauthNotice(null);
|
||||
@@ -239,6 +291,7 @@ export function EasyModePage({
|
||||
tone: "success",
|
||||
message: "登录成功!已成功授权并获取账号额度。",
|
||||
});
|
||||
setGuideOAuthCompleted(true);
|
||||
void refreshSourceStatus();
|
||||
} else if (status === "error") {
|
||||
if (oauthPollTimer.current) window.clearInterval(oauthPollTimer.current);
|
||||
@@ -258,24 +311,41 @@ export function EasyModePage({
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleApiSectionChange = (sec: ApiSection) => {
|
||||
setSelectedApiSection(sec);
|
||||
if (guideActive && guideStep === 2) {
|
||||
setGuideApiFormatSelected(true);
|
||||
setGuideApiBaseEdited(false);
|
||||
setGuideApiKeyEdited(false);
|
||||
}
|
||||
const opt = apiSectionOptions.find((o) => o.id === sec);
|
||||
if (opt) setApiBaseUrl(opt.defaultBaseUrl);
|
||||
setApiTestedModels([]);
|
||||
setApiSelectedModels([]);
|
||||
setApiTestError("");
|
||||
setApiSaveNotice(null);
|
||||
setGuideApiSaved(false);
|
||||
setGuideApiModelsFetched(false);
|
||||
setGuideApiModelSelected(false);
|
||||
};
|
||||
|
||||
const handleTestApi = async () => {
|
||||
if (!apiBaseUrl.trim()) {
|
||||
setApiTestError("请先填写 API URL");
|
||||
setApiTestError("请先填写 API Base URL");
|
||||
return;
|
||||
}
|
||||
if (!apiKey.trim()) {
|
||||
setApiTestError("请先填写 API Key");
|
||||
return;
|
||||
}
|
||||
setApiTesting(true);
|
||||
setApiTestError("");
|
||||
setApiTestedModels([]);
|
||||
setApiSelectedModels([]);
|
||||
setGuideApiSaved(false);
|
||||
setGuideApiModelsFetched(false);
|
||||
setGuideApiModelSelected(false);
|
||||
|
||||
const opt = apiSectionOptions.find((o) => o.id === selectedApiSection);
|
||||
const providerType = opt ? opt.provider : "openai";
|
||||
@@ -291,9 +361,10 @@ export function EasyModePage({
|
||||
);
|
||||
if (models.length > 0) {
|
||||
setApiTestedModels(models);
|
||||
setApiSelectedModels(models);
|
||||
setApiSelectedModels([]);
|
||||
setGuideApiModelsFetched(true);
|
||||
} else {
|
||||
setApiTestError("未获取到任何模型,请检查 API URL 和 API 密钥");
|
||||
setApiTestError("未获取到任何模型,请检查 API Base URL 与密钥");
|
||||
}
|
||||
} catch (err) {
|
||||
setApiTestError(String(err));
|
||||
@@ -303,6 +374,8 @@ export function EasyModePage({
|
||||
};
|
||||
|
||||
const handleToggleApiModel = (model: ModelOption) => {
|
||||
setGuideApiSaved(false);
|
||||
setGuideApiModelSelected(true);
|
||||
const key = model.name.trim().toLowerCase();
|
||||
if (!key) return;
|
||||
setApiSelectedModels((current) => {
|
||||
@@ -315,16 +388,27 @@ export function EasyModePage({
|
||||
|
||||
const handleSaveApi = async () => {
|
||||
if (!apiBaseUrl.trim()) {
|
||||
setApiTestError("请先填写 API URL");
|
||||
setApiTestError("请先填写 API Base URL");
|
||||
return;
|
||||
}
|
||||
if (!apiKey.trim()) {
|
||||
setApiTestError("请先填写 API Key");
|
||||
return;
|
||||
}
|
||||
if (apiSelectedModels.length === 0) {
|
||||
setApiTestError(t("easyMode.api.modelRequired"));
|
||||
return;
|
||||
}
|
||||
if (guideActive && guideStep === 2 && authMethod === "api") {
|
||||
if (!guideApiFormatSelected || !guideApiBaseEdited || !guideApiKeyEdited || !guideApiModelsFetched || !guideApiModelSelected) {
|
||||
setApiTestError("请按引导完成接口格式、地址、密钥和模型选择后再保存");
|
||||
return;
|
||||
}
|
||||
}
|
||||
setApiSaving(true);
|
||||
setApiSaveNotice(null);
|
||||
setApiTestError("");
|
||||
setGuideApiSaved(false);
|
||||
|
||||
try {
|
||||
const selectedOption = apiSectionOptions.find((option) => option.id === selectedApiSection);
|
||||
@@ -349,6 +433,7 @@ export function EasyModePage({
|
||||
|
||||
await managementApi.put(`/${managementSection}`, [...list, newEntry]);
|
||||
setApiSaveNotice("API 接入已保存成功!");
|
||||
setGuideApiSaved(true);
|
||||
void refreshSourceStatus();
|
||||
} catch (err) {
|
||||
setApiTestError(String(err));
|
||||
@@ -357,24 +442,262 @@ export function EasyModePage({
|
||||
}
|
||||
};
|
||||
|
||||
const isOAuthLoggedIn = (providerId: OAuthProviderId) => {
|
||||
const norm = providerId === "claude" ? "claude" : providerId === "codex" ? "codex" : providerId;
|
||||
return authFiles.some((f) => {
|
||||
const p = readString(f, "provider", "type").toLowerCase();
|
||||
return p.includes(norm) || (norm === "codex" && p.includes("openai")) || (norm === "claude" && p.includes("anthropic"));
|
||||
});
|
||||
// 获取当前聚焦点元素 ID (4 个稳定步骤)
|
||||
const currentTargetId = (() => {
|
||||
if (!guideActive) return null;
|
||||
if (activeStep === 1) {
|
||||
if (guideStep === 1) return "easy-guide-choice-grid";
|
||||
if (guideStep === 2) return authMethod === "oauth" ? "easy-guide-oauth-box" : "easy-guide-api-box";
|
||||
if (guideStep >= 3) return "easy-guide-footer-action";
|
||||
} else if (activeStep === 2) {
|
||||
return "easy-guide-agents-panel";
|
||||
}
|
||||
return null;
|
||||
})();
|
||||
|
||||
// 计算聚光灯位置
|
||||
const updateSpotlightPosition = useCallback(() => {
|
||||
if (!guideActive || !currentTargetId) {
|
||||
setSpotlightRect(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const el = document.getElementById(currentTargetId);
|
||||
if (el) {
|
||||
const rect = el.getBoundingClientRect();
|
||||
setSpotlightRect({
|
||||
top: Math.max(0, rect.top),
|
||||
left: Math.max(0, rect.left),
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
});
|
||||
el.scrollIntoView({ behavior: "smooth", block: "nearest" });
|
||||
} else {
|
||||
setSpotlightRect(null);
|
||||
}
|
||||
}, [guideActive, currentTargetId]);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(updateSpotlightPosition, 100);
|
||||
window.addEventListener("resize", updateSpotlightPosition);
|
||||
window.addEventListener("scroll", updateSpotlightPosition, true);
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
window.removeEventListener("resize", updateSpotlightPosition);
|
||||
window.removeEventListener("scroll", updateSpotlightPosition, true);
|
||||
};
|
||||
}, [updateSpotlightPosition]);
|
||||
|
||||
useEffect(() => {
|
||||
setGuideCardPosition(null);
|
||||
}, [currentTargetId]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!guideActive || !spotlightRect || !guideTooltipRef.current) {
|
||||
setGuideCardPosition(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const cardRect = guideTooltipRef.current.getBoundingClientRect();
|
||||
const viewportPadding = 16;
|
||||
const gap = 18;
|
||||
const viewportWidth = window.innerWidth;
|
||||
const viewportHeight = window.innerHeight;
|
||||
const cardWidth = cardRect.width;
|
||||
const cardHeight = cardRect.height;
|
||||
const targetTop = spotlightRect.top;
|
||||
const targetBottom = spotlightRect.top + spotlightRect.height;
|
||||
const targetRight = spotlightRect.left + spotlightRect.width;
|
||||
const clamp = (value: number, min: number, max: number) => Math.min(Math.max(value, min), Math.max(min, max));
|
||||
const centeredLeft = clamp(spotlightRect.left, viewportPadding, viewportWidth - cardWidth - viewportPadding);
|
||||
const belowTop = targetBottom + gap;
|
||||
const aboveTop = targetTop - cardHeight - gap;
|
||||
let top: number;
|
||||
let left = centeredLeft;
|
||||
|
||||
if (belowTop + cardHeight <= viewportHeight - viewportPadding) {
|
||||
top = belowTop;
|
||||
} else if (aboveTop >= viewportPadding) {
|
||||
top = aboveTop;
|
||||
} else if (targetRight + gap + cardWidth <= viewportWidth - viewportPadding) {
|
||||
left = targetRight + gap;
|
||||
top = clamp(targetTop, viewportPadding, viewportHeight - cardHeight - viewportPadding);
|
||||
} else if (spotlightRect.left - gap - cardWidth >= viewportPadding) {
|
||||
left = spotlightRect.left - cardWidth - gap;
|
||||
top = clamp(targetTop, viewportPadding, viewportHeight - cardHeight - viewportPadding);
|
||||
} else {
|
||||
top = clamp(belowTop, viewportPadding, viewportHeight - cardHeight - viewportPadding);
|
||||
}
|
||||
|
||||
setGuideCardPosition({ top, left });
|
||||
}, [
|
||||
authMethod,
|
||||
currentTargetId,
|
||||
guideActive,
|
||||
guideAgentConfigured,
|
||||
guideApiSaved,
|
||||
guideOAuthCompleted,
|
||||
guideStep,
|
||||
spotlightRect,
|
||||
]);
|
||||
|
||||
const guideCanAdvance = guideStep === 1
|
||||
? guideChoice !== null
|
||||
: guideStep === 2
|
||||
? authMethod === "oauth"
|
||||
? guideOAuthProvider !== null && guideOAuthCompleted
|
||||
: guideApiFormatSelected && guideApiBaseEdited && guideApiKeyEdited && guideApiModelsFetched && guideApiModelSelected && guideApiSaved
|
||||
: guideStep === 3
|
||||
? hasConnectedSource || guideOAuthCompleted || guideApiSaved
|
||||
: guideAgentConfigured;
|
||||
|
||||
// 指引步骤控制
|
||||
const handleNextGuideStep = () => {
|
||||
if (!guideCanAdvance) return;
|
||||
if (guideStep === 1) {
|
||||
setGuideStep(2);
|
||||
} else if (guideStep === 2) {
|
||||
setGuideStep(3);
|
||||
} else if (guideStep === 3) {
|
||||
setActiveStep(2);
|
||||
setGuideStep(4);
|
||||
} else if (guideStep === 4) {
|
||||
setGuideActive(false);
|
||||
}
|
||||
};
|
||||
|
||||
const totalLoggedInOAuth = oauthProviders.filter((p) => isOAuthLoggedIn(p.id)).length;
|
||||
const totalApiProviders = Object.values(apiCounts).reduce((a, b) => a + b, 0);
|
||||
const hasConnectedSource = totalLoggedInOAuth > 0 || totalApiProviders > 0;
|
||||
const setupStepStatus = t("easyMode.steps.current", {
|
||||
current: activeStep,
|
||||
total: 2,
|
||||
});
|
||||
const handlePrevGuideStep = () => {
|
||||
if (guideStep === 4) {
|
||||
setActiveStep(1);
|
||||
setGuideStep(3);
|
||||
} else if (guideStep > 1) {
|
||||
setGuideStep(guideStep - 1);
|
||||
}
|
||||
};
|
||||
|
||||
const handleGuideToggle = () => {
|
||||
if (guideActive) {
|
||||
setGuideActive(false);
|
||||
return;
|
||||
}
|
||||
setActiveStep(1);
|
||||
setGuideStep(1);
|
||||
setGuideChoice(null);
|
||||
setGuideOAuthProvider(null);
|
||||
setGuideOAuthCompleted(false);
|
||||
setGuideApiSaved(false);
|
||||
setGuideApiFormatSelected(false);
|
||||
setGuideApiBaseEdited(false);
|
||||
setGuideApiKeyEdited(false);
|
||||
setGuideApiModelsFetched(false);
|
||||
setGuideApiModelSelected(false);
|
||||
setGuideAgentConfigured(false);
|
||||
setGuideActive(true);
|
||||
};
|
||||
|
||||
const currentActiveLang = languageOptions.find((opt) => opt.value === (locale || currentLocale)) || languageOptions[0];
|
||||
|
||||
return (
|
||||
<section className="page simple-mode-page">
|
||||
<section className="page simple-mode-page simple-mode-expanded">
|
||||
{/* 全灰色聚焦蒙层 (Dimmed Backdrop) */}
|
||||
{guideActive ? <div className="guide-dimmed-overlay" /> : null}
|
||||
|
||||
{/* 顶部全宽导航栏 */}
|
||||
<header className="simple-mode-topbar">
|
||||
<div className="simple-mode-topbar-left">
|
||||
<div className="simple-mode-brand">
|
||||
<img src={appLogo} alt="" className="brand-mark brand-logo" />
|
||||
<div className="simple-mode-brand-text">
|
||||
<div className="simple-mode-brand-title">
|
||||
<strong>EasyCLIProxyAPI</strong>
|
||||
<span className="simple-mode-badge">新手模式</span>
|
||||
</div>
|
||||
<span className="simple-mode-brand-sub">极简智能体与模型接入向导</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="simple-mode-topbar-right">
|
||||
{/* 新手聚焦指导开关 */}
|
||||
<button
|
||||
type="button"
|
||||
className={`simple-mode-guide-toggle${guideActive ? " active" : ""}`}
|
||||
title={guideActive ? "点击关闭操作指引" : "点击开启操作指引"}
|
||||
onClick={() => {
|
||||
handleGuideToggle();
|
||||
}}
|
||||
>
|
||||
<span>{guideActive ? "操作指引 (进行中)" : "操作指引"}</span>
|
||||
</button>
|
||||
|
||||
{/* 主题切换 */}
|
||||
{setTheme ? (
|
||||
<div className="simple-mode-theme-group">
|
||||
<button
|
||||
type="button"
|
||||
className={theme === "light" ? "active" : ""}
|
||||
title="明亮主题"
|
||||
onClick={() => setTheme("light")}
|
||||
>
|
||||
<Sun size={15} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={theme === "dark" ? "active" : ""}
|
||||
title="暗色主题"
|
||||
onClick={() => setTheme("dark")}
|
||||
>
|
||||
<Moon size={15} />
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* 语言选择 */}
|
||||
<div className="simple-mode-lang-dropdown">
|
||||
<button
|
||||
type="button"
|
||||
className="simple-mode-lang-btn"
|
||||
onClick={() => setLangMenuOpen(!langMenuOpen)}
|
||||
title="切换语言"
|
||||
>
|
||||
<Languages size={15} />
|
||||
<span>{currentActiveLang.nativeLabel}</span>
|
||||
<ChevronDown size={13} />
|
||||
</button>
|
||||
{langMenuOpen ? (
|
||||
<div className="simple-mode-lang-menu">
|
||||
{languageOptions.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
className={opt.value === (locale || currentLocale) ? "selected" : ""}
|
||||
onClick={() => {
|
||||
if (setLocale) setLocale(opt.value);
|
||||
else setI18nLocale(opt.value);
|
||||
setLangMenuOpen(false);
|
||||
}}
|
||||
>
|
||||
<span>{opt.nativeLabel}</span>
|
||||
{opt.value === (locale || currentLocale) ? <Check size={14} /> : null}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* 退出新手模式返回常规控制台 */}
|
||||
<button
|
||||
type="button"
|
||||
className="secondary-button simple-mode-exit-btn"
|
||||
title="返回常规控制台"
|
||||
onClick={() => onExit?.()}
|
||||
>
|
||||
<span>退出新手模式</span>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* 步骤条进度指示器 */}
|
||||
<nav className="simple-mode-step-status" aria-label={t("easyMode.steps.label")}>
|
||||
<div className="simple-mode-step-status-heading">
|
||||
<span>{t("easyMode.steps.label")}</span>
|
||||
@@ -384,6 +707,11 @@ export function EasyModePage({
|
||||
<div
|
||||
className={`simple-mode-step-status-item${activeStep === 1 ? " active" : " complete"}`}
|
||||
aria-current={activeStep === 1 ? "step" : undefined}
|
||||
onClick={() => {
|
||||
if (guideActive) return;
|
||||
setActiveStep(1);
|
||||
}}
|
||||
style={{ cursor: guideActive ? "default" : "pointer" }}
|
||||
>
|
||||
<span className="simple-mode-step-status-number">
|
||||
{activeStep > 1 ? <Check size={14} aria-hidden="true" /> : "1"}
|
||||
@@ -397,6 +725,12 @@ export function EasyModePage({
|
||||
<div
|
||||
className={`simple-mode-step-status-item${activeStep === 2 ? " active" : ""}`}
|
||||
aria-current={activeStep === 2 ? "step" : undefined}
|
||||
onClick={() => {
|
||||
if (!guideActive && hasConnectedSource) {
|
||||
setActiveStep(2);
|
||||
}
|
||||
}}
|
||||
style={{ cursor: !guideActive && hasConnectedSource ? "pointer" : "default" }}
|
||||
>
|
||||
<span className="simple-mode-step-status-number">2</span>
|
||||
<span className="simple-mode-step-status-copy">
|
||||
@@ -417,12 +751,17 @@ export function EasyModePage({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="simple-mode-choice-grid">
|
||||
{/* 连接方式选择卡片 */}
|
||||
<div
|
||||
id="easy-guide-choice-grid"
|
||||
className={`simple-mode-choice-grid${guideActive && guideStep === 1 ? " guide-focus-highlight" : ""}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={`simple-mode-choice${authMethod === "oauth" ? " selected" : ""}`}
|
||||
aria-pressed={authMethod === "oauth"}
|
||||
onClick={() => setAuthMethod("oauth")}
|
||||
onClick={() => handleAuthMethodSelect("oauth")}
|
||||
disabled={guideActive && guideStep !== 1}
|
||||
>
|
||||
<div className="simple-mode-choice-heading">
|
||||
<div className="simple-mode-choice-title">
|
||||
@@ -444,7 +783,8 @@ export function EasyModePage({
|
||||
type="button"
|
||||
className={`simple-mode-choice${authMethod === "api" ? " selected" : ""}`}
|
||||
aria-pressed={authMethod === "api"}
|
||||
onClick={() => setAuthMethod("api")}
|
||||
onClick={() => handleAuthMethodSelect("api")}
|
||||
disabled={guideActive && guideStep !== 1}
|
||||
>
|
||||
<div className="simple-mode-choice-heading">
|
||||
<div className="simple-mode-choice-title">
|
||||
@@ -461,12 +801,14 @@ export function EasyModePage({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* OAuth 平台列表 */}
|
||||
{authMethod === "oauth" ? (
|
||||
<div className="simple-mode-embedded-box">
|
||||
<div
|
||||
id="easy-guide-oauth-box"
|
||||
className={`simple-mode-embedded-box${guideActive && guideStep === 2 ? " guide-focus-highlight" : ""}`}
|
||||
>
|
||||
{oauthNotice ? (
|
||||
<div
|
||||
className={`config-toast ${oauthNotice.tone}`}
|
||||
>
|
||||
<div className={`config-toast ${oauthNotice.tone}`}>
|
||||
{oauthNotice.tone === "success" ? <CheckCircle2 size={16} /> : <AlertCircle size={16} />}
|
||||
<span>{oauthNotice.message}</span>
|
||||
</div>
|
||||
@@ -480,28 +822,26 @@ export function EasyModePage({
|
||||
return (
|
||||
<article
|
||||
key={provider.id}
|
||||
className={`client-api-card simple-mode-provider-card${loggedIn ? " connected" : ""}`}
|
||||
className={`panel simple-mode-provider-card${loggedIn ? " connected" : ""}`}
|
||||
>
|
||||
<div className="simple-mode-provider-card-head">
|
||||
<span className="client-api-logo simple-mode-provider-logo">
|
||||
<div className="simple-mode-provider-logo">
|
||||
<img src={provider.icon} alt="" />
|
||||
</span>
|
||||
</div>
|
||||
<div className="simple-mode-provider-copy">
|
||||
<strong>{provider.name}</strong>
|
||||
<span>
|
||||
{provider.description}
|
||||
</span>
|
||||
<span>{provider.description}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="simple-mode-provider-card-foot">
|
||||
{loggedIn ? (
|
||||
<span className="state-pill success" style={{ fontSize: "12px" }}>
|
||||
<Check size={13} style={{ marginRight: 4 }} />
|
||||
<Check size={12} style={{ marginRight: 4 }} />
|
||||
{t("easyMode.oauth.loggedIn")}
|
||||
</span>
|
||||
) : (
|
||||
<span className="state-pill neutral" style={{ fontSize: "12px" }}>未授权</span>
|
||||
<span className="state-pill neutral" style={{ fontSize: "12px" }}>未登录</span>
|
||||
)}
|
||||
|
||||
<button
|
||||
@@ -529,8 +869,12 @@ export function EasyModePage({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* API 接入表单 */}
|
||||
{authMethod === "api" ? (
|
||||
<div className="simple-mode-embedded-box">
|
||||
<div
|
||||
id="easy-guide-api-box"
|
||||
className={`simple-mode-embedded-box${guideActive && guideStep === 2 ? " guide-focus-highlight" : ""}`}
|
||||
>
|
||||
{apiSaveNotice ? (
|
||||
<div className="config-toast success">
|
||||
<CheckCircle2 size={16} />
|
||||
@@ -549,6 +893,7 @@ export function EasyModePage({
|
||||
) : null}
|
||||
|
||||
<div className="simple-mode-api-form">
|
||||
{/* 平台格式切换 */}
|
||||
<div className="simple-mode-api-platforms">
|
||||
{apiSectionOptions.map((opt) => (
|
||||
<button
|
||||
@@ -564,55 +909,57 @@ export function EasyModePage({
|
||||
</div>
|
||||
|
||||
<div className="simple-mode-field">
|
||||
<label>
|
||||
{t("easyMode.api.name")}
|
||||
</label>
|
||||
<label>{t("easyMode.api.name")}</label>
|
||||
<input
|
||||
type="text"
|
||||
className="text-input"
|
||||
value={apiRemark}
|
||||
onChange={(e) => setApiRemark(e.target.value)}
|
||||
onChange={(e) => { setApiRemark(e.target.value); setGuideApiSaved(false); }}
|
||||
placeholder="例如:主力 DeepSeek V3、中转 API"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="simple-mode-api-fields">
|
||||
<div className="simple-mode-field">
|
||||
<label>
|
||||
{t("easyMode.api.baseUrl")}
|
||||
</label>
|
||||
<label>{t("easyMode.api.baseUrl")}</label>
|
||||
<input
|
||||
type="text"
|
||||
className="text-input"
|
||||
value={apiBaseUrl}
|
||||
onChange={(e) => setApiBaseUrl(e.target.value)}
|
||||
onChange={(e) => { setApiBaseUrl(e.target.value); setGuideApiBaseEdited(true); setGuideApiSaved(false); }}
|
||||
placeholder="https://..."
|
||||
/>
|
||||
</div>
|
||||
<div className="simple-mode-field">
|
||||
<label>
|
||||
{t("easyMode.api.apiKey")}
|
||||
</label>
|
||||
<label>{t("easyMode.api.apiKey")}</label>
|
||||
<input
|
||||
type="password"
|
||||
className="text-input"
|
||||
value={apiKey}
|
||||
onChange={(e) => setApiKey(e.target.value)}
|
||||
onChange={(e) => { setApiKey(e.target.value); setGuideApiKeyEdited(true); setGuideApiSaved(false); }}
|
||||
placeholder="sk-..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 模型拉取与选择 */}
|
||||
<div className="simple-mode-api-model-card">
|
||||
{apiTestedModels.length === 0 ? (
|
||||
<div className="simple-mode-api-model-fetch">
|
||||
<button
|
||||
type="button"
|
||||
className="secondary-button simple-mode-api-fetch-button"
|
||||
disabled={apiTesting || !apiBaseUrl.trim()}
|
||||
disabled={apiTesting || !apiBaseUrl.trim() || !apiKey.trim()}
|
||||
onClick={() => void handleTestApi()}
|
||||
>
|
||||
{apiTesting ? t("easyMode.api.fetchingModels") : t("easyMode.api.fetchModels")}
|
||||
{apiTesting ? (
|
||||
<>
|
||||
<LoaderCircle size={14} className="spin" style={{ marginRight: 6 }} />
|
||||
{t("easyMode.api.fetchingModels")}
|
||||
</>
|
||||
) : (
|
||||
t("easyMode.api.fetchModels")
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
@@ -652,7 +999,7 @@ export function EasyModePage({
|
||||
<button
|
||||
type="button"
|
||||
className="primary-button"
|
||||
disabled={apiSaving || !apiBaseUrl.trim() || apiSelectedModels.length === 0}
|
||||
disabled={apiSaving || !apiBaseUrl.trim() || !apiKey.trim() || apiSelectedModels.length === 0}
|
||||
onClick={() => void handleSaveApi()}
|
||||
>
|
||||
保存并接入
|
||||
@@ -665,11 +1012,16 @@ export function EasyModePage({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="simple-mode-task-footer" style={{ marginTop: 10 }}>
|
||||
{/* 第一步底部操作栏 */}
|
||||
<div
|
||||
id="easy-guide-footer-action"
|
||||
className={`simple-mode-task-footer${guideActive && guideStep === 3 ? " guide-focus-highlight" : ""}`}
|
||||
style={{ marginTop: 10 }}
|
||||
>
|
||||
<div className="simple-mode-selection">
|
||||
{hasConnectedSource ? (
|
||||
<span style={{ color: "var(--ui-accent-strong)", fontWeight: 600 }}>
|
||||
{t("easyMode.status.connectedModels", { count: Math.max(availableModels.length, 1) })}
|
||||
{t("easyMode.status.connectedModels", { count: connectedSourceCount })}
|
||||
</span>
|
||||
) : (
|
||||
<span className="muted">{t("easyMode.status.noModelsYet")}</span>
|
||||
@@ -681,7 +1033,10 @@ export function EasyModePage({
|
||||
className="primary-button"
|
||||
style={{ minHeight: 42, padding: "0 22px", fontSize: "15px" }}
|
||||
disabled={!hasConnectedSource}
|
||||
onClick={() => setActiveStep(2)}
|
||||
onClick={() => {
|
||||
setActiveStep(2);
|
||||
if (guideActive) setGuideStep(4);
|
||||
}}
|
||||
>
|
||||
{t("easyMode.navigation.next")}: 配置智能体
|
||||
<ArrowRight size={16} style={{ marginLeft: 6 }} />
|
||||
@@ -692,14 +1047,20 @@ export function EasyModePage({
|
||||
|
||||
{/* 第二步:接入智能体 */}
|
||||
{activeStep === 2 ? (
|
||||
<section className="panel simple-mode-task">
|
||||
<AgentsPage embedded />
|
||||
<section
|
||||
id="easy-guide-agents-panel"
|
||||
className={`panel simple-mode-task${guideActive && guideStep === 4 ? " guide-focus-highlight" : ""}`}
|
||||
>
|
||||
<AgentsPage embedded onConfigurationApplied={() => setGuideAgentConfigured(true)} />
|
||||
|
||||
<div className="simple-mode-task-footer" style={{ marginTop: 14 }}>
|
||||
<button
|
||||
type="button"
|
||||
className="secondary-button"
|
||||
onClick={() => setActiveStep(1)}
|
||||
onClick={() => {
|
||||
setActiveStep(1);
|
||||
if (guideActive) setGuideStep(3);
|
||||
}}
|
||||
>
|
||||
<ArrowLeft size={16} style={{ marginRight: 6 }} />
|
||||
{t("easyMode.navigation.back")}
|
||||
@@ -717,6 +1078,156 @@ export function EasyModePage({
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{/* 悬浮指导卡片 */}
|
||||
{guideActive && spotlightRect ? (
|
||||
<aside
|
||||
ref={guideTooltipRef}
|
||||
className="guide-interactive-card"
|
||||
style={{
|
||||
top: guideCardPosition ? `${guideCardPosition.top}px` : "0px",
|
||||
left: guideCardPosition ? `${guideCardPosition.left}px` : "0px",
|
||||
visibility: guideCardPosition ? "visible" : "hidden",
|
||||
}}
|
||||
>
|
||||
<div className="guide-tooltip-header">
|
||||
<div className="guide-tooltip-badge">
|
||||
<span>
|
||||
{guideStep === 1 && "步骤 1/4 · 选择连接方式"}
|
||||
{guideStep === 2 && (authMethod === "oauth" ? "步骤 2/4 · 授权登录账号" : "步骤 2/4 · 填写 API 与接入")}
|
||||
{guideStep === 3 && "步骤 3/4 · 确认接入来源并进入下一步"}
|
||||
{guideStep === 4 && "步骤 4/4 · 选择客户端并应用配置"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="guide-tooltip-close"
|
||||
title="关闭指导"
|
||||
onClick={() => setGuideActive(false)}
|
||||
>
|
||||
<X size={15} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="guide-tooltip-body">
|
||||
{guideStep === 1 ? (
|
||||
<>
|
||||
<h4>步骤 1:选择接入方式</h4>
|
||||
<p>
|
||||
<strong>OAuth 授权登录</strong>:适用于页面列出的 OAuth 平台。<br />
|
||||
<strong>API 密钥接入</strong>:适用于你已有 API Base URL 和 API Key 的服务或中转平台。
|
||||
</p>
|
||||
<div className="guide-tooltip-tip">
|
||||
{guideChoice ? "选择接入方式后,请点击『下一步』继续。" : "请点击上方任一接入方式;选择完成后才能继续。"}
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{guideStep === 2 && authMethod === "oauth" ? (
|
||||
<>
|
||||
<h4>步骤 2:完成 OAuth 授权</h4>
|
||||
<p>
|
||||
请选择一个平台,点击它右侧的 <strong>『开始登录』</strong> 或 <strong>『重新登录』</strong>,并在浏览器中完成授权。<br />
|
||||
返回应用后,平台显示为 <strong>『已登录』</strong> 才算完成本步骤;未成功授权不能继续。
|
||||
</p>
|
||||
<div className="guide-tooltip-tip">
|
||||
{guideOAuthCompleted && guideOAuthProvider ? (
|
||||
<strong>{oauthProviders.find((provider) => provider.id === guideOAuthProvider)?.name} 已授权成功,请点击『下一步』。</strong>
|
||||
) : (
|
||||
"请先点击一个平台的登录按钮,并等待授权结果返回。"
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{guideStep === 2 && authMethod === "api" ? (
|
||||
<>
|
||||
<h4>步骤 2:填写并保存 API 接入</h4>
|
||||
<p>
|
||||
1. 选择接口格式。<br />
|
||||
2. 填写 <strong>API Base URL</strong> 和 <strong>API Key</strong>。<br />
|
||||
3. 点击 <strong>『获取模型列表』</strong>,至少勾选一个模型,再点击 <strong>『保存并接入』</strong>。
|
||||
</p>
|
||||
<div className="guide-tooltip-tip">
|
||||
{guideApiSaved ? (
|
||||
<strong>API 接入已保存成功,请点击『下一步』。</strong>
|
||||
) : !guideApiFormatSelected ? (
|
||||
"请先点击一个接口格式,再填写接入信息。"
|
||||
) : !guideApiBaseEdited || !guideApiKeyEdited ? (
|
||||
"请重新填写 Base URL 和 API Key;填写完成后才能获取模型。"
|
||||
) : !guideApiModelsFetched || apiTestedModels.length === 0 ? (
|
||||
"请点击『获取模型列表』并等待结果返回。"
|
||||
) : !guideApiModelSelected || apiSelectedModels.length === 0 ? (
|
||||
"已获取模型,请至少勾选一个模型后保存。"
|
||||
) : (
|
||||
<span>已完成填写并选中 {apiSelectedModels.length} 个模型,请点击『保存并接入』。</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{guideStep === 3 ? (
|
||||
<>
|
||||
<h4>步骤 3:确认接入来源</h4>
|
||||
<p>
|
||||
当前已确认 <strong>{guideConnectedSourceCount}</strong> 个接入来源。这里表示账号或 API 配置已接入,不代表客户端已经完成配置。<br />
|
||||
请点击下方高亮的 <strong>『下一步:配置智能体』</strong>,进入客户端选择和模型绑定。
|
||||
</p>
|
||||
<div className="guide-tooltip-tip">
|
||||
{guideCanAdvance ? "接入来源已确认,请点击『下一步:配置智能体』。" : "请先完成上一步的授权或 API 保存。"}
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{guideStep === 4 ? (
|
||||
<>
|
||||
<h4>步骤 4:选择客户端并应用配置</h4>
|
||||
<p>
|
||||
左侧列表会显示客户端的检测状态。请选择一个显示为 <strong>『已检测到』</strong> 且支持当前系统的客户端,再在右侧 <strong>『使用模型』</strong> 中选择模型。<br />
|
||||
非 Pi 客户端点击实际显示的 <strong>『应用配置』</strong> 或 <strong>『更新配置』</strong>;Pi 客户端点击 <strong>『安装提供方』</strong> 或 <strong>『修复提供方』</strong>。成功后才能完成指引。
|
||||
</p>
|
||||
<div className="guide-tooltip-tip">
|
||||
{guideAgentConfigured ? "配置已成功应用。你还可以点击『完成并进入控制台』返回控制台;启动客户端需要另行点击启动按钮。" : "请先选择已检测到的客户端和模型,再点击对应的配置按钮并等待成功结果。"}
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="guide-tooltip-footer">
|
||||
<div className="guide-tooltip-step-dots">
|
||||
{[1, 2, 3, 4].map((step) => (
|
||||
<span
|
||||
key={step}
|
||||
className={`guide-step-dot${guideStep === step ? " active" : ""}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="guide-tooltip-actions">
|
||||
{guideStep > 1 ? (
|
||||
<button
|
||||
type="button"
|
||||
className="secondary-button guide-btn-sm"
|
||||
onClick={handlePrevGuideStep}
|
||||
>
|
||||
上一步
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="primary-button guide-btn-sm"
|
||||
onClick={handleNextGuideStep}
|
||||
disabled={!guideCanAdvance}
|
||||
>
|
||||
{guideStep === 4 ? "完成指引" : "下一步"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
+506
@@ -11952,3 +11952,509 @@ body.table-col-resizing * {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Easy Mode Full-Screen Spread-Out Layout & Spotlight Guided Tour
|
||||
========================================================================== */
|
||||
|
||||
.app-shell.app-shell-easy-mode {
|
||||
display: block;
|
||||
width: 100%;
|
||||
min-height: 100vh;
|
||||
background: var(--theme-faf9f5);
|
||||
}
|
||||
|
||||
.app-shell-easy-mode .workspace {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.app-shell-easy-mode .content {
|
||||
width: 100%;
|
||||
max-width: 1360px;
|
||||
margin: 0 auto;
|
||||
padding: 24px 36px 48px;
|
||||
}
|
||||
|
||||
.simple-mode-page.simple-mode-expanded {
|
||||
max-width: 1360px;
|
||||
width: 100%;
|
||||
margin-inline: auto;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
/* 顶部全宽导航条 */
|
||||
.simple-mode-topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 12px 18px;
|
||||
border: 1px solid var(--theme-e3e1db);
|
||||
border-radius: 12px;
|
||||
background: var(--theme-fffdf8);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.03);
|
||||
}
|
||||
|
||||
.simple-mode-topbar-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.simple-mode-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.simple-mode-brand .brand-logo {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.simple-mode-brand-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.simple-mode-brand-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.simple-mode-brand-title strong {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: var(--theme-2d2a26);
|
||||
}
|
||||
|
||||
.simple-mode-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
background: var(--ui-accent-soft, #eef2ff);
|
||||
color: var(--ui-accent, #3b82f6);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
border: 1px solid color-mix(in srgb, var(--ui-accent) 30%, transparent);
|
||||
}
|
||||
|
||||
.simple-mode-brand-sub {
|
||||
font-size: 12px;
|
||||
color: var(--theme-7a746c);
|
||||
}
|
||||
|
||||
.simple-mode-topbar-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
/* 聚焦指导切换按钮 */
|
||||
.simple-mode-guide-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
height: 34px;
|
||||
padding: 0 12px;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 160ms ease;
|
||||
border: 1px solid var(--theme-d5d2cb);
|
||||
background: var(--theme-faf9f5);
|
||||
color: var(--theme-2d2a26);
|
||||
}
|
||||
|
||||
.simple-mode-guide-toggle:hover {
|
||||
border-color: var(--ui-accent);
|
||||
background: var(--ui-accent-soft);
|
||||
color: var(--ui-accent);
|
||||
}
|
||||
|
||||
.simple-mode-guide-toggle.active {
|
||||
background: var(--ui-accent, #3b82f6);
|
||||
color: #ffffff;
|
||||
border-color: var(--ui-accent, #3b82f6);
|
||||
box-shadow: 0 0 12px rgba(59, 130, 246, 0.4);
|
||||
}
|
||||
|
||||
.simple-guide-pulse-dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: #ffffff;
|
||||
animation: pulseDot 1.4s infinite ease-in-out;
|
||||
}
|
||||
|
||||
/* 主题选择器 */
|
||||
.simple-mode-theme-group {
|
||||
display: flex;
|
||||
border: 1px solid var(--theme-d5d2cb);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background: var(--theme-faf9f5);
|
||||
}
|
||||
|
||||
.simple-mode-theme-group button {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 34px;
|
||||
height: 32px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--theme-7a746c);
|
||||
cursor: pointer;
|
||||
transition: all 140ms ease;
|
||||
}
|
||||
|
||||
.simple-mode-theme-group button:hover {
|
||||
color: var(--theme-2d2a26);
|
||||
}
|
||||
|
||||
.simple-mode-theme-group button.active {
|
||||
background: var(--theme-fffdf8);
|
||||
color: var(--ui-accent, #3b82f6);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* 语言下拉框 */
|
||||
.simple-mode-lang-dropdown {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.simple-mode-lang-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
height: 34px;
|
||||
padding: 0 10px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--theme-d5d2cb);
|
||||
background: var(--theme-faf9f5);
|
||||
color: var(--theme-2d2a26);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 140ms ease;
|
||||
}
|
||||
|
||||
.simple-mode-lang-btn:hover {
|
||||
border-color: var(--theme-7a746c);
|
||||
}
|
||||
|
||||
.simple-mode-lang-menu {
|
||||
position: absolute;
|
||||
top: calc(100% + 4px);
|
||||
right: 0;
|
||||
min-width: 140px;
|
||||
border: 1px solid var(--theme-d5d2cb);
|
||||
border-radius: 8px;
|
||||
background: var(--theme-fffdf8);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
|
||||
z-index: 100;
|
||||
padding: 4px;
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.simple-mode-lang-menu button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
padding: 6px 10px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
font-size: 13px;
|
||||
color: var(--theme-2d2a26);
|
||||
cursor: pointer;
|
||||
transition: all 120ms ease;
|
||||
}
|
||||
|
||||
.simple-mode-lang-menu button:hover {
|
||||
background: var(--theme-faf9f5);
|
||||
}
|
||||
|
||||
.simple-mode-lang-menu button.selected {
|
||||
background: var(--ui-accent-soft);
|
||||
color: var(--ui-accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.simple-mode-exit-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
height: 34px;
|
||||
padding: 0 12px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* 全灰色聚焦蒙层与高亮指示器 */
|
||||
.guide-spotlight-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 9000;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.guide-spotlight-box {
|
||||
position: fixed;
|
||||
border-radius: 12px;
|
||||
border: 2px solid var(--ui-accent, #3b82f6);
|
||||
box-shadow: 0 0 0 9999px rgba(10, 14, 26, 0.72), 0 0 28px rgba(59, 130, 246, 0.55);
|
||||
pointer-events: none;
|
||||
transition: all 260ms cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||
z-index: 9001;
|
||||
animation: guideGlow 2.5s infinite alternate ease-in-out;
|
||||
}
|
||||
|
||||
.guide-tooltip-card {
|
||||
position: fixed;
|
||||
width: 420px;
|
||||
max-width: calc(100vw - 32px);
|
||||
padding: 18px;
|
||||
border-radius: 14px;
|
||||
background: var(--theme-fffdf8, #ffffff);
|
||||
border: 1px solid var(--theme-d5d2cb, #e2e8f0);
|
||||
box-shadow: 0 16px 36px rgba(0, 0, 0, 0.25), 0 0 0 1px rgba(255, 255, 255, 0.1);
|
||||
z-index: 9002;
|
||||
pointer-events: auto;
|
||||
max-height: calc(100vh - 32px);
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
animation: popoverIn 220ms ease-out;
|
||||
}
|
||||
|
||||
.guide-tooltip-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.guide-tooltip-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 3px 10px;
|
||||
border-radius: 999px;
|
||||
background: var(--ui-accent, #3b82f6);
|
||||
color: #ffffff;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.guide-tooltip-close {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 6px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--theme-7a746c);
|
||||
cursor: pointer;
|
||||
transition: all 120ms ease;
|
||||
}
|
||||
|
||||
.guide-tooltip-close:hover {
|
||||
background: var(--theme-faf9f5);
|
||||
color: var(--theme-2d2a26);
|
||||
}
|
||||
|
||||
.guide-tooltip-body h4 {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: var(--theme-2d2a26);
|
||||
margin: 0 0 8px 0;
|
||||
}
|
||||
|
||||
.guide-tooltip-body p {
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
color: var(--theme-5f5a53);
|
||||
margin: 0 0 10px 0;
|
||||
}
|
||||
|
||||
.guide-tooltip-tip {
|
||||
font-size: 12px;
|
||||
color: var(--ui-accent-strong, #2563eb);
|
||||
background: var(--ui-accent-soft, #eff6ff);
|
||||
padding: 8px 12px;
|
||||
border-radius: 6px;
|
||||
margin-top: 6px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.guide-tooltip-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-top: 14px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid var(--theme-e3e1db);
|
||||
}
|
||||
|
||||
.guide-tooltip-step-dots {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.guide-step-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--theme-d5d2cb);
|
||||
cursor: default;
|
||||
transition: all 140ms ease;
|
||||
}
|
||||
|
||||
.guide-step-dot.active {
|
||||
width: 20px;
|
||||
border-radius: 999px;
|
||||
background: var(--ui-accent, #3b82f6);
|
||||
}
|
||||
|
||||
.guide-tooltip-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.guide-btn-sm {
|
||||
min-height: 32px !important;
|
||||
height: 32px !important;
|
||||
padding: 0 14px !important;
|
||||
font-size: 13px !important;
|
||||
}
|
||||
|
||||
@keyframes guideGlow {
|
||||
0% {
|
||||
box-shadow: 0 0 0 9999px rgba(10, 14, 26, 0.72), 0 0 18px rgba(59, 130, 246, 0.4);
|
||||
}
|
||||
100% {
|
||||
box-shadow: 0 0 0 9999px rgba(10, 14, 26, 0.72), 0 0 32px rgba(59, 130, 246, 0.7);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulseDot {
|
||||
0%, 100% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.4);
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes popoverIn {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(6px) scale(0.98);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
/* 实时全灰聚焦遮罩与高亮目标系统 */
|
||||
.guide-dimmed-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(8, 12, 22, 0.72);
|
||||
backdrop-filter: blur(1.5px);
|
||||
z-index: 8990;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.guide-focus-highlight {
|
||||
position: relative !important;
|
||||
z-index: 8995 !important;
|
||||
background: var(--theme-fffdf8, #ffffff) !important;
|
||||
box-shadow: 0 0 0 4px var(--ui-accent, #3b82f6), 0 0 35px rgba(59, 130, 246, 0.65), 0 12px 36px rgba(0, 0, 0, 0.25) !important;
|
||||
border-radius: 12px;
|
||||
pointer-events: auto !important;
|
||||
animation: targetPulse 2.5s infinite alternate ease-in-out;
|
||||
}
|
||||
|
||||
.guide-interactive-card {
|
||||
position: fixed;
|
||||
width: 430px;
|
||||
max-width: calc(100vw - 32px);
|
||||
padding: 18px 20px;
|
||||
border-radius: 14px;
|
||||
background: var(--theme-fffdf8, #ffffff);
|
||||
border: 1px solid var(--theme-d5d2cb, #e2e8f0);
|
||||
box-shadow: 0 20px 48px rgba(0, 0, 0, 0.35), 0 0 0 1px rgba(255, 255, 255, 0.15);
|
||||
z-index: 9005;
|
||||
pointer-events: auto;
|
||||
max-height: calc(100vh - 32px);
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
animation: popoverIn 220ms ease-out;
|
||||
}
|
||||
|
||||
@keyframes targetPulse {
|
||||
0% {
|
||||
box-shadow: 0 0 0 4px var(--ui-accent, #3b82f6), 0 0 20px rgba(59, 130, 246, 0.45);
|
||||
}
|
||||
100% {
|
||||
box-shadow: 0 0 0 4px var(--ui-accent, #3b82f6), 0 0 38px rgba(59, 130, 246, 0.75);
|
||||
}
|
||||
}
|
||||
|
||||
/* 严格校验状态指示胶囊与控制 */
|
||||
.guide-status-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 3px 9px;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.guide-status-pill.success {
|
||||
background: var(--theme-eef7f0, #eef7f0);
|
||||
color: var(--theme-2f6b3f, #16a34a);
|
||||
border: 1px solid var(--theme-b6d3bd, #86efac);
|
||||
}
|
||||
|
||||
.guide-status-pill.pending {
|
||||
background: var(--theme-fff1ee, #fff1ee);
|
||||
color: var(--theme-9f2d20, #e11d48);
|
||||
border: 1px solid var(--theme-dfb7b0, #fca5a5);
|
||||
}
|
||||
|
||||
.guide-tooltip-actions-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.guide-btn-sm.disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed !important;
|
||||
pointer-events: none;
|
||||
}
|
||||
Reference in new issue
Block a user