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
@@ -0,0 +1,32 @@
<script lang="ts">
import { Checkbox } from 'bits-ui';
import Icon from './Icon.svelte';
let {
checked = $bindable(false),
disabled = false,
class: className = '',
onchange
}: {
checked?: boolean;
disabled?: boolean;
class?: string;
onchange?: (checked: boolean) => void;
} = $props();
</script>
<Checkbox.Root
class="saas-checkbox {className}"
{disabled}
{checked}
onCheckedChange={(next) => {
checked = next;
onchange?.(next);
}}
>
{#snippet children({ checked: isChecked })}
{#if isChecked}
<Icon name="check" class="h-3.5 w-3.5" />
{/if}
{/snippet}
</Checkbox.Root>
+16 -22
View File
@@ -1,5 +1,6 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import { Dialog } from 'bits-ui';
let {
open = $bindable(false),
@@ -12,33 +13,26 @@
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">
<Dialog.Root
bind:open
onOpenChange={(next) => {
if (!next) onclose?.();
}}
>
<Dialog.Portal>
<Dialog.Overlay class="saas-modal-backdrop" />
<Dialog.Content class="saas-modal">
<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="关闭">
<Dialog.Title class="text-lg font-semibold text-surface-900">{title}</Dialog.Title>
<Dialog.Close class="saas-btn-ghost !px-2 !py-1 text-surface-400" 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>
</Dialog.Close>
</div>
{@render children()}
</div>
</div>
{/if}
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
@@ -1,7 +1,12 @@
<script lang="ts">
import { api, type OrgRoleRow, type OrgModelRow } from '$lib/api';
import { Checkbox, Label } from 'bits-ui';
import type { OrgRoleRow, OrgModelRow } from '$lib/api';
import { api } from '$lib/api';
import { fmtDate } from '$lib/format';
import { TOOL_OPTIONS } from '$lib/constants';
import SelectField from '$lib/components/SelectField.svelte';
import CheckboxControl from '$lib/components/CheckboxControl.svelte';
import Icon from '$lib/components/Icon.svelte';
import { toastError, toastSuccess } from '$lib/toast';
let {
@@ -18,7 +23,6 @@
onupdated: (updated: OrgRoleRow) => void;
} = $props();
// Local edit buffer intentionally seeded once from the row props.
const initial = {
roleId: r.roleId,
label: r.label,
@@ -32,19 +36,12 @@
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 selectedTools = $state<string[]>([...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];
const tools = unrestricted ? null : selectedTools;
try {
const updated = await api.updateRole(slug, r.id, {
roleId: roleId.trim(),
@@ -69,6 +66,11 @@
},
{} as Record<string, typeof TOOL_OPTIONS>
);
const modelItems = $derived([
{ value: '', label: '(使用清单中的首个模型)' },
...models.map((m) => ({ value: m.modelId, label: `${m.label}${m.modelId}` }))
]);
</script>
<div class="saas-card-pad">
@@ -79,60 +81,58 @@
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
<label for="role-id-{r.id}" class="saas-label">roleId</label>
<Label.Root for="role-id-{r.id}" class="saas-label">角色 ID</Label.Root>
<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>
<Label.Root for="role-label-{r.id}" class="saas-label">显示名</Label.Root>
<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>
<p class="saas-label">默认模型</p>
<SelectField items={modelItems} bind:value={defaultModelId} />
</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} />
<CheckboxControl bind:checked={unrestricted} />
<span class="text-sm">不限(使用全部注册工具)</span>
</label>
<div class="space-y-3 {unrestricted ? 'pointer-events-none opacity-40' : ''}">
<Checkbox.Group bind:value={selectedTools} disabled={unrestricted}>
{#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}
<Checkbox.Root class="saas-checkbox" value={t.id} id={`tool-${r.id}-${t.id}`}>
{#snippet children({ checked })}
{#if checked}
<Icon name="check" class="h-3.5 w-3.5" />
{/if}
{/snippet}
</Checkbox.Root>
<span>{t.label}</span>
</label>
{/each}
</div>
</div>
{/each}
</Checkbox.Group>
</div>
</div>
<div class="mt-4">
<label for="role-prompt-{r.id}" class="saas-label">系统提示词</label>
<Label.Root for="role-prompt-{r.id}" class="saas-label">系统提示词</Label.Root>
<textarea
id="role-prompt-{r.id}"
class="saas-textarea"
rows="4"
placeholder="系统提示词(可选)。会话开始时注入,定义 agent 人格/指令。"
placeholder="系统提示词(可选)。会话开始时注入,定义智能体人格/指令。"
bind:value={systemPrompt}
></textarea>
</div>
@@ -0,0 +1,71 @@
<script lang="ts">
import { Select } from 'bits-ui';
import Icon from './Icon.svelte';
export type SelectItem = { label: string; value: string; disabled?: boolean };
let {
items,
value = $bindable(''),
class: className = '',
disabled = false,
placeholder = '请选择…',
onchange
}: {
items: SelectItem[];
value?: string;
class?: string;
disabled?: boolean;
placeholder?: string;
onchange?: (value: string) => void;
} = $props();
</script>
<Select.Root
type="single"
{items}
{disabled}
{value}
onValueChange={(next) => {
value = next;
onchange?.(next);
}}
>
<Select.Trigger class="saas-select-trigger {className}" {disabled}>
<span class="min-w-0 flex-1 truncate text-left">
<Select.Value {placeholder} />
</span>
<svg
class="ml-2 h-4 w-4 shrink-0 text-surface-400"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.75"
aria-hidden="true"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M8.25 15l3.75 3.75L15.75 15" />
<path stroke-linecap="round" stroke-linejoin="round" d="M8.25 9l3.75-3.75L15.75 9" />
</svg>
</Select.Trigger>
<Select.Portal>
<Select.Content class="saas-select-content" sideOffset={6} collisionPadding={8}>
<Select.Viewport class="p-1">
{#each items as item (item.value)}
<Select.Item
class="saas-select-item"
value={item.value}
label={item.label}
disabled={item.disabled}
>
{#snippet children({ selected })}
<span class="min-w-0 flex-1 truncate">{item.label}</span>
{#if selected}
<Icon name="check" class="ml-2 h-4 w-4 shrink-0 text-primary-600" />
{/if}
{/snippet}
</Select.Item>
{/each}
</Select.Viewport>
</Select.Content>
</Select.Portal>
</Select.Root>
@@ -0,0 +1,27 @@
<script lang="ts">
import { Switch } from 'bits-ui';
let {
checked = $bindable(false),
disabled = false,
class: className = '',
onchange
}: {
checked?: boolean;
disabled?: boolean;
class?: string;
onchange?: (checked: boolean) => void;
} = $props();
</script>
<Switch.Root
class="saas-switch {className}"
{disabled}
{checked}
onCheckedChange={(next) => {
checked = next;
onchange?.(next);
}}
>
<Switch.Thumb class="saas-switch-thumb" />
</Switch.Root>
+30 -8
View File
@@ -5,18 +5,40 @@ export interface ToolOption {
}
export const TOOL_OPTIONS: ToolOption[] = [
{ id: 'read_file', label: '读取文件 (Read)', group: '文件' },
{ id: 'write_file', label: '写入文件 (Write)', group: '文件' },
{ id: 'list_files', label: '列目录 (Glob)', group: '文件' },
{ id: 'search_files', label: '搜索 (Grep)', group: '文件' },
{ id: 'read_file', label: '读取文件', group: '文件' },
{ id: 'write_file', label: '写入文件', group: '文件' },
{ id: 'list_files', label: '列目录', group: '文件' },
{ id: 'search_files', label: '搜索', group: '文件' },
{ id: 'bash', label: 'Bash 命令', group: 'Shell' },
{ id: 'cph_check', label: 'cph check', group: 'CPH' },
{ id: 'cph_build', label: 'cph build', group: 'CPH' },
{ id: 'send_file', label: '发送文件 (飞书)', group: '飞书 MCP' },
{ id: 'feishu_read_context', label: '读飞书上下文', group: '飞书 MCP' },
{ id: 'feishu_download_resource', label: '下载飞书资源', group: '飞书 MCP' },
{ id: 'request_approval', label: '请求审批', group: '飞书 MCP' }
{ id: 'send_file', label: '发送文件飞书', group: '飞书' },
{ id: 'feishu_read_context', label: '读飞书上下文', group: '飞书' },
{ id: 'feishu_download_resource', label: '下载飞书资源', group: '飞书' },
{ id: 'request_approval', label: '请求审批', group: '飞书' }
];
/** 组织成员角色(接口枚举保持英文,界面用 orgRoleLabel */
export const ORG_ROLES = ['OWNER', 'ADMIN', 'MEMBER'] as const;
export type OrgRole = (typeof ORG_ROLES)[number];
export const ORG_ROLE_LABELS: Record<OrgRole, string> = {
OWNER: '所有者',
ADMIN: '管理员',
MEMBER: '成员'
};
/** 项目团队授权角色(接口枚举保持英文,界面用 permissionRoleLabel */
export const PERMISSION_ROLES = ['READ', 'EDIT', 'MANAGE'] as const;
export type PermissionRole = (typeof PERMISSION_ROLES)[number];
export const PERMISSION_ROLE_LABELS: Record<PermissionRole, string> = {
READ: '只读',
EDIT: '编辑',
MANAGE: '管理'
};
export const PROVIDER_MODES = [
{ value: 'PLATFORM_MANAGED', label: '平台托管' },
{ value: 'BYOK', label: '自带密钥' }
] as const;
+23
View File
@@ -1,3 +1,10 @@
import {
ORG_ROLE_LABELS,
PERMISSION_ROLE_LABELS,
type OrgRole,
type PermissionRole
} from './constants';
export function fmtDate(iso: string): string {
if (!iso) return '—';
const d = new Date(iso);
@@ -26,3 +33,19 @@ export function fmtCost(usd: number | null): string {
export function fmtNum(n: number): string {
return n.toLocaleString();
}
export function orgRoleLabel(role: string): string {
const key = role.toUpperCase() as OrgRole;
return ORG_ROLE_LABELS[key] ?? role;
}
export function permissionRoleLabel(role: string): string {
const key = role.toUpperCase() as PermissionRole;
return PERMISSION_ROLE_LABELS[key] ?? role;
}
export function providerModeLabel(mode: string): string {
if (mode === 'BYOK') return '自带密钥';
if (mode === 'PLATFORM_MANAGED') return '平台托管';
return mode;
}
+34 -30
View File
@@ -5,8 +5,10 @@
import { page } from '$app/state';
import { session, loadSession, logout, redirectToLogin } from '$lib/session';
import type { OrgMembership } from '$lib/api';
import { orgRoleLabel } from '$lib/format';
import Icon from '$lib/components/Icon.svelte';
import ToastHost from '$lib/components/ToastHost.svelte';
import SelectField from '$lib/components/SelectField.svelte';
let { children } = $props();
let mobileNavOpen = $state(false);
@@ -23,7 +25,7 @@
{ key: 'projects', label: '项目', icon: 'projects' as const },
{ key: 'models', label: '模型', icon: 'models' as const },
{ key: 'roles', label: '角色', icon: 'roles' as const },
{ key: 'provider', label: 'Provider', icon: 'provider' as const }
{ key: 'provider', label: '供应方', icon: 'provider' as const }
];
function isAdmin(org: OrgMembership): boolean {
@@ -47,7 +49,6 @@
return memberships().filter(isAdmin);
}
/** Org matching the URL slug (if any). */
function currentOrg(): OrgMembership | null {
const slug = orgSlugFromPath();
if (!slug) return null;
@@ -79,9 +80,9 @@
return navItems.find((i) => i.key === key)?.label ?? '管理后台';
}
function onOrgSwitch(e: Event) {
const sel = e.target as HTMLSelectElement;
void goto(`/admin/org/${sel.value}`);
function switchOrg(nextSlug: string) {
if (!nextSlug || nextSlug === currentOrg()?.slug) return;
void goto(`/admin/org/${nextSlug}`);
}
function handleLogout(e: Event) {
@@ -89,14 +90,18 @@
logout();
}
// Close mobile nav on route change.
function orgSelectItems(list: OrgMembership[]) {
return list.map((o) => ({
value: o.slug,
label: `${o.name} · ${orgRoleLabel(o.role)}`
}));
}
$effect(() => {
page.url.pathname;
mobileNavOpen = false;
});
// If the user has admin rights but the URL is not under a valid org slug,
// send them to their home org. Fixes false "未加入组织" on / or wrong paths.
$effect(() => {
if ($session.loading || !$session.me) return;
const admins = adminOrgs();
@@ -109,8 +114,6 @@
return;
}
// Only auto-redirect when URL is not already a matched *member* org
// (member orgs keep their own denied screen). Missing / unknown slug → home.
if (!matched) {
const target = `/admin/org/${admins[0].slug}`;
if (page.url.pathname !== target && !page.url.pathname.startsWith(`${target}/`)) {
@@ -179,21 +182,21 @@
CPH
</div>
<div class="min-w-0">
<div class="truncate text-sm font-semibold text-surface-900">Org Admin</div>
<div class="truncate text-sm font-semibold text-surface-900">组织后台</div>
<div class="truncate text-xs text-surface-400">Curriculum Hub</div>
</div>
</div>
<div class="px-3 pb-3">
<label for="org-sel" class="mb-1.5 flex items-center gap-1.5 text-xs font-medium text-surface-500">
<div class="mb-1.5 flex items-center gap-1.5 text-xs font-medium text-surface-500">
<Icon name="org" class="h-3.5 w-3.5" />
组织
</label>
<select id="org-sel" class="saas-select text-sm" value={org.slug} onchange={onOrgSwitch}>
{#each me.organizations as o}
<option value={o.slug}>{o.name} · {o.role}</option>
{/each}
</select>
</div>
<SelectField
items={orgSelectItems(me.organizations)}
value={org.slug}
onchange={switchOrg}
/>
</div>
<nav class="flex-1 space-y-0.5 overflow-y-auto px-2 pb-3">
@@ -218,7 +221,7 @@
{/if}
<div class="min-w-0 flex-1">
<div class="truncate text-sm font-medium text-surface-800">{me.user.displayName}</div>
<div class="truncate text-[11px] text-surface-400">{org.role}</div>
<div class="truncate text-[11px] text-surface-400">{orgRoleLabel(org.role)}</div>
</div>
<button
type="button"
@@ -268,16 +271,18 @@
<h2 class="mb-2 text-lg font-semibold">无权访问管理后台</h2>
<p class="mb-3 text-sm text-surface-500">
组织 <strong>{denied.name}</strong>/{denied.slug})中你的角色是
<span class="saas-badge-neutral mx-1">{denied.role}</span>
需要 <strong>OWNER</strong><strong>ADMIN</strong>
<span class="saas-badge-neutral mx-1">{orgRoleLabel(denied.role)}</span>
需要<strong>所有者</strong><strong>管理员</strong>
</p>
{#if memberships().length > 1}
<label for="org-switch" class="saas-label text-left">切换到其他组织</label>
<select id="org-switch" class="saas-select mb-4" onchange={onOrgSwitch}>
{#each memberships() as o}
<option value={o.slug} selected={o.slug === denied.slug}>{o.name} · {o.role}</option>
{/each}
</select>
<p class="saas-label text-left mb-1.5">切换到其他组织</p>
<div class="mb-4">
<SelectField
items={orgSelectItems(memberships())}
value={denied.slug}
onchange={switchOrg}
/>
</div>
{/if}
<button class="saas-btn-ghost" onclick={handleLogout}>退出登录</button>
</div>
@@ -290,7 +295,7 @@
<h2 class="mb-2 text-lg font-semibold">无权访问管理后台</h2>
<p class="mb-5 text-sm text-surface-500">
你加入了 <strong>{denied.name}</strong>,角色是
<span class="saas-badge-neutral mx-1">{denied.role}</span>。仅 OWNER/ADMIN 可进入后台。
<span class="saas-badge-neutral mx-1">{orgRoleLabel(denied.role)}</span>。仅所有者与管理员可进入后台。
</p>
{:else}
<h2 class="mb-2 text-lg font-semibold">正在跳转…</h2>
@@ -308,8 +313,7 @@
<div class="saas-status-card">
<h2 class="mb-2 text-lg font-semibold">未加入组织</h2>
<p class="mb-3 text-sm text-surface-500">
飞书账号已登录,但 <code class="font-mono">/api/me</code>
<code class="font-mono">organizations</code> 为空。
飞书账号已登录,但当前账号尚未加入任何组织。
</p>
<p class="mb-5 text-left text-xs text-surface-400">
open_id
@@ -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>
</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>
<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}
+154 -5
View File
@@ -324,6 +324,79 @@
resize: vertical;
}
.saas-select-trigger {
display: inline-flex;
width: 100%;
align-items: center;
border-radius: 0.5rem;
border: 1px solid var(--color-surface-200);
background: var(--color-surface-50);
color: var(--color-surface-900);
padding: 0.5rem 0.75rem;
font-size: 0.875rem;
line-height: 1.25rem;
outline: none;
transition: border-color 0.15s, box-shadow 0.15s;
cursor: pointer;
text-align: left;
}
.saas-select-trigger:focus-visible,
.saas-select-trigger[data-state='open'] {
border-color: var(--color-primary-400);
box-shadow: 0 0 0 3px color-mix(in oklab, var(--color-primary-500) 20%, transparent);
}
.saas-select-trigger:disabled,
.saas-select-trigger[data-disabled] {
cursor: not-allowed;
opacity: 0.55;
}
.saas-select-trigger [data-placeholder] {
color: var(--color-surface-400);
}
.saas-select-content {
z-index: 70;
max-height: min(18rem, var(--bits-select-content-available-height, 18rem));
width: var(--bits-select-anchor-width);
min-width: var(--bits-select-anchor-width);
overflow: hidden;
border-radius: 0.5rem;
border: 1px solid var(--color-surface-200);
background: var(--color-surface-50);
box-shadow: 0 10px 30px rgb(15 23 42 / 0.12);
outline: none;
}
.saas-select-item {
display: flex;
align-items: center;
border-radius: 0.375rem;
padding: 0.4rem 0.6rem;
font-size: 0.875rem;
line-height: 1.25rem;
color: var(--color-surface-800);
cursor: pointer;
outline: none;
user-select: none;
}
.saas-select-item[data-highlighted] {
background: color-mix(in oklab, var(--color-primary-500) 12%, transparent);
}
.saas-select-item[data-selected] {
color: var(--color-primary-800);
font-weight: 500;
}
.saas-select-item[data-disabled] {
cursor: not-allowed;
opacity: 0.45;
}
.saas-btn-primary,
.saas-btn-secondary,
.saas-btn-ghost,
@@ -386,26 +459,102 @@
background: var(--color-error-200);
}
.saas-checkbox {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.1rem;
height: 1.1rem;
flex-shrink: 0;
border-radius: 0.3rem;
border: 1px solid var(--color-surface-300);
background: var(--color-surface-50);
color: white;
cursor: pointer;
transition: background 0.15s, border-color 0.15s, box-shadow 0.15s;
}
.saas-checkbox[data-state='checked'] {
background: var(--color-primary-600);
border-color: var(--color-primary-600);
}
.saas-checkbox:focus-visible {
outline: none;
box-shadow: 0 0 0 3px color-mix(in oklab, var(--color-primary-500) 20%, transparent);
}
.saas-checkbox[data-disabled] {
cursor: not-allowed;
opacity: 0.5;
}
.saas-switch {
position: relative;
display: inline-flex;
width: 2.5rem;
height: 1.4rem;
flex-shrink: 0;
align-items: center;
border-radius: 9999px;
background: var(--color-surface-300);
padding: 0.125rem;
cursor: pointer;
transition: background 0.15s;
}
.saas-switch[data-state='checked'] {
background: var(--color-primary-600);
}
.saas-switch:focus-visible {
outline: none;
box-shadow: 0 0 0 3px color-mix(in oklab, var(--color-primary-500) 20%, transparent);
}
.saas-switch[data-disabled] {
cursor: not-allowed;
opacity: 0.5;
}
.saas-switch-thumb {
display: block;
width: 1.05rem;
height: 1.05rem;
border-radius: 9999px;
background: white;
box-shadow: 0 1px 2px rgb(15 23 42 / 0.18);
transition: transform 0.15s;
transform: translateX(0);
}
.saas-switch[data-state='checked'] .saas-switch-thumb,
.saas-switch-thumb[data-state='checked'] {
transform: translateX(1.05rem);
}
.saas-modal-backdrop {
position: fixed;
inset: 0;
z-index: 50;
display: flex;
align-items: center;
justify-content: center;
background: rgb(2 6 23 / 0.4);
padding: 1rem;
backdrop-filter: blur(2px);
}
.saas-modal {
width: 100%;
position: fixed;
left: 50%;
top: 50%;
z-index: 51;
width: calc(100% - 2rem);
max-width: 28rem;
transform: translate(-50%, -50%);
border-radius: 1rem;
border: 1px solid var(--color-surface-200);
background: var(--color-surface-50);
padding: 1.5rem;
box-shadow: 0 20px 40px rgb(15 23 42 / 0.16);
outline: none;
}
.saas-status-panel {