feat(feedback): 反馈 FAB 菜单加「反馈管理」入口,BI-ADMIN-FEEDBACK 角色可见
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful

- shared/auth/roles 新增 FEEDBACK_ADMIN_ROLES = ['BI-ADMIN-FEEDBACK']
  + canManageFeedback() helper(含 FULL_ACCESS_ROLES 兜底)
- FeedbackFab 菜单:在「我的反馈」下方加分割线 + 紫色 ⚙ 图标的「反馈管理」
  仅 canManageFeedback 为 true 时渲染,跳到 #/admin/feedback
- 后端守卫:GET /api/feedback/list 与 PATCH /api/feedback/:id 加角色判断
  无权限返回 403。/mine /submit /upload 仍对全部登录用户开放。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
kkfluous
2026-04-30 14:20:45 +08:00
parent c5541fbbf5
commit 1a3d48b2d1
4 changed files with 39 additions and 3 deletions

View File

@@ -2,10 +2,12 @@ import { useEffect, useRef, useState } from 'react';
import { motion, AnimatePresence } from 'motion/react'; import { motion, AnimatePresence } from 'motion/react';
import { import {
MessageCircleHeart, X, ChevronRight, ChevronLeft, Check, Sparkles, MessageCircleHeart, X, ChevronRight, ChevronLeft, Check, Sparkles,
ImagePlus, Loader2, Inbox, Lightbulb, Bug, Palette, NotebookPen, ImagePlus, Loader2, Inbox, Lightbulb, Bug, Palette, NotebookPen, Settings2,
type LucideIcon, type LucideIcon,
} from 'lucide-react'; } from 'lucide-react';
import { fetchJson } from '../auth/api-client'; import { fetchJson } from '../auth/api-client';
import { useAuth } from '../auth/useAuth';
import { canManageFeedback } from '../shared/auth/roles';
import FeedbackHistoryDrawer from './FeedbackHistoryDrawer'; import FeedbackHistoryDrawer from './FeedbackHistoryDrawer';
const MAX_SCREENSHOTS = 6; const MAX_SCREENSHOTS = 6;
@@ -86,6 +88,8 @@ interface Props {
} }
export default function FeedbackFab({ module: moduleProp }: Props = {}) { export default function FeedbackFab({ module: moduleProp }: Props = {}) {
const { user } = useAuth();
const isAdmin = canManageFeedback(user?.roles);
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [historyOpen, setHistoryOpen] = useState(false); const [historyOpen, setHistoryOpen] = useState(false);
const [menuOpen, setMenuOpen] = useState(false); const [menuOpen, setMenuOpen] = useState(false);
@@ -229,6 +233,18 @@ export default function FeedbackFab({ module: moduleProp }: Props = {}) {
> >
<Inbox size={14} className="text-emerald-500" /> <Inbox size={14} className="text-emerald-500" />
</button> </button>
{isAdmin && (
<>
<div className="h-px bg-slate-100 my-0.5" />
<a
href="#/admin/feedback"
onClick={() => setMenuOpen(false)}
className="flex items-center gap-2 px-3 py-2 text-[12px] font-bold text-slate-700 rounded-lg hover:bg-violet-50 hover:text-violet-600"
>
<Settings2 size={14} className="text-violet-500" />
</a>
</>
)}
</motion.div> </motion.div>
)} )}
</AnimatePresence> </AnimatePresence>

View File

@@ -28,5 +28,7 @@ export {
FULL_ACCESS_ROLES, FULL_ACCESS_ROLES,
DEPT_ACCESS_ROLES, DEPT_ACCESS_ROLES,
SCHEDULING_ACCESS_ROLES, SCHEDULING_ACCESS_ROLES,
FEEDBACK_ADMIN_ROLES,
canAccessScheduling, canAccessScheduling,
canManageFeedback,
} from '../../shared/auth/roles.js'; } from '../../shared/auth/roles.js';

View File

@@ -2,6 +2,7 @@ import { Hono } from 'hono';
import type { ResultSetHeader, RowDataPacket } from 'mysql2'; import type { ResultSetHeader, RowDataPacket } from 'mysql2';
import pool from '../../db.js'; import pool from '../../db.js';
import type { AuthUser } from '../../auth/types.js'; import type { AuthUser } from '../../auth/types.js';
import { canManageFeedback } from '../../auth/types.js';
import { uploadFeedbackImage } from './oss.js'; import { uploadFeedbackImage } from './oss.js';
const app = new Hono(); const app = new Hono();
@@ -127,9 +128,13 @@ app.get('/mine', async (c) => {
return c.json({ items: rows }); return c.json({ items: rows });
}); });
// GET /api/feedback/list — 管理列表(含全部反馈 // GET /api/feedback/list — 管理列表(仅 BI-ADMIN-FEEDBACK / 全量权限
app.get('/list', async (c) => { app.get('/list', async (c) => {
await ensureTable(); 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 limit = Math.min(500, Math.max(1, Number(c.req.query('limit')) || 100));
const status = c.req.query('status') || ''; const status = c.req.query('status') || '';
const where: string[] = ['1=1']; const where: string[] = ['1=1'];
@@ -150,9 +155,13 @@ app.get('/list', async (c) => {
return c.json({ items: rows }); return c.json({ items: rows });
}); });
// PATCH /api/feedback/:id — 管理:更新状态与回复 // PATCH /api/feedback/:id — 管理:更新状态与回复(仅 BI-ADMIN-FEEDBACK / 全量权限)
app.patch('/:id', async (c) => { app.patch('/:id', async (c) => {
await ensureTable(); 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')); const id = Number(c.req.param('id'));
if (!Number.isFinite(id) || id <= 0) return c.json({ ok: false, message: 'id 不合法' }, 400); 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 body = await c.req.json().catch(() => ({})) as { status?: string; reply?: string };

View File

@@ -10,8 +10,17 @@ export const DEPT_ACCESS_ROLES = ['BI-Leader-Dep'];
/** 智能调度模块访问角色 */ /** 智能调度模块访问角色 */
export const SCHEDULING_ACCESS_ROLES = ['BI-SCHEDULE-OPT']; export const SCHEDULING_ACCESS_ROLES = ['BI-SCHEDULE-OPT'];
/** 反馈管理(管理员)访问角色 */
export const FEEDBACK_ADMIN_ROLES = ['BI-ADMIN-FEEDBACK'];
/** 用户是否可访问智能调度模块。仅 BI-SCHEDULE-OPT 角色允许访问。 */ /** 用户是否可访问智能调度模块。仅 BI-SCHEDULE-OPT 角色允许访问。 */
export function canAccessScheduling(roles: readonly string[] | null | undefined): boolean { export function canAccessScheduling(roles: readonly string[] | null | undefined): boolean {
if (!roles || roles.length === 0) return false; if (!roles || roles.length === 0) return false;
return roles.some(r => SCHEDULING_ACCESS_ROLES.includes(r)); return roles.some(r => SCHEDULING_ACCESS_ROLES.includes(r));
} }
/** 用户是否可管理反馈。仅 BI-ADMIN-FEEDBACK 或全量权限角色可访问。 */
export function canManageFeedback(roles: readonly string[] | null | undefined): boolean {
if (!roles || roles.length === 0) return false;
return roles.some(r => FEEDBACK_ADMIN_ROLES.includes(r) || FULL_ACCESS_ROLES.includes(r));
}