Skip to content

Commit 6e286ea

Browse files
committed
feat: 新增侧边栏功能
1 parent dfc6056 commit 6e286ea

20 files changed

Lines changed: 1176 additions & 103 deletions

CHANGELOG.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,3 @@
1-
- style: 修复管理权限用户头像光圈在简洁模式下的冲突
1+
- style: 修复管理权限用户头像光圈在简洁模式下的冲突
2+
- feat: 支持弹窗/侧边双重切换,暂不可同时存在
3+
- feat: 支持在弹窗/侧边快速查看收藏夹

entrypoints/background.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,44 @@ export default defineBackground(() => {
33
});
44
const browserAPI = (typeof browser !== 'undefined' ? browser : chrome);
55

6+
// 根据用户偏好切换点击扩展图标时的打开行为(sidepanel 或 popup)
7+
const CLICK_BEHAVIOR_KEY = 'clickOpenTarget'; // 'sidepanel' | 'popup'
8+
9+
async function applyClickBehavior(target) {
10+
const isSidePanel = target === 'sidepanel';
11+
try {
12+
if (browserAPI.sidePanel && browserAPI.sidePanel.setPanelBehavior) {
13+
await browserAPI.sidePanel.setPanelBehavior({ openPanelOnActionClick: isSidePanel });
14+
}
15+
} catch (err) {
16+
console.warn('sidePanel.setPanelBehavior not available', err);
17+
}
18+
19+
try {
20+
// 当选择 sidepanel 时,清空 action 的 popup;选择 popup 时,设置为 popup.html
21+
if (browserAPI.action && browserAPI.action.setPopup) {
22+
await browserAPI.action.setPopup({ popup: isSidePanel ? '' : 'popup.html' });
23+
}
24+
} catch (err) {
25+
console.warn('action.setPopup not available', err);
26+
}
27+
}
28+
29+
// 初始化:读取存储并应用行为;默认 sidepanel
30+
browserAPI.storage?.local.get([CLICK_BEHAVIOR_KEY], (res) => {
31+
const target = res?.[CLICK_BEHAVIOR_KEY] || 'sidepanel';
32+
applyClickBehavior(target);
33+
});
34+
35+
// 监听存储变化,动态应用
36+
browserAPI.storage?.onChanged?.addListener((changes, area) => {
37+
if (area !== 'local') return;
38+
if (CLICK_BEHAVIOR_KEY in changes) {
39+
const newValue = changes[CLICK_BEHAVIOR_KEY]?.newValue || 'sidepanel';
40+
applyClickBehavior(newValue);
41+
}
42+
});
43+
644
browserAPI.runtime.onMessage.addListener((request, sender, sendResponse) => {
745
if (request.action === 'sendData') {
846
// 查询所有打开的标签页

entrypoints/popup/App.vue

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
<ul class="nav">
44
<li :class="{ act: activeTab === 'hot' }" @click="changeTab('hot')">最热</li>
55
<li :class="{ act: activeTab === 'news' }" @click="changeTab('news')">最新</li>
6+
<li :class="{ act: activeTab === 'book' }" @click="changeTab('book')">收藏</li>
67
<li :class="{ act: activeTab === 'menu' }" @click="changeTab('menu')">菜单</li>
78
</ul>
89
<div class="fixed-icon">
@@ -26,6 +27,9 @@
2627
<NewsPosts v-if="newslist.length > 0" :list="newslist" />
2728
<p v-else>{{ nonewlist }}</p>
2829
</div>
30+
<div class="content" :class="{ act: activeTab === 'book' }">
31+
<Bookmark :bookmarkData="bookmarkDataList" />
32+
</div>
2933
<div class="content" :class="{ act: activeTab === 'menu' }">
3034
<SettingMenu />
3135
</div>
@@ -35,6 +39,7 @@
3539
<script>
3640
import HotPosts from "./components/HotPosts.vue";
3741
import NewsPosts from "./components/NewsPosts.vue";
42+
import Bookmark from "./components/Bookmark.vue";
3843
import SettingMenu from "./components/SettingMenu.vue";
3944
import Refresh from "./components/SVG/Refresh.vue";
4045
@@ -52,11 +57,15 @@ export default {
5257
lastScrollPosition: 0, // 存储滚动位置
5358
5459
themes: "light", // 主题风格
60+
61+
// 从 bookmark 页面共享的书签数据(localStorage: bookmarkData)
62+
bookmarkDataList: [],
5563
};
5664
},
5765
components: {
5866
HotPosts,
5967
NewsPosts,
68+
Bookmark,
6069
SettingMenu,
6170
Refresh,
6271
},
@@ -135,6 +144,97 @@ export default {
135144
});
136145
}
137146
},
147+
148+
// 获取本地存储中的书签数据
149+
getBookmarkDataFromLocalStorage() {
150+
try {
151+
const raw = localStorage.getItem("bookmarkData");
152+
if (!raw) return [];
153+
const parsed = JSON.parse(raw);
154+
return Array.isArray(parsed) ? parsed : [];
155+
} catch (e) {
156+
return [];
157+
}
158+
},
159+
160+
// localStorage 变更监听(来自 bookmark 页面写入)
161+
handleLocalStorageChange(e) {
162+
if (e && e.key === "bookmarkData") {
163+
this.bookmarkDataList = this.getBookmarkDataFromLocalStorage();
164+
}
165+
},
166+
167+
// chrome.storage 变更监听(来自内容脚本/其他页面写入)
168+
handleChromeStorageChange(changes, area) {
169+
if (area !== "local") return;
170+
if (changes.bookmarkData || changes.bookmarks) {
171+
// 主动同步 storage.local 中的临时数据到本地存储
172+
this.syncFromStorageLocal().then(() => {
173+
this.bookmarkDataList = this.getBookmarkDataFromLocalStorage();
174+
});
175+
}
176+
},
177+
178+
// 将 storage.local 的数据合并进 localStorage('bookmarkData')
179+
async syncFromStorageLocal() {
180+
return new Promise((resolve) => {
181+
const browserAPI = typeof browser !== "undefined" ? browser : chrome;
182+
browserAPI.storage?.local.get(["bookmarkData", "bookmarks"], (result) => {
183+
const local = this.getBookmarkDataFromLocalStorage();
184+
185+
// 确保有默认列表 id=0
186+
let defaultList = local.find((item) => item.id === 0);
187+
if (!defaultList) {
188+
defaultList = { id: 0, name: "默认", list: [], sort: 1 };
189+
local.unshift(defaultList);
190+
}
191+
192+
// 处理单个书签(向后兼容)
193+
if (result && result.bookmarkData) {
194+
const single = result.bookmarkData;
195+
// 去重:先从所有列表移除相同 URL
196+
local.forEach((folder) => {
197+
folder.list = (folder.list || []).filter((item) => item.url !== single.url);
198+
});
199+
// 添加到默认列表开头
200+
defaultList.list.unshift(single);
201+
}
202+
203+
// 处理批量书签
204+
if (result && Array.isArray(result.bookmarks) && result.bookmarks.length > 0) {
205+
result.bookmarks.forEach((bm) => {
206+
// 如果已存在相同 URL,则更新;否则添加
207+
let updated = false;
208+
local.forEach((folder) => {
209+
const idx = (folder.list || []).findIndex((i) => i.url === bm.url);
210+
if (idx !== -1) {
211+
folder.list[idx] = { ...bm };
212+
updated = true;
213+
}
214+
});
215+
if (!updated) {
216+
defaultList.list.unshift({ ...bm });
217+
}
218+
});
219+
}
220+
221+
// 持久化
222+
try {
223+
localStorage.setItem("bookmarkData", JSON.stringify(local));
224+
} catch (e) {}
225+
226+
// 清理已消费的临时数据
227+
const removeKeys = [];
228+
if (result && result.bookmarkData) removeKeys.push("bookmarkData");
229+
if (result && result.bookmarks) removeKeys.push("bookmarks");
230+
if (removeKeys.length > 0) {
231+
browserAPI.storage?.local.remove(removeKeys, () => resolve());
232+
} else {
233+
resolve();
234+
}
235+
});
236+
});
237+
},
138238
},
139239
created() {
140240
// 获取风格主题
@@ -195,6 +295,16 @@ export default {
195295
} else {
196296
this.getNewsPosts(); // 获取最新列表
197297
}
298+
299+
// 同步一次 storage.local -> localStorage,并初始化读取书签数据
300+
this.syncFromStorageLocal().then(() => {
301+
this.bookmarkDataList = this.getBookmarkDataFromLocalStorage();
302+
});
303+
304+
// 监听 localStorage 与 chrome.storage 的变更
305+
window.addEventListener("storage", this.handleLocalStorageChange);
306+
const browserAPI = typeof browser !== "undefined" ? browser : chrome;
307+
browserAPI.storage?.onChanged?.addListener(this.handleChromeStorageChange);
198308
},
199309
// 绑定滚动事件,自动记录滚动位置
200310
mounted() {
@@ -209,6 +319,9 @@ export default {
209319
if (container) {
210320
container.removeEventListener("scroll", this.recordScroll);
211321
}
322+
window.removeEventListener("storage", this.handleLocalStorageChange);
323+
const browserAPI = typeof browser !== "undefined" ? browser : chrome;
324+
browserAPI.storage?.onChanged?.removeListener?.(this.handleChromeStorageChange);
212325
},
213326
};
214327
</script>
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
<template>
2+
<div class="bookmark-tab">
3+
<BookmarksByCategory :data="bookmarkData" />
4+
</div>
5+
</template>
6+
7+
<script>
8+
import BookmarksByCategory from './BookmarksByCategory.vue';
9+
10+
export default {
11+
name: 'BookmarkTab',
12+
components: { BookmarksByCategory },
13+
props: {
14+
bookmarkData: {
15+
type: Array,
16+
default: () => [],
17+
},
18+
},
19+
};
20+
</script>
21+
22+
<style scoped>
23+
.bookmark-tab {
24+
padding: 6px 0;
25+
}
26+
</style>
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
<template>
2+
<div class="bookmarks-by-cate">
3+
<div
4+
class="category-block"
5+
v-for="cat in categories"
6+
:key="cat.name || '未分类'"
7+
>
8+
<div class="category-title" @click="toggleCollapse(cat.name)">
9+
<span class="name">{{ cat.name || '未分类' }}</span>
10+
<span class="right">
11+
<span class="count">{{ cat.list.length }}</span>
12+
<svg class="chevron" :class="{ collapsed: isCollapsed(cat.name) }" width="16" height="16" viewBox="0 0 24 24" aria-hidden="true">
13+
<path d="M6 9l6 6 6-6" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round"/>
14+
</svg>
15+
</span>
16+
</div>
17+
<ul class="list" v-show="!isCollapsed(cat.name)">
18+
<li v-for="post in cat.list" :key="post.url">
19+
<a :href="post.url" target="_blank" :title="post.title">{{ post.title }}</a>
20+
</li>
21+
</ul>
22+
</div>
23+
</div>
24+
</template>
25+
26+
<script>
27+
export default {
28+
name: 'BookmarksByCategory',
29+
props: {
30+
data: {
31+
type: Array,
32+
default: () => [],
33+
},
34+
},
35+
data() {
36+
return {
37+
collapsedByName: {},
38+
};
39+
},
40+
computed: {
41+
categories() {
42+
const folders = Array.isArray(this.data) ? this.data : [];
43+
const mapped = folders.map((folder) => ({
44+
name: folder?.name || '未命名',
45+
list: Array.isArray(folder?.list) ? folder.list : [],
46+
sort: typeof folder?.sort === 'number' ? folder.sort : 1,
47+
}));
48+
// 可按 sort 降序展示(大在前);如不需要可移除此排序
49+
return mapped.sort((a, b) => (b.sort ?? 1) - (a.sort ?? 1));
50+
},
51+
},
52+
created() {
53+
const key = 'sidepanelCollapsedFolders';
54+
try {
55+
const raw = localStorage.getItem(key);
56+
const parsed = raw ? JSON.parse(raw) : {};
57+
if (parsed && typeof parsed === 'object') {
58+
this.collapsedByName = parsed;
59+
}
60+
} catch (e) {}
61+
},
62+
methods: {
63+
toggleCollapse(name) {
64+
const key = name || '未命名';
65+
const next = !this.collapsedByName[key];
66+
this.$set ? this.$set(this.collapsedByName, key, next) : (this.collapsedByName[key] = next);
67+
try {
68+
localStorage.setItem('sidepanelCollapsedFolders', JSON.stringify(this.collapsedByName));
69+
} catch (e) {}
70+
},
71+
isCollapsed(name) {
72+
const key = name || '未命名';
73+
return !!this.collapsedByName[key];
74+
},
75+
},
76+
};
77+
</script>
78+
79+
<style scoped lang="less">
80+
.bookmarks-by-cate {
81+
.category-block {
82+
margin-bottom: 16px;
83+
}
84+
.category-title {
85+
display: flex;
86+
align-items: center;
87+
justify-content: space-between;
88+
font-weight: 600;
89+
margin-bottom: 6px;
90+
background: #F2F3F5;
91+
padding: 5px 10px;
92+
border-radius: 5px;
93+
font-size: 14px;
94+
color: #333;
95+
cursor: pointer;
96+
97+
}
98+
.right {
99+
display: inline-flex;
100+
align-items: center;
101+
gap: 6px;
102+
}
103+
.chevron {
104+
transition: transform .2s ease;
105+
transform: rotate(0deg);
106+
}
107+
.chevron.collapsed {
108+
transform: rotate(180deg);
109+
}
110+
}
111+
</style>
112+
113+

0 commit comments

Comments
 (0)