140 lines
5.2 KiB
TypeScript
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>
|
|
);
|
|
}
|