feat(extension): popup 改紧凑视图,markdown 详情挪到侧边栏

之前 popup 直接在 400px 宽里渲染 markdown,看起来很挤。改成:
- popup:标题 + 封面缩略图 + 进度条 + 「在侧边栏查看」按钮,不再渲染 markdown
- 提交「生成笔记」后自动调 chrome.sidePanel.open 把侧边栏拉起来
- 最近任务列表显示标题(拿到时)而非 URL
- 新增 logic/api.resolveImageUrl:相对 /static 路径拼后端域名;hdslb / byteimg / kpcdn / ytimg 等带 referer 校验的封面走后端 /api/image_proxy 转发,避免 CORS / 防盗链问题
- 侧边栏顶部同样展示封面 + 标题 + 跳原片链接,方便用户辨识当前任务

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
huangjianwu
2026-05-07 12:11:48 +08:00
parent e694b460e8
commit 0793949516
3 changed files with 106 additions and 12 deletions

View File

@@ -213,3 +213,16 @@ export function absolutizeMarkdownImages(md: string): string {
const base = backendUrl()
return md.replace(/!\[([^\]]*)\]\((\/static\/[^)]+)\)/g, (_, alt, path) => `![${alt}](${base}${path})`)
}
// 单个图片 URL 的处理:相对路径 → 拼后端域名B 站等带防盗链的封面 → 走后端 image_proxy
export function resolveImageUrl(url: string | undefined | null): string {
if (!url)
return ''
const base = backendUrl()
if (url.startsWith('/'))
return `${base}${url}`
// B 站封面、抖音封面等会做 referer 校验;走后端代理
if (/(hdslb|byteimg|kpcdn|akamaized|ytimg)\.com/i.test(url))
return `${base}/api/image_proxy?url=${encodeURIComponent(url)}`
return url
}

View File

