feat tools
This commit is contained in:
parent
78be2a682f
commit
8de18e0e95
65
README.md
65
README.md
|
|
@ -40,3 +40,68 @@ docker compose up --build
|
|||
```
|
||||
|
||||
完成後開啟 <http://localhost:8080>。
|
||||
|
||||
## 內容管線(YouTube / HyRead)
|
||||
|
||||
### 知識庫
|
||||
|
||||
知識庫分為兩層:
|
||||
- `content/raw/emmy/` — 原始 Obsidian 格式筆記(Markdown + frontmatter)
|
||||
- `data/knowledge.json` + `data/notes.json` — `npm run build:knowledge` 快照產出
|
||||
|
||||
匯入的 YouTube / HyRead 內容會自動寫入 `content/raw/emmy/`,再透過 `build:knowledge` 輸出到前端。
|
||||
|
||||
### 前端頁面
|
||||
|
||||
| 路由 | 頁面 | 說明 |
|
||||
|------|------|------|
|
||||
| `/library` | Library | 知識總覽(全文檢索 + 類型過濾) |
|
||||
| `/library/:kind/:id` | LibraryDetail | 單篇知識全文 |
|
||||
| `/content` | ContentManager | 內容管理(來源列表 / YouTube 擷取 / HyRead 匯入 / 排程) |
|
||||
|
||||
Library 和 LibraryDetail 的選單項目在 `Chrome.tsx` 的「修練 → 知識 · 圖書館」。
|
||||
ContentManager 在「內容 → 內容 · 管理」。
|
||||
|
||||
### API 端點
|
||||
|
||||
所有端點以 `/api/content` 為前綴:
|
||||
|
||||
| 方法 | 路徑 | 說明 |
|
||||
|------|------|------|
|
||||
| GET | `/api/content` | 列舉內容來源(支援 `?kind=` `?status=` `?limit=` `?offset=`) |
|
||||
| GET | `/api/content/stats` | 來源統計(by kind / by status) |
|
||||
| GET | `/api/content/:id` | 單筆來源詳情 |
|
||||
| DELETE | `/api/content/:id` | 刪除來源 |
|
||||
| POST | `/api/content/youtube/fetch` | 擷取 YouTube 影片/播放清單(傳 `{ url }`) |
|
||||
| POST | `/api/content/youtube/process/:id` | 用 AI 處理 transcript(傳 `{ transcript, episode? }`) |
|
||||
| GET | `/api/content/youtube/yt-dlp-status` | 檢查 yt-dlp 是否可用 |
|
||||
| GET | `/api/content/youtube/local-transcripts` | 列出本地 `~/youtube_transcripts/` 整理檔 |
|
||||
| GET | `/api/content/hyread/scan` | 掃描 `hyread-tools/output/` 目錄 |
|
||||
| POST | `/api/content/hyread/import` | 匯入指定書籍(傳 `{ dir }`) |
|
||||
| POST | `/api/content/hyread/upload` | 上傳 book.html(傳 `{ title, html }`) |
|
||||
| GET | `/api/content/schedules` | 列舉排程 |
|
||||
| POST | `/api/content/schedules` | 新增排程(傳 `{ kind, name, url, ... }`) |
|
||||
| DELETE | `/api/content/schedules/:id` | 刪除排程 |
|
||||
| POST | `/api/content/capture` | 擴充功能接收端(傳 `{ kind, url, title, transcript/html, ... }`) |
|
||||
| POST | `/api/content/rebuild-knowledge` | 觸發 `npm run build:knowledge` |
|
||||
|
||||
### Chrome 擴充功能
|
||||
|
||||
位於 `extension/` 目錄。
|
||||
|
||||
**安裝步驟:**
|
||||
|
||||
1. 打開 Chrome,前往 `chrome://extensions`
|
||||
2. 開啟右上角「開發人員模式」
|
||||
3. 點選「載入未封裝項目」
|
||||
4. 選擇 `path/to/app/extension/` 資料夾
|
||||
|
||||
**使用方式:**
|
||||
|
||||
- **YouTube 影片頁**:擴充功能會在影片標題下方自動加入「📥 擷取此影片」按鈕,點擊後取得字幕並傳送至後端
|
||||
- **HyRead 閱讀器**:擴充功能會在頁面右上角加入「📥 匯入此書」按鈕,點擊後擷取 book HTML 並傳送至後端
|
||||
- **Popup 面板**:點擊工具列圖示可查看伺服器狀態、偵測當前分頁類型,也可手動擷取
|
||||
|
||||
### 排程器
|
||||
|
||||
伺服器啟動後會自動啟動排程器(預設每 5 分鐘檢查一次排程),在內容管理頁面的「排程」分頁中管理 YouTube 頻道 / 播放清單的定期抓取。
|
||||
|
|
|
|||
|
|
@ -1,11 +1,6 @@
|
|||
{
|
||||
"enabled": [
|
||||
"macroscope",
|
||||
"stock-scanner",
|
||||
"openinsider",
|
||||
"context7",
|
||||
"yfinance",
|
||||
"alphavantage"
|
||||
"macroscope"
|
||||
],
|
||||
"note": "啟用的 MCP 會由後端真實呼叫,結果附在每次對話中。"
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -0,0 +1,161 @@
|
|||
// Investor RPG — HyRead 閱讀器內容擷取
|
||||
|
||||
const API_HOST = "http://localhost:3000";
|
||||
|
||||
function isReaderPage() {
|
||||
return /\/reader\//.test(window.location.pathname) ||
|
||||
/reader\.html/.test(window.location.href) ||
|
||||
document.querySelector(".hyread-reader") ||
|
||||
document.querySelector("#readerContainer") ||
|
||||
document.querySelector("[class*='reader']");
|
||||
}
|
||||
|
||||
function getBookTitle() {
|
||||
const el = document.querySelector(".book-title") ||
|
||||
document.querySelector("h1") ||
|
||||
document.querySelector("meta[property='og:title']") ||
|
||||
document.querySelector("title");
|
||||
return el?.textContent?.trim() || el?.getAttribute("content") || "HyRead 書籍";
|
||||
}
|
||||
|
||||
function extractPageContent() {
|
||||
const contentSelectors = [
|
||||
"#pageContent", ".page-content", ".reader-content",
|
||||
"#contentArea", ".content-area", "#textLayer",
|
||||
"section.page", ".book-page", "[class*='page-text']",
|
||||
"#iframeContent", "article",
|
||||
];
|
||||
|
||||
for (const sel of contentSelectors) {
|
||||
const el = document.querySelector(sel);
|
||||
if (el && el.textContent.trim().length > 50) {
|
||||
return {
|
||||
text: el.textContent.trim(),
|
||||
html: el.innerHTML,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const iframes = document.querySelectorAll("iframe");
|
||||
for (const iframe of iframes) {
|
||||
try {
|
||||
const doc = iframe.contentDocument || iframe.contentWindow?.document;
|
||||
if (doc && doc.body.textContent.trim().length > 50) {
|
||||
return {
|
||||
text: doc.body.textContent.trim(),
|
||||
html: doc.body.innerHTML,
|
||||
};
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const mainText = document.body.innerText?.trim() || "";
|
||||
return {
|
||||
text: mainText,
|
||||
html: document.body.innerHTML,
|
||||
};
|
||||
}
|
||||
|
||||
function getAllPageContent() {
|
||||
const allTexts = [];
|
||||
const allHtmls = [];
|
||||
|
||||
const content = extractPageContent();
|
||||
if (content.text.length > 50) {
|
||||
allTexts.push(content.text);
|
||||
allHtmls.push(content.html);
|
||||
}
|
||||
|
||||
return {
|
||||
text: allTexts.join("\n\n---\n\n"),
|
||||
html: allHtmls.join("\n"),
|
||||
pageCount: allTexts.length,
|
||||
};
|
||||
}
|
||||
|
||||
async function captureBook() {
|
||||
const title = getBookTitle();
|
||||
const content = getAllPageContent();
|
||||
|
||||
if (content.text.length < 50) {
|
||||
throw new Error("無法擷取到足夠的內容,請確認閱讀器已開啟");
|
||||
}
|
||||
|
||||
const payload = {
|
||||
kind: "hyread",
|
||||
title,
|
||||
url: window.location.href,
|
||||
html: content.html,
|
||||
metadata: {
|
||||
title,
|
||||
url: window.location.href,
|
||||
pageCount: content.pageCount,
|
||||
textLength: content.text.length,
|
||||
},
|
||||
};
|
||||
|
||||
const res = await fetch(`${API_HOST}/api/content/capture`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
throw new Error(err.message || `伺服器回應 ${res.status}`);
|
||||
}
|
||||
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
function injectCaptureButton() {
|
||||
if (document.querySelector("#irpg-capture-btn")) return;
|
||||
if (!isReaderPage()) return;
|
||||
|
||||
const btn = document.createElement("button");
|
||||
btn.id = "irpg-capture-btn";
|
||||
btn.textContent = "📖 擷取到 RPG";
|
||||
btn.style.cssText = `
|
||||
position: fixed; top: 12px; right: 12px; z-index: 99999;
|
||||
background: #2a6c3b; color: #fff; border: none; border-radius: 8px;
|
||||
padding: 8px 16px; font-size: 14px; cursor: pointer;
|
||||
font-weight: 500; box-shadow: 0 2px 8px rgba(0,0,0,.3);
|
||||
transition: background .15s;
|
||||
`;
|
||||
btn.onmouseenter = () => btn.style.background = "#1e7e34";
|
||||
btn.onmouseleave = () => btn.style.background = "#2a6c3b";
|
||||
|
||||
btn.onclick = async () => {
|
||||
btn.textContent = "擷取中...";
|
||||
btn.disabled = true;
|
||||
try {
|
||||
const result = await captureBook();
|
||||
btn.textContent = `✓ ${result.count || "已送出"}`;
|
||||
btn.style.background = "#1e7e34";
|
||||
setTimeout(() => {
|
||||
btn.remove();
|
||||
}, 3000);
|
||||
} catch (err) {
|
||||
btn.textContent = `✕ ${err.message.slice(0, 25)}`;
|
||||
btn.style.background = "#a13a3a";
|
||||
setTimeout(() => {
|
||||
btn.textContent = "📖 擷取到 RPG";
|
||||
btn.style.background = "#2a6c3b";
|
||||
btn.disabled = false;
|
||||
}, 3000);
|
||||
}
|
||||
};
|
||||
|
||||
document.body.appendChild(btn);
|
||||
}
|
||||
|
||||
setTimeout(injectCaptureButton, 2000);
|
||||
|
||||
let lastHref = window.location.href;
|
||||
setInterval(() => {
|
||||
if (window.location.href !== lastHref) {
|
||||
lastHref = window.location.href;
|
||||
document.querySelector("#irpg-capture-btn")?.remove();
|
||||
setTimeout(injectCaptureButton, 1500);
|
||||
}
|
||||
}, 1500);
|
||||
|
|
@ -0,0 +1,190 @@
|
|||
// Investor RPG — YouTube 內容擷取
|
||||
// 執行在 YouTube 頁面上,利用使用者的登入 session 取得字幕
|
||||
|
||||
const API_HOST = "http://localhost:3000";
|
||||
let injectButton = null;
|
||||
|
||||
function extractVideoId() {
|
||||
const m = window.location.pathname.match(/\/watch\/([a-zA-Z0-9_-]{11})/) ||
|
||||
window.location.search.match(/[?&]v=([a-zA-Z0-9_-]{11})/);
|
||||
return m ? m[1] : null;
|
||||
}
|
||||
|
||||
function extractPlaylistId() {
|
||||
const m = window.location.search.match(/[?&]list=([a-zA-Z0-9_-]+)/);
|
||||
return m ? m[1] : null;
|
||||
}
|
||||
|
||||
function getVideoTitle() {
|
||||
const el = document.querySelector("h1 yt-formatted-string") ||
|
||||
document.querySelector("#title h1") ||
|
||||
document.querySelector("meta[itemprop='name']");
|
||||
return el?.textContent?.trim() || el?.getAttribute("content") || document.title.replace(" - YouTube", "");
|
||||
}
|
||||
|
||||
function getChannelName() {
|
||||
const el = document.querySelector("#owner #channel-name a") ||
|
||||
document.querySelector("ytd-channel-name a") ||
|
||||
document.querySelector("link[itemprop='name'][href^='http']");
|
||||
return el?.textContent?.trim() || el?.getAttribute("content") || "";
|
||||
}
|
||||
|
||||
function getUploadDate() {
|
||||
const el = document.querySelector("meta[itemprop='datePublished']") ||
|
||||
document.querySelector("#info-strings yt-formatted-string");
|
||||
return el?.getAttribute("content") || el?.textContent?.trim() || "";
|
||||
}
|
||||
|
||||
function getThumbnail() {
|
||||
const el = document.querySelector("meta[property='og:image']");
|
||||
return el?.getAttribute("content") || "";
|
||||
}
|
||||
|
||||
function injectCaptureButton() {
|
||||
if (injectButton || !extractVideoId()) return;
|
||||
|
||||
injectButton = document.createElement("button");
|
||||
injectButton.textContent = "📥 擷取到 RPG";
|
||||
injectButton.style.cssText = `
|
||||
background: #2a6c3b; color: #fff; border: none; border-radius: 18px;
|
||||
padding: 6px 16px; font-size: 13px; cursor: pointer;
|
||||
margin-left: 8px; white-space: nowrap; font-weight: 500;
|
||||
transition: background .15s;
|
||||
`;
|
||||
injectButton.onmouseenter = () => injectButton.style.background = "#1e7e34";
|
||||
injectButton.onmouseleave = () => injectButton.style.background = "#2a6c3b";
|
||||
|
||||
injectButton.onclick = async () => {
|
||||
injectButton.textContent = "擷取中...";
|
||||
injectButton.disabled = true;
|
||||
try {
|
||||
const result = await captureVideo();
|
||||
injectButton.textContent = `✓ ${result.count || "已送出"}`;
|
||||
setTimeout(() => {
|
||||
injectButton.textContent = "📥 擷取到 RPG";
|
||||
injectButton.disabled = false;
|
||||
}, 3000);
|
||||
} catch (err) {
|
||||
injectButton.textContent = `✕ ${err.message.slice(0, 20)}`;
|
||||
injectButton.style.background = "#a13a3a";
|
||||
setTimeout(() => {
|
||||
injectButton.textContent = "📥 擷取到 RPG";
|
||||
injectButton.style.background = "#2a6c3b";
|
||||
injectButton.disabled = false;
|
||||
}, 3000);
|
||||
}
|
||||
};
|
||||
|
||||
const waitForTarget = () => {
|
||||
const target = document.querySelector("#menu-container") ||
|
||||
document.querySelector("#top-level-buttons-computed") ||
|
||||
document.querySelector("#actions-inner");
|
||||
if (target) {
|
||||
target.appendChild(injectButton);
|
||||
} else {
|
||||
setTimeout(waitForTarget, 500);
|
||||
}
|
||||
};
|
||||
waitForTarget();
|
||||
}
|
||||
|
||||
async function fetchTranscriptFromPage() {
|
||||
const videoId = extractVideoId();
|
||||
if (!videoId) return "";
|
||||
|
||||
try {
|
||||
const apiKey = "";
|
||||
const response = await fetch(`https://youtubetranscript.com/api?vid=${videoId}`, {
|
||||
signal: AbortSignal.timeout(8000),
|
||||
});
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
return (data.segments || []).map(s => s.text).join(" ");
|
||||
}
|
||||
} catch {}
|
||||
|
||||
try {
|
||||
const pageResponse = await fetch(`https://www.youtube.com/watch?v=${videoId}`, {
|
||||
credentials: "include",
|
||||
signal: AbortSignal.timeout(8000),
|
||||
});
|
||||
const html = await pageResponse.text();
|
||||
const capsMatch = html.match(/"captionTracks":\s*(\[.*?\])/);
|
||||
if (capsMatch) {
|
||||
const tracks = JSON.parse(capsMatch[1]);
|
||||
const zhTrack = tracks.find(t => t.languageCode?.startsWith("zh")) || tracks[0];
|
||||
if (zhTrack?.baseUrl) {
|
||||
const subResp = await fetch(zhTrack.baseUrl, { signal: AbortSignal.timeout(8000) });
|
||||
const subText = await subResp.text();
|
||||
return subText.replace(/<\s*\/?\s*[^>]+>/g, "").replace(/[\d:,.\s]+-->[\d:,.\s]+/g, "").replace(/WEBVTT.*?\n/, "").trim();
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
function extractDescription() {
|
||||
const el = document.querySelector("#description yt-formatted-string") ||
|
||||
document.querySelector("#description");
|
||||
return el?.textContent?.trim()?.slice(0, 1000) || "";
|
||||
}
|
||||
|
||||
async function captureVideo() {
|
||||
const videoId = extractVideoId();
|
||||
if (!videoId) throw new Error("找不到影片 ID");
|
||||
|
||||
const title = getVideoTitle();
|
||||
const channel = getChannelName();
|
||||
const uploadDate = getUploadDate();
|
||||
const thumbnail = getThumbnail();
|
||||
const description = extractDescription();
|
||||
const transcript = await fetchTranscriptFromPage();
|
||||
|
||||
const payload = {
|
||||
kind: "youtube",
|
||||
title,
|
||||
url: window.location.href,
|
||||
videoId,
|
||||
transcript,
|
||||
metadata: {
|
||||
channel,
|
||||
uploadDate,
|
||||
thumbnail,
|
||||
description,
|
||||
title,
|
||||
videoId,
|
||||
},
|
||||
};
|
||||
|
||||
const res = await fetch(`${API_HOST}/api/content/capture`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
throw new Error(err.message || `伺服器回應 ${res.status}`);
|
||||
}
|
||||
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
let lastUrl = "";
|
||||
function checkForVideoChange() {
|
||||
const currentUrl = window.location.href;
|
||||
if (currentUrl !== lastUrl) {
|
||||
lastUrl = currentUrl;
|
||||
if (injectButton) {
|
||||
injectButton.remove();
|
||||
injectButton = null;
|
||||
}
|
||||
if (extractVideoId()) {
|
||||
setTimeout(injectCaptureButton, 1000);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setInterval(checkForVideoChange, 1500);
|
||||
setTimeout(injectCaptureButton, 1500);
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
{
|
||||
"manifest_version": 3,
|
||||
"name": "Investor RPG 內容擷取器",
|
||||
"version": "1.0.0",
|
||||
"description": "從 YouTube(含會員影片)和 HyRead 閱讀器擷取內容,傳送到 Investor RPG",
|
||||
"permissions": ["storage", "activeTab"],
|
||||
"host_permissions": [
|
||||
"https://www.youtube.com/*",
|
||||
"https://youtube.com/*",
|
||||
"https://*.ebook.hyread.com.tw/*",
|
||||
"https://*.hyread.com.tw/*"
|
||||
],
|
||||
"content_scripts": [
|
||||
{
|
||||
"matches": ["https://www.youtube.com/*", "https://youtube.com/*"],
|
||||
"js": ["content-youtube.js"],
|
||||
"run_at": "document_idle"
|
||||
},
|
||||
{
|
||||
"matches": ["https://*.ebook.hyread.com.tw/*", "https://*.hyread.com.tw/*"],
|
||||
"js": ["content-hyread.js"],
|
||||
"run_at": "document_idle"
|
||||
}
|
||||
],
|
||||
"action": {
|
||||
"default_popup": "popup.html",
|
||||
"default_title": "Investor RPG 擷取器",
|
||||
"default_icon": {
|
||||
"16": "icon16.png",
|
||||
"48": "icon48.png",
|
||||
"128": "icon128.png"
|
||||
}
|
||||
},
|
||||
"icons": {
|
||||
"16": "icon16.png",
|
||||
"48": "icon48.png",
|
||||
"128": "icon128.png"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<style>
|
||||
body { width: 300px; padding: 12px; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; font-size: 13px; background: #0d1224; color: #e0e8f0; }
|
||||
h1 { font-size: 15px; margin: 0 0 8px; color: #e7c66b; }
|
||||
.section { margin-bottom: 12px; padding: 8px; background: #1b2240; border-radius: 6px; }
|
||||
.section h2 { font-size: 12px; margin: 0 0 4px; color: #8fb8ff; text-transform: uppercase; letter-spacing: .5px; }
|
||||
.status { display: flex; justify-content: space-between; align-items: center; }
|
||||
.badge { padding: 2px 6px; border-radius: 4px; font-size: 11px; }
|
||||
.badge-ok { background: #1a5a2a; color: #6fcf97; }
|
||||
.badge-warn { background: #5a4a1a; color: #e7c66b; }
|
||||
.btn { background: #2a6c3b; color: #fff; border: none; border-radius: 4px; padding: 6px 12px; cursor: pointer; font-size: 12px; margin-top: 4px; }
|
||||
.btn:hover { background: #1e7e34; }
|
||||
.text-dim { color: #6b7a99; font-size: 11px; }
|
||||
a { color: #8fb8ff; text-decoration: none; }
|
||||
.row { padding: 4px 0; border-bottom: 1px solid #242b50; }
|
||||
.server-status { margin-top: 8px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>📥 Investor RPG 擷取器</h1>
|
||||
|
||||
<div class="section" id="current-page">
|
||||
<h2>目前頁面</h2>
|
||||
<div id="page-info" class="text-dim">請在 YouTube 或 HyRead 頁面上使用</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>快速操作</h2>
|
||||
<button class="btn" id="btn-capture" style="display:none">擷取此頁面</button>
|
||||
<div id="capture-result" class="text-dim" style="margin-top:4px"></div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>伺服器連線</h2>
|
||||
<div class="status">
|
||||
<span id="server-status" class="text-dim">檢查中...</span>
|
||||
<span id="server-badge" class="badge badge-warn">??</span>
|
||||
</div>
|
||||
<div style="margin-top:4px">
|
||||
<input type="text" id="api-host" value="http://localhost:3000" style="width:100%;padding:4px;background:#0d1224;border:1px solid #242b50;color:#e0e8f0;border-radius:4px;font-size:11px;box-sizing:border-box" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-dim" style="font-size:10px;text-align:center;margin-top:8px">
|
||||
Investor RPG v1.0.0
|
||||
</div>
|
||||
|
||||
<script src="popup.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,78 @@
|
|||
let apiHost = "http://localhost:3000";
|
||||
|
||||
function $(id) { return document.getElementById(id); }
|
||||
|
||||
async function checkServer() {
|
||||
const host = $("api-host").value.trim() || apiHost;
|
||||
apiHost = host;
|
||||
try {
|
||||
const res = await fetch(`${host}/api/health`, { signal: AbortSignal.timeout(3000) });
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
$("server-status").textContent = `✓ 已連線 (knowledge: ${data.knowledge ? "✓" : "✕"})`;
|
||||
$("server-badge").textContent = "OK";
|
||||
$("server-badge").className = "badge badge-ok";
|
||||
} else {
|
||||
$("server-status").textContent = `✕ 伺服器回應 ${res.status}`;
|
||||
$("server-badge").className = "badge badge-warn";
|
||||
}
|
||||
} catch (err) {
|
||||
$("server-status").textContent = `✕ 無法連線: ${err.message.slice(0, 30)}`;
|
||||
$("server-badge").className = "badge badge-warn";
|
||||
}
|
||||
}
|
||||
|
||||
async function getCurrentTab() {
|
||||
const tabs = await chrome.tabs.query({ active: true, currentWindow: true });
|
||||
return tabs[0];
|
||||
}
|
||||
|
||||
async function updatePageInfo() {
|
||||
const tab = await getCurrentTab();
|
||||
if (!tab) return;
|
||||
|
||||
const url = tab.url || "";
|
||||
const title = tab.title || "";
|
||||
|
||||
if (url.includes("youtube.com/watch") || url.includes("youtu.be")) {
|
||||
$("page-info").innerHTML = `🎬 YouTube 影片<br/><span class="text-dim">${title.slice(0, 60)}</span>`;
|
||||
$("btn-capture").style.display = "block";
|
||||
$("btn-capture").textContent = "📥 擷取此影片";
|
||||
$("btn-capture").onclick = async () => {
|
||||
try {
|
||||
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
|
||||
await chrome.tabs.sendMessage(tab.id, { action: "capture" });
|
||||
$("capture-result").textContent = "✓ 已觸發擷取,請查看頁面按鈕狀態";
|
||||
} catch (err) {
|
||||
$("capture-result").textContent = `✕ ${err.message}`;
|
||||
}
|
||||
};
|
||||
} else if (url.includes("hyread")) {
|
||||
$("page-info").innerHTML = `📖 HyRead 閱讀器<br/><span class="text-dim">${title.slice(0, 60)}</span>`;
|
||||
$("btn-capture").style.display = "block";
|
||||
$("btn-capture").textContent = "📖 擷取此書籍";
|
||||
$("btn-capture").onclick = async () => {
|
||||
try {
|
||||
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
|
||||
await chrome.tabs.sendMessage(tab.id, { action: "capture" });
|
||||
$("capture-result").textContent = "✓ 已觸發擷取,請查看頁面按鈕狀態";
|
||||
} catch (err) {
|
||||
$("capture-result").textContent = `✕ ${err.message}`;
|
||||
}
|
||||
};
|
||||
} else {
|
||||
$("page-info").textContent = `目前頁面: ${title.slice(0, 40)} (不支援此頁面)`;
|
||||
$("btn-capture").style.display = "none";
|
||||
}
|
||||
}
|
||||
|
||||
$("api-host").addEventListener("change", checkServer);
|
||||
|
||||
document.addEventListener("DOMContentLoaded", async () => {
|
||||
const stored = await chrome.storage.local.get("apiHost");
|
||||
if (stored.apiHost) {
|
||||
$("api-host").value = stored.apiHost;
|
||||
}
|
||||
checkServer();
|
||||
updatePageInfo();
|
||||
});
|
||||
|
|
@ -9,7 +9,7 @@
|
|||
"dev:server": "node --disable-warning=ExperimentalWarning --watch server.js",
|
||||
"dev:all": "node scripts/dev-all.mjs",
|
||||
"start": "node --disable-warning=ExperimentalWarning server.js",
|
||||
"build": "tsc --noEmit && vite build",
|
||||
"build": "tsc --noEmit && vite build && chmod -R 755 dist/icons dist/mascot",
|
||||
"preview": "vite preview",
|
||||
"build:knowledge": "node scripts/build-knowledge.mjs",
|
||||
"build:skill-drills": "node scripts/build-skill-drills.mjs",
|
||||
|
|
|
|||
427
server.js
427
server.js
|
|
@ -59,6 +59,7 @@ import {
|
|||
getTraderBrief,
|
||||
buildTraderOperationalStatus,
|
||||
buildTraderParameterSummary,
|
||||
buildTraderUniversePreview,
|
||||
refreshDailyTakeaway,
|
||||
} from './lib/ai-trader.js';
|
||||
import { listTraderPersonalities } from './lib/trader-personalities.js';
|
||||
|
|
@ -77,6 +78,7 @@ import {
|
|||
} from './lib/ai-debug.js';
|
||||
import { buildReport } from './lib/fincheck.js';
|
||||
import { buildFundAnalysis } from './lib/fund-analysis.js';
|
||||
import { computeCompositeAnalysis, computeDCF, computeFinancialMetrics } from './lib/quant-analysis.js';
|
||||
import { RANGES, INTERVALS } from './lib/marketdata.js';
|
||||
import { runBacktest, STRATEGIES } from './lib/backtest.js';
|
||||
import {
|
||||
|
|
@ -1476,6 +1478,18 @@ app.get('/api/trades/personality-templates', (_req, res) => {
|
|||
res.json({ templates: listTraderPersonalities() });
|
||||
} catch (e) { res.status(500).json({ error: String(e.message) }); }
|
||||
});
|
||||
app.get('/api/trades/accounts/:id/agent-models', async (req, res) => {
|
||||
try {
|
||||
const account = getTradeAccount(Number(req.params.id));
|
||||
if (!account) return res.status(404).json({ error: 'not_found' });
|
||||
const { listAgentModelConfig } = await import('./lib/agent-orchestrator.js');
|
||||
const config = listAgentModelConfig(account);
|
||||
res.json({
|
||||
agents: config,
|
||||
multiAgentEnabled: account?.traderConfig?.multiAgentEnabled !== false,
|
||||
});
|
||||
} catch (e) { res.status(500).json({ error: String(e.message) }); }
|
||||
});
|
||||
app.get('/api/trades/accounts/:id/trader-brief', async (req, res) => {
|
||||
try {
|
||||
const id = Number(req.params.id);
|
||||
|
|
@ -1492,6 +1506,7 @@ app.get('/api/trades/accounts/:id/trader-brief', async (req, res) => {
|
|||
canTrade: phase === 'intraday' && isMarketOpenForAccount(account.simulationMarket),
|
||||
brief: getTraderBrief(id),
|
||||
followSignals,
|
||||
universePreview: account.kind === 'ai' ? buildTraderUniversePreview(id) : null,
|
||||
operationalStatus: buildTraderOperationalStatus(id),
|
||||
effectiveParams: account.kind === 'ai' ? buildTraderParameterSummary(id) : null,
|
||||
});
|
||||
|
|
@ -1533,6 +1548,7 @@ app.post('/api/trades/accounts/:id/simulate', async (req, res) => {
|
|||
else if (phase === 'industry_report') result = await runDailyIndustryResearch(id, { force });
|
||||
else if (phase === 'post_market') result = await runPostMarketReview(id, { force });
|
||||
else if (phase === 'intraday_review') result = await runIntradayReview(id, { force });
|
||||
else if (phase === 'intraday_test') result = await runAiSimulation(id, { force: true, phase: 'intraday_test', deps });
|
||||
else if (phase === 'intraday') result = await runAiSimulation(id, { force, phase: 'intraday' });
|
||||
else result = await runAiSimulation(id, { force, phase: 'auto', deps });
|
||||
res.json(result);
|
||||
|
|
@ -1552,6 +1568,18 @@ app.get('/api/trades/accounts/:id/simulation-runs', (req, res) => {
|
|||
});
|
||||
} catch (e) { res.status(500).json({ error: String(e.message) }); }
|
||||
});
|
||||
app.get('/api/quant-analysis/:symbol', async (req, res) => {
|
||||
try {
|
||||
const symbol = String(req.params.symbol).trim().toUpperCase();
|
||||
if (!symbol) return res.status(400).json({ error: 'missing_symbol' });
|
||||
const fundamentals = await getFundamentals(symbol);
|
||||
const dcf = computeDCF(fundamentals, fundamentals.price);
|
||||
const financialMetrics = computeFinancialMetrics(fundamentals);
|
||||
res.json({ symbol, name: fundamentals.name, price: fundamentals.price, dcf, financialMetrics });
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: String(e?.message || e), symbol: req.params.symbol });
|
||||
}
|
||||
});
|
||||
app.get('/api/trades', (req, res) => res.json({ trades: listTrades(parseAccountId(req)) }));
|
||||
app.get('/api/trades/stats', (req, res) => res.json(tradeStats(parseAccountId(req))));
|
||||
app.post('/api/trades', (req, res) => {
|
||||
|
|
@ -2386,6 +2414,395 @@ app.post('/api/settings/env', (req, res) => {
|
|||
}
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// 內容管線 API(YouTube 擷取 / HyRead 匯入 / 排程)
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
import {
|
||||
listContentSources, getContentSource, insertContentSource, updateContentSource, deleteContentSource,
|
||||
enqueueJob, listPendingJobs, claimJob, completeJob,
|
||||
listSchedules, getSchedule, upsertSchedule, deleteSchedule,
|
||||
getContentStats,
|
||||
} from './lib/content-pipeline/db.js';
|
||||
import { fetchTranscript, fetchPlaylist, extractVideoId, extractPlaylistId, checkYtDlp, listLocalTranscripts } from './lib/content-pipeline/youtube.js';
|
||||
import { processTranscriptWithAI, processTranscriptLocally, saveAsKnowledgeNote, appendPrinciple } from './lib/content-pipeline/transcript-to-knowledge.js';
|
||||
import { scanHyreadOutput, parseBookContent, extractPrinciplesFromBook, extractTermsFromBook } from './lib/content-pipeline/hyread.js';
|
||||
import { callAI, extractJSONObject } from './lib/ai-client.js';
|
||||
|
||||
// ─── 內容來源列表 ───
|
||||
app.get('/api/content', (req, res) => {
|
||||
try {
|
||||
const kind = req.query.kind || null;
|
||||
const status = req.query.status || null;
|
||||
const limit = Math.min(100, Math.max(1, Number(req.query.limit) || 50));
|
||||
const offset = Math.max(0, Number(req.query.offset) || 0);
|
||||
const sources = listContentSources(kind, status, limit, offset);
|
||||
const stats = getContentStats();
|
||||
res.json({ sources, stats });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: 'content_list_failed', message: String(err?.message || err) });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/content/stats', (req, res) => {
|
||||
try {
|
||||
res.json(getContentStats());
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: 'content_stats_failed', message: String(err?.message || err) });
|
||||
}
|
||||
});
|
||||
|
||||
// ─── YouTube ───
|
||||
app.post('/api/content/youtube/fetch', async (req, res) => {
|
||||
const { url } = req.body || {};
|
||||
if (!url) return res.status(400).json({ error: 'missing_url', message: '請提供 YouTube URL' });
|
||||
|
||||
try {
|
||||
const playlistId = extractPlaylistId(url);
|
||||
if (playlistId) {
|
||||
const entries = await fetchPlaylist(url);
|
||||
const results = [];
|
||||
for (const entry of entries) {
|
||||
const sourceId = insertContentSource({
|
||||
kind: 'youtube', title: entry.title, url: entry.url, sourceId: entry.id,
|
||||
channel: entry.channel, publishedAt: null, metadata: { playlistId, entry },
|
||||
});
|
||||
enqueueJob(sourceId, 'youtube');
|
||||
results.push({ id: sourceId, title: entry.title, videoId: entry.id });
|
||||
}
|
||||
return res.json({ ok: true, kind: 'playlist', count: results.length, items: results });
|
||||
}
|
||||
|
||||
const result = await fetchTranscript(url);
|
||||
const videoId = result.videoId;
|
||||
const meta = result.metadata || {};
|
||||
|
||||
const sourceId = insertContentSource({
|
||||
kind: 'youtube', title: meta.title || videoId, url: meta.webpageUrl || url,
|
||||
sourceId: videoId, channel: meta.channel, author: meta.channel,
|
||||
publishedAt: meta.uploadDate ? `${meta.uploadDate.slice(0,4)}-${meta.uploadDate.slice(4,6)}-${meta.uploadDate.slice(6,8)}` : null,
|
||||
thumbnailUrl: meta.thumbnail, durationSec: meta.duration,
|
||||
metadata: { title: meta.title, description: meta.description, videoId },
|
||||
});
|
||||
|
||||
const jobId = enqueueJob(sourceId, 'youtube', 'process', { transcript: result.transcript, metadata: meta });
|
||||
|
||||
res.json({
|
||||
ok: true, kind: 'single', sourceId, videoId,
|
||||
title: meta.title || videoId, transcriptLength: result.transcript.length,
|
||||
});
|
||||
} catch (err) {
|
||||
res.status(502).json({ error: 'youtube_fetch_failed', message: String(err?.message || err) });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/content/youtube/process/:id', async (req, res) => {
|
||||
try {
|
||||
const source = getContentSource(req.params.id);
|
||||
if (!source) return res.status(404).json({ error: 'not_found', message: '找不到該內容' });
|
||||
|
||||
if (!req.body?.transcript) {
|
||||
return res.status(400).json({ error: 'missing_transcript', message: '需要提供 transcript' });
|
||||
}
|
||||
|
||||
updateContentSource(source.id, { status: 'processing' });
|
||||
const transcript = req.body.transcript;
|
||||
const metadata = source.metadata || {};
|
||||
|
||||
let items;
|
||||
const aiCfg = await import('./lib/ai-client.js').then(m => m.getActiveAIConfig());
|
||||
if (aiCfg) {
|
||||
try {
|
||||
const aiClient = {
|
||||
chat: async (messages, opts) => {
|
||||
const sysMsg = messages.find(m => m.role === 'system');
|
||||
const userMsg = messages.find(m => m.role === 'user');
|
||||
const result = await callAI({
|
||||
system: sysMsg?.content || '',
|
||||
user: userMsg?.content || '',
|
||||
temperature: opts?.temperature ?? 0.3,
|
||||
timeoutMs: 120000,
|
||||
debugMeta: { source: 'content_pipeline_youtube' },
|
||||
});
|
||||
if (!result.ok) throw new Error(result.error || 'AI call failed');
|
||||
return result.text;
|
||||
},
|
||||
};
|
||||
items = await processTranscriptWithAI(transcript, metadata, aiClient);
|
||||
} catch (aiErr) {
|
||||
console.warn('[content] AI processing failed, using local fallback:', aiErr.message);
|
||||
items = processTranscriptLocally(transcript, metadata);
|
||||
}
|
||||
} else {
|
||||
items = processTranscriptLocally(transcript, metadata);
|
||||
}
|
||||
|
||||
const saved = { principles: [], terms: [], cases: [], companies: [], episodes: [] };
|
||||
|
||||
for (const item of items) {
|
||||
switch (item.type) {
|
||||
case 'principle':
|
||||
saved.principles.push(appendPrinciple({
|
||||
title: item.title, body: item.body,
|
||||
source: 'youtube', videoId: metadata.id || source.source_id,
|
||||
episode: req.body.episode || null, date: metadata.uploadDate || null,
|
||||
}));
|
||||
break;
|
||||
case 'term':
|
||||
saved.terms.push(saveAsKnowledgeNote({
|
||||
kind: 'term', id: item.id || item.title, title: item.title, body: item.body,
|
||||
source: 'youtube', videoId: metadata.id || source.source_id,
|
||||
category: item.category || null,
|
||||
}));
|
||||
break;
|
||||
case 'case':
|
||||
saved.cases.push(saveAsKnowledgeNote({
|
||||
kind: 'case', id: item.id || item.title, title: item.title, body: item.body,
|
||||
source: 'youtube', videoId: metadata.id || source.source_id,
|
||||
}));
|
||||
break;
|
||||
case 'company':
|
||||
saved.companies.push(saveAsKnowledgeNote({
|
||||
kind: 'company', id: item.ticker?.[0] || item.id, title: item.title, body: item.body,
|
||||
source: 'youtube', videoId: metadata.id || source.source_id,
|
||||
ticker: item.ticker,
|
||||
}));
|
||||
break;
|
||||
case 'episode':
|
||||
saved.episodes.push(saveAsKnowledgeNote({
|
||||
kind: 'episode', id: item.id, title: item.title, body: item.body,
|
||||
source: 'youtube', videoId: metadata.id || source.source_id,
|
||||
episode: item.id,
|
||||
}));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const count = saved.principles.length + saved.terms.length + saved.cases.length + saved.companies.length + saved.episodes.length;
|
||||
updateContentSource(source.id, { status: 'ready', processed_count: count, metadata_json: { ...source.metadata, processingResult: { saved, count } } });
|
||||
|
||||
res.json({ ok: true, sourceId: source.id, count, saved });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: 'content_process_failed', message: String(err?.message || err) });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/content/youtube/yt-dlp-status', (req, res) => {
|
||||
res.json({ available: checkYtDlp() });
|
||||
});
|
||||
|
||||
app.get('/api/content/youtube/local-transcripts', (req, res) => {
|
||||
try {
|
||||
res.json({ transcripts: listLocalTranscripts() });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: 'local_transcripts_failed', message: String(err?.message || err) });
|
||||
}
|
||||
});
|
||||
|
||||
// ─── HyRead ───
|
||||
app.get('/api/content/hyread/scan', (req, res) => {
|
||||
try {
|
||||
const books = scanHyreadOutput();
|
||||
res.json({ books });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: 'hyread_scan_failed', message: String(err?.message || err) });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/content/hyread/import', (req, res) => {
|
||||
const { dir } = req.body || {};
|
||||
if (!dir) return res.status(400).json({ error: 'missing_dir', message: '請提供目錄名稱' });
|
||||
|
||||
try {
|
||||
const books = scanHyreadOutput();
|
||||
const book = books.find(b => b.dir === dir);
|
||||
if (!book) return res.status(404).json({ error: 'not_found', message: '找不到該書籍' });
|
||||
|
||||
const content = parseBookContent(book.bookHtmlPath);
|
||||
if (!content) return res.status(500).json({ error: 'parse_failed', message: '無法解析書籍內容' });
|
||||
|
||||
const sourceId = insertContentSource({
|
||||
kind: 'hyread', title: content.title, url: null, sourceId: book.dir,
|
||||
author: book.metadata.author || null, publishedAt: null,
|
||||
metadata: { dir: book.dir, pageCount: content.pages.length, ...book.metadata },
|
||||
});
|
||||
|
||||
const principles = extractPrinciplesFromBook(content);
|
||||
const terms = extractTermsFromBook(content);
|
||||
|
||||
for (const p of principles) {
|
||||
appendPrinciple({ title: p.title, body: p.body, source: 'hyread', videoId: book.dir });
|
||||
}
|
||||
for (const t of terms) {
|
||||
saveAsKnowledgeNote({ kind: 'term', id: t.title, title: t.title, body: t.body, source: 'hyread', videoId: book.dir });
|
||||
}
|
||||
saveAsKnowledgeNote({
|
||||
kind: 'category', id: book.dir, title: content.title,
|
||||
body: `# ${content.title}\n\n本書由 HyRead 匯入,共 ${content.pages.length} 頁。\n\n來源:hyread-tools/output/${book.dir}/`,
|
||||
source: 'hyread',
|
||||
});
|
||||
|
||||
updateContentSource(sourceId, { status: 'ready', processed_count: principles.length + terms.length });
|
||||
|
||||
res.json({ ok: true, sourceId, title: content.title, principleCount: principles.length, termCount: terms.length });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: 'hyread_import_failed', message: String(err?.message || err) });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/content/hyread/upload', async (req, res) => {
|
||||
const { title, html } = req.body || {};
|
||||
if (!html) return res.status(400).json({ error: 'missing_html', message: '請提供 book.html 內容' });
|
||||
|
||||
try {
|
||||
const bookTitle = title || '匯入書籍';
|
||||
const content = { title: bookTitle, html, pages: [] };
|
||||
const pageRegex = /<section[^>]*class="page"[^>]*>(.*?)<\/section>/gs;
|
||||
let m;
|
||||
let idx = 0;
|
||||
while ((m = pageRegex.exec(html)) !== null) {
|
||||
idx++;
|
||||
const text = m[1].replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim();
|
||||
if (text) content.pages.push({ index: idx, text });
|
||||
}
|
||||
if (!content.pages.length) {
|
||||
const text = html.replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim();
|
||||
content.pages = [{ index: 1, text }];
|
||||
}
|
||||
|
||||
const sourceId = insertContentSource({
|
||||
kind: 'hyread', title: bookTitle, url: null, sourceId: `upload_${Date.now()}`,
|
||||
metadata: { pageCount: content.pages.length, upload: true },
|
||||
});
|
||||
|
||||
const principles = extractPrinciplesFromBook(content);
|
||||
const terms = extractTermsFromBook(content);
|
||||
|
||||
for (const p of principles) {
|
||||
appendPrinciple({ title: p.title, body: p.body, source: 'hyread', videoId: sourceId });
|
||||
}
|
||||
for (const t of terms) {
|
||||
saveAsKnowledgeNote({ kind: 'term', id: t.title, title: t.title, body: t.body, source: 'hyread', videoId: sourceId });
|
||||
}
|
||||
|
||||
updateContentSource(sourceId, { status: 'ready', processed_count: principles.length + terms.length });
|
||||
|
||||
res.json({ ok: true, sourceId, title: bookTitle, principleCount: principles.length, termCount: terms.length });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: 'hyread_upload_failed', message: String(err?.message || err) });
|
||||
}
|
||||
});
|
||||
|
||||
// ─── 排程 ───
|
||||
app.get('/api/content/schedules', (req, res) => {
|
||||
try {
|
||||
res.json({ schedules: listSchedules() });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: 'schedules_list_failed', message: String(err?.message || err) });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/content/schedules', (req, res) => {
|
||||
try {
|
||||
const { kind, name, url, channelId, playlistId, intervalMin, enabled } = req.body || {};
|
||||
if (!kind || !name || !url) {
|
||||
return res.status(400).json({ error: 'missing_fields', message: '需要 kind, name, url' });
|
||||
}
|
||||
const id = upsertSchedule({ kind, name, url, channelId, playlistId, intervalMin, enabled });
|
||||
res.json({ ok: true, id });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: 'schedule_create_failed', message: String(err?.message || err) });
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/api/content/schedules/:id', (req, res) => {
|
||||
try {
|
||||
deleteSchedule(Number(req.params.id));
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: 'schedule_delete_failed', message: String(err?.message || err) });
|
||||
}
|
||||
});
|
||||
|
||||
// ─── 擴充功能接收 ───
|
||||
app.post('/api/content/capture', async (req, res) => {
|
||||
const { kind, url, title, videoId, transcript, html, sourceId: extSourceId, metadata } = req.body || {};
|
||||
if (!kind) return res.status(400).json({ error: 'missing_kind', message: '需要 kind (youtube/hyread)' });
|
||||
|
||||
try {
|
||||
const sourceId = insertContentSource({
|
||||
kind, title: title || '擴充功能擷取', url, sourceId: extSourceId || videoId || `ext_${Date.now()}`,
|
||||
channel: metadata?.channel, author: metadata?.channel,
|
||||
publishedAt: metadata?.uploadDate, thumbnailUrl: metadata?.thumbnail,
|
||||
metadata: { ...metadata, extCapture: true },
|
||||
});
|
||||
|
||||
if (kind === 'youtube' && transcript) {
|
||||
updateContentSource(sourceId, { status: 'processing' });
|
||||
enqueueJob(sourceId, 'youtube', 'process', { transcript, metadata: metadata || {} });
|
||||
return res.json({ ok: true, sourceId, note: '已排程 AI 處理' });
|
||||
}
|
||||
|
||||
if (kind === 'hyread' && html) {
|
||||
const content = { title: title || '擴充功能擷取', html, pages: [] };
|
||||
const pageRegex = /<section[^>]*class="page"[^>]*>(.*?)<\/section>/gs;
|
||||
let m;
|
||||
let idx = 0;
|
||||
while ((m = pageRegex.exec(html)) !== null) {
|
||||
idx++;
|
||||
const text = m[1].replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim();
|
||||
if (text) content.pages.push({ index: idx, text });
|
||||
}
|
||||
if (!content.pages.length) {
|
||||
const text = html.replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim();
|
||||
content.pages = [{ index: 1, text }];
|
||||
}
|
||||
const principles = extractPrinciplesFromBook(content);
|
||||
const terms = extractTermsFromBook(content);
|
||||
for (const p of principles) appendPrinciple({ title: p.title, body: p.body, source: 'hyread', videoId: sourceId });
|
||||
for (const t of terms) saveAsKnowledgeNote({ kind: 'term', id: t.title, title: t.title, body: t.body, source: 'hyread', videoId: sourceId });
|
||||
updateContentSource(sourceId, { status: 'ready', processed_count: principles.length + terms.length });
|
||||
return res.json({ ok: true, sourceId, count: principles.length + terms.length });
|
||||
}
|
||||
|
||||
updateContentSource(sourceId, { status: 'ready' });
|
||||
res.json({ ok: true, sourceId, note: '已接收,待處理' });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: 'content_capture_failed', message: String(err?.message || err) });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/content/rebuild-knowledge', async (req, res) => {
|
||||
try {
|
||||
const { execSync } = await import('node:child_process');
|
||||
const appDir = path.resolve(__dirname);
|
||||
const result = execSync('npm run build:knowledge 2>&1', { cwd: appDir, timeout: 30000 });
|
||||
const output = result.toString();
|
||||
res.json({ ok: true, output: output.slice(0, 2000) });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: 'rebuild_failed', message: String(err?.message || err), output: String(err?.stdout || '') });
|
||||
}
|
||||
});
|
||||
|
||||
// ─── 內容來源單篇查詢 / 刪除(放在最後避免攔截特定路徑)───
|
||||
app.get('/api/content/:id', (req, res) => {
|
||||
try {
|
||||
const source = getContentSource(req.params.id);
|
||||
if (!source) return res.status(404).json({ error: 'not_found', message: '找不到該內容來源' });
|
||||
res.json(source);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: 'content_get_failed', message: String(err?.message || err) });
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/api/content/:id', (req, res) => {
|
||||
try {
|
||||
deleteContentSource(req.params.id);
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: 'content_delete_failed', message: String(err?.message || err) });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/health', (req, res) => res.json({ ok: true, knowledge: knowledgeReady() }));
|
||||
|
||||
// 《短線交易日線圖大全》教材卷軸(MD → HTML,含 content/raw/patterns 後備)
|
||||
|
|
@ -2398,7 +2815,7 @@ app.get('*', (req, res, next) => {
|
|||
res.sendFile(path.join(DIST_DIR, 'index.html'));
|
||||
});
|
||||
|
||||
app.listen(PORT, HOST, () => {
|
||||
app.listen(PORT, HOST, async () => {
|
||||
ensureAIConfigDefaults();
|
||||
console.log(`\nMacroScope 已啟動 → http://${HOST}:${PORT}\n`);
|
||||
if (!hasKey) {
|
||||
|
|
@ -2435,4 +2852,12 @@ app.listen(PORT, HOST, () => {
|
|||
setInterval(() => tickAiSimulations(simDeps), 60_000);
|
||||
setTimeout(() => tickAiSimulations(simDeps), 15_000);
|
||||
console.log('AI 交易員排程已啟動:交易日跑盤前/盤中/盤後;每日產業研究包含週末與休市日。');
|
||||
// 內容管線排程(每 5 分鐘檢查一次排程)
|
||||
try {
|
||||
const { startScheduler } = await import('./lib/content-pipeline/scheduler.js');
|
||||
startScheduler(5 * 60 * 1000);
|
||||
console.log('內容排程器已啟動。');
|
||||
} catch (err) {
|
||||
console.warn('[content-scheduler] 啟動失敗:', err.message);
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -9,6 +9,9 @@ import Research from "./pages/Research";
|
|||
import Skills from "./pages/Skills";
|
||||
import Patterns from "./pages/Patterns";
|
||||
import Journal from "./pages/Journal";
|
||||
import Library from "./pages/Library";
|
||||
import LibraryDetail from "./pages/LibraryDetail";
|
||||
import ContentManager from "./pages/ContentManager";
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
|
|
@ -23,6 +26,9 @@ export default function App() {
|
|||
<Route path="/patterns" element={<Patterns />} />
|
||||
<Route path="/journal" element={<Journal />} />
|
||||
<Route path="/profile" element={<Profile />} />
|
||||
<Route path="/library" element={<Library />} />
|
||||
<Route path="/library/:kind/:id" element={<LibraryDetail />} />
|
||||
<Route path="/content" element={<ContentManager />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
|
|
|
|||
|
|
@ -30,6 +30,13 @@ const NAV: { section: string; items: { to: string; icon: IconName; label: string
|
|||
items: [
|
||||
{ to: "/skills", icon: "scroll", label: "心法 · 技能樹" },
|
||||
{ to: "/patterns", icon: "cards", label: "線型 · 圖鑑" },
|
||||
{ to: "/library", icon: "book", label: "知識 · 圖書館" },
|
||||
],
|
||||
},
|
||||
{
|
||||
section: "內容",
|
||||
items: [
|
||||
{ to: "/content", icon: "folder", label: "內容 · 管理" },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
|
@ -47,6 +54,7 @@ const BOTTOM_NAV: { to: string; icon: IconName; label: string }[] = [
|
|||
{ to: "/market", icon: "world", label: "市場" },
|
||||
{ to: "/research", icon: "folder", label: "背包" },
|
||||
{ to: "/skills", icon: "scroll", label: "心法" },
|
||||
{ to: "/library", icon: "book", label: "圖書館" },
|
||||
];
|
||||
|
||||
function NavLinks({ onNavigate }: { onNavigate?: () => void }) {
|
||||
|
|
|
|||
|
|
@ -257,6 +257,22 @@ export function IconCoin({ size = 24, className }: IconProps) {
|
|||
});
|
||||
}
|
||||
|
||||
export function IconBook({ size = 24, className }: IconProps) {
|
||||
return base({
|
||||
size,
|
||||
className,
|
||||
children: (
|
||||
<>
|
||||
<path d="M6 7 C6 5 8 4 10 4 H26 V22 H10 C8 22 6 21 6 19 Z" fill="#1b2240" stroke="#8fb8ff" strokeWidth="2" />
|
||||
<line x1="10" y1="4" x2="10" y2="22" stroke="rgba(143,184,255,.4)" strokeWidth="1.5" />
|
||||
<rect x="14" y="8" width="8" height="1.5" fill="#6fe0d0" />
|
||||
<rect x="14" y="12" width="6" height="1.5" fill="#8fb8ff" />
|
||||
<rect x="14" y="16" width="7" height="1.5" fill="#e7c66b" />
|
||||
</>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
export function IconMap({ size = 24, className }: IconProps) {
|
||||
return base({
|
||||
size,
|
||||
|
|
@ -524,7 +540,8 @@ export type IconName =
|
|||
| "warning"
|
||||
| "key"
|
||||
| "hammer"
|
||||
| "hourglass";
|
||||
| "hourglass"
|
||||
| "book";
|
||||
|
||||
const ICON_MAP: Record<IconName, ComponentType<IconProps>> = {
|
||||
compass: IconCompass,
|
||||
|
|
@ -538,6 +555,7 @@ const ICON_MAP: Record<IconName, ComponentType<IconProps>> = {
|
|||
scroll: IconScroll,
|
||||
cards: IconCards,
|
||||
folder: IconFolder,
|
||||
book: IconBook,
|
||||
wizard: IconWizard,
|
||||
gear: IconGear,
|
||||
menu: IconMenu,
|
||||
|
|
@ -584,6 +602,8 @@ export const ROUTE_ICON: Record<string, IconName> = {
|
|||
"/research": "sword",
|
||||
"/skills": "scroll",
|
||||
"/patterns": "cards",
|
||||
"/library": "book",
|
||||
"/content": "folder",
|
||||
"/journal": "folder",
|
||||
"/profile": "wizard",
|
||||
"/settings": "gear",
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ export type AccountForm = {
|
|||
simulationMarket: string;
|
||||
universeMode: UniverseMode;
|
||||
universeCustom: string;
|
||||
universeGroupIds: string[];
|
||||
simulationOnlyMarketHours: boolean;
|
||||
aiProvider: string;
|
||||
aiModel: string;
|
||||
|
|
@ -27,12 +28,22 @@ export type AccountForm = {
|
|||
note: string;
|
||||
};
|
||||
|
||||
export const AGENT_ROLES = [
|
||||
{ type: "value", label: "價值分析師", badge: "V" },
|
||||
{ type: "growth", label: "成長分析師", badge: "G" },
|
||||
{ type: "macro", label: "總經分析師", badge: "M" },
|
||||
{ type: "technical", label: "技術分析師", badge: "T" },
|
||||
{ type: "portfolioManager", label: "投資組合經理", badge: "PM" },
|
||||
] as const;
|
||||
|
||||
export const DEFAULT_TRADER_CONFIG: TraderCustomConfig = {
|
||||
riskScore: 5,
|
||||
maxPositionPct: 30,
|
||||
maxActionsPerRound: 2,
|
||||
maxOpenPositions: 8,
|
||||
minCashReservePct: 10,
|
||||
multiAgentEnabled: true,
|
||||
agentModels: {},
|
||||
observation: {
|
||||
preMarket: ["總經、利率、美元與 VIX", "板塊輪動與候選標的新聞"],
|
||||
intraday: ["持倉盈虧與停損停利", "大盤方向與量價關鍵位"],
|
||||
|
|
@ -47,15 +58,23 @@ export const DEFAULT_TRADER_CONFIG: TraderCustomConfig = {
|
|||
paramOverrides: {},
|
||||
};
|
||||
|
||||
function parseUniverse(raw?: string | null): { mode: UniverseMode; custom: string } {
|
||||
const u = String(raw || "watchlist").trim().toLowerCase();
|
||||
if (u === "watchlist") return { mode: "watchlist", custom: "" };
|
||||
if (u === "ai_pick" || u === "ai" || u === "auto") return { mode: "ai_pick", custom: "" };
|
||||
return { mode: "custom", custom: String(raw || "").trim() };
|
||||
function parseUniverse(raw?: string | null): { mode: UniverseMode; custom: string; groupIds: string[] } {
|
||||
const original = String(raw || "watchlist").trim();
|
||||
const u = original.toLowerCase();
|
||||
if (u === "watchlist") return { mode: "watchlist", custom: "", groupIds: [] };
|
||||
if (u.startsWith("watchlist:")) {
|
||||
return {
|
||||
mode: "watchlist",
|
||||
custom: "",
|
||||
groupIds: original.slice("watchlist:".length).split(",").map((s) => s.trim()).filter(Boolean),
|
||||
};
|
||||
}
|
||||
if (u === "ai_pick" || u === "ai" || u === "auto") return { mode: "ai_pick", custom: "", groupIds: [] };
|
||||
return { mode: "custom", custom: String(raw || "").trim(), groupIds: [] };
|
||||
}
|
||||
|
||||
export function accountFormFromRecord(a: TradeAccount): AccountForm {
|
||||
const { mode, custom } = parseUniverse(a.simulationUniverse);
|
||||
const { mode, custom, groupIds } = parseUniverse(a.simulationUniverse);
|
||||
const savedConfig = a.traderConfig || {};
|
||||
return {
|
||||
name: a.name,
|
||||
|
|
@ -68,6 +87,7 @@ export function accountFormFromRecord(a: TradeAccount): AccountForm {
|
|||
simulationMarket: a.simulationMarket || "ANY",
|
||||
universeMode: mode,
|
||||
universeCustom: custom,
|
||||
universeGroupIds: groupIds,
|
||||
simulationOnlyMarketHours: a.simulationOnlyMarketHours !== false,
|
||||
aiProvider: a.aiProvider || "",
|
||||
aiModel: a.aiModel || "",
|
||||
|
|
@ -109,6 +129,7 @@ export const EMPTY_ACCOUNT_FORM: AccountForm = {
|
|||
simulationMarket: "US",
|
||||
universeMode: "watchlist",
|
||||
universeCustom: "",
|
||||
universeGroupIds: [],
|
||||
simulationOnlyMarketHours: true,
|
||||
aiProvider: "",
|
||||
aiModel: "",
|
||||
|
|
@ -122,6 +143,45 @@ function Hint({ children }: { children: React.ReactNode }) {
|
|||
return <p className="journal-form-hint">{children}</p>;
|
||||
}
|
||||
|
||||
function ModelSelect({
|
||||
providerId,
|
||||
allProviders,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
providerId: string;
|
||||
allProviders: { id: string; label: string; model?: string }[];
|
||||
value: string;
|
||||
onChange: (model: string) => void;
|
||||
}) {
|
||||
const modelsQ = useQuery({
|
||||
queryKey: ["ai-models", providerId],
|
||||
queryFn: () => aiApi.models(providerId as any),
|
||||
enabled: !!providerId,
|
||||
staleTime: 120_000,
|
||||
});
|
||||
const modelOptions = modelsQ.data?.models || [];
|
||||
const defaultModel = allProviders.find((p) => p.id === providerId)?.model || modelOptions[0]?.id || "";
|
||||
return (
|
||||
<select
|
||||
value={value}
|
||||
disabled={!providerId || modelsQ.isLoading || modelOptions.length === 0}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
>
|
||||
<option value="">{providerId ? "選擇模型" : "跟隨帳號預設"}</option>
|
||||
{modelOptions.length === 0 ? (
|
||||
<option value="">{modelsQ.isLoading ? "載入中…" : (defaultModel || "無可用模型")}</option>
|
||||
) : (
|
||||
modelOptions.map((m: any) => (
|
||||
<option key={m.id} value={m.id}>
|
||||
{m.id}
|
||||
</option>
|
||||
))
|
||||
)}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
|
||||
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="journal-form-section">
|
||||
|
|
@ -176,6 +236,12 @@ export function AccountSettingsModal({
|
|||
enabled: open && form.kind === "ai",
|
||||
});
|
||||
const providers = (providersQ.data?.providers || []).filter((p) => p.hasKey);
|
||||
const watchlistQ = useQuery({
|
||||
queryKey: ["stock-watchlist"],
|
||||
queryFn: api.watchlist,
|
||||
enabled: open && form.kind === "ai" && form.simulationEnabled,
|
||||
});
|
||||
const watchlistGroups = watchlistQ.data?.groups || [];
|
||||
const activeProviderId = (form.aiProvider || providers[0]?.id || "grok") as AIProviderId;
|
||||
|
||||
const modelsQ = useQuery({
|
||||
|
|
@ -187,7 +253,8 @@ export function AccountSettingsModal({
|
|||
const modelOptions = modelsQ.data?.models || [];
|
||||
const defaultModel = providers.find((p) => p.id === activeProviderId)?.model || modelOptions[0]?.id || "";
|
||||
|
||||
const effectiveModel = form.aiModel || defaultModel;
|
||||
const accountProviderSelected = !!form.aiProvider;
|
||||
const effectiveModel = accountProviderSelected ? (form.aiModel || defaultModel) : "";
|
||||
|
||||
const templatesQ = useQuery({
|
||||
queryKey: ["trader-personality-templates"],
|
||||
|
|
@ -217,13 +284,15 @@ export function AccountSettingsModal({
|
|||
|
||||
const universeBehavior = useMemo(() => {
|
||||
if (form.universeMode === "watchlist") {
|
||||
return "只在你 App 追蹤清單(watchlist)內找標的;盤中買賣不會超出清單。";
|
||||
const selected = watchlistGroups.filter((g) => form.universeGroupIds.includes(g.id));
|
||||
if (selected.length) return `只在追蹤清單分群「${selected.map((g) => g.name).join("、")}」內找標的。`;
|
||||
return "只在你 App 追蹤清單全部分群內找標的;可下方改成只選指定分群。";
|
||||
}
|
||||
if (form.universeMode === "ai_pick") {
|
||||
return "盤前研究時 AI 依總經、輪動、新聞自選 5–8 檔寫入 watch_focus;盤中只在這個池子裡交易(可含清單外標的)。";
|
||||
}
|
||||
return "只交易你指定的代號(逗號分隔),例如 NVDA,AAPL,2330.TW。";
|
||||
}, [form.universeMode]);
|
||||
}, [form.universeGroupIds, form.universeMode, watchlistGroups]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
|
|
@ -307,7 +376,7 @@ export function AccountSettingsModal({
|
|||
<div className="settings-field">
|
||||
<label>AI Provider</label>
|
||||
<select
|
||||
value={form.aiProvider || providers[0]?.id || ""}
|
||||
value={form.aiProvider || ""}
|
||||
onChange={(e) => onChange({ aiProvider: e.target.value, aiModel: "" })}
|
||||
>
|
||||
<option value="">使用全域預設</option>
|
||||
|
|
@ -323,11 +392,11 @@ export function AccountSettingsModal({
|
|||
<label>模型</label>
|
||||
<select
|
||||
value={effectiveModel}
|
||||
disabled={modelsQ.isLoading || modelOptions.length === 0}
|
||||
disabled={!accountProviderSelected || modelsQ.isLoading || modelOptions.length === 0}
|
||||
onChange={(e) => onChange({ aiModel: e.target.value })}
|
||||
>
|
||||
{modelOptions.length === 0 ? (
|
||||
<option value="">{modelsQ.isLoading ? "載入模型中…" : defaultModel || "無可用模型"}</option>
|
||||
<option value="">{!accountProviderSelected ? "跟隨全域預設" : (modelsQ.isLoading ? "載入模型中…" : defaultModel || "無可用模型")}</option>
|
||||
) : (
|
||||
modelOptions.map((m) => (
|
||||
<option key={m.id} value={m.id}>
|
||||
|
|
@ -336,11 +405,91 @@ export function AccountSettingsModal({
|
|||
))
|
||||
)}
|
||||
</select>
|
||||
<Hint>盤前研究、每日產業報告、盤中決策與盤後復盤都會用此模型。</Hint>
|
||||
<Hint>只有選擇帳號專用 Provider 時才會保存此模型;留空則完整跟隨全域 Provider 與模型。</Hint>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title="多分析師模型配置">
|
||||
<label className="journal-mistake-check">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.traderConfig.multiAgentEnabled !== false}
|
||||
onChange={(e) =>
|
||||
onChange({
|
||||
traderConfig: {
|
||||
...form.traderConfig,
|
||||
multiAgentEnabled: e.target.checked,
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
啟用多分析師投票機制(4 分析師 + 投資組合經理)
|
||||
</label>
|
||||
<Hint>
|
||||
啟用後盤中交易會平行呼叫 4 位 AI 分析師(價值/成長/總經/技術),再由投資組合經理綜合投票做出最終決定。可分別指定每位分析師使用的模型。
|
||||
</Hint>
|
||||
{form.traderConfig.multiAgentEnabled !== false ? (
|
||||
<div className="multi-agent-table">
|
||||
{AGENT_ROLES.map((role) => {
|
||||
const agentCfg = form.traderConfig.agentModels?.[role.type];
|
||||
return (
|
||||
<div key={role.type} className="multi-agent-row">
|
||||
<span className="multi-agent-label"><span className="agent-badge">{role.badge}</span>{role.label}</span>
|
||||
<div className="grid g3">
|
||||
<div className="settings-field">
|
||||
<label>Provider</label>
|
||||
<select
|
||||
value={agentCfg?.providerId || ""}
|
||||
onChange={(e) => {
|
||||
const models = { ...(form.traderConfig.agentModels || {}) };
|
||||
if (e.target.value) {
|
||||
models[role.type] = { providerId: e.target.value, model: "" };
|
||||
} else {
|
||||
delete models[role.type];
|
||||
}
|
||||
onChange({
|
||||
traderConfig: { ...form.traderConfig, agentModels: models },
|
||||
});
|
||||
}}
|
||||
>
|
||||
<option value="">跟隨帳號預設</option>
|
||||
{providers.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="settings-field">
|
||||
<label>模型</label>
|
||||
<ModelSelect
|
||||
providerId={agentCfg?.providerId || ""}
|
||||
allProviders={providers}
|
||||
value={agentCfg?.model || ""}
|
||||
onChange={(model) => {
|
||||
const models = { ...(form.traderConfig.agentModels || {}) };
|
||||
if (model && agentCfg?.providerId) {
|
||||
models[role.type] = { ...agentCfg, model };
|
||||
} else if (!model && agentCfg?.providerId) {
|
||||
models[role.type] = { ...agentCfg, model: "" };
|
||||
} else {
|
||||
delete models[role.type];
|
||||
}
|
||||
onChange({
|
||||
traderConfig: { ...form.traderConfig, agentModels: models },
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</Section>
|
||||
|
||||
{form.simulationEnabled ? (
|
||||
<Section title="自動投資行為">
|
||||
<div className="grid g2">
|
||||
|
|
@ -384,6 +533,36 @@ export function AccountSettingsModal({
|
|||
<Hint>{universeBehavior}</Hint>
|
||||
</div>
|
||||
|
||||
{form.universeMode === "watchlist" ? (
|
||||
<div className="settings-field">
|
||||
<label>追蹤清單分群(可複選)</label>
|
||||
<div className="watchlist-group-picker">
|
||||
{watchlistGroups.length === 0 ? (
|
||||
<p className="journal-form-hint">目前沒有追蹤清單分群。</p>
|
||||
) : watchlistGroups.map((group) => {
|
||||
const checked = form.universeGroupIds.includes(group.id);
|
||||
return (
|
||||
<label key={group.id} className="watchlist-group-option">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={(event) => {
|
||||
const next = event.target.checked
|
||||
? [...form.universeGroupIds, group.id]
|
||||
: form.universeGroupIds.filter((id) => id !== group.id);
|
||||
onChange({ universeGroupIds: next });
|
||||
}}
|
||||
/>
|
||||
<span>{group.name}</span>
|
||||
<small>{group.symbols.length ? group.symbols.join(", ") : "空分群"}</small>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<Hint>不勾任何分群 = 使用全部追蹤清單;勾選後只使用指定分群。</Hint>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{form.universeMode === "custom" ? (
|
||||
<div className="settings-field">
|
||||
<label>自訂代號(逗號分隔)</label>
|
||||
|
|
@ -505,6 +684,8 @@ export function accountPayloadFromForm(form: AccountForm) {
|
|||
const simulationUniverse =
|
||||
form.universeMode === "custom"
|
||||
? form.universeCustom.trim() || "watchlist"
|
||||
: form.universeMode === "watchlist" && form.universeGroupIds.length > 0
|
||||
? `watchlist:${form.universeGroupIds.join(",")}`
|
||||
: form.universeMode;
|
||||
|
||||
return {
|
||||
|
|
@ -519,12 +700,16 @@ export function accountPayloadFromForm(form: AccountForm) {
|
|||
simulationUniverse,
|
||||
simulationOnlyMarketHours: form.simulationOnlyMarketHours,
|
||||
aiProvider: form.aiProvider.trim() || null,
|
||||
aiModel: form.aiModel.trim() || null,
|
||||
aiModel: form.aiProvider.trim() ? (form.aiModel.trim() || null) : null,
|
||||
traderPersonality: form.kind === "ai" && form.simulationEnabled ? form.traderPersonality || "custom" : null,
|
||||
traderConfig: form.kind === "ai" && form.simulationEnabled
|
||||
? form.traderPersonality === "custom"
|
||||
? form.traderConfig
|
||||
: { paramOverrides: form.traderConfig.paramOverrides || {} }
|
||||
: {
|
||||
paramOverrides: form.traderConfig.paramOverrides || {},
|
||||
multiAgentEnabled: form.traderConfig.multiAgentEnabled,
|
||||
agentModels: form.traderConfig.agentModels,
|
||||
}
|
||||
: null,
|
||||
simulationStrategy:
|
||||
form.kind === "ai" && form.simulationEnabled ? form.simulationStrategy.trim() || null : null,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import { useMemo } from "react";
|
||||
import type { TradeAccount, TradeDashboardPayload } from "../../lib/api";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import type { TradeAccount, TradeDashboardPayload, TraderPersonalityTemplate, TraderCustomConfig } from "../../lib/api";
|
||||
import { api } from "../../lib/api";
|
||||
import { Card } from "../ui";
|
||||
import { AppIcon } from "../PixelIcons";
|
||||
import EquityChart from "../EquityChart";
|
||||
|
|
@ -153,6 +155,10 @@ export function JournalDashboard({
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{account.kind === "ai" && account.traderPersonality ? (
|
||||
<PersonalityCard account={account} />
|
||||
) : null}
|
||||
|
||||
{account.kind === "ai" ? (
|
||||
<SimulationPanel
|
||||
account={account}
|
||||
|
|
@ -229,3 +235,107 @@ export function JournalDashboard({
|
|||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function PersonalityCard({ account }: { account: TradeAccount }) {
|
||||
const templatesQ = useQuery({
|
||||
queryKey: ["trader-personality-templates"],
|
||||
queryFn: api.traderPersonalityTemplates,
|
||||
});
|
||||
const agentMQ = useQuery({
|
||||
queryKey: ["agent-models", account.id],
|
||||
queryFn: () => api.tradeAgentModels(account.id),
|
||||
enabled: account.kind === "ai",
|
||||
});
|
||||
|
||||
const templates = templatesQ.data?.templates || [];
|
||||
const personality = templates.find((t) => t.id === account.traderPersonality);
|
||||
const agentConfig = agentMQ.data;
|
||||
const config = (account.traderConfig || {}) as TraderCustomConfig;
|
||||
const multiAgent = config.multiAgentEnabled !== false;
|
||||
|
||||
if (!personality) return null;
|
||||
|
||||
return (
|
||||
<Card title={`交易員性格 · ${personality.name}`} ico={<AppIcon name="compass" size={22} framed variant="nav" />} className="mt">
|
||||
<div className="personality-dashboard-grid">
|
||||
<div className="personality-dash-section">
|
||||
<div className="personality-dash-head">
|
||||
<strong>{personality.name}</strong>
|
||||
<span className="small muted">{personality.nameEn}</span>
|
||||
<span className={`trader-risk-pill risk-${personality.riskLevel}`}>{personality.riskLabel}</span>
|
||||
</div>
|
||||
<p className="small" data-pct={personality.riskScore * 10}>
|
||||
<span>LV.{personality.riskScore} 風險等級</span>
|
||||
<span className="personality-hp-bar">
|
||||
<span className="personality-hp-fill" style={{ width: `${personality.riskScore * 10}%` }} />
|
||||
</span>
|
||||
</p>
|
||||
<p className="small muted">{personality.strategyPreview}</p>
|
||||
|
||||
{personality.assetClasses && personality.assetClasses.length > 0 ? (
|
||||
<div className="personality-assets">
|
||||
<span className="small muted">觀察資產:</span>
|
||||
{personality.assetClasses!.map((a) => (
|
||||
<span key={a.id} className={`trader-personality-asset-chip w-${a.weight}`} title={a.note}>
|
||||
{a.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{personality.tradingRules ? (
|
||||
<div className="personality-dash-section">
|
||||
<span className="small muted">交易規範</span>
|
||||
{(["entry", "exit", "sizing", "forbidden"] as const).map((cat) => {
|
||||
const items = personality.tradingRules?.[cat];
|
||||
if (!items?.length) return null;
|
||||
const labels = { entry: "進場", exit: "出場", sizing: "倉位", forbidden: "禁止" };
|
||||
return (
|
||||
<div key={cat} className="personality-rule-group" data-cat={cat}>
|
||||
<span className="personality-rule-label">{labels[cat]}</span>
|
||||
<ul>
|
||||
{items.map((item) => (
|
||||
<li key={item}>{item}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{multiAgent && agentConfig?.agents?.length ? (
|
||||
<div className="personality-dash-section">
|
||||
<span className="small muted">分析師模型分配</span>
|
||||
<div className="personality-agent-models">
|
||||
{agentConfig.agents.map((a: any) => {
|
||||
const agentBadge: Record<string, string> = {
|
||||
value: "V",
|
||||
growth: "G",
|
||||
macro: "M",
|
||||
technical: "T",
|
||||
portfolioManager: "PM",
|
||||
};
|
||||
return (
|
||||
<div key={a.type} className="personality-agent-row">
|
||||
<span><span className="agent-badge">{agentBadge[a.type] || "?"}</span> {a.label}</span>
|
||||
<span className="small">
|
||||
{a.incomplete ? (
|
||||
<span className="muted">未完成設定,暫用 {a.providerId || "預設"}/{a.model || "預設"}</span>
|
||||
) : a.isCustom ? (
|
||||
<strong>{a.providerId}/{a.model}</strong>
|
||||
) : (
|
||||
<span className="muted">{a.providerId || "預設"}/{a.model} (預設)</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ const UNIVERSE_LABEL: Record<string, string> = {
|
|||
function universeLabel(raw?: string | null) {
|
||||
const u = String(raw || "watchlist").trim().toLowerCase();
|
||||
if (UNIVERSE_LABEL[u]) return UNIVERSE_LABEL[u];
|
||||
if (u.startsWith("watchlist:")) return "追蹤清單分群";
|
||||
if (u === "ai" || u === "auto") return "AI 自選";
|
||||
return `自訂:${raw}`;
|
||||
}
|
||||
|
|
@ -44,6 +45,7 @@ function universeLabel(raw?: string | null) {
|
|||
const PHASE_LABEL: Record<string, string> = {
|
||||
pre_market: "市場分析",
|
||||
intraday: "盤中",
|
||||
intraday_test: "盤中測試",
|
||||
intraday_review: "盤中復盤",
|
||||
post_market: "盤後",
|
||||
industry_report: "每日產業研究",
|
||||
|
|
@ -60,10 +62,30 @@ function fmtMoney(v: number | null | undefined) {
|
|||
return `$${v.toLocaleString(undefined, { maximumFractionDigits: 0 })}`;
|
||||
}
|
||||
|
||||
function RunRow({ run }: { run: SimulationRun }) {
|
||||
function SymbolChips({ title, symbols, empty = "無" }: { title: string; symbols?: string[]; empty?: string }) {
|
||||
const rows = (symbols || []).filter(Boolean);
|
||||
return (
|
||||
<div className="trader-universe-group">
|
||||
<span>{title}</span>
|
||||
<div className="trader-universe-chips">
|
||||
{rows.length ? rows.map((symbol) => <strong key={`${title}-${symbol}`}>{symbol}</strong>) : <em>{empty}</em>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function researchModelFallback(account: TradeAccount) {
|
||||
const cfg = account.traderConfig?.agentModels?.portfolioManager;
|
||||
if (cfg?.providerId && cfg?.model) return cfg;
|
||||
if (account.aiProvider && account.aiModel) return { providerId: account.aiProvider, model: account.aiModel };
|
||||
return null;
|
||||
}
|
||||
|
||||
function RunRow({ run, account }: { run: SimulationRun; account: TradeAccount }) {
|
||||
const buyRows = run.executed.filter((e) => e.type === "buy" && e.ok);
|
||||
const sellRows = run.executed.filter((e) => e.type === "sell" && e.ok);
|
||||
const phase = run.phase ? PHASE_LABEL[run.phase] || run.phase : "";
|
||||
const fallbackModel = run.phase === "industry_report" && !run.model ? researchModelFallback(account) : null;
|
||||
return (
|
||||
<div className="journal-sim-run">
|
||||
<div className="journal-sim-run-head">
|
||||
|
|
@ -73,8 +95,14 @@ function RunRow({ run }: { run: SimulationRun }) {
|
|||
</span>
|
||||
<span className={`journal-sim-status ${statusTone(run.status)}`}>{run.status}</span>
|
||||
</div>
|
||||
{run.provider || run.model ? (
|
||||
<div className="journal-sim-model small muted">
|
||||
模型:{run.provider || fallbackModel?.providerId || "預設"}
|
||||
{run.model ? ` / ${run.model}` : fallbackModel ? ` / ${fallbackModel.model}(舊紀錄補示)` : ""}
|
||||
</div>
|
||||
) : null}
|
||||
{run.summary ? <p className="small">{run.summary}</p> : null}
|
||||
{run.phase === "intraday" ? (
|
||||
{run.phase === "intraday" || run.phase === "intraday_test" ? (
|
||||
<div className="small muted">
|
||||
{buyRows.length > 0 ? (
|
||||
<div className="trader-entry-sizing">
|
||||
|
|
@ -90,6 +118,9 @@ function RunRow({ run }: { run: SimulationRun }) {
|
|||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{run.executed.filter((e) => !e.ok && e.error === "test_mode").length > 0 ? (
|
||||
<p style={{ color: "var(--gold)", marginTop: 4 }}>測試模式:已完成分析與決策驗證,不執行下單</p>
|
||||
) : null}
|
||||
{run.executed.filter((e) => !e.ok && e.error === "market_closed").length > 0 ? (
|
||||
<p style={{ color: "var(--gold)", marginTop: 4 }}>非交易時段,買賣已拒絕</p>
|
||||
) : null}
|
||||
|
|
@ -159,6 +190,7 @@ export function SimulationPanel({
|
|||
const phaseLabel = brief?.phaseLabel || "—";
|
||||
const operational = brief?.operationalStatus;
|
||||
const effectiveParams = brief?.effectiveParams;
|
||||
const universePreview = brief?.universePreview;
|
||||
const todaySignals = brief?.followSignals?.signals?.length || 0;
|
||||
const intradayRuns = operational?.today.intradayRuns || 0;
|
||||
const tradingDay = operational?.tradingDay !== false;
|
||||
|
|
@ -245,6 +277,24 @@ export function SimulationPanel({
|
|||
</section>
|
||||
) : null}
|
||||
|
||||
{universePreview ? (
|
||||
<section className="trader-universe-preview">
|
||||
<div className="trader-universe-head">
|
||||
<div>
|
||||
<span>本輪掃描範圍</span>
|
||||
<strong>{universePreview.modeLabel} · 今日候選池</strong>
|
||||
</div>
|
||||
<span className="trader-effective-badge">{universePreview.candidateSymbols.length} 檔</span>
|
||||
</div>
|
||||
<div className="trader-universe-groups">
|
||||
<SymbolChips title="今日會觀察" symbols={universePreview.candidateSymbols} />
|
||||
<SymbolChips title="性格種子" symbols={universePreview.personalitySeeds} />
|
||||
<SymbolChips title="目前持倉" symbols={universePreview.openSymbols} empty="目前無持倉" />
|
||||
<SymbolChips title="過去交易過" symbols={universePreview.recentClosedSymbols} empty="尚無平倉紀錄" />
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<div className="ai-command-actions">
|
||||
<div>
|
||||
<strong>今日操作</strong>
|
||||
|
|
@ -263,6 +313,15 @@ export function SimulationPanel({
|
|||
>
|
||||
② 盤中一輪
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-ghost sm"
|
||||
disabled={running}
|
||||
title="測試盤中每 N 分鐘會跑的多分析師/PM 決策;使用目前可取得的最新或收盤資料,不下單、不更新排程時間"
|
||||
onClick={() => onRunPhase("intraday_test")}
|
||||
>
|
||||
②T 測試盤中輪詢
|
||||
</button>
|
||||
<button type="button" className="btn-ghost sm" disabled={running || !tradingDay} onClick={() => onRunPhase("post_market")}>
|
||||
③ 盤後復盤
|
||||
</button>
|
||||
|
|
@ -403,7 +462,7 @@ export function SimulationPanel({
|
|||
<summary>最近執行紀錄({runs.length})</summary>
|
||||
{runs.length > 0 ? (
|
||||
<div className="journal-sim-runs">
|
||||
{runs.slice(0, 10).map((r) => <RunRow key={r.id} run={r} />)}
|
||||
{runs.slice(0, 10).map((r) => <RunRow key={r.id} run={r} account={account} />)}
|
||||
</div>
|
||||
) : <p className="muted small">尚無執行紀錄。</p>}
|
||||
</details>
|
||||
|
|
|
|||
|
|
@ -47,6 +47,12 @@ const BEHAVIOR_HINT: Record<string, string> = {
|
|||
druckenmiller: "高信念時敢重倉;宏觀+個股共振才出手。",
|
||||
soros: "抓反身性趨勢;快進快出,假設錯立刻撤。",
|
||||
livermore: "投機趨勢、關鍵點突破;賺了才加碼,絕不攤平。",
|
||||
ackman: "深度研究集中押注;維權改善營運,搭配避險。",
|
||||
wood: "押注破壞式創新;高成長高估值,不懼波動。",
|
||||
burry: "深度價值+催化劑;放空泡沫,不跟隨市場共識。",
|
||||
taleb: "槓鈴策略:極安全資產+尾部風險押注,反脆弱。",
|
||||
graham: "淨淨值、清算價值、安全邊際;永不虧錢為上。",
|
||||
munger: "護城河+管理層+耐心;不出價太低不交易。",
|
||||
custom: "完全由你定義風險、倉位與進出規則。",
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,312 @@
|
|||
import { useState } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { api } from "../lib/api";
|
||||
import { RouteIcon } from "../components/PixelIcons";
|
||||
|
||||
const TYPE_LABELS: Record<string, string> = { youtube: "YouTube", hyread: "HyRead 書籍", manual: "手動" };
|
||||
const STATUS_LABELS: Record<string, string> = { captured: "已擷取", processing: "處理中", ready: "已完成", error: "錯誤" };
|
||||
|
||||
function SourceRow({ source, onRefresh }: { source: any; onRefresh: () => void }) {
|
||||
const [processing, setProcessing] = useState(false);
|
||||
const qc = useQueryClient();
|
||||
|
||||
const processMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const transcript = source.metadata?.transcript || "";
|
||||
return api.youtubeProcess(source.id, transcript);
|
||||
},
|
||||
onSuccess: () => { qc.invalidateQueries({ queryKey: ["content"] }); onRefresh(); },
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={`cs-row cs-${source.kind}`}>
|
||||
<div className="cs-info">
|
||||
<div className="cs-title">{source.title || source.id}</div>
|
||||
<div className="cs-meta">
|
||||
<span className={`tag tag-${source.kind}`}>{TYPE_LABELS[source.kind] || source.kind}</span>
|
||||
<span className={`tag tag-${source.status}`}>{STATUS_LABELS[source.status] || source.status}</span>
|
||||
{source.channel && <span>{source.channel}</span>}
|
||||
{source.published_at && <span>{source.published_at}</span>}
|
||||
{source.processed_count > 0 && <span>{source.processed_count} 項知識</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="cs-actions">
|
||||
{source.status === "captured" && source.kind === "youtube" && (
|
||||
<button className="btn btn-sm" onClick={() => processMutation.mutate()} disabled={processing}>
|
||||
{processing ? "處理中..." : "AI 處理"}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="btn btn-sm btn-ghost"
|
||||
onClick={() => api.rebuildKnowledge().then(onRefresh)}
|
||||
>
|
||||
重建知識庫
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ContentManager() {
|
||||
const [tab, setTab] = useState<"sources" | "youtube" | "hyread" | "schedule">("sources");
|
||||
const [url, setUrl] = useState("");
|
||||
const [scheduleUrl, setScheduleUrl] = useState("");
|
||||
const [scheduleName, setScheduleName] = useState("");
|
||||
const [scheduleInterval, setScheduleInterval] = useState(10080);
|
||||
const [fetchStatus, setFetchStatus] = useState<string | null>(null);
|
||||
const [importStatus, setImportStatus] = useState<string | null>(null);
|
||||
const qc = useQueryClient();
|
||||
|
||||
const sourcesQ = useQuery({
|
||||
queryKey: ["content"],
|
||||
queryFn: () => api.contentSources(),
|
||||
});
|
||||
|
||||
const hyreadQ = useQuery({
|
||||
queryKey: ["hyread-scan"],
|
||||
queryFn: () => api.hyreadScan(),
|
||||
enabled: tab === "hyread",
|
||||
});
|
||||
|
||||
const schedulesQ = useQuery({
|
||||
queryKey: ["content-schedules"],
|
||||
queryFn: () => api.contentSchedules(),
|
||||
enabled: tab === "schedule",
|
||||
});
|
||||
|
||||
const ytDlpQ = useQuery({
|
||||
queryKey: ["yt-dlp"],
|
||||
queryFn: () => api.ytDlpStatus(),
|
||||
});
|
||||
|
||||
const fetchMutation = useMutation({
|
||||
mutationFn: (u: string) => api.youtubeFetch(u),
|
||||
onSuccess: (data) => {
|
||||
setFetchStatus(`成功: 已加入 ${data.items?.length || 1} 個影片`);
|
||||
setUrl("");
|
||||
qc.invalidateQueries({ queryKey: ["content"] });
|
||||
},
|
||||
onError: (err: any) => setFetchStatus(`錯誤: ${err.message}`),
|
||||
});
|
||||
|
||||
const scheduleMutation = useMutation({
|
||||
mutationFn: () => api.createContentSchedule({
|
||||
kind: "youtube_channel",
|
||||
name: scheduleName,
|
||||
url: scheduleUrl,
|
||||
intervalMin: scheduleInterval,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
setScheduleUrl("");
|
||||
setScheduleName("");
|
||||
qc.invalidateQueries({ queryKey: ["content-schedules"] });
|
||||
},
|
||||
});
|
||||
|
||||
const refresh = () => {
|
||||
qc.invalidateQueries({ queryKey: ["content"] });
|
||||
qc.invalidateQueries({ queryKey: ["knowledge"] });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="page content-manager-page">
|
||||
<div className="page-head">
|
||||
<h1>內容管理</h1>
|
||||
<p className="page-subtitle">管理 YouTube 與 HyRead 內容擷取</p>
|
||||
</div>
|
||||
|
||||
<div className="cm-tabs">
|
||||
{(["sources", "youtube", "hyread", "schedule"] as const).map(t => (
|
||||
<button key={t} className={`btn ${tab === t ? "btn-active" : ""}`} onClick={() => setTab(t)}>
|
||||
{t === "sources" ? "所有來源" : t === "youtube" ? "YouTube" : t === "hyread" ? "HyRead" : "排程"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{tab === "sources" && (
|
||||
<section>
|
||||
<div className="cm-toolbar">
|
||||
<span className="text-dim">{sourcesQ.data?.stats?.total || 0} 個內容來源</span>
|
||||
<button className="btn btn-sm" onClick={() => api.rebuildKnowledge().then(refresh)}>重建知識庫</button>
|
||||
</div>
|
||||
<div className="cs-list">
|
||||
{(sourcesQ.data?.sources || []).map((s: any) => (
|
||||
<SourceRow key={s.id} source={s} onRefresh={refresh} />
|
||||
))}
|
||||
{(!sourcesQ.data?.sources || sourcesQ.data.sources.length === 0) && (
|
||||
<div className="lc-empty"><p>尚無內容來源,請從 YouTube 或 HyRead 匯入</p></div>
|
||||
)}
|
||||
</div>
|
||||
{sourcesQ.data?.stats?.byKind && (
|
||||
<div className="cm-stats">
|
||||
{Object.entries(sourcesQ.data.stats.byKind).map(([k, counts]: [string, any]) => (
|
||||
<div key={k} className="cm-stat-chip">
|
||||
{TYPE_LABELS[k] || k}: {Object.values(counts).reduce((a: number, b: any) => a + (b as number), 0)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{tab === "youtube" && (
|
||||
<section>
|
||||
<div className="cm-card">
|
||||
<h3>抓取 YouTube 影片</h3>
|
||||
<div className="cm-input-row">
|
||||
<input
|
||||
type="text"
|
||||
className="cm-input"
|
||||
placeholder="YouTube URL (單一影片或播放清單)"
|
||||
value={url}
|
||||
onChange={e => setUrl(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
className="btn"
|
||||
onClick={() => url.trim() && fetchMutation.mutate(url.trim())}
|
||||
disabled={fetchMutation.isPending || !url.trim()}
|
||||
>
|
||||
{fetchMutation.isPending ? "抓取中..." : "抓取"}
|
||||
</button>
|
||||
</div>
|
||||
{fetchStatus && <div className={`cm-status ${fetchStatus.startsWith("錯誤") ? "error" : "ok"}`}>{fetchStatus}</div>}
|
||||
<div className="cm-hint">
|
||||
{ytDlpQ.data?.available ? "✓ yt-dlp 已安裝" : "⚠ yt-dlp 未安裝(僅能用公開 API)"}
|
||||
|支援單一影片與播放清單
|
||||
|會員影片請用 Chrome 擴充功能
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="cm-card">
|
||||
<h3>Chrome 擴充功能</h3>
|
||||
<p className="text-dim">安裝擴充功能後,在 YouTube 或 HyRead 頁面點擊「擷取」即可將內容送回伺服器。</p>
|
||||
<a href="/extension.crx" className="btn btn-sm" download>下載擴充功能</a>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{tab === "hyread" && (
|
||||
<section>
|
||||
<div className="cm-card">
|
||||
<h3>掃描 hyread-tools/output/</h3>
|
||||
<button className="btn" onClick={() => qc.invalidateQueries({ queryKey: ["hyread-scan"] })}>
|
||||
重新掃描
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="cm-card">
|
||||
<h3>上傳 book.html</h3>
|
||||
<p className="text-dim">從 hyread-tools/output/ 取得 book.html 後在此上傳</p>
|
||||
<input
|
||||
type="file"
|
||||
accept=".html"
|
||||
onChange={async (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
const text = await file.text();
|
||||
const title = file.name.replace(/\.html$/, "");
|
||||
setImportStatus("上傳中...");
|
||||
try {
|
||||
const res = await api.hyreadUpload(title, text);
|
||||
setImportStatus(`成功: ${res.title} (${res.principleCount} 原則, ${res.termCount} 名詞)`);
|
||||
qc.invalidateQueries({ queryKey: ["content"] });
|
||||
} catch (err: any) {
|
||||
setImportStatus(`錯誤: ${err.message}`);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{importStatus && <div className={`cm-status ${importStatus.startsWith("錯誤") ? "error" : "ok"}`}>{importStatus}</div>}
|
||||
</div>
|
||||
|
||||
<div className="cm-card">
|
||||
<h3>已掃描書籍</h3>
|
||||
{(hyreadQ.data?.books || []).length === 0 && <div className="text-dim">尚未掃描到書籍</div>}
|
||||
{(hyreadQ.data?.books || []).map((book: any) => (
|
||||
<div key={book.dir} className="cs-row">
|
||||
<div className="cs-info">
|
||||
<div className="cs-title">{book.title}</div>
|
||||
<div className="cs-meta">
|
||||
<span>{book.pageCount} 頁</span>
|
||||
<span>{book.metadata?.author || ""}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="cs-actions">
|
||||
<button
|
||||
className="btn btn-sm"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await api.hyreadImport(book.dir);
|
||||
qc.invalidateQueries({ queryKey: ["content"] });
|
||||
qc.invalidateQueries({ queryKey: ["hyread-scan"] });
|
||||
} catch (err: any) {
|
||||
alert(err.message);
|
||||
}
|
||||
}}
|
||||
>
|
||||
匯入
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{tab === "schedule" && (
|
||||
<section>
|
||||
<div className="cm-card">
|
||||
<h3>新增排程</h3>
|
||||
<div className="cm-input-row">
|
||||
<input
|
||||
type="text"
|
||||
className="cm-input"
|
||||
placeholder="顯示名稱"
|
||||
value={scheduleName}
|
||||
onChange={e => setScheduleName(e.target.value)}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
className="cm-input"
|
||||
placeholder="YouTube 頻道/播放清單 URL"
|
||||
value={scheduleUrl}
|
||||
onChange={e => setScheduleUrl(e.target.value)}
|
||||
/>
|
||||
<select value={scheduleInterval} onChange={e => setScheduleInterval(Number(e.target.value))}>
|
||||
<option value={1440}>每天</option>
|
||||
<option value={10080}>每週</option>
|
||||
<option value={43200}>每月</option>
|
||||
</select>
|
||||
<button
|
||||
className="btn btn-sm"
|
||||
onClick={() => scheduleMutation.mutate()}
|
||||
disabled={!scheduleName.trim() || !scheduleUrl.trim()}
|
||||
>
|
||||
新增
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="cm-card">
|
||||
<h3>現有排程</h3>
|
||||
{(schedulesQ.data?.schedules || []).length === 0 && <div className="text-dim">尚無排程</div>}
|
||||
{(schedulesQ.data?.schedules || []).map((s: any) => (
|
||||
<div key={s.id} className="cs-row">
|
||||
<div className="cs-info">
|
||||
<div className="cs-title">{s.name}</div>
|
||||
<div className="cs-meta">
|
||||
<span>{s.interval_min >= 43200 ? "每月" : s.interval_min >= 10080 ? "每週" : "每天"}</span>
|
||||
<span>{s.enabled ? "啟用" : "停用"}</span>
|
||||
{s.last_checked_at && <span>上次檢查: {new Date(s.last_checked_at).toLocaleDateString()}</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="cs-actions">
|
||||
<button className="btn btn-sm btn-ghost" onClick={() => api.deleteContentSchedule(s.id).then(() => qc.invalidateQueries({ queryKey: ["content-schedules"] }))}>刪除</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -148,6 +148,8 @@ export default function Home() {
|
|||
</Card>
|
||||
</div>
|
||||
|
||||
<ContentWidget />
|
||||
|
||||
<h3 className="pixel mt page-section-title" style={{ marginBottom: 12 }}>
|
||||
<AppIcon name="map" size={26} framed variant="hero" />
|
||||
前往各區域
|
||||
|
|
@ -306,6 +308,42 @@ function IndicatorBars({ q }: { q: { data?: WeathervanePayload; isLoading: boole
|
|||
);
|
||||
}
|
||||
|
||||
function ContentWidget() {
|
||||
const q = useQuery({
|
||||
queryKey: ["content-sources"],
|
||||
queryFn: () => api.contentSources(),
|
||||
staleTime: 30000,
|
||||
});
|
||||
const sources = q.data?.sources;
|
||||
if (q.isLoading || q.error || !sources?.length) return null;
|
||||
const items = sources.slice(0, 4);
|
||||
return (
|
||||
<Card
|
||||
title="最新知識庫內容"
|
||||
ico={<AppIcon name="book" size={24} framed variant="nav" />}
|
||||
className="mt"
|
||||
>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
{items.map((item: any) => (
|
||||
<Link
|
||||
key={item.id}
|
||||
to={item.kind === "youtube" ? `/library/note/${item.id}` : `/library/note/${item.id}`}
|
||||
className="lc-item"
|
||||
style={{ display: "flex", alignItems: "center", gap: 8 }}
|
||||
>
|
||||
<span className={`tag tag-${item.kind}`}>{item.kind}</span>
|
||||
<span style={{ flex: 1 }}>{item.title || item.url}</span>
|
||||
<span className="small muted">{new Date(item.created_at).toLocaleDateString()}</span>
|
||||
</Link>
|
||||
))}
|
||||
<Link to="/content" className="small" style={{ textAlign: "right", marginTop: 4 }}>
|
||||
管理內容 →
|
||||
</Link>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function CalendarBlock({ q }: { q: { data?: CalendarPayload; isLoading: boolean; error: unknown } }) {
|
||||
if (q.isLoading) return <Loading />;
|
||||
if (q.error || !q.data) return <ErrorState detail={(q.error as Error)?.message} />;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,279 @@
|
|||
import { useState, useMemo } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Link, useSearchParams } from "react-router-dom";
|
||||
import { api } from "../lib/api";
|
||||
import type { KnowledgePayload, KnowledgeNote, KnowledgePrinciple } from "../lib/api";
|
||||
import { RouteIcon } from "../components/PixelIcons";
|
||||
|
||||
const TYPE_LABELS: Record<string, string> = {
|
||||
overview: "總覽",
|
||||
principleMap: "心法地圖",
|
||||
quiz: "練習題庫",
|
||||
category: "學習分類",
|
||||
case: "案例講解",
|
||||
term: "名詞",
|
||||
company: "公司",
|
||||
episode: "單集",
|
||||
principle: "心法原則",
|
||||
};
|
||||
|
||||
const TYPE_ICONS: Record<string, string> = {
|
||||
category: "book",
|
||||
case: "sword",
|
||||
term: "flow",
|
||||
company: "castle",
|
||||
episode: "world",
|
||||
principle: "scroll",
|
||||
};
|
||||
|
||||
function highlightText(text: string, query: string) {
|
||||
if (!query.trim()) return text;
|
||||
const parts = text.split(new RegExp(`(${query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'gi'));
|
||||
return parts.map((p, i) =>
|
||||
p.toLowerCase() === query.toLowerCase() ? <mark key={i} className="hl">{p}</mark> : p
|
||||
);
|
||||
}
|
||||
|
||||
function PrincipleCard({ p, query }: { p: KnowledgePrinciple; query: string }) {
|
||||
return (
|
||||
<Link to={`/library/principle/${encodeURIComponent(p.id)}`} className="lc-card">
|
||||
<div className="lc-card-icon">
|
||||
<RouteIcon to="/skills" size={20} />
|
||||
</div>
|
||||
<div className="lc-card-body">
|
||||
<div className="lc-card-title">{highlightText(p.title, query)}</div>
|
||||
{p.summary && <div className="lc-card-summary">{highlightText(p.summary.slice(0, 120), query)}</div>}
|
||||
<div className="lc-card-meta">
|
||||
<span className="tag tag-principle">心法</span>
|
||||
{p.num && <span className="tag">#{p.num}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function NoteCard({ note, kind, query }: { note: KnowledgeNote; kind: string; query: string }) {
|
||||
const icon = TYPE_ICONS[kind] || "book";
|
||||
return (
|
||||
<Link to={`/library/${kind}/${encodeURIComponent(note.id)}`} className="lc-card">
|
||||
<div className="lc-card-icon">
|
||||
<RouteIcon to={kind === "case" ? "/skills" : kind === "company" ? "/research" : "/"} size={20} />
|
||||
</div>
|
||||
<div className="lc-card-body">
|
||||
<div className="lc-card-title">{highlightText(note.title, query)}</div>
|
||||
{note.summary && <div className="lc-card-summary">{highlightText(note.summary.slice(0, 120), query)}</div>}
|
||||
<div className="lc-card-meta">
|
||||
<span className={`tag tag-${kind}`}>{TYPE_LABELS[kind] || kind}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function IndexItemRow({ item, query }: { item: NonNullable<KnowledgePayload["index"]>[number]; query: string }) {
|
||||
const kind = item.kind?.split(":")[0] || "term";
|
||||
const link = kind === "company" ? `/library/company/${item.id}`
|
||||
: kind === "episode" ? `/library/episode/${item.id}`
|
||||
: `/library/term/${item.id}`;
|
||||
return (
|
||||
<Link to={link} className="lc-index-row">
|
||||
<span className={`tag tag-${kind}`}>{TYPE_LABELS[kind] || kind}</span>
|
||||
<span>{highlightText(item.title, query)}</span>
|
||||
{item.aliases && item.aliases.length > 0 && (
|
||||
<span className="lc-aliases">{item.aliases.join(", ")}</span>
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Library() {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const query = searchParams.get("q") || "";
|
||||
const typeFilter = searchParams.get("type") || "";
|
||||
const [showFilters, setShowFilters] = useState(false);
|
||||
|
||||
const kbQ = useQuery({ queryKey: ["knowledge"], queryFn: api.knowledge, staleTime: 60_000 });
|
||||
|
||||
const data = kbQ.data;
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (!data) return null;
|
||||
const q = query.toLowerCase().trim();
|
||||
|
||||
const matchText = (text: string) => !q || text.toLowerCase().includes(q);
|
||||
const matchType = (t: string) => !typeFilter || t === typeFilter;
|
||||
|
||||
const out: {
|
||||
principles: KnowledgePrinciple[];
|
||||
categories: KnowledgeNote[];
|
||||
cases: KnowledgeNote[];
|
||||
terms: { id: string; title: string; body?: string }[];
|
||||
companies: { id: string; title: string; body?: string }[];
|
||||
episodes: { id: string; title: string; body?: string }[];
|
||||
index: NonNullable<KnowledgePayload["index"]>;
|
||||
} = { principles: [], categories: [], cases: [], terms: [], companies: [], episodes: [], index: [] };
|
||||
|
||||
if (matchType("principle") || !typeFilter) {
|
||||
out.principles = (data.principles || []).filter(p => matchText(p.title) || matchText(p.summary || "") || matchText(p.body));
|
||||
}
|
||||
|
||||
if (matchType("category") || !typeFilter) {
|
||||
out.categories = (data.categories || []).filter(c => matchText(c.title) || matchText(c.summary || ""));
|
||||
}
|
||||
|
||||
if (matchType("case") || !typeFilter) {
|
||||
out.cases = (data.cases || []).filter(c => matchText(c.title) || matchText(c.summary || ""));
|
||||
}
|
||||
|
||||
if (data.index) {
|
||||
out.index = data.index.filter(item => {
|
||||
const kind = item.kind?.split(":")[0] || "term";
|
||||
return matchType(kind) && (matchText(item.title) || (item.aliases || []).some(a => a.toLowerCase().includes(q)));
|
||||
});
|
||||
}
|
||||
|
||||
return out;
|
||||
}, [data, query, typeFilter]);
|
||||
|
||||
if (kbQ.isLoading) return <div className="page"><div className="loading">載入知識庫中...</div></div>;
|
||||
if (kbQ.error) return <div className="page"><div className="error">載入失敗</div></div>;
|
||||
|
||||
const hasData = data && (data.principles?.length || data.categories?.length || data.cases?.length || data.index?.length);
|
||||
|
||||
return (
|
||||
<div className="page library-page">
|
||||
<div className="page-head">
|
||||
<h1>知識圖書館</h1>
|
||||
<p className="page-subtitle">瀏覽所有心法、案例、名詞、公司研究與單集筆記</p>
|
||||
</div>
|
||||
|
||||
<div className="lc-toolbar">
|
||||
<input
|
||||
type="text"
|
||||
className="lc-search"
|
||||
placeholder="搜尋知識庫..."
|
||||
value={query}
|
||||
onChange={e => {
|
||||
const p = new URLSearchParams(searchParams);
|
||||
if (e.target.value) p.set("q", e.target.value); else p.delete("q");
|
||||
setSearchParams(p);
|
||||
}}
|
||||
/>
|
||||
<button className="btn btn-sm" onClick={() => setShowFilters(!showFilters)}>
|
||||
{typeFilter ? `${TYPE_LABELS[typeFilter] || typeFilter} ✕` : "過濾器 ▾"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showFilters && (
|
||||
<div className="lc-filters">
|
||||
{["", "principle", "category", "case", "term", "company", "episode"].map(t => (
|
||||
<button
|
||||
key={t}
|
||||
className={`btn btn-sm ${typeFilter === t ? "btn-active" : ""}`}
|
||||
onClick={() => {
|
||||
const p = new URLSearchParams(searchParams);
|
||||
if (t) p.set("type", t); else p.delete("type");
|
||||
setSearchParams(p);
|
||||
}}
|
||||
>
|
||||
{TYPE_LABELS[t] || "全部"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data?.overview && !query && !typeFilter && (
|
||||
<section className="lc-section">
|
||||
<h2 className="lc-section-title">
|
||||
<RouteIcon to="/" size={18} /> 總覽
|
||||
</h2>
|
||||
<div className="lc-overview-preview">
|
||||
<div className="markdown-body">{data.overview.summary || data.overview.body?.slice(0, 300)}...</div>
|
||||
<Link to={`/library/overview`} className="btn btn-sm">閱讀完整課綱 →</Link>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{!hasData && (
|
||||
<div className="lc-empty">
|
||||
<div className="lc-empty-icon">📚</div>
|
||||
<p>知識庫尚未建立</p>
|
||||
<p className="text-dim">請先執行 <code>npm run build:knowledge</code> 或從 YouTube/HyRead 匯入內容</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{filtered && (
|
||||
<>
|
||||
{filtered.principles.length > 0 && (
|
||||
<section className="lc-section">
|
||||
<h2 className="lc-section-title">
|
||||
<RouteIcon to="/skills" size={18} /> 心法原則 ({filtered.principles.length})
|
||||
</h2>
|
||||
<div className="lc-grid">
|
||||
{filtered.principles.map(p => <PrincipleCard key={p.id} p={p} query={query} />)}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{filtered.categories.length > 0 && (
|
||||
<section className="lc-section">
|
||||
<h2 className="lc-section-title">
|
||||
<RouteIcon to="/" size={18} /> 學習分類 ({filtered.categories.length})
|
||||
</h2>
|
||||
<div className="lc-grid">
|
||||
{filtered.categories.map(c => <NoteCard key={c.id} note={c} kind="category" query={query} />)}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{filtered.cases.length > 0 && (
|
||||
<section className="lc-section">
|
||||
<h2 className="lc-section-title">
|
||||
<RouteIcon to="/skills" size={18} /> 案例講解 ({filtered.cases.length})
|
||||
</h2>
|
||||
<div className="lc-grid">
|
||||
{filtered.cases.map(c => <NoteCard key={c.id} note={c} kind="case" query={query} />)}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{filtered.index.length > 0 && (
|
||||
<section className="lc-section">
|
||||
<h2 className="lc-section-title">
|
||||
<RouteIcon to="/" size={18} /> 名詞 · 公司 · 單集 ({filtered.index.length})
|
||||
</h2>
|
||||
<div className="lc-index-list">
|
||||
{filtered.index.slice(0, 100).map((item, i) => (
|
||||
<IndexItemRow key={`${item.kind}:${item.id}:${i}`} item={item} query={query} />
|
||||
))}
|
||||
{filtered.index.length > 100 && (
|
||||
<div className="text-dim">...還有 {filtered.index.length - 100} 項</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{!filtered.principles.length && !filtered.categories.length && !filtered.cases.length && !filtered.index.length && query && (
|
||||
<div className="lc-empty">
|
||||
<p>搜尋「{query}」沒有結果</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{data?.counts && (
|
||||
<section className="lc-section lc-stats">
|
||||
<h2 className="lc-section-title">知識庫統計</h2>
|
||||
<div className="lc-stat-grid">
|
||||
{Object.entries(data.counts).map(([k, v]) => (
|
||||
<div key={k} className="lc-stat-card">
|
||||
<div className="lc-stat-num">{v}</div>
|
||||
<div className="lc-stat-label">{TYPE_LABELS[k] || k}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,114 @@
|
|||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useParams, Link } from "react-router-dom";
|
||||
import { api } from "../lib/api";
|
||||
|
||||
const TYPE_LABELS: Record<string, string> = {
|
||||
overview: "總覽",
|
||||
principleMap: "心法地圖",
|
||||
quiz: "練習題庫",
|
||||
category: "學習分類",
|
||||
case: "案例講解",
|
||||
term: "名詞",
|
||||
company: "公司",
|
||||
episode: "單集",
|
||||
principle: "心法原則",
|
||||
};
|
||||
|
||||
export default function LibraryDetail() {
|
||||
const { kind, id } = useParams<{ kind: string; id: string }>();
|
||||
const kbQ = useQuery({ queryKey: ["knowledge"], queryFn: api.knowledge, staleTime: 60_000 });
|
||||
const noteQ = useQuery({
|
||||
queryKey: ["note", kind, id],
|
||||
queryFn: () => api.knowledgeNote(kind || "", id || ""),
|
||||
enabled: !!kind && !!id && kind !== "principle",
|
||||
});
|
||||
|
||||
if (kbQ.isLoading) return <div className="page"><div className="loading">載入中...</div></div>;
|
||||
|
||||
const data = kbQ.data;
|
||||
|
||||
let title = id || "";
|
||||
let body = "";
|
||||
let extraMeta: Record<string, any> | null = null;
|
||||
|
||||
if (kind === "principle") {
|
||||
const p = data?.principles?.find(p => p.id === id || encodeURIComponent(p.id) === id);
|
||||
if (p) {
|
||||
title = p.title;
|
||||
body = p.body;
|
||||
extraMeta = { num: p.num, summary: p.summary };
|
||||
}
|
||||
} else if (kind === "overview" && data?.overview) {
|
||||
title = data.overview.title;
|
||||
body = data.overview.body;
|
||||
} else if (kind === "principleMap" && data?.principleMap) {
|
||||
title = data.principleMap.title;
|
||||
body = data.principleMap.body;
|
||||
} else if (kind === "quiz" && data?.quiz) {
|
||||
title = data.quiz.title;
|
||||
body = data.quiz.body;
|
||||
} else if (kind === "category") {
|
||||
const c = data?.categories?.find(c => c.id === id || encodeURIComponent(c.id) === id);
|
||||
if (c) { title = c.title; body = c.body; extraMeta = c.frontmatter || null; }
|
||||
} else if (kind === "case") {
|
||||
const c = data?.cases?.find(c => c.id === id || encodeURIComponent(c.id) === id);
|
||||
if (c) { title = c.title; body = c.body; extraMeta = c.frontmatter || null; }
|
||||
} else if (kind === "term" || kind === "company" || kind === "episode") {
|
||||
if (noteQ.data) {
|
||||
title = noteQ.data.title;
|
||||
body = noteQ.data.body;
|
||||
extraMeta = noteQ.data.frontmatter || null;
|
||||
} else if (data?.index) {
|
||||
const item = data.index.find(i => i.id === id && i.kind?.startsWith(kind || ""));
|
||||
if (item) title = item.title;
|
||||
if (!body) body = `# ${title}\n\n(尚未擷取全文,請執行 npm run build:knowledge 更新)`;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="page library-detail-page">
|
||||
<div className="page-head">
|
||||
<Link to="/library" className="btn btn-sm">← 回圖書館</Link>
|
||||
<h1>{title}</h1>
|
||||
<p className="page-subtitle">{TYPE_LABELS[kind || ""] || kind}</p>
|
||||
{extraMeta && (
|
||||
<div className="ld-meta">
|
||||
{Object.entries(extraMeta).map(([k, v]) =>
|
||||
v != null && <span key={k} className="tag">{k}: {String(v).slice(0, 60)}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="ld-body markdown-body">
|
||||
{body ? (
|
||||
<RenderMarkdown text={body} />
|
||||
) : (
|
||||
<div className="text-dim">暫無內容</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RenderMarkdown({ text }: { text: string }) {
|
||||
const lines = text.split("\n");
|
||||
const html = lines
|
||||
.map(line => {
|
||||
if (line.startsWith("### ")) return `<h3>${escapeHtml(line.slice(4))}</h3>`;
|
||||
if (line.startsWith("## ")) return `<h2>${escapeHtml(line.slice(3))}</h2>`;
|
||||
if (line.startsWith("# ")) return `<h1>${escapeHtml(line.slice(2))}</h1>`;
|
||||
if (line.startsWith("> ")) return `<blockquote>${escapeHtml(line.slice(2))}</blockquote>`;
|
||||
if (line.startsWith("- ") || line.startsWith("* ")) return `<li>${escapeHtml(line.slice(2))}</li>`;
|
||||
if (line.trim() === "") return "<br/>";
|
||||
if (line.match(/^\d+\.\s/)) return `<li>${escapeHtml(line.replace(/^\d+\.\s/, ""))}</li>`;
|
||||
return `<p>${escapeHtml(line)}</p>`;
|
||||
})
|
||||
.join("\n");
|
||||
|
||||
return <div dangerouslySetInnerHTML={{ __html: html }} />;
|
||||
}
|
||||
|
||||
function escapeHtml(s: string) {
|
||||
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { AttributeRadar } from "../components/AttributeRadar";
|
||||
import { AppIcon } from "../components/PixelIcons";
|
||||
import { Card, ErrorState, Loading, Tag } from "../components/ui";
|
||||
|
|
@ -502,6 +502,39 @@ export default function Profile() {
|
|||
{resetBusy ? "重置中…" : "清空角色養成"}
|
||||
</button>
|
||||
</Card>
|
||||
|
||||
<Card title="內容統計" ico={<AppIcon name="folder" size={22} framed variant="nav" />} className="mt">
|
||||
<ContentReadingStats />
|
||||
</Card>
|
||||
|
||||
<Card title="重置角色" ico={<AppIcon name="hourglass" size={22} framed variant="nav" />} className="mt">
|
||||
<p className="small muted" style={{ marginTop: 0 }}>
|
||||
從 Lv.1 重新開始學習路線。不會刪除交易帳號與買賣紀錄,但會清掉復盤心得、心法標註等養成資料。
|
||||
</p>
|
||||
{resetMsg ? <p className="small" style={{ color: "var(--teal)" }}>{resetMsg}</p> : null}
|
||||
<button type="button" className="btn-ghost sm" disabled={resetBusy} onClick={() => void resetCharacter()}>
|
||||
{resetBusy ? "重置中…" : "清空角色養成"}
|
||||
</button>
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ContentReadingStats() {
|
||||
const q = useQuery({
|
||||
queryKey: ["content-stats"],
|
||||
queryFn: () => api.contentStats(),
|
||||
staleTime: 30000,
|
||||
});
|
||||
if (!q.data) return null;
|
||||
const stats = q.data;
|
||||
const total = stats.total || 0;
|
||||
if (!total) return <p className="muted small">尚未從 YouTube 或 HyRead 匯入內容</p>;
|
||||
return (
|
||||
<div className="dl">
|
||||
<div className="row"><span className="k">YouTube 影片</span><span className="v">{stats.byKind?.youtube?.ready || 0} 部</span></div>
|
||||
<div className="row"><span className="k">HyRead 書籍</span><span className="v">{stats.byKind?.hyread?.ready || 0} 本</span></div>
|
||||
<div className="row"><span className="k">知識庫總計</span><span className="v">{total} 個來源</span></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -26,6 +26,21 @@ import {
|
|||
type SkillTreeNode,
|
||||
} from "../lib/skillTree";
|
||||
|
||||
const SOURCE_TYPES = [
|
||||
{ value: "", label: "全部來源" },
|
||||
{ value: "youtube", label: "YouTube" },
|
||||
{ value: "hyread", label: "HyRead" },
|
||||
{ value: "system", label: "系統" },
|
||||
];
|
||||
|
||||
const MASTERY_FILTERS = [
|
||||
{ value: "", label: "全部狀態" },
|
||||
{ value: "mastered", label: "已精通" },
|
||||
{ value: "drilled", label: "已試煉" },
|
||||
{ value: "read", label: "已閱讀" },
|
||||
{ value: "unread", label: "未閱讀" },
|
||||
];
|
||||
|
||||
export default function Skills() {
|
||||
const qc = useQueryClient();
|
||||
const kbQ = useQuery({ queryKey: ["knowledge"], queryFn: api.knowledge, staleTime: 3600_000 });
|
||||
|
|
@ -53,6 +68,8 @@ export default function Skills() {
|
|||
const [openTab, setOpenTab] = useState<"detail" | "drill" | "records" | "coach" | "notes">("detail");
|
||||
const [notesRevision, setNotesRevision] = useState(0);
|
||||
const [query, setQuery] = useState("");
|
||||
const [sourceFilter, setSourceFilter] = useState("");
|
||||
const [masteryFilter, setMasteryFilter] = useState("");
|
||||
const [collapsed, setCollapsed] = useState<Record<string, boolean>>({});
|
||||
|
||||
const principles = kbQ.data?.principles || [];
|
||||
|
|
@ -60,7 +77,41 @@ export default function Skills() {
|
|||
() => buildSkillTree(principles, kbQ.data?.principleMap?.body),
|
||||
[principles, kbQ.data?.principleMap?.body],
|
||||
);
|
||||
const filtered = useMemo(() => filterSkillTree(groups, query), [groups, query]);
|
||||
const filtered = useMemo(() => {
|
||||
let result = filterSkillTree(groups, query);
|
||||
|
||||
if (sourceFilter) {
|
||||
result = result.map(g => ({
|
||||
...g,
|
||||
nodes: g.nodes.filter(n => {
|
||||
const body = n.principle.body || "";
|
||||
const sourceMatch = body.match(/> 來源:(\w+)/);
|
||||
return sourceMatch?.[1] === sourceFilter;
|
||||
}),
|
||||
})).filter(g => g.nodes.length > 0);
|
||||
}
|
||||
|
||||
if (masteryFilter) {
|
||||
result = result.map(g => ({
|
||||
...g,
|
||||
nodes: g.nodes.filter(n => {
|
||||
const p = progress[n.principle.id];
|
||||
const mastered = p?.mastered;
|
||||
const hasDrill = (p?.drillAttempts || 0) > 0;
|
||||
const hasRead = (p?.reads || 0) > 0;
|
||||
switch (masteryFilter) {
|
||||
case "mastered": return mastered;
|
||||
case "drilled": return !mastered && hasDrill;
|
||||
case "read": return !mastered && !hasDrill && hasRead;
|
||||
case "unread": return !hasRead;
|
||||
default: return true;
|
||||
}
|
||||
}),
|
||||
})).filter(g => g.nodes.length > 0);
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [groups, query, sourceFilter, masteryFilter, progress]);
|
||||
const stats = useMemo(() => countProgress(principles, progress), [principles, progress]);
|
||||
const achievement = nextAchievement(stats.read, stats.mastered, stats.total);
|
||||
const nodeById = useMemo(() => {
|
||||
|
|
@ -187,8 +238,14 @@ export default function Skills() {
|
|||
onChange={(e) => setQuery(e.target.value)}
|
||||
spellCheck={false}
|
||||
/>
|
||||
{query ? (
|
||||
<button type="button" className="btn-ghost sm" onClick={() => setQuery("")}>
|
||||
<select className="skill-filter-select" value={sourceFilter} onChange={e => setSourceFilter(e.target.value)}>
|
||||
{SOURCE_TYPES.map(s => <option key={s.value} value={s.value}>{s.label}</option>)}
|
||||
</select>
|
||||
<select className="skill-filter-select" value={masteryFilter} onChange={e => setMasteryFilter(e.target.value)}>
|
||||
{MASTERY_FILTERS.map(s => <option key={s.value} value={s.value}>{s.label}</option>)}
|
||||
</select>
|
||||
{(query || sourceFilter || masteryFilter) ? (
|
||||
<button type="button" className="btn-ghost sm" onClick={() => { setQuery(""); setSourceFilter(""); setMasteryFilter(""); }}>
|
||||
清除
|
||||
</button>
|
||||
) : null}
|
||||
|
|
|
|||
|
|
@ -3392,6 +3392,343 @@ table.tbl tr:hover td { background: rgba(231,198,107,.05); }
|
|||
.trader-follow-scale { margin-top: 6px; padding: 6px 8px; border-radius: 6px; background: color-mix(in srgb, var(--gold) 10%, var(--panel)); border: 1px dashed color-mix(in srgb, var(--gold) 35%, var(--line)); }
|
||||
|
||||
/* AI 交易員控制台:先摘要與操作,再展開完整資料 */
|
||||
|
||||
/* ─── HD-2D 風格:多分析師裝備介面 ─── */
|
||||
.multi-agent-table { display: flex; flex-direction: column; gap: 10px; margin-top: 10px; }
|
||||
.multi-agent-row {
|
||||
position: relative;
|
||||
padding: 12px 12px 12px 34px;
|
||||
background:
|
||||
radial-gradient(circle at 12px 14px, color-mix(in srgb, var(--teal) 18%, transparent), transparent 18px),
|
||||
color-mix(in srgb, var(--panel) 94%, var(--sky) 4%);
|
||||
border: 1px solid color-mix(in srgb, var(--line) 72%, var(--teal) 18%);
|
||||
border-radius: 10px;
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255,255,255,.035),
|
||||
0 0 12px rgba(111,224,208,.035);
|
||||
}
|
||||
.multi-agent-row::before {
|
||||
content: "";
|
||||
position: absolute; left: 13px; top: 17px;
|
||||
width: 7px; height: 7px;
|
||||
border-radius: 50%;
|
||||
background: color-mix(in srgb, var(--teal) 80%, var(--gold));
|
||||
box-shadow: 0 0 10px rgba(111,224,208,.35);
|
||||
}
|
||||
.multi-agent-row .multi-agent-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-family: var(--pixel);
|
||||
font-size: 12px;
|
||||
letter-spacing: 1.5px;
|
||||
text-transform: uppercase;
|
||||
color: var(--gold);
|
||||
margin-bottom: 8px;
|
||||
text-shadow: 0 0 8px rgba(231,198,107,.2);
|
||||
}
|
||||
.multi-agent-row .grid.g3 { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; }
|
||||
.multi-agent-row .settings-field { margin: 0; }
|
||||
.multi-agent-row .settings-field label {
|
||||
font-family: var(--pixel);
|
||||
font-size: 9px; letter-spacing: 1px;
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
.multi-agent-row select {
|
||||
background: color-mix(in srgb, var(--panel) 88%, #0d1224);
|
||||
border-color: color-mix(in srgb, var(--line) 78%, var(--teal) 16%);
|
||||
color: var(--ink);
|
||||
}
|
||||
.watchlist-group-picker {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
|
||||
gap: 8px;
|
||||
padding: 8px;
|
||||
border: 1px solid color-mix(in srgb, var(--line) 70%, var(--teal) 20%);
|
||||
border-radius: 10px;
|
||||
background:
|
||||
radial-gradient(circle at 18px 16px, color-mix(in srgb, var(--teal) 12%, transparent), transparent 24px),
|
||||
color-mix(in srgb, var(--panel) 92%, var(--sky) 4%);
|
||||
}
|
||||
.watchlist-group-option {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 3px 8px;
|
||||
align-items: start;
|
||||
min-width: 0;
|
||||
padding: 9px 10px;
|
||||
border: 1px solid color-mix(in srgb, var(--line-soft) 85%, var(--teal) 14%);
|
||||
border-radius: 8px;
|
||||
background: rgba(0,0,0,.09);
|
||||
transition: border-color .12s, background .12s, box-shadow .12s;
|
||||
}
|
||||
.watchlist-group-option:has(input:checked) {
|
||||
border-color: color-mix(in srgb, var(--teal) 40%, var(--line));
|
||||
background: color-mix(in srgb, var(--teal) 8%, rgba(0,0,0,.08));
|
||||
box-shadow: inset 0 0 0 1px rgba(111,224,208,.045), 0 0 12px rgba(111,224,208,.055);
|
||||
}
|
||||
.watchlist-group-option input { margin-top: 2px; }
|
||||
.watchlist-group-option span { color: var(--ink); font-weight: 700; min-width: 0; overflow-wrap: anywhere; }
|
||||
.watchlist-group-option small { grid-column: 2; color: var(--ink-soft); line-height: 1.35; overflow-wrap: anywhere; }
|
||||
.agent-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
height: 20px;
|
||||
flex: 0 0 auto;
|
||||
font-family: var(--pixel);
|
||||
font-size: 9px;
|
||||
line-height: 1;
|
||||
color: var(--gold-soft);
|
||||
background: color-mix(in srgb, var(--gold) 10%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--gold) 30%, transparent);
|
||||
border-radius: 3px;
|
||||
box-shadow: inset 0 1px 0 rgba(255,255,255,.04), 0 0 8px rgba(231,198,107,.12);
|
||||
text-shadow: 0 0 5px rgba(231,198,107,.2);
|
||||
}
|
||||
@media (max-width: 640px) { .multi-agent-row .grid.g3 { grid-template-columns: 1fr; } }
|
||||
|
||||
/* ─── HD-2D 風格:性格角色面板 ─── */
|
||||
.personality-dashboard-grid {
|
||||
display: flex; flex-direction: column; gap: 10px;
|
||||
}
|
||||
|
||||
/* 角色名牌區 */
|
||||
.personality-dash-section {
|
||||
position: relative;
|
||||
padding: 14px;
|
||||
background: linear-gradient(160deg, color-mix(in srgb, var(--gold) 5%, var(--paper)), var(--panel) 80%);
|
||||
border: 1px solid color-mix(in srgb, var(--gold) 20%, var(--line));
|
||||
border-radius: var(--r);
|
||||
box-shadow:
|
||||
0 0 0 1px rgba(231,198,107,.06),
|
||||
0 2px 12px rgba(0,0,0,.3),
|
||||
inset 0 1px 0 rgba(255,255,255,.03);
|
||||
}
|
||||
.personality-dash-section::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: -7px; left: 12px;
|
||||
width: 8px; height: 8px;
|
||||
background: color-mix(in srgb, var(--gold) 35%, transparent);
|
||||
box-shadow: 0 0 6px rgba(231,198,107,.2);
|
||||
}
|
||||
.personality-dash-section::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: -7px; right: 12px;
|
||||
width: 8px; height: 8px;
|
||||
background: color-mix(in srgb, var(--gold) 35%, transparent);
|
||||
box-shadow: 0 0 6px rgba(231,198,107,.2);
|
||||
}
|
||||
|
||||
/* 角色名 */
|
||||
.personality-dash-head {
|
||||
display: flex; flex-wrap: wrap; align-items: baseline; gap: 6px 10px;
|
||||
padding-bottom: 8px;
|
||||
margin-bottom: 8px;
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--gold) 15%, var(--line));
|
||||
}
|
||||
.personality-dash-head strong {
|
||||
font-family: var(--pixel);
|
||||
font-size: 16px;
|
||||
color: var(--gold-soft);
|
||||
text-shadow: 0 0 12px rgba(231,198,107,.25), 0 1px 0 rgba(0,0,0,.5);
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
.personality-dash-head .small.muted {
|
||||
font-family: var(--pixel);
|
||||
font-size: 10px;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
.personality-dash-section > .small.muted:first-child {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
font-family: var(--pixel);
|
||||
font-size: 10px;
|
||||
letter-spacing: 2px;
|
||||
text-transform: uppercase;
|
||||
color: var(--ink-soft);
|
||||
text-shadow: 0 0 6px rgba(255,255,255,.05);
|
||||
}
|
||||
|
||||
/* 行為描述-像 RPG 角色背景 */
|
||||
.personality-dash-section > p.small.muted:nth-child(2) {
|
||||
font-style: italic;
|
||||
color: color-mix(in srgb, var(--ink-soft) 80%, var(--gold-soft));
|
||||
line-height: 1.5;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
/* 職業標籤列 */
|
||||
.personality-dash-head .trader-risk-pill {
|
||||
font-family: var(--pixel);
|
||||
font-size: 9px;
|
||||
letter-spacing: 1px;
|
||||
padding: 2px 10px;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--gold) 12%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--gold) 25%, transparent);
|
||||
color: var(--gold-soft);
|
||||
}
|
||||
|
||||
/* HP 式風險條 */
|
||||
.personality-hp-bar {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex: 1;
|
||||
height: 10px;
|
||||
}
|
||||
.personality-hp-fill {
|
||||
display: block;
|
||||
height: 6px;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(90deg, var(--emerald), var(--gold) 50%, var(--crimson));
|
||||
box-shadow: 0 0 6px rgba(231,198,107,.2);
|
||||
transition: width .3s ease;
|
||||
}
|
||||
.personality-dash-section > p.small[data-pct] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-family: var(--pixel);
|
||||
font-size: 10px;
|
||||
color: var(--muted);
|
||||
margin-bottom: 8px;
|
||||
padding: 4px 0;
|
||||
}
|
||||
.personality-dash-section > p.small[data-pct]::before {
|
||||
content: "LV";
|
||||
font-family: var(--pixel);
|
||||
font-size: 9px;
|
||||
color: var(--gold-soft);
|
||||
text-shadow: 0 0 6px rgba(231,198,107,.3);
|
||||
opacity: .8;
|
||||
}
|
||||
|
||||
/* 資產親和 ─ 元素石 */
|
||||
.personality-assets {
|
||||
display: flex; flex-wrap: wrap; align-items: center; gap: 6px;
|
||||
margin-top: 6px; padding-top: 8px;
|
||||
border-top: 1px solid color-mix(in srgb, var(--gold) 10%, var(--line));
|
||||
}
|
||||
.personality-assets > .small.muted {
|
||||
font-family: var(--pixel);
|
||||
font-size: 9px;
|
||||
letter-spacing: 1px;
|
||||
color: var(--ink-soft);
|
||||
margin-right: 4px;
|
||||
}
|
||||
.personality-assets .trader-personality-asset-chip {
|
||||
font-family: var(--pixel);
|
||||
font-size: 10px;
|
||||
padding: 3px 10px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid color-mix(in srgb, var(--gold) 20%, transparent);
|
||||
background: color-mix(in srgb, var(--gold) 8%, transparent);
|
||||
letter-spacing: .5px;
|
||||
color: var(--gold-soft);
|
||||
text-shadow: 0 0 6px rgba(231,198,107,.15);
|
||||
}
|
||||
.personality-assets .trader-personality-asset-chip.w-primary {
|
||||
border-color: color-mix(in srgb, var(--gold) 40%, transparent);
|
||||
background: color-mix(in srgb, var(--gold) 14%, transparent);
|
||||
}
|
||||
.personality-assets .trader-personality-asset-chip em {
|
||||
font-style: normal;
|
||||
margin-right: 3px;
|
||||
opacity: .7;
|
||||
}
|
||||
|
||||
/* ─── 技能(交易規則)面板 ─── */
|
||||
.personality-rule-group {
|
||||
margin-top: 8px;
|
||||
padding: 6px 8px 6px 10px;
|
||||
border-left: 2px solid color-mix(in srgb, var(--gold) 20%, transparent);
|
||||
background: color-mix(in srgb, var(--ink) 5%, transparent);
|
||||
border-radius: 0 var(--r-sm) var(--r-sm) 0;
|
||||
}
|
||||
.personality-rule-group .personality-rule-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-family: var(--pixel);
|
||||
font-size: 10px;
|
||||
letter-spacing: 1.5px;
|
||||
text-transform: uppercase;
|
||||
color: var(--gold-soft);
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.personality-rule-group .personality-rule-label::before {
|
||||
content: "";
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 1px;
|
||||
background: var(--gold);
|
||||
opacity: .55;
|
||||
box-shadow: 0 0 5px rgba(231,198,107,.2);
|
||||
}
|
||||
.personality-rule-group[data-cat="exit"] .personality-rule-label::before { background: var(--sky); }
|
||||
.personality-rule-group[data-cat="sizing"] .personality-rule-label::before { background: var(--emerald); }
|
||||
.personality-rule-group[data-cat="forbidden"] .personality-rule-label::before { background: var(--crimson); }
|
||||
.personality-rule-group ul {
|
||||
margin: 2px 0 2px 14px;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.personality-rule-group ul li {
|
||||
position: relative;
|
||||
padding-left: 14px;
|
||||
margin-bottom: 1px;
|
||||
color: color-mix(in srgb, var(--ink-soft) 85%, var(--gold-soft));
|
||||
}
|
||||
.personality-rule-group ul li::before {
|
||||
content: "·";
|
||||
position: absolute; left: 2px;
|
||||
color: var(--gold);
|
||||
opacity: .5;
|
||||
}
|
||||
|
||||
/* ─── 分析師小隊(party 裝備) ─── */
|
||||
.personality-agent-models {
|
||||
display: flex; flex-direction: column; gap: 3px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.personality-agent-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-family: var(--pixel);
|
||||
font-size: 11px;
|
||||
padding: 5px 8px;
|
||||
border-radius: 4px;
|
||||
background: color-mix(in srgb, var(--ink) 8%, transparent);
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--gold) 8%, transparent);
|
||||
letter-spacing: .5px;
|
||||
}
|
||||
.personality-agent-row:last-child { border-bottom: none; }
|
||||
.personality-agent-row strong {
|
||||
color: var(--gold-soft);
|
||||
text-shadow: 0 0 6px rgba(231,198,107,.15);
|
||||
}
|
||||
.personality-agent-row .muted {
|
||||
color: var(--ink-soft);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.personality-agent-row span:first-child {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
|
||||
.ai-command-card { border-color: color-mix(in srgb, var(--teal) 42%, var(--line)); }
|
||||
.ai-command-grid {
|
||||
display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 8px;
|
||||
|
|
@ -3459,15 +3796,35 @@ table.tbl tr:hover td { background: rgba(231,198,107,.05); }
|
|||
.trader-effective-clauses { margin-top: 8px; padding: 8px 9px; border-left: 2px solid var(--rar-epic); background: rgba(201,139,255,.035); }
|
||||
.trader-effective-clauses > span { font-family: var(--pixel); font-size: 8px; color: var(--rar-epic); }
|
||||
.trader-effective-clauses ul { margin: 6px 0 0; padding-left: 17px; font-size: 10px; line-height: 1.5; color: var(--ink-soft); }
|
||||
.trader-universe-preview {
|
||||
margin-top: 12px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid color-mix(in srgb, var(--line) 72%, var(--teal) 18%);
|
||||
background:
|
||||
radial-gradient(circle at 18px 18px, color-mix(in srgb, var(--teal) 13%, transparent), transparent 30px),
|
||||
color-mix(in srgb, var(--panel) 94%, var(--sky) 4%);
|
||||
box-shadow: inset 0 1px 0 rgba(255,255,255,.025), 0 0 16px rgba(111,224,208,.035);
|
||||
}
|
||||
.trader-universe-head { display: flex; justify-content: space-between; align-items: flex-start; gap: 10px; margin-bottom: 8px; }
|
||||
.trader-universe-head span:first-child { display: block; font-family: var(--pixel); font-size: 9px; letter-spacing: 1.5px; color: var(--ink-soft); text-transform: uppercase; }
|
||||
.trader-universe-head strong { display: block; margin-top: 2px; color: var(--gold-soft); }
|
||||
.trader-universe-groups { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; }
|
||||
.trader-universe-group { min-width: 0; padding: 8px 9px; border: 1px solid var(--line-soft); border-radius: 7px; background: rgba(0,0,0,.10); }
|
||||
.trader-universe-group > span { display: block; margin-bottom: 5px; font-family: var(--pixel); font-size: 9px; letter-spacing: 1px; color: var(--ink-soft); }
|
||||
.trader-universe-chips { display: flex; flex-wrap: wrap; gap: 4px; }
|
||||
.trader-universe-chips strong { font-size: 11px; padding: 2px 7px; border-radius: 999px; border: 1px solid color-mix(in srgb, var(--teal) 22%, var(--line-soft)); background: color-mix(in srgb, var(--teal) 7%, transparent); color: var(--ink); }
|
||||
.trader-universe-chips em { font-size: 11px; color: var(--muted); font-style: normal; }
|
||||
@media (max-width: 760px) { .trader-universe-groups { grid-template-columns: 1fr; } }
|
||||
.ai-command-actions {
|
||||
display: flex; align-items: center; justify-content: space-between; gap: 14px;
|
||||
display: flex; align-items: center; justify-content: space-between; gap: 14px; flex-wrap: wrap;
|
||||
margin-top: 12px; padding: 10px 12px; border: 1px solid var(--line);
|
||||
border-radius: var(--r-sm); background: rgba(231,198,107,.05);
|
||||
}
|
||||
.ai-command-actions > div:first-child { min-width: 170px; }
|
||||
.ai-command-actions > div:first-child { min-width: min(220px, 100%); flex: 1 1 220px; }
|
||||
.ai-command-actions strong { display: block; font-family: var(--pixel); font-size: 11px; color: var(--gold-soft); }
|
||||
.ai-command-actions span { display: block; margin-top: 2px; font-size: 11px; color: var(--ink-soft); }
|
||||
.ai-command-actions .journal-toolbar { justify-content: flex-end; }
|
||||
.ai-command-actions .journal-toolbar { justify-content: flex-end; flex: 2 1 360px; margin-bottom: 0; }
|
||||
.ai-command-strategy {
|
||||
margin-top: 12px; padding: 12px 14px; border-left: 3px solid var(--teal);
|
||||
background: rgba(111,224,208,.06);
|
||||
|
|
@ -4002,3 +4359,87 @@ table.tbl tr:hover td { background: rgba(231,198,107,.05); }
|
|||
.flow-summary, .holder-breakdown, .tech-grid { grid-template-columns: 1fr; }
|
||||
.candle-chart { height: 260px; }
|
||||
}
|
||||
|
||||
/* ─── 知識圖書館 ─── */
|
||||
.library-page .page-head { margin-bottom: 12px; }
|
||||
.lc-toolbar { display: flex; gap: 8px; margin-bottom: 12px; align-items: center; }
|
||||
.lc-search { flex: 1; padding: 10px 14px; border-radius: var(--r-sm); border: 2px solid var(--line); background: var(--surface); color: var(--ink); font: inherit; font-size: 14px; }
|
||||
.lc-filters { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 12px; }
|
||||
.lc-section { margin-bottom: 20px; }
|
||||
.lc-section-title { display: flex; align-items: center; gap: 6px; font-family: var(--pixel); font-size: 13px; color: var(--gold-soft); margin: 0 0 10px; text-transform: uppercase; letter-spacing: .5px; }
|
||||
.lc-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 10px; }
|
||||
.lc-card { display: flex; gap: 10px; padding: 12px; background: var(--surface); border: 1px solid var(--line); border-radius: var(--r-sm); text-decoration: none; color: var(--ink); transition: border-color .15s, background .15s; }
|
||||
.lc-card:hover { border-color: var(--teal); background: rgba(111, 224, 208, .06); }
|
||||
.lc-card-icon { flex-shrink: 0; width: 32px; height: 32px; display: flex; align-items: center; justify-content: center; background: rgba(0,0,0,.2); border-radius: 6px; }
|
||||
.lc-card-body { flex: 1; min-width: 0; }
|
||||
.lc-card-title { font-size: 14px; font-weight: 600; color: #dce4fa; margin-bottom: 4px; }
|
||||
.lc-card-summary { font-size: 12px; color: var(--ink-soft); line-height: 1.4; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
|
||||
.lc-card-meta { display: flex; gap: 6px; margin-top: 4px; }
|
||||
.lc-card-meta .tag { font-size: 10px; }
|
||||
.lc-overview-preview { padding: 12px; background: var(--surface); border: 1px solid var(--line); border-radius: var(--r-sm); margin-bottom: 8px; }
|
||||
.lc-index-list { display: flex; flex-direction: column; gap: 4px; }
|
||||
.lc-index-row { display: flex; align-items: center; gap: 8px; padding: 6px 10px; background: var(--surface); border: 1px solid var(--line); border-radius: 4px; text-decoration: none; color: var(--ink); font-size: 13px; }
|
||||
.lc-index-row:hover { border-color: var(--teal); }
|
||||
.lc-index-row .tag { flex-shrink: 0; font-size: 10px; }
|
||||
.lc-aliases { color: var(--ink-soft); font-size: 11px; }
|
||||
.lc-empty { text-align: center; padding: 40px 20px; color: var(--ink-soft); }
|
||||
.lc-empty-icon { font-size: 40px; margin-bottom: 8px; }
|
||||
.lc-stat-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(100px, 1fr)); gap: 8px; }
|
||||
.lc-stat-card { padding: 10px; text-align: center; background: var(--surface); border: 1px solid var(--line); border-radius: var(--r-sm); }
|
||||
.lc-stat-num { font-size: 24px; font-weight: 700; color: var(--gold-soft); font-family: var(--pixel); }
|
||||
.lc-stat-label { font-size: 11px; color: var(--ink-soft); margin-top: 2px; }
|
||||
.lc-stats { margin-top: 24px; }
|
||||
|
||||
.library-detail-page .ld-meta { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
|
||||
.library-detail-page .ld-body { padding: 16px; background: var(--surface); border: 1px solid var(--line); border-radius: var(--r-sm); line-height: 1.7; }
|
||||
|
||||
.markdown-body h1 { font-size: 20px; color: var(--gold-soft); margin: 0 0 12px; }
|
||||
.markdown-body h2 { font-size: 16px; color: var(--gold-soft); margin: 16px 0 8px; }
|
||||
.markdown-body h3 { font-size: 14px; color: var(--teal); margin: 12px 0 6px; }
|
||||
.markdown-body p { margin: 0 0 8px; }
|
||||
.markdown-body blockquote { border-left: 3px solid var(--teal); padding-left: 12px; margin: 8px 0; color: var(--ink-soft); }
|
||||
.markdown-body li { margin: 2px 0 2px 16px; }
|
||||
|
||||
/* ─── 內容管理 ─── */
|
||||
.content-manager-page { }
|
||||
.cm-tabs { display: flex; gap: 6px; margin-bottom: 16px; flex-wrap: wrap; }
|
||||
.cm-toolbar { display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; }
|
||||
.cm-card { background: var(--surface); border: 1px solid var(--line); border-radius: var(--r-sm); padding: 16px; margin-bottom: 12px; }
|
||||
.cm-card h3 { font-family: var(--pixel); font-size: 12px; color: var(--gold-soft); margin: 0 0 10px; text-transform: uppercase; letter-spacing: .5px; }
|
||||
.cm-input-row { display: flex; gap: 8px; margin-bottom: 8px; flex-wrap: wrap; }
|
||||
.cm-input { flex: 1; min-width: 160px; padding: 8px 12px; border-radius: var(--r-sm); border: 2px solid var(--line); background: var(--bg); color: var(--ink); font: inherit; font-size: 13px; }
|
||||
.cm-input-row select { padding: 8px; border-radius: var(--r-sm); border: 2px solid var(--line); background: var(--bg); color: var(--ink); font: inherit; font-size: 13px; }
|
||||
.cm-status { margin: 4px 0; font-size: 12px; padding: 4px 8px; border-radius: 4px; }
|
||||
.cm-status.ok { background: rgba(111, 207, 151, .12); color: #6fcf97; }
|
||||
.cm-status.error { background: rgba(232, 106, 82, .12); color: #e86a52; }
|
||||
.cm-hint { font-size: 11px; color: var(--ink-soft); margin-top: 4px; }
|
||||
.cm-stats { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 4px; }
|
||||
.cm-stat-chip { padding: 4px 10px; background: var(--surface); border: 1px solid var(--line); border-radius: 12px; font-size: 11px; color: var(--ink-soft); }
|
||||
|
||||
.cs-list { display: flex; flex-direction: column; gap: 6px; }
|
||||
.cs-row { display: flex; justify-content: space-between; align-items: center; padding: 10px 12px; background: var(--surface); border: 1px solid var(--line); border-radius: var(--r-sm); gap: 8px; }
|
||||
.cs-youtube { border-left: 3px solid #e86a52; }
|
||||
.cs-hyread { border-left: 3px solid #8fb8ff; }
|
||||
.cs-info { flex: 1; min-width: 0; }
|
||||
.cs-title { font-size: 14px; font-weight: 600; color: #dce4fa; }
|
||||
.cs-meta { display: flex; gap: 8px; margin-top: 4px; flex-wrap: wrap; font-size: 11px; color: var(--ink-soft); }
|
||||
.cs-meta .tag { font-size: 10px; }
|
||||
.cs-actions { display: flex; gap: 6px; flex-shrink: 0; }
|
||||
|
||||
.tag-youtube { background: rgba(232, 106, 82, .2); color: #e86a52; }
|
||||
.tag-hyread { background: rgba(143, 184, 255, .2); color: #8fb8ff; }
|
||||
.tag-captured { background: rgba(111, 224, 208, .15); color: #6fe0d0; }
|
||||
.tag-processing { background: rgba(231, 198, 107, .2); color: #e7c66b; }
|
||||
.tag-ready { background: rgba(111, 207, 151, .15); color: #6fcf97; }
|
||||
.tag-error { background: rgba(232, 106, 82, .2); color: #e86a52; }
|
||||
.tag-principle { background: rgba(231, 198, 107, .15); color: #e7c66b; }
|
||||
.tag-category { background: rgba(111, 224, 208, .15); color: #6fe0d0; }
|
||||
.tag-case { background: rgba(143, 184, 255, .15); color: #8fb8ff; }
|
||||
.tag-term { background: rgba(206, 167, 255, .15); color: #cea7ff; }
|
||||
.tag-company { background: rgba(111, 207, 151, .15); color: #6fcf97; }
|
||||
.tag-episode { background: rgba(255, 195, 97, .15); color: #ffc361; }
|
||||
|
||||
.hl { background: rgba(231, 198, 107, .25); color: #e7c66b; padding: 0 2px; border-radius: 2px; }
|
||||
|
||||
.btn-ghost { background: transparent; border: 1px solid var(--line); color: var(--ink-soft); }
|
||||
.btn-ghost:hover { border-color: var(--teal); color: var(--teal); }
|
||||
|
|
|
|||
Loading…
Reference in New Issue