feat: add org admin SPA for models, roles and provider

Introduce admin-web (Skeleton/SvelteKit), Prisma models for provider connection / OrgModel / OrgRole, DB-backed runtime settings, and admin API routes so org admins can manage agent configuration end-to-end.
This commit is contained in:
2026-07-11 12:33:56 +08:00
parent 461d2e89b0
commit 552c1c353e
50 changed files with 6986 additions and 151 deletions
@@ -0,0 +1,30 @@
<script lang="ts">
import type { Snippet } from 'svelte';
let {
title = '暂无数据',
description,
action
}: {
title?: string;
description?: string;
action?: Snippet;
} = $props();
</script>
<div class="saas-empty">
<div class="mb-1 flex h-12 w-12 items-center justify-center rounded-2xl bg-surface-100 text-surface-400">
<svg class="h-6 w-6" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25H12" />
</svg>
</div>
<p class="text-sm font-medium text-surface-700">{title}</p>
{#if description}
<p class="max-w-sm text-sm text-surface-400">{description}</p>
{/if}
{#if action}
<div class="mt-2">
{@render action()}
</div>
{/if}
</div>
@@ -0,0 +1,26 @@
<script lang="ts">
let {
message,
onretry
}: {
message: string;
onretry?: () => void;
} = $props();
</script>
<div class="saas-card flex flex-wrap items-start gap-3 border-error-200 bg-error-50 p-4 text-error-700">
<svg class="mt-0.5 h-5 w-5 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z"
/>
</svg>
<div class="min-w-0 flex-1">
<p class="text-sm font-medium">请求失败</p>
<p class="mt-0.5 text-sm opacity-90">{message}</p>
</div>
{#if onretry}
<button type="button" class="saas-btn-ghost text-sm" onclick={onretry}>重试</button>
{/if}
</div>
@@ -0,0 +1,48 @@
<script lang="ts">
import type { ExplorerFolder } from '$lib/api';
import Icon from './Icon.svelte';
import FolderTree from './FolderTree.svelte';
let {
folder,
folders,
projects,
slug
}: {
folder: ExplorerFolder;
folders: ExplorerFolder[];
projects: {
id: string;
name: string;
folderId: string | null;
createdAt: string;
binding: { chatId: string } | null;
}[];
slug: string;
} = $props();
let open = $state(true);
</script>
<div>
<button
type="button"
class="flex w-full items-center gap-2.5 rounded-lg px-3 py-2.5 text-left text-sm transition hover:bg-surface-100"
onclick={() => (open = !open)}
>
<span class="w-3.5 text-center text-xs text-surface-400">{open ? '▾' : '▸'}</span>
<span class="flex h-7 w-7 items-center justify-center rounded-md bg-warning-50 text-warning-700">
<Icon name="folder" class="h-4 w-4" />
</span>
<span class="min-w-0 flex-1 truncate font-medium text-surface-800">{folder.name}</span>
<span class="saas-badge-neutral">{folder.projectCount} 项目</span>
{#if folder.childFolderCount > 0}
<span class="saas-badge-neutral">{folder.childFolderCount} 子夹</span>
{/if}
</button>
{#if open}
<div class="ml-4 border-l border-surface-200 pl-2">
<FolderTree {folders} {projects} parentId={folder.id} {slug} />
</div>
{/if}
</div>
@@ -0,0 +1,49 @@
<script lang="ts">
import type { ExplorerFolder } from '$lib/api';
import { fmtDate } from '$lib/format';
import Icon from './Icon.svelte';
import FolderNode from './FolderNode.svelte';
let {
folders,
projects,
parentId,
slug
}: {
folders: ExplorerFolder[];
projects: {
id: string;
name: string;
folderId: string | null;
createdAt: string;
binding: { chatId: string } | null;
}[];
parentId: string | null;
slug: string;
} = $props();
let childFolders = $derived(folders.filter((f) => f.parentId === parentId));
let childProjects = $derived(projects.filter((p) => p.folderId === parentId));
</script>
<div class="space-y-0.5">
{#each childProjects as p (p.id)}
<a
href={`/admin/org/${slug}/projects/${p.id}`}
class="flex items-center gap-2.5 rounded-lg px-3 py-2.5 text-sm transition hover:bg-surface-100"
>
<span class="flex h-7 w-7 items-center justify-center rounded-md bg-primary-50 text-primary-600">
<Icon name="file" class="h-4 w-4" />
</span>
<span class="min-w-0 flex-1 truncate font-medium text-surface-800">{p.name}</span>
{#if p.binding}
<span class="saas-badge-success">已绑定</span>
{/if}
<span class="hidden text-xs text-surface-400 sm:inline">{fmtDate(p.createdAt)}</span>
</a>
{/each}
{#each childFolders as f (f.id)}
<FolderNode folder={f} {folders} {projects} {slug} />
{/each}
</div>
@@ -0,0 +1,115 @@
<script lang="ts">
/** Inline nav icons for the admin shell. */
let {
name,
class: className = 'h-4 w-4'
}: {
name:
| 'overview'
| 'members'
| 'teams'
| 'projects'
| 'models'
| 'roles'
| 'provider'
| 'menu'
| 'logout'
| 'org'
| 'chevron'
| 'folder'
| 'file'
| 'check';
class?: string;
} = $props();
</script>
{#if name === 'overview'}
<svg class={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
<path stroke-linecap="round" stroke-linejoin="round" d="M3 12l9-9 9 9M5 10v9a1 1 0 001 1h3v-5h6v5h3a1 1 0 001-1v-9" />
</svg>
{:else if name === 'members'}
<svg class={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M15.75 7.5a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.5 19.5a7.5 7.5 0 0115 0"
/>
</svg>
{:else if name === 'teams'}
<svg class={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M18 18.72a9.09 9.09 0 003.74-.72 9 9 0 00-5.07-5.95M15 11a4 4 0 10-8 0 4 4 0 008 0zM4.26 18a9 9 0 0115.48 0"
/>
</svg>
{:else if name === 'projects'}
<svg class={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M3.75 6.75A2.25 2.25 0 016 4.5h3.379c.6 0 1.175.238 1.6.66l.842.84c.424.423 1 .66 1.6.66H18A2.25 2.25 0 0120.25 9v8.25A2.25 2.25 0 0118 19.5H6a2.25 2.25 0 01-2.25-2.25V6.75z"
/>
</svg>
{:else if name === 'models'}
<svg class={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
<path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12l7.5-7.5L19.5 12 12 19.5 4.5 12z" />
</svg>
{:else if name === 'roles'}
<svg class={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09z"
/>
</svg>
{:else if name === 'provider'}
<svg class={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
<path stroke-linecap="round" stroke-linejoin="round" d="M13.19 8.688a4.5 4.5 0 016.364 6.364l-3.182 3.182a4.5 4.5 0 01-6.364-6.364" />
<path stroke-linecap="round" stroke-linejoin="round" d="M10.81 15.312a4.5 4.5 0 01-6.364-6.364l3.182-3.182a4.5 4.5 0 016.364 6.364" />
</svg>
{:else if name === 'menu'}
<svg class={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" />
</svg>
{:else if name === 'logout'}
<svg class={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6A2.25 2.25 0 005.25 5.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15M12 9l3 3m0 0l-3 3m3-3H6"
/>
</svg>
{:else if name === 'org'}
<svg class={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M3.75 21h16.5M4.5 3h15M5.25 3v18m13.5-18v18M9 6.75h1.5m-1.5 3h1.5m-1.5 3h1.5m3-6H15m-1.5 3H15m-1.5 3H15M9 21v-3.375c0-.621.504-1.125 1.125-1.125h3.75c.621 0 1.125.504 1.125 1.125V21"
/>
</svg>
{:else if name === 'chevron'}
<svg class={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
<path stroke-linecap="round" stroke-linejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
</svg>
{:else if name === 'folder'}
<svg class={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M3.75 6.75A2.25 2.25 0 016 4.5h3.379c.6 0 1.175.238 1.6.66l.842.84c.424.423 1 .66 1.6.66H18A2.25 2.25 0 0120.25 9v8.25A2.25 2.25 0 0118 19.5H6a2.25 2.25 0 01-2.25-2.25V6.75z"
/>
</svg>
{:else if name === 'file'}
<svg class={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z"
/>
</svg>
{:else if name === 'check'}
<svg class={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
<path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5" />
</svg>
{/if}
@@ -0,0 +1,15 @@
<script lang="ts">
let { label = '加载中…' }: { label?: string } = $props();
</script>
<div class="flex flex-col items-center justify-center gap-3 py-16 text-surface-400">
<svg class="h-7 w-7 animate-spin text-primary-500" viewBox="0 0 24 24" fill="none">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path
class="opacity-90"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
<p class="text-sm">{label}</p>
</div>
@@ -0,0 +1,44 @@
<script lang="ts">
import type { Snippet } from 'svelte';
let {
open = $bindable(false),
title,
children,
onclose
}: {
open?: boolean;
title: string;
children: Snippet;
onclose?: () => void;
} = $props();
function close() {
open = false;
onclose?.();
}
function onBackdrop(e: MouseEvent) {
if (e.target === e.currentTarget) close();
}
function onKey(e: KeyboardEvent) {
if (e.key === 'Escape') close();
}
</script>
{#if open}
<div class="saas-modal-backdrop" role="presentation" onclick={onBackdrop} onkeydown={onKey}>
<div class="saas-modal" role="dialog" aria-modal="true" aria-label={title} tabindex="-1">
<div class="mb-4 flex items-start justify-between gap-3">
<h3 class="text-lg font-semibold text-surface-900">{title}</h3>
<button type="button" class="saas-btn-ghost !px-2 !py-1 text-surface-400" onclick={close} aria-label="关闭">
<svg class="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{@render children()}
</div>
</div>
{/if}
@@ -0,0 +1,27 @@
<script lang="ts">
import type { Snippet } from 'svelte';
let {
title,
description,
actions
}: {
title: string;
description?: string;
actions?: Snippet;
} = $props();
</script>
<div class="saas-toolbar">
<div class="min-w-0">
<h1 class="saas-page-title">{title}</h1>
{#if description}
<p class="saas-muted mt-1">{description}</p>
{/if}
</div>
{#if actions}
<div class="ml-auto flex flex-wrap items-center gap-2">
{@render actions()}
</div>
{/if}
</div>
@@ -0,0 +1,148 @@
<script lang="ts">
import { api, type OrgRoleRow, type OrgModelRow } from '$lib/api';
import { fmtDate } from '$lib/format';
import { TOOL_OPTIONS } from '$lib/constants';
import { toastError, toastSuccess } from '$lib/toast';
let {
r,
models,
slug,
onremoved,
onupdated
}: {
r: OrgRoleRow;
models: OrgModelRow[];
slug: string;
onremoved: () => void;
onupdated: (updated: OrgRoleRow) => void;
} = $props();
// Local edit buffer intentionally seeded once from the row props.
const initial = {
roleId: r.roleId,
label: r.label,
defaultModelId: r.defaultModelId ?? '',
systemPrompt: r.systemPrompt ?? '',
unrestricted: r.tools === null,
tools: r.tools ?? []
};
let roleId = $state(initial.roleId);
let label = $state(initial.label);
let defaultModelId = $state(initial.defaultModelId);
let systemPrompt = $state(initial.systemPrompt);
let unrestricted = $state(initial.unrestricted);
let selectedTools = $state<Set<string>>(new Set(initial.tools));
let saving = $state(false);
function toggleTool(id: string) {
const next = new Set(selectedTools);
if (next.has(id)) next.delete(id);
else next.add(id);
selectedTools = next;
}
async function save() {
saving = true;
const tools = unrestricted ? null : [...selectedTools];
try {
const updated = await api.updateRole(slug, r.id, {
roleId: roleId.trim(),
label: label.trim(),
defaultModelId: defaultModelId === '' ? null : defaultModelId,
systemPrompt: systemPrompt === '' ? null : systemPrompt,
tools
});
onupdated(updated);
toastSuccess('角色已保存');
} catch (err) {
toastError(err instanceof Error ? err.message : String(err));
} finally {
saving = false;
}
}
const groupedTools = TOOL_OPTIONS.reduce(
(acc, t) => {
(acc[t.group] ??= []).push(t);
return acc;
},
{} as Record<string, typeof TOOL_OPTIONS>
);
</script>
<div class="saas-card-pad">
<div class="mb-4 flex flex-wrap items-center gap-2">
<span class="saas-badge-primary font-mono">/{roleId || r.roleId}</span>
<span class="text-sm text-surface-500">{label || r.label}</span>
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
<label for="role-id-{r.id}" class="saas-label">roleId</label>
<input id="role-id-{r.id}" class="saas-input font-mono text-sm" bind:value={roleId} />
</div>
<div>
<label for="role-label-{r.id}" class="saas-label">label</label>
<input id="role-label-{r.id}" class="saas-input" bind:value={label} />
</div>
</div>
<div class="mt-4">
<label for="role-model-{r.id}" class="saas-label">默认模型</label>
<select id="role-model-{r.id}" class="saas-select" bind:value={defaultModelId}>
<option value="">(角色默认 → 首模型)</option>
{#each models as m}
<option value={m.modelId}>{m.label} ({m.modelId})</option>
{/each}
</select>
</div>
<div class="mt-4">
<span class="saas-label">工具白名单</span>
<label class="mb-3 flex cursor-pointer items-center gap-2 rounded-lg border border-surface-200 bg-surface-100/40 px-3 py-2">
<input type="checkbox" class="checkbox" bind:checked={unrestricted} />
<span class="text-sm">不限(使用全部注册工具)</span>
</label>
<div class="space-y-3 {unrestricted ? 'pointer-events-none opacity-40' : ''}">
{#each Object.entries(groupedTools) as [group, tools]}
<div>
<p class="mb-1.5 text-xs font-semibold uppercase tracking-wide text-surface-400">{group}</p>
<div class="grid grid-cols-1 gap-1.5 sm:grid-cols-2">
{#each tools as t}
<label class="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-surface-100">
<input
type="checkbox"
class="checkbox"
checked={selectedTools.has(t.id)}
onchange={() => toggleTool(t.id)}
/>
{t.label}
</label>
{/each}
</div>
</div>
{/each}
</div>
</div>
<div class="mt-4">
<label for="role-prompt-{r.id}" class="saas-label">系统提示词</label>
<textarea
id="role-prompt-{r.id}"
class="saas-textarea"
rows="4"
placeholder="系统提示词(可选)。会话开始时注入,定义 agent 人格/指令。"
bind:value={systemPrompt}
></textarea>
</div>
<div class="mt-4 flex flex-wrap items-center gap-3 border-t border-surface-100 pt-4">
<span class="text-xs text-surface-400">更新于 {fmtDate(r.updatedAt)}</span>
<div class="flex-1"></div>
<button class="saas-btn-danger" onclick={onremoved}>删除角色</button>
<button class="saas-btn-primary" onclick={save} disabled={saving}>
{saving ? '保存中…' : '保存'}
</button>
</div>
</div>
@@ -0,0 +1,19 @@
<script lang="ts">
let {
label,
value,
hint
}: {
label: string;
value: string;
hint?: string;
} = $props();
</script>
<div class="saas-stat">
<div class="saas-stat-label">{label}</div>
<div class="saas-stat-value">{value}</div>
{#if hint}
<div class="saas-help">{hint}</div>
{/if}
</div>
@@ -0,0 +1,24 @@
<script lang="ts">
import { dismissToast, toasts } from '$lib/toast';
const kindClass: Record<string, string> = {
info: 'border-surface-200 bg-surface-50 text-surface-800',
success: 'border-success-200 bg-success-50 text-success-800',
error: 'border-error-200 bg-error-50 text-error-800'
};
</script>
<div class="pointer-events-none fixed inset-x-0 top-0 z-[100] flex flex-col items-end gap-2 p-4">
{#each $toasts as t (t.id)}
<div
class="pointer-events-auto flex max-w-sm items-start gap-3 rounded-xl border px-4 py-3 text-sm shadow-lg {kindClass[t.kind] ??
kindClass.info}"
role="status"
>
<p class="min-w-0 flex-1">{t.message}</p>
<button type="button" class="opacity-60 hover:opacity-100" onclick={() => dismissToast(t.id)} aria-label="关闭">
×
</button>
</div>
{/each}
</div>