@@ -2,11 +2,12 @@
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { detectPlatform } from '~/logic/platform'
import { settings, settingsReady, tasks, tasksReady, upsertTask } from '~/logic/storage'
import { generateNote, getTaskStatus } from '~/logic/api'
import { generateNote, getTaskStatus, resolveImageUrl } from '~/logic/api'
import type { TaskRecord } from '~/logic/types'
const tabUrl = ref<string>('')
const tabTitle = ref<string>('')
const tabId = ref<number | undefined>(undefined)
const platform = computed(() => detectPlatform(tabUrl.value))
const supported = computed(() => platform.value !== null)
@@ -22,6 +23,7 @@ async function loadActiveTab() {
const [tab] = await browser.tabs.query({ active: true, currentWindow: true })
tabUrl.value = tab?.url ?? ''
tabTitle.value = tab?.title ?? ''
tabId.value = tab?.id
}
catch (e) {
console.warn('无法读取当前 tab:', e)
@@ -87,6 +89,8 @@ async function start() {
updatedAt: Date.now(),
})
poll(task_id)
// 提交后顺手把侧边栏拉起来,免得用户来回切窗口
openSidePanel()
}
catch (e) {
errorMsg.value = (e as Error).message
@@ -100,6 +104,22 @@ function openOptions() {
browser.runtime.openOptionsPage()
}
async function openSidePanel() {
// 只能在用户操作触发的同步上下文里调,且需要明确的 tabId
try {
const target = tabId.value ?? (await browser.tabs.query({ active: true, currentWindow: true }))[0]?.id
if (target == null)
return
// @ts-expect-error sidePanel 类型在 polyfill 中不全
if (typeof chrome !== 'undefined' && chrome.sidePanel?.open)
// @ts-expect-error see above
await chrome.sidePanel.open({ tabId: target })
}
catch (err) {
console.warn('打开侧边栏失败:', err)
}
}
function selectTask(id: string) {
activeTaskId.value = id
const t = tasks.value?.find(x => x.taskId === id)
@@ -107,10 +127,19 @@ function selectTask(id: string) {
poll(id)
}
const activeCover = computed(() => activeTask.value?.result?.audio_meta?.cover_url as string | undefined)
const activeTitle = computed(() => (activeTask.value?.result?.audio_meta?.title as string | undefined) || tabTitle.value)
function fmtTime(ts?: number) {
if (!ts)
return ''
const d = new Date(ts)
return `${d.getMonth() + 1}/${d.getDate()} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
}
onMounted(async () => {
await Promise.all([settingsReady, tasksReady])
await loadActiveTab()
// 如果有进行中的任务,恢复轮询
const running = tasks.value?.find(t => t.status !== 'SUCCESS' && t.status !== 'FAILED')
if (running) {
activeTaskId.value = running.taskId
@@ -180,12 +209,40 @@ onUnmounted(() => {
</div>
<section v-if="activeTask" class="flex flex-col gap-2">
<div v-if="activeCover || activeTitle" class="flex gap-3 items-start">
<img
v-if="activeCover"
:src="resolveImageUrl(activeCover)"
class="w-20 h-12 object-cover rounded border bg-gray-100 shrink-0"
alt="cover"
@error="($event.target as HTMLImageElement).style.display = 'none'"
>
<div class="flex-1 min-w-0">
<div class="text-sm font-medium leading-snug line-clamp-2 break-words" :title="activeTitle">
{{ activeTitle || '(未取到标题)' }}
</div>
<div class="text-xs text-gray-400 mt-0.5">
{{ fmtTime(activeTask.updatedAt) }}
</div>
</div>
</div>
<TaskProgress :status="activeTask.status" :message="activeTask.message" />
<MarkdownView
v-if="activeTask.status === 'SUCCESS' && activeTask.result?.markdown"
:markdown="activeTask.result.markdown"
:title="activeTask.result.audio_meta?.title || tabTitle"
/>
<button
v-if="activeTask.status === 'SUCCESS'"
class="btn-primary"
@click="openSidePanel"
>
在侧边栏查看笔记 / 思维导图 / AI 问答
</button>
<button
v-else
class="btn-secondary"
@click="openSidePanel"
>
在侧边栏看进度
</button>
</section>
<details v-if="(tasks?.length ?? 0) > 0" class="text-xs">
@@ -198,8 +255,10 @@ onUnmounted(() => {
:class="{ 'bg-blue-50': t.taskId === activeTaskId }"
@click="selectTask(t.taskId)"
>
<span class="truncate flex-1" :title="t.videoUrl">{{ t.result?.audio_meta?.title || t.videoUrl }}</span>
<span class="text-gray-500">{{ t.status }}</span>
<span class="truncate flex-1" :title="t.videoUrl">
{{ (t.result?.audio_meta as { title?: string } | undefined)?.title || t.videoUrl }}
</span>
<span class="text-gray-500 shrink-0">{{ t.status }}</span>
</li>
</ul>
</details>
@@ -209,4 +268,5 @@ onUnmounted(() => {
<style>
.btn-primary { @apply bg-blue-600 text-white px-3 py-1.5 rounded hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed text-sm; }
.btn-secondary { @apply bg-gray-100 text-gray-700 px-2 py-1 rounded hover:bg-gray-200 text-xs; }
.line-clamp-2 { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
</style>

View File

@@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { getTaskStatus } from '~/logic/api'
import { getTaskStatus, resolveImageUrl } from '~/logic/api'
import { settings, settingsReady, tasks, tasksReady, upsertTask } from '~/logic/storage'
import type { TaskRecord } from '~/logic/types'
@@ -83,8 +83,24 @@ onUnmounted(() => {
</section>
<section v-else class="flex-1 flex flex-col gap-3 p-3 overflow-hidden">
<div class="text-xs text-gray-500 truncate" :title="activeTask.videoUrl">
{{ activeTask.videoUrl }}
<div class="flex gap-3 items-start">
<img
v-if="activeTask.result?.audio_meta?.cover_url"
:src="resolveImageUrl(activeTask.result.audio_meta.cover_url as string)"
class="w-24 h-14 object-cover rounded border bg-gray-100 shrink-0"
alt="cover"
@error="($event.target as HTMLImageElement).style.display = 'none'"
>
<div class="flex-1 min-w-0">
<div class="text-sm font-medium leading-snug line-clamp-2 break-words">
{{ (activeTask.result?.audio_meta as { title?: string } | undefined)?.title || activeTask.videoUrl }}
</div>
<a
class="text-xs text-blue-600 hover:underline break-all line-clamp-1"
:href="activeTask.videoUrl"
target="_blank"
>{{ activeTask.videoUrl }}</a>
</div>
</div>
<TaskProgress :status="activeTask.status" :message="activeTask.message" />
@@ -144,3 +160,8 @@ onUnmounted(() => {
</details>
</main>
</template>
<style>
.line-clamp-2 { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.line-clamp-1 { display: -webkit-box; -webkit-line-clamp: 1; -webkit-box-orient: vertical; overflow: hidden; }
</style>