Skip to content
This repository was archived by the owner on May 29, 2026. It is now read-only.

Commit d03256b

Browse files
authored
feat(shorthand): multi-enrichment preview for recently entries (#1044)
The recently model now carries a URL-keyed enrichments map instead of a single enrichment. The composer detects and previews every URL, the list renders one card per enrichment, and retry re-resolves per URL.
1 parent 8ff334a commit d03256b

4 files changed

Lines changed: 122 additions & 122 deletions

File tree

apps/admin/src/api/recently.ts

Lines changed: 1 addition & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2,20 +2,8 @@ import type { RecentlyModel } from '~/models/recently'
22

33
import { request } from '~/utils/request'
44

5-
export type RecentlyType =
6-
| 'text'
7-
| 'link'
8-
| 'book'
9-
| 'media'
10-
| 'music'
11-
| 'github'
12-
| 'academic'
13-
| 'code'
14-
155
export interface RecentlyCreatePayload {
16-
type?: RecentlyType
17-
content?: string
18-
metadata?: Record<string, unknown>
6+
content: string
197
}
208

219
export type RecentlyUpdatePayload = RecentlyCreatePayload

apps/admin/src/components/shorthand/index.tsx

Lines changed: 91 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -10,26 +10,29 @@ import { recentlyApi } from '~/api'
1010
import { enrichmentApi } from '~/api/enrichment'
1111
import { EnrichmentCard } from '~/components/enrichment-card'
1212

13-
const URL_REGEX = /https?:\/\/\S+/i
14-
// Trailing punctuation that should not be part of the URL (ASCII + CJK)
13+
const URL_REGEX = /https?:\/\/\S+/gi
1514
const URL_TAIL_TRIM = /[)\].,;:!?'"`>}]+$/
1615

