├── .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 | }
--------------------------------------------------------------------------------