From c09857cc810d8a163a79b8365644d33f09d31679 Mon Sep 17 00:00:00 2001 From: cheney Date: Tue, 1 Sep 2026 10:53:17 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E7=99=BB=E5=BD=95=E8=AE=B0=E4=BD=8F?= =?UTF-8?q?=E6=9C=AC=E6=9C=BA+=E5=AF=86=E7=A0=81=E5=8F=AF=E8=A7=81?= =?UTF-8?q?=E3=80=81=E5=86=85=E5=AE=B9=E5=88=86=E7=AE=80=E4=BB=8B=E4=B8=8E?= =?UTF-8?q?=E4=B8=BB=E4=BD=93=E3=80=81Markdown=20=E6=B8=B2=E6=9F=93?= =?UTF-8?q?=E3=80=81=E9=A6=96=E9=A1=B5=E9=9A=8F=E6=9C=BA=E6=9D=A1=E7=9B=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package-lock.json | 13 ++++++ package.json | 1 + public/css/main.css | 82 ++++++++++++++++++++++++++++++++++ public/js/admin.js | 13 ------ public/js/main.js | 17 +++++++ src/app.js | 9 ++++ src/db/migrate.js | 1 + src/db/schema.sql | 1 + src/routes/admin.routes.js | 5 +++ src/routes/entries.routes.js | 2 + src/routes/pages.routes.js | 15 ++++++- src/services/entry.service.js | 13 +++--- src/services/search.service.js | 2 +- src/views/admin-entry-edit.ejs | 2 + src/views/detail.ejs | 8 ++-- src/views/index.ejs | 25 ++++++----- src/views/list.ejs | 2 +- src/views/login.ejs | 10 ++++- 18 files changed, 180 insertions(+), 41 deletions(-) diff --git a/package-lock.json b/package-lock.json index 9239875..0956ba1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "ejs": "^3.1.10", "express": "^5.1.0", "express-session": "^1.18.1", + "marked": "^18.0.11", "node-cron": "^4.6.0", "zod": "^3.25.76" }, @@ -1139,6 +1140,18 @@ "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", "license": "BSD-2-Clause" }, + "node_modules/marked": { + "version": "18.0.11", + "resolved": "https://registry.npmmirror.com/marked/-/marked-18.0.11.tgz", + "integrity": "sha512-HnslJfsZkRPBDJRHvVtAaWlZHEpSu7u8LgQuJCELjRKuWR+hpq4A7sLq3p8HaI9ypVoXDXxV34CsQJEe1+J5Aw==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmmirror.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz", diff --git a/package.json b/package.json index 65dab4c..a5feaf3 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "ejs": "^3.1.10", "express": "^5.1.0", "express-session": "^1.18.1", + "marked": "^18.0.11", "node-cron": "^4.6.0", "zod": "^3.25.76" } diff --git a/public/css/main.css b/public/css/main.css index fbff0ab..3c928d1 100644 --- a/public/css/main.css +++ b/public/css/main.css @@ -1171,6 +1171,81 @@ button.chip:hover { overflow-x: auto; white-space: pre; } +.md-body { + line-height: 1.7; + font-size: 14.5px; +} +.md-body h1, +.md-body h2, +.md-body h3, +.md-body h4 { + font-weight: 700; + margin: 22px 0 10px; +} +.md-body h2 { + font-size: 20px; +} +.md-body h3 { + font-size: 16.5px; +} +.md-body p { + margin: 10px 0; +} +.md-body a { + color: var(--riso-blue); + text-decoration: underline; +} +.md-body ul, +.md-body ol { + margin: 10px 0; + padding-left: 22px; +} +.md-body li { + margin: 4px 0; +} +.md-body code { + font-family: var(--font-mono); + font-size: 12.5px; + background: var(--riso-blue-soft); + border-radius: 5px; + padding: 2px 5px; +} +.md-body pre { + background: var(--panel); + color: #cdd3ea; + border-radius: var(--r-md); + padding: 16px 18px; + overflow-x: auto; + font-family: var(--font-mono); + font-size: 12.5px; + line-height: 1.6; + margin: 14px 0; +} +.md-body pre code { + background: none; + padding: 0; + color: inherit; +} +.md-body blockquote { + border-left: 3px solid var(--ink-faint); + padding-left: 14px; + color: var(--ink-dim); + margin: 12px 0; +} +.md-body table { + border-collapse: collapse; + margin: 14px 0; + font-size: 13.5px; +} +.md-body th, +.md-body td { + border: 1.5px solid var(--ink-faint); + padding: 7px 12px; + text-align: left; +} +.md-body th { + background: var(--riso-blue-soft); +} .side-card { background: var(--paper-raised); border: var(--border); @@ -1603,6 +1678,13 @@ button.chip:hover { width: 100%; padding-right: 42px; } +.pwd-field { + position: relative; +} +.pwd-field .input { + width: 100%; + padding-right: 42px; +} .eye-toggle { position: absolute; right: 8px; diff --git a/public/js/admin.js b/public/js/admin.js index cfb1333..041f1cc 100644 --- a/public/js/admin.js +++ b/public/js/admin.js @@ -139,19 +139,6 @@ }); } - document.querySelectorAll("[data-eye-toggle]").forEach(function (btn) { - btn.addEventListener("click", function () { - var input = btn.closest("form").querySelector("input[name=key]"); - var show = input.type === "password"; - input.type = show ? "text" : "password"; - btn.setAttribute("aria-label", show ? "隐藏密钥" : "显示密钥"); - var open = btn.querySelector(".eye-open"); - var closed = btn.querySelector(".eye-closed"); - if (open) open.hidden = show; - if (closed) closed.hidden = !show; - }); - }); - document.querySelectorAll("form.source-config").forEach(function (form) { form.addEventListener("submit", function (e) { e.preventDefault(); diff --git a/public/js/main.js b/public/js/main.js index f459fd2..c41b684 100644 --- a/public/js/main.js +++ b/public/js/main.js @@ -118,6 +118,22 @@ }); }); + document.querySelectorAll("[data-eye-toggle]").forEach(function (btn) { + btn.addEventListener("click", function () { + var input = btn.closest(".pwd-field, .ai-key-field") + ? btn.closest(".pwd-field, .ai-key-field").querySelector("input") + : null; + if (!input) return; + var show = input.type === "password"; + input.type = show ? "text" : "password"; + btn.setAttribute("aria-label", show ? "隐藏密码" : "显示密码"); + var open = btn.querySelector(".eye-open"); + var closed = btn.querySelector(".eye-closed"); + if (open) open.hidden = show; + if (closed) closed.hidden = !show; + }); + }); + var loginForm = document.getElementById("login-form"); if (loginForm) { loginForm.addEventListener("submit", function (e) { @@ -126,6 +142,7 @@ postJson("/api/admin/login", { username: loginForm.username.value, password: loginForm.password.value, + remember: loginForm.remember ? loginForm.remember.checked : true, }) .then(function () { toast("登录成功,正在进入管理台…"); diff --git a/src/app.js b/src/app.js index af90c95..27a298a 100644 --- a/src/app.js +++ b/src/app.js @@ -2,6 +2,7 @@ import express from "express"; import session from "express-session"; import path from "node:path"; import { fileURLToPath } from "node:url"; +import { marked } from "marked"; import { loadUser } from "./middleware/auth.js"; import { notFoundHandler, errorHandler } from "./middleware/errors.js"; import { pagesRouter } from "./routes/pages.routes.js"; @@ -26,6 +27,14 @@ export function buildApp(config) { }; app.locals.fmtDate = (d) => d ? new Date(d).toISOString().slice(0, 10) : "—"; + app.locals.md = (s) => marked.parse(String(s || "")); + app.locals.excerpt = (s, n = 200) => { + const plain = String(s || "") + .replace(/[#*`>_\[\]()!-]/g, " ") + .replace(/\s+/g, " ") + .trim(); + return plain.length > n ? plain.slice(0, n).trim() + "…" : plain; + }; app.locals.qs = (obj) => { const p = new URLSearchParams(); for (const [k, v] of Object.entries(obj)) { diff --git a/src/db/migrate.js b/src/db/migrate.js index 0a78138..7c523aa 100644 --- a/src/db/migrate.js +++ b/src/db/migrate.js @@ -81,6 +81,7 @@ export async function seedTagsAndEntries() { export async function runMigrate(config) { await runSchema(); await run(`ALTER TABLE sources ADD COLUMN IF NOT EXISTS api_key VARCHAR`); + await run(`ALTER TABLE entries ADD COLUMN IF NOT EXISTS summary VARCHAR DEFAULT ''`); const admin = await findByUsername("admin"); if (!admin) { diff --git a/src/db/schema.sql b/src/db/schema.sql index 05847c8..2872fab 100644 --- a/src/db/schema.sql +++ b/src/db/schema.sql @@ -54,6 +54,7 @@ CREATE TABLE IF NOT EXISTS entries ( title VARCHAR NOT NULL, slug VARCHAR UNIQUE NOT NULL, type VARCHAR NOT NULL, + summary VARCHAR DEFAULT '', description_md TEXT DEFAULT '', url VARCHAR DEFAULT '', license VARCHAR DEFAULT '', diff --git a/src/routes/admin.routes.js b/src/routes/admin.routes.js index 564c3b9..4583395 100644 --- a/src/routes/admin.routes.js +++ b/src/routes/admin.routes.js @@ -23,6 +23,11 @@ adminRouter.post("/api/admin/login", async (req, res) => { req.session.userId = user.id; req.session.username = user.username; req.session.role = user.role; + if (req.body.remember === true || req.body.remember === "true") { + req.session.cookie.maxAge = 1000 * 60 * 60 * 24 * 14; + } else { + req.session.cookie.maxAge = null; + } res.json({ ok: true, data: { username: user.username, role: user.role } }); }); diff --git a/src/routes/entries.routes.js b/src/routes/entries.routes.js index 6fa6b7e..6e3c92f 100644 --- a/src/routes/entries.routes.js +++ b/src/routes/entries.routes.js @@ -44,6 +44,7 @@ const entryInput = z.object({ title: z.string().min(1), url: z.string().url(), type: z.enum(entryService.ENTRY_TYPES), + summary: z.string().max(1000).optional(), description_md: z.string().max(5000).optional(), license: z.string().optional(), stars: z.number().nonnegative().optional(), @@ -77,6 +78,7 @@ const patchInput = z.object({ title: z.string().min(1).optional(), url: z.string().url().optional(), type: z.enum(entryService.ENTRY_TYPES).optional(), + summary: z.string().max(1000).optional(), description_md: z.string().max(5000).optional(), license: z.string().optional(), stars: z.number().nonnegative().optional(), diff --git a/src/routes/pages.routes.js b/src/routes/pages.routes.js index 9a33b82..e32f34c 100644 --- a/src/routes/pages.routes.js +++ b/src/routes/pages.routes.js @@ -21,10 +21,19 @@ pagesRouter.get("/healthz", (_req, res) => { pagesRouter.get("/", async (_req, res, next) => { try { + const [stats, recent, hero] = await Promise.all([ + searchService.stats(), + entryService.listRecent(4), + (async () => { + const rand = await searchService.randomActive(); + return rand ? entryService.getById(rand.id) : null; + })(), + ]); res.render("index", { title: "Awesome Index · 把网上牛逼的东西收进一个库", - stats: await searchService.stats(), - recent: await entryService.listRecent(4), + stats, + recent, + hero, }); } catch (e) { next(e); @@ -72,6 +81,7 @@ pagesRouter.get("/entries/:slug", async (req, res, next) => { id: entry.slug, type: entry.type, name: entry.title, + summary: entry.summary, description: entry.description_md, url: entry.url, tags: entry.tags, @@ -162,6 +172,7 @@ pagesRouter.post("/admin/entries/:id/edit", async (req, res, next) => { const patch = { title: String(req.body.title || "").trim(), type: req.body.type, + summary: String(req.body.summary || "").trim(), description_md: String(req.body.description_md || ""), url: String(req.body.url || "").trim(), license: String(req.body.license || "").trim(), diff --git a/src/services/entry.service.js b/src/services/entry.service.js index 94b260e..901ae6b 100644 --- a/src/services/entry.service.js +++ b/src/services/entry.service.js @@ -83,12 +83,13 @@ export async function createEntry(data, { source } = {}) { const status = decideStatus(source, data.status); const slug = data.slug || (await slugify(data.title)); await run( - `INSERT INTO entries (title, slug, type, description_md, url, license, stars, status, source_id, verified_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + `INSERT INTO entries (title, slug, type, summary, description_md, url, license, stars, status, source_id, verified_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [ data.title.trim(), slug, data.type, + data.summary || "", data.description_md || "", data.url.trim(), data.license || "", @@ -103,7 +104,7 @@ export async function createEntry(data, { source } = {}) { return { created: true, id: row.id, status }; } -const EDITABLE = ["title", "type", "description_md", "url", "license", "stars"]; +const EDITABLE = ["title", "type", "summary", "description_md", "url", "license", "stars"]; export async function updateEntry(id, patch, { isHumanEditor = false } = {}) { const entry = await get(`SELECT * FROM entries WHERE id = ?`, [id]); @@ -116,9 +117,9 @@ export async function updateEntry(id, patch, { isHumanEditor = false } = {}) { if (patch[key] !== undefined) next[key] = patch[key]; } await run( - `UPDATE entries SET title = ?, type = ?, description_md = ?, url = ?, license = ?, stars = ?, updated_at = now() + `UPDATE entries SET title = ?, type = ?, summary = ?, description_md = ?, url = ?, license = ?, stars = ?, updated_at = now() WHERE id = ?`, - [next.title, next.type, next.description_md, next.url, next.license, Number(next.stars) || 0, id] + [next.title, next.type, next.summary || "", next.description_md, next.url, next.license, Number(next.stars) || 0, id] ); if (patch.tags !== undefined) await linkTags(id, patch.tags); if (isHumanEditor) { @@ -185,7 +186,7 @@ export async function getBySlug(slug) { export async function listRecent(limit = 4) { const rows = await all( - `SELECT e.id, e.title, e.slug, e.type, e.description_md, e.url, e.stars, e.updated_at + `SELECT e.id, e.title, e.slug, e.type, e.summary, e.description_md, e.url, e.stars, e.updated_at FROM entries e WHERE e.status = 'active' ORDER BY e.created_at DESC LIMIT ?`, [limit] diff --git a/src/services/search.service.js b/src/services/search.service.js index 33e44d3..454837a 100644 --- a/src/services/search.service.js +++ b/src/services/search.service.js @@ -56,7 +56,7 @@ export async function searchEntries({ [...cteParams, ...params] ); const items = await all( - `${cte} SELECT e.id, e.title, e.slug, e.type, e.description_md, e.url, e.license, e.stars, e.status, e.updated_at + `${cte} SELECT e.id, e.title, e.slug, e.type, e.summary, e.description_md, e.url, e.license, e.stars, e.status, e.updated_at FROM entries e ${whereSql} ORDER BY ${orderBy} LIMIT ? OFFSET ?`, [...cteParams, ...params, perPage, offset] ); diff --git a/src/views/admin-entry-edit.ejs b/src/views/admin-entry-edit.ejs index 847ac80..c9bb533 100644 --- a/src/views/admin-entry-edit.ejs +++ b/src/views/admin-entry-edit.ejs @@ -15,6 +15,8 @@

基础信息

+
+
diff --git a/src/views/detail.ejs b/src/views/detail.ejs index 2e25292..9c8d1c7 100644 --- a/src/views/detail.ejs +++ b/src/views/detail.ejs @@ -14,16 +14,16 @@
-

<%= entry.description_md %>

+

<%= excerpt(entry.summary || entry.description_md, 200) %>

-

// entry.body · markdown source

+

// entry.body · markdown rendered

这是什么

-

<%= entry.description_md || '暂无介绍。' %>

-

仓库地址:<%= entry.url %>

+
<%- md(entry.description_md || '暂无介绍。') %>
+

仓库地址:<%= entry.url %>

diff --git a/src/views/index.ejs b/src/views/index.ejs index 4366138..afcd0bb 100644 --- a/src/views/index.ejs +++ b/src/views/index.ejs @@ -33,21 +33,22 @@