diff options
| author | Somhairle H. Marisol <[email protected]> | 2026-09-18 12:03:32 +0800 |
|---|---|---|
| committer | Somhairle H. Marisol <[email protected]> | 2026-09-18 12:03:32 +0800 |
| commit | 61f5c545953bb4ee20b123bf9671ac35d7f1f3c1 (patch) | |
| tree | 9ede3483db5f7e0d81189f2d29514892b8f77867 /frontend/src/pages/AdminPage.svelte | |
| parent | 1f16a357213611a6d7f84e506064fc0d9c6317c6 (diff) | |
| download | strategy-lab-61f5c545953bb4ee20b123bf9671ac35d7f1f3c1.tar.gz | |
[变更性质] feat(frontend)
[新增功能] round3 页面组件全量迁移 shadcn-svelte(12 页面 + Modal/DatasetWizard/DatasetCard/Status)
[实现方案] .btn→Button、.banner→Alert、table.data→Table.Root、.badge→Badge、.tabs→Tabs.Root、.metric-card/form.card→Card.Root;ProjectStrategyTab div 配对修复
[影响范围] src/pages/*.svelte、src/components/*.svelte
Diffstat (limited to 'frontend/src/pages/AdminPage.svelte')
| -rw-r--r-- | frontend/src/pages/AdminPage.svelte | 237 |
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> |
