|
|
"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,
|
|
|
adminRotateCustomerKey,
|
|
|
adminUpdateCustomer,
|
|
|
} 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 [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 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 handleCreate = async () => {
|
|
|
if (!token) return;
|
|
|
const name = createName.trim();
|
|
|
if (!name) {
|
|
|
setCreateError("请填写客户名称");
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
setCreating(true);
|
|
|
setCreateError(undefined);
|
|
|
const res = await adminCreateCustomer("", token, {
|
|
|
name,
|
|
|
remark: createRemark.trim() || undefined,
|
|
|
embed_password: createPassword.trim() || undefined,
|
|
|
});
|
|
|
setCreating(false);
|
|
|
|
|
|
if (res.code !== 0) {
|
|
|
setCreateError(res.message);
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
setCreateOpen(false);
|
|
|
setCreateName("");
|
|
|
setCreateRemark("");
|
|
|
setCreatePassword("123456");
|
|
|
setRevealedKey({
|
|
|
customer_id: res.data.customer_id,
|
|
|
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 totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
|
|
const activeKeyPrefix = (row: HostCustomerRecord) =>
|
|
|
row.api_keys.find((k) => k.is_active)?.key_prefix ?? "—";
|
|
|
|
|
|
return (
|
|
|
<AdminLayout>
|
|
|
<PageHeader
|
|
|
title="客户管理"
|
|
|
subtitle="在管理端创建客户并签发 API Key,无需修改服务器 .env"
|
|
|
action={
|
|
|
<PrimaryButton onClick={() => 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">
|
|
|
调用方式: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">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">
|
|
|
{activeKeyPrefix(row)}…
|
|
|
</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 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-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">
|
|
|
创建后将自动生成 API Key;可设置宿主嵌入演示登录密码(默认 123456)。
|
|
|
</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)}
|
|
|
/>
|
|
|
</div>
|
|
|
<div className="mt-8 flex justify-end gap-3">
|
|
|
<SecondaryButton
|
|
|
disabled={creating}
|
|
|
onClick={() => setCreateOpen(false)}
|
|
|
>
|
|
|
取消
|
|
|
</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>
|
|
|
)}
|
|
|
</AdminLayout>
|
|
|
);
|
|
|
}
|