Compare commits

..

2 Commits

Author SHA1 Message Date
Coldsmile_7 db6d6de74a feat: add GitHub site initialization 2026-06-12 00:02:23 +08:00
Coldsmile_7 d5f7e06e4e feat: add system settings panel 2026-06-08 22:50:18 +08:00
10 changed files with 886 additions and 41 deletions
+4 -2
View File
@@ -1,4 +1,4 @@
import { db, getSetting, migrate, setSetting } from "./db.mjs"; import { db, migrate, setSetting } from "./db.mjs";
import { hashPassword } from "./security.mjs"; import { hashPassword } from "./security.mjs";
const defaultAdminEmail = process.env.CMS_ADMIN_EMAIL || "admin@example.com"; const defaultAdminEmail = process.env.CMS_ADMIN_EMAIL || "admin@example.com";
@@ -33,7 +33,9 @@ function seedSettings() {
}; };
Object.entries(seeds).forEach(([key, value]) => { Object.entries(seeds).forEach(([key, value]) => {
if (getSetting(key, undefined) === undefined) { const existing = db.prepare("SELECT key FROM system_settings WHERE key = ?").get(key);
if (!existing) {
setSetting(key, value, key.includes("secret") || key.includes("password")); setSetting(key, value, key.includes("secret") || key.includes("password"));
} }
}); });
+507 -19
View File
@@ -1,10 +1,195 @@
import { randomBytes } from "node:crypto"; import { randomBytes } from "node:crypto";
import { db } from "./db.mjs"; import { spawn } from "node:child_process";
import { request as httpRequest } from "node:http";
import { request as httpsRequest } from "node:https";
import { db, getSetting } from "./db.mjs";
import { json, readBody, redirect } from "./http.mjs"; import { json, readBody, redirect } from "./http.mjs";
import { getRequestSession } from "./auth.mjs"; import { getRequestSession } from "./auth.mjs";
import { createSession, sessionCookie, updateSession } from "./session.mjs"; import { createSession, sessionCookie, updateSession } from "./session.mjs";
const oauthStates = new Map(); const oauthStates = new Map();
const githubRequestTimeoutMs = Number(process.env.GITHUB_HTTP_TIMEOUT_MS || 60000);
const githubUserAgent = "VitePress-CMS";
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function isRetriableNetworkError(error) {
return ["ECONNRESET", "ETIMEDOUT", "EAI_AGAIN", "ENOTFOUND", "ECONNREFUSED"].includes(error?.code);
}
function canUsePowerShellFallback(targetUrl) {
const url = new URL(targetUrl);
return process.platform === "win32" && url.hostname === "github.com";
}
function requestTextWithPowerShell(targetUrl, init = {}) {
return new Promise((resolve, reject) => {
const payload = Buffer.from(
JSON.stringify({
url: targetUrl,
method: init.method || "GET",
headers: init.headers || {},
body: init.body || "",
}),
"utf-8",
).toString("base64");
const script = `
$ErrorActionPreference = 'Stop'
$payload = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($args[0])) | ConvertFrom-Json
$headers = @{}
if ($payload.headers) {
foreach ($property in $payload.headers.PSObject.Properties) {
$headers[$property.Name] = [string]$property.Value
}
}
$params = @{
Uri = [string]$payload.url
Method = [string]$payload.method
Headers = $headers
TimeoutSec = 180
UseBasicParsing = $true
}
if ($payload.body -ne $null -and [string]$payload.body -ne '') {
$params.Body = [string]$payload.body
}
try {
$response = Invoke-WebRequest @params
$result = @{
ok = ($response.StatusCode -ge 200 -and $response.StatusCode -lt 300)
status = [int]$response.StatusCode
headers = @{}
text = [string]$response.Content
}
} catch {
$webResponse = $_.Exception.Response
if ($webResponse -eq $null) {
throw
}
$stream = $webResponse.GetResponseStream()
$reader = New-Object IO.StreamReader($stream)
$text = $reader.ReadToEnd()
$status = [int]$webResponse.StatusCode
$result = @{
ok = ($status -ge 200 -and $status -lt 300)
status = $status
headers = @{}
text = [string]$text
}
}
$result | ConvertTo-Json -Compress -Depth 5
`;
const encodedCommand = Buffer.from(script, "utf16le").toString("base64");
const child = spawn("powershell.exe", ["-NoProfile", "-NonInteractive", "-EncodedCommand", encodedCommand, payload], {
windowsHide: true,
});
const stdout = [];
const stderr = [];
const timeout = setTimeout(() => {
child.kill();
reject(new Error("PowerShell GitHub request timed out"));
}, 190000);
child.stdout.on("data", (chunk) => stdout.push(chunk));
child.stderr.on("data", (chunk) => stderr.push(chunk));
child.on("error", (error) => {
clearTimeout(timeout);
reject(error);
});
child.on("close", (code) => {
clearTimeout(timeout);
if (code !== 0) {
reject(new Error(Buffer.concat(stderr).toString("utf-8") || `PowerShell exited with ${code}`));
return;
}
try {
resolve(JSON.parse(Buffer.concat(stdout).toString("utf-8")));
} catch (error) {
reject(error);
}
});
});
}
function requestText(targetUrl, init = {}) {
return new Promise((resolve, reject) => {
const url = new URL(targetUrl);
const transport = url.protocol === "http:" ? httpRequest : httpsRequest;
const headers = { ...init.headers };
const body = init.body;
if (typeof body === "string" && !headers["Content-Length"]) {
headers["Content-Length"] = Buffer.byteLength(body);
}
const request = transport(
url,
{
method: init.method || "GET",
headers,
timeout: githubRequestTimeoutMs,
},
(response) => {
const chunks = [];
response.on("data", (chunk) => chunks.push(chunk));
response.on("end", () => {
resolve({
ok: response.statusCode >= 200 && response.statusCode < 300,
status: response.statusCode,
headers: response.headers,
text: Buffer.concat(chunks).toString("utf-8"),
});
});
},
);
request.on("timeout", () => {
request.destroy(new Error(`GitHub request timed out after ${githubRequestTimeoutMs}ms`));
});
request.on("error", reject);
if (body) request.write(body);
request.end();
});
}
async function requestJson(targetUrl, init = {}) {
let response;
for (let attempt = 1; attempt <= 3; attempt += 1) {
try {
response = await requestText(targetUrl, init);
break;
} catch (error) {
if (canUsePowerShellFallback(targetUrl) && isRetriableNetworkError(error)) {
response = await requestTextWithPowerShell(targetUrl, init);
break;
}
if (attempt === 3 || !isRetriableNetworkError(error)) {
throw error;
}
await sleep(1000 * attempt);
}
}
let payload = null;
if (response?.text) {
try {
payload = JSON.parse(response.text);
} catch {
payload = null;
}
}
return { ...response, payload };
}
function getBaseUrl(request) { function getBaseUrl(request) {
const configuredBaseUrl = process.env.GITHUB_OAUTH_REDIRECT_BASE_URL; const configuredBaseUrl = process.env.GITHUB_OAUTH_REDIRECT_BASE_URL;
@@ -15,39 +200,64 @@ function getBaseUrl(request) {
return `${protocol}://${host}`; return `${protocol}://${host}`;
} }
function trimTrailingSlash(value) {
return value.replace(/\/$/, "");
}
function getSettingOrFallback(key, fallback = "") {
const value = getSetting(key, "");
return value || fallback;
}
function getGithubConfig() {
return {
apiBaseUrl: trimTrailingSlash(getSettingOrFallback("github.api_base_url", process.env.GITHUB_API_BASE_URL || "https://api.github.com")),
webBaseUrl: trimTrailingSlash(getSettingOrFallback("github.web_base_url", process.env.GITHUB_WEB_BASE_URL || "https://github.com")),
oauthEnabled: getSettingOrFallback("auth.allow_github_login", "true") === "true",
clientId: getSettingOrFallback("github.oauth_client_id", process.env.GITHUB_CLIENT_ID || ""),
clientSecret: getSettingOrFallback("github.oauth_client_secret", process.env.GITHUB_CLIENT_SECRET || ""),
};
}
function getOAuthConfig(request) { function getOAuthConfig(request) {
const clientId = process.env.GITHUB_CLIENT_ID; const config = getGithubConfig();
const clientSecret = process.env.GITHUB_CLIENT_SECRET;
const baseUrl = getBaseUrl(request); const baseUrl = getBaseUrl(request);
if (!clientId || !clientSecret) { if (!config.oauthEnabled) {
throw new Error("GitHub login is disabled in system settings");
}
if (!config.clientId || !config.clientSecret) {
throw new Error("请先配置 GITHUB_CLIENT_ID 和 GITHUB_CLIENT_SECRET"); throw new Error("请先配置 GITHUB_CLIENT_ID 和 GITHUB_CLIENT_SECRET");
} }
return { return {
clientId, ...config,
clientSecret,
redirectUri: `${baseUrl}/api/github/callback`, redirectUri: `${baseUrl}/api/github/callback`,
}; };
} }
async function githubFetch(session, path, init = {}) { async function githubFetch(session, path, init = {}) {
const response = await fetch(`https://api.github.com${path}`, { const { apiBaseUrl } = getGithubConfig();
const response = await requestJson(`${apiBaseUrl}${path}`, {
...init, ...init,
headers: { headers: {
Accept: "application/vnd.github+json", Accept: "application/vnd.github+json",
Authorization: `Bearer ${session.accessToken}`, Authorization: `Bearer ${session.accessToken}`,
"User-Agent": githubUserAgent,
"X-GitHub-Api-Version": "2022-11-28", "X-GitHub-Api-Version": "2022-11-28",
...init.headers, ...init.headers,
}, },
}); });
if (!response.ok) { if (!response.ok) {
const details = await response.text(); const details = response.text;
throw new Error(`GitHub API error ${response.status}: ${details}`); const error = new Error(`GitHub API error ${response.status}: ${details}`);
error.statusCode = response.status;
throw error;
} }
return response.json(); return response.payload;
} }
function requireSession(request, response) { function requireSession(request, response) {
@@ -120,18 +330,18 @@ function findOrCreateGithubUser(githubUser, accessToken, currentSession) {
} }
async function handleLogin(request, response) { async function handleLogin(request, response) {
const { clientId, redirectUri } = getOAuthConfig(request); const { clientId, redirectUri, webBaseUrl } = getOAuthConfig(request);
const state = randomBytes(16).toString("hex"); const state = randomBytes(16).toString("hex");
oauthStates.set(state, Date.now()); oauthStates.set(state, Date.now());
const params = new URLSearchParams({ const params = new URLSearchParams({
client_id: clientId, client_id: clientId,
redirect_uri: redirectUri, redirect_uri: redirectUri,
scope: "repo", scope: "repo workflow",
state, state,
}); });
redirect(response, `https://github.com/login/oauth/authorize?${params.toString()}`); redirect(response, `${webBaseUrl}/login/oauth/authorize?${params.toString()}`);
} }
async function handleCallback(request, response, url) { async function handleCallback(request, response, url) {
@@ -144,12 +354,13 @@ async function handleCallback(request, response, url) {
} }
oauthStates.delete(state); oauthStates.delete(state);
const { clientId, clientSecret, redirectUri } = getOAuthConfig(request); const { clientId, clientSecret, redirectUri, apiBaseUrl, webBaseUrl } = getOAuthConfig(request);
const tokenResponse = await fetch("https://github.com/login/oauth/access_token", { const tokenResponse = await requestJson(`${webBaseUrl}/login/oauth/access_token`, {
method: "POST", method: "POST",
headers: { headers: {
Accept: "application/json", Accept: "application/json",
"Content-Type": "application/json", "Content-Type": "application/json",
"User-Agent": githubUserAgent,
}, },
body: JSON.stringify({ body: JSON.stringify({
client_id: clientId, client_id: clientId,
@@ -158,21 +369,27 @@ async function handleCallback(request, response, url) {
redirect_uri: redirectUri, redirect_uri: redirectUri,
}), }),
}); });
const tokenPayload = await tokenResponse.json(); const tokenPayload = tokenResponse.payload ?? {};
if (!tokenPayload.access_token) { if (!tokenPayload.access_token) {
redirect(response, "/?github=token_error"); redirect(response, "/?github=token_error");
return; return;
} }
const userResponse = await fetch("https://api.github.com/user", { const userResponse = await requestJson(`${apiBaseUrl}/user`, {
headers: { headers: {
Accept: "application/vnd.github+json", Accept: "application/vnd.github+json",
Authorization: `Bearer ${tokenPayload.access_token}`, Authorization: `Bearer ${tokenPayload.access_token}`,
"User-Agent": githubUserAgent,
"X-GitHub-Api-Version": "2022-11-28", "X-GitHub-Api-Version": "2022-11-28",
}, },
}); });
const user = await userResponse.json();
if (!userResponse.ok) {
throw new Error(`GitHub user API error ${userResponse.status}: ${userResponse.text}`);
}
const user = userResponse.payload ?? {};
const { session: currentSession } = getRequestSession(request); const { session: currentSession } = getRequestSession(request);
const cmsUser = findOrCreateGithubUser(user, tokenPayload.access_token, currentSession); const cmsUser = findOrCreateGithubUser(user, tokenPayload.access_token, currentSession);
const sessionId = createSession({ const sessionId = createSession({
@@ -290,6 +507,147 @@ function encodeBase64Content(content) {
return Buffer.from(content, "utf-8").toString("base64"); return Buffer.from(content, "utf-8").toString("base64");
} }
function packageJsonTemplate(siteRoot) {
const root = normalizeSiteRoot(siteRoot) || ".";
return `${JSON.stringify(
{
scripts: {
"docs:dev": `vitepress dev ${root}`,
"docs:build": `vitepress build ${root}`,
"docs:preview": `vitepress preview ${root}`,
},
devDependencies: {
vitepress: "^1.6.4",
},
},
null,
2,
)}
`;
}
function siteConfigTemplate() {
return `import { defineConfig } from "vitepress";
export default defineConfig({
title: "VitePress Site",
description: "Created by VitePress-CMS",
lastUpdated: true,
themeConfig: {
nav: [
{ text: "首页", link: "/" },
{ text: "指南", link: "/guide/getting-started" },
],
sidebar: {
"/guide/": [
{
text: "指南",
items: [
{ text: "快速开始", link: "/guide/getting-started" },
],
},
],
},
search: {
provider: "local",
},
footer: {
message: "Powered by VitePress-CMS",
copyright: "Copyright 2026",
},
},
});
`;
}
function deployWorkflowTemplate(siteRoot, branch) {
const root = normalizeSiteRoot(siteRoot);
const outputDir = root ? `${root}/.vitepress/dist` : ".vitepress/dist";
return `name: Deploy VitePress site to Pages
on:
push:
branches: [${branch || "main"}]
workflow_dispatch:
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: pages
cancel-in-progress: false
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm install
- run: npm run docs:build
- uses: actions/upload-pages-artifact@v3
with:
path: ${outputDir}
deploy:
environment:
name: github-pages
url: \${{ steps.deployment.outputs.page_url }}
needs: build
runs-on: ubuntu-latest
steps:
- id: deployment
uses: actions/deploy-pages@v4
`;
}
function initTemplateFiles(siteRoot, branch) {
return [
{
path: "package.json",
content: packageJsonTemplate(siteRoot),
},
{
path: toRepoPath(siteRoot, ".vitepress/config.ts"),
content: siteConfigTemplate(),
},
{
path: toRepoPath(siteRoot, "index.md"),
content: `---
title: 首页
description: VitePress-CMS 初始化页面
---
# VitePress Site
这个站点由 VitePress-CMS 初始化。
`,
},
{
path: toRepoPath(siteRoot, "guide/getting-started.md"),
content: `---
title: 快速开始
description: 使用 VitePress-CMS 管理内容
---
# 快速开始
这里是你的第一篇指南文档。
`,
},
{
path: ".github/workflows/deploy.yml",
content: deployWorkflowTemplate(siteRoot, branch),
},
];
}
function parseFrontmatter(source) { function parseFrontmatter(source) {
const match = source.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/); const match = source.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/);
@@ -419,6 +777,131 @@ async function getRepoContent(session, site, path) {
); );
} }
async function getRepoPathContent(session, site, repoPath) {
const encodedPath = repoPath.split("/").map(encodeURIComponent).join("/");
return githubFetch(
session,
`/repos/${site.owner}/${site.repo}/contents/${encodedPath}?ref=${encodeURIComponent(site.branch)}`,
);
}
async function createRepoPathFile(session, site, file) {
try {
const existing = await getRepoPathContent(session, site, file.path);
if (file.path.startsWith(".github/workflows/")) {
const encodedPath = file.path.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 ${file.path} from VitePress-CMS`,
content: encodeBase64Content(file.content),
branch: site.branch,
sha: existing.sha,
}),
});
return {
path: file.path,
status: "updated",
sha: result.content.sha,
};
}
return {
path: file.path,
status: "skipped",
};
} catch (error) {
if (file.path.startsWith(".github/workflows/") && error?.statusCode === 403) {
return {
path: file.path,
status: "failed",
error: error.message,
};
}
if (error?.statusCode !== 404) {
throw error;
}
}
const encodedPath = file.path.split("/").map(encodeURIComponent).join("/");
let result;
try {
result = await githubFetch(session, `/repos/${site.owner}/${site.repo}/contents/${encodedPath}`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
message: `docs: initialize ${file.path} from VitePress-CMS`,
content: encodeBase64Content(file.content),
branch: site.branch,
}),
});
} catch (error) {
if (file.path.startsWith(".github/workflows/") && [403, 404].includes(error?.statusCode)) {
return {
path: file.path,
status: "failed",
error: error.message,
};
}
if (isRetriableNetworkError(error) || error?.statusCode === 422) {
const existing = await getRepoPathContent(session, site, file.path).catch(() => null);
if (existing) {
return {
path: file.path,
status: "skipped",
sha: existing.sha,
};
}
}
throw error;
}
return {
path: file.path,
status: "created",
sha: result.content.sha,
};
}
async function handleInitSite(request, response) {
const session = requireSession(request, response);
if (!session) return;
const body = JSON.parse(await readBody(request) || "{}");
const site = body.site;
if (!site?.owner || !site?.repo) {
json(response, 400, { error: "owner and repo are required" });
return;
}
const normalizedSite = {
owner: String(site.owner),
repo: String(site.repo),
branch: String(site.branch || "main"),
siteRoot: normalizeSiteRoot(String(site.siteRoot || "")),
};
const files = [];
for (const file of initTemplateFiles(normalizedSite.siteRoot, normalizedSite.branch)) {
files.push(await createRepoPathFile(session, normalizedSite, file));
}
json(response, 200, { files });
}
async function handleSitePages(request, response, url) { async function handleSitePages(request, response, url) {
const session = requireSession(request, response); const session = requireSession(request, response);
if (!session) return; if (!session) return;
@@ -557,6 +1040,11 @@ export async function handleGithubApi(request, response, url) {
return true; return true;
} }
if (url.pathname === "/api/github/site/init" && request.method === "POST") {
await handleInitSite(request, response);
return true;
}
if (url.pathname === "/api/github/site/pages") { if (url.pathname === "/api/github/site/pages") {
await handleSitePages(request, response, url); await handleSitePages(request, response, url);
return true; return true;
@@ -569,7 +1057,7 @@ export async function handleGithubApi(request, response, url) {
return false; return false;
} catch (error) { } catch (error) {
json(response, 500, { json(response, error?.statusCode || 500, {
error: error instanceof Error ? error.message : "Unknown GitHub API error", error: error instanceof Error ? error.message : "Unknown GitHub API error",
}); });
return true; return true;
+4
View File
@@ -42,6 +42,10 @@ async function handleSaveSettings(request, response) {
settings.forEach((setting) => { settings.forEach((setting) => {
if (!setting.key) return; if (!setting.key) return;
const existing = db.prepare("SELECT encrypted FROM system_settings WHERE key = ?").get(String(setting.key));
const shouldKeepExistingSecret = Boolean(existing?.encrypted) && Boolean(setting.encrypted) && !String(setting.value ?? "");
if (shouldKeepExistingSecret) return;
setSetting(String(setting.key), String(setting.value ?? ""), Boolean(setting.encrypted)); setSetting(String(setting.key), String(setting.value ?? ""), Boolean(setting.encrypted));
}); });
+130 -6
View File
@@ -1,6 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, reactive, ref } from "vue"; import { computed, onMounted, reactive, ref } from "vue";
import { loadCurrentUser, loginWithEmail, logoutCms } from "./api/auth"; import { loadCurrentUser, loginWithEmail, logoutCms } from "./api/auth";
import { loadSystemSettings, saveSystemSettings } from "./api/adminSettings";
import { import {
createGitHubRepository, createGitHubRepository,
loadGitHubRepositories, loadGitHubRepositories,
@@ -9,6 +10,7 @@ import {
startGitHubLogin, startGitHubLogin,
} from "./api/githubAuth"; } from "./api/githubAuth";
import { import {
initializeGitHubSite,
loadGitHubPages, loadGitHubPages,
loadGitHubSettings, loadGitHubSettings,
saveGitHubPage, saveGitHubPage,
@@ -28,17 +30,35 @@ import ContentPanel from "./components/ContentPanel.vue";
import LoginView from "./components/LoginView.vue"; import LoginView from "./components/LoginView.vue";
import PublishPanel from "./components/PublishPanel.vue"; import PublishPanel from "./components/PublishPanel.vue";
import StructurePanel from "./components/StructurePanel.vue"; import StructurePanel from "./components/StructurePanel.vue";
import SystemSettingsPanel from "./components/SystemSettingsPanel.vue";
import { mockNavItems, mockPages, mockSidebarGroups, mockSiteSettings } from "./data/mockSite"; import { mockNavItems, mockPages, mockSidebarGroups, mockSiteSettings } from "./data/mockSite";
import type { CmsUser, DataSource, DocPage, GitHubConnection, GitHubRepository, GitHubUser, PanelKey } from "./types"; import type {
CmsUser,
DataSource,
DocPage,
GitHubConnection,
GitHubRepository,
GitHubUser,
PanelKey,
SystemSetting,
} from "./types";
const panels: Array<{ key: PanelKey; label: string; icon: string }> = [ const panels = computed<Array<{ key: PanelKey; label: string; icon: string }>>(() => {
const basePanels: Array<{ key: PanelKey; label: string; icon: string }> = [
{ key: "account", label: "用户中心", icon: "U" }, { key: "account", label: "用户中心", icon: "U" },
{ key: "connect", label: "仓库连接", icon: "G" }, { key: "connect", label: "仓库连接", icon: "G" },
{ key: "content", label: "页面管理", icon: "P" }, { key: "content", label: "页面管理", icon: "P" },
{ key: "structure", label: "站点结构", icon: "S" }, { key: "structure", label: "站点结构", icon: "S" },
{ key: "config", label: "主题配置", icon: "C" }, { key: "config", label: "主题配置", icon: "C" },
{ key: "publish", label: "发布中心", icon: "R" }, { key: "publish", label: "发布中心", icon: "R" },
]; ];
if (currentUser.value?.role === "system_admin") {
basePanels.push({ key: "admin", label: "系统设置", icon: "A" });
}
return basePanels;
});
const githubConnection = reactive<GitHubConnection>({ const githubConnection = reactive<GitHubConnection>({
token: "", token: "",
@@ -60,26 +80,33 @@ const sidebarGroups = reactive(
})), })),
); );
const siteSettings = reactive({ ...mockSiteSettings }); const siteSettings = reactive({ ...mockSiteSettings });
const systemSettings = ref<SystemSetting[]>([]);
const activePageId = ref(""); const activePageId = ref("");
const settingsSha = ref<string>(); const settingsSha = ref<string>();
const isLoadingPages = ref(false); const isLoadingPages = ref(false);
const isConnecting = ref(false); const isConnecting = ref(false);
const isLoadingRepos = ref(false); const isLoadingRepos = ref(false);
const isCreatingRepository = ref(false);
const isInitializingSite = ref(false);
const pageSaveState = ref<"idle" | "saving" | "saved" | "error">("idle"); const pageSaveState = ref<"idle" | "saving" | "saved" | "error">("idle");
const settingsSaveState = ref<"idle" | "saving" | "saved" | "error">("idle"); const settingsSaveState = ref<"idle" | "saving" | "saved" | "error">("idle");
const systemSettingsSaveState = ref<"idle" | "saving" | "saved" | "error">("idle");
const githubUser = ref<GitHubUser | null>(null); const githubUser = ref<GitHubUser | null>(null);
const currentUser = ref<CmsUser | null>(null); const currentUser = ref<CmsUser | null>(null);
const repositories = ref<GitHubRepository[]>([]); const repositories = ref<GitHubRepository[]>([]);
const isAuthLoading = ref(true); const isAuthLoading = ref(true);
const loginError = ref(""); const loginError = ref("");
const connectionMessage = ref("");
const repositoryMessage = ref("");
const activityItems = ref(["等待读取站点"]); const activityItems = ref(["等待读取站点"]);
const activePage = computed(() => pages.find((page) => page.id === activePageId.value) ?? pages[0]); const activePage = computed(() => pages.find((page) => page.id === activePageId.value) ?? pages[0]);
const activePanelTitle = computed(() => panels.find((panel) => panel.key === activePanel.value)?.label ?? ""); const activePanelTitle = computed(() => panels.value.find((panel) => panel.key === activePanel.value)?.label ?? "");
const repoLabel = computed(() => { const repoLabel = computed(() => {
if (dataSource.value === "github" && hasGitHubConnection.value) { if (dataSource.value === "github" && hasGitHubConnection.value) {
return `${githubConnection.owner}/${githubConnection.repo}`; return `${githubConnection.owner}/${githubConnection.repo}`;
} }
return "test-vitepress"; return "test-vitepress";
}); });
const repoBranch = computed(() => const repoBranch = computed(() =>
@@ -165,6 +192,19 @@ async function loadSettings() {
} }
} }
async function loadAdminSettings() {
if (currentUser.value?.role !== "system_admin") return;
try {
systemSettings.value = await loadSystemSettings();
} catch (error) {
activityItems.value = [
error instanceof Error ? error.message : "读取系统设置失败",
...activityItems.value,
];
}
}
async function reloadSite() { async function reloadSite() {
await Promise.all([loadPages(), loadSettings()]); await Promise.all([loadPages(), loadSettings()]);
} }
@@ -208,7 +248,7 @@ async function handleEmailLogin(payload: { email: string; password: string }) {
try { try {
currentUser.value = await loginWithEmail(payload.email, payload.password); currentUser.value = await loginWithEmail(payload.email, payload.password);
await loadAuthState(); await loadAuthState();
await reloadSite(); await Promise.all([reloadSite(), loadAdminSettings()]);
} catch (error) { } catch (error) {
loginError.value = error instanceof Error ? error.message : "登录失败"; loginError.value = error instanceof Error ? error.message : "登录失败";
} finally { } finally {
@@ -221,14 +261,17 @@ async function handleLogout() {
currentUser.value = null; currentUser.value = null;
githubUser.value = null; githubUser.value = null;
repositories.value = []; repositories.value = [];
systemSettings.value = [];
} }
async function connectGithub(connection: GitHubConnection) { async function connectGithub(connection: GitHubConnection) {
const nextConnection = normalizeConnection(connection); const nextConnection = normalizeConnection(connection);
connectionMessage.value = "";
try { try {
validateConnection(nextConnection); validateConnection(nextConnection);
} catch (error) { } catch (error) {
connectionMessage.value = error instanceof Error ? error.message : "连接信息不完整";
activityItems.value = [ activityItems.value = [
error instanceof Error ? error.message : "连接信息不完整", error instanceof Error ? error.message : "连接信息不完整",
...activityItems.value, ...activityItems.value,
@@ -237,6 +280,7 @@ async function connectGithub(connection: GitHubConnection) {
} }
isConnecting.value = true; isConnecting.value = true;
connectionMessage.value = `正在读取 GitHub 仓库 ${nextConnection.owner}/${nextConnection.repo}`;
try { try {
const [nextPages, nextSettings] = await Promise.all([ const [nextPages, nextSettings] = await Promise.all([
@@ -251,10 +295,15 @@ async function connectGithub(connection: GitHubConnection) {
Object.assign(siteSettings, nextSettings.settings); Object.assign(siteSettings, nextSettings.settings);
settingsSha.value = nextSettings.sha; settingsSha.value = nextSettings.sha;
activePanel.value = "content"; activePanel.value = "content";
connectionMessage.value = `已连接 GitHub 仓库 ${githubConnection.owner}/${githubConnection.repo}`;
activityItems.value = [`已连接 GitHub 仓库 ${githubConnection.owner}/${githubConnection.repo}`, ...activityItems.value]; activityItems.value = [`已连接 GitHub 仓库 ${githubConnection.owner}/${githubConnection.repo}`, ...activityItems.value];
} catch (error) { } catch (error) {
hasGitHubConnection.value = false; hasGitHubConnection.value = false;
dataSource.value = "local"; dataSource.value = "local";
const message = error instanceof Error ? error.message : "未知错误";
connectionMessage.value = message.includes("404") || message.includes("Not Found")
? "未找到 VitePress 配置文件 .vitepress/config.ts。这个仓库可能还是空仓库,需要先初始化 VitePress 模板。"
: message;
activityItems.value = [ activityItems.value = [
"GitHub 仓库连接失败,仍保持本地测试站点模式", "GitHub 仓库连接失败,仍保持本地测试站点模式",
error instanceof Error ? error.message : "未知错误", error instanceof Error ? error.message : "未知错误",
@@ -281,11 +330,24 @@ async function signOutGithub() {
} }
async function createRepository(payload: { name: string; private: boolean }) { async function createRepository(payload: { name: string; private: boolean }) {
isCreatingRepository.value = true;
repositoryMessage.value = `正在创建 GitHub 仓库 ${payload.name}`;
try { try {
const repo = await createGitHubRepository(payload); const repo = await createGitHubRepository(payload);
repositories.value = [repo, ...repositories.value]; repositories.value = [repo, ...repositories.value];
Object.assign(githubConnection, {
owner: repo.owner,
repo: repo.name,
branch: repo.defaultBranch || "main",
siteRoot: "",
});
repositoryMessage.value = `已创建 GitHub 仓库 ${repo.fullName}`;
isCreatingRepository.value = false;
activityItems.value = [`已创建 GitHub 仓库 ${repo.fullName}`, ...activityItems.value]; activityItems.value = [`已创建 GitHub 仓库 ${repo.fullName}`, ...activityItems.value];
} catch (error) { } catch (error) {
repositoryMessage.value = error instanceof Error ? error.message : "创建 GitHub 仓库失败";
isCreatingRepository.value = false;
activityItems.value = [ activityItems.value = [
error instanceof Error ? error.message : "创建 GitHub 仓库失败", error instanceof Error ? error.message : "创建 GitHub 仓库失败",
...activityItems.value, ...activityItems.value,
@@ -293,6 +355,40 @@ async function createRepository(payload: { name: string; private: boolean }) {
} }
} }
async function initializeRepository(connection: GitHubConnection) {
const nextConnection = normalizeConnection(connection);
connectionMessage.value = "";
try {
validateConnection(nextConnection);
} catch (error) {
connectionMessage.value = error instanceof Error ? error.message : "连接信息不完整";
return;
}
isInitializingSite.value = true;
connectionMessage.value = `正在初始化 VitePress 模板到 ${nextConnection.owner}/${nextConnection.repo}`;
try {
const result = await initializeGitHubSite(nextConnection);
const createdCount = result.files.filter((file) => file.status === "created").length;
const updatedCount = result.files.filter((file) => file.status === "updated").length;
const skippedCount = result.files.filter((file) => file.status === "skipped").length;
const failedCount = result.files.filter((file) => file.status === "failed").length;
connectionMessage.value = `初始化完成:创建 ${createdCount} 个文件,更新 ${updatedCount} 个文件,跳过 ${skippedCount} 个已存在文件,${failedCount} 个文件需要额外权限。正在读取仓库。`;
activityItems.value = [
`已初始化 VitePress 模板到 ${nextConnection.owner}/${nextConnection.repo}`,
...activityItems.value,
];
await connectGithub(nextConnection);
} catch (error) {
connectionMessage.value = error instanceof Error ? error.message : "初始化 VitePress 模板失败";
activityItems.value = [connectionMessage.value, ...activityItems.value];
} finally {
isInitializingSite.value = false;
}
}
function selectPage(pageId: string) { function selectPage(pageId: string) {
activePageId.value = pageId; activePageId.value = pageId;
pageSaveState.value = "idle"; pageSaveState.value = "idle";
@@ -357,6 +453,23 @@ async function saveSettings() {
} }
} }
async function saveAdminSettings(settings: SystemSetting[]) {
systemSettingsSaveState.value = "saving";
try {
await saveSystemSettings(settings);
systemSettingsSaveState.value = "saved";
await loadAdminSettings();
activityItems.value = ["已保存系统设置", ...activityItems.value];
} catch (error) {
systemSettingsSaveState.value = "error";
activityItems.value = [
error instanceof Error ? error.message : "保存系统设置失败",
...activityItems.value,
];
}
}
function publishChanges() { function publishChanges() {
activityItems.value = ["已创建 commit,并触发部署流程", ...activityItems.value]; activityItems.value = ["已创建 commit,并触发部署流程", ...activityItems.value];
} }
@@ -366,7 +479,7 @@ onMounted(async () => {
await loadAuthState(); await loadAuthState();
if (currentUser.value) { if (currentUser.value) {
await reloadSite(); await Promise.all([reloadSite(), loadAdminSettings()]);
} }
} finally { } finally {
isAuthLoading.value = false; isAuthLoading.value = false;
@@ -417,17 +530,28 @@ onMounted(async () => {
@github-logout="signOutGithub" @github-logout="signOutGithub"
@logout="handleLogout" @logout="handleLogout"
/> />
<SystemSettingsPanel
v-else-if="activePanel === 'admin'"
:save-state="systemSettingsSaveState"
:settings="systemSettings"
@save-settings="saveAdminSettings"
/>
<ConnectPanel <ConnectPanel
v-else-if="activePanel === 'connect'" v-else-if="activePanel === 'connect'"
:connection="githubConnection" :connection="githubConnection"
:connection-message="connectionMessage"
:data-source="dataSource" :data-source="dataSource"
:github-user="githubUser" :github-user="githubUser"
:has-git-hub-connection="hasGitHubConnection" :has-git-hub-connection="hasGitHubConnection"
:is-connecting="isConnecting" :is-connecting="isConnecting"
:is-creating-repository="isCreatingRepository"
:is-initializing-site="isInitializingSite"
:is-loading-repos="isLoadingRepos" :is-loading-repos="isLoadingRepos"
:repository-message="repositoryMessage"
:repositories="repositories" :repositories="repositories"
@connect-github="connectGithub" @connect-github="connectGithub"
@create-repository="createRepository" @create-repository="createRepository"
@initialize-repository="initializeRepository"
@load-repositories="refreshRepositories" @load-repositories="refreshRepositories"
@login-github="startGitHubLogin" @login-github="startGitHubLogin"
@logout-github="signOutGithub" @logout-github="signOutGithub"
+30
View File
@@ -0,0 +1,30 @@
import type { SystemSetting } from "../types";
interface SettingsResponse {
settings: SystemSetting[];
}
export async function loadSystemSettings() {
const response = await fetch("/api/admin/settings");
if (!response.ok) {
throw new Error(`读取系统设置失败:${response.status}`);
}
const data = (await response.json()) as SettingsResponse;
return data.settings;
}
export async function saveSystemSettings(settings: SystemSetting[]) {
const response = await fetch("/api/admin/settings", {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ settings }),
});
if (!response.ok) {
throw new Error(`保存系统设置失败:${response.status}`);
}
}
+2 -1
View File
@@ -62,7 +62,8 @@ export async function createGitHubRepository(payload: {
}); });
if (!response.ok) { if (!response.ok) {
throw new Error(`创建 GitHub 仓库失败:${response.status}`); const data = await response.json().catch(() => null);
throw new Error(data?.error || `创建 GitHub 仓库失败:${response.status}`);
} }
const data = (await response.json()) as CreateRepoResponse; const data = (await response.json()) as CreateRepoResponse;
+41 -4
View File
@@ -1,6 +1,15 @@
import type { DocPage, GitHubConnection, SiteSettings } from "../types"; import type { DocPage, GitHubConnection, SiteSettings } from "../types";
import { parseFrontmatter, parseSiteSettings, stringifyMarkdownPage, stringifySiteConfig } from "../utils/siteContent"; import { parseFrontmatter, parseSiteSettings, stringifyMarkdownPage, stringifySiteConfig } from "../utils/siteContent";
interface InitSiteResponse {
files: Array<{
path: string;
status: "created" | "updated" | "skipped" | "failed";
sha?: string;
error?: string;
}>;
}
interface GitTreeResponse { interface GitTreeResponse {
tree: Array<{ tree: Array<{
path: string; path: string;
@@ -71,6 +80,11 @@ function encodeBase64Content(content: string) {
return btoa(binary); return btoa(binary);
} }
async function readApiError(response: Response, fallback: string) {
const data = await response.json().catch(() => null);
return data?.error || `${fallback}${response.status}`;
}
async function githubFetch<T>(connection: GitHubConnection, path: string, init?: RequestInit) { async function githubFetch<T>(connection: GitHubConnection, path: string, init?: RequestInit) {
const response = await fetch(`https://api.github.com${path}`, { const response = await fetch(`https://api.github.com${path}`, {
...init, ...init,
@@ -101,7 +115,7 @@ export async function loadGitHubPages(connection: GitHubConnection) {
const response = await fetch(`/api/github/site/pages?${siteParams(connection).toString()}`); const response = await fetch(`/api/github/site/pages?${siteParams(connection).toString()}`);
if (!response.ok) { if (!response.ok) {
throw new Error(`读取 GitHub 页面失败${response.status}`); throw new Error(await readApiError(response, "读取 GitHub 页面失败"));
} }
const data = (await response.json()) as { pages: DocPage[] }; const data = (await response.json()) as { pages: DocPage[] };
@@ -159,7 +173,7 @@ export async function saveGitHubPage(connection: GitHubConnection, page: DocPage
}); });
if (!response.ok) { if (!response.ok) {
throw new Error(`保存 GitHub 页面失败${response.status}`); throw new Error(await readApiError(response, "保存 GitHub 页面失败"));
} }
const data = (await response.json()) as { sha: string }; const data = (await response.json()) as { sha: string };
@@ -193,7 +207,7 @@ export async function loadGitHubSettings(connection: GitHubConnection) {
const response = await fetch(`/api/github/site/settings?${siteParams(connection).toString()}`); const response = await fetch(`/api/github/site/settings?${siteParams(connection).toString()}`);
if (!response.ok) { if (!response.ok) {
throw new Error(`读取 GitHub 配置失败${response.status}`); throw new Error(await readApiError(response, "读取 GitHub 配置失败"));
} }
return (await response.json()) as { return (await response.json()) as {
@@ -212,6 +226,29 @@ export async function loadGitHubSettings(connection: GitHubConnection) {
}; };
} }
export async function initializeGitHubSite(connection: GitHubConnection) {
const response = await fetch("/api/github/site/init", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
site: {
owner: connection.owner,
repo: connection.repo,
branch: connection.branch,
siteRoot: connection.siteRoot,
},
}),
});
if (!response.ok) {
throw new Error(await readApiError(response, "初始化 VitePress 模板失败"));
}
return (await response.json()) as InitSiteResponse;
}
export async function saveGitHubSettings( export async function saveGitHubSettings(
connection: GitHubConnection, connection: GitHubConnection,
settings: SiteSettings, settings: SiteSettings,
@@ -236,7 +273,7 @@ export async function saveGitHubSettings(
}); });
if (!response.ok) { if (!response.ok) {
throw new Error(`保存 GitHub 配置失败${response.status}`); throw new Error(await readApiError(response, "保存 GitHub 配置失败"));
} }
const data = (await response.json()) as { sha: string }; const data = (await response.json()) as { sha: string };
+22 -2
View File
@@ -4,17 +4,22 @@ import type { DataSource, GitHubConnection, GitHubRepository, GitHubUser } from
const props = defineProps<{ const props = defineProps<{
connection: GitHubConnection; connection: GitHubConnection;
connectionMessage: string;
dataSource: DataSource; dataSource: DataSource;
githubUser: GitHubUser | null; githubUser: GitHubUser | null;
hasGitHubConnection: boolean; hasGitHubConnection: boolean;
isConnecting: boolean; isConnecting: boolean;
isCreatingRepository: boolean;
isInitializingSite: boolean;
isLoadingRepos: boolean; isLoadingRepos: boolean;
repositoryMessage: string;
repositories: GitHubRepository[]; repositories: GitHubRepository[];
}>(); }>();
const emit = defineEmits<{ const emit = defineEmits<{
connectGithub: [connection: GitHubConnection]; connectGithub: [connection: GitHubConnection];
createRepository: [payload: { name: string; private: boolean }]; createRepository: [payload: { name: string; private: boolean }];
initializeRepository: [connection: GitHubConnection];
loginGithub: []; loginGithub: [];
loadRepositories: []; loadRepositories: [];
logoutGithub: []; logoutGithub: [];
@@ -64,6 +69,11 @@ function createRepository() {
private: newRepo.private, private: newRepo.private,
}); });
} }
function initializeRepository() {
if (!canConnect.value) return;
emit("initializeRepository", { ...draft });
}
</script> </script>
<template> <template>
@@ -121,7 +131,16 @@ function createRepository() {
<button class="primary-button" type="button" :disabled="!canConnect || isConnecting" @click="connectGithub"> <button class="primary-button" type="button" :disabled="!canConnect || isConnecting" @click="connectGithub">
{{ isConnecting ? "连接中" : "读取选中仓库" }} {{ isConnecting ? "连接中" : "读取选中仓库" }}
</button> </button>
<button
class="secondary-button"
type="button"
:disabled="!canConnect || isInitializingSite"
@click="initializeRepository"
>
{{ isInitializingSite ? "初始化中" : "初始化 VitePress 模板" }}
</button>
</div> </div>
<p v-if="connectionMessage" class="helper-text">{{ connectionMessage }}</p>
<div class="new-repo-box"> <div class="new-repo-box">
<p class="eyebrow">create</p> <p class="eyebrow">create</p>
@@ -136,8 +155,9 @@ function createRepository() {
<input v-model="newRepo.private" type="checkbox" /> <input v-model="newRepo.private" type="checkbox" />
</label> </label>
</div> </div>
<button class="secondary-button" type="button" :disabled="!canCreateRepo" @click="createRepository"> <p v-if="repositoryMessage" class="helper-text">{{ repositoryMessage }}</p>
创建仓库 <button class="secondary-button" type="button" :disabled="!canCreateRepo || isCreatingRepository" @click="createRepository">
{{ isCreatingRepository ? "创建中" : "创建仓库" }}
</button> </button>
</div> </div>
</template> </template>
+132
View File
@@ -0,0 +1,132 @@
<script setup lang="ts">
import { computed, reactive, watch } from "vue";
import type { SystemSetting } from "../types";
const props = defineProps<{
settings: SystemSetting[];
saveState: "idle" | "saving" | "saved" | "error";
}>();
defineEmits<{
saveSettings: [settings: SystemSetting[]];
}>();
const draft = reactive<Record<string, SystemSetting>>({});
const groups = [
{
title: "基础",
eyebrow: "general",
keys: ["site.title"],
},
{
title: "认证",
eyebrow: "auth",
keys: ["auth.allow_email_login", "auth.allow_github_login", "auth.allow_registration"],
},
{
title: "SMTP",
eyebrow: "mail",
keys: ["smtp.host", "smtp.port", "smtp.username", "smtp.password", "smtp.sender_email"],
},
{
title: "GitHub",
eyebrow: "github",
keys: ["github.api_base_url", "github.web_base_url", "github.oauth_client_id", "github.oauth_client_secret"],
},
];
const labels: Record<string, string> = {
"site.title": "CMS 标题",
"auth.allow_email_login": "允许邮箱登录",
"auth.allow_github_login": "允许 GitHub 登录",
"auth.allow_registration": "开放注册",
"smtp.host": "SMTP 地址",
"smtp.port": "SMTP 端口",
"smtp.username": "SMTP 用户名",
"smtp.password": "SMTP 密码",
"smtp.sender_email": "发件人邮箱",
"github.api_base_url": "GitHub API 地址",
"github.web_base_url": "GitHub Web 地址",
"github.oauth_client_id": "OAuth Client ID",
"github.oauth_client_secret": "OAuth Client Secret",
};
const booleanKeys = new Set([
"auth.allow_email_login",
"auth.allow_github_login",
"auth.allow_registration",
]);
watch(
() => props.settings,
(settings) => {
Object.keys(draft).forEach((key) => delete draft[key]);
settings.forEach((setting) => {
draft[setting.key] = { ...setting };
});
},
{ immediate: true },
);
const draftSettings = computed(() => Object.values(draft));
function getSetting(key: string) {
if (!draft[key]) {
draft[key] = {
key,
value: "",
encrypted: key.includes("password") || key.includes("secret"),
updatedAt: "",
};
}
return draft[key];
}
function isBooleanKey(key: string) {
return booleanKeys.has(key);
}
</script>
<template>
<section class="panel is-visible">
<div class="settings-toolbar">
<p class="save-status" :class="`is-${saveState}`">
<template v-if="saveState === 'saved'">系统设置已保存</template>
<template v-else-if="saveState === 'error'">保存失败请查看日志</template>
<template v-else-if="saveState === 'saving'">正在保存系统设置</template>
<template v-else>系统设置保存到 SQLite 数据库</template>
</p>
<button class="primary-button" type="button" @click="$emit('saveSettings', draftSettings)">
{{ saveState === "saving" ? "保存中" : "保存设置" }}
</button>
</div>
<div class="settings-grid">
<section v-for="group in groups" :key="group.title" class="management-card">
<p class="eyebrow">{{ group.eyebrow }}</p>
<h3>{{ group.title }}</h3>
<label
v-for="key in group.keys"
:key="key"
:class="{ 'toggle-row': isBooleanKey(key) }"
>
<span>{{ labels[key] || key }}</span>
<input
v-if="isBooleanKey(key)"
:checked="getSetting(key).value === 'true'"
type="checkbox"
@change="getSetting(key).value = ($event.target as HTMLInputElement).checked ? 'true' : 'false'"
/>
<input
v-else
v-model="getSetting(key).value"
:placeholder="getSetting(key).encrypted ? '留空表示不修改敏感值' : ''"
:type="getSetting(key).encrypted ? 'password' : 'text'"
/>
</label>
</section>
</div>
</section>
</template>
+8 -1
View File
@@ -1,4 +1,4 @@
export type PanelKey = "account" | "connect" | "content" | "structure" | "config" | "publish"; export type PanelKey = "account" | "admin" | "connect" | "content" | "structure" | "config" | "publish";
export type EditorMode = "write" | "preview"; export type EditorMode = "write" | "preview";
export type DataSource = "local" | "github"; export type DataSource = "local" | "github";
@@ -69,3 +69,10 @@ export interface GitHubRepository {
defaultBranch: string; defaultBranch: string;
updatedAt: string; updatedAt: string;
} }
export interface SystemSetting {
key: string;
value: string;
encrypted: boolean;
updatedAt: string;
}