初始提交:浏览器首页 MyHomePage 全栈项目

# 项目概述
个人浏览器首页导航应用,支持书签分类管理、搜索引擎快捷搜索、
必应每日壁纸轮播、前后端分离部署,适配 1Panel 服务器(Docker 模式)。

# 技术栈
- 前端:Vue 3 + TypeScript + Vite + Pinia + Capacitor(Android 打包)
- 后端:.NET 8 + SqlSugar(多数据库) + SQLite/MySQL + Swashbuckle
- 部署:1Panel 应用商店自定义应用(Docker Compose 模式)

# 项目结构
- backend/    .NET 8 API 后端(8 个 Controller + 15 个 Service)
- frontend/   Vue 3 前端(19 个组件 + 9 个 API 模块 + 5 个 Store)
- docker/     Docker 部署文件(后端镜像 + Nginx 反代)
- docs/       部署手册(1Panel 实战版)
- scripts/    E2E 测试脚本

# 已实现功能
- 书签管理:增删改查 + 树形分类 + 拖拽排序 + 主色自适应
- 搜索引擎:8 个内置引擎 + 自定义引擎 + favicon 自动抓取
- 必应壁纸:每日轮播 + 多分辨率自动选择 + 1.6MP 质量优先
- 全局设置:主题/行为/数据/工具 4 分类 + 跨设备同步
- 文件上传:图标/书签/通用(容器持久化 + 跨域 URL 拼接)
- 同步:基于变更日志的设备间数据同步
- 跨域部署:前后端分离 + runtime config.json 无需重新编译

# 进度记录
- 已完成 P0~P52 共 53 个开发节点(详细见 说明文档.md)
- 当前版本:v1.0 部署就绪

# 部署文档
- README.md:项目说明 + 快速开始
- 说明文档.md:完整开发进度(中文)
- docs/DEPLOY.md:1Panel 部署手册(Docker 模式)
This commit is contained in:
2026-07-05 05:09:56 +08:00
commit 68be41e7a2
129 changed files with 15900 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
# 开发环境:使用 vite proxy/api -> http://localhost:5080
# 如果需要直连后端,把空值改为 http://localhost:5080
VITE_API_BASE=
+3
View File
@@ -0,0 +1,3 @@
# Capacitor Android APP 生产环境后端地址
# APP 内不能使用 vite proxy,必须填真实后端 URL(带 https:// 或 http://
VITE_API_BASE=http://10.0.2.2:5080
+12
View File
@@ -0,0 +1,12 @@
node_modules
dist
.DS_Store
*.local
.env.local
# Capacitor
android/app/build
android/build
android/.gradle
android/local.properties
android/app/release
+69
View File
@@ -0,0 +1,69 @@
# Capacitor Android 打包
## 前置环境
1. **Node.js** ≥ 18(项目用 22
2. **JDK 17**`java -version` 可验证)
3. **Android Studio** + Android SDKAPI 34
4. 配好 `ANDROID_HOME``ANDROID_SDK_ROOT` 环境变量
## 一次性配置
```bash
cd frontend
npm install
# 构建前端静态资源到 dist/
npm run build
# 初始化 Capacitor + 创建 android 壳工程
npx cap init cn.myhomepage.app MyHomePage --web-dir=dist
npx cap add android
```
## 每次更新
```bash
# 1. 改完前端代码
npm run build
# 2. 同步到 android 工程
npx cap sync android
# 3. 在 Android Studio 中打开并打包
npx cap open android
# 或命令行:
cd android
./gradlew assembleDebug # 调试 APK → android/app/build/outputs/apk/debug/
./gradlew assembleRelease # 发布 APK(需先在 build.gradle 配签名)
```
## APP 内后端地址
APP 内不能使用 vite proxy,必须指向真实后端:
修改 `frontend/.env.production`
```
VITE_API_BASE=http://10.0.2.2:5080 # Android 模拟器访问宿主机
# 或:
VITE_API_BASE=https://your-domain.com
```
然后 `npm run build` 重新构建。
## 网络权限
首次 `npx cap add android` 后,在 `android/app/src/main/AndroidManifest.xml`
`<application>` 标签里加:
```xml
android:usesCleartextTraffic="true"
```
(如已在 `capacitor.config.ts` 中设 `cleartext: true`,则通常已自动加好)
## 常见问题
- **空白页 / 加载失败**:检查 `VITE_API_BASE` 是否填写,后端是否允许 CORS
- **CORS 报错**:在 `backend/appsettings.json``Cors.Origins` 中加上 `capacitor://localhost``http://localhost`
- **图片显示 404**`Upload.BaseUrl``appsettings.{Env}.json` 保持一致,建议反代
+27
View File
@@ -0,0 +1,27 @@
/**
* Capacitor 配置:用于打包 Android APP。
* 完整文档:https://capacitorjs.com/docs/config
*/
import type { CapacitorConfig } from '@capacitor/cli';
const config: CapacitorConfig = {
appId: 'cn.myhomepage.app',
appName: 'MyHomePage',
webDir: 'dist',
/** 后端地址:APP 内 webview 调用 */
server: {
/** 留空则使用 http.ts 的相对路径 /api(仅当 Capacitor 拦截代理可用时) */
url: undefined,
/** 启用 Capacitor HTTP 拦截,让 /api 走 native 网络栈(避免 CORS */
iosScheme: 'https',
androidScheme: 'https',
cleartext: true
},
android: {
allowMixedContent: true,
captureInput: true,
webContentsDebuggingEnabled: true
}
};
export default config;
+14
View File
@@ -0,0 +1,14 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="theme-color" content="#0f0f1a" />
<title>MyHomePage · 浏览器首页</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+3007
View File
File diff suppressed because it is too large Load Diff
+32
View File
@@ -0,0 +1,32 @@
{
"name": "myhomepage-frontend",
"version": "1.0.0",
"private": true,
"type": "module",
"description": "MyHomePage 浏览器首页 - Vue 3 前端",
"scripts": {
"dev": "vite",
"build": "vue-tsc --noEmit && vite build",
"preview": "vite preview",
"android:sync": "cap sync android",
"android:open": "cap open android"
},
"dependencies": {
"vue": "^3.4.27",
"vue-router": "^4.3.3",
"pinia": "^2.1.7",
"axios": "^1.7.2",
"lucide-vue-next": "^0.395.0",
"@capacitor/core": "^6.1.2",
"@capacitor/android": "^6.1.2"
},
"devDependencies": {
"@types/node": "^20.12.12",
"@vitejs/plugin-vue": "^5.0.4",
"@vue/tsconfig": "^0.5.1",
"@capacitor/cli": "^6.1.2",
"typescript": "^5.4.5",
"vite": "^5.2.11",
"vue-tsc": "^2.0.19"
}
}
+3
View File
@@ -0,0 +1,3 @@
{
"apiBase": ""
}
+10
View File
@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<defs>
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#6c5ce7"/>
<stop offset="100%" stop-color="#00cec9"/>
</linearGradient>
</defs>
<rect width="64" height="64" rx="14" fill="url(#g)"/>
<path d="M18 42V22h8a8 8 0 0 1 0 16h-4v4Zm4-10h4a2 2 0 0 0 0-4h-4Zm16 10V22h4l8 12V22h4v20h-4l-8-12v12Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 439 B

