From 6f6823acc9685462ae7ea5a1c632ed8521b6c765 Mon Sep 17 00:00:00 2001 From: yokowu <18836617@qq.com> Date: Tue, 29 Sep 2026 18:17:24 +0800 Subject: [PATCH] =?UTF-8?q?=E5=8A=9F=E8=83=BD=EF=BC=9A=E5=A2=9E=E5=8A=A0?= =?UTF-8?q?=E7=AE=A1=E7=90=86=E5=90=8E=E5=8F=B0=E7=94=A8=E6=88=B7=E5=8F=8D?= =?UTF-8?q?=E9=A6=88=E9=A1=B5=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- monkeyai/admin/src/App.tsx | 3 + monkeyai/admin/src/components/app-sidebar.tsx | 4 + monkeyai/admin/src/i18n/locales/en-US.ts | 54 ++ monkeyai/admin/src/i18n/locales/zh-CN.ts | 54 ++ monkeyai/admin/src/lib/routes.ts | 8 + monkeyai/admin/src/pages/feedback-page.tsx | 571 ++++++++++++++++++ 6 files changed, 694 insertions(+) create mode 100644 monkeyai/admin/src/pages/feedback-page.tsx diff --git a/monkeyai/admin/src/App.tsx b/monkeyai/admin/src/App.tsx index 5d6046842..390fdb2f2 100644 --- a/monkeyai/admin/src/App.tsx +++ b/monkeyai/admin/src/App.tsx @@ -16,6 +16,7 @@ import { ConsolePage } from "@/pages/console-page" import { BillingDetailsPage } from "@/pages/billing-details-page" import { BillingSettingsPage } from "@/pages/billing-settings-page" import { ExpertsPage } from "@/pages/experts-page" +import { FeedbackPage } from "@/pages/feedback-page" import { KnowledgeBasesPage } from "@/pages/knowledge-bases-page" import { LoginPage } from "@/pages/login-page" import { ClientLoginPage } from "@/pages/client-login-page" @@ -129,6 +130,8 @@ export function App() { ) : page.path === CONSOLE_ROUTES.membersAndGroups ? ( + ) : page.path === CONSOLE_ROUTES.feedback ? ( + ) : page.path === CONSOLE_ROUTES.operationLogs ? ( ) : page.path === CONSOLE_ROUTES.otherSettings ? ( diff --git a/monkeyai/admin/src/components/app-sidebar.tsx b/monkeyai/admin/src/components/app-sidebar.tsx index 19624c190..c7eeb0cd0 100644 --- a/monkeyai/admin/src/components/app-sidebar.tsx +++ b/monkeyai/admin/src/components/app-sidebar.tsx @@ -110,6 +110,10 @@ export function AppSidebar({ title: t("pages.membersAndGroups.title"), url: CONSOLE_ROUTES.membersAndGroups, }, + { + title: t("pages.feedback.title"), + url: CONSOLE_ROUTES.feedback, + }, { title: t("pages.operationLogs.title"), url: CONSOLE_ROUTES.operationLogs, diff --git a/monkeyai/admin/src/i18n/locales/en-US.ts b/monkeyai/admin/src/i18n/locales/en-US.ts index aac65ab06..92571a1c9 100644 --- a/monkeyai/admin/src/i18n/locales/en-US.ts +++ b/monkeyai/admin/src/i18n/locales/en-US.ts @@ -1098,6 +1098,60 @@ export const enUS = { confirm: "Save", }, }, + feedback: { + title: "User Feedback", + description: "Review feedback and screenshots submitted by Agent users.", + category: "Category", + state: "Status", + allCategories: "All categories", + allStates: "All statuses", + categories: { + bug: "Bug report", + feature: "Feature request", + experience: "Experience", + other: "Other", + }, + states: { + uploading: "Uploading", + new: "New", + resolved: "Resolved", + ignored: "Ignored", + failed: "Submission failed", + }, + columns: { + time: "Submitted", + user: "User ID", + category: "Category", + content: "Feedback", + rating: "Rating", + state: "Status", + actions: "Actions", + }, + detail: "Feedback details", + view: "View", + content: "Feedback", + images: "Screenshots", + noImages: "No screenshots", + unavailableImage: "Screenshot unavailable", + platform: "Platform", + version: "Client version", + requestId: "Request ID", + imageAlt: "Feedback screenshot {{index}}", + openImage: "Open full image", + changeState: "Update status", + save: "Save status", + saved: "Feedback status updated", + noContent: "Screenshot-only feedback", + empty: "No feedback matches the current filters.", + loadError: "Failed to load feedback", + retry: "Retry", + pagination: { + summary: "Showing {{from}}–{{to}} of {{total}}", + page: "Page {{page}} of {{pages}}", + previous: "Previous page", + next: "Next page", + }, + }, operationLogs: { title: "Activity Logs", description: "Review administrative actions and important activity.", diff --git a/monkeyai/admin/src/i18n/locales/zh-CN.ts b/monkeyai/admin/src/i18n/locales/zh-CN.ts index bd607a7df..acf032d5e 100644 --- a/monkeyai/admin/src/i18n/locales/zh-CN.ts +++ b/monkeyai/admin/src/i18n/locales/zh-CN.ts @@ -1037,6 +1037,60 @@ export const zhCN = { confirm: "保存", }, }, + feedback: { + title: "用户反馈", + description: "查看 Agent 用户提交的反馈与截图,并处理反馈状态。", + category: "反馈类型", + state: "处理状态", + allCategories: "全部类型", + allStates: "全部状态", + categories: { + bug: "问题反馈", + feature: "功能建议", + experience: "使用体验", + other: "其他", + }, + states: { + uploading: "上传中", + new: "待处理", + resolved: "已处理", + ignored: "已忽略", + failed: "提交失败", + }, + columns: { + time: "提交时间", + user: "用户 ID", + category: "类型", + content: "反馈内容", + rating: "评分", + state: "状态", + actions: "操作", + }, + detail: "反馈详情", + view: "查看", + content: "反馈内容", + images: "反馈截图", + noImages: "未上传截图", + unavailableImage: "截图不可查看", + platform: "客户端平台", + version: "客户端版本", + requestId: "请求 ID", + imageAlt: "反馈截图 {{index}}", + openImage: "打开原图", + changeState: "更新状态", + save: "保存状态", + saved: "反馈状态已更新", + noContent: "仅提交了截图", + empty: "当前筛选条件下暂无反馈。", + loadError: "加载反馈失败", + retry: "重试", + pagination: { + summary: "显示 {{from}}–{{to}} 条,共 {{total}} 条", + page: "第 {{page}} / {{pages}} 页", + previous: "上一页", + next: "下一页", + }, + }, operationLogs: { title: "操作记录", description: "查看后台管理操作和关键行为记录。", diff --git a/monkeyai/admin/src/lib/routes.ts b/monkeyai/admin/src/lib/routes.ts index 423cdaa83..d984ce9b6 100644 --- a/monkeyai/admin/src/lib/routes.ts +++ b/monkeyai/admin/src/lib/routes.ts @@ -15,6 +15,7 @@ export const CONSOLE_ROUTES = { billingDetails: "/console/billing/details", billingSettings: "/console/billing/settings", membersAndGroups: "/console/settings/members", + feedback: "/console/settings/feedback", operationLogs: "/console/settings/operation-logs", otherSettings: "/console/settings/other", } as const @@ -113,6 +114,13 @@ export const CONSOLE_PAGES = [ titleKey: "pages.membersAndGroups.title", descriptionKey: "pages.membersAndGroups.description", }, + { + path: CONSOLE_ROUTES.feedback, + sectionKey: "sections.systemSettings", + sectionPath: CONSOLE_ROUTES.membersAndGroups, + titleKey: "pages.feedback.title", + descriptionKey: "pages.feedback.description", + }, { path: CONSOLE_ROUTES.operationLogs, sectionKey: "sections.systemSettings", diff --git a/monkeyai/admin/src/pages/feedback-page.tsx b/monkeyai/admin/src/pages/feedback-page.tsx new file mode 100644 index 000000000..7d7342786 --- /dev/null +++ b/monkeyai/admin/src/pages/feedback-page.tsx @@ -0,0 +1,571 @@ +import { useEffect, useMemo, useState } from "react" +import { useTranslation } from "react-i18next" + +import { useAppToast } from "@/components/animated-toast-provider" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { Card, CardContent } from "@/components/ui/card" +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table" +import { api } from "@/lib/api" + +type Category = "bug" | "feature" | "experience" | "other" +type State = "uploading" | "new" | "resolved" | "ignored" | "failed" +type EditableState = "new" | "resolved" | "ignored" +type Attachment = { + id: string + mime_type: string + byte_size: number + width: number + height: number + state: "pending" | "ready" | "failed" + downloadable: boolean +} +type Feedback = { + id: string + user_id: string + category: Category + content: string + rating?: number | null + platform: string + client_version: string + state: State + request_id?: string + created_at: string + updated_at: string + attachments: Attachment[] +} +type FeedbackPageData = { + items: Feedback[] + total: number + page: number + page_size: number +} + +const categories: Category[] = ["bug", "feature", "experience", "other"] +const states: State[] = ["uploading", "new", "resolved", "ignored", "failed"] +const editableStates: EditableState[] = ["new", "resolved", "ignored"] +const pageSize = 20 + +function isEditable(state: State): state is EditableState { + return state === "new" || state === "resolved" || state === "ignored" +} + +function feedbackPath(id: string) { + return `/api/admin/v1/feedback/${encodeURIComponent(id)}` +} + +function StateBadge({ state }: { state: State }) { + const { t } = useTranslation() + return ( + + {t(`pages.feedback.states.${state}`)} + + ) +} + +function FeedbackDetails({ + id, + onUpdated, +}: { + id: string + onUpdated: () => void +}) { + const { i18n, t } = useTranslation() + const { showToast } = useAppToast() + const [response, setResponse] = useState<{ + key: string + data?: Feedback + error?: string + }>() + const [error, setError] = useState("") + const [revision, setRevision] = useState(0) + const [saving, setSaving] = useState(false) + const [nextState, setNextState] = useState("new") + const locale = i18n.resolvedLanguage ?? i18n.language + const requestKey = `${id}:${revision}` + const loading = response?.key !== requestKey + const data = loading ? undefined : response.data + const loadError = loading ? "" : (response.error ?? "") + + useEffect(() => { + const controller = new AbortController() + api(feedbackPath(id), { signal: controller.signal }) + .then((result) => { + if (controller.signal.aborted) return + setResponse({ key: requestKey, data: result }) + if (isEditable(result.state)) setNextState(result.state) + }) + .catch((reason: unknown) => { + if (!controller.signal.aborted) { + setResponse({ + key: requestKey, + error: reason instanceof Error ? reason.message : String(reason), + }) + } + }) + return () => controller.abort() + }, [id, requestKey]) + + async function saveState() { + if (!data || !isEditable(data.state) || saving || nextState === data.state) + return + setSaving(true) + setError("") + try { + const result = await api(feedbackPath(id), { + method: "PATCH", + body: JSON.stringify({ state: nextState }), + }) + setResponse({ key: requestKey, data: result }) + onUpdated() + showToast({ status: "success", title: t("pages.feedback.saved") }) + } catch (reason) { + const message = reason instanceof Error ? reason.message : String(reason) + setError(message) + showToast({ status: "error", title: message }) + } finally { + setSaving(false) + } + } + + if (loading) return

{t("resources.loading")}

+ if (loadError) { + return ( +
+

+ {t("pages.feedback.loadError")}: {loadError} +

+ +
+ ) + } + if (!data) return null + + return ( +
+
+ {[ + [ + t("pages.feedback.columns.time"), + new Date(data.created_at).toLocaleString(locale), + ], + [t("pages.feedback.columns.user"), data.user_id], + [ + t("pages.feedback.columns.category"), + t(`pages.feedback.categories.${data.category}`), + ], + [t("pages.feedback.columns.rating"), data.rating ?? "—"], + [t("pages.feedback.platform"), data.platform], + [t("pages.feedback.version"), data.client_version || "—"], + [t("pages.feedback.requestId"), data.request_id || "—"], + ].map(([label, value]) => ( +
+
{label}
+
{value}
+
+ ))} +
+
{t("pages.feedback.state")}
+
+ +
+
+
+
+

{t("pages.feedback.content")}

+

+ {data.content || t("pages.feedback.noContent")} +

+
+
+

{t("pages.feedback.images")}

+ {data.attachments.length ? ( +
+ {data.attachments.map((attachment, index) => { + if (!attachment.downloadable) { + return ( +
+ {t("pages.feedback.unavailableImage")} +
+ ) + } + const path = `${feedbackPath(id)}/attachments/${encodeURIComponent(attachment.id)}` + return ( + + {t("pages.feedback.imageAlt", + + {attachment.width} × {attachment.height} ·{" "} + {t("pages.feedback.openImage")} + + + ) + })} +
+ ) : ( +

+ {t("pages.feedback.noImages")} +

+ )} +
+ {isEditable(data.state) && ( +
+ + +
+ )} + {error && ( +

+ {error} +

+ )} +
+ ) +} + +export function FeedbackPage() { + const { i18n, t } = useTranslation() + const [category, setCategory] = useState("all") + const [state, setState] = useState("all") + const [page, setPage] = useState(1) + const [revision, setRevision] = useState(0) + const [selectedId, setSelectedId] = useState(null) + const [response, setResponse] = useState<{ + key: string + data?: FeedbackPageData + error?: string + }>() + const locale = i18n.resolvedLanguage ?? i18n.language + const formatter = useMemo( + () => + new Intl.DateTimeFormat(locale, { + dateStyle: "medium", + timeStyle: "short", + }), + [locale] + ) + const query = new URLSearchParams({ + page: String(page), + page_size: String(pageSize), + }) + if (category !== "all") query.set("category", category) + if (state !== "all") query.set("state", state) + const path = `/api/admin/v1/feedback?${query}` + const requestKey = `${path}:${revision}` + const loading = response?.key !== requestKey + const error = loading ? "" : (response.error ?? "") + const data = loading ? undefined : response.data + + useEffect(() => { + const controller = new AbortController() + api(path, { signal: controller.signal }) + .then((result) => { + if (!controller.signal.aborted) + setResponse({ key: requestKey, data: result }) + }) + .catch((reason: unknown) => { + if (!controller.signal.aborted) { + setResponse({ + key: requestKey, + error: reason instanceof Error ? reason.message : String(reason), + }) + } + }) + return () => controller.abort() + }, [path, requestKey]) + + const rows = !loading && !error ? (data?.items ?? []) : [] + const total = data?.total ?? 0 + const pages = Math.max(1, Math.ceil(total / pageSize)) + + return ( +
+ + +
+ + + +
+
+ + + + + {t("pages.feedback.columns.time")} + + {t("pages.feedback.columns.user")} + {t("pages.feedback.columns.category")} + {t("pages.feedback.columns.content")} + {t("pages.feedback.columns.rating")} + {t("pages.feedback.columns.state")} + + {t("pages.feedback.columns.actions")} + + + + + {rows.length ? ( + rows.map((item) => ( + + + {formatter.format(new Date(item.created_at))} + + + {item.user_id} + + + {t(`pages.feedback.categories.${item.category}`)} + + + {item.content || t("pages.feedback.noContent")} + + {item.rating ?? "—"} + + + + + + + + )) + ) : ( + + + {loading ? ( + t("resources.loading") + ) : error ? ( +
+

+ {t("pages.feedback.loadError")}: {error} +

+ +
+ ) : ( + t("pages.feedback.empty") + )} +
+
+ )} +
+
+
+
+ + {t("pages.feedback.pagination.summary", { + from: rows.length ? (page - 1) * pageSize + 1 : 0, + to: rows.length ? (page - 1) * pageSize + rows.length : 0, + total, + })} + +
+ + {t("pages.feedback.pagination.page", { page, pages })} + + + +
+
+
+
+ { + if (!open) setSelectedId(null) + }} + > + {selectedId && ( + + + {t("pages.feedback.detail")} + + setRevision((value) => value + 1)} + /> + + )} + +
+ ) +}