echoctl 0.1.0 → 0.1.3
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
- package/scripts/build-docs.js +1 -1
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<Teleport to="body">
|
|
3
|
+
<div
|
|
4
|
+
v-if="visible"
|
|
5
|
+
class="echo-sel-popup"
|
|
6
|
+
:style="{ top: popupTop + 'px', left: popupLeft + 'px' }"
|
|
7
|
+
>
|
|
8
|
+
<button class="echo-btn" @click="startComment">评论选中文字</button>
|
|
9
|
+
</div>
|
|
10
|
+
</Teleport>
|
|
11
|
+
</template>
|
|
12
|
+
|
|
13
|
+
<script setup lang="ts">
|
|
14
|
+
import { ref, onMounted, onUnmounted, computed } from 'vue'
|
|
15
|
+
import { useData } from 'vitepress'
|
|
16
|
+
import { useEchoStatus } from '../lib/useEchoStatus'
|
|
17
|
+
import { postComment } from '../lib/echo-api'
|
|
18
|
+
|
|
19
|
+
const { frontmatter } = useData()
|
|
20
|
+
const articleId = computed(() => (frontmatter.value as any)?.echo?.articleId as string | undefined)
|
|
21
|
+
const projectId = computed(() => (frontmatter.value as any)?.echo?.projectId as string | null | undefined)
|
|
22
|
+
const { status } = useEchoStatus(articleId)
|
|
23
|
+
|
|
24
|
+
const visible = ref(false)
|
|
25
|
+
const popupTop = ref(0)
|
|
26
|
+
const popupLeft = ref(0)
|
|
27
|
+
let currentQuote = ''
|
|
28
|
+
let currentPrefix = ''
|
|
29
|
+
let currentSuffix = ''
|
|
30
|
+
let currentOccurrence = 1
|
|
31
|
+
|
|
32
|
+
function getArticleText(): string {
|
|
33
|
+
const doc = document.querySelector('.vp-doc')
|
|
34
|
+
if (!doc) return ''
|
|
35
|
+
return (doc as HTMLElement).innerText || ''
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function computeAnchor(quote: string): { prefix: string; suffix: string; occurrence: number } {
|
|
39
|
+
const body = getArticleText()
|
|
40
|
+
const idx = body.indexOf(quote)
|
|
41
|
+
const prefix = body.slice(Math.max(0, idx - 100), idx).trim()
|
|
42
|
+
const suffix = body.slice(idx + quote.length, idx + quote.length + 100).trim()
|
|
43
|
+
|
|
44
|
+
let occurrence = 0
|
|
45
|
+
let pos = -1
|
|
46
|
+
while ((pos = body.indexOf(quote, pos + 1)) !== -1) {
|
|
47
|
+
occurrence++
|
|
48
|
+
if (pos === idx) break
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return { prefix, suffix, occurrence }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function handleMouseUp(e: MouseEvent) {
|
|
55
|
+
const target = e.target as HTMLElement
|
|
56
|
+
if (!target) return
|
|
57
|
+
|
|
58
|
+
// Only handle selections inside .vp-doc content area
|
|
59
|
+
const doc = target.closest('.vp-doc')
|
|
60
|
+
if (!doc) return
|
|
61
|
+
|
|
62
|
+
// Exclude echo components, textareas, buttons, nav
|
|
63
|
+
if (target.closest('.echo-sel-popup, .echo-comment-box, .echo-toolbar, .echo-modal, textarea, button, nav')) return
|
|
64
|
+
|
|
65
|
+
const sel = window.getSelection()
|
|
66
|
+
if (!sel || sel.rangeCount === 0) return
|
|
67
|
+
const text = sel.toString().trim()
|
|
68
|
+
if (text.length < 3 || text.length > 500) {
|
|
69
|
+
visible.value = false
|
|
70
|
+
return
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const range = sel.getRangeAt(0)
|
|
74
|
+
const rect = range.getBoundingClientRect()
|
|
75
|
+
popupTop.value = window.scrollY + rect.bottom + 8
|
|
76
|
+
popupLeft.value = window.scrollX + rect.left
|
|
77
|
+
currentQuote = text
|
|
78
|
+
|
|
79
|
+
const anchor = computeAnchor(text)
|
|
80
|
+
currentPrefix = anchor.prefix
|
|
81
|
+
currentSuffix = anchor.suffix
|
|
82
|
+
currentOccurrence = anchor.occurrence
|
|
83
|
+
|
|
84
|
+
visible.value = true
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function hide() {
|
|
88
|
+
visible.value = false
|
|
89
|
+
currentQuote = ''
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function startComment() {
|
|
93
|
+
const comment = prompt(`评论 "${currentQuote.slice(0, 60)}...":`)
|
|
94
|
+
if (!comment) { hide(); return }
|
|
95
|
+
postComment({
|
|
96
|
+
articleId: articleId.value || '',
|
|
97
|
+
comment: comment.trim(),
|
|
98
|
+
quote: currentQuote,
|
|
99
|
+
prefix: currentPrefix,
|
|
100
|
+
suffix: currentSuffix,
|
|
101
|
+
occurrence: currentOccurrence,
|
|
102
|
+
author: status.value?.author,
|
|
103
|
+
projectId: projectId.value ?? null,
|
|
104
|
+
}).then(() => {
|
|
105
|
+
setTimeout(() => location.reload(), 800)
|
|
106
|
+
}).catch((err) => {
|
|
107
|
+
alert('评论失败: ' + (err.message || '未知错误'))
|
|
108
|
+
})
|
|
109
|
+
hide()
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Hide on click outside
|
|
113
|
+
function handleClick(e: MouseEvent) {
|
|
114
|
+
const target = e.target as HTMLElement
|
|
115
|
+
if (target && !target.closest('.echo-sel-popup')) {
|
|
116
|
+
hide()
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
onMounted(() => {
|
|
121
|
+
document.addEventListener('mouseup', handleMouseUp)
|
|
122
|
+
document.addEventListener('click', handleClick)
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
onUnmounted(() => {
|
|
126
|
+
document.removeEventListener('mouseup', handleMouseUp)
|
|
127
|
+
document.removeEventListener('click', handleClick)
|
|
128
|
+
})
|
|
129
|
+
</script>
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="echo-tag-cloud" aria-label="标签筛选">
|
|
3
|
+
<!-- Existing tag chips with action buttons -->
|
|
4
|
+
<div
|
|
5
|
+
v-for="group in groups"
|
|
6
|
+
:key="group.anchor"
|
|
7
|
+
class="echo-tag-chip-row"
|
|
8
|
+
>
|
|
9
|
+
<!-- Inline rename for editing tag -->
|
|
10
|
+
<template v-if="editingTag === group.tag">
|
|
11
|
+
<input
|
|
12
|
+
:ref="(el) => { if (el) renameInput = el as HTMLInputElement }"
|
|
13
|
+
v-model="renameValue"
|
|
14
|
+
class="echo-tag-rename-input"
|
|
15
|
+
maxlength="60"
|
|
16
|
+
@keydown.enter="confirmRename(group.tag)"
|
|
17
|
+
@keydown.escape="cancelRename"
|
|
18
|
+
@blur="confirmRename(group.tag)"
|
|
19
|
+
/>
|
|
20
|
+
<button class="echo-tag-action-btn echo-tag-action-confirm" title="确认" @mousedown.prevent="confirmRename(group.tag)">✓</button>
|
|
21
|
+
<button class="echo-tag-action-btn echo-tag-action-cancel" title="取消" @mousedown.prevent="cancelRename">✕</button>
|
|
22
|
+
</template>
|
|
23
|
+
|
|
24
|
+
<!-- Normal display -->
|
|
25
|
+
<template v-else>
|
|
26
|
+
<a
|
|
27
|
+
:href="`#${encodeURIComponent(group.anchor)}`"
|
|
28
|
+
class="echo-tag-chip"
|
|
29
|
+
:class="{ 'echo-tag-chip-active': group.anchor === selectedAnchor, 'echo-tag-chip-muted': selectedAnchor && group.anchor !== selectedAnchor }"
|
|
30
|
+
@click="select(group.anchor)"
|
|
31
|
+
>
|
|
32
|
+
{{ group.tag }}<span>{{ group.articles.length }}</span>
|
|
33
|
+
</a>
|
|
34
|
+
<button
|
|
35
|
+
class="echo-tag-action-btn"
|
|
36
|
+
title="重命名"
|
|
37
|
+
:disabled="busyTag === group.tag"
|
|
38
|
+
@click.stop="startRename(group.tag)"
|
|
39
|
+
>✏️</button>
|
|
40
|
+
<button
|
|
41
|
+
class="echo-tag-action-btn echo-tag-action-danger"
|
|
42
|
+
title="删除"
|
|
43
|
+
:disabled="busyTag === group.tag"
|
|
44
|
+
@click.stop="promptDelete(group.tag, group.articles.length)"
|
|
45
|
+
>🗑️</button>
|
|
46
|
+
</template>
|
|
47
|
+
</div>
|
|
48
|
+
|
|
49
|
+
<!-- New tag button -->
|
|
50
|
+
<button
|
|
51
|
+
class="echo-tag-new-btn"
|
|
52
|
+
:disabled="busy"
|
|
53
|
+
@click="showNewTagModal = true"
|
|
54
|
+
>+ 新建标签</button>
|
|
55
|
+
</div>
|
|
56
|
+
|
|
57
|
+
<!-- Delete confirmation -->
|
|
58
|
+
<div v-if="deleteTarget" class="echo-modal" @click.self="deleteTarget = null">
|
|
59
|
+
<div class="echo-modal-content">
|
|
60
|
+
<h3>删除标签「{{ deleteTarget.tag }}」</h3>
|
|
61
|
+
<p>此标签将从 {{ deleteTarget.count }} 篇文章中移除。此操作不可撤销。</p>
|
|
62
|
+
<div class="echo-modal-btns">
|
|
63
|
+
<button class="echo-btn" :disabled="busy" @click="doDelete">确认删除</button>
|
|
64
|
+
<button class="echo-btn echo-btn-off" :disabled="busy" @click="deleteTarget = null">取消</button>
|
|
65
|
+
</div>
|
|
66
|
+
<p v-if="error" class="echo-serve-notice">{{ error }}</p>
|
|
67
|
+
</div>
|
|
68
|
+
</div>
|
|
69
|
+
|
|
70
|
+
<!-- New tag modal -->
|
|
71
|
+
<div v-if="showNewTagModal" class="echo-modal" @click.self="showNewTagModal = false">
|
|
72
|
+
<div class="echo-modal-content">
|
|
73
|
+
<h3>新建标签</h3>
|
|
74
|
+
<input
|
|
75
|
+
v-model="newTagName"
|
|
76
|
+
class="echo-tag-rename-input"
|
|
77
|
+
placeholder="输入标签名..."
|
|
78
|
+
maxlength="60"
|
|
79
|
+
style="width:100%;box-sizing:border-box;margin:12px 0;"
|
|
80
|
+
@keydown.enter="doCreateTag"
|
|
81
|
+
/>
|
|
82
|
+
<p class="echo-muted-text" style="font-size:13px;margin:0 0 12px;">
|
|
83
|
+
选择要添加此标签的文章(至少选择一篇):
|
|
84
|
+
</p>
|
|
85
|
+
<div class="echo-new-tag-articles">
|
|
86
|
+
<label
|
|
87
|
+
v-for="article in allArticles"
|
|
88
|
+
:key="article.id"
|
|
89
|
+
class="echo-new-tag-article-label"
|
|
90
|
+
>
|
|
91
|
+
<input
|
|
92
|
+
type="checkbox"
|
|
93
|
+
:value="article.id"
|
|
94
|
+
v-model="selectedArticleIds"
|
|
95
|
+
/>
|
|
96
|
+
<span class="echo-new-tag-article-title">{{ article.title }}</span>
|
|
97
|
+
</label>
|
|
98
|
+
</div>
|
|
99
|
+
<div class="echo-modal-btns">
|
|
100
|
+
<button
|
|
101
|
+
class="echo-btn"
|
|
102
|
+
:disabled="busy || !newTagName.trim() || selectedArticleIds.length === 0"
|
|
103
|
+
@click="doCreateTag"
|
|
104
|
+
>创建</button>
|
|
105
|
+
<button class="echo-btn echo-btn-off" :disabled="busy" @click="showNewTagModal = false">取消</button>
|
|
106
|
+
</div>
|
|
107
|
+
<p v-if="error" class="echo-serve-notice">{{ error }}</p>
|
|
108
|
+
</div>
|
|
109
|
+
</div>
|
|
110
|
+
|
|
111
|
+
<!-- Toast -->
|
|
112
|
+
<div v-if="toast" class="echo-toast">{{ toast }}</div>
|
|
113
|
+
|
|
114
|
+
<!-- Results -->
|
|
115
|
+
<div v-if="selectedGroup" class="echo-tag-result">
|
|
116
|
+
<h2 :id="selectedGroup.anchor">{{ selectedGroup.tag }} ({{ selectedGroup.articles.length }})</h2>
|
|
117
|
+
<div class="echo-article-grid">
|
|
118
|
+
<a
|
|
119
|
+
v-for="article in selectedGroup.articles"
|
|
120
|
+
:key="article.href"
|
|
121
|
+
class="echo-article-card"
|
|
122
|
+
:href="article.href"
|
|
123
|
+
>
|
|
124
|
+
<strong>{{ article.title }}</strong>
|
|
125
|
+
<p>{{ article.summary || '无摘要' }}</p>
|
|
126
|
+
</a>
|
|
127
|
+
</div>
|
|
128
|
+
</div>
|
|
129
|
+
|
|
130
|
+
<div v-else class="echo-tag-result">
|
|
131
|
+
<h2>选择一个标签</h2>
|
|
132
|
+
<p class="echo-muted-text">点击上方标签后,只显示对应文章。</p>
|
|
133
|
+
</div>
|
|
134
|
+
</template>
|
|
135
|
+
|
|
136
|
+
<script setup lang="ts">
|
|
137
|
+
import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue'
|
|
138
|
+
import { renameTag, purgeTag, postTag } from '../lib/echo-api'
|
|
139
|
+
|
|
140
|
+
type TagArticle = {
|
|
141
|
+
id: string
|
|
142
|
+
href: string
|
|
143
|
+
summary?: string
|
|
144
|
+
title: string
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
type TagGroup = {
|
|
148
|
+
anchor: string
|
|
149
|
+
articles: TagArticle[]
|
|
150
|
+
tag: string
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const props = defineProps<{ payload: string }>()
|
|
154
|
+
|
|
155
|
+
const groups = computed<TagGroup[]>(() => {
|
|
156
|
+
try {
|
|
157
|
+
return JSON.parse(decodeURIComponent(props.payload)) as TagGroup[]
|
|
158
|
+
} catch {
|
|
159
|
+
return []
|
|
160
|
+
}
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
const selectedAnchor = ref('')
|
|
164
|
+
|
|
165
|
+
const selectedGroup = computed(() => {
|
|
166
|
+
const anchor = selectedAnchor.value || groups.value[0]?.anchor || ''
|
|
167
|
+
return groups.value.find((group) => group.anchor === anchor) || null
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
function syncFromHash() {
|
|
171
|
+
selectedAnchor.value = decodeURIComponent(window.location.hash.slice(1) || '') || groups.value[0]?.anchor || ''
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function select(anchor: string) {
|
|
175
|
+
selectedAnchor.value = anchor
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// --- Rename ---
|
|
179
|
+
const editingTag = ref('')
|
|
180
|
+
const renameValue = ref('')
|
|
181
|
+
const renameInput = ref<HTMLInputElement | null>(null)
|
|
182
|
+
const busy = ref(false)
|
|
183
|
+
const busyTag = ref('')
|
|
184
|
+
const error = ref('')
|
|
185
|
+
const toast = ref('')
|
|
186
|
+
|
|
187
|
+
function startRename(tag: string) {
|
|
188
|
+
editingTag.value = tag
|
|
189
|
+
renameValue.value = tag
|
|
190
|
+
error.value = ''
|
|
191
|
+
nextTick(() => renameInput.value?.focus())
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function cancelRename() {
|
|
195
|
+
editingTag.value = ''
|
|
196
|
+
renameValue.value = ''
|
|
197
|
+
error.value = ''
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
async function confirmRename(oldTag: string) {
|
|
201
|
+
const newTag = renameValue.value.trim()
|
|
202
|
+
if (!newTag || newTag === oldTag) {
|
|
203
|
+
cancelRename()
|
|
204
|
+
return
|
|
205
|
+
}
|
|
206
|
+
busy.value = true
|
|
207
|
+
busyTag.value = oldTag
|
|
208
|
+
error.value = ''
|
|
209
|
+
try {
|
|
210
|
+
const result = await renameTag({ oldTag, newTag })
|
|
211
|
+
editingTag.value = ''
|
|
212
|
+
renameValue.value = ''
|
|
213
|
+
toast.value = `已重命名:${oldTag} → ${newTag}(${result.renamed} 篇文章)`
|
|
214
|
+
setTimeout(() => { toast.value = '' }, 3000)
|
|
215
|
+
// Reload the page to get updated tags payload
|
|
216
|
+
setTimeout(() => { window.location.reload() }, 800)
|
|
217
|
+
} catch (err: any) {
|
|
218
|
+
error.value = err?.message || '重命名失败'
|
|
219
|
+
} finally {
|
|
220
|
+
busy.value = false
|
|
221
|
+
busyTag.value = ''
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// --- Delete ---
|
|
226
|
+
const deleteTarget = ref<{ tag: string; count: number } | null>(null)
|
|
227
|
+
|
|
228
|
+
function promptDelete(tag: string, count: number) {
|
|
229
|
+
deleteTarget.value = { tag, count }
|
|
230
|
+
error.value = ''
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
async function doDelete() {
|
|
234
|
+
if (!deleteTarget.value) return
|
|
235
|
+
const tag = deleteTarget.value.tag
|
|
236
|
+
busy.value = true
|
|
237
|
+
busyTag.value = tag
|
|
238
|
+
error.value = ''
|
|
239
|
+
try {
|
|
240
|
+
const result = await purgeTag({ tag })
|
|
241
|
+
deleteTarget.value = null
|
|
242
|
+
toast.value = `已删除标签「${tag}」(从 ${result.purged} 篇文章中移除)`
|
|
243
|
+
setTimeout(() => { toast.value = '' }, 3000)
|
|
244
|
+
setTimeout(() => { window.location.reload() }, 800)
|
|
245
|
+
} catch (err: any) {
|
|
246
|
+
error.value = err?.message || '删除失败'
|
|
247
|
+
} finally {
|
|
248
|
+
busy.value = false
|
|
249
|
+
busyTag.value = ''
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// --- Create ---
|
|
254
|
+
const showNewTagModal = ref(false)
|
|
255
|
+
const newTagName = ref('')
|
|
256
|
+
const selectedArticleIds = ref<string[]>([])
|
|
257
|
+
|
|
258
|
+
const allArticles = computed(() => {
|
|
259
|
+
const seen = new Map<string, TagArticle>()
|
|
260
|
+
for (const group of groups.value) {
|
|
261
|
+
for (const article of group.articles) {
|
|
262
|
+
if (!seen.has(article.id)) {
|
|
263
|
+
seen.set(article.id, article)
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
return [...seen.values()]
|
|
268
|
+
})
|
|
269
|
+
|
|
270
|
+
async function doCreateTag() {
|
|
271
|
+
const tag = newTagName.value.trim()
|
|
272
|
+
if (!tag || selectedArticleIds.value.length === 0) return
|
|
273
|
+
busy.value = true
|
|
274
|
+
error.value = ''
|
|
275
|
+
try {
|
|
276
|
+
// Add tag to each selected article
|
|
277
|
+
for (const articleId of selectedArticleIds.value) {
|
|
278
|
+
await postTag({ articleId, tag })
|
|
279
|
+
}
|
|
280
|
+
showNewTagModal.value = false
|
|
281
|
+
newTagName.value = ''
|
|
282
|
+
selectedArticleIds.value = []
|
|
283
|
+
toast.value = `已创建标签「${tag}」(${selectedArticleIds.value.length} 篇文章)`
|
|
284
|
+
setTimeout(() => { toast.value = '' }, 3000)
|
|
285
|
+
setTimeout(() => { window.location.reload() }, 800)
|
|
286
|
+
} catch (err: any) {
|
|
287
|
+
error.value = err?.message || '创建失败'
|
|
288
|
+
} finally {
|
|
289
|
+
busy.value = false
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
onMounted(() => {
|
|
294
|
+
syncFromHash()
|
|
295
|
+
window.addEventListener('hashchange', syncFromHash)
|
|
296
|
+
})
|
|
297
|
+
|
|
298
|
+
onUnmounted(() => {
|
|
299
|
+
window.removeEventListener('hashchange', syncFromHash)
|
|
300
|
+
})
|
|
301
|
+
</script>
|