From db6d6de74a21f043a2391d31efb30fdd1e33dec2 Mon Sep 17 00:00:00 2001 From: Coldsmile_7 Date: Fri, 12 Jun 2026 00:02:23 +0800 Subject: [PATCH] feat: add GitHub site initialization --- server/github.mjs | 526 ++++++++++++++++++++++++++++++-- server/settings.mjs | 4 + src/App.vue | 65 ++++ src/api/githubAuth.ts | 3 +- src/api/githubSite.ts | 45 ++- src/components/ConnectPanel.vue | 24 +- 6 files changed, 641 insertions(+), 26 deletions(-) diff --git a/server/github.mjs b/server/github.mjs index a30180a..04f9154 100644 --- a/server/github.mjs +++ b/server/github.mjs @@ -1,10 +1,195 @@ 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 { getRequestSession } from "./auth.mjs"; import { createSession, sessionCookie, updateSession } from "./session.mjs"; 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) { const configuredBaseUrl = process.env.GITHUB_OAUTH_REDIRECT_BASE_URL; @@ -15,39 +200,64 @@ function getBaseUrl(request) { 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) { - const clientId = process.env.GITHUB_CLIENT_ID; - const clientSecret = process.env.GITHUB_CLIENT_SECRET; + const config = getGithubConfig(); 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"); } return { - clientId, - clientSecret, + ...config, redirectUri: `${baseUrl}/api/github/callback`, }; } 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, headers: { Accept: "application/vnd.github+json", Authorization: `Bearer ${session.accessToken}`, + "User-Agent": githubUserAgent, "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}`); + const details = response.text; + 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) { @@ -120,18 +330,18 @@ function findOrCreateGithubUser(githubUser, accessToken, currentSession) { } async function handleLogin(request, response) { - const { clientId, redirectUri } = getOAuthConfig(request); + const { clientId, redirectUri, webBaseUrl } = 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", + scope: "repo workflow", 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) { @@ -144,12 +354,13 @@ async function handleCallback(request, response, url) { } oauthStates.delete(state); - const { clientId, clientSecret, redirectUri } = getOAuthConfig(request); - const tokenResponse = await fetch("https://github.com/login/oauth/access_token", { + const { clientId, clientSecret, redirectUri, apiBaseUrl, webBaseUrl } = getOAuthConfig(request); + const tokenResponse = await requestJson(`${webBaseUrl}/login/oauth/access_token`, { method: "POST", headers: { Accept: "application/json", "Content-Type": "application/json", + "User-Agent": githubUserAgent, }, body: JSON.stringify({ client_id: clientId, @@ -158,21 +369,27 @@ async function handleCallback(request, response, url) { redirect_uri: redirectUri, }), }); - const tokenPayload = await tokenResponse.json(); + const tokenPayload = tokenResponse.payload ?? {}; if (!tokenPayload.access_token) { redirect(response, "/?github=token_error"); return; } - const userResponse = await fetch("https://api.github.com/user", { + const userResponse = await requestJson(`${apiBaseUrl}/user`, { headers: { Accept: "application/vnd.github+json", Authorization: `Bearer ${tokenPayload.access_token}`, + "User-Agent": githubUserAgent, "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 cmsUser = findOrCreateGithubUser(user, tokenPayload.access_token, currentSession); const sessionId = createSession({ @@ -290,6 +507,147 @@ function encodeBase64Content(content) { 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) { 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) { const session = requireSession(request, response); if (!session) return; @@ -557,6 +1040,11 @@ export async function handleGithubApi(request, response, url) { 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") { await handleSitePages(request, response, url); return true; @@ -569,7 +1057,7 @@ export async function handleGithubApi(request, response, url) { return false; } catch (error) { - json(response, 500, { + json(response, error?.statusCode || 500, { error: error instanceof Error ? error.message : "Unknown GitHub API error", }); return true; diff --git a/server/settings.mjs b/server/settings.mjs index 15e3661..929295a 100644 --- a/server/settings.mjs +++ b/server/settings.mjs @@ -42,6 +42,10 @@ async function handleSaveSettings(request, response) { settings.forEach((setting) => { 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)); }); diff --git a/src/App.vue b/src/App.vue index ff1f20d..a3cc1de 100644 --- a/src/App.vue +++ b/src/App.vue @@ -10,6 +10,7 @@ import { startGitHubLogin, } from "./api/githubAuth"; import { + initializeGitHubSite, loadGitHubPages, loadGitHubSettings, saveGitHubPage, @@ -85,6 +86,8 @@ const settingsSha = ref(); const isLoadingPages = ref(false); const isConnecting = ref(false); const isLoadingRepos = ref(false); +const isCreatingRepository = ref(false); +const isInitializingSite = ref(false); const pageSaveState = ref<"idle" | "saving" | "saved" | "error">("idle"); const settingsSaveState = ref<"idle" | "saving" | "saved" | "error">("idle"); const systemSettingsSaveState = ref<"idle" | "saving" | "saved" | "error">("idle"); @@ -93,6 +96,8 @@ const currentUser = ref(null); const repositories = ref([]); const isAuthLoading = ref(true); const loginError = ref(""); +const connectionMessage = ref(""); +const repositoryMessage = ref(""); const activityItems = ref(["等待读取站点"]); const activePage = computed(() => pages.find((page) => page.id === activePageId.value) ?? pages[0]); @@ -261,10 +266,12 @@ async function handleLogout() { async function connectGithub(connection: GitHubConnection) { const nextConnection = normalizeConnection(connection); + connectionMessage.value = ""; try { validateConnection(nextConnection); } catch (error) { + connectionMessage.value = error instanceof Error ? error.message : "连接信息不完整"; activityItems.value = [ error instanceof Error ? error.message : "连接信息不完整", ...activityItems.value, @@ -273,6 +280,7 @@ async function connectGithub(connection: GitHubConnection) { } isConnecting.value = true; + connectionMessage.value = `正在读取 GitHub 仓库 ${nextConnection.owner}/${nextConnection.repo}`; try { const [nextPages, nextSettings] = await Promise.all([ @@ -287,10 +295,15 @@ async function connectGithub(connection: GitHubConnection) { Object.assign(siteSettings, nextSettings.settings); settingsSha.value = nextSettings.sha; activePanel.value = "content"; + connectionMessage.value = `已连接 GitHub 仓库 ${githubConnection.owner}/${githubConnection.repo}`; activityItems.value = [`已连接 GitHub 仓库 ${githubConnection.owner}/${githubConnection.repo}`, ...activityItems.value]; } catch (error) { hasGitHubConnection.value = false; 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 = [ "GitHub 仓库连接失败,仍保持本地测试站点模式", error instanceof Error ? error.message : "未知错误", @@ -317,11 +330,24 @@ async function signOutGithub() { } async function createRepository(payload: { name: string; private: boolean }) { + isCreatingRepository.value = true; + repositoryMessage.value = `正在创建 GitHub 仓库 ${payload.name}`; + try { const repo = await createGitHubRepository(payload); 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]; } catch (error) { + repositoryMessage.value = error instanceof Error ? error.message : "创建 GitHub 仓库失败"; + isCreatingRepository.value = false; activityItems.value = [ error instanceof Error ? error.message : "创建 GitHub 仓库失败", ...activityItems.value, @@ -329,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) { activePageId.value = pageId; pageSaveState.value = "idle"; @@ -479,14 +539,19 @@ onMounted(async () => { null); + throw new Error(data?.error || `创建 GitHub 仓库失败:${response.status}`); } const data = (await response.json()) as CreateRepoResponse; diff --git a/src/api/githubSite.ts b/src/api/githubSite.ts index 47cde21..95e98b4 100644 --- a/src/api/githubSite.ts +++ b/src/api/githubSite.ts @@ -1,6 +1,15 @@ import type { DocPage, GitHubConnection, SiteSettings } from "../types"; 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 { tree: Array<{ path: string; @@ -71,6 +80,11 @@ function encodeBase64Content(content: string) { 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(connection: GitHubConnection, path: string, init?: RequestInit) { const response = await fetch(`https://api.github.com${path}`, { ...init, @@ -101,7 +115,7 @@ export async function loadGitHubPages(connection: GitHubConnection) { const response = await fetch(`/api/github/site/pages?${siteParams(connection).toString()}`); if (!response.ok) { - throw new Error(`读取 GitHub 页面失败:${response.status}`); + throw new Error(await readApiError(response, "读取 GitHub 页面失败")); } const data = (await response.json()) as { pages: DocPage[] }; @@ -159,7 +173,7 @@ export async function saveGitHubPage(connection: GitHubConnection, page: DocPage }); if (!response.ok) { - throw new Error(`保存 GitHub 页面失败:${response.status}`); + throw new Error(await readApiError(response, "保存 GitHub 页面失败")); } 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()}`); if (!response.ok) { - throw new Error(`读取 GitHub 配置失败:${response.status}`); + throw new Error(await readApiError(response, "读取 GitHub 配置失败")); } 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( connection: GitHubConnection, settings: SiteSettings, @@ -236,7 +273,7 @@ export async function saveGitHubSettings( }); if (!response.ok) { - throw new Error(`保存 GitHub 配置失败:${response.status}`); + throw new Error(await readApiError(response, "保存 GitHub 配置失败")); } const data = (await response.json()) as { sha: string }; diff --git a/src/components/ConnectPanel.vue b/src/components/ConnectPanel.vue index da12cf9..53cf0cb 100644 --- a/src/components/ConnectPanel.vue +++ b/src/components/ConnectPanel.vue @@ -4,17 +4,22 @@ import type { DataSource, GitHubConnection, GitHubRepository, GitHubUser } from const props = defineProps<{ connection: GitHubConnection; + connectionMessage: string; dataSource: DataSource; githubUser: GitHubUser | null; hasGitHubConnection: boolean; isConnecting: boolean; + isCreatingRepository: boolean; + isInitializingSite: boolean; isLoadingRepos: boolean; + repositoryMessage: string; repositories: GitHubRepository[]; }>(); const emit = defineEmits<{ connectGithub: [connection: GitHubConnection]; createRepository: [payload: { name: string; private: boolean }]; + initializeRepository: [connection: GitHubConnection]; loginGithub: []; loadRepositories: []; logoutGithub: []; @@ -64,6 +69,11 @@ function createRepository() { private: newRepo.private, }); } + +function initializeRepository() { + if (!canConnect.value) return; + emit("initializeRepository", { ...draft }); +}