feat: initialize VitePress CMS
This commit is contained in:
32
.gitignore
vendored
Normal file
32
.gitignore
vendored
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
# Dependencies
|
||||||
|
node_modules/
|
||||||
|
test-vitepress/node_modules/
|
||||||
|
|
||||||
|
# Build output
|
||||||
|
dist/
|
||||||
|
test-vitepress/.vitepress/dist/
|
||||||
|
test-vitepress/.vitepress/.temp/
|
||||||
|
test-vitepress/.vitepress/cache/
|
||||||
|
|
||||||
|
# Local data
|
||||||
|
data/
|
||||||
|
*.sqlite
|
||||||
|
*.sqlite-shm
|
||||||
|
*.sqlite-wal
|
||||||
|
|
||||||
|
# Environment
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
|
||||||
|
# OS / editor
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
112
README.md
Normal file
112
README.md
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
# VitePress-CMS
|
||||||
|
|
||||||
|
VitePress-CMS 是一个面向 VitePress 的可视化内容管理后台。它的目标是让用户通过网页界面管理 Markdown 页面、站点结构、主题配置与 GitHub 发布流程。
|
||||||
|
|
||||||
|
## 当前版本
|
||||||
|
|
||||||
|
当前项目已经从静态原型升级为 Vue 3 + TypeScript + Vite 工程。
|
||||||
|
|
||||||
|
已完成的原型能力:
|
||||||
|
|
||||||
|
- 页面目录、搜索与新建页面
|
||||||
|
- Markdown 编辑与预览切换
|
||||||
|
- Frontmatter 标题和描述编辑
|
||||||
|
- 导航栏与侧边栏管理界面
|
||||||
|
- 站点基础配置界面
|
||||||
|
- 发布中心与部署状态模拟
|
||||||
|
|
||||||
|
## 本地运行
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Windows PowerShell 如果拦截 `npm.ps1`,可以使用:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm.cmd install
|
||||||
|
npm.cmd run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
## 初始账户
|
||||||
|
|
||||||
|
项目启动时会自动创建 SQLite 数据库:
|
||||||
|
|
||||||
|
```text
|
||||||
|
data/vitepress-cms.sqlite
|
||||||
|
```
|
||||||
|
|
||||||
|
如果数据库里还没有系统管理员,会自动创建初始账户:
|
||||||
|
|
||||||
|
```text
|
||||||
|
邮箱:admin@example.com
|
||||||
|
密码:admin123456
|
||||||
|
角色:system_admin
|
||||||
|
```
|
||||||
|
|
||||||
|
可以通过环境变量修改初始账户:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
CMS_ADMIN_EMAIL=admin@your-domain.com
|
||||||
|
CMS_ADMIN_PASSWORD=change-me
|
||||||
|
```
|
||||||
|
|
||||||
|
## 构建验证
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
## 测试站点
|
||||||
|
|
||||||
|
VitePress 测试站点放在项目内的独立子目录:
|
||||||
|
|
||||||
|
```text
|
||||||
|
test-vitepress/
|
||||||
|
```
|
||||||
|
|
||||||
|
它有自己的 `package.json` 和依赖,不会让 VitePress 成为 CMS 主项目的一部分。这样既方便开发测试,也能保持主项目依赖干净。
|
||||||
|
|
||||||
|
运行测试站点:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd test-vitepress
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
## GitHub 连接
|
||||||
|
|
||||||
|
当前版本支持两种 GitHub 连接方式:
|
||||||
|
|
||||||
|
1. GitHub OAuth 登录后选择仓库。
|
||||||
|
2. 手动输入 token、owner、repo、branch 和站点目录。
|
||||||
|
|
||||||
|
使用 OAuth 登录前,需要创建 GitHub OAuth App,并配置环境变量:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
GITHUB_CLIENT_ID=xxx
|
||||||
|
GITHUB_CLIENT_SECRET=xxx
|
||||||
|
```
|
||||||
|
|
||||||
|
开发环境 callback URL:
|
||||||
|
|
||||||
|
```text
|
||||||
|
http://localhost:5173/api/github/callback
|
||||||
|
```
|
||||||
|
|
||||||
|
Token 或 OAuth 授权至少需要仓库内容读写权限。保存页面或配置时,CMS 会通过 GitHub Contents API 创建 commit。
|
||||||
|
|
||||||
|
站点目录示例:
|
||||||
|
|
||||||
|
- VitePress 在仓库根目录:留空
|
||||||
|
- VitePress 在 `docs/`:填写 `docs`
|
||||||
|
|
||||||
|
## 下一步
|
||||||
|
|
||||||
|
1. 建立 GitHub OAuth 登录流程。
|
||||||
|
2. 读取仓库中的 `docs/` 页面文件。
|
||||||
|
3. 解析 `.vitepress/config.ts` 中的常用配置。
|
||||||
|
4. 将页面和配置修改提交为 GitHub commit。
|
||||||
|
5. 显示 GitHub Pages 或 Actions 部署状态。
|
||||||
12
index.html
Normal file
12
index.html
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>VitePress-CMS</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
1422
package-lock.json
generated
Normal file
1422
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
23
package.json
Normal file
23
package.json
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"name": "vitepress-cms",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"description": "A visual CMS prototype for managing VitePress sites.",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "node server/index.mjs",
|
||||||
|
"dev:vite": "vite --host 0.0.0.0",
|
||||||
|
"build": "vue-tsc --noEmit && vite build",
|
||||||
|
"start": "node server/index.mjs",
|
||||||
|
"preview": "vite preview --host 0.0.0.0"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"vue": "^3.5.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-vue": "^5.1.0",
|
||||||
|
"typescript": "^5.5.0",
|
||||||
|
"vite": "^5.4.0",
|
||||||
|
"vue-tsc": "^2.1.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
45
server.mjs
Normal file
45
server.mjs
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { createReadStream, existsSync } from "node:fs";
|
||||||
|
import { extname, join, normalize } from "node:path";
|
||||||
|
import { createServer } from "node:http";
|
||||||
|
|
||||||
|
const port = Number(process.env.PORT || 5173);
|
||||||
|
const root = process.cwd();
|
||||||
|
|
||||||
|
const mimeTypes = {
|
||||||
|
".html": "text/html; charset=utf-8",
|
||||||
|
".js": "text/javascript; charset=utf-8",
|
||||||
|
".css": "text/css; charset=utf-8",
|
||||||
|
".json": "application/json; charset=utf-8",
|
||||||
|
".svg": "image/svg+xml",
|
||||||
|
".png": "image/png",
|
||||||
|
".jpg": "image/jpeg",
|
||||||
|
".jpeg": "image/jpeg",
|
||||||
|
};
|
||||||
|
|
||||||
|
function resolveRequest(url) {
|
||||||
|
const pathname = decodeURIComponent(new URL(url, `http://localhost:${port}`).pathname);
|
||||||
|
const requestedPath = normalize(join(root, pathname === "/" ? "index.html" : pathname));
|
||||||
|
|
||||||
|
if (!requestedPath.startsWith(root)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return requestedPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
createServer((request, response) => {
|
||||||
|
const filePath = resolveRequest(request.url || "/");
|
||||||
|
|
||||||
|
if (!filePath || !existsSync(filePath)) {
|
||||||
|
response.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
|
||||||
|
response.end("Not found");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
response.writeHead(200, {
|
||||||
|
"Content-Type": mimeTypes[extname(filePath)] || "application/octet-stream",
|
||||||
|
});
|
||||||
|
createReadStream(filePath).pipe(response);
|
||||||
|
}).listen(port, () => {
|
||||||
|
console.log(`VitePress-CMS prototype running at http://localhost:${port}`);
|
||||||
|
});
|
||||||
96
server/auth.mjs
Normal file
96
server/auth.mjs
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
import { db } from "./db.mjs";
|
||||||
|
import { json, readBody } from "./http.mjs";
|
||||||
|
import { clearSessionCookie, createSession, deleteSession, getSession, parseCookies, sessionCookie } from "./session.mjs";
|
||||||
|
import { verifyPassword } from "./security.mjs";
|
||||||
|
|
||||||
|
function publicUser(user) {
|
||||||
|
if (!user) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: user.id,
|
||||||
|
email: user.email,
|
||||||
|
name: user.name,
|
||||||
|
avatarUrl: user.avatar_url,
|
||||||
|
role: user.role,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRequestSession(request) {
|
||||||
|
const cookies = parseCookies(request.headers.cookie);
|
||||||
|
const sessionId = cookies.vpc_session;
|
||||||
|
const session = getSession(sessionId);
|
||||||
|
return { sessionId, session };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function requireCmsUser(request, response) {
|
||||||
|
const { session } = getRequestSession(request);
|
||||||
|
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
json(response, 401, { error: "Not signed in" });
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return session.user;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleMe(request, response) {
|
||||||
|
const { session } = getRequestSession(request);
|
||||||
|
json(response, 200, { user: session?.user ?? null });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleLogin(request, response) {
|
||||||
|
const body = JSON.parse(await readBody(request) || "{}");
|
||||||
|
const email = String(body.email || "").trim().toLowerCase();
|
||||||
|
const password = String(body.password || "");
|
||||||
|
|
||||||
|
if (!email || !password) {
|
||||||
|
json(response, 400, { error: "Email and password are required" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = db.prepare("SELECT * FROM users WHERE email = ?").get(email);
|
||||||
|
|
||||||
|
if (!user?.password_hash || !verifyPassword(password, user.password_hash)) {
|
||||||
|
json(response, 401, { error: "Invalid email or password" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionUser = publicUser(user);
|
||||||
|
const sessionId = createSession({
|
||||||
|
user: sessionUser,
|
||||||
|
});
|
||||||
|
|
||||||
|
json(response, 200, { user: sessionUser }, { "Set-Cookie": sessionCookie(sessionId) });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleLogout(request, response) {
|
||||||
|
const { sessionId } = getRequestSession(request);
|
||||||
|
deleteSession(sessionId);
|
||||||
|
json(response, 200, { ok: true }, { "Set-Cookie": clearSessionCookie() });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleAuthApi(request, response, url) {
|
||||||
|
try {
|
||||||
|
if (url.pathname === "/api/auth/me" && request.method === "GET") {
|
||||||
|
await handleMe(request, response);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.pathname === "/api/auth/login" && request.method === "POST") {
|
||||||
|
await handleLogin(request, response);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.pathname === "/api/auth/logout" && request.method === "POST") {
|
||||||
|
await handleLogout(request, response);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
} catch (error) {
|
||||||
|
json(response, 500, {
|
||||||
|
error: error instanceof Error ? error.message : "Unknown auth API error",
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
47
server/bootstrap.mjs
Normal file
47
server/bootstrap.mjs
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { db, getSetting, migrate, setSetting } from "./db.mjs";
|
||||||
|
import { hashPassword } from "./security.mjs";
|
||||||
|
|
||||||
|
const defaultAdminEmail = process.env.CMS_ADMIN_EMAIL || "admin@example.com";
|
||||||
|
const defaultAdminPassword = process.env.CMS_ADMIN_PASSWORD || "admin123456";
|
||||||
|
|
||||||
|
function seedAdmin() {
|
||||||
|
const admin = db.prepare("SELECT id FROM users WHERE role = 'system_admin' LIMIT 1").get();
|
||||||
|
|
||||||
|
if (admin) return;
|
||||||
|
|
||||||
|
db.prepare(`
|
||||||
|
INSERT INTO users (email, password_hash, name, role, email_verified)
|
||||||
|
VALUES (?, ?, ?, 'system_admin', 1)
|
||||||
|
`).run(defaultAdminEmail, hashPassword(defaultAdminPassword), "Administrator");
|
||||||
|
}
|
||||||
|
|
||||||
|
function seedSettings() {
|
||||||
|
const seeds = {
|
||||||
|
"site.title": "VitePress-CMS",
|
||||||
|
"auth.allow_email_login": "true",
|
||||||
|
"auth.allow_github_login": "true",
|
||||||
|
"auth.allow_registration": "false",
|
||||||
|
"smtp.host": "",
|
||||||
|
"smtp.port": "587",
|
||||||
|
"smtp.username": "",
|
||||||
|
"smtp.password": "",
|
||||||
|
"smtp.sender_email": "",
|
||||||
|
"github.api_base_url": "https://api.github.com",
|
||||||
|
"github.web_base_url": "https://github.com",
|
||||||
|
"github.oauth_client_id": "",
|
||||||
|
"github.oauth_client_secret": "",
|
||||||
|
};
|
||||||
|
|
||||||
|
Object.entries(seeds).forEach(([key, value]) => {
|
||||||
|
if (getSetting(key, undefined) === undefined) {
|
||||||
|
setSetting(key, value, key.includes("secret") || key.includes("password"));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function bootstrap() {
|
||||||
|
migrate();
|
||||||
|
seedAdmin();
|
||||||
|
seedSettings();
|
||||||
|
console.log(`Initial admin: ${defaultAdminEmail}`);
|
||||||
|
}
|
||||||
86
server/db.mjs
Normal file
86
server/db.mjs
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import { mkdirSync } from "node:fs";
|
||||||
|
import { dirname, join } from "node:path";
|
||||||
|
import { DatabaseSync } from "node:sqlite";
|
||||||
|
|
||||||
|
const dbPath = join(process.cwd(), "data", "vitepress-cms.sqlite");
|
||||||
|
mkdirSync(dirname(dbPath), { recursive: true });
|
||||||
|
|
||||||
|
export const db = new DatabaseSync(dbPath);
|
||||||
|
db.exec("PRAGMA foreign_keys = ON");
|
||||||
|
|
||||||
|
export function migrate() {
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
email TEXT UNIQUE,
|
||||||
|
password_hash TEXT,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
avatar_url TEXT,
|
||||||
|
role TEXT NOT NULL DEFAULT 'user',
|
||||||
|
email_verified INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS oauth_accounts (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
provider TEXT NOT NULL,
|
||||||
|
provider_user_id TEXT NOT NULL,
|
||||||
|
provider_login TEXT NOT NULL,
|
||||||
|
access_token TEXT,
|
||||||
|
avatar_url TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE(provider, provider_user_id),
|
||||||
|
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS projects (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
owner_user_id INTEGER NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
github_owner TEXT,
|
||||||
|
github_repo TEXT,
|
||||||
|
github_branch TEXT NOT NULL DEFAULT 'main',
|
||||||
|
site_root TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY(owner_user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS project_members (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
project_id INTEGER NOT NULL,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
role TEXT NOT NULL DEFAULT 'editor',
|
||||||
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE(project_id, user_id),
|
||||||
|
FOREIGN KEY(project_id) REFERENCES projects(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS system_settings (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT NOT NULL,
|
||||||
|
encrypted INTEGER NOT NULL DEFAULT 0,
|
||||||
|
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSetting(key, fallback = "") {
|
||||||
|
const row = db.prepare("SELECT value FROM system_settings WHERE key = ?").get(key);
|
||||||
|
return row?.value ?? fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setSetting(key, value, encrypted = false) {
|
||||||
|
db.prepare(`
|
||||||
|
INSERT INTO system_settings (key, value, encrypted, updated_at)
|
||||||
|
VALUES (?, ?, ?, CURRENT_TIMESTAMP)
|
||||||
|
ON CONFLICT(key) DO UPDATE SET
|
||||||
|
value = excluded.value,
|
||||||
|
encrypted = excluded.encrypted,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
`).run(key, value, encrypted ? 1 : 0);
|
||||||
|
}
|
||||||
577
server/github.mjs
Normal file
577
server/github.mjs
Normal file
@@ -0,0 +1,577 @@
|
|||||||
|
import { randomBytes } from "node:crypto";
|
||||||
|
import { db } from "./db.mjs";
|
||||||
|
import { json, readBody, redirect } from "./http.mjs";
|
||||||
|
import { getRequestSession } from "./auth.mjs";
|
||||||
|
import { createSession, sessionCookie, updateSession } from "./session.mjs";
|
||||||
|
|
||||||
|
const oauthStates = new Map();
|
||||||
|
|
||||||
|
function getBaseUrl(request) {
|
||||||
|
const configuredBaseUrl = process.env.GITHUB_OAUTH_REDIRECT_BASE_URL;
|
||||||
|
if (configuredBaseUrl) return configuredBaseUrl.replace(/\/$/, "");
|
||||||
|
|
||||||
|
const host = request.headers.host ?? "localhost:5173";
|
||||||
|
const protocol = host.startsWith("localhost") || host.startsWith("127.0.0.1") ? "http" : "https";
|
||||||
|
return `${protocol}://${host}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getOAuthConfig(request) {
|
||||||
|
const clientId = process.env.GITHUB_CLIENT_ID;
|
||||||
|
const clientSecret = process.env.GITHUB_CLIENT_SECRET;
|
||||||
|
const baseUrl = getBaseUrl(request);
|
||||||
|
|
||||||
|
if (!clientId || !clientSecret) {
|
||||||
|
throw new Error("请先配置 GITHUB_CLIENT_ID 和 GITHUB_CLIENT_SECRET");
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
clientId,
|
||||||
|
clientSecret,
|
||||||
|
redirectUri: `${baseUrl}/api/github/callback`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function githubFetch(session, path, init = {}) {
|
||||||
|
const response = await fetch(`https://api.github.com${path}`, {
|
||||||
|
...init,
|
||||||
|
headers: {
|
||||||
|
Accept: "application/vnd.github+json",
|
||||||
|
Authorization: `Bearer ${session.accessToken}`,
|
||||||
|
"X-GitHub-Api-Version": "2022-11-28",
|
||||||
|
...init.headers,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const details = await response.text();
|
||||||
|
throw new Error(`GitHub API error ${response.status}: ${details}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireSession(request, response) {
|
||||||
|
const { session } = getRequestSession(request);
|
||||||
|
|
||||||
|
if (!session?.github?.accessToken) {
|
||||||
|
json(response, 401, { error: "Not signed in" });
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...session,
|
||||||
|
accessToken: session.github.accessToken,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function publicCmsUser(user) {
|
||||||
|
return {
|
||||||
|
id: user.id,
|
||||||
|
email: user.email,
|
||||||
|
name: user.name,
|
||||||
|
avatarUrl: user.avatar_url,
|
||||||
|
role: user.role,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function findOrCreateGithubUser(githubUser, accessToken, currentSession) {
|
||||||
|
const providerUserId = String(githubUser.id);
|
||||||
|
const linkedAccount = db
|
||||||
|
.prepare("SELECT user_id FROM oauth_accounts WHERE provider = 'github' AND provider_user_id = ?")
|
||||||
|
.get(providerUserId);
|
||||||
|
|
||||||
|
let user;
|
||||||
|
|
||||||
|
if (linkedAccount) {
|
||||||
|
user = db.prepare("SELECT * FROM users WHERE id = ?").get(linkedAccount.user_id);
|
||||||
|
} else if (currentSession?.user?.id) {
|
||||||
|
user = db.prepare("SELECT * FROM users WHERE id = ?").get(currentSession.user.id);
|
||||||
|
} else {
|
||||||
|
const email = githubUser.email ? String(githubUser.email).toLowerCase() : null;
|
||||||
|
const existingByEmail = email ? db.prepare("SELECT * FROM users WHERE email = ?").get(email) : null;
|
||||||
|
|
||||||
|
if (existingByEmail) {
|
||||||
|
user = existingByEmail;
|
||||||
|
} else {
|
||||||
|
const result = db
|
||||||
|
.prepare("INSERT INTO users (email, name, avatar_url, role, email_verified) VALUES (?, ?, ?, 'user', ?)")
|
||||||
|
.run(email, githubUser.name || githubUser.login, githubUser.avatar_url, email ? 1 : 0);
|
||||||
|
user = db.prepare("SELECT * FROM users WHERE id = ?").get(result.lastInsertRowid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
db.prepare(`
|
||||||
|
INSERT INTO oauth_accounts (user_id, provider, provider_user_id, provider_login, access_token, avatar_url, updated_at)
|
||||||
|
VALUES (?, 'github', ?, ?, ?, ?, CURRENT_TIMESTAMP)
|
||||||
|
ON CONFLICT(provider, provider_user_id) DO UPDATE SET
|
||||||
|
user_id = excluded.user_id,
|
||||||
|
provider_login = excluded.provider_login,
|
||||||
|
access_token = excluded.access_token,
|
||||||
|
avatar_url = excluded.avatar_url,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
`).run(user.id, providerUserId, githubUser.login, accessToken, githubUser.avatar_url);
|
||||||
|
|
||||||
|
db.prepare("UPDATE users SET avatar_url = COALESCE(avatar_url, ?), updated_at = CURRENT_TIMESTAMP WHERE id = ?").run(
|
||||||
|
githubUser.avatar_url,
|
||||||
|
user.id,
|
||||||
|
);
|
||||||
|
|
||||||
|
return db.prepare("SELECT * FROM users WHERE id = ?").get(user.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleLogin(request, response) {
|
||||||
|
const { clientId, redirectUri } = getOAuthConfig(request);
|
||||||
|
const state = randomBytes(16).toString("hex");
|
||||||
|
oauthStates.set(state, Date.now());
|
||||||
|
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
client_id: clientId,
|
||||||
|
redirect_uri: redirectUri,
|
||||||
|
scope: "repo",
|
||||||
|
state,
|
||||||
|
});
|
||||||
|
|
||||||
|
redirect(response, `https://github.com/login/oauth/authorize?${params.toString()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCallback(request, response, url) {
|
||||||
|
const code = url.searchParams.get("code");
|
||||||
|
const state = url.searchParams.get("state");
|
||||||
|
|
||||||
|
if (!code || !state || !oauthStates.has(state)) {
|
||||||
|
redirect(response, "/?github=oauth_error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
oauthStates.delete(state);
|
||||||
|
const { clientId, clientSecret, redirectUri } = getOAuthConfig(request);
|
||||||
|
const tokenResponse = await fetch("https://github.com/login/oauth/access_token", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Accept: "application/json",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
client_id: clientId,
|
||||||
|
client_secret: clientSecret,
|
||||||
|
code,
|
||||||
|
redirect_uri: redirectUri,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const tokenPayload = await tokenResponse.json();
|
||||||
|
|
||||||
|
if (!tokenPayload.access_token) {
|
||||||
|
redirect(response, "/?github=token_error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const userResponse = await fetch("https://api.github.com/user", {
|
||||||
|
headers: {
|
||||||
|
Accept: "application/vnd.github+json",
|
||||||
|
Authorization: `Bearer ${tokenPayload.access_token}`,
|
||||||
|
"X-GitHub-Api-Version": "2022-11-28",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const user = await userResponse.json();
|
||||||
|
const { session: currentSession } = getRequestSession(request);
|
||||||
|
const cmsUser = findOrCreateGithubUser(user, tokenPayload.access_token, currentSession);
|
||||||
|
const sessionId = createSession({
|
||||||
|
user: publicCmsUser(cmsUser),
|
||||||
|
github: {
|
||||||
|
accessToken: tokenPayload.access_token,
|
||||||
|
login: user.login,
|
||||||
|
avatarUrl: user.avatar_url,
|
||||||
|
name: user.name,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
redirect(response, "/?github=connected", {
|
||||||
|
"Set-Cookie": sessionCookie(sessionId),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleMe(request, response) {
|
||||||
|
const { session } = getRequestSession(request);
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
json(response, 200, { user: null });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
json(response, 200, { user: session.github ?? null });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleLogout(request, response) {
|
||||||
|
const { sessionId, session } = getRequestSession(request);
|
||||||
|
if (session) {
|
||||||
|
const { github, ...restSession } = session;
|
||||||
|
updateSession(sessionId, restSession);
|
||||||
|
}
|
||||||
|
json(response, 200, { ok: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRepos(request, response) {
|
||||||
|
const session = requireSession(request, response);
|
||||||
|
if (!session) return;
|
||||||
|
|
||||||
|
const repos = await githubFetch(
|
||||||
|
session,
|
||||||
|
"/user/repos?per_page=100&sort=updated&affiliation=owner,collaborator,organization_member",
|
||||||
|
);
|
||||||
|
json(response, 200, {
|
||||||
|
repos: repos.map((repo) => ({
|
||||||
|
id: repo.id,
|
||||||
|
fullName: repo.full_name,
|
||||||
|
owner: repo.owner.login,
|
||||||
|
name: repo.name,
|
||||||
|
private: repo.private,
|
||||||
|
defaultBranch: repo.default_branch,
|
||||||
|
updatedAt: repo.updated_at,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCreateRepo(request, response) {
|
||||||
|
const session = requireSession(request, response);
|
||||||
|
if (!session) return;
|
||||||
|
|
||||||
|
const body = JSON.parse(await readBody(request) || "{}");
|
||||||
|
|
||||||
|
if (!body.name) {
|
||||||
|
json(response, 400, { error: "Repository name is required" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const repo = await githubFetch(session, "/user/repos", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: body.name,
|
||||||
|
private: Boolean(body.private),
|
||||||
|
auto_init: true,
|
||||||
|
description: body.description || "Created by VitePress-CMS",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
json(response, 201, {
|
||||||
|
repo: {
|
||||||
|
id: repo.id,
|
||||||
|
fullName: repo.full_name,
|
||||||
|
owner: repo.owner.login,
|
||||||
|
name: repo.name,
|
||||||
|
private: repo.private,
|
||||||
|
defaultBranch: repo.default_branch,
|
||||||
|
updatedAt: repo.updated_at,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeSiteRoot(siteRoot = "") {
|
||||||
|
return siteRoot.trim().replace(/^\/+|\/+$/g, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function toRepoPath(siteRoot, path) {
|
||||||
|
const normalizedRoot = normalizeSiteRoot(siteRoot);
|
||||||
|
return normalizedRoot ? `${normalizedRoot}/${path}` : path;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fromRepoPath(siteRoot, path) {
|
||||||
|
const normalizedRoot = normalizeSiteRoot(siteRoot);
|
||||||
|
return normalizedRoot && path.startsWith(`${normalizedRoot}/`) ? path.slice(normalizedRoot.length + 1) : path;
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeBase64Content(content) {
|
||||||
|
return Buffer.from(content.replace(/\n/g, ""), "base64").toString("utf-8");
|
||||||
|
}
|
||||||
|
|
||||||
|
function encodeBase64Content(content) {
|
||||||
|
return Buffer.from(content, "utf-8").toString("base64");
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseFrontmatter(source) {
|
||||||
|
const match = source.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/);
|
||||||
|
|
||||||
|
if (!match) {
|
||||||
|
return {
|
||||||
|
title: "",
|
||||||
|
description: "",
|
||||||
|
content: source,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const frontmatter = match[1];
|
||||||
|
const title = frontmatter.match(/^title:\s*(.+)$/m)?.[1]?.trim().replace(/^["']|["']$/g, "") ?? "";
|
||||||
|
const description =
|
||||||
|
frontmatter.match(/^description:\s*(.+)$/m)?.[1]?.trim().replace(/^["']|["']$/g, "") ?? "";
|
||||||
|
|
||||||
|
return {
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
content: source.slice(match[0].length),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringifyMarkdownPage(page) {
|
||||||
|
return [
|
||||||
|
"---",
|
||||||
|
`title: ${page.title || "未命名页面"}`,
|
||||||
|
`description: ${page.description || ""}`,
|
||||||
|
"---",
|
||||||
|
"",
|
||||||
|
page.content.trimStart(),
|
||||||
|
].join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
function readStringValue(source, key, fallback = "") {
|
||||||
|
const pattern = new RegExp(`${key}:\\s*["'\`]([^"'\`]+)["'\`]`);
|
||||||
|
return source.match(pattern)?.[1] ?? fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readBooleanValue(source, key, fallback = false) {
|
||||||
|
const pattern = new RegExp(`${key}:\\s*(true|false)`);
|
||||||
|
const value = source.match(pattern)?.[1];
|
||||||
|
return value ? value === "true" : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseSiteSettings(source) {
|
||||||
|
const socialLinksSource = source.match(/socialLinks:\s*\[([\s\S]*?)\]/)?.[1] ?? "";
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: readStringValue(source, "title", "VitePress Site"),
|
||||||
|
description: readStringValue(source, "description"),
|
||||||
|
logo: readStringValue(source, "logo", "/logo.svg"),
|
||||||
|
lastUpdated: readBooleanValue(source, "lastUpdated", true),
|
||||||
|
localSearch: /search:\s*{[\s\S]*?provider:\s*["']local["'][\s\S]*?}/.test(source),
|
||||||
|
outline: readBooleanValue(source, "outline", true),
|
||||||
|
socialKind: readStringValue(socialLinksSource, "icon", "github"),
|
||||||
|
socialLink: readStringValue(socialLinksSource, "link", "https://github.com/example/vitepress-cms"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringifySiteConfig(settings) {
|
||||||
|
const searchConfig = settings.localSearch
|
||||||
|
? ` search: {
|
||||||
|
provider: "local",
|
||||||
|
},`
|
||||||
|
: "";
|
||||||
|
|
||||||
|
return `import { defineConfig } from "vitepress";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
title: ${JSON.stringify(settings.title)},
|
||||||
|
description: ${JSON.stringify(settings.description)},
|
||||||
|
lastUpdated: ${settings.lastUpdated},
|
||||||
|
themeConfig: {
|
||||||
|
logo: ${JSON.stringify(settings.logo)},
|
||||||
|
outline: ${settings.outline},
|
||||||
|
nav: [
|
||||||
|
{ text: "首页", link: "/" },
|
||||||
|
{ text: "指南", link: "/guide/getting-started" },
|
||||||
|
{ text: "配置", link: "/guide/config" },
|
||||||
|
{ text: "更新日志", link: "/changelog" },
|
||||||
|
],
|
||||||
|
sidebar: {
|
||||||
|
"/guide/": [
|
||||||
|
{
|
||||||
|
text: "指南",
|
||||||
|
items: [
|
||||||
|
{ text: "快速开始", link: "/guide/getting-started" },
|
||||||
|
{ text: "页面管理", link: "/guide/pages" },
|
||||||
|
{ text: "配置说明", link: "/guide/config" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
socialLinks: [
|
||||||
|
{ icon: ${JSON.stringify(settings.socialKind)}, link: ${JSON.stringify(settings.socialLink)} },
|
||||||
|
],
|
||||||
|
footer: {
|
||||||
|
message: "Powered by VitePress-CMS",
|
||||||
|
copyright: "Copyright 2026",
|
||||||
|
},
|
||||||
|
${searchConfig}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSiteQuery(url) {
|
||||||
|
const owner = url.searchParams.get("owner");
|
||||||
|
const repo = url.searchParams.get("repo");
|
||||||
|
const branch = url.searchParams.get("branch") || "main";
|
||||||
|
const siteRoot = url.searchParams.get("siteRoot") || "";
|
||||||
|
|
||||||
|
if (!owner || !repo) {
|
||||||
|
throw new Error("owner and repo are required");
|
||||||
|
}
|
||||||
|
|
||||||
|
return { owner, repo, branch, siteRoot };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getRepoContent(session, site, path) {
|
||||||
|
const repoPath = toRepoPath(site.siteRoot, path);
|
||||||
|
const encodedPath = repoPath.split("/").map(encodeURIComponent).join("/");
|
||||||
|
return githubFetch(
|
||||||
|
session,
|
||||||
|
`/repos/${site.owner}/${site.repo}/contents/${encodedPath}?ref=${encodeURIComponent(site.branch)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSitePages(request, response, url) {
|
||||||
|
const session = requireSession(request, response);
|
||||||
|
if (!session) return;
|
||||||
|
|
||||||
|
if (request.method === "GET") {
|
||||||
|
const site = getSiteQuery(url);
|
||||||
|
const tree = await githubFetch(
|
||||||
|
session,
|
||||||
|
`/repos/${site.owner}/${site.repo}/git/trees/${encodeURIComponent(site.branch)}?recursive=1`,
|
||||||
|
);
|
||||||
|
const siteRoot = normalizeSiteRoot(site.siteRoot);
|
||||||
|
const prefix = siteRoot ? `${siteRoot}/` : "";
|
||||||
|
const markdownFiles = tree.tree
|
||||||
|
.filter((entry) => entry.type === "blob")
|
||||||
|
.filter((entry) => entry.path.endsWith(".md"))
|
||||||
|
.filter((entry) => !entry.path.includes("/node_modules/"))
|
||||||
|
.filter((entry) => entry.path !== `${prefix}README.md`)
|
||||||
|
.filter((entry) => !prefix || entry.path.startsWith(prefix));
|
||||||
|
const pages = await Promise.all(
|
||||||
|
markdownFiles.sort((a, b) => a.path.localeCompare(b.path)).map(async (entry) => {
|
||||||
|
const file = await getRepoContent(session, site, fromRepoPath(site.siteRoot, entry.path));
|
||||||
|
const source = decodeBase64Content(file.content);
|
||||||
|
const parsed = parseFrontmatter(source);
|
||||||
|
const path = fromRepoPath(site.siteRoot, entry.path);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: path,
|
||||||
|
path,
|
||||||
|
sha: file.sha,
|
||||||
|
title: parsed.title || path.replace(/\.md$/, ""),
|
||||||
|
description: parsed.description,
|
||||||
|
content: parsed.content,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
json(response, 200, { pages });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.method === "PUT") {
|
||||||
|
const body = JSON.parse(await readBody(request) || "{}");
|
||||||
|
const site = body.site;
|
||||||
|
const page = body.page;
|
||||||
|
const repoPath = toRepoPath(site.siteRoot, page.path);
|
||||||
|
const encodedPath = repoPath.split("/").map(encodeURIComponent).join("/");
|
||||||
|
const result = await githubFetch(session, `/repos/${site.owner}/${site.repo}/contents/${encodedPath}`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
message: `docs: update ${page.path} from VitePress-CMS`,
|
||||||
|
content: encodeBase64Content(stringifyMarkdownPage(page)),
|
||||||
|
branch: site.branch,
|
||||||
|
sha: page.sha,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
json(response, 200, { sha: result.content.sha });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
json(response, 405, { error: "Method not allowed" });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSiteSettings(request, response, url) {
|
||||||
|
const session = requireSession(request, response);
|
||||||
|
if (!session) return;
|
||||||
|
|
||||||
|
if (request.method === "GET") {
|
||||||
|
const site = getSiteQuery(url);
|
||||||
|
const file = await getRepoContent(session, site, ".vitepress/config.ts");
|
||||||
|
json(response, 200, {
|
||||||
|
settings: parseSiteSettings(decodeBase64Content(file.content)),
|
||||||
|
sha: file.sha,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.method === "PUT") {
|
||||||
|
const body = JSON.parse(await readBody(request) || "{}");
|
||||||
|
const site = body.site;
|
||||||
|
const settings = body.settings;
|
||||||
|
const repoPath = toRepoPath(site.siteRoot, ".vitepress/config.ts");
|
||||||
|
const encodedPath = repoPath.split("/").map(encodeURIComponent).join("/");
|
||||||
|
const result = await githubFetch(session, `/repos/${site.owner}/${site.repo}/contents/${encodedPath}`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
message: "docs: update VitePress config from VitePress-CMS",
|
||||||
|
content: encodeBase64Content(stringifySiteConfig(settings)),
|
||||||
|
branch: site.branch,
|
||||||
|
sha: body.sha,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
json(response, 200, { sha: result.content.sha });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
json(response, 405, { error: "Method not allowed" });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleGithubApi(request, response, url) {
|
||||||
|
try {
|
||||||
|
if (url.pathname === "/api/github/login" && request.method === "GET") {
|
||||||
|
await handleLogin(request, response);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.pathname === "/api/github/callback" && request.method === "GET") {
|
||||||
|
await handleCallback(request, response, url);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.pathname === "/api/github/me" && request.method === "GET") {
|
||||||
|
await handleMe(request, response);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.pathname === "/api/github/logout" && request.method === "POST") {
|
||||||
|
await handleLogout(request, response);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.pathname === "/api/github/repos" && request.method === "GET") {
|
||||||
|
await handleRepos(request, response);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.pathname === "/api/github/repos" && request.method === "POST") {
|
||||||
|
await handleCreateRepo(request, response);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.pathname === "/api/github/site/pages") {
|
||||||
|
await handleSitePages(request, response, url);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.pathname === "/api/github/site/settings") {
|
||||||
|
await handleSiteSettings(request, response, url);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
} catch (error) {
|
||||||
|
json(response, 500, {
|
||||||
|
error: error instanceof Error ? error.message : "Unknown GitHub API error",
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
26
server/http.mjs
Normal file
26
server/http.mjs
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
export function json(response, status, payload, headers = {}) {
|
||||||
|
response.writeHead(status, {
|
||||||
|
"Content-Type": "application/json; charset=utf-8",
|
||||||
|
...headers,
|
||||||
|
});
|
||||||
|
response.end(JSON.stringify(payload));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function redirect(response, location, headers = {}) {
|
||||||
|
response.writeHead(302, {
|
||||||
|
Location: location,
|
||||||
|
...headers,
|
||||||
|
});
|
||||||
|
response.end();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readBody(request) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
let body = "";
|
||||||
|
request.on("data", (chunk) => {
|
||||||
|
body += chunk;
|
||||||
|
});
|
||||||
|
request.on("end", () => resolve(body));
|
||||||
|
request.on("error", reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
81
server/index.mjs
Normal file
81
server/index.mjs
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import { createReadStream, existsSync } from "node:fs";
|
||||||
|
import { extname, join, normalize } from "node:path";
|
||||||
|
import { createServer } from "node:http";
|
||||||
|
import { createServer as createViteServer } from "vite";
|
||||||
|
import { bootstrap } from "./bootstrap.mjs";
|
||||||
|
import { handleAuthApi } from "./auth.mjs";
|
||||||
|
import { handleGithubApi } from "./github.mjs";
|
||||||
|
import { handleSettingsApi } from "./settings.mjs";
|
||||||
|
|
||||||
|
const isProduction = process.env.NODE_ENV === "production";
|
||||||
|
const port = Number(process.env.PORT || 5173);
|
||||||
|
const root = process.cwd();
|
||||||
|
|
||||||
|
bootstrap();
|
||||||
|
|
||||||
|
const mimeTypes = {
|
||||||
|
".html": "text/html; charset=utf-8",
|
||||||
|
".js": "text/javascript; charset=utf-8",
|
||||||
|
".css": "text/css; charset=utf-8",
|
||||||
|
".json": "application/json; charset=utf-8",
|
||||||
|
".svg": "image/svg+xml",
|
||||||
|
};
|
||||||
|
|
||||||
|
const vite = isProduction
|
||||||
|
? undefined
|
||||||
|
: await createViteServer({
|
||||||
|
server: {
|
||||||
|
middlewareMode: true,
|
||||||
|
host: "0.0.0.0",
|
||||||
|
},
|
||||||
|
appType: "spa",
|
||||||
|
});
|
||||||
|
|
||||||
|
function sendNotFound(response) {
|
||||||
|
response.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
|
||||||
|
response.end("Not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
function serveStatic(request, response) {
|
||||||
|
const pathname = decodeURIComponent(new URL(request.url ?? "/", `http://localhost:${port}`).pathname);
|
||||||
|
const requestedPath = normalize(join(root, "dist", pathname === "/" ? "index.html" : pathname));
|
||||||
|
const fallbackPath = join(root, "dist", "index.html");
|
||||||
|
const filePath = existsSync(requestedPath) ? requestedPath : fallbackPath;
|
||||||
|
|
||||||
|
if (!filePath.startsWith(join(root, "dist")) || !existsSync(filePath)) {
|
||||||
|
sendNotFound(response);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
response.writeHead(200, {
|
||||||
|
"Content-Type": mimeTypes[extname(filePath)] || "application/octet-stream",
|
||||||
|
});
|
||||||
|
createReadStream(filePath).pipe(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
const server = createServer(async (request, response) => {
|
||||||
|
const url = new URL(request.url ?? "/", `http://localhost:${port}`);
|
||||||
|
|
||||||
|
if (await handleAuthApi(request, response, url)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (await handleSettingsApi(request, response, url)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (await handleGithubApi(request, response, url)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (vite) {
|
||||||
|
vite.middlewares(request, response, () => sendNotFound(response));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
serveStatic(request, response);
|
||||||
|
});
|
||||||
|
|
||||||
|
server.listen(port, "0.0.0.0", () => {
|
||||||
|
console.log(`VitePress-CMS running at http://localhost:${port}`);
|
||||||
|
});
|
||||||
24
server/security.mjs
Normal file
24
server/security.mjs
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { pbkdf2Sync, randomBytes, timingSafeEqual } from "node:crypto";
|
||||||
|
|
||||||
|
const iterations = 120000;
|
||||||
|
const keyLength = 32;
|
||||||
|
const digest = "sha256";
|
||||||
|
|
||||||
|
export function hashPassword(password) {
|
||||||
|
const salt = randomBytes(16).toString("hex");
|
||||||
|
const hash = pbkdf2Sync(password, salt, iterations, keyLength, digest).toString("hex");
|
||||||
|
return `pbkdf2:${iterations}:${salt}:${hash}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function verifyPassword(password, storedHash) {
|
||||||
|
const [scheme, storedIterations, salt, hash] = storedHash.split(":");
|
||||||
|
|
||||||
|
if (scheme !== "pbkdf2" || !storedIterations || !salt || !hash) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidate = pbkdf2Sync(password, salt, Number(storedIterations), keyLength, digest);
|
||||||
|
const expected = Buffer.from(hash, "hex");
|
||||||
|
|
||||||
|
return candidate.length === expected.length && timingSafeEqual(candidate, expected);
|
||||||
|
}
|
||||||
59
server/session.mjs
Normal file
59
server/session.mjs
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import { randomBytes } from "node:crypto";
|
||||||
|
|
||||||
|
const sessions = new Map();
|
||||||
|
|
||||||
|
export function createSession(data) {
|
||||||
|
const id = randomBytes(24).toString("hex");
|
||||||
|
sessions.set(id, {
|
||||||
|
...data,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
});
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSession(id) {
|
||||||
|
if (!id) return undefined;
|
||||||
|
return sessions.get(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateSession(id, data) {
|
||||||
|
if (!id || !sessions.has(id)) return undefined;
|
||||||
|
const nextSession = {
|
||||||
|
...sessions.get(id),
|
||||||
|
...data,
|
||||||
|
};
|
||||||
|
sessions.set(id, nextSession);
|
||||||
|
return nextSession;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteSession(id) {
|
||||||
|
if (!id) return;
|
||||||
|
sessions.delete(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseCookies(cookieHeader = "") {
|
||||||
|
return Object.fromEntries(
|
||||||
|
cookieHeader
|
||||||
|
.split(";")
|
||||||
|
.map((item) => item.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((item) => {
|
||||||
|
const index = item.indexOf("=");
|
||||||
|
return [item.slice(0, index), decodeURIComponent(item.slice(index + 1))];
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sessionCookie(id) {
|
||||||
|
return [
|
||||||
|
`vpc_session=${encodeURIComponent(id)}`,
|
||||||
|
"Path=/",
|
||||||
|
"HttpOnly",
|
||||||
|
"SameSite=Lax",
|
||||||
|
"Max-Age=2592000",
|
||||||
|
].join("; ");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearSessionCookie() {
|
||||||
|
return "vpc_session=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0";
|
||||||
|
}
|
||||||
70
server/settings.mjs
Normal file
70
server/settings.mjs
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import { db, setSetting } from "./db.mjs";
|
||||||
|
import { requireCmsUser } from "./auth.mjs";
|
||||||
|
import { json, readBody } from "./http.mjs";
|
||||||
|
|
||||||
|
function requireAdmin(request, response) {
|
||||||
|
const user = requireCmsUser(request, response);
|
||||||
|
|
||||||
|
if (!user) return undefined;
|
||||||
|
|
||||||
|
if (user.role !== "system_admin") {
|
||||||
|
json(response, 403, { error: "System admin permission is required" });
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
function publicSetting(row) {
|
||||||
|
return {
|
||||||
|
key: row.key,
|
||||||
|
value: row.encrypted ? "" : row.value,
|
||||||
|
encrypted: Boolean(row.encrypted),
|
||||||
|
updatedAt: row.updated_at,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleGetSettings(request, response) {
|
||||||
|
if (!requireAdmin(request, response)) return;
|
||||||
|
|
||||||
|
const rows = db
|
||||||
|
.prepare("SELECT key, value, encrypted, updated_at FROM system_settings ORDER BY key")
|
||||||
|
.all();
|
||||||
|
|
||||||
|
json(response, 200, { settings: rows.map(publicSetting) });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSaveSettings(request, response) {
|
||||||
|
if (!requireAdmin(request, response)) return;
|
||||||
|
|
||||||
|
const body = JSON.parse(await readBody(request) || "{}");
|
||||||
|
const settings = Array.isArray(body.settings) ? body.settings : [];
|
||||||
|
|
||||||
|
settings.forEach((setting) => {
|
||||||
|
if (!setting.key) return;
|
||||||
|
setSetting(String(setting.key), String(setting.value ?? ""), Boolean(setting.encrypted));
|
||||||
|
});
|
||||||
|
|
||||||
|
json(response, 200, { ok: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleSettingsApi(request, response, url) {
|
||||||
|
try {
|
||||||
|
if (url.pathname === "/api/admin/settings" && request.method === "GET") {
|
||||||
|
await handleGetSettings(request, response);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.pathname === "/api/admin/settings" && request.method === "PUT") {
|
||||||
|
await handleSaveSettings(request, response);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
} catch (error) {
|
||||||
|
json(response, 500, {
|
||||||
|
error: error instanceof Error ? error.message : "Unknown settings API error",
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
465
src/App.vue
Normal file
465
src/App.vue
Normal file
@@ -0,0 +1,465 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, reactive, ref } from "vue";
|
||||||
|
import {
|
||||||
|
loadGitHubPages,
|
||||||
|
loadGitHubSettings,
|
||||||
|
saveGitHubPage,
|
||||||
|
saveGitHubSettings,
|
||||||
|
} from "./api/githubSite";
|
||||||
|
import { loadCurrentUser, loginWithEmail, logoutCms } from "./api/auth";
|
||||||
|
import {
|
||||||
|
createGitHubRepository,
|
||||||
|
loadGitHubRepositories,
|
||||||
|
loadGitHubUser,
|
||||||
|
logoutGitHub,
|
||||||
|
startGitHubLogin,
|
||||||
|
} from "./api/githubAuth";
|
||||||
|
import {
|
||||||
|
loadLocalPages,
|
||||||
|
loadLocalSettings,
|
||||||
|
saveLocalPage,
|
||||||
|
saveLocalSettings,
|
||||||
|
} from "./api/localSite";
|
||||||
|
import AppSidebar from "./components/AppSidebar.vue";
|
||||||
|
import ConfigPanel from "./components/ConfigPanel.vue";
|
||||||
|
import ConnectPanel from "./components/ConnectPanel.vue";
|
||||||
|
import ContentPanel from "./components/ContentPanel.vue";
|
||||||
|
import LoginView from "./components/LoginView.vue";
|
||||||
|
import PublishPanel from "./components/PublishPanel.vue";
|
||||||
|
import StructurePanel from "./components/StructurePanel.vue";
|
||||||
|
import { mockNavItems, mockPages, mockSidebarGroups, mockSiteSettings } from "./data/mockSite";
|
||||||
|
import type { CmsUser, DataSource, DocPage, GitHubConnection, GitHubRepository, GitHubUser, PanelKey } from "./types";
|
||||||
|
|
||||||
|
const panels: Array<{ key: PanelKey; label: string; icon: string }> = [
|
||||||
|
{ key: "connect", label: "仓库连接", icon: "G" },
|
||||||
|
{ key: "content", label: "页面管理", icon: "P" },
|
||||||
|
{ key: "structure", label: "站点结构", icon: "S" },
|
||||||
|
{ key: "config", label: "主题配置", icon: "C" },
|
||||||
|
{ key: "publish", label: "发布中心", icon: "R" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const githubConnection = reactive<GitHubConnection>({
|
||||||
|
token: "",
|
||||||
|
owner: "",
|
||||||
|
repo: "",
|
||||||
|
branch: "main",
|
||||||
|
siteRoot: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
const activePanel = ref<PanelKey>("content");
|
||||||
|
const dataSource = ref<DataSource>("local");
|
||||||
|
const hasGitHubConnection = ref(false);
|
||||||
|
const pages = reactive<DocPage[]>([]);
|
||||||
|
const navItems = reactive(mockNavItems.map((item) => ({ ...item })));
|
||||||
|
const sidebarGroups = reactive(
|
||||||
|
mockSidebarGroups.map((group) => ({
|
||||||
|
...group,
|
||||||
|
items: group.items.map((item) => ({ ...item })),
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
const siteSettings = reactive({ ...mockSiteSettings });
|
||||||
|
const activePageId = ref("");
|
||||||
|
const settingsSha = ref<string>();
|
||||||
|
const isLoadingPages = ref(false);
|
||||||
|
const isConnecting = ref(false);
|
||||||
|
const isLoadingRepos = ref(false);
|
||||||
|
const pageSaveState = ref<"idle" | "saving" | "saved" | "error">("idle");
|
||||||
|
const settingsSaveState = ref<"idle" | "saving" | "saved" | "error">("idle");
|
||||||
|
const githubUser = ref<GitHubUser | null>(null);
|
||||||
|
const currentUser = ref<CmsUser | null>(null);
|
||||||
|
const repositories = ref<GitHubRepository[]>([]);
|
||||||
|
const isAuthLoading = ref(true);
|
||||||
|
const loginError = ref("");
|
||||||
|
const activityItems = ref(["等待读取站点"]);
|
||||||
|
|
||||||
|
const activePage = computed(() => pages.find((page) => page.id === activePageId.value) ?? pages[0]);
|
||||||
|
const activePanelTitle = computed(() => panels.find((panel) => panel.key === activePanel.value)?.label ?? "");
|
||||||
|
const repoLabel = computed(() => {
|
||||||
|
if (dataSource.value === "github" && hasGitHubConnection.value) {
|
||||||
|
return `${githubConnection.owner}/${githubConnection.repo}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return "test-vitepress";
|
||||||
|
});
|
||||||
|
const repoBranch = computed(() =>
|
||||||
|
dataSource.value === "github" && hasGitHubConnection.value ? githubConnection.branch : "local",
|
||||||
|
);
|
||||||
|
const repoRoot = computed(() =>
|
||||||
|
dataSource.value === "github" && hasGitHubConnection.value ? githubConnection.siteRoot || "/" : "test-vitepress/",
|
||||||
|
);
|
||||||
|
|
||||||
|
function normalizeConnection(connection: GitHubConnection): GitHubConnection {
|
||||||
|
return {
|
||||||
|
token: connection.token.trim(),
|
||||||
|
owner: connection.owner.trim(),
|
||||||
|
repo: connection.repo.trim(),
|
||||||
|
branch: connection.branch.trim() || "main",
|
||||||
|
siteRoot: connection.siteRoot.trim().replace(/^\/+|\/+$/g, ""),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateConnection(connection: GitHubConnection) {
|
||||||
|
if (!connection.owner || !connection.repo || !connection.branch) {
|
||||||
|
throw new Error("请填写 GitHub owner、repo 和 branch 后再连接");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function replacePages(nextPages: DocPage[]) {
|
||||||
|
pages.splice(0, pages.length, ...nextPages.map((page) => ({ ...page })));
|
||||||
|
activePageId.value = pages[0]?.id ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadPagesForSource(source: DataSource, connection = githubConnection) {
|
||||||
|
if (source === "github") {
|
||||||
|
return loadGitHubPages(connection);
|
||||||
|
}
|
||||||
|
|
||||||
|
return loadLocalPages();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSettingsForSource(source: DataSource, connection = githubConnection) {
|
||||||
|
if (source === "github") {
|
||||||
|
const result = await loadGitHubSettings(connection);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
settings: await loadLocalSettings(),
|
||||||
|
sha: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadPages() {
|
||||||
|
isLoadingPages.value = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const nextPages = await loadPagesForSource(dataSource.value);
|
||||||
|
replacePages(nextPages);
|
||||||
|
activityItems.value = [
|
||||||
|
`已从 ${dataSource.value === "github" ? "GitHub" : "test-vitepress"} 读取 ${nextPages.length} 个 Markdown 页面`,
|
||||||
|
...activityItems.value,
|
||||||
|
];
|
||||||
|
} catch (error) {
|
||||||
|
replacePages(mockPages.map((page) => ({ ...page })));
|
||||||
|
activityItems.value = [
|
||||||
|
"读取页面失败,已回退到 mock 数据",
|
||||||
|
error instanceof Error ? error.message : "未知错误",
|
||||||
|
...activityItems.value,
|
||||||
|
];
|
||||||
|
} finally {
|
||||||
|
isLoadingPages.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSettings() {
|
||||||
|
try {
|
||||||
|
const result = await loadSettingsForSource(dataSource.value);
|
||||||
|
Object.assign(siteSettings, result.settings);
|
||||||
|
settingsSha.value = result.sha;
|
||||||
|
activityItems.value = [
|
||||||
|
`已读取 ${dataSource.value === "github" ? "GitHub" : "test-vitepress"} 的 VitePress 配置`,
|
||||||
|
...activityItems.value,
|
||||||
|
];
|
||||||
|
} catch (error) {
|
||||||
|
activityItems.value = [
|
||||||
|
"读取站点配置失败,已使用默认配置",
|
||||||
|
error instanceof Error ? error.message : "未知错误",
|
||||||
|
...activityItems.value,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reloadSite() {
|
||||||
|
await Promise.all([loadPages(), loadSettings()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadAuthState() {
|
||||||
|
try {
|
||||||
|
currentUser.value = await loadCurrentUser();
|
||||||
|
githubUser.value = await loadGitHubUser();
|
||||||
|
|
||||||
|
if (githubUser.value) {
|
||||||
|
await refreshRepositories();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
activityItems.value = [
|
||||||
|
error instanceof Error ? error.message : "读取 GitHub 登录状态失败",
|
||||||
|
...activityItems.value,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleEmailLogin(payload: { email: string; password: string }) {
|
||||||
|
isAuthLoading.value = true;
|
||||||
|
loginError.value = "";
|
||||||
|
|
||||||
|
try {
|
||||||
|
currentUser.value = await loginWithEmail(payload.email, payload.password);
|
||||||
|
await loadAuthState();
|
||||||
|
await reloadSite();
|
||||||
|
} catch (error) {
|
||||||
|
loginError.value = error instanceof Error ? error.message : "登录失败";
|
||||||
|
} finally {
|
||||||
|
isAuthLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleLogout() {
|
||||||
|
await logoutCms();
|
||||||
|
currentUser.value = null;
|
||||||
|
githubUser.value = null;
|
||||||
|
repositories.value = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshRepositories() {
|
||||||
|
isLoadingRepos.value = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
repositories.value = await loadGitHubRepositories();
|
||||||
|
activityItems.value = [`已读取 ${repositories.value.length} 个 GitHub 仓库`, ...activityItems.value];
|
||||||
|
} catch (error) {
|
||||||
|
activityItems.value = [
|
||||||
|
error instanceof Error ? error.message : "读取 GitHub 仓库列表失败",
|
||||||
|
...activityItems.value,
|
||||||
|
];
|
||||||
|
} finally {
|
||||||
|
isLoadingRepos.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function connectGithub(connection: GitHubConnection) {
|
||||||
|
const nextConnection = normalizeConnection(connection);
|
||||||
|
|
||||||
|
try {
|
||||||
|
validateConnection(nextConnection);
|
||||||
|
} catch (error) {
|
||||||
|
activityItems.value = [
|
||||||
|
error instanceof Error ? error.message : "连接信息不完整",
|
||||||
|
...activityItems.value,
|
||||||
|
];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isConnecting.value = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [nextPages, nextSettings] = await Promise.all([
|
||||||
|
loadPagesForSource("github", nextConnection),
|
||||||
|
loadSettingsForSource("github", nextConnection),
|
||||||
|
]);
|
||||||
|
|
||||||
|
Object.assign(githubConnection, nextConnection);
|
||||||
|
dataSource.value = "github";
|
||||||
|
hasGitHubConnection.value = true;
|
||||||
|
replacePages(nextPages);
|
||||||
|
Object.assign(siteSettings, nextSettings.settings);
|
||||||
|
settingsSha.value = nextSettings.sha;
|
||||||
|
activePanel.value = "content";
|
||||||
|
activityItems.value = [`已连接 GitHub 仓库 ${githubConnection.owner}/${githubConnection.repo}`, ...activityItems.value];
|
||||||
|
} catch (error) {
|
||||||
|
hasGitHubConnection.value = false;
|
||||||
|
dataSource.value = "local";
|
||||||
|
activityItems.value = [
|
||||||
|
"GitHub 仓库连接失败,仍保持本地测试站点模式",
|
||||||
|
error instanceof Error ? error.message : "未知错误",
|
||||||
|
...activityItems.value,
|
||||||
|
];
|
||||||
|
} finally {
|
||||||
|
isConnecting.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function useLocalSource() {
|
||||||
|
dataSource.value = "local";
|
||||||
|
hasGitHubConnection.value = false;
|
||||||
|
settingsSha.value = undefined;
|
||||||
|
await reloadSite();
|
||||||
|
activePanel.value = "content";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function signOutGithub() {
|
||||||
|
await logoutGitHub();
|
||||||
|
githubUser.value = null;
|
||||||
|
repositories.value = [];
|
||||||
|
await useLocalSource();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createRepository(payload: { name: string; private: boolean }) {
|
||||||
|
try {
|
||||||
|
const repo = await createGitHubRepository(payload);
|
||||||
|
repositories.value = [repo, ...repositories.value];
|
||||||
|
activityItems.value = [`已创建 GitHub 仓库 ${repo.fullName}`, ...activityItems.value];
|
||||||
|
} catch (error) {
|
||||||
|
activityItems.value = [
|
||||||
|
error instanceof Error ? error.message : "创建 GitHub 仓库失败",
|
||||||
|
...activityItems.value,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectPage(pageId: string) {
|
||||||
|
activePageId.value = pageId;
|
||||||
|
pageSaveState.value = "idle";
|
||||||
|
}
|
||||||
|
|
||||||
|
function addPage() {
|
||||||
|
const nextIndex = pages.length + 1;
|
||||||
|
const page: DocPage = {
|
||||||
|
id: `new-page-${Date.now()}`,
|
||||||
|
title: `新页面 ${nextIndex}`,
|
||||||
|
path: `new-page-${nextIndex}.md`,
|
||||||
|
description: "新的 VitePress 页面",
|
||||||
|
content: `# 新页面 ${nextIndex}\n\n在这里编辑内容。`,
|
||||||
|
};
|
||||||
|
|
||||||
|
pages.unshift(page);
|
||||||
|
activePageId.value = page.id;
|
||||||
|
pageSaveState.value = "idle";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveActivePage() {
|
||||||
|
if (!activePage.value) return;
|
||||||
|
|
||||||
|
pageSaveState.value = "saving";
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (dataSource.value === "github" && hasGitHubConnection.value) {
|
||||||
|
activePage.value.sha = await saveGitHubPage(githubConnection, activePage.value);
|
||||||
|
} else {
|
||||||
|
await saveLocalPage(activePage.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
pageSaveState.value = "saved";
|
||||||
|
activityItems.value = [`已保存 ${activePage.value.path}`, ...activityItems.value];
|
||||||
|
} catch (error) {
|
||||||
|
pageSaveState.value = "error";
|
||||||
|
activityItems.value = [
|
||||||
|
error instanceof Error ? error.message : "保存页面失败",
|
||||||
|
...activityItems.value,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveSettings() {
|
||||||
|
settingsSaveState.value = "saving";
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (dataSource.value === "github" && hasGitHubConnection.value) {
|
||||||
|
settingsSha.value = await saveGitHubSettings(githubConnection, siteSettings, settingsSha.value);
|
||||||
|
} else {
|
||||||
|
await saveLocalSettings(siteSettings);
|
||||||
|
}
|
||||||
|
|
||||||
|
settingsSaveState.value = "saved";
|
||||||
|
activityItems.value = ["已保存 .vitepress/config.ts", ...activityItems.value];
|
||||||
|
} catch (error) {
|
||||||
|
settingsSaveState.value = "error";
|
||||||
|
activityItems.value = [
|
||||||
|
error instanceof Error ? error.message : "保存配置失败",
|
||||||
|
...activityItems.value,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function publishChanges() {
|
||||||
|
activityItems.value = ["已创建 commit,并触发部署流程", ...activityItems.value];
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
await loadAuthState();
|
||||||
|
|
||||||
|
if (currentUser.value) {
|
||||||
|
await reloadSite();
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
isAuthLoading.value = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<LoginView
|
||||||
|
v-if="!currentUser"
|
||||||
|
:error-message="loginError"
|
||||||
|
:is-loading="isAuthLoading"
|
||||||
|
@email-login="handleEmailLogin"
|
||||||
|
@github-login="startGitHubLogin"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div v-else id="app-shell">
|
||||||
|
<AppSidebar
|
||||||
|
:active-panel="activePanel"
|
||||||
|
:data-source="dataSource"
|
||||||
|
:has-git-hub-connection="hasGitHubConnection"
|
||||||
|
:panels="panels"
|
||||||
|
:repo-branch="repoBranch"
|
||||||
|
:repo-label="repoLabel"
|
||||||
|
:repo-root="repoRoot"
|
||||||
|
@change-panel="activePanel = $event"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<main class="app-shell">
|
||||||
|
<header class="topbar">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">VitePress 后台工作台</p>
|
||||||
|
<h2>{{ activePanelTitle }}</h2>
|
||||||
|
</div>
|
||||||
|
<div class="topbar-actions">
|
||||||
|
<button class="ghost-button" type="button" @click="handleLogout">
|
||||||
|
退出
|
||||||
|
</button>
|
||||||
|
<button class="ghost-button" type="button" @click="reloadSite">重新读取</button>
|
||||||
|
<button class="primary-button" type="button" @click="activePanel = 'publish'">发布</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<ConnectPanel
|
||||||
|
v-if="activePanel === 'connect'"
|
||||||
|
:connection="githubConnection"
|
||||||
|
:data-source="dataSource"
|
||||||
|
:github-user="githubUser"
|
||||||
|
:has-git-hub-connection="hasGitHubConnection"
|
||||||
|
:is-connecting="isConnecting"
|
||||||
|
:is-loading-repos="isLoadingRepos"
|
||||||
|
:repositories="repositories"
|
||||||
|
@connect-github="connectGithub"
|
||||||
|
@create-repository="createRepository"
|
||||||
|
@load-repositories="refreshRepositories"
|
||||||
|
@login-github="startGitHubLogin"
|
||||||
|
@logout-github="signOutGithub"
|
||||||
|
@use-local="useLocalSource"
|
||||||
|
/>
|
||||||
|
<ContentPanel
|
||||||
|
v-else-if="activePanel === 'content' && activePage"
|
||||||
|
:active-page="activePage"
|
||||||
|
:active-page-id="activePageId"
|
||||||
|
:is-loading="isLoadingPages"
|
||||||
|
:pages="pages"
|
||||||
|
:save-state="pageSaveState"
|
||||||
|
@add-page="addPage"
|
||||||
|
@save-page="saveActivePage"
|
||||||
|
@select-page="selectPage"
|
||||||
|
/>
|
||||||
|
<StructurePanel
|
||||||
|
v-else-if="activePanel === 'structure'"
|
||||||
|
:nav-items="navItems"
|
||||||
|
:sidebar-groups="sidebarGroups"
|
||||||
|
/>
|
||||||
|
<ConfigPanel
|
||||||
|
v-else-if="activePanel === 'config'"
|
||||||
|
:save-state="settingsSaveState"
|
||||||
|
:settings="siteSettings"
|
||||||
|
@save-settings="saveSettings"
|
||||||
|
/>
|
||||||
|
<PublishPanel
|
||||||
|
v-else-if="activePanel === 'publish'"
|
||||||
|
:activity-items="activityItems"
|
||||||
|
@publish="publishChanges"
|
||||||
|
/>
|
||||||
|
<section v-else class="management-card empty-state">
|
||||||
|
正在读取站点...
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
47
src/api/auth.ts
Normal file
47
src/api/auth.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import type { CmsUser } from "../types";
|
||||||
|
|
||||||
|
interface MeResponse {
|
||||||
|
user: CmsUser | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LoginResponse {
|
||||||
|
user: CmsUser;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadCurrentUser() {
|
||||||
|
const response = await fetch("/api/auth/me");
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`读取当前用户失败:${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await response.json()) as MeResponse;
|
||||||
|
return data.user;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loginWithEmail(email: string, password: string) {
|
||||||
|
const response = await fetch("/api/auth/login", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ email, password }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error("邮箱或密码错误");
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await response.json()) as LoginResponse;
|
||||||
|
return data.user;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function logoutCms() {
|
||||||
|
const response = await fetch("/api/auth/logout", {
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`退出登录失败:${response.status}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
70
src/api/githubAuth.ts
Normal file
70
src/api/githubAuth.ts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import type { GitHubRepository, GitHubUser } from "../types";
|
||||||
|
|
||||||
|
interface MeResponse {
|
||||||
|
user: GitHubUser | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ReposResponse {
|
||||||
|
repos: GitHubRepository[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CreateRepoResponse {
|
||||||
|
repo: GitHubRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startGitHubLogin() {
|
||||||
|
window.location.href = "/api/github/login";
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadGitHubUser() {
|
||||||
|
const response = await fetch("/api/github/me");
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`读取 GitHub 登录状态失败:${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await response.json()) as MeResponse;
|
||||||
|
return data.user;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function logoutGitHub() {
|
||||||
|
const response = await fetch("/api/github/logout", {
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`退出 GitHub 登录失败:${response.status}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadGitHubRepositories() {
|
||||||
|
const response = await fetch("/api/github/repos");
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`读取 GitHub 仓库列表失败:${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await response.json()) as ReposResponse;
|
||||||
|
return data.repos;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createGitHubRepository(payload: {
|
||||||
|
name: string;
|
||||||
|
private: boolean;
|
||||||
|
description?: string;
|
||||||
|
}) {
|
||||||
|
const response = await fetch("/api/github/repos", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`创建 GitHub 仓库失败:${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await response.json()) as CreateRepoResponse;
|
||||||
|
return data.repo;
|
||||||
|
}
|
||||||
266
src/api/githubSite.ts
Normal file
266
src/api/githubSite.ts
Normal file
@@ -0,0 +1,266 @@
|
|||||||
|
import type { DocPage, GitHubConnection, SiteSettings } from "../types";
|
||||||
|
import { parseFrontmatter, parseSiteSettings, stringifyMarkdownPage, stringifySiteConfig } from "../utils/siteContent";
|
||||||
|
|
||||||
|
interface GitTreeResponse {
|
||||||
|
tree: Array<{
|
||||||
|
path: string;
|
||||||
|
type: "blob" | "tree";
|
||||||
|
sha: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GitHubContentResponse {
|
||||||
|
content: string;
|
||||||
|
encoding: string;
|
||||||
|
sha: string;
|
||||||
|
path: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function githubHeaders(connection: GitHubConnection) {
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
Accept: "application/vnd.github+json",
|
||||||
|
"X-GitHub-Api-Version": "2022-11-28",
|
||||||
|
};
|
||||||
|
|
||||||
|
if (connection.token.trim()) {
|
||||||
|
headers.Authorization = `Bearer ${connection.token.trim()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
function usesSessionProxy(connection: GitHubConnection) {
|
||||||
|
return !connection.token.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function siteParams(connection: GitHubConnection) {
|
||||||
|
return new URLSearchParams({
|
||||||
|
owner: connection.owner,
|
||||||
|
repo: connection.repo,
|
||||||
|
branch: connection.branch,
|
||||||
|
siteRoot: connection.siteRoot,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeSiteRoot(siteRoot: string) {
|
||||||
|
return siteRoot.trim().replace(/^\/+|\/+$/g, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function toRepoPath(connection: GitHubConnection, path: string) {
|
||||||
|
const siteRoot = normalizeSiteRoot(connection.siteRoot);
|
||||||
|
return siteRoot ? `${siteRoot}/${path}` : path;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fromRepoPath(connection: GitHubConnection, path: string) {
|
||||||
|
const siteRoot = normalizeSiteRoot(connection.siteRoot);
|
||||||
|
return siteRoot && path.startsWith(`${siteRoot}/`) ? path.slice(siteRoot.length + 1) : path;
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeBase64Content(content: string) {
|
||||||
|
const binary = atob(content.replace(/\n/g, ""));
|
||||||
|
const bytes = Uint8Array.from(binary, (char) => char.charCodeAt(0));
|
||||||
|
return new TextDecoder().decode(bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
function encodeBase64Content(content: string) {
|
||||||
|
const bytes = new TextEncoder().encode(content);
|
||||||
|
let binary = "";
|
||||||
|
bytes.forEach((byte) => {
|
||||||
|
binary += String.fromCharCode(byte);
|
||||||
|
});
|
||||||
|
return btoa(binary);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function githubFetch<T>(connection: GitHubConnection, path: string, init?: RequestInit) {
|
||||||
|
const response = await fetch(`https://api.github.com${path}`, {
|
||||||
|
...init,
|
||||||
|
headers: {
|
||||||
|
...githubHeaders(connection),
|
||||||
|
...init?.headers,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const details = await response.text();
|
||||||
|
throw new Error(`GitHub 请求失败:${response.status} ${details}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (await response.json()) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getContent(connection: GitHubConnection, repoPath: string) {
|
||||||
|
const encodedPath = repoPath.split("/").map(encodeURIComponent).join("/");
|
||||||
|
return githubFetch<GitHubContentResponse>(
|
||||||
|
connection,
|
||||||
|
`/repos/${connection.owner}/${connection.repo}/contents/${encodedPath}?ref=${encodeURIComponent(connection.branch)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadGitHubPages(connection: GitHubConnection) {
|
||||||
|
if (usesSessionProxy(connection)) {
|
||||||
|
const response = await fetch(`/api/github/site/pages?${siteParams(connection).toString()}`);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`读取 GitHub 页面失败:${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await response.json()) as { pages: DocPage[] };
|
||||||
|
return data.pages;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tree = await githubFetch<GitTreeResponse>(
|
||||||
|
connection,
|
||||||
|
`/repos/${connection.owner}/${connection.repo}/git/trees/${encodeURIComponent(connection.branch)}?recursive=1`,
|
||||||
|
);
|
||||||
|
const siteRoot = normalizeSiteRoot(connection.siteRoot);
|
||||||
|
const prefix = siteRoot ? `${siteRoot}/` : "";
|
||||||
|
const markdownFiles = tree.tree
|
||||||
|
.filter((entry) => entry.type === "blob")
|
||||||
|
.filter((entry) => entry.path.endsWith(".md"))
|
||||||
|
.filter((entry) => !entry.path.includes("/node_modules/"))
|
||||||
|
.filter((entry) => entry.path !== `${prefix}README.md`)
|
||||||
|
.filter((entry) => !prefix || entry.path.startsWith(prefix));
|
||||||
|
|
||||||
|
return Promise.all(
|
||||||
|
markdownFiles.sort((a, b) => a.path.localeCompare(b.path)).map(async (entry) => {
|
||||||
|
const file = await getContent(connection, entry.path);
|
||||||
|
const source = decodeBase64Content(file.content);
|
||||||
|
const parsed = parseFrontmatter(source);
|
||||||
|
const path = fromRepoPath(connection, entry.path);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: path,
|
||||||
|
path,
|
||||||
|
sha: file.sha,
|
||||||
|
title: parsed.title || path.replace(/\.md$/, ""),
|
||||||
|
description: parsed.description,
|
||||||
|
content: parsed.content,
|
||||||
|
} satisfies DocPage;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveGitHubPage(connection: GitHubConnection, page: DocPage) {
|
||||||
|
if (usesSessionProxy(connection)) {
|
||||||
|
const response = await fetch("/api/github/site/pages", {
|
||||||
|
method: "PUT",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
site: {
|
||||||
|
owner: connection.owner,
|
||||||
|
repo: connection.repo,
|
||||||
|
branch: connection.branch,
|
||||||
|
siteRoot: connection.siteRoot,
|
||||||
|
},
|
||||||
|
page,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`保存 GitHub 页面失败:${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await response.json()) as { sha: string };
|
||||||
|
return data.sha;
|
||||||
|
}
|
||||||
|
|
||||||
|
const repoPath = toRepoPath(connection, page.path);
|
||||||
|
const encodedPath = repoPath.split("/").map(encodeURIComponent).join("/");
|
||||||
|
const response = await githubFetch<{ content: { sha: string } }>(
|
||||||
|
connection,
|
||||||
|
`/repos/${connection.owner}/${connection.repo}/contents/${encodedPath}`,
|
||||||
|
{
|
||||||
|
method: "PUT",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
message: `docs: update ${page.path} from VitePress-CMS`,
|
||||||
|
content: encodeBase64Content(stringifyMarkdownPage(page)),
|
||||||
|
branch: connection.branch,
|
||||||
|
sha: page.sha,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return response.content.sha;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadGitHubSettings(connection: GitHubConnection) {
|
||||||
|
if (usesSessionProxy(connection)) {
|
||||||
|
const response = await fetch(`/api/github/site/settings?${siteParams(connection).toString()}`);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`读取 GitHub 配置失败:${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (await response.json()) as {
|
||||||
|
settings: SiteSettings;
|
||||||
|
sha: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const configPath = toRepoPath(connection, ".vitepress/config.ts");
|
||||||
|
const file = await getContent(connection, configPath);
|
||||||
|
const source = decodeBase64Content(file.content);
|
||||||
|
|
||||||
|
return {
|
||||||
|
settings: parseSiteSettings(source),
|
||||||
|
sha: file.sha,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveGitHubSettings(
|
||||||
|
connection: GitHubConnection,
|
||||||
|
settings: SiteSettings,
|
||||||
|
sha?: string,
|
||||||
|
) {
|
||||||
|
if (usesSessionProxy(connection)) {
|
||||||
|
const response = await fetch("/api/github/site/settings", {
|
||||||
|
method: "PUT",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
site: {
|
||||||
|
owner: connection.owner,
|
||||||
|
repo: connection.repo,
|
||||||
|
branch: connection.branch,
|
||||||
|
siteRoot: connection.siteRoot,
|
||||||
|
},
|
||||||
|
settings,
|
||||||
|
sha,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`保存 GitHub 配置失败:${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await response.json()) as { sha: string };
|
||||||
|
return data.sha;
|
||||||
|
}
|
||||||
|
|
||||||
|
const repoPath = toRepoPath(connection, ".vitepress/config.ts");
|
||||||
|
const encodedPath = repoPath.split("/").map(encodeURIComponent).join("/");
|
||||||
|
const response = await githubFetch<{ content: { sha: string } }>(
|
||||||
|
connection,
|
||||||
|
`/repos/${connection.owner}/${connection.repo}/contents/${encodedPath}`,
|
||||||
|
{
|
||||||
|
method: "PUT",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
message: "docs: update VitePress config from VitePress-CMS",
|
||||||
|
content: encodeBase64Content(stringifySiteConfig(settings)),
|
||||||
|
branch: connection.branch,
|
||||||
|
sha,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return response.content.sha;
|
||||||
|
}
|
||||||
64
src/api/localSite.ts
Normal file
64
src/api/localSite.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import type { DocPage, SiteSettings } from "../types";
|
||||||
|
|
||||||
|
interface PagesResponse {
|
||||||
|
pages: DocPage[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SettingsResponse {
|
||||||
|
settings: SiteSettings;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadLocalPages() {
|
||||||
|
const response = await fetch("/api/local-site/pages");
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`读取测试站点失败:${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await response.json()) as PagesResponse;
|
||||||
|
return data.pages;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveLocalPage(page: DocPage) {
|
||||||
|
const response = await fetch("/api/local-site/pages", {
|
||||||
|
method: "PUT",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
path: page.path,
|
||||||
|
title: page.title,
|
||||||
|
description: page.description,
|
||||||
|
content: page.content,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`保存测试站点失败:${response.status}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadLocalSettings() {
|
||||||
|
const response = await fetch("/api/local-site/settings");
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`读取测试站点配置失败:${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await response.json()) as SettingsResponse;
|
||||||
|
return data.settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveLocalSettings(settings: SiteSettings) {
|
||||||
|
const response = await fetch("/api/local-site/settings", {
|
||||||
|
method: "PUT",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify(settings),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`保存测试站点配置失败:${response.status}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
67
src/components/AppSidebar.vue
Normal file
67
src/components/AppSidebar.vue
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { DataSource, PanelKey } from "../types";
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
activePanel: PanelKey;
|
||||||
|
dataSource: DataSource;
|
||||||
|
hasGitHubConnection: boolean;
|
||||||
|
panels: Array<{ key: PanelKey; label: string; icon: string }>;
|
||||||
|
repoLabel: string;
|
||||||
|
repoBranch: string;
|
||||||
|
repoRoot: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
changePanel: [panel: PanelKey];
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<aside class="workspace-sidebar">
|
||||||
|
<div class="brand-block">
|
||||||
|
<div class="brand-mark">VC</div>
|
||||||
|
<div>
|
||||||
|
<h1>VitePress-CMS</h1>
|
||||||
|
<p>VitePress 可视化后台</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="connect-button" type="button" @click="$emit('changePanel', 'connect')">
|
||||||
|
<span>{{ hasGitHubConnection ? "GitHub" : dataSource === "github" ? "GitHub" : "Local" }}</span>
|
||||||
|
<strong>{{ hasGitHubConnection ? "已连接仓库" : "连接仓库" }}</strong>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<nav class="main-nav" aria-label="后台模块">
|
||||||
|
<button
|
||||||
|
v-for="panel in panels"
|
||||||
|
:key="panel.key"
|
||||||
|
class="nav-item"
|
||||||
|
:class="{ 'is-active': activePanel === panel.key }"
|
||||||
|
type="button"
|
||||||
|
@click="$emit('changePanel', panel.key)"
|
||||||
|
>
|
||||||
|
<span class="nav-icon">{{ panel.icon }}</span>
|
||||||
|
{{ panel.label }}
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<section class="repo-card" aria-label="当前仓库">
|
||||||
|
<p class="eyebrow">当前数据源</p>
|
||||||
|
<h2>{{ repoLabel }}</h2>
|
||||||
|
<dl>
|
||||||
|
<div>
|
||||||
|
<dt>分支</dt>
|
||||||
|
<dd>{{ repoBranch }}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>目录</dt>
|
||||||
|
<dd>{{ repoRoot }}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>状态</dt>
|
||||||
|
<dd class="status-dot">Ready</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
|
</aside>
|
||||||
|
</template>
|
||||||
74
src/components/ConfigPanel.vue
Normal file
74
src/components/ConfigPanel.vue
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { SiteSettings } from "../types";
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
settings: SiteSettings;
|
||||||
|
saveState: "idle" | "saving" | "saved" | "error";
|
||||||
|
}>();
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
saveSettings: [];
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="panel is-visible">
|
||||||
|
<div class="settings-toolbar">
|
||||||
|
<p class="save-status" :class="`is-${saveState}`">
|
||||||
|
<template v-if="saveState === 'saved'">已保存到 test-vitepress/.vitepress/config.ts</template>
|
||||||
|
<template v-else-if="saveState === 'error'">保存配置失败,请查看发布中心日志</template>
|
||||||
|
<template v-else-if="saveState === 'saving'">正在写入 VitePress 配置</template>
|
||||||
|
<template v-else>读取自 test-vitepress/.vitepress/config.ts</template>
|
||||||
|
</p>
|
||||||
|
<button class="primary-button" type="button" @click="$emit('saveSettings')">
|
||||||
|
{{ saveState === "saving" ? "保存中" : "保存配置" }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-grid">
|
||||||
|
<section class="management-card">
|
||||||
|
<p class="eyebrow">site</p>
|
||||||
|
<h3>站点信息</h3>
|
||||||
|
<label>
|
||||||
|
站点标题
|
||||||
|
<input v-model="settings.title" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
描述
|
||||||
|
<input v-model="settings.description" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Logo 路径
|
||||||
|
<input v-model="settings.logo" />
|
||||||
|
</label>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="management-card">
|
||||||
|
<p class="eyebrow">theme</p>
|
||||||
|
<h3>主题选项</h3>
|
||||||
|
<label class="toggle-row">
|
||||||
|
<span>显示最后更新时间</span>
|
||||||
|
<input v-model="settings.lastUpdated" type="checkbox" />
|
||||||
|
</label>
|
||||||
|
<label class="toggle-row">
|
||||||
|
<span>开启本地搜索</span>
|
||||||
|
<input v-model="settings.localSearch" type="checkbox" />
|
||||||
|
</label>
|
||||||
|
<label class="toggle-row">
|
||||||
|
<span>显示大纲导航</span>
|
||||||
|
<input v-model="settings.outline" type="checkbox" />
|
||||||
|
</label>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="management-card wide-card">
|
||||||
|
<p class="eyebrow">socialLinks</p>
|
||||||
|
<h3>社交链接</h3>
|
||||||
|
<div class="sort-row">
|
||||||
|
<span class="drag-handle">::</span>
|
||||||
|
<input v-model="settings.socialKind" />
|
||||||
|
<input v-model="settings.socialLink" />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
206
src/components/ConnectPanel.vue
Normal file
206
src/components/ConnectPanel.vue
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, reactive, watch } from "vue";
|
||||||
|
import type { DataSource, GitHubConnection, GitHubRepository, GitHubUser } from "../types";
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
connection: GitHubConnection;
|
||||||
|
dataSource: DataSource;
|
||||||
|
githubUser: GitHubUser | null;
|
||||||
|
hasGitHubConnection: boolean;
|
||||||
|
isConnecting: boolean;
|
||||||
|
isLoadingRepos: boolean;
|
||||||
|
repositories: GitHubRepository[];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
connectGithub: [connection: GitHubConnection];
|
||||||
|
createRepository: [payload: { name: string; private: boolean }];
|
||||||
|
loginGithub: [];
|
||||||
|
loadRepositories: [];
|
||||||
|
logoutGithub: [];
|
||||||
|
useLocal: [];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const draft = reactive<GitHubConnection>({ ...props.connection });
|
||||||
|
const newRepo = reactive({
|
||||||
|
name: "",
|
||||||
|
private: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const selectedRepo = computed({
|
||||||
|
get() {
|
||||||
|
return draft.owner && draft.repo ? `${draft.owner}/${draft.repo}` : "";
|
||||||
|
},
|
||||||
|
set(fullName: string) {
|
||||||
|
const repo = props.repositories.find((item) => item.fullName === fullName);
|
||||||
|
if (!repo) return;
|
||||||
|
|
||||||
|
draft.owner = repo.owner;
|
||||||
|
draft.repo = repo.name;
|
||||||
|
draft.branch = repo.defaultBranch || "main";
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const canConnect = computed(() => Boolean(draft.owner.trim() && draft.repo.trim() && draft.branch.trim()));
|
||||||
|
const canCreateRepo = computed(() => Boolean(newRepo.name.trim()));
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.connection,
|
||||||
|
(connection) => {
|
||||||
|
Object.assign(draft, connection);
|
||||||
|
},
|
||||||
|
{ deep: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
function connectGithub() {
|
||||||
|
if (!canConnect.value) return;
|
||||||
|
emit("connectGithub", { ...draft });
|
||||||
|
}
|
||||||
|
|
||||||
|
function createRepository() {
|
||||||
|
if (!canCreateRepo.value) return;
|
||||||
|
emit("createRepository", {
|
||||||
|
name: newRepo.name.trim(),
|
||||||
|
private: newRepo.private,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="panel is-visible">
|
||||||
|
<div class="connect-layout">
|
||||||
|
<section class="management-card">
|
||||||
|
<p class="eyebrow">GitHub</p>
|
||||||
|
<h3>登录与仓库选择</h3>
|
||||||
|
|
||||||
|
<div v-if="githubUser" class="github-user">
|
||||||
|
<img v-if="githubUser.avatarUrl" :src="githubUser.avatarUrl" alt="" />
|
||||||
|
<div>
|
||||||
|
<strong>{{ githubUser.name || githubUser.login }}</strong>
|
||||||
|
<span>@{{ githubUser.login }}</span>
|
||||||
|
</div>
|
||||||
|
<button class="ghost-button" type="button" @click="$emit('logoutGithub')">退出</button>
|
||||||
|
</div>
|
||||||
|
<div v-else class="connect-actions">
|
||||||
|
<button class="primary-button" type="button" @click="$emit('loginGithub')">
|
||||||
|
使用 GitHub 登录
|
||||||
|
</button>
|
||||||
|
<button class="ghost-button" type="button" @click="$emit('useLocal')">
|
||||||
|
使用本地测试站点
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-if="githubUser">
|
||||||
|
<div class="connect-actions">
|
||||||
|
<button class="secondary-button" type="button" @click="$emit('loadRepositories')">
|
||||||
|
{{ isLoadingRepos ? "读取中" : "刷新仓库列表" }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-grid">
|
||||||
|
<label class="wide-field">
|
||||||
|
仓库
|
||||||
|
<select v-model="selectedRepo">
|
||||||
|
<option value="">选择仓库</option>
|
||||||
|
<option v-for="repo in repositories" :key="repo.id" :value="repo.fullName">
|
||||||
|
{{ repo.fullName }}{{ repo.private ? " private" : "" }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Branch
|
||||||
|
<input v-model="draft.branch" placeholder="main" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
站点目录
|
||||||
|
<input v-model="draft.siteRoot" placeholder="docs 或留空" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="connect-actions">
|
||||||
|
<button class="primary-button" type="button" :disabled="!canConnect || isConnecting" @click="connectGithub">
|
||||||
|
{{ isConnecting ? "连接中" : "读取选中仓库" }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="new-repo-box">
|
||||||
|
<p class="eyebrow">create</p>
|
||||||
|
<h3>新建仓库</h3>
|
||||||
|
<div class="form-grid">
|
||||||
|
<label>
|
||||||
|
仓库名
|
||||||
|
<input v-model="newRepo.name" placeholder="my-vitepress-site" />
|
||||||
|
</label>
|
||||||
|
<label class="toggle-row">
|
||||||
|
<span>私有仓库</span>
|
||||||
|
<input v-model="newRepo.private" type="checkbox" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<button class="secondary-button" type="button" :disabled="!canCreateRepo" @click="createRepository">
|
||||||
|
创建仓库
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="management-card">
|
||||||
|
<p class="eyebrow">manual</p>
|
||||||
|
<h3>手动连接</h3>
|
||||||
|
<div class="form-grid">
|
||||||
|
<label>
|
||||||
|
Token
|
||||||
|
<input v-model="draft.token" type="password" placeholder="ghp_..." autocomplete="off" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Owner
|
||||||
|
<input v-model="draft.owner" placeholder="octocat" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Repo
|
||||||
|
<input v-model="draft.repo" placeholder="vitepress-site" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Branch
|
||||||
|
<input v-model="draft.branch" placeholder="main" />
|
||||||
|
</label>
|
||||||
|
<label class="wide-field">
|
||||||
|
站点目录
|
||||||
|
<input v-model="draft.siteRoot" placeholder="docs 或留空" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="!canConnect" class="helper-text">
|
||||||
|
请至少填写 owner、repo 和 branch。私有仓库或保存操作还需要 token。
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="connect-actions">
|
||||||
|
<button class="primary-button" type="button" :disabled="!canConnect || isConnecting" @click="connectGithub">
|
||||||
|
{{ isConnecting ? "连接中" : "读取 GitHub 仓库" }}
|
||||||
|
</button>
|
||||||
|
<button class="ghost-button" type="button" @click="$emit('useLocal')">
|
||||||
|
使用本地测试站点
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<dl class="source-summary">
|
||||||
|
<div>
|
||||||
|
<dt>模式</dt>
|
||||||
|
<dd>{{ dataSource === "github" && hasGitHubConnection ? "GitHub 仓库" : "本地测试站点" }}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>仓库</dt>
|
||||||
|
<dd>{{ hasGitHubConnection ? `${connection.owner}/${connection.repo}` : "未连接" }}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>分支</dt>
|
||||||
|
<dd>{{ hasGitHubConnection ? connection.branch : "-" }}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>目录</dt>
|
||||||
|
<dd>{{ hasGitHubConnection ? connection.siteRoot || "/" : "-" }}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
108
src/components/ContentPanel.vue
Normal file
108
src/components/ContentPanel.vue
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref } from "vue";
|
||||||
|
import { markdownToHtml } from "../utils/markdown";
|
||||||
|
import type { DocPage, EditorMode } from "../types";
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
pages: DocPage[];
|
||||||
|
activePage: DocPage;
|
||||||
|
activePageId: string;
|
||||||
|
isLoading: boolean;
|
||||||
|
saveState: "idle" | "saving" | "saved" | "error";
|
||||||
|
}>();
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
addPage: [];
|
||||||
|
savePage: [];
|
||||||
|
selectPage: [pageId: string];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const search = ref("");
|
||||||
|
const mode = ref<EditorMode>("write");
|
||||||
|
|
||||||
|
const filteredPages = computed(() => {
|
||||||
|
const query = search.value.trim().toLowerCase();
|
||||||
|
|
||||||
|
if (!query) return props.pages;
|
||||||
|
|
||||||
|
return props.pages.filter((page) => `${page.title} ${page.path}`.toLowerCase().includes(query));
|
||||||
|
});
|
||||||
|
|
||||||
|
const previewHtml = computed(() => markdownToHtml(props.activePage.content));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="panel is-visible">
|
||||||
|
<div class="content-layout">
|
||||||
|
<aside class="document-tree" aria-label="文档目录">
|
||||||
|
<div class="section-head">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">docs</p>
|
||||||
|
<h3>页面</h3>
|
||||||
|
</div>
|
||||||
|
<button class="icon-button" type="button" title="新建页面" @click="$emit('addPage')">+</button>
|
||||||
|
</div>
|
||||||
|
<div class="search-box">
|
||||||
|
<input v-model="search" type="search" placeholder="搜索页面" />
|
||||||
|
</div>
|
||||||
|
<ul class="page-list">
|
||||||
|
<li v-for="page in filteredPages" :key="page.id">
|
||||||
|
<button
|
||||||
|
class="page-item"
|
||||||
|
:class="{ 'is-active': page.id === activePageId }"
|
||||||
|
type="button"
|
||||||
|
@click="$emit('selectPage', page.id)"
|
||||||
|
>
|
||||||
|
<strong>{{ page.title }}</strong>
|
||||||
|
<span>{{ page.path }}</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<p v-if="isLoading" class="helper-text">正在读取 test-vitepress...</p>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<section class="editor-pane" aria-label="编辑器">
|
||||||
|
<div class="editor-header">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">{{ activePage.path }}</p>
|
||||||
|
<h3>{{ activePage.title }}</h3>
|
||||||
|
</div>
|
||||||
|
<div class="editor-actions">
|
||||||
|
<div class="segmented-control" role="tablist" aria-label="编辑模式">
|
||||||
|
<button :class="{ 'is-active': mode === 'write' }" type="button" @click="mode = 'write'">
|
||||||
|
编辑
|
||||||
|
</button>
|
||||||
|
<button :class="{ 'is-active': mode === 'preview' }" type="button" @click="mode = 'preview'">
|
||||||
|
预览
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button class="primary-button" type="button" @click="$emit('savePage')">
|
||||||
|
{{ saveState === "saving" ? "保存中" : "保存" }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="save-status" :class="`is-${saveState}`">
|
||||||
|
<template v-if="saveState === 'saved'">已保存到 test-vitepress</template>
|
||||||
|
<template v-else-if="saveState === 'error'">保存失败,请查看发布中心日志</template>
|
||||||
|
<template v-else-if="saveState === 'saving'">正在写入 Markdown 文件</template>
|
||||||
|
<template v-else>读取自 test-vitepress,本次修改需要手动保存</template>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="frontmatter-grid">
|
||||||
|
<label>
|
||||||
|
标题
|
||||||
|
<input v-model="activePage.title" type="text" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
描述
|
||||||
|
<input v-model="activePage.description" type="text" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<textarea v-if="mode === 'write'" v-model="activePage.content" spellcheck="false"></textarea>
|
||||||
|
<article v-else class="markdown-preview" v-html="previewHtml"></article>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
58
src/components/LoginView.vue
Normal file
58
src/components/LoginView.vue
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { reactive } from "vue";
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
errorMessage: string;
|
||||||
|
isLoading: boolean;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
emailLogin: [payload: { email: string; password: string }];
|
||||||
|
githubLogin: [];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const form = reactive({
|
||||||
|
email: "admin@example.com",
|
||||||
|
password: "admin123456",
|
||||||
|
});
|
||||||
|
|
||||||
|
function submit() {
|
||||||
|
emit("emailLogin", { ...form });
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<main class="login-screen">
|
||||||
|
<section class="login-panel">
|
||||||
|
<div class="brand-block">
|
||||||
|
<div class="brand-mark">VC</div>
|
||||||
|
<div>
|
||||||
|
<h1>VitePress-CMS</h1>
|
||||||
|
<p>登录 CMS 后台</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form class="login-form" @submit.prevent="submit">
|
||||||
|
<label>
|
||||||
|
邮箱
|
||||||
|
<input v-model="form.email" type="email" autocomplete="username" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
密码
|
||||||
|
<input v-model="form.password" type="password" autocomplete="current-password" />
|
||||||
|
</label>
|
||||||
|
<button class="primary-button" type="submit" :disabled="isLoading">
|
||||||
|
{{ isLoading ? "登录中" : "邮箱登录" }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<button class="connect-button" type="button" @click="$emit('githubLogin')">
|
||||||
|
<span>GitHub</span>
|
||||||
|
<strong>使用 GitHub 登录</strong>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<p v-if="errorMessage" class="save-status is-error">{{ errorMessage }}</p>
|
||||||
|
<p class="helper-text">默认管理员账号:admin@example.com / admin123456</p>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</template>
|
||||||
29
src/components/PublishPanel.vue
Normal file
29
src/components/PublishPanel.vue
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{
|
||||||
|
activityItems: string[];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
publish: [];
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="panel is-visible">
|
||||||
|
<div class="publish-layout">
|
||||||
|
<section class="management-card publish-card">
|
||||||
|
<p class="eyebrow">commit</p>
|
||||||
|
<h3>发布修改</h3>
|
||||||
|
<textarea class="commit-message">docs: update site content from VitePress-CMS</textarea>
|
||||||
|
<button class="primary-button" type="button" @click="$emit('publish')">提交并发布</button>
|
||||||
|
</section>
|
||||||
|
<section class="management-card">
|
||||||
|
<p class="eyebrow">activity</p>
|
||||||
|
<h3>部署状态</h3>
|
||||||
|
<ol class="activity-list">
|
||||||
|
<li v-for="item in activityItems" :key="item"><span></span>{{ item }}</li>
|
||||||
|
</ol>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
49
src/components/StructurePanel.vue
Normal file
49
src/components/StructurePanel.vue
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { NavItem, SidebarGroup } from "../types";
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
navItems: NavItem[];
|
||||||
|
sidebarGroups: SidebarGroup[];
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="panel is-visible">
|
||||||
|
<div class="two-column">
|
||||||
|
<section class="management-card">
|
||||||
|
<div class="section-head">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">nav</p>
|
||||||
|
<h3>导航栏</h3>
|
||||||
|
</div>
|
||||||
|
<button class="secondary-button" type="button">添加</button>
|
||||||
|
</div>
|
||||||
|
<div class="sortable-list">
|
||||||
|
<div v-for="item in navItems" :key="item.id" class="sort-row">
|
||||||
|
<span class="drag-handle">::</span>
|
||||||
|
<input v-model="item.label" />
|
||||||
|
<input v-model="item.link" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="management-card">
|
||||||
|
<div class="section-head">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">sidebar</p>
|
||||||
|
<h3>侧边栏</h3>
|
||||||
|
</div>
|
||||||
|
<button class="secondary-button" type="button">自动生成</button>
|
||||||
|
</div>
|
||||||
|
<div class="sidebar-preview">
|
||||||
|
<template v-for="group in sidebarGroups" :key="group.id">
|
||||||
|
<strong>{{ group.title }}</strong>
|
||||||
|
<button v-for="item in group.items" :key="item.id" type="button">
|
||||||
|
{{ item.label }}
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
6
src/env.d.ts
vendored
Normal file
6
src/env.d.ts
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
declare module "*.vue" {
|
||||||
|
import type { DefineComponent } from "vue";
|
||||||
|
|
||||||
|
const component: DefineComponent<Record<string, unknown>, Record<string, unknown>, unknown>;
|
||||||
|
export default component;
|
||||||
|
}
|
||||||
5
src/main.ts
Normal file
5
src/main.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { createApp } from "vue";
|
||||||
|
import App from "./App.vue";
|
||||||
|
import "./styles.css";
|
||||||
|
|
||||||
|
createApp(App).mount("#app");
|
||||||
757
src/styles.css
Normal file
757
src/styles.css
Normal file
@@ -0,0 +1,757 @@
|
|||||||
|
:root {
|
||||||
|
color-scheme: light;
|
||||||
|
--bg: #eef1f5;
|
||||||
|
--surface: #ffffff;
|
||||||
|
--surface-soft: #f7f9fb;
|
||||||
|
--ink: #18212f;
|
||||||
|
--muted: #687386;
|
||||||
|
--line: #dfe5ed;
|
||||||
|
--accent: #117a72;
|
||||||
|
--accent-strong: #0b625d;
|
||||||
|
--accent-soft: #e1f4f1;
|
||||||
|
--warning: #c07a21;
|
||||||
|
--radius: 8px;
|
||||||
|
font-family:
|
||||||
|
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
|
||||||
|
sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
min-width: 320px;
|
||||||
|
min-height: 100vh;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
button,
|
||||||
|
input,
|
||||||
|
select,
|
||||||
|
textarea {
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
#app-shell {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 280px minmax(0, 1fr);
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-screen {
|
||||||
|
display: grid;
|
||||||
|
min-height: 100vh;
|
||||||
|
place-items: center;
|
||||||
|
padding: 20px;
|
||||||
|
background: var(--bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-panel {
|
||||||
|
display: grid;
|
||||||
|
width: min(420px, 100%);
|
||||||
|
gap: 16px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--surface);
|
||||||
|
padding: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-form {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-sidebar {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 18px;
|
||||||
|
padding: 22px;
|
||||||
|
border-right: 1px solid var(--line);
|
||||||
|
background: #fbfcfd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-block {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-mark {
|
||||||
|
display: grid;
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
place-items: center;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-block h1,
|
||||||
|
.brand-block p,
|
||||||
|
.topbar h2,
|
||||||
|
.topbar p,
|
||||||
|
.section-head h3,
|
||||||
|
.section-head p,
|
||||||
|
.management-card h3,
|
||||||
|
.management-card p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-block h1 {
|
||||||
|
font-size: 18px;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-block p,
|
||||||
|
.eyebrow {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eyebrow {
|
||||||
|
margin-bottom: 4px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.connect-button,
|
||||||
|
.primary-button,
|
||||||
|
.secondary-button,
|
||||||
|
.ghost-button,
|
||||||
|
.icon-button,
|
||||||
|
.nav-item,
|
||||||
|
.page-item,
|
||||||
|
.sidebar-preview button {
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
.connect-button {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 13px 14px;
|
||||||
|
background: #171b23;
|
||||||
|
color: #fff;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.connect-button span {
|
||||||
|
color: #bfc7d1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-nav {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 11px 12px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--muted);
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item.is-active {
|
||||||
|
background: var(--accent-soft);
|
||||||
|
color: var(--accent-strong);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-icon {
|
||||||
|
display: grid;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
place-items: center;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #e8edf3;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.repo-card,
|
||||||
|
.management-card,
|
||||||
|
.document-tree,
|
||||||
|
.editor-pane {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.repo-card {
|
||||||
|
margin-top: auto;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.repo-card h2 {
|
||||||
|
margin: 0 0 14px;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.repo-card dl {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.repo-card dl div {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.repo-card dt {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.repo-card dd {
|
||||||
|
margin: 0;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot::before {
|
||||||
|
display: inline-block;
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
margin-right: 6px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #21a36e;
|
||||||
|
content: "";
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-shell {
|
||||||
|
min-width: 0;
|
||||||
|
padding: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar h2 {
|
||||||
|
font-size: 28px;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary-button,
|
||||||
|
.secondary-button,
|
||||||
|
.ghost-button,
|
||||||
|
.icon-button {
|
||||||
|
min-height: 38px;
|
||||||
|
padding: 0 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary-button {
|
||||||
|
background: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.secondary-button,
|
||||||
|
.ghost-button,
|
||||||
|
.icon-button {
|
||||||
|
border-color: var(--line);
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel.is-visible {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 320px minmax(0, 1fr);
|
||||||
|
gap: 16px;
|
||||||
|
min-height: calc(100vh - 112px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-tree,
|
||||||
|
.editor-pane,
|
||||||
|
.management-card {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-head h3,
|
||||||
|
.management-card h3 {
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-box input,
|
||||||
|
label input,
|
||||||
|
label select,
|
||||||
|
.sort-row input,
|
||||||
|
.commit-message {
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--surface-soft);
|
||||||
|
color: var(--ink);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-box input,
|
||||||
|
label input,
|
||||||
|
label select,
|
||||||
|
.sort-row input {
|
||||||
|
height: 40px;
|
||||||
|
padding: 0 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
max-height: calc(100vh - 226px);
|
||||||
|
margin: 14px 0 0;
|
||||||
|
padding: 0;
|
||||||
|
overflow: auto;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-item {
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 12px;
|
||||||
|
background: transparent;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-item span {
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-item.is-active {
|
||||||
|
border-color: #b9d8d4;
|
||||||
|
background: var(--accent-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-pane {
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: auto auto minmax(0, 1fr);
|
||||||
|
gap: 14px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-header h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.segmented-control {
|
||||||
|
display: flex;
|
||||||
|
padding: 3px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--surface-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.segmented-control button {
|
||||||
|
min-width: 64px;
|
||||||
|
height: 32px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--muted);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.segmented-control button.is-active {
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--ink);
|
||||||
|
box-shadow: 0 1px 4px rgba(23, 33, 46, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.frontmatter-grid,
|
||||||
|
.settings-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: grid;
|
||||||
|
gap: 7px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.helper-text,
|
||||||
|
.save-status {
|
||||||
|
margin: 10px 0 0;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-status {
|
||||||
|
margin: -4px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-status.is-saved {
|
||||||
|
color: var(--accent-strong);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-status.is-error {
|
||||||
|
color: #a64038;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-pane > textarea,
|
||||||
|
.markdown-preview {
|
||||||
|
min-height: 460px;
|
||||||
|
overflow: auto;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--surface-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-pane > textarea {
|
||||||
|
resize: none;
|
||||||
|
padding: 16px;
|
||||||
|
line-height: 1.65;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-preview {
|
||||||
|
padding: 18px 22px;
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-preview h1,
|
||||||
|
.markdown-preview h2,
|
||||||
|
.markdown-preview h3 {
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-preview blockquote {
|
||||||
|
margin: 12px 0;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-left: 4px solid var(--accent);
|
||||||
|
background: #edf7f5;
|
||||||
|
color: #30514e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-list {
|
||||||
|
margin: 6px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.two-column,
|
||||||
|
.publish-layout,
|
||||||
|
.connect-layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sortable-list,
|
||||||
|
.sidebar-preview,
|
||||||
|
.settings-grid,
|
||||||
|
.publish-card,
|
||||||
|
.form-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-grid {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
margin-top: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wide-field {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.connect-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.github-user {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 42px minmax(0, 1fr) auto;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
margin-top: 14px;
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--surface-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.github-user img {
|
||||||
|
width: 42px;
|
||||||
|
height: 42px;
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.github-user strong,
|
||||||
|
.github-user span {
|
||||||
|
display: block;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.github-user span {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.new-repo-box {
|
||||||
|
margin-top: 18px;
|
||||||
|
padding-top: 16px;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-summary {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
margin: 14px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-summary div {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
padding-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-summary dt {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-summary dd {
|
||||||
|
margin: 0;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
font-weight: 700;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 28px 1fr 1.4fr;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drag-handle {
|
||||||
|
color: var(--muted);
|
||||||
|
font-weight: 800;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-preview {
|
||||||
|
align-content: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-preview strong {
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-preview button {
|
||||||
|
min-height: 34px;
|
||||||
|
border-color: var(--line);
|
||||||
|
background: var(--surface-soft);
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-grid {
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wide-card {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
min-height: 42px;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-row input {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.commit-message {
|
||||||
|
min-height: 140px;
|
||||||
|
padding: 12px;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 0;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-list li {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-list span {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 980px) {
|
||||||
|
#app-shell {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-sidebar {
|
||||||
|
position: static;
|
||||||
|
border-right: 0;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-nav {
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.repo-card {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-layout,
|
||||||
|
.two-column,
|
||||||
|
.publish-layout,
|
||||||
|
.connect-layout,
|
||||||
|
.settings-grid,
|
||||||
|
.frontmatter-grid,
|
||||||
|
.form-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.app-shell,
|
||||||
|
.workspace-sidebar {
|
||||||
|
padding: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar,
|
||||||
|
.editor-header,
|
||||||
|
.settings-toolbar {
|
||||||
|
align-items: stretch;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-actions {
|
||||||
|
align-items: stretch;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-actions,
|
||||||
|
.segmented-control,
|
||||||
|
.connect-actions {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-actions button,
|
||||||
|
.segmented-control button,
|
||||||
|
.connect-actions button {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-nav {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-icon {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort-row {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-pane > textarea,
|
||||||
|
.markdown-preview {
|
||||||
|
min-height: 360px;
|
||||||
|
}
|
||||||
|
}
|
||||||
71
src/types.ts
Normal file
71
src/types.ts
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
export type PanelKey = "connect" | "content" | "structure" | "config" | "publish";
|
||||||
|
export type EditorMode = "write" | "preview";
|
||||||
|
export type DataSource = "local" | "github";
|
||||||
|
|
||||||
|
export interface DocPage {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
path: string;
|
||||||
|
description: string;
|
||||||
|
content: string;
|
||||||
|
sha?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NavItem {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
link: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SidebarGroup {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
items: Array<{
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
link: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SiteSettings {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
logo: string;
|
||||||
|
lastUpdated: boolean;
|
||||||
|
localSearch: boolean;
|
||||||
|
outline: boolean;
|
||||||
|
socialKind: string;
|
||||||
|
socialLink: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GitHubConnection {
|
||||||
|
token: string;
|
||||||
|
owner: string;
|
||||||
|
repo: string;
|
||||||
|
branch: string;
|
||||||
|
siteRoot: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GitHubUser {
|
||||||
|
login: string;
|
||||||
|
avatarUrl?: string;
|
||||||
|
name?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CmsUser {
|
||||||
|
id: number;
|
||||||
|
email?: string;
|
||||||
|
name: string;
|
||||||
|
avatarUrl?: string;
|
||||||
|
role: "system_admin" | "user" | string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GitHubRepository {
|
||||||
|
id: number;
|
||||||
|
fullName: string;
|
||||||
|
owner: string;
|
||||||
|
name: string;
|
||||||
|
private: boolean;
|
||||||
|
defaultBranch: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
27
src/utils/markdown.ts
Normal file
27
src/utils/markdown.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
function escapeHtml(value: string) {
|
||||||
|
return value
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """)
|
||||||
|
.replace(/'/g, "'");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function markdownToHtml(markdown: string) {
|
||||||
|
return markdown
|
||||||
|
.split("\n")
|
||||||
|
.map((rawLine) => {
|
||||||
|
const line = escapeHtml(rawLine);
|
||||||
|
|
||||||
|
if (line.startsWith("### ")) return `<h3>${line.slice(4)}</h3>`;
|
||||||
|
if (line.startsWith("## ")) return `<h2>${line.slice(3)}</h2>`;
|
||||||
|
if (line.startsWith("# ")) return `<h1>${line.slice(2)}</h1>`;
|
||||||
|
if (line.startsWith("- ")) return `<p class="preview-list">• ${line.slice(2)}</p>`;
|
||||||
|
if (/^\d+\.\s/.test(line)) return `<p class="preview-list">${line}</p>`;
|
||||||
|
if (line.startsWith("> ")) return `<blockquote>${line.slice(5)}</blockquote>`;
|
||||||
|
if (!line.trim()) return "";
|
||||||
|
|
||||||
|
return `<p>${line}</p>`;
|
||||||
|
})
|
||||||
|
.join("");
|
||||||
|
}
|
||||||
113
src/utils/siteContent.ts
Normal file
113
src/utils/siteContent.ts
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
import type { SiteSettings } from "../types";
|
||||||
|
|
||||||
|
export function parseFrontmatter(source: string) {
|
||||||
|
const match = source.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/);
|
||||||
|
|
||||||
|
if (!match) {
|
||||||
|
return {
|
||||||
|
title: "",
|
||||||
|
description: "",
|
||||||
|
content: source,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const frontmatter = match[1];
|
||||||
|
const title = frontmatter.match(/^title:\s*(.+)$/m)?.[1]?.trim().replace(/^["']|["']$/g, "") ?? "";
|
||||||
|
const description =
|
||||||
|
frontmatter.match(/^description:\s*(.+)$/m)?.[1]?.trim().replace(/^["']|["']$/g, "") ?? "";
|
||||||
|
|
||||||
|
return {
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
content: source.slice(match[0].length),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stringifyMarkdownPage(page: {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
content: string;
|
||||||
|
}) {
|
||||||
|
const frontmatter = [
|
||||||
|
"---",
|
||||||
|
`title: ${page.title || "未命名页面"}`,
|
||||||
|
`description: ${page.description || ""}`,
|
||||||
|
"---",
|
||||||
|
"",
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
return `${frontmatter}${page.content.trimStart()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readStringValue(source: string, key: string, fallback = "") {
|
||||||
|
const pattern = new RegExp(`${key}:\\s*["'\`]([^"'\`]+)["'\`]`);
|
||||||
|
return source.match(pattern)?.[1] ?? fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readBooleanValue(source: string, key: string, fallback = false) {
|
||||||
|
const pattern = new RegExp(`${key}:\\s*(true|false)`);
|
||||||
|
const value = source.match(pattern)?.[1];
|
||||||
|
return value ? value === "true" : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseSiteSettings(source: string): SiteSettings {
|
||||||
|
const socialLinksSource = source.match(/socialLinks:\s*\[([\s\S]*?)\]/)?.[1] ?? "";
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: readStringValue(source, "title", "VitePress Site"),
|
||||||
|
description: readStringValue(source, "description"),
|
||||||
|
logo: readStringValue(source, "logo", "/logo.svg"),
|
||||||
|
lastUpdated: readBooleanValue(source, "lastUpdated", true),
|
||||||
|
localSearch: /search:\s*{[\s\S]*?provider:\s*["']local["'][\s\S]*?}/.test(source),
|
||||||
|
outline: readBooleanValue(source, "outline", true),
|
||||||
|
socialKind: readStringValue(socialLinksSource, "icon", "github"),
|
||||||
|
socialLink: readStringValue(socialLinksSource, "link", "https://github.com/example/vitepress-cms"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stringifySiteConfig(settings: SiteSettings) {
|
||||||
|
const searchConfig = settings.localSearch
|
||||||
|
? ` search: {
|
||||||
|
provider: "local",
|
||||||
|
},`
|
||||||
|
: "";
|
||||||
|
|
||||||
|
return `import { defineConfig } from "vitepress";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
title: ${JSON.stringify(settings.title)},
|
||||||
|
description: ${JSON.stringify(settings.description)},
|
||||||
|
lastUpdated: ${settings.lastUpdated},
|
||||||
|
themeConfig: {
|
||||||
|
logo: ${JSON.stringify(settings.logo)},
|
||||||
|
outline: ${settings.outline},
|
||||||
|
nav: [
|
||||||
|
{ text: "首页", link: "/" },
|
||||||
|
{ text: "指南", link: "/guide/getting-started" },
|
||||||
|
{ text: "配置", link: "/guide/config" },
|
||||||
|
{ text: "更新日志", link: "/changelog" },
|
||||||
|
],
|
||||||
|
sidebar: {
|
||||||
|
"/guide/": [
|
||||||
|
{
|
||||||
|
text: "指南",
|
||||||
|
items: [
|
||||||
|
{ text: "快速开始", link: "/guide/getting-started" },
|
||||||
|
{ text: "页面管理", link: "/guide/pages" },
|
||||||
|
{ text: "配置说明", link: "/guide/config" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
socialLinks: [
|
||||||
|
{ icon: ${JSON.stringify(settings.socialKind)}, link: ${JSON.stringify(settings.socialLink)} },
|
||||||
|
],
|
||||||
|
footer: {
|
||||||
|
message: "Powered by VitePress-CMS",
|
||||||
|
copyright: "Copyright 2026",
|
||||||
|
},
|
||||||
|
${searchConfig}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
`;
|
||||||
|
}
|
||||||
38
test-vitepress/.vitepress/config.ts
Normal file
38
test-vitepress/.vitepress/config.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { defineConfig } from "vitepress";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
title: "VitePress CMS Test",
|
||||||
|
description: "A local VitePress site used by VitePress-CMS for integration tests.",
|
||||||
|
lastUpdated: true,
|
||||||
|
themeConfig: {
|
||||||
|
logo: "/logo.svg",
|
||||||
|
nav: [
|
||||||
|
{ text: "首页", link: "/" },
|
||||||
|
{ text: "指南", link: "/guide/getting-started" },
|
||||||
|
{ text: "配置", link: "/guide/config" },
|
||||||
|
{ text: "更新日志", link: "/changelog" },
|
||||||
|
],
|
||||||
|
sidebar: {
|
||||||
|
"/guide/": [
|
||||||
|
{
|
||||||
|
text: "指南",
|
||||||
|
items: [
|
||||||
|
{ text: "快速开始", link: "/guide/getting-started" },
|
||||||
|
{ text: "页面管理", link: "/guide/pages" },
|
||||||
|
{ text: "配置说明", link: "/guide/config" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
socialLinks: [
|
||||||
|
{ icon: "github", link: "https://github.com/example/vitepress-cms" },
|
||||||
|
],
|
||||||
|
footer: {
|
||||||
|
message: "Powered by VitePress-CMS",
|
||||||
|
copyright: "Copyright 2026",
|
||||||
|
},
|
||||||
|
search: {
|
||||||
|
provider: "local",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
24
test-vitepress/README.md
Normal file
24
test-vitepress/README.md
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# VitePress CMS Test Site
|
||||||
|
|
||||||
|
这是一个独立的 VitePress 测试站点,用来模拟真实用户项目。
|
||||||
|
|
||||||
|
VitePress-CMS 主项目不依赖 VitePress。后续 CMS 会通过文件系统或 GitHub API 读取这个站点的 Markdown 页面和 `.vitepress/config.ts`。
|
||||||
|
|
||||||
|
## 本地运行
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
默认开发地址:
|
||||||
|
|
||||||
|
```text
|
||||||
|
http://localhost:5175
|
||||||
|
```
|
||||||
|
|
||||||
|
## 构建
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
12
test-vitepress/changelog.md
Normal file
12
test-vitepress/changelog.md
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
---
|
||||||
|
title: 更新日志
|
||||||
|
description: 测试站点更新记录
|
||||||
|
---
|
||||||
|
|
||||||
|
# 更新日志
|
||||||
|
|
||||||
|
## v0.1.0
|
||||||
|
|
||||||
|
- 创建 VitePress 测试站点
|
||||||
|
- 添加导航栏、侧边栏和多篇 Markdown 页面
|
||||||
|
- 准备给 VitePress-CMS 做本地读取测试
|
||||||
18
test-vitepress/guide/config.md
Normal file
18
test-vitepress/guide/config.md
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
---
|
||||||
|
title: 配置说明
|
||||||
|
description: 测试 VitePress 配置解析
|
||||||
|
---
|
||||||
|
|
||||||
|
# 配置说明
|
||||||
|
|
||||||
|
这个页面用于测试 `.vitepress/config.ts` 的读取与回写。
|
||||||
|
|
||||||
|
## 第一版优先支持
|
||||||
|
|
||||||
|
- `title`
|
||||||
|
- `description`
|
||||||
|
- `themeConfig.logo`
|
||||||
|
- `themeConfig.nav`
|
||||||
|
- `themeConfig.sidebar`
|
||||||
|
- `themeConfig.socialLinks`
|
||||||
|
- `themeConfig.search`
|
||||||
17
test-vitepress/guide/getting-started.md
Normal file
17
test-vitepress/guide/getting-started.md
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
---
|
||||||
|
title: 快速开始
|
||||||
|
description: 测试站点的快速开始页面
|
||||||
|
---
|
||||||
|
|
||||||
|
# 快速开始
|
||||||
|
|
||||||
|
这个页面用于测试 VitePress-CMS 的 Markdown 编辑体验。
|
||||||
|
|
||||||
|
## 编辑流程
|
||||||
|
|
||||||
|
1. CMS 读取这个文件。
|
||||||
|
2. 用户在后台修改标题、描述和正文。
|
||||||
|
3. CMS 将修改保存回 Markdown 文件。
|
||||||
|
4. VitePress 重新构建站点。
|
||||||
|
|
||||||
|
> 这条链路跑通后,项目就从界面原型进入真实可用阶段。
|
||||||
20
test-vitepress/guide/pages.md
Normal file
20
test-vitepress/guide/pages.md
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
---
|
||||||
|
title: 页面管理
|
||||||
|
description: 测试页面目录、搜索和新增页面
|
||||||
|
---
|
||||||
|
|
||||||
|
# 页面管理
|
||||||
|
|
||||||
|
这个页面用于测试页面树和文件路径管理。
|
||||||
|
|
||||||
|
## 需要覆盖的场景
|
||||||
|
|
||||||
|
- 读取嵌套目录
|
||||||
|
- 新建 Markdown 页面
|
||||||
|
- 重命名页面
|
||||||
|
- 删除页面
|
||||||
|
- 移动页面位置
|
||||||
|
|
||||||
|
## 文件路径
|
||||||
|
|
||||||
|
当前文件路径是 `test-vitepress/guide/pages.md`。
|
||||||
20
test-vitepress/index.md
Normal file
20
test-vitepress/index.md
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
---
|
||||||
|
title: 首页
|
||||||
|
description: VitePress-CMS 测试站点首页
|
||||||
|
---
|
||||||
|
|
||||||
|
# VitePress CMS Test
|
||||||
|
|
||||||
|
这是一个给 VitePress-CMS 使用的本地测试站点。
|
||||||
|
|
||||||
|
它用来验证这些能力:
|
||||||
|
|
||||||
|
- 扫描 Markdown 页面
|
||||||
|
- 解析 frontmatter
|
||||||
|
- 读取和修改 `.vitepress/config.ts`
|
||||||
|
- 管理导航栏和侧边栏
|
||||||
|
- 构建真实 VitePress 站点
|
||||||
|
|
||||||
|
## 测试入口
|
||||||
|
|
||||||
|
从 [快速开始](/guide/getting-started) 开始查看测试内容。
|
||||||
2552
test-vitepress/package-lock.json
generated
Normal file
2552
test-vitepress/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
15
test-vitepress/package.json
Normal file
15
test-vitepress/package.json
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"name": "vitepress-cms-test-site",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"description": "A standalone VitePress site used to test VitePress-CMS integrations.",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vitepress dev . --host 0.0.0.0 --port 5175",
|
||||||
|
"build": "vitepress build .",
|
||||||
|
"preview": "vitepress preview . --host 0.0.0.0 --port 5176"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"vitepress": "^1.6.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
4
test-vitepress/public/logo.svg
Normal file
4
test-vitepress/public/logo.svg
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<svg width="64" height="64" viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="VC">
|
||||||
|
<rect width="64" height="64" rx="12" fill="#117a72"/>
|
||||||
|
<path d="M14 18h9l9 26 9-26h9L36 50h-8L14 18Z" fill="#fff"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 237 B |
17
tsconfig.json
Normal file
17
tsconfig.json
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "preserve",
|
||||||
|
"strict": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "src/**/*.vue"]
|
||||||
|
}
|
||||||
283
vite.config.ts
Normal file
283
vite.config.ts
Normal file
@@ -0,0 +1,283 @@
|
|||||||
|
import { createReadStream, promises as fs } from "node:fs";
|
||||||
|
import { extname, join, normalize, relative, sep } from "node:path";
|
||||||
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||||
|
import { defineConfig, type Plugin } from "vite";
|
||||||
|
import vue from "@vitejs/plugin-vue";
|
||||||
|
|
||||||
|
const siteRoot = join(process.cwd(), "test-vitepress");
|
||||||
|
|
||||||
|
interface LocalPagePayload {
|
||||||
|
path: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LocalSiteSettingsPayload {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
logo: string;
|
||||||
|
lastUpdated: boolean;
|
||||||
|
localSearch: boolean;
|
||||||
|
outline: boolean;
|
||||||
|
socialKind: string;
|
||||||
|
socialLink: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const configPath = join(siteRoot, ".vitepress", "config.ts");
|
||||||
|
|
||||||
|
function isMarkdownPath(filePath: string) {
|
||||||
|
return filePath.endsWith(".md") && !filePath.includes(`${sep}node_modules${sep}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toPosixPath(filePath: string) {
|
||||||
|
return filePath.split(sep).join("/");
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveSitePath(relativePath: string) {
|
||||||
|
const normalizedPath = normalize(join(siteRoot, relativePath));
|
||||||
|
|
||||||
|
if (!normalizedPath.startsWith(siteRoot) || !normalizedPath.endsWith(".md")) {
|
||||||
|
throw new Error("Invalid site path");
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalizedPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseFrontmatter(source: string) {
|
||||||
|
const match = source.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/);
|
||||||
|
|
||||||
|
if (!match) {
|
||||||
|
return {
|
||||||
|
title: "",
|
||||||
|
description: "",
|
||||||
|
content: source,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const frontmatter = match[1];
|
||||||
|
const title = frontmatter.match(/^title:\s*(.+)$/m)?.[1]?.trim().replace(/^["']|["']$/g, "") ?? "";
|
||||||
|
const description =
|
||||||
|
frontmatter.match(/^description:\s*(.+)$/m)?.[1]?.trim().replace(/^["']|["']$/g, "") ?? "";
|
||||||
|
|
||||||
|
return {
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
content: source.slice(match[0].length),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringifyPage(page: LocalPagePayload) {
|
||||||
|
const frontmatter = [
|
||||||
|
"---",
|
||||||
|
`title: ${page.title || "未命名页面"}`,
|
||||||
|
`description: ${page.description || ""}`,
|
||||||
|
"---",
|
||||||
|
"",
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
return `${frontmatter}${page.content.trimStart()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readStringValue(source: string, key: string, fallback = "") {
|
||||||
|
const pattern = new RegExp(`${key}:\\s*["'\`]([^"'\`]+)["'\`]`);
|
||||||
|
return source.match(pattern)?.[1] ?? fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readBooleanValue(source: string, key: string, fallback = false) {
|
||||||
|
const pattern = new RegExp(`${key}:\\s*(true|false)`);
|
||||||
|
const value = source.match(pattern)?.[1];
|
||||||
|
return value ? value === "true" : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseSiteSettings(source: string): LocalSiteSettingsPayload {
|
||||||
|
const socialLinksSource = source.match(/socialLinks:\s*\[([\s\S]*?)\]/)?.[1] ?? "";
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: readStringValue(source, "title", "VitePress Site"),
|
||||||
|
description: readStringValue(source, "description"),
|
||||||
|
logo: readStringValue(source, "logo", "/logo.svg"),
|
||||||
|
lastUpdated: readBooleanValue(source, "lastUpdated", true),
|
||||||
|
localSearch: /search:\s*{[\s\S]*?provider:\s*["']local["'][\s\S]*?}/.test(source),
|
||||||
|
outline: readBooleanValue(source, "outline", true),
|
||||||
|
socialKind: readStringValue(socialLinksSource, "icon", "github"),
|
||||||
|
socialLink: readStringValue(socialLinksSource, "link", "https://github.com/example/vitepress-cms"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringifySiteConfig(settings: LocalSiteSettingsPayload) {
|
||||||
|
const searchConfig = settings.localSearch
|
||||||
|
? ` search: {
|
||||||
|
provider: "local",
|
||||||
|
},`
|
||||||
|
: "";
|
||||||
|
|
||||||
|
return `import { defineConfig } from "vitepress";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
title: ${JSON.stringify(settings.title)},
|
||||||
|
description: ${JSON.stringify(settings.description)},
|
||||||
|
lastUpdated: ${settings.lastUpdated},
|
||||||
|
themeConfig: {
|
||||||
|
logo: ${JSON.stringify(settings.logo)},
|
||||||
|
outline: ${settings.outline},
|
||||||
|
nav: [
|
||||||
|
{ text: "首页", link: "/" },
|
||||||
|
{ text: "指南", link: "/guide/getting-started" },
|
||||||
|
{ text: "配置", link: "/guide/config" },
|
||||||
|
{ text: "更新日志", link: "/changelog" },
|
||||||
|
],
|
||||||
|
sidebar: {
|
||||||
|
"/guide/": [
|
||||||
|
{
|
||||||
|
text: "指南",
|
||||||
|
items: [
|
||||||
|
{ text: "快速开始", link: "/guide/getting-started" },
|
||||||
|
{ text: "页面管理", link: "/guide/pages" },
|
||||||
|
{ text: "配置说明", link: "/guide/config" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
socialLinks: [
|
||||||
|
{ icon: ${JSON.stringify(settings.socialKind)}, link: ${JSON.stringify(settings.socialLink)} },
|
||||||
|
],
|
||||||
|
footer: {
|
||||||
|
message: "Powered by VitePress-CMS",
|
||||||
|
copyright: "Copyright 2026",
|
||||||
|
},
|
||||||
|
${searchConfig}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function collectMarkdownFiles(dir: string): Promise<string[]> {
|
||||||
|
const entries = await fs.readdir(dir, { withFileTypes: true });
|
||||||
|
const nestedFiles = await Promise.all(
|
||||||
|
entries.map(async (entry) => {
|
||||||
|
const entryPath = join(dir, entry.name);
|
||||||
|
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
if (entry.name === "node_modules" || entry.name === ".vitepress") {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return collectMarkdownFiles(entryPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
return isMarkdownPath(entryPath) ? [entryPath] : [];
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
return nestedFiles.flat();
|
||||||
|
}
|
||||||
|
|
||||||
|
function readRequestBody(request: IncomingMessage) {
|
||||||
|
return new Promise<string>((resolve, reject) => {
|
||||||
|
let body = "";
|
||||||
|
request.on("data", (chunk) => {
|
||||||
|
body += chunk;
|
||||||
|
});
|
||||||
|
request.on("end", () => resolve(body));
|
||||||
|
request.on("error", reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendJson(response: ServerResponse, status: number, payload: unknown) {
|
||||||
|
response.statusCode = status;
|
||||||
|
response.setHeader("Content-Type", "application/json; charset=utf-8");
|
||||||
|
response.end(JSON.stringify(payload));
|
||||||
|
}
|
||||||
|
|
||||||
|
function localSiteApi(): Plugin {
|
||||||
|
return {
|
||||||
|
name: "vitepress-cms-local-site-api",
|
||||||
|
configureServer(server) {
|
||||||
|
server.middlewares.use("/api/local-site/pages", async (request, response) => {
|
||||||
|
try {
|
||||||
|
if (request.method === "GET") {
|
||||||
|
const files = await collectMarkdownFiles(siteRoot);
|
||||||
|
const contentFiles = files.filter((filePath) => {
|
||||||
|
const sitePath = toPosixPath(relative(siteRoot, filePath));
|
||||||
|
return sitePath !== "README.md";
|
||||||
|
});
|
||||||
|
const pages = await Promise.all(
|
||||||
|
contentFiles.sort().map(async (filePath) => {
|
||||||
|
const source = await fs.readFile(filePath, "utf-8");
|
||||||
|
const parsed = parseFrontmatter(source);
|
||||||
|
const path = toPosixPath(relative(siteRoot, filePath));
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: path,
|
||||||
|
path,
|
||||||
|
title: parsed.title || path.replace(/\.md$/, ""),
|
||||||
|
description: parsed.description,
|
||||||
|
content: parsed.content,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
sendJson(response, 200, { pages });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.method === "PUT") {
|
||||||
|
const payload = JSON.parse(await readRequestBody(request)) as LocalPagePayload;
|
||||||
|
const filePath = resolveSitePath(payload.path);
|
||||||
|
await fs.writeFile(filePath, stringifyPage(payload), "utf-8");
|
||||||
|
|
||||||
|
sendJson(response, 200, { ok: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
sendJson(response, 405, { error: "Method not allowed" });
|
||||||
|
} catch (error) {
|
||||||
|
sendJson(response, 500, {
|
||||||
|
error: error instanceof Error ? error.message : "Unknown local site API error",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
server.middlewares.use("/api/local-site/settings", async (request, response) => {
|
||||||
|
try {
|
||||||
|
if (request.method === "GET") {
|
||||||
|
const source = await fs.readFile(configPath, "utf-8");
|
||||||
|
sendJson(response, 200, { settings: parseSiteSettings(source) });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.method === "PUT") {
|
||||||
|
const settings = JSON.parse(await readRequestBody(request)) as LocalSiteSettingsPayload;
|
||||||
|
await fs.writeFile(configPath, stringifySiteConfig(settings), "utf-8");
|
||||||
|
sendJson(response, 200, { ok: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
sendJson(response, 405, { error: "Method not allowed" });
|
||||||
|
} catch (error) {
|
||||||
|
sendJson(response, 500, {
|
||||||
|
error: error instanceof Error ? error.message : "Unknown local site settings API error",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
server.middlewares.use("/api/local-site/assets/", (request, response, next) => {
|
||||||
|
const url = request.url ?? "";
|
||||||
|
const filePath = normalize(join(siteRoot, "public", decodeURIComponent(url)));
|
||||||
|
|
||||||
|
if (!filePath.startsWith(join(siteRoot, "public"))) {
|
||||||
|
sendJson(response, 403, { error: "Forbidden" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
response.setHeader("Content-Type", extname(filePath) === ".svg" ? "image/svg+xml" : "application/octet-stream");
|
||||||
|
createReadStream(filePath).on("error", next).pipe(response);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [localSiteApi(), vue()],
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user