refactor(admin-web): polish Chinese UI and bits-ui controls

Map roles to Chinese labels, remove ADR/spec wording from the surface, and replace native selects/checkboxes/modals with bits-ui Select, Checkbox, Switch, Dialog, Label, and Collapsible.
This commit is contained in:
2026-07-11 12:46:54 +08:00
parent 552c1c353e
commit 0968545b5a
17 changed files with 586 additions and 235 deletions
@@ -2,11 +2,12 @@
import { page } from '$app/state';
import { api, type OrgMembership } from '$lib/api';
import { session } from '$lib/session';
import { fmtCost, fmtNum } from '$lib/format';
import { fmtCost, fmtNum, orgRoleLabel } 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 SwitchControl from '$lib/components/SwitchControl.svelte';
import { toastError, toastSuccess } from '$lib/toast';
let orgSlug = $derived(page.params.slug ?? '');
@@ -32,14 +33,14 @@
}
}
async function toggleCreate() {
if (!settings) return;
async function setMembersCanCreate(next: boolean) {
if (!settings || settings.membersCanCreateProjects === next) return;
saving = true;
const prev = settings.membersCanCreateProjects;
settings.membersCanCreateProjects = !prev;
settings.membersCanCreateProjects = next;
try {
await api.setSettings(orgSlug, { membersCanCreateProjects: settings.membersCanCreateProjects });
toastSuccess(settings.membersCanCreateProjects ? '已允许成员自助建项' : '已关闭成员自助建项');
await api.setSettings(orgSlug, { membersCanCreateProjects: next });
toastSuccess(next ? '已允许成员自助建项' : '已关闭成员自助建项');
} catch (err) {
settings.membersCanCreateProjects = prev;
toastError(err instanceof Error ? err.message : String(err));
@@ -74,7 +75,7 @@
</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>
<dd><span class="saas-badge-primary">{orgRoleLabel(org.role)}</span></dd>
</div>
</dl>
</div>
@@ -82,28 +83,26 @@
<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"
<div class="flex items-center gap-3 rounded-xl border border-surface-200 bg-surface-100/50 px-4 py-3">
<SwitchControl
checked={settings.membersCanCreateProjects}
disabled={saving}
onchange={toggleCreate}
onchange={setMembersCanCreate}
/>
<div>
<div class="text-sm font-medium text-surface-800">允许成员自助创建项目</div>
<div class="text-xs text-surface-400">membersCanCreateProjects</div>
<div class="text-xs text-surface-400">普通成员在飞书群中自助建项</div>
</div>
</label>
</div>
</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>
<p class="saas-muted">全组织智能体运行汇总</p>
</div>
</div>
@@ -2,15 +2,17 @@
import { page } from '$app/state';
import { api, type OrgMember } from '$lib/api';
import { fmtDate } from '$lib/format';
import { ORG_ROLES } from '$lib/constants';
import { ORG_ROLES, ORG_ROLE_LABELS, 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 { toastError, toastSuccess } from '$lib/toast';
const slug = $derived(page.params.slug ?? '');
const roles = [...ORG_ROLES];
const roleItems = ORG_ROLES.map((r) => ({ value: r, label: ORG_ROLE_LABELS[r] }));
const permHint = Object.values(PERMISSION_ROLE_LABELS).join(' / ');
let members = $state<OrgMember[]>([]);
let loading = $state(true);
@@ -82,7 +84,7 @@
});
</script>
<PageHeader title="成员与权限" description="管理组织角色:OWNER / ADMIN 可访问本后台,MEMBER 不可。" />
<PageHeader title="成员与权限" description="管理组织角色:所有者与管理员可访问本后台,成员不可。" />
{#if loading}
<LoadingState />
@@ -91,12 +93,10 @@
{: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} />
<div class="grid gap-3 md:grid-cols-[1.2fr_1fr_12rem_auto]">
<input class="saas-input" placeholder="飞书 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>
<SelectField items={roleItems} bind:value={newRole} />
<button class="saas-btn-primary" onclick={addMember} disabled={adding}>
{adding ? '添加中…' : '添加成员'}
</button>
@@ -140,16 +140,14 @@
</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"
<td class="min-w-[9rem]">
<SelectField
items={roleItems}
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>
onchange={(role) => {
if (role !== m.role) changeRole(m, role);
}}
/>
</td>
<td class="text-surface-500">{fmtDate(m.createdAt)}</td>
<td class="text-right">
@@ -162,7 +160,7 @@
</div>
{/if}
<p class="border-t border-surface-100 px-5 py-3 text-xs text-surface-400">
组织角色控制后台访问;项目级权限由「项目」页团队授权(READ / EDIT / MANAGE)决定。
组织角色控制后台访问;项目级权限由「项目」页团队授权({permHint})决定。
</p>
</div>
{/if}
@@ -6,6 +6,7 @@
import LoadingState from '$lib/components/LoadingState.svelte';
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
import EmptyState from '$lib/components/EmptyState.svelte';
import CheckboxControl from '$lib/components/CheckboxControl.svelte';
import { toastError, toastSuccess } from '$lib/toast';
const slug = $derived(page.params.slug ?? '');
@@ -82,7 +83,7 @@
<PageHeader
title="模型"
description="Org-scoped 模型清单(ADR-0017)。modelId 面向 providerlabel 面向教师。"
description="本组织启用的模型清单。模型 ID 面向供应方接口;显示名面向教师侧切换。"
/>
{#if loading}
@@ -95,10 +96,10 @@
<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"
placeholder="模型 ID(如 anthropic/claude-sonnet-5"
bind:value={newModelId}
/>
<input class="saas-input" placeholder="label(如 Claude Sonnet 5" bind:value={newLabel} />
<input class="saas-input" placeholder="显示名(如 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>
@@ -116,8 +117,8 @@
<table class="data-table">
<thead>
<tr>
<th>label</th>
<th>modelId</th>
<th>显示名</th>
<th>模型 ID</th>
<th>工具能力</th>
<th>排序</th>
<th>创建</th>
@@ -142,11 +143,9 @@
/>
</td>
<td>
<input
type="checkbox"
class="checkbox"
<CheckboxControl
checked={m.toolCapable}
onchange={(e) => save(m, { toolCapable: (e.target as HTMLInputElement).checked })}
onchange={(toolCapable) => save(m, { toolCapable })}
/>
</td>
<td>
@@ -6,7 +6,9 @@
import LoadingState from '$lib/components/LoadingState.svelte';
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
import EmptyState from '$lib/components/EmptyState.svelte';
import { Label } from 'bits-ui';
import Modal from '$lib/components/Modal.svelte';
import SelectField from '$lib/components/SelectField.svelte';
import { toastError, toastSuccess } from '$lib/toast';
const slug = $derived(page.params.slug ?? '');
@@ -81,6 +83,14 @@
return parts.join(' / ');
}
function folderItems() {
if (!data) return [{ value: '', label: '(根)' }];
return [
{ value: '', label: '(根)' },
...data.folders.map((f) => ({ value: f.id, label: folderPath(f) }))
];
}
$effect(() => {
if (slug) load();
});
@@ -108,7 +118,7 @@
{/if}
<Modal bind:open={showFolderModal} title="新建文件夹">
<label class="saas-label" for="folder-name">名称</label>
<Label.Root class="saas-label" for="folder-name">名称</Label.Root>
<input
id="folder-name"
class="saas-input mb-4"
@@ -118,13 +128,10 @@
}}
/>
{#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>
<p class="saas-label">父文件夹(可选)</p>
<div class="mb-4">
<SelectField items={folderItems()} bind:value={folderParent} />
</div>
{/if}
<div class="flex justify-end gap-2">
<button class="saas-btn-ghost" onclick={() => (showFolderModal = false)}>取消</button>
@@ -133,7 +140,7 @@
</Modal>
<Modal bind:open={showProjectModal} title="新建项目">
<label class="saas-label" for="project-name">项目名</label>
<Label.Root class="saas-label" for="project-name">项目名</Label.Root>
<input
id="project-name"
class="saas-input mb-4"
@@ -143,13 +150,10 @@
}}
/>
{#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>
<p class="saas-label">文件夹(可选)</p>
<div class="mb-4">
<SelectField items={folderItems()} bind:value={projectFolder} />
</div>
{/if}
<div class="flex justify-end gap-2">
<button class="saas-btn-ghost" onclick={() => (showProjectModal = false)}>取消</button>
@@ -8,16 +8,19 @@
type SessionSummary,
type ExplorerData
} from '$lib/api';
import { fmtDate } from '$lib/format';
import { PERMISSION_ROLES } from '$lib/constants';
import { fmtDate, permissionRoleLabel } 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 { toastError, toastSuccess } from '$lib/toast';
const slug = $derived(page.params.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[]>([]);
@@ -69,7 +72,7 @@
}
async function archiveBinding() {
if (!confirm('解绑当前飞书群? 用户将无法通过该群触发 agent。')) return;
if (!confirm('解绑当前飞书群? 用户将无法通过该群触发智能体。')) return;
try {
await api.archiveBinding(slug, projectId);
await load();
@@ -122,6 +125,22 @@
}
}
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();
});
@@ -139,7 +158,7 @@
</div>
{@const detail = proj}
<PageHeader title={detail.name} description="项目是权限边界;通过团队授予 READ EDIT MANAGE。">
<PageHeader title={detail.name} description={`项目是权限边界;通过团队授予 ${roleChain}。`}>
{#snippet actions()}
<button class="saas-btn-secondary !py-1.5 text-sm" onclick={rename}>重命名</button>
{#if detail.binding}
@@ -152,7 +171,7 @@
<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>
<dt class="text-xs font-medium uppercase tracking-wide text-surface-400">工作区路径</dt>
<dd class="mt-0.5 break-all font-mono text-xs text-surface-700">{proj.workspaceDir}</dd>
</div>
<div>
@@ -170,7 +189,7 @@
<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="font-mono text-xs"> {proj.binding.chatId}</span>
<span class="text-surface-400"> · {fmtDate(proj.binding.createdAt)}</span>
{:else}
<span class="saas-badge-neutral">未绑定</span>
@@ -184,14 +203,11 @@
</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>
<div class="mt-5 flex flex-wrap items-end gap-2 border-t border-surface-100 pt-4">
<div class="min-w-[12rem] flex-1">
<p class="saas-label">移动到文件夹</p>
<SelectField items={folderItems()} bind:value={moveFolder} />
</div>
<button class="saas-btn-secondary" onclick={move}>移动</button>
</div>
{/if}
@@ -201,16 +217,9 @@
<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>
<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>
@@ -221,7 +230,7 @@
<thead>
<tr>
<th>团队</th>
<th>slug</th>
<th>标识</th>
<th>角色</th>
<th></th>
</tr>
@@ -231,7 +240,7 @@
<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><span class="saas-badge-primary">{permissionRoleLabel(g.role)}</span></td>
<td class="text-right">
<button class="saas-btn-danger !py-1 text-xs" onclick={() => revoke(g)}>撤销</button>
</td>
@@ -244,17 +253,17 @@
<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>
<h3 class="text-sm font-semibold">智能体会话</h3>
</div>
{#if sessions.length === 0}
<EmptyState title="暂无会话" description="飞书侧触发 agent 后会显示在此。" />
<EmptyState title="暂无会话" description="飞书侧触发智能体后会显示在此。" />
{:else}
<table class="data-table">
<thead>
<tr>
<th>provider / role</th>
<th>model</th>
<th>运行</th>
<th>供应方 / 角色</th>
<th>模型</th>
<th>运行次数</th>
<th>更新</th>
</tr>
</thead>
@@ -2,12 +2,16 @@
import { page } from '$app/state';
import { api, type ProviderConnection } from '$lib/api';
import { fmtDate } from '$lib/format';
import { PROVIDER_MODES } from '$lib/constants';
import { Label } from 'bits-ui';
import PageHeader from '$lib/components/PageHeader.svelte';
import LoadingState from '$lib/components/LoadingState.svelte';
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
import SelectField from '$lib/components/SelectField.svelte';
import { toastError, toastSuccess } from '$lib/toast';
const slug = $derived(page.params.slug ?? '');
const modeItems = PROVIDER_MODES.map((m) => ({ value: m.value, label: m.label }));
let conn = $state<ProviderConnection | null>(null);
let loading = $state(true);
@@ -50,7 +54,7 @@
providerId = conn.providerId;
baseUrl = conn.baseUrl ?? '';
authToken = '';
toastSuccess('Provider 配置已保存');
toastSuccess('供应方配置已保存');
} catch (err) {
toastError(err instanceof Error ? err.message : String(err));
} finally {
@@ -64,8 +68,8 @@
</script>
<PageHeader
title="Provider 连接"
description="ADR-0021:连接归属且仅归属本 org。BYOK 自带密钥;PLATFORM_MANAGED 平台托管。"
title="模型供应方"
description="连接归属且仅归属本组织。可选择自带密钥,或使用平台为该组织单独托管的凭据。"
/>
{#if loading}
@@ -76,21 +80,24 @@
<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>
<p class="saas-label">凭据模式</p>
<SelectField
items={modeItems}
value={mode}
onchange={(v) => {
if (v === 'BYOK' || v === 'PLATFORM_MANAGED') mode = v;
}}
/>
</div>
<div>
<label class="saas-label" for="provider-id">providerId</label>
<Label.Root class="saas-label" for="provider-id">供应方 ID</Label.Root>
<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>
<Label.Root class="saas-label" for="base-url">接口地址</Label.Root>
<input
id="base-url"
class="saas-input"
@@ -99,12 +106,12 @@
/>
</div>
<div>
<label class="saas-label" for="auth-token">Auth Token</label>
<Label.Root class="saas-label" for="auth-token">访问令牌</Label.Root>
<input
id="auth-token"
class="saas-input"
type="password"
placeholder={conn.hasAuthToken ? '已设置(留空则不变)' : 'auth token'}
placeholder={conn.hasAuthToken ? '已设置(留空则不变)' : '填写访问令牌'}
bind:value={authToken}
/>
{#if conn.hasAuthToken}
@@ -128,17 +135,19 @@
<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>BYOKagent 使用本连接 baseUrl + authToken。</span></li>
<li class="flex gap-2">
<span class="text-primary-500"></span>
<span>自带密钥:智能体会使用本连接的接口地址与访问令牌。</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-planeOPEN)。
平台托管或未配置时,回退进程环境变量中的接口地址与访问令牌。真正的按组织托管密钥方案仍在规划中。
</span>
</li>
<li class="flex gap-2">
<span class="text-primary-500"></span>
<span>模型与角色在对应页面管理;时回退环境默认。</span>
<span>模型与角色在对应页面管理;未配置时回退环境默认</span>
</li>
</ul>
</div>
@@ -67,7 +67,7 @@
<PageHeader
title="角色"
description="ADR-0017角色数据。roleId 即斜杠命令(/draft),绑定默认模型、工具白名单与系统提示词。"
description="角色数据配置。角色 ID 同时是斜杠命令(/draft),绑定默认模型、工具白名单与系统提示词。"
/>
{#if loading}
@@ -78,15 +78,15 @@
<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} />
<input class="saas-input font-mono text-sm" placeholder="角色 ID(如 draft" bind:value={newRoleId} />
<input class="saas-input" placeholder="显示名(如 草稿)" 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)。" />
<EmptyState title="暂无角色" description="未配置时回退环境默认角色draft / review)。" />
</div>
{:else}
<div class="space-y-4">
@@ -1,4 +1,5 @@
<script lang="ts">
import { Collapsible } from 'bits-ui';
import { page } from '$app/state';
import { api, type TeamRow, type TeamMemberRow } from '$lib/api';
import { fmtDate } from '$lib/format';
@@ -62,6 +63,7 @@
if (!confirm(`归档团队 ${t.name}? 其活跃项目授权将被撤销。`)) return;
try {
await api.archiveTeam(slug, t.id);
if (expandedId === t.id) expandedId = null;
await load();
toastSuccess('团队已归档');
} catch (err) {
@@ -69,13 +71,11 @@
}
}
async function toggleMembers(t: TeamRow) {
if (expandedId === t.id) {
expandedId = null;
return;
}
async function openMembers(t: TeamRow) {
if (expandedId === t.id) return;
expandedId = t.id;
loadingMembers = true;
memberInput = '';
try {
const res = await api.teamMembers(slug, t.id);
teamMembers = res.members;
@@ -86,6 +86,11 @@
}
}
function onExpandChange(t: TeamRow, open: boolean) {
if (open) void openMembers(t);
else if (expandedId === t.id) expandedId = null;
}
async function addMember(t: TeamRow) {
if (!memberInput.trim()) return;
const v = memberInput.trim();
@@ -119,7 +124,7 @@
});
</script>
<PageHeader title="团队" description="团队是项目授权的 principal,可被授予 READ / EDIT / MANAGE。" />
<PageHeader title="团队" description="团队是项目授权主体,可被授予只读、编辑或管理权限。" />
{#if loading}
<LoadingState />
@@ -129,7 +134,7 @@
<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={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>
@@ -143,16 +148,22 @@
{:else}
<div class="space-y-3">
{#each teams as t (t.id)}
<div class="saas-card p-5">
<Collapsible.Root
class="saas-card p-5"
open={expandedId === t.id}
onOpenChange={(open) => onExpandChange(t, open)}
>
<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)}>
<Collapsible.Trigger class="saas-btn-secondary !py-1.5 text-sm">
{expandedId === t.id ? '收起' : '管理成员'}
</Collapsible.Trigger>
<button type="button" class="saas-btn-danger !py-1.5 text-sm" onclick={() => archiveTeam(t)}>
归档
</button>
<button class="saas-btn-danger !py-1.5 text-sm" onclick={() => archiveTeam(t)}>归档</button>
</div>
</div>
{#if t.description}
@@ -160,15 +171,15 @@
{/if}
<p class="mt-1 text-xs text-surface-400">创建于 {fmtDate(t.createdAt)}</p>
{#if expandedId === t.id}
<Collapsible.Content>
<div class="mt-4 border-t border-surface-200 pt-4">
{#if loadingMembers}
{#if loadingMembers && expandedId === t.id}
<p class="text-sm text-surface-400">加载中…</p>
{:else}
{:else if expandedId === t.id}
<div class="mb-4 flex gap-2">
<input
class="saas-input"
placeholder="Feishu open_id 或用户 id"
placeholder="飞书 open_id 或用户 id"
bind:value={memberInput}
onkeydown={(e) => {
if (e.key === 'Enter') addMember(t);
@@ -195,9 +206,9 @@
<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
>
<button class="saas-btn-danger !py-1 text-xs" onclick={() => revokeMember(t, m)}>
移除
</button>
</td>
</tr>
{/each}
@@ -206,8 +217,8 @@
{/if}
{/if}
</div>
{/if}
</div>
</Collapsible.Content>
</Collapsible.Root>
{/each}
</div>
{/if}