feat(feedback): 反馈 FAB 菜单加「反馈管理」入口,BI-ADMIN-FEEDBACK 角色可见
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
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:
@@ -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>
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user