You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

385 lines
12 KiB

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

"use client";
import { useCallback, useEffect, useState } from "react";
import { Card } from "@/components/ui/card";
import {
PrimaryButton,
SecondaryButton,
} from "@/components/ui/primary-button";
import { InputField } from "@/components/ui/input-field";
import { ErrorBanner } from "@/components/ui/error-banner";
import { Skeleton } from "@/components/ui/skeleton";
import { EmbeddedQuoteWidget } from "@/components/embed/embedded-quote-widget";
import {
embedGetProviderCredentials,
embedSaveProviderCredential,
type EmbedProviderCredential,
} from "@/lib/frontend/api-client";
import {
moduleToHubRoute,
useHostBridgeOptional,
} from "@/lib/embed/host-bridge-react";
import type { ChajiaModuleId } from "@/lib/embed/host-bridge";
type QuoteProvider = "mothership" | "flock";
type QuoteMode = "anon" | "login";
interface ModuleDef {
provider: QuoteProvider;
title: string;
desc: string;
}
/** 门户仅两模块:私卡 / 合单(进入后可选免账号或登录后) */
const MODULES: ModuleDef[] = [
{
provider: "mothership",
title: "私卡询价",
desc: "免账号实时报价,或使用已绑定账号登录后查价",
},
{
provider: "flock",
title: "合单询价",
desc: "ZIP Direct 匿名询价,或使用已绑定账号登录后查价",
},
];
function hubModuleId(
provider: QuoteProvider,
mode: QuoteMode,
): ChajiaModuleId {
if (provider === "mothership") {
return mode === "login" ? "MS_LOGGED_IN" : "MS_GUEST";
}
return mode === "login" ? "FLOCK_LOGGED_IN" : "FLOCK_GUEST";
}
interface QuoteRouteHubProps {
customerId: string;
apiBaseUrl?: string;
/**
* 隐藏门户外壳:当前模块条、免账号/登录后 Tab、承运商账密表单等。
* 宿主嵌入与正式演示仅展示查价表单本身。
*/
hideChrome?: boolean;
}
export function QuoteRouteHub({
customerId,
apiBaseUrl = "",
hideChrome = false,
}: QuoteRouteHubProps) {
const hostBridge = useHostBridgeOptional();
const [loading, setLoading] = useState(true);
const [creds, setCreds] = useState<
Record<QuoteProvider, EmbedProviderCredential>
>({
mothership: {
provider: "mothership",
email: null,
password: null,
has_password: false,
updated_at: null,
},
flock: {
provider: "flock",
email: null,
password: null,
has_password: false,
updated_at: null,
},
});
const [selected, setSelected] = useState<ModuleDef | null>(null);
const [mode, setMode] = useState<QuoteMode>("anon");
const loadCreds = useCallback(async () => {
setLoading(true);
const res = await embedGetProviderCredentials(apiBaseUrl);
if (res.code === 0) {
setCreds((prev) => {
const next = { ...prev };
for (const item of res.data.list) {
if (item.provider === "mothership" || item.provider === "flock") {
next[item.provider] = item;
}
}
return next;
});
}
setLoading(false);
}, [apiBaseUrl]);
useEffect(() => {
void loadCreds();
}, [loadCreds]);
// 宿主 postMessage 切模块
useEffect(() => {
const m = hostBridge?.activeModule;
if (!m) return;
const route = moduleToHubRoute(m);
const mod = MODULES.find((x) => x.provider === route.provider) ?? null;
if (!mod) return;
setSelected(mod);
setMode(route.mode);
}, [hostBridge?.activeModule]);
// 精简壳:未点选且未指定 entry 时,自动进入已配置账密的模块
useEffect(() => {
if (!hideChrome || loading || selected) return;
if (hostBridge?.activeModule) return;
const preferred =
MODULES.find((m) => creds[m.provider].has_password) ?? MODULES[0];
if (!preferred) return;
const nextMode = creds[preferred.provider].has_password ? "login" : "anon";
setSelected(preferred);
setMode(nextMode);
hostBridge?.setActiveModule(hubModuleId(preferred.provider, nextMode));
}, [hideChrome, loading, selected, creds, hostBridge]);
const handleSaved = useCallback((saved: EmbedProviderCredential) => {
setCreds((prev) => ({ ...prev, [saved.provider]: saved }));
}, []);
const openModule = (mod: ModuleDef) => {
setSelected(mod);
// 已配置账密时默认进登录后;否则免账号
const nextMode = creds[mod.provider].has_password ? "login" : "anon";
setMode(nextMode);
hostBridge?.setActiveModule(hubModuleId(mod.provider, nextMode));
};
const onModeChange = (next: QuoteMode) => {
setMode(next);
if (selected) {
hostBridge?.setActiveModule(hubModuleId(selected.provider, next));
}
};
if (loading) {
return <Skeleton className="h-64 w-full" />;
}
if (!selected) {
if (hideChrome) {
return <Skeleton className="h-64 w-full" />;
}
return (
<div className="grid gap-4 sm:grid-cols-2">
{MODULES.map((mod) => {
const hasPassword = creds[mod.provider].has_password;
return (
<Card
key={mod.provider}
className="flex cursor-pointer flex-col gap-2 transition-colors hover:border-primary"
onClick={() => openModule(mod)}
data-testid={`quote-route-module-${mod.provider}`}
>
<div className="flex items-center justify-between gap-2">
<span className="text-base font-semibold text-text-primary">
{mod.title}
</span>
<span
className={`shrink-0 rounded-full px-2 py-0.5 text-xs ${
hasPassword
? "bg-green-50 text-green-600"
: "bg-bg text-text-secondary"
}`}
>
{hasPassword ? "已配置官网账号" : "可免账号查价"}
</span>
</div>
<span className="text-sm text-text-secondary">{mod.desc}</span>
</Card>
);
})}
</div>
);
}
const providerCred = creds[selected.provider];
const requiresAccount = mode === "login";
const canQuote = !requiresAccount || providerCred.has_password;
const quoteArea = canQuote ? (
<EmbeddedQuoteWidget
key={`${selected.provider}-${mode}`}
customerId={customerId}
apiBaseUrl={apiBaseUrl}
forcedProvider={selected.provider}
hideProviderSwitch
mothershipLoggedInUi={
selected.provider === "mothership" && requiresAccount
}
flockLoggedInUi={selected.provider === "flock" && requiresAccount}
/>
) : (
<Card className="mt-4">
<p className="text-sm text-text-secondary">
当前客户未配置承运商官网账号,请在管理端「租户管理」中配置后再查价。
</p>
</Card>
);
if (hideChrome) {
return <div>{quoteArea}</div>;
}
return (
<div>
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
<div>
<p className="text-sm text-text-secondary">当前查价模块</p>
<p className="text-lg font-semibold text-text-primary">
{selected.title}
</p>
</div>
<SecondaryButton onClick={() => setSelected(null)}>
返回模块列表
</SecondaryButton>
</div>
<div className="mb-4 flex flex-wrap gap-1 rounded-md bg-bg p-1">
{(
[
{ id: "anon" as const, label: "免账号", desc: "无需官网登录" },
{ id: "login" as const, label: "登录后", desc: "使用官网账密" },
] as const
).map((tab) => (
<button
key={tab.id}
type="button"
onClick={() => onModeChange(tab.id)}
className={`flex min-w-[40%] flex-1 flex-col items-center rounded-sm px-3 py-2 text-center transition-colors sm:flex-row sm:justify-center sm:gap-2 ${
mode === tab.id
? "bg-surface text-primary shadow-card"
: "text-text-secondary hover:text-text-primary"
}`}
>
<span className="text-sm font-semibold">{tab.label}</span>
<span className="text-xs text-text-secondary">{tab.desc}</span>
</button>
))}
</div>
{requiresAccount && (
<ProviderCredentialPanel
provider={selected.provider}
apiBaseUrl={apiBaseUrl}
current={providerCred}
onSaved={handleSaved}
/>
)}
{quoteArea}
</div>
);
}
interface ProviderCredentialPanelProps {
provider: QuoteProvider;
apiBaseUrl: string;
current: EmbedProviderCredential;
onSaved: (saved: EmbedProviderCredential) => void;
}
function ProviderCredentialPanel({
provider,
apiBaseUrl,
current,
onSaved,
}: ProviderCredentialPanelProps) {
const [email, setEmail] = useState(current.email ?? "");
const [password, setPassword] = useState(current.password ?? "");
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [savedHint, setSavedHint] = useState<string | null>(null);
useEffect(() => {
setEmail(current.email ?? "");
setPassword(current.password ?? "");
setError(null);
setSavedHint(null);
}, [provider, current.email, current.password]);
const handleSave = async () => {
if (saving) return;
if (!email.trim()) {
setError("请填写登录邮箱");
return;
}
if (!password.trim()) {
setError("请填写登录密码(明文展示便于核对,修改后请点保存)");
return;
}
setSaving(true);
setError(null);
setSavedHint(null);
const res = await embedSaveProviderCredential(
apiBaseUrl,
provider,
email.trim(),
password,
);
setSaving(false);
if (res.code !== 0) {
setError(res.message || "保存失败");
return;
}
setEmail(res.data.email ?? email.trim());
setPassword(res.data.password ?? password);
setSavedHint(
"账号密码已保存。若官网已改密,请确认此处已同步更新后再查价。",
);
onSaved(res.data);
};
return (
<Card className="mb-4">
<h3 className="mb-1 text-base font-semibold text-text-primary">
承运商账号密码
</h3>
<p className="mb-4 text-sm text-text-secondary">
{current.has_password
? "以下为当前已保存的明文账密,请核对是否与官网一致。官网改密后须在此即时保存,否则查价会卡在登录页并报错。"
: "首次使用请填写官网账号邮箱与密码,保存后方可查价。"}
</p>
{error && (
<div className="mb-4">
<ErrorBanner>{error}</ErrorBanner>
</div>
)}
{savedHint && (
<p className="mb-4 text-sm text-green-600">{savedHint}</p>
)}
<div className="space-y-4">
<InputField
label="登录邮箱"
name="provider_email"
placeholder="账号邮箱"
autoComplete="off"
value={email}
disabled={saving}
onChange={(e) => setEmail(e.target.value)}
/>
<InputField
label="登录密码(明文)"
name="provider_password"
type="text"
autoComplete="off"
placeholder="官网登录密码"
value={password}
disabled={saving}
onChange={(e) => setPassword(e.target.value)}
/>
<PrimaryButton
type="button"
loading={saving}
onClick={() => void handleSave()}
>
{current.has_password ? "保存修改" : "保存"}
</PrimaryButton>
</div>
</Card>
);
}