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.

759 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无需修改服务器 .env"
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">
KeyAuthorization:
{" "}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>
</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>
);
}