From a850b2ec988ef10cbd9ba81c8c5ea9d44bf0e4a9 Mon Sep 17 00:00:00 2001 From: code2rich Date: Wed, 29 Jul 2026 23:23:13 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20add=20user=20feedback=20(=E9=97=AE?= =?UTF-8?q?=E9=A2=98=E5=8F=8D=E9=A6=88)=20with=20email=20notification?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增免登录公开接口 POST /api/feedback(IP 限流 10次/15min) - 反馈写入 feedback 表(migration 028)并发送邮件给管理员 - 收件邮箱优先级:FEEDBACK_EMAIL → 首个 admin 邮箱 → SMTP_FROM - 落地页页脚 + 工作台设置菜单新增「问题反馈」入口 - 反馈弹窗含类型选择/字数统计/公众号 code2rich 展示区 - 新增环境变量 FEEDBACK_EMAIL(同步 .env.example / docker-compose.yml) - 集成测试覆盖:校验、写库、category 默认、邮箱回退优先级 - 版本号 1.6.6 → 1.6.7 --- .env.example | 5 + AGENTS.md | 12 ++- README.md | 9 ++ docker-compose.yml | 1 + docs/api.md | 37 ++++++- eslint.config.mjs | 1 + migrations/028_feedback.js | 33 ++++++ package.json | 2 +- public/css/style.css | 107 ++++++++++++++++++++ public/index.html | 57 ++++++++++- public/js/app.js | 10 +- public/js/components/feedback-modal.js | 133 +++++++++++++++++++++++++ public/js/pages/home.js | 73 +++++++++----- public/js/pages/landing.js | 11 ++ routes/feedback.js | 129 ++++++++++++++++++++++++ server.js | 2 + test/integration/feedback.test.js | 97 ++++++++++++++++++ 17 files changed, 677 insertions(+), 42 deletions(-) create mode 100644 migrations/028_feedback.js create mode 100644 public/js/components/feedback-modal.js create mode 100644 routes/feedback.js create mode 100644 test/integration/feedback.test.js diff --git a/.env.example b/.env.example index d32f014..e78bbd8 100644 --- a/.env.example +++ b/.env.example @@ -56,6 +56,11 @@ SMTP_PASS=your-password SMTP_FROM="即页 " APP_URL=http://localhost:8858 +# --- 反馈邮件接收地址(可选)--- +# 用户通过「问题反馈」提交的内容会发送到此邮箱。 +# 留空时按优先级回退:首个 admin 用户邮箱 → SMTP_FROM;均无则仅写库不发信。 +# FEEDBACK_EMAIL=you@example.com + # --- 开放注册(可选) --- # 设为 true 开放用户自助注册(需配合 SMTP 配置使用);不设或设为其他值则仅允许管理员创建用户。 # ALLOW_REGISTRATION=true diff --git a/AGENTS.md b/AGENTS.md index 18d2d94..0e37c1f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -244,7 +244,7 @@ CI(`.github/workflows/ci.yml`)在 Node 20/22 矩阵上执行 `npm run lint` - `_migrations(id, name UNIQUE, applied_at)` - `files(id, original_name, stored_name, file_type, size, created_at, updated_at, is_public, uploaded_by, share_key, category_id, is_bundle, entry_path, view_count, template_id, share_expires_at, share_password_hash, upload_source, source_asset_id, created_from)` -- `file_versions(id, file_id, version, stored_name, size, created_at, uploaded_by, upload_source, performed_by)` +- `file_versions(id, file_id, version, stored_name, size, created_at, uploaded_by, upload_source, performed_by, is_bundle, entry_path, file_type)` - `users(id, username UNIQUE, email, email_verified, password_hash, role, created_at, total_storage_bytes, api_calls_count, storage_quota_bytes)`(email 有 `WHERE email IS NOT NULL` 唯一索引) - `api_calls(id, user_id, source, action, method, path, status, created_at)` - `tokens(id, user_id, name, token_hash UNIQUE, token_prefix, token_enc, last_used_at, created_at)` @@ -259,6 +259,7 @@ CI(`.github/workflows/ci.yml`)在 Node 20/22 矩阵上执行 `npm run lint` - `template_market_categories(id, slug UNIQUE, name, description, sort_order, is_enabled, created_at, updated_at)` - `content_template_installs(id, template_id, user_id, file_id, source_version, created_at, UNIQUE(template_id, user_id))` - `starred_templates(user_id, template_id, created_at)` +- `feedback(id, name, contact, content, category, user_id, ip, email_sent, status, created_at)` - `file_contents_fts` — FTS5 虚拟表 `(content, file_id UNINDEXED, tokenize='porter unicode61')` ### REST API @@ -269,8 +270,8 @@ CI(`.github/workflows/ci.yml`)在 Node 20/22 矩阵上执行 `npm run lint` - **用户管理(admin)**:`/api/users` - **个人用量**:`/api/users/me/usage` - **API Token**:`/api/tokens`、`/api/tokens/:id/reveal` -- **文件管理**:`/api/files`、`/api/files/search`、`/api/files/upload`、`/api/files/upload-json`、`/api/files/upload-zip-base64`、`/api/files/batch`、`/api/files/:id`、`/api/files/:id/content`、`/api/files/:id/render`、`/api/files/:id/download`、`/api/files/:id/asset/*`、`/api/files/:id/overwrite`、`/api/files/:id/overwrite-json`、`/api/files/:id/stats` -- **版本历史**:`/api/files/:id/versions`、versions content/render/restore/delete +- **文件管理**:`/api/files`、`/api/files/search`、`/api/files/upload`、`/api/files/upload-json`、`/api/files/upload-zip-base64`、`/api/files/batch`、`/api/files/:id`、`/api/files/:id/content`、`/api/files/:id/render`、`/api/files/:id/download`、`/api/files/:id/asset/*`、`/api/files/:id/overwrite`、`/api/files/:id/overwrite-json`、`/api/files/:id/overwrite-zip-base64`、`/api/files/:id/stats` +- **版本历史**:`/api/files/:id/versions`、versions content/render/download/restore/delete(bundle 历史按目录保存) - **分享设置**:`/api/files/:id/share`(PUT 别名/过期/密码)、`/api/files/:id/share/regenerate` - **标签**:`/api/tags`、`/api/files/:id/tags` - **收藏**:`/api/files/:id/star` @@ -281,6 +282,7 @@ CI(`.github/workflows/ci.yml`)在 Node 20/22 矩阵上执行 `npm run lint` - **Skills**:`/api/skills`、`/api/skills/:name`、`/api/skills/:name/download` - **短链**:`/s/:key`(文件)、`/t/:key`(市场模板) - **MCP 配置**:`/api/mcp/config` +- **问题反馈(公开)**:`POST /api/feedback` - **CLI 指南**:`/api/cli/guide` ### MCP 端点 @@ -346,7 +348,7 @@ CLI 与 MCP 共用同一套 REST API,是对等的两个客户端入口。 - `_migrations(name UNIQUE)` 记录已应用的 migration。 - `migrations/` 下文件按文件名排序执行;每个文件导出 `{ name, async up(db, { dbRun, dbGet, dbAll }) }`。 - **新增 migration 规则**: - 1. 命名格式 `{序号}_{描述}.js`,序号接续当前最大值(当前已到 022)。 + 1. 命名格式 `{序号}_{描述}.js`,序号接续当前最大值(当前已到 027)。 2. 新建表用 `CREATE TABLE IF NOT EXISTS`。 3. 新增列必须幂等:先 `PRAGMA table_info` 检查是否存在,再 `ALTER TABLE ADD COLUMN`。 4. SQLite `ALTER TABLE ADD COLUMN` 不支持非恒定默认值;需要默认值时先加列(无默认或恒定默认),再 `UPDATE` 回填。 @@ -374,7 +376,7 @@ CLI 与 MCP 共用同一套 REST API,是对等的两个客户端入口。 - 版本号以 `package.json` 为准。 - 推荐发版:`npm version patch|minor|major` → `git push origin main` → `git push origin vX.Y.Z`,由 `.github/workflows/release.yml` 自动执行 lint、test、build、校验 tag、发布到 npm。 - Docker:`Dockerfile` 多阶段构建(builder / frontend / runner),`EXPOSE 8858`;`docker-compose.yml` 映射 host 8858 → container 8858,挂载 `./data:/app/data`。 -- 环境变量完整清单(按需配置):`PORT`、`NODE_ENV`、`JPAGE_DATA_DIR`、`ADMIN_USER`、`ADMIN_PASSWORD`、`SESSION_SECRET`、`COOKIE_SECURE`、`MCP_TOKEN`、`MCP_IP`、`MCP_PROTOCOL`、`TOKEN_ENCRYPTION_KEY`、`SMTP_HOST`、`SMTP_PORT`、`SMTP_SECURE`、`SMTP_USER`、`SMTP_PASS`、`SMTP_FROM`、`APP_URL`、`ALLOW_REGISTRATION`、`GITHUB_CLIENT_ID`、`GITHUB_CLIENT_SECRET`、`GOOGLE_CLIENT_ID`、`GOOGLE_CLIENT_SECRET`、`WECHAT_OPEN_APP_ID`、`WECHAT_OPEN_APP_SECRET`、`MAX_FILE_VERSIONS`、`BACKUP_CRON`、`BACKUP_DIR`、`ICP_BEIAN`。 +- 环境变量完整清单(按需配置):`PORT`、`NODE_ENV`、`JPAGE_DATA_DIR`、`ADMIN_USER`、`ADMIN_PASSWORD`、`SESSION_SECRET`、`COOKIE_SECURE`、`MCP_TOKEN`、`MCP_IP`、`MCP_PROTOCOL`、`TOKEN_ENCRYPTION_KEY`、`SMTP_HOST`、`SMTP_PORT`、`SMTP_SECURE`、`SMTP_USER`、`SMTP_PASS`、`SMTP_FROM`、`APP_URL`、`FEEDBACK_EMAIL`、`ALLOW_REGISTRATION`、`GITHUB_CLIENT_ID`、`GITHUB_CLIENT_SECRET`、`GOOGLE_CLIENT_ID`、`GOOGLE_CLIENT_SECRET`、`WECHAT_OPEN_APP_ID`、`WECHAT_OPEN_APP_SECRET`、`MAX_FILE_VERSIONS`、`BACKUP_CRON`、`BACKUP_DIR`、`ICP_BEIAN`。 --- diff --git a/README.md b/README.md index fdad349..2f4c747 100644 --- a/README.md +++ b/README.md @@ -161,6 +161,7 @@ API 和 MCP 端点支持三种认证方式: | `SMTP_PASS` | 否 | SMTP 登录密码或授权码 | | `SMTP_FROM` | 否 | 发件人地址(如 `"即页 "`) | | `APP_URL` | 否 | 应用外部访问地址,用于拼接验证链接(如 `https://jpage.cn`) | +| `FEEDBACK_EMAIL` | 否 | 问题反馈邮件接收地址;留空时回退到首个管理员邮箱 → `SMTP_FROM` | | `GOOGLE_CLIENT_ID` | 否 | Google Web 应用 OAuth Client ID;与 `GOOGLE_CLIENT_SECRET` 同时配置后启用 Google 登录 | | `GOOGLE_CLIENT_SECRET` | 否 | Google Web 应用 OAuth Client Secret;仅保存在服务端环境变量中 | | `GOOGLE_HTTP_TIMEOUT_MS` | 否 | Google Token 与 OIDC 公钥请求超时,范围 1000-60000 毫秒,默认 10000 | @@ -312,8 +313,10 @@ jpage/ | `/api/files/:id/download` | GET | 流式下载文件(Bundle 以 ZIP 下载) | | `/api/files/:id/asset/*` | GET | Bundle 资源文件访问 | | `/api/files/:id/overwrite` | POST | 覆盖上传(自动版本备份) | +| `/api/files/:id/overwrite-zip-base64` | POST | base64 bundle ZIP 显式覆盖 | | `/api/files/:id/versions` | GET | 版本历史列表 | | `/api/files/:id/versions/:ver/restore` | POST | 恢复到指定版本 | +| `/api/files/:id/versions/:ver/download` | GET | 下载历史版本(Bundle 以 ZIP 下载) | | `/api/files/:id/stats` | GET | 访问统计(viewCount/daily7/daily30) | | `/s/:key` | GET | 短链接渲染页面 | @@ -370,6 +373,12 @@ jpage/ | `/api/skills/:name` | GET | skill 详情 | | `/api/skills/:name/download` | GET | ZIP 下载整个 skill 目录 | +### 问题反馈 + +| 端点 | 方法 | 说明 | +|---|---|---| +| `/api/feedback` | POST | 提交问题/功能建议(免登录,写入数据库并邮件通知管理员) | + 完整 API 文档见 [docs/api.md](docs/api.md)。 ## MCP / AI 集成 diff --git a/docker-compose.yml b/docker-compose.yml index 9a1072e..73a3bb1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -25,6 +25,7 @@ services: - SMTP_PASS=${SMTP_PASS:-} - SMTP_FROM=${SMTP_FROM:-} - APP_URL=${APP_URL:-} + - FEEDBACK_EMAIL=${FEEDBACK_EMAIL:-} - ALLOW_REGISTRATION=${ALLOW_REGISTRATION:-false} - GITHUB_CLIENT_ID=${GITHUB_CLIENT_ID:-} - GITHUB_CLIENT_SECRET=${GITHUB_CLIENT_SECRET:-} diff --git a/docs/api.md b/docs/api.md index acb67bb..309d1a3 100644 --- a/docs/api.md +++ b/docs/api.md @@ -145,7 +145,7 @@ curl -b jpage.sid -X POST http://localhost:8858/api/files/upload \ 返回 `{id, original_name, file_type, size, is_public}`。 -`.zip` 走两种模式:(1) **网站包 Bundle**(含 `index.html` + 资源目录),存为解压目录,`is_bundle=1`;(2) **批量上传**(多个独立 HTML/MD),各自建文件记录。同名文件自动覆盖(旧版本备份到 `file_versions`)。 +`.zip` 走两种模式:(1) **网站包 Bundle**(含 `index.html` + 资源目录),存为解压目录,`is_bundle=1`;(2) **批量上传**(多个独立 HTML/MD),各自建文件记录。同一用户再次上传同名 bundle ZIP 时保留原 file ID / 短链并归档旧目录;batch ZIP 则按包内文件名逐项覆盖并生成各自历史版本。包内不同路径出现相同 basename 时拒绝上传。 ### `POST /api/files/upload-json` — JSON @@ -226,7 +226,11 @@ Bundle 资源文件访问(带路径穿越校验)。 ### `POST /api/files/:id/overwrite` — multipart -预览页专用覆盖上传,自动把旧版本备份到 `file_versions`。 +预览页专用覆盖上传,自动把旧版本备份到 `file_versions`。普通文件只能用相同 HTML/Markdown 类型覆盖;bundle 只能用分类结果仍为 bundle 的 ZIP 覆盖,batch ZIP 不能覆盖单一 ID。 + +### `POST /api/files/:id/overwrite-zip-base64` — JSON + +MCP 使用的 bundle ZIP 显式覆盖端点。Body: `{name, content(base64)}`。目标必须是 bundle,且 ZIP 必须分类为 bundle。 ### `POST /api/files/:id/overwrite-json` — JSON @@ -247,6 +251,7 @@ MCP 使用的 JSON 覆盖上传,自动版本备份。 | `/api/files/:id/versions` | GET | 版本历史列表 | | `/api/files/:id/versions/:ver/content` | GET | 历史版本原文 | | `/api/files/:id/versions/:ver/render` | GET | 渲染历史版本 | +| `/api/files/:id/versions/:ver/download` | GET | 下载历史版本;bundle 动态打包为 ZIP | | `/api/files/:id/versions/:ver/restore` | POST | 恢复到指定版本 | | `/api/files/:id/versions/:ver` | DELETE | 删除指定历史版本 | @@ -445,6 +450,33 @@ curl -b jpage.sid -OJ http://localhost:8858/api/skills/jpage/download --- +## 问题反馈(`/api/feedback`) + +免登录公开接口,用户(含匿名访客)提交问题/功能建议。反馈写入数据库,并向管理员发送邮件通知。 + +### `POST /api/feedback` + +**请求体**(JSON): + +| 字段 | 类型 | 必填 | 说明 | +|---|---|---|---| +| `content` | string | 是 | 反馈正文,≤ 5000 字 | +| `category` | string | 否 | 类型:`feature`(功能建议)/ `bug`(问题反馈)/ `other`(其他),默认 `feature` | +| `name` | string | 否 | 提交者称呼,≤ 100 字 | +| `contact` | string | 否 | 联系方式(邮箱/微信等),≤ 100 字 | + +**限流**:每 IP 15 分钟最多 10 次。**收件邮箱**优先级:`FEEDBACK_EMAIL` → 首个 admin 用户邮箱 → `SMTP_FROM`;均无或 SMTP 未配置时仅写库不发信。 + +```bash +curl -X POST http://localhost:8858/api/feedback \ + -H "Content-Type: application/json" \ + -d '{"content":"希望增加暗色模式","category":"feature","contact":"alice@example.com"}' +``` + +返回 `{ "success": true, "id": }`。 + +--- + ## MCP 端点(`/mcp`) 独立的 MCP Streamable HTTP 端点。**全局 `MCP_TOKEN` 或用户级 API Token 任一即可挂载**(未配置任何 Token 时 `/mcp` 禁用)。 @@ -478,5 +510,6 @@ curl -b jpage.sid -OJ http://localhost:8858/api/skills/jpage/download | `ALLOW_REGISTRATION` | `false` | 设为 `true` 开放用户自助注册 | | `SMTP_HOST` 等 | — | SMTP 配置(`SMTP_HOST/PORT/SECURE/USER/PASS/FROM`),用于邮箱验证 | | `APP_URL` | `http://localhost:8858` | 应用外部访问地址,用于拼接验证链接 | +| `FEEDBACK_EMAIL` | — | 问题反馈邮件接收地址;留空时回退到首个管理员邮箱 → `SMTP_FROM` | | `GOOGLE_CLIENT_ID` | — | Google Web 应用 OAuth Client ID;与 Client Secret 同时配置后启用 Google 登录 | | `GOOGLE_CLIENT_SECRET` | — | Google Web 应用 OAuth Client Secret,仅限服务端保存 | diff --git a/eslint.config.mjs b/eslint.config.mjs index 9e97c9d..f5515c0 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -74,6 +74,7 @@ const browserGlobals = { Event: 'readonly', CustomEvent: 'readonly', FileReader: 'readonly', + Image: 'readonly', // new Image() 用于二维码等图片可达性预检 FormData: 'readonly', Blob: 'readonly', DragEvent: 'readonly', diff --git a/migrations/028_feedback.js b/migrations/028_feedback.js new file mode 100644 index 0000000..9ca2419 --- /dev/null +++ b/migrations/028_feedback.js @@ -0,0 +1,33 @@ +// 用户问题反馈:功能建议 / 问题反馈 / 其他。 +// 反馈同时写库(不丢数据)并发送邮件给管理员;本表仅建结构,发信逻辑在 routes/feedback.js。 +// +// 字段说明: +// name 提交者称呼(可选) +// contact 联系方式:邮箱 / 微信等(可选) +// content 反馈正文(必填) +// category 类型白名单:feature / bug / other(默认 other) +// user_id 已登录用户则记录,匿名为 NULL(与匿名粘贴文件一致) +// ip 提交者 IP(审计与反滥用排查) +// email_sent 邮件是否投递成功:0=未发或失败,1=成功(SMTP 未配置时仍存库,值为 0) +// status 处理状态:new / read(预留管理后台,本期未使用) +// +// 幂等:CREATE TABLE 用 IF NOT EXISTS。 + +module.exports = { + name: 'feedback', + + async up(db, { dbRun }) { + await dbRun(db, `CREATE TABLE IF NOT EXISTS feedback ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT, + contact TEXT, + content TEXT NOT NULL, + category TEXT DEFAULT 'other', + user_id INTEGER, + ip TEXT, + email_sent INTEGER NOT NULL DEFAULT 0, + status TEXT NOT NULL DEFAULT 'new', + created_at TEXT NOT NULL DEFAULT (datetime('now')) + )`); + } +}; diff --git a/package.json b/package.json index ecd688f..a5693e2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@code2rich/jpage", - "version": "1.6.6", + "version": "1.6.7", "description": "即页 — 零配置 HTML/Markdown 即时预览与分享工具", "main": "server.js", "bin": { diff --git a/public/css/style.css b/public/css/style.css index 7550029..b111ec4 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -5993,3 +5993,110 @@ html:not(.light) .btn-danger-soft { margin-top: 8px; line-height: 1.5; } + +/* ---------- 问题反馈弹窗 ---------- */ +.feedback-category-group { + display: flex; + gap: 8px; + flex-wrap: wrap; + margin-bottom: 16px; +} +.feedback-category-chip { + padding: 7px 14px; + border: 1px solid var(--border); + border-radius: 999px; + background: var(--bg); + color: var(--text-secondary); + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: all .15s; + white-space: nowrap; +} +.feedback-category-chip:hover { + border-color: var(--text-secondary); + color: var(--text); +} +.feedback-category-chip.active { + background: var(--primary); + color: #fff; + border-color: var(--primary); +} +.feedback-content-input { + width: 100%; + padding: 10px 12px; + border: 1px solid var(--border); + border-radius: var(--radius); + font-size: 14px; + line-height: 1.6; + background: var(--card); + color: var(--text); + transition: border-color .2s, box-shadow .2s; + resize: vertical; + min-height: 96px; + font-family: inherit; +} +.feedback-content-input:focus { + outline: none; + border-color: var(--primary); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 15%, transparent); +} +.feedback-content-foot { + display: flex; + justify-content: flex-end; + margin-top: 4px; +} +.feedback-content-count { + font-size: 12px; + color: var(--text-secondary); +} +.feedback-row { + display: flex; + gap: 12px; + margin-top: 12px; +} +.feedback-row .login-field { + flex: 1; + min-width: 0; +} +.feedback-wechat { + display: flex; + align-items: center; + gap: 14px; + margin-top: 16px; + padding: 12px 14px; + border: 1px solid var(--border); + border-radius: var(--radius); + background: color-mix(in srgb, var(--card) 94%, var(--bg) 6%); +} +.feedback-wechat-info { + display: flex; + flex-direction: column; + gap: 4px; + min-width: 0; +} +.feedback-wechat-label { + font-size: 13px; + color: var(--text-secondary); +} +.feedback-wechat-id { + font-size: 14px; + font-weight: 600; + color: var(--text); +} +.feedback-wechat-qr { + width: 64px; + height: 64px; + border-radius: 8px; + flex-shrink: 0; + object-fit: cover; +} +@media (max-width: 480px) { + .feedback-row { + flex-direction: column; + } + .feedback-wechat { + flex-direction: column; + text-align: center; + } +} diff --git a/public/index.html b/public/index.html index 7ba0963..90b5bc6 100644 --- a/public/index.html +++ b/public/index.html @@ -9,7 +9,7 @@ - +
@@ -349,7 +349,7 @@

准备好让内容流动起来了吗?

@@ -534,6 +534,10 @@

即页

用量统计 + + + + + + +