@l1yp/file-viewer 0.1.4 → 0.1.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +57 -6
- package/dist/assets/step.worker-CfDTYOf_.js.map +1 -0
- package/dist/components/AnsiText.d.ts +47 -0
- package/dist/components/AnsiText.js +41 -0
- package/dist/components/AnsiText.js.map +1 -0
- package/dist/components/CertViewer.js +1 -1
- package/dist/components/CertViewer.js.map +1 -1
- package/dist/components/CertViewer.vue_vue_type_script_setup_true_lang.js.map +1 -1
- package/dist/components/CodeView.js +1 -1
- package/dist/components/CodeView.js.map +1 -1
- package/dist/components/CodeView.vue_vue_type_script_setup_true_lang.js +80 -71
- package/dist/components/CodeView.vue_vue_type_script_setup_true_lang.js.map +1 -1
- package/dist/components/DocxEmbeddedFiles.js +9 -0
- package/dist/components/DocxEmbeddedFiles.js.map +1 -0
- package/dist/components/DocxEmbeddedFiles.vue.d.ts +15 -0
- package/dist/components/DocxEmbeddedFiles.vue_vue_type_script_setup_true_lang.js +69 -0
- package/dist/components/DocxEmbeddedFiles.vue_vue_type_script_setup_true_lang.js.map +1 -0
- package/dist/components/DocxNavigationPane.js +1 -1
- package/dist/components/DocxNavigationPane.js.map +1 -1
- package/dist/components/DocxNavigationPane.vue_vue_type_script_setup_true_lang.js.map +1 -1
- package/dist/components/DocxViewer.js +1 -1
- package/dist/components/DocxViewer.js.map +1 -1
- package/dist/components/DocxViewer.vue.d.ts +1 -1
- package/dist/components/DocxViewer.vue_vue_type_script_setup_true_lang.js +182 -50
- package/dist/components/DocxViewer.vue_vue_type_script_setup_true_lang.js.map +1 -1
- package/dist/components/EplViewer.js +1 -1
- package/dist/components/EplViewer.js.map +1 -1
- package/dist/components/EplViewer.vue_vue_type_script_setup_true_lang.js.map +1 -1
- package/dist/components/FileViewerBody.js +1 -1
- package/dist/components/FileViewerBody.js.map +1 -1
- package/dist/components/FileViewerBody.vue.d.ts +1 -0
- package/dist/components/FileViewerBody.vue_vue_type_script_setup_true_lang.js +292 -281
- package/dist/components/FileViewerBody.vue_vue_type_script_setup_true_lang.js.map +1 -1
- package/dist/components/FileViewerPanel.js +1 -1
- package/dist/components/FileViewerPanel.js.map +1 -1
- package/dist/components/FileViewerPanel.vue.d.ts +6 -1
- package/dist/components/FileViewerPanel.vue_vue_type_script_setup_true_lang.js +53 -51
- package/dist/components/FileViewerPanel.vue_vue_type_script_setup_true_lang.js.map +1 -1
- package/dist/components/HarViewer.js +1 -1
- package/dist/components/HarViewer.js.map +1 -1
- package/dist/components/HarViewer.vue_vue_type_script_setup_true_lang.js.map +1 -1
- package/dist/components/JksViewer.js +1 -1
- package/dist/components/JksViewer.js.map +1 -1
- package/dist/components/JksViewer.vue_vue_type_script_setup_true_lang.js.map +1 -1
- package/dist/components/LogViewer.js +1 -1
- package/dist/components/LogViewer.js.map +1 -1
- package/dist/components/LogViewer.vue.d.ts +3 -1
- package/dist/components/LogViewer.vue_vue_type_script_setup_true_lang.js +284 -280
- package/dist/components/LogViewer.vue_vue_type_script_setup_true_lang.js.map +1 -1
- package/dist/components/ModelViewModes.js +9 -0
- package/dist/components/ModelViewModes.js.map +1 -0
- package/dist/components/ModelViewModes.vue.d.ts +11 -0
- package/dist/components/ModelViewModes.vue_vue_type_script_setup_true_lang.js +49 -0
- package/dist/components/ModelViewModes.vue_vue_type_script_setup_true_lang.js.map +1 -0
- package/dist/components/ModelViewer.js +1 -1
- package/dist/components/ModelViewer.js.map +1 -1
- package/dist/components/ModelViewer.vue.d.ts +2 -1
- package/dist/components/ModelViewer.vue_vue_type_script_setup_true_lang.js +183 -96
- package/dist/components/ModelViewer.vue_vue_type_script_setup_true_lang.js.map +1 -1
- package/dist/components/PdfNavigationPane.js +1 -1
- package/dist/components/PdfNavigationPane.js.map +1 -1
- package/dist/components/PdfNavigationPane.vue_vue_type_script_setup_true_lang.js.map +1 -1
- package/dist/components/PdfViewer.js +1 -1
- package/dist/components/PdfViewer.js.map +1 -1
- package/dist/components/PdfViewer.vue_vue_type_script_setup_true_lang.js.map +1 -1
- package/dist/components/PptxNavigationPane.js +1 -1
- package/dist/components/PptxNavigationPane.js.map +1 -1
- package/dist/components/PptxNavigationPane.vue_vue_type_script_setup_true_lang.js.map +1 -1
- package/dist/components/PsdViewer.js +1 -1
- package/dist/components/PsdViewer.js.map +1 -1
- package/dist/components/PsdViewer.vue_vue_type_script_setup_true_lang.js.map +1 -1
- package/dist/components/SqliteViewer.js +1 -1
- package/dist/components/SqliteViewer.js.map +1 -1
- package/dist/components/SqliteViewer.vue_vue_type_script_setup_true_lang.js.map +1 -1
- package/dist/components/ViewerLoadError.js +9 -0
- package/dist/components/ViewerLoadError.js.map +1 -0
- package/dist/components/ViewerLoadError.vue.d.ts +17 -0
- package/dist/components/ViewerLoadError.vue_vue_type_script_setup_true_lang.js +58 -0
- package/dist/components/ViewerLoadError.vue_vue_type_script_setup_true_lang.js.map +1 -0
- package/dist/components/XlsxChart.js +1 -1
- package/dist/components/XlsxChart.js.map +1 -1
- package/dist/components/XlsxChart.vue_vue_type_script_setup_true_lang.js +9 -9
- package/dist/components/XlsxChart.vue_vue_type_script_setup_true_lang.js.map +1 -1
- package/dist/components/XlsxImageLayer.js +9 -0
- package/dist/components/XlsxImageLayer.js.map +1 -0
- package/dist/components/XlsxImageLayer.vue.d.ts +9 -0
- package/dist/components/XlsxImageLayer.vue_vue_type_script_setup_true_lang.js +46 -0
- package/dist/components/XlsxImageLayer.vue_vue_type_script_setup_true_lang.js.map +1 -0
- package/dist/components/XlsxViewer.js +1 -1
- package/dist/components/XlsxViewer.js.map +1 -1
- package/dist/components/XlsxViewer.vue_vue_type_script_setup_true_lang.js +230 -216
- package/dist/components/XlsxViewer.vue_vue_type_script_setup_true_lang.js.map +1 -1
- package/dist/components/epl/EplResource.js +1 -1
- package/dist/components/epl/EplResource.js.map +1 -1
- package/dist/components/epl/EplResource.vue_vue_type_script_setup_true_lang.js.map +1 -1
- package/dist/components/optionalViewer.d.ts +3 -2
- package/dist/components/optionalViewer.js +6 -3
- package/dist/components/optionalViewer.js.map +1 -1
- package/dist/composables/useFileViewer.d.ts +11 -3
- package/dist/composables/useFileViewer.js.map +1 -1
- package/dist/composables/useTeleportTheme.d.ts +7 -0
- package/dist/composables/useTeleportTheme.js +42 -0
- package/dist/composables/useTeleportTheme.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +29 -28
- package/dist/lib/ansi.d.ts +44 -0
- package/dist/lib/ansi.js +224 -0
- package/dist/lib/ansi.js.map +1 -0
- package/dist/lib/cfb.d.ts +37 -0
- package/dist/lib/cfb.js +112 -0
- package/dist/lib/cfb.js.map +1 -0
- package/dist/lib/docxEmbeddedDom.d.ts +22 -0
- package/dist/lib/docxEmbeddedDom.js +128 -0
- package/dist/lib/docxEmbeddedDom.js.map +1 -0
- package/dist/lib/docxEmbeddings.d.ts +41 -0
- package/dist/lib/docxEmbeddings.js +226 -0
- package/dist/lib/docxEmbeddings.js.map +1 -0
- package/dist/lib/docxTheme.d.ts +6 -0
- package/dist/lib/docxTheme.js +64 -0
- package/dist/lib/docxTheme.js.map +1 -0
- package/dist/lib/epl/code/structures.js +82 -0
- package/dist/lib/epl/code/structures.js.map +1 -0
- package/dist/lib/epl/code/variable.js +16 -0
- package/dist/lib/epl/code/variable.js.map +1 -0
- package/dist/lib/epl/eplDocument.js +28 -22
- package/dist/lib/epl/eplDocument.js.map +1 -1
- package/dist/lib/epl/index.d.ts +2 -1
- package/dist/lib/epl/index.js.map +1 -1
- package/dist/lib/epl/sections/code.js +37 -0
- package/dist/lib/epl/sections/code.js.map +1 -0
- package/dist/lib/epl/sections/esystemInfo.js +29 -0
- package/dist/lib/epl/sections/esystemInfo.js.map +1 -0
- package/dist/lib/epl/sections/losable.js +33 -0
- package/dist/lib/epl/sections/losable.js.map +1 -0
- package/dist/lib/epl/sections/projectConfig.js +39 -0
- package/dist/lib/epl/sections/projectConfig.js.map +1 -0
- package/dist/lib/epl/sections/resource.js +84 -0
- package/dist/lib/epl/sections/resource.js.map +1 -0
- package/dist/lib/fileType.d.ts +3 -1
- package/dist/lib/fileType.js +150 -137
- package/dist/lib/fileType.js.map +1 -1
- package/dist/lib/modelAppearance.d.ts +7 -0
- package/dist/lib/modelAppearance.js +41 -0
- package/dist/lib/modelAppearance.js.map +1 -0
- package/dist/lib/msi.js +62 -154
- package/dist/lib/msi.js.map +1 -1
- package/dist/lib/olePackage.d.ts +8 -0
- package/dist/lib/olePackage.js +37 -0
- package/dist/lib/olePackage.js.map +1 -0
- package/dist/lib/pptxFirstSlide.d.ts +11 -0
- package/dist/lib/pptxFirstSlide.js +34 -0
- package/dist/lib/pptxFirstSlide.js.map +1 -0
- package/dist/lib/step.d.ts +10 -0
- package/dist/lib/step.js +35 -0
- package/dist/lib/step.js.map +1 -0
- package/dist/lib/step.worker.d.ts +1 -0
- package/dist/lib/step.worker.js +18 -0
- package/dist/lib/step.worker.js.map +1 -0
- package/dist/lib/stepObject.d.ts +4 -0
- package/dist/lib/stepObject.js +52 -0
- package/dist/lib/stepObject.js.map +1 -0
- package/dist/lib/stepTypes.d.ts +60 -0
- package/dist/lib/viewerKind.js +10 -5
- package/dist/lib/viewerKind.js.map +1 -1
- package/dist/lib/xlsx.d.ts +1 -1
- package/dist/lib/xlsx.js +106 -89
- package/dist/lib/xlsx.js.map +1 -1
- package/dist/lib/xlsxCharts.js +71 -119
- package/dist/lib/xlsxCharts.js.map +1 -1
- package/dist/lib/xlsxImages.d.ts +43 -0
- package/dist/lib/xlsxImages.js +114 -0
- package/dist/lib/xlsxImages.js.map +1 -0
- package/dist/lib/xlsxParts.d.ts +17 -0
- package/dist/lib/xlsxParts.js +86 -0
- package/dist/lib/xlsxParts.js.map +1 -0
- package/dist/style.css +1 -1
- package/package.json +8 -3
|
@@ -4,28 +4,26 @@ import { VIEWER_FORMATTERS as r, findFormatter as i } from "../lib/viewerFormats
|
|
|
4
4
|
import a from "./icons/Icon.js";
|
|
5
5
|
import { useFileViewer as o } from "../composables/useFileViewer.js";
|
|
6
6
|
import s from "./FileViewerBody.js";
|
|
7
|
-
import { Fragment as c, computed as l, createBlock as u, createCommentVNode as d, createElementBlock as f, createElementVNode as p, createVNode as m, defineComponent as
|
|
7
|
+
import { Fragment as c, computed as l, createBlock as u, createCommentVNode as d, createElementBlock as f, createElementVNode as p, createVNode as m, defineComponent as h, normalizeClass as g, onBeforeUnmount as _, openBlock as v, ref as y, renderList as ee, toDisplayString as b, unref as x, watch as S } from "vue";
|
|
8
8
|
//#region src/components/FileViewerPanel.vue?vue&type=script&setup=true&lang.ts
|
|
9
|
-
var ne = {
|
|
10
|
-
key: 0,
|
|
11
|
-
class: "fv"
|
|
12
|
-
}, S = { class: "fv__head" }, re = {
|
|
9
|
+
var te = ["data-theme"], C = { class: "fv__head" }, ne = {
|
|
13
10
|
key: 0,
|
|
14
11
|
class: "fv__view",
|
|
15
12
|
role: "group",
|
|
16
13
|
"aria-label": "渲染或源码"
|
|
17
|
-
},
|
|
14
|
+
}, re = ["aria-pressed"], w = ["aria-pressed"], T = ["title"], E = { class: "fv__fname" }, D = { class: "fv__ext" }, O = { class: "fv__actions" }, k = ["title", "aria-expanded"], A = {
|
|
18
15
|
key: 0,
|
|
19
16
|
class: "fv__menu-pop",
|
|
20
17
|
role: "menu"
|
|
21
|
-
}, j = ["aria-checked"], ie = ["aria-checked", "onClick"], ae = { class: "fv__menu-label" }, oe = ["href", "download"], M = /*@__PURE__*/
|
|
18
|
+
}, j = ["aria-checked"], ie = ["aria-checked", "onClick"], ae = { class: "fv__menu-label" }, oe = ["href", "download"], M = /*@__PURE__*/ h({
|
|
22
19
|
__name: "FileViewerPanel",
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
20
|
+
props: { theme: { default: "inherit" } },
|
|
21
|
+
setup(h) {
|
|
22
|
+
let { current: M, width: se, setWidth: ce, close: N } = o(), P = y("render"), F = y(""), I = y(!1), L, R = y(null), z = y(null), B = l(() => z.value ?? M.value), V = l(() => M.value?.content != null), H = l(() => n(B.value)), U = l(() => !H.value || B.value?.log === !0), W = l(() => t(B.value, F.value)), le = r, G = y(!1), K = y(null), q = y(""), J = l(() => i(q.value)), Y = l(() => !!M.value?.url), ue = l(() => J.value ? `已强制以「${J.value.label}」打开 —— 点击切换查看方式` : "打开方式 —— 选择查看器(十六进制 / 文本 / 图片 / 音频 …)");
|
|
23
|
+
function de(e) {
|
|
26
24
|
R.value?.pickFormat(e), G.value = !1;
|
|
27
25
|
}
|
|
28
|
-
function
|
|
26
|
+
function fe() {
|
|
29
27
|
R.value?.resetFormat(), G.value = !1;
|
|
30
28
|
}
|
|
31
29
|
function X(e) {
|
|
@@ -34,24 +32,24 @@ var ne = {
|
|
|
34
32
|
function Z(e) {
|
|
35
33
|
e.key === "Escape" && (G.value = !1);
|
|
36
34
|
}
|
|
37
|
-
|
|
35
|
+
S(G, (e) => {
|
|
38
36
|
typeof document > "u" || (e ? (document.addEventListener("click", X), document.addEventListener("keydown", Z)) : (document.removeEventListener("click", X), document.removeEventListener("keydown", Z)));
|
|
39
37
|
});
|
|
40
|
-
let Q = l(() => e(M.value?.name ?? "").toUpperCase()),
|
|
38
|
+
let Q = l(() => e(M.value?.name ?? "").toUpperCase()), pe = l(() => {
|
|
41
39
|
let t = M.value?.name ?? "", n = e(t);
|
|
42
40
|
return n ? t.slice(0, t.length - n.length - 1) : t;
|
|
43
41
|
});
|
|
44
|
-
|
|
42
|
+
S(M, (e) => {
|
|
45
43
|
e && (P.value = "render", F.value = "", z.value = null, q.value = "", G.value = !1);
|
|
46
44
|
});
|
|
47
|
-
async function
|
|
45
|
+
async function me() {
|
|
48
46
|
try {
|
|
49
|
-
await navigator.clipboard?.writeText(F.value);
|
|
47
|
+
await navigator.clipboard?.writeText(R.value?.copyText() ?? F.value);
|
|
50
48
|
} catch {}
|
|
51
49
|
I.value = !0, clearTimeout(L), L = setTimeout(() => I.value = !1, 1400);
|
|
52
50
|
}
|
|
53
|
-
let $ =
|
|
54
|
-
function
|
|
51
|
+
let $ = y(!1);
|
|
52
|
+
function he(e) {
|
|
55
53
|
e.preventDefault();
|
|
56
54
|
let t = e.currentTarget, n = e.clientX, r = se.value, i = document.body, a = i.style.cursor, o = i.style.userSelect;
|
|
57
55
|
t.setPointerCapture(e.pointerId), $.value = !0, i.style.cursor = "col-resize", i.style.userSelect = "none";
|
|
@@ -60,27 +58,31 @@ var ne = {
|
|
|
60
58
|
};
|
|
61
59
|
t.addEventListener("pointermove", s), t.addEventListener("pointerup", l), document.addEventListener("selectstart", c);
|
|
62
60
|
}
|
|
63
|
-
return
|
|
61
|
+
return _(() => {
|
|
64
62
|
clearTimeout(L), typeof document < "u" && (document.removeEventListener("click", X), document.removeEventListener("keydown", Z));
|
|
65
|
-
}), (e, t) =>
|
|
63
|
+
}), (e, t) => x(M) ? (v(), f("aside", {
|
|
64
|
+
key: 0,
|
|
65
|
+
class: "fv-root fv",
|
|
66
|
+
"data-theme": h.theme === "inherit" ? void 0 : h.theme
|
|
67
|
+
}, [
|
|
66
68
|
p("div", {
|
|
67
|
-
class:
|
|
69
|
+
class: g(["fv__resizer", { "fv__resizer--active": $.value }]),
|
|
68
70
|
title: "拖动调整宽度",
|
|
69
|
-
onPointerdown:
|
|
71
|
+
onPointerdown: he
|
|
70
72
|
}, [...t[8] ||= [p("span", { class: "fv__grip" }, null, -1)]], 34),
|
|
71
|
-
p("header",
|
|
72
|
-
|
|
73
|
+
p("header", C, [
|
|
74
|
+
W.value ? (v(), f("div", ne, [p("button", {
|
|
73
75
|
type: "button",
|
|
74
|
-
class:
|
|
76
|
+
class: g(["fv__view-btn", { "fv__view-btn--on": P.value === "render" }]),
|
|
75
77
|
"aria-pressed": P.value === "render",
|
|
76
78
|
title: "渲染",
|
|
77
79
|
onClick: t[0] ||= (e) => P.value = "render"
|
|
78
80
|
}, [m(a, {
|
|
79
81
|
name: "eye",
|
|
80
82
|
size: 15
|
|
81
|
-
})], 10,
|
|
83
|
+
})], 10, re), p("button", {
|
|
82
84
|
type: "button",
|
|
83
|
-
class:
|
|
85
|
+
class: g(["fv__view-btn", { "fv__view-btn--on": P.value === "source" }]),
|
|
84
86
|
"aria-pressed": P.value === "source",
|
|
85
87
|
title: "源码",
|
|
86
88
|
onClick: t[1] ||= (e) => P.value = "source"
|
|
@@ -90,38 +92,38 @@ var ne = {
|
|
|
90
92
|
})], 10, w)])) : d("", !0),
|
|
91
93
|
p("span", {
|
|
92
94
|
class: "fv__title",
|
|
93
|
-
title:
|
|
94
|
-
}, [p("span", E,
|
|
95
|
+
title: x(M).name
|
|
96
|
+
}, [p("span", E, b(pe.value), 1), Q.value ? (v(), f(c, { key: 0 }, [t[9] ||= p("span", { class: "fv__dot" }, "·", -1), p("span", D, b(Q.value), 1)], 64)) : d("", !0)], 8, T),
|
|
95
97
|
p("div", O, [
|
|
96
|
-
|
|
98
|
+
Y.value ? (v(), f("div", {
|
|
97
99
|
key: 0,
|
|
98
100
|
ref_key: "menuRef",
|
|
99
101
|
ref: K,
|
|
100
102
|
class: "fv__menu"
|
|
101
103
|
}, [p("button", {
|
|
102
104
|
type: "button",
|
|
103
|
-
class:
|
|
104
|
-
title:
|
|
105
|
+
class: g(["fv__btn fv__btn--text", { "fv__btn--on": q.value }]),
|
|
106
|
+
title: ue.value,
|
|
105
107
|
"aria-haspopup": !0,
|
|
106
108
|
"aria-expanded": G.value,
|
|
107
109
|
onClick: t[2] ||= (e) => G.value = !G.value
|
|
108
110
|
}, [m(a, {
|
|
109
111
|
name: "apps",
|
|
110
112
|
size: 14
|
|
111
|
-
}), p("span", null,
|
|
113
|
+
}), p("span", null, b(J.value ? J.value.label : "打开方式"), 1)], 10, k), G.value ? (v(), f("div", A, [
|
|
112
114
|
p("button", {
|
|
113
115
|
type: "button",
|
|
114
|
-
class:
|
|
116
|
+
class: g(["fv__menu-item", { "fv__menu-item--on": !q.value }]),
|
|
115
117
|
role: "menuitemradio",
|
|
116
118
|
"aria-checked": !q.value,
|
|
117
|
-
onClick:
|
|
119
|
+
onClick: fe
|
|
118
120
|
}, [
|
|
119
121
|
m(a, {
|
|
120
122
|
name: "eye",
|
|
121
123
|
size: 14
|
|
122
124
|
}),
|
|
123
125
|
t[10] ||= p("span", { class: "fv__menu-label" }, "自动检测", -1),
|
|
124
|
-
q.value ? d("", !0) : (
|
|
126
|
+
q.value ? d("", !0) : (v(), u(a, {
|
|
125
127
|
key: 0,
|
|
126
128
|
name: "check",
|
|
127
129
|
size: 15,
|
|
@@ -129,20 +131,20 @@ var ne = {
|
|
|
129
131
|
}))
|
|
130
132
|
], 10, j),
|
|
131
133
|
t[11] ||= p("div", { class: "fv__menu-sep" }, null, -1),
|
|
132
|
-
(
|
|
134
|
+
(v(!0), f(c, null, ee(x(le), (e) => (v(), f("button", {
|
|
133
135
|
key: e.key,
|
|
134
136
|
type: "button",
|
|
135
|
-
class:
|
|
137
|
+
class: g(["fv__menu-item", { "fv__menu-item--on": q.value === e.key }]),
|
|
136
138
|
role: "menuitemradio",
|
|
137
139
|
"aria-checked": q.value === e.key,
|
|
138
|
-
onClick: (t) =>
|
|
140
|
+
onClick: (t) => de(e.key)
|
|
139
141
|
}, [
|
|
140
142
|
m(a, {
|
|
141
143
|
name: e.icon,
|
|
142
144
|
size: 14
|
|
143
145
|
}, null, 8, ["name"]),
|
|
144
|
-
p("span", ae,
|
|
145
|
-
q.value === e.key ? (
|
|
146
|
+
p("span", ae, b(e.label), 1),
|
|
147
|
+
q.value === e.key ? (v(), u(a, {
|
|
146
148
|
key: 0,
|
|
147
149
|
name: "check",
|
|
148
150
|
size: 15,
|
|
@@ -150,26 +152,26 @@ var ne = {
|
|
|
150
152
|
})) : d("", !0)
|
|
151
153
|
], 10, ie))), 128))
|
|
152
154
|
])) : d("", !0)], 512)) : d("", !0),
|
|
153
|
-
|
|
155
|
+
U.value ? (v(), f("button", {
|
|
154
156
|
key: 1,
|
|
155
157
|
type: "button",
|
|
156
158
|
class: "fv__btn fv__btn--text",
|
|
157
|
-
onClick:
|
|
159
|
+
onClick: me
|
|
158
160
|
}, [m(a, {
|
|
159
161
|
name: "copy",
|
|
160
162
|
size: 14
|
|
161
|
-
}), p("span", null,
|
|
162
|
-
V.value ? d("", !0) : (
|
|
163
|
+
}), p("span", null, b(I.value ? "Copied" : "Copy"), 1)])) : d("", !0),
|
|
164
|
+
V.value ? d("", !0) : (v(), f("a", {
|
|
163
165
|
key: 2,
|
|
164
166
|
class: "fv__btn",
|
|
165
|
-
href:
|
|
166
|
-
download:
|
|
167
|
+
href: x(M).url,
|
|
168
|
+
download: x(M).name,
|
|
167
169
|
title: "下载"
|
|
168
170
|
}, [m(a, {
|
|
169
171
|
name: "download",
|
|
170
172
|
size: 15
|
|
171
173
|
})], 8, oe)),
|
|
172
|
-
!V.value && !H.value ? (
|
|
174
|
+
!V.value && !H.value ? (v(), f("button", {
|
|
173
175
|
key: 3,
|
|
174
176
|
type: "button",
|
|
175
177
|
class: "fv__btn",
|
|
@@ -183,7 +185,7 @@ var ne = {
|
|
|
183
185
|
type: "button",
|
|
184
186
|
class: "fv__btn",
|
|
185
187
|
title: "关闭",
|
|
186
|
-
onClick: t[4] ||= (...e) =>
|
|
188
|
+
onClick: t[4] ||= (...e) => x(N) && x(N)(...e)
|
|
187
189
|
}, [m(a, {
|
|
188
190
|
name: "close",
|
|
189
191
|
size: 16
|
|
@@ -193,14 +195,14 @@ var ne = {
|
|
|
193
195
|
m(s, {
|
|
194
196
|
ref_key: "bodyRef",
|
|
195
197
|
ref: R,
|
|
196
|
-
file:
|
|
198
|
+
file: x(M),
|
|
197
199
|
mode: P.value,
|
|
198
200
|
"host-chooser": "",
|
|
199
201
|
onLoaded: t[5] ||= (e) => F.value = e,
|
|
200
202
|
onFormatchange: t[6] ||= (e) => z.value = e,
|
|
201
203
|
onFormatpick: t[7] ||= (e) => q.value = e
|
|
202
204
|
}, null, 8, ["file", "mode"])
|
|
203
|
-
])) : d("", !0);
|
|
205
|
+
], 8, te)) : d("", !0);
|
|
204
206
|
}
|
|
205
207
|
});
|
|
206
208
|
//#endregion
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"FileViewerPanel.vue_vue_type_script_setup_true_lang.js","names":[],"sources":["../../src/components/FileViewerPanel.vue"],"sourcesContent":["<script setup lang=\"ts\">\nimport { computed, onBeforeUnmount, ref, watch } from 'vue'\nimport { useFileViewer, type ViewerFile } from '@/composables/useFileViewer'\nimport { canToggleView, skipsTextLoad } from '@/lib/viewerKind'\nimport { VIEWER_FORMATTERS, findFormatter } from '@/lib/viewerFormats'\nimport { fileExt } from '@/lib/fileType'\nimport FileViewerBody from '@/components/FileViewerBody.vue'\nimport Icon from '@/components/icons/Icon.vue'\n\n/**\n * Right-docked viewer for files (DetailView reflows the conversation left to make room — a split pane, not an\n * overlay). The header owns the title, the render/source toggle, and the copy/download/refresh/close actions;\n * the CONTENT is delegated to {@link FileViewerBody}, the shared type dispatcher (also used by the archive\n * browsers, so a file inside a zip previews with the same viewers). The left edge is a drag splitter.\n */\nconst { current, width, setWidth, close } = useFileViewer()\n\ntype ViewMode = 'render' | 'source'\nconst mode = ref<ViewMode>('render')\nconst text = ref('') // body emits the loaded text up so the copy button can use it\nconst copied = ref(false)\nlet copyTimer: ReturnType<typeof setTimeout> | undefined\nconst bodyRef = ref<{\n reload: () => void\n pickFormat: (key: string) => void\n resetFormat: () => void\n} | null>(null)\n\n// The EFFECTIVE file the body is actually showing — it differs from `current` when the user picks a formatter\n// for an otherwise-unpreviewable file (\"open with\", see FileViewerBody). The body announces it via\n// `formatchange`, so the header's render/source toggle + copy/refresh track the chosen format, not the raw file.\nconst shown = ref<ViewerFile | null>(null)\nconst effective = computed(() => shown.value ?? current.value)\n\n// Inline content (e.g. a loaded skill): rendered directly — no download/refresh, but copy still applies.\nconst isInline = computed(() => current.value?.content != null)\n// A file whose content comes from its own sub-viewer / URL (not the panel-loaded text): no copy/refresh.\nconst selfRendered = computed(() => skipsTextLoad(effective.value))\n// Markdown / html / svg / subtitle / geo / csv / mind-map can flip between rendered and source.\nconst canToggle = computed(() => canToggleView(effective.value, text.value))\n\n// ── \"open with\" menu ──────────────────────────────────────────────────────────────────────────\n// The universal entry point: any file can be force-opened as a different formatter (hex / text / image /\n// audio / …) when auto-detection guessed wrong. The body owns the actual override; this header menu just\n// drives it through the exposed pickFormat / resetFormat. `activeFormat` mirrors the body's current choice\n// (it announces it via `formatpick`), so the menu checks the active row and the button labels it.\nconst formatters = VIEWER_FORMATTERS\nconst menuOpen = ref(false)\nconst menuRef = ref<HTMLElement | null>(null)\nconst activeFormat = ref('')\nconst activeFormatter = computed(() => findFormatter(activeFormat.value))\n// Formatters reinterpret fetched bytes, so they need a URL — hide the menu for inline-only content (no url).\nconst showChooser = computed(() => !!current.value?.url)\nconst chooserTitle = computed(() =>\n activeFormatter.value\n ? `已强制以「${activeFormatter.value.label}」打开 —— 点击切换查看方式`\n : '打开方式 —— 选择查看器(十六进制 / 文本 / 图片 / 音频 …)',\n)\n\nfunction chooseFormat(key: string): void {\n bodyRef.value?.pickFormat(key)\n menuOpen.value = false\n}\nfunction chooseAuto(): void {\n bodyRef.value?.resetFormat()\n menuOpen.value = false\n}\n\n// Close the menu on an outside click or Esc — listeners live only while it's open (SSR-guarded). The toggle\n// button + popup both sit inside menuRef, so a click on either is \"inside\" and never self-closes.\nfunction onDocPointer(e: MouseEvent): void {\n if (menuRef.value && !menuRef.value.contains(e.target as Node)) menuOpen.value = false\n}\nfunction onMenuKey(e: KeyboardEvent): void {\n if (e.key === 'Escape') menuOpen.value = false\n}\nwatch(menuOpen, (on) => {\n if (typeof document === 'undefined') return\n if (on) {\n document.addEventListener('click', onDocPointer)\n document.addEventListener('keydown', onMenuKey)\n } else {\n document.removeEventListener('click', onDocPointer)\n document.removeEventListener('keydown', onMenuKey)\n }\n})\n\n// Header: \"name · EXT\" with the extension split off and emphasized.\nconst ext = computed(() => fileExt(current.value?.name ?? '').toUpperCase())\nconst baseName = computed(() => {\n const n = current.value?.name ?? ''\n const e = fileExt(n)\n return e ? n.slice(0, n.length - e.length - 1) : n\n})\n\n// Re-load whenever a different file is opened (the panel stays mounted across files);\n// each newly opened file starts in its rendered (default) view. The body owns the actual fetch.\nwatch(current, (f) => {\n if (!f) return\n mode.value = 'render'\n text.value = ''\n shown.value = null // body re-announces via `formatchange`; clear so a stale override never lingers\n activeFormat.value = '' // a new file starts auto-detected (the body also re-announces via `formatpick`)\n menuOpen.value = false\n})\n\nasync function copy(): Promise<void> {\n try {\n await navigator.clipboard?.writeText(text.value)\n } catch {\n /* clipboard unavailable — ignore */\n }\n copied.value = true\n clearTimeout(copyTimer)\n copyTimer = setTimeout(() => (copied.value = false), 1400)\n}\n\nconst resizing = ref(false)\n\n// Drag the left edge to resize. The pane is docked right, so moving the cursor left widens it.\n// `resizing` drives a scoped class that colors only the grip — no global body class, so the color\n// cannot leak onto the page. The page-wide resize cursor + selection lock are set inline on <body>.\nfunction onResizeStart(e: PointerEvent): void {\n e.preventDefault()\n const el = e.currentTarget as HTMLElement\n const startX = e.clientX\n const startW = width.value\n const body = document.body\n const prevCursor = body.style.cursor\n const prevSelect = body.style.userSelect\n el.setPointerCapture(e.pointerId)\n resizing.value = true\n body.style.cursor = 'col-resize'\n body.style.userSelect = 'none'\n const move = (ev: PointerEvent) => setWidth(startW + (startX - ev.clientX))\n const noSelect = (ev: Event) => ev.preventDefault()\n const up = (ev: PointerEvent) => {\n el.releasePointerCapture(ev.pointerId)\n el.removeEventListener('pointermove', move)\n el.removeEventListener('pointerup', up)\n document.removeEventListener('selectstart', noSelect)\n body.style.cursor = prevCursor\n body.style.userSelect = prevSelect\n resizing.value = false\n }\n el.addEventListener('pointermove', move)\n el.addEventListener('pointerup', up)\n document.addEventListener('selectstart', noSelect)\n}\n\nonBeforeUnmount(() => {\n clearTimeout(copyTimer)\n if (typeof document !== 'undefined') {\n document.removeEventListener('click', onDocPointer)\n document.removeEventListener('keydown', onMenuKey)\n }\n})\n</script>\n\n<template>\n <aside v-if=\"current\" class=\"fv\">\n <div\n class=\"fv__resizer\"\n :class=\"{ 'fv__resizer--active': resizing }\"\n title=\"拖动调整宽度\"\n @pointerdown=\"onResizeStart\"\n >\n <span class=\"fv__grip\" />\n </div>\n\n <header class=\"fv__head\">\n <!-- markdown / html / svg / …: flip between the rendered view and its source. -->\n <div v-if=\"canToggle\" class=\"fv__view\" role=\"group\" aria-label=\"渲染或源码\">\n <button\n type=\"button\"\n class=\"fv__view-btn\"\n :class=\"{ 'fv__view-btn--on': mode === 'render' }\"\n :aria-pressed=\"mode === 'render'\"\n title=\"渲染\"\n @click=\"mode = 'render'\"\n >\n <Icon name=\"eye\" :size=\"15\" />\n </button>\n <button\n type=\"button\"\n class=\"fv__view-btn\"\n :class=\"{ 'fv__view-btn--on': mode === 'source' }\"\n :aria-pressed=\"mode === 'source'\"\n title=\"源码\"\n @click=\"mode = 'source'\"\n >\n <Icon name=\"code\" :size=\"15\" />\n </button>\n </div>\n <span class=\"fv__title\" :title=\"current.name\">\n <span class=\"fv__fname\">{{ baseName }}</span>\n <template v-if=\"ext\"\n ><span class=\"fv__dot\">·</span><span class=\"fv__ext\">{{ ext }}</span></template\n >\n </span>\n <div class=\"fv__actions\">\n <!-- \"open with\" menu: universal viewer chooser — any file can be force-opened as hex/text/image/audio/…\n when auto-detection guessed wrong. Drives the body's override via the exposed pickFormat/resetFormat. -->\n <div v-if=\"showChooser\" ref=\"menuRef\" class=\"fv__menu\">\n <button\n type=\"button\"\n class=\"fv__btn fv__btn--text\"\n :class=\"{ 'fv__btn--on': activeFormat }\"\n :title=\"chooserTitle\"\n :aria-haspopup=\"true\"\n :aria-expanded=\"menuOpen\"\n @click=\"menuOpen = !menuOpen\"\n >\n <Icon name=\"apps\" :size=\"14\" /><span>{{\n activeFormatter ? activeFormatter.label : '打开方式'\n }}</span>\n </button>\n <div v-if=\"menuOpen\" class=\"fv__menu-pop\" role=\"menu\">\n <button\n type=\"button\"\n class=\"fv__menu-item\"\n :class=\"{ 'fv__menu-item--on': !activeFormat }\"\n role=\"menuitemradio\"\n :aria-checked=\"!activeFormat\"\n @click=\"chooseAuto\"\n >\n <Icon name=\"eye\" :size=\"14\" /><span class=\"fv__menu-label\">自动检测</span>\n <Icon v-if=\"!activeFormat\" name=\"check\" :size=\"15\" class=\"fv__menu-check\" />\n </button>\n <div class=\"fv__menu-sep\" />\n <button\n v-for=\"f in formatters\"\n :key=\"f.key\"\n type=\"button\"\n class=\"fv__menu-item\"\n :class=\"{ 'fv__menu-item--on': activeFormat === f.key }\"\n role=\"menuitemradio\"\n :aria-checked=\"activeFormat === f.key\"\n @click=\"chooseFormat(f.key)\"\n >\n <Icon :name=\"f.icon\" :size=\"14\" /><span class=\"fv__menu-label\">{{ f.label }}</span>\n <Icon v-if=\"activeFormat === f.key\" name=\"check\" :size=\"15\" class=\"fv__menu-check\" />\n </button>\n </div>\n </div>\n <button v-if=\"!selfRendered\" type=\"button\" class=\"fv__btn fv__btn--text\" @click=\"copy\">\n <Icon name=\"copy\" :size=\"14\" /><span>{{ copied ? 'Copied' : 'Copy' }}</span>\n </button>\n <a\n v-if=\"!isInline\"\n class=\"fv__btn\"\n :href=\"current.url\"\n :download=\"current.name\"\n title=\"下载\"\n ><Icon name=\"download\" :size=\"15\"\n /></a>\n <button\n v-if=\"!isInline && !selfRendered\"\n type=\"button\"\n class=\"fv__btn\"\n title=\"刷新\"\n @click=\"bodyRef?.reload()\"\n >\n <Icon name=\"retry\" :size=\"15\" />\n </button>\n <button type=\"button\" class=\"fv__btn\" title=\"关闭\" @click=\"close\">\n <Icon name=\"close\" :size=\"16\" />\n </button>\n </div>\n </header>\n\n <FileViewerBody\n ref=\"bodyRef\"\n :file=\"current\"\n :mode=\"mode\"\n host-chooser\n @loaded=\"text = $event\"\n @formatchange=\"shown = $event\"\n @formatpick=\"activeFormat = $event\"\n />\n </aside>\n</template>\n\n<style scoped>\n/* In-flow flex sibling of the conversation (DetailView is the flex row), so each pane owns its own\n * scrollbar — the conversation's sits at its right edge, not hidden behind a floating overlay. */\n.fv {\n --fv-code-fs: 12.5px;\n --fv-code-lh: 21px;\n position: relative;\n flex: 0 0 var(--fv-width);\n min-width: 0;\n height: 100%;\n z-index: 55;\n display: flex;\n flex-direction: column;\n background: var(--surface);\n border-left: 1px solid var(--border);\n box-shadow: var(--shadow);\n}\n\n/* Left-edge drag handle: a wide invisible hit area with a full-height rail + a small centered grip. */\n.fv__resizer {\n position: absolute;\n top: 0;\n bottom: 0;\n left: -4px;\n width: 11px;\n z-index: 2;\n display: flex;\n align-items: center;\n justify-content: center;\n cursor: col-resize;\n touch-action: none;\n}\n.fv__resizer::before {\n content: '';\n position: absolute;\n top: 0;\n bottom: 0;\n left: 50%;\n width: 2px;\n transform: translateX(-50%);\n background: transparent;\n transition: background 0.15s;\n}\n.fv__grip {\n position: relative;\n width: 5px;\n height: 34px;\n border-radius: 3px;\n background: var(--border-2);\n transition:\n background 0.15s,\n height 0.15s;\n}\n.fv__resizer:hover::before,\n.fv__resizer--active::before {\n background: #6ea8fe;\n}\n.fv__resizer:hover .fv__grip,\n.fv__resizer--active .fv__grip {\n background: #6ea8fe;\n}\n.fv__resizer--active .fv__grip {\n height: 48px;\n}\n\n.fv__head {\n display: flex;\n align-items: center;\n gap: 10px;\n flex-shrink: 0;\n padding: 10px 12px 10px 16px;\n border-bottom: 1px solid var(--border);\n background: var(--surface);\n}\n/* Render/source segmented toggle, top-left. */\n.fv__view {\n display: inline-flex;\n align-items: center;\n flex-shrink: 0;\n gap: 2px;\n padding: 2px;\n border-radius: 8px;\n background: var(--surface-2);\n}\n.fv__view-btn {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: 30px;\n height: 26px;\n padding: 0;\n background: transparent;\n border: none;\n border-radius: 6px;\n color: var(--faint);\n cursor: pointer;\n transition:\n background 0.15s,\n color 0.15s;\n}\n.fv__view-btn:hover {\n color: var(--muted);\n}\n.fv__view-btn--on {\n background: var(--surface);\n color: var(--accent);\n box-shadow: var(--shadow);\n}\n.fv__title {\n flex: 1 1 auto;\n min-width: 0;\n display: flex;\n align-items: baseline;\n gap: 5px;\n white-space: nowrap;\n overflow: hidden;\n}\n.fv__fname {\n min-width: 0;\n overflow: hidden;\n text-overflow: ellipsis;\n font-size: 13px;\n font-weight: 500;\n color: var(--text-strong);\n}\n.fv__dot {\n flex-shrink: 0;\n color: var(--faint);\n}\n.fv__ext {\n flex-shrink: 0;\n font-size: 15px;\n font-weight: 600;\n letter-spacing: 0.02em;\n color: var(--muted);\n}\n.fv__actions {\n display: flex;\n align-items: center;\n gap: 2px;\n flex-shrink: 0;\n}\n.fv__btn {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n gap: 5px;\n height: 30px;\n min-width: 30px;\n padding: 0 7px;\n background: transparent;\n border: none;\n border-radius: 7px;\n color: var(--muted);\n font-family: inherit;\n font-size: 12px;\n text-decoration: none;\n cursor: pointer;\n transition:\n background 0.15s,\n color 0.15s;\n}\n.fv__btn:hover {\n background: var(--surface-2);\n color: var(--accent);\n}\n.fv__btn--text {\n padding: 0 9px;\n}\n/* \"open with\" toggle, accented while an override is active (so the forced format reads at a glance). */\n.fv__btn--on {\n color: var(--accent);\n background: var(--surface-2);\n}\n.fv__btn--on:hover {\n color: var(--accent);\n}\n\n/* \"Open with\" dropdown: anchored under its toggle button, right-aligned to the actions edge. */\n.fv__menu {\n position: relative;\n display: inline-flex;\n}\n.fv__menu-pop {\n position: absolute;\n top: calc(100% + 6px);\n right: 0;\n z-index: 60;\n min-width: 184px;\n padding: 5px;\n background: var(--surface);\n border: 1px solid var(--border);\n border-radius: 10px;\n box-shadow: var(--shadow);\n}\n.fv__menu-item {\n display: flex;\n align-items: center;\n gap: 9px;\n width: 100%;\n padding: 7px 9px;\n background: transparent;\n border: none;\n border-radius: 7px;\n color: var(--text);\n font-family: inherit;\n font-size: 12.5px;\n text-align: left;\n cursor: pointer;\n transition:\n background 0.12s,\n color 0.12s;\n}\n.fv__menu-item:hover {\n background: var(--surface-2);\n color: var(--accent);\n}\n.fv__menu-item--on {\n color: var(--accent);\n}\n.fv__menu-label {\n flex: 1 1 auto;\n min-width: 0;\n}\n.fv__menu-check {\n flex-shrink: 0;\n color: var(--accent);\n}\n.fv__menu-sep {\n height: 1px;\n margin: 4px 6px;\n background: var(--border);\n}\n\n@media (max-width: 860px) {\n /* Too narrow to split — overlay the viewer full-screen instead. */\n .fv {\n position: fixed;\n inset: 0;\n flex-basis: auto;\n width: 100%;\n z-index: 70;\n }\n .fv__resizer {\n display: none;\n }\n}\n</style>\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"FileViewerPanel.vue_vue_type_script_setup_true_lang.js","names":[],"sources":["../../src/components/FileViewerPanel.vue"],"sourcesContent":["<script setup lang=\"ts\">\nimport { computed, onBeforeUnmount, ref, watch } from 'vue'\nimport { useFileViewer, type ViewerFile } from '@/composables/useFileViewer'\nimport { canToggleView, skipsTextLoad } from '@/lib/viewerKind'\nimport { VIEWER_FORMATTERS, findFormatter } from '@/lib/viewerFormats'\nimport { fileExt } from '@/lib/fileType'\nimport FileViewerBody from '@/components/FileViewerBody.vue'\nimport Icon from '@/components/icons/Icon.vue'\n\nwithDefaults(defineProps<{ theme?: 'light' | 'dark' | 'system' | 'inherit' }>(), {\n theme: 'inherit',\n})\n\n/**\n * Right-docked viewer for files (DetailView reflows the conversation left to make room — a split pane, not an\n * overlay). The header owns the title, the render/source toggle, and the copy/download/refresh/close actions;\n * the CONTENT is delegated to {@link FileViewerBody}, the shared type dispatcher (also used by the archive\n * browsers, so a file inside a zip previews with the same viewers). The left edge is a drag splitter.\n */\nconst { current, width, setWidth, close } = useFileViewer()\n\ntype ViewMode = 'render' | 'source'\nconst mode = ref<ViewMode>('render')\nconst text = ref('') // body emits the loaded text up so the copy button can use it\nconst copied = ref(false)\nlet copyTimer: ReturnType<typeof setTimeout> | undefined\nconst bodyRef = ref<{\n reload: () => void\n pickFormat: (key: string) => void\n resetFormat: () => void\n copyText: () => string\n} | null>(null)\n\n// The EFFECTIVE file the body is actually showing — it differs from `current` when the user picks a formatter\n// for an otherwise-unpreviewable file (\"open with\", see FileViewerBody). The body announces it via\n// `formatchange`, so the header's render/source toggle + copy/refresh track the chosen format, not the raw file.\nconst shown = ref<ViewerFile | null>(null)\nconst effective = computed(() => shown.value ?? current.value)\n\n// Inline content (e.g. a loaded skill): rendered directly — no download/refresh, but copy still applies.\nconst isInline = computed(() => current.value?.content != null)\n// A file whose content comes from its own sub-viewer / URL (not the panel-loaded text): no copy/refresh.\nconst selfRendered = computed(() => skipsTextLoad(effective.value))\n// LogViewer self-fetches for virtualization but exposes its control-free lines for on-demand copy.\nconst canCopy = computed(() => !selfRendered.value || effective.value?.log === true)\n// Markdown / html / svg / subtitle / geo / csv / mind-map can flip between rendered and source.\nconst canToggle = computed(() => canToggleView(effective.value, text.value))\n\n// ── \"open with\" menu ──────────────────────────────────────────────────────────────────────────\n// The universal entry point: any file can be force-opened as a different formatter (hex / text / image /\n// audio / …) when auto-detection guessed wrong. The body owns the actual override; this header menu just\n// drives it through the exposed pickFormat / resetFormat. `activeFormat` mirrors the body's current choice\n// (it announces it via `formatpick`), so the menu checks the active row and the button labels it.\nconst formatters = VIEWER_FORMATTERS\nconst menuOpen = ref(false)\nconst menuRef = ref<HTMLElement | null>(null)\nconst activeFormat = ref('')\nconst activeFormatter = computed(() => findFormatter(activeFormat.value))\n// Formatters reinterpret fetched bytes, so they need a URL — hide the menu for inline-only content (no url).\nconst showChooser = computed(() => !!current.value?.url)\nconst chooserTitle = computed(() =>\n activeFormatter.value\n ? `已强制以「${activeFormatter.value.label}」打开 —— 点击切换查看方式`\n : '打开方式 —— 选择查看器(十六进制 / 文本 / 图片 / 音频 …)',\n)\n\nfunction chooseFormat(key: string): void {\n bodyRef.value?.pickFormat(key)\n menuOpen.value = false\n}\nfunction chooseAuto(): void {\n bodyRef.value?.resetFormat()\n menuOpen.value = false\n}\n\n// Close the menu on an outside click or Esc — listeners live only while it's open (SSR-guarded). The toggle\n// button + popup both sit inside menuRef, so a click on either is \"inside\" and never self-closes.\nfunction onDocPointer(e: MouseEvent): void {\n if (menuRef.value && !menuRef.value.contains(e.target as Node)) menuOpen.value = false\n}\nfunction onMenuKey(e: KeyboardEvent): void {\n if (e.key === 'Escape') menuOpen.value = false\n}\nwatch(menuOpen, (on) => {\n if (typeof document === 'undefined') return\n if (on) {\n document.addEventListener('click', onDocPointer)\n document.addEventListener('keydown', onMenuKey)\n } else {\n document.removeEventListener('click', onDocPointer)\n document.removeEventListener('keydown', onMenuKey)\n }\n})\n\n// Header: \"name · EXT\" with the extension split off and emphasized.\nconst ext = computed(() => fileExt(current.value?.name ?? '').toUpperCase())\nconst baseName = computed(() => {\n const n = current.value?.name ?? ''\n const e = fileExt(n)\n return e ? n.slice(0, n.length - e.length - 1) : n\n})\n\n// Re-load whenever a different file is opened (the panel stays mounted across files);\n// each newly opened file starts in its rendered (default) view. The body owns the actual fetch.\nwatch(current, (f) => {\n if (!f) return\n mode.value = 'render'\n text.value = ''\n shown.value = null // body re-announces via `formatchange`; clear so a stale override never lingers\n activeFormat.value = '' // a new file starts auto-detected (the body also re-announces via `formatpick`)\n menuOpen.value = false\n})\n\nasync function copy(): Promise<void> {\n try {\n await navigator.clipboard?.writeText(bodyRef.value?.copyText() ?? text.value)\n } catch {\n /* clipboard unavailable — ignore */\n }\n copied.value = true\n clearTimeout(copyTimer)\n copyTimer = setTimeout(() => (copied.value = false), 1400)\n}\n\nconst resizing = ref(false)\n\n// Drag the left edge to resize. The pane is docked right, so moving the cursor left widens it.\n// `resizing` drives a scoped class that colors only the grip — no global body class, so the color\n// cannot leak onto the page. The page-wide resize cursor + selection lock are set inline on <body>.\nfunction onResizeStart(e: PointerEvent): void {\n e.preventDefault()\n const el = e.currentTarget as HTMLElement\n const startX = e.clientX\n const startW = width.value\n const body = document.body\n const prevCursor = body.style.cursor\n const prevSelect = body.style.userSelect\n el.setPointerCapture(e.pointerId)\n resizing.value = true\n body.style.cursor = 'col-resize'\n body.style.userSelect = 'none'\n const move = (ev: PointerEvent) => setWidth(startW + (startX - ev.clientX))\n const noSelect = (ev: Event) => ev.preventDefault()\n const up = (ev: PointerEvent) => {\n el.releasePointerCapture(ev.pointerId)\n el.removeEventListener('pointermove', move)\n el.removeEventListener('pointerup', up)\n document.removeEventListener('selectstart', noSelect)\n body.style.cursor = prevCursor\n body.style.userSelect = prevSelect\n resizing.value = false\n }\n el.addEventListener('pointermove', move)\n el.addEventListener('pointerup', up)\n document.addEventListener('selectstart', noSelect)\n}\n\nonBeforeUnmount(() => {\n clearTimeout(copyTimer)\n if (typeof document !== 'undefined') {\n document.removeEventListener('click', onDocPointer)\n document.removeEventListener('keydown', onMenuKey)\n }\n})\n</script>\n\n<template>\n <aside v-if=\"current\" class=\"fv-root fv\" :data-theme=\"theme === 'inherit' ? undefined : theme\">\n <div\n class=\"fv__resizer\"\n :class=\"{ 'fv__resizer--active': resizing }\"\n title=\"拖动调整宽度\"\n @pointerdown=\"onResizeStart\"\n >\n <span class=\"fv__grip\" />\n </div>\n\n <header class=\"fv__head\">\n <!-- markdown / html / svg / …: flip between the rendered view and its source. -->\n <div v-if=\"canToggle\" class=\"fv__view\" role=\"group\" aria-label=\"渲染或源码\">\n <button\n type=\"button\"\n class=\"fv__view-btn\"\n :class=\"{ 'fv__view-btn--on': mode === 'render' }\"\n :aria-pressed=\"mode === 'render'\"\n title=\"渲染\"\n @click=\"mode = 'render'\"\n >\n <Icon name=\"eye\" :size=\"15\" />\n </button>\n <button\n type=\"button\"\n class=\"fv__view-btn\"\n :class=\"{ 'fv__view-btn--on': mode === 'source' }\"\n :aria-pressed=\"mode === 'source'\"\n title=\"源码\"\n @click=\"mode = 'source'\"\n >\n <Icon name=\"code\" :size=\"15\" />\n </button>\n </div>\n <span class=\"fv__title\" :title=\"current.name\">\n <span class=\"fv__fname\">{{ baseName }}</span>\n <template v-if=\"ext\"\n ><span class=\"fv__dot\">·</span><span class=\"fv__ext\">{{ ext }}</span></template\n >\n </span>\n <div class=\"fv__actions\">\n <!-- \"open with\" menu: universal viewer chooser — any file can be force-opened as hex/text/image/audio/…\n when auto-detection guessed wrong. Drives the body's override via the exposed pickFormat/resetFormat. -->\n <div v-if=\"showChooser\" ref=\"menuRef\" class=\"fv__menu\">\n <button\n type=\"button\"\n class=\"fv__btn fv__btn--text\"\n :class=\"{ 'fv__btn--on': activeFormat }\"\n :title=\"chooserTitle\"\n :aria-haspopup=\"true\"\n :aria-expanded=\"menuOpen\"\n @click=\"menuOpen = !menuOpen\"\n >\n <Icon name=\"apps\" :size=\"14\" /><span>{{\n activeFormatter ? activeFormatter.label : '打开方式'\n }}</span>\n </button>\n <div v-if=\"menuOpen\" class=\"fv__menu-pop\" role=\"menu\">\n <button\n type=\"button\"\n class=\"fv__menu-item\"\n :class=\"{ 'fv__menu-item--on': !activeFormat }\"\n role=\"menuitemradio\"\n :aria-checked=\"!activeFormat\"\n @click=\"chooseAuto\"\n >\n <Icon name=\"eye\" :size=\"14\" /><span class=\"fv__menu-label\">自动检测</span>\n <Icon v-if=\"!activeFormat\" name=\"check\" :size=\"15\" class=\"fv__menu-check\" />\n </button>\n <div class=\"fv__menu-sep\" />\n <button\n v-for=\"f in formatters\"\n :key=\"f.key\"\n type=\"button\"\n class=\"fv__menu-item\"\n :class=\"{ 'fv__menu-item--on': activeFormat === f.key }\"\n role=\"menuitemradio\"\n :aria-checked=\"activeFormat === f.key\"\n @click=\"chooseFormat(f.key)\"\n >\n <Icon :name=\"f.icon\" :size=\"14\" /><span class=\"fv__menu-label\">{{ f.label }}</span>\n <Icon v-if=\"activeFormat === f.key\" name=\"check\" :size=\"15\" class=\"fv__menu-check\" />\n </button>\n </div>\n </div>\n <button v-if=\"canCopy\" type=\"button\" class=\"fv__btn fv__btn--text\" @click=\"copy\">\n <Icon name=\"copy\" :size=\"14\" /><span>{{ copied ? 'Copied' : 'Copy' }}</span>\n </button>\n <a\n v-if=\"!isInline\"\n class=\"fv__btn\"\n :href=\"current.url\"\n :download=\"current.name\"\n title=\"下载\"\n ><Icon name=\"download\" :size=\"15\"\n /></a>\n <button\n v-if=\"!isInline && !selfRendered\"\n type=\"button\"\n class=\"fv__btn\"\n title=\"刷新\"\n @click=\"bodyRef?.reload()\"\n >\n <Icon name=\"retry\" :size=\"15\" />\n </button>\n <button type=\"button\" class=\"fv__btn\" title=\"关闭\" @click=\"close\">\n <Icon name=\"close\" :size=\"16\" />\n </button>\n </div>\n </header>\n\n <FileViewerBody\n ref=\"bodyRef\"\n :file=\"current\"\n :mode=\"mode\"\n host-chooser\n @loaded=\"text = $event\"\n @formatchange=\"shown = $event\"\n @formatpick=\"activeFormat = $event\"\n />\n </aside>\n</template>\n\n<style scoped>\n/* In-flow flex sibling of the conversation (DetailView is the flex row), so each pane owns its own\n * scrollbar — the conversation's sits at its right edge, not hidden behind a floating overlay. */\n.fv {\n --fv-code-fs: 12.5px;\n --fv-code-lh: 21px;\n position: relative;\n flex: 0 0 var(--fv-width);\n min-width: 0;\n height: 100%;\n z-index: 55;\n display: flex;\n flex-direction: column;\n background: var(--surface);\n border-left: 1px solid var(--border);\n box-shadow: var(--shadow);\n}\n\n/* Left-edge drag handle: a wide invisible hit area with a full-height rail + a small centered grip. */\n.fv__resizer {\n position: absolute;\n top: 0;\n bottom: 0;\n left: -4px;\n width: 11px;\n z-index: 2;\n display: flex;\n align-items: center;\n justify-content: center;\n cursor: col-resize;\n touch-action: none;\n}\n.fv__resizer::before {\n content: '';\n position: absolute;\n top: 0;\n bottom: 0;\n left: 50%;\n width: 2px;\n transform: translateX(-50%);\n background: transparent;\n transition: background 0.15s;\n}\n.fv__grip {\n position: relative;\n width: 5px;\n height: 34px;\n border-radius: 3px;\n background: var(--border-2);\n transition:\n background 0.15s,\n height 0.15s;\n}\n.fv__resizer:hover::before,\n.fv__resizer--active::before {\n background: var(--hl-attr);\n}\n.fv__resizer:hover .fv__grip,\n.fv__resizer--active .fv__grip {\n background: var(--hl-attr);\n}\n.fv__resizer--active .fv__grip {\n height: 48px;\n}\n\n.fv__head {\n display: flex;\n align-items: center;\n gap: 10px;\n flex-shrink: 0;\n padding: 10px 12px 10px 16px;\n border-bottom: 1px solid var(--border);\n background: var(--surface);\n}\n/* Render/source segmented toggle, top-left. */\n.fv__view {\n display: inline-flex;\n align-items: center;\n flex-shrink: 0;\n gap: 2px;\n padding: 2px;\n border-radius: 8px;\n background: var(--surface-2);\n}\n.fv__view-btn {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: 30px;\n height: 26px;\n padding: 0;\n background: transparent;\n border: none;\n border-radius: 6px;\n color: var(--faint);\n cursor: pointer;\n transition:\n background 0.15s,\n color 0.15s;\n}\n.fv__view-btn:hover {\n color: var(--muted);\n}\n.fv__view-btn--on {\n background: var(--surface);\n color: var(--accent);\n box-shadow: var(--shadow);\n}\n.fv__title {\n flex: 1 1 auto;\n min-width: 0;\n display: flex;\n align-items: baseline;\n gap: 5px;\n white-space: nowrap;\n overflow: hidden;\n}\n.fv__fname {\n min-width: 0;\n overflow: hidden;\n text-overflow: ellipsis;\n font-size: 13px;\n font-weight: 500;\n color: var(--text-strong);\n}\n.fv__dot {\n flex-shrink: 0;\n color: var(--faint);\n}\n.fv__ext {\n flex-shrink: 0;\n font-size: 15px;\n font-weight: 600;\n letter-spacing: 0.02em;\n color: var(--muted);\n}\n.fv__actions {\n display: flex;\n align-items: center;\n gap: 2px;\n flex-shrink: 0;\n}\n.fv__btn {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n gap: 5px;\n height: 30px;\n min-width: 30px;\n padding: 0 7px;\n background: transparent;\n border: none;\n border-radius: 7px;\n color: var(--muted);\n font-family: inherit;\n font-size: 12px;\n text-decoration: none;\n cursor: pointer;\n transition:\n background 0.15s,\n color 0.15s;\n}\n.fv__btn:hover {\n background: var(--surface-2);\n color: var(--accent);\n}\n.fv__btn--text {\n padding: 0 9px;\n}\n/* \"open with\" toggle, accented while an override is active (so the forced format reads at a glance). */\n.fv__btn--on {\n color: var(--accent);\n background: var(--surface-2);\n}\n.fv__btn--on:hover {\n color: var(--accent);\n}\n\n/* \"Open with\" dropdown: anchored under its toggle button, right-aligned to the actions edge. */\n.fv__menu {\n position: relative;\n display: inline-flex;\n}\n.fv__menu-pop {\n position: absolute;\n top: calc(100% + 6px);\n right: 0;\n z-index: 60;\n min-width: 184px;\n padding: 5px;\n background: var(--surface);\n border: 1px solid var(--border);\n border-radius: 10px;\n box-shadow: var(--shadow);\n}\n.fv__menu-item {\n display: flex;\n align-items: center;\n gap: 9px;\n width: 100%;\n padding: 7px 9px;\n background: transparent;\n border: none;\n border-radius: 7px;\n color: var(--text);\n font-family: inherit;\n font-size: 12.5px;\n text-align: left;\n cursor: pointer;\n transition:\n background 0.12s,\n color 0.12s;\n}\n.fv__menu-item:hover {\n background: var(--surface-2);\n color: var(--accent);\n}\n.fv__menu-item--on {\n color: var(--accent);\n}\n.fv__menu-label {\n flex: 1 1 auto;\n min-width: 0;\n}\n.fv__menu-check {\n flex-shrink: 0;\n color: var(--accent);\n}\n.fv__menu-sep {\n height: 1px;\n margin: 4px 6px;\n background: var(--border);\n}\n\n@media (max-width: 860px) {\n /* Too narrow to split — overlay the viewer full-screen instead. */\n .fv {\n position: fixed;\n inset: 0;\n flex-basis: auto;\n width: 100%;\n z-index: 70;\n }\n .fv__resizer {\n display: none;\n }\n}\n</style>\n"],"mappings":"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"}
|
|
@@ -2,7 +2,7 @@ import e from "../_virtual/_plugin-vue_export-helper.js";
|
|
|
2
2
|
import t from "./HarViewer.vue_vue_type_script_setup_true_lang.js";
|
|
3
3
|
/* empty css */
|
|
4
4
|
//#region src/components/HarViewer.vue
|
|
5
|
-
var n = /*#__PURE__*/ e(t, [["__scopeId", "data-v-
|
|
5
|
+
var n = /*#__PURE__*/ e(t, [["__scopeId", "data-v-c882020f"]]);
|
|
6
6
|
//#endregion
|
|
7
7
|
export { n as default };
|
|
8
8
|
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"HarViewer.js","names":[],"sources":["../../src/components/HarViewer.vue"],"sourcesContent":["<script setup lang=\"ts\">\nimport { computed, onBeforeUnmount, ref, watch } from 'vue'\nimport {\n type DecodedBody,\n type HarEntry,\n type HarNV,\n decodeContent,\n decodePostData,\n fmtMs,\n fmtSize,\n parseHar,\n parseQuery,\n prettyBody,\n statusClass,\n toCurl,\n} from '@/lib/har'\nimport { highlightCode } from '@/lib/highlight'\nimport { formatBytes } from '@/lib/format'\n\n/**\n * Side-viewer HAR (`.har`) renderer. Fetches the archive text and parses the HTTP Archive entirely in-browser\n * (hand-rolled, see lib/har.ts) — no third-party. Lays out a master-detail vertical split tuned for the narrow\n * side pane: a filterable request list on top, and the selected request's detail (general / headers / query /\n * cookies / request + response body) below. Bodies are decoded (standard base64 + the «base64 but undeclared»\n * case some tools emit); images preview from a data URI, binaries show a hex dump.\n *\n * Safety: a HAR body is untrusted upstream content. We never execute it — text bodies render as escaped,\n * optionally syntax-highlighted source; image bodies render through a data-URI `<img>` (an `<img>` never runs\n * scripts, so even an SVG body is inert). No body is fetched as a live resource.\n */\nconst props = defineProps<{\n /** Download URL of the .har bytes. */\n url?: string\n name: string\n size?: number\n}>()\n\ntype Status = 'loading' | 'gate' | 'ready' | 'error'\nconst status = ref<Status>('loading')\nconst errorMsg = ref('')\n\ninterface HarState {\n entries: HarEntry[]\n creator: string\n version: string\n}\nconst doc = ref<HarState | null>(null)\nconst selected = ref<HarEntry | null>(null)\nconst query = ref('')\n\ntype DetailTab =\n | 'general'\n | 'reqHeaders'\n | 'resHeaders'\n | 'query'\n | 'cookies'\n | 'reqBody'\n | 'resBody'\nconst tab = ref<DetailTab>('general')\n\n// A HAR can be large (megabytes of inlined bodies); gate very big ones behind a confirm before parsing.\nconst SIZE_LIMIT = 25 * 1024 * 1024\n\nconst CATEGORY_LABELS: Record<string, string> = {\n doc: '文档',\n xhr: 'XHR',\n js: 'JS',\n css: 'CSS',\n img: '图片',\n font: '字体',\n media: '媒体',\n ws: 'WS',\n other: '其它',\n}\n\n// Category quick-filter: 'all' or a HarCategory; only categories present in the doc are offered.\nconst catFilter = ref<string>('all')\nconst presentCategories = computed(() => {\n const seen = new Set<string>()\n for (const e of doc.value?.entries ?? []) seen.add(e.category)\n return Object.keys(CATEGORY_LABELS).filter((c) => seen.has(c))\n})\n\nconst filtered = computed(() => {\n const list = doc.value?.entries ?? []\n const q = query.value.trim().toLowerCase()\n const cat = catFilter.value\n return list.filter((e) => {\n if (cat !== 'all' && e.category !== cat) return false\n if (!q) return true\n return (\n e.url.toLowerCase().includes(q) ||\n e.method.toLowerCase().includes(q) ||\n String(e.status).includes(q) ||\n e.type.toLowerCase().includes(q)\n )\n })\n})\n\n// Last path segment (or host) as the row's headline; the host shows as the dim sub-line.\nfunction entryName(e: HarEntry): string {\n const seg = e.pathname.split('/').filter(Boolean).pop()\n return seg || e.host || e.pathname || e.url\n}\n\nfunction pick(e: HarEntry): void {\n selected.value = e\n // Reset to a tab that has content: prefer general; it's always present.\n tab.value = 'general'\n}\n\nasync function doLoad(): Promise<void> {\n if (!props.url) return\n status.value = 'loading'\n errorMsg.value = ''\n selected.value = null\n try {\n const res = await fetch(props.url)\n if (!res.ok) throw new Error(`HTTP ${res.status}`)\n const text = await res.text()\n const parsed = parseHar(text)\n doc.value = { entries: parsed.entries, creator: parsed.creator, version: parsed.version }\n status.value = 'ready'\n } catch (e) {\n errorMsg.value = e instanceof Error ? e.message : String(e)\n status.value = 'error'\n }\n}\n\nfunction load(): void {\n if (props.size && props.size > SIZE_LIMIT) {\n status.value = 'gate'\n return\n }\n void doLoad()\n}\n\nwatch(() => props.url, load, { immediate: true })\n\n/* ===== detail derivations ===== */\n\nconst reqHeaders = computed<HarNV[]>(() => selected.value?.raw.request.headers ?? [])\nconst resHeaders = computed<HarNV[]>(() => selected.value?.raw.response.headers ?? [])\nconst queryParams = computed<HarNV[]>(() => {\n const e = selected.value\n if (!e) return []\n // Prefer the explicit queryString; fall back to parsing the URL's search.\n const qs = e.raw.request.queryString\n if (qs.length) return qs\n const i = e.url.indexOf('?')\n return i >= 0 ? parseQuery(e.url.slice(i)) : []\n})\nconst cookies = computed(() => {\n const e = selected.value\n if (!e) return { req: [], res: [] }\n return { req: e.raw.request.cookies, res: e.raw.response.cookies }\n})\nconst hasCookies = computed(() => cookies.value.req.length + cookies.value.res.length > 0)\n\nconst reqBody = computed<DecodedBody | null>(() => {\n const e = selected.value\n if (!e) return null\n const b = decodePostData(e.raw.request.postData, 'auto')\n return b.kind === 'empty' ? null : b\n})\nconst resBody = computed<DecodedBody | null>(() => {\n const e = selected.value\n if (!e) return null\n const b = decodeContent(e.raw.response.content, 'auto')\n return b.kind === 'empty' ? null : b\n})\n\n// General info rows for the selected entry.\nconst generalRows = computed<HarNV[]>(() => {\n const e = selected.value\n if (!e) return []\n const r = e.raw\n const rows: HarNV[] = [\n { name: '请求 URL', value: e.url },\n { name: '方法', value: e.method },\n { name: '状态', value: e.status ? `${e.status} ${e.statusText}`.trim() : '(无响应)' },\n { name: '协议', value: r.response.httpVersion || r.request.httpVersion || '' },\n { name: '远端 IP', value: e.serverIP },\n { name: '响应类型', value: e.mime || '' },\n { name: '响应大小', value: fmtSize(e.size) },\n { name: '请求体大小', value: e.reqSize ? fmtSize(e.reqSize) : '' },\n { name: '耗时', value: fmtMs(e.time) },\n { name: '开始时间', value: e.startedISO },\n ]\n if (r.response.redirectURL) rows.push({ name: '重定向', value: r.response.redirectURL })\n return rows.filter((row) => row.value)\n})\n\n/** Render a decoded body to HTML for the <pre>: pretty-print JSON, syntax-highlight when we have a language. */\nfunction bodyHtml(body: DecodedBody): string {\n const { text, pretty } = prettyBody(body.text, body.mime)\n const lang = pretty ? 'json' : bodyLangFor(body.mime)\n return lang ? highlightCode(text, lang) : escapeHtml(text)\n}\nfunction bodyLangFor(mime: string): string {\n const m = mime.toLowerCase()\n if (m.includes('javascript') || m.includes('ecmascript')) return 'javascript'\n if (m === 'text/css') return 'css'\n if (m === 'text/html' || m.includes('xhtml')) return 'xml'\n if (m.includes('json')) return 'json'\n if (m.includes('svg') || m.includes('xml')) return 'xml'\n return ''\n}\nfunction escapeHtml(s: string): string {\n return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')\n}\n\n/* ===== copy helpers ===== */\n\nconst copiedKey = ref('')\nlet copyTimer: ReturnType<typeof setTimeout> | undefined\nasync function copyText(text: string, key: string): Promise<void> {\n try {\n await navigator.clipboard?.writeText(text)\n } catch {\n /* clipboard unavailable — ignore */\n }\n copiedKey.value = key\n clearTimeout(copyTimer)\n copyTimer = setTimeout(() => (copiedKey.value = ''), 1400)\n}\nfunction copyCurl(): void {\n if (selected.value) void copyText(toCurl(selected.value), 'curl')\n}\nonBeforeUnmount(() => clearTimeout(copyTimer))\n</script>\n\n<template>\n <div class=\"har\">\n <p v-if=\"status === 'loading'\" class=\"har__msg\">正在解析 HAR…</p>\n\n <div v-else-if=\"status === 'gate'\" class=\"har__msg har__gate\">\n <p>该 HAR 较大({{ formatBytes(props.size) }}),将在浏览器内解析。是否继续?</p>\n <button type=\"button\" class=\"har__btn\" @click=\"doLoad\">解析并预览</button>\n </div>\n\n <div v-else-if=\"status === 'error'\" class=\"har__msg har__msg--error\">\n <p>解析失败:{{ errorMsg }}</p>\n <button type=\"button\" class=\"har__btn\" @click=\"doLoad\">重试</button>\n </div>\n\n <template v-else-if=\"status === 'ready' && doc\">\n <!-- toolbar: filter + category chips + count -->\n <div class=\"har__bar\">\n <input\n v-model=\"query\"\n class=\"har__filter\"\n type=\"text\"\n placeholder=\"筛选 URL / 方法 / 状态…\"\n />\n <div class=\"har__cats\">\n <button\n type=\"button\"\n class=\"har__cat\"\n :class=\"{ 'har__cat--on': catFilter === 'all' }\"\n @click=\"catFilter = 'all'\"\n >\n 全部 {{ doc.entries.length }}\n </button>\n <button\n v-for=\"c in presentCategories\"\n :key=\"c\"\n type=\"button\"\n class=\"har__cat\"\n :class=\"[`har__cat--${c}`, { 'har__cat--on': catFilter === c }]\"\n @click=\"catFilter = c\"\n >\n {{ CATEGORY_LABELS[c] }}\n </button>\n </div>\n </div>\n\n <!-- master: request list -->\n <div class=\"har__list\" :class=\"{ 'har__list--split': selected }\">\n <p v-if=\"!filtered.length\" class=\"har__empty\">没有匹配的请求。</p>\n <button\n v-for=\"e in filtered\"\n :key=\"e.index\"\n type=\"button\"\n class=\"har__row\"\n :class=\"{ 'har__row--on': selected?.index === e.index }\"\n :title=\"e.url\"\n @click=\"pick(e)\"\n >\n <span class=\"har__method\" :class=\"`m-${e.method.toLowerCase()}`\">{{ e.method }}</span>\n <span class=\"har__status\" :class=\"statusClass(e.status)\">{{ e.status || '—' }}</span>\n <span class=\"har__name\">\n <span class=\"har__name-main\">{{ entryName(e) }}</span>\n <span class=\"har__name-sub\">{{ e.host }}</span>\n </span>\n <span class=\"har__type\">{{ e.type }}</span>\n <span class=\"har__size\">{{ fmtSize(e.size) }}</span>\n <span class=\"har__time\">{{ fmtMs(e.time) }}</span>\n </button>\n </div>\n\n <!-- detail: tabs + content -->\n <section v-if=\"selected\" class=\"har__detail\">\n <div class=\"har__tabs\">\n <button\n type=\"button\"\n class=\"har__tab\"\n :class=\"{ 'har__tab--on': tab === 'general' }\"\n @click=\"tab = 'general'\"\n >\n 概览\n </button>\n <button\n type=\"button\"\n class=\"har__tab\"\n :class=\"{ 'har__tab--on': tab === 'reqHeaders' }\"\n @click=\"tab = 'reqHeaders'\"\n >\n 请求头 <span class=\"har__tab-n\">{{ reqHeaders.length }}</span>\n </button>\n <button\n type=\"button\"\n class=\"har__tab\"\n :class=\"{ 'har__tab--on': tab === 'resHeaders' }\"\n @click=\"tab = 'resHeaders'\"\n >\n 响应头 <span class=\"har__tab-n\">{{ resHeaders.length }}</span>\n </button>\n <button\n v-if=\"queryParams.length\"\n type=\"button\"\n class=\"har__tab\"\n :class=\"{ 'har__tab--on': tab === 'query' }\"\n @click=\"tab = 'query'\"\n >\n 查询 <span class=\"har__tab-n\">{{ queryParams.length }}</span>\n </button>\n <button\n v-if=\"hasCookies\"\n type=\"button\"\n class=\"har__tab\"\n :class=\"{ 'har__tab--on': tab === 'cookies' }\"\n @click=\"tab = 'cookies'\"\n >\n Cookie\n </button>\n <button\n v-if=\"reqBody\"\n type=\"button\"\n class=\"har__tab\"\n :class=\"{ 'har__tab--on': tab === 'reqBody' }\"\n @click=\"tab = 'reqBody'\"\n >\n 请求体\n </button>\n <button\n v-if=\"resBody\"\n type=\"button\"\n class=\"har__tab\"\n :class=\"{ 'har__tab--on': tab === 'resBody' }\"\n @click=\"tab = 'resBody'\"\n >\n 响应体\n </button>\n </div>\n\n <div class=\"har__pane\">\n <!-- general -->\n <template v-if=\"tab === 'general'\">\n <dl class=\"har__kv\">\n <div v-for=\"row in generalRows\" :key=\"row.name\" class=\"har__kv-row\">\n <dt>{{ row.name }}</dt>\n <dd>{{ row.value }}</dd>\n </div>\n </dl>\n <button type=\"button\" class=\"har__btn har__btn--sm\" @click=\"copyCurl\">\n {{ copiedKey === 'curl' ? '已复制' : '复制为 cURL' }}\n </button>\n </template>\n\n <!-- request headers -->\n <dl v-else-if=\"tab === 'reqHeaders'\" class=\"har__kv har__kv--mono\">\n <div v-for=\"(h, i) in reqHeaders\" :key=\"i\" class=\"har__kv-row\">\n <dt>{{ h.name }}</dt>\n <dd>{{ h.value }}</dd>\n </div>\n </dl>\n\n <!-- response headers -->\n <dl v-else-if=\"tab === 'resHeaders'\" class=\"har__kv har__kv--mono\">\n <div v-for=\"(h, i) in resHeaders\" :key=\"i\" class=\"har__kv-row\">\n <dt>{{ h.name }}</dt>\n <dd>{{ h.value }}</dd>\n </div>\n </dl>\n\n <!-- query string -->\n <dl v-else-if=\"tab === 'query'\" class=\"har__kv har__kv--mono\">\n <div v-for=\"(p, i) in queryParams\" :key=\"i\" class=\"har__kv-row\">\n <dt>{{ p.name }}</dt>\n <dd>{{ p.value }}</dd>\n </div>\n </dl>\n\n <!-- cookies -->\n <template v-else-if=\"tab === 'cookies'\">\n <template v-if=\"cookies.req.length\">\n <h4 class=\"har__sub\">请求 Cookie</h4>\n <dl class=\"har__kv har__kv--mono\">\n <div v-for=\"(c, i) in cookies.req\" :key=\"i\" class=\"har__kv-row\">\n <dt>{{ c.name }}</dt>\n <dd>{{ c.value }}</dd>\n </div>\n </dl>\n </template>\n <template v-if=\"cookies.res.length\">\n <h4 class=\"har__sub\">响应 Set-Cookie</h4>\n <dl class=\"har__kv har__kv--mono\">\n <div v-for=\"(c, i) in cookies.res\" :key=\"i\" class=\"har__kv-row\">\n <dt>{{ c.name }}</dt>\n <dd>{{ c.value }}</dd>\n </div>\n </dl>\n </template>\n </template>\n\n <!-- request body -->\n <template v-else-if=\"tab === 'reqBody' && reqBody\">\n <dl v-if=\"reqBody.kind === 'form' && reqBody.form\" class=\"har__kv har__kv--mono\">\n <div v-for=\"(p, i) in reqBody.form\" :key=\"i\" class=\"har__kv-row\">\n <dt>{{ p.name }}</dt>\n <dd>{{ p.value }}</dd>\n </div>\n </dl>\n <template v-else>\n <div class=\"har__bodybar\">\n <span class=\"har__bodymime\"\n >{{ reqBody.mime || '文本' }} · {{ fmtSize(reqBody.size) }}</span\n >\n <button\n type=\"button\"\n class=\"har__btn har__btn--sm\"\n @click=\"copyText(reqBody.text, 'reqBody')\"\n >\n {{ copiedKey === 'reqBody' ? '已复制' : '复制' }}\n </button>\n </div>\n <pre class=\"har__body\"><code class=\"hljs\" v-html=\"bodyHtml(reqBody)\" /></pre>\n </template>\n </template>\n\n <!-- response body -->\n <template v-else-if=\"tab === 'resBody' && resBody\">\n <div class=\"har__bodybar\">\n <span class=\"har__bodymime\">\n {{ resBody.mime || '文本' }} · {{ fmtSize(resBody.size) }}\n <span v-if=\"resBody.wasBase64\" class=\"har__b64\">base64 已解码</span>\n </span>\n <button\n v-if=\"!resBody.isImage\"\n type=\"button\"\n class=\"har__btn har__btn--sm\"\n @click=\"copyText(resBody.text, 'resBody')\"\n >\n {{ copiedKey === 'resBody' ? '已复制' : '复制' }}\n </button>\n </div>\n <div v-if=\"resBody.isImage\" class=\"har__img\">\n <img :src=\"resBody.dataUri\" :alt=\"selected.url\" />\n </div>\n <pre v-else class=\"har__body\"><code class=\"hljs\" v-html=\"bodyHtml(resBody)\" /></pre>\n </template>\n </div>\n </section>\n </template>\n </div>\n</template>\n\n<style scoped>\n.har {\n flex: 1 1 auto;\n min-height: 0;\n display: flex;\n flex-direction: column;\n background: var(--surface);\n}\n\n.har__msg {\n padding: 20px 16px;\n color: var(--muted);\n font-size: 13px;\n}\n.har__msg--error {\n display: flex;\n flex-direction: column;\n align-items: flex-start;\n gap: 10px;\n color: var(--danger);\n}\n.har__gate {\n display: flex;\n flex-direction: column;\n align-items: flex-start;\n gap: 12px;\n}\n.har__btn {\n font-family: inherit;\n font-size: 12.5px;\n color: var(--text);\n background: var(--surface);\n border: 1px solid var(--border-2);\n border-radius: 8px;\n padding: 5px 12px;\n cursor: pointer;\n}\n.har__btn:hover {\n border-color: var(--accent);\n color: var(--accent);\n}\n.har__btn--sm {\n padding: 3px 10px;\n font-size: 11.5px;\n}\n\n/* toolbar */\n.har__bar {\n flex-shrink: 0;\n display: flex;\n flex-direction: column;\n gap: 8px;\n padding: 10px 12px;\n border-bottom: 1px solid var(--border);\n}\n.har__filter {\n width: 100%;\n padding: 6px 10px;\n font-family: inherit;\n font-size: 12.5px;\n color: var(--text);\n background: var(--surface-2);\n border: 1px solid var(--border);\n border-radius: 7px;\n}\n.har__filter:focus {\n outline: none;\n border-color: var(--accent);\n}\n.har__cats {\n display: flex;\n flex-wrap: wrap;\n gap: 5px;\n}\n.har__cat {\n font-family: inherit;\n font-size: 11px;\n color: var(--muted);\n background: var(--surface-2);\n border: 1px solid var(--border);\n border-radius: 999px;\n padding: 2px 9px;\n cursor: pointer;\n transition:\n border-color 0.15s,\n color 0.15s;\n}\n.har__cat:hover {\n border-color: var(--accent);\n color: var(--accent);\n}\n.har__cat--on {\n color: #fff;\n background: var(--accent);\n border-color: var(--accent);\n}\n\n/* master list */\n.har__list {\n flex: 1 1 auto;\n min-height: 0;\n overflow-y: auto;\n overscroll-behavior: contain;\n}\n.har__list--split {\n flex: 0 0 42%;\n border-bottom: 1px solid var(--border);\n}\n.har__empty {\n padding: 18px 14px;\n font-size: 12.5px;\n color: var(--faint);\n}\n.har__row {\n display: flex;\n align-items: center;\n gap: 8px;\n width: 100%;\n padding: 6px 12px;\n background: transparent;\n border: none;\n border-bottom: 1px solid var(--border);\n font-family: inherit;\n text-align: left;\n cursor: pointer;\n}\n.har__row:hover {\n background: var(--surface-2);\n}\n.har__row--on {\n background: color-mix(in srgb, var(--accent) 12%, transparent);\n}\n.har__method {\n flex-shrink: 0;\n width: 42px;\n font-family: var(--font-mono);\n font-size: 10.5px;\n font-weight: 700;\n letter-spacing: 0.02em;\n color: var(--muted);\n}\n.har__method.m-get {\n color: #2f9e44;\n}\n.har__method.m-post {\n color: #1c7ed6;\n}\n.har__method.m-put,\n.har__method.m-patch {\n color: #f08c00;\n}\n.har__method.m-delete {\n color: #e03131;\n}\n.har__status {\n flex-shrink: 0;\n width: 30px;\n font-family: var(--font-mono);\n font-size: 11px;\n font-weight: 600;\n}\n.har__name {\n flex: 1 1 auto;\n min-width: 0;\n display: flex;\n flex-direction: column;\n}\n.har__name-main {\n font-size: 12px;\n color: var(--text-strong);\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n}\n.har__name-sub {\n font-size: 10.5px;\n color: var(--faint);\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n}\n.har__type {\n flex-shrink: 0;\n font-size: 10.5px;\n color: var(--muted);\n}\n.har__size,\n.har__time {\n flex-shrink: 0;\n width: 52px;\n text-align: right;\n font-family: var(--font-mono);\n font-size: 10.5px;\n color: var(--faint);\n}\n\n/* status colors (shared) */\n.s-2xx {\n color: #2f9e44;\n}\n.s-3xx {\n color: #1c7ed6;\n}\n.s-4xx {\n color: #f08c00;\n}\n.s-5xx {\n color: #e03131;\n}\n.s-pending {\n color: var(--faint);\n}\n\n/* detail */\n.har__detail {\n flex: 1 1 58%;\n min-height: 0;\n display: flex;\n flex-direction: column;\n}\n.har__tabs {\n flex-shrink: 0;\n display: flex;\n gap: 2px;\n padding: 6px 10px 0;\n overflow-x: auto;\n border-bottom: 1px solid var(--border);\n white-space: nowrap;\n}\n.har__tab {\n flex-shrink: 0;\n font-family: inherit;\n font-size: 12px;\n color: var(--muted);\n background: transparent;\n border: none;\n border-bottom: 2px solid transparent;\n padding: 6px 9px;\n cursor: pointer;\n}\n.har__tab:hover {\n color: var(--text);\n}\n.har__tab--on {\n color: var(--accent);\n border-bottom-color: var(--accent);\n}\n.har__tab-n {\n font-size: 10.5px;\n color: var(--faint);\n}\n.har__pane {\n flex: 1 1 auto;\n min-height: 0;\n overflow-y: auto;\n overscroll-behavior: contain;\n padding: 12px 14px;\n}\n\n.har__kv {\n margin: 0 0 12px;\n display: flex;\n flex-direction: column;\n}\n.har__kv-row {\n display: flex;\n gap: 10px;\n padding: 4px 0;\n border-bottom: 1px solid var(--border);\n font-size: 12px;\n line-height: 1.5;\n}\n.har__kv-row dt {\n flex-shrink: 0;\n width: 38%;\n max-width: 180px;\n color: var(--muted);\n word-break: break-word;\n}\n.har__kv-row dd {\n flex: 1 1 auto;\n min-width: 0;\n margin: 0;\n color: var(--text);\n word-break: break-word;\n}\n.har__kv--mono dt,\n.har__kv--mono dd {\n font-family: var(--font-mono);\n font-size: 11.5px;\n}\n.har__sub {\n margin: 4px 0 8px;\n font-size: 12px;\n font-weight: 600;\n color: var(--text-strong);\n}\n\n.har__bodybar {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 10px;\n margin-bottom: 8px;\n}\n.har__bodymime {\n font-size: 11px;\n color: var(--faint);\n}\n.har__b64 {\n margin-left: 6px;\n padding: 1px 6px;\n border-radius: 4px;\n background: var(--surface-2);\n color: var(--muted);\n}\n.har__body {\n margin: 0;\n padding: 10px 12px;\n background: var(--code-bg);\n border: 1px solid var(--code-border);\n border-radius: 8px;\n font-family: var(--font-mono);\n font-size: 11.5px;\n line-height: 1.55;\n color: var(--text);\n white-space: pre-wrap;\n word-break: break-word;\n overflow-x: auto;\n}\n.har__body code {\n font-family: inherit;\n background: transparent;\n border: none;\n padding: 0;\n}\n.har__img {\n display: flex;\n justify-content: center;\n padding: 12px;\n background: var(--surface-2);\n border-radius: 8px;\n}\n.har__img img {\n max-width: 100%;\n height: auto;\n object-fit: contain;\n}\n</style>\n"],"mappings":""}
|
|
1
|
+
{"version":3,"file":"HarViewer.js","names":[],"sources":["../../src/components/HarViewer.vue"],"sourcesContent":["<script setup lang=\"ts\">\nimport { computed, onBeforeUnmount, ref, watch } from 'vue'\nimport {\n type DecodedBody,\n type HarEntry,\n type HarNV,\n decodeContent,\n decodePostData,\n fmtMs,\n fmtSize,\n parseHar,\n parseQuery,\n prettyBody,\n statusClass,\n toCurl,\n} from '@/lib/har'\nimport { highlightCode } from '@/lib/highlight'\nimport { formatBytes } from '@/lib/format'\n\n/**\n * Side-viewer HAR (`.har`) renderer. Fetches the archive text and parses the HTTP Archive entirely in-browser\n * (hand-rolled, see lib/har.ts) — no third-party. Lays out a master-detail vertical split tuned for the narrow\n * side pane: a filterable request list on top, and the selected request's detail (general / headers / query /\n * cookies / request + response body) below. Bodies are decoded (standard base64 + the «base64 but undeclared»\n * case some tools emit); images preview from a data URI, binaries show a hex dump.\n *\n * Safety: a HAR body is untrusted upstream content. We never execute it — text bodies render as escaped,\n * optionally syntax-highlighted source; image bodies render through a data-URI `<img>` (an `<img>` never runs\n * scripts, so even an SVG body is inert). No body is fetched as a live resource.\n */\nconst props = defineProps<{\n /** Download URL of the .har bytes. */\n url?: string\n name: string\n size?: number\n}>()\n\ntype Status = 'loading' | 'gate' | 'ready' | 'error'\nconst status = ref<Status>('loading')\nconst errorMsg = ref('')\n\ninterface HarState {\n entries: HarEntry[]\n creator: string\n version: string\n}\nconst doc = ref<HarState | null>(null)\nconst selected = ref<HarEntry | null>(null)\nconst query = ref('')\n\ntype DetailTab =\n | 'general'\n | 'reqHeaders'\n | 'resHeaders'\n | 'query'\n | 'cookies'\n | 'reqBody'\n | 'resBody'\nconst tab = ref<DetailTab>('general')\n\n// A HAR can be large (megabytes of inlined bodies); gate very big ones behind a confirm before parsing.\nconst SIZE_LIMIT = 25 * 1024 * 1024\n\nconst CATEGORY_LABELS: Record<string, string> = {\n doc: '文档',\n xhr: 'XHR',\n js: 'JS',\n css: 'CSS',\n img: '图片',\n font: '字体',\n media: '媒体',\n ws: 'WS',\n other: '其它',\n}\n\n// Category quick-filter: 'all' or a HarCategory; only categories present in the doc are offered.\nconst catFilter = ref<string>('all')\nconst presentCategories = computed(() => {\n const seen = new Set<string>()\n for (const e of doc.value?.entries ?? []) seen.add(e.category)\n return Object.keys(CATEGORY_LABELS).filter((c) => seen.has(c))\n})\n\nconst filtered = computed(() => {\n const list = doc.value?.entries ?? []\n const q = query.value.trim().toLowerCase()\n const cat = catFilter.value\n return list.filter((e) => {\n if (cat !== 'all' && e.category !== cat) return false\n if (!q) return true\n return (\n e.url.toLowerCase().includes(q) ||\n e.method.toLowerCase().includes(q) ||\n String(e.status).includes(q) ||\n e.type.toLowerCase().includes(q)\n )\n })\n})\n\n// Last path segment (or host) as the row's headline; the host shows as the dim sub-line.\nfunction entryName(e: HarEntry): string {\n const seg = e.pathname.split('/').filter(Boolean).pop()\n return seg || e.host || e.pathname || e.url\n}\n\nfunction pick(e: HarEntry): void {\n selected.value = e\n // Reset to a tab that has content: prefer general; it's always present.\n tab.value = 'general'\n}\n\nasync function doLoad(): Promise<void> {\n if (!props.url) return\n status.value = 'loading'\n errorMsg.value = ''\n selected.value = null\n try {\n const res = await fetch(props.url)\n if (!res.ok) throw new Error(`HTTP ${res.status}`)\n const text = await res.text()\n const parsed = parseHar(text)\n doc.value = { entries: parsed.entries, creator: parsed.creator, version: parsed.version }\n status.value = 'ready'\n } catch (e) {\n errorMsg.value = e instanceof Error ? e.message : String(e)\n status.value = 'error'\n }\n}\n\nfunction load(): void {\n if (props.size && props.size > SIZE_LIMIT) {\n status.value = 'gate'\n return\n }\n void doLoad()\n}\n\nwatch(() => props.url, load, { immediate: true })\n\n/* ===== detail derivations ===== */\n\nconst reqHeaders = computed<HarNV[]>(() => selected.value?.raw.request.headers ?? [])\nconst resHeaders = computed<HarNV[]>(() => selected.value?.raw.response.headers ?? [])\nconst queryParams = computed<HarNV[]>(() => {\n const e = selected.value\n if (!e) return []\n // Prefer the explicit queryString; fall back to parsing the URL's search.\n const qs = e.raw.request.queryString\n if (qs.length) return qs\n const i = e.url.indexOf('?')\n return i >= 0 ? parseQuery(e.url.slice(i)) : []\n})\nconst cookies = computed(() => {\n const e = selected.value\n if (!e) return { req: [], res: [] }\n return { req: e.raw.request.cookies, res: e.raw.response.cookies }\n})\nconst hasCookies = computed(() => cookies.value.req.length + cookies.value.res.length > 0)\n\nconst reqBody = computed<DecodedBody | null>(() => {\n const e = selected.value\n if (!e) return null\n const b = decodePostData(e.raw.request.postData, 'auto')\n return b.kind === 'empty' ? null : b\n})\nconst resBody = computed<DecodedBody | null>(() => {\n const e = selected.value\n if (!e) return null\n const b = decodeContent(e.raw.response.content, 'auto')\n return b.kind === 'empty' ? null : b\n})\n\n// General info rows for the selected entry.\nconst generalRows = computed<HarNV[]>(() => {\n const e = selected.value\n if (!e) return []\n const r = e.raw\n const rows: HarNV[] = [\n { name: '请求 URL', value: e.url },\n { name: '方法', value: e.method },\n { name: '状态', value: e.status ? `${e.status} ${e.statusText}`.trim() : '(无响应)' },\n { name: '协议', value: r.response.httpVersion || r.request.httpVersion || '' },\n { name: '远端 IP', value: e.serverIP },\n { name: '响应类型', value: e.mime || '' },\n { name: '响应大小', value: fmtSize(e.size) },\n { name: '请求体大小', value: e.reqSize ? fmtSize(e.reqSize) : '' },\n { name: '耗时', value: fmtMs(e.time) },\n { name: '开始时间', value: e.startedISO },\n ]\n if (r.response.redirectURL) rows.push({ name: '重定向', value: r.response.redirectURL })\n return rows.filter((row) => row.value)\n})\n\n/** Render a decoded body to HTML for the <pre>: pretty-print JSON, syntax-highlight when we have a language. */\nfunction bodyHtml(body: DecodedBody): string {\n const { text, pretty } = prettyBody(body.text, body.mime)\n const lang = pretty ? 'json' : bodyLangFor(body.mime)\n return lang ? highlightCode(text, lang) : escapeHtml(text)\n}\nfunction bodyLangFor(mime: string): string {\n const m = mime.toLowerCase()\n if (m.includes('javascript') || m.includes('ecmascript')) return 'javascript'\n if (m === 'text/css') return 'css'\n if (m === 'text/html' || m.includes('xhtml')) return 'xml'\n if (m.includes('json')) return 'json'\n if (m.includes('svg') || m.includes('xml')) return 'xml'\n return ''\n}\nfunction escapeHtml(s: string): string {\n return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')\n}\n\n/* ===== copy helpers ===== */\n\nconst copiedKey = ref('')\nlet copyTimer: ReturnType<typeof setTimeout> | undefined\nasync function copyText(text: string, key: string): Promise<void> {\n try {\n await navigator.clipboard?.writeText(text)\n } catch {\n /* clipboard unavailable — ignore */\n }\n copiedKey.value = key\n clearTimeout(copyTimer)\n copyTimer = setTimeout(() => (copiedKey.value = ''), 1400)\n}\nfunction copyCurl(): void {\n if (selected.value) void copyText(toCurl(selected.value), 'curl')\n}\nonBeforeUnmount(() => clearTimeout(copyTimer))\n</script>\n\n<template>\n <div class=\"har\">\n <p v-if=\"status === 'loading'\" class=\"har__msg\">正在解析 HAR…</p>\n\n <div v-else-if=\"status === 'gate'\" class=\"har__msg har__gate\">\n <p>该 HAR 较大({{ formatBytes(props.size) }}),将在浏览器内解析。是否继续?</p>\n <button type=\"button\" class=\"har__btn\" @click=\"doLoad\">解析并预览</button>\n </div>\n\n <div v-else-if=\"status === 'error'\" class=\"har__msg har__msg--error\">\n <p>解析失败:{{ errorMsg }}</p>\n <button type=\"button\" class=\"har__btn\" @click=\"doLoad\">重试</button>\n </div>\n\n <template v-else-if=\"status === 'ready' && doc\">\n <!-- toolbar: filter + category chips + count -->\n <div class=\"har__bar\">\n <input\n v-model=\"query\"\n class=\"har__filter\"\n type=\"text\"\n placeholder=\"筛选 URL / 方法 / 状态…\"\n />\n <div class=\"har__cats\">\n <button\n type=\"button\"\n class=\"har__cat\"\n :class=\"{ 'har__cat--on': catFilter === 'all' }\"\n @click=\"catFilter = 'all'\"\n >\n 全部 {{ doc.entries.length }}\n </button>\n <button\n v-for=\"c in presentCategories\"\n :key=\"c\"\n type=\"button\"\n class=\"har__cat\"\n :class=\"[`har__cat--${c}`, { 'har__cat--on': catFilter === c }]\"\n @click=\"catFilter = c\"\n >\n {{ CATEGORY_LABELS[c] }}\n </button>\n </div>\n </div>\n\n <!-- master: request list -->\n <div class=\"har__list\" :class=\"{ 'har__list--split': selected }\">\n <p v-if=\"!filtered.length\" class=\"har__empty\">没有匹配的请求。</p>\n <button\n v-for=\"e in filtered\"\n :key=\"e.index\"\n type=\"button\"\n class=\"har__row\"\n :class=\"{ 'har__row--on': selected?.index === e.index }\"\n :title=\"e.url\"\n @click=\"pick(e)\"\n >\n <span class=\"har__method\" :class=\"`m-${e.method.toLowerCase()}`\">{{ e.method }}</span>\n <span class=\"har__status\" :class=\"statusClass(e.status)\">{{ e.status || '—' }}</span>\n <span class=\"har__name\">\n <span class=\"har__name-main\">{{ entryName(e) }}</span>\n <span class=\"har__name-sub\">{{ e.host }}</span>\n </span>\n <span class=\"har__type\">{{ e.type }}</span>\n <span class=\"har__size\">{{ fmtSize(e.size) }}</span>\n <span class=\"har__time\">{{ fmtMs(e.time) }}</span>\n </button>\n </div>\n\n <!-- detail: tabs + content -->\n <section v-if=\"selected\" class=\"har__detail\">\n <div class=\"har__tabs\">\n <button\n type=\"button\"\n class=\"har__tab\"\n :class=\"{ 'har__tab--on': tab === 'general' }\"\n @click=\"tab = 'general'\"\n >\n 概览\n </button>\n <button\n type=\"button\"\n class=\"har__tab\"\n :class=\"{ 'har__tab--on': tab === 'reqHeaders' }\"\n @click=\"tab = 'reqHeaders'\"\n >\n 请求头 <span class=\"har__tab-n\">{{ reqHeaders.length }}</span>\n </button>\n <button\n type=\"button\"\n class=\"har__tab\"\n :class=\"{ 'har__tab--on': tab === 'resHeaders' }\"\n @click=\"tab = 'resHeaders'\"\n >\n 响应头 <span class=\"har__tab-n\">{{ resHeaders.length }}</span>\n </button>\n <button\n v-if=\"queryParams.length\"\n type=\"button\"\n class=\"har__tab\"\n :class=\"{ 'har__tab--on': tab === 'query' }\"\n @click=\"tab = 'query'\"\n >\n 查询 <span class=\"har__tab-n\">{{ queryParams.length }}</span>\n </button>\n <button\n v-if=\"hasCookies\"\n type=\"button\"\n class=\"har__tab\"\n :class=\"{ 'har__tab--on': tab === 'cookies' }\"\n @click=\"tab = 'cookies'\"\n >\n Cookie\n </button>\n <button\n v-if=\"reqBody\"\n type=\"button\"\n class=\"har__tab\"\n :class=\"{ 'har__tab--on': tab === 'reqBody' }\"\n @click=\"tab = 'reqBody'\"\n >\n 请求体\n </button>\n <button\n v-if=\"resBody\"\n type=\"button\"\n class=\"har__tab\"\n :class=\"{ 'har__tab--on': tab === 'resBody' }\"\n @click=\"tab = 'resBody'\"\n >\n 响应体\n </button>\n </div>\n\n <div class=\"har__pane\">\n <!-- general -->\n <template v-if=\"tab === 'general'\">\n <dl class=\"har__kv\">\n <div v-for=\"row in generalRows\" :key=\"row.name\" class=\"har__kv-row\">\n <dt>{{ row.name }}</dt>\n <dd>{{ row.value }}</dd>\n </div>\n </dl>\n <button type=\"button\" class=\"har__btn har__btn--sm\" @click=\"copyCurl\">\n {{ copiedKey === 'curl' ? '已复制' : '复制为 cURL' }}\n </button>\n </template>\n\n <!-- request headers -->\n <dl v-else-if=\"tab === 'reqHeaders'\" class=\"har__kv har__kv--mono\">\n <div v-for=\"(h, i) in reqHeaders\" :key=\"i\" class=\"har__kv-row\">\n <dt>{{ h.name }}</dt>\n <dd>{{ h.value }}</dd>\n </div>\n </dl>\n\n <!-- response headers -->\n <dl v-else-if=\"tab === 'resHeaders'\" class=\"har__kv har__kv--mono\">\n <div v-for=\"(h, i) in resHeaders\" :key=\"i\" class=\"har__kv-row\">\n <dt>{{ h.name }}</dt>\n <dd>{{ h.value }}</dd>\n </div>\n </dl>\n\n <!-- query string -->\n <dl v-else-if=\"tab === 'query'\" class=\"har__kv har__kv--mono\">\n <div v-for=\"(p, i) in queryParams\" :key=\"i\" class=\"har__kv-row\">\n <dt>{{ p.name }}</dt>\n <dd>{{ p.value }}</dd>\n </div>\n </dl>\n\n <!-- cookies -->\n <template v-else-if=\"tab === 'cookies'\">\n <template v-if=\"cookies.req.length\">\n <h4 class=\"har__sub\">请求 Cookie</h4>\n <dl class=\"har__kv har__kv--mono\">\n <div v-for=\"(c, i) in cookies.req\" :key=\"i\" class=\"har__kv-row\">\n <dt>{{ c.name }}</dt>\n <dd>{{ c.value }}</dd>\n </div>\n </dl>\n </template>\n <template v-if=\"cookies.res.length\">\n <h4 class=\"har__sub\">响应 Set-Cookie</h4>\n <dl class=\"har__kv har__kv--mono\">\n <div v-for=\"(c, i) in cookies.res\" :key=\"i\" class=\"har__kv-row\">\n <dt>{{ c.name }}</dt>\n <dd>{{ c.value }}</dd>\n </div>\n </dl>\n </template>\n </template>\n\n <!-- request body -->\n <template v-else-if=\"tab === 'reqBody' && reqBody\">\n <dl v-if=\"reqBody.kind === 'form' && reqBody.form\" class=\"har__kv har__kv--mono\">\n <div v-for=\"(p, i) in reqBody.form\" :key=\"i\" class=\"har__kv-row\">\n <dt>{{ p.name }}</dt>\n <dd>{{ p.value }}</dd>\n </div>\n </dl>\n <template v-else>\n <div class=\"har__bodybar\">\n <span class=\"har__bodymime\"\n >{{ reqBody.mime || '文本' }} · {{ fmtSize(reqBody.size) }}</span\n >\n <button\n type=\"button\"\n class=\"har__btn har__btn--sm\"\n @click=\"copyText(reqBody.text, 'reqBody')\"\n >\n {{ copiedKey === 'reqBody' ? '已复制' : '复制' }}\n </button>\n </div>\n <pre class=\"har__body\"><code class=\"hljs\" v-html=\"bodyHtml(reqBody)\" /></pre>\n </template>\n </template>\n\n <!-- response body -->\n <template v-else-if=\"tab === 'resBody' && resBody\">\n <div class=\"har__bodybar\">\n <span class=\"har__bodymime\">\n {{ resBody.mime || '文本' }} · {{ fmtSize(resBody.size) }}\n <span v-if=\"resBody.wasBase64\" class=\"har__b64\">base64 已解码</span>\n </span>\n <button\n v-if=\"!resBody.isImage\"\n type=\"button\"\n class=\"har__btn har__btn--sm\"\n @click=\"copyText(resBody.text, 'resBody')\"\n >\n {{ copiedKey === 'resBody' ? '已复制' : '复制' }}\n </button>\n </div>\n <div v-if=\"resBody.isImage\" class=\"har__img\">\n <img :src=\"resBody.dataUri\" :alt=\"selected.url\" />\n </div>\n <pre v-else class=\"har__body\"><code class=\"hljs\" v-html=\"bodyHtml(resBody)\" /></pre>\n </template>\n </div>\n </section>\n </template>\n </div>\n</template>\n\n<style scoped>\n.har {\n flex: 1 1 auto;\n min-height: 0;\n display: flex;\n flex-direction: column;\n background: var(--surface);\n}\n\n.har__msg {\n padding: 20px 16px;\n color: var(--muted);\n font-size: 13px;\n}\n.har__msg--error {\n display: flex;\n flex-direction: column;\n align-items: flex-start;\n gap: 10px;\n color: var(--danger);\n}\n.har__gate {\n display: flex;\n flex-direction: column;\n align-items: flex-start;\n gap: 12px;\n}\n.har__btn {\n font-family: inherit;\n font-size: 12.5px;\n color: var(--text);\n background: var(--surface);\n border: 1px solid var(--border-2);\n border-radius: 8px;\n padding: 5px 12px;\n cursor: pointer;\n}\n.har__btn:hover {\n border-color: var(--accent);\n color: var(--accent);\n}\n.har__btn--sm {\n padding: 3px 10px;\n font-size: 11.5px;\n}\n\n/* toolbar */\n.har__bar {\n flex-shrink: 0;\n display: flex;\n flex-direction: column;\n gap: 8px;\n padding: 10px 12px;\n border-bottom: 1px solid var(--border);\n}\n.har__filter {\n width: 100%;\n padding: 6px 10px;\n font-family: inherit;\n font-size: 12.5px;\n color: var(--text);\n background: var(--surface-2);\n border: 1px solid var(--border);\n border-radius: 7px;\n}\n.har__filter:focus {\n outline: none;\n border-color: var(--accent);\n}\n.har__cats {\n display: flex;\n flex-wrap: wrap;\n gap: 5px;\n}\n.har__cat {\n font-family: inherit;\n font-size: 11px;\n color: var(--muted);\n background: var(--surface-2);\n border: 1px solid var(--border);\n border-radius: 999px;\n padding: 2px 9px;\n cursor: pointer;\n transition:\n border-color 0.15s,\n color 0.15s;\n}\n.har__cat:hover {\n border-color: var(--accent);\n color: var(--accent);\n}\n.har__cat--on {\n color: #fff;\n background: var(--accent);\n border-color: var(--accent);\n}\n\n/* master list */\n.har__list {\n flex: 1 1 auto;\n min-height: 0;\n overflow-y: auto;\n overscroll-behavior: contain;\n}\n.har__list--split {\n flex: 0 0 42%;\n border-bottom: 1px solid var(--border);\n}\n.har__empty {\n padding: 18px 14px;\n font-size: 12.5px;\n color: var(--faint);\n}\n.har__row {\n display: flex;\n align-items: center;\n gap: 8px;\n width: 100%;\n padding: 6px 12px;\n background: transparent;\n border: none;\n border-bottom: 1px solid var(--border);\n font-family: inherit;\n text-align: left;\n cursor: pointer;\n}\n.har__row:hover {\n background: var(--surface-2);\n}\n.har__row--on {\n background: color-mix(in srgb, var(--accent) 12%, transparent);\n}\n.har__method {\n flex-shrink: 0;\n width: 42px;\n font-family: var(--font-mono);\n font-size: 10.5px;\n font-weight: 700;\n letter-spacing: 0.02em;\n color: var(--muted);\n}\n.har__method.m-get {\n color: var(--hl-name);\n}\n.har__method.m-post {\n color: var(--hl-attr);\n}\n.har__method.m-put,\n.har__method.m-patch {\n color: var(--ansi-yellow);\n}\n.har__method.m-delete {\n color: var(--danger);\n}\n.har__status {\n flex-shrink: 0;\n width: 30px;\n font-family: var(--font-mono);\n font-size: 11px;\n font-weight: 600;\n}\n.har__name {\n flex: 1 1 auto;\n min-width: 0;\n display: flex;\n flex-direction: column;\n}\n.har__name-main {\n font-size: 12px;\n color: var(--text-strong);\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n}\n.har__name-sub {\n font-size: 10.5px;\n color: var(--faint);\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n}\n.har__type {\n flex-shrink: 0;\n font-size: 10.5px;\n color: var(--muted);\n}\n.har__size,\n.har__time {\n flex-shrink: 0;\n width: 52px;\n text-align: right;\n font-family: var(--font-mono);\n font-size: 10.5px;\n color: var(--faint);\n}\n\n/* status colors (shared) */\n.s-2xx {\n color: var(--hl-name);\n}\n.s-3xx {\n color: var(--hl-attr);\n}\n.s-4xx {\n color: var(--ansi-yellow);\n}\n.s-5xx {\n color: var(--danger);\n}\n.s-pending {\n color: var(--faint);\n}\n\n/* detail */\n.har__detail {\n flex: 1 1 58%;\n min-height: 0;\n display: flex;\n flex-direction: column;\n}\n.har__tabs {\n flex-shrink: 0;\n display: flex;\n gap: 2px;\n padding: 6px 10px 0;\n overflow-x: auto;\n border-bottom: 1px solid var(--border);\n white-space: nowrap;\n}\n.har__tab {\n flex-shrink: 0;\n font-family: inherit;\n font-size: 12px;\n color: var(--muted);\n background: transparent;\n border: none;\n border-bottom: 2px solid transparent;\n padding: 6px 9px;\n cursor: pointer;\n}\n.har__tab:hover {\n color: var(--text);\n}\n.har__tab--on {\n color: var(--accent);\n border-bottom-color: var(--accent);\n}\n.har__tab-n {\n font-size: 10.5px;\n color: var(--faint);\n}\n.har__pane {\n flex: 1 1 auto;\n min-height: 0;\n overflow-y: auto;\n overscroll-behavior: contain;\n padding: 12px 14px;\n}\n\n.har__kv {\n margin: 0 0 12px;\n display: flex;\n flex-direction: column;\n}\n.har__kv-row {\n display: flex;\n gap: 10px;\n padding: 4px 0;\n border-bottom: 1px solid var(--border);\n font-size: 12px;\n line-height: 1.5;\n}\n.har__kv-row dt {\n flex-shrink: 0;\n width: 38%;\n max-width: 180px;\n color: var(--muted);\n word-break: break-word;\n}\n.har__kv-row dd {\n flex: 1 1 auto;\n min-width: 0;\n margin: 0;\n color: var(--text);\n word-break: break-word;\n}\n.har__kv--mono dt,\n.har__kv--mono dd {\n font-family: var(--font-mono);\n font-size: 11.5px;\n}\n.har__sub {\n margin: 4px 0 8px;\n font-size: 12px;\n font-weight: 600;\n color: var(--text-strong);\n}\n\n.har__bodybar {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 10px;\n margin-bottom: 8px;\n}\n.har__bodymime {\n font-size: 11px;\n color: var(--faint);\n}\n.har__b64 {\n margin-left: 6px;\n padding: 1px 6px;\n border-radius: 4px;\n background: var(--surface-2);\n color: var(--muted);\n}\n.har__body {\n margin: 0;\n padding: 10px 12px;\n background: var(--code-bg);\n border: 1px solid var(--code-border);\n border-radius: 8px;\n font-family: var(--font-mono);\n font-size: 11.5px;\n line-height: 1.55;\n color: var(--text);\n white-space: pre-wrap;\n word-break: break-word;\n overflow-x: auto;\n}\n.har__body code {\n font-family: inherit;\n background: transparent;\n border: none;\n padding: 0;\n}\n.har__img {\n display: flex;\n justify-content: center;\n padding: 12px;\n background: var(--surface-2);\n border-radius: 8px;\n}\n.har__img img {\n max-width: 100%;\n height: auto;\n object-fit: contain;\n}\n</style>\n"],"mappings":""}
|