mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-20 00:17:44 +00:00
467 lines
21 KiB
TypeScript
467 lines
21 KiB
TypeScript
"use client";
|
|
|
|
import { ArrowUpRight, Check, CheckCircle2, ImagePlus, LoaderCircle, RotateCcw, ShieldAlert, Sparkles, X } from "lucide-react";
|
|
import { useEffect, useRef, useState, type ClipboardEvent, type DragEvent } from "react";
|
|
import type { DocsLang } from "@/lib/i18n";
|
|
|
|
type IssueType = "bug" | "feature" | "question" | "compatibility";
|
|
|
|
type SelectedImage = {
|
|
file: File;
|
|
url: string;
|
|
};
|
|
|
|
type DraftResponse = {
|
|
draftId: string;
|
|
expiresAt: number;
|
|
preview: {
|
|
type: IssueType;
|
|
title: string;
|
|
summary: string;
|
|
body: string;
|
|
};
|
|
rateLimit: {
|
|
remaining: number;
|
|
resetAt: number;
|
|
};
|
|
};
|
|
|
|
type SubmitResponse = {
|
|
issueNumber: number;
|
|
issueUrl: string;
|
|
};
|
|
|
|
const copy = {
|
|
cn: {
|
|
draftStep: "问题描述",
|
|
previewStep: "Issue 预览",
|
|
descriptionLabel: "发生了什么?",
|
|
descriptionHint: "建议包含:你做了什么、看到了什么、期望怎样。版本和环境不知道也没关系,不要猜。",
|
|
descriptionPlaceholder: "例如:macOS 上连接 PostgreSQL 后,展开 schema 一直转圈,没有错误提示。重启和重新连接都试过了。",
|
|
attachments: "截图(可选)",
|
|
attachmentHint: "点击添加、拖入或直接粘贴截图。支持 PNG、JPEG 和 WebP,最多 3 张,单张不超过 5 MB。",
|
|
addImages: "添加截图",
|
|
generate: "整理为 Issue 草稿",
|
|
generateAgain: "重新整理",
|
|
generating: "正在整理",
|
|
type: "类型",
|
|
titleLabel: "标题",
|
|
bodyLabel: "GitHub 正文",
|
|
bodyHint: "可以自由修改。最终提交时会在末尾追加图片和匿名来源说明。",
|
|
summary: "整理摘要",
|
|
confirm: "我已检查标题、正文和截图,并理解这些内容将公开显示在 GitHub。",
|
|
submit: "确认并公开创建 Issue",
|
|
submitting: "正在创建 Issue",
|
|
publicWarning: "提交前请确认",
|
|
publicNotice: "请勿上传密码、Token、连接串、未脱敏 IP、客户或隐私数据;提交后 Issue 和图片将公开可见。",
|
|
successTitle: "Issue 已创建",
|
|
successText: (number: number) => `GitHub Issue #${number} 已创建。后续进展会公开记录在该页面。`,
|
|
openIssue: "查看 GitHub Issue",
|
|
another: "再提交一个",
|
|
removeImage: "移除图片",
|
|
errors: {
|
|
DESCRIPTION_REQUIRED: "请先填写问题描述。",
|
|
DESCRIPTION_TOO_SHORT: "描述至少需要 4 个字符。",
|
|
DESCRIPTION_TOO_LONG: "描述过长,请控制在 6000 字符以内。",
|
|
TOO_MANY_IMAGES: "最多只能上传 3 张图片。",
|
|
IMAGE_TOO_LARGE: "单张图片不能超过 5 MB。",
|
|
IMAGES_TOO_LARGE: "图片总大小不能超过 12 MB。",
|
|
IMAGE_TYPE_UNSUPPORTED: "仅支持真实的 PNG、JPEG 或 WebP 图片。",
|
|
RATE_LIMITED: "生成草稿过于频繁,请稍后再试。",
|
|
AI_NOT_CONFIGURED: "AI 草稿服务尚未配置,请联系维护者。",
|
|
AI_REQUEST_FAILED: "AI 服务暂时不可用,请稍后重试。",
|
|
AI_REQUEST_TIMEOUT: "截图分析时间较长,请重试;如果仍然超时,可以减少截图数量。",
|
|
AI_TIMEOUT_TEXT_ONLY: "AI 服务响应超时,请稍后重试。",
|
|
AI_RESPONSE_INVALID: "AI 返回的草稿格式异常,请重新生成。",
|
|
DRAFT_EXPIRED: "草稿已过期,请重新生成。",
|
|
DRAFT_SUBMITTING: "这个草稿正在提交,请不要重复点击。",
|
|
DRAFT_IMAGES_CHANGED: "草稿生成后截图发生变化,请重新生成草稿。",
|
|
ISSUE_SESSION_EXPIRED: "临时会话已过期,请重新生成草稿。",
|
|
GITHUB_ISSUE_CREATE_FAILED: "GitHub 暂时未能创建 Issue,请稍后重试。",
|
|
ISSUE_IMAGE_UPLOAD_FAILED: "截图上传失败,请稍后重试。",
|
|
ISSUE_REQUEST_FAILED: "提交失败,请稍后重试。",
|
|
},
|
|
genericError: "请求失败,请稍后重试。",
|
|
clientImageError: "仅支持 PNG、JPEG 或 WebP,最多 3 张,单张不超过 5 MB。",
|
|
retryAfter: (minutes: number) => `大约 ${minutes} 分钟后可再次生成。`,
|
|
},
|
|
en: {
|
|
draftStep: "Describe the issue",
|
|
previewStep: "Issue preview",
|
|
descriptionLabel: "What happened?",
|
|
descriptionHint: "Include what you did, what you saw, and what you expected. If you do not know a version or environment detail, do not guess.",
|
|
descriptionPlaceholder: "Example: On macOS, expanding a PostgreSQL schema keeps loading without an error. Restarting and reconnecting did not help.",
|
|
attachments: "Screenshots (optional)",
|
|
attachmentHint: "Choose, drag, or paste screenshots here. PNG, JPEG, or WebP; up to 3 images and 5 MB each.",
|
|
addImages: "Add screenshots",
|
|
generate: "Prepare Issue draft",
|
|
generateAgain: "Prepare again",
|
|
generating: "Preparing draft",
|
|
type: "Type",
|
|
titleLabel: "Title",
|
|
bodyLabel: "GitHub body",
|
|
bodyHint: "Fully editable. Images and an anonymous-source note are appended when submitted.",
|
|
summary: "Draft summary",
|
|
confirm: "I reviewed the title, body, and screenshots and understand that they will be publicly visible on GitHub.",
|
|
submit: "Confirm and create public Issue",
|
|
submitting: "Creating Issue",
|
|
publicWarning: "Before submitting",
|
|
publicNotice: "Do not upload passwords, tokens, connection strings, unredacted IPs, customer data, or personal data. The Issue and images will be public.",
|
|
successTitle: "Issue created",
|
|
successText: (number: number) => `GitHub Issue #${number} was created. Follow-up progress will be recorded on that public page.`,
|
|
openIssue: "Open GitHub Issue",
|
|
another: "Submit another",
|
|
removeImage: "Remove image",
|
|
errors: {
|
|
DESCRIPTION_REQUIRED: "Describe the problem first.",
|
|
DESCRIPTION_TOO_SHORT: "The description must contain at least 4 characters.",
|
|
DESCRIPTION_TOO_LONG: "Keep the description under 6,000 characters.",
|
|
TOO_MANY_IMAGES: "You can upload up to 3 images.",
|
|
IMAGE_TOO_LARGE: "Each image must be 5 MB or smaller.",
|
|
IMAGES_TOO_LARGE: "Images must be 12 MB or smaller in total.",
|
|
IMAGE_TYPE_UNSUPPORTED: "Only real PNG, JPEG, and WebP images are supported.",
|
|
RATE_LIMITED: "Too many draft attempts. Please try again later.",
|
|
AI_NOT_CONFIGURED: "The AI drafting service has not been configured yet.",
|
|
AI_REQUEST_FAILED: "The AI service is temporarily unavailable. Try again later.",
|
|
AI_REQUEST_TIMEOUT: "Screenshot analysis took too long. Try again, or attach fewer screenshots.",
|
|
AI_TIMEOUT_TEXT_ONLY: "The AI service timed out. Please try again later.",
|
|
AI_RESPONSE_INVALID: "The AI returned an invalid draft. Generate it again.",
|
|
DRAFT_EXPIRED: "This draft expired. Generate a new one.",
|
|
DRAFT_SUBMITTING: "This draft is already being submitted.",
|
|
DRAFT_IMAGES_CHANGED: "The screenshots changed after drafting. Generate the draft again.",
|
|
ISSUE_SESSION_EXPIRED: "The temporary session expired. Generate the draft again.",
|
|
GITHUB_ISSUE_CREATE_FAILED: "GitHub could not create the Issue. Try again later.",
|
|
ISSUE_IMAGE_UPLOAD_FAILED: "The screenshots could not be uploaded. Try again later.",
|
|
ISSUE_REQUEST_FAILED: "Submission failed. Try again later.",
|
|
},
|
|
genericError: "The request failed. Try again later.",
|
|
clientImageError: "Use up to 3 PNG, JPEG, or WebP images, no more than 5 MB each.",
|
|
retryAfter: (minutes: number) => `Try again in about ${minutes} minutes.`,
|
|
},
|
|
};
|
|
|
|
const issuePrefixes: Record<IssueType, string> = {
|
|
bug: "[Bug]",
|
|
feature: "[Feature]",
|
|
question: "[Question]",
|
|
compatibility: "[Compatibility]",
|
|
};
|
|
|
|
class ResponseError extends Error {
|
|
constructor(
|
|
message: string,
|
|
public readonly code: string,
|
|
) {
|
|
super(message);
|
|
this.name = "ResponseError";
|
|
}
|
|
}
|
|
|
|
function responseErrorMessage(lang: DocsLang, code: string, retryAfter?: number, hasImages = false): string {
|
|
const t = copy[lang];
|
|
const key = code === "AI_REQUEST_TIMEOUT" && !hasImages ? "AI_TIMEOUT_TEXT_ONLY" : code;
|
|
const base = t.errors[key as keyof typeof t.errors] ?? t.genericError;
|
|
if (code !== "RATE_LIMITED" || !retryAfter) return base;
|
|
return `${base} ${t.retryAfter(Math.max(1, Math.ceil(retryAfter / 60)))}`;
|
|
}
|
|
|
|
export function IssueSubmissionClient({ lang }: { lang: DocsLang }) {
|
|
const t = copy[lang];
|
|
const [description, setDescription] = useState("");
|
|
const [images, setImages] = useState<SelectedImage[]>([]);
|
|
const [draftId, setDraftId] = useState("");
|
|
const [draftExpiresAt, setDraftExpiresAt] = useState(0);
|
|
const [issueType, setIssueType] = useState<IssueType>("bug");
|
|
const [title, setTitle] = useState("");
|
|
const [body, setBody] = useState("");
|
|
const [summary, setSummary] = useState("");
|
|
const [confirmed, setConfirmed] = useState(false);
|
|
const [loading, setLoading] = useState<"draft" | "submit" | null>(null);
|
|
const [draggingImages, setDraggingImages] = useState(false);
|
|
const [error, setError] = useState("");
|
|
const [success, setSuccess] = useState<SubmitResponse | null>(null);
|
|
const imagesRef = useRef<SelectedImage[]>([]);
|
|
|
|
useEffect(() => {
|
|
imagesRef.current = images;
|
|
}, [images]);
|
|
|
|
useEffect(() => {
|
|
return () => imagesRef.current.forEach((image) => URL.revokeObjectURL(image.url));
|
|
}, []);
|
|
|
|
function clearDraft() {
|
|
setDraftId("");
|
|
setDraftExpiresAt(0);
|
|
setTitle("");
|
|
setBody("");
|
|
setSummary("");
|
|
setConfirmed(false);
|
|
setSuccess(null);
|
|
}
|
|
|
|
function changeDescription(value: string) {
|
|
setDescription(value);
|
|
if (draftId) clearDraft();
|
|
setError("");
|
|
}
|
|
|
|
function addImages(files: FileList | File[] | null) {
|
|
if (!files) return;
|
|
const candidates = Array.from(files);
|
|
if (candidates.length === 0) return;
|
|
const valid = candidates.every((file) => ["image/png", "image/jpeg", "image/webp"].includes(file.type) && file.size <= 5 * 1024 * 1024);
|
|
const totalBytes = [...images.map((image) => image.file), ...candidates].reduce((total, file) => total + file.size, 0);
|
|
if (!valid || images.length + candidates.length > 3 || totalBytes > 12 * 1024 * 1024) {
|
|
setError(t.clientImageError);
|
|
return;
|
|
}
|
|
if (draftId) clearDraft();
|
|
setImages((current) => [...current, ...candidates.map((file) => ({ file, url: URL.createObjectURL(file) }))]);
|
|
setError("");
|
|
}
|
|
|
|
function pasteImages(event: ClipboardEvent<HTMLDivElement>) {
|
|
const pastedImages = Array.from(event.clipboardData.items)
|
|
.filter((item) => item.kind === "file" && item.type.startsWith("image/"))
|
|
.map((item) => item.getAsFile())
|
|
.filter((file): file is File => file !== null);
|
|
if (pastedImages.length === 0) return;
|
|
event.preventDefault();
|
|
addImages(pastedImages);
|
|
}
|
|
|
|
function dropImages(event: DragEvent<HTMLDivElement>) {
|
|
event.preventDefault();
|
|
setDraggingImages(false);
|
|
addImages(event.dataTransfer.files);
|
|
}
|
|
|
|
function removeImage(index: number) {
|
|
if (draftId) clearDraft();
|
|
setImages((current) => {
|
|
const target = current[index];
|
|
if (target) URL.revokeObjectURL(target.url);
|
|
return current.filter((_, currentIndex) => currentIndex !== index);
|
|
});
|
|
}
|
|
|
|
function changeIssueType(nextType: IssueType) {
|
|
const withoutPrefix = title.replace(/^\[(?:Bug|Feature|Question|Compatibility)]\s*/i, "").trim();
|
|
setIssueType(nextType);
|
|
setTitle(`${issuePrefixes[nextType]} ${withoutPrefix}`.trim());
|
|
setConfirmed(false);
|
|
}
|
|
|
|
async function parseResponse<T>(response: Response, hasImages = false): Promise<T> {
|
|
let data: Record<string, unknown> = {};
|
|
try {
|
|
data = (await response.json()) as Record<string, unknown>;
|
|
} catch {
|
|
data = {};
|
|
}
|
|
if (!response.ok) {
|
|
const code = typeof data.error === "string" ? data.error : "ISSUE_REQUEST_FAILED";
|
|
const retryAfter = typeof data.retryAfter === "number" ? data.retryAfter : undefined;
|
|
throw new ResponseError(responseErrorMessage(lang, code, retryAfter, hasImages), code);
|
|
}
|
|
return data as T;
|
|
}
|
|
|
|
async function requestDraft(): Promise<DraftResponse> {
|
|
const form = new FormData();
|
|
form.set("description", description);
|
|
form.set("language", lang);
|
|
imagesRef.current.forEach((image) => form.append("images", image.file));
|
|
const response = await fetch("/api/issues/draft", { method: "POST", body: form });
|
|
return parseResponse<DraftResponse>(response, imagesRef.current.length > 0);
|
|
}
|
|
|
|
async function generateDraft() {
|
|
setLoading("draft");
|
|
setError("");
|
|
setSuccess(null);
|
|
try {
|
|
let data: DraftResponse;
|
|
try {
|
|
data = await requestDraft();
|
|
} catch (requestError) {
|
|
// The AI gateway is slow to admit requests far more often than it is
|
|
// down, so a timed-out text-only draft gets one silent retry. Timed-out
|
|
// drafts with screenshots are not retried: 90s already elapsed and the
|
|
// timeout message offers removing screenshots instead.
|
|
if (!(requestError instanceof ResponseError) || requestError.code !== "AI_REQUEST_TIMEOUT" || imagesRef.current.length > 0) throw requestError;
|
|
data = await requestDraft();
|
|
}
|
|
setDraftId(data.draftId);
|
|
setDraftExpiresAt(data.expiresAt);
|
|
setIssueType(data.preview.type);
|
|
setTitle(data.preview.title);
|
|
setBody(data.preview.body);
|
|
setSummary(data.preview.summary);
|
|
setConfirmed(false);
|
|
} catch (requestError) {
|
|
setError(requestError instanceof Error ? requestError.message : t.genericError);
|
|
} finally {
|
|
setLoading(null);
|
|
}
|
|
}
|
|
|
|
async function submitIssue() {
|
|
if (!draftId || !confirmed) return;
|
|
if (draftExpiresAt <= Date.now()) {
|
|
setError(responseErrorMessage(lang, "DRAFT_EXPIRED"));
|
|
return;
|
|
}
|
|
setLoading("submit");
|
|
setError("");
|
|
const form = new FormData();
|
|
form.set("draftId", draftId);
|
|
form.set("type", issueType);
|
|
form.set("title", title);
|
|
form.set("body", body);
|
|
images.forEach((image) => form.append("images", image.file));
|
|
try {
|
|
const response = await fetch("/api/issues/submit", { method: "POST", body: form });
|
|
setSuccess(await parseResponse<SubmitResponse>(response));
|
|
} catch (requestError) {
|
|
setError(requestError instanceof Error ? requestError.message : t.genericError);
|
|
} finally {
|
|
setLoading(null);
|
|
}
|
|
}
|
|
|
|
function resetForm() {
|
|
images.forEach((image) => URL.revokeObjectURL(image.url));
|
|
setDescription("");
|
|
setImages([]);
|
|
clearDraft();
|
|
setError("");
|
|
}
|
|
|
|
if (success) {
|
|
return (
|
|
<section className="issue-shell issue-success-shell">
|
|
<div className="issue-success-card">
|
|
<span className="issue-success-icon"><CheckCircle2 size={30} /></span>
|
|
<h1>{t.successTitle}</h1>
|
|
<p>{t.successText(success.issueNumber)}</p>
|
|
<div className="issue-success-actions">
|
|
<a href={success.issueUrl} target="_blank" rel="noopener noreferrer" className="issue-primary-button">
|
|
{t.openIssue}<ArrowUpRight size={17} />
|
|
</a>
|
|
<button type="button" className="issue-secondary-button" onClick={resetForm}>
|
|
<RotateCcw size={16} />{t.another}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<section className="issue-shell">
|
|
<div className="issue-layout">
|
|
<div className="issue-workbench" onPaste={pasteImages}>
|
|
<section className="issue-public-note">
|
|
<div className="issue-warning-heading"><ShieldAlert size={15} /><strong>{t.publicWarning}</strong></div>
|
|
<p>{t.publicNotice}</p>
|
|
</section>
|
|
|
|
<section className="issue-panel">
|
|
<div className="issue-panel-heading">
|
|
<span className="issue-step-number">1</span>
|
|
<h1>{t.draftStep}</h1>
|
|
</div>
|
|
|
|
<label className="issue-field">
|
|
<span>{t.descriptionLabel}</span>
|
|
<small>{t.descriptionHint}</small>
|
|
<textarea value={description} onChange={(event) => changeDescription(event.target.value)} placeholder={t.descriptionPlaceholder} maxLength={6000} rows={7} disabled={loading !== null} />
|
|
<em>{description.length} / 6000</em>
|
|
</label>
|
|
|
|
<div
|
|
className={`issue-upload-block${draggingImages ? " is-dragging" : ""}`}
|
|
onDragEnter={(event) => { event.preventDefault(); setDraggingImages(true); }}
|
|
onDragOver={(event) => { event.preventDefault(); event.dataTransfer.dropEffect = "copy"; setDraggingImages(true); }}
|
|
onDragLeave={(event) => {
|
|
if (!event.currentTarget.contains(event.relatedTarget as Node | null)) setDraggingImages(false);
|
|
}}
|
|
onDrop={dropImages}
|
|
>
|
|
<div><strong>{t.attachments}</strong><small>{t.attachmentHint}</small></div>
|
|
{images.length > 0 && (
|
|
<div className="issue-image-grid">
|
|
{images.map((image, index) => (
|
|
<div className="issue-image-preview" key={`${image.file.name}-${image.file.lastModified}-${index}`}>
|
|
<img src={image.url} alt={image.file.name} />
|
|
<button type="button" onClick={() => removeImage(index)} aria-label={t.removeImage} disabled={loading !== null}><X size={15} /></button>
|
|
<span>{Math.max(1, Math.round(image.file.size / 1024))} KB</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
{images.length < 3 && (
|
|
<label className="issue-upload-button">
|
|
<ImagePlus size={17} />{t.addImages}
|
|
<input type="file" accept="image/png,image/jpeg,image/webp" multiple onChange={(event) => { addImages(event.target.files); event.currentTarget.value = ""; }} disabled={loading !== null} />
|
|
</label>
|
|
)}
|
|
</div>
|
|
|
|
<div className="issue-action-row">
|
|
<button type="button" className="issue-primary-button" onClick={generateDraft} disabled={loading !== null || description.trim().length < 4}>
|
|
{loading === "draft" ? <LoaderCircle className="issue-spinner" size={18} /> : <Sparkles size={18} />}
|
|
{loading === "draft" ? t.generating : draftId ? t.generateAgain : t.generate}
|
|
</button>
|
|
</div>
|
|
</section>
|
|
|
|
{draftId && (
|
|
<section className="issue-panel issue-preview-panel">
|
|
<div className="issue-panel-heading">
|
|
<span className="issue-step-number">2</span>
|
|
<h2>{t.previewStep}</h2>
|
|
</div>
|
|
<div className="issue-ai-summary"><Sparkles size={16} /><p>{summary}</p></div>
|
|
<div className="issue-preview-grid">
|
|
<label className="issue-field issue-type-field">
|
|
<span>{t.type}</span>
|
|
<select value={issueType} onChange={(event) => changeIssueType(event.target.value as IssueType)} disabled={loading !== null}>
|
|
<option value="bug">Bug</option>
|
|
<option value="feature">Feature</option>
|
|
<option value="question">Question</option>
|
|
<option value="compatibility">Compatibility</option>
|
|
</select>
|
|
</label>
|
|
<label className="issue-field issue-title-field">
|
|
<span>{t.titleLabel}</span>
|
|
<input value={title} onChange={(event) => { setTitle(event.target.value); setConfirmed(false); }} maxLength={160} disabled={loading !== null} />
|
|
</label>
|
|
</div>
|
|
<label className="issue-field">
|
|
<span>{t.bodyLabel}</span>
|
|
<small>{t.bodyHint}</small>
|
|
<textarea className="issue-body-editor" value={body} onChange={(event) => { setBody(event.target.value); setConfirmed(false); }} maxLength={12000} rows={18} disabled={loading !== null} />
|
|
<em>{body.length} / 12000</em>
|
|
</label>
|
|
<label className="issue-confirm-row">
|
|
<input type="checkbox" checked={confirmed} onChange={(event) => setConfirmed(event.target.checked)} disabled={loading !== null} />
|
|
<span aria-hidden="true"><Check size={14} /></span>
|
|
<p>{t.confirm}</p>
|
|
</label>
|
|
<button type="button" className="issue-submit-button" onClick={submitIssue} disabled={!confirmed || loading !== null || title.trim().length < 8 || body.trim().length < 20}>
|
|
{loading === "submit" ? <LoaderCircle className="issue-spinner" size={18} /> : <ArrowUpRight size={18} />}
|
|
{loading === "submit" ? t.submitting : t.submit}
|
|
</button>
|
|
</section>
|
|
)}
|
|
|
|
{error && <div className="issue-error" role="alert"><ShieldAlert size={18} /><span>{error}</span></div>}
|
|
</div>
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|