Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,11 @@ SMTP_PASS=your-password
SMTP_FROM="即页 <noreply@example.com>"
APP_URL=http://localhost:8858

# --- 反馈邮件接收地址(可选)---
# 用户通过「问题反馈」提交的内容会发送到此邮箱。
# 留空时按优先级回退:首个 admin 用户邮箱 → SMTP_FROM;均无则仅写库不发信。
# FEEDBACK_EMAIL=you@example.com

# --- 开放注册(可选) ---
# 设为 true 开放用户自助注册(需配合 SMTP 配置使用);不设或设为其他值则仅允许管理员创建用户。
# ALLOW_REGISTRATION=true
Expand Down
4 changes: 3 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 端点
Expand Down Expand Up @@ -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`。

---

Expand Down
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,7 @@ API 和 MCP 端点支持三种认证方式:
| `SMTP_PASS` | 否 | SMTP 登录密码或授权码 |
| `SMTP_FROM` | 否 | 发件人地址(如 `"即页 <user@example.com>"`) |
| `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 |
Expand Down Expand Up @@ -372,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 集成
Expand Down
1 change: 1 addition & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:-}
Expand Down
28 changes: 28 additions & 0 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -450,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": <feedbackId> }`。

---

## MCP 端点(`/mcp`)

独立的 MCP Streamable HTTP 端点。**全局 `MCP_TOKEN` 或用户级 API Token 任一即可挂载**(未配置任何 Token 时 `/mcp` 禁用)。
Expand Down Expand Up @@ -483,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,仅限服务端保存 |
1 change: 1 addition & 0 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ const browserGlobals = {
Event: 'readonly',
CustomEvent: 'readonly',
FileReader: 'readonly',
Image: 'readonly', // new Image() 用于二维码等图片可达性预检
FormData: 'readonly',
Blob: 'readonly',
DragEvent: 'readonly',
Expand Down
33 changes: 33 additions & 0 deletions migrations/028_feedback.js
Original file line number Diff line number Diff line change
@@ -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'))
)`);
}
};
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@code2rich/jpage",
"version": "1.6.6",
"version": "1.6.7",
"description": "即页 — 零配置 HTML/Markdown 即时预览与分享工具",
"main": "server.js",
"bin": {
Expand Down
106 changes: 106 additions & 0 deletions public/css/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -5993,6 +5993,112 @@ 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;
}
/* ============================================================
创作市场 2026:策展式首页
真实模板预览优先,用层级与留白替代堆叠式筛选面板。
Expand Down
57 changes: 54 additions & 3 deletions public/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
<link rel="icon" type="image/png" sizes="32x32" href="/jpage_logo/favicons/favicon-32.png">
<link rel="icon" type="image/png" sizes="16x16" href="/jpage_logo/favicons/favicon-16.png">
<link rel="apple-touch-icon" sizes="180x180" href="/jpage_logo/jpage-app-icon-180.png">
<link rel="stylesheet" href="/css/style.css?v=1.6.6">
<link rel="stylesheet" href="/css/style.css?v=1.6.7">
</head>
<body>
<div id="app"></div>
Expand Down Expand Up @@ -349,7 +349,7 @@ <h2>准备好让内容流动起来了吗?</h2>

<!-- 页脚 -->
<footer class="landing-footer">
<p>&copy; 2026 即页 · <a href="https://github.com/code2rich/jpage" target="_blank">GitHub</a> · <a href="/privacy">隐私政策</a> · <a href="/terms">服务条款</a> · <a href="mailto:code2rich@qq.com">联系</a></p>
<p>&copy; 2026 即页 · <a href="https://github.com/code2rich/jpage" target="_blank">GitHub</a> · <a href="/privacy">隐私政策</a> · <a href="/terms">服务条款</a> · <a href="mailto:code2rich@qq.com">联系</a> · <a href="javascript:void(0)" id="landing-feedback-link">问题反馈</a></p>
<!-- {{ICP_BEIAN_PLACEHOLDER}} -->
</footer>
</div>
Expand Down Expand Up @@ -534,6 +534,10 @@ <h1>即页</h1>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M18 20V10"/><path d="M12 20V4"/><path d="M6 20v-6"/></svg>
用量统计
</button>
<button type="button" class="settings-menu-item" role="menuitem" id="menu-item-feedback">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
问题反馈
</button>
<div class="settings-menu-divider admin-only" role="separator"></div>
<button type="button" class="settings-menu-item admin-only" role="menuitem" id="menu-item-market-admin">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M3 9l1-5h16l1 5"/><path d="M4 9v11a1 1 0 0 0 1 1h14a1 1 0 0 0 1-1V9"/><line x1="9" y1="13" x2="15" y2="13"/></svg>
Expand Down Expand Up @@ -1037,6 +1041,53 @@ <h2 id="usage-modal-title">用量统计</h2>
</div>
</div>

<!-- 问题反馈弹窗(公开,含未登录访客) -->
<div class="modal-backdrop" id="feedback-modal" hidden aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="feedback-modal-title">
<div class="modal-panel modal-panel-sm">
<div class="modal-header">
<h2 id="feedback-modal-title">问题反馈</h2>
<button type="button" class="btn btn-small modal-close" id="feedback-modal-close" aria-label="关闭">×</button>
</div>
<div class="modal-body">
<div class="feedback-category-group" id="feedback-category-group" role="radiogroup" aria-label="反馈类型">
<button type="button" class="feedback-category-chip active" data-category="feature" role="radio" aria-checked="true">💡 功能建议</button>
<button type="button" class="feedback-category-chip" data-category="bug" role="radio" aria-checked="false">🐛 问题反馈</button>
<button type="button" class="feedback-category-chip" data-category="other" role="radio" aria-checked="false">💬 其他</button>
</div>
<label class="login-field">
<span>反馈内容 <small>*</small></span>
<textarea class="feedback-content-input" id="feedback-content" maxlength="5000" rows="5" placeholder="想提什么建议或遇到了什么问题?请详细描述,我们会认真阅读。" aria-describedby="feedback-content-count"></textarea>
<div class="feedback-content-foot">
<span class="feedback-content-count" id="feedback-content-count">0 / 5000</span>
</div>
</label>
<div class="feedback-row">
<label class="login-field">
<span>称呼 <small>(可选)</small></span>
<input type="text" id="feedback-name" maxlength="100" autocomplete="off" placeholder="怎么称呼你">
</label>
<label class="login-field">
<span>联系方式 <small>(可选)</small></span>
<input type="text" id="feedback-contact" maxlength="100" autocomplete="off" placeholder="邮箱 / 微信(方便我们回复)">
</label>
</div>
<div class="login-error" id="feedback-error" role="alert" hidden></div>
<!-- 公众号展示区 -->
<div class="feedback-wechat">
<div class="feedback-wechat-info">
<span class="feedback-wechat-label">关注公众号,获取更多更新与使用技巧</span>
<span class="feedback-wechat-id">公众号:code2rich</span>
</div>
<img class="feedback-wechat-qr" id="feedback-wechat-qr" src="/jpage_logo/wechat-code2rich.png" alt="公众号 code2rich 二维码" hidden>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-small" id="feedback-modal-cancel">取消</button>
<button type="button" class="btn btn-primary" id="feedback-modal-submit">提交反馈</button>
</div>
</div>
</div>

<div class="modal-backdrop" id="dialog-modal" hidden aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="dialog-modal-title">
<div class="modal-panel modal-panel-sm">
<div class="modal-header">
Expand Down Expand Up @@ -1210,6 +1261,6 @@ <h2 id="share-settings-title">分享设置</h2>
</div>
</div>

<script type="module" src="/js/app.js?v=1.6.6"></script>
<script type="module" src="/js/app.js?v=1.6.7"></script>
</body>
</html>
10 changes: 5 additions & 5 deletions public/js/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,11 +34,11 @@ function navigate(path) {
// 动态 import 带字面量版本串:发版时连同 index.html 里 app.js 的 ?v= 一起改,
// 让浏览器在开发模式(直接服务源文件、无内容哈希文件名)下也重新拉取各页面 chunk,
// 绕过 immutable 长缓存。字面量(非模板)能被 esbuild 正确分割。
async function loadHome() { const m = await import('./pages/home.js?v=1.6.6'); return m.renderHome; }
async function loadLogin() { const m = await import('./pages/login.js?v=1.6.6'); return m.renderLogin; }
async function loadLanding() { const m = await import('./pages/landing.js?v=1.6.6'); return m.renderLanding; }
async function loadPreview() { const m = await import('./pages/preview.js?v=1.6.6'); return m.renderPreview; }
async function loadMarket() { const m = await import('./pages/market.js?v=1.6.6'); return m.renderMarket; }
async function loadHome() { const m = await import('./pages/home.js?v=1.6.7'); return m.renderHome; }
async function loadLogin() { const m = await import('./pages/login.js?v=1.6.7'); return m.renderLogin; }
async function loadLanding() { const m = await import('./pages/landing.js?v=1.6.7'); return m.renderLanding; }
async function loadPreview() { const m = await import('./pages/preview.js?v=1.6.7'); return m.renderPreview; }
async function loadMarket() { const m = await import('./pages/market.js?v=1.6.7'); return m.renderMarket; }

function route() {
const hash = location.hash.replace('#', '') || '/';
Expand Down
Loading
Loading