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,335 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div v-if="visible" class="echo-import-banner">
|
|
3
|
+
<div class="echo-import-banner-inner">
|
|
4
|
+
<div class="echo-import-banner-header" @click="expanded = !expanded">
|
|
5
|
+
<span class="echo-import-banner-icon">📥</span>
|
|
6
|
+
<span class="echo-import-banner-title">
|
|
7
|
+
发现 <strong>{{ candidates.length }}</strong> 个未导入的 Claude 历史会话
|
|
8
|
+
</span>
|
|
9
|
+
<span class="echo-import-banner-chevron">{{ expanded ? '▾' : '▸' }}</span>
|
|
10
|
+
<button class="echo-import-banner-close" @click.stop="dismiss" title="关闭">✕</button>
|
|
11
|
+
</div>
|
|
12
|
+
|
|
13
|
+
<div v-if="expanded" class="echo-import-banner-body">
|
|
14
|
+
<p class="echo-import-banner-desc">
|
|
15
|
+
这些会话来自 Claude Code 的 JSONL 转录文件,尚未导入 Echo。
|
|
16
|
+
导入后它们会作为不可变文章出现在当前项目的文章列表中。
|
|
17
|
+
</p>
|
|
18
|
+
|
|
19
|
+
<div class="echo-import-banner-candidates" v-if="candidates.length > 0">
|
|
20
|
+
<div v-for="c in candidates" :key="c.sessionId" class="echo-import-candidate-item">
|
|
21
|
+
<span class="echo-import-candidate-id">{{ c.articleId }}</span>
|
|
22
|
+
<span class="echo-import-candidate-meta">{{ c.turnCount }} turns · {{ formatDate(c.mtime) }}</span>
|
|
23
|
+
<span class="echo-import-candidate-status" :class="'echo-status-' + c.status">{{ statusLabel(c.status) }}</span>
|
|
24
|
+
</div>
|
|
25
|
+
</div>
|
|
26
|
+
|
|
27
|
+
<div class="echo-import-banner-cli">
|
|
28
|
+
<p class="echo-import-banner-cli-title">💡 命令行导入</p>
|
|
29
|
+
<pre class="echo-import-banner-cli-code"><code>echoctl import claude --project {{ projectRoot || '<项目路径>' }} --all --apply</code></pre>
|
|
30
|
+
<p class="echo-import-banner-cli-note">
|
|
31
|
+
更多选项:<code>echoctl import claude --help</code>
|
|
32
|
+
</p>
|
|
33
|
+
</div>
|
|
34
|
+
|
|
35
|
+
<div v-if="error" class="echo-import-banner-error">{{ error }}</div>
|
|
36
|
+
<div v-if="success" class="echo-import-banner-success">{{ success }}</div>
|
|
37
|
+
|
|
38
|
+
<div class="echo-import-banner-actions">
|
|
39
|
+
<button
|
|
40
|
+
class="echo-import-btn echo-import-btn-primary"
|
|
41
|
+
:disabled="importing"
|
|
42
|
+
@click="importAll"
|
|
43
|
+
>
|
|
44
|
+
{{ importing ? '导入中…' : `导入全部(${candidates.length} 个)` }}
|
|
45
|
+
</button>
|
|
46
|
+
</div>
|
|
47
|
+
</div>
|
|
48
|
+
</div>
|
|
49
|
+
</div>
|
|
50
|
+
</template>
|
|
51
|
+
|
|
52
|
+
<script setup lang="ts">
|
|
53
|
+
import { ref, onMounted } from 'vue'
|
|
54
|
+
import { getClaudeImportCandidates, importClaudeSessions, getStatus } from '../lib/echo-api'
|
|
55
|
+
|
|
56
|
+
const STORAGE_KEY = 'echo-claude-import-dismissed'
|
|
57
|
+
|
|
58
|
+
const visible = ref(false)
|
|
59
|
+
const expanded = ref(false)
|
|
60
|
+
const candidates = ref<any[]>([])
|
|
61
|
+
const projectRoot = ref('')
|
|
62
|
+
const projectId = ref<string | null>(null)
|
|
63
|
+
const importing = ref(false)
|
|
64
|
+
const error = ref('')
|
|
65
|
+
const success = ref('')
|
|
66
|
+
|
|
67
|
+
onMounted(async () => {
|
|
68
|
+
// 检查是否已被关闭(跨会话保留)
|
|
69
|
+
try {
|
|
70
|
+
if (localStorage.getItem(STORAGE_KEY) === '1') return
|
|
71
|
+
} catch (_) {}
|
|
72
|
+
|
|
73
|
+
// 获取当前项目 ID
|
|
74
|
+
let pid: string | null = null
|
|
75
|
+
try {
|
|
76
|
+
const s = await getStatus()
|
|
77
|
+
pid = s.projectId
|
|
78
|
+
} catch (_) {}
|
|
79
|
+
|
|
80
|
+
if (!pid) return
|
|
81
|
+
projectId.value = pid
|
|
82
|
+
|
|
83
|
+
try {
|
|
84
|
+
const result = await getClaudeImportCandidates(pid)
|
|
85
|
+
const newCandidates = result.candidates.filter(
|
|
86
|
+
(c) => c.status === 'new' || c.status === 'updated'
|
|
87
|
+
)
|
|
88
|
+
if (newCandidates.length === 0) return
|
|
89
|
+
candidates.value = newCandidates
|
|
90
|
+
projectRoot.value = result.projectDir || ''
|
|
91
|
+
visible.value = true
|
|
92
|
+
} catch (_) {}
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
function dismiss() {
|
|
96
|
+
visible.value = false
|
|
97
|
+
try { localStorage.setItem(STORAGE_KEY, '1') } catch (_) {}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function statusLabel(status: string): string {
|
|
101
|
+
switch (status) {
|
|
102
|
+
case 'new': return '新'
|
|
103
|
+
case 'updated': return '已更新'
|
|
104
|
+
case 'skipped': return '已跳过'
|
|
105
|
+
default: return status
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function formatDate(mtime: string): string {
|
|
110
|
+
try {
|
|
111
|
+
const d = new Date(mtime)
|
|
112
|
+
return d.toLocaleDateString('zh-CN', { month: 'short', day: 'numeric' })
|
|
113
|
+
} catch {
|
|
114
|
+
return mtime
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function importAll() {
|
|
119
|
+
if (!projectId.value) return
|
|
120
|
+
importing.value = true
|
|
121
|
+
error.value = ''
|
|
122
|
+
success.value = ''
|
|
123
|
+
try {
|
|
124
|
+
const ids = candidates.value.map((c) => c.sessionId)
|
|
125
|
+
const result = await importClaudeSessions(projectId.value, ids)
|
|
126
|
+
if (result.ok) {
|
|
127
|
+
success.value = `已导入 ${result.imported} 个会话,跳过 ${result.skipped} 个。页面即将刷新…`
|
|
128
|
+
try { localStorage.removeItem(STORAGE_KEY) } catch (_) {}
|
|
129
|
+
setTimeout(() => location.reload(), 2000)
|
|
130
|
+
}
|
|
131
|
+
} catch (err: any) {
|
|
132
|
+
error.value = err.message || '导入失败'
|
|
133
|
+
} finally {
|
|
134
|
+
importing.value = false
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
</script>
|
|
138
|
+
|
|
139
|
+
<style scoped>
|
|
140
|
+
.echo-import-banner {
|
|
141
|
+
margin: 16px 0 24px;
|
|
142
|
+
border: 1px solid var(--vp-c-brand-soft);
|
|
143
|
+
border-radius: 8px;
|
|
144
|
+
background: var(--vp-c-bg-soft);
|
|
145
|
+
overflow: hidden;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
.echo-import-banner-inner {
|
|
149
|
+
padding: 0;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
.echo-import-banner-header {
|
|
153
|
+
display: flex;
|
|
154
|
+
align-items: center;
|
|
155
|
+
gap: 8px;
|
|
156
|
+
padding: 12px 16px;
|
|
157
|
+
cursor: pointer;
|
|
158
|
+
user-select: none;
|
|
159
|
+
transition: background 0.15s;
|
|
160
|
+
}
|
|
161
|
+
.echo-import-banner-header:hover {
|
|
162
|
+
background: var(--vp-c-bg-mute);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
.echo-import-banner-icon {
|
|
166
|
+
font-size: 16px;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
.echo-import-banner-title {
|
|
170
|
+
flex: 1;
|
|
171
|
+
font-size: 14px;
|
|
172
|
+
color: var(--vp-c-text-1);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
.echo-import-banner-chevron {
|
|
176
|
+
font-size: 12px;
|
|
177
|
+
color: var(--vp-c-text-3);
|
|
178
|
+
transition: transform 0.15s;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
.echo-import-banner-close {
|
|
182
|
+
background: none;
|
|
183
|
+
border: none;
|
|
184
|
+
color: var(--vp-c-text-3);
|
|
185
|
+
cursor: pointer;
|
|
186
|
+
font-size: 14px;
|
|
187
|
+
padding: 2px 6px;
|
|
188
|
+
border-radius: 4px;
|
|
189
|
+
}
|
|
190
|
+
.echo-import-banner-close:hover {
|
|
191
|
+
background: var(--vp-c-bg-mute);
|
|
192
|
+
color: var(--vp-c-text-1);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
.echo-import-banner-body {
|
|
196
|
+
padding: 0 16px 16px;
|
|
197
|
+
border-top: 1px solid var(--vp-c-divider);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
.echo-import-banner-desc {
|
|
201
|
+
margin: 12px 0;
|
|
202
|
+
font-size: 13px;
|
|
203
|
+
color: var(--vp-c-text-2);
|
|
204
|
+
line-height: 1.6;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
.echo-import-banner-candidates {
|
|
208
|
+
max-height: 200px;
|
|
209
|
+
overflow-y: auto;
|
|
210
|
+
margin-bottom: 12px;
|
|
211
|
+
border: 1px solid var(--vp-c-divider);
|
|
212
|
+
border-radius: 6px;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
.echo-import-candidate-item {
|
|
216
|
+
display: flex;
|
|
217
|
+
align-items: center;
|
|
218
|
+
gap: 12px;
|
|
219
|
+
padding: 6px 12px;
|
|
220
|
+
border-bottom: 1px solid var(--vp-c-divider-light);
|
|
221
|
+
font-size: 13px;
|
|
222
|
+
}
|
|
223
|
+
.echo-import-candidate-item:last-child {
|
|
224
|
+
border-bottom: none;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
.echo-import-candidate-id {
|
|
228
|
+
font-family: monospace;
|
|
229
|
+
font-weight: 600;
|
|
230
|
+
color: var(--vp-c-brand);
|
|
231
|
+
min-width: 140px;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
.echo-import-candidate-meta {
|
|
235
|
+
color: var(--vp-c-text-3);
|
|
236
|
+
flex: 1;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
.echo-import-candidate-status {
|
|
240
|
+
font-size: 12px;
|
|
241
|
+
padding: 1px 8px;
|
|
242
|
+
border-radius: 10px;
|
|
243
|
+
font-weight: 500;
|
|
244
|
+
}
|
|
245
|
+
.echo-status-new {
|
|
246
|
+
background: var(--vp-c-brand-soft);
|
|
247
|
+
color: var(--vp-c-brand);
|
|
248
|
+
}
|
|
249
|
+
.echo-status-updated {
|
|
250
|
+
background: var(--vp-c-warning-soft);
|
|
251
|
+
color: var(--vp-c-warning);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
.echo-import-banner-cli {
|
|
255
|
+
margin-bottom: 16px;
|
|
256
|
+
padding: 12px;
|
|
257
|
+
background: var(--vp-c-bg);
|
|
258
|
+
border: 1px solid var(--vp-c-divider);
|
|
259
|
+
border-radius: 6px;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
.echo-import-banner-cli-title {
|
|
263
|
+
margin: 0 0 8px;
|
|
264
|
+
font-size: 13px;
|
|
265
|
+
font-weight: 600;
|
|
266
|
+
color: var(--vp-c-text-1);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
.echo-import-banner-cli-code {
|
|
270
|
+
margin: 0 0 4px;
|
|
271
|
+
padding: 8px 12px;
|
|
272
|
+
background: var(--vp-code-block-bg);
|
|
273
|
+
border-radius: 4px;
|
|
274
|
+
font-size: 12px;
|
|
275
|
+
overflow-x: auto;
|
|
276
|
+
}
|
|
277
|
+
.echo-import-banner-cli-code code {
|
|
278
|
+
color: var(--vp-c-text-1);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
.echo-import-banner-cli-note {
|
|
282
|
+
margin: 4px 0 0;
|
|
283
|
+
font-size: 12px;
|
|
284
|
+
color: var(--vp-c-text-3);
|
|
285
|
+
}
|
|
286
|
+
.echo-import-banner-cli-note code {
|
|
287
|
+
font-size: 12px;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
.echo-import-banner-error {
|
|
291
|
+
margin-bottom: 12px;
|
|
292
|
+
padding: 8px 12px;
|
|
293
|
+
background: var(--vp-c-danger-soft);
|
|
294
|
+
color: var(--vp-c-danger);
|
|
295
|
+
border-radius: 4px;
|
|
296
|
+
font-size: 13px;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
.echo-import-banner-success {
|
|
300
|
+
margin-bottom: 12px;
|
|
301
|
+
padding: 8px 12px;
|
|
302
|
+
background: var(--vp-c-brand-soft);
|
|
303
|
+
color: var(--vp-c-brand);
|
|
304
|
+
border-radius: 4px;
|
|
305
|
+
font-size: 13px;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
.echo-import-banner-actions {
|
|
309
|
+
display: flex;
|
|
310
|
+
gap: 8px;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
.echo-import-btn {
|
|
314
|
+
padding: 8px 16px;
|
|
315
|
+
border-radius: 6px;
|
|
316
|
+
border: 1px solid var(--vp-c-divider);
|
|
317
|
+
background: var(--vp-c-bg);
|
|
318
|
+
color: var(--vp-c-text-1);
|
|
319
|
+
cursor: pointer;
|
|
320
|
+
font-size: 14px;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
.echo-import-btn-primary {
|
|
324
|
+
background: var(--vp-c-brand);
|
|
325
|
+
color: #fff;
|
|
326
|
+
border-color: var(--vp-c-brand);
|
|
327
|
+
}
|
|
328
|
+
.echo-import-btn-primary:hover {
|
|
329
|
+
background: var(--vp-c-brand-dark);
|
|
330
|
+
}
|
|
331
|
+
.echo-import-btn:disabled {
|
|
332
|
+
opacity: 0.5;
|
|
333
|
+
cursor: not-allowed;
|
|
334
|
+
}
|
|
335
|
+
</style>
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="echo-comment-chain" v-if="roots.length > 0">
|
|
3
|
+
<h3>评论回复链</h3>
|
|
4
|
+
<EchoCommentNode
|
|
5
|
+
v-for="root in roots"
|
|
6
|
+
:key="root.id"
|
|
7
|
+
:comment="root"
|
|
8
|
+
:children-map="childrenMap"
|
|
9
|
+
:depth="0"
|
|
10
|
+
:article-id="articleId"
|
|
11
|
+
:project-id="projectId"
|
|
12
|
+
:author="author"
|
|
13
|
+
@reply-submitted="handleReplySubmitted"
|
|
14
|
+
/>
|
|
15
|
+
</div>
|
|
16
|
+
</template>
|
|
17
|
+
|
|
18
|
+
<script setup lang="ts">
|
|
19
|
+
import { ref, computed, onMounted } from 'vue'
|
|
20
|
+
import { useData } from 'vitepress'
|
|
21
|
+
import { useEchoStatus } from '../lib/useEchoStatus'
|
|
22
|
+
import EchoCommentNode from './EchoCommentNode.vue'
|
|
23
|
+
|
|
24
|
+
interface CommentData {
|
|
25
|
+
id: string
|
|
26
|
+
author: string
|
|
27
|
+
date: string
|
|
28
|
+
content: string
|
|
29
|
+
quote: string | null
|
|
30
|
+
evolutionOf: string[]
|
|
31
|
+
evolutionKind: string
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const { frontmatter } = useData()
|
|
35
|
+
const articleId = computed(() => (frontmatter.value as any)?.echo?.articleId as string | undefined)
|
|
36
|
+
const projectId = computed(() => (frontmatter.value as any)?.echo?.projectId as string | null | undefined)
|
|
37
|
+
const { status } = useEchoStatus(articleId)
|
|
38
|
+
const author = computed(() => status.value?.author || 'vincent')
|
|
39
|
+
|
|
40
|
+
const comments = ref<CommentData[]>([])
|
|
41
|
+
const roots = ref<CommentData[]>([])
|
|
42
|
+
const childrenMap = ref<Map<string, CommentData[]>>(new Map())
|
|
43
|
+
|
|
44
|
+
function buildTree(items: CommentData[]) {
|
|
45
|
+
const byId = new Map<string, CommentData>()
|
|
46
|
+
for (const c of items) byId.set(c.id, c)
|
|
47
|
+
|
|
48
|
+
const children = new Map<string, CommentData[]>()
|
|
49
|
+
const rootIds = new Set(byId.keys())
|
|
50
|
+
|
|
51
|
+
for (const c of items) {
|
|
52
|
+
const parents = c.evolutionOf.filter((pid) => byId.has(pid))
|
|
53
|
+
if (parents.length > 0) {
|
|
54
|
+
rootIds.delete(c.id)
|
|
55
|
+
for (const pid of parents) {
|
|
56
|
+
if (!children.has(pid)) children.set(pid, [])
|
|
57
|
+
children.get(pid)!.push(c)
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
childrenMap.value = children
|
|
63
|
+
roots.value = [...rootIds].map((id) => byId.get(id)!).sort((a, b) => a.id.localeCompare(b.id))
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function handleReplySubmitted() {
|
|
67
|
+
setTimeout(() => location.reload(), 1200)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
onMounted(() => {
|
|
71
|
+
try {
|
|
72
|
+
const el = document.getElementById('echo-comments-data')
|
|
73
|
+
if (!el) return
|
|
74
|
+
const items = JSON.parse(el.textContent || '[]') as CommentData[]
|
|
75
|
+
comments.value = items
|
|
76
|
+
buildTree(items)
|
|
77
|
+
} catch (_) {}
|
|
78
|
+
})
|
|
79
|
+
</script>
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="echo-thread" :class="{ 'echo-thread-root': depth === 0 }">
|
|
3
|
+
<div class="echo-thread-item">
|
|
4
|
+
<div class="echo-thread-connector" v-if="depth > 0">
|
|
5
|
+
<span class="echo-evo-kind" v-if="comment.evolutionKind && comment.evolutionKind !== 'null'">
|
|
6
|
+
{{ kindLabel(comment.evolutionKind) }}
|
|
7
|
+
</span>
|
|
8
|
+
</div>
|
|
9
|
+
<div class="echo-thread-card">
|
|
10
|
+
<div class="echo-thread-head">
|
|
11
|
+
<strong>{{ comment.author }}</strong>
|
|
12
|
+
<span>{{ comment.date }}</span>
|
|
13
|
+
</div>
|
|
14
|
+
<div class="echo-thread-body" v-html="comment.content"></div>
|
|
15
|
+
<button class="echo-btn echo-reply-btn" @click="toggleReply">
|
|
16
|
+
{{ replying ? '取消回复' : '回复' }}
|
|
17
|
+
</button>
|
|
18
|
+
<div class="echo-reply-form" v-if="replying">
|
|
19
|
+
<textarea
|
|
20
|
+
v-model="replyText"
|
|
21
|
+
:placeholder="`回复 ${comment.id}...`"
|
|
22
|
+
rows="2"
|
|
23
|
+
></textarea>
|
|
24
|
+
<div class="echo-reply-btns">
|
|
25
|
+
<button
|
|
26
|
+
class="echo-btn"
|
|
27
|
+
:disabled="!replyText.trim() || submitting"
|
|
28
|
+
@click="submitReply"
|
|
29
|
+
>
|
|
30
|
+
{{ submitting ? '提交中...' : '提交回复' }}
|
|
31
|
+
</button>
|
|
32
|
+
<button class="echo-btn" @click="replying = false">取消</button>
|
|
33
|
+
</div>
|
|
34
|
+
<span v-if="replyError" class="echo-inline-error">{{ replyError }}</span>
|
|
35
|
+
</div>
|
|
36
|
+
</div>
|
|
37
|
+
</div>
|
|
38
|
+
<div class="echo-thread-children">
|
|
39
|
+
<EchoCommentNode
|
|
40
|
+
v-for="child in children"
|
|
41
|
+
:key="child.id"
|
|
42
|
+
:comment="child"
|
|
43
|
+
:children-map="childrenMap"
|
|
44
|
+
:depth="depth + 1"
|
|
45
|
+
:article-id="articleId"
|
|
46
|
+
:project-id="projectId"
|
|
47
|
+
:author="author"
|
|
48
|
+
@reply-submitted="$emit('replySubmitted')"
|
|
49
|
+
/>
|
|
50
|
+
</div>
|
|
51
|
+
</div>
|
|
52
|
+
</template>
|
|
53
|
+
|
|
54
|
+
<script setup lang="ts">
|
|
55
|
+
import { computed, ref } from 'vue'
|
|
56
|
+
import { postComment } from '../lib/echo-api'
|
|
57
|
+
|
|
58
|
+
interface CommentData {
|
|
59
|
+
id: string
|
|
60
|
+
author: string
|
|
61
|
+
date: string
|
|
62
|
+
content: string
|
|
63
|
+
quote: string | null
|
|
64
|
+
evolutionOf: string[]
|
|
65
|
+
evolutionKind: string
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const props = defineProps<{
|
|
69
|
+
comment: CommentData
|
|
70
|
+
childrenMap: Map<string, CommentData[]>
|
|
71
|
+
depth: number
|
|
72
|
+
articleId: string | undefined
|
|
73
|
+
projectId: string | null | undefined
|
|
74
|
+
author: string
|
|
75
|
+
}>()
|
|
76
|
+
|
|
77
|
+
const emit = defineEmits<{
|
|
78
|
+
replySubmitted: []
|
|
79
|
+
}>()
|
|
80
|
+
|
|
81
|
+
const children = computed(() => props.childrenMap.get(props.comment.id) || [])
|
|
82
|
+
|
|
83
|
+
const replying = ref(false)
|
|
84
|
+
const replyText = ref('')
|
|
85
|
+
const submitting = ref(false)
|
|
86
|
+
const replyError = ref('')
|
|
87
|
+
|
|
88
|
+
const kindLabels: Record<string, string> = {
|
|
89
|
+
expands: '扩展',
|
|
90
|
+
contradicts: '反驳',
|
|
91
|
+
refines: '深化',
|
|
92
|
+
supersedes: '替代',
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function kindLabel(kind: string): string {
|
|
96
|
+
return kindLabels[kind] || kind
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function toggleReply() {
|
|
100
|
+
replying.value = !replying.value
|
|
101
|
+
replyText.value = ''
|
|
102
|
+
replyError.value = ''
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function submitReply() {
|
|
106
|
+
if (!props.articleId || !replyText.value.trim()) return
|
|
107
|
+
submitting.value = true
|
|
108
|
+
replyError.value = ''
|
|
109
|
+
try {
|
|
110
|
+
await postComment({
|
|
111
|
+
articleId: props.articleId,
|
|
112
|
+
comment: replyText.value.trim(),
|
|
113
|
+
scope: 'article',
|
|
114
|
+
author: props.author,
|
|
115
|
+
evolutionOf: [props.comment.id],
|
|
116
|
+
evolutionKind: 'expands',
|
|
117
|
+
projectId: props.projectId ?? null,
|
|
118
|
+
})
|
|
119
|
+
emit('replySubmitted')
|
|
120
|
+
} catch (err: any) {
|
|
121
|
+
replyError.value = err.message || '提交失败'
|
|
122
|
+
} finally {
|
|
123
|
+
submitting.value = false
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
</script>
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="echo-reply-form" v-if="activeReplyId">
|
|
3
|
+
<textarea
|
|
4
|
+
v-model="replyText"
|
|
5
|
+
:placeholder="`回复 ${activeReplyId}...`"
|
|
6
|
+
rows="2"
|
|
7
|
+
></textarea>
|
|
8
|
+
<div class="echo-reply-btns">
|
|
9
|
+
<button class="echo-btn" :disabled="!replyText.trim() || submitting" @click="submitReply">
|
|
10
|
+
{{ submitting ? '提交中...' : '提交回复' }}
|
|
11
|
+
</button>
|
|
12
|
+
<button class="echo-btn" @click="cancelReply">取消</button>
|
|
13
|
+
</div>
|
|
14
|
+
<span v-if="replyError" class="echo-inline-error">{{ replyError }}</span>
|
|
15
|
+
</div>
|
|
16
|
+
</template>
|
|
17
|
+
|
|
18
|
+
<script setup lang="ts">
|
|
19
|
+
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
|
20
|
+
import { useData } from 'vitepress'
|
|
21
|
+
import { useEchoStatus } from '../lib/useEchoStatus'
|
|
22
|
+
import { postComment } from '../lib/echo-api'
|
|
23
|
+
|
|
24
|
+
const { frontmatter } = useData()
|
|
25
|
+
const articleId = computed(() => (frontmatter.value as any)?.echo?.articleId as string | undefined)
|
|
26
|
+
const projectId = computed(() => (frontmatter.value as any)?.echo?.projectId as string | null | undefined)
|
|
27
|
+
const { status } = useEchoStatus(articleId)
|
|
28
|
+
|
|
29
|
+
const activeReplyId = ref('')
|
|
30
|
+
const replyText = ref('')
|
|
31
|
+
const submitting = ref(false)
|
|
32
|
+
const replyError = ref('')
|
|
33
|
+
|
|
34
|
+
function attachReplyButtons() {
|
|
35
|
+
const comments = document.querySelectorAll<HTMLElement>('.echo-comment[data-comment-id]')
|
|
36
|
+
for (const el of comments) {
|
|
37
|
+
if (el.querySelector('.echo-reply-btn')) continue
|
|
38
|
+
const btn = document.createElement('button')
|
|
39
|
+
btn.className = 'echo-btn echo-reply-btn'
|
|
40
|
+
btn.textContent = '回复'
|
|
41
|
+
btn.addEventListener('click', () => {
|
|
42
|
+
const id = el.dataset.commentId
|
|
43
|
+
if (!id) return
|
|
44
|
+
activeReplyId.value = id
|
|
45
|
+
replyText.value = ''
|
|
46
|
+
replyError.value = ''
|
|
47
|
+
})
|
|
48
|
+
el.appendChild(btn)
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function submitReply() {
|
|
53
|
+
if (!activeReplyId.value || !replyText.value.trim() || !articleId.value) return
|
|
54
|
+
submitting.value = true
|
|
55
|
+
replyError.value = ''
|
|
56
|
+
try {
|
|
57
|
+
await postComment({
|
|
58
|
+
articleId: articleId.value,
|
|
59
|
+
comment: replyText.value.trim(),
|
|
60
|
+
scope: 'article',
|
|
61
|
+
author: status.value?.author,
|
|
62
|
+
evolutionOf: [activeReplyId.value],
|
|
63
|
+
evolutionKind: 'expands',
|
|
64
|
+
projectId: projectId.value ?? null,
|
|
65
|
+
})
|
|
66
|
+
replyText.value = ''
|
|
67
|
+
activeReplyId.value = ''
|
|
68
|
+
replyError.value = '回复已提交,即将刷新...'
|
|
69
|
+
setTimeout(() => location.reload(), 1200)
|
|
70
|
+
} catch (err: any) {
|
|
71
|
+
replyError.value = err.message || '提交失败'
|
|
72
|
+
} finally {
|
|
73
|
+
submitting.value = false
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function cancelReply() {
|
|
78
|
+
activeReplyId.value = ''
|
|
79
|
+
replyText.value = ''
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
let observer: MutationObserver | null = null
|
|
83
|
+
onMounted(() => {
|
|
84
|
+
attachReplyButtons()
|
|
85
|
+
|
|
86
|
+
observer = new MutationObserver(() => {
|
|
87
|
+
attachReplyButtons()
|
|
88
|
+
})
|
|
89
|
+
observer.observe(document.body, { childList: true, subtree: true })
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
onUnmounted(() => {
|
|
93
|
+
observer?.disconnect()
|
|
94
|
+
})
|
|
95
|
+
</script>
|