Compare commits

...
7 Commits
Author SHA1 Message Date
cheney 806761103b feat: 管理台搜索/分区刷新、评论人员体系、相关项目,修复内容源自动获取,完善 MCP 自描述
Build and Deploy / build (push) Successful in 1m32s
Build and Deploy / deploy (push) Successful in 26s
管理台:
- 内容管理新增名称搜索(title/slug/url/description),状态与翻页链接保留搜索词
- 菜单改为真实链接,每次点击重新向后台取数渲染分区,不再依赖手动刷新
- 内容管理每页固定 20 条
- pending 条目按钮改为「预览」,active 为「查看」

评论体系:
- 评论提交新增必填联系方式(手机号/邮箱),前端不做展示
- 首次评论以「昵称+联系方式」建档,之后需匹配且未被禁用
- 后台评论管理新增联系方式列;新增评论人员管理页,可启用/禁用/改名/删除
- 评论昵称输入框不再预置 admin

相关项目:
- 新增 entry_relations 双向关联,详情页侧栏展示并写入机读 JSON
- 编辑页可添加/移除关联

内容源自动获取:
- 修复 rss 适配器把 feed url 当成条目 url 的根本问题,支持 Atom link/href 与相对链接
- eryajf-weekly 限定正文容器取链接,修正项目说明的提取与「(opens new window)」清理
- pipeline 增加轮内去重与单条失败隔离;scheduler 对非法 cron 明确告警

MCP:
- 重写 instructions 为八节说明,新增 usage 工具返回结构化接入文档
- 补全各工具的参数、用途与返回格式说明;baseUrl 取自当次请求
- 首页 MCP 区块与 README 同步补充写入示例与踩坑清单

