feat: 登录记住本机+密码可见、内容分简介与主体、Markdown 渲染、首页随机条目
This commit is contained in:
parent
a5c5d24083
commit
c09857cc81
13
package-lock.json
generated
13
package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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"
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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("登录成功,正在进入管理台…");
|
||||
|
||||
@ -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)) {
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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 '',
|
||||
|
||||
@ -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 } });
|
||||
});
|
||||
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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]
|
||||
);
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -14,16 +14,16 @@
|
||||
<button class="btn btn-sm" type="button" data-copy='请通过 awesome-index 的 MCP 接口调用 get_entry,slug 为 "<%= 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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -16,10 +16,16 @@
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="login-pass">密码</label>
|
||||
<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>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user