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:
lzt404 committed 2026-09-01 23:32:26 +08:00
1 parent d93d8a38e4
commit 7bf860055a
6 files changed
+1118 -90

No files matched your search

+13 -6
View File
@@ -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 />
)
+5 -5
View File
@@ -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...',
+5 -5
View File
@@ -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': '等待浏览器授权...',
+5 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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;
}