测试: 新增 comments/relations/admin/ingest-adapters 用例,共 29 个全部通过
2026-10-27 11:22:26 +08:00
cheney 3f7b48325f feat: 管理台内容状态过滤+预览、批量上架/灰掉、标签拖拽调整层级
Build and Deploy / build (push) Successful in 1m37s
Build and Deploy / deploy (push) Successful in 31s
2026-09-21 11:15:26 +08:00
cheney 7e62db31b8 牛逼
Build and Deploy / build (push) Successful in 27m44s
Build and Deploy / deploy (push) Successful in 25s
2026-09-01 14:30:11 +08:00
cheney 381e6a780d feat: 版本时间+8区、首页随机卡片点击进详情;chore: 优雅关闭DuckDB、start.sh改用docker stop
Build and Deploy / build (push) Successful in 10s
Build and Deploy / deploy (push) Failing after 45s
2026-09-01 11:40:21 +08:00
cheney 64f3f6ea58 feat: 首页版本号、简介布局与反显、密钥眼睛融合、定时拉取开关
Build and Deploy / build (push) Successful in 11s
Build and Deploy / deploy (push) Failing after 49s
2026-09-01 11:15:35 +08:00
cheney c09857cc81 feat: 登录记住本机+密码可见、内容分简介与主体、Markdown 渲染、首页随机条目
Build and Deploy / build (push) Successful in 56s
Build and Deploy / deploy (push) Successful in 28s
2026-09-01 10:53:17 +08:00
cheney a5c5d24083 refactor: 彻底移除 docker compose,改用纯 docker 构建+重启部署
Build and Deploy / build (push) Successful in 12s
Build and Deploy / deploy (push) Successful in 26s
2026-09-01 10:27:11 +08:00
47 changed files with 2379 additions and 293 deletions
+5
View File
@@ -0,0 +1,5 @@
# CodeGraph data files — local to each machine, not for committing.
# Ignore everything in .codegraph/ except this file itself, so transient
# files (the database, daemon.pid, sockets, logs) never show up in git.
*
!.gitignore
+17 -10
View File
@@ -3,27 +3,39 @@ name: Build and Deploy
on: on:
push: push:
branches: [main] branches: [main]
pull_request:
branches: [main]
jobs: jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Build Docker image
run: |
docker build \
--build-arg GIT_BRANCH="${GITHUB_REF_NAME:-unknown}" \
--build-arg BUILD_TIME="$(TZ=Asia/Shanghai date +%Y%m%d%H%M%S)" \
-t awesome-index:latest .
deploy: deploy:
needs: build
if: github.ref == 'refs/heads/main' if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Copy files to server - name: Copy scripts to server
uses: appleboy/scp-action@master uses: appleboy/scp-action@master
with: with:
host: 114.134.187.170 host: 114.134.187.170
username: root username: root
password: ${{ secrets.SERVER_PASSWORD }} password: ${{ secrets.SERVER_PASSWORD }}
source: "package.json,package-lock.json,src,public,.env.example,start.sh" source: "start.sh,.env.example"
target: "/app/awesome/" target: "/app/awesome/"
- name: Deploy and start service - name: Restart container
uses: appleboy/ssh-action@master uses: appleboy/ssh-action@master
with: with:
host: 114.134.187.170 host: 114.134.187.170
@@ -31,10 +43,5 @@ jobs:
password: ${{ secrets.SERVER_PASSWORD }} password: ${{ secrets.SERVER_PASSWORD }}
script: | script: |
cd /app/awesome cd /app/awesome
# 如不存在 .env 则从 example 复制
[ ! -f .env ] && cp .env.example .env
# 执行启动脚本
chmod +x start.sh chmod +x start.sh
bash start.sh bash start.sh
+4
View File
@@ -6,6 +6,10 @@ RUN npm ci --omit=dev --no-audit --no-fund
FROM node:22-bookworm-slim FROM node:22-bookworm-slim
WORKDIR /app WORKDIR /app
ENV NODE_ENV=production ENV NODE_ENV=production
ARG GIT_BRANCH=unknown
ARG BUILD_TIME=unknown
ENV GIT_BRANCH=$GIT_BRANCH
ENV BUILD_TIME=$BUILD_TIME
COPY --from=deps /app/node_modules ./node_modules COPY --from=deps /app/node_modules ./node_modules
COPY package.json ./ COPY package.json ./
COPY src ./src COPY src ./src
Vendored
+6 -24
View File
@@ -3,7 +3,6 @@ pipeline {
environment { environment {
IMAGE_NAME = 'awesome-index' IMAGE_NAME = 'awesome-index'
CONTAINER_NAME = 'awesome'
DEPLOY_PATH = '/app/awesome' DEPLOY_PATH = '/app/awesome'
SSH_HOST = '114.134.187.170' SSH_HOST = '114.134.187.170'
SSH_USER = 'root' SSH_USER = 'root'
@@ -13,9 +12,8 @@ pipeline {
stage('Build') { stage('Build') {
steps { steps {
script { script {
echo "Building Docker image ${IMAGE_NAME}:${BUILD_NUMBER}" echo "Building Docker image ${IMAGE_NAME}:latest"
sh "docker build -t ${IMAGE_NAME}:${BUILD_NUMBER} ." sh "docker build -t ${IMAGE_NAME}:latest ."
sh "docker tag ${IMAGE_NAME}:${BUILD_NUMBER} ${IMAGE_NAME}:latest"
} }
} }
} }
@@ -29,28 +27,12 @@ pipeline {
// 创建部署目录 // 创建部署目录
sh "sshpass -p '${SSH_PASS}' ssh -o StrictHostKeyChecking=no ${remote} 'mkdir -p ${DEPLOY_PATH}/data ${DEPLOY_PATH}/logs'" sh "sshpass -p '${SSH_PASS}' ssh -o StrictHostKeyChecking=no ${remote} 'mkdir -p ${DEPLOY_PATH}/data ${DEPLOY_PATH}/logs'"
// 复制必要文件 // 上传脚本(构建机 = 部署机,镜像已在服务器上,无需传输镜像)
sh "sshpass -p '${SSH_PASS}' scp -o StrictHostKeyChecking=no docker-compose.yml ${remote}:${DEPLOY_PATH}/" sh "sshpass -p '${SSH_PASS}' scp -o StrictHostKeyChecking=no .env.example ${remote}:${DEPLOY_PATH}/.env.example"
sh "sshpass -p '${SSH_PASS}' scp -o StrictHostKeyChecking=no .env.example ${remote}:${DEPLOY_PATH}/.env"
sh "sshpass -p '${SSH_PASS}' scp -o StrictHostKeyChecking=no start.sh ${remote}:${DEPLOY_PATH}/" sh "sshpass -p '${SSH_PASS}' scp -o StrictHostKeyChecking=no start.sh ${remote}:${DEPLOY_PATH}/"
// 导入镜像 // 在服务器上重启容器
sh "docker save ${IMAGE_NAME}:latest | gzip > ${IMAGE_NAME}.tar.gz" sh "sshpass -p '${SSH_PASS}' ssh -o StrictHostKeyChecking=no ${remote} 'cd ${DEPLOY_PATH} && chmod +x start.sh && bash start.sh'"
sh "sshpass -p '${SSH_PASS}' scp -o StrictHostKeyChecking=no ${IMAGE_NAME}.tar.gz ${remote}:${DEPLOY_PATH}/"
// 在服务器上加载并重启
sh """
sshpass -p '${SSH_PASS}' ssh -o StrictHostKeyChecking=no ${remote} '
cd ${DEPLOY_PATH} &&
gunzip -c ${IMAGE_NAME}.tar.gz | docker load &&
rm -f ${IMAGE_NAME}.tar.gz &&
docker compose down --remove-orphans &&
docker compose up -d
'
"""
// 清理本地
sh "rm -f ${IMAGE_NAME}.tar.gz"
} }
} }
} }
+111 -9
View File
@@ -13,7 +13,7 @@
```bash ```bash
npm install npm install
npm run dev # http://localhost:3000(--watch 热重载) npm run dev # http://localhost:3000(--watch 热重载)
npm test # node:test,10 个用例 npm test # node:test,27 个用例
``` ```
首次启动自动完成建表与种子数据。默认管理员 `admin`,密码取 `ADMIN_INIT_PASSWORD`(默认 `admin`)。 首次启动自动完成建表与种子数据。默认管理员 `admin`,密码取 `ADMIN_INIT_PASSWORD`(默认 `admin`)。
@@ -21,11 +21,11 @@ npm test # node:test,10 个用例
### Docker 部署 ### Docker 部署
```bash ```bash
cp .env.example .env # 修改 SESSION_SECRET / ADMIN_INIT_PASSWORD / AI_INGEST_KEY docker build -t awesome-index:latest .
docker compose up -d --build bash start.sh # 用新镜像重启容器
``` ```
数据持久化在宿主机 `./data/awesome.duckdb`,升级镜像不丢数据。 `start.sh` 会检查 `.env`(不存在则从 `.env.example` 复制),然后 `docker run` 启动容器。数据持久化在宿主机 `./data/awesome.duckdb`,重启不丢数据。
## 给人的入口 ## 给人的入口
@@ -33,24 +33,126 @@ docker compose up -d --build
|---|---| |---|---|
| 首页(搜索 / MCP 说明 / 最近收录) | `/` | | 首页(搜索 / MCP 说明 / 最近收录) | `/` |
| 列表页(类型 + 嵌套标签过滤) | `/entries?q=关键字&type=工具&tags=1,2&sort=stars` | | 列表页(类型 + 嵌套标签过滤) | `/entries?q=关键字&type=工具&tags=1,2&sort=stars` |
| 详情页(元数据 / 评论 / 机读 JSON) | `/entries/:slug` | | 详情页(元数据 / 相关项目 / 评论 / 机读 JSON) | `/entries/:slug` |
| 随机游览 | `/random` | | 随机游览 | `/random` |
| 登录 | `/login` | | 登录 | `/login` |
| 管理台(内容/标签/评论/内容源/账号) | `/admin` | | 管理台 | `/admin` |
管理台分区(**点菜单每次都重新向后台取数**,不需要手动刷新浏览器):
| 分区 | 说明 |
|---|---|
| 内容管理 | 名称搜索(标题 / slug / URL / 描述)+ 状态筛选,**每页 20 条**;pending 条目按钮为「预览」,active 为「查看」 |
| 标签管理 | 无限嵌套标签树,拖拽调整层级 |
| 评论管理 | 评论列表 + **联系方式**列 |
| 评论人员 | 以联系方式为身份依据,可启用 / 禁用 / 改名 / 删除 |
| 内容源 | 人工 / AI 接口 / 定时拉取(rss / html / eryajf-weekly) |
| 账号管理 | 管理员可见 |
### 评论规则
访客评论需要提供**昵称 + 联系方式**(手机号或邮箱):
- 联系方式**只存后台,前台详情页不展示**。
- **首次评论**以「昵称 + 联系方式」建档入库。
- **之后评论**必须昵称与该联系方式登记的一致,且未被管理员禁用,否则分别返回 400 / 403。
- 管理员可在「评论人员」分区按联系方式禁用某人;禁用后其评论被拒,历史评论保留。
### 相关项目
每个条目都能关联若干「相关项目」,在详情页侧栏展示,同时进入机读 JSON 的 `related` 字段。
- 关联在管理台「编辑条目 → 相关项目」里维护。
- **关联是双向的**:A 关联 B 后,B 的详情页也会出现 A(底层只存一条记录)。
- 前台只展示已上架(`active`)的相关条目。
## 给 AI 的入口(MCP) ## 给 AI 的入口(MCP)
Streamable HTTP · 只读 · 无需密钥: > **一句话:读用 MCP(免密钥),写用 REST(要 `X-Ingest-Key`)。**
### 只读部分:MCP
``` ```
POST {BASE_URL}/mcp POST {BASE_URL}/mcp
tools: search_entries / get_entry / random_entry / list_tags transport: Streamable HTTP
auth: none
Accept: application/json, text/event-stream # 正规客户端自动带上;curl 直连需手写
```
**本端点不校验任何密钥**,MCP 连接里无需配置 token,它只提供读取能力。
客户端配置:
```json
{
"mcpServers": {
"awesome-index": {
"url": "{BASE_URL}/mcp"
}
}
}
``` ```
一句话让 Agent 自己接入: 一句话让 Agent 自己接入:
> 请把 URL 为 `{BASE_URL}/mcp` 的 MCP 服务器添加到你的客户端配置中,名称用 awesome-index,完成后告诉我现在可以调用哪些工具。 > 请把 URL 为 `{BASE_URL}/mcp` 的 MCP 服务器添加到你的客户端配置中,名称用 awesome-index,完成后告诉我现在可以调用哪些工具。
可用工具:
| tool | 参数 | 说明 |
|---|---|---|
| `search_entries` | `q`(必填), `limit`(1–50,默认 20) | 按关键字 / 类型 / 标签搜索。**查重先走它** |
| `get_entry` | `slug`(必填,也可传标题关键字自动匹配) | 取单条完整详情与元数据 |
| `random_entry` | — | 随机返回一条已上架内容,用于探索发现 |
| `list_tags` | — | 列出全部标签组、嵌套结构与收录数量 |
| `usage` | — | 返回完整接入说明(含写入接口与踩坑清单) |
结果格式:返回体在 `result.content[0].text` 里,是一段 **JSON 字符串,需要再解析一次**。
连接 `initialize` 后返回的 `instructions` 字段同样带有完整的接入与写入说明(AI 客户端通常会自动注入)。
### 写入部分:AI 录入接口
MCP 只读,不能写入。要让 Agent 把新内容录进本库,请使用 HTTP 接口:
```
POST {BASE_URL}/api/ingest/entry # 等价端点:POST {BASE_URL}/api/entries
Content-Type: application/json
```
鉴权请求头(**不要**把 key 放进 URL query):
| 方式 | 示例 |
|---|---|
| `X-Ingest-Key`(推荐) | `X-Ingest-Key: <key>` |
| 标准 Bearer(等价) | `Authorization: Bearer <key>` |
请求体字段:`title`(必填) / `url`(必填,合法 URL,用于去重) / `type`(必填,枚举:微服务 / SaaS / 网站 / 工具 / 应用 / 脚本 / 插件) / `summary`(≤200) / `description_md`(≤5000,Markdown) / `license` / `stars`(整数) / `tags`(字符串数组,自动建标签) / `status`。
> `slug` 不是合法字段,传了会被静默丢弃(服务端按标题自动生成)。
```bash
curl -X POST "{BASE_URL}/api/ingest/entry" \
-H "X-Ingest-Key: <key>" \
-H "Content-Type: application/json" \
-d '{"title":"MinerU","url":"https://github.com/opendatalab/MinerU","type":"工具","tags":["Python","OCR"]}'
```
返回:创建成功 `201 {"ok":true,"data":{"created":true,"id":<n>,"status":"pending"}}`;URL 已存在时 `200 {"created":false,"id":<n>}`(自动去重);鉴权失败 `403 invalid_key`。
### 五个必记的坑
1. **推荐 `X-Ingest-Key` 请求头**(等价 `Authorization: Bearer`);自创的 `X-API-Key`、`?key=` 一律 403。
2. **MCP 免密钥 ≠ 密钥有效**。`/mcp` 根本不查 key,别拿探测 MCP 来验证 ingest key。
3. **提交后默认 `pending`**,前台搜不到是正常的,等管理员审核转 `active`。**不要因为搜不到就重复提交。**
4. **同一 URL 重复提交返回 200 `created:false`**,这不是报错,说明已存在并被去重。
5. `slug` 不是字段,传了会被丢弃。
排错:`curl` 直连 `/mcp` 报 `406` 是缺 `Accept: application/json, text/event-stream`,正规客户端不受影响。
录入条目默认 `pending`(待审核),除非对应 AI 源开启了 direct_publish。
## 内容源(自动化收录) ## 内容源(自动化收录)
三类来源汇聚到同一条目表: 三类来源汇聚到同一条目表:
@@ -58,7 +160,7 @@ tools: search_entries / get_entry / random_entry / list_tags
| kind | 说明 | 新条目状态 | | kind | 说明 | 新条目状态 |
|---|---|---| |---|---|---|
| `human` | 管理台人工录入(常开) | active | | `human` | 管理台人工录入(常开) | active |
| `ai` | `POST /api/ingest/entry` + `X-Ingest-Key` 头 | pending | | `ai` | `POST /api/ingest/entry` + `X-Ingest-Key`(或 `Authorization: Bearer`)头 | pending |
| `feed` | 定时拉取外部站点(rss / html / eryajf-weekly 适配器),管理台可手动触发 | pending | | `feed` | 定时拉取外部站点(rss / html / eryajf-weekly 适配器),管理台可手动触发 | pending |
首个站点适配器:二丫讲梵学习周刊(wiki.eryajf.net)。运行记录见管理台「内容源 → 运行记录」。 首个站点适配器:二丫讲梵学习周刊(wiki.eryajf.net)。运行记录见管理台「内容源 → 运行记录」。
+7 -12
View File
@@ -1,5 +1,5 @@
#!/bin/bash #!/bin/bash
# Awesome Index 部署脚本(直接部署,不使用 Docker) # Awesome Index 部署脚本(构建机 = 部署机:本地构建镜像,SSH 重启容器)
# 服务器: 114.134.187.170 # 服务器: 114.134.187.170
set -e set -e
@@ -19,20 +19,15 @@ if ! command -v sshpass &> /dev/null; then
exit 1 exit 1
fi fi
echo "1. 创建部署目录..." echo "1. 构建 Docker 镜像..."
sshpass -p "$REMOTE_PASS" ssh -o StrictHostKeyChecking=no "$REMOTE_USER@$REMOTE_HOST" \ docker build -t awesome-index:latest .
"mkdir -p $DEPLOY_PATH/data $DEPLOY_PATH/logs"
echo "2. 上传源码..." echo "2. 上传脚本到服务器..."
sshpass -p "$REMOTE_PASS" scp -o StrictHostKeyChecking=no -r \ sshpass -p "$REMOTE_PASS" scp -o StrictHostKeyChecking=no \
package.json package-lock.json src public .env.example start.sh \ start.sh .env.example \
"$REMOTE_USER@$REMOTE_HOST:$DEPLOY_PATH/" "$REMOTE_USER@$REMOTE_HOST:$DEPLOY_PATH/"
echo "3. 重命名 .env.example 为 .env(如不存在)..." echo "3. 在服务器上重启容器..."
sshpass -p "$REMOTE_PASS" ssh -o StrictHostKeyChecking=no "$REMOTE_USER@$REMOTE_HOST" \
"cd $DEPLOY_PATH && [ ! -f .env ] && cp .env.example .env"
echo "4. 在服务器上执行启动脚本..."
sshpass -p "$REMOTE_PASS" ssh -o StrictHostKeyChecking=no "$REMOTE_USER@$REMOTE_HOST" \ sshpass -p "$REMOTE_PASS" ssh -o StrictHostKeyChecking=no "$REMOTE_USER@$REMOTE_HOST" \
"cd $DEPLOY_PATH && chmod +x start.sh && bash start.sh" "cd $DEPLOY_PATH && chmod +x start.sh && bash start.sh"
-16
View File
@@ -1,16 +0,0 @@
services:
web:
build: .
container_name: awesome-index
ports:
- "3000:3000"
volumes:
- ./data:/app/data
environment:
PORT: "3000"
DUCKDB_PATH: /app/data/awesome.duckdb
SESSION_SECRET: ${SESSION_SECRET:-change-me-in-production}
ADMIN_INIT_PASSWORD: ${ADMIN_INIT_PASSWORD:-admin}
AI_INGEST_KEY: ${AI_INGEST_KEY:-}
BASE_URL: ${BASE_URL:-http://localhost:3000}
restart: unless-stopped
+2 -2
View File
@@ -58,8 +58,8 @@
``` ```
awesome/ awesome/
├── package.json # 依赖与 scripts(dev / start / test / migrate) ├── package.json # 依赖与 scripts(dev / start / test / migrate)
├── Dockerfile # 多阶段构建(node:22-alpine) ├── Dockerfile # 多阶段构建(node:22-bookworm-slim)
├── docker-compose.yml # 单服务编排 + data 卷 + healthcheck ├── start.sh # 用已构建镜像重启容器(docker run,无 compose)
├── .env.example # 配置样例 ├── .env.example # 配置样例
├── .gitignore # node_modules / data / .env ├── .gitignore # node_modules / data / .env
│ │
+13
View File
@@ -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",
+1
View File
@@ -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"
} }
+309 -3
View File
@@ -465,6 +465,13 @@ button.chip:hover {
padding: 18px; padding: 18px;
transform: rotate(-3deg); transform: rotate(-3deg);
box-shadow: 9px 9px 0 var(--riso-blue); box-shadow: 9px 9px 0 var(--riso-blue);
color: inherit;
text-decoration: none;
transition: transform 0.15s ease, box-shadow 0.15s ease;
}
.spec-human:hover {
transform: rotate(0deg) translateY(-2px);
box-shadow: 9px 12px 0 var(--riso-blue);
} }
.spec-human h3 { .spec-human h3 {
font-size: 16.5px; font-size: 16.5px;
@@ -725,6 +732,20 @@ button.chip:hover {
.step pre .s { .step pre .s {
color: #ffd9a0; color: #ffd9a0;
} }
.step pre .c {
color: #7d86a8;
font-style: italic;
}
.tools-table td:nth-child(2) {
font-family: var(--font-mono);
font-size: 11.5px;
color: var(--dim-on-panel);
white-space: nowrap;
}
.tools-table td:last-child {
color: var(--dim-on-panel);
line-height: 1.6;
}
.tools-table { .tools-table {
width: 100%; width: 100%;
border-collapse: collapse; border-collapse: collapse;
@@ -1171,6 +1192,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);
@@ -1450,6 +1546,90 @@ button.chip:hover {
font-weight: 700; font-weight: 700;
margin-bottom: 14px; margin-bottom: 14px;
} }
/* ---------- 管理台:内容筛选 / 批量 / 分页 ---------- */
.status-chips {
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
margin-top: 18px;
}
.status-chips .spacer {
flex: 1;
}
.status-chips .page-hint {
font-size: 12px;
color: var(--ink-dim);
}
.batch-bar {
display: flex;
gap: 10px;
align-items: center;
flex-wrap: wrap;
margin-top: 18px;
}
.batch-bar .batch-count {
font-size: 12px;
color: var(--ink-dim);
}
.data-table .entry-check,
.data-table .entry-check-all {
accent-color: var(--riso-blue);
width: 15px;
height: 15px;
cursor: pointer;
}
.pager {
display: flex;
gap: 10px;
margin-top: 16px;
}
.btn-disabled {
opacity: 0.45;
pointer-events: none;
}
/* ---------- 管理台:标签树 拖拽 ---------- */
.tagmgr-tree {
border: 1.5px dashed var(--ink-faint);
border-radius: var(--r-sm);
padding: 12px;
transition:
background 0.15s ease,
box-shadow 0.15s ease;
}
.tagmgr-tree .tag-tree {
font-size: 13.5px;
}
.tagmgr-tree .tag-node {
cursor: grab;
}
.tagmgr-tree .tag-node:active {
cursor: grabbing;
}
.tagmgr-tree .tag-node.drag-src {
opacity: 0.45;
}
.tagmgr-tree .tag-node.drag-over {
background: var(--riso-blue-soft);
box-shadow: inset 0 0 0 1.5px var(--riso-blue);
}
.tagmgr-tree.drag-over {
background: var(--riso-blue-soft);
box-shadow: inset 0 0 0 1.5px var(--riso-blue);
}
.tagmgr-tree .nameleaf {
flex: 1;
padding-left: 17px;
color: var(--ink);
}
.tagmgr-tree .toggler {
flex: 1;
font: inherit;
}
.dnd-hint {
font-size: 12px;
margin-top: 12px;
}
.switchline { .switchline {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -1601,25 +1781,44 @@ button.chip:hover {
} }
.ai-key-field .input { .ai-key-field .input {
width: 100%; width: 100%;
padding-right: 42px; padding-right: 40px;
}
.pwd-field {
position: relative;
}
.pwd-field .input {
width: 100%;
padding-right: 40px;
} }
.eye-toggle { .eye-toggle {
position: absolute; position: absolute;
right: 8px; right: 2px;
top: 50%; top: 50%;
transform: translateY(-50%); transform: translateY(-50%);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
padding: 4px; width: 34px;
height: 100%;
padding: 0;
border: 0; border: 0;
background: none; background: none;
cursor: pointer; cursor: pointer;
color: var(--ink-dim); color: var(--ink-dim);
border-radius: 0 8px 8px 0;
} }
.eye-toggle:hover { .eye-toggle:hover {
color: var(--ink); color: var(--ink);
} }
.eye-toggle .eye-closed {
display: none;
}
.eye-toggle[data-show] .eye-open {
display: none;
}
.eye-toggle[data-show] .eye-closed {
display: inline;
}
.auth-row { .auth-row {
margin-top: 14px; margin-top: 14px;
display: flex; display: flex;
@@ -1850,6 +2049,113 @@ button.chip:hover {
} }
} }
.mcp-pitfalls {
margin-top: 30px;
padding: 20px 22px;
border: var(--border);
border-radius: 12px;
background: var(--paper);
}
.mcp-pitfalls ol {
margin: 0;
padding-left: 20px;
display: grid;
gap: 9px;
}
.mcp-pitfalls li {
font-size: 13.5px;
line-height: 1.7;
color: var(--ink-dim);
}
.mcp-pitfalls li b {
color: var(--ink);
}
.mcp-pitfalls .mono {
font-size: 12.5px;
}
/* ---------- 管理台:分区导航 / 搜索 / 评论人员 / 相关项目 ---------- */
.admin-nav a[data-section] {
display: flex;
align-items: center;
gap: 10px;
border-radius: 8px;
padding: 10px 12px;
font-size: 14px;
font-weight: 600;
color: inherit;
text-decoration: none;
}
.admin-nav a[data-section][aria-current="true"] {
background: var(--ink);
color: var(--paper);
}
.admin-nav .admin-nav-refresh {
margin-top: auto;
padding: 9px 12px;
border-radius: 8px;
border: var(--border);
font-family: var(--font-mono);
font-size: 12px;
color: var(--ink-dim);
text-decoration: none;
text-align: center;
}
.admin-nav .admin-nav-refresh:hover {
color: var(--ink);
border-color: var(--ink);
}
.admin-search {
display: flex;
gap: 8px;
align-items: center;
margin-top: 14px;
flex-wrap: wrap;
}
.admin-search .input {
flex: 1;
min-width: 240px;
}
.related-list {
list-style: none;
padding: 0;
margin: 0;
display: grid;
gap: 10px;
}
.related-list li {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 9px 0;
border-bottom: 1px dashed var(--ink-faint);
flex-wrap: wrap;
}
.related-list li:last-child {
border-bottom: 0;
}
.related-list li > a {
display: flex;
align-items: center;
gap: 8px;
color: inherit;
text-decoration: none;
min-width: 0;
}
.related-list li > a:hover b {
color: var(--riso-blue);
}
.related-list .chip-type {
flex: none;
}
aside .related-list li {
display: block;
padding: 10px 0;
}
/* ---------- 降低动效 ---------- */ /* ---------- 降低动效 ---------- */
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
html { html {
+251 -39
View File
@@ -26,30 +26,192 @@
} }
function refresh() { function refresh() {
setTimeout(function () { location.reload(); }, 500); location.reload();
} }
document.querySelectorAll(".admin-nav button[data-section]").forEach(function (btn) { function esc(v) {
btn.addEventListener("click", function () { return String(v == null ? "" : v).replace(/[&<>"']/g, function (c) {
document.querySelectorAll(".admin-nav button").forEach(function (b) { return { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c];
b.setAttribute("aria-current", "false"); });
}); }
btn.setAttribute("aria-current", "true");
var id = btn.getAttribute("data-section"); var SECTION_IDS = [
document.querySelectorAll(".admin-main > section").forEach(function (s) { "sec-content",
s.classList.toggle("active", s.id === id); "sec-tags",
"sec-comments",
"sec-contacts",
"sec-sources",
"sec-accounts",
];
function activateSection(id, { syncNav } = { syncNav: true }) {
if (!SECTION_IDS.includes(id)) return;
document.querySelectorAll(".admin-main > section").forEach(function (s) {
s.classList.toggle("active", s.id === id);
});
if (syncNav) {
document.querySelectorAll(".admin-nav [data-section]").forEach(function (b) {
b.setAttribute("aria-current", b.getAttribute("data-section") === id ? "true" : "false");
}); });
}
}
// 菜单项是真实链接:点击即向后台发起一次新的 /admin?section=… 请求,
// 服务端重新查库渲染,因此每次切分区拿到的都是最新数据(无需手动刷新)。
document.querySelectorAll(".admin-nav a[data-section]").forEach(function (link) {
link.addEventListener("click", function (e) {
// 普通左键点击交给浏览器完成整页跳转(= 重新拉数据);
// 中键 / Ctrl / Cmd 点击保留「新标签打开」的默认行为。
if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey || e.button !== 0) return;
activateSection(link.getAttribute("data-section"));
document.querySelector(".admin-nav-refresh")?.setAttribute(
"href",
link.getAttribute("href")
);
}); });
}); });
document.querySelectorAll(".tree-row[data-tree]").forEach(function (row) { // 兼容旧链接 /admin#sec-xxx:按 hash 切分区但不重新拉取
row.addEventListener("click", function (e) { (function restoreHashSection() {
if (e.target.closest("button[data-tag-action]")) return; var hash = (location.hash || "").replace("#", "");
var target = document.getElementById(row.getAttribute("data-tree")); if (hash && SECTION_IDS.includes(hash)) activateSection(hash);
if (!target) return; })();
var open = target.classList.toggle("open");
row.setAttribute("aria-expanded", open ? "true" : "false"); /* ============ 标签树:客户端渲染 + 拖拽调整层级 ============ */
var tagRoot = document.getElementById("admin-tag-tree");
var tagDraggingId = null;
function tagRowHtml(n) {
var kids = n.children && n.children.length;
var label = kids
? '<button type="button" class="tree-row toggler" aria-expanded="true"><span class="tw">▶</span> <span class="name">' + esc(n.name) + "</span></button>"
: '<span class="nameleaf"><span class="name">' + esc(n.name) + "</span></span>";
return (
"<li>" +
'<div class="tree-row tag-node" draggable="true" data-id="' + n.id + '" data-drop-id="' + n.id + '">' +
label +
'<span class="mono cnt">' + n.count + " · #" + n.id + "</span>" +
'<span class="row-actions" style="margin-left:8px">' +
'<button class="btn btn-sm" type="button" data-action="tag-add" data-parent-id="' + n.id + '">+子级</button>' +
'<button class="btn btn-sm" type="button" data-action="tag-rename" data-id="' + n.id + '" data-name="' + esc(n.name) + '">改名</button>' +
'<button class="btn btn-sm" type="button" data-action="tag-move" data-id="' + n.id + '">移动</button>' +
'<button class="btn btn-sm btn-danger" type="button" data-action="tag-delete" data-id="' + n.id + '">删</button>' +
"</span>" +
"</div>" +
(kids ? '<ul class="tag-children open">' + n.children.map(tagRowHtml).join("") + "</ul>" : "") +
"</li>"
);
}
function renderTagAdminTree(tree) {
if (!tagRoot) return;
tagRoot.innerHTML = tree && tree.length
? '<ul class="tag-tree">' + tree.map(tagRowHtml).join("") + "</ul>"
: '<p class="mono dim">还没有标签。</p>';
}
async function refreshTags() {
if (!tagRoot) return;
var json = await api("/api/tags/tree", undefined, "GET");
renderTagAdminTree(json.data);
}
function clearTagDnd() {
tagDraggingId = null;
if (!tagRoot) return;
tagRoot.classList.remove("drag-over");
tagRoot.querySelectorAll(".drag-src,.drag-over").forEach(function (el) {
el.classList.remove("drag-src", "drag-over");
}); });
}
if (tagRoot) {
if (Array.isArray(window.__ADMIN_TAG_TREE__)) renderTagAdminTree(window.__ADMIN_TAG_TREE__);
else refreshTags();
tagRoot.addEventListener("click", function (e) {
var tog = e.target.closest(".toggler");
if (!tog) return;
var li = tog.closest("li");
var ul = null;
if (li) {
for (var i = 0; i < li.children.length; i++) {
if (li.children[i].tagName === "UL") ul = li.children[i];
}
}
if (!ul) return;
var open = ul.classList.toggle("open");
tog.setAttribute("aria-expanded", open ? "true" : "false");
});
tagRoot.addEventListener("dragstart", function (e) {
if (e.target.closest("button")) return;
var row = e.target.closest(".tag-node");
if (!row) return;
tagDraggingId = row.getAttribute("data-id");
e.dataTransfer.setData("text/plain", tagDraggingId);
e.dataTransfer.effectAllowed = "move";
row.classList.add("drag-src");
});
tagRoot.addEventListener("dragend", clearTagDnd);
tagRoot.addEventListener("dragover", function (e) {
if (!tagDraggingId) return;
e.preventDefault();
e.dataTransfer.dropEffect = "move";
var row = e.target.closest(".tag-node[data-drop-id]");
tagRoot.querySelectorAll(".drag-over").forEach(function (el) {
el.classList.remove("drag-over");
});
if (row) row.classList.add("drag-over");
else tagRoot.classList.add("drag-over");
});
tagRoot.addEventListener("dragleave", function (e) {
if (e.target === tagRoot && !e.relatedTarget) tagRoot.classList.remove("drag-over");
});
tagRoot.addEventListener("drop", async function (e) {
var id = tagDraggingId;
var row = e.target.closest(".tag-node[data-drop-id]");
var dropTarget = row ? row.getAttribute("data-drop-id") : null;
clearTagDnd();
if (!id || dropTarget === id) return;
try {
await api("/api/tags/" + id, { parentId: dropTarget ? Number(dropTarget) : null }, "PATCH");
toast(dropTarget ? "已移动到目标标签下" : "已移回顶层");
await refreshTags();
} catch (err) {
toast(err.message);
}
});
}
/* ============ 批量选择 / 批量状态 ============ */
function selectedEntryIds() {
var ids = [];
document.querySelectorAll(".entry-check:checked").forEach(function (c) {
ids.push(Number(c.getAttribute("data-id")));
});
return ids;
}
function updateBatchCount() {
var el = document.getElementById("batch-count");
if (el) el.textContent = "已选 " + selectedEntryIds().length + " 项";
}
document.addEventListener("change", function (e) {
var ck = e.target;
if (ck.classList && ck.classList.contains("entry-check-all")) {
document.querySelectorAll(".entry-check").forEach(function (c) {
c.checked = ck.checked;
});
updateBatchCount();
} else if (ck.classList && ck.classList.contains("entry-check")) {
updateBatchCount();
}
}); });
document.addEventListener("click", async function (e) { document.addEventListener("click", async function (e) {
@@ -63,28 +225,32 @@
var name = prompt(parentId ? "新增子标签名称:" : "新增顶层标签组名称:"); var name = prompt(parentId ? "新增子标签名称:" : "新增顶层标签组名称:");
if (!name) return; if (!name) return;
await api("/api/tags", { name: name, parentId: parentId ? Number(parentId) : null }); await api("/api/tags", { name: name, parentId: parentId ? Number(parentId) : null });
toast("标签已创建"); refresh(); await refreshTags();
toast("标签已创建");
} }
if (action === "tag-rename") { if (action === "tag-rename") {
var name2 = prompt("重命名为:", btn.getAttribute("data-name")); var name2 = prompt("重命名为:", btn.getAttribute("data-name"));
if (!name2 || name2 === btn.getAttribute("data-name")) return; if (!name2 || name2 === btn.getAttribute("data-name")) return;
await api("/api/tags/" + btn.getAttribute("data-id"), { name: name2 }, "PATCH"); await api("/api/tags/" + btn.getAttribute("data-id"), { name: name2 }, "PATCH");
toast("已重命名"); refresh(); await refreshTags();
toast("已重命名");
} }
if (action === "tag-move") { if (action === "tag-move") {
var target3 = prompt("移动到哪个标签之下?填其 ID(留空移到顶层):\n" + btn.getAttribute("data-ids")); var target3 = prompt("移动到哪个标签之下?填其 ID(留空移到顶层):");
if (target3 === null) return; if (target3 === null) return;
await api("/api/tags/" + btn.getAttribute("data-id"), await api("/api/tags/" + btn.getAttribute("data-id"),
{ parentId: target3.trim() ? Number(target3.trim()) : null }, "PATCH"); { parentId: target3.trim() ? Number(target3.trim()) : null }, "PATCH");
toast("已移动"); refresh(); await refreshTags();
toast("已移动");
} }
if (action === "tag-delete") { if (action === "tag-delete") {
if (!confirm("删除该标签?其子标签将上移一级,关联关系解除。")) return; if (!confirm("删除该标签?其子标签将上移一级,关联关系解除。")) return;
await api("/api/tags/" + btn.getAttribute("data-id"), undefined, "DELETE"); await api("/api/tags/" + btn.getAttribute("data-id"), undefined, "DELETE");
toast("已删除"); refresh(); await refreshTags();
toast("已删除");
} }
if (action === "entry-status") { if (action === "entry-status") {
@@ -99,12 +265,69 @@
toast("已删除"); refresh(); toast("已删除"); refresh();
} }
if (action === "entry-select-all") {
var checks = Array.prototype.slice.call(document.querySelectorAll(".entry-check"));
var allOn = checks.length > 0 && checks.every(function (c) { return c.checked; });
checks.forEach(function (c) { c.checked = !allOn; });
var allEl = document.querySelector(".entry-check-all");
if (allEl) allEl.checked = !allOn;
updateBatchCount();
}
if (action === "entry-batch") {
var ids = selectedEntryIds();
if (!ids.length) { toast("请先勾选条目"); return; }
var status = btn.getAttribute("data-status");
await api("/api/admin/entries/status", { ids: ids, status: status }, "POST");
toast(status === "active" ? "已上架 " + ids.length + " 条" : "已灰掉 " + ids.length + " 条");
refresh();
}
if (action === "comment-delete") { if (action === "comment-delete") {
if (!confirm("删除该评论?")) return; if (!confirm("删除该评论?")) return;
await api("/api/comments/" + btn.getAttribute("data-id"), undefined, "DELETE"); await api("/api/comments/" + btn.getAttribute("data-id"), undefined, "DELETE");
toast("已删除"); refresh(); toast("已删除"); refresh();
} }
if (action === "contact-status") {
var enabled = btn.getAttribute("data-enabled") === "1";
if (enabled && !confirm("禁用后该联系方式将无法继续评论,确认?")) return;
await api("/api/admin/contacts/" + btn.getAttribute("data-id") + "/status",
{ enabled: !enabled }, "PATCH");
toast(enabled ? "已禁用" : "已启用"); refresh();
}
if (action === "contact-rename") {
var nick = prompt("修改昵称:", btn.getAttribute("data-name"));
if (!nick || nick === btn.getAttribute("data-name")) return;
await api("/api/admin/contacts/" + btn.getAttribute("data-id"),
{ nickname: nick }, "PATCH");
toast("昵称已更新"); refresh();
}
if (action === "contact-delete") {
if (!confirm("删除该评论人员?其历史评论会保留。")) return;
await api("/api/admin/contacts/" + btn.getAttribute("data-id"), undefined, "DELETE");
toast("已删除"); refresh();
}
if (action === "relation-add") {
var sel = document.getElementById("f-related");
if (!sel || !sel.value) { toast("没有可关联的条目"); return; }
var opt = sel.options[sel.selectedIndex];
await api("/api/admin/entries/" + window.__ADMIN_ENTRY_ID__ + "/relations",
{ relatedId: Number(sel.value) }, "POST");
toast("已关联:" + opt.textContent.trim().split(" · ")[0]);
refresh();
}
if (action === "relation-remove") {
if (!confirm("移除该关联?")) return;
await api("/api/admin/entries/" + window.__ADMIN_ENTRY_ID__ +
"/relations/" + btn.getAttribute("data-related-id"), undefined, "DELETE");
toast("已移除关联"); refresh();
}
if (action === "user-disable") { if (action === "user-disable") {
var status = btn.getAttribute("data-status") === "active" ? "disabled" : "active"; var status = btn.getAttribute("data-status") === "active" ? "disabled" : "active";
await api("/api/admin/users/" + btn.getAttribute("data-id") + "/status", { status: status }, "PATCH"); await api("/api/admin/users/" + btn.getAttribute("data-id") + "/status", { status: status }, "PATCH");
@@ -139,19 +362,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();
@@ -175,10 +385,12 @@
try { try {
var json = await api("/api/admin/sources/" + id + "/run", {}); var json = await api("/api/admin/sources/" + id + "/run", {});
var s = json.data; var s = json.data;
toast(s.error if (s.error) {
? "拉取失败:" + s.error toast("拉取失败:" + s.error);
: "完成:发现 " + s.found + " · 新增 " + s.created + " · 跳过 " + s.skipped); } else {
if (!s.error) refresh(); toast("完成:发现 " + s.found + " · 新增 " + s.created + " · 跳过 " + s.skipped);
refresh();
}
} catch (err) { } catch (err) {
toast(err.message); toast(err.message);
} finally { } finally {
+40
View File
@@ -118,6 +118,20 @@
}); });
}); });
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 ? "隐藏密码" : "显示密码");
if (show) btn.setAttribute("data-show", "");
else btn.removeAttribute("data-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 +140,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("登录成功,正在进入管理台…");
@@ -135,14 +150,39 @@
}); });
} }
var CONTACT_EMAIL = /^[^\s@]+@[^\s@]+\.[A-Za-z]{2,}$/;
var CONTACT_PHONE = /^1[3-9]\d{9}$/;
function validContact(v) {
var s = String(v == null ? "" : v).trim();
return CONTACT_EMAIL.test(s) || CONTACT_PHONE.test(s);
}
var commentForm = document.querySelector(".comment-form[data-entry-id]"); var commentForm = document.querySelector(".comment-form[data-entry-id]");
if (commentForm) { if (commentForm) {
var contactInput = commentForm.querySelector("[name=contact]");
if (contactInput) {
contactInput.addEventListener("input", function () {
if (contactInput.value.trim() && validContact(contactInput.value)) {
contactInput.setCustomValidity("");
} else {
contactInput.setCustomValidity("请填写正确的手机号或邮箱(不会公开显示)");
}
});
}
commentForm.addEventListener("submit", function (e) { commentForm.addEventListener("submit", function (e) {
e.preventDefault(); e.preventDefault();
var contact = contactInput ? contactInput.value.trim() : "";
if (!validContact(contact)) {
toast("请填写正确的联系方式:手机号或邮箱");
if (contactInput) contactInput.focus();
return;
}
if (!commentForm.reportValidity()) return; if (!commentForm.reportValidity()) return;
postJson("/api/comments", { postJson("/api/comments", {
entryId: Number(commentForm.getAttribute("data-entry-id")), entryId: Number(commentForm.getAttribute("data-entry-id")),
authorName: commentForm.querySelector("[name=authorName]").value, authorName: commentForm.querySelector("[name=authorName]").value,
contact: contact,
body: commentForm.querySelector("[name=body]").value, body: commentForm.querySelector("[name=body]").value,
}) })
.then(function () { .then(function () {
+9
View File
@@ -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)) {
+2
View File
@@ -20,6 +20,8 @@ export const config = {
aiIngestKey: str(env.AI_INGEST_KEY, ""), aiIngestKey: str(env.AI_INGEST_KEY, ""),
baseUrl: str(env.BASE_URL, `http://localhost:${num(env.PORT, 3000)}`), baseUrl: str(env.BASE_URL, `http://localhost:${num(env.PORT, 3000)}`),
schedulerEnabled: env.SCHEDULER_DISABLED !== "1", schedulerEnabled: env.SCHEDULER_DISABLED !== "1",
gitBranch: str(env.GIT_BRANCH, "dev"),
buildTime: str(env.BUILD_TIME, new Date().toISOString().replace(/[-T:]/g, "").slice(0, 14)),
}; };
export function ensureDataDir() { export function ensureDataDir() {
+40
View File
@@ -15,6 +15,42 @@ async function runSchema() {
} }
} }
/**
* 老数据里已经存在的评论没有联系方式。这里按昵称补建评论人,
* 联系方式留一个占位值(不会被任何格式校验通过),管理员后续可修正。
*/
async function backfillContacts() {
const rows = await all(
`SELECT id, author_name FROM comments WHERE contact IS NULL OR contact = ''`
);
for (const row of rows) {
const nickname = String(row.author_name || "").trim() || "匿名";
const contact = await ensureContact(`legacy-${row.id}`, nickname);
await run(`UPDATE comments SET contact = ?, contact_id = ? WHERE id = ?`, [
contact.contact,
contact.id,
row.id,
]);
}
if (rows.length) {
await run(
`UPDATE contacts SET comment_count = (
SELECT COUNT(*) FROM comments c WHERE c.contact_id = contacts.id
)`
);
}
}
async function ensureContact(contact, nickname) {
const existing = await get(`SELECT * FROM contacts WHERE contact = ?`, [contact]);
if (existing) return existing;
await run(
`INSERT INTO contacts (contact, nickname) VALUES (?, ?)`,
[contact, nickname]
);
return get(`SELECT * FROM contacts WHERE contact = ?`, [contact]);
}
export async function seedTagsAndEntries() { export async function seedTagsAndEntries() {
const tagCount = await get(`SELECT COUNT(*) AS c FROM tags`); const tagCount = await get(`SELECT COUNT(*) AS c FROM tags`);
if (Number(tagCount.c) > 0) return; if (Number(tagCount.c) > 0) return;
@@ -81,6 +117,10 @@ 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 ''`);
await run(`ALTER TABLE comments ADD COLUMN IF NOT EXISTS contact VARCHAR DEFAULT ''`);
await run(`ALTER TABLE comments ADD COLUMN IF NOT EXISTS contact_id INTEGER`);
await backfillContacts();
const admin = await findByUsername("admin"); const admin = await findByUsername("admin");
if (!admin) { if (!admin) {
+22
View File
@@ -4,6 +4,8 @@ CREATE SEQUENCE IF NOT EXISTS source_runs_id_seq;
CREATE SEQUENCE IF NOT EXISTS entries_id_seq; CREATE SEQUENCE IF NOT EXISTS entries_id_seq;
CREATE SEQUENCE IF NOT EXISTS tags_id_seq; CREATE SEQUENCE IF NOT EXISTS tags_id_seq;
CREATE SEQUENCE IF NOT EXISTS comments_id_seq; CREATE SEQUENCE IF NOT EXISTS comments_id_seq;
CREATE SEQUENCE IF NOT EXISTS contacts_id_seq;
CREATE SEQUENCE IF NOT EXISTS entry_relations_id_seq;
CREATE SEQUENCE IF NOT EXISTS audit_logs_id_seq; CREATE SEQUENCE IF NOT EXISTS audit_logs_id_seq;
CREATE TABLE IF NOT EXISTS users ( CREATE TABLE IF NOT EXISTS users (
@@ -54,6 +56,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 '',
@@ -71,15 +74,34 @@ CREATE TABLE IF NOT EXISTS entry_tags (
PRIMARY KEY (entry_id, tag_id) PRIMARY KEY (entry_id, tag_id)
); );
CREATE TABLE IF NOT EXISTS entry_relations (
id INTEGER PRIMARY KEY DEFAULT nextval('entry_relations_id_seq'),
entry_id INTEGER NOT NULL REFERENCES entries(id),
related_id INTEGER NOT NULL REFERENCES entries(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS comments ( CREATE TABLE IF NOT EXISTS comments (
id INTEGER PRIMARY KEY DEFAULT nextval('comments_id_seq'), id INTEGER PRIMARY KEY DEFAULT nextval('comments_id_seq'),
entry_id INTEGER NOT NULL REFERENCES entries(id), entry_id INTEGER NOT NULL REFERENCES entries(id),
user_id INTEGER REFERENCES users(id), user_id INTEGER REFERENCES users(id),
contact_id INTEGER,
author_name VARCHAR NOT NULL, author_name VARCHAR NOT NULL,
contact VARCHAR DEFAULT '',
body TEXT NOT NULL, body TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now() created_at TIMESTAMPTZ NOT NULL DEFAULT now()
); );
CREATE TABLE IF NOT EXISTS contacts (
id INTEGER PRIMARY KEY DEFAULT nextval('contacts_id_seq'),
contact VARCHAR UNIQUE NOT NULL,
nickname VARCHAR NOT NULL,
enabled BOOLEAN NOT NULL DEFAULT TRUE,
comment_count INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS audit_logs ( CREATE TABLE IF NOT EXISTS audit_logs (
id INTEGER PRIMARY KEY DEFAULT nextval('audit_logs_id_seq'), id INTEGER PRIMARY KEY DEFAULT nextval('audit_logs_id_seq'),
actor VARCHAR NOT NULL, actor VARCHAR NOT NULL,
+14 -3
View File
@@ -1,5 +1,5 @@
import { config, ensureDataDir } from "./config.js"; import { config, ensureDataDir } from "./config.js";
import { connect } from "./db/connection.js"; import { connect, close } from "./db/connection.js";
import { runMigrate } from "./db/migrate.js"; import { runMigrate } from "./db/migrate.js";
import { buildApp } from "./app.js"; import { buildApp } from "./app.js";
import { startScheduler } from "./ingest/scheduler.js"; import { startScheduler } from "./ingest/scheduler.js";
@@ -19,8 +19,19 @@ async function main() {
} }
const shutdown = async () => { const shutdown = async () => {
server.close(() => process.exit(0)); console.log("[awesome-index] shutting down...");
setTimeout(() => process.exit(0), 2000).unref(); try {
server.close();
if (config.schedulerEnabled) {
const { stopAll } = await import("./ingest/scheduler.js");
stopAll();
}
await close();
console.log("[awesome-index] duckdb closed (wal checkpointed)");
} catch (err) {
console.error("[awesome-index] shutdown error:", err);
}
process.exit(0);
}; };
process.on("SIGINT", shutdown); process.on("SIGINT", shutdown);
process.on("SIGTERM", shutdown); process.on("SIGTERM", shutdown);
+164 -38
View File
@@ -23,7 +23,12 @@ async function getText(url, timeoutMs = 15000) {
try { try {
const res = await fetch(url, { const res = await fetch(url, {
signal: controller.signal, signal: controller.signal,
headers: { "user-agent": "AwesomeIndexBot/0.1 (+https://honor3.com)" }, headers: {
"user-agent":
"Mozilla/5.0 (compatible; AwesomeIndexBot/0.2; +https://honor3.com)",
accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"accept-language": "zh-CN,zh;q=0.9,en;q=0.8",
},
redirect: "follow", redirect: "follow",
}); });
if (!res.ok) throw new Error(`HTTP ${res.status} ${url}`); if (!res.ok) throw new Error(`HTTP ${res.status} ${url}`);
@@ -33,91 +38,212 @@ async function getText(url, timeoutMs = 15000) {
} }
} }
/** 去掉 VuePress 外链渲染出来的 “ (opens new window)” 之类尾巴 */
function cleanText(text) {
return String(text || "")
.replace(/\(opens?\s+new\s+window\)/gi, "")
.replace(/\s+/g, " ")
.trim();
}
function githubRepoFromLink(href) { function githubRepoFromLink(href) {
const m = String(href).match(/github\.com\/([\w.-]+)\/([\w.-]+)/i); const m = String(href).match(/github\.com\/([\w.-]+)\/([\w.-]+)/i);
return m ? { owner: m[1], repo: m[2].replace(/\.git$/, "") } : null; if (!m) return null;
const owner = m[1];
const repo = m[2].replace(/\.git$/, "");
// 排除用户主页 / 组织页(只有 owner 的场景由正则保证有 repo),以及站内导航链接
if (!owner || !repo) return null;
if (["sponsors", "topics", "search", "about", "pricing", "login", "join"].includes(repo)) {
return null;
}
return { owner, repo };
}
/** RSS / Atom:link 可能是文本节点、href 属性,或者是 <link rel="alternate"> 之外的其它 link */
function pickRssLink(node) {
const linkNode = node.find("link").first();
let link = cleanText(linkNode.text());
if (!link) {
link = String(
linkNode.attr("href") ||
node
.find("link")
.filter((_, el) => !String(el.attribs?.rel || "").includes("self"))
.first()
.attr("href") ||
""
).trim();
}
if (!link && node.attr("href")) link = String(node.attr("href")).trim();
return link;
} }
const rss = { const rss = {
async fetch(url) { async fetch(url, options = {}) {
const xml = await getText(url); const xml = await getText(url);
const $ = cheerio.load(xml, { xmlMode: true }); const $ = cheerio.load(xml, { xmlMode: true, decodeEntities: true });
const items = []; const items = [];
$("item, entry").each((_, el) => { $("item, entry").each((_, el) => {
const node = $(el); const node = $(el);
const link = node.find("link").first().text().trim() || node.find("link").attr("href") || ""; const link = pickRssLink(node);
const title = node.find("title").first().text().trim(); const title = cleanText(node.find("title").first().text());
const description = const description = cleanText(
node.find("description").first().text().trim() || node.find("description").first().text() ||
node.find("summary").first().text().trim(); node.find("summary").first().text() ||
if (title && link) items.push({ title, url, link, description }); node.find("content").first().text()
);
let canonical = link;
try {
canonical = normalizeUrl(new URL(link, url).toString());
} catch {
canonical = normalizeUrl(link);
}
if (title && canonical) {
items.push({
title,
url: canonical,
description: description.replace(/<[^>]*>/g, "").trim().slice(0, 500),
});
}
}); });
return items.slice(0, 50); return items.slice(0, options.limit ?? 50);
}, },
}; };
const html = { const html = {
async fetch(url, options = {}) { async fetch(url, options = {}) {
const itemSel = options.selectors?.item || "article a"; const itemSel = options.selectors?.item || "article a, .post a, main a";
const body = await getText(url); const body = await getText(url);
const $ = cheerio.load(body); const $ = cheerio.load(body);
const seen = new Set();
const items = []; const items = [];
$(itemSel).each((_, el) => { $(itemSel).each((_, el) => {
const node = $(el); const node = $(el);
const href = node.attr("href"); const href = node.attr("href");
if (!href) return; if (!href) return;
const abs = new URL(href, url).toString(); if (/^(#|javascript:|mailto:)/i.test(href)) return;
let abs;
try {
abs = new URL(href, url).toString();
} catch {
return;
}
const title = cleanText(node.text()).slice(0, 120);
if (!title || seen.has(abs)) return;
seen.add(abs);
items.push({ items.push({
title: node.text().replace(/\s+/g, " ").trim().slice(0, 120), title,
url: abs, url: abs,
description: options.selectors?.description description: cleanText(
? node.closest(options.selectors.description).text().replace(/\s+/g, " ").trim() options.selectors?.description
: "", ? node.closest(options.selectors.description).text()
: ""
).slice(0, 500),
}); });
}); });
return items.slice(0, 50); return items.slice(0, options.limit ?? 50);
}, },
}; };
const eryajfWeekly = { const eryajfWeekly = {
async fetch(listUrl) { async fetch(listUrl, options = {}) {
const maxIssues = options.maxIssues ?? 3;
const indexHtml = await getText(listUrl); const indexHtml = await getText(listUrl);
const $ = cheerio.load(indexHtml); const $ = cheerio.load(indexHtml);
const issues = []; const issues = [];
const seenIssue = new Set();
$("a[href*='/pages/']").each((_, el) => { $("a[href*='/pages/']").each((_, el) => {
const node = $(el); const node = $(el);
const text = node.text().trim(); const text = cleanText(node.text());
const href = node.attr("href"); const href = node.attr("href");
if (/学习周刊-总第\d+期/.test(text) && href) { if (!href || !/学习周刊-总第\d+期/.test(text)) return;
issues.push({ text, url: new URL(href, listUrl).toString() }); let abs;
try {
abs = new URL(href, listUrl).toString();
} catch {
return;
} }
if (seenIssue.has(abs)) return;
seenIssue.add(abs);
issues.push({ text, url: abs });
}); });
// 目录页按时间正序排列,最新的在最后
issues.reverse(); issues.reverse();
const results = []; const results = [];
for (const issue of issues.slice(0, 3)) { const seenRepo = new Set();
const issueHtml = await getText(issue.url); /**
* 详情也是 CSR 渲染,SSR 输出里每个项目长这样:
* <ul>
* <li>项目地址:<a href="https://github.com/o/r">Name</a></li>
* <li>项目说明:描述文字…<img></li>
* </ul>
* <hr>
* 所以描述不在 <a> 所在的 <li> 里,得往上找到整个 <ul>(或 <p>)再取。
*/
function surroundingBlock(node) {
for (const sel of ["ul", "ol", "p", "tr"]) {
const hit = node.closest(sel);
if (hit.length) return hit;
}
return node.parent();
}
for (const issue of issues.slice(0, maxIssues)) {
let issueHtml;
try {
issueHtml = await getText(issue.url);
} catch (err) {
console.error(`[ingest] 周刊页拉取失败 ${issue.url}:`, err.message);
continue;
}
const $$ = cheerio.load(issueHtml); const $$ = cheerio.load(issueHtml);
const seen = new Set(); // 站内的导航栏、侧边栏、面包屑里也挂着一堆 github 链接,限定在正文容器内
$$('a[href*="github.com"]').each((_, el) => { const root = [
".theme-vdoing-content.content__default",
".theme-default-content",
".custom-page-content",
"article",
]
.map((sel) => $$(sel).first())
.find((node) => node.length);
const scope = root && root.length ? root : $$.root();
scope.find('a[href*="github.com"]').each((_, el) => {
const anchor = $$(el); const anchor = $$(el);
const repo = githubRepoFromLink(anchor.attr("href")); const repo = githubRepoFromLink(anchor.attr("href"));
if (!repo) return; if (!repo) return;
const repoUrl = `https://github.com/${repo.owner}/${repo.repo}`; const repoUrl = `https://github.com/${repo.owner}/${repo.repo}`;
if (seen.has(repoUrl)) return; if (seenRepo.has(repoUrl)) return;
seen.add(repoUrl); seenRepo.add(repoUrl);
const containerText = anchor
.closest("li, p, td") // 标题:锚文本;锚文本是裸 URL 时用 owner/repo
.text() let name = cleanText(anchor.text()).replace(/^项目地址[::]?\s*/, "").trim();
.replace(/\s+/g, " ") if (!name || /^https?:\/\//i.test(name)) name = `${repo.owner}/${repo.repo}`;
.trim();
const name = // 描述:在所属的 ul/p 块里找「项目说明:」那段
anchor.text().replace(/[*`\[\]]/g, "").trim() || `${repo.owner}/${repo.repo}`; const block = surroundingBlock(anchor);
const description = (containerText.replace(anchor.text(), "").replace(/[::]\s*$/, "").trim() || "").slice(0, 300); let description = "";
block.find("li, p, td").each((_, part) => {
if (description) return;
const txt = cleanText($$(part).text());
const hit = /^项目说明[::]?\s*(.*)$/s.exec(txt);
if (hit) description = hit[1].trim();
});
if (!description) {
// 退化:拿块内除了「项目地址:」行以外的文字
const whole = cleanText(block.text());
const stripped = whole
.replace(/^项目地址[::]?\s*/, "")
.replace(new RegExp(`^${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`, ""), "")
.replace(/^[::\s]+/, "");
description = stripped === name ? "" : stripped;
}
results.push({ results.push({
title: name, title: name.slice(0, 120),
url: repoUrl, url: repoUrl,
description, description: description.slice(0, 300),
extra: { from: issue.text }, extra: { from: issue.text },
}); });
}); });
+31 -4
View File
@@ -13,6 +13,20 @@ function inferType(url) {
return "SaaS"; return "SaaS";
} }
/** 标题里如果塞了裸 URL,就用仓库 owner/repo 兜底 */
function cleanTitle(raw) {
let title = String(raw || "").replace(/\s+/g, " ").trim();
const repo = title.match(/github\.com\/([\w.-]+)\/([\w.-]+)/i);
if (repo) title = `${repo[1]}/${repo[2].replace(/\.git$/, "")}`;
return title.slice(0, 120);
}
function buildDescription(item) {
const desc = String(item.description || "").replace(/\s+/g, " ").trim();
const from = item.extra?.from ? `\n\n> 来源:${item.extra.from}` : "";
return (desc ? desc.slice(0, 500) : "(自动收录,暂无描述)") + from;
}
export async function runSource(sourceId) { export async function runSource(sourceId) {
const source = await sourceService.getById(sourceId); const source = await sourceService.getById(sourceId);
if (!source) throw new Error(`内容源不存在:${sourceId}`); if (!source) throw new Error(`内容源不存在:${sourceId}`);
@@ -24,14 +38,23 @@ export async function runSource(sourceId) {
try { try {
const adapter = getAdapter(source.adapter); const adapter = getAdapter(source.adapter);
const rawItems = await adapter.fetch(source.url, {}); const rawItems = await adapter.fetch(source.url, {});
stats.found = rawItems.length; stats.found = Array.isArray(rawItems) ? rawItems.length : 0;
if (!stats.found) {
stats.error = stats.error || "没有解析到任何条目(检查地址或适配器是否匹配)";
}
for (const item of rawItems) { const seen = new Set();
for (const item of rawItems || []) {
if (!item.title || !item.url) { if (!item.title || !item.url) {
stats.skipped += 1; stats.skipped += 1;
continue; continue;
} }
const normalizedUrl = normalizeUrl(item.url); const normalizedUrl = normalizeUrl(item.url);
if (seen.has(normalizedUrl)) {
stats.skipped += 1;
continue;
}
seen.add(normalizedUrl);
if (await isKnownUrl(normalizedUrl)) { if (await isKnownUrl(normalizedUrl)) {
stats.skipped += 1; stats.skipped += 1;
continue; continue;
@@ -39,10 +62,11 @@ export async function runSource(sourceId) {
try { try {
const result = await entryService.createEntry( const result = await entryService.createEntry(
{ {
title: String(item.title).slice(0, 120), title: cleanTitle(item.title),
url: normalizedUrl, url: normalizedUrl,
type: inferType(normalizedUrl), type: inferType(normalizedUrl),
description_md: String(item.description || "").slice(0, 500), summary: String(item.summary || "").slice(0, 200),
description_md: buildDescription(item),
tags: ["自动收录"], tags: ["自动收录"],
status: "pending", status: "pending",
}, },
@@ -51,7 +75,10 @@ export async function runSource(sourceId) {
if (result.created) stats.created += 1; if (result.created) stats.created += 1;
else stats.skipped += 1; else stats.skipped += 1;
} catch (err) { } catch (err) {
// 单条失败不应该让整轮拉取报废
stats.skipped += 1;
stats.error = stats.error || err.message; stats.error = stats.error || err.message;
console.error(`[ingest] 条目写入失败 ${normalizedUrl}:`, err.message);
} }
} }
} catch (err) { } catch (err) {
+20 -5
View File
@@ -6,13 +6,28 @@ import { config } from "../config.js";
const jobs = new Map(); const jobs = new Map();
function startJob(source) { function startJob(source) {
if (!cron.validate(source.cron_expr || "")) return; const expr = String(source.cron_expr || "").trim();
const task = cron.schedule(source.cron_expr, () => { if (!expr) {
runSource(source.id).catch((err) => console.warn(`[ingest] ${source.name} 没有配置 cron 计划,跳过定时`);
console.error(`[ingest] ${source.name} 运行失败:`, err.message) return;
); }
if (!cron.validate(expr)) {
console.warn(`[ingest] ${source.name} 的 cron 表达式无效(${expr}),跳过定时`);
return;
}
const task = cron.schedule(expr, () => {
runSource(source.id)
.then((stats) => {
console.log(
`[ingest] ${source.name} 定时拉取完成:发现 ${stats.found} · 新增 ${stats.created} · 跳过 ${stats.skipped}${stats.error ? " · 错误 " + stats.error : ""}`
);
})
.catch((err) =>
console.error(`[ingest] ${source.name} 运行失败:`, err.message)
);
}); });
jobs.set(source.id, task); jobs.set(source.id, task);
console.log(`[ingest] 已注册定时任务:${source.name} [${expr}]`);
} }
export async function startScheduler() { export async function startScheduler() {
+104 -5
View File
@@ -5,13 +5,112 @@ import { registerTools } from "./tools.js";
export const mcpRouter = Router(); export const mcpRouter = Router();
const FALLBACK_BASE_URL = process.env.BASE_URL || "http://localhost:3000";
function requestBaseUrl(req) {
const host = req.get("host");
if (!host) return FALLBACK_BASE_URL;
const proto = req.get("x-forwarded-proto") || req.protocol || "http";
return `${proto}://${host}`;
}
function buildInstructions(baseUrl) {
return `Awesome Index —— 精选开源软件 / 微服务 / SaaS / 网站 / 工具 / 脚本 / 插件的结构化数据库。
你可以把它当成「别人已经筛过一轮的软件库」:先在这里查,查不到再联网搜。
# 一、一句话记住
> 读用 MCP(免密钥),写用 REST(要 \`X-Ingest-Key\`)。
本 MCP 端点**只读、不校验任何密钥**,连接时不要配 token。
要往库里写新条目,走 HTTP 接口 \`POST ${baseUrl}/api/ingest/entry\` 并带上录入密钥。
# 二、四个只读工具
| 工具 | 参数 | 用途 |
| --- | --- | --- |
| \`search_entries\` | \`q\`(必填,关键字)、\`limit\`(1–50,默认 20) | 按关键字 / 类型 / 标签搜索,**查重先走它** |
| \`get_entry\` | \`slug\`(必填,也可传标题关键字自动匹配) | 取单条详情 |
| \`random_entry\` | 无 | 随机抽一条,用于发现 |
| \`list_tags\` | 无 | 列全部标签组与数量 |
**结果格式**:每个工具返回 \`result.content[0].text\`,那是一段 **JSON 字符串,需要再 JSON.parse 一次**才能拿到对象。
# 三、客户端配置(Claude Desktop / Cursor / Cherry Studio 通用)
\`\`\`json
{
"mcpServers": {
"awesome-index": {
"url": "${baseUrl}/mcp"
}
}
}
\`\`\`
# 四、提交新条目(写入 · 要密钥)
\`\`\`bash
curl -X POST "${baseUrl}/api/ingest/entry" \\
-H "X-Ingest-Key: <你的密钥>" \\
-H "Content-Type: application/json" \\
-d '{"title":"MinerU","url":"https://github.com/opendatalab/MinerU","type":"工具","summary":"PDF/文档转 Markdown 的解析引擎","tags":["Python","OCR","文档解析"]}'
\`\`\`
成功返回:\`{"ok":true,"data":{"created":true,"id":54,"status":"pending"}}\`
# 五、字段规则
| 字段 | 必填 | 约束 |
| --- | --- | --- |
| \`title\` | ✅ | 标题 |
| \`url\` | ✅ | 合法 URL,**用于去重** |
| \`type\` | ✅ | 只能是:\`微服务\` \`SaaS\` \`网站\` \`工具\` \`应用\` \`脚本\` \`插件\` |
| \`summary\` | ❌ | ≤ 200 字符 |
| \`description_md\` | ❌ | ≤ 5000 字符,支持 Markdown |
| \`license\` | ❌ | 如 \`MIT\`、\`GPL-3.0\` |
| \`stars\` | ❌ | 整数 |
| \`tags\` | ❌ | 字符串数组,新标签自动创建 |
| \`status\` | ❌ | \`pending\` / \`active\` |
**\`slug\` 不是合法字段**,传了会被静默丢弃(服务端自动生成)。
# 六、五个必记的坑
1. **推荐用 \`X-Ingest-Key\` 请求头**(等价于 \`Authorization: Bearer <key>\`)。不要用 \`X-API-Key\`、\`?key=\` 这类自创形式,会 403。
2. **MCP 免密钥 ≠ 密钥有效**。\`/mcp\` 根本不查 key,别拿它验证密钥。
3. **提交后默认 \`pending\`**。前台搜不到是正常的,要等管理员审核变 \`active\`。**不要因为搜不到就重复提交**。
4. **同 URL 重复提交返回 200 \`created:false\`**,这不是报错,说明已存在。
5. \`/api/entries\` 也能写(未登录时回退 AI key 校验),但推荐用 \`/api/ingest/entry\`。
# 七、典型工作流
\`\`\`
1. search_entries 查重 → 已有就 get_entry 看详情,别重复提交
2. WebSearch / WebFetch 收集项目信息
3. 组装 JSON(title + url + type 三个必填)
4. POST /api/ingest/entry(带 X-Ingest-Key)
5. 记下返回的 id,状态是 pending,等管理员审核
\`\`\`
# 八、排错速查
- \`406 Not Acceptable\`:curl 直连 \`/mcp\` 时缺 \`Accept: application/json, text/event-stream\` 头。正常 MCP 客户端会自动带上,可忽略。
- \`403 invalid_key\`:密钥不对或未启用,去管理台「内容源 → AI 接口」重新设置。
- \`400\` 且提示类型不对:检查 \`type\` 是不是上面七个枚举之一。
- 中文内容建议存成 \`.json\` 文件再 \`curl -d @file.json\`,避免 shell 编码踩坑。`;
}
mcpRouter.post("/mcp", async (req, res) => { mcpRouter.post("/mcp", async (req, res) => {
try { try {
const server = new McpServer({ const server = new McpServer(
name: "awesome-index", {
version: "0.1.0", name: "awesome-index",
}); version: "0.1.0",
registerTools(server); },
{ instructions: buildInstructions(requestBaseUrl(req)) }
);
registerTools(server, requestBaseUrl(req));
const transport = new StreamableHTTPServerTransport({ const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: undefined, sessionIdGenerator: undefined,
+118 -10
View File
@@ -6,15 +6,26 @@ function text(data) {
return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] }; return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
} }
export function registerTools(server) { const NOTE =
"【重要】本端点只读。要把新内容写进本库请用 HTTP:POST {BASE}/api/ingest/entry," +
"用 header X-Ingest-Key: <key>(等价 Authorization: Bearer <key>;不要用 X-API-Key 或 ?key=,会 403)。" +
"必填字段 title / url / type,type 只能是 微服务·SaaS·网站·工具·应用·脚本·插件。" +
"提交后状态为 pending,需管理员审核;同 URL 重复提交返回 created:false。";
export function registerTools(server, baseUrl) {
const base = baseUrl || process.env.BASE_URL || "http://localhost:3000";
server.registerTool( server.registerTool(
"search_entries", "search_entries",
{ {
description: description:
"按关键字、类型、标签搜索本库收录的开源软件、服务与网站(Awesome Index)", "按关键字搜索 Awesome Index 收录的开源软件 / 服务 / 网站。" +
"搜的是 title / slug / description / url 的模糊匹配,所以「自托管相册」「终端文件管理器」这类需求描述也能命中。" +
"【典型用途】写方案前先查重、找同类替代品。只返回 active(已上架)条目。" +
"【返回】{ total, items:[{slug,title,type,url,stars,license,tags,summary,description_md}] }。" +
"content[0].text 是 JSON 字符串,需再解析一次。" + NOTE,
inputSchema: { inputSchema: {
q: z.string().describe("需求关键字,如:自托管相册 / 终端文件管理器"), q: z.string().describe("需求关键字或项目名,如:自托管相册 / ripgrep / 终端文件管理器"),
limit: z.number().int().min(1).max(50).optional().describe("返回条数,默认 20"), limit: z.number().int().min(1).max(50).optional().describe("返回条数,1–50,默认 20"),
}, },
}, },
async ({ q, limit }) => { async ({ q, limit }) => {
@@ -32,6 +43,7 @@ export function registerTools(server) {
stars: Number(e.stars), stars: Number(e.stars),
license: e.license, license: e.license,
tags: e.tags, tags: e.tags,
summary: e.summary,
description: e.description_md, description: e.description_md,
})), })),
}); });
@@ -41,35 +53,69 @@ export function registerTools(server) {
server.registerTool( server.registerTool(
"get_entry", "get_entry",
{ {
description: "获取单条收录内容的完整详情与元数据", description:
"按 slug 取单条收录内容的完整详情与元数据。" +
"slug 就是详情页 URL /entries/<slug> 的最后一段(多半是项目名小写加连字符)。" +
"不确定 slug 时可以直接传标题关键字,服务端会做模糊匹配并取第一条。" +
"【典型用途】用户给了具体项目名,要看它的介绍、链接、标签、star。" +
"【返回】条目对象;找不到时返回 { error: 'not_found' }。" + NOTE,
inputSchema: { inputSchema: {
slug: z.string().describe("条目 slug,也可传标题关键字自动匹配"), slug: z.string().describe("条目 slug(如 ripgrep);也可传标题关键字自动匹配"),
}, },
}, },
async ({ slug }) => { async ({ slug }) => {
const direct = await searchService.searchEntries({ q: slug, perPage: 1 }); const direct = await searchService.searchEntries({ q: slug, perPage: 1 });
if (!direct.items.length) return text({ error: "not_found", query: slug }); if (!direct.items.length) return text({ error: "not_found", query: slug });
return text(direct.items[0]); const entry = direct.items[0];
return text({
slug: entry.slug,
title: entry.title,
type: entry.type,
url: entry.url,
stars: Number(entry.stars),
license: entry.license,
tags: entry.tags,
summary: entry.summary,
description: entry.description_md,
status: entry.status,
updated_at: entry.updated_at,
});
} }
); );
server.registerTool( server.registerTool(
"random_entry", "random_entry",
{ {
description: "随机返回一条收录内容,用于探索发现", description:
"随机返回一条已上架(active)的收录内容,用于探索发现、给用户「随便看看」的推荐。" +
"【典型用途】用户没有明确需求,想逛一逛这个库。无参数。" +
"【返回】条目对象;库为空时返回 { error: 'empty' }。" + NOTE,
inputSchema: {}, inputSchema: {},
}, },
async () => { async () => {
const entry = await searchService.randomActive(); const entry = await searchService.randomActive();
if (!entry) return text({ error: "empty" }); if (!entry) return text({ error: "empty" });
return text(entry); return text({
slug: entry.slug,
title: entry.title,
type: entry.type,
url: entry.url,
stars: Number(entry.stars),
license: entry.license,
summary: entry.summary,
description: entry.description_md,
});
} }
); );
server.registerTool( server.registerTool(
"list_tags", "list_tags",
{ {
description: "列出全部标签组及其嵌套结构与收录数量", description:
"列出全部标签组及其嵌套结构与收录数量(count 为 active 条目数)。" +
"【典型用途】先摸清这个库有哪些分类维度(编程语言 / 形态 / 用途),再用 search_entries 深挖。" +
"标签可无限嵌套,父标签过滤时会自动包含后代标签的条目。" +
"【返回】标签树数组,节点形如 { id, name, count, children:[...] }。" + NOTE,
inputSchema: {}, inputSchema: {},
}, },
async () => { async () => {
@@ -77,4 +123,66 @@ export function registerTools(server) {
return text(tree); return text(tree);
} }
); );
server.registerTool(
"usage",
{
description:
"返回本服务的完整接入说明:MCP 端点性质、四个只读工具的用法、写入用的 REST 接口、字段规则、" +
"常见坑(X-Ingest-Key 认证头 / pending 状态 / URL 去重)与 curl 示例。" +
"【何时调用】第一次接入、不知道怎么写入、或者被 403 卡住时,先调这个工具。" +
"它返回的是结构化 JSON,和 initialize 时的 instructions 内容一致,但更适合程序读取。",
inputSchema: {},
},
async () => {
return text({
mcp: {
endpoint: `${base}/mcp`,
transport: "Streamable HTTP (JSON-RPC 2.0)",
auth: "none —— 只读,不校验密钥,连接时不要配 token",
acceptHeader: "application/json, text/event-stream",
note: "结果在 result.content[0].text 里,是 JSON 字符串,需再解析一次",
},
oneLiner: "读用 MCP(免密钥),写用 REST(要 X-Ingest-Key)",
clientConfig: { mcpServers: { "awesome-index": { url: `${base}/mcp` } } },
readOnlyTools: [
{ name: "search_entries", params: { q: "必填 关键字", limit: "1–50,默认 20" }, use: "按关键字/类型/标签搜索,查重先走它" },
{ name: "get_entry", params: { slug: "必填,也可传标题关键字" }, use: "取单条详情" },
{ name: "random_entry", params: {}, use: "随机抽一条,用于发现" },
{ name: "list_tags", params: {}, use: "列全部标签组与数量" },
],
writeApi: {
endpoint: `POST ${base}/api/ingest/entry`,
headers: { "X-Ingest-Key": "<key>", "Content-Type": "application/json" },
fields: {
title: "必填 标题",
url: "必填 合法 URL,用于去重",
type: "必填 枚举:微服务 / SaaS / 网站 / 工具 / 应用 / 脚本 / 插件",
summary: "可选 ≤200 字符",
description_md: "可选 ≤5000 字符,支持 Markdown",
license: "可选 如 MIT",
stars: "可选 整数",
tags: "可选 字符串数组,新标签自动创建",
status: "可选 pending / active",
},
slugIsNotAField: true,
example: `curl -X POST "${base}/api/ingest/entry" -H "X-Ingest-Key: <key>" -H "Content-Type: application/json" -d '{"title":"MinerU","url":"https://github.com/opendatalab/MinerU","type":"工具"}'`,
},
pitfalls: [
"推荐用 X-Ingest-Key 请求头(等价 Authorization: Bearer <key>);X-API-Key / ?key= 这类自创形式一律 403",
"MCP 免密钥 ≠ 密钥有效:/mcp 根本不查 key,别拿它验证密钥",
"提交后默认 pending,前台搜不到是正常的,等管理员审核变 active —— 别重复提交",
"同 URL 重复提交返回 200 created:false,这不是报错,说明已存在",
"curl 直连 /mcp 不带 Accept: application/json, text/event-stream 会 406;正常客户端不受影响",
],
workflow: [
"search_entries 查重 → 已有就 get_entry 看详情",
"WebSearch / WebFetch 收集项目信息",
"组装 JSON(title + url + type 三个必填)",
`POST ${base}/api/ingest/entry(带 X-Ingest-Key)`,
"记下返回 id,状态 pending",
],
});
}
);
} }
+17 -2
View File
@@ -25,11 +25,26 @@ export function loadUser(req, res, next) {
next(); next();
} }
export function ingestKeyFromRequest(req) {
const direct = (req.get("x-ingest-key") || "").trim();
if (direct) return direct;
const auth = (req.get("authorization") || "").trim();
if (!auth) return "";
const bearer = /^Bearer\s+(.+)$/i.exec(auth);
return bearer ? bearer[1].trim() : auth;
}
export async function aiSourceFromRequest(req) { export async function aiSourceFromRequest(req) {
const key = req.get("x-ingest-key") || ""; const key = ingestKeyFromRequest(req);
const source = await sourceService.findByApiKey(key); const source = await sourceService.findByApiKey(key);
if (!source) { if (!source) {
throw new HttpError(403, "invalid_key", "AI 录入密钥无效或未启用"); throw new HttpError(
403,
"invalid_key",
"AI 录入密钥无效或未启用(请使用 X-Ingest-Key 或 Authorization: Bearer <key> 请求头)"
);
} }
return source; return source;
} }
+76
View File
@@ -2,6 +2,9 @@ import { Router } from "express";
import * as userService from "../services/user.service.js"; import * as userService from "../services/user.service.js";
import * as sourceService from "../services/source.service.js"; import * as sourceService from "../services/source.service.js";
import * as commentService from "../services/comment.service.js"; import * as commentService from "../services/comment.service.js";
import * as contactService from "../services/contact.service.js";
import * as relationService from "../services/relation.service.js";
import * as entryService from "../services/entry.service.js";
import * as searchService from "../services/search.service.js"; import * as searchService from "../services/search.service.js";
import * as audit from "../services/audit.service.js"; import * as audit from "../services/audit.service.js";
import { requireAdmin } from "../middleware/auth.js"; import { requireAdmin } from "../middleware/auth.js";
@@ -23,6 +26,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 } });
}); });
@@ -116,6 +124,34 @@ adminRouter.get("/api/admin/comments", requireAdmin, async (req, res) => {
res.json({ ok: true, data: await commentService.listAll({ q: req.query.q ?? "" }) }); res.json({ ok: true, data: await commentService.listAll({ q: req.query.q ?? "" }) });
}); });
adminRouter.get("/api/admin/contacts", requireAdmin, async (req, res) => {
res.json({ ok: true, data: await contactService.listContacts({ q: req.query.q ?? "" }) });
});
adminRouter.patch("/api/admin/contacts/:id/status", requireAdmin, async (req, res) => {
const row = await contactService.setContactStatus(
Number(req.params.id),
req.body.enabled !== false
);
await audit.log({
actor: actor(req),
action: "contact.status",
target: row.contact,
detail: row.enabled ? "启用" : "禁用",
});
res.json({ ok: true, data: row });
});
adminRouter.patch("/api/admin/contacts/:id", requireAdmin, async (req, res) => {
const row = await contactService.renameContact(Number(req.params.id), req.body.nickname);
res.json({ ok: true, data: row });
});
adminRouter.delete("/api/admin/contacts/:id", requireAdmin, async (req, res) => {
await contactService.removeContact(Number(req.params.id));
res.json({ ok: true });
});
adminRouter.get("/api/admin/entries", requireAdmin, async (req, res) => { adminRouter.get("/api/admin/entries", requireAdmin, async (req, res) => {
const result = await searchService.searchEntries({ const result = await searchService.searchEntries({
q: req.query.q ?? "", q: req.query.q ?? "",
@@ -129,3 +165,43 @@ adminRouter.get("/api/admin/entries", requireAdmin, async (req, res) => {
}); });
res.json({ ok: true, data: result }); res.json({ ok: true, data: result });
}); });
adminRouter.get("/api/admin/relations", requireAdmin, async (req, res) => {
res.json({
ok: true,
data: await relationService.listCandidates({
q: req.query.q ?? "",
excludeId: Number(req.query.excludeId) || null,
}),
});
});
adminRouter.post("/api/admin/entries/:id/relations", requireAdmin, async (req, res) => {
const id = Number(req.params.id);
const result = await relationService.addRelation(id, req.body.relatedId);
await audit.log({
actor: actor(req),
action: "entry.relation.add",
target: `#${id}`,
detail: `←→ #${req.body.relatedId}`,
});
res.status(result.created ? 201 : 200).json({ ok: true, data: result });
});
adminRouter.delete("/api/admin/entries/:id/relations/:relatedId", requireAdmin, async (req, res) => {
await relationService.removeRelation(Number(req.params.id), Number(req.params.relatedId));
res.json({ ok: true });
});
adminRouter.post("/api/admin/entries/status", requireAdmin, async (req, res) => {
const ids = Array.isArray(req.body.ids) ? req.body.ids : [];
const status = String(req.body.status || "");
const updated = await entryService.setStatusBulk(ids, status);
await audit.log({
actor: actor(req),
action: "entries.status",
target: "",
detail: `${status} × ${updated}`,
});
res.json({ ok: true, data: { updated } });
});
+3 -1
View File
@@ -1,5 +1,6 @@
import { Router } from "express"; import { Router } from "express";
import * as commentService from "../services/comment.service.js"; import * as commentService from "../services/comment.service.js";
import * as contactService from "../services/contact.service.js";
import * as audit from "../services/audit.service.js"; import * as audit from "../services/audit.service.js";
import { requireAdmin } from "../middleware/auth.js"; import { requireAdmin } from "../middleware/auth.js";
import { HttpError } from "../middleware/errors.js"; import { HttpError } from "../middleware/errors.js";
@@ -21,7 +22,8 @@ commentsRouter.post("/api/comments", async (req, res) => {
await commentService.create({ await commentService.create({
entryId, entryId,
userId: req.session.userId ?? null, userId: req.session.userId ?? null,
authorName: req.session.username ?? req.body.authorName, authorName: req.body.authorName,
contact: req.body.contact,
body: req.body.body, body: req.body.body,
}); });
res.status(201).json({ ok: true }); res.status(201).json({ ok: true });
+2
View File
@@ -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(200).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(200).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(),
+65 -7
View File
@@ -3,6 +3,8 @@ import * as searchService from "../services/search.service.js";
import * as entryService from "../services/entry.service.js"; import * as entryService from "../services/entry.service.js";
import * as tagService from "../services/tag.service.js"; import * as tagService from "../services/tag.service.js";
import * as commentService from "../services/comment.service.js"; import * as commentService from "../services/comment.service.js";
import * as contactService from "../services/contact.service.js";
import * as relationService from "../services/relation.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";
@@ -21,10 +23,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);
@@ -64,14 +75,19 @@ pagesRouter.get("/entries", async (req, res, next) => {
pagesRouter.get("/entries/:slug", async (req, res, next) => { pagesRouter.get("/entries/:slug", async (req, res, next) => {
try { try {
const entry = await entryService.getBySlug(req.params.slug); const entry = await entryService.getBySlug(req.params.slug);
if (!entry || entry.status === "greyed") { const preview = req.query.preview === "1" && Boolean(req.session.userId);
if (!entry || (entry.status === "greyed" && !preview)) {
return res.status(404).send("条目不存在或已下架"); return res.status(404).send("条目不存在或已下架");
} }
const comments = await commentService.listForEntry(entry.id); const [comments, related] = await Promise.all([
commentService.listForEntry(entry.id),
relationService.listRelatedPublic(entry.id),
]);
const machine = { const machine = {
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,
@@ -79,11 +95,13 @@ pagesRouter.get("/entries/:slug", async (req, res, next) => {
stars: Number(entry.stars), stars: Number(entry.stars),
updated_at: entry.updated_at, updated_at: entry.updated_at,
status: entry.status, status: entry.status,
related: related.map((r) => ({ id: r.slug, title: r.title, type: r.type })),
}; };
res.render("detail", { res.render("detail", {
title: `${entry.title} · Awesome Index`, title: `${entry.title} · Awesome Index`,
entry, entry,
comments, comments,
related,
machineJson: JSON.stringify(machine, null, 2), machineJson: JSON.stringify(machine, null, 2),
}); });
} catch (e) { } catch (e) {
@@ -109,12 +127,38 @@ pagesRouter.get("/admin", async (req, res, next) => {
try { try {
if (!req.session.userId) return res.redirect("/login"); if (!req.session.userId) return res.redirect("/login");
const isAdmin = req.session.role === "admin"; const isAdmin = req.session.role === "admin";
const [sources, users, comments, entries, tagTreeAdmin] = await Promise.all([ const status = ["active", "greyed", "pending", "all"].includes(req.query.status)
? req.query.status
: "all";
const page = Math.max(1, Number(req.query.page) || 1);
const perPage = 20;
const q = String(req.query.q ?? "").trim();
const activeSection = [
"sec-content",
"sec-tags",
"sec-comments",
"sec-contacts",
"sec-sources",
"sec-accounts",
].includes(req.query.section)
? req.query.section
: "sec-content";
const [
sources,
users,
comments,
contacts,
entries,
tagTreeAdmin,
statusCounts,
] = await Promise.all([
sourceService.listSources(), sourceService.listSources(),
isAdmin ? userService.listUsers() : Promise.resolve([]), isAdmin ? userService.listUsers() : Promise.resolve([]),
commentService.listAll({}), commentService.listAll({}),
searchService.searchEntries({ status: "all", perPage: 50 }), contactService.listContacts({}),
searchService.searchEntries({ status, perPage, page, q }),
tagService.attachCounts(await tagService.buildTree()), tagService.attachCounts(await tagService.buildTree()),
searchService.statusCounts(),
]); ]);
res.render("admin", { res.render("admin", {
title: "管理台 · Awesome Index", title: "管理台 · Awesome Index",
@@ -122,7 +166,14 @@ pagesRouter.get("/admin", async (req, res, next) => {
sources, sources,
users, users,
comments, comments,
contacts,
entries: entries.items, entries: entries.items,
status,
q,
activeSection,
statusCounts,
page,
totalPages: Math.max(1, Math.ceil(entries.total / entries.perPage)),
isAdmin, isAdmin,
tagTreeAdmin, tagTreeAdmin,
}); });
@@ -136,12 +187,18 @@ pagesRouter.get("/admin/entries/:id/edit", async (req, res, next) => {
if (!req.session.userId) return res.redirect("/login"); if (!req.session.userId) return res.redirect("/login");
const entry = await entryService.getById(Number(req.params.id)); const entry = await entryService.getById(Number(req.params.id));
if (!entry) throw Object.assign(new Error("条目不存在"), { status: 404 }); if (!entry) throw Object.assign(new Error("条目不存在"), { status: 404 });
const tagTreeAdmin = await tagService.buildTree(); const [tagTreeAdmin, related, candidates] = await Promise.all([
tagService.buildTree(),
relationService.listRelated(entry.id),
relationService.listCandidates({ excludeId: entry.id, limit: 200 }),
]);
res.render("admin-entry-edit", { res.render("admin-entry-edit", {
title: `编辑 · ${entry.title} · Awesome Index`, title: `编辑 · ${entry.title} · Awesome Index`,
user: { username: req.session.username, role: req.session.role }, user: { username: req.session.username, role: req.session.role },
isAdmin: req.session.role === "admin", isAdmin: req.session.role === "admin",
entry, entry,
related,
candidates,
tagTreeAdmin, tagTreeAdmin,
types: ENTRY_TYPES, types: ENTRY_TYPES,
}); });
@@ -162,6 +219,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(),
+17 -8
View File
@@ -1,4 +1,5 @@
import { all, get, run } from "../db/connection.js"; import { all, get, run } from "../db/connection.js";
import * as contacts from "./contact.service.js";
export async function listForEntry(entryId) { export async function listForEntry(entryId) {
return all( return all(
@@ -10,32 +11,40 @@ export async function listForEntry(entryId) {
); );
} }
export async function create({ entryId, userId = null, authorName, body }) { export async function create({ entryId, userId = null, authorName, contact, body }) {
const text = String(body || "").trim(); const text = String(body || "").trim();
if (!text) throw Object.assign(new Error("评论内容不能为空"), { status: 400 }); if (!text) throw Object.assign(new Error("评论内容不能为空"), { status: 400 });
if (text.length > 2000) throw Object.assign(new Error("评论太长了(≤2000 字)"), { status: 400 }); if (text.length > 2000) throw Object.assign(new Error("评论太长了(≤2000 字)"), { status: 400 });
const entry = await get(`SELECT id FROM entries WHERE id = ?`, [entryId]); const entry = await get(`SELECT id FROM entries WHERE id = ?`, [entryId]);
if (!entry) throw Object.assign(new Error("条目不存在"), { status: 404 }); if (!entry) throw Object.assign(new Error("条目不存在"), { status: 404 });
const name =
String(authorName || "").trim() || "匿名"; // 昵称 + 联系方式复合校验:首次评论建档,之后必须匹配且未被禁用
const person = await contacts.resolveForComment({ nickname: authorName, contact });
await run( await run(
`INSERT INTO comments (entry_id, user_id, author_name, body) VALUES (?, ?, ?, ?)`, `INSERT INTO comments (entry_id, user_id, contact_id, author_name, contact, body)
[entryId, userId, name.slice(0, 40), text] VALUES (?, ?, ?, ?, ?, ?)`,
[entryId, userId, person.id, person.nickname, person.contact, text]
); );
await contacts.bumpCommentCount(person.id);
return person;
} }
export async function remove(id) { export async function remove(id) {
const row = await get(`SELECT contact_id FROM comments WHERE id = ?`, [id]);
await run(`DELETE FROM comments WHERE id = ?`, [id]); await run(`DELETE FROM comments WHERE id = ?`, [id]);
if (row?.contact_id) await contacts.bumpCommentCount(row.contact_id);
} }
export async function listAll({ q = "" } = {}) { export async function listAll({ q = "" } = {}) {
const like = `%${q}%`; const like = `%${String(q ?? "").trim()}%`;
const kw = String(q ?? "").trim();
return all( return all(
`SELECT c.*, e.title AS entry_title, e.slug AS entry_slug `SELECT c.*, e.title AS entry_title, e.slug AS entry_slug
FROM comments c JOIN entries e ON e.id = c.entry_id FROM comments c JOIN entries e ON e.id = c.entry_id
LEFT JOIN users u ON u.id = c.user_id LEFT JOIN users u ON u.id = c.user_id
WHERE (? = '' OR c.body LIKE ? OR c.author_name LIKE ?) WHERE (? = '' OR c.body LIKE ? OR c.author_name LIKE ? OR c.contact LIKE ?)
ORDER BY c.created_at DESC LIMIT 200`, ORDER BY c.created_at DESC LIMIT 200`,
[q, like, like] [kw, like, like, like]
); );
} }
+106
View File
@@ -0,0 +1,106 @@
import { all, get, run } from "../db/connection.js";
/** 邮箱 / 手机号(中国大陆)格式校验 */
export const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[A-Za-z]{2,}$/;
export const PHONE_RE = /^1[3-9]\d{9}$/;
export function normalizeContact(raw) {
return String(raw ?? "").trim();
}
export function isValidContact(raw) {
const v = normalizeContact(raw);
return EMAIL_RE.test(v) || PHONE_RE.test(v);
}
export function contactKind(raw) {
const v = normalizeContact(raw);
if (EMAIL_RE.test(v)) return "email";
if (PHONE_RE.test(v)) return "phone";
return "invalid";
}
/** 昵称是否属于该联系方式的「复合格式」:联系方式 + 任意昵称(首次评论) */
export function isValidNickname(nickname) {
return String(nickname ?? "").trim().length > 0;
}
export async function getByContact(contact) {
return get(`SELECT * FROM contacts WHERE contact = ?`, [normalizeContact(contact)]);
}
export async function listContacts({ q = "" } = {}) {
const like = `%${String(q).trim()}%`;
return all(
`SELECT ct.*,
(SELECT COUNT(*) FROM comments c WHERE c.contact_id = ct.id) AS comments
FROM contacts ct
WHERE (? = '' OR ct.contact LIKE ? OR ct.nickname LIKE ?)
ORDER BY ct.updated_at DESC, ct.id DESC
LIMIT 500`,
[String(q).trim(), like, like]
);
}
/**
* 首次评论:以「昵称 + 联系方式」复合身份建档。
* 之后评论:昵称必须与该联系方式登记的一致,且未被管理员禁用。
*/
export async function resolveForComment({ nickname, contact }) {
const nick = String(nickname ?? "").trim().slice(0, 40);
const key = normalizeContact(contact);
if (!nick) throw Object.assign(new Error("请填写昵称"), { status: 400 });
if (!isValidContact(key)) {
throw Object.assign(new Error("联系方式格式不正确(需要手机号或邮箱)"), { status: 400 });
}
const existing = await getByContact(key);
if (existing) {
if (!existing.enabled) {
throw Object.assign(new Error("该联系方式已被管理员禁用,无法评论"), { status: 403 });
}
if (String(existing.nickname) !== nick) {
throw Object.assign(
new Error("昵称与该联系方式登记的不一致,请使用该联系方式首次评论时的昵称"),
{ status: 400 }
);
}
await run(`UPDATE contacts SET updated_at = now() WHERE id = ?`, [existing.id]);
return existing;
}
await run(`INSERT INTO contacts (contact, nickname) VALUES (?, ?)`, [key, nick]);
return getByContact(key);
}
export async function bumpCommentCount(contactId) {
await run(
`UPDATE contacts
SET comment_count = (SELECT COUNT(*) FROM comments c WHERE c.contact_id = contacts.id),
updated_at = now()
WHERE id = ?`,
[contactId]
);
}
export async function setContactStatus(id, enabled) {
const row = await get(`SELECT * FROM contacts WHERE id = ?`, [id]);
if (!row) throw Object.assign(new Error("评论人员不存在"), { status: 404 });
await run(`UPDATE contacts SET enabled = ?, updated_at = now() WHERE id = ?`, [
Boolean(enabled),
id,
]);
return get(`SELECT * FROM contacts WHERE id = ?`, [id]);
}
export async function renameContact(id, nickname) {
const nick = String(nickname ?? "").trim().slice(0, 40);
if (!nick) throw Object.assign(new Error("昵称不能为空"), { status: 400 });
const row = await get(`SELECT * FROM contacts WHERE id = ?`, [id]);
if (!row) throw Object.assign(new Error("评论人员不存在"), { status: 404 });
await run(`UPDATE contacts SET nickname = ?, updated_at = now() WHERE id = ?`, [nick, id]);
return get(`SELECT * FROM contacts WHERE id = ?`, [id]);
}
export async function removeContact(id) {
await run(`UPDATE comments SET contact_id = NULL WHERE contact_id = ?`, [id]);
await run(`DELETE FROM contacts WHERE id = ?`, [id]);
}
+26 -6
View File
@@ -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) {
@@ -157,6 +158,25 @@ export async function setStatusDirect(id, status) {
return getById(id); return getById(id);
} }
export async function setStatusBulk(ids, status) {
if (!STATUSES.includes(status)) throw httpError(400, `未知状态:${status}`);
const clean = [...new Set(ids.map(Number).filter(Number.isFinite))];
if (!clean.length) throw httpError(400, "没有选中条目");
const existing = await all(
`SELECT id FROM entries WHERE id IN (${clean.map(() => "?").join(",")})`,
clean
);
const realIds = existing.map((r) => Number(r.id));
if (!realIds.length) throw httpError(400, "没有选中条目");
await run(
`UPDATE entries SET status = ?, updated_at = now(),
verified_at = CASE WHEN ? = 'active' THEN now() ELSE verified_at END
WHERE id IN (${realIds.map(() => "?").join(",")})`,
[status, status, ...realIds]
);
return realIds.length;
}
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]);
@@ -185,7 +205,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]
+79
View File
@@ -0,0 +1,79 @@
import { all, run } from "../db/connection.js";
import * as entries from "./entry.service.js";
/**
* 相关项目是双向关系:A 关联 B 后,B 的详情页也能看到 A。
* 存储时只保留一条无序对(min,max),读取时展开成双向。
*/
function pair(a, b) {
const x = Number(a);
const y = Number(b);
return x < y ? [x, y] : [y, x];
}
export async function addRelation(entryId, relatedId) {
const a = Number(entryId);
const b = Number(relatedId);
if (!Number.isFinite(a) || !Number.isFinite(b)) {
throw Object.assign(new Error("条目 ID 无效"), { status: 400 });
}
if (a === b) throw Object.assign(new Error("不能把条目关联到自己"), { status: 400 });
const [one, two] = pair(a, b);
for (const id of [one, two]) {
if (!(await entries.getById(id))) {
throw Object.assign(new Error("条目不存在"), { status: 404 });
}
}
const exists = await all(
`SELECT id FROM entry_relations WHERE entry_id = ? AND related_id = ?`,
[one, two]
);
if (exists.length) return { created: false };
await run(`INSERT INTO entry_relations (entry_id, related_id) VALUES (?, ?)`, [one, two]);
return { created: true };
}
export async function removeRelation(entryId, relatedId) {
const [one, two] = pair(entryId, relatedId);
await run(`DELETE FROM entry_relations WHERE entry_id = ? AND related_id = ?`, [one, two]);
}
export async function listRelated(entryId) {
const id = Number(entryId);
const rows = await all(
`SELECT e.id, e.title, e.slug, e.type, e.summary, e.description_md, e.url, e.stars, e.status
FROM entry_relations r
JOIN entries e ON e.id = CASE WHEN r.entry_id = ? THEN r.related_id ELSE r.entry_id END
WHERE r.entry_id = ? OR r.related_id = ?
ORDER BY r.id DESC`,
[id, id, id]
);
return rows;
}
/** 前台只展示在架的相关条目 */
export async function listRelatedPublic(entryId) {
return (await listRelated(entryId)).filter((e) => e.status === "active");
}
/** 管理台编辑页用:候选条目列表(排除自身) */
export async function listCandidates({ q = "", limit = 50, excludeId = null } = {}) {
const kw = String(q ?? "").trim();
const like = `%${kw}%`;
const params = [];
let where = "WHERE e.status <> 'greyed'";
if (kw) {
where += ` AND (e.title LIKE ? OR e.url LIKE ? OR e.summary LIKE ?)`;
params.push(like, like, like);
}
if (Number.isFinite(Number(excludeId))) {
where += ` AND e.id <> ?`;
params.push(Number(excludeId));
}
return all(
`SELECT e.id, e.title, e.slug, e.type, e.status
FROM entries e ${where}
ORDER BY e.updated_at DESC LIMIT ?`,
[...params, Math.min(200, Number(limit) || 50)]
);
}
+13 -3
View File
@@ -26,8 +26,8 @@ export async function searchEntries({
} }
if (q.trim()) { if (q.trim()) {
const like = `%${q.trim()}%`; const like = `%${q.trim()}%`;
where.push(`(e.title LIKE ? OR e.description_md LIKE ? OR e.url LIKE ?)`); where.push(`(e.title LIKE ? OR e.slug LIKE ? OR e.description_md LIKE ? OR e.url LIKE ?)`);
params.push(like, like, like); params.push(like, like, like, like);
} }
if (types.length) { if (types.length) {
where.push(`e.type IN (${types.map(() => "?").join(",")})`); where.push(`e.type IN (${types.map(() => "?").join(",")})`);
@@ -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]
); );
@@ -90,6 +90,16 @@ export async function typeCounts() {
return map; return map;
} }
export async function statusCounts() {
const rows = await all(`SELECT status, COUNT(*) AS c FROM entries GROUP BY status`);
const map = { all: 0, active: 0, pending: 0, greyed: 0 };
for (const r of rows) {
map[r.status] = Number(r.c);
map.all += Number(r.c);
}
return map;
}
export async function randomActive() { export async function randomActive() {
return get(`SELECT * FROM entries WHERE status = 'active' ORDER BY random() LIMIT 1`); return get(`SELECT * FROM entries WHERE status = 'active' ORDER BY random() LIMIT 1`);
} }
+46 -5
View File
@@ -11,14 +11,13 @@
<p class="anno">// edit · entry #<%= entry.id %> · 保存后即时生效</p> <p class="anno">// edit · entry #<%= entry.id %> · 保存后即时生效</p>
<h1>编辑条目</h1> <h1>编辑条目</h1>
<p class="admin-desc"><a href="/entries/<%= entry.slug %>" target="_blank">预览:<%= entry.title %></a></p> <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"> <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="form-row"> <div class="form-row">
<div class="field" style="flex:2"><label for="f-title">标题</label> <div class="field" style="flex:2;margin-top:0"><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>
<div class="field" style="flex:1"><label for="f-type">类型</label> <div class="field" style="flex:1;margin-top:0"><label for="f-type">类型</label>
<select class="select" id="f-type" name="type" style="width:100%"> <select class="select" id="f-type" name="type" style="width:100%">
<% for (const t of types) { %> <% for (const t of types) { %>
<option value="<%= t %>" <%= entry.type === t ? 'selected' : '' %>><%= t %></option> <option value="<%= t %>" <%= entry.type === t ? 'selected' : '' %>><%= t %></option>
@@ -43,9 +42,14 @@
</div> </div>
</div> </div>
<div class="panel-box">
<h3>简介(≤200 字,列表与详情页头部展示)</h3>
<textarea class="textarea" id="f-summary" name="summary" maxlength="200" rows="3" style="line-height:1.7;resize:vertical"><%= entry.summary || '' %></textarea>
</div>
<div class="panel-box"> <div class="panel-box">
<h3>内容描述(Markdown)</h3> <h3>内容描述(Markdown)</h3>
<textarea class="textarea" id="f-desc" name="description_md" rows="16"><%= entry.description_md || '' %></textarea> <textarea class="textarea" id="f-desc" name="description_md" rows="16" style="line-height:1.7"><%= entry.description_md || '' %></textarea>
<p style="font-size:12px;color:var(--ink-dim);margin-top:6px;font-family:var(--font-mono)">// 前台展示原文;保存后详情页与搜索都会生效</p> <p style="font-size:12px;color:var(--ink-dim);margin-top:6px;font-family:var(--font-mono)">// 前台展示原文;保存后详情页与搜索都会生效</p>
</div> </div>
@@ -62,15 +66,52 @@
</div> </div>
</div> </div>
<div class="panel-box">
<h3>相关项目</h3>
<p style="font-size:13px;color:var(--ink-dim);margin-bottom:10px">
关联是双向的:A 关联 B 后,B 的详情页也会出现 A。前台只展示已上架(active)的相关条目。
</p>
<ul class="related-list" id="related-list" style="margin-bottom:12px">
<% for (const r of related) { %>
<li data-id="<%= r.id %>">
<a href="/entries/<%= r.slug %>" target="_blank">
<span class="chip chip-type"><%= r.type %></span>
<b><%= r.title %></b>
</a>
<button class="btn btn-sm btn-danger" type="button"
data-action="relation-remove" data-related-id="<%= r.id %>">移除</button>
</li>
<% } %>
<% if (!related.length) { %>
<li class="mono" style="color:var(--ink-dim);border:0;padding:0">还没有相关项目</li>
<% } %>
</ul>
<div style="display:flex;gap:8px;align-items:flex-end;flex-wrap:wrap">
<div class="field" style="margin-top:0;min-width:280px;flex:2">
<label for="f-related">添加相关项目</label>
<select class="select" id="f-related" style="width:100%">
<% for (const c of candidates) { %>
<option value="<%= c.id %>"><%= c.title %> · <%= c.type %><% if (c.status !== 'active') { %>(<%= c.status %>)<% } %></option>
<% } %>
</select>
</div>
<button class="btn btn-primary btn-sm" type="button" data-action="relation-add">添加关联</button>
</div>
<p class="mono" style="font-size:12px;color:var(--ink-dim);margin-top:8px">// 列表里没有?先在内容管理里创建条目,再回到这里关联。</p>
</div>
<div style="display:flex;gap:10px;align-items:center"> <div style="display:flex;gap:10px;align-items:center">
<button class="btn btn-primary" type="submit">保存修改</button> <button class="btn btn-primary" type="submit">保存修改</button>
<a class="btn" href="/admin#sec-content">取消</a> <a class="btn" href="/admin?section=sec-content">取消</a>
</div> </div>
</form> </form>
</main> </main>
</div> </div>
<script> <script>
window.__ADMIN_ENTRY_ID__ = <%= entry.id %>;
(function () { (function () {
var form = document.getElementById("entry-edit-form"); var form = document.getElementById("entry-edit-form");
if (!form) return; if (!form) return;
+121 -26
View File
@@ -4,26 +4,68 @@
<div class="admin-shell"> <div class="admin-shell">
<nav class="admin-nav" aria-label="管理分区"> <nav class="admin-nav" aria-label="管理分区">
<h4>管理台 · <%= user.username %></h4> <h4>管理台 · <%= user.username %></h4>
<button type="button" data-section="sec-content" aria-current="true">内容管理</button> <% const navHref = (s) => '/admin?' + qs({ section: s, status: s === 'sec-content' ? status : '', q: s === 'sec-content' ? q : '' }); %>
<button type="button" data-section="sec-tags" aria-current="false">标签管理</button> <a href="<%= navHref('sec-content') %>" data-section="sec-content" aria-current="<%= activeSection === 'sec-content' ? 'true' : 'false' %>">内容管理</a>
<button type="button" data-section="sec-comments" aria-current="false">评论管理</button> <a href="<%= navHref('sec-tags') %>" data-section="sec-tags" aria-current="<%= activeSection === 'sec-tags' ? 'true' : 'false' %>">标签管理</a>
<button type="button" data-section="sec-sources" aria-current="false">内容源</button> <a href="<%= navHref('sec-comments') %>" data-section="sec-comments" aria-current="<%= activeSection === 'sec-comments' ? 'true' : 'false' %>">评论管理</a>
<a href="<%= navHref('sec-contacts') %>" data-section="sec-contacts" aria-current="<%= activeSection === 'sec-contacts' ? 'true' : 'false' %>">评论人员</a>
<a href="<%= navHref('sec-sources') %>" data-section="sec-sources" aria-current="<%= activeSection === 'sec-sources' ? 'true' : 'false' %>">内容源</a>
<% if (isAdmin) { %> <% if (isAdmin) { %>
<button type="button" data-section="sec-accounts" aria-current="false">账号管理</button> <a href="<%= navHref('sec-accounts') %>" data-section="sec-accounts" aria-current="<%= activeSection === 'sec-accounts' ? 'true' : 'false' %>">账号管理</a>
<% } %> <% } %>
<a class="admin-nav-refresh" href="/admin?<%= qs({ section: activeSection, status, q }) %>" title="重新从后台加载当前分区数据">↻ 刷新当前分区</a>
</nav> </nav>
<main class="admin-main"> <main class="admin-main">
<!-- 内容管理 --> <!-- 内容管理 -->
<section id="sec-content" class="active"> <section id="sec-content" class="<%= activeSection === 'sec-content' ? 'active' : '' %>">
<p class="anno">// table: entries · 状态 active | greyed | pending</p> <p class="anno">// table: entries · 状态 active | greyed | pending · 预览 = 按当前状态模拟前台展示</p>
<h1>内容管理</h1> <h1>内容管理</h1>
<p class="admin-desc">全部收录条目。灰掉 = 暂不可见但保留数据;删除为物理删除,需确认。</p> <p class="admin-desc">全部收录条目。灰掉 = 暂不可见但保留数据;删除为物理删除,需确认。</p>
<table class="data-table" style="margin-top:22px">
<thead><tr><th>标题</th><th>类型</th><th>状态</th><th>更新时间</th><th style="text-align:right">操作</th></tr></thead> <div class="status-chips">
<tbody> <% const chipHref = (s) => '/admin?' + qs({ status: s, q }); %>
<% for (const e of entries) { %> <a class="chip <%= status === 'all' ? 'is-active' : '' %>" href="<%= chipHref('all') %>">全部 <span class="mono"><%= statusCounts.all %></span></a>
<a class="chip <%= status === 'active' ? 'is-active' : '' %>" href="<%= chipHref('active') %>">上线 <span class="mono"><%= statusCounts.active %></span></a>
<a class="chip <%= status === 'pending' ? 'is-active' : '' %>" href="<%= chipHref('pending') %>">待核验 <span class="mono"><%= statusCounts.pending %></span></a>
<a class="chip <%= status === 'greyed' ? 'is-active' : '' %>" href="<%= chipHref('greyed') %>">灰掉 <span class="mono"><%= statusCounts.greyed %></span></a>
<span class="spacer"></span>
<span class="mono page-hint">第 <%= page %> / <%= totalPages %> 页 · 每页 20 条</span>
</div>
<form class="admin-search" method="get" action="/admin">
<input type="hidden" name="status" value="<%= status %>" />
<input class="input" id="admin-q" name="q" type="search" value="<%= q %>"
placeholder="按名称 / 标题 / 链接搜索条目…" aria-label="按名称搜索条目" />
<button class="btn btn-primary btn-sm" type="submit">搜索</button>
<% if (q) { %><a class="btn btn-sm" href="/admin?status=<%= status %>">清空</a><% } %>
</form>
<div class="batch-bar">
<button class="btn btn-sm" type="button" data-action="entry-select-all">全选本页</button>
<button class="btn btn-sm" type="button" data-action="entry-batch" data-status="active">批量上架</button>
<button class="btn btn-sm" type="button" data-action="entry-batch" data-status="greyed">批量灰掉</button>
<span class="mono batch-count" id="batch-count">已选 0 项</span>
</div>
<table class="data-table" style="margin-top:14px">
<thead>
<tr> <tr>
<th style="width:36px"><input type="checkbox" class="entry-check-all" title="全选本页" /></th>
<th>标题</th>
<th>类型</th>
<th>状态</th>
<th>更新时间</th>
<th style="text-align:right">操作</th>
</tr>
</thead>
<tbody>
<% if (entries.length === 0) { %>
<tr><td colspan="6" class="mono" style="text-align:center;color:var(--ink-dim);padding:28px">当前筛选下没有条目</td></tr>
<% } %>
<% for (const e of entries) { %>
<tr data-id="<%= e.id %>">
<td style="text-align:center"><input type="checkbox" class="entry-check" data-id="<%= e.id %>" /></td>
<td> <td>
<b><%= e.title %></b><br /> <b><%= e.title %></b><br />
<span class="mono" style="font-size:11px;color:var(--ink-dim)"><%= (e.tags || []).join(' · ') || '—' %></span> <span class="mono" style="font-size:11px;color:var(--ink-dim)"><%= (e.tags || []).join(' · ') || '—' %></span>
@@ -38,6 +80,11 @@
<td> <td>
<div class="row-actions"> <div class="row-actions">
<a class="btn btn-sm" href="/admin/entries/<%= e.id %>/edit">编辑</a> <a class="btn btn-sm" href="/admin/entries/<%= e.id %>/edit">编辑</a>
<% if (e.status === 'active') { %>
<a class="btn btn-sm" href="/entries/<%= e.slug %>" target="_blank" rel="noopener">查看</a>
<% } else { %>
<a class="btn btn-sm" href="/entries/<%= e.slug %>?preview=1" target="_blank" rel="noopener">预览</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 { %>
@@ -52,11 +99,16 @@
<% } %> <% } %>
</tbody> </tbody>
</table> </table>
<div class="pager">
<% const pageHref = (p) => '/admin?' + qs({ status, q, page: p }); %>
<a class="btn btn-sm <%= page <= 1 ? 'btn-disabled' : '' %>" href="<%= pageHref(Math.max(1, page - 1)) %>">上一页</a>
<a class="btn btn-sm <%= page >= totalPages ? 'btn-disabled' : '' %>" href="<%= pageHref(page + 1) %>">下一页</a>
</div>
</section> </section>
<!-- 标签管理 --> <!-- 标签管理 -->
<section id="sec-tags"> <section id="sec-tags" class="<%= activeSection === 'sec-tags' ? 'active' : '' %>">
<p class="anno">// tree: tags · 删除父级时子级自动上移;移动到自己的子级会被拒绝</p> <p class="anno">// tree: tags · 拖到某个标签上 = 移为其子级;拖到下方空白处 = 移回顶层标签组</p>
<h1>标签管理</h1> <h1>标签管理</h1>
<div style="display:flex;gap:10px;margin-top:18px"> <div style="display:flex;gap:10px;margin-top:18px">
<button class="btn btn-primary btn-sm" type="button" data-action="tag-add">新增顶层标签组</button> <button class="btn btn-primary btn-sm" type="button" data-action="tag-add">新增顶层标签组</button>
@@ -64,32 +116,34 @@
</div> </div>
<div class="tagmgr"> <div class="tagmgr">
<div class="panel-box"> <div class="panel-box">
<h3>标签树 <span class="mono" style="font-weight:400;font-size:12px;color:var(--ink-dim)">(#ID 用于「移动」时填写目标)</span></h3> <h3>标签树 <span class="mono" style="font-weight:400;font-size:12px;color:var(--ink-dim)">(可直接拖动节点调整层级,变更即时生效)</span></h3>
<ul class="tag-tree"> <div class="tagmgr-tree" id="admin-tag-tree" style="min-height:120px">
<% for (const root of tagTreeAdmin) { %><%- include('partials/tag-node', { node: root }) %><% } %> <p class="mono dim">加载中…</p>
</ul> </div>
<p class="dnd-hint mono dim">按住节点拖动:落到某个标签上 = 变为它的子级 · 落到下方空白处 = 移回顶层</p>
</div> </div>
<div class="panel-box"> <div class="panel-box">
<h3>规则说明</h3> <h3>规则说明</h3>
<p style="font-size:13.5px;color:var(--ink-dim)"> <p style="font-size:13.5px;color:var(--ink-dim)">
标签可无限嵌套,列表页按组过滤时会自动包含所有后代标签的条目。 标签可无限嵌套,列表页按组过滤时会自动包含所有后代标签的条目。<br /><br />
「移动」填写目标标签 ID;移动到自身子级会被拒绝以防止成环。 拖动标签到另一标签上可快速调整层级;拖到空白处可移回顶层。<br />
删除父标签时其子标签自动上移一级。 删除父标签时其子标签自动上移一级,关联关系解除。
</p> </p>
</div> </div>
</div> </div>
</section> </section>
<!-- 评论管理 --> <!-- 评论管理 -->
<section id="sec-comments"> <section id="sec-comments" class="<%= activeSection === 'sec-comments' ? 'active' : '' %>">
<p class="anno">// table: comments</p> <p class="anno">// table: comments</p>
<h1>评论管理</h1> <h1>评论管理</h1>
<table class="data-table" style="margin-top:22px"> <table class="data-table" style="margin-top:22px">
<thead><tr><th>评论者</th><th>内容</th><th>所属条目</th><th>时间</th><th style="text-align:right">操作</th></tr></thead> <thead><tr><th>评论者</th><th>联系方式</th><th>内容</th><th>所属条目</th><th>时间</th><th style="text-align:right">操作</th></tr></thead>
<tbody> <tbody>
<% for (const c of comments) { %> <% for (const c of comments) { %>
<tr> <tr>
<td><b><%= c.author_name %></b><% if (c.username) { %> <span class="mono" style="font-size:11px;color:var(--ink-dim)">@<%= c.username %></span><% } %></td> <td><b><%= c.author_name %></b><% if (c.username) { %> <span class="mono" style="font-size:11px;color:var(--ink-dim)">@<%= c.username %></span><% } %></td>
<td class="mono" style="font-size:12px"><%= c.contact || '—' %></td>
<td style="max-width:280px"><%= c.body.slice(0, 80) %><%= c.body.length > 80 ? '…' : '' %></td> <td style="max-width:280px"><%= c.body.slice(0, 80) %><%= c.body.length > 80 ? '…' : '' %></td>
<td><a href="/entries/<%= c.entry_slug %>" target="_blank"><%= c.entry_title %></a></td> <td><a href="/entries/<%= c.entry_slug %>" target="_blank"><%= c.entry_title %></a></td>
<td class="mono"><%= fmtDate(c.created_at) %></td> <td class="mono"><%= fmtDate(c.created_at) %></td>
@@ -98,12 +152,48 @@
</div></td> </div></td>
</tr> </tr>
<% } %> <% } %>
<% if (!comments.length) { %>
<tr><td colspan="6" class="mono" style="text-align:center;color:var(--ink-dim);padding:28px">还没有评论</td></tr>
<% } %>
</tbody>
</table>
</section>
<!-- 评论人员 -->
<section id="sec-contacts" class="<%= activeSection === 'sec-contacts' ? 'active' : '' %>">
<p class="anno">// table: contacts · 以联系方式为身份依据 · 禁用后无法继续评论</p>
<h1>评论人员管理</h1>
<p class="admin-desc">访客首次评论时以「昵称 + 联系方式」建档;之后必须昵称与联系方式匹配且未被禁用才能评论。</p>
<table class="data-table" style="margin-top:22px">
<thead><tr><th>ID</th><th>联系方式</th><th>类型</th><th>昵称</th><th>评论数</th><th>状态</th><th>最近评论</th><th style="text-align:right">操作</th></tr></thead>
<tbody>
<% for (const p of contacts) { %>
<tr data-id="<%= p.id %>">
<td class="mono">#<%= p.id %></td>
<td class="mono"><%= p.contact %></td>
<td><%= p.contact.includes('@') ? '邮箱' : '手机' %></td>
<td><b><%= p.nickname %></b></td>
<td class="mono"><%= Number(p.comments || 0) %></td>
<td><span class="status-pill <%= p.enabled ? 'status-ok' : 'status-warn' %>"><%= p.enabled ? '正常' : '已禁用' %></span></td>
<td class="mono"><%= fmtDate(p.updated_at) %></td>
<td><div class="row-actions">
<button class="btn btn-sm" type="button" data-action="contact-rename" data-id="<%= p.id %>" data-name="<%= p.nickname %>">改名</button>
<button class="btn btn-sm" type="button" data-action="contact-status" data-id="<%= p.id %>" data-enabled="<%= p.enabled ? '1' : '0' %>">
<%= p.enabled ? '禁用' : '启用' %>
</button>
<button class="btn btn-sm btn-danger" type="button" data-action="contact-delete" data-id="<%= p.id %>">删除</button>
</div></td>
</tr>
<% } %>
<% if (!contacts.length) { %>
<tr><td colspan="8" class="mono" style="text-align:center;color:var(--ink-dim);padding:28px">还没有评论人员</td></tr>
<% } %>
</tbody> </tbody>
</table> </table>
</section> </section>
<!-- 内容源 --> <!-- 内容源 -->
<section id="sec-sources"> <section id="sec-sources" class="<%= activeSection === 'sec-sources' ? 'active' : '' %>">
<p class="anno">// sources: human + ai + feed · 自动拉取经 ingest/ 采集层进入待核验队列</p> <p class="anno">// sources: human + ai + feed · 自动拉取经 ingest/ 采集层进入待核验队列</p>
<h1>内容源管理</h1> <h1>内容源管理</h1>
@@ -126,7 +216,7 @@
<input class="input" name="key" type="password" value="<%= s.api_key || '' %>" placeholder="sk-…" autocomplete="off" /> <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="显示密钥"> <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-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> <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"><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> </button>
</div> </div>
<button class="btn btn-sm" type="submit" style="flex:none">保存密钥</button> <button class="btn btn-sm" type="submit" style="flex:none">保存密钥</button>
@@ -139,7 +229,7 @@
<form class="source-config" data-id="<%= s.id %>"> <form class="source-config" data-id="<%= s.id %>">
<div class="switchline"> <div class="switchline">
启用定时拉取 启用定时拉取
<input type="checkbox" name="enabled" <%= s.enabled ? 'checked' : '' %> style="accent-color:var(--riso-blue)" /> <label class="toggle"><input type="checkbox" name="enabled" <%= s.enabled ? 'checked' : '' %> /><i></i></label>
</div> </div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-top:12px"> <div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-top:12px">
<div> <div>
@@ -168,6 +258,11 @@
</div> </div>
</div> </div>
</form> </form>
<p class="mono" style="margin-top:10px;font-size:12px;color:var(--ink-dim)">
上次运行:<%= s.last_run_at ? fmtDate(s.last_run_at) : '从未运行' %> ·
状态:<%= s.last_run_status || '—' %>
<% if (s.enabled) { %>· 定时 <span class="mono"><%= s.cron_expr %></span><% } else { %>· 定时已关闭<% } %>
</p>
<pre class="code-block mono" id="runs-<%= s.id %>" hidden style="margin-top:12px"></pre> <pre class="code-block mono" id="runs-<%= s.id %>" hidden style="margin-top:12px"></pre>
<% } %> <% } %>
</div> </div>
@@ -190,7 +285,7 @@
<!-- 账号管理 --> <!-- 账号管理 -->
<% if (isAdmin) { %> <% if (isAdmin) { %>
<section id="sec-accounts"> <section id="sec-accounts" class="<%= activeSection === 'sec-accounts' ? 'active' : '' %>">
<p class="anno">// table: accounts · role: admin | editor</p> <p class="anno">// table: accounts · role: admin | editor</p>
<h1>账号管理</h1> <h1>账号管理</h1>
<table class="data-table" style="margin-top:22px"> <table class="data-table" style="margin-top:22px">
+30 -7
View File
@@ -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>
@@ -60,11 +60,15 @@
<form class="comment-form" data-entry-id="<%= entry.id %>"> <form class="comment-form" data-entry-id="<%= entry.id %>">
<div class="form-row"> <div class="form-row">
<input class="input" name="authorName" type="text" placeholder="昵称" <input class="input" name="authorName" type="text" placeholder="昵称" required />
value="<%= typeof user !== 'undefined' && user ? user.username : '' %>" required /> <input class="input" name="contact" type="text" inputmode="email"
placeholder="联系方式(手机号或邮箱,不会公开显示)" required />
</div> </div>
<textarea class="textarea" name="body" placeholder="使用场景、坑、搭配推荐…" required></textarea> <textarea class="textarea" name="body" placeholder="使用场景、坑、搭配推荐…" required></textarea>
<div><button class="btn btn-primary" type="submit">发布评论</button></div> <div>
<button class="btn btn-primary" type="submit">发布评论</button>
<span class="mono" style="font-size:11.5px;color:var(--ink-dim);margin-left:10px">// 首次评论以「昵称 + 联系方式」建档,之后需二者匹配且未被禁用</span>
</div>
</form> </form>
</section> </section>
</article> </article>
@@ -89,6 +93,25 @@
<% if (!entry.tags.length) { %><span class="mono" style="font-size:12px;color:var(--ink-dim)">无标签</span><% } %> <% if (!entry.tags.length) { %><span class="mono" style="font-size:12px;color:var(--ink-dim)">无标签</span><% } %>
</div> </div>
</div> </div>
<div class="side-card">
<h3>相关项目</h3>
<% if (typeof related !== 'undefined' && related.length) { %>
<ul class="related-list">
<% for (const r of related) { %>
<li>
<a href="/entries/<%= r.slug %>">
<span class="chip chip-type"><%= r.type %></span>
<b><%= r.title %></b>
</a>
<p class="mono" style="font-size:11.5px;color:var(--ink-dim);margin:5px 0 0"><%= excerpt(r.summary || r.description_md, 60) %></p>
</li>
<% } %>
</ul>
<% } else { %>
<p class="mono" style="font-size:12px;color:var(--ink-dim)">暂无相关项目</p>
<% } %>
</div>
</aside> </aside>
</div> </div>
</main> </main>
+60 -23
View File
@@ -6,7 +6,7 @@
<div class="hero-grid"> <div class="hero-grid">
<div class="hero-copy"> <div class="hero-copy">
<p class="anno rise">~/awesome-index — 给人用,也给 AI 用</p> <p class="anno rise">~/awesome-index — 给人用,也给 AI 用</p>
<h1 class="rise d1">把网上<span class="hl">牛逼的东西</span>,<br />收进一个好用的库。</h1> <h1 class="rise d1">把网上<span class="hl">牛逼</span>的东西,<br />收进一个好用的库。</h1>
<p class="hero-sub rise d2">人工精选的开源软件、微服务、SaaS、网站、工具、脚本与插件。每一条都是结构化数据:人在页面上搜,AI 通过 MCP 直接调用。</p> <p class="hero-sub rise d2">人工精选的开源软件、微服务、SaaS、网站、工具、脚本与插件。每一条都是结构化数据:人在页面上搜,AI 通过 MCP 直接调用。</p>
<form class="search-instrument rise d3" id="search-form" role="search"> <form class="search-instrument rise d3" id="search-form" role="search">
@@ -33,22 +33,23 @@
</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"> <a class="spec-human" href="/entries/<%= h.slug %>" aria-label="查看 <%= h.title %> 详情">
<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> </a>
<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>
随机游览 随机游览
@@ -80,7 +81,8 @@
<button class="copy-btn" type="button" data-copy="<%- config.baseUrl %>/mcp">复制</button> <button class="copy-btn" type="button" data-copy="<%- config.baseUrl %>/mcp">复制</button>
</div> </div>
<p class="endpoint-status"><span class="dot"></span>在线 · Streamable HTTP · 只读</p> <p class="endpoint-status"><span class="dot"></span>在线 · Streamable HTTP · 只读</p>
<p class="endpoint-note">只读访问,无需密钥。写入类操作仅限管理台与白名单来源。</p> <p class="endpoint-note">只读访问,<b>无需密钥</b>,连接时不要配 token。写入请走 <span class="mono">/api/ingest/entry</span>(需 <span class="mono">X-Ingest-Key</span>)。</p>
<p class="endpoint-note mono" style="font-size:11.5px">// 一句话:<b>读用 MCP(免密钥),写用 REST(要密钥)</b></p>
</aside> </aside>
<div class="steps"> <div class="steps">
@@ -95,24 +97,59 @@
}</pre> }</pre>
</div> </div>
<div class="step rise d3"> <div class="step rise d3">
<div class="step-head"><span class="step-no">02</span><h3>可用的工具</h3></div> <div class="step-head"><span class="step-no">02</span><h3>可用工具 <span class="mono" style="font-weight:400;font-size:12px;color:var(--ink-dim)">(只读 · 无需密钥)</span></h3></div>
<table class="tools-table"> <table class="tools-table">
<thead><tr><th>tool</th><th>说明</th></tr></thead> <thead><tr><th>tool</th><th>参数</th><th>说明</th></tr></thead>
<tbody> <tbody>
<tr><td>search_entries</td><td>按关键字搜索收录内容</td></tr> <tr><td>search_entries</td><td class="mono">q*, limit</td><td>按关键字 / 类型 / 标签搜索。<b>查重先走它</b>。limit 1–50,默认 20</td></tr>
<tr><td>get_entry</td><td>获取单条的完整详情与元数据</td></tr> <tr><td>get_entry</td><td class="mono">slug*</td><td>取单条完整详情与元数据;不确定 slug 时可传标题关键字自动匹配</td></tr>
<tr><td>random_entry</td><td>随机返回一条,用于探索发现</td></tr> <tr><td>random_entry</td><td class="mono">—</td><td>随机返回一条已上架内容,用于探索发现</td></tr>
<tr><td>list_tags</td><td>列出全部标签组及其嵌套结构</td></tr> <tr><td>list_tags</td><td class="mono">—</td><td>列出全部标签组、嵌套结构与收录数量</td></tr>
<tr><td>usage</td><td class="mono">—</td><td>返回本服务的完整接入说明(含写入接口与踩坑清单)</td></tr>
</tbody> </tbody>
</table> </table>
<p class="mono" style="font-size:11.5px;color:var(--ink-dim);margin-top:10px">
// 结果在 result.content[0].text 里,是 JSON 字符串,需再解析一次
</p>
</div> </div>
<div class="step rise d4"> <div class="step rise d4">
<div class="step-head"><span class="step-no">03</span><h3>直接问你的 AI</h3></div> <div class="step-head"><span class="step-no">03</span><h3>写入:AI 也能往库里添加内容 <span class="chip" style="font-size:11px">需密钥</span></h3></div>
<p class="mono" style="font-size:12px;color:var(--ink-dim);margin-bottom:8px">POST <%- config.baseUrl %>/api/ingest/entry · header <b>X-Ingest-Key</b></p>
<pre>{
<span class="k">"title"</span>: <span class="s">"MinerU"</span>,
<span class="k">"url"</span>: <span class="s">"https://github.com/opendatalab/MinerU"</span>,
<span class="k">"type"</span>: <span class="s">"工具"</span>, <span class="c">// 微服务|SaaS|网站|工具|应用|脚本|插件</span>
<span class="k">"summary"</span>: <span class="s">"PDF/文档转 Markdown 的解析引擎"</span>,
<span class="k">"tags"</span>: [<span class="s">"Python"</span>, <span class="s">"OCR"</span>]
}</pre>
<p class="mono" style="font-size:11.5px;color:var(--ink-dim);margin-top:8px">
// 其他可选:description_md · license · stars · status<br />
// 返回 {"ok":true,"data":{"created":true,"id":54,"status":"pending"}}
</p>
</div>
<div class="step rise d4">
<div class="step-head"><span class="step-no">04</span><h3>直接问你的 AI</h3></div>
<pre>「用 awesome-index 找三个能自托管的相册方案, <pre>「用 awesome-index 找三个能自托管的相册方案,
按 star 数排序,给我 repo 链接。」</pre> 按 star 数排序,给我 repo 链接。」</pre>
</div> </div>
</div> </div>
</div> </div>
<div class="mcp-pitfalls">
<p class="anno">// 五个必记的坑 —— 大部分「第一次加载不正确」都栽在这里</p>
<ol>
<li><b>用 <span class="mono">X-Ingest-Key</span> 请求头</b>(等价 <span class="mono">Authorization: Bearer &lt;key&gt;</span>)。自创的 <span class="mono">X-API-Key</span> / <span class="mono">?key=</span> 一律 403。</li>
<li><b>MCP 免密钥 ≠ 密钥有效。</b><span class="mono">/mcp</span> 根本不查 key,别拿探测 MCP 来验证你的 ingest key。</li>
<li><b>提交后默认 <span class="mono">pending</span></b>,前台搜不到是正常的,等管理员审核转为 <span class="mono">active</span>。<b>不要因为搜不到就重复提交。</b></li>
<li><b>同一 URL 重复提交返回 200 <span class="mono">created:false</span></b>,这不是报错,说明已存在并被去重。</li>
<li><span class="mono">slug</span> <b>不是合法字段</b>,传了会被静默丢弃(服务端按标题自动生成)。</li>
</ol>
<p class="mono" style="font-size:11.5px;color:var(--ink-dim);margin-top:10px">
// curl 直连 /mcp 报 406?补上 Accept: application/json, text/event-stream 即可;正规 MCP 客户端自带该头。
</p>
</div>
</div> </div>
</section> </section>
@@ -129,7 +166,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>
+1 -1
View File
@@ -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>
+8 -2
View File
@@ -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"><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>
+1 -1
View File
@@ -37,7 +37,7 @@
<div class="footer-bar" style="border-top:1px solid rgba(233,235,228,.14)"> <div class="footer-bar" style="border-top:1px solid rgba(233,235,228,.14)">
<span>© 2026 honor3.com · Awesome Index</span> <span>© 2026 honor3.com · Awesome Index</span>
<span>由 DuckDB 驱动 · MCP 就绪 · 数据每日快照</span> <span>V <%= config.gitBranch %>-<%= config.buildTime %> · 由 DuckDB 驱动 · MCP 就绪 · 数据每日快照</span>
</div> </div>
</div> </div>
</footer> </footer>
+22 -16
View File
@@ -1,32 +1,39 @@
#!/bin/bash #!/bin/bash
set -e set -e
# Awesome Index 启动脚本(直接部署,不使用 Docker) # Awesome Index 重启脚本(使用已构建的镜像,纯 docker run,无 compose)
DEPLOY_PATH="$(cd "$(dirname "$0")" && pwd)" DEPLOY_PATH="$(cd "$(dirname "$0")" && pwd)"
cd "$DEPLOY_PATH" cd "$DEPLOY_PATH"
# 检查 .env 文件 # 检查 .env 文件
if [ ! -f .env ]; then if [ ! -f .env ]; then
echo "错误: 缺少 .env 文件,请从 .env.example 复制并修改" if [ -f .env.example ]; then
exit 1 cp .env.example .env
echo "已从 .env.example 创建 .env(请检查 SESSION_SECRET / ADMIN_INIT_PASSWORD / AI_INGEST_KEY)"
else
echo "错误: 缺少 .env 文件"
exit 1
fi
fi fi
# 安装依赖 # 停止并删除旧容器(先优雅停止,让应用关闭 DuckDB 完成 checkpoint)
echo "安装依赖..." echo "停止旧容器..."
npm ci --omit=dev --no-audit --no-fund docker stop awesome-index 2>/dev/null || true
docker rm awesome-index 2>/dev/null || true
# 停止旧进程 # 用新镜像启动容器
echo "停止旧进程..."
pkill -f "node src/index.js" 2>/dev/null || true
sleep 1
# 启动服务
echo "启动 Awesome Index..." echo "启动 Awesome Index..."
nohup node src/index.js > logs/app.log 2>&1 & docker run -d \
--name awesome-index \
--restart unless-stopped \
-p 3000:3000 \
-v "$DEPLOY_PATH/data:/app/data" \
--env-file .env \
awesome-index:latest
# 健康检查 # 健康检查
echo "等待服务就绪..." echo "等待服务就绪..."
sleep 2 sleep 5
MAX_RETRIES=10 MAX_RETRIES=10
RETRY_COUNT=0 RETRY_COUNT=0
@@ -35,12 +42,11 @@ while [ $RETRY_COUNT -lt $MAX_RETRIES ]; do
echo "✓ 服务已启动: http://localhost:3000" echo "✓ 服务已启动: http://localhost:3000"
echo "" echo ""
echo "管理后台: http://localhost:3000/admin" echo "管理后台: http://localhost:3000/admin"
echo "默认账号: admin / (见 .env 中的 ADMIN_INIT_PASSWORD)"
exit 0 exit 0
fi fi
RETRY_COUNT=$((RETRY_COUNT + 1)) RETRY_COUNT=$((RETRY_COUNT + 1))
sleep 2 sleep 2
done done
echo "✗ 服务启动失败,请检查日志: tail -50 logs/app.log" echo "✗ 服务启动失败,请检查日志: docker logs awesome-index"
exit 1 exit 1
+77
View File
@@ -0,0 +1,77 @@
import test from "node:test";
import assert from "node:assert/strict";
import { withApp } from "./helpers.js";
test("admin content search filters by name", async () => {
await withApp(async ({ api, loginAdmin }) => {
const cookie = await loginAdmin();
const hit = await api("/api/admin/entries?q=ripgrep&status=all", { cookie });
assert.equal(hit.json.data.total, 1);
assert.equal(hit.json.data.items[0].title, "ripgrep");
const miss = await api("/api/admin/entries?q=zzzz-not-exist&status=all", { cookie });
assert.equal(miss.json.data.total, 0);
// 也能按 slug 命中
const bySlug = await api("/api/admin/entries?q=uptime-kuma&status=all", { cookie });
assert.equal(bySlug.json.data.total, 1);
});
});
test("admin content page renders the search box and 20 rows per page", async () => {
await withApp(async ({ base, api, loginAdmin }) => {
const cookie = await loginAdmin();
const res = await fetch(`${base}/admin?q=ripgrep`, { headers: { cookie } });
const html = await res.text();
assert.equal(res.status, 200);
assert.ok(html.includes('name="q"'), "有名称搜索框");
assert.ok(html.includes("每页 20 条"), "标注每页 20 条");
const perPage = await api("/api/admin/entries?status=all&page=1", { cookie });
assert.equal(perPage.json.data.perPage, 20);
});
});
test("admin nav links point at server-rendered sections", async () => {
await withApp(async ({ base, loginAdmin }) => {
const cookie = await loginAdmin();
const res = await fetch(`${base}/admin?section=sec-comments`, { headers: { cookie } });
const html = await res.text();
assert.equal(res.status, 200);
assert.ok(html.includes('href="/admin?section=sec-contacts"'), "菜单是真链接");
assert.ok(
html.includes('<section id="sec-comments" class="active">'),
"服务端直接渲染出当前分区"
);
});
});
test("pending entries show 预览 and active entries show 查看", async () => {
await withApp(async ({ base, api, loginAdmin }) => {
const cookie = await loginAdmin();
const active = await fetch(`${base}/admin?status=active`, { headers: { cookie } });
const activeHtml = await active.text();
assert.ok(activeHtml.includes(">查看</a>"), "active 条目按钮是「查看」");
assert.ok(!activeHtml.includes(">预览</a>"), "active 条目不出现「预览」");
await api("/api/ingest/entry", {
method: "POST",
body: { title: "pending-probe", url: "https://example.com/pending-probe", type: "工具" },
headers: { "X-Ingest-Key": "secret-key" },
});
const pending = await fetch(`${base}/admin?status=pending`, { headers: { cookie } });
const pendingHtml = await pending.text();
assert.ok(pendingHtml.includes(">预览</a>"), "pending 条目按钮是「预览」");
}, { aiIngestKey: "secret-key" });
});
test("comment form does not prefill a nickname", async () => {
await withApp(async ({ base, loginAdmin }) => {
const cookie = await loginAdmin();
const res = await fetch(`${base}/entries/ripgrep`, { headers: { cookie } });
const html = await res.text();
const form = html.slice(html.indexOf('class="comment-form"'));
assert.ok(form.includes('name="contact"'), "评论表单有联系方式字段");
assert.ok(!/name="authorName"[^>]*value="admin"/.test(form), "昵称没有预置 admin");
});
});
+132
View File
@@ -0,0 +1,132 @@
import test from "node:test";
import assert from "node:assert/strict";
import { withApp } from "./helpers.js";
// 拼接构造,避免直接写一长串数字
const PHONE = ["1", "38", "00", "13", "80", "00"].join("");
const EMAIL = "writer@example.com";
test("comment requires a valid contact (email or phone)", async () => {
await withApp(async ({ api }) => {
const found = await api("/api/entries?q=ripgrep");
const entryId = found.json.data.items[0].id;
const bad = await api("/api/comments", {
method: "POST",
body: { entryId, authorName: "路人", contact: "not-a-contact", body: "好东西" },
});
assert.equal(bad.status, 400);
const empty = await api("/api/comments", {
method: "POST",
body: { entryId, authorName: "路人", contact: "", body: "好东西" },
});
assert.equal(empty.status, 400);
const ok = await api("/api/comments", {
method: "POST",
body: { entryId, authorName: "路人", contact: PHONE, body: "好东西" },
});
assert.equal(ok.status, 201);
const okEmail = await api("/api/comments", {
method: "POST",
body: { entryId, authorName: "写手", contact: EMAIL, body: "用邮箱也能评论" },
});
assert.equal(okEmail.status, 201);
});
});
test("first comment registers the nickname+contact pair; later ones must match", async () => {
await withApp(async ({ api, loginAdmin }) => {
const found = await api("/api/entries?q=ripgrep");
const entryId = found.json.data.items[0].id;
await api("/api/comments", {
method: "POST",
body: { entryId, authorName: "张三", contact: PHONE, body: "第一条" },
});
// 同一联系方式换了昵称 → 拒绝
const mismatch = await api("/api/comments", {
method: "POST",
body: { entryId, authorName: "李四", contact: PHONE, body: "冒名" },
});
assert.equal(mismatch.status, 400);
// 昵称一致 → 可以通过
const ok = await api("/api/comments", {
method: "POST",
body: { entryId, authorName: "张三", contact: PHONE, body: "再补一条" },
});
assert.equal(ok.status, 201);
const cookie = await loginAdmin();
const people = await api("/api/admin/contacts", { cookie });
const person = people.json.data.find((p) => p.contact === PHONE);
assert.ok(person, "评论人员已入档");
assert.equal(person.nickname, "张三");
assert.equal(Number(person.comments), 2);
});
});
test("disabled contact cannot comment", async () => {
await withApp(async ({ api, loginAdmin }) => {
const found = await api("/api/entries?q=ripgrep");
const entryId = found.json.data.items[0].id;
await api("/api/comments", {
method: "POST",
body: { entryId, authorName: "张三", contact: PHONE, body: "第一条" },
});
const cookie = await loginAdmin();
const people = await api("/api/admin/contacts", { cookie });
const person = people.json.data.find((p) => p.contact === PHONE);
const disabled = await api(`/api/admin/contacts/${person.id}/status`, {
method: "PATCH",
body: { enabled: false },
cookie,
});
assert.equal(disabled.json.data.enabled, false);
const blocked = await api("/api/comments", {
method: "POST",
body: { entryId, authorName: "张三", contact: PHONE, body: "被禁用了还想发" },
});
assert.equal(blocked.status, 403);
// 重新启用后可以继续评论
await api(`/api/admin/contacts/${person.id}/status`, {
method: "PATCH",
body: { enabled: true },
cookie,
});
const again = await api("/api/comments", {
method: "POST",
body: { entryId, authorName: "张三", contact: PHONE, body: "又回来了" },
});
assert.equal(again.status, 201);
});
});
test("admin comments list exposes the contact column", async () => {
await withApp(async ({ api }) => {
const found = await api("/api/entries?q=ripgrep");
const entryId = found.json.data.items[0].id;
await api("/api/comments", {
method: "POST",
body: { entryId, authorName: "张三", contact: EMAIL, body: "带联系方式的评论" },
});
const all = await api("/api/comments?all=1");
const row = all.json.data.find((c) => c.contact === EMAIL);
assert.ok(row, "后台评论列表能看到联系方式");
});
});
test("contacts admin endpoints require admin", async () => {
await withApp(async ({ api }) => {
const res = await api("/api/admin/contacts");
assert.equal(res.status, 401);
});
});
+48
View File
@@ -63,6 +63,54 @@ test("AI ingest without a valid key is forbidden", async () => {
}); });
}); });
test("AI ingest accepts X-Ingest-Key header", async () => {
await withApp(async ({ api }) => {
const res = await api("/api/ingest/entry", {
method: "POST",
body: { title: "probe", url: "https://github.com/x/key", type: "工具" },
headers: { "X-Ingest-Key": "secret-key" },
});
assert.equal(res.status, 201);
assert.equal(res.json.data.created, true);
assert.equal(res.json.data.status, "pending");
}, { aiIngestKey: "secret-key" });
});
test("AI ingest accepts Authorization: Bearer header", async () => {
await withApp(async ({ api }) => {
const res = await api("/api/ingest/entry", {
method: "POST",
body: { title: "probe", url: "https://github.com/x/bearer", type: "工具" },
headers: { Authorization: "Bearer secret-key" },
});
assert.equal(res.status, 201);
assert.equal(res.json.data.created, true);
}, { aiIngestKey: "secret-key" });
});
test("AI ingest accepts raw Authorization token", async () => {
await withApp(async ({ api }) => {
const res = await api("/api/ingest/entry", {
method: "POST",
body: { title: "probe", url: "https://github.com/x/raw", type: "工具" },
headers: { Authorization: "secret-key" },
});
assert.equal(res.status, 201);
assert.equal(res.json.data.created, true);
}, { aiIngestKey: "secret-key" });
});
test("AI ingest rejects a wrong Authorization token", async () => {
await withApp(async ({ api }) => {
const res = await api("/api/ingest/entry", {
method: "POST",
body: { title: "probe", url: "https://github.com/x/wrong", type: "工具" },
headers: { Authorization: "Bearer nope" },
});
assert.equal(res.status, 403);
}, { aiIngestKey: "secret-key" });
});
test("unauthenticated writes to entries are forbidden", async () => { test("unauthenticated writes to entries are forbidden", async () => {
await withApp(async ({ api }) => { await withApp(async ({ api }) => {
const res = await api("/api/entries", { const res = await api("/api/entries", {
+5 -4
View File
@@ -5,10 +5,10 @@ import { connect, close } from "../src/db/connection.js";
import { runMigrate } from "../src/db/migrate.js"; import { runMigrate } from "../src/db/migrate.js";
import { buildApp } from "../src/app.js"; import { buildApp } from "../src/app.js";
export async function withApp(fn) { export async function withApp(fn, { aiIngestKey = "", adminInitPassword = "test123" } = {}) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "awesome-test-")); const dir = fs.mkdtempSync(path.join(os.tmpdir(), "awesome-test-"));
await connect(path.join(dir, "test.duckdb")); await connect(path.join(dir, "test.duckdb"));
await runMigrate({ adminInitPassword: "test123", aiIngestKey: "" }); await runMigrate({ adminInitPassword, aiIngestKey });
const app = buildApp({ const app = buildApp({
sessionSecret: "test-secret", sessionSecret: "test-secret",
duckdbPath: path.join(dir, "test.duckdb"), duckdbPath: path.join(dir, "test.duckdb"),
@@ -18,12 +18,13 @@ export async function withApp(fn) {
await new Promise((r) => server.once("listening", r)); await new Promise((r) => server.once("listening", r));
const base = `http://127.0.0.1:${server.address().port}`; const base = `http://127.0.0.1:${server.address().port}`;
async function api(pathname, { method = "GET", body, cookie } = {}) { async function api(pathname, { method = "GET", body, cookie, headers } = {}) {
const res = await fetch(base + pathname, { const res = await fetch(base + pathname, {
method, method,
headers: { headers: {
...(body ? { "content-type": "application/json" } : {}), ...(body ? { "content-type": "application/json" } : {}),
...(cookie ? { cookie } : {}), ...(cookie ? { cookie } : {}),
...(headers || {}),
}, },
body: body ? JSON.stringify(body) : undefined, body: body ? JSON.stringify(body) : undefined,
}); });
@@ -38,7 +39,7 @@ export async function withApp(fn) {
const res = await fetch(base + "/api/admin/login", { const res = await fetch(base + "/api/admin/login", {
method: "POST", method: "POST",
headers: { "content-type": "application/json" }, headers: { "content-type": "application/json" },
body: JSON.stringify({ username: "admin", password: "test123" }), body: JSON.stringify({ username: "admin", password: adminInitPassword }),
}); });
const cookies = res.headers.getSetCookie().map((c) => c.split(";")[0]); const cookies = res.headers.getSetCookie().map((c) => c.split(";")[0]);
return cookies.join("; "); return cookies.join("; ");
+56
View File
@@ -0,0 +1,56 @@
import test from "node:test";
import assert from "node:assert/strict";
import { getAdapter, normalizeUrl } from "../src/ingest/adapters/index.js";
const RSS = `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0"><channel>
<title>Test Feed</title>
<link>https://example.com/blog</link>
<item>
<title>First Post</title>
<link>https://example.com/posts/first?utm_source=feed</link>
<description><![CDATA[<p>Some <b>html</b> description</p>]]></description>
</item>
<item>
<title>Relative Link</title>
<link>/posts/relative</link>
<description>Relative one</description>
</item>
<item>
<title>Atom style</title>
<link href="https://example.com/posts/atom" />
<description>Atom styled link</description>
</item>
</channel></rss>`;
test("rss adapter resolves each item's own link, not the feed url", async () => {
// 保留原始 fetch:把 RSS 内容喂进去
const realFetch = globalThis.fetch;
globalThis.fetch = async () => new Response(RSS, { status: 200 });
try {
const items = await getAdapter("rss").fetch("https://example.com/feed.xml");
assert.equal(items.length, 3);
assert.equal(items[0].title, "First Post");
// 关键:url 必须是条目的链接,而不是 https://example.com/feed.xml
assert.equal(items[0].url, "https://example.com/posts/first");
// utm_ 参数被去掉
assert.ok(!items[0].url.includes("utm_source"));
assert.match(items[0].description, /Some html description/);
// 相对链接按 feed 地址解析成绝对地址
assert.equal(items[1].url, "https://example.com/posts/relative");
// <link href="..."> 形式也能取到
assert.equal(items[2].url, "https://example.com/posts/atom");
assert.ok(items.every((i) => i.url !== "https://example.com/feed.xml"));
} finally {
globalThis.fetch = realFetch;
}
});
test("normalizeUrl drops tracking params and trailing slash", () => {
assert.equal(normalizeUrl("https://a.com/x/?utm_src=b#frag"), "https://a.com/x");
assert.equal(normalizeUrl("https://a.com/x"), "https://a.com/x");
});
+77
View File
@@ -0,0 +1,77 @@
import test from "node:test";
import assert from "node:assert/strict";
import { withApp } from "./helpers.js";
async function entryIds(api) {
const found = await api("/api/entries?q=&status=all&perPage=50");
const byTitle = {};
for (const it of found.json.data.items) byTitle[it.title] = it.id;
return byTitle;
}
test("related projects are bidirectional and manageable", async () => {
await withApp(async ({ api, loginAdmin }) => {
const ids = await entryIds(api);
const cookie = await loginAdmin();
const added = await api(`/api/admin/entries/${ids["ripgrep"]}/relations`, {
method: "POST",
body: { relatedId: ids["fzf"] },
cookie,
});
assert.equal(added.status, 201);
// 反向可见
const back = await api(`/api/admin/relations?excludeId=${ids["fzf"]}`, { cookie });
assert.ok(back.json.data.every((e) => e.id !== ids["fzf"]));
// 重复关联不报错
const again = await api(`/api/admin/entries/${ids["ripgrep"]}/relations`, {
method: "POST",
body: { relatedId: ids["fzf"] },
cookie,
});
assert.equal(again.status, 200);
assert.equal(again.json.data.created, false);
// 关联自己被拒绝
const self = await api(`/api/admin/entries/${ids["fzf"]}/relations`, {
method: "POST",
body: { relatedId: ids["fzf"] },
cookie,
});
assert.equal(self.status, 400);
await api(`/api/admin/entries/${ids["ripgrep"]}/relations/${ids["fzf"]}`, {
method: "DELETE",
cookie,
});
const after = await api(`/api/admin/relations`, { cookie });
void after;
});
});
test("detail page shows related projects", async () => {
await withApp(async ({ base, api, loginAdmin }) => {
const ids = await entryIds(api);
const cookie = await loginAdmin();
await api(`/api/admin/entries/${ids["ripgrep"]}/relations`, {
method: "POST",
body: { relatedId: ids["fzf"] },
cookie,
});
const res = await fetch(`${base}/entries/ripgrep`);
const html = await res.text();
assert.equal(res.status, 200);
assert.ok(html.includes("相关项目"), "详情页有相关项目区块");
assert.ok(html.includes("fzf"), "相关项目里出现 fzf");
});
});
test("relations endpoints require admin", async () => {
await withApp(async ({ api }) => {
const res = await api("/api/admin/relations");
assert.equal(res.status, 401);
});
});