Compare commits
9
Commits
f58970783f
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
806761103b | ||
|
|
3f7b48325f | ||
|
|
7e62db31b8 | ||
|
|
381e6a780d | ||
|
|
64f3f6ea58 | ||
|
|
c09857cc81 | ||
|
|
a5c5d24083 | ||
|
|
e11814048c | ||
|
|
2422ae452b |
@@ -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
|
||||||
@@ -3,8 +3,6 @@ name: Build and Deploy
|
|||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
pull_request:
|
|
||||||
branches: [main]
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
@@ -13,13 +11,12 @@ jobs:
|
|||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
|
||||||
uses: docker/setup-buildx-action@v3
|
|
||||||
|
|
||||||
- name: Build Docker image
|
- name: Build Docker image
|
||||||
run: |
|
run: |
|
||||||
docker build -t awesome-index:${{ github.sha }} .
|
docker build \
|
||||||
docker tag awesome-index:${{ github.sha }} awesome-index:latest
|
--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
|
needs: build
|
||||||
@@ -29,26 +26,16 @@ jobs:
|
|||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Deploy to server
|
- name: Copy scripts to server
|
||||||
uses: appleboy/ssh-action@master
|
|
||||||
with:
|
|
||||||
host: 114.134.187.170
|
|
||||||
username: root
|
|
||||||
password: ${{ secrets.SERVER_PASSWORD }}
|
|
||||||
script: |
|
|
||||||
# 创建部署目录
|
|
||||||
mkdir -p /app/awesome/data /app/awesome/logs
|
|
||||||
|
|
||||||
- name: Copy files 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: "docker-compose.yml,start.sh"
|
source: "start.sh,.env.example"
|
||||||
target: "/app/awesome/"
|
target: "/app/awesome/"
|
||||||
|
|
||||||
- name: Load and start container
|
- 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
|
||||||
@@ -56,15 +43,5 @@ jobs:
|
|||||||
password: ${{ secrets.SERVER_PASSWORD }}
|
password: ${{ secrets.SERVER_PASSWORD }}
|
||||||
script: |
|
script: |
|
||||||
cd /app/awesome
|
cd /app/awesome
|
||||||
|
chmod +x start.sh
|
||||||
# 停止旧容器
|
bash start.sh
|
||||||
docker compose down --remove-orphans 2>/dev/null || true
|
|
||||||
|
|
||||||
# 启动新容器
|
|
||||||
docker compose up -d
|
|
||||||
|
|
||||||
# 等待启动
|
|
||||||
sleep 10
|
|
||||||
|
|
||||||
# 健康检查
|
|
||||||
curl -sf http://localhost:3000/healthz && echo " - 服务已启动" || echo " - 启动失败"
|
|
||||||
|
|||||||
@@ -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
@@ -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"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)。运行记录见管理台「内容源 → 运行记录」。
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# Awesome Index 部署脚本
|
# Awesome Index 部署脚本(构建机 = 部署机:本地构建镜像,SSH 重启容器)
|
||||||
# 服务器: 114.134.187.170
|
# 服务器: 114.134.187.170
|
||||||
|
|
||||||
set -e
|
set -e
|
||||||
@@ -19,68 +19,17 @@ 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 \
|
sshpass -p "$REMOTE_PASS" scp -o StrictHostKeyChecking=no \
|
||||||
docker-compose.yml start.sh .env.example \
|
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" \
|
sshpass -p "$REMOTE_PASS" ssh -o StrictHostKeyChecking=no "$REMOTE_USER@$REMOTE_HOST" \
|
||||||
"cd $DEPLOY_PATH && [ ! -f .env ] && cp .env.example .env"
|
"cd $DEPLOY_PATH && chmod +x start.sh && bash start.sh"
|
||||||
|
|
||||||
echo "4. 在服务器上构建并启动..."
|
|
||||||
sshpass -p "$REMOTE_PASS" ssh -o StrictHostKeyChecking=no "$REMOTE_USER@$REMOTE_HOST" << 'EOF'
|
|
||||||
cd /app/awesome
|
|
||||||
|
|
||||||
# 创建 Dockerfile 用于服务器构建
|
|
||||||
cat > Dockerfile << 'DOCKERFILE'
|
|
||||||
FROM node:22-bookworm-slim AS deps
|
|
||||||
WORKDIR /app
|
|
||||||
COPY package.json package-lock.json ./
|
|
||||||
RUN npm ci --omit=dev --no-audit --no-fund
|
|
||||||
|
|
||||||
FROM node:22-bookworm-slim
|
|
||||||
WORKDIR /app
|
|
||||||
ENV NODE_ENV=production
|
|
||||||
COPY --from=deps /app/node_modules ./node_modules
|
|
||||||
COPY package.json ./
|
|
||||||
COPY src ./src
|
|
||||||
COPY public ./public
|
|
||||||
VOLUME ["/app/data"]
|
|
||||||
EXPOSE 3000
|
|
||||||
USER node
|
|
||||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s \
|
|
||||||
CMD node -e "fetch('http://localhost:3000/healthz').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
|
|
||||||
CMD ["node", "src/index.js"]
|
|
||||||
DOCKERFILE
|
|
||||||
|
|
||||||
# 构建镜像
|
|
||||||
docker build -t awesome-index:latest .
|
|
||||||
|
|
||||||
# 停止旧容器
|
|
||||||
docker compose down --remove-orphans 2>/dev/null || true
|
|
||||||
|
|
||||||
# 启动新容器
|
|
||||||
docker compose up -d
|
|
||||||
|
|
||||||
echo "等待服务启动..."
|
|
||||||
sleep 10
|
|
||||||
|
|
||||||
# 健康检查
|
|
||||||
if curl -sf http://localhost:3000/healthz > /dev/null 2>&1; then
|
|
||||||
echo "✓ 服务启动成功!"
|
|
||||||
echo " 访问: http://$REMOTE_HOST:3000"
|
|
||||||
echo " 管理: http://$REMOTE_HOST:3000/admin"
|
|
||||||
else
|
|
||||||
echo "✗ 服务启动失败,查看日志:"
|
|
||||||
docker compose logs
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
EOF
|
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "=== 部署完成 ==="
|
echo "=== 部署完成 ==="
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -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
|
||||||
│
|
│
|
||||||
|
|||||||
Generated
+13
@@ -14,6 +14,7 @@
|
|||||||
"ejs": "^3.1.10",
|
"ejs": "^3.1.10",
|
||||||
"express": "^5.1.0",
|
"express": "^5.1.0",
|
||||||
"express-session": "^1.18.1",
|
"express-session": "^1.18.1",
|
||||||
|
"marked": "^18.0.11",
|
||||||
"node-cron": "^4.6.0",
|
"node-cron": "^4.6.0",
|
||||||
"zod": "^3.25.76"
|
"zod": "^3.25.76"
|
||||||
},
|
},
|
||||||
@@ -1139,6 +1140,18 @@
|
|||||||
"integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==",
|
"integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==",
|
||||||
"license": "BSD-2-Clause"
|
"license": "BSD-2-Clause"
|
||||||
},
|
},
|
||||||
|
"node_modules/marked": {
|
||||||
|
"version": "18.0.11",
|
||||||
|
"resolved": "https://registry.npmmirror.com/marked/-/marked-18.0.11.tgz",
|
||||||
|
"integrity": "sha512-HnslJfsZkRPBDJRHvVtAaWlZHEpSu7u8LgQuJCELjRKuWR+hpq4A7sLq3p8HaI9ypVoXDXxV34CsQJEe1+J5Aw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"marked": "bin/marked.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 20"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/math-intrinsics": {
|
"node_modules/math-intrinsics": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmmirror.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
"resolved": "https://registry.npmmirror.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||||
|
|||||||
@@ -20,6 +20,7 @@
|
|||||||
"ejs": "^3.1.10",
|
"ejs": "^3.1.10",
|
||||||
"express": "^5.1.0",
|
"express": "^5.1.0",
|
||||||
"express-session": "^1.18.1",
|
"express-session": "^1.18.1",
|
||||||
|
"marked": "^18.0.11",
|
||||||
"node-cron": "^4.6.0",
|
"node-cron": "^4.6.0",
|
||||||
"zod": "^3.25.76"
|
"zod": "^3.25.76"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -1594,6 +1774,51 @@ button.chip:hover {
|
|||||||
.field .input {
|
.field .input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
.ai-key-field {
|
||||||
|
position: relative;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.ai-key-field .input {
|
||||||
|
width: 100%;
|
||||||
|
padding-right: 40px;
|
||||||
|
}
|
||||||
|
.pwd-field {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.pwd-field .input {
|
||||||
|
width: 100%;
|
||||||
|
padding-right: 40px;
|
||||||
|
}
|
||||||
|
.eye-toggle {
|
||||||
|
position: absolute;
|
||||||
|
right: 2px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 34px;
|
||||||
|
height: 100%;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
background: none;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--ink-dim);
|
||||||
|
border-radius: 0 8px 8px 0;
|
||||||
|
}
|
||||||
|
.eye-toggle:hover {
|
||||||
|
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;
|
||||||
@@ -1824,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
-26
@@ -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 { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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");
|
||||||
@@ -162,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 {
|
||||||
|
|||||||
@@ -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 () {
|
||||||
|
|||||||
@@ -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)) {
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -80,6 +116,11 @@ 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 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) {
|
||||||
|
|||||||
@@ -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 (
|
||||||
@@ -24,6 +26,7 @@ CREATE TABLE IF NOT EXISTS sources (
|
|||||||
adapter VARCHAR,
|
adapter VARCHAR,
|
||||||
cron_expr VARCHAR DEFAULT '0 9 * * 1',
|
cron_expr VARCHAR DEFAULT '0 9 * * 1',
|
||||||
api_key_hash VARCHAR,
|
api_key_hash VARCHAR,
|
||||||
|
api_key VARCHAR,
|
||||||
direct_publish BOOLEAN NOT NULL DEFAULT FALSE,
|
direct_publish BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
last_run_at TIMESTAMPTZ,
|
last_run_at TIMESTAMPTZ,
|
||||||
last_run_status VARCHAR
|
last_run_status VARCHAR
|
||||||
@@ -53,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 '',
|
||||||
@@ -70,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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 } });
|
||||||
|
});
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
+112
-7
@@ -3,10 +3,11 @@ 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";
|
||||||
|
|
||||||
export const pagesRouter = Router();
|
export const pagesRouter = Router();
|
||||||
|
|
||||||
function csv(v) {
|
function csv(v) {
|
||||||
@@ -22,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);
|
||||||
@@ -65,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,
|
||||||
@@ -80,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) {
|
||||||
@@ -110,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",
|
||||||
@@ -123,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,
|
||||||
});
|
});
|
||||||
@@ -131,3 +181,58 @@ pagesRouter.get("/admin", async (req, res, next) => {
|
|||||||
next(e);
|
next(e);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
pagesRouter.get("/admin/entries/:id/edit", async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
if (!req.session.userId) return res.redirect("/login");
|
||||||
|
const entry = await entryService.getById(Number(req.params.id));
|
||||||
|
if (!entry) throw Object.assign(new Error("条目不存在"), { status: 404 });
|
||||||
|
const [tagTreeAdmin, related, candidates] = await Promise.all([
|
||||||
|
tagService.buildTree(),
|
||||||
|
relationService.listRelated(entry.id),
|
||||||
|
relationService.listCandidates({ excludeId: entry.id, limit: 200 }),
|
||||||
|
]);
|
||||||
|
res.render("admin-entry-edit", {
|
||||||
|
title: `编辑 · ${entry.title} · Awesome Index`,
|
||||||
|
user: { username: req.session.username, role: req.session.role },
|
||||||
|
isAdmin: req.session.role === "admin",
|
||||||
|
entry,
|
||||||
|
related,
|
||||||
|
candidates,
|
||||||
|
tagTreeAdmin,
|
||||||
|
types: ENTRY_TYPES,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
next(e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
pagesRouter.post("/admin/entries/:id/edit", async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
if (!req.session.userId) return res.redirect("/login");
|
||||||
|
const id = Number(req.params.id);
|
||||||
|
const tags = Array.isArray(req.body.tags) ? req.body.tags : req.body.tags ? [req.body.tags] : [];
|
||||||
|
const newTags = String(req.body.new_tags || "")
|
||||||
|
.split(",")
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
const patch = {
|
||||||
|
title: String(req.body.title || "").trim(),
|
||||||
|
type: req.body.type,
|
||||||
|
summary: String(req.body.summary || "").trim(),
|
||||||
|
description_md: String(req.body.description_md || ""),
|
||||||
|
url: String(req.body.url || "").trim(),
|
||||||
|
license: String(req.body.license || "").trim(),
|
||||||
|
stars: Number(req.body.stars) || 0,
|
||||||
|
tags: [...new Set([...tags, ...newTags])],
|
||||||
|
};
|
||||||
|
await entryService.updateEntry(id, patch, { isHumanEditor: true });
|
||||||
|
const status = req.body.status;
|
||||||
|
if (status && entryService.STATUSES.includes(status)) {
|
||||||
|
await entryService.setStatusDirect(id, status);
|
||||||
|
}
|
||||||
|
res.redirect("/admin#sec-content");
|
||||||
|
} catch (e) {
|
||||||
|
next(e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -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]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
}
|
||||||
@@ -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) {
|
||||||
@@ -144,6 +145,38 @@ export async function setStatus(id, status) {
|
|||||||
return getById(id);
|
return getById(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function setStatusDirect(id, status) {
|
||||||
|
const entry = await get(`SELECT * FROM entries WHERE id = ?`, [id]);
|
||||||
|
if (!entry) throw httpError(404, "条目不存在");
|
||||||
|
if (!STATUSES.includes(status)) throw httpError(400, `未知状态:${status}`);
|
||||||
|
await run(
|
||||||
|
`UPDATE entries SET status = ?, updated_at = now(),
|
||||||
|
verified_at = CASE WHEN ? = 'active' THEN now() ELSE verified_at END
|
||||||
|
WHERE id = ?`,
|
||||||
|
[status, status, id]
|
||||||
|
);
|
||||||
|
return getById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function 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]);
|
||||||
@@ -172,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]
|
||||||
|
|||||||
@@ -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)]
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,9 +13,9 @@ export async function ensureBuiltins({ aiIngestKey }) {
|
|||||||
const ai = await get(`SELECT id FROM sources WHERE name = 'AI 接口'`);
|
const ai = await get(`SELECT id FROM sources WHERE name = 'AI 接口'`);
|
||||||
if (!ai) {
|
if (!ai) {
|
||||||
await run(
|
await run(
|
||||||
`INSERT INTO sources (kind, name, enabled, api_key_hash, direct_publish)
|
`INSERT INTO sources (kind, name, enabled, api_key_hash, api_key, direct_publish)
|
||||||
VALUES ('ai', 'AI 接口', ?, ?, FALSE)`,
|
VALUES ('ai', 'AI 接口', ?, ?, ?, FALSE)`,
|
||||||
[Boolean(aiIngestKey), keyHash(aiIngestKey)]
|
[Boolean(aiIngestKey), keyHash(aiIngestKey), aiIngestKey]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -68,9 +68,10 @@ export async function updateSource(id, patch) {
|
|||||||
export async function setAiKey(key) {
|
export async function setAiKey(key) {
|
||||||
const ai = await get(`SELECT id FROM sources WHERE kind = 'ai'`);
|
const ai = await get(`SELECT id FROM sources WHERE kind = 'ai'`);
|
||||||
if (!ai) return;
|
if (!ai) return;
|
||||||
await run(`UPDATE sources SET enabled = ?, api_key_hash = ? WHERE id = ?`, [
|
await run(`UPDATE sources SET enabled = ?, api_key_hash = ?, api_key = ? WHERE id = ?`, [
|
||||||
Boolean(key),
|
Boolean(key),
|
||||||
keyHash(key),
|
keyHash(key),
|
||||||
|
key,
|
||||||
ai.id,
|
ai.id,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
<%- include('partials/head') %>
|
||||||
|
<%- include('partials/topbar', { active: 'admin' }) %>
|
||||||
|
|
||||||
|
<div class="admin-shell">
|
||||||
|
<nav class="admin-nav" aria-label="管理分区">
|
||||||
|
<h4>管理台 · <%= user.username %></h4>
|
||||||
|
<a href="/admin" style="display:flex;align-items:center;gap:10px;text-decoration:none;color:inherit;border-radius:8px;padding:10px 12px;font-size:14px;font-weight:600;background:var(--ink);color:var(--paper)">← 返回管理台</a>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<main class="admin-main">
|
||||||
|
<p class="anno">// edit · entry #<%= entry.id %> · 保存后即时生效</p>
|
||||||
|
<h1>编辑条目</h1>
|
||||||
|
<p class="admin-desc"><a href="/entries/<%= entry.slug %>" target="_blank">预览:<%= entry.title %></a></p>
|
||||||
|
<form id="entry-edit-form" method="post" action="/admin/entries/<%= entry.id %>/edit" style="margin-top:22px;display:grid;gap:18px;max-width:820px">
|
||||||
|
<div class="panel-box">
|
||||||
|
<h3>基础信息</h3>
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="field" style="flex:2;margin-top:0"><label for="f-title">标题</label>
|
||||||
|
<input class="input" id="f-title" name="title" value="<%= entry.title %>" required /></div>
|
||||||
|
<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%">
|
||||||
|
<% for (const t of types) { %>
|
||||||
|
<option value="<%= t %>" <%= entry.type === t ? 'selected' : '' %>><%= t %></option>
|
||||||
|
<% } %>
|
||||||
|
</select></div>
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="field" style="flex:2"><label for="f-url">链接 URL</label>
|
||||||
|
<input class="input" id="f-url" name="url" type="url" value="<%= entry.url %>" required /></div>
|
||||||
|
<div class="field" style="flex:1"><label for="f-license">License</label>
|
||||||
|
<input class="input" id="f-license" name="license" value="<%= entry.license || '' %>" /></div>
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="field" style="flex:1"><label for="f-stars">Stars</label>
|
||||||
|
<input class="input" id="f-stars" name="stars" type="number" min="0" value="<%= entry.stars || 0 %>" /></div>
|
||||||
|
<div class="field" style="flex:1"><label for="f-status">状态</label>
|
||||||
|
<select class="select" id="f-status" name="status" style="width:100%">
|
||||||
|
<% for (const s of ['active','greyed','pending']) { %>
|
||||||
|
<option value="<%= s %>" <%= entry.status === s ? 'selected' : '' %>><%= s %></option>
|
||||||
|
<% } %>
|
||||||
|
</select></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel-box">
|
||||||
|
<h3>简介(≤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">
|
||||||
|
<h3>内容描述(Markdown)</h3>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel-box">
|
||||||
|
<h3>标签</h3>
|
||||||
|
<p style="font-size:13px;color:var(--ink-dim);margin-bottom:10px">勾选已有的标签;也可以把新标签名填到下方输入框(逗号分隔)</p>
|
||||||
|
<div style="border:1.5px solid var(--ink-faint);border-radius:8px;padding:14px;max-height:340px;overflow:auto">
|
||||||
|
<ul class="tag-tree" style="padding:0;margin:0">
|
||||||
|
<% for (const root of tagTreeAdmin) { %><%- include('partials/tag-check', { node: root, entry }) %><% } %>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="field" style="margin-top:12px"><label for="f-newtags">新增标签</label>
|
||||||
|
<input class="input" id="f-newtags" name="new_tags" placeholder="例如:自托管, Docker 就绪(留空则不改动)" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div 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">
|
||||||
|
<button class="btn btn-primary" type="submit">保存修改</button>
|
||||||
|
<a class="btn" href="/admin?section=sec-content">取消</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
window.__ADMIN_ENTRY_ID__ = <%= entry.id %>;
|
||||||
|
(function () {
|
||||||
|
var form = document.getElementById("entry-edit-form");
|
||||||
|
if (!form) return;
|
||||||
|
form.addEventListener("submit", function (e) {
|
||||||
|
var box = document.querySelector('input[name="tags"]');
|
||||||
|
if (!box) {
|
||||||
|
var hidden = document.createElement("input");
|
||||||
|
hidden.type = "hidden";
|
||||||
|
hidden.name = "tags";
|
||||||
|
hidden.value = "";
|
||||||
|
form.appendChild(hidden);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<%- include('partials/footer-bar') %>
|
||||||
+133
-33
@@ -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>
|
||||||
@@ -37,6 +79,12 @@
|
|||||||
<td class="mono"><%= fmtDate(e.updated_at) %></td>
|
<td class="mono"><%= fmtDate(e.updated_at) %></td>
|
||||||
<td>
|
<td>
|
||||||
<div class="row-actions">
|
<div class="row-actions">
|
||||||
|
<a class="btn btn-sm" href="/admin/entries/<%= e.id %>/edit">编辑</a>
|
||||||
|
<% if (e.status === 'active') { %>
|
||||||
|
<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 { %>
|
||||||
@@ -51,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>
|
||||||
@@ -63,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>
|
||||||
@@ -97,34 +152,74 @@
|
|||||||
</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>
|
||||||
|
|
||||||
<% for (const s of sources) { %>
|
<% for (const s of sources) { if (s.kind === 'human') continue; %>
|
||||||
<div class="panel-box" style="margin-top:20px">
|
<div class="panel-box" style="margin-top:20px">
|
||||||
<h3 style="display:flex;align-items:center;gap:10px">
|
<h3 style="display:flex;align-items:center;gap:10px">
|
||||||
<span class="dot <%= s.enabled ? '' : 'dot-off' %>"></span> <%= s.name %>
|
<span class="dot <%= s.enabled ? '' : 'dot-off' %>"></span> <%= s.name %>
|
||||||
<span class="mono" style="font-weight:400;font-size:12px;color:var(--ink-dim)">kind: <%= s.kind %> · 已收录 <%= Number(s.entry_count) %> 条</span>
|
<span class="mono" style="font-weight:400;font-size:12px;color:var(--ink-dim)">kind: <%= s.kind %> · 已收录 <%= Number(s.entry_count) %> 条</span>
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
<% if (s.kind === 'human') { %>
|
<% if (s.kind === 'ai') { %>
|
||||||
<p style="font-size:13.5px;color:var(--ink-dim)">管理员与编辑在条目接口直接写入,始终可用,改动写入审计日志。</p>
|
|
||||||
<% } else if (s.kind === 'ai') { %>
|
|
||||||
<div class="switchline">
|
<div class="switchline">
|
||||||
AI 录入接口启用
|
AI 录入接口启用
|
||||||
<span class="mono"><%= s.enabled ? 'ON' : 'OFF' %></span>
|
<span class="mono"><%= s.enabled ? 'ON' : 'OFF' %></span>
|
||||||
</div>
|
</div>
|
||||||
<form id="ai-key-form">
|
<form id="ai-key-form" style="margin-top:12px">
|
||||||
<p style="font-size:13px;color:var(--ink-dim);margin-bottom:6px">设置新的 X-Ingest-Key(留空 = 关闭通道)</p>
|
<p style="font-size:13px;color:var(--ink-dim);margin-bottom:6px">设置新的 X-Ingest-Key(留空 = 关闭通道)</p>
|
||||||
<div style="display:flex;gap:10px;max-width:420px">
|
<div style="display:flex;gap:10px;align-items:center;flex-wrap:nowrap">
|
||||||
<input class="input" name="key" type="text" placeholder="sk-…" />
|
<div class="ai-key-field">
|
||||||
<button class="btn btn-sm" type="submit">保存密钥</button>
|
<input class="input" name="key" type="password" value="<%= s.api_key || '' %>" placeholder="sk-…" autocomplete="off" />
|
||||||
|
<button type="button" class="eye-toggle" data-eye-toggle aria-label="显示密钥">
|
||||||
|
<svg class="eye-open" viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
|
||||||
|
<svg class="eye-closed" viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"/><line x1="1" y1="1" x2="23" y2="23"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-sm" type="submit" style="flex:none">保存密钥</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
<p class="mono" style="margin-top:12px;font-size:12px;color:var(--ink-dim)">
|
<p class="mono" style="margin-top:12px;font-size:12px;color:var(--ink-dim)">
|
||||||
@@ -134,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>
|
||||||
@@ -163,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>
|
||||||
@@ -185,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
@@ -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
@@ -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 <key></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
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<li>
|
||||||
|
<label class="checkline" style="padding:3px 0">
|
||||||
|
<input type="checkbox" name="tags" value="<%= node.name %>" <%= (entry.tags || []).includes(node.name) ? 'checked' : '' %> />
|
||||||
|
<span><%= node.name %></span>
|
||||||
|
<% if (node.count !== undefined) { %><span class="cnt"><%= node.count %></span><% } %>
|
||||||
|
</label>
|
||||||
|
<% if (node.children && node.children.length) { %>
|
||||||
|
<ul class="tag-tree" style="padding-left:18px;margin:0">
|
||||||
|
<% for (const child of node.children) { %><%- include('tag-check', { node: child, entry }) %><% } %>
|
||||||
|
</ul>
|
||||||
|
<% } %>
|
||||||
|
</li>
|
||||||
@@ -1,23 +1,35 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
# Awesome Index 启动脚本
|
# 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 "停止旧容器..."
|
||||||
docker compose down --remove-orphans 2>/dev/null || true
|
docker stop awesome-index 2>/dev/null || true
|
||||||
|
docker rm awesome-index 2>/dev/null || true
|
||||||
|
|
||||||
# 启动新容器
|
# 用新镜像启动容器
|
||||||
echo "启动 Awesome Index..."
|
echo "启动 Awesome Index..."
|
||||||
docker compose up -d
|
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 "等待服务就绪..."
|
||||||
@@ -30,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 "✗ 服务启动失败,请检查日志: docker compose logs"
|
echo "✗ 服务启动失败,请检查日志: docker logs awesome-index"
|
||||||
exit 1
|
exit 1
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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
@@ -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("; ");
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user