mirror of
https://github.com/JefferyHcool/BiliNote.git
synced 2026-08-06 05:34:12 +08:00
feat(extension): 浏览器插件 P1 MVP
新建 BillNote_extension/ 工作空间(基于 vitesse-webext 骨架,Vue 3 + Vite + UnoCSS + MV3)。 P1 MVP 范围: - popup:自动读当前 tab URL,识别 Bilibili / YouTube / 抖音 / 快手;提交 /generate_note 后轮询 /task_status;展示 markdown,复制 + 下载 .md - options:后端地址输入与连通性测试;从 /get_all_providers + /get_models_by_provider 拉供应商/模型列表;默认画质、截图/跳转、笔记风格 - chrome.storage.local 持久化设置与最近 30 个任务,popup 重开恢复进行中任务 - markdown 里的 /static/screenshots 路径在渲染前重写为绝对地址 后端:CORS 改用 regex,新增允许 chrome-extension:// 与 moz-extension:// 源(同时保留 localhost / 127.0.0.1 / tauri.localhost)。无新增 backend endpoint。 P2-P4(content script 悬浮按钮、cookie 直通、side panel、思维导图、RAG 问答)保留 stub 文件,不在本次范围。 去掉 vitesse-webext 自带的 simple-git-hooks postinstall 配置——它会在仓库根装 pre-commit 钩子去跑 pnpm lint-staged,但仓库根没有 package.json,会破坏所有提交流。 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
12
BillNote_extension/src/assets/logo.svg
Normal file
12
BillNote_extension/src/assets/logo.svg
Normal file
@@ -0,0 +1,12 @@
|
||||
<svg width="415" height="412" viewBox="0 0 415 412" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M0 28C0 12.536 12.536 0 28 0H387C402.464 0 415 12.536 415 28V384C415 399.464 402.464 412 387 412H28C12.536 412 0 399.464 0 384V28Z" fill="#3C77FB"/>
|
||||
<rect x="60" y="64" width="296" height="283" rx="37" fill="white"/>
|
||||
<path d="M268.422 175.657C276.308 180.298 276.308 191.702 268.422 196.343L186.335 244.641C178.336 249.348 168.25 243.58 168.25 234.298V137.702C168.25 128.42 178.336 122.652 186.335 127.359L268.422 175.657Z" fill="#3C77FB"/>
|
||||
<path d="M17 282C17 270.954 25.9543 262 37 262H83C94.0457 262 103 270.954 103 282V282C103 293.046 94.0457 302 83 302H37C25.9543 302 17 293.046 17 282V282Z" fill="#3C77FB"/>
|
||||
<path d="M38 281.5C38 274.044 44.0442 268 51.5 268H82.5C89.9558 268 96 274.044 96 281.5V281.5C96 288.956 89.9558 295 82.5 295H51.5C44.0442 295 38 288.956 38 281.5V281.5Z" fill="white"/>
|
||||
<path d="M17 206C17 194.954 25.9543 186 37 186H83C94.0457 186 103 194.954 103 206V206C103 217.046 94.0457 226 83 226H37C25.9543 226 17 217.046 17 206V206Z" fill="#3C77FB"/>
|
||||
<path d="M38 205.5C38 198.044 44.0442 192 51.5 192H82.5C89.9558 192 96 198.044 96 205.5V205.5C96 212.956 89.9558 219 82.5 219H51.5C44.0442 219 38 212.956 38 205.5V205.5Z" fill="white"/>
|
||||
<path d="M17 130C17 118.954 25.9543 110 37 110H83C94.0457 110 103 118.954 103 130V130C103 141.046 94.0457 150 83 150H37C25.9543 150 17 141.046 17 130V130Z" fill="#3C77FB"/>
|
||||
<path d="M38 129.5C38 122.044 44.0442 116 51.5 116H82.5C89.9558 116 96 122.044 96 129.5V129.5C96 136.956 89.9558 143 82.5 143H51.5C44.0442 143 38 136.956 38 129.5V129.5Z" fill="white"/>
|
||||
<path d="M145 290C145 285.582 148.582 282 153 282H284C288.418 282 292 285.582 292 290V299C292 303.418 288.418 307 284 307H153C148.582 307 145 303.418 145 299V290Z" fill="#3C77FB"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.8 KiB |
18
BillNote_extension/src/background/contentScriptHMR.ts
Normal file
18
BillNote_extension/src/background/contentScriptHMR.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { isFirefox, isForbiddenUrl } from '~/env'
|
||||
|
||||
// Firefox fetch files from cache instead of reloading changes from disk,
|
||||
// hmr will not work as Chromium based browser
|
||||
browser.webNavigation.onCommitted.addListener(({ tabId, frameId, url }) => {
|
||||
// Filter out non main window events.
|
||||
if (frameId !== 0)
|
||||
return
|
||||
|
||||
if (isForbiddenUrl(url))
|
||||
return
|
||||
|
||||
// inject the latest scripts
|
||||
browser.tabs.executeScript(tabId, {
|
||||
file: `${isFirefox ? '' : '.'}/dist/contentScripts/index.global.js`,
|
||||
runAt: 'document_end',
|
||||
}).catch(error => console.error(error))
|
||||
})
|
||||
30
BillNote_extension/src/background/main.ts
Normal file
30
BillNote_extension/src/background/main.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { onMessage } from 'webext-bridge/background'
|
||||
|
||||
// only on dev mode
|
||||
if (import.meta.hot) {
|
||||
// @ts-expect-error for background HMR
|
||||
import('/@vite/client')
|
||||
// load latest content script
|
||||
import('./contentScriptHMR')
|
||||
}
|
||||
|
||||
// 工具栏图标点击 → 打开 popup(默认行为,无需配置)
|
||||
// side panel 留给 P3 阶段:在那时把 popup 替换成"action 行为"或加单独命令
|
||||
// 此处不开启 openPanelOnActionClick,否则会绕过 default_popup
|
||||
|
||||
browser.runtime.onInstalled.addListener((): void => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('BiliNote extension installed')
|
||||
})
|
||||
|
||||
// 占位:未来 content script 通过 webext-bridge 触发任务时,由这里转发到后端
|
||||
onMessage('get-current-tab', async () => {
|
||||
try {
|
||||
const [tab] = await browser.tabs.query({ active: true, currentWindow: true })
|
||||
return { title: tab?.title, url: tab?.url }
|
||||
}
|
||||
catch {
|
||||
return { title: undefined, url: undefined }
|
||||
}
|
||||
})
|
||||
|
||||
5
BillNote_extension/src/components/Logo.vue
Normal file
5
BillNote_extension/src/components/Logo.vue
Normal file
@@ -0,0 +1,5 @@
|
||||
<template>
|
||||
<a class="icon-btn mx-2 text-2xl" rel="noreferrer" href="https://github.com/antfu/vitesse-webext" target="_blank" title="GitHub">
|
||||
<pixelarticons-power />
|
||||
</a>
|
||||
</template>
|
||||
44
BillNote_extension/src/components/MarkdownView.vue
Normal file
44
BillNote_extension/src/components/MarkdownView.vue
Normal file
@@ -0,0 +1,44 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import MarkdownIt from 'markdown-it'
|
||||
import { absolutizeMarkdownImages } from '~/logic/api'
|
||||
|
||||
const props = defineProps<{ markdown: string, title?: string }>()
|
||||
|
||||
const md = new MarkdownIt({ html: false, linkify: true, breaks: true })
|
||||
|
||||
const html = computed(() => md.render(absolutizeMarkdownImages(props.markdown || '')))
|
||||
|
||||
async function copy() {
|
||||
await navigator.clipboard.writeText(props.markdown)
|
||||
}
|
||||
|
||||
function download() {
|
||||
const blob = new Blob([props.markdown], { type: 'text/markdown;charset=utf-8' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `${props.title || 'bilinote'}.md`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex gap-2 justify-end">
|
||||
<button class="btn-secondary" @click="copy">复制 Markdown</button>
|
||||
<button class="btn-secondary" @click="download">下载 .md</button>
|
||||
</div>
|
||||
<div class="prose prose-sm max-w-none border rounded p-3 bg-gray-50 max-h-[400px] overflow-auto" v-html="html" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.prose img { max-width: 100%; }
|
||||
.prose h1, .prose h2, .prose h3 { font-weight: 600; margin-top: 0.8em; margin-bottom: 0.4em; }
|
||||
.prose p { margin-bottom: 0.5em; line-height: 1.55; }
|
||||
.prose ul, .prose ol { padding-left: 1.4em; margin-bottom: 0.5em; }
|
||||
.prose code { background: #eee; padding: 0 4px; border-radius: 3px; font-size: 0.9em; }
|
||||
.prose a { color: #2563eb; text-decoration: underline; }
|
||||
</style>
|
||||
24
BillNote_extension/src/components/PlatformBadge.vue
Normal file
24
BillNote_extension/src/components/PlatformBadge.vue
Normal file
@@ -0,0 +1,24 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { Platform } from '~/logic/types'
|
||||
import { PLATFORM_LABELS } from '~/logic/platform'
|
||||
|
||||
const props = defineProps<{ platform: Platform | null }>()
|
||||
|
||||
const colorMap: Record<Platform, string> = {
|
||||
bilibili: 'bg-pink-100 text-pink-700',
|
||||
youtube: 'bg-red-100 text-red-700',
|
||||
douyin: 'bg-zinc-200 text-zinc-800',
|
||||
kuaishou: 'bg-orange-100 text-orange-700',
|
||||
local: 'bg-gray-100 text-gray-600',
|
||||
}
|
||||
|
||||
const cls = computed(() => (props.platform ? colorMap[props.platform] : 'bg-gray-100 text-gray-500'))
|
||||
const label = computed(() => (props.platform ? PLATFORM_LABELS[props.platform] : '未识别'))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium" :class="cls">
|
||||
{{ label }}
|
||||
</span>
|
||||
</template>
|
||||
11
BillNote_extension/src/components/README.md
Normal file
11
BillNote_extension/src/components/README.md
Normal file
@@ -0,0 +1,11 @@
|
||||
## Components
|
||||
|
||||
Components in this dir will be auto-registered and on-demand, powered by [unplugin-vue-components](https://github.com/unplugin/unplugin-vue-components).
|
||||
|
||||
Components can be shared in all views.
|
||||
|
||||
### Icons
|
||||
|
||||
You can use icons from almost any icon sets by the power of [Iconify](https://iconify.design/).
|
||||
|
||||
It will only bundle the icons you use. Check out [unplugin-icons](https://github.com/unplugin/unplugin-icons) for more details.
|
||||
5
BillNote_extension/src/components/SharedSubtitle.vue
Normal file
5
BillNote_extension/src/components/SharedSubtitle.vue
Normal file
@@ -0,0 +1,5 @@
|
||||
<template>
|
||||
<p class="mt-2 opacity-50">
|
||||
This is the {{ $app.context }} page
|
||||
</p>
|
||||
</template>
|
||||
42
BillNote_extension/src/components/TaskProgress.vue
Normal file
42
BillNote_extension/src/components/TaskProgress.vue
Normal file
@@ -0,0 +1,42 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { TaskStatus } from '~/logic/types'
|
||||
|
||||
const props = defineProps<{ status: TaskStatus, message?: string }>()
|
||||
|
||||
const STAGE_ORDER: TaskStatus[] = ['PENDING', 'PARSING', 'DOWNLOADING', 'TRANSCRIBING', 'SUMMARIZING', 'FORMATTING', 'SAVING', 'SUCCESS']
|
||||
const STAGE_LABELS: Record<TaskStatus, string> = {
|
||||
PENDING: '排队中',
|
||||
PARSING: '解析中',
|
||||
DOWNLOADING: '下载中',
|
||||
TRANSCRIBING: '转写中',
|
||||
SUMMARIZING: '总结中',
|
||||
FORMATTING: '格式化',
|
||||
SAVING: '保存中',
|
||||
SUCCESS: '完成',
|
||||
FAILED: '失败',
|
||||
}
|
||||
|
||||
const currentIdx = computed(() => STAGE_ORDER.indexOf(props.status))
|
||||
const isFailed = computed(() => props.status === 'FAILED')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex items-center gap-2 text-sm">
|
||||
<span :class="isFailed ? 'text-red-600' : 'text-blue-600'" class="font-medium">
|
||||
{{ STAGE_LABELS[status] }}
|
||||
</span>
|
||||
<span v-if="message" class="text-gray-500 text-xs truncate">{{ message }}</span>
|
||||
</div>
|
||||
<div v-if="!isFailed" class="flex gap-1">
|
||||
<div
|
||||
v-for="(s, i) in STAGE_ORDER"
|
||||
:key="s"
|
||||
class="h-1 flex-1 rounded-full"
|
||||
:class="i <= currentIdx ? 'bg-blue-500' : 'bg-gray-200'"
|
||||
/>
|
||||
</div>
|
||||
<div v-else class="h-1 rounded-full bg-red-500" />
|
||||
</div>
|
||||
</template>
|
||||
11
BillNote_extension/src/components/__tests__/Logo.test.ts
Normal file
11
BillNote_extension/src/components/__tests__/Logo.test.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import Logo from '../Logo.vue'
|
||||
|
||||
describe('logo component', () => {
|
||||
it('should render', () => {
|
||||
const wrapper = mount(Logo)
|
||||
|
||||
expect(wrapper.html()).toBeTruthy()
|
||||
})
|
||||
})
|
||||
166
BillNote_extension/src/composables/useWebExtensionStorage.ts
Normal file
166
BillNote_extension/src/composables/useWebExtensionStorage.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
import { StorageSerializers } from '@vueuse/core'
|
||||
import { pausableWatch, toValue, tryOnScopeDispose } from '@vueuse/shared'
|
||||
import { ref, shallowRef } from 'vue-demi'
|
||||
import { storage } from 'webextension-polyfill'
|
||||
|
||||
import type {
|
||||
StorageLikeAsync,
|
||||
UseStorageAsyncOptions,
|
||||
} from '@vueuse/core'
|
||||
import type { MaybeRefOrGetter, RemovableRef } from '@vueuse/shared'
|
||||
import type { Ref } from 'vue-demi'
|
||||
import type { Storage } from 'webextension-polyfill'
|
||||
|
||||
export type WebExtensionStorageOptions<T> = UseStorageAsyncOptions<T>
|
||||
|
||||
// https://github.com/vueuse/vueuse/blob/658444bf9f8b96118dbd06eba411bb6639e24e88/packages/core/useStorage/guess.ts
|
||||
export function guessSerializerType(rawInit: unknown) {
|
||||
return rawInit == null
|
||||
? 'any'
|
||||
: rawInit instanceof Set
|
||||
? 'set'
|
||||
: rawInit instanceof Map
|
||||
? 'map'
|
||||
: rawInit instanceof Date
|
||||
? 'date'
|
||||
: typeof rawInit === 'boolean'
|
||||
? 'boolean'
|
||||
: typeof rawInit === 'string'
|
||||
? 'string'
|
||||
: typeof rawInit === 'object'
|
||||
? 'object'
|
||||
: Number.isNaN(rawInit)
|
||||
? 'any'
|
||||
: 'number'
|
||||
}
|
||||
|
||||
const storageInterface: StorageLikeAsync = {
|
||||
removeItem(key: string) {
|
||||
return storage.local.remove(key)
|
||||
},
|
||||
|
||||
setItem(key: string, value: string) {
|
||||
return storage.local.set({ [key]: value })
|
||||
},
|
||||
|
||||
async getItem(key: string) {
|
||||
const storedData = await storage.local.get(key)
|
||||
|
||||
return storedData[key] as string
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* https://github.com/vueuse/vueuse/blob/658444bf9f8b96118dbd06eba411bb6639e24e88/packages/core/useStorageAsync/index.ts
|
||||
*
|
||||
* @param key
|
||||
* @param initialValue
|
||||
* @param options
|
||||
*/
|
||||
export function useWebExtensionStorage<T>(
|
||||
key: string,
|
||||
initialValue: MaybeRefOrGetter<T>,
|
||||
options: WebExtensionStorageOptions<T> = {},
|
||||
): { data: RemovableRef<T>, dataReady: Promise<T> } {
|
||||
const {
|
||||
flush = 'pre',
|
||||
deep = true,
|
||||
listenToStorageChanges = true,
|
||||
writeDefaults = true,
|
||||
mergeDefaults = false,
|
||||
shallow,
|
||||
eventFilter,
|
||||
onError = (e) => {
|
||||
console.error(e)
|
||||
},
|
||||
} = options
|
||||
|
||||
const rawInit: T = toValue(initialValue)
|
||||
const type = guessSerializerType(rawInit)
|
||||
|
||||
const data = (shallow ? shallowRef : ref)(initialValue) as Ref<T>
|
||||
const serializer = options.serializer ?? StorageSerializers[type]
|
||||
|
||||
async function read(event?: { key: string, newValue: string | null }) {
|
||||
if (event && event.key !== key)
|
||||
return
|
||||
|
||||
try {
|
||||
const rawValue = event ? event.newValue : await storageInterface.getItem(key)
|
||||
if (rawValue == null) {
|
||||
data.value = rawInit
|
||||
if (writeDefaults && rawInit !== null)
|
||||
await storageInterface.setItem(key, await serializer.write(rawInit))
|
||||
}
|
||||
else if (mergeDefaults) {
|
||||
const value = await serializer.read(rawValue) as T
|
||||
if (typeof mergeDefaults === 'function')
|
||||
data.value = mergeDefaults(value, rawInit)
|
||||
else if (type === 'object' && !Array.isArray(value))
|
||||
data.value = { ...(rawInit as Record<keyof unknown, unknown>), ...(value as Record<keyof unknown, unknown>) } as T
|
||||
else data.value = value
|
||||
}
|
||||
else {
|
||||
data.value = await serializer.read(rawValue) as T
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
onError(error)
|
||||
}
|
||||
}
|
||||
|
||||
const dataReadyPromise = new Promise<T>((resolve, reject) => {
|
||||
read().then(() => resolve(data.value)).catch(reject)
|
||||
})
|
||||
|
||||
async function write() {
|
||||
try {
|
||||
await (
|
||||
data.value == null
|
||||
? storageInterface.removeItem(key)
|
||||
: storageInterface.setItem(key, await serializer.write(data.value))
|
||||
)
|
||||
}
|
||||
catch (error) {
|
||||
onError(error)
|
||||
}
|
||||
}
|
||||
|
||||
const { pause: pauseWatch, resume: resumeWatch } = pausableWatch(
|
||||
data,
|
||||
write,
|
||||
{
|
||||
flush,
|
||||
deep,
|
||||
eventFilter,
|
||||
},
|
||||
)
|
||||
|
||||
if (listenToStorageChanges) {
|
||||
const listener = async (changes: Record<string, Storage.StorageChange>) => {
|
||||
try {
|
||||
pauseWatch()
|
||||
for (const [key, change] of Object.entries(changes)) {
|
||||
await read({
|
||||
key,
|
||||
newValue: change.newValue as string | null,
|
||||
})
|
||||
}
|
||||
}
|
||||
finally {
|
||||
resumeWatch()
|
||||
}
|
||||
}
|
||||
|
||||
storage.onChanged.addListener(listener)
|
||||
|
||||
tryOnScopeDispose(() => {
|
||||
storage.onChanged.removeListener(listener)
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
data: data as RemovableRef<T>,
|
||||
dataReady: dataReadyPromise,
|
||||
}
|
||||
}
|
||||
30
BillNote_extension/src/contentScripts/index.ts
Normal file
30
BillNote_extension/src/contentScripts/index.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
/* 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'
|
||||
|
||||
// Firefox `browser.tabs.executeScript()` requires scripts return a primitive value
|
||||
(() => {
|
||||
console.info('[vitesse-webext] Hello world from content script')
|
||||
|
||||
// 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')
|
||||
const styleEl = document.createElement('link')
|
||||
const shadowDOM = container.attachShadow?.({ mode: __DEV__ ? 'open' : 'closed' }) || container
|
||||
styleEl.setAttribute('rel', 'stylesheet')
|
||||
styleEl.setAttribute('href', browser.runtime.getURL('dist/contentScripts/style.css'))
|
||||
shadowDOM.appendChild(styleEl)
|
||||
shadowDOM.appendChild(root)
|
||||
document.body.appendChild(container)
|
||||
const app = createApp(App)
|
||||
setupApp(app)
|
||||
app.mount(root)
|
||||
})()
|
||||
9
BillNote_extension/src/contentScripts/views/App.vue
Normal file
9
BillNote_extension/src/contentScripts/views/App.vue
Normal file
@@ -0,0 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
// P2 计划:在视频页注入悬浮按钮 → 一键调起 BiliNote 任务。
|
||||
// MVP 阶段无注入;保留组件外壳以便编译与未来扩展。
|
||||
import 'uno.css'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- intentionally empty in P1 MVP -->
|
||||
</template>
|
||||
14
BillNote_extension/src/env.ts
Normal file
14
BillNote_extension/src/env.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
const forbiddenProtocols = [
|
||||
'chrome-extension://',
|
||||
'chrome-search://',
|
||||
'chrome://',
|
||||
'devtools://',
|
||||
'edge://',
|
||||
'https://chrome.google.com/webstore',
|
||||
]
|
||||
|
||||
export function isForbiddenUrl(url: string): boolean {
|
||||
return forbiddenProtocols.some(protocol => url.startsWith(protocol))
|
||||
}
|
||||
|
||||
export const isFirefox = navigator.userAgent.includes('Firefox')
|
||||
8
BillNote_extension/src/global.d.ts
vendored
Normal file
8
BillNote_extension/src/global.d.ts
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
declare const __DEV__: boolean
|
||||
/** Extension name, defined in packageJson.name */
|
||||
declare const __NAME__: string
|
||||
|
||||
declare module '*.vue' {
|
||||
const component: any
|
||||
export default component
|
||||
}
|
||||
69
BillNote_extension/src/logic/api.ts
Normal file
69
BillNote_extension/src/logic/api.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import type { GenerateRequest, Model, Provider, TaskStatusResponse } from './types'
|
||||
import { settings } from './storage'
|
||||
|
||||
interface ApiEnvelope<T> {
|
||||
code: number
|
||||
msg: string
|
||||
data: T
|
||||
}
|
||||
|
||||
function backendUrl(): string {
|
||||
return (settings.value?.backendUrl || 'http://localhost:8483').replace(/\/$/, '')
|
||||
}
|
||||
|
||||
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const res = await fetch(`${backendUrl()}${path}`, {
|
||||
headers: { 'Content-Type': 'application/json', ...(init?.headers || {}) },
|
||||
...init,
|
||||
})
|
||||
if (!res.ok)
|
||||
throw new Error(`HTTP ${res.status}: ${await res.text()}`)
|
||||
const body = (await res.json()) as ApiEnvelope<T> | T
|
||||
// 后端 ResponseWrapper 包了 {code, msg, data};非 0 视为业务错
|
||||
if (body && typeof body === 'object' && 'code' in body) {
|
||||
const env = body as ApiEnvelope<T>
|
||||
if (env.code !== 0)
|
||||
throw new Error(env.msg || '后端返回失败')
|
||||
return env.data
|
||||
}
|
||||
return body as T
|
||||
}
|
||||
|
||||
export async function getProviders(): Promise<Provider[]> {
|
||||
return request<Provider[]>('/api/get_all_providers')
|
||||
}
|
||||
|
||||
export async function getModelsByProvider(providerId: string): Promise<Model[]> {
|
||||
return request<Model[]>(`/api/get_models_by_provider/${providerId}`)
|
||||
}
|
||||
|
||||
export async function generateNote(payload: GenerateRequest): Promise<{ task_id: string }> {
|
||||
return request<{ task_id: string }>('/api/generate_note', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
|
||||
export async function getTaskStatus(taskId: string): Promise<TaskStatusResponse> {
|
||||
// /task_status/{id} 返回的是裸对象(非 ResponseWrapper 包装),见 routers/note.py
|
||||
const res = await fetch(`${backendUrl()}/api/task_status/${taskId}`)
|
||||
if (!res.ok)
|
||||
throw new Error(`HTTP ${res.status}`)
|
||||
return (await res.json()) as TaskStatusResponse
|
||||
}
|
||||
|
||||
export async function ping(): Promise<boolean> {
|
||||
try {
|
||||
await getProviders()
|
||||
return true
|
||||
}
|
||||
catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// markdown 里的 /static/screenshots/xxx 是相对路径,extension 渲染时需要拼绝对地址
|
||||
export function absolutizeMarkdownImages(md: string): string {
|
||||
const base = backendUrl()
|
||||
return md.replace(/!\[([^\]]*)\]\((\/static\/[^)]+)\)/g, (_, alt, path) => ``)
|
||||
}
|
||||
15
BillNote_extension/src/logic/common-setup.ts
Normal file
15
BillNote_extension/src/logic/common-setup.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import type { App } from 'vue'
|
||||
|
||||
export function setupApp(app: App) {
|
||||
// Inject a globally available `$app` object in template
|
||||
app.config.globalProperties.$app = {
|
||||
context: '',
|
||||
}
|
||||
|
||||
// Provide access to `app` in script setup with `const app = inject('app')`
|
||||
app.provide('app', app.config.globalProperties.$app)
|
||||
|
||||
// Here you can install additional plugins for all contexts: popup, options page and content-script.
|
||||
// example: app.use(i18n)
|
||||
// example excluding content-script context: if (context !== 'content-script') app.use(i18n)
|
||||
}
|
||||
1
BillNote_extension/src/logic/index.ts
Normal file
1
BillNote_extension/src/logic/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './storage'
|
||||
24
BillNote_extension/src/logic/platform.ts
Normal file
24
BillNote_extension/src/logic/platform.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import type { Platform } from './types'
|
||||
|
||||
// 与 backend/app/validators/video_url_validator.py 保持一致
|
||||
export function detectPlatform(url: string | undefined | null): Platform | null {
|
||||
if (!url)
|
||||
return null
|
||||
if (/bilibili\.com\/video\//.test(url))
|
||||
return 'bilibili'
|
||||
if (/(youtube\.com\/watch|youtu\.be\/)/.test(url))
|
||||
return 'youtube'
|
||||
if (url.includes('douyin'))
|
||||
return 'douyin'
|
||||
if (url.includes('kuaishou'))
|
||||
return 'kuaishou'
|
||||
return null
|
||||
}
|
||||
|
||||
export const PLATFORM_LABELS: Record<Platform, string> = {
|
||||
bilibili: '哔哩哔哩',
|
||||
youtube: 'YouTube',
|
||||
douyin: '抖音',
|
||||
kuaishou: '快手',
|
||||
local: '本地',
|
||||
}
|
||||
44
BillNote_extension/src/logic/storage.ts
Normal file
44
BillNote_extension/src/logic/storage.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { useWebExtensionStorage } from '~/composables/useWebExtensionStorage'
|
||||
import type { Settings, TaskRecord } 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: '',
|
||||
}
|
||||
|
||||
// 全局共享设置(popup / options / sidepanel / background 都读这一份)
|
||||
export const { data: settings, dataReady: settingsReady } = useWebExtensionStorage<Settings>(
|
||||
'bilinote-settings',
|
||||
DEFAULT_SETTINGS,
|
||||
{ mergeDefaults: true },
|
||||
)
|
||||
|
||||
// 历史任务列表,最近的在前
|
||||
export const { data: tasks, dataReady: tasksReady } = useWebExtensionStorage<TaskRecord[]>(
|
||||
'bilinote-tasks',
|
||||
[],
|
||||
)
|
||||
|
||||
export const MAX_TASKS = 30
|
||||
|
||||
export function upsertTask(record: TaskRecord) {
|
||||
const list = tasks.value ?? []
|
||||
const idx = list.findIndex(t => t.taskId === record.taskId)
|
||||
if (idx >= 0)
|
||||
list.splice(idx, 1, { ...list[idx], ...record })
|
||||
else
|
||||
list.unshift(record)
|
||||
tasks.value = list.slice(0, MAX_TASKS)
|
||||
}
|
||||
|
||||
export function removeTask(taskId: string) {
|
||||
const list = tasks.value ?? []
|
||||
tasks.value = list.filter(t => t.taskId !== taskId)
|
||||
}
|
||||
82
BillNote_extension/src/logic/types.ts
Normal file
82
BillNote_extension/src/logic/types.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
// 与 backend/app/routers/note.py / provider.py / model.py 对齐
|
||||
export type Platform = 'bilibili' | 'youtube' | 'douyin' | 'kuaishou' | 'local'
|
||||
export type Quality = 'fast' | 'medium' | 'slow'
|
||||
|
||||
export type TaskStatus =
|
||||
| 'PENDING'
|
||||
| 'PARSING'
|
||||
| 'DOWNLOADING'
|
||||
| 'TRANSCRIBING'
|
||||
| 'SUMMARIZING'
|
||||
| 'FORMATTING'
|
||||
| 'SAVING'
|
||||
| 'SUCCESS'
|
||||
| 'FAILED'
|
||||
|
||||
export interface Provider {
|
||||
id: string
|
||||
name: string
|
||||
logo: string
|
||||
type: string
|
||||
enabled: number
|
||||
base_url?: string
|
||||
api_key?: string
|
||||
}
|
||||
|
||||
export interface Model {
|
||||
id: string
|
||||
model_name: string
|
||||
provider_id: string
|
||||
}
|
||||
|
||||
export interface GenerateRequest {
|
||||
video_url: string
|
||||
platform: Platform
|
||||
quality: Quality
|
||||
model_name: string
|
||||
provider_id: string
|
||||
screenshot?: boolean
|
||||
link?: boolean
|
||||
format?: string[]
|
||||
style?: string
|
||||
extras?: string
|
||||
}
|
||||
|
||||
export interface NoteResult {
|
||||
markdown: string
|
||||
transcript?: unknown
|
||||
audio_meta?: {
|
||||
title?: string
|
||||
duration?: number
|
||||
cover_url?: string
|
||||
[k: string]: unknown
|
||||
}
|
||||
}
|
||||
|
||||
export interface TaskStatusResponse {
|
||||
status: TaskStatus
|
||||
message: string
|
||||
task_id: string
|
||||
result?: NoteResult
|
||||
}
|
||||
|
||||
export interface TaskRecord {
|
||||
taskId: string
|
||||
videoUrl: string
|
||||
platform: Platform
|
||||
status: TaskStatus
|
||||
message: string
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
result?: NoteResult
|
||||
}
|
||||
|
||||
export interface Settings {
|
||||
backendUrl: string
|
||||
providerId: string
|
||||
modelName: string
|
||||
quality: Quality
|
||||
screenshot: boolean
|
||||
link: boolean
|
||||
style: string
|
||||
}
|
||||
91
BillNote_extension/src/manifest.ts
Normal file
91
BillNote_extension/src/manifest.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import fs from 'fs-extra'
|
||||
import type { Manifest } from 'webextension-polyfill'
|
||||
import type PkgType from '../package.json'
|
||||
import { isDev, isFirefox, port, r } from '../scripts/utils'
|
||||
|
||||
export async function getManifest() {
|
||||
const pkg = await fs.readJSON(r('package.json')) as typeof PkgType
|
||||
|
||||
// update this file to update this manifest.json
|
||||
// can also be conditional based on your need
|
||||
const manifest: Manifest.WebExtensionManifest = {
|
||||
manifest_version: 3,
|
||||
name: pkg.displayName || pkg.name,
|
||||
version: pkg.version,
|
||||
description: pkg.description,
|
||||
action: {
|
||||
default_icon: 'assets/icon-512.png',
|
||||
default_popup: 'dist/popup/index.html',
|
||||
},
|
||||
options_ui: {
|
||||
page: 'dist/options/index.html',
|
||||
open_in_tab: true,
|
||||
},
|
||||
background: isFirefox
|
||||
? {
|
||||
scripts: ['dist/background/index.mjs'],
|
||||
type: 'module',
|
||||
}
|
||||
: {
|
||||
service_worker: 'dist/background/index.mjs',
|
||||
},
|
||||
icons: {
|
||||
16: 'assets/icon-512.png',
|
||||
48: 'assets/icon-512.png',
|
||||
128: 'assets/icon-512.png',
|
||||
},
|
||||
permissions: [
|
||||
'tabs',
|
||||
'storage',
|
||||
'activeTab',
|
||||
'sidePanel',
|
||||
],
|
||||
host_permissions: ['*://*/*'],
|
||||
content_scripts: [
|
||||
{
|
||||
matches: [
|
||||
'<all_urls>',
|
||||
],
|
||||
js: [
|
||||
'dist/contentScripts/index.global.js',
|
||||
],
|
||||
},
|
||||
],
|
||||
web_accessible_resources: [
|
||||
{
|
||||
resources: ['dist/contentScripts/style.css'],
|
||||
matches: ['<all_urls>'],
|
||||
},
|
||||
],
|
||||
content_security_policy: {
|
||||
extension_pages: isDev
|
||||
// this is required on dev for Vite script to load
|
||||
? `script-src \'self\' http://localhost:${port}; object-src \'self\'`
|
||||
: 'script-src \'self\'; object-src \'self\'',
|
||||
},
|
||||
}
|
||||
|
||||
// add sidepanel
|
||||
if (isFirefox) {
|
||||
manifest.sidebar_action = {
|
||||
default_panel: 'dist/sidepanel/index.html',
|
||||
}
|
||||
}
|
||||
else {
|
||||
// the sidebar_action does not work for chromium based
|
||||
(manifest as any).side_panel = {
|
||||
default_path: 'dist/sidepanel/index.html',
|
||||
}
|
||||
}
|
||||
|
||||
// FIXME: not work in MV3
|
||||
if (isDev && false) {
|
||||
// for content script, as browsers will cache them for each reload,
|
||||
// we use a background script to always inject the latest version
|
||||
// see src/background/contentScriptHMR.ts
|
||||
delete manifest.content_scripts
|
||||
manifest.permissions?.push('webNavigation')
|
||||
}
|
||||
|
||||
return manifest
|
||||
}
|
||||
157
BillNote_extension/src/options/Options.vue
Normal file
157
BillNote_extension/src/options/Options.vue
Normal file
@@ -0,0 +1,157 @@
|
||||
<script setup lang="ts">
|
||||
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'
|
||||
|
||||
const providers = ref<Provider[]>([])
|
||||
const models = ref<Model[]>([])
|
||||
const loading = ref(false)
|
||||
const status = ref<{ kind: 'idle' | 'ok' | 'err', text: string }>({ kind: 'idle', text: '' })
|
||||
|
||||
async function refreshProviders() {
|
||||
loading.value = true
|
||||
status.value = { kind: 'idle', text: '' }
|
||||
try {
|
||||
providers.value = (await getProviders()).filter(p => p.enabled === 1)
|
||||
if (settings.value.providerId)
|
||||
await refreshModels(settings.value.providerId)
|
||||
status.value = { kind: 'ok', text: `已加载 ${providers.value.length} 个供应商` }
|
||||
}
|
||||
catch (e) {
|
||||
status.value = { kind: 'err', text: `加载失败:${(e as Error).message}` }
|
||||
providers.value = []
|
||||
models.value = []
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshModels(providerId: string) {
|
||||
if (!providerId) {
|
||||
models.value = []
|
||||
return
|
||||
}
|
||||
try {
|
||||
models.value = await getModelsByProvider(providerId)
|
||||
}
|
||||
catch {
|
||||
models.value = []
|
||||
}
|
||||
}
|
||||
|
||||
async function testConnection() {
|
||||
status.value = { kind: 'idle', text: '正在测试…' }
|
||||
const ok = await ping()
|
||||
status.value = ok
|
||||
? { kind: 'ok', text: '后端连通 ✓' }
|
||||
: { kind: 'err', text: '无法连接后端,请检查地址、端口与 CORS 配置' }
|
||||
}
|
||||
|
||||
watch(() => settings.value?.providerId, (id) => {
|
||||
if (id)
|
||||
refreshModels(id)
|
||||
// 切换供应商时清空已选模型,避免错配
|
||||
if (id !== providers.value.find(p => p.id === id)?.id)
|
||||
settings.value.modelName = ''
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
await settingsReady
|
||||
if (settings.value.backendUrl)
|
||||
await refreshProviders()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="max-w-2xl mx-auto p-6 text-gray-800 dark:text-gray-100">
|
||||
<header class="flex items-center gap-2 mb-6">
|
||||
<h1 class="text-xl font-bold">BiliNote 浏览器插件 · 设置</h1>
|
||||
</header>
|
||||
|
||||
<section class="bg-white dark:bg-gray-800 border rounded p-4 mb-4 flex flex-col gap-3">
|
||||
<h2 class="font-semibold">后端地址</h2>
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
v-model="settings.backendUrl"
|
||||
class="flex-1 border rounded px-2 py-1"
|
||||
placeholder="http://localhost:8483"
|
||||
>
|
||||
<button class="btn-secondary" @click="testConnection">测试连通</button>
|
||||
<button class="btn-secondary" :disabled="loading" @click="refreshProviders">
|
||||
{{ loading ? '加载中…' : '刷新' }}
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
v-if="status.text"
|
||||
class="text-xs"
|
||||
:class="{
|
||||
'text-green-700': status.kind === 'ok',
|
||||
'text-red-600': status.kind === 'err',
|
||||
'text-gray-500': status.kind === 'idle',
|
||||
}"
|
||||
>
|
||||
{{ status.text }}
|
||||
</div>
|
||||
<p class="text-xs text-gray-500">
|
||||
默认 http://localhost:8483 — 需要在该地址先跑起 BiliNote 后端 (cd backend && python main.py)
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="bg-white dark:bg-gray-800 border rounded p-4 mb-4 flex flex-col gap-3">
|
||||
<h2 class="font-semibold">默认供应商与模型</h2>
|
||||
<label class="flex flex-col gap-1 text-sm">
|
||||
<span class="text-gray-600">供应商</span>
|
||||
<select v-model="settings.providerId" class="border rounded px-2 py-1">
|
||||
<option value="">— 选择供应商 —</option>
|
||||
<option v-for="p in providers" :key="p.id" :value="p.id">
|
||||
{{ p.name }} <span v-if="p.type === 'built-in'">(内置)</span>
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="flex flex-col gap-1 text-sm">
|
||||
<span class="text-gray-600">模型</span>
|
||||
<select v-model="settings.modelName" class="border rounded px-2 py-1" :disabled="!settings.providerId">
|
||||
<option value="">— 选择模型 —</option>
|
||||
<option v-for="m in models" :key="m.id" :value="m.model_name">{{ m.model_name }}</option>
|
||||
</select>
|
||||
<span v-if="settings.providerId && models.length === 0" class="text-xs text-amber-700">
|
||||
该供应商下还没有可用模型;请到桌面 web 端的「模型设置」里添加
|
||||
</span>
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<section class="bg-white dark:bg-gray-800 border rounded p-4 mb-4 flex flex-col gap-3">
|
||||
<h2 class="font-semibold">默认生成选项</h2>
|
||||
<div class="grid grid-cols-2 gap-3 text-sm">
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="text-gray-600">画质</span>
|
||||
<select v-model="settings.quality" class="border rounded px-2 py-1">
|
||||
<option value="fast">快速 (32k)</option>
|
||||
<option value="medium">中等 (64k)</option>
|
||||
<option value="slow">高质 (128k)</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="text-gray-600">笔记风格</span>
|
||||
<input v-model="settings.style" class="border rounded px-2 py-1" placeholder="留空使用默认">
|
||||
</label>
|
||||
<label class="flex items-center gap-2">
|
||||
<input v-model="settings.screenshot" type="checkbox"> 自动插入截图
|
||||
</label>
|
||||
<label class="flex items-center gap-2">
|
||||
<input v-model="settings.link" type="checkbox"> 插入原片跳转链接
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<p class="text-xs text-gray-500">
|
||||
所有设置自动保存。不在桌面端管理供应商/模型?请在 BiliNote web 端 (http://localhost:3015) 完成。
|
||||
</p>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.btn-secondary { @apply bg-gray-100 text-gray-700 px-3 py-1 rounded hover:bg-gray-200 text-sm disabled:opacity-50; }
|
||||
</style>
|
||||
12
BillNote_extension/src/options/index.html
Normal file
12
BillNote_extension/src/options/index.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<base target="_blank">
|
||||
<title>Options</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="./main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
8
BillNote_extension/src/options/main.ts
Normal file
8
BillNote_extension/src/options/main.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { createApp } from 'vue'
|
||||
import App from './Options.vue'
|
||||
import { setupApp } from '~/logic/common-setup'
|
||||
import '../styles'
|
||||
|
||||
const app = createApp(App)
|
||||
setupApp(app)
|
||||
app.mount('#app')
|
||||
212
BillNote_extension/src/popup/Popup.vue
Normal file
212
BillNote_extension/src/popup/Popup.vue
Normal file
@@ -0,0 +1,212 @@
|
||||
<script setup lang="ts">
|
||||
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 type { TaskRecord } from '~/logic/types'
|
||||
|
||||
const tabUrl = ref<string>('')
|
||||
const tabTitle = ref<string>('')
|
||||
const platform = computed(() => detectPlatform(tabUrl.value))
|
||||
const supported = computed(() => platform.value !== null)
|
||||
|
||||
const submitting = ref(false)
|
||||
const errorMsg = ref('')
|
||||
const activeTaskId = ref<string>('')
|
||||
const activeTask = computed<TaskRecord | undefined>(() => tasks.value?.find(t => t.taskId === activeTaskId.value))
|
||||
|
||||
let pollTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
async function loadActiveTab() {
|
||||
try {
|
||||
const [tab] = await browser.tabs.query({ active: true, currentWindow: true })
|
||||
tabUrl.value = tab?.url ?? ''
|
||||
tabTitle.value = tab?.title ?? ''
|
||||
}
|
||||
catch (e) {
|
||||
console.warn('无法读取当前 tab:', e)
|
||||
}
|
||||
}
|
||||
|
||||
async function poll(taskId: string) {
|
||||
try {
|
||||
const res = await getTaskStatus(taskId)
|
||||
upsertTask({
|
||||
taskId,
|
||||
videoUrl: activeTask.value?.videoUrl ?? tabUrl.value,
|
||||
platform: (activeTask.value?.platform ?? platform.value)!,
|
||||
status: res.status,
|
||||
message: res.message,
|
||||
createdAt: activeTask.value?.createdAt ?? Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
result: res.result ?? activeTask.value?.result,
|
||||
})
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
async function start() {
|
||||
errorMsg.value = ''
|
||||
if (!supported.value) {
|
||||
errorMsg.value = '当前页面不是支持的视频链接'
|
||||
return
|
||||
}
|
||||
if (!settings.value.providerId || !settings.value.modelName) {
|
||||
errorMsg.value = '请先去设置页选择供应商和模型'
|
||||
return
|
||||
}
|
||||
submitting.value = true
|
||||
try {
|
||||
const { task_id } = await generateNote({
|
||||
video_url: tabUrl.value,
|
||||
platform: platform.value!,
|
||||
quality: settings.value.quality,
|
||||
provider_id: settings.value.providerId,
|
||||
model_name: settings.value.modelName,
|
||||
screenshot: settings.value.screenshot,
|
||||
link: settings.value.link,
|
||||
style: settings.value.style || undefined,
|
||||
format: [
|
||||
...(settings.value.screenshot ? ['screenshot'] : []),
|
||||
...(settings.value.link ? ['link'] : []),
|
||||
],
|
||||
})
|
||||
activeTaskId.value = task_id
|
||||
upsertTask({
|
||||
taskId: task_id,
|
||||
videoUrl: tabUrl.value,
|
||||
platform: platform.value!,
|
||||
status: 'PENDING',
|
||||
message: '已提交',
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
})
|
||||
poll(task_id)
|
||||
}
|
||||
catch (e) {
|
||||
errorMsg.value = (e as Error).message
|
||||
}
|
||||
finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openOptions() {
|
||||
browser.runtime.openOptionsPage()
|
||||
}
|
||||
|
||||
function selectTask(id: string) {
|
||||
activeTaskId.value = id
|
||||
const t = tasks.value?.find(x => x.taskId === id)
|
||||
if (t && t.status !== 'SUCCESS' && t.status !== 'FAILED')
|
||||
poll(id)
|
||||
}
|
||||
|
||||
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
|
||||
poll(running.taskId)
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (pollTimer)
|
||||
clearTimeout(pollTimer)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="w-[400px] p-3 text-sm text-gray-800 flex flex-col gap-3 bg-white">
|
||||
<header class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-semibold text-base">BiliNote</span>
|
||||
<PlatformBadge :platform="platform" />
|
||||
</div>
|
||||
<button class="text-xs text-gray-500 hover:text-gray-800" @click="openOptions">设置</button>
|
||||
</header>
|
||||
|
||||
<div class="text-xs text-gray-500 truncate" :title="tabUrl">
|
||||
{{ tabUrl || '当前没有打开的标签页' }}
|
||||
</div>
|
||||
|
||||
<div v-if="!supported" class="text-xs text-amber-700 bg-amber-50 p-2 rounded">
|
||||
当前页面不是 BiliNote 支持的视频链接(Bilibili / YouTube / Douyin / Kuaishou)
|
||||
</div>
|
||||
|
||||
<fieldset class="border rounded p-2 flex flex-col gap-2" :disabled="!supported || submitting">
|
||||
<div class="grid grid-cols-3 gap-2 text-xs">
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="text-gray-600">画质</span>
|
||||
<select v-model="settings.quality" class="border rounded px-1 py-0.5">
|
||||
<option value="fast">快速</option>
|
||||
<option value="medium">中等</option>
|
||||
<option value="slow">高质</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="flex items-center gap-1 mt-4">
|
||||
<input v-model="settings.screenshot" type="checkbox"> 截图
|
||||
</label>
|
||||
<label class="flex items-center gap-1 mt-4">
|
||||
<input v-model="settings.link" type="checkbox"> 跳转
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="text-xs text-gray-600">
|
||||
<span v-if="settings.providerId && settings.modelName">
|
||||
模型:{{ settings.modelName }}
|
||||
</span>
|
||||
<span v-else class="text-amber-700">
|
||||
⚠ 未选择供应商/模型,
|
||||
<button class="underline" @click="openOptions">去设置</button>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<button class="btn-primary" :disabled="!supported || submitting || !settings.providerId" @click="start">
|
||||
{{ submitting ? '提交中…' : '生成笔记' }}
|
||||
</button>
|
||||
</fieldset>
|
||||
|
||||
<div v-if="errorMsg" class="text-xs text-red-600 break-words">
|
||||
{{ errorMsg }}
|
||||
</div>
|
||||
|
||||
<section v-if="activeTask" class="flex flex-col gap-2">
|
||||
<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"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<details v-if="(tasks?.length ?? 0) > 0" class="text-xs">
|
||||
<summary class="cursor-pointer text-gray-500">最近任务({{ tasks!.length }})</summary>
|
||||
<ul class="mt-1 flex flex-col gap-1 max-h-32 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>
|
||||
|
||||
<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; }
|
||||
</style>
|
||||
12
BillNote_extension/src/popup/index.html
Normal file
12
BillNote_extension/src/popup/index.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<base target="_blank">
|
||||
<title>Popup</title>
|
||||
</head>
|
||||
<body style="min-width: 100px">
|
||||
<div id="app"></div>
|
||||
<script type="module" src="./main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
8
BillNote_extension/src/popup/main.ts
Normal file
8
BillNote_extension/src/popup/main.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { createApp } from 'vue'
|
||||
import App from './Popup.vue'
|
||||
import { setupApp } from '~/logic/common-setup'
|
||||
import '../styles'
|
||||
|
||||
const app = createApp(App)
|
||||
setupApp(app)
|
||||
app.mount('#app')
|
||||
18
BillNote_extension/src/sidepanel/Sidepanel.vue
Normal file
18
BillNote_extension/src/sidepanel/Sidepanel.vue
Normal file
@@ -0,0 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
function openOptionsPage() {
|
||||
browser.runtime.openOptionsPage()
|
||||
}
|
||||
</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>
|
||||
</template>
|
||||
12
BillNote_extension/src/sidepanel/index.html
Normal file
12
BillNote_extension/src/sidepanel/index.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<base target="_blank">
|
||||
<title>Sidepanel</title>
|
||||
</head>
|
||||
<body style="min-width: 100px">
|
||||
<div id="app"></div>
|
||||
<script type="module" src="./main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
8
BillNote_extension/src/sidepanel/main.ts
Normal file
8
BillNote_extension/src/sidepanel/main.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { createApp } from 'vue'
|
||||
import App from './Sidepanel.vue'
|
||||
import { setupApp } from '~/logic/common-setup'
|
||||
import '../styles'
|
||||
|
||||
const app = createApp(App)
|
||||
setupApp(app)
|
||||
app.mount('#app')
|
||||
3
BillNote_extension/src/styles/index.ts
Normal file
3
BillNote_extension/src/styles/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import '@unocss/reset/tailwind.css'
|
||||
import './main.css'
|
||||
import 'uno.css'
|
||||
20
BillNote_extension/src/styles/main.css
Executable file
20
BillNote_extension/src/styles/main.css
Executable file
@@ -0,0 +1,20 @@
|
||||
html,
|
||||
body,
|
||||
#app {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.btn {
|
||||
@apply px-4 py-1 rounded inline-block
|
||||
bg-teal-600 text-white cursor-pointer
|
||||
hover:bg-teal-700
|
||||
disabled:cursor-default disabled:bg-gray-600 disabled:opacity-50;
|
||||
}
|
||||
|
||||
.icon-btn {
|
||||
@apply inline-block cursor-pointer select-none
|
||||
opacity-75 transition duration-200 ease-in-out
|
||||
hover:opacity-100 hover:text-teal-600;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
7
BillNote_extension/src/tests/demo.spec.ts
Normal file
7
BillNote_extension/src/tests/demo.spec.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
describe('demo', () => {
|
||||
it('should work', () => {
|
||||
expect(1 + 1).toBe(2)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user