Files
curriculum-project-hub/hub/admin-web/src/routes/admin/feishu/+page.svelte
T
hongjr03 7f09fb1f13 feat(hub): drop redundant /admin/org/:slug path + release v0.0.35 (#11)
Silo hostname already carries tenancy. Admin SPA routes become /admin/..., legacy bookmarks redirect, login lands on /admin.

Co-authored-by: Hong Jiarong <me@jrhim.com>
Co-committed-by: Hong Jiarong <me@jrhim.com>
2026-07-19 01:36:10 +08:00

180 lines
5.9 KiB
Svelte

<script lang="ts">
import { page } from '$app/state';
import { api, type FeishuApplicationConnection } from '$lib/api';
import { session } from '$lib/session';
import { resolveOrg } from '$lib/org';
import { fmtDate } from '$lib/format';
import { Label } from 'bits-ui';
import PageHeader from '$lib/components/PageHeader.svelte';
import LoadingState from '$lib/components/LoadingState.svelte';
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
import { toastError, toastSuccess } from '$lib/toast';
const org = $derived(resolveOrg($session.me, page.url.search));
const slug = $derived(org?.slug ?? '');
let connection = $state<FeishuApplicationConnection | null>(null);
let loading = $state(true);
let error = $state<string | null>(null);
let appId = $state('');
let appSecret = $state('');
let botOpenId = $state('');
let verificationToken = $state('');
let encryptKey = $state('');
let saving = $state(false);
let disabling = $state(false);
async function load() {
loading = true;
error = null;
try {
const res = await api.feishuApplication(slug);
connection = res.connection;
} catch (err) {
error = err instanceof Error ? err.message : String(err);
} finally {
loading = false;
}
}
function resetForm() {
appId = '';
appSecret = '';
botOpenId = '';
verificationToken = '';
encryptKey = '';
}
async function save() {
const id = appId.trim();
const secret = appSecret.trim();
const bot = botOpenId.trim();
if (id === '' || secret === '' || bot === '') {
toastError('App ID、App Secret、Bot Open ID 均为必填');
return;
}
saving = true;
const body: {
appId: string;
appSecret: string;
botOpenId: string;
verificationToken?: string;
encryptKey?: string;
} = { appId: id, appSecret: secret, botOpenId: bot };
const vt = verificationToken.trim();
if (vt !== '') body.verificationToken = vt;
const ek = encryptKey.trim();
if (ek !== '') body.encryptKey = ek;
try {
connection = await api.rotateFeishuApplication(slug, body);
resetForm();
toastSuccess('飞书应用凭据已保存');
} catch (err) {
toastError(err instanceof Error ? err.message : String(err));
} finally {
saving = false;
}
}
async function disable() {
if (!connection) return;
if (!confirm('停用后该组织将无法收发飞书消息,确定停用?')) return;
disabling = true;
try {
connection = await api.disableFeishuApplication(slug);
toastSuccess('已停用飞书应用连接');
} catch (err) {
toastError(err instanceof Error ? err.message : String(err));
} finally {
disabling = false;
}
}
$effect(() => {
if (slug) load();
});
</script>
<PageHeader
title="飞书应用"
description="本组织绑定的飞书应用凭据(ADR-0021:组织与应用 1:1)。凭据按组织隔离、版本化信封存储,缺失或校验失败即 fail-closed。"
/>
{#if loading}
<LoadingState />
{:else if error}
<ErrorBanner message={error} onretry={load} />
{:else}
{#if connection}
<div class="saas-card-pad mb-6">
<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-700">状态</dt>
<dd><span class="saas-badge-success">{connection.status}</span></dd>
</div>
<div class="flex items-center justify-between gap-3">
<dt class="text-surface-700">App 指纹</dt>
<dd class="font-mono text-xs text-surface-800">{connection.appFingerprint}</dd>
</div>
<div class="flex items-center justify-between gap-3">
<dt class="text-surface-700">版本</dt>
<dd class="tabular-nums">{connection.activeVersion ?? '—'}</dd>
</div>
<div class="flex items-center justify-between gap-3">
<dt class="text-surface-700">更新于</dt>
<dd class="text-surface-600">{fmtDate(connection.updatedAt)}</dd>
</div>
</dl>
{#if connection.status !== 'DISABLED'}
<div class="mt-5 flex items-center justify-end gap-3 border-t border-surface-100 pt-4">
<button class="saas-btn-ghost" onclick={disable} disabled={disabling}>
{disabling ? '停用中…' : '停用连接'}
</button>
</div>
{/if}
</div>
{:else}
<div class="saas-card-pad mb-6">
<p class="text-sm text-surface-700">本组织尚未绑定飞书应用。填写下方凭据以创建连接。</p>
</div>
{/if}
<div class="saas-card-pad">
<h3 class="saas-section-title mb-1">{connection ? '轮换凭据' : '创建连接'}</h3>
<p class="saas-muted mb-4">
密钥仅写入新版本,旧版本归档。{#if connection}App ID 不可变更,须与现有应用一致。{/if}
</p>
<div class="grid gap-5">
<div>
<Label.Root class="saas-label" for="app-id">App ID</Label.Root>
<input id="app-id" class="saas-input font-mono text-sm" bind:value={appId} />
</div>
<div>
<Label.Root class="saas-label" for="app-secret">App Secret</Label.Root>
<input id="app-secret" class="saas-input" type="password" bind:value={appSecret} />
</div>
<div>
<Label.Root class="saas-label" for="bot-open-id">Bot Open ID</Label.Root>
<input id="bot-open-id" class="saas-input font-mono text-sm" bind:value={botOpenId} />
</div>
<div>
<Label.Root class="saas-label" for="verification-token">Verification Token(可选)</Label.Root>
<input id="verification-token" class="saas-input" type="password" bind:value={verificationToken} />
</div>
<div>
<Label.Root class="saas-label" for="encrypt-key">Encrypt Key(可选)</Label.Root>
<input id="encrypt-key" class="saas-input" type="password" bind:value={encryptKey} />
</div>
</div>
<div class="mt-6 flex items-center gap-3 border-t border-surface-100 pt-4">
<div class="flex-1"></div>
<button class="saas-btn-ghost" onclick={resetForm} disabled={saving}>清空</button>
<button class="saas-btn-primary" onclick={save} disabled={saving}>
{saving ? '保存中…' : '保存'}
</button>
</div>
</div>
{/if}