299 lines
12 KiB
TypeScript
299 lines
12 KiB
TypeScript
import { ActionForm } from "@/components/action-form";
|
|
import { CopyButton } from "@/components/copy-button";
|
|
import { FilterList } from "@/components/filter-list";
|
|
import { Input } from "@/components/ui/input";
|
|
import { SubmitButton } from "@/components/submit-button";
|
|
import { getRequestOrigin } from "@/lib/request-origin";
|
|
import { formatLocalDateTime } from "@/lib/datetime";
|
|
import {
|
|
buildSubscriptionUrl,
|
|
describeRemainingTraffic,
|
|
} from "@/lib/subscription";
|
|
import { formatBytes, listUsers, toDatetimeLocalValue } from "@/lib/store";
|
|
|
|
import {
|
|
createUserAction,
|
|
deleteUserAction,
|
|
resetUserPasswordAction,
|
|
updateUserAction,
|
|
} from "./actions";
|
|
|
|
function getUserPills(user: {
|
|
enabled: number;
|
|
is_admin: number;
|
|
expires_at: string | null;
|
|
}) {
|
|
return [
|
|
{
|
|
label: user.enabled ? "已启用" : "已禁用",
|
|
className: `status-pill ${user.enabled ? "status-pill--ok" : "status-pill--danger"}`,
|
|
},
|
|
user.is_admin
|
|
? { label: "管理员", className: "status-pill status-pill--ink" }
|
|
: null,
|
|
user.expires_at
|
|
? {
|
|
label: formatLocalDateTime(user.expires_at),
|
|
className: "status-pill status-pill--neutral",
|
|
}
|
|
: { label: "长期有效", className: "status-pill status-pill--warm" },
|
|
].filter((pill): pill is { label: string; className: string } =>
|
|
Boolean(pill),
|
|
);
|
|
}
|
|
|
|
export default async function UsersPage() {
|
|
const [users, origin] = await Promise.all([listUsers(), getRequestOrigin()]);
|
|
|
|
return (
|
|
<main className="page-stack management-page">
|
|
<section className="hero-panel">
|
|
<div className="hero-copy">
|
|
<span className="eyebrow">用户</span>
|
|
<h2>用户管理</h2>
|
|
<div className="badge-row">
|
|
<span className="status-pill status-pill--ok">
|
|
启用 {users.filter((user) => user.enabled).length}
|
|
</span>
|
|
<span className="status-pill status-pill--neutral">
|
|
总数 {users.length}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<section className="panel-card compact-create-panel">
|
|
<details>
|
|
<summary className="compact-create-summary">
|
|
<div>
|
|
<h3>新建用户</h3>
|
|
</div>
|
|
<span className="status-pill status-pill--warm">展开</span>
|
|
</summary>
|
|
<ActionForm
|
|
action={createUserAction}
|
|
className="dense-form-grid dense-form-grid--users"
|
|
>
|
|
<label className="field">
|
|
<span>用户名</span>
|
|
<Input name="username" required />
|
|
</label>
|
|
<label className="field">
|
|
<span>密码</span>
|
|
<Input name="password" type="password" required />
|
|
</label>
|
|
<label className="field">
|
|
<span>到期时间</span>
|
|
<Input name="expires_at" type="datetime-local" />
|
|
</label>
|
|
<label className="field">
|
|
<span>总流量上限 GiB</span>
|
|
<Input
|
|
name="traffic_limit_gib"
|
|
type="number"
|
|
min="0"
|
|
step="0.1"
|
|
/>
|
|
</label>
|
|
<label className="field dense-field-wide">
|
|
<span>备注</span>
|
|
<textarea name="notes" rows={2} />
|
|
</label>
|
|
<label className="checkbox">
|
|
<input name="enabled" type="checkbox" defaultChecked />
|
|
<span>立即启用</span>
|
|
</label>
|
|
<SubmitButton variant="default">创建用户</SubmitButton>
|
|
</ActionForm>
|
|
</details>
|
|
</section>
|
|
|
|
<section className="panel-card dense-panel">
|
|
<div className="section-title">
|
|
<h3>用户列表</h3>
|
|
<span className="status-pill status-pill--neutral">
|
|
{users.length} 个账户
|
|
</span>
|
|
</div>
|
|
<FilterList
|
|
className="entity-table entity-table--users"
|
|
placeholder="搜索用户名、ID、备注…"
|
|
statuses={[
|
|
{ value: "enabled", label: "已启用" },
|
|
{ value: "disabled", label: "已禁用" },
|
|
]}
|
|
header={
|
|
<div className="entity-table__head user-row-grid">
|
|
<span>用户</span>
|
|
<span>状态</span>
|
|
<span>用量</span>
|
|
<span>剩余额度</span>
|
|
<span>在线</span>
|
|
<span>操作</span>
|
|
</div>
|
|
}
|
|
items={users.map((user) => {
|
|
const usedBytes = user.used_tx_bytes + user.used_rx_bytes;
|
|
const subscriptionUrl = user.subscription_token
|
|
? buildSubscriptionUrl(user.subscription_token, origin)
|
|
: "暂不可用";
|
|
|
|
return {
|
|
id: user.id,
|
|
text: `${user.username} ${user.auth_id} ${user.notes}`,
|
|
status: user.enabled ? "enabled" : "disabled",
|
|
content: (
|
|
<details key={user.id} className="entity-table__item">
|
|
<summary className="entity-table__summary user-row-grid">
|
|
<span className="entity-main-cell">
|
|
<strong>{user.username}</strong>
|
|
<span className="muted mono break-all">
|
|
{user.auth_id}
|
|
</span>
|
|
</span>
|
|
<span className="badge-row entity-status-cell">
|
|
{getUserPills(user).map((pill) => (
|
|
<span key={pill.label} className={pill.className}>
|
|
{pill.label}
|
|
</span>
|
|
))}
|
|
</span>
|
|
<span className="entity-number-cell">
|
|
<strong>{formatBytes(usedBytes)}</strong>
|
|
<span>
|
|
TX {formatBytes(user.used_tx_bytes)} / RX{" "}
|
|
{formatBytes(user.used_rx_bytes)}
|
|
</span>
|
|
</span>
|
|
<span className="entity-number-cell">
|
|
<strong>{describeRemainingTraffic(user)}</strong>
|
|
<span>
|
|
{user.traffic_limit_bytes ? "有额度" : "不限量"}
|
|
</span>
|
|
</span>
|
|
<span className="entity-number-cell">
|
|
<strong>{user.online_connections}</strong>
|
|
<span>连接</span>
|
|
</span>
|
|
<span className="entity-expand-label">
|
|
<span className="when-closed">编辑</span>
|
|
<span className="when-open">收起</span>
|
|
</span>
|
|
</summary>
|
|
<div className="entity-table__details">
|
|
<div className="dense-code-line">
|
|
<span>订阅链接</span>
|
|
<div className="copy-line">
|
|
<code>{subscriptionUrl}</code>
|
|
{user.subscription_token && (
|
|
<CopyButton value={subscriptionUrl} />
|
|
)}
|
|
</div>
|
|
</div>
|
|
<ActionForm
|
|
action={updateUserAction}
|
|
className="dense-form-grid dense-form-grid--users"
|
|
>
|
|
<input name="id" type="hidden" value={user.id} />
|
|
<label className="field">
|
|
<span>用户名</span>
|
|
<Input
|
|
name="username"
|
|
defaultValue={user.username}
|
|
required
|
|
/>
|
|
</label>
|
|
<label className="field">
|
|
<span>到期时间</span>
|
|
<Input
|
|
name="expires_at"
|
|
type="datetime-local"
|
|
defaultValue={toDatetimeLocalValue(user.expires_at)}
|
|
/>
|
|
</label>
|
|
<label className="field">
|
|
<span>总流量上限 GiB</span>
|
|
<Input
|
|
name="traffic_limit_gib"
|
|
type="number"
|
|
min="0"
|
|
step="0.1"
|
|
defaultValue={
|
|
user.traffic_limit_bytes && !user.is_admin
|
|
? (
|
|
user.traffic_limit_bytes /
|
|
1024 /
|
|
1024 /
|
|
1024
|
|
).toFixed(2)
|
|
: ""
|
|
}
|
|
disabled={Boolean(user.is_admin)}
|
|
/>
|
|
</label>
|
|
<label className="field">
|
|
<span>订阅名称</span>
|
|
<Input
|
|
name="subscription_label"
|
|
defaultValue={
|
|
user.subscription_label || user.username
|
|
}
|
|
/>
|
|
</label>
|
|
<label className="field dense-field-wide">
|
|
<span>备注</span>
|
|
<textarea
|
|
name="notes"
|
|
rows={2}
|
|
defaultValue={user.notes}
|
|
/>
|
|
</label>
|
|
<label className="checkbox">
|
|
<input
|
|
name="enabled"
|
|
type="checkbox"
|
|
defaultChecked={Boolean(user.enabled)}
|
|
/>
|
|
<span>启用用户</span>
|
|
</label>
|
|
<SubmitButton variant="outline">保存修改</SubmitButton>
|
|
</ActionForm>
|
|
<div className="entity-action-strip">
|
|
<ActionForm
|
|
action={resetUserPasswordAction}
|
|
className="inline-form inline-form--compact"
|
|
>
|
|
<input name="id" type="hidden" value={user.id} />
|
|
<Input
|
|
name="password"
|
|
type="password"
|
|
aria-label="新密码"
|
|
autoComplete="new-password"
|
|
placeholder="新密码"
|
|
required
|
|
/>
|
|
<SubmitButton variant="outline">重置密码</SubmitButton>
|
|
</ActionForm>
|
|
{user.is_admin ? null : (
|
|
<ActionForm action={deleteUserAction}>
|
|
<input name="id" type="hidden" value={user.id} />
|
|
<SubmitButton
|
|
variant="destructive"
|
|
confirm={`将永久删除用户「${user.username}」,此用户将无法继续连接。`}
|
|
>
|
|
删除用户
|
|
</SubmitButton>
|
|
</ActionForm>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</details>
|
|
),
|
|
};
|
|
})}
|
|
/>
|
|
</section>
|
|
</main>
|
|
);
|
|
}
|