Files
soul/app/documentation/page.tsx
2026-01-09 11:58:08 +08:00

307 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client"
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import { getDocumentationCatalog, type DocumentationPage } from "@/lib/documentation/catalog"
import { FileText, Download, Loader2, CheckCircle, XCircle, Eye, RefreshCw } from "lucide-react"
type PageStatus = "pending" | "loading" | "success" | "error"
type PageState = {
page: DocumentationPage
status: PageStatus
error?: string
}
export default function DocumentationToolPage() {
const [isGenerating, setIsGenerating] = useState(false)
const [error, setError] = useState<string | null>(null)
const [progress, setProgress] = useState(0)
const [currentPage, setCurrentPage] = useState<string | null>(null)
const [pageStates, setPageStates] = useState<PageState[]>([])
const [previewPath, setPreviewPath] = useState<string | null>(null)
const [showPreview, setShowPreview] = useState(false)
const iframeRef = useRef<HTMLIFrameElement>(null)
const pages = useMemo(() => getDocumentationCatalog(), [])
const groupedPages = useMemo(() => {
const groups: Record<string, DocumentationPage[]> = {}
for (const page of pages) {
if (!groups[page.group]) groups[page.group] = []
groups[page.group].push(page)
}
return groups
}, [pages])
useEffect(() => {
setPageStates(pages.map((page) => ({ page, status: "pending" })))
}, [pages])
const handleGenerate = async () => {
setError(null)
setIsGenerating(true)
setProgress(0)
setCurrentPage(null)
setPageStates(pages.map((page) => ({ page, status: "loading" })))
try {
// Simulate progress while waiting for the API
let progressValue = 0
const progressInterval = setInterval(() => {
progressValue += 2
const pageIndex = Math.floor((progressValue / 100) * pages.length)
const nextPage = pages[Math.min(pageIndex, pages.length - 1)]
if (nextPage) setCurrentPage(nextPage.title)
setProgress(Math.min(progressValue, 90))
// Update page states to show progress
setPageStates((prev) =>
prev.map((s, idx) => ({
...s,
status: idx < pageIndex ? "success" : idx === pageIndex ? "loading" : "pending",
})),
)
}, 800)
// Get token from URL if provided
const urlParams = new URLSearchParams(window.location.search)
const token = urlParams.get("token") || ""
const response = await fetch(`/api/documentation/generate${token ? `?token=${token}` : ""}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
...(token ? { "x-documentation-token": token } : {}),
},
})
clearInterval(progressInterval)
if (!response.ok) {
const text = await response.text().catch(() => "")
let errorMessage = `生成失败(${response.status})`
try {
const json = JSON.parse(text)
errorMessage = json.error || errorMessage
} catch {
if (text) errorMessage = text
}
throw new Error(errorMessage)
}
setProgress(100)
setCurrentPage("完成")
setPageStates(pages.map((page) => ({ page, status: "success" })))
const blob = await response.blob()
const url = URL.createObjectURL(blob)
const a = document.createElement("a")
a.href = url
a.download = `应用功能文档_${new Date().toISOString().slice(0, 10)}.docx`
document.body.appendChild(a)
a.click()
a.remove()
URL.revokeObjectURL(url)
} catch (e) {
const message = e instanceof Error ? e.message : String(e)
setError(message)
setPageStates((prev) => prev.map((s) => ({ ...s, status: "error" })))
} finally {
setIsGenerating(false)
}
}
const handlePreview = useCallback((path: string) => {
setPreviewPath(path)
setShowPreview(true)
}, [])
const getStatusIcon = (status: PageStatus) => {
switch (status) {
case "pending":
return <div className="w-4 h-4 rounded-full bg-gray-600" />
case "loading":
return <Loader2 className="w-4 h-4 animate-spin text-teal-400" />
case "success":
return <CheckCircle className="w-4 h-4 text-green-500" />
case "error":
return <XCircle className="w-4 h-4 text-red-500" />
}
}
return (
<main className="min-h-screen bg-background text-foreground p-4 pb-24">
<div className="max-w-md mx-auto space-y-4">
{/* Header */}
<div className="flex items-center gap-3 py-2">
<FileText className="w-6 h-6 text-teal-400" />
<div>
<h1 className="text-lg font-semibold"></h1>
<p className="text-xs text-muted-foreground"></p>
</div>
</div>
{/* Info Card */}
<div className="bg-card border border-border rounded-xl p-4 space-y-2">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground"></span>
<span className="font-medium text-teal-400">{pages.length} </span>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground"></span>
<span className="font-medium">{Object.keys(groupedPages).length} </span>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground"></span>
<span className="font-medium">Word (.docx)</span>
</div>
</div>
{/* Progress */}
{isGenerating && (
<div className="bg-card border border-border rounded-xl p-4 space-y-3">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground"></span>
<span className="font-medium text-teal-400">{Math.round(progress)}%</span>
</div>
<div className="w-full h-2 bg-muted rounded-full overflow-hidden">
<div
className="h-full bg-gradient-to-r from-teal-500 to-cyan-400 transition-all duration-300"
style={{ width: `${progress}%` }}
/>
</div>
{currentPage && <p className="text-xs text-muted-foreground truncate">: {currentPage}</p>}
</div>
)}
{/* Error */}
{error && (
<div className="bg-destructive/10 border border-destructive/30 text-destructive rounded-xl p-3 text-sm">
<p className="font-medium mb-1"></p>
<p className="text-xs opacity-80">{error}</p>
<p className="text-xs mt-2 opacity-60">提示: 如需授权,URL中添加 ?token=your_token</p>
</div>
)}
{/* Generate Button */}
<button
type="button"
onClick={handleGenerate}
disabled={isGenerating}
className="w-full bg-gradient-to-r from-teal-500 to-cyan-500 text-white rounded-xl py-3.5 font-medium disabled:opacity-60 flex items-center justify-center gap-2 shadow-lg shadow-teal-500/20"
>
{isGenerating ? (
<>
<Loader2 className="w-5 h-5 animate-spin" />
<span>...</span>
</>
) : (
<>
<Download className="w-5 h-5" />
<span> Word </span>
</>
)}
</button>
{/* Page List */}
<div className="space-y-3">
<h2 className="text-sm font-medium text-muted-foreground flex items-center gap-2">
<span></span>
<span className="text-xs opacity-60">({pages.length})</span>
</h2>
{Object.entries(groupedPages).map(([group, groupPages]) => (
<div key={group} className="bg-card border border-border rounded-xl overflow-hidden">
<div className="px-3 py-2 bg-muted/50 border-b border-border">
<h3 className="text-sm font-medium text-teal-400">{group}</h3>
</div>
<div className="divide-y divide-border">
{groupPages.map((page, index) => {
const state = pageStates.find((s) => s.page.path === page.path)
return (
<div
key={page.path}
className="px-3 py-2.5 flex items-center gap-3 hover:bg-muted/30 transition-colors"
>
<span className="text-xs text-muted-foreground w-5">{index + 1}</span>
{state && getStatusIcon(state.status)}
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{page.title}</p>
{page.subtitle && <p className="text-xs text-muted-foreground truncate">{page.subtitle}</p>}
</div>
<button
type="button"
onClick={() => handlePreview(page.path)}
className="p-1.5 text-muted-foreground hover:text-foreground hover:bg-muted rounded-lg transition-colors"
title="预览页面"
>
<Eye className="w-4 h-4" />
</button>
</div>
)
})}
</div>
</div>
))}
</div>
{/* Features */}
<div className="bg-card border border-border rounded-xl p-4 space-y-3">
<h3 className="text-sm font-medium"></h3>
<ul className="text-xs text-muted-foreground space-y-2">
<li className="flex items-start gap-2">
<CheckCircle className="w-4 h-4 text-green-500 flex-shrink-0 mt-0.5" />
<span></span>
</li>
<li className="flex items-start gap-2">
<CheckCircle className="w-4 h-4 text-green-500 flex-shrink-0 mt-0.5" />
<span>(iPhone 14 Pro Max尺寸)</span>
</li>
<li className="flex items-start gap-2">
<CheckCircle className="w-4 h-4 text-green-500 flex-shrink-0 mt-0.5" />
<span></span>
</li>
<li className="flex items-start gap-2">
<CheckCircle className="w-4 h-4 text-green-500 flex-shrink-0 mt-0.5" />
<span></span>
</li>
</ul>
</div>
{/* Note */}
<p className="text-xs text-muted-foreground text-center">30-60</p>
</div>
{/* Preview Modal */}
{showPreview && previewPath && (
<div className="fixed inset-0 bg-black/80 z-50 flex items-center justify-center p-4">
<div className="bg-card rounded-2xl w-full max-w-md overflow-hidden border border-border">
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
<h3 className="font-medium text-sm"></h3>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => iframeRef.current?.contentWindow?.location.reload()}
className="p-1.5 text-muted-foreground hover:text-foreground rounded-lg"
>
<RefreshCw className="w-4 h-4" />
</button>
<button
type="button"
onClick={() => setShowPreview(false)}
className="p-1.5 text-muted-foreground hover:text-foreground rounded-lg"
>
<XCircle className="w-4 h-4" />
</button>
</div>
</div>
<div className="w-full aspect-[430/932] bg-white">
<iframe ref={iframeRef} src={previewPath} className="w-full h-full" title="Page Preview" />
</div>
</div>
</div>
)}
</main>
)
}