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.

764 lines
27 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 Link from "next/link";
import { AdminLayout } from "@/components/layout/admin-layout";
import { PageHeader } from "@/components/layout/page-header";
import { Skeleton } from "@/components/ui/skeleton";
import { ErrorBanner } from "@/components/ui/error-banner";
import { SecondaryButton } from "@/components/ui/primary-button";
import { PrimaryButton } from "@/components/ui/primary-button";
import { InputField } from "@/components/ui/input-field";
import { Card } from "@/components/ui/card";
import { WarningBanner } from "@/components/ui/warning-banner";
import {
adminCreateCustomer,
adminGetCustomers,
adminGetProviderCredentials,
adminRotateCustomerKey,
adminUpdateCustomer,
adminUpsertProviderCredential,
type ProviderCredentialProvider,
type ProviderCredentialRecord,
} from "@/lib/frontend/api-client";
import { formatDateTime } from "@/lib/frontend/format";
import { useAuth } from "@/hooks/use-auth";
import type { DataPageStatus, HostCustomerRecord } from "@/lib/frontend/types";
const PAGE_SIZE = 10;
export default function AdminCustomersPage() {
const { token } = useAuth();
const [status, setStatus] = useState<DataPageStatus>("loading");
const [rows, setRows] = useState<HostCustomerRecord[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [keyword, setKeyword] = useState("");
const [createOpen, setCreateOpen] = useState(false);
const [createName, setCreateName] = useState("");
const [createRemark, setCreateRemark] = useState("");
const [createPassword, setCreatePassword] = useState("123456");
const [createBindCreds, setCreateBindCreds] = useState(false);
const [createFlockEmail, setCreateFlockEmail] = useState("");
const [createFlockPassword, setCreateFlockPassword] = useState("");
const [createMsEmail, setCreateMsEmail] = useState("");
const [createMsPassword, setCreateMsPassword] = useState("");
const [creating, setCreating] = useState(false);
const [createError, setCreateError] = useState<string | undefined>();
const [passwordTarget, setPasswordTarget] = useState<HostCustomerRecord | null>(
null,
);
const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [passwordError, setPasswordError] = useState<string | undefined>();
const [savingPassword, setSavingPassword] = useState(false);
const [revealedKey, setRevealedKey] = useState<{
customer_id: string;
api_key: string;
} | null>(null);
const [actionError, setActionError] = useState<string | null>(null);
const [rotatingId, setRotatingId] = useState<string | null>(null);
const [credTarget, setCredTarget] = useState<HostCustomerRecord | null>(null);
const [credList, setCredList] = useState<ProviderCredentialRecord[]>([]);
const [credError, setCredError] = useState<string | undefined>();
const [credLoading, setCredLoading] = useState(false);
const [showCredPassword, setShowCredPassword] = useState<{
flock: boolean;
mothership: boolean;
}>({ flock: false, mothership: false });
const load = useCallback(
async (p: number, kw: string) => {
if (!token) return;
setStatus("loading");
const res = await adminGetCustomers("", token, p, PAGE_SIZE, kw);
if (res.code !== 0) {
setStatus("error");
return;
}
setRows(res.data.list);
setTotal(res.data.total);
setStatus(res.data.total === 0 ? "empty" : "success");
},
[token],
);
useEffect(() => {
void load(page, keyword);
}, [load, page, keyword]);
const resetCreateForm = () => {
setCreateName("");
setCreateRemark("");
setCreatePassword("123456");
setCreateBindCreds(false);
setCreateFlockEmail("");
setCreateFlockPassword("");
setCreateMsEmail("");
setCreateMsPassword("");
setCreateError(undefined);
};
const saveProviderCred = async (
customerId: string,
provider: ProviderCredentialProvider,
email: string,
password: string,
): Promise<string | null> => {
if (!token) return "未登录";
const trimmedEmail = email.trim();
const trimmedPassword = password.trim();
if (!trimmedEmail && !trimmedPassword) return null;
if (!trimmedEmail || !trimmedEmail.includes("@")) {
return `${provider === "flock" ? "Flock Freight" : "MotherShip"}:请填写有效登录邮箱`;
}
if (!trimmedPassword) {
return `${provider === "flock" ? "Flock Freight" : "MotherShip"}:首次配置必须填写密码`;
}
const res = await adminUpsertProviderCredential("", token, customerId, {
provider,
email: trimmedEmail,
password: trimmedPassword,
});
if (res.code !== 0) return res.message;
return null;
};
const handleCreate = async () => {
if (!token) return;
const name = createName.trim();
if (!name) {
setCreateError("请填写客户名称");
return;
}
if (createBindCreds) {
const hasAny =
createFlockEmail.trim() ||
createFlockPassword.trim() ||
createMsEmail.trim() ||
createMsPassword.trim();
if (!hasAny) {
setCreateError("已勾选绑定查价网站账密,请至少填写一套邮箱和密码");
return;
}
}
setCreating(true);
setCreateError(undefined);
const res = await adminCreateCustomer("", token, {
name,
remark: createRemark.trim() || undefined,
embed_password: createPassword.trim() || undefined,
});
if (res.code !== 0) {
setCreating(false);
setCreateError(res.message);
return;
}
const customerId = res.data.customer_id;
if (createBindCreds) {
const flockErr = await saveProviderCred(
customerId,
"flock",
createFlockEmail,
createFlockPassword,
);
if (flockErr) {
setCreating(false);
setCreateError(`客户已创建,但 Flock 账密保存失败:${flockErr}`);
setRevealedKey({ customer_id: customerId, api_key: res.data.api_key });
setCreateOpen(false);
resetCreateForm();
void load(1, keyword);
setPage(1);
return;
}
const msErr = await saveProviderCred(
customerId,
"mothership",
createMsEmail,
createMsPassword,
);
if (msErr) {
setCreating(false);
setCreateError(`客户已创建,但 MotherShip 账密保存失败:${msErr}`);
setRevealedKey({ customer_id: customerId, api_key: res.data.api_key });
setCreateOpen(false);
resetCreateForm();
void load(1, keyword);
setPage(1);
return;
}
}
setCreating(false);
setCreateOpen(false);
resetCreateForm();
setRevealedKey({
customer_id: customerId,
api_key: res.data.api_key,
});
void load(1, keyword);
setPage(1);
};
const handleToggleStatus = async (row: HostCustomerRecord) => {
if (!token) return;
setActionError(null);
const nextStatus = row.status === "active" ? "disabled" : "active";
const res = await adminUpdateCustomer("", token, row.customer_id, {
status: nextStatus,
});
if (res.code !== 0) {
setActionError(res.message);
return;
}
setRows((list) =>
list.map((item) =>
item.customer_id === row.customer_id ? res.data : item,
),
);
};
const openPasswordDrawer = (row: HostCustomerRecord) => {
setPasswordTarget(row);
setNewPassword("");
setConfirmPassword("");
setPasswordError(undefined);
};
const handleSavePassword = async () => {
if (!token || !passwordTarget) return;
if (newPassword.length < 6) {
setPasswordError("密码至少 6 位");
return;
}
if (newPassword !== confirmPassword) {
setPasswordError("两次输入的密码不一致");
return;
}
setSavingPassword(true);
setPasswordError(undefined);
const res = await adminUpdateCustomer("", token, passwordTarget.customer_id, {
embed_password: newPassword,
});
setSavingPassword(false);
if (res.code !== 0) {
setPasswordError(res.message);
return;
}
setRows((list) =>
list.map((item) =>
item.customer_id === passwordTarget.customer_id ? res.data : item,
),
);
setPasswordTarget(null);
};
const handleRotateKey = async (customerId: string) => {
if (!token) return;
setRotatingId(customerId);
setActionError(null);
const res = await adminRotateCustomerKey("", token, customerId);
setRotatingId(null);
if (res.code !== 0) {
setActionError(res.message);
return;
}
setRevealedKey({ customer_id: customerId, api_key: res.data.api_key });
void load(page, keyword);
};
const openCredDrawer = async (row: HostCustomerRecord) => {
if (!token) return;
setCredTarget(row);
setCredError(undefined);
setShowCredPassword({ flock: false, mothership: false });
setCredLoading(true);
const res = await adminGetProviderCredentials("", token, row.customer_id);
setCredLoading(false);
if (res.code !== 0) {
setCredError(res.message);
setCredList([]);
return;
}
setCredList(res.data.list);
};
const flockCredSummary = credList.find((c) => c.provider === "flock");
const msCredSummary = credList.find((c) => c.provider === "mothership");
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
return (
<AdminLayout>
<PageHeader
title="租户管理"
subtitle="管理接入租户、API Key、演示密码与查价网站账密"
action={
<PrimaryButton
onClick={() => {
resetCreateForm();
setCreateOpen(true);
}}
>
新建租户
</PrimaryButton>
}
/>
{revealedKey && (
<div className="mb-4">
<WarningBanner>
<div className="space-y-2 text-left">
<p className="font-medium">
客户 {revealedKey.customer_id} 的 API Key
</p>
<p className="break-all font-mono text-sm">{revealedKey.api_key}</p>
<p className="text-xs text-text-secondary">
已保存到客户资料,可随时查看当前有效 Key。调用方式:Authorization:
{" "}Bearer &lt;Key&gt;;Header X-Customer-Id: {revealedKey.customer_id}
</p>
<SecondaryButton onClick={() => setRevealedKey(null)}>
我已保存
</SecondaryButton>
</div>
</WarningBanner>
</div>
)}
{actionError && (
<div className="mb-4">
<ErrorBanner>{actionError}</ErrorBanner>
</div>
)}
<div className="mb-4 w-64">
<InputField
label="搜索"
placeholder="客户 ID 或名称"
value={keyword}
onChange={(e) => {
setPage(1);
setKeyword(e.target.value);
}}
/>
</div>
{status === "loading" && (
<div className="space-y-2">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-14 w-full" />
))}
</div>
)}
{status === "error" && (
<ErrorBanner
action={
<SecondaryButton onClick={() => void load(page, keyword)}>
重试
</SecondaryButton>
}
>
加载客户列表失败
</ErrorBanner>
)}
{status === "empty" && (
<Card className="p-8 text-center text-text-secondary">
暂无客户,点击「新建客户」创建第一个租户
</Card>
)}
{status === "success" && (
<div className="space-y-4">
<div className="overflow-x-auto rounded-md border border-border">
<table className="min-w-full text-sm">
<thead className="bg-bg text-left text-text-secondary">
<tr>
<th className="px-4 py-3 font-medium">租户 ID</th>
<th className="px-4 py-3 font-medium">名称</th>
<th className="px-4 py-3 font-medium">状态</th>
<th className="px-4 py-3 font-medium">当前 API Key</th>
<th className="px-4 py-3 font-medium">演示密码</th>
<th className="px-4 py-3 font-medium">备注</th>
<th className="px-4 py-3 font-medium">创建时间</th>
<th className="px-4 py-3 font-medium">操作</th>
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr key={row.customer_id} className="border-t border-border">
<td className="px-4 py-3 font-mono">{row.customer_id}</td>
<td className="px-4 py-3">{row.name}</td>
<td className="px-4 py-3">
{row.status === "active" ? "启用" : "停用"}
</td>
<td className="px-4 py-3 font-mono text-text-secondary">
{row.active_api_key ? (
<span className="break-all">{row.active_api_key}</span>
) : (
<span>历史 Key 未保存原文,请轮换一次 Key</span>
)}
</td>
<td className="px-4 py-3 text-text-secondary">
{row.embed_password_set ? "已设置" : "未设置"}
</td>
<td className="px-4 py-3 text-text-secondary">
{row.remark || "—"}
</td>
<td className="px-4 py-3 text-text-secondary">
{formatDateTime(row.created_at)}
</td>
<td className="px-4 py-3">
<div className="flex flex-wrap gap-2">
<SecondaryButton
onClick={() => openPasswordDrawer(row)}
>
设置密码
</SecondaryButton>
<SecondaryButton
onClick={() => void openCredDrawer(row)}
>
租户账密
</SecondaryButton>
<SecondaryButton
onClick={() => void handleToggleStatus(row)}
>
{row.status === "active" ? "停用" : "启用"}
</SecondaryButton>
<SecondaryButton
loading={rotatingId === row.customer_id}
disabled={row.status !== "active"}
onClick={() => void handleRotateKey(row.customer_id)}
>
轮换 Key
</SecondaryButton>
<Link href={`/admin/markup?keyword=${encodeURIComponent(row.customer_id)}`}>
<SecondaryButton>租户加价</SecondaryButton>
</Link>
<Link
href={`/admin/tenants/${encodeURIComponent(row.customer_id)}/customers`}
>
<SecondaryButton>业务客户</SecondaryButton>
</Link>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="flex items-center justify-between text-sm text-text-secondary">
<span>
共 {total} 条,第 {page} / {totalPages} 页
</span>
<div className="flex gap-2">
<SecondaryButton
disabled={page <= 1}
onClick={() => setPage((p) => Math.max(1, p - 1))}
>
上一页
</SecondaryButton>
<SecondaryButton
disabled={page >= totalPages}
onClick={() => setPage((p) => p + 1)}
>
下一页
</SecondaryButton>
</div>
</div>
</div>
)}
{createOpen && (
<div
className="fixed inset-0 z-50 flex justify-end bg-black/30"
onClick={() => !creating && setCreateOpen(false)}
>
<div
className="h-full w-full max-w-lg overflow-y-auto bg-surface p-6 shadow-lg"
onClick={(e) => e.stopPropagation()}
>
<h3 className="text-lg font-semibold">新建客户</h3>
<p className="mt-1 text-sm text-text-secondary">
创建后将自动生成 API Key;可选绑定 MotherShip / Flock Freight
官网账密,用于该客户查价登录拿折扣价。
</p>
<div className="mt-6 space-y-4">
<InputField
label="客户名称"
value={createName}
disabled={creating}
error={createError}
onChange={(e) => setCreateName(e.target.value)}
/>
<InputField
label="嵌入演示密码"
type="password"
autoComplete="new-password"
value={createPassword}
disabled={creating}
onChange={(e) => setCreatePassword(e.target.value)}
/>
<InputField
label="备注"
value={createRemark}
disabled={creating}
onChange={(e) => setCreateRemark(e.target.value)}
/>
<label className="flex cursor-pointer items-start gap-3 rounded-md border border-border bg-bg px-3 py-3">
<input
type="checkbox"
className="mt-1 h-4 w-4"
checked={createBindCreds}
disabled={creating}
onChange={(e) => setCreateBindCreds(e.target.checked)}
/>
<span>
<span className="block text-sm font-medium text-text-primary">
绑定查价网站账密
</span>
<span className="mt-0.5 block text-xs text-text-secondary">
仅新建时可写入;写入后管理端只能查看,不可修改。不勾选则走匿名/Direct
原线路
</span>
</span>
</label>
{createBindCreds && (
<div className="space-y-5 rounded-md border border-border p-4">
<div className="space-y-3">
<p className="text-sm font-medium">Flock Freight</p>
<InputField
label="登录邮箱"
type="email"
autoComplete="off"
value={createFlockEmail}
disabled={creating}
onChange={(e) => setCreateFlockEmail(e.target.value)}
placeholder="Flock 官网登录邮箱"
/>
<InputField
label="密码"
type="password"
autoComplete="new-password"
value={createFlockPassword}
disabled={creating}
onChange={(e) => setCreateFlockPassword(e.target.value)}
/>
</div>
<div className="space-y-3 border-t border-border pt-4">
<p className="text-sm font-medium">MotherShip</p>
<InputField
label="登录邮箱"
type="email"
autoComplete="off"
value={createMsEmail}
disabled={creating}
onChange={(e) => setCreateMsEmail(e.target.value)}
placeholder="MotherShip 官网登录邮箱"
/>
<InputField
label="密码"
type="password"
autoComplete="new-password"
value={createMsPassword}
disabled={creating}
onChange={(e) => setCreateMsPassword(e.target.value)}
/>
</div>
</div>
)}
</div>
<div className="mt-8 flex justify-end gap-3">
<SecondaryButton
disabled={creating}
onClick={() => {
setCreateOpen(false);
resetCreateForm();
}}
>
取消
</SecondaryButton>
<PrimaryButton loading={creating} onClick={() => void handleCreate()}>
创建并生成 Key
</PrimaryButton>
</div>
</div>
</div>
)}
{passwordTarget && (
<div
className="fixed inset-0 z-50 flex justify-end bg-black/30"
onClick={() => !savingPassword && setPasswordTarget(null)}
>
<div
className="h-full w-full max-w-md bg-surface p-6 shadow-lg"
onClick={(e) => e.stopPropagation()}
>
<h3 className="text-lg font-semibold">设置嵌入演示密码</h3>
<p className="mt-1 text-sm text-text-secondary">
客户 {passwordTarget.customer_id}({passwordTarget.name}
)在 /embed-demo 登录时使用此密码
</p>
<form
className="mt-6 space-y-4"
autoComplete="off"
onSubmit={(e) => {
e.preventDefault();
void handleSavePassword();
}}
>
<InputField
label="新密码"
type="password"
autoComplete="new-password"
value={newPassword}
disabled={savingPassword}
error={passwordError}
onChange={(e) => setNewPassword(e.target.value)}
/>
<InputField
label="确认密码"
type="password"
autoComplete="new-password"
value={confirmPassword}
disabled={savingPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
/>
<div className="flex justify-end gap-3 pt-4">
<SecondaryButton
type="button"
disabled={savingPassword}
onClick={() => setPasswordTarget(null)}
>
取消
</SecondaryButton>
<PrimaryButton type="submit" loading={savingPassword}>
保存
</PrimaryButton>
</div>
</form>
</div>
</div>
)}
{credTarget && (
<div
className="fixed inset-0 z-50 flex justify-end bg-black/30"
onClick={() => setCredTarget(null)}
>
<div
className="h-full w-full max-w-lg overflow-y-auto bg-surface p-6 shadow-lg"
onClick={(e) => e.stopPropagation()}
>
<h3 className="text-lg font-semibold">查价网站账密</h3>
<p className="mt-1 text-sm text-text-secondary">
客户 {credTarget.customer_id}({credTarget.name}
)。仅供查看;账密在新建客户时写入,本页不提供修改。有账密时该客户查价强制官网登录。
</p>
{credLoading ? (
<p className="mt-6 text-sm text-text-secondary">加载中…</p>
) : (
<div className="mt-6 space-y-6">
{credError && <ErrorBanner>{credError}</ErrorBanner>}
{(
[
{
key: "flock" as const,
title: "Flock Freight",
summary: flockCredSummary,
},
{
key: "mothership" as const,
title: "MotherShip",
summary: msCredSummary,
},
] as const
).map(({ key, title, summary }) => (
<div
key={key}
className="space-y-3 rounded-md border border-border p-4"
>
<div className="flex items-center justify-between gap-2">
<p className="text-sm font-medium">{title}</p>
<span className="text-xs text-text-secondary">
{summary?.has_password ? "已配置" : "未配置"}
</span>
</div>
{summary?.has_password ? (
<>
<div>
<p className="text-xs text-text-secondary">登录邮箱</p>
<p className="mt-1 break-all font-mono text-sm">
{summary.email || "—"}
</p>
</div>
<div>
<div className="flex items-center justify-between gap-2">
<p className="text-xs text-text-secondary">密码</p>
<button
type="button"
className="text-xs text-[#1890FF] hover:underline"
onClick={() =>
setShowCredPassword((s) => ({
...s,
[key]: !s[key],
}))
}
>
{showCredPassword[key] ? "隐藏" : "显示"}
</button>
</div>
<p className="mt-1 break-all font-mono text-sm">
{showCredPassword[key]
? summary.password || "(无法解密,请核对 JWT_SECRET)"
: "••••••••"}
</p>
</div>
{summary.updated_at ? (
<p className="text-xs text-text-secondary">
更新时间 {formatDateTime(summary.updated_at)}
</p>
) : null}
</>
) : (
<p className="text-sm text-text-secondary">
未绑定官网账密(新建客户时可勾选写入)
</p>
)}
</div>
))}
<div className="flex justify-end">
<SecondaryButton
type="button"
onClick={() => setCredTarget(null)}
>
关闭
</SecondaryButton>
</div>
</div>
)}
</div>
</div>
)}
</AdminLayout>
);
}