├── .gitignore ├── images ├── kdj9udt.png └── WechatIMG124.jpeg ├── src ├── app │ ├── favicon.ico │ ├── page.tsx │ ├── globals.css │ ├── layout.tsx │ ├── api │ │ ├── parse-image │ │ │ └── route.ts │ │ ├── solve │ │ │ └── route.ts │ │ └── generate │ │ │ └── route.ts │ └── [lang] │ │ └── page.tsx ├── i18n │ └── locales │ │ ├── zh.ts │ │ └── en.ts ├── middleware.ts └── components │ └── LanguageSwitcher.tsx ├── next.config.ts ├── public ├── vercel.svg ├── window.svg ├── file.svg ├── globe.svg └── next.svg ├── postcss.config.mjs ├── next-env.d.ts ├── eslint.config.mjs ├── .dockerignore ├── tailwind.config.ts ├── package.json ├── tsconfig.json ├── Dockerfile ├── CLAUDE.md ├── .github └── workflows │ ├── claude.yml │ └── claude-code-review.yml └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /images/kdj9udt.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aneasystone/sudoku/main/images/kdj9udt.png -------------------------------------------------------------------------------- /src/app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aneasystone/sudoku/main/src/app/favicon.ico -------------------------------------------------------------------------------- /images/WechatIMG124.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aneasystone/sudoku/main/images/WechatIMG124.jpeg -------------------------------------------------------------------------------- /src/app/page.tsx: -------------------------------------------------------------------------------- 1 | import { redirect } from 'next/navigation'; 2 | 3 | export default function RootPage() { 4 | redirect('/zh'); 5 | } 6 | -------------------------------------------------------------------------------- /next.config.ts: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const config = { 3 | output: 'standalone', 4 | }; 5 | 6 | export default config; 7 | -------------------------------------------------------------------------------- /public/vercel.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /postcss.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('postcss-load-config').Config} */ 2 | const config = { 3 | plugins: { 4 | tailwindcss: {}, 5 | }, 6 | }; 7 | 8 | export default config; 9 | -------------------------------------------------------------------------------- /next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | // NOTE: This file should not be edited 5 | // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. 6 | -------------------------------------------------------------------------------- /public/window.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/file.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import { dirname } from "path"; 2 | import { fileURLToPath } from "url"; 3 | import { FlatCompat } from "@eslint/eslintrc"; 4 | 5 | const __filename = fileURLToPath(import.meta.url); 6 | const __dirname = dirname(__filename); 7 | 8 | const compat = new FlatCompat({ 9 | baseDirectory: __dirname, 10 | }); 11 | 12 | const eslintConfig = [ 13 | ...compat.extends("next/core-web-vitals", "next/typescript"), 14 | ]; 15 | 16 | export default eslintConfig; 17 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | # 依赖 2 | node_modules 3 | .pnpm-store 4 | 5 | # 测试 6 | coverage 7 | .nyc_output 8 | 9 | # 构建输出 10 | .next 11 | out 12 | 13 | # 环境变量 14 | .env 15 | .env.* 16 | !.env.example 17 | 18 | # 调试 19 | npm-debug.log* 20 | yarn-debug.log* 21 | yarn-error.log* 22 | .pnpm-debug.log* 23 | 24 | # 本地文件 25 | .DS_Store 26 | *.pem 27 | 28 | # 版本控制 29 | .git 30 | .gitignore 31 | 32 | # IDE 33 | .idea 34 | .vscode 35 | *.swp 36 | *.swo 37 | 38 | # 其他 39 | README.md 40 | LICENSE 41 | *.md -------------------------------------------------------------------------------- /tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from "tailwindcss"; 2 | 3 | export default { 4 | content: [ 5 | "./src/pages/**/*.{js,ts,jsx,tsx,mdx}", 6 | "./src/components/**/*.{js,ts,jsx,tsx,mdx}", 7 | "./src/app/**/*.{js,ts,jsx,tsx,mdx}", 8 | ], 9 | theme: { 10 | extend: { 11 | colors: { 12 | background: "var(--background)", 13 | foreground: "var(--foreground)", 14 | }, 15 | }, 16 | }, 17 | plugins: [], 18 | } satisfies Config; 19 | -------------------------------------------------------------------------------- /src/i18n/locales/zh.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | title: '数独解题器', 3 | uploadButton: '上传数独图片', 4 | solveButton: '解决数独', 5 | generateButton: '随机生成', 6 | processing: '处理中...', 7 | generating: '生成中...', 8 | difficulty: '难度:', 9 | difficulties: { 10 | easy: '简单', 11 | medium: '中等', 12 | hard: '困难' 13 | }, 14 | errors: { 15 | noImage: '未找到图片文件', 16 | processError: '处理图片时出错', 17 | solveError: '无法解决此数独', 18 | requestError: '处理请求时出错', 19 | generateError: '生成数独时出错', 20 | invalidDifficulty: '无效的难度级别' 21 | } 22 | }; -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sudoku2", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev --turbopack", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint" 10 | }, 11 | "dependencies": { 12 | "react": "^19.0.0", 13 | "react-dom": "^19.0.0", 14 | "next": "15.1.8" 15 | }, 16 | "devDependencies": { 17 | "typescript": "^5", 18 | "@types/node": "^20", 19 | "@types/react": "^19", 20 | "@types/react-dom": "^19", 21 | "postcss": "^8", 22 | "tailwindcss": "^3.4.1", 23 | "eslint": "^9", 24 | "eslint-config-next": "15.1.8", 25 | "@eslint/eslintrc": "^3" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/i18n/locales/en.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | title: 'Sudoku Solver', 3 | uploadButton: 'Upload Sudoku Image', 4 | solveButton: 'Solve Sudoku', 5 | generateButton: 'Random Generate', 6 | processing: 'Processing...', 7 | generating: 'Generating...', 8 | difficulty: 'Difficulty:', 9 | difficulties: { 10 | easy: 'Easy', 11 | medium: 'Medium', 12 | hard: 'Hard' 13 | }, 14 | errors: { 15 | noImage: 'No image file found', 16 | processError: 'Error processing image', 17 | solveError: 'Cannot solve this sudoku', 18 | requestError: 'Error processing request', 19 | generateError: 'Error generating sudoku', 20 | invalidDifficulty: 'Invalid difficulty level' 21 | } 22 | }; -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2017", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "noEmit": true, 9 | "esModuleInterop": true, 10 | "module": "esnext", 11 | "moduleResolution": "bundler", 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "jsx": "preserve", 15 | "incremental": true, 16 | "plugins": [ 17 | { 18 | "name": "next" 19 | } 20 | ], 21 | "paths": { 22 | "@/*": ["./src/*"] 23 | } 24 | }, 25 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 26 | "exclude": ["node_modules"] 27 | } 28 | -------------------------------------------------------------------------------- /src/app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | :root { 6 | --foreground-rgb: 0, 0, 0; 7 | --background-start-rgb: 214, 219, 220; 8 | --background-end-rgb: 255, 255, 255; 9 | } 10 | 11 | @media (prefers-color-scheme: dark) { 12 | :root { 13 | --background: #0a0a0a; 14 | --foreground: #ededed; 15 | } 16 | } 17 | 18 | body { 19 | color: rgb(var(--foreground-rgb)); 20 | background: rgb(var(--background-end-rgb)); 21 | font-family: Arial, Helvetica, sans-serif; 22 | } 23 | 24 | /* 自定义输入框样式 */ 25 | input[type="number"]::-webkit-inner-spin-button, 26 | input[type="number"]::-webkit-outer-spin-button { 27 | -webkit-appearance: none; 28 | margin: 0; 29 | } 30 | 31 | input[type="number"] { 32 | -moz-appearance: textfield; 33 | } 34 | -------------------------------------------------------------------------------- /src/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from "next"; 2 | import { Geist, Geist_Mono } from "next/font/google"; 3 | import "./globals.css"; 4 | 5 | const geistSans = Geist({ 6 | variable: "--font-geist-sans", 7 | subsets: ["latin"], 8 | }); 9 | 10 | const geistMono = Geist_Mono({ 11 | variable: "--font-geist-mono", 12 | subsets: ["latin"], 13 | }); 14 | 15 | export const metadata: Metadata = { 16 | title: "Create Next App", 17 | description: "Generated by create next app", 18 | }; 19 | 20 | export default function RootLayout({ 21 | children, 22 | }: Readonly<{ 23 | children: React.ReactNode; 24 | }>) { 25 | return ( 26 | 27 | 30 | {children} 31 | 32 | 33 | ); 34 | } 35 | -------------------------------------------------------------------------------- /src/middleware.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from 'next/server'; 2 | import type { NextRequest } from 'next/server'; 3 | 4 | export function middleware(request: NextRequest) { 5 | const pathname = request.nextUrl.pathname; 6 | 7 | // 检查路径是否已经包含语言代码 8 | const pathnameHasLocale = pathname.startsWith('/en') || pathname.startsWith('/zh'); 9 | 10 | if (pathnameHasLocale) return; 11 | 12 | // 获取首选语言 13 | const locale = request.headers.get('accept-language')?.split(',')?.[0].split('-')[0] || 'en'; 14 | const defaultLocale = ['en', 'zh'].includes(locale) ? locale : 'en'; 15 | 16 | // 重定向到带语言代码的路径 17 | return NextResponse.redirect( 18 | new URL(`/${defaultLocale}${pathname}`, request.url) 19 | ); 20 | } 21 | 22 | export const config = { 23 | matcher: [ 24 | // 跳过所有内部路径 (_next) 25 | '/((?!_next|api|favicon.ico).*)', 26 | ], 27 | }; -------------------------------------------------------------------------------- /src/components/LanguageSwitcher.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { useRouter, usePathname } from 'next/navigation'; 4 | 5 | export default function LanguageSwitcher() { 6 | const router = useRouter(); 7 | const pathname = usePathname(); 8 | 9 | const toggleLanguage = () => { 10 | const currentLang = pathname.startsWith('/en') ? 'en' : 'zh'; 11 | const newLang = currentLang === 'en' ? 'zh' : 'en'; 12 | const newPath = pathname.replace(`/${currentLang}`, `/${newLang}`); 13 | router.push(newPath); 14 | }; 15 | 16 | return ( 17 | 24 | ); 25 | } -------------------------------------------------------------------------------- /public/globe.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/app/api/parse-image/route.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from 'next/server'; 2 | 3 | // 模拟的数独识别结果 4 | const mockSudokuGrid = [ 5 | [5, 3, 0, 0, 7, 0, 0, 0, 0], 6 | [6, 0, 0, 1, 9, 5, 0, 0, 0], 7 | [0, 9, 8, 0, 0, 0, 0, 6, 0], 8 | [8, 0, 0, 0, 6, 0, 0, 0, 3], 9 | [4, 0, 0, 8, 0, 3, 0, 0, 1], 10 | [7, 0, 0, 0, 2, 0, 0, 0, 6], 11 | [0, 6, 0, 0, 0, 0, 2, 8, 0], 12 | [0, 0, 0, 4, 1, 9, 0, 0, 5], 13 | [0, 0, 0, 0, 8, 0, 0, 7, 9] 14 | ]; 15 | 16 | export async function POST(request: Request) { 17 | try { 18 | const formData = await request.formData(); 19 | const image = formData.get('image'); 20 | 21 | if (!image) { 22 | return NextResponse.json( 23 | { error: '未找到图片文件' }, 24 | { status: 400 } 25 | ); 26 | } 27 | 28 | // 这里应该是实际的图片处理逻辑 29 | // 为了演示,我们返回一个模拟的数独网格 30 | // 在实际应用中,这里应该调用 OCR 服务或其他图像处理服务 31 | 32 | // 模拟处理延迟 33 | await new Promise(resolve => setTimeout(resolve, 1000)); 34 | 35 | return NextResponse.json({ grid: mockSudokuGrid }); 36 | } catch (error) { 37 | console.error('Error processing image:', error); 38 | return NextResponse.json( 39 | { error: '处理图片时出错' }, 40 | { status: 500 } 41 | ); 42 | } 43 | } -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # 使用 Node.js 18 作为基础镜像 2 | FROM node:18-alpine AS base 3 | 4 | # 设置工作目录 5 | WORKDIR /app 6 | 7 | # 安装系统依赖 8 | RUN apk add --no-cache libc6-compat 9 | 10 | # 安装 pnpm 11 | RUN corepack enable && corepack prepare pnpm@latest --activate 12 | 13 | # 安装依赖阶段 14 | FROM base AS deps 15 | WORKDIR /app 16 | 17 | # 复制 package.json 18 | COPY package.json ./ 19 | 20 | # 安装依赖 21 | RUN pnpm install 22 | 23 | # 构建阶段 24 | FROM base AS builder 25 | WORKDIR /app 26 | 27 | # 复制依赖和源代码 28 | COPY --from=deps /app/node_modules ./node_modules 29 | COPY . . 30 | 31 | # 设置环境变量 32 | ENV NEXT_TELEMETRY_DISABLED 1 33 | ENV NODE_ENV production 34 | 35 | # 构建应用 36 | RUN pnpm build 37 | 38 | # 生产阶段 39 | FROM base AS runner 40 | WORKDIR /app 41 | 42 | ENV NODE_ENV production 43 | ENV NEXT_TELEMETRY_DISABLED 1 44 | 45 | # 创建非 root 用户 46 | RUN addgroup --system --gid 1001 nodejs 47 | RUN adduser --system --uid 1001 nextjs 48 | 49 | # 复制必要文件 50 | COPY --from=builder /app/public ./public 51 | COPY --from=builder /app/.next/standalone ./ 52 | COPY --from=builder /app/.next/static ./.next/static 53 | 54 | # 设置正确的权限 55 | RUN chown -R nextjs:nodejs /app 56 | 57 | # 切换到非 root 用户 58 | USER nextjs 59 | 60 | # 暴露端口 61 | EXPOSE 3000 62 | 63 | # 设置环境变量 64 | ENV PORT 3000 65 | ENV HOSTNAME "0.0.0.0" 66 | 67 | # 启动应用 68 | CMD ["node", "server.js"] -------------------------------------------------------------------------------- /public/next.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/app/api/solve/route.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from 'next/server'; 2 | 3 | function isValid(grid: number[][], row: number, col: number, num: number): boolean { 4 | // 检查行 5 | for (let x = 0; x < 9; x++) { 6 | if (grid[row][x] === num) return false; 7 | } 8 | 9 | // 检查列 10 | for (let x = 0; x < 9; x++) { 11 | if (grid[x][col] === num) return false; 12 | } 13 | 14 | // 检查 3x3 方格 15 | const startRow = row - (row % 3); 16 | const startCol = col - (col % 3); 17 | for (let i = 0; i < 3; i++) { 18 | for (let j = 0; j < 3; j++) { 19 | if (grid[i + startRow][j + startCol] === num) return false; 20 | } 21 | } 22 | 23 | return true; 24 | } 25 | 26 | function solveSudoku(grid: number[][]): boolean { 27 | for (let row = 0; row < 9; row++) { 28 | for (let col = 0; col < 9; col++) { 29 | if (grid[row][col] === 0) { 30 | for (let num = 1; num <= 9; num++) { 31 | if (isValid(grid, row, col, num)) { 32 | grid[row][col] = num; 33 | if (solveSudoku(grid)) return true; 34 | grid[row][col] = 0; 35 | } 36 | } 37 | return false; 38 | } 39 | } 40 | } 41 | return true; 42 | } 43 | 44 | export async function POST(request: Request) { 45 | try { 46 | const { grid } = await request.json(); 47 | 48 | // 创建网格的深拷贝 49 | const gridCopy = JSON.parse(JSON.stringify(grid)); 50 | 51 | if (solveSudoku(gridCopy)) { 52 | return NextResponse.json({ solution: gridCopy }); 53 | } else { 54 | return NextResponse.json( 55 | { error: '无法解决此数独' }, 56 | { status: 400 } 57 | ); 58 | } 59 | } catch (err) { 60 | console.error('Error solving sudoku:', err); 61 | return NextResponse.json( 62 | { error: '处理请求时出错' }, 63 | { status: 500 } 64 | ); 65 | } 66 | } -------------------------------------------------------------------------------- /CLAUDE.md: -------------------------------------------------------------------------------- 1 | # CLAUDE.md 2 | 3 | This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. 4 | 5 | ## Development Commands 6 | 7 | ```bash 8 | # Start development server 9 | npm run dev 10 | 11 | # Build for production 12 | npm run build 13 | 14 | # Start production server 15 | npm start 16 | 17 | # Run linting 18 | npm run lint 19 | ``` 20 | 21 | ## Architecture Overview 22 | 23 | This is a Next.js 15 sudoku solver application with the following key architectural components: 24 | 25 | ### Core Algorithm Implementation 26 | - **Sudoku Solver**: Implements backtracking algorithm in `src/app/api/solve/route.ts` 27 | - **Sudoku Generator**: Creates puzzles with difficulty levels in `src/app/api/generate/route.ts` 28 | - **Image Parser**: Mock OCR functionality in `src/app/api/parse-image/route.ts` 29 | 30 | ### Internationalization Architecture 31 | - **Route-based i18n**: Uses Next.js dynamic routes with `[lang]` segments 32 | - **Middleware**: `src/middleware.ts` handles language detection and redirection 33 | - **Translations**: Located in `src/i18n/locales/` with `zh.ts` and `en.ts` 34 | - **Language Switcher**: `src/components/LanguageSwitcher.tsx` manages language state 35 | 36 | ### API Routes Structure 37 | All API endpoints are in `src/app/api/`: 38 | - `POST /api/solve` - Solves sudoku puzzles using backtracking 39 | - `POST /api/generate` - Generates puzzles with difficulty levels (easy: 35 removed, medium: 45 removed, hard: 55 removed) 40 | - `POST /api/parse-image` - Mock image parsing (returns sample puzzle) 41 | 42 | ### Key Technical Details 43 | - **Sudoku Validation**: Checks row, column, and 3x3 box constraints 44 | - **Puzzle Generation**: Fisher-Yates shuffle for randomization, then selective number removal 45 | - **State Management**: React useState for grid state, loading states, and UI interactions 46 | - **Styling**: Tailwind CSS with responsive grid layout and visual feedback 47 | 48 | ### Docker Support 49 | - Dockerfile included for containerized deployment 50 | - Production build optimized for Next.js 51 | 52 | ## Important Notes 53 | - The image parsing API currently returns mock data - real OCR integration is planned 54 | - All error messages are internationalized 55 | - Grid state is managed as 9x9 number array where 0 represents empty cells -------------------------------------------------------------------------------- /.github/workflows/claude.yml: -------------------------------------------------------------------------------- 1 | name: Claude Code 2 | 3 | on: 4 | issue_comment: 5 | types: [created] 6 | pull_request_review_comment: 7 | types: [created] 8 | issues: 9 | types: [opened, assigned] 10 | pull_request_review: 11 | types: [submitted] 12 | 13 | jobs: 14 | claude: 15 | if: | 16 | (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) || 17 | (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) || 18 | (github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) || 19 | (github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude'))) 20 | runs-on: ubuntu-latest 21 | permissions: 22 | contents: read 23 | pull-requests: read 24 | issues: read 25 | id-token: write 26 | steps: 27 | - name: Checkout repository 28 | uses: actions/checkout@v4 29 | with: 30 | fetch-depth: 1 31 | 32 | - name: Run Claude Code 33 | id: claude 34 | uses: anthropics/claude-code-action@beta 35 | with: 36 | anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} 37 | 38 | # Optional: Specify model (defaults to Claude Sonnet 4, uncomment for Claude Opus 4) 39 | # model: "claude-opus-4-20250514" 40 | 41 | # Optional: Customize the trigger phrase (default: @claude) 42 | # trigger_phrase: "/claude" 43 | 44 | # Optional: Trigger when specific user is assigned to an issue 45 | # assignee_trigger: "claude-bot" 46 | 47 | # Optional: Allow Claude to run specific commands 48 | # allowed_tools: "Bash(npm install),Bash(npm run build),Bash(npm run test:*),Bash(npm run lint:*)" 49 | 50 | # Optional: Add custom instructions for Claude to customize its behavior for your project 51 | # custom_instructions: | 52 | # Follow our coding standards 53 | # Ensure all new code has tests 54 | # Use TypeScript for new files 55 | 56 | # Optional: Custom environment variables for Claude 57 | # claude_env: | 58 | # NODE_ENV: test 59 | 60 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 数独求解器 (Sudoku Solver) 2 | 3 | 这是一个基于 Next.js 开发的智能数独求解器,支持手动输入、图片识别和自动求解功能。 4 | 5 | ## 核心功能 6 | 7 | - **手动输入数独**:提供直观的 9x9 网格界面,用户可手动输入数字 8 | - **随机生成数独**:支持简单、中等、困难三种难度级别的随机题目生成 9 | - **图片识别**:支持上传数独图片进行识别(目前使用模拟数据) 10 | - **自动求解**:使用回溯算法快速解决数独难题 11 | - **国际化支持**:完整的中英文语言切换,包括界面和路由 12 | - **响应式设计**:适配不同设备屏幕尺寸 13 | 14 | ## 技术栈 15 | 16 | - **前端框架**:Next.js 15、React 19 17 | - **语言**:TypeScript 18 | - **样式**:Tailwind CSS 19 | - **后端**:Next.js API Routes 20 | - **部署**:Docker 容器化支持 21 | 22 | ## 项目结构 23 | 24 | ``` 25 | src/ 26 | ├── app/ 27 | │ ├── [lang]/ # 国际化路由 28 | │ │ └── page.tsx # 多语言主页面 29 | │ ├── api/ 30 | │ │ ├── solve/ # 数独求解算法API 31 | │ │ ├── parse-image/ # 图片解析API 32 | │ │ └── generate/ # 随机生成数独API 33 | │ ├── globals.css # 全局样式 34 | │ ├── layout.tsx # 布局组件 35 | │ └── page.tsx # 根页面重定向 36 | ├── components/ 37 | │ └── LanguageSwitcher.tsx # 语言切换组件 38 | ├── i18n/ 39 | │ └── locales/ # 多语言配置文件 40 | │ ├── zh.ts # 中文翻译 41 | │ └── en.ts # 英文翻译 42 | └── middleware.ts # 路由中间件配置 43 | ``` 44 | 45 | ## 算法实现 46 | 47 | ### 数独求解算法 48 | 采用经典的**回溯法**(Backtracking): 49 | 1. 遍历 9x9 网格寻找空白位置 50 | 2. 尝试填入 1-9 的数字 51 | 3. 验证行、列、3x3 宫格的唯一性 52 | 4. 递归求解,失败时回溯 53 | 54 | ### 数独生成算法 55 | 1. **完整填充**:使用回溯算法生成完整的有效数独解 56 | 2. **随机打乱**:使用 Fisher-Yates 算法随机化数字顺序 57 | 3. **难度控制**:根据难度移除不同数量的数字 58 | - 简单:移除 35 个数字(保留 46 个) 59 | - 中等:移除 45 个数字(保留 36 个) 60 | - 困难:移除 55 个数字(保留 26 个) 61 | 62 | ## 快速开始 63 | 64 | ### 本地开发 65 | 66 | ```bash 67 | # 安装依赖 68 | npm install 69 | 70 | # 启动开发服务器 71 | npm run dev 72 | 73 | # 构建项目 74 | npm run build 75 | 76 | # 启动生产服务器 77 | npm start 78 | ``` 79 | 80 | ### Docker 部署 81 | 82 | ```bash 83 | # 构建镜像 84 | docker build -t sudoku-solver . 85 | 86 | # 运行容器 87 | docker run -p 3000:3000 sudoku-solver 88 | ``` 89 | 90 | 访问 [http://localhost:3000](http://localhost:3000) 查看应用。 91 | 92 | ## 使用说明 93 | 94 | 1. **自动生成题目**:页面加载时会根据当前难度自动生成一道数独题 95 | 2. **选择难度**:使用下拉菜单选择简单、中等或困难级别 96 | 3. **随机生成**:点击"随机生成"按钮创建新的数独题目 97 | 4. **手动输入**:直接在网格中输入数字(1-9) 98 | 5. **图片上传**:点击"上传数独图片"按钮选择图片文件 99 | 6. **自动求解**:点击"解决数独"按钮自动求解 100 | 7. **语言切换**:通过语言切换器选择中文或英文界面 101 | 102 | ## 开发计划 103 | 104 | - [x] ~~添加数独生成功能~~ 105 | - [x] ~~支持多种难度级别~~ 106 | - [ ] 集成真实的 OCR 图像识别服务 107 | - [ ] 添加求解步骤展示动画 108 | - [ ] 添加数独验证功能 109 | - [ ] 支持用户自定义难度 110 | - [ ] 添加游戏计时器 111 | - [ ] 优化移动端体验 112 | 113 | ## 贡献指南 114 | 115 | 欢迎提交 Issue 和 Pull Request 来改进这个项目。 116 | 117 | ## 许可证 118 | 119 | MIT License 120 | -------------------------------------------------------------------------------- /.github/workflows/claude-code-review.yml: -------------------------------------------------------------------------------- 1 | name: Claude Code Review 2 | 3 | on: 4 | pull_request: 5 | types: [opened, synchronize] 6 | # Optional: Only run on specific file changes 7 | # paths: 8 | # - "src/**/*.ts" 9 | # - "src/**/*.tsx" 10 | # - "src/**/*.js" 11 | # - "src/**/*.jsx" 12 | 13 | jobs: 14 | claude-review: 15 | # Optional: Filter by PR author 16 | # if: | 17 | # github.event.pull_request.user.login == 'external-contributor' || 18 | # github.event.pull_request.user.login == 'new-developer' || 19 | # github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR' 20 | 21 | runs-on: ubuntu-latest 22 | permissions: 23 | contents: read 24 | pull-requests: read 25 | issues: read 26 | id-token: write 27 | 28 | steps: 29 | - name: Checkout repository 30 | uses: actions/checkout@v4 31 | with: 32 | fetch-depth: 1 33 | 34 | - name: Run Claude Code Review 35 | id: claude-review 36 | uses: anthropics/claude-code-action@beta 37 | with: 38 | anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} 39 | 40 | # Optional: Specify model (defaults to Claude Sonnet 4, uncomment for Claude Opus 4) 41 | # model: "claude-opus-4-20250514" 42 | 43 | # Direct prompt for automated review (no @claude mention needed) 44 | direct_prompt: | 45 | Please review this pull request and provide feedback on: 46 | - Code quality and best practices 47 | - Potential bugs or issues 48 | - Performance considerations 49 | - Security concerns 50 | - Test coverage 51 | 52 | Be constructive and helpful in your feedback. 53 | 54 | # Optional: Customize review based on file types 55 | # direct_prompt: | 56 | # Review this PR focusing on: 57 | # - For TypeScript files: Type safety and proper interface usage 58 | # - For API endpoints: Security, input validation, and error handling 59 | # - For React components: Performance, accessibility, and best practices 60 | # - For tests: Coverage, edge cases, and test quality 61 | 62 | # Optional: Different prompts for different authors 63 | # direct_prompt: | 64 | # ${{ github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR' && 65 | # 'Welcome! Please review this PR from a first-time contributor. Be encouraging and provide detailed explanations for any suggestions.' || 66 | # 'Please provide a thorough code review focusing on our coding standards and best practices.' }} 67 | 68 | # Optional: Add specific tools for running tests or linting 69 | # allowed_tools: "Bash(npm run test),Bash(npm run lint),Bash(npm run typecheck)" 70 | 71 | # Optional: Skip review for certain conditions 72 | # if: | 73 | # !contains(github.event.pull_request.title, '[skip-review]') && 74 | # !contains(github.event.pull_request.title, '[WIP]') 75 | 76 | -------------------------------------------------------------------------------- /src/app/api/generate/route.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from 'next/server'; 2 | 3 | // 随机打乱数组 4 | function shuffle(array: T[]): T[] { 5 | const newArray = [...array]; 6 | for (let i = newArray.length - 1; i > 0; i--) { 7 | const j = Math.floor(Math.random() * (i + 1)); 8 | [newArray[i], newArray[j]] = [newArray[j], newArray[i]]; 9 | } 10 | return newArray; 11 | } 12 | 13 | // 检查数字在指定位置是否有效 14 | function isValidPlacement(grid: number[][], row: number, col: number, num: number): boolean { 15 | // 检查行 16 | for (let x = 0; x < 9; x++) { 17 | if (grid[row][x] === num) return false; 18 | } 19 | 20 | // 检查列 21 | for (let x = 0; x < 9; x++) { 22 | if (grid[x][col] === num) return false; 23 | } 24 | 25 | // 检查 3x3 方格 26 | const startRow = row - (row % 3); 27 | const startCol = col - (col % 3); 28 | for (let i = 0; i < 3; i++) { 29 | for (let j = 0; j < 3; j++) { 30 | if (grid[i + startRow][j + startCol] === num) return false; 31 | } 32 | } 33 | 34 | return true; 35 | } 36 | 37 | // 填充完整的数独网格 38 | function fillGrid(grid: number[][]): boolean { 39 | for (let row = 0; row < 9; row++) { 40 | for (let col = 0; col < 9; col++) { 41 | if (grid[row][col] === 0) { 42 | const numbers = shuffle([1, 2, 3, 4, 5, 6, 7, 8, 9]); 43 | for (const num of numbers) { 44 | if (isValidPlacement(grid, row, col, num)) { 45 | grid[row][col] = num; 46 | if (fillGrid(grid)) return true; 47 | grid[row][col] = 0; 48 | } 49 | } 50 | return false; 51 | } 52 | } 53 | } 54 | return true; 55 | } 56 | 57 | // 移除数字来创建谜题 58 | function removeNumbers(grid: number[][], difficulty: 'easy' | 'medium' | 'hard' = 'medium'): number[][] { 59 | const puzzle = grid.map(row => [...row]); 60 | 61 | // 根据难度设置要移除的数字数量 62 | const removeCount = { 63 | easy: 35, // 移除35个数字(46个已填充) 64 | medium: 45, // 移除45个数字(36个已填充) 65 | hard: 55 // 移除55个数字(26个已填充) 66 | }[difficulty]; 67 | 68 | const positions = []; 69 | for (let row = 0; row < 9; row++) { 70 | for (let col = 0; col < 9; col++) { 71 | positions.push([row, col]); 72 | } 73 | } 74 | 75 | const shuffledPositions = shuffle(positions); 76 | 77 | for (let i = 0; i < removeCount && i < shuffledPositions.length; i++) { 78 | const [row, col] = shuffledPositions[i]; 79 | puzzle[row][col] = 0; 80 | } 81 | 82 | return puzzle; 83 | } 84 | 85 | // 生成随机数独 86 | function generateSudoku(difficulty: 'easy' | 'medium' | 'hard' = 'medium'): number[][] { 87 | const grid: number[][] = Array(9).fill(null).map(() => Array(9).fill(0)); 88 | 89 | // 填充完整的数独网格 90 | fillGrid(grid); 91 | 92 | // 移除数字创建谜题 93 | return removeNumbers(grid, difficulty); 94 | } 95 | 96 | export async function POST(request: Request) { 97 | try { 98 | const { difficulty = 'medium' } = await request.json().catch(() => ({})); 99 | 100 | // 验证难度参数 101 | if (!['easy', 'medium', 'hard'].includes(difficulty)) { 102 | return NextResponse.json( 103 | { error: '无效的难度级别' }, 104 | { status: 400 } 105 | ); 106 | } 107 | 108 | const puzzle = generateSudoku(difficulty as 'easy' | 'medium' | 'hard'); 109 | 110 | return NextResponse.json({ 111 | grid: puzzle, 112 | difficulty 113 | }); 114 | } catch (error) { 115 | console.error('Error generating sudoku:', error); 116 | return NextResponse.json( 117 | { error: '生成数独时出错' }, 118 | { status: 500 } 119 | ); 120 | } 121 | } -------------------------------------------------------------------------------- /src/app/[lang]/page.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { useState, useRef, useEffect } from 'react'; 4 | import { use } from 'react'; 5 | import LanguageSwitcher from '@/components/LanguageSwitcher'; 6 | import zh from '@/i18n/locales/zh'; 7 | import en from '@/i18n/locales/en'; 8 | 9 | const translations = { 10 | zh, 11 | en, 12 | }; 13 | 14 | export default function Home({ 15 | params, 16 | }: { 17 | params: Promise<{ lang: string }>; 18 | }) { 19 | const { lang } = use(params); 20 | const [grid, setGrid] = useState( 21 | Array(9).fill(null).map(() => Array(9).fill(0)) 22 | ); 23 | const [solved, setSolved] = useState(false); 24 | const [isLoading, setIsLoading] = useState(false); 25 | const [difficulty, setDifficulty] = useState<'easy' | 'medium' | 'hard'>('medium'); 26 | const fileInputRef = useRef(null); 27 | const t = translations[lang as keyof typeof translations]; 28 | 29 | // 页面加载时自动生成一道数独题 30 | useEffect(() => { 31 | const generateInitialPuzzle = async () => { 32 | try { 33 | setIsLoading(true); 34 | const response = await fetch('/api/generate', { 35 | method: 'POST', 36 | headers: { 37 | 'Content-Type': 'application/json', 38 | }, 39 | body: JSON.stringify({ difficulty }), 40 | }); 41 | 42 | const data = await response.json(); 43 | if (data.grid) { 44 | setGrid(data.grid); 45 | setSolved(false); 46 | } 47 | } catch (error) { 48 | console.error('Error generating initial sudoku:', error); 49 | } finally { 50 | setIsLoading(false); 51 | } 52 | }; 53 | 54 | generateInitialPuzzle(); 55 | }, [difficulty]); 56 | 57 | const handleCellChange = (row: number, col: number, value: string) => { 58 | const newValue = value === '' ? 0 : parseInt(value); 59 | if (isNaN(newValue) || newValue < 0 || newValue > 9) return; 60 | 61 | const newGrid = [...grid]; 62 | newGrid[row][col] = newValue; 63 | setGrid(newGrid); 64 | }; 65 | 66 | const handleSubmit = async () => { 67 | try { 68 | setIsLoading(true); 69 | const response = await fetch('/api/solve', { 70 | method: 'POST', 71 | headers: { 72 | 'Content-Type': 'application/json', 73 | }, 74 | body: JSON.stringify({ grid }), 75 | }); 76 | 77 | const data = await response.json(); 78 | if (data.solution) { 79 | setGrid(data.solution); 80 | setSolved(true); 81 | } 82 | } catch (error) { 83 | console.error('Error solving sudoku:', error); 84 | } finally { 85 | setIsLoading(false); 86 | } 87 | }; 88 | 89 | const handleImageUpload = async (event: React.ChangeEvent) => { 90 | const file = event.target.files?.[0]; 91 | if (!file) return; 92 | 93 | try { 94 | setIsLoading(true); 95 | const formData = new FormData(); 96 | formData.append('image', file); 97 | 98 | const response = await fetch('/api/parse-image', { 99 | method: 'POST', 100 | body: formData, 101 | }); 102 | 103 | const data = await response.json(); 104 | if (data.grid) { 105 | setGrid(data.grid); 106 | setSolved(false); 107 | } 108 | } catch (error) { 109 | console.error('Error parsing image:', error); 110 | } finally { 111 | setIsLoading(false); 112 | } 113 | }; 114 | 115 | const handleGenerateRandom = async () => { 116 | try { 117 | setIsLoading(true); 118 | const response = await fetch('/api/generate', { 119 | method: 'POST', 120 | headers: { 121 | 'Content-Type': 'application/json', 122 | }, 123 | body: JSON.stringify({ difficulty }), 124 | }); 125 | 126 | const data = await response.json(); 127 | if (data.grid) { 128 | setGrid(data.grid); 129 | setSolved(false); 130 | } 131 | } catch (error) { 132 | console.error('Error generating sudoku:', error); 133 | } finally { 134 | setIsLoading(false); 135 | } 136 | }; 137 | 138 | return ( 139 |
140 | 141 |
142 |

143 | {t.title} 144 |

145 | 146 |
147 |
148 | 151 | 161 |
162 | 163 |
164 | 171 | 180 | 187 | 196 |
197 |
198 | 199 |
200 |
201 | {grid.map((row, rowIndex) => ( 202 | row.map((cell, colIndex) => ( 203 | handleCellChange(rowIndex, colIndex, e.target.value)} 210 | className={` 211 | w-full aspect-square text-center text-xl font-medium 212 | border border-gray-300 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 213 | ${(rowIndex + 1) % 3 === 0 && rowIndex !== 8 ? 'border-b-2' : ''} 214 | ${(colIndex + 1) % 3 === 0 && colIndex !== 8 ? 'border-r-2' : ''} 215 | ${solved ? 'bg-green-50' : 'bg-white'} 216 | `} 217 | /> 218 | )) 219 | ))} 220 |
221 |
222 |
223 |
224 | ); 225 | } --------------------------------------------------------------------------------