echoctl 0.1.0 → 0.1.2
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/docs/.vitepress/config.mts +38 -0
- package/docs/.vitepress/echo-sidebar.mts +52 -0
- package/docs/.vitepress/theme/Layout.vue +28 -0
- package/docs/.vitepress/theme/components/EchoArticleActions.vue +235 -0
- package/docs/.vitepress/theme/components/EchoChatBubbles.vue +153 -0
- package/docs/.vitepress/theme/components/EchoClaudeImportBanner.vue +335 -0
- package/docs/.vitepress/theme/components/EchoCommentChain.vue +79 -0
- package/docs/.vitepress/theme/components/EchoCommentNode.vue +126 -0
- package/docs/.vitepress/theme/components/EchoCommentReplies.vue +95 -0
- package/docs/.vitepress/theme/components/EchoGlobalControls.vue +91 -0
- package/docs/.vitepress/theme/components/EchoLegacyRecovery.vue +178 -0
- package/docs/.vitepress/theme/components/EchoLiveSession.vue +82 -0
- package/docs/.vitepress/theme/components/EchoProjectTabs.vue +132 -0
- package/docs/.vitepress/theme/components/EchoSearchLanding.vue +215 -0
- package/docs/.vitepress/theme/components/EchoSelectionComment.vue +129 -0
- package/docs/.vitepress/theme/components/EchoTagsPage.vue +301 -0
- package/docs/.vitepress/theme/custom.css +964 -0
- package/docs/.vitepress/theme/index.ts +36 -0
- package/docs/.vitepress/theme/lib/echo-api.ts +298 -0
- package/docs/.vitepress/theme/lib/echo-heartbeat.ts +34 -0
- package/docs/.vitepress/theme/lib/useEchoStatus.ts +59 -0
- package/docs/.vitepress/theme/lib/useProjectFilter.ts +42 -0
- package/docs/index.md +89 -0
- package/package.json +13 -4
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="echo-global-controls">
|
|
3
|
+
<!-- [PROJECT_FILTER_DISABLED] 功能失灵,后期修复后取消注释
|
|
4
|
+
<select
|
|
5
|
+
class="echo-global-select"
|
|
6
|
+
:value="projectFilter.selectedProject.value"
|
|
7
|
+
@change="projectFilter.select(($event.target as HTMLSelectElement).value)"
|
|
8
|
+
>
|
|
9
|
+
<option value="__all__">全部项目</option>
|
|
10
|
+
<option v-for="p in projectFilter.allProjects.value" :key="p.id" :value="p.id">
|
|
11
|
+
{{ p.name }}
|
|
12
|
+
</option>
|
|
13
|
+
</select>
|
|
14
|
+
-->
|
|
15
|
+
<button
|
|
16
|
+
class="echo-global-btn"
|
|
17
|
+
:class="status?.captureEnabled ? 'echo-btn-on' : 'echo-btn-off'"
|
|
18
|
+
:disabled="state !== 'ready'"
|
|
19
|
+
:title="state === 'ready' ? '切换 Echo 收集状态' : 'Echo API 未运行'"
|
|
20
|
+
@click="toggleCapture"
|
|
21
|
+
>
|
|
22
|
+
收集 {{ status?.captureEnabled ? '开' : '关' }}
|
|
23
|
+
</button>
|
|
24
|
+
<button
|
|
25
|
+
class="echo-global-btn"
|
|
26
|
+
:disabled="state !== 'ready'"
|
|
27
|
+
title="复制 Echo MCP 配置"
|
|
28
|
+
@click="showMcp"
|
|
29
|
+
>
|
|
30
|
+
MCP
|
|
31
|
+
</button>
|
|
32
|
+
</div>
|
|
33
|
+
|
|
34
|
+
<Teleport to="body">
|
|
35
|
+
<div v-if="mcpVisible" class="echo-modal" @click.self="mcpVisible = false">
|
|
36
|
+
<div class="echo-modal-content">
|
|
37
|
+
<h3>MCP 配置</h3>
|
|
38
|
+
<p>这是 AI 访问 Echo 的桥。将此 JSON 添加到 Claude/Codex MCP 配置中:</p>
|
|
39
|
+
<pre>{{ mcpConfigText }}</pre>
|
|
40
|
+
<div class="echo-modal-btns">
|
|
41
|
+
<button class="echo-btn" @click="copyMcp">复制</button>
|
|
42
|
+
<button class="echo-btn" @click="mcpVisible = false">关闭</button>
|
|
43
|
+
</div>
|
|
44
|
+
</div>
|
|
45
|
+
</div>
|
|
46
|
+
</Teleport>
|
|
47
|
+
</template>
|
|
48
|
+
|
|
49
|
+
<script setup lang="ts">
|
|
50
|
+
import { onMounted, ref } from 'vue'
|
|
51
|
+
import { useEchoStatus } from '../lib/useEchoStatus'
|
|
52
|
+
import { getMcpConfig, setCapture } from '../lib/echo-api'
|
|
53
|
+
// [PROJECT_FILTER_DISABLED] 后期恢复时取消注释
|
|
54
|
+
// import { useProjectFilter } from '../lib/useProjectFilter'
|
|
55
|
+
|
|
56
|
+
const articleId = ref<string | undefined>(undefined)
|
|
57
|
+
const { state, status } = useEchoStatus(articleId)
|
|
58
|
+
// const projectFilter = useProjectFilter()
|
|
59
|
+
|
|
60
|
+
onMounted(() => {
|
|
61
|
+
// projectFilter.load()
|
|
62
|
+
// projectFilter.restore()
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
const mcpVisible = ref(false)
|
|
66
|
+
const mcpConfigText = ref('')
|
|
67
|
+
|
|
68
|
+
async function toggleCapture() {
|
|
69
|
+
if (!status.value) return
|
|
70
|
+
try {
|
|
71
|
+
const r = await setCapture(!status.value.captureEnabled)
|
|
72
|
+
status.value = { ...status.value, captureEnabled: r.enabled }
|
|
73
|
+
} catch (_) {}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function showMcp() {
|
|
77
|
+
try {
|
|
78
|
+
const cfg = await getMcpConfig()
|
|
79
|
+
mcpConfigText.value = JSON.stringify({
|
|
80
|
+
mcpServers: { echo: { command: cfg.canonical.command, args: cfg.canonical.args } }
|
|
81
|
+
}, null, 2)
|
|
82
|
+
mcpVisible.value = true
|
|
83
|
+
} catch (_) {}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function copyMcp() {
|
|
87
|
+
try {
|
|
88
|
+
await navigator.clipboard.writeText(mcpConfigText.value)
|
|
89
|
+
} catch (_) {}
|
|
90
|
+
}
|
|
91
|
+
</script>
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div v-if="visible" class="echo-legacy-overlay" @click.self="dismiss">
|
|
3
|
+
<div class="echo-legacy-dialog">
|
|
4
|
+
<h2>Echo 发现遗留会话 / Echo found legacy sessions</h2>
|
|
5
|
+
<p class="echo-legacy-desc">
|
|
6
|
+
Echo 发现有一些会话记录之前进入了 legacy 区。它们看起来属于当前项目。<br/>
|
|
7
|
+
Echo found chat records in the legacy area. They appear to belong to the current project.
|
|
8
|
+
</p>
|
|
9
|
+
<p class="echo-legacy-question">
|
|
10
|
+
是否迁移到当前项目?迁移后,它们会出现在当前项目的实时会话或文章列表中。<br/>
|
|
11
|
+
Move them into this project? After migration, they will appear in this project's live sessions or articles.
|
|
12
|
+
</p>
|
|
13
|
+
|
|
14
|
+
<div v-if="mode === 'list' && candidates.length > 0" class="echo-legacy-list">
|
|
15
|
+
<div v-for="c in candidates" :key="c.sessionId" class="echo-legacy-item">
|
|
16
|
+
<span class="echo-legacy-session">{{ c.fileName }}</span>
|
|
17
|
+
<span class="echo-legacy-meta">{{ c.turnCount }} turns, {{ c.confidence }} confidence</span>
|
|
18
|
+
</div>
|
|
19
|
+
</div>
|
|
20
|
+
|
|
21
|
+
<div v-if="error" class="echo-legacy-error">{{ error }}</div>
|
|
22
|
+
<div v-if="success" class="echo-legacy-success">{{ success }}</div>
|
|
23
|
+
|
|
24
|
+
<div class="echo-legacy-buttons">
|
|
25
|
+
<button class="echo-legacy-btn later" @click="dismiss" :disabled="migrating">
|
|
26
|
+
稍后处理 / Later
|
|
27
|
+
</button>
|
|
28
|
+
<button v-if="mode === 'prompt'" class="echo-legacy-btn review" @click="mode = 'list'">
|
|
29
|
+
查看候选 / Review
|
|
30
|
+
</button>
|
|
31
|
+
<button class="echo-legacy-btn migrate" @click="migrate" :disabled="migrating">
|
|
32
|
+
{{ migrating ? '迁移中... / Migrating...' : '迁移到当前项目 / Move to this project' }}
|
|
33
|
+
</button>
|
|
34
|
+
</div>
|
|
35
|
+
</div>
|
|
36
|
+
</div>
|
|
37
|
+
</template>
|
|
38
|
+
|
|
39
|
+
<script setup lang="ts">
|
|
40
|
+
import { ref, computed, onMounted } from 'vue'
|
|
41
|
+
import { useData } from 'vitepress'
|
|
42
|
+
import { getLegacyCandidates, migrateLegacyCandidates, getStatus } from '../lib/echo-api'
|
|
43
|
+
|
|
44
|
+
const props = defineProps<{ projectId?: string }>()
|
|
45
|
+
|
|
46
|
+
const { frontmatter } = useData()
|
|
47
|
+
const pageProjectId = computed(() => (frontmatter.value as any)?.echo?.projectId as string | null | undefined)
|
|
48
|
+
|
|
49
|
+
const visible = ref(false)
|
|
50
|
+
const mode = ref<'prompt' | 'list'>('prompt')
|
|
51
|
+
const candidates = ref<any[]>([])
|
|
52
|
+
const migrating = ref(false)
|
|
53
|
+
const error = ref('')
|
|
54
|
+
const success = ref('')
|
|
55
|
+
const resolvedProjectId = ref<string | null>(null)
|
|
56
|
+
|
|
57
|
+
onMounted(async () => {
|
|
58
|
+
let pid = pageProjectId.value || props.projectId || null
|
|
59
|
+
if (!pid) {
|
|
60
|
+
try { const s = await getStatus(); pid = s.projectId } catch (_) {}
|
|
61
|
+
}
|
|
62
|
+
if (!pid) return
|
|
63
|
+
resolvedProjectId.value = pid
|
|
64
|
+
try {
|
|
65
|
+
const result = await getLegacyCandidates(pid)
|
|
66
|
+
if (result.candidates.length > 0) {
|
|
67
|
+
candidates.value = result.candidates
|
|
68
|
+
visible.value = true
|
|
69
|
+
}
|
|
70
|
+
} catch (_) {}
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
function dismiss() {
|
|
74
|
+
visible.value = false
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function migrate() {
|
|
78
|
+
migrating.value = true
|
|
79
|
+
error.value = ''
|
|
80
|
+
try {
|
|
81
|
+
const ids = candidates.value.map((c) => c.sessionId)
|
|
82
|
+
const result = await migrateLegacyCandidates(resolvedProjectId.value!, ids)
|
|
83
|
+
if (result.ok) {
|
|
84
|
+
success.value = `已迁移 ${result.migrated} 个会话。页面即将刷新... / Migrated ${result.migrated} sessions. Refreshing...`
|
|
85
|
+
setTimeout(() => location.reload(), 1500)
|
|
86
|
+
}
|
|
87
|
+
} catch (err: any) {
|
|
88
|
+
error.value = err.message || '迁移失败 / Migration failed'
|
|
89
|
+
} finally {
|
|
90
|
+
migrating.value = false
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
</script>
|
|
94
|
+
|
|
95
|
+
<style scoped>
|
|
96
|
+
.echo-legacy-overlay {
|
|
97
|
+
position: fixed;
|
|
98
|
+
inset: 0;
|
|
99
|
+
background: rgba(0, 0, 0, 0.5);
|
|
100
|
+
display: flex;
|
|
101
|
+
align-items: center;
|
|
102
|
+
justify-content: center;
|
|
103
|
+
z-index: 1000;
|
|
104
|
+
}
|
|
105
|
+
.echo-legacy-dialog {
|
|
106
|
+
background: var(--vp-c-bg);
|
|
107
|
+
border: 1px solid var(--vp-c-divider);
|
|
108
|
+
border-radius: 12px;
|
|
109
|
+
padding: 24px;
|
|
110
|
+
max-width: 520px;
|
|
111
|
+
width: 90%;
|
|
112
|
+
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
|
|
113
|
+
}
|
|
114
|
+
.echo-legacy-dialog h2 {
|
|
115
|
+
margin: 0 0 12px;
|
|
116
|
+
font-size: 18px;
|
|
117
|
+
}
|
|
118
|
+
.echo-legacy-desc, .echo-legacy-question {
|
|
119
|
+
margin: 0 0 12px;
|
|
120
|
+
color: var(--vp-c-text-2);
|
|
121
|
+
font-size: 14px;
|
|
122
|
+
line-height: 1.6;
|
|
123
|
+
}
|
|
124
|
+
.echo-legacy-list {
|
|
125
|
+
max-height: 200px;
|
|
126
|
+
overflow-y: auto;
|
|
127
|
+
margin-bottom: 12px;
|
|
128
|
+
}
|
|
129
|
+
.echo-legacy-item {
|
|
130
|
+
display: flex;
|
|
131
|
+
justify-content: space-between;
|
|
132
|
+
padding: 6px 0;
|
|
133
|
+
border-bottom: 1px solid var(--vp-c-divider);
|
|
134
|
+
font-size: 13px;
|
|
135
|
+
}
|
|
136
|
+
.echo-legacy-session {
|
|
137
|
+
font-weight: 600;
|
|
138
|
+
}
|
|
139
|
+
.echo-legacy-meta {
|
|
140
|
+
color: var(--vp-c-text-3);
|
|
141
|
+
}
|
|
142
|
+
.echo-legacy-error {
|
|
143
|
+
color: var(--vp-c-danger);
|
|
144
|
+
margin-bottom: 12px;
|
|
145
|
+
font-size: 14px;
|
|
146
|
+
}
|
|
147
|
+
.echo-legacy-success {
|
|
148
|
+
color: var(--vp-c-brand);
|
|
149
|
+
margin-bottom: 12px;
|
|
150
|
+
font-size: 14px;
|
|
151
|
+
}
|
|
152
|
+
.echo-legacy-buttons {
|
|
153
|
+
display: flex;
|
|
154
|
+
gap: 8px;
|
|
155
|
+
justify-content: flex-end;
|
|
156
|
+
}
|
|
157
|
+
.echo-legacy-btn {
|
|
158
|
+
padding: 8px 16px;
|
|
159
|
+
border-radius: 6px;
|
|
160
|
+
border: 1px solid var(--vp-c-divider);
|
|
161
|
+
background: var(--vp-c-bg);
|
|
162
|
+
color: var(--vp-c-text-1);
|
|
163
|
+
cursor: pointer;
|
|
164
|
+
font-size: 14px;
|
|
165
|
+
}
|
|
166
|
+
.echo-legacy-btn.migrate {
|
|
167
|
+
background: var(--vp-c-brand);
|
|
168
|
+
color: #fff;
|
|
169
|
+
border-color: var(--vp-c-brand);
|
|
170
|
+
}
|
|
171
|
+
.echo-legacy-btn.migrate:hover {
|
|
172
|
+
background: var(--vp-c-brand-dark);
|
|
173
|
+
}
|
|
174
|
+
.echo-legacy-btn:disabled {
|
|
175
|
+
opacity: 0.5;
|
|
176
|
+
cursor: not-allowed;
|
|
177
|
+
}
|
|
178
|
+
</style>
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="echo-live-actions" v-if="!isPublished">
|
|
3
|
+
<button class="echo-publish-btn" @click="publish" :disabled="publishing">
|
|
4
|
+
{{ publishing ? '发布中...' : '发布为正式文章' }}
|
|
5
|
+
</button>
|
|
6
|
+
<span v-if="error" class="echo-publish-error">{{ error }}</span>
|
|
7
|
+
<span v-if="ok" class="echo-publish-ok">{{ ok }}</span>
|
|
8
|
+
</div>
|
|
9
|
+
<div class="echo-live-actions" v-else>
|
|
10
|
+
<a :href="`/articles/generated/${publishedSlug}`" class="echo-published-link">查看已发布文章</a>
|
|
11
|
+
</div>
|
|
12
|
+
</template>
|
|
13
|
+
|
|
14
|
+
<script setup lang="ts">
|
|
15
|
+
import { ref, computed, onBeforeUnmount, onMounted } from 'vue'
|
|
16
|
+
import { postPublish, getLiveSessionState, EchoApiError } from '../lib/echo-api'
|
|
17
|
+
import { subscribeEchoHeartbeat } from '../lib/echo-heartbeat'
|
|
18
|
+
|
|
19
|
+
const props = defineProps<{
|
|
20
|
+
projectId: string
|
|
21
|
+
sessionId: string
|
|
22
|
+
published: string
|
|
23
|
+
publishedSlug: string
|
|
24
|
+
}>()
|
|
25
|
+
|
|
26
|
+
const publishing = ref(false)
|
|
27
|
+
const error = ref("")
|
|
28
|
+
const ok = ref("")
|
|
29
|
+
const isPublished = computed(() => props.published === "true")
|
|
30
|
+
let unsubscribeHeartbeat: (() => void) | null = null
|
|
31
|
+
let lastHash: string | null = null
|
|
32
|
+
let checking = false
|
|
33
|
+
|
|
34
|
+
onMounted(() => {
|
|
35
|
+
const livePath = window.location.pathname
|
|
36
|
+
const checkForChanges = async () => {
|
|
37
|
+
if (checking) return
|
|
38
|
+
if (document.visibilityState === "hidden") return
|
|
39
|
+
checking = true
|
|
40
|
+
try {
|
|
41
|
+
const state = await getLiveSessionState(props.projectId || null, props.sessionId)
|
|
42
|
+
if (!state.exists || !state.hash) return
|
|
43
|
+
if (lastHash && lastHash !== state.hash && window.location.pathname === livePath) {
|
|
44
|
+
window.location.reload()
|
|
45
|
+
return
|
|
46
|
+
}
|
|
47
|
+
lastHash = state.hash
|
|
48
|
+
} catch (_) {
|
|
49
|
+
// Live pages must remain readable when the local API is stopped.
|
|
50
|
+
} finally {
|
|
51
|
+
checking = false
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
void checkForChanges()
|
|
56
|
+
unsubscribeHeartbeat = subscribeEchoHeartbeat(checkForChanges)
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
onBeforeUnmount(() => {
|
|
60
|
+
if (unsubscribeHeartbeat) unsubscribeHeartbeat()
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
async function publish() {
|
|
64
|
+
publishing.value = true
|
|
65
|
+
error.value = ""
|
|
66
|
+
ok.value = ""
|
|
67
|
+
try {
|
|
68
|
+
const data = await postPublish({
|
|
69
|
+
projectId: props.projectId || null,
|
|
70
|
+
sessionId: props.sessionId,
|
|
71
|
+
})
|
|
72
|
+
ok.value = '发布成功!页面即将跳转...'
|
|
73
|
+
setTimeout(() => { window.location.href = `/articles/generated/${data.slug}` }, 1500)
|
|
74
|
+
} catch (e: any) {
|
|
75
|
+
error.value = e instanceof EchoApiError && e.status === 409
|
|
76
|
+
? '已经是最新快照'
|
|
77
|
+
: e.message || '网络错误'
|
|
78
|
+
} finally {
|
|
79
|
+
publishing.value = false
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
</script>
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="echo-project-tabs" aria-label="项目筛选">
|
|
3
|
+
<a
|
|
4
|
+
v-for="tab in tabs"
|
|
5
|
+
:key="tab.key"
|
|
6
|
+
:href="`#${encodeURIComponent(tab.anchor)}`"
|
|
7
|
+
class="echo-project-tab"
|
|
8
|
+
:class="{ 'echo-project-tab-active': tab.key === selectedKey }"
|
|
9
|
+
@click.prevent="select(tab.key)"
|
|
10
|
+
>
|
|
11
|
+
{{ tab.label }}<span>{{ tab.articles.length }}</span>
|
|
12
|
+
</a>
|
|
13
|
+
</div>
|
|
14
|
+
|
|
15
|
+
<div class="echo-article-grid">
|
|
16
|
+
<a
|
|
17
|
+
v-for="article in selectedArticles"
|
|
18
|
+
:key="article.href"
|
|
19
|
+
class="echo-article-card"
|
|
20
|
+
:href="article.href"
|
|
21
|
+
>
|
|
22
|
+
<strong>{{ article.title }}</strong>
|
|
23
|
+
<small>{{ article.updated || '-' }}</small>
|
|
24
|
+
<p>{{ article.summary || '无摘要' }}</p>
|
|
25
|
+
<div v-if="article.tags.length" class="echo-tags">
|
|
26
|
+
<span v-for="tag in article.tags" :key="tag">{{ tag }}</span>
|
|
27
|
+
</div>
|
|
28
|
+
</a>
|
|
29
|
+
</div>
|
|
30
|
+
</template>
|
|
31
|
+
|
|
32
|
+
<script setup lang="ts">
|
|
33
|
+
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
|
34
|
+
import { useProjectFilter } from '../lib/useProjectFilter'
|
|
35
|
+
|
|
36
|
+
type ProjectArticle = {
|
|
37
|
+
href: string
|
|
38
|
+
summary?: string
|
|
39
|
+
tags: string[]
|
|
40
|
+
title: string
|
|
41
|
+
updated?: string
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
type ProjectGroup = {
|
|
45
|
+
anchor: string
|
|
46
|
+
articles: ProjectArticle[]
|
|
47
|
+
key: string
|
|
48
|
+
label: string
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const props = defineProps<{ payload: string }>()
|
|
52
|
+
|
|
53
|
+
const projectFilter = useProjectFilter()
|
|
54
|
+
|
|
55
|
+
const projectGroups = computed<ProjectGroup[]>(() => {
|
|
56
|
+
try {
|
|
57
|
+
return JSON.parse(decodeURIComponent(props.payload)) as ProjectGroup[]
|
|
58
|
+
} catch {
|
|
59
|
+
return []
|
|
60
|
+
}
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
const allTab = computed<ProjectGroup>(() => ({
|
|
64
|
+
anchor: 'project-all',
|
|
65
|
+
articles: projectGroups.value.flatMap((group) => group.articles),
|
|
66
|
+
key: '__all__',
|
|
67
|
+
label: '全部',
|
|
68
|
+
}))
|
|
69
|
+
|
|
70
|
+
const emptyProjectTabs = computed<ProjectGroup[]>(() => {
|
|
71
|
+
const withArticles = new Set(projectGroups.value.map((group) => group.key))
|
|
72
|
+
return projectFilter.allProjects.value
|
|
73
|
+
.filter((project) => !withArticles.has(project.id))
|
|
74
|
+
.map((project) => ({
|
|
75
|
+
anchor: `project-${project.id}`,
|
|
76
|
+
articles: [],
|
|
77
|
+
key: project.id,
|
|
78
|
+
label: project.name,
|
|
79
|
+
}))
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
const tabs = computed(() => [allTab.value, ...projectGroups.value, ...emptyProjectTabs.value])
|
|
83
|
+
const selectedKey = ref('__all__')
|
|
84
|
+
|
|
85
|
+
const selectedArticles = computed(() => {
|
|
86
|
+
return tabs.value.find((tab) => tab.key === selectedKey.value)?.articles || []
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
function resolveTabKey(projectId: string): string {
|
|
90
|
+
if (projectId === '__all__') return '__all__'
|
|
91
|
+
const tab = tabs.value.find((t) => t.key === projectId)
|
|
92
|
+
return tab ? tab.key : '__all__'
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function syncFromHash() {
|
|
96
|
+
const hash = decodeURIComponent(window.location.hash.slice(1) || '')
|
|
97
|
+
const tab = tabs.value.find((item) => item.anchor === hash)
|
|
98
|
+
selectedKey.value = tab?.key || '__all__'
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function select(key: string) {
|
|
102
|
+
selectedKey.value = key
|
|
103
|
+
// Also update the global filter so nav dropdown syncs
|
|
104
|
+
projectFilter.select(key === '__all__' ? '__all__' : key)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function syncFromProjectFilter(projectId: string) {
|
|
108
|
+
selectedKey.value = resolveTabKey(projectId)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Sync with global project filter (nav dropdown), including async project loads.
|
|
112
|
+
watch(
|
|
113
|
+
() => [projectFilter.selectedProject.value, tabs.value.map((tab) => tab.key).join('|')],
|
|
114
|
+
([projectId]) => syncFromProjectFilter(projectId),
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
onMounted(() => {
|
|
118
|
+
projectFilter.load()
|
|
119
|
+
projectFilter.restore()
|
|
120
|
+
// If global filter is set, use it; otherwise fall back to hash
|
|
121
|
+
if (projectFilter.selectedProject.value !== '__all__') {
|
|
122
|
+
syncFromProjectFilter(projectFilter.selectedProject.value)
|
|
123
|
+
} else {
|
|
124
|
+
syncFromHash()
|
|
125
|
+
}
|
|
126
|
+
window.addEventListener('hashchange', syncFromHash)
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
onUnmounted(() => {
|
|
130
|
+
window.removeEventListener('hashchange', syncFromHash)
|
|
131
|
+
})
|
|
132
|
+
</script>
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
<template></template>
|
|
2
|
+
|
|
3
|
+
<script setup lang="ts">
|
|
4
|
+
import { nextTick, onMounted, onUnmounted, watch } from 'vue'
|
|
5
|
+
import { useRoute } from 'vitepress'
|
|
6
|
+
|
|
7
|
+
const route = useRoute()
|
|
8
|
+
const STORAGE_KEY = 'echo:lastSearchQuery'
|
|
9
|
+
const APPLIED_KEY = 'echo:lastSearchApplied'
|
|
10
|
+
const MAX_AGE_MS = 2 * 60 * 1000
|
|
11
|
+
let memorySearch: StoredSearch | null = null
|
|
12
|
+
let memoryApplied = ''
|
|
13
|
+
|
|
14
|
+
type StoredSearch = {
|
|
15
|
+
query: string
|
|
16
|
+
ts: number
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function readStoredSearch(): StoredSearch | null {
|
|
20
|
+
if (memorySearch && Date.now() - memorySearch.ts <= MAX_AGE_MS) return memorySearch
|
|
21
|
+
try {
|
|
22
|
+
const raw = window.sessionStorage?.getItem(STORAGE_KEY)
|
|
23
|
+
if (!raw) return null
|
|
24
|
+
const parsed = JSON.parse(raw) as StoredSearch
|
|
25
|
+
if (!parsed.query || Date.now() - parsed.ts > MAX_AGE_MS) return null
|
|
26
|
+
return parsed
|
|
27
|
+
} catch {
|
|
28
|
+
return null
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function writeSearchQuery(query: string) {
|
|
33
|
+
const value = query.trim()
|
|
34
|
+
if (!value) return
|
|
35
|
+
memorySearch = { query: value, ts: Date.now() }
|
|
36
|
+
try {
|
|
37
|
+
window.sessionStorage?.setItem(STORAGE_KEY, JSON.stringify(memorySearch))
|
|
38
|
+
} catch {}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function isSearchInput(input: HTMLInputElement): boolean {
|
|
42
|
+
const text = [
|
|
43
|
+
input.type,
|
|
44
|
+
input.placeholder,
|
|
45
|
+
input.ariaLabel,
|
|
46
|
+
input.className,
|
|
47
|
+
input.id,
|
|
48
|
+
input.closest('[class*="Search"], [class*="search"], [id*="Search"], [id*="search"]')?.className || '',
|
|
49
|
+
].join(' ')
|
|
50
|
+
return /search|docsearch|搜索/i.test(text)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function currentSearchQuery(): string {
|
|
54
|
+
const active = document.activeElement
|
|
55
|
+
if (active instanceof HTMLInputElement && isSearchInput(active)) return active.value.trim()
|
|
56
|
+
|
|
57
|
+
const inputs = Array.from(document.querySelectorAll<HTMLInputElement>('input'))
|
|
58
|
+
const found = inputs.find((input) => isSearchInput(input) && input.value.trim())
|
|
59
|
+
return found?.value.trim() || ''
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function rememberSearchQuery() {
|
|
63
|
+
const query = currentSearchQuery()
|
|
64
|
+
if (query) writeSearchQuery(query)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function handleInput(event: Event) {
|
|
68
|
+
const target = event.target
|
|
69
|
+
if (target instanceof HTMLInputElement && isSearchInput(target)) {
|
|
70
|
+
writeSearchQuery(target.value)
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function handlePointerDown(event: Event) {
|
|
75
|
+
const target = event.target as HTMLElement | null
|
|
76
|
+
if (!target) return
|
|
77
|
+
if (target.closest('a, button, [role="option"], [role="link"], [role="button"]')) {
|
|
78
|
+
rememberSearchQuery()
|
|
79
|
+
scheduleSearchLanding()
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function handleKeyDown(event: KeyboardEvent) {
|
|
84
|
+
if (event.key === 'Enter') {
|
|
85
|
+
rememberSearchQuery()
|
|
86
|
+
scheduleSearchLanding()
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function unwrapPreviousHighlights(root: Element) {
|
|
91
|
+
const marks = Array.from(root.querySelectorAll('mark.echo-search-hit'))
|
|
92
|
+
for (const mark of marks) {
|
|
93
|
+
mark.replaceWith(document.createTextNode(mark.textContent || ''))
|
|
94
|
+
}
|
|
95
|
+
root.normalize()
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function shouldSkipNode(node: Node): boolean {
|
|
99
|
+
const parent = node.parentElement
|
|
100
|
+
if (!parent) return true
|
|
101
|
+
return !!parent.closest('script, style, pre, code, textarea, input, select, button, nav, .echo-toolbar, .echo-modal, .echo-sel-popup, .echo-comment-box')
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function highlightTextNode(node: Text, query: string): HTMLElement[] {
|
|
105
|
+
const text = node.nodeValue || ''
|
|
106
|
+
const lowerText = text.toLowerCase()
|
|
107
|
+
const lowerQuery = query.toLowerCase()
|
|
108
|
+
const hits: HTMLElement[] = []
|
|
109
|
+
let index = lowerText.indexOf(lowerQuery)
|
|
110
|
+
if (index === -1) return hits
|
|
111
|
+
|
|
112
|
+
const fragment = document.createDocumentFragment()
|
|
113
|
+
let cursor = 0
|
|
114
|
+
while (index !== -1) {
|
|
115
|
+
if (index > cursor) {
|
|
116
|
+
fragment.appendChild(document.createTextNode(text.slice(cursor, index)))
|
|
117
|
+
}
|
|
118
|
+
const mark = document.createElement('mark')
|
|
119
|
+
mark.className = 'echo-search-hit'
|
|
120
|
+
mark.textContent = text.slice(index, index + query.length)
|
|
121
|
+
fragment.appendChild(mark)
|
|
122
|
+
hits.push(mark)
|
|
123
|
+
cursor = index + query.length
|
|
124
|
+
index = lowerText.indexOf(lowerQuery, cursor)
|
|
125
|
+
}
|
|
126
|
+
if (cursor < text.length) {
|
|
127
|
+
fragment.appendChild(document.createTextNode(text.slice(cursor)))
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
node.replaceWith(fragment)
|
|
131
|
+
return hits
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function highlightQuery(root: Element, query: string): HTMLElement[] {
|
|
135
|
+
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
|
|
136
|
+
acceptNode(node) {
|
|
137
|
+
if (shouldSkipNode(node)) return NodeFilter.FILTER_REJECT
|
|
138
|
+
return (node.nodeValue || '').toLowerCase().includes(query.toLowerCase())
|
|
139
|
+
? NodeFilter.FILTER_ACCEPT
|
|
140
|
+
: NodeFilter.FILTER_REJECT
|
|
141
|
+
},
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
const nodes: Text[] = []
|
|
145
|
+
while (walker.nextNode()) nodes.push(walker.currentNode as Text)
|
|
146
|
+
return nodes.flatMap((node) => highlightTextNode(node, query))
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function bestHit(hits: HTMLElement[]): HTMLElement | null {
|
|
150
|
+
if (hits.length === 0) return null
|
|
151
|
+
|
|
152
|
+
const hash = decodeURIComponent(location.hash.slice(1))
|
|
153
|
+
const hashTarget = hash ? document.getElementById(hash) : null
|
|
154
|
+
const anchorTop = hashTarget
|
|
155
|
+
? hashTarget.getBoundingClientRect().top + window.scrollY
|
|
156
|
+
: window.scrollY + 120
|
|
157
|
+
|
|
158
|
+
return hits
|
|
159
|
+
.map((hit) => {
|
|
160
|
+
const rect = hit.getBoundingClientRect()
|
|
161
|
+
const top = rect.top + window.scrollY
|
|
162
|
+
return { hit, distance: Math.abs(top - anchorTop) }
|
|
163
|
+
})
|
|
164
|
+
.sort((a, b) => a.distance - b.distance)[0].hit
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function applySearchLanding() {
|
|
168
|
+
const stored = readStoredSearch()
|
|
169
|
+
const root = document.querySelector('.vp-doc')
|
|
170
|
+
if (!stored || !root) return
|
|
171
|
+
|
|
172
|
+
const applyKey = `${location.pathname}${location.hash}:${stored.query}:${stored.ts}`
|
|
173
|
+
let applied = memoryApplied
|
|
174
|
+
try {
|
|
175
|
+
applied = window.sessionStorage?.getItem(APPLIED_KEY) || memoryApplied
|
|
176
|
+
} catch {}
|
|
177
|
+
if (applied === applyKey) return
|
|
178
|
+
|
|
179
|
+
unwrapPreviousHighlights(root)
|
|
180
|
+
const hits = highlightQuery(root, stored.query)
|
|
181
|
+
const target = bestHit(hits)
|
|
182
|
+
if (!target) return
|
|
183
|
+
|
|
184
|
+
memoryApplied = applyKey
|
|
185
|
+
try {
|
|
186
|
+
window.sessionStorage?.setItem(APPLIED_KEY, applyKey)
|
|
187
|
+
} catch {}
|
|
188
|
+
target.classList.add('echo-search-hit-active')
|
|
189
|
+
target.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function scheduleSearchLanding() {
|
|
193
|
+
window.setTimeout(() => {
|
|
194
|
+
nextTick(() => applySearchLanding())
|
|
195
|
+
}, 120)
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
onMounted(() => {
|
|
199
|
+
document.addEventListener('input', handleInput, true)
|
|
200
|
+
document.addEventListener('pointerdown', handlePointerDown, true)
|
|
201
|
+
document.addEventListener('keydown', handleKeyDown, true)
|
|
202
|
+
scheduleSearchLanding()
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
onUnmounted(() => {
|
|
206
|
+
document.removeEventListener('input', handleInput, true)
|
|
207
|
+
document.removeEventListener('pointerdown', handlePointerDown, true)
|
|
208
|
+
document.removeEventListener('keydown', handleKeyDown, true)
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
watch(
|
|
212
|
+
() => route.path,
|
|
213
|
+
() => scheduleSearchLanding(),
|
|
214
|
+
)
|
|
215
|
+
</script>
|