chore: 添加远程桌面控制组件、文档和构建产物
This commit is contained in:
6
console.txt
Normal file
6
console.txt
Normal file
@@ -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'
|
||||||
156
counter.py
Normal file
156
counter.py
Normal file
@@ -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")
|
||||||
12
dist-api/ai-GLAEBMJM.js
Normal file
12
dist-api/ai-GLAEBMJM.js
Normal file
@@ -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
|
||||||
|
};
|
||||||
15
dist-api/chunk-47DJ6YUB.js
Normal file
15
dist-api/chunk-47DJ6YUB.js
Normal file
@@ -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
|
||||||
|
};
|
||||||
39
dist-api/chunk-5EGA6GHY.js
Normal file
39
dist-api/chunk-5EGA6GHY.js
Normal file
@@ -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
|
||||||
|
};
|
||||||
110
dist-api/chunk-74TMTGBG.js
Normal file
110
dist-api/chunk-74TMTGBG.js
Normal file
@@ -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
|
||||||
|
};
|
||||||
163
dist-api/chunk-ER4KPD22.js
Normal file
163
dist-api/chunk-ER4KPD22.js
Normal file
@@ -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
|
||||||
|
};
|
||||||
20
dist-api/chunk-FTVFWJFJ.js
Normal file
20
dist-api/chunk-FTVFWJFJ.js
Normal file
@@ -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
|
||||||
|
};
|
||||||
234
dist-api/chunk-M2SZ5AIA.js
Normal file
234
dist-api/chunk-M2SZ5AIA.js
Normal file
@@ -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
|
||||||
|
};
|
||||||
912
dist-api/chunk-QS2CMBFP.js
Normal file
912
dist-api/chunk-QS2CMBFP.js
Normal file
@@ -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
|
||||||
|
};
|
||||||
586
dist-api/chunk-R5LQJNQE.js
Normal file
586
dist-api/chunk-R5LQJNQE.js
Normal file
@@ -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
|
||||||
|
};
|
||||||
313
dist-api/chunk-T5RPAMG6.js
Normal file
313
dist-api/chunk-T5RPAMG6.js
Normal file
@@ -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
|
||||||
|
};
|
||||||
125
dist-api/chunk-TSQNCXAS.js
Normal file
125
dist-api/chunk-TSQNCXAS.js
Normal file
@@ -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
|
||||||
|
};
|
||||||
399
dist-api/chunk-V2OWYGQG.js
Normal file
399
dist-api/chunk-V2OWYGQG.js
Normal file
@@ -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
|
||||||
|
};
|
||||||
280
dist-api/chunk-W5TDYTXE.js
Normal file
280
dist-api/chunk-W5TDYTXE.js
Normal file
@@ -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
|
||||||
|
};
|
||||||
32
dist-api/document-parser-FDZMXJVD.js
Normal file
32
dist-api/document-parser-FDZMXJVD.js
Normal file
@@ -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
|
||||||
|
};
|
||||||
14
dist-api/pydemos-W2QKOORB.js
Normal file
14
dist-api/pydemos-W2QKOORB.js
Normal file
@@ -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
|
||||||
|
};
|
||||||
14
dist-api/recycle-bin-VT62BD42.js
Normal file
14
dist-api/recycle-bin-VT62BD42.js
Normal file
@@ -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
|
||||||
|
};
|
||||||
16
dist-api/remote-KLYC364F.js
Normal file
16
dist-api/remote-KLYC364F.js
Normal file
@@ -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
|
||||||
|
};
|
||||||
28
dist-api/time-tracking-5F7NPQRY.js
Normal file
28
dist-api/time-tracking-5F7NPQRY.js
Normal file
@@ -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
|
||||||
|
};
|
||||||
33
dist-api/todo-GDTZZEQA.js
Normal file
33
dist-api/todo-GDTZZEQA.js
Normal file
@@ -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
|
||||||
|
};
|
||||||
2114
docs/vercel/VERCEL_GUIDE.md
Normal file
2114
docs/vercel/VERCEL_GUIDE.md
Normal file
File diff suppressed because it is too large
Load Diff
478
docs/vercel/VERCEL_MARKETPLACE.md
Normal file
478
docs/vercel/VERCEL_MARKETPLACE.md
Normal file
@@ -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
|
||||||
983
docs/vercel/vercel-mvp-system-v2.md
Normal file
983
docs/vercel/vercel-mvp-system-v2.md
Normal file
@@ -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*
|
||||||
@@ -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);
|
|
||||||
@@ -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();
|
|
||||||
@@ -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);
|
|
||||||
@@ -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);
|
|
||||||
});
|
|
||||||
@@ -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);
|
|
||||||
@@ -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);
|
|
||||||
@@ -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);
|
|
||||||
@@ -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);
|
|
||||||
99
remote/xcopencodeweb/README.md
Normal file
99
remote/xcopencodeweb/README.md
Normal file
@@ -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
|
||||||
BIN
remote/xcopencodeweb/XCOpenCodeWeb.exe
Normal file
BIN
remote/xcopencodeweb/XCOpenCodeWeb.exe
Normal file
Binary file not shown.
99
service/xcopencodeweb/README.md
Normal file
99
service/xcopencodeweb/README.md
Normal file
@@ -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
|
||||||
BIN
service/xcopencodeweb/XCOpenCodeWeb.exe
Normal file
BIN
service/xcopencodeweb/XCOpenCodeWeb.exe
Normal file
Binary file not shown.
Reference in New Issue
Block a user