桌面侧栏可在 200 到 360 像素之间拖宽,拖到最左收起,宽度记在本机。手机从左边滑出。会话在同一组里拖动排序,顺序写入可空的 sidebar_order,收藏仍置顶。账户菜单、设置、反馈和退出确认改用组件库。顶栏点数只显示图标和数字。启程清单还在。菜单、拖宽、拖动排序和翻页在首屏之后再载入。
自测:tsc 退出码 0;npm run lint 0 error、121 warning;改到的前端测试 205 项全过;npm run build 后首页仍是 static;首屏 gzip 630350,相对 8f7c0f7d 的 621226 为 +1.47%。test:db 92 项里 79 过、13 败,失败名与 Windows 已知清单一致,排序列在通过的 ok 37。没有跑全量 npm test,没有跑 Python 快速门。没有登录态,没有在浏览器里拖侧栏。
Co-Authored-By: Grok (agent loop)
145 lines
5.5 KiB
TypeScript
145 lines
5.5 KiB
TypeScript
"use client";
|
|
|
|
import { X } from "lucide-react";
|
|
import { useState } from "react";
|
|
|
|
import { Button } from "@/components/ui/button";
|
|
import { Checkbox } from "@/components/ui/checkbox";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from "@/components/ui/dialog";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|
import { Textarea } from "@/components/ui/textarea";
|
|
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";
|
|
import { notifyError } from "@/lib/notify";
|
|
|
|
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 FeedbackDialogBody({ accountEmail, sessionId, onClose }: Readonly<{
|
|
accountEmail?: string | null;
|
|
sessionId?: string | null;
|
|
onClose: () => void;
|
|
}>) {
|
|
const [type, setType] = useState<FeedbackType>("problem");
|
|
const [body, setBody] = useState("");
|
|
const [contact, setContact] = useState(accountEmail ?? "");
|
|
const [attach, setAttach] = useState(false);
|
|
const [sending, setSending] = useState(false);
|
|
const [sent, setSent] = useState(false);
|
|
const canAttach = Boolean(sessionId && UUID.test(sessionId));
|
|
|
|
async function submit() {
|
|
const problem = feedbackBodyProblem(body);
|
|
if (problem) {
|
|
notifyError(problem);
|
|
return;
|
|
}
|
|
setSending(true);
|
|
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;
|
|
notifyError(json?.error ?? "暂时没能提交,请稍后再试。");
|
|
return;
|
|
}
|
|
setSent(true);
|
|
} catch {
|
|
notifyError("网络不太稳,没能提交,请稍后再试。");
|
|
} finally {
|
|
setSending(false);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<>
|
|
{sent ? (
|
|
<div className="feedback-dialog-body feedback-dialog-done" role="status">
|
|
<p>已收到,我们会在 {FEEDBACK_RESPONSE_WORKING_DAYS} 个工作日内处理。</p>
|
|
<p className="feedback-dialog-caption">急事也可以发邮件到 {LEGAL_ENTITY.contactEmail}。</p>
|
|
<Button onClick={onClose}>好的</Button>
|
|
</div>
|
|
) : (
|
|
<form className="feedback-dialog-body" onSubmit={(event) => { event.preventDefault(); void submit(); }}>
|
|
<Tabs value={type} onValueChange={(next) => setType(next as FeedbackType)}>
|
|
<TabsList className="feedback-dialog-types" aria-label="反馈类型">
|
|
{FEEDBACK_TYPES.map((option) => (
|
|
<TabsTrigger key={option} value={option}>
|
|
{FEEDBACK_TYPE_LABELS[option]}
|
|
</TabsTrigger>
|
|
))}
|
|
</TabsList>
|
|
</Tabs>
|
|
<label className="feedback-dialog-field">
|
|
<span>发生了什么</span>
|
|
<Textarea
|
|
value={body}
|
|
maxLength={FEEDBACK_BODY_MAX}
|
|
rows={5}
|
|
placeholder={type === "content_report" ? "哪一段内容有问题,为什么" : type === "refund" ? "哪一次扣点或付款,希望怎么处理" : "尽量写清楚你做了什么、看到了什么"}
|
|
onChange={(event) => setBody(event.target.value)}
|
|
/>
|
|
</label>
|
|
<label className="feedback-dialog-field">
|
|
<span>联系方式(选填)</span>
|
|
<Input value={contact} maxLength={200} onChange={(event) => setContact(event.target.value)} placeholder="邮箱或手机号" />
|
|
</label>
|
|
{canAttach ? (
|
|
<label className="feedback-dialog-check">
|
|
<Checkbox checked={attach} onCheckedChange={(checked) => setAttach(checked === true)} />
|
|
<span>附上当前对话(只附编号,便于我们查看)</span>
|
|
</label>
|
|
) : null}
|
|
<Button type="submit" disabled={sending}>{sending ? "正在提交…" : "提交"}</Button>
|
|
</form>
|
|
)}
|
|
</>
|
|
);
|
|
}
|
|
|
|
export function FeedbackDialog(props: Readonly<{
|
|
accountEmail?: string | null;
|
|
sessionId?: string | null;
|
|
onClose: () => void;
|
|
}>) {
|
|
return (
|
|
<Dialog open onOpenChange={(next) => { if (!next) props.onClose(); }}>
|
|
<DialogContent className="feedback-dialog sm:max-w-md" showCloseButton={false}>
|
|
<DialogHeader className="feedback-dialog-header">
|
|
<DialogTitle>反馈与投诉</DialogTitle>
|
|
<Button variant="ghost" size="icon" onClick={props.onClose} aria-label="关闭反馈与投诉" title="关闭"><X aria-hidden="true" /></Button>
|
|
</DialogHeader>
|
|
<FeedbackDialogBody {...props} />
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|