+64
View File
@@ -0,0 +1,64 @@
<script setup lang="ts">
import { onMounted, ref, computed, onBeforeUnmount } from 'vue';
import { useSettingsStore } from '@/stores/settings';
import { useSyncStore } from '@/stores/sync';
import { useSearchEnginesStore } from '@/stores/searchEngines';
import { useCategoriesStore } from '@/stores/categories';
import { useBookmarksStore } from '@/stores/bookmarks';
import AppWallpaper from '@/components/AppWallpaper.vue';
import ToastHost from '@/components/AppToastHost.vue';
const settings = useSettingsStore();
const sync = useSyncStore();
const engines = useSearchEnginesStore();
const categories = useCategoriesStore();
const bookmarks = useBookmarksStore();
// 自适应:检测宽度
const width = ref(typeof window !== 'undefined' ? window.innerWidth : 1280);
const isMobile = computed(() => width.value < 768);
const onResize = () => { width.value = window.innerWidth; };
onMounted(async () => {
window.addEventListener('resize', onResize);
// 1. 先加载设置(主题立即生效)
await settings.load();
// 2. 拉一次全量同步
try {
await sync.sync();
} catch (e) {
console.warn('[init] sync failed', e);
// 兜底:单独拉
await Promise.allSettled([engines.load(), categories.load(), bookmarks.load()]);
}
// 3. 启动定时同步:每 30 秒拉一次增量(visibility 隐藏时暂停)
const tick = async () => {
if (document.visibilityState !== 'visible') return;
try { await sync.sync(); } catch (e) { console.warn('[sync] tick failed', e); }
};
setInterval(tick, 30_000);
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') tick();
});
});
onBeforeUnmount(() => {
window.removeEventListener('resize', onResize);
});
</script>
<template>
<AppWallpaper />
<router-view v-slot="{ Component }">
<transition name="fade" mode="out-in">
<component :is="Component" :is-mobile="isMobile" />
</transition>
</router-view>
<ToastHost />
</template>
<style>
.fade-enter-active, .fade-leave-active { transition: opacity var(--duration-base) var(--ease); }
.fade-enter-from, .fade-leave-to { opacity: 0; }
</style>
+18
View File
@@ -0,0 +1,18 @@
import http from './http';
import type { Bookmark, BookmarkUpsert } from '@/types/api';
/** 链接列表(可按分类过滤) */
export const fetchBookmarks = (categoryId?: number) =>
http.get<Bookmark[]>('/bookmarks', { params: { categoryId } }).then(r => r.data);
/** 创建链接 */
export const createBookmark = (body: BookmarkUpsert) =>
http.post<Bookmark>('/bookmarks', body).then(r => r.data);
/** 更新链接 */
export const updateBookmark = (id: number, body: BookmarkUpsert) =>
http.put<Bookmark>(`/bookmarks/${id}`, body).then(r => r.data);
/** 删除链接(软删) */
export const deleteBookmark = (id: number) =>
http.delete<void>(`/bookmarks/${id}`).then(r => r.data);
+18
View File
@@ -0,0 +1,18 @@
import http from './http';
import type { Category, CategoryUpsert } from '@/types/api';
/** 获取全量分类(树形) */
export const fetchCategoryTree = () =>
http.get<Category[]>('/categories').then(r => r.data);
/** 创建分类 */
export const createCategory = (body: CategoryUpsert) =>
http.post<Category>('/categories', body).then(r => r.data);
/** 更新分类 */
export const updateCategory = (id: number, body: CategoryUpsert) =>
http.put<Category>(`/categories/${id}`, body).then(r => r.data);
/** 删除分类 */
export const deleteCategory = (id: number) =>
http.delete<void>(`/categories/${id}`).then(r => r.data);
+78
View File
@@ -0,0 +1,78 @@
import axios, { type AxiosInstance, type AxiosResponse, AxiosError } from 'axios';
import type { ApiEnvelope } from '@/types/api';
import { loadRuntimeConfig } from '@/config';
/**
* 全局 axios 实例
* --------------------------------------------------------------------
* baseURL 优先级(initHttp 调用后):
* 1. /config.json 的 apiBase 字段(运行时配置,部署后可改 — 主人 P49 需求)
* 2. .env.production / .env.development 的 VITE_API_BASE(编译时注入,保留兼容)
* 3. /api(相对路径,走 1Panel 反代 — 默认)
*
* 初始化流程:main.ts bootstrap() → initHttp() → app.mount()
* 这样保证所有业务代码拿到的 baseURL 都是最终值,不会出现"组件已发请求但 baseURL 还没改"的竞态
*/
function resolveBaseURL(): string {
// 编译时 VITE_API_BASE 兜底(保留旧构建兼容)
const envBase = import.meta.env.VITE_API_BASE?.trim();
if (envBase) {
return `${envBase.replace(/\/$/, '')}/api`;
}
return '/api';
}
const http: AxiosInstance = axios.create({
baseURL: resolveBaseURL(),
timeout: 15000,
headers: { 'Content-Type': 'application/json' }
});
/**
* 启动时调用:加载 /config.json,更新 axios 的 baseURL
* - 必须 await 完成后再 app.mount(),否则首屏请求会带错 baseURL
* - 失败时 loadRuntimeConfig 内部已兜底,baseURL 保持默认 /api
*/
export async function initHttp(): Promise<void> {
const config = await loadRuntimeConfig();
if (config.apiBase) {
const newBaseURL = `${config.apiBase.replace(/\/$/, '')}/api`;
http.defaults.baseURL = newBaseURL;
console.info(`[http] baseURL 已更新为 ${newBaseURL}(来源:/config.json`);
} else {
console.info('[http] baseURL 保持 /api(来源:默认相对路径)');
}
}
// 响应拦截器:解包 ApiResponse
http.interceptors.response.use(
(response: AxiosResponse<ApiEnvelope<unknown>>) => {
const payload = response.data;
// 二进制(文件下载)透传
if (response.config.responseType === 'blob' || response.config.responseType === 'arraybuffer') {
return response;
}
if (payload && typeof payload === 'object' && 'code' in payload) {
if (payload.code !== 0) {
const err = new Error(payload.message || '请求失败') as Error & { code?: number };
err.code = payload.code;
return Promise.reject(err);
}
// 把 data 直接挂回 response.data,便于调用方直接拿到业务数据
response.data = payload.data as never;
}
return response;
},
(error: AxiosError) => {
const status = error.response?.status;
const message =
(error.response?.data as { message?: string } | undefined)?.message ||
error.message ||
'网络错误';
const err = new Error(message) as Error & { status?: number };
err.status = status;
return Promise.reject(err);
}
);
export default http;
+18
View File
@@ -0,0 +1,18 @@
import http from './http';
import type { SearchEngine, SearchEngineUpsert } from '@/types/api';
export const fetchSearchEngines = () =>
http.get<SearchEngine[]>('/search-engines').then(r => r.data);
export const createSearchEngine = (body: SearchEngineUpsert) =>
http.post<SearchEngine>('/search-engines', body).then(r => r.data);
export const updateSearchEngine = (id: number, body: SearchEngineUpsert) =>
http.put<SearchEngine>(`/search-engines/${id}`, body).then(r => r.data);
export const deleteSearchEngine = (id: number) =>
http.delete<void>(`/search-engines/${id}`).then(r => r.data);
/** 设为默认引擎(其他自动取消) */
export const setDefaultEngine = (id: number) =>
http.put<SearchEngine>(`/search-engines/${id}/default`).then(r => r.data);
+8
View File
@@ -0,0 +1,8 @@
import http from './http';
import type { AppSettings, SettingUpdate } from '@/types/api';
export const fetchSettings = () =>
http.get<AppSettings>('/settings').then(r => r.data);
export const updateSettings = (body: SettingUpdate) =>
http.put<AppSettings>('/settings', body).then(r => r.data);
+14
View File
@@ -0,0 +1,14 @@
import http from './http';
import type { SyncChangesResponse } from '@/types/api';
/**
* 拉取增量同步(since 可选,ISO8601 字符串)。
*
* P34.2 修复:axios 在传 `params: { since: undefined }` 时**不会过滤 undefined**
* 会序列化成 `?since=undefined` 字符串 → 后端 `DateTime?` 解析失败 → 400。
* 这里显式判断:undefined / null / 空字符串都不带 since 参数(让后端走全量分支)。
*/
export const fetchChanges = (since?: string | null) => {
const params = since ? { since } : {};
return http.get<SyncChangesResponse>('/sync/changes', { params }).then(r => r.data);
};
+12
View File
@@ -0,0 +1,12 @@
import http from './http';
import type { UploadResult } from '@/types/api';
/** 上传单个文件(FormData 方式) */
export const uploadFile = async (file: File): Promise<UploadResult> => {
const form = new FormData();
form.append('file', file);
const { data } = await http.post<UploadResult>('/upload', form, {
headers: { 'Content-Type': 'multipart/form-data' }
});
return data;
};
+26
View File
@@ -0,0 +1,26 @@
import http from './http';
export interface FaviconResult {
/** 原始请求的 URL */
url: string;
/**
* 抓取并保存到 upload 路径后的相对 URL(如 "/uploads/2026/07/04/favicons/xxx.png")。
* 若抓取失败(网络/404/SSRF)则为 null,由调用方回退到默认图标。
*/
iconUrl: string | null;
}
/**
* P32:手动触发后端 favicon 抓取(BookmarkForm 「自动获取」按钮调用)。
* 后端实现见 [backend/Controllers/UtilityController.cs](file:///d:/Code/MyHomePage/backend/Controllers/UtilityController.cs) 与
* [backend/Services/FaviconService.cs](file:///d:/Code/MyHomePage/backend/Services/FaviconService.cs)。
*
* @param url 目标网站 URL(必须 http/https
* @returns 抓取结果;iconUrl=null 时调用方应保持原状(不修改 icon 字段)
*/
export async function fetchFavicon(url: string): Promise<FaviconResult> {
const { data } = await http.post<FaviconResult>('/utility/favicon', { url }, {
timeout: 15000 // favicon 抓取 + 下载可能稍久,给 15s
});
return data;
}
+53
View File
@@ -0,0 +1,53 @@
import http from './http';
/** 360 壁纸分类 */
export interface WallpaperCategory {
id: string;
name: string;
}
/** 随机壁纸返回结果 */
export interface WallpaperRandom {
/** 改造后的最终 URL(带指定分辨率/画质) */
url: string;
/** 360 接口原始 URL(调试用) */
originalUrl: string;
width: number;
height: number;
}
/**
* P34:拉取 360 全部分类列表(24h 缓存,由后端代理)。
* 后端实现见 [backend/Controllers/WallpaperController.cs](file:///d:/Code/MyHomePage/backend/Controllers/WallpaperController.cs)。
*/
export async function fetchWallpaperCategories(): Promise<WallpaperCategory[]> {
const { data } = await http.get<WallpaperCategory[]>('/wallpaper/categories', { timeout: 12000 });
return data ?? [];
}
/**
* P34:按分类 + 视口分辨率取 1 张随机壁纸。
*
* @param cid 360 分类 ID(空 = 全部/推荐,后端兜底用 36=4K专区)
* @param w 视口宽度 px
* @param h 视口高度 px
*/
export async function fetchWallpaperRandom(cid: string, w: number, h: number): Promise<WallpaperRandom> {
const { data } = await http.get<WallpaperRandom>('/wallpaper/random', {
params: { cid, w, h },
timeout: 12000
});
return data;
}
/**
* P34:立即刷新指定分类的池子(清缓存重新拉 200 张),并返回 1 张新随机图。
* 「立即切换」按钮调用。
*/
export async function refreshWallpaperRandom(cid: string, w: number, h: number): Promise<WallpaperRandom> {
const { data } = await http.post<WallpaperRandom>('/wallpaper/refresh', null, {
params: { cid, w, h },
timeout: 15000 // 刷新池子要给久一点
});
return data;
}
+107
View File
@@ -0,0 +1,107 @@
<script setup lang="ts">
/**
* AppButton:玻璃拟态风格的按钮,支持 variant。
*/
import { computed } from 'vue';
interface Props {
variant?: 'primary' | 'ghost' | 'danger' | 'text';
size?: 'sm' | 'md' | 'lg';
iconOnly?: boolean;
disabled?: boolean;
loading?: boolean;
type?: 'button' | 'submit';
}
const props = withDefaults(defineProps<Props>(), {
variant: 'ghost',
size: 'md',
type: 'button',
disabled: false,
loading: false,
iconOnly: false
});
defineEmits<{ (e: 'click', ev: MouseEvent): void }>();
const classes = computed(() => [
'app-btn',
`app-btn--${props.variant}`,
`app-btn--${props.size}`,
props.iconOnly && 'app-btn--icon',
props.loading && 'is-loading'
]);
</script>
<template>
<button
:class="classes"
:type="type"
:disabled="disabled || loading"
@click="$emit('click', $event)"
>
<span v-if="loading" class="app-btn__spinner" />
<slot v-else />
</button>
</template>
<style scoped>
.app-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--space-2);
border-radius: var(--radius-md);
font-weight: var(--weight-medium);
transition: background var(--duration-fast) var(--ease),
color var(--duration-fast) var(--ease),
transform var(--duration-fast) var(--ease),
box-shadow var(--duration-fast) var(--ease);
user-select: none;
white-space: nowrap;
}
.app-btn:disabled { opacity: 0.5; cursor: not-allowed; }
.app-btn:not(:disabled):active { transform: scale(0.97); }
.app-btn--sm { padding: 4px 10px; font-size: var(--font-sm); min-height: 28px; }
.app-btn--md { padding: 6px 14px; font-size: var(--font-base); min-height: 36px; }
.app-btn--lg { padding: 10px 20px; font-size: var(--font-md); min-height: 44px; }
.app-btn--icon.app-btn--sm { padding: 4px; min-width: 28px; }
.app-btn--icon.app-btn--md { padding: 6px; min-width: 36px; }
.app-btn--icon.app-btn--lg { padding: 10px; min-width: 44px; }
.app-btn--primary {
background: var(--color-accent);
color: white;
box-shadow: var(--shadow-glow);
}
.app-btn--primary:hover:not(:disabled) { background: var(--color-accent-hover); }
.app-btn--ghost {
background: var(--color-surface);
color: var(--color-text);
border: 1px solid var(--color-border);
}
.app-btn--ghost:hover:not(:disabled) { background: var(--color-surface-strong); border-color: var(--color-border-strong); }
.app-btn--text {
color: var(--color-text-muted);
}
.app-btn--text:hover:not(:disabled) { color: var(--color-text); background: var(--color-accent-soft); }
.app-btn--danger {
background: transparent;
color: var(--color-danger);
border: 1px solid transparent;
}
.app-btn--danger:hover:not(:disabled) { background: rgba(255,118,117,.12); }
.app-btn__spinner {
width: 14px; height: 14px;
border: 2px solid currentColor;
border-top-color: transparent;
border-radius: 50%;
animation: spin .8s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
</style>
+44
View File
@@ -0,0 +1,44 @@
<script setup lang="ts">
/**
* AppCard:玻璃拟态卡片容器。
*/
interface Props {
hoverable?: boolean;
active?: boolean;
padding?: 'none' | 'sm' | 'md' | 'lg';
}
withDefaults(defineProps<Props>(), { padding: 'md', hoverable: false, active: false });
</script>
<template>
<div :class="['app-card', `app-card--p-${padding}`, { 'is-hoverable': hoverable, 'is-active': active }]">
<slot />
</div>
</template>
<style scoped>
.app-card {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-sm);
transition: transform var(--duration-fast) var(--ease),
border-color var(--duration-fast) var(--ease),
box-shadow var(--duration-fast) var(--ease);
}
.app-card--p-none { padding: 0; }
.app-card--p-sm { padding: var(--space-3); }
.app-card--p-md { padding: var(--space-4); }
.app-card--p-lg { padding: var(--space-6); }
.app-card.is-hoverable { cursor: pointer; }
.app-card.is-hoverable:hover {
transform: translateY(-2px);
border-color: var(--color-accent-soft);
box-shadow: var(--shadow-md);
}
.app-card.is-active {
border-color: var(--color-accent);
background: var(--color-accent-soft);
}
</style>
@@ -0,0 +1,77 @@
<script setup lang="ts">
/**
* AppCategoryTabs:移动端横向分类标签。
*/
import AppIcon from './AppIcon.vue';
import type { Category } from '@/types/api';
interface Props {
categories: Category[]; // 一级分类(含子分类)
selected: number | null; // 当前选中的二级分类 ID
}
const props = defineProps<Props>();
const emit = defineEmits<{ (e: 'select', id: number | null): void }>();
// 把一级分类和它的子项展平到一行
function pickByIndex(idx: number): number | null {
if (idx === 0) return null;
let cursor = 1;
for (const root of props.categories) {
for (const child of root.children) {
if (cursor === idx) return child.id;
cursor++;
}
}
return null;
}
</script>
<template>
<div class="tabs">
<button
:class="['tabs__item', { active: selected === null }]"
@click="emit('select', null)"
>
<AppIcon name="layers" :size="14" />
全部
</button>
<template v-for="root in categories" :key="root.id">
<button
v-for="child in root.children"
:key="child.id"
:class="['tabs__item', { active: selected === child.id }]"
@click="emit('select', child.id)"
>
<AppIcon :name="child.icon || 'link'" :size="14" />
{{ child.name }}
</button>
</template>
</div>
</template>
<style scoped>
.tabs {
display: flex; gap: 8px;
padding: 4px 4px;
overflow-x: auto;
scrollbar-width: none;
}
.tabs::-webkit-scrollbar { display: none; }
.tabs__item {
display: inline-flex; align-items: center; gap: 6px;
padding: 6px 14px;
font-size: var(--font-sm);
color: var(--color-text-muted);
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-pill);
white-space: nowrap;
flex-shrink: 0;
transition: all var(--duration-fast) var(--ease);
}
.tabs__item.active {
background: var(--color-accent);
color: white;
border-color: var(--color-accent);
}
</style>
+92
View File
@@ -0,0 +1,92 @@
<script setup lang="ts">
/**
* AppDrawer:侧滑抽屉(左侧或右侧)。
*/
import { watch, onMounted, onBeforeUnmount } from 'vue';
interface Props {
modelValue: boolean;
side?: 'left' | 'right';
title?: string;
width?: number;
}
const props = withDefaults(defineProps<Props>(), { side: 'left', width: 320 });
const emit = defineEmits<{ (e: 'update:modelValue', v: boolean): void }>();
function close() { emit('update:modelValue', false); }
function onKey(e: KeyboardEvent) { if (e.key === 'Escape' && props.modelValue) close(); }
onMounted(() => window.addEventListener('keydown', onKey));
onBeforeUnmount(() => window.removeEventListener('keydown', onKey));
watch(() => props.modelValue, (v) => { document.body.style.overflow = v ? 'hidden' : ''; });
</script>
<template>
<Teleport to="body">
<Transition :name="`drawer-${side}`">
<div v-if="modelValue" class="app-drawer" @click.self="close">
<aside :class="['app-drawer__panel', `app-drawer__panel--${side}`]" :style="{ width: width + 'px' }" @click.stop>
<header class="app-drawer__header">
<h3 v-if="title">{{ title }}</h3>
<slot name="header" />
<button class="app-drawer__close" @click="close" aria-label="关闭">
<AppIcon name="x" :size="18" />
</button>
</header>
<div class="app-drawer__body">
<slot />
</div>
</aside>
</div>
</Transition>
</Teleport>
</template>
<style scoped>
.app-drawer {
position: fixed; inset: 0;
z-index: var(--z-drawer);
background: rgba(0,0,0,.4);
backdrop-filter: blur(4px);
}
.app-drawer__panel {
position: absolute; top: 0; bottom: 0;
background: var(--color-bg-elevated);
border-right: 1px solid var(--color-border);
display: flex; flex-direction: column;
box-shadow: var(--shadow-xl);
}
.app-drawer__panel--left { left: 0; }
.app-drawer__panel--right { right: 0; border-right: 0; border-left: 1px solid var(--color-border); }
.app-drawer__header {
display: flex; align-items: center; justify-content: space-between;
padding: var(--space-4) var(--space-5);
border-bottom: 1px solid var(--color-border);
}
.app-drawer__header h3 { font-size: var(--font-lg); font-weight: var(--weight-semibold); }
.app-drawer__close {
width: 32px; height: 32px;
display: flex; align-items: center; justify-content: center;
border-radius: var(--radius-md);
color: var(--color-text-muted);
}
.app-drawer__close:hover { background: var(--color-surface); color: var(--color-text); }
.app-drawer__body { flex: 1; overflow-y: auto; padding: var(--space-4); }
.drawer-left-enter-active, .drawer-left-leave-active { transition: opacity var(--duration-base) var(--ease); }
.drawer-left-enter-active .app-drawer__panel, .drawer-left-leave-active .app-drawer__panel {
transition: transform var(--duration-base) var(--ease);
}
.drawer-left-enter-from, .drawer-left-leave-to { opacity: 0; }
.drawer-left-enter-from .app-drawer__panel { transform: translateX(-100%); }
.drawer-left-leave-to .app-drawer__panel { transform: translateX(-100%); }
.drawer-right-enter-active, .drawer-right-leave-active { transition: opacity var(--duration-base) var(--ease); }
.drawer-right-enter-active .app-drawer__panel, .drawer-right-leave-active .app-drawer__panel {
transition: transform var(--duration-base) var(--ease);
}
.drawer-right-enter-from, .drawer-right-leave-to { opacity: 0; }
.drawer-right-enter-from .app-drawer__panel { transform: translateX(100%); }
.drawer-right-leave-to .app-drawer__panel { transform: translateX(100%); }
</style>
+30
View File
@@ -0,0 +1,30 @@
<script setup lang="ts">
/**
* AppFab:浮动操作按钮(右下角)。
*/
import AppIcon from './AppIcon.vue';
defineEmits<{ (e: 'click'): void }>();
</script>
<template>
<button class="fab" @click="$emit('click')" aria-label="添加">
<AppIcon name="plus" :size="24" />
</button>
</template>
<style scoped>
.fab {
position: fixed;
right: 20px; bottom: 24px;
width: var(--fab-size); height: var(--fab-size);
border-radius: 50%;
background: var(--color-accent);
color: white;
display: flex; align-items: center; justify-content: center;
box-shadow: var(--shadow-lg), var(--shadow-glow);
z-index: var(--z-fab);
transition: transform var(--duration-fast) var(--ease);
}
.fab:hover { transform: scale(1.08); }
.fab:active { transform: scale(0.95); }
</style>
+48
View File
@@ -0,0 +1,48 @@
<script setup lang="ts">
/**
* AppIcon:根据字符串 key 渲染 lucide 图标;找不到时退化为 emoji / 文本。
*/
import { computed } from 'vue';
import { resolveIcon } from '@/utils/icon';
interface Props {
name?: string | null;
emoji?: string | null;
url?: string | null;
size?: number;
}
const props = withDefaults(defineProps<Props>(), { size: 18 });
const Comp = computed(() => resolveIcon(props.name));
const isImage = computed(() => !!props.url);
</script>
<template>
<span class="app-icon" :style="{ width: size + 'px', height: size + 'px' }">
<img v-if="isImage" :src="props.url!" :alt="name ?? ''" :width="size" :height="size" />
<span v-else-if="emoji" class="app-icon-emoji" :style="{ fontSize: size + 'px' }">{{ emoji }}</span>
<component v-else-if="Comp" :is="Comp" :size="size" />
<span v-else class="app-icon-fallback" :style="{ fontSize: size + 'px' }">{{ (name ?? '?').charAt(0).toUpperCase() }}</span>
</span>
</template>
<style scoped>
.app-icon {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
color: inherit;
border-radius: var(--radius-sm);
overflow: hidden;
}
.app-icon img { width: 100%; height: 100%; object-fit: cover; }
.app-icon-emoji { line-height: 1; }
.app-icon-fallback {
font-weight: var(--weight-semibold);
color: var(--color-text-muted);
background: var(--color-surface);
width: 100%; height: 100%;
display: flex; align-items: center; justify-content: center;
}
</style>
+167
View File
@@ -0,0 +1,167 @@
<script setup lang="ts">
/**
* AppIconPicker:图标选择器弹窗(P23)。
* - 从 utils/icon 拉取 SUPPORTED_ICONS,渲染为响应式网格
* - 顶部搜索框实时过滤
* - 选中后 emit('select', name) 并关闭弹窗
*/
import { ref, computed, watch, nextTick, useTemplateRef } from 'vue';
import { SUPPORTED_ICONS } from '@/utils/icon';
import AppModal from './AppModal.vue';
import AppIcon from './AppIcon.vue';
interface Props {
modelValue: boolean;
selected?: string | null;
}
const props = withDefaults(defineProps<Props>(), { selected: '' });
const emit = defineEmits<{
(e: 'update:modelValue', v: boolean): void;
(e: 'select', name: string): void;
}>();
const search = ref('');
const searchInput = useTemplateRef<HTMLInputElement>('searchInput');
const filteredIcons = computed(() => {
const q = search.value.trim().toLowerCase();
if (!q) return SUPPORTED_ICONS;
return SUPPORTED_ICONS.filter(n => n.toLowerCase().includes(q));
});
// 打开时自动 focus 搜索框 + 清空上次搜索
watch(() => props.modelValue, async (open) => {
if (open) {
search.value = '';
await nextTick();
searchInput.value?.focus();
}
});
function pick(name: string) {
emit('select', name);
emit('update:modelValue', false);
}
</script>
<template>
<AppModal
:model-value="modelValue"
title="选择图标"
size="lg"
@update:model-value="emit('update:modelValue', $event)"
>
<div class="picker">
<div class="picker__head">
<AppIcon name="search" :size="16" class="picker__search-icon" />
<input
ref="searchInput"
v-model="search"
class="picker__search"
placeholder="搜索图标名(layers / bot / github ..."
autocomplete="off"
spellcheck="false"
/>
<span class="picker__count">{{ filteredIcons.length }} </span>
</div>
<div v-if="filteredIcons.length === 0" class="picker__empty">
<AppIcon name="help-circle" :size="24" />
<p>没找到匹配的图标{{ search }}</p>
</div>
<div v-else class="picker__grid">
<button
v-for="name in filteredIcons"
:key="name"
:class="['picker__item', { active: name === selected }]"
:title="name"
type="button"
@click="pick(name)"
>
<AppIcon :name="name" :size="22" />
<span class="picker__name">{{ name }}</span>
</button>
</div>
</div>
</AppModal>
</template>
<style scoped>
.picker { display: flex; flex-direction: column; gap: 12px; }
.picker__head {
display: flex; align-items: center; gap: 8px;
position: relative;
}
.picker__search-icon {
position: absolute; left: 12px; top: 50%; transform: translateY(-50%);
color: var(--color-text-subtle);
pointer-events: none;
}
.picker__search {
flex: 1;
width: 100%;
padding: 8px 12px 8px 36px;
background: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
color: var(--color-text);
outline: none;
font-size: var(--font-sm);
transition: border-color var(--duration-fast) var(--ease);
}
.picker__search:focus { border-color: var(--color-accent); }
.picker__search::placeholder { color: var(--color-text-subtle); }
.picker__count {
font-size: var(--font-xs);
color: var(--color-text-subtle);
flex-shrink: 0;
}
.picker__empty {
display: flex; flex-direction: column; align-items: center; gap: 8px;
padding: 48px 16px;
color: var(--color-text-subtle);
text-align: center;
}
.picker__empty p { font-size: var(--font-sm); }
.picker__grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(88px, 1fr));
gap: 8px;
max-height: 56vh;
overflow-y: auto;
padding: 4px;
}
.picker__item {
display: flex; flex-direction: column; align-items: center; gap: 6px;
padding: 10px 6px;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
color: var(--color-text-muted);
cursor: pointer;
transition: all var(--duration-fast) var(--ease);
min-width: 0;
}
.picker__item:hover {
background: var(--color-bg-elevated);
border-color: var(--color-border-strong);
color: var(--color-text);
transform: translateY(-1px);
}
.picker__item.active {
background: var(--color-accent-soft);
border-color: var(--color-accent);
color: var(--color-accent);
font-weight: var(--weight-medium);
}
.picker__name {
font-size: 10px;
font-family: var(--font-mono, ui-monospace, monospace);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%;
line-height: 1.2;
}
</style>
+192
View File
@@ -0,0 +1,192 @@
<script setup lang="ts">
/**
* AppLinkCard:桌面端链接卡片。
* - 左:方形彩色 logo 块(用 user 选定的 colorBg;未设时品牌色兜底;上传图片则用图片主色调)
* - 中:图标(lucide / emoji / image / 名称前 2 字符)
* - 右:标题 + 描述
*/
import { ref, computed, onMounted, watch } from 'vue';
import type { Bookmark } from '@/types/api';
import AppIcon from './AppIcon.vue';
import { colorFromUrl, firstChar, extractDominantColor } from '@/utils/color';
import { resolveAssetUrl } from '@/config';
interface Props {
bookmark: Bookmark;
}
const props = defineProps<Props>();
defineEmits<{ (e: 'click', b: Bookmark): void; (e: 'edit', b: Bookmark): void; (e: 'delete', b: Bookmark): void }>();
// P31iconType='favicon' 与 'image' 都是「显示 iconUrl 图片」(仅来源不同:用户上传 vs 后端自动抓取)
const isImage = computed(() =>
(props.bookmark.iconType === 'image' || props.bookmark.iconType === 'favicon') &&
!!props.bookmark.iconUrl
);
// P52:把后端返回的相对路径("/uploads/...")拼成 apiBase 的绝对 URL
const iconSrc = computed(() => resolveAssetUrl(props.bookmark.iconUrl));
/** 适配图片时,从图片主色调异步提取 */
const adaptiveColor = ref<string | null>(null);
async function refreshAdaptive() {
adaptiveColor.value = null;
if (props.bookmark.colorBg) return;
if (isImage.value && iconSrc.value) {
const c = await extractDominantColor(iconSrc.value);
if (c) adaptiveColor.value = c;
}
}
onMounted(refreshAdaptive);
watch(() => [props.bookmark.iconUrl, props.bookmark.colorBg], refreshAdaptive);
/** 实际使用的背景色:用户指定 > 图片主色 > 品牌色 */
const bg = computed<string>(() => {
if (props.bookmark.colorBg) return props.bookmark.colorBg;
if (adaptiveColor.value) return adaptiveColor.value;
return colorFromUrl(props.bookmark.url).bg;
});
const fg = computed<string>(() => {
if (props.bookmark.colorBg) return '#ffffff'; // 简化:自定义色统一白字(用户可选亮色块时再用 fg)
if (adaptiveColor.value) return '#ffffff';
return colorFromUrl(props.bookmark.url).fg;
});
/** 兜底:没有图标时使用名称前 2 字符 */
const hasIcon = computed(() => isImage.value || !!props.bookmark.icon);
const logoText = computed(() => firstChar(props.bookmark.title));
</script>
<template>
<div class="link-card glass" @click="$emit('click', bookmark)">
<!-- 左侧正方形彩色 logo 色块 -->
<div class="link-card__logo" :style="{ background: bg, color: fg }">
<AppIcon
v-if="isImage"
:url="iconSrc"
:size="36"
class="link-card__logo-img"
/>
<span v-else-if="bookmark.iconType === 'emoji' && bookmark.icon" class="link-card__logo-emoji">{{ bookmark.icon }}</span>
<AppIcon
v-else-if="bookmark.iconType === 'lucide' && bookmark.icon"
:name="bookmark.icon"
:size="32"
/>
<span v-else class="link-card__logo-text">{{ logoText }}</span>
</div>
<!-- 右侧标题 + 描述 -->
<div class="link-card__body">
<h4 class="link-card__title">{{ bookmark.title }}</h4>
<p class="link-card__desc">{{ bookmark.description || bookmark.url }}</p>
</div>
<!-- hover 浮现的编辑/删除 -->
<div class="link-card__actions" @click.stop>
<button class="link-card__action" @click="$emit('edit', bookmark)" :aria-label="`编辑 ${bookmark.title}`">
<AppIcon name="edit" :size="14" />
</button>
<button class="link-card__action link-card__action--danger" @click="$emit('delete', bookmark)" :aria-label="`删除 ${bookmark.title}`">
<AppIcon name="trash" :size="14" />
</button>
</div>
</div>
</template>
<style scoped>
/* P28:横排 + 方形 logo + AppIcon + colorBg */
.link-card {
position: relative;
display: flex; align-items: stretch;
min-height: var(--link-card-min-height);
padding: 0;
background: var(--glass-bg-faint);
border: 1px solid var(--glass-border);
border-radius: var(--link-card-radius);
overflow: hidden;
cursor: pointer;
transition: transform var(--duration-fast) var(--ease),
box-shadow var(--duration-fast) var(--ease),
border-color var(--duration-fast) var(--ease);
backdrop-filter: blur(var(--glass-blur-sm)) saturate(180%);
-webkit-backdrop-filter: blur(var(--glass-blur-sm)) saturate(180%);
}
.link-card:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-lg);
border-color: var(--color-border-strong);
}
.link-card__logo {
width: var(--link-logo-size);
height: var(--link-logo-size);
flex-shrink: 0;
display: flex; align-items: center; justify-content: center;
font-size: 32px;
font-weight: var(--weight-bold);
letter-spacing: -0.5px;
user-select: none;
position: relative;
border-right: 1px solid var(--glass-border);
overflow: hidden;
}
.link-card__logo-text {
text-shadow: 0 1px 2px rgba(0,0,0,0.15);
}
.link-card__logo-img { border-radius: var(--radius-sm); }
.link-card__logo-emoji { line-height: 1; }
.link-card__body {
flex: 1; min-width: 0;
display: flex; flex-direction: column; justify-content: center;
padding: 12px 14px;
background: var(--glass-bg-faint);
backdrop-filter: blur(var(--glass-blur-sm));
-webkit-backdrop-filter: blur(var(--glass-blur-sm));
}
.link-card__title {
font-size: var(--font-md);
font-weight: var(--weight-semibold);
color: var(--color-text);
margin-bottom: 4px;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.link-card__desc {
font-size: var(--font-xs);
color: var(--color-text-muted);
line-height: 1.5;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.link-card__actions {
position: absolute;
top: 8px; right: 8px;
display: flex; gap: 4px;
opacity: 0;
transition: opacity var(--duration-fast) var(--ease);
}
.link-card:hover .link-card__actions { opacity: 1; }
.link-card__action {
width: 28px; height: 28px;
display: flex; align-items: center; justify-content: center;
border-radius: var(--radius-md);
color: var(--color-text-muted);
background: var(--glass-bg-strong);
border: 1px solid var(--glass-border);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
transition: color var(--duration-fast) var(--ease), background var(--duration-fast) var(--ease);
}
.link-card__action:hover { color: var(--color-text); background: var(--color-bg-elevated); }
.link-card__action--danger:hover { color: var(--color-danger); background: rgba(255,118,117,0.15); }
/* 移动端 / 极窄屏:logo 缩小为方形 */
@media (max-width: 640px) {
.link-card__logo { width: 64px; height: 64px; font-size: 24px; }
.link-card__body { padding: 10px 12px; }
.link-card__title { font-size: var(--font-sm); }
}
</style>
+159
View File
@@ -0,0 +1,159 @@
<script setup lang="ts">
/**
* AppLinkListItem:移动端 / 窄屏的链接列表项。
* P28 升级:
* - logo 块变**正方形**(边长 = 高度),与 AppLinkCard 视觉对齐
* - 显示 AppIconlucide / emoji / image / 名称前 2 字符兜底)
* - 背景色三级 fallbackuser.colorBg > 图片主色 > 品牌色
*/
import { ref, computed, onMounted, watch } from 'vue';
import type { Bookmark } from '@/types/api';
import AppIcon from './AppIcon.vue';
import { colorFromUrl, firstChar, extractDominantColor } from '@/utils/color';
import { resolveAssetUrl } from '@/config';
interface Props { bookmark: Bookmark; }
const props = defineProps<Props>();
defineEmits<{ (e: 'click', b: Bookmark): void; (e: 'edit', b: Bookmark): void; (e: 'delete', b: Bookmark): void }>();
// P31iconType='favicon' 与 'image' 都是「显示 iconUrl 图片」(仅来源不同:用户上传 vs 后端自动抓取)
const isImage = computed(() =>
(props.bookmark.iconType === 'image' || props.bookmark.iconType === 'favicon') &&
!!props.bookmark.iconUrl
);
// P52:跨域部署时把后端相对路径("/uploads/...")拼成 apiBase 绝对 URL
const iconSrc = computed(() => resolveAssetUrl(props.bookmark.iconUrl));
/** 自适应:从 iconUrl 提取主色 */
const adaptiveColor = ref<string | null>(null);
async function refreshAdaptive() {
adaptiveColor.value = null;
if (props.bookmark.colorBg) return;
if (isImage.value && iconSrc.value) {
const c = await extractDominantColor(iconSrc.value);
if (c) adaptiveColor.value = c;
}
}
onMounted(refreshAdaptive);
watch(() => [props.bookmark.iconUrl, props.bookmark.colorBg], refreshAdaptive);
const bg = computed<string>(() => {
if (props.bookmark.colorBg) return props.bookmark.colorBg;
if (adaptiveColor.value) return adaptiveColor.value;
return colorFromUrl(props.bookmark.url).bg;
});
const fg = computed<string>(() => {
if (props.bookmark.colorBg) return '#ffffff';
if (adaptiveColor.value) return '#ffffff';
return colorFromUrl(props.bookmark.url).fg;
});
const hasIcon = computed(() => isImage.value || !!props.bookmark.icon);
const logoText = computed(() => firstChar(props.bookmark.title));
</script>
<template>
<div class="link-row" @click="$emit('click', bookmark)">
<!-- 左侧正方形彩色 logo -->
<div class="link-row__logo" :style="{ background: bg, color: fg }">
<AppIcon
v-if="isImage"
:url="iconSrc"
:size="28"
class="link-row__logo-img"
/>
<span v-else-if="bookmark.iconType === 'emoji' && bookmark.icon" class="link-row__logo-emoji">{{ bookmark.icon }}</span>
<AppIcon
v-else-if="bookmark.iconType === 'lucide' && bookmark.icon"
:name="bookmark.icon"
:size="24"
/>
<span v-else class="link-row__logo-text">{{ logoText }}</span>
</div>
<!-- 标题 + 描述 -->
<div class="link-row__body">
<div class="link-row__title">{{ bookmark.title }}</div>
<div class="link-row__desc">{{ bookmark.description || bookmark.url }}</div>
</div>
<!-- 编辑 / 删除 -->
<div class="link-row__actions" @click.stop>
<button class="link-row__action" @click="$emit('edit', bookmark)" :aria-label="`编辑 ${bookmark.title}`">
<AppIcon name="edit" :size="14" />
</button>
<button class="link-row__action link-row__action--danger" @click="$emit('delete', bookmark)" :aria-label="`删除 ${bookmark.title}`">
<AppIcon name="trash" :size="14" />
</button>
</div>
</div>
</template>
<style scoped>
/* P28:横排 + 方形 logo + AppIcon + colorBg(与 AppLinkCard 视觉一致,体积更紧凑) */
/* P36align-items 改 center —— 原 stretch + min-height 64px + logo 56×56 固定高度时,flex 把 logo "贴顶" 排版(stretch 在子项有 height 时退化为 flex-start),导致 logo 框上下错位 */
.link-row {
display: flex; align-items: center;
min-height: var(--link-row-logo-size);
background: var(--glass-bg-faint);
border: 1px solid var(--glass-border);
border-radius: var(--radius-md);
overflow: hidden;
cursor: pointer;
backdrop-filter: blur(var(--glass-blur-sm)) saturate(180%);
-webkit-backdrop-filter: blur(var(--glass-blur-sm)) saturate(180%);
transition: transform var(--duration-fast) var(--ease),
box-shadow var(--duration-fast) var(--ease);
}
.link-row:hover {
transform: translateY(-1px);
box-shadow: var(--shadow-md);
}
.link-row:active { transform: scale(0.99); }
.link-row__logo {
width: var(--link-row-logo-size);
height: var(--link-row-logo-size);
flex-shrink: 0;
display: flex; align-items: center; justify-content: center;
font-size: 20px;
font-weight: var(--weight-bold);
letter-spacing: -0.5px;
text-shadow: 0 1px 2px rgba(0,0,0,0.15);
border-right: 1px solid var(--glass-border);
user-select: none;
overflow: hidden;
}
.link-row__logo-img { border-radius: var(--link-logo-radius); }
.link-row__logo-emoji { line-height: 1; }
.link-row__logo-text { letter-spacing: 0; }
.link-row__body {
flex: 1; min-width: 0;
display: flex; flex-direction: column; justify-content: center;
padding: 8px 10px;
}
.link-row__title {
font-size: var(--font-sm);
font-weight: var(--weight-medium);
color: var(--color-text);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.link-row__desc {
font-size: var(--font-xs);
color: var(--color-text-muted);
margin-top: 2px;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.link-row__actions { display: flex; gap: 2px; align-items: center; padding-right: 4px; }
.link-row__action {
width: 28px; height: 28px;
display: flex; align-items: center; justify-content: center;
border-radius: var(--radius-sm);
color: var(--color-text-muted);
}
.link-row__action:hover { color: var(--color-text); background: var(--color-bg-elevated); }
.link-row__action--danger:hover { color: var(--color-danger); }
</style>
@@ -0,0 +1,44 @@
<script setup lang="ts">
/**
* AppMobileTopBar:移动端顶栏。
*/
import AppIcon from './AppIcon.vue';
import AppSearchBar from './AppSearchBar.vue';
defineEmits<{ (e: 'menu'): void; (e: 'settings'): void }>();
</script>
<template>
<header class="topbar">
<button class="topbar__icon-btn" @click="$emit('menu')" aria-label="菜单">
<AppIcon name="menu" :size="20" />
</button>
<div class="topbar__search">
<AppSearchBar />
</div>
<button class="topbar__icon-btn" @click="$emit('settings')" aria-label="设置">
<AppIcon name="settings" :size="20" />
</button>
</header>
</template>
<style scoped>
.topbar {
position: sticky; top: 0;
z-index: var(--z-elevated);
display: flex; align-items: center; gap: var(--space-2);
padding: 10px 12px;
background: var(--glass-bg-strong);
backdrop-filter: blur(20px);
border-bottom: 1px solid var(--color-border);
}
.topbar__search { flex: 1; min-width: 0; }
.topbar__icon-btn {
width: 40px; height: 40px;
display: flex; align-items: center; justify-content: center;
border-radius: var(--radius-md);
color: var(--color-text-muted);
flex-shrink: 0;
}
.topbar__icon-btn:hover { color: var(--color-text); background: var(--color-surface); }
</style>
+112
View File
@@ -0,0 +1,112 @@
<script setup lang="ts">
/**
* AppModal:通用弹窗(带遮罩 + ESC 关闭 + 点击遮罩关闭)。
*/
import { watch, onMounted, onBeforeUnmount } from 'vue';
interface Props {
modelValue: boolean;
title?: string;
size?: 'sm' | 'md' | 'lg';
closeOnBackdrop?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
size: 'md',
closeOnBackdrop: true
});
const emit = defineEmits<{ (e: 'update:modelValue', v: boolean): void }>();
function close() { emit('update:modelValue', false); }
function onKey(e: KeyboardEvent) { if (e.key === 'Escape' && props.modelValue) close(); }
onMounted(() => window.addEventListener('keydown', onKey));
onBeforeUnmount(() => window.removeEventListener('keydown', onKey));
watch(() => props.modelValue, (v) => {
document.body.style.overflow = v ? 'hidden' : '';
});
</script>
<template>
<Teleport to="body">
<Transition name="modal">
<div v-if="modelValue" class="app-modal" @click.self="closeOnBackdrop && close()">
<div :class="['app-modal__panel', `app-modal__panel--${size}`]" @click.stop>
<header v-if="title || $slots.header" class="app-modal__header">
<slot name="header">
<h3>{{ title }}</h3>
</slot>
<button class="app-modal__close" @click="close" aria-label="关闭">
<AppIcon name="x" :size="18" />
</button>
</header>
<div class="app-modal__body">
<slot />
</div>
<footer v-if="$slots.footer" class="app-modal__footer">
<slot name="footer" />
</footer>
</div>
</div>
</Transition>
</Teleport>
</template>
<style scoped>
.app-modal {
position: fixed; inset: 0;
z-index: var(--z-modal);
display: flex; align-items: center; justify-content: center;
background: rgba(0,0,0,.5);
backdrop-filter: blur(6px);
padding: var(--space-4);
}
.app-modal__panel {
width: 100%;
max-height: 90vh;
background: var(--color-bg-elevated);
border: 1px solid var(--color-border-strong);
border-radius: var(--radius-xl);
box-shadow: var(--shadow-xl);
display: flex; flex-direction: column;
overflow: hidden;
}
.app-modal__panel--sm { max-width: 380px; }
.app-modal__panel--md { max-width: 560px; }
.app-modal__panel--lg { max-width: 820px; }
.app-modal__header {
display: flex; align-items: center; justify-content: space-between;
padding: var(--space-4) var(--space-5);
border-bottom: 1px solid var(--color-border);
}
.app-modal__header h3 { font-size: var(--font-xl); font-weight: var(--weight-semibold); }
.app-modal__close {
width: 32px; height: 32px;
display: flex; align-items: center; justify-content: center;
border-radius: var(--radius-md);
color: var(--color-text-muted);
transition: background var(--duration-fast) var(--ease);
}
.app-modal__close:hover { background: var(--color-surface); color: var(--color-text); }
.app-modal__body {
flex: 1; overflow-y: auto;
padding: var(--space-5);
}
.app-modal__footer {
display: flex; gap: var(--space-3); justify-content: flex-end;
padding: var(--space-4) var(--space-5);
border-top: 1px solid var(--color-border);
}
.modal-enter-active, .modal-leave-active { transition: opacity var(--duration-base) var(--ease); }
.modal-enter-from, .modal-leave-to { opacity: 0; }
.modal-enter-active .app-modal__panel, .modal-leave-active .app-modal__panel {
transition: transform var(--duration-base) var(--ease);
}
.modal-enter-from .app-modal__panel, .modal-leave-to .app-modal__panel {
transform: scale(.96) translateY(8px);
}
</style>
+261
View File
@@ -0,0 +1,261 @@
<script setup lang="ts">
/**
* AppSearchBar:顶部搜索栏(搜索引擎切换 + 输入 + 提交)。
* - outside-click 关闭引擎菜单
* - P27:引擎区加品牌色 logo 色块
* - P37logo 改用引擎 iconType/iconUrl/colorBg 字段(与链接 logo 同款),不再用 url 推断
*/
import { ref, computed, onMounted, onBeforeUnmount } from 'vue';
import { useSearchEnginesStore } from '@/stores/searchEngines';
import { useSettingsStore } from '@/stores/settings';
import type { SearchEngine } from '@/types/api';
import { resolveAssetUrl } from '@/config';
const engines = useSearchEnginesStore();
const settings = useSettingsStore();
const query = ref('');
const wrapEl = ref<HTMLElement | null>(null);
const engine = computed(() => engines.current ?? engines.defaultEngine);
const showEngineMenu = ref(false);
/** P37:引擎 logo 背景色(与 SettingsView.engineLogoStyle 同款)
* - colorBg 有值 → 用设置的颜色 + 浅文字色
* - colorBg = null + iconType=image → fallback 浅蓝色
* - colorBg = null + iconType=lucide/emoji → 用 accent-soft
* - iconType 为空字符串(老数据 SQLite DefaultValue 不回填)→ 视为 lucide
*/
const ENGINE_DEFAULT_BG = '#5b8def';
function engineLogoStyle(e: SearchEngine | null) {
if (!e) return undefined;
if (e.colorBg) return { background: e.colorBg, color: '#fff' };
if (e.iconType === 'image' && e.iconUrl) return { background: ENGINE_DEFAULT_BG, color: '#fff' };
return { background: 'var(--color-accent-soft)', color: 'var(--color-accent)' };
}
function engineIconType(e: SearchEngine | null): 'lucide' | 'image' | 'emoji' {
if (!e) return 'lucide';
return (e.iconType === 'image' || e.iconType === 'emoji') ? e.iconType : 'lucide';
}
// P52:跨域部署时把后端相对路径("/uploads/...")拼成 apiBase 绝对 URL
function engineIconSrc(e: SearchEngine | null): string {
return resolveAssetUrl(e?.iconUrl);
}
function submit() {
const q = query.value.trim();
if (!q) return;
const url = engines.buildUrl(q);
// P46:根据设置决定 target(与 HomeView.openBookmark 用 openLinksInNewTab 同款模式)
const target = settings.settings.openSearchInNewTab ? '_blank' : '_self';
window.open(url, target, 'noopener');
}
function selectEngine(id: number) {
const e = engines.items.find(x => x.id === id);
if (e) engines.setCurrent(e);
showEngineMenu.value = false;
}
function onDocPointerDown(e: PointerEvent) {
if (!wrapEl.value) return;
const target = e.target as Node | null;
if (showEngineMenu.value && target && !wrapEl.value.contains(target)) {
showEngineMenu.value = false;
}
}
onMounted(async () => {
// P41:每次进入首页都 force reload 引擎数据,避免 Pinia + HMR 缓存老数据(缺 iconType/iconUrl 字段)。
// 之前默认 force=false,只在 items.length===0 时 loadP37 加新字段后,
// HMR 不重跑 onMounted,老数据留在 store.items 里 → 引擎 logo 永远显示 lucide 兜底色。
// 这里强制 reload 是 1 次轻量 GET~5ms 完成(前端 cache-control 不参与),可接受。
await engines.load(true);
document.addEventListener('pointerdown', onDocPointerDown);
});
onBeforeUnmount(() => document.removeEventListener('pointerdown', onDocPointerDown));
</script>
<template>
<div ref="wrapEl" class="searchbar glass-strong">
<button class="searchbar__engine" @click="showEngineMenu = !showEngineMenu" :aria-expanded="showEngineMenu" :title="`当前引擎:${engine?.name ?? '搜索'}`">
<!-- P37用引擎 iconType/iconUrl/colorBg 渲染与链接 logo 同款 -->
<span class="searchbar__engine-logo" :style="engineLogoStyle(engine)">
<img v-if="engineIconType(engine) === 'image' && engine.iconUrl" class="searchbar__engine-img" :src="engineIconSrc(engine)" :alt="engine.name" :width="18" :height="18" />
<span v-else-if="engineIconType(engine) === 'emoji' && engine.icon" class="searchbar__engine-emoji">{{ engine.icon }}</span>
<AppIcon v-else :name="engine?.icon ?? 'search'" :size="18" />
</span>
<span class="searchbar__engine-name">{{ engine?.name ?? '搜索' }}</span>
<span class="searchbar__engine-caret"></span>
<Transition name="fade">
<div v-if="showEngineMenu" class="searchbar__engine-menu" @click.stop>
<button
v-for="e in engines.ordered"
:key="e.id"
:class="['searchbar__engine-item', { active: e.id === engine?.id }]"
@click="selectEngine(e.id)"
>
<span class="searchbar__engine-item-logo" :style="engineLogoStyle(e)">
<img v-if="engineIconType(e) === 'image' && e.iconUrl" class="searchbar__engine-img" :src="engineIconSrc(e)" :alt="e.name" :width="14" :height="14" />
<span v-else-if="engineIconType(e) === 'emoji' && e.icon" class="searchbar__engine-emoji">{{ e.icon }}</span>
<AppIcon v-else :name="e.icon ?? 'search'" :size="14" />
</span>
<span class="searchbar__engine-item-name">{{ e.name }}</span>
<span v-if="e.id === engine?.id" class="searchbar__engine-item-check"></span>
</button>
</div>
</Transition>
</button>
<form class="searchbar__form" @submit.prevent="submit">
<input
v-model="query"
type="text"
class="searchbar__input"
placeholder="搜索任何内容…"
autocomplete="off"
spellcheck="false"
/>
<button class="searchbar__submit" type="submit" :disabled="!query.trim()" aria-label="搜索">
<span class="searchbar__submit-icon"></span>
</button>
</form>
</div>
</template>
<style scoped>
.searchbar {
display: flex; align-items: center;
border-radius: var(--radius-pill);
padding: 6px;
width: 100%; max-width: 720px;
box-shadow: var(--shadow-md);
position: relative;
z-index: var(--z-searchbar);
}
.searchbar__engine {
position: relative;
display: flex; align-items: center; gap: 8px;
padding: 6px 14px 6px 6px;
border-radius: var(--radius-pill);
cursor: pointer;
color: var(--color-text);
transition: background var(--duration-fast) var(--ease);
/* P24:移动端不压缩,避免「百度」被拆成「百/度」两行 */
flex-shrink: 0;
white-space: nowrap;
border: 0;
background: transparent;
}
.searchbar__engine:hover { background: var(--color-surface); }
.searchbar__engine-logo {
width: 28px; height: 28px;
display: flex; align-items: center; justify-content: center;
border-radius: var(--radius-sm);
font-size: 12px;
font-weight: var(--weight-bold);
text-shadow: 0 1px 2px rgba(0,0,0,0.15);
flex-shrink: 0;
letter-spacing: -0.3px;
overflow: hidden;
}
.searchbar__engine-emoji { font-size: 18px; line-height: 1; }
/* P43:引擎 logo 图片直接用 <img>,绕开 AppIconVue scoped CSS 对 v-if 创建的 img 元素的
data-v attribute 处理有坑,导致 .app-icon img 100% 样式不生效 → 图片不显示) */
.searchbar__engine-img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.searchbar__engine-name {
font-size: var(--font-sm);
font-weight: var(--weight-medium);
/* P24:强制不换行 */
white-space: nowrap;
flex-shrink: 0;
}
.searchbar__engine-caret {
font-size: 12px;
color: var(--color-text-muted);
margin-left: -2px;
transition: transform var(--duration-fast) var(--ease);
}
.searchbar__engine[aria-expanded="true"] .searchbar__engine-caret { transform: rotate(180deg); }
.searchbar__engine-menu {
position: absolute; top: calc(100% + 8px); left: 0;
min-width: 220px;
background: var(--glass-bg-strong);
border: 1px solid var(--color-border-strong);
border-radius: var(--radius-md);
box-shadow: var(--shadow-lg);
padding: 6px;
z-index: var(--z-popover);
backdrop-filter: blur(var(--glass-blur-lg)) saturate(180%);
-webkit-backdrop-filter: blur(var(--glass-blur-lg)) saturate(180%);
}
.searchbar__engine-item {
width: 100%;
display: flex; align-items: center; gap: 10px;
padding: 8px 10px;
border-radius: var(--radius-sm);
font-size: var(--font-sm);
color: var(--color-text-muted);
text-align: left;
transition: background var(--duration-fast) var(--ease), color var(--duration-fast) var(--ease);
}
.searchbar__engine-item:hover { background: var(--color-surface); color: var(--color-text); }
.searchbar__engine-item.active { color: var(--color-accent); background: var(--color-accent-soft); }
.searchbar__engine-item-logo {
width: 22px; height: 22px;
display: flex; align-items: center; justify-content: center;
border-radius: 5px;
font-size: 11px;
font-weight: var(--weight-bold);
text-shadow: 0 1px 1px rgba(0,0,0,0.15);
flex-shrink: 0;
}
.searchbar__engine-item-name { flex: 1; }
.searchbar__engine-item-check { color: var(--color-accent); font-weight: var(--weight-bold); }
.searchbar__form {
flex: 1;
display: flex; align-items: center; gap: 6px;
padding: 0 6px;
}
.searchbar__input {
flex: 1;
height: 40px;
background: transparent;
border: 0;
outline: 0;
color: var(--color-text);
font-size: var(--font-md);
}
.searchbar__input::placeholder { color: var(--color-text-subtle); }
.searchbar__submit {
width: 40px; height: 40px;
display: flex; align-items: center; justify-content: center;
border-radius: 50%;
background: var(--color-accent);
color: white;
transition: background var(--duration-fast) var(--ease), transform var(--duration-fast) var(--ease);
font-size: 18px;
font-weight: var(--weight-bold);
border: 0;
}
.searchbar__submit:hover:not(:disabled) { background: var(--color-accent-hover); transform: scale(1.05); }
.searchbar__submit:disabled { opacity: .4; cursor: not-allowed; }
.searchbar__submit-icon { line-height: 1; }
.fade-enter-active, .fade-leave-active { transition: opacity var(--duration-fast) var(--ease); }
.fade-enter-from, .fade-leave-to { opacity: 0; }
/* P24 + P27:移动端进一步压缩 */
@media (max-width: 640px) {
.searchbar { padding: 4px; }
.searchbar__engine { padding: 4px 8px 4px 4px; gap: 6px; }
.searchbar__engine-logo { width: 24px; height: 24px; font-size: 11px; }
.searchbar__form { padding: 0 4px; gap: 4px; }
.searchbar__input { height: 36px; font-size: var(--font-sm); }
.searchbar__submit { width: 36px; height: 36px; font-size: 16px; }
}
</style>
+489
View File
@@ -0,0 +1,489 @@
<script setup lang="ts">
/**
* AppSidebar:桌面端左侧二级分类侧边栏。
* - 顶部:品牌 + 齿轮(设置)
* - 中部:一级分类(可展开二级) + hover ⋯ 菜单
* - 底部:统计 + + 新建分类
*
* 菜单层级修复(P21):
* - ⋯ 菜单通过 <Teleport to="body"> + position:fixed 渲染到 body 顶层,
* 彻底避免父级 stacking context 限制。
* - 打开菜单时根据 trigger 按钮的 getBoundingClientRect() 动态计算位置。
*/
import { ref, computed, onMounted, onBeforeUnmount } from 'vue';
import { useCategoriesStore } from '@/stores/categories';
import { useBookmarksStore } from '@/stores/bookmarks';
import AppIcon from './AppIcon.vue';
import type { Category } from '@/types/api';
interface Props {
modelValue: number | null; // 当前选中的分类 ID(顶级或二级均可;null = 全部)
showAll?: boolean;
}
const props = withDefaults(defineProps<Props>(), { showAll: true });
const emit = defineEmits<{
(e: 'update:modelValue', v: number | null): void;
(e: 'manage'): void;
(e: 'create-category', parentId: number): void;
(e: 'edit-category', category: Category): void;
(e: 'delete-category', category: Category): void;
}>();
const categories = useCategoriesStore();
const bookmarks = useBookmarksStore();
const expanded = ref<Set<number>>(new Set());
function ensureExpanded() {
categories.tree.forEach(c => expanded.value.add(c.id));
}
ensureExpanded();
if (categories.tree.length === 0) {
const stop = setInterval(() => {
if (categories.tree.length > 0) {
ensureExpanded();
clearInterval(stop);
}
}, 200);
setTimeout(() => clearInterval(stop), 5000);
}
function toggle(id: number) {
if (expanded.value.has(id)) expanded.value.delete(id);
else expanded.value.add(id);
expanded.value = new Set(expanded.value);
}
function selectLeaf(id: number | null) {
emit('update:modelValue', id);
}
/**
* P22:点击一级分类主区域
* - 未选中 → 选中 + 展开
* - 已选中 → 取消选中(回到全部)+ 折叠
* 视觉态与"是否展开"解耦;保留用户折叠 root 的能力。
*/
function onRootClick(root: Category) {
if (props.modelValue === root.id) {
// 再次点击同一 root:取消选中 + 折叠
emit('update:modelValue', null);
if (expanded.value.has(root.id)) {
expanded.value.delete(root.id);
expanded.value = new Set(expanded.value);
}
} else {
// 选中该 root + 展开
emit('update:modelValue', root.id);
if (!expanded.value.has(root.id)) {
expanded.value.add(root.id);
expanded.value = new Set(expanded.value);
}
}
}
// 底部统计
const totalLinks = computed(() => bookmarks.items.length);
const totalCategories = computed(() => {
let n = 0;
for (const r of categories.tree) n += 1 + r.children.length;
return n;
});
// ─── ⋯ 菜单状态(P21Teleport + fixed 动态定位)──────────────────────
const rootMenuOpen = ref<number | null>(null);
const childMenuOpen = ref<number | null>(null);
const rootMenuPos = ref<{ top: number; left: number } | null>(null);
const childMenuPos = ref<{ top: number; left: number } | null>(null);
const MENU_OFFSET_Y = 4; // 菜单与 trigger 按钮垂直间距
const MENU_OFFSET_X = -4; // 菜单与 trigger 按钮水平微调
function closeAllMenus() {
rootMenuOpen.value = null;
childMenuOpen.value = null;
rootMenuPos.value = null;
childMenuPos.value = null;
}
function onRootMenu(root: Category, e: Event) {
e.stopPropagation();
const trigger = e.currentTarget as HTMLElement;
if (rootMenuOpen.value === root.id) { closeAllMenus(); return; }
closeAllMenus();
rootMenuOpen.value = root.id;
const rect = trigger.getBoundingClientRect();
rootMenuPos.value = {
top: rect.bottom + MENU_OFFSET_Y,
left: rect.right + MENU_OFFSET_X
};
}
function onChildMenu(child: Category, e: Event) {
e.stopPropagation();
const trigger = e.currentTarget as HTMLElement;
if (childMenuOpen.value === child.id) { closeAllMenus(); return; }
closeAllMenus();
childMenuOpen.value = child.id;
const rect = trigger.getBoundingClientRect();
childMenuPos.value = {
top: rect.bottom + MENU_OFFSET_Y,
left: rect.right + MENU_OFFSET_X
};
}
function emitCreateChild(parentId: number) { closeAllMenus(); emit('create-category', parentId); }
function emitEditCategory(c: Category) { closeAllMenus(); emit('edit-category', c); }
function emitDeleteCategory(c: Category) { closeAllMenus(); emit('delete-category', c); }
// ─── P21.2outside-click 关闭菜单 ────────────────────────────────
// 因为菜单 Teleport 到 body 顶层,不能再用 sidebarEl.contains() 判断
// 用 closest() 排除菜单自身和 trigger 按钮,其它情况一律关闭
function onDocPointerDown(e: PointerEvent) {
// 没有菜单打开就不处理
if (rootMenuOpen.value === null && childMenuOpen.value === null) return;
const target = e.target as Element | null;
if (!target || typeof target.closest !== 'function') {
closeAllMenus();
return;
}
// 点在菜单内(含菜单项)→ 不关
if (target.closest('.sidebar__menu')) return;
// 点在 trigger 按钮上 → 让 onRootMenu / onChildMenu 自己处理切换
if (target.closest('.sidebar__row-action')) return;
// 其它区域:关闭
closeAllMenus();
}
onMounted(() => {
document.addEventListener('pointerdown', onDocPointerDown);
});
onBeforeUnmount(() => {
document.removeEventListener('pointerdown', onDocPointerDown);
});
</script>
<template>
<aside ref="sidebarEl" class="sidebar sidebar--glass">
<!-- P28去除用户头像/用户名 header项目无用户系统直接从分类列表开始 -->
<nav class="sidebar__nav">
<div v-for="root in categories.tree" :key="root.id" class="sidebar__group">
<div :class="['sidebar__row sidebar__row--root', { active: modelValue === root.id }]">
<button class="sidebar__item sidebar__item--root" @click="onRootClick(root)">
<AppIcon
:name="expanded.has(root.id) ? 'chevron-down' : 'chevron-right'"
:size="14"
class="sidebar__caret"
/>
<AppIcon :name="root.icon || 'layers'" :size="16" />
<span class="sidebar__label">{{ root.name }}</span>
</button>
<!-- P23+号直接放在 root 行右侧点选 root 也能在该 root 下快速建子分类 -->
<button
class="sidebar__row-action sidebar__row-action--add"
@click="emitCreateChild(root.id)"
:aria-label="`新建子分类到 ${root.name}`"
title="新建子分类"
>
<AppIcon name="plus" :size="14" />
</button>
<button
class="sidebar__row-action"
@click="onRootMenu(root, $event)"
:aria-label="`${root.name} 管理`"
title="管理"
>
<AppIcon name="more-vertical" :size="14" />
</button>
</div>
<Transition name="collapse">
<div v-if="expanded.has(root.id)" class="sidebar__children">
<div
v-for="child in root.children"
:key="child.id"
:class="['sidebar__row sidebar__row--leaf', { active: modelValue === child.id }]"
>
<button
:class="['sidebar__item sidebar__item--leaf', { active: modelValue === child.id }]"
@click="selectLeaf(child.id)"
>
<AppIcon :name="child.icon || 'link'" :size="14" />
<span class="sidebar__label">{{ child.name }}</span>
</button>
<button
class="sidebar__row-action"
@click="onChildMenu(child, $event)"
:aria-label="`${child.name} 管理`"
title="管理"
>
<AppIcon name="more-vertical" :size="14" />
</button>
</div>
<!-- 二级分类 菜单P21Teleport + fixed -->
<Teleport to="body">
<div
v-for="child in root.children"
:key="`m-${child.id}`"
>
<div
v-if="childMenuOpen === child.id && childMenuPos"
class="sidebar__menu sidebar__menu--leaf"
:style="{ top: childMenuPos.top + 'px', left: childMenuPos.left + 'px' }"
@click.stop
>
<button class="sidebar__menu-item" @click="emitEditCategory(child)">
<AppIcon name="edit" :size="14" /> 编辑
</button>
<button class="sidebar__menu-item sidebar__menu-item--danger" @click="emitDeleteCategory(child)">
<AppIcon name="trash" :size="14" /> 删除
</button>
</div>
</div>
</Teleport>
</div>
</Transition>
<!-- 一级分类 菜单P21Teleport + fixed -->
<Teleport to="body">
<div
v-if="rootMenuOpen === root.id && rootMenuPos"
class="sidebar__menu sidebar__menu--root"
:style="{ top: rootMenuPos.top + 'px', left: rootMenuPos.left + 'px' }"
@click.stop
>
<button class="sidebar__menu-item" @click="emitCreateChild(root.id)">
<AppIcon name="plus" :size="14" /> 新建子分类
</button>
<button class="sidebar__menu-item" @click="emitEditCategory(root)">
<AppIcon name="edit" :size="14" /> 编辑
</button>
<button class="sidebar__menu-item sidebar__menu-item--danger" @click="emitDeleteCategory(root)">
<AppIcon name="trash" :size="14" /> 删除
</button>
</div>
</Teleport>
</div>
</nav>
<footer class="sidebar__footer">
<button
v-if="showAll"
:class="['sidebar__view-all', { active: modelValue === null }]"
@click="selectLeaf(null)"
:aria-pressed="modelValue === null"
:title="modelValue === null ? '当前是全部视图' : '切换到全部视图'"
>
<AppIcon name="layers" :size="14" />
<span>查看全部</span>
<span class="sidebar__view-all-count">{{ totalLinks }}</span>
</button>
<div class="sidebar__stats">
<span>{{ totalCategories }} 个分类</span>
<span class="sidebar__stats-dot">·</span>
<span>{{ totalLinks }} 个链接</span>
</div>
<button class="sidebar__add-root" @click="emit('create-category', 0)">
<AppIcon name="plus" :size="14" /> 新建分类
</button>
</footer>
</aside>
</template>
<style scoped>
/* 玻璃质感:跟主题走(暗 0.72 / 亮 0.78),任何壁纸上都能撑住 */
.sidebar {
width: var(--sidebar-width);
height: 100%;
display: flex; flex-direction: column;
background: var(--glass-bg);
border-right: 1px solid var(--glass-border);
backdrop-filter: blur(28px) saturate(180%);
-webkit-backdrop-filter: blur(28px) saturate(180%);
box-shadow: 4px 0 24px rgba(0, 0, 0, 0.18);
position: relative;
z-index: var(--z-sidebar);
/* 文字兜底阴影:浅色 / 暖色壁纸上保证可读性 */
/* P44:阴影从 0.35 强化到 0.55 + 1px 模糊半径提升到 3px —— 在浅色 / 高饱和度壁纸上撑住对比度 */
--sidebar-text-shadow: 0 1px 3px rgba(0, 0, 0, 0.55);
}
.sidebar--glass::before {
/* 顶部轻高光叠加 */
content: '';
position: absolute; inset: 0;
background: linear-gradient(180deg, rgba(255,255,255,0.04) 0%, rgba(255,255,255,0) 30%);
pointer-events: none;
}
/* P28:彻底移除旧 header(项目无用户系统) */
.sidebar__header { display: none; }
.sidebar__profile { display: none; }
.sidebar__manage { display: none; }
/* 菜单:直接接顶,零顶部 padding;让 sidebar 内容更紧凑 */
.sidebar__nav {
flex: 1; overflow-y: auto;
padding: 8px 8px 8px;
display: flex; flex-direction: column; gap: 2px;
min-height: 0;
}
.sidebar__row {
position: relative;
display: flex; align-items: center; gap: 2px;
border-radius: var(--radius-sm);
transition: background var(--duration-fast) var(--ease);
}
.sidebar__row:hover { background: var(--color-surface); }
.sidebar__row--root.active,
.sidebar__row--leaf.active {
/* P45active 背景从 accent-soft(淡紫)改 accent 主色实色 —— 与文字 text 白色形成高对比;
旧版"淡紫底+深紫字"同色系对比度仅 ~3:1,主人截图能看到糊在一起 */
background: var(--color-accent);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.25);
}
.sidebar__item {
flex: 1; min-width: 0;
display: flex; align-items: center; gap: var(--space-2);
padding: 8px 10px;
border-radius: var(--radius-sm);
font-size: var(--font-sm);
/* P44:默认色从 --color-text-muted 升到 --color-text,提升在玻璃上的对比度 */
color: var(--color-text);
text-shadow: var(--sidebar-text-shadow);
text-align: left;
transition: color var(--duration-fast) var(--ease);
}
/* P45active 文字从 accent(深紫)改 text(白色)+ 加深 shadow —— 紫底白字对比度 ~8:1,远高于 WCAG AAA 7:1 */
/* P44hover 态用 accent 色(hover 和 active 颜色不同:hover=accent 紫字+灰底,active=白字+紫底——三级清晰) */
.sidebar__item:hover { color: var(--color-accent); }
.sidebar__row--root.active .sidebar__item--root,
.sidebar__row--leaf.active .sidebar__item--leaf {
color: var(--color-text);
font-weight: var(--weight-semibold);
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.4);
}
.sidebar__item--root { font-weight: var(--weight-medium); }
.sidebar__item--leaf { padding-left: 28px; font-size: var(--font-sm); }
.sidebar__label { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.sidebar__caret { color: var(--color-text-muted); text-shadow: var(--sidebar-text-shadow); }
/* P45active 状态下 caret / 分类图标也要变白,与文字保持一致视觉层级 */
.sidebar__row--root.active .sidebar__caret,
.sidebar__row--leaf.active .sidebar__caret { color: var(--color-text); }
/* P45active 状态下 row action+ / 菜单)也要白化 */
.sidebar__row--root.active .sidebar__row-action,
.sidebar__row--leaf.active .sidebar__row-action { color: var(--color-text); }
.sidebar__row-action {
width: 24px; height: 24px;
display: flex; align-items: center; justify-content: center;
border-radius: var(--radius-sm);
color: var(--color-text-muted);
margin-right: 4px;
opacity: 0;
text-shadow: var(--sidebar-text-shadow);
transition: opacity var(--duration-fast) var(--ease), background var(--duration-fast) var(--ease);
flex-shrink: 0;
}
.sidebar__row:hover .sidebar__row-action { opacity: 1; }
.sidebar__row-action:hover { background: var(--color-bg-elevated); color: var(--color-text); }
/* P23.1:「+」号与 ⋯ 一样默认隐藏,hover row 时一起浮现 */
/* hover 高亮沿用基类,hover 自身时再覆盖成 accent 色(视觉引导"添加"语义) */
.sidebar__row-action--add:hover {
background: var(--color-accent-soft);
color: var(--color-accent);
}
.sidebar__children {
display: flex; flex-direction: column; gap: 2px;
margin-left: 12px;
border-left: 1px solid var(--color-border);
padding-left: 4px;
}
.sidebar__menu {
/* P21Teleport to="body" + position:fixed,位置由 inline style 动态计算 */
position: fixed;
z-index: var(--z-popover);
min-width: 180px;
background: var(--color-bg-elevated);
border: 1px solid var(--color-border-strong);
border-radius: var(--radius-md);
box-shadow: var(--shadow-lg);
padding: 4px;
backdrop-filter: blur(20px);
animation: sidebar-menu-in var(--duration-fast) var(--ease);
}
@keyframes sidebar-menu-in {
from { opacity: 0; transform: translateY(-4px); }
to { opacity: 1; transform: translateY(0); }
}
.sidebar__menu-item {
width: 100%;
display: flex; align-items: center; gap: 8px;
padding: 8px 10px;
font-size: var(--font-sm);
color: var(--color-text);
border-radius: var(--radius-sm);
text-align: left;
}
.sidebar__menu-item:hover { background: var(--color-surface); }
.sidebar__menu-item--danger { color: var(--color-danger); }
.sidebar__menu-item--danger:hover { background: rgba(255,118,117,0.12); }
.sidebar__footer {
padding: var(--space-3) var(--space-3);
border-top: 1px solid var(--color-border);
display: flex; flex-direction: column; gap: var(--space-2);
flex-shrink: 0;
/* 渐变实底:底部三层保障(壁纸色 → 表面色 → 提亮色),按钮 / 统计文字有底可依 */
background: linear-gradient(180deg, transparent 0%, var(--color-surface) 35%, var(--color-bg-elevated) 100%);
}
.sidebar__view-all {
width: 100%;
display: flex; align-items: center; gap: 8px;
padding: 7px 10px;
font-size: var(--font-sm);
color: var(--color-text);
background: var(--color-surface);
border: 1px solid var(--color-border-strong);
border-radius: var(--radius-md);
text-shadow: var(--sidebar-text-shadow);
transition: color var(--duration-fast) var(--ease), background var(--duration-fast) var(--ease), border-color var(--duration-fast) var(--ease);
}
.sidebar__view-all:hover { color: var(--color-text); background: var(--color-surface-strong); border-color: var(--color-accent); }
.sidebar__view-all.active { color: var(--color-accent); background: var(--color-accent-soft); border-color: var(--color-accent); font-weight: var(--weight-medium); }
.sidebar__view-all-count {
margin-left: auto;
font-size: var(--font-xs);
color: var(--color-text-muted);
font-weight: var(--weight-normal);
}
.sidebar__stats {
display: flex; align-items: center; gap: 6px;
font-size: var(--font-xs);
color: var(--color-text-muted);
font-weight: var(--weight-medium);
text-shadow: var(--sidebar-text-shadow);
padding: 0 4px;
}
.sidebar__stats-dot { opacity: 0.6; }
.sidebar__add-root {
width: 100%;
display: flex; align-items: center; justify-content: center; gap: 6px;
padding: 8px 12px;
font-size: var(--font-sm);
color: var(--color-text);
background: var(--color-bg-elevated);
border: 1px dashed var(--color-border-strong);
border-radius: var(--radius-md);
text-shadow: var(--sidebar-text-shadow);
transition: color var(--duration-fast) var(--ease), background var(--duration-fast) var(--ease);
}
.sidebar__add-root:hover { color: var(--color-accent); background: var(--color-accent-soft); border-color: var(--color-accent); border-style: solid; }
.collapse-enter-active, .collapse-leave-active {
transition: max-height var(--duration-base) var(--ease), opacity var(--duration-base) var(--ease);
overflow: hidden;
}
.collapse-enter-from, .collapse-leave-to { max-height: 0; opacity: 0; }
.collapse-enter-to, .collapse-leave-from { max-height: 600px; opacity: 1; }
</style>
+64
View File
@@ -0,0 +1,64 @@
<script setup lang="ts">
/**
* Toast 通知宿主。
* 暴露一个全局 list,由 useToasts() 调用 pushToast 添加。
*/
import { useToasts } from '@/utils/toast';
import AppIcon from './AppIcon.vue';
const { list, removeToast } = useToasts();
</script>
<template>
<Teleport to="body">
<div class="toast-host">
<TransitionGroup name="toast">
<div v-for="t in list" :key="t.id" :class="['toast', `toast--${t.type}`]">
<span class="toast__msg">{{ t.message }}</span>
<button class="toast__close" @click="removeToast(t.id)">
<AppIcon name="x" :size="14" />
</button>
</div>
</TransitionGroup>
</div>
</Teleport>
</template>
<style scoped>
.toast-host {
position: fixed;
right: 24px; bottom: 24px;
z-index: var(--z-toast);
display: flex; flex-direction: column; gap: 8px;
pointer-events: none;
}
.toast {
pointer-events: auto;
display: flex; align-items: center; gap: 12px;
padding: 10px 14px;
background: var(--glass-bg-strong);
border: 1px solid var(--color-border-strong);
border-radius: var(--radius-md);
box-shadow: var(--shadow-lg);
backdrop-filter: blur(20px);
min-width: 200px; max-width: 360px;
font-size: var(--font-sm);
}
.toast--success { border-left: 3px solid var(--color-success); }
.toast--error { border-left: 3px solid var(--color-danger); }
.toast--warning { border-left: 3px solid var(--color-warning); }
.toast--info { border-left: 3px solid var(--color-accent); }
.toast__msg { flex: 1; }
.toast__close {
width: 22px; height: 22px;
display: flex; align-items: center; justify-content: center;
border-radius: var(--radius-sm);
color: var(--color-text-muted);
}
.toast__close:hover { color: var(--color-text); background: var(--color-surface); }
.toast-enter-active, .toast-leave-active { transition: all var(--duration-base) var(--ease); }
.toast-enter-from { opacity: 0; transform: translateX(20px); }
.toast-leave-to { opacity: 0; transform: translateX(20px); }
</style>
+131
View File
@@ -0,0 +1,131 @@
<script setup lang="ts">
/**
* AppWallpaper:根据当前 settings 渲染背景图。
*
* P29 之前:只通过 CSS 变量 --bg-image 渲染(preset/custom/solid)。
* P34 之后:增加「360 在线壁纸」模式:
* - wallpaperEnabled = true:使用 store.wallpaperUrl 作为背景图
* 视口变化 / 切换分类 / 定时器到点 / 立即切换 → fetchRandomWallpaper
* - wallpaperEnabled = false:维持原 --bg-image 逻辑
*
* 三端不变形 = 后端按视口分辨率改 URL 路径段 bdm/{W}_{H}_{80} + 前端 background-size: cover。
* - 启用 360 模式时:进入组件时拉 1 张;watch wallpaperCategoryId 时再拉
* - 定时器:watch wallpaperInterval(分钟)变化时重建 setInterval
*/
import { useSettingsStore } from '@/stores/settings';
import { computed, watch, onMounted, onBeforeUnmount, ref } from 'vue';
const settings = useSettingsStore();
// 视口尺寸(用于响应式判断 + 兜底宽度传给后端)
const viewport = ref({ w: 0, h: 0 });
function updateViewport() {
viewport.value = { w: window.innerWidth, h: window.innerHeight };
}
/** 背景图样式:360 模式用 store.wallpaperUrl;否则用 --bg-image 变量。 */
const style = computed(() => {
if (settings.settings.wallpaperEnabled && settings.wallpaperUrl) {
return {
backgroundImage: `url("${settings.wallpaperUrl}")`,
backgroundSize: 'cover',
backgroundPosition: 'center',
backgroundRepeat: 'no-repeat'
};
}
// 传统模式:靠 --bg-image
const bg = settings.settings.backgroundImage;
if (settings.settings.backgroundType === 'solid' || !bg) {
return { background: 'var(--color-bg)' };
}
return {
backgroundImage: `var(--bg-image)`,
backgroundSize: 'cover',
backgroundPosition: 'center',
backgroundRepeat: 'no-repeat'
};
});
// ===== 360 模式:定时自动切换 =====
let timerId: ReturnType<typeof setInterval> | null = null;
/** 重新计算 / 启动定时器(依赖 wallpaperInterval */
function rebuildTimer() {
stopTimer();
if (!settings.settings.wallpaperEnabled) return;
const minutes = settings.settings.wallpaperInterval;
if (minutes <= 0) return; // 0 = 不自动切换(只能手动)
const ms = minutes * 60 * 1000;
timerId = setInterval(() => {
// 自动切换走「普通 random」接口(refresh 留给「立即切换」按钮用,避免重复清缓存)
settings.fetchRandomWallpaper(false);
}, ms);
}
function stopTimer() {
if (timerId !== null) {
clearInterval(timerId);
timerId = null;
}
}
// ===== 生命周期 =====
onMounted(() => {
updateViewport();
window.addEventListener('resize', onResize, { passive: true });
// 启用 360 模式时:第一次进入时拉一张(load() 时已 setWallpaperEnabled 但那时可能未挂载)
if (settings.settings.wallpaperEnabled && !settings.wallpaperUrl) {
settings.fetchRandomWallpaper(false);
}
rebuildTimer();
});
onBeforeUnmount(() => {
window.removeEventListener('resize', onResize);
stopTimer();
});
// resize 用 rAF 节流,避免频繁触发(视口变了但图本身不重新拉;只有用户主动按立即切换或定时器到点才拉新图)
let rafId: number | null = null;
function onResize() {
if (rafId !== null) return;
rafId = requestAnimationFrame(() => {
rafId = null;
updateViewport();
});
}
// ===== 监听设置变化 =====
watch(
() => settings.settings.wallpaperEnabled,
(enabled) => {
if (enabled && !settings.wallpaperUrl) {
settings.fetchRandomWallpaper(false);
}
rebuildTimer();
}
);
watch(
() => settings.settings.wallpaperInterval,
() => rebuildTimer()
);
// 分类变更已由 store.setWallpaperCategoryId 内置 fetchRandomWallpaper,无需再 watch
// 暴露给调试
defineExpose({ viewport });
</script>
<template>
<div class="app-wallpaper" :style="style" />
</template>
<style scoped>
.app-wallpaper {
position: fixed; inset: 0;
z-index: -1;
transition: background var(--duration-slow) var(--ease),
background-image var(--duration-slow) var(--ease);
}
/* P29:移除原 ::after 深色蒙层(rgba(15,15,26,0.45→0.72)),
它把壁纸亮度压低 45-72%,让背景图看起来发闷。改为全透明让背景原样显示。 */
</style>
+522
View File
@@ -0,0 +1,522 @@
<script setup lang="ts">
/**
* BookmarkForm:链接增 / 改表单。
* P23:图标字段右侧新增「选择」按钮,弹出 AppIconPicker 网格选择器
* P28:在图标字段下方新增「背景色」选择器:
* - 10 套预设色块(PRESET_COLORS
* - 自定义颜色选择器(<input type="color">
* - 「自适应」按钮(点击后实时从 iconUrl 提取主色,并预览)
* - 选中态用高亮边框标识
* - 存储语义:colorBg = null 表示「自适应」;存预设 hex / 自定义 hex
* P32:在图标行新增「自动获取」按钮(位于「选择」/「上传」右侧):
* - 点击时检查 URL(未填则弹 toast 提示)
* - 调后端 POST /api/utility/favicon 抓取
* - 成功 → 写入 iconType='favicon' + iconUrl='/uploads/.../favicons/...'
* - 失败(网络/404/无 favicon)→ 静默保留原状,红色 hint 提示原因
*/
import { ref, computed, watch } from 'vue';
import { useBookmarksStore } from '@/stores/bookmarks';
import { useCategoriesStore } from '@/stores/categories';
import { uploadFile } from '@/api/upload';
import { fetchFavicon } from '@/api/utility';
import AppModal from './AppModal.vue';
import AppButton from './AppButton.vue';
import AppIcon from './AppIcon.vue';
import AppIconPicker from './AppIconPicker.vue';
import type { Bookmark, Category, BookmarkUpsert } from '@/types/api';
import { PRESET_COLORS, extractDominantColor } from '@/utils/color';
import { resolveAssetUrl } from '@/config';
interface Props {
bookmark: Bookmark | null;
defaultCategoryId?: number;
}
const props = defineProps<Props>();
const emit = defineEmits<{ (e: 'close'): void }>();
const bookmarks = useBookmarksStore();
const categories = useCategoriesStore();
// 顶级 + 二级分类均可放链接(方案 B),按顶级分组展示
const categoryGroups = computed<{ rootName: string; items: { id: number; name: string; depth: 1 | 2 }[] }[]>(() => {
const groups: { rootName: string; items: { id: number; name: string; depth: 1 | 2 }[] }[] = [];
for (const root of categories.tree) {
const items: { id: number; name: string; depth: 1 | 2 }[] = [
{ id: root.id, name: root.name, depth: 1 }
];
for (const child of root.children) items.push({ id: child.id, name: child.name, depth: 2 });
groups.push({ rootName: root.name, items });
}
return groups;
});
const form = ref<BookmarkUpsert>({
// P30 修复:编辑模式下必须以「链接真实分类 (bookmark.categoryId)」为最高优先级,
// 否则在某个父级页面点开时,defaultCategoryId 会覆盖链接原本所属的子分类,
// 导致下拉框显示的分类与链接实际不符(用户困扰)。
// 创建模式 (bookmark=null) → 用 defaultCategoryId → 兜底第一个可用分组。
categoryId: props.bookmark?.categoryId ?? props.defaultCategoryId ?? categoryGroups.value[0]?.items[0]?.id ?? 0,
title: props.bookmark?.title ?? '',
url: props.bookmark?.url ?? '',
description: props.bookmark?.description ?? '',
icon: props.bookmark?.icon ?? 'link',
iconType: props.bookmark?.iconType ?? 'lucide',
iconUrl: props.bookmark?.iconUrl ?? null,
colorBg: props.bookmark?.colorBg ?? null, // P28null = 自适应
sort: props.bookmark?.sort ?? 0
});
const fileInput = ref<HTMLInputElement | null>(null);
const uploading = ref(false);
/** P32:自动获取 favicon 的 loading 与错误状态 */
const fetchingFavicon = ref(false);
const faviconError = ref<string | null>(null);
/**
* P32:点击「自动获取」按钮的处理:
* 1. 校验 URL(未填则红色 hint 提示用户填写)
* 2. 调后端 POST /api/utility/favicon 抓取
* 3. 成功 → 写入 form.iconType='favicon' + form.iconUrl=服务端返回的 URL
* 4. 失败 → 红色 hint 提示「未能获取该网站图标」等
*/
async function autoFetchIcon() {
faviconError.value = null;
const url = (form.value.url ?? '').trim();
if (!url) {
faviconError.value = '请先填写 URL';
return;
}
if (!/^https?:\/\//i.test(url)) {
faviconError.value = 'URL 必须以 http:// 或 https:// 开头';
return;
}
fetchingFavicon.value = true;
try {
const r = await fetchFavicon(url);
if (r.iconUrl) {
form.value.iconType = 'favicon';
form.value.iconUrl = r.iconUrl;
form.value.icon = null;
} else {
faviconError.value = '未能获取该网站图标(可能未提供 favicon)';
}
} catch (e) {
const msg = (e as Error)?.message || '获取失败';
faviconError.value = `获取失败:${msg}`;
} finally {
fetchingFavicon.value = false;
}
}
/** URL 变化时清掉旧错误(避免红字一直挂在那) */
watch(() => form.value.url, () => { faviconError.value = null; });
// P23:图标选择器
const iconPickerOpen = ref(false);
function onIconPick(name: string) {
// 选 lucide 图标:icon 字段填名,iconType 切回 lucideiconUrl 保留(作 fallback
form.value.icon = name;
form.value.iconType = 'lucide';
// 切回 lucide 后,如果当前是自适应(colorBg=null)但有自定义 image
// 用户不再处于「自定义图片」语境,保留原值即可;前端 AppLinkCard 会兜底到品牌色
}
watch(() => props.bookmark, (b) => {
form.value = {
categoryId: b?.categoryId ?? form.value.categoryId,
title: b?.title ?? '',
url: b?.url ?? '',
description: b?.description ?? '',
icon: b?.icon ?? 'link',
iconType: b?.iconType ?? 'lucide',
iconUrl: b?.iconUrl ?? null,
colorBg: b?.colorBg ?? null,
sort: b?.sort ?? 0
};
// 切到不同 bookmark 时清掉预览色
adaptivePreview.value = null;
extracting.value = false;
});
async function pickFile() {
fileInput.value?.click();
}
async function onFile(e: Event) {
const f = (e.target as HTMLInputElement).files?.[0];
if (!f) return;
uploading.value = true;
try {
const r = await uploadFile(f);
form.value.iconType = 'image';
form.value.iconUrl = r.url;
// 上传图片后,自动切到「自适应」模式,让用户看到从图片提取的主色
form.value.colorBg = null;
await previewAdaptive();
} finally {
uploading.value = false;
if (fileInput.value) fileInput.value.value = '';
}
}
/* ---------- P28:背景色选择器 ---------- */
/** 自适应预览:用户点「自适应」按钮时实时从 iconUrl 提取的主色 */
const adaptivePreview = ref<string | null>(null);
const extracting = ref(false);
/** 选预设色 */
function pickPreset(value: string) {
form.value.colorBg = value;
adaptivePreview.value = null;
}
/** 选自定义颜色(<input type="color"> */
function onCustomColor(e: Event) {
const v = (e.target as HTMLInputElement).value;
form.value.colorBg = v;
adaptivePreview.value = null;
}
/** 切到「自适应」模式 */
async function pickAdaptive() {
form.value.colorBg = null;
await previewAdaptive();
}
/** P31:判定「未指定图标」(与后端 BookmarkService.IsIconUnspecified 对齐) */
const DEFAULT_ICON_NAMES = ['link', 'globe', 'bookmark'];
const isIconUnspecified = computed(() => {
if (form.value.iconType === 'image' || form.value.iconType === 'emoji') return false;
if (!form.value.iconType || form.value.iconType === 'lucide') {
if (form.value.iconUrl) return false;
const name = (form.value.icon ?? '').trim().toLowerCase();
return !name || DEFAULT_ICON_NAMES.includes(name);
}
return false;
});
/** P31:当前显示的是后端自动抓的 favicon(编辑模式时) */
const isAutoFavicon = computed(() => form.value.iconType === 'favicon' && !!form.value.iconUrl);
async function previewAdaptive() {
adaptivePreview.value = null;
if (form.value.iconType !== 'image' || !form.value.iconUrl) return;
extracting.value = true;
try {
const c = await extractDominantColor(resolveAssetUrl(form.value.iconUrl));
if (c) adaptivePreview.value = c;
} finally {
extracting.value = false;
}
}
/** 选中判断:与预设同色 / 与自定义同色 / 自适应 */
const selectedPreset = computed<string | null>(() => {
if (!form.value.colorBg) return null;
const hit = PRESET_COLORS.find(p => p.value.toLowerCase() === form.value.colorBg!.toLowerCase());
return hit ? hit.value : null;
});
const isCustomSelected = computed(() => !!form.value.colorBg && !selectedPreset.value);
const isAdaptiveSelected = computed(() => form.value.colorBg === null);
/** 「自适应」模式是否可点击(仅在用户上传了图片时可提取) */
const adaptiveAvailable = computed(() => form.value.iconType === 'image' && !!form.value.iconUrl);
const saving = ref(false);
async function save() {
if (!form.value.title.trim() || !form.value.url.trim() || !form.value.categoryId) return;
saving.value = true;
try {
const payload = { ...form.value, sort: Number(form.value.sort) || 0 };
if (props.bookmark) {
await bookmarks.update(props.bookmark.id, payload);
} else {
await bookmarks.create(payload);
}
emit('close');
} finally {
saving.value = false;
}
}
</script>
<template>
<AppModal :model-value="true" :title="bookmark ? '编辑链接' : '新建链接'" size="md" @update:model-value="emit('close')">
<div class="form">
<label class="form__row">
<span class="form__label">分类</span>
<select v-model.number="form.categoryId" class="form__input">
<optgroup v-for="g in categoryGroups" :key="g.rootName" :label="g.rootName">
<option v-for="it in g.items" :key="it.id" :value="it.id">
{{ it.depth === 2 ? ' ' : '' }}{{ it.name }}
</option>
</optgroup>
</select>
</label>
<label class="form__row">
<span class="form__label">标题</span>
<input v-model="form.title" class="form__input" placeholder="例如:GitHub" />
</label>
<label class="form__row">
<span class="form__label">URL</span>
<input v-model="form.url" class="form__input" placeholder="https://..." />
</label>
<label class="form__row">
<span class="form__label">简介</span>
<textarea v-model="form.description" class="form__input" rows="2" placeholder="可选" />
</label>
<div class="form__row">
<span class="form__label">图标</span>
<div class="form__icon-row">
<div class="form__icon-preview">
<AppIcon
:name="form.iconType === 'image' ? null : (form.icon || 'link')"
:url="form.iconType === 'image' ? resolveAssetUrl(form.iconUrl) : null"
:size="32"
/>
</div>
<input v-model="form.icon" class="form__input" placeholder="lucide 名,如 github / bot" />
<AppButton size="sm" variant="ghost" type="button" @click="iconPickerOpen = true" title="从图标库选择">
<AppIcon name="layout-grid" :size="14" /> 选择
</AppButton>
<AppButton size="sm" variant="ghost" type="button" :loading="uploading" @click="pickFile" title="上传图片">
<AppIcon name="upload" :size="14" /> 上传
</AppButton>
<AppButton
size="sm"
variant="ghost"
type="button"
:loading="fetchingFavicon"
:disabled="fetchingFavicon"
@click="autoFetchIcon"
title="从 URL 自动抓取网站 favicon"
>
<AppIcon name="download" :size="14" /> 自动获取
</AppButton>
<input ref="fileInput" type="file" accept="image/*" hidden @change="onFile" />
</div>
<!-- P32自动获取失败 / URL 未填的错误提示红色 -->
<p v-if="faviconError" class="form__hint form__hint--error">
<AppIcon name="alert-circle" :size="12" /> {{ faviconError }}
</p>
<!-- P31分情况提示 自动 favicon / 未指定将自动抓/ 已上传 -->
<p v-if="isAutoFavicon" class="form__hint form__hint--info">
<AppIcon name="globe" :size="12" /> 当前图标为系统自动抓取的网站 favicon可在选择中改用图标库上传自定义图片覆盖
</p>
<p v-else-if="isIconUnspecified" class="form__hint form__hint--info">
<AppIcon name="info" :size="12" /> 未指定图标保存时将自动抓取该网站的 favicon失败则使用默认图标
</p>
<p v-else-if="form.iconType === 'image' && form.iconUrl" class="form__hint">
已上传自定义图片可配合下方自适应自动提取主色
</p>
</div>
<!-- P28背景色选择器 -->
<div class="form__row">
<span class="form__label">背景色</span>
<!-- 预设色块 -->
<div class="form__color-grid">
<button
v-for="p in PRESET_COLORS"
:key="p.value"
type="button"
class="form__color-swatch"
:class="{ 'is-selected': selectedPreset === p.value }"
:style="{ background: p.value, color: p.fg }"
:title="p.label"
:aria-label="`预设颜色 ${p.label}`"
@click="pickPreset(p.value)"
>
<AppIcon v-if="selectedPreset === p.value" name="check" :size="14" />
</button>
</div>
<!-- 自定义颜色 / 自适应 -->
<div class="form__color-extra">
<label
class="form__color-swatch form__color-swatch--custom"
:class="{ 'is-selected': isCustomSelected }"
:style="isCustomSelected && form.colorBg ? { background: form.colorBg } : undefined"
title="自定义颜色"
>
<input
type="color"
:value="form.colorBg && !isAdaptiveSelected && !selectedPreset ? form.colorBg : '#6c5ce7'"
class="form__color-picker"
@input="onCustomColor"
/>
<AppIcon v-if="isCustomSelected" name="check" :size="14" />
<AppIcon v-else name="palette" :size="14" />
</label>
<button
type="button"
class="form__color-adaptive"
:class="{ 'is-selected': isAdaptiveSelected, 'is-disabled': !adaptiveAvailable }"
:disabled="!adaptiveAvailable"
:title="adaptiveAvailable ? '从上传的图片主色调自动提取' : '请先上传自定义图片'"
@click="pickAdaptive"
>
<span
class="form__color-adaptive-preview"
:style="adaptivePreview ? { background: adaptivePreview } : undefined"
>
<AppIcon v-if="extracting" name="loader" :size="14" class="spin" />
<AppIcon v-else-if="adaptivePreview" name="check" :size="14" />
<AppIcon v-else name="sparkles" :size="14" />
</span>
<span>自适应</span>
</button>
</div>
<p class="form__hint">
<template v-if="isAdaptiveSelected && adaptiveAvailable">
将从{{ form.iconUrl }}提取主色作为 logo 背景
</template>
<template v-else-if="isAdaptiveSelected && !adaptiveAvailable">
自适应仅在使用上传图片作为图标时可用未选时 fallback 到品牌色
</template>
<template v-else>
选中的颜色将作为 logo 块的背景色
</template>
</p>
</div>
<label class="form__row">
<span class="form__label">排序</span>
<input v-model.number="form.sort" type="number" class="form__input" />
</label>
</div>
<template #footer>
<AppButton variant="text" @click="emit('close')">取消</AppButton>
<AppButton variant="primary" :loading="saving" @click="save">保存</AppButton>
</template>
</AppModal>
<AppIconPicker v-model="iconPickerOpen" :selected="form.icon" @select="onIconPick" />
</template>
<style scoped>
.form { display: flex; flex-direction: column; gap: 14px; }
.form__row { display: flex; flex-direction: column; gap: 6px; }
.form__label { font-size: var(--font-sm); color: var(--color-text-muted); }
.form__input {
width: 100%;
padding: 8px 12px;
background: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
color: var(--color-text);
outline: none;
transition: border-color var(--duration-fast) var(--ease);
font-size: var(--font-sm);
}
.form__input:focus { border-color: var(--color-accent); }
.form__hint { font-size: var(--font-xs); color: var(--color-accent); display: flex; align-items: center; gap: 4px; }
/* P31favicon 提示用蓝色调 */
.form__hint--info { color: #4a90e2; }
/* P32:自动获取失败的红色错误提示 */
.form__hint--error { color: var(--color-danger); }
.form__icon-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.form__icon-preview {
width: 40px; height: 40px;
display: flex; align-items: center; justify-content: center;
background: var(--color-accent-soft);
color: var(--color-accent);
border-radius: var(--radius-sm);
flex-shrink: 0;
}
.form__icon-row .form__input { flex: 1; min-width: 120px; }
/* P28:背景色选择器 */
.form__color-grid {
display: grid;
grid-template-columns: repeat(10, 1fr);
gap: 6px;
}
.form__color-swatch {
position: relative;
width: 100%;
aspect-ratio: 1 / 1;
border: 2px solid transparent;
border-radius: var(--radius-sm);
cursor: pointer;
display: flex; align-items: center; justify-content: center;
transition: transform var(--duration-fast) var(--ease),
border-color var(--duration-fast) var(--ease),
box-shadow var(--duration-fast) var(--ease);
color: #ffffff;
overflow: hidden;
}
.form__color-swatch:hover { transform: scale(1.08); }
.form__color-swatch.is-selected {
border-color: var(--color-text);
box-shadow: 0 0 0 2px var(--color-accent-soft);
}
.form__color-swatch--custom {
background: repeating-conic-gradient(#6c5ce7 0 25%, #00cec9 0 50%, #fdcb6e 0 75%, #ff7675 0 100%);
background-size: 12px 12px;
color: #ffffff;
}
.form__color-picker {
position: absolute; inset: 0;
width: 100%; height: 100%;
opacity: 0;
cursor: pointer;
border: none; padding: 0; background: transparent;
}
.form__color-extra {
display: flex; gap: 8px; align-items: center;
margin-top: 4px;
}
.form__color-swatch--custom {
width: 36px; height: 36px;
flex-shrink: 0;
}
.form__color-adaptive {
display: flex; align-items: center; gap: 6px;
padding: 6px 10px;
background: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
color: var(--color-text);
font-size: var(--font-xs);
cursor: pointer;
transition: border-color var(--duration-fast) var(--ease),
background var(--duration-fast) var(--ease);
}
.form__color-adaptive:hover:not(:disabled) { border-color: var(--color-accent); }
.form__color-adaptive.is-selected {
border-color: var(--color-accent);
background: var(--color-accent-soft);
color: var(--color-accent);
}
.form__color-adaptive.is-disabled {
opacity: 0.4;
cursor: not-allowed;
}
.form__color-adaptive-preview {
width: 18px; height: 18px;
display: flex; align-items: center; justify-content: center;
background: linear-gradient(135deg, #6c5ce7, #00cec9);
color: #ffffff;
border-radius: var(--radius-xs);
flex-shrink: 0;
font-size: 10px;
}
.spin { animation: spin 0.8s linear infinite; }
@keyframes spin { from { transform: rotate(0); } to { transform: rotate(360deg); } }
@media (max-width: 480px) {
.form__color-grid { grid-template-columns: repeat(5, 1fr); }
}
</style>
+133
View File
@@ -0,0 +1,133 @@
<script setup lang="ts">
/**
* CategoryForm:分类增 / 改表单。
* P23:图标字段右侧新增「选择」按钮,弹出 AppIconPicker 网格选择器
*/
import { ref, watch } from 'vue';
import { useCategoriesStore } from '@/stores/categories';
import AppModal from './AppModal.vue';
import AppButton from './AppButton.vue';
import AppIcon from './AppIcon.vue';
import AppIconPicker from './AppIconPicker.vue';
import type { Category } from '@/types/api';
interface Props {
category: Category | null;
defaultParentId: number;
}
const props = defineProps<Props>();
const emit = defineEmits<{ (e: 'close'): void }>();
const categories = useCategoriesStore();
const form = ref({
parentId: props.category?.parentId ?? props.defaultParentId,
name: props.category?.name ?? '',
icon: props.category?.icon ?? 'layers',
sort: props.category?.sort ?? 0
});
watch(() => props.category, (c) => {
form.value = {
parentId: c?.parentId ?? props.defaultParentId,
name: c?.name ?? '',
icon: c?.icon ?? 'layers',
sort: c?.sort ?? 0
};
});
// P23:图标选择器
const iconPickerOpen = ref(false);
function onIconPick(name: string) {
form.value.icon = name;
}
const saving = ref(false);
async function save() {
if (!form.value.name.trim()) return;
saving.value = true;
try {
const payload = { ...form.value, sort: Number(form.value.sort) || 0 };
if (props.category) {
await categories.update(props.category.id, payload);
} else {
await categories.create(payload);
}
emit('close');
} finally {
saving.value = false;
}
}
</script>
<template>
<AppModal :model-value="true" :title="category ? '编辑分类' : '新建分类'" size="sm" @update:model-value="emit('close')">
<div class="form">
<label class="form__row">
<span class="form__label">父分类</span>
<select v-model.number="form.parentId" class="form__input" :disabled="!!category">
<option :value="0"> 顶级分类 </option>
<option v-for="c in categories.tree" :key="c.id" :value="c.id">{{ c.name }}</option>
</select>
</label>
<label class="form__row">
<span class="form__label">名称</span>
<input v-model="form.name" class="form__input" placeholder="分类名" />
</label>
<div class="form__row">
<span class="form__label">图标</span>
<div class="form__icon-row">
<div class="form__icon-preview">
<AppIcon :name="form.icon || 'layers'" :size="28" />
</div>
<input v-model="form.icon" class="form__input" placeholder="lucide 名,如 layers / bot" />
<AppButton size="sm" variant="ghost" type="button" @click="iconPickerOpen = true" title="从图标库选择">
<AppIcon name="layout-grid" :size="14" /> 选择
</AppButton>
</div>
</div>
<label class="form__row">
<span class="form__label">排序</span>
<input v-model.number="form.sort" type="number" class="form__input" />
</label>
</div>
<template #footer>
<AppButton variant="text" @click="emit('close')">取消</AppButton>
<AppButton variant="primary" :loading="saving" @click="save">保存</AppButton>
</template>
</AppModal>
<AppIconPicker v-model="iconPickerOpen" :selected="form.icon" @select="onIconPick" />
</template>
<style scoped>
.form { display: flex; flex-direction: column; gap: 14px; }
.form__row { display: flex; flex-direction: column; gap: 6px; }
.form__label { font-size: var(--font-sm); color: var(--color-text-muted); }
.form__input {
width: 100%;
padding: 8px 12px;
background: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
color: var(--color-text);
outline: none;
font-size: var(--font-sm);
transition: border-color var(--duration-fast) var(--ease);
}
.form__input:focus { border-color: var(--color-accent); }
.form__input:disabled { opacity: .6; }
.form__icon-row { display: flex; align-items: center; gap: 8px; }
.form__icon-preview {
width: 40px; height: 40px;
display: flex; align-items: center; justify-content: center;
background: var(--color-accent-soft);
color: var(--color-accent);
border-radius: var(--radius-sm);
flex-shrink: 0;
}
.form__icon-row .form__input { flex: 1; }
</style>
+71
View File
@@ -0,0 +1,71 @@
/**
* 运行时配置加载器
* --------------------------------------------------------------------
* 改地址流程:1Panel 文件管理 → 编辑 dist/config.json → 浏览器 Ctrl+F5 强刷
* 加载策略:async fetch /config.json(带时间戳 bust 浏览器缓存)
* 失败降级:apiBase 兜底为空字符串 → 走相对路径 /api(推荐前后端同域反代模式)
* 类型安全:RuntimeConfig interface 强约束所有字段
*/
export interface RuntimeConfig {
/** 后端 API 地址。空字符串 = 当前域名(推荐 1Panel 反代模式);非空 = 绝对 URL(直连后端) */
apiBase: string;
}
const DEFAULT_CONFIG: RuntimeConfig = {
apiBase: ''
};
let cachedConfig: RuntimeConfig | null = null;
/** 启动时调用一次,加载 public/config.json。结果缓存到内存,重复调用直接返回缓存。 */
export async function loadRuntimeConfig(): Promise<RuntimeConfig> {
if (cachedConfig) return cachedConfig;
try {
// 时间戳 bust 浏览器 + CDN 缓存(config.json 是部署后可改的,缓存会导致改完不生效)
const res = await fetch(`/config.json?_t=${Date.now()}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const raw = await res.json();
cachedConfig = {
apiBase: typeof raw?.apiBase === 'string' ? raw.apiBase.trim() : ''
};
return cachedConfig;
} catch (err) {
// 加载失败(404 / JSON 格式错 / 网络问题)→ 兜底用默认配置,绝不阻塞启动
console.warn('[config] 加载 /config.json 失败,使用默认配置(apiBase="" 走相对路径)', err);
cachedConfig = { ...DEFAULT_CONFIG };
return cachedConfig;
}
}
/** 同步获取已加载的配置(必须在 loadRuntimeConfig 完成后调用,否则返回默认值) */
export function getRuntimeConfig(): RuntimeConfig {
return cachedConfig ?? { ...DEFAULT_CONFIG };
}
/**
* P52 修复:把后端返回的相对路径资源 URL"/uploads/...")解析为可访问的绝对 URL。
*
* 场景:主人前后端分离部署在不同域名(前端 mh.1vs5.top + 后端 mhapi.1vs5.top),
* 后端返回的 iconUrl 是 "/uploads/2026/...png" 相对路径,浏览器直接当相对路径
* 拼到当前页面 host(前端域名)就 404。必须用 config.json 的 apiBase 拼成绝对 URL。
*
* 规则(按顺序短路):
* 1. 空 / null / undefined → 返回 ''(调用方应已 v-if 兜底)
* 2. 已是 http(s):// / data: / blob: / 协议相对 // → 原样返回(不重复拼)
* 3. apiBase 为空(同域反代模式)→ 原样返回(让浏览器用当前 host 解析)
* 4. apiBase 非空(跨域直连模式)→ apiBase + 路径 拼成绝对 URL
*/
export function resolveAssetUrl(path: string | null | undefined): string {
if (!path) return '';
if (/^(https?:|data:|blob:)/i.test(path)) return path;
if (path.startsWith('//')) return path;
const apiBase = getRuntimeConfig().apiBase;
if (!apiBase) return path;
const base = apiBase.replace(/\/$/, '');
return path.startsWith('/') ? `${base}${path}` : `${base}/${path}`;
}
+15
View File
@@ -0,0 +1,15 @@
/// <reference types="vite/client" />
declare module '*.vue' {
import type { DefineComponent } from 'vue';
const component: DefineComponent<{}, {}, any>;
export default component;
}
interface ImportMetaEnv {
readonly VITE_API_BASE: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}
+30
View File
@@ -0,0 +1,30 @@
import { createApp } from 'vue';
import { createPinia } from 'pinia';
import App from './App.vue';
import router from './router';
import { initHttp } from '@/api/http';
import './styles/tokens.css';
import './styles/global.css';
/**
* 启动流程(async bootstrap):
* 1. initHttp() → 加载 /config.json + 更新 axios baseURLP49 运行时配置)
* 2. createApp + mount → 业务代码运行时拿到的 baseURL 已经是最终值
*
* 为什么不直接 createApp
* 避免竞态:组件 setup 时可能立刻发请求,baseURL 还没改就会指错地方
*/
async function bootstrap(): Promise<void> {
await initHttp();
const app = createApp(App);
app.use(createPinia());
app.use(router);
app.mount('#app');
}
bootstrap().catch((err) => {
// 兜底:initHttp 内部已捕获 /config.json 错误,到这一步说明真的崩了
console.error('[main] 启动失败', err);
});
+15
View File
@@ -0,0 +1,15 @@
import { createRouter, createWebHashHistory, type RouteRecordRaw } from 'vue-router';
import HomeView from '@/views/HomeView.vue';
import SettingsView from '@/views/SettingsView.vue';
const routes: RouteRecordRaw[] = [
{ path: '/', name: 'home', component: HomeView },
{ path: '/settings', name: 'settings', component: SettingsView }
];
const router = createRouter({
history: createWebHashHistory(),
routes
});
export default router;
+59
View File
@@ -0,0 +1,59 @@
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';
import * as api from '@/api/bookmarks';
import type { Bookmark, BookmarkUpsert } from '@/types/api';
export const useBookmarksStore = defineStore('bookmarks', () => {
const items = ref<Bookmark[]>([]);
const loaded = ref(false);
const loading = ref(false);
const byCategory = computed(() => {
const map = new Map<number, Bookmark[]>();
for (const b of items.value) {
if (!map.has(b.categoryId)) map.set(b.categoryId, []);
map.get(b.categoryId)!.push(b);
}
// 排序
for (const list of map.values()) {
list.sort((a, b) => a.sort - b.sort || b.id - a.id);
}
return map;
});
const byId = computed(() => {
const m = new Map<number, Bookmark>();
items.value.forEach(b => m.set(b.id, b));
return m;
});
async function load(force = false) {
if ((loaded.value && !force) || loading.value) return;
loading.value = true;
try {
items.value = await api.fetchBookmarks();
loaded.value = true;
} finally {
loading.value = false;
}
}
async function create(payload: BookmarkUpsert) {
const created = await api.createBookmark(payload);
await load(true);
return created;
}
async function update(id: number, payload: BookmarkUpsert) {
const updated = await api.updateBookmark(id, payload);
await load(true);
return updated;
}
async function remove(id: number) {
await api.deleteBookmark(id);
await load(true);
}
return { items, byCategory, byId, loaded, loading, load, create, update, remove };
});
+59
View File
@@ -0,0 +1,59 @@
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';
import * as api from '@/api/categories';
import type { Category, CategoryUpsert } from '@/types/api';
export const useCategoriesStore = defineStore('categories', () => {
const tree = ref<Category[]>([]);
const loaded = ref(false);
const loading = ref(false);
const flat = computed(() => {
const list: Category[] = [];
const walk = (nodes: Category[]) => {
for (const n of nodes) {
list.push(n);
if (n.children?.length) walk(n.children);
}
};
walk(tree.value);
return list;
});
const byId = computed(() => {
const m = new Map<number, Category>();
flat.value.forEach(c => m.set(c.id, c));
return m;
});
async function load(force = false) {
if ((loaded.value && !force) || loading.value) return;
loading.value = true;
try {
const result = await api.fetchCategoryTree();
tree.value = result;
loaded.value = true;
} finally {
loading.value = false;
}
}
async function create(payload: CategoryUpsert) {
const created = await api.createCategory(payload);
await load(true);
return created;
}
async function update(id: number, payload: CategoryUpsert) {
const updated = await api.updateCategory(id, payload);
await load(true);
return updated;
}
async function remove(id: number) {
await api.deleteCategory(id);
await load(true);
}
return { tree, flat, byId, loaded, loading, load, create, update, remove };
});
+66
View File
@@ -0,0 +1,66 @@
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';
import * as api from '@/api/searchEngines';
import type { SearchEngine, SearchEngineUpsert } from '@/types/api';
export const useSearchEnginesStore = defineStore('searchEngines', () => {
const items = ref<SearchEngine[]>([]);
const loaded = ref(false);
const loading = ref(false);
const current = ref<SearchEngine | null>(null);
const ordered = computed(() =>
[...items.value].sort((a, b) => a.sort - b.sort || a.id - b.id)
);
const defaultEngine = computed(() =>
items.value.find(e => e.isDefault) ?? items.value[0] ?? null
);
async function load(force = false) {
if ((loaded.value && !force) || loading.value) return;
loading.value = true;
try {
items.value = await api.fetchSearchEngines();
loaded.value = true;
if (!current.value) current.value = defaultEngine.value;
} finally {
loading.value = false;
}
}
function setCurrent(engine: SearchEngine) {
current.value = engine;
}
function buildUrl(q: string): string {
if (!current.value) return `https://www.google.com/search?q=${encodeURIComponent(q)}`;
return current.value.urlTemplate.replace('{q}', encodeURIComponent(q));
}
async function create(payload: SearchEngineUpsert) {
const created = await api.createSearchEngine(payload);
await load(true);
return created;
}
async function update(id: number, payload: SearchEngineUpsert) {
const updated = await api.updateSearchEngine(id, payload);
await load(true);
return updated;
}
async function remove(id: number) {
await api.deleteSearchEngine(id);
await load(true);
}
async function setDefault(id: number) {
const updated = await api.setDefaultEngine(id);
await load(true);
return updated;
}
return { items, ordered, current, defaultEngine, loaded, loading, load, setCurrent, buildUrl, create, update, remove, setDefault };
});
+261
View File
@@ -0,0 +1,261 @@
import { defineStore } from 'pinia';
import { ref, computed, watch } from 'vue';
import { fetchSettings, updateSettings } from '@/api/settings';
import { fetchWallpaperCategories, fetchWallpaperRandom, refreshWallpaperRandom } from '@/api/wallpaper';
import type { AppSettings, ThemeMode, SettingUpdate } from '@/types/api';
import type { WallpaperCategory, WallpaperRandom } from '@/api/wallpaper';
/** 设置 store:主题 / 主色调 / 背景图 / 360 壁纸,并自动应用到 <html> 上。 */
export const useSettingsStore = defineStore('settings', () => {
const settings = ref<AppSettings>({
themeMode: 'dark',
accentColor: '#6c5ce7',
backgroundImage: 'wp1',
backgroundType: 'preset',
openLinksInNewTab: true,
// P46:搜索框行为(true = 新选项卡打开搜索结果,默认与链接行为一致)
openSearchInNewTab: true,
// ===== P34 360 壁纸模式默认值 =====
wallpaperEnabled: false,
wallpaperCategoryId: '',
wallpaperInterval: 30,
updatedAt: new Date().toISOString()
});
const loaded = ref(false);
const loading = ref(false);
/** 计算属性:是否暗色 */
const isDark = computed(() => {
if (settings.value.themeMode === 'auto') {
return window.matchMedia('(prefers-color-scheme: dark)').matches;
}
return settings.value.themeMode === 'dark';
});
// ===== P34 360 壁纸相关状态 =====
/** 当前正在显示的 360 壁纸 URL(开启了 wallpaperEnabled 时使用) */
const wallpaperUrl = ref<string | null>(null);
/** 360 壁纸分类列表(启动时按需拉一次) */
const wallpaperCategories = ref<WallpaperCategory[]>([]);
/** 360 壁纸是否正在切换中(用于按钮 loading 状态) */
const wallpaperLoading = ref(false);
/** 360 壁纸拉取失败信息(提示给用户) */
const wallpaperError = ref<string | null>(null);
/** 把主题 / 主色 / 背景图应用到 <html> 上。 */
function applyToDom() {
const root = document.documentElement;
const theme: 'dark' | 'light' = isDark.value ? 'dark' : 'light';
root.setAttribute('data-theme', theme);
root.style.setProperty('--color-accent', settings.value.accentColor);
root.style.setProperty('--color-accent-hover', shade(settings.value.accentColor, -12));
root.style.setProperty('--color-accent-soft', hexToRgba(settings.value.accentColor, 0.18));
applyBackground();
}
/** 背景图:预设 key / 自定义 URL / 纯色 */
function applyBackground() {
const root = document.documentElement;
root.style.setProperty('--bg-overlay', 'transparent');
// P34360 模式开启时,背景由 AppWallpaper 组件直接接管(不写 --bg-image
if (settings.value.wallpaperEnabled) {
// 不清空 --bg-image,让传统预设/自定义继续兜底(防止 360 接口失败时一片黑)
return;
}
const bg = settings.value.backgroundImage;
if (settings.value.backgroundType === 'solid' || !bg) {
root.style.setProperty('--bg-image', 'none');
return;
}
if (settings.value.backgroundType === 'custom') {
root.style.setProperty('--bg-image', `url("${bg}")`);
return;
}
// preset:使用 SVG 渐变作占位(也可换成真实 jpg/png)
const url = presetToDataUrl(bg);
root.style.setProperty('--bg-image', `url("${url}")`);
}
/** 拉取设置(初始化时) */
async function load() {
if (loaded.value || loading.value) return;
loading.value = true;
try {
const data = await fetchSettings();
settings.value = { ...settings.value, ...data };
loaded.value = true;
applyToDom();
// 360 模式:尝试拉一次分类(失败不影响主流程)
if (settings.value.wallpaperEnabled) {
loadWallpaperCategories().catch(() => {});
}
} catch (e) {
console.warn('[settings] load failed, using defaults', e);
} finally {
loading.value = false;
}
}
/** 更新设置(部分字段) */
async function update(patch: SettingUpdate) {
const next = await updateSettings(patch);
settings.value = { ...settings.value, ...next };
applyToDom();
return next;
}
/** 切主题:dark ↔ light */
async function setThemeMode(mode: ThemeMode) {
return update({ themeMode: mode });
}
/** 切主色调 */
async function setAccentColor(color: string) {
return update({ accentColor: color });
}
/** 切背景图 */
async function setBackground(type: 'preset' | 'custom' | 'solid', value: string | null) {
return update({ backgroundType: type, backgroundImage: value });
}
/** 切链接打开方式:true = 新选项卡;false = 当前选项卡 */
async function setOpenLinksInNewTab(openInNewTab: boolean) {
return update({ openLinksInNewTab: openInNewTab });
}
/** P46:切搜索框打开方式:true = 新选项卡;false = 当前选项卡 */
async function setOpenSearchInNewTab(openInNewTab: boolean) {
return update({ openSearchInNewTab: openInNewTab });
}
// ===== P34 360 壁纸相关 actions =====
/** 加载 360 壁纸分类列表(首次进入设置页或开启 360 模式时调用) */
async function loadWallpaperCategories() {
if (wallpaperCategories.value.length > 0) return;
try {
wallpaperCategories.value = await fetchWallpaperCategories();
} catch (e) {
console.warn('[settings] load 360 categories failed', e);
}
}
/**
* 拉 1 张随机壁纸并写入 wallpaperUrl。失败时 wallpaperUrl 不变,错误信息写入 wallpaperError。
* @param refresh true=用 refresh 接口清缓存重拉;false=普通 random
*/
async function fetchRandomWallpaper(refresh: boolean = false) {
if (wallpaperLoading.value) return; // 防并发
wallpaperLoading.value = true;
wallpaperError.value = null;
try {
const { innerWidth: w, innerHeight: h } = window;
// 视口保护:极端窄屏(如移动端 < 800)就用 1080 兜底
const safeW = Math.max(800, w);
const safeH = Math.max(600, h);
const cid = settings.value.wallpaperCategoryId ?? '';
const r: WallpaperRandom = refresh
? await refreshWallpaperRandom(cid, safeW, safeH)
: await fetchWallpaperRandom(cid, safeW, safeH);
wallpaperUrl.value = r.url;
} catch (e: any) {
wallpaperError.value = e?.message || '360 壁纸拉取失败';
console.warn('[settings] fetch random wallpaper failed', e);
} finally {
wallpaperLoading.value = false;
}
}
/** 「立即切换」按钮:调 refresh 接口清缓存重拉,立即换图 */
async function forceRefreshWallpaper() {
return fetchRandomWallpaper(true);
}
/** 启用 / 关闭 360 壁纸模式 */
async function setWallpaperEnabled(enabled: boolean) {
await update({ wallpaperEnabled: enabled });
// 开启时如果还没有 wallpaperUrl,立即拉一张
if (enabled && !wallpaperUrl.value) {
loadWallpaperCategories().catch(() => {});
await fetchRandomWallpaper(false);
}
}
/** 切 360 分类:立即拉该分类的新一张 */
async function setWallpaperCategoryId(cid: string) {
await update({ wallpaperCategoryId: cid });
if (settings.value.wallpaperEnabled) {
await fetchRandomWallpaper(true); // 切分类 = 强制刷新
}
}
/** 切切换间隔(不立即换图,下次定时器触发时生效) */
async function setWallpaperInterval(minutes: number) {
return update({ wallpaperInterval: minutes });
}
// 跟随系统主题变化(auto 模式下实时响应)
if (typeof window !== 'undefined' && window.matchMedia) {
const mql = window.matchMedia('(prefers-color-scheme: dark)');
mql.addEventListener('change', () => {
if (settings.value.themeMode === 'auto') applyToDom();
});
}
watch([() => settings.value.themeMode, () => settings.value.accentColor, isDark], applyToDom);
// P34:壁纸启用 / 关闭时重新应用背景变量(避免开关切换时背景图短暂错乱)
watch(() => settings.value.wallpaperEnabled, applyBackground);
return {
settings, loaded, loading, isDark,
load, update, setThemeMode, setAccentColor, setBackground, setOpenLinksInNewTab, setOpenSearchInNewTab, applyToDom,
// P34 360 壁纸
wallpaperUrl, wallpaperCategories, wallpaperLoading, wallpaperError,
loadWallpaperCategories, fetchRandomWallpaper, forceRefreshWallpaper,
setWallpaperEnabled, setWallpaperCategoryId, setWallpaperInterval
};
});
/** 颜色加深 / 减淡(百分比) */
function shade(hex: string, percent: number): string {
const { r, g, b } = parseHex(hex);
const f = percent / 100;
return toHex(
Math.max(0, Math.min(255, Math.round(r + (f < 0 ? r : 255 - r) * f))),
Math.max(0, Math.min(255, Math.round(g + (f < 0 ? g : 255 - g) * f))),
Math.max(0, Math.min(255, Math.round(b + (f < 0 ? b : 255 - b) * f)))
);
}
function parseHex(hex: string): { r: number; g: number; b: number } {
let h = hex.replace('#', '').trim();
if (h.length === 3) h = h.split('').map(c => c + c).join('');
const n = parseInt(h, 16);
return { r: (n >> 16) & 255, g: (n >> 8) & 255, b: n & 255 };
}
function toHex(r: number, g: number, b: number): string {
return '#' + [r, g, b].map(v => v.toString(16).padStart(2, '0')).join('');
}
function hexToRgba(hex: string, alpha: number): string {
const { r, g, b } = parseHex(hex);
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
}
/** 预设背景 key 转 dataUrl(占位渐变,生产可换成真实 jpg) */
function presetToDataUrl(key: string): string {
const presets: Record<string, [string, string]> = {
wp1: ['#6c5ce7', '#00cec9'],
wp2: ['#ff6b6b', '#ffa500'],
wp3: ['#0093E9', '#80D0C7'],
wp4: ['#8EC5FC', '#E0C3FC'],
wp5: ['#0F2027', '#2C5364'],
wp6: ['#F7971E', '#FFD200']
};
const [a, b] = presets[key] ?? presets.wp1;
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="1600" height="1000" viewBox="0 0 1600 1000"><defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1"><stop offset="0%" stop-color="${a}"/><stop offset="100%" stop-color="${b}"/></linearGradient></defs><rect width="1600" height="1000" fill="url(#g)"/></svg>`;
return 'data:image/svg+xml;utf8,' + encodeURIComponent(svg);
}
+53
View File
@@ -0,0 +1,53 @@
import { defineStore } from 'pinia';
import { ref } from 'vue';
import { fetchChanges } from '@/api/sync';
import { useSettingsStore } from './settings';
import { useCategoriesStore } from './categories';
import { useBookmarksStore } from './bookmarks';
import { useSearchEnginesStore } from './searchEngines';
const LAST_SYNC_KEY = 'myhomepage.lastSync';
/** 同步 store:维护一个 lastSync 时间戳,触发时拉增量并刷新各 store。 */
export const useSyncStore = defineStore('sync', () => {
const lastSync = ref<string | null>(localStorage.getItem(LAST_SYNC_KEY));
const syncing = ref(false);
const lastError = ref<string | null>(null);
async function sync() {
if (syncing.value) return;
syncing.value = true;
lastError.value = null;
try {
const data = await fetchChanges(lastSync.value ?? undefined);
const snap = data.snapshot;
const settings = useSettingsStore();
const categories = useCategoriesStore();
const bookmarks = useBookmarksStore();
const engines = useSearchEnginesStore();
// 全量覆盖本地(前端无独立 store 中间状态时,最简单一致)
settings.$patch({ settings: snap.settings ?? settings.settings });
categories.$patch({ tree: snap.categories });
bookmarks.$patch({ items: snap.bookmarks });
engines.$patch({ items: snap.searchEngines });
categories.$patch({ loaded: true });
bookmarks.$patch({ loaded: true });
engines.$patch({ loaded: true });
settings.$patch({ loaded: true });
settings.applyToDom();
lastSync.value = data.serverTime;
localStorage.setItem(LAST_SYNC_KEY, data.serverTime);
} catch (e) {
lastError.value = (e as Error).message;
throw e;
} finally {
syncing.value = false;
}
}
return { lastSync, syncing, lastError, sync };
});
+88
View File
@@ -0,0 +1,88 @@
/* ==========================================================================
Global Styles
全局基础样式(reset + 滚动条 + 通用类)
========================================================================== */
*, *::before, *::after { box-sizing: border-box; }
html, body, #app {
margin: 0;
padding: 0;
height: 100%;
width: 100%;
}
body {
font-family: var(--font-family-base);
font-size: var(--font-base);
line-height: var(--line-base);
color: var(--color-text);
background: var(--color-bg);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
transition: background-color var(--duration-slow) var(--ease),
color var(--duration-slow) var(--ease);
overflow: hidden;
}
h1, h2, h3, h4, h5, h6, p { margin: 0; }
button { font: inherit; color: inherit; cursor: pointer; border: 0; background: none; padding: 0; }
input, textarea, select { font: inherit; color: inherit; }
a { color: inherit; text-decoration: none; }
img, svg { display: block; max-width: 100%; }
ul, ol { list-style: none; padding: 0; margin: 0; }
/* 滚动条 */
::-webkit-scrollbar { width: 8px; height: 8px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb {
background: var(--color-border-strong);
border-radius: var(--radius-pill);
}
::-webkit-scrollbar-thumb:hover { background: var(--color-text-subtle); }
/* 选区 */
::selection { background: var(--color-accent-soft); color: var(--color-text); }
/* 通用工具类 */
.glass {
background: var(--glass-bg);
backdrop-filter: blur(var(--glass-blur)) saturate(180%);
-webkit-backdrop-filter: blur(var(--glass-blur)) saturate(180%);
border: 1px solid var(--glass-border);
}
.glass-strong {
background: var(--glass-bg-strong);
backdrop-filter: blur(var(--glass-blur)) saturate(180%);
-webkit-backdrop-filter: blur(var(--glass-blur)) saturate(180%);
border: 1px solid var(--glass-border);
}
.flex { display: flex; }
.flex-1 { flex: 1; min-width: 0; min-height: 0; }
.flex-center { display: flex; align-items: center; justify-content: center; }
.flex-col { display: flex; flex-direction: column; }
.gap-2 { gap: var(--space-2); }
.gap-3 { gap: var(--space-3); }
.gap-4 { gap: var(--space-4); }
/* 渐入动画 */
@keyframes fade-in {
from { opacity: 0; transform: translateY(4px); }
to { opacity: 1; transform: translateY(0); }
}
.fade-in { animation: fade-in var(--duration-base) var(--ease); }
/* 文本省略 */
.ellipsis {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.line-clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
+142
View File
@@ -0,0 +1,142 @@
/* ==========================================================================
Design Tokens
从 browser-homepage/colors_and_type.css 抽取并扩展
暗 / 亮主题通过覆盖 [data-theme] 下的同名变量实现
========================================================================== */
:root {
/* 主题模式:dark | light | autoauto 跟随系统,由 useTheme() 切换 data-theme */
color-scheme: dark;
/* ---- 颜色 · 暗色(默认)---- */
--color-bg: #0f0f1a;
--color-bg-elevated: #1a1a2e;
--color-surface: #16213e;
--color-surface-strong: #1f2940;
--color-border: rgba(255, 255, 255, 0.08);
--color-border-strong: rgba(255, 255, 255, 0.16);
--color-text: #e8e8f0;
--color-text-muted: #a0a0b8;
--color-text-subtle: #6c6c84;
--color-accent: #6c5ce7;
--color-accent-hover: #5b4cd1;
--color-accent-soft: rgba(108, 92, 231, 0.18);
--color-danger: #ff7675;
--color-success: #00cec9;
--color-warning: #fdcb6e;
/* ---- 阴影 ---- */
--shadow-sm: 0 1px 2px rgba(0,0,0,.12);
--shadow-md: 0 4px 12px rgba(0,0,0,.18);
--shadow-lg: 0 12px 32px rgba(0,0,0,.32);
--shadow-xl: 0 24px 64px rgba(0,0,0,.48);
/* ---- Z-Index 层级(统一管理)---- */
--z-base: 0;
--z-elevated: 10;
--z-fab: 50;
--z-searchbar: 60;
--z-drawer: 1000;
--z-popover: 1500; /* 菜单(AppSidebar ⋯ 菜单、AppSearchBar 引擎菜单等) */
--z-modal: 2000; /* AppModal */
--z-toast: 3000; /* AppToastHost */
--shadow-glow: 0 0 24px rgba(108,92,231,.45);
/* ---- 圆角 ---- */
--radius-xs: 4px;
--radius-sm: 6px;
--radius-md: 10px;
--radius-lg: 14px;
--radius-xl: 20px;
--radius-pill: 999px;
/* ---- 间距 ---- */
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--space-5: 20px;
--space-6: 24px;
--space-8: 32px;
--space-10: 40px;
--space-12: 48px;
/* ---- 字号 ---- */
--font-xs: 12px;
--font-sm: 13px;
--font-base: 14px;
--font-md: 15px;
--font-lg: 16px;
--font-xl: 18px;
--font-2xl: 22px;
--font-3xl: 28px;
--font-4xl: 34px;
/* ---- 行高 ---- */
--line-tight: 1.2;
--line-base: 1.5;
--line-loose: 1.7;
/* ---- 字重 ---- */
--weight-regular: 400;
--weight-medium: 500;
--weight-semibold: 600;
--weight-bold: 700;
/* ---- 玻璃拟态 ---- */
--glass-bg: rgba(26, 26, 46, 0.72);
--glass-bg-strong: rgba(26, 26, 46, 0.88);
--glass-bg-faint: rgba(26, 26, 46, 0.40);
--glass-border: rgba(255, 255, 255, 0.08);
--glass-blur: 20px;
--glass-blur-lg: 32px;
--glass-blur-sm: 12px;
/* ---- 布局尺寸 ---- */
--sidebar-width: 240px;
--sidebar-width-collapsed: 64px;
--topbar-height: 64px;
--fab-size: 56px;
--container-max: 1400px;
/* ---- 链接卡片 / 列表项 ---- */
--link-logo-size: 88px; /* 桌面端方形 logo 块边长 */
--link-card-min-height: 88px; /* 桌面端卡片最小高度,与 logo 同步 */
--link-card-radius: var(--radius-lg);
--link-logo-radius: var(--radius-sm);
--link-row-logo-size: 56px; /* 移动端列表项 logo 块边长 */
--link-row-min-height: 64px; /* 移动端列表项最小高度 */
/* ---- 动画 ---- */
--ease: cubic-bezier(.4,0,.2,1);
--duration-fast: 120ms;
--duration-base: 200ms;
--duration-slow: 360ms;
/* ---- 字体族 ---- */
--font-family-base: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC',
'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
--font-family-mono: ui-monospace, SFMono-Regular, 'JetBrains Mono', Menlo, monospace;
}
/* 亮色主题 */
[data-theme="light"] {
color-scheme: light;
--color-bg: #f6f7fb;
--color-bg-elevated: #ffffff;
--color-surface: #ffffff;
--color-surface-strong: #f0f1f5;
--color-border: rgba(15, 15, 26, 0.08);
--color-border-strong: rgba(15, 15, 26, 0.16);
--color-text: #1a1a2e;
--color-text-muted: #5c5c70;
--color-text-subtle: #8a8aa0;
--color-accent-soft: rgba(108, 92, 231, 0.12);
--glass-bg: rgba(255, 255, 255, 0.78);
--glass-bg-strong: rgba(255, 255, 255, 0.92);
--glass-bg-faint: rgba(255, 255, 255, 0.55);
--glass-border: rgba(15, 15, 26, 0.08);
--shadow-md: 0 4px 12px rgba(0,0,0,.08);
--shadow-lg: 0 10px 30px rgba(0,0,0,.12);
--shadow-xl: 0 20px 60px rgba(0,0,0,.16);
}
+158
View File
@@ -0,0 +1,158 @@
/* ==========================================================================
API 类型定义(与后端 DTOs 一一对应)
========================================================================== */
export interface Category {
id: number;
parentId: number;
name: string;
icon?: string | null;
sort: number;
createdAt: string;
updatedAt: string;
children: Category[];
}
export interface CategoryUpsert {
id?: number;
parentId: number;
name: string;
icon?: string | null;
sort: number;
}
export interface Bookmark {
id: number;
categoryId: number;
title: string;
url: string;
description?: string | null;
icon?: string | null;
/**
* 图标类型:
* - `lucide` 用户从图标库选择(lucide-vue-next
* - `emoji` 用户填的 emoji 字符
* - `image` 用户手动上传的图片(iconUrl 指向 /uploads/...
* - `favicon` P31:后端自动抓取的网站 faviconiconUrl 指向 /uploads/.../favicons/...
*/
iconType?: 'lucide' | 'emoji' | 'image' | 'favicon';
iconUrl?: string | null;
/** P28logo 背景色;null = 自适应(前端从 url / iconUrl 推断) */
colorBg?: string | null;
sort: number;
createdAt: string;
updatedAt: string;
}
export interface BookmarkUpsert {
id?: number;
categoryId: number;
title: string;
url: string;
description?: string | null;
icon?: string | null;
iconType?: 'lucide' | 'emoji' | 'image' | 'favicon';
iconUrl?: string | null;
/** P28logo 背景色;null = 自适应 */
colorBg?: string | null;
sort: number;
}
export interface SearchEngine {
id: number;
name: string;
urlTemplate: string;
/** P37:图标类型(与 Bookmark.IconType 对齐):lucide / image / emoji */
iconType: string;
icon?: string | null;
iconUrl?: string | null;
/** P37logo 背景色(与 Bookmark.ColorBg 对齐);null = 自适应 */
colorBg?: string | null;
sort: number;
isDefault: boolean;
createdAt: string;
updatedAt: string;
}
export interface SearchEngineUpsert {
id?: number;
name: string;
urlTemplate: string;
iconType: string;
icon?: string | null;
iconUrl?: string | null;
colorBg?: string | null;
sort: number;
isDefault: boolean;
}
export type ThemeMode = 'dark' | 'light' | 'auto';
export type BackgroundType = 'preset' | 'custom' | 'solid';
export interface AppSettings {
themeMode: ThemeMode;
accentColor: string;
backgroundImage?: string | null;
backgroundType: BackgroundType;
openLinksInNewTab: boolean;
/** P46:搜索框行为 */
openSearchInNewTab: boolean;
// ===== P34360 在线壁纸模式 =====
/** 是否启用 360 在线壁纸(按分类随机 + 定时切换) */
wallpaperEnabled: boolean;
/** 360 壁纸分类 ID(空字符串 = 全部/推荐) */
wallpaperCategoryId: string;
/** 自动切换间隔(分钟),0 = 不自动切换 */
wallpaperInterval: number;
updatedAt: string;
}
export interface SettingUpdate {
themeMode?: ThemeMode;
accentColor?: string;
backgroundImage?: string | null;
backgroundType?: BackgroundType;
openLinksInNewTab?: boolean;
/** P46:搜索框行为 */
openSearchInNewTab?: boolean;
// ===== P34360 在线壁纸 =====
wallpaperEnabled?: boolean;
wallpaperCategoryId?: string;
wallpaperInterval?: number;
}
export interface UploadResult {
path: string;
url: string;
fileName: string;
size: number;
}
export interface SyncChange {
entityType: 'category' | 'bookmark' | 'search_engine' | 'setting';
entityId: number;
operation: 'create' | 'update' | 'delete';
timestamp: string;
}
export interface SyncSnapshot {
categories: Category[];
bookmarks: Bookmark[];
searchEngines: SearchEngine[];
settings: AppSettings | null;
}
export interface SyncChangesResponse {
changes: SyncChange[];
snapshot: SyncSnapshot;
serverTime: string;
}
export interface ApiEnvelope<T> {
code: number;
message: string;
data: T;
timestamp: number;
}
+171
View File
@@ -0,0 +1,171 @@
/**
* utils/color.ts
* 根据 URL / 标题生成稳定的彩色背景 + 浅色文字色,用于书签卡片的 logo 色块。
* 同一条链接每次生成的颜色都一致。
*/
/**
* 预设品牌色库:常见网站识别度高的颜色,命中域名关键字时优先使用。
* 这样 JD 是红、Apple 是黑、淘宝是橙,不必每次靠哈希碰运气。
*/
const BRAND_PALETTE: Array<{ key: string; bg: string; fg: string; text: string }> = [
{ key: 'jd.com', bg: '#e1251b', fg: '#ffffff', text: 'JD' },
{ key: 'taobao.com', bg: '#ff5000', fg: '#ffffff', text: '淘' },
{ key: 'tmall.com', bg: '#ff0036', fg: '#ffffff', text: '猫' },
{ key: 'apple.com', bg: '#000000', fg: '#ffffff', text: '' }, // 用 logo
{ key: 'mi.com', bg: '#ff6700', fg: '#ffffff', text: 'mi' },
{ key: 'xiaomi.com', bg: '#ff6700', fg: '#ffffff', text: 'mi' },
{ key: 'suning.com', bg: '#f3a10c', fg: '#ffffff', text: '苏' },
{ key: 'baidu.com', bg: '#2319dc', fg: '#ffffff', text: '百' },
{ key: 'bilibili.com', bg: '#fb7299', fg: '#ffffff', text: 'B' },
{ key: 'weibo.com', bg: '#e6162d', fg: '#ffffff', text: 'W' },
{ key: 'zhihu.com', bg: '#0084ff', fg: '#ffffff', text: '知' },
{ key: 'github.com', bg: '#181717', fg: '#ffffff', text: 'GH' },
{ key: 'gitee.com', bg: '#c71d23', fg: '#ffffff', text: 'G' },
{ key: 'google.com', bg: '#4285f4', fg: '#ffffff', text: 'G' },
{ key: 'youtube.com', bg: '#ff0000', fg: '#ffffff', text: 'YT' },
{ key: 'twitter.com', bg: '#1da1f2', fg: '#ffffff', text: 'X' },
{ key: 'x.com', bg: '#000000', fg: '#ffffff', text: 'X' },
{ key: '163.com', bg: '#c20c0c', fg: '#ffffff', text: '网' },
{ key: 'netease.com', bg: '#c20c0c', fg: '#ffffff', text: '易' },
{ key: 'qq.com', bg: '#1e80ff', fg: '#ffffff', text: 'Q' },
{ key: 'wechat.com', bg: '#07c160', fg: '#ffffff', text: '微' },
{ key: 'douyin.com', bg: '#161823', fg: '#fe2c55', text: '抖' },
{ key: 'tiktok.com', bg: '#161823', fg: '#fe2c55', text: 'TT' },
{ key: 'microsoft.com', bg: '#0078d4', fg: '#ffffff', text: 'MS' },
{ key: 'linux.do', bg: '#1e1e2e', fg: '#cba6f7', text: 'L' },
{ key: 'linuxidc.com', bg: '#1e1e2e', fg: '#f5c2e7', text: 'L' }
];
/** 哈希:根据字符串生成 32-bit 整数 */
function hash(str: string): number {
let h = 0;
for (let i = 0; i < str.length; i++) {
h = (h << 5) - h + str.charCodeAt(i);
h |= 0;
}
return Math.abs(h);
}
/** 从 host 提取主域名(去掉子域和 TLD)用于品牌匹配 */
function mainDomain(host: string): string {
const parts = host.replace(/^www\./, '').split('.');
if (parts.length <= 2) return host;
return parts.slice(-2).join('.');
}
export interface BrandColor {
bg: string;
fg: string;
text: string; // 文字 logo 字符(无 icon 时的备选)
}
/**
* 根据 URL 推断品牌色。命中品牌库返回预设,否则用哈希生成稳定的 HSL。
*/
export function colorFromUrl(url: string): BrandColor {
let host = '';
try { host = new URL(url).hostname; } catch { /* ignore */ }
if (host) {
const main = mainDomain(host);
for (const p of BRAND_PALETTE) {
if (host.includes(p.key) || main.includes(p.key)) return p;
}
}
// 兜底:用哈希生成稳定的 HSL
const h = hash(url || Math.random().toString()) % 360;
return {
bg: `hsl(${h}, 60%, 48%)`,
fg: '#ffffff',
text: ''
};
}
/** 取字符串首字(中文 1 字,英文 2 大写)作为 logo 字符 */
export function firstChar(str: string): string {
const s = (str || '').trim();
if (!s) return '?';
const first = s.charAt(0);
if (/[\u4e00-\u9fa5]/.test(first)) return first;
if (/[a-zA-Z]/.test(first)) return (s.charAt(0) + (s.charAt(1) || '')).toUpperCase();
return first;
}
/**
* P28:可选的预设背景色板(用户选择非自适应时使用)。
* 提供一组辨识度高的中度饱和色 + 中等亮度,文字始终白色可读。
*/
export const PRESET_COLORS: ReadonlyArray<{ label: string; value: string; fg: string }> = [
{ label: '靛蓝', value: '#6c5ce7', fg: '#ffffff' },
{ label: '天蓝', value: '#3498db', fg: '#ffffff' },
{ label: '青绿', value: '#1abc9c', fg: '#ffffff' },
{ label: '翠绿', value: '#27ae60', fg: '#ffffff' },
{ label: '柠檬', value: '#f1c40f', fg: '#222222' },
{ label: '橙色', value: '#ff6700', fg: '#ffffff' },
{ label: '朱红', value: '#e74c3c', fg: '#ffffff' },
{ label: '玫红', value: '#e84393', fg: '#ffffff' },
{ label: '炭黑', value: '#2d3436', fg: '#ffffff' },
{ label: '银灰', value: '#95a5a6', fg: '#222222' }
];
/** 自适应模式:null 表示「前端从 url / 图片主色调推断」 */
export const ADAPTIVE_COLOR: null = null;
/**
* P28:从图片提取主色调(提取算法:缩放到 32×32 → 统计像素 → 过滤近白近黑 → 选最饱和的)。
* 失败时返回 null(调用方 fallback 到品牌色)。
*/
export async function extractDominantColor(imageUrl: string): Promise<string | null> {
if (!imageUrl || typeof document === 'undefined') return null;
try {
const img = new Image();
img.crossOrigin = 'anonymous';
img.referrerPolicy = 'no-referrer';
img.src = imageUrl;
await new Promise<void>((resolve, reject) => {
img.onload = () => resolve();
img.onerror = () => reject(new Error('image load failed'));
// 6s 超时
setTimeout(() => reject(new Error('image load timeout')), 6000);
});
const canvas = document.createElement('canvas');
const W = 32, H = 32;
canvas.width = W; canvas.height = H;
const ctx = canvas.getContext('2d');
if (!ctx) return null;
ctx.drawImage(img, 0, 0, W, H);
const data = ctx.getImageData(0, 0, W, H).data;
// 统计每种颜色的「饱和度 × 出现次数」
const buckets = new Map<string, { r: number; g: number; b: number; count: number; sat: number }>();
for (let i = 0; i < data.length; i += 4) {
const a = data[i + 3];
if (a < 200) continue; // 跳过透明像素
const r = data[i], g = data[i + 1], b = data[i + 2];
// 过滤近白 / 近黑
const max = Math.max(r, g, b), min = Math.min(r, g, b);
if (max > 235 && min > 215) continue;
if (max < 30) continue;
// 按 5×5 桶量化
const key = `${r >> 4}-${g >> 4}-${b >> 4}`;
const sat = max === 0 ? 0 : (max - min) / max;
const cur = buckets.get(key);
if (cur) { cur.count++; cur.sat = Math.max(cur.sat, sat); }
else { buckets.set(key, { r, g, b, count: 1, sat }); }
}
if (buckets.size === 0) return null;
// 选 count × sat 最大的桶
let best: { r: number; g: number; b: number; score: number } | null = null;
for (const v of buckets.values()) {
const score = v.count * (0.4 + v.sat);
if (!best || score > best.score) best = { r: v.r, g: v.g, b: v.b, score };
}
if (!best) return null;
return rgbToHex(best.r, best.g, best.b);
} catch {
return null;
}
}
function rgbToHex(r: number, g: number, b: number): string {
return '#' + [r, g, b].map(n => n.toString(16).padStart(2, '0')).join('');
}
+158
View File
@@ -0,0 +1,158 @@
/**
* 通用 Iconify 风格图标映射(lucide-vue-next)。
* 把设计稿里使用的字符串 key 映射到对应的 Lucide 图标组件。
*
* P23 扩展:
* - 增加系统支持的图标库(覆盖分类、链接、工具、品牌等常用场景,约 130+)
* - 导出 SUPPORTED_ICONS 列表,供 AppIconPicker 网格展示
*/
import {
// ─── 基础(P0-P22 已用)───
Search, Bot, Code2, Wrench, Github, Book, MessageCircle, Settings, Menu, Plus,
X, Edit, Trash2, Sun, Moon, Monitor, Star, Check, ChevronRight, ChevronDown,
ChevronLeft, Upload, Image as ImageIcon, RefreshCw, Home, Layers, Link as LinkIcon,
MoreHorizontal, MoreVertical, Save,
// ─── 文档 / 标签 ───
Tag, Folder, FolderOpen, FolderPlus, File, FileText, FilePlus, Bookmark, Pin, Hash, AtSign,
// ─── 通用形状 ───
Square, Circle, Triangle, Hexagon, Aperture, Crosshair, Maximize, Minimize, Expand, Shrink,
// ─── 网络 / 通讯 ───
Globe, Link2, Mail, MessageSquare, Phone, PhoneCall, PhoneIncoming, PhoneOutgoing,
Rss, Share2, Send, Inbox, Bell, BellOff,
// ─── 媒体 ───
Camera, Music, Video, Film, Play, Pause, SkipForward, SkipBack, Volume2, VolumeX, Mic, MicOff,
Headphones, Radio, Tv,
// ─── 设备 / 硬件 ───
Smartphone, Tablet, Laptop, Monitor as MonitorIcon, Watch, Gamepad2, Mouse, Keyboard, Cpu, Server, HardDrive,
// ─── 数据 / 状态 ───
Database, Cloud, CloudRain, CloudSnow, CloudLightning, CloudOff, Wifi, WifiOff, Bluetooth,
Battery, BatteryLow, Power,
// ─── 时钟 / 时间 ───
Clock, Calendar, CalendarDays, Watch as WatchIcon, Timer, Hourglass, AlarmClock,
// ─── 地图 / 导航 ───
MapPin, Map, Navigation, Compass, Locate, LocateFixed,
// ─── 天气 / 自然 ───
Sun as SunIcon, Moon as MoonIcon, Cloud as CloudIcon, Lightbulb, Flame, Droplet, Snowflake, Wind, Umbrella, Sunrise, Sunset,
// ─── 购物 / 商业 ───
ShoppingCart, ShoppingBag, ShoppingBasket, Gift, Package, PackageOpen, Archive, Briefcase, Wallet, CreditCard, DollarSign, Euro, Percent,
// ─── 人物 / 社交 ───
User, Users, UserPlus, UserMinus, UserCheck, Heart, HeartHandshake, ThumbsUp, ThumbsDown, Smile, Frown, Meh,
// ─── 安全 / 锁 ───
Lock, Unlock, Key, Shield, ShieldCheck, ShieldAlert, Eye, EyeOff, Fingerprint,
// ─── 图表 / 数据可视化 ───
TrendingUp, TrendingDown, Activity, BarChart2, BarChart3, BarChartHorizontal, LineChart, PieChart, Target, Award, Trophy, Medal,
// ─── 箭头 / 操作 ───
ChevronUp, ChevronDown as ChevronDownIcon, ChevronLeft as ChevronLeftIcon, ChevronRight as ChevronRightIcon,
ArrowRight, ArrowLeft, ArrowUp, ArrowDown, ArrowUpRight, ArrowUpLeft, ArrowDownRight, ArrowDownLeft,
ChevronsRight, ChevronsLeft, ChevronsUp, ChevronsDown, Move, RotateCw, RotateCcw,
// ─── 工具 / 编辑 ───
Scissors, Paperclip, Pencil, Pen, PenLine, PenTool, Brush, Eraser, Type, Bold, Italic, Underline,
AlignLeft, AlignCenter, AlignRight, AlignJustify, ListOrdered, List, ListTree, ListChecks,
Table, LayoutGrid, LayoutList, Columns, Rows,
// ─── 通用操作 ───
Copy, Clipboard, ClipboardCheck, Download, Trash, Filter, Sliders, SlidersHorizontal,
Settings2, Cog, ToggleLeft, ToggleRight, Sliders as SlidersIcon,
// ─── Git / 开发 ───
GitBranch, GitCommit, GitMerge, GitPullRequest, GitFork, Terminal, Bug,
Chrome, Twitter, Youtube, Twitch, Slack, Apple, Facebook, Instagram, Linkedin, Github as GithubIcon,
// ─── 系统 / 提示 ───
HelpCircle, Info, AlertCircle, AlertTriangle, CheckCircle, XCircle, Loader, Loader2, RotateCw as RotateCwIcon, Power as PowerIcon, LogIn, LogOut,
Zap, ZapOff, Plug, PlugZap, Magnet, BatteryCharging
} from 'lucide-vue-next';
const map: Record<string, unknown> = {
// ─── 基础 ───
search: Search, bot: Bot, 'code-2': Code2, wrench: Wrench, github: Github, book: Book,
'message-circle': MessageCircle, settings: Settings, menu: Menu, plus: Plus, x: X,
edit: Edit, trash: Trash2, 'trash-2': Trash2, sun: Sun, moon: Moon, monitor: Monitor,
star: Star, check: Check, 'chevron-right': ChevronRight, 'chevron-down': ChevronDown,
'chevron-left': ChevronLeft, 'chevron-up': ChevronUp, upload: Upload, image: ImageIcon,
refresh: RefreshCw, home: Home, layers: Layers, link: LinkIcon, link2: Link2,
'more-horizontal': MoreHorizontal, 'more-vertical': MoreVertical, save: Save,
// ─── 文档 / 标签 ───
tag: Tag, folder: Folder, 'folder-open': FolderOpen, 'folder-plus': FolderPlus,
file: File, 'file-text': FileText, 'file-plus': FilePlus, bookmark: Bookmark,
pin: Pin, hash: Hash, atsign: AtSign,
// ─── 通用形状 ───
square: Square, circle: Circle, triangle: Triangle, hexagon: Hexagon,
aperture: Aperture, crosshair: Crosshair, maximize: Maximize, minimize: Minimize,
expand: Expand, shrink: Shrink,
// ─── 网络 / 通讯 ───
globe: Globe, mail: Mail, 'message-square': MessageSquare, phone: Phone, 'phone-call': PhoneCall,
rss: Rss, 'share-2': Share2, send: Send, inbox: Inbox, bell: Bell, 'bell-off': BellOff,
// ─── 媒体 ───
camera: Camera, music: Music, video: Video, film: Film, play: Play, pause: Pause,
'skip-forward': SkipForward, 'skip-back': SkipBack, 'volume-2': Volume2, 'volume-x': VolumeX,
mic: Mic, 'mic-off': MicOff, headphones: Headphones, radio: Radio, tv: Tv,
// ─── 设备 / 硬件 ───
smartphone: Smartphone, tablet: Tablet, laptop: Laptop, watch: Watch,
'gamepad-2': Gamepad2, mouse: Mouse, keyboard: Keyboard, cpu: Cpu, server: Server,
'hard-drive': HardDrive,
// ─── 数据 / 状态 ───
database: Database, cloud: Cloud, 'cloud-rain': CloudRain, 'cloud-snow': CloudSnow,
'cloud-lightning': CloudLightning, 'cloud-off': CloudOff, wifi: Wifi, 'wifi-off': WifiOff,
bluetooth: Bluetooth, battery: Battery, 'battery-low': BatteryLow, power: Power,
// ─── 时钟 / 时间 ───
clock: Clock, calendar: Calendar, 'calendar-days': CalendarDays, timer: Timer,
hourglass: Hourglass, 'alarm-clock': AlarmClock,
// ─── 地图 / 导航 ───
'map-pin': MapPin, map: Map, navigation: Navigation, compass: Compass,
locate: Locate, 'locate-fixed': LocateFixed,
// ─── 天气 / 自然 ───
lightbulb: Lightbulb, flame: Flame, droplet: Droplet, snowflake: Snowflake,
wind: Wind, umbrella: Umbrella, sunrise: Sunrise, sunset: Sunset,
// ─── 购物 / 商业 ───
'shopping-cart': ShoppingCart, 'shopping-bag': ShoppingBag, 'shopping-basket': ShoppingBasket,
gift: Gift, package: Package, 'package-open': PackageOpen, archive: Archive,
briefcase: Briefcase, wallet: Wallet, 'credit-card': CreditCard,
'dollar-sign': DollarSign, euro: Euro, percent: Percent,
// ─── 人物 / 社交 ───
user: User, users: Users, 'user-plus': UserPlus, 'user-minus': UserMinus, 'user-check': UserCheck,
heart: Heart, 'heart-handshake': HeartHandshake, 'thumbs-up': ThumbsUp, 'thumbs-down': ThumbsDown,
smile: Smile, frown: Frown, meh: Meh,
// ─── 安全 / 锁 ───
lock: Lock, unlock: Unlock, key: Key, shield: Shield, 'shield-check': ShieldCheck,
'shield-alert': ShieldAlert, eye: Eye, 'eye-off': EyeOff, fingerprint: Fingerprint,
// ─── 图表 / 数据可视化 ───
'trending-up': TrendingUp, 'trending-down': TrendingDown, activity: Activity,
'bar-chart-2': BarChart2, 'bar-chart-3': BarChart3, 'bar-chart-horizontal': BarChartHorizontal,
'line-chart': LineChart, 'pie-chart': PieChart, target: Target, award: Award, trophy: Trophy, medal: Medal,
// ─── 箭头 / 操作 ───
'arrow-right': ArrowRight, 'arrow-left': ArrowLeft, 'arrow-up': ArrowUp, 'arrow-down': ArrowDown,
'arrow-up-right': ArrowUpRight, 'arrow-up-left': ArrowUpLeft, 'arrow-down-right': ArrowDownRight, 'arrow-down-left': ArrowDownLeft,
'chevrons-right': ChevronsRight, 'chevrons-left': ChevronsLeft, 'chevrons-up': ChevronsUp, 'chevrons-down': ChevronsDown,
move: Move, 'rotate-cw': RotateCw, 'rotate-ccw': RotateCcw,
// ─── 工具 / 编辑 ───
scissors: Scissors, paperclip: Paperclip, pencil: Pencil, pen: Pen, 'pen-line': PenLine,
'pen-tool': PenTool, brush: Brush, eraser: Eraser, type: Type, bold: Bold, italic: Italic,
underline: Underline, 'align-left': AlignLeft, 'align-center': AlignCenter, 'align-right': AlignRight,
'align-justify': AlignJustify, 'list-ordered': ListOrdered, list: List, 'list-tree': ListTree,
'list-checks': ListChecks, table: Table, 'layout-grid': LayoutGrid, 'layout-list': LayoutList,
columns: Columns, rows: Rows,
// ─── 通用操作 ───
copy: Copy, clipboard: Clipboard, 'clipboard-check': ClipboardCheck, download: Download,
filter: Filter, sliders: Sliders, 'sliders-horizontal': SlidersHorizontal,
'settings-2': Settings2, cog: Cog, 'toggle-left': ToggleLeft, 'toggle-right': ToggleRight,
// ─── Git / 开发 ───
'git-branch': GitBranch, 'git-commit': GitCommit, 'git-merge': GitMerge,
'git-pull-request': GitPullRequest, 'git-fork': GitFork, terminal: Terminal, bug: Bug,
chrome: Chrome, twitter: Twitter, youtube: Youtube, twitch: Twitch,
slack: Slack, apple: Apple, facebook: Facebook, instagram: Instagram, linkedin: Linkedin,
// ─── 系统 / 提示 ───
'help-circle': HelpCircle, info: Info, 'alert-circle': AlertCircle, 'alert-triangle': AlertTriangle,
'check-circle': CheckCircle, 'x-circle': XCircle, loader: Loader, 'loader-2': Loader2,
'log-in': LogIn, 'log-out': LogOut,
// ─── 工具补充 ───
zap: Zap, 'zap-off': ZapOff, plug: Plug, 'plug-zap': PlugZap, magnet: Magnet, 'battery-charging': BatteryCharging
};
/** 系统支持的所有图标 key(按字母排序,供 AppIconPicker 网格展示) */
export const SUPPORTED_ICONS: string[] = Object.keys(map).sort();
/**
* 把图标名解析为 lucide 组件;找不到时返回 null(让 AppIcon 走 fallback
*/
export function resolveIcon(name?: string | null) {
if (!name) return null;
return map[name] ?? null;
}
+39
View File
@@ -0,0 +1,39 @@
/**
* localStorage 持久化工具(包装 + 容错)。
* - 浏览器禁用 storage 或隐私模式下安全降级(吞掉异常)
* - 序列化 / 反序列化集中在 load / save 两个函数,便于后续加版本字段
*/
/** 用户上次浏览的分类 ID(首页恢复用) */
const KEY_SELECTED_CATEGORY = 'myhomepage.selectedCategoryId';
/**
* 保存用户上次浏览的分类 ID。
* @param id 分类 ID;传 null 表示清除(fallback 到全部视图)
*/
export function saveSelectedCategory(id: number | null): void {
try {
if (id === null) {
localStorage.removeItem(KEY_SELECTED_CATEGORY);
} else {
localStorage.setItem(KEY_SELECTED_CATEGORY, String(id));
}
} catch {
// 隐私模式 / 存储满 / SSR 等场景静默失败
}
}
/**
* 读取用户上次浏览的分类 ID。
* @returns number | nulllocalStorage 中无值或解析失败时返回 null
*/
export function loadSelectedCategory(): number | null {
try {
const raw = localStorage.getItem(KEY_SELECTED_CATEGORY);
if (raw === null) return null;
const n = Number(raw);
return Number.isFinite(n) && Number.isInteger(n) && n > 0 ? n : null;
} catch {
return null;
}
}
+30
View File
@@ -0,0 +1,30 @@
import { ref } from 'vue';
export interface Toast {
id: number;
type: 'info' | 'success' | 'warning' | 'error';
message: string;
duration?: number;
}
const list = ref<Toast[]>([]);
let nextId = 1;
export function pushToast(t: Omit<Toast, 'id'>) {
const id = nextId++;
const toast: Toast = { id, duration: 2500, ...t };
list.value.push(toast);
if (toast.duration && toast.duration > 0) {
setTimeout(() => removeToast(id), toast.duration);
}
return id;
}
export function removeToast(id: number) {
const idx = list.value.findIndex(t => t.id === id);
if (idx >= 0) list.value.splice(idx, 1);
}
export function useToasts() {
return { list, pushToast, removeToast };
}
+531
View File
@@ -0,0 +1,531 @@
<script setup lang="ts">
/**
* HomeView:根据屏幕宽度切换桌面 / 移动端两种布局。
*
* P22 链接列表显示逻辑:
* - selectedCategory 含义扩展:null = 全部;顶级 ID = 该 root 下所有链接(直挂 + 所有 children);
* 二级 ID = 该 child 下的链接
* - 首次加载 / localStorage 失效 → 显示全部
* - 每次 selectedCategory 变化 → 持久化到 localStorage
*/
import { computed, ref, onMounted, watch } from 'vue';
import { useRouter } from 'vue-router';
import { useCategoriesStore } from '@/stores/categories';
import { useBookmarksStore } from '@/stores/bookmarks';
import AppSidebar from '@/components/AppSidebar.vue';
import AppSearchBar from '@/components/AppSearchBar.vue';
import AppLinkCard from '@/components/AppLinkCard.vue';
import AppLinkListItem from '@/components/AppLinkListItem.vue';
import AppCategoryTabs from '@/components/AppCategoryTabs.vue';
import AppMobileTopBar from '@/components/AppMobileTopBar.vue';
import AppDrawer from '@/components/AppDrawer.vue';
import AppFab from '@/components/AppFab.vue';
import AppIcon from '@/components/AppIcon.vue';
import AppButton from '@/components/AppButton.vue';
import BookmarkForm from '@/components/BookmarkForm.vue';
import CategoryForm from '@/components/CategoryForm.vue';
import { pushToast } from '@/utils/toast';
import { saveSelectedCategory, loadSelectedCategory } from '@/utils/storage';
import type { Bookmark, Category } from '@/types/api';
import { useSettingsStore } from '@/stores/settings';
interface Props { isMobile: boolean; }
defineProps<Props>();
const router = useRouter();
const categories = useCategoriesStore();
const bookmarks = useBookmarksStore();
const settings = useSettingsStore();
const toast = {
success: (m: string) => pushToast({ type: 'success', message: m }),
error: (m: string) => pushToast({ type: 'error', message: m }),
info: (m: string) => pushToast({ type: 'info', message: m })
};
// 选中的分类(null = 全部;支持任意分类 ID,含顶级和二级)
const selectedCategory = ref<number | null>(null);
// 显示的分类标题
const currentTitle = computed(() => {
if (selectedCategory.value === null) return '全部链接';
const c = categories.byId.get(selectedCategory.value);
return c?.name ?? '未分类';
});
/**
* 链接列表显示逻辑(P22):
* - null → 全部
* - 顶级分类(parentId === 0)→ 聚合该 root 直挂 + 所有 children 下的链接
* - 二级分类 → 单分类下的链接
* - 分类 ID 在后端列表中找不到(被删除 / 失效)→ fallback 到全部
*/
const displayedBookmarks = computed<Bookmark[]>(() => {
const sortFn = (a: Bookmark, b: Bookmark) => a.sort - b.sort || b.id - a.id;
const id = selectedCategory.value;
if (id === null) {
return [...bookmarks.items].sort(sortFn);
}
const cat = categories.byId.get(id);
if (!cat) {
// 分类已被删除:清理状态 + fallback 到全部
return [...bookmarks.items].sort(sortFn);
}
if (cat.parentId === 0) {
// 顶级:聚合该 root + 所有 children 的 ID
const ids = new Set<number>([cat.id, ...cat.children.map(c => c.id)]);
return bookmarks.items.filter(b => ids.has(b.categoryId)).sort(sortFn);
}
// 二级:单分类
return bookmarks.byCategory.get(id) ?? [];
});
// 编辑器状态
const drawerOpen = ref(false);
const editBookmark = ref<Bookmark | null>(null);
const bookmarkFormOpen = ref(false);
const categoryFormOpen = ref(false);
const editCategory = ref<Category | null>(null);
const parentForNewCategory = ref<number>(0);
const expandedRoots = ref<Set<number>>(new Set(categories.tree.map(c => c.id)));
function toggleRoot(id: number) {
if (expandedRoots.value.has(id)) expandedRoots.value.delete(id);
else expandedRoots.value.add(id);
expandedRoots.value = new Set(expandedRoots.value);
}
function openBookmark(b: Bookmark) {
if (!b.url) return;
// 根据设置决定 targetopenLinksInNewTab=true → _blank(新选项卡);false → _self(当前选项卡)
const target = settings.settings.openLinksInNewTab ? '_blank' : '_self';
window.open(b.url, target, 'noopener');
}
async function startCreateBookmark() {
console.log('[startCreateBookmark] invoked', { loaded: categories.loaded, selected: selectedCategory.value, tree: categories.tree.length });
// 兜底:若 store 还没加载完,强制拉一次
if (!categories.loaded) {
try { await categories.load(true); } catch (e) { console.error('[startCreateBookmark] load failed', e); }
}
// 已有选中的二级分类,直接打开
if (selectedCategory.value !== null) {
editBookmark.value = null;
bookmarkFormOpen.value = true;
console.log('[startCreateBookmark] open form for selected=', selectedCategory.value);
return;
}
// "全部" 视图:自动选第一个可用的二级分类,再打开表单
for (const r of categories.tree) {
if (r.children.length > 0) {
selectedCategory.value = r.children[0].id;
editBookmark.value = null;
bookmarkFormOpen.value = true;
toast.info(`已自动选中「${r.children[0].name}`);
console.log('[startCreateBookmark] auto-pick=', r.children[0].id, r.children[0].name);
return;
}
}
// 一个子分类都没有:弹 toast 引导用户先建
toast.error('请先在左侧创建一个分类');
console.warn('[startCreateBookmark] no leaf category available');
}
function startEditBookmark(b: Bookmark) {
editBookmark.value = b;
bookmarkFormOpen.value = true;
}
function startCreateCategory(parentId: number) {
editCategory.value = null;
parentForNewCategory.value = parentId;
categoryFormOpen.value = true;
}
function startEditCategory(c: Category) {
editCategory.value = c;
parentForNewCategory.value = c.parentId;
categoryFormOpen.value = true;
}
async function startDeleteCategory(c: Category) {
const hasChildren = c.children && c.children.length > 0;
const tip = hasChildren
? `分类「${c.name}」下还有 ${c.children.length} 个子分类,删除将一并移除,是否继续?`
: `确定删除分类「${c.name}」?`;
if (!confirm(tip)) return;
try {
await categories.remove(c.id);
if (selectedCategory.value === c.id) selectedCategory.value = null;
toast.success(`已删除「${c.name}`);
} catch (e) {
toast.error(`删除失败:${(e as Error).message}`);
}
}
async function startDeleteBookmark(b: Bookmark) {
if (!confirm(`确定删除链接「${b.title}」?`)) return;
try {
await bookmarks.remove(b.id);
toast.success(`已删除「${b.title}`);
} catch (e) {
toast.error(`删除失败:${(e as Error).message}`);
}
}
function goSettings() { router.push('/settings'); }
// FAB 快捷:未选分类时打开抽屉;已选则新建链接
function onFab() {
if (selectedCategory.value === null) {
drawerOpen.value = true;
} else {
startCreateBookmark();
}
}
// 侧边栏 ⋯ 菜单的「新建链接」:直接用传入的 categoryId 打开表单
function onSidebarCreateBookmark(categoryId: number) {
editBookmark.value = null;
// 把当前选中也同步到该分类,右侧列表能立即看到
selectedCategory.value = categoryId;
bookmarkFormOpen.value = true;
}
// ─── P22localStorage 持久化 ────────────────────────────────────
// 每次 selectedCategory 变化 → 写入 localStorage
watch(selectedCategory, (v) => {
saveSelectedCategory(v);
});
// 页面加载:等 sync 把 categories 拉回来后,从 localStorage 恢复
// - localStorage 无值 / 值为 null → 保持 null(首次打开 → 显示全部)
// - localStorage 有值但后端分类列表中不存在 → fallback 到 null(清理 localStorage
// - localStorage 有值且后端存在 → 恢复
onMounted(async () => {
// 兜底:App.vue 的 sync.sync() 已主动拉过全量,但若 sync 失败或未完成,这里再补一次
if (!categories.loaded) {
try { await categories.load(true); } catch (e) { console.error('[home] categories.load failed', e); }
}
if (!bookmarks.loaded) {
try { await bookmarks.load(true); } catch (e) { console.error('[home] bookmarks.load failed', e); }
}
const stored = loadSelectedCategory();
if (stored === null) {
// 首次打开 / 清过 localStorage → 保持 null(全部)
return;
}
if (categories.byId.has(stored)) {
// 恢复上次浏览的分类
selectedCategory.value = stored;
} else {
// 分类已不存在 → fallback 到全部,并清理失效的 ID
console.warn('[home] stored category id not found, fallback to all', stored);
saveSelectedCategory(null);
}
});
</script>
<template>
<div :class="['home', isMobile ? 'home--mobile' : 'home--desktop']">
<!-- 桌面端左侧栏 -->
<AppSidebar
v-if="!isMobile"
v-model="selectedCategory"
@manage="goSettings"
@create-category="startCreateCategory"
@edit-category="startEditCategory"
@delete-category="startDeleteCategory"
/>
<!-- 桌面端主体 -->
<main v-if="!isMobile" class="desktop-main">
<!-- P29右上角齿轮设置按钮玻璃质感圆形路由到 /settings -->
<button
class="desktop-main__settings"
@click="goSettings"
aria-label="设置"
title="设置"
>
<AppIcon name="settings" :size="18" />
</button>
<header class="desktop-main__top">
<AppSearchBar />
</header>
<div class="desktop-main__scroll">
<div class="desktop-main__head">
<h2 class="desktop-main__title">{{ currentTitle }}</h2>
<div class="desktop-main__actions">
<AppButton
size="sm"
variant="primary"
@click="startCreateBookmark"
title="新建链接"
>
<AppIcon name="plus" :size="14" /> 新建链接
</AppButton>
</div>
</div>
<div v-if="displayedBookmarks.length === 0" class="empty">
<AppIcon name="link" :size="32" />
<p v-if="selectedCategory === null">从左侧选一个分类开始浏览或新建一个分类</p>
<p v-else>这里还没有链接</p>
<div v-if="selectedCategory === null" class="empty__actions">
<AppButton size="sm" variant="primary" @click="startCreateCategory(0)">
<AppIcon name="plus" :size="14" /> 新建分类
</AppButton>
</div>
<div v-else class="empty__actions">
<AppButton size="sm" variant="primary" @click="startCreateBookmark">
<AppIcon name="plus" :size="14" /> 添加一个
</AppButton>
</div>
</div>
<div v-else class="card-grid">
<AppLinkCard
v-for="b in displayedBookmarks"
:key="b.id"
:bookmark="b"
@click="openBookmark"
@edit="startEditBookmark"
@delete="startDeleteBookmark"
/>
</div>
</div>
</main>
<!-- 移动端顶栏 + 标签 + 列表 + FAB -->
<template v-else>
<AppMobileTopBar @menu="drawerOpen = true" @settings="goSettings" />
<div class="mobile-tabs-wrap">
<AppCategoryTabs
:categories="categories.tree"
:selected="selectedCategory"
@select="(id) => (selectedCategory = id)"
/>
</div>
<main class="mobile-main">
<div v-if="displayedBookmarks.length === 0" class="empty">
<AppIcon name="link" :size="32" />
<p v-if="selectedCategory === null">从顶部抽屉选一个分类</p>
<p v-else>这里还没有链接</p>
</div>
<div v-else class="list">
<AppLinkListItem
v-for="b in displayedBookmarks"
:key="b.id"
:bookmark="b"
@click="openBookmark"
@edit="startEditBookmark"
@delete="bookmarks.remove(b.id)"
/>
</div>
</main>
<AppFab @click="onFab" />
<!-- 抽屉分类侧滑 -->
<AppDrawer v-model="drawerOpen" title="分类" :width="300">
<button class="drawer-cat" :class="{ active: selectedCategory === null }" @click="selectedCategory = null; drawerOpen = false">
<AppIcon name="layers" :size="16" />
<span>全部</span>
</button>
<template v-for="root in categories.tree" :key="root.id">
<!-- P24root 整行可点击 = 选中并显示该 root 下所有链接直挂 + children
内部按钮用 .stop 阻止冒泡单独处理各自事件 -->
<div
class="drawer-cat drawer-cat--root"
:class="{ active: selectedCategory === root.id }"
@click="selectedCategory = root.id; drawerOpen = false"
>
<button class="drawer-cat__expand" @click.stop="toggleRoot(root.id)" :aria-label="expandedRoots.has(root.id) ? '折叠' : '展开'">
<AppIcon :name="expandedRoots.has(root.id) ? 'chevron-down' : 'chevron-right'" :size="14" />
</button>
<AppIcon :name="root.icon || 'layers'" :size="16" />
<span class="drawer-cat__name">{{ root.name }}</span>
<button class="drawer-cat__add" @click.stop="startCreateCategory(root.id)" :aria-label="`新建 ${root.name} 子分类`" title="新建子分类">
<AppIcon name="plus" :size="14" />
</button>
<button class="drawer-cat__edit" @click.stop="startEditCategory(root)" :aria-label="`编辑 ${root.name}`" title="编辑">
<AppIcon name="edit" :size="12" />
</button>
<button class="drawer-cat__del" @click.stop="startDeleteCategory(root)" :aria-label="`删除 ${root.name}`" title="删除">
<AppIcon name="trash" :size="12" />
</button>
</div>
<template v-if="expandedRoots.has(root.id)">
<div
v-for="child in root.children"
:key="child.id"
class="drawer-cat drawer-cat--leaf-wrap"
>
<button
class="drawer-cat drawer-cat--leaf"
:class="{ active: selectedCategory === child.id }"
@click="selectedCategory = child.id; drawerOpen = false"
>
<AppIcon :name="child.icon || 'link'" :size="14" />
<span class="drawer-cat__name">{{ child.name }}</span>
</button>
<button class="drawer-cat__edit" @click.stop="startEditCategory(child)" :aria-label="`编辑 ${child.name}`" title="编辑">
<AppIcon name="edit" :size="12" />
</button>
<button class="drawer-cat__del" @click.stop="startDeleteCategory(child)" :aria-label="`删除 ${child.name}`" title="删除">
<AppIcon name="trash" :size="12" />
</button>
</div>
</template>
</template>
<button class="drawer-add-root" @click="startCreateCategory(0); drawerOpen = false">
<AppIcon name="plus" :size="14" /> 新建顶级分类
</button>
</AppDrawer>
</template>
<!-- 编辑器 -->
<BookmarkForm
v-if="bookmarkFormOpen"
:bookmark="editBookmark"
:default-category-id="selectedCategory ?? undefined"
@close="bookmarkFormOpen = false"
/>
<CategoryForm
v-if="categoryFormOpen"
:category="editCategory"
:default-parent-id="parentForNewCategory"
@close="categoryFormOpen = false"
/>
</div>
</template>
<style scoped>
.home { width: 100%; height: 100%; display: flex; }
.home--mobile { flex-direction: column; }
.desktop-main {
position: relative; /* P29:让右上角齿轮按钮的 absolute 定位有参照 */
flex: 1;
display: flex; flex-direction: column;
min-width: 0; min-height: 0;
}
/* P29:右上角齿轮设置按钮(玻璃质感圆形) */
.desktop-main__settings {
position: absolute;
top: 20px; right: 24px;
z-index: var(--z-fab);
width: 40px; height: 40px;
display: flex; align-items: center; justify-content: center;
background: var(--glass-bg);
border: 1px solid var(--glass-border);
border-radius: var(--radius-pill);
color: var(--color-text-muted);
backdrop-filter: blur(var(--glass-blur-sm)) saturate(180%);
-webkit-backdrop-filter: blur(var(--glass-blur-sm)) saturate(180%);
box-shadow: var(--shadow-sm);
transition: color var(--duration-fast) var(--ease),
background var(--duration-fast) var(--ease),
transform var(--duration-fast) var(--ease),
box-shadow var(--duration-fast) var(--ease);
}
.desktop-main__settings:hover {
color: var(--color-text);
background: var(--glass-bg-strong);
transform: translateY(-1px);
box-shadow: var(--shadow-md);
}
.desktop-main__settings:active { transform: translateY(0); }
.desktop-main__top {
padding: 20px 28px 12px;
display: flex; align-items: center; justify-content: center;
}
.desktop-main__scroll {
flex: 1; overflow-y: auto;
padding: 0 28px 28px;
}
.desktop-main__head {
display: flex; align-items: center; justify-content: space-between;
margin-bottom: 16px;
}
.desktop-main__title {
font-size: var(--font-2xl);
font-weight: var(--weight-semibold);
}
.desktop-main__actions { display: flex; gap: 8px; }
.card-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 12px;
}
.empty {
display: flex; flex-direction: column; align-items: center; gap: 12px;
padding: 80px 0;
color: var(--color-text-muted);
text-align: center;
}
.empty p { font-size: var(--font-sm); }
.empty__actions { display: flex; gap: 8px; }
/* 移动端 */
.mobile-tabs-wrap {
padding: 8px 12px;
background: var(--glass-bg);
backdrop-filter: blur(20px);
border-bottom: 1px solid var(--color-border);
}
.mobile-main {
flex: 1; overflow-y: auto;
padding: 12px;
}
.list { display: flex; flex-direction: column; gap: 8px; }
/* 抽屉分类 */
.drawer-cat {
width: 100%;
display: flex; align-items: center; gap: 8px;
padding: 8px 10px;
border-radius: var(--radius-sm);
font-size: var(--font-sm);
color: var(--color-text-muted);
text-align: left;
}
.drawer-cat:hover { background: var(--color-surface); color: var(--color-text); }
.drawer-cat.active { background: var(--color-accent-soft); color: var(--color-accent); }
.drawer-cat--root { font-weight: var(--weight-medium); }
.drawer-cat--leaf-wrap {
display: flex; align-items: center; gap: 2px;
padding: 0 0 0 24px;
}
.drawer-cat--leaf {
flex: 1;
padding-left: 0;
}
.drawer-cat__expand {
width: 20px; height: 20px;
display: flex; align-items: center; justify-content: center;
color: var(--color-text-subtle);
flex-shrink: 0;
}
.drawer-cat__name { flex: 1; }
.drawer-cat__add,
.drawer-cat__edit,
.drawer-cat__del {
width: 22px; height: 22px;
display: flex; align-items: center; justify-content: center;
color: var(--color-text-muted);
border-radius: var(--radius-sm);
flex-shrink: 0;
}
.drawer-cat__add:hover { background: var(--color-accent-soft); color: var(--color-accent); }
.drawer-cat__edit:hover { background: var(--color-accent-soft); color: var(--color-accent); }
.drawer-cat__del:hover { background: rgba(255,118,117,0.18); color: var(--color-danger); }
.drawer-add-root {
width: 100%;
margin-top: 8px;
display: flex; align-items: center; justify-content: center; gap: 6px;
padding: 8px 12px;
font-size: var(--font-sm);
color: var(--color-text-muted);
background: var(--color-surface);
border: 1px dashed var(--color-border-strong);
border-radius: var(--radius-md);
}
.drawer-add-root:hover { color: var(--color-accent); border-color: var(--color-accent); background: var(--color-accent-soft); }
</style>
File diff suppressed because it is too large Load Diff
+28
View File
@@ -0,0 +1,28 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"jsx": "preserve",
"esModuleInterop": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"allowSyntheticDefaultImports": true,
"useDefineForClassFields": true,
"noImplicitAny": true,
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
},
"types": ["node"]
},
"include": [
"src/**/*.ts",
"src/**/*.d.ts",
"src/**/*.tsx",
"src/**/*.vue"
],
"references": [{ "path": "./tsconfig.node.json" }]
}
+12
View File
@@ -0,0 +1,12 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"allowSyntheticDefaultImports": true,
"strict": true,
"types": ["node"]
},
"include": ["vite.config.ts"]
}
+45
View File
@@ -0,0 +1,45 @@
import { defineConfig, loadEnv } from 'vite';
import vue from '@vitejs/plugin-vue';
import path from 'node:path';
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), '');
// P34.2 修复:默认端口必须与后端 Program.cs 里的 urls 保持一致(当前为 5141)。
// 之前默认 5080 与后端真实端口不一致 → Vite proxy 转发到 5080 没人监听 → 前端全 500。
// 主人 dev 时如要切到其他端口,在 frontend/.env 里设 VITE_API_BASE=http://localhost:新端口 即可覆盖。
const apiTarget = env.VITE_API_BASE || 'http://localhost:5141';
return {
plugins: [vue()],
resolve: {
alias: {
'@': path.resolve(__dirname, 'src')
}
},
server: {
port: 5173,
host: '0.0.0.0',
proxy: {
'/api': {
target: apiTarget,
changeOrigin: true
},
'/uploads': {
target: apiTarget,
changeOrigin: true
}
}
},
build: {
outDir: 'dist',
sourcemap: false,
rollupOptions: {
output: {
manualChunks: {
vue: ['vue', 'vue-router', 'pinia'],
icons: ['lucide-vue-next']
}
}
}
}
};
});