forked from EduCraft/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">
|
<script lang="ts">
|
||||||
import type { Snippet } from 'svelte';
|
import type { Snippet } from 'svelte';
|
||||||
|
import { Dialog } from 'bits-ui';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
open = $bindable(false),
|
open = $bindable(false),
|
||||||
@@ -12,33 +13,26 @@
|
|||||||
children: Snippet;
|
children: Snippet;
|
||||||
onclose?: () => void;
|
onclose?: () => void;
|
||||||
} = $props();
|
} = $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>
|
</script>
|
||||||
|
|
||||||
{#if open}
|
<Dialog.Root
|
||||||
<div class="saas-modal-backdrop" role="presentation" onclick={onBackdrop} onkeydown={onKey}>
|
bind:open
|
||||||
<div class="saas-modal" role="dialog" aria-modal="true" aria-label={title} tabindex="-1">
|
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">
|
<div class="mb-4 flex items-start justify-between gap-3">
|
||||||
<h3 class="text-lg font-semibold text-surface-900">{title}</h3>
|
<Dialog.Title class="text-lg font-semibold text-surface-900">{title}</Dialog.Title>
|
||||||
<button type="button" class="saas-btn-ghost !px-2 !py-1 text-surface-400" onclick={close} aria-label="关闭">
|
<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">
|
<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" />
|
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</Dialog.Close>
|
||||||
</div>
|
</div>
|
||||||
{@render children()}
|
{@render children()}
|
||||||
</div>
|
</Dialog.Content>
|
||||||
</div>
|
</Dialog.Portal>
|
||||||
{/if}
|
</Dialog.Root>
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
<script lang="ts">
|
<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 { fmtDate } from '$lib/format';
|
||||||
import { TOOL_OPTIONS } from '$lib/constants';
|
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';
|
import { toastError, toastSuccess } from '$lib/toast';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
@@ -18,7 +23,6 @@
|
|||||||
onupdated: (updated: OrgRoleRow) => void;
|
onupdated: (updated: OrgRoleRow) => void;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
// Local edit buffer intentionally seeded once from the row props.
|
|
||||||
const initial = {
|
const initial = {
|
||||||
roleId: r.roleId,
|
roleId: r.roleId,
|
||||||
label: r.label,
|
label: r.label,
|
||||||
@@ -32,19 +36,12 @@
|
|||||||
let defaultModelId = $state(initial.defaultModelId);
|
let defaultModelId = $state(initial.defaultModelId);
|
||||||
let systemPrompt = $state(initial.systemPrompt);
|
let systemPrompt = $state(initial.systemPrompt);
|
||||||
let unrestricted = $state(initial.unrestricted);
|
let unrestricted = $state(initial.unrestricted);
|
||||||
let selectedTools = $state<Set<string>>(new Set(initial.tools));
|
let selectedTools = $state<string[]>([...initial.tools]);
|
||||||
let saving = $state(false);
|
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() {
|
async function save() {
|
||||||
saving = true;
|
saving = true;
|
||||||
const tools = unrestricted ? null : [...selectedTools];
|
const tools = unrestricted ? null : selectedTools;
|
||||||
try {
|
try {
|
||||||
const updated = await api.updateRole(slug, r.id, {
|
const updated = await api.updateRole(slug, r.id, {
|
||||||
roleId: roleId.trim(),
|
roleId: roleId.trim(),
|
||||||
@@ -69,6 +66,11 @@
|
|||||||
},
|
},
|
||||||
{} as Record<string, typeof TOOL_OPTIONS>
|
{} as Record<string, typeof TOOL_OPTIONS>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const modelItems = $derived([
|
||||||
|
{ value: '', label: '(使用清单中的首个模型)' },
|
||||||
|
...models.map((m) => ({ value: m.modelId, label: `${m.label}(${m.modelId})` }))
|
||||||
|
]);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="saas-card-pad">
|
<div class="saas-card-pad">
|
||||||
@@ -79,60 +81,58 @@
|
|||||||
|
|
||||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
<div>
|
<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} />
|
<input id="role-id-{r.id}" class="saas-input font-mono text-sm" bind:value={roleId} />
|
||||||
</div>
|
</div>
|
||||||
<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} />
|
<input id="role-label-{r.id}" class="saas-input" bind:value={label} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-4">
|
<div class="mt-4">
|
||||||
<label for="role-model-{r.id}" class="saas-label">默认模型</label>
|
<p class="saas-label">默认模型</p>
|
||||||
<select id="role-model-{r.id}" class="saas-select" bind:value={defaultModelId}>
|
<SelectField items={modelItems} bind:value={defaultModelId} />
|
||||||
<option value="">(角色默认 → 首模型)</option>
|
|
||||||
{#each models as m}
|
|
||||||
<option value={m.modelId}>{m.label} ({m.modelId})</option>
|
|
||||||
{/each}
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-4">
|
<div class="mt-4">
|
||||||
<span class="saas-label">工具白名单</span>
|
<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">
|
<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>
|
<span class="text-sm">不限(使用全部注册工具)</span>
|
||||||
</label>
|
</label>
|
||||||
<div class="space-y-3 {unrestricted ? 'pointer-events-none opacity-40' : ''}">
|
<div class="space-y-3 {unrestricted ? 'pointer-events-none opacity-40' : ''}">
|
||||||
{#each Object.entries(groupedTools) as [group, tools]}
|
<Checkbox.Group bind:value={selectedTools} disabled={unrestricted}>
|
||||||
<div>
|
{#each Object.entries(groupedTools) as [group, tools]}
|
||||||
<p class="mb-1.5 text-xs font-semibold uppercase tracking-wide text-surface-400">{group}</p>
|
<div>
|
||||||
<div class="grid grid-cols-1 gap-1.5 sm:grid-cols-2">
|
<p class="mb-1.5 text-xs font-semibold uppercase tracking-wide text-surface-400">{group}</p>
|
||||||
{#each tools as t}
|
<div class="grid grid-cols-1 gap-1.5 sm:grid-cols-2">
|
||||||
<label class="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-surface-100">
|
{#each tools as t}
|
||||||
<input
|
<label class="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-surface-100">
|
||||||
type="checkbox"
|
<Checkbox.Root class="saas-checkbox" value={t.id} id={`tool-${r.id}-${t.id}`}>
|
||||||
class="checkbox"
|
{#snippet children({ checked })}
|
||||||
checked={selectedTools.has(t.id)}
|
{#if checked}
|
||||||
onchange={() => toggleTool(t.id)}
|
<Icon name="check" class="h-3.5 w-3.5" />
|
||||||
/>
|
{/if}
|
||||||
{t.label}
|
{/snippet}
|
||||||
</label>
|
</Checkbox.Root>
|
||||||
{/each}
|
<span>{t.label}</span>
|
||||||
|
</label>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
{/each}
|
||||||
{/each}
|
</Checkbox.Group>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-4">
|
<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
|
<textarea
|
||||||
id="role-prompt-{r.id}"
|
id="role-prompt-{r.id}"
|
||||||
class="saas-textarea"
|
class="saas-textarea"
|
||||||
rows="4"
|
rows="4"
|
||||||
placeholder="系统提示词(可选)。会话开始时注入,定义 agent 人格/指令。"
|
placeholder="系统提示词(可选)。会话开始时注入,定义智能体人格/指令。"
|
||||||
bind:value={systemPrompt}
|
bind:value={systemPrompt}
|
||||||
></textarea>
|
></textarea>
|
||||||
</div>
|
</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[] = [
|
export const TOOL_OPTIONS: ToolOption[] = [
|
||||||
{ id: 'read_file', label: '读取文件 (Read)', group: '文件' },
|
{ id: 'read_file', label: '读取文件', group: '文件' },
|
||||||
{ id: 'write_file', label: '写入文件 (Write)', group: '文件' },
|
{ id: 'write_file', label: '写入文件', group: '文件' },
|
||||||
{ id: 'list_files', label: '列目录 (Glob)', group: '文件' },
|
{ id: 'list_files', label: '列目录', group: '文件' },
|
||||||
{ id: 'search_files', label: '搜索 (Grep)', group: '文件' },
|
{ id: 'search_files', label: '搜索', group: '文件' },
|
||||||
{ id: 'bash', label: 'Bash 命令', group: 'Shell' },
|
{ id: 'bash', label: 'Bash 命令', group: 'Shell' },
|
||||||
{ id: 'cph_check', label: 'cph check', group: 'CPH' },
|
{ id: 'cph_check', label: 'cph check', group: 'CPH' },
|
||||||
{ id: 'cph_build', label: 'cph build', group: 'CPH' },
|
{ id: 'cph_build', label: 'cph build', group: 'CPH' },
|
||||||
{ id: 'send_file', label: '发送文件 (飞书)', group: '飞书 MCP' },
|
{ id: 'send_file', label: '发送文件(飞书)', group: '飞书' },
|
||||||
{ id: 'feishu_read_context', label: '读飞书上下文', group: '飞书 MCP' },
|
{ id: 'feishu_read_context', label: '读飞书上下文', group: '飞书' },
|
||||||
{ id: 'feishu_download_resource', label: '下载飞书资源', group: '飞书 MCP' },
|
{ id: 'feishu_download_resource', label: '下载飞书资源', group: '飞书' },
|
||||||
{ id: 'request_approval', label: '请求审批', group: '飞书 MCP' }
|
{ id: 'request_approval', label: '请求审批', group: '飞书' }
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/** 组织成员角色(接口枚举保持英文,界面用 orgRoleLabel) */
|
||||||
export const ORG_ROLES = ['OWNER', 'ADMIN', 'MEMBER'] as const;
|
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 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 {
|
export function fmtDate(iso: string): string {
|
||||||
if (!iso) return '—';
|
if (!iso) return '—';
|
||||||
const d = new Date(iso);
|
const d = new Date(iso);
|
||||||
@@ -26,3 +33,19 @@ export function fmtCost(usd: number | null): string {
|
|||||||
export function fmtNum(n: number): string {
|
export function fmtNum(n: number): string {
|
||||||
return n.toLocaleString();
|
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 { page } from '$app/state';
|
||||||
import { session, loadSession, logout, redirectToLogin } from '$lib/session';
|
import { session, loadSession, logout, redirectToLogin } from '$lib/session';
|
||||||
import type { OrgMembership } from '$lib/api';
|
import type { OrgMembership } from '$lib/api';
|
||||||
|
import { orgRoleLabel } from '$lib/format';
|
||||||
import Icon from '$lib/components/Icon.svelte';
|
import Icon from '$lib/components/Icon.svelte';
|
||||||
import ToastHost from '$lib/components/ToastHost.svelte';
|
import ToastHost from '$lib/components/ToastHost.svelte';
|
||||||
|
import SelectField from '$lib/components/SelectField.svelte';
|
||||||
|
|
||||||
let { children } = $props();
|
let { children } = $props();
|
||||||
let mobileNavOpen = $state(false);
|
let mobileNavOpen = $state(false);
|
||||||
@@ -23,7 +25,7 @@
|
|||||||
{ key: 'projects', label: '项目', icon: 'projects' as const },
|
{ key: 'projects', label: '项目', icon: 'projects' as const },
|
||||||
{ key: 'models', label: '模型', icon: 'models' as const },
|
{ key: 'models', label: '模型', icon: 'models' as const },
|
||||||
{ key: 'roles', label: '角色', icon: 'roles' 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 {
|
function isAdmin(org: OrgMembership): boolean {
|
||||||
@@ -47,7 +49,6 @@
|
|||||||
return memberships().filter(isAdmin);
|
return memberships().filter(isAdmin);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Org matching the URL slug (if any). */
|
|
||||||
function currentOrg(): OrgMembership | null {
|
function currentOrg(): OrgMembership | null {
|
||||||
const slug = orgSlugFromPath();
|
const slug = orgSlugFromPath();
|
||||||
if (!slug) return null;
|
if (!slug) return null;
|
||||||
@@ -68,7 +69,7 @@
|
|||||||
|
|
||||||
function navHref(key: string): string {
|
function navHref(key: string): string {
|
||||||
const slug = currentOrg()?.slug ?? pickHomeOrg()?.slug;
|
const slug = currentOrg()?.slug ?? pickHomeOrg()?.slug;
|
||||||
if (!slug) return '/';
|
if (!slug) return '/';
|
||||||
if (key === 'overview') return `/admin/org/${slug}`;
|
if (key === 'overview') return `/admin/org/${slug}`;
|
||||||
return `/admin/org/${slug}/${key}`;
|
return `/admin/org/${slug}/${key}`;
|
||||||
}
|
}
|
||||||
@@ -79,9 +80,9 @@
|
|||||||
return navItems.find((i) => i.key === key)?.label ?? '管理后台';
|
return navItems.find((i) => i.key === key)?.label ?? '管理后台';
|
||||||
}
|
}
|
||||||
|
|
||||||
function onOrgSwitch(e: Event) {
|
function switchOrg(nextSlug: string) {
|
||||||
const sel = e.target as HTMLSelectElement;
|
if (!nextSlug || nextSlug === currentOrg()?.slug) return;
|
||||||
void goto(`/admin/org/${sel.value}`);
|
void goto(`/admin/org/${nextSlug}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleLogout(e: Event) {
|
function handleLogout(e: Event) {
|
||||||
@@ -89,14 +90,18 @@
|
|||||||
logout();
|
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(() => {
|
$effect(() => {
|
||||||
page.url.pathname;
|
page.url.pathname;
|
||||||
mobileNavOpen = false;
|
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(() => {
|
$effect(() => {
|
||||||
if ($session.loading || !$session.me) return;
|
if ($session.loading || !$session.me) return;
|
||||||
const admins = adminOrgs();
|
const admins = adminOrgs();
|
||||||
@@ -109,8 +114,6 @@
|
|||||||
return;
|
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) {
|
if (!matched) {
|
||||||
const target = `/admin/org/${admins[0].slug}`;
|
const target = `/admin/org/${admins[0].slug}`;
|
||||||
if (page.url.pathname !== target && !page.url.pathname.startsWith(`${target}/`)) {
|
if (page.url.pathname !== target && !page.url.pathname.startsWith(`${target}/`)) {
|
||||||
@@ -179,21 +182,21 @@
|
|||||||
CPH
|
CPH
|
||||||
</div>
|
</div>
|
||||||
<div class="min-w-0">
|
<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 class="truncate text-xs text-surface-400">Curriculum Hub</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="px-3 pb-3">
|
<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" />
|
<Icon name="org" class="h-3.5 w-3.5" />
|
||||||
组织
|
组织
|
||||||
</label>
|
</div>
|
||||||
<select id="org-sel" class="saas-select text-sm" value={org.slug} onchange={onOrgSwitch}>
|
<SelectField
|
||||||
{#each me.organizations as o}
|
items={orgSelectItems(me.organizations)}
|
||||||
<option value={o.slug}>{o.name} · {o.role}</option>
|
value={org.slug}
|
||||||
{/each}
|
onchange={switchOrg}
|
||||||
</select>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav class="flex-1 space-y-0.5 overflow-y-auto px-2 pb-3">
|
<nav class="flex-1 space-y-0.5 overflow-y-auto px-2 pb-3">
|
||||||
@@ -218,7 +221,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
<div class="min-w-0 flex-1">
|
<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-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>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -268,16 +271,18 @@
|
|||||||
<h2 class="mb-2 text-lg font-semibold">无权访问管理后台</h2>
|
<h2 class="mb-2 text-lg font-semibold">无权访问管理后台</h2>
|
||||||
<p class="mb-3 text-sm text-surface-500">
|
<p class="mb-3 text-sm text-surface-500">
|
||||||
组织 <strong>{denied.name}</strong>(/{denied.slug})中你的角色是
|
组织 <strong>{denied.name}</strong>(/{denied.slug})中你的角色是
|
||||||
<span class="saas-badge-neutral mx-1">{denied.role}</span>,
|
<span class="saas-badge-neutral mx-1">{orgRoleLabel(denied.role)}</span>,
|
||||||
需要 <strong>OWNER</strong> 或 <strong>ADMIN</strong>。
|
需要<strong>所有者</strong>或<strong>管理员</strong>。
|
||||||
</p>
|
</p>
|
||||||
{#if memberships().length > 1}
|
{#if memberships().length > 1}
|
||||||
<label for="org-switch" class="saas-label text-left">切换到其他组织</label>
|
<p class="saas-label text-left mb-1.5">切换到其他组织</p>
|
||||||
<select id="org-switch" class="saas-select mb-4" onchange={onOrgSwitch}>
|
<div class="mb-4">
|
||||||
{#each memberships() as o}
|
<SelectField
|
||||||
<option value={o.slug} selected={o.slug === denied.slug}>{o.name} · {o.role}</option>
|
items={orgSelectItems(memberships())}
|
||||||
{/each}
|
value={denied.slug}
|
||||||
</select>
|
onchange={switchOrg}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
<button class="saas-btn-ghost" onclick={handleLogout}>退出登录</button>
|
<button class="saas-btn-ghost" onclick={handleLogout}>退出登录</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -290,7 +295,7 @@
|
|||||||
<h2 class="mb-2 text-lg font-semibold">无权访问管理后台</h2>
|
<h2 class="mb-2 text-lg font-semibold">无权访问管理后台</h2>
|
||||||
<p class="mb-5 text-sm text-surface-500">
|
<p class="mb-5 text-sm text-surface-500">
|
||||||
你加入了 <strong>{denied.name}</strong>,角色是
|
你加入了 <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>
|
</p>
|
||||||
{:else}
|
{:else}
|
||||||
<h2 class="mb-2 text-lg font-semibold">正在跳转…</h2>
|
<h2 class="mb-2 text-lg font-semibold">正在跳转…</h2>
|
||||||
@@ -308,8 +313,7 @@
|
|||||||
<div class="saas-status-card">
|
<div class="saas-status-card">
|
||||||
<h2 class="mb-2 text-lg font-semibold">未加入组织</h2>
|
<h2 class="mb-2 text-lg font-semibold">未加入组织</h2>
|
||||||
<p class="mb-3 text-sm text-surface-500">
|
<p class="mb-3 text-sm text-surface-500">
|
||||||
飞书账号已登录,但 <code class="font-mono">/api/me</code> 的
|
飞书账号已登录,但当前账号尚未加入任何组织。
|
||||||
<code class="font-mono">organizations</code> 为空。
|
|
||||||
</p>
|
</p>
|
||||||
<p class="mb-5 text-left text-xs text-surface-400">
|
<p class="mb-5 text-left text-xs text-surface-400">
|
||||||
open_id:
|
open_id:
|
||||||
|
|||||||
@@ -2,11 +2,12 @@
|
|||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import { api, type OrgMembership } from '$lib/api';
|
import { api, type OrgMembership } from '$lib/api';
|
||||||
import { session } from '$lib/session';
|
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 PageHeader from '$lib/components/PageHeader.svelte';
|
||||||
import StatCard from '$lib/components/StatCard.svelte';
|
import StatCard from '$lib/components/StatCard.svelte';
|
||||||
import LoadingState from '$lib/components/LoadingState.svelte';
|
import LoadingState from '$lib/components/LoadingState.svelte';
|
||||||
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
|
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
|
||||||
|
import SwitchControl from '$lib/components/SwitchControl.svelte';
|
||||||
import { toastError, toastSuccess } from '$lib/toast';
|
import { toastError, toastSuccess } from '$lib/toast';
|
||||||
|
|
||||||
let orgSlug = $derived(page.params.slug ?? '');
|
let orgSlug = $derived(page.params.slug ?? '');
|
||||||
@@ -32,14 +33,14 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function toggleCreate() {
|
async function setMembersCanCreate(next: boolean) {
|
||||||
if (!settings) return;
|
if (!settings || settings.membersCanCreateProjects === next) return;
|
||||||
saving = true;
|
saving = true;
|
||||||
const prev = settings.membersCanCreateProjects;
|
const prev = settings.membersCanCreateProjects;
|
||||||
settings.membersCanCreateProjects = !prev;
|
settings.membersCanCreateProjects = next;
|
||||||
try {
|
try {
|
||||||
await api.setSettings(orgSlug, { membersCanCreateProjects: settings.membersCanCreateProjects });
|
await api.setSettings(orgSlug, { membersCanCreateProjects: next });
|
||||||
toastSuccess(settings.membersCanCreateProjects ? '已允许成员自助建项' : '已关闭成员自助建项');
|
toastSuccess(next ? '已允许成员自助建项' : '已关闭成员自助建项');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
settings.membersCanCreateProjects = prev;
|
settings.membersCanCreateProjects = prev;
|
||||||
toastError(err instanceof Error ? err.message : String(err));
|
toastError(err instanceof Error ? err.message : String(err));
|
||||||
@@ -74,7 +75,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="flex items-center justify-between gap-3">
|
<div class="flex items-center justify-between gap-3">
|
||||||
<dt class="text-surface-500">你的角色</dt>
|
<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>
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
</div>
|
</div>
|
||||||
@@ -82,28 +83,26 @@
|
|||||||
<div class="saas-card-pad sm:col-span-2">
|
<div class="saas-card-pad sm:col-span-2">
|
||||||
<p class="saas-section-title mb-1">项目自助创建策略</p>
|
<p class="saas-section-title mb-1">项目自助创建策略</p>
|
||||||
<p class="saas-muted mb-4">
|
<p class="saas-muted mb-4">
|
||||||
开启后,普通老师可在飞书群自助创建项目;关闭后仅 OWNER / ADMIN 可建。
|
开启后,普通老师可在飞书群自助创建项目;关闭后仅所有者与管理员可建。
|
||||||
</p>
|
</p>
|
||||||
<label class="flex cursor-pointer items-center gap-3 rounded-xl border border-surface-200 bg-surface-100/50 px-4 py-3">
|
<div class="flex items-center gap-3 rounded-xl border border-surface-200 bg-surface-100/50 px-4 py-3">
|
||||||
<input
|
<SwitchControl
|
||||||
type="checkbox"
|
|
||||||
class="checkbox"
|
|
||||||
checked={settings.membersCanCreateProjects}
|
checked={settings.membersCanCreateProjects}
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
onchange={toggleCreate}
|
onchange={setMembersCanCreate}
|
||||||
/>
|
/>
|
||||||
<div>
|
<div>
|
||||||
<div class="text-sm font-medium text-surface-800">允许成员自助创建项目</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-4 flex items-end justify-between gap-3">
|
<div class="mb-4 flex items-end justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<h2 class="saas-section-title">用量概览</h2>
|
<h2 class="saas-section-title">用量概览</h2>
|
||||||
<p class="saas-muted">全组织 Agent 运行汇总</p>
|
<p class="saas-muted">全组织智能体运行汇总</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -2,15 +2,17 @@
|
|||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import { api, type OrgMember } from '$lib/api';
|
import { api, type OrgMember } from '$lib/api';
|
||||||
import { fmtDate } from '$lib/format';
|
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 PageHeader from '$lib/components/PageHeader.svelte';
|
||||||
import LoadingState from '$lib/components/LoadingState.svelte';
|
import LoadingState from '$lib/components/LoadingState.svelte';
|
||||||
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
|
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
|
||||||
import EmptyState from '$lib/components/EmptyState.svelte';
|
import EmptyState from '$lib/components/EmptyState.svelte';
|
||||||
|
import SelectField from '$lib/components/SelectField.svelte';
|
||||||
import { toastError, toastSuccess } from '$lib/toast';
|
import { toastError, toastSuccess } from '$lib/toast';
|
||||||
|
|
||||||
const slug = $derived(page.params.slug ?? '');
|
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 members = $state<OrgMember[]>([]);
|
||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
@@ -82,7 +84,7 @@
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<PageHeader title="成员与权限" description="管理组织角色:OWNER / ADMIN 可访问本后台,MEMBER 不可。" />
|
<PageHeader title="成员与权限" description="管理组织角色:所有者与管理员可访问本后台,成员不可。" />
|
||||||
|
|
||||||
{#if loading}
|
{#if loading}
|
||||||
<LoadingState />
|
<LoadingState />
|
||||||
@@ -91,12 +93,10 @@
|
|||||||
{:else}
|
{:else}
|
||||||
<div class="saas-card-pad mb-6">
|
<div class="saas-card-pad mb-6">
|
||||||
<h2 class="saas-section-title mb-4">添加成员</h2>
|
<h2 class="saas-section-title mb-4">添加成员</h2>
|
||||||
<div class="grid gap-3 md:grid-cols-[1.2fr_1fr_auto_auto]">
|
<div class="grid gap-3 md:grid-cols-[1.2fr_1fr_12rem_auto]">
|
||||||
<input class="saas-input" placeholder="Feishu open_id" bind:value={newOpenId} />
|
<input class="saas-input" placeholder="飞书 open_id" bind:value={newOpenId} />
|
||||||
<input class="saas-input" placeholder="显示名(可选)" bind:value={newName} />
|
<input class="saas-input" placeholder="显示名(可选)" bind:value={newName} />
|
||||||
<select class="saas-select" bind:value={newRole}>
|
<SelectField items={roleItems} bind:value={newRole} />
|
||||||
{#each roles as r}<option value={r}>{r}</option>{/each}
|
|
||||||
</select>
|
|
||||||
<button class="saas-btn-primary" onclick={addMember} disabled={adding}>
|
<button class="saas-btn-primary" onclick={addMember} disabled={adding}>
|
||||||
{adding ? '添加中…' : '添加成员'}
|
{adding ? '添加中…' : '添加成员'}
|
||||||
</button>
|
</button>
|
||||||
@@ -140,16 +140,14 @@
|
|||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="font-mono text-xs text-surface-500">{m.feishuOpenId}</td>
|
<td class="font-mono text-xs text-surface-500">{m.feishuOpenId}</td>
|
||||||
<td>
|
<td class="min-w-[9rem]">
|
||||||
<select
|
<SelectField
|
||||||
class="saas-select max-w-[9rem] py-1.5 text-sm"
|
items={roleItems}
|
||||||
value={m.role}
|
value={m.role}
|
||||||
onchange={(e) => changeRole(m, (e.target as HTMLSelectElement).value)}
|
onchange={(role) => {
|
||||||
>
|
if (role !== m.role) changeRole(m, role);
|
||||||
{#each roles as r}
|
}}
|
||||||
<option value={r} selected={r === m.role}>{r}</option>
|
/>
|
||||||
{/each}
|
|
||||||
</select>
|
|
||||||
</td>
|
</td>
|
||||||
<td class="text-surface-500">{fmtDate(m.createdAt)}</td>
|
<td class="text-surface-500">{fmtDate(m.createdAt)}</td>
|
||||||
<td class="text-right">
|
<td class="text-right">
|
||||||
@@ -162,7 +160,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
<p class="border-t border-surface-100 px-5 py-3 text-xs text-surface-400">
|
<p class="border-t border-surface-100 px-5 py-3 text-xs text-surface-400">
|
||||||
组织角色控制后台访问;项目级权限由「项目」页团队授权(READ / EDIT / MANAGE)决定。
|
组织角色控制后台访问;项目级权限由「项目」页团队授权({permHint})决定。
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
import LoadingState from '$lib/components/LoadingState.svelte';
|
import LoadingState from '$lib/components/LoadingState.svelte';
|
||||||
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
|
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
|
||||||
import EmptyState from '$lib/components/EmptyState.svelte';
|
import EmptyState from '$lib/components/EmptyState.svelte';
|
||||||
|
import CheckboxControl from '$lib/components/CheckboxControl.svelte';
|
||||||
import { toastError, toastSuccess } from '$lib/toast';
|
import { toastError, toastSuccess } from '$lib/toast';
|
||||||
|
|
||||||
const slug = $derived(page.params.slug ?? '');
|
const slug = $derived(page.params.slug ?? '');
|
||||||
@@ -82,7 +83,7 @@
|
|||||||
|
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="模型"
|
title="模型"
|
||||||
description="Org-scoped 模型清单(ADR-0017)。modelId 面向 provider;label 面向教师。"
|
description="本组织启用的模型清单。模型 ID 面向供应方接口;显示名面向教师侧切换。"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{#if loading}
|
{#if loading}
|
||||||
@@ -95,10 +96,10 @@
|
|||||||
<div class="grid gap-3 md:grid-cols-[1.4fr_1fr_auto_auto]">
|
<div class="grid gap-3 md:grid-cols-[1.4fr_1fr_auto_auto]">
|
||||||
<input
|
<input
|
||||||
class="saas-input font-mono text-sm"
|
class="saas-input font-mono text-sm"
|
||||||
placeholder="modelId(如 anthropic/claude-sonnet-5)"
|
placeholder="模型 ID(如 anthropic/claude-sonnet-5)"
|
||||||
bind:value={newModelId}
|
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} />
|
<input class="saas-input w-28" placeholder="排序" bind:value={newSortKey} />
|
||||||
<button class="saas-btn-primary" onclick={add} disabled={adding}>添加</button>
|
<button class="saas-btn-primary" onclick={add} disabled={adding}>添加</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -116,8 +117,8 @@
|
|||||||
<table class="data-table">
|
<table class="data-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>label</th>
|
<th>显示名</th>
|
||||||
<th>modelId</th>
|
<th>模型 ID</th>
|
||||||
<th>工具能力</th>
|
<th>工具能力</th>
|
||||||
<th>排序</th>
|
<th>排序</th>
|
||||||
<th>创建</th>
|
<th>创建</th>
|
||||||
@@ -142,11 +143,9 @@
|
|||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<input
|
<CheckboxControl
|
||||||
type="checkbox"
|
|
||||||
class="checkbox"
|
|
||||||
checked={m.toolCapable}
|
checked={m.toolCapable}
|
||||||
onchange={(e) => save(m, { toolCapable: (e.target as HTMLInputElement).checked })}
|
onchange={(toolCapable) => save(m, { toolCapable })}
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
|
|||||||
@@ -6,7 +6,9 @@
|
|||||||
import LoadingState from '$lib/components/LoadingState.svelte';
|
import LoadingState from '$lib/components/LoadingState.svelte';
|
||||||
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
|
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
|
||||||
import EmptyState from '$lib/components/EmptyState.svelte';
|
import EmptyState from '$lib/components/EmptyState.svelte';
|
||||||
|
import { Label } from 'bits-ui';
|
||||||
import Modal from '$lib/components/Modal.svelte';
|
import Modal from '$lib/components/Modal.svelte';
|
||||||
|
import SelectField from '$lib/components/SelectField.svelte';
|
||||||
import { toastError, toastSuccess } from '$lib/toast';
|
import { toastError, toastSuccess } from '$lib/toast';
|
||||||
|
|
||||||
const slug = $derived(page.params.slug ?? '');
|
const slug = $derived(page.params.slug ?? '');
|
||||||
@@ -81,6 +83,14 @@
|
|||||||
return parts.join(' / ');
|
return parts.join(' / ');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function folderItems() {
|
||||||
|
if (!data) return [{ value: '', label: '(根)' }];
|
||||||
|
return [
|
||||||
|
{ value: '', label: '(根)' },
|
||||||
|
...data.folders.map((f) => ({ value: f.id, label: folderPath(f) }))
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (slug) load();
|
if (slug) load();
|
||||||
});
|
});
|
||||||
@@ -108,7 +118,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<Modal bind:open={showFolderModal} title="新建文件夹">
|
<Modal bind:open={showFolderModal} title="新建文件夹">
|
||||||
<label class="saas-label" for="folder-name">名称</label>
|
<Label.Root class="saas-label" for="folder-name">名称</Label.Root>
|
||||||
<input
|
<input
|
||||||
id="folder-name"
|
id="folder-name"
|
||||||
class="saas-input mb-4"
|
class="saas-input mb-4"
|
||||||
@@ -118,13 +128,10 @@
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{#if data && data.folders.length > 0}
|
{#if data && data.folders.length > 0}
|
||||||
<label class="saas-label" for="folder-parent">父文件夹(可选)</label>
|
<p class="saas-label">父文件夹(可选)</p>
|
||||||
<select id="folder-parent" class="saas-select mb-4" bind:value={folderParent}>
|
<div class="mb-4">
|
||||||
<option value="">(根)</option>
|
<SelectField items={folderItems()} bind:value={folderParent} />
|
||||||
{#each data.folders as f}
|
</div>
|
||||||
<option value={f.id}>{folderPath(f)}</option>
|
|
||||||
{/each}
|
|
||||||
</select>
|
|
||||||
{/if}
|
{/if}
|
||||||
<div class="flex justify-end gap-2">
|
<div class="flex justify-end gap-2">
|
||||||
<button class="saas-btn-ghost" onclick={() => (showFolderModal = false)}>取消</button>
|
<button class="saas-btn-ghost" onclick={() => (showFolderModal = false)}>取消</button>
|
||||||
@@ -133,7 +140,7 @@
|
|||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
<Modal bind:open={showProjectModal} title="新建项目">
|
<Modal bind:open={showProjectModal} title="新建项目">
|
||||||
<label class="saas-label" for="project-name">项目名</label>
|
<Label.Root class="saas-label" for="project-name">项目名</Label.Root>
|
||||||
<input
|
<input
|
||||||
id="project-name"
|
id="project-name"
|
||||||
class="saas-input mb-4"
|
class="saas-input mb-4"
|
||||||
@@ -143,13 +150,10 @@
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{#if data && data.folders.length > 0}
|
{#if data && data.folders.length > 0}
|
||||||
<label class="saas-label" for="project-folder">文件夹(可选)</label>
|
<p class="saas-label">文件夹(可选)</p>
|
||||||
<select id="project-folder" class="saas-select mb-4" bind:value={projectFolder}>
|
<div class="mb-4">
|
||||||
<option value="">(根)</option>
|
<SelectField items={folderItems()} bind:value={projectFolder} />
|
||||||
{#each data.folders as f}
|
</div>
|
||||||
<option value={f.id}>{folderPath(f)}</option>
|
|
||||||
{/each}
|
|
||||||
</select>
|
|
||||||
{/if}
|
{/if}
|
||||||
<div class="flex justify-end gap-2">
|
<div class="flex justify-end gap-2">
|
||||||
<button class="saas-btn-ghost" onclick={() => (showProjectModal = false)}>取消</button>
|
<button class="saas-btn-ghost" onclick={() => (showProjectModal = false)}>取消</button>
|
||||||
|
|||||||
@@ -8,16 +8,19 @@
|
|||||||
type SessionSummary,
|
type SessionSummary,
|
||||||
type ExplorerData
|
type ExplorerData
|
||||||
} from '$lib/api';
|
} from '$lib/api';
|
||||||
import { fmtDate } from '$lib/format';
|
import { fmtDate, permissionRoleLabel } from '$lib/format';
|
||||||
import { PERMISSION_ROLES } from '$lib/constants';
|
import { PERMISSION_ROLES, PERMISSION_ROLE_LABELS } from '$lib/constants';
|
||||||
import PageHeader from '$lib/components/PageHeader.svelte';
|
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||||
import LoadingState from '$lib/components/LoadingState.svelte';
|
import LoadingState from '$lib/components/LoadingState.svelte';
|
||||||
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
|
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
|
||||||
import EmptyState from '$lib/components/EmptyState.svelte';
|
import EmptyState from '$lib/components/EmptyState.svelte';
|
||||||
|
import SelectField from '$lib/components/SelectField.svelte';
|
||||||
import { toastError, toastSuccess } from '$lib/toast';
|
import { toastError, toastSuccess } from '$lib/toast';
|
||||||
|
|
||||||
const slug = $derived(page.params.slug ?? '');
|
const slug = $derived(page.params.slug ?? '');
|
||||||
const projectId = $derived(page.params.projectId ?? '');
|
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 proj = $state<ProjectDetail | null>(null);
|
||||||
let access = $state<TeamAccessEntry[]>([]);
|
let access = $state<TeamAccessEntry[]>([]);
|
||||||
@@ -69,7 +72,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function archiveBinding() {
|
async function archiveBinding() {
|
||||||
if (!confirm('解绑当前飞书群? 用户将无法通过该群触发 agent。')) return;
|
if (!confirm('解绑当前飞书群? 用户将无法通过该群触发智能体。')) return;
|
||||||
try {
|
try {
|
||||||
await api.archiveBinding(slug, projectId);
|
await api.archiveBinding(slug, projectId);
|
||||||
await load();
|
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(() => {
|
$effect(() => {
|
||||||
if (slug && projectId) load();
|
if (slug && projectId) load();
|
||||||
});
|
});
|
||||||
@@ -139,7 +158,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{@const detail = proj}
|
{@const detail = proj}
|
||||||
<PageHeader title={detail.name} description="项目是权限边界;通过团队授予 READ ⊂ EDIT ⊂ MANAGE。">
|
<PageHeader title={detail.name} description={`项目是权限边界;通过团队授予 ${roleChain}。`}>
|
||||||
{#snippet actions()}
|
{#snippet actions()}
|
||||||
<button class="saas-btn-secondary !py-1.5 text-sm" onclick={rename}>重命名</button>
|
<button class="saas-btn-secondary !py-1.5 text-sm" onclick={rename}>重命名</button>
|
||||||
{#if detail.binding}
|
{#if detail.binding}
|
||||||
@@ -152,7 +171,7 @@
|
|||||||
<div class="saas-card-pad mb-6">
|
<div class="saas-card-pad mb-6">
|
||||||
<dl class="grid gap-x-8 gap-y-3 text-sm sm:grid-cols-2">
|
<dl class="grid gap-x-8 gap-y-3 text-sm sm:grid-cols-2">
|
||||||
<div>
|
<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>
|
<dd class="mt-0.5 break-all font-mono text-xs text-surface-700">{proj.workspaceDir}</dd>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -170,7 +189,7 @@
|
|||||||
<dd class="mt-0.5 text-surface-800">
|
<dd class="mt-0.5 text-surface-800">
|
||||||
{#if proj.binding}
|
{#if proj.binding}
|
||||||
<span class="saas-badge-success mr-1">已绑定</span>
|
<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>
|
<span class="text-surface-400"> · {fmtDate(proj.binding.createdAt)}</span>
|
||||||
{:else}
|
{:else}
|
||||||
<span class="saas-badge-neutral">未绑定</span>
|
<span class="saas-badge-neutral">未绑定</span>
|
||||||
@@ -184,14 +203,11 @@
|
|||||||
</dl>
|
</dl>
|
||||||
|
|
||||||
{#if explorer}
|
{#if explorer}
|
||||||
<div class="mt-5 flex flex-wrap items-center gap-2 border-t border-surface-100 pt-4">
|
<div class="mt-5 flex flex-wrap items-end gap-2 border-t border-surface-100 pt-4">
|
||||||
<span class="text-sm font-medium text-surface-700">移动到文件夹</span>
|
<div class="min-w-[12rem] flex-1">
|
||||||
<select class="saas-select max-w-xs" bind:value={moveFolder}>
|
<p class="saas-label">移动到文件夹</p>
|
||||||
<option value="">(根)</option>
|
<SelectField items={folderItems()} bind:value={moveFolder} />
|
||||||
{#each explorer.folders as f}
|
</div>
|
||||||
<option value={f.id}>{f.name}</option>
|
|
||||||
{/each}
|
|
||||||
</select>
|
|
||||||
<button class="saas-btn-secondary" onclick={move}>移动</button>
|
<button class="saas-btn-secondary" onclick={move}>移动</button>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -201,16 +217,9 @@
|
|||||||
<h3 class="saas-section-title mb-1">团队授权</h3>
|
<h3 class="saas-section-title mb-1">团队授权</h3>
|
||||||
<p class="saas-muted mb-4">通过团队授权项目访问。一项目可授多团队,一团队可访问多项目。</p>
|
<p class="saas-muted mb-4">通过团队授权项目访问。一项目可授多团队,一团队可访问多项目。</p>
|
||||||
|
|
||||||
<div class="mb-4 grid gap-2 sm:grid-cols-[1fr_auto_auto]">
|
<div class="mb-4 grid gap-2 sm:grid-cols-[1fr_10rem_auto]">
|
||||||
<select class="saas-select" bind:value={grantTeam}>
|
<SelectField items={teamItems()} bind:value={grantTeam} />
|
||||||
<option value="">选择团队…</option>
|
<SelectField items={roleItems} bind:value={grantRole} />
|
||||||
{#each teams as t}
|
|
||||||
<option value={t.id}>{t.name} ({t.slug})</option>
|
|
||||||
{/each}
|
|
||||||
</select>
|
|
||||||
<select class="saas-select" bind:value={grantRole}>
|
|
||||||
{#each [...PERMISSION_ROLES] as r}<option value={r}>{r}</option>{/each}
|
|
||||||
</select>
|
|
||||||
<button class="saas-btn-primary" onclick={grant}>授权</button>
|
<button class="saas-btn-primary" onclick={grant}>授权</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -221,7 +230,7 @@
|
|||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>团队</th>
|
<th>团队</th>
|
||||||
<th>slug</th>
|
<th>标识</th>
|
||||||
<th>角色</th>
|
<th>角色</th>
|
||||||
<th></th>
|
<th></th>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -231,7 +240,7 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<td class="font-medium">{g.teamName}</td>
|
<td class="font-medium">{g.teamName}</td>
|
||||||
<td class="font-mono text-xs">/{g.teamSlug}</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">
|
<td class="text-right">
|
||||||
<button class="saas-btn-danger !py-1 text-xs" onclick={() => revoke(g)}>撤销</button>
|
<button class="saas-btn-danger !py-1 text-xs" onclick={() => revoke(g)}>撤销</button>
|
||||||
</td>
|
</td>
|
||||||
@@ -244,17 +253,17 @@
|
|||||||
|
|
||||||
<div class="saas-card overflow-hidden">
|
<div class="saas-card overflow-hidden">
|
||||||
<div class="border-b border-surface-200 px-5 py-3">
|
<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>
|
</div>
|
||||||
{#if sessions.length === 0}
|
{#if sessions.length === 0}
|
||||||
<EmptyState title="暂无会话" description="飞书侧触发 agent 后会显示在此。" />
|
<EmptyState title="暂无会话" description="飞书侧触发智能体后会显示在此。" />
|
||||||
{:else}
|
{:else}
|
||||||
<table class="data-table">
|
<table class="data-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>provider / role</th>
|
<th>供应方 / 角色</th>
|
||||||
<th>model</th>
|
<th>模型</th>
|
||||||
<th>运行</th>
|
<th>运行次数</th>
|
||||||
<th>更新</th>
|
<th>更新</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
|
|||||||
@@ -2,12 +2,16 @@
|
|||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import { api, type ProviderConnection } from '$lib/api';
|
import { api, type ProviderConnection } from '$lib/api';
|
||||||
import { fmtDate } from '$lib/format';
|
import { fmtDate } from '$lib/format';
|
||||||
|
import { PROVIDER_MODES } from '$lib/constants';
|
||||||
|
import { Label } from 'bits-ui';
|
||||||
import PageHeader from '$lib/components/PageHeader.svelte';
|
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||||
import LoadingState from '$lib/components/LoadingState.svelte';
|
import LoadingState from '$lib/components/LoadingState.svelte';
|
||||||
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
|
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
|
||||||
|
import SelectField from '$lib/components/SelectField.svelte';
|
||||||
import { toastError, toastSuccess } from '$lib/toast';
|
import { toastError, toastSuccess } from '$lib/toast';
|
||||||
|
|
||||||
const slug = $derived(page.params.slug ?? '');
|
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 conn = $state<ProviderConnection | null>(null);
|
||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
@@ -50,7 +54,7 @@
|
|||||||
providerId = conn.providerId;
|
providerId = conn.providerId;
|
||||||
baseUrl = conn.baseUrl ?? '';
|
baseUrl = conn.baseUrl ?? '';
|
||||||
authToken = '';
|
authToken = '';
|
||||||
toastSuccess('Provider 配置已保存');
|
toastSuccess('供应方配置已保存');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toastError(err instanceof Error ? err.message : String(err));
|
toastError(err instanceof Error ? err.message : String(err));
|
||||||
} finally {
|
} finally {
|
||||||
@@ -64,8 +68,8 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Provider 连接"
|
title="模型供应方"
|
||||||
description="ADR-0021:连接归属且仅归属本 org。BYOK 自带密钥;PLATFORM_MANAGED 平台托管。"
|
description="连接归属且仅归属本组织。可选择自带密钥,或使用平台为该组织单独托管的凭据。"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{#if loading}
|
{#if loading}
|
||||||
@@ -76,21 +80,24 @@
|
|||||||
<div class="saas-card-pad mb-6">
|
<div class="saas-card-pad mb-6">
|
||||||
<div class="grid gap-5">
|
<div class="grid gap-5">
|
||||||
<div>
|
<div>
|
||||||
<label class="saas-label" for="mode">凭据模式</label>
|
<p class="saas-label">凭据模式</p>
|
||||||
<select id="mode" class="saas-select" bind:value={mode}>
|
<SelectField
|
||||||
<option value="PLATFORM_MANAGED">PLATFORM_MANAGED(平台托管)</option>
|
items={modeItems}
|
||||||
<option value="BYOK">BYOK(自带密钥)</option>
|
value={mode}
|
||||||
</select>
|
onchange={(v) => {
|
||||||
|
if (v === 'BYOK' || v === 'PLATFORM_MANAGED') mode = v;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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} />
|
<input id="provider-id" class="saas-input font-mono text-sm" bind:value={providerId} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if mode === 'BYOK'}
|
{#if mode === 'BYOK'}
|
||||||
<div>
|
<div>
|
||||||
<label class="saas-label" for="base-url">Base URL</label>
|
<Label.Root class="saas-label" for="base-url">接口地址</Label.Root>
|
||||||
<input
|
<input
|
||||||
id="base-url"
|
id="base-url"
|
||||||
class="saas-input"
|
class="saas-input"
|
||||||
@@ -99,12 +106,12 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="saas-label" for="auth-token">Auth Token</label>
|
<Label.Root class="saas-label" for="auth-token">访问令牌</Label.Root>
|
||||||
<input
|
<input
|
||||||
id="auth-token"
|
id="auth-token"
|
||||||
class="saas-input"
|
class="saas-input"
|
||||||
type="password"
|
type="password"
|
||||||
placeholder={conn.hasAuthToken ? '已设置(留空则不变)' : 'auth token'}
|
placeholder={conn.hasAuthToken ? '已设置(留空则不变)' : '填写访问令牌'}
|
||||||
bind:value={authToken}
|
bind:value={authToken}
|
||||||
/>
|
/>
|
||||||
{#if conn.hasAuthToken}
|
{#if conn.hasAuthToken}
|
||||||
@@ -128,17 +135,19 @@
|
|||||||
<div class="saas-card-pad border-dashed">
|
<div class="saas-card-pad border-dashed">
|
||||||
<h3 class="saas-section-title mb-2">运行时解析</h3>
|
<h3 class="saas-section-title mb-2">运行时解析</h3>
|
||||||
<ul class="space-y-2 text-sm text-surface-500">
|
<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">
|
<li class="flex gap-2">
|
||||||
<span class="text-primary-500">•</span>
|
<span class="text-primary-500">•</span>
|
||||||
<span>
|
<span>
|
||||||
PLATFORM_MANAGED / 未配置:回退进程环境变量(ANTHROPIC_BASE_URL / ANTHROPIC_AUTH_TOKEN)。真正 per-org
|
平台托管或未配置时,回退到进程环境变量中的接口地址与访问令牌。真正的按组织托管密钥方案仍在规划中。
|
||||||
平台托管凭据是后续 secret-control-plane(OPEN)。
|
|
||||||
</span>
|
</span>
|
||||||
</li>
|
</li>
|
||||||
<li class="flex gap-2">
|
<li class="flex gap-2">
|
||||||
<span class="text-primary-500">•</span>
|
<span class="text-primary-500">•</span>
|
||||||
<span>模型与角色在对应页面管理;空时回退环境默认。</span>
|
<span>模型与角色在对应页面管理;未配置时回退环境默认值。</span>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -67,7 +67,7 @@
|
|||||||
|
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="角色"
|
title="角色"
|
||||||
description="ADR-0017:角色是数据。roleId 即斜杠命令(/draft…),绑定默认模型、工具白名单与系统提示词。"
|
description="角色按数据配置。角色 ID 同时是斜杠命令(如 /draft),可绑定默认模型、工具白名单与系统提示词。"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{#if loading}
|
{#if loading}
|
||||||
@@ -78,15 +78,15 @@
|
|||||||
<div class="saas-card-pad mb-6">
|
<div class="saas-card-pad mb-6">
|
||||||
<h2 class="saas-section-title mb-4">新建角色</h2>
|
<h2 class="saas-section-title mb-4">新建角色</h2>
|
||||||
<div class="grid gap-3 sm:grid-cols-[10rem_1fr_auto]">
|
<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 font-mono text-sm" placeholder="角色 ID(如 draft)" bind:value={newRoleId} />
|
||||||
<input class="saas-input" placeholder="label(如 草稿)" bind:value={newLabel} />
|
<input class="saas-input" placeholder="显示名(如 草稿)" bind:value={newLabel} />
|
||||||
<button class="saas-btn-primary" onclick={add} disabled={adding}>新建</button>
|
<button class="saas-btn-primary" onclick={add} disabled={adding}>新建</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if roles.length === 0}
|
{#if roles.length === 0}
|
||||||
<div class="saas-card">
|
<div class="saas-card">
|
||||||
<EmptyState title="暂无角色" description="空时回退环境默认(draft / review)。" />
|
<EmptyState title="暂无角色" description="未配置时回退环境默认角色(draft / review)。" />
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { Collapsible } from 'bits-ui';
|
||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import { api, type TeamRow, type TeamMemberRow } from '$lib/api';
|
import { api, type TeamRow, type TeamMemberRow } from '$lib/api';
|
||||||
import { fmtDate } from '$lib/format';
|
import { fmtDate } from '$lib/format';
|
||||||
@@ -62,6 +63,7 @@
|
|||||||
if (!confirm(`归档团队 ${t.name}? 其活跃项目授权将被撤销。`)) return;
|
if (!confirm(`归档团队 ${t.name}? 其活跃项目授权将被撤销。`)) return;
|
||||||
try {
|
try {
|
||||||
await api.archiveTeam(slug, t.id);
|
await api.archiveTeam(slug, t.id);
|
||||||
|
if (expandedId === t.id) expandedId = null;
|
||||||
await load();
|
await load();
|
||||||
toastSuccess('团队已归档');
|
toastSuccess('团队已归档');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -69,13 +71,11 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function toggleMembers(t: TeamRow) {
|
async function openMembers(t: TeamRow) {
|
||||||
if (expandedId === t.id) {
|
if (expandedId === t.id) return;
|
||||||
expandedId = null;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
expandedId = t.id;
|
expandedId = t.id;
|
||||||
loadingMembers = true;
|
loadingMembers = true;
|
||||||
|
memberInput = '';
|
||||||
try {
|
try {
|
||||||
const res = await api.teamMembers(slug, t.id);
|
const res = await api.teamMembers(slug, t.id);
|
||||||
teamMembers = res.members;
|
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) {
|
async function addMember(t: TeamRow) {
|
||||||
if (!memberInput.trim()) return;
|
if (!memberInput.trim()) return;
|
||||||
const v = memberInput.trim();
|
const v = memberInput.trim();
|
||||||
@@ -119,7 +124,7 @@
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<PageHeader title="团队" description="团队是项目授权的 principal,可被授予 READ / EDIT / MANAGE。" />
|
<PageHeader title="团队" description="团队是项目授权主体,可被授予只读、编辑或管理权限。" />
|
||||||
|
|
||||||
{#if loading}
|
{#if loading}
|
||||||
<LoadingState />
|
<LoadingState />
|
||||||
@@ -129,7 +134,7 @@
|
|||||||
<div class="saas-card-pad mb-6">
|
<div class="saas-card-pad mb-6">
|
||||||
<h2 class="saas-section-title mb-4">新建团队</h2>
|
<h2 class="saas-section-title mb-4">新建团队</h2>
|
||||||
<div class="grid gap-3 md:grid-cols-[1fr_1fr_1.2fr_auto]">
|
<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={newName} />
|
||||||
<input class="saas-input" placeholder="描述(可选)" bind:value={newDesc} />
|
<input class="saas-input" placeholder="描述(可选)" bind:value={newDesc} />
|
||||||
<button class="saas-btn-primary" onclick={createTeam} disabled={adding}>新建</button>
|
<button class="saas-btn-primary" onclick={createTeam} disabled={adding}>新建</button>
|
||||||
@@ -143,16 +148,22 @@
|
|||||||
{:else}
|
{:else}
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
{#each teams as t (t.id)}
|
{#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">
|
<div class="flex flex-wrap items-center gap-2.5">
|
||||||
<span class="text-base font-semibold text-surface-900">{t.name}</span>
|
<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="font-mono text-xs text-surface-400">/{t.slug}</span>
|
||||||
<span class="saas-badge-neutral">{t.memberCount} 成员</span>
|
<span class="saas-badge-neutral">{t.memberCount} 成员</span>
|
||||||
<div class="ml-auto flex flex-wrap gap-2">
|
<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 ? '收起' : '管理成员'}
|
{expandedId === t.id ? '收起' : '管理成员'}
|
||||||
|
</Collapsible.Trigger>
|
||||||
|
<button type="button" class="saas-btn-danger !py-1.5 text-sm" onclick={() => archiveTeam(t)}>
|
||||||
|
归档
|
||||||
</button>
|
</button>
|
||||||
<button class="saas-btn-danger !py-1.5 text-sm" onclick={() => archiveTeam(t)}>归档</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{#if t.description}
|
{#if t.description}
|
||||||
@@ -160,15 +171,15 @@
|
|||||||
{/if}
|
{/if}
|
||||||
<p class="mt-1 text-xs text-surface-400">创建于 {fmtDate(t.createdAt)}</p>
|
<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">
|
<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>
|
<p class="text-sm text-surface-400">加载中…</p>
|
||||||
{:else}
|
{:else if expandedId === t.id}
|
||||||
<div class="mb-4 flex gap-2">
|
<div class="mb-4 flex gap-2">
|
||||||
<input
|
<input
|
||||||
class="saas-input"
|
class="saas-input"
|
||||||
placeholder="Feishu open_id 或用户 id"
|
placeholder="飞书 open_id 或用户 id"
|
||||||
bind:value={memberInput}
|
bind:value={memberInput}
|
||||||
onkeydown={(e) => {
|
onkeydown={(e) => {
|
||||||
if (e.key === 'Enter') addMember(t);
|
if (e.key === 'Enter') addMember(t);
|
||||||
@@ -195,9 +206,9 @@
|
|||||||
<td class="font-mono text-xs">{m.feishuOpenId}</td>
|
<td class="font-mono text-xs">{m.feishuOpenId}</td>
|
||||||
<td class="text-surface-500">{fmtDate(m.createdAt)}</td>
|
<td class="text-surface-500">{fmtDate(m.createdAt)}</td>
|
||||||
<td class="text-right">
|
<td class="text-right">
|
||||||
<button class="saas-btn-danger !py-1 text-xs" onclick={() => revokeMember(t, m)}
|
<button class="saas-btn-danger !py-1 text-xs" onclick={() => revokeMember(t, m)}>
|
||||||
>移除</button
|
移除
|
||||||
>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{/each}
|
{/each}
|
||||||
@@ -206,8 +217,8 @@
|
|||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
</Collapsible.Content>
|
||||||
</div>
|
</Collapsible.Root>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -324,6 +324,79 @@
|
|||||||
resize: vertical;
|
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-primary,
|
||||||
.saas-btn-secondary,
|
.saas-btn-secondary,
|
||||||
.saas-btn-ghost,
|
.saas-btn-ghost,
|
||||||
@@ -386,26 +459,102 @@
|
|||||||
background: var(--color-error-200);
|
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 {
|
.saas-modal-backdrop {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
z-index: 50;
|
z-index: 50;
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
background: rgb(2 6 23 / 0.4);
|
background: rgb(2 6 23 / 0.4);
|
||||||
padding: 1rem;
|
|
||||||
backdrop-filter: blur(2px);
|
backdrop-filter: blur(2px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.saas-modal {
|
.saas-modal {
|
||||||
width: 100%;
|
position: fixed;
|
||||||
|
left: 50%;
|
||||||
|
top: 50%;
|
||||||
|
z-index: 51;
|
||||||
|
width: calc(100% - 2rem);
|
||||||
max-width: 28rem;
|
max-width: 28rem;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
border-radius: 1rem;
|
border-radius: 1rem;
|
||||||
border: 1px solid var(--color-surface-200);
|
border: 1px solid var(--color-surface-200);
|
||||||
background: var(--color-surface-50);
|
background: var(--color-surface-50);
|
||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
box-shadow: 0 20px 40px rgb(15 23 42 / 0.16);
|
box-shadow: 0 20px 40px rgb(15 23 42 / 0.16);
|
||||||
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.saas-status-panel {
|
.saas-status-panel {
|
||||||
|
|||||||
Reference in New Issue
Block a user