Files
openmaic/OpenMAIC/app/publish/page.tsx
2026-08-16 14:58:47 +08:00

140 lines
5.2 KiB
TypeScript

'use client';
// Ops publish management (版本管理) — lists published frozen coursewares from
// the registry (L2) with their versions and bundle URLs. This is the ops
// workbench's "manage published courseware" view; the registry API is public
// read-only for the learner player, so this page needs no token to load.
import { useCallback, useEffect, useState } from 'react';
import { Copy, RefreshCw, Rocket } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { useI18n } from '@/lib/hooks/use-i18n';
import { useRouter } from 'next/navigation';
export interface PublishedItem {
coursewareId: string;
version: number;
title: string;
language?: string;
status: string;
publishedAt: string;
contentHash: string;
sceneCount: number;
quizSceneCount: number;
bundleUrl: string;
}
export default function PublishManagementPage() {
const { t } = useI18n();
const router = useRouter();
const [items, setItems] = useState<PublishedItem[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [copiedId, setCopiedId] = useState<string | null>(null);
const load = useCallback(async () => {
setLoading(true);
setError(null);
try {
const response = await fetch('/api/coursewares');
const body = (await response.json()) as { items?: PublishedItem[] };
if (!response.ok || !Array.isArray(body.items)) {
throw new Error(`HTTP ${response.status}`);
}
setItems(body.items);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void load();
}, [load]);
const copyUrl = async (item: PublishedItem) => {
await navigator.clipboard.writeText(item.bundleUrl);
setCopiedId(`${item.coursewareId}@${item.version}`);
setTimeout(() => setCopiedId(null), 1500);
};
const formatDate = (iso: string) =>
new Date(iso).toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' });
return (
<main className="min-h-screen bg-zinc-50 dark:bg-zinc-950">
<div className="mx-auto max-w-4xl px-6 py-10">
<header className="flex items-center justify-between mb-6">
<div>
<h1 className="flex items-center gap-2 text-xl font-semibold">
<Rocket className="w-5 h-5 text-violet-500" />
{t('publish.manageTitle')}
</h1>
<p className="text-sm text-muted-foreground mt-1">{t('publish.manageDesc')}</p>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={() => router.push('/learn')}>
{t('learner.openCatalog')}
</Button>
<Button variant="outline" size="sm" onClick={() => router.push('/')}>
{t('publish.backToApp')}
</Button>
<Button variant="outline" size="sm" onClick={load} disabled={loading}>
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
{t('publish.refresh')}
</Button>
</div>
</header>
{loading && <p className="text-sm text-muted-foreground">{t('publish.loading')}</p>}
{error && (
<div className="rounded-lg border border-red-200 dark:border-red-900/50 bg-red-50 dark:bg-red-950/40 p-4 text-sm text-red-800 dark:text-red-200">
{t('publish.loadFailed')}: {error}
</div>
)}
{!loading && !error && items.length === 0 && (
<p className="text-sm text-muted-foreground">{t('publish.empty')}</p>
)}
<div className="space-y-3">
{items.map((item) => (
<div
key={`${item.coursewareId}@${item.version}`}
className="rounded-xl border border-border/60 bg-white dark:bg-zinc-900 p-4"
>
<div className="flex items-start justify-between gap-4">
<div className="min-w-0">
<div className="font-medium truncate">{item.title}</div>
<div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground">
<span className="font-mono">{item.coursewareId}</span>
<span className="font-mono">v{item.version}</span>
<span>{formatDate(item.publishedAt)}</span>
<span>
{t('publish.sceneCount', { count: item.sceneCount })}
</span>
<span className="font-mono text-[10px] text-muted-foreground/70">
{item.contentHash.slice(0, 12)}…
</span>
</div>
</div>
<Button variant="outline" size="sm" onClick={() => copyUrl(item)}>
<Copy className="w-3.5 h-3.5" />
{copiedId === `${item.coursewareId}@${item.version}`
? t('publish.copied')
: t('publish.copyUrl')}
</Button>
</div>
<div className="mt-2 text-[11px] text-muted-foreground truncate">
{item.bundleUrl}
</div>
</div>
))}
</div>
</div>
</main>
);
}