|
|
"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">
|
|
|
已保存到客户资料,可随时查看当前有效 Key。调用方式:Authorization:
|
|
|
{" "}Bearer <Key>;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>
|
|
|
);
|
|
|
}
|