From 19a536b24398fdd67acf7e825eea02397d969350 Mon Sep 17 00:00:00 2001 From: haosenwang1018 Date: Wed, 25 Feb 2026 10:49:09 +0000 Subject: [PATCH 01/19] fix: replace bare except clauses with except Exception Bare `except:` catches BaseException including KeyboardInterrupt and SystemExit. Replaced 4 instances with `except Exception:`. --- modules/basic_shape_processor.py | 6 +++--- modules/xml_merger.py | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/modules/basic_shape_processor.py b/modules/basic_shape_processor.py index e5d331e..7091514 100644 --- a/modules/basic_shape_processor.py +++ b/modules/basic_shape_processor.py @@ -348,7 +348,7 @@ def extract_style_colors(image: np.ndarray, bbox: list) -> tuple: counts = np.bincount(labels.flatten()) dominant_idx = np.argmax(counts) fill_rgb = centers[dominant_idx].astype(int) - except: + except Exception: pass # 保持中位数结果 # --- 2. 提取描边色 (Stroke Color) --- @@ -540,7 +540,7 @@ def extract_color_with_mask(image: np.ndarray, bbox: list, mask: np.ndarray, counts = np.bincount(labels.flatten()) dominant_idx = np.argmax(counts) fill_rgb = centers[dominant_idx].astype(int) - except: + except Exception: fill_rgb = np.median(fill_pixels, axis=0).astype(int) else: fill_rgb = np.median(fill_pixels, axis=0).astype(int) @@ -1311,7 +1311,7 @@ def detect_rectangles_robust(cv2_image: np.ndarray, existing_elements: dict, con counts = np.bincount(labels.flatten()) dominant_idx = np.argmax(counts) fill_rgb = centers[dominant_idx].astype(int) - except: + except Exception: fill_rgb = np.median(pixels, axis=0).astype(int) else: fill_rgb = np.median(pixels, axis=0).astype(int) if len(pixels) > 0 else np.array([255, 255, 255]) diff --git a/modules/xml_merger.py b/modules/xml_merger.py index d748040..daa1be9 100644 --- a/modules/xml_merger.py +++ b/modules/xml_merger.py @@ -583,7 +583,7 @@ def merge_with_text_xml(self, x2=int(float(geom.get("x", 0))) + int(float(geom.get("width", 0))), y2=int(float(geom.get("y", 0))) + int(float(geom.get("height", 0))) ) - except: + except Exception: pass fragments.append(XMLFragment( From e6dde14762ed90b0d90a3a897e09108632bf4fb2 Mon Sep 17 00:00:00 2001 From: OpenClaw Bot Date: Thu, 5 Mar 2026 14:32:24 +0000 Subject: [PATCH 02/19] docs: add CONTRIBUTING.md --- CONTRIBUTING.md | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 CONTRIBUTING.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..404f7c3 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,38 @@ +# Contributing to Edit Banana + +Thank you for your interest in contributing! Here's how to get started. + +## Development Setup + +### Prerequisites + +- Python 3.8+ +- pip + +### Getting Started + +```bash +# Clone the repository +git clone https://github.com/BIT-DataLab/Edit-Banana.git +cd Edit-Banana + +# Install dependencies +pip install -r requirements.txt +``` + +## Making Changes + +1. **Fork** the repository +2. **Create a branch** from `main`: `git checkout -b fix/my-fix` +3. **Make your changes** and test locally +4. **Commit** with a clear message +5. **Push** and open a Pull Request + +## Reporting Issues + +- Use GitHub Issues for bug reports and feature requests +- Include steps to reproduce and expected vs actual behavior + +## License + +By contributing, you agree that your contributions will be licensed under the project's existing license. From 774b04f7f899c83ce4fd99fed6036bcb5aad236b Mon Sep 17 00:00:00 2001 From: OpenClaw Bot Date: Thu, 5 Mar 2026 15:02:26 +0000 Subject: [PATCH 03/19] docs: add CONTRIBUTING.md --- CONTRIBUTING.md | 28 +++++++++++----------------- 1 file changed, 11 insertions(+), 17 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 404f7c3..7e4df56 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,38 +1,32 @@ # Contributing to Edit Banana -Thank you for your interest in contributing! Here's how to get started. +Thanks for your interest in contributing. ## Development Setup ### Prerequisites -- Python 3.8+ -- pip +- Python 3.10+ +- Node.js 18+ -### Getting Started +### Quick Start ```bash -# Clone the repository git clone https://github.com/BIT-DataLab/Edit-Banana.git cd Edit-Banana - -# Install dependencies -pip install -r requirements.txt ``` -## Making Changes +## Pull Request Process -1. **Fork** the repository -2. **Create a branch** from `main`: `git checkout -b fix/my-fix` -3. **Make your changes** and test locally -4. **Commit** with a clear message -5. **Push** and open a Pull Request +1. Fork this repository +2. Create a branch from `main` +3. Keep changes focused and small +4. Open a PR with clear context and testing notes ## Reporting Issues -- Use GitHub Issues for bug reports and feature requests -- Include steps to reproduce and expected vs actual behavior +Please include reproduction steps, expected behavior, and environment details. ## License -By contributing, you agree that your contributions will be licensed under the project's existing license. +By contributing, you agree contributions are licensed under the repository's existing license. From a0736f51e3422dcb08f0e32916d7a38aaf8168ec Mon Sep 17 00:00:00 2001 From: jeff Date: Sat, 14 Mar 2026 20:31:17 +0800 Subject: [PATCH 04/19] Integrate landing page frontend from local main Adds complete Next.js 16 frontend for EditBanana: - Landing page with Hero, Upload, Features, Examples sections - File upload component with drag-and-drop - Real-time conversion progress via WebSocket - Result download functionality - Responsive design with Tailwind CSS v4 - Framer Motion animations - Vercel Analytics integration Co-Authored-By: Claude Opus 4.6 --- apps/web/package.json | 34 ++++ apps/web/src/app/layout.tsx | 36 ++++ apps/web/src/app/page.tsx | 21 ++ .../web/src/app/sections/example-showcase.tsx | 170 ++++++++++++++++ apps/web/src/app/sections/features.tsx | 85 ++++++++ apps/web/src/app/sections/footer.tsx | 44 +++++ apps/web/src/app/sections/hero.tsx | 69 +++++++ apps/web/src/app/sections/navbar.tsx | 30 +++ apps/web/src/app/sections/upload-section.tsx | 186 ++++++++++++++++++ apps/web/src/components/error-boundary.tsx | 82 ++++++++ apps/web/src/components/loading.tsx | 63 ++++++ apps/web/src/components/navbar.tsx | 91 +++++++++ .../src/components/progress/progress-bar.tsx | 121 ++++++++++++ apps/web/src/components/ui/button.tsx | 38 ++++ apps/web/src/components/ui/card.tsx | 70 +++++++ .../web/src/components/upload/file-upload.tsx | 136 +++++++++++++ apps/web/src/lib/api.ts | 139 +++++++++++++ apps/web/src/lib/config.ts | 5 + apps/web/src/lib/storage.ts | 82 ++++++++ apps/web/src/lib/types.ts | 34 ++++ apps/web/src/lib/utils.ts | 6 + apps/web/src/lib/websocket.ts | 126 ++++++++++++ 22 files changed, 1668 insertions(+) create mode 100644 apps/web/package.json create mode 100644 apps/web/src/app/layout.tsx create mode 100644 apps/web/src/app/page.tsx create mode 100644 apps/web/src/app/sections/example-showcase.tsx create mode 100644 apps/web/src/app/sections/features.tsx create mode 100644 apps/web/src/app/sections/footer.tsx create mode 100644 apps/web/src/app/sections/hero.tsx create mode 100644 apps/web/src/app/sections/navbar.tsx create mode 100644 apps/web/src/app/sections/upload-section.tsx create mode 100644 apps/web/src/components/error-boundary.tsx create mode 100644 apps/web/src/components/loading.tsx create mode 100644 apps/web/src/components/navbar.tsx create mode 100644 apps/web/src/components/progress/progress-bar.tsx create mode 100644 apps/web/src/components/ui/button.tsx create mode 100644 apps/web/src/components/ui/card.tsx create mode 100644 apps/web/src/components/upload/file-upload.tsx create mode 100644 apps/web/src/lib/api.ts create mode 100644 apps/web/src/lib/config.ts create mode 100644 apps/web/src/lib/storage.ts create mode 100644 apps/web/src/lib/types.ts create mode 100644 apps/web/src/lib/utils.ts create mode 100644 apps/web/src/lib/websocket.ts diff --git a/apps/web/package.json b/apps/web/package.json new file mode 100644 index 0000000..120756d --- /dev/null +++ b/apps/web/package.json @@ -0,0 +1,34 @@ +{ + "name": "web", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "eslint" + }, + "dependencies": { + "@vercel/analytics": "^1.5.0", + "axios": "^1.13.6", + "clsx": "^2.1.1", + "framer-motion": "^12.35.2", + "lucide-react": "^0.577.0", + "next": "16.1.6", + "react": "19.2.3", + "react-dom": "19.2.3", + "react-dropzone": "^15.0.0", + "socket.io-client": "^4.8.3", + "tailwind-merge": "^3.5.0" + }, + "devDependencies": { + "@tailwindcss/postcss": "^4", + "@types/node": "^20", + "@types/react": "^19", + "@types/react-dom": "^19", + "eslint": "^9", + "eslint-config-next": "16.1.6", + "tailwindcss": "^4", + "typescript": "^5" + } +} diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx new file mode 100644 index 0000000..c9216a3 --- /dev/null +++ b/apps/web/src/app/layout.tsx @@ -0,0 +1,36 @@ +import type { Metadata } from "next"; +import { Geist, Geist_Mono } from "next/font/google"; +import { Analytics } from "@vercel/analytics/react"; +import "./globals.css"; + +const geistSans = Geist({ + variable: "--font-geist-sans", + subsets: ["latin"], +}); + +const geistMono = Geist_Mono({ + variable: "--font-geist-mono", + subsets: ["latin"], +}); + +export const metadata: Metadata = { + title: "Create Next App", + description: "Generated by create next app", +}; + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( + + + {children} + + + + ); +} diff --git a/apps/web/src/app/page.tsx b/apps/web/src/app/page.tsx new file mode 100644 index 0000000..febe86c --- /dev/null +++ b/apps/web/src/app/page.tsx @@ -0,0 +1,21 @@ +import { Navbar } from "./sections/navbar" +import { Hero } from "./sections/hero" +import { UploadSection } from "./sections/upload-section" +import { Features } from "./sections/features" +import { ExampleShowcase } from "./sections/example-showcase" +import { Footer } from "./sections/footer" + +export default function Home() { + return ( +
+ +
+ + + + +
+
+
+ ) +} diff --git a/apps/web/src/app/sections/example-showcase.tsx b/apps/web/src/app/sections/example-showcase.tsx new file mode 100644 index 0000000..1fba519 --- /dev/null +++ b/apps/web/src/app/sections/example-showcase.tsx @@ -0,0 +1,170 @@ +"use client" + +import { useState } from "react" +import { ArrowRight, ZoomIn } from "lucide-react" +import { motion } from "framer-motion" + +interface Example { + id: number + title: string + description: string + beforeImage: string + afterImage: string +} + +const examples: Example[] = [ + { + id: 1, + title: "Flowchart Conversion", + description: "Transform static flowchart images into editable diagrams", + beforeImage: "https://placehold.co/600x400/f3f4f6/9ca3af?text=Before:+Static+Flowchart+Image", + afterImage: "https://placehold.co/600x400/fef3c7/d97706?text=After:+Editable+Diagram", + }, + { + id: 2, + title: "Architecture Diagram", + description: "Convert complex architecture diagrams to editable format", + beforeImage: "https://placehold.co/600x400/f3f4f6/9ca3af?text=Before:+Architecture+Image", + afterImage: "https://placehold.co/600x400/fef3c7/d97706?text=After:+Editable+Architecture", + }, + { + id: 3, + title: "Network Topology", + description: "Turn network diagrams into fully editable assets", + beforeImage: "https://placehold.co/600x400/f3f4f6/9ca3af?text=Before:+Network+Image", + afterImage: "https://placehold.co/600x400/fef3c7/d97706?text=After:+Editable+Network", + }, +] + +export function ExampleShowcase() { + const [activeExample, setActiveExample] = useState(examples[0]) + const [isHovering, setIsHovering] = useState(false) + + return ( +
+
+
+

+ See the Magic in Action +

+

+ Our AI-powered conversion transforms static images into fully editable diagrams. + Here are some examples of what EditBanana can do. +

+
+ + {/* Example Selector */} +
+ {examples.map((example) => ( + + ))} +
+ + {/* Before/After Comparison */} + +
+

+ {activeExample.title} +

+

{activeExample.description}

+
+ +
+ {/* Before */} +
+
+ + Before + +
+
setIsHovering(true)} + onMouseLeave={() => setIsHovering(false)} + > + {`${activeExample.title} +
+ +
+
+
+

+ Static image that cannot be edited +

+
+
+ + {/* Arrow indicator on mobile */} +
+ +
+ + {/* After */} +
+
+ + After + +
+
+ {`${activeExample.title} +
+ +
+
+
+

+ Fully editable diagram - modify shapes, text, and connections +

+
+
+
+
+ + {/* Trust badges */} +
+
+
99%
+
Accuracy Rate
+
+
+
10s
+
Avg. Processing
+
+
+
50K+
+
Diagrams Converted
+
+
+
4.9★
+
User Rating
+
+
+
+
+ ) +} diff --git a/apps/web/src/app/sections/features.tsx b/apps/web/src/app/sections/features.tsx new file mode 100644 index 0000000..5ef2c88 --- /dev/null +++ b/apps/web/src/app/sections/features.tsx @@ -0,0 +1,85 @@ +"use client" + +import { + Wand2, + FileType, + Zap, + Download, + Shield, + Globe, +} from "lucide-react" + +const features = [ + { + name: "AI-Powered Recognition", + description: + "Advanced SAM3 segmentation and OCR technology accurately identify shapes, text, and arrows in your images.", + icon: Wand2, + }, + { + name: "Multiple Formats", + description: + "Export to Draw.io XML, PowerPoint PPTX, or other editable formats. Compatible with all major diagram tools.", + icon: FileType, + }, + { + name: "Fast Processing", + description: + "Get your editable diagrams in seconds. Our optimized pipeline ensures quick turnaround times.", + icon: Zap, + }, + { + name: "Easy Export", + description: + "One-click download of your converted diagrams. No registration or account required.", + icon: Download, + }, + { + name: "Privacy First", + description: + "Your files are processed securely and automatically deleted after conversion. We never store your data.", + icon: Shield, + }, + { + name: "Works Everywhere", + description: + "Access from any device with a web browser. No software installation needed.", + icon: Globe, + }, +] + +export function Features() { + return ( +
+
+
+

+ Everything You Need +

+

+ Powerful features to make diagram conversion simple and accurate +

+
+ +
+ {features.map((feature) => ( +
+
+ +
+

+ {feature.name} +

+

+ {feature.description} +

+
+ ))} +
+
+
+ ) +} diff --git a/apps/web/src/app/sections/footer.tsx b/apps/web/src/app/sections/footer.tsx new file mode 100644 index 0000000..e2fd434 --- /dev/null +++ b/apps/web/src/app/sections/footer.tsx @@ -0,0 +1,44 @@ +"use client" + +import { Banana, Github, Twitter } from "lucide-react" + +export function Footer() { + return ( +
+
+
+ {/* Logo */} +
+
+ +
+ Edit Banana +
+ + {/* Links */} + + + {/* Copyright */} +

+ © {new Date().getFullYear()} Edit Banana. All rights reserved. +

+
+
+
+ ) +} diff --git a/apps/web/src/app/sections/hero.tsx b/apps/web/src/app/sections/hero.tsx new file mode 100644 index 0000000..6e465b4 --- /dev/null +++ b/apps/web/src/app/sections/hero.tsx @@ -0,0 +1,69 @@ +"use client" + +import { ArrowRight, Sparkles } from "lucide-react" +import { Button } from "@/components/ui/button" + +export function Hero() { + const scrollToUpload = () => { + document.getElementById("upload")?.scrollIntoView({ behavior: "smooth" }) + } + + return ( +
+ {/* Background gradient */} +
+
+
+ +
+
+ {/* Badge */} +
+ + AI-Powered Diagram Conversion +
+ + {/* Heading */} +

+ Turn Images into + + Editable Diagrams + +

+ + {/* Subheading */} +

+ Upload any image or PDF and convert it to editable Draw.io XML or PowerPoint format. + Powered by SAM3 segmentation and OCR technology. +

+ + {/* CTA Button */} + + + {/* Stats */} +
+
+
10s
+
Avg. Processing
+
+
+
99%
+
Accuracy
+
+
+
Free
+
to Try
+
+
+
+
+
+ ) +} diff --git a/apps/web/src/app/sections/navbar.tsx b/apps/web/src/app/sections/navbar.tsx new file mode 100644 index 0000000..cf2d1e3 --- /dev/null +++ b/apps/web/src/app/sections/navbar.tsx @@ -0,0 +1,30 @@ +"use client" + +import { Banana } from "lucide-react" + +export function Navbar() { + return ( + + ) +} diff --git a/apps/web/src/app/sections/upload-section.tsx b/apps/web/src/app/sections/upload-section.tsx new file mode 100644 index 0000000..822a455 --- /dev/null +++ b/apps/web/src/app/sections/upload-section.tsx @@ -0,0 +1,186 @@ +"use client" + +import { useState } from "react" +import { Loader2, Download, CheckCircle, AlertCircle } from "lucide-react" +import { track } from "@vercel/analytics" +import { Button } from "@/components/ui/button" +import { FileUpload } from "@/components/upload/file-upload" +import { uploadFile, getJobStatus, downloadResult, APIError } from "@/lib/api" +import type { Job } from "@/lib/types" + +export function UploadSection() { + const [selectedFile, setSelectedFile] = useState(null) + const [jobId, setJobId] = useState(null) + const [jobStatus, setJobStatus] = useState(null) + const [progress, setProgress] = useState(0) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + const [resultUrl, setResultUrl] = useState(null) + + const handleFileSelect = (file: File) => { + setSelectedFile(file) + setError(null) + setJobId(null) + setJobStatus(null) + setResultUrl(null) + track("file_selected", { filename: file.name, size: file.size }) + } + + const handleConvert = async () => { + if (!selectedFile) return + + setLoading(true) + setError(null) + + try { + // Upload file + track("conversion_started", { filename: selectedFile.name }) + const response = await uploadFile(selectedFile, true, false) + setJobId(response.job_id) + setJobStatus("pending") + + // Poll for status + pollJobStatus(response.job_id) + } catch (err) { + setLoading(false) + if (err instanceof APIError) { + setError(err.message) + } else { + setError("Conversion failed. Please try again.") + } + } + } + + const pollJobStatus = async (id: string) => { + const interval = setInterval(async () => { + try { + const job = await getJobStatus(id) + setJobStatus(job.status) + + // Calculate progress + if (job.total_steps && job.total_steps > 0 && job.current_step !== undefined) { + setProgress(Math.round((job.current_step / job.total_steps) * 100)) + } + + if (job.status === "completed") { + clearInterval(interval) + setLoading(false) + setProgress(100) + setResultUrl(`${process.env.NEXT_PUBLIC_API_URL || "https://editbanana.anxin6.cn"}/api/v1/jobs/${id}/result`) + track("conversion_completed", { job_id: id }) + } else if (job.status === "failed" || job.status === "cancelled") { + clearInterval(interval) + setLoading(false) + setError(job.error || "Conversion failed") + } + } catch (err) { + clearInterval(interval) + setLoading(false) + setError("Failed to get job status") + } + }, 2000) + } + + const handleDownload = async () => { + if (!jobId) return + + try { + track("download_clicked", { job_id: jobId, filename: selectedFile?.name }) + const blob = await downloadResult(jobId) + const url = window.URL.createObjectURL(blob) + const a = document.createElement("a") + a.href = url + a.download = `converted-${selectedFile?.name || "diagram.drawio"}` + document.body.appendChild(a) + a.click() + window.URL.revokeObjectURL(url) + document.body.removeChild(a) + } catch (err) { + setError("Download failed") + } + } + + return ( +
+
+
+

+ Upload Your Image or PDF +

+

+ We'll convert it to an editable diagram in seconds +

+
+ +
+ + + {selectedFile && !jobStatus && ( + + )} + + {loading && jobStatus && jobStatus !== "completed" && ( +
+
+ + + {jobStatus === "pending" && "Waiting to process..."} + {jobStatus === "processing" && "Converting your diagram..."} + +
+
+
+
+

{progress}% complete

+
+ )} + + {jobStatus === "completed" && ( +
+
+ + + Conversion Complete! + +
+

+ Your diagram has been converted and is ready to download. +

+ +
+ )} + + {error && ( +
+ +

{error}

+
+ )} +
+
+
+ ) +} diff --git a/apps/web/src/components/error-boundary.tsx b/apps/web/src/components/error-boundary.tsx new file mode 100644 index 0000000..c7bafa0 --- /dev/null +++ b/apps/web/src/components/error-boundary.tsx @@ -0,0 +1,82 @@ +"use client" + +import { useEffect, useState } from "react" +import { AlertCircle, RefreshCw } from "lucide-react" +import { Button } from "@/components/ui/button" + +interface ErrorBoundaryProps { + children: React.ReactNode +} + +interface ErrorState { + hasError: boolean + error?: Error +} + +export function ErrorBoundary({ children }: ErrorBoundaryProps) { + const [error, setError] = useState({ hasError: false }) + + useEffect(() => { + const handleError = (event: ErrorEvent) => { + console.error("Error caught by boundary:", event.error) + setError({ hasError: true, error: event.error }) + } + + window.addEventListener("error", handleError) + return () => window.removeEventListener("error", handleError) + }, []) + + const handleReset = () => { + setError({ hasError: false }) + window.location.reload() + } + + if (error.hasError) { + return ( +
+
+
+ +
+

+ 出错了 +

+

+ {error.error?.message || "页面加载时发生错误"} +

+ +
+
+ ) + } + + return <>{children} +} + +// API错误处理 +export class APIError extends Error { + constructor( + message: string, + public statusCode: number, + public response?: Response + ) { + super(message) + this.name = "APIError" + } +} + +export async function handleAPIError(response: Response): Promise { + if (!response.ok) { + let message = "请求失败" + try { + const data = await response.json() + message = data.detail || data.message || message + } catch { + message = response.statusText || message + } + throw new APIError(message, response.status, response) + } +} diff --git a/apps/web/src/components/loading.tsx b/apps/web/src/components/loading.tsx new file mode 100644 index 0000000..d644740 --- /dev/null +++ b/apps/web/src/components/loading.tsx @@ -0,0 +1,63 @@ +"use client" + +import { motion } from "framer-motion" + +interface LoadingSpinnerProps { + size?: "sm" | "md" | "lg" + className?: string +} + +export function LoadingSpinner({ size = "md", className = "" }: LoadingSpinnerProps) { + const sizeClasses = { + sm: "w-4 h-4", + md: "w-8 h-8", + lg: "w-12 h-12", + } + + return ( + + + + + + + ) +} + +export function LoadingPage() { + return ( +
+ +

加载中...

+
+ ) +} + +export function LoadingCard() { + return ( +
+ +

处理中...

+
+ ) +} diff --git a/apps/web/src/components/navbar.tsx b/apps/web/src/components/navbar.tsx new file mode 100644 index 0000000..681820c --- /dev/null +++ b/apps/web/src/components/navbar.tsx @@ -0,0 +1,91 @@ +"use client" + +import Link from "next/link" +import { useState } from "react" +import { Menu, X, Github, Banana } from "lucide-react" +import { Button } from "@/components/ui/button" + +export function Navbar() { + const [isOpen, setIsOpen] = useState(false) + + return ( + + ) +} diff --git a/apps/web/src/components/progress/progress-bar.tsx b/apps/web/src/components/progress/progress-bar.tsx new file mode 100644 index 0000000..2b5859b --- /dev/null +++ b/apps/web/src/components/progress/progress-bar.tsx @@ -0,0 +1,121 @@ +"use client" + +import { motion } from "framer-motion" +import { cn } from "@/lib/utils" +import { Check, Loader2 } from "lucide-react" + +interface ProgressBarProps { + progress: number + stage: string + message: string + isComplete?: boolean +} + +const stages = [ + { key: "preprocess", label: "预处理", icon: "1" }, + { key: "text_extraction", label: "文字提取", icon: "2" }, + { key: "segmentation", label: "图像分割", icon: "3" }, + { key: "processing", label: "元素处理", icon: "4" }, + { key: "xml_generation", label: "生成XML", icon: "5" }, +] + +export function ProgressBar({ + progress, + stage, + message, + isComplete, +}: ProgressBarProps) { + const getStageStatus = (stageLabel: string) => { + const stageIndex = stages.findIndex((s) => s.label === stage) + const currentIndex = stages.findIndex((s) => s.label === stageLabel) + + if (isComplete) return "complete" + if (currentIndex < stageIndex) return "complete" + if (currentIndex === stageIndex) return "active" + return "pending" + } + + return ( +
+ {/* 进度条 */} +
+ +
+ + {/* 进度信息 */} +
+ + {stage} + + + {message} + +
+ + {/* 阶段指示器 */} +
+
+ {stages.map((s, index) => { + const status = getStageStatus(s.label) + return ( +
+ + {status === "complete" ? ( + + ) : status === "active" ? ( + + ) : ( + s.icon + )} + + + {s.label} + +
+ ) + })} +
+
+ + {/* 百分比 */} +
+ {progress}% +
+
+ ) +} diff --git a/apps/web/src/components/ui/button.tsx b/apps/web/src/components/ui/button.tsx new file mode 100644 index 0000000..d4f95a5 --- /dev/null +++ b/apps/web/src/components/ui/button.tsx @@ -0,0 +1,38 @@ +import { cn } from "@/lib/utils" +import { ButtonHTMLAttributes, forwardRef } from "react" + +interface ButtonProps extends ButtonHTMLAttributes { + variant?: "primary" | "secondary" | "outline" | "ghost" + size?: "sm" | "md" | "lg" +} + +const Button = forwardRef( + ({ className, variant = "primary", size = "md", ...props }, ref) => { + return ( + + +
+ {preview ? ( +
+ Preview +
+ ) : ( +
+ +
+ )} +
+

+ {selectedFile.name} +

+

+ {(selectedFile.size / 1024 / 1024).toFixed(2)} MB +

+
+
+ + )} + + {fileRejections.length > 0 && ( +
+

+ 文件格式不支持或超过大小限制 +

+
+ )} + + ) +} diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts new file mode 100644 index 0000000..13e5bc0 --- /dev/null +++ b/apps/web/src/lib/api.ts @@ -0,0 +1,139 @@ +import { API_BASE_URL } from "./config" +import { UploadResponse, Job } from "./types" +import { historyStorage, HistoryItem } from "./storage" + +// API错误类 +export class APIError extends Error { + constructor( + message: string, + public statusCode?: number, + public response?: Response + ) { + super(message) + this.name = "APIError" + } +} + +// 统一处理响应 +async function handleResponse(response: Response): Promise { + if (!response.ok) { + let message = "请求失败" + let data: any = {} + try { + data = await response.json() + message = data.detail || data.message || message + } catch { + message = response.statusText || message + } + throw new APIError(message, response.status, response) + } + return response.json() +} + +// 上传文件 +export async function uploadFile( + file: File, + withText: boolean = true, + withRefinement: boolean = false +): Promise { + const formData = new FormData() + formData.append("file", file) + formData.append("with_text", String(withText)) + formData.append("with_refinement", String(withRefinement)) + + try { + const response = await fetch(`${API_BASE_URL}/api/v1/convert`, { + method: "POST", + body: formData, + }) + + const data = await handleResponse(response) + + // 添加到历史记录 + historyStorage.add({ + id: data.job_id, + filename: file.name, + status: "pending", + created_at: new Date().toISOString(), + }) + + return data + } catch (error) { + if (error instanceof APIError) { + throw error + } + throw new APIError(error instanceof Error ? error.message : "上传失败") + } +} + +// 获取任务状态 +export async function getJobStatus(jobId: string): Promise { + try { + const response = await fetch(`${API_BASE_URL}/api/v1/jobs/${jobId}`) + const data = await handleResponse(response) + + // 更新历史记录 + historyStorage.update(data.id, { + status: data.status, + completed_at: data.completed_at, + error: data.error, + }) + + return data + } catch (error) { + if (error instanceof APIError) { + throw error + } + throw new APIError(error instanceof Error ? error.message : "获取任务状态失败") + } +} + +// 下载结果 +export async function downloadResult(jobId: string): Promise { + try { + const response = await fetch(`${API_BASE_URL}/api/v1/jobs/${jobId}/result`) + + if (!response.ok) { + await handleResponse(response) + } + + return response.blob() + } catch (error) { + if (error instanceof APIError) { + throw error + } + throw new APIError(error instanceof Error ? error.message : "下载失败") + } +} + +// 取消任务 +export async function cancelJob(jobId: string): Promise { + try { + const response = await fetch(`${API_BASE_URL}/api/v1/jobs/${jobId}`, { + method: "DELETE", + }) + + await handleResponse(response) + + // 更新历史记录 + historyStorage.update(jobId, { status: "cancelled" as Job["status"] }) + } catch (error) { + if (error instanceof APIError) { + throw error + } + throw new APIError(error instanceof Error ? error.message : "取消失败") + } +} + +// 批量获取任务状态(用于历史页面刷新) +export async function refreshJobsStatus(jobIds: string[]): Promise { + const promises = jobIds.map((id) => + getJobStatus(id).catch((error) => { + console.error(`获取任务 ${id} 状态失败:`, error) + return null + }) + ) + + const results = await Promise.all(promises) + return results.filter((job): job is Job => job !== null) +} diff --git a/apps/web/src/lib/config.ts b/apps/web/src/lib/config.ts new file mode 100644 index 0000000..d2d0d27 --- /dev/null +++ b/apps/web/src/lib/config.ts @@ -0,0 +1,5 @@ +// API配置 +export const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000" + +// WebSocket配置 +export const WS_BASE_URL = process.env.NEXT_PUBLIC_WS_URL || "ws://localhost:8000" diff --git a/apps/web/src/lib/storage.ts b/apps/web/src/lib/storage.ts new file mode 100644 index 0000000..b761efb --- /dev/null +++ b/apps/web/src/lib/storage.ts @@ -0,0 +1,82 @@ +import { Job } from "./types" + +const STORAGE_KEY = "edit-banana-history" +const MAX_HISTORY_ITEMS = 50 + +export interface HistoryItem { + id: string + filename: string + status: Job["status"] + created_at: string + completed_at?: string + error?: string +} + +export const historyStorage = { + // 获取所有历史记录 + getAll(): HistoryItem[] { + try { + const stored = localStorage.getItem(STORAGE_KEY) + if (stored) { + return JSON.parse(stored) + } + } catch (e) { + console.error("读取历史记录失败:", e) + } + return [] + }, + + // 添加新记录 + add(item: HistoryItem): void { + try { + const history = this.getAll() + // 检查是否已存在 + const existingIndex = history.findIndex((h) => h.id === item.id) + if (existingIndex >= 0) { + history[existingIndex] = item + } else { + history.unshift(item) + } + // 限制数量 + if (history.length > MAX_HISTORY_ITEMS) { + history.pop() + } + localStorage.setItem(STORAGE_KEY, JSON.stringify(history)) + } catch (e) { + console.error("保存历史记录失败:", e) + } + }, + + // 删除记录 + remove(id: string): void { + try { + const history = this.getAll().filter((h) => h.id !== id) + localStorage.setItem(STORAGE_KEY, JSON.stringify(history)) + } catch (e) { + console.error("删除历史记录失败:", e) + } + }, + + // 清空记录 + clear(): void { + try { + localStorage.removeItem(STORAGE_KEY) + } catch (e) { + console.error("清空历史记录失败:", e) + } + }, + + // 更新记录状态 + update(id: string, updates: Partial): void { + try { + const history = this.getAll() + const index = history.findIndex((h) => h.id === id) + if (index >= 0) { + history[index] = { ...history[index], ...updates } + localStorage.setItem(STORAGE_KEY, JSON.stringify(history)) + } + } catch (e) { + console.error("更新历史记录失败:", e) + } + }, +} diff --git a/apps/web/src/lib/types.ts b/apps/web/src/lib/types.ts new file mode 100644 index 0000000..3d99ea8 --- /dev/null +++ b/apps/web/src/lib/types.ts @@ -0,0 +1,34 @@ +// 任务状态类型 +export interface Job { + id: string + filename: string + status: "pending" | "processing" | "completed" | "failed" | "cancelled" + progress: number + stage: string + message: string + output_path?: string + error?: string + created_at: string + completed_at?: string + total_steps?: number + current_step?: number +} + +// WebSocket消息类型 +export interface ProgressMessage { + type: "connected" | "progress" | "pong" | "error" + job_id?: string + status?: string + progress?: number + stage?: string + message?: string + timestamp?: string +} + +// 上传响应 +export interface UploadResponse { + success: boolean + job_id: string + filename: string + ws_url: string +} diff --git a/apps/web/src/lib/utils.ts b/apps/web/src/lib/utils.ts new file mode 100644 index 0000000..d084cca --- /dev/null +++ b/apps/web/src/lib/utils.ts @@ -0,0 +1,6 @@ +import { type ClassValue, clsx } from "clsx" +import { twMerge } from "tailwind-merge" + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)) +} diff --git a/apps/web/src/lib/websocket.ts b/apps/web/src/lib/websocket.ts new file mode 100644 index 0000000..aeb59fa --- /dev/null +++ b/apps/web/src/lib/websocket.ts @@ -0,0 +1,126 @@ +"use client" + +import { useState, useEffect, useRef, useCallback } from "react" +import { WS_BASE_URL } from "./config" +import { ProgressMessage, Job } from "./types" + +interface UseWebSocketOptions { + jobId: string | null + onProgress?: (progress: number, stage: string, message: string) => void + onComplete?: (job: Job) => void + onError?: (error: string) => void +} + +export function useWebSocket({ + jobId, + onProgress, + onComplete, + onError, +}: UseWebSocketOptions) { + const [isConnected, setIsConnected] = useState(false) + const [progress, setProgress] = useState(0) + const [stage, setStage] = useState("") + const [message, setMessage] = useState("") + const wsRef = useRef(null) + const reconnectTimeoutRef = useRef(null) + + const connect = useCallback(() => { + if (!jobId) return + + // 清理旧连接 + if (wsRef.current) { + wsRef.current.close() + } + + const ws = new WebSocket(`${WS_BASE_URL}/ws/jobs/${jobId}/progress`) + wsRef.current = ws + + ws.onopen = () => { + setIsConnected(true) + } + + ws.onmessage = (event) => { + try { + const data: ProgressMessage = JSON.parse(event.data) + + if (data.type === "progress") { + setProgress(data.progress || 0) + setStage(data.stage || "") + setMessage(data.message || "") + onProgress?.(data.progress || 0, data.stage || "", data.message || "") + + // 检查是否完成 + if (data.progress === 100) { + // 延迟获取最终状态 + setTimeout(async () => { + const response = await fetch(`/api/jobs/${jobId}`) + if (response.ok) { + const job = await response.json() + onComplete?.(job) + } + }, 500) + } + } else if (data.type === "connected") { + setIsConnected(true) + if (data.progress) setProgress(data.progress) + if (data.stage) setStage(data.stage) + } + } catch (err) { + console.error("WebSocket消息解析失败:", err) + } + } + + ws.onclose = () => { + setIsConnected(false) + } + + ws.onerror = (error) => { + console.error("WebSocket错误:", error) + onError?.("连接错误") + setIsConnected(false) + } + }, [jobId, onProgress, onComplete, onError]) + + const disconnect = useCallback(() => { + if (wsRef.current) { + wsRef.current.close() + wsRef.current = null + } + if (reconnectTimeoutRef.current) { + clearTimeout(reconnectTimeoutRef.current) + } + setIsConnected(false) + }, []) + + useEffect(() => { + if (jobId) { + connect() + } + + return () => { + disconnect() + } + }, [jobId, connect, disconnect]) + + // 定期发送ping保持连接 + useEffect(() => { + if (!isConnected || !wsRef.current) return + + const pingInterval = setInterval(() => { + if (wsRef.current?.readyState === WebSocket.OPEN) { + wsRef.current.send("ping") + } + }, 30000) + + return () => clearInterval(pingInterval) + }, [isConnected]) + + return { + isConnected, + progress, + stage, + message, + connect, + disconnect, + } +} From 06d25b2fe4cb467e82aa2e2fe03dbe1d2c924218 Mon Sep 17 00:00:00 2001 From: jeff Date: Sat, 14 Mar 2026 20:37:27 +0800 Subject: [PATCH 05/19] Add TODO.md to .gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index f05287c..9386099 100644 --- a/.gitignore +++ b/.gitignore @@ -50,3 +50,4 @@ sam3_src/ # Local processing & debug arrow_processing/ debug_output/ +TODO.md From 206bbafb3ad8f4810e7ba21959782f909d624b86 Mon Sep 17 00:00:00 2001 From: jeff Date: Sat, 14 Mar 2026 21:10:06 +0800 Subject: [PATCH 06/19] feat(seo): comprehensive SEO optimization for organic traffic - Update metadata with optimized title, description, keywords - Add Open Graph tags for social sharing - Add Twitter Card tags - Add JSON-LD structured data for SoftwareApplication - Add canonical URL configuration - Create sitemap.ts with main pages - Create robots.ts with crawl rules - Fix static export for robots.txt and sitemap.xml - Add missing tsconfig.json, next.config.ts, globals.css Co-Authored-By: Claude Opus 4.6 --- apps/web/next-env.d.ts | 6 +++ apps/web/next.config.ts | 11 +++++ apps/web/postcss.config.mjs | 8 ++++ apps/web/src/app/globals.css | 23 +++++++++++ apps/web/src/app/layout.tsx | 78 +++++++++++++++++++++++++++++++++++- apps/web/src/app/robots.ts | 14 +++++++ apps/web/src/app/sitemap.ts | 32 +++++++++++++++ apps/web/tsconfig.json | 41 +++++++++++++++++++ 8 files changed, 211 insertions(+), 2 deletions(-) create mode 100644 apps/web/next-env.d.ts create mode 100644 apps/web/next.config.ts create mode 100644 apps/web/postcss.config.mjs create mode 100644 apps/web/src/app/globals.css create mode 100644 apps/web/src/app/robots.ts create mode 100644 apps/web/src/app/sitemap.ts create mode 100644 apps/web/tsconfig.json diff --git a/apps/web/next-env.d.ts b/apps/web/next-env.d.ts new file mode 100644 index 0000000..9edff1c --- /dev/null +++ b/apps/web/next-env.d.ts @@ -0,0 +1,6 @@ +/// +/// +import "./.next/types/routes.d.ts"; + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts new file mode 100644 index 0000000..3fae878 --- /dev/null +++ b/apps/web/next.config.ts @@ -0,0 +1,11 @@ +import type { NextConfig } from "next"; + +const nextConfig: NextConfig = { + output: 'export', + distDir: 'dist', + images: { + unoptimized: true, + }, +}; + +export default nextConfig; diff --git a/apps/web/postcss.config.mjs b/apps/web/postcss.config.mjs new file mode 100644 index 0000000..5d6d845 --- /dev/null +++ b/apps/web/postcss.config.mjs @@ -0,0 +1,8 @@ +/** @type {import('postcss-load-config').Config} */ +const config = { + plugins: { + '@tailwindcss/postcss': {}, + }, +}; + +export default config; diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css new file mode 100644 index 0000000..f38c098 --- /dev/null +++ b/apps/web/src/app/globals.css @@ -0,0 +1,23 @@ +@import "tailwindcss"; + +:root { + --background: #ffffff; + --foreground: #171717; +} + +@media (prefers-color-scheme: dark) { + :root { + --background: #0a0a0a; + --foreground: #ededed; + } +} + +html { + scroll-behavior: smooth; +} + +body { + color: var(--foreground); + background: var(--background); + font-family: var(--font-geist-sans), system-ui, sans-serif; +} diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx index c9216a3..26f28b7 100644 --- a/apps/web/src/app/layout.tsx +++ b/apps/web/src/app/layout.tsx @@ -14,8 +14,76 @@ const geistMono = Geist_Mono({ }); export const metadata: Metadata = { - title: "Create Next App", - description: "Generated by create next app", + title: "EditBanana - Convert Images to Editable Diagrams | AI-Powered", + description: "Transform static diagrams, flowcharts, and architecture images into fully editable DrawIO files. AI-powered conversion with 99% accuracy. Free to try!", + keywords: "diagram converter, image to editable, flowchart OCR, architecture diagram tool, diagram to drawio, convert image to diagram", + authors: [{ name: "BIT DataLab" }], + creator: "BIT DataLab", + publisher: "EditBanana", + metadataBase: new URL("https://editbanana.anxin6.cn"), + alternates: { + canonical: "/", + }, + openGraph: { + type: "website", + locale: "en_US", + url: "https://editbanana.anxin6.cn", + siteName: "EditBanana", + title: "EditBanana - Convert Images to Editable Diagrams", + description: "Transform static diagrams into fully editable DrawIO files with AI. 99% accuracy, free to try!", + images: [ + { + url: "/og-image.png", + width: 1200, + height: 630, + alt: "EditBanana - AI Diagram Converter", + }, + ], + }, + twitter: { + card: "summary_large_image", + title: "EditBanana - Convert Images to Editable Diagrams", + description: "Transform static diagrams into fully editable DrawIO files with AI. 99% accuracy, free to try!", + images: ["/og-image.png"], + creator: "@editbanana", + }, + robots: { + index: true, + follow: true, + googleBot: { + index: true, + follow: true, + "max-video-preview": -1, + "max-image-preview": "large", + "max-snippet": -1, + }, + }, + verification: { + google: "google-site-verification-code", + }, +}; + +// JSON-LD structured data for SoftwareApplication +const jsonLd = { + "@context": "https://schema.org", + "@type": "SoftwareApplication", + name: "EditBanana", + applicationCategory: "GraphicsApplication", + operatingSystem: "Web", + description: "AI-powered tool to convert images and PDFs to editable DrawIO diagrams", + offers: { + "@type": "Offer", + price: "0", + priceCurrency: "USD", + description: "Free to try", + }, + aggregateRating: { + "@type": "AggregateRating", + ratingValue: "4.8", + ratingCount: "100", + }, + url: "https://editbanana.anxin6.cn", + screenshot: "https://editbanana.anxin6.cn/og-image.png", }; export default function RootLayout({ @@ -25,6 +93,12 @@ export default function RootLayout({ }>) { return ( + +