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",
|
"ejs": "^3.1.10",
|
||||||
"express": "^5.1.0",
|
"express": "^5.1.0",
|
||||||
"express-session": "^1.18.1",
|
"express-session": "^1.18.1",
|
||||||
|
"marked": "^18.0.11",
|
||||||
"node-cron": "^4.6.0",
|
"node-cron": "^4.6.0",
|
||||||
"zod": "^3.25.76"
|
"zod": "^3.25.76"
|
||||||
},
|
},
|
||||||
@ -1139,6 +1140,18 @@
|
|||||||
"integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==",
|
"integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==",
|
||||||
"license": "BSD-2-Clause"
|
"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": {
|
"node_modules/math-intrinsics": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmmirror.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
"resolved": "https://registry.npmmirror.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||||
|
|||||||
@ -20,6 +20,7 @@
|
|||||||
"ejs": "^3.1.10",
|
"ejs": "^3.1.10",
|
||||||
"express": "^5.1.0",
|
"express": "^5.1.0",
|
||||||
"express-session": "^1.18.1",
|
"express-session": "^1.18.1",
|
||||||
|
"marked": "^18.0.11",
|
||||||
"node-cron": "^4.6.0",
|
"node-cron": "^4.6.0",
|
||||||
"zod": "^3.25.76"
|
"zod": "^3.25.76"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1171,6 +1171,81 @@ button.chip:hover {
|
|||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
white-space: pre;
|
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 {
|
.side-card {
|
||||||
background: var(--paper-raised);
|
background: var(--paper-raised);
|
||||||
border: var(--border);
|
border: var(--border);
|
||||||
@ -1603,6 +1678,13 @@ button.chip:hover {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
padding-right: 42px;
|
padding-right: 42px;
|
||||||
}
|
}
|
||||||
|
.pwd-field {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.pwd-field .input {
|
||||||
|
width: 100%;
|
||||||
|
padding-right: 42px;
|
||||||
|
}
|
||||||
.eye-toggle {
|
.eye-toggle {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: 8px;
|
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) {
|
document.querySelectorAll("form.source-config").forEach(function (form) {
|
||||||
form.addEventListener("submit", function (e) {
|
form.addEventListener("submit", function (e) {
|
||||||
e.preventDefault();
|
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");
|
var loginForm = document.getElementById("login-form");
|
||||||
if (loginForm) {
|
if (loginForm) {
|
||||||
loginForm.addEventListener("submit", function (e) {
|
loginForm.addEventListener("submit", function (e) {
|
||||||
@ -126,6 +142,7 @@
|
|||||||
postJson("/api/admin/login", {
|
postJson("/api/admin/login", {
|
||||||
username: loginForm.username.value,
|
username: loginForm.username.value,
|
||||||
password: loginForm.password.value,
|
password: loginForm.password.value,
|
||||||
|
remember: loginForm.remember ? loginForm.remember.checked : true,
|
||||||
})
|
})
|
||||||
.then(function () {
|
.then(function () {
|
||||||
toast("登录成功,正在进入管理台…");
|
toast("登录成功,正在进入管理台…");
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import express from "express";
|
|||||||
import session from "express-session";
|
import session from "express-session";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { marked } from "marked";
|
||||||
import { loadUser } from "./middleware/auth.js";
|
import { loadUser } from "./middleware/auth.js";
|
||||||
import { notFoundHandler, errorHandler } from "./middleware/errors.js";
|
import { notFoundHandler, errorHandler } from "./middleware/errors.js";
|
||||||
import { pagesRouter } from "./routes/pages.routes.js";
|
import { pagesRouter } from "./routes/pages.routes.js";
|
||||||
@ -26,6 +27,14 @@ export function buildApp(config) {
|
|||||||
};
|
};
|
||||||
app.locals.fmtDate = (d) =>
|
app.locals.fmtDate = (d) =>
|
||||||
d ? new Date(d).toISOString().slice(0, 10) : "—";
|
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) => {
|
app.locals.qs = (obj) => {
|
||||||
const p = new URLSearchParams();
|
const p = new URLSearchParams();
|
||||||
for (const [k, v] of Object.entries(obj)) {
|
for (const [k, v] of Object.entries(obj)) {
|
||||||
|
|||||||
@ -81,6 +81,7 @@ export async function seedTagsAndEntries() {
|
|||||||
export async function runMigrate(config) {
|
export async function runMigrate(config) {
|
||||||
await runSchema();
|
await runSchema();
|
||||||
await run(`ALTER TABLE sources ADD COLUMN IF NOT EXISTS api_key VARCHAR`);
|
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");
|
const admin = await findByUsername("admin");
|
||||||
if (!admin) {
|
if (!admin) {
|
||||||
|
|||||||
@ -54,6 +54,7 @@ CREATE TABLE IF NOT EXISTS entries (
|
|||||||
title VARCHAR NOT NULL,
|
title VARCHAR NOT NULL,
|
||||||
slug VARCHAR UNIQUE NOT NULL,
|
slug VARCHAR UNIQUE NOT NULL,
|
||||||
type VARCHAR NOT NULL,
|
type VARCHAR NOT NULL,
|
||||||
|
summary VARCHAR DEFAULT '',
|
||||||
description_md TEXT DEFAULT '',
|
description_md TEXT DEFAULT '',
|
||||||
url VARCHAR DEFAULT '',
|
url VARCHAR DEFAULT '',
|
||||||
license VARCHAR DEFAULT '',
|
license VARCHAR DEFAULT '',
|
||||||
|
|||||||
@ -23,6 +23,11 @@ adminRouter.post("/api/admin/login", async (req, res) => {
|
|||||||
req.session.userId = user.id;
|
req.session.userId = user.id;
|
||||||
req.session.username = user.username;
|
req.session.username = user.username;
|
||||||
req.session.role = user.role;
|
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 } });
|
res.json({ ok: true, data: { username: user.username, role: user.role } });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -44,6 +44,7 @@ const entryInput = z.object({
|
|||||||
title: z.string().min(1),
|
title: z.string().min(1),
|
||||||
url: z.string().url(),
|
url: z.string().url(),
|
||||||
type: z.enum(entryService.ENTRY_TYPES),
|
type: z.enum(entryService.ENTRY_TYPES),
|
||||||
|
summary: z.string().max(1000).optional(),
|
||||||
description_md: z.string().max(5000).optional(),
|
description_md: z.string().max(5000).optional(),
|
||||||
license: z.string().optional(),
|
license: z.string().optional(),
|
||||||
stars: z.number().nonnegative().optional(),
|
stars: z.number().nonnegative().optional(),
|
||||||
@ -77,6 +78,7 @@ const patchInput = z.object({
|
|||||||
title: z.string().min(1).optional(),
|
title: z.string().min(1).optional(),
|
||||||
url: z.string().url().optional(),
|
url: z.string().url().optional(),
|
||||||
type: z.enum(entryService.ENTRY_TYPES).optional(),
|
type: z.enum(entryService.ENTRY_TYPES).optional(),
|
||||||
|
summary: z.string().max(1000).optional(),
|
||||||
description_md: z.string().max(5000).optional(),
|
description_md: z.string().max(5000).optional(),
|
||||||
license: z.string().optional(),
|
license: z.string().optional(),
|
||||||
stars: z.number().nonnegative().optional(),
|
stars: z.number().nonnegative().optional(),
|
||||||
|
|||||||
@ -21,10 +21,19 @@ pagesRouter.get("/healthz", (_req, res) => {
|
|||||||
|
|
||||||
pagesRouter.get("/", async (_req, res, next) => {
|
pagesRouter.get("/", async (_req, res, next) => {
|
||||||
try {
|
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", {
|
res.render("index", {
|
||||||
title: "Awesome Index · 把网上牛逼的东西收进一个库",
|
title: "Awesome Index · 把网上牛逼的东西收进一个库",
|
||||||
stats: await searchService.stats(),
|
stats,
|
||||||
recent: await entryService.listRecent(4),
|
recent,
|
||||||
|
hero,
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
next(e);
|
next(e);
|
||||||
@ -72,6 +81,7 @@ pagesRouter.get("/entries/:slug", async (req, res, next) => {
|
|||||||
id: entry.slug,
|
id: entry.slug,
|
||||||
type: entry.type,
|
type: entry.type,
|
||||||
name: entry.title,
|
name: entry.title,
|
||||||
|
summary: entry.summary,
|
||||||
description: entry.description_md,
|
description: entry.description_md,
|
||||||
url: entry.url,
|
url: entry.url,
|
||||||
tags: entry.tags,
|
tags: entry.tags,
|
||||||
@ -162,6 +172,7 @@ pagesRouter.post("/admin/entries/:id/edit", async (req, res, next) => {
|
|||||||
const patch = {
|
const patch = {
|
||||||
title: String(req.body.title || "").trim(),
|
title: String(req.body.title || "").trim(),
|
||||||
type: req.body.type,
|
type: req.body.type,
|
||||||
|
summary: String(req.body.summary || "").trim(),
|
||||||
description_md: String(req.body.description_md || ""),
|
description_md: String(req.body.description_md || ""),
|
||||||
url: String(req.body.url || "").trim(),
|
url: String(req.body.url || "").trim(),
|
||||||
license: String(req.body.license || "").trim(),
|
license: String(req.body.license || "").trim(),
|
||||||
|
|||||||
@ -83,12 +83,13 @@ export async function createEntry(data, { source } = {}) {
|
|||||||
const status = decideStatus(source, data.status);
|
const status = decideStatus(source, data.status);
|
||||||
const slug = data.slug || (await slugify(data.title));
|
const slug = data.slug || (await slugify(data.title));
|
||||||
await run(
|
await run(
|
||||||
`INSERT INTO entries (title, slug, type, description_md, url, license, stars, status, source_id, verified_at)
|
`INSERT INTO entries (title, slug, type, summary, description_md, url, license, stars, status, source_id, verified_at)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
[
|
[
|
||||||
data.title.trim(),
|
data.title.trim(),
|
||||||
slug,
|
slug,
|
||||||
data.type,
|
data.type,
|
||||||
|
data.summary || "",
|
||||||
data.description_md || "",
|
data.description_md || "",
|
||||||
data.url.trim(),
|
data.url.trim(),
|
||||||
data.license || "",
|
data.license || "",
|
||||||
@ -103,7 +104,7 @@ export async function createEntry(data, { source } = {}) {
|
|||||||
return { created: true, id: row.id, status };
|
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 } = {}) {
|
export async function updateEntry(id, patch, { isHumanEditor = false } = {}) {
|
||||||
const entry = await get(`SELECT * FROM entries WHERE id = ?`, [id]);
|
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];
|
if (patch[key] !== undefined) next[key] = patch[key];
|
||||||
}
|
}
|
||||||
await run(
|
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 = ?`,
|
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 (patch.tags !== undefined) await linkTags(id, patch.tags);
|
||||||
if (isHumanEditor) {
|
if (isHumanEditor) {
|
||||||
@ -185,7 +186,7 @@ export async function getBySlug(slug) {
|
|||||||
|
|
||||||
export async function listRecent(limit = 4) {
|
export async function listRecent(limit = 4) {
|
||||||
const rows = await all(
|
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'
|
FROM entries e WHERE e.status = 'active'
|
||||||
ORDER BY e.created_at DESC LIMIT ?`,
|
ORDER BY e.created_at DESC LIMIT ?`,
|
||||||
[limit]
|
[limit]
|
||||||
|
|||||||
@ -56,7 +56,7 @@ export async function searchEntries({
|
|||||||
[...cteParams, ...params]
|
[...cteParams, ...params]
|
||||||
);
|
);
|
||||||
const items = await all(
|
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 ?`,
|
FROM entries e ${whereSql} ORDER BY ${orderBy} LIMIT ? OFFSET ?`,
|
||||||
[...cteParams, ...params, perPage, 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">
|
<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">
|
<div class="panel-box">
|
||||||
<h3>基础信息</h3>
|
<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="form-row">
|
||||||
<div class="field" style="flex:2"><label for="f-title">标题</label>
|
<div class="field" style="flex:2"><label for="f-title">标题</label>
|
||||||
<input class="input" id="f-title" name="title" value="<%= entry.title %>" required /></div>
|
<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>
|
<button class="btn btn-sm" type="button" data-copy='请通过 awesome-index 的 MCP 接口调用 get_entry,slug 为 "<%= entry.slug %>"'>复制给 AI</button>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
|
|
||||||
<div class="detail-layout">
|
<div class="detail-layout">
|
||||||
<article class="detail-body">
|
<article class="detail-body">
|
||||||
<section>
|
<section>
|
||||||
<p class="anno">// entry.body · markdown source</p>
|
<p class="anno">// entry.body · markdown rendered</p>
|
||||||
<h2>这是什么</h2>
|
<h2>这是什么</h2>
|
||||||
<p><%= entry.description_md || '暂无介绍。' %></p>
|
<div class="md-body"><%- md(entry.description_md || '暂无介绍。') %></div>
|
||||||
<p style="margin-top:10px">仓库地址:<a class="mono" style="color:var(--riso-blue)" href="<%= entry.url %>" target="_blank" rel="noopener"><%= entry.url %></a></p>
|
<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>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
|
|||||||
@ -33,21 +33,22 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="hero-visual rise d3" role="presentation">
|
<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">{
|
<div class="spec-machine mono" aria-hidden="true">{
|
||||||
"id": "ripgrep",
|
"id": "<%= h.slug %>",
|
||||||
"type": "tool",
|
"type": "<%= h.type %>",
|
||||||
"name": "ripgrep",
|
"name": "<%= h.title %>",
|
||||||
"tags": ["Rust", "CLI"],
|
"tags": <%= JSON.stringify(h.tags || []) %>,
|
||||||
"stars": <%= typeof recent[0] !== 'undefined' ? Number(recent[0].stars) : 52300 %>,
|
"stars": <%= Number(h.stars) %>,
|
||||||
"status": "active"
|
"status": "active"
|
||||||
}</div>
|
}</div>
|
||||||
<div class="spec-human" aria-hidden="true">
|
<div class="spec-human" aria-hidden="true">
|
||||||
<span class="chip chip-type">工具</span>
|
<span class="chip chip-type"><%= h.type %></span>
|
||||||
<h3>ripgrep</h3>
|
<h3><%= h.title %></h3>
|
||||||
<p>以正则递归搜索目录,默认尊重 .gitignore,是代码库里最快的找东西方式。</p>
|
<p><%= h.summary || excerpt(h.description_md, 90) %></p>
|
||||||
<div class="card-tags"><span class="chip">Rust</span><span class="chip">CLI</span></div>
|
<div class="card-tags"><% for (const t of (h.tags || []).slice(0, 3)) { %><span class="chip"><%= t %></span><% } %></div>
|
||||||
<p class="card-meta">★ <%= fmtStars(52300) %> · 终端找东西的事实标准</p>
|
<p class="card-meta">★ <%= fmtStars(h.stars) %> · 随机一条,看看这个库</p>
|
||||||
</div>
|
</div>
|
||||||
<button class="btn btn-random" id="random-btn" type="button" title="随机打开一条收录,没有头绪时让库替你挑一个">
|
<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>
|
<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 %>">
|
<a class="card" href="/entries/<%= e.slug %>">
|
||||||
<span class="chip chip-type"><%= e.type %></span>
|
<span class="chip chip-type"><%= e.type %></span>
|
||||||
<h3><%= e.title %></h3>
|
<h3><%= e.title %></h3>
|
||||||
<p><%= e.description_md %></p>
|
<p><%= e.summary || excerpt(e.description_md, 120) %></p>
|
||||||
<div class="card-tags">
|
<div class="card-tags">
|
||||||
<% for (const t of (e.tags || []).slice(0, 3)) { %><span class="chip"><%= t %></span><% } %>
|
<% for (const t of (e.tags || []).slice(0, 3)) { %><span class="chip"><%= t %></span><% } %>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -78,7 +78,7 @@
|
|||||||
<span class="chip chip-type"><%= e.type %></span>
|
<span class="chip chip-type"><%= e.type %></span>
|
||||||
<div class="result-main">
|
<div class="result-main">
|
||||||
<h3><%= e.title %></h3>
|
<h3><%= e.title %></h3>
|
||||||
<p><%= e.description_md %></p>
|
<p><%= e.summary || excerpt(e.description_md, 160) %></p>
|
||||||
<div class="card-tags">
|
<div class="card-tags">
|
||||||
<% for (const t of e.tags.slice(0, 4)) { %><span class="chip"><%= t %></span><% } %>
|
<% for (const t of e.tags.slice(0, 4)) { %><span class="chip"><%= t %></span><% } %>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -16,10 +16,16 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="login-pass">密码</label>
|
<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>
|
||||||
<div class="auth-row">
|
<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>
|
<a href="mailto:admin@honor3.com">忘记密码?</a>
|
||||||
</div>
|
</div>
|
||||||
<button class="btn btn-primary btn-block" type="submit">登 录</button>
|
<button class="btn btn-primary btn-block" type="submit">登 录</button>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user