forked from bai/curriculum-project-hub
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:
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 面向 provider;label 面向教师。"
|
||||
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>BYOK:agent 使用本连接 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-plane(OPEN)。
|
||||
平台托管或未配置时,回退到进程环境变量中的接口地址与访问令牌。真正的按组织托管密钥方案仍在规划中。
|
||||
</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}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user