Compare commits
1 Commits
db6d6de74a
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 49c86bc14a |
Generated
+1192
-54
File diff suppressed because it is too large
Load Diff
@@ -12,6 +12,7 @@
|
|||||||
"preview": "vite preview --host 0.0.0.0"
|
"preview": "vite preview --host 0.0.0.0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"vitepress": "^1.6.4",
|
||||||
"vue": "^3.5.0"
|
"vue": "^3.5.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
+179
-3
@@ -1,9 +1,10 @@
|
|||||||
import { db } from "./db.mjs";
|
import { db } from "./db.mjs";
|
||||||
import { json, readBody } from "./http.mjs";
|
import { json, readBody } from "./http.mjs";
|
||||||
import { clearSessionCookie, createSession, deleteSession, getSession, parseCookies, sessionCookie } from "./session.mjs";
|
import { clearSessionCookie, createSession, deleteSession, getSession, parseCookies, sessionCookie } from "./session.mjs";
|
||||||
import { verifyPassword } from "./security.mjs";
|
import { hashPassword, verifyPassword } from "./security.mjs";
|
||||||
|
import { hasPermission, publicPermissions } from "./permissions.mjs";
|
||||||
|
|
||||||
function publicUser(user) {
|
export function publicUser(user) {
|
||||||
if (!user) return null;
|
if (!user) return null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -12,6 +13,12 @@ function publicUser(user) {
|
|||||||
name: user.name,
|
name: user.name,
|
||||||
avatarUrl: user.avatar_url,
|
avatarUrl: user.avatar_url,
|
||||||
role: user.role,
|
role: user.role,
|
||||||
|
status: user.status || "active",
|
||||||
|
emailVerified: Boolean(user.email_verified),
|
||||||
|
createdAt: user.created_at,
|
||||||
|
updatedAt: user.updated_at,
|
||||||
|
lastLoginAt: user.last_login_at,
|
||||||
|
permissions: publicPermissions(user),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -33,12 +40,49 @@ export function requireCmsUser(request, response) {
|
|||||||
return session.user;
|
return session.user;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function requirePermission(request, response, permission) {
|
||||||
|
const user = requireCmsUser(request, response);
|
||||||
|
|
||||||
|
if (!user) return undefined;
|
||||||
|
|
||||||
|
if (!hasPermission(user, permission)) {
|
||||||
|
json(response, 403, { error: "Permission denied" });
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
function settingEnabled(key, fallback = false) {
|
||||||
|
const row = db.prepare("SELECT value FROM system_settings WHERE key = ?").get(key);
|
||||||
|
return (row?.value ?? String(fallback)) === "true";
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateEmail(email) {
|
||||||
|
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
|
||||||
|
}
|
||||||
|
|
||||||
async function handleMe(request, response) {
|
async function handleMe(request, response) {
|
||||||
const { session } = getRequestSession(request);
|
const { session } = getRequestSession(request);
|
||||||
json(response, 200, { user: session?.user ?? null });
|
json(response, 200, { user: session?.user ?? null });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleOptions(request, response) {
|
||||||
|
json(response, 200, {
|
||||||
|
options: {
|
||||||
|
allowEmailLogin: settingEnabled("auth.allow_email_login", true),
|
||||||
|
allowGitHubLogin: settingEnabled("auth.allow_github_login", true),
|
||||||
|
allowRegistration: settingEnabled("auth.allow_registration", false),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function handleLogin(request, response) {
|
async function handleLogin(request, response) {
|
||||||
|
if (!settingEnabled("auth.allow_email_login", true)) {
|
||||||
|
json(response, 403, { error: "Email login is disabled" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const body = JSON.parse(await readBody(request) || "{}");
|
const body = JSON.parse(await readBody(request) || "{}");
|
||||||
const email = String(body.email || "").trim().toLowerCase();
|
const email = String(body.email || "").trim().toLowerCase();
|
||||||
const password = String(body.password || "");
|
const password = String(body.password || "");
|
||||||
@@ -55,7 +99,15 @@ async function handleLogin(request, response) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const sessionUser = publicUser(user);
|
if ((user.status || "active") !== "active") {
|
||||||
|
json(response, 403, { error: "This account is disabled" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
db.prepare("UPDATE users SET last_login_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP WHERE id = ?").run(user.id);
|
||||||
|
const nextUser = db.prepare("SELECT * FROM users WHERE id = ?").get(user.id);
|
||||||
|
|
||||||
|
const sessionUser = publicUser(nextUser);
|
||||||
const sessionId = createSession({
|
const sessionId = createSession({
|
||||||
user: sessionUser,
|
user: sessionUser,
|
||||||
});
|
});
|
||||||
@@ -63,12 +115,116 @@ async function handleLogin(request, response) {
|
|||||||
json(response, 200, { user: sessionUser }, { "Set-Cookie": sessionCookie(sessionId) });
|
json(response, 200, { user: sessionUser }, { "Set-Cookie": sessionCookie(sessionId) });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleRegister(request, response) {
|
||||||
|
if (!settingEnabled("auth.allow_registration", false)) {
|
||||||
|
json(response, 403, { error: "Registration is disabled" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = JSON.parse((await readBody(request)) || "{}");
|
||||||
|
const email = String(body.email || "").trim().toLowerCase();
|
||||||
|
const name = String(body.name || "").trim();
|
||||||
|
const password = String(body.password || "");
|
||||||
|
|
||||||
|
if (!validateEmail(email)) {
|
||||||
|
json(response, 400, { error: "A valid email is required" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!name) {
|
||||||
|
json(response, 400, { error: "Name is required" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (password.length < 8) {
|
||||||
|
json(response, 400, { error: "Password must be at least 8 characters" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = db.prepare("SELECT id FROM users WHERE email = ?").get(email);
|
||||||
|
if (existing) {
|
||||||
|
json(response, 409, { error: "Email already exists" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = db
|
||||||
|
.prepare("INSERT INTO users (email, password_hash, name, role, email_verified) VALUES (?, ?, ?, 'user', 0)")
|
||||||
|
.run(email, hashPassword(password), name);
|
||||||
|
|
||||||
|
const user = publicUser(db.prepare("SELECT * FROM users WHERE id = ?").get(result.lastInsertRowid));
|
||||||
|
const sessionId = createSession({ user });
|
||||||
|
|
||||||
|
json(response, 201, { user }, { "Set-Cookie": sessionCookie(sessionId) });
|
||||||
|
}
|
||||||
|
|
||||||
async function handleLogout(request, response) {
|
async function handleLogout(request, response) {
|
||||||
const { sessionId } = getRequestSession(request);
|
const { sessionId } = getRequestSession(request);
|
||||||
deleteSession(sessionId);
|
deleteSession(sessionId);
|
||||||
json(response, 200, { ok: true }, { "Set-Cookie": clearSessionCookie() });
|
json(response, 200, { ok: true }, { "Set-Cookie": clearSessionCookie() });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleUpdateProfile(request, response) {
|
||||||
|
const { sessionId, session } = getRequestSession(request);
|
||||||
|
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
json(response, 401, { error: "Not signed in" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = JSON.parse((await readBody(request)) || "{}");
|
||||||
|
const name = String(body.name || "").trim();
|
||||||
|
|
||||||
|
if (!name) {
|
||||||
|
json(response, 400, { error: "Name is required" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
db.prepare("UPDATE users SET name = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?").run(name, session.user.id);
|
||||||
|
const user = db.prepare("SELECT * FROM users WHERE id = ?").get(session.user.id);
|
||||||
|
const sessionUser = publicUser(user);
|
||||||
|
|
||||||
|
delete session.user;
|
||||||
|
const nextSession = {
|
||||||
|
...session,
|
||||||
|
user: sessionUser,
|
||||||
|
};
|
||||||
|
deleteSession(sessionId);
|
||||||
|
const nextSessionId = createSession(nextSession);
|
||||||
|
|
||||||
|
json(response, 200, { user: sessionUser }, { "Set-Cookie": sessionCookie(nextSessionId) });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleChangePassword(request, response) {
|
||||||
|
const { session } = getRequestSession(request);
|
||||||
|
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
json(response, 401, { error: "Not signed in" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = JSON.parse((await readBody(request)) || "{}");
|
||||||
|
const currentPassword = String(body.currentPassword || "");
|
||||||
|
const newPassword = String(body.newPassword || "");
|
||||||
|
const user = db.prepare("SELECT * FROM users WHERE id = ?").get(session.user.id);
|
||||||
|
|
||||||
|
if (newPassword.length < 8) {
|
||||||
|
json(response, 400, { error: "New password must be at least 8 characters" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.password_hash && !verifyPassword(currentPassword, user.password_hash)) {
|
||||||
|
json(response, 403, { error: "Current password is incorrect" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
db.prepare("UPDATE users SET password_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?").run(
|
||||||
|
hashPassword(newPassword),
|
||||||
|
session.user.id,
|
||||||
|
);
|
||||||
|
|
||||||
|
json(response, 200, { ok: true });
|
||||||
|
}
|
||||||
|
|
||||||
export async function handleAuthApi(request, response, url) {
|
export async function handleAuthApi(request, response, url) {
|
||||||
try {
|
try {
|
||||||
if (url.pathname === "/api/auth/me" && request.method === "GET") {
|
if (url.pathname === "/api/auth/me" && request.method === "GET") {
|
||||||
@@ -76,16 +232,36 @@ export async function handleAuthApi(request, response, url) {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (url.pathname === "/api/auth/options" && request.method === "GET") {
|
||||||
|
await handleOptions(request, response);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
if (url.pathname === "/api/auth/login" && request.method === "POST") {
|
if (url.pathname === "/api/auth/login" && request.method === "POST") {
|
||||||
await handleLogin(request, response);
|
await handleLogin(request, response);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (url.pathname === "/api/auth/register" && request.method === "POST") {
|
||||||
|
await handleRegister(request, response);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
if (url.pathname === "/api/auth/logout" && request.method === "POST") {
|
if (url.pathname === "/api/auth/logout" && request.method === "POST") {
|
||||||
await handleLogout(request, response);
|
await handleLogout(request, response);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (url.pathname === "/api/auth/profile" && request.method === "PATCH") {
|
||||||
|
await handleUpdateProfile(request, response);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.pathname === "/api/auth/password" && request.method === "POST") {
|
||||||
|
await handleChangePassword(request, response);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
json(response, 500, {
|
json(response, 500, {
|
||||||
|
|||||||
@@ -67,6 +67,16 @@ export function migrate() {
|
|||||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
);
|
);
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
const userColumns = db.prepare("PRAGMA table_info(users)").all().map((column) => column.name);
|
||||||
|
|
||||||
|
if (!userColumns.includes("status")) {
|
||||||
|
db.exec("ALTER TABLE users ADD COLUMN status TEXT NOT NULL DEFAULT 'active'");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!userColumns.includes("last_login_at")) {
|
||||||
|
db.exec("ALTER TABLE users ADD COLUMN last_login_at TEXT");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getSetting(key, fallback = "") {
|
export function getSetting(key, fallback = "") {
|
||||||
|
|||||||
+146
-72
@@ -1,7 +1,10 @@
|
|||||||
import { randomBytes } from "node:crypto";
|
import { randomBytes } from "node:crypto";
|
||||||
|
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||||
import { spawn } from "node:child_process";
|
import { spawn } from "node:child_process";
|
||||||
import { request as httpRequest } from "node:http";
|
import { request as httpRequest } from "node:http";
|
||||||
import { request as httpsRequest } from "node:https";
|
import { request as httpsRequest } from "node:https";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
import { db, getSetting } from "./db.mjs";
|
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";
|
||||||
@@ -37,7 +40,7 @@ function requestTextWithPowerShell(targetUrl, init = {}) {
|
|||||||
).toString("base64");
|
).toString("base64");
|
||||||
const script = `
|
const script = `
|
||||||
$ErrorActionPreference = 'Stop'
|
$ErrorActionPreference = 'Stop'
|
||||||
$payload = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($args[0])) | ConvertFrom-Json
|
$payload = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($env:VITEPRESS_CMS_PS_PAYLOAD)) | ConvertFrom-Json
|
||||||
$headers = @{}
|
$headers = @{}
|
||||||
if ($payload.headers) {
|
if ($payload.headers) {
|
||||||
foreach ($property in $payload.headers.PSObject.Properties) {
|
foreach ($property in $payload.headers.PSObject.Properties) {
|
||||||
@@ -81,7 +84,11 @@ try {
|
|||||||
$result | ConvertTo-Json -Compress -Depth 5
|
$result | ConvertTo-Json -Compress -Depth 5
|
||||||
`;
|
`;
|
||||||
const encodedCommand = Buffer.from(script, "utf16le").toString("base64");
|
const encodedCommand = Buffer.from(script, "utf16le").toString("base64");
|
||||||
const child = spawn("powershell.exe", ["-NoProfile", "-NonInteractive", "-EncodedCommand", encodedCommand, payload], {
|
const child = spawn("powershell.exe", ["-NoProfile", "-NonInteractive", "-EncodedCommand", encodedCommand], {
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
VITEPRESS_CMS_PS_PAYLOAD: payload,
|
||||||
|
},
|
||||||
windowsHide: true,
|
windowsHide: true,
|
||||||
});
|
});
|
||||||
const stdout = [];
|
const stdout = [];
|
||||||
@@ -281,6 +288,11 @@ function publicCmsUser(user) {
|
|||||||
name: user.name,
|
name: user.name,
|
||||||
avatarUrl: user.avatar_url,
|
avatarUrl: user.avatar_url,
|
||||||
role: user.role,
|
role: user.role,
|
||||||
|
status: user.status || "active",
|
||||||
|
emailVerified: Boolean(user.email_verified),
|
||||||
|
createdAt: user.created_at,
|
||||||
|
updatedAt: user.updated_at,
|
||||||
|
lastLoginAt: user.last_login_at,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -392,8 +404,14 @@ async function handleCallback(request, response, url) {
|
|||||||
const user = userResponse.payload ?? {};
|
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);
|
||||||
|
if ((cmsUser.status || "active") !== "active") {
|
||||||
|
throw new Error("This account is disabled");
|
||||||
|
}
|
||||||
|
|
||||||
|
db.prepare("UPDATE users SET last_login_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP WHERE id = ?").run(cmsUser.id);
|
||||||
|
const nextCmsUser = db.prepare("SELECT * FROM users WHERE id = ?").get(cmsUser.id);
|
||||||
const sessionId = createSession({
|
const sessionId = createSession({
|
||||||
user: publicCmsUser(cmsUser),
|
user: publicCmsUser(nextCmsUser),
|
||||||
github: {
|
github: {
|
||||||
accessToken: tokenPayload.access_token,
|
accessToken: tokenPayload.access_token,
|
||||||
login: user.login,
|
login: user.login,
|
||||||
@@ -489,6 +507,10 @@ function normalizeSiteRoot(siteRoot = "") {
|
|||||||
return siteRoot.trim().replace(/^\/+|\/+$/g, "");
|
return siteRoot.trim().replace(/^\/+|\/+$/g, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getVitePressSiteRoot(siteRoot = "") {
|
||||||
|
return normalizeSiteRoot(siteRoot) || "docs";
|
||||||
|
}
|
||||||
|
|
||||||
function toRepoPath(siteRoot, path) {
|
function toRepoPath(siteRoot, path) {
|
||||||
const normalizedRoot = normalizeSiteRoot(siteRoot);
|
const normalizedRoot = normalizeSiteRoot(siteRoot);
|
||||||
return normalizedRoot ? `${normalizedRoot}/${path}` : path;
|
return normalizedRoot ? `${normalizedRoot}/${path}` : path;
|
||||||
@@ -508,10 +530,11 @@ function encodeBase64Content(content) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function packageJsonTemplate(siteRoot) {
|
function packageJsonTemplate(siteRoot) {
|
||||||
const root = normalizeSiteRoot(siteRoot) || ".";
|
const root = getVitePressSiteRoot(siteRoot);
|
||||||
|
|
||||||
return `${JSON.stringify(
|
return `${JSON.stringify(
|
||||||
{
|
{
|
||||||
|
type: "module",
|
||||||
scripts: {
|
scripts: {
|
||||||
"docs:dev": `vitepress dev ${root}`,
|
"docs:dev": `vitepress dev ${root}`,
|
||||||
"docs:build": `vitepress build ${root}`,
|
"docs:build": `vitepress build ${root}`,
|
||||||
@@ -527,43 +550,100 @@ function packageJsonTemplate(siteRoot) {
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function siteConfigTemplate() {
|
function mergePackageJsonContent(existingSource, siteRoot) {
|
||||||
return `import { defineConfig } from "vitepress";
|
const root = getVitePressSiteRoot(siteRoot);
|
||||||
|
let packageJson;
|
||||||
|
|
||||||
export default defineConfig({
|
try {
|
||||||
|
packageJson = JSON.parse(existingSource);
|
||||||
|
} catch {
|
||||||
|
throw new Error("package.json is not valid JSON");
|
||||||
|
}
|
||||||
|
|
||||||
|
packageJson.type = "module";
|
||||||
|
packageJson.scripts = {
|
||||||
|
...packageJson.scripts,
|
||||||
|
"docs:dev": `vitepress dev ${root}`,
|
||||||
|
"docs:build": `vitepress build ${root}`,
|
||||||
|
"docs:preview": `vitepress preview ${root}`,
|
||||||
|
};
|
||||||
|
packageJson.devDependencies = {
|
||||||
|
...packageJson.devDependencies,
|
||||||
|
vitepress: packageJson.devDependencies?.vitepress || "^1.6.4",
|
||||||
|
};
|
||||||
|
|
||||||
|
return `${JSON.stringify(packageJson, null, 2)}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadVitePressScaffold() {
|
||||||
|
try {
|
||||||
|
const module = await import("vitepress");
|
||||||
|
return module.scaffold;
|
||||||
|
} catch {
|
||||||
|
const module = await import("../test-vitepress/node_modules/vitepress/dist/node/index.js");
|
||||||
|
return module.scaffold;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createOfficialVitePressFiles(siteRoot) {
|
||||||
|
const tempRoot = mkdtempSync(join(tmpdir(), "vitepress-cms-init-"));
|
||||||
|
const cwd = process.cwd();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const scaffold = await loadVitePressScaffold();
|
||||||
|
writeFileSync(join(tempRoot, "package.json"), packageJsonTemplate(siteRoot), "utf-8");
|
||||||
|
process.chdir(tempRoot);
|
||||||
|
scaffold({
|
||||||
|
root: siteRoot,
|
||||||
title: "VitePress Site",
|
title: "VitePress Site",
|
||||||
description: "Created by VitePress-CMS",
|
description: "Created by VitePress-CMS",
|
||||||
lastUpdated: true,
|
theme: "default theme",
|
||||||
themeConfig: {
|
useTs: true,
|
||||||
nav: [
|
injectNpmScripts: true,
|
||||||
{ 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",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
`;
|
|
||||||
|
const configPath = [
|
||||||
|
toRepoPath(siteRoot, ".vitepress/config.ts"),
|
||||||
|
toRepoPath(siteRoot, ".vitepress/config.mts"),
|
||||||
|
toRepoPath(siteRoot, ".vitepress/config.mjs"),
|
||||||
|
toRepoPath(siteRoot, ".vitepress/config.js"),
|
||||||
|
].find((path) => {
|
||||||
|
try {
|
||||||
|
readFileSync(join(tempRoot, path), "utf-8");
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!configPath) {
|
||||||
|
throw new Error("VitePress official scaffold did not generate a config file");
|
||||||
|
}
|
||||||
|
|
||||||
|
const filePaths = [
|
||||||
|
"package.json",
|
||||||
|
toRepoPath(siteRoot, "index.md"),
|
||||||
|
toRepoPath(siteRoot, "api-examples.md"),
|
||||||
|
toRepoPath(siteRoot, "markdown-examples.md"),
|
||||||
|
configPath,
|
||||||
|
];
|
||||||
|
|
||||||
|
return filePaths.map((path) => ({
|
||||||
|
path,
|
||||||
|
content:
|
||||||
|
path === "package.json"
|
||||||
|
? mergePackageJsonContent(readFileSync(join(tempRoot, path), "utf-8"), siteRoot)
|
||||||
|
: readFileSync(join(tempRoot, path), "utf-8"),
|
||||||
|
}));
|
||||||
|
} finally {
|
||||||
|
process.chdir(cwd);
|
||||||
|
rmSync(tempRoot, { recursive: true, force: true });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function deployWorkflowTemplate(siteRoot, branch) {
|
function deployWorkflowTemplate(siteRoot, branch) {
|
||||||
const root = normalizeSiteRoot(siteRoot);
|
const root = getVitePressSiteRoot(siteRoot);
|
||||||
const outputDir = root ? `${root}/.vitepress/dist` : ".vitepress/dist";
|
const outputDir = `${root}/.vitepress/dist`;
|
||||||
|
|
||||||
return `name: Deploy VitePress site to Pages
|
return `name: Deploy VitePress site to Pages
|
||||||
|
|
||||||
@@ -579,7 +659,7 @@ permissions:
|
|||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: pages
|
group: pages
|
||||||
cancel-in-progress: false
|
cancel-in-progress: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
@@ -603,44 +683,16 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- id: deployment
|
- id: deployment
|
||||||
uses: actions/deploy-pages@v4
|
uses: actions/deploy-pages@v5
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function initTemplateFiles(siteRoot, branch) {
|
async function initTemplateFiles(siteRoot, branch) {
|
||||||
|
const root = getVitePressSiteRoot(siteRoot);
|
||||||
|
const files = await createOfficialVitePressFiles(root);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
{
|
...files,
|
||||||
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",
|
path: ".github/workflows/deploy.yml",
|
||||||
content: deployWorkflowTemplate(siteRoot, branch),
|
content: deployWorkflowTemplate(siteRoot, branch),
|
||||||
@@ -759,7 +811,7 @@ function getSiteQuery(url) {
|
|||||||
const owner = url.searchParams.get("owner");
|
const owner = url.searchParams.get("owner");
|
||||||
const repo = url.searchParams.get("repo");
|
const repo = url.searchParams.get("repo");
|
||||||
const branch = url.searchParams.get("branch") || "main";
|
const branch = url.searchParams.get("branch") || "main";
|
||||||
const siteRoot = url.searchParams.get("siteRoot") || "";
|
const siteRoot = getVitePressSiteRoot(url.searchParams.get("siteRoot") || "");
|
||||||
|
|
||||||
if (!owner || !repo) {
|
if (!owner || !repo) {
|
||||||
throw new Error("owner and repo are required");
|
throw new Error("owner and repo are required");
|
||||||
@@ -789,6 +841,28 @@ async function createRepoPathFile(session, site, file) {
|
|||||||
try {
|
try {
|
||||||
const existing = await getRepoPathContent(session, site, file.path);
|
const existing = await getRepoPathContent(session, site, file.path);
|
||||||
|
|
||||||
|
if (file.path === "package.json") {
|
||||||
|
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 package metadata from VitePress-CMS",
|
||||||
|
content: encodeBase64Content(mergePackageJsonContent(decodeBase64Content(existing.content), site.siteRoot)),
|
||||||
|
branch: site.branch,
|
||||||
|
sha: existing.sha,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
path: file.path,
|
||||||
|
status: "updated",
|
||||||
|
sha: result.content.sha,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if (file.path.startsWith(".github/workflows/")) {
|
if (file.path.startsWith(".github/workflows/")) {
|
||||||
const encodedPath = file.path.split("/").map(encodeURIComponent).join("/");
|
const encodedPath = file.path.split("/").map(encodeURIComponent).join("/");
|
||||||
const result = await githubFetch(session, `/repos/${site.owner}/${site.repo}/contents/${encodedPath}`, {
|
const result = await githubFetch(session, `/repos/${site.owner}/${site.repo}/contents/${encodedPath}`, {
|
||||||
@@ -891,11 +965,11 @@ async function handleInitSite(request, response) {
|
|||||||
owner: String(site.owner),
|
owner: String(site.owner),
|
||||||
repo: String(site.repo),
|
repo: String(site.repo),
|
||||||
branch: String(site.branch || "main"),
|
branch: String(site.branch || "main"),
|
||||||
siteRoot: normalizeSiteRoot(String(site.siteRoot || "")),
|
siteRoot: getVitePressSiteRoot(String(site.siteRoot || "")),
|
||||||
};
|
};
|
||||||
const files = [];
|
const files = [];
|
||||||
|
|
||||||
for (const file of initTemplateFiles(normalizedSite.siteRoot, normalizedSite.branch)) {
|
for (const file of await initTemplateFiles(normalizedSite.siteRoot, normalizedSite.branch)) {
|
||||||
files.push(await createRepoPathFile(session, normalizedSite, file));
|
files.push(await createRepoPathFile(session, normalizedSite, file));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import { bootstrap } from "./bootstrap.mjs";
|
|||||||
import { handleAuthApi } from "./auth.mjs";
|
import { handleAuthApi } from "./auth.mjs";
|
||||||
import { handleGithubApi } from "./github.mjs";
|
import { handleGithubApi } from "./github.mjs";
|
||||||
import { handleSettingsApi } from "./settings.mjs";
|
import { handleSettingsApi } from "./settings.mjs";
|
||||||
|
import { handleUsersApi } from "./users.mjs";
|
||||||
|
import { handleProjectsApi } from "./projects.mjs";
|
||||||
|
|
||||||
const isProduction = process.env.NODE_ENV === "production";
|
const isProduction = process.env.NODE_ENV === "production";
|
||||||
const port = Number(process.env.PORT || 5173);
|
const port = Number(process.env.PORT || 5173);
|
||||||
@@ -64,6 +66,14 @@ const server = createServer(async (request, response) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (await handleUsersApi(request, response, url)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (await handleProjectsApi(request, response, url)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (await handleGithubApi(request, response, url)) {
|
if (await handleGithubApi(request, response, url)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
export const permissions = {
|
||||||
|
settingsManage: "settings.manage",
|
||||||
|
usersManage: "users.manage",
|
||||||
|
projectsManage: "projects.manage",
|
||||||
|
contentEdit: "content.edit",
|
||||||
|
contentPublish: "content.publish",
|
||||||
|
contentView: "content.view",
|
||||||
|
};
|
||||||
|
|
||||||
|
const rolePermissions = {
|
||||||
|
system_admin: Object.values(permissions),
|
||||||
|
admin: [
|
||||||
|
permissions.usersManage,
|
||||||
|
permissions.projectsManage,
|
||||||
|
permissions.contentEdit,
|
||||||
|
permissions.contentPublish,
|
||||||
|
permissions.contentView,
|
||||||
|
],
|
||||||
|
editor: [permissions.contentEdit, permissions.contentPublish, permissions.contentView],
|
||||||
|
viewer: [permissions.contentView],
|
||||||
|
user: [permissions.contentView],
|
||||||
|
};
|
||||||
|
|
||||||
|
export function hasPermission(user, permission) {
|
||||||
|
if (!user) return false;
|
||||||
|
return Boolean(rolePermissions[user.role]?.includes(permission));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function publicPermissions(user) {
|
||||||
|
return rolePermissions[user?.role] ?? [];
|
||||||
|
}
|
||||||
@@ -0,0 +1,343 @@
|
|||||||
|
import { db } from "./db.mjs";
|
||||||
|
import { requireCmsUser } from "./auth.mjs";
|
||||||
|
import { json, readBody } from "./http.mjs";
|
||||||
|
|
||||||
|
const projectRoles = ["owner", "admin", "editor", "viewer"];
|
||||||
|
const projectManagers = ["owner", "admin"];
|
||||||
|
|
||||||
|
function publicProject(row) {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
name: row.name,
|
||||||
|
githubOwner: row.github_owner,
|
||||||
|
githubRepo: row.github_repo,
|
||||||
|
githubBranch: row.github_branch,
|
||||||
|
siteRoot: row.site_root,
|
||||||
|
createdAt: row.created_at,
|
||||||
|
updatedAt: row.updated_at,
|
||||||
|
ownerUserId: row.owner_user_id,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function publicMember(row) {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
projectId: row.project_id,
|
||||||
|
userId: row.user_id,
|
||||||
|
role: row.role,
|
||||||
|
name: row.name,
|
||||||
|
email: row.email,
|
||||||
|
avatarUrl: row.avatar_url,
|
||||||
|
status: row.status || "active",
|
||||||
|
createdAt: row.created_at,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getProject(projectId) {
|
||||||
|
return db.prepare("SELECT * FROM projects WHERE id = ?").get(projectId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasGlobalProjectAccess(user) {
|
||||||
|
return user.role === "system_admin" || user.role === "admin";
|
||||||
|
}
|
||||||
|
|
||||||
|
function getProjectMember(projectId, userId) {
|
||||||
|
return db.prepare("SELECT * FROM project_members WHERE project_id = ? AND user_id = ?").get(projectId, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function canViewProject(user, projectId) {
|
||||||
|
return hasGlobalProjectAccess(user) || Boolean(getProjectMember(projectId, user.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
function canManageProject(user, projectId) {
|
||||||
|
if (hasGlobalProjectAccess(user)) return true;
|
||||||
|
const member = getProjectMember(projectId, user.id);
|
||||||
|
return Boolean(member && projectManagers.includes(member.role));
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireProjectView(request, response, projectId) {
|
||||||
|
const user = requireCmsUser(request, response);
|
||||||
|
if (!user) return undefined;
|
||||||
|
|
||||||
|
if (!canViewProject(user, projectId)) {
|
||||||
|
json(response, 403, { error: "Permission denied" });
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireProjectManage(request, response, projectId) {
|
||||||
|
const user = requireCmsUser(request, response);
|
||||||
|
if (!user) return undefined;
|
||||||
|
|
||||||
|
if (!canManageProject(user, projectId)) {
|
||||||
|
json(response, 403, { error: "Only project owners and admins can manage this project" });
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
function listMembers(projectId) {
|
||||||
|
return db
|
||||||
|
.prepare(`
|
||||||
|
SELECT project_members.*, users.name, users.email, users.avatar_url, users.status
|
||||||
|
FROM project_members
|
||||||
|
INNER JOIN users ON users.id = project_members.user_id
|
||||||
|
WHERE project_members.project_id = ?
|
||||||
|
ORDER BY
|
||||||
|
CASE project_members.role
|
||||||
|
WHEN 'owner' THEN 1
|
||||||
|
WHEN 'admin' THEN 2
|
||||||
|
WHEN 'editor' THEN 3
|
||||||
|
ELSE 4
|
||||||
|
END,
|
||||||
|
project_members.created_at ASC
|
||||||
|
`)
|
||||||
|
.all(projectId)
|
||||||
|
.map(publicMember);
|
||||||
|
}
|
||||||
|
|
||||||
|
function listProjects(user) {
|
||||||
|
if (user.role === "system_admin" || user.role === "admin") {
|
||||||
|
return db.prepare("SELECT * FROM projects ORDER BY updated_at DESC, id DESC").all().map(publicProject);
|
||||||
|
}
|
||||||
|
|
||||||
|
return db
|
||||||
|
.prepare(`
|
||||||
|
SELECT projects.*
|
||||||
|
FROM projects
|
||||||
|
INNER JOIN project_members ON project_members.project_id = projects.id
|
||||||
|
WHERE project_members.user_id = ?
|
||||||
|
ORDER BY projects.updated_at DESC, projects.id DESC
|
||||||
|
`)
|
||||||
|
.all(user.id)
|
||||||
|
.map(publicProject);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleListProjects(request, response) {
|
||||||
|
const user = requireCmsUser(request, response);
|
||||||
|
if (!user) return;
|
||||||
|
|
||||||
|
json(response, 200, { projects: listProjects(user) });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCreateProject(request, response) {
|
||||||
|
const user = requireCmsUser(request, response);
|
||||||
|
if (!user) return;
|
||||||
|
|
||||||
|
const body = JSON.parse((await readBody(request)) || "{}");
|
||||||
|
const name = String(body.name || "").trim();
|
||||||
|
|
||||||
|
if (!name) {
|
||||||
|
json(response, 400, { error: "Project name is required" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = db
|
||||||
|
.prepare(`
|
||||||
|
INSERT INTO projects (owner_user_id, name, github_owner, github_repo, github_branch, site_root)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
|
`)
|
||||||
|
.run(
|
||||||
|
user.id,
|
||||||
|
name,
|
||||||
|
String(body.githubOwner || "").trim() || null,
|
||||||
|
String(body.githubRepo || "").trim() || null,
|
||||||
|
String(body.githubBranch || "main").trim() || "main",
|
||||||
|
String(body.siteRoot || "docs").trim().replace(/^\/+|\/+$/g, "") || "docs",
|
||||||
|
);
|
||||||
|
|
||||||
|
db.prepare("INSERT OR IGNORE INTO project_members (project_id, user_id, role) VALUES (?, ?, 'owner')").run(
|
||||||
|
result.lastInsertRowid,
|
||||||
|
user.id,
|
||||||
|
);
|
||||||
|
|
||||||
|
const project = db.prepare("SELECT * FROM projects WHERE id = ?").get(result.lastInsertRowid);
|
||||||
|
json(response, 201, { project: publicProject(project) });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleUpdateProject(request, response, projectId) {
|
||||||
|
const project = db.prepare("SELECT * FROM projects WHERE id = ?").get(projectId);
|
||||||
|
if (!project) {
|
||||||
|
json(response, 404, { error: "Project not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = requireProjectManage(request, response, projectId);
|
||||||
|
if (!user) return;
|
||||||
|
|
||||||
|
const body = JSON.parse((await readBody(request)) || "{}");
|
||||||
|
db.prepare(`
|
||||||
|
UPDATE projects
|
||||||
|
SET name = ?,
|
||||||
|
github_owner = ?,
|
||||||
|
github_repo = ?,
|
||||||
|
github_branch = ?,
|
||||||
|
site_root = ?,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = ?
|
||||||
|
`).run(
|
||||||
|
String(body.name || project.name).trim(),
|
||||||
|
String(body.githubOwner ?? project.github_owner ?? "").trim() || null,
|
||||||
|
String(body.githubRepo ?? project.github_repo ?? "").trim() || null,
|
||||||
|
String(body.githubBranch || project.github_branch || "main").trim() || "main",
|
||||||
|
String(body.siteRoot || project.site_root || "docs").trim().replace(/^\/+|\/+$/g, "") || "docs",
|
||||||
|
projectId,
|
||||||
|
);
|
||||||
|
|
||||||
|
json(response, 200, { project: publicProject(db.prepare("SELECT * FROM projects WHERE id = ?").get(projectId)) });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleListMembers(request, response, projectId) {
|
||||||
|
if (!getProject(projectId)) {
|
||||||
|
json(response, 404, { error: "Project not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = requireProjectView(request, response, projectId);
|
||||||
|
if (!user) return;
|
||||||
|
|
||||||
|
json(response, 200, { members: listMembers(projectId) });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleAddMember(request, response, projectId) {
|
||||||
|
if (!getProject(projectId)) {
|
||||||
|
json(response, 404, { error: "Project not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = requireProjectManage(request, response, projectId);
|
||||||
|
if (!user) return;
|
||||||
|
|
||||||
|
const body = JSON.parse((await readBody(request)) || "{}");
|
||||||
|
const email = String(body.email || "").trim().toLowerCase();
|
||||||
|
const role = String(body.role || "editor");
|
||||||
|
|
||||||
|
if (!projectRoles.includes(role)) {
|
||||||
|
json(response, 400, { error: "Invalid member role" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = db.prepare("SELECT * FROM users WHERE email = ?").get(email);
|
||||||
|
if (!target) {
|
||||||
|
json(response, 404, { error: "User not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
db.prepare(`
|
||||||
|
INSERT INTO project_members (project_id, user_id, role)
|
||||||
|
VALUES (?, ?, ?)
|
||||||
|
ON CONFLICT(project_id, user_id) DO UPDATE SET role = excluded.role
|
||||||
|
`).run(projectId, target.id, role);
|
||||||
|
|
||||||
|
json(response, 200, { members: listMembers(projectId) });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleUpdateMember(request, response, projectId, memberId) {
|
||||||
|
const member = db.prepare("SELECT * FROM project_members WHERE project_id = ? AND id = ?").get(projectId, memberId);
|
||||||
|
if (!member) {
|
||||||
|
json(response, 404, { error: "Member not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = requireProjectManage(request, response, projectId);
|
||||||
|
if (!user) return;
|
||||||
|
|
||||||
|
const body = JSON.parse((await readBody(request)) || "{}");
|
||||||
|
const role = String(body.role || member.role);
|
||||||
|
|
||||||
|
if (!projectRoles.includes(role)) {
|
||||||
|
json(response, 400, { error: "Invalid member role" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (member.role === "owner" && role !== "owner") {
|
||||||
|
const ownerCount = db
|
||||||
|
.prepare("SELECT COUNT(*) AS count FROM project_members WHERE project_id = ? AND role = 'owner'")
|
||||||
|
.get(projectId).count;
|
||||||
|
|
||||||
|
if (ownerCount <= 1) {
|
||||||
|
json(response, 400, { error: "At least one project owner is required" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
db.prepare("UPDATE project_members SET role = ? WHERE id = ?").run(role, memberId);
|
||||||
|
json(response, 200, { members: listMembers(projectId) });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRemoveMember(request, response, projectId, memberId) {
|
||||||
|
const member = db.prepare("SELECT * FROM project_members WHERE project_id = ? AND id = ?").get(projectId, memberId);
|
||||||
|
if (!member) {
|
||||||
|
json(response, 404, { error: "Member not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = requireProjectManage(request, response, projectId);
|
||||||
|
if (!user) return;
|
||||||
|
|
||||||
|
if (member.role === "owner") {
|
||||||
|
const ownerCount = db
|
||||||
|
.prepare("SELECT COUNT(*) AS count FROM project_members WHERE project_id = ? AND role = 'owner'")
|
||||||
|
.get(projectId).count;
|
||||||
|
|
||||||
|
if (ownerCount <= 1) {
|
||||||
|
json(response, 400, { error: "At least one project owner is required" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
db.prepare("DELETE FROM project_members WHERE id = ?").run(memberId);
|
||||||
|
json(response, 200, { members: listMembers(projectId) });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleProjectsApi(request, response, url) {
|
||||||
|
try {
|
||||||
|
if (url.pathname === "/api/projects" && request.method === "GET") {
|
||||||
|
await handleListProjects(request, response);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.pathname === "/api/projects" && request.method === "POST") {
|
||||||
|
await handleCreateProject(request, response);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const match = url.pathname.match(/^\/api\/projects\/(\d+)$/);
|
||||||
|
if (match && request.method === "PATCH") {
|
||||||
|
await handleUpdateProject(request, response, Number(match[1]));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const membersMatch = url.pathname.match(/^\/api\/projects\/(\d+)\/members$/);
|
||||||
|
if (membersMatch && request.method === "GET") {
|
||||||
|
await handleListMembers(request, response, Number(membersMatch[1]));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (membersMatch && request.method === "POST") {
|
||||||
|
await handleAddMember(request, response, Number(membersMatch[1]));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const memberMatch = url.pathname.match(/^\/api\/projects\/(\d+)\/members\/(\d+)$/);
|
||||||
|
if (memberMatch && request.method === "PATCH") {
|
||||||
|
await handleUpdateMember(request, response, Number(memberMatch[1]), Number(memberMatch[2]));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (memberMatch && request.method === "DELETE") {
|
||||||
|
await handleRemoveMember(request, response, Number(memberMatch[1]), Number(memberMatch[2]));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
} catch (error) {
|
||||||
|
json(response, 500, {
|
||||||
|
error: error instanceof Error ? error.message : "Unknown projects API error",
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
+78
-12
@@ -1,18 +1,64 @@
|
|||||||
|
import net from "node:net";
|
||||||
|
import tls from "node:tls";
|
||||||
import { db, setSetting } from "./db.mjs";
|
import { db, setSetting } from "./db.mjs";
|
||||||
import { requireCmsUser } from "./auth.mjs";
|
import { requirePermission } from "./auth.mjs";
|
||||||
import { json, readBody } from "./http.mjs";
|
import { json, readBody } from "./http.mjs";
|
||||||
|
import { permissions } from "./permissions.mjs";
|
||||||
|
|
||||||
function requireAdmin(request, response) {
|
function getSettingValue(key, fallback = "") {
|
||||||
const user = requireCmsUser(request, response);
|
return db.prepare("SELECT value FROM system_settings WHERE key = ?").get(key)?.value ?? fallback;
|
||||||
|
|
||||||
if (!user) return undefined;
|
|
||||||
|
|
||||||
if (user.role !== "system_admin") {
|
|
||||||
json(response, 403, { error: "System admin permission is required" });
|
|
||||||
return undefined;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return user;
|
function testSmtpConnection({ host, port }) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const socketFactory = Number(port) === 465 ? tls.connect : net.connect;
|
||||||
|
const socket = socketFactory({
|
||||||
|
host,
|
||||||
|
port: Number(port),
|
||||||
|
servername: host,
|
||||||
|
timeout: 10000,
|
||||||
|
});
|
||||||
|
let buffer = "";
|
||||||
|
|
||||||
|
const finish = (result) => {
|
||||||
|
socket.removeAllListeners();
|
||||||
|
socket.end();
|
||||||
|
resolve(result);
|
||||||
|
};
|
||||||
|
|
||||||
|
socket.on("connect", () => {
|
||||||
|
if (Number(port) === 465) {
|
||||||
|
socket.write("EHLO vitepress-cms.local\r\n");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on("secureConnect", () => {
|
||||||
|
socket.write("EHLO vitepress-cms.local\r\n");
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on("data", (chunk) => {
|
||||||
|
buffer += chunk.toString("utf-8");
|
||||||
|
|
||||||
|
if (buffer.startsWith("220")) {
|
||||||
|
socket.write("EHLO vitepress-cms.local\r\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (buffer.includes("\n250 ") || buffer.includes("\n250-") || buffer.startsWith("250")) {
|
||||||
|
socket.write("QUIT\r\n");
|
||||||
|
finish({
|
||||||
|
ok: true,
|
||||||
|
greeting: buffer.split(/\r?\n/).filter(Boolean).slice(0, 3).join(" / "),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on("timeout", () => {
|
||||||
|
socket.destroy();
|
||||||
|
reject(new Error("SMTP connection timed out"));
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on("error", reject);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function publicSetting(row) {
|
function publicSetting(row) {
|
||||||
@@ -25,7 +71,7 @@ function publicSetting(row) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleGetSettings(request, response) {
|
async function handleGetSettings(request, response) {
|
||||||
if (!requireAdmin(request, response)) return;
|
if (!requirePermission(request, response, permissions.settingsManage)) return;
|
||||||
|
|
||||||
const rows = db
|
const rows = db
|
||||||
.prepare("SELECT key, value, encrypted, updated_at FROM system_settings ORDER BY key")
|
.prepare("SELECT key, value, encrypted, updated_at FROM system_settings ORDER BY key")
|
||||||
@@ -35,7 +81,7 @@ async function handleGetSettings(request, response) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleSaveSettings(request, response) {
|
async function handleSaveSettings(request, response) {
|
||||||
if (!requireAdmin(request, response)) return;
|
if (!requirePermission(request, response, permissions.settingsManage)) return;
|
||||||
|
|
||||||
const body = JSON.parse(await readBody(request) || "{}");
|
const body = JSON.parse(await readBody(request) || "{}");
|
||||||
const settings = Array.isArray(body.settings) ? body.settings : [];
|
const settings = Array.isArray(body.settings) ? body.settings : [];
|
||||||
@@ -52,6 +98,21 @@ async function handleSaveSettings(request, response) {
|
|||||||
json(response, 200, { ok: true });
|
json(response, 200, { ok: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleTestSmtp(request, response) {
|
||||||
|
if (!requirePermission(request, response, permissions.settingsManage)) return;
|
||||||
|
|
||||||
|
const host = getSettingValue("smtp.host");
|
||||||
|
const port = getSettingValue("smtp.port", "587");
|
||||||
|
|
||||||
|
if (!host) {
|
||||||
|
json(response, 400, { error: "SMTP host is required" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await testSmtpConnection({ host, port });
|
||||||
|
json(response, 200, result);
|
||||||
|
}
|
||||||
|
|
||||||
export async function handleSettingsApi(request, response, url) {
|
export async function handleSettingsApi(request, response, url) {
|
||||||
try {
|
try {
|
||||||
if (url.pathname === "/api/admin/settings" && request.method === "GET") {
|
if (url.pathname === "/api/admin/settings" && request.method === "GET") {
|
||||||
@@ -64,6 +125,11 @@ export async function handleSettingsApi(request, response, url) {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (url.pathname === "/api/admin/settings/test-smtp" && request.method === "POST") {
|
||||||
|
await handleTestSmtp(request, response);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
json(response, 500, {
|
json(response, 500, {
|
||||||
|
|||||||
@@ -0,0 +1,143 @@
|
|||||||
|
import { db } from "./db.mjs";
|
||||||
|
import { requirePermission, publicUser } from "./auth.mjs";
|
||||||
|
import { json, readBody } from "./http.mjs";
|
||||||
|
import { permissions } from "./permissions.mjs";
|
||||||
|
import { hashPassword } from "./security.mjs";
|
||||||
|
|
||||||
|
const roles = new Set(["system_admin", "admin", "editor", "viewer", "user"]);
|
||||||
|
const statuses = new Set(["active", "disabled"]);
|
||||||
|
|
||||||
|
function listUsers() {
|
||||||
|
return db
|
||||||
|
.prepare(`
|
||||||
|
SELECT id, email, name, avatar_url, role, status, email_verified, created_at, updated_at, last_login_at
|
||||||
|
FROM users
|
||||||
|
ORDER BY created_at DESC, id DESC
|
||||||
|
`)
|
||||||
|
.all()
|
||||||
|
.map(publicUser);
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateEmail(email) {
|
||||||
|
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleListUsers(request, response) {
|
||||||
|
if (!requirePermission(request, response, permissions.usersManage)) return;
|
||||||
|
json(response, 200, { users: listUsers() });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCreateUser(request, response) {
|
||||||
|
if (!requirePermission(request, response, permissions.usersManage)) return;
|
||||||
|
|
||||||
|
const body = JSON.parse((await readBody(request)) || "{}");
|
||||||
|
const email = String(body.email || "").trim().toLowerCase();
|
||||||
|
const name = String(body.name || "").trim();
|
||||||
|
const password = String(body.password || "");
|
||||||
|
const role = String(body.role || "user");
|
||||||
|
|
||||||
|
if (!validateEmail(email)) {
|
||||||
|
json(response, 400, { error: "A valid email is required" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!name) {
|
||||||
|
json(response, 400, { error: "Name is required" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (password.length < 8) {
|
||||||
|
json(response, 400, { error: "Password must be at least 8 characters" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!roles.has(role)) {
|
||||||
|
json(response, 400, { error: "Invalid role" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = db.prepare("SELECT id FROM users WHERE email = ?").get(email);
|
||||||
|
if (existing) {
|
||||||
|
json(response, 409, { error: "Email already exists" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = db
|
||||||
|
.prepare("INSERT INTO users (email, password_hash, name, role, email_verified) VALUES (?, ?, ?, ?, 1)")
|
||||||
|
.run(email, hashPassword(password), name, role);
|
||||||
|
|
||||||
|
json(response, 201, { user: publicUser(db.prepare("SELECT * FROM users WHERE id = ?").get(result.lastInsertRowid)) });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleUpdateUser(request, response, userId) {
|
||||||
|
const actor = requirePermission(request, response, permissions.usersManage);
|
||||||
|
if (!actor) return;
|
||||||
|
|
||||||
|
const target = db.prepare("SELECT * FROM users WHERE id = ?").get(userId);
|
||||||
|
if (!target) {
|
||||||
|
json(response, 404, { error: "User not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = JSON.parse((await readBody(request)) || "{}");
|
||||||
|
const nextRole = body.role === undefined ? target.role : String(body.role);
|
||||||
|
const nextStatus = body.status === undefined ? target.status || "active" : String(body.status);
|
||||||
|
|
||||||
|
if (!roles.has(nextRole)) {
|
||||||
|
json(response, 400, { error: "Invalid role" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!statuses.has(nextStatus)) {
|
||||||
|
json(response, 400, { error: "Invalid status" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Number(actor.id) === Number(userId) && nextStatus !== "active") {
|
||||||
|
json(response, 400, { error: "You cannot disable your own account" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (target.role === "system_admin" && nextRole !== "system_admin") {
|
||||||
|
const adminCount = db.prepare("SELECT COUNT(*) AS count FROM users WHERE role = 'system_admin'").get().count;
|
||||||
|
if (adminCount <= 1) {
|
||||||
|
json(response, 400, { error: "At least one system admin is required" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
db.prepare("UPDATE users SET role = ?, status = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?").run(
|
||||||
|
nextRole,
|
||||||
|
nextStatus,
|
||||||
|
userId,
|
||||||
|
);
|
||||||
|
|
||||||
|
json(response, 200, { user: publicUser(db.prepare("SELECT * FROM users WHERE id = ?").get(userId)) });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleUsersApi(request, response, url) {
|
||||||
|
try {
|
||||||
|
if (url.pathname === "/api/admin/users" && request.method === "GET") {
|
||||||
|
await handleListUsers(request, response);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.pathname === "/api/admin/users" && request.method === "POST") {
|
||||||
|
await handleCreateUser(request, response);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const match = url.pathname.match(/^\/api\/admin\/users\/(\d+)$/);
|
||||||
|
if (match && request.method === "PATCH") {
|
||||||
|
await handleUpdateUser(request, response, Number(match[1]));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
} catch (error) {
|
||||||
|
json(response, 500, {
|
||||||
|
error: error instanceof Error ? error.message : "Unknown users API error",
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
+349
-8
@@ -1,7 +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 { changePassword, loadAuthOptions, loadCurrentUser, loginWithEmail, logoutCms, registerWithEmail, updateProfile } from "./api/auth";
|
||||||
import { loadSystemSettings, saveSystemSettings } from "./api/adminSettings";
|
import { loadSystemSettings, saveSystemSettings, testSmtpSettings } from "./api/adminSettings";
|
||||||
import {
|
import {
|
||||||
createGitHubRepository,
|
createGitHubRepository,
|
||||||
loadGitHubRepositories,
|
loadGitHubRepositories,
|
||||||
@@ -29,23 +29,40 @@ import ConnectPanel from "./components/ConnectPanel.vue";
|
|||||||
import ContentPanel from "./components/ContentPanel.vue";
|
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 ProjectPanel from "./components/ProjectPanel.vue";
|
||||||
import StructurePanel from "./components/StructurePanel.vue";
|
import StructurePanel from "./components/StructurePanel.vue";
|
||||||
import SystemSettingsPanel from "./components/SystemSettingsPanel.vue";
|
import SystemSettingsPanel from "./components/SystemSettingsPanel.vue";
|
||||||
|
import UserManagementPanel from "./components/UserManagementPanel.vue";
|
||||||
|
import { createAdminUser, loadAdminUsers, updateAdminUser } from "./api/adminUsers";
|
||||||
|
import {
|
||||||
|
addProjectMember,
|
||||||
|
createProject,
|
||||||
|
loadProjectMembers,
|
||||||
|
loadProjects,
|
||||||
|
removeProjectMember,
|
||||||
|
updateProject,
|
||||||
|
updateProjectMember,
|
||||||
|
} from "./api/projects";
|
||||||
|
import { hasPermission, permissions } from "./utils/permissions";
|
||||||
import { mockNavItems, mockPages, mockSidebarGroups, mockSiteSettings } from "./data/mockSite";
|
import { mockNavItems, mockPages, mockSidebarGroups, mockSiteSettings } from "./data/mockSite";
|
||||||
import type {
|
import type {
|
||||||
CmsUser,
|
CmsUser,
|
||||||
|
CmsProject,
|
||||||
|
AuthOptions,
|
||||||
DataSource,
|
DataSource,
|
||||||
DocPage,
|
DocPage,
|
||||||
GitHubConnection,
|
GitHubConnection,
|
||||||
GitHubRepository,
|
GitHubRepository,
|
||||||
GitHubUser,
|
GitHubUser,
|
||||||
PanelKey,
|
PanelKey,
|
||||||
|
ProjectMember,
|
||||||
SystemSetting,
|
SystemSetting,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
const panels = computed<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 }> = [
|
const basePanels: Array<{ key: PanelKey; label: string; icon: string }> = [
|
||||||
{ key: "account", label: "用户中心", icon: "U" },
|
{ key: "account", label: "用户中心", icon: "U" },
|
||||||
|
{ key: "projects", label: "项目管理", icon: "J" },
|
||||||
{ 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" },
|
||||||
@@ -53,7 +70,11 @@ const panels = computed<Array<{ key: PanelKey; label: string; icon: string }>>((
|
|||||||
{ key: "publish", label: "发布中心", icon: "R" },
|
{ key: "publish", label: "发布中心", icon: "R" },
|
||||||
];
|
];
|
||||||
|
|
||||||
if (currentUser.value?.role === "system_admin") {
|
if (hasPermission(currentUser.value, permissions.usersManage)) {
|
||||||
|
basePanels.push({ key: "users", label: "用户管理", icon: "M" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasPermission(currentUser.value, permissions.settingsManage)) {
|
||||||
basePanels.push({ key: "admin", label: "系统设置", icon: "A" });
|
basePanels.push({ key: "admin", label: "系统设置", icon: "A" });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,17 +112,43 @@ 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 systemSettingsSaveState = ref<"idle" | "saving" | "saved" | "error">("idle");
|
||||||
|
const profileSaveState = ref<"idle" | "saving" | "saved" | "error">("idle");
|
||||||
|
const passwordSaveState = ref<"idle" | "saving" | "saved" | "error">("idle");
|
||||||
|
const smtpTestState = ref<"idle" | "testing" | "success" | "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 authOptions = ref<AuthOptions>({
|
||||||
|
allowEmailLogin: true,
|
||||||
|
allowGitHubLogin: true,
|
||||||
|
allowRegistration: false,
|
||||||
|
});
|
||||||
|
const adminUsers = ref<CmsUser[]>([]);
|
||||||
|
const projects = ref<CmsProject[]>([]);
|
||||||
|
const projectMembers = ref<ProjectMember[]>([]);
|
||||||
|
const activeProjectId = ref<number>();
|
||||||
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 connectionMessage = ref("");
|
||||||
const repositoryMessage = ref("");
|
const repositoryMessage = ref("");
|
||||||
|
const usersMessage = ref("");
|
||||||
|
const accountMessage = ref("");
|
||||||
|
const smtpMessage = ref("");
|
||||||
|
const usersLoadState = ref<"idle" | "loading" | "error">("idle");
|
||||||
|
const projectsMessage = ref("");
|
||||||
|
const projectsState = ref<"idle" | "loading" | "saving" | "error">("idle");
|
||||||
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.value.find((panel) => panel.key === activePanel.value)?.label ?? "");
|
const activePanelTitle = computed(() => panels.value.find((panel) => panel.key === activePanel.value)?.label ?? "");
|
||||||
|
const activeProjectMember = computed(() =>
|
||||||
|
projectMembers.value.find((member) => member.userId === currentUser.value?.id),
|
||||||
|
);
|
||||||
|
const canManageActiveProject = computed(() => {
|
||||||
|
if (!currentUser.value) return false;
|
||||||
|
if (currentUser.value.role === "system_admin" || currentUser.value.role === "admin") return true;
|
||||||
|
return Boolean(activeProjectMember.value && ["owner", "admin"].includes(activeProjectMember.value.role));
|
||||||
|
});
|
||||||
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}`;
|
||||||
@@ -122,7 +169,7 @@ function normalizeConnection(connection: GitHubConnection): GitHubConnection {
|
|||||||
owner: connection.owner.trim(),
|
owner: connection.owner.trim(),
|
||||||
repo: connection.repo.trim(),
|
repo: connection.repo.trim(),
|
||||||
branch: connection.branch.trim() || "main",
|
branch: connection.branch.trim() || "main",
|
||||||
siteRoot: connection.siteRoot.trim().replace(/^\/+|\/+$/g, ""),
|
siteRoot: connection.siteRoot.trim().replace(/^\/+|\/+$/g, "") || "docs",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -193,7 +240,7 @@ async function loadSettings() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loadAdminSettings() {
|
async function loadAdminSettings() {
|
||||||
if (currentUser.value?.role !== "system_admin") return;
|
if (!hasPermission(currentUser.value, permissions.settingsManage)) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
systemSettings.value = await loadSystemSettings();
|
systemSettings.value = await loadSystemSettings();
|
||||||
@@ -205,6 +252,190 @@ async function loadAdminSettings() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadUsers() {
|
||||||
|
if (!hasPermission(currentUser.value, permissions.usersManage)) return;
|
||||||
|
usersLoadState.value = "loading";
|
||||||
|
usersMessage.value = "正在读取用户列表";
|
||||||
|
|
||||||
|
try {
|
||||||
|
adminUsers.value = await loadAdminUsers();
|
||||||
|
usersMessage.value = `已读取 ${adminUsers.value.length} 个用户`;
|
||||||
|
usersLoadState.value = "idle";
|
||||||
|
} catch (error) {
|
||||||
|
usersMessage.value = error instanceof Error ? error.message : "读取用户列表失败";
|
||||||
|
usersLoadState.value = "error";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyProject(project: CmsProject) {
|
||||||
|
activeProjectId.value = project.id;
|
||||||
|
Object.assign(githubConnection, {
|
||||||
|
token: "",
|
||||||
|
owner: project.githubOwner || "",
|
||||||
|
repo: project.githubRepo || "",
|
||||||
|
branch: project.githubBranch || "main",
|
||||||
|
siteRoot: project.siteRoot || "docs",
|
||||||
|
});
|
||||||
|
hasGitHubConnection.value = Boolean(project.githubOwner && project.githubRepo);
|
||||||
|
dataSource.value = hasGitHubConnection.value ? "github" : "local";
|
||||||
|
projectsMessage.value = `已选择项目 ${project.name}`;
|
||||||
|
void loadMembers(project.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadMembers(projectId = activeProjectId.value) {
|
||||||
|
if (!projectId) {
|
||||||
|
projectMembers.value = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
projectMembers.value = await loadProjectMembers(projectId);
|
||||||
|
} catch (error) {
|
||||||
|
projectsMessage.value = error instanceof Error ? error.message : "读取项目成员失败";
|
||||||
|
projectsState.value = "error";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadProjectList() {
|
||||||
|
projectsState.value = "loading";
|
||||||
|
projectsMessage.value = "正在读取项目列表";
|
||||||
|
|
||||||
|
try {
|
||||||
|
projects.value = await loadProjects();
|
||||||
|
if (!activeProjectId.value && projects.value[0]) {
|
||||||
|
applyProject(projects.value[0]);
|
||||||
|
}
|
||||||
|
projectsMessage.value = `已读取 ${projects.value.length} 个项目`;
|
||||||
|
projectsState.value = "idle";
|
||||||
|
} catch (error) {
|
||||||
|
projectsMessage.value = error instanceof Error ? error.message : "读取项目失败";
|
||||||
|
projectsState.value = "error";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createCmsProject(payload: {
|
||||||
|
name: string;
|
||||||
|
githubOwner?: string;
|
||||||
|
githubRepo?: string;
|
||||||
|
githubBranch?: string;
|
||||||
|
siteRoot?: string;
|
||||||
|
}) {
|
||||||
|
projectsState.value = "saving";
|
||||||
|
projectsMessage.value = "正在创建项目";
|
||||||
|
|
||||||
|
try {
|
||||||
|
const project = await createProject(payload);
|
||||||
|
projects.value = [project, ...projects.value];
|
||||||
|
applyProject(project);
|
||||||
|
projectsState.value = "idle";
|
||||||
|
projectsMessage.value = `已创建项目 ${project.name}`;
|
||||||
|
} catch (error) {
|
||||||
|
projectsState.value = "error";
|
||||||
|
projectsMessage.value = error instanceof Error ? error.message : "创建项目失败";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveCmsProject(payload: {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
githubOwner?: string;
|
||||||
|
githubRepo?: string;
|
||||||
|
githubBranch?: string;
|
||||||
|
siteRoot?: string;
|
||||||
|
}) {
|
||||||
|
projectsState.value = "saving";
|
||||||
|
projectsMessage.value = "正在保存项目";
|
||||||
|
|
||||||
|
try {
|
||||||
|
const project = await updateProject(payload.id, payload);
|
||||||
|
projects.value = projects.value.map((item) => (item.id === project.id ? project : item));
|
||||||
|
applyProject(project);
|
||||||
|
projectsState.value = "idle";
|
||||||
|
projectsMessage.value = `已保存项目 ${project.name}`;
|
||||||
|
} catch (error) {
|
||||||
|
projectsState.value = "error";
|
||||||
|
projectsMessage.value = error instanceof Error ? error.message : "保存项目失败";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addMember(payload: { email: string; role: string }) {
|
||||||
|
if (!activeProjectId.value) return;
|
||||||
|
projectsState.value = "saving";
|
||||||
|
|
||||||
|
try {
|
||||||
|
projectMembers.value = await addProjectMember(activeProjectId.value, payload);
|
||||||
|
projectsState.value = "idle";
|
||||||
|
projectsMessage.value = "项目成员已添加";
|
||||||
|
} catch (error) {
|
||||||
|
projectsState.value = "error";
|
||||||
|
projectsMessage.value = error instanceof Error ? error.message : "添加项目成员失败";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateMember(payload: { id: number; role: string }) {
|
||||||
|
if (!activeProjectId.value) return;
|
||||||
|
projectsState.value = "saving";
|
||||||
|
|
||||||
|
try {
|
||||||
|
projectMembers.value = await updateProjectMember(activeProjectId.value, payload.id, { role: payload.role });
|
||||||
|
projectsState.value = "idle";
|
||||||
|
projectsMessage.value = "项目成员已更新";
|
||||||
|
} catch (error) {
|
||||||
|
projectsState.value = "error";
|
||||||
|
projectsMessage.value = error instanceof Error ? error.message : "更新项目成员失败";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeMember(memberId: number) {
|
||||||
|
if (!activeProjectId.value) return;
|
||||||
|
projectsState.value = "saving";
|
||||||
|
|
||||||
|
try {
|
||||||
|
projectMembers.value = await removeProjectMember(activeProjectId.value, memberId);
|
||||||
|
projectsState.value = "idle";
|
||||||
|
projectsMessage.value = "项目成员已移除";
|
||||||
|
} catch (error) {
|
||||||
|
projectsState.value = "error";
|
||||||
|
projectsMessage.value = error instanceof Error ? error.message : "移除项目成员失败";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateUser(payload: { id: number; role?: string; status?: string }) {
|
||||||
|
usersLoadState.value = "loading";
|
||||||
|
usersMessage.value = "正在更新用户";
|
||||||
|
|
||||||
|
try {
|
||||||
|
const user = await updateAdminUser(payload.id, {
|
||||||
|
role: payload.role,
|
||||||
|
status: payload.status,
|
||||||
|
});
|
||||||
|
adminUsers.value = adminUsers.value.map((item) => (item.id === user.id ? user : item));
|
||||||
|
if (currentUser.value?.id === user.id) {
|
||||||
|
currentUser.value = user;
|
||||||
|
}
|
||||||
|
usersMessage.value = "用户已更新";
|
||||||
|
usersLoadState.value = "idle";
|
||||||
|
} catch (error) {
|
||||||
|
usersMessage.value = error instanceof Error ? error.message : "更新用户失败";
|
||||||
|
usersLoadState.value = "error";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createUser(payload: { email: string; name: string; password: string; role: string }) {
|
||||||
|
usersLoadState.value = "loading";
|
||||||
|
usersMessage.value = "正在创建用户";
|
||||||
|
|
||||||
|
try {
|
||||||
|
const user = await createAdminUser(payload);
|
||||||
|
adminUsers.value = [user, ...adminUsers.value];
|
||||||
|
usersMessage.value = `用户 ${user.name} 已创建`;
|
||||||
|
usersLoadState.value = "idle";
|
||||||
|
} catch (error) {
|
||||||
|
usersMessage.value = error instanceof Error ? error.message : "创建用户失败";
|
||||||
|
usersLoadState.value = "error";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function reloadSite() {
|
async function reloadSite() {
|
||||||
await Promise.all([loadPages(), loadSettings()]);
|
await Promise.all([loadPages(), loadSettings()]);
|
||||||
}
|
}
|
||||||
@@ -227,6 +458,7 @@ async function refreshRepositories() {
|
|||||||
|
|
||||||
async function loadAuthState() {
|
async function loadAuthState() {
|
||||||
try {
|
try {
|
||||||
|
authOptions.value = await loadAuthOptions();
|
||||||
currentUser.value = await loadCurrentUser();
|
currentUser.value = await loadCurrentUser();
|
||||||
githubUser.value = await loadGitHubUser();
|
githubUser.value = await loadGitHubUser();
|
||||||
|
|
||||||
@@ -248,7 +480,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 Promise.all([reloadSite(), loadAdminSettings()]);
|
await Promise.all([reloadSite(), loadAdminSettings(), loadUsers(), loadProjectList()]);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
loginError.value = error instanceof Error ? error.message : "登录失败";
|
loginError.value = error instanceof Error ? error.message : "登录失败";
|
||||||
} finally {
|
} finally {
|
||||||
@@ -256,12 +488,57 @@ async function handleEmailLogin(payload: { email: string; password: string }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleEmailRegister(payload: { email: string; name: string; password: string }) {
|
||||||
|
isAuthLoading.value = true;
|
||||||
|
loginError.value = "";
|
||||||
|
|
||||||
|
try {
|
||||||
|
currentUser.value = await registerWithEmail(payload);
|
||||||
|
await loadAuthState();
|
||||||
|
await Promise.all([reloadSite(), loadAdminSettings(), loadUsers(), loadProjectList()]);
|
||||||
|
} catch (error) {
|
||||||
|
loginError.value = error instanceof Error ? error.message : "注册失败";
|
||||||
|
} finally {
|
||||||
|
isAuthLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function handleLogout() {
|
async function handleLogout() {
|
||||||
await logoutCms();
|
await logoutCms();
|
||||||
currentUser.value = null;
|
currentUser.value = null;
|
||||||
githubUser.value = null;
|
githubUser.value = null;
|
||||||
repositories.value = [];
|
repositories.value = [];
|
||||||
systemSettings.value = [];
|
systemSettings.value = [];
|
||||||
|
adminUsers.value = [];
|
||||||
|
projects.value = [];
|
||||||
|
projectMembers.value = [];
|
||||||
|
activeProjectId.value = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveProfile(payload: { name: string }) {
|
||||||
|
profileSaveState.value = "saving";
|
||||||
|
accountMessage.value = "正在保存个人资料";
|
||||||
|
|
||||||
|
try {
|
||||||
|
currentUser.value = await updateProfile(payload);
|
||||||
|
profileSaveState.value = "saved";
|
||||||
|
accountMessage.value = "个人资料已保存";
|
||||||
|
} catch (error) {
|
||||||
|
profileSaveState.value = "error";
|
||||||
|
accountMessage.value = error instanceof Error ? error.message : "保存个人资料失败";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updatePassword(payload: { currentPassword: string; newPassword: string }) {
|
||||||
|
passwordSaveState.value = "saving";
|
||||||
|
|
||||||
|
try {
|
||||||
|
await changePassword(payload);
|
||||||
|
passwordSaveState.value = "saved";
|
||||||
|
} catch (error) {
|
||||||
|
passwordSaveState.value = "error";
|
||||||
|
accountMessage.value = error instanceof Error ? error.message : "修改密码失败";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function connectGithub(connection: GitHubConnection) {
|
async function connectGithub(connection: GitHubConnection) {
|
||||||
@@ -295,6 +572,19 @@ 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";
|
||||||
|
if (activeProjectId.value) {
|
||||||
|
const project = projects.value.find((item) => item.id === activeProjectId.value);
|
||||||
|
if (project) {
|
||||||
|
await saveCmsProject({
|
||||||
|
id: project.id,
|
||||||
|
name: project.name,
|
||||||
|
githubOwner: githubConnection.owner,
|
||||||
|
githubRepo: githubConnection.repo,
|
||||||
|
githubBranch: githubConnection.branch,
|
||||||
|
siteRoot: githubConnection.siteRoot,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
connectionMessage.value = `已连接 GitHub 仓库 ${githubConnection.owner}/${githubConnection.repo}`;
|
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) {
|
||||||
@@ -340,7 +630,7 @@ async function createRepository(payload: { name: string; private: boolean }) {
|
|||||||
owner: repo.owner,
|
owner: repo.owner,
|
||||||
repo: repo.name,
|
repo: repo.name,
|
||||||
branch: repo.defaultBranch || "main",
|
branch: repo.defaultBranch || "main",
|
||||||
siteRoot: "",
|
siteRoot: "docs",
|
||||||
});
|
});
|
||||||
repositoryMessage.value = `已创建 GitHub 仓库 ${repo.fullName}`;
|
repositoryMessage.value = `已创建 GitHub 仓库 ${repo.fullName}`;
|
||||||
isCreatingRepository.value = false;
|
isCreatingRepository.value = false;
|
||||||
@@ -459,6 +749,7 @@ async function saveAdminSettings(settings: SystemSetting[]) {
|
|||||||
try {
|
try {
|
||||||
await saveSystemSettings(settings);
|
await saveSystemSettings(settings);
|
||||||
systemSettingsSaveState.value = "saved";
|
systemSettingsSaveState.value = "saved";
|
||||||
|
authOptions.value = await loadAuthOptions();
|
||||||
await loadAdminSettings();
|
await loadAdminSettings();
|
||||||
activityItems.value = ["已保存系统设置", ...activityItems.value];
|
activityItems.value = ["已保存系统设置", ...activityItems.value];
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -470,6 +761,20 @@ async function saveAdminSettings(settings: SystemSetting[]) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function testSmtp() {
|
||||||
|
smtpTestState.value = "testing";
|
||||||
|
smtpMessage.value = "正在测试 SMTP 连接";
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await testSmtpSettings();
|
||||||
|
smtpTestState.value = "success";
|
||||||
|
smtpMessage.value = result.greeting ? `SMTP 连接正常:${result.greeting}` : "SMTP 连接正常";
|
||||||
|
} catch (error) {
|
||||||
|
smtpTestState.value = "error";
|
||||||
|
smtpMessage.value = error instanceof Error ? error.message : "SMTP 测试失败";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function publishChanges() {
|
function publishChanges() {
|
||||||
activityItems.value = ["已创建 commit,并触发部署流程", ...activityItems.value];
|
activityItems.value = ["已创建 commit,并触发部署流程", ...activityItems.value];
|
||||||
}
|
}
|
||||||
@@ -479,7 +784,7 @@ onMounted(async () => {
|
|||||||
await loadAuthState();
|
await loadAuthState();
|
||||||
|
|
||||||
if (currentUser.value) {
|
if (currentUser.value) {
|
||||||
await Promise.all([reloadSite(), loadAdminSettings()]);
|
await Promise.all([reloadSite(), loadAdminSettings(), loadUsers(), loadProjectList()]);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
isAuthLoading.value = false;
|
isAuthLoading.value = false;
|
||||||
@@ -490,9 +795,11 @@ onMounted(async () => {
|
|||||||
<template>
|
<template>
|
||||||
<LoginView
|
<LoginView
|
||||||
v-if="!currentUser"
|
v-if="!currentUser"
|
||||||
|
:auth-options="authOptions"
|
||||||
:error-message="loginError"
|
:error-message="loginError"
|
||||||
:is-loading="isAuthLoading"
|
:is-loading="isAuthLoading"
|
||||||
@email-login="handleEmailLogin"
|
@email-login="handleEmailLogin"
|
||||||
|
@email-register="handleEmailRegister"
|
||||||
@github-login="startGitHubLogin"
|
@github-login="startGitHubLogin"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -524,17 +831,51 @@ onMounted(async () => {
|
|||||||
|
|
||||||
<AccountPanel
|
<AccountPanel
|
||||||
v-if="activePanel === 'account'"
|
v-if="activePanel === 'account'"
|
||||||
|
:message="accountMessage"
|
||||||
:current-user="currentUser"
|
:current-user="currentUser"
|
||||||
:github-user="githubUser"
|
:github-user="githubUser"
|
||||||
|
:password-state="passwordSaveState"
|
||||||
|
:profile-state="profileSaveState"
|
||||||
|
@change-password="updatePassword"
|
||||||
@github-login="startGitHubLogin"
|
@github-login="startGitHubLogin"
|
||||||
@github-logout="signOutGithub"
|
@github-logout="signOutGithub"
|
||||||
@logout="handleLogout"
|
@logout="handleLogout"
|
||||||
|
@save-profile="saveProfile"
|
||||||
|
/>
|
||||||
|
<UserManagementPanel
|
||||||
|
v-else-if="activePanel === 'users'"
|
||||||
|
:current-user="currentUser"
|
||||||
|
:load-state="usersLoadState"
|
||||||
|
:message="usersMessage"
|
||||||
|
:users="adminUsers"
|
||||||
|
@create-user="createUser"
|
||||||
|
@refresh-users="loadUsers"
|
||||||
|
@update-user="updateUser"
|
||||||
|
/>
|
||||||
|
<ProjectPanel
|
||||||
|
v-else-if="activePanel === 'projects'"
|
||||||
|
:active-project-id="activeProjectId"
|
||||||
|
:can-manage-active-project="canManageActiveProject"
|
||||||
|
:members="projectMembers"
|
||||||
|
:message="projectsMessage"
|
||||||
|
:projects="projects"
|
||||||
|
:state="projectsState"
|
||||||
|
@add-member="addMember"
|
||||||
|
@create-project="createCmsProject"
|
||||||
|
@remove-member="removeMember"
|
||||||
|
@refresh-projects="loadProjectList"
|
||||||
|
@select-project="applyProject"
|
||||||
|
@update-member="updateMember"
|
||||||
|
@update-project="saveCmsProject"
|
||||||
/>
|
/>
|
||||||
<SystemSettingsPanel
|
<SystemSettingsPanel
|
||||||
v-else-if="activePanel === 'admin'"
|
v-else-if="activePanel === 'admin'"
|
||||||
:save-state="systemSettingsSaveState"
|
:save-state="systemSettingsSaveState"
|
||||||
:settings="systemSettings"
|
:settings="systemSettings"
|
||||||
|
:smtp-message="smtpMessage"
|
||||||
|
:smtp-state="smtpTestState"
|
||||||
@save-settings="saveAdminSettings"
|
@save-settings="saveAdminSettings"
|
||||||
|
@test-smtp="testSmtp"
|
||||||
/>
|
/>
|
||||||
<ConnectPanel
|
<ConnectPanel
|
||||||
v-else-if="activePanel === 'connect'"
|
v-else-if="activePanel === 'connect'"
|
||||||
|
|||||||
@@ -4,11 +4,16 @@ interface SettingsResponse {
|
|||||||
settings: SystemSetting[];
|
settings: SystemSetting[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function readError(response: Response, fallback: string) {
|
||||||
|
const data = await response.json().catch(() => null);
|
||||||
|
return data?.error || `${fallback}: ${response.status}`;
|
||||||
|
}
|
||||||
|
|
||||||
export async function loadSystemSettings() {
|
export async function loadSystemSettings() {
|
||||||
const response = await fetch("/api/admin/settings");
|
const response = await fetch("/api/admin/settings");
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`读取系统设置失败:${response.status}`);
|
throw new Error(await readError(response, "读取系统设置失败"));
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = (await response.json()) as SettingsResponse;
|
const data = (await response.json()) as SettingsResponse;
|
||||||
@@ -25,6 +30,21 @@ export async function saveSystemSettings(settings: SystemSetting[]) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`保存系统设置失败:${response.status}`);
|
throw new Error(await readError(response, "保存系统设置失败"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function testSmtpSettings() {
|
||||||
|
const response = await fetch("/api/admin/settings/test-smtp", {
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(await readError(response, "SMTP 测试失败"));
|
||||||
|
}
|
||||||
|
|
||||||
|
return (await response.json()) as {
|
||||||
|
ok: boolean;
|
||||||
|
greeting?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import type { CmsUser } from "../types";
|
||||||
|
|
||||||
|
interface UsersResponse {
|
||||||
|
users: CmsUser[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UserResponse {
|
||||||
|
user: CmsUser;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readError(response: Response, fallback: string) {
|
||||||
|
const data = await response.json().catch(() => null);
|
||||||
|
return data?.error || `${fallback}: ${response.status}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadAdminUsers() {
|
||||||
|
const response = await fetch("/api/admin/users");
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(await readError(response, "读取用户列表失败"));
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await response.json()) as UsersResponse;
|
||||||
|
return data.users;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createAdminUser(payload: {
|
||||||
|
email: string;
|
||||||
|
name: string;
|
||||||
|
password: string;
|
||||||
|
role: string;
|
||||||
|
}) {
|
||||||
|
const response = await fetch("/api/admin/users", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(await readError(response, "创建用户失败"));
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await response.json()) as UserResponse;
|
||||||
|
return data.user;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateAdminUser(
|
||||||
|
id: number,
|
||||||
|
payload: {
|
||||||
|
role?: string;
|
||||||
|
status?: string;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const response = await fetch(`/api/admin/users/${id}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(await readError(response, "更新用户失败"));
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await response.json()) as UserResponse;
|
||||||
|
return data.user;
|
||||||
|
}
|
||||||
+77
-6
@@ -1,24 +1,44 @@
|
|||||||
import type { CmsUser } from "../types";
|
import type { AuthOptions, CmsUser } from "../types";
|
||||||
|
|
||||||
interface MeResponse {
|
interface MeResponse {
|
||||||
user: CmsUser | null;
|
user: CmsUser | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface LoginResponse {
|
interface UserResponse {
|
||||||
user: CmsUser;
|
user: CmsUser;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface OptionsResponse {
|
||||||
|
options: AuthOptions;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readError(response: Response, fallback: string) {
|
||||||
|
const data = await response.json().catch(() => null);
|
||||||
|
return data?.error || `${fallback}: ${response.status}`;
|
||||||
|
}
|
||||||
|
|
||||||
export async function loadCurrentUser() {
|
export async function loadCurrentUser() {
|
||||||
const response = await fetch("/api/auth/me");
|
const response = await fetch("/api/auth/me");
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`读取当前用户失败:${response.status}`);
|
throw new Error(await readError(response, "读取当前用户失败"));
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = (await response.json()) as MeResponse;
|
const data = (await response.json()) as MeResponse;
|
||||||
return data.user;
|
return data.user;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function loadAuthOptions() {
|
||||||
|
const response = await fetch("/api/auth/options");
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(await readError(response, "读取登录设置失败"));
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await response.json()) as OptionsResponse;
|
||||||
|
return data.options;
|
||||||
|
}
|
||||||
|
|
||||||
export async function loginWithEmail(email: string, password: string) {
|
export async function loginWithEmail(email: string, password: string) {
|
||||||
const response = await fetch("/api/auth/login", {
|
const response = await fetch("/api/auth/login", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -29,19 +49,70 @@ export async function loginWithEmail(email: string, password: string) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error("邮箱或密码错误");
|
throw new Error(await readError(response, "邮箱或密码错误"));
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = (await response.json()) as LoginResponse;
|
const data = (await response.json()) as UserResponse;
|
||||||
return data.user;
|
return data.user;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function registerWithEmail(payload: { email: string; name: string; password: string }) {
|
||||||
|
const response = await fetch("/api/auth/register", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(await readError(response, "注册失败"));
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await response.json()) as UserResponse;
|
||||||
|
return data.user;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateProfile(payload: { name: string }) {
|
||||||
|
const response = await fetch("/api/auth/profile", {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(await readError(response, "更新个人资料失败"));
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await response.json()) as UserResponse;
|
||||||
|
return data.user;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function changePassword(payload: {
|
||||||
|
currentPassword: string;
|
||||||
|
newPassword: string;
|
||||||
|
}) {
|
||||||
|
const response = await fetch("/api/auth/password", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(await readError(response, "修改密码失败"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function logoutCms() {
|
export async function logoutCms() {
|
||||||
const response = await fetch("/api/auth/logout", {
|
const response = await fetch("/api/auth/logout", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`退出登录失败:${response.status}`);
|
throw new Error(await readError(response, "退出登录失败"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ function siteParams(connection: GitHubConnection) {
|
|||||||
owner: connection.owner,
|
owner: connection.owner,
|
||||||
repo: connection.repo,
|
repo: connection.repo,
|
||||||
branch: connection.branch,
|
branch: connection.branch,
|
||||||
siteRoot: connection.siteRoot,
|
siteRoot: normalizeSiteRoot(connection.siteRoot) || "docs",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -166,7 +166,7 @@ export async function saveGitHubPage(connection: GitHubConnection, page: DocPage
|
|||||||
owner: connection.owner,
|
owner: connection.owner,
|
||||||
repo: connection.repo,
|
repo: connection.repo,
|
||||||
branch: connection.branch,
|
branch: connection.branch,
|
||||||
siteRoot: connection.siteRoot,
|
siteRoot: normalizeSiteRoot(connection.siteRoot) || "docs",
|
||||||
},
|
},
|
||||||
page,
|
page,
|
||||||
}),
|
}),
|
||||||
@@ -237,7 +237,7 @@ export async function initializeGitHubSite(connection: GitHubConnection) {
|
|||||||
owner: connection.owner,
|
owner: connection.owner,
|
||||||
repo: connection.repo,
|
repo: connection.repo,
|
||||||
branch: connection.branch,
|
branch: connection.branch,
|
||||||
siteRoot: connection.siteRoot,
|
siteRoot: normalizeSiteRoot(connection.siteRoot) || "docs",
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
@@ -265,7 +265,7 @@ export async function saveGitHubSettings(
|
|||||||
owner: connection.owner,
|
owner: connection.owner,
|
||||||
repo: connection.repo,
|
repo: connection.repo,
|
||||||
branch: connection.branch,
|
branch: connection.branch,
|
||||||
siteRoot: connection.siteRoot,
|
siteRoot: normalizeSiteRoot(connection.siteRoot) || "docs",
|
||||||
},
|
},
|
||||||
settings,
|
settings,
|
||||||
sha,
|
sha,
|
||||||
|
|||||||
@@ -0,0 +1,136 @@
|
|||||||
|
import type { CmsProject, ProjectMember } from "../types";
|
||||||
|
|
||||||
|
interface ProjectsResponse {
|
||||||
|
projects: CmsProject[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProjectResponse {
|
||||||
|
project: CmsProject;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MembersResponse {
|
||||||
|
members: ProjectMember[];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readError(response: Response, fallback: string) {
|
||||||
|
const data = await response.json().catch(() => null);
|
||||||
|
return data?.error || `${fallback}: ${response.status}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadProjects() {
|
||||||
|
const response = await fetch("/api/projects");
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(await readError(response, "读取项目失败"));
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await response.json()) as ProjectsResponse;
|
||||||
|
return data.projects;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createProject(payload: {
|
||||||
|
name: string;
|
||||||
|
githubOwner?: string;
|
||||||
|
githubRepo?: string;
|
||||||
|
githubBranch?: string;
|
||||||
|
siteRoot?: string;
|
||||||
|
}) {
|
||||||
|
const response = await fetch("/api/projects", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(await readError(response, "创建项目失败"));
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await response.json()) as ProjectResponse;
|
||||||
|
return data.project;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateProject(
|
||||||
|
id: number,
|
||||||
|
payload: {
|
||||||
|
name: string;
|
||||||
|
githubOwner?: string;
|
||||||
|
githubRepo?: string;
|
||||||
|
githubBranch?: string;
|
||||||
|
siteRoot?: string;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const response = await fetch(`/api/projects/${id}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(await readError(response, "更新项目失败"));
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await response.json()) as ProjectResponse;
|
||||||
|
return data.project;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadProjectMembers(projectId: number) {
|
||||||
|
const response = await fetch(`/api/projects/${projectId}/members`);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(await readError(response, "读取项目成员失败"));
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await response.json()) as MembersResponse;
|
||||||
|
return data.members;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function addProjectMember(projectId: number, payload: { email: string; role: string }) {
|
||||||
|
const response = await fetch(`/api/projects/${projectId}/members`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(await readError(response, "添加项目成员失败"));
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await response.json()) as MembersResponse;
|
||||||
|
return data.members;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateProjectMember(projectId: number, memberId: number, payload: { role: string }) {
|
||||||
|
const response = await fetch(`/api/projects/${projectId}/members/${memberId}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(await readError(response, "更新项目成员失败"));
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await response.json()) as MembersResponse;
|
||||||
|
return data.members;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removeProjectMember(projectId: number, memberId: number) {
|
||||||
|
const response = await fetch(`/api/projects/${projectId}/members/${memberId}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(await readError(response, "移除项目成员失败"));
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await response.json()) as MembersResponse;
|
||||||
|
return data.members;
|
||||||
|
}
|
||||||
+120
-18
@@ -1,40 +1,144 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { reactive, watch } from "vue";
|
||||||
import type { CmsUser, GitHubUser } from "../types";
|
import type { CmsUser, GitHubUser } from "../types";
|
||||||
|
|
||||||
defineProps<{
|
const props = defineProps<{
|
||||||
currentUser: CmsUser;
|
currentUser: CmsUser;
|
||||||
githubUser: GitHubUser | null;
|
githubUser: GitHubUser | null;
|
||||||
|
profileState: "idle" | "saving" | "saved" | "error";
|
||||||
|
passwordState: "idle" | "saving" | "saved" | "error";
|
||||||
|
message: string;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
defineEmits<{
|
const emit = defineEmits<{
|
||||||
githubLogin: [];
|
githubLogin: [];
|
||||||
githubLogout: [];
|
githubLogout: [];
|
||||||
logout: [];
|
logout: [];
|
||||||
|
saveProfile: [payload: { name: string }];
|
||||||
|
changePassword: [payload: { currentPassword: string; newPassword: string }];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
|
const profileDraft = reactive({
|
||||||
|
name: props.currentUser.name,
|
||||||
|
});
|
||||||
|
|
||||||
|
const passwordDraft = reactive({
|
||||||
|
currentPassword: "",
|
||||||
|
newPassword: "",
|
||||||
|
confirmPassword: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.currentUser,
|
||||||
|
(user) => {
|
||||||
|
profileDraft.name = user.name;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
function submitProfile() {
|
||||||
|
emit("saveProfile", {
|
||||||
|
name: profileDraft.name.trim(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function submitPassword() {
|
||||||
|
if (!passwordDraft.newPassword || passwordDraft.newPassword !== passwordDraft.confirmPassword) return;
|
||||||
|
|
||||||
|
emit("changePassword", {
|
||||||
|
currentPassword: passwordDraft.currentPassword,
|
||||||
|
newPassword: passwordDraft.newPassword,
|
||||||
|
});
|
||||||
|
passwordDraft.currentPassword = "";
|
||||||
|
passwordDraft.newPassword = "";
|
||||||
|
passwordDraft.confirmPassword = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(value?: string) {
|
||||||
|
if (!value) return "-";
|
||||||
|
return new Date(value).toLocaleString();
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section class="panel is-visible">
|
<section class="panel is-visible">
|
||||||
<div class="account-layout">
|
<div class="account-grid">
|
||||||
<section class="management-card">
|
<section class="management-card">
|
||||||
<p class="eyebrow">account</p>
|
<p class="eyebrow">profile</p>
|
||||||
<h3>用户中心</h3>
|
<h3>个人资料</h3>
|
||||||
|
<div class="profile-card">
|
||||||
|
<span class="user-avatar">{{ currentUser.name.slice(0, 1).toUpperCase() }}</span>
|
||||||
|
<div>
|
||||||
|
<strong>{{ currentUser.name }}</strong>
|
||||||
|
<small>{{ currentUser.email || "未绑定邮箱" }}</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-grid single-column">
|
||||||
|
<label>
|
||||||
|
昵称
|
||||||
|
<input v-model="profileDraft.name" autocomplete="name" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<p class="helper-text">{{ message }}</p>
|
||||||
|
<div class="connect-actions">
|
||||||
|
<button class="primary-button" type="button" :disabled="profileState === 'saving'" @click="submitProfile">
|
||||||
|
{{ profileState === "saving" ? "保存中" : "保存资料" }}
|
||||||
|
</button>
|
||||||
|
<button class="ghost-button" type="button" @click="$emit('logout')">退出登录</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="management-card">
|
||||||
|
<p class="eyebrow">security</p>
|
||||||
|
<h3>安全信息</h3>
|
||||||
<dl class="source-summary account-summary">
|
<dl class="source-summary account-summary">
|
||||||
<div>
|
|
||||||
<dt>名称</dt>
|
|
||||||
<dd>{{ currentUser.name }}</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt>邮箱</dt>
|
|
||||||
<dd>{{ currentUser.email || "-" }}</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
<div>
|
||||||
<dt>角色</dt>
|
<dt>角色</dt>
|
||||||
<dd>{{ currentUser.role }}</dd>
|
<dd>{{ currentUser.role }}</dd>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>状态</dt>
|
||||||
|
<dd>{{ currentUser.status === "disabled" ? "已禁用" : "正常" }}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>权限</dt>
|
||||||
|
<dd>{{ currentUser.permissions?.length || 0 }} 项</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>最后登录</dt>
|
||||||
|
<dd>{{ formatDate(currentUser.lastLoginAt) }}</dd>
|
||||||
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
<button class="ghost-button account-logout" type="button" @click="$emit('logout')">
|
</section>
|
||||||
退出 CMS 登录
|
|
||||||
|
<section class="management-card">
|
||||||
|
<p class="eyebrow">password</p>
|
||||||
|
<h3>修改密码</h3>
|
||||||
|
<div class="form-grid single-column">
|
||||||
|
<label>
|
||||||
|
当前密码
|
||||||
|
<input v-model="passwordDraft.currentPassword" type="password" autocomplete="current-password" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
新密码
|
||||||
|
<input v-model="passwordDraft.newPassword" type="password" autocomplete="new-password" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
确认新密码
|
||||||
|
<input v-model="passwordDraft.confirmPassword" type="password" autocomplete="new-password" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<p class="helper-text">
|
||||||
|
<template v-if="passwordState === 'saved'">密码已更新。</template>
|
||||||
|
<template v-else-if="passwordState === 'error'">密码更新失败,请检查当前密码。</template>
|
||||||
|
<template v-else>新密码至少 8 位,确认密码需要一致。</template>
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
class="secondary-button"
|
||||||
|
type="button"
|
||||||
|
:disabled="passwordState === 'saving' || !passwordDraft.newPassword || passwordDraft.newPassword !== passwordDraft.confirmPassword"
|
||||||
|
@click="submitPassword"
|
||||||
|
>
|
||||||
|
{{ passwordState === "saving" ? "更新中" : "更新密码" }}
|
||||||
</button>
|
</button>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -51,9 +155,7 @@ defineEmits<{
|
|||||||
</div>
|
</div>
|
||||||
<div v-else>
|
<div v-else>
|
||||||
<p class="helper-text">绑定 GitHub 后,可以读取仓库列表、选择站点仓库并提交内容变更。</p>
|
<p class="helper-text">绑定 GitHub 后,可以读取仓库列表、选择站点仓库并提交内容变更。</p>
|
||||||
<button class="primary-button" type="button" @click="$emit('githubLogin')">
|
<button class="primary-button" type="button" @click="$emit('githubLogin')">绑定 GitHub</button>
|
||||||
绑定 GitHub
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -123,7 +123,7 @@ function initializeRepository() {
|
|||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
站点目录
|
站点目录
|
||||||
<input v-model="draft.siteRoot" placeholder="docs 或留空" />
|
<input v-model="draft.siteRoot" placeholder="默认 docs" />
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -185,7 +185,7 @@ function initializeRepository() {
|
|||||||
</label>
|
</label>
|
||||||
<label class="wide-field">
|
<label class="wide-field">
|
||||||
站点目录
|
站点目录
|
||||||
<input v-model="draft.siteRoot" placeholder="docs 或留空" />
|
<input v-model="draft.siteRoot" placeholder="默认 docs" />
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,23 +1,45 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { reactive } from "vue";
|
import { reactive } from "vue";
|
||||||
|
import type { AuthOptions } from "../types";
|
||||||
|
|
||||||
defineProps<{
|
const props = defineProps<{
|
||||||
|
authOptions: AuthOptions;
|
||||||
errorMessage: string;
|
errorMessage: string;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
emailLogin: [payload: { email: string; password: string }];
|
emailLogin: [payload: { email: string; password: string }];
|
||||||
|
emailRegister: [payload: { email: string; name: string; password: string }];
|
||||||
githubLogin: [];
|
githubLogin: [];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
|
const mode = reactive({
|
||||||
|
value: "login" as "login" | "register",
|
||||||
|
});
|
||||||
|
|
||||||
const form = reactive({
|
const form = reactive({
|
||||||
email: "admin@example.com",
|
email: "admin@example.com",
|
||||||
|
name: "",
|
||||||
password: "admin123456",
|
password: "admin123456",
|
||||||
});
|
});
|
||||||
|
|
||||||
function submit() {
|
function submit() {
|
||||||
emit("emailLogin", { ...form });
|
const submitMode = !props.authOptions.allowEmailLogin && props.authOptions.allowRegistration ? "register" : mode.value;
|
||||||
|
|
||||||
|
if (submitMode === "register") {
|
||||||
|
emit("emailRegister", {
|
||||||
|
email: form.email,
|
||||||
|
name: form.name,
|
||||||
|
password: form.password,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
emit("emailLogin", {
|
||||||
|
email: form.email,
|
||||||
|
password: form.password,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -32,27 +54,45 @@ function submit() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form class="login-form" @submit.prevent="submit">
|
<div class="segmented-control">
|
||||||
|
<button v-if="authOptions.allowEmailLogin" type="button" :class="{ 'is-active': mode.value === 'login' }" @click="mode.value = 'login'">
|
||||||
|
登录
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="authOptions.allowRegistration"
|
||||||
|
type="button"
|
||||||
|
:class="{ 'is-active': mode.value === 'register' }"
|
||||||
|
@click="mode.value = 'register'"
|
||||||
|
>
|
||||||
|
注册
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form v-if="authOptions.allowEmailLogin || authOptions.allowRegistration" class="login-form" @submit.prevent="submit">
|
||||||
<label>
|
<label>
|
||||||
邮箱
|
邮箱
|
||||||
<input v-model="form.email" type="email" autocomplete="username" />
|
<input v-model="form.email" type="email" autocomplete="username" />
|
||||||
</label>
|
</label>
|
||||||
|
<label v-if="mode.value === 'register' || (!authOptions.allowEmailLogin && authOptions.allowRegistration)">
|
||||||
|
昵称
|
||||||
|
<input v-model="form.name" autocomplete="name" />
|
||||||
|
</label>
|
||||||
<label>
|
<label>
|
||||||
密码
|
密码
|
||||||
<input v-model="form.password" type="password" autocomplete="current-password" />
|
<input v-model="form.password" type="password" autocomplete="current-password" />
|
||||||
</label>
|
</label>
|
||||||
<button class="primary-button" type="submit" :disabled="isLoading">
|
<button class="primary-button" type="submit" :disabled="isLoading">
|
||||||
{{ isLoading ? "登录中" : "邮箱登录" }}
|
{{ isLoading ? "处理中" : mode.value === "register" || (!authOptions.allowEmailLogin && authOptions.allowRegistration) ? "创建账号" : "邮箱登录" }}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<button class="connect-button" type="button" @click="$emit('githubLogin')">
|
<button v-if="authOptions.allowGitHubLogin" class="connect-button" type="button" @click="$emit('githubLogin')">
|
||||||
<span>GitHub</span>
|
<span>GitHub</span>
|
||||||
<strong>使用 GitHub 登录</strong>
|
<strong>使用 GitHub 登录</strong>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<p v-if="errorMessage" class="save-status is-error">{{ errorMessage }}</p>
|
<p v-if="errorMessage" class="save-status is-error">{{ errorMessage }}</p>
|
||||||
<p class="helper-text">默认管理员账号:admin@example.com / admin123456</p>
|
<p v-if="!authOptions.allowRegistration" class="helper-text">默认管理员账号:admin@example.com / admin123456。注册入口当前已关闭。</p>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -0,0 +1,199 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { reactive, watch } from "vue";
|
||||||
|
import type { CmsProject, ProjectMember } from "../types";
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
activeProjectId?: number;
|
||||||
|
canManageActiveProject: boolean;
|
||||||
|
members: ProjectMember[];
|
||||||
|
projects: CmsProject[];
|
||||||
|
message: string;
|
||||||
|
state: "idle" | "loading" | "saving" | "error";
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
createProject: [payload: { name: string; githubOwner?: string; githubRepo?: string; githubBranch?: string; siteRoot?: string }];
|
||||||
|
addMember: [payload: { email: string; role: string }];
|
||||||
|
refreshProjects: [];
|
||||||
|
removeMember: [memberId: number];
|
||||||
|
selectProject: [project: CmsProject];
|
||||||
|
updateMember: [payload: { id: number; role: string }];
|
||||||
|
updateProject: [payload: { id: number; name: string; githubOwner?: string; githubRepo?: string; githubBranch?: string; siteRoot?: string }];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const draft = reactive({
|
||||||
|
id: 0,
|
||||||
|
name: "",
|
||||||
|
githubOwner: "",
|
||||||
|
githubRepo: "",
|
||||||
|
githubBranch: "main",
|
||||||
|
siteRoot: "docs",
|
||||||
|
});
|
||||||
|
|
||||||
|
const memberDraft = reactive({
|
||||||
|
email: "",
|
||||||
|
role: "editor",
|
||||||
|
});
|
||||||
|
|
||||||
|
const memberRoles = [
|
||||||
|
{ label: "Owner", value: "owner" },
|
||||||
|
{ label: "Admin", value: "admin" },
|
||||||
|
{ label: "Editor", value: "editor" },
|
||||||
|
{ label: "Viewer", value: "viewer" },
|
||||||
|
];
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.projects.find((project) => project.id === props.activeProjectId),
|
||||||
|
(project) => {
|
||||||
|
if (!project) return;
|
||||||
|
draft.id = project.id;
|
||||||
|
draft.name = project.name;
|
||||||
|
draft.githubOwner = project.githubOwner || "";
|
||||||
|
draft.githubRepo = project.githubRepo || "";
|
||||||
|
draft.githubBranch = project.githubBranch || "main";
|
||||||
|
draft.siteRoot = project.siteRoot || "docs";
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
function payload() {
|
||||||
|
return {
|
||||||
|
name: draft.name.trim(),
|
||||||
|
githubOwner: draft.githubOwner.trim(),
|
||||||
|
githubRepo: draft.githubRepo.trim(),
|
||||||
|
githubBranch: draft.githubBranch.trim() || "main",
|
||||||
|
siteRoot: draft.siteRoot.trim() || "docs",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function addMember() {
|
||||||
|
if (!memberDraft.email.trim()) return;
|
||||||
|
emit("addMember", {
|
||||||
|
email: memberDraft.email.trim(),
|
||||||
|
role: memberDraft.role,
|
||||||
|
});
|
||||||
|
memberDraft.email = "";
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="panel is-visible">
|
||||||
|
<div class="project-layout">
|
||||||
|
<section class="management-card">
|
||||||
|
<div class="section-head">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">projects</p>
|
||||||
|
<h3>项目列表</h3>
|
||||||
|
</div>
|
||||||
|
<button class="secondary-button" type="button" :disabled="state === 'loading'" @click="$emit('refreshProjects')">
|
||||||
|
{{ state === "loading" ? "刷新中" : "刷新" }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="project-list">
|
||||||
|
<button
|
||||||
|
v-for="project in projects"
|
||||||
|
:key="project.id"
|
||||||
|
class="project-item"
|
||||||
|
:class="{ 'is-active': project.id === activeProjectId }"
|
||||||
|
type="button"
|
||||||
|
@click="$emit('selectProject', project)"
|
||||||
|
>
|
||||||
|
<strong>{{ project.name }}</strong>
|
||||||
|
<span>{{ project.githubOwner && project.githubRepo ? `${project.githubOwner}/${project.githubRepo}` : "未绑定仓库" }}</span>
|
||||||
|
</button>
|
||||||
|
<p v-if="!projects.length" class="helper-text">暂无项目。</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="management-card">
|
||||||
|
<p class="eyebrow">details</p>
|
||||||
|
<h3>项目配置</h3>
|
||||||
|
<div class="form-grid">
|
||||||
|
<label class="wide-field">
|
||||||
|
项目名称
|
||||||
|
<input v-model="draft.name" placeholder="文档站点" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
GitHub Owner
|
||||||
|
<input v-model="draft.githubOwner" placeholder="octocat" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
GitHub Repo
|
||||||
|
<input v-model="draft.githubRepo" placeholder="docs-site" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Branch
|
||||||
|
<input v-model="draft.githubBranch" placeholder="main" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
站点目录
|
||||||
|
<input v-model="draft.siteRoot" placeholder="docs" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<p class="helper-text" :class="{ 'is-error': state === 'error' }">{{ message || "项目用于保存仓库、分支和站点目录。" }}</p>
|
||||||
|
<p v-if="!canManageActiveProject && activeProjectId" class="helper-text">
|
||||||
|
当前账号可以查看此项目,只有项目 Owner、项目 Admin 或系统管理员可以修改项目配置。
|
||||||
|
</p>
|
||||||
|
<div class="connect-actions">
|
||||||
|
<button class="primary-button" type="button" :disabled="state === 'saving' || !draft.name.trim()" @click="$emit('createProject', payload())">
|
||||||
|
新建项目
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="secondary-button"
|
||||||
|
type="button"
|
||||||
|
:disabled="state === 'saving' || !draft.id || !draft.name.trim() || !canManageActiveProject"
|
||||||
|
@click="$emit('updateProject', { id: draft.id, ...payload() })"
|
||||||
|
>
|
||||||
|
保存项目
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="management-card wide-card">
|
||||||
|
<p class="eyebrow">members</p>
|
||||||
|
<h3>项目成员</h3>
|
||||||
|
<div class="member-editor">
|
||||||
|
<label>
|
||||||
|
用户邮箱
|
||||||
|
<input v-model="memberDraft.email" placeholder="user@example.com" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
角色
|
||||||
|
<select v-model="memberDraft.role">
|
||||||
|
<option v-for="role in memberRoles" :key="role.value" :value="role.value">
|
||||||
|
{{ role.label }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<button class="primary-button" type="button" :disabled="!activeProjectId || !memberDraft.email.trim() || !canManageActiveProject" @click="addMember">
|
||||||
|
添加成员
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="member-list">
|
||||||
|
<div v-for="member in members" :key="member.id" class="member-row">
|
||||||
|
<div class="user-identity">
|
||||||
|
<span class="user-avatar">{{ member.name.slice(0, 1).toUpperCase() }}</span>
|
||||||
|
<div>
|
||||||
|
<strong>{{ member.name }}</strong>
|
||||||
|
<small>{{ member.email || "未绑定邮箱" }}</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<select
|
||||||
|
:disabled="!canManageActiveProject"
|
||||||
|
:value="member.role"
|
||||||
|
@change="$emit('updateMember', { id: member.id, role: ($event.target as HTMLSelectElement).value })"
|
||||||
|
>
|
||||||
|
<option v-for="role in memberRoles" :key="role.value" :value="role.value">
|
||||||
|
{{ role.label }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<button class="ghost-button" type="button" :disabled="!canManageActiveProject" @click="$emit('removeMember', member.id)">
|
||||||
|
移除
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p v-if="!members.length" class="helper-text">暂无项目成员。</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
@@ -5,10 +5,13 @@ import type { SystemSetting } from "../types";
|
|||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
settings: SystemSetting[];
|
settings: SystemSetting[];
|
||||||
saveState: "idle" | "saving" | "saved" | "error";
|
saveState: "idle" | "saving" | "saved" | "error";
|
||||||
|
smtpState: "idle" | "testing" | "success" | "error";
|
||||||
|
smtpMessage: string;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
defineEmits<{
|
defineEmits<{
|
||||||
saveSettings: [settings: SystemSetting[]];
|
saveSettings: [settings: SystemSetting[]];
|
||||||
|
testSmtp: [];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const draft = reactive<Record<string, SystemSetting>>({});
|
const draft = reactive<Record<string, SystemSetting>>({});
|
||||||
@@ -126,6 +129,14 @@ function isBooleanKey(key: string) {
|
|||||||
:type="getSetting(key).encrypted ? 'password' : 'text'"
|
:type="getSetting(key).encrypted ? 'password' : 'text'"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
<template v-if="group.eyebrow === 'mail'">
|
||||||
|
<p class="helper-text" :class="{ 'is-error': smtpState === 'error', 'is-success': smtpState === 'success' }">
|
||||||
|
{{ smtpMessage || "保存 SMTP 设置后,可以测试服务器连通性。" }}
|
||||||
|
</p>
|
||||||
|
<button class="secondary-button" type="button" :disabled="smtpState === 'testing'" @click="$emit('testSmtp')">
|
||||||
|
{{ smtpState === "testing" ? "测试中" : "测试 SMTP" }}
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -0,0 +1,150 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { reactive } from "vue";
|
||||||
|
import type { CmsUser } from "../types";
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
currentUser: CmsUser;
|
||||||
|
users: CmsUser[];
|
||||||
|
loadState: "idle" | "loading" | "error";
|
||||||
|
message: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
createUser: [payload: { email: string; name: string; password: string; role: string }];
|
||||||
|
refreshUsers: [];
|
||||||
|
updateUser: [payload: { id: number; role?: string; status?: string }];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const roleOptions = [
|
||||||
|
{ label: "系统管理员", value: "system_admin" },
|
||||||
|
{ label: "管理员", value: "admin" },
|
||||||
|
{ label: "编辑者", value: "editor" },
|
||||||
|
{ label: "查看者", value: "viewer" },
|
||||||
|
{ label: "普通用户", value: "user" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const createDraft = reactive({
|
||||||
|
email: "",
|
||||||
|
name: "",
|
||||||
|
password: "",
|
||||||
|
role: "user",
|
||||||
|
});
|
||||||
|
|
||||||
|
function submitCreateUser() {
|
||||||
|
if (!createDraft.email.trim() || !createDraft.name.trim() || createDraft.password.length < 8) return;
|
||||||
|
|
||||||
|
emit("createUser", {
|
||||||
|
email: createDraft.email.trim(),
|
||||||
|
name: createDraft.name.trim(),
|
||||||
|
password: createDraft.password,
|
||||||
|
role: createDraft.role,
|
||||||
|
});
|
||||||
|
|
||||||
|
createDraft.email = "";
|
||||||
|
createDraft.name = "";
|
||||||
|
createDraft.password = "";
|
||||||
|
createDraft.role = "user";
|
||||||
|
}
|
||||||
|
|
||||||
|
function roleLabel(role: string) {
|
||||||
|
return roleOptions.find((item) => item.value === role)?.label || role;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(value?: string) {
|
||||||
|
if (!value) return "-";
|
||||||
|
return new Date(value).toLocaleString();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="panel is-visible">
|
||||||
|
<div class="settings-toolbar">
|
||||||
|
<p class="save-status" :class="{ 'is-error': loadState === 'error' }">
|
||||||
|
{{ message || "管理后台用户、角色和账号状态" }}
|
||||||
|
</p>
|
||||||
|
<button class="secondary-button" type="button" :disabled="loadState === 'loading'" @click="$emit('refreshUsers')">
|
||||||
|
{{ loadState === "loading" ? "刷新中" : "刷新用户" }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="management-card wide-card user-create-card">
|
||||||
|
<p class="eyebrow">create</p>
|
||||||
|
<h3>创建用户</h3>
|
||||||
|
<div class="user-create-grid">
|
||||||
|
<label>
|
||||||
|
邮箱
|
||||||
|
<input v-model="createDraft.email" type="email" placeholder="user@example.com" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
昵称
|
||||||
|
<input v-model="createDraft.name" placeholder="编辑成员" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
初始密码
|
||||||
|
<input v-model="createDraft.password" type="password" placeholder="至少 8 位" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
角色
|
||||||
|
<select v-model="createDraft.role">
|
||||||
|
<option v-for="role in roleOptions" :key="role.value" :value="role.value">
|
||||||
|
{{ role.label }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
class="primary-button"
|
||||||
|
type="button"
|
||||||
|
:disabled="loadState === 'loading' || !createDraft.email.trim() || !createDraft.name.trim() || createDraft.password.length < 8"
|
||||||
|
@click="submitCreateUser"
|
||||||
|
>
|
||||||
|
创建用户
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="management-card wide-card">
|
||||||
|
<p class="eyebrow">users</p>
|
||||||
|
<h3>用户与权限</h3>
|
||||||
|
<div class="user-table">
|
||||||
|
<div class="user-table-head">
|
||||||
|
<span>用户</span>
|
||||||
|
<span>角色</span>
|
||||||
|
<span>状态</span>
|
||||||
|
<span>最后登录</span>
|
||||||
|
<span>操作</span>
|
||||||
|
</div>
|
||||||
|
<div v-for="user in users" :key="user.id" class="user-table-row">
|
||||||
|
<div class="user-identity">
|
||||||
|
<span class="user-avatar">{{ user.name.slice(0, 1).toUpperCase() }}</span>
|
||||||
|
<div>
|
||||||
|
<strong>{{ user.name }}</strong>
|
||||||
|
<small>{{ user.email || "未绑定邮箱" }}</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<select
|
||||||
|
:disabled="user.id === currentUser.id && user.role === 'system_admin'"
|
||||||
|
:value="user.role"
|
||||||
|
@change="$emit('updateUser', { id: user.id, role: ($event.target as HTMLSelectElement).value })"
|
||||||
|
>
|
||||||
|
<option v-for="role in roleOptions" :key="role.value" :value="role.value">
|
||||||
|
{{ role.label }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<span :class="['status-pill', user.status === 'disabled' ? 'is-disabled' : 'is-active']">
|
||||||
|
{{ user.status === "disabled" ? "已禁用" : "正常" }}
|
||||||
|
</span>
|
||||||
|
<span>{{ formatDate(user.lastLoginAt) }}</span>
|
||||||
|
<button
|
||||||
|
class="ghost-button"
|
||||||
|
type="button"
|
||||||
|
:disabled="user.id === currentUser.id"
|
||||||
|
@click="$emit('updateUser', { id: user.id, status: user.status === 'disabled' ? 'active' : 'disabled' })"
|
||||||
|
>
|
||||||
|
{{ user.status === "disabled" ? "启用" : "禁用" }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p v-if="!users.length" class="helper-text">暂无用户。</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
+181
-1
@@ -498,6 +498,16 @@ label {
|
|||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.helper-text.is-error {
|
||||||
|
color: #a64038;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.helper-text.is-success {
|
||||||
|
color: var(--accent-strong);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
.empty-state {
|
.empty-state {
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
@@ -543,6 +553,8 @@ label {
|
|||||||
.two-column,
|
.two-column,
|
||||||
.publish-layout,
|
.publish-layout,
|
||||||
.account-layout,
|
.account-layout,
|
||||||
|
.account-grid,
|
||||||
|
.project-layout,
|
||||||
.connect-layout {
|
.connect-layout {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
@@ -563,6 +575,10 @@ label {
|
|||||||
margin-top: 14px;
|
margin-top: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.single-column {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
.wide-field {
|
.wide-field {
|
||||||
grid-column: 1 / -1;
|
grid-column: 1 / -1;
|
||||||
}
|
}
|
||||||
@@ -614,10 +630,163 @@ label {
|
|||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.profile-card {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 46px minmax(0, 1fr);
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
margin-top: 14px;
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--surface-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-card strong,
|
||||||
|
.profile-card small {
|
||||||
|
display: block;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-card small {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-item {
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--surface-soft);
|
||||||
|
padding: 12px;
|
||||||
|
color: var(--ink);
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-item.is-active {
|
||||||
|
border-color: #b9d8d4;
|
||||||
|
background: var(--accent-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-item span {
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 13px;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-editor {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(220px, 1fr) minmax(140px, 0.4fr) auto;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: end;
|
||||||
|
margin-top: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(220px, 1fr) minmax(140px, 0.4fr) auto;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
padding: 10px 0;
|
||||||
|
}
|
||||||
|
|
||||||
.account-logout {
|
.account-logout {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.user-table {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-create-card {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-create-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(180px, 1fr) minmax(160px, 0.8fr) minmax(160px, 0.8fr) minmax(140px, 0.7fr) auto;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: end;
|
||||||
|
margin-top: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-table-head,
|
||||||
|
.user-table-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(220px, 1.6fr) minmax(150px, 0.8fr) minmax(90px, 0.5fr) minmax(160px, 0.9fr) minmax(90px, 0.4fr);
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-table-head {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 800;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-table-row {
|
||||||
|
padding: 10px 0;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-identity {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 38px minmax(0, 1fr);
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-identity strong,
|
||||||
|
.user-identity small {
|
||||||
|
display: block;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-identity small {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-pill {
|
||||||
|
display: inline-flex;
|
||||||
|
width: fit-content;
|
||||||
|
min-height: 28px;
|
||||||
|
align-items: center;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0 10px;
|
||||||
|
background: var(--accent-soft);
|
||||||
|
color: var(--accent-strong);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-pill.is-disabled {
|
||||||
|
background: #f4e4e2;
|
||||||
|
color: #9a3d36;
|
||||||
|
}
|
||||||
|
|
||||||
.source-summary {
|
.source-summary {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
@@ -747,12 +916,23 @@ label {
|
|||||||
.two-column,
|
.two-column,
|
||||||
.publish-layout,
|
.publish-layout,
|
||||||
.account-layout,
|
.account-layout,
|
||||||
|
.account-grid,
|
||||||
|
.project-layout,
|
||||||
.connect-layout,
|
.connect-layout,
|
||||||
.settings-grid,
|
.settings-grid,
|
||||||
.frontmatter-grid,
|
.frontmatter-grid,
|
||||||
.form-grid {
|
.form-grid,
|
||||||
|
.user-create-grid,
|
||||||
|
.user-table-head,
|
||||||
|
.user-table-row,
|
||||||
|
.member-editor,
|
||||||
|
.member-row {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.user-table-head {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
|
|||||||
+38
-2
@@ -1,4 +1,4 @@
|
|||||||
export type PanelKey = "account" | "admin" | "connect" | "content" | "structure" | "config" | "publish";
|
export type PanelKey = "account" | "projects" | "users" | "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";
|
||||||
|
|
||||||
@@ -57,7 +57,19 @@ export interface CmsUser {
|
|||||||
email?: string;
|
email?: string;
|
||||||
name: string;
|
name: string;
|
||||||
avatarUrl?: string;
|
avatarUrl?: string;
|
||||||
role: "system_admin" | "user" | string;
|
role: "system_admin" | "admin" | "editor" | "viewer" | "user" | string;
|
||||||
|
status?: "active" | "disabled" | string;
|
||||||
|
emailVerified?: boolean;
|
||||||
|
createdAt?: string;
|
||||||
|
updatedAt?: string;
|
||||||
|
lastLoginAt?: string;
|
||||||
|
permissions?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthOptions {
|
||||||
|
allowEmailLogin: boolean;
|
||||||
|
allowGitHubLogin: boolean;
|
||||||
|
allowRegistration: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GitHubRepository {
|
export interface GitHubRepository {
|
||||||
@@ -70,6 +82,30 @@ export interface GitHubRepository {
|
|||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CmsProject {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
githubOwner?: string;
|
||||||
|
githubRepo?: string;
|
||||||
|
githubBranch: string;
|
||||||
|
siteRoot: string;
|
||||||
|
ownerUserId: number;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProjectMember {
|
||||||
|
id: number;
|
||||||
|
projectId: number;
|
||||||
|
userId: number;
|
||||||
|
role: "owner" | "admin" | "editor" | "viewer" | string;
|
||||||
|
name: string;
|
||||||
|
email?: string;
|
||||||
|
avatarUrl?: string;
|
||||||
|
status?: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface SystemSetting {
|
export interface SystemSetting {
|
||||||
key: string;
|
key: string;
|
||||||
value: string;
|
value: string;
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import type { CmsUser } from "../types";
|
||||||
|
|
||||||
|
export const permissions = {
|
||||||
|
settingsManage: "settings.manage",
|
||||||
|
usersManage: "users.manage",
|
||||||
|
projectsManage: "projects.manage",
|
||||||
|
contentEdit: "content.edit",
|
||||||
|
contentPublish: "content.publish",
|
||||||
|
contentView: "content.view",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type Permission = (typeof permissions)[keyof typeof permissions];
|
||||||
|
|
||||||
|
const rolePermissions: Record<string, Permission[]> = {
|
||||||
|
system_admin: Object.values(permissions),
|
||||||
|
admin: [
|
||||||
|
permissions.usersManage,
|
||||||
|
permissions.projectsManage,
|
||||||
|
permissions.contentEdit,
|
||||||
|
permissions.contentPublish,
|
||||||
|
permissions.contentView,
|
||||||
|
],
|
||||||
|
editor: [permissions.contentEdit, permissions.contentPublish, permissions.contentView],
|
||||||
|
viewer: [permissions.contentView],
|
||||||
|
user: [permissions.contentView],
|
||||||
|
};
|
||||||
|
|
||||||
|
export function hasPermission(user: CmsUser | null | undefined, permission: Permission) {
|
||||||
|
if (!user) return false;
|
||||||
|
return Boolean(user.permissions?.includes(permission) || rolePermissions[user.role]?.includes(permission));
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user