Initial commit
This commit is contained in:
24
extension/.gitignore
vendored
Normal file
24
extension/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
3
extension/.vscode/extensions.json
vendored
Normal file
3
extension/.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"recommendations": ["Vue.volar"]
|
||||
}
|
||||
5
extension/README.md
Normal file
5
extension/README.md
Normal file
@@ -0,0 +1,5 @@
|
||||
# Vue 3 + TypeScript + Vite
|
||||
|
||||
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
|
||||
|
||||
Learn more about the recommended Project Setup and IDE Support in the [Vue Docs TypeScript Guide](https://vuejs.org/guide/typescript/overview.html#project-setup).
|
||||
13
extension/index.html
Normal file
13
extension/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>extension</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
33
extension/manifest.json
Normal file
33
extension/manifest.json
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "InsightReply",
|
||||
"version": "1.0.0",
|
||||
"description": "InsightReply 是一个帮助创始人在行业热点中增强社交表达并且输出高质评论的助手",
|
||||
"action": {
|
||||
"default_popup": "index.html"
|
||||
},
|
||||
"background": {
|
||||
"service_worker": "src/background/index.ts",
|
||||
"type": "module"
|
||||
},
|
||||
"permissions": [
|
||||
"storage",
|
||||
"activeTab"
|
||||
],
|
||||
"host_permissions": [
|
||||
"https://twitter.com/*",
|
||||
"https://x.com/*"
|
||||
],
|
||||
"content_scripts": [
|
||||
{
|
||||
"js": [
|
||||
"src/content/index.ts",
|
||||
"src/content/sidebar-mount.ts"
|
||||
],
|
||||
"matches": [
|
||||
"https://twitter.com/*",
|
||||
"https://x.com/*"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
3139
extension/package-lock.json
generated
Normal file
3139
extension/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
30
extension/package.json
Normal file
30
extension/package.json
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "extension",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc -b && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"clsx": "^2.1.1",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"vue": "^3.5.25"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@crxjs/vite-plugin": "^2.0.0-beta.33",
|
||||
"@tailwindcss/postcss": "^4.2.1",
|
||||
"@types/chrome": "^0.1.37",
|
||||
"@types/node": "^24.10.1",
|
||||
"@vitejs/plugin-vue": "^6.0.2",
|
||||
"@vue/tsconfig": "^0.8.1",
|
||||
"autoprefixer": "^10.4.27",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^4.2.1",
|
||||
"typescript": "~5.9.3",
|
||||
"vite": "^7.3.1",
|
||||
"vue-tsc": "^3.1.5"
|
||||
}
|
||||
}
|
||||
6
extension/postcss.config.js
Normal file
6
extension/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
'@tailwindcss/postcss': {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
1
extension/public/vite.svg
Normal file
1
extension/public/vite.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
59
extension/src/App.vue
Normal file
59
extension/src/App.vue
Normal file
@@ -0,0 +1,59 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { cn } from './lib/utils'
|
||||
|
||||
const isLoading = ref(false)
|
||||
|
||||
const triggerMockLoading = () => {
|
||||
isLoading.value = true
|
||||
setTimeout(() => {
|
||||
isLoading.value = false
|
||||
}, 1000)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-[400px] h-[600px] bg-[#0A0A0A]/90 backdrop-blur-xl border border-white/10 text-[#E5E5E5] p-6 flex flex-col font-sans">
|
||||
|
||||
<!-- Title Area -->
|
||||
<div class="mb-6">
|
||||
<h1 class="text-xl font-medium tracking-tight bg-gradient-to-r from-violet-500 to-blue-500 bg-clip-text text-transparent inline-block">
|
||||
InsightReply
|
||||
</h1>
|
||||
<p class="text-xs text-zinc-400 mt-1">Social Insight Copilot</p>
|
||||
</div>
|
||||
|
||||
<!-- Main Content Area -->
|
||||
<div class="flex-1 overflow-y-auto pr-2 space-y-4">
|
||||
|
||||
<!-- Example Heat Score Card -->
|
||||
<div class="bg-[#171717] rounded-xl p-4 border border-white/5 shadow-lg shadow-black/50">
|
||||
<div class="flex justify-between items-center mb-2">
|
||||
<span class="text-sm font-medium text-zinc-300">Current Tweet Heat</span>
|
||||
<span class="text-xs font-bold px-2 py-0.5 rounded-full bg-orange-500/20 text-orange-400 border border-orange-500/20">Hot</span>
|
||||
</div>
|
||||
<div class="flex items-end gap-2">
|
||||
<span class="text-3xl font-semibold tracking-tighter">87.5</span>
|
||||
<span class="text-xs text-zinc-500 mb-1">/ 100</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action Button -->
|
||||
<button
|
||||
@click="triggerMockLoading"
|
||||
:disabled="isLoading"
|
||||
:class="cn(
|
||||
'w-full py-2.5 rounded-lg text-sm font-medium transition-all duration-200 ease-in-out',
|
||||
'flex items-center justify-center gap-2',
|
||||
isLoading
|
||||
? 'bg-[#171717] text-zinc-500 border border-white/5 cursor-not-allowed'
|
||||
: 'bg-brand-primary hover:bg-brand-primary/90 text-white shadow-lg shadow-brand-primary/20 hover:scale-[0.98]'
|
||||
)"
|
||||
>
|
||||
<span v-if="isLoading" class="animate-spin inline-block w-4 h-4 border-2 border-white/20 border-t-white rounded-full"></span>
|
||||
{{ isLoading ? 'Generating Insights...' : 'Generate Replies' }}
|
||||
</button>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
16
extension/src/assets/tailwind.css
Normal file
16
extension/src/assets/tailwind.css
Normal file
@@ -0,0 +1,16 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@theme {
|
||||
--color-brand-primary: #8B5CF6;
|
||||
--color-brand-secondary: #3B82F6;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
body {
|
||||
@apply bg-[#0A0A0A] text-[#E5E5E5] antialiased;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background-color: rgb(139 92 246 / 0.3);
|
||||
}
|
||||
}
|
||||
1
extension/src/assets/vue.svg
Normal file
1
extension/src/assets/vue.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 496 B |
39
extension/src/background/index.ts
Normal file
39
extension/src/background/index.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* InsightReply Background Script
|
||||
* 负责中转消息、管理 OAuth 以及与 Go 后端 API 通信
|
||||
*/
|
||||
|
||||
console.log('InsightReply Background Script Loaded');
|
||||
|
||||
const API_BASE = 'http://localhost:8080/api/v1';
|
||||
|
||||
chrome.runtime.onMessage.addListener((message: { type: string; payload?: any }, _sender: chrome.runtime.MessageSender, sendResponse: (response?: any) => void) => {
|
||||
if (message.type === 'SHOW_INSIGHT') {
|
||||
console.log('Received tweet data in background:', message.payload);
|
||||
}
|
||||
|
||||
if (message.type === 'GENERATE_REPLY') {
|
||||
const { tweetContent, strategy, identity } = message.payload;
|
||||
|
||||
fetch(`${API_BASE}/ai/generate`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
tweet_content: tweetContent,
|
||||
strategy: strategy,
|
||||
identity: identity || 'Independent Developer / Founder'
|
||||
})
|
||||
})
|
||||
.then(resp => resp.json())
|
||||
.then(data => {
|
||||
sendResponse({ success: true, data: data.data });
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('API Error:', err);
|
||||
sendResponse({ success: false, error: err.message });
|
||||
});
|
||||
|
||||
return true; // Keep channel open for async response
|
||||
}
|
||||
return true;
|
||||
});
|
||||
41
extension/src/components/HelloWorld.vue
Normal file
41
extension/src/components/HelloWorld.vue
Normal file
@@ -0,0 +1,41 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
defineProps<{ msg: string }>()
|
||||
|
||||
const count = ref(0)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<h1>{{ msg }}</h1>
|
||||
|
||||
<div class="card">
|
||||
<button type="button" @click="count++">count is {{ count }}</button>
|
||||
<p>
|
||||
Edit
|
||||
<code>components/HelloWorld.vue</code> to test HMR
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p>
|
||||
Check out
|
||||
<a href="https://vuejs.org/guide/quick-start.html#local" target="_blank"
|
||||
>create-vue</a
|
||||
>, the official Vue + Vite starter
|
||||
</p>
|
||||
<p>
|
||||
Learn more about IDE Support for Vue in the
|
||||
<a
|
||||
href="https://vuejs.org/guide/scaling-up/tooling.html#ide-support"
|
||||
target="_blank"
|
||||
>Vue Docs Scaling up Guide</a
|
||||
>.
|
||||
</p>
|
||||
<p class="read-the-docs">Click on the Vite and Vue logos to learn more</p>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.read-the-docs {
|
||||
color: #888;
|
||||
}
|
||||
</style>
|
||||
143
extension/src/content/Sidebar.vue
Normal file
143
extension/src/content/Sidebar.vue
Normal file
@@ -0,0 +1,143 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
tweetData?: {
|
||||
author: string;
|
||||
content: string;
|
||||
stats: {
|
||||
replies: string;
|
||||
retweets: string;
|
||||
likes: string;
|
||||
}
|
||||
}
|
||||
}>()
|
||||
|
||||
const isVisible = ref(true)
|
||||
const selectedStrategy = ref('Insightful')
|
||||
const generatedReply = ref('')
|
||||
const isGenerating = ref(false)
|
||||
|
||||
const strategies = [
|
||||
{ id: 'Insightful', label: '认知升级型', icon: '🧠' },
|
||||
{ id: 'Humorous', label: '幽默风趣型', icon: '😄' },
|
||||
{ id: 'Professional', label: '专业严谨型', icon: '⚖️' },
|
||||
{ id: 'Supportive', label: '共鸣支持型', icon: '❤️' },
|
||||
{ id: 'Critical', label: '锐评批判型', icon: '🔥' }
|
||||
]
|
||||
|
||||
const generate = () => {
|
||||
if (!props.tweetData) return
|
||||
|
||||
isGenerating.value = true
|
||||
chrome.runtime.sendMessage({
|
||||
type: 'GENERATE_REPLY',
|
||||
payload: {
|
||||
tweetContent: props.tweetData.content,
|
||||
strategy: selectedStrategy.value,
|
||||
identity: 'Independent Developer / Founder' // Could be dynamic later
|
||||
}
|
||||
}, (response) => {
|
||||
isGenerating.value = false
|
||||
if (response && response.success) {
|
||||
generatedReply.value = response.data.reply
|
||||
} else {
|
||||
generatedReply.value = 'Failed to generate reply. Please check your connection or API key.'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const copyToClipboard = () => {
|
||||
navigator.clipboard.writeText(generatedReply.value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="isVisible" class="fixed right-4 top-20 w-[360px] max-h-[85vh] flex flex-col bg-[#0A0A0A]/90 backdrop-blur-2xl border border-white/10 rounded-2xl shadow-2xl text-[#E5E5E5] font-sans z-[9999] overflow-hidden">
|
||||
|
||||
<!-- Header -->
|
||||
<div class="p-4 border-b border-white/5 flex justify-between items-center bg-white/5">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-2 h-2 rounded-full bg-brand-primary animate-pulse"></div>
|
||||
<span class="text-sm font-medium tracking-wide">InsightReply AI</span>
|
||||
</div>
|
||||
<button @click="isVisible = false" class="text-zinc-500 hover:text-white transition-colors">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 6L6 18M6 6l12 12"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="p-5 flex-1 overflow-y-auto space-y-6">
|
||||
|
||||
<!-- Tweet Context (Small Preview) -->
|
||||
<div v-if="tweetData" class="bg-white/5 rounded-xl p-3 border border-white/5">
|
||||
<div class="text-[10px] text-zinc-500 mb-1 font-mono uppercase">Context</div>
|
||||
<div class="text-xs text-zinc-400 line-clamp-2 italic">" {{ tweetData.content }} "</div>
|
||||
</div>
|
||||
|
||||
<!-- Strategy Selector -->
|
||||
<div class="space-y-3">
|
||||
<span class="text-xs font-semibold text-zinc-500 uppercase tracking-widest">Generation Strategy</span>
|
||||
<div class="grid grid-cols-1 gap-2">
|
||||
<button
|
||||
v-for="s in strategies"
|
||||
:key="s.id"
|
||||
@click="selectedStrategy = s.id"
|
||||
:class="[
|
||||
'flex items-center gap-3 p-3 rounded-xl border transition-all duration-200 text-sm group',
|
||||
selectedStrategy === s.id
|
||||
? 'bg-brand-primary/20 border-brand-primary text-white shadow-lg shadow-brand-primary/10'
|
||||
: 'bg-white/5 border-transparent hover:bg-white/10 text-zinc-400'
|
||||
]"
|
||||
>
|
||||
<span class="text-lg">{{ s.icon }}</span>
|
||||
<span class="flex-1 text-left">{{ s.label }}</span>
|
||||
<div v-if="selectedStrategy === s.id" class="w-1.5 h-1.5 rounded-full bg-brand-primary"></div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Result Area -->
|
||||
<div v-if="generatedReply" class="space-y-3 animate-in fade-in slide-in-from-bottom-2 duration-500">
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-xs font-semibold text-zinc-500 uppercase tracking-widest">Assistant Suggestion</span>
|
||||
<button @click="copyToClipboard" class="text-[10px] text-brand-primary hover:underline">Copy Result</button>
|
||||
</div>
|
||||
<div class="bg-[#171717] rounded-xl p-4 border border-white/10 text-sm leading-relaxed whitespace-pre-wrap">
|
||||
{{ generatedReply }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Footer Action -->
|
||||
<div class="p-4 bg-white/5 border-t border-white/5">
|
||||
<button
|
||||
@click="generate"
|
||||
:disabled="isGenerating"
|
||||
class="w-full py-3 bg-gradient-to-r from-violet-600 to-blue-600 hover:from-violet-500 hover:to-blue-500 disabled:from-zinc-800 disabled:to-zinc-800 disabled:text-zinc-500 disabled:cursor-not-allowed text-white rounded-xl text-sm font-semibold transition-all shadow-xl shadow-brand-primary/20 flex items-center justify-center gap-2"
|
||||
>
|
||||
<div v-if="isGenerating" class="w-4 h-4 border-2 border-white/20 border-t-white rounded-full animate-spin"></div>
|
||||
{{ isGenerating ? 'AI Thinking...' : 'Generate High-Quality Reply' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 局部样式保证不溢出 */
|
||||
::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 10px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
</style>
|
||||
107
extension/src/content/index.ts
Normal file
107
extension/src/content/index.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* InsightReply Content Script
|
||||
* 负责解析 X (Twitter) 页面 DOM,提取推文内容并注入交互按钮
|
||||
*/
|
||||
|
||||
console.log('InsightReply Content Script Loaded');
|
||||
|
||||
// 1. 定义推文数据结构
|
||||
interface TweetData {
|
||||
id: string;
|
||||
author: string;
|
||||
content: string;
|
||||
stats: {
|
||||
replies: string;
|
||||
retweets: string;
|
||||
likes: string;
|
||||
};
|
||||
}
|
||||
|
||||
// 2. 提取推文内容的逻辑
|
||||
const extractTweetData = (tweetElement: HTMLElement): TweetData | null => {
|
||||
try {
|
||||
// 根据 X 的 DOM 结构提取 (可能会随 Twitter 更新而变化)
|
||||
const textElement = tweetElement.querySelector('[data-testid="tweetText"]');
|
||||
const authorElement = tweetElement.querySelector('[data-testid="User-Name"]');
|
||||
const linkElement = tweetElement.querySelector('time')?.parentElement as HTMLAnchorElement;
|
||||
|
||||
// 互动数据提取
|
||||
const getStat = (testid: string) => {
|
||||
const el = tweetElement.querySelector(`[data-testid="${testid}"]`);
|
||||
return el?.getAttribute('aria-label') || '0';
|
||||
};
|
||||
|
||||
if (!textElement || !authorElement || !linkElement) return null;
|
||||
|
||||
const tweetId = linkElement.href.split('/').pop() || '';
|
||||
|
||||
return {
|
||||
id: tweetId,
|
||||
author: authorElement.textContent || 'Unknown',
|
||||
content: textElement.textContent || '',
|
||||
stats: {
|
||||
replies: getStat('reply'),
|
||||
retweets: getStat('retweet'),
|
||||
likes: getStat('like'),
|
||||
}
|
||||
};
|
||||
} catch (e) {
|
||||
console.error('Failed to extract tweet data:', e);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// 3. 注入“Insight”按钮
|
||||
const injectInsightButton = (tweetElement: HTMLElement) => {
|
||||
// 查找操作栏 (Actions bar)
|
||||
const actionBar = tweetElement.querySelector('[role="group"]');
|
||||
if (!actionBar || actionBar.querySelector('.insight-reply-btn')) return;
|
||||
|
||||
// 创建按钮
|
||||
const btnContainer = document.createElement('div');
|
||||
btnContainer.className = 'insight-reply-btn';
|
||||
btnContainer.style.display = 'flex';
|
||||
btnContainer.style.alignItems = 'center';
|
||||
btnContainer.style.marginLeft = '12px';
|
||||
btnContainer.style.cursor = 'pointer';
|
||||
|
||||
// 按钮内部图标 (简易版)
|
||||
btnContainer.innerHTML = `
|
||||
<div style="padding: 4px; border-radius: 9999px; transition: background 0.2s;" onmouseover="this.style.background='rgba(139, 92, 246, 0.1)'" onmouseout="this.style.background='transparent'">
|
||||
<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor" style="color: #8B5CF6;">
|
||||
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
`;
|
||||
|
||||
btnContainer.onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
const data = extractTweetData(tweetElement);
|
||||
console.log('Target Tweet Data:', data);
|
||||
|
||||
// 发送消息给插件侧边栏/Popup (后续实现)
|
||||
if (data) {
|
||||
chrome.runtime.sendMessage({ type: 'SHOW_INSIGHT', payload: data });
|
||||
}
|
||||
};
|
||||
|
||||
actionBar.appendChild(btnContainer);
|
||||
};
|
||||
|
||||
// 4. 定时或监听 DOM 变化进行扫描
|
||||
const scanTweets = () => {
|
||||
const tweets = document.querySelectorAll('article[data-testid="tweet"]');
|
||||
tweets.forEach((tweet) => {
|
||||
injectInsightButton(tweet as HTMLElement);
|
||||
});
|
||||
};
|
||||
|
||||
// 使用 MutationObserver 监听动态加载
|
||||
const observer = new MutationObserver(() => {
|
||||
scanTweets();
|
||||
});
|
||||
|
||||
observer.observe(document.body, { childList: true, subtree: true });
|
||||
|
||||
// 初始扫描
|
||||
scanTweets();
|
||||
51
extension/src/content/sidebar-mount.ts
Normal file
51
extension/src/content/sidebar-mount.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { createApp } from 'vue'
|
||||
import Sidebar from './Sidebar.vue'
|
||||
import '../assets/tailwind.css' // We might need to handle this specially for Shadow DOM
|
||||
|
||||
const MOUNT_ID = 'insight-reply-sidebar-root'
|
||||
|
||||
function initSidebar(tweetData?: any) {
|
||||
if (document.getElementById(MOUNT_ID)) return
|
||||
|
||||
// 1. Create Host Element
|
||||
const host = document.createElement('div')
|
||||
host.id = MOUNT_ID
|
||||
document.body.appendChild(host)
|
||||
|
||||
// 2. Create Shadow Root
|
||||
const shadowRoot = host.attachShadow({ mode: 'open' })
|
||||
|
||||
// 3. Create Container for Vue
|
||||
const container = document.createElement('div')
|
||||
container.id = 'app'
|
||||
shadowRoot.appendChild(container)
|
||||
|
||||
// 4. Inject Styles into Shadow DOM
|
||||
// Note: In development/build, we need to find the generated CSS and inject it.
|
||||
// CRXJS usually puts CSS in <link> tags in the head for content scripts.
|
||||
// For Shadow DOM, we need to move or clone them into the shadow root.
|
||||
const injectStyles = () => {
|
||||
const styles = document.querySelectorAll('style, link[rel="stylesheet"]')
|
||||
styles.forEach(style => {
|
||||
// Only clone styles that look like they belong to our extension
|
||||
// This is a heuristic, in a real build we'd use the asset URL
|
||||
shadowRoot.appendChild(style.cloneNode(true))
|
||||
})
|
||||
}
|
||||
|
||||
// Initial injection
|
||||
injectStyles()
|
||||
|
||||
// 5. Create Vue App
|
||||
const app = createApp(Sidebar, { tweetData })
|
||||
app.mount(container)
|
||||
|
||||
console.log('InsightReply Sidebar Mounted in Shadow DOM');
|
||||
}
|
||||
|
||||
// Listen for messages to show/hide or update data
|
||||
chrome.runtime.onMessage.addListener((message) => {
|
||||
if (message.type === 'SHOW_INSIGHT') {
|
||||
initSidebar(message.payload)
|
||||
}
|
||||
});
|
||||
6
extension/src/lib/utils.ts
Normal file
6
extension/src/lib/utils.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
5
extension/src/main.ts
Normal file
5
extension/src/main.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { createApp } from 'vue'
|
||||
import './assets/tailwind.css'
|
||||
import App from './App.vue'
|
||||
|
||||
createApp(App).mount('#app')
|
||||
79
extension/src/style.css
Normal file
79
extension/src/style.css
Normal file
@@ -0,0 +1,79 @@
|
||||
:root {
|
||||
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||
line-height: 1.5;
|
||||
font-weight: 400;
|
||||
|
||||
color-scheme: light dark;
|
||||
color: rgba(255, 255, 255, 0.87);
|
||||
background-color: #242424;
|
||||
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
a {
|
||||
font-weight: 500;
|
||||
color: #646cff;
|
||||
text-decoration: inherit;
|
||||
}
|
||||
a:hover {
|
||||
color: #535bf2;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
place-items: center;
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 3.2em;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
button {
|
||||
border-radius: 8px;
|
||||
border: 1px solid transparent;
|
||||
padding: 0.6em 1.2em;
|
||||
font-size: 1em;
|
||||
font-weight: 500;
|
||||
font-family: inherit;
|
||||
background-color: #1a1a1a;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.25s;
|
||||
}
|
||||
button:hover {
|
||||
border-color: #646cff;
|
||||
}
|
||||
button:focus,
|
||||
button:focus-visible {
|
||||
outline: 4px auto -webkit-focus-ring-color;
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 2em;
|
||||
}
|
||||
|
||||
#app {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root {
|
||||
color: #213547;
|
||||
background-color: #ffffff;
|
||||
}
|
||||
a:hover {
|
||||
color: #747bff;
|
||||
}
|
||||
button {
|
||||
background-color: #f9f9f9;
|
||||
}
|
||||
}
|
||||
7
extension/src/vite-env.d.ts
vendored
Normal file
7
extension/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare module "*.vue" {
|
||||
import type { DefineComponent } from "vue";
|
||||
const component: DefineComponent<{}, {}, any>;
|
||||
export default component;
|
||||
}
|
||||
16
extension/tsconfig.app.json
Normal file
16
extension/tsconfig.app.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"types": ["vite/client", "chrome"],
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
|
||||
}
|
||||
7
extension/tsconfig.json
Normal file
7
extension/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
26
extension/tsconfig.node.json
Normal file
26
extension/tsconfig.node.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "ES2023",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"types": ["node"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
12
extension/vite.config.ts
Normal file
12
extension/vite.config.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import { crx } from '@crxjs/vite-plugin'
|
||||
import manifest from './manifest.json' assert { type: 'json' }
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
vue(),
|
||||
crx({ manifest }),
|
||||
],
|
||||
})
|
||||
Reference in New Issue
Block a user