-
- DR
-
-
-
- WEB EDITION
-
-
- Daily Recommender
-
+
+ iD
+
+ iDeer
+
From 2677fbff94e4a83e081dc6358a50324eb4e8af7c Mon Sep 17 00:00:00 2001
From: AprShine <1844677202@qq.com>
Date: Tue, 14 Apr 2026 01:06:20 +0800
Subject: [PATCH 05/11] fix(webui): remove desktop route from App
---
webui/src/App.tsx | 3 ---
1 file changed, 3 deletions(-)
diff --git a/webui/src/App.tsx b/webui/src/App.tsx
index ee8a5a2..21e880a 100644
--- a/webui/src/App.tsx
+++ b/webui/src/App.tsx
@@ -2,7 +2,6 @@ import { BrowserRouter, Routes, Route, useLocation } from "react-router-dom";
import { ToastProvider } from "./lib/hooks/useToast";
import { PublicPage } from "./pages/public/PublicPage";
import { AdminPage } from "./pages/admin/AdminPage";
-import { DesktopPage } from "./pages/desktop/DesktopPage";
function AppRoutes() {
const { pathname } = useLocation();
@@ -12,8 +11,6 @@ function AppRoutes() {
} />
} />
- } />
- } />
);
From d3142b8adaa5fecc94acabe74cd288d231a30ca0 Mon Sep 17 00:00:00 2001
From: AprShine <1844677202@qq.com>
Date: Tue, 14 Apr 2026 14:50:28 +0800
Subject: [PATCH 06/11] refactor(webui): remove desktop embed feature and
reorganize hooks
---
webui/README.md | 17 ++++++++-------
webui/src/App.tsx | 2 +-
webui/src/components/Toast.tsx | 2 +-
webui/src/{lib => }/hooks/useToast.tsx | 0
webui/src/{lib => }/hooks/useWebSocket.ts | 2 +-
webui/src/index.css | 15 -------------
webui/src/lib/hooks/useDesktopEmbed.ts | 9 --------
webui/src/pages/admin/AdminPage.tsx | 21 ++++++-------------
webui/src/pages/admin/components/Header.tsx | 5 +----
.../{lib => pages/admin}/hooks/useConfig.ts | 4 ++--
.../{lib => pages/admin}/hooks/useHistory.ts | 4 ++--
.../{lib => pages/admin}/hooks/useRunState.ts | 6 +++---
webui/src/pages/public/PublicPage.tsx | 18 ++++------------
webui/src/pages/public/components/Header.tsx | 17 +++++++--------
.../{lib => pages/public}/hooks/useMeta.ts | 4 ++--
15 files changed, 39 insertions(+), 87 deletions(-)
rename webui/src/{lib => }/hooks/useToast.tsx (100%)
rename webui/src/{lib => }/hooks/useWebSocket.ts (96%)
delete mode 100644 webui/src/lib/hooks/useDesktopEmbed.ts
rename webui/src/{lib => pages/admin}/hooks/useConfig.ts (94%)
rename webui/src/{lib => pages/admin}/hooks/useHistory.ts (81%)
rename webui/src/{lib => pages/admin}/hooks/useRunState.ts (92%)
rename webui/src/{lib => pages/public}/hooks/useMeta.ts (86%)
diff --git a/webui/README.md b/webui/README.md
index a4f2047..0d8740b 100644
--- a/webui/README.md
+++ b/webui/README.md
@@ -41,6 +41,9 @@ src/
App.tsx # 路由入口
main.tsx # 应用入口
index.css # Tailwind 基础样式 + 自定义 CSS
+ hooks/ # 跨页面共享 Hook
+ useToast.tsx # 通知状态(Context)
+ useWebSocket.ts # WebSocket 连接管理
components/ # 跨页面共享组件
MarkdownRender.tsx # Markdown 渲染
Toast.tsx # 通知组件
@@ -49,17 +52,11 @@ src/
types.ts # 类型定义(含 AdminConfig / HistoryEntry 等)
constants.ts # 常量(含 TYPE_COLORS / TYPE_LABELS 等)
utils.ts # 工具函数
- hooks/
- useMeta.ts # 公开页元信息获取
- useWebSocket.ts # WebSocket 管理
- useToast.tsx # 通知状态
- useDesktopEmbed.ts # 桌面嵌入检测
- useConfig.ts # 配置加载/编辑/保存
- useHistory.ts # 历史记录加载
- useRunState.ts # WebSocket 运行生命周期
pages/
public/
PublicPage.tsx # 公开页主组件
+ hooks/
+ useMeta.ts # 公开页元信息获取
components/
Header.tsx # 页头 + 模式切换
HeroSection.tsx # 标题区域
@@ -73,6 +70,10 @@ src/
FileCard.tsx # 文件卡片
admin/ # 管理后台
AdminPage.tsx # 顶层编排(Tab 切换 + Hook 调用)
+ hooks/
+ useConfig.ts # 配置加载/编辑/保存
+ useHistory.ts # 历史记录加载
+ useRunState.ts # WebSocket 运行生命周期
components/
Header.tsx # 导航栏 + Tab 按钮
config/ # 配置 Tab
diff --git a/webui/src/App.tsx b/webui/src/App.tsx
index 21e880a..bb9c414 100644
--- a/webui/src/App.tsx
+++ b/webui/src/App.tsx
@@ -1,5 +1,5 @@
import { BrowserRouter, Routes, Route, useLocation } from "react-router-dom";
-import { ToastProvider } from "./lib/hooks/useToast";
+import { ToastProvider } from "./hooks/useToast";
import { PublicPage } from "./pages/public/PublicPage";
import { AdminPage } from "./pages/admin/AdminPage";
diff --git a/webui/src/components/Toast.tsx b/webui/src/components/Toast.tsx
index b6e92ce..8caf76c 100644
--- a/webui/src/components/Toast.tsx
+++ b/webui/src/components/Toast.tsx
@@ -1,4 +1,4 @@
-import { useToast } from '../lib/hooks/useToast';
+import { useToast } from '../hooks/useToast';
export function Toast() {
const { toasts, removeToast } = useToast();
diff --git a/webui/src/lib/hooks/useToast.tsx b/webui/src/hooks/useToast.tsx
similarity index 100%
rename from webui/src/lib/hooks/useToast.tsx
rename to webui/src/hooks/useToast.tsx
diff --git a/webui/src/lib/hooks/useWebSocket.ts b/webui/src/hooks/useWebSocket.ts
similarity index 96%
rename from webui/src/lib/hooks/useWebSocket.ts
rename to webui/src/hooks/useWebSocket.ts
index aa8f62f..df9a32d 100644
--- a/webui/src/lib/hooks/useWebSocket.ts
+++ b/webui/src/hooks/useWebSocket.ts
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useRef, useState } from 'react';
-import { createRunWebSocket } from '../api';
+import { createRunWebSocket } from '../lib/api';
export function useWebSocket(path: string | null) {
const wsRef = useRef
(null);
diff --git a/webui/src/index.css b/webui/src/index.css
index d85f499..296adac 100644
--- a/webui/src/index.css
+++ b/webui/src/index.css
@@ -14,21 +14,6 @@ body {
linear-gradient(180deg, #f7fbff 0%, #f6f7fb 42%, #ffffff 100%);
}
-body.desktop-embed {
- background:
- radial-gradient(circle at 20% 10%, rgba(15, 118, 110, 0.08), transparent 24%),
- linear-gradient(180deg, #f8fbff 0%, #f4f7fb 100%);
-}
-
-body.desktop-embed header {
- display: none;
-}
-
-body.desktop-embed main {
- max-width: none;
- padding: 28px 24px 36px;
-}
-
.glass-panel {
background: rgba(255, 255, 255, 0.82);
backdrop-filter: blur(20px);
diff --git a/webui/src/lib/hooks/useDesktopEmbed.ts b/webui/src/lib/hooks/useDesktopEmbed.ts
deleted file mode 100644
index f9af5b8..0000000
--- a/webui/src/lib/hooks/useDesktopEmbed.ts
+++ /dev/null
@@ -1,9 +0,0 @@
-import { useMemo } from 'react';
-
-export function useDesktopEmbed() {
- return useMemo(
- () =>
- new URLSearchParams(window.location.search).get('desktop_embed') === '1',
- [],
- );
-}
diff --git a/webui/src/pages/admin/AdminPage.tsx b/webui/src/pages/admin/AdminPage.tsx
index 5a5d875..189856b 100644
--- a/webui/src/pages/admin/AdminPage.tsx
+++ b/webui/src/pages/admin/AdminPage.tsx
@@ -1,10 +1,9 @@
import { useCallback, useEffect, useState } from 'react';
import type { AdminTab } from '../../lib/types';
-import { useToast } from '../../lib/hooks/useToast';
-import { useConfig } from '../../lib/hooks/useConfig';
-import { useRunState } from '../../lib/hooks/useRunState';
-import { useHistory } from '../../lib/hooks/useHistory';
-import { useDesktopEmbed } from '../../lib/hooks/useDesktopEmbed';
+import { useToast } from '../../hooks/useToast';
+import { useConfig } from './hooks/useConfig';
+import { useRunState } from './hooks/useRunState';
+import { useHistory } from './hooks/useHistory';
import { Toast } from '../../components/Toast';
import { Header } from './components/Header';
import { DashboardView } from './components/dashboard/DashboardView';
@@ -12,7 +11,6 @@ import { ConfigView } from './components/config/ConfigView';
import { HistoryView } from './components/records/HistoryView';
export function AdminPage() {
- const isDesktopEmbed = useDesktopEmbed();
const { showToast } = useToast();
const config = useConfig();
const runState = useRunState();
@@ -33,13 +31,6 @@ export function AdminPage() {
history.reload();
}, []);
- // Apply desktop embed body class
- useEffect(() => {
- if (isDesktopEmbed) {
- document.body.classList.add('desktop-embed');
- }
- }, [isDesktopEmbed]);
-
// Refresh history after a successful run
useEffect(() => {
if (runState.visible && runState.files.length > 0 && !runState.isRunning) {
@@ -49,11 +40,11 @@ export function AdminPage() {
return (
<>
-
+
{activeTab === 'dashboard' && (
void;
- hidden?: boolean;
}
-export function Header({ activeTab, onTabChange, hidden }: HeaderProps) {
+export function Header({ activeTab, onTabChange }: HeaderProps) {
const navigate = useNavigate();
- if (hidden) return null;
-
return (
diff --git a/webui/src/lib/hooks/useConfig.ts b/webui/src/pages/admin/hooks/useConfig.ts
similarity index 94%
rename from webui/src/lib/hooks/useConfig.ts
rename to webui/src/pages/admin/hooks/useConfig.ts
index 18b8d1b..194fca4 100644
--- a/webui/src/lib/hooks/useConfig.ts
+++ b/webui/src/pages/admin/hooks/useConfig.ts
@@ -1,6 +1,6 @@
import { useCallback, useRef, useState } from 'react';
-import type { AdminConfig } from '../types';
-import { getConfig, saveConfig } from '../api';
+import type { AdminConfig } from '../../../lib/types';
+import { getConfig, saveConfig } from '../../../lib/api';
const DEFAULT_CONFIG: AdminConfig = {
desktop_python_path: '',
diff --git a/webui/src/lib/hooks/useHistory.ts b/webui/src/pages/admin/hooks/useHistory.ts
similarity index 81%
rename from webui/src/lib/hooks/useHistory.ts
rename to webui/src/pages/admin/hooks/useHistory.ts
index 11f330c..563061f 100644
--- a/webui/src/lib/hooks/useHistory.ts
+++ b/webui/src/pages/admin/hooks/useHistory.ts
@@ -1,6 +1,6 @@
import { useCallback, useState } from 'react';
-import type { HistoryEntry } from '../types';
-import { getHistory } from '../api';
+import type { HistoryEntry } from '../../../lib/types';
+import { getHistory } from '../../../lib/api';
export function useHistory() {
const [entries, setEntries] = useState
([]);
diff --git a/webui/src/lib/hooks/useRunState.ts b/webui/src/pages/admin/hooks/useRunState.ts
similarity index 92%
rename from webui/src/lib/hooks/useRunState.ts
rename to webui/src/pages/admin/hooks/useRunState.ts
index 5c95afe..dc3d36e 100644
--- a/webui/src/lib/hooks/useRunState.ts
+++ b/webui/src/pages/admin/hooks/useRunState.ts
@@ -1,7 +1,7 @@
import { useCallback, useRef, useState } from 'react';
-import type { AdminRunRequest, RunFile, WsMessage } from '../types';
-import { updateProgress } from '../utils';
-import { useWebSocket } from './useWebSocket';
+import type { AdminRunRequest, RunFile, WsMessage } from '../../../lib/types';
+import { updateProgress } from '../../../lib/utils';
+import { useWebSocket } from '../../../hooks/useWebSocket';
export function useRunState() {
const ws = useWebSocket('/ws/run');
diff --git a/webui/src/pages/public/PublicPage.tsx b/webui/src/pages/public/PublicPage.tsx
index 6b59ccd..ecf4a7e 100644
--- a/webui/src/pages/public/PublicPage.tsx
+++ b/webui/src/pages/public/PublicPage.tsx
@@ -6,10 +6,9 @@ import type {
WsMessage,
} from '../../lib/types';
import { updateProgress } from '../../lib/utils';
-import { useToast } from '../../lib/hooks/useToast';
-import { useMeta } from '../../lib/hooks/useMeta';
-import { useDesktopEmbed } from '../../lib/hooks/useDesktopEmbed';
-import { useWebSocket } from '../../lib/hooks/useWebSocket';
+import { useToast } from '../../hooks/useToast';
+import { useMeta } from './hooks/useMeta';
+import { useWebSocket } from '../../hooks/useWebSocket';
import { Toast } from '../../components/Toast';
import { Header } from './components/Header';
import { HeroSection } from './components/HeroSection';
@@ -21,7 +20,6 @@ import { ResultPanel } from './components/ResultPanel';
export function PublicPage() {
const { meta } = useMeta();
- const isDesktopEmbed = useDesktopEmbed();
const { showToast } = useToast();
const ws = useWebSocket('/ws/run');
@@ -51,13 +49,6 @@ export function PublicPage() {
}
}, [meta.twitter_enabled]);
- // Apply desktop embed class
- useEffect(() => {
- if (isDesktopEmbed) {
- document.body.classList.add('desktop-embed');
- }
- }, [isDesktopEmbed]);
-
const toggleSource = useCallback(
(source: string) => {
if (source === 'twitter' && !meta.twitter_enabled) {
@@ -214,11 +205,10 @@ export function PublicPage() {
mode={mode}
onModeChange={(m) => { setMode(m); setModeKey((k) => k + 1); }}
meta={meta}
- isDesktopEmbed={isDesktopEmbed}
/>
diff --git a/webui/src/pages/public/components/Header.tsx b/webui/src/pages/public/components/Header.tsx
index f1569af..ee20148 100644
--- a/webui/src/pages/public/components/Header.tsx
+++ b/webui/src/pages/public/components/Header.tsx
@@ -5,10 +5,9 @@ interface HeaderProps {
mode: 'quick' | 'custom';
onModeChange: (mode: 'quick' | 'custom') => void;
meta: PublicMeta;
- isDesktopEmbed: boolean;
}
-export function Header({ mode, onModeChange, meta, isDesktopEmbed }: HeaderProps) {
+export function Header({ mode, onModeChange, meta }: HeaderProps) {
const refs = useRef>({});
const [pos, setPos] = useState({ left: 0, top: 0, width: 0, height: 0, ready: false });
@@ -88,14 +87,12 @@ export function Header({ mode, onModeChange, meta, isDesktopEmbed }: HeaderProps
GitHub 仓库
- {!isDesktopEmbed && (
-
- 后台
-
- )}
+
+ 后台
+
diff --git a/webui/src/lib/hooks/useMeta.ts b/webui/src/pages/public/hooks/useMeta.ts
similarity index 86%
rename from webui/src/lib/hooks/useMeta.ts
rename to webui/src/pages/public/hooks/useMeta.ts
index 0b1eba5..7d846b2 100644
--- a/webui/src/lib/hooks/useMeta.ts
+++ b/webui/src/pages/public/hooks/useMeta.ts
@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react';
-import type { PublicMeta } from '../types';
-import { getMeta } from '../api';
+import type { PublicMeta } from '../../../lib/types';
+import { getMeta } from '../../../lib/api';
const DEFAULT_META: PublicMeta = {
github_url: 'https://github.com/LiYu0524/daily-recommender',
From f6dd0db10b4efca37bffbbbb8b4b41fbf13219e1 Mon Sep 17 00:00:00 2001
From: AprShine <1844677202@qq.com>
Date: Thu, 16 Apr 2026 00:04:13 +0800
Subject: [PATCH 07/11] feat: Add favicon, SVG logo and backend connectivity
banner
---
webui/assets/icon_ideer.svg | 1 +
webui/index.html | 1 +
webui/src/App.tsx | 2 ++
webui/src/components/BackendStatus.tsx | 18 ++++++++++++
webui/src/pages/admin/components/Header.tsx | 4 +--
webui/src/pages/public/components/Header.tsx | 4 +--
webui/src/pages/public/hooks/useMeta.ts | 30 ++++++++++++++++++--
7 files changed, 52 insertions(+), 8 deletions(-)
create mode 100644 webui/assets/icon_ideer.svg
create mode 100644 webui/src/components/BackendStatus.tsx
diff --git a/webui/assets/icon_ideer.svg b/webui/assets/icon_ideer.svg
new file mode 100644
index 0000000..993271e
--- /dev/null
+++ b/webui/assets/icon_ideer.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/webui/index.html b/webui/index.html
index d9e7f62..c2cbb5e 100644
--- a/webui/index.html
+++ b/webui/index.html
@@ -4,6 +4,7 @@
iDeer
+
diff --git a/webui/src/App.tsx b/webui/src/App.tsx
index bb9c414..bde7627 100644
--- a/webui/src/App.tsx
+++ b/webui/src/App.tsx
@@ -1,5 +1,6 @@
import { BrowserRouter, Routes, Route, useLocation } from "react-router-dom";
import { ToastProvider } from "./hooks/useToast";
+import { BackendStatus } from "./components/BackendStatus";
import { PublicPage } from "./pages/public/PublicPage";
import { AdminPage } from "./pages/admin/AdminPage";
@@ -20,6 +21,7 @@ export default function App() {
return (
+
diff --git a/webui/src/components/BackendStatus.tsx b/webui/src/components/BackendStatus.tsx
new file mode 100644
index 0000000..62f1fd4
--- /dev/null
+++ b/webui/src/components/BackendStatus.tsx
@@ -0,0 +1,18 @@
+import { useBackendHealth } from '../pages/public/hooks/useMeta';
+
+export function BackendStatus() {
+ const { connected } = useBackendHealth();
+
+ if (connected !== false) return null;
+
+ return (
+
+
+
+ 无法连接到后端服务,请检查服务器是否已启动
+
+
+ );
+}
diff --git a/webui/src/pages/admin/components/Header.tsx b/webui/src/pages/admin/components/Header.tsx
index d708770..e148076 100644
--- a/webui/src/pages/admin/components/Header.tsx
+++ b/webui/src/pages/admin/components/Header.tsx
@@ -30,9 +30,7 @@ export function Header({ activeTab, onTabChange }: HeaderProps) {
返回
-
- iD
-
+
iDeer
Admin
diff --git a/webui/src/pages/public/components/Header.tsx b/webui/src/pages/public/components/Header.tsx
index ee20148..05dfb24 100644
--- a/webui/src/pages/public/components/Header.tsx
+++ b/webui/src/pages/public/components/Header.tsx
@@ -28,9 +28,7 @@ export function Header({ mode, onModeChange, meta }: HeaderProps) {
-
- iD
-
+
iDeer
diff --git a/webui/src/pages/public/hooks/useMeta.ts b/webui/src/pages/public/hooks/useMeta.ts
index 7d846b2..cdb1d1d 100644
--- a/webui/src/pages/public/hooks/useMeta.ts
+++ b/webui/src/pages/public/hooks/useMeta.ts
@@ -1,4 +1,4 @@
-import { useEffect, useState } from 'react';
+import { useCallback, useEffect, useRef, useState } from 'react';
import type { PublicMeta } from '../../../lib/types';
import { getMeta } from '../../../lib/api';
@@ -8,15 +8,41 @@ const DEFAULT_META: PublicMeta = {
mail_enabled: false,
};
+export function useBackendHealth() {
+ const [connected, setConnected] = useState
(null);
+
+ const check = useCallback(async () => {
+ try {
+ const res = await fetch('/api/public/meta');
+ setConnected(res.ok);
+ } catch {
+ setConnected(false);
+ }
+ }, []);
+
+ useEffect(() => {
+ check();
+ const id = setInterval(check, 15_000);
+ return () => clearInterval(id);
+ }, [check]);
+
+ return { connected, check };
+}
+
export function useMeta() {
const [meta, setMeta] = useState(DEFAULT_META);
const [loading, setLoading] = useState(true);
+ const notified = useRef(false);
useEffect(() => {
let cancelled = false;
getMeta()
.then((data) => { if (!cancelled) setMeta(data); })
- .catch(console.error)
+ .catch(() => {
+ if (!cancelled && !notified.current) {
+ notified.current = true;
+ }
+ })
.finally(() => { if (!cancelled) setLoading(false); });
return () => { cancelled = true; };
}, []);
From a4af393d247ee9389686d5f7bccaeb3de6b1476b Mon Sep 17 00:00:00 2001
From: AprShine <1844677202@qq.com>
Date: Thu, 16 Apr 2026 01:05:51 +0800
Subject: [PATCH 08/11] feat: Desktop packaging with PyWebView and serve React
build from backend
---
desktop.py | 66 ++++++++++++++++++++
web_server.py | 48 ++++++++++++++
webui/README.md | 47 +++++++++++++-
webui/index.html | 2 +-
webui/public/icons/icon_ideer.svg | 1 +
webui/src/pages/admin/components/Header.tsx | 2 +-
webui/src/pages/public/components/Header.tsx | 2 +-
7 files changed, 164 insertions(+), 4 deletions(-)
create mode 100644 desktop.py
create mode 100644 webui/public/icons/icon_ideer.svg
diff --git a/desktop.py b/desktop.py
new file mode 100644
index 0000000..371b755
--- /dev/null
+++ b/desktop.py
@@ -0,0 +1,66 @@
+import sys
+import time
+import threading
+from pathlib import Path
+
+import httpx
+import uvicorn
+import webview
+
+import web_server as server_module
+
+# PyInstaller --onefile 会解压到临时目录,退出后清除
+# 将可写数据路径重定向到 exe 所在目录,避免每次运行数据丢失
+if getattr(sys, "frozen", False):
+ d = Path(sys.executable).parent
+ _DATA_PATHS = {
+ "HISTORY_DIR": "history",
+ "CONFIG_FILE": ".web_config.json",
+ "CLIENT_CONFIG_FILE": ".client_config.json",
+ "ENV_FILE": ".env",
+ "DESCRIPTION_FILE": "profiles/description.txt",
+ "RESEARCHER_PROFILE_FILE": "profiles/researcher_profile.md",
+ "TWITTER_ACCOUNTS_FILE": "profiles/x_accounts.txt",
+ "SWIPE_FEEDBACK_FILE": "profiles/swipe_feedback.json",
+ "USERS_DIR": "users",
+ }
+ for attr, rel in _DATA_PATHS.items():
+ setattr(server_module, attr, d / rel)
+
+ # 打包后 web_server 用 sys.executable 调用 main.py,实际会重启 exe
+ # 检测到 "main.py" 参数时,直接调用 main() 函数
+ if "main.py" in sys.argv:
+ sys.argv.remove("main.py")
+ from main import main as run_main
+ run_main()
+ sys.exit(0)
+
+
+def start_server():
+ uvicorn.run(server_module.app, host="127.0.0.1", port=8090)
+
+
+def wait_for_server(url: str = "http://127.0.0.1:8090/health", timeout: int = 15):
+ deadline = time.time() + timeout
+ while time.time() < deadline:
+ try:
+ if httpx.get(url, timeout=1).status_code == 200:
+ return
+ except Exception:
+ pass
+ time.sleep(0.3)
+ raise TimeoutError("Server did not start in time")
+
+
+threading.Thread(target=start_server, daemon=True).start()
+wait_for_server()
+
+webview.create_window(
+ "iDeer",
+ "http://127.0.0.1:8090",
+ width=1280,
+ height=800,
+ min_size=(960, 600),
+)
+# webview.start(debug=True)
+webview.start()
diff --git a/web_server.py b/web_server.py
index bb15731..b6a8dc2 100644
--- a/web_server.py
+++ b/web_server.py
@@ -26,6 +26,17 @@
from fetchers.profile_fetcher import build_profile_text_from_urls
+# Fix missing MIME types on Windows
+mimetypes.add_type("application/javascript", ".js")
+mimetypes.add_type("text/javascript", ".mjs")
+mimetypes.add_type("text/css", ".css")
+mimetypes.add_type("image/svg+xml", ".svg")
+mimetypes.add_type("image/png", ".png")
+mimetypes.add_type("image/jpeg", ".jpg")
+mimetypes.add_type("image/webp", ".webp")
+mimetypes.add_type("font/woff", ".woff")
+mimetypes.add_type("font/woff2", ".woff2")
+
# 项目根目录
PROJECT_ROOT = Path(__file__).parent.absolute()
@@ -986,23 +997,52 @@ def get_file(source: str, date: str, filename: str):
# ============== Static Files ==============
+WEBUI_DIST = PROJECT_ROOT / "webui" / "dist"
+WEBUI_INDEX = WEBUI_DIST / "index.html"
+
@app.get("/health")
def health_check():
return {"status": "ok", "service": "Daily Recommender"}
+@app.get("/assets/{path:path}")
+def webui_assets(path: str):
+ if WEBUI_DIST.exists():
+ file = WEBUI_DIST / "assets" / path
+ if file.is_file():
+ media_type, _ = mimetypes.guess_type(str(file))
+ return FileResponse(file, media_type=media_type or "application/octet-stream")
+ return JSONResponse({"error": "Not found"}, status_code=404)
+
+
+@app.get("/icons/{path:path}")
+def webui_icons(path: str):
+ if WEBUI_DIST.exists():
+ file = WEBUI_DIST / "icons" / path
+ if file.is_file():
+ media_type, _ = mimetypes.guess_type(str(file))
+ return FileResponse(file, media_type=media_type or "application/octet-stream")
+ return JSONResponse({"error": "Not found"}, status_code=404)
+
+
@app.get("/")
def root():
+ if WEBUI_INDEX.exists():
+ return FileResponse(WEBUI_INDEX)
return FileResponse(PUBLIC_UI_FILE)
@app.get("/public")
def public_web_ui():
+ if WEBUI_INDEX.exists():
+ return FileResponse(WEBUI_INDEX)
return FileResponse(PUBLIC_UI_FILE)
@app.get("/admin")
def admin_web_ui():
+ if WEBUI_INDEX.exists():
+ return FileResponse(WEBUI_INDEX)
return FileResponse(ADMIN_UI_FILE)
@@ -1034,6 +1074,14 @@ def legacy_admin_web_ui():
return FileResponse(ADMIN_UI_FILE)
+# SPA fallback: serve React index.html for unrecognized routes when webui dist exists
+@app.get("/{path:path}")
+def spa_fallback(path: str):
+ if WEBUI_INDEX.exists():
+ return FileResponse(WEBUI_INDEX)
+ return JSONResponse({"error": "Not found"}, status_code=404)
+
+
# ============== Scheduler ==============
_scheduler_task: asyncio.Task | None = None
diff --git a/webui/README.md b/webui/README.md
index 0d8740b..83bed47 100644
--- a/webui/README.md
+++ b/webui/README.md
@@ -27,6 +27,47 @@ npm run preview
开发服务器通过 Vite proxy 将 `/api` 和 `/ws` 请求转发到后端 `localhost:8090`,需要先启动后端服务(`python web_server.py`)。
+## 打包为桌面客户端
+
+使用 PyWebView + PyInstaller 将前后端打包为单个可执行文件。
+
+### 依赖
+
+```bash
+pip install pywebview pyinstaller httpx
+```
+
+### 打包步骤
+
+```bash
+# 1. 构建前端
+cd webui && npm run build && cd ..
+
+# 2. 打包为 exe(在项目根目录执行)
+pyinstaller --onefile --windowed --name iDeer --add-data "webui/dist;webui/dist" desktop.py
+```
+
+打包产物在 `dist/iDeer.exe`。
+
+### 原理
+
+- `desktop.py` 在后台线程启动 FastAPI 后端,等待 `/health` 就绪后用 PyWebView 创建原生窗口加载 `http://127.0.0.1:8090`
+- `web_server.py` 检测到 `webui/dist` 存在时,自动优先 serve React 构建产物(否则回退到旧模板)
+- 静态资源(JS/CSS/SVG)放在 `public/` 目录下,构建后路径不变,避免 hash 问题
+
+### 与 Tauri 的对比
+
+| 维度 | PyWebView + PyInstaller | Tauri |
+| ---------- | ------------------------------ | ---------------------------- |
+| 后端语言 | Python(无需重写) | Rust(需要重写后端) |
+| 打包体积 | ~40-80 MB(含 Python 运行时) | ~5-10 MB |
+| 窗口渲染 | 系统 WebView(Edge / WKWebView)| 系统 WebView(同左) |
+| 启动速度 | 较慢(Python 解释器初始化) | 快 |
+| 维护成本 | 低(现有 Python 代码直接复用) | 高(需要用 Rust 重写全部逻辑)|
+| 跨平台 | Windows / macOS / Linux | Windows / macOS / Linux |
+
+**选择建议:** 如果后端逻辑较复杂且已有成熟的 Python 实现,PyWebView 方案更合适,避免用 Rust 重写大量代码。如果追求最小体积和最快启动速度,且愿意投入 Rust 开发成本,可以考虑 Tauri。
+
## 页面结构
| 路由 | 说明 | 状态 |
@@ -45,6 +86,7 @@ src/
useToast.tsx # 通知状态(Context)
useWebSocket.ts # WebSocket 连接管理
components/ # 跨页面共享组件
+ BackendStatus.tsx # 后端连接状态横幅
MarkdownRender.tsx # Markdown 渲染
Toast.tsx # 通知组件
lib/
@@ -56,7 +98,7 @@ src/
public/
PublicPage.tsx # 公开页主组件
hooks/
- useMeta.ts # 公开页元信息获取
+ useMeta.ts # 公开页元信息 + 后端健康检查
components/
Header.tsx # 页头 + 模式切换
HeroSection.tsx # 标题区域
@@ -95,4 +137,7 @@ src/
HistoryView.tsx # 历史记录容器 + 筛选
HistoryList.tsx # 历史卡片列表
ResultModal.tsx # 结果详情弹窗
+public/ # 静态资源(不经过 Vite 处理,原样复制到 dist)
+ icons/
+ icon_ideer.svg # 应用图标
```
diff --git a/webui/index.html b/webui/index.html
index c2cbb5e..8fef335 100644
--- a/webui/index.html
+++ b/webui/index.html
@@ -4,7 +4,7 @@
iDeer
-
+
diff --git a/webui/public/icons/icon_ideer.svg b/webui/public/icons/icon_ideer.svg
new file mode 100644
index 0000000..993271e
--- /dev/null
+++ b/webui/public/icons/icon_ideer.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/webui/src/pages/admin/components/Header.tsx b/webui/src/pages/admin/components/Header.tsx
index e148076..1d50e08 100644
--- a/webui/src/pages/admin/components/Header.tsx
+++ b/webui/src/pages/admin/components/Header.tsx
@@ -30,7 +30,7 @@ export function Header({ activeTab, onTabChange }: HeaderProps) {
返回
-
+
iDeer
Admin
diff --git a/webui/src/pages/public/components/Header.tsx b/webui/src/pages/public/components/Header.tsx
index 05dfb24..eee00ed 100644
--- a/webui/src/pages/public/components/Header.tsx
+++ b/webui/src/pages/public/components/Header.tsx
@@ -28,7 +28,7 @@ export function Header({ mode, onModeChange, meta }: HeaderProps) {
-

+
iDeer
From 437a67a07e25578a16650b9b16ec3a5b8f4b4ea5 Mon Sep 17 00:00:00 2001
From: AprShine <1844677202@qq.com>
Date: Thu, 16 Apr 2026 01:14:06 +0800
Subject: [PATCH 09/11] docs: Update packaging README with current status and
future alternatives
---
webui/README.md | 9 +++++++++
1 file changed, 9 insertions(+)
diff --git a/webui/README.md b/webui/README.md
index 83bed47..ea4e4b3 100644
--- a/webui/README.md
+++ b/webui/README.md
@@ -68,6 +68,15 @@ pyinstaller --onefile --windowed --name iDeer --add-data "webui/dist;webui/dist"
**选择建议:** 如果后端逻辑较复杂且已有成熟的 Python 实现,PyWebView 方案更合适,避免用 Rust 重写大量代码。如果追求最小体积和最快启动速度,且愿意投入 Rust 开发成本,可以考虑 Tauri。
+### 当前状态
+
+PyWebView 方案已测试通过,可以正常打包和运行。
+
+### 可能可以尝试的其他方案
+
+- PyInstaller + Pake:用 PyInstaller 打包 Python 后端,再用 Pake 将前端包裹为轻量桌面客户端,两个进程独立运行,体积更小
+- Rust 重写 web_server + CLI:用 Rust 重写后端 API 服务和 CLI 调用逻辑,核心算法保留为 Python 包供 Rust 侧调用(通过 PyO3 或子进程),兼顾体积和启动速度
+
## 页面结构
| 路由 | 说明 | 状态 |
From ee2eeba9592ba717b435c010927882ee26666c5e Mon Sep 17 00:00:00 2001
From: AprShine <1844677202@qq.com>
Date: Thu, 16 Apr 2026 01:20:24 +0800
Subject: [PATCH 10/11] docs: Simplify packaging README
---
webui/README.md | 13 +++++--------
1 file changed, 5 insertions(+), 8 deletions(-)
diff --git a/webui/README.md b/webui/README.md
index ea4e4b3..b10d43f 100644
--- a/webui/README.md
+++ b/webui/README.md
@@ -34,7 +34,7 @@ npm run preview
### 依赖
```bash
-pip install pywebview pyinstaller httpx
+pip install pywebview pyinstaller
```
### 打包步骤
@@ -58,15 +58,12 @@ pyinstaller --onefile --windowed --name iDeer --add-data "webui/dist;webui/dist"
### 与 Tauri 的对比
| 维度 | PyWebView + PyInstaller | Tauri |
-| ---------- | ------------------------------ | ---------------------------- |
-| 后端语言 | Python(无需重写) | Rust(需要重写后端) |
+| ---------- | ------------------------------ | ----------------------------|
+| 后端语言 | Python | Rust |
| 打包体积 | ~40-80 MB(含 Python 运行时) | ~5-10 MB |
-| 窗口渲染 | 系统 WebView(Edge / WKWebView)| 系统 WebView(同左) |
-| 启动速度 | 较慢(Python 解释器初始化) | 快 |
-| 维护成本 | 低(现有 Python 代码直接复用) | 高(需要用 Rust 重写全部逻辑)|
-| 跨平台 | Windows / macOS / Linux | Windows / macOS / Linux |
+| 窗口渲染 | 系统 WebView(Edge / WKWebView)| 系统 WebView |
+| 启动速度 | 较慢(Python 解释器初始化) | 相对快 |
-**选择建议:** 如果后端逻辑较复杂且已有成熟的 Python 实现,PyWebView 方案更合适,避免用 Rust 重写大量代码。如果追求最小体积和最快启动速度,且愿意投入 Rust 开发成本,可以考虑 Tauri。
### 当前状态
From a86479050b8f23b4beacf71fde6dd615fd5a2986 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E6=9B=99=E5=85=89?=
<72030337+AprShine@users.noreply.github.com>
Date: Thu, 16 Apr 2026 01:38:25 +0800
Subject: [PATCH 11/11] fix: Delete useless webui/assets/icon_ideer.svg
---
webui/assets/icon_ideer.svg | 1 -
1 file changed, 1 deletion(-)
delete mode 100644 webui/assets/icon_ideer.svg
diff --git a/webui/assets/icon_ideer.svg b/webui/assets/icon_ideer.svg
deleted file mode 100644
index 993271e..0000000
--- a/webui/assets/icon_ideer.svg
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file