feat: establish NekoNest Cloud control and relay

This commit is contained in:
2026-08-12 23:25:43 +08:00
commit f27606b709
222 changed files with 71456 additions and 0 deletions
+89
View File
@@ -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>
);
}
+87
View File
@@ -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>
);
}