chore: sync local changes to Gitea
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,3 +1,4 @@
|
||||
dist/
|
||||
node_modules/
|
||||
.wrangler/
|
||||
.dev.vars
|
||||
|
||||
62
README.md
62
README.md
@@ -1,27 +1,59 @@
|
||||
# SproutBlog
|
||||
|
||||
Cloudflare Worker + D1 的极简博客。
|
||||
Cloudflare Worker + D1 + 静态资源(Vite/React SPA)。Worker **仅处理 `/api/*`**,前端构建产物输出到 `dist/`,由 `[assets]` 提供;`wrangler deploy` 一次部署 API + 前端。
|
||||
|
||||
## 功能
|
||||
|
||||
- 公共博客页
|
||||
- Markdown 渲染
|
||||
- 简单后台 CRUD
|
||||
- 手机和电脑自适应
|
||||
- 公共博客(React + React Router)
|
||||
- Markdown(marked + KaTeX + highlight.js + Mermaid,按需动态加载)
|
||||
- 简单后台 CRUD(Cookie / Basic Auth 与原先一致)
|
||||
- 评论(cwd-widget)
|
||||
- 手机与桌面适配(沿用原样式)
|
||||
|
||||
## 初始化
|
||||
|
||||
1. 创建 D1 数据库:
|
||||
`wrangler d1 create sproutblog`
|
||||
2. 把生成的 `database_id` 填到 `wrangler.toml`
|
||||
3. 应用迁移:
|
||||
`wrangler d1 migrations apply sproutblog --remote`
|
||||
4. 本地运行:
|
||||
`npm run dev`
|
||||
1. 创建 D1:`wrangler d1 create sproutblog`,把 `database_id` 写入 `wrangler.toml`。
|
||||
2. 应用迁移:`wrangler d1 migrations apply sproutblog --remote`
|
||||
3. 安装依赖:`npm install`
|
||||
|
||||
## 可选后台密码
|
||||
## 开发
|
||||
|
||||
如果要给 `/admin` 加密码,设置 `ADMIN_PASSWORD`:
|
||||
- 一体化(会先 `vite build` 再同时拉起 Worker 与前端开发服务器):
|
||||
- `npm run dev`
|
||||
- Worker:默认 `http://127.0.0.1:8787`(读取 `dist` 静态资源)
|
||||
- 前端:`http://127.0.0.1:5173`,`/api` 代理到 `8787`
|
||||
- 仅 Worker(需已有 `dist`):`npm run dev:worker`
|
||||
- 仅前端 + API 代理:`npm run dev:client`
|
||||
- 修改 React 后若要在 `8787` 上看到最新静态文件,请再执行 `npm run build`。
|
||||
|
||||
`wrangler secret put ADMIN_PASSWORD`
|
||||
## 部署
|
||||
|
||||
```bash
|
||||
npm run deploy
|
||||
```
|
||||
|
||||
等同 `npm run build && wrangler deploy`。
|
||||
|
||||
## 环境变量
|
||||
|
||||
- `SITE_TITLE` / `SITE_TITLE_EN` / `CWD_API_BASE`:可在 `wrangler.toml` 的 `[vars]` 中配置。
|
||||
- `ADMIN_PASSWORD`:生产环境建议使用 `wrangler secret put ADMIN_PASSWORD`,勿提交仓库。
|
||||
- 前端可选:`VITE_CWD_SITE_ID`(构建时注入;否则以 `/api/config` 中的 `CWD_SITE_ID` 为准)。
|
||||
|
||||
## API 说明(节选)
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| GET | `/api/config` | 站点公开配置 |
|
||||
| POST | `/api/admin/session` | 设置后台 Cookie(body: `{ token }`) |
|
||||
| GET | `/api/posts` | 列表;未登录仅返回已发布文章 |
|
||||
| GET | `/api/posts/:idOrSlug?bump=1` | 详情;`bump=1` 且匿名访问已发布文章时增加浏览量 |
|
||||
| POST/PUT/DELETE | `/api/posts` … | 写操作需鉴权(与原先一致) |
|
||||
|
||||
## 目录结构(节选)
|
||||
|
||||
- `src/worker` — Worker 入口
|
||||
- `src/api` — 路由与 HTTP 处理
|
||||
- `src/db` — D1 访问
|
||||
- `client/` — Vite + React 源码,构建至 `dist/`
|
||||
- `migrations/` — D1 SQL
|
||||
|
||||
32
client/index.html
Normal file
32
client/index.html
Normal file
@@ -0,0 +1,32 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover"
|
||||
/>
|
||||
<title>萌芽小窝</title>
|
||||
<link rel="icon" href="/favicon.ico" sizes="any" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Noto+Sans+SC:wght@400;500;600;700&family=Source+Code+Pro:wght@400;500;600&display=swap"
|
||||
/>
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://cdn.jsdelivr.net/npm/katex@0.16.45/dist/katex.min.css"
|
||||
crossorigin="anonymous"
|
||||
/>
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11/build/styles/github.min.css"
|
||||
crossorigin="anonymous"
|
||||
/>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
BIN
client/public/favicon.ico
Normal file
BIN
client/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 95 KiB |
BIN
client/public/logo.png
Normal file
BIN
client/public/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 19 KiB |
25
client/src/App.tsx
Normal file
25
client/src/App.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
|
||||
import { ConfigProvider } from "./ConfigContext";
|
||||
import { Layout } from "./Layout";
|
||||
import { Home } from "./pages/Home";
|
||||
import { PostPage } from "./pages/PostPage";
|
||||
import { AdminDashboard, AdminEditor } from "./pages/Admin";
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<ConfigProvider>
|
||||
<Routes>
|
||||
<Route path="/" element={<Layout />}>
|
||||
<Route index element={<Home />} />
|
||||
<Route path="post/:slug" element={<PostPage />} />
|
||||
<Route path="admin" element={<AdminDashboard />} />
|
||||
<Route path="admin/new" element={<AdminEditor />} />
|
||||
<Route path="admin/edit/:id" element={<AdminEditor />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</ConfigProvider>
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
51
client/src/ConfigContext.tsx
Normal file
51
client/src/ConfigContext.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
type ReactNode
|
||||
} from "react";
|
||||
import type { SiteConfig } from "./api";
|
||||
import { fetchConfig } from "./api";
|
||||
|
||||
const Ctx = createContext<SiteConfig | null>(null);
|
||||
|
||||
export function ConfigProvider({ children }: { children: ReactNode }) {
|
||||
const [cfg, setCfg] = useState<SiteConfig | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
setCfg(await fetchConfig());
|
||||
} catch {
|
||||
setCfg({
|
||||
siteTitle: "萌芽小窝",
|
||||
siteTitleEn: "SproutBlog",
|
||||
cwdApiBase: "https://cwd.api.smyhub.com"
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
const value = useMemo(() => cfg, [cfg]);
|
||||
|
||||
if (!value) {
|
||||
return (
|
||||
<main style={{ padding: 24 }}>
|
||||
<p>加载站点配置…</p>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
return <Ctx.Provider value={value}>{children}</Ctx.Provider>;
|
||||
}
|
||||
|
||||
export function useSiteConfig(): SiteConfig {
|
||||
const v = useContext(Ctx);
|
||||
if (!v) throw new Error("useSiteConfig");
|
||||
return v;
|
||||
}
|
||||
40
client/src/Layout.tsx
Normal file
40
client/src/Layout.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import { Link, Outlet } from "react-router-dom";
|
||||
import { useSiteConfig } from "./ConfigContext";
|
||||
import { AdminGate } from "./components/AdminGate";
|
||||
import { GeoFooter } from "./components/GeoFooter";
|
||||
|
||||
export function Layout() {
|
||||
const { siteTitle, siteTitleEn } = useSiteConfig();
|
||||
|
||||
return (
|
||||
<>
|
||||
<main>
|
||||
<header className="site-header">
|
||||
<div className="site-brand">
|
||||
<AdminGate>
|
||||
<img
|
||||
className="site-logo"
|
||||
src="/logo.png"
|
||||
alt=""
|
||||
width={40}
|
||||
height={40}
|
||||
decoding="async"
|
||||
/>
|
||||
</AdminGate>
|
||||
<div className="site-titles">
|
||||
<Link to="/" className="site-title-link">
|
||||
{siteTitle}
|
||||
</Link>
|
||||
<span className="site-title-en">{siteTitleEn}</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<Outlet />
|
||||
</main>
|
||||
<footer className="site-footer">
|
||||
<p>萌芽小窝-一个安静的小地方 @2026-至今</p>
|
||||
<GeoFooter />
|
||||
</footer>
|
||||
</>
|
||||
);
|
||||
}
|
||||
123
client/src/api.ts
Normal file
123
client/src/api.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
const API_BASE = (import.meta.env.VITE_API_BASE as string | undefined) ?? "";
|
||||
|
||||
function apiUrl(path: string): string {
|
||||
return API_BASE ? `${API_BASE}${path}` : path;
|
||||
}
|
||||
|
||||
export type SiteConfig = {
|
||||
siteTitle: string;
|
||||
siteTitleEn: string;
|
||||
cwdApiBase: string;
|
||||
cwdSiteId?: string;
|
||||
};
|
||||
|
||||
export type Post = {
|
||||
id: number;
|
||||
title: string;
|
||||
slug: string;
|
||||
excerpt: string;
|
||||
content: string;
|
||||
published: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
view_count?: number;
|
||||
};
|
||||
|
||||
export async function fetchConfig(): Promise<SiteConfig> {
|
||||
const r = await fetch(apiUrl("/api/config"));
|
||||
const j = (await r.json()) as {
|
||||
ok?: boolean;
|
||||
config?: SiteConfig;
|
||||
};
|
||||
if (!j.ok || !j.config) throw new Error("config");
|
||||
const viteId = import.meta.env.VITE_CWD_SITE_ID;
|
||||
if (viteId && !j.config.cwdSiteId) {
|
||||
j.config.cwdSiteId = viteId;
|
||||
}
|
||||
return j.config;
|
||||
}
|
||||
|
||||
export async function fetchPosts(
|
||||
q?: string,
|
||||
opts?: { credentials?: RequestCredentials }
|
||||
): Promise<Post[]> {
|
||||
const u = new URL(apiUrl("/api/posts"), window.location.origin);
|
||||
if (q) u.searchParams.set("q", q);
|
||||
const r = await fetch(u.toString(), {
|
||||
credentials: opts?.credentials ?? "same-origin"
|
||||
});
|
||||
const j = (await r.json()) as { ok?: boolean; posts?: Post[] };
|
||||
if (!r.ok || !j.ok) throw new Error("posts");
|
||||
return j.posts || [];
|
||||
}
|
||||
|
||||
export async function fetchPost(
|
||||
idOrSlug: string,
|
||||
bump: boolean,
|
||||
opts?: { credentials?: RequestCredentials }
|
||||
): Promise<Post> {
|
||||
const u = new URL(apiUrl(`/api/posts/${encodeURIComponent(idOrSlug)}`), window.location.origin);
|
||||
if (bump) u.searchParams.set("bump", "1");
|
||||
const r = await fetch(u.toString(), {
|
||||
credentials: opts?.credentials ?? "same-origin"
|
||||
});
|
||||
const j = (await r.json()) as { ok?: boolean; post?: Post; error?: string };
|
||||
if (!r.ok || !j.ok || !j.post) throw new Error(j.error || "post");
|
||||
return j.post;
|
||||
}
|
||||
|
||||
export async function postJson<T>(
|
||||
path: string,
|
||||
body: unknown,
|
||||
opts?: { method?: string }
|
||||
): Promise<T> {
|
||||
const r = await fetch(apiUrl(path), {
|
||||
method: opts?.method || "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
return (await r.json()) as T;
|
||||
}
|
||||
|
||||
export async function adminLogin(token: string): Promise<boolean> {
|
||||
const j = await postJson<{ ok?: boolean }>("/api/admin/session", { token });
|
||||
return !!j.ok;
|
||||
}
|
||||
|
||||
export async function savePost(
|
||||
data: Record<string, unknown>,
|
||||
id?: number | null
|
||||
): Promise<Post> {
|
||||
if (id) {
|
||||
const r = await fetch(apiUrl(`/api/posts/${id}`), {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
const j = (await r.json()) as { ok?: boolean; post?: Post; error?: string };
|
||||
if (!r.ok || !j.ok || !j.post) throw new Error(j.error || "save");
|
||||
return j.post;
|
||||
}
|
||||
const { id: _omit, ...body } = data;
|
||||
void _omit;
|
||||
const r = await fetch(apiUrl("/api/posts"), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
const j = (await r.json()) as { ok?: boolean; post?: Post; error?: string };
|
||||
if (!r.ok || !j.ok || !j.post) throw new Error(j.error || "save");
|
||||
return j.post;
|
||||
}
|
||||
|
||||
export async function deletePostApi(id: number): Promise<void> {
|
||||
const r = await fetch(apiUrl(`/api/posts/${id}`), {
|
||||
method: "DELETE",
|
||||
credentials: "include"
|
||||
});
|
||||
const j = (await r.json()) as { ok?: boolean };
|
||||
if (!r.ok || !j.ok) throw new Error("delete");
|
||||
}
|
||||
78
client/src/components/AdminGate.tsx
Normal file
78
client/src/components/AdminGate.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import { useCallback, useState, type ReactNode } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { adminLogin } from "../api";
|
||||
|
||||
export function AdminGate({ children }: { children: ReactNode }) {
|
||||
const [n, setN] = useState(0);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [token, setToken] = useState("");
|
||||
const navigate = useNavigate();
|
||||
|
||||
const onLogoClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
const next = n + 1;
|
||||
if (next >= 5) {
|
||||
setN(0);
|
||||
setOpen(true);
|
||||
return;
|
||||
}
|
||||
setN(next);
|
||||
window.setTimeout(() => setN(0), 4000);
|
||||
},
|
||||
[n]
|
||||
);
|
||||
|
||||
const submit = useCallback(async () => {
|
||||
const ok = await adminLogin(token);
|
||||
if (ok) {
|
||||
setOpen(false);
|
||||
setToken("");
|
||||
navigate("/admin");
|
||||
return;
|
||||
}
|
||||
alert("Token 无效");
|
||||
}, [token, navigate]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="admin-gate-logo-btn"
|
||||
onClick={onLogoClick}
|
||||
aria-label="站点标志"
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
<div id="admin-gate" className="admin-gate" hidden={!open}>
|
||||
<div className="admin-gate-panel">
|
||||
<input
|
||||
id="admin-token"
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
placeholder="Token"
|
||||
aria-label="Token"
|
||||
value={token}
|
||||
onChange={(e) => setToken(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
submit();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button type="button" id="admin-token-go" onClick={submit}>
|
||||
进入
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
id="admin-token-cancel"
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
134
client/src/components/CwdComments.tsx
Normal file
134
client/src/components/CwdComments.tsx
Normal file
@@ -0,0 +1,134 @@
|
||||
import { useEffect } from "react";
|
||||
import { useSiteConfig } from "../ConfigContext";
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
CWDComments?: new (opts: {
|
||||
el: string;
|
||||
apiBaseUrl: string;
|
||||
postSlug: string;
|
||||
lang?: string;
|
||||
siteId?: string;
|
||||
}) => { mount: () => void };
|
||||
__cwdLikeStatusQueryPatched?: boolean;
|
||||
}
|
||||
}
|
||||
|
||||
/** 修正 cwd-widget 点赞 GET 使用完整 URL 导致计数为 0 的问题(沿用旧脚本逻辑) */
|
||||
function patchCwdLikeGetQuery(apiBaseNorm: string, postSlug: string): void {
|
||||
if (window.__cwdLikeStatusQueryPatched) return;
|
||||
window.__cwdLikeStatusQueryPatched = true;
|
||||
|
||||
const base = apiBaseNorm;
|
||||
const orig = window.fetch.bind(window);
|
||||
window.fetch = function fetchPatched(input: RequestInfo | URL, init?: RequestInit) {
|
||||
let url =
|
||||
typeof input === "string"
|
||||
? input
|
||||
: input instanceof URL
|
||||
? input.toString()
|
||||
: input.url;
|
||||
let method = "GET";
|
||||
if (init?.method) method = String(init.method).toUpperCase();
|
||||
else if (typeof Request !== "undefined" && input instanceof Request) {
|
||||
method = String(input.method || "GET").toUpperCase();
|
||||
}
|
||||
if (
|
||||
url &&
|
||||
url.indexOf(base) === 0 &&
|
||||
url.indexOf("/api/like?") !== -1 &&
|
||||
method === "GET"
|
||||
) {
|
||||
try {
|
||||
const u = new URL(url);
|
||||
const ps = u.searchParams.get("post_slug") || "";
|
||||
if (ps.indexOf("://") !== -1) {
|
||||
u.searchParams.set("post_slug", postSlug);
|
||||
if (typeof input === "string") {
|
||||
input = u.toString();
|
||||
} else if (typeof Request !== "undefined" && input instanceof Request) {
|
||||
input = new Request(u.toString(), input);
|
||||
} else if (input instanceof URL) {
|
||||
input = u;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
return orig(input as RequestInfo, init);
|
||||
};
|
||||
}
|
||||
|
||||
export function CwdComments({ postSlug }: { postSlug: string }) {
|
||||
const { cwdApiBase, cwdSiteId } = useSiteConfig();
|
||||
|
||||
useEffect(() => {
|
||||
const apiNorm = (cwdApiBase || "").replace(/\/$/, "");
|
||||
patchCwdLikeGetQuery(apiNorm, postSlug);
|
||||
|
||||
function loadScript(src: string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const s = document.createElement("script");
|
||||
s.src = src;
|
||||
s.async = true;
|
||||
s.onload = () => resolve();
|
||||
s.onerror = () => reject(new Error(src));
|
||||
document.head.appendChild(s);
|
||||
});
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
const el = document.getElementById("comments");
|
||||
if (el) el.innerHTML = "";
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
if (!window.CWDComments) {
|
||||
await loadScript(
|
||||
"https://cdn.jsdelivr.net/npm/cwd-widget@0.1.11/dist/cwd.umd.js"
|
||||
);
|
||||
}
|
||||
if (cancelled || typeof window.CWDComments === "undefined") return;
|
||||
const opts: {
|
||||
el: string;
|
||||
apiBaseUrl: string;
|
||||
postSlug: string;
|
||||
lang?: string;
|
||||
siteId?: string;
|
||||
} = {
|
||||
el: "#comments",
|
||||
apiBaseUrl: cwdApiBase.replace(/\/$/, ""),
|
||||
postSlug,
|
||||
lang: "zh-CN"
|
||||
};
|
||||
if (cwdSiteId) opts.siteId = cwdSiteId;
|
||||
new window.CWDComments(opts).mount();
|
||||
} catch {
|
||||
const node = document.getElementById("comments");
|
||||
if (node) node.textContent = "评论插件加载失败。";
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
const node = document.getElementById("comments");
|
||||
if (node) node.innerHTML = "";
|
||||
};
|
||||
}, [postSlug, cwdApiBase, cwdSiteId]);
|
||||
|
||||
return (
|
||||
<section className="post-comments" aria-labelledby="comments-heading">
|
||||
<h2 id="comments-heading" className="post-comments__title">
|
||||
评论
|
||||
</h2>
|
||||
<div
|
||||
id="comments"
|
||||
data-api-base={cwdApiBase}
|
||||
data-post-slug={postSlug}
|
||||
data-lang="zh-CN"
|
||||
{...(cwdSiteId ? { "data-site-id": cwdSiteId } : {})}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
37
client/src/components/GeoFooter.tsx
Normal file
37
client/src/components/GeoFooter.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
const GEO_API = "https://geoip.api.smyhub.com/api";
|
||||
|
||||
function formatGeo(g: Record<string, string> | null): string {
|
||||
if (!g || typeof g !== "object") return "";
|
||||
const parts: string[] = [];
|
||||
if (g.countryName) parts.push(g.countryName);
|
||||
if (g.regionName) parts.push(g.regionName);
|
||||
if (g.cityName) parts.push(g.cityName);
|
||||
return parts.join(" ");
|
||||
}
|
||||
|
||||
export function GeoFooter() {
|
||||
const [text, setText] = useState("正在获取访问信息…");
|
||||
|
||||
useEffect(() => {
|
||||
fetch(GEO_API, { credentials: "omit", cache: "no-store" })
|
||||
.then((r) => {
|
||||
if (!r.ok) throw new Error("http " + r.status);
|
||||
return r.json() as Promise<{ ip?: string; geo?: Record<string, string> }>;
|
||||
})
|
||||
.then((data) => {
|
||||
const ip = data && data.ip ? String(data.ip) : "—";
|
||||
let loc = formatGeo(data.geo || null);
|
||||
if (!loc) loc = "未知位置";
|
||||
setText(`您的访问 IP:${ip} · ${loc}`);
|
||||
})
|
||||
.catch(() => setText("无法获取访问位置信息"));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<p className="site-footer-geo" id="site-footer-geo" aria-live="polite">
|
||||
{text}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
37
client/src/components/MarkdownBody.tsx
Normal file
37
client/src/components/MarkdownBody.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { renderMarkdownInto } from "../lib/markdown";
|
||||
import { initPostToc, teardownPostTocUi } from "../lib/postToc";
|
||||
|
||||
export function MarkdownBody({ markdown }: { markdown: string }) {
|
||||
const ref = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
teardownPostTocUi();
|
||||
if (!markdown.trim()) {
|
||||
el.innerHTML = "";
|
||||
return;
|
||||
}
|
||||
el.classList.add("markdown-body", "markdown-pending");
|
||||
el.innerHTML = '<p class="markdown-loading">加载中…</p>';
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
await renderMarkdownInto(el, markdown);
|
||||
if (!cancelled) initPostToc(el);
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
el.innerHTML = "<p>渲染失败。</p>";
|
||||
el.classList.remove("markdown-pending");
|
||||
}
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
teardownPostTocUi();
|
||||
};
|
||||
}, [markdown]);
|
||||
|
||||
return <div ref={ref} className="markdown-body" />;
|
||||
}
|
||||
73
client/src/lib/markdown.ts
Normal file
73
client/src/lib/markdown.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { marked } from "marked";
|
||||
import markedKatex from "marked-katex-extension";
|
||||
import hljs from "highlight.js";
|
||||
|
||||
marked.use(
|
||||
markedKatex({
|
||||
throwOnError: false,
|
||||
nonStandard: true
|
||||
})
|
||||
);
|
||||
marked.setOptions({ gfm: true, breaks: true });
|
||||
|
||||
function decodeHtmlEntities(str: string): string {
|
||||
return String(str)
|
||||
.replace(/ /g, "\u00a0")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/&#(\d+);/g, (_, n) => {
|
||||
const code = Number(n);
|
||||
return code >= 0 && code <= 0x10ffff ? String.fromCharCode(code) : "";
|
||||
})
|
||||
.replace(/&#x([0-9a-fA-F]+);/g, (_, h) => {
|
||||
const code = parseInt(h, 16);
|
||||
return code >= 0 && code <= 0x10ffff ? String.fromCharCode(code) : "";
|
||||
});
|
||||
}
|
||||
|
||||
function escapeHtml(s: string): string {
|
||||
return String(s)
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
function mermaidFencedToDiv(html: string): string {
|
||||
return html.replace(
|
||||
/<pre><code class="language-mermaid">([\s\S]*?)<\/code><\/pre>/gi,
|
||||
(_, inner: string) => {
|
||||
const raw = decodeHtmlEntities(inner);
|
||||
return `<div class="mermaid">${escapeHtml(raw)}</div>`;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/** 将 Markdown 渲染进元素(含 KaTeX、高亮、Mermaid) */
|
||||
export async function renderMarkdownInto(
|
||||
el: HTMLElement,
|
||||
markdown: string
|
||||
): Promise<void> {
|
||||
let html = marked.parse(markdown, { async: false }) as string;
|
||||
if (typeof html !== "string") html = "";
|
||||
html = mermaidFencedToDiv(html);
|
||||
el.innerHTML = html;
|
||||
el.classList.remove("markdown-pending");
|
||||
el.querySelectorAll("pre code").forEach((block) => {
|
||||
try {
|
||||
hljs.highlightElement(block as HTMLElement);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
});
|
||||
const mNodes = el.querySelectorAll(".mermaid");
|
||||
if (mNodes.length) {
|
||||
const mermaid = (await import("mermaid")).default;
|
||||
mermaid.initialize({ startOnLoad: false, theme: "neutral" });
|
||||
await mermaid.run({ nodes: [...mNodes] as HTMLElement[] });
|
||||
}
|
||||
}
|
||||
246
client/src/lib/postToc.ts
Normal file
246
client/src/lib/postToc.ts
Normal file
@@ -0,0 +1,246 @@
|
||||
/**
|
||||
* 文章目录(由旧 post-toc.js 迁移)
|
||||
*/
|
||||
const HEADING_SEL = "h2, h3, h4";
|
||||
|
||||
function headingText(h: Element): string {
|
||||
return (h.textContent || "").replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
function makeHeadingIds(headings: Element[]): void {
|
||||
const used = new Set<string>();
|
||||
headings.forEach((h) => {
|
||||
if (h.id && !used.has(h.id)) {
|
||||
used.add(h.id);
|
||||
return;
|
||||
}
|
||||
const raw = headingText(h);
|
||||
let base =
|
||||
raw
|
||||
.toLowerCase()
|
||||
.replace(/\s+/g, "-")
|
||||
.replace(/[^a-z0-9\u2e80-\u9fff-]/gi, "-")
|
||||
.replace(/-+/g, "-")
|
||||
.replace(/^-|-$/g, "") || "section";
|
||||
let id = base;
|
||||
let n = 0;
|
||||
while (used.has(id) || document.getElementById(id)) id = `${base}-${++n}`;
|
||||
h.id = id;
|
||||
used.add(id);
|
||||
});
|
||||
}
|
||||
|
||||
function buildTocOl(headings: Element[]) {
|
||||
const ol = document.createElement("ol");
|
||||
ol.className = "toc-tree";
|
||||
const links: HTMLAnchorElement[] = [];
|
||||
const path: { level: number; li: HTMLLIElement }[] = [];
|
||||
|
||||
for (const h of headings) {
|
||||
const level = parseInt(h.tagName[1], 10);
|
||||
if (level < 2 || level > 6) continue;
|
||||
|
||||
const li = document.createElement("li");
|
||||
li.className = "toc-item";
|
||||
const a = document.createElement("a");
|
||||
a.className = "toc-link";
|
||||
a.href = "#" + h.id;
|
||||
a.textContent = headingText(h);
|
||||
a.dataset.targetId = h.id;
|
||||
li.appendChild(a);
|
||||
links.push(a);
|
||||
|
||||
while (path.length && path[path.length - 1].level >= level) path.pop();
|
||||
|
||||
if (path.length === 0) {
|
||||
ol.appendChild(li);
|
||||
} else {
|
||||
const parentLi = path[path.length - 1].li;
|
||||
let sub = parentLi.querySelector(":scope > ol.toc-sub");
|
||||
if (!sub) {
|
||||
sub = document.createElement("ol");
|
||||
sub.className = "toc-sub";
|
||||
parentLi.appendChild(sub);
|
||||
}
|
||||
sub.appendChild(li);
|
||||
}
|
||||
path.push({ level, li });
|
||||
}
|
||||
|
||||
return { ol, links };
|
||||
}
|
||||
|
||||
function setActive(
|
||||
linkGroups: HTMLAnchorElement[][],
|
||||
activeId: string | null
|
||||
): void {
|
||||
for (const links of linkGroups) {
|
||||
for (const a of links) {
|
||||
const on = a.dataset.targetId === activeId;
|
||||
a.classList.toggle("toc-link--active", !!on);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function bindScrollSpy(
|
||||
headings: Element[],
|
||||
linkGroups: HTMLAnchorElement[][],
|
||||
desktopAside: HTMLElement | null,
|
||||
mobileFab: HTMLElement | null,
|
||||
scope: Element | null | undefined
|
||||
): void {
|
||||
const OFFSET = 100;
|
||||
|
||||
function update() {
|
||||
const inArticle = scope ? scope.getBoundingClientRect().bottom > 120 : true;
|
||||
if (desktopAside)
|
||||
desktopAside.classList.toggle("post-toc--hidden", !inArticle);
|
||||
if (mobileFab) mobileFab.classList.toggle("toc-fab--hidden", !inArticle);
|
||||
|
||||
const y = window.scrollY + OFFSET;
|
||||
let current: string | null = headings[0]?.id || null;
|
||||
for (const h of headings) {
|
||||
if (h.getBoundingClientRect().top + window.scrollY <= y)
|
||||
current = h.id || null;
|
||||
}
|
||||
if (current) setActive(linkGroups, current);
|
||||
}
|
||||
|
||||
let ticking = false;
|
||||
function onScroll() {
|
||||
if (ticking) return;
|
||||
ticking = true;
|
||||
requestAnimationFrame(() => {
|
||||
update();
|
||||
ticking = false;
|
||||
});
|
||||
}
|
||||
|
||||
window.addEventListener("scroll", onScroll, { passive: true });
|
||||
window.addEventListener("resize", onScroll, { passive: true });
|
||||
update();
|
||||
}
|
||||
|
||||
function bindTocLinks(
|
||||
container: HTMLElement,
|
||||
onNavigate?: (() => void) | null
|
||||
): void {
|
||||
container.addEventListener("click", (e) => {
|
||||
const t = e.target as HTMLElement | null;
|
||||
const a = t?.closest("a.toc-link") as HTMLAnchorElement | null;
|
||||
if (!a) return;
|
||||
const id = a.getAttribute("href")?.slice(1);
|
||||
if (!id) return;
|
||||
const target = document.getElementById(id);
|
||||
if (target) {
|
||||
e.preventDefault();
|
||||
target.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
history.replaceState(null, "", "#" + id);
|
||||
onNavigate?.();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function initDesktop(
|
||||
aside: HTMLElement,
|
||||
nav: HTMLElement,
|
||||
headings: Element[]
|
||||
): HTMLAnchorElement[] {
|
||||
const { ol, links } = buildTocOl(headings);
|
||||
nav.innerHTML = "";
|
||||
nav.appendChild(ol);
|
||||
aside.hidden = false;
|
||||
bindTocLinks(nav, null);
|
||||
return links;
|
||||
}
|
||||
|
||||
function initMobile(headings: Element[]) {
|
||||
if (document.getElementById("toc-fab")) return { links: [] as HTMLAnchorElement[], fab: null as HTMLElement | null };
|
||||
|
||||
const { ol, links } = buildTocOl(headings);
|
||||
|
||||
const panel = document.createElement("div");
|
||||
panel.id = "toc-panel";
|
||||
panel.setAttribute("aria-label", "文章目录");
|
||||
panel.setAttribute("role", "navigation");
|
||||
const panelTitle = document.createElement("p");
|
||||
panelTitle.className = "toc-panel__title";
|
||||
panelTitle.textContent = "目录";
|
||||
panel.appendChild(panelTitle);
|
||||
panel.appendChild(ol);
|
||||
document.body.appendChild(panel);
|
||||
|
||||
const fab = document.createElement("button");
|
||||
fab.id = "toc-fab";
|
||||
fab.type = "button";
|
||||
fab.setAttribute("aria-label", "打开文章目录");
|
||||
fab.setAttribute("aria-controls", "toc-panel");
|
||||
fab.setAttribute("aria-expanded", "false");
|
||||
fab.innerHTML = `
|
||||
<span class="toc-fab__icon" aria-hidden="true">
|
||||
<span></span><span></span><span></span>
|
||||
</span>`;
|
||||
document.body.appendChild(fab);
|
||||
|
||||
function openPanel() {
|
||||
panel.classList.add("toc-panel--open");
|
||||
fab.setAttribute("aria-expanded", "true");
|
||||
fab.setAttribute("aria-label", "关闭文章目录");
|
||||
fab.classList.add("toc-fab--open");
|
||||
}
|
||||
function closePanel() {
|
||||
panel.classList.remove("toc-panel--open");
|
||||
fab.setAttribute("aria-expanded", "false");
|
||||
fab.setAttribute("aria-label", "打开文章目录");
|
||||
fab.classList.remove("toc-fab--open");
|
||||
}
|
||||
|
||||
fab.addEventListener("click", () => {
|
||||
panel.classList.contains("toc-panel--open") ? closePanel() : openPanel();
|
||||
});
|
||||
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Escape") closePanel();
|
||||
});
|
||||
|
||||
bindTocLinks(panel, closePanel);
|
||||
|
||||
return { links, fab };
|
||||
}
|
||||
|
||||
export function teardownPostTocUi(): void {
|
||||
document.getElementById("toc-fab")?.remove();
|
||||
document.getElementById("toc-panel")?.remove();
|
||||
}
|
||||
|
||||
export function initPostToc(markdownBody: HTMLElement | null): void {
|
||||
const aside = document.getElementById("post-toc");
|
||||
const nav = document.getElementById("post-toc-nav");
|
||||
if (!markdownBody) return;
|
||||
|
||||
const headings = [...markdownBody.querySelectorAll(HEADING_SEL)];
|
||||
if (headings.length === 0) {
|
||||
if (aside) aside.hidden = true;
|
||||
return;
|
||||
}
|
||||
|
||||
makeHeadingIds(headings);
|
||||
const scope = markdownBody.closest(".post-with-toc");
|
||||
|
||||
let desktopLinks: HTMLAnchorElement[] = [];
|
||||
if (aside && nav) {
|
||||
desktopLinks = initDesktop(aside, nav, headings);
|
||||
}
|
||||
|
||||
const { links: mobileLinks, fab: mobileElem } = initMobile(headings);
|
||||
|
||||
const fab = mobileElem || document.getElementById("toc-fab");
|
||||
bindScrollSpy(headings, [desktopLinks, mobileLinks], aside, fab, scope);
|
||||
|
||||
if (location.hash.length > 1) {
|
||||
const id = decodeURIComponent(location.hash.slice(1));
|
||||
if (headings.some((h) => h.id === id)) {
|
||||
setActive([desktopLinks, mobileLinks], id);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,37 +1,4 @@
|
||||
export function cleanText(value) {
|
||||
return String(value ?? "").trim();
|
||||
}
|
||||
|
||||
export function isTruthy(value) {
|
||||
return value === true || value === "true" || value === "1" || value === 1 || value === "on" || value === "yes";
|
||||
}
|
||||
|
||||
export function escapeHtml(value) {
|
||||
return String(value ?? "")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
export function escapeAttr(value) {
|
||||
return escapeHtml(value).replace(/`/g, "`");
|
||||
}
|
||||
|
||||
export function formatDateTime(value) {
|
||||
if (!value) return "";
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return escapeHtml(value);
|
||||
const yyyy = date.getFullYear();
|
||||
const mm = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const dd = String(date.getDate()).padStart(2, "0");
|
||||
const hh = String(date.getHours()).padStart(2, "0");
|
||||
const mi = String(date.getMinutes()).padStart(2, "0");
|
||||
return `${yyyy}-${mm}-${dd} ${hh}:${mi}`;
|
||||
}
|
||||
|
||||
export function stripMarkdown(text) {
|
||||
export function stripMarkdown(text: unknown): string {
|
||||
return String(text ?? "")
|
||||
.replace(/```[\s\S]*?```/g, " ")
|
||||
.replace(/\$\$[\s\S]*?\$\$/g, " ")
|
||||
@@ -47,6 +14,14 @@ export function stripMarkdown(text) {
|
||||
.trim();
|
||||
}
|
||||
|
||||
export function b64EncodeUtf8(str) {
|
||||
return btoa(unescape(encodeURIComponent(str)));
|
||||
export function formatDateTime(value: unknown): string {
|
||||
if (!value) return "";
|
||||
const date = new Date(String(value));
|
||||
if (Number.isNaN(date.getTime())) return String(value);
|
||||
const yyyy = date.getFullYear();
|
||||
const mm = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const dd = String(date.getDate()).padStart(2, "0");
|
||||
const hh = String(date.getHours()).padStart(2, "0");
|
||||
const mi = String(date.getMinutes()).padStart(2, "0");
|
||||
return `${yyyy}-${mm}-${dd} ${hh}:${mi}`;
|
||||
}
|
||||
11
client/src/main.tsx
Normal file
11
client/src/main.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { App } from "./App";
|
||||
import "./styles/app.css";
|
||||
import "./styles/markdown.css";
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>
|
||||
);
|
||||
234
client/src/pages/Admin.tsx
Normal file
234
client/src/pages/Admin.tsx
Normal file
@@ -0,0 +1,234 @@
|
||||
import { FormEvent, useEffect, useState } from "react";
|
||||
import { Link, useNavigate, useParams, useSearchParams } from "react-router-dom";
|
||||
import {
|
||||
deletePostApi,
|
||||
fetchPost,
|
||||
fetchPosts,
|
||||
savePost,
|
||||
type Post
|
||||
} from "../api";
|
||||
import { useSiteConfig } from "../ConfigContext";
|
||||
|
||||
export function AdminDashboard() {
|
||||
const { siteTitle } = useSiteConfig();
|
||||
const [q, setQ] = useState("");
|
||||
const [inputQ, setInputQ] = useState("");
|
||||
const [posts, setPosts] = useState<Post[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
document.title = `后台 - ${siteTitle}`;
|
||||
}, [siteTitle]);
|
||||
|
||||
useEffect(() => {
|
||||
const id = window.setTimeout(() => setQ(inputQ.trim()), 300);
|
||||
return () => clearTimeout(id);
|
||||
}, [inputQ]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const list = await fetchPosts(q || undefined, {
|
||||
credentials: "include"
|
||||
});
|
||||
if (!cancelled) setPosts(list);
|
||||
} catch {
|
||||
if (!cancelled) setPosts([]);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [q]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<p>
|
||||
<Link to="/">返回</Link>
|
||||
</p>
|
||||
<h1>后台</h1>
|
||||
<p>
|
||||
<Link to="/admin/new">新建文章</Link>
|
||||
</p>
|
||||
<section>
|
||||
<h2>文章列表</h2>
|
||||
<form
|
||||
onSubmit={(e) => e.preventDefault()}
|
||||
>
|
||||
<p>
|
||||
<label>
|
||||
查询 <input value={inputQ} onChange={(e) => setInputQ(e.target.value)} />
|
||||
</label>
|
||||
</p>
|
||||
</form>
|
||||
{posts.length ? (
|
||||
posts.map((post) => (
|
||||
<article key={post.id}>
|
||||
<h3>{post.title}</h3>
|
||||
<p>
|
||||
ID: {post.id} | Slug: {post.slug} |{" "}
|
||||
{post.published ? "已发布" : "草稿"}
|
||||
</p>
|
||||
<div>
|
||||
<Link to={`/admin/edit/${post.id}`}>编辑</Link>
|
||||
<DeleteButton postId={post.id} onDone={() => setPosts((p) => p.filter((x) => x.id !== post.id))} />
|
||||
</div>
|
||||
</article>
|
||||
))
|
||||
) : (
|
||||
<p>没有找到文章。</p>
|
||||
)}
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function DeleteButton({
|
||||
postId,
|
||||
onDone
|
||||
}: {
|
||||
postId: number;
|
||||
onDone: () => void;
|
||||
}) {
|
||||
return (
|
||||
<form
|
||||
style={{ display: "inline", marginLeft: 8 }}
|
||||
onSubmit={async (e) => {
|
||||
e.preventDefault();
|
||||
if (!confirm("确定删除?")) return;
|
||||
try {
|
||||
await deletePostApi(postId);
|
||||
onDone();
|
||||
} catch {
|
||||
alert("删除失败");
|
||||
}
|
||||
}}
|
||||
>
|
||||
<button type="submit">删除</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
export function AdminEditor() {
|
||||
const { id: idParam } = useParams();
|
||||
const [search] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
const { siteTitle } = useSiteConfig();
|
||||
const id = idParam ? Number(idParam) : Number(search.get("id") || "") || null;
|
||||
|
||||
const [title, setTitle] = useState("");
|
||||
const [slug, setSlug] = useState("");
|
||||
const [excerpt, setExcerpt] = useState("");
|
||||
const [content, setContent] = useState("");
|
||||
const [published, setPublished] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
document.title = `后台 - ${siteTitle}`;
|
||||
}, [siteTitle]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) {
|
||||
setTitle("");
|
||||
setSlug("");
|
||||
setExcerpt("");
|
||||
setContent("");
|
||||
setPublished(true);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const post = await fetchPost(String(id), false, {
|
||||
credentials: "include"
|
||||
});
|
||||
if (cancelled) return;
|
||||
setTitle(post.title);
|
||||
setSlug(post.slug);
|
||||
setExcerpt(post.excerpt || "");
|
||||
setContent(post.content || "");
|
||||
setPublished(!!post.published);
|
||||
} catch {
|
||||
if (!cancelled) alert("加载文章失败");
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [id]);
|
||||
|
||||
async function onSubmit(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
try {
|
||||
const payload = {
|
||||
title,
|
||||
slug,
|
||||
excerpt,
|
||||
content,
|
||||
published: published ? 1 : 0,
|
||||
id: id || undefined
|
||||
};
|
||||
const saved = await savePost(payload, id);
|
||||
navigate(`/admin/edit/${saved.id}?saved=1`, { replace: true });
|
||||
} catch (er) {
|
||||
alert(er instanceof Error ? er.message : "保存失败");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<p>
|
||||
<Link to="/admin">返回后台</Link>
|
||||
</p>
|
||||
<h1>{id ? `编辑 #${id}` : "新建文章"}</h1>
|
||||
<form onSubmit={onSubmit}>
|
||||
<p>
|
||||
<label>
|
||||
标题
|
||||
<br />
|
||||
<input value={title} onChange={(e) => setTitle(e.target.value)} required />
|
||||
</label>
|
||||
</p>
|
||||
<p>
|
||||
<label>
|
||||
Slug
|
||||
<br />
|
||||
<input value={slug} onChange={(e) => setSlug(e.target.value)} />
|
||||
</label>
|
||||
</p>
|
||||
<p>
|
||||
<label>
|
||||
摘要
|
||||
<br />
|
||||
<input value={excerpt} onChange={(e) => setExcerpt(e.target.value)} />
|
||||
</label>
|
||||
</p>
|
||||
<p>
|
||||
<label>
|
||||
内容
|
||||
<br />
|
||||
<textarea
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
required
|
||||
rows={18}
|
||||
style={{ width: "100%", maxWidth: "100%" }}
|
||||
/>
|
||||
</label>
|
||||
</p>
|
||||
<p>
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={published}
|
||||
onChange={(e) => setPublished(e.target.checked)}
|
||||
/>{" "}
|
||||
发布
|
||||
</label>
|
||||
</p>
|
||||
<p>
|
||||
<button type="submit">保存</button>
|
||||
</p>
|
||||
</form>
|
||||
</>
|
||||
);
|
||||
}
|
||||
95
client/src/pages/Home.tsx
Normal file
95
client/src/pages/Home.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { fetchPosts, type Post } from "../api";
|
||||
import { useSiteConfig } from "../ConfigContext";
|
||||
import { formatDateTime, stripMarkdown } from "../lib/text";
|
||||
|
||||
export function Home() {
|
||||
const { siteTitle } = useSiteConfig();
|
||||
const [inputQ, setInputQ] = useState("");
|
||||
const [q, setQ] = useState("");
|
||||
const [posts, setPosts] = useState<Post[]>([]);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
document.title = siteTitle;
|
||||
}, [siteTitle]);
|
||||
|
||||
useEffect(() => {
|
||||
const id = window.setTimeout(() => setQ(inputQ.trim()), 300);
|
||||
return () => clearTimeout(id);
|
||||
}, [inputQ]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
setErr(null);
|
||||
const list = await fetchPosts(q || undefined);
|
||||
if (!cancelled) setPosts(list);
|
||||
} catch {
|
||||
if (!cancelled) setErr("加载失败");
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [q]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<form
|
||||
className="home-search"
|
||||
onSubmit={(e) => e.preventDefault()}
|
||||
style={{ marginBottom: 16 }}
|
||||
>
|
||||
<label>
|
||||
搜索{" "}
|
||||
<input
|
||||
name="q"
|
||||
value={inputQ}
|
||||
onChange={(e) => setInputQ(e.target.value)}
|
||||
placeholder="标题 / 正文"
|
||||
/>
|
||||
</label>
|
||||
</form>
|
||||
{q ? (
|
||||
<p>
|
||||
搜索:<strong>{q}</strong>
|
||||
</p>
|
||||
) : null}
|
||||
{err ? <p>{err}</p> : null}
|
||||
{posts.length ? (
|
||||
posts.map((post) => {
|
||||
const excerpt =
|
||||
post.excerpt || stripMarkdown(post.content).slice(0, 180);
|
||||
const views = Number(post.view_count) || 0;
|
||||
return (
|
||||
<article key={post.id} className="post-card">
|
||||
<h2>
|
||||
<Link to={`/post/${encodeURIComponent(post.slug)}`}>
|
||||
{post.title}
|
||||
</Link>
|
||||
</h2>
|
||||
<p className="post-excerpt">
|
||||
{excerpt}
|
||||
{excerpt.length >= 180 ? "…" : ""}
|
||||
</p>
|
||||
<p className="post-meta">
|
||||
<span className="post-card-time">
|
||||
创建于:{formatDateTime(post.created_at)}
|
||||
</span>
|
||||
<span className="post-card-time">
|
||||
最后更新于:{formatDateTime(post.updated_at)}
|
||||
</span>
|
||||
<span className="post-card-views">浏览 {views}</span>
|
||||
</p>
|
||||
</article>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<p>暂无文章。</p>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
85
client/src/pages/PostPage.tsx
Normal file
85
client/src/pages/PostPage.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
import { fetchPost } from "../api";
|
||||
import { useSiteConfig } from "../ConfigContext";
|
||||
import { CwdComments } from "../components/CwdComments";
|
||||
import { MarkdownBody } from "../components/MarkdownBody";
|
||||
import { formatDateTime } from "../lib/text";
|
||||
|
||||
export function PostPage() {
|
||||
const { slug = "" } = useParams();
|
||||
const { siteTitle } = useSiteConfig();
|
||||
const [title, setTitle] = useState("");
|
||||
const [content, setContent] = useState("");
|
||||
const [updatedAt, setUpdatedAt] = useState("");
|
||||
const [views, setViews] = useState(0);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
setErr(null);
|
||||
const post = await fetchPost(decodeURIComponent(slug), true);
|
||||
if (cancelled) return;
|
||||
setTitle(post.title);
|
||||
setContent(post.content || "");
|
||||
setUpdatedAt(post.updated_at);
|
||||
setViews(Number(post.view_count) || 0);
|
||||
document.title = `${post.title} - ${siteTitle}`;
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
setErr("文章不存在或加载失败");
|
||||
document.title = `404 - ${siteTitle}`;
|
||||
}
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [slug, siteTitle]);
|
||||
|
||||
if (err) {
|
||||
return (
|
||||
<>
|
||||
<p>
|
||||
<Link to="/">返回</Link>
|
||||
</p>
|
||||
<h1>404</h1>
|
||||
<p>{err}</p>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<p>
|
||||
<Link to="/">返回</Link>
|
||||
</p>
|
||||
<div className="post-with-toc">
|
||||
<div className="post-with-toc__main">
|
||||
<article>
|
||||
<h1>{title}</h1>
|
||||
<p className="post-detail-meta">
|
||||
<span className="post-detail-time">{formatDateTime(updatedAt)}</span>
|
||||
<span className="post-detail-views">浏览 {views}</span>
|
||||
</p>
|
||||
<MarkdownBody markdown={content} />
|
||||
</article>
|
||||
</div>
|
||||
<aside
|
||||
className="post-toc"
|
||||
id="post-toc"
|
||||
aria-label="文章目录"
|
||||
hidden
|
||||
>
|
||||
<div className="post-toc__inner">
|
||||
<p className="post-toc__title">目录</p>
|
||||
<nav className="post-toc__nav" id="post-toc-nav" />
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
<CwdComments key={slug} postSlug={decodeURIComponent(slug)} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
612
client/src/styles/app.css
Normal file
612
client/src/styles/app.css
Normal file
@@ -0,0 +1,612 @@
|
||||
:root {
|
||||
--page-max: 920px;
|
||||
--page-pad-x: 24px;
|
||||
--page-pad-y: 24px;
|
||||
--page-gap: 24px;
|
||||
--text: #2a1f0e;
|
||||
--muted: #6b5a42;
|
||||
--line: #d9cdb8;
|
||||
/* 笔记纸底色与格线 */
|
||||
--paper-bg: #fdf8ec;
|
||||
--paper-rule: rgba(160, 140, 100, 0.18);
|
||||
--paper-grid: rgba(160, 140, 100, 0.10);
|
||||
--paper-rule-spacing: 28px;
|
||||
/**
|
||||
* 有网:Inter(拉丁)+ Noto Sans SC(补中文,外链见 render 中 Google Fonts)
|
||||
* 无网/外链失败:不加载 webfont 时,浏览器跳过未解析名称,用后续系统无衬线
|
||||
*/
|
||||
--font-sans: "Inter", "Noto Sans SC", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Segoe UI", system-ui, sans-serif;
|
||||
/* 行内/块 <code> 等;无 Source Code Pro 时走系统等宽 */
|
||||
--font-mono: "Source Code Pro", Menlo, Monaco, Consolas, "Courier New", monospace;
|
||||
}
|
||||
html {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: var(--paper-bg);
|
||||
color: var(--text);
|
||||
font-family: var(--font-sans);
|
||||
scroll-behavior: smooth;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
text-size-adjust: 100%;
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
color: var(--text);
|
||||
font-family: var(--font-sans);
|
||||
line-height: 1.7;
|
||||
overflow-x: hidden;
|
||||
/* 笔记纸纹理:竖向 + 横向格线,横线更淡 */
|
||||
background-color: var(--paper-bg);
|
||||
background-image:
|
||||
repeating-linear-gradient(
|
||||
90deg,
|
||||
transparent,
|
||||
transparent calc(var(--paper-rule-spacing) - 1px),
|
||||
var(--paper-grid) calc(var(--paper-rule-spacing) - 1px),
|
||||
var(--paper-grid) var(--paper-rule-spacing)
|
||||
),
|
||||
repeating-linear-gradient(
|
||||
180deg,
|
||||
transparent,
|
||||
transparent calc(var(--paper-rule-spacing) - 1px),
|
||||
rgba(160, 140, 100, 0.07) calc(var(--paper-rule-spacing) - 1px),
|
||||
rgba(160, 140, 100, 0.07) var(--paper-rule-spacing)
|
||||
);
|
||||
}
|
||||
code,
|
||||
kbd,
|
||||
samp {
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
main {
|
||||
width: min(100%, var(--page-max));
|
||||
margin: 0 auto;
|
||||
padding: var(--page-pad-y) var(--page-pad-x) 32px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.site-footer {
|
||||
width: min(100%, var(--page-max));
|
||||
margin: 0 auto;
|
||||
padding: 20px var(--page-pad-x) 28px;
|
||||
box-sizing: border-box;
|
||||
border-top: 1px solid var(--line);
|
||||
text-align: center;
|
||||
font-size: 0.88rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
.site-footer p {
|
||||
margin: 0;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.site-footer p.site-footer-geo {
|
||||
margin-top: 10px;
|
||||
font-size: 0.82rem;
|
||||
color: var(--muted);
|
||||
word-break: break-word;
|
||||
}
|
||||
header.site-header {
|
||||
margin-bottom: var(--page-gap);
|
||||
padding-bottom: 16px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.site-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.site-titles {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
.site-title-link {
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
font-weight: 700;
|
||||
font-size: 1.25rem;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.site-title-en {
|
||||
font-size: 0.82rem;
|
||||
font-weight: 500;
|
||||
color: var(--muted);
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.site-logo {
|
||||
flex-shrink: 0;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
object-fit: contain;
|
||||
cursor: pointer;
|
||||
/* 让 logo 白底融入纸张背景 */
|
||||
mix-blend-mode: multiply;
|
||||
}
|
||||
.admin-gate {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 9999;
|
||||
padding: 16px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.admin-gate[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
.admin-gate-panel {
|
||||
background: var(--paper-bg);
|
||||
padding: 16px 18px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #c8b99a;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
width: min(100%, 420px);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.admin-gate-panel input[type="password"] {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
min-width: 0;
|
||||
}
|
||||
h1,
|
||||
h2,
|
||||
h3 {
|
||||
line-height: 1.3;
|
||||
margin: 0 0 12px;
|
||||
word-break: break-word;
|
||||
}
|
||||
article,
|
||||
section {
|
||||
margin: 0 0 24px;
|
||||
}
|
||||
p {
|
||||
margin: 0 0 12px;
|
||||
word-break: break-word;
|
||||
}
|
||||
a {
|
||||
color: inherit;
|
||||
}
|
||||
input,
|
||||
textarea,
|
||||
button {
|
||||
font: inherit;
|
||||
max-width: 100%;
|
||||
}
|
||||
input[type="text"],
|
||||
input[type="password"],
|
||||
textarea {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
min-width: 0;
|
||||
}
|
||||
textarea {
|
||||
min-height: 320px;
|
||||
resize: vertical;
|
||||
}
|
||||
pre {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.92em;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
overflow-x: auto;
|
||||
}
|
||||
img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
hr {
|
||||
border: 0;
|
||||
border-top: 1px solid #d4c5a8;
|
||||
margin: 24px 0;
|
||||
}
|
||||
.post-card {
|
||||
padding-bottom: 16px;
|
||||
border-bottom: 1px solid #e4d9c4;
|
||||
}
|
||||
.post-card:last-of-type {
|
||||
padding-bottom: 0;
|
||||
border-bottom: 0;
|
||||
}
|
||||
.post-card .post-excerpt {
|
||||
margin: 0 0 10px;
|
||||
}
|
||||
.post-card .post-meta {
|
||||
font-size: 0.9em;
|
||||
color: #555;
|
||||
margin: 0 0 16px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px 16px;
|
||||
}
|
||||
.post-detail-meta {
|
||||
font-size: 0.95em;
|
||||
color: #555;
|
||||
margin: 0 0 16px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px 16px;
|
||||
}
|
||||
.post-detail-views {
|
||||
color: #666;
|
||||
}
|
||||
.post-comments {
|
||||
margin-top: 36px;
|
||||
padding-top: 28px;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
.post-comments__title {
|
||||
font-size: 1.35rem;
|
||||
margin: 0 0 16px;
|
||||
}
|
||||
form {
|
||||
margin: 0;
|
||||
}
|
||||
@media (max-width: 1024px) {
|
||||
:root {
|
||||
--page-max: 840px;
|
||||
--page-pad-x: 20px;
|
||||
--page-pad-y: 20px;
|
||||
--page-gap: 20px;
|
||||
}
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
:root {
|
||||
--page-max: 100%;
|
||||
--page-pad-x: 16px;
|
||||
--page-pad-y: 16px;
|
||||
--page-gap: 18px;
|
||||
}
|
||||
.site-logo {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
}
|
||||
.site-title-link {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
.post-card .post-meta,
|
||||
.post-detail-meta {
|
||||
gap: 6px 12px;
|
||||
}
|
||||
}
|
||||
@media (max-width: 480px) {
|
||||
:root {
|
||||
--page-pad-x: 14px;
|
||||
--page-pad-y: 14px;
|
||||
--page-gap: 16px;
|
||||
}
|
||||
body {
|
||||
line-height: 1.68;
|
||||
}
|
||||
header.site-header {
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
textarea {
|
||||
min-height: 240px;
|
||||
}
|
||||
.site-brand {
|
||||
gap: 10px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.site-logo {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
.site-title-link {
|
||||
font-size: 1rem;
|
||||
}
|
||||
.post-card .post-meta,
|
||||
.post-detail-meta {
|
||||
font-size: 0.82em;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: 4px 10px;
|
||||
row-gap: 4px;
|
||||
}
|
||||
.post-card .post-meta span,
|
||||
.post-detail-meta span {
|
||||
white-space: nowrap;
|
||||
}
|
||||
.admin-gate-panel {
|
||||
padding: 14px 14px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ----- 文章页布局 ----- */
|
||||
main:has(.post-with-toc),
|
||||
body:has(.post-with-toc) .site-footer {
|
||||
width: min(100%, 920px);
|
||||
max-width: 920px;
|
||||
}
|
||||
.post-with-toc {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
.post-with-toc__main {
|
||||
min-width: 0;
|
||||
width: min(100%, 720px);
|
||||
margin: 0 auto;
|
||||
}
|
||||
/* ═══ 桌面端目录(≥1181px)═══════════════════════════════════ */
|
||||
.post-toc {
|
||||
position: fixed;
|
||||
top: 96px;
|
||||
right: max(14px, calc((100vw - 1400px) / 2));
|
||||
z-index: 20;
|
||||
width: 260px;
|
||||
max-width: min(30vw, 260px);
|
||||
transition: opacity 0.18s ease, visibility 0.18s ease;
|
||||
}
|
||||
.post-toc--hidden {
|
||||
visibility: hidden;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
.post-toc__inner {
|
||||
box-sizing: border-box;
|
||||
max-height: calc(100vh - 128px);
|
||||
overflow-y: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
.post-toc__title {
|
||||
margin: 0 0 10px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
color: #888;
|
||||
}
|
||||
.post-toc__nav {
|
||||
position: relative;
|
||||
margin: 0;
|
||||
border-left: 1px solid #d4c5a8;
|
||||
padding-left: 14px;
|
||||
}
|
||||
|
||||
/* 桌面:≤1180px 完全隐藏 aside */
|
||||
@media (max-width: 1180px) {
|
||||
.post-toc {
|
||||
display: none;
|
||||
}
|
||||
main:has(.post-with-toc),
|
||||
body:has(.post-with-toc) .site-footer {
|
||||
width: min(100%, var(--page-max));
|
||||
max-width: var(--page-max);
|
||||
}
|
||||
}
|
||||
|
||||
/* ═══ 公共目录列表样式(桌面 & 移动端共用类名)══════════════ */
|
||||
.toc-tree,
|
||||
.toc-sub {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
.toc-sub {
|
||||
margin-top: 2px;
|
||||
margin-bottom: 2px;
|
||||
padding-left: 10px;
|
||||
}
|
||||
.toc-item {
|
||||
margin: 0;
|
||||
}
|
||||
.toc-link {
|
||||
display: block;
|
||||
position: relative;
|
||||
padding: 5px 0 6px;
|
||||
font-size: 0.88rem;
|
||||
line-height: 1.5;
|
||||
text-decoration: none;
|
||||
color: #777;
|
||||
}
|
||||
.toc-sub .toc-link {
|
||||
color: #999;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
.toc-link:hover,
|
||||
.toc-sub .toc-link:hover {
|
||||
color: #111;
|
||||
}
|
||||
.toc-link--active {
|
||||
color: #111;
|
||||
font-weight: 500;
|
||||
}
|
||||
/* 桌面目录:当前项左侧指示条 */
|
||||
.post-toc__nav .toc-link--active::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: -16px;
|
||||
top: 0.15em;
|
||||
bottom: 0.15em;
|
||||
width: 2px;
|
||||
background: #111;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
/* ═══ 移动端 FAB(JS append 到 body,≤1180px 显示)═════════ */
|
||||
#toc-fab {
|
||||
display: none;
|
||||
}
|
||||
@media (max-width: 1180px) {
|
||||
#toc-fab {
|
||||
display: inline-flex;
|
||||
position: fixed;
|
||||
right: 20px;
|
||||
bottom: calc(22px + env(safe-area-inset-bottom));
|
||||
z-index: 50;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid rgba(160, 130, 80, 0.25);
|
||||
border-radius: 50%;
|
||||
background: rgba(253, 248, 236, 0.97);
|
||||
box-shadow: 0 6px 20px rgba(100, 80, 40, 0.16);
|
||||
color: #333;
|
||||
cursor: pointer;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
transition: opacity 0.18s, visibility 0.18s, transform 0.18s;
|
||||
}
|
||||
#toc-fab.toc-fab--hidden {
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
transform: scale(0.85);
|
||||
}
|
||||
/* 汉堡线 */
|
||||
.toc-fab__icon {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
width: 18px;
|
||||
transition: gap 0.18s;
|
||||
}
|
||||
.toc-fab__icon span {
|
||||
display: block;
|
||||
height: 2px;
|
||||
width: 18px;
|
||||
background: currentColor;
|
||||
border-radius: 2px;
|
||||
transition: transform 0.18s, opacity 0.18s, width 0.18s;
|
||||
transform-origin: center;
|
||||
}
|
||||
/* 打开状态 → X 形 */
|
||||
#toc-fab.toc-fab--open .toc-fab__icon {
|
||||
gap: 0;
|
||||
}
|
||||
#toc-fab.toc-fab--open .toc-fab__icon span:nth-child(1) {
|
||||
transform: translateY(2px) rotate(45deg);
|
||||
}
|
||||
#toc-fab.toc-fab--open .toc-fab__icon span:nth-child(2) {
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
}
|
||||
#toc-fab.toc-fab--open .toc-fab__icon span:nth-child(3) {
|
||||
transform: translateY(-2px) rotate(-45deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* ═══ 移动端弹出面板(JS append 到 body,≤1180px 显示)══════ */
|
||||
#toc-panel {
|
||||
display: none;
|
||||
}
|
||||
@media (max-width: 1180px) {
|
||||
#toc-panel {
|
||||
display: block;
|
||||
position: fixed;
|
||||
right: 16px;
|
||||
bottom: calc(76px + env(safe-area-inset-bottom));
|
||||
z-index: 49;
|
||||
width: min(300px, calc(100vw - 32px));
|
||||
max-height: min(60vh, 440px);
|
||||
overflow-y: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
box-sizing: border-box;
|
||||
padding: 14px 16px 16px;
|
||||
background: rgba(253, 248, 236, 0.98);
|
||||
border: 1px solid rgba(160, 130, 80, 0.2);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 12px 36px rgba(100, 80, 40, 0.18);
|
||||
/* 默认隐藏 */
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
transform: translateY(10px) scale(0.97);
|
||||
transform-origin: bottom right;
|
||||
transition: opacity 0.16s ease, visibility 0.16s ease, transform 0.16s ease;
|
||||
}
|
||||
#toc-panel.toc-panel--open {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
pointer-events: auto;
|
||||
transform: none;
|
||||
}
|
||||
.toc-panel__title {
|
||||
margin: 0 0 8px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
color: #888;
|
||||
}
|
||||
/* 移动端面板:当前项左侧指示条 */
|
||||
#toc-panel .toc-link--active::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: -14px;
|
||||
top: 0.15em;
|
||||
bottom: 0.15em;
|
||||
width: 2px;
|
||||
background: #111;
|
||||
border-radius: 0;
|
||||
}
|
||||
#toc-panel .toc-tree {
|
||||
border-left: 1px solid #d4c5a8;
|
||||
padding-left: 14px;
|
||||
}
|
||||
#toc-panel .toc-link {
|
||||
font-size: 0.9rem;
|
||||
padding: 6px 0 7px;
|
||||
}
|
||||
#toc-panel .toc-sub .toc-link {
|
||||
font-size: 0.84rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* React:可点击 Logo 用于后台入口 */
|
||||
.admin-gate-logo-btn {
|
||||
all: unset;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ═══ cwd-widget 评论区覆盖:融入纸张主题 ═══════════════════ */
|
||||
#comments > div,
|
||||
#comments .cwd-widget,
|
||||
#comments [class*="card"],
|
||||
#comments [class*="form-wrap"],
|
||||
#comments [class*="comment-form"],
|
||||
#comments [class*="CommentForm"],
|
||||
#comments form {
|
||||
background: var(--paper-bg) !important;
|
||||
border-color: var(--line) !important;
|
||||
box-shadow: 0 2px 12px rgba(100, 80, 40, 0.1) !important;
|
||||
}
|
||||
#comments input[type="text"],
|
||||
#comments input[type="email"],
|
||||
#comments input[type="url"],
|
||||
#comments textarea {
|
||||
background: rgba(255, 252, 240, 0.8) !important;
|
||||
border-color: #c8b99a !important;
|
||||
color: var(--text) !important;
|
||||
}
|
||||
#comments input[type="text"]:focus,
|
||||
#comments input[type="email"]:focus,
|
||||
#comments input[type="url"]:focus,
|
||||
#comments textarea:focus {
|
||||
border-color: #a08060 !important;
|
||||
outline: none !important;
|
||||
box-shadow: 0 0 0 2px rgba(160, 128, 96, 0.2) !important;
|
||||
}
|
||||
#comments [class*="comment-item"],
|
||||
#comments [class*="CommentItem"],
|
||||
#comments [class*="comment-card"] {
|
||||
background: rgba(255, 252, 240, 0.6) !important;
|
||||
border-color: var(--line) !important;
|
||||
}
|
||||
/* 兜底:widget 内所有白色背景块 */
|
||||
#comments > div[style*="background"],
|
||||
#comments > div > div[style*="background:#fff"],
|
||||
#comments > div > div[style*="background: #fff"],
|
||||
#comments > div > div[style*="background:white"],
|
||||
#comments > div > div[style*="background: white"] {
|
||||
background: var(--paper-bg) !important;
|
||||
}
|
||||
@@ -23,11 +23,13 @@
|
||||
.markdown-body h2 {
|
||||
font-size: 1.5rem;
|
||||
margin: 22px 0 10px;
|
||||
scroll-margin-top: 1.25em;
|
||||
}
|
||||
|
||||
.markdown-body h3 {
|
||||
font-size: 1.25rem;
|
||||
margin: 18px 0 8px;
|
||||
scroll-margin-top: 1.15em;
|
||||
}
|
||||
|
||||
.markdown-body h4,
|
||||
@@ -35,6 +37,7 @@
|
||||
.markdown-body h6 {
|
||||
font-size: 1.05rem;
|
||||
margin: 16px 0 8px;
|
||||
scroll-margin-top: 1.05em;
|
||||
}
|
||||
|
||||
.markdown-body p {
|
||||
@@ -83,7 +86,7 @@
|
||||
}
|
||||
|
||||
.markdown-body code {
|
||||
font-family: ui-monospace, "Cascadia Code", "Source Code Pro", Menlo, Consolas, monospace;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.92em;
|
||||
}
|
||||
|
||||
9
client/src/vite-env.d.ts
vendored
Normal file
9
client/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_CWD_SITE_ID?: string;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv;
|
||||
}
|
||||
24
client/tsconfig.json
Normal file
24
client/tsconfig.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx"],
|
||||
"exclude": ["vite.config.ts"]
|
||||
}
|
||||
33
client/vite.config.ts
Normal file
33
client/vite.config.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import path from "node:path";
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
const root = path.resolve(__dirname);
|
||||
|
||||
export default defineConfig(({ mode }) => {
|
||||
const isDesktop = mode === "desktop";
|
||||
return {
|
||||
plugins: [react()],
|
||||
root,
|
||||
publicDir: path.join(root, "public"),
|
||||
define: {
|
||||
"import.meta.env.VITE_API_BASE": JSON.stringify(
|
||||
isDesktop ? "https://blog.smyhub.com" : ""
|
||||
)
|
||||
},
|
||||
build: {
|
||||
outDir: path.resolve(__dirname, isDesktop ? "../dist-desktop" : "../dist"),
|
||||
emptyOutDir: true,
|
||||
chunkSizeWarningLimit: 1200
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
"/api": {
|
||||
target: "http://127.0.0.1:8787",
|
||||
changeOrigin: true
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
});
|
||||
1
dist-desktop/assets/_baseUniq-D-yNBara.js
Normal file
1
dist-desktop/assets/_baseUniq-D-yNBara.js
Normal file
File diff suppressed because one or more lines are too long
1
dist-desktop/assets/arc-CZ0PsMQW.js
Normal file
1
dist-desktop/assets/arc-CZ0PsMQW.js
Normal file
@@ -0,0 +1 @@
|
||||
import{a0 as ln,a1 as an,a2 as H,a3 as q,a4 as B,a5 as un,a6 as y,a7 as tn,a8 as L,a9 as _,aa as rn,ab as o,ac as on,ad as sn,ae as fn}from"./mermaid.core-DD7RPEfx.js";function cn(l){return l.innerRadius}function yn(l){return l.outerRadius}function gn(l){return l.startAngle}function dn(l){return l.endAngle}function mn(l){return l&&l.padAngle}function pn(l,h,I,D,v,A,C,a){var O=I-l,i=D-h,n=C-v,d=a-A,u=d*O-n*i;if(!(u*u<y))return u=(n*(h-A)-d*(l-v))/u,[l+u*O,h+u*i]}function W(l,h,I,D,v,A,C){var a=l-I,O=h-D,i=(C?A:-A)/L(a*a+O*O),n=i*O,d=-i*a,u=l+n,s=h+d,f=I+n,c=D+d,F=(u+f)/2,t=(s+c)/2,m=f-u,g=c-s,R=m*m+g*g,T=v-A,P=u*c-f*s,S=(g<0?-1:1)*L(on(0,T*T*R-P*P)),j=(P*g-m*S)/R,z=(-P*m-g*S)/R,w=(P*g+m*S)/R,p=(-P*m+g*S)/R,x=j-F,e=z-t,r=w-F,G=p-t;return x*x+e*e>r*r+G*G&&(j=w,z=p),{cx:j,cy:z,x01:-n,y01:-d,x11:j*(v/T-1),y11:z*(v/T-1)}}function hn(){var l=cn,h=yn,I=B(0),D=null,v=gn,A=dn,C=mn,a=null,O=ln(i);function i(){var n,d,u=+l.apply(this,arguments),s=+h.apply(this,arguments),f=v.apply(this,arguments)-un,c=A.apply(this,arguments)-un,F=rn(c-f),t=c>f;if(a||(a=n=O()),s<u&&(d=s,s=u,u=d),!(s>y))a.moveTo(0,0);else if(F>tn-y)a.moveTo(s*H(f),s*q(f)),a.arc(0,0,s,f,c,!t),u>y&&(a.moveTo(u*H(c),u*q(c)),a.arc(0,0,u,c,f,t));else{var m=f,g=c,R=f,T=c,P=F,S=F,j=C.apply(this,arguments)/2,z=j>y&&(D?+D.apply(this,arguments):L(u*u+s*s)),w=_(rn(s-u)/2,+I.apply(this,arguments)),p=w,x=w,e,r;if(z>y){var G=sn(z/u*q(j)),M=sn(z/s*q(j));(P-=G*2)>y?(G*=t?1:-1,R+=G,T-=G):(P=0,R=T=(f+c)/2),(S-=M*2)>y?(M*=t?1:-1,m+=M,g-=M):(S=0,m=g=(f+c)/2)}var J=s*H(m),K=s*q(m),N=u*H(T),Q=u*q(T);if(w>y){var U=s*H(g),V=s*q(g),X=u*H(R),Y=u*q(R),E;if(F<an)if(E=pn(J,K,X,Y,U,V,N,Q)){var Z=J-E[0],$=K-E[1],b=U-E[0],k=V-E[1],nn=1/q(fn((Z*b+$*k)/(L(Z*Z+$*$)*L(b*b+k*k)))/2),en=L(E[0]*E[0]+E[1]*E[1]);p=_(w,(u-en)/(nn-1)),x=_(w,(s-en)/(nn+1))}else p=x=0}S>y?x>y?(e=W(X,Y,J,K,s,x,t),r=W(U,V,N,Q,s,x,t),a.moveTo(e.cx+e.x01,e.cy+e.y01),x<w?a.arc(e.cx,e.cy,x,o(e.y01,e.x01),o(r.y01,r.x01),!t):(a.arc(e.cx,e.cy,x,o(e.y01,e.x01),o(e.y11,e.x11),!t),a.arc(0,0,s,o(e.cy+e.y11,e.cx+e.x11),o(r.cy+r.y11,r.cx+r.x11),!t),a.arc(r.cx,r.cy,x,o(r.y11,r.x11),o(r.y01,r.x01),!t))):(a.moveTo(J,K),a.arc(0,0,s,m,g,!t)):a.moveTo(J,K),!(u>y)||!(P>y)?a.lineTo(N,Q):p>y?(e=W(N,Q,U,V,u,-p,t),r=W(J,K,X,Y,u,-p,t),a.lineTo(e.cx+e.x01,e.cy+e.y01),p<w?a.arc(e.cx,e.cy,p,o(e.y01,e.x01),o(r.y01,r.x01),!t):(a.arc(e.cx,e.cy,p,o(e.y01,e.x01),o(e.y11,e.x11),!t),a.arc(0,0,u,o(e.cy+e.y11,e.cx+e.x11),o(r.cy+r.y11,r.cx+r.x11),t),a.arc(r.cx,r.cy,p,o(r.y11,r.x11),o(r.y01,r.x01),!t))):a.arc(0,0,u,T,R,t)}if(a.closePath(),n)return a=null,n+""||null}return i.centroid=function(){var n=(+l.apply(this,arguments)+ +h.apply(this,arguments))/2,d=(+v.apply(this,arguments)+ +A.apply(this,arguments))/2-an/2;return[H(d)*n,q(d)*n]},i.innerRadius=function(n){return arguments.length?(l=typeof n=="function"?n:B(+n),i):l},i.outerRadius=function(n){return arguments.length?(h=typeof n=="function"?n:B(+n),i):h},i.cornerRadius=function(n){return arguments.length?(I=typeof n=="function"?n:B(+n),i):I},i.padRadius=function(n){return arguments.length?(D=n==null?null:typeof n=="function"?n:B(+n),i):D},i.startAngle=function(n){return arguments.length?(v=typeof n=="function"?n:B(+n),i):v},i.endAngle=function(n){return arguments.length?(A=typeof n=="function"?n:B(+n),i):A},i.padAngle=function(n){return arguments.length?(C=typeof n=="function"?n:B(+n),i):C},i.context=function(n){return arguments.length?(a=n??null,i):a},i}export{hn as d};
|
||||
36
dist-desktop/assets/architectureDiagram-Q4EWVU46-C8umIefJ.js
Normal file
36
dist-desktop/assets/architectureDiagram-Q4EWVU46-C8umIefJ.js
Normal file
File diff suppressed because one or more lines are too long
132
dist-desktop/assets/blockDiagram-DXYQGD6D-rHreedhf.js
Normal file
132
dist-desktop/assets/blockDiagram-DXYQGD6D-rHreedhf.js
Normal file
File diff suppressed because one or more lines are too long
10
dist-desktop/assets/c4Diagram-AHTNJAMY-Cnif2nNv.js
Normal file
10
dist-desktop/assets/c4Diagram-AHTNJAMY-Cnif2nNv.js
Normal file
File diff suppressed because one or more lines are too long
1
dist-desktop/assets/channel-Bv1ZAY8d.js
Normal file
1
dist-desktop/assets/channel-Bv1ZAY8d.js
Normal file
@@ -0,0 +1 @@
|
||||
import{aq as o,ar as n}from"./mermaid.core-DD7RPEfx.js";const t=(r,a)=>o.lang.round(n.parse(r)[a]);export{t as c};
|
||||
1
dist-desktop/assets/chunk-4BX2VUAB-CI7enyPF.js
Normal file
1
dist-desktop/assets/chunk-4BX2VUAB-CI7enyPF.js
Normal file
@@ -0,0 +1 @@
|
||||
import{_ as l}from"./mermaid.core-DD7RPEfx.js";function m(e,c){var i,t,o;e.accDescr&&((i=c.setAccDescription)==null||i.call(c,e.accDescr)),e.accTitle&&((t=c.setAccTitle)==null||t.call(c,e.accTitle)),e.title&&((o=c.setDiagramTitle)==null||o.call(c,e.title))}l(m,"populateCommonDb");export{m as p};
|
||||
206
dist-desktop/assets/chunk-4TB4RGXK-D8YpqJC4.js
Normal file
206
dist-desktop/assets/chunk-4TB4RGXK-D8YpqJC4.js
Normal file
File diff suppressed because one or more lines are too long
1
dist-desktop/assets/chunk-55IACEB6-D6svk_zs.js
Normal file
1
dist-desktop/assets/chunk-55IACEB6-D6svk_zs.js
Normal file
@@ -0,0 +1 @@
|
||||
import{_ as a,d as o}from"./mermaid.core-DD7RPEfx.js";var d=a((t,e)=>{let n;return e==="sandbox"&&(n=o("#i"+t)),(e==="sandbox"?o(n.nodes()[0].contentDocument.body):o("body")).select(`[id="${t}"]`)},"getDiagramElement");export{d as g};
|
||||
1
dist-desktop/assets/chunk-EDXVE4YY-D2oMcFeR.js
Normal file
1
dist-desktop/assets/chunk-EDXVE4YY-D2oMcFeR.js
Normal file
@@ -0,0 +1 @@
|
||||
import{_ as a,e as w,l as x}from"./mermaid.core-DD7RPEfx.js";var d=a((e,t,i,o)=>{e.attr("class",i);const{width:r,height:h,x:n,y:c}=u(e,t);w(e,h,r,o);const s=l(n,c,r,h,t);e.attr("viewBox",s),x.debug(`viewBox configured: ${s} with padding: ${t}`)},"setupViewPortForSVG"),u=a((e,t)=>{var o;const i=((o=e.node())==null?void 0:o.getBBox())||{width:0,height:0,x:0,y:0};return{width:i.width+t*2,height:i.height+t*2,x:i.x,y:i.y}},"calculateDimensionsWithPadding"),l=a((e,t,i,o,r)=>`${e-r} ${t-r} ${i} ${o}`,"createViewBox");export{d as s};
|
||||
15
dist-desktop/assets/chunk-FMBD7UC4-TatyaWoN.js
Normal file
15
dist-desktop/assets/chunk-FMBD7UC4-TatyaWoN.js
Normal file
@@ -0,0 +1,15 @@
|
||||
import{_ as e}from"./mermaid.core-DD7RPEfx.js";var l=e(()=>`
|
||||
/* Font Awesome icon styling - consolidated */
|
||||
.label-icon {
|
||||
display: inline-block;
|
||||
height: 1em;
|
||||
overflow: visible;
|
||||
vertical-align: -0.125em;
|
||||
}
|
||||
|
||||
.node .label-icon path {
|
||||
fill: currentColor;
|
||||
stroke: revert;
|
||||
stroke-width: revert;
|
||||
}
|
||||
`,"getIconStyles");export{l as g};
|
||||
231
dist-desktop/assets/chunk-OYMX7WX6-Bgc90t5_.js
Normal file
231
dist-desktop/assets/chunk-OYMX7WX6-Bgc90t5_.js
Normal file
File diff suppressed because one or more lines are too long
1
dist-desktop/assets/chunk-QZHKN3VN-BFI20CEN.js
Normal file
1
dist-desktop/assets/chunk-QZHKN3VN-BFI20CEN.js
Normal file
@@ -0,0 +1 @@
|
||||
import{_ as s}from"./mermaid.core-DD7RPEfx.js";var t,e=(t=class{constructor(i){this.init=i,this.records=this.init()}reset(){this.records=this.init()}},s(t,"ImperativeState"),t);export{e as I};
|
||||
1
dist-desktop/assets/chunk-YZCP3GAM-Blce-T0j.js
Normal file
1
dist-desktop/assets/chunk-YZCP3GAM-Blce-T0j.js
Normal file
@@ -0,0 +1 @@
|
||||
import{_ as i,d as l,U as d,j as o}from"./mermaid.core-DD7RPEfx.js";var x=i((r,t)=>{const e=r.append("rect");if(e.attr("x",t.x),e.attr("y",t.y),e.attr("fill",t.fill),e.attr("stroke",t.stroke),e.attr("width",t.width),e.attr("height",t.height),t.name&&e.attr("name",t.name),t.rx&&e.attr("rx",t.rx),t.ry&&e.attr("ry",t.ry),t.attrs!==void 0)for(const s in t.attrs)e.attr(s,t.attrs[s]);return t.class&&e.attr("class",t.class),e},"drawRect"),p=i((r,t)=>{const e={x:t.startx,y:t.starty,width:t.stopx-t.startx,height:t.stopy-t.starty,fill:t.fill,stroke:t.stroke,class:"rect"};x(r,e).lower()},"drawBackgroundRect"),y=i((r,t)=>{const e=t.text.replace(d," "),s=r.append("text");s.attr("x",t.x),s.attr("y",t.y),s.attr("class","legend"),s.style("text-anchor",t.anchor),t.class&&s.attr("class",t.class);const a=s.append("tspan");return a.attr("x",t.x+t.textMargin*2),a.text(e),s},"drawText"),m=i((r,t,e,s)=>{const a=r.append("image");a.attr("x",t),a.attr("y",e);const n=o.sanitizeUrl(s);a.attr("xlink:href",n)},"drawImage"),g=i((r,t,e,s)=>{const a=r.append("use");a.attr("x",t),a.attr("y",e);const n=o.sanitizeUrl(s);a.attr("xlink:href",`#${n}`)},"drawEmbeddedImage"),h=i(()=>({x:0,y:0,width:100,height:100,fill:"#EDF2AE",stroke:"#666",anchor:"start",rx:0,ry:0}),"getNoteRect"),f=i(()=>({x:0,y:0,width:100,height:100,"text-anchor":"start",style:"#666",textMargin:0,rx:0,ry:0,tspan:!0}),"getTextObj"),w=i(()=>{let r=l(".mermaidTooltip");return r.empty()&&(r=l("body").append("div").attr("class","mermaidTooltip").style("opacity",0).style("position","absolute").style("text-align","center").style("max-width","200px").style("padding","2px").style("font-size","12px").style("background","#ffffde").style("border","1px solid #333").style("border-radius","2px").style("pointer-events","none").style("z-index","100")),r},"createTooltip");export{p as a,f as b,w as c,x as d,g as e,m as f,h as g,y as h};
|
||||
1
dist-desktop/assets/classDiagram-6PBFFD2Q-vWrFBNY5.js
Normal file
1
dist-desktop/assets/classDiagram-6PBFFD2Q-vWrFBNY5.js
Normal file
@@ -0,0 +1 @@
|
||||
import{s as a,c as s,a as e,C as t}from"./chunk-4TB4RGXK-D8YpqJC4.js";import{_ as i}from"./mermaid.core-DD7RPEfx.js";import"./chunk-FMBD7UC4-TatyaWoN.js";import"./chunk-YZCP3GAM-Blce-T0j.js";import"./chunk-55IACEB6-D6svk_zs.js";import"./chunk-EDXVE4YY-D2oMcFeR.js";import"./index-jkhvCyNw.js";var n={parser:e,get db(){return new t},renderer:s,styles:a,init:i(r=>{r.class||(r.class={}),r.class.arrowMarkerAbsolute=r.arrowMarkerAbsolute},"init")};export{n as diagram};
|
||||
1
dist-desktop/assets/classDiagram-v2-HSJHXN6E-vWrFBNY5.js
Normal file
1
dist-desktop/assets/classDiagram-v2-HSJHXN6E-vWrFBNY5.js
Normal file
@@ -0,0 +1 @@
|
||||
import{s as a,c as s,a as e,C as t}from"./chunk-4TB4RGXK-D8YpqJC4.js";import{_ as i}from"./mermaid.core-DD7RPEfx.js";import"./chunk-FMBD7UC4-TatyaWoN.js";import"./chunk-YZCP3GAM-Blce-T0j.js";import"./chunk-55IACEB6-D6svk_zs.js";import"./chunk-EDXVE4YY-D2oMcFeR.js";import"./index-jkhvCyNw.js";var n={parser:e,get db(){return new t},renderer:s,styles:a,init:i(r=>{r.class||(r.class={}),r.class.arrowMarkerAbsolute=r.arrowMarkerAbsolute},"init")};export{n as diagram};
|
||||
1
dist-desktop/assets/clone-DL0QTDot.js
Normal file
1
dist-desktop/assets/clone-DL0QTDot.js
Normal file
@@ -0,0 +1 @@
|
||||
import{b as r}from"./graph-C_fMmQpx.js";var e=4;function a(o){return r(o,e)}export{a as c};
|
||||
1
dist-desktop/assets/cose-bilkent-S5V4N54A-DA7lnOJ3.js
Normal file
1
dist-desktop/assets/cose-bilkent-S5V4N54A-DA7lnOJ3.js
Normal file
File diff suppressed because one or more lines are too long
331
dist-desktop/assets/cytoscape.esm-D_LviqZs.js
Normal file
331
dist-desktop/assets/cytoscape.esm-D_LviqZs.js
Normal file
File diff suppressed because one or more lines are too long
4
dist-desktop/assets/dagre-KV5264BT-BCic1E4j.js
Normal file
4
dist-desktop/assets/dagre-KV5264BT-BCic1E4j.js
Normal file
File diff suppressed because one or more lines are too long
1
dist-desktop/assets/defaultLocale-DX6XiGOO.js
Normal file
1
dist-desktop/assets/defaultLocale-DX6XiGOO.js
Normal file
@@ -0,0 +1 @@
|
||||
function J(n){return Math.abs(n=Math.round(n))>=1e21?n.toLocaleString("en").replace(/,/g,""):n.toString(10)}function j(n,t){if(!isFinite(n)||n===0)return null;var e=(n=t?n.toExponential(t-1):n.toExponential()).indexOf("e"),i=n.slice(0,e);return[i.length>1?i[0]+i.slice(2):i,+n.slice(e+1)]}function K(n){return n=j(Math.abs(n)),n?n[1]:NaN}function Q(n,t){return function(e,i){for(var o=e.length,a=[],c=0,h=n[0],M=0;o>0&&h>0&&(M+h+1>i&&(h=Math.max(1,i-M)),a.push(e.substring(o-=h,o+h)),!((M+=h+1)>i));)h=n[c=(c+1)%n.length];return a.reverse().join(t)}}function V(n){return function(t){return t.replace(/[0-9]/g,function(e){return n[+e]})}}var W=/^(?:(.)?([<>=^]))?([+\-( ])?([$#])?(0)?(\d+)?(,)?(\.\d+)?(~)?([a-z%])?$/i;function $(n){if(!(t=W.exec(n)))throw new Error("invalid format: "+n);var t;return new L({fill:t[1],align:t[2],sign:t[3],symbol:t[4],zero:t[5],width:t[6],comma:t[7],precision:t[8]&&t[8].slice(1),trim:t[9],type:t[10]})}$.prototype=L.prototype;function L(n){this.fill=n.fill===void 0?" ":n.fill+"",this.align=n.align===void 0?">":n.align+"",this.sign=n.sign===void 0?"-":n.sign+"",this.symbol=n.symbol===void 0?"":n.symbol+"",this.zero=!!n.zero,this.width=n.width===void 0?void 0:+n.width,this.comma=!!n.comma,this.precision=n.precision===void 0?void 0:+n.precision,this.trim=!!n.trim,this.type=n.type===void 0?"":n.type+""}L.prototype.toString=function(){return this.fill+this.align+this.sign+this.symbol+(this.zero?"0":"")+(this.width===void 0?"":Math.max(1,this.width|0))+(this.comma?",":"")+(this.precision===void 0?"":"."+Math.max(0,this.precision|0))+(this.trim?"~":"")+this.type};function _(n){n:for(var t=n.length,e=1,i=-1,o;e<t;++e)switch(n[e]){case".":i=o=e;break;case"0":i===0&&(i=e),o=e;break;default:if(!+n[e])break n;i>0&&(i=0);break}return i>0?n.slice(0,i)+n.slice(o+1):n}var N;function v(n,t){var e=j(n,t);if(!e)return N=void 0,n.toPrecision(t);var i=e[0],o=e[1],a=o-(N=Math.max(-8,Math.min(8,Math.floor(o/3)))*3)+1,c=i.length;return a===c?i:a>c?i+new Array(a-c+1).join("0"):a>0?i.slice(0,a)+"."+i.slice(a):"0."+new Array(1-a).join("0")+j(n,Math.max(0,t+a-1))[0]}function X(n,t){var e=j(n,t);if(!e)return n+"";var i=e[0],o=e[1];return o<0?"0."+new Array(-o).join("0")+i:i.length>o+1?i.slice(0,o+1)+"."+i.slice(o+1):i+new Array(o-i.length+2).join("0")}const O={"%":(n,t)=>(n*100).toFixed(t),b:n=>Math.round(n).toString(2),c:n=>n+"",d:J,e:(n,t)=>n.toExponential(t),f:(n,t)=>n.toFixed(t),g:(n,t)=>n.toPrecision(t),o:n=>Math.round(n).toString(8),p:(n,t)=>X(n*100,t),r:X,s:v,X:n=>Math.round(n).toString(16).toUpperCase(),x:n=>Math.round(n).toString(16)};function R(n){return n}var U=Array.prototype.map,Y=["y","z","a","f","p","n","µ","m","","k","M","G","T","P","E","Z","Y"];function nn(n){var t=n.grouping===void 0||n.thousands===void 0?R:Q(U.call(n.grouping,Number),n.thousands+""),e=n.currency===void 0?"":n.currency[0]+"",i=n.currency===void 0?"":n.currency[1]+"",o=n.decimal===void 0?".":n.decimal+"",a=n.numerals===void 0?R:V(U.call(n.numerals,String)),c=n.percent===void 0?"%":n.percent+"",h=n.minus===void 0?"−":n.minus+"",M=n.nan===void 0?"NaN":n.nan+"";function T(f,g){f=$(f);var b=f.fill,p=f.align,m=f.sign,w=f.symbol,S=f.zero,E=f.width,F=f.comma,y=f.precision,C=f.trim,d=f.type;d==="n"?(F=!0,d="g"):O[d]||(y===void 0&&(y=12),C=!0,d="g"),(S||b==="0"&&p==="=")&&(S=!0,b="0",p="=");var q=(g&&g.prefix!==void 0?g.prefix:"")+(w==="$"?e:w==="#"&&/[boxX]/.test(d)?"0"+d.toLowerCase():""),B=(w==="$"?i:/[%p]/.test(d)?c:"")+(g&&g.suffix!==void 0?g.suffix:""),D=O[d],H=/[defgprs%]/.test(d);y=y===void 0?6:/[gprs]/.test(d)?Math.max(1,Math.min(21,y)):Math.max(0,Math.min(20,y));function G(r){var l=q,u=B,x,I,k;if(d==="c")u=D(r)+u,r="";else{r=+r;var P=r<0||1/r<0;if(r=isNaN(r)?M:D(Math.abs(r),y),C&&(r=_(r)),P&&+r==0&&m!=="+"&&(P=!1),l=(P?m==="("?m:h:m==="-"||m==="("?"":m)+l,u=(d==="s"&&!isNaN(r)&&N!==void 0?Y[8+N/3]:"")+u+(P&&m==="("?")":""),H){for(x=-1,I=r.length;++x<I;)if(k=r.charCodeAt(x),48>k||k>57){u=(k===46?o+r.slice(x+1):r.slice(x))+u,r=r.slice(0,x);break}}}F&&!S&&(r=t(r,1/0));var z=l.length+r.length+u.length,s=z<E?new Array(E-z+1).join(b):"";switch(F&&S&&(r=t(s+r,s.length?E-u.length:1/0),s=""),p){case"<":r=l+r+u+s;break;case"=":r=l+s+r+u;break;case"^":r=s.slice(0,z=s.length>>1)+l+r+u+s.slice(z);break;default:r=s+l+r+u;break}return a(r)}return G.toString=function(){return f+""},G}function Z(f,g){var b=Math.max(-8,Math.min(8,Math.floor(K(g)/3)))*3,p=Math.pow(10,-b),m=T((f=$(f),f.type="f",f),{suffix:Y[8+b/3]});return function(w){return m(p*w)}}return{format:T,formatPrefix:Z}}var A,tn,rn;en({thousands:",",grouping:[3],currency:["$",""]});function en(n){return A=nn(n),tn=A.format,rn=A.formatPrefix,A}export{rn as a,tn as b,K as e,$ as f};
|
||||
10
dist-desktop/assets/diagram-5BDNPKRD-DXx24IDi.js
Normal file
10
dist-desktop/assets/diagram-5BDNPKRD-DXx24IDi.js
Normal file
@@ -0,0 +1,10 @@
|
||||
import{p as x,b as f,s as C,q as B,g as T,a as y,_ as s,F as u,l as k,I as V,e as _,D,G as N,z as S}from"./mermaid.core-DD7RPEfx.js";import{p as I}from"./chunk-4BX2VUAB-CI7enyPF.js";import{I as $}from"./chunk-QZHKN3VN-BFI20CEN.js";import{p as z}from"./wardley-RL74JXVD-BAks5CVw.js";import"./index-jkhvCyNw.js";import"./min-CxVBSoSd.js";import"./_baseUniq-D-yNBara.js";var d=new $(()=>({cnt:1,stack:[{id:0,level:-1,name:"/",children:[]}]})),A=s(()=>{d.reset(),S()},"clear"),X=s(()=>d.records.stack[0],"getRoot"),H=s(()=>d.records.cnt,"getCount"),L=N.treeView,R=s(()=>u(L,D().treeView),"getConfig"),W=s((e,t)=>{for(;e<=d.records.stack[d.records.stack.length-1].level;)d.records.stack.pop();const a={id:d.records.cnt++,level:e,name:t,children:[]};d.records.stack[d.records.stack.length-1].children.push(a),d.records.stack.push(a)},"addNode"),E={clear:A,addNode:W,getRoot:X,getCount:H,getConfig:R,getAccTitle:y,getAccDescription:T,getDiagramTitle:B,setAccDescription:C,setAccTitle:f,setDiagramTitle:x},w=E,F=s(e=>{I(e,w),e.nodes.map(t=>w.addNode(t.indent?parseInt(t.indent):0,t.name))},"populate"),M={parse:s(async e=>{const t=await z("treeView",e);k.debug(t),F(t)},"parse")},Y=s((e,t,a,n,o)=>{const c=n.append("text").text(a.name).attr("dominant-baseline","middle").attr("class","treeView-node-label"),{height:p,width:r}=c.node().getBBox(),l=p+o.paddingY*2,i=r+o.paddingX*2;c.attr("x",e+o.paddingX),c.attr("y",t+l/2),a.BBox={x:e,y:t,width:i,height:l}},"positionLabel"),b=s((e,t,a,n,o,c)=>e.append("line").attr("x1",t).attr("y1",a).attr("x2",n).attr("y2",o).attr("stroke-width",c).attr("class","treeView-node-line"),"positionLine"),q=s((e,t,a)=>{let n=0,o=0;const c=s((r,l,i,h)=>{const v=h*(i.rowIndent+i.paddingX);Y(v,n,l,r,i);const{height:g,width:m}=l.BBox;b(r,v-i.rowIndent,n+g/2,v,n+g/2,i.lineThickness),o=Math.max(o,v+m),n+=g},"drawNode"),p=s((r,l=0)=>{c(e,r,a,l),r.children.forEach(g=>{p(g,l+1)});const{x:i,y:h,height:v}=r.BBox;if(r.children.length){const{y:g,height:m}=r.children[r.children.length-1].BBox;b(e,i+a.paddingX,h+v,i+a.paddingX,g+m/2+a.lineThickness/2,a.lineThickness)}},"processNode");return p(t),{totalHeight:n,totalWidth:o}},"drawTree"),G=s((e,t,a,n)=>{k.debug(`Rendering treeView diagram
|
||||
`+e);const o=n.db,c=o.getRoot(),p=o.getConfig(),r=V(t),l=r.append("g");l.attr("class","tree-view");const{totalHeight:i,totalWidth:h}=q(l,c,p);r.attr("viewBox",`-${p.lineThickness/2} 0 ${h} ${i}`),_(r,i,h,p.useMaxWidth)},"draw"),j={draw:G},J=j,K={labelFontSize:"16px",labelColor:"black",lineColor:"black"},O=s(({treeView:e})=>{const{labelFontSize:t,labelColor:a,lineColor:n}=u(K,e);return`
|
||||
.treeView-node-label {
|
||||
font-size: ${t};
|
||||
fill: ${a};
|
||||
}
|
||||
.treeView-node-line {
|
||||
stroke: ${n};
|
||||
}
|
||||
`},"styles"),P=O,se={db:w,renderer:J,parser:M,styles:P};export{se as diagram};
|
||||
24
dist-desktop/assets/diagram-G4DWMVQ6-sxZR-OqR.js
Normal file
24
dist-desktop/assets/diagram-G4DWMVQ6-sxZR-OqR.js
Normal file
File diff suppressed because one or more lines are too long
43
dist-desktop/assets/diagram-MMDJMWI5-CSpsjX4P.js
Normal file
43
dist-desktop/assets/diagram-MMDJMWI5-CSpsjX4P.js
Normal file
@@ -0,0 +1,43 @@
|
||||
import{s as k,g as I,q as R,p as F,a as _,b as D,_ as l,I as E,z,F as y,G,D as C,l as P,K as W,e as B}from"./mermaid.core-DD7RPEfx.js";import{p as V}from"./chunk-4BX2VUAB-CI7enyPF.js";import{p as H}from"./wardley-RL74JXVD-BAks5CVw.js";import"./index-jkhvCyNw.js";import"./min-CxVBSoSd.js";import"./_baseUniq-D-yNBara.js";var x={showLegend:!0,ticks:5,max:null,min:0,graticule:"circle"},w={axes:[],curves:[],options:x},m=structuredClone(w),j=G.radar,q=l(()=>y({...j,...C().radar}),"getConfig"),b=l(()=>m.axes,"getAxes"),K=l(()=>m.curves,"getCurves"),N=l(()=>m.options,"getOptions"),U=l(a=>{m.axes=a.map(t=>({name:t.name,label:t.label??t.name}))},"setAxes"),X=l(a=>{m.curves=a.map(t=>({name:t.name,label:t.label??t.name,entries:Y(t.entries)}))},"setCurves"),Y=l(a=>{if(a[0].axis==null)return a.map(e=>e.value);const t=b();if(t.length===0)throw new Error("Axes must be populated before curves for reference entries");return t.map(e=>{const r=a.find(s=>{var o;return((o=s.axis)==null?void 0:o.$refText)===e.name});if(r===void 0)throw new Error("Missing entry for axis "+e.label);return r.value})},"computeCurveEntries"),Z=l(a=>{var e,r,s,o,i;const t=a.reduce((n,c)=>(n[c.name]=c,n),{});m.options={showLegend:((e=t.showLegend)==null?void 0:e.value)??x.showLegend,ticks:((r=t.ticks)==null?void 0:r.value)??x.ticks,max:((s=t.max)==null?void 0:s.value)??x.max,min:((o=t.min)==null?void 0:o.value)??x.min,graticule:((i=t.graticule)==null?void 0:i.value)??x.graticule}},"setOptions"),J=l(()=>{z(),m=structuredClone(w)},"clear"),$={getAxes:b,getCurves:K,getOptions:N,setAxes:U,setCurves:X,setOptions:Z,getConfig:q,clear:J,setAccTitle:D,getAccTitle:_,setDiagramTitle:F,getDiagramTitle:R,getAccDescription:I,setAccDescription:k},Q=l(a=>{V(a,$);const{axes:t,curves:e,options:r}=a;$.setAxes(t),$.setCurves(e),$.setOptions(r)},"populate"),tt={parse:l(async a=>{const t=await H("radar",a);P.debug(t),Q(t)},"parse")},et=l((a,t,e,r)=>{const s=r.db,o=s.getAxes(),i=s.getCurves(),n=s.getOptions(),c=s.getConfig(),d=s.getDiagramTitle(),p=E(t),u=at(p,c),g=n.max??Math.max(...i.map(f=>Math.max(...f.entries))),h=n.min,v=Math.min(c.width,c.height)/2;rt(u,o,v,n.ticks,n.graticule),st(u,o,v,c),M(u,o,i,h,g,n.graticule,c),T(u,i,n.showLegend,c),u.append("text").attr("class","radarTitle").text(d).attr("x",0).attr("y",-c.height/2-c.marginTop)},"draw"),at=l((a,t)=>{const e=t.width+t.marginLeft+t.marginRight,r=t.height+t.marginTop+t.marginBottom,s={x:t.marginLeft+t.width/2,y:t.marginTop+t.height/2};return B(a,r,e,t.useMaxWidth??!0),a.attr("viewBox",`0 0 ${e} ${r}`),a.append("g").attr("transform",`translate(${s.x}, ${s.y})`)},"drawFrame"),rt=l((a,t,e,r,s)=>{if(s==="circle")for(let o=0;o<r;o++){const i=e*(o+1)/r;a.append("circle").attr("r",i).attr("class","radarGraticule")}else if(s==="polygon"){const o=t.length;for(let i=0;i<r;i++){const n=e*(i+1)/r,c=t.map((d,p)=>{const u=2*p*Math.PI/o-Math.PI/2,g=n*Math.cos(u),h=n*Math.sin(u);return`${g},${h}`}).join(" ");a.append("polygon").attr("points",c).attr("class","radarGraticule")}}},"drawGraticule"),st=l((a,t,e,r)=>{const s=t.length;for(let o=0;o<s;o++){const i=t[o].label,n=2*o*Math.PI/s-Math.PI/2;a.append("line").attr("x1",0).attr("y1",0).attr("x2",e*r.axisScaleFactor*Math.cos(n)).attr("y2",e*r.axisScaleFactor*Math.sin(n)).attr("class","radarAxisLine"),a.append("text").text(i).attr("x",e*r.axisLabelFactor*Math.cos(n)).attr("y",e*r.axisLabelFactor*Math.sin(n)).attr("class","radarAxisLabel")}},"drawAxes");function M(a,t,e,r,s,o,i){const n=t.length,c=Math.min(i.width,i.height)/2;e.forEach((d,p)=>{if(d.entries.length!==n)return;const u=d.entries.map((g,h)=>{const v=2*Math.PI*h/n-Math.PI/2,f=A(g,r,s,c),S=f*Math.cos(v),O=f*Math.sin(v);return{x:S,y:O}});o==="circle"?a.append("path").attr("d",L(u,i.curveTension)).attr("class",`radarCurve-${p}`):o==="polygon"&&a.append("polygon").attr("points",u.map(g=>`${g.x},${g.y}`).join(" ")).attr("class",`radarCurve-${p}`)})}l(M,"drawCurves");function A(a,t,e,r){const s=Math.min(Math.max(a,t),e);return r*(s-t)/(e-t)}l(A,"relativeRadius");function L(a,t){const e=a.length;let r=`M${a[0].x},${a[0].y}`;for(let s=0;s<e;s++){const o=a[(s-1+e)%e],i=a[s],n=a[(s+1)%e],c=a[(s+2)%e],d={x:i.x+(n.x-o.x)*t,y:i.y+(n.y-o.y)*t},p={x:n.x-(c.x-i.x)*t,y:n.y-(c.y-i.y)*t};r+=` C${d.x},${d.y} ${p.x},${p.y} ${n.x},${n.y}`}return`${r} Z`}l(L,"closedRoundCurve");function T(a,t,e,r){if(!e)return;const s=(r.width/2+r.marginRight)*3/4,o=-(r.height/2+r.marginTop)*3/4,i=20;t.forEach((n,c)=>{const d=a.append("g").attr("transform",`translate(${s}, ${o+c*i})`);d.append("rect").attr("width",12).attr("height",12).attr("class",`radarLegendBox-${c}`),d.append("text").attr("x",16).attr("y",0).attr("class","radarLegendText").text(n.label)})}l(T,"drawLegend");var nt={draw:et},ot=l((a,t)=>{let e="";for(let r=0;r<a.THEME_COLOR_LIMIT;r++){const s=a[`cScale${r}`];e+=`
|
||||
.radarCurve-${r} {
|
||||
color: ${s};
|
||||
fill: ${s};
|
||||
fill-opacity: ${t.curveOpacity};
|
||||
stroke: ${s};
|
||||
stroke-width: ${t.curveStrokeWidth};
|
||||
}
|
||||
.radarLegendBox-${r} {
|
||||
fill: ${s};
|
||||
fill-opacity: ${t.curveOpacity};
|
||||
stroke: ${s};
|
||||
}
|
||||
`}return e},"genIndexStyles"),it=l(a=>{const t=W(),e=C(),r=y(t,e.themeVariables),s=y(r.radar,a);return{themeVariables:r,radarOptions:s}},"buildRadarStyleOptions"),lt=l(({radar:a}={})=>{const{themeVariables:t,radarOptions:e}=it(a);return`
|
||||
.radarTitle {
|
||||
font-size: ${t.fontSize};
|
||||
color: ${t.titleColor};
|
||||
dominant-baseline: hanging;
|
||||
text-anchor: middle;
|
||||
}
|
||||
.radarAxisLine {
|
||||
stroke: ${e.axisColor};
|
||||
stroke-width: ${e.axisStrokeWidth};
|
||||
}
|
||||
.radarAxisLabel {
|
||||
dominant-baseline: middle;
|
||||
text-anchor: middle;
|
||||
font-size: ${e.axisLabelFontSize}px;
|
||||
color: ${e.axisColor};
|
||||
}
|
||||
.radarGraticule {
|
||||
fill: ${e.graticuleColor};
|
||||
fill-opacity: ${e.graticuleOpacity};
|
||||
stroke: ${e.graticuleColor};
|
||||
stroke-width: ${e.graticuleStrokeWidth};
|
||||
}
|
||||
.radarLegendText {
|
||||
text-anchor: start;
|
||||
font-size: ${e.legendFontSize}px;
|
||||
dominant-baseline: hanging;
|
||||
}
|
||||
${ot(t,e)}
|
||||
`},"styles"),xt={parser:tt,db:$,renderer:nt,styles:lt};export{xt as diagram};
|
||||
24
dist-desktop/assets/diagram-TYMM5635-B1FX1-Vv.js
Normal file
24
dist-desktop/assets/diagram-TYMM5635-B1FX1-Vv.js
Normal file
@@ -0,0 +1,24 @@
|
||||
import{_ as b,F as m,I as B,e as C,l as w,b as S,a as D,p as T,q as z,g as F,s as P,G as E,D as A,z as W}from"./mermaid.core-DD7RPEfx.js";import{p as _}from"./chunk-4BX2VUAB-CI7enyPF.js";import{p as N}from"./wardley-RL74JXVD-BAks5CVw.js";import"./index-jkhvCyNw.js";import"./min-CxVBSoSd.js";import"./_baseUniq-D-yNBara.js";var I=E.packet,u,v=(u=class{constructor(){this.packet=[],this.setAccTitle=S,this.getAccTitle=D,this.setDiagramTitle=T,this.getDiagramTitle=z,this.getAccDescription=F,this.setAccDescription=P}getConfig(){const t=m({...I,...A().packet});return t.showBits&&(t.paddingY+=10),t}getPacket(){return this.packet}pushWord(t){t.length>0&&this.packet.push(t)}clear(){W(),this.packet=[]}},b(u,"PacketDB"),u),L=1e4,M=b((e,t)=>{_(e,t);let o=-1,r=[],n=1;const{bitsPerRow:l}=t.getConfig();for(let{start:a,end:i,bits:d,label:c}of e.blocks){if(a!==void 0&&i!==void 0&&i<a)throw new Error(`Packet block ${a} - ${i} is invalid. End must be greater than start.`);if(a??(a=o+1),a!==o+1)throw new Error(`Packet block ${a} - ${i??a} is not contiguous. It should start from ${o+1}.`);if(d===0)throw new Error(`Packet block ${a} is invalid. Cannot have a zero bit field.`);for(i??(i=a+(d??1)-1),d??(d=i-a+1),o=i,w.debug(`Packet block ${a} - ${o} with label ${c}`);r.length<=l+1&&t.getPacket().length<L;){const[p,s]=Y({start:a,end:i,bits:d,label:c},n,l);if(r.push(p),p.end+1===n*l&&(t.pushWord(r),r=[],n++),!s)break;({start:a,end:i,bits:d,label:c}=s)}}t.pushWord(r)},"populate"),Y=b((e,t,o)=>{if(e.start===void 0)throw new Error("start should have been set during first phase");if(e.end===void 0)throw new Error("end should have been set during first phase");if(e.start>e.end)throw new Error(`Block start ${e.start} is greater than block end ${e.end}.`);if(e.end+1<=t*o)return[e,void 0];const r=t*o-1,n=t*o;return[{start:e.start,end:r,label:e.label,bits:r-e.start},{start:n,end:e.end,label:e.label,bits:e.end-n}]},"getNextFittingBlock"),x={parser:{yy:void 0},parse:b(async e=>{var r;const t=await N("packet",e),o=(r=x.parser)==null?void 0:r.yy;if(!(o instanceof v))throw new Error("parser.parser?.yy was not a PacketDB. This is due to a bug within Mermaid, please report this issue at https://github.com/mermaid-js/mermaid/issues.");w.debug(t),M(t,o)},"parse")},G=b((e,t,o,r)=>{const n=r.db,l=n.getConfig(),{rowHeight:a,paddingY:i,bitWidth:d,bitsPerRow:c}=l,p=n.getPacket(),s=n.getDiagramTitle(),h=a+i,g=h*(p.length+1)-(s?0:a),k=d*c+2,f=B(t);f.attr("viewBox",`0 0 ${k} ${g}`),C(f,g,k,l.useMaxWidth);for(const[y,$]of p.entries())O(f,$,y,l);f.append("text").text(s).attr("x",k/2).attr("y",g-h/2).attr("dominant-baseline","middle").attr("text-anchor","middle").attr("class","packetTitle")},"draw"),O=b((e,t,o,{rowHeight:r,paddingX:n,paddingY:l,bitWidth:a,bitsPerRow:i,showBits:d})=>{const c=e.append("g"),p=o*(r+l)+l;for(const s of t){const h=s.start%i*a+1,g=(s.end-s.start+1)*a-n;if(c.append("rect").attr("x",h).attr("y",p).attr("width",g).attr("height",r).attr("class","packetBlock"),c.append("text").attr("x",h+g/2).attr("y",p+r/2).attr("class","packetLabel").attr("dominant-baseline","middle").attr("text-anchor","middle").text(s.label),!d)continue;const k=s.end===s.start,f=p-2;c.append("text").attr("x",h+(k?g/2:0)).attr("y",f).attr("class","packetByte start").attr("dominant-baseline","auto").attr("text-anchor",k?"middle":"start").text(s.start),k||c.append("text").attr("x",h+g).attr("y",f).attr("class","packetByte end").attr("dominant-baseline","auto").attr("text-anchor","end").text(s.end)}},"drawWord"),j={draw:G},q={byteFontSize:"10px",startByteColor:"black",endByteColor:"black",labelColor:"black",labelFontSize:"12px",titleColor:"black",titleFontSize:"14px",blockStrokeColor:"black",blockStrokeWidth:"1",blockFillColor:"#efefef"},H=b(({packet:e}={})=>{const t=m(q,e);return`
|
||||
.packetByte {
|
||||
font-size: ${t.byteFontSize};
|
||||
}
|
||||
.packetByte.start {
|
||||
fill: ${t.startByteColor};
|
||||
}
|
||||
.packetByte.end {
|
||||
fill: ${t.endByteColor};
|
||||
}
|
||||
.packetLabel {
|
||||
fill: ${t.labelColor};
|
||||
font-size: ${t.labelFontSize};
|
||||
}
|
||||
.packetTitle {
|
||||
fill: ${t.titleColor};
|
||||
font-size: ${t.titleFontSize};
|
||||
}
|
||||
.packetBlock {
|
||||
stroke: ${t.blockStrokeColor};
|
||||
stroke-width: ${t.blockStrokeWidth};
|
||||
fill: ${t.blockFillColor};
|
||||
}
|
||||
`},"styles"),V={parser:x,get db(){return new v},renderer:j,styles:H};export{V as diagram};
|
||||
85
dist-desktop/assets/erDiagram-SMLLAGMA-DxMlnAr7.js
Normal file
85
dist-desktop/assets/erDiagram-SMLLAGMA-DxMlnAr7.js
Normal file
File diff suppressed because one or more lines are too long
162
dist-desktop/assets/flowDiagram-DWJPFMVM-DI9fej45.js
Normal file
162
dist-desktop/assets/flowDiagram-DWJPFMVM-DI9fej45.js
Normal file
File diff suppressed because one or more lines are too long
292
dist-desktop/assets/ganttDiagram-T4ZO3ILL-ByyPQG8e.js
Normal file
292
dist-desktop/assets/ganttDiagram-T4ZO3ILL-ByyPQG8e.js
Normal file
File diff suppressed because one or more lines are too long
106
dist-desktop/assets/gitGraphDiagram-UUTBAWPF-iyZ_JHIV.js
Normal file
106
dist-desktop/assets/gitGraphDiagram-UUTBAWPF-iyZ_JHIV.js
Normal file
File diff suppressed because one or more lines are too long
1
dist-desktop/assets/graph-C_fMmQpx.js
Normal file
1
dist-desktop/assets/graph-C_fMmQpx.js
Normal file
File diff suppressed because one or more lines are too long
1
dist-desktop/assets/index-DCC3CA10.css
Normal file
1
dist-desktop/assets/index-DCC3CA10.css
Normal file
File diff suppressed because one or more lines are too long
384
dist-desktop/assets/index-jkhvCyNw.js
Normal file
384
dist-desktop/assets/index-jkhvCyNw.js
Normal file
File diff suppressed because one or more lines are too long
2
dist-desktop/assets/infoDiagram-42DDH7IO-69l94V-f.js
Normal file
2
dist-desktop/assets/infoDiagram-42DDH7IO-69l94V-f.js
Normal file
@@ -0,0 +1,2 @@
|
||||
import{_ as a,l as s,I as n,e as i}from"./mermaid.core-DD7RPEfx.js";import{p}from"./wardley-RL74JXVD-BAks5CVw.js";import"./index-jkhvCyNw.js";import"./min-CxVBSoSd.js";import"./_baseUniq-D-yNBara.js";var g={parse:a(async r=>{const e=await p("info",r);s.debug(e)},"parse")},v={version:"11.14.0"},d=a(()=>v.version,"getVersion"),m={getVersion:d},c=a((r,e,o)=>{s.debug(`rendering info diagram
|
||||
`+r);const t=n(e);i(t,100,400,!0),t.append("g").append("text").attr("x",100).attr("y",40).attr("class","version").attr("font-size",32).style("text-anchor","middle").text(`v${o}`)},"draw"),l={draw:c},y={parser:g,db:m,renderer:l};export{y as diagram};
|
||||
1
dist-desktop/assets/init-Gi6I4Gst.js
Normal file
1
dist-desktop/assets/init-Gi6I4Gst.js
Normal file
@@ -0,0 +1 @@
|
||||
function t(e,a){switch(arguments.length){case 0:break;case 1:this.range(e);break;default:this.range(a).domain(e);break}return this}export{t as i};
|
||||
70
dist-desktop/assets/ishikawaDiagram-UXIWVN3A-BjC5Jacw.js
Normal file
70
dist-desktop/assets/ishikawaDiagram-UXIWVN3A-BjC5Jacw.js
Normal file
File diff suppressed because one or more lines are too long
139
dist-desktop/assets/journeyDiagram-VCZTEJTY-CsFlB-4a.js
Normal file
139
dist-desktop/assets/journeyDiagram-VCZTEJTY-CsFlB-4a.js
Normal file
File diff suppressed because one or more lines are too long
89
dist-desktop/assets/kanban-definition-6JOO6SKY-CBgE3YZo.js
Normal file
89
dist-desktop/assets/kanban-definition-6JOO6SKY-CBgE3YZo.js
Normal file
File diff suppressed because one or more lines are too long
1
dist-desktop/assets/layout-CBIttl_i.js
Normal file
1
dist-desktop/assets/layout-CBIttl_i.js
Normal file
File diff suppressed because one or more lines are too long
1
dist-desktop/assets/linear-BIZ8jh0J.js
Normal file
1
dist-desktop/assets/linear-BIZ8jh0J.js
Normal file
File diff suppressed because one or more lines are too long
309
dist-desktop/assets/mermaid.core-DD7RPEfx.js
Normal file
309
dist-desktop/assets/mermaid.core-DD7RPEfx.js
Normal file
File diff suppressed because one or more lines are too long
1
dist-desktop/assets/min-CxVBSoSd.js
Normal file
1
dist-desktop/assets/min-CxVBSoSd.js
Normal file
@@ -0,0 +1 @@
|
||||
import{b,a as m,c as d,d as h,i as l}from"./_baseUniq-D-yNBara.js";import{aG as g,aH as o,aI as p}from"./mermaid.core-DD7RPEfx.js";function L(a){var n=a==null?0:a.length;return n?b(a):[]}function v(a,n){var s=-1,t=g(a)?Array(a.length):[];return m(a,function(f,i,e){t[++s]=n(f,i,e)}),t}function M(a,n){var s=o(a)?h:v;return s(a,d(n))}function x(a,n){return a<n}function A(a,n,s){for(var t=-1,f=a.length;++t<f;){var i=a[t],e=n(i);if(e!=null&&(r===void 0?e===e&&!l(e):s(e,r)))var r=e,u=i}return u}function k(a){return a&&a.length?A(a,p,x):void 0}export{x as a,A as b,v as c,k as d,L as f,M as m};
|
||||
96
dist-desktop/assets/mindmap-definition-QFDTVHPH-DJZtV1tM.js
Normal file
96
dist-desktop/assets/mindmap-definition-QFDTVHPH-DJZtV1tM.js
Normal file
File diff suppressed because one or more lines are too long
1
dist-desktop/assets/ordinal-Cboi1Yqb.js
Normal file
1
dist-desktop/assets/ordinal-Cboi1Yqb.js
Normal file
@@ -0,0 +1 @@
|
||||
import{i as a}from"./init-Gi6I4Gst.js";class o extends Map{constructor(n,t=g){if(super(),Object.defineProperties(this,{_intern:{value:new Map},_key:{value:t}}),n!=null)for(const[r,s]of n)this.set(r,s)}get(n){return super.get(c(this,n))}has(n){return super.has(c(this,n))}set(n,t){return super.set(l(this,n),t)}delete(n){return super.delete(p(this,n))}}function c({_intern:e,_key:n},t){const r=n(t);return e.has(r)?e.get(r):t}function l({_intern:e,_key:n},t){const r=n(t);return e.has(r)?e.get(r):(e.set(r,t),t)}function p({_intern:e,_key:n},t){const r=n(t);return e.has(r)&&(t=e.get(r),e.delete(r)),t}function g(e){return e!==null&&typeof e=="object"?e.valueOf():e}const f=Symbol("implicit");function h(){var e=new o,n=[],t=[],r=f;function s(u){let i=e.get(u);if(i===void 0){if(r!==f)return r;e.set(u,i=n.push(u)-1)}return t[i%t.length]}return s.domain=function(u){if(!arguments.length)return n.slice();n=[],e=new o;for(const i of u)e.has(i)||e.set(i,n.push(i)-1);return s},s.range=function(u){return arguments.length?(t=Array.from(u),s):t.slice()},s.unknown=function(u){return arguments.length?(r=u,s):r},s.copy=function(){return h(n,t).unknown(r)},a.apply(s,arguments),s}export{h as o};
|
||||
30
dist-desktop/assets/pieDiagram-DEJITSTG-BuuhnNxp.js
Normal file
30
dist-desktop/assets/pieDiagram-DEJITSTG-BuuhnNxp.js
Normal file
@@ -0,0 +1,30 @@
|
||||
import{a4 as S,a7 as R,aE as Q,g as Y,s as tt,a as et,b as at,q as rt,p as nt,_ as p,l as z,c as it,F as st,I as ot,N as lt,e as ct,z as ut,G as pt}from"./mermaid.core-DD7RPEfx.js";import{p as gt}from"./chunk-4BX2VUAB-CI7enyPF.js";import{p as dt}from"./wardley-RL74JXVD-BAks5CVw.js";import{d as _}from"./arc-CZ0PsMQW.js";import{o as ft}from"./ordinal-Cboi1Yqb.js";import"./index-jkhvCyNw.js";import"./min-CxVBSoSd.js";import"./_baseUniq-D-yNBara.js";import"./init-Gi6I4Gst.js";function ht(t,a){return a<t?-1:a>t?1:a>=t?0:NaN}function mt(t){return t}function vt(){var t=mt,a=ht,f=null,y=S(0),s=S(R),g=S(0);function o(e){var n,l=(e=Q(e)).length,d,h,v=0,c=new Array(l),i=new Array(l),x=+y.apply(this,arguments),w=Math.min(R,Math.max(-R,s.apply(this,arguments)-x)),m,D=Math.min(Math.abs(w)/l,g.apply(this,arguments)),$=D*(w<0?-1:1),u;for(n=0;n<l;++n)(u=i[c[n]=n]=+t(e[n],n,e))>0&&(v+=u);for(a!=null?c.sort(function(A,C){return a(i[A],i[C])}):f!=null&&c.sort(function(A,C){return f(e[A],e[C])}),n=0,h=v?(w-l*$)/v:0;n<l;++n,x=m)d=c[n],u=i[d],m=x+(u>0?u*h:0)+$,i[d]={data:e[d],index:n,value:u,startAngle:x,endAngle:m,padAngle:D};return i}return o.value=function(e){return arguments.length?(t=typeof e=="function"?e:S(+e),o):t},o.sortValues=function(e){return arguments.length?(a=e,f=null,o):a},o.sort=function(e){return arguments.length?(f=e,a=null,o):f},o.startAngle=function(e){return arguments.length?(y=typeof e=="function"?e:S(+e),o):y},o.endAngle=function(e){return arguments.length?(s=typeof e=="function"?e:S(+e),o):s},o.padAngle=function(e){return arguments.length?(g=typeof e=="function"?e:S(+e),o):g},o}var xt=pt.pie,F={sections:new Map,showData:!1},T=F.sections,W=F.showData,St=structuredClone(xt),yt=p(()=>structuredClone(St),"getConfig"),wt=p(()=>{T=new Map,W=F.showData,ut()},"clear"),At=p(({label:t,value:a})=>{if(a<0)throw new Error(`"${t}" has invalid value: ${a}. Negative values are not allowed in pie charts. All slice values must be >= 0.`);T.has(t)||(T.set(t,a),z.debug(`added new section: ${t}, with value: ${a}`))},"addSection"),Ct=p(()=>T,"getSections"),Dt=p(t=>{W=t},"setShowData"),$t=p(()=>W,"getShowData"),V={getConfig:yt,clear:wt,setDiagramTitle:nt,getDiagramTitle:rt,setAccTitle:at,getAccTitle:et,setAccDescription:tt,getAccDescription:Y,addSection:At,getSections:Ct,setShowData:Dt,getShowData:$t},Tt=p((t,a)=>{gt(t,a),a.setShowData(t.showData),t.sections.map(a.addSection)},"populateDb"),Et={parse:p(async t=>{const a=await dt("pie",t);z.debug(a),Tt(a,V)},"parse")},bt=p(t=>`
|
||||
.pieCircle{
|
||||
stroke: ${t.pieStrokeColor};
|
||||
stroke-width : ${t.pieStrokeWidth};
|
||||
opacity : ${t.pieOpacity};
|
||||
}
|
||||
.pieOuterCircle{
|
||||
stroke: ${t.pieOuterStrokeColor};
|
||||
stroke-width: ${t.pieOuterStrokeWidth};
|
||||
fill: none;
|
||||
}
|
||||
.pieTitleText {
|
||||
text-anchor: middle;
|
||||
font-size: ${t.pieTitleTextSize};
|
||||
fill: ${t.pieTitleTextColor};
|
||||
font-family: ${t.fontFamily};
|
||||
}
|
||||
.slice {
|
||||
font-family: ${t.fontFamily};
|
||||
fill: ${t.pieSectionTextColor};
|
||||
font-size:${t.pieSectionTextSize};
|
||||
// fill: white;
|
||||
}
|
||||
.legend text {
|
||||
fill: ${t.pieLegendTextColor};
|
||||
font-family: ${t.fontFamily};
|
||||
font-size: ${t.pieLegendTextSize};
|
||||
}
|
||||
`,"getStyles"),kt=bt,Mt=p(t=>{const a=[...t.values()].reduce((s,g)=>s+g,0),f=[...t.entries()].map(([s,g])=>({label:s,value:g})).filter(s=>s.value/a*100>=1);return vt().value(s=>s.value).sort(null)(f)},"createPieArcs"),Rt=p((t,a,f,y)=>{var O;z.debug(`rendering pie chart
|
||||
`+t);const s=y.db,g=it(),o=st(s.getConfig(),g.pie),e=40,n=18,l=4,d=450,h=d,v=ot(a),c=v.append("g");c.attr("transform","translate("+h/2+","+d/2+")");const{themeVariables:i}=g;let[x]=lt(i.pieOuterStrokeWidth);x??(x=2);const w=o.textPosition,m=Math.min(h,d)/2-e,D=_().innerRadius(0).outerRadius(m),$=_().innerRadius(m*w).outerRadius(m*w);c.append("circle").attr("cx",0).attr("cy",0).attr("r",m+x/2).attr("class","pieOuterCircle");const u=s.getSections(),A=Mt(u),C=[i.pie1,i.pie2,i.pie3,i.pie4,i.pie5,i.pie6,i.pie7,i.pie8,i.pie9,i.pie10,i.pie11,i.pie12];let E=0;u.forEach(r=>{E+=r});const G=A.filter(r=>(r.data.value/E*100).toFixed(0)!=="0"),b=ft(C).domain([...u.keys()]);c.selectAll("mySlices").data(G).enter().append("path").attr("d",D).attr("fill",r=>b(r.data.label)).attr("class","pieCircle"),c.selectAll("mySlices").data(G).enter().append("text").text(r=>(r.data.value/E*100).toFixed(0)+"%").attr("transform",r=>"translate("+$.centroid(r)+")").style("text-anchor","middle").attr("class","slice");const U=c.append("text").text(s.getDiagramTitle()).attr("x",0).attr("y",-400/2).attr("class","pieTitleText"),N=[...u.entries()].map(([r,M])=>({label:r,value:M})),k=c.selectAll(".legend").data(N).enter().append("g").attr("class","legend").attr("transform",(r,M)=>{const P=n+l,H=P*N.length/2,J=12*n,K=M*P-H;return"translate("+J+","+K+")"});k.append("rect").attr("width",n).attr("height",n).style("fill",r=>b(r.label)).style("stroke",r=>b(r.label)),k.append("text").attr("x",n+l).attr("y",n-l).text(r=>s.getShowData()?`${r.label} [${r.value}]`:r.label);const j=Math.max(...k.selectAll("text").nodes().map(r=>(r==null?void 0:r.getBoundingClientRect().width)??0)),q=h+e+n+l+j,L=((O=U.node())==null?void 0:O.getBoundingClientRect().width)??0,X=h/2-L/2,Z=h/2+L/2,B=Math.min(0,X),I=Math.max(q,Z)-B;v.attr("viewBox",`${B} 0 ${I} ${d}`),ct(v,d,I,o.useMaxWidth)},"draw"),zt={draw:Rt},Vt={parser:Et,db:V,renderer:zt,styles:kt};export{Vt as diagram};
|
||||
7
dist-desktop/assets/quadrantDiagram-34T5L4WZ-B6g9ZuX5.js
Normal file
7
dist-desktop/assets/quadrantDiagram-34T5L4WZ-B6g9ZuX5.js
Normal file
File diff suppressed because one or more lines are too long
84
dist-desktop/assets/requirementDiagram-MS252O5E-CHmLf7zu.js
Normal file
84
dist-desktop/assets/requirementDiagram-MS252O5E-CHmLf7zu.js
Normal file
File diff suppressed because one or more lines are too long
10
dist-desktop/assets/sankeyDiagram-XADWPNL6-kanRK42m.js
Normal file
10
dist-desktop/assets/sankeyDiagram-XADWPNL6-kanRK42m.js
Normal file
File diff suppressed because one or more lines are too long
157
dist-desktop/assets/sequenceDiagram-FGHM5R23-C8ahaLpL.js
Normal file
157
dist-desktop/assets/sequenceDiagram-FGHM5R23-C8ahaLpL.js
Normal file
File diff suppressed because one or more lines are too long
1
dist-desktop/assets/stateDiagram-FHFEXIEX-zPv0f0M3.js
Normal file
1
dist-desktop/assets/stateDiagram-FHFEXIEX-zPv0f0M3.js
Normal file
File diff suppressed because one or more lines are too long
1
dist-desktop/assets/stateDiagram-v2-QKLJ7IA2-BHZ9r_Vp.js
Normal file
1
dist-desktop/assets/stateDiagram-v2-QKLJ7IA2-BHZ9r_Vp.js
Normal file
@@ -0,0 +1 @@
|
||||
import{s as e,b as r,a,S as s}from"./chunk-OYMX7WX6-Bgc90t5_.js";import{_ as i}from"./mermaid.core-DD7RPEfx.js";import"./chunk-55IACEB6-D6svk_zs.js";import"./chunk-EDXVE4YY-D2oMcFeR.js";import"./index-jkhvCyNw.js";var p={parser:a,get db(){return new s(2)},renderer:r,styles:e,init:i(t=>{t.state||(t.state={}),t.state.arrowMarkerAbsolute=t.arrowMarkerAbsolute},"init")};export{p as diagram};
|
||||
120
dist-desktop/assets/timeline-definition-GMOUNBTQ-zynpC_2V.js
Normal file
120
dist-desktop/assets/timeline-definition-GMOUNBTQ-zynpC_2V.js
Normal file
File diff suppressed because one or more lines are too long
34
dist-desktop/assets/vennDiagram-DHZGUBPP-BP7e_Dyb.js
Normal file
34
dist-desktop/assets/vennDiagram-DHZGUBPP-BP7e_Dyb.js
Normal file
File diff suppressed because one or more lines are too long
162
dist-desktop/assets/wardley-RL74JXVD-BAks5CVw.js
Normal file
162
dist-desktop/assets/wardley-RL74JXVD-BAks5CVw.js
Normal file
File diff suppressed because one or more lines are too long
20
dist-desktop/assets/wardleyDiagram-NUSXRM2D-CcpeuWSI.js
Normal file
20
dist-desktop/assets/wardleyDiagram-NUSXRM2D-CcpeuWSI.js
Normal file
File diff suppressed because one or more lines are too long
7
dist-desktop/assets/xychartDiagram-5P7HB3ND-BX6JhKI6.js
Normal file
7
dist-desktop/assets/xychartDiagram-5P7HB3ND-BX6JhKI6.js
Normal file
File diff suppressed because one or more lines are too long
BIN
dist-desktop/favicon.ico
Normal file
BIN
dist-desktop/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 95 KiB |
33
dist-desktop/index.html
Normal file
33
dist-desktop/index.html
Normal file
@@ -0,0 +1,33 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover"
|
||||
/>
|
||||
<title>萌芽小窝</title>
|
||||
<link rel="icon" href="/favicon.ico" sizes="any" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Noto+Sans+SC:wght@400;500;600;700&family=Source+Code+Pro:wght@400;500;600&display=swap"
|
||||
/>
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://cdn.jsdelivr.net/npm/katex@0.16.45/dist/katex.min.css"
|
||||
crossorigin="anonymous"
|
||||
/>
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11/build/styles/github.min.css"
|
||||
crossorigin="anonymous"
|
||||
/>
|
||||
<script type="module" crossorigin src="/assets/index-jkhvCyNw.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-DCC3CA10.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
</body>
|
||||
BIN
dist-desktop/logo.png
Normal file
BIN
dist-desktop/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 19 KiB |
5025
package-lock.json
generated
Normal file
5025
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
29
package.json
29
package.json
@@ -3,8 +3,31 @@
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "npx wrangler dev",
|
||||
"deploy": "npx wrangler deploy",
|
||||
"typecheck": "node --check worker.js"
|
||||
"dev": "npm run build && concurrently -k \"npm:dev:worker\" \"npm:dev:client\"",
|
||||
"dev:worker": "wrangler dev",
|
||||
"dev:client": "vite --config client/vite.config.ts",
|
||||
"build": "vite build --config client/vite.config.ts && vite build --config client/vite.config.ts --mode desktop",
|
||||
"deploy": "npm run build && wrangler deploy",
|
||||
"typecheck": "tsc -p tsconfig.worker.json && tsc -p client/tsconfig.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"highlight.js": "^11.11.1",
|
||||
"katex": "^0.16.21",
|
||||
"marked": "^15.0.7",
|
||||
"marked-katex-extension": "^5.1.5",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-router-dom": "^7.1.1",
|
||||
"mermaid": "^11.4.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cloudflare/workers-types": "^4.20250510.0",
|
||||
"@types/react": "^19.0.2",
|
||||
"@types/react-dom": "^19.0.2",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"concurrently": "^9.1.2",
|
||||
"typescript": "^5.7.2",
|
||||
"vite": "^6.0.7",
|
||||
"wrangler": "^4.85.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,278 +0,0 @@
|
||||
:root {
|
||||
--page-max: 920px;
|
||||
--page-pad-x: 24px;
|
||||
--page-pad-y: 24px;
|
||||
--page-gap: 24px;
|
||||
--text: #111;
|
||||
--muted: #555;
|
||||
--line: #e8e8e8;
|
||||
}
|
||||
html {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: #fff;
|
||||
color: var(--text);
|
||||
font-family: "FangSong", serif;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
text-size-adjust: 100%;
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: #fff;
|
||||
color: var(--text);
|
||||
font-family: "FangSong", serif;
|
||||
line-height: 1.7;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
main {
|
||||
width: min(100%, var(--page-max));
|
||||
margin: 0 auto;
|
||||
padding: var(--page-pad-y) var(--page-pad-x) 32px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.site-footer {
|
||||
width: min(100%, var(--page-max));
|
||||
margin: 0 auto;
|
||||
padding: 20px var(--page-pad-x) 28px;
|
||||
box-sizing: border-box;
|
||||
border-top: 1px solid var(--line);
|
||||
text-align: center;
|
||||
font-size: 0.88rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
.site-footer p {
|
||||
margin: 0;
|
||||
line-height: 1.6;
|
||||
}
|
||||
header.site-header {
|
||||
margin-bottom: var(--page-gap);
|
||||
padding-bottom: 16px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.site-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.site-titles {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
.site-title-link {
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
font-weight: 700;
|
||||
font-size: 1.25rem;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.site-title-en {
|
||||
font-size: 0.82rem;
|
||||
font-weight: 500;
|
||||
color: var(--muted);
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.site-logo {
|
||||
flex-shrink: 0;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
object-fit: contain;
|
||||
cursor: pointer;
|
||||
}
|
||||
.admin-gate {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 9999;
|
||||
padding: 16px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.admin-gate[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
.admin-gate-panel {
|
||||
background: #fff;
|
||||
padding: 16px 18px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #ccc;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
width: min(100%, 420px);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.admin-gate-panel input[type="password"] {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
min-width: 0;
|
||||
}
|
||||
h1,
|
||||
h2,
|
||||
h3 {
|
||||
line-height: 1.3;
|
||||
margin: 0 0 12px;
|
||||
word-break: break-word;
|
||||
}
|
||||
article,
|
||||
section {
|
||||
margin: 0 0 24px;
|
||||
}
|
||||
p {
|
||||
margin: 0 0 12px;
|
||||
word-break: break-word;
|
||||
}
|
||||
a {
|
||||
color: inherit;
|
||||
}
|
||||
input,
|
||||
textarea,
|
||||
button {
|
||||
font: inherit;
|
||||
max-width: 100%;
|
||||
}
|
||||
input[type="text"],
|
||||
input[type="password"],
|
||||
textarea {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
min-width: 0;
|
||||
}
|
||||
textarea {
|
||||
min-height: 320px;
|
||||
resize: vertical;
|
||||
}
|
||||
pre {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
overflow-x: auto;
|
||||
}
|
||||
img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
hr {
|
||||
border: 0;
|
||||
border-top: 1px solid #ddd;
|
||||
margin: 24px 0;
|
||||
}
|
||||
.post-card {
|
||||
padding-bottom: 16px;
|
||||
border-bottom: 1px solid #efefef;
|
||||
}
|
||||
.post-card:last-of-type {
|
||||
padding-bottom: 0;
|
||||
border-bottom: 0;
|
||||
}
|
||||
.post-card .post-excerpt {
|
||||
margin: 0 0 10px;
|
||||
}
|
||||
.post-card .post-meta {
|
||||
font-size: 0.9em;
|
||||
color: #555;
|
||||
margin: 0 0 16px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px 16px;
|
||||
}
|
||||
.post-detail-meta {
|
||||
font-size: 0.95em;
|
||||
color: #555;
|
||||
margin: 0 0 16px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px 16px;
|
||||
}
|
||||
.post-detail-views {
|
||||
color: #666;
|
||||
}
|
||||
.post-comments {
|
||||
margin-top: 36px;
|
||||
padding-top: 28px;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
.post-comments__title {
|
||||
font-size: 1.35rem;
|
||||
margin: 0 0 16px;
|
||||
}
|
||||
form {
|
||||
margin: 0;
|
||||
}
|
||||
@media (max-width: 1024px) {
|
||||
:root {
|
||||
--page-max: 840px;
|
||||
--page-pad-x: 20px;
|
||||
--page-pad-y: 20px;
|
||||
--page-gap: 20px;
|
||||
}
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
:root {
|
||||
--page-max: 100%;
|
||||
--page-pad-x: 16px;
|
||||
--page-pad-y: 16px;
|
||||
--page-gap: 18px;
|
||||
}
|
||||
.site-logo {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
}
|
||||
.site-title-link {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
.post-card .post-meta,
|
||||
.post-detail-meta {
|
||||
gap: 6px 12px;
|
||||
}
|
||||
}
|
||||
@media (max-width: 480px) {
|
||||
:root {
|
||||
--page-pad-x: 14px;
|
||||
--page-pad-y: 14px;
|
||||
--page-gap: 16px;
|
||||
}
|
||||
body {
|
||||
line-height: 1.68;
|
||||
}
|
||||
header.site-header {
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
textarea {
|
||||
min-height: 240px;
|
||||
}
|
||||
.site-brand {
|
||||
gap: 10px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.site-logo {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
.site-title-link {
|
||||
font-size: 1rem;
|
||||
}
|
||||
.post-card .post-meta,
|
||||
.post-detail-meta {
|
||||
font-size: 0.82em;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: 4px 10px;
|
||||
row-gap: 4px;
|
||||
}
|
||||
.post-card .post-meta span,
|
||||
.post-detail-meta span {
|
||||
white-space: nowrap;
|
||||
}
|
||||
.admin-gate-panel {
|
||||
padding: 14px 14px;
|
||||
}
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
(function () {
|
||||
var logo = document.querySelector(".site-logo");
|
||||
var gate = document.getElementById("admin-gate");
|
||||
var inp = document.getElementById("admin-token");
|
||||
var btnGo = document.getElementById("admin-token-go");
|
||||
var btnCancel = document.getElementById("admin-token-cancel");
|
||||
if (!logo || !gate || !inp || !btnGo || !btnCancel) return;
|
||||
var n = 0;
|
||||
var resetTimer = null;
|
||||
function showGate() {
|
||||
gate.hidden = false;
|
||||
inp.value = "";
|
||||
inp.focus();
|
||||
}
|
||||
function hideGate() {
|
||||
gate.hidden = true;
|
||||
}
|
||||
logo.addEventListener("click", function (e) {
|
||||
e.preventDefault();
|
||||
if (resetTimer) clearTimeout(resetTimer);
|
||||
n += 1;
|
||||
if (n >= 5) {
|
||||
n = 0;
|
||||
showGate();
|
||||
return;
|
||||
}
|
||||
resetTimer = setTimeout(function () {
|
||||
n = 0;
|
||||
}, 4000);
|
||||
});
|
||||
btnCancel.addEventListener("click", hideGate);
|
||||
function submit() {
|
||||
fetch("/admin/session", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "same-origin",
|
||||
body: JSON.stringify({ token: inp.value })
|
||||
})
|
||||
.then(function (r) {
|
||||
if (r.ok) {
|
||||
hideGate();
|
||||
location.href = "/admin";
|
||||
return;
|
||||
}
|
||||
inp.select();
|
||||
alert("Token 无效");
|
||||
})
|
||||
.catch(function () {
|
||||
alert("网络错误");
|
||||
});
|
||||
}
|
||||
btnGo.addEventListener("click", submit);
|
||||
inp.addEventListener("keydown", function (e) {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
submit();
|
||||
}
|
||||
});
|
||||
})();
|
||||
@@ -1,78 +0,0 @@
|
||||
(function () {
|
||||
/**
|
||||
* cwd-widget(≤0.1.11)里 getLikeStatus 用 post_slug = 完整页面 URL(postUrl),
|
||||
* 而点赞写入 likePage 用的是 postSlug;后端排行榜按 slug 汇总,导致前台一直显示 0。
|
||||
* getPagePv 已写成 postUrl || postSlug,点赞查询未对齐。这里把 GET /api/like 的
|
||||
* post_slug 从绝对地址改回与评论区一致的 slug。
|
||||
*/
|
||||
function patchCwdLikeGetQuery(apiBaseNorm, postSlug) {
|
||||
if (window.__cwdLikeStatusQueryPatched) return;
|
||||
window.__cwdLikeStatusQueryPatched = true;
|
||||
|
||||
var base = apiBaseNorm;
|
||||
var orig = window.fetch;
|
||||
window.fetch = function (input, init) {
|
||||
var url =
|
||||
typeof input === "string"
|
||||
? input
|
||||
: input && typeof input.url === "string"
|
||||
? input.url
|
||||
: "";
|
||||
var method = "GET";
|
||||
if (init && init.method) method = String(init.method).toUpperCase();
|
||||
else if (typeof Request !== "undefined" && input instanceof Request) {
|
||||
method = String(input.method || "GET").toUpperCase();
|
||||
}
|
||||
if (
|
||||
url &&
|
||||
url.indexOf(base) === 0 &&
|
||||
url.indexOf("/api/like?") !== -1 &&
|
||||
method === "GET"
|
||||
) {
|
||||
try {
|
||||
var u = new URL(url);
|
||||
var ps = u.searchParams.get("post_slug") || "";
|
||||
if (ps.indexOf("://") !== -1) {
|
||||
u.searchParams.set("post_slug", postSlug);
|
||||
if (typeof input === "string") {
|
||||
input = u.toString();
|
||||
} else if (typeof Request !== "undefined" && input instanceof Request) {
|
||||
input = new Request(u.toString(), input);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
return orig.call(this, input, init);
|
||||
};
|
||||
}
|
||||
|
||||
function mount() {
|
||||
var el = document.getElementById("comments");
|
||||
if (!el || typeof CWDComments === "undefined") return;
|
||||
|
||||
var ds = el.dataset;
|
||||
var apiBaseUrl = ds.apiBase;
|
||||
var postSlug = ds.postSlug;
|
||||
if (!apiBaseUrl || !postSlug) return;
|
||||
|
||||
patchCwdLikeGetQuery(apiBaseUrl.replace(/\/$/, ""), postSlug);
|
||||
|
||||
var opts = {
|
||||
el: "#comments",
|
||||
apiBaseUrl: apiBaseUrl,
|
||||
postSlug: postSlug,
|
||||
lang: ds.lang || "zh-CN"
|
||||
};
|
||||
if (ds.siteId) opts.siteId = ds.siteId;
|
||||
|
||||
new CWDComments(opts).mount();
|
||||
}
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", mount);
|
||||
} else {
|
||||
mount();
|
||||
}
|
||||
})();
|
||||
@@ -1,97 +0,0 @@
|
||||
import { marked } from "https://esm.sh/marked@15";
|
||||
import markedKatex from "https://esm.sh/marked-katex-extension@5?deps=marked@15,katex@0.16";
|
||||
import hljs from "https://esm.sh/highlight.js@11";
|
||||
import mermaid from "https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.esm.min.mjs";
|
||||
|
||||
marked.use(
|
||||
markedKatex({
|
||||
throwOnError: false,
|
||||
nonStandard: true
|
||||
})
|
||||
);
|
||||
// breaks: true — 段内单换行渲染为 <br>,与 Obsidian 等编辑器的换行习惯一致(CommonMark 默认会把单换行当成空格)
|
||||
marked.setOptions({ gfm: true, breaks: true });
|
||||
|
||||
function decodeHtmlEntities(str) {
|
||||
return String(str)
|
||||
.replace(/ /g, "\u00a0")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/&#(\d+);/g, (_, n) => {
|
||||
const code = Number(n);
|
||||
return code >= 0 && code <= 0x10ffff ? String.fromCharCode(code) : "";
|
||||
})
|
||||
.replace(/&#x([0-9a-fA-F]+);/g, (_, h) => {
|
||||
const code = parseInt(h, 16);
|
||||
return code >= 0 && code <= 0x10ffff ? String.fromCharCode(code) : "";
|
||||
});
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s)
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
function mermaidFencedToDiv(html) {
|
||||
return html.replace(
|
||||
/<pre><code class="language-mermaid">([\s\S]*?)<\/code><\/pre>/gi,
|
||||
(_, inner) => {
|
||||
const raw = decodeHtmlEntities(inner);
|
||||
return `<div class="mermaid">${escapeHtml(raw)}</div>`;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function decodeMdFromB64(b64) {
|
||||
return decodeURIComponent(escape(atob(b64)));
|
||||
}
|
||||
|
||||
async function renderOne(el) {
|
||||
const b64 = el.getAttribute("data-b64-md");
|
||||
if (!b64) return;
|
||||
let md;
|
||||
try {
|
||||
md = decodeMdFromB64(b64);
|
||||
} catch {
|
||||
el.innerHTML = "<p>正文解码失败。</p>";
|
||||
el.classList.remove("markdown-pending");
|
||||
return;
|
||||
}
|
||||
el.removeAttribute("data-b64-md");
|
||||
let html = marked.parse(md, { async: false });
|
||||
if (typeof html !== "string") html = "";
|
||||
html = mermaidFencedToDiv(html);
|
||||
el.innerHTML = html;
|
||||
el.classList.remove("markdown-pending");
|
||||
el.querySelectorAll("pre code").forEach((block) => {
|
||||
try {
|
||||
hljs.highlightElement(block);
|
||||
} catch {
|
||||
/* ignore unknown languages */
|
||||
}
|
||||
});
|
||||
const mNodes = el.querySelectorAll(".mermaid");
|
||||
if (mNodes.length) {
|
||||
mermaid.initialize({ startOnLoad: false, theme: "neutral" });
|
||||
await mermaid.run({ nodes: mNodes });
|
||||
}
|
||||
}
|
||||
|
||||
function run() {
|
||||
document.querySelectorAll(".markdown-body[data-b64-md]").forEach((el) => {
|
||||
renderOne(el).catch(() => {
|
||||
el.innerHTML = "<p>渲染失败。</p>";
|
||||
el.classList.remove("markdown-pending");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", run);
|
||||
else run();
|
||||
131
render.js
131
render.js
@@ -1,131 +0,0 @@
|
||||
import {
|
||||
escapeHtml,
|
||||
escapeAttr,
|
||||
formatDateTime,
|
||||
cleanText,
|
||||
stripMarkdown,
|
||||
b64EncodeUtf8
|
||||
} from "./utils.js";
|
||||
|
||||
const DEFAULT_SITE_NAME_ZH = "萌芽小窝";
|
||||
const DEFAULT_SITE_NAME_EN = "SproutBlog";
|
||||
|
||||
export function siteNameZh(env) {
|
||||
return cleanText(env?.SITE_TITLE) || DEFAULT_SITE_NAME_ZH;
|
||||
}
|
||||
|
||||
export function siteNameEn(env) {
|
||||
return cleanText(env?.SITE_TITLE_EN) || DEFAULT_SITE_NAME_EN;
|
||||
}
|
||||
|
||||
export function pageTitle(env, suffix = "") {
|
||||
const site = siteNameZh(env);
|
||||
return suffix ? `${site} - ${suffix}` : site;
|
||||
}
|
||||
|
||||
export function renderLayout(title, body, env, options = {}) {
|
||||
const siteZh = siteNameZh(env);
|
||||
const siteEn = siteNameEn(env);
|
||||
const cwdPostSlug = options.cwdPostSlug ? String(options.cwdPostSlug) : "";
|
||||
const cwdApiBase =
|
||||
cleanText(env?.CWD_API_BASE) || "https://cwd.api.smyhub.com";
|
||||
const cwdSiteId = cleanText(env?.CWD_SITE_ID);
|
||||
const cwdSection = cwdPostSlug
|
||||
? `
|
||||
<section class="post-comments" aria-labelledby="comments-heading">
|
||||
<h2 id="comments-heading" class="post-comments__title">评论</h2>
|
||||
<div id="comments" data-api-base="${escapeAttr(cwdApiBase)}" data-post-slug="${escapeAttr(cwdPostSlug)}" data-lang="zh-CN"${cwdSiteId ? ` data-site-id="${escapeAttr(cwdSiteId)}"` : ""}></div>
|
||||
</section>`
|
||||
: "";
|
||||
const mdHead = options.markdownPage
|
||||
? `
|
||||
<link rel="stylesheet" href="/css/markdown.css">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.45/dist/katex.min.css" crossorigin="anonymous">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11/build/styles/github.min.css" crossorigin="anonymous">
|
||||
`
|
||||
: "";
|
||||
const mdScript = options.markdownPage
|
||||
? `\n <script type="module" src="/js/markdown-client.js"></script>`
|
||||
: "";
|
||||
const cwdScripts = cwdPostSlug
|
||||
? `
|
||||
<script src="https://cdn.jsdelivr.net/npm/cwd-widget@0.1.11/dist/cwd.umd.js" defer></script>
|
||||
<script src="/js/cwd-comments.js" defer></script>`
|
||||
: "";
|
||||
return `<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover">
|
||||
<title>${escapeHtml(title)}</title>
|
||||
<link rel="icon" href="/favicon.ico" sizes="any">
|
||||
<link rel="stylesheet" href="/css/app.css">
|
||||
${mdHead}
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<header class="site-header">
|
||||
<div class="site-brand">
|
||||
<img class="site-logo" src="/logo.png" alt="" width="40" height="40" decoding="async">
|
||||
<div class="site-titles">
|
||||
<a href="/" class="site-title-link">${escapeHtml(siteZh)}</a>
|
||||
<span class="site-title-en">${escapeHtml(siteEn)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
${body}
|
||||
${cwdSection}
|
||||
</main>
|
||||
<footer class="site-footer">
|
||||
<p>萌芽小窝-一个安静的小地方 @2026-至今</p>
|
||||
</footer>
|
||||
<div id="admin-gate" class="admin-gate" hidden>
|
||||
<div class="admin-gate-panel">
|
||||
<input id="admin-token" type="password" autocomplete="off" placeholder="Token" aria-label="Token">
|
||||
<button type="button" id="admin-token-go">进入</button>
|
||||
<button type="button" id="admin-token-cancel">取消</button>
|
||||
</div>
|
||||
</div>
|
||||
<script src="/js/app.js" defer></script>${mdScript}${cwdScripts}
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
export function renderPostCard(post) {
|
||||
const excerpt = post.excerpt || stripMarkdown(post.content).slice(0, 180);
|
||||
const views = Number(post.view_count) || 0;
|
||||
return `
|
||||
<article class="post-card">
|
||||
<h2><a href="/post/${encodeURIComponent(post.slug)}">${escapeHtml(post.title)}</a></h2>
|
||||
<p class="post-excerpt">${escapeHtml(excerpt)}${excerpt.length >= 180 ? "…" : ""}</p>
|
||||
<p class="post-meta">
|
||||
<span class="post-card-time">创建于:${formatDateTime(post.created_at)}</span>
|
||||
<span class="post-card-time">最后更新于:${formatDateTime(post.updated_at)}</span>
|
||||
<span class="post-card-views">浏览 ${views}</span>
|
||||
</p>
|
||||
</article>
|
||||
`;
|
||||
}
|
||||
|
||||
export function renderAdminCard(post) {
|
||||
return `
|
||||
<article>
|
||||
<h3>${escapeHtml(post.title)}</h3>
|
||||
<p>ID: ${post.id} | Slug: ${escapeHtml(post.slug)} | ${post.published ? "已发布" : "草稿"} | ${formatDateTime(post.updated_at)}</p>
|
||||
<div>
|
||||
<a href="/admin/edit/${post.id}">编辑</a>
|
||||
<form method="post" action="/admin/delete" style="display:inline">
|
||||
<input type="hidden" name="id" value="${post.id}">
|
||||
<button type="submit">删除</button>
|
||||
</form>
|
||||
</div>
|
||||
</article>
|
||||
`;
|
||||
}
|
||||
|
||||
export function markdownPlaceholder(raw) {
|
||||
const src = String(raw ?? "");
|
||||
if (!src.trim()) return "";
|
||||
const b64 = b64EncodeUtf8(src);
|
||||
return `<div class="markdown-body markdown-pending" data-b64-md="${escapeAttr(b64)}"><p class="markdown-loading">加载中…</p></div>`;
|
||||
}
|
||||
504
server.js
504
server.js
@@ -1,504 +0,0 @@
|
||||
import {
|
||||
renderLayout,
|
||||
renderPostCard,
|
||||
renderAdminCard,
|
||||
markdownPlaceholder,
|
||||
pageTitle
|
||||
} from "./render.js";
|
||||
import {
|
||||
escapeHtml,
|
||||
escapeAttr,
|
||||
formatDateTime,
|
||||
cleanText,
|
||||
isTruthy
|
||||
} from "./utils.js";
|
||||
|
||||
export default {
|
||||
async fetch(request, env) {
|
||||
const url = new URL(request.url);
|
||||
const path = normalizePath(url.pathname);
|
||||
|
||||
if (!env.DB) {
|
||||
return htmlResponse(renderLayout("配置缺失", `
|
||||
<h1>DB 绑定未配置</h1>
|
||||
<p>请先在 <code>wrangler.toml</code> 里配置 D1 数据库。</p>
|
||||
`, env), 500);
|
||||
}
|
||||
|
||||
if (request.method === "OPTIONS") {
|
||||
return new Response(null, { headers: corsHeaders() });
|
||||
}
|
||||
|
||||
try {
|
||||
if (path === "/admin/session" && request.method === "POST") {
|
||||
return await handleAdminSession(request, env);
|
||||
}
|
||||
|
||||
if (request.method === "GET") {
|
||||
if (path === "/") return await handleIndex(env, url);
|
||||
if (path.startsWith("/post/")) return await handlePost(env, path.slice(6));
|
||||
if (path === "/admin" || path === "/admin/new" || path.startsWith("/admin/edit/")) {
|
||||
return await handleAdminPage(request, env, url, path);
|
||||
}
|
||||
if (path === "/api/posts") return await handleApiList(env, url);
|
||||
if (path.startsWith("/api/posts/")) return await handleApiRead(env, path.slice(11));
|
||||
}
|
||||
|
||||
if (path === "/admin/save" && request.method === "POST") {
|
||||
return await handleAdminSave(request, env, url);
|
||||
}
|
||||
|
||||
if (path === "/admin/delete" && request.method === "POST") {
|
||||
return await handleAdminDelete(request, env, url);
|
||||
}
|
||||
|
||||
if (path === "/api/posts" && request.method === "POST") {
|
||||
return await handleApiCreate(request, env);
|
||||
}
|
||||
|
||||
if (path.startsWith("/api/posts/")) {
|
||||
const idOrSlug = path.slice(11);
|
||||
if (request.method === "PUT") return await handleApiUpdate(request, env, idOrSlug);
|
||||
if (request.method === "DELETE") return await handleApiDelete(request, env, idOrSlug);
|
||||
}
|
||||
|
||||
return notFoundPage(env);
|
||||
} catch (error) {
|
||||
return htmlResponse(renderLayout("错误", `
|
||||
<h1>发生错误</h1>
|
||||
<pre>${escapeHtml(error?.stack || error?.message || String(error))}</pre>
|
||||
`, env), 500);
|
||||
}
|
||||
}
|
||||
};
|
||||
async function handleIndex(env, url) {
|
||||
const q = (url.searchParams.get("q") || "").trim();
|
||||
const posts = await listPosts(env.DB, { publishedOnly: true, q });
|
||||
return htmlResponse(renderLayout(pageTitle(env, "首页"), `
|
||||
${q ? `<p>搜索:<strong>${escapeHtml(q)}</strong></p>` : ""}
|
||||
${posts.length ? posts.map(renderPostCard).join("") : "<p>暂无文章。</p>"}
|
||||
`, env));
|
||||
}
|
||||
|
||||
async function handlePost(env, slug) {
|
||||
const post = await env.DB.prepare(
|
||||
"SELECT * FROM posts WHERE slug = ? AND published = 1 LIMIT 1"
|
||||
).bind(decodeURIComponent(slug)).first();
|
||||
|
||||
if (!post) return notFoundPage(env);
|
||||
|
||||
await env.DB.prepare(
|
||||
"UPDATE posts SET view_count = COALESCE(view_count, 0) + 1 WHERE id = ?"
|
||||
).bind(post.id).run();
|
||||
const views = (Number(post.view_count) || 0) + 1;
|
||||
|
||||
return htmlResponse(renderLayout(`${post.title} - ${pageTitle(env)}`, `
|
||||
<p><a href="/">返回</a></p>
|
||||
<article>
|
||||
<h1>${escapeHtml(post.title)}</h1>
|
||||
<p class="post-detail-meta"><span class="post-detail-time">${formatDateTime(post.updated_at)}</span><span class="post-detail-views">浏览 ${views}</span></p>
|
||||
${markdownPlaceholder(post.content)}
|
||||
</article>
|
||||
`, env, { markdownPage: true, cwdPostSlug: post.slug }));
|
||||
}
|
||||
|
||||
async function handleAdminPage(request, env, url, path) {
|
||||
const auth = await requireAdmin(request, env);
|
||||
if (auth) return auth;
|
||||
|
||||
let post = blankPost();
|
||||
if (path.startsWith("/admin/edit/")) {
|
||||
const id = Number(path.slice("/admin/edit/".length));
|
||||
if (Number.isFinite(id) && id > 0) {
|
||||
post = await getPostById(env.DB, id) || post;
|
||||
}
|
||||
} else if (path === "/admin" && url.searchParams.get("id")) {
|
||||
const id = Number(url.searchParams.get("id"));
|
||||
if (Number.isFinite(id) && id > 0) {
|
||||
post = await getPostById(env.DB, id) || post;
|
||||
}
|
||||
}
|
||||
|
||||
const q = (url.searchParams.get("q") || "").trim();
|
||||
const posts = await listPosts(env.DB, { publishedOnly: false, q });
|
||||
|
||||
return htmlResponse(renderLayout(`后台 - ${pageTitle(env)}`, `
|
||||
<p><a href="/">返回</a></p>
|
||||
<h1>后台</h1>
|
||||
<section>
|
||||
<h2>${post.id ? `编辑 #${post.id}` : "新建文章"}</h2>
|
||||
<form method="post" action="/admin/save">
|
||||
<input type="hidden" name="id" value="${escapeAttr(post.id || "")}">
|
||||
<p>
|
||||
<label>标题<br><input name="title" type="text" value="${escapeAttr(post.title || "")}" required></label>
|
||||
</p>
|
||||
<p>
|
||||
<label>Slug<br><input name="slug" type="text" value="${escapeAttr(post.slug || "")}"></label>
|
||||
</p>
|
||||
<p>
|
||||
<label>摘要<br><input name="excerpt" type="text" value="${escapeAttr(post.excerpt || "")}"></label>
|
||||
</p>
|
||||
<p>
|
||||
<label>内容<br><textarea name="content" required>${escapeHtml(post.content || "")}</textarea></label>
|
||||
</p>
|
||||
<p>
|
||||
<label><input name="published" type="checkbox" value="1" ${post.published ? "checked" : ""}> 发布</label>
|
||||
</p>
|
||||
<p><button type="submit">保存</button></p>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<hr>
|
||||
|
||||
<section>
|
||||
<h2>文章列表</h2>
|
||||
<form method="get" action="/admin">
|
||||
<p>
|
||||
<label>查询<br><input name="q" type="text" value="${escapeAttr(q)}"></label>
|
||||
</p>
|
||||
<p><button type="submit">搜索</button></p>
|
||||
</form>
|
||||
${posts.length ? posts.map(renderAdminCard).join("") : "<p>没有找到文章。</p>"}
|
||||
</section>
|
||||
`, env));
|
||||
}
|
||||
|
||||
async function handleAdminSave(request, env, url) {
|
||||
const auth = await requireAdmin(request, env);
|
||||
if (auth) return auth;
|
||||
|
||||
const data = await readBody(request);
|
||||
const rawId = data.id ? Number(data.id) : null;
|
||||
const id = Number.isFinite(rawId) && rawId > 0 ? rawId : null;
|
||||
const title = cleanText(data.title);
|
||||
const content = cleanText(data.content);
|
||||
const excerpt = cleanText(data.excerpt);
|
||||
const slugBase = cleanText(data.slug) || title;
|
||||
const published = isTruthy(data.published) ? 1 : 0;
|
||||
|
||||
if (!title || !content) {
|
||||
return htmlResponse(renderLayout("表单错误", `
|
||||
<h1>标题和内容不能为空</h1>
|
||||
<p><a href="/admin">返回后台</a></p>
|
||||
`, env), 400);
|
||||
}
|
||||
|
||||
const slug = await uniqueSlug(env.DB, slugify(slugBase), id);
|
||||
const now = new Date().toISOString();
|
||||
|
||||
if (id) {
|
||||
const exists = await getPostById(env.DB, id);
|
||||
if (!exists) return notFoundPage(env);
|
||||
await env.DB.prepare(`
|
||||
UPDATE posts
|
||||
SET title = ?, slug = ?, excerpt = ?, content = ?, published = ?, updated_at = ?
|
||||
WHERE id = ?
|
||||
`).bind(title, slug, excerpt, content, published, now, id).run();
|
||||
} else {
|
||||
const result = await env.DB.prepare(`
|
||||
INSERT INTO posts (title, slug, excerpt, content, published, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`).bind(title, slug, excerpt, content, published, now, now).run();
|
||||
return Response.redirect(new URL(`/admin?id=${result.meta.last_row_id}&saved=1`, url), 303);
|
||||
}
|
||||
|
||||
return Response.redirect(new URL(`/admin?id=${id}&saved=1`, url), 303);
|
||||
}
|
||||
|
||||
async function handleAdminDelete(request, env, url) {
|
||||
const auth = await requireAdmin(request, env);
|
||||
if (auth) return auth;
|
||||
|
||||
const data = await readBody(request);
|
||||
const id = Number(data.id);
|
||||
if (!Number.isFinite(id) || id <= 0) {
|
||||
return htmlResponse(renderLayout("表单错误", `
|
||||
<h1>无效的文章 ID</h1>
|
||||
<p><a href="/admin">返回后台</a></p>
|
||||
`, env), 400);
|
||||
}
|
||||
|
||||
await env.DB.prepare("DELETE FROM posts WHERE id = ?").bind(id).run();
|
||||
return Response.redirect(new URL(`/admin?deleted=1`, url), 303);
|
||||
}
|
||||
|
||||
async function handleAdminSession(request, env) {
|
||||
const password = cleanText(env.ADMIN_PASSWORD);
|
||||
if (!password) {
|
||||
return jsonResponse({ ok: false, error: "not_configured" }, 503);
|
||||
}
|
||||
|
||||
const data = await readBody(request);
|
||||
const token = cleanText(data.token);
|
||||
if (token !== password) {
|
||||
return jsonResponse({ ok: false, error: "invalid" }, 401);
|
||||
}
|
||||
|
||||
const value = await adminSessionCookieValue(env);
|
||||
const maxAge = 60 * 60 * 24 * 7;
|
||||
const secure = new URL(request.url).protocol === "https:";
|
||||
const parts = [`sb_admin=${value}`, "Path=/", "HttpOnly", "SameSite=Lax", `Max-Age=${maxAge}`];
|
||||
if (secure) parts.push("Secure");
|
||||
|
||||
return jsonResponse({ ok: true }, 200, {
|
||||
"Set-Cookie": parts.join("; ")
|
||||
});
|
||||
}
|
||||
|
||||
async function handleApiList(env, url) {
|
||||
const q = (url.searchParams.get("q") || "").trim();
|
||||
const posts = await listPosts(env.DB, { publishedOnly: false, q });
|
||||
return jsonResponse({ ok: true, posts });
|
||||
}
|
||||
|
||||
async function handleApiRead(env, idOrSlug) {
|
||||
const post = await readPost(env.DB, idOrSlug);
|
||||
if (!post) return jsonResponse({ ok: false, error: "Not found" }, 404);
|
||||
return jsonResponse({ ok: true, post });
|
||||
}
|
||||
|
||||
async function handleApiCreate(request, env) {
|
||||
const auth = await requireAdmin(request, env);
|
||||
if (auth) return auth;
|
||||
|
||||
const data = await readBody(request);
|
||||
const created = await createOrUpdatePost(env.DB, data, null);
|
||||
return jsonResponse({ ok: true, post: created }, 201);
|
||||
}
|
||||
|
||||
async function handleApiUpdate(request, env, idOrSlug) {
|
||||
const auth = await requireAdmin(request, env);
|
||||
if (auth) return auth;
|
||||
|
||||
const data = await readBody(request);
|
||||
const existing = await readPost(env.DB, idOrSlug);
|
||||
if (!existing) return jsonResponse({ ok: false, error: "Not found" }, 404);
|
||||
const updated = await createOrUpdatePost(env.DB, data, existing.id);
|
||||
return jsonResponse({ ok: true, post: updated });
|
||||
}
|
||||
|
||||
async function handleApiDelete(request, env, idOrSlug) {
|
||||
const auth = await requireAdmin(request, env);
|
||||
if (auth) return auth;
|
||||
|
||||
const post = await readPost(env.DB, idOrSlug);
|
||||
if (!post) return jsonResponse({ ok: false, error: "Not found" }, 404);
|
||||
await env.DB.prepare("DELETE FROM posts WHERE id = ?").bind(post.id).run();
|
||||
return jsonResponse({ ok: true, deleted: post.id });
|
||||
}
|
||||
|
||||
async function createOrUpdatePost(db, data, currentId) {
|
||||
const rawId = currentId ? Number(currentId) : null;
|
||||
const id = Number.isFinite(rawId) && rawId > 0 ? rawId : null;
|
||||
const title = cleanText(data.title);
|
||||
const content = cleanText(data.content);
|
||||
const excerpt = cleanText(data.excerpt);
|
||||
const slugBase = cleanText(data.slug) || title;
|
||||
const published = isTruthy(data.published) ? 1 : 0;
|
||||
|
||||
if (!title || !content) {
|
||||
throw new Error("title 和 content 不能为空");
|
||||
}
|
||||
|
||||
const slug = await uniqueSlug(db, slugify(slugBase), id);
|
||||
const now = new Date().toISOString();
|
||||
|
||||
if (id) {
|
||||
await db.prepare(`
|
||||
UPDATE posts
|
||||
SET title = ?, slug = ?, excerpt = ?, content = ?, published = ?, updated_at = ?
|
||||
WHERE id = ?
|
||||
`).bind(title, slug, excerpt, content, published, now, id).run();
|
||||
return await getPostById(db, id);
|
||||
}
|
||||
|
||||
const result = await db.prepare(`
|
||||
INSERT INTO posts (title, slug, excerpt, content, published, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`).bind(title, slug, excerpt, content, published, now, now).run();
|
||||
return await getPostById(db, result.meta.last_row_id);
|
||||
}
|
||||
|
||||
async function readPost(db, idOrSlug) {
|
||||
if (!idOrSlug) return null;
|
||||
const trimmed = decodeURIComponent(String(idOrSlug)).trim();
|
||||
if (/^\d+$/.test(trimmed)) {
|
||||
return await getPostById(db, Number(trimmed));
|
||||
}
|
||||
return await db.prepare("SELECT * FROM posts WHERE slug = ? LIMIT 1").bind(trimmed).first();
|
||||
}
|
||||
|
||||
async function getPostById(db, id) {
|
||||
return await db.prepare("SELECT * FROM posts WHERE id = ? LIMIT 1").bind(Number(id)).first();
|
||||
}
|
||||
|
||||
async function listPosts(db, { publishedOnly = true, q = "" } = {}) {
|
||||
const params = [];
|
||||
const where = [];
|
||||
if (publishedOnly) where.push("published = 1");
|
||||
if (q) {
|
||||
where.push("(title LIKE ? OR slug LIKE ? OR excerpt LIKE ? OR content LIKE ?)");
|
||||
const like = `%${q}%`;
|
||||
params.push(like, like, like, like);
|
||||
}
|
||||
const sql = `
|
||||
SELECT * FROM posts
|
||||
${where.length ? `WHERE ${where.join(" AND ")}` : ""}
|
||||
ORDER BY published DESC, updated_at DESC, id DESC
|
||||
`;
|
||||
const statement = db.prepare(sql);
|
||||
const { results } = params.length ? await statement.bind(...params).all() : await statement.all();
|
||||
return results || [];
|
||||
}
|
||||
|
||||
async function uniqueSlug(db, baseSlug, currentId) {
|
||||
let slug = baseSlug || "post";
|
||||
let suffix = 2;
|
||||
|
||||
while (true) {
|
||||
const existing = await db.prepare("SELECT id FROM posts WHERE slug = ? LIMIT 1").bind(slug).first();
|
||||
if (!existing || Number(existing.id) === Number(currentId)) return slug;
|
||||
slug = `${baseSlug || "post"}-${suffix++}`;
|
||||
}
|
||||
}
|
||||
|
||||
function slugify(value) {
|
||||
const raw = cleanText(value);
|
||||
const slug = raw
|
||||
.normalize("NFKD")
|
||||
.replace(/[\u0300-\u036f]/g, "")
|
||||
.replace(/[^\p{L}\p{N}]+/gu, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.replace(/-+/g, "-")
|
||||
.toLowerCase();
|
||||
return slug || "post";
|
||||
}
|
||||
function normalizePath(pathname) {
|
||||
if (pathname === "/") return "/";
|
||||
return pathname.replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
function htmlResponse(html, status = 200, headers = {}) {
|
||||
return new Response(html, {
|
||||
status,
|
||||
headers: {
|
||||
"content-type": "text/html; charset=UTF-8",
|
||||
...headers
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function jsonResponse(data, status = 200, extraHeaders = {}) {
|
||||
return new Response(JSON.stringify(data, null, 2), {
|
||||
status,
|
||||
headers: {
|
||||
"content-type": "application/json; charset=UTF-8",
|
||||
...corsHeaders(),
|
||||
...extraHeaders
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function notFoundPage(env) {
|
||||
return htmlResponse(renderLayout("404", `
|
||||
<h1>404</h1>
|
||||
<p>页面不存在。</p>
|
||||
<p><a href="/">返回</a></p>
|
||||
`, env), 404);
|
||||
}
|
||||
|
||||
function corsHeaders() {
|
||||
return {
|
||||
"access-control-allow-origin": "*",
|
||||
"access-control-allow-methods": "GET, POST, PUT, DELETE, OPTIONS",
|
||||
"access-control-allow-headers": "*"
|
||||
};
|
||||
}
|
||||
|
||||
async function requireAdmin(request, env) {
|
||||
const password = cleanText(env.ADMIN_PASSWORD);
|
||||
if (!password) return null;
|
||||
|
||||
const cookies = parseCookie(request.headers.get("Cookie") || "");
|
||||
const expected = await adminSessionCookieValue(env);
|
||||
if (cookies.sb_admin && cookies.sb_admin === expected) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const header = request.headers.get("Authorization") || "";
|
||||
if (header.startsWith("Basic ")) {
|
||||
try {
|
||||
const decoded = atob(header.slice(6));
|
||||
const split = decoded.indexOf(":");
|
||||
const inputPassword = split >= 0 ? decoded.slice(split + 1) : "";
|
||||
if (inputPassword === password) return null;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
return unauthorized();
|
||||
}
|
||||
|
||||
function parseCookie(header) {
|
||||
const out = {};
|
||||
if (!header) return out;
|
||||
for (const part of header.split(";")) {
|
||||
const idx = part.indexOf("=");
|
||||
if (idx === -1) continue;
|
||||
const k = part.slice(0, idx).trim();
|
||||
const v = part.slice(idx + 1).trim();
|
||||
try {
|
||||
out[k] = decodeURIComponent(v);
|
||||
} catch {
|
||||
out[k] = v;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function adminSessionCookieValue(env) {
|
||||
const password = cleanText(env.ADMIN_PASSWORD);
|
||||
const enc = new TextEncoder();
|
||||
const data = enc.encode(`${password}\0sproutblog_admin_v1`);
|
||||
const hash = await crypto.subtle.digest("SHA-256", data);
|
||||
return [...new Uint8Array(hash)].map((b) => b.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
function unauthorized() {
|
||||
return new Response("Authentication required", {
|
||||
status: 401,
|
||||
headers: {
|
||||
"content-type": "text/plain; charset=UTF-8"
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function readBody(request) {
|
||||
const type = request.headers.get("content-type") || "";
|
||||
if (type.includes("application/json")) {
|
||||
return await request.json();
|
||||
}
|
||||
|
||||
if (type.includes("multipart/form-data") || type.includes("application/x-www-form-urlencoded")) {
|
||||
const form = await request.formData();
|
||||
const data = {};
|
||||
for (const [key, value] of form.entries()) {
|
||||
data[key] = value;
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
function blankPost() {
|
||||
return {
|
||||
id: "",
|
||||
title: "",
|
||||
slug: "",
|
||||
excerpt: "",
|
||||
content: "",
|
||||
published: 1
|
||||
};
|
||||
}
|
||||
|
||||
56
src/api/router.ts
Normal file
56
src/api/router.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { jsonResponse, corsHeaders } from "../lib/http";
|
||||
import { normalizePath } from "../lib/slug";
|
||||
import { handleConfigGet } from "./routes/config";
|
||||
import { handleAdminSessionPost } from "./routes/admin-session";
|
||||
import {
|
||||
handlePostsCollection,
|
||||
handlePostItem
|
||||
} from "./routes/posts";
|
||||
|
||||
export async function handleApi(request: Request, env: Env): Promise<Response> {
|
||||
if (!env.DB) {
|
||||
return jsonResponse({ ok: false, error: "db_not_configured" }, 500);
|
||||
}
|
||||
|
||||
if (request.method === "OPTIONS") {
|
||||
return new Response(null, { headers: corsHeaders() });
|
||||
}
|
||||
|
||||
const url = new URL(request.url);
|
||||
const path = normalizePath(url.pathname);
|
||||
const segments = path.split("/").filter(Boolean);
|
||||
|
||||
if (segments[0] !== "api") {
|
||||
return jsonResponse({ ok: false, error: "Not found" }, 404);
|
||||
}
|
||||
|
||||
const parts = segments.slice(1);
|
||||
|
||||
if (
|
||||
parts[0] === "config" &&
|
||||
parts.length === 1 &&
|
||||
request.method === "GET"
|
||||
) {
|
||||
return handleConfigGet(env);
|
||||
}
|
||||
|
||||
if (
|
||||
parts[0] === "admin" &&
|
||||
parts[1] === "session" &&
|
||||
parts.length === 2 &&
|
||||
request.method === "POST"
|
||||
) {
|
||||
return handleAdminSessionPost(request, env);
|
||||
}
|
||||
|
||||
if (parts[0] === "posts") {
|
||||
if (parts.length === 1) {
|
||||
return handlePostsCollection(request, env, url);
|
||||
}
|
||||
if (parts.length === 2) {
|
||||
return handlePostItem(request, env, url, parts[1]);
|
||||
}
|
||||
}
|
||||
|
||||
return jsonResponse({ ok: false, error: "Not found" }, 404);
|
||||
}
|
||||
29
src/api/routes/admin-session.ts
Normal file
29
src/api/routes/admin-session.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { jsonResponse, readBody, getAdminPassword } from "../../lib/http";
|
||||
import { cleanText } from "../../lib/text";
|
||||
import { adminSessionCookieValue } from "../../lib/auth";
|
||||
|
||||
export async function handleAdminSessionPost(
|
||||
request: Request,
|
||||
env: Env
|
||||
): Promise<Response> {
|
||||
const password = getAdminPassword(env);
|
||||
if (!password) {
|
||||
return jsonResponse({ ok: false, error: "not_configured" }, 503);
|
||||
}
|
||||
|
||||
const data = await readBody(request);
|
||||
const token = cleanText(data.token);
|
||||
if (token !== password) {
|
||||
return jsonResponse({ ok: false, error: "invalid" }, 401);
|
||||
}
|
||||
|
||||
const value = await adminSessionCookieValue(env);
|
||||
const maxAge = 60 * 60 * 24 * 7;
|
||||
const secure = new URL(request.url).protocol === "https:";
|
||||
const parts = [`sb_admin=${value}`, "Path=/", "HttpOnly", "SameSite=Lax", `Max-Age=${maxAge}`];
|
||||
if (secure) parts.push("Secure");
|
||||
|
||||
return jsonResponse({ ok: true }, 200, {
|
||||
"Set-Cookie": parts.join("; ")
|
||||
});
|
||||
}
|
||||
17
src/api/routes/config.ts
Normal file
17
src/api/routes/config.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { jsonResponse } from "../../lib/http";
|
||||
import { cleanText } from "../../lib/text";
|
||||
|
||||
export function handleConfigGet(env: Env): Response {
|
||||
const cwdApiBase =
|
||||
cleanText(env.CWD_API_BASE) || "https://cwd.api.smyhub.com";
|
||||
const cwdSiteId = cleanText(env.CWD_SITE_ID) || "";
|
||||
return jsonResponse({
|
||||
ok: true,
|
||||
config: {
|
||||
siteTitle: cleanText(env.SITE_TITLE) || "萌芽小窝",
|
||||
siteTitleEn: cleanText(env.SITE_TITLE_EN) || "SproutBlog",
|
||||
cwdApiBase,
|
||||
cwdSiteId: cwdSiteId || undefined
|
||||
}
|
||||
});
|
||||
}
|
||||
113
src/api/routes/posts.ts
Normal file
113
src/api/routes/posts.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import { jsonResponse, readBody } from "../../lib/http";
|
||||
import { requireAdmin, canAccessDrafts, hasValidAdminSession } from "../../lib/auth";
|
||||
import {
|
||||
listPosts,
|
||||
readPost,
|
||||
createOrUpdatePost,
|
||||
bumpViewCount,
|
||||
deletePost,
|
||||
type PostRow
|
||||
} from "../../db/posts";
|
||||
|
||||
function numPublished(p: PostRow): number {
|
||||
return Number(p.published) ? 1 : 0;
|
||||
}
|
||||
|
||||
async function listForRequest(
|
||||
request: Request,
|
||||
env: Env,
|
||||
url: URL
|
||||
): Promise<Response> {
|
||||
const q = (url.searchParams.get("q") || "").trim();
|
||||
const seeAll = await canAccessDrafts(request, env);
|
||||
const publishedOnly = !seeAll;
|
||||
const posts = await listPosts(env.DB, { publishedOnly, q });
|
||||
return jsonResponse({ ok: true, posts });
|
||||
}
|
||||
|
||||
export async function handlePostsCollection(
|
||||
request: Request,
|
||||
env: Env,
|
||||
url: URL
|
||||
): Promise<Response> {
|
||||
if (request.method === "GET") {
|
||||
return listForRequest(request, env, url);
|
||||
}
|
||||
|
||||
if (request.method === "POST") {
|
||||
const denied = await requireAdmin(request, env);
|
||||
if (denied) return denied;
|
||||
const data = await readBody(request);
|
||||
try {
|
||||
const created = await createOrUpdatePost(env.DB, data, null);
|
||||
return jsonResponse({ ok: true, post: created }, 201);
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
return jsonResponse({ ok: false, error: msg }, 400);
|
||||
}
|
||||
}
|
||||
|
||||
return jsonResponse({ ok: false, error: "Method not allowed" }, 405);
|
||||
}
|
||||
|
||||
export async function handlePostItem(
|
||||
request: Request,
|
||||
env: Env,
|
||||
url: URL,
|
||||
idOrSlug: string
|
||||
): Promise<Response> {
|
||||
const bump = url.searchParams.get("bump") === "1";
|
||||
|
||||
if (request.method === "GET") {
|
||||
const post = await readPost(env.DB, idOrSlug);
|
||||
if (!post) return jsonResponse({ ok: false, error: "Not found" }, 404);
|
||||
|
||||
const draftsOk = await canAccessDrafts(request, env);
|
||||
if (!draftsOk && numPublished(post) !== 1) {
|
||||
return jsonResponse({ ok: false, error: "Not found" }, 404);
|
||||
}
|
||||
|
||||
let row = post;
|
||||
const shouldBump =
|
||||
bump &&
|
||||
numPublished(post) === 1 &&
|
||||
!(await hasValidAdminSession(request, env));
|
||||
if (shouldBump) {
|
||||
await bumpViewCount(env.DB, Number(post.id));
|
||||
const refreshed = await readPost(env.DB, idOrSlug);
|
||||
if (refreshed) row = refreshed;
|
||||
}
|
||||
|
||||
return jsonResponse({ ok: true, post: row });
|
||||
}
|
||||
|
||||
if (request.method === "PUT") {
|
||||
const denied = await requireAdmin(request, env);
|
||||
if (denied) return denied;
|
||||
const data = await readBody(request);
|
||||
const existing = await readPost(env.DB, idOrSlug);
|
||||
if (!existing) return jsonResponse({ ok: false, error: "Not found" }, 404);
|
||||
try {
|
||||
const updated = await createOrUpdatePost(
|
||||
env.DB,
|
||||
data,
|
||||
Number(existing.id)
|
||||
);
|
||||
return jsonResponse({ ok: true, post: updated });
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
return jsonResponse({ ok: false, error: msg }, 400);
|
||||
}
|
||||
}
|
||||
|
||||
if (request.method === "DELETE") {
|
||||
const denied = await requireAdmin(request, env);
|
||||
if (denied) return denied;
|
||||
const post = await readPost(env.DB, idOrSlug);
|
||||
if (!post) return jsonResponse({ ok: false, error: "Not found" }, 404);
|
||||
await deletePost(env.DB, Number(post.id));
|
||||
return jsonResponse({ ok: true, deleted: post.id });
|
||||
}
|
||||
|
||||
return jsonResponse({ ok: false, error: "Method not allowed" }, 405);
|
||||
}
|
||||
9
src/bindings.d.ts
vendored
Normal file
9
src/bindings.d.ts
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
interface Env {
|
||||
DB: D1Database;
|
||||
ASSETS: Fetcher;
|
||||
SITE_TITLE: string;
|
||||
SITE_TITLE_EN: string;
|
||||
ADMIN_PASSWORD?: string;
|
||||
CWD_API_BASE?: string;
|
||||
CWD_SITE_ID?: string;
|
||||
}
|
||||
135
src/db/posts.ts
Normal file
135
src/db/posts.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import { cleanText, isTruthy } from "../lib/text";
|
||||
import { slugify } from "../lib/slug";
|
||||
|
||||
type Db = Env["DB"];
|
||||
|
||||
export type PostRow = Record<string, unknown>;
|
||||
|
||||
export function blankPost(): PostRow {
|
||||
return {
|
||||
id: "",
|
||||
title: "",
|
||||
slug: "",
|
||||
excerpt: "",
|
||||
content: "",
|
||||
published: 1
|
||||
};
|
||||
}
|
||||
|
||||
export async function getPostById(db: Db, id: number): Promise<PostRow | null> {
|
||||
return (await db
|
||||
.prepare("SELECT * FROM posts WHERE id = ? LIMIT 1")
|
||||
.bind(Number(id))
|
||||
.first()) as PostRow | null;
|
||||
}
|
||||
|
||||
export async function readPost(db: Db, idOrSlug: string): Promise<PostRow | null> {
|
||||
if (!idOrSlug) return null;
|
||||
const trimmed = decodeURIComponent(String(idOrSlug)).trim();
|
||||
if (/^\d+$/.test(trimmed)) {
|
||||
return getPostById(db, Number(trimmed));
|
||||
}
|
||||
return (await db
|
||||
.prepare("SELECT * FROM posts WHERE slug = ? LIMIT 1")
|
||||
.bind(trimmed)
|
||||
.first()) as PostRow | null;
|
||||
}
|
||||
|
||||
export async function listPosts(
|
||||
db: Db,
|
||||
{ publishedOnly = true, q = "" } = {}
|
||||
): Promise<PostRow[]> {
|
||||
const params: string[] = [];
|
||||
const where: string[] = [];
|
||||
if (publishedOnly) where.push("published = 1");
|
||||
if (q) {
|
||||
where.push("(title LIKE ? OR slug LIKE ? OR excerpt LIKE ? OR content LIKE ?)");
|
||||
const like = `%${q}%`;
|
||||
params.push(like, like, like, like);
|
||||
}
|
||||
const sql = `
|
||||
SELECT * FROM posts
|
||||
${where.length ? `WHERE ${where.join(" AND ")}` : ""}
|
||||
ORDER BY published DESC, updated_at DESC, id DESC
|
||||
`;
|
||||
const statement = db.prepare(sql);
|
||||
const { results } = params.length
|
||||
? await statement.bind(...params).all()
|
||||
: await statement.all();
|
||||
return (results || []) as PostRow[];
|
||||
}
|
||||
|
||||
export async function uniqueSlug(
|
||||
db: Db,
|
||||
baseSlug: string,
|
||||
currentId: number | null
|
||||
): Promise<string> {
|
||||
let slug = baseSlug || "post";
|
||||
let suffix = 2;
|
||||
|
||||
while (true) {
|
||||
const existing = (await db
|
||||
.prepare("SELECT id FROM posts WHERE slug = ? LIMIT 1")
|
||||
.bind(slug)
|
||||
.first()) as { id: number } | null;
|
||||
if (!existing || Number(existing.id) === Number(currentId)) return slug;
|
||||
slug = `${baseSlug || "post"}-${suffix++}`;
|
||||
}
|
||||
}
|
||||
|
||||
export async function createOrUpdatePost(
|
||||
db: Db,
|
||||
data: Record<string, unknown>,
|
||||
currentId: number | null
|
||||
): Promise<PostRow | null> {
|
||||
const rawId = currentId != null ? Number(currentId) : NaN;
|
||||
const id = Number.isFinite(rawId) && rawId > 0 ? rawId : null;
|
||||
const title = cleanText(data.title);
|
||||
const content = cleanText(data.content);
|
||||
const excerpt = cleanText(data.excerpt);
|
||||
const slugBase = cleanText(data.slug) || title;
|
||||
const published = isTruthy(data.published) ? 1 : 0;
|
||||
|
||||
if (!title || !content) {
|
||||
throw new Error("title 和 content 不能为空");
|
||||
}
|
||||
|
||||
const slug = await uniqueSlug(db, slugify(slugBase), id);
|
||||
const now = new Date().toISOString();
|
||||
|
||||
if (id) {
|
||||
await db
|
||||
.prepare(`
|
||||
UPDATE posts
|
||||
SET title = ?, slug = ?, excerpt = ?, content = ?, published = ?, updated_at = ?
|
||||
WHERE id = ?
|
||||
`)
|
||||
.bind(title, slug, excerpt, content, published, now, id)
|
||||
.run();
|
||||
return getPostById(db, id);
|
||||
}
|
||||
|
||||
const result = await db
|
||||
.prepare(`
|
||||
INSERT INTO posts (title, slug, excerpt, content, published, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`)
|
||||
.bind(title, slug, excerpt, content, published, now, now)
|
||||
.run();
|
||||
const newId = result.meta?.last_row_id;
|
||||
if (newId == null) return null;
|
||||
return getPostById(db, Number(newId));
|
||||
}
|
||||
|
||||
export async function bumpViewCount(db: Db, postId: number): Promise<void> {
|
||||
await db
|
||||
.prepare(
|
||||
"UPDATE posts SET view_count = COALESCE(view_count, 0) + 1 WHERE id = ?"
|
||||
)
|
||||
.bind(postId)
|
||||
.run();
|
||||
}
|
||||
|
||||
export async function deletePost(db: Db, id: number): Promise<void> {
|
||||
await db.prepare("DELETE FROM posts WHERE id = ?").bind(id).run();
|
||||
}
|
||||
78
src/lib/auth.ts
Normal file
78
src/lib/auth.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { cleanText } from "./text";
|
||||
import { getAdminPassword, unauthorized } from "./http";
|
||||
|
||||
export function parseCookie(header: string): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
if (!header) return out;
|
||||
for (const part of header.split(";")) {
|
||||
const idx = part.indexOf("=");
|
||||
if (idx === -1) continue;
|
||||
const k = part.slice(0, idx).trim();
|
||||
const v = part.slice(idx + 1).trim();
|
||||
try {
|
||||
out[k] = decodeURIComponent(v);
|
||||
} catch {
|
||||
out[k] = v;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export async function adminSessionCookieValue(env: Env): Promise<string> {
|
||||
const password = getAdminPassword(env);
|
||||
const enc = new TextEncoder();
|
||||
const data = enc.encode(`${password}\0sproutblog_admin_v1`);
|
||||
const hash = await crypto.subtle.digest("SHA-256", data);
|
||||
return [...new Uint8Array(hash)]
|
||||
.map((b) => b.toString(16).padStart(2, "0"))
|
||||
.join("");
|
||||
}
|
||||
|
||||
/**
|
||||
* 需要后台权限的接口:未配置密码则放行;否则校验 Cookie 或 Basic Auth。
|
||||
* 返回 null 表示已通过;返回 Response 为 401。
|
||||
*/
|
||||
export async function requireAdmin(
|
||||
request: Request,
|
||||
env: Env
|
||||
): Promise<Response | null> {
|
||||
const password = getAdminPassword(env);
|
||||
if (!password) return null;
|
||||
|
||||
const cookies = parseCookie(request.headers.get("Cookie") || "");
|
||||
const expected = await adminSessionCookieValue(env);
|
||||
if (cookies.sb_admin && cookies.sb_admin === expected) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const header = request.headers.get("Authorization") || "";
|
||||
if (header.startsWith("Basic ")) {
|
||||
try {
|
||||
const decoded = atob(header.slice(6));
|
||||
const split = decoded.indexOf(":");
|
||||
const inputPassword = split >= 0 ? decoded.slice(split + 1) : "";
|
||||
if (inputPassword === password) return null;
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
return unauthorized();
|
||||
}
|
||||
|
||||
/** 已配置密码且当前请求具备后台凭证(Cookie / Basic) */
|
||||
export async function hasValidAdminSession(
|
||||
request: Request,
|
||||
env: Env
|
||||
): Promise<boolean> {
|
||||
const password = getAdminPassword(env);
|
||||
if (!password) return false;
|
||||
return (await requireAdmin(request, env)) === null;
|
||||
}
|
||||
|
||||
/** 是否可列出/阅读草稿(未配置密码时保持原先「全量可见」行为) */
|
||||
export async function canAccessDrafts(request: Request, env: Env): Promise<boolean> {
|
||||
const password = getAdminPassword(env);
|
||||
if (!password) return true;
|
||||
return (await requireAdmin(request, env)) === null;
|
||||
}
|
||||
63
src/lib/http.ts
Normal file
63
src/lib/http.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { cleanText } from "./text";
|
||||
|
||||
export function corsHeaders(): Record<string, string> {
|
||||
return {
|
||||
"access-control-allow-origin": "*",
|
||||
"access-control-allow-methods": "GET, POST, PUT, DELETE, OPTIONS",
|
||||
"access-control-allow-headers": "*"
|
||||
};
|
||||
}
|
||||
|
||||
export function jsonResponse(
|
||||
data: unknown,
|
||||
status = 200,
|
||||
extraHeaders: Record<string, string> = {}
|
||||
): Response {
|
||||
return new Response(JSON.stringify(data, null, 2), {
|
||||
status,
|
||||
headers: {
|
||||
"content-type": "application/json; charset=UTF-8",
|
||||
...corsHeaders(),
|
||||
...extraHeaders
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function readBody(request: Request): Promise<Record<string, unknown>> {
|
||||
const type = request.headers.get("content-type") || "";
|
||||
if (type.includes("application/json")) {
|
||||
try {
|
||||
return (await request.json()) as Record<string, unknown>;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
type.includes("multipart/form-data") ||
|
||||
type.includes("application/x-www-form-urlencoded")
|
||||
) {
|
||||
const form = await request.formData();
|
||||
const data: Record<string, unknown> = {};
|
||||
for (const [key, value] of form.entries()) {
|
||||
data[key] = value;
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
export function unauthorized(): Response {
|
||||
return new Response("Authentication required", {
|
||||
status: 401,
|
||||
headers: {
|
||||
"content-type": "text/plain; charset=UTF-8"
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Basic Auth / Cookie 中的密码校验用原始字符串 */
|
||||
export function getAdminPassword(env: Pick<Env, "ADMIN_PASSWORD">): string {
|
||||
return cleanText(env.ADMIN_PASSWORD);
|
||||
}
|
||||
18
src/lib/slug.ts
Normal file
18
src/lib/slug.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { cleanText } from "./text";
|
||||
|
||||
export function normalizePath(pathname: string): string {
|
||||
if (pathname === "/") return "/";
|
||||
return pathname.replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
export function slugify(value: unknown): string {
|
||||
const raw = cleanText(value);
|
||||
const slug = raw
|
||||
.normalize("NFKD")
|
||||
.replace(/[\u0300-\u036f]/g, "")
|
||||
.replace(/[^\p{L}\p{N}]+/gu, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.replace(/-+/g, "-")
|
||||
.toLowerCase();
|
||||
return slug || "post";
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user