from __future__ import annotations import json import os import re from collections.abc import Iterator from dataclasses import dataclass, field from pathlib import Path IGNORED_DIRECTORIES = {"node_modules", "target", "build", "dist", ".git", ".idea", ".vscode"} @dataclass(frozen=True) class ProjectComponent: kind: str path: Path detail: str metadata: dict[str, str] = field(default_factory=dict) @dataclass(frozen=True) class ScanResult: root: Path backend: ProjectComponent | None frontend: ProjectComponent | None sql_files: list[Path] frontends: list[ProjectComponent] = field(default_factory=list) @property def detected_frontends(self) -> list[ProjectComponent]: """Return every detected frontend while preserving the old single-frontend API.""" if self.frontends: return self.frontends return [self.frontend] if self.frontend else [] def scan_project(root: str | Path) -> ScanResult: project_root = Path(root).resolve() if not project_root.exists(): raise FileNotFoundError(f"目录不存在: {project_root}") if not project_root.is_dir(): raise NotADirectoryError(f"不是文件夹: {project_root}") backend = _find_backend(project_root) frontends = _find_frontends(project_root) frontend = frontends[0] if frontends else None sql_files = list(_walk_project_files(project_root, suffix=".sql")) return ScanResult(project_root, backend, frontend, sql_files, frontends) def _find_backend(root: Path) -> ProjectComponent | None: candidates: list[tuple[int, Path, str]] = [] for build_file in _walk_project_files(root, names={"pom.xml", "build.gradle", "build.gradle.kts"}): project_dir = build_file.parent score = 1 detail_parts = [build_file.name] text = _read_text(build_file) metadata = _extract_backend_metadata(text) metadata.update(_extract_backend_runtime_metadata(project_dir)) if "spring-boot" in text or "org.springframework.boot" in text: score += 3 detail_parts.append("Spring Boot dependency") if java_version := metadata.get("java_version"): detail_parts.append(f"Java {java_version}") if boot_version := metadata.get("spring_boot_version"): detail_parts.append(f"Spring Boot {boot_version}") if (project_dir / "src" / "main" / "java").exists(): score += 2 detail_parts.append("src/main/java") candidates.append((score, project_dir, ", ".join(detail_parts), metadata)) if not candidates: return None score, path, detail, metadata = sorted(candidates, key=lambda item: (-item[0], len(item[1].parts)))[0] if score < 2: return None return ProjectComponent("Spring Boot", path, detail, metadata) def _find_frontends(root: Path) -> list[ProjectComponent]: candidates: list[tuple[int, Path, str, dict[str, str]]] = [] for package_file in _walk_project_files(root, names={"package.json"}): project_dir = package_file.parent score = 1 detail_parts = ["package.json"] package = _load_json(package_file) deps = {} for key in ("dependencies", "devDependencies"): value = package.get(key) if isinstance(value, dict): deps.update(value) scripts = package.get("scripts") if isinstance(package.get("scripts"), dict) else {} if "vue" in deps: score += 3 detail_parts.append("Vue dependency") if "build" in scripts: score += 1 detail_parts.append("build script") if any((project_dir / name).exists() for name in ("vue.config.js", "vite.config.js", "vite.config.ts")): score += 1 detail_parts.append("Vue/Vite config") metadata = _classify_frontend(project_dir) for script_name in ("serve", "dev", "start"): if isinstance(scripts.get(script_name), str): metadata["start_script"] = script_name break configured_port = _extract_frontend_port(project_dir) if configured_port: metadata["port"] = configured_port detail_parts.append(f"port {configured_port}") candidates.append((score, project_dir, ", ".join(detail_parts), metadata)) if not candidates: return [] detected = [ ProjectComponent("Vue", path, detail, metadata) for score, path, detail, metadata in candidates if score >= 3 ] role_order = {"user": 0, "admin": 1, "frontend": 2} return sorted( detected, key=lambda item: (role_order.get(item.metadata.get("role", "frontend"), 2), len(item.path.parts), str(item.path)), ) def _find_frontend(root: Path) -> ProjectComponent | None: """Compatibility helper for callers that still expect one frontend.""" frontends = _find_frontends(root) return frontends[0] if frontends else None def _classify_frontend(project_dir: Path) -> dict[str, str]: name = project_dir.name.lower() if any(token in name for token in ("manage", "manager", "admin", "backend", "backstage", "后台", "管理")): return {"role": "admin", "display_name": "管理端"} if any(token in name for token in ("client", "user", "portal", "front", "web", "用户", "前台")): return {"role": "user", "display_name": "用户端"} return {"role": "frontend", "display_name": project_dir.name} def _extract_frontend_port(project_dir: Path) -> str | None: for name in ("vue.config.js", "vite.config.js", "vite.config.ts"): config_file = project_dir / name if not config_file.is_file(): continue match = re.search(r"\bport\s*:\s*['\"]?(\d{1,5})", _read_text(config_file), re.IGNORECASE) if match and 1 <= int(match.group(1)) <= 65535: return match.group(1) return None def _is_ignored(path: Path) -> bool: return any(part in IGNORED_DIRECTORIES for part in path.parts) def _walk_project_files( root: Path, *, names: set[str] | None = None, suffix: str | None = None, ) -> Iterator[Path]: """Yield matching files without descending into generated or dependency directories.""" for current_dir, directories, filenames in os.walk(root, topdown=True, onerror=lambda _error: None): directories[:] = sorted(name for name in directories if name not in IGNORED_DIRECTORIES) for filename in sorted(filenames): if names is not None and filename not in names: continue if suffix is not None and not filename.lower().endswith(suffix.lower()): continue yield Path(current_dir) / filename def _read_text(path: Path) -> str: try: return path.read_text(encoding="utf-8", errors="ignore").lower() except OSError: return "" def _load_json(path: Path) -> dict: try: return json.loads(path.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError): return {} def _extract_backend_metadata(text: str) -> dict[str, str]: metadata: dict[str, str] = {} java_patterns = [ r"\s*([^<\s]+)\s*", r"\s*([^<\s]+)\s*", r"\s*([^<\s]+)\s*", r"sourceCompatibility\s*=\s*['\"]?([^'\"\s]+)", r"targetCompatibility\s*=\s*['\"]?([^'\"\s]+)", r"java\s*\{\s*sourceCompatibility\s*=\s*JavaVersion\.VERSION_([^'\"\s}]+)", ] for pattern in java_patterns: match = re.search(pattern, text, re.IGNORECASE | re.MULTILINE) if match: metadata["java_version"] = _normalize_java_version(match.group(1)) metadata["java_version_source"] = _java_source_label(pattern) break boot_patterns = [ r"\s*spring-boot-starter-parent\s*\s*\s*([^<\s]+)\s*", r"org\.springframework\.boot['\"]?\s*:\s*spring-boot[^:'\"]*['\"]?\s*:\s*([^'\"\s]+)", r"id\s+['\"]org\.springframework\.boot['\"]\s+version\s+['\"]([^'\"]+)['\"]", ] for pattern in boot_patterns: match = re.search(pattern, text, re.IGNORECASE | re.MULTILINE) if match: metadata["spring_boot_version"] = match.group(1) break return metadata def _extract_backend_runtime_metadata(project_dir: Path) -> dict[str, str]: resources = project_dir / "src" / "main" / "resources" if not resources.is_dir(): return {} for pattern in ("application*.properties", "application*.yml", "application*.yaml"): for config_file in sorted(resources.glob(pattern)): try: text = config_file.read_text(encoding="utf-8", errors="ignore") except OSError: continue match = re.search( r"(?:server\.(?:servlet\.)?)?context-path\s*[:=]\s*['\"]?([^'\"\s#]+)", text, re.IGNORECASE, ) if match: context_path = match.group(1).strip() if not context_path.startswith("/"): context_path = "/" + context_path return {"context_path": context_path} return {} def _normalize_java_version(value: str) -> str: clean = value.strip().replace("version_", "").replace("_", ".").lower() if clean.startswith("1."): return clean.split(".", 1)[1] return clean.split(".", 1)[0] def _java_source_label(pattern: str) -> str: if "maven\\.compiler\\.source" in pattern: return "maven.compiler.source" if "maven\\.compiler\\.target" in pattern: return "maven.compiler.target" if "java\\.version" in pattern: return "java.version" if "sourceCompatibility" in pattern: return "sourceCompatibility" if "targetCompatibility" in pattern: return "targetCompatibility" return "项目配置"