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.

456 lines
15 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,
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 &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">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>
);
}