- 内容源管理:去掉人工录入卡片,AI 密钥保存后可返显并支持小眼睛切换,布局优化 - 内容管理:新增编辑按钮与编辑页,可调整条目全部字段与标签
This commit is contained in:
parent
2422ae452b
commit
e11814048c
@ -1594,6 +1594,32 @@ button.chip:hover {
|
|||||||
.field .input {
|
.field .input {
|
||||||
width: 100%;
|
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 {
|
.auth-row {
|
||||||
margin-top: 14px;
|
margin-top: 14px;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@ -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) {
|
document.querySelectorAll("form.source-config").forEach(function (form) {
|
||||||
form.addEventListener("submit", function (e) {
|
form.addEventListener("submit", function (e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|||||||
@ -80,6 +80,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`);
|
||||||
|
|
||||||
const admin = await findByUsername("admin");
|
const admin = await findByUsername("admin");
|
||||||
if (!admin) {
|
if (!admin) {
|
||||||
|
|||||||
@ -24,6 +24,7 @@ CREATE TABLE IF NOT EXISTS sources (
|
|||||||
adapter VARCHAR,
|
adapter VARCHAR,
|
||||||
cron_expr VARCHAR DEFAULT '0 9 * * 1',
|
cron_expr VARCHAR DEFAULT '0 9 * * 1',
|
||||||
api_key_hash VARCHAR,
|
api_key_hash VARCHAR,
|
||||||
|
api_key VARCHAR,
|
||||||
direct_publish BOOLEAN NOT NULL DEFAULT FALSE,
|
direct_publish BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
last_run_at TIMESTAMPTZ,
|
last_run_at TIMESTAMPTZ,
|
||||||
last_run_status VARCHAR
|
last_run_status VARCHAR
|
||||||
|
|||||||
@ -6,7 +6,6 @@ import * as commentService from "../services/comment.service.js";
|
|||||||
import * as sourceService from "../services/source.service.js";
|
import * as sourceService from "../services/source.service.js";
|
||||||
import * as userService from "../services/user.service.js";
|
import * as userService from "../services/user.service.js";
|
||||||
import { ENTRY_TYPES } from "../services/entry.service.js";
|
import { ENTRY_TYPES } from "../services/entry.service.js";
|
||||||
|
|
||||||
export const pagesRouter = Router();
|
export const pagesRouter = Router();
|
||||||
|
|
||||||
function csv(v) {
|
function csv(v) {
|
||||||
@ -131,3 +130,51 @@ pagesRouter.get("/admin", async (req, res, next) => {
|
|||||||
next(e);
|
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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@ -144,6 +144,19 @@ export async function setStatus(id, status) {
|
|||||||
return getById(id);
|
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) {
|
export async function deleteEntry(id) {
|
||||||
await run(`DELETE FROM entry_tags WHERE entry_id = ?`, [id]);
|
await run(`DELETE FROM entry_tags WHERE entry_id = ?`, [id]);
|
||||||
await run(`DELETE FROM comments WHERE entry_id = ?`, [id]);
|
await run(`DELETE FROM comments WHERE entry_id = ?`, [id]);
|
||||||
|
|||||||
@ -13,9 +13,9 @@ export async function ensureBuiltins({ aiIngestKey }) {
|
|||||||
const ai = await get(`SELECT id FROM sources WHERE name = 'AI 接口'`);
|
const ai = await get(`SELECT id FROM sources WHERE name = 'AI 接口'`);
|
||||||
if (!ai) {
|
if (!ai) {
|
||||||
await run(
|
await run(
|
||||||
`INSERT INTO sources (kind, name, enabled, api_key_hash, direct_publish)
|
`INSERT INTO sources (kind, name, enabled, api_key_hash, api_key, direct_publish)
|
||||||
VALUES ('ai', 'AI 接口', ?, ?, FALSE)`,
|
VALUES ('ai', 'AI 接口', ?, ?, ?, FALSE)`,
|
||||||
[Boolean(aiIngestKey), keyHash(aiIngestKey)]
|
[Boolean(aiIngestKey), keyHash(aiIngestKey), aiIngestKey]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -68,9 +68,10 @@ export async function updateSource(id, patch) {
|
|||||||
export async function setAiKey(key) {
|
export async function setAiKey(key) {
|
||||||
const ai = await get(`SELECT id FROM sources WHERE kind = 'ai'`);
|
const ai = await get(`SELECT id FROM sources WHERE kind = 'ai'`);
|
||||||
if (!ai) return;
|
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),
|
Boolean(key),
|
||||||
keyHash(key),
|
keyHash(key),
|
||||||
|
key,
|
||||||
ai.id,
|
ai.id,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
90
src/views/admin-entry-edit.ejs
Normal file
90
src/views/admin-entry-edit.ejs
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
<%- include('partials/head') %>
|
||||||
|
<%- include('partials/topbar', { active: 'admin' }) %>
|
||||||
|
|
||||||
|
<div class="admin-shell">
|
||||||
|
<nav class="admin-nav" aria-label="管理分区">
|
||||||
|
<h4>管理台 · <%= user.username %></h4>
|
||||||
|
<a href="/admin" style="display:flex;align-items:center;gap:10px;text-decoration:none;color:inherit;border-radius:8px;padding:10px 12px;font-size:14px;font-weight:600;background:var(--ink);color:var(--paper)">← 返回管理台</a>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<main class="admin-main">
|
||||||
|
<p class="anno">// edit · entry #<%= entry.id %> · 保存后即时生效</p>
|
||||||
|
<h1>编辑条目</h1>
|
||||||
|
<p class="admin-desc"><a href="/entries/<%= entry.slug %>" target="_blank">预览:<%= entry.title %></a></p>
|
||||||
|
|
||||||
|
<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="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>
|
||||||
|
<div class="field" style="flex:1"><label for="f-type">类型</label>
|
||||||
|
<select class="select" id="f-type" name="type" style="width:100%">
|
||||||
|
<% for (const t of types) { %>
|
||||||
|
<option value="<%= t %>" <%= entry.type === t ? 'selected' : '' %>><%= t %></option>
|
||||||
|
<% } %>
|
||||||
|
</select></div>
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="field" style="flex:2"><label for="f-url">链接 URL</label>
|
||||||
|
<input class="input" id="f-url" name="url" type="url" value="<%= entry.url %>" required /></div>
|
||||||
|
<div class="field" style="flex:1"><label for="f-license">License</label>
|
||||||
|
<input class="input" id="f-license" name="license" value="<%= entry.license || '' %>" /></div>
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="field" style="flex:1"><label for="f-stars">Stars</label>
|
||||||
|
<input class="input" id="f-stars" name="stars" type="number" min="0" value="<%= entry.stars || 0 %>" /></div>
|
||||||
|
<div class="field" style="flex:1"><label for="f-status">状态</label>
|
||||||
|
<select class="select" id="f-status" name="status" style="width:100%">
|
||||||
|
<% for (const s of ['active','greyed','pending']) { %>
|
||||||
|
<option value="<%= s %>" <%= entry.status === s ? 'selected' : '' %>><%= s %></option>
|
||||||
|
<% } %>
|
||||||
|
</select></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel-box">
|
||||||
|
<h3>内容描述(Markdown)</h3>
|
||||||
|
<textarea class="textarea" id="f-desc" name="description_md" rows="16"><%= entry.description_md || '' %></textarea>
|
||||||
|
<p style="font-size:12px;color:var(--ink-dim);margin-top:6px;font-family:var(--font-mono)">// 前台展示原文;保存后详情页与搜索都会生效</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel-box">
|
||||||
|
<h3>标签</h3>
|
||||||
|
<p style="font-size:13px;color:var(--ink-dim);margin-bottom:10px">勾选已有的标签;也可以把新标签名填到下方输入框(逗号分隔)</p>
|
||||||
|
<div style="border:1.5px solid var(--ink-faint);border-radius:8px;padding:14px;max-height:340px;overflow:auto">
|
||||||
|
<ul class="tag-tree" style="padding:0;margin:0">
|
||||||
|
<% for (const root of tagTreeAdmin) { %><%- include('partials/tag-check', { node: root, entry }) %><% } %>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="field" style="margin-top:12px"><label for="f-newtags">新增标签</label>
|
||||||
|
<input class="input" id="f-newtags" name="new_tags" placeholder="例如:自托管, Docker 就绪(留空则不改动)" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display:flex;gap:10px;align-items:center">
|
||||||
|
<button class="btn btn-primary" type="submit">保存修改</button>
|
||||||
|
<a class="btn" href="/admin#sec-content">取消</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
var form = document.getElementById("entry-edit-form");
|
||||||
|
if (!form) return;
|
||||||
|
form.addEventListener("submit", function (e) {
|
||||||
|
var box = document.querySelector('input[name="tags"]');
|
||||||
|
if (!box) {
|
||||||
|
var hidden = document.createElement("input");
|
||||||
|
hidden.type = "hidden";
|
||||||
|
hidden.name = "tags";
|
||||||
|
hidden.value = "";
|
||||||
|
form.appendChild(hidden);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<%- include('partials/footer-bar') %>
|
||||||
@ -37,6 +37,7 @@
|
|||||||
<td class="mono"><%= fmtDate(e.updated_at) %></td>
|
<td class="mono"><%= fmtDate(e.updated_at) %></td>
|
||||||
<td>
|
<td>
|
||||||
<div class="row-actions">
|
<div class="row-actions">
|
||||||
|
<a class="btn btn-sm" href="/admin/entries/<%= e.id %>/edit">编辑</a>
|
||||||
<% if (e.status !== 'active') { %>
|
<% if (e.status !== 'active') { %>
|
||||||
<button class="btn btn-sm" type="button" data-action="entry-status" data-id="<%= e.id %>" data-status="active">上架</button>
|
<button class="btn btn-sm" type="button" data-action="entry-status" data-id="<%= e.id %>" data-status="active">上架</button>
|
||||||
<% } else { %>
|
<% } else { %>
|
||||||
@ -106,25 +107,29 @@
|
|||||||
<p class="anno">// sources: human + ai + feed · 自动拉取经 ingest/ 采集层进入待核验队列</p>
|
<p class="anno">// sources: human + ai + feed · 自动拉取经 ingest/ 采集层进入待核验队列</p>
|
||||||
<h1>内容源管理</h1>
|
<h1>内容源管理</h1>
|
||||||
|
|
||||||
<% for (const s of sources) { %>
|
<% for (const s of sources) { if (s.kind === 'human') continue; %>
|
||||||
<div class="panel-box" style="margin-top:20px">
|
<div class="panel-box" style="margin-top:20px">
|
||||||
<h3 style="display:flex;align-items:center;gap:10px">
|
<h3 style="display:flex;align-items:center;gap:10px">
|
||||||
<span class="dot <%= s.enabled ? '' : 'dot-off' %>"></span> <%= s.name %>
|
<span class="dot <%= s.enabled ? '' : 'dot-off' %>"></span> <%= s.name %>
|
||||||
<span class="mono" style="font-weight:400;font-size:12px;color:var(--ink-dim)">kind: <%= s.kind %> · 已收录 <%= Number(s.entry_count) %> 条</span>
|
<span class="mono" style="font-weight:400;font-size:12px;color:var(--ink-dim)">kind: <%= s.kind %> · 已收录 <%= Number(s.entry_count) %> 条</span>
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
<% if (s.kind === 'human') { %>
|
<% if (s.kind === 'ai') { %>
|
||||||
<p style="font-size:13.5px;color:var(--ink-dim)">管理员与编辑在条目接口直接写入,始终可用,改动写入审计日志。</p>
|
|
||||||
<% } else if (s.kind === 'ai') { %>
|
|
||||||
<div class="switchline">
|
<div class="switchline">
|
||||||
AI 录入接口启用
|
AI 录入接口启用
|
||||||
<span class="mono"><%= s.enabled ? 'ON' : 'OFF' %></span>
|
<span class="mono"><%= s.enabled ? 'ON' : 'OFF' %></span>
|
||||||
</div>
|
</div>
|
||||||
<form id="ai-key-form">
|
<form id="ai-key-form" style="margin-top:12px">
|
||||||
<p style="font-size:13px;color:var(--ink-dim);margin-bottom:6px">设置新的 X-Ingest-Key(留空 = 关闭通道)</p>
|
<p style="font-size:13px;color:var(--ink-dim);margin-bottom:6px">设置新的 X-Ingest-Key(留空 = 关闭通道)</p>
|
||||||
<div style="display:flex;gap:10px;max-width:420px">
|
<div style="display:flex;gap:10px;align-items:center;flex-wrap:nowrap">
|
||||||
<input class="input" name="key" type="text" placeholder="sk-…" />
|
<div class="ai-key-field">
|
||||||
<button class="btn btn-sm" type="submit">保存密钥</button>
|
<input class="input" name="key" type="password" value="<%= s.api_key || '' %>" placeholder="sk-…" autocomplete="off" />
|
||||||
|
<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>
|
||||||
|
<button class="btn btn-sm" type="submit" style="flex:none">保存密钥</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
<p class="mono" style="margin-top:12px;font-size:12px;color:var(--ink-dim)">
|
<p class="mono" style="margin-top:12px;font-size:12px;color:var(--ink-dim)">
|
||||||
|
|||||||
12
src/views/partials/tag-check.ejs
Normal file
12
src/views/partials/tag-check.ejs
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
<li>
|
||||||
|
<label class="checkline" style="padding:3px 0">
|
||||||
|
<input type="checkbox" name="tags" value="<%= node.name %>" <%= (entry.tags || []).includes(node.name) ? 'checked' : '' %> />
|
||||||
|
<span><%= node.name %></span>
|
||||||
|
<% if (node.count !== undefined) { %><span class="cnt"><%= node.count %></span><% } %>
|
||||||
|
</label>
|
||||||
|
<% if (node.children && node.children.length) { %>
|
||||||
|
<ul class="tag-tree" style="padding-left:18px;margin:0">
|
||||||
|
<% for (const child of node.children) { %><%- include('tag-check', { node: child, entry }) %><% } %>
|
||||||
|
</ul>
|
||||||
|
<% } %>
|
||||||
|
</li>
|
||||||
Loading…
Reference in New Issue
Block a user