17-
function firstUrl(text: string): string | null {
18-
const m = text.match(URL_REGEX)
19-
if (!m) return null
20-
let url = m[0]
21-
// Strip trailing punctuation iteratively (handles "(url).")
22-
while (URL_TAIL_TRIM.test(url)) {
23-
url = url.replace(URL_TAIL_TRIM, '')
16+
function extractUrls(text: string): string[] {
17+
const matches = text.match(URL_REGEX)
18+
if (!matches) return []
19+
const seen = new Set<string>()
20+
const result: string[] = []
21+
for (let url of matches) {
22+
while (URL_TAIL_TRIM.test(url)) {
23+
url = url.replace(URL_TAIL_TRIM, '')
24+
}
25+
if (url && !seen.has(url)) {
26+
seen.add(url)
27+
result.push(url)
28+
}
2429
}
25-
return url || null
30+
return result
2631
}
2732

2833
function cleanErrorMessage(raw: string | null | undefined): string {
2934
if (!raw) return '解析失败'
30-
// Strip embedded URLs (often docs links) and excess whitespace
3135
let msg = raw.replace(/https?:\/\/\S+/g, '').trim()
32-
// Common patterns we shorten
3336
if (/\(404\)|\b404\b/.test(msg)) {
3437
return '404 — 资源不存在,或私有内容无访问权(请检查 GitHub Token 等凭证)'
3538
}
@@ -42,21 +45,18 @@ function cleanErrorMessage(raw: string | null | undefined): string {
4245
if (/Token missing/i.test(msg)) {
4346
return '此 provider 需配置凭证(请至「第三方集成」设置)'
4447
}
45-
// Generic fallback — cap length
4648
msg = msg.replace(/[\s-]+$/, '').trim()
4749
return msg.length > 100 ? msg.slice(0, 100) + '…' : msg || '解析失败'
4850
}
4951

50-
function buildPayload(text: string): {
51-
type: 'text' | 'link'
52-
content: string
53-
metadata?: { url: string }
54-
} {
55-
const url = firstUrl(text)
56-
if (url) {
57-
return { type: 'link', content: text, metadata: { url } }
58-
}
59-
return { type: 'text', content: text }
52+
function buildPayload(text: string): { content: string } {
53+
return { content: text }
54+
}
55+
56+
interface UrlPreviewState {
57+
loading: boolean
58+
result: EnrichmentResult | null
59+
error: string | null
6060
}
6161

6262
const ShorthandForm = defineComponent({
@@ -70,56 +70,61 @@ const ShorthandForm = defineComponent({
7070
},
7171
setup(props) {
7272
const text = ref(props.initialContent)
73-
const detectedUrl = ref<string | null>(null)
74-
const previewLoading = ref(false)
75-
const previewResult = ref<EnrichmentResult | null>(null)
76-
const previewError = ref<string | null>(null)
73+
const detectedUrls = ref<string[]>([])
74+
const previewStates = ref<Map<string, UrlPreviewState>>(new Map())
7775
let debounceTimer: ReturnType<typeof setTimeout> | null = null
76+
let generation = 0
7877

79-
const triggerResolve = (url: string) => {
80-
previewLoading.value = true
81-
previewError.value = null
82-
// cancel previous in-flight via stale check
83-
const myUrl = url
78+
const resolveUrl = (url: string, gen: number) => {
79+
const state: UrlPreviewState = { loading: true, result: null, error: null }
80+
previewStates.value = new Map(previewStates.value).set(url, state)
8481
enrichmentApi
8582
.resolve(url)
8683
.then((result) => {
87-
if (detectedUrl.value !== myUrl) return
88-
previewResult.value = result
89-
previewError.value = null
84+
if (gen !== generation) return
85+
previewStates.value = new Map(previewStates.value).set(url, {
86+
loading: false,
87+
result,
88+
error: null,
89+
})
9090
})
9191
.catch((e: any) => {
92-
if (detectedUrl.value !== myUrl) return
93-
previewResult.value = null
94-
previewError.value = e?.message || '解析失败'
95-
})
96-
.finally(() => {
97-
if (detectedUrl.value !== myUrl) return
98-
previewLoading.value = false
92+
if (gen !== generation) return
93+
previewStates.value = new Map(previewStates.value).set(url, {
94+
loading: false,
95+
result: null,
96+
error: e?.message || '解析失败',
97+
})
9998
})
10099
}
101100

102101
watch(
103102
text,
104103
(val) => {
105104
props.onUpdate(val)
106-
const url = firstUrl(val)
107-
if (url !== detectedUrl.value) {
108-
detectedUrl.value = url
109-
previewResult.value = null
110-
previewError.value = null
111-
previewLoading.value = false
112-
if (debounceTimer) clearTimeout(debounceTimer)
113-
if (url) {
114-
debounceTimer = setTimeout(() => triggerResolve(url), 500)
115-
}
105+
const urls = extractUrls(val)
106+
const prev = detectedUrls.value
107+
const same =
108+
urls.length === prev.length && urls.every((u, i) => u === prev[i])
109+
if (same) return
110+
111+
detectedUrls.value = urls
112+
previewStates.value = new Map()
113+
if (debounceTimer) clearTimeout(debounceTimer)
114+
if (urls.length > 0) {
115+
generation++
116+
const gen = generation
117+
debounceTimer = setTimeout(() => {
118+
for (const url of urls) resolveUrl(url, gen)
119+
}, 500)
116120
}
117121
},
118122
{ immediate: true },
119123
)
120124

121125
onBeforeUnmount(() => {
122126
if (debounceTimer) clearTimeout(debounceTimer)
127+
generation++
123128
})
124129

125130
return () => (
@@ -132,33 +137,43 @@ const ShorthandForm = defineComponent({
132137
autosize={{ minRows: 4, maxRows: 12 }}
133138
/>
134139

135-
{detectedUrl.value && (
136-
<div>
137-
<div class="mb-1.5 flex items-center gap-2 text-xs text-neutral-500 dark:text-neutral-400">
138-
<span>检测到链接:</span>
139-
<code class="truncate rounded bg-neutral-100 px-1.5 py-0.5 font-mono text-[11px] dark:bg-neutral-800">
140-
{detectedUrl.value}
141-
</code>
142-
{previewLoading.value && (
143-
<LoaderIcon class="size-3 animate-spin" aria-hidden="true" />
144-
)}
145-
</div>
140+
{detectedUrls.value.length > 0 && (
141+
<div class="space-y-2">
142+
{detectedUrls.value.map((url) => {
143+
const state = previewStates.value.get(url)
144+
return (
145+
<div key={url}>
146+
<div class="mb-1.5 flex items-center gap-2 text-xs text-neutral-500 dark:text-neutral-400">
147+
<span>检测到链接:</span>
148+
<code class="truncate rounded bg-neutral-100 px-1.5 py-0.5 font-mono text-[11px] dark:bg-neutral-800">
149+
{url}
150+
</code>
151+
{state?.loading && (
152+
<LoaderIcon
153+
class="size-3 animate-spin"
154+
aria-hidden="true"
155+
/>
156+
)}
157+
</div>
146158

147-
{previewResult.value && (
148-
<EnrichmentCard enrichment={previewResult.value} />
149-
)}
159+
{state?.result && (
160+
<EnrichmentCard enrichment={state.result} />
161+
)}
150162

151-
{previewError.value && !previewLoading.value && (
152-
<div class="rounded-lg border border-neutral-200 bg-neutral-50 px-3 py-2 text-xs text-neutral-500 dark:border-neutral-800 dark:bg-neutral-900 dark:text-neutral-400">
153-
<div class="font-medium text-neutral-700 dark:text-neutral-300">
154-
未识别该链接
155-
</div>
156-
<div class="mt-0.5">
157-
{cleanErrorMessage(previewError.value)}
163+
{state?.error && !state.loading && (
164+
<div class="rounded-lg border border-neutral-200 bg-neutral-50 px-3 py-2 text-xs text-neutral-500 dark:border-neutral-800 dark:bg-neutral-900 dark:text-neutral-400">
165+
<div class="font-medium text-neutral-700 dark:text-neutral-300">
166+
未识别该链接
167+
</div>
168+
<div class="mt-0.5">{cleanErrorMessage(state.error)}</div>
169+
<div class="mt-1 text-neutral-400">
170+
仍可保存,按链接处理。
171+
</div>
172+
</div>
173+
)}
158174
</div>
159-
<div class="mt-1 text-neutral-400">仍可保存,按链接处理。</div>
160-
</div>
161-
)}
175+
)
176+
})}
162177
</div>
163178
)}
164179
</div>

apps/admin/src/models/recently.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,7 @@ export interface RecentlyModel {
2222
refId?: string
2323
refType?: RecentlyRefTypes
2424

25-
enrichmentProvider?: string | null
26-
enrichmentExternalId?: string | null
27-
enrichment?: EnrichmentResult | null
25+
enrichments?: Record<string, EnrichmentResult>
2826

2927
up: number
3028
down: number

apps/admin/src/views/shorthand/index.tsx

Lines changed: 29 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -57,35 +57,30 @@ const RecentlyItem = defineComponent({
5757
},
5858
onEnrichmentUpdate: {
5959
type: Function as PropType<
60-
(next: NonNullable<RecentlyModel['enrichment']>) => void
60+
(url: string, next: NonNullable<RecentlyModel['enrichments']>[string]) => void
6161
>,
6262
required: true,
6363
},
6464
},
6565
setup(props) {
6666
const totalVotes = computed(() => props.item.up + props.item.down)
67-
const retrying = ref(false)
68-
const handleRetryEnrichment = async () => {
69-
const provider = props.item.enrichmentProvider
70-
const externalId = props.item.enrichmentExternalId
71-
if (!provider || !externalId) return
72-
retrying.value = true
67+
const retryingUrls = ref<Set<string>>(new Set())
68+
69+
const handleRetryUrl = async (url: string) => {
70+
retryingUrls.value = new Set(retryingUrls.value).add(url)
7371
try {
74-
const result = await enrichmentApi.refresh(provider, externalId)
75-
props.onEnrichmentUpdate(result)
72+
const result = await enrichmentApi.resolve(url)
73+
props.onEnrichmentUpdate(url, result)
7674
toast.success('已刷新')
7775
} catch (e: any) {
7876
toast.error(e?.message || '刷新失败')
7977
} finally {
80-
retrying.value = false
78+
const next = new Set(retryingUrls.value)
79+
next.delete(url)
80+
retryingUrls.value = next
8181
}
8282
}
83-
const enrichmentFailed = computed(
84-
() =>
85-
!!props.item.enrichmentProvider &&
86-
!!props.item.enrichmentExternalId &&
87-
!props.item.enrichment,
88-
)
83+
8984
const upPercentage = computed(() =>
9085
totalVotes.value > 0
9186
? Math.round((props.item.up / totalVotes.value) * 100)
@@ -103,18 +98,19 @@ const RecentlyItem = defineComponent({
10398
<p class={styles.text}>{props.item.content}</p>
10499
</div>
105100

106-
{(props.item.enrichment || enrichmentFailed.value) && (
107-
<div class="mt-2">
108-
<EnrichmentCard
109-
enrichment={props.item.enrichment}
110-
provider={props.item.enrichmentProvider ?? undefined}
111-
externalId={props.item.enrichmentExternalId ?? undefined}
112-
failed={enrichmentFailed.value}
113-
retrying={retrying.value}
114-
onRetry={handleRetryEnrichment}
115-
/>
116-
</div>
117-
)}
101+
{props.item.enrichments &&
102+
Object.keys(props.item.enrichments).length > 0 && (
103+
<div class="mt-2 space-y-2">
104+
{Object.entries(props.item.enrichments).map(([url, enrichment]) => (
105+
<EnrichmentCard
106+
key={url}
107+
enrichment={enrichment}
108+
retrying={retryingUrls.value.has(url)}
109+
onRetry={() => handleRetryUrl(url)}
110+
/>
111+
))}
112+
</div>
113+
)}
118114

119115
{props.item.ref && props.item.refType && (
120116
<div class={styles.reference}>
@@ -346,8 +342,11 @@ export default defineComponent({
346342
toast.success('删除成功')
347343
data.value.splice(data.value.indexOf(item), 1)
348344
}}
349-
onEnrichmentUpdate={(next) => {
350-
data.value[index] = { ...item, enrichment: next }
345+
onEnrichmentUpdate={(url, next) => {
346+
data.value[index] = {
347+
...item,
348+
enrichments: { ...item.enrichments, [url]: next },
349+
}
351350
}}
352351
/>
353352
))}

0 commit comments

Comments
 (0)