forked from EduCraft/curriculum-project-hub
7f09fb1f13
Silo hostname already carries tenancy. Admin SPA routes become /admin/..., legacy bookmarks redirect, login lands on /admin. Co-authored-by: Hong Jiarong <me@jrhim.com> Co-committed-by: Hong Jiarong <me@jrhim.com>
391 lines
12 KiB
Svelte
391 lines
12 KiB
Svelte
<script lang="ts">
|
||
import { page } from '$app/state';
|
||
import {
|
||
api,
|
||
type ProjectDetail,
|
||
type TeamAccessEntry,
|
||
type TeamRow,
|
||
type SessionSummary,
|
||
type ExplorerData,
|
||
type ProjectUsageReport,
|
||
} from '$lib/api';
|
||
import { session } from '$lib/session';
|
||
import { resolveOrg } from '$lib/org';
|
||
import {
|
||
fmtCost,
|
||
fmtDate,
|
||
fmtNum,
|
||
fmtQuantity,
|
||
fmtTokens,
|
||
permissionRoleLabel,
|
||
usageKindLabel,
|
||
} from '$lib/format';
|
||
import { PERMISSION_ROLES, PERMISSION_ROLE_LABELS } 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 SelectField from '$lib/components/SelectField.svelte';
|
||
import Icon from '$lib/components/Icon.svelte';
|
||
import { toastError, toastSuccess } from '$lib/toast';
|
||
|
||
const org = $derived(resolveOrg($session.me, page.url.search));
|
||
const slug = $derived(org?.slug ?? '');
|
||
const projectId = $derived(page.params.projectId ?? '');
|
||
const roleItems = PERMISSION_ROLES.map((r) => ({ value: r, label: PERMISSION_ROLE_LABELS[r] }));
|
||
const roleChain = `${PERMISSION_ROLE_LABELS.READ} ⊂ ${PERMISSION_ROLE_LABELS.EDIT} ⊂ ${PERMISSION_ROLE_LABELS.MANAGE}`;
|
||
|
||
let proj = $state<ProjectDetail | null>(null);
|
||
let access = $state<TeamAccessEntry[]>([]);
|
||
let sessions = $state<SessionSummary[]>([]);
|
||
let projectUsage = $state<ProjectUsageReport | null>(null);
|
||
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('');
|
||
|
||
const actorIsOrgAdmin = $derived(proj?.actorIsOrgAdmin ?? false);
|
||
const actorCanManage = $derived(proj?.actorCanManageProject ?? false);
|
||
|
||
async function load() {
|
||
loading = true;
|
||
error = null;
|
||
try {
|
||
// Project detail + team-access list are gated to project read/oversight.
|
||
const [p, a] = await Promise.all([api.project(slug, projectId), api.teamAccess(slug, projectId)]);
|
||
proj = p;
|
||
access = a.access;
|
||
// Team list is needed for grant UI whenever the actor has project MANAGE
|
||
// (org admin or member). Sessions/explorer stay org-admin oversight only.
|
||
const needTeams = p.actorIsOrgAdmin === true || p.actorCanManageProject === true;
|
||
const [s, t, e, u] = await Promise.all([
|
||
p.actorIsOrgAdmin ? api.sessions(slug, projectId) : Promise.resolve({ sessions: [] as SessionSummary[] }),
|
||
needTeams ? api.teams(slug) : Promise.resolve({ teams: [] as TeamRow[] }),
|
||
p.actorIsOrgAdmin ? api.explorer(slug) : Promise.resolve(null as ExplorerData | null),
|
||
p.actorIsOrgAdmin
|
||
? api.projectUsage(slug, projectId)
|
||
: Promise.resolve(null as ProjectUsageReport | null),
|
||
]);
|
||
sessions = s.sessions;
|
||
teams = t.teams;
|
||
explorer = e;
|
||
projectUsage = u;
|
||
if (p.actorIsOrgAdmin) {
|
||
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('解绑当前飞书群? 用户将无法通过该群触发智能体。')) 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/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));
|
||
}
|
||
}
|
||
|
||
function folderItems() {
|
||
const items = [{ value: '', label: '(根)' }];
|
||
if (!explorer) return items;
|
||
return [...items, ...explorer.folders.map((f) => ({ value: f.id, label: f.name }))];
|
||
}
|
||
|
||
function teamItems() {
|
||
return [{ value: '', label: '选择团队…' }, ...teams.map((t) => ({ value: t.id, label: `${t.name}(${t.slug})` }))];
|
||
}
|
||
|
||
$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/projects`}
|
||
class="inline-flex items-center gap-1 text-sm text-surface-700 hover:text-primary-600"
|
||
>
|
||
<Icon name="arrow-left" class="h-4 w-4" />
|
||
返回项目列表
|
||
</a>
|
||
</div>
|
||
|
||
{@const detail = proj}
|
||
<PageHeader title={detail.name} description={`项目是权限边界;通过团队授予 ${roleChain}。`}>
|
||
{#snippet actions()}
|
||
{#if actorIsOrgAdmin}
|
||
<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>
|
||
{/if}
|
||
{/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-600">工作区路径</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-600">创建者</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-600">文件夹</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-600">飞书群</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">群 {proj.binding.chatId}</span>
|
||
<span class="text-surface-600"> · {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-600">创建时间</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-end gap-2 border-t border-surface-100 pt-4">
|
||
<div class="min-w-48 flex-1">
|
||
<p class="saas-label">移动到文件夹</p>
|
||
<SelectField items={folderItems()} bind:value={moveFolder} />
|
||
</div>
|
||
<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>
|
||
|
||
{#if actorCanManage}
|
||
<div class="mb-4 grid gap-2 sm:grid-cols-[1fr_10rem_auto]">
|
||
<SelectField items={teamItems()} bind:value={grantTeam} />
|
||
<SelectField items={roleItems} bind:value={grantRole} />
|
||
<button class="saas-btn-primary" onclick={grant}>授权</button>
|
||
</div>
|
||
{:else}
|
||
<p class="mb-4 text-xs text-surface-600">需要项目 MANAGE 授权才能增删团队访问。</p>
|
||
{/if}
|
||
|
||
{#if access.length === 0}
|
||
<EmptyState title="暂无团队授权" description="选择团队并授予角色以开放项目访问。" />
|
||
{:else}
|
||
<table class="data-table">
|
||
<thead>
|
||
<tr>
|
||
<th>团队</th>
|
||
<th>标识</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">{permissionRoleLabel(g.role)}</span></td>
|
||
<td class="text-right">
|
||
{#if actorCanManage}
|
||
<button class="saas-btn-danger py-1! text-xs" onclick={() => revoke(g)}>撤销</button>
|
||
{:else}
|
||
<span class="text-xs text-surface-500">—</span>
|
||
{/if}
|
||
</td>
|
||
</tr>
|
||
{/each}
|
||
</tbody>
|
||
</table>
|
||
{/if}
|
||
</div>
|
||
|
||
{#if actorIsOrgAdmin}
|
||
{#if projectUsage}
|
||
<div class="saas-card overflow-hidden mb-6">
|
||
<div class="border-b border-surface-200 px-5 py-3 flex items-center justify-between gap-3">
|
||
<div>
|
||
<h3 class="text-sm font-semibold">项目用量分账</h3>
|
||
<p class="saas-muted mt-0.5 text-xs">
|
||
{fmtNum(projectUsage.runCount)} 次运行 · 成本 {fmtCost(projectUsage.costUsd)} · tokens
|
||
{fmtTokens(projectUsage.inputTokens, projectUsage.outputTokens)}
|
||
</p>
|
||
</div>
|
||
<a class="text-sm text-primary-700 hover:underline" href={`/admin/usage`}>组织报告</a>
|
||
</div>
|
||
{#if projectUsage.breakdown.length === 0}
|
||
<div class="px-5 py-4 text-sm text-surface-600">尚无 UsageFact。</div>
|
||
{:else}
|
||
<div class="overflow-x-auto">
|
||
<table class="data-table">
|
||
<thead>
|
||
<tr>
|
||
<th>类型</th>
|
||
<th>供应方</th>
|
||
<th>模型 / 能力</th>
|
||
<th>次数</th>
|
||
<th>计量</th>
|
||
<th>成本</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{#each projectUsage.breakdown as row}
|
||
<tr>
|
||
<td>
|
||
<span
|
||
class={row.kind === 'external_capability' ? 'saas-badge-primary' : 'saas-badge-success'}
|
||
>
|
||
{usageKindLabel(row.kind)}
|
||
</span>
|
||
</td>
|
||
<td class="font-mono text-xs">{row.provider}</td>
|
||
<td class="font-mono text-xs">{row.capabilityId ?? row.model ?? '—'}</td>
|
||
<td class="tabular-nums">{fmtNum(row.factCount)}</td>
|
||
<td class="tabular-nums text-xs">
|
||
{#if row.unit}
|
||
{fmtQuantity(row.quantity, row.unit)}
|
||
{:else}
|
||
{fmtTokens(row.inputTokens, row.outputTokens)}
|
||
{/if}
|
||
</td>
|
||
<td class="tabular-nums">{fmtCost(row.costUsd)}</td>
|
||
</tr>
|
||
{/each}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
{/if}
|
||
</div>
|
||
{/if}
|
||
|
||
<div class="saas-card overflow-hidden">
|
||
<div class="border-b border-surface-200 px-5 py-3">
|
||
<h3 class="text-sm font-semibold">智能体会话</h3>
|
||
<p class="saas-muted mt-0.5 text-xs">点进会话可查看每次 run 的 UsageFact 分账(模型 / 外部能力)。</p>
|
||
</div>
|
||
{#if sessions.length === 0}
|
||
<EmptyState title="暂无会话" description="飞书侧触发智能体后会显示在此。" />
|
||
{:else}
|
||
<table class="data-table">
|
||
<thead>
|
||
<tr>
|
||
<th>供应方 / 角色</th>
|
||
<th>模型</th>
|
||
<th>运行次数</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-700">{fmtDate(s.updatedAt)}</td>
|
||
<td class="text-right">
|
||
<a
|
||
class="text-sm text-primary-700 hover:underline"
|
||
href={`/admin/sessions/${s.id}`}
|
||
>
|
||
详情
|
||
</a>
|
||
</td>
|
||
</tr>
|
||
{/each}
|
||
</tbody>
|
||
</table>
|
||
{/if}
|
||
</div>
|
||
{/if}
|
||
{:else}
|
||
<div class="saas-empty">
|
||
<p class="text-sm text-surface-600">项目数据不可用</p>
|
||
</div>
|
||
{/if}
|