初始提交:浏览器首页 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:
@@ -0,0 +1,3 @@
|
||||
# 开发环境:使用 vite proxy(/api -> http://localhost:5080)
|
||||
# 如果需要直连后端,把空值改为 http://localhost:5080
|
||||
VITE_API_BASE=
|
||||
@@ -0,0 +1,3 @@
|
||||
# Capacitor Android APP 生产环境后端地址
|
||||
# APP 内不能使用 vite proxy,必须填真实后端 URL(带 https:// 或 http://)
|
||||
VITE_API_BASE=http://10.0.2.2:5080
|
||||
@@ -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
|
||||
@@ -0,0 +1,69 @@
|
||||
# Capacitor Android 打包
|
||||
|
||||
## 前置环境
|
||||
|
||||
1. **Node.js** ≥ 18(项目用 22)
|
||||
2. **JDK 17**(`java -version` 可验证)
|
||||
3. **Android Studio** + Android SDK(API 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` 保持一致,建议反代
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
Generated
+3007
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"apiBase": ""
|
||||
}
|
||||
@@ -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 |
@@ -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>
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 }>();
|
||||
|
||||
// P31:iconType='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>
|
||||
@@ -0,0 +1,159 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* AppLinkListItem:移动端 / 窄屏的链接列表项。
|
||||
* P28 升级:
|
||||
* - logo 块变**正方形**(边长 = 高度),与 AppLinkCard 视觉对齐
|
||||
* - 显示 AppIcon(lucide / emoji / image / 名称前 2 字符兜底)
|
||||
* - 背景色三级 fallback:user.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 }>();
|
||||
|
||||
// P31:iconType='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 视觉一致,体积更紧凑) */
|
||||
/* P36:align-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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,261 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* AppSearchBar:顶部搜索栏(搜索引擎切换 + 输入 + 提交)。
|
||||
* - outside-click 关闭引擎菜单
|
||||
* - P27:引擎区加品牌色 logo 色块
|
||||
* - P37:logo 改用引擎 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 时 load;P37 加新字段后,
|
||||
// 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>,绕开 AppIcon(Vue 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>
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
// ─── ⋯ 菜单状态(P21:Teleport + 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.2:outside-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>
|
||||
<!-- 二级分类 ⋯ 菜单(P21:Teleport + 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>
|
||||
|
||||
<!-- 一级分类 ⋯ 菜单(P21:Teleport + 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 {
|
||||
/* P45:active 背景从 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);
|
||||
}
|
||||
/* P45:active 文字从 accent(深紫)改 text(白色)+ 加深 shadow —— 紫底白字对比度 ~8:1,远高于 WCAG AAA 7:1 */
|
||||
/* P44:hover 态用 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); }
|
||||
/* P45:active 状态下 caret / 分类图标也要变白,与文字保持一致视觉层级 */
|
||||
.sidebar__row--root.active .sidebar__caret,
|
||||
.sidebar__row--leaf.active .sidebar__caret { color: var(--color-text); }
|
||||
/* P45:active 状态下 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 {
|
||||
/* P21:Teleport 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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, // P28:null = 自适应
|
||||
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 切回 lucide,iconUrl 保留(作 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; }
|
||||
/* P31:favicon 提示用蓝色调 */
|
||||
.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>
|
||||
@@ -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>
|
||||
@@ -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}`;
|
||||
}
|
||||
Vendored
+15
@@ -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;
|
||||
}
|
||||
@@ -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 baseURL(P49 运行时配置)
|
||||
* 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);
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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 };
|
||||
});
|
||||
@@ -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 };
|
||||
});
|
||||
@@ -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 };
|
||||
});
|
||||
@@ -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');
|
||||
// P34:360 模式开启时,背景由 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);
|
||||
}
|
||||
@@ -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 };
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
/* ==========================================================================
|
||||
Design Tokens
|
||||
从 browser-homepage/colors_and_type.css 抽取并扩展
|
||||
暗 / 亮主题通过覆盖 [data-theme] 下的同名变量实现
|
||||
========================================================================== */
|
||||
|
||||
:root {
|
||||
/* 主题模式:dark | light | auto(auto 跟随系统,由 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);
|
||||
}
|
||||
@@ -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:后端自动抓取的网站 favicon(iconUrl 指向 /uploads/.../favicons/...)
|
||||
*/
|
||||
iconType?: 'lucide' | 'emoji' | 'image' | 'favicon';
|
||||
iconUrl?: string | null;
|
||||
/** P28:logo 背景色;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;
|
||||
/** P28:logo 背景色;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;
|
||||
/** P37:logo 背景色(与 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;
|
||||
|
||||
// ===== P34:360 在线壁纸模式 =====
|
||||
/** 是否启用 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;
|
||||
// ===== P34:360 在线壁纸 =====
|
||||
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;
|
||||
}
|
||||
@@ -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('');
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 | null;localStorage 中无值或解析失败时返回 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;
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
// 根据设置决定 target:openLinksInNewTab=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;
|
||||
}
|
||||
|
||||
// ─── P22:localStorage 持久化 ────────────────────────────────────
|
||||
// 每次 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">
|
||||
<!-- P24:root 整行可点击 = 选中并显示该 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
@@ -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" }]
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -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']
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
});
|
||||
Reference in New Issue
Block a user