功能:完善用户与项目管理框架

This commit is contained in:
Coldsmile_7
2026-06-13 00:50:11 +08:00
parent db6d6de74a
commit 49c86bc14a
25 changed files with 3571 additions and 192 deletions
+1192 -54
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -12,6 +12,7 @@
"preview": "vite preview --host 0.0.0.0"
},
"dependencies": {
"vitepress": "^1.6.4",
"vue": "^3.5.0"
},
"devDependencies": {
+179 -3
View File
@@ -1,9 +1,10 @@
import { db } from "./db.mjs";
import { json, readBody } from "./http.mjs";
import { clearSessionCookie, createSession, deleteSession, getSession, parseCookies, sessionCookie } from "./session.mjs";
import { verifyPassword } from "./security.mjs";
import { hashPassword, verifyPassword } from "./security.mjs";
import { hasPermission, publicPermissions } from "./permissions.mjs";
function publicUser(user) {
export function publicUser(user) {
if (!user) return null;
return {
@@ -12,6 +13,12 @@ function publicUser(user) {
name: user.name,
avatarUrl: user.avatar_url,
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;
}
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) {
const { session } = getRequestSession(request);
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) {
if (!settingEnabled("auth.allow_email_login", true)) {
json(response, 403, { error: "Email login is disabled" });
return;
}
const body = JSON.parse(await readBody(request) || "{}");
const email = String(body.email || "").trim().toLowerCase();
const password = String(body.password || "");
@@ -55,7 +99,15 @@ async function handleLogin(request, response) {
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({
user: sessionUser,
});
@@ -63,12 +115,116 @@ async function handleLogin(request, response) {
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) {
const { sessionId } = getRequestSession(request);
deleteSession(sessionId);
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) {
try {
if (url.pathname === "/api/auth/me" && request.method === "GET") {
@@ -76,16 +232,36 @@ export async function handleAuthApi(request, response, url) {
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") {
await handleLogin(request, response);
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") {
await handleLogout(request, response);
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;
} catch (error) {
json(response, 500, {
+10
View File
@@ -67,6 +67,16 @@ export function migrate() {
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 = "") {
+146 -72
View File
@@ -1,7 +1,10 @@
import { randomBytes } from "node:crypto";
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { spawn } from "node:child_process";
import { request as httpRequest } from "node:http";
import { request as httpsRequest } from "node:https";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { db, getSetting } from "./db.mjs";
import { json, readBody, redirect } from "./http.mjs";
import { getRequestSession } from "./auth.mjs";
@@ -37,7 +40,7 @@ function requestTextWithPowerShell(targetUrl, init = {}) {
).toString("base64");
const script = `
$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 = @{}
if ($payload.headers) {
foreach ($property in $payload.headers.PSObject.Properties) {
@@ -81,7 +84,11 @@ try {
$result | ConvertTo-Json -Compress -Depth 5
`;
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,
});
const stdout = [];
@@ -281,6 +288,11 @@ function publicCmsUser(user) {
name: user.name,
avatarUrl: user.avatar_url,
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 { session: currentSession } = getRequestSession(request);
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({
user: publicCmsUser(cmsUser),
user: publicCmsUser(nextCmsUser),
github: {
accessToken: tokenPayload.access_token,
login: user.login,
@@ -489,6 +507,10 @@ function normalizeSiteRoot(siteRoot = "") {
return siteRoot.trim().replace(/^\/+|\/+$/g, "");
}
function getVitePressSiteRoot(siteRoot = "") {
return normalizeSiteRoot(siteRoot) || "docs";
}
function toRepoPath(siteRoot, path) {
const normalizedRoot = normalizeSiteRoot(siteRoot);
return normalizedRoot ? `${normalizedRoot}/${path}` : path;
@@ -508,10 +530,11 @@ function encodeBase64Content(content) {
}
function packageJsonTemplate(siteRoot) {
const root = normalizeSiteRoot(siteRoot) || ".";
const root = getVitePressSiteRoot(siteRoot);
return `${JSON.stringify(
{
type: "module",
scripts: {
"docs:dev": `vitepress dev ${root}`,
"docs:build": `vitepress build ${root}`,
@@ -527,43 +550,100 @@ function packageJsonTemplate(siteRoot) {
`;
}
function siteConfigTemplate() {
return `import { defineConfig } from "vitepress";
function mergePackageJsonContent(existingSource, siteRoot) {
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",
description: "Created by VitePress-CMS",
lastUpdated: true,
themeConfig: {
nav: [
{ text: "首页", link: "/" },
{ text: "指南", link: "/guide/getting-started" },
],
sidebar: {
"/guide/": [
{
text: "指南",
items: [
{ text: "快速开始", link: "/guide/getting-started" },
],
},
],
},
search: {
provider: "local",
},
footer: {
message: "Powered by VitePress-CMS",
copyright: "Copyright 2026",
},
},
theme: "default theme",
useTs: true,
injectNpmScripts: true,
});
`;
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) {
const root = normalizeSiteRoot(siteRoot);
const outputDir = root ? `${root}/.vitepress/dist` : ".vitepress/dist";
const root = getVitePressSiteRoot(siteRoot);
const outputDir = `${root}/.vitepress/dist`;
return `name: Deploy VitePress site to Pages
@@ -579,7 +659,7 @@ permissions:
concurrency:
group: pages
cancel-in-progress: false
cancel-in-progress: true
jobs:
build:
@@ -603,44 +683,16 @@ jobs:
runs-on: ubuntu-latest
steps:
- 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 [
{
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 管理内容
---
# 快速开始
这里是你的第一篇指南文档。
`,
},
...files,
{
path: ".github/workflows/deploy.yml",
content: deployWorkflowTemplate(siteRoot, branch),
@@ -759,7 +811,7 @@ function getSiteQuery(url) {
const owner = url.searchParams.get("owner");
const repo = url.searchParams.get("repo");
const branch = url.searchParams.get("branch") || "main";
const siteRoot = url.searchParams.get("siteRoot") || "";
const siteRoot = getVitePressSiteRoot(url.searchParams.get("siteRoot") || "");
if (!owner || !repo) {
throw new Error("owner and repo are required");
@@ -789,6 +841,28 @@ async function createRepoPathFile(session, site, file) {
try {
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/")) {
const encodedPath = file.path.split("/").map(encodeURIComponent).join("/");
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),
repo: String(site.repo),
branch: String(site.branch || "main"),
siteRoot: normalizeSiteRoot(String(site.siteRoot || "")),
siteRoot: getVitePressSiteRoot(String(site.siteRoot || "")),
};
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));
}
+10
View File
@@ -6,6 +6,8 @@ import { bootstrap } from "./bootstrap.mjs";
import { handleAuthApi } from "./auth.mjs";
import { handleGithubApi } from "./github.mjs";
import { handleSettingsApi } from "./settings.mjs";
import { handleUsersApi } from "./users.mjs";
import { handleProjectsApi } from "./projects.mjs";
const isProduction = process.env.NODE_ENV === "production";
const port = Number(process.env.PORT || 5173);
@@ -64,6 +66,14 @@ const server = createServer(async (request, response) => {
return;
}
if (await handleUsersApi(request, response, url)) {
return;
}
if (await handleProjectsApi(request, response, url)) {
return;
}
if (await handleGithubApi(request, response, url)) {
return;
}
+31
View File
@@ -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] ?? [];
}
+343
View File
@@ -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
View File
@@ -1,18 +1,64 @@
import net from "node:net";
import tls from "node:tls";
import { db, setSetting } from "./db.mjs";
import { requireCmsUser } from "./auth.mjs";
import { requirePermission } from "./auth.mjs";
import { json, readBody } from "./http.mjs";
import { permissions } from "./permissions.mjs";
function requireAdmin(request, response) {
const user = requireCmsUser(request, response);
if (!user) return undefined;
if (user.role !== "system_admin") {
json(response, 403, { error: "System admin permission is required" });
return undefined;
function getSettingValue(key, fallback = "") {
return db.prepare("SELECT value FROM system_settings WHERE key = ?").get(key)?.value ?? fallback;
}
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) {
@@ -25,7 +71,7 @@ function publicSetting(row) {
}
async function handleGetSettings(request, response) {
if (!requireAdmin(request, response)) return;
if (!requirePermission(request, response, permissions.settingsManage)) return;
const rows = db
.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) {
if (!requireAdmin(request, response)) return;
if (!requirePermission(request, response, permissions.settingsManage)) return;
const body = JSON.parse(await readBody(request) || "{}");
const settings = Array.isArray(body.settings) ? body.settings : [];
@@ -52,6 +98,21 @@ async function handleSaveSettings(request, response) {
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) {
try {
if (url.pathname === "/api/admin/settings" && request.method === "GET") {
@@ -64,6 +125,11 @@ export async function handleSettingsApi(request, response, url) {
return true;
}
if (url.pathname === "/api/admin/settings/test-smtp" && request.method === "POST") {
await handleTestSmtp(request, response);
return true;
}
return false;
} catch (error) {
json(response, 500, {
+143
View File
@@ -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
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from "vue";
import { loadCurrentUser, loginWithEmail, logoutCms } from "./api/auth";
import { loadSystemSettings, saveSystemSettings } from "./api/adminSettings";
import { changePassword, loadAuthOptions, loadCurrentUser, loginWithEmail, logoutCms, registerWithEmail, updateProfile } from "./api/auth";
import { loadSystemSettings, saveSystemSettings, testSmtpSettings } from "./api/adminSettings";
import {
createGitHubRepository,
loadGitHubRepositories,
@@ -29,23 +29,40 @@ import ConnectPanel from "./components/ConnectPanel.vue";
import ContentPanel from "./components/ContentPanel.vue";
import LoginView from "./components/LoginView.vue";
import PublishPanel from "./components/PublishPanel.vue";
import ProjectPanel from "./components/ProjectPanel.vue";
import StructurePanel from "./components/StructurePanel.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 type {
CmsUser,
CmsProject,
AuthOptions,
DataSource,
DocPage,
GitHubConnection,
GitHubRepository,
GitHubUser,
PanelKey,
ProjectMember,
SystemSetting,
} from "./types";
const panels = computed<Array<{ key: PanelKey; label: string; icon: string }>>(() => {
const basePanels: Array<{ key: PanelKey; label: string; icon: string }> = [
{ key: "account", label: "用户中心", icon: "U" },
{ key: "projects", label: "项目管理", icon: "J" },
{ key: "connect", label: "仓库连接", icon: "G" },
{ key: "content", label: "页面管理", icon: "P" },
{ key: "structure", label: "站点结构", icon: "S" },
@@ -53,7 +70,11 @@ const panels = computed<Array<{ key: PanelKey; label: string; icon: string }>>((
{ 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" });
}
@@ -91,17 +112,43 @@ const isInitializingSite = ref(false);
const pageSaveState = ref<"idle" | "saving" | "saved" | "error">("idle");
const settingsSaveState = ref<"idle" | "saving" | "saved" | "error">("idle");
const systemSettingsSaveState = ref<"idle" | "saving" | "saved" | "error">("idle");
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 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 isAuthLoading = ref(true);
const loginError = ref("");
const connectionMessage = 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 activePage = computed(() => pages.find((page) => page.id === activePageId.value) ?? pages[0]);
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(() => {
if (dataSource.value === "github" && hasGitHubConnection.value) {
return `${githubConnection.owner}/${githubConnection.repo}`;
@@ -122,7 +169,7 @@ function normalizeConnection(connection: GitHubConnection): GitHubConnection {
owner: connection.owner.trim(),
repo: connection.repo.trim(),
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() {
if (currentUser.value?.role !== "system_admin") return;
if (!hasPermission(currentUser.value, permissions.settingsManage)) return;
try {
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() {
await Promise.all([loadPages(), loadSettings()]);
}
@@ -227,6 +458,7 @@ async function refreshRepositories() {
async function loadAuthState() {
try {
authOptions.value = await loadAuthOptions();
currentUser.value = await loadCurrentUser();
githubUser.value = await loadGitHubUser();
@@ -248,7 +480,7 @@ async function handleEmailLogin(payload: { email: string; password: string }) {
try {
currentUser.value = await loginWithEmail(payload.email, payload.password);
await loadAuthState();
await Promise.all([reloadSite(), loadAdminSettings()]);
await Promise.all([reloadSite(), loadAdminSettings(), loadUsers(), loadProjectList()]);
} catch (error) {
loginError.value = error instanceof Error ? error.message : "登录失败";
} 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() {
await logoutCms();
currentUser.value = null;
githubUser.value = null;
repositories.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) {
@@ -295,6 +572,19 @@ async function connectGithub(connection: GitHubConnection) {
Object.assign(siteSettings, nextSettings.settings);
settingsSha.value = nextSettings.sha;
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}`;
activityItems.value = [`已连接 GitHub 仓库 ${githubConnection.owner}/${githubConnection.repo}`, ...activityItems.value];
} catch (error) {
@@ -340,7 +630,7 @@ async function createRepository(payload: { name: string; private: boolean }) {
owner: repo.owner,
repo: repo.name,
branch: repo.defaultBranch || "main",
siteRoot: "",
siteRoot: "docs",
});
repositoryMessage.value = `已创建 GitHub 仓库 ${repo.fullName}`;
isCreatingRepository.value = false;
@@ -459,6 +749,7 @@ async function saveAdminSettings(settings: SystemSetting[]) {
try {
await saveSystemSettings(settings);
systemSettingsSaveState.value = "saved";
authOptions.value = await loadAuthOptions();
await loadAdminSettings();
activityItems.value = ["已保存系统设置", ...activityItems.value];
} 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() {
activityItems.value = ["已创建 commit,并触发部署流程", ...activityItems.value];
}
@@ -479,7 +784,7 @@ onMounted(async () => {
await loadAuthState();
if (currentUser.value) {
await Promise.all([reloadSite(), loadAdminSettings()]);
await Promise.all([reloadSite(), loadAdminSettings(), loadUsers(), loadProjectList()]);
}
} finally {
isAuthLoading.value = false;
@@ -490,9 +795,11 @@ onMounted(async () => {
<template>
<LoginView
v-if="!currentUser"
:auth-options="authOptions"
:error-message="loginError"
:is-loading="isAuthLoading"
@email-login="handleEmailLogin"
@email-register="handleEmailRegister"
@github-login="startGitHubLogin"
/>
@@ -524,17 +831,51 @@ onMounted(async () => {
<AccountPanel
v-if="activePanel === 'account'"
:message="accountMessage"
:current-user="currentUser"
:github-user="githubUser"
:password-state="passwordSaveState"
:profile-state="profileSaveState"
@change-password="updatePassword"
@github-login="startGitHubLogin"
@github-logout="signOutGithub"
@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
v-else-if="activePanel === 'admin'"
:save-state="systemSettingsSaveState"
:settings="systemSettings"
:smtp-message="smtpMessage"
:smtp-state="smtpTestState"
@save-settings="saveAdminSettings"
@test-smtp="testSmtp"
/>
<ConnectPanel
v-else-if="activePanel === 'connect'"
+22 -2
View File
@@ -4,11 +4,16 @@ interface SettingsResponse {
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() {
const response = await fetch("/api/admin/settings");
if (!response.ok) {
throw new Error(`读取系统设置失败:${response.status}`);
throw new Error(await readError(response, "读取系统设置失败"));
}
const data = (await response.json()) as SettingsResponse;
@@ -25,6 +30,21 @@ export async function saveSystemSettings(settings: SystemSetting[]) {
});
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;
};
}
+70
View File
@@ -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
View File
@@ -1,24 +1,44 @@
import type { CmsUser } from "../types";
import type { AuthOptions, CmsUser } from "../types";
interface MeResponse {
user: CmsUser | null;
}
interface LoginResponse {
interface UserResponse {
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() {
const response = await fetch("/api/auth/me");
if (!response.ok) {
throw new Error(`读取当前用户失败:${response.status}`);
throw new Error(await readError(response, "读取当前用户失败"));
}
const data = (await response.json()) as MeResponse;
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) {
const response = await fetch("/api/auth/login", {
method: "POST",
@@ -29,19 +49,70 @@ export async function loginWithEmail(email: string, password: string) {
});
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;
}
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() {
const response = await fetch("/api/auth/logout", {
method: "POST",
});
if (!response.ok) {
throw new Error(`退出登录失败:${response.status}`);
throw new Error(await readError(response, "退出登录失败"));
}
}
+4 -4
View File
@@ -47,7 +47,7 @@ function siteParams(connection: GitHubConnection) {
owner: connection.owner,
repo: connection.repo,
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,
repo: connection.repo,
branch: connection.branch,
siteRoot: connection.siteRoot,
siteRoot: normalizeSiteRoot(connection.siteRoot) || "docs",
},
page,
}),
@@ -237,7 +237,7 @@ export async function initializeGitHubSite(connection: GitHubConnection) {
owner: connection.owner,
repo: connection.repo,
branch: connection.branch,
siteRoot: connection.siteRoot,
siteRoot: normalizeSiteRoot(connection.siteRoot) || "docs",
},
}),
});
@@ -265,7 +265,7 @@ export async function saveGitHubSettings(
owner: connection.owner,
repo: connection.repo,
branch: connection.branch,
siteRoot: connection.siteRoot,
siteRoot: normalizeSiteRoot(connection.siteRoot) || "docs",
},
settings,
sha,
+136
View File
@@ -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
View File
@@ -1,40 +1,144 @@
<script setup lang="ts">
import { reactive, watch } from "vue";
import type { CmsUser, GitHubUser } from "../types";
defineProps<{
const props = defineProps<{
currentUser: CmsUser;
githubUser: GitHubUser | null;
profileState: "idle" | "saving" | "saved" | "error";
passwordState: "idle" | "saving" | "saved" | "error";
message: string;
}>();
defineEmits<{
const emit = defineEmits<{
githubLogin: [];
githubLogout: [];
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>
<template>
<section class="panel is-visible">
<div class="account-layout">
<div class="account-grid">
<section class="management-card">
<p class="eyebrow">account</p>
<h3>用户中心</h3>
<p class="eyebrow">profile</p>
<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">
<div>
<dt>名称</dt>
<dd>{{ currentUser.name }}</dd>
</div>
<div>
<dt>邮箱</dt>
<dd>{{ currentUser.email || "-" }}</dd>
</div>
<div>
<dt>角色</dt>
<dd>{{ currentUser.role }}</dd>
</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>
<button class="ghost-button account-logout" type="button" @click="$emit('logout')">
退出 CMS 登录
</section>
<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>
</section>
@@ -51,9 +155,7 @@ defineEmits<{
</div>
<div v-else>
<p class="helper-text">绑定 GitHub 可以读取仓库列表选择站点仓库并提交内容变更</p>
<button class="primary-button" type="button" @click="$emit('githubLogin')">
绑定 GitHub
</button>
<button class="primary-button" type="button" @click="$emit('githubLogin')">绑定 GitHub</button>
</div>
</section>
</div>
+2 -2
View File
@@ -123,7 +123,7 @@ function initializeRepository() {
</label>
<label>
站点目录
<input v-model="draft.siteRoot" placeholder="docs 或留空" />
<input v-model="draft.siteRoot" placeholder="默认 docs" />
</label>
</div>
@@ -185,7 +185,7 @@ function initializeRepository() {
</label>
<label class="wide-field">
站点目录
<input v-model="draft.siteRoot" placeholder="docs 或留空" />
<input v-model="draft.siteRoot" placeholder="默认 docs" />
</label>
</div>
+46 -6
View File
@@ -1,23 +1,45 @@
<script setup lang="ts">
import { reactive } from "vue";
import type { AuthOptions } from "../types";
defineProps<{
const props = defineProps<{
authOptions: AuthOptions;
errorMessage: string;
isLoading: boolean;
}>();
const emit = defineEmits<{
emailLogin: [payload: { email: string; password: string }];
emailRegister: [payload: { email: string; name: string; password: string }];
githubLogin: [];
}>();
const mode = reactive({
value: "login" as "login" | "register",
});
const form = reactive({
email: "admin@example.com",
name: "",
password: "admin123456",
});
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>
@@ -32,27 +54,45 @@ function submit() {
</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>
邮箱
<input v-model="form.email" type="email" autocomplete="username" />
</label>
<label v-if="mode.value === 'register' || (!authOptions.allowEmailLogin && authOptions.allowRegistration)">
昵称
<input v-model="form.name" autocomplete="name" />
</label>
<label>
密码
<input v-model="form.password" type="password" autocomplete="current-password" />
</label>
<button class="primary-button" type="submit" :disabled="isLoading">
{{ isLoading ? "登录中" : "邮箱登录" }}
{{ isLoading ? "处理中" : mode.value === "register" || (!authOptions.allowEmailLogin && authOptions.allowRegistration) ? "创建账号" : "邮箱登录" }}
</button>
</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>
<strong>使用 GitHub 登录</strong>
</button>
<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>
</main>
</template>
+199
View File
@@ -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>
+11
View File
@@ -5,10 +5,13 @@ import type { SystemSetting } from "../types";
const props = defineProps<{
settings: SystemSetting[];
saveState: "idle" | "saving" | "saved" | "error";
smtpState: "idle" | "testing" | "success" | "error";
smtpMessage: string;
}>();
defineEmits<{
saveSettings: [settings: SystemSetting[]];
testSmtp: [];
}>();
const draft = reactive<Record<string, SystemSetting>>({});
@@ -126,6 +129,14 @@ function isBooleanKey(key: string) {
:type="getSetting(key).encrypted ? 'password' : 'text'"
/>
</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>
</div>
</section>
+150
View File
@@ -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
View File
@@ -498,6 +498,16 @@ label {
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 {
color: var(--muted);
}
@@ -543,6 +553,8 @@ label {
.two-column,
.publish-layout,
.account-layout,
.account-grid,
.project-layout,
.connect-layout {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -563,6 +575,10 @@ label {
margin-top: 14px;
}
.single-column {
grid-template-columns: 1fr;
}
.wide-field {
grid-column: 1 / -1;
}
@@ -614,10 +630,163 @@ label {
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 {
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 {
display: grid;
gap: 12px;
@@ -747,12 +916,23 @@ label {
.two-column,
.publish-layout,
.account-layout,
.account-grid,
.project-layout,
.connect-layout,
.settings-grid,
.frontmatter-grid,
.form-grid {
.form-grid,
.user-create-grid,
.user-table-head,
.user-table-row,
.member-editor,
.member-row {
grid-template-columns: 1fr;
}
.user-table-head {
display: none;
}
}
@media (max-width: 640px) {
+38 -2
View File
@@ -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 DataSource = "local" | "github";
@@ -57,7 +57,19 @@ export interface CmsUser {
email?: string;
name: 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 {
@@ -70,6 +82,30 @@ export interface GitHubRepository {
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 {
key: string;
value: string;
+31
View File
@@ -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));
}