feat: 全局反馈系统 + 各模块底部统一动态提示
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful

- 新增 components/RotatingFooterHint:统一文案+蓝色脉冲,4 秒轮换
- 新增 components/FeedbackFab:右下角悬浮按钮(渐变 + 心形信封 + 黄色脉冲点),
  点击打开 4 步引导式弹窗
    Step 1 选类型(💡新维度 / 🐛bug / 🎨界面 / 📝其他)
    Step 2 描述需求 + 选当前板块(chip)
    Step 3 留联系方式(可选)+ 提交概览
    Step 4 ❤️ 成功页(弹簧 √ 动画)
  顶部 spring 进度条,底部上一步/下一步,下拉手柄,背景点击或 X 关闭
- 后端 routes/feedback:bi_user_feedback 表(自动建表,含 status 字段)
  POST /api/feedback/submit + GET /api/feedback/list
- Shell 全局挂载 FeedbackFab,自动从 hash 检测当前模块
- 各模块底部追加 RotatingFooterHint:
  AssetsModule / MileageModule / SchedulingModule / EleImportPage
  HydrogenOverview / HydrogenDaily / ElectricOverview / ElectricDaily
  (HydrogenOverview 旧的内嵌实现已替换为共享组件)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
kkfluous
2026-04-30 13:50:39 +08:00
parent 08f21b7e24
commit e8f1604c11
13 changed files with 486 additions and 36 deletions

View File

@@ -0,0 +1,77 @@
import { Hono } from 'hono';
import type { ResultSetHeader, RowDataPacket } from 'mysql2';
import pool from '../../db.js';
import type { AuthUser } from '../../auth/types.js';
const app = new Hono();
const CREATE_TABLE_SQL = `
CREATE TABLE IF NOT EXISTS bi_user_feedback (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
type ENUM('dimension','bug','ux','other') NOT NULL DEFAULT 'other',
module VARCHAR(64) NULL,
content TEXT NOT NULL,
contact VARCHAR(200) NULL,
user_id VARCHAR(64) NULL,
user_name VARCHAR(128) NULL,
user_agent VARCHAR(512) NULL,
status ENUM('open','in_progress','done','rejected') NOT NULL DEFAULT 'open',
created_at DATETIME NOT NULL,
KEY idx_created_at (created_at),
KEY idx_type (type),
KEY idx_status (status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
`;
let ensured = false;
async function ensureTable(): Promise<void> {
if (ensured) return;
await pool.query(CREATE_TABLE_SQL);
ensured = true;
}
const VALID_TYPES = new Set(['dimension', 'bug', 'ux', 'other']);
app.post('/submit', async (c) => {
await ensureTable();
const body = await c.req.json().catch(() => ({})) as {
type?: string; module?: string | null; content?: string;
contact?: string | null; userAgent?: string;
};
const type = (body.type || '').trim();
const content = (body.content || '').trim();
if (!VALID_TYPES.has(type)) {
return c.json({ ok: false, message: '类型不合法' }, 400);
}
if (!content || content.length > 2000) {
return c.json({ ok: false, message: '内容长度需在 1-2000 字之间' }, 400);
}
const user = (c as { get?: (k: string) => unknown }).get?.('user') as AuthUser | undefined;
const moduleVal = (body.module || '').slice(0, 64) || null;
const contact = (body.contact || '').slice(0, 200) || null;
const userAgent = (body.userAgent || '').slice(0, 512) || null;
const [r] = await pool.query<ResultSetHeader>(
`INSERT INTO bi_user_feedback (type, module, content, contact, user_id, user_name, user_agent, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, NOW())`,
[type, moduleVal, content, contact, user?.userId || null, user?.userName || null, userAgent],
);
return c.json({ ok: true, id: r.insertId });
});
// GET /api/feedback/list — 简易列表(按时间倒序,最多 200 条)
app.get('/list', async (c) => {
await ensureTable();
const limit = Math.min(200, Math.max(1, Number(c.req.query('limit')) || 50));
const [rows] = await pool.query<RowDataPacket[]>(
`SELECT id, type, module, content, contact, user_name, status, created_at
FROM bi_user_feedback
ORDER BY created_at DESC
LIMIT ?`,
[limit],
);
return c.json({ items: rows });
});
export default app;