Archived
- json-text-view:投影层接线(可视窗口/spacer/行号反查/指针换算/边缘滚动八接入点);折叠事务带 scrollTop 补偿(锚点=视口顶部 model 行号,折叠视口上方块无跳变);选区端点写入前钳制,syncDomSelection 修正坐标混用隐患;props.json 变化重置折叠态;chevron(hover 显/已折叠常显)与行尾摘要 {…} N 行;非 JSON 响应折叠 UI 整体关闭(hasFolds 门控)
- endpoint-panel:meta 行「折叠全部/展开全部」(收 depth=1 顶层块,经 defineExpose 通道)
- 新增 6 个视图层测试(折叠渲染/chevron 交互/选区隔离/补偿无跳变/状态重置/非 JSON 降级)
- 附技术博客 docs/JSON预览器代码折叠技术博客.md
- 全量 89/89 四绿(test/type-check/lint/build)
293 lines
8.2 KiB
Vue
293 lines
8.2 KiB
Vue
<script setup lang="ts">
|
||
/**
|
||
* 单接口完整面板:信息行 → 注意事项折叠 → sk-key 输入 + 发送按钮 → 响应区
|
||
*
|
||
* sk-key 存在 bench-store(每 tab 独立、无持久化、随刷新重置):
|
||
* 2026-09 起从组件局部 ref 提升——EndpointPanel 随 tab 切换卸载重建,
|
||
* 局部 ref 会在切回时丢输入;store 持有则切 tab 保留(数据仍由 Query 缓存兜底)
|
||
* 响应区:元信息行(状态/耗时/大小)+ 操作(复制/下载)+ 虚拟滚动 JSON 视图
|
||
*/
|
||
|
||
import { useClipboard } from '@vueuse/core';
|
||
import { computed, ref, watch } from 'vue';
|
||
|
||
import { findEndpointByPath } from '@/api/endpoints';
|
||
import { useManualEndpointQuery } from '@/api/endpoint-queries';
|
||
import EndpointInfoLine from '@/components/endpoint-info-line.vue';
|
||
import JsonTextView from '@/components/json-text-view.vue';
|
||
import { useBenchStore } from '@/stores/bench-store';
|
||
|
||
const props = defineProps<{ endpointPath: string }>();
|
||
|
||
const endpoint = computed(() => {
|
||
const found = findEndpointByPath(props.endpointPath);
|
||
if (!found) throw new Error(`未知接口路径:${props.endpointPath}`);
|
||
return found;
|
||
});
|
||
|
||
/* ---------- sk-key(bench-store 持有:每 tab 独立、仅内存态、切 tab 保留) ---------- */
|
||
|
||
const benchStore = useBenchStore();
|
||
|
||
const skKeyInput = computed({
|
||
get: () => benchStore.getSkKey(props.endpointPath),
|
||
set: (value: string) => benchStore.setSkKey(props.endpointPath, value),
|
||
});
|
||
|
||
/* ---------- 手动请求(TanStack Query) ---------- */
|
||
|
||
const endpointPathRef = computed(() => props.endpointPath);
|
||
const {
|
||
data: responseData,
|
||
isFetching,
|
||
hasSent,
|
||
sendRequest,
|
||
} = useManualEndpointQuery(endpointPathRef, skKeyInput);
|
||
|
||
/** 无鉴权接口(如 /api/status)不需要 sk-key:隐藏输入行、按钮不因空 key 禁用 */
|
||
const requiresKey = computed(() => endpoint.value.authMode !== 'none');
|
||
|
||
const keyInputStatus = computed(() =>
|
||
hasSent.value && requiresKey.value && !skKeyInput.value.trim() ? 'error' : 'default',
|
||
);
|
||
|
||
const sendButtonDisabled = computed(() => requiresKey.value && !skKeyInput.value.trim());
|
||
|
||
function handleSend(): void {
|
||
if (sendButtonDisabled.value) return; // 空 key 不发(输入框已标红提示);无鉴权接口直接发
|
||
sendRequest();
|
||
}
|
||
|
||
/* ---------- 折叠全部 / 展开全部(JsonTextView defineExpose 通道) ---------- */
|
||
|
||
const jsonTextViewRef = ref<InstanceType<typeof JsonTextView> | null>(null);
|
||
const foldable = ref(false);
|
||
|
||
// hasFolds 是 JsonTextView 内部的 computed,经 watch 桥接为面板侧响应式标志
|
||
watch(
|
||
() => jsonTextViewRef.value?.hasFolds,
|
||
(value) => {
|
||
foldable.value = value === true;
|
||
},
|
||
{ immediate: true },
|
||
);
|
||
|
||
function collapseAllFolds(): void {
|
||
jsonTextViewRef.value?.collapseAll();
|
||
}
|
||
|
||
function expandAllFolds(): void {
|
||
jsonTextViewRef.value?.expandAll();
|
||
}
|
||
|
||
/* ---------- 响应区操作:复制 / 下载 ---------- */
|
||
|
||
const {
|
||
copy: copyResponseToClipboard,
|
||
copied,
|
||
isSupported: clipboardSupported,
|
||
} = useClipboard({
|
||
source: () => responseData.value?.text ?? '',
|
||
legacy: true, // 非 https 环境降级 execCommand
|
||
});
|
||
|
||
const downloadFileName = computed(() => {
|
||
const pathSegment = props.endpointPath.replaceAll('/', '_').slice(1);
|
||
return `bench-${pathSegment}.json`;
|
||
});
|
||
|
||
function downloadResponseAsFile(): void {
|
||
const text = responseData.value?.text;
|
||
if (!text) return;
|
||
const blob = new Blob([text], { type: 'application/json' });
|
||
const url = URL.createObjectURL(blob);
|
||
const anchor = document.createElement('a');
|
||
anchor.href = url;
|
||
anchor.download = downloadFileName.value;
|
||
anchor.click();
|
||
URL.revokeObjectURL(url);
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<div class="endpoint-panel">
|
||
<!-- 信息行:method + 路径 + 鉴权徽标 -->
|
||
<section class="panel-section">
|
||
<EndpointInfoLine :endpoint="endpoint" />
|
||
</section>
|
||
|
||
<!-- 注意事项折叠(坑文档,文案来自 endpoints.ts) -->
|
||
<section class="panel-section">
|
||
<t-collapse :default-value="[]" expand-mutex>
|
||
<t-collapse-panel value="notes" header="注意事项(接口实证调研结论)">
|
||
<ul class="note-list">
|
||
<li v-for="note in endpoint.notes" :key="note">{{ note }}</li>
|
||
</ul>
|
||
</t-collapse-panel>
|
||
</t-collapse>
|
||
</section>
|
||
|
||
<!-- 调试行:sk-key + 发送(无鉴权接口隐藏 key 输入) -->
|
||
<section class="panel-section">
|
||
<div class="request-row">
|
||
<t-input
|
||
v-if="requiresKey"
|
||
v-model="skKeyInput"
|
||
class="sk-key-input"
|
||
type="password"
|
||
:clearable="true"
|
||
placeholder="sk-xxxxxxxxxxxx"
|
||
:status="keyInputStatus"
|
||
:tips="keyInputStatus === 'error' ? '请填写 sk-key' : ''"
|
||
@enter="handleSend"
|
||
/>
|
||
<span v-else class="no-key-hint">公开接口,无需 sk-key</span>
|
||
<t-button
|
||
theme="primary"
|
||
:loading="isFetching"
|
||
:disabled="sendButtonDisabled"
|
||
@click="handleSend"
|
||
>
|
||
发送请求
|
||
</t-button>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- 响应区 -->
|
||
<section v-if="responseData" class="panel-section response-section">
|
||
<div class="response-meta">
|
||
<span
|
||
class="status"
|
||
:class="
|
||
responseData.status !== null && responseData.status >= 200 && responseData.status < 300
|
||
? 'ok'
|
||
: 'err'
|
||
"
|
||
>
|
||
{{ responseData.status !== null ? `HTTP ${responseData.status}` : '网络错误' }}
|
||
</span>
|
||
<span>{{ responseData.durationMs }}ms</span>
|
||
<span>{{ responseData.byteLength.toLocaleString('en-US') }} 字符</span>
|
||
<span v-if="responseData.networkError" class="network-error">
|
||
{{ responseData.networkError }}
|
||
</span>
|
||
<span class="meta-spacer"></span>
|
||
<t-button v-if="foldable" size="small" variant="outline" @click="collapseAllFolds">
|
||
折叠全部
|
||
</t-button>
|
||
<t-button v-if="foldable" size="small" variant="outline" @click="expandAllFolds">
|
||
展开全部
|
||
</t-button>
|
||
<t-button
|
||
v-if="clipboardSupported"
|
||
size="small"
|
||
variant="outline"
|
||
@click="copyResponseToClipboard()"
|
||
>
|
||
{{ copied ? '已复制' : '复制' }}
|
||
</t-button>
|
||
<t-button size="small" variant="outline" @click="downloadResponseAsFile">下载</t-button>
|
||
</div>
|
||
<JsonTextView ref="jsonTextViewRef" :json="responseData.json" :raw-text="responseData.text" />
|
||
</section>
|
||
<section v-else-if="!hasSent" class="panel-section response-placeholder">
|
||
{{ requiresKey ? '填写 sk-key 后点击「发送请求」' : '点击「发送请求」查看站点配置' }}
|
||
</section>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.endpoint-panel {
|
||
flex: 1;
|
||
min-height: 0;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 16px;
|
||
padding: 16px 20px 24px;
|
||
}
|
||
|
||
.panel-section {
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.request-row {
|
||
display: flex;
|
||
gap: 12px;
|
||
align-items: flex-start;
|
||
}
|
||
|
||
.sk-key-input {
|
||
flex: 1;
|
||
max-width: 480px;
|
||
}
|
||
|
||
.no-key-hint {
|
||
align-self: center;
|
||
font-size: 12.5px;
|
||
color: var(--td-text-color-placeholder);
|
||
}
|
||
|
||
.response-section {
|
||
flex: 1;
|
||
min-height: 220px; /* 响应区保底高度:窄窗口下不被上部固定分区压缩到不可用 */
|
||
display: flex;
|
||
flex-direction: column;
|
||
overflow: hidden; /* 分区固定:滚动收敛到 JsonTextView 自身 */
|
||
}
|
||
|
||
.response-meta {
|
||
display: flex;
|
||
gap: 14px;
|
||
align-items: center;
|
||
margin-bottom: 10px;
|
||
flex-shrink: 0; /* 元信息行固定:不随响应区压缩 */
|
||
font-size: 12.5px;
|
||
color: var(--td-text-color-secondary);
|
||
font-family: var(--td-font-family-mono, monospace);
|
||
}
|
||
|
||
.meta-spacer {
|
||
flex: 1;
|
||
}
|
||
|
||
.status {
|
||
font-weight: 700;
|
||
}
|
||
|
||
.status.ok {
|
||
color: var(--td-success-color);
|
||
}
|
||
|
||
.status.err {
|
||
color: var(--td-error-color);
|
||
}
|
||
|
||
.network-error {
|
||
color: var(--td-error-color);
|
||
}
|
||
|
||
.response-placeholder {
|
||
flex: 1;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
color: var(--td-text-color-placeholder);
|
||
}
|
||
|
||
.note-list {
|
||
margin: 0;
|
||
padding-left: 18px;
|
||
font-size: 12.5px;
|
||
color: var(--td-text-color-secondary);
|
||
}
|
||
|
||
.note-list li {
|
||
margin: 4px 0;
|
||
line-height: 1.5;
|
||
}
|
||
|
||
.note-list li::marker {
|
||
color: var(--td-warning-color);
|
||
}
|
||
</style>
|