diff --git a/BLOCKED.md b/BLOCKED.md index af86b953..de48cc48 100644 --- a/BLOCKED.md +++ b/BLOCKED.md @@ -18,6 +18,12 @@ - **数据库测试**:新表 `user_consents`(`20260930010000_user_consents.sql`,只加不改)没有跑 `npm run test:db`(本机无 Docker);staging 部署时由迁移步骤首次真实执行,部署后需用受控账号登录一次核对写入。 - **真机**:登录页勾选、协议页阅读、账户菜单链接未在真机走查(清单 `docs/testing/legal-consent-20260930.md`)。 +## 反馈与投诉 + 回复评价(2026-09-30):真实 PostgreSQL 与真机待验 + +- 缺什么:本机无 Docker,`npm run test:db` 跑不了;迁移 `20260930030000_user_feedback.sql` 里的函数(五次 / 小时限流、会话归属校验、管理员权限与审计写入)只有源码合同测试,没有真实库执行证据。无登录态与实体手机,弹窗与后台列表未在真实环境点过。 +- 替代证据:`frontend/tests/feedback-complaints-20260930.test.tsx`(校验、错误映射、弹窗提交与限流提示、评价按文本哈希恢复、SQL 与 RBAC 合同);部署到 staging 时迁移会被真实执行一次。 +- 解除条件:staging 迁移成功后,用受控账号提交一条反馈、连续提交第 6 条看到限流提示、在后台把它改为「已解决」并在审计日志看到记录;点赞后刷新对话仍在。 + ## TASK-chart-surface-polish:受控登录、实体手机与基线全量失败(2026-09-29) - 本轮 Chrome、Edge、Docker 均可用,不套用历史“无 Chrome / 无 Docker”。真实 Chrome + golden 的本地组件验收及骨架高度修复后复验已完成(70+8 项通过);没有受控线上登录账号、实体手机和读屏实测;不能代替完整账户/人物/请求链路。清单见 `docs/testing/chart-surface-polish-20260929.md`。 diff --git a/CHANGELOG.md b/CHANGELOG.md index 96b4ade1..b7e19d89 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,13 @@ - 运营主体、地址、客服邮箱目前是占位,产品提供后替换一处即可。 - Skill 版本不 bump。新增一张表 `user_consents`(只加不改)。 +## 2026-09-30 — 新增「反馈与投诉」,回答下面的赞 / 踩会保存(待验收) + +- 账户菜单新增「反馈与投诉」:选类型(问题反馈 / 内容举报 / 退款与扣点 / 其他),写下发生了什么,可留联系方式,也可勾选附上当前对话(只附编号)。提交后告诉你 3 个工作日内处理,并给出客服邮箱。同一账号一小时最多提交 5 次。 +- 回答下面的赞 / 踩现在会保存,重新打开这段对话还在;重新生成的回答不会沿用旧的评价。 +- 管理后台新增「反馈与投诉」列表:按状态或类型筛选,新的内容举报排在最前面并标红;可以改处理状态、写内部备注,每次处理都记入审计日志。 +- 新增数据库表 `user_feedback`、`reply_ratings`(只加不改)。Skill 版本不 bump。 + ## 2026-09-30 — 首页去掉校正提示、星盘类型文字完整显示、加载改为轨道环动画、「那一刻的天空」换小星座图标(待验收) - 首页不再显示「上次那次校正还没完成,可以在历史对话里接着做。」(BUG-1111)。 diff --git a/CONTEXT.md b/CONTEXT.md index 53e0084f..6103784c 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -48,6 +48,10 @@ _避免使用_:授权、签约 负向回复评价附带的一个或多个原因分类,可包含用户补充说明。 _避免使用_:投诉、差评文本 +**反馈与投诉**: +用户从账户菜单主动提交的一条问题反馈、内容举报、退款与扣点诉求或其他意见,由后台「反馈与投诉」处理,状态为待处理、处理中、已解决或不予处理。只保存用户自己写的内容、选填的联系方式和(勾选时)会话编号,不保存对话原文。它与「回复评价」「对话质量记录」是两回事。 +_避免使用_:工单(泛指时)、差评、客服记录 + **对话质量记录**: 管理后台中供管理员排查或审阅的一项 Agent 执行故障或负向回复评价。 _避免使用_:聊天日志、客服工单 diff --git a/docs/tasks/PROGRESS-feedback-complaints-20260930.md b/docs/tasks/PROGRESS-feedback-complaints-20260930.md new file mode 100644 index 00000000..258df990 --- /dev/null +++ b/docs/tasks/PROGRESS-feedback-complaints-20260930.md @@ -0,0 +1,31 @@ +# PROGRESS · 反馈与投诉 + 回复评价持久化 · 2026-09-30 + +> 合规轮第四项之一(产品 2026-09-30「合规与法律的前四个你可以帮我做吗」,选定「站内反馈表单 + 后台处理」)。执行:Claude fork 子代理 C。分支 `codex/feedback-complaints-20260930`,基于 `codex/compliance-base-20260930`(`2cd37720`,含 `lib/legal-entity.ts` 占位)。 + +## 复用与新增 + +| 项 | 复用 | 新增 | +|---|---|---| +| 账户菜单 | `app-sidebar.tsx` 的 Menu(只加一行「反馈与投诉」) | `components/feedback-dialog.tsx`(侧栏自持状态,任何页面原地打开,不经 `/?account=`、不动 Home) | +| 写入与限流 | 服务端 `createAdminSupabaseClient().rpc` 模式(同 telemetry) | 迁移 `20260930030000_user_feedback.sql`:`user_feedback` 表、`submit_user_feedback()`(五次 / 滚动一小时、会话归属校验) | +| 回复评价 | 页面既有的 `messageFeedback` 状态与 `toggleChatMessageFeedback`;术语沿用 CONTEXT「回复评价」 | `reply_ratings` 表(库里原先没有评价表)、`set_reply_rating()` / `list_reply_ratings()`、`/api/reply-ratings`、`lib/reply-ratings.ts`、`hooks/use-reply-ratings-sync.ts`(Home 的 useState / useRef 数不变) | +| 后台 | RBAC 表、`admin_has_permission`、`audit.admin_audit_logs`、`ResourceTable`、refine 资源权限映射 | 权限 `support.feedback.read/write`(owner / operations / support 读写,auditor 只读);`admin_list_user_feedback()` / `admin_update_user_feedback()`(带审计);`/api/admin/feedback`、`/admin/feedback` | + +## 决定 + +- 评价按「会话 + 第几条消息」存,附回答文本 SHA-256 前 16 位;重新打开对话时只恢复文本哈希一致的评价,重新生成的回答不继承旧评价。未保存的本地会话(非 UUID)不写库。 +- 反馈只存用户自己写的内容、选填联系方式、(勾选时)会话编号;表上没有任何对话原文列。 +- 两张表都开 RLS、对所有运行时角色撤销表权限,只能经 SECURITY DEFINER 函数读写;随账号(auth.users)与会话级联删除。 +- 新内容举报在后台排最前、标红。 + +## 验证(Linux,Node 22.14;基线 = `origin/staging` `5208f19b` 的全量结果) + +| 项 | 结果 | +|---|---| +| `tsc --noEmit` | 0 错 | +| `npm run lint` | 0 error,126 warning(同基线) | +| `npm test` 全量 | 4,427 / fail 24;失败名单与基线逐条相同(无 Docker 的数据库 / 部署套件);新增 9 条,消失 0 条 | +| 新测试 `feedback-complaints-20260930.test.tsx` | 9 / 9:校验、错误映射(限流 429)、弹窗提交与成功态、限流提示、无会话时不出现附带勾选、菜单只有一个入口、评价保存与按文本哈希恢复、SQL 限流 / 归属 / 撤权 / 审计、RBAC 映射 | +| `next build` | `/` 仍 `○ Static` | +| 首屏 gzip-9 | 646,480 B → 648,901 B(+0.37%;侧栏首屏多了反馈弹窗组件) | +| 真实数据库 / 真机 | 未验证,见 `BLOCKED.md`「反馈与投诉 + 回复评价」 | diff --git a/frontend/DESIGN.md b/frontend/DESIGN.md index 18b9deb1..0a1f8a03 100644 --- a/frontend/DESIGN.md +++ b/frontend/DESIGN.md @@ -709,6 +709,11 @@ page. Three parts now, in reading order: - **States:** closed, open, hover, focus-visible, and logout confirmation. - **Accessibility:** `aria-expanded`, `aria-controls`, menu semantics, 44px rows, outside-click and Escape dismissal, and focus return. - **Billing:** the 账户与点数 row opens the settings dialog on the billing pane. It does not leave the homepage. +- **反馈与投诉 (2026-09-30):** one row after 账户与点数 (icon `MessageSquareWarning`). It opens the sidebar's own dialog in place on every page — no `/?account=` hop, no Home state. The dialog uses the export sheet's language: bottom sheet on phones (up to 90dvh), a centred 520px panel from 860px; a four-way type segment (问题反馈 / 内容举报 / 退款与扣点 / 其他; 2×2 below 480px), a description (10–2000 characters), an optional contact prefilled with the account email, and — only when a saved conversation is open — 「附上当前对话」 which attaches the session id, never its text. Submit reads 「正在提交…」 while sending; errors sit above the button as an alert; success replaces the form with the promised reply window and the contact email. + +### Reply ratings persist (2026-09-30) + +The 👍 / 👎 under an answer (回复评价) is saved per conversation and message position with a 16-hex hash of the answer text, and restored when that conversation is opened again — but only onto the same text, so a regenerated answer starts unrated. Saving is fire-and-forget: a failed save never disturbs the conversation. ### Settings dialog diff --git a/frontend/docs/VOICE.md b/frontend/docs/VOICE.md index 89b96c61..2c57f8b6 100644 --- a/frontend/docs/VOICE.md +++ b/frontend/docs/VOICE.md @@ -105,6 +105,12 @@ Jyotisha 的可见文案是产品的一部分。正确性红线(真实性、 这四句只在活动行里,是「流式生成中」的一部分:不进正文、不进历史、刷新后不出现;只往前走,不回头;不写秒数、不写「请稍候」,不承诺多久。 +## 反馈与投诉(2026-09-30) + +- 菜单与弹窗标题「反馈与投诉」;类型「问题反馈 / 内容举报 / 退款与扣点 / 其他」;输入框标签「发生了什么」「联系方式(选填)」;勾选「附上当前对话(只附编号,便于我们查看)」;按钮「提交」,发送中「正在提交…」。 +- 太短:「请至少写 10 个字,说清楚发生了什么。」太频繁:「提交得有点频繁,请一小时后再试;急事可以直接发邮件给我们。」 +- 成功:「已收到,我们会在 3 个工作日内处理。」「急事也可以发邮件到 {客服邮箱}。」按钮「好的」。邮箱只从 `lib/legal-entity.ts` 读,不在文案里写死。 + ## 星盘图标说明与能力列(2026-09-29) - 盘面不显示引擎品牌,也不替换成「自研引擎」「本站引擎」。岁差、交点等计算口径与准确性边界照旧。 diff --git a/frontend/src/app/(app)/page.tsx b/frontend/src/app/(app)/page.tsx index c917db37..6a3a0ece 100644 --- a/frontend/src/app/(app)/page.tsx +++ b/frontend/src/app/(app)/page.tsx @@ -166,6 +166,7 @@ import { import { runHomeBootstrap, runHomeWarmRefresh } from "@/lib/home-bootstrap-run"; import { writeHomeWarmSnapshot } from "@/lib/home-warm-snapshot"; import { markHomeReadyAfterNewChat } from "@/lib/new-chat-timing"; +import { useReplyRatingsSync } from "@/hooks/use-reply-ratings-sync"; import { homeWarmStartInput, mountedHomeWarmStart, @@ -346,6 +347,7 @@ export default function Home() { const activeSession = sessions.find((session) => session.id === activeSessionId) ?? pendingWarmLandingSession(activeSessionId) ?? sessions.find((session) => session.sessionType !== "birth_time_rectification" && sessionMatchesSubject(session, readCurrentSubjectId())); + useReplyRatingsSync(activeSession, setMessageFeedback); const activeRectificationSession = composerLocksAsRectification(activeSession, sessions); const rectificationSurfaceOpen = Boolean(activeRectificationSession && activeSession && activeSession.id === rectificationSessionId); const rectificationComposerLocked = Boolean(activeRectificationSession && !rectificationSurfaceOpen); diff --git a/frontend/src/app/admin/feedback/page.tsx b/frontend/src/app/admin/feedback/page.tsx new file mode 100644 index 00000000..bab2b5e5 --- /dev/null +++ b/frontend/src/app/admin/feedback/page.tsx @@ -0,0 +1,2 @@ +import { FeedbackResource } from "@/components/admin/feedback-resource"; +export default function Page() { return ; } diff --git a/frontend/src/app/api/admin/feedback/route.ts b/frontend/src/app/api/admin/feedback/route.ts new file mode 100644 index 00000000..cdab68cd --- /dev/null +++ b/frontend/src/app/api/admin/feedback/route.ts @@ -0,0 +1,95 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; + +import { requirePermission } from "@/lib/admin/auth"; +import { pageOffset, queryAdminRows } from "@/lib/admin/database"; +import { adminErrorResponse, invalidQueryResponse, parseListQuery, requestId, requireAdminMutation } from "@/lib/admin/http"; +import { FEEDBACK_STATUSES, parseFeedbackFilter } from "@/lib/user-feedback"; + +export const runtime = "nodejs"; + +/** + * 反馈与投诉 in the admin console (compliance round 2026-09-30). Reads and + * updates go only through admin_list_user_feedback / admin_update_user_feedback, + * which re-check support.feedback.* and audit every change. The list shows the + * user's own words and, if they attached one, a session id — never message text. + * `status` filter accepts a status or `type:`. + */ +const updateSchema = z.object({ + id: z.string().uuid(), + status: z.enum(FEEDBACK_STATUSES), + adminNote: z.string().trim().max(2000).optional(), +}); + +type Row = { + id: string; + user_id: string; + email: string | null; + feedback_type: string; + body: string; + contact: string | null; + session_id: string | null; + status: string; + admin_note: string | null; + handled_by: string | null; + created_at: Date; + updated_at: Date; + total_count: string; +}; + +export async function GET(request: Request) { + try { + const session = await requirePermission("support.feedback.read"); + const parsed = parseListQuery(request); + if (!parsed.success) return invalidQueryResponse(parsed.error.flatten()); + const filter = parseFeedbackFilter(parsed.data.status); + const rows = await queryAdminRows( + "select * from public.admin_list_user_feedback($1::uuid,$2::text,$3::text,$4::text,$5::integer,$6::integer)", + [ + session.user.id, + filter.type, + filter.status, + parsed.data.q ? `%${parsed.data.q}%` : null, + parsed.data.pageSize, + pageOffset(parsed.data.page, parsed.data.pageSize), + ], + ); + return NextResponse.json({ + data: rows.map((row) => ({ + id: row.id, + userId: row.user_id, + email: row.email, + type: row.feedback_type, + body: row.body, + contact: row.contact, + sessionId: row.session_id, + status: row.status, + adminNote: row.admin_note, + handledBy: row.handled_by, + createdAt: row.created_at.toISOString(), + updatedAt: row.updated_at.toISOString(), + })), + total: Number(rows[0]?.total_count ?? 0), + }); + } catch (error) { + return adminErrorResponse(error); + } +} + +export async function POST(request: Request) { + try { + const session = await requireAdminMutation(request, "support.feedback.write"); + const parsed = updateSchema.safeParse(await request.json().catch(() => null)); + if (!parsed.success) return invalidQueryResponse(parsed.error.flatten()); + const rows = await queryAdminRows<{ id: string; status: string; admin_note: string | null; updated_at: Date }>( + "select * from public.admin_update_user_feedback($1::uuid,$2::uuid,$3::text,$4::text,$5::text)", + [session.user.id, parsed.data.id, parsed.data.status, parsed.data.adminNote ?? null, requestId(request)], + ); + const row = rows[0]; + return NextResponse.json({ + data: row ? { id: row.id, status: row.status, adminNote: row.admin_note, updatedAt: row.updated_at.toISOString() } : null, + }); + } catch (error) { + return adminErrorResponse(error); + } +} diff --git a/frontend/src/app/api/feedback/route.ts b/frontend/src/app/api/feedback/route.ts new file mode 100644 index 00000000..606d3cfe --- /dev/null +++ b/frontend/src/app/api/feedback/route.ts @@ -0,0 +1,45 @@ +import { NextResponse } from "next/server"; + +import { jsonForSupabaseSetupFailure } from "@/lib/api/service-unavailable"; +import { createAdminSupabaseClient } from "@/lib/supabase/admin"; +import { createServerSupabaseClient } from "@/lib/supabase/server"; +import { feedbackRpcErrorStatus, feedbackSubmissionSchema } from "@/lib/user-feedback"; + +export const runtime = "nodejs"; + +/** + * POST /api/feedback — 反馈与投诉 from the account menu (compliance round + * 2026-09-30). The write goes through submit_user_feedback(), which enforces + * the hourly limit and that an attached session belongs to the caller. + */ +export async function POST(request: Request) { + let supabase: Awaited>; + let service: ReturnType; + try { + supabase = await createServerSupabaseClient(); + service = createAdminSupabaseClient(); + } catch (error) { + return jsonForSupabaseSetupFailure(error, "POST /api/feedback"); + } + const { data: { user }, error: authError } = await supabase.auth.getUser(); + if (authError || !user) { + return NextResponse.json({ error: "请先登录" }, { status: 401 }); + } + const parsed = feedbackSubmissionSchema.safeParse(await request.json().catch(() => null)); + if (!parsed.success) { + return NextResponse.json({ error: "请选择类型,并写至少 10 个字的描述。" }, { status: 400 }); + } + const { data, error } = await service.rpc("submit_user_feedback", { + p_user_id: user.id, + p_type: parsed.data.type, + p_body: parsed.data.body, + p_contact: parsed.data.contact ?? null, + p_session_id: parsed.data.sessionId ?? null, + }); + if (error) { + const mapped = feedbackRpcErrorStatus(error.message); + if (mapped.status === 503) console.error("[feedback] submit failed", error.code ?? "unknown"); + return NextResponse.json({ error: mapped.error }, { status: mapped.status }); + } + return NextResponse.json({ id: typeof data === "string" ? data : null }, { status: 201 }); +} diff --git a/frontend/src/app/api/reply-ratings/route.ts b/frontend/src/app/api/reply-ratings/route.ts new file mode 100644 index 00000000..8a112623 --- /dev/null +++ b/frontend/src/app/api/reply-ratings/route.ts @@ -0,0 +1,77 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; + +import { jsonForSupabaseSetupFailure } from "@/lib/api/service-unavailable"; +import { createAdminSupabaseClient } from "@/lib/supabase/admin"; +import { createServerSupabaseClient } from "@/lib/supabase/server"; + +export const runtime = "nodejs"; + +/** + * 回复评价 (CONTEXT.md): the 👍 / 👎 on one assistant reply, persisted per + * session + message position (compliance round 2026-09-30). Reads and writes + * go through list_reply_ratings() / set_reply_rating(), which check that the + * session belongs to the caller. + */ +const putSchema = z.object({ + sessionId: z.string().uuid(), + messageIndex: z.number().int().min(0).max(100_000), + rating: z.enum(["up", "down"]).nullable(), + answerSha256: z.string().regex(/^[a-f0-9]{16,64}$/), +}); + +async function clients() { + const supabase = await createServerSupabaseClient(); + const service = createAdminSupabaseClient(); + return { supabase, service }; +} + +export async function GET(request: Request) { + let context: Awaited>; + try { + context = await clients(); + } catch (error) { + return jsonForSupabaseSetupFailure(error, "GET /api/reply-ratings"); + } + const { data: { user } } = await context.supabase.auth.getUser(); + if (!user) return NextResponse.json({ error: "请先登录" }, { status: 401 }); + const sessionId = new URL(request.url).searchParams.get("sessionId") ?? ""; + if (!z.string().uuid().safeParse(sessionId).success) { + return NextResponse.json({ error: "会话编号不正确" }, { status: 400 }); + } + const { data, error } = await context.service.rpc("list_reply_ratings", { p_user_id: user.id, p_session_id: sessionId }); + if (error) return NextResponse.json({ error: "暂时读不到评价" }, { status: 503 }); + const rows = Array.isArray(data) ? data : []; + return NextResponse.json({ + ratings: rows.flatMap((row: Record) => ( + typeof row.message_index === "number" && (row.rating === "up" || row.rating === "down") && typeof row.answer_sha256 === "string" + ? [{ messageIndex: row.message_index, rating: row.rating, answerSha256: row.answer_sha256 }] + : [] + )), + }); +} + +export async function PUT(request: Request) { + let context: Awaited>; + try { + context = await clients(); + } catch (error) { + return jsonForSupabaseSetupFailure(error, "PUT /api/reply-ratings"); + } + const { data: { user } } = await context.supabase.auth.getUser(); + if (!user) return NextResponse.json({ error: "请先登录" }, { status: 401 }); + const parsed = putSchema.safeParse(await request.json().catch(() => null)); + if (!parsed.success) return NextResponse.json({ error: "评价格式不正确" }, { status: 400 }); + const { error } = await context.service.rpc("set_reply_rating", { + p_user_id: user.id, + p_session_id: parsed.data.sessionId, + p_message_index: parsed.data.messageIndex, + p_rating: parsed.data.rating, + p_answer_sha256: parsed.data.answerSha256, + }); + if (error) { + const notOwned = error.message?.includes("rating_session_not_owned"); + return NextResponse.json({ error: notOwned ? "会话不属于当前账号" : "暂时没能保存评价" }, { status: notOwned ? 403 : 503 }); + } + return NextResponse.json({ saved: true }); +} diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index 4f4b7103..89a4cb7d 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -4077,6 +4077,50 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class .report-export-footer > button { width: 100%; } .report-export-footer p { margin: 0 0 var(--space-2); color: var(--color-danger); font-size: var(--type-body-sm); } @media print { .report-export-drawer { display: none !important; } } +/* 反馈与投诉 (2026-09-30): the same sheet language as 导出报告 — bottom sheet on + phones, a centred 520px panel from 860px. Type is a four-way segment, then + the description, an optional contact and the attach-conversation opt-in. */ +.feedback-dialog { + position: fixed; + inset: auto 0 0; + width: min(760px, 100%); + max-width: none; + max-height: 90dvh; + margin: 0 auto; + padding: 0; + border: 1px solid var(--color-border); + border-radius: var(--radius-lg) var(--radius-lg) 0 0; + background: var(--color-canvas); + color: var(--color-ink); + box-shadow: var(--shadow-elevated); + overflow: hidden; +} +.feedback-dialog::backdrop { background: var(--color-scrim); } +.feedback-dialog-panel { display: flex; flex-direction: column; max-height: 90dvh; } +.feedback-dialog-header { display: flex; align-items: center; justify-content: space-between; gap: var(--space-2); padding: var(--space-3) var(--space-3) var(--space-1) var(--space-5); flex-shrink: 0; } +.feedback-dialog-header h2 { margin: 0; font-size: var(--type-title-md); font-weight: 600; } +.feedback-dialog-body { display: grid; gap: var(--space-4); min-height: 0; overflow-y: auto; padding: var(--space-2) var(--space-5) max(var(--space-5), env(safe-area-inset-bottom)); } +.feedback-dialog-types { display: grid; grid-template-columns: repeat(4, 1fr); gap: 2px; padding: 3px; border-radius: var(--radius-md); background: var(--color-canvas-muted); } +.feedback-dialog-types button { min-height: 40px; border: 0; border-radius: calc(var(--radius-md) - 2px); background: transparent; color: var(--color-ink-secondary); font: inherit; font-size: var(--type-body-sm); cursor: pointer; } +.feedback-dialog-types button[aria-checked="true"] { background: var(--color-canvas); color: var(--color-ink); font-weight: 600; box-shadow: 0 1px 2px color-mix(in srgb, var(--color-ink) 12%, transparent); } +.feedback-dialog-field { display: grid; gap: var(--space-1); font-size: var(--type-body-sm); color: var(--color-ink-secondary); } +.feedback-dialog-field textarea, +.feedback-dialog-field input { width: 100%; padding: var(--space-2) var(--space-3); border: 1px solid var(--color-border); border-radius: var(--radius-md); background: var(--color-canvas); color: var(--color-ink); font: inherit; font-size: var(--type-body-md); } +.feedback-dialog-field textarea { resize: vertical; min-height: 120px; } +.feedback-dialog-check { display: flex; align-items: center; gap: var(--space-2); min-height: 44px; font-size: var(--type-body-sm); color: var(--color-ink-secondary); } +.feedback-dialog-error { margin: 0; color: var(--color-danger); font-size: var(--type-body-sm); } +.feedback-dialog-caption { margin: 0; color: var(--color-ink-tertiary); font-size: var(--type-caption); } +.feedback-dialog-done p { margin: 0; } +.feedback-dialog button:focus-visible, +.feedback-dialog textarea:focus-visible, +.feedback-dialog input:focus-visible { outline: 2px solid var(--color-focus); outline-offset: 2px; } +@media (min-width: 860px) { + .feedback-dialog { inset: 0; width: min(520px, 92vw); max-height: 80vh; margin: auto; border-radius: var(--radius-lg); } + .feedback-dialog-panel { max-height: calc(80vh - 2px); } +} +@media (max-width: 480px) { .feedback-dialog-types { grid-template-columns: repeat(2, 1fr); } } +@media print { .feedback-dialog { display: none !important; } } + /* ============================================================ personal-report: unique block — editorial reader + A4 print diff --git a/frontend/src/components/admin/admin-app.tsx b/frontend/src/components/admin/admin-app.tsx index d90d2d8f..cf9d86f9 100644 --- a/frontend/src/components/admin/admin-app.tsx +++ b/frontend/src/components/admin/admin-app.tsx @@ -112,6 +112,7 @@ export function AdminApp({ children }: { children: ReactNode }) { { name: "consultations", list: "/admin/consultations", meta: { label: "咨询请求", icon: } }, { name: "usage", list: "/admin/usage", meta: { label: "用量与成本", icon: } }, { name: "rectification-telemetry", list: "/admin/rectification-telemetry", meta: { label: "校正统计", icon: } }, + { name: "feedback", list: "/admin/feedback", meta: { label: "反馈与投诉", icon: } }, { name: "models", list: "/admin/models", meta: { label: "模型配置", icon: } }, { name: "feature-pricing", list: "/admin/feature-pricing", meta: { label: "功能定价", icon: } }, { name: "pricing-simulator", list: "/admin/pricing-simulator", meta: { label: "定价测算", icon: } }, diff --git a/frontend/src/components/admin/feedback-resource.tsx b/frontend/src/components/admin/feedback-resource.tsx new file mode 100644 index 00000000..38e96e24 --- /dev/null +++ b/frontend/src/components/admin/feedback-resource.tsx @@ -0,0 +1,125 @@ +"use client"; + +import { useGetIdentity, useInvalidate } from "@refinedev/core"; +import { App, Button, Input, Modal, Select, Space, Tag, Typography, type TableColumnsType } from "antd"; +import { useState } from "react"; + +import { adminRequestJson, type AdminIdentity } from "@/lib/admin/providers"; +import { + FEEDBACK_STATUS_LABELS, + FEEDBACK_STATUSES, + FEEDBACK_TYPE_LABELS, + FEEDBACK_TYPES, + type FeedbackStatus, + type FeedbackType, +} from "@/lib/user-feedback"; +import { formatAdminDate, ResourceTable } from "./resource-table"; + +const { Text, Paragraph } = Typography; + +type Feedback = { + id: string; + userId: string; + email: string | null; + type: FeedbackType; + body: string; + contact: string | null; + sessionId: string | null; + status: FeedbackStatus; + adminNote: string | null; + createdAt: string; + updatedAt: string; +}; + +const STATUS_COLOR: Record = { new: "gold", in_progress: "blue", resolved: "green", rejected: "default" }; + +/** + * 反馈与投诉 (compliance round 2026-09-30). New content reports sort first + * and are tagged red. Handling = a status plus an internal note; every change + * is audited by the database function. + */ +export function FeedbackResource() { + const { message } = App.useApp(); + const { data: identity } = useGetIdentity(); + const invalidate = useInvalidate(); + const canHandle = Boolean(identity?.permissions.includes("support.feedback.write")); + const [editing, setEditing] = useState(null); + const [status, setStatus] = useState("in_progress"); + const [note, setNote] = useState(""); + const [saving, setSaving] = useState(false); + + function open(item: Feedback) { + setEditing(item); + setStatus(item.status === "new" ? "in_progress" : item.status); + setNote(item.adminNote ?? ""); + } + + async function save() { + if (!editing) return; + setSaving(true); + try { + await adminRequestJson("/api/admin/feedback", { + method: "POST", + headers: { "x-request-id": crypto.randomUUID() }, + body: JSON.stringify({ id: editing.id, status, adminNote: note }), + }); + await invalidate({ resource: "feedback", invalidates: ["list"] }); + message.success("已更新处理状态"); + setEditing(null); + } catch (error) { + message.error(error instanceof Error ? error.message : "更新失败"); + } finally { + setSaving(false); + } + } + + const columns: TableColumnsType = [ + { + title: "类型", + render: (_, item) => {FEEDBACK_TYPE_LABELS[item.type]}, + }, + { + title: "内容", + render: (_, item) => {item.body}, + }, + { title: "用户", render: (_, item) => item.email ?? item.userId }, + { title: "联系方式", dataIndex: "contact", render: (value) => value ?? "—" }, + { title: "附带会话", dataIndex: "sessionId", render: (value) => (value ? {value} : "—") }, + { title: "状态", render: (_, item) => {FEEDBACK_STATUS_LABELS[item.status]} }, + { title: "提交时间", dataIndex: "createdAt", render: formatAdminDate }, + { + title: "处理", + fixed: "right", + render: (_, item) => (canHandle ? : null), + }, + ]; + + return ( + <> + + resource="feedback" + title="反馈与投诉" + columns={columns} + statusOptions={[ + ...FEEDBACK_STATUSES.map((value) => ({ value, label: FEEDBACK_STATUS_LABELS[value] })), + ...FEEDBACK_TYPES.map((value) => ({ value: `type:${value}`, label: `类型:${FEEDBACK_TYPE_LABELS[value]}` })), + ]} + /> + setEditing(null)} onOk={() => void save()} okText="保存" confirmLoading={saving}> + {editing ? ( + + {FEEDBACK_TYPE_LABELS[editing.type]} · {formatAdminDate(editing.createdAt)} + {editing.body} + + value={status} + onChange={setStatus} + options={FEEDBACK_STATUSES.map((value) => ({ value, label: FEEDBACK_STATUS_LABELS[value] }))} + style={{ width: 200 }} + /> + setNote(event.target.value)} maxLength={2000} rows={4} placeholder="内部备注(用户看不到)" /> + + ) : null} + + + ); +} diff --git a/frontend/src/components/app-sidebar.tsx b/frontend/src/components/app-sidebar.tsx index c270ca4f..a3b49904 100644 --- a/frontend/src/components/app-sidebar.tsx +++ b/frontend/src/components/app-sidebar.tsx @@ -1,5 +1,6 @@ "use client"; +import { FeedbackDialog } from "@/components/feedback-dialog"; import { Menu } from "@base-ui/react/menu"; import { CalendarDays, @@ -10,6 +11,7 @@ import { Settings, WalletCards, MessageSquareText, + MessageSquareWarning, SquarePen, Star, UserRound, @@ -151,6 +153,8 @@ export function AppSidebar({ }: Partial = controls ?? {}; /* Off `/` nothing controls the account menu, so it keeps its own open state (S2). */ const [ownMenuOpen, setOwnMenuOpen] = useState(false); + /* 反馈与投诉 is the sidebar's own dialog, so it opens the same way on every page. */ + const [feedbackOpen, setFeedbackOpen] = useState(false); const accountMenuOpen = controls ? controlledMenuOpen : ownMenuOpen; const onAccountMenuOpenChange = controls ? controlledMenuOpenChange : setOwnMenuOpen; const hasMoreSessions = sessionControls?.hasMore ?? false; @@ -368,6 +372,9 @@ export function AppSidebar({ )} + {feedbackOpen && account ? ( + setFeedbackOpen(false)} /> + ) : null} {account ? ( /* One account menu on every page. On `/` Home controls it and its @@ -412,6 +419,9 @@ export function AppSidebar({ onPointerEnter: () => { prefetchBillingPanel(); prefetchPaymentPackages(); }, onPointerDown: () => { prefetchBillingPanel(); prefetchPaymentPackages(); }, })} + { onAccountMenuOpenChange?.(false); setFeedbackOpen(true); }}> + diff --git a/frontend/src/components/chat-transcript.tsx b/frontend/src/components/chat-transcript.tsx index 1c72ec07..ab163cc5 100644 --- a/frontend/src/components/chat-transcript.tsx +++ b/frontend/src/components/chat-transcript.tsx @@ -7,6 +7,7 @@ import { type ChatMessageFeedback, } from "@/components/chat-message-actions"; import { ConversationFollowUps } from "@/components/conversation-follow-ups"; +import { persistReplyRating } from "@/lib/reply-ratings"; import { isGeneralDailyFortuneQuestion } from "@/lib/consultation-entrypoint"; import { deriveConsultationFollowUps } from "@/lib/consultation-follow-ups"; import type { ConsultationDomain } from "@/lib/consultation-domain-registry"; @@ -116,7 +117,11 @@ function MessageEntry({ feedback={messageFeedback[feedbackKey]} copied={copiedMessageKey === feedbackKey} canRegenerate={message.renderKey === latestRegeneratableKey} - onFeedback={(requested) => actionsRef.current.onFeedback(feedbackKey, requested)} + onFeedback={(requested) => { + // 回复评价 is saved here, where the current value and the reply text are both at hand. + void persistReplyRating(feedbackKey, toggleChatMessageFeedback(messageFeedback[feedbackKey], requested), message.text); + actionsRef.current.onFeedback(feedbackKey, requested); + }} onCopy={() => actionsRef.current.onCopy(feedbackKey, message.text)} onRegenerate={() => actionsRef.current.onRegenerate(message.renderKey)} /> diff --git a/frontend/src/components/feedback-dialog.tsx b/frontend/src/components/feedback-dialog.tsx new file mode 100644 index 00000000..2d664b33 --- /dev/null +++ b/frontend/src/components/feedback-dialog.tsx @@ -0,0 +1,140 @@ +"use client"; + +import { useEffect, useId, useRef, useState } from "react"; +import { X } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { LEGAL_ENTITY } from "@/lib/legal-entity"; +import { + FEEDBACK_BODY_MAX, + FEEDBACK_RESPONSE_WORKING_DAYS, + FEEDBACK_TYPE_LABELS, + FEEDBACK_TYPES, + feedbackBodyProblem, + type FeedbackType, +} from "@/lib/user-feedback"; + +const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +/** + * 反馈与投诉 (compliance round 2026-09-30): opened from the account menu on + * every page. Type, a description, an optional contact and, when a saved + * conversation is open, an opt-in to attach it (its id only). Submitting + * stores the row for the admin console; the success view promises a reply + * window and gives the contact email. + */ +export function FeedbackDialog({ accountEmail, sessionId, onClose }: Readonly<{ + accountEmail?: string | null; + sessionId?: string | null; + onClose: () => void; +}>) { + const dialog = useRef(null); + const titleId = useId(); + const [type, setType] = useState("problem"); + const [body, setBody] = useState(""); + const [contact, setContact] = useState(accountEmail ?? ""); + const [attach, setAttach] = useState(false); + const [error, setError] = useState(null); + const [sending, setSending] = useState(false); + const [sent, setSent] = useState(false); + const canAttach = Boolean(sessionId && UUID.test(sessionId)); + + useEffect(() => { + const element = dialog.current; + const previous = document.activeElement; + element?.showModal(); + return () => { + element?.close(); + if (previous instanceof HTMLElement && previous.isConnected) previous.focus({ preventScroll: true }); + }; + }, []); + + async function submit() { + const problem = feedbackBodyProblem(body); + if (problem) { + setError(problem); + return; + } + setSending(true); + setError(null); + try { + const response = await fetch("/api/feedback", { + method: "POST", + credentials: "same-origin", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + type, + body, + ...(contact.trim() ? { contact: contact.trim() } : {}), + ...(attach && canAttach && sessionId ? { sessionId } : {}), + }), + }); + if (!response.ok) { + const json = await response.json().catch(() => null) as { error?: string } | null; + setError(json?.error ?? "暂时没能提交,请稍后再试。"); + return; + } + setSent(true); + } catch { + setError("网络不太稳,没能提交,请稍后再试。"); + } finally { + setSending(false); + } + } + + return ( + { event.preventDefault(); onClose(); }} + onClick={(event) => { if (event.target === event.currentTarget) onClose(); }} + > +
+
+

反馈与投诉

+ +
+ {sent ? ( +
+

已收到,我们会在 {FEEDBACK_RESPONSE_WORKING_DAYS} 个工作日内处理。

+

急事也可以发邮件到 {LEGAL_ENTITY.contactEmail}。

+ +
+ ) : ( +
{ event.preventDefault(); void submit(); }}> +
+ {FEEDBACK_TYPES.map((option) => ( + + ))} +
+