接入 AGC 客户端错误采集与报告面板
在当前进程按指纹合并客户端错误并持久化应用诊断日志。 新增登录态批量提交、用户说明和可选日志上传面板。 将 /bug-report 快捷入口改为打开报告问题面板并更新端到端断言。
This commit is contained in:
@@ -39,6 +39,7 @@ use shared_contracts::game_creation_app::{
|
||||
GAME_CREATION_AGENT_TOOL_CALL_MAX, GAME_CREATION_APP_COMMANDS,
|
||||
GAME_CREATION_APP_LIMITED_RUN_COMMANDS, GAME_CREATION_RESOURCE_LAYOUT_SCHEMA_VERSION,
|
||||
};
|
||||
use shared_contracts::error_reports::{ErrorReportEventInput, ErrorReportLogInput};
|
||||
use tauri::{Emitter, Manager};
|
||||
use tauri_plugin_dialog::DialogExt;
|
||||
use tauri_plugin_opener::OpenerExt;
|
||||
@@ -1563,6 +1564,49 @@ static DIAGNOSTIC_LOG_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
|
||||
static STARTUP_PANIC_LOG_PATH: OnceLock<PathBuf> = OnceLock::new();
|
||||
static STARTUP_ERROR_DIALOG_SHOWN: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
#[tauri::command]
|
||||
fn append_diagnostic_event(event: ErrorReportEventInput) -> Result<(), String> {
|
||||
let config_dir = game_creator_runtime_config_dir()
|
||||
.ok_or_else(|| "客户端 AppData 配置目录未初始化".to_string())?;
|
||||
let path = config_dir.join("diagnostics/application.log");
|
||||
let mut event = event;
|
||||
event.event_id = sanitize_diagnostic_message(&event.event_id, Some(&config_dir));
|
||||
event.fingerprint = sanitize_diagnostic_message(&event.fingerprint, Some(&config_dir));
|
||||
event.source = sanitize_diagnostic_message(&event.source, Some(&config_dir));
|
||||
event.message = sanitize_diagnostic_message(&event.message, Some(&config_dir));
|
||||
event.stack = event
|
||||
.stack
|
||||
.take()
|
||||
.map(|value| sanitize_diagnostic_message(&value, Some(&config_dir)));
|
||||
event.occurred_at = sanitize_diagnostic_message(&event.occurred_at, Some(&config_dir));
|
||||
let line = serde_json::to_string(&event).map_err(|error| error.to_string())?;
|
||||
append_bounded_diagnostic_line(&path, &line).map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn read_diagnostic_logs() -> Result<Vec<ErrorReportLogInput>, String> {
|
||||
let Some(config_dir) = game_creator_runtime_config_dir() else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
let _guard = DIAGNOSTIC_LOG_LOCK
|
||||
.get_or_init(|| Mutex::new(()))
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
let directory = config_dir.join("diagnostics");
|
||||
let mut files = Vec::new();
|
||||
for name in ["application.log", "application.previous.log", "startup.log"] {
|
||||
let path = directory.join(name);
|
||||
let Ok(content) = fs::read_to_string(&path) else {
|
||||
continue;
|
||||
};
|
||||
files.push(ErrorReportLogInput {
|
||||
name: name.to_string(),
|
||||
content,
|
||||
});
|
||||
}
|
||||
Ok(files)
|
||||
}
|
||||
|
||||
fn diagnostic_timestamp() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
@@ -2059,6 +2103,11 @@ fn main() {
|
||||
show_startup_error_dialog(path);
|
||||
}
|
||||
})?;
|
||||
let startup_log = game_creator_runtime_config_dir()
|
||||
.map(|directory| directory.join("diagnostics/startup.log"));
|
||||
if let Some(path) = startup_log.as_deref() {
|
||||
let _ = append_bounded_diagnostic_line(path, "startup.appdata.configure.complete");
|
||||
}
|
||||
if let Some(path) = setup_log.as_deref() {
|
||||
let _ = append_bounded_diagnostic_line(path, "startup.appdata.configure.complete");
|
||||
}
|
||||
@@ -2292,7 +2341,9 @@ fn main() {
|
||||
commit_local_project_asset,
|
||||
commit_local_project_asset_canvas_candidate,
|
||||
get_local_game_project_revision,
|
||||
get_local_game_manifest
|
||||
get_local_game_manifest,
|
||||
append_diagnostic_event,
|
||||
read_diagnostic_logs
|
||||
])
|
||||
.build(tauri_context);
|
||||
let app = match app {
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
|
||||
import type { AuthUser } from '../../../../packages/shared/src/contracts/auth';
|
||||
import brandIcon from '../../../../packages/shared/src/icons/taonier-product-ip.png';
|
||||
import { ErrorReportDialog } from '../components/error-report/ErrorReportDialog';
|
||||
import {
|
||||
clearStoredAuthAccessToken,
|
||||
getClientAuthErrorMessage,
|
||||
@@ -29,6 +30,7 @@ import {
|
||||
normalizeClientServerBaseUrl,
|
||||
setClientServerSelection,
|
||||
} from '../services/clientHttp';
|
||||
import { captureClientError } from '../services/errorReporting';
|
||||
import {
|
||||
beginPlatformSessionClearTransition,
|
||||
beginPlatformSessionTransition,
|
||||
@@ -43,6 +45,7 @@ import {
|
||||
type ClientRuntimeErrorBoundaryProps = {
|
||||
children: ReactNode;
|
||||
onLogout: () => void;
|
||||
onReport?: () => void;
|
||||
};
|
||||
|
||||
type ClientRuntimeErrorBoundaryState = {
|
||||
@@ -66,6 +69,7 @@ export class ClientRuntimeErrorBoundary extends Component<
|
||||
|
||||
componentDidCatch(error: unknown, errorInfo: ErrorInfo) {
|
||||
console.error('AGC authenticated client render failed', error, errorInfo);
|
||||
void captureClientError(error, { source: 'react-render' });
|
||||
}
|
||||
|
||||
render() {
|
||||
@@ -86,6 +90,11 @@ export class ClientRuntimeErrorBoundary extends Component<
|
||||
<button type="button" onClick={this.props.onLogout}>
|
||||
返回登录
|
||||
</button>
|
||||
{this.props.onReport ? (
|
||||
<button type="button" onClick={this.props.onReport}>
|
||||
报告问题
|
||||
</button>
|
||||
) : null}
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
@@ -109,6 +118,7 @@ export function AuthenticatedClient({
|
||||
const [loginBusy, setLoginBusy] = useState(false);
|
||||
const [codeBusy, setCodeBusy] = useState(false);
|
||||
const [codeCooldownSeconds, setCodeCooldownSeconds] = useState(0);
|
||||
const [errorReportOpen, setErrorReportOpen] = useState(false);
|
||||
const initialServerSelection = getClientServerSelection();
|
||||
const [serverSelection, setServerSelection] = useState<ClientServerSelection>(
|
||||
initialServerSelection,
|
||||
@@ -117,6 +127,35 @@ export function AuthenticatedClient({
|
||||
initialServerSelection.customBaseUrl,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const handleError = (event: ErrorEvent) => {
|
||||
void captureClientError(event.error ?? event.message, {
|
||||
source: 'window.onerror',
|
||||
});
|
||||
};
|
||||
const handleRejection = (event: PromiseRejectionEvent) => {
|
||||
void captureClientError(event.reason, { source: 'unhandledrejection' });
|
||||
};
|
||||
window.addEventListener('error', handleError);
|
||||
window.addEventListener('unhandledrejection', handleRejection);
|
||||
return () => {
|
||||
window.removeEventListener('error', handleError);
|
||||
window.removeEventListener('unhandledrejection', handleRejection);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const openReport = () => {
|
||||
void captureClientError(new Error('用户主动报告问题'), {
|
||||
source: 'manual',
|
||||
});
|
||||
setErrorReportOpen(true);
|
||||
};
|
||||
window.addEventListener('agc-open-error-report', openReport);
|
||||
return () =>
|
||||
window.removeEventListener('agc-open-error-report', openReport);
|
||||
}, []);
|
||||
|
||||
function persistServerSelection() {
|
||||
try {
|
||||
const next = setClientServerSelection({
|
||||
@@ -126,6 +165,10 @@ export function AuthenticatedClient({
|
||||
setServerSelection(next);
|
||||
return next;
|
||||
} catch (error) {
|
||||
void captureClientError(error, {
|
||||
source: 'auth-hydrate',
|
||||
action: 'restore-session',
|
||||
});
|
||||
setLoginStatus(error instanceof Error ? error.message : String(error));
|
||||
return null;
|
||||
}
|
||||
@@ -304,6 +347,10 @@ export function AuthenticatedClient({
|
||||
setCodeCooldownSeconds(Math.max(0, Math.floor(response.cooldownSeconds)));
|
||||
setLoginStatus(`验证码已发送,${response.expiresInSeconds} 秒内有效`);
|
||||
} catch (error) {
|
||||
void captureClientError(error, {
|
||||
source: 'auth',
|
||||
action: 'send-login-code',
|
||||
});
|
||||
setLoginStatus(error instanceof Error ? error.message : String(error));
|
||||
} finally {
|
||||
setCodeBusy(false);
|
||||
@@ -362,6 +409,7 @@ export function AuthenticatedClient({
|
||||
setCode('');
|
||||
setPassword('');
|
||||
} catch (error) {
|
||||
void captureClientError(error, { source: 'auth', action: 'login' });
|
||||
setLoginStatus(error instanceof Error ? error.message : String(error));
|
||||
} finally {
|
||||
setLoginBusy(false);
|
||||
@@ -530,8 +578,22 @@ export function AuthenticatedClient({
|
||||
}
|
||||
|
||||
return (
|
||||
<ClientRuntimeErrorBoundary onLogout={logout}>
|
||||
{children({ user: authUser, logout })}
|
||||
</ClientRuntimeErrorBoundary>
|
||||
<>
|
||||
<ClientRuntimeErrorBoundary
|
||||
onLogout={logout}
|
||||
onReport={() => {
|
||||
void captureClientError(new Error('用户主动报告问题'), {
|
||||
source: 'manual',
|
||||
});
|
||||
setErrorReportOpen(true);
|
||||
}}
|
||||
>
|
||||
{children({ user: authUser, logout })}
|
||||
</ClientRuntimeErrorBoundary>
|
||||
<ErrorReportDialog
|
||||
open={errorReportOpen}
|
||||
onClose={() => setErrorReportOpen(false)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import {
|
||||
type ClientErrorEvent,
|
||||
type DiagnosticLogFile,
|
||||
getPendingClientErrorEvents,
|
||||
markClientErrorEventsSubmitted,
|
||||
readApplicationDiagnosticLogs,
|
||||
submitErrorReportBatch,
|
||||
subscribeClientErrorEvents,
|
||||
} from '../../services/errorReporting';
|
||||
import { ThemedModal } from '../modal/ThemedModal';
|
||||
|
||||
type ErrorReportDialogProps = {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export function ErrorReportDialog({ open, onClose }: ErrorReportDialogProps) {
|
||||
const [events, setEvents] = useState<ClientErrorEvent[]>([]);
|
||||
const [logs, setLogs] = useState<DiagnosticLogFile[]>([]);
|
||||
const [includeLogs, setIncludeLogs] = useState(true);
|
||||
const [description, setDescription] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [status, setStatus] = useState('');
|
||||
|
||||
useEffect(
|
||||
() =>
|
||||
subscribeClientErrorEvents(() =>
|
||||
setEvents(getPendingClientErrorEvents()),
|
||||
),
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setEvents(getPendingClientErrorEvents());
|
||||
setStatus('');
|
||||
void readApplicationDiagnosticLogs().then(setLogs);
|
||||
}, [open]);
|
||||
|
||||
const submit = useCallback(async () => {
|
||||
if (!events.length || busy) return;
|
||||
setBusy(true);
|
||||
setStatus('正在提交…');
|
||||
try {
|
||||
await submitErrorReportBatch({
|
||||
events,
|
||||
logs: includeLogs ? logs : [],
|
||||
userDescription: description,
|
||||
});
|
||||
markClientErrorEventsSubmitted(events);
|
||||
setStatus('已提交,感谢你的反馈');
|
||||
window.setTimeout(onClose, 700);
|
||||
} catch (error) {
|
||||
setStatus(
|
||||
error instanceof Error ? error.message : '提交失败,请稍后再试',
|
||||
);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}, [busy, description, events, includeLogs, logs, onClose]);
|
||||
|
||||
return (
|
||||
<ThemedModal
|
||||
open={open}
|
||||
ariaLabel="报告问题"
|
||||
onClose={onClose}
|
||||
panelClassName="error-report-dialog"
|
||||
>
|
||||
<header className="error-report-dialog__header">
|
||||
<div>
|
||||
<h2>报告问题</h2>
|
||||
</div>
|
||||
<button type="button" onClick={onClose} aria-label="关闭">
|
||||
×
|
||||
</button>
|
||||
</header>
|
||||
<div className="error-report-dialog__body">
|
||||
<section>
|
||||
<strong>错误事件({events.length})</strong>
|
||||
{events.length ? (
|
||||
<ul className="error-report-dialog__events">
|
||||
{events.map((event) => (
|
||||
<li key={event.fingerprint}>
|
||||
<span>{event.message}</span>
|
||||
<small>
|
||||
{event.source} · {event.count} 次
|
||||
</small>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p>当前没有待报告的错误。</p>
|
||||
)}
|
||||
</section>
|
||||
<label>
|
||||
补充说明(可选)
|
||||
<textarea
|
||||
maxLength={2000}
|
||||
value={description}
|
||||
onChange={(event) => setDescription(event.target.value)}
|
||||
placeholder="刚刚做了什么?"
|
||||
/>
|
||||
</label>
|
||||
<label className="error-report-dialog__logs">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={includeLogs}
|
||||
onChange={(event) => setIncludeLogs(event.target.checked)}
|
||||
/>
|
||||
<span>附加应用诊断日志({logs.length} 个文件)</span>
|
||||
</label>
|
||||
<p className="error-report-dialog__consent">
|
||||
将上传错误信息、应用环境信息、脱敏系统日志和你填写的说明。
|
||||
</p>
|
||||
{status ? <p role="status">{status}</p> : null}
|
||||
</div>
|
||||
<footer className="error-report-dialog__actions">
|
||||
<button type="button" onClick={onClose} disabled={busy}>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void submit()}
|
||||
disabled={busy || !events.length}
|
||||
>
|
||||
{busy ? '提交中…' : '提交报告'}
|
||||
</button>
|
||||
</footer>
|
||||
</ThemedModal>
|
||||
);
|
||||
}
|
||||
@@ -75,7 +75,7 @@ export function summarizeNextProjectActions(
|
||||
addSuggestion('查看隐私与导出边界', '/privacy');
|
||||
addSuggestion('查看首批试玩对象', '/audience');
|
||||
addSuggestion('准备试玩邀请文案', '/invite');
|
||||
addSuggestion('准备缺陷复现记录', '/bug-report');
|
||||
addSuggestion('报告问题', '/bug-report');
|
||||
addSuggestion('准备试玩问卷问题', '/survey');
|
||||
addSuggestion('准备封面与缩略图检查', '/cover');
|
||||
addSuggestion('准备宣传截图清单', '/screenshots');
|
||||
|
||||
@@ -123,7 +123,7 @@ export const chatCommandHelp = [
|
||||
'/privacy:查看隐私与导出边界',
|
||||
'/audience:查看首批试玩对象',
|
||||
'/invite:准备试玩邀请文案',
|
||||
'/bug-report:准备缺陷复现记录',
|
||||
'/bug-report:报告问题',
|
||||
'/survey:准备试玩问卷问题',
|
||||
'/cover:准备封面与缩略图检查',
|
||||
'/screenshots:准备宣传截图清单',
|
||||
|
||||
@@ -19,7 +19,6 @@ import {
|
||||
summarizeProjectBalanceState,
|
||||
summarizeProjectBlockers,
|
||||
summarizeProjectBrief,
|
||||
summarizeProjectBugReport,
|
||||
summarizeProjectCommunityPost,
|
||||
summarizeProjectCompatibilityNotes,
|
||||
summarizeProjectContextSources,
|
||||
@@ -507,19 +506,16 @@ export function handleProjectSummaryChatCommand({
|
||||
}
|
||||
|
||||
if (prompt === '/bug-report') {
|
||||
if (!requireChatProjectForUserAction()) {
|
||||
return true;
|
||||
}
|
||||
const summary = summarizeProjectBugReport(manifest, agentRunTrace);
|
||||
setMessages((current) => [
|
||||
...current,
|
||||
{
|
||||
role: 'assistant',
|
||||
text: summary.text,
|
||||
draftCommand: summary.draftCommand,
|
||||
draftCommandLabel: summary.draftCommandLabel,
|
||||
text: '已打开“报告问题”面板,请填写遇到的问题。',
|
||||
},
|
||||
]);
|
||||
if (typeof window !== 'undefined') {
|
||||
window.dispatchEvent(new CustomEvent('agc-open-error-report'));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
unwrapApiResponse,
|
||||
} from '../../../../packages/shared/src';
|
||||
import { fetchClientHttp } from './clientHttp';
|
||||
import { captureClientError } from './errorReporting';
|
||||
|
||||
const ACCESS_TOKEN_STORAGE_KEY = 'genarrative.auth.access-token.v1';
|
||||
|
||||
@@ -77,13 +78,23 @@ export async function requestClientApi<T>(
|
||||
credentials: 'same-origin',
|
||||
headers,
|
||||
});
|
||||
} catch {
|
||||
} catch (error) {
|
||||
void captureClientError(error, {
|
||||
source: 'api',
|
||||
action: url.split('?')[0],
|
||||
});
|
||||
throw new ClientAuthRequestError(
|
||||
'无法连接登录服务,请确认配套后端或 API 代理已启动后重试',
|
||||
{ networkError: true },
|
||||
);
|
||||
}
|
||||
if (!response.ok) {
|
||||
void captureClientError(
|
||||
new ClientAuthRequestError(`HTTP ${response.status}`, {
|
||||
status: response.status,
|
||||
}),
|
||||
{ source: 'api', action: url.split('?')[0] },
|
||||
);
|
||||
throw new ClientAuthRequestError(
|
||||
await readApiErrorMessage(response, fallbackMessage),
|
||||
{ status: response.status },
|
||||
@@ -116,13 +127,23 @@ export async function requestClientApiBytes(
|
||||
credentials: 'same-origin',
|
||||
headers,
|
||||
});
|
||||
} catch {
|
||||
} catch (error) {
|
||||
void captureClientError(error, {
|
||||
source: 'api',
|
||||
action: url.split('?')[0],
|
||||
});
|
||||
throw new ClientAuthRequestError(
|
||||
'无法连接登录服务,请确认配套后端或 API 代理已启动后重试',
|
||||
{ networkError: true },
|
||||
);
|
||||
}
|
||||
if (!response.ok) {
|
||||
void captureClientError(
|
||||
new ClientAuthRequestError(`HTTP ${response.status}`, {
|
||||
status: response.status,
|
||||
}),
|
||||
{ source: 'api', action: url.split('?')[0] },
|
||||
);
|
||||
throw new ClientAuthRequestError(
|
||||
await readApiErrorMessage(response, fallbackMessage),
|
||||
{ status: response.status },
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
|
||||
import { getStoredAuthAccessToken } from './clientAuth';
|
||||
import { fetchClientHttp, getClientServerBaseUrl } from './clientHttp';
|
||||
|
||||
export type ClientErrorEvent = {
|
||||
eventId: string;
|
||||
fingerprint: string;
|
||||
source: string;
|
||||
message: string;
|
||||
stack?: string;
|
||||
occurredAt: string;
|
||||
count: number;
|
||||
};
|
||||
|
||||
export type DiagnosticLogFile = { name: string; content: string };
|
||||
|
||||
const pending = new Map<string, ClientErrorEvent>();
|
||||
const listeners = new Set<() => void>();
|
||||
const MAX_EVENTS = 100;
|
||||
|
||||
function notify() {
|
||||
listeners.forEach((listener) => listener());
|
||||
}
|
||||
|
||||
function normalizeMessage(value: string) {
|
||||
return value
|
||||
.replace(/https?:\/\/\S+/giu, '<url>')
|
||||
.replace(/[A-Z]:\\[^\s]+|\/(?:Users|home|private|tmp)\/[^\s]+/giu, '<path>')
|
||||
.replace(/\b[0-9a-f]{8,}\b/giu, '<id>')
|
||||
.replace(/\d{2,}/gu, '<n>')
|
||||
.trim()
|
||||
.slice(0, 512);
|
||||
}
|
||||
|
||||
async function sha256(value: string) {
|
||||
if (globalThis.crypto?.subtle) {
|
||||
const digest = await globalThis.crypto.subtle.digest(
|
||||
'SHA-256',
|
||||
new TextEncoder().encode(value),
|
||||
);
|
||||
return Array.from(new Uint8Array(digest))
|
||||
.map((byte) => byte.toString(16).padStart(2, '0'))
|
||||
.join('');
|
||||
}
|
||||
return normalizeMessage(value);
|
||||
}
|
||||
|
||||
export async function captureClientError(
|
||||
error: unknown,
|
||||
context: { source?: string; action?: string; page?: string } = {},
|
||||
) {
|
||||
const errorValue = error instanceof Error ? error : new Error(String(error));
|
||||
const message = normalizeMessage(errorValue.message || '未知客户端错误');
|
||||
const stack = errorValue.stack?.slice(0, 8_000);
|
||||
// 只用栈的首行参与指纹,避免同一错误因调用位置行号变化而无法合并。
|
||||
const fingerprint = await sha256(
|
||||
[context.source ?? 'client', message, stack?.split('\n')[0]]
|
||||
.filter(Boolean)
|
||||
.join('|'),
|
||||
);
|
||||
const existing = pending.get(fingerprint);
|
||||
const now = new Date().toISOString();
|
||||
if (existing) {
|
||||
existing.count += 1;
|
||||
existing.occurredAt = now;
|
||||
notify();
|
||||
return existing;
|
||||
}
|
||||
const event: ClientErrorEvent = {
|
||||
eventId: `client-error-${globalThis.crypto?.randomUUID?.() ?? Date.now()}`,
|
||||
fingerprint,
|
||||
source: context.source ?? 'client',
|
||||
message,
|
||||
stack,
|
||||
occurredAt: now,
|
||||
count: 1,
|
||||
};
|
||||
if (pending.size >= MAX_EVENTS) {
|
||||
const oldest = pending.keys().next().value;
|
||||
if (oldest) pending.delete(oldest);
|
||||
}
|
||||
pending.set(fingerprint, event);
|
||||
try {
|
||||
await invoke('append_diagnostic_event', { event });
|
||||
} catch {
|
||||
// 浏览器预览和未初始化 Tauri 时仍保留内存事件;不得阻断主流程。
|
||||
}
|
||||
notify();
|
||||
return event;
|
||||
}
|
||||
|
||||
export function getPendingClientErrorEvents() {
|
||||
return Array.from(pending.values());
|
||||
}
|
||||
|
||||
export function subscribeClientErrorEvents(listener: () => void) {
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
export async function readApplicationDiagnosticLogs(): Promise<
|
||||
DiagnosticLogFile[]
|
||||
> {
|
||||
try {
|
||||
return await invoke<DiagnosticLogFile[]>('read_diagnostic_logs');
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function submitErrorReportBatch(
|
||||
payload: {
|
||||
events: ClientErrorEvent[];
|
||||
userDescription?: string;
|
||||
logs: DiagnosticLogFile[];
|
||||
},
|
||||
apiBaseUrl = getClientServerBaseUrl(),
|
||||
) {
|
||||
const token = getStoredAuthAccessToken();
|
||||
if (!token) throw new Error('请先登录后再提交错误报告');
|
||||
const response = await fetchClientHttp(
|
||||
'/api/error-reports',
|
||||
{
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
submissionId:
|
||||
globalThis.crypto?.randomUUID?.() ?? `submission-${Date.now()}`,
|
||||
events: payload.events,
|
||||
userDescription: payload.userDescription?.trim() || null,
|
||||
logs: payload.logs,
|
||||
}),
|
||||
},
|
||||
{ serverBaseUrl: apiBaseUrl },
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error(`错误报告提交失败(${response.status})`);
|
||||
}
|
||||
const result = (await response.json()) as { data?: unknown };
|
||||
return result.data ?? result;
|
||||
}
|
||||
|
||||
export function markClientErrorEventsSubmitted(events: ClientErrorEvent[]) {
|
||||
for (const event of events) pending.delete(event.fingerprint);
|
||||
notify();
|
||||
}
|
||||
|
||||
/** 仅供单元测试隔离进程内错误池;生产流程不调用。 */
|
||||
export function resetClientErrorEventsForTests() {
|
||||
pending.clear();
|
||||
notify();
|
||||
}
|
||||
@@ -671,6 +671,38 @@ textarea {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.error-report-dialog {
|
||||
width: min(560px, calc(100vw - 2rem));
|
||||
max-height: min(760px, calc(100vh - 2rem));
|
||||
overflow: auto;
|
||||
border-radius: 18px;
|
||||
padding: 20px;
|
||||
box-shadow: 0 24px 80px rgb(36 20 12 / 22%);
|
||||
}
|
||||
|
||||
.error-report-dialog__header,
|
||||
.error-report-dialog__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.error-report-dialog__header h2 { margin: 0; }
|
||||
.error-report-dialog__header p,
|
||||
.error-report-dialog__consent { margin: 4px 0 0; color: var(--platform-text-soft); font-size: 13px; }
|
||||
.error-report-dialog__header > button { border: 0; background: transparent; font-size: 24px; cursor: pointer; }
|
||||
.error-report-dialog__body { display: grid; gap: 16px; margin: 20px 0; }
|
||||
.error-report-dialog__events { display: grid; gap: 8px; padding: 0; margin: 8px 0 0; list-style: none; }
|
||||
.error-report-dialog__events li { display: grid; gap: 3px; border: 1px solid var(--platform-border-subtle); border-radius: 10px; padding: 10px; }
|
||||
.error-report-dialog__events small { color: var(--platform-text-soft); }
|
||||
.error-report-dialog__body label { display: grid; gap: 6px; font-size: 13px; font-weight: 600; }
|
||||
.error-report-dialog__body textarea { min-height: 100px; resize: vertical; border: 1px solid var(--platform-border-subtle); border-radius: 10px; padding: 10px; font: inherit; }
|
||||
.error-report-dialog__logs { display: flex !important; grid-template-columns: auto 1fr; align-items: center; }
|
||||
.error-report-dialog__actions { justify-content: flex-end; }
|
||||
.error-report-dialog__actions button { border: 0; border-radius: 9px; padding: 9px 14px; cursor: pointer; }
|
||||
.error-report-dialog__actions button:last-child { background: var(--platform-button-primary-fill); color: var(--platform-button-primary-text); }
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.client-auth-shell {
|
||||
padding: 16px;
|
||||
|
||||
@@ -5072,7 +5072,7 @@ export function registerDeveloperToolsTests() {
|
||||
expect(screen.getByText(/\/privacy:查看隐私与导出边界/)).not.toBeNull();
|
||||
expect(screen.getByText(/\/audience:查看首批试玩对象/)).not.toBeNull();
|
||||
expect(screen.getByText(/\/invite:准备试玩邀请文案/)).not.toBeNull();
|
||||
expect(screen.getByText(/\/bug-report:准备缺陷复现记录/)).not.toBeNull();
|
||||
expect(screen.getByText(/\/bug-report:报告问题/)).not.toBeNull();
|
||||
expect(screen.getByText(/\/survey:准备试玩问卷问题/)).not.toBeNull();
|
||||
expect(screen.getByText(/\/cover:准备封面与缩略图检查/)).not.toBeNull();
|
||||
expect(screen.getByText(/\/screenshots:准备宣传截图清单/)).not.toBeNull();
|
||||
|
||||
+4
-34
@@ -46,7 +46,7 @@ export async function assertPlaytestAndReleaseShortcutFlow(
|
||||
expect(screen.getByText(/查看隐私与导出边界:\/privacy/)).not.toBeNull();
|
||||
expect(screen.getByText(/查看首批试玩对象:\/audience/)).not.toBeNull();
|
||||
expect(screen.getByText(/准备试玩邀请文案:\/invite/)).not.toBeNull();
|
||||
expect(screen.getByText(/准备缺陷复现记录:\/bug-report/)).not.toBeNull();
|
||||
expect(screen.getByText(/报告问题:\/bug-report/)).not.toBeNull();
|
||||
expect(screen.getByText(/准备试玩问卷问题:\/survey/)).not.toBeNull();
|
||||
expect(screen.getByText(/准备封面与缩略图检查:\/cover/)).not.toBeNull();
|
||||
expect(screen.getByText(/准备宣传截图清单:\/screenshots/)).not.toBeNull();
|
||||
@@ -648,39 +648,9 @@ export async function assertPlaytestAndReleaseShortcutFlow(
|
||||
).length,
|
||||
};
|
||||
submitChat('/bug-report');
|
||||
const bugReportMessages = await screen.findAllByText(/缺陷记录:/);
|
||||
const bugReportMessage = bugReportMessages[bugReportMessages.length - 1];
|
||||
expect(bugReportMessage.textContent).toContain('项目:未命名游戏原型');
|
||||
expect(bugReportMessage.textContent).toContain(
|
||||
'Run:run-main-shortcut-trace · passed / done · 1/3 轮 · evaluator-passed',
|
||||
);
|
||||
expect(bugReportMessage.textContent).toContain(
|
||||
'复现入口:预览 未启动 · 建议 /run',
|
||||
);
|
||||
expect(bugReportMessage.textContent).toContain('最近试玩证据:暂无');
|
||||
expect(bugReportMessage.textContent).toContain(
|
||||
'记录模板:问题一句话;复现步骤 1/2/3;期望结果;实际结果;设备/输入方式;严重度 阻断/高/中/低;附件 截图/录屏/日志时间点',
|
||||
);
|
||||
expect(bugReportMessage.textContent).toContain(
|
||||
'优先级口径:阻断无法进入首局;高影响胜负或重开;中影响理解或手感;低为包装和文字问题',
|
||||
);
|
||||
expect(bugReportMessage.textContent).toContain(
|
||||
'转修复草稿:/agent-resume 缺陷修复:现象…;复现…;期望…;实际…',
|
||||
);
|
||||
expect(bugReportMessage.textContent).toContain(
|
||||
'参考:/test-plan;/feedback;/review;/logs',
|
||||
);
|
||||
expect(bugReportMessage.textContent).toContain(
|
||||
'边界:只准备缺陷记录模板;不读取文件;不启动或打开预览;不导出试玩包;不写项目',
|
||||
);
|
||||
expect(bugReportMessage.textContent).toContain('建议:/run');
|
||||
const bugReportMessageList = document.querySelector('.message-list');
|
||||
expect(bugReportMessageList).not.toBeNull();
|
||||
const bugReportDraftButtons = within(
|
||||
bugReportMessageList as HTMLElement,
|
||||
).getAllByRole('button', { name: '启动试玩' });
|
||||
fireEvent.click(bugReportDraftButtons[bugReportDraftButtons.length - 1]);
|
||||
expect(screen.getByLabelText('创作想法')).toHaveProperty('value', '/run');
|
||||
expect(
|
||||
await screen.findByText('已打开“报告问题”面板,请填写遇到的问题。'),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
invoke.mock.calls.filter(
|
||||
([command]) => command === 'get_local_game_manifest',
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
/** @vitest-environment jsdom */
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('@tauri-apps/api/core', () => ({
|
||||
invoke: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock('../src/services/clientAuth', () => ({
|
||||
getStoredAuthAccessToken: vi.fn(() => 'test-token'),
|
||||
}));
|
||||
vi.mock('../src/services/clientHttp', () => ({
|
||||
fetchClientHttp: vi.fn(),
|
||||
getClientServerBaseUrl: vi.fn(() => 'https://example.test'),
|
||||
}));
|
||||
|
||||
import { fetchClientHttp } from '../src/services/clientHttp';
|
||||
import {
|
||||
captureClientError,
|
||||
getPendingClientErrorEvents,
|
||||
markClientErrorEventsSubmitted,
|
||||
resetClientErrorEventsForTests,
|
||||
submitErrorReportBatch,
|
||||
} from '../src/services/errorReporting';
|
||||
|
||||
describe('客户端错误报告池', () => {
|
||||
afterEach(() => {
|
||||
resetClientErrorEventsForTests();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('按 fingerprint 合并重复错误并累计次数', async () => {
|
||||
const first = await captureClientError(new Error('重复错误'), {
|
||||
source: 'test',
|
||||
});
|
||||
const second = await captureClientError(new Error('重复错误'), {
|
||||
source: 'test',
|
||||
});
|
||||
|
||||
expect(second.eventId).toBe(first.eventId);
|
||||
expect(getPendingClientErrorEvents()).toHaveLength(1);
|
||||
expect(getPendingClientErrorEvents()[0]?.count).toBe(2);
|
||||
});
|
||||
|
||||
it('限制当前进程错误池最多保留 100 条', async () => {
|
||||
for (let index = 0; index < 101; index += 1) {
|
||||
await captureClientError(new Error(`错误 ${index}`), { source: 'test' });
|
||||
}
|
||||
expect(getPendingClientErrorEvents()).toHaveLength(100);
|
||||
});
|
||||
|
||||
it('提交成功后只清除本次提交的事件', async () => {
|
||||
const first = await captureClientError(new Error('第一个错误'), {
|
||||
source: 'test',
|
||||
});
|
||||
const second = await captureClientError(new Error('第二个错误'), {
|
||||
source: 'test',
|
||||
});
|
||||
vi.mocked(fetchClientHttp).mockResolvedValue(
|
||||
new Response(JSON.stringify({ data: { batchId: 'batch-1' } }), {
|
||||
status: 200,
|
||||
}),
|
||||
);
|
||||
|
||||
await submitErrorReportBatch({ events: [first], logs: [] });
|
||||
markClientErrorEventsSubmitted([first]);
|
||||
|
||||
expect(getPendingClientErrorEvents()).toEqual([second]);
|
||||
});
|
||||
|
||||
it('未登录时拒绝提交且不发请求', async () => {
|
||||
const auth = await import('../src/services/clientAuth');
|
||||
vi.mocked(auth.getStoredAuthAccessToken).mockReturnValue('');
|
||||
|
||||
await expect(
|
||||
submitErrorReportBatch({ events: [], logs: [] }),
|
||||
).rejects.toThrow('请先登录');
|
||||
expect(fetchClientHttp).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user