// edit · entry #<%= entry.id %> · 保存后即时生效
+diff --git a/public/css/main.css b/public/css/main.css index 08d8af7..fbff0ab 100644 --- a/public/css/main.css +++ b/public/css/main.css @@ -1594,6 +1594,32 @@ button.chip:hover { .field .input { width: 100%; } +.ai-key-field { + position: relative; + flex: 1; + min-width: 0; +} +.ai-key-field .input { + width: 100%; + padding-right: 42px; +} +.eye-toggle { + position: absolute; + right: 8px; + top: 50%; + transform: translateY(-50%); + display: flex; + align-items: center; + justify-content: center; + padding: 4px; + border: 0; + background: none; + cursor: pointer; + color: var(--ink-dim); +} +.eye-toggle:hover { + color: var(--ink); +} .auth-row { margin-top: 14px; display: flex; diff --git a/public/js/admin.js b/public/js/admin.js index 041f1cc..cfb1333 100644 --- a/public/js/admin.js +++ b/public/js/admin.js @@ -139,6 +139,19 @@ }); } + 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/src/db/migrate.js b/src/db/migrate.js index c1272e7..0a78138 100644 --- a/src/db/migrate.js +++ b/src/db/migrate.js @@ -80,6 +80,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`); const admin = await findByUsername("admin"); if (!admin) { diff --git a/src/db/schema.sql b/src/db/schema.sql index c124025..05847c8 100644 --- a/src/db/schema.sql +++ b/src/db/schema.sql @@ -24,6 +24,7 @@ CREATE TABLE IF NOT EXISTS sources ( adapter VARCHAR, cron_expr VARCHAR DEFAULT '0 9 * * 1', api_key_hash VARCHAR, + api_key VARCHAR, direct_publish BOOLEAN NOT NULL DEFAULT FALSE, last_run_at TIMESTAMPTZ, last_run_status VARCHAR diff --git a/src/routes/pages.routes.js b/src/routes/pages.routes.js index 10bf216..9a33b82 100644 --- a/src/routes/pages.routes.js +++ b/src/routes/pages.routes.js @@ -6,7 +6,6 @@ import * as commentService from "../services/comment.service.js"; import * as sourceService from "../services/source.service.js"; import * as userService from "../services/user.service.js"; import { ENTRY_TYPES } from "../services/entry.service.js"; - export const pagesRouter = Router(); function csv(v) { @@ -131,3 +130,51 @@ pagesRouter.get("/admin", async (req, res, next) => { next(e); } }); + +pagesRouter.get("/admin/entries/:id/edit", async (req, res, next) => { + try { + if (!req.session.userId) return res.redirect("/login"); + const entry = await entryService.getById(Number(req.params.id)); + if (!entry) throw Object.assign(new Error("条目不存在"), { status: 404 }); + const tagTreeAdmin = await tagService.buildTree(); + res.render("admin-entry-edit", { + title: `编辑 · ${entry.title} · Awesome Index`, + user: { username: req.session.username, role: req.session.role }, + isAdmin: req.session.role === "admin", + entry, + tagTreeAdmin, + types: ENTRY_TYPES, + }); + } catch (e) { + next(e); + } +}); + +pagesRouter.post("/admin/entries/:id/edit", async (req, res, next) => { + try { + if (!req.session.userId) return res.redirect("/login"); + const id = Number(req.params.id); + const tags = Array.isArray(req.body.tags) ? req.body.tags : req.body.tags ? [req.body.tags] : []; + const newTags = String(req.body.new_tags || "") + .split(",") + .map((s) => s.trim()) + .filter(Boolean); + const patch = { + title: String(req.body.title || "").trim(), + type: req.body.type, + description_md: String(req.body.description_md || ""), + url: String(req.body.url || "").trim(), + license: String(req.body.license || "").trim(), + stars: Number(req.body.stars) || 0, + tags: [...new Set([...tags, ...newTags])], + }; + await entryService.updateEntry(id, patch, { isHumanEditor: true }); + const status = req.body.status; + if (status && entryService.STATUSES.includes(status)) { + await entryService.setStatusDirect(id, status); + } + res.redirect("/admin#sec-content"); + } catch (e) { + next(e); + } +}); diff --git a/src/services/entry.service.js b/src/services/entry.service.js index 015e440..94b260e 100644 --- a/src/services/entry.service.js +++ b/src/services/entry.service.js @@ -144,6 +144,19 @@ export async function setStatus(id, status) { return getById(id); } +export async function setStatusDirect(id, status) { + const entry = await get(`SELECT * FROM entries WHERE id = ?`, [id]); + if (!entry) throw httpError(404, "条目不存在"); + if (!STATUSES.includes(status)) throw httpError(400, `未知状态:${status}`); + await run( + `UPDATE entries SET status = ?, updated_at = now(), + verified_at = CASE WHEN ? = 'active' THEN now() ELSE verified_at END + WHERE id = ?`, + [status, status, id] + ); + return getById(id); +} + export async function deleteEntry(id) { await run(`DELETE FROM entry_tags WHERE entry_id = ?`, [id]); await run(`DELETE FROM comments WHERE entry_id = ?`, [id]); diff --git a/src/services/source.service.js b/src/services/source.service.js index e431e6d..89e157a 100644 --- a/src/services/source.service.js +++ b/src/services/source.service.js @@ -13,9 +13,9 @@ export async function ensureBuiltins({ aiIngestKey }) { const ai = await get(`SELECT id FROM sources WHERE name = 'AI 接口'`); if (!ai) { await run( - `INSERT INTO sources (kind, name, enabled, api_key_hash, direct_publish) - VALUES ('ai', 'AI 接口', ?, ?, FALSE)`, - [Boolean(aiIngestKey), keyHash(aiIngestKey)] + `INSERT INTO sources (kind, name, enabled, api_key_hash, api_key, direct_publish) + VALUES ('ai', 'AI 接口', ?, ?, ?, FALSE)`, + [Boolean(aiIngestKey), keyHash(aiIngestKey), aiIngestKey] ); } } @@ -68,9 +68,10 @@ export async function updateSource(id, patch) { export async function setAiKey(key) { const ai = await get(`SELECT id FROM sources WHERE kind = 'ai'`); if (!ai) return; - await run(`UPDATE sources SET enabled = ?, api_key_hash = ? WHERE id = ?`, [ + await run(`UPDATE sources SET enabled = ?, api_key_hash = ?, api_key = ? WHERE id = ?`, [ Boolean(key), keyHash(key), + key, ai.id, ]); } diff --git a/src/views/admin-entry-edit.ejs b/src/views/admin-entry-edit.ejs new file mode 100644 index 0000000..847ac80 --- /dev/null +++ b/src/views/admin-entry-edit.ejs @@ -0,0 +1,90 @@ +<%- include('partials/head') %> +<%- include('partials/topbar', { active: 'admin' }) %> + +
// edit · entry #<%= entry.id %> · 保存后即时生效
+// sources: human + ai + feed · 自动拉取经 ingest/ 采集层进入待核验队列
管理员与编辑在条目接口直接写入,始终可用,改动写入审计日志。
- <% } else if (s.kind === 'ai') { %> + <% if (s.kind === 'ai') { %>