227 lines
8.0 KiB
TypeScript
227 lines
8.0 KiB
TypeScript
import { useEffect, useState } from 'react';
|
||
import { Link, Navigate, useParams } from 'react-router-dom';
|
||
import { getAdminUser, rechargeUser, updateAdminUser } from '../../api/client';
|
||
import { CapabilitySettings } from '../../components/CapabilitySettings';
|
||
import { PolicySettings } from '../../components/PolicySettings';
|
||
import { SkillSettings } from '../../components/SkillSettings';
|
||
import { useAdminUsers } from '../hooks/useAdminUsers';
|
||
import { formatYuan } from '../utils/format';
|
||
import type { PortalUser } from '../../types';
|
||
|
||
function formatBytes(bytes: number) {
|
||
if (bytes < 1024) return `${bytes} B`;
|
||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
||
}
|
||
|
||
export function UserDetailPage() {
|
||
const { userId = '' } = useParams();
|
||
const { users, reload, setError } = useAdminUsers();
|
||
const [user, setUser] = useState<PortalUser | null>(null);
|
||
const [loading, setLoading] = useState(true);
|
||
const [error, setLocalError] = useState<string | null>(null);
|
||
const [message, setMessage] = useState<string | null>(null);
|
||
const [recharge, setRecharge] = useState({ amountYuan: '10', note: '' });
|
||
const [spaceQuotaMb, setSpaceQuotaMb] = useState('5');
|
||
|
||
useEffect(() => {
|
||
if (!user?.spaceQuotaBytes) return;
|
||
setSpaceQuotaMb(String(Math.max(1, Math.round(user.spaceQuotaBytes / 1024 / 1024))));
|
||
}, [user?.spaceQuotaBytes]);
|
||
|
||
useEffect(() => {
|
||
let cancelled = false;
|
||
if (!userId) return;
|
||
setLoading(true);
|
||
setLocalError(null);
|
||
void getAdminUser(userId)
|
||
.then((nextUser) => {
|
||
if (cancelled) return;
|
||
setUser(nextUser);
|
||
})
|
||
.catch((err) => {
|
||
if (cancelled) return;
|
||
const fallbackUser = users.find((row) => row.id === userId) ?? null;
|
||
if (fallbackUser) {
|
||
setUser(fallbackUser);
|
||
setLocalError(null);
|
||
return;
|
||
}
|
||
setLocalError(err instanceof Error ? err.message : '加载用户失败');
|
||
})
|
||
.finally(() => {
|
||
if (!cancelled) setLoading(false);
|
||
});
|
||
return () => {
|
||
cancelled = true;
|
||
};
|
||
}, [userId, users]);
|
||
|
||
const handleRecharge = async (e: React.FormEvent) => {
|
||
e.preventDefault();
|
||
if (!user) return;
|
||
setMessage(null);
|
||
setLocalError(null);
|
||
setError(null);
|
||
try {
|
||
const amountCents = Math.round(Number(recharge.amountYuan) * 100);
|
||
const nextUser = await rechargeUser(user.id, amountCents, recharge.note || undefined);
|
||
setUser(nextUser);
|
||
setMessage('充值成功');
|
||
setRecharge({ amountYuan: '10', note: '' });
|
||
await reload();
|
||
} catch (err) {
|
||
setLocalError(err instanceof Error ? err.message : '充值失败');
|
||
}
|
||
};
|
||
|
||
if (!loading && !user && !error) {
|
||
return <Navigate to="/users" replace />;
|
||
}
|
||
|
||
const currentQuotaMb = Math.max(1, Math.round((user?.spaceQuotaBytes ?? 0) / 1024 / 1024));
|
||
|
||
const handleSpaceQuotaSave = async (e: React.FormEvent) => {
|
||
e.preventDefault();
|
||
if (!user) return;
|
||
setMessage(null);
|
||
setLocalError(null);
|
||
setError(null);
|
||
try {
|
||
const quotaMb = Math.floor(Number(spaceQuotaMb));
|
||
const nextUser = await updateAdminUser(user.id, {
|
||
spaceQuotaBytes: quotaMb * 1024 * 1024,
|
||
});
|
||
setUser(nextUser);
|
||
setMessage('空间已更新');
|
||
await reload();
|
||
} catch (err) {
|
||
setLocalError(err instanceof Error ? err.message : '空间更新失败');
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div className="admin-page">
|
||
<div className="admin-page-head">
|
||
<Link to="/users" className="ghost-btn admin-back-inline">
|
||
← 返回用户列表
|
||
</Link>
|
||
<h2>{user?.displayName ?? '用户详情'}</h2>
|
||
{user && <p className="muted">@{user.username} · {user.role} · {user.status}</p>}
|
||
</div>
|
||
|
||
{error && <p className="banner banner-error">{error}</p>}
|
||
{message && <p className="banner banner-info">{message}</p>}
|
||
|
||
{loading || !user ? (
|
||
<p className="muted">加载中…</p>
|
||
) : (
|
||
<>
|
||
<section className="admin-card">
|
||
<div className="admin-card-head">
|
||
<h2>账户信息</h2>
|
||
{user.role === 'user' && (
|
||
<button
|
||
type="button"
|
||
className="ghost-btn"
|
||
onClick={() => {
|
||
setLocalError(null);
|
||
void updateAdminUser(user.id, {
|
||
status: user.status === 'active' ? 'disabled' : 'active',
|
||
}).then((nextUser) => {
|
||
setUser(nextUser);
|
||
void reload();
|
||
}).catch((err) => {
|
||
setLocalError(err instanceof Error ? err.message : '状态更新失败');
|
||
});
|
||
}}
|
||
>
|
||
{user.status === 'active' ? '禁用' : '启用'}
|
||
</button>
|
||
)}
|
||
</div>
|
||
<dl className="admin-dl">
|
||
<div>
|
||
<dt>余额</dt>
|
||
<dd>¥{formatYuan(user.balanceCents)}</dd>
|
||
</div>
|
||
<div>
|
||
<dt>空间配额</dt>
|
||
<dd>
|
||
{user.spaceQuotaBytes ? formatBytes(user.spaceQuotaBytes) : '—'}
|
||
{user.spaceUsedBytes !== undefined && (
|
||
<span className="muted">
|
||
{' '}
|
||
· 已用 {formatBytes(user.spaceUsedBytes)} · 剩余{' '}
|
||
{formatBytes(user.spaceAvailableBytes ?? 0)}
|
||
</span>
|
||
)}
|
||
</dd>
|
||
</div>
|
||
<div>
|
||
<dt>工作目录</dt>
|
||
<dd className="mono">{user.workspaceRoot}</dd>
|
||
</div>
|
||
</dl>
|
||
</section>
|
||
|
||
{user.role === 'user' && (
|
||
<>
|
||
<section className="admin-card">
|
||
<h2>充值</h2>
|
||
<form className="admin-form" onSubmit={handleRecharge}>
|
||
<input
|
||
placeholder="金额(元)"
|
||
value={recharge.amountYuan}
|
||
onChange={(e) => setRecharge((s) => ({ ...s, amountYuan: e.target.value }))}
|
||
/>
|
||
<input
|
||
placeholder="备注"
|
||
value={recharge.note}
|
||
onChange={(e) => setRecharge((s) => ({ ...s, note: e.target.value }))}
|
||
/>
|
||
<button type="submit" className="send-btn">
|
||
充值
|
||
</button>
|
||
</form>
|
||
</section>
|
||
|
||
<section className="admin-card">
|
||
<h2>调整空间</h2>
|
||
<p className="muted">
|
||
当前默认按 MB 设置空间总额度。用户前台购买会在此基础上继续累加。
|
||
</p>
|
||
<form className="admin-form" onSubmit={handleSpaceQuotaSave}>
|
||
<input
|
||
placeholder="空间总额(MB)"
|
||
type="number"
|
||
min="1"
|
||
value={spaceQuotaMb}
|
||
onChange={(e) => setSpaceQuotaMb(e.target.value)}
|
||
/>
|
||
<button type="submit" className="send-btn">
|
||
保存空间
|
||
</button>
|
||
</form>
|
||
<p className="muted">当前总额约 {currentQuotaMb} MB。</p>
|
||
</section>
|
||
|
||
<CapabilitySettings users={users} userId={user.id} userOnly />
|
||
<SkillSettings users={users} userId={user.id} userOnly />
|
||
<PolicySettings users={users} userId={user.id} userOnly />
|
||
</>
|
||
)}
|
||
|
||
{user.role === 'admin' && (
|
||
<section className="admin-card">
|
||
<p className="muted">
|
||
管理员账号拥有 goose 全权限:不受能力、技能、策略、计费与路径限制,会话以 auto 模式运行。
|
||
</p>
|
||
</section>
|
||
)}
|
||
</>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|