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