All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
- FeedbackAdminPage / EleImportPage 头部加 ← 返回按钮: 优先 history.back(来自 SPA 内跳转),否则 hash=#mileage 兜底回主页 - 反馈入库(created_at / reply_at)改为 DATE_ADD(UTC_TIMESTAMP, INTERVAL 8 HOUR) 不再依赖 MySQL/容器的本地时区设置,固定 CST Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
191 lines
7.6 KiB
TypeScript
191 lines
7.6 KiB
TypeScript
import { Hono } from 'hono';
|
||
import type { ResultSetHeader, RowDataPacket } from 'mysql2';
|
||
import pool from '../../db.js';
|
||
import type { AuthUser } from '../../auth/types.js';
|
||
import { canManageFeedback } from '../../auth/types.js';
|
||
import { uploadFeedbackImage } from './oss.js';
|
||
|
||
const app = new Hono();
|
||
|
||
const MAX_IMAGE_SIZE = 5 * 1024 * 1024; // 5 MB
|
||
const ALLOWED_MIME = new Set(['image/png', 'image/jpeg', 'image/webp', 'image/gif']);
|
||
|
||
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,
|
||
screenshots JSON 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',
|
||
reply_content TEXT NULL,
|
||
reply_user VARCHAR(128) NULL,
|
||
reply_at DATETIME NULL,
|
||
created_at DATETIME NOT NULL,
|
||
KEY idx_created_at (created_at),
|
||
KEY idx_type (type),
|
||
KEY idx_status (status),
|
||
KEY idx_user_id (user_id)
|
||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||
`;
|
||
|
||
let ensured = false;
|
||
async function ensureTable(): Promise<void> {
|
||
if (ensured) return;
|
||
await pool.query(CREATE_TABLE_SQL);
|
||
// 兼容旧表:补齐缺失列
|
||
for (const alter of [
|
||
`ALTER TABLE bi_user_feedback ADD COLUMN screenshots JSON NULL AFTER contact`,
|
||
`ALTER TABLE bi_user_feedback ADD COLUMN reply_content TEXT NULL AFTER status`,
|
||
`ALTER TABLE bi_user_feedback ADD COLUMN reply_user VARCHAR(128) NULL AFTER reply_content`,
|
||
`ALTER TABLE bi_user_feedback ADD COLUMN reply_at DATETIME NULL AFTER reply_user`,
|
||
`ALTER TABLE bi_user_feedback ADD INDEX idx_user_id (user_id)`,
|
||
]) {
|
||
try { await pool.query(alter); } catch { /* 已存在则忽略 */ }
|
||
}
|
||
ensured = true;
|
||
}
|
||
|
||
const VALID_STATUS = new Set(['open', 'in_progress', 'done', 'rejected']);
|
||
|
||
const VALID_TYPES = new Set(['dimension', 'bug', 'ux', 'other']);
|
||
|
||
// 写入时间戳一律用东八区 CST,避免依赖 MySQL/容器时区设置
|
||
const CST_NOW = `DATE_ADD(UTC_TIMESTAMP(), INTERVAL 8 HOUR)`;
|
||
|
||
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; screenshots?: 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 screenshots = Array.isArray(body.screenshots)
|
||
? body.screenshots.filter(s => typeof s === 'string' && /^https?:\/\//.test(s)).slice(0, 6)
|
||
: [];
|
||
|
||
const [r] = await pool.query<ResultSetHeader>(
|
||
`INSERT INTO bi_user_feedback (type, module, content, contact, screenshots, user_id, user_name, user_agent, created_at)
|
||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ${CST_NOW})`,
|
||
[type, moduleVal, content, contact, JSON.stringify(screenshots), user?.userId || null, user?.userName || null, userAgent],
|
||
);
|
||
return c.json({ ok: true, id: r.insertId });
|
||
});
|
||
|
||
// =========================================================
|
||
// POST /api/feedback/upload — 单张截图上传(multipart/form-data, field=file)
|
||
// =========================================================
|
||
app.post('/upload', async (c) => {
|
||
const form = await c.req.formData();
|
||
const file = form.get('file');
|
||
if (!(file instanceof File)) {
|
||
return c.json({ ok: false, message: '未上传文件' }, 400);
|
||
}
|
||
const mime = file.type || 'image/png';
|
||
if (!ALLOWED_MIME.has(mime)) {
|
||
return c.json({ ok: false, message: `不支持的文件类型:${mime}` }, 400);
|
||
}
|
||
if (file.size > MAX_IMAGE_SIZE) {
|
||
return c.json({ ok: false, message: `图片过大(${(file.size / 1024 / 1024).toFixed(1)}MB)`}, 400);
|
||
}
|
||
const buf = Buffer.from(await file.arrayBuffer());
|
||
try {
|
||
const url = await uploadFeedbackImage(file.name || 'screenshot.png', buf, mime);
|
||
return c.json({ ok: true, url });
|
||
} catch (e) {
|
||
console.error('feedback upload error:', e);
|
||
return c.json({ ok: false, message: e instanceof Error ? e.message : '上传失败' }, 500);
|
||
}
|
||
});
|
||
|
||
// GET /api/feedback/mine — 当前用户的反馈历史
|
||
app.get('/mine', async (c) => {
|
||
await ensureTable();
|
||
const user = (c as { get?: (k: string) => unknown }).get?.('user') as AuthUser | undefined;
|
||
if (!user?.userId) return c.json({ items: [] });
|
||
const [rows] = await pool.query<RowDataPacket[]>(
|
||
`SELECT id, type, module, content, contact, screenshots, status,
|
||
reply_content, reply_user, reply_at, created_at
|
||
FROM bi_user_feedback
|
||
WHERE user_id = ?
|
||
ORDER BY created_at DESC
|
||
LIMIT 100`,
|
||
[user.userId],
|
||
);
|
||
return c.json({ items: rows });
|
||
});
|
||
|
||
// GET /api/feedback/list — 管理列表(仅 BI-ADMIN-FEEDBACK / 全量权限)
|
||
app.get('/list', async (c) => {
|
||
await ensureTable();
|
||
const user = (c as { get?: (k: string) => unknown }).get?.('user') as AuthUser | undefined;
|
||
if (!canManageFeedback(user?.roles)) {
|
||
return c.json({ ok: false, message: '无权限' }, 403);
|
||
}
|
||
const limit = Math.min(500, Math.max(1, Number(c.req.query('limit')) || 100));
|
||
const status = c.req.query('status') || '';
|
||
const where: string[] = ['1=1'];
|
||
const params: (string | number)[] = [];
|
||
if (VALID_STATUS.has(status)) {
|
||
where.push('status = ?');
|
||
params.push(status);
|
||
}
|
||
const [rows] = await pool.query<RowDataPacket[]>(
|
||
`SELECT id, type, module, content, contact, screenshots, user_id, user_name, status,
|
||
reply_content, reply_user, reply_at, created_at
|
||
FROM bi_user_feedback
|
||
WHERE ${where.join(' AND ')}
|
||
ORDER BY created_at DESC
|
||
LIMIT ?`,
|
||
[...params, limit],
|
||
);
|
||
return c.json({ items: rows });
|
||
});
|
||
|
||
// PATCH /api/feedback/:id — 管理:更新状态与回复(仅 BI-ADMIN-FEEDBACK / 全量权限)
|
||
app.patch('/:id', async (c) => {
|
||
await ensureTable();
|
||
const user = (c as { get?: (k: string) => unknown }).get?.('user') as AuthUser | undefined;
|
||
if (!canManageFeedback(user?.roles)) {
|
||
return c.json({ ok: false, message: '无权限' }, 403);
|
||
}
|
||
const id = Number(c.req.param('id'));
|
||
if (!Number.isFinite(id) || id <= 0) return c.json({ ok: false, message: 'id 不合法' }, 400);
|
||
const body = await c.req.json().catch(() => ({})) as { status?: string; reply?: string };
|
||
const fields: string[] = [];
|
||
const params: (string | number | null)[] = [];
|
||
if (body.status) {
|
||
if (!VALID_STATUS.has(body.status)) return c.json({ ok: false, message: '状态不合法' }, 400);
|
||
fields.push('status = ?');
|
||
params.push(body.status);
|
||
}
|
||
if (typeof body.reply === 'string') {
|
||
const reply = body.reply.trim().slice(0, 2000);
|
||
fields.push('reply_content = ?', 'reply_user = ?', `reply_at = ${CST_NOW}`);
|
||
const user = (c as { get?: (k: string) => unknown }).get?.('user') as AuthUser | undefined;
|
||
params.push(reply || null, user?.userName || user?.userId || null);
|
||
}
|
||
if (fields.length === 0) return c.json({ ok: false, message: '没有可更新的字段' }, 400);
|
||
params.push(id);
|
||
await pool.query(`UPDATE bi_user_feedback SET ${fields.join(', ')} WHERE id = ?`, params);
|
||
return c.json({ ok: true });
|
||
});
|
||
|
||
export default app;
|