summaryrefslogtreecommitdiff
path: root/frontend/src/pages/AdminPage.svelte
diff options
context:
space:
mode:
Diffstat (limited to 'frontend/src/pages/AdminPage.svelte')
-rw-r--r--frontend/src/pages/AdminPage.svelte237
1 files changed, 122 insertions, 115 deletions
diff --git a/frontend/src/pages/AdminPage.svelte b/frontend/src/pages/AdminPage.svelte
index 984962d..d9bea83 100644
--- a/frontend/src/pages/AdminPage.svelte
+++ b/frontend/src/pages/AdminPage.svelte
@@ -12,11 +12,20 @@
postAdminUserPassword
} from '../lib/client';
import { extractStatusMessage } from '../lib/state';
+ import { Button } from '$lib/components/ui/button';
+ import { Input } from '$lib/components/ui/input';
+ import { Alert } from '$lib/components/ui/alert';
+ import { Badge } from '$lib/components/ui/badge';
+ import { Label } from '$lib/components/ui/label';
+ import * as Tabs from '$lib/components/ui/tabs';
+ import * as Card from '$lib/components/ui/card';
+ import * as Table from '$lib/components/ui/table';
import { fmtDateTime } from '../lib/format';
import type { User, Invitation } from '../lib/types';
let tab = $state<'users' | 'invites' | 'audit'>('users');
+
let users: User[] = $state([]);
let usersBusy = $state(true);
let usersErr = $state<string | null>(null);
@@ -167,39 +176,41 @@
<Page title="管理员" subtitle="用户与邀请管理、安全审计。不会直接查看他人策略代码">
{#snippet actions()}
- <div class="tabs" role="tablist">
- <button role="tab" aria-selected={tab === 'users'} onclick={() => (tab = 'users')}>用户</button>
- <button role="tab" aria-selected={tab === 'invites'} onclick={() => (tab = 'invites')}>邀请与恢复</button>
- <button role="tab" aria-selected={tab === 'audit'} onclick={() => (tab = 'audit')}>审计日志</button>
- </div>
+ <Tabs.Root value={tab} onValueChange={(v) => (tab = v as 'users' | 'invites' | 'audit')}>
+ <Tabs.List variant="line">
+ <Tabs.Trigger value="users">用户</Tabs.Trigger>
+ <Tabs.Trigger value="invites">邀请与恢复</Tabs.Trigger>
+ <Tabs.Trigger value="audit">审计日志</Tabs.Trigger>
+ </Tabs.List>
+ </Tabs.Root>
{/snippet}
<div class="stack">
<Status busy={false} error={adminOpsErr} empty={null} />
{#if tab === 'users'}
- <section class="card stack">
+ <Card.Root size="sm" class="stack-sec">
<h2>用户</h2>
<Status busy={usersBusy} error={usersErr} busyText="载入用户…" empty={null} />
{#if !usersBusy && !usersErr && users.length > 0}
- <table class="data">
- <thead>
- <tr>
- <th>邮箱</th>
- <th>姓名</th>
- <th>角色</th>
- <th>状态</th>
- <th>AI 权限</th>
- <th>日回测上限</th>
- <th>操作</th>
- </tr>
- </thead>
- <tbody>
+ <Table.Root>
+ <Table.Header>
+ <Table.Row>
+ <Table.Head>邮箱</Table.Head>
+ <Table.Head>姓名</Table.Head>
+ <Table.Head>角色</Table.Head>
+ <Table.Head>状态</Table.Head>
+ <Table.Head>AI 权限</Table.Head>
+ <Table.Head>日回测上限</Table.Head>
+ <Table.Head>操作</Table.Head>
+ </Table.Row>
+ </Table.Header>
+ <Table.Body>
{#each users as u (u.id)}
- <tr>
- <td>{u.email}</td>
- <td>{u.name}</td>
- <td>
+ <Table.Row>
+ <Table.Cell>{u.email}</Table.Cell>
+ <Table.Cell>{u.name}</Table.Cell>
+ <Table.Cell>
<select
value={u.role}
aria-label={`角色 ${u.email}`}
@@ -208,162 +219,158 @@
<option value="member" selected={u.role === 'member'}>成员</option>
<option value="admin" selected={u.role === 'admin'}>管理员</option>
</select>
- </td>
- <td>
- <span class="badge {u.active ? 'ok' : 'danger'}">{u.active ? '启用' : '停用'}</span>
- </td>
- <td>
+ </Table.Cell>
+ <Table.Cell>
+ <Badge variant={u.active ? 'secondary' : 'destructive'}>{u.active ? '启用' : '停用'}</Badge>
+ </Table.Cell>
+ <Table.Cell>
<label class="checkline">
<input type="checkbox" checked={u.ai_enabled ?? false} onchange={(e) => void patchUser(u, { ai_enabled: (e.target as HTMLInputElement).checked })} />
</label>
- </td>
- <td>
- <input
- class="num"
- style="width:90px"
+ </Table.Cell>
+ <Table.Cell>
+ <Input
+ class="num w-[90px]"
type="number"
min="0"
max="200"
value={u.daily_run_limit ?? 5}
onchange={(e) => void patchUser(u, { daily_run_limit: Number((e.target as HTMLInputElement).value) })}
/>
- </td>
- <td>
+ </Table.Cell>
+ <Table.Cell>
<div class="row">
- <button
- class="btn small {u.active ? 'danger' : ''}"
- onclick={() => void patchUser(u, { active: !u.active })}
- >
+ <Button variant={u.active ? 'destructive' : 'outline'} size="sm" onclick={() => void patchUser(u, { active: !u.active })}>
{u.active ? '停用' : '启用'}
- </button>
- <button class="btn small" disabled={resetting} onclick={() => void issueReset(u)}>生成恢复</button>
+ </Button>
+ <Button variant="outline" size="sm" disabled={resetting} onclick={() => void issueReset(u)}>生成恢复</Button>
</div>
- </td>
- </tr>
+ </Table.Cell>
+ </Table.Row>
{/each}
- </tbody>
- </table>
+ </Table.Body>
+ </Table.Root>
{/if}
<p class="hint">停用账户会立即吊销其会话并阻止其回测与 AI 请求。角色不能作用于他人项目,私阅策略代码不提供。</p>
- </section>
+ </Card.Root>
{:else if tab === 'invites'}
- <section class="card stack">
+ <Card.Root size="sm" class="stack-sec">
<h2>签发邀请</h2>
<form onsubmit={makeInvite} class="row">
- <label style="flex:1 1 220px">
- 邮箱(可选备注)
- <input bind:value={inviteEmail} placeholder="📬 将线下告知受邀人,用于区分" />
- </label>
- <label style="flex:0 0 130px">
- 有效小时
- <input type="number" bind:value={inviteHours} min="1" max="720" />
- </label>
- <button class="btn primary" disabled={creatingInvite}>{creatingInvite ? '生成中…' : '生成邀请'}</button>
+ <div style="flex:1 1 220px">
+ <Label for="inv-em">邮箱(可选备注)</Label>
+ <Input id="inv-em" bind:value={inviteEmail} placeholder="📬 将线下告知受邀人,用于区分" />
+ </div>
+ <div style="flex:0 0 130px">
+ <Label for="inv-h">有效小时</Label>
+ <Input id="inv-h" type="number" bind:value={inviteHours} min="1" max="720" />
+ </div>
+ <Button disabled={creatingInvite}>{creatingInvite ? '生成中…' : '生成邀请'}</Button>
</form>
<Status busy={false} error={inviteErr} empty={null} />
{#if newInvite}
- <div class="banner info">
+ <Alert variant="default" class="bg-[var(--brand-soft)] border-[#bfe9db]">
<div>
<div class="cov-caption">邀请令牌(只显示这一次,请立即复制):</div>
<div class="mono-invite">{newInvite.token}</div>
<div class="hint">到期:{fmtDateTime(newInvite.expires_at)} · 单次使用 · 注册页链接:</div>
<div class="mono-invite" title="复制该完整链接给对方">{inviteUrl}</div>
- <button class="btn small" onclick={() => void copy(inviteUrl)}>{inviteUrl && inviteCopied ? '已复制' : '复制链接'}</button>
+ <Button variant="outline" size="sm" class="mt-1" onclick={() => void copy(inviteUrl)}>{inviteUrl && inviteCopied ? '已复制' : '复制链接'}</Button>
</div>
- </div>
+ </Alert>
{/if}
- </section>
+ </Card.Root>
- <section class="card stack">
+ <Card.Root size="sm" class="stack-sec">
<h2>历史邀请</h2>
<Status busy={inviteBusy} error={inviteErr} busyText="载入邀请…" empty={null} />
{#if !inviteBusy && !inviteErr && invites.length === 0}
<Status busy={false} empty="还没有邀请记录" />
{/if}
{#if !inviteBusy && invites.length > 0}
- <table class="data">
- <thead>
- <tr>
- <th>邮箱备注</th>
- <th>角色</th>
- <th>创建时间</th>
- <th>过期</th>
- <th>状态</th>
- <th></th>
- </tr>
- </thead>
- <tbody>
+ <Table.Root>
+ <Table.Header>
+ <Table.Row>
+ <Table.Head>邮箱备注</Table.Head>
+ <Table.Head>角色</Table.Head>
+ <Table.Head>创建时间</Table.Head>
+ <Table.Head>过期</Table.Head>
+ <Table.Head>状态</Table.Head>
+ <Table.Head></Table.Head>
+ </Table.Row>
+ </Table.Header>
+ <Table.Body>
{#each invites as inv (inv.id)}
- <tr>
- <td>{inv.email ?? '—'}</td>
- <td>{inv.role === 'admin' ? '管理员' : '成员'}</td>
- <td>{fmtDateTime(inv.created_at)}</td>
- <td>{fmtDateTime(inv.expires_at)}</td>
- <td>
- {#if inv.used_at}<span class="badge grey">已使用</span>
- {:else if inv.revoked}<span class="badge grey">已撤销</span>
- {:else if inv.expires_at && new Date(inv.expires_at) < new Date()}<span class="badge grey">已过期</span>
- {:else}<span class="badge teal">有效</span>
+ <Table.Row>
+ <Table.Cell>{inv.email ?? '—'}</Table.Cell>
+ <Table.Cell>{inv.role === 'admin' ? '管理员' : '成员'}</Table.Cell>
+ <Table.Cell>{fmtDateTime(inv.created_at)}</Table.Cell>
+ <Table.Cell>{fmtDateTime(inv.expires_at)}</Table.Cell>
+ <Table.Cell>
+ {#if inv.used_at}<Badge variant="outline">已使用</Badge>
+ {:else if inv.revoked}<Badge variant="outline">已撤销</Badge>
+ {:else if inv.expires_at && new Date(inv.expires_at) < new Date()}<Badge variant="outline">已过期</Badge>
+ {:else}<Badge variant="secondary">有效</Badge>
{/if}
- </td>
- <td>
+ </Table.Cell>
+ <Table.Cell>
{#if !inv.used_at && !inv.revoked}
- <button class="btn small danger" onclick={() => void revokeInvite(inv)}>撤销</button>
+ <Button variant="destructive" size="sm" onclick={() => void revokeInvite(inv)}>撤销</Button>
{/if}
- </td>
- </tr>
+ </Table.Cell>
+ </Table.Row>
{/each}
- </tbody>
- </table>
+ </Table.Body>
+ </Table.Root>
{/if}
<p class="hint">恢复链接由“生成恢复”按钮产生,仅出现一次;不会自动发邮件。</p>
- </section>
+ </Card.Root>
{:else}
- <section class="card stack">
+ <Card.Root size="sm" class="stack-sec">
<h2>安全审计(收尾尾部)</h2>
<Status busy={auditBusy} error={auditErr} busyText="载入审计记录…" empty={null} />
{#if !auditBusy && !auditErr && audit.length === 0}
<Status busy={false} empty="暂无审计事件" />
{/if}
{#if !auditBusy && audit.length > 0}
- <table class="data">
- <thead>
- <tr>
- <th>时间</th>
- <th>行为者</th>
- <th>事件</th>
- <th>对象</th>
- <th>结果</th>
- </tr>
- </thead>
- <tbody>
+ <Table.Root>
+ <Table.Header>
+ <Table.Row>
+ <Table.Head>时间</Table.Head>
+ <Table.Head>行为者</Table.Head>
+ <Table.Head>事件</Table.Head>
+ <Table.Head>对象</Table.Head>
+ <Table.Head>结果</Table.Head>
+ </Table.Row>
+ </Table.Header>
+ <Table.Body>
{#each audit as e, i (i)}
- <tr>
- <td>{fmtDateTime(e.time)}</td>
- <td>{e.actor ?? '—'}</td>
- <td>{e.action}</td>
- <td class="num">{e.target ?? '—'}</td>
- <td><span class="badge {e.status === 'ok' || e.status === 'success' ? 'ok' : 'danger'}">{e.status}</span></td>
- </tr>
+ <Table.Row>
+ <Table.Cell>{fmtDateTime(e.time)}</Table.Cell>
+ <Table.Cell>{e.actor ?? '—'}</Table.Cell>
+ <Table.Cell>{e.action}</Table.Cell>
+ <Table.Cell class="num">{e.target ?? '—'}</Table.Cell>
+ <Table.Cell><Badge variant={e.status === 'ok' || e.status === 'success' ? 'secondary' : 'destructive'}>{e.status}</Badge></Table.Cell>
+ </Table.Row>
{/each}
- </tbody>
- </table>
+ </Table.Body>
+ </Table.Root>
{/if}
<p class="hint">审计仅记录操作行为与结果,不含密码/密钥/策略代码内容。</p>
- </section>
+ </Card.Root>
{/if}
</div>
{#if resetFor}
<Modal title={`为 ${resetFor.email} 生成密码恢复`} onClose={() => (resetFor = null)}>
<div class="stack">
- <div class="banner info">
+ <Alert variant="default" class="bg-[var(--brand-soft)] border-[#bfe9db]">
<div>
<div>恢复链接(管理员线下告知对方,只出现一次):</div>
<div class="mono-invite">{resetUrl}</div>
<div class="hint">令牌 {resetToken.slice(0, 8)}… 会即刻失效前提示时间(见链接有效期)。</div>
</div>
- </div>
+ </Alert>
<p class="hint">单次有效、限时;重置会吊销对方的全部历史会话。</p>
</div>
</Modal>