duqing2026 commited on
Commit
9081dbe
·
0 Parent(s):
.gitignore ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ __pycache__/
2
+ *.py[cod]
3
+ *$py.class
4
+ .env
5
+ .venv
6
+ venv/
7
+ env/
8
+ data/
9
+ frontend/node_modules/
10
+ frontend/dist/
11
+ frontend/.env
12
+ .DS_Store
Dockerfile ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Stage 1: Build Frontend
2
+ FROM node:20-slim AS frontend-builder
3
+ WORKDIR /app/frontend
4
+ COPY frontend/package*.json ./
5
+ RUN npm install
6
+ COPY frontend/ ./
7
+ RUN npm run build
8
+
9
+ # Stage 2: Backend Setup
10
+ FROM python:3.9-slim
11
+ WORKDIR /app
12
+
13
+ # Install system dependencies if needed
14
+ RUN apt-get update && apt-get install -y --no-install-recommends \
15
+ git \
16
+ && rm -rf /var/lib/apt/lists/*
17
+
18
+ # Copy backend requirements
19
+ COPY requirements.txt .
20
+ RUN pip install --no-cache-dir -r requirements.txt
21
+
22
+ # Copy backend code
23
+ COPY app.py .
24
+ COPY data ./data
25
+
26
+ # Copy built frontend from Stage 1
27
+ COPY --from=frontend-builder /app/frontend/dist ./frontend/dist
28
+
29
+ # Set permissions for data directory (crucial for HF Spaces)
30
+ RUN chmod -R 777 /app/data
31
+
32
+ # Environment variables
33
+ ENV PYTHONUNBUFFERED=1
34
+ ENV PORT=7860
35
+
36
+ # Expose port
37
+ EXPOSE 7860
38
+
39
+ # Run command
40
+ CMD ["python", "app.py"]
README.md ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Skill Tree Tracker
2
+
3
+ A personal growth visualization tool that helps you track your skills, learning progress, and future roadmap. Built with React Flow and Python Flask, deployable to Hugging Face Spaces.
4
+
5
+ ## Features
6
+
7
+ - **Visual Skill Tree**: Interactive node-based graph to map out your skills.
8
+ - **Progress Tracking**: Mark skills as Pending, In Progress, or Completed.
9
+ - **Data Persistence**: Saves data locally and syncs to Hugging Face Dataset (optional).
10
+ - **Modern UI**: Clean interface built with Tailwind CSS.
11
+
12
+ ## Local Development
13
+
14
+ ### Prerequisites
15
+
16
+ - Node.js 20+
17
+ - Python 3.9+
18
+
19
+ ### Setup
20
+
21
+ 1. **Backend**:
22
+ ```bash
23
+ pip install -r requirements.txt
24
+ python app.py
25
+ ```
26
+ The backend runs on http://localhost:7860.
27
+
28
+ 2. **Frontend**:
29
+ ```bash
30
+ cd frontend
31
+ npm install
32
+ npm run dev
33
+ ```
34
+ The frontend runs on http://localhost:5173.
35
+
36
+ ### Deployment to Hugging Face Spaces
37
+
38
+ 1. Create a new Space on Hugging Face (Docker SDK).
39
+ 2. Upload the contents of this repository.
40
+ 3. Set `HF_TOKEN` in Space secrets if you want data sync capabilities.
41
+ 4. Set `HF_DB_REPO` to your dataset repository ID (e.g., `username/skill-tree-data`).
42
+
43
+ ## Data Sync
44
+
45
+ The application automatically syncs `skills.json` to a configured Hugging Face Dataset. This ensures your progress is saved even if the Space restarts.
app.py ADDED
@@ -0,0 +1,141 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import json
3
+ import threading
4
+ import time
5
+ import shutil
6
+ from pathlib import Path
7
+ from flask import Flask, jsonify, request, send_from_directory
8
+ try:
9
+ from dotenv import load_dotenv
10
+ load_dotenv(override=True)
11
+ except ImportError:
12
+ pass
13
+
14
+ try:
15
+ from huggingface_hub import HfApi, snapshot_download
16
+ except ImportError:
17
+ HfApi = None
18
+
19
+ app = Flask(__name__, static_folder='frontend/dist')
20
+
21
+ # Configuration
22
+ DATA_DIR = Path("data")
23
+ DATA_DIR.mkdir(parents=True, exist_ok=True)
24
+ SKILLS_FILE = DATA_DIR / "skills.json"
25
+ REPO_ID = os.getenv("HF_DB_REPO", "duqing2026/skill-tree-data") # Default repo, user should configure
26
+ HF_TOKEN = os.getenv("HF_TOKEN")
27
+
28
+ # Default initial data
29
+ DEFAULT_SKILLS = {
30
+ "nodes": [
31
+ {"id": "1", "type": "input", "data": {"label": "开始", "status": "completed"}, "position": {"x": 250, "y": 0}},
32
+ {"id": "2", "data": {"label": "学习 Python", "status": "in_progress"}, "position": {"x": 100, "y": 100}},
33
+ {"id": "3", "data": {"label": "学习 React", "status": "pending"}, "position": {"x": 400, "y": 100}},
34
+ ],
35
+ "edges": [
36
+ {"id": "e1-2", "source": "1", "target": "2"},
37
+ {"id": "e1-3", "source": "1", "target": "3"},
38
+ ]
39
+ }
40
+
41
+ class SyncManager:
42
+ def __init__(self, repo_id, token, data_dir):
43
+ self.repo_id = repo_id
44
+ self.token = token
45
+ self.data_dir = data_dir
46
+ self.api = HfApi(token=token) if HfApi and token else None
47
+ self.is_pushing = False
48
+
49
+ def pull(self):
50
+ if not self.api:
51
+ print("Sync: Skipping pull (no API/Token)")
52
+ return
53
+ print("Sync: Pulling data...")
54
+ try:
55
+ temp_dir = self.data_dir.parent / "temp_data_sync"
56
+ if temp_dir.exists(): shutil.rmtree(temp_dir)
57
+ temp_dir.mkdir()
58
+
59
+ snapshot_download(
60
+ repo_id=self.repo_id,
61
+ repo_type="dataset",
62
+ local_dir=temp_dir,
63
+ token=self.token,
64
+ allow_patterns=["skills.json"]
65
+ )
66
+
67
+ # Simple overwrite strategy for now, as this is a personal tool
68
+ # In a real multi-user scenario, we'd need smart merging
69
+ src = temp_dir / "skills.json"
70
+ dst = self.data_dir / "skills.json"
71
+ if src.exists():
72
+ shutil.copy2(src, dst)
73
+ print("Sync: Data pulled successfully.")
74
+
75
+ shutil.rmtree(temp_dir)
76
+ except Exception as e:
77
+ print(f"Sync: Pull failed: {e}")
78
+
79
+ def push(self):
80
+ if not self.api: return
81
+ if self.is_pushing: return
82
+
83
+ print("Sync: Pushing data...")
84
+ self.is_pushing = True
85
+ try:
86
+ self.api.upload_file(
87
+ path_or_fileobj=self.data_dir / "skills.json",
88
+ path_in_repo="skills.json",
89
+ repo_id=self.repo_id,
90
+ repo_type="dataset",
91
+ commit_message=f"Update skills.json {int(time.time())}"
92
+ )
93
+ print("Sync: Push successful.")
94
+ except Exception as e:
95
+ print(f"Sync: Push failed: {e}")
96
+ finally:
97
+ self.is_pushing = False
98
+
99
+ sync_manager = SyncManager(REPO_ID, HF_TOKEN, DATA_DIR)
100
+
101
+ # Initialize data
102
+ if not SKILLS_FILE.exists():
103
+ # Try to pull first
104
+ sync_manager.pull()
105
+ # If still not exists, use default
106
+ if not SKILLS_FILE.exists():
107
+ with open(SKILLS_FILE, "w") as f:
108
+ json.dump(DEFAULT_SKILLS, f, indent=2)
109
+
110
+ @app.route("/")
111
+ def serve_index():
112
+ if os.path.exists(os.path.join(app.static_folder, "index.html")):
113
+ return send_from_directory(app.static_folder, "index.html")
114
+ return "Frontend not built. Please run `npm run build` in frontend directory."
115
+
116
+ @app.route("/<path:path>")
117
+ def serve_static(path):
118
+ if os.path.exists(os.path.join(app.static_folder, path)):
119
+ return send_from_directory(app.static_folder, path)
120
+ return serve_index()
121
+
122
+ @app.route("/api/skills", methods=["GET"])
123
+ def get_skills():
124
+ if SKILLS_FILE.exists():
125
+ with open(SKILLS_FILE, "r") as f:
126
+ return jsonify(json.load(f))
127
+ return jsonify(DEFAULT_SKILLS)
128
+
129
+ @app.route("/api/skills", methods=["POST"])
130
+ def save_skills():
131
+ data = request.json
132
+ with open(SKILLS_FILE, "w") as f:
133
+ json.dump(data, f, indent=2)
134
+
135
+ # Trigger sync in background
136
+ threading.Thread(target=sync_manager.push).start()
137
+
138
+ return jsonify({"status": "success"})
139
+
140
+ if __name__ == "__main__":
141
+ app.run(host="0.0.0.0", port=7861)
frontend/.gitignore ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Logs
2
+ logs
3
+ *.log
4
+ npm-debug.log*
5
+ yarn-debug.log*
6
+ yarn-error.log*
7
+ pnpm-debug.log*
8
+ lerna-debug.log*
9
+
10
+ node_modules
11
+ dist
12
+ dist-ssr
13
+ *.local
14
+
15
+ # Editor directories and files
16
+ .vscode/*
17
+ !.vscode/extensions.json
18
+ .idea
19
+ .DS_Store
20
+ *.suo
21
+ *.ntvs*
22
+ *.njsproj
23
+ *.sln
24
+ *.sw?
frontend/README.md ADDED
@@ -0,0 +1,73 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # React + TypeScript + Vite
2
+
3
+ This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
4
+
5
+ Currently, two official plugins are available:
6
+
7
+ - [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
8
+ - [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
9
+
10
+ ## React Compiler
11
+
12
+ The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
13
+
14
+ ## Expanding the ESLint configuration
15
+
16
+ If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
17
+
18
+ ```js
19
+ export default defineConfig([
20
+ globalIgnores(['dist']),
21
+ {
22
+ files: ['**/*.{ts,tsx}'],
23
+ extends: [
24
+ // Other configs...
25
+
26
+ // Remove tseslint.configs.recommended and replace with this
27
+ tseslint.configs.recommendedTypeChecked,
28
+ // Alternatively, use this for stricter rules
29
+ tseslint.configs.strictTypeChecked,
30
+ // Optionally, add this for stylistic rules
31
+ tseslint.configs.stylisticTypeChecked,
32
+
33
+ // Other configs...
34
+ ],
35
+ languageOptions: {
36
+ parserOptions: {
37
+ project: ['./tsconfig.node.json', './tsconfig.app.json'],
38
+ tsconfigRootDir: import.meta.dirname,
39
+ },
40
+ // other options...
41
+ },
42
+ },
43
+ ])
44
+ ```
45
+
46
+ You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
47
+
48
+ ```js
49
+ // eslint.config.js
50
+ import reactX from 'eslint-plugin-react-x'
51
+ import reactDom from 'eslint-plugin-react-dom'
52
+
53
+ export default defineConfig([
54
+ globalIgnores(['dist']),
55
+ {
56
+ files: ['**/*.{ts,tsx}'],
57
+ extends: [
58
+ // Other configs...
59
+ // Enable lint rules for React
60
+ reactX.configs['recommended-typescript'],
61
+ // Enable lint rules for React DOM
62
+ reactDom.configs.recommended,
63
+ ],
64
+ languageOptions: {
65
+ parserOptions: {
66
+ project: ['./tsconfig.node.json', './tsconfig.app.json'],
67
+ tsconfigRootDir: import.meta.dirname,
68
+ },
69
+ // other options...
70
+ },
71
+ },
72
+ ])
73
+ ```
frontend/eslint.config.js ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import js from '@eslint/js'
2
+ import globals from 'globals'
3
+ import reactHooks from 'eslint-plugin-react-hooks'
4
+ import reactRefresh from 'eslint-plugin-react-refresh'
5
+ import tseslint from 'typescript-eslint'
6
+ import { defineConfig, globalIgnores } from 'eslint/config'
7
+
8
+ export default defineConfig([
9
+ globalIgnores(['dist']),
10
+ {
11
+ files: ['**/*.{ts,tsx}'],
12
+ extends: [
13
+ js.configs.recommended,
14
+ tseslint.configs.recommended,
15
+ reactHooks.configs.flat.recommended,
16
+ reactRefresh.configs.vite,
17
+ ],
18
+ languageOptions: {
19
+ ecmaVersion: 2020,
20
+ globals: globals.browser,
21
+ },
22
+ },
23
+ ])
frontend/index.html ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <link rel="icon" type="image/svg+xml" href="/vite.svg" />
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
+ <title>frontend</title>
8
+ </head>
9
+ <body>
10
+ <div id="root"></div>
11
+ <script type="module" src="/src/main.tsx"></script>
12
+ </body>
13
+ </html>
frontend/package-lock.json ADDED
The diff for this file is too large to render. See raw diff
 
frontend/package.json ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "frontend",
3
+ "private": true,
4
+ "version": "0.0.0",
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "vite",
8
+ "build": "tsc -b && vite build",
9
+ "lint": "eslint .",
10
+ "preview": "vite preview"
11
+ },
12
+ "dependencies": {
13
+ "@tailwindcss/vite": "^4.1.18",
14
+ "@xyflow/react": "^12.10.0",
15
+ "clsx": "^2.1.1",
16
+ "lucide-react": "^0.563.0",
17
+ "react": "^19.2.0",
18
+ "react-dom": "^19.2.0",
19
+ "tailwind-merge": "^3.4.0"
20
+ },
21
+ "devDependencies": {
22
+ "@eslint/js": "^9.39.1",
23
+ "@types/node": "^24.10.1",
24
+ "@types/react": "^19.2.5",
25
+ "@types/react-dom": "^19.2.3",
26
+ "@vitejs/plugin-react": "^5.1.1",
27
+ "autoprefixer": "^10.4.23",
28
+ "eslint": "^9.39.1",
29
+ "eslint-plugin-react-hooks": "^7.0.1",
30
+ "eslint-plugin-react-refresh": "^0.4.24",
31
+ "globals": "^16.5.0",
32
+ "postcss": "^8.5.6",
33
+ "tailwindcss": "^4.1.18",
34
+ "typescript": "~5.9.3",
35
+ "typescript-eslint": "^8.46.4",
36
+ "vite": "npm:rolldown-vite@7.2.5"
37
+ },
38
+ "overrides": {
39
+ "vite": "npm:rolldown-vite@7.2.5"
40
+ }
41
+ }
frontend/public/vite.svg ADDED
frontend/src/App.css ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #root {
2
+ max-width: 1280px;
3
+ margin: 0 auto;
4
+ padding: 2rem;
5
+ text-align: center;
6
+ }
7
+
8
+ .logo {
9
+ height: 6em;
10
+ padding: 1.5em;
11
+ will-change: filter;
12
+ transition: filter 300ms;
13
+ }
14
+ .logo:hover {
15
+ filter: drop-shadow(0 0 2em #646cffaa);
16
+ }
17
+ .logo.react:hover {
18
+ filter: drop-shadow(0 0 2em #61dafbaa);
19
+ }
20
+
21
+ @keyframes logo-spin {
22
+ from {
23
+ transform: rotate(0deg);
24
+ }
25
+ to {
26
+ transform: rotate(360deg);
27
+ }
28
+ }
29
+
30
+ @media (prefers-reduced-motion: no-preference) {
31
+ a:nth-of-type(2) .logo {
32
+ animation: logo-spin infinite 20s linear;
33
+ }
34
+ }
35
+
36
+ .card {
37
+ padding: 2em;
38
+ }
39
+
40
+ .read-the-docs {
41
+ color: #888;
42
+ }
frontend/src/App.tsx ADDED
@@ -0,0 +1,139 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useCallback, useEffect, useState } from 'react';
2
+ import {
3
+ ReactFlow,
4
+ MiniMap,
5
+ Controls,
6
+ Background,
7
+ useNodesState,
8
+ useEdgesState,
9
+ addEdge,
10
+ Panel,
11
+ type Connection,
12
+ type Node,
13
+ } from '@xyflow/react';
14
+ import '@xyflow/react/dist/style.css';
15
+ import { CustomNode } from './CustomNode';
16
+ import { Save, Plus } from 'lucide-react';
17
+
18
+ const nodeTypes = {
19
+ custom: CustomNode,
20
+ };
21
+
22
+ const INITIAL_NODES: Node[] = [
23
+ { id: '1', type: 'custom', data: { label: '开始', status: 'completed' }, position: { x: 250, y: 5 } },
24
+ ];
25
+
26
+ export default function App() {
27
+ const [nodes, setNodes, onNodesChange] = useNodesState(INITIAL_NODES);
28
+ const [edges, setEdges, onEdgesChange] = useEdgesState([]);
29
+ const [loading, setLoading] = useState(true);
30
+
31
+ // Fetch initial data
32
+ useEffect(() => {
33
+ fetch('/api/skills')
34
+ .then(res => res.json())
35
+ .then(data => {
36
+ if (data.nodes && data.nodes.length > 0) {
37
+ // Map standard types to custom if needed
38
+ const mappedNodes = data.nodes.map((n: Node) => ({
39
+ ...n,
40
+ type: 'custom', // Force custom type
41
+ }));
42
+ setNodes(mappedNodes);
43
+ setEdges(data.edges || []);
44
+ }
45
+ })
46
+ .catch(err => console.error("Failed to load skills", err))
47
+ .finally(() => setLoading(false));
48
+ }, [setNodes, setEdges]);
49
+
50
+ const onConnect = useCallback(
51
+ (params: Connection) => setEdges((eds) => addEdge(params, eds)),
52
+ [setEdges],
53
+ );
54
+
55
+ const onSave = useCallback(() => {
56
+ fetch('/api/skills', {
57
+ method: 'POST',
58
+ headers: { 'Content-Type': 'application/json' },
59
+ body: JSON.stringify({ nodes, edges }),
60
+ }).then(() => alert('保存成功!'));
61
+ }, [nodes, edges]);
62
+
63
+ const onNodeClick = useCallback((_: React.MouseEvent, node: Node) => {
64
+ // Simple toggle status logic for demo
65
+ setNodes((nds) =>
66
+ nds.map((n) => {
67
+ if (n.id === node.id) {
68
+ const statuses = ['pending', 'in_progress', 'completed'];
69
+ const currentIdx = statuses.indexOf((n.data.status as string) || 'pending');
70
+ const nextStatus = statuses[(currentIdx + 1) % 3];
71
+ return {
72
+ ...n,
73
+ data: { ...n.data, status: nextStatus },
74
+ };
75
+ }
76
+ return n;
77
+ })
78
+ );
79
+ }, [setNodes]);
80
+
81
+ const onAddNode = useCallback(() => {
82
+ const id = (Math.max(...nodes.map(n => parseInt(n.id) || 0), 0) + 1).toString();
83
+ const newNode: Node = {
84
+ id,
85
+ type: 'custom',
86
+ position: { x: Math.random() * 400 + 100, y: Math.random() * 400 + 100 },
87
+ data: { label: `技能 ${id}`, status: 'pending' },
88
+ };
89
+ setNodes((nds) => nds.concat(newNode));
90
+ }, [nodes, setNodes]);
91
+
92
+ if (loading) return <div className="flex items-center justify-center h-screen" style={{ height: '100vh' }}>加载中...</div>;
93
+
94
+ return (
95
+ <div className="w-full h-screen bg-gray-50" style={{ height: '100vh', width: '100vw' }}>
96
+ <ReactFlow
97
+ nodes={nodes}
98
+ edges={edges}
99
+ onNodesChange={onNodesChange}
100
+ onEdgesChange={onEdgesChange}
101
+ onConnect={onConnect}
102
+ onNodeClick={onNodeClick}
103
+ nodeTypes={nodeTypes}
104
+ fitView
105
+ >
106
+ <Controls />
107
+ <MiniMap />
108
+ <Background gap={12} size={1} />
109
+ <Panel position="top-right" className="flex gap-2">
110
+ <button
111
+ onClick={onAddNode}
112
+ className="flex items-center gap-2 px-4 py-2 bg-white border border-gray-200 rounded-lg shadow-sm hover:bg-gray-50 text-gray-700 font-medium cursor-pointer"
113
+ >
114
+ <Plus className="w-4 h-4" />
115
+ 添加技能
116
+ </button>
117
+ <button
118
+ onClick={onSave}
119
+ className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg shadow-sm hover:bg-blue-700 font-medium cursor-pointer"
120
+ >
121
+ <Save className="w-4 h-4" />
122
+ 保存进度
123
+ </button>
124
+ </Panel>
125
+ <Panel position="top-left" className="bg-white/80 backdrop-blur p-4 rounded-lg border border-gray-200 shadow-sm max-w-sm">
126
+ <h1 className="text-xl font-bold text-gray-900 mb-1">技能树追踪器</h1>
127
+ <p className="text-sm text-gray-500">
128
+ 点击节点切换状态,拖拽手柄连接节点。
129
+ </p>
130
+ <div className="flex gap-3 mt-3 text-xs">
131
+ <div className="flex items-center gap-1"><div className="w-2 h-2 rounded-full bg-gray-400"></div>待办</div>
132
+ <div className="flex items-center gap-1"><div className="w-2 h-2 rounded-full bg-blue-400"></div>进行中</div>
133
+ <div className="flex items-center gap-1"><div className="w-2 h-2 rounded-full bg-green-400"></div>已完成</div>
134
+ </div>
135
+ </Panel>
136
+ </ReactFlow>
137
+ </div>
138
+ );
139
+ }
frontend/src/CustomNode.tsx ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Handle, Position, type NodeProps, type Node } from '@xyflow/react';
2
+ import { clsx } from 'clsx';
3
+ import { CheckCircle2, Circle, Clock } from 'lucide-react';
4
+
5
+ type NodeData = {
6
+ label: string;
7
+ status: 'pending' | 'in_progress' | 'completed';
8
+ };
9
+
10
+ // We need to extend NodeProps to include our specific data type
11
+ export function CustomNode({ data }: NodeProps<Node<NodeData>>) {
12
+ const statusColors = {
13
+ pending: 'bg-gray-100 border-gray-300 text-gray-500',
14
+ in_progress: 'bg-blue-50 border-blue-400 text-blue-700',
15
+ completed: 'bg-green-50 border-green-400 text-green-700',
16
+ };
17
+
18
+ const status = (data.status as keyof typeof statusColors) || 'pending';
19
+
20
+ const StatusIcon = {
21
+ pending: Circle,
22
+ in_progress: Clock,
23
+ completed: CheckCircle2,
24
+ }[status];
25
+
26
+ return (
27
+ <div className={clsx(
28
+ "px-4 py-2 rounded-lg border-2 shadow-sm min-w-[150px] transition-all bg-white",
29
+ statusColors[status]
30
+ )}>
31
+ <Handle type="target" position={Position.Top} className="w-3 h-3 !bg-gray-400" />
32
+
33
+ <div className="flex items-center gap-2">
34
+ <StatusIcon className="w-4 h-4" />
35
+ <span className="font-medium text-sm">{data.label}</span>
36
+ </div>
37
+
38
+ <Handle type="source" position={Position.Bottom} className="w-3 h-3 !bg-gray-400" />
39
+ </div>
40
+ );
41
+ }
frontend/src/assets/react.svg ADDED
frontend/src/index.css ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ @import "tailwindcss";
2
+
3
+ @theme {
4
+ --font-sans: "Inter", system-ui, sans-serif;
5
+ }
6
+
7
+ html, body, #root {
8
+ height: 100%;
9
+ margin: 0;
10
+ font-family: var(--font-sans);
11
+ }
frontend/src/main.tsx ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ import { StrictMode } from 'react'
2
+ import { createRoot } from 'react-dom/client'
3
+ import './index.css'
4
+ import App from './App.tsx'
5
+
6
+ createRoot(document.getElementById('root')!).render(
7
+ <StrictMode>
8
+ <App />
9
+ </StrictMode>,
10
+ )
frontend/tsconfig.app.json ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "compilerOptions": {
3
+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
4
+ "target": "ES2022",
5
+ "useDefineForClassFields": true,
6
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
7
+ "module": "ESNext",
8
+ "types": ["vite/client"],
9
+ "skipLibCheck": true,
10
+
11
+ /* Bundler mode */
12
+ "moduleResolution": "bundler",
13
+ "allowImportingTsExtensions": true,
14
+ "verbatimModuleSyntax": true,
15
+ "moduleDetection": "force",
16
+ "noEmit": true,
17
+ "jsx": "react-jsx",
18
+
19
+ /* Linting */
20
+ "strict": true,
21
+ "noUnusedLocals": true,
22
+ "noUnusedParameters": true,
23
+ "erasableSyntaxOnly": true,
24
+ "noFallthroughCasesInSwitch": true,
25
+ "noUncheckedSideEffectImports": true
26
+ },
27
+ "include": ["src"]
28
+ }
frontend/tsconfig.json ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ {
2
+ "files": [],
3
+ "references": [
4
+ { "path": "./tsconfig.app.json" },
5
+ { "path": "./tsconfig.node.json" }
6
+ ]
7
+ }
frontend/tsconfig.node.json ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "compilerOptions": {
3
+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
4
+ "target": "ES2023",
5
+ "lib": ["ES2023"],
6
+ "module": "ESNext",
7
+ "types": ["node"],
8
+ "skipLibCheck": true,
9
+
10
+ /* Bundler mode */
11
+ "moduleResolution": "bundler",
12
+ "allowImportingTsExtensions": true,
13
+ "verbatimModuleSyntax": true,
14
+ "moduleDetection": "force",
15
+ "noEmit": true,
16
+
17
+ /* Linting */
18
+ "strict": true,
19
+ "noUnusedLocals": true,
20
+ "noUnusedParameters": true,
21
+ "erasableSyntaxOnly": true,
22
+ "noFallthroughCasesInSwitch": true,
23
+ "noUncheckedSideEffectImports": true
24
+ },
25
+ "include": ["vite.config.ts"]
26
+ }
frontend/vite.config.ts ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { defineConfig } from 'vite'
2
+ import react from '@vitejs/plugin-react'
3
+ import tailwindcss from '@tailwindcss/vite'
4
+
5
+ // https://vite.dev/config/
6
+ export default defineConfig({
7
+ plugins: [react(), tailwindcss()],
8
+ server: {
9
+ proxy: {
10
+ '/api': {
11
+ target: 'http://127.0.0.1:7861',
12
+ changeOrigin: true,
13
+ }
14
+ }
15
+ }
16
+ })
requirements.txt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ flask
2
+ python-dotenv
3
+ huggingface_hub