forked from bai/curriculum-project-hub
feat: add org admin SPA for models, roles and provider
Introduce admin-web (Skeleton/SvelteKit), Prisma models for provider connection / OrgModel / OrgRole, DB-backed runtime settings, and admin API routes so org admins can manage agent configuration end-to-end.
This commit is contained in:
@@ -0,0 +1,23 @@
|
||||
node_modules
|
||||
|
||||
# Output
|
||||
.output
|
||||
.vercel
|
||||
.netlify
|
||||
.wrangler
|
||||
/.svelte-kit
|
||||
/build
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Env
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
!.env.test
|
||||
|
||||
# Vite
|
||||
vite.config.js.timestamp-*
|
||||
vite.config.ts.timestamp-*
|
||||
@@ -0,0 +1 @@
|
||||
engine-strict=true
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"recommendations": ["svelte.svelte-vscode"]
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
# sv
|
||||
|
||||
Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli).
|
||||
|
||||
## Creating a project
|
||||
|
||||
If you're seeing this, you've probably already done this step. Congrats!
|
||||
|
||||
```sh
|
||||
# create a new project
|
||||
npx sv create my-app
|
||||
```
|
||||
|
||||
To recreate this project with the same configuration:
|
||||
|
||||
```sh
|
||||
# recreate this project
|
||||
npx sv@0.16.2 create --template minimal --types ts --install npm D:/Projects/curriculum-project-hub/hub/admin-web
|
||||
```
|
||||
|
||||
## Developing
|
||||
|
||||
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
|
||||
|
||||
```sh
|
||||
npm run dev
|
||||
|
||||
# or start the server and open the app in a new browser tab
|
||||
npm run dev -- --open
|
||||
```
|
||||
|
||||
## Building
|
||||
|
||||
To create a production version of your app:
|
||||
|
||||
```sh
|
||||
npm run build
|
||||
```
|
||||
|
||||
You can preview the production build with `npm run preview`.
|
||||
|
||||
> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.
|
||||
Generated
+2594
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "admin-web",
|
||||
"private": true,
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"prepare": "svelte-kit sync || echo ''",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@skeletonlabs/skeleton": "^4.15.2",
|
||||
"@skeletonlabs/skeleton-svelte": "^4.15.2",
|
||||
"@sveltejs/adapter-auto": "^7.0.1",
|
||||
"@sveltejs/adapter-static": "^3.0.10",
|
||||
"@sveltejs/kit": "^2.63.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^7.1.2",
|
||||
"@tailwindcss/vite": "^4.3.2",
|
||||
"bits-ui": "^2.18.1",
|
||||
"svelte": "^5.56.1",
|
||||
"svelte-check": "^4.6.0",
|
||||
"tailwindcss": "^4.3.2",
|
||||
"typescript": "^6.0.3",
|
||||
"vite": "^8.0.16"
|
||||
}
|
||||
}
|
||||
Vendored
+13
@@ -0,0 +1,13 @@
|
||||
// See https://svelte.dev/docs/kit/types#app.d.ts
|
||||
// for information about these interfaces
|
||||
declare global {
|
||||
namespace App {
|
||||
// interface Error {}
|
||||
// interface Locals {}
|
||||
// interface PageData {}
|
||||
// interface PageState {}
|
||||
// interface Platform {}
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
@@ -0,0 +1,21 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN" data-theme="hamlindigo">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="text-scale" content="scale" />
|
||||
<meta name="description" content="Curriculum Project Hub — 组织管理后台" />
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.svg" type="image/svg+xml" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&family=Noto+Sans+SC:wght@400;500;600;700&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<title>CPH Admin</title>
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,291 @@
|
||||
/**
|
||||
* Thin API client for the org admin backend. Same-origin cookie auth.
|
||||
*/
|
||||
|
||||
export class ApiError extends Error {
|
||||
code: string;
|
||||
status: number;
|
||||
constructor(code: string, message: string, status: number) {
|
||||
super(message);
|
||||
this.name = 'ApiError';
|
||||
this.code = code;
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
async function request(method: string, url: string, body?: unknown): Promise<unknown> {
|
||||
const init: RequestInit = {
|
||||
method,
|
||||
credentials: 'same-origin',
|
||||
headers: body !== undefined ? { 'content-type': 'application/json' } : undefined,
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined
|
||||
};
|
||||
const res = await fetch(url, init);
|
||||
const text = await res.text();
|
||||
let data: unknown = null;
|
||||
if (text !== '') {
|
||||
try {
|
||||
data = JSON.parse(text);
|
||||
} catch {
|
||||
data = text;
|
||||
}
|
||||
}
|
||||
if (!res.ok) {
|
||||
const err = (data as { error?: { code?: string; message?: string } } | null)?.error;
|
||||
throw new ApiError(err?.code ?? 'http_error', err?.message ?? `HTTP ${res.status}`, res.status);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
const get = (u: string) => request('GET', u);
|
||||
const post = (u: string, b?: unknown) => request('POST', u, b);
|
||||
const put = (u: string, b?: unknown) => request('PUT', u, b);
|
||||
const patch = (u: string, b?: unknown) => request('PATCH', u, b);
|
||||
const del = (u: string) => request('DELETE', u);
|
||||
|
||||
const orgBase = (slug: string) => `/api/org/${encodeURIComponent(slug)}`;
|
||||
|
||||
// --- Types ---
|
||||
|
||||
export interface OrgMembership {
|
||||
id: string;
|
||||
slug: string;
|
||||
name: string;
|
||||
status: string;
|
||||
role: 'OWNER' | 'ADMIN' | 'MEMBER';
|
||||
}
|
||||
|
||||
export interface MeResponse {
|
||||
user: {
|
||||
id: string;
|
||||
feishuOpenId: string;
|
||||
displayName: string;
|
||||
avatarUrl: string | null;
|
||||
};
|
||||
organizations: OrgMembership[];
|
||||
}
|
||||
|
||||
export interface OrgMember {
|
||||
userId: string;
|
||||
feishuOpenId: string;
|
||||
displayName: string;
|
||||
avatarUrl: string | null;
|
||||
role: 'OWNER' | 'ADMIN' | 'MEMBER';
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface TeamRow {
|
||||
id: string;
|
||||
slug: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
memberCount: number;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface TeamMemberRow {
|
||||
userId: string;
|
||||
feishuOpenId: string;
|
||||
displayName: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface ExplorerFolder {
|
||||
id: string;
|
||||
name: string;
|
||||
parentId: string | null;
|
||||
sortKey: string;
|
||||
projectCount: number;
|
||||
childFolderCount: number;
|
||||
}
|
||||
|
||||
export interface ExplorerProject {
|
||||
id: string;
|
||||
name: string;
|
||||
folderId: string | null;
|
||||
createdAt: string;
|
||||
binding: { chatId: string; createdAt: string } | null;
|
||||
}
|
||||
|
||||
export interface ExplorerData {
|
||||
folders: ExplorerFolder[];
|
||||
projects: ExplorerProject[];
|
||||
}
|
||||
|
||||
export interface ProjectDetail {
|
||||
id: string;
|
||||
name: string;
|
||||
folderId: string | null;
|
||||
folder: { id: string; name: string } | null;
|
||||
workspaceDir: string;
|
||||
createdAt: string;
|
||||
archivedAt: string | null;
|
||||
createdBy: { id: string; displayName: string; feishuOpenId: string } | null;
|
||||
binding: { chatId: string; createdAt: string } | null;
|
||||
}
|
||||
|
||||
export interface TeamAccessEntry {
|
||||
grantId: string;
|
||||
projectId: string;
|
||||
organizationId: string;
|
||||
teamId: string;
|
||||
teamSlug: string;
|
||||
teamName: string;
|
||||
role: 'READ' | 'EDIT' | 'MANAGE';
|
||||
}
|
||||
|
||||
export interface SessionSummary {
|
||||
id: string;
|
||||
provider: string;
|
||||
roleId: string;
|
||||
model: string;
|
||||
title: string | null;
|
||||
runCount: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface OrgModelRow {
|
||||
id: string;
|
||||
modelId: string;
|
||||
label: string;
|
||||
toolCapable: boolean;
|
||||
sortKey: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface OrgRoleRow {
|
||||
id: string;
|
||||
roleId: string;
|
||||
label: string;
|
||||
defaultModelId: string | null;
|
||||
systemPrompt: string | null;
|
||||
tools: string[] | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface ProviderConnection {
|
||||
organizationId: string;
|
||||
providerId: string;
|
||||
mode: 'BYOK' | 'PLATFORM_MANAGED';
|
||||
baseUrl: string | null;
|
||||
hasAuthToken: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface UsageTotals {
|
||||
runCount: number;
|
||||
runsWithCost: number;
|
||||
runsWithoutCost: number;
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
costUsd: number | null;
|
||||
}
|
||||
|
||||
export interface ProjectUsageRow extends UsageTotals {
|
||||
projectId: string;
|
||||
projectName: string;
|
||||
folderId: string | null;
|
||||
}
|
||||
|
||||
export interface UsageReport {
|
||||
from: string | null;
|
||||
to: string | null;
|
||||
projects: ProjectUsageRow[];
|
||||
totals: UsageTotals;
|
||||
}
|
||||
|
||||
// --- API ---
|
||||
|
||||
export const api = {
|
||||
me: () => get('/api/me') as Promise<MeResponse>,
|
||||
logout: () => post('/auth/logout'),
|
||||
|
||||
org: (slug: string) => get(orgBase(slug)) as Promise<{ organization: { id: string; slug: string; name: string; status: string }; actorRole: string }>,
|
||||
settings: (slug: string) => get(`${orgBase(slug)}/settings`) as Promise<{ membersCanCreateProjects: boolean }>,
|
||||
setSettings: (slug: string, body: { membersCanCreateProjects: boolean }) =>
|
||||
patch(`${orgBase(slug)}/settings`, body) as Promise<{ membersCanCreateProjects: boolean }>,
|
||||
|
||||
members: (slug: string) => get(`${orgBase(slug)}/members`) as Promise<{ members: OrgMember[] }>,
|
||||
addMember: (slug: string, body: { feishuOpenId: string; displayName?: string; role: string }) =>
|
||||
post(`${orgBase(slug)}/members`, body) as Promise<OrgMember>,
|
||||
setMemberRole: (slug: string, userId: string, role: string) =>
|
||||
patch(`${orgBase(slug)}/members/${userId}`, { role }),
|
||||
revokeMember: (slug: string, userId: string) =>
|
||||
post(`${orgBase(slug)}/members/${userId}/revoke`),
|
||||
|
||||
teams: (slug: string) => get(`${orgBase(slug)}/teams`) as Promise<{ teams: TeamRow[] }>,
|
||||
createTeam: (slug: string, body: { slug: string; name: string; description?: string }) =>
|
||||
post(`${orgBase(slug)}/teams`, body) as Promise<TeamRow>,
|
||||
updateTeam: (slug: string, teamId: string, body: { name?: string; description?: string | null }) =>
|
||||
patch(`${orgBase(slug)}/teams/${teamId}`, body) as Promise<TeamRow>,
|
||||
archiveTeam: (slug: string, teamId: string) =>
|
||||
post(`${orgBase(slug)}/teams/${teamId}/archive`),
|
||||
teamMembers: (slug: string, teamId: string) =>
|
||||
get(`${orgBase(slug)}/teams/${teamId}/members`) as Promise<{ members: TeamMemberRow[] }>,
|
||||
addTeamMember: (slug: string, teamId: string, body: { userId?: string; feishuOpenId?: string }) =>
|
||||
post(`${orgBase(slug)}/teams/${teamId}/members`, body) as Promise<TeamMemberRow>,
|
||||
revokeTeamMember: (slug: string, teamId: string, userId: string) =>
|
||||
post(`${orgBase(slug)}/teams/${teamId}/members/${userId}/revoke`),
|
||||
|
||||
explorer: (slug: string) => get(`${orgBase(slug)}/explorer`) as Promise<ExplorerData>,
|
||||
createFolder: (slug: string, body: { name: string; parentId?: string; sortKey?: string }) =>
|
||||
post(`${orgBase(slug)}/folders`, body) as Promise<{ id: string; name: string; parentId: string | null; sortKey: string }>,
|
||||
renameFolder: (slug: string, folderId: string, body: { name?: string; sortKey?: string; parentId?: string | null }) =>
|
||||
patch(`${orgBase(slug)}/folders/${folderId}`, body) as Promise<{ id: string; name: string; parentId: string | null; sortKey: string }>,
|
||||
archiveFolder: (slug: string, folderId: string) =>
|
||||
post(`${orgBase(slug)}/folders/${folderId}/archive`) as Promise<{ archived: true; folderId: string }>,
|
||||
createProject: (slug: string, body: { name: string; folderId?: string }) =>
|
||||
post(`${orgBase(slug)}/projects`, body) as Promise<{ id: string; name: string }>,
|
||||
project: (slug: string, projectId: string) =>
|
||||
get(`${orgBase(slug)}/projects/${projectId}`) as Promise<ProjectDetail>,
|
||||
renameProject: (slug: string, projectId: string, name: string) =>
|
||||
patch(`${orgBase(slug)}/projects/${projectId}`, { name }),
|
||||
moveProject: (slug: string, projectId: string, folderId: string | null) =>
|
||||
patch(`${orgBase(slug)}/projects/${projectId}/folder`, { folderId }),
|
||||
archiveProject: (slug: string, projectId: string) =>
|
||||
post(`${orgBase(slug)}/projects/${projectId}/archive`),
|
||||
archiveBinding: (slug: string, projectId: string) =>
|
||||
post(`${orgBase(slug)}/projects/${projectId}/binding/archive`),
|
||||
|
||||
teamAccess: (slug: string, projectId: string) =>
|
||||
get(`${orgBase(slug)}/projects/${projectId}/team-access`) as Promise<{ access: TeamAccessEntry[] }>,
|
||||
grantTeamAccess: (slug: string, projectId: string, body: { teamId?: string; teamSlug?: string; role: string }) =>
|
||||
put(`${orgBase(slug)}/projects/${projectId}/team-access`, body) as Promise<TeamAccessEntry>,
|
||||
revokeTeamAccess: (slug: string, projectId: string, teamId: string) =>
|
||||
del(`${orgBase(slug)}/projects/${projectId}/team-access/${teamId}`),
|
||||
|
||||
sessions: (slug: string, projectId: string, limit?: number) =>
|
||||
get(`${orgBase(slug)}/projects/${projectId}/sessions${limit !== undefined ? `?limit=${limit}` : ''}`) as Promise<{ sessions: SessionSummary[] }>,
|
||||
usage: (slug: string, params?: { from?: string; to?: string; folderId?: string }) => {
|
||||
const q = new URLSearchParams();
|
||||
if (params?.from) q.set('from', params.from);
|
||||
if (params?.to) q.set('to', params.to);
|
||||
if (params?.folderId) q.set('folderId', params.folderId);
|
||||
const qs = q.toString();
|
||||
return get(`${orgBase(slug)}/usage${qs ? `?${qs}` : ''}`) as Promise<UsageReport>;
|
||||
},
|
||||
|
||||
models: (slug: string) => get(`${orgBase(slug)}/models`) as Promise<{ models: OrgModelRow[] }>,
|
||||
createModel: (slug: string, body: { modelId: string; label: string; toolCapable?: boolean; sortKey?: string }) =>
|
||||
post(`${orgBase(slug)}/models`, body) as Promise<OrgModelRow>,
|
||||
updateModel: (slug: string, id: string, body: { label?: string; toolCapable?: boolean; sortKey?: string; modelId?: string }) =>
|
||||
patch(`${orgBase(slug)}/models/${id}`, body) as Promise<OrgModelRow>,
|
||||
deleteModel: (slug: string, id: string) =>
|
||||
del(`${orgBase(slug)}/models/${id}`),
|
||||
|
||||
roles: (slug: string) => get(`${orgBase(slug)}/roles`) as Promise<{ roles: OrgRoleRow[] }>,
|
||||
createRole: (slug: string, body: { roleId: string; label: string; defaultModelId?: string | null; systemPrompt?: string | null; tools?: string[] | null }) =>
|
||||
post(`${orgBase(slug)}/roles`, body) as Promise<OrgRoleRow>,
|
||||
updateRole: (slug: string, id: string, body: { label?: string; defaultModelId?: string | null; systemPrompt?: string | null; tools?: string[] | null; roleId?: string }) =>
|
||||
patch(`${orgBase(slug)}/roles/${id}`, body) as Promise<OrgRoleRow>,
|
||||
deleteRole: (slug: string, id: string) =>
|
||||
del(`${orgBase(slug)}/roles/${id}`),
|
||||
|
||||
provider: (slug: string) => get(`${orgBase(slug)}/provider-connection`) as Promise<ProviderConnection>,
|
||||
setProvider: (slug: string, body: { mode: string; providerId?: string; baseUrl?: string | null; authToken?: string | null }) =>
|
||||
put(`${orgBase(slug)}/provider-connection`, body) as Promise<ProviderConnection>
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo</title><path d="M94.157 22.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282 29.608A29.92 29.92 0 0 0 8.764 49.65a31.5 31.5 0 0 0 3.108 20.231 30 30 0 0 0-4.477 11.183 31.9 31.9 0 0 0 5.448 24.116c10.402 14.887 30.942 19.297 45.791 9.835l26.083-16.624A29.92 29.92 0 0 0 98.235 78.35a31.53 31.53 0 0 0-3.105-20.232 30 30 0 0 0 4.474-11.182 31.88 31.88 0 0 0-5.447-24.116" style="fill:#ff3e00"/><path d="M45.817 106.582a20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.503 18 18 0 0 1 .624-2.435l.49-1.498 1.337.981a33.6 33.6 0 0 0 10.203 5.098l.97.294-.09.968a5.85 5.85 0 0 0 1.052 3.878 6.24 6.24 0 0 0 6.695 2.485 5.8 5.8 0 0 0 1.603-.704L69.27 76.28a5.43 5.43 0 0 0 2.45-3.631 5.8 5.8 0 0 0-.987-4.371 6.24 6.24 0 0 0-6.698-2.487 5.7 5.7 0 0 0-1.6.704l-9.953 6.345a19 19 0 0 1-5.296 2.326 20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.502 17.99 17.99 0 0 1 8.13-12.052l26.081-16.623a19 19 0 0 1 5.3-2.329 20.72 20.72 0 0 1 22.237 8.243 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-.624 2.435l-.49 1.498-1.337-.98a33.6 33.6 0 0 0-10.203-5.1l-.97-.294.09-.968a5.86 5.86 0 0 0-1.052-3.878 6.24 6.24 0 0 0-6.696-2.485 5.8 5.8 0 0 0-1.602.704L37.73 51.72a5.42 5.42 0 0 0-2.449 3.63 5.79 5.79 0 0 0 .986 4.372 6.24 6.24 0 0 0 6.698 2.486 5.8 5.8 0 0 0 1.602-.704l9.952-6.342a19 19 0 0 1 5.295-2.328 20.72 20.72 0 0 1 22.237 8.242 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-8.13 12.053l-26.081 16.622a19 19 0 0 1-5.3 2.328" style="fill:#fff"/></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,30 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
let {
|
||||
title = '暂无数据',
|
||||
description,
|
||||
action
|
||||
}: {
|
||||
title?: string;
|
||||
description?: string;
|
||||
action?: Snippet;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<div class="saas-empty">
|
||||
<div class="mb-1 flex h-12 w-12 items-center justify-center rounded-2xl bg-surface-100 text-surface-400">
|
||||
<svg class="h-6 w-6" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25H12" />
|
||||
</svg>
|
||||
</div>
|
||||
<p class="text-sm font-medium text-surface-700">{title}</p>
|
||||
{#if description}
|
||||
<p class="max-w-sm text-sm text-surface-400">{description}</p>
|
||||
{/if}
|
||||
{#if action}
|
||||
<div class="mt-2">
|
||||
{@render action()}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,26 @@
|
||||
<script lang="ts">
|
||||
let {
|
||||
message,
|
||||
onretry
|
||||
}: {
|
||||
message: string;
|
||||
onretry?: () => void;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<div class="saas-card flex flex-wrap items-start gap-3 border-error-200 bg-error-50 p-4 text-error-700">
|
||||
<svg class="mt-0.5 h-5 w-5 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z"
|
||||
/>
|
||||
</svg>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-sm font-medium">请求失败</p>
|
||||
<p class="mt-0.5 text-sm opacity-90">{message}</p>
|
||||
</div>
|
||||
{#if onretry}
|
||||
<button type="button" class="saas-btn-ghost text-sm" onclick={onretry}>重试</button>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,48 @@
|
||||
<script lang="ts">
|
||||
import type { ExplorerFolder } from '$lib/api';
|
||||
import Icon from './Icon.svelte';
|
||||
import FolderTree from './FolderTree.svelte';
|
||||
|
||||
let {
|
||||
folder,
|
||||
folders,
|
||||
projects,
|
||||
slug
|
||||
}: {
|
||||
folder: ExplorerFolder;
|
||||
folders: ExplorerFolder[];
|
||||
projects: {
|
||||
id: string;
|
||||
name: string;
|
||||
folderId: string | null;
|
||||
createdAt: string;
|
||||
binding: { chatId: string } | null;
|
||||
}[];
|
||||
slug: string;
|
||||
} = $props();
|
||||
|
||||
let open = $state(true);
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-center gap-2.5 rounded-lg px-3 py-2.5 text-left text-sm transition hover:bg-surface-100"
|
||||
onclick={() => (open = !open)}
|
||||
>
|
||||
<span class="w-3.5 text-center text-xs text-surface-400">{open ? '▾' : '▸'}</span>
|
||||
<span class="flex h-7 w-7 items-center justify-center rounded-md bg-warning-50 text-warning-700">
|
||||
<Icon name="folder" class="h-4 w-4" />
|
||||
</span>
|
||||
<span class="min-w-0 flex-1 truncate font-medium text-surface-800">{folder.name}</span>
|
||||
<span class="saas-badge-neutral">{folder.projectCount} 项目</span>
|
||||
{#if folder.childFolderCount > 0}
|
||||
<span class="saas-badge-neutral">{folder.childFolderCount} 子夹</span>
|
||||
{/if}
|
||||
</button>
|
||||
{#if open}
|
||||
<div class="ml-4 border-l border-surface-200 pl-2">
|
||||
<FolderTree {folders} {projects} parentId={folder.id} {slug} />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,49 @@
|
||||
<script lang="ts">
|
||||
import type { ExplorerFolder } from '$lib/api';
|
||||
import { fmtDate } from '$lib/format';
|
||||
import Icon from './Icon.svelte';
|
||||
import FolderNode from './FolderNode.svelte';
|
||||
|
||||
let {
|
||||
folders,
|
||||
projects,
|
||||
parentId,
|
||||
slug
|
||||
}: {
|
||||
folders: ExplorerFolder[];
|
||||
projects: {
|
||||
id: string;
|
||||
name: string;
|
||||
folderId: string | null;
|
||||
createdAt: string;
|
||||
binding: { chatId: string } | null;
|
||||
}[];
|
||||
parentId: string | null;
|
||||
slug: string;
|
||||
} = $props();
|
||||
|
||||
let childFolders = $derived(folders.filter((f) => f.parentId === parentId));
|
||||
let childProjects = $derived(projects.filter((p) => p.folderId === parentId));
|
||||
</script>
|
||||
|
||||
<div class="space-y-0.5">
|
||||
{#each childProjects as p (p.id)}
|
||||
<a
|
||||
href={`/admin/org/${slug}/projects/${p.id}`}
|
||||
class="flex items-center gap-2.5 rounded-lg px-3 py-2.5 text-sm transition hover:bg-surface-100"
|
||||
>
|
||||
<span class="flex h-7 w-7 items-center justify-center rounded-md bg-primary-50 text-primary-600">
|
||||
<Icon name="file" class="h-4 w-4" />
|
||||
</span>
|
||||
<span class="min-w-0 flex-1 truncate font-medium text-surface-800">{p.name}</span>
|
||||
{#if p.binding}
|
||||
<span class="saas-badge-success">已绑定</span>
|
||||
{/if}
|
||||
<span class="hidden text-xs text-surface-400 sm:inline">{fmtDate(p.createdAt)}</span>
|
||||
</a>
|
||||
{/each}
|
||||
|
||||
{#each childFolders as f (f.id)}
|
||||
<FolderNode folder={f} {folders} {projects} {slug} />
|
||||
{/each}
|
||||
</div>
|
||||
@@ -0,0 +1,115 @@
|
||||
<script lang="ts">
|
||||
/** Inline nav icons for the admin shell. */
|
||||
let {
|
||||
name,
|
||||
class: className = 'h-4 w-4'
|
||||
}: {
|
||||
name:
|
||||
| 'overview'
|
||||
| 'members'
|
||||
| 'teams'
|
||||
| 'projects'
|
||||
| 'models'
|
||||
| 'roles'
|
||||
| 'provider'
|
||||
| 'menu'
|
||||
| 'logout'
|
||||
| 'org'
|
||||
| 'chevron'
|
||||
| 'folder'
|
||||
| 'file'
|
||||
| 'check';
|
||||
class?: string;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
{#if name === 'overview'}
|
||||
<svg class={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3 12l9-9 9 9M5 10v9a1 1 0 001 1h3v-5h6v5h3a1 1 0 001-1v-9" />
|
||||
</svg>
|
||||
{:else if name === 'members'}
|
||||
<svg class={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M15.75 7.5a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.5 19.5a7.5 7.5 0 0115 0"
|
||||
/>
|
||||
</svg>
|
||||
{:else if name === 'teams'}
|
||||
<svg class={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M18 18.72a9.09 9.09 0 003.74-.72 9 9 0 00-5.07-5.95M15 11a4 4 0 10-8 0 4 4 0 008 0zM4.26 18a9 9 0 0115.48 0"
|
||||
/>
|
||||
</svg>
|
||||
{:else if name === 'projects'}
|
||||
<svg class={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M3.75 6.75A2.25 2.25 0 016 4.5h3.379c.6 0 1.175.238 1.6.66l.842.84c.424.423 1 .66 1.6.66H18A2.25 2.25 0 0120.25 9v8.25A2.25 2.25 0 0118 19.5H6a2.25 2.25 0 01-2.25-2.25V6.75z"
|
||||
/>
|
||||
</svg>
|
||||
{:else if name === 'models'}
|
||||
<svg class={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12l7.5-7.5L19.5 12 12 19.5 4.5 12z" />
|
||||
</svg>
|
||||
{:else if name === 'roles'}
|
||||
<svg class={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09z"
|
||||
/>
|
||||
</svg>
|
||||
{:else if name === 'provider'}
|
||||
<svg class={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M13.19 8.688a4.5 4.5 0 016.364 6.364l-3.182 3.182a4.5 4.5 0 01-6.364-6.364" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M10.81 15.312a4.5 4.5 0 01-6.364-6.364l3.182-3.182a4.5 4.5 0 016.364 6.364" />
|
||||
</svg>
|
||||
{:else if name === 'menu'}
|
||||
<svg class={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" />
|
||||
</svg>
|
||||
{:else if name === 'logout'}
|
||||
<svg class={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6A2.25 2.25 0 005.25 5.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15M12 9l3 3m0 0l-3 3m3-3H6"
|
||||
/>
|
||||
</svg>
|
||||
{:else if name === 'org'}
|
||||
<svg class={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M3.75 21h16.5M4.5 3h15M5.25 3v18m13.5-18v18M9 6.75h1.5m-1.5 3h1.5m-1.5 3h1.5m3-6H15m-1.5 3H15m-1.5 3H15M9 21v-3.375c0-.621.504-1.125 1.125-1.125h3.75c.621 0 1.125.504 1.125 1.125V21"
|
||||
/>
|
||||
</svg>
|
||||
{:else if name === 'chevron'}
|
||||
<svg class={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
|
||||
</svg>
|
||||
{:else if name === 'folder'}
|
||||
<svg class={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M3.75 6.75A2.25 2.25 0 016 4.5h3.379c.6 0 1.175.238 1.6.66l.842.84c.424.423 1 .66 1.6.66H18A2.25 2.25 0 0120.25 9v8.25A2.25 2.25 0 0118 19.5H6a2.25 2.25 0 01-2.25-2.25V6.75z"
|
||||
/>
|
||||
</svg>
|
||||
{:else if name === 'file'}
|
||||
<svg class={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z"
|
||||
/>
|
||||
</svg>
|
||||
{:else if name === 'check'}
|
||||
<svg class={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5" />
|
||||
</svg>
|
||||
{/if}
|
||||
@@ -0,0 +1,15 @@
|
||||
<script lang="ts">
|
||||
let { label = '加载中…' }: { label?: string } = $props();
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col items-center justify-center gap-3 py-16 text-surface-400">
|
||||
<svg class="h-7 w-7 animate-spin text-primary-500" viewBox="0 0 24 24" fill="none">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path
|
||||
class="opacity-90"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
</svg>
|
||||
<p class="text-sm">{label}</p>
|
||||
</div>
|
||||
@@ -0,0 +1,44 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
let {
|
||||
open = $bindable(false),
|
||||
title,
|
||||
children,
|
||||
onclose
|
||||
}: {
|
||||
open?: boolean;
|
||||
title: string;
|
||||
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">
|
||||
<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="关闭">
|
||||
<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>
|
||||
</div>
|
||||
{@render children()}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,27 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
let {
|
||||
title,
|
||||
description,
|
||||
actions
|
||||
}: {
|
||||
title: string;
|
||||
description?: string;
|
||||
actions?: Snippet;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<div class="saas-toolbar">
|
||||
<div class="min-w-0">
|
||||
<h1 class="saas-page-title">{title}</h1>
|
||||
{#if description}
|
||||
<p class="saas-muted mt-1">{description}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{#if actions}
|
||||
<div class="ml-auto flex flex-wrap items-center gap-2">
|
||||
{@render actions()}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,148 @@
|
||||
<script lang="ts">
|
||||
import { api, type OrgRoleRow, type OrgModelRow } from '$lib/api';
|
||||
import { fmtDate } from '$lib/format';
|
||||
import { TOOL_OPTIONS } from '$lib/constants';
|
||||
import { toastError, toastSuccess } from '$lib/toast';
|
||||
|
||||
let {
|
||||
r,
|
||||
models,
|
||||
slug,
|
||||
onremoved,
|
||||
onupdated
|
||||
}: {
|
||||
r: OrgRoleRow;
|
||||
models: OrgModelRow[];
|
||||
slug: string;
|
||||
onremoved: () => void;
|
||||
onupdated: (updated: OrgRoleRow) => void;
|
||||
} = $props();
|
||||
|
||||
// Local edit buffer intentionally seeded once from the row props.
|
||||
const initial = {
|
||||
roleId: r.roleId,
|
||||
label: r.label,
|
||||
defaultModelId: r.defaultModelId ?? '',
|
||||
systemPrompt: r.systemPrompt ?? '',
|
||||
unrestricted: r.tools === null,
|
||||
tools: r.tools ?? []
|
||||
};
|
||||
let roleId = $state(initial.roleId);
|
||||
let label = $state(initial.label);
|
||||
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 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];
|
||||
try {
|
||||
const updated = await api.updateRole(slug, r.id, {
|
||||
roleId: roleId.trim(),
|
||||
label: label.trim(),
|
||||
defaultModelId: defaultModelId === '' ? null : defaultModelId,
|
||||
systemPrompt: systemPrompt === '' ? null : systemPrompt,
|
||||
tools
|
||||
});
|
||||
onupdated(updated);
|
||||
toastSuccess('角色已保存');
|
||||
} catch (err) {
|
||||
toastError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
const groupedTools = TOOL_OPTIONS.reduce(
|
||||
(acc, t) => {
|
||||
(acc[t.group] ??= []).push(t);
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, typeof TOOL_OPTIONS>
|
||||
);
|
||||
</script>
|
||||
|
||||
<div class="saas-card-pad">
|
||||
<div class="mb-4 flex flex-wrap items-center gap-2">
|
||||
<span class="saas-badge-primary font-mono">/{roleId || r.roleId}</span>
|
||||
<span class="text-sm text-surface-500">{label || r.label}</span>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<label for="role-id-{r.id}" class="saas-label">roleId</label>
|
||||
<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>
|
||||
<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>
|
||||
</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} />
|
||||
<span class="text-sm">不限(使用全部注册工具)</span>
|
||||
</label>
|
||||
<div class="space-y-3 {unrestricted ? 'pointer-events-none opacity-40' : ''}">
|
||||
{#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}
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<label for="role-prompt-{r.id}" class="saas-label">系统提示词</label>
|
||||
<textarea
|
||||
id="role-prompt-{r.id}"
|
||||
class="saas-textarea"
|
||||
rows="4"
|
||||
placeholder="系统提示词(可选)。会话开始时注入,定义 agent 人格/指令。"
|
||||
bind:value={systemPrompt}
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex flex-wrap items-center gap-3 border-t border-surface-100 pt-4">
|
||||
<span class="text-xs text-surface-400">更新于 {fmtDate(r.updatedAt)}</span>
|
||||
<div class="flex-1"></div>
|
||||
<button class="saas-btn-danger" onclick={onremoved}>删除角色</button>
|
||||
<button class="saas-btn-primary" onclick={save} disabled={saving}>
|
||||
{saving ? '保存中…' : '保存'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,19 @@
|
||||
<script lang="ts">
|
||||
let {
|
||||
label,
|
||||
value,
|
||||
hint
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
hint?: string;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<div class="saas-stat">
|
||||
<div class="saas-stat-label">{label}</div>
|
||||
<div class="saas-stat-value">{value}</div>
|
||||
{#if hint}
|
||||
<div class="saas-help">{hint}</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,24 @@
|
||||
<script lang="ts">
|
||||
import { dismissToast, toasts } from '$lib/toast';
|
||||
|
||||
const kindClass: Record<string, string> = {
|
||||
info: 'border-surface-200 bg-surface-50 text-surface-800',
|
||||
success: 'border-success-200 bg-success-50 text-success-800',
|
||||
error: 'border-error-200 bg-error-50 text-error-800'
|
||||
};
|
||||
</script>
|
||||
|
||||
<div class="pointer-events-none fixed inset-x-0 top-0 z-[100] flex flex-col items-end gap-2 p-4">
|
||||
{#each $toasts as t (t.id)}
|
||||
<div
|
||||
class="pointer-events-auto flex max-w-sm items-start gap-3 rounded-xl border px-4 py-3 text-sm shadow-lg {kindClass[t.kind] ??
|
||||
kindClass.info}"
|
||||
role="status"
|
||||
>
|
||||
<p class="min-w-0 flex-1">{t.message}</p>
|
||||
<button type="button" class="opacity-60 hover:opacity-100" onclick={() => dismissToast(t.id)} aria-label="关闭">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -0,0 +1,22 @@
|
||||
export interface ToolOption {
|
||||
id: string;
|
||||
label: string;
|
||||
group: string;
|
||||
}
|
||||
|
||||
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: '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' }
|
||||
];
|
||||
|
||||
export const ORG_ROLES = ['OWNER', 'ADMIN', 'MEMBER'] as const;
|
||||
export const PERMISSION_ROLES = ['READ', 'EDIT', 'MANAGE'] as const;
|
||||
@@ -0,0 +1,28 @@
|
||||
export function fmtDate(iso: string): string {
|
||||
if (!iso) return '—';
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return iso;
|
||||
return d.toLocaleString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
}
|
||||
|
||||
export function fmtDateOnly(iso: string): string {
|
||||
if (!iso) return '—';
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return iso;
|
||||
return d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: '2-digit' });
|
||||
}
|
||||
|
||||
export function fmtCost(usd: number | null): string {
|
||||
if (usd === null) return '—';
|
||||
return `$${Number(usd).toFixed(4)}`;
|
||||
}
|
||||
|
||||
export function fmtNum(n: number): string {
|
||||
return n.toLocaleString();
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
// place files you want to import through the `$lib` alias in this folder.
|
||||
@@ -0,0 +1,43 @@
|
||||
import { writable } from 'svelte/store';
|
||||
import { api, type MeResponse } from './api';
|
||||
|
||||
interface SessionState {
|
||||
loading: boolean;
|
||||
me: MeResponse | null;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export const session = writable<SessionState>({
|
||||
loading: true,
|
||||
me: null,
|
||||
error: null
|
||||
});
|
||||
|
||||
export async function loadSession(): Promise<void> {
|
||||
session.update((s) => ({ ...s, loading: true, error: null }));
|
||||
try {
|
||||
const me = await api.me();
|
||||
session.set({ loading: false, me, error: null });
|
||||
} catch (err) {
|
||||
const status = (err as { status?: number }).status;
|
||||
if (status === 401) {
|
||||
redirectToLogin();
|
||||
return;
|
||||
}
|
||||
session.set({
|
||||
loading: false,
|
||||
me: null,
|
||||
error: err instanceof Error ? err.message : String(err)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function redirectToLogin(): void {
|
||||
const ret = encodeURIComponent(window.location.pathname + window.location.hash);
|
||||
window.location.href = `/auth/feishu?returnTo=${ret}`;
|
||||
}
|
||||
|
||||
export async function logout(): Promise<void> {
|
||||
await api.logout();
|
||||
redirectToLogin();
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
export type ToastKind = 'info' | 'success' | 'error';
|
||||
|
||||
export interface ToastItem {
|
||||
id: number;
|
||||
message: string;
|
||||
kind: ToastKind;
|
||||
}
|
||||
|
||||
let seq = 0;
|
||||
export const toasts = writable<ToastItem[]>([]);
|
||||
|
||||
export function pushToast(message: string, kind: ToastKind = 'info', ms = 3200): void {
|
||||
const id = ++seq;
|
||||
toasts.update((list) => [...list, { id, message, kind }]);
|
||||
if (ms > 0) {
|
||||
setTimeout(() => {
|
||||
toasts.update((list) => list.filter((t) => t.id !== id));
|
||||
}, ms);
|
||||
}
|
||||
}
|
||||
|
||||
export function dismissToast(id: number): void {
|
||||
toasts.update((list) => list.filter((t) => t.id !== id));
|
||||
}
|
||||
|
||||
export function toastSuccess(message: string): void {
|
||||
pushToast(message, 'success');
|
||||
}
|
||||
|
||||
export function toastError(message: string): void {
|
||||
pushToast(message, 'error', 5000);
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
<script lang="ts">
|
||||
import '../routes/app.css';
|
||||
import { onMount } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/state';
|
||||
import { session, loadSession, logout, redirectToLogin } from '$lib/session';
|
||||
import type { OrgMembership } from '$lib/api';
|
||||
import Icon from '$lib/components/Icon.svelte';
|
||||
import ToastHost from '$lib/components/ToastHost.svelte';
|
||||
|
||||
let { children } = $props();
|
||||
let mobileNavOpen = $state(false);
|
||||
let redirecting = $state(false);
|
||||
|
||||
onMount(() => {
|
||||
loadSession();
|
||||
});
|
||||
|
||||
const navItems = [
|
||||
{ key: 'overview', label: '概览', icon: 'overview' as const },
|
||||
{ key: 'members', label: '成员', icon: 'members' as const },
|
||||
{ key: 'teams', label: '团队', icon: 'teams' as const },
|
||||
{ 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 }
|
||||
];
|
||||
|
||||
function isAdmin(org: OrgMembership): boolean {
|
||||
const role = String(org.role ?? '').toUpperCase();
|
||||
return role === 'OWNER' || role === 'ADMIN';
|
||||
}
|
||||
|
||||
function orgSlugFromPath(): string | null {
|
||||
const parts = page.url.pathname.split('/').filter(Boolean);
|
||||
if (parts[0] === 'admin' && parts[1] === 'org' && parts[2]) {
|
||||
return decodeURIComponent(parts[2]);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function memberships(): OrgMembership[] {
|
||||
return $session.me?.organizations ?? [];
|
||||
}
|
||||
|
||||
function adminOrgs(): OrgMembership[] {
|
||||
return memberships().filter(isAdmin);
|
||||
}
|
||||
|
||||
/** Org matching the URL slug (if any). */
|
||||
function currentOrg(): OrgMembership | null {
|
||||
const slug = orgSlugFromPath();
|
||||
if (!slug) return null;
|
||||
return memberships().find((o) => o.slug === slug) ?? null;
|
||||
}
|
||||
|
||||
function pickHomeOrg(): OrgMembership | null {
|
||||
const admin = adminOrgs()[0];
|
||||
if (admin) return admin;
|
||||
return memberships()[0] ?? null;
|
||||
}
|
||||
|
||||
function activeKey(): string {
|
||||
const parts = page.url.pathname.split('/').filter(Boolean);
|
||||
if (parts[0] !== 'admin' || parts[1] !== 'org' || !parts[2]) return '';
|
||||
return parts[3] ?? 'overview';
|
||||
}
|
||||
|
||||
function navHref(key: string): string {
|
||||
const slug = currentOrg()?.slug ?? pickHomeOrg()?.slug;
|
||||
if (!slug) return '/';
|
||||
if (key === 'overview') return `/admin/org/${slug}`;
|
||||
return `/admin/org/${slug}/${key}`;
|
||||
}
|
||||
|
||||
function pageTitle(): string {
|
||||
const key = activeKey();
|
||||
if (key === 'overview' || key === '') return '概览';
|
||||
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 handleLogout(e: Event) {
|
||||
e.preventDefault();
|
||||
logout();
|
||||
}
|
||||
|
||||
// Close mobile nav on route change.
|
||||
$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();
|
||||
if (admins.length === 0) return;
|
||||
|
||||
const slug = orgSlugFromPath();
|
||||
const matched = slug ? memberships().find((o) => o.slug === slug) : null;
|
||||
if (matched && isAdmin(matched)) {
|
||||
redirecting = false;
|
||||
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}/`)) {
|
||||
redirecting = true;
|
||||
void goto(target, { replaceState: true });
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<ToastHost />
|
||||
|
||||
{#if $session.loading || redirecting}
|
||||
<div class="saas-status-panel">
|
||||
<div class="flex flex-col items-center gap-3 text-surface-400">
|
||||
<svg class="h-8 w-8 animate-spin text-primary-500" viewBox="0 0 24 24" fill="none">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path
|
||||
class="opacity-90"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
</svg>
|
||||
<p class="text-sm">{redirecting ? '正在进入组织…' : '正在加载会话…'}</p>
|
||||
</div>
|
||||
</div>
|
||||
{:else if $session.error}
|
||||
<div class="saas-status-panel">
|
||||
<div class="saas-status-card">
|
||||
<div class="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-2xl bg-error-100 text-error-600">!</div>
|
||||
<h2 class="mb-1 text-lg font-semibold">无法连接到后端</h2>
|
||||
<p class="mb-5 text-sm text-surface-500">{$session.error}</p>
|
||||
<button class="saas-btn-primary" onclick={() => loadSession()}>重试</button>
|
||||
</div>
|
||||
</div>
|
||||
{:else if !$session.me}
|
||||
<div class="saas-status-panel">
|
||||
<div class="saas-status-card">
|
||||
<div class="mx-auto mb-5 flex h-12 w-12 items-center justify-center rounded-2xl bg-primary-600 text-white font-bold">
|
||||
CPH
|
||||
</div>
|
||||
<h1 class="text-xl font-semibold tracking-tight">Curriculum Project Hub</h1>
|
||||
<p class="mt-2 mb-6 text-sm text-surface-500">登录以管理组织、项目、团队与模型供应。</p>
|
||||
<button class="saas-btn-primary w-full" onclick={() => redirectToLogin()}>使用飞书登录</button>
|
||||
</div>
|
||||
</div>
|
||||
{:else if currentOrg() && isAdmin(currentOrg()!)}
|
||||
{@const org = currentOrg()!}
|
||||
{@const me = $session.me!}
|
||||
<div class="saas-shell">
|
||||
{#if mobileNavOpen}
|
||||
<button
|
||||
type="button"
|
||||
class="fixed inset-0 z-40 bg-surface-950/40 md:hidden"
|
||||
aria-label="关闭导航"
|
||||
onclick={() => (mobileNavOpen = false)}
|
||||
></button>
|
||||
{/if}
|
||||
|
||||
<aside
|
||||
class="saas-sidebar fixed inset-y-0 left-0 z-50 transition-transform md:static md:translate-x-0
|
||||
{mobileNavOpen ? 'translate-x-0' : '-translate-x-full md:translate-x-0'}"
|
||||
>
|
||||
<div class="flex items-center gap-2.5 px-4 py-4">
|
||||
<div class="flex h-9 w-9 items-center justify-center rounded-xl bg-primary-600 text-xs font-bold tracking-wide text-white">
|
||||
CPH
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<div class="truncate text-sm font-semibold text-surface-900">Org Admin</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">
|
||||
<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>
|
||||
|
||||
<nav class="flex-1 space-y-0.5 overflow-y-auto px-2 pb-3">
|
||||
<p class="px-3 pb-1 pt-2 text-[11px] font-semibold uppercase tracking-wider text-surface-400">工作台</p>
|
||||
{#each navItems as item}
|
||||
{@const active = activeKey() === item.key || (item.key === 'overview' && (activeKey() === '' || activeKey() === 'overview'))}
|
||||
<a href={navHref(item.key)} class="saas-nav-item" data-active={active ? 'true' : 'false'}>
|
||||
<Icon name={item.icon} class="h-4 w-4 shrink-0 opacity-80" />
|
||||
<span>{item.label}</span>
|
||||
</a>
|
||||
{/each}
|
||||
</nav>
|
||||
|
||||
<div class="border-t border-surface-200 p-3">
|
||||
<div class="flex items-center gap-2.5 rounded-xl bg-surface-100/80 px-2.5 py-2">
|
||||
{#if me.user.avatarUrl}
|
||||
<img src={me.user.avatarUrl} alt="" class="h-8 w-8 rounded-full object-cover" />
|
||||
{:else}
|
||||
<div class="flex h-8 w-8 items-center justify-center rounded-full bg-primary-100 text-xs font-semibold text-primary-700">
|
||||
{me.user.displayName.slice(0, 1)}
|
||||
</div>
|
||||
{/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>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg p-1.5 text-surface-400 transition hover:bg-surface-200 hover:text-error-600"
|
||||
title="退出登录"
|
||||
onclick={handleLogout}
|
||||
>
|
||||
<Icon name="logout" class="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div class="saas-main">
|
||||
<header class="saas-topbar">
|
||||
<button
|
||||
type="button"
|
||||
class="saas-btn-ghost !px-2 md:hidden"
|
||||
onclick={() => (mobileNavOpen = !mobileNavOpen)}
|
||||
aria-label="打开导航"
|
||||
>
|
||||
<Icon name="menu" class="h-5 w-5" />
|
||||
</button>
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-center gap-1.5 text-xs text-surface-400">
|
||||
<span class="truncate">{org.name}</span>
|
||||
<span>/</span>
|
||||
<span class="truncate text-surface-600">{pageTitle()}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ml-auto hidden items-center gap-2 sm:flex">
|
||||
<span class="saas-badge-primary">{org.status}</span>
|
||||
<span class="saas-badge-neutral font-mono">/{org.slug}</span>
|
||||
</div>
|
||||
</header>
|
||||
<main class="saas-content">
|
||||
<div class="saas-content-inner">
|
||||
{@render children()}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
{:else if currentOrg() && !isAdmin(currentOrg()!)}
|
||||
{@const denied = currentOrg()!}
|
||||
<div class="saas-status-panel">
|
||||
<div class="saas-status-card">
|
||||
<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>。
|
||||
</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>
|
||||
{/if}
|
||||
<button class="saas-btn-ghost" onclick={handleLogout}>退出登录</button>
|
||||
</div>
|
||||
</div>
|
||||
{:else if memberships().length > 0}
|
||||
{@const denied = pickHomeOrg()!}
|
||||
<div class="saas-status-panel">
|
||||
<div class="saas-status-card">
|
||||
{#if adminOrgs().length === 0}
|
||||
<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 可进入后台。
|
||||
</p>
|
||||
{:else}
|
||||
<h2 class="mb-2 text-lg font-semibold">正在跳转…</h2>
|
||||
<p class="mb-5 text-sm text-surface-500">
|
||||
即将进入 <strong>{adminOrgs()[0].name}</strong>
|
||||
(/{adminOrgs()[0].slug})。
|
||||
</p>
|
||||
<a class="saas-btn-primary" href={`/admin/org/${adminOrgs()[0].slug}`}>立即进入</a>
|
||||
{/if}
|
||||
<button class="saas-btn-ghost mt-3" onclick={handleLogout}>退出登录</button>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="saas-status-panel">
|
||||
<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:
|
||||
<code class="break-all font-mono text-surface-600">{$session.me!.user.feishuOpenId}</code>
|
||||
</p>
|
||||
<button class="saas-btn-primary" onclick={handleLogout}>退出登录</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,43 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { session, redirectToLogin } from '$lib/session';
|
||||
|
||||
function homeForSession(): string | null {
|
||||
const me = $session.me;
|
||||
if (!me) return null;
|
||||
const admin = me.organizations.find((o) => {
|
||||
const r = String(o.role ?? '').toUpperCase();
|
||||
return r === 'OWNER' || r === 'ADMIN';
|
||||
});
|
||||
const target = admin ?? me.organizations[0];
|
||||
return target ? `/admin/org/${target.slug}` : null;
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
const unsub = session.subscribe((s) => {
|
||||
if (s.loading) return;
|
||||
if (!s.me) {
|
||||
redirectToLogin();
|
||||
return;
|
||||
}
|
||||
const dest = homeForSession();
|
||||
if (dest) void goto(dest, { replaceState: true });
|
||||
});
|
||||
return unsub;
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="saas-status-panel">
|
||||
<div class="flex flex-col items-center gap-3 text-surface-400">
|
||||
<svg class="h-7 w-7 animate-spin text-primary-500" viewBox="0 0 24 24" fill="none">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path
|
||||
class="opacity-90"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
</svg>
|
||||
<p class="text-sm">正在进入工作台…</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,158 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { api, type OrgMembership } from '$lib/api';
|
||||
import { session } from '$lib/session';
|
||||
import { fmtCost, fmtNum } 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 { toastError, toastSuccess } from '$lib/toast';
|
||||
|
||||
let orgSlug = $derived(page.params.slug ?? '');
|
||||
let org = $derived(
|
||||
$session.me?.organizations.find((o) => o.slug === orgSlug) as OrgMembership | undefined
|
||||
);
|
||||
|
||||
let settings = $state<{ membersCanCreateProjects: boolean } | null>(null);
|
||||
let usage = $state<Awaited<ReturnType<typeof api.usage>> | null>(null);
|
||||
let loading = $state(true);
|
||||
let error = $state<string | null>(null);
|
||||
let saving = $state(false);
|
||||
|
||||
async function load() {
|
||||
loading = true;
|
||||
error = null;
|
||||
try {
|
||||
[settings, usage] = await Promise.all([api.settings(orgSlug), api.usage(orgSlug)]);
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : String(err);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleCreate() {
|
||||
if (!settings) return;
|
||||
saving = true;
|
||||
const prev = settings.membersCanCreateProjects;
|
||||
settings.membersCanCreateProjects = !prev;
|
||||
try {
|
||||
await api.setSettings(orgSlug, { membersCanCreateProjects: settings.membersCanCreateProjects });
|
||||
toastSuccess(settings.membersCanCreateProjects ? '已允许成员自助建项' : '已关闭成员自助建项');
|
||||
} catch (err) {
|
||||
settings.membersCanCreateProjects = prev;
|
||||
toastError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (orgSlug) load();
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if loading}
|
||||
<LoadingState />
|
||||
{:else if error}
|
||||
<ErrorBanner message={error} onretry={load} />
|
||||
{:else if org && settings && usage}
|
||||
<PageHeader title={org.name} description="组织健康度、用量与生产策略一览。" />
|
||||
|
||||
<div class="mb-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<div class="saas-card-pad sm:col-span-2 lg:col-span-1">
|
||||
<p class="saas-section-title mb-3">组织信息</p>
|
||||
<dl class="space-y-2.5 text-sm">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<dt class="text-surface-500">Slug</dt>
|
||||
<dd class="font-mono text-xs text-surface-800">/{org.slug}</dd>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<dt class="text-surface-500">状态</dt>
|
||||
<dd><span class="saas-badge-success">{org.status}</span></dd>
|
||||
</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>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<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"
|
||||
checked={settings.membersCanCreateProjects}
|
||||
disabled={saving}
|
||||
onchange={toggleCreate}
|
||||
/>
|
||||
<div>
|
||||
<div class="text-sm font-medium text-surface-800">允许成员自助创建项目</div>
|
||||
<div class="text-xs text-surface-400">membersCanCreateProjects</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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-6 grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<StatCard label="运行总数" value={fmtNum(usage.totals.runCount)} />
|
||||
<StatCard label="有成本运行" value={fmtNum(usage.totals.runsWithCost)} />
|
||||
<StatCard label="无成本运行" value={fmtNum(usage.totals.runsWithoutCost)} />
|
||||
<StatCard label="输入 tokens" value={fmtNum(usage.totals.inputTokens)} />
|
||||
<StatCard label="输出 tokens" value={fmtNum(usage.totals.outputTokens)} />
|
||||
<StatCard label="成本 (USD)" value={fmtCost(usage.totals.costUsd)} />
|
||||
</div>
|
||||
|
||||
<div class="saas-card overflow-hidden">
|
||||
<div class="border-b border-surface-200 px-5 py-3">
|
||||
<h3 class="text-sm font-semibold text-surface-800">按项目用量</h3>
|
||||
</div>
|
||||
{#if usage.projects.length === 0}
|
||||
<div class="saas-empty">
|
||||
<p class="text-sm text-surface-400">暂无项目用量数据</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="overflow-x-auto">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>项目</th>
|
||||
<th>运行</th>
|
||||
<th>in / out tokens</th>
|
||||
<th>成本</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each usage.projects as p}
|
||||
<tr>
|
||||
<td class="font-medium">{p.projectName}</td>
|
||||
<td class="tabular-nums">{fmtNum(p.runCount)}</td>
|
||||
<td class="tabular-nums text-surface-600"
|
||||
>{fmtNum(p.inputTokens)} / {fmtNum(p.outputTokens)}</td
|
||||
>
|
||||
<td class="tabular-nums">{fmtCost(p.costUsd)}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="saas-empty">
|
||||
<p class="text-sm text-surface-400">组织数据不可用</p>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,168 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { api, type OrgMember } from '$lib/api';
|
||||
import { fmtDate } from '$lib/format';
|
||||
import { ORG_ROLES } 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 { toastError, toastSuccess } from '$lib/toast';
|
||||
|
||||
const slug = $derived(page.params.slug ?? '');
|
||||
const roles = [...ORG_ROLES];
|
||||
|
||||
let members = $state<OrgMember[]>([]);
|
||||
let loading = $state(true);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
let newOpenId = $state('');
|
||||
let newName = $state('');
|
||||
let newRole = $state<string>('MEMBER');
|
||||
let adding = $state(false);
|
||||
|
||||
async function load() {
|
||||
loading = true;
|
||||
error = null;
|
||||
try {
|
||||
const res = await api.members(slug);
|
||||
members = res.members;
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : String(err);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function addMember() {
|
||||
if (!newOpenId.trim()) return;
|
||||
adding = true;
|
||||
try {
|
||||
const m = await api.addMember(slug, {
|
||||
feishuOpenId: newOpenId.trim(),
|
||||
role: newRole,
|
||||
...(newName.trim() ? { displayName: newName.trim() } : {})
|
||||
});
|
||||
members = [...members, m].sort(
|
||||
(a, b) => a.role.localeCompare(b.role) || a.createdAt.localeCompare(b.createdAt)
|
||||
);
|
||||
newOpenId = '';
|
||||
newName = '';
|
||||
toastSuccess('成员已添加');
|
||||
} catch (err) {
|
||||
toastError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
adding = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function changeRole(m: OrgMember, role: string) {
|
||||
try {
|
||||
await api.setMemberRole(slug, m.userId, role);
|
||||
await load();
|
||||
toastSuccess('角色已更新');
|
||||
} catch (err) {
|
||||
toastError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
|
||||
async function revoke(m: OrgMember) {
|
||||
if (!confirm(`移除成员 ${m.displayName} 出本组织?`)) return;
|
||||
try {
|
||||
await api.revokeMember(slug, m.userId);
|
||||
await load();
|
||||
toastSuccess('成员已移除');
|
||||
} catch (err) {
|
||||
toastError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (slug) load();
|
||||
});
|
||||
</script>
|
||||
|
||||
<PageHeader title="成员与权限" description="管理组织角色:OWNER / ADMIN 可访问本后台,MEMBER 不可。" />
|
||||
|
||||
{#if loading}
|
||||
<LoadingState />
|
||||
{:else if error}
|
||||
<ErrorBanner message={error} onretry={load} />
|
||||
{: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} />
|
||||
<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>
|
||||
<button class="saas-btn-primary" onclick={addMember} disabled={adding}>
|
||||
{adding ? '添加中…' : '添加成员'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="saas-card overflow-hidden">
|
||||
<div class="flex items-center justify-between border-b border-surface-200 px-5 py-3">
|
||||
<h2 class="text-sm font-semibold">成员列表</h2>
|
||||
<span class="saas-badge-neutral">{members.length}</span>
|
||||
</div>
|
||||
{#if members.length === 0}
|
||||
<EmptyState title="暂无成员" description="使用上方表单按飞书 open_id 添加成员。" />
|
||||
{:else}
|
||||
<div class="overflow-x-auto">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>用户</th>
|
||||
<th>open_id</th>
|
||||
<th>组织角色</th>
|
||||
<th>加入时间</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each members as m}
|
||||
<tr>
|
||||
<td>
|
||||
<div class="flex items-center gap-2.5">
|
||||
{#if m.avatarUrl}
|
||||
<img src={m.avatarUrl} alt="" class="h-7 w-7 rounded-full object-cover" />
|
||||
{:else}
|
||||
<div
|
||||
class="flex h-7 w-7 items-center justify-center rounded-full bg-primary-100 text-xs font-semibold text-primary-700"
|
||||
>
|
||||
{m.displayName.slice(0, 1)}
|
||||
</div>
|
||||
{/if}
|
||||
<span class="font-medium">{m.displayName}</span>
|
||||
</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"
|
||||
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>
|
||||
</td>
|
||||
<td class="text-surface-500">{fmtDate(m.createdAt)}</td>
|
||||
<td class="text-right">
|
||||
<button class="saas-btn-danger !py-1 text-sm" onclick={() => revoke(m)}>移除</button>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
<p class="border-t border-surface-100 px-5 py-3 text-xs text-surface-400">
|
||||
组织角色控制后台访问;项目级权限由「项目」页团队授权(READ / EDIT / MANAGE)决定。
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,170 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { api, type OrgModelRow } from '$lib/api';
|
||||
import { fmtDateOnly } from '$lib/format';
|
||||
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 { toastError, toastSuccess } from '$lib/toast';
|
||||
|
||||
const slug = $derived(page.params.slug ?? '');
|
||||
|
||||
let models = $state<OrgModelRow[]>([]);
|
||||
let loading = $state(true);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
let newModelId = $state('');
|
||||
let newLabel = $state('');
|
||||
let newSortKey = $state('');
|
||||
let adding = $state(false);
|
||||
|
||||
async function load() {
|
||||
loading = true;
|
||||
error = null;
|
||||
try {
|
||||
const res = await api.models(slug);
|
||||
models = res.models;
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : String(err);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function add() {
|
||||
if (!newModelId.trim() || !newLabel.trim()) return;
|
||||
adding = true;
|
||||
try {
|
||||
const m = await api.createModel(slug, {
|
||||
modelId: newModelId.trim(),
|
||||
label: newLabel.trim(),
|
||||
...(newSortKey.trim() ? { sortKey: newSortKey.trim() } : {})
|
||||
});
|
||||
models = [...models, m];
|
||||
newModelId = '';
|
||||
newLabel = '';
|
||||
newSortKey = '';
|
||||
toastSuccess('模型已添加');
|
||||
} catch (err) {
|
||||
toastError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
adding = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function save(m: OrgModelRow, patch: Partial<OrgModelRow>) {
|
||||
try {
|
||||
const updated = await api.updateModel(slug, m.id, patch);
|
||||
models = models.map((x) => (x.id === m.id ? updated : x));
|
||||
toastSuccess('已保存');
|
||||
} catch (err) {
|
||||
toastError(err instanceof Error ? err.message : String(err));
|
||||
await load();
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(m: OrgModelRow) {
|
||||
if (!confirm(`删除模型 ${m.label}?`)) return;
|
||||
try {
|
||||
await api.deleteModel(slug, m.id);
|
||||
models = models.filter((x) => x.id !== m.id);
|
||||
toastSuccess('模型已删除');
|
||||
} catch (err) {
|
||||
toastError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (slug) load();
|
||||
});
|
||||
</script>
|
||||
|
||||
<PageHeader
|
||||
title="模型"
|
||||
description="Org-scoped 模型清单(ADR-0017)。modelId 面向 provider;label 面向教师。"
|
||||
/>
|
||||
|
||||
{#if loading}
|
||||
<LoadingState />
|
||||
{:else if error}
|
||||
<ErrorBanner message={error} onretry={load} />
|
||||
{: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.4fr_1fr_auto_auto]">
|
||||
<input
|
||||
class="saas-input font-mono text-sm"
|
||||
placeholder="modelId(如 anthropic/claude-sonnet-5)"
|
||||
bind:value={newModelId}
|
||||
/>
|
||||
<input class="saas-input" placeholder="label(如 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>
|
||||
</div>
|
||||
|
||||
<div class="saas-card overflow-hidden">
|
||||
<div class="flex items-center justify-between border-b border-surface-200 px-5 py-3">
|
||||
<h2 class="text-sm font-semibold">已启用模型</h2>
|
||||
<span class="saas-badge-neutral">{models.length}</span>
|
||||
</div>
|
||||
{#if models.length === 0}
|
||||
<EmptyState title="暂无已启用模型" description="空清单时会回退到环境默认模型。" />
|
||||
{:else}
|
||||
<div class="overflow-x-auto">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>label</th>
|
||||
<th>modelId</th>
|
||||
<th>工具能力</th>
|
||||
<th>排序</th>
|
||||
<th>创建</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each models as m (m.id)}
|
||||
<tr>
|
||||
<td>
|
||||
<input
|
||||
class="saas-input py-1.5"
|
||||
value={m.label}
|
||||
onchange={(e) => save(m, { label: (e.target as HTMLInputElement).value })}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<input
|
||||
class="saas-input py-1.5 font-mono text-xs"
|
||||
value={m.modelId}
|
||||
onchange={(e) => save(m, { modelId: (e.target as HTMLInputElement).value })}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="checkbox"
|
||||
checked={m.toolCapable}
|
||||
onchange={(e) => save(m, { toolCapable: (e.target as HTMLInputElement).checked })}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<input
|
||||
class="saas-input w-20 py-1.5"
|
||||
value={m.sortKey}
|
||||
onchange={(e) => save(m, { sortKey: (e.target as HTMLInputElement).value })}
|
||||
/>
|
||||
</td>
|
||||
<td class="text-xs text-surface-400">{fmtDateOnly(m.createdAt)}</td>
|
||||
<td class="text-right">
|
||||
<button class="saas-btn-danger !py-1 text-xs" onclick={() => remove(m)}>删除</button>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,158 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { api, type ExplorerData, type ExplorerFolder } from '$lib/api';
|
||||
import FolderTree from '$lib/components/FolderTree.svelte';
|
||||
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 Modal from '$lib/components/Modal.svelte';
|
||||
import { toastError, toastSuccess } from '$lib/toast';
|
||||
|
||||
const slug = $derived(page.params.slug ?? '');
|
||||
|
||||
let data = $state<ExplorerData | null>(null);
|
||||
let loading = $state(true);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
let showFolderModal = $state(false);
|
||||
let folderName = $state('');
|
||||
let folderParent = $state('');
|
||||
|
||||
let showProjectModal = $state(false);
|
||||
let projectName = $state('');
|
||||
let projectFolder = $state('');
|
||||
|
||||
async function load() {
|
||||
loading = true;
|
||||
error = null;
|
||||
try {
|
||||
data = await api.explorer(slug);
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : String(err);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function createFolder() {
|
||||
if (!folderName.trim()) return;
|
||||
try {
|
||||
await api.createFolder(slug, {
|
||||
name: folderName.trim(),
|
||||
...(folderParent ? { parentId: folderParent } : {})
|
||||
});
|
||||
folderName = '';
|
||||
folderParent = '';
|
||||
showFolderModal = false;
|
||||
await load();
|
||||
toastSuccess('文件夹已创建');
|
||||
} catch (err) {
|
||||
toastError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
|
||||
async function createProject() {
|
||||
if (!projectName.trim()) return;
|
||||
try {
|
||||
const res = await api.createProject(slug, {
|
||||
name: projectName.trim(),
|
||||
...(projectFolder ? { folderId: projectFolder } : {})
|
||||
});
|
||||
projectName = '';
|
||||
projectFolder = '';
|
||||
showProjectModal = false;
|
||||
window.location.href = `/admin/org/${slug}/projects/${res.id}`;
|
||||
} catch (err) {
|
||||
toastError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
|
||||
function folderPath(f: ExplorerFolder): string {
|
||||
if (!data) return f.name;
|
||||
const parts: string[] = [f.name];
|
||||
let cur: ExplorerFolder | undefined = f;
|
||||
while (cur?.parentId) {
|
||||
const parent = data.folders.find((x) => x.id === cur!.parentId);
|
||||
if (!parent) break;
|
||||
parts.unshift(parent.name);
|
||||
cur = parent;
|
||||
}
|
||||
return parts.join(' / ');
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (slug) load();
|
||||
});
|
||||
</script>
|
||||
|
||||
<PageHeader title="项目" description="文件夹是透明组织节点;项目是权限边界。">
|
||||
{#snippet actions()}
|
||||
<button class="saas-btn-secondary" onclick={() => (showFolderModal = true)}>新建文件夹</button>
|
||||
<button class="saas-btn-primary" onclick={() => (showProjectModal = true)}>新建项目</button>
|
||||
{/snippet}
|
||||
</PageHeader>
|
||||
|
||||
{#if loading}
|
||||
<LoadingState />
|
||||
{:else if error}
|
||||
<ErrorBanner message={error} onretry={load} />
|
||||
{:else if data}
|
||||
<div class="saas-card p-2 sm:p-3">
|
||||
{#if data.projects.filter((p) => !p.folderId).length === 0 && data.folders.filter((f) => !f.parentId).length === 0}
|
||||
<EmptyState title="暂无项目" description="新建文件夹或项目,开始组织你的教研资产。" />
|
||||
{:else}
|
||||
<FolderTree folders={data.folders} projects={data.projects} parentId={null} {slug} />
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<Modal bind:open={showFolderModal} title="新建文件夹">
|
||||
<label class="saas-label" for="folder-name">名称</label>
|
||||
<input
|
||||
id="folder-name"
|
||||
class="saas-input mb-4"
|
||||
bind:value={folderName}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Enter') createFolder();
|
||||
}}
|
||||
/>
|
||||
{#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>
|
||||
{/if}
|
||||
<div class="flex justify-end gap-2">
|
||||
<button class="saas-btn-ghost" onclick={() => (showFolderModal = false)}>取消</button>
|
||||
<button class="saas-btn-primary" onclick={createFolder}>创建</button>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<Modal bind:open={showProjectModal} title="新建项目">
|
||||
<label class="saas-label" for="project-name">项目名</label>
|
||||
<input
|
||||
id="project-name"
|
||||
class="saas-input mb-4"
|
||||
bind:value={projectName}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Enter') createProject();
|
||||
}}
|
||||
/>
|
||||
{#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>
|
||||
{/if}
|
||||
<div class="flex justify-end gap-2">
|
||||
<button class="saas-btn-ghost" onclick={() => (showProjectModal = false)}>取消</button>
|
||||
<button class="saas-btn-primary" onclick={createProject}>创建</button>
|
||||
</div>
|
||||
</Modal>
|
||||
@@ -0,0 +1,274 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import {
|
||||
api,
|
||||
type ProjectDetail,
|
||||
type TeamAccessEntry,
|
||||
type TeamRow,
|
||||
type SessionSummary,
|
||||
type ExplorerData
|
||||
} from '$lib/api';
|
||||
import { fmtDate } from '$lib/format';
|
||||
import { PERMISSION_ROLES } 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 { toastError, toastSuccess } from '$lib/toast';
|
||||
|
||||
const slug = $derived(page.params.slug ?? '');
|
||||
const projectId = $derived(page.params.projectId ?? '');
|
||||
|
||||
let proj = $state<ProjectDetail | null>(null);
|
||||
let access = $state<TeamAccessEntry[]>([]);
|
||||
let sessions = $state<SessionSummary[]>([]);
|
||||
let teams = $state<TeamRow[]>([]);
|
||||
let explorer = $state<ExplorerData | null>(null);
|
||||
let loading = $state(true);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
let grantTeam = $state('');
|
||||
let grantRole = $state<string>('EDIT');
|
||||
let moveFolder = $state('');
|
||||
|
||||
async function load() {
|
||||
loading = true;
|
||||
error = null;
|
||||
try {
|
||||
const [p, a, s, t, e] = await Promise.all([
|
||||
api.project(slug, projectId),
|
||||
api.teamAccess(slug, projectId),
|
||||
api.sessions(slug, projectId),
|
||||
api.teams(slug),
|
||||
api.explorer(slug)
|
||||
]);
|
||||
proj = p;
|
||||
access = a.access;
|
||||
sessions = s.sessions;
|
||||
teams = t.teams;
|
||||
explorer = e;
|
||||
moveFolder = p.folderId ?? '';
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : String(err);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function rename() {
|
||||
if (!proj) return;
|
||||
const name = prompt('新名称', proj.name);
|
||||
if (!name) return;
|
||||
try {
|
||||
await api.renameProject(slug, projectId, name);
|
||||
await load();
|
||||
toastSuccess('已重命名');
|
||||
} catch (err) {
|
||||
toastError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
|
||||
async function archiveBinding() {
|
||||
if (!confirm('解绑当前飞书群? 用户将无法通过该群触发 agent。')) return;
|
||||
try {
|
||||
await api.archiveBinding(slug, projectId);
|
||||
await load();
|
||||
toastSuccess('已解绑飞书群');
|
||||
} catch (err) {
|
||||
toastError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
|
||||
async function archiveProject() {
|
||||
if (!confirm(`归档项目 ${proj?.name}?`)) return;
|
||||
try {
|
||||
await api.archiveProject(slug, projectId);
|
||||
window.location.href = `/admin/org/${slug}/projects`;
|
||||
} catch (err) {
|
||||
toastError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
|
||||
async function move() {
|
||||
try {
|
||||
await api.moveProject(slug, projectId, moveFolder || null);
|
||||
await load();
|
||||
toastSuccess('已移动');
|
||||
} catch (err) {
|
||||
toastError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
|
||||
async function grant() {
|
||||
if (!grantTeam) return;
|
||||
try {
|
||||
await api.grantTeamAccess(slug, projectId, { teamId: grantTeam, role: grantRole });
|
||||
grantTeam = '';
|
||||
await load();
|
||||
toastSuccess('已授权');
|
||||
} catch (err) {
|
||||
toastError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
|
||||
async function revoke(t: TeamAccessEntry) {
|
||||
if (!confirm(`撤销 ${t.teamName} 对此项目的授权?`)) return;
|
||||
try {
|
||||
await api.revokeTeamAccess(slug, projectId, t.teamId);
|
||||
await load();
|
||||
toastSuccess('已撤销授权');
|
||||
} catch (err) {
|
||||
toastError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (slug && projectId) load();
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if loading}
|
||||
<LoadingState />
|
||||
{:else if error}
|
||||
<ErrorBanner message={error} onretry={load} />
|
||||
{:else if proj}
|
||||
<div class="mb-2">
|
||||
<a href={`/admin/org/${slug}/projects`} class="inline-flex items-center gap-1 text-sm text-surface-500 hover:text-primary-600">
|
||||
← 返回项目列表
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{@const detail = proj}
|
||||
<PageHeader title={detail.name} description="项目是权限边界;通过团队授予 READ ⊂ EDIT ⊂ MANAGE。">
|
||||
{#snippet actions()}
|
||||
<button class="saas-btn-secondary !py-1.5 text-sm" onclick={rename}>重命名</button>
|
||||
{#if detail.binding}
|
||||
<button class="saas-btn-secondary !py-1.5 text-sm" onclick={archiveBinding}>解绑飞书群</button>
|
||||
{/if}
|
||||
<button class="saas-btn-danger !py-1.5 text-sm" onclick={archiveProject}>归档</button>
|
||||
{/snippet}
|
||||
</PageHeader>
|
||||
|
||||
<div class="saas-card-pad mb-6">
|
||||
<dl class="grid gap-x-8 gap-y-3 text-sm sm:grid-cols-2">
|
||||
<div>
|
||||
<dt class="text-xs font-medium uppercase tracking-wide text-surface-400">Workspace</dt>
|
||||
<dd class="mt-0.5 break-all font-mono text-xs text-surface-700">{proj.workspaceDir}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-xs font-medium uppercase tracking-wide text-surface-400">创建者</dt>
|
||||
<dd class="mt-0.5 text-surface-800">
|
||||
{proj.createdBy ? `${proj.createdBy.displayName} (${proj.createdBy.feishuOpenId})` : '—'}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-xs font-medium uppercase tracking-wide text-surface-400">文件夹</dt>
|
||||
<dd class="mt-0.5 text-surface-800">{proj.folder ? proj.folder.name : '(根)'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-xs font-medium uppercase tracking-wide text-surface-400">飞书群</dt>
|
||||
<dd class="mt-0.5 text-surface-800">
|
||||
{#if proj.binding}
|
||||
<span class="saas-badge-success mr-1">已绑定</span>
|
||||
<span class="font-mono text-xs">chat {proj.binding.chatId}</span>
|
||||
<span class="text-surface-400"> · {fmtDate(proj.binding.createdAt)}</span>
|
||||
{:else}
|
||||
<span class="saas-badge-neutral">未绑定</span>
|
||||
{/if}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-xs font-medium uppercase tracking-wide text-surface-400">创建时间</dt>
|
||||
<dd class="mt-0.5 text-surface-800">{fmtDate(proj.createdAt)}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
{#if explorer}
|
||||
<div class="mt-5 flex flex-wrap items-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>
|
||||
<button class="saas-btn-secondary" onclick={move}>移动</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="saas-card-pad mb-6">
|
||||
<h3 class="saas-section-title mb-1">团队授权</h3>
|
||||
<p class="saas-muted mb-4">通过团队授权项目访问。一项目可授多团队,一团队可访问多项目。</p>
|
||||
|
||||
<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>
|
||||
<button class="saas-btn-primary" onclick={grant}>授权</button>
|
||||
</div>
|
||||
|
||||
{#if access.length === 0}
|
||||
<EmptyState title="暂无团队授权" description="选择团队并授予角色以开放项目访问。" />
|
||||
{:else}
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>团队</th>
|
||||
<th>slug</th>
|
||||
<th>角色</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each access as g}
|
||||
<tr>
|
||||
<td class="font-medium">{g.teamName}</td>
|
||||
<td class="font-mono text-xs">/{g.teamSlug}</td>
|
||||
<td><span class="saas-badge-primary">{g.role}</span></td>
|
||||
<td class="text-right">
|
||||
<button class="saas-btn-danger !py-1 text-xs" onclick={() => revoke(g)}>撤销</button>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
{#if sessions.length === 0}
|
||||
<EmptyState title="暂无会话" description="飞书侧触发 agent 后会显示在此。" />
|
||||
{:else}
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>provider / role</th>
|
||||
<th>model</th>
|
||||
<th>运行</th>
|
||||
<th>更新</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each sessions as s}
|
||||
<tr>
|
||||
<td class="font-mono text-xs">{s.provider} / {s.roleId}</td>
|
||||
<td class="font-mono text-xs">{s.model}</td>
|
||||
<td class="tabular-nums">{s.runCount}</td>
|
||||
<td class="text-surface-500">{fmtDate(s.updatedAt)}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,145 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { api, type ProviderConnection } from '$lib/api';
|
||||
import { fmtDate } from '$lib/format';
|
||||
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||
import LoadingState from '$lib/components/LoadingState.svelte';
|
||||
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
|
||||
import { toastError, toastSuccess } from '$lib/toast';
|
||||
|
||||
const slug = $derived(page.params.slug ?? '');
|
||||
|
||||
let conn = $state<ProviderConnection | null>(null);
|
||||
let loading = $state(true);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
let mode = $state<'BYOK' | 'PLATFORM_MANAGED'>('PLATFORM_MANAGED');
|
||||
let providerId = $state('openrouter');
|
||||
let baseUrl = $state('');
|
||||
let authToken = $state('');
|
||||
let saving = $state(false);
|
||||
|
||||
async function load() {
|
||||
loading = true;
|
||||
error = null;
|
||||
try {
|
||||
conn = await api.provider(slug);
|
||||
mode = conn.mode;
|
||||
providerId = conn.providerId;
|
||||
baseUrl = conn.baseUrl ?? '';
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : String(err);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function save() {
|
||||
saving = true;
|
||||
const body: { mode: string; providerId: string; baseUrl?: string; authToken?: string } = {
|
||||
mode,
|
||||
providerId: providerId.trim()
|
||||
};
|
||||
if (mode === 'BYOK') {
|
||||
if (baseUrl.trim()) body.baseUrl = baseUrl.trim();
|
||||
if (authToken !== '') body.authToken = authToken;
|
||||
}
|
||||
try {
|
||||
conn = await api.setProvider(slug, body);
|
||||
mode = conn.mode;
|
||||
providerId = conn.providerId;
|
||||
baseUrl = conn.baseUrl ?? '';
|
||||
authToken = '';
|
||||
toastSuccess('Provider 配置已保存');
|
||||
} catch (err) {
|
||||
toastError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (slug) load();
|
||||
});
|
||||
</script>
|
||||
|
||||
<PageHeader
|
||||
title="Provider 连接"
|
||||
description="ADR-0021:连接归属且仅归属本 org。BYOK 自带密钥;PLATFORM_MANAGED 平台托管。"
|
||||
/>
|
||||
|
||||
{#if loading}
|
||||
<LoadingState />
|
||||
{:else if error}
|
||||
<ErrorBanner message={error} onretry={load} />
|
||||
{:else if conn}
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="saas-label" for="provider-id">providerId</label>
|
||||
<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>
|
||||
<input
|
||||
id="base-url"
|
||||
class="saas-input"
|
||||
placeholder="https://openrouter.ai/api"
|
||||
bind:value={baseUrl}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="saas-label" for="auth-token">Auth Token</label>
|
||||
<input
|
||||
id="auth-token"
|
||||
class="saas-input"
|
||||
type="password"
|
||||
placeholder={conn.hasAuthToken ? '已设置(留空则不变)' : 'auth token'}
|
||||
bind:value={authToken}
|
||||
/>
|
||||
{#if conn.hasAuthToken}
|
||||
<p class="saas-help">已存储密钥;输入新值替换,留空不变。</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex items-center gap-3 border-t border-surface-100 pt-4">
|
||||
<span class="text-xs text-surface-400"
|
||||
>{conn.updatedAt ? '更新于 ' + fmtDate(conn.updatedAt) : '尚未配置'}</span
|
||||
>
|
||||
<div class="flex-1"></div>
|
||||
<button class="saas-btn-primary" onclick={save} disabled={saving}>
|
||||
{saving ? '保存中…' : '保存'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
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>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,106 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { api, type OrgRoleRow, type OrgModelRow } from '$lib/api';
|
||||
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 RoleCard from '$lib/components/RoleCard.svelte';
|
||||
import { toastError, toastSuccess } from '$lib/toast';
|
||||
|
||||
const slug = $derived(page.params.slug ?? '');
|
||||
|
||||
let roles = $state<OrgRoleRow[]>([]);
|
||||
let models = $state<OrgModelRow[]>([]);
|
||||
let loading = $state(true);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
let newRoleId = $state('');
|
||||
let newLabel = $state('');
|
||||
let adding = $state(false);
|
||||
|
||||
async function load() {
|
||||
loading = true;
|
||||
error = null;
|
||||
try {
|
||||
const [r, m] = await Promise.all([api.roles(slug), api.models(slug)]);
|
||||
roles = r.roles;
|
||||
models = m.models;
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : String(err);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function add() {
|
||||
if (!newRoleId.trim() || !newLabel.trim()) return;
|
||||
adding = true;
|
||||
try {
|
||||
const r = await api.createRole(slug, { roleId: newRoleId.trim(), label: newLabel.trim() });
|
||||
roles = [...roles, r];
|
||||
newRoleId = '';
|
||||
newLabel = '';
|
||||
toastSuccess('角色已创建');
|
||||
} catch (err) {
|
||||
toastError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
adding = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(r: OrgRoleRow) {
|
||||
if (!confirm(`删除角色 ${r.label} (${r.roleId})?`)) return;
|
||||
try {
|
||||
await api.deleteRole(slug, r.id);
|
||||
roles = roles.filter((x) => x.id !== r.id);
|
||||
toastSuccess('角色已删除');
|
||||
} catch (err) {
|
||||
toastError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (slug) load();
|
||||
});
|
||||
</script>
|
||||
|
||||
<PageHeader
|
||||
title="角色"
|
||||
description="ADR-0017:角色是数据。roleId 即斜杠命令(/draft…),绑定默认模型、工具白名单与系统提示词。"
|
||||
/>
|
||||
|
||||
{#if loading}
|
||||
<LoadingState />
|
||||
{:else if error}
|
||||
<ErrorBanner message={error} onretry={load} />
|
||||
{:else}
|
||||
<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} />
|
||||
<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)。" />
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-4">
|
||||
{#each roles as r (r.id)}
|
||||
<RoleCard
|
||||
{r}
|
||||
{models}
|
||||
{slug}
|
||||
onremoved={() => remove(r)}
|
||||
onupdated={(updated: OrgRoleRow) => {
|
||||
roles = roles.map((x) => (x.id === r.id ? updated : x));
|
||||
}}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
@@ -0,0 +1,215 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { api, type TeamRow, type TeamMemberRow } from '$lib/api';
|
||||
import { fmtDate } from '$lib/format';
|
||||
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 { toastError, toastSuccess } from '$lib/toast';
|
||||
|
||||
const slug = $derived(page.params.slug ?? '');
|
||||
|
||||
let teams = $state<TeamRow[]>([]);
|
||||
let loading = $state(true);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
let newSlug = $state('');
|
||||
let newName = $state('');
|
||||
let newDesc = $state('');
|
||||
let adding = $state(false);
|
||||
|
||||
let expandedId = $state<string | null>(null);
|
||||
let teamMembers = $state<TeamMemberRow[]>([]);
|
||||
let memberInput = $state('');
|
||||
let loadingMembers = $state(false);
|
||||
|
||||
async function load() {
|
||||
loading = true;
|
||||
error = null;
|
||||
try {
|
||||
const res = await api.teams(slug);
|
||||
teams = res.teams;
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : String(err);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function createTeam() {
|
||||
if (!newSlug.trim() || !newName.trim()) return;
|
||||
adding = true;
|
||||
try {
|
||||
await api.createTeam(slug, {
|
||||
slug: newSlug.trim(),
|
||||
name: newName.trim(),
|
||||
...(newDesc.trim() ? { description: newDesc.trim() } : {})
|
||||
});
|
||||
newSlug = '';
|
||||
newName = '';
|
||||
newDesc = '';
|
||||
await load();
|
||||
toastSuccess('团队已创建');
|
||||
} catch (err) {
|
||||
toastError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
adding = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function archiveTeam(t: TeamRow) {
|
||||
if (!confirm(`归档团队 ${t.name}? 其活跃项目授权将被撤销。`)) return;
|
||||
try {
|
||||
await api.archiveTeam(slug, t.id);
|
||||
await load();
|
||||
toastSuccess('团队已归档');
|
||||
} catch (err) {
|
||||
toastError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleMembers(t: TeamRow) {
|
||||
if (expandedId === t.id) {
|
||||
expandedId = null;
|
||||
return;
|
||||
}
|
||||
expandedId = t.id;
|
||||
loadingMembers = true;
|
||||
try {
|
||||
const res = await api.teamMembers(slug, t.id);
|
||||
teamMembers = res.members;
|
||||
} catch (err) {
|
||||
toastError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
loadingMembers = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function addMember(t: TeamRow) {
|
||||
if (!memberInput.trim()) return;
|
||||
const v = memberInput.trim();
|
||||
try {
|
||||
await api.addTeamMember(slug, t.id, v.startsWith('ou') ? { feishuOpenId: v } : { userId: v });
|
||||
memberInput = '';
|
||||
const res = await api.teamMembers(slug, t.id);
|
||||
teamMembers = res.members;
|
||||
await load();
|
||||
toastSuccess('已加入团队');
|
||||
} catch (err) {
|
||||
toastError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
|
||||
async function revokeMember(t: TeamRow, m: TeamMemberRow) {
|
||||
if (!confirm(`将 ${m.displayName} 移出团队 ${t.name}?`)) return;
|
||||
try {
|
||||
await api.revokeTeamMember(slug, t.id, m.userId);
|
||||
const res = await api.teamMembers(slug, t.id);
|
||||
teamMembers = res.members;
|
||||
await load();
|
||||
toastSuccess('已移出团队');
|
||||
} catch (err) {
|
||||
toastError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (slug) load();
|
||||
});
|
||||
</script>
|
||||
|
||||
<PageHeader title="团队" description="团队是项目授权的 principal,可被授予 READ / EDIT / MANAGE。" />
|
||||
|
||||
{#if loading}
|
||||
<LoadingState />
|
||||
{:else if error}
|
||||
<ErrorBanner message={error} onretry={load} />
|
||||
{:else}
|
||||
<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={newName} />
|
||||
<input class="saas-input" placeholder="描述(可选)" bind:value={newDesc} />
|
||||
<button class="saas-btn-primary" onclick={createTeam} disabled={adding}>新建</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if teams.length === 0}
|
||||
<div class="saas-card">
|
||||
<EmptyState title="暂无团队" description="创建团队后,可在项目页授权项目访问。" />
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-3">
|
||||
{#each teams as t (t.id)}
|
||||
<div class="saas-card p-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="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)}>
|
||||
{expandedId === t.id ? '收起' : '管理成员'}
|
||||
</button>
|
||||
<button class="saas-btn-danger !py-1.5 text-sm" onclick={() => archiveTeam(t)}>归档</button>
|
||||
</div>
|
||||
</div>
|
||||
{#if t.description}
|
||||
<p class="mt-1.5 text-sm text-surface-500">{t.description}</p>
|
||||
{/if}
|
||||
<p class="mt-1 text-xs text-surface-400">创建于 {fmtDate(t.createdAt)}</p>
|
||||
|
||||
{#if expandedId === t.id}
|
||||
<div class="mt-4 border-t border-surface-200 pt-4">
|
||||
{#if loadingMembers}
|
||||
<p class="text-sm text-surface-400">加载中…</p>
|
||||
{:else}
|
||||
<div class="mb-4 flex gap-2">
|
||||
<input
|
||||
class="saas-input"
|
||||
placeholder="Feishu open_id 或用户 id"
|
||||
bind:value={memberInput}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Enter') addMember(t);
|
||||
}}
|
||||
/>
|
||||
<button class="saas-btn-secondary shrink-0" onclick={() => addMember(t)}>加入</button>
|
||||
</div>
|
||||
{#if teamMembers.length === 0}
|
||||
<p class="py-3 text-center text-sm text-surface-400">团队暂无成员</p>
|
||||
{:else}
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>成员</th>
|
||||
<th>open_id</th>
|
||||
<th>加入时间</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each teamMembers as m}
|
||||
<tr>
|
||||
<td class="font-medium">{m.displayName}</td>
|
||||
<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
|
||||
>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
<p class="mt-4 text-xs text-surface-400">归档团队会同步撤销其活跃的项目授权。</p>
|
||||
{/if}
|
||||
@@ -0,0 +1,431 @@
|
||||
@import 'tailwindcss';
|
||||
|
||||
@import '@skeletonlabs/skeleton';
|
||||
@import '@skeletonlabs/skeleton/themes/hamlindigo';
|
||||
|
||||
@source './**/*.{html,js,svelte,ts}';
|
||||
@source '../lib/**/*.{html,js,svelte,ts}';
|
||||
|
||||
@theme {
|
||||
--font-sans: 'Inter', 'Noto Sans SC', ui-sans-serif, system-ui, -apple-system, 'Segoe UI', sans-serif;
|
||||
--font-mono: 'JetBrains Mono', ui-monospace, 'SF Mono', Menlo, Consolas, monospace;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
html {
|
||||
height: 100%;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
|
||||
body {
|
||||
min-height: 100%;
|
||||
font-family: var(--font-sans);
|
||||
background: var(--color-surface-50);
|
||||
color: var(--color-surface-900);
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: color-mix(in oklab, var(--color-primary-500) 28%, transparent);
|
||||
}
|
||||
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--color-primary-500);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
table.data-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
table.data-table thead th {
|
||||
padding: 0.625rem 0.75rem;
|
||||
text-align: left;
|
||||
font-weight: 500;
|
||||
color: var(--color-surface-500);
|
||||
border-bottom: 1px solid var(--color-surface-200);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
table.data-table tbody td {
|
||||
padding: 0.75rem;
|
||||
border-bottom: 1px solid var(--color-surface-100);
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
table.data-table tbody tr:hover td {
|
||||
background: color-mix(in oklab, var(--color-surface-100) 70%, transparent);
|
||||
}
|
||||
|
||||
table.data-table tbody tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.saas-card {
|
||||
border-radius: 0.75rem;
|
||||
border: 1px solid var(--color-surface-200);
|
||||
background: var(--color-surface-50);
|
||||
box-shadow: 0 1px 2px rgb(15 23 42 / 0.04);
|
||||
}
|
||||
|
||||
.saas-card-pad {
|
||||
border-radius: 0.75rem;
|
||||
border: 1px solid var(--color-surface-200);
|
||||
background: var(--color-surface-50);
|
||||
box-shadow: 0 1px 2px rgb(15 23 42 / 0.04);
|
||||
padding: 1.25rem;
|
||||
}
|
||||
|
||||
.saas-page-title {
|
||||
font-size: 1.5rem;
|
||||
line-height: 2rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.02em;
|
||||
color: var(--color-surface-900);
|
||||
}
|
||||
|
||||
.saas-section-title {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-surface-900);
|
||||
}
|
||||
|
||||
.saas-muted {
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-surface-500);
|
||||
}
|
||||
|
||||
.saas-label {
|
||||
display: block;
|
||||
margin-bottom: 0.375rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-surface-700);
|
||||
}
|
||||
|
||||
.saas-help {
|
||||
margin-top: 0.375rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-surface-400);
|
||||
}
|
||||
|
||||
.saas-toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.saas-stat {
|
||||
border-radius: 0.75rem;
|
||||
border: 1px solid var(--color-surface-200);
|
||||
background: var(--color-surface-50);
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.saas-stat-label {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-surface-500);
|
||||
}
|
||||
|
||||
.saas-stat-value {
|
||||
margin-top: 0.25rem;
|
||||
font-size: 1.5rem;
|
||||
line-height: 2rem;
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
letter-spacing: -0.02em;
|
||||
color: var(--color-surface-900);
|
||||
}
|
||||
|
||||
.saas-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
padding: 3rem 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.saas-shell {
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
background: var(--color-surface-100);
|
||||
}
|
||||
|
||||
.saas-sidebar {
|
||||
display: flex;
|
||||
width: 16rem;
|
||||
flex-shrink: 0;
|
||||
flex-direction: column;
|
||||
border-right: 1px solid var(--color-surface-200);
|
||||
background: var(--color-surface-50);
|
||||
}
|
||||
|
||||
.saas-main {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.saas-topbar {
|
||||
display: flex;
|
||||
height: 3.5rem;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
border-bottom: 1px solid var(--color-surface-200);
|
||||
background: color-mix(in oklab, var(--color-surface-50) 90%, transparent);
|
||||
padding: 0 1.5rem;
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.saas-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.saas-content-inner {
|
||||
margin-inline: auto;
|
||||
width: 100%;
|
||||
max-width: 72rem;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.saas-content-inner {
|
||||
padding: 2rem;
|
||||
}
|
||||
}
|
||||
|
||||
.saas-nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-surface-600);
|
||||
transition: color 0.15s, background-color 0.15s;
|
||||
}
|
||||
|
||||
.saas-nav-item:hover {
|
||||
background: var(--color-surface-100);
|
||||
color: var(--color-surface-900);
|
||||
}
|
||||
|
||||
.saas-nav-item[data-active='true'] {
|
||||
background: var(--color-primary-100);
|
||||
color: var(--color-primary-700);
|
||||
}
|
||||
|
||||
.saas-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
border-radius: 0.375rem;
|
||||
padding: 0.125rem 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.saas-badge-neutral {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
border-radius: 0.375rem;
|
||||
padding: 0.125rem 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
background: var(--color-surface-200);
|
||||
color: var(--color-surface-700);
|
||||
}
|
||||
|
||||
.saas-badge-primary {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
border-radius: 0.375rem;
|
||||
padding: 0.125rem 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
background: var(--color-primary-100);
|
||||
color: var(--color-primary-700);
|
||||
}
|
||||
|
||||
.saas-badge-success {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
border-radius: 0.375rem;
|
||||
padding: 0.125rem 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
background: var(--color-success-100);
|
||||
color: var(--color-success-700);
|
||||
}
|
||||
|
||||
.saas-badge-warning {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
border-radius: 0.375rem;
|
||||
padding: 0.125rem 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
background: var(--color-warning-100);
|
||||
color: var(--color-warning-800);
|
||||
}
|
||||
|
||||
.saas-badge-error {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
border-radius: 0.375rem;
|
||||
padding: 0.125rem 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
background: var(--color-error-100);
|
||||
color: var(--color-error-700);
|
||||
}
|
||||
|
||||
.saas-input,
|
||||
.saas-select,
|
||||
.saas-textarea {
|
||||
width: 100%;
|
||||
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;
|
||||
}
|
||||
|
||||
.saas-input:focus,
|
||||
.saas-select:focus,
|
||||
.saas-textarea:focus {
|
||||
border-color: var(--color-primary-400);
|
||||
box-shadow: 0 0 0 3px color-mix(in oklab, var(--color-primary-500) 20%, transparent);
|
||||
}
|
||||
|
||||
.saas-textarea {
|
||||
font-family: var(--font-mono);
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.saas-btn-primary,
|
||||
.saas-btn-secondary,
|
||||
.saas-btn-ghost,
|
||||
.saas-btn-danger {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.375rem;
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.5rem 0.875rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
line-height: 1.25rem;
|
||||
border: 1px solid transparent;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s, color 0.15s, border-color 0.15s, opacity 0.15s;
|
||||
}
|
||||
|
||||
.saas-btn-primary:disabled,
|
||||
.saas-btn-secondary:disabled,
|
||||
.saas-btn-ghost:disabled,
|
||||
.saas-btn-danger:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.saas-btn-primary {
|
||||
background: var(--color-primary-500);
|
||||
color: var(--color-primary-contrast-500, white);
|
||||
}
|
||||
|
||||
.saas-btn-primary:hover:not(:disabled) {
|
||||
background: var(--color-primary-600);
|
||||
}
|
||||
|
||||
.saas-btn-secondary {
|
||||
background: var(--color-secondary-100);
|
||||
color: var(--color-secondary-800);
|
||||
}
|
||||
|
||||
.saas-btn-secondary:hover:not(:disabled) {
|
||||
background: var(--color-secondary-200);
|
||||
}
|
||||
|
||||
.saas-btn-ghost {
|
||||
background: var(--color-surface-100);
|
||||
color: var(--color-surface-700);
|
||||
}
|
||||
|
||||
.saas-btn-ghost:hover:not(:disabled) {
|
||||
background: var(--color-surface-200);
|
||||
}
|
||||
|
||||
.saas-btn-danger {
|
||||
background: var(--color-error-100);
|
||||
color: var(--color-error-700);
|
||||
}
|
||||
|
||||
.saas-btn-danger:hover:not(:disabled) {
|
||||
background: var(--color-error-200);
|
||||
}
|
||||
|
||||
.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%;
|
||||
max-width: 28rem;
|
||||
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);
|
||||
}
|
||||
|
||||
.saas-status-panel {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--color-surface-100);
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.saas-status-card {
|
||||
width: 100%;
|
||||
max-width: 28rem;
|
||||
border-radius: 1rem;
|
||||
border: 1px solid var(--color-surface-200);
|
||||
background: var(--color-surface-50);
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
box-shadow: 0 1px 2px rgb(15 23 42 / 0.04);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none">
|
||||
<rect width="32" height="32" rx="8" fill="#4F46E5"/>
|
||||
<path d="M8 10.5h7.5a3.5 3.5 0 0 1 0 7H11v4H8v-11Zm3 4.5h4.5a1.5 1.5 0 0 0 0-3H11v3Z" fill="white"/>
|
||||
<path d="M20.5 21.5c1.93 0 3.5-1.34 3.5-3s-1.57-3-3.5-3S17 16.84 17 18.5s1.57 3 3.5 3Z" fill="white" opacity=".9"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 353 B |
@@ -0,0 +1,3 @@
|
||||
# allow crawling everything by default
|
||||
User-agent: *
|
||||
Disallow:
|
||||
@@ -0,0 +1,18 @@
|
||||
import adapter from '@sveltejs/adapter-static';
|
||||
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
|
||||
|
||||
/** @type {import('@sveltejs/kit').Config} */
|
||||
const config = {
|
||||
preprocess: vitePreprocess(),
|
||||
kit: {
|
||||
adapter: adapter({
|
||||
pages: 'build',
|
||||
assets: 'build',
|
||||
fallback: 'index.html',
|
||||
precompress: false,
|
||||
strict: false
|
||||
})
|
||||
}
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"extends": "./.svelte-kit/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rewriteRelativeImportExtensions": true,
|
||||
"allowJs": true,
|
||||
"checkJs": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"skipLibCheck": true,
|
||||
"sourceMap": true,
|
||||
"strict": true,
|
||||
"moduleResolution": "bundler"
|
||||
}
|
||||
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
|
||||
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
|
||||
//
|
||||
// To make changes to top-level options such as include and exclude, we recommend extending
|
||||
// the generated config; see https://svelte.dev/docs/kit/configuration#typescript
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { sveltekit } from '@sveltejs/kit/vite';
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [tailwindcss(), sveltekit()],
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': 'http://127.0.0.1:8788',
|
||||
'/auth': 'http://127.0.0.1:8788'
|
||||
}
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user