feat: establish NekoNest Cloud control and relay
This commit is contained in:
@@ -0,0 +1,89 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import type { FeedbackCategory } from "@/db/repository";
|
||||
|
||||
type SubmitState = { loading: boolean; message: string; error: boolean };
|
||||
|
||||
export function FeedbackForm() {
|
||||
const router = useRouter();
|
||||
const pendingKey = useRef<string | null>(null);
|
||||
const [category, setCategory] = useState<FeedbackCategory>("connection_issue");
|
||||
const [message, setMessage] = useState("");
|
||||
const [state, setState] = useState<SubmitState>({
|
||||
loading: false,
|
||||
message: "",
|
||||
error: false,
|
||||
});
|
||||
|
||||
async function submit(event: React.FormEvent) {
|
||||
event.preventDefault();
|
||||
setState({ loading: true, message: "", error: false });
|
||||
pendingKey.current ??= crypto.randomUUID();
|
||||
try {
|
||||
const response = await fetch("/api/feedback", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
category,
|
||||
message,
|
||||
idempotencyKey: pendingKey.current,
|
||||
}),
|
||||
});
|
||||
const body = (await response.json()) as { message?: string };
|
||||
if (!response.ok) throw new Error(body.message || "提交失败");
|
||||
pendingKey.current = null;
|
||||
setMessage("");
|
||||
setState({
|
||||
loading: false,
|
||||
message: "已收到。处理结果会显示在本页,不需要重复提交。",
|
||||
error: false,
|
||||
});
|
||||
router.refresh();
|
||||
} catch (error) {
|
||||
setState({
|
||||
loading: false,
|
||||
message: error instanceof Error ? error.message : "提交失败",
|
||||
error: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form className="cloud-form feedback-form" onSubmit={submit}>
|
||||
<label>
|
||||
<span>问题类型</span>
|
||||
<select
|
||||
value={category}
|
||||
onChange={(event) => setCategory(event.target.value as FeedbackCategory)}
|
||||
>
|
||||
<option value="connection_issue">连接或接入问题</option>
|
||||
<option value="bug">功能异常</option>
|
||||
<option value="suggestion">使用建议</option>
|
||||
<option value="other">其他</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>详细描述</span>
|
||||
<textarea
|
||||
minLength={10}
|
||||
maxLength={2000}
|
||||
value={message}
|
||||
onChange={(event) => setMessage(event.target.value)}
|
||||
placeholder="请写清出现了什么、你原本想完成什么;不要粘贴令牌、密码或私密会话内容。"
|
||||
required
|
||||
/>
|
||||
<small>{message.length}/2000 · 请勿提交密钥、令牌或会话正文</small>
|
||||
</label>
|
||||
<button className="button button-primary" type="submit" disabled={state.loading}>
|
||||
{state.loading ? "正在提交…" : "提交反馈"}
|
||||
</button>
|
||||
{state.message && (
|
||||
<p className={state.error ? "form-error" : "form-success"} role="status">
|
||||
{state.message}
|
||||
</p>
|
||||
)}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { requireCloudViewer } from "../../cloud-auth";
|
||||
import {
|
||||
DashboardShell,
|
||||
PageHeading,
|
||||
StatusPill,
|
||||
formatDate,
|
||||
} from "../../components/Shells";
|
||||
import {
|
||||
getOrCreateAccount,
|
||||
listAccountFeedback,
|
||||
type FeedbackCategory,
|
||||
} from "@/db/repository";
|
||||
import { FeedbackForm } from "./FeedbackForm";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const categoryLabels: Record<FeedbackCategory, string> = {
|
||||
connection_issue: "连接或接入",
|
||||
bug: "功能异常",
|
||||
suggestion: "使用建议",
|
||||
other: "其他",
|
||||
};
|
||||
|
||||
export default async function FeedbackPage() {
|
||||
const viewer = await requireCloudViewer("/dashboard/feedback");
|
||||
const account = await getOrCreateAccount(viewer);
|
||||
const feedback = await listAccountFeedback(account.id);
|
||||
|
||||
return (
|
||||
<DashboardShell viewer={viewer} active="/dashboard/feedback">
|
||||
<div className="cloud-page narrow-cloud-page">
|
||||
<PageHeading
|
||||
eyebrow="BETA FEEDBACK / 公测反馈"
|
||||
title="遇到问题,直接告诉我们。"
|
||||
description="这是免费公测期的站内反馈通道。后台回复会保存在这里;当前不承诺即时客服,但接入故障会优先处理。"
|
||||
/>
|
||||
<div className="feedback-layout">
|
||||
<section className="panel form-panel">
|
||||
<div className="panel-heading">
|
||||
<div><span>新反馈</span><h2>描述你卡住的地方</h2></div>
|
||||
</div>
|
||||
<FeedbackForm />
|
||||
</section>
|
||||
<aside className="form-aside feedback-aside">
|
||||
<span className="eyebrow">提交前</span>
|
||||
<h2>尽量让问题可以复现。</h2>
|
||||
<ol>
|
||||
<li><strong>先写目标</strong><p>你原本想完成什么操作。</p></li>
|
||||
<li><strong>再写现象</strong><p>页面或 daemon 显示了什么。</p></li>
|
||||
<li><strong>保护秘密</strong><p>不要粘贴令牌、密码、私钥或完整会话正文。</p></li>
|
||||
</ol>
|
||||
</aside>
|
||||
</div>
|
||||
<section className="panel full-panel">
|
||||
<div className="panel-heading">
|
||||
<div><span>处理记录</span><h2>我的反馈</h2></div>
|
||||
<StatusPill tone="info">{feedback.length} 条</StatusPill>
|
||||
</div>
|
||||
{feedback.length ? (
|
||||
<div className="feedback-list">
|
||||
{feedback.map((item) => (
|
||||
<article key={item.id}>
|
||||
<div className="feedback-meta">
|
||||
<StatusPill tone={item.status === "resolved" ? "good" : "warn"}>
|
||||
{item.status === "resolved" ? "已回复" : "待处理"}
|
||||
</StatusPill>
|
||||
<span>{categoryLabels[item.category]}</span>
|
||||
<time>{formatDate(item.created_at, true)}</time>
|
||||
</div>
|
||||
<p>{item.message}</p>
|
||||
{item.admin_response && (
|
||||
<div className="feedback-response">
|
||||
<strong>后台回复</strong>
|
||||
<p>{item.admin_response}</p>
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="empty-inline">还没有反馈记录。</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</DashboardShell>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user