feat: 登录记住本机+密码可见、内容分简介与主体、Markdown 渲染、首页随机条目
All checks were successful
Build and Deploy / build (push) Successful in 56s
Build and Deploy / deploy (push) Successful in 28s

This commit is contained in:
cheney 2026-09-01 10:53:17 +08:00
parent a5c5d24083
commit c09857cc81
18 changed files with 180 additions and 41 deletions

13
package-lock.json generated
View File

@ -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",

View File

@ -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"
}

View File

@ -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;

View File

@ -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();

View File

@ -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("登录成功,正在进入管理台…");

View File

@ -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)) {

View File

@ -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) {

View File

@ -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 '',

View File

@ -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 } });
});

View File

@ -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(),

View File

@ -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(),

View File

@ -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]

View File

@ -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]
);

View File

@ -15,6 +15,8 @@
<form id="entry-edit-form" method="post" action="/admin/entries/<%= entry.id %>/edit" style="margin-top:22px;display:grid;gap:18px;max-width:820px">
<div class="panel-box">
<h3>基础信息</h3>
<div class="field" style="margin-top:0"><label for="f-summary">简介≤200 字,列表与详情页头部展示)</label>
<input class="input" id="f-summary" name="summary" maxlength="200" value="<%= entry.summary || '' %>" /></div>
<div class="form-row">
<div class="field" style="flex:2"><label for="f-title">标题</label>
<input class="input" id="f-title" name="title" value="<%= entry.title %>" required /></div>

View File

@ -14,16 +14,16 @@
<button class="btn btn-sm" type="button" data-copy='请通过 awesome-index 的 MCP 接口调用 get_entryslug 为 "<%= entry.slug %>"'>复制给 AI</button>
</div>
</div>
<p style="margin-top:12px;color:var(--ink-dim);max-width:56ch"><%= entry.description_md %></p>
<p style="margin-top:12px;color:var(--ink-dim);max-width:64ch"><%= excerpt(entry.summary || entry.description_md, 200) %></p>
</div>
<div class="detail-layout">
<article class="detail-body">
<section>
<p class="anno">// entry.body · markdown source</p>
<p class="anno">// entry.body · markdown rendered</p>
<h2>这是什么</h2>
<p><%= entry.description_md || '暂无介绍。' %></p>
<p style="margin-top:10px">仓库地址:<a class="mono" style="color:var(--riso-blue)" href="<%= entry.url %>" target="_blank" rel="noopener"><%= entry.url %></a></p>
<div class="md-body"><%- md(entry.description_md || '暂无介绍。') %></div>
<p style="margin-top:16px">仓库地址:<a class="mono" style="color:var(--riso-blue)" href="<%= entry.url %>" target="_blank" rel="noopener"><%= entry.url %></a></p>
</section>
<section>

View File

@ -33,21 +33,22 @@
</div>
<div class="hero-visual rise d3" role="presentation">
<p class="spec-label mono">// get_entry("ripgrep") · 同一条数据,两种读者</p>
<% const h = hero || { slug: 'ripgrep', title: 'ripgrep', type: '工具', summary: '', description_md: '以正则递归搜索目录,默认尊重 .gitignore是代码库里最快的找东西方式。', tags: ['Rust', 'CLI'], stars: 52300 }; %>
<p class="spec-label mono">// get_entry("<%= h.slug %>") · 同一条数据,两种读者</p>
<div class="spec-machine mono" aria-hidden="true">{
"id": "ripgrep",
"type": "tool",
"name": "ripgrep",
"tags": ["Rust", "CLI"],
"stars": <%= typeof recent[0] !== 'undefined' ? Number(recent[0].stars) : 52300 %>,
"id": "<%= h.slug %>",
"type": "<%= h.type %>",
"name": "<%= h.title %>",
"tags": <%= JSON.stringify(h.tags || []) %>,
"stars": <%= Number(h.stars) %>,
"status": "active"
}</div>
<div class="spec-human" aria-hidden="true">
<span class="chip chip-type">工具</span>
<h3>ripgrep</h3>
<p>以正则递归搜索目录,默认尊重 .gitignore是代码库里最快的找东西方式。</p>
<div class="card-tags"><span class="chip">Rust</span><span class="chip">CLI</span></div>
<p class="card-meta">★ <%= fmtStars(52300) %> · 终端找东西的事实标准</p>
<span class="chip chip-type"><%= h.type %></span>
<h3><%= h.title %></h3>
<p><%= h.summary || excerpt(h.description_md, 90) %></p>
<div class="card-tags"><% for (const t of (h.tags || []).slice(0, 3)) { %><span class="chip"><%= t %></span><% } %></div>
<p class="card-meta">★ <%= fmtStars(h.stars) %> · 随机一条,看看这个库</p>
</div>
<button class="btn btn-random" id="random-btn" type="button" title="随机打开一条收录,没有头绪时让库替你挑一个">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" aria-hidden="true"><path d="M3 7h4l10 10h4M17 3l4 4-4 4M3 17h4l2.5-2.5M21 17l-4 4"/></svg>
@ -129,7 +130,7 @@
<a class="card" href="/entries/<%= e.slug %>">
<span class="chip chip-type"><%= e.type %></span>
<h3><%= e.title %></h3>
<p><%= e.description_md %></p>
<p><%= e.summary || excerpt(e.description_md, 120) %></p>
<div class="card-tags">
<% for (const t of (e.tags || []).slice(0, 3)) { %><span class="chip"><%= t %></span><% } %>
</div>

View File

@ -78,7 +78,7 @@
<span class="chip chip-type"><%= e.type %></span>
<div class="result-main">
<h3><%= e.title %></h3>
<p><%= e.description_md %></p>
<p><%= e.summary || excerpt(e.description_md, 160) %></p>
<div class="card-tags">
<% for (const t of e.tags.slice(0, 4)) { %><span class="chip"><%= t %></span><% } %>
</div>

View File

@ -16,10 +16,16 @@
</div>
<div class="field">
<label for="login-pass">密码</label>
<input class="input" id="login-pass" name="password" type="password" placeholder="••••••••" autocomplete="current-password" required />
<div class="pwd-field">
<input class="input" id="login-pass" name="password" type="password" placeholder="••••••••" autocomplete="current-password" required />
<button type="button" class="eye-toggle" data-eye-toggle aria-label="显示密码">
<svg class="eye-open" viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
<svg class="eye-closed" viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" hidden><path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"/><line x1="1" y1="1" x2="23" y2="23"/></svg>
</button>
</div>
</div>
<div class="auth-row">
<label class="checkline" style="padding:0"><input type="checkbox" checked /> 记住这台设备</label>
<label class="checkline" style="padding:0"><input type="checkbox" name="remember" checked /> 记住这台设备</label>
<a href="mailto:admin@honor3.com">忘记密码?</a>
</div>
<button class="btn btn-primary btn-block" type="submit">登 录</button>