forked from EduCraft/curriculum-project-hub
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:
@@ -0,0 +1,158 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { api, type OrgMembership } from '$lib/api';
|
||||
import { session } from '$lib/session';
|
||||
import { fmtCost, fmtNum } from '$lib/format';
|
||||
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||
import StatCard from '$lib/components/StatCard.svelte';
|
||||
import LoadingState from '$lib/components/LoadingState.svelte';
|
||||
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
|
||||
import { toastError, toastSuccess } from '$lib/toast';
|
||||
|
||||
let orgSlug = $derived(page.params.slug ?? '');
|
||||
let org = $derived(
|
||||
$session.me?.organizations.find((o) => o.slug === orgSlug) as OrgMembership | undefined
|
||||
);
|
||||
|
||||
let settings = $state<{ membersCanCreateProjects: boolean } | null>(null);
|
||||
let usage = $state<Awaited<ReturnType<typeof api.usage>> | null>(null);
|
||||
let loading = $state(true);
|
||||
let error = $state<string | null>(null);
|
||||
let saving = $state(false);
|
||||
|
||||
async function load() {
|
||||
loading = true;
|
||||
error = null;
|
||||
try {
|
||||
[settings, usage] = await Promise.all([api.settings(orgSlug), api.usage(orgSlug)]);
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : String(err);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleCreate() {
|
||||
if (!settings) return;
|
||||
saving = true;
|
||||
const prev = settings.membersCanCreateProjects;
|
||||
settings.membersCanCreateProjects = !prev;
|
||||
try {
|
||||
await api.setSettings(orgSlug, { membersCanCreateProjects: settings.membersCanCreateProjects });
|
||||
toastSuccess(settings.membersCanCreateProjects ? '已允许成员自助建项' : '已关闭成员自助建项');
|
||||
} catch (err) {
|
||||
settings.membersCanCreateProjects = prev;
|
||||
toastError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (orgSlug) load();
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if loading}
|
||||
<LoadingState />
|
||||
{:else if error}
|
||||
<ErrorBanner message={error} onretry={load} />
|
||||
{:else if org && settings && usage}
|
||||
<PageHeader title={org.name} description="组织健康度、用量与生产策略一览。" />
|
||||
|
||||
<div class="mb-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<div class="saas-card-pad sm:col-span-2 lg:col-span-1">
|
||||
<p class="saas-section-title mb-3">组织信息</p>
|
||||
<dl class="space-y-2.5 text-sm">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<dt class="text-surface-500">Slug</dt>
|
||||
<dd class="font-mono text-xs text-surface-800">/{org.slug}</dd>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<dt class="text-surface-500">状态</dt>
|
||||
<dd><span class="saas-badge-success">{org.status}</span></dd>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<dt class="text-surface-500">你的角色</dt>
|
||||
<dd><span class="saas-badge-primary">{org.role}</span></dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<div class="saas-card-pad sm:col-span-2">
|
||||
<p class="saas-section-title mb-1">项目自助创建策略</p>
|
||||
<p class="saas-muted mb-4">
|
||||
开启后,普通老师可在飞书群自助创建项目;关闭后仅 OWNER / ADMIN 可建。
|
||||
</p>
|
||||
<label class="flex cursor-pointer items-center gap-3 rounded-xl border border-surface-200 bg-surface-100/50 px-4 py-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="checkbox"
|
||||
checked={settings.membersCanCreateProjects}
|
||||
disabled={saving}
|
||||
onchange={toggleCreate}
|
||||
/>
|
||||
<div>
|
||||
<div class="text-sm font-medium text-surface-800">允许成员自助创建项目</div>
|
||||
<div class="text-xs text-surface-400">membersCanCreateProjects</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4 flex items-end justify-between gap-3">
|
||||
<div>
|
||||
<h2 class="saas-section-title">用量概览</h2>
|
||||
<p class="saas-muted">全组织 Agent 运行汇总</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-6 grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<StatCard label="运行总数" value={fmtNum(usage.totals.runCount)} />
|
||||
<StatCard label="有成本运行" value={fmtNum(usage.totals.runsWithCost)} />
|
||||
<StatCard label="无成本运行" value={fmtNum(usage.totals.runsWithoutCost)} />
|
||||
<StatCard label="输入 tokens" value={fmtNum(usage.totals.inputTokens)} />
|
||||
<StatCard label="输出 tokens" value={fmtNum(usage.totals.outputTokens)} />
|
||||
<StatCard label="成本 (USD)" value={fmtCost(usage.totals.costUsd)} />
|
||||
</div>
|
||||
|
||||
<div class="saas-card overflow-hidden">
|
||||
<div class="border-b border-surface-200 px-5 py-3">
|
||||
<h3 class="text-sm font-semibold text-surface-800">按项目用量</h3>
|
||||
</div>
|
||||
{#if usage.projects.length === 0}
|
||||
<div class="saas-empty">
|
||||
<p class="text-sm text-surface-400">暂无项目用量数据</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="overflow-x-auto">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>项目</th>
|
||||
<th>运行</th>
|
||||
<th>in / out tokens</th>
|
||||
<th>成本</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each usage.projects as p}
|
||||
<tr>
|
||||
<td class="font-medium">{p.projectName}</td>
|
||||
<td class="tabular-nums">{fmtNum(p.runCount)}</td>
|
||||
<td class="tabular-nums text-surface-600"
|
||||
>{fmtNum(p.inputTokens)} / {fmtNum(p.outputTokens)}</td
|
||||
>
|
||||
<td class="tabular-nums">{fmtCost(p.costUsd)}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="saas-empty">
|
||||
<p class="text-sm text-surface-400">组织数据不可用</p>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,168 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { api, type OrgMember } from '$lib/api';
|
||||
import { fmtDate } from '$lib/format';
|
||||
import { ORG_ROLES } from '$lib/constants';
|
||||
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||
import LoadingState from '$lib/components/LoadingState.svelte';
|
||||
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
|
||||
import EmptyState from '$lib/components/EmptyState.svelte';
|
||||
import { toastError, toastSuccess } from '$lib/toast';
|
||||
|
||||
const slug = $derived(page.params.slug ?? '');
|
||||
const roles = [...ORG_ROLES];
|
||||
|
||||
let members = $state<OrgMember[]>([]);
|
||||
let loading = $state(true);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
let newOpenId = $state('');
|
||||
let newName = $state('');
|
||||
let newRole = $state<string>('MEMBER');
|
||||
let adding = $state(false);
|
||||
|
||||
async function load() {
|
||||
loading = true;
|
||||
error = null;
|
||||
try {
|
||||
const res = await api.members(slug);
|
||||
members = res.members;
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : String(err);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function addMember() {
|
||||
if (!newOpenId.trim()) return;
|
||||
adding = true;
|
||||
try {
|
||||
const m = await api.addMember(slug, {
|
||||
feishuOpenId: newOpenId.trim(),
|
||||
role: newRole,
|
||||
...(newName.trim() ? { displayName: newName.trim() } : {})
|
||||
});
|
||||
members = [...members, m].sort(
|
||||
(a, b) => a.role.localeCompare(b.role) || a.createdAt.localeCompare(b.createdAt)
|
||||
);
|
||||
newOpenId = '';
|
||||
newName = '';
|
||||
toastSuccess('成员已添加');
|
||||
} catch (err) {
|
||||
toastError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
adding = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function changeRole(m: OrgMember, role: string) {
|
||||
try {
|
||||
await api.setMemberRole(slug, m.userId, role);
|
||||
await load();
|
||||
toastSuccess('角色已更新');
|
||||
} catch (err) {
|
||||
toastError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
|
||||
async function revoke(m: OrgMember) {
|
||||
if (!confirm(`移除成员 ${m.displayName} 出本组织?`)) return;
|
||||
try {
|
||||
await api.revokeMember(slug, m.userId);
|
||||
await load();
|
||||
toastSuccess('成员已移除');
|
||||
} catch (err) {
|
||||
toastError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (slug) load();
|
||||
});
|
||||
</script>
|
||||
|
||||
<PageHeader title="成员与权限" description="管理组织角色:OWNER / ADMIN 可访问本后台,MEMBER 不可。" />
|
||||
|
||||
{#if loading}
|
||||
<LoadingState />
|
||||
{:else if error}
|
||||
<ErrorBanner message={error} onretry={load} />
|
||||
{:else}
|
||||
<div class="saas-card-pad mb-6">
|
||||
<h2 class="saas-section-title mb-4">添加成员</h2>
|
||||
<div class="grid gap-3 md:grid-cols-[1.2fr_1fr_auto_auto]">
|
||||
<input class="saas-input" placeholder="Feishu open_id" bind:value={newOpenId} />
|
||||
<input class="saas-input" placeholder="显示名(可选)" bind:value={newName} />
|
||||
<select class="saas-select" bind:value={newRole}>
|
||||
{#each roles as r}<option value={r}>{r}</option>{/each}
|
||||
</select>
|
||||
<button class="saas-btn-primary" onclick={addMember} disabled={adding}>
|
||||
{adding ? '添加中…' : '添加成员'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="saas-card overflow-hidden">
|
||||
<div class="flex items-center justify-between border-b border-surface-200 px-5 py-3">
|
||||
<h2 class="text-sm font-semibold">成员列表</h2>
|
||||
<span class="saas-badge-neutral">{members.length}</span>
|
||||
</div>
|
||||
{#if members.length === 0}
|
||||
<EmptyState title="暂无成员" description="使用上方表单按飞书 open_id 添加成员。" />
|
||||
{:else}
|
||||
<div class="overflow-x-auto">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>用户</th>
|
||||
<th>open_id</th>
|
||||
<th>组织角色</th>
|
||||
<th>加入时间</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each members as m}
|
||||
<tr>
|
||||
<td>
|
||||
<div class="flex items-center gap-2.5">
|
||||
{#if m.avatarUrl}
|
||||
<img src={m.avatarUrl} alt="" class="h-7 w-7 rounded-full object-cover" />
|
||||
{:else}
|
||||
<div
|
||||
class="flex h-7 w-7 items-center justify-center rounded-full bg-primary-100 text-xs font-semibold text-primary-700"
|
||||
>
|
||||
{m.displayName.slice(0, 1)}
|
||||
</div>
|
||||
{/if}
|
||||
<span class="font-medium">{m.displayName}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="font-mono text-xs text-surface-500">{m.feishuOpenId}</td>
|
||||
<td>
|
||||
<select
|
||||
class="saas-select max-w-[9rem] py-1.5 text-sm"
|
||||
value={m.role}
|
||||
onchange={(e) => changeRole(m, (e.target as HTMLSelectElement).value)}
|
||||
>
|
||||
{#each roles as r}
|
||||
<option value={r} selected={r === m.role}>{r}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</td>
|
||||
<td class="text-surface-500">{fmtDate(m.createdAt)}</td>
|
||||
<td class="text-right">
|
||||
<button class="saas-btn-danger !py-1 text-sm" onclick={() => revoke(m)}>移除</button>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
<p class="border-t border-surface-100 px-5 py-3 text-xs text-surface-400">
|
||||
组织角色控制后台访问;项目级权限由「项目」页团队授权(READ / EDIT / MANAGE)决定。
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,170 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { api, type OrgModelRow } from '$lib/api';
|
||||
import { fmtDateOnly } from '$lib/format';
|
||||
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||
import LoadingState from '$lib/components/LoadingState.svelte';
|
||||
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
|
||||
import EmptyState from '$lib/components/EmptyState.svelte';
|
||||
import { toastError, toastSuccess } from '$lib/toast';
|
||||
|
||||
const slug = $derived(page.params.slug ?? '');
|
||||
|
||||
let models = $state<OrgModelRow[]>([]);
|
||||
let loading = $state(true);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
let newModelId = $state('');
|
||||
let newLabel = $state('');
|
||||
let newSortKey = $state('');
|
||||
let adding = $state(false);
|
||||
|
||||
async function load() {
|
||||
loading = true;
|
||||
error = null;
|
||||
try {
|
||||
const res = await api.models(slug);
|
||||
models = res.models;
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : String(err);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function add() {
|
||||
if (!newModelId.trim() || !newLabel.trim()) return;
|
||||
adding = true;
|
||||
try {
|
||||
const m = await api.createModel(slug, {
|
||||
modelId: newModelId.trim(),
|
||||
label: newLabel.trim(),
|
||||
...(newSortKey.trim() ? { sortKey: newSortKey.trim() } : {})
|
||||
});
|
||||
models = [...models, m];
|
||||
newModelId = '';
|
||||
newLabel = '';
|
||||
newSortKey = '';
|
||||
toastSuccess('模型已添加');
|
||||
} catch (err) {
|
||||
toastError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
adding = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function save(m: OrgModelRow, patch: Partial<OrgModelRow>) {
|
||||
try {
|
||||
const updated = await api.updateModel(slug, m.id, patch);
|
||||
models = models.map((x) => (x.id === m.id ? updated : x));
|
||||
toastSuccess('已保存');
|
||||
} catch (err) {
|
||||
toastError(err instanceof Error ? err.message : String(err));
|
||||
await load();
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(m: OrgModelRow) {
|
||||
if (!confirm(`删除模型 ${m.label}?`)) return;
|
||||
try {
|
||||
await api.deleteModel(slug, m.id);
|
||||
models = models.filter((x) => x.id !== m.id);
|
||||
toastSuccess('模型已删除');
|
||||
} catch (err) {
|
||||
toastError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (slug) load();
|
||||
});
|
||||
</script>
|
||||
|
||||
<PageHeader
|
||||
title="模型"
|
||||
description="Org-scoped 模型清单(ADR-0017)。modelId 面向 provider;label 面向教师。"
|
||||
/>
|
||||
|
||||
{#if loading}
|
||||
<LoadingState />
|
||||
{:else if error}
|
||||
<ErrorBanner message={error} onretry={load} />
|
||||
{:else}
|
||||
<div class="saas-card-pad mb-6">
|
||||
<h2 class="saas-section-title mb-4">添加模型</h2>
|
||||
<div class="grid gap-3 md:grid-cols-[1.4fr_1fr_auto_auto]">
|
||||
<input
|
||||
class="saas-input font-mono text-sm"
|
||||
placeholder="modelId(如 anthropic/claude-sonnet-5)"
|
||||
bind:value={newModelId}
|
||||
/>
|
||||
<input class="saas-input" placeholder="label(如 Claude Sonnet 5)" bind:value={newLabel} />
|
||||
<input class="saas-input w-28" placeholder="排序" bind:value={newSortKey} />
|
||||
<button class="saas-btn-primary" onclick={add} disabled={adding}>添加</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="saas-card overflow-hidden">
|
||||
<div class="flex items-center justify-between border-b border-surface-200 px-5 py-3">
|
||||
<h2 class="text-sm font-semibold">已启用模型</h2>
|
||||
<span class="saas-badge-neutral">{models.length}</span>
|
||||
</div>
|
||||
{#if models.length === 0}
|
||||
<EmptyState title="暂无已启用模型" description="空清单时会回退到环境默认模型。" />
|
||||
{:else}
|
||||
<div class="overflow-x-auto">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>label</th>
|
||||
<th>modelId</th>
|
||||
<th>工具能力</th>
|
||||
<th>排序</th>
|
||||
<th>创建</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each models as m (m.id)}
|
||||
<tr>
|
||||
<td>
|
||||
<input
|
||||
class="saas-input py-1.5"
|
||||
value={m.label}
|
||||
onchange={(e) => save(m, { label: (e.target as HTMLInputElement).value })}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<input
|
||||
class="saas-input py-1.5 font-mono text-xs"
|
||||
value={m.modelId}
|
||||
onchange={(e) => save(m, { modelId: (e.target as HTMLInputElement).value })}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="checkbox"
|
||||
checked={m.toolCapable}
|
||||
onchange={(e) => save(m, { toolCapable: (e.target as HTMLInputElement).checked })}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<input
|
||||
class="saas-input w-20 py-1.5"
|
||||
value={m.sortKey}
|
||||
onchange={(e) => save(m, { sortKey: (e.target as HTMLInputElement).value })}
|
||||
/>
|
||||
</td>
|
||||
<td class="text-xs text-surface-400">{fmtDateOnly(m.createdAt)}</td>
|
||||
<td class="text-right">
|
||||
<button class="saas-btn-danger !py-1 text-xs" onclick={() => remove(m)}>删除</button>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,158 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { api, type ExplorerData, type ExplorerFolder } from '$lib/api';
|
||||
import FolderTree from '$lib/components/FolderTree.svelte';
|
||||
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||
import LoadingState from '$lib/components/LoadingState.svelte';
|
||||
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
|
||||
import EmptyState from '$lib/components/EmptyState.svelte';
|
||||
import Modal from '$lib/components/Modal.svelte';
|
||||
import { toastError, toastSuccess } from '$lib/toast';
|
||||
|
||||
const slug = $derived(page.params.slug ?? '');
|
||||
|
||||
let data = $state<ExplorerData | null>(null);
|
||||
let loading = $state(true);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
let showFolderModal = $state(false);
|
||||
let folderName = $state('');
|
||||
let folderParent = $state('');
|
||||
|
||||
let showProjectModal = $state(false);
|
||||
let projectName = $state('');
|
||||
let projectFolder = $state('');
|
||||
|
||||
async function load() {
|
||||
loading = true;
|
||||
error = null;
|
||||
try {
|
||||
data = await api.explorer(slug);
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : String(err);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function createFolder() {
|
||||
if (!folderName.trim()) return;
|
||||
try {
|
||||
await api.createFolder(slug, {
|
||||
name: folderName.trim(),
|
||||
...(folderParent ? { parentId: folderParent } : {})
|
||||
});
|
||||
folderName = '';
|
||||
folderParent = '';
|
||||
showFolderModal = false;
|
||||
await load();
|
||||
toastSuccess('文件夹已创建');
|
||||
} catch (err) {
|
||||
toastError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
|
||||
async function createProject() {
|
||||
if (!projectName.trim()) return;
|
||||
try {
|
||||
const res = await api.createProject(slug, {
|
||||
name: projectName.trim(),
|
||||
...(projectFolder ? { folderId: projectFolder } : {})
|
||||
});
|
||||
projectName = '';
|
||||
projectFolder = '';
|
||||
showProjectModal = false;
|
||||
window.location.href = `/admin/org/${slug}/projects/${res.id}`;
|
||||
} catch (err) {
|
||||
toastError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
|
||||
function folderPath(f: ExplorerFolder): string {
|
||||
if (!data) return f.name;
|
||||
const parts: string[] = [f.name];
|
||||
let cur: ExplorerFolder | undefined = f;
|
||||
while (cur?.parentId) {
|
||||
const parent = data.folders.find((x) => x.id === cur!.parentId);
|
||||
if (!parent) break;
|
||||
parts.unshift(parent.name);
|
||||
cur = parent;
|
||||
}
|
||||
return parts.join(' / ');
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (slug) load();
|
||||
});
|
||||
</script>
|
||||
|
||||
<PageHeader title="项目" description="文件夹是透明组织节点;项目是权限边界。">
|
||||
{#snippet actions()}
|
||||
<button class="saas-btn-secondary" onclick={() => (showFolderModal = true)}>新建文件夹</button>
|
||||
<button class="saas-btn-primary" onclick={() => (showProjectModal = true)}>新建项目</button>
|
||||
{/snippet}
|
||||
</PageHeader>
|
||||
|
||||
{#if loading}
|
||||
<LoadingState />
|
||||
{:else if error}
|
||||
<ErrorBanner message={error} onretry={load} />
|
||||
{:else if data}
|
||||
<div class="saas-card p-2 sm:p-3">
|
||||
{#if data.projects.filter((p) => !p.folderId).length === 0 && data.folders.filter((f) => !f.parentId).length === 0}
|
||||
<EmptyState title="暂无项目" description="新建文件夹或项目,开始组织你的教研资产。" />
|
||||
{:else}
|
||||
<FolderTree folders={data.folders} projects={data.projects} parentId={null} {slug} />
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<Modal bind:open={showFolderModal} title="新建文件夹">
|
||||
<label class="saas-label" for="folder-name">名称</label>
|
||||
<input
|
||||
id="folder-name"
|
||||
class="saas-input mb-4"
|
||||
bind:value={folderName}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Enter') createFolder();
|
||||
}}
|
||||
/>
|
||||
{#if data && data.folders.length > 0}
|
||||
<label class="saas-label" for="folder-parent">父文件夹(可选)</label>
|
||||
<select id="folder-parent" class="saas-select mb-4" bind:value={folderParent}>
|
||||
<option value="">(根)</option>
|
||||
{#each data.folders as f}
|
||||
<option value={f.id}>{folderPath(f)}</option>
|
||||
{/each}
|
||||
</select>
|
||||
{/if}
|
||||
<div class="flex justify-end gap-2">
|
||||
<button class="saas-btn-ghost" onclick={() => (showFolderModal = false)}>取消</button>
|
||||
<button class="saas-btn-primary" onclick={createFolder}>创建</button>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<Modal bind:open={showProjectModal} title="新建项目">
|
||||
<label class="saas-label" for="project-name">项目名</label>
|
||||
<input
|
||||
id="project-name"
|
||||
class="saas-input mb-4"
|
||||
bind:value={projectName}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Enter') createProject();
|
||||
}}
|
||||
/>
|
||||
{#if data && data.folders.length > 0}
|
||||
<label class="saas-label" for="project-folder">文件夹(可选)</label>
|
||||
<select id="project-folder" class="saas-select mb-4" bind:value={projectFolder}>
|
||||
<option value="">(根)</option>
|
||||
{#each data.folders as f}
|
||||
<option value={f.id}>{folderPath(f)}</option>
|
||||
{/each}
|
||||
</select>
|
||||
{/if}
|
||||
<div class="flex justify-end gap-2">
|
||||
<button class="saas-btn-ghost" onclick={() => (showProjectModal = false)}>取消</button>
|
||||
<button class="saas-btn-primary" onclick={createProject}>创建</button>
|
||||
</div>
|
||||
</Modal>
|
||||
@@ -0,0 +1,274 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import {
|
||||
api,
|
||||
type ProjectDetail,
|
||||
type TeamAccessEntry,
|
||||
type TeamRow,
|
||||
type SessionSummary,
|
||||
type ExplorerData
|
||||
} from '$lib/api';
|
||||
import { fmtDate } from '$lib/format';
|
||||
import { PERMISSION_ROLES } from '$lib/constants';
|
||||
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||
import LoadingState from '$lib/components/LoadingState.svelte';
|
||||
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
|
||||
import EmptyState from '$lib/components/EmptyState.svelte';
|
||||
import { toastError, toastSuccess } from '$lib/toast';
|
||||
|
||||
const slug = $derived(page.params.slug ?? '');
|
||||
const projectId = $derived(page.params.projectId ?? '');
|
||||
|
||||
let proj = $state<ProjectDetail | null>(null);
|
||||
let access = $state<TeamAccessEntry[]>([]);
|
||||
let sessions = $state<SessionSummary[]>([]);
|
||||
let teams = $state<TeamRow[]>([]);
|
||||
let explorer = $state<ExplorerData | null>(null);
|
||||
let loading = $state(true);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
let grantTeam = $state('');
|
||||
let grantRole = $state<string>('EDIT');
|
||||
let moveFolder = $state('');
|
||||
|
||||
async function load() {
|
||||
loading = true;
|
||||
error = null;
|
||||
try {
|
||||
const [p, a, s, t, e] = await Promise.all([
|
||||
api.project(slug, projectId),
|
||||
api.teamAccess(slug, projectId),
|
||||
api.sessions(slug, projectId),
|
||||
api.teams(slug),
|
||||
api.explorer(slug)
|
||||
]);
|
||||
proj = p;
|
||||
access = a.access;
|
||||
sessions = s.sessions;
|
||||
teams = t.teams;
|
||||
explorer = e;
|
||||
moveFolder = p.folderId ?? '';
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : String(err);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function rename() {
|
||||
if (!proj) return;
|
||||
const name = prompt('新名称', proj.name);
|
||||
if (!name) return;
|
||||
try {
|
||||
await api.renameProject(slug, projectId, name);
|
||||
await load();
|
||||
toastSuccess('已重命名');
|
||||
} catch (err) {
|
||||
toastError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
|
||||
async function archiveBinding() {
|
||||
if (!confirm('解绑当前飞书群? 用户将无法通过该群触发 agent。')) return;
|
||||
try {
|
||||
await api.archiveBinding(slug, projectId);
|
||||
await load();
|
||||
toastSuccess('已解绑飞书群');
|
||||
} catch (err) {
|
||||
toastError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
|
||||
async function archiveProject() {
|
||||
if (!confirm(`归档项目 ${proj?.name}?`)) return;
|
||||
try {
|
||||
await api.archiveProject(slug, projectId);
|
||||
window.location.href = `/admin/org/${slug}/projects`;
|
||||
} catch (err) {
|
||||
toastError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
|
||||
async function move() {
|
||||
try {
|
||||
await api.moveProject(slug, projectId, moveFolder || null);
|
||||
await load();
|
||||
toastSuccess('已移动');
|
||||
} catch (err) {
|
||||
toastError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
|
||||
async function grant() {
|
||||
if (!grantTeam) return;
|
||||
try {
|
||||
await api.grantTeamAccess(slug, projectId, { teamId: grantTeam, role: grantRole });
|
||||
grantTeam = '';
|
||||
await load();
|
||||
toastSuccess('已授权');
|
||||
} catch (err) {
|
||||
toastError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
|
||||
async function revoke(t: TeamAccessEntry) {
|
||||
if (!confirm(`撤销 ${t.teamName} 对此项目的授权?`)) return;
|
||||
try {
|
||||
await api.revokeTeamAccess(slug, projectId, t.teamId);
|
||||
await load();
|
||||
toastSuccess('已撤销授权');
|
||||
} catch (err) {
|
||||
toastError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (slug && projectId) load();
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if loading}
|
||||
<LoadingState />
|
||||
{:else if error}
|
||||
<ErrorBanner message={error} onretry={load} />
|
||||
{:else if proj}
|
||||
<div class="mb-2">
|
||||
<a href={`/admin/org/${slug}/projects`} class="inline-flex items-center gap-1 text-sm text-surface-500 hover:text-primary-600">
|
||||
← 返回项目列表
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{@const detail = proj}
|
||||
<PageHeader title={detail.name} description="项目是权限边界;通过团队授予 READ ⊂ EDIT ⊂ MANAGE。">
|
||||
{#snippet actions()}
|
||||
<button class="saas-btn-secondary !py-1.5 text-sm" onclick={rename}>重命名</button>
|
||||
{#if detail.binding}
|
||||
<button class="saas-btn-secondary !py-1.5 text-sm" onclick={archiveBinding}>解绑飞书群</button>
|
||||
{/if}
|
||||
<button class="saas-btn-danger !py-1.5 text-sm" onclick={archiveProject}>归档</button>
|
||||
{/snippet}
|
||||
</PageHeader>
|
||||
|
||||
<div class="saas-card-pad mb-6">
|
||||
<dl class="grid gap-x-8 gap-y-3 text-sm sm:grid-cols-2">
|
||||
<div>
|
||||
<dt class="text-xs font-medium uppercase tracking-wide text-surface-400">Workspace</dt>
|
||||
<dd class="mt-0.5 break-all font-mono text-xs text-surface-700">{proj.workspaceDir}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-xs font-medium uppercase tracking-wide text-surface-400">创建者</dt>
|
||||
<dd class="mt-0.5 text-surface-800">
|
||||
{proj.createdBy ? `${proj.createdBy.displayName} (${proj.createdBy.feishuOpenId})` : '—'}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-xs font-medium uppercase tracking-wide text-surface-400">文件夹</dt>
|
||||
<dd class="mt-0.5 text-surface-800">{proj.folder ? proj.folder.name : '(根)'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-xs font-medium uppercase tracking-wide text-surface-400">飞书群</dt>
|
||||
<dd class="mt-0.5 text-surface-800">
|
||||
{#if proj.binding}
|
||||
<span class="saas-badge-success mr-1">已绑定</span>
|
||||
<span class="font-mono text-xs">chat {proj.binding.chatId}</span>
|
||||
<span class="text-surface-400"> · {fmtDate(proj.binding.createdAt)}</span>
|
||||
{:else}
|
||||
<span class="saas-badge-neutral">未绑定</span>
|
||||
{/if}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-xs font-medium uppercase tracking-wide text-surface-400">创建时间</dt>
|
||||
<dd class="mt-0.5 text-surface-800">{fmtDate(proj.createdAt)}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
{#if explorer}
|
||||
<div class="mt-5 flex flex-wrap items-center gap-2 border-t border-surface-100 pt-4">
|
||||
<span class="text-sm font-medium text-surface-700">移动到文件夹</span>
|
||||
<select class="saas-select max-w-xs" bind:value={moveFolder}>
|
||||
<option value="">(根)</option>
|
||||
{#each explorer.folders as f}
|
||||
<option value={f.id}>{f.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<button class="saas-btn-secondary" onclick={move}>移动</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="saas-card-pad mb-6">
|
||||
<h3 class="saas-section-title mb-1">团队授权</h3>
|
||||
<p class="saas-muted mb-4">通过团队授权项目访问。一项目可授多团队,一团队可访问多项目。</p>
|
||||
|
||||
<div class="mb-4 grid gap-2 sm:grid-cols-[1fr_auto_auto]">
|
||||
<select class="saas-select" bind:value={grantTeam}>
|
||||
<option value="">选择团队…</option>
|
||||
{#each teams as t}
|
||||
<option value={t.id}>{t.name} ({t.slug})</option>
|
||||
{/each}
|
||||
</select>
|
||||
<select class="saas-select" bind:value={grantRole}>
|
||||
{#each [...PERMISSION_ROLES] as r}<option value={r}>{r}</option>{/each}
|
||||
</select>
|
||||
<button class="saas-btn-primary" onclick={grant}>授权</button>
|
||||
</div>
|
||||
|
||||
{#if access.length === 0}
|
||||
<EmptyState title="暂无团队授权" description="选择团队并授予角色以开放项目访问。" />
|
||||
{:else}
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>团队</th>
|
||||
<th>slug</th>
|
||||
<th>角色</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each access as g}
|
||||
<tr>
|
||||
<td class="font-medium">{g.teamName}</td>
|
||||
<td class="font-mono text-xs">/{g.teamSlug}</td>
|
||||
<td><span class="saas-badge-primary">{g.role}</span></td>
|
||||
<td class="text-right">
|
||||
<button class="saas-btn-danger !py-1 text-xs" onclick={() => revoke(g)}>撤销</button>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="saas-card overflow-hidden">
|
||||
<div class="border-b border-surface-200 px-5 py-3">
|
||||
<h3 class="text-sm font-semibold">Agent 会话</h3>
|
||||
</div>
|
||||
{#if sessions.length === 0}
|
||||
<EmptyState title="暂无会话" description="飞书侧触发 agent 后会显示在此。" />
|
||||
{:else}
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>provider / role</th>
|
||||
<th>model</th>
|
||||
<th>运行</th>
|
||||
<th>更新</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each sessions as s}
|
||||
<tr>
|
||||
<td class="font-mono text-xs">{s.provider} / {s.roleId}</td>
|
||||
<td class="font-mono text-xs">{s.model}</td>
|
||||
<td class="tabular-nums">{s.runCount}</td>
|
||||
<td class="text-surface-500">{fmtDate(s.updatedAt)}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,145 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { api, type ProviderConnection } from '$lib/api';
|
||||
import { fmtDate } from '$lib/format';
|
||||
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||
import LoadingState from '$lib/components/LoadingState.svelte';
|
||||
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
|
||||
import { toastError, toastSuccess } from '$lib/toast';
|
||||
|
||||
const slug = $derived(page.params.slug ?? '');
|
||||
|
||||
let conn = $state<ProviderConnection | null>(null);
|
||||
let loading = $state(true);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
let mode = $state<'BYOK' | 'PLATFORM_MANAGED'>('PLATFORM_MANAGED');
|
||||
let providerId = $state('openrouter');
|
||||
let baseUrl = $state('');
|
||||
let authToken = $state('');
|
||||
let saving = $state(false);
|
||||
|
||||
async function load() {
|
||||
loading = true;
|
||||
error = null;
|
||||
try {
|
||||
conn = await api.provider(slug);
|
||||
mode = conn.mode;
|
||||
providerId = conn.providerId;
|
||||
baseUrl = conn.baseUrl ?? '';
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : String(err);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function save() {
|
||||
saving = true;
|
||||
const body: { mode: string; providerId: string; baseUrl?: string; authToken?: string } = {
|
||||
mode,
|
||||
providerId: providerId.trim()
|
||||
};
|
||||
if (mode === 'BYOK') {
|
||||
if (baseUrl.trim()) body.baseUrl = baseUrl.trim();
|
||||
if (authToken !== '') body.authToken = authToken;
|
||||
}
|
||||
try {
|
||||
conn = await api.setProvider(slug, body);
|
||||
mode = conn.mode;
|
||||
providerId = conn.providerId;
|
||||
baseUrl = conn.baseUrl ?? '';
|
||||
authToken = '';
|
||||
toastSuccess('Provider 配置已保存');
|
||||
} catch (err) {
|
||||
toastError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (slug) load();
|
||||
});
|
||||
</script>
|
||||
|
||||
<PageHeader
|
||||
title="Provider 连接"
|
||||
description="ADR-0021:连接归属且仅归属本 org。BYOK 自带密钥;PLATFORM_MANAGED 平台托管。"
|
||||
/>
|
||||
|
||||
{#if loading}
|
||||
<LoadingState />
|
||||
{:else if error}
|
||||
<ErrorBanner message={error} onretry={load} />
|
||||
{:else if conn}
|
||||
<div class="saas-card-pad mb-6">
|
||||
<div class="grid gap-5">
|
||||
<div>
|
||||
<label class="saas-label" for="mode">凭据模式</label>
|
||||
<select id="mode" class="saas-select" bind:value={mode}>
|
||||
<option value="PLATFORM_MANAGED">PLATFORM_MANAGED(平台托管)</option>
|
||||
<option value="BYOK">BYOK(自带密钥)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="saas-label" for="provider-id">providerId</label>
|
||||
<input id="provider-id" class="saas-input font-mono text-sm" bind:value={providerId} />
|
||||
</div>
|
||||
|
||||
{#if mode === 'BYOK'}
|
||||
<div>
|
||||
<label class="saas-label" for="base-url">Base URL</label>
|
||||
<input
|
||||
id="base-url"
|
||||
class="saas-input"
|
||||
placeholder="https://openrouter.ai/api"
|
||||
bind:value={baseUrl}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="saas-label" for="auth-token">Auth Token</label>
|
||||
<input
|
||||
id="auth-token"
|
||||
class="saas-input"
|
||||
type="password"
|
||||
placeholder={conn.hasAuthToken ? '已设置(留空则不变)' : 'auth token'}
|
||||
bind:value={authToken}
|
||||
/>
|
||||
{#if conn.hasAuthToken}
|
||||
<p class="saas-help">已存储密钥;输入新值替换,留空不变。</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex items-center gap-3 border-t border-surface-100 pt-4">
|
||||
<span class="text-xs text-surface-400"
|
||||
>{conn.updatedAt ? '更新于 ' + fmtDate(conn.updatedAt) : '尚未配置'}</span
|
||||
>
|
||||
<div class="flex-1"></div>
|
||||
<button class="saas-btn-primary" onclick={save} disabled={saving}>
|
||||
{saving ? '保存中…' : '保存'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="saas-card-pad border-dashed">
|
||||
<h3 class="saas-section-title mb-2">运行时解析</h3>
|
||||
<ul class="space-y-2 text-sm text-surface-500">
|
||||
<li class="flex gap-2"><span class="text-primary-500">•</span><span>BYOK:agent 使用本连接 baseUrl + authToken。</span></li>
|
||||
<li class="flex gap-2">
|
||||
<span class="text-primary-500">•</span>
|
||||
<span>
|
||||
PLATFORM_MANAGED / 未配置:回退进程环境变量(ANTHROPIC_BASE_URL / ANTHROPIC_AUTH_TOKEN)。真正 per-org
|
||||
平台托管凭据是后续 secret-control-plane(OPEN)。
|
||||
</span>
|
||||
</li>
|
||||
<li class="flex gap-2">
|
||||
<span class="text-primary-500">•</span>
|
||||
<span>模型与角色在对应页面管理;空时回退环境默认。</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,106 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { api, type OrgRoleRow, type OrgModelRow } from '$lib/api';
|
||||
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||
import LoadingState from '$lib/components/LoadingState.svelte';
|
||||
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
|
||||
import EmptyState from '$lib/components/EmptyState.svelte';
|
||||
import RoleCard from '$lib/components/RoleCard.svelte';
|
||||
import { toastError, toastSuccess } from '$lib/toast';
|
||||
|
||||
const slug = $derived(page.params.slug ?? '');
|
||||
|
||||
let roles = $state<OrgRoleRow[]>([]);
|
||||
let models = $state<OrgModelRow[]>([]);
|
||||
let loading = $state(true);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
let newRoleId = $state('');
|
||||
let newLabel = $state('');
|
||||
let adding = $state(false);
|
||||
|
||||
async function load() {
|
||||
loading = true;
|
||||
error = null;
|
||||
try {
|
||||
const [r, m] = await Promise.all([api.roles(slug), api.models(slug)]);
|
||||
roles = r.roles;
|
||||
models = m.models;
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : String(err);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function add() {
|
||||
if (!newRoleId.trim() || !newLabel.trim()) return;
|
||||
adding = true;
|
||||
try {
|
||||
const r = await api.createRole(slug, { roleId: newRoleId.trim(), label: newLabel.trim() });
|
||||
roles = [...roles, r];
|
||||
newRoleId = '';
|
||||
newLabel = '';
|
||||
toastSuccess('角色已创建');
|
||||
} catch (err) {
|
||||
toastError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
adding = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(r: OrgRoleRow) {
|
||||
if (!confirm(`删除角色 ${r.label} (${r.roleId})?`)) return;
|
||||
try {
|
||||
await api.deleteRole(slug, r.id);
|
||||
roles = roles.filter((x) => x.id !== r.id);
|
||||
toastSuccess('角色已删除');
|
||||
} catch (err) {
|
||||
toastError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (slug) load();
|
||||
});
|
||||
</script>
|
||||
|
||||
<PageHeader
|
||||
title="角色"
|
||||
description="ADR-0017:角色是数据。roleId 即斜杠命令(/draft…),绑定默认模型、工具白名单与系统提示词。"
|
||||
/>
|
||||
|
||||
{#if loading}
|
||||
<LoadingState />
|
||||
{:else if error}
|
||||
<ErrorBanner message={error} onretry={load} />
|
||||
{:else}
|
||||
<div class="saas-card-pad mb-6">
|
||||
<h2 class="saas-section-title mb-4">新建角色</h2>
|
||||
<div class="grid gap-3 sm:grid-cols-[10rem_1fr_auto]">
|
||||
<input class="saas-input font-mono text-sm" placeholder="roleId(如 draft)" bind:value={newRoleId} />
|
||||
<input class="saas-input" placeholder="label(如 草稿)" bind:value={newLabel} />
|
||||
<button class="saas-btn-primary" onclick={add} disabled={adding}>新建</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if roles.length === 0}
|
||||
<div class="saas-card">
|
||||
<EmptyState title="暂无角色" description="空时回退环境默认(draft / review)。" />
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-4">
|
||||
{#each roles as r (r.id)}
|
||||
<RoleCard
|
||||
{r}
|
||||
{models}
|
||||
{slug}
|
||||
onremoved={() => remove(r)}
|
||||
onupdated={(updated: OrgRoleRow) => {
|
||||
roles = roles.map((x) => (x.id === r.id ? updated : x));
|
||||
}}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
@@ -0,0 +1,215 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { api, type TeamRow, type TeamMemberRow } from '$lib/api';
|
||||
import { fmtDate } from '$lib/format';
|
||||
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||
import LoadingState from '$lib/components/LoadingState.svelte';
|
||||
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
|
||||
import EmptyState from '$lib/components/EmptyState.svelte';
|
||||
import { toastError, toastSuccess } from '$lib/toast';
|
||||
|
||||
const slug = $derived(page.params.slug ?? '');
|
||||
|
||||
let teams = $state<TeamRow[]>([]);
|
||||
let loading = $state(true);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
let newSlug = $state('');
|
||||
let newName = $state('');
|
||||
let newDesc = $state('');
|
||||
let adding = $state(false);
|
||||
|
||||
let expandedId = $state<string | null>(null);
|
||||
let teamMembers = $state<TeamMemberRow[]>([]);
|
||||
let memberInput = $state('');
|
||||
let loadingMembers = $state(false);
|
||||
|
||||
async function load() {
|
||||
loading = true;
|
||||
error = null;
|
||||
try {
|
||||
const res = await api.teams(slug);
|
||||
teams = res.teams;
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : String(err);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function createTeam() {
|
||||
if (!newSlug.trim() || !newName.trim()) return;
|
||||
adding = true;
|
||||
try {
|
||||
await api.createTeam(slug, {
|
||||
slug: newSlug.trim(),
|
||||
name: newName.trim(),
|
||||
...(newDesc.trim() ? { description: newDesc.trim() } : {})
|
||||
});
|
||||
newSlug = '';
|
||||
newName = '';
|
||||
newDesc = '';
|
||||
await load();
|
||||
toastSuccess('团队已创建');
|
||||
} catch (err) {
|
||||
toastError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
adding = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function archiveTeam(t: TeamRow) {
|
||||
if (!confirm(`归档团队 ${t.name}? 其活跃项目授权将被撤销。`)) return;
|
||||
try {
|
||||
await api.archiveTeam(slug, t.id);
|
||||
await load();
|
||||
toastSuccess('团队已归档');
|
||||
} catch (err) {
|
||||
toastError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleMembers(t: TeamRow) {
|
||||
if (expandedId === t.id) {
|
||||
expandedId = null;
|
||||
return;
|
||||
}
|
||||
expandedId = t.id;
|
||||
loadingMembers = true;
|
||||
try {
|
||||
const res = await api.teamMembers(slug, t.id);
|
||||
teamMembers = res.members;
|
||||
} catch (err) {
|
||||
toastError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
loadingMembers = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function addMember(t: TeamRow) {
|
||||
if (!memberInput.trim()) return;
|
||||
const v = memberInput.trim();
|
||||
try {
|
||||
await api.addTeamMember(slug, t.id, v.startsWith('ou') ? { feishuOpenId: v } : { userId: v });
|
||||
memberInput = '';
|
||||
const res = await api.teamMembers(slug, t.id);
|
||||
teamMembers = res.members;
|
||||
await load();
|
||||
toastSuccess('已加入团队');
|
||||
} catch (err) {
|
||||
toastError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
|
||||
async function revokeMember(t: TeamRow, m: TeamMemberRow) {
|
||||
if (!confirm(`将 ${m.displayName} 移出团队 ${t.name}?`)) return;
|
||||
try {
|
||||
await api.revokeTeamMember(slug, t.id, m.userId);
|
||||
const res = await api.teamMembers(slug, t.id);
|
||||
teamMembers = res.members;
|
||||
await load();
|
||||
toastSuccess('已移出团队');
|
||||
} catch (err) {
|
||||
toastError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (slug) load();
|
||||
});
|
||||
</script>
|
||||
|
||||
<PageHeader title="团队" description="团队是项目授权的 principal,可被授予 READ / EDIT / MANAGE。" />
|
||||
|
||||
{#if loading}
|
||||
<LoadingState />
|
||||
{:else if error}
|
||||
<ErrorBanner message={error} onretry={load} />
|
||||
{:else}
|
||||
<div class="saas-card-pad mb-6">
|
||||
<h2 class="saas-section-title mb-4">新建团队</h2>
|
||||
<div class="grid gap-3 md:grid-cols-[1fr_1fr_1.2fr_auto]">
|
||||
<input class="saas-input" placeholder="slug(小写字母数字)" bind:value={newSlug} />
|
||||
<input class="saas-input" placeholder="名称" bind:value={newName} />
|
||||
<input class="saas-input" placeholder="描述(可选)" bind:value={newDesc} />
|
||||
<button class="saas-btn-primary" onclick={createTeam} disabled={adding}>新建</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if teams.length === 0}
|
||||
<div class="saas-card">
|
||||
<EmptyState title="暂无团队" description="创建团队后,可在项目页授权项目访问。" />
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-3">
|
||||
{#each teams as t (t.id)}
|
||||
<div class="saas-card p-5">
|
||||
<div class="flex flex-wrap items-center gap-2.5">
|
||||
<span class="text-base font-semibold text-surface-900">{t.name}</span>
|
||||
<span class="font-mono text-xs text-surface-400">/{t.slug}</span>
|
||||
<span class="saas-badge-neutral">{t.memberCount} 成员</span>
|
||||
<div class="ml-auto flex flex-wrap gap-2">
|
||||
<button class="saas-btn-secondary !py-1.5 text-sm" onclick={() => toggleMembers(t)}>
|
||||
{expandedId === t.id ? '收起' : '管理成员'}
|
||||
</button>
|
||||
<button class="saas-btn-danger !py-1.5 text-sm" onclick={() => archiveTeam(t)}>归档</button>
|
||||
</div>
|
||||
</div>
|
||||
{#if t.description}
|
||||
<p class="mt-1.5 text-sm text-surface-500">{t.description}</p>
|
||||
{/if}
|
||||
<p class="mt-1 text-xs text-surface-400">创建于 {fmtDate(t.createdAt)}</p>
|
||||
|
||||
{#if expandedId === t.id}
|
||||
<div class="mt-4 border-t border-surface-200 pt-4">
|
||||
{#if loadingMembers}
|
||||
<p class="text-sm text-surface-400">加载中…</p>
|
||||
{:else}
|
||||
<div class="mb-4 flex gap-2">
|
||||
<input
|
||||
class="saas-input"
|
||||
placeholder="Feishu open_id 或用户 id"
|
||||
bind:value={memberInput}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Enter') addMember(t);
|
||||
}}
|
||||
/>
|
||||
<button class="saas-btn-secondary shrink-0" onclick={() => addMember(t)}>加入</button>
|
||||
</div>
|
||||
{#if teamMembers.length === 0}
|
||||
<p class="py-3 text-center text-sm text-surface-400">团队暂无成员</p>
|
||||
{:else}
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>成员</th>
|
||||
<th>open_id</th>
|
||||
<th>加入时间</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each teamMembers as m}
|
||||
<tr>
|
||||
<td class="font-medium">{m.displayName}</td>
|
||||
<td class="font-mono text-xs">{m.feishuOpenId}</td>
|
||||
<td class="text-surface-500">{fmtDate(m.createdAt)}</td>
|
||||
<td class="text-right">
|
||||
<button class="saas-btn-danger !py-1 text-xs" onclick={() => revokeMember(t, m)}
|
||||
>移除</button
|
||||
>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
<p class="mt-4 text-xs text-surface-400">归档团队会同步撤销其活跃的项目授权。</p>
|
||||
{/if}
|
||||
Reference in New Issue
Block a user