412 lines
12 KiB
TypeScript
412 lines
12 KiB
TypeScript
"use client";
|
||
|
||
import { useState } from "react";
|
||
import type {
|
||
AdminSnapshot,
|
||
AdminUserSummary,
|
||
SettingsSnapshot,
|
||
UserRole,
|
||
} from "@/lib/types";
|
||
|
||
interface SettingsResponse {
|
||
ok?: boolean;
|
||
error?: string;
|
||
settings?: SettingsSnapshot;
|
||
}
|
||
|
||
interface UserResponse {
|
||
ok?: boolean;
|
||
error?: string;
|
||
user?: AdminUserSummary;
|
||
}
|
||
|
||
interface SettingsFormState {
|
||
defaultMonthlyQuota: string;
|
||
}
|
||
|
||
interface CreateUserDraft {
|
||
username: string;
|
||
password: string;
|
||
role: UserRole;
|
||
monthlyQuota: string;
|
||
}
|
||
|
||
type EditableAdminUser = Omit<AdminUserSummary, "monthlyQuota"> & {
|
||
monthlyQuota: string | null;
|
||
passwordDraft: string;
|
||
};
|
||
|
||
function toEditableUser(user: AdminUserSummary): EditableAdminUser {
|
||
return {
|
||
...user,
|
||
monthlyQuota: user.monthlyQuota === null ? null : String(user.monthlyQuota),
|
||
passwordDraft: "",
|
||
};
|
||
}
|
||
|
||
function getErrorMessage(error: unknown, fallbackMessage: string): string {
|
||
return error instanceof Error ? error.message : fallbackMessage;
|
||
}
|
||
|
||
export default function AdminDashboard({
|
||
initialSnapshot,
|
||
}: {
|
||
initialSnapshot: AdminSnapshot;
|
||
}) {
|
||
const [settings, setSettings] = useState<SettingsFormState>({
|
||
defaultMonthlyQuota: String(initialSnapshot.settings.defaultMonthlyQuota),
|
||
});
|
||
const [users, setUsers] = useState<EditableAdminUser[]>(
|
||
initialSnapshot.users.map(toEditableUser),
|
||
);
|
||
const [feedback, setFeedback] = useState("");
|
||
const [errorMessage, setErrorMessage] = useState("");
|
||
const [isSavingSettings, setIsSavingSettings] = useState(false);
|
||
const [createDraft, setCreateDraft] = useState<CreateUserDraft>({
|
||
username: "",
|
||
password: "",
|
||
role: "user",
|
||
monthlyQuota: settings.defaultMonthlyQuota,
|
||
});
|
||
|
||
async function saveSettings(): Promise<void> {
|
||
setFeedback("");
|
||
setErrorMessage("");
|
||
setIsSavingSettings(true);
|
||
|
||
try {
|
||
const response = await fetch("/api/admin/settings", {
|
||
method: "PUT",
|
||
headers: {
|
||
"Content-Type": "application/json",
|
||
},
|
||
body: JSON.stringify({
|
||
defaultMonthlyQuota: settings.defaultMonthlyQuota,
|
||
}),
|
||
});
|
||
const payload = (await response.json()) as SettingsResponse;
|
||
|
||
if (!response.ok || payload.ok !== true || !payload.settings) {
|
||
throw new Error(payload.error || "保存默认额度失败。");
|
||
}
|
||
|
||
setSettings({
|
||
defaultMonthlyQuota: String(payload.settings.defaultMonthlyQuota),
|
||
});
|
||
setFeedback("默认额度已更新。");
|
||
} catch (error) {
|
||
setErrorMessage(getErrorMessage(error, "保存默认额度失败。"));
|
||
} finally {
|
||
setIsSavingSettings(false);
|
||
}
|
||
}
|
||
|
||
async function createUser(): Promise<void> {
|
||
setFeedback("");
|
||
setErrorMessage("");
|
||
|
||
try {
|
||
const response = await fetch("/api/admin/users", {
|
||
method: "POST",
|
||
headers: {
|
||
"Content-Type": "application/json",
|
||
},
|
||
body: JSON.stringify(createDraft),
|
||
});
|
||
const payload = (await response.json()) as UserResponse;
|
||
|
||
if (!response.ok || payload.ok !== true || !payload.user) {
|
||
throw new Error(payload.error || "创建用户失败。");
|
||
}
|
||
|
||
const createdUser = payload.user;
|
||
|
||
setUsers((currentUsers) => [
|
||
...currentUsers,
|
||
toEditableUser(createdUser),
|
||
]);
|
||
setCreateDraft({
|
||
username: "",
|
||
password: "",
|
||
role: "user",
|
||
monthlyQuota: settings.defaultMonthlyQuota,
|
||
});
|
||
setFeedback("用户已创建。");
|
||
} catch (error) {
|
||
setErrorMessage(getErrorMessage(error, "创建用户失败。"));
|
||
}
|
||
}
|
||
|
||
async function saveUser(user: EditableAdminUser): Promise<void> {
|
||
setFeedback("");
|
||
setErrorMessage("");
|
||
|
||
try {
|
||
const response = await fetch(`/api/admin/users/${user.id}`, {
|
||
method: "PATCH",
|
||
headers: {
|
||
"Content-Type": "application/json",
|
||
},
|
||
body: JSON.stringify({
|
||
role: user.role,
|
||
monthlyQuota: user.role === "admin" ? null : user.monthlyQuota,
|
||
isActive: user.isActive,
|
||
password: user.passwordDraft || undefined,
|
||
}),
|
||
});
|
||
const payload = (await response.json()) as UserResponse;
|
||
|
||
if (!response.ok || payload.ok !== true || !payload.user) {
|
||
throw new Error(payload.error || "保存用户失败。");
|
||
}
|
||
|
||
const savedUser = payload.user;
|
||
|
||
setUsers((currentUsers) =>
|
||
currentUsers.map((item) =>
|
||
item.id === user.id ? toEditableUser(savedUser) : item,
|
||
),
|
||
);
|
||
setFeedback(`已保存 ${savedUser.username}。`);
|
||
} catch (error) {
|
||
setErrorMessage(getErrorMessage(error, "保存用户失败。"));
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div className="admin-grid">
|
||
<section className="card settings-card">
|
||
<div className="section-heading">
|
||
<div>
|
||
<p className="section-kicker">Policy</p>
|
||
<h3>默认额度设置</h3>
|
||
</div>
|
||
<span className="subtle-pill">{initialSnapshot.periodKey}</span>
|
||
</div>
|
||
|
||
<label className="field">
|
||
<span>默认月额度</span>
|
||
<input
|
||
type="number"
|
||
min="0"
|
||
value={settings.defaultMonthlyQuota}
|
||
onChange={(event) =>
|
||
setSettings((current) => ({
|
||
...current,
|
||
defaultMonthlyQuota: event.target.value,
|
||
}))
|
||
}
|
||
/>
|
||
</label>
|
||
|
||
<p className="muted block-copy">
|
||
没有单独指定额度的普通用户,将继承这里的月额度。
|
||
</p>
|
||
|
||
<button
|
||
type="button"
|
||
className="primary-button"
|
||
onClick={saveSettings}
|
||
disabled={isSavingSettings}
|
||
>
|
||
{isSavingSettings ? "保存中..." : "保存默认额度"}
|
||
</button>
|
||
</section>
|
||
|
||
<section className="card create-user-card">
|
||
<div className="section-heading">
|
||
<div>
|
||
<p className="section-kicker">Users</p>
|
||
<h3>创建用户</h3>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="form-grid">
|
||
<label className="field">
|
||
<span>用户名</span>
|
||
<input
|
||
type="text"
|
||
value={createDraft.username}
|
||
onChange={(event) =>
|
||
setCreateDraft((current) => ({
|
||
...current,
|
||
username: event.target.value,
|
||
}))
|
||
}
|
||
/>
|
||
</label>
|
||
|
||
<label className="field">
|
||
<span>密码</span>
|
||
<input
|
||
type="password"
|
||
value={createDraft.password}
|
||
onChange={(event) =>
|
||
setCreateDraft((current) => ({
|
||
...current,
|
||
password: event.target.value,
|
||
}))
|
||
}
|
||
/>
|
||
</label>
|
||
|
||
<label className="field">
|
||
<span>角色</span>
|
||
<select
|
||
value={createDraft.role}
|
||
onChange={(event) =>
|
||
setCreateDraft((current) => ({
|
||
...current,
|
||
role: event.target.value as UserRole,
|
||
}))
|
||
}
|
||
>
|
||
<option value="user">普通用户</option>
|
||
<option value="admin">管理员</option>
|
||
</select>
|
||
</label>
|
||
|
||
<label className="field">
|
||
<span>月额度</span>
|
||
<input
|
||
type="number"
|
||
min="0"
|
||
value={createDraft.monthlyQuota}
|
||
onChange={(event) =>
|
||
setCreateDraft((current) => ({
|
||
...current,
|
||
monthlyQuota: event.target.value,
|
||
}))
|
||
}
|
||
/>
|
||
</label>
|
||
</div>
|
||
|
||
<button type="button" className="primary-button" onClick={createUser}>
|
||
创建用户
|
||
</button>
|
||
</section>
|
||
|
||
{(feedback || errorMessage) && (
|
||
<div className={errorMessage ? "error-banner" : "success-banner"}>
|
||
{errorMessage || feedback}
|
||
</div>
|
||
)}
|
||
|
||
<section className="card user-table-card">
|
||
<div className="section-heading">
|
||
<div>
|
||
<p className="section-kicker">Manage</p>
|
||
<h3>用户管理</h3>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="user-table">
|
||
<div className="user-table-head">
|
||
<span>用户</span>
|
||
<span>角色</span>
|
||
<span>额度</span>
|
||
<span>本月已用</span>
|
||
<span>状态</span>
|
||
<span>重置密码</span>
|
||
<span>操作</span>
|
||
</div>
|
||
|
||
{users.map((user) => (
|
||
<div key={user.id} className="user-table-row">
|
||
<div className="user-cell user-cell-name">
|
||
<strong>{user.username}</strong>
|
||
<small>
|
||
{user.isUnlimited ? "无限额" : `剩余 ${user.remaining}`}
|
||
</small>
|
||
</div>
|
||
|
||
<select
|
||
value={user.role}
|
||
onChange={(event) =>
|
||
setUsers((currentUsers) =>
|
||
currentUsers.map((item) =>
|
||
item.id === user.id
|
||
? { ...item, role: event.target.value as UserRole }
|
||
: item,
|
||
),
|
||
)
|
||
}
|
||
>
|
||
<option value="user">普通用户</option>
|
||
<option value="admin">管理员</option>
|
||
</select>
|
||
|
||
<input
|
||
type="number"
|
||
min="0"
|
||
value={user.monthlyQuota ?? ""}
|
||
placeholder={String(settings.defaultMonthlyQuota)}
|
||
onChange={(event) =>
|
||
setUsers((currentUsers) =>
|
||
currentUsers.map((item) =>
|
||
item.id === user.id
|
||
? {
|
||
...item,
|
||
monthlyQuota:
|
||
event.target.value === ""
|
||
? null
|
||
: event.target.value,
|
||
}
|
||
: item,
|
||
),
|
||
)
|
||
}
|
||
/>
|
||
|
||
<span>{user.usedThisPeriod}</span>
|
||
|
||
<select
|
||
value={String(user.isActive)}
|
||
onChange={(event) =>
|
||
setUsers((currentUsers) =>
|
||
currentUsers.map((item) =>
|
||
item.id === user.id
|
||
? {
|
||
...item,
|
||
isActive: event.target.value === "true",
|
||
}
|
||
: item,
|
||
),
|
||
)
|
||
}
|
||
>
|
||
<option value="true">启用</option>
|
||
<option value="false">禁用</option>
|
||
</select>
|
||
|
||
<input
|
||
type="password"
|
||
value={user.passwordDraft}
|
||
placeholder="留空则不修改"
|
||
onChange={(event) =>
|
||
setUsers((currentUsers) =>
|
||
currentUsers.map((item) =>
|
||
item.id === user.id
|
||
? { ...item, passwordDraft: event.target.value }
|
||
: item,
|
||
),
|
||
)
|
||
}
|
||
/>
|
||
|
||
<button
|
||
type="button"
|
||
className="ghost-button"
|
||
onClick={() => saveUser(user)}
|
||
>
|
||
保存
|
||
</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</section>
|
||
</div>
|
||
);
|
||
}
|