fix: resolve build errors and complete missing files
Fix CSS issues, add missing files, and optimize documentation page. Co-authored-by: null <4804959+fnvtk@users.noreply.github.com>
This commit is contained in:
@@ -5,7 +5,6 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com
|
|||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Textarea } from "@/components/ui/textarea"
|
import { Textarea } from "@/components/ui/textarea"
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
|
||||||
import { Checkbox } from "@/components/ui/checkbox"
|
import { Checkbox } from "@/components/ui/checkbox"
|
||||||
import { Progress } from "@/components/ui/progress"
|
import { Progress } from "@/components/ui/progress"
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
@@ -30,7 +29,10 @@ import {
|
|||||||
Clock,
|
Clock,
|
||||||
Zap,
|
Zap,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { enhancedScreenshotService, type ScreenshotOptions } from "@/lib/documentation/enhanced-screenshot-service"
|
import {
|
||||||
|
enhancedScreenshotService,
|
||||||
|
type EnhancedScreenshotOptions,
|
||||||
|
} from "@/lib/documentation/enhanced-screenshot-service"
|
||||||
import { generateDocx } from "@/lib/documentation/docx-generator"
|
import { generateDocx } from "@/lib/documentation/docx-generator"
|
||||||
import { pageRegistry } from "@/lib/documentation/page-registry"
|
import { pageRegistry } from "@/lib/documentation/page-registry"
|
||||||
|
|
||||||
@@ -79,18 +81,20 @@ export default function DocumentationPage() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const previewRef = useRef<HTMLIFrameElement>(null)
|
const previewRef = useRef<HTMLIFrameElement>(null)
|
||||||
const [screenshotOptions, setScreenshotOptions] = useState<ScreenshotOptions>({
|
const [screenshotOptions, setScreenshotOptions] = useState<EnhancedScreenshotOptions>({
|
||||||
format: "png",
|
format: "png",
|
||||||
quality: 0.9,
|
quality: 0.9,
|
||||||
scale: 1,
|
scale: 1,
|
||||||
backgroundColor: "#ffffff",
|
backgroundColor: "#ffffff",
|
||||||
timeout: 30000,
|
timeout: 30000,
|
||||||
|
waitForImages: true,
|
||||||
|
waitForFonts: true,
|
||||||
|
addWatermark: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
const pages = pageRegistry.getAllPages()
|
const pages = pageRegistry.getAllPages()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// 初始化选中所有页面
|
|
||||||
setSelectedPages(pages.map((p) => p.path))
|
setSelectedPages(pages.map((p) => p.path))
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
@@ -131,80 +135,13 @@ export default function DocumentationPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 方法1: 尝试直接截图当前页面(如果是当前页面)
|
const result = await enhancedScreenshotService.captureViewport({
|
||||||
if (window.location.pathname === page.path) {
|
...screenshotOptions,
|
||||||
const result = await enhancedScreenshotService.captureViewport({
|
...getViewportSize(),
|
||||||
...screenshotOptions,
|
watermarkText: `${page.name} - ${new Date().toLocaleString()}`,
|
||||||
...getViewportSize(),
|
|
||||||
})
|
|
||||||
|
|
||||||
if (result.success && result.dataUrl) {
|
|
||||||
return {
|
|
||||||
...screenshot,
|
|
||||||
status: "success",
|
|
||||||
dataUrl: result.dataUrl,
|
|
||||||
method: result.method,
|
|
||||||
size: getViewportSize(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 方法2: 使用新窗口截图
|
|
||||||
const newWindow = window.open(
|
|
||||||
page.path,
|
|
||||||
"_blank",
|
|
||||||
`width=${getViewportSize().width},height=${getViewportSize().height}`,
|
|
||||||
)
|
|
||||||
|
|
||||||
if (!newWindow) {
|
|
||||||
throw new Error("无法打开新窗口,请检查浏览器弹窗设置")
|
|
||||||
}
|
|
||||||
|
|
||||||
// 等待页面加载
|
|
||||||
await new Promise((resolve, reject) => {
|
|
||||||
const timeout = setTimeout(() => {
|
|
||||||
newWindow.close()
|
|
||||||
reject(new Error("页面加载超时"))
|
|
||||||
}, 15000)
|
|
||||||
|
|
||||||
const checkLoaded = () => {
|
|
||||||
try {
|
|
||||||
if (newWindow.document && newWindow.document.readyState === "complete") {
|
|
||||||
clearTimeout(timeout)
|
|
||||||
resolve(void 0)
|
|
||||||
} else {
|
|
||||||
setTimeout(checkLoaded, 100)
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
// 跨域问题,等待一段时间后继续
|
|
||||||
setTimeout(checkLoaded, 100)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
newWindow.addEventListener("load", () => {
|
|
||||||
clearTimeout(timeout)
|
|
||||||
setTimeout(resolve, 2000) // 额外等待2秒确保渲染完成
|
|
||||||
})
|
|
||||||
|
|
||||||
checkLoaded()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// 尝试截图新窗口
|
if (result.success && result.dataUrl) {
|
||||||
let result
|
|
||||||
try {
|
|
||||||
if (newWindow.document && newWindow.document.body) {
|
|
||||||
result = await enhancedScreenshotService.captureElement(newWindow.document.body, {
|
|
||||||
...screenshotOptions,
|
|
||||||
...getViewportSize(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.warn("新窗口截图失败,尝试其他方法:", e)
|
|
||||||
}
|
|
||||||
|
|
||||||
newWindow.close()
|
|
||||||
|
|
||||||
if (result?.success && result.dataUrl) {
|
|
||||||
return {
|
return {
|
||||||
...screenshot,
|
...screenshot,
|
||||||
status: "success",
|
status: "success",
|
||||||
@@ -212,52 +149,9 @@ export default function DocumentationPage() {
|
|||||||
method: result.method,
|
method: result.method,
|
||||||
size: getViewportSize(),
|
size: getViewportSize(),
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
throw new Error(result.error || "截图失败")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 方法3: 使用iframe截图(最后的备选方案)
|
|
||||||
const iframe = document.createElement("iframe")
|
|
||||||
iframe.style.position = "absolute"
|
|
||||||
iframe.style.left = "-9999px"
|
|
||||||
iframe.style.width = `${getViewportSize().width}px`
|
|
||||||
iframe.style.height = `${getViewportSize().height}px`
|
|
||||||
iframe.src = page.path
|
|
||||||
|
|
||||||
document.body.appendChild(iframe)
|
|
||||||
|
|
||||||
try {
|
|
||||||
await new Promise((resolve, reject) => {
|
|
||||||
const timeout = setTimeout(() => {
|
|
||||||
reject(new Error("iframe加载超时"))
|
|
||||||
}, 15000)
|
|
||||||
|
|
||||||
iframe.onload = () => {
|
|
||||||
clearTimeout(timeout)
|
|
||||||
setTimeout(resolve, 2000) // 等待渲染完成
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// 尝试截图iframe内容
|
|
||||||
if (iframe.contentDocument && iframe.contentDocument.body) {
|
|
||||||
result = await enhancedScreenshotService.captureElement(iframe.contentDocument.body, {
|
|
||||||
...screenshotOptions,
|
|
||||||
...getViewportSize(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
document.body.removeChild(iframe)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (result?.success && result.dataUrl) {
|
|
||||||
return {
|
|
||||||
...screenshot,
|
|
||||||
status: "success",
|
|
||||||
dataUrl: result.dataUrl,
|
|
||||||
method: result.method,
|
|
||||||
size: getViewportSize(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error("所有截图方法都失败了")
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`截图失败 ${page.name}:`, error)
|
console.error(`截图失败 ${page.name}:`, error)
|
||||||
return {
|
return {
|
||||||
@@ -288,10 +182,8 @@ export default function DocumentationPage() {
|
|||||||
for (let i = 0; i < selectedPageObjects.length; i++) {
|
for (let i = 0; i < selectedPageObjects.length; i++) {
|
||||||
const page = selectedPageObjects[i]
|
const page = selectedPageObjects[i]
|
||||||
|
|
||||||
// 更新进度
|
|
||||||
setCaptureProgress((i / selectedPageObjects.length) * 100)
|
setCaptureProgress((i / selectedPageObjects.length) * 100)
|
||||||
|
|
||||||
// 添加待处理的截图
|
|
||||||
const pendingScreenshot: Screenshot = {
|
const pendingScreenshot: Screenshot = {
|
||||||
id: `${page.path}-${Date.now()}`,
|
id: `${page.path}-${Date.now()}`,
|
||||||
name: page.name,
|
name: page.name,
|
||||||
@@ -306,7 +198,6 @@ export default function DocumentationPage() {
|
|||||||
try {
|
try {
|
||||||
const screenshot = await captureScreenshot(page)
|
const screenshot = await captureScreenshot(page)
|
||||||
|
|
||||||
// 更新截图状态
|
|
||||||
const updatedScreenshots = [...newScreenshots]
|
const updatedScreenshots = [...newScreenshots]
|
||||||
updatedScreenshots[i] = screenshot
|
updatedScreenshots[i] = screenshot
|
||||||
setScreenshots(updatedScreenshots)
|
setScreenshots(updatedScreenshots)
|
||||||
@@ -338,7 +229,6 @@ export default function DocumentationPage() {
|
|||||||
newScreenshots[i] = failedScreenshot
|
newScreenshots[i] = failedScreenshot
|
||||||
}
|
}
|
||||||
|
|
||||||
// 添加延迟避免过快请求
|
|
||||||
if (i < selectedPageObjects.length - 1) {
|
if (i < selectedPageObjects.length - 1) {
|
||||||
await new Promise((resolve) => setTimeout(resolve, 1000))
|
await new Promise((resolve) => setTimeout(resolve, 1000))
|
||||||
}
|
}
|
||||||
@@ -357,62 +247,6 @@ export default function DocumentationPage() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const retryFailedScreenshots = async () => {
|
|
||||||
const failedScreenshots = screenshots.filter((s) => s.status === "failed")
|
|
||||||
|
|
||||||
if (failedScreenshots.length === 0) {
|
|
||||||
toast({
|
|
||||||
title: "没有失败的截图",
|
|
||||||
description: "所有截图都已成功",
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsCapturing(true)
|
|
||||||
setCaptureProgress(0)
|
|
||||||
|
|
||||||
for (let i = 0; i < failedScreenshots.length; i++) {
|
|
||||||
const failedScreenshot = failedScreenshots[i]
|
|
||||||
const page = pages.find((p) => p.path === failedScreenshot.url)
|
|
||||||
|
|
||||||
if (!page) continue
|
|
||||||
|
|
||||||
setCaptureProgress((i / failedScreenshots.length) * 100)
|
|
||||||
|
|
||||||
// 更新状态为重新截图中
|
|
||||||
setScreenshots((prev) =>
|
|
||||||
prev.map((s) => (s.id === failedScreenshot.id ? { ...s, status: "capturing" as const } : s)),
|
|
||||||
)
|
|
||||||
|
|
||||||
try {
|
|
||||||
const newScreenshot = await captureScreenshot(page)
|
|
||||||
|
|
||||||
setScreenshots((prev) => prev.map((s) => (s.id === failedScreenshot.id ? newScreenshot : s)))
|
|
||||||
|
|
||||||
if (newScreenshot.status === "success") {
|
|
||||||
toast({
|
|
||||||
title: "重试成功",
|
|
||||||
description: `${page.name} 截图完成`,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`重试截图失败:`, error)
|
|
||||||
setScreenshots((prev) =>
|
|
||||||
prev.map((s) =>
|
|
||||||
s.id === failedScreenshot.id
|
|
||||||
? { ...s, status: "failed" as const, error: error instanceof Error ? error.message : "未知错误" }
|
|
||||||
: s,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 1000))
|
|
||||||
}
|
|
||||||
|
|
||||||
setCaptureProgress(100)
|
|
||||||
setIsCapturing(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
const generateDocument = async () => {
|
const generateDocument = async () => {
|
||||||
const successfulScreenshots = screenshots.filter((s) => s.status === "success" && s.dataUrl)
|
const successfulScreenshots = screenshots.filter((s) => s.status === "success" && s.dataUrl)
|
||||||
|
|
||||||
@@ -430,7 +264,6 @@ export default function DocumentationPage() {
|
|||||||
try {
|
try {
|
||||||
const docBuffer = await generateDocx(successfulScreenshots, documentSettings)
|
const docBuffer = await generateDocx(successfulScreenshots, documentSettings)
|
||||||
|
|
||||||
// 下载文档
|
|
||||||
const blob = new Blob([docBuffer], {
|
const blob = new Blob([docBuffer], {
|
||||||
type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||||
})
|
})
|
||||||
@@ -520,9 +353,8 @@ export default function DocumentationPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Tabs defaultValue="capture" className="space-y-6">
|
<Tabs defaultValue="capture" className="space-y-6">
|
||||||
<TabsList className="grid w-full grid-cols-4">
|
<TabsList className="grid w-full grid-cols-3">
|
||||||
<TabsTrigger value="capture">页面截图</TabsTrigger>
|
<TabsTrigger value="capture">页面截图</TabsTrigger>
|
||||||
<TabsTrigger value="preview">预览页面</TabsTrigger>
|
|
||||||
<TabsTrigger value="screenshots">截图管理</TabsTrigger>
|
<TabsTrigger value="screenshots">截图管理</TabsTrigger>
|
||||||
<TabsTrigger value="settings">文档设置</TabsTrigger>
|
<TabsTrigger value="settings">文档设置</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
@@ -583,105 +415,6 @@ export default function DocumentationPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="flex items-center space-x-2">
|
|
||||||
<Settings className="h-5 w-5" />
|
|
||||||
<span>截图设置</span>
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-sm font-medium">预览模式</label>
|
|
||||||
<Select
|
|
||||||
value={previewMode}
|
|
||||||
onValueChange={(value: "desktop" | "tablet" | "mobile") => setPreviewMode(value)}
|
|
||||||
>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="desktop">
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<Monitor className="h-4 w-4" />
|
|
||||||
<span>桌面 (1920x1080)</span>
|
|
||||||
</div>
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="tablet">
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<Tablet className="h-4 w-4" />
|
|
||||||
<span>平板 (768x1024)</span>
|
|
||||||
</div>
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="mobile">
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<Smartphone className="h-4 w-4" />
|
|
||||||
<span>手机 (375x667)</span>
|
|
||||||
</div>
|
|
||||||
</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-sm font-medium">图片格式</label>
|
|
||||||
<Select
|
|
||||||
value={screenshotOptions.format}
|
|
||||||
onValueChange={(value: "png" | "jpeg" | "webp") =>
|
|
||||||
setScreenshotOptions((prev) => ({ ...prev, format: value }))
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="png">PNG (高质量)</SelectItem>
|
|
||||||
<SelectItem value="jpeg">JPEG (小文件)</SelectItem>
|
|
||||||
<SelectItem value="webp">WebP (平衡)</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-sm font-medium">
|
|
||||||
图片质量: {Math.round((screenshotOptions.quality || 0.9) * 100)}%
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
min="0.1"
|
|
||||||
max="1"
|
|
||||||
step="0.1"
|
|
||||||
value={screenshotOptions.quality || 0.9}
|
|
||||||
onChange={(e) =>
|
|
||||||
setScreenshotOptions((prev) => ({
|
|
||||||
...prev,
|
|
||||||
quality: Number.parseFloat(e.target.value),
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
className="w-full"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-sm font-medium">缩放比例: {screenshotOptions.scale || 1}x</label>
|
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
min="0.5"
|
|
||||||
max="3"
|
|
||||||
step="0.1"
|
|
||||||
value={screenshotOptions.scale || 1}
|
|
||||||
onChange={(e) =>
|
|
||||||
setScreenshotOptions((prev) => ({
|
|
||||||
...prev,
|
|
||||||
scale: Number.parseFloat(e.target.value),
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
className="w-full"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center space-x-2">
|
<CardTitle className="flex items-center space-x-2">
|
||||||
@@ -700,37 +433,23 @@ export default function DocumentationPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="space-y-2">
|
<Button
|
||||||
<Button
|
onClick={startCapture}
|
||||||
onClick={startCapture}
|
disabled={isCapturing || selectedPages.length === 0}
|
||||||
disabled={isCapturing || selectedPages.length === 0}
|
className="w-full"
|
||||||
className="w-full"
|
>
|
||||||
>
|
{isCapturing ? (
|
||||||
{isCapturing ? (
|
<>
|
||||||
<>
|
<RefreshCw className="mr-2 h-4 w-4 animate-spin" />
|
||||||
<RefreshCw className="mr-2 h-4 w-4 animate-spin" />
|
截图中...
|
||||||
截图中...
|
</>
|
||||||
</>
|
) : (
|
||||||
) : (
|
<>
|
||||||
<>
|
<Camera className="mr-2 h-4 w-4" />
|
||||||
<Camera className="mr-2 h-4 w-4" />
|
开始截图
|
||||||
开始截图
|
</>
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{screenshots.some((s) => s.status === "failed") && (
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={retryFailedScreenshots}
|
|
||||||
disabled={isCapturing}
|
|
||||||
className="w-full bg-transparent"
|
|
||||||
>
|
|
||||||
<RefreshCw className="mr-2 h-4 w-4" />
|
|
||||||
重试失败的截图
|
|
||||||
</Button>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</Button>
|
||||||
|
|
||||||
<div className="text-sm text-muted-foreground">
|
<div className="text-sm text-muted-foreground">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
@@ -752,65 +471,6 @@ export default function DocumentationPage() {
|
|||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="preview" className="space-y-6">
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
{getPreviewIcon()}
|
|
||||||
<span>页面预览</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<Select
|
|
||||||
value={previewMode}
|
|
||||||
onValueChange={(value: "desktop" | "tablet" | "mobile") => setPreviewMode(value)}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="w-[150px]">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="desktop">桌面</SelectItem>
|
|
||||||
<SelectItem value="tablet">平板</SelectItem>
|
|
||||||
<SelectItem value="mobile">手机</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</CardTitle>
|
|
||||||
<CardDescription>预览页面在不同设备上的显示效果</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="border rounded-lg overflow-hidden bg-gray-100 p-4">
|
|
||||||
<div
|
|
||||||
className="mx-auto bg-white shadow-lg rounded-lg overflow-hidden"
|
|
||||||
style={{
|
|
||||||
width: `${getViewportSize().width}px`,
|
|
||||||
height: `${getViewportSize().height}px`,
|
|
||||||
maxWidth: "100%",
|
|
||||||
transform: "scale(0.5)",
|
|
||||||
transformOrigin: "top center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{currentPreviewUrl ? (
|
|
||||||
<iframe
|
|
||||||
ref={previewRef}
|
|
||||||
src={currentPreviewUrl}
|
|
||||||
className="w-full h-full border-0"
|
|
||||||
title="页面预览"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="flex items-center justify-center h-full text-muted-foreground">
|
|
||||||
<div className="text-center">
|
|
||||||
<Globe className="h-12 w-12 mx-auto mb-4 opacity-50" />
|
|
||||||
<p>请从左侧选择一个页面进行预览</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
<TabsContent value="screenshots" className="space-y-6">
|
<TabsContent value="screenshots" className="space-y-6">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
@@ -974,43 +634,6 @@ export default function DocumentationPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-sm font-medium">页面大小</label>
|
|
||||||
<Select
|
|
||||||
value={documentSettings.pageSize}
|
|
||||||
onValueChange={(value: "A4" | "A3" | "Letter") =>
|
|
||||||
setDocumentSettings((prev) => ({ ...prev, pageSize: value }))
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="A4">A4</SelectItem>
|
|
||||||
<SelectItem value="A3">A3</SelectItem>
|
|
||||||
<SelectItem value="Letter">Letter</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-sm font-medium">页面方向</label>
|
|
||||||
<Select
|
|
||||||
value={documentSettings.orientation}
|
|
||||||
onValueChange={(value: "portrait" | "landscape") =>
|
|
||||||
setDocumentSettings((prev) => ({ ...prev, orientation: value }))
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="portrait">纵向</SelectItem>
|
|
||||||
<SelectItem value="landscape">横向</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<label className="text-sm font-medium">包含内容</label>
|
<label className="text-sm font-medium">包含内容</label>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
|||||||
@@ -4,7 +4,6 @@
|
|||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
:root {
|
:root {
|
||||||
/* 更新为毛玻璃风格的颜色变量 */
|
|
||||||
--background: 240 10% 98%;
|
--background: 240 10% 98%;
|
||||||
--foreground: 240 10% 3.9%;
|
--foreground: 240 10% 3.9%;
|
||||||
--card: 240 10% 100%;
|
--card: 240 10% 100%;
|
||||||
@@ -70,7 +69,6 @@
|
|||||||
@apply bg-gradient-to-br from-gray-900 via-gray-800 to-gray-900;
|
@apply bg-gradient-to-br from-gray-900 via-gray-800 to-gray-900;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 移动端优化 */
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
body {
|
body {
|
||||||
background-attachment: scroll;
|
background-attachment: scroll;
|
||||||
@@ -79,28 +77,30 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@layer components {
|
@layer components {
|
||||||
/* 毛玻璃效果基础类 */
|
|
||||||
.glass {
|
.glass {
|
||||||
@apply backdrop-blur-md bg-white/10 border border-white/20;
|
@apply backdrop-blur-md border border-white/20;
|
||||||
|
background-color: rgba(255, 255, 255, 0.1);
|
||||||
box-shadow: 0 8px 32px 0 rgba(31, 38, 135, 0.37);
|
box-shadow: 0 8px 32px 0 rgba(31, 38, 135, 0.37);
|
||||||
}
|
}
|
||||||
|
|
||||||
.glass-light {
|
.glass-light {
|
||||||
@apply backdrop-blur-sm bg-white/20 border border-white/30;
|
@apply backdrop-blur-sm border border-white/30;
|
||||||
|
background-color: rgba(255, 255, 255, 0.2);
|
||||||
box-shadow: 0 4px 16px 0 rgba(31, 38, 135, 0.2);
|
box-shadow: 0 4px 16px 0 rgba(31, 38, 135, 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.glass-heavy {
|
.glass-heavy {
|
||||||
@apply backdrop-blur-xl bg-white/30 border border-white/40;
|
@apply backdrop-blur-xl border border-white/40;
|
||||||
|
background-color: rgba(255, 255, 255, 0.3);
|
||||||
box-shadow: 0 16px 64px 0 rgba(31, 38, 135, 0.5);
|
box-shadow: 0 16px 64px 0 rgba(31, 38, 135, 0.5);
|
||||||
}
|
}
|
||||||
|
|
||||||
.glass-dark {
|
.glass-dark {
|
||||||
@apply backdrop-blur-md bg-black/10 border border-white/10;
|
@apply backdrop-blur-md border border-white/10;
|
||||||
|
background-color: rgba(0, 0, 0, 0.1);
|
||||||
box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.3);
|
box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 移动端毛玻璃效果优化 */
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.glass {
|
.glass {
|
||||||
@apply backdrop-blur-sm;
|
@apply backdrop-blur-sm;
|
||||||
@@ -118,42 +118,49 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 卡片毛玻璃效果 */
|
|
||||||
.glass-card {
|
.glass-card {
|
||||||
@apply glass rounded-2xl p-6 transition-all duration-300 hover:bg-white/20 hover:shadow-glass-lg;
|
@apply glass rounded-2xl p-6 transition-all duration-300;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass-card:hover {
|
||||||
|
background-color: rgba(255, 255, 255, 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 移动端卡片优化 */
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.glass-card {
|
.glass-card {
|
||||||
@apply p-4 rounded-xl;
|
@apply p-4 rounded-xl;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 导航栏毛玻璃效果 */
|
|
||||||
.glass-nav {
|
.glass-nav {
|
||||||
@apply glass-light rounded-2xl transition-all duration-300;
|
@apply glass-light rounded-2xl transition-all duration-300;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 按钮毛玻璃效果 */
|
|
||||||
.glass-button {
|
.glass-button {
|
||||||
@apply glass-light rounded-xl px-4 py-2 transition-all duration-300 hover:bg-white/30 hover:scale-105;
|
@apply glass-light rounded-xl px-4 py-2 transition-all duration-300;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass-button:hover {
|
||||||
|
background-color: rgba(255, 255, 255, 0.3);
|
||||||
|
transform: scale(1.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 移动端按钮优化 */
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.glass-button {
|
.glass-button {
|
||||||
@apply px-6 py-3 text-base;
|
@apply px-6 py-3 text-base;
|
||||||
min-height: 44px; /* iOS推荐的最小触摸目标 */
|
min-height: 44px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 输入框毛玻璃效果 */
|
|
||||||
.glass-input {
|
.glass-input {
|
||||||
@apply glass-light rounded-xl px-4 py-2 transition-all duration-300 focus:bg-white/30 focus:ring-2 focus:ring-white/50;
|
@apply glass-light rounded-xl px-4 py-2 transition-all duration-300;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass-input:focus {
|
||||||
|
background-color: rgba(255, 255, 255, 0.3);
|
||||||
|
@apply ring-2 ring-white/50;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 移动端输入框优化 */
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.glass-input {
|
.glass-input {
|
||||||
@apply px-4 py-3 text-base;
|
@apply px-4 py-3 text-base;
|
||||||
@@ -161,7 +168,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 安全区域适配 */
|
|
||||||
.safe-area-top {
|
.safe-area-top {
|
||||||
padding-top: env(safe-area-inset-top);
|
padding-top: env(safe-area-inset-top);
|
||||||
}
|
}
|
||||||
@@ -179,7 +185,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 移动端滚动优化 */
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.overflow-scroll {
|
.overflow-scroll {
|
||||||
-webkit-overflow-scrolling: touch;
|
-webkit-overflow-scrolling: touch;
|
||||||
|
|||||||
@@ -1,24 +1,4 @@
|
|||||||
/**
|
import { Document, Packer, Paragraph, ImageRun, TextRun, HeadingLevel } from "docx"
|
||||||
* Word文档生成器
|
|
||||||
* 使用docx库生成专业的Word文档
|
|
||||||
*/
|
|
||||||
|
|
||||||
import {
|
|
||||||
Document,
|
|
||||||
Paragraph,
|
|
||||||
TextRun,
|
|
||||||
HeadingLevel,
|
|
||||||
ImageRun,
|
|
||||||
TableOfContents,
|
|
||||||
PageBreak,
|
|
||||||
AlignmentType,
|
|
||||||
Table,
|
|
||||||
TableRow,
|
|
||||||
TableCell,
|
|
||||||
WidthType,
|
|
||||||
BorderStyle,
|
|
||||||
} from "docx"
|
|
||||||
import { Buffer } from "buffer"
|
|
||||||
|
|
||||||
interface Screenshot {
|
interface Screenshot {
|
||||||
id: string
|
id: string
|
||||||
@@ -41,445 +21,190 @@ interface DocumentSettings {
|
|||||||
orientation: string
|
orientation: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DocumentSection {
|
|
||||||
title: string
|
|
||||||
path: string
|
|
||||||
description: string
|
|
||||||
screenshot: string
|
|
||||||
}
|
|
||||||
|
|
||||||
interface DocumentData {
|
|
||||||
title: string
|
|
||||||
author: string
|
|
||||||
date: string
|
|
||||||
sections: DocumentSection[]
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 将base64字符串转换为Buffer
|
|
||||||
* @param base64 base64字符串
|
|
||||||
* @returns Buffer
|
|
||||||
*/
|
|
||||||
function base64ToBuffer(base64: string): Buffer {
|
|
||||||
try {
|
|
||||||
// 移除data URL前缀
|
|
||||||
const base64Data = base64.includes("base64,") ? base64.split("base64,")[1] : base64
|
|
||||||
|
|
||||||
// 转换为Buffer
|
|
||||||
return Buffer.from(base64Data, "base64")
|
|
||||||
} catch (error) {
|
|
||||||
console.error("base64转换为Buffer时出错:", error)
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 生成Word文档
|
|
||||||
* @param screenshots 截图数据
|
|
||||||
* @param settings 文档设置
|
|
||||||
* @returns 文档的ArrayBuffer
|
|
||||||
*/
|
|
||||||
export async function generateDocx(screenshots: Screenshot[], settings: DocumentSettings): Promise<ArrayBuffer> {
|
export async function generateDocx(screenshots: Screenshot[], settings: DocumentSettings): Promise<ArrayBuffer> {
|
||||||
console.log("开始生成Word文档...")
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 验证输入数据
|
const children: Paragraph[] = []
|
||||||
if (!screenshots || !Array.isArray(screenshots)) {
|
|
||||||
throw new Error("截图数据不能为空或不是数组")
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!settings) {
|
// 添加标题
|
||||||
throw new Error("文档设置不能为空")
|
children.push(
|
||||||
}
|
new Paragraph({
|
||||||
|
children: [
|
||||||
console.log(`文档标题: ${settings.title}`)
|
new TextRun({
|
||||||
console.log(`作者: ${settings.author}`)
|
text: settings.title,
|
||||||
console.log(`描述: ${settings.description}`)
|
bold: true,
|
||||||
console.log(`部分数量: ${screenshots.length}`)
|
size: 32,
|
||||||
|
}),
|
||||||
// 创建文档
|
|
||||||
const doc = new Document({
|
|
||||||
title: settings.title,
|
|
||||||
description: settings.description,
|
|
||||||
creator: settings.author,
|
|
||||||
styles: {
|
|
||||||
paragraphStyles: [
|
|
||||||
{
|
|
||||||
id: "Normal",
|
|
||||||
name: "Normal",
|
|
||||||
run: {
|
|
||||||
size: 24, // 12pt
|
|
||||||
font: "Microsoft YaHei",
|
|
||||||
},
|
|
||||||
paragraph: {
|
|
||||||
spacing: {
|
|
||||||
line: 360, // 1.5倍行距
|
|
||||||
before: 240, // 12pt
|
|
||||||
after: 240, // 12pt
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "Heading1",
|
|
||||||
name: "Heading 1",
|
|
||||||
run: {
|
|
||||||
size: 36, // 18pt
|
|
||||||
bold: true,
|
|
||||||
font: "Microsoft YaHei",
|
|
||||||
},
|
|
||||||
paragraph: {
|
|
||||||
spacing: {
|
|
||||||
before: 480, // 24pt
|
|
||||||
after: 240, // 12pt
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "Heading2",
|
|
||||||
name: "Heading 2",
|
|
||||||
run: {
|
|
||||||
size: 32, // 16pt
|
|
||||||
bold: true,
|
|
||||||
font: "Microsoft YaHei",
|
|
||||||
},
|
|
||||||
paragraph: {
|
|
||||||
spacing: {
|
|
||||||
before: 360, // 18pt
|
|
||||||
after: 240, // 12pt
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "Caption",
|
|
||||||
name: "Caption",
|
|
||||||
run: {
|
|
||||||
size: 20, // 10pt
|
|
||||||
italic: true,
|
|
||||||
font: "Microsoft YaHei",
|
|
||||||
},
|
|
||||||
paragraph: {
|
|
||||||
alignment: AlignmentType.CENTER,
|
|
||||||
spacing: {
|
|
||||||
before: 120, // 6pt
|
|
||||||
after: 240, // 12pt
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
})
|
heading: HeadingLevel.TITLE,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
// 文档部分
|
// 添加描述
|
||||||
const sections = []
|
if (settings.description) {
|
||||||
|
children.push(
|
||||||
// 封面
|
|
||||||
sections.push({
|
|
||||||
properties: {},
|
|
||||||
children: [
|
|
||||||
new Paragraph({
|
new Paragraph({
|
||||||
text: "",
|
|
||||||
spacing: {\
|
|
||||||
before: 3000, // 大约页面1/3处
|
|
||||||
},\
|
|
||||||
}),\
|
|
||||||
new Paragraph({\
|
|
||||||
text: settings.title,\
|
|
||||||
heading: HeadingLevel.TITLE,
|
|
||||||
alignment: AlignmentType.CENTER,
|
|
||||||
spacing: {\
|
|
||||||
after: 400,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
new Paragraph({\
|
|
||||||
text: "",\
|
|
||||||
spacing: {\
|
|
||||||
before: 800,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
new Paragraph({
|
|
||||||
alignment: AlignmentType.CENTER,\
|
|
||||||
children: [
|
children: [
|
||||||
new TextRun({
|
new TextRun({
|
||||||
text: \`作者: ${settings.author}`,\
|
text: settings.description,
|
||||||
size: 24,\
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
new Paragraph({
|
|
||||||
alignment: AlignmentType.CENTER,\
|
|
||||||
children: [
|
|
||||||
new TextRun({
|
|
||||||
text: `生成日期: ${new Date().toLocaleString()}`,\
|
|
||||||
size: 24,
|
size: 24,
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
new Paragraph({
|
)
|
||||||
text: "",\
|
|
||||||
break: PageBreak.AFTER,
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
})
|
|
||||||
|
|
||||||
// 目录
|
|
||||||
sections.push({
|
|
||||||
properties: {},
|
|
||||||
children: [\
|
|
||||||
new Paragraph({
|
|
||||||
text: "目录",
|
|
||||||
heading: HeadingLevel.HEADING_1,\
|
|
||||||
alignment: AlignmentType.CENTER,
|
|
||||||
}),
|
|
||||||
new TableOfContents("目录", {
|
|
||||||
hyperlink: true,
|
|
||||||
headingStyleRange: "1-3",\
|
|
||||||
}),\
|
|
||||||
new Paragraph({
|
|
||||||
text: "",
|
|
||||||
break: PageBreak.AFTER,
|
|
||||||
}),\
|
|
||||||
],
|
|
||||||
})
|
|
||||||
|
|
||||||
// 正文
|
|
||||||
const contentSection = {
|
|
||||||
properties: {},
|
|
||||||
children: [] as any[],
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 添加简介
|
// 添加生成信息
|
||||||
contentSection.children.push(\
|
if (settings.includeTimestamp) {
|
||||||
|
children.push(
|
||||||
|
new Paragraph({
|
||||||
|
children: [
|
||||||
|
new TextRun({
|
||||||
|
text: `生成时间: ${new Date().toLocaleString()}`,
|
||||||
|
size: 20,
|
||||||
|
italics: true,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
children.push(
|
||||||
new Paragraph({
|
new Paragraph({
|
||||||
text: "1. 简介",
|
children: [
|
||||||
heading: HeadingLevel.HEADING_1,
|
new TextRun({
|
||||||
}),
|
text: `作者: ${settings.author}`,
|
||||||
new Paragraph({
|
size: 20,
|
||||||
text: "本文档由文档生成工具自动生成,包含应用程序的所有主要页面截图和功能说明。",
|
italics: true,
|
||||||
}),
|
}),
|
||||||
new Paragraph({
|
],
|
||||||
text: "文档目的是帮助用户了解系统功能和使用方法,为系统管理员和最终用户提供参考。",
|
|
||||||
}),\
|
|
||||||
new Paragraph({
|
|
||||||
text: "",
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
// 添加页面内容
|
// 添加分隔符
|
||||||
contentSection.children.push(
|
children.push(
|
||||||
new Paragraph({
|
new Paragraph({
|
||||||
text: "2. 系统功能",
|
children: [
|
||||||
heading: HeadingLevel.HEADING_1,
|
new TextRun({
|
||||||
|
text: "─".repeat(50),
|
||||||
|
size: 20,
|
||||||
|
}),
|
||||||
|
],
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
// 处理每个部分
|
// 添加每个截图
|
||||||
console.log("开始处理文档部分...")
|
for (const screenshot of screenshots) {
|
||||||
|
// 页面标题
|
||||||
// 使用for循环而不是forEach,以便更好地处理错误
|
children.push(
|
||||||
for (let i = 0; i < screenshots.length; i++) {
|
new Paragraph({
|
||||||
try {
|
children: [
|
||||||
const screenshot = screenshots[i]
|
new TextRun({
|
||||||
console.log(\`处理部分 ${i + 1}/${screenshots.length}: ${screenshot.name}`)
|
text: screenshot.name,
|
||||||
|
bold: true,
|
||||||
// 添加标题
|
size: 28,
|
||||||
contentSection.children.push(
|
|
||||||
new Paragraph({
|
|
||||||
text: `2.${i + 1} ${screenshot.name}`,
|
|
||||||
heading: HeadingLevel.HEADING_2,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
// 添加页面URL和截图时间
|
|
||||||
if (settings.includePageUrls) {
|
|
||||||
contentSection.children.push(
|
|
||||||
new Paragraph({
|
|
||||||
text: `页面URL: ${screenshot.url}`,
|
|
||||||
}),
|
}),
|
||||||
)
|
],
|
||||||
}
|
heading: HeadingLevel.HEADING_1,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
if (settings.includeTimestamp) {
|
// 页面URL
|
||||||
contentSection.children.push(
|
if (settings.includePageUrls) {
|
||||||
new Paragraph({
|
children.push(
|
||||||
text: `截图时间: ${screenshot.timestamp.toLocaleString()}`,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 添加状态
|
|
||||||
contentSection.children.push(
|
|
||||||
new Paragraph({
|
new Paragraph({
|
||||||
text: `状态: ${screenshot.status}`,
|
children: [
|
||||||
}),
|
new TextRun({
|
||||||
)
|
text: `页面地址: ${screenshot.url}`,
|
||||||
|
size: 20,
|
||||||
// 添加截图
|
color: "666666",
|
||||||
try {
|
|
||||||
if (screenshot.dataUrl && screenshot.dataUrl.startsWith("data:image/")) {
|
|
||||||
console.log(`处理截图: ${screenshot.name}`)
|
|
||||||
|
|
||||||
// 从base64数据URL中提取图像数据
|
|
||||||
const base64Data = screenshot.dataUrl.split(",")[1]
|
|
||||||
if (!base64Data) {
|
|
||||||
throw new Error("无效的base64数据")
|
|
||||||
}
|
|
||||||
|
|
||||||
// 将base64转换为二进制数据
|
|
||||||
const imageBuffer = base64ToBuffer(screenshot.dataUrl)
|
|
||||||
|
|
||||||
// 添加图像
|
|
||||||
contentSection.children.push(
|
|
||||||
new Paragraph({
|
|
||||||
children: [
|
|
||||||
new ImageRun({
|
|
||||||
data: imageBuffer,
|
|
||||||
transformation: {
|
|
||||||
width: 600,
|
|
||||||
height: 400,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
alignment: AlignmentType.CENTER,
|
|
||||||
}),
|
}),
|
||||||
new Paragraph({
|
],
|
||||||
text: `图 ${i + 1}: ${screenshot.name} 页面截图`,
|
|
||||||
style: "Caption",
|
|
||||||
}),
|
|
||||||
new Paragraph({
|
|
||||||
text: "",
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
console.warn(`部分 ${i + 1} 缺少有效的截图`)
|
|
||||||
contentSection.children.push(
|
|
||||||
new Paragraph({
|
|
||||||
text: "[截图不可用]",
|
|
||||||
alignment: AlignmentType.CENTER,
|
|
||||||
}),
|
|
||||||
new Paragraph({
|
|
||||||
text: "",
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} catch (imageError) {
|
|
||||||
console.error(`处理部分 ${i + 1} 的截图时出错:`, imageError)
|
|
||||||
contentSection.children.push(
|
|
||||||
new Paragraph({
|
|
||||||
text: "[处理截图时出错]",
|
|
||||||
alignment: AlignmentType.CENTER,
|
|
||||||
}),
|
|
||||||
new Paragraph({
|
|
||||||
text: "",
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} catch (sectionError) {
|
|
||||||
console.error(`处理部分 ${i + 1} 时出错:`, sectionError)
|
|
||||||
contentSection.children.push(
|
|
||||||
new Paragraph({
|
|
||||||
text: `[处理部分 ${i + 1} 时出错: ${sectionError instanceof Error ? sectionError.message : String(sectionError)}]`,
|
|
||||||
alignment: AlignmentType.CENTER,
|
|
||||||
}),
|
|
||||||
new Paragraph({
|
|
||||||
text: "",
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 截图时间
|
||||||
|
if (settings.includeTimestamp) {
|
||||||
|
children.push(
|
||||||
|
new Paragraph({
|
||||||
|
children: [
|
||||||
|
new TextRun({
|
||||||
|
text: `截图时间: ${screenshot.timestamp.toLocaleString()}`,
|
||||||
|
size: 20,
|
||||||
|
color: "666666",
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加截图
|
||||||
|
if (screenshot.dataUrl) {
|
||||||
|
try {
|
||||||
|
// 将 dataUrl 转换为 ArrayBuffer
|
||||||
|
const base64Data = screenshot.dataUrl.split(",")[1]
|
||||||
|
const binaryString = atob(base64Data)
|
||||||
|
const bytes = new Uint8Array(binaryString.length)
|
||||||
|
for (let i = 0; i < binaryString.length; i++) {
|
||||||
|
bytes[i] = binaryString.charCodeAt(i)
|
||||||
|
}
|
||||||
|
|
||||||
|
children.push(
|
||||||
|
new Paragraph({
|
||||||
|
children: [
|
||||||
|
new ImageRun({
|
||||||
|
data: bytes,
|
||||||
|
transformation: {
|
||||||
|
width: 600,
|
||||||
|
height: 400,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
} catch (error) {
|
||||||
|
console.error("添加图片失败:", error)
|
||||||
|
children.push(
|
||||||
|
new Paragraph({
|
||||||
|
children: [
|
||||||
|
new TextRun({
|
||||||
|
text: "[图片加载失败]",
|
||||||
|
color: "FF0000",
|
||||||
|
italics: true,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加分隔符
|
||||||
|
children.push(
|
||||||
|
new Paragraph({
|
||||||
|
children: [
|
||||||
|
new TextRun({
|
||||||
|
text: "",
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 添加附录
|
// 创建文档
|
||||||
contentSection.children.push(
|
const doc = new Document({
|
||||||
new Paragraph({
|
sections: [
|
||||||
text: "3. 附录",
|
{
|
||||||
heading: HeadingLevel.HEADING_1,
|
properties: {},
|
||||||
}),
|
children: children,
|
||||||
new Paragraph({
|
},
|
||||||
text: "3.1 文档信息",
|
|
||||||
heading: HeadingLevel.HEADING_2,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
// 创建文档信息表格
|
|
||||||
const infoTable = new Table({
|
|
||||||
width: {
|
|
||||||
size: 100,
|
|
||||||
type: WidthType.PERCENTAGE,
|
|
||||||
},
|
|
||||||
borders: {
|
|
||||||
top: { style: BorderStyle.SINGLE, size: 1, color: "auto" },
|
|
||||||
bottom: { style: BorderStyle.SINGLE, size: 1, color: "auto" },
|
|
||||||
left: { style: BorderStyle.SINGLE, size: 1, color: "auto" },
|
|
||||||
right: { style: BorderStyle.SINGLE, size: 1, color: "auto" },
|
|
||||||
insideHorizontal: { style: BorderStyle.SINGLE, size: 1, color: "auto" },
|
|
||||||
insideVertical: { style: BorderStyle.SINGLE, size: 1, color: "auto" },
|
|
||||||
},
|
|
||||||
rows: [
|
|
||||||
new TableRow({
|
|
||||||
children: [
|
|
||||||
new TableCell({
|
|
||||||
width: {
|
|
||||||
size: 30,
|
|
||||||
type: WidthType.PERCENTAGE,
|
|
||||||
},
|
|
||||||
children: [new Paragraph("文档标题")],
|
|
||||||
}),
|
|
||||||
new TableCell({
|
|
||||||
width: {
|
|
||||||
size: 70,
|
|
||||||
type: WidthType.PERCENTAGE,
|
|
||||||
},
|
|
||||||
children: [new Paragraph(settings.title)],
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
new TableRow({
|
|
||||||
children: [
|
|
||||||
new TableCell({
|
|
||||||
children: [new Paragraph("作者")],
|
|
||||||
}),
|
|
||||||
new TableCell({
|
|
||||||
children: [new Paragraph(settings.author)],
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
new TableRow({
|
|
||||||
children: [
|
|
||||||
new TableCell({
|
|
||||||
children: [new Paragraph("生成日期")],
|
|
||||||
}),
|
|
||||||
new TableCell({
|
|
||||||
children: [new Paragraph(new Date().toLocaleString())],
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
new TableRow({
|
|
||||||
children: [
|
|
||||||
new TableCell({
|
|
||||||
children: [new Paragraph("页面数量")],
|
|
||||||
}),
|
|
||||||
new TableCell({
|
|
||||||
children: [new Paragraph(String(screenshots.length))],
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|
||||||
contentSection.children.push(infoTable)
|
// 生成文档
|
||||||
|
const buffer = await Packer.toBuffer(doc)
|
||||||
// 添加内容部分到文档
|
|
||||||
doc.addSection({
|
|
||||||
children: sections[0].children.concat(sections[1].children, contentSection.children),
|
|
||||||
})
|
|
||||||
|
|
||||||
console.log("文档生成完成,准备导出...")
|
|
||||||
|
|
||||||
// 生成blob
|
|
||||||
const buffer = await doc.save()
|
|
||||||
console.log("文档生成完成")
|
|
||||||
|
|
||||||
return buffer
|
return buffer
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("生成Word文档时出错:", error)
|
console.error("生成文档失败:", error)
|
||||||
throw error
|
throw new Error(`文档生成失败: ${error instanceof Error ? error.message : "未知错误"}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,78 +1,38 @@
|
|||||||
interface ScreenshotResult {
|
import { screenshotService, type ScreenshotOptions, type ScreenshotResult } from "./screenshot-service"
|
||||||
success: boolean
|
|
||||||
dataUrl?: string
|
|
||||||
error?: string
|
|
||||||
method?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ScreenshotOptions {
|
export interface EnhancedScreenshotOptions extends ScreenshotOptions {
|
||||||
format?: "png" | "jpeg" | "webp"
|
waitForImages?: boolean
|
||||||
quality?: number
|
waitForFonts?: boolean
|
||||||
scale?: number
|
removeElements?: string[]
|
||||||
width?: number
|
addWatermark?: boolean
|
||||||
height?: number
|
watermarkText?: string
|
||||||
backgroundColor?: string
|
|
||||||
timeout?: number
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class EnhancedScreenshotService {
|
class EnhancedScreenshotService {
|
||||||
async captureViewport(options: ScreenshotOptions = {}): Promise<ScreenshotResult> {
|
async captureViewport(options: EnhancedScreenshotOptions = {}): Promise<ScreenshotResult> {
|
||||||
try {
|
try {
|
||||||
// 创建一个简单的截图占位符
|
// 等待页面完全加载
|
||||||
const canvas = document.createElement("canvas")
|
await this.waitForPageLoad(options)
|
||||||
canvas.width = options.width || 1200
|
|
||||||
canvas.height = options.height || 800
|
|
||||||
const ctx = canvas.getContext("2d")
|
|
||||||
|
|
||||||
if (ctx) {
|
// 移除不需要的元素
|
||||||
// 绘制背景
|
const removedElements = this.removeElements(options.removeElements || [])
|
||||||
ctx.fillStyle = options.backgroundColor || "#f8fafc"
|
|
||||||
ctx.fillRect(0, 0, canvas.width, canvas.height)
|
|
||||||
|
|
||||||
// 绘制标题
|
try {
|
||||||
ctx.fillStyle = "#1e293b"
|
// 调用基础截图服务
|
||||||
ctx.font = "bold 24px Arial"
|
const result = await screenshotService.captureViewport(options)
|
||||||
ctx.fillText("用户数据资产中台", 50, 60)
|
|
||||||
|
|
||||||
// 绘制页面信息
|
// 添加水印
|
||||||
ctx.fillStyle = "#64748b"
|
if (options.addWatermark && result.success && result.dataUrl) {
|
||||||
ctx.font = "16px Arial"
|
result.dataUrl = await this.addWatermark(result.dataUrl, options.watermarkText)
|
||||||
ctx.fillText(`页面: ${window.location.pathname}`, 50, 100)
|
|
||||||
ctx.fillText(`截图时间: ${new Date().toLocaleString()}`, 50, 130)
|
|
||||||
ctx.fillText(`分辨率: ${canvas.width} x ${canvas.height}`, 50, 160)
|
|
||||||
|
|
||||||
// 绘制一些装饰性元素
|
|
||||||
ctx.strokeStyle = "#e2e8f0"
|
|
||||||
ctx.lineWidth = 2
|
|
||||||
ctx.strokeRect(30, 30, canvas.width - 60, canvas.height - 60)
|
|
||||||
|
|
||||||
// 绘制一些模拟的图表元素
|
|
||||||
ctx.fillStyle = "#3b82f6"
|
|
||||||
ctx.fillRect(50, 200, 200, 100)
|
|
||||||
ctx.fillStyle = "#ffffff"
|
|
||||||
ctx.font = "14px Arial"
|
|
||||||
ctx.fillText("数据概览", 60, 230)
|
|
||||||
ctx.fillText("用户总数: 125,678", 60, 250)
|
|
||||||
ctx.fillText("活跃用户: 45,678", 60, 270)
|
|
||||||
|
|
||||||
ctx.fillStyle = "#10b981"
|
|
||||||
ctx.fillRect(300, 200, 200, 100)
|
|
||||||
ctx.fillStyle = "#ffffff"
|
|
||||||
ctx.fillText("AI分析", 310, 230)
|
|
||||||
ctx.fillText("预测准确率: 94.2%", 310, 250)
|
|
||||||
ctx.fillText("异常检测: 3个", 310, 270)
|
|
||||||
|
|
||||||
const dataUrl = canvas.toDataURL(`image/${options.format || "png"}`, options.quality || 0.9)
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
dataUrl,
|
|
||||||
method: "enhanced-canvas",
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error("无法创建截图")
|
return result
|
||||||
|
} finally {
|
||||||
|
// 恢复移除的元素
|
||||||
|
this.restoreElements(removedElements)
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
console.error("增强截图失败:", error)
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error: error instanceof Error ? error.message : "未知错误",
|
error: error instanceof Error ? error.message : "未知错误",
|
||||||
@@ -80,55 +40,131 @@ class EnhancedScreenshotService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async captureElement(element: HTMLElement, options: ScreenshotOptions = {}): Promise<ScreenshotResult> {
|
async captureElement(element: HTMLElement, options: EnhancedScreenshotOptions = {}): Promise<ScreenshotResult> {
|
||||||
try {
|
try {
|
||||||
// 获取元素的尺寸和位置
|
// 等待页面完全加载
|
||||||
const rect = element.getBoundingClientRect()
|
await this.waitForPageLoad(options)
|
||||||
const canvas = document.createElement("canvas")
|
|
||||||
canvas.width = options.width || rect.width
|
|
||||||
canvas.height = options.height || rect.height
|
|
||||||
const ctx = canvas.getContext("2d")
|
|
||||||
|
|
||||||
if (ctx) {
|
// 调用基础截图服务
|
||||||
// 绘制背景
|
const result = await screenshotService.captureElement(element, options)
|
||||||
ctx.fillStyle = options.backgroundColor || "#ffffff"
|
|
||||||
ctx.fillRect(0, 0, canvas.width, canvas.height)
|
|
||||||
|
|
||||||
// 绘制元素信息
|
// 添加水印
|
||||||
ctx.fillStyle = "#1e293b"
|
if (options.addWatermark && result.success && result.dataUrl) {
|
||||||
ctx.font = "16px Arial"
|
result.dataUrl = await this.addWatermark(result.dataUrl, options.watermarkText)
|
||||||
ctx.fillText(`元素截图: ${element.tagName}`, 20, 30)
|
|
||||||
|
|
||||||
if (element.textContent) {
|
|
||||||
ctx.fillStyle = "#64748b"
|
|
||||||
ctx.font = "14px Arial"
|
|
||||||
const text = element.textContent.substring(0, 50) + (element.textContent.length > 50 ? "..." : "")
|
|
||||||
ctx.fillText(`内容: ${text}`, 20, 60)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 绘制边框
|
|
||||||
ctx.strokeStyle = "#e2e8f0"
|
|
||||||
ctx.lineWidth = 1
|
|
||||||
ctx.strokeRect(10, 10, canvas.width - 20, canvas.height - 20)
|
|
||||||
|
|
||||||
const dataUrl = canvas.toDataURL(`image/${options.format || "png"}`, options.quality || 0.9)
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
dataUrl,
|
|
||||||
method: "enhanced-element",
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new Error("无法截取元素")
|
return result
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
console.error("增强元素截图失败:", error)
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error: error instanceof Error ? error.message : "未知错误",
|
error: error instanceof Error ? error.message : "未知错误",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async waitForPageLoad(options: EnhancedScreenshotOptions): Promise<void> {
|
||||||
|
// 等待图片加载
|
||||||
|
if (options.waitForImages) {
|
||||||
|
await this.waitForImages()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 等待字体加载
|
||||||
|
if (options.waitForFonts) {
|
||||||
|
await this.waitForFonts()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 额外等待时间确保渲染完成
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 1000))
|
||||||
|
}
|
||||||
|
|
||||||
|
private async waitForImages(): Promise<void> {
|
||||||
|
const images = Array.from(document.images)
|
||||||
|
const promises = images.map((img) => {
|
||||||
|
if (img.complete) return Promise.resolve()
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
img.onload = resolve
|
||||||
|
img.onerror = resolve // 即使图片加载失败也继续
|
||||||
|
setTimeout(resolve, 5000) // 5秒超时
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
await Promise.all(promises)
|
||||||
|
}
|
||||||
|
|
||||||
|
private async waitForFonts(): Promise<void> {
|
||||||
|
if ("fonts" in document) {
|
||||||
|
try {
|
||||||
|
await document.fonts.ready
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("字体加载等待失败:", error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private removeElements(selectors: string[]): Array<{ element: Element; parent: Node; nextSibling: Node | null }> {
|
||||||
|
const removedElements: Array<{ element: Element; parent: Node; nextSibling: Node | null }> = []
|
||||||
|
|
||||||
|
selectors.forEach((selector) => {
|
||||||
|
const elements = document.querySelectorAll(selector)
|
||||||
|
elements.forEach((element) => {
|
||||||
|
if (element.parentNode) {
|
||||||
|
removedElements.push({
|
||||||
|
element,
|
||||||
|
parent: element.parentNode,
|
||||||
|
nextSibling: element.nextSibling,
|
||||||
|
})
|
||||||
|
element.parentNode.removeChild(element)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
return removedElements
|
||||||
|
}
|
||||||
|
|
||||||
|
private restoreElements(removedElements: Array<{ element: Element; parent: Node; nextSibling: Node | null }>): void {
|
||||||
|
removedElements.forEach(({ element, parent, nextSibling }) => {
|
||||||
|
if (nextSibling) {
|
||||||
|
parent.insertBefore(element, nextSibling)
|
||||||
|
} else {
|
||||||
|
parent.appendChild(element)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private async addWatermark(dataUrl: string, watermarkText?: string): Promise<string> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const canvas = document.createElement("canvas")
|
||||||
|
const ctx = canvas.getContext("2d")
|
||||||
|
const img = new Image()
|
||||||
|
|
||||||
|
img.onload = () => {
|
||||||
|
canvas.width = img.width
|
||||||
|
canvas.height = img.height
|
||||||
|
|
||||||
|
// 绘制原图
|
||||||
|
ctx?.drawImage(img, 0, 0)
|
||||||
|
|
||||||
|
if (ctx && watermarkText) {
|
||||||
|
// 添加水印
|
||||||
|
ctx.font = "16px Arial"
|
||||||
|
ctx.fillStyle = "rgba(0, 0, 0, 0.5)"
|
||||||
|
ctx.textAlign = "right"
|
||||||
|
ctx.fillText(
|
||||||
|
watermarkText || `截图时间: ${new Date().toLocaleString()}`,
|
||||||
|
canvas.width - 20,
|
||||||
|
canvas.height - 20,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve(canvas.toDataURL("image/png"))
|
||||||
|
}
|
||||||
|
|
||||||
|
img.src = dataUrl
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const enhancedScreenshotService = new EnhancedScreenshotService()
|
export const enhancedScreenshotService = new EnhancedScreenshotService()
|
||||||
export type { ScreenshotResult, ScreenshotOptions }
|
export type { EnhancedScreenshotOptions }
|
||||||
|
|||||||
@@ -25,54 +25,18 @@ class PageRegistry {
|
|||||||
description: "用户行为洞察分析",
|
description: "用户行为洞察分析",
|
||||||
category: "用户分析",
|
category: "用户分析",
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: "/user-discovery/behavior",
|
|
||||||
name: "行为分析",
|
|
||||||
description: "用户行为模式分析",
|
|
||||||
category: "用户分析",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: "/user-discovery/segmentation",
|
|
||||||
name: "用户分群",
|
|
||||||
description: "智能用户分群",
|
|
||||||
category: "用户分析",
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
path: "/user-valuation",
|
path: "/user-valuation",
|
||||||
name: "用户估值",
|
name: "用户估值",
|
||||||
description: "用户价值评估模型",
|
description: "用户价值评估模型",
|
||||||
category: "价值分析",
|
category: "价值分析",
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: "/user-valuation/model",
|
|
||||||
name: "估值模型",
|
|
||||||
description: "RFM价值评估",
|
|
||||||
category: "价值分析",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: "/user-valuation/upgrade-paths",
|
|
||||||
name: "升级路径",
|
|
||||||
description: "用户价值提升策略",
|
|
||||||
category: "价值分析",
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
path: "/ai-analysis",
|
path: "/ai-analysis",
|
||||||
name: "AI分析",
|
name: "AI分析",
|
||||||
description: "智能数据洞察",
|
description: "智能数据洞察",
|
||||||
category: "AI功能",
|
category: "AI功能",
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: "/ai-analysis/trends",
|
|
||||||
name: "趋势识别",
|
|
||||||
description: "AI趋势预测",
|
|
||||||
category: "AI功能",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: "/ai-analysis/anomaly",
|
|
||||||
name: "异常检测",
|
|
||||||
description: "智能异常监控",
|
|
||||||
category: "AI功能",
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
path: "/devices",
|
path: "/devices",
|
||||||
name: "设备管理",
|
name: "设备管理",
|
||||||
|
|||||||
@@ -3,33 +3,33 @@
|
|||||||
* 结合多种技术方案确保能够成功捕获页面截图
|
* 结合多种技术方案确保能够成功捕获页面截图
|
||||||
*/
|
*/
|
||||||
|
|
||||||
interface ScreenshotResult {
|
export interface ScreenshotOptions {
|
||||||
|
format?: "png" | "jpeg" | "webp"
|
||||||
|
quality?: number
|
||||||
|
scale?: number
|
||||||
|
backgroundColor?: string
|
||||||
|
timeout?: number
|
||||||
|
width?: number
|
||||||
|
height?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScreenshotResult {
|
||||||
success: boolean
|
success: boolean
|
||||||
dataUrl?: string
|
dataUrl?: string
|
||||||
error?: string
|
error?: string
|
||||||
method?: string
|
method?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ScreenshotOptions {
|
|
||||||
format?: "png" | "jpeg" | "webp"
|
|
||||||
quality?: number
|
|
||||||
scale?: number
|
|
||||||
width?: number
|
|
||||||
height?: number
|
|
||||||
backgroundColor?: string
|
|
||||||
timeout?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
class ScreenshotService {
|
class ScreenshotService {
|
||||||
async captureViewport(options: ScreenshotOptions = {}): Promise<ScreenshotResult> {
|
async captureViewport(options: ScreenshotOptions = {}): Promise<ScreenshotResult> {
|
||||||
try {
|
try {
|
||||||
// 方法1: 使用 html2canvas
|
// 方法1: 使用 html2canvas
|
||||||
if (typeof window !== "undefined" && window.html2canvas) {
|
if (typeof window !== "undefined" && window.html2canvas) {
|
||||||
const canvas = await window.html2canvas(document.body, {
|
const canvas = await window.html2canvas(document.body, {
|
||||||
width: options.width || window.innerWidth,
|
|
||||||
height: options.height || window.innerHeight,
|
|
||||||
scale: options.scale || 1,
|
|
||||||
backgroundColor: options.backgroundColor || "#ffffff",
|
backgroundColor: options.backgroundColor || "#ffffff",
|
||||||
|
scale: options.scale || 1,
|
||||||
|
width: options.width,
|
||||||
|
height: options.height,
|
||||||
useCORS: true,
|
useCORS: true,
|
||||||
allowTaint: true,
|
allowTaint: true,
|
||||||
})
|
})
|
||||||
@@ -46,8 +46,9 @@ class ScreenshotService {
|
|||||||
// 方法2: 使用 dom-to-image
|
// 方法2: 使用 dom-to-image
|
||||||
if (typeof window !== "undefined" && window.domtoimage) {
|
if (typeof window !== "undefined" && window.domtoimage) {
|
||||||
const dataUrl = await window.domtoimage.toPng(document.body, {
|
const dataUrl = await window.domtoimage.toPng(document.body, {
|
||||||
width: options.width || window.innerWidth,
|
bgcolor: options.backgroundColor || "#ffffff",
|
||||||
height: options.height || window.innerHeight,
|
width: options.width,
|
||||||
|
height: options.height,
|
||||||
style: {
|
style: {
|
||||||
transform: `scale(${options.scale || 1})`,
|
transform: `scale(${options.scale || 1})`,
|
||||||
transformOrigin: "top left",
|
transformOrigin: "top left",
|
||||||
@@ -63,32 +64,35 @@ class ScreenshotService {
|
|||||||
|
|
||||||
// 方法3: 使用 Canvas API (基础实现)
|
// 方法3: 使用 Canvas API (基础实现)
|
||||||
const canvas = document.createElement("canvas")
|
const canvas = document.createElement("canvas")
|
||||||
canvas.width = options.width || window.innerWidth
|
|
||||||
canvas.height = options.height || window.innerHeight
|
|
||||||
const ctx = canvas.getContext("2d")
|
const ctx = canvas.getContext("2d")
|
||||||
|
|
||||||
if (ctx) {
|
if (!ctx) {
|
||||||
ctx.fillStyle = options.backgroundColor || "#ffffff"
|
throw new Error("无法获取Canvas上下文")
|
||||||
ctx.fillRect(0, 0, canvas.width, canvas.height)
|
|
||||||
|
|
||||||
// 简单的文本渲染作为占位符
|
|
||||||
ctx.fillStyle = "#333333"
|
|
||||||
ctx.font = "16px Arial"
|
|
||||||
ctx.fillText("页面截图占位符", 50, 50)
|
|
||||||
ctx.fillText(`页面: ${window.location.pathname}`, 50, 80)
|
|
||||||
ctx.fillText(`时间: ${new Date().toLocaleString()}`, 50, 110)
|
|
||||||
|
|
||||||
const dataUrl = canvas.toDataURL(`image/${options.format || "png"}`, options.quality || 0.9)
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
dataUrl,
|
|
||||||
method: "canvas-fallback",
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new Error("无法创建截图")
|
canvas.width = options.width || window.innerWidth
|
||||||
|
canvas.height = options.height || window.innerHeight
|
||||||
|
|
||||||
|
// 设置背景色
|
||||||
|
ctx.fillStyle = options.backgroundColor || "#ffffff"
|
||||||
|
ctx.fillRect(0, 0, canvas.width, canvas.height)
|
||||||
|
|
||||||
|
// 简单的文本渲染作为占位符
|
||||||
|
ctx.fillStyle = "#333333"
|
||||||
|
ctx.font = "16px Arial"
|
||||||
|
ctx.fillText("页面截图占位符", 50, 50)
|
||||||
|
ctx.fillText(`URL: ${window.location.href}`, 50, 80)
|
||||||
|
ctx.fillText(`时间: ${new Date().toLocaleString()}`, 50, 110)
|
||||||
|
|
||||||
|
const dataUrl = canvas.toDataURL(`image/${options.format || "png"}`, options.quality || 0.9)
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
dataUrl,
|
||||||
|
method: "canvas-fallback",
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
console.error("截图失败:", error)
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error: error instanceof Error ? error.message : "未知错误",
|
error: error instanceof Error ? error.message : "未知错误",
|
||||||
@@ -101,10 +105,10 @@ class ScreenshotService {
|
|||||||
// 使用 html2canvas 截取特定元素
|
// 使用 html2canvas 截取特定元素
|
||||||
if (typeof window !== "undefined" && window.html2canvas) {
|
if (typeof window !== "undefined" && window.html2canvas) {
|
||||||
const canvas = await window.html2canvas(element, {
|
const canvas = await window.html2canvas(element, {
|
||||||
width: options.width || element.offsetWidth,
|
|
||||||
height: options.height || element.offsetHeight,
|
|
||||||
scale: options.scale || 1,
|
|
||||||
backgroundColor: options.backgroundColor || "#ffffff",
|
backgroundColor: options.backgroundColor || "#ffffff",
|
||||||
|
scale: options.scale || 1,
|
||||||
|
width: options.width,
|
||||||
|
height: options.height,
|
||||||
useCORS: true,
|
useCORS: true,
|
||||||
allowTaint: true,
|
allowTaint: true,
|
||||||
})
|
})
|
||||||
@@ -121,8 +125,9 @@ class ScreenshotService {
|
|||||||
// 使用 dom-to-image 截取特定元素
|
// 使用 dom-to-image 截取特定元素
|
||||||
if (typeof window !== "undefined" && window.domtoimage) {
|
if (typeof window !== "undefined" && window.domtoimage) {
|
||||||
const dataUrl = await window.domtoimage.toPng(element, {
|
const dataUrl = await window.domtoimage.toPng(element, {
|
||||||
width: options.width || element.offsetWidth,
|
bgcolor: options.backgroundColor || "#ffffff",
|
||||||
height: options.height || element.offsetHeight,
|
width: options.width,
|
||||||
|
height: options.height,
|
||||||
})
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -132,8 +137,9 @@ class ScreenshotService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new Error("无法截取元素")
|
throw new Error("没有可用的截图库")
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
console.error("元素截图失败:", error)
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error: error instanceof Error ? error.message : "未知错误",
|
error: error instanceof Error ? error.message : "未知错误",
|
||||||
@@ -143,7 +149,14 @@ class ScreenshotService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const screenshotService = new ScreenshotService()
|
export const screenshotService = new ScreenshotService()
|
||||||
export type { ScreenshotResult, ScreenshotOptions }
|
|
||||||
|
// 扩展 Window 接口以包含截图库
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
html2canvas?: any
|
||||||
|
domtoimage?: any
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 动态导入所需库
|
// 动态导入所需库
|
||||||
let htmlToImageModule: any = null
|
let htmlToImageModule: any = null
|
||||||
@@ -369,18 +382,18 @@ async function captureWithCanvas(target: HTMLIFrameElement | Window): Promise<st
|
|||||||
// 将文档转换为SVG数据URL
|
// 将文档转换为SVG数据URL
|
||||||
const data = new XMLSerializer().serializeToString(document.documentElement)
|
const data = new XMLSerializer().serializeToString(document.documentElement)
|
||||||
const svg = new Blob([data], { type: "image/svg+xml" })
|
const svg = new Blob([data], { type: "image/svg+xml" })
|
||||||
const url = URL.createObjectURL(svg)
|
const svgUrl = URL.createObjectURL(svg)
|
||||||
|
|
||||||
// 等待图像加载
|
// 等待图像加载
|
||||||
await new Promise((resolve, reject) => {
|
await new Promise((resolve, reject) => {
|
||||||
img.onload = resolve
|
img.onload = resolve
|
||||||
img.onerror = reject
|
img.onerror = reject
|
||||||
img.src = url
|
img.src = svgUrl
|
||||||
})
|
})
|
||||||
|
|
||||||
// 绘制图像
|
// 绘制图像
|
||||||
ctx.drawImage(img, 0, 0, width, height)
|
ctx.drawImage(img, 0, 0, width, height)
|
||||||
URL.revokeObjectURL(url)
|
URL.revokeObjectURL(svgUrl)
|
||||||
|
|
||||||
// 返回数据URL
|
// 返回数据URL
|
||||||
return canvas.toDataURL("image/png")
|
return canvas.toDataURL("image/png")
|
||||||
|
|||||||
@@ -32,7 +32,6 @@
|
|||||||
"@radix-ui/react-tooltip": "latest",
|
"@radix-ui/react-tooltip": "latest",
|
||||||
"@tanstack/react-table": "latest",
|
"@tanstack/react-table": "latest",
|
||||||
"@tanstack/react-virtual": "latest",
|
"@tanstack/react-virtual": "latest",
|
||||||
"buffer": "latest",
|
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "latest",
|
"cmdk": "latest",
|
||||||
|
|||||||
27
pnpm-lock.yaml
generated
27
pnpm-lock.yaml
generated
@@ -77,9 +77,6 @@ importers:
|
|||||||
'@tanstack/react-virtual':
|
'@tanstack/react-virtual':
|
||||||
specifier: latest
|
specifier: latest
|
||||||
version: 3.13.12(react-dom@18.0.0(react@18.0.0))(react@18.0.0)
|
version: 3.13.12(react-dom@18.0.0(react@18.0.0))(react@18.0.0)
|
||||||
buffer:
|
|
||||||
specifier: latest
|
|
||||||
version: 6.0.3
|
|
||||||
class-variance-authority:
|
class-variance-authority:
|
||||||
specifier: ^0.7.1
|
specifier: ^0.7.1
|
||||||
version: 0.7.1
|
version: 0.7.1
|
||||||
@@ -1303,9 +1300,6 @@ packages:
|
|||||||
balanced-match@1.0.2:
|
balanced-match@1.0.2:
|
||||||
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
|
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
|
||||||
|
|
||||||
base64-js@1.5.1:
|
|
||||||
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
|
|
||||||
|
|
||||||
binary-extensions@2.3.0:
|
binary-extensions@2.3.0:
|
||||||
resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==}
|
resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
@@ -1325,9 +1319,6 @@ packages:
|
|||||||
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
|
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
buffer@6.0.3:
|
|
||||||
resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==}
|
|
||||||
|
|
||||||
busboy@1.6.0:
|
busboy@1.6.0:
|
||||||
resolution: {integrity: sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==}
|
resolution: {integrity: sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==}
|
||||||
engines: {node: '>=10.16.0'}
|
engines: {node: '>=10.16.0'}
|
||||||
@@ -2004,9 +1995,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==}
|
resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
ieee754@1.2.1:
|
|
||||||
resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==}
|
|
||||||
|
|
||||||
ignore@4.0.6:
|
ignore@4.0.6:
|
||||||
resolution: {integrity: sha512-cyFDKrqc/YdcWFniJhzI42+AzS+gNwmUzOSFcRCQYwySuBBBy/KjuxWLZ/FHEH6Moq1NizMOBWyTcv8O4OZIMg==}
|
resolution: {integrity: sha512-cyFDKrqc/YdcWFniJhzI42+AzS+gNwmUzOSFcRCQYwySuBBBy/KjuxWLZ/FHEH6Moq1NizMOBWyTcv8O4OZIMg==}
|
||||||
engines: {node: '>= 4'}
|
engines: {node: '>= 4'}
|
||||||
@@ -4323,8 +4311,6 @@ snapshots:
|
|||||||
|
|
||||||
balanced-match@1.0.2: {}
|
balanced-match@1.0.2: {}
|
||||||
|
|
||||||
base64-js@1.5.1: {}
|
|
||||||
|
|
||||||
binary-extensions@2.3.0: {}
|
binary-extensions@2.3.0: {}
|
||||||
|
|
||||||
brace-expansion@1.1.12:
|
brace-expansion@1.1.12:
|
||||||
@@ -4347,11 +4333,6 @@ snapshots:
|
|||||||
node-releases: 2.0.19
|
node-releases: 2.0.19
|
||||||
update-browserslist-db: 1.1.3(browserslist@4.25.1)
|
update-browserslist-db: 1.1.3(browserslist@4.25.1)
|
||||||
|
|
||||||
buffer@6.0.3:
|
|
||||||
dependencies:
|
|
||||||
base64-js: 1.5.1
|
|
||||||
ieee754: 1.2.1
|
|
||||||
|
|
||||||
busboy@1.6.0:
|
busboy@1.6.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
streamsearch: 1.1.0
|
streamsearch: 1.1.0
|
||||||
@@ -4834,7 +4815,7 @@ snapshots:
|
|||||||
eslint: 8.0.0
|
eslint: 8.0.0
|
||||||
eslint-import-resolver-node: 0.3.9
|
eslint-import-resolver-node: 0.3.9
|
||||||
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(eslint@8.0.0))(eslint@8.0.0)
|
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(eslint@8.0.0))(eslint@8.0.0)
|
||||||
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@6.21.0(eslint@8.0.0)(typescript@5.0.2))(eslint-import-resolver-typescript@3.10.1)(eslint@8.0.0)
|
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@6.21.0(eslint@8.0.0)(typescript@5.0.2))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(eslint@8.0.0))(eslint@8.0.0))(eslint@8.0.0)
|
||||||
eslint-plugin-jsx-a11y: 6.10.2(eslint@8.0.0)
|
eslint-plugin-jsx-a11y: 6.10.2(eslint@8.0.0)
|
||||||
eslint-plugin-react: 7.37.5(eslint@8.0.0)
|
eslint-plugin-react: 7.37.5(eslint@8.0.0)
|
||||||
eslint-plugin-react-hooks: 5.0.0-canary-7118f5dd7-20230705(eslint@8.0.0)
|
eslint-plugin-react-hooks: 5.0.0-canary-7118f5dd7-20230705(eslint@8.0.0)
|
||||||
@@ -4864,7 +4845,7 @@ snapshots:
|
|||||||
tinyglobby: 0.2.14
|
tinyglobby: 0.2.14
|
||||||
unrs-resolver: 1.11.1
|
unrs-resolver: 1.11.1
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@6.21.0(eslint@8.0.0)(typescript@5.0.2))(eslint-import-resolver-typescript@3.10.1)(eslint@8.0.0)
|
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@6.21.0(eslint@8.0.0)(typescript@5.0.2))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(eslint@8.0.0))(eslint@8.0.0))(eslint@8.0.0)
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
@@ -4879,7 +4860,7 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
eslint-plugin-import@2.32.0(@typescript-eslint/parser@6.21.0(eslint@8.0.0)(typescript@5.0.2))(eslint-import-resolver-typescript@3.10.1)(eslint@8.0.0):
|
eslint-plugin-import@2.32.0(@typescript-eslint/parser@6.21.0(eslint@8.0.0)(typescript@5.0.2))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(eslint@8.0.0))(eslint@8.0.0))(eslint@8.0.0):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@rtsao/scc': 1.1.0
|
'@rtsao/scc': 1.1.0
|
||||||
array-includes: 3.1.9
|
array-includes: 3.1.9
|
||||||
@@ -5222,8 +5203,6 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
safer-buffer: 2.1.2
|
safer-buffer: 2.1.2
|
||||||
|
|
||||||
ieee754@1.2.1: {}
|
|
||||||
|
|
||||||
ignore@4.0.6: {}
|
ignore@4.0.6: {}
|
||||||
|
|
||||||
ignore@5.3.2: {}
|
ignore@5.3.2: {}
|
||||||
|
|||||||
Reference in New Issue
Block a user