305 lines
11 KiB
Vue
305 lines
11 KiB
Vue
<script setup lang="ts">
|
||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
|
||
import {
|
||
clearAiRequestInspections,
|
||
subscribeAiRequestInspections,
|
||
type AiRequestInspectionRecord
|
||
} from './ai-request-inspector';
|
||
|
||
const open = ref(false);
|
||
const records = ref<AiRequestInspectionRecord[]>([]);
|
||
const selectedId = ref<string | null>(null);
|
||
const copyState = ref('');
|
||
let unsubscribe: (() => void) | null = null;
|
||
|
||
const selected = computed(() =>
|
||
records.value.find((record) => record.id === selectedId.value) ?? records.value[0] ?? null
|
||
);
|
||
const pendingCount = computed(() => records.value.filter((record) => record.status === 'prepared').length);
|
||
|
||
onMounted(() => {
|
||
unsubscribe = subscribeAiRequestInspections((nextRecords) => {
|
||
records.value = nextRecords;
|
||
if (!selectedId.value || !nextRecords.some((record) => record.id === selectedId.value)) {
|
||
selectedId.value = nextRecords[0]?.id ?? null;
|
||
}
|
||
});
|
||
});
|
||
|
||
onBeforeUnmount(() => unsubscribe?.());
|
||
|
||
function formatJson(value: unknown) {
|
||
return JSON.stringify(value ?? null, null, 2);
|
||
}
|
||
|
||
function formatTime(value: string | null) {
|
||
if (!value) return '处理中';
|
||
return new Intl.DateTimeFormat('zh-CN', {
|
||
month: '2-digit',
|
||
day: '2-digit',
|
||
hour: '2-digit',
|
||
minute: '2-digit',
|
||
second: '2-digit'
|
||
}).format(new Date(value));
|
||
}
|
||
|
||
function statusLabel(status: AiRequestInspectionRecord['status']) {
|
||
if (status === 'succeeded') return '已返回';
|
||
if (status === 'failed') return '失败';
|
||
return '已提交';
|
||
}
|
||
|
||
function categoryLabel(category: AiRequestInspectionRecord['category']) {
|
||
return { preview: '预检', generation: '生成', review: '审核', render: '合成' }[category];
|
||
}
|
||
|
||
async function copySelected() {
|
||
if (!selected.value) return;
|
||
await navigator.clipboard.writeText(formatJson(selected.value));
|
||
copyState.value = '已复制';
|
||
window.setTimeout(() => { copyState.value = ''; }, 1500);
|
||
}
|
||
|
||
function clearHistory() {
|
||
clearAiRequestInspections();
|
||
selectedId.value = null;
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<button
|
||
type="button"
|
||
class="ai-request-trigger"
|
||
:class="{ 'has-pending': pendingCount > 0 }"
|
||
aria-label="查看AI请求参数"
|
||
@click="open = true"
|
||
>
|
||
<span class="trigger-mark">AI</span>
|
||
<span>请求参数</span>
|
||
<strong>{{ records.length }}</strong>
|
||
</button>
|
||
|
||
<Teleport to="body">
|
||
<div v-if="open" class="ai-request-backdrop" @click.self="open = false">
|
||
<aside class="ai-request-drawer" role="dialog" aria-modal="true" aria-label="AI请求参数">
|
||
<header class="drawer-header">
|
||
<div>
|
||
<span>AI Request Inspector</span>
|
||
<h2>提交参数</h2>
|
||
<p>显示页面提交明文及接口返回的最终执行证据,不展示密钥和二进制正文。</p>
|
||
</div>
|
||
<button type="button" class="drawer-close" aria-label="关闭" @click="open = false">×</button>
|
||
</header>
|
||
|
||
<div v-if="records.length" class="drawer-layout">
|
||
<nav class="request-history" aria-label="AI请求历史">
|
||
<button
|
||
v-for="record in records"
|
||
:key="record.id"
|
||
type="button"
|
||
:class="{ active: selected?.id === record.id }"
|
||
@click="selectedId = record.id"
|
||
>
|
||
<span class="history-line">
|
||
<strong>{{ record.title }}</strong>
|
||
<i :class="`is-${record.status}`">{{ statusLabel(record.status) }}</i>
|
||
</span>
|
||
<small>{{ categoryLabel(record.category) }} · {{ formatTime(record.submitted_at) }}</small>
|
||
</button>
|
||
</nav>
|
||
|
||
<section v-if="selected" class="request-detail">
|
||
<div class="request-toolbar">
|
||
<div>
|
||
<span>{{ categoryLabel(selected.category) }}</span>
|
||
<h3>{{ selected.title }}</h3>
|
||
</div>
|
||
<div class="toolbar-actions">
|
||
<button type="button" @click="copySelected">{{ copyState || '复制全部' }}</button>
|
||
<button type="button" @click="clearHistory">清空</button>
|
||
</div>
|
||
</div>
|
||
|
||
<dl class="request-meta">
|
||
<div><dt>状态</dt><dd :class="`is-${selected.status}`">{{ statusLabel(selected.status) }}</dd></div>
|
||
<div><dt>方法</dt><dd>{{ selected.method }}</dd></div>
|
||
<div><dt>接口</dt><dd>{{ selected.path }}</dd></div>
|
||
<div><dt>Request ID</dt><dd>{{ selected.request_id || '等待接口返回' }}</dd></div>
|
||
</dl>
|
||
|
||
<details open>
|
||
<summary>页面提交参数</summary>
|
||
<pre>{{ formatJson(selected.request_body) }}</pre>
|
||
</details>
|
||
|
||
<details open>
|
||
<summary>最终执行证据</summary>
|
||
<p v-if="!selected.execution_evidence" class="empty-copy">
|
||
当前接口尚未返回 task.input_json、Generation Plan 或 Provider 路由信息。
|
||
</p>
|
||
<pre v-else>{{ formatJson(selected.execution_evidence) }}</pre>
|
||
</details>
|
||
|
||
<details v-if="selected.error_message" open class="error-detail">
|
||
<summary>失败原因</summary>
|
||
<pre>{{ selected.error_message }}</pre>
|
||
</details>
|
||
</section>
|
||
</div>
|
||
|
||
<div v-else class="drawer-empty">
|
||
<strong>还没有 AI 请求记录</strong>
|
||
<p>执行生成、AI审核、预检或合成后,请求会自动出现在这里。</p>
|
||
</div>
|
||
</aside>
|
||
</div>
|
||
</Teleport>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.ai-request-trigger {
|
||
position: fixed;
|
||
z-index: 70;
|
||
right: 0;
|
||
top: 42%;
|
||
display: grid;
|
||
gap: 4px;
|
||
justify-items: center;
|
||
width: 72px;
|
||
padding: 12px 8px;
|
||
border: 1px solid #c6d0da;
|
||
border-right: 0;
|
||
border-radius: 6px 0 0 6px;
|
||
background: #ffffff;
|
||
color: #17212b;
|
||
box-shadow: 0 8px 22px rgba(15, 30, 44, 0.14);
|
||
cursor: pointer;
|
||
}
|
||
|
||
.ai-request-trigger:hover { background: #f4f7f9; }
|
||
.ai-request-trigger.has-pending { border-color: #2b6f6a; }
|
||
.ai-request-trigger > span:not(.trigger-mark) { font-size: 12px; }
|
||
.ai-request-trigger strong { font-size: 11px; color: #587080; }
|
||
.trigger-mark {
|
||
display: grid;
|
||
place-items: center;
|
||
width: 30px;
|
||
height: 30px;
|
||
border-radius: 50%;
|
||
background: #153d3a;
|
||
color: #fff;
|
||
font-size: 12px;
|
||
font-weight: 800;
|
||
}
|
||
|
||
.ai-request-backdrop {
|
||
position: fixed;
|
||
inset: 0;
|
||
z-index: 120;
|
||
background: rgba(12, 20, 27, 0.42);
|
||
}
|
||
|
||
.ai-request-drawer {
|
||
position: absolute;
|
||
inset: 0 0 0 auto;
|
||
width: min(860px, 94vw);
|
||
height: 100%;
|
||
overflow: hidden;
|
||
background: #f5f7f8;
|
||
border-left: 1px solid #cfd7dd;
|
||
box-shadow: -14px 0 40px rgba(10, 25, 36, 0.2);
|
||
color: #17212b;
|
||
}
|
||
|
||
.drawer-header {
|
||
min-height: 112px;
|
||
display: flex;
|
||
align-items: flex-start;
|
||
justify-content: space-between;
|
||
gap: 24px;
|
||
padding: 22px 24px;
|
||
border-bottom: 1px solid #d8dfe4;
|
||
background: #fff;
|
||
}
|
||
.drawer-header span { color: #2b6f6a; font-size: 12px; font-weight: 700; text-transform: uppercase; }
|
||
.drawer-header h2 { margin: 4px 0; font-size: 24px; letter-spacing: 0; }
|
||
.drawer-header p { margin: 0; color: #65747f; font-size: 13px; line-height: 1.6; }
|
||
.drawer-close {
|
||
width: 38px;
|
||
height: 38px;
|
||
border: 1px solid #cfd7dd;
|
||
border-radius: 4px;
|
||
background: #fff;
|
||
color: #31434f;
|
||
font-size: 25px;
|
||
line-height: 1;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.drawer-layout { display: grid; grid-template-columns: 220px minmax(0, 1fr); height: calc(100% - 112px); }
|
||
.request-history { overflow-y: auto; border-right: 1px solid #d8dfe4; background: #eef2f4; }
|
||
.request-history button {
|
||
width: 100%;
|
||
padding: 14px;
|
||
border: 0;
|
||
border-bottom: 1px solid #d8dfe4;
|
||
background: transparent;
|
||
color: inherit;
|
||
text-align: left;
|
||
cursor: pointer;
|
||
}
|
||
.request-history button:hover { background: #e3eaed; }
|
||
.request-history button.active { background: #fff; box-shadow: inset 3px 0 #2b6f6a; }
|
||
.history-line { display: flex; align-items: flex-start; justify-content: space-between; gap: 8px; }
|
||
.history-line strong { font-size: 13px; line-height: 1.45; }
|
||
.history-line i { font-style: normal; white-space: nowrap; font-size: 10px; font-weight: 700; }
|
||
.history-line i.is-prepared { color: #9a6200; }
|
||
.history-line i.is-succeeded { color: #1c6b48; }
|
||
.history-line i.is-failed { color: #a93434; }
|
||
.request-history small { display: block; margin-top: 6px; color: #70808b; font-size: 11px; }
|
||
|
||
.request-detail { overflow-y: auto; padding: 20px 22px 48px; }
|
||
.request-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 16px; }
|
||
.request-toolbar span { color: #2b6f6a; font-size: 11px; font-weight: 700; }
|
||
.request-toolbar h3 { margin: 3px 0 0; font-size: 20px; letter-spacing: 0; }
|
||
.toolbar-actions { display: flex; gap: 8px; }
|
||
.toolbar-actions button {
|
||
padding: 8px 11px;
|
||
border: 1px solid #c6d0d7;
|
||
border-radius: 4px;
|
||
background: #fff;
|
||
color: #263844;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.request-meta { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 1px; margin: 18px 0; border: 1px solid #d7dee3; background: #d7dee3; }
|
||
.request-meta div { min-width: 0; padding: 10px 12px; background: #fff; }
|
||
.request-meta dt { color: #71808a; font-size: 11px; }
|
||
.request-meta dd { margin: 4px 0 0; overflow-wrap: anywhere; font-size: 12px; font-weight: 650; }
|
||
.request-meta dd.is-prepared { color: #9a6200; }
|
||
.request-meta dd.is-succeeded { color: #1c6b48; }
|
||
.request-meta dd.is-failed { color: #a93434; }
|
||
|
||
details { margin-top: 12px; border: 1px solid #d7dee3; border-radius: 5px; background: #fff; }
|
||
summary { padding: 12px 14px; cursor: pointer; font-size: 13px; font-weight: 750; }
|
||
pre { margin: 0; padding: 15px; overflow: auto; border-top: 1px solid #e2e7ea; background: #111b22; color: #d9e6ec; font: 12px/1.65 ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; white-space: pre-wrap; overflow-wrap: anywhere; }
|
||
.empty-copy { margin: 0; padding: 14px; border-top: 1px solid #e2e7ea; color: #687985; font-size: 13px; }
|
||
.error-detail { border-color: #e0baba; }
|
||
.error-detail pre { background: #351c1c; color: #ffd8d8; }
|
||
.drawer-empty { display: grid; place-content: center; height: calc(100% - 112px); padding: 30px; text-align: center; }
|
||
.drawer-empty strong { font-size: 18px; }
|
||
.drawer-empty p { color: #687985; }
|
||
|
||
@media (max-width: 720px) {
|
||
.ai-request-trigger { top: auto; bottom: 92px; width: 62px; }
|
||
.ai-request-drawer { width: 100vw; }
|
||
.drawer-layout { grid-template-columns: 1fr; grid-template-rows: 150px minmax(0, 1fr); }
|
||
.request-history { display: flex; overflow-x: auto; overflow-y: hidden; border-right: 0; border-bottom: 1px solid #d8dfe4; }
|
||
.request-history button { min-width: 190px; border-right: 1px solid #d8dfe4; }
|
||
.request-meta { grid-template-columns: 1fr; }
|
||
.drawer-header { padding: 18px; }
|
||
.drawer-header p { display: none; }
|
||
.request-detail { padding: 16px 14px 40px; }
|
||
}
|
||
</style>
|