mirror of
https://github.com/JefferyHcool/BiliNote.git
synced 2026-05-18 01:17:36 +08:00
feat(extension): P2 视频页悬浮按钮 + 右键菜单 + cookie 直通;P3 侧边栏首版
- contentScripts: 仅在支持的视频平台(B 站 / YouTube / 抖音 / 快手)注入悬浮 BiliNote 按钮,点击通过 webext-bridge 发 'bilinote-start' 给 background - background: 处理 bilinote-start 与右键菜单点击;调 /api/generate_note;写 chrome.storage;自动打开侧边栏。logic/storage 是 Vue 反应式版本,service worker 不能 import,因此把常量抽到 logic/constants.ts - contextMenus: onInstalled 时注册"用 BiliNote 总结此视频",限定 4 个支持平台域名 - 浏览器 Cookie 同步:options 页加按钮,按平台读 chrome.cookies.getAll,序列化为 'name=value; ...' 后 POST 给后端 /api/update_downloader_cookie。chrome.cookies + contextMenus 权限补到 manifest - 侧边栏(P3 首版):从 storage 读最近任务并轮询,复用 TaskProgress + MarkdownView。markmap 思维导图与 RAG 问答推到后续 - 修 P1 endpoint 拼错的 bug:/api/get_models_by_provider 实际是 /api/model_enable,404 来自这里 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,7 @@
|
||||
import { onMessage } from 'webext-bridge/background'
|
||||
import type { Settings, TaskRecord } from '~/logic/types'
|
||||
import { DEFAULT_SETTINGS, MAX_TASKS, SETTINGS_KEY, TASKS_KEY } from '~/logic/constants'
|
||||
import { detectPlatform } from '~/logic/platform'
|
||||
|
||||
// only on dev mode
|
||||
if (import.meta.hot) {
|
||||
@@ -8,16 +11,162 @@ if (import.meta.hot) {
|
||||
import('./contentScriptHMR')
|
||||
}
|
||||
|
||||
// 工具栏图标点击 → 打开 popup(默认行为,无需配置)
|
||||
// side panel 留给 P3 阶段:在那时把 popup 替换成"action 行为"或加单独命令
|
||||
// 此处不开启 openPanelOnActionClick,否则会绕过 default_popup
|
||||
// ---------- 直接操作 chrome.storage(service worker 里别用 Vue 反应式)----------
|
||||
|
||||
browser.runtime.onInstalled.addListener((): void => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('BiliNote extension installed')
|
||||
async function readSettings(): Promise<Settings> {
|
||||
const obj = await browser.storage.local.get(SETTINGS_KEY)
|
||||
const raw = obj[SETTINGS_KEY] as string | undefined
|
||||
if (!raw)
|
||||
return { ...DEFAULT_SETTINGS }
|
||||
try {
|
||||
return { ...DEFAULT_SETTINGS, ...(JSON.parse(raw) as Partial<Settings>) }
|
||||
}
|
||||
catch {
|
||||
return { ...DEFAULT_SETTINGS }
|
||||
}
|
||||
}
|
||||
|
||||
async function readTasks(): Promise<TaskRecord[]> {
|
||||
const obj = await browser.storage.local.get(TASKS_KEY)
|
||||
const raw = obj[TASKS_KEY] as string | undefined
|
||||
if (!raw)
|
||||
return []
|
||||
try {
|
||||
return JSON.parse(raw) as TaskRecord[]
|
||||
}
|
||||
catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
async function writeTasks(tasks: TaskRecord[]) {
|
||||
await browser.storage.local.set({ [TASKS_KEY]: JSON.stringify(tasks.slice(0, MAX_TASKS)) })
|
||||
}
|
||||
|
||||
async function upsertTask(record: TaskRecord) {
|
||||
const tasks = await readTasks()
|
||||
const idx = tasks.findIndex(t => t.taskId === record.taskId)
|
||||
if (idx >= 0)
|
||||
tasks.splice(idx, 1, { ...tasks[idx], ...record })
|
||||
else
|
||||
tasks.unshift(record)
|
||||
await writeTasks(tasks)
|
||||
}
|
||||
|
||||
// ---------- 启动任务 ----------
|
||||
|
||||
async function startTask(url: string): Promise<{ ok: boolean, taskId?: string, error?: string }> {
|
||||
const platform = detectPlatform(url)
|
||||
if (!platform)
|
||||
return { ok: false, error: '当前链接不是支持的视频平台' }
|
||||
|
||||
const settings = await readSettings()
|
||||
if (!settings.providerId || !settings.modelName)
|
||||
return { ok: false, error: '请先在设置页选择供应商与模型' }
|
||||
|
||||
const backend = settings.backendUrl.replace(/\/$/, '')
|
||||
try {
|
||||
const res = await fetch(`${backend}/api/generate_note`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
video_url: url,
|
||||
platform,
|
||||
quality: settings.quality,
|
||||
provider_id: settings.providerId,
|
||||
model_name: settings.modelName,
|
||||
screenshot: settings.screenshot,
|
||||
link: settings.link,
|
||||
style: settings.style || undefined,
|
||||
format: [
|
||||
...(settings.screenshot ? ['screenshot'] : []),
|
||||
...(settings.link ? ['link'] : []),
|
||||
],
|
||||
}),
|
||||
})
|
||||
if (!res.ok)
|
||||
return { ok: false, error: `HTTP ${res.status}` }
|
||||
const body = await res.json() as { code: number, msg: string, data: { task_id: string } }
|
||||
if (body.code !== 0)
|
||||
return { ok: false, error: body.msg }
|
||||
|
||||
await upsertTask({
|
||||
taskId: body.data.task_id,
|
||||
videoUrl: url,
|
||||
platform,
|
||||
status: 'PENDING',
|
||||
message: '已提交',
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
})
|
||||
return { ok: true, taskId: body.data.task_id }
|
||||
}
|
||||
catch (e) {
|
||||
return { ok: false, error: (e as Error).message }
|
||||
}
|
||||
}
|
||||
|
||||
async function openSidePanelInTab(tabId?: number) {
|
||||
try {
|
||||
// @ts-expect-error chrome.sidePanel 类型在 webextension-polyfill 中尚未补全
|
||||
if (typeof chrome !== 'undefined' && chrome.sidePanel?.open && tabId !== undefined)
|
||||
// @ts-expect-error see above
|
||||
await chrome.sidePanel.open({ tabId })
|
||||
}
|
||||
catch (err) {
|
||||
console.warn('打开侧边栏失败:', err)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 消息桥 ----------
|
||||
|
||||
onMessage<{ url: string }, 'bilinote-start'>('bilinote-start', async ({ data, sender }) => {
|
||||
const result = await startTask(data.url)
|
||||
// 成功就把侧边栏拉起来给用户看进度
|
||||
if (result.ok)
|
||||
await openSidePanelInTab(sender?.tabId)
|
||||
return result
|
||||
})
|
||||
|
||||
// 占位:未来 content script 通过 webext-bridge 触发任务时,由这里转发到后端
|
||||
// ---------- 安装时事件 ----------
|
||||
|
||||
browser.runtime.onInstalled.addListener(() => {
|
||||
console.log('BiliNote extension installed')
|
||||
|
||||
// 右键菜单:在视频页或视频链接上"用 BiliNote 总结"
|
||||
try {
|
||||
browser.contextMenus.create({
|
||||
id: 'bilinote-summarize-page',
|
||||
title: '用 BiliNote 总结此视频',
|
||||
contexts: ['page', 'link', 'video'],
|
||||
documentUrlPatterns: [
|
||||
'*://*.bilibili.com/*',
|
||||
'*://*.youtube.com/*',
|
||||
'*://youtu.be/*',
|
||||
'*://*.douyin.com/*',
|
||||
'*://*.kuaishou.com/*',
|
||||
],
|
||||
})
|
||||
}
|
||||
catch (e) {
|
||||
console.warn('注册右键菜单失败:', e)
|
||||
}
|
||||
})
|
||||
|
||||
browser.contextMenus?.onClicked.addListener(async (info, tab) => {
|
||||
if (info.menuItemId !== 'bilinote-summarize-page')
|
||||
return
|
||||
const url = info.linkUrl || tab?.url
|
||||
if (!url)
|
||||
return
|
||||
const result = await startTask(url)
|
||||
if (result.ok)
|
||||
await openSidePanelInTab(tab?.id)
|
||||
else
|
||||
console.warn('右键启动失败:', result.error)
|
||||
})
|
||||
|
||||
// content script 占位握手 —— 未来可扩展为查询当前任务等
|
||||
onMessage('get-current-tab', async () => {
|
||||
try {
|
||||
const [tab] = await browser.tabs.query({ active: true, currentWindow: true })
|
||||
@@ -27,4 +176,3 @@ onMessage('get-current-tab', async () => {
|
||||
return { title: undefined, url: undefined }
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -1,19 +1,13 @@
|
||||
/* eslint-disable no-console */
|
||||
import { onMessage } from 'webext-bridge/content-script'
|
||||
import { createApp } from 'vue'
|
||||
import App from './views/App.vue'
|
||||
import { setupApp } from '~/logic/common-setup'
|
||||
import { detectPlatform } from '~/logic/platform'
|
||||
|
||||
// Firefox `browser.tabs.executeScript()` requires scripts return a primitive value
|
||||
// 只在支持的视频平台上挂悬浮按钮,避免污染其他网站
|
||||
(() => {
|
||||
console.info('[vitesse-webext] Hello world from content script')
|
||||
if (!detectPlatform(window.location.href))
|
||||
return
|
||||
|
||||
// communication example: send previous tab title from background page
|
||||
onMessage('tab-prev', ({ data }) => {
|
||||
console.log(`[vitesse-webext] Navigate from page "${data.title}"`)
|
||||
})
|
||||
|
||||
// mount component to context window
|
||||
const container = document.createElement('div')
|
||||
container.id = __NAME__
|
||||
const root = document.createElement('div')
|
||||
|
||||
@@ -1,9 +1,57 @@
|
||||
<script setup lang="ts">
|
||||
// P2 计划:在视频页注入悬浮按钮 → 一键调起 BiliNote 任务。
|
||||
// MVP 阶段无注入;保留组件外壳以便编译与未来扩展。
|
||||
import 'uno.css'
|
||||
import { computed, ref } from 'vue'
|
||||
import { sendMessage } from 'webext-bridge/content-script'
|
||||
import { detectPlatform, PLATFORM_LABELS } from '~/logic/platform'
|
||||
|
||||
const platform = detectPlatform(window.location.href)
|
||||
const busy = ref(false)
|
||||
const toast = ref<{ kind: 'ok' | 'err', text: string } | null>(null)
|
||||
|
||||
const label = computed(() => platform ? `用 BiliNote 总结这个${PLATFORM_LABELS[platform]}视频` : '')
|
||||
|
||||
async function trigger() {
|
||||
if (!platform || busy.value)
|
||||
return
|
||||
busy.value = true
|
||||
toast.value = null
|
||||
try {
|
||||
const res = await sendMessage('bilinote-start', {
|
||||
url: window.location.href,
|
||||
platform,
|
||||
}, 'background')
|
||||
const ok = res && (res as any).ok
|
||||
toast.value = ok
|
||||
? { kind: 'ok', text: '已开始生成笔记,可在侧边栏 / popup 查看进度' }
|
||||
: { kind: 'err', text: (res as any)?.error || '提交失败,请打开设置检查后端与供应商' }
|
||||
}
|
||||
catch (e) {
|
||||
toast.value = { kind: 'err', text: (e as Error).message }
|
||||
}
|
||||
finally {
|
||||
busy.value = false
|
||||
setTimeout(() => { toast.value = null }, 4000)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- intentionally empty in P1 MVP -->
|
||||
<div v-if="platform" class="bilinote-fab fixed bottom-24 right-6 z-[2147483647] flex flex-col items-end gap-2 font-sans select-none">
|
||||
<div
|
||||
v-if="toast"
|
||||
class="text-xs px-3 py-2 rounded shadow max-w-[260px]"
|
||||
:class="toast.kind === 'ok' ? 'bg-green-600 text-white' : 'bg-red-600 text-white'"
|
||||
>
|
||||
{{ toast.text }}
|
||||
</div>
|
||||
<button
|
||||
class="flex items-center gap-2 px-3 py-2 rounded-full shadow-lg cursor-pointer border-none text-white text-sm font-medium bg-pink-600 hover:bg-pink-700 disabled:bg-pink-300"
|
||||
:disabled="busy"
|
||||
:title="label"
|
||||
@click="trigger"
|
||||
>
|
||||
<span class="text-base">📝</span>
|
||||
<span>{{ busy ? '提交中…' : 'BiliNote' }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -34,7 +34,14 @@ export async function getProviders(): Promise<Provider[]> {
|
||||
}
|
||||
|
||||
export async function getModelsByProvider(providerId: string): Promise<Model[]> {
|
||||
return request<Model[]>(`/api/get_models_by_provider/${providerId}`)
|
||||
return request<Model[]>(`/api/model_enable/${providerId}`)
|
||||
}
|
||||
|
||||
export async function setDownloaderCookie(platform: string, cookie: string): Promise<void> {
|
||||
await request('/api/update_downloader_cookie', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ platform, cookie }),
|
||||
})
|
||||
}
|
||||
|
||||
export async function generateNote(payload: GenerateRequest): Promise<{ task_id: string }> {
|
||||
|
||||
18
BillNote_extension/src/logic/constants.ts
Normal file
18
BillNote_extension/src/logic/constants.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import type { Settings } from './types'
|
||||
|
||||
export const DEFAULT_BACKEND_URL = 'http://localhost:8483'
|
||||
|
||||
export const DEFAULT_SETTINGS: Settings = {
|
||||
backendUrl: DEFAULT_BACKEND_URL,
|
||||
providerId: '',
|
||||
modelName: '',
|
||||
quality: 'medium',
|
||||
screenshot: false,
|
||||
link: false,
|
||||
style: '',
|
||||
}
|
||||
|
||||
export const MAX_TASKS = 30
|
||||
|
||||
export const SETTINGS_KEY = 'bilinote-settings'
|
||||
export const TASKS_KEY = 'bilinote-tasks'
|
||||
38
BillNote_extension/src/logic/cookies.ts
Normal file
38
BillNote_extension/src/logic/cookies.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { setDownloaderCookie } from './api'
|
||||
import type { Platform } from './types'
|
||||
|
||||
// 后端期望的 cookie 字符串格式:name=value; name=value; ...
|
||||
// 见 backend/app/downloaders/bilibili_downloader.py 的 split("; ")
|
||||
const COOKIE_DOMAINS: Record<Exclude<Platform, 'local'>, string> = {
|
||||
bilibili: '.bilibili.com',
|
||||
youtube: '.youtube.com',
|
||||
douyin: '.douyin.com',
|
||||
kuaishou: '.kuaishou.com',
|
||||
}
|
||||
|
||||
export const SUPPORTED_COOKIE_PLATFORMS: Array<Exclude<Platform, 'local'>> = [
|
||||
'bilibili',
|
||||
'douyin',
|
||||
'kuaishou',
|
||||
'youtube',
|
||||
]
|
||||
|
||||
export async function readBrowserCookies(platform: Exclude<Platform, 'local'>): Promise<string> {
|
||||
const domain = COOKIE_DOMAINS[platform]
|
||||
const list = await browser.cookies.getAll({ domain })
|
||||
return list.map(c => `${c.name}=${c.value}`).join('; ')
|
||||
}
|
||||
|
||||
export async function syncCookieToBackend(platform: Exclude<Platform, 'local'>): Promise<{ ok: boolean, count: number, error?: string }> {
|
||||
try {
|
||||
const cookieStr = await readBrowserCookies(platform)
|
||||
if (!cookieStr)
|
||||
return { ok: false, count: 0, error: '当前浏览器没有该域名的 cookie,先在浏览器内登录目标站点' }
|
||||
const count = cookieStr.split('; ').length
|
||||
await setDownloaderCookie(platform, cookieStr)
|
||||
return { ok: true, count }
|
||||
}
|
||||
catch (e) {
|
||||
return { ok: false, count: 0, error: (e as Error).message }
|
||||
}
|
||||
}
|
||||
@@ -1,33 +1,22 @@
|
||||
import { useWebExtensionStorage } from '~/composables/useWebExtensionStorage'
|
||||
import type { Settings, TaskRecord } from './types'
|
||||
import { DEFAULT_SETTINGS, MAX_TASKS, SETTINGS_KEY, TASKS_KEY } from './constants'
|
||||
|
||||
export const DEFAULT_BACKEND_URL = 'http://localhost:8483'
|
||||
export { DEFAULT_BACKEND_URL, DEFAULT_SETTINGS, MAX_TASKS } from './constants'
|
||||
|
||||
export const DEFAULT_SETTINGS: Settings = {
|
||||
backendUrl: DEFAULT_BACKEND_URL,
|
||||
providerId: '',
|
||||
modelName: '',
|
||||
quality: 'medium',
|
||||
screenshot: false,
|
||||
link: false,
|
||||
style: '',
|
||||
}
|
||||
|
||||
// 全局共享设置(popup / options / sidepanel / background 都读这一份)
|
||||
// 全局共享设置(popup / options / sidepanel 三个 Vue 上下文都读这一份)
|
||||
// 注意:background service worker 不要 import 这个文件,改用 chrome.storage 直读
|
||||
export const { data: settings, dataReady: settingsReady } = useWebExtensionStorage<Settings>(
|
||||
'bilinote-settings',
|
||||
SETTINGS_KEY,
|
||||
DEFAULT_SETTINGS,
|
||||
{ mergeDefaults: true },
|
||||
)
|
||||
|
||||
// 历史任务列表,最近的在前
|
||||
export const { data: tasks, dataReady: tasksReady } = useWebExtensionStorage<TaskRecord[]>(
|
||||
'bilinote-tasks',
|
||||
TASKS_KEY,
|
||||
[],
|
||||
)
|
||||
|
||||
export const MAX_TASKS = 30
|
||||
|
||||
export function upsertTask(record: TaskRecord) {
|
||||
const list = tasks.value ?? []
|
||||
const idx = list.findIndex(t => t.taskId === record.taskId)
|
||||
|
||||
@@ -39,6 +39,8 @@ export async function getManifest() {
|
||||
'storage',
|
||||
'activeTab',
|
||||
'sidePanel',
|
||||
'contextMenus',
|
||||
'cookies',
|
||||
],
|
||||
host_permissions: ['*://*/*'],
|
||||
content_scripts: [
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
import { onMounted, ref, watch } from 'vue'
|
||||
import { getModelsByProvider, getProviders, ping } from '~/logic/api'
|
||||
import { settings, settingsReady } from '~/logic/storage'
|
||||
import type { Model, Provider } from '~/logic/types'
|
||||
import { SUPPORTED_COOKIE_PLATFORMS, syncCookieToBackend } from '~/logic/cookies'
|
||||
import { PLATFORM_LABELS } from '~/logic/platform'
|
||||
import type { Model, Platform, Provider } from '~/logic/types'
|
||||
|
||||
const providers = ref<Provider[]>([])
|
||||
const models = ref<Model[]>([])
|
||||
@@ -49,6 +51,19 @@ async function testConnection() {
|
||||
: { kind: 'err', text: '无法连接后端,请检查地址、端口与 CORS 配置' }
|
||||
}
|
||||
|
||||
const cookieStatus = ref<Record<string, { kind: 'ok' | 'err' | 'idle', text: string }>>({})
|
||||
const cookieBusy = ref<Record<string, boolean>>({})
|
||||
|
||||
async function syncCookie(platform: Exclude<Platform, 'local'>) {
|
||||
cookieBusy.value[platform] = true
|
||||
cookieStatus.value[platform] = { kind: 'idle', text: '同步中…' }
|
||||
const res = await syncCookieToBackend(platform)
|
||||
cookieStatus.value[platform] = res.ok
|
||||
? { kind: 'ok', text: `已同步 ${res.count} 条 cookie ✓` }
|
||||
: { kind: 'err', text: res.error || '同步失败' }
|
||||
cookieBusy.value[platform] = false
|
||||
}
|
||||
|
||||
watch(() => settings.value?.providerId, (id) => {
|
||||
if (id)
|
||||
refreshModels(id)
|
||||
@@ -146,6 +161,36 @@ onMounted(async () => {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="bg-white dark:bg-gray-800 border rounded p-4 mb-4 flex flex-col gap-3">
|
||||
<h2 class="font-semibold">浏览器 Cookie 同步</h2>
|
||||
<p class="text-xs text-gray-500">
|
||||
从当前浏览器读取你已登录站点的 cookie,并写入后端 (POST /api/update_downloader_cookie)。
|
||||
Bilibili / Douyin / Kuaishou 这类需要登录态的下载尤其需要这一步。
|
||||
</p>
|
||||
<div class="flex flex-col gap-2">
|
||||
<div
|
||||
v-for="p in SUPPORTED_COOKIE_PLATFORMS"
|
||||
:key="p"
|
||||
class="flex items-center justify-between gap-2 text-sm"
|
||||
>
|
||||
<span class="w-20">{{ PLATFORM_LABELS[p] }}</span>
|
||||
<button class="btn-secondary" :disabled="cookieBusy[p]" @click="syncCookie(p)">
|
||||
{{ cookieBusy[p] ? '同步中…' : '同步 Cookie' }}
|
||||
</button>
|
||||
<span
|
||||
class="flex-1 text-xs"
|
||||
:class="{
|
||||
'text-green-700': cookieStatus[p]?.kind === 'ok',
|
||||
'text-red-600': cookieStatus[p]?.kind === 'err',
|
||||
'text-gray-500': cookieStatus[p]?.kind === 'idle',
|
||||
}"
|
||||
>
|
||||
{{ cookieStatus[p]?.text || '' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<p class="text-xs text-gray-500">
|
||||
所有设置自动保存。不在桌面端管理供应商/模型?请在 BiliNote web 端 (http://localhost:3015) 完成。
|
||||
</p>
|
||||
|
||||
@@ -1,18 +1,114 @@
|
||||
<script setup lang="ts">
|
||||
function openOptionsPage() {
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { getTaskStatus } from '~/logic/api'
|
||||
import { settings, settingsReady, tasks, tasksReady, upsertTask } from '~/logic/storage'
|
||||
import type { TaskRecord } from '~/logic/types'
|
||||
|
||||
const activeTaskId = ref<string>('')
|
||||
const activeTask = computed<TaskRecord | undefined>(() => tasks.value?.find(t => t.taskId === activeTaskId.value))
|
||||
const errorMsg = ref('')
|
||||
|
||||
let pollTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
async function poll(taskId: string) {
|
||||
try {
|
||||
const res = await getTaskStatus(taskId)
|
||||
const cur = tasks.value?.find(t => t.taskId === taskId)
|
||||
if (cur) {
|
||||
upsertTask({
|
||||
...cur,
|
||||
status: res.status,
|
||||
message: res.message,
|
||||
result: res.result ?? cur.result,
|
||||
updatedAt: Date.now(),
|
||||
})
|
||||
}
|
||||
if (res.status !== 'SUCCESS' && res.status !== 'FAILED')
|
||||
pollTimer = setTimeout(() => poll(taskId), 3000)
|
||||
}
|
||||
catch (e) {
|
||||
errorMsg.value = (e as Error).message
|
||||
pollTimer = setTimeout(() => poll(taskId), 5000)
|
||||
}
|
||||
}
|
||||
|
||||
function selectTask(id: string) {
|
||||
if (pollTimer) {
|
||||
clearTimeout(pollTimer)
|
||||
pollTimer = null
|
||||
}
|
||||
activeTaskId.value = id
|
||||
const t = tasks.value?.find(x => x.taskId === id)
|
||||
if (t && t.status !== 'SUCCESS' && t.status !== 'FAILED')
|
||||
poll(id)
|
||||
}
|
||||
|
||||
function openOptions() {
|
||||
browser.runtime.openOptionsPage()
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([settingsReady, tasksReady])
|
||||
// 默认选中最近的任务(无论是否完成)
|
||||
const latest = tasks.value?.[0]
|
||||
if (latest) {
|
||||
activeTaskId.value = latest.taskId
|
||||
if (latest.status !== 'SUCCESS' && latest.status !== 'FAILED')
|
||||
poll(latest.taskId)
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (pollTimer)
|
||||
clearTimeout(pollTimer)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="w-full h-full p-4 flex flex-col gap-3 text-sm text-gray-700">
|
||||
<h1 class="text-base font-semibold">BiliNote 侧边栏</h1>
|
||||
<p class="text-gray-500">
|
||||
P3 计划:在这里展示任务进度、思维导图与 RAG 问答。
|
||||
当前 MVP 仅启用工具栏 popup。
|
||||
</p>
|
||||
<button class="bg-gray-100 hover:bg-gray-200 px-3 py-1 rounded w-fit" @click="openOptionsPage">
|
||||
打开设置
|
||||
</button>
|
||||
<main class="w-full h-full flex flex-col bg-white text-sm text-gray-800">
|
||||
<header class="flex items-center justify-between px-3 py-2 border-b">
|
||||
<div class="font-semibold">BiliNote 侧边栏</div>
|
||||
<button class="text-xs text-gray-500 hover:text-gray-800" @click="openOptions">设置</button>
|
||||
</header>
|
||||
|
||||
<div v-if="errorMsg" class="text-xs text-red-600 px-3 py-2 break-words bg-red-50">
|
||||
{{ errorMsg }}
|
||||
</div>
|
||||
|
||||
<section v-if="!activeTask" class="flex-1 flex items-center justify-center text-gray-400 text-xs px-4 text-center">
|
||||
还没有任务。在视频页点悬浮按钮、在 popup 提交,或右键菜单选「用 BiliNote 总结」。
|
||||
</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>
|
||||
<TaskProgress :status="activeTask.status" :message="activeTask.message" />
|
||||
<div class="flex-1 overflow-auto">
|
||||
<MarkdownView
|
||||
v-if="activeTask.status === 'SUCCESS' && activeTask.result?.markdown"
|
||||
:markdown="activeTask.result.markdown"
|
||||
:title="activeTask.result.audio_meta?.title"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<details v-if="(tasks?.length ?? 0) > 1" class="text-xs border-t px-3 py-2">
|
||||
<summary class="cursor-pointer text-gray-500">
|
||||
历史任务({{ tasks!.length }})
|
||||
</summary>
|
||||
<ul class="mt-1 flex flex-col gap-1 max-h-40 overflow-auto">
|
||||
<li
|
||||
v-for="t in tasks"
|
||||
:key="t.taskId"
|
||||
class="flex justify-between items-center gap-2 px-1 py-0.5 rounded hover:bg-gray-100 cursor-pointer"
|
||||
: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>
|
||||
</li>
|
||||
</ul>
|
||||
</details>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
Reference in New Issue
Block a user