diff --git a/console.txt b/console.txt new file mode 100644 index 0000000..b39d35b --- /dev/null +++ b/console.txt @@ -0,0 +1,6 @@ +Total messages: 5 (Errors: 3, Warnings: 0) +Returning 3 messages for level "error" + +[ERROR] Manifest: Line: 1, column: 1, Syntax error. @ http://localhost:3000/site.webmanifest:0 +[ERROR] Manifest: Line: 1, column: 1, Syntax error. @ http://localhost:3000/site.webmanifest:0 +The requested module '/node_modules/lru_map/dist/lru.js?v=e2db4b71' does not provide an export named 'default' \ No newline at end of file diff --git a/counter.py b/counter.py new file mode 100644 index 0000000..f384dcb --- /dev/null +++ b/counter.py @@ -0,0 +1,156 @@ +#!/usr/bin/env python3 +import os +import sys +import argparse +from pathlib import Path + +if sys.platform == "win32": + sys.stdout.reconfigure(encoding="utf-8") + sys.stderr.reconfigure(encoding="utf-8") + + +def count_lines(file_path: Path) -> int: + try: + with open(file_path, "r", encoding="utf-8", errors="ignore") as f: + return sum(1 for _ in f) + except Exception: + return 0 + + +def should_include(file_path: Path, extensions: list[str]) -> bool: + if not extensions: + return True + return file_path.suffix in extensions + + +def walk_dir(root_path: Path, extensions: list[str], exclude_dirs: set[str]): + results = [] + total_lines = 0 + + for root, dirs, files in os.walk(root_path): + dirs[:] = [d for d in dirs if d not in exclude_dirs] + + rel_root = Path(root).relative_to(root_path) + indent = len(rel_root.parts) if str(rel_root) != "." else 0 + prefix = " " * indent + + if indent > 0: + print(f"{prefix}└── {rel_root.name}/") + + for i, file in enumerate(files): + file_path = Path(root) / file + + if not should_include(file_path, extensions): + continue + + lines = count_lines(file_path) + total_lines += lines + results.append((file_path, lines, indent + 1)) + + is_last = (i == len(files) - 1) and not any( + should_include(Path(root) / f, extensions) for f in dirs + ) + connector = "└──" if is_last else "├──" + + print(f"{' ' * (indent + 1)}{connector} {file} ({lines} lines)") + + return total_lines + + +def main_all(): + directories = { + "src": [".ts", ".tsx", ".vue", ".js"], + "api": [".ts", ".py"], + "electron": [".ts"], + "remote": [".js"], + "shared": [".ts"], + "tools": [".py"], + } + + total_all = 0 + results = [] + + for directory, extensions in directories.items(): + root_path = Path(directory) + if not root_path.exists(): + continue + + exclude_dirs = { + ".git", + "node_modules", + "dist", + "dist-api", + "dist-electron", + "__pycache__", + ".ruff_cache", + } + + print(f"\n{'=' * 60}") + print(f"项目文件统计: {root_path}") + print(f"后缀过滤: {extensions}") + print(f"{'=' * 60}\n") + + total = walk_dir(root_path, extensions, exclude_dirs) + results.append((directory, total)) + total_all += total + + print(f"\n{'=' * 60}") + print("汇总统计") + print(f"{'=' * 60}") + for name, lines in results: + print(f"{name:15} {lines:>10,} 行") + print(f"{'=' * 60}") + print(f"{'总计':15} {total_all:>10,} 行") + print(f"{'=' * 60}\n") + + +if __name__ == "__main__": + import argparse + + parser = argparse.ArgumentParser(description="统计项目文件行数") + parser.add_argument( + "-e", "--extension", action="append", help="指定后缀名,如: .py .ts .js" + ) + parser.add_argument("-d", "--directory", default=".", help="指定子文件夹路径") + parser.add_argument( + "-x", "--exclude", action="append", default=[], help="排除的文件夹" + ) + parser.add_argument( + "--all", + action="store_true", + help="统计所有源码目录 (src/api/electron/remote/shared/tools)", + ) + + args = parser.parse_args() + + if args.all: + main_all() + else: + root_path = Path(args.directory).resolve() + extensions = args.extension + exclude_dirs = { + ".git", + "node_modules", + "dist", + "dist-api", + "dist-electron", + "__pycache__", + ".ruff_cache", + } + exclude_dirs.update(args.exclude) + + if not root_path.exists(): + print(f"错误: 目录 {root_path} 不存在") + sys.exit(1) + + print(f"\n{'=' * 60}") + print(f"项目文件统计: {root_path}") + if extensions: + print(f"后缀过滤: {extensions}") + print(f"{'=' * 60}\n") + + total = walk_dir(root_path, extensions, exclude_dirs) + + print(f"\n{'=' * 60}") + print(f"总行数: {total}") + print(f"{'=' * 60}\n") diff --git a/dist-api/ai-GLAEBMJM.js b/dist-api/ai-GLAEBMJM.js new file mode 100644 index 0000000..b1cb65c --- /dev/null +++ b/dist-api/ai-GLAEBMJM.js @@ -0,0 +1,12 @@ +import { + ai_default, + createAiModule, + createAiRoutes +} from "./chunk-TSQNCXAS.js"; +import "./chunk-ER4KPD22.js"; +import "./chunk-74TMTGBG.js"; +export { + createAiModule, + createAiRoutes, + ai_default as default +}; diff --git a/dist-api/chunk-47DJ6YUB.js b/dist-api/chunk-47DJ6YUB.js new file mode 100644 index 0000000..ad0a3c9 --- /dev/null +++ b/dist-api/chunk-47DJ6YUB.js @@ -0,0 +1,15 @@ +// api/utils/logger.ts +var createLogger = () => { + const isProd = process.env.NODE_ENV === "production"; + const debug = isProd ? () => { + } : console.debug.bind(console); + const info = console.info.bind(console); + const warn = console.warn.bind(console); + const error = console.error.bind(console); + return { debug, info, warn, error }; +}; +var logger = createLogger(); + +export { + logger +}; diff --git a/dist-api/chunk-5EGA6GHY.js b/dist-api/chunk-5EGA6GHY.js new file mode 100644 index 0000000..8a27427 --- /dev/null +++ b/dist-api/chunk-5EGA6GHY.js @@ -0,0 +1,39 @@ +import { + ValidationError +} from "./chunk-ER4KPD22.js"; + +// api/middlewares/validate.ts +import { ZodError } from "zod"; +var validateBody = (schema) => { + return (req, _res, next) => { + try { + req.body = schema.parse(req.body); + next(); + } catch (error) { + if (error instanceof ZodError) { + next(new ValidationError("Request validation failed", { issues: error.issues })); + } else { + next(error); + } + } + }; +}; +var validateQuery = (schema) => { + return (req, _res, next) => { + try { + req.query = schema.parse(req.query); + next(); + } catch (error) { + if (error instanceof ZodError) { + next(new ValidationError("Query validation failed", { issues: error.issues })); + } else { + next(error); + } + } + }; +}; + +export { + validateBody, + validateQuery +}; diff --git a/dist-api/chunk-74TMTGBG.js b/dist-api/chunk-74TMTGBG.js new file mode 100644 index 0000000..d06f109 --- /dev/null +++ b/dist-api/chunk-74TMTGBG.js @@ -0,0 +1,110 @@ +var __glob = (map) => (path2) => { + var fn = map[path2]; + if (fn) return fn(); + throw new Error("Module not found in bundle: " + path2); +}; + +// api/infra/createModule.ts +function createApiModule(config2, options) { + const metadata = { + id: config2.id, + name: config2.name, + version: config2.version, + basePath: config2.basePath, + order: config2.order, + dependencies: config2.dependencies + }; + const lifecycle = options.lifecycle ? options.lifecycle : options.services ? { + onLoad: options.services + } : void 0; + return { + metadata, + lifecycle, + createRouter: options.routes + }; +} + +// api/utils/asyncHandler.ts +var asyncHandler = (fn) => (req, res, next) => { + Promise.resolve(fn(req, res, next)).catch(next); +}; + +// api/utils/response.ts +var successResponse = (res, data, statusCode = 200) => { + const response = { + success: true, + data + }; + res.status(statusCode).json(response); +}; + +// api/config/index.ts +import path from "path"; +import { fileURLToPath } from "url"; +import os from "os"; +var __filename = fileURLToPath(import.meta.url); +var __dirname = path.dirname(__filename); +var config = { + get projectRoot() { + if (__dirname.includes("app.asar")) { + return path.resolve(__dirname, "..").replace("app.asar", "app.asar.unpacked"); + } + return path.resolve(__dirname, "../../"); + }, + get notebookRoot() { + return process.env.NOTEBOOK_ROOT ? path.resolve(process.env.NOTEBOOK_ROOT) : path.join(this.projectRoot, "notebook"); + }, + get tempRoot() { + return path.join(os.tmpdir(), "xcdesktop_uploads"); + }, + get serverPort() { + return parseInt(process.env.PORT || "3001", 10); + }, + get isVercel() { + return !!process.env.VERCEL; + }, + get isElectron() { + return __dirname.includes("app.asar"); + }, + get isDev() { + return !this.isElectron && !this.isVercel; + } +}; +var PATHS = { + get PROJECT_ROOT() { + return config.projectRoot; + }, + get NOTEBOOK_ROOT() { + return config.notebookRoot; + }, + get TEMP_ROOT() { + return config.tempRoot; + } +}; + +// api/config/paths.ts +var PROJECT_ROOT = PATHS.PROJECT_ROOT; +var NOTEBOOK_ROOT = PATHS.NOTEBOOK_ROOT; +var TEMP_ROOT = PATHS.TEMP_ROOT; + +// shared/modules/types.ts +function defineApiModule(config2) { + return config2; +} +function defineEndpoints(endpoints) { + return endpoints; +} + +export { + __glob, + asyncHandler, + successResponse, + config, + PATHS, + PROJECT_ROOT, + NOTEBOOK_ROOT, + TEMP_ROOT, + createApiModule, + defineApiModule, + defineEndpoints +}; diff --git a/dist-api/chunk-ER4KPD22.js b/dist-api/chunk-ER4KPD22.js new file mode 100644 index 0000000..c8a2458 --- /dev/null +++ b/dist-api/chunk-ER4KPD22.js @@ -0,0 +1,163 @@ +import { + NOTEBOOK_ROOT +} from "./chunk-74TMTGBG.js"; + +// shared/errors/index.ts +var AppError = class extends Error { + constructor(code, message, statusCode = 500, details) { + super(message); + this.code = code; + this.name = "AppError"; + this.statusCode = statusCode; + this.details = details; + } + statusCode; + details; + toJSON() { + return { + name: this.name, + code: this.code, + message: this.message, + statusCode: this.statusCode, + details: this.details + }; + } +}; +var ValidationError = class extends AppError { + constructor(message, details) { + super("VALIDATION_ERROR", message, 400, details); + this.name = "ValidationError"; + } +}; +var NotFoundError = class extends AppError { + constructor(message = "Resource not found", details) { + super("NOT_FOUND", message, 404, details); + this.name = "NotFoundError"; + } +}; +var AccessDeniedError = class extends AppError { + constructor(message = "Access denied", details) { + super("ACCESS_DENIED", message, 403, details); + this.name = "AccessDeniedError"; + } +}; +var BadRequestError = class extends AppError { + constructor(message, details) { + super("BAD_REQUEST", message, 400, details); + this.name = "BadRequestError"; + } +}; +var NotADirectoryError = class extends AppError { + constructor(message = "\u4E0D\u662F\u76EE\u5F55", details) { + super("NOT_A_DIRECTORY", message, 400, details); + this.name = "NotADirectoryError"; + } +}; +var AlreadyExistsError = class extends AppError { + constructor(message = "Resource already exists", details) { + super("ALREADY_EXISTS", message, 409, details); + this.name = "AlreadyExistsError"; + } +}; +var ForbiddenError = class extends AppError { + constructor(message = "\u7981\u6B62\u8BBF\u95EE", details) { + super("FORBIDDEN", message, 403, details); + this.name = "ForbiddenError"; + } +}; +var UnsupportedMediaTypeError = class extends AppError { + constructor(message = "\u4E0D\u652F\u6301\u7684\u5A92\u4F53\u7C7B\u578B", details) { + super("UNSUPPORTED_MEDIA_TYPE", message, 415, details); + this.name = "UnsupportedMediaTypeError"; + } +}; +var ResourceLockedError = class extends AppError { + constructor(message = "\u8D44\u6E90\u5DF2\u9501\u5B9A", details) { + super("RESOURCE_LOCKED", message, 423, details); + this.name = "ResourceLockedError"; + } +}; +var InternalError = class extends AppError { + constructor(message = "\u670D\u52A1\u5668\u5185\u90E8\u9519\u8BEF", details) { + super("INTERNAL_ERROR", message, 500, details); + this.name = "InternalError"; + } +}; +function isAppError(error) { + return error instanceof AppError; +} +function isNodeError(error) { + return error instanceof Error && "code" in error; +} + +// api/utils/pathSafety.ts +import path from "path"; +var DANGEROUS_PATTERNS = [ + /\.\./, + /\0/, + /%2e%2e[%/]/i, + /%252e%252e[%/]/i, + /\.\.%2f/i, + /\.\.%5c/i, + /%c0%ae/i, + /%c1%9c/i, + /%c0%ae%c0%ae/i, + /%c1%9c%c1%9c/i, + /\.\.%c0%af/i, + /\.\.%c1%9c/i, + /%252e/i, + /%uff0e/i, + /%u002e/i +]; +var DOUBLE_ENCODE_PATTERNS = [ + /%25[0-9a-fA-F]{2}/, + /%[0-9a-fA-F]{2}%[0-9a-fA-F]{2}/ +]; +var normalizeRelPath = (input) => { + const trimmed = input.replace(/\0/g, "").trim(); + return trimmed.replace(/^[/\\]+/, ""); +}; +var containsPathTraversal = (input) => { + const decoded = decodeURIComponentSafe(input); + return DANGEROUS_PATTERNS.some((pattern) => pattern.test(input) || pattern.test(decoded)); +}; +var containsDoubleEncoding = (input) => { + return DOUBLE_ENCODE_PATTERNS.some((pattern) => pattern.test(input)); +}; +var hasPathSecurityIssues = (input) => { + return containsPathTraversal(input) || containsDoubleEncoding(input); +}; +var decodeURIComponentSafe = (input) => { + try { + return decodeURIComponent(input); + } catch { + return input; + } +}; +var resolveNotebookPath = (relPath) => { + if (hasPathSecurityIssues(relPath)) { + throw new AccessDeniedError("Path traversal detected"); + } + const safeRelPath = normalizeRelPath(relPath); + const notebookRoot = path.resolve(NOTEBOOK_ROOT); + const fullPath = path.resolve(notebookRoot, safeRelPath); + if (!fullPath.startsWith(notebookRoot)) { + throw new AccessDeniedError("Access denied"); + } + return { safeRelPath, fullPath }; +}; + +export { + ValidationError, + NotFoundError, + BadRequestError, + NotADirectoryError, + AlreadyExistsError, + ForbiddenError, + UnsupportedMediaTypeError, + ResourceLockedError, + InternalError, + isAppError, + isNodeError, + resolveNotebookPath +}; diff --git a/dist-api/chunk-FTVFWJFJ.js b/dist-api/chunk-FTVFWJFJ.js new file mode 100644 index 0000000..e79caf7 --- /dev/null +++ b/dist-api/chunk-FTVFWJFJ.js @@ -0,0 +1,20 @@ +import { + PATHS +} from "./chunk-74TMTGBG.js"; + +// api/utils/tempDir.ts +import { existsSync, mkdirSync } from "fs"; +var tempDir = null; +var getTempDir = () => { + if (!tempDir) { + tempDir = PATHS.TEMP_ROOT; + if (!existsSync(tempDir)) { + mkdirSync(tempDir, { recursive: true }); + } + } + return tempDir; +}; + +export { + getTempDir +}; diff --git a/dist-api/chunk-M2SZ5AIA.js b/dist-api/chunk-M2SZ5AIA.js new file mode 100644 index 0000000..72c60a7 --- /dev/null +++ b/dist-api/chunk-M2SZ5AIA.js @@ -0,0 +1,234 @@ +import { + AlreadyExistsError, + BadRequestError, + NotFoundError, + ValidationError, + resolveNotebookPath +} from "./chunk-ER4KPD22.js"; +import { + asyncHandler, + createApiModule, + defineApiModule, + defineEndpoints, + successResponse +} from "./chunk-74TMTGBG.js"; + +// shared/modules/recycle-bin/api.ts +var RECYCLE_BIN_ENDPOINTS = defineEndpoints({ + list: { path: "/", method: "GET" }, + restore: { path: "/restore", method: "POST" }, + permanent: { path: "/permanent", method: "DELETE" }, + empty: { path: "/empty", method: "DELETE" } +}); + +// shared/modules/recycle-bin/index.ts +var RECYCLE_BIN_MODULE = defineApiModule({ + id: "recycle-bin", + name: "\u56DE\u6536\u7AD9", + basePath: "/recycle-bin", + order: 40, + version: "1.0.0", + endpoints: RECYCLE_BIN_ENDPOINTS +}); + +// api/modules/recycle-bin/routes.ts +import express from "express"; +import fs2 from "fs/promises"; +import path2 from "path"; + +// api/modules/recycle-bin/recycleBinService.ts +import fs from "fs/promises"; +import path from "path"; +async function restoreFile(srcPath, destPath, deletedDate, year, month, day) { + const { fullPath: imagesDir } = resolveNotebookPath(`images/${year}/${month}/${day}`); + let content = await fs.readFile(srcPath, "utf-8"); + const imageRegex = /!\[([^\]]*)\]\(([^)]+)\)/g; + let match; + const imageReplacements = []; + while ((match = imageRegex.exec(content)) !== null) { + const imagePath = match[2]; + const imageName = path.basename(imagePath); + const rbImageName = `${deletedDate}_${imageName}`; + const { fullPath: srcImagePath } = resolveNotebookPath(`RB/${rbImageName}`); + try { + await fs.access(srcImagePath); + await fs.mkdir(imagesDir, { recursive: true }); + const destImagePath = path.join(imagesDir, imageName); + await fs.rename(srcImagePath, destImagePath); + const newImagePath = `images/${year}/${month}/${day}/${imageName}`; + imageReplacements.push({ oldPath: imagePath, newPath: newImagePath }); + } catch { + } + } + for (const { oldPath, newPath } of imageReplacements) { + content = content.replace(new RegExp(oldPath.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "g"), newPath); + } + await fs.writeFile(destPath, content, "utf-8"); + await fs.unlink(srcPath); +} +async function restoreFolder(srcPath, destPath, deletedDate, year, month, day) { + await fs.mkdir(destPath, { recursive: true }); + const entries = await fs.readdir(srcPath, { withFileTypes: true }); + for (const entry of entries) { + const srcEntryPath = path.join(srcPath, entry.name); + const destEntryPath = path.join(destPath, entry.name); + if (entry.isDirectory()) { + await restoreFolder(srcEntryPath, destEntryPath, deletedDate, year, month, day); + } else if (entry.isFile() && entry.name.toLowerCase().endsWith(".md")) { + await restoreFile(srcEntryPath, destEntryPath, deletedDate, year, month, day); + } else { + await fs.rename(srcEntryPath, destEntryPath); + } + } + const remaining = await fs.readdir(srcPath); + if (remaining.length === 0) { + await fs.rmdir(srcPath); + } +} + +// api/modules/recycle-bin/routes.ts +var router = express.Router(); +router.get( + "/", + asyncHandler(async (req, res) => { + const { fullPath: rbDir } = resolveNotebookPath("RB"); + try { + await fs2.access(rbDir); + } catch { + successResponse(res, { groups: [] }); + return; + } + const entries = await fs2.readdir(rbDir, { withFileTypes: true }); + const items = []; + for (const entry of entries) { + const match = entry.name.match(/^(\d{8})_(.+)$/); + if (!match) continue; + const [, dateStr, originalName] = match; + if (entry.isDirectory()) { + items.push({ + name: entry.name, + originalName, + type: "dir", + deletedDate: dateStr, + path: `RB/${entry.name}` + }); + } else if (entry.isFile() && entry.name.toLowerCase().endsWith(".md")) { + items.push({ + name: entry.name, + originalName, + type: "file", + deletedDate: dateStr, + path: `RB/${entry.name}` + }); + } + } + const groupedMap = /* @__PURE__ */ new Map(); + for (const item of items) { + const existing = groupedMap.get(item.deletedDate) || []; + existing.push(item); + groupedMap.set(item.deletedDate, existing); + } + const groups = Array.from(groupedMap.entries()).map(([date, items2]) => ({ + date, + items: items2.sort((a, b) => a.originalName.localeCompare(b.originalName)) + })).sort((a, b) => b.date.localeCompare(a.date)); + successResponse(res, { groups }); + }) +); +router.post( + "/restore", + asyncHandler(async (req, res) => { + const { path: relPath, type } = req.body; + if (!relPath || !type) { + throw new ValidationError("Path and type are required"); + } + const { fullPath: itemPath } = resolveNotebookPath(relPath); + try { + await fs2.access(itemPath); + } catch { + throw new NotFoundError("Item not found in recycle bin"); + } + const match = path2.basename(itemPath).match(/^(\d{8})_(.+)$/); + if (!match) { + throw new BadRequestError("Invalid recycle bin item name"); + } + const [, dateStr, originalName] = match; + const year = dateStr.substring(0, 4); + const month = dateStr.substring(4, 6); + const day = dateStr.substring(6, 8); + const { fullPath: markdownsDir } = resolveNotebookPath("markdowns"); + await fs2.mkdir(markdownsDir, { recursive: true }); + const destPath = path2.join(markdownsDir, originalName); + const existing = await fs2.stat(destPath).catch(() => null); + if (existing) { + throw new AlreadyExistsError("A file or folder with this name already exists"); + } + if (type === "dir") { + await restoreFolder(itemPath, destPath, dateStr, year, month, day); + } else { + await restoreFile(itemPath, destPath, dateStr, year, month, day); + } + successResponse(res, null); + }) +); +router.delete( + "/permanent", + asyncHandler(async (req, res) => { + const { path: relPath, type } = req.body; + if (!relPath || !type) { + throw new ValidationError("Path and type are required"); + } + const { fullPath: itemPath } = resolveNotebookPath(relPath); + try { + await fs2.access(itemPath); + } catch { + throw new NotFoundError("Item not found in recycle bin"); + } + if (type === "dir") { + await fs2.rm(itemPath, { recursive: true, force: true }); + } else { + await fs2.unlink(itemPath); + } + successResponse(res, null); + }) +); +router.delete( + "/empty", + asyncHandler(async (req, res) => { + const { fullPath: rbDir } = resolveNotebookPath("RB"); + try { + await fs2.access(rbDir); + } catch { + successResponse(res, null); + return; + } + const entries = await fs2.readdir(rbDir, { withFileTypes: true }); + for (const entry of entries) { + const entryPath = path2.join(rbDir, entry.name); + if (entry.isDirectory()) { + await fs2.rm(entryPath, { recursive: true, force: true }); + } else { + await fs2.unlink(entryPath); + } + } + successResponse(res, null); + }) +); +var routes_default = router; + +// api/modules/recycle-bin/index.ts +var createRecycleBinModule = () => { + return createApiModule(RECYCLE_BIN_MODULE, { + routes: (_container) => { + return routes_default; + } + }); +}; +var recycle_bin_default = createRecycleBinModule; + +export { + restoreFile, + restoreFolder, + createRecycleBinModule, + recycle_bin_default +}; diff --git a/dist-api/chunk-QS2CMBFP.js b/dist-api/chunk-QS2CMBFP.js new file mode 100644 index 0000000..3f92d5e --- /dev/null +++ b/dist-api/chunk-QS2CMBFP.js @@ -0,0 +1,912 @@ +import { + logger +} from "./chunk-47DJ6YUB.js"; +import { + NOTEBOOK_ROOT, + asyncHandler, + createApiModule, + defineApiModule, + defineEndpoints, + successResponse +} from "./chunk-74TMTGBG.js"; + +// shared/modules/time-tracking/api.ts +var TIME_TRACKING_ENDPOINTS = defineEndpoints({ + current: { path: "/current", method: "GET" }, + event: { path: "/event", method: "POST" }, + day: { path: "/day/:date", method: "GET" }, + week: { path: "/week/:startDate", method: "GET" }, + month: { path: "/month/:yearMonth", method: "GET" }, + year: { path: "/year/:year", method: "GET" }, + stats: { path: "/stats", method: "GET" } +}); + +// shared/modules/time-tracking/index.ts +var TIME_TRACKING_MODULE = defineApiModule({ + id: "time-tracking", + name: "\u65F6\u95F4\u7EDF\u8BA1", + basePath: "/time", + order: 20, + version: "1.0.0", + endpoints: TIME_TRACKING_ENDPOINTS +}); + +// shared/utils/tabType.ts +var KNOWN_MODULE_IDS = [ + "home", + "settings", + "search", + "weread", + "recycle-bin", + "todo", + "time-tracking", + "pydemos" +]; +function getTabTypeFromPath(filePath) { + if (!filePath) return "other"; + if (filePath.startsWith("file-transfer-panel")) { + return "file-transfer"; + } + if (filePath.startsWith("remote-git://")) { + return "remote-git"; + } + if (filePath.startsWith("remote-desktop://")) { + return "remote-desktop"; + } + if (filePath.startsWith("remote-") && filePath !== "remote-tab") { + return "remote-desktop"; + } + if (filePath === "remote-tab" || filePath === "remote") { + return "remote"; + } + for (const moduleId of KNOWN_MODULE_IDS) { + if (filePath === `${moduleId}-tab` || filePath === moduleId) { + if (moduleId === "home" || moduleId === "settings" || moduleId === "search" || moduleId === "weread") { + return "other"; + } + return moduleId; + } + } + if (filePath.endsWith(".md")) { + return "markdown"; + } + return "other"; +} +function getFileNameFromPath(filePath) { + if (!filePath) return "\u672A\u77E5"; + if (filePath.startsWith("file-transfer-panel")) { + const params = new URLSearchParams(filePath.split("?")[1] || ""); + const deviceName = params.get("device") || ""; + return deviceName ? `\u6587\u4EF6\u4F20\u8F93 - ${deviceName}` : "\u6587\u4EF6\u4F20\u8F93"; + } + for (const moduleId of KNOWN_MODULE_IDS) { + if (filePath === `${moduleId}-tab` || filePath === moduleId) { + const names = { + "home": "\u9996\u9875", + "settings": "\u8BBE\u7F6E", + "search": "\u641C\u7D22", + "weread": "\u5FAE\u4FE1\u8BFB\u4E66", + "recycle-bin": "\u56DE\u6536\u7AD9", + "todo": "TODO", + "time-tracking": "\u65F6\u95F4\u7EDF\u8BA1", + "pydemos": "Python Demo", + "remote": "\u8FDC\u7A0B\u684C\u9762" + }; + return names[moduleId] ?? moduleId; + } + } + const parts = filePath.split("/"); + return parts[parts.length - 1] || filePath; +} + +// api/modules/time-tracking/heartbeatService.ts +var DEFAULT_HEARTBEAT_INTERVAL = 6e4; +var HeartbeatService = class { + interval = null; + lastHeartbeat = /* @__PURE__ */ new Date(); + intervalMs; + callback = null; + constructor(intervalMs = DEFAULT_HEARTBEAT_INTERVAL) { + this.intervalMs = intervalMs; + } + setCallback(callback) { + this.callback = callback; + } + start() { + if (this.interval) { + this.stop(); + } + this.interval = setInterval(async () => { + if (this.callback) { + try { + this.lastHeartbeat = /* @__PURE__ */ new Date(); + await this.callback(); + } catch (err) { + logger.error("Heartbeat callback failed:", err); + } + } + }, this.intervalMs); + this.lastHeartbeat = /* @__PURE__ */ new Date(); + } + stop() { + if (this.interval) { + clearInterval(this.interval); + this.interval = null; + } + } + isRunning() { + return this.interval !== null; + } + getLastHeartbeat() { + return this.lastHeartbeat; + } + updateHeartbeat() { + this.lastHeartbeat = /* @__PURE__ */ new Date(); + } + getState() { + return { + lastHeartbeat: this.lastHeartbeat, + isRunning: this.isRunning() + }; + } + restoreState(state) { + this.lastHeartbeat = new Date(state.lastHeartbeat); + } +}; +var createHeartbeatService = (intervalMs) => { + return new HeartbeatService(intervalMs); +}; + +// api/modules/time-tracking/sessionPersistence.ts +import fs from "fs/promises"; +import path from "path"; +var TIME_ROOT = path.join(NOTEBOOK_ROOT, "time"); +var getDayFilePath = (year, month, day) => { + const monthStr = month.toString().padStart(2, "0"); + const dayStr = day.toString().padStart(2, "0"); + return path.join(TIME_ROOT, year.toString(), `${year}${monthStr}${dayStr}.json`); +}; +var getMonthFilePath = (year, month) => { + const monthStr = month.toString().padStart(2, "0"); + return path.join(TIME_ROOT, year.toString(), `${year}${monthStr}.json`); +}; +var getYearFilePath = (year) => { + return path.join(TIME_ROOT, "summary", `${year}.json`); +}; +var ensureDirExists = async (filePath) => { + const dir = path.dirname(filePath); + await fs.mkdir(dir, { recursive: true }); +}; +var createEmptyDayData = (year, month, day) => ({ + date: `${year}-${month.toString().padStart(2, "0")}-${day.toString().padStart(2, "0")}`, + totalDuration: 0, + sessions: [], + tabSummary: {}, + lastUpdated: (/* @__PURE__ */ new Date()).toISOString() +}); +var createEmptyMonthData = (year, month) => ({ + year, + month, + days: {}, + monthlyTotal: 0, + averageDaily: 0, + activeDays: 0, + lastUpdated: (/* @__PURE__ */ new Date()).toISOString() +}); +var createEmptyYearData = (year) => ({ + year, + months: {}, + yearlyTotal: 0, + averageMonthly: 0, + averageDaily: 0, + totalActiveDays: 0 +}); +var SessionPersistenceService = class { + stateFilePath; + constructor() { + this.stateFilePath = path.join(TIME_ROOT, ".current-session.json"); + } + async loadCurrentState() { + try { + const content = await fs.readFile(this.stateFilePath, "utf-8"); + const state = JSON.parse(content); + return { + session: state.session || null, + currentTabRecord: state.currentTabRecord || null, + isPaused: state.isPaused || false, + lastHeartbeat: state.lastHeartbeat || (/* @__PURE__ */ new Date()).toISOString() + }; + } catch (err) { + logger.debug("No existing session to load or session file corrupted"); + return { + session: null, + currentTabRecord: null, + isPaused: false, + lastHeartbeat: (/* @__PURE__ */ new Date()).toISOString() + }; + } + } + async saveCurrentState(state) { + await ensureDirExists(this.stateFilePath); + await fs.writeFile(this.stateFilePath, JSON.stringify({ + session: state.session, + currentTabRecord: state.currentTabRecord, + isPaused: state.isPaused, + lastHeartbeat: state.lastHeartbeat + }), "utf-8"); + } + async clearCurrentState() { + try { + await fs.unlink(this.stateFilePath); + } catch (err) { + logger.debug("Session state file already removed or does not exist"); + } + } + async saveSessionToDay(session) { + const startTime = new Date(session.startTime); + const year = startTime.getFullYear(); + const month = startTime.getMonth() + 1; + const day = startTime.getDate(); + const filePath = getDayFilePath(year, month, day); + await ensureDirExists(filePath); + let dayData = await this.getDayData(year, month, day); + dayData.sessions.push(session); + dayData.totalDuration += session.duration; + for (const record of session.tabRecords) { + const key = record.filePath || record.fileName; + if (!dayData.tabSummary[key]) { + dayData.tabSummary[key] = { + fileName: record.fileName, + tabType: record.tabType, + totalDuration: 0, + focusCount: 0 + }; + } + dayData.tabSummary[key].totalDuration += record.duration; + dayData.tabSummary[key].focusCount += record.focusedPeriods.length; + } + dayData.lastUpdated = (/* @__PURE__ */ new Date()).toISOString(); + await fs.writeFile(filePath, JSON.stringify(dayData, null, 2), "utf-8"); + await this.updateMonthSummary(year, month, day, session.duration); + await this.updateYearSummary(year, month, session.duration); + } + async getDayData(year, month, day) { + const filePath = getDayFilePath(year, month, day); + try { + const content = await fs.readFile(filePath, "utf-8"); + return JSON.parse(content); + } catch (err) { + return createEmptyDayData(year, month, day); + } + } + async getMonthData(year, month) { + const filePath = getMonthFilePath(year, month); + try { + const content = await fs.readFile(filePath, "utf-8"); + const data = JSON.parse(content); + data.activeDays = Object.values(data.days).filter((d) => d.totalDuration > 0).length; + return data; + } catch (err) { + return createEmptyMonthData(year, month); + } + } + async getYearData(year) { + const filePath = getYearFilePath(year); + try { + const content = await fs.readFile(filePath, "utf-8"); + const data = JSON.parse(content); + data.totalActiveDays = Object.values(data.months).filter((m) => m.totalDuration > 0).length; + return data; + } catch (err) { + return createEmptyYearData(year); + } + } + async updateDayDataRealtime(year, month, day, session, currentTabRecord) { + const filePath = getDayFilePath(year, month, day); + await ensureDirExists(filePath); + let dayData = await this.getDayData(year, month, day); + const currentSessionDuration = session.tabRecords.reduce((sum, r) => sum + r.duration, 0) + (currentTabRecord?.duration || 0); + const existingSessionIndex = dayData.sessions.findIndex((s) => s.id === session.id); + const realtimeSession = { + ...session, + duration: currentSessionDuration, + tabRecords: currentTabRecord ? [...session.tabRecords, currentTabRecord] : session.tabRecords + }; + if (existingSessionIndex >= 0) { + const oldDuration = dayData.sessions[existingSessionIndex].duration; + dayData.sessions[existingSessionIndex] = realtimeSession; + dayData.totalDuration = dayData.totalDuration - oldDuration + currentSessionDuration; + } else { + dayData.sessions.push(realtimeSession); + dayData.totalDuration += currentSessionDuration; + } + dayData.tabSummary = {}; + for (const s of dayData.sessions) { + for (const record of s.tabRecords) { + const key = record.filePath || record.fileName; + if (!dayData.tabSummary[key]) { + dayData.tabSummary[key] = { + fileName: record.fileName, + tabType: record.tabType, + totalDuration: 0, + focusCount: 0 + }; + } + dayData.tabSummary[key].totalDuration += record.duration; + dayData.tabSummary[key].focusCount += record.focusedPeriods.length; + } + } + dayData.lastUpdated = (/* @__PURE__ */ new Date()).toISOString(); + await fs.writeFile(filePath, JSON.stringify(dayData, null, 2), "utf-8"); + return dayData; + } + async updateMonthSummary(year, month, day, duration) { + const filePath = getMonthFilePath(year, month); + await ensureDirExists(filePath); + let monthData = await this.getMonthData(year, month); + const dayStr = day.toString().padStart(2, "0"); + if (!monthData.days[dayStr]) { + monthData.days[dayStr] = { totalDuration: 0, sessions: 0, topTabs: [] }; + } + monthData.days[dayStr].totalDuration += duration; + monthData.days[dayStr].sessions += 1; + monthData.monthlyTotal += duration; + monthData.activeDays = Object.values(monthData.days).filter((d) => d.totalDuration > 0).length; + monthData.averageDaily = monthData.activeDays > 0 ? Math.floor(monthData.monthlyTotal / monthData.activeDays) : 0; + monthData.lastUpdated = (/* @__PURE__ */ new Date()).toISOString(); + await fs.writeFile(filePath, JSON.stringify(monthData, null, 2), "utf-8"); + } + async updateYearSummary(year, month, duration) { + const filePath = getYearFilePath(year); + await ensureDirExists(filePath); + let yearData = await this.getYearData(year); + const monthStr = month.toString().padStart(2, "0"); + if (!yearData.months[monthStr]) { + yearData.months[monthStr] = { totalDuration: 0, activeDays: 0 }; + } + yearData.months[monthStr].totalDuration += duration; + yearData.yearlyTotal += duration; + yearData.totalActiveDays = Object.values(yearData.months).reduce((sum, m) => { + const hasActiveDays = m.totalDuration > 0 ? 1 : 0; + return sum + hasActiveDays; + }, 0); + const activeMonthCount = Object.values(yearData.months).filter((m) => m.totalDuration > 0).length; + yearData.averageMonthly = activeMonthCount > 0 ? Math.floor(yearData.yearlyTotal / activeMonthCount) : 0; + yearData.averageDaily = yearData.totalActiveDays > 0 ? Math.floor(yearData.yearlyTotal / yearData.totalActiveDays) : 0; + await fs.writeFile(filePath, JSON.stringify(yearData, null, 2), "utf-8"); + } + async recalculateMonthSummary(year, month, todayDuration) { + const monthFilePath = getMonthFilePath(year, month); + await ensureDirExists(monthFilePath); + let monthData = await this.getMonthData(year, month); + const dayStr = (/* @__PURE__ */ new Date()).getDate().toString().padStart(2, "0"); + if (!monthData.days[dayStr]) { + monthData.days[dayStr] = { totalDuration: 0, sessions: 0, topTabs: [] }; + } + const oldDayDuration = monthData.days[dayStr].totalDuration; + monthData.days[dayStr].totalDuration = todayDuration; + monthData.monthlyTotal = monthData.monthlyTotal - oldDayDuration + todayDuration; + monthData.activeDays = Object.values(monthData.days).filter((d) => d.totalDuration > 0).length; + monthData.averageDaily = monthData.activeDays > 0 ? Math.floor(monthData.monthlyTotal / monthData.activeDays) : 0; + monthData.lastUpdated = (/* @__PURE__ */ new Date()).toISOString(); + await fs.writeFile(monthFilePath, JSON.stringify(monthData, null, 2), "utf-8"); + } + async recalculateYearSummary(year) { + const yearFilePath = getYearFilePath(year); + await ensureDirExists(yearFilePath); + let yearData = await this.getYearData(year); + const monthStr = ((/* @__PURE__ */ new Date()).getMonth() + 1).toString().padStart(2, "0"); + const monthFilePath = getMonthFilePath(year, (/* @__PURE__ */ new Date()).getMonth() + 1); + try { + const monthContent = await fs.readFile(monthFilePath, "utf-8"); + const monthData = JSON.parse(monthContent); + if (!yearData.months[monthStr]) { + yearData.months[monthStr] = { totalDuration: 0, activeDays: 0 }; + } + const oldMonthTotal = yearData.months[monthStr].totalDuration; + yearData.months[monthStr].totalDuration = monthData.monthlyTotal; + yearData.months[monthStr].activeDays = monthData.activeDays; + yearData.yearlyTotal = yearData.yearlyTotal - oldMonthTotal + monthData.monthlyTotal; + yearData.totalActiveDays = Object.values(yearData.months).reduce((sum, m) => { + const hasActiveDays = m.totalDuration > 0 ? 1 : 0; + return sum + hasActiveDays; + }, 0); + const activeMonthCount = Object.values(yearData.months).filter((m) => m.totalDuration > 0).length; + yearData.averageMonthly = activeMonthCount > 0 ? Math.floor(yearData.yearlyTotal / activeMonthCount) : 0; + yearData.averageDaily = yearData.totalActiveDays > 0 ? Math.floor(yearData.yearlyTotal / yearData.totalActiveDays) : 0; + } catch (err) { + logger.debug("Month file not found for year summary calculation"); + } + await fs.writeFile(yearFilePath, JSON.stringify(yearData, null, 2), "utf-8"); + } +}; +var createSessionPersistence = () => { + return new SessionPersistenceService(); +}; + +// api/modules/time-tracking/timeService.ts +var generateId = () => { + return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; +}; +var TimeTrackerService = class _TimeTrackerService { + currentSession = null; + currentTabRecord = null; + isPaused = false; + todayDuration = 0; + _initialized = false; + static _initializationPromise = null; + heartbeatService; + persistence; + constructor(dependencies) { + this.heartbeatService = dependencies.heartbeatService; + this.persistence = dependencies.persistence; + } + static async create(config) { + const heartbeatService = createHeartbeatService(config?.heartbeatIntervalMs); + const persistence = createSessionPersistence(); + const instance = new _TimeTrackerService({ + heartbeatService, + persistence + }); + await instance.initialize(); + return instance; + } + static async createWithDependencies(dependencies) { + const instance = new _TimeTrackerService(dependencies); + await instance.initialize(); + return instance; + } + async initialize() { + if (this._initialized) { + return; + } + if (_TimeTrackerService._initializationPromise) { + await _TimeTrackerService._initializationPromise; + return; + } + _TimeTrackerService._initializationPromise = this.loadCurrentState(); + await _TimeTrackerService._initializationPromise; + this._initialized = true; + _TimeTrackerService._initializationPromise = null; + this.heartbeatService.setCallback(async () => { + if (this.currentSession && !this.isPaused) { + try { + this.heartbeatService.updateHeartbeat(); + await this.updateCurrentTabDuration(); + await this.saveCurrentState(); + await this.updateTodayDataRealtime(); + } catch (err) { + logger.error("Heartbeat update failed:", err); + } + } + }); + } + ensureInitialized() { + if (!this._initialized) { + throw new Error("TimeTrackerService \u672A\u521D\u59CB\u5316\uFF0C\u8BF7\u4F7F\u7528 TimeTrackerService.create() \u521B\u5EFA\u5B9E\u4F8B"); + } + } + async loadCurrentState() { + const now = /* @__PURE__ */ new Date(); + const todayData = await this.persistence.getDayData(now.getFullYear(), now.getMonth() + 1, now.getDate()); + this.todayDuration = todayData.totalDuration; + const state = await this.persistence.loadCurrentState(); + if (state.session && state.session.status === "active") { + const sessionStart = new Date(state.session.startTime); + const now2 = /* @__PURE__ */ new Date(); + if (now2.getTime() - sessionStart.getTime() < 24 * 60 * 60 * 1e3) { + this.currentSession = state.session; + this.isPaused = state.isPaused; + if (state.currentTabRecord) { + this.currentTabRecord = state.currentTabRecord; + } + this.heartbeatService.restoreState({ lastHeartbeat: state.lastHeartbeat }); + } else { + await this.endSession(); + } + } + } + async saveCurrentState() { + await this.persistence.saveCurrentState({ + session: this.currentSession, + currentTabRecord: this.currentTabRecord, + isPaused: this.isPaused, + lastHeartbeat: this.heartbeatService.getLastHeartbeat().toISOString() + }); + } + async startSession() { + if (this.currentSession && this.currentSession.status === "active") { + return this.currentSession; + } + const now = /* @__PURE__ */ new Date(); + this.currentSession = { + id: generateId(), + startTime: now.toISOString(), + duration: 0, + status: "active", + tabRecords: [] + }; + this.isPaused = false; + this.heartbeatService.updateHeartbeat(); + this.heartbeatService.start(); + await this.saveCurrentState(); + return this.currentSession; + } + async pauseSession() { + if (!this.currentSession || this.isPaused) return; + this.isPaused = true; + await this.updateCurrentTabDuration(); + await this.saveCurrentState(); + } + async resumeSession() { + if (!this.currentSession || !this.isPaused) return; + this.isPaused = false; + this.heartbeatService.updateHeartbeat(); + if (this.currentTabRecord) { + const now = /* @__PURE__ */ new Date(); + const timeStr = `${now.getHours().toString().padStart(2, "0")}:${now.getMinutes().toString().padStart(2, "0")}:${now.getSeconds().toString().padStart(2, "0")}`; + this.currentTabRecord.focusedPeriods.push({ start: timeStr, end: timeStr }); + } + await this.saveCurrentState(); + } + async endSession() { + if (!this.currentSession) return; + this.heartbeatService.stop(); + await this.updateCurrentTabDuration(); + const now = /* @__PURE__ */ new Date(); + this.currentSession.endTime = now.toISOString(); + this.currentSession.status = "ended"; + const startTime = new Date(this.currentSession.startTime); + this.currentSession.duration = Math.floor((now.getTime() - startTime.getTime()) / 1e3); + await this.persistence.saveSessionToDay(this.currentSession); + this.todayDuration += this.currentSession.duration; + this.currentSession = null; + this.currentTabRecord = null; + this.isPaused = false; + await this.persistence.clearCurrentState(); + } + async updateCurrentTabDuration() { + if (!this.currentSession || !this.currentTabRecord) return; + const now = /* @__PURE__ */ new Date(); + const periods = this.currentTabRecord.focusedPeriods; + if (periods.length > 0) { + const lastPeriod = periods[periods.length - 1]; + const [h, m, s] = lastPeriod.start.split(":").map(Number); + const startSeconds = h * 3600 + m * 60 + s; + const currentSeconds = now.getHours() * 3600 + now.getMinutes() * 60 + now.getSeconds(); + this.currentTabRecord.duration = currentSeconds - startSeconds; + lastPeriod.end = `${now.getHours().toString().padStart(2, "0")}:${now.getMinutes().toString().padStart(2, "0")}:${now.getSeconds().toString().padStart(2, "0")}`; + } + } + async updateTodayDataRealtime() { + if (!this.currentSession) return; + const now = /* @__PURE__ */ new Date(); + const year = now.getFullYear(); + const month = now.getMonth() + 1; + const day = now.getDate(); + const dayData = await this.persistence.updateDayDataRealtime( + year, + month, + day, + this.currentSession, + this.currentTabRecord + ); + this.todayDuration = dayData.totalDuration; + await this.persistence.recalculateMonthSummary(year, month, this.todayDuration); + await this.persistence.recalculateYearSummary(year); + } + async handleTabSwitch(tabInfo) { + if (!this.currentSession || this.isPaused) return; + await this.updateCurrentTabDuration(); + if (this.currentTabRecord && this.currentTabRecord.duration > 0) { + this.currentSession.tabRecords.push({ ...this.currentTabRecord }); + } + const now = /* @__PURE__ */ new Date(); + const timeStr = `${now.getHours().toString().padStart(2, "0")}:${now.getMinutes().toString().padStart(2, "0")}:${now.getSeconds().toString().padStart(2, "0")}`; + this.currentTabRecord = { + tabId: tabInfo.tabId, + filePath: tabInfo.filePath, + fileName: getFileNameFromPath(tabInfo.filePath), + tabType: getTabTypeFromPath(tabInfo.filePath), + duration: 0, + focusedPeriods: [{ start: timeStr, end: timeStr }] + }; + await this.saveCurrentState(); + } + async handleEvent(event) { + switch (event.type) { + case "window-focus": + if (!this.currentSession) { + await this.startSession(); + if (event.tabInfo) { + await this.handleTabSwitch(event.tabInfo); + } + } else { + await this.resumeSession(); + await this.updateTodayDataRealtime(); + } + break; + case "window-blur": + await this.pauseSession(); + await this.updateTodayDataRealtime(); + break; + case "app-quit": + await this.endSession(); + break; + case "tab-switch": + case "tab-open": + if (!this.currentSession) { + await this.startSession(); + } + if (event.tabInfo) { + await this.handleTabSwitch(event.tabInfo); + } + await this.updateTodayDataRealtime(); + break; + case "tab-close": + await this.updateCurrentTabDuration(); + await this.updateTodayDataRealtime(); + break; + case "heartbeat": + if (this.currentSession && !this.isPaused) { + this.heartbeatService.updateHeartbeat(); + await this.updateCurrentTabDuration(); + await this.saveCurrentState(); + await this.updateTodayDataRealtime(); + } + break; + } + } + async getDayData(year, month, day) { + return this.persistence.getDayData(year, month, day); + } + async getWeekData(startDate) { + const result = []; + for (let i = 0; i < 7; i++) { + const date = new Date(startDate); + date.setDate(date.getDate() + i); + const data = await this.persistence.getDayData(date.getFullYear(), date.getMonth() + 1, date.getDate()); + result.push(data); + } + return result; + } + async getMonthData(year, month) { + return this.persistence.getMonthData(year, month); + } + async getYearData(year) { + return this.persistence.getYearData(year); + } + getCurrentState() { + return { + isRunning: this.currentSession !== null, + isPaused: this.isPaused, + currentSession: this.currentSession, + todayDuration: this.todayDuration, + currentTabRecord: this.currentTabRecord + }; + } + async getStats(year, month) { + const now = /* @__PURE__ */ new Date(); + const targetYear = year || now.getFullYear(); + const targetMonth = month; + let totalDuration = 0; + let activeDays = 0; + let longestDay = null; + let longestSession = null; + const tabDurations = {}; + const tabTypeDurations = {}; + if (targetMonth) { + const monthData = await this.persistence.getMonthData(targetYear, targetMonth); + totalDuration = monthData.monthlyTotal; + activeDays = Object.values(monthData.days).filter((d) => d.totalDuration > 0).length; + for (const [day, summary] of Object.entries(monthData.days)) { + if (!longestDay || summary.totalDuration > longestDay.duration) { + longestDay = { date: `${targetYear}-${targetMonth.toString().padStart(2, "0")}-${day}`, duration: summary.totalDuration }; + } + for (const tab of summary.topTabs || []) { + tabDurations[tab.fileName] = (tabDurations[tab.fileName] || 0) + tab.duration; + } + } + const dayData = await this.persistence.getDayData(targetYear, targetMonth, 1); + for (const session of dayData.sessions) { + for (const record of session.tabRecords) { + const key = record.filePath || record.fileName; + tabDurations[key] = (tabDurations[key] || 0) + record.duration; + tabTypeDurations[record.tabType] = (tabTypeDurations[record.tabType] || 0) + record.duration; + } + } + } else { + const yearData = await this.persistence.getYearData(targetYear); + totalDuration = yearData.yearlyTotal; + activeDays = Object.values(yearData.months).reduce((sum, m) => sum + m.activeDays, 0); + for (const [month2, summary] of Object.entries(yearData.months)) { + if (!longestDay || summary.totalDuration > longestDay.duration) { + longestDay = { date: `${targetYear}-${month2}`, duration: summary.totalDuration }; + } + } + for (let m = 1; m <= 12; m++) { + const monthStr = m.toString().padStart(2, "0"); + const monthData = await this.persistence.getMonthData(targetYear, m); + for (const dayData of Object.values(monthData.days)) { + for (const tab of dayData.topTabs || []) { + tabDurations[tab.fileName] = (tabDurations[tab.fileName] || 0) + tab.duration; + } + } + } + } + return { + totalDuration, + activeDays, + averageDaily: activeDays > 0 ? Math.floor(totalDuration / activeDays) : 0, + longestDay, + longestSession, + topTabs: Object.entries(tabDurations).map(([fileName, duration]) => ({ + fileName, + duration, + percentage: totalDuration > 0 ? Math.round(duration / totalDuration * 100) : 0 + })).sort((a, b) => b.duration - a.duration).slice(0, 10), + tabTypeDistribution: Object.entries(tabTypeDurations).map(([tabType, duration]) => ({ + tabType, + duration, + percentage: totalDuration > 0 ? Math.round(duration / totalDuration * 100) : 0 + })).sort((a, b) => b.duration - a.duration) + }; + } +}; +var _timeTrackerService = null; +var getTimeTrackerService = () => { + if (!_timeTrackerService) { + throw new Error("TimeTrackerService \u672A\u521D\u59CB\u5316\uFF0C\u8BF7\u5148\u8C03\u7528 initializeTimeTrackerService()"); + } + return _timeTrackerService; +}; +var initializeTimeTrackerService = async (config) => { + if (_timeTrackerService) { + return _timeTrackerService; + } + _timeTrackerService = await TimeTrackerService.create(config); + return _timeTrackerService; +}; +var initializeTimeTrackerServiceWithDependencies = async (dependencies) => { + if (_timeTrackerService) { + return _timeTrackerService; + } + _timeTrackerService = await TimeTrackerService.createWithDependencies(dependencies); + return _timeTrackerService; +}; + +// api/modules/time-tracking/routes.ts +import express from "express"; +var createTimeTrackingRoutes = (deps) => { + const router = express.Router(); + const { timeTrackerService } = deps; + router.get( + "/current", + asyncHandler(async (_req, res) => { + const state = timeTrackerService.getCurrentState(); + successResponse(res, { + isRunning: state.isRunning, + isPaused: state.isPaused, + currentSession: state.currentSession ? { + id: state.currentSession.id, + startTime: state.currentSession.startTime, + duration: state.currentSession.duration, + currentTab: state.currentTabRecord ? { + tabId: state.currentTabRecord.tabId, + fileName: state.currentTabRecord.fileName, + tabType: state.currentTabRecord.tabType + } : null + } : null, + todayDuration: state.todayDuration + }); + }) + ); + router.post( + "/event", + asyncHandler(async (req, res) => { + const event = req.body; + await timeTrackerService.handleEvent(event); + successResponse(res, null); + }) + ); + router.get( + "/day/:date", + asyncHandler(async (req, res) => { + const { date } = req.params; + const [year, month, day] = date.split("-").map(Number); + const data = await timeTrackerService.getDayData(year, month, day); + const sessionsCount = data.sessions.length; + const averageSessionDuration = sessionsCount > 0 ? Math.floor(data.totalDuration / sessionsCount) : 0; + const longestSession = data.sessions.reduce((max, s) => s.duration > max ? s.duration : max, 0); + const topTabs = Object.entries(data.tabSummary).map(([_, summary]) => ({ + fileName: summary.fileName, + duration: summary.totalDuration + })).sort((a, b) => b.duration - a.duration).slice(0, 5); + successResponse(res, { + ...data, + stats: { + sessionsCount, + averageSessionDuration, + longestSession, + topTabs + } + }); + }) + ); + router.get( + "/week/:startDate", + asyncHandler(async (req, res) => { + const { startDate } = req.params; + const [year, month, day] = startDate.split("-").map(Number); + const start = new Date(year, month - 1, day); + const data = await timeTrackerService.getWeekData(start); + const totalDuration = data.reduce((sum, d) => sum + d.totalDuration, 0); + const activeDays = data.filter((d) => d.totalDuration > 0).length; + successResponse(res, { + days: data, + totalDuration, + activeDays, + averageDaily: activeDays > 0 ? Math.floor(totalDuration / activeDays) : 0 + }); + }) + ); + router.get( + "/month/:yearMonth", + asyncHandler(async (req, res) => { + const { yearMonth } = req.params; + const [year, month] = yearMonth.split("-").map(Number); + const data = await timeTrackerService.getMonthData(year, month); + successResponse(res, data); + }) + ); + router.get( + "/year/:year", + asyncHandler(async (req, res) => { + const { year } = req.params; + const data = await timeTrackerService.getYearData(parseInt(year)); + successResponse(res, data); + }) + ); + router.get( + "/stats", + asyncHandler(async (req, res) => { + const year = req.query.year ? parseInt(req.query.year) : void 0; + const month = req.query.month ? parseInt(req.query.month) : void 0; + const stats = await timeTrackerService.getStats(year, month); + successResponse(res, stats); + }) + ); + return router; +}; + +// api/modules/time-tracking/index.ts +var createTimeTrackingModule = (moduleConfig = {}) => { + let serviceInstance; + return createApiModule(TIME_TRACKING_MODULE, { + routes: (container) => { + const timeTrackerService = container.getSync("timeTrackerService"); + return createTimeTrackingRoutes({ timeTrackerService }); + }, + lifecycle: { + onLoad: async (container) => { + serviceInstance = await initializeTimeTrackerService(moduleConfig.config); + container.register("timeTrackerService", () => serviceInstance); + } + } + }); +}; +var time_tracking_default = createTimeTrackingModule; + +export { + HeartbeatService, + createHeartbeatService, + SessionPersistenceService, + createSessionPersistence, + TimeTrackerService, + getTimeTrackerService, + initializeTimeTrackerService, + initializeTimeTrackerServiceWithDependencies, + createTimeTrackingRoutes, + createTimeTrackingModule, + time_tracking_default +}; diff --git a/dist-api/chunk-R5LQJNQE.js b/dist-api/chunk-R5LQJNQE.js new file mode 100644 index 0000000..adaacd1 --- /dev/null +++ b/dist-api/chunk-R5LQJNQE.js @@ -0,0 +1,586 @@ +import { + logger +} from "./chunk-47DJ6YUB.js"; +import { + getTempDir +} from "./chunk-FTVFWJFJ.js"; +import { + InternalError, + ValidationError, + resolveNotebookPath +} from "./chunk-ER4KPD22.js"; +import { + NOTEBOOK_ROOT, + PROJECT_ROOT, + TEMP_ROOT, + asyncHandler, + createApiModule, + defineApiModule, + successResponse +} from "./chunk-74TMTGBG.js"; + +// api/modules/document-parser/index.ts +import express3 from "express"; + +// shared/modules/document-parser/index.ts +var DOCUMENT_PARSER_MODULE = defineApiModule({ + id: "document-parser", + name: "Document Parser", + basePath: "/document-parser", + order: 60, + version: "1.0.0", + frontend: { + enabled: false + }, + backend: { + enabled: true + } +}); + +// api/modules/document-parser/blogRoutes.ts +import express from "express"; +import path3 from "path"; +import fs3 from "fs/promises"; +import { existsSync as existsSync2 } from "fs"; +import axios from "axios"; + +// api/utils/file.ts +import fs from "fs/promises"; +import path from "path"; +var getUniqueFilename = async (imagesDirFullPath, baseName, ext) => { + const maxAttempts = 1e3; + for (let i = 0; i < maxAttempts; i++) { + const suffix = i === 0 ? "" : `-${i + 1}`; + const filename = `${baseName}${suffix}${ext}`; + const fullPath = path.join(imagesDirFullPath, filename); + try { + await fs.access(fullPath); + } catch { + return filename; + } + } + throw new InternalError("Failed to generate unique filename"); +}; +var mimeToExt = { + "image/png": ".png", + "image/jpeg": ".jpg", + "image/jpg": ".jpg", + "image/gif": ".gif", + "image/webp": ".webp" +}; +var IMAGE_MAGIC_BYTES = { + "image/png": { bytes: [137, 80, 78, 71, 13, 10, 26, 10] }, + "image/jpeg": { bytes: [255, 216, 255] }, + "image/gif": { bytes: [71, 73, 70, 56] }, + "image/webp": { bytes: [82, 73, 70, 70], offset: 0 } +}; +var WEBP_WEBP_MARKER = [87, 69, 66, 80]; +var MIN_IMAGE_SIZE = 16; +var MAX_IMAGE_SIZE = 8 * 1024 * 1024; +var validateImageBuffer = (buffer, claimedMimeType) => { + if (buffer.byteLength < MIN_IMAGE_SIZE) { + throw new ValidationError("Image file is too small or corrupted"); + } + if (buffer.byteLength > MAX_IMAGE_SIZE) { + throw new ValidationError("Image file is too large"); + } + const magicInfo = IMAGE_MAGIC_BYTES[claimedMimeType]; + if (!magicInfo) { + throw new ValidationError("Unsupported image type for content validation"); + } + const offset = magicInfo.offset || 0; + const expectedBytes = magicInfo.bytes; + for (let i = 0; i < expectedBytes.length; i++) { + if (buffer[offset + i] !== expectedBytes[i]) { + throw new ValidationError("Image content does not match the claimed file type"); + } + } + if (claimedMimeType === "image/webp") { + if (buffer.byteLength < 12) { + throw new ValidationError("WebP image is corrupted"); + } + for (let i = 0; i < WEBP_WEBP_MARKER.length; i++) { + if (buffer[8 + i] !== WEBP_WEBP_MARKER[i]) { + throw new ValidationError("WebP image content is invalid"); + } + } + } +}; +var detectImageMimeType = (buffer) => { + if (buffer.byteLength < 8) return null; + if (buffer[0] === 137 && buffer[1] === 80 && buffer[2] === 78 && buffer[3] === 71 && buffer[4] === 13 && buffer[5] === 10 && buffer[6] === 26 && buffer[7] === 10) { + return "image/png"; + } + if (buffer[0] === 255 && buffer[1] === 216 && buffer[2] === 255) { + return "image/jpeg"; + } + if (buffer[0] === 71 && buffer[1] === 73 && buffer[2] === 70 && buffer[3] === 56) { + return "image/gif"; + } + if (buffer[0] === 82 && buffer[1] === 73 && buffer[2] === 70 && buffer[3] === 70 && buffer[8] === 87 && buffer[9] === 69 && buffer[10] === 66 && buffer[11] === 80) { + return "image/webp"; + } + return null; +}; + +// shared/utils/date.ts +var pad2 = (n) => String(n).padStart(2, "0"); +var pad3 = (n) => String(n).padStart(3, "0"); +var formatTimestamp = (d) => { + const yyyy = d.getFullYear(); + const mm = pad2(d.getMonth() + 1); + const dd = pad2(d.getDate()); + const hh = pad2(d.getHours()); + const mi = pad2(d.getMinutes()); + const ss = pad2(d.getSeconds()); + const ms = pad3(d.getMilliseconds()); + return `${yyyy}${mm}${dd}_${hh}${mi}${ss}_${ms}`; +}; + +// api/modules/document-parser/documentParser.ts +import path2 from "path"; +import { spawn } from "child_process"; +import fs2 from "fs/promises"; +import { existsSync, mkdirSync } from "fs"; +if (!existsSync(TEMP_ROOT)) { + mkdirSync(TEMP_ROOT, { recursive: true }); +} +var createJobContext = async (prefix) => { + const now = /* @__PURE__ */ new Date(); + const jobDir = path2.join(TEMP_ROOT, `${prefix}_${formatTimestamp(now)}`); + await fs2.mkdir(jobDir, { recursive: true }); + const year = now.getFullYear(); + const month = pad2(now.getMonth() + 1); + const day = pad2(now.getDate()); + const imagesSubDir = `images/${year}/${month}/${day}`; + const destImagesDir = path2.join(NOTEBOOK_ROOT, imagesSubDir); + await fs2.mkdir(destImagesDir, { recursive: true }); + return { jobDir, now, imagesSubDir, destImagesDir }; +}; +var spawnPythonScript = async (options) => { + const { scriptPath, args, cwd, inputContent } = options; + return new Promise((resolve, reject) => { + const pythonProcess = spawn("python", ["-X", "utf8", scriptPath, ...args], { + cwd, + env: { ...process.env, PYTHONIOENCODING: "utf-8", PYTHONUTF8: "1" } + }); + let stdout = ""; + let stderr = ""; + pythonProcess.stdout.on("data", (data) => { + stdout += data.toString(); + }); + pythonProcess.stderr.on("data", (data) => { + stderr += data.toString(); + }); + pythonProcess.on("close", (code) => { + if (code !== 0) { + logger.error("Python script error:", stderr); + reject(new Error(`Process exited with code ${code}. Error: ${stderr}`)); + } else { + resolve(stdout); + } + }); + pythonProcess.on("error", (err) => { + reject(err); + }); + if (inputContent !== void 0) { + pythonProcess.stdin.write(inputContent); + pythonProcess.stdin.end(); + } + }); +}; +var findImageDestinations = (md) => { + const results = []; + let i = 0; + while (i < md.length) { + const bang = md.indexOf("![", i); + if (bang === -1) break; + const closeBracket = md.indexOf("]", bang + 2); + if (closeBracket === -1) break; + if (md[closeBracket + 1] !== "(") { + i = closeBracket + 1; + continue; + } + const urlStart = closeBracket + 2; + let depth = 1; + let j = urlStart; + for (; j < md.length; j++) { + const ch = md[j]; + if (ch === "(") depth++; + else if (ch === ")") { + depth--; + if (depth === 0) break; + } + } + if (depth !== 0) break; + results.push({ url: md.slice(urlStart, j), start: urlStart, end: j }); + i = j + 1; + } + return results; +}; +var applyReplacements = (md, replacements) => { + const sorted = [...replacements].sort((a, b) => b.start - a.start); + let result = md; + for (const r of sorted) { + result = `${result.slice(0, r.start)}${r.replacement}${result.slice(r.end)}`; + } + return result; +}; +var copyLocalImage = async (src, jobDir, htmlDir, destImagesDir, imagesSubDir, now) => { + const s0 = src.trim().replace(/^<|>$/g, ""); + if (!s0) return null; + let decoded = s0; + try { + decoded = decodeURI(s0); + } catch { + } + const s1 = decoded.replace(/\\/g, "/"); + const s2 = s1.startsWith("./") ? s1.slice(2) : s1; + const candidates = s2.startsWith("/") ? [path2.join(jobDir, s2.slice(1)), path2.join(htmlDir, s2.slice(1))] : [path2.resolve(htmlDir, s2), path2.resolve(jobDir, s2)]; + let foundFile = null; + for (const c of candidates) { + if (existsSync(c)) { + foundFile = c; + break; + } + } + if (!foundFile) return null; + const ext = path2.extname(foundFile) || ".jpg"; + const baseName = formatTimestamp(now); + const newFilename = await getUniqueFilename(destImagesDir, baseName, ext); + const newPath = path2.join(destImagesDir, newFilename); + await fs2.copyFile(foundFile, newPath); + return { newLink: `/${imagesSubDir}/${newFilename}` }; +}; +var cleanupJob = async (jobDir, additionalPaths = []) => { + await fs2.rm(jobDir, { recursive: true, force: true }).catch(() => { + }); + for (const p of additionalPaths) { + await fs2.unlink(p).catch(() => { + }); + } +}; +var getScriptPath = (toolName, scriptName) => { + return path2.join(PROJECT_ROOT, "tools", toolName, scriptName); +}; +var ensureScriptExists = (scriptPath) => { + return existsSync(scriptPath); +}; + +// api/modules/document-parser/blogRoutes.ts +var router = express.Router(); +var tempDir = getTempDir(); +router.post( + "/parse-local", + asyncHandler(async (req, res) => { + const { htmlPath, htmlDir, assetsDirName, assetsFiles, targetPath } = req.body; + if (!htmlPath || !htmlDir || !targetPath) { + throw new ValidationError("htmlPath, htmlDir and targetPath are required"); + } + let fullTargetPath; + try { + const resolved = resolveNotebookPath(targetPath); + fullTargetPath = resolved.fullPath; + } catch (error) { + throw error; + } + const scriptPath = getScriptPath("blog", "parse_blog.py"); + if (!ensureScriptExists(scriptPath)) { + throw new InternalError("Parser script not found"); + } + const jobContext = await createJobContext("blog"); + let htmlPathInJob = ""; + try { + htmlPathInJob = path3.join(jobContext.jobDir, "input.html"); + await fs3.copyFile(htmlPath, htmlPathInJob); + if (assetsDirName && assetsFiles && assetsFiles.length > 0) { + const assetsDirPath = path3.join(htmlDir, assetsDirName); + for (const relPath of assetsFiles) { + const srcPath = path3.join(assetsDirPath, relPath); + if (existsSync2(srcPath)) { + const destPath = path3.join(jobContext.jobDir, assetsDirName, relPath); + await fs3.mkdir(path3.dirname(destPath), { recursive: true }); + await fs3.copyFile(srcPath, destPath); + } + } + } + } catch (err) { + await cleanupJob(jobContext.jobDir); + throw err; + } + processHtmlInBackground({ + jobDir: jobContext.jobDir, + htmlPath: htmlPathInJob, + targetPath: fullTargetPath, + cwd: path3.dirname(scriptPath), + jobContext, + originalHtmlDir: htmlDir, + originalAssetsDirName: assetsDirName + }).catch((err) => { + logger.error("Background HTML processing failed:", err); + fs3.writeFile(fullTargetPath, `# \u89E3\u6790\u5931\u8D25 + +> \u9519\u8BEF\u4FE1\u606F: ${err.message}`, "utf-8").catch(() => { + }); + cleanupJob(jobContext.jobDir).catch(() => { + }); + }); + successResponse(res, { + message: "HTML parsing started in background.", + status: "processing" + }); + }) +); +async function processHtmlInBackground(args) { + const { jobDir, htmlPath, targetPath, cwd, jobContext, originalHtmlDir, originalAssetsDirName } = args; + try { + await spawnPythonScript({ + scriptPath: "parse_blog.py", + args: [htmlPath], + cwd + }); + const parsedPathObj = path3.parse(htmlPath); + const markdownPath = path3.join(parsedPathObj.dir, `${parsedPathObj.name}.md`); + if (!existsSync2(markdownPath)) { + throw new Error("Markdown result file not found"); + } + let mdContent = await fs3.readFile(markdownPath, "utf-8"); + const ctx = await jobContext; + const htmlDir = path3.dirname(htmlPath); + const replacements = []; + const destinations = findImageDestinations(mdContent); + for (const dest of destinations) { + const originalSrc = dest.url; + if (!originalSrc) continue; + if (originalSrc.startsWith("http://") || originalSrc.startsWith("https://")) { + try { + const response = await axios.get(originalSrc, { responseType: "arraybuffer", timeout: 1e4 }); + const contentType = response.headers["content-type"]; + let ext = ".jpg"; + if (contentType) { + if (contentType.includes("png")) ext = ".png"; + else if (contentType.includes("gif")) ext = ".gif"; + else if (contentType.includes("webp")) ext = ".webp"; + else if (contentType.includes("svg")) ext = ".svg"; + else if (contentType.includes("jpeg") || contentType.includes("jpg")) ext = ".jpg"; + } + const urlExt = path3.extname(originalSrc.split("?")[0]); + if (urlExt) ext = urlExt; + const baseName = formatTimestamp(ctx.now); + const newFilename = await getUniqueFilename(ctx.destImagesDir, baseName, ext); + const newPath = path3.join(ctx.destImagesDir, newFilename); + await fs3.writeFile(newPath, response.data); + replacements.push({ + start: dest.start, + end: dest.end, + original: originalSrc, + replacement: `/${ctx.imagesSubDir}/${newFilename}` + }); + } catch { + } + continue; + } + if (originalSrc.startsWith("data:")) continue; + let result = await copyLocalImage( + originalSrc, + jobDir, + htmlDir, + ctx.destImagesDir, + ctx.imagesSubDir, + ctx.now + ); + if (!result && originalHtmlDir && originalAssetsDirName) { + const srcWithFiles = originalSrc.replace(/^\.\//, "").replace(/^\//, ""); + const possiblePaths = [ + path3.join(originalHtmlDir, originalAssetsDirName, srcWithFiles), + path3.join(originalHtmlDir, originalAssetsDirName, path3.basename(srcWithFiles)) + ]; + for (const p of possiblePaths) { + if (existsSync2(p)) { + const ext = path3.extname(p) || ".jpg"; + const baseName = formatTimestamp(ctx.now); + const newFilename = await getUniqueFilename(ctx.destImagesDir, baseName, ext); + const newPath = path3.join(ctx.destImagesDir, newFilename); + await fs3.copyFile(p, newPath); + result = { newLink: `/${ctx.imagesSubDir}/${newFilename}` }; + break; + } + } + } + if (result) { + replacements.push({ + start: dest.start, + end: dest.end, + original: originalSrc, + replacement: result.newLink + }); + } + } + mdContent = applyReplacements(mdContent, replacements); + await fs3.writeFile(targetPath, mdContent, "utf-8"); + await fs3.unlink(markdownPath).catch(() => { + }); + } finally { + await cleanupJob(jobDir); + } +} +var blogRoutes_default = router; + +// api/modules/document-parser/mineruRoutes.ts +import express2 from "express"; +import multer from "multer"; +import path4 from "path"; +import fs4 from "fs/promises"; +import { existsSync as existsSync3 } from "fs"; +var router2 = express2.Router(); +var tempDir2 = getTempDir(); +var upload = multer({ + dest: tempDir2, + limits: { + fileSize: 50 * 1024 * 1024 + } +}); +router2.post( + "/parse", + upload.single("file"), + asyncHandler(async (req, res) => { + if (!req.file) { + throw new ValidationError("File is required"); + } + const { targetPath } = req.body; + if (!targetPath) { + await fs4.unlink(req.file.path).catch(() => { + }); + throw new ValidationError("Target path is required"); + } + let fullTargetPath; + try { + const resolved = resolveNotebookPath(targetPath); + fullTargetPath = resolved.fullPath; + } catch (error) { + await fs4.unlink(req.file.path).catch(() => { + }); + throw error; + } + const scriptPath = getScriptPath("mineru", "mineru_parser.py"); + if (!ensureScriptExists(scriptPath)) { + await fs4.unlink(req.file.path).catch(() => { + }); + throw new InternalError("Parser script not found"); + } + processPdfInBackground(req.file.path, fullTargetPath, path4.dirname(scriptPath)).catch((err) => { + logger.error("Background PDF processing failed:", err); + fs4.writeFile(fullTargetPath, `# \u89E3\u6790\u5931\u8D25 + +> \u9519\u8BEF\u4FE1\u606F: ${err.message}`, "utf-8").catch(() => { + }); + }); + successResponse(res, { + message: "PDF upload successful. Parsing started in background.", + status: "processing" + }); + }) +); +async function processPdfInBackground(filePath, targetPath, cwd) { + try { + const output = await spawnPythonScript({ + scriptPath: "mineru_parser.py", + args: [filePath], + cwd + }); + const match = output.match(/JSON_RESULT:(.*)/); + if (!match) { + throw new Error("Failed to parse Python script output: JSON_RESULT not found"); + } + const result = JSON.parse(match[1]); + const markdownPath = result.markdown_file; + const outputDir = result.output_dir; + if (!existsSync3(markdownPath)) { + throw new Error("Markdown result file not found"); + } + let mdContent = await fs4.readFile(markdownPath, "utf-8"); + const imagesDir = path4.join(outputDir, "images"); + if (existsSync3(imagesDir)) { + const jobContext = await createJobContext("pdf_images"); + const destinations = findImageDestinations(mdContent); + const replacements = []; + for (const dest of destinations) { + const originalSrc = dest.url; + if (!originalSrc) continue; + const possibleFilenames = [originalSrc, path4.basename(originalSrc)]; + let foundFile = null; + for (const fname of possibleFilenames) { + const localPath = path4.join(imagesDir, fname); + if (existsSync3(localPath)) { + foundFile = localPath; + break; + } + const directPath = path4.join(outputDir, originalSrc); + if (existsSync3(directPath)) { + foundFile = directPath; + break; + } + } + if (foundFile) { + const ext = path4.extname(foundFile); + const baseName = formatTimestamp(jobContext.now); + const newFilename = await getUniqueFilename(jobContext.destImagesDir, baseName, ext); + const newPath = path4.join(jobContext.destImagesDir, newFilename); + await fs4.copyFile(foundFile, newPath); + replacements.push({ + start: dest.start, + end: dest.end, + original: originalSrc, + replacement: `${jobContext.imagesSubDir}/${newFilename}` + }); + } + } + mdContent = applyReplacements(mdContent, replacements); + } + await fs4.writeFile(targetPath, mdContent, "utf-8"); + await fs4.unlink(markdownPath).catch(() => { + }); + if (outputDir && outputDir.includes("temp")) { + await fs4.rm(outputDir, { recursive: true, force: true }).catch(() => { + }); + } + } finally { + await fs4.unlink(filePath).catch(() => { + }); + } +} +var mineruRoutes_default = router2; + +// api/modules/document-parser/index.ts +var createDocumentParserModule = () => { + return createApiModule(DOCUMENT_PARSER_MODULE, { + routes: (_container) => { + const router3 = express3.Router(); + router3.use("/blog", blogRoutes_default); + router3.use("/mineru", mineruRoutes_default); + return router3; + } + }); +}; +var document_parser_default = createDocumentParserModule; + +export { + pad2, + formatTimestamp, + getUniqueFilename, + mimeToExt, + validateImageBuffer, + detectImageMimeType, + createJobContext, + spawnPythonScript, + findImageDestinations, + applyReplacements, + copyLocalImage, + cleanupJob, + getScriptPath, + ensureScriptExists, + blogRoutes_default, + mineruRoutes_default, + createDocumentParserModule, + document_parser_default +}; diff --git a/dist-api/chunk-T5RPAMG6.js b/dist-api/chunk-T5RPAMG6.js new file mode 100644 index 0000000..bedbeac --- /dev/null +++ b/dist-api/chunk-T5RPAMG6.js @@ -0,0 +1,313 @@ +import { + validateBody, + validateQuery +} from "./chunk-5EGA6GHY.js"; +import { + getTempDir +} from "./chunk-FTVFWJFJ.js"; +import { + AlreadyExistsError, + NotFoundError, + ValidationError, + isNodeError, + resolveNotebookPath +} from "./chunk-ER4KPD22.js"; +import { + asyncHandler, + createApiModule, + defineApiModule, + defineEndpoints, + successResponse +} from "./chunk-74TMTGBG.js"; + +// shared/modules/pydemos/api.ts +var PYDEMOS_ENDPOINTS = defineEndpoints({ + list: { path: "/", method: "GET" }, + create: { path: "/create", method: "POST" }, + delete: { path: "/delete", method: "DELETE" }, + rename: { path: "/rename", method: "POST" } +}); + +// shared/modules/pydemos/index.ts +var PYDEMOS_MODULE = defineApiModule({ + id: "pydemos", + name: "Python Demos", + basePath: "/pydemos", + order: 50, + version: "1.0.0", + endpoints: PYDEMOS_ENDPOINTS +}); + +// api/modules/pydemos/routes.ts +import express from "express"; +import fs from "fs/promises"; +import path from "path"; +import multer from "multer"; + +// api/schemas/files.ts +import { z } from "zod"; +var listFilesQuerySchema = z.object({ + path: z.string().optional().default("") +}); +var contentQuerySchema = z.object({ + path: z.string().min(1) +}); +var rawQuerySchema = z.object({ + path: z.string().min(1) +}); +var pathSchema = z.object({ + path: z.string().min(1) +}); +var saveFileSchema = z.object({ + path: z.string().min(1), + content: z.string() +}); +var renameSchema = z.object({ + oldPath: z.string().min(1), + newPath: z.string().min(1) +}); +var searchSchema = z.object({ + keywords: z.array(z.string()).min(1) +}); +var existsSchema = z.object({ + path: z.string().min(1) +}); +var createDirSchema = z.object({ + path: z.string().min(1) +}); +var createFileSchema = z.object({ + path: z.string().min(1) +}); + +// api/schemas/pydemos.ts +import { z as z2 } from "zod"; +var listPyDemosQuerySchema = z2.object({ + year: z2.string().optional() +}); +var createPyDemoSchema = z2.object({ + name: z2.string().min(1), + year: z2.string().min(1), + month: z2.string().min(1), + folderStructure: z2.string().optional() +}); +var deletePyDemoSchema = z2.object({ + path: z2.string().min(1) +}); +var renamePyDemoSchema = z2.object({ + oldPath: z2.string().min(1), + newName: z2.string().min(1) +}); + +// api/modules/pydemos/routes.ts +var tempDir = getTempDir(); +var upload = multer({ + dest: tempDir, + limits: { + fileSize: 50 * 1024 * 1024 + } +}); +var toPosixPath = (p) => p.replace(/\\/g, "/"); +var getYearPath = (year) => { + const relPath = `pydemos/${year}`; + const { fullPath } = resolveNotebookPath(relPath); + return { relPath, fullPath }; +}; +var getMonthPath = (year, month) => { + const monthStr = month.toString().padStart(2, "0"); + const relPath = `pydemos/${year}/${monthStr}`; + const { fullPath } = resolveNotebookPath(relPath); + return { relPath, fullPath }; +}; +var countFilesInDir = async (dirPath) => { + try { + const entries = await fs.readdir(dirPath, { withFileTypes: true }); + return entries.filter((e) => e.isFile()).length; + } catch { + return 0; + } +}; +var createPyDemosRoutes = () => { + const router = express.Router(); + router.get( + "/", + validateQuery(listPyDemosQuerySchema), + asyncHandler(async (req, res) => { + const year = parseInt(req.query.year) || (/* @__PURE__ */ new Date()).getFullYear(); + const { fullPath: yearPath } = getYearPath(year); + const months = []; + try { + await fs.access(yearPath); + } catch { + successResponse(res, { months }); + return; + } + const monthEntries = await fs.readdir(yearPath, { withFileTypes: true }); + for (const monthEntry of monthEntries) { + if (!monthEntry.isDirectory()) continue; + const monthNum = parseInt(monthEntry.name); + if (isNaN(monthNum) || monthNum < 1 || monthNum > 12) continue; + const monthPath = path.join(yearPath, monthEntry.name); + const demoEntries = await fs.readdir(monthPath, { withFileTypes: true }); + const demos = []; + for (const demoEntry of demoEntries) { + if (!demoEntry.isDirectory()) continue; + const demoPath = path.join(monthPath, demoEntry.name); + const relDemoPath = `pydemos/${year}/${monthEntry.name}/${demoEntry.name}`; + let created; + try { + const stats = await fs.stat(demoPath); + created = stats.birthtime.toISOString(); + } catch { + created = (/* @__PURE__ */ new Date()).toISOString(); + } + const fileCount = await countFilesInDir(demoPath); + demos.push({ + name: demoEntry.name, + path: toPosixPath(relDemoPath), + created, + fileCount + }); + } + demos.sort((a, b) => new Date(b.created).getTime() - new Date(a.created).getTime()); + if (demos.length > 0) { + months.push({ + month: monthNum, + demos + }); + } + } + months.sort((a, b) => a.month - b.month); + successResponse(res, { months }); + }) + ); + router.post( + "/create", + upload.array("files"), + validateBody(createPyDemoSchema), + asyncHandler(async (req, res) => { + const { name, year, month, folderStructure } = req.body; + const yearNum = parseInt(year); + const monthNum = parseInt(month); + if (!/^[a-zA-Z0-9_\-\u4e00-\u9fa5]+$/.test(name)) { + throw new ValidationError("Invalid name format"); + } + const { fullPath: monthPath, relPath: monthRelPath } = getMonthPath(yearNum, monthNum); + const demoPath = path.join(monthPath, name); + const relDemoPath = `${monthRelPath}/${name}`; + try { + await fs.access(demoPath); + throw new AlreadyExistsError("Demo already exists"); + } catch (err) { + if (isNodeError(err) && err.code === "ENOENT") { + } else if (err instanceof AlreadyExistsError) { + throw err; + } else { + throw err; + } + } + await fs.mkdir(demoPath, { recursive: true }); + const files = req.files; + let fileCount = 0; + if (files && files.length > 0) { + let structure = {}; + if (folderStructure) { + try { + structure = JSON.parse(folderStructure); + } catch { + structure = {}; + } + } + for (const file of files) { + const relativePath = structure[file.originalname] || file.originalname; + const targetPath = path.join(demoPath, relativePath); + const targetDir = path.dirname(targetPath); + await fs.mkdir(targetDir, { recursive: true }); + await fs.copyFile(file.path, targetPath); + await fs.unlink(file.path).catch(() => { + }); + fileCount++; + } + } + successResponse(res, { path: toPosixPath(relDemoPath), fileCount }); + }) + ); + router.delete( + "/delete", + validateBody(deletePyDemoSchema), + asyncHandler(async (req, res) => { + const { path: demoPath } = req.body; + if (!demoPath.startsWith("pydemos/")) { + throw new ValidationError("Invalid path"); + } + const { fullPath } = resolveNotebookPath(demoPath); + try { + await fs.access(fullPath); + } catch { + throw new NotFoundError("Demo not found"); + } + await fs.rm(fullPath, { recursive: true, force: true }); + successResponse(res, null); + }) + ); + router.post( + "/rename", + validateBody(renamePyDemoSchema), + asyncHandler(async (req, res) => { + const { oldPath, newName } = req.body; + if (!oldPath.startsWith("pydemos/")) { + throw new ValidationError("Invalid path"); + } + if (!/^[a-zA-Z0-9_\-\u4e00-\u9fa5]+$/.test(newName)) { + throw new ValidationError("Invalid name format"); + } + const { fullPath: oldFullPath } = resolveNotebookPath(oldPath); + try { + await fs.access(oldFullPath); + } catch { + throw new NotFoundError("Demo not found"); + } + const parentDir = path.dirname(oldFullPath); + const newFullPath = path.join(parentDir, newName); + const newPath = toPosixPath(path.join(path.dirname(oldPath), newName)); + try { + await fs.access(newFullPath); + throw new AlreadyExistsError("Demo with this name already exists"); + } catch (err) { + if (isNodeError(err) && err.code === "ENOENT") { + } else if (err instanceof AlreadyExistsError) { + throw err; + } else { + throw err; + } + } + await fs.rename(oldFullPath, newFullPath); + successResponse(res, { newPath }); + }) + ); + return router; +}; +var routes_default = createPyDemosRoutes(); + +// api/modules/pydemos/index.ts +var createPyDemosModule = () => { + return createApiModule(PYDEMOS_MODULE, { + routes: (_container) => { + return createPyDemosRoutes(); + } + }); +}; +var pydemos_default = createPyDemosModule; + +export { + listFilesQuerySchema, + contentQuerySchema, + rawQuerySchema, + pathSchema, + saveFileSchema, + renameSchema, + createDirSchema, + createFileSchema, + createPyDemosRoutes, + createPyDemosModule, + pydemos_default +}; diff --git a/dist-api/chunk-TSQNCXAS.js b/dist-api/chunk-TSQNCXAS.js new file mode 100644 index 0000000..9205719 --- /dev/null +++ b/dist-api/chunk-TSQNCXAS.js @@ -0,0 +1,125 @@ +import { + InternalError, + NotFoundError, + ValidationError, + resolveNotebookPath +} from "./chunk-ER4KPD22.js"; +import { + asyncHandler, + createApiModule, + defineApiModule, + successResponse +} from "./chunk-74TMTGBG.js"; + +// shared/modules/ai/index.ts +var AI_MODULE = defineApiModule({ + id: "ai", + name: "AI", + basePath: "/ai", + order: 70, + version: "1.0.0", + frontend: { + enabled: false + }, + backend: { + enabled: true + } +}); + +// api/modules/ai/routes.ts +import express from "express"; +import { spawn } from "child_process"; +import path from "path"; +import { fileURLToPath } from "url"; +import fs from "fs/promises"; +import fsSync from "fs"; +var __filename = fileURLToPath(import.meta.url); +var __dirname = path.dirname(__filename); +var router = express.Router(); +var PYTHON_TIMEOUT_MS = 3e4; +var spawnPythonWithTimeout = (scriptPath, args, stdinContent, timeoutMs = PYTHON_TIMEOUT_MS) => { + return new Promise((resolve, reject) => { + const pythonProcess = spawn("python", args, { + env: { ...process.env } + }); + let stdout = ""; + let stderr = ""; + let timeoutId = null; + const cleanup = () => { + if (timeoutId) { + clearTimeout(timeoutId); + timeoutId = null; + } + }; + timeoutId = setTimeout(() => { + cleanup(); + pythonProcess.kill(); + reject(new Error(`Python script timed out after ${timeoutMs}ms`)); + }, timeoutMs); + pythonProcess.stdout.on("data", (data) => { + stdout += data.toString(); + }); + pythonProcess.stderr.on("data", (data) => { + stderr += data.toString(); + }); + pythonProcess.on("close", (code) => { + cleanup(); + if (code !== 0) { + reject(new Error(`Python script exited with code ${code}. Stderr: ${stderr}`)); + } else { + resolve(stdout); + } + }); + pythonProcess.on("error", (err) => { + cleanup(); + reject(new Error(`Failed to start python process: ${err.message}`)); + }); + pythonProcess.stdin.write(stdinContent); + pythonProcess.stdin.end(); + }); +}; +router.post( + "/doubao", + asyncHandler(async (req, res) => { + const { task, path: relPath } = req.body; + if (!task) throw new ValidationError("Task is required"); + if (!relPath) throw new ValidationError("Path is required"); + const { fullPath } = resolveNotebookPath(relPath); + try { + await fs.access(fullPath); + } catch { + throw new NotFoundError("File not found"); + } + const content = await fs.readFile(fullPath, "utf-8"); + const projectRoot = path.resolve(__dirname, "..", "..", ".."); + const scriptPath = path.join(projectRoot, "tools", "doubao", "main.py"); + if (!fsSync.existsSync(scriptPath)) { + throw new InternalError(`Python script not found: ${scriptPath}`); + } + try { + const result = await spawnPythonWithTimeout(scriptPath, ["--task", task], content); + await fs.writeFile(fullPath, result, "utf-8"); + successResponse(res, { message: "Task completed successfully" }); + } catch (err) { + const message = err instanceof Error ? err.message : "Unknown error"; + throw new InternalError(`AI task failed: ${message}`); + } + }) +); +var createAiRoutes = () => router; + +// api/modules/ai/index.ts +var createAiModule = () => { + return createApiModule(AI_MODULE, { + routes: (_container) => { + return createAiRoutes(); + } + }); +}; +var ai_default = createAiModule; + +export { + createAiRoutes, + createAiModule, + ai_default +}; diff --git a/dist-api/chunk-V2OWYGQG.js b/dist-api/chunk-V2OWYGQG.js new file mode 100644 index 0000000..19fb8f4 --- /dev/null +++ b/dist-api/chunk-V2OWYGQG.js @@ -0,0 +1,399 @@ +import { + validateBody, + validateQuery +} from "./chunk-5EGA6GHY.js"; +import { + NotFoundError, + resolveNotebookPath +} from "./chunk-ER4KPD22.js"; +import { + asyncHandler, + createApiModule, + defineApiModule, + defineEndpoints, + successResponse +} from "./chunk-74TMTGBG.js"; + +// shared/modules/todo/api.ts +var TODO_ENDPOINTS = defineEndpoints({ + list: { path: "/", method: "GET" }, + save: { path: "/save", method: "POST" }, + add: { path: "/add", method: "POST" }, + toggle: { path: "/toggle", method: "POST" }, + update: { path: "/update", method: "POST" }, + delete: { path: "/delete", method: "DELETE" } +}); + +// shared/modules/todo/index.ts +var TODO_MODULE = defineApiModule({ + id: "todo", + name: "TODO", + basePath: "/todo", + order: 30, + version: "1.0.0", + endpoints: TODO_ENDPOINTS +}); + +// api/modules/todo/service.ts +import fs from "fs/promises"; +import path from "path"; + +// api/modules/todo/parser.ts +var parseTodoContent = (content) => { + const lines = content.split("\n"); + const result = []; + let currentDate = null; + let currentItems = []; + let itemId = 0; + for (const line of lines) { + const dateMatch = line.match(/^##\s*(\d{4}-\d{2}-\d{2})/); + if (dateMatch) { + if (currentDate) { + result.push({ date: currentDate, items: currentItems }); + } + currentDate = dateMatch[1]; + currentItems = []; + } else if (currentDate) { + const todoMatch = line.match(/^- (√|○) (.*)$/); + if (todoMatch) { + currentItems.push({ + id: `${currentDate}-${itemId++}`, + content: todoMatch[2], + completed: todoMatch[1] === "\u221A" + }); + } + } + } + if (currentDate) { + result.push({ date: currentDate, items: currentItems }); + } + return result; +}; +var generateTodoContent = (dayTodos) => { + const lines = []; + const sortedDays = [...dayTodos].sort((a, b) => a.date.localeCompare(b.date)); + for (const day of sortedDays) { + lines.push(`## ${day.date}`); + for (const item of day.items) { + const checkbox = item.completed ? "\u221A" : "\u25CB"; + lines.push(`- ${checkbox} ${item.content}`); + } + lines.push(""); + } + return lines.join("\n").trimEnd(); +}; + +// api/modules/todo/service.ts +var TodoService = class { + constructor(deps = {}) { + this.deps = deps; + } + getTodoFilePath(year, month) { + const yearStr = year.toString(); + const monthStr = month.toString().padStart(2, "0"); + const relPath = `TODO/${yearStr}/${yearStr}${monthStr}TODO.md`; + const { fullPath } = resolveNotebookPath(relPath); + return { relPath, fullPath }; + } + async ensureTodoFileExists(fullPath) { + const dir = path.dirname(fullPath); + await fs.mkdir(dir, { recursive: true }); + try { + await fs.access(fullPath); + } catch { + await fs.writeFile(fullPath, "", "utf-8"); + } + } + async loadAndParseTodoFile(year, month) { + const { fullPath } = this.getTodoFilePath(year, month); + try { + await fs.access(fullPath); + } catch { + throw new NotFoundError("TODO file not found"); + } + const content = await fs.readFile(fullPath, "utf-8"); + return { fullPath, dayTodos: parseTodoContent(content) }; + } + async saveTodoFile(fullPath, dayTodos) { + const content = generateTodoContent(dayTodos); + await fs.writeFile(fullPath, content, "utf-8"); + } + async getTodo(year, month) { + const { fullPath } = this.getTodoFilePath(year, month); + let dayTodos = []; + try { + await fs.access(fullPath); + const content = await fs.readFile(fullPath, "utf-8"); + dayTodos = parseTodoContent(content); + } catch { + } + const now = /* @__PURE__ */ new Date(); + const todayStr = `${now.getFullYear()}-${(now.getMonth() + 1).toString().padStart(2, "0")}-${now.getDate().toString().padStart(2, "0")}`; + const yesterday = new Date(now); + yesterday.setDate(yesterday.getDate() - 1); + const yesterdayStr = `${yesterday.getFullYear()}-${(yesterday.getMonth() + 1).toString().padStart(2, "0")}-${yesterday.getDate().toString().padStart(2, "0")}`; + if (year === now.getFullYear() && month === now.getMonth() + 1) { + const migrated = this.migrateIncompleteItems(dayTodos, todayStr, yesterdayStr); + if (migrated) { + const newContent = generateTodoContent(dayTodos); + await this.ensureTodoFileExists(fullPath); + await fs.writeFile(fullPath, newContent, "utf-8"); + } + } + return { dayTodos, year, month }; + } + migrateIncompleteItems(dayTodos, todayStr, yesterdayStr) { + let migrated = false; + const yesterdayTodo = dayTodos.find((d) => d.date === yesterdayStr); + if (yesterdayTodo) { + const incompleteItems = yesterdayTodo.items.filter((item) => !item.completed); + if (incompleteItems.length > 0) { + const todayTodo = dayTodos.find((d) => d.date === todayStr); + if (todayTodo) { + const existingIds = new Set(todayTodo.items.map((i) => i.id)); + const itemsToAdd = incompleteItems.map((item, idx) => ({ + ...item, + id: existingIds.has(item.id) ? `${todayStr}-migrated-${idx}` : item.id + })); + todayTodo.items = [...itemsToAdd, ...todayTodo.items]; + } else { + dayTodos.push({ + date: todayStr, + items: incompleteItems.map((item, idx) => ({ + ...item, + id: `${todayStr}-migrated-${idx}` + })) + }); + } + yesterdayTodo.items = yesterdayTodo.items.filter((item) => item.completed); + if (yesterdayTodo.items.length === 0) { + const index = dayTodos.findIndex((d) => d.date === yesterdayStr); + if (index !== -1) { + dayTodos.splice(index, 1); + } + } + migrated = true; + } + } + return migrated; + } + async saveTodo(year, month, dayTodos) { + const { fullPath } = this.getTodoFilePath(year, month); + await this.ensureTodoFileExists(fullPath); + const content = generateTodoContent(dayTodos); + await fs.writeFile(fullPath, content, "utf-8"); + } + async addTodo(year, month, date, todoContent) { + const { fullPath } = this.getTodoFilePath(year, month); + await this.ensureTodoFileExists(fullPath); + let fileContent = await fs.readFile(fullPath, "utf-8"); + const dayTodos = parseTodoContent(fileContent); + const existingDay = dayTodos.find((d) => d.date === date); + if (existingDay) { + const newId = `${date}-${existingDay.items.length}`; + existingDay.items.push({ + id: newId, + content: todoContent, + completed: false + }); + } else { + dayTodos.push({ + date, + items: [{ + id: `${date}-0`, + content: todoContent, + completed: false + }] + }); + } + fileContent = generateTodoContent(dayTodos); + await fs.writeFile(fullPath, fileContent, "utf-8"); + return dayTodos; + } + async toggleTodo(year, month, date, itemIndex, completed) { + const { fullPath, dayTodos } = await this.loadAndParseTodoFile(year, month); + const day = dayTodos.find((d) => d.date === date); + if (!day || itemIndex >= day.items.length) { + throw new NotFoundError("TODO item not found"); + } + day.items[itemIndex].completed = completed; + await this.saveTodoFile(fullPath, dayTodos); + return dayTodos; + } + async updateTodo(year, month, date, itemIndex, newContent) { + const { fullPath, dayTodos } = await this.loadAndParseTodoFile(year, month); + const day = dayTodos.find((d) => d.date === date); + if (!day || itemIndex >= day.items.length) { + throw new NotFoundError("TODO item not found"); + } + day.items[itemIndex].content = newContent; + await this.saveTodoFile(fullPath, dayTodos); + return dayTodos; + } + async deleteTodo(year, month, date, itemIndex) { + const { fullPath, dayTodos } = await this.loadAndParseTodoFile(year, month); + const dayIndex = dayTodos.findIndex((d) => d.date === date); + if (dayIndex === -1) { + throw new NotFoundError("Day not found"); + } + const day = dayTodos[dayIndex]; + if (itemIndex >= day.items.length) { + throw new NotFoundError("TODO item not found"); + } + day.items.splice(itemIndex, 1); + if (day.items.length === 0) { + dayTodos.splice(dayIndex, 1); + } + await this.saveTodoFile(fullPath, dayTodos); + return dayTodos; + } +}; +var createTodoService = (deps) => { + return new TodoService(deps); +}; + +// api/modules/todo/routes.ts +import express from "express"; + +// api/modules/todo/schemas.ts +import { z } from "zod"; +var todoItemSchema = z.object({ + id: z.string(), + content: z.string(), + completed: z.boolean() +}); +var dayTodoSchema = z.object({ + date: z.string(), + items: z.array(todoItemSchema) +}); +var getTodoQuerySchema = z.object({ + year: z.string().optional(), + month: z.string().optional() +}); +var saveTodoSchema = z.object({ + year: z.number().int().positive(), + month: z.number().int().min(1).max(12), + dayTodos: z.array(dayTodoSchema) +}); +var addTodoSchema = z.object({ + year: z.number().int().positive(), + month: z.number().int().min(1).max(12), + date: z.string(), + content: z.string() +}); +var toggleTodoSchema = z.object({ + year: z.number().int().positive(), + month: z.number().int().min(1).max(12), + date: z.string(), + itemIndex: z.number().int().nonnegative(), + completed: z.boolean() +}); +var updateTodoSchema = z.object({ + year: z.number().int().positive(), + month: z.number().int().min(1).max(12), + date: z.string(), + itemIndex: z.number().int().nonnegative(), + content: z.string() +}); +var deleteTodoSchema = z.object({ + year: z.number().int().positive(), + month: z.number().int().min(1).max(12), + date: z.string(), + itemIndex: z.number().int().nonnegative() +}); + +// api/modules/todo/routes.ts +var createTodoRoutes = (deps) => { + const router = express.Router(); + const { todoService: todoService2 } = deps; + router.get( + "/", + validateQuery(getTodoQuerySchema), + asyncHandler(async (req, res) => { + const year = parseInt(req.query.year) || (/* @__PURE__ */ new Date()).getFullYear(); + const month = parseInt(req.query.month) || (/* @__PURE__ */ new Date()).getMonth() + 1; + const result = await todoService2.getTodo(year, month); + successResponse(res, result); + }) + ); + router.post( + "/save", + validateBody(saveTodoSchema), + asyncHandler(async (req, res) => { + const { year, month, dayTodos } = req.body; + await todoService2.saveTodo(year, month, dayTodos); + successResponse(res, null); + }) + ); + router.post( + "/add", + validateBody(addTodoSchema), + asyncHandler(async (req, res) => { + const { year, month, date, content: todoContent } = req.body; + const dayTodos = await todoService2.addTodo(year, month, date, todoContent); + successResponse(res, { dayTodos }); + }) + ); + router.post( + "/toggle", + validateBody(toggleTodoSchema), + asyncHandler(async (req, res) => { + const { year, month, date, itemIndex, completed } = req.body; + const dayTodos = await todoService2.toggleTodo(year, month, date, itemIndex, completed); + successResponse(res, { dayTodos }); + }) + ); + router.post( + "/update", + validateBody(updateTodoSchema), + asyncHandler(async (req, res) => { + const { year, month, date, itemIndex, content: newContent } = req.body; + const dayTodos = await todoService2.updateTodo(year, month, date, itemIndex, newContent); + successResponse(res, { dayTodos }); + }) + ); + router.delete( + "/delete", + validateBody(deleteTodoSchema), + asyncHandler(async (req, res) => { + const { year, month, date, itemIndex } = req.body; + const dayTodos = await todoService2.deleteTodo(year, month, date, itemIndex); + successResponse(res, { dayTodos }); + }) + ); + return router; +}; +var todoService = new TodoService(); +var routes_default = createTodoRoutes({ todoService }); + +// api/modules/todo/index.ts +var createTodoModule = () => { + return createApiModule(TODO_MODULE, { + routes: (container) => { + const todoService2 = container.getSync("todoService"); + return createTodoRoutes({ todoService: todoService2 }); + }, + lifecycle: { + onLoad: (container) => { + container.register("todoService", () => new TodoService()); + } + } + }); +}; +var todo_default = createTodoModule; + +export { + parseTodoContent, + generateTodoContent, + TodoService, + createTodoService, + getTodoQuerySchema, + saveTodoSchema, + addTodoSchema, + toggleTodoSchema, + updateTodoSchema, + deleteTodoSchema, + createTodoRoutes, + createTodoModule, + todo_default +}; diff --git a/dist-api/chunk-W5TDYTXE.js b/dist-api/chunk-W5TDYTXE.js new file mode 100644 index 0000000..3b3bb90 --- /dev/null +++ b/dist-api/chunk-W5TDYTXE.js @@ -0,0 +1,280 @@ +import { + resolveNotebookPath +} from "./chunk-ER4KPD22.js"; +import { + asyncHandler, + createApiModule, + defineApiModule, + defineEndpoints, + successResponse +} from "./chunk-74TMTGBG.js"; + +// shared/modules/remote/api.ts +var REMOTE_ENDPOINTS = defineEndpoints({ + getConfig: { path: "/config", method: "GET" }, + saveConfig: { path: "/config", method: "POST" }, + getScreenshot: { path: "/screenshot", method: "GET" }, + saveScreenshot: { path: "/screenshot", method: "POST" }, + getData: { path: "/data", method: "GET" }, + saveData: { path: "/data", method: "POST" } +}); + +// shared/modules/remote/index.ts +var REMOTE_MODULE = defineApiModule({ + id: "remote", + name: "\u8FDC\u7A0B", + basePath: "/remote", + order: 25, + version: "1.0.0", + endpoints: REMOTE_ENDPOINTS +}); + +// api/modules/remote/service.ts +import fs from "fs/promises"; +import path from "path"; +var REMOTE_DIR = "remote"; +var RemoteService = class { + constructor(deps = {}) { + this.deps = deps; + } + getRemoteDir() { + const { fullPath } = resolveNotebookPath(REMOTE_DIR); + return { relPath: REMOTE_DIR, fullPath }; + } + getDeviceDir(deviceName) { + const safeName = this.sanitizeFileName(deviceName); + const { fullPath } = resolveNotebookPath(path.join(REMOTE_DIR, safeName)); + return { relPath: path.join(REMOTE_DIR, safeName), fullPath }; + } + sanitizeFileName(name) { + return name.replace(/[<>:"/\\|?*]/g, "_").trim() || "unnamed"; + } + getDeviceConfigPath(deviceName) { + const { fullPath } = this.getDeviceDir(deviceName); + return path.join(fullPath, "config.json"); + } + getDeviceScreenshotPath(deviceName) { + const { fullPath } = this.getDeviceDir(deviceName); + return path.join(fullPath, "screenshot.png"); + } + getDeviceDataPath(deviceName) { + const { fullPath } = this.getDeviceDir(deviceName); + return path.join(fullPath, "data.json"); + } + async ensureDir(dirPath) { + await fs.mkdir(dirPath, { recursive: true }); + } + async getDeviceNames() { + const { fullPath } = this.getRemoteDir(); + try { + const entries = await fs.readdir(fullPath, { withFileTypes: true }); + const dirs = entries.filter((e) => e.isDirectory()).map((e) => e.name); + return dirs; + } catch { + return []; + } + } + async getConfig() { + const deviceNames = await this.getDeviceNames(); + const devices = await Promise.all( + deviceNames.map(async (name) => { + try { + const configPath = this.getDeviceConfigPath(name); + const content = await fs.readFile(configPath, "utf-8"); + const deviceConfig = JSON.parse(content); + return { + id: deviceConfig.id || name, + deviceName: name, + serverHost: deviceConfig.serverHost || "", + desktopPort: deviceConfig.desktopPort || 3e3, + gitPort: deviceConfig.gitPort || 3001, + openCodePort: deviceConfig.openCodePort || 3002, + fileTransferPort: deviceConfig.fileTransferPort || 3003, + password: deviceConfig.password || "" + }; + } catch { + return { + id: name, + deviceName: name, + serverHost: "", + desktopPort: 3e3, + gitPort: 3001, + openCodePort: 3002, + fileTransferPort: 3003, + password: "" + }; + } + }) + ); + return { devices }; + } + async saveConfig(config) { + const { fullPath: remoteDirFullPath } = this.getRemoteDir(); + await this.ensureDir(remoteDirFullPath); + const existingDevices = await this.getDeviceNames(); + const newDeviceNames = config.devices.map((d) => this.sanitizeFileName(d.deviceName)); + for (const oldDevice of existingDevices) { + if (!newDeviceNames.includes(oldDevice)) { + try { + const oldDir = path.join(remoteDirFullPath, oldDevice); + await fs.rm(oldDir, { recursive: true, force: true }); + } catch { + } + } + } + for (const device of config.devices) { + const deviceDir = this.getDeviceDir(device.deviceName); + await this.ensureDir(deviceDir.fullPath); + const deviceConfigPath = this.getDeviceConfigPath(device.deviceName); + const deviceConfig = { + id: device.id, + serverHost: device.serverHost, + desktopPort: device.desktopPort, + gitPort: device.gitPort, + openCodePort: device.openCodePort, + fileTransferPort: device.fileTransferPort, + password: device.password || "" + }; + await fs.writeFile(deviceConfigPath, JSON.stringify(deviceConfig, null, 2), "utf-8"); + } + } + async getScreenshot(deviceName) { + if (!deviceName) { + return null; + } + const screenshotPath = this.getDeviceScreenshotPath(deviceName); + try { + return await fs.readFile(screenshotPath); + } catch { + return null; + } + } + async saveScreenshot(dataUrl, deviceName) { + console.log("[RemoteService] saveScreenshot:", { deviceName, dataUrlLength: dataUrl?.length }); + if (!deviceName || deviceName.trim() === "") { + console.warn("[RemoteService] saveScreenshot skipped: no deviceName"); + return; + } + const deviceDir = this.getDeviceDir(deviceName); + await this.ensureDir(deviceDir.fullPath); + const base64Data = dataUrl.replace(/^data:image\/png;base64,/, ""); + const buffer = Buffer.from(base64Data, "base64"); + const screenshotPath = this.getDeviceScreenshotPath(deviceName); + await fs.writeFile(screenshotPath, buffer); + } + async getData(deviceName) { + if (!deviceName || deviceName.trim() === "") { + return null; + } + const dataPath = this.getDeviceDataPath(deviceName); + try { + const content = await fs.readFile(dataPath, "utf-8"); + return JSON.parse(content); + } catch { + return null; + } + } + async saveData(data, deviceName) { + if (!deviceName || deviceName.trim() === "") { + console.warn("[RemoteService] saveData skipped: no deviceName"); + return; + } + const deviceDir = this.getDeviceDir(deviceName); + await this.ensureDir(deviceDir.fullPath); + const dataPath = this.getDeviceDataPath(deviceName); + await fs.writeFile(dataPath, JSON.stringify(data, null, 2), "utf-8"); + } +}; +var createRemoteService = (deps) => { + return new RemoteService(deps); +}; + +// api/modules/remote/routes.ts +import express from "express"; +var createRemoteRoutes = (deps) => { + const router = express.Router(); + const { remoteService: remoteService2 } = deps; + router.get( + "/config", + asyncHandler(async (req, res) => { + const config = await remoteService2.getConfig(); + successResponse(res, config); + }) + ); + router.post( + "/config", + asyncHandler(async (req, res) => { + const config = req.body; + await remoteService2.saveConfig(config); + successResponse(res, null); + }) + ); + router.get( + "/screenshot", + asyncHandler(async (req, res) => { + const deviceName = req.query.device; + const buffer = await remoteService2.getScreenshot(deviceName); + if (!buffer) { + return successResponse(res, ""); + } + const base64 = `data:image/png;base64,${buffer.toString("base64")}`; + successResponse(res, base64); + }) + ); + router.post( + "/screenshot", + asyncHandler(async (req, res) => { + const { dataUrl, deviceName } = req.body; + console.log("[Remote] saveScreenshot called:", { deviceName, hasDataUrl: !!dataUrl }); + await remoteService2.saveScreenshot(dataUrl, deviceName); + successResponse(res, null); + }) + ); + router.get( + "/data", + asyncHandler(async (req, res) => { + const deviceName = req.query.device; + const data = await remoteService2.getData(deviceName); + successResponse(res, data); + }) + ); + router.post( + "/data", + asyncHandler(async (req, res) => { + const { deviceName, lastConnected } = req.body; + const data = {}; + if (lastConnected !== void 0) { + data.lastConnected = lastConnected; + } + await remoteService2.saveData(data, deviceName); + successResponse(res, null); + }) + ); + return router; +}; +var remoteService = new RemoteService(); +var routes_default = createRemoteRoutes({ remoteService }); + +// api/modules/remote/index.ts +var createRemoteModule = () => { + return createApiModule(REMOTE_MODULE, { + routes: (container) => { + const remoteService2 = container.getSync("remoteService"); + return createRemoteRoutes({ remoteService: remoteService2 }); + }, + lifecycle: { + onLoad: (container) => { + container.register("remoteService", () => new RemoteService()); + } + } + }); +}; +var remote_default = createRemoteModule; + +export { + RemoteService, + createRemoteService, + createRemoteRoutes, + createRemoteModule, + remote_default +}; diff --git a/dist-api/document-parser-FDZMXJVD.js b/dist-api/document-parser-FDZMXJVD.js new file mode 100644 index 0000000..8ce010f --- /dev/null +++ b/dist-api/document-parser-FDZMXJVD.js @@ -0,0 +1,32 @@ +import { + applyReplacements, + blogRoutes_default, + cleanupJob, + copyLocalImage, + createDocumentParserModule, + createJobContext, + document_parser_default, + ensureScriptExists, + findImageDestinations, + getScriptPath, + mineruRoutes_default, + spawnPythonScript +} from "./chunk-R5LQJNQE.js"; +import "./chunk-47DJ6YUB.js"; +import "./chunk-FTVFWJFJ.js"; +import "./chunk-ER4KPD22.js"; +import "./chunk-74TMTGBG.js"; +export { + applyReplacements, + blogRoutes_default as blogRoutes, + cleanupJob, + copyLocalImage, + createDocumentParserModule, + createJobContext, + document_parser_default as default, + ensureScriptExists, + findImageDestinations, + getScriptPath, + mineruRoutes_default as mineruRoutes, + spawnPythonScript +}; diff --git a/dist-api/pydemos-W2QKOORB.js b/dist-api/pydemos-W2QKOORB.js new file mode 100644 index 0000000..8f6577d --- /dev/null +++ b/dist-api/pydemos-W2QKOORB.js @@ -0,0 +1,14 @@ +import { + createPyDemosModule, + createPyDemosRoutes, + pydemos_default +} from "./chunk-T5RPAMG6.js"; +import "./chunk-5EGA6GHY.js"; +import "./chunk-FTVFWJFJ.js"; +import "./chunk-ER4KPD22.js"; +import "./chunk-74TMTGBG.js"; +export { + createPyDemosModule, + createPyDemosRoutes, + pydemos_default as default +}; diff --git a/dist-api/recycle-bin-VT62BD42.js b/dist-api/recycle-bin-VT62BD42.js new file mode 100644 index 0000000..a1f3fa3 --- /dev/null +++ b/dist-api/recycle-bin-VT62BD42.js @@ -0,0 +1,14 @@ +import { + createRecycleBinModule, + recycle_bin_default, + restoreFile, + restoreFolder +} from "./chunk-M2SZ5AIA.js"; +import "./chunk-ER4KPD22.js"; +import "./chunk-74TMTGBG.js"; +export { + createRecycleBinModule, + recycle_bin_default as default, + restoreFile, + restoreFolder +}; diff --git a/dist-api/remote-KLYC364F.js b/dist-api/remote-KLYC364F.js new file mode 100644 index 0000000..09e766b --- /dev/null +++ b/dist-api/remote-KLYC364F.js @@ -0,0 +1,16 @@ +import { + RemoteService, + createRemoteModule, + createRemoteRoutes, + createRemoteService, + remote_default +} from "./chunk-W5TDYTXE.js"; +import "./chunk-ER4KPD22.js"; +import "./chunk-74TMTGBG.js"; +export { + RemoteService, + createRemoteModule, + createRemoteRoutes, + createRemoteService, + remote_default as default +}; diff --git a/dist-api/time-tracking-5F7NPQRY.js b/dist-api/time-tracking-5F7NPQRY.js new file mode 100644 index 0000000..ef43b82 --- /dev/null +++ b/dist-api/time-tracking-5F7NPQRY.js @@ -0,0 +1,28 @@ +import { + HeartbeatService, + SessionPersistenceService, + TimeTrackerService, + createHeartbeatService, + createSessionPersistence, + createTimeTrackingModule, + createTimeTrackingRoutes, + getTimeTrackerService, + initializeTimeTrackerService, + initializeTimeTrackerServiceWithDependencies, + time_tracking_default +} from "./chunk-QS2CMBFP.js"; +import "./chunk-47DJ6YUB.js"; +import "./chunk-74TMTGBG.js"; +export { + HeartbeatService, + SessionPersistenceService, + TimeTrackerService, + createHeartbeatService, + createSessionPersistence, + createTimeTrackingModule, + createTimeTrackingRoutes, + time_tracking_default as default, + getTimeTrackerService, + initializeTimeTrackerService, + initializeTimeTrackerServiceWithDependencies +}; diff --git a/dist-api/todo-GDTZZEQA.js b/dist-api/todo-GDTZZEQA.js new file mode 100644 index 0000000..f421ab3 --- /dev/null +++ b/dist-api/todo-GDTZZEQA.js @@ -0,0 +1,33 @@ +import { + TodoService, + addTodoSchema, + createTodoModule, + createTodoRoutes, + createTodoService, + deleteTodoSchema, + generateTodoContent, + getTodoQuerySchema, + parseTodoContent, + saveTodoSchema, + todo_default, + toggleTodoSchema, + updateTodoSchema +} from "./chunk-V2OWYGQG.js"; +import "./chunk-5EGA6GHY.js"; +import "./chunk-ER4KPD22.js"; +import "./chunk-74TMTGBG.js"; +export { + TodoService, + addTodoSchema, + createTodoModule, + createTodoRoutes, + createTodoService, + todo_default as default, + deleteTodoSchema, + generateTodoContent, + getTodoQuerySchema, + parseTodoContent, + saveTodoSchema, + toggleTodoSchema, + updateTodoSchema +}; diff --git a/docs/vercel/VERCEL_GUIDE.md b/docs/vercel/VERCEL_GUIDE.md new file mode 100644 index 0000000..b9fff13 --- /dev/null +++ b/docs/vercel/VERCEL_GUIDE.md @@ -0,0 +1,2114 @@ +# Vercel 完整指南 + +> 本文档基于 Vercel 官方文档整理,涵盖 Vercel 平台的所有核心功能和使用方法。 + +--- + +## 目录 + +1. [平台概述](#一平台概述) +2. [快速入门](#二快速入门) +3. [模板市场](#三模板市场) +4. [项目与部署](#四项目与部署) +5. [Git 集成](#五git-集成) +6. [Vercel Functions](#六vercel-functions) +7. [路由中间件](#七路由中间件) +8. [环境变量](#八环境变量) +9. [域名管理](#九域名管理) +10. [存储服务](#十存储服务) +11. [AI SDK](#十一ai-sdk) +12. [分析与监控](#十二分析与监控) +13. [安全功能](#十三安全功能) +14. [图片优化](#十四图片优化) +15. [定时任务](#十五定时任务) +16. [CLI 命令行工具](#十六cli-命令行工具) +17. [REST API](#十七rest-api) +18. [框架支持](#十八框架支持) +19. [部署保护](#十九部署保护) +20. [计费与限制](#二十计费与限制) +21. [最佳实践](#二十一最佳实践) + +--- + +## 一、平台概述 + +### 什么是 Vercel? + +Vercel 是 AI 云平台,为开发者提供构建、部署和扩展 AI 驱动应用的统一平台。支持部署 Web 应用、代理工作负载以及介于两者之间的所有内容。 + +### 核心特性 + +- **零配置部署**:支持主流框架的零配置部署 +- **全球 CDN**:内容分发至全球数据中心 +- **自动 HTTPS**:免费 SSL 证书 +- **预览环境**:每次 Git 推送自动生成预览 URL +- **Serverless Functions**:无需管理服务器的后端函数 +- **边缘网络**:全球低延迟执行 + +### 订阅计划 + +| 计划 | 适用场景 | 价格 | +|------|----------|------| +| **Hobby** | 个人项目、学习 | 免费 | +| **Pro** | 专业开发者、团队 | $20/用户/月 | +| **Enterprise** | 大型企业 | 定制 | + +--- + +## 二、快速入门 + +### 创建账户 + +1. 访问 [vercel.com/signup](https://vercel.com/signup) +2. 选择 Git 提供商登录或使用邮箱 +3. 完成邮箱和手机验证 + +### 安装 CLI + +```bash +# npm +npm i -g vercel + +# pnpm +pnpm i -g vercel + +# yarn +yarn global add vercel + +# bun +bun i -g vercel +``` + +### 首次部署 + +```bash +# 进入项目目录 +cd your-project + +# 部署到生产环境 +vercel --prod +``` + +### 部署流程 + +``` +┌─────────────┐ ┌─────────────┐ ┌─────────────┐ +│ 本地开发 │ ──▶ │ Git 推送 │ ──▶ │ 自动构建 │ +└─────────────┘ └─────────────┘ └─────────────┘ + │ + ▼ + ┌─────────────┐ ┌─────────────┐ + │ 生成 URL │ ◀── │ 部署成功 │ + └─────────────┘ └─────────────┘ +``` + +--- + +## 三、模板市场 + +### 概述 + +Vercel Templates 是官方提供的模板市场,包含大量预构建的项目模板,帮助开发者快速启动项目开发。所有模板都是**免费**使用的。 + +**访问地址**:[vercel.com/templates](https://vercel.com/templates) + +### 模板分类 + +#### 按用途分类 + +| 类别 | 说明 | 适用场景 | +|------|------|----------| +| **AI** | AI 应用模板 | 聊天机器人、AI Agent、语音接口 | +| **Starter** | 入门模板 | 快速启动新项目 | +| **E-commerce** | 电商模板 | 在线商店、商品展示 | +| **SaaS** | SaaS 应用模板 | 多租户平台、企业应用 | +| **Blog** | 博客模板 | 个人博客、技术文章 | +| **Portfolio** | 作品集模板 | 个人简历、作品展示 | +| **Documentation** | 文档模板 | 技术文档、API 文档 | +| **Backend** | 后端模板 | API 服务、微服务 | +| **CMS** | 内容管理模板 | 内容网站、新闻站点 | +| **Marketing Sites** | 营销网站模板 | 产品官网、落地页 | +| **Authentication** | 认证模板 | 登录系统、用户管理 | +| **Admin Dashboard** | 管理后台模板 | 数据管理、控制面板 | +| **Web3** | 区块链模板 | DApp、NFT 市场 | +| **Monorepos** | 单体仓库模板 | 多包项目管理 | + +#### 按技术栈分类 + +| 类别 | 说明 | +|------|------| +| **Framework** | Next.js、Nuxt、SvelteKit、Astro、Remix 等 | +| **CSS** | Tailwind CSS、CSS Modules、Styled Components 等 | +| **Database** | PostgreSQL、MongoDB、Redis、PlanetScale 等 | +| **CMS** | Contentful、Sanity、Prismic、Strapi 等 | +| **Authentication** | NextAuth.js、Auth0、Clerk、Supabase Auth 等 | + +### 热门模板详解 + +#### 1. Next.js Boilerplate + +最基础的 Next.js 起手架模板。 + +``` +技术栈: +- Next.js 14+ (App Router) +- TypeScript +- ESLint + Prettier +- Tailwind CSS + +适用场景: +- 快速启动新项目 +- 学习 Next.js +- 自定义项目基础 +``` + +**使用方式**: +```bash +# CLI 方式 +vercel init nextjs-boilerplate + +# 或访问 Dashboard +# vercel.com/templates/next.js/nextjs-boilerplate +``` + +#### 2. AI Chatbot + +Vercel 官方出品的完整 AI 聊天机器人模板。 + +``` +技术栈: +- Next.js App Router +- Vercel AI SDK +- OpenAI / Anthropic / 其他 LLM +- Tailwind CSS +- Supabase(可选,用于数据持久化) + +功能特性: +- 流式响应 +- 多模型支持 +- 聊天历史 +- 代码高亮 +- Markdown 渲染 +``` + +**使用方式**: +```bash +vercel init ai-chatbot +``` + +**配置环境变量**: +```bash +# OpenAI +OPENAI_API_KEY=sk-xxx + +# 或 Anthropic +ANTHROPIC_API_KEY=sk-xxx +``` + +#### 3. Next.js Commerce + +高性能电商网站模板,支持多种电商平台集成。 + +``` +技术栈: +- Next.js 14 +- Shopify Storefront API +- Tailwind CSS +- Framer Motion + +功能特性: +- 商品列表与详情 +- 购物车 +- 结账流程 +- 商品搜索 +- 响应式设计 +- SEO 优化 +``` + +**支持的电商平台**: +- Shopify +- BigCommerce +- Saleor +- Swell +- Vendure + +**使用方式**: +```bash +vercel init nextjs-commerce +``` + +**配置 Shopify**: +```bash +SHOPIFY_STORE_DOMAIN=your-store.myshopify.com +SHOPIFY_STOREFRONT_ACCESS_TOKEN=xxx +``` + +#### 4. Platforms Starter Kit + +多租户 SaaS 平台模板,用于构建类似 Vercel 的平台。 + +``` +技术栈: +- Next.js App Router +- Redis (Upstash) +- Tailwind CSS +- Prisma + +功能特性: +- 多租户架构 +- 自定义域名 +- 子域名路由 +- 用户认证 +- 计费集成 +- Dashboard 管理 +``` + +**使用场景**: +- 博客平台(类似 Medium) +- 电商托管平台 +- CMS 托管服务 +- 任何需要多租户的 SaaS + +**使用方式**: +```bash +vercel init platforms-starter-kit +``` + +#### 5. Nextra: Docs Starter Kit + +Markdown 驱动的文档站点模板。 + +``` +技术栈: +- Next.js +- Nextra +- MDX +- Tailwind CSS + +功能特性: +- Markdown/MDX 支持 +- 自动侧边栏 +- 全文搜索 +- 深色模式 +- 代码高亮 +- 版本管理 +``` + +**使用场景**: +- 技术文档 +- API 文档 +- 知识库 +- 教程网站 + +**使用方式**: +```bash +vercel init documentation-starter-kit +``` + +#### 6. Blog Starter Kit + +静态博客模板,支持 Markdown 写作。 + +``` +技术栈: +- Next.js +- TypeScript +- Tailwind CSS +- Markdown + +功能特性: +- Markdown 写作 +- 标签分类 +- RSS 订阅 +- SEO 优化 +- 深色模式 +``` + +**使用方式**: +```bash +vercel init blog-starter-kit +``` + +#### 7. Next.js Enterprise Boilerplate + +企业级 Next.js 模板,包含完整的工程化配置。 + +``` +技术栈: +- Next.js +- TypeScript +- Tailwind CSS +- Radix UI +- ESLint + Prettier +- Jest + Playwright +- Storybook +- GitHub Actions + +功能特性: +- 企业级代码规范 +- 完整的测试配置 +- CI/CD 流程 +- 组件文档 +- 性能监控 +``` + +**适用场景**: +- 企业项目 +- 大型团队协作 +- 需要完整工程化的项目 + +#### 8. Portfolio Starter Kit + +个人作品集模板。 + +``` +技术栈: +- Next.js +- TypeScript +- Tailwind CSS +- MDX + +功能特性: +- Markdown 管理内容 +- 响应式设计 +- 深色模式 +- SEO 优化 +- 项目展示 +``` + +#### 9. Express on Bun / Hono on Bun + +后端服务模板,使用 Bun 运行时。 + +``` +Express on Bun: +- Express.js +- Bun 运行时 +- TypeScript + +Hono on Bun: +- Hono 框架 +- Bun 运行时 +- TypeScript +- Edge-ready +``` + +**使用方式**: +```bash +vercel init express-on-bun +vercel init hono-on-bun +``` + +#### 10. Nuxt AI Chatbot + +Nuxt.js 版本的 AI 聊天机器人。 + +``` +技术栈: +- Nuxt 3 +- Vercel AI SDK +- Nuxt MDC +- Tailwind CSS +``` + +### 使用模板的方式 + +#### 方式一:通过 Dashboard(推荐) + +``` +1. 访问 vercel.com/templates +2. 浏览或搜索模板 +3. 点击模板查看详情 +4. 点击 "Deploy" 按钮 +5. 系统自动 Fork 到你的 GitHub +6. 自动部署到 Vercel +``` + +#### 方式二:通过 CLI + +```bash +# 列出可用模板 +vercel init + +# 初始化特定模板 +vercel init nextjs-boilerplate +vercel init ai-chatbot +vercel init nextjs-commerce + +# 查看模板帮助 +vercel init --help +``` + +#### 方式三:克隆 GitHub 仓库 + +```bash +# 直接克隆模板仓库 +git clone https://github.com/vercel/next.js/tree/canary/packages/create-next-app + +# 或从模板创建新仓库 +# GitHub → Use this template → Create a new repository +``` + +### 模板筛选 + +在模板市场可以使用多种条件筛选: + +``` +按用途筛选: +- AI +- Starter +- Ecommerce +- SaaS +- Blog +- Portfolio +- CMS +- Backend +- ... + +按框架筛选: +- Next.js +- Nuxt +- SvelteKit +- Astro +- Remix +- Vue +- React +- ... + +按数据库筛选: +- PostgreSQL +- MongoDB +- MySQL +- Redis +- PlanetScale +- Supabase +- Neon +- ... + +按 CMS 筛选: +- Contentful +- Sanity +- Prismic +- Strapi +- WordPress +- ... +``` + +### 创建自定义模板 + +你可以将自己的项目变成模板供他人使用: + +1. **创建示例仓库** + ```bash + # 项目结构清晰 + # 包含 README.md + # 配置好所有必要文件 + ``` + +2. **添加模板元数据** + ```json + // vercel.json + { + "name": "My Template", + "description": "A custom template", + "public": true + } + ``` + +3. **提交到 Vercel 模板库** + - 发送 PR 到 [vercel/templates](https://github.com/vercel/templates) + - 或在 Vercel Dashboard 标记项目为模板 + +### 模板最佳实践 + +#### 选择模板时的考虑 + +``` +1. 技术栈匹配 + - 团队熟悉的框架 + - 项目需求的技术支持 + +2. 功能完整性 + - 是否包含所需功能 + - 是否需要大量修改 + +3. 维护状态 + - 最近更新时间 + - 社区活跃度 + - Issue 处理情况 + +4. 文档质量 + - README 是否清晰 + - 配置说明是否完整 +``` + +#### 使用模板后的步骤 + +``` +1. 阅读模板文档 + - README.md + - 配置要求 + - 环境变量 + +2. 安装依赖 + npm install + # 或 + pnpm install + +3. 配置环境变量 + cp .env.example .env.local + # 编辑 .env.local 填入实际值 + +4. 本地开发 + npm run dev + +5. 自定义修改 + - 修改品牌信息 + - 调整样式 + - 添加功能 + +6. 部署上线 + git push + # 或 + vercel --prod +``` + +### 官方模板仓库 + +Vercel 官方维护的模板仓库: + +| 仓库 | 说明 | +|------|------| +| [vercel/next.js](https://github.com/vercel/next.js) | Next.js 官方示例 | +| [vercel/templates](https://github.com/vercel/templates) | Vercel 模板集合 | +| [vercel/examples](https://github.com/vercel/examples) | 示例项目集合 | + +### 模板 vs 从零开始 + +| 对比项 | 使用模板 | 从零开始 | +|--------|----------|----------| +| 启动速度 | ⚡ 分钟级 | 🐢 小时级 | +| 最佳实践 | ✅ 已内置 | ❌ 需自行配置 | +| 学习成本 | 低 | 高 | +| 自由度 | 中等 | 完全自由 | +| 代码质量 | 取决于模板 | 取决于自己 | + +**建议**: +- 快速原型 / 学习 / 中小型项目 → 使用模板 +- 特殊需求 / 大型定制项目 → 从零开始 + +--- + +## 四、项目与部署 + +### 项目概念 + +项目是部署到 Vercel 的应用程序,来自单个 Git 仓库。每个项目可以有多个部署: +- 一个生产部署 +- 多个预生产部署 + +### 创建项目 + +1. **通过 Dashboard**:点击 "New Project" 按钮 +2. **通过 CLI**:`vercel project add` +3. **通过 API**:POST 请求到 `/v9/projects` + +### 部署方法 + +#### 1. Git 部署(推荐) + +```bash +# 连接 Git 仓库后,每次推送自动部署 +git push origin main +``` + +#### 2. CLI 部署 + +```bash +# 预览部署 +vercel + +# 生产部署 +vercel --prod +``` + +#### 3. Deploy Hooks + +用于触发部署的唯一 URL: + +```bash +# 创建 Deploy Hook 后 +curl -X POST https://api.vercel.com/v1/integrations/deploy/xxx +``` + +#### 4. REST API 部署 + +```typescript +const response = await fetch('https://api.vercel.com/v13/deployments', { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + name: 'my-project', + files: [...], + projectSettings: {...} + }) +}); +``` + +### 部署环境 + +| 环境 | 说明 | URL 格式 | +|------|------|----------| +| **Local** | 本地开发 | `localhost:3000` | +| **Preview** | 预览环境 | `project-branch-hash.vercel.app` | +| **Production** | 生产环境 | `your-domain.com` | + +### 管理部署 + +```bash +# 查看部署列表 +vercel list + +# 查看部署详情 +vercel inspect [deployment-url] + +# 重新部署 +vercel redeploy [deployment-id] + +# 回滚生产部署 +vercel rollback [deployment-id] + +# 删除部署 +vercel remove [deployment-url] +``` + +--- + +## 五、Git 集成 + +### 支持的 Git 提供商 + +- GitHub(免费/团队/企业云) +- GitLab(免费/Premium/Ultimate/企业) +- Bitbucket(免费/标准/高级) +- Azure DevOps Pipelines + +### 连接 Git 仓库 + +1. 在 Dashboard 点击 "New Project" +2. 选择 Git 仓库 +3. 配置项目设置 +4. 点击 "Deploy" + +### 生产分支 + +默认规则(优先级从高到低): +1. `main` 分支 +2. `master` 分支 +3. Git 仓库的默认分支 + +**自定义生产分支**: +1. 进入项目 Settings → Environments +2. 选择 Production 环境 +3. 修改 Branch Tracking + +### 预览分支 + +所有非生产分支自动创建预览部署: + +``` +main (生产) → https://your-domain.com +feature/a (预览) → https://project-feature-a-hash.vercel.app +develop (预览) → https://project-develop-hash.vercel.app +``` + +### 私有仓库部署 + +**Pro 团队**: +- 提交作者必须是团队成员 +- 非成员提交需要申请加入 + +**Hobby 团队**: +- 无法从组织私有仓库部署 +- 需要升级到 Pro 或公开仓库 + +--- + +## 六、Vercel Functions + +### 概述 + +Vercel Functions 允许运行服务器端代码,无需管理服务器。自动扩展、处理 API 和数据库连接。 + +### 创建函数 + +#### 标准方式(推荐) + +```typescript +// api/hello.ts +export default { + fetch(request: Request) { + return new Response('Hello from Vercel!'); + }, +}; +``` + +#### HTTP 方法 + +```typescript +// api/user.ts +export function GET(request: Request) { + return new Response(JSON.stringify({ name: 'User' }), { + headers: { 'Content-Type': 'application/json' } + }); +} + +export function POST(request: Request) { + return new Response('Created', { status: 201 }); +} +``` + +#### Next.js App Router + +```typescript +// app/api/hello/route.ts +export function GET(request: Request) { + return new Response('Hello from Vercel!'); +} +``` + +### 函数配置 + +```typescript +// api/configured.ts +export const config = { + runtime: 'nodejs', // 'nodejs' | 'edge' | 'python' | 'go' | 'ruby' + regions: ['iad1', 'sfo1'], // 部署区域 + maxDuration: 60, // 最大执行时间(秒) + memory: 1024, // 内存(MB) +}; + +export default function handler(request: Request) { + return new Response('Configured function'); +} +``` + +### 运行时支持 + +| 运行时 | 说明 | +|--------|------| +| **Node.js** | 默认运行时,完整 Node.js API | +| **Edge** | 全球边缘执行,低延迟 | +| **Python** | 支持 Python 函数 | +| **Go** | 支持 Go 函数 | +| **Ruby** | 支持 Ruby 函数 | +| **Bun** | Bun 运行时 | + +### 流式响应 + +```typescript +// api/stream.ts +import { streamText } from 'ai'; + +export default async function handler(request: Request) { + const result = await streamText({ + model: 'openai/gpt-4', + prompt: 'Hello', + }); + + return result.toDataStreamResponse(); +} +``` + +### 函数限制 + +| 限制 | Hobby | Pro | Enterprise | +|------|-------|-----|------------| +| 最大执行时间 | 10秒 | 60秒 | 900秒 | +| 内存 | 1024 MB | 3008 MB | 自定义 | +| 包大小 | 50 MB | 50 MB | 自定义 | + +### 定价 + +基于以下因素计费: +- **Active CPU**:CPU 使用时间 +- **Provisioned Memory**:内存分配 +- **Invocations**:调用次数 + +--- + +## 七、路由中间件 + +### 概述 + +路由中间件在请求被处理之前执行代码,用于: +- 重定向 +- 重写 URL +- 添加头部 +- 认证检查 +- A/B 测试 + +### 创建中间件 + +```typescript +// middleware.ts +export default function middleware(request: Request) { + const url = new URL(request.url); + + // 重定向旧路径 + if (url.pathname === '/old-page') { + return new Response(null, { + status: 302, + headers: { Location: '/new-page' }, + }); + } + + // 添加自定义头部 + const response = new Response('Hello'); + response.headers.set('X-Custom-Header', 'value'); + return response; +} +``` + +### 配置匹配路径 + +```typescript +// middleware.ts +import { NextResponse } from 'next/server'; +import type { NextRequest } from 'next/server'; + +export function middleware(request: NextRequest) { + return NextResponse.next(); +} + +// 配置匹配规则 +export const config = { + matcher: [ + '/api/:path*', // 匹配 /api/* 路径 + '/admin/:path*', // 匹配 /admin/* 路径 + ], + runtime: 'edge', // 或 'nodejs' +}; +``` + +### 使用 Edge Config + +```typescript +// middleware.ts +import { get } from '@vercel/edge-config'; + +export default async function middleware(request: Request) { + // 从 Edge Config 读取功能开关 + const featureEnabled = await get('feature-flag'); + + if (featureEnabled) { + return new Response('Feature enabled'); + } + + return new Response('Feature disabled'); +} +``` + +### 请求限制 + +| 限制 | 值 | +|------|-----| +| 最大 URL 长度 | 14 KB | +| 最大请求体长度 | 4 MB | +| 最大请求头数量 | 64 | +| 最大请求头长度 | 16 KB | + +--- + +## 八、环境变量 + +### 创建环境变量 + +**通过 Dashboard**: +1. 进入项目 Settings → Environment Variables +2. 添加键值对 +3. 选择适用的环境 + +**通过 CLI**: + +```bash +# 添加环境变量 +vercel env add DATABASE_URL + +# 拉取环境变量到本地 +vercel env pull .env.local + +# 列出所有环境变量 +vercel env ls + +# 删除环境变量 +vercel env rm DATABASE_URL production +``` + +### 环境类型 + +| 环境 | 说明 | +|------|------| +| **Production** | 生产部署时使用 | +| **Preview** | 预览部署时使用 | +| **Development** | 本地开发时使用(`vercel dev`) | + +### 代码中使用 + +```typescript +// 服务端 +const dbUrl = process.env.DATABASE_URL; + +// 客户端(需要 NEXT_PUBLIC_ 前缀) +const apiKey = process.env.NEXT_PUBLIC_API_KEY; +``` + +### 预览分支特定变量 + +可以为特定分支设置不同的变量值: + +```bash +# 为 feature 分支设置特定变量 +vercel env add API_URL preview --branch feature +``` + +### 系统环境变量 + +Vercel 自动注入以下变量: + +```bash +VERCEL=1 +VERCEL_ENV=production|preview|development +VERCEL_URL=project-hash.vercel.app +VERCEL_REGION=iad1 +VERCEL_GIT_PROVIDER=github +VERCEL_GIT_REPO_SLUG=my-repo +VERCEL_GIT_COMMIT_SHA=abc123 +VERCEL_GIT_COMMIT_MESSAGE="Initial commit" +``` + +### 限制 + +- 每个环境最多 **1000** 个变量 +- 总大小限制 **64 KB** +- Edge Function 单个变量限制 **5 KB** + +--- + +## 九、域名管理 + +### 添加域名 + +**通过 Dashboard**: +1. 进入项目 Settings → Domains +2. 输入域名 +3. 选择验证方式 + +**通过 CLI**: + +```bash +# 添加域名 +vercel domains add example.com + +# 验证域名 +vercel domains inspect example.com +``` + +### DNS 配置 + +#### A 记录 + +``` +类型: A +名称: @ +值: 76.76.21.21 +``` + +#### CNAME 记录 + +``` +类型: CNAME +名称: www +值: cname.vercel-dns.com +``` + +### 域名类型 + +| 类型 | 说明 | 示例 | +|------|------|------| +| **根域名** | 主域名 | `example.com` | +| **子域名** | 域名的子集 | `blog.example.com` | +| **通配符** | 匹配所有子域名 | `*.example.com` | + +### 分配域名到分支 + +```bash +# 将域名分配给特定分支 +# staging.example.com → staging 分支 +``` + +1. 进入 Domains 设置 +2. 添加域名 +3. 选择 Git Branch + +### SSL 证书 + +Vercel 自动为所有域名生成 SSL 证书: +- 自动续期 +- 免费 +- 支持 Let's Encrypt + +```bash +# 查看证书 +vercel certs ls + +# 手动颁发证书 +vercel certs issue example.com +``` + +--- + +## 十、存储服务 + +### 存储产品概览 + +| 产品 | 用途 | 读取速度 | 写入速度 | +|------|------|----------|----------| +| **Vercel Blob** | 大文件存储 | 快 | 毫秒级 | +| **Edge Config** | 全局配置 | 超快(<1ms) | 秒级 | +| **Marketplace** | 数据库(Postgres/Redis/Mongo) | 取决于提供商 | 取决于提供商 | + +### Vercel Blob + +用于存储图片、视频等大文件。 + +```typescript +import { put } from '@vercel/blob'; + +// 上传文件 +const blob = await put('profile.jpg', file, { + access: 'public', +}); + +console.log(blob.url); // https://xxx.public.blob.vercel-storage.com/profile.jpg +``` + +```typescript +import { list, del } from '@vercel/blob'; + +// 列出文件 +const { blobs } = await list(); + +// 删除文件 +await del(blob.url); +``` + +### Edge Config + +用于存储频繁读取、很少更改的数据。 + +```typescript +import { get } from '@vercel/edge-config'; + +// 读取值 +const featureFlag = await get('new-feature'); + +// 读取多个值 +const values = await get(['feature-a', 'feature-b']); +``` + +**使用场景**: +- 功能开关 +- A/B 测试配置 +- 关键重定向 +- IP 黑名单 + +### Marketplace 数据库 + +通过 Vercel Marketplace 直接配置数据库: + +| 类型 | 提供商 | +|------|--------| +| **PostgreSQL** | Neon, Supabase, PlanetScale | +| **Redis/KV** | Upstash, Redis | +| **NoSQL** | MongoDB, DynamoDB | +| **Vector** | Pinecone, Weaviate | + +--- + +## 十一、AI SDK + +### 概述 + +Vercel AI SDK 是构建 AI 应用的 TypeScript 工具包,支持: +- React / Next.js +- Vue / Nuxt +- Svelte / SvelteKit +- Node.js + +### 安装 + +```bash +# 安装 AI SDK +npm install ai + +# 安装模型提供商 +npm install @ai-sdk/openai +# 或 +npm install @ai-sdk/anthropic +``` + +### 文本生成 + +```typescript +import { generateText } from 'ai'; +import { openai } from '@ai-sdk/openai'; + +const { text } = await generateText({ + model: openai('gpt-4'), + prompt: '解释量子纠缠的概念。', +}); + +console.log(text); +``` + +### 流式文本生成 + +```typescript +import { streamText } from 'ai'; +import { openai } from '@ai-sdk/openai'; + +const result = await streamText({ + model: openai('gpt-4'), + prompt: '写一首关于春天的诗。', +}); + +// 流式输出 +for await (const textPart of result.textStream) { + process.stdout.write(textPart); +} +``` + +### 结构化输出 + +```typescript +import { generateObject } from 'ai'; +import { openai } from '@ai-sdk/openai'; +import { z } from 'zod'; + +const { object } = await generateObject({ + model: openai('gpt-4'), + schema: z.object({ + name: z.string(), + ingredients: z.array(z.string()), + steps: z.array(z.string()), + }), + prompt: '生成一个意大利面食谱。', +}); +``` + +### 工具调用 + +```typescript +import { generateText, tool } from 'ai'; +import { openai } from '@ai-sdk/openai'; +import { z } from 'zod'; + +const { text } = await generateText({ + model: openai('gpt-4'), + prompt: '旧金山今天的天气如何?', + tools: { + getWeather: tool({ + description: '获取指定位置的天气', + parameters: z.object({ + location: z.string().describe('位置'), + }), + execute: async ({ location }) => ({ + location, + temperature: 22, + condition: '晴天', + }), + }), + }, +}); +``` + +### 聊天界面(React) + +```tsx +'use client'; +import { useChat } from 'ai/react'; + +export default function Chat() { + const { messages, input, handleInputChange, handleSubmit } = useChat(); + + return ( +
+ {messages.map(m => ( +
+ {m.role}: {m.content} +
+ ))} +
+ +
+
+ ); +} +``` + +--- + +## 十二、分析与监控 + +### Web Analytics + +**功能**: +- 访客统计 +- 页面浏览量 +- 跳出率 +- 流量来源 +- 用户地理分布 + +**启用方式**: + +```typescript +// app/layout.tsx +import { Analytics } from '@vercel/analytics/react'; + +export default function RootLayout({ children }) { + return ( + + + {children} + + + + ); +``` + +**隐私特性**: +- 不使用 Cookie +- 匿名化数据 +- 不跟踪跨站用户 + +### Speed Insights + +监控 Core Web Vitals 性能指标。 + +```typescript +// app/layout.tsx +import { SpeedInsights } from '@vercel/speed-insights/next'; + +export default function RootLayout({ children }) { + return ( + + + {children} + + + + ); +} +``` + +### Observability + +**可观测性仪表板**提供: +- Vercel Functions 性能 +- 外部 API 调用 +- Edge Requests +- 中间件执行 +- ISR 缓存状态 + +**查看方式**: +1. 进入项目 Dashboard +2. 点击 Observability 标签 + +### 日志 + +**构建日志**:永久保存 + +**运行时日志**: +| 计划 | 保留时间 | +|------|----------| +| Hobby | 1 小时 | +| Pro | 1 天 | +| Enterprise | 3 天 | + +```bash +# 查看日志 +vercel logs [deployment-url] + +# 实时日志 +vercel logs [deployment-url] --follow +``` + +--- + +## 十三、安全功能 + +### 安全层 + +``` +┌─────────────────────────────────────────┐ +│ 用户请求 │ +└─────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────┐ +│ DDoS 防护(自动) │ +└─────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────┐ +│ WAF(可配置) │ +└─────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────┐ +│ Bot 管理 │ +└─────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────┐ +│ 部署保护 │ +└─────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────┐ +│ 应用 │ +└─────────────────────────────────────────┘ +``` + +### DDoS 防护 + +- 自动启用 +- 全平台防护 +- 无需配置 + +### Web Application Firewall (WAF) + +**功能**: +- 自定义规则 +- 托管规则集 +- 速率限制 +- AI Bot 过滤 + +**配置规则**: + +```json +// vercel.json +{ + "firewall": { + "rules": [ + { + "path": "/api/*", + "rateLimit": { + "requests": 100, + "window": "1m" + } + } + ] + } +} +``` + +### Bot 管理 + +- 自动识别恶意 Bot +- 可配置 Bot 白名单/黑名单 +- AI Bot 过滤规则 + +### 加密 + +- 所有部署自动 HTTPS +- 自动 SSL 证书 +- 端到端加密 + +--- + +## 十四、图片优化 + +### 概述 + +Vercel 自动优化图片: +- 自动格式转换(WebP、AVIF) +- 响应式尺寸 +- 智能压缩 +- CDN 缓存 + +### 使用方式 + +#### Next.js + +```tsx +import Image from 'next/image'; + +// 本地图片 +import hero from './hero.png'; + +export default function Page() { + return ( + Hero + ); +} +``` + +```tsx +// 远程图片 +export default function Page() { + return ( + Remote + ); +} +``` + +### 配置远程图片 + +```typescript +// next.config.ts +import type { NextConfig } from 'next'; + +const nextConfig: NextConfig = { + images: { + remotePatterns: [ + { + protocol: 'https', + hostname: 'example.com', + pathname: '/images/**', + }, + ], + }, +}; + +export default nextConfig; +``` + +### 图片 URL 格式 + +``` +/_next/image?url=/image.jpg&w=3840&q=75 + +参数: +- url: 图片地址 +- w: 宽度(像素) +- q: 质量(1-100) +``` + +### 缓存行为 + +| 状态 | 说明 | +|------|------| +| HIT | 从缓存返回 | +| MISS | 转换后缓存再返回 | +| STALE | 返回缓存同时后台更新 | + +### 最佳实践 + +- 使用响应式尺寸 +- 配置正确的 remotePatterns +- 对于小图标(<10KB)使用 `unoptimized` +- SVG 和 GIF 不需要优化 + +--- + +## 十五、定时任务 + +### 概述 + +Cron Jobs 用于定时执行任务,如: +- 数据备份 +- 发送通知 +- 更新订阅 + +### 配置方式 + +```json +// vercel.json +{ + "crons": [ + { + "path": "/api/cron/daily-report", + "schedule": "0 0 * * *" + }, + { + "path": "/api/cron/hourly-check", + "schedule": "0 * * * *" + } + ] +} +``` + +### Cron 表达式 + +``` +┌───────────── 分钟 (0-59) +│ ┌───────────── 小时 (0-23) +│ │ ┌───────────── 日期 (1-31) +│ │ │ ┌───────────── 月份 (1-12) +│ │ │ │ ┌───────────── 星期 (0-6, 0=周日) +│ │ │ │ │ +* * * * * + +示例: +0 0 * * * → 每天午夜 +0 */2 * * * → 每2小时 +30 9 * * 1-5 → 工作日早上9:30 +``` + +### 创建 Cron 端点 + +```typescript +// api/cron/daily.ts +export default async function handler(request: Request) { + // 验证请求来自 Vercel Cron + const authHeader = request.headers.get('authorization'); + if (authHeader !== `Bearer ${process.env.CRON_SECRET}`) { + return new Response('Unauthorized', { status: 401 }); + } + + // 执行任务 + await sendDailyReport(); + + return new Response('OK'); +} +``` + +### 限制 + +| 计划 | 每项目限制 | +|------|------------| +| Hobby | 2 个 Cron | +| Pro | 100 个 Cron | +| Enterprise | 100 个 Cron | + +--- + +## 十六、CLI 命令行工具 + +### 常用命令 + +#### 部署相关 + +```bash +# 部署 +vercel # 预览部署 +vercel --prod # 生产部署 + +# 查看部署列表 +vercel list +vercel list [project-name] + +# 查看部署详情 +vercel inspect [deployment-url] + +# 重新部署 +vercel redeploy [deployment-id] + +# 回滚 +vercel rollback [deployment-id] + +# 删除部署 +vercel remove [deployment-url] +``` + +#### 项目管理 + +```bash +# 列出项目 +vercel project ls + +# 创建项目 +vercel project add + +# 查看项目详情 +vercel project inspect [project-name] + +# 删除项目 +vercel project rm [project-name] + +# 链接本地目录到项目 +vercel link +``` + +#### 环境变量 + +```bash +# 列出环境变量 +vercel env ls + +# 添加环境变量 +vercel env add [name] + +# 更新环境变量 +vercel env update [name] [environment] + +# 删除环境变量 +vercel env rm [name] [environment] + +# 拉取环境变量到本地 +vercel env pull +``` + +#### 域名管理 + +```bash +# 列出域名 +vercel domains ls + +# 添加域名 +vercel domains add [domain] + +# 查看域名详情 +vercel domains inspect [domain] + +# 购买域名 +vercel domains buy [domain] + +# 删除域名 +vercel domains rm [domain] +``` + +#### DNS 管理 + +```bash +# 列出 DNS 记录 +vercel dns ls [domain] + +# 添加 DNS 记录 +vercel dns add [domain] [name] [type] [value] + +# 删除 DNS 记录 +vercel dns rm [record-id] +``` + +#### 其他命令 + +```bash +# 本地开发 +vercel dev +vercel dev --port 3000 + +# 查看日志 +vercel logs [deployment-url] +vercel logs [deployment-url] --follow + +# 登录 +vercel login +vercel login [email] + +# 登出 +vercel logout + +# 查看当前用户 +vercel whoami + +# 切换团队 +vercel switch [team-name] + +# 打开项目 Dashboard +vercel open +``` + +### 全局选项 + +```bash +--token # 使用令牌认证 +--scope # 指定团队范围 +--yes # 跳过确认 +--version # 显示版本 +--help # 显示帮助 +``` + +--- + +## 十七、REST API + +### 认证 + +```bash +# 创建访问令牌 +# Dashboard → Settings → Tokens + +# 使用令牌 +curl -H "Authorization: Bearer YOUR_TOKEN" https://api.vercel.com/v9/projects +``` + +### 常用端点 + +#### 项目 + +```bash +# 列出项目 +GET /v9/projects + +# 获取项目 +GET /v9/projects/{id} + +# 创建项目 +POST /v9/projects + +# 更新项目 +PATCH /v9/projects/{id} + +# 删除项目 +DELETE /v9/projects/{id} +``` + +#### 部署 + +```bash +# 列出部署 +GET /v13/deployments + +# 获取部署 +GET /v13/deployments/{id} + +# 创建部署 +POST /v13/deployments + +# 删除部署 +DELETE /v13/deployments/{id} +``` + +#### 环境变量 + +```bash +# 列出环境变量 +GET /v9/projects/{id}/env + +# 创建环境变量 +POST /v9/projects/{id}/env + +# 更新环境变量 +PATCH /v9/projects/{id}/env/{key} + +# 删除环境变量 +DELETE /v9/projects/{id}/env/{key} +``` + +#### 域名 + +```bash +# 列出域名 +GET /v6/domains + +# 添加域名 +POST /v6/domains + +# 验证域名 +POST /v6/domains/{domain}/verify + +# 删除域名 +DELETE /v6/domains/{domain} +``` + +### 示例代码 + +```typescript +// 创建部署 +const response = await fetch('https://api.vercel.com/v13/deployments', { + method: 'POST', + headers: { + 'Authorization': `Bearer ${process.env.VERCEL_TOKEN}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + name: 'my-project', + files: [ + { + file: 'index.html', + data: 'Hello', + }, + ], + projectSettings: { + framework: null, + }, + }), +}); + +const deployment = await response.json(); +console.log(deployment.url); +``` + +--- + +## 十八、框架支持 + +### 支持的框架 + +| 框架 | 静态资源 | SSR | ISR | 中间件 | 图片优化 | +|------|:--------:|:---:|:---:|:------:|:--------:| +| **Next.js** | ✓ | ✓ | ✓ | ✓ | ✓ | +| **SvelteKit** | ✓ | ✓ | ✓ | ✓ | ✓ | +| **Nuxt** | ✓ | ✓ | ✓ | ✓ | ✓ | +| **Astro** | ✓ | ✓ | ✓ | ✓ | ✓ | +| **Remix** | ✓ | ✓ | ✗ | ✓ | ✗ | +| **Vite** | ✓ | ✗ | ✗ | ✓ | ✗ | +| **TanStack** | ✓ | ✓ | ✗ | ✓ | ✗ | +| **Create React App** | ✓ | ✗ | ✗ | ✓ | ✗ | + +### 功能支持矩阵 + +**✓ 支持 | ✗ 不支持 | N/A 不适用** + +### Build Output API + +用于自定义框架集成: + +``` +.vercel/output/ +├── config.json +├── static/ +│ └── index.html +├── functions/ +│ └── api/ +│ └── hello.func/ +│ └── index.js +└── prerendered-files/ + └── blog/ + └── post.html +``` + +--- + +## 十九、部署保护 + +### 保护方法 + +| 方法 | 说明 | 计划可用性 | +|------|------|------------| +| **Vercel Authentication** | 限制 Vercel 用户访问 | 所有计划 | +| **Password Protection** | 密码保护 | Pro + $150/月 或 Enterprise | +| **Trusted IPs** | IP 白名单 | Enterprise | + +### 保护范围 + +| 范围 | 说明 | 保护内容 | +|------|------|----------| +| **Standard Protection** | 标准保护 | 所有部署(生产域名除外) | +| **All Deployments** | 全部保护 | 所有 URL,包括生产域名 | +| **Only Production** | 仅生产 | 仅生产部署 | +| **Only Preview** | 仅预览 | 仅预览部署 | + +### 配置保护 + +1. 进入项目 Settings → Deployment Protection +2. 选择保护方法 +3. 选择保护范围 +4. 保存设置 + +### 绕过保护 + +**Protection Bypass for Automation**: + +```bash +# 添加绕过密钥 +# Settings → Deployment Protection → Bypass for Automation + +# 使用密钥访问 +curl -H "x-vercel-protection-bypass: YOUR_SECRET" https://preview.example.com +``` + +--- + +## 二十、计费与限制 + +### 计划限制 + +#### 通用限制 + +| 限制 | Hobby | Pro | Enterprise | +|------|-------|-----|------------| +| 项目数量 | 200 | 无限 | 无限 | +| 每日部署次数 | 100 | 6000 | 自定义 | +| 每次部署文件数 | 15000 | 15000 | 自定义 | +| 并发构建 | 1 | 12 | 自定义 | +| 构建时间限制 | 45分钟 | 45分钟 | 45分钟 | +| 磁盘空间 | 23 GB | 23-64 GB | 23-64 GB | +| Cron Jobs/项目 | 2 | 100 | 100 | + +#### 包含资源(Pro) + +| 资源 | 包含量 | +|------|--------| +| Fast Data Transfer | 1 TB | +| Function Invocations | 1,000,000 | +| Edge Requests | 10,000,000 | +| Edge Config Reads | 1,000,000 | +| Image Optimizations | 10,000/月 | +| Blob Storage | 5 GB | +| Web Analytics Events | 100,000 | + +### 按需付费(Pro) + +| 资源 | 价格 | +|------|------| +| Fast Data Transfer | 按区域定价 | +| Function Invocations | $0.60/百万次 | +| Edge Requests | 按区域定价 | +| Image Cache Reads | 按区域定价 | +| Blob Simple Operations | 按区域定价 | +| Monitoring Events | $1.20/百万次 | + +### 环境变量限制 + +- 每个环境最多 1000 个变量 +- 总大小限制 64 KB +- Edge Function 单变量限制 5 KB + +### 域名限制 + +| 计划 | 域名/项目 | +|------|-----------| +| Hobby | 50 | +| Pro | 无限* | +| Enterprise | 无限* | + +*软限制:Pro 100,000,Enterprise 1,000,000 + +### 日志保留 + +| 计划 | 构建日志 | 运行时日志 | +|------|----------|------------| +| Hobby | 永久 | 1 小时 | +| Pro | 永久 | 1 天 | +| Enterprise | 永久 | 3 天 | + +--- + +## 二十一、最佳实践 + +### 性能优化 + +#### 1. 使用 ISR + +```typescript +// app/blog/[slug]/page.tsx +export const revalidate = 60; // 每60秒重新生成 + +export default async function BlogPost({ params }) { + const post = await fetchBlogPost(params.slug); + return
{post.content}
; +} +``` + +#### 2. 图片优化 + +```tsx +// 使用响应式图片 +Hero +``` + +#### 3. 字体优化 + +```tsx +// 使用 next/font +import { Inter } from 'next/font/google'; + +const inter = Inter({ subsets: ['latin'] }); + +export default function RootLayout({ children }) { + return ( + + {children} + + ); +} +``` + +### SEO 优化 + +#### 1. Metadata API + +```typescript +// app/layout.tsx +export const metadata = { + title: { + default: 'My Website', + template: '%s | My Website', + }, + description: 'Description of my website', + openGraph: { + title: 'My Website', + description: 'Description', + images: ['/og-image.jpg'], + }, +}; +``` + +#### 2. Sitemap + +```typescript +// app/sitemap.ts +import { MetadataRoute from 'next'; + +export default function sitemap(): MetadataRoute.Sitemap { + return [ + { + url: 'https://example.com', + lastModified: new Date(), + }, + { + url: 'https://example.com/about', + lastModified: new Date(), + }, + ]; +} +``` + +### 开发工作流 + +``` +1. 本地开发 + npm run dev + +2. 创建分支 + git checkout -b feature/new-feature + +3. 提交并推送 + git add . && git commit -m "Add new feature" + git push origin feature/new-feature + +4. Vercel 自动创建预览部署 + +5. 合并到 main 后自动生产部署 +``` + +### 监控与调试 + +```bash +# 查看生产日志 +vercel logs your-domain.com + +# 查看函数日志 +vercel logs your-domain.com --output raw | grep "api/" + +# 使用 Speed Insights +// 添加组件到 layout.tsx +import { SpeedInsights } from '@vercel/speed-insights/next'; +``` + +--- + +**文档结束** diff --git a/docs/vercel/VERCEL_MARKETPLACE.md b/docs/vercel/VERCEL_MARKETPLACE.md new file mode 100644 index 0000000..4f4f7e2 --- /dev/null +++ b/docs/vercel/VERCEL_MARKETPLACE.md @@ -0,0 +1,478 @@ +# Vercel Marketplace 集成文档 + +> 更新时间:2026年3月12日 +> 数据来源:https://vercel.com/marketplace + +--- + +## 一、定价模式说明 + +| 标记 | 含义 | 说明 | +|------|------|------| +| 🆓 | 免费开源 | 完全免费,开源项目 | +| 🆓Free | 免费套餐 | 有免费额度,付费升级 | +| 💰 | 付费订阅 | 无免费层或限制大 | +| 🏢 | 企业定价 | 需联系销售 | +| ❓ | 需查询 | 定价不明确,需查看官网 | + +--- + +## 二、分类列表(共22个) + +| 分类 | URL | +|------|-----| +| AI Agents & Services | /marketplace/category/agents | +| AI | /marketplace/category/ai | +| Analytics | /marketplace/category/analytics | +| Authentication | /marketplace/category/authentication | +| CMS | /marketplace/category/cms | +| Commerce | /marketplace/category/commerce | +| Database | /marketplace/category/database | +| DevTools | /marketplace/category/dev-tools | +| Experimentation | /marketplace/category/experimentation | +| Flags | /marketplace/category/flags | +| Logging | /marketplace/category/logging | +| Messaging | /marketplace/category/messaging | +| Monitoring | /marketplace/category/monitoring | +| Observability | /marketplace/category/observability | +| Payments | /marketplace/category/payments | +| Productivity | /marketplace/category/productivity | +| Searching | /marketplace/category/searching | +| Security | /marketplace/category/security | +| Storage | /marketplace/category/storage | +| Testing | /marketplace/category/testing | +| Video | /marketplace/category/video | +| Workflow | /marketplace/category/workflow | + +--- + +## 三、推荐模板(Next.js专用) + +| 模板名称 | 定价 | 描述 | URL | +|----------|------|------|-----| +| Next.js AI Chatbot | 🆓 | 全功能的可定制 Next.js AI 聊天机器人 | https://vercel.com/templates/ai/nextjs-ai-chatbot | +| Statsig + Flags SDK | 🆓Free | 使用 Statsig 和 Flags SDK 在静态页面上运行实验 | https://vercel.com/templates/edge-config/statsig-experimentation-with-flags-sdk | +| Next.js + Mux Video | 🆓Free | 使用 Mux 为 Next.js 应用添加视频 | https://vercel.com/templates/next.js/next-video-starter | + +--- + +## 四、Native Integrations(原生集成) + +### 4.1 Storage(存储)- 12个 + +| 名称 | 定价 | 描述 | URL | +|------|------|------|-----| +| **Neon** | 🆓Free | Postgres无服务器平台,0.5GB免费 | /marketplace/neon | +| **AWS** | 🏢 | 亚马逊云服务 | /marketplace/aws | +| **Upstash** | 🆓Free | 无服务器Redis/Vector,10K命令/天免费 | /marketplace/upstash | +| **Supabase** | 🆓 | 开源Postgres开发平台 | /marketplace/supabase | +| **Redis** | 🆓Free | 无服务器Redis,30MB免费 | /marketplace/redis | +| **Nile** | 🆓Free | PostgreSQL for B2B | /marketplace/nile | +| **MotherDuck** | ❓ | 分析的无服务器后端 | /marketplace/motherduck | +| **Convex** | 🆓 | 开源后端平台 | /marketplace/convex | +| **Prisma** | 🆓 | 开源ORM/Postgres | /marketplace/prisma | +| **Turso Cloud** | 🆓Free | SQLite,500MB免费 | /marketplace/tursocloud | +| **MongoDB Atlas** | 🏢 | MongoDB官方NoSQL数据库 | /marketplace/mongodbatlas | +| **Mixedbread** | ❓ | 多模态搜索API | /marketplace/mixedbread | + +### 4.2 AI(人工智能)- 4个 + +| 名称 | 定价 | 描述 | URL | +|------|------|------|-----| +| **xAI (Grok)** | 💰 | xAI的Grok AI | /marketplace/xai | +| **Groq** | ❓ | AI快速推理服务 | /marketplace/groq | +| **fal** | ❓ | 生成式媒体平台 | /marketplace/fal | +| **Deep Infra** | ❓ | Deep Infra AI集成 | /marketplace/deepinfra | + +### 4.3 Observability(可观测性)- 5个 + +| 名称 | 定价 | 描述 | URL | +|------|------|------|-----| +| **Sentry** | 🆓Free | 错误监控,5K错误/月免费 | /marketplace/sentry | +| **Dash0** | ❓ | 日志、追踪、指标 | /marketplace/dash0 | +| **Braintrust** | ❓ | AI评估监控平台 | /marketplace/braintrust | +| **Kubiks** | ❓ | 日志追踪、仪表盘、警报 | /marketplace/kubiks | +| **Rollbar** | 🆓Free | 实时崩溃报告,$0/月 | /marketplace/rollbar | + +### 4.4 Monitoring(监控)- 1个 + +| 名称 | 定价 | 描述 | URL | +|------|------|------|-----| +| **Checkly** | 💰 | Playwright监控,$29/月起 | /marketplace/checkly | + +### 4.5 Web Automation(网页自动化)- 2个 + +| 名称 | 定价 | 描述 | URL | +|------|------|------|-----| +| **Browserbase** | ❓ | 无服务器浏览器自动化 | /marketplace/browserbase | +| **Kernel** | ❓ | AI代理互联网访问infra | /marketplace/kernel | + +### 4.6 Code Review(代码审查)- 3个 + +| 名称 | 定价 | 描述 | URL | +|------|------|------|-----| +| **cubic** | ❓ | 复杂代码库AI审查 | /marketplace/cubic | +| **CodeRabbit** | 🆓Free | 代码审查,500行/月免费,$9/月起 | /marketplace/coderabbit | +| **Sourcery** | 🆓Free | AI代码审查,免费版无限 | /marketplace/sourcery | + +### 4.7 Support Agent(客服代理)- 2个 + +| 名称 | 定价 | 描述 | URL | +|------|------|------|-----| +| **AssistLoop** | ❓ | AI客服代理 | /marketplace/assistloop | +| **Chatbase** | ❓ | 免费AI客服代理 | /marketplace/chatbase | + +### 4.8 Searching(搜索)- 1个 + +| 名称 | 定价 | 描述 | URL | +|------|------|------|-----| +| **Parallel Web Systems** | ❓ | AI网页搜索 | /marketplace/parallel | + +### 4.9 Code Security(代码安全)- 1个 + +| 名称 | 定价 | 描述 | URL | +|------|------|------|-----| +| **Corridor** | ❓ | AI编码安全审查 | /marketplace/corridor | + +### 4.10 Payments(支付)- 1个 + +| 名称 | 定价 | 描述 | URL | +|------|------|------|-----| +| **Stripe** | 🆓Free | 支付平台,交易手续费 | /marketplace/stripe | + +### 4.11 Testing(测试)- 1个 + +| 名称 | 定价 | 描述 | URL | +|------|------|------|-----| +| **Autonoma AI** | ❓ | AI端到端测试 | /marketplace/autonoma-ai | + +### 4.12 Analytics(分析)- 3个 + +| 名称 | 定价 | 描述 | URL | +|------|------|------|-----| +| **Statsig** | ❓ | 特性标志、实验、分析 | /marketplace/statsig | +| **Hypertune** | ❓ | 类型安全特性标志 | /marketplace/hypertune | +| **PostHog** | 🆓Free | 分析、会话回放,1M事件/月免费 | /marketplace/posthog | + +### 4.13 Authentication(认证)- 2个 + +| 名称 | 定价 | 描述 | URL | +|------|------|------|-----| +| **Descope** | 🆓Free | 拖放式认证 | /marketplace/descope | +| **Clerk** | 🆓Free | 认证服务,10K月活免费 | /marketplace/clerk | + +### 4.14 DevTools(开发工具)- 1个 + +| 名称 | 定价 | 描述 | URL | +|------|------|------|-----| +| **Inngest** | 🆓 | 开源AI工作流平台 | /marketplace/inngest | + +### 4.15 Experimentation(实验)- 1个 + +| 名称 | 定价 | 描述 | URL | +|------|------|------|-----| +| **GrowthBook** | 🆓 | 开源特性标志实验 | /marketplace/growthbook | + +### 4.16 Video(视频)- 1个 + +| 名称 | 定价 | 描述 | URL | +|------|------|------|-----| +| **Mux** | 🆓Free | 视频服务,1K分钟免费 | /marketplace/mux | + +### 4.17 CMS(内容管理)- 1个 + +| 名称 | 定价 | 描述 | URL | +|------|------|------|-----| +| **Sanity** | 🆓Free | 内容平台,10K文档免费 | /marketplace/sanity | + +--- + +## 五、External Integrations(外部集成) + +### 5.1 AI(人工智能)- 6个 + +| 名称 | 定价 | 描述 | URL | +|------|------|------|-----| +| **Deep Infra** | ❓ | AI集成 | /marketplace/deepinfra | +| **ElevenLabs** | 💰 | AI文本转语音 | /marketplace/elevenlabs | +| **LMNT** | ❓ | 文本转语音&语音克隆 | /marketplace/lmnt | +| **Perplexity API** | ❓ | Perplexity LLM | /marketplace/pplx-api | +| **Replicate** | 🆓Free | AI模型运行,有免费额度 | /marketplace/replicate | +| **Together AI** | 💰 | 生成式AI云平台 | /marketplace/together-ai | + +### 5.2 Analytics(分析)- 7个 + +| 名称 | 定价 | 描述 | URL | +|------|------|------|-----| +| **Hypertune** | ❓ | 特性标志 | /marketplace/hypertune | +| **LaunchDarkly** | 💰 | 特性标志,$30/月起 | /marketplace/launchdarkly | +| **Statsig** | ❓ | 特性标志实验 | /marketplace/statsig | +| **Vercel Web Analytics** | 💰 | 第一方分析,$20/月 | /marketplace/vercel-analytics | +| **DevCycle** | ❓ | 特性标志 | /marketplace/devcycle | +| **Kameleoon** | ❓ | A/B测试 | /marketplace/kameleoon | +| **Split** | 💰 | 特性标志,$49/月起 | /marketplace/split | + +### 5.3 Authentication(认证)- 2个 + +| 名称 | 定价 | 描述 | URL | +|------|------|------|-----| +| **Auth0** | 🆓Free | 认证服务,7K月活免费 | /marketplace/auth0 | +| **Clerk** | 🆓Free | 认证服务,10K月活免费 | /marketplace/clerk | + +> ⚠️ **页面显示错误**:Neon 和 Supabase 在页面上被错误地归类为 Authentication,它们实际是 Storage 服务(提供 Postgres 数据库)。 + +### 5.4 CMS(内容管理)- 10个 + +| 名称 | 定价 | 描述 | URL | +|------|------|------|-----| +| **Agility CMS** | ❓ | Headless CMS | /marketplace/agility-cms | +| **Builder.io** | 🏢 | 可视化CMS,企业定价 | https://vercel.com/new/templates?search=builder&cms=builder.io | +| **ButterCMS** | ❓ | Headless CMS | /marketplace/buttercms | +| **Contentful** | 🆓Free | 内容平台,25K记录免费 | /marketplace/contentful | +| **Contentstack** | 🏢 | 全渠道CMS,企业 | https://vercel.com/guides/integrate-vercel-and-contentstack | +| **DatoCMS** | ❓ | Headless CMS | /marketplace/datocms | +| **Formspree** | ❓ | 表单后端 | /marketplace/formspree | +| **Makeswift** | ❓ | Next.js可视化构建 | /marketplace/makeswift | +| **Sanity** | 🆓Free | 内容平台,10K文档免费 | /marketplace/sanity | +| **Sitecore XM Cloud** | 🏢 | SaaS CMS | https://vercel.com/docs/integrations/sitecore | + +### 5.5 Commerce(商务)- 7个 + +| 名称 | 定价 | 描述 | URL | +|------|------|------|-----| +| **BigCommerce** | 💰 | 电商平台,$29/月起 | https://vercel.com/templates/next.js/nextjs-commerce | +| **Saleor** | 🆓 | 开源商务API | https://vercel.com/templates/next.js/nextjs-saleor-commerce | +| **Salesforce Commerce Cloud** | 🏢 | 商务平台,企业 | https://vercel.com/templates/next.js/salesforce-commerce-cloud-starter | +| **Shopify** | 💰 | Headless电商,$29/月起 | https://vercel.com/docs/integrations/shopify | +| **Sitecore OrderCloud** | 🏢 | B2X商务 | /marketplace/ordercloud | +| **Swell** | 🆓 | 开源Headless电商 | /marketplace/swell | +| **Wix** | 💰 | 业务解决方案 | /marketplace/wix | + +### 5.6 DevTools(开发工具)- 8个 + +| 名称 | 定价 | 描述 | URL | +|------|------|------|-----| +| **Deploy Summary** | 🆓 | Vercel部署摘要 | /marketplace/deploy-summary | +| **Doppler** | ❓ | 密钥管理 | /marketplace/doppler | +| **Inngest** | 🆓 | 开源工作流平台 | /marketplace/inngest | +| **Liveblocks** | 🆓Free | 实时协作,有免费层 | https://vercel.com/templates/next.js/liveblocks-starter-kit | +| **Railway** | 🆓Free | 部署平台,$5额度免费 | /marketplace/railway | +| **Raycast** | ❓ | Vercel项目集成 | /marketplace/raycast | +| **Svix** | ❓ | Webhook服务 | /marketplace/svix | +| **Terraform** | 🆓 | 开源IaC工具 | /marketplace/terraform | + +> ⚠️ **注**:AWS 不在 External Integrations 列表中(通过 /partners/aws 跳转) + +### 5.7 Logging(日志)- 8个 + +| 名称 | 定价 | 描述 | URL | +|------|------|------|-----| +| **Axiom** | 🏢 | 日志服务,企业 | /marketplace/axiom | +| **Baselime** | 🏢 | 日志查询,企业 | /marketplace/baselime | +| **Better Stack** | ❓ | 日志服务 | /marketplace/betterstack | +| **GraphJSON** | ❓ | 日志可视化 | /marketplace/graphjson | +| **Logalert** | ❓ | 日志警报 | /marketplace/logalert | +| **Logflare** | ❓ | 日志服务 | /marketplace/logflare | +| **Profound** | ❓ | AI活动监控 | /marketplace/profound | +| **Sematext Logs** | ❓ | 日志服务 | /marketplace/sematext-logs | + +### 5.8 Messaging(消息)- 4个 + +| 名称 | 定价 | 描述 | URL | +|------|------|------|-----| +| **Knock** | ❓ | 消息API | /marketplace/knock | +| **Novu** | 🆓 | 开源通知基础设施 | /marketplace/novu | +| **Resend** | 🆓Free | 邮件服务,3K邮件免费 | /marketplace/resend | +| **Slack** | 🆓Free | 消息通知,免费版可用 | /marketplace/slack | + +### 5.9 Monitoring(监控)- 3个 + +| 名称 | 定价 | 描述 | URL | +|------|------|------|-----| +| **Checkly** | 💰 | 监控平台,$29/月起 | /marketplace/checkly | +| **DebugBear** | 💰 | 性能监控,$39/月起 | /marketplace/debugbear | +| **Zeitgeist** | ❓ | Vercel部署管理 | /marketplace/zeitgeist-app | + +### 5.10 Observability(可观测性)- 8个 + +| 名称 | 定价 | 描述 | URL | +|------|------|------|-----| +| **Dash0** | ❓ | 可观测平台 | /marketplace/dash0 | +| **Datadog** | 💰 | 监控平台,$15/月起 | /marketplace/datadog | +| **Highlight** | ❓ | 调试平台 | /marketplace/highlight | +| **HyperDX** | ❓ | 可观测平台 | /marketplace/hyperdx | +| **New Relic** | 💰 | 监控平台,$49/月起 | /marketplace/newrelic | +| **Sentry** | 🆓Free | 错误监控,5K错误/月免费 | /marketplace/sentry | +| **shipshape** | ❓ | 部署仪表盘 | /marketplace/shipshape | +| **Middleware** | ❓ | AI云可观测平台 | /marketplace/middleware | + +### 5.11 Productivity(生产力)- 3个 + +| 名称 | 定价 | 描述 | URL | +|------|------|------|-----| +| **GitHub Issues** | 🆓 | 评论转GitHub Issue | /marketplace/gh-issues | +| **Jira** | 🆓 | 评论转Jira | /marketplace/jira | +| **Linear** | 🆓 | 评论转Linear | /marketplace/linear | + +### 5.12 Searching(搜索)- 2个 + +| 名称 | 定价 | 描述 | URL | +|------|------|------|-----| +| **Meilisearch Cloud** | 🏢 | 搜索服务,企业 | /marketplace/meilisearch-cloud | +| **Upstash** | 🆓Free | Redis搜索,10K命令/天免费 | /marketplace/upstash | + +### 5.13 Security(安全)- 1个 + +| 名称 | 定价 | 描述 | URL | +|------|------|------|-----| +| **Arcjet** | ❓ | 代码化安全 | /marketplace/arcjet | + +### 5.14 Storage(存储)- 13个 + +| 名称 | 定价 | 描述 | URL | +|------|------|------|-----| +| **AWS S3** | 🏢 | 对象存储 | https://vercel.com/templates/next.js/aws-s3-image-upload-nextjs | +| **Azure Cosmos DB** | 🏢 | NoSQL数据库 | /marketplace/azurecosmosdb | +| **Couchbase Capella** | 🏢 | NoSQL云数据库 | /marketplace/couchbase-capella | +| **DataStax Astra DB** | 🏢 | 向量数据库 | /marketplace/datastax-astra-db | +| **Hasura** | 🆓 | 开源GraphQL API | /marketplace/hasura | +| **Pinecone** | 🏢 | 向量数据库 | /marketplace/pinecone | +| **SingleStoreDB Cloud** | 🏢 | 时序数据库 | /marketplace/singlestoredb-cloud | +| **StepZen** | 🆓 | 开源GraphQL | /marketplace/stepzen | +| **Thin Backend** | 🆓 | 开源Postgres后端 | /marketplace/thin | +| **TiDB Cloud** | 🏢 | 向量MySQL | /marketplace/tidb-cloud | +| **Tinybird** | 🏢 | 实时分析 | /marketplace/tinybird | +| **Xata** | ❓ | 数据库服务 | /marketplace/xata | +| **Tigris** | ❓ | 对象存储 | /marketplace/tigris | + +### 5.15 Testing(测试)- 1个 + +| 名称 | 定价 | 描述 | URL | +|------|------|------|-----| +| **Meticulous AI** | ❓ | AI端到端测试 | /marketplace/meticulous | + +--- + +## 六、Coming Soon(即将推出) + +| 名称 | 定价 | 描述 | +|------|------|------| +| **Browser Use** | ❓ | 网页自动化,可订阅通知 | + +--- + +## 七、统计汇总 + +| 类别 | 数量 | +|------|------| +| **总分类数** | 22 | +| **模板数** | 3 | +| **Native Integrations** | 42 | +| **External Integrations** | 90 | +| 🆓 免费开源 | ~15 | +| 🆓Free 免费套餐 | ~25 | +| 💰 付费订阅 | ~15 | +| 🏢 企业定价 | ~20 | +| ❓ 需查询定价 | ~35 | + +--- + +## 八、Native Integrations 完整列表(42个) + +### Storage(存储)- 12个 +Neon, AWS, Upstash, Supabase, Redis, Nile, MotherDuck, Convex, Prisma, Turso Cloud, MongoDB Atlas, Mixedbread + +### AI(人工智能)- 4个 +xAI (Grok), Groq, fal, Deep Infra + +### Observability(可观测性)- 5个 +Sentry, Dash0, Braintrust, Kubiks, Rollbar + +### Monitoring(监控)- 1个 +Checkly + +### Web Automation(网页自动化)- 2个 +Browserbase, Kernel + +### Code Review(代码审查)- 3个 +cubic, CodeRabbit, Sourcery + +### Support Agent(客服代理)- 2个 +AssistLoop, Chatbase + +### Searching(搜索)- 1个 +Parallel Web Systems + +### Code Security(代码安全)- 1个 +Corridor + +### Payments(支付)- 1个 +Stripe + +### Testing(测试)- 1个 +Autonoma AI + +### Analytics(分析)- 3个 +Statsig, Hypertune, PostHog + +### Authentication(认证)- 2个 +Descope, Clerk + +### DevTools(开发工具)- 1个 +Inngest + +### Experimentation(实验)- 1个 +GrowthBook + +### Video(视频)- 1个 +Mux + +### CMS(内容管理)- 1个 +Sanity + +--- + +## 九、External Integrations 分类统计(89个) + +| 类别 | 数量 | +|------|------| +| AI | 6 | +| Analytics | 7 | +| Authentication | 2 | +| CMS | 10 | +| Commerce | 7 | +| DevTools | 8 | +| Logging | 8 | +| Messaging | 4 | +| Monitoring | 3 | +| Observability | 8 | +| Productivity | 3 | +| Searching | 2 | +| Security | 1 | +| Storage | 13 | +| Testing | 1 | + +--- + +## 十、快速参考 + +### ⚠️ Vercel专有(仅6个) +这些服务只能在Vercel平台使用: +- Vercel Web Analytics +- Deploy Summary +- Raycast +- GitHub Issues +- Jira +- Linear + +### 🆓 免费开源(完全免费) +Supabase, Convex, Inngest, Novu, GrowthBook, Saleor, Swell, Hasura, StepZen, Thin Backend, Terraform, Prisma, Sourcery + +### 🆓Free 免费套餐(适合个人项目) +Neon, Upstash, Clerk, Auth0, Sentry, PostHog, Mux, Stripe, Resend, Railway, CodeRabbit, Sanity, Contentful, Turso, Redis, Descope, Replicate, Liveblocks, Rollbar + +### 💰 付费订阅 +Vercel Analytics($20/月), Checkly($29/月), DebugBear($39/月), Datadog($15/月), New Relic($49/月), LaunchDarkly($30/月), BigCommerce($29/月), Shopify($29/月), ElevenLabs, Together AI + +### 🏢 企业定价 +AWS, Azure, MongoDB Atlas, Pinecone, DataStax, Couchbase, TiDB, Tinybird, Meilisearch, Contentstack, Builder.io, Sitecore, Salesforce diff --git a/docs/vercel/vercel-mvp-system-v2.md b/docs/vercel/vercel-mvp-system-v2.md new file mode 100644 index 0000000..46f3294 --- /dev/null +++ b/docs/vercel/vercel-mvp-system-v2.md @@ -0,0 +1,983 @@ +# Vercel MVP Idea 验证系统 v2.0 + +> 精简版设计文档 - 专注核心价值 + +--- + +## 一、系统定位 + +**一句话定义**:帮你分析 idea 可行性,生成网站设计文档,追踪验证数据。 + +**核心价值**: +1. **AI 深度分析** - 不是简单的评分,而是给出具体的市场分析和建议 +2. **设计文档生成** - 输出专业的网站设计文档,你用专业工具构建 +3. **数据追踪** - 部署后追踪访问和转化数据 + +--- + +## 二、核心流程 + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ 简化版流程 │ +└─────────────────────────────────────────────────────────────────┘ + + ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ + │ 1. 输入 │ ──▶ │ 2. AI │ ──▶ │ 3. 生成 │ + │ Idea │ │ 深度分析 │ │ 设计文档 │ + └─────────────┘ └─────────────┘ └─────────────┘ + │ + ▼ + ┌─────────────┐ + │ 4. 你自己 │ + │ 构建部署 │ + └─────────────┘ + │ + ▼ + ┌─────────────┐ + │ 5. 追踪 │ + │ 验证数据 │ + └─────────────┘ +``` + +--- + +## 三、功能模块 + +### 3.1 Idea 管理 + +#### 数据模型 + +```typescript +interface Idea { + id: string + name: string // "AI 写作助手" + description: string // 一句话描述 + category: 'tool' | 'content' | 'saas' | 'ecommerce' | 'other' + targetKeywords: string[] // 目标关键词 + status: 'draft' | 'analyzing' | 'ready' | 'deployed' | 'validated' | 'abandoned' + + // AI 分析结果 + analysis?: IdeaAnalysis + + // 设计文档 + designDoc?: DesignDocument + + // 部署信息 + deployment?: { + url: string + deployedAt: Date + } + + // 验证数据 + validation?: { + visits: number + signups: number + lastChecked: Date + } + + createdAt: Date + updatedAt: Date +} +``` + +#### 功能 +- 创建 / 编辑 / 删除 Idea +- 状态管理(草稿 → 分析中 → 就绪 → 已部署 → 验证中 → 已验证 / 已放弃) +- 列表展示 + 筛选 + +--- + +### 3.2 AI 深度分析 + +#### 分析维度 + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ AI 分析输出 │ +└─────────────────────────────────────────────────────────────────┘ + +1. 市场分析 + ├── 市场规模估算 + ├── 目标用户画像 + ├── 用户痛点分析 + └── 竞品概览(3-5个主要竞品) + +2. 可行性评估 + ├── 技术难度 (1-5星) + ├── 资金需求 (低/中/高) + ├── 时间成本估算 + └── 总体可行性得分 (0-100) + +3. 差异化建议 + ├── 现有竞品不足之处 + ├── 你的独特切入点 + └── MVP 功能建议 + +4. 变现路径 + ├── 建议定价 + ├── 收入模型 + └── 预估时间线 + +5. 风险提示 + ├── 主要风险点 + └── 应对建议 + +6. 行动建议 + ├── 是否值得继续 + ├── 下一步行动清单 + └── 推荐验证方式 +``` + +#### 数据模型 + +```typescript +interface IdeaAnalysis { + // 市场分析 + market: { + size: string // "中小型市场,预计月搜索量 10K-50K" + targetUsers: string[] // ["内容创作者", "营销人员", "自由职业者"] + painPoints: string[] // 用户痛点 + competitors: Competitor[] // 竞品列表 + } + + // 可行性 + feasibility: { + technicalDifficulty: 1 | 2 | 3 | 4 | 5 + fundRequirement: 'low' | 'medium' | 'high' + timeEstimate: string // "2-4周可完成 MVP" + overallScore: number // 0-100 + } + + // 差异化 + differentiation: { + competitorGaps: string[] // 竞品不足 + uniqueAngle: string // 你的切入点 + mvpFeatures: string[] // 建议 MVP 功能 + } + + // 变现 + monetization: { + pricingModel: 'subscription' | 'one-time' | 'freemium' | 'usage-based' + suggestedPrice: { min: number, max: number, currency: string } + revenueModel: string // 收入模型说明 + timeline: string // 预估盈利时间 + } + + // 风险 + risks: { + main: string // 主要风险 + mitigation: string // 应对建议 + }[] + + // 行动建议 + recommendation: { + shouldProceed: boolean // 是否建议继续 + confidence: number // 信心指数 0-100 + nextSteps: string[] // 下一步行动 + validationMethod: string // 推荐验证方式 + } + + analyzedAt: Date +} + +interface Competitor { + name: string + url?: string + description: string + strengths: string[] + weaknesses: string[] + pricing?: string +} +``` + +#### AI Prompt 设计 + +```typescript +const ANALYSIS_PROMPT = ` +你是一位资深的创业顾问和产品经理。请对以下 idea 进行深度分析: + +## Idea 信息 +- 名称:{name} +- 描述:{description} +- 分类:{category} +- 目标关键词:{keywords} + +## 请输出以下内容: + +### 1. 市场分析 +- 市场规模(基于你的知识判断) +- 目标用户画像(3-5类) +- 用户痛点(3-5个) +- 主要竞品(3-5个,包括名称、简介、优缺点、定价) + +### 2. 可行性评估 +- 技术难度(1-5星) +- 资金需求(低/中/高) +- 时间成本估算 +- 总体可行性得分(0-100分) + +### 3. 差异化建议 +- 竞品的不足之处 +- 建议的独特切入点 +- MVP 核心功能建议(3-5个) + +### 4. 变现路径 +- 建议定价模式 +- 价格区间 +- 收入模型 +- 预估盈利时间 + +### 5. 风险提示 +- 主要风险(2-3个) +- 应对建议 + +### 6. 行动建议 +- 是否值得继续(是/否) +- 信心指数(0-100) +- 下一步行动清单 +- 推荐验证方式 + +请以 JSON 格式输出。 +` +``` + +--- + +### 3.3 设计文档生成 + +#### 文档结构 + +```typescript +interface DesignDocument { + id: string + ideaId: string + + // 基本信息 + projectName: string + projectSlug: string // URL 友好的名称 + tagline: string // 一句话介绍 + + // 网站类型 + siteType: 'waitlist' | 'landing' | 'pricing' | 'blog' | 'portfolio' + + // 目标 + primaryGoal: 'collect_emails' | 'show_product' | 'sell' | 'content' + + // 页面结构 + pages: PageDefinition[] + + // 设计规范 + design: { + colorScheme: ColorScheme + typography: Typography + components: ComponentSpec[] + } + + // 内容 + content: { + hero: HeroSection + features: FeatureSection + pricing?: PricingSection + faq?: FaqSection + cta: CtaSection + } + + // SEO + seo: { + title: string + description: string + keywords: string[] + ogImage?: string + } + + // 技术建议 + techStack: { + framework: string // 推荐框架 + hosting: string // 推荐托管 + analytics: string[] // 分析工具 + integrations: string[] // 集成建议 + } + + createdAt: Date +} + +interface PageDefinition { + name: string // "首页", "定价页" + path: string // "/", "/pricing" + sections: string[] // 页面包含的区块 + purpose: string // 页面目的 +} + +interface ColorScheme { + primary: string // 主色 + secondary: string // 辅色 + accent: string // 强调色 + background: string // 背景色 + text: string // 文字色 +} + +interface Typography { + headingFont: string + bodyFont: string + headingSizes: { h1: string, h2: string, h3: string } + bodySize: string +} + +interface HeroSection { + headline: string + subheadline: string + cta: { + text: string + action: string // "signup" | "learn_more" | "get_started" + } + visual: { + type: 'image' | 'video' | 'illustration' | 'screenshot' + description: string + } +} + +interface FeatureSection { + title: string + subtitle?: string + features: { + icon: string // 图标建议 + title: string + description: string + }[] +} + +interface PricingSection { + plans: { + name: string // "Free", "Pro", "Enterprise" + price: string // "$0", "$9.99/mo" + features: string[] + highlighted: boolean // 是否推荐 + cta: string // 按钮文字 + }[] +} + +interface FaqSection { + items: { + question: string + answer: string + }[] +} + +interface CtaSection { + headline: string + description: string + buttonText: string +} +``` + +#### 网站类型模板 + +| 类型 | 适用场景 | 核心目标 | 页面结构 | +|------|----------|----------|----------| +| **Waitlist** | 产品未上线 | 收集邮箱 | Hero + Features + Waitlist Form | +| **Landing** | 产品展示 | 展示 + 转化 | Hero + Features + Testimonials + CTA | +| **Pricing** | SaaS 产品 | 销售订阅 | Hero + Features + Pricing + FAQ | +| **Blog** | 内容/SEO | 流量 + 订阅 | Hero + Articles + Newsletter | +| **Portfolio** | 个人/作品 | 展示能力 | Hero + Projects + About + Contact | + +--- + +### 3.4 数据追踪 + +#### 追踪内容 + +```typescript +interface ValidationData { + ideaId: string + + // 访问数据 + traffic: { + totalVisits: number + uniqueVisitors: number + avgDuration: string // "2:34" + bounceRate: number // 百分比 + trend: 'up' | 'down' | 'stable' + } + + // 转化数据 + conversion: { + emailSignups: number + conversionRate: number // 百分比 + sources: { + direct: number + organic: number + social: number + referral: number + } + } + + // 时间线 + timeline: { + date: string + visits: number + signups: number + }[] + + lastUpdated: Date +} +``` + +#### 数据来源 +- **Vercel Analytics API** - 访问量、访客数 +- **自定义追踪** - 表单提交、按钮点击 +- **UTM 参数** - 流量来源追踪 + +--- + +## 四、数据流设计 + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ 数据流 │ +└─────────────────────────────────────────────────────────────────────────┘ + + ┌──────────────────┐ + │ 用户输入 Idea │ + │ - 名称 │ + │ - 描述 │ + │ - 分类 │ + │ - 关键词 │ + └────────┬─────────┘ + │ + ▼ + ┌──────────────────────────────┐ + │ AI 分析引擎 │ + │ (调用 LLM API) │ + │ │ + │ 输出: │ + │ - 市场分析 │ + │ - 可行性评估 │ + │ - 差异化建议 │ + │ - 变现路径 │ + │ - 风险提示 │ + │ - 行动建议 │ + └──────────────┬───────────────┘ + │ + ┌──────────────┴───────────────┐ + │ 用户决定是否继续 │ + └──────────────┬───────────────┘ + │ 继续 + ▼ + ┌──────────────────────────────┐ + │ 选择网站类型 + 目标 │ + │ - Waitlist / Landing / ... │ + │ - 收集邮箱 / 展示 / 销售 │ + └──────────────┬───────────────┘ + │ + ▼ + ┌──────────────────────────────┐ + │ 生成设计文档 │ + │ │ + │ 包含: │ + │ - 页面结构 │ + │ - 设计规范 │ + │ - 内容文案 │ + │ - SEO 配置 │ + │ - 技术建议 │ + └──────────────┬───────────────┘ + │ + ▼ + ┌──────────────────────────────┐ + │ 导出设计文档 │ + │ - Markdown 格式 │ + │ - JSON 格式 │ + │ - 复制到剪贴板 │ + └──────────────┬───────────────┘ + │ + ▼ + ┌──────────────────────────────┐ + │ 你自己构建和部署 │ + │ (使用 v0 / Cursor / 等) │ + └──────────────┬───────────────┘ + │ + ▼ + ┌──────────────────────────────┐ + │ 录入部署 URL │ + │ 系统开始追踪数据 │ + └──────────────────────────────┘ +``` + +--- + +## 五、技术架构 + +### 5.1 技术栈 + +| 层级 | 技术 | 说明 | +|------|------|------| +| 桌面端 | Electron | 现有 | +| 前端 | React + TypeScript | 现有 | +| 样式 | Tailwind CSS | 现有 | +| 后端 | Express | 现有 | +| 数据库 | SQLite | 本地存储 | +| AI | OpenAI API / Anthropic API | LLM 调用 | +| 数据 | Vercel Analytics API | 访问数据 | + +### 5.2 模块结构 + +``` +api/modules/idea-validator/ +├── index.ts # 模块导出 +├── routes.ts # API 路由 +├── service.ts # 业务逻辑 +├── analyzer.ts # AI 分析逻辑 +├── generator.ts # 设计文档生成 +├── tracker.ts # 数据追踪 +├── prompts.ts # AI Prompt 模板 +├── types.ts # 类型定义 +└── db/ + ├── ideas.ts # Idea 数据操作 + ├── analysis.ts # 分析结果存储 + └── documents.ts # 设计文档存储 + +src/modules/idea-validator/ +├── index.ts +├── pages/ +│ └── IdeaValidator.tsx # 主面板 +├── components/ +│ ├── IdeaList.tsx # Idea 列表 +│ ├── IdeaCard.tsx # Idea 卡片 +│ ├── IdeaForm.tsx # 创建/编辑表单 +│ ├── AnalysisView.tsx # 分析结果展示 +│ ├── DesignDocView.tsx # 设计文档预览 +│ ├── ExportPanel.tsx # 导出面板 +│ ├── ValidationChart.tsx # 验证数据图表 +│ └── Recommendation.tsx # AI 建议展示 +└── stores/ + └── ideaStore.ts # 状态管理 +``` + +--- + +## 六、API 设计 + +### Idea 管理 + +``` +GET /api/ideas # 获取所有 Idea +POST /api/ideas # 创建 Idea +GET /api/ideas/:id # 获取单个 Idea +PUT /api/ideas/:id # 更新 Idea +DELETE /api/ideas/:id # 删除 Idea +``` + +### AI 分析 + +``` +POST /api/ideas/:id/analyze # 执行 AI 分析 +GET /api/ideas/:id/analysis # 获取分析结果 +``` + +### 设计文档 + +``` +POST /api/ideas/:id/design # 生成设计文档 +GET /api/ideas/:id/design # 获取设计文档 +PUT /api/ideas/:id/design # 更新设计文档 +GET /api/ideas/:id/design/export # 导出文档 (markdown/json) +``` + +### 数据追踪 + +``` +POST /api/ideas/:id/deployment # 录入部署信息 +GET /api/ideas/:id/validation # 获取验证数据 +POST /api/ideas/:id/sync-analytics # 同步 Vercel 数据 +``` + +--- + +## 七、页面设计 + +### 7.1 主面板 + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ Idea Validator [+ 新建 Idea] │ +├─────────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌───────────┐ │ +│ │ 💡 总 Idea │ │ ✅ 已验证 │ │ 🚀 已部署 │ │ 📧 邮箱 │ │ +│ │ 12 │ │ 3 │ │ 5 │ │ 1,234 │ │ +│ └─────────────┘ └─────────────┘ └─────────────┘ └───────────┘ │ +│ │ +│ ┌─────────────────────────────────────────────────────────────┐ │ +│ │ 我的 Ideas 筛选 ▾ │ │ +│ │ ───────────────────────────────────────────────────────── │ │ +│ │ │ │ +│ │ 💡 AI 写作助手 │ │ +│ │ 可行性: 78/100 │ 状态: 🟢 已验证 │ 邮箱: 234 │ │ +│ │ [查看分析] [设计文档] [验证数据] │ │ +│ │ │ │ +│ │ 💡 PDF 转换工具 │ │ +│ │ 可行性: 65/100 │ 状态: 🚀 已部署 │ 邮箱: 89 │ │ +│ │ [查看分析] [设计文档] [验证数据] │ │ +│ │ │ │ +│ │ 💡 时间追踪 App │ │ +│ │ 可行性: 45/100 │ 状态: 📝 草稿 │ │ │ +│ │ [继续分析] [删除] │ │ +│ │ │ │ +│ └─────────────────────────────────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +### 7.2 创建 Idea 流程 + +``` +Step 1: 基本信息 +┌─────────────────────────────────────────────────────────────────────┐ +│ 创建新 Idea Step 1/3 │ +├─────────────────────────────────────────────────────────────────────┤ +│ │ +│ Idea 名称 * │ +│ ┌─────────────────────────────────────────────────────────────┐ │ +│ │ AI 写作助手 │ │ +│ └─────────────────────────────────────────────────────────────┘ │ +│ │ +│ 一句话描述 * │ +│ ┌─────────────────────────────────────────────────────────────┐ │ +│ │ 帮助内容创作者快速生成高质量文章的 AI 工具 │ │ +│ └─────────────────────────────────────────────────────────────┘ │ +│ │ +│ 分类 │ +│ ○ 工具类 ● SaaS ○ 内容类 ○ 电商 ○ 其他 │ +│ │ +│ 目标关键词(按回车添加) │ +│ ┌─────────────────────────────────────────────────────────────┐ │ +│ │ AI writing ✕ content generator ✕ 写作工具 ✕ │ │ +│ └─────────────────────────────────────────────────────────────┘ │ +│ │ +│ [取消] [下一步 →] │ +└─────────────────────────────────────────────────────────────────────┘ + +Step 2: AI 分析中 +┌─────────────────────────────────────────────────────────────────────┐ +│ AI 正在分析... │ +├─────────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌─────────────────┐ │ +│ │ │ │ +│ │ 分析市场中... │ │ +│ │ │ │ +│ └─────────────────┘ │ +│ │ +│ ████████░░░░░░░░ 45% │ +│ │ +│ 正在分析竞品... │ +│ │ +└─────────────────────────────────────────────────────────────────────┘ + +Step 3: 分析结果 +┌─────────────────────────────────────────────────────────────────────┐ +│ 分析结果 [重新分析] │ +├─────────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌─────────────────────────────────────────────────────────────┐ │ +│ │ 🎯 可行性评分 │ │ +│ │ │ │ +│ │ ████████████████████████████░░░░░░░░░░ 78/100 │ │ +│ │ │ │ +│ │ 💡 AI 建议:这个 idea 值得尝试! │ │ +│ │ 信心指数:72% │ │ +│ └─────────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌─────────────────────────────────────────────────────────────┐ │ +│ │ 📊 市场分析 │ │ +│ │ ───────────────────────────────────────────────────────── │ │ +│ │ 市场规模:中小型,月搜索量约 20K-50K │ │ +│ │ 目标用户:内容创作者、营销人员、自由职业者 │ │ +│ │ 用户痛点:写作效率低、灵感枯竭、SEO优化困难 │ │ +│ └─────────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌─────────────────────────────────────────────────────────────┐ │ +│ │ 🏆 竞品分析 │ │ +│ │ ───────────────────────────────────────────────────────── │ │ +│ │ 1. Jasper AI - 功能强大但价格贵 ($49/月起) │ │ +│ │ 2. Copy.ai - 简单易用但专业度不够 │ │ +│ │ 3. Writesonic - 性价比高但中文支持弱 │ │ +│ │ │ │ +│ │ 💡 差异化机会:专注中文市场 + 更低价格 + 更简洁的体验 │ │ +│ └─────────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌─────────────────────────────────────────────────────────────┐ │ +│ │ 💰 变现建议 │ │ +│ │ ───────────────────────────────────────────────────────── │ │ +│ │ 定价模式:Freemium + 订阅 │ │ +│ │ 建议价格:免费版 + $9.99/月 Pro版 │ │ +│ │ 预估盈利:6-12个月 │ │ +│ └─────────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌─────────────────────────────────────────────────────────────┐ │ +│ │ ⚠️ 风险提示 │ │ +│ │ ───────────────────────────────────────────────────────── │ │ +│ │ 1. AI 领域竞争激烈,需快速迭代 │ │ +│ │ 2. 依赖大模型 API,成本需控制 │ │ +│ │ │ │ +│ │ 建议:从细分场景切入,如"小红书文案生成" │ │ +│ └─────────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌─────────────────────────────────────────────────────────────┐ │ +│ │ 📋 下一步行动 │ │ +│ │ ───────────────────────────────────────────────────────── │ │ +│ │ ✅ 选择一个细分场景(如小红书文案) │ │ +│ │ ✅ 生成设计文档 │ │ +│ │ ✅ 构建简单落地页,验证需求 │ │ +│ │ ✅ 收集 100 个邮箱后再开发产品 │ │ +│ └─────────────────────────────────────────────────────────────┘ │ +│ │ +│ [放弃] [生成设计文档 →] │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +### 7.3 设计文档页面 + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ 设计文档 - AI 写作助手 [导出 ▾] [编辑] │ +├─────────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌─────────────────────────────────────────────────────────────┐ │ +│ │ 基本信息 │ │ +│ │ ───────────────────────────────────────────────────────── │ │ +│ │ 项目名称:AIWriter │ │ +│ │ 项目 Slug:ai-writer │ │ +│ │ 标语:让写作更简单,让创作更高效 │ │ +│ │ 网站类型:Waitlist (收集邮箱) │ │ +│ └─────────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌─────────────────────────────────────────────────────────────┐ │ +│ │ 页面结构 │ │ +│ │ ───────────────────────────────────────────────────────── │ │ +│ │ 首页 (/) │ │ +│ │ ├── Hero Section (主视觉区) │ │ +│ │ ├── Features Section (功能特点) │ │ +│ │ ├── How It Works (使用流程) │ │ +│ │ ├── Waitlist Form (邮箱收集) │ │ +│ │ └── Footer (页脚) │ │ +│ └─────────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌─────────────────────────────────────────────────────────────┐ │ +│ │ Hero Section │ │ +│ │ ───────────────────────────────────────────────────────── │ │ +│ │ 主标题:AI 驱动的智能写作助手 │ │ +│ │ 副标题:告别写作焦虑,3分钟生成高质量文章 │ │ +│ │ CTA 按钮:加入等待名单 │ │ +│ │ 视觉元素:产品截图 / 动态演示 │ │ +│ └─────────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌─────────────────────────────────────────────────────────────┐ │ +│ │ Features │ │ +│ │ ───────────────────────────────────────────────────────── │ │ +│ │ 1. 🚀 快速生成 - 3秒内生成完整文章 │ │ +│ │ 2. 🎯 SEO优化 - 自动优化关键词和结构 │ │ +│ │ 3. 🌍 多语言 - 支持10+种语言 │ │ +│ │ 4. ✏️ 风格定制 - 适配你的写作风格 │ │ +│ └─────────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌─────────────────────────────────────────────────────────────┐ │ +│ │ 设计规范 │ │ +│ │ ───────────────────────────────────────────────────────── │ │ +│ │ 主色:#6366F1 (Indigo) │ │ +│ │ 辅色:#EC4899 (Pink) │ │ +│ │ 背景:#FFFFFF / #F8FAFC │ │ +│ │ 字体:Inter (英文) / Noto Sans SC (中文) │ │ +│ └─────────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌─────────────────────────────────────────────────────────────┐ │ +│ │ SEO 配置 │ │ +│ │ ───────────────────────────────────────────────────────── │ │ +│ │ Title: AI 写作助手 - 智能内容生成工具 │ │ +│ │ Description: AI 驱动的写作助手,帮助内容创作者... │ │ +│ │ Keywords: AI写作, 内容生成, 文案工具, ... │ │ +│ └─────────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌─────────────────────────────────────────────────────────────┐ │ +│ │ 技术建议 │ │ +│ │ ───────────────────────────────────────────────────────── │ │ +│ │ 框架:Next.js 14 (App Router) │ │ +│ │ 托管:Vercel │ │ +│ │ 样式:Tailwind CSS │ │ +│ │ 分析:Vercel Analytics + 自定义追踪 │ │ +│ │ 邮箱收集:Resend / ConvertKit │ │ +│ └─────────────────────────────────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +### 7.4 导出面板 + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ 导出设计文档 │ +├─────────────────────────────────────────────────────────────────────┤ +│ │ +│ 格式选择: │ +│ ● Markdown ○ JSON ○ PDF │ +│ │ +│ 包含内容: │ +│ ☑ 基本信息 │ +│ ☑ 页面结构 │ +│ ☑ 内容文案 │ +│ ☑ 设计规范 │ +│ ☑ SEO 配置 │ +│ ☑ 技术建议 │ +│ │ +│ ┌─────────────────────────────────────────────────────────────┐ │ +│ │ 预览 │ │ +│ │ ───────────────────────────────────────────────────────── │ │ +│ │ # AI Writer - 设计文档 │ │ +│ │ │ │ +│ │ ## 基本信息 │ │ +│ │ - 项目名称:AIWriter │ │ +│ │ - 标语:让写作更简单,让创作更高效 │ │ +│ │ ... │ │ +│ └─────────────────────────────────────────────────────────────┘ │ +│ │ +│ [复制到剪贴板] [下载文件] [发送到邮箱] │ +│ │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +### 7.5 验证数据页面 + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ 验证数据 - AI 写作助手 [同步数据] [设置] │ +├─────────────────────────────────────────────────────────────────────┤ +│ │ +│ 部署 URL:https://ai-writer.vercel.app [打开] [编辑] │ +│ 部署时间:2024-03-10 │ +│ │ +│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌───────────┐ │ +│ │ 👁️ 总访问 │ │ 👥 独立访客 │ │ 📧 邮箱 │ │ 📈 转化率 │ │ +│ │ 1,234 │ │ 892 │ │ 234 │ │ 26.2% │ │ +│ │ ↑ 12% │ │ ↑ 8% │ │ ↑ 15% │ │ ↑ 2.1% │ │ +│ └─────────────┘ └─────────────┘ └─────────────┘ └───────────┘ │ +│ │ +│ ┌─────────────────────────────────────────────────────────────┐ │ +│ │ 访问趋势 (最近7天) │ │ +│ │ ───────────────────────────────────────────────────────── │ │ +│ │ │ │ +│ │ 200 ┤ │ │ +│ │ 150 ┤ ╭──╮ │ │ +│ │ 100 ┤ ╭──╯ ╰──╮ │ │ +│ │ 50 ┤───╯ ╰───╮ │ │ +│ │ 0 ┼──────────────────╰─── │ │ +│ │ Mon Tue Wed Thu Fri Sat Sun │ │ +│ │ │ │ +│ └─────────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌─────────────────────────────────────────────────────────────┐ │ +│ │ 流量来源 │ │ +│ │ ───────────────────────────────────────────────────────── │ │ +│ │ Direct ████████████████████░░░░░░░░░░░░░░░░░░░░ 45% │ │ +│ │ Social ██████████████░░░░░░░░░░░░░░░░░░░░░░░░░░ 30% │ │ +│ │ Organic ████████░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ 20% │ │ +│ │ Referral ████░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ 5% │ │ +│ └─────────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌─────────────────────────────────────────────────────────────┐ │ +│ │ 📊 验证评估 │ │ +│ │ ───────────────────────────────────────────────────────── │ │ +│ │ │ │ +│ │ 当前数据表明这个 idea 有一定潜力: │ │ +│ │ ✅ 转化率 26% 高于行业平均 (15-20%) │ │ +│ │ ✅ 流量稳步增长 │ │ +│ │ ⚠️ 社交媒体流量占比较高,需关注长期留存 │ │ +│ │ │ │ +│ │ 建议: │ │ +│ │ • 继续收集数据,目标 500+ 邮箱 │ │ +│ │ • 开始开发 MVP 核心功能 │ │ +│ │ • 尝试 SEO 获取更多自然流量 │ │ +│ │ │ │ +│ └─────────────────────────────────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +--- + +## 八、实施计划 + +### 第一阶段:核心功能(2周) + +**Week 1** +- [ ] Idea CRUD(创建/读取/更新/删除) +- [ ] SQLite 数据存储 +- [ ] 基础 UI 组件 + +**Week 2** +- [ ] AI 分析功能 +- [ ] 分析结果展示 +- [ ] 设计文档生成(基础版) + +### 第二阶段:完善功能(2周) + +**Week 3** +- [ ] 设计文档编辑 +- [ ] 导出功能(Markdown/JSON) +- [ ] 网站类型模板优化 + +**Week 4** +- [ ] Vercel 数据追踪集成 +- [ ] 验证数据展示 +- [ ] 数据同步功能 + +### 第三阶段:优化迭代(按需) + +- [ ] 更丰富的分析维度 +- [ ] 设计文档模板库 +- [ ] 数据可视化增强 +- [ ] AI 建议优化 + +--- + +## 九、配置需求 + +### AI 服务配置 + +```typescript +// 配置项 +interface AIConfig { + provider: 'openai' | 'anthropic' | 'azure' + apiKey: string + model: string // 'gpt-4' | 'claude-3-opus' 等 + maxTokens: number + temperature: number +} +``` + +### Vercel 配置 + +```typescript +// Vercel API 配置 +interface VercelConfig { + apiToken: string // Vercel API Token + teamId?: string // 团队 ID(可选) +} +``` + +--- + +## 十、后续扩展方向 + +### 可选功能(按需添加) + +1. **多语言支持** - 分析结果和设计文档多语言 +2. **设计文档版本** - 保存多个版本,对比选择 +3. **团队协作** - 分享 idea 和分析结果 +4. **模板市场** - 用户分享设计文档模板 +5. **自动化建议** - 基于数据自动给出优化建议 +6. **竞品监控** - 定期更新竞品信息 + +--- + +*文档版本:v2.0* +*最后更新:2024-03-12* \ No newline at end of file diff --git a/remote/test-connect.js b/remote/test-connect.js deleted file mode 100644 index 01d879b..0000000 --- a/remote/test-connect.js +++ /dev/null @@ -1,22 +0,0 @@ -const WebSocket = require('ws'); - -console.log('Testing connection to remote...'); -const ws = new WebSocket('ws://146.56.248.142:8083/ws?password=wzw20040525', { - handshakeTimeout: 5000 -}); - -ws.on('open', () => { - console.log('Connected!'); - ws.close(); - process.exit(0); -}); - -ws.on('error', (err) => { - console.log('Error:', err.message); - process.exit(1); -}); - -setTimeout(() => { - console.log('Connection timeout'); - process.exit(1); -}, 8000); diff --git a/remote/test-file-api.js b/remote/test-file-api.js deleted file mode 100644 index d21c41b..0000000 --- a/remote/test-file-api.js +++ /dev/null @@ -1,268 +0,0 @@ -const http = require('http'); -const https = require('https'); - -const BASE_URL = 'http://localhost:3000'; -const PASSWORD = 'wzw20040525'; -const CHUNK_SIZE = 5 * 1024 * 1024; - -function request(options, body = null) { - return new Promise((resolve, reject) => { - const url = new URL(options.path, BASE_URL); - url.searchParams.set('password', PASSWORD); - - const client = url.protocol === 'https:' ? https : http; - - const reqOptions = { - hostname: url.hostname, - port: url.port, - path: url.pathname + url.search, - method: options.method, - headers: options.headers || {} - }; - - const req = client.request(reqOptions, (res) => { - let data = ''; - res.on('data', chunk => data += chunk); - res.on('end', () => { - try { - resolve({ status: res.statusCode, data: JSON.parse(data) }); - } catch { - resolve({ status: res.statusCode, data }); - } - }); - }); - - req.on('error', reject); - - if (body) { - if (body instanceof FormData) { - req.write(body.getBuffer()); - } else if (Buffer.isBuffer(body)) { - req.write(body); - } else { - req.write(JSON.stringify(body)); - } - } - - req.end(); - }); -} - -async function testGetDrives() { - console.log('\n=== 测试1: 获取驱动器列表 ==='); - try { - const driveUrl = new URL('/api/files/browse', BASE_URL); - driveUrl.searchParams.set('allowSystem', 'true'); - driveUrl.searchParams.set('password', PASSWORD); - - const res = await request({ - method: 'GET', - path: driveUrl.pathname + driveUrl.search - }); - console.log('状态:', res.status); - console.log('currentPath:', res.data.currentPath); - console.log('parentPath:', res.data.parentPath); - - // 检查是否返回了驱动器(盘符如 C:, D:) - const drives = res.data.items?.filter(item => item.name.match(/^[A-Z]:$/i)); - if (drives && drives.length > 0) { - console.log('✓ 驱动器列表:', drives.map(d => d.name).join(', ')); - return true; - } - - // 如果返回的是目录列表而非驱动器,说明 allowSystem 未生效 - console.log('✗ 未获取到驱动器列表'); - console.log('返回的项目:', res.data.items?.slice(0, 5).map(i => i.name).join(', ')); - return false; - } catch (err) { - console.error('错误:', err.message); - return false; - } -} - -async function testUploadToSystemDir() { - console.log('\n=== 测试2: 上传到系统目录 ==='); - const testContent = 'Hello Remote System Directory Test ' + Date.now(); - const filename = 'D:\\xc_test_file.txt'; - - try { - // 1. 开始上传 - console.log('1. 开始上传...'); - const startUrl = new URL('/api/files/upload/start', BASE_URL); - startUrl.searchParams.set('password', PASSWORD); - - const startRes = await new Promise((resolve, reject) => { - const req = http.request({ - hostname: startUrl.hostname, - port: startUrl.port, - path: startUrl.pathname + startUrl.search, - method: 'POST', - headers: { 'Content-Type': 'application/json' } - }, (res) => { - let data = ''; - res.on('data', chunk => data += chunk); - res.on('end', () => { - try { resolve({ status: res.statusCode, data: JSON.parse(data) }); } - catch { resolve({ status: res.statusCode, data }); } - }); - }); - req.on('error', reject); - req.write(JSON.stringify({ - filename: filename, - totalChunks: 1, - fileSize: Buffer.byteLength(testContent) - })); - req.end(); - }); - console.log('开始上传响应:', startRes.data); - - if (!startRes.data.fileId) { - console.error('未获取到fileId'); - return false; - } - - const fileId = startRes.data.fileId; - - // 2. 上传分块 - console.log('2. 上传分块...'); - const chunk = Buffer.from(testContent); - - const chunkUrl = new URL('/api/files/upload/chunk', BASE_URL); - chunkUrl.searchParams.set('password', PASSWORD); - - const FormData = require('form-data'); - const form = new FormData(); - form.append('fileId', fileId); - form.append('chunkIndex', '0'); - form.append('chunk', chunk, { filename: 'chunk', contentType: 'application/octet-stream' }); - - const chunkRes = await new Promise((resolve, reject) => { - const req = http.request({ - hostname: chunkUrl.hostname, - port: chunkUrl.port, - path: chunkUrl.pathname + chunkUrl.search, - method: 'POST', - headers: form.getHeaders() - }, (res) => { - let data = ''; - res.on('data', c => data += c); - res.on('end', () => { - try { resolve({ status: res.statusCode, data: JSON.parse(data) }); } - catch { resolve({ status: res.statusCode, data }); } - }); - }); - req.on('error', reject); - req.write(form.getBuffer()); - req.end(); - }); - console.log('分块上传响应:', chunkRes.data); - - // 3. 合并文件 - console.log('3. 合并文件...'); - const mergeUrl = new URL('/api/files/upload/merge', BASE_URL); - mergeUrl.searchParams.set('password', PASSWORD); - - const mergeRes = await new Promise((resolve, reject) => { - const req = http.request({ - hostname: mergeUrl.hostname, - port: mergeUrl.port, - path: mergeUrl.pathname + mergeUrl.search, - method: 'POST', - headers: { 'Content-Type': 'application/json' } - }, (res) => { - let data = ''; - res.on('data', c => data += c); - res.on('end', () => { - try { resolve({ status: res.statusCode, data: JSON.parse(data) }); } - catch { resolve({ status: res.statusCode, data }); } - }); - }); - req.on('error', reject); - req.write(JSON.stringify({ - fileId: fileId, - totalChunks: 1, - filename: filename - })); - req.end(); - }); - console.log('合并响应:', mergeRes.data); - - return mergeRes.data.success === true; - } catch (err) { - console.error('错误:', err.message); - return false; - } -} - -async function testDownloadFromSystemDir() { - console.log('\n=== 测试3: 从系统目录下载 ==='); - const filename = 'D:\\xc_test_file.txt'; - - try { - const encodedPath = encodeURIComponent(filename); - const res = await request({ - method: 'GET', - path: `/api/files/${encodedPath}` - }); - console.log('状态:', res.status); - console.log('响应类型:', typeof res.data); - return res.status === 200; - } catch (err) { - console.error('错误:', err.message); - return false; - } -} - -async function testDeleteFile() { - console.log('\n=== 测试4: 删除测试文件 ==='); - const filename = 'D:\\xc_test_file.txt'; - - try { - const encodedPath = encodeURIComponent(filename); - const res = await request({ - method: 'DELETE', - path: `/api/files/${encodedPath}` - }); - console.log('状态:', res.status); - console.log('响应:', res.data); - return res.data.success === true || res.status === 200; - } catch (err) { - console.error('错误:', err.message); - return false; - } -} - -async function main() { - console.log('========================================'); - console.log('远程文件传输功能测试'); - console.log('目标服务器: 146.56.248.142:8080'); - console.log('========================================'); - - const results = []; - - // 测试1: 获取驱动器列表 - results.push({ name: '获取驱动器列表', pass: await testGetDrives() }); - - // 测试2: 上传到系统目录 - results.push({ name: '上传到系统目录', pass: await testUploadToSystemDir() }); - - // 测试3: 从系统目录下载 - results.push({ name: '从系统目录下载', pass: await testDownloadFromSystemDir() }); - - // 测试4: 删除测试文件 - results.push({ name: '删除测试文件', pass: await testDeleteFile() }); - - // 汇总结果 - console.log('\n========================================'); - console.log('测试结果汇总:'); - console.log('========================================'); - results.forEach(r => { - console.log(`${r.pass ? '✓' : '✗'} ${r.name}`); - }); - - const allPass = results.every(r => r.pass); - console.log(`\n总体结果: ${allPass ? '全部通过' : '存在失败'}`); - process.exit(allPass ? 0 : 1); -} - -main(); diff --git a/remote/test-full.js b/remote/test-full.js deleted file mode 100644 index 03a5383..0000000 --- a/remote/test-full.js +++ /dev/null @@ -1,89 +0,0 @@ -const WebSocket = require('ws'); -const fs = require('fs'); - -const ws = new WebSocket('ws://127.0.0.1:3003/ws?password=wzw20040525'); - -let fileId = 'upload_test_' + Date.now(); -const testContent = Buffer.from('Hello World Test File Content'); - -ws.on('open', () => { - console.log('=== Connected, starting upload test ==='); - - ws.send(JSON.stringify({ - type: 'fileUploadStart', - fileId: fileId, - filename: 'test.txt', - totalChunks: 1, - fileSize: testContent.length, - requestId: 'req1' - })); -}); - -ws.on('message', (data, isBinary) => { - if (isBinary) { - console.log('Binary data received'); - return; - } - - const msg = JSON.parse(data.toString()); - console.log('Received:', msg.type, msg.fileId || ''); - - if (msg.type === 'fileUploadStart') { - console.log('Session started, sending chunk...'); - ws.send(JSON.stringify({ - type: 'fileUploadChunk', - fileId: fileId, - chunkIndex: 0, - data: testContent.toString('base64'), - requestId: 'req2' - })); - } - - if (msg.type === 'fileUploadChunk') { - console.log('Chunk sent, sending merge...'); - ws.send(JSON.stringify({ - type: 'fileUploadMerge', - fileId: fileId, - filename: 'test.txt', - totalChunks: 1, - requestId: 'req3' - })); - } - - if (msg.type === 'fileUploadResult') { - console.log('=== Upload Result:', msg.success ? 'SUCCESS' : 'FAILED', msg.filename || msg.error); - - if (msg.success) { - console.log('\n=== Testing download ==='); - ws.send(JSON.stringify({ - type: 'fileDownloadStart', - filename: 'test.txt', - filePath: 'test.txt', - allowSystem: true, - requestId: 'req4' - })); - } else { - ws.close(); - process.exit(1); - } - } - - if (msg.type === 'fileDownloadStart') { - console.log('Download started, size:', msg.size); - } - - if (msg.type === 'fileDownloadChunk') { - console.log('Download chunk:', msg.chunkIndex, 'progress:', msg.progress + '%'); - } - - if (msg.type === 'fileDownloadComplete') { - console.log('=== Download Result:', msg.success ? 'SUCCESS' : 'FAILED'); - console.log('=== ALL TESTS PASSED ==='); - ws.close(); - process.exit(0); - } -}); - -ws.on('error', (err) => { console.error('Error:', err.message); }); - -setTimeout(() => { console.log('=== TIMEOUT ==='); process.exit(1); }, 20000); diff --git a/remote/test-ports.js b/remote/test-ports.js deleted file mode 100644 index 7b326f7..0000000 --- a/remote/test-ports.js +++ /dev/null @@ -1,13 +0,0 @@ -const WebSocket = require('ws'); - -console.log('Testing port 8080...'); -const ws1 = new WebSocket('ws://146.56.248.142:8080/ws?password=wzw20040525'); -ws1.on('open', () => console.log('8080: Connected')); -ws1.on('error', () => console.log('8080: Failed')); -ws1.on('close', () => { - console.log('Testing port 8083...'); - const ws2 = new WebSocket('ws://146.56.248.142:8083/ws?password=wzw20040525'); - ws2.on('open', () => console.log('8083: Connected')); - ws2.on('error', () => console.log('8083: Failed')); - setTimeout(() => process.exit(0), 3000); -}); diff --git a/remote/test-remote.js b/remote/test-remote.js deleted file mode 100644 index 6028805..0000000 --- a/remote/test-remote.js +++ /dev/null @@ -1,71 +0,0 @@ -const WebSocket = require('ws'); - -const ws = new WebSocket('ws://146.56.248.142:8083/ws?password=wzw20040525'); - -let fileId = 'test_' + Date.now(); -const testContent = Buffer.from('Hello Remote Test'); - -ws.on('open', () => { - console.log('=== Connected to 146.56.248.142:8083 ==='); - ws.send(JSON.stringify({ - type: 'fileUploadStart', - fileId: fileId, - filename: 'test.txt', - totalChunks: 1, - fileSize: testContent.length, - requestId: 'req1' - })); -}); - -ws.on('message', (data, isBinary) => { - if (isBinary) return; - const msg = JSON.parse(data.toString()); - console.log('Received:', msg.type); - - if (msg.type === 'fileUploadStart') { - ws.send(JSON.stringify({ - type: 'fileUploadChunk', - fileId: fileId, - chunkIndex: 0, - data: testContent.toString('base64'), - requestId: 'req2' - })); - } - - if (msg.type === 'fileUploadChunk') { - ws.send(JSON.stringify({ - type: 'fileUploadMerge', - fileId: fileId, - filename: 'test.txt', - totalChunks: 1, - requestId: 'req3' - })); - } - - if (msg.type === 'fileUploadResult') { - console.log('=== Upload:', msg.success ? 'SUCCESS' : 'FAILED'); - if (msg.success) { - ws.send(JSON.stringify({ - type: 'fileDownloadStart', - filename: 'test.txt', - filePath: 'test.txt', - allowSystem: false, - requestId: 'req4' - })); - } else { - ws.close(); - process.exit(1); - } - } - - if (msg.type === 'fileDownloadComplete') { - console.log('=== Download:', msg.success ? 'SUCCESS' : 'FAILED'); - console.log('=== ALL TESTS PASSED ==='); - ws.close(); - process.exit(0); - } -}); - -ws.on('error', err => console.error('Error:', err.message)); - -setTimeout(() => { console.log('TIMEOUT'); process.exit(1); }, 15000); diff --git a/remote/test-simple.js b/remote/test-simple.js deleted file mode 100644 index 8ec9831..0000000 --- a/remote/test-simple.js +++ /dev/null @@ -1,30 +0,0 @@ -const WebSocket = require('ws'); - -console.log('Starting...'); -const ws = new WebSocket('ws://146.56.248.142:8083/ws?password=wzw20040525'); - -ws.on('open', () => { - console.log('Connected!'); - - let fileId = 'test_' + Date.now(); - const testContent = Buffer.from('Hello'); - - console.log('Sending fileUploadStart...'); - ws.send(JSON.stringify({ - type: 'fileUploadStart', - fileId: fileId, - filename: 'F:/test.txt', - totalChunks: 1, - fileSize: 5, - requestId: 'req1' - })); -}); - -ws.on('message', (data, isBinary) => { - console.log('Got message:', isBinary ? 'binary' : data.toString().substring(0,80)); -}); - -ws.on('error', e => console.log('Error:', e.message)); -ws.on('close', () => console.log('Closed')); - -setTimeout(() => { console.log('Timeout'); process.exit(1); }, 10000); diff --git a/remote/test-upload.js b/remote/test-upload.js deleted file mode 100644 index 9c8b0f2..0000000 --- a/remote/test-upload.js +++ /dev/null @@ -1,57 +0,0 @@ -const WebSocket = require('ws'); - -console.log('Testing upload to remote 8083...'); -const ws = new WebSocket('ws://146.56.248.142:8083/ws?password=wzw20040525'); - -let fileId = 'test_' + Date.now(); -const testContent = Buffer.from('Hello Test 中文'); - -ws.on('open', () => { - console.log('Connected, sending fileUploadStart with F:/xxx.txt...'); - ws.send(JSON.stringify({ - type: 'fileUploadStart', - fileId: fileId, - filename: 'F:/小问题.txt', - totalChunks: 1, - fileSize: testContent.length, - requestId: 'req1' - })); -}); - -ws.on('message', (data, isBinary) => { - if (isBinary) return; - const msg = JSON.parse(data.toString()); - console.log('Received:', msg.type); - - if (msg.type === 'fileUploadStart') { - console.log('Session started, sending chunk...'); - ws.send(JSON.stringify({ - type: 'fileUploadChunk', - fileId: fileId, - chunkIndex: 0, - data: testContent.toString('base64'), - requestId: 'req2' - })); - } - - if (msg.type === 'fileUploadChunk') { - console.log('Chunk sent, sending merge...'); - ws.send(JSON.stringify({ - type: 'fileUploadMerge', - fileId: fileId, - filename: 'F:/小问题.txt', - totalChunks: 1, - requestId: 'req3' - })); - } - - if (msg.type === 'fileUploadResult') { - console.log('=== Upload Result:', msg.success ? 'SUCCESS' : 'FAILED', msg.error || ''); - ws.close(); - process.exit(0); - } -}); - -ws.on('error', err => console.error('Error:', err.message)); - -setTimeout(() => { console.log('Timeout'); process.exit(1); }, 15000); diff --git a/remote/test-ws.js b/remote/test-ws.js deleted file mode 100644 index 93c21a1..0000000 --- a/remote/test-ws.js +++ /dev/null @@ -1,32 +0,0 @@ -const WebSocket = require('ws'); -const ws = new WebSocket('ws://127.0.0.1:3001/ws?password=wzw20040525'); - -ws.on('open', () => { - console.log('Connected, sending fileBrowse...'); - ws.send(JSON.stringify({ type: 'fileBrowse', path: 'C:\\', allowSystem: true, requestId: 'test1' })); -}); - -ws.on('message', (data) => { - console.log('Received raw:', data.toString().substring(0, 200)); - - if (Buffer.isBuffer(data) || data instanceof ArrayBuffer) { - console.log('Binary data'); - return; - } - - try { - const msg = JSON.parse(data); - console.log('Received:', msg.type); - if (msg.type === 'fileBrowseResult') { - console.log('Items:', msg.items?.slice(0,3)); - ws.close(); - process.exit(0); - } - } catch(e) { - console.log('Parse error:', e.message); - } -}); - -ws.on('error', (err) => { console.error('Error:', err.message); }); - -setTimeout(() => { console.log('Timeout'); process.exit(1); }, 10000); diff --git a/remote/xcopencodeweb/README.md b/remote/xcopencodeweb/README.md new file mode 100644 index 0000000..afb0778 --- /dev/null +++ b/remote/xcopencodeweb/README.md @@ -0,0 +1,99 @@ +# XCOpenCodeWeb + +XCOpenCodeWeb 是一个基于 Web 的 AI 编程助手界面,用于与 OpenCode 服务器交互。 + +## 快速开始 + +### 使用单文件 exe(推荐) + +直接下载 `XCOpenCodeWeb.exe`,双击运行: + +```bash +# 默认端口 3000 +XCOpenCodeWeb.exe + +# 指定端口 +XCOpenCodeWeb.exe --port 8080 + +# 查看帮助 +XCOpenCodeWeb.exe --help +``` + +启动后访问 http://localhost:3000 + +### 从源码运行 + +需要安装 [Bun](https://bun.sh) 或 Node.js 20+ + +```bash +# 安装依赖 +bun install + +# 构建前端 +bun run build + +# 启动服务器 +bun server/index.js --port 3000 +``` + +## 构建单文件 exe + +```bash +cd web +bun run build:exe +``` + +输出:`web/XCOpenCodeWeb.exe`(约 320MB) + +详细文档:[docs/single-file-exe-build.md](docs/single-file-exe-build.md) + +## 项目结构 + +``` +├── ui/ # 前端组件库 +├── web/ +│ ├── src/ # 前端源码 +│ ├── server/ # 后端服务器 +│ ├── bin/ # CLI 工具 +│ └── dist/ # 构建输出 +├── docs/ # 文档 +└── AGENTS.md # AI Agent 参考文档 +``` + +## 常用命令 + +```bash +# 开发模式 +bun run dev # 前端热更新 +bun run dev:server # 启动开发服务器 + +# 构建 +bun run build # 构建前端 +bun run build:exe # 构建单文件 exe + +# 代码检查 +bun run type-check:web # TypeScript 类型检查 +bun run lint:web # ESLint 检查 +``` + +## 依赖 + +- [Bun](https://bun.sh) - 运行时和打包工具 +- [React](https://react.dev) - 前端框架 +- [Express](https://expressjs.com) - 后端服务器 +- [Tailwind CSS](https://tailwindcss.com) - 样式框架 + +## 配置 + +### 环境变量 + +| 变量 | 说明 | +|------|------| +| `XCOpenCodeWeb_PORT` | 服务器端口(默认 3000) | +| `OPENCODE_HOST` | 外部 OpenCode 服务器地址 | +| `OPENCODE_PORT` | 外部 OpenCode 端口 | +| `OPENCODE_SKIP_START` | 跳过启动 OpenCode | + +## 许可证 + +MIT \ No newline at end of file diff --git a/remote/xcopencodeweb/XCOpenCodeWeb.exe b/remote/xcopencodeweb/XCOpenCodeWeb.exe new file mode 100644 index 0000000..e9377dc Binary files /dev/null and b/remote/xcopencodeweb/XCOpenCodeWeb.exe differ diff --git a/service/xcopencodeweb/README.md b/service/xcopencodeweb/README.md new file mode 100644 index 0000000..afb0778 --- /dev/null +++ b/service/xcopencodeweb/README.md @@ -0,0 +1,99 @@ +# XCOpenCodeWeb + +XCOpenCodeWeb 是一个基于 Web 的 AI 编程助手界面,用于与 OpenCode 服务器交互。 + +## 快速开始 + +### 使用单文件 exe(推荐) + +直接下载 `XCOpenCodeWeb.exe`,双击运行: + +```bash +# 默认端口 3000 +XCOpenCodeWeb.exe + +# 指定端口 +XCOpenCodeWeb.exe --port 8080 + +# 查看帮助 +XCOpenCodeWeb.exe --help +``` + +启动后访问 http://localhost:3000 + +### 从源码运行 + +需要安装 [Bun](https://bun.sh) 或 Node.js 20+ + +```bash +# 安装依赖 +bun install + +# 构建前端 +bun run build + +# 启动服务器 +bun server/index.js --port 3000 +``` + +## 构建单文件 exe + +```bash +cd web +bun run build:exe +``` + +输出:`web/XCOpenCodeWeb.exe`(约 320MB) + +详细文档:[docs/single-file-exe-build.md](docs/single-file-exe-build.md) + +## 项目结构 + +``` +├── ui/ # 前端组件库 +├── web/ +│ ├── src/ # 前端源码 +│ ├── server/ # 后端服务器 +│ ├── bin/ # CLI 工具 +│ └── dist/ # 构建输出 +├── docs/ # 文档 +└── AGENTS.md # AI Agent 参考文档 +``` + +## 常用命令 + +```bash +# 开发模式 +bun run dev # 前端热更新 +bun run dev:server # 启动开发服务器 + +# 构建 +bun run build # 构建前端 +bun run build:exe # 构建单文件 exe + +# 代码检查 +bun run type-check:web # TypeScript 类型检查 +bun run lint:web # ESLint 检查 +``` + +## 依赖 + +- [Bun](https://bun.sh) - 运行时和打包工具 +- [React](https://react.dev) - 前端框架 +- [Express](https://expressjs.com) - 后端服务器 +- [Tailwind CSS](https://tailwindcss.com) - 样式框架 + +## 配置 + +### 环境变量 + +| 变量 | 说明 | +|------|------| +| `XCOpenCodeWeb_PORT` | 服务器端口(默认 3000) | +| `OPENCODE_HOST` | 外部 OpenCode 服务器地址 | +| `OPENCODE_PORT` | 外部 OpenCode 端口 | +| `OPENCODE_SKIP_START` | 跳过启动 OpenCode | + +## 许可证 + +MIT \ No newline at end of file diff --git a/service/xcopencodeweb/XCOpenCodeWeb.exe b/service/xcopencodeweb/XCOpenCodeWeb.exe new file mode 100644 index 0000000..e9377dc Binary files /dev/null and b/service/xcopencodeweb/XCOpenCodeWeb.exe differ