feat: AI分析单页展示 + 主题切换 + 移动端适配

- AI分析页改为直接展示最新报告,历史存数据库
- 新增浅色/深色/系统自动主题切换
- 市场看板、AI分析页适配主题变量
- 移动端表格可滑动、按钮自动换行
- Mermaid图表支持
- Markdown表格渲染支持(remark-gfm)
This commit is contained in:
Sakurasan
2026-09-01 12:33:27 +08:00
parent 912a224b6b
commit 6478e0d403
16 changed files with 614 additions and 407 deletions
+16
View File
@@ -52,6 +52,22 @@ def _can_trigger(trade_date: str) -> tuple[bool, str]:
conn.close() conn.close()
@router.get("/ai-analysis/latest", summary="最新 AI 分析报告")
async def get_latest_report():
conn = get_connection()
try:
row = conn.execute(
"SELECT * FROM ai_reports ORDER BY trade_date DESC, id DESC LIMIT 1"
).fetchone()
if not row:
raise HTTPException(status_code=404, detail="暂无分析报告")
item = dict_from_row(row)
item["toolsUsed"] = json.loads(item.pop("tools_used") or "[]")
return JSONResponse({"data": item}, headers=_NO_CACHE_HEADERS)
finally:
conn.close()
@router.get("/ai-analysis", summary="AI 分析报告列表") @router.get("/ai-analysis", summary="AI 分析报告列表")
async def list_reports(): async def list_reports():
conn = get_connection() conn = get_connection()
+12 -4
View File
@@ -24,14 +24,14 @@
<script> <script>
// 在 React 加载前立即设置主题,避免闪烁 // 在 React 加载前立即设置主题,避免闪烁
(function() { (function() {
const isInIframe = window.self !== window.top;
function applyThemeToDOM(theme) { function applyThemeToDOM(theme) {
document.documentElement.classList.remove('light', 'dark'); document.documentElement.classList.remove('light', 'dark');
document.documentElement.classList.add(theme); document.documentElement.classList.add(theme);
document.documentElement.setAttribute('data-theme', theme); document.documentElement.setAttribute('data-theme', theme);
} }
var isInIframe = window.self !== window.top;
if (isInIframe) { if (isInIframe) {
// 监听父窗口主动推送的主题消息 // 监听父窗口主动推送的主题消息
window.addEventListener('message', function(event) { window.addEventListener('message', function(event) {
@@ -43,8 +43,16 @@
} }
}); });
} else { } else {
// 非 iframe 环境,使用默认 light 主题 // 从 localStorage 读取用户选择
applyThemeToDOM('light'); var saved = localStorage.getItem('theme');
var theme;
if (saved === 'light' || saved === 'dark') {
theme = saved;
} else {
// system: 跟随系统偏好
theme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
applyThemeToDOM(theme);
} }
})(); })();
</script> </script>
+1
View File
@@ -59,6 +59,7 @@
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",
"react-resizable-panels": "^4.12.1", "react-resizable-panels": "^4.12.1",
"recharts": "^2.15.4", "recharts": "^2.15.4",
"remark-gfm": "^4.0.1",
"sonner": "^2.0.7", "sonner": "^2.0.7",
"tailwind-merge": "^3.6.0", "tailwind-merge": "^3.6.0",
"tailwindcss": "^4.3.2", "tailwindcss": "^4.3.2",
+201
View File
@@ -155,6 +155,9 @@ importers:
recharts: recharts:
specifier: ^2.15.4 specifier: ^2.15.4
version: 2.15.4(react-dom@19.2.7(react@19.2.7))(react@19.2.7) version: 2.15.4(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
remark-gfm:
specifier: ^4.0.1
version: 4.0.1
sonner: sonner:
specifier: ^2.0.7 specifier: ^2.0.7
version: 2.0.8(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) version: 2.0.8(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
@@ -1893,6 +1896,10 @@ packages:
resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==}
engines: {node: '>=6'} engines: {node: '>=6'}
escape-string-regexp@5.0.0:
resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==}
engines: {node: '>=12'}
estree-util-is-identifier-name@3.0.0: estree-util-is-identifier-name@3.0.0:
resolution: {integrity: sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==} resolution: {integrity: sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==}
@@ -2137,14 +2144,38 @@ packages:
magic-string@0.30.21: magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
markdown-table@3.0.4:
resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==}
marked@16.3.0: marked@16.3.0:
resolution: {integrity: sha512-K3UxuKu6l6bmA5FUwYho8CfJBlsUWAooKtdGgMcERSpF7gcBUrCGsLH7wDaaNOzwq18JzSUDyoEb/YsrqMac3w==} resolution: {integrity: sha512-K3UxuKu6l6bmA5FUwYho8CfJBlsUWAooKtdGgMcERSpF7gcBUrCGsLH7wDaaNOzwq18JzSUDyoEb/YsrqMac3w==}
engines: {node: '>= 20'} engines: {node: '>= 20'}
hasBin: true hasBin: true
mdast-util-find-and-replace@3.0.2:
resolution: {integrity: sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==}
mdast-util-from-markdown@2.0.3: mdast-util-from-markdown@2.0.3:
resolution: {integrity: sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==} resolution: {integrity: sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==}
mdast-util-gfm-autolink-literal@2.0.1:
resolution: {integrity: sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==}
mdast-util-gfm-footnote@2.1.0:
resolution: {integrity: sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==}
mdast-util-gfm-strikethrough@2.0.0:
resolution: {integrity: sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==}
mdast-util-gfm-table@2.0.0:
resolution: {integrity: sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==}
mdast-util-gfm-task-list-item@2.0.0:
resolution: {integrity: sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==}
mdast-util-gfm@3.1.0:
resolution: {integrity: sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==}
mdast-util-mdx-expression@2.0.1: mdast-util-mdx-expression@2.0.1:
resolution: {integrity: sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==} resolution: {integrity: sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==}
@@ -2172,6 +2203,27 @@ packages:
micromark-core-commonmark@2.0.0: micromark-core-commonmark@2.0.0:
resolution: {integrity: sha512-jThOz/pVmAYUtkroV3D5c1osFXAMv9e0ypGDOIZuCeAe91/sD6BoE2Sjzt30yuXtwOYUmySOhMas/PVyh02itA==} resolution: {integrity: sha512-jThOz/pVmAYUtkroV3D5c1osFXAMv9e0ypGDOIZuCeAe91/sD6BoE2Sjzt30yuXtwOYUmySOhMas/PVyh02itA==}
micromark-extension-gfm-autolink-literal@2.1.0:
resolution: {integrity: sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==}
micromark-extension-gfm-footnote@2.1.0:
resolution: {integrity: sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==}
micromark-extension-gfm-strikethrough@2.1.0:
resolution: {integrity: sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==}
micromark-extension-gfm-table@2.1.1:
resolution: {integrity: sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==}
micromark-extension-gfm-tagfilter@2.0.0:
resolution: {integrity: sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==}
micromark-extension-gfm-task-list-item@2.1.0:
resolution: {integrity: sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==}
micromark-extension-gfm@3.0.0:
resolution: {integrity: sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==}
micromark-factory-destination@2.0.0: micromark-factory-destination@2.0.0:
resolution: {integrity: sha512-j9DGrQLm/Uhl2tCzcbLhy5kXsgkHUrjJHg4fFAeoMRwJmJerT9aw4FEhIbZStWN8A3qMwOp1uzHr4UL8AInxtA==} resolution: {integrity: sha512-j9DGrQLm/Uhl2tCzcbLhy5kXsgkHUrjJHg4fFAeoMRwJmJerT9aw4FEhIbZStWN8A3qMwOp1uzHr4UL8AInxtA==}
@@ -2393,12 +2445,18 @@ packages:
react: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
remark-gfm@4.0.1:
resolution: {integrity: sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==}
remark-parse@11.0.0: remark-parse@11.0.0:
resolution: {integrity: sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==} resolution: {integrity: sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==}
remark-rehype@11.1.2: remark-rehype@11.1.2:
resolution: {integrity: sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==} resolution: {integrity: sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==}
remark-stringify@11.0.0:
resolution: {integrity: sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==}
robust-predicates@3.0.3: robust-predicates@3.0.3:
resolution: {integrity: sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==} resolution: {integrity: sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==}
@@ -4378,6 +4436,8 @@ snapshots:
escalade@3.2.0: {} escalade@3.2.0: {}
escape-string-regexp@5.0.0: {}
estree-util-is-identifier-name@3.0.0: {} estree-util-is-identifier-name@3.0.0: {}
eventemitter3@4.0.1: {} eventemitter3@4.0.1: {}
@@ -4567,8 +4627,17 @@ snapshots:
dependencies: dependencies:
'@jridgewell/sourcemap-codec': 1.5.5 '@jridgewell/sourcemap-codec': 1.5.5
markdown-table@3.0.4: {}
marked@16.3.0: {} marked@16.3.0: {}
mdast-util-find-and-replace@3.0.2:
dependencies:
'@types/mdast': 4.0.4
escape-string-regexp: 5.0.0
unist-util-is: 6.0.1
unist-util-visit-parents: 6.0.2
mdast-util-from-markdown@2.0.3: mdast-util-from-markdown@2.0.3:
dependencies: dependencies:
'@types/mdast': 4.0.4 '@types/mdast': 4.0.4
@@ -4586,6 +4655,63 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
mdast-util-gfm-autolink-literal@2.0.1:
dependencies:
'@types/mdast': 4.0.4
ccount: 2.0.1
devlop: 1.1.0
mdast-util-find-and-replace: 3.0.2
micromark-util-character: 2.0.0
mdast-util-gfm-footnote@2.1.0:
dependencies:
'@types/mdast': 4.0.4
devlop: 1.1.0
mdast-util-from-markdown: 2.0.3
mdast-util-to-markdown: 2.0.0
micromark-util-normalize-identifier: 2.0.0
transitivePeerDependencies:
- supports-color
mdast-util-gfm-strikethrough@2.0.0:
dependencies:
'@types/mdast': 4.0.4
mdast-util-from-markdown: 2.0.3
mdast-util-to-markdown: 2.0.0
transitivePeerDependencies:
- supports-color
mdast-util-gfm-table@2.0.0:
dependencies:
'@types/mdast': 4.0.4
devlop: 1.1.0
markdown-table: 3.0.4
mdast-util-from-markdown: 2.0.3
mdast-util-to-markdown: 2.0.0
transitivePeerDependencies:
- supports-color
mdast-util-gfm-task-list-item@2.0.0:
dependencies:
'@types/mdast': 4.0.4
devlop: 1.1.0
mdast-util-from-markdown: 2.0.3
mdast-util-to-markdown: 2.0.0
transitivePeerDependencies:
- supports-color
mdast-util-gfm@3.1.0:
dependencies:
mdast-util-from-markdown: 2.0.3
mdast-util-gfm-autolink-literal: 2.0.1
mdast-util-gfm-footnote: 2.1.0
mdast-util-gfm-strikethrough: 2.0.0
mdast-util-gfm-table: 2.0.0
mdast-util-gfm-task-list-item: 2.0.0
mdast-util-to-markdown: 2.0.0
transitivePeerDependencies:
- supports-color
mdast-util-mdx-expression@2.0.1: mdast-util-mdx-expression@2.0.1:
dependencies: dependencies:
'@types/estree-jsx': 1.0.5 '@types/estree-jsx': 1.0.5
@@ -4701,6 +4827,64 @@ snapshots:
micromark-util-symbol: 2.0.0 micromark-util-symbol: 2.0.0
micromark-util-types: 2.0.0 micromark-util-types: 2.0.0
micromark-extension-gfm-autolink-literal@2.1.0:
dependencies:
micromark-util-character: 2.0.0
micromark-util-sanitize-uri: 2.0.0
micromark-util-symbol: 2.0.0
micromark-util-types: 2.0.0
micromark-extension-gfm-footnote@2.1.0:
dependencies:
devlop: 1.1.0
micromark-core-commonmark: 2.0.0
micromark-factory-space: 2.0.0
micromark-util-character: 2.0.0
micromark-util-normalize-identifier: 2.0.0
micromark-util-sanitize-uri: 2.0.0
micromark-util-symbol: 2.0.0
micromark-util-types: 2.0.0
micromark-extension-gfm-strikethrough@2.1.0:
dependencies:
devlop: 1.1.0
micromark-util-chunked: 2.0.0
micromark-util-classify-character: 2.0.0
micromark-util-resolve-all: 2.0.0
micromark-util-symbol: 2.0.0
micromark-util-types: 2.0.0
micromark-extension-gfm-table@2.1.1:
dependencies:
devlop: 1.1.0
micromark-factory-space: 2.0.0
micromark-util-character: 2.0.0
micromark-util-symbol: 2.0.0
micromark-util-types: 2.0.0
micromark-extension-gfm-tagfilter@2.0.0:
dependencies:
micromark-util-types: 2.0.0
micromark-extension-gfm-task-list-item@2.1.0:
dependencies:
devlop: 1.1.0
micromark-factory-space: 2.0.0
micromark-util-character: 2.0.0
micromark-util-symbol: 2.0.0
micromark-util-types: 2.0.0
micromark-extension-gfm@3.0.0:
dependencies:
micromark-extension-gfm-autolink-literal: 2.1.0
micromark-extension-gfm-footnote: 2.1.0
micromark-extension-gfm-strikethrough: 2.1.0
micromark-extension-gfm-table: 2.1.1
micromark-extension-gfm-tagfilter: 2.0.0
micromark-extension-gfm-task-list-item: 2.1.0
micromark-util-combine-extensions: 2.0.0
micromark-util-types: 2.0.0
micromark-factory-destination@2.0.0: micromark-factory-destination@2.0.0:
dependencies: dependencies:
micromark-util-character: 2.0.0 micromark-util-character: 2.0.0
@@ -4983,6 +5167,17 @@ snapshots:
tiny-invariant: 1.3.3 tiny-invariant: 1.3.3
victory-vendor: 36.9.2 victory-vendor: 36.9.2
remark-gfm@4.0.1:
dependencies:
'@types/mdast': 4.0.4
mdast-util-gfm: 3.1.0
micromark-extension-gfm: 3.0.0
remark-parse: 11.0.0
remark-stringify: 11.0.0
unified: 11.0.0
transitivePeerDependencies:
- supports-color
remark-parse@11.0.0: remark-parse@11.0.0:
dependencies: dependencies:
'@types/mdast': 4.0.4 '@types/mdast': 4.0.4
@@ -5000,6 +5195,12 @@ snapshots:
unified: 11.0.0 unified: 11.0.0
vfile: 6.0.3 vfile: 6.0.3
remark-stringify@11.0.0:
dependencies:
'@types/mdast': 4.0.4
mdast-util-to-markdown: 2.0.0
unified: 11.0.0
robust-predicates@3.0.3: {} robust-predicates@3.0.3: {}
rollup@4.62.2: rollup@4.62.2:
+9 -9
View File
@@ -3,14 +3,14 @@ import mermaid from "mermaid";
mermaid.initialize({ mermaid.initialize({
startOnLoad: false, startOnLoad: false,
theme: "dark", theme: "default",
themeVariables: { themeVariables: {
primaryColor: "#21262d", primaryColor: "#f0f0f0",
primaryTextColor: "#e6edf3", primaryTextColor: "#333333",
primaryBorderColor: "#30363d", primaryBorderColor: "#cccccc",
lineColor: "#58a6ff", lineColor: "#3366cc",
secondaryColor: "#161b22", secondaryColor: "#e8e8e8",
tertiaryColor: "#0d1117", tertiaryColor: "#ffffff",
fontFamily: "inherit", fontFamily: "inherit",
fontSize: "14px", fontSize: "14px",
}, },
@@ -37,7 +37,7 @@ export function Mermaid({ chart }: MermaidProps) {
if (error) { if (error) {
return ( return (
<pre className="bg-[#0d1117] border border-[#f85149]/30 rounded p-3 text-sm text-[#f85149] overflow-x-auto"> <pre className="bg-muted border border-destructive/30 rounded p-3 text-sm text-destructive overflow-x-auto">
{error} {error}
</pre> </pre>
); );
@@ -46,7 +46,7 @@ export function Mermaid({ chart }: MermaidProps) {
return ( return (
<div <div
ref={ref} ref={ref}
className="bg-[#0d1117] rounded p-3 overflow-x-auto my-2 flex justify-center [&>svg]:max-w-full" className="bg-muted rounded p-3 overflow-x-auto my-2 flex justify-center [&>svg]:max-w-full"
dangerouslySetInnerHTML={{ __html: svg }} dangerouslySetInnerHTML={{ __html: svg }}
/> />
); );
+58
View File
@@ -0,0 +1,58 @@
import { useState, useRef, useEffect } from "react";
import { Sun, Moon, Monitor } from "lucide-react";
import { useTheme } from "../lib/use-theme";
export function ThemeToggle() {
const { theme, setTheme } = useTheme();
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const handler = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) {
setOpen(false);
}
};
document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
}, [open]);
const iconMap = { light: Sun, dark: Moon, system: Monitor };
const CurrentIcon = iconMap[theme];
return (
<div ref={ref} className="relative">
<button
onClick={() => setOpen(!open)}
className="flex items-center justify-center w-8 h-8 rounded-lg bg-muted text-muted-foreground hover:text-foreground shadow-sm transition-colors"
title="切换主题"
>
<CurrentIcon className="h-4 w-4" />
</button>
{open && (
<div className="absolute bottom-full right-0 mb-1 bg-popover border rounded-lg shadow-lg p-1 flex gap-0.5 animate-in fade-in slide-in-from-bottom-2 duration-150">
{([
{ value: "light" as const, icon: Sun, label: "浅色" },
{ value: "dark" as const, icon: Moon, label: "深色" },
{ value: "system" as const, icon: Monitor, label: "自动" },
]).map(({ value, icon: Icon, label }) => (
<button
key={value}
onClick={() => { setTheme(value); setOpen(false); }}
title={label}
className={`flex items-center justify-center w-8 h-8 rounded-md text-sm transition-colors ${
theme === value
? "bg-accent text-accent-foreground"
: "text-muted-foreground hover:text-foreground"
}`}
>
<Icon className="h-4 w-4" />
</button>
))}
</div>
)}
</div>
);
}
+7
View File
@@ -17,6 +17,13 @@ export interface AiReport {
created_at: string; created_at: string;
} }
export async function fetchAiLatestReport(): Promise<AiReport> {
const resp = await fetch(`${API_BASE}/api/ai-analysis/latest`);
if (!resp.ok) throw new Error(`请求失败 (${resp.status})`);
const result = await resp.json();
return result.data;
}
export async function fetchAiReports(): Promise<AiReport[]> { export async function fetchAiReports(): Promise<AiReport[]> {
const resp = await fetch(`${API_BASE}/api/ai-analysis`); const resp = await fetch(`${API_BASE}/api/ai-analysis`);
if (!resp.ok) throw new Error(`请求失败 (${resp.status})`); if (!resp.ok) throw new Error(`请求失败 (${resp.status})`);
+56
View File
@@ -0,0 +1,56 @@
import { useState, useEffect, useCallback } from "react";
type Theme = "light" | "dark" | "system";
function getSystemTheme(): "light" | "dark" {
if (typeof window === "undefined") return "light";
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
}
function getResolvedTheme(theme: Theme): "light" | "dark" {
return theme === "system" ? getSystemTheme() : theme;
}
function applyTheme(resolved: "light" | "dark") {
const root = document.documentElement;
root.classList.remove("light", "dark");
root.classList.add(resolved);
root.setAttribute("data-theme", resolved);
}
export function useTheme() {
const [theme, setThemeState] = useState<Theme>(() => {
if (typeof window === "undefined") return "system";
return (localStorage.getItem("theme") as Theme) || "system";
});
const [resolved, setResolved] = useState<"light" | "dark">(() => getResolvedTheme(theme));
const setTheme = useCallback((newTheme: Theme) => {
setThemeState(newTheme);
localStorage.setItem("theme", newTheme);
const r = getResolvedTheme(newTheme);
setResolved(r);
applyTheme(r);
}, []);
// 初始应用
useEffect(() => {
applyTheme(resolved);
}, []);
// 监听系统主题变化
useEffect(() => {
if (theme !== "system") return;
const mq = window.matchMedia("(prefers-color-scheme: dark)");
const handler = () => {
const r = getSystemTheme();
setResolved(r);
applyTheme(r);
};
mq.addEventListener("change", handler);
return () => mq.removeEventListener("change", handler);
}, [theme]);
return { theme, resolved, setTheme };
}
+5 -52
View File
@@ -19,8 +19,6 @@ import { Route as IndexRouteImport } from './routes/index'
import { Route as ThemeCodeRouteImport } from './routes/theme.$code' import { Route as ThemeCodeRouteImport } from './routes/theme.$code'
import { Route as StockCodeRouteImport } from './routes/stock.$code' import { Route as StockCodeRouteImport } from './routes/stock.$code'
import { Route as ShareCodeRouteImport } from './routes/share.$code' import { Route as ShareCodeRouteImport } from './routes/share.$code'
import { Route as AiAnalysisIndexRouteImport } from './routes/ai-analysis._index'
import { Route as AiAnalysisIdRouteImport } from './routes/ai-analysis.$id'
const ThemesRoute = ThemesRouteImport.update({ const ThemesRoute = ThemesRouteImport.update({
id: '/themes', id: '/themes',
@@ -72,38 +70,27 @@ const ShareCodeRoute = ShareCodeRouteImport.update({
path: '/share/$code', path: '/share/$code',
getParentRoute: () => rootRouteImport, getParentRoute: () => rootRouteImport,
} as any) } as any)
const AiAnalysisIndexRoute = AiAnalysisIndexRouteImport.update({
id: '/_index',
getParentRoute: () => AiAnalysisRoute,
} as any)
const AiAnalysisIdRoute = AiAnalysisIdRouteImport.update({
id: '/$id',
path: '/$id',
getParentRoute: () => AiAnalysisRoute,
} as any)
export interface FileRoutesByFullPath { export interface FileRoutesByFullPath {
'/': typeof IndexRoute '/': typeof IndexRoute
'/ai-analysis': typeof AiAnalysisRouteWithChildren '/ai-analysis': typeof AiAnalysisRoute
'/core-stocks': typeof CoreStocksRoute '/core-stocks': typeof CoreStocksRoute
'/dashboard': typeof DashboardRoute '/dashboard': typeof DashboardRoute
'/hot-map': typeof HotMapRoute '/hot-map': typeof HotMapRoute
'/theme-history': typeof ThemeHistoryRoute '/theme-history': typeof ThemeHistoryRoute
'/themes': typeof ThemesRoute '/themes': typeof ThemesRoute
'/ai-analysis/$id': typeof AiAnalysisIdRoute
'/share/$code': typeof ShareCodeRoute '/share/$code': typeof ShareCodeRoute
'/stock/$code': typeof StockCodeRoute '/stock/$code': typeof StockCodeRoute
'/theme/$code': typeof ThemeCodeRoute '/theme/$code': typeof ThemeCodeRoute
} }
export interface FileRoutesByTo { export interface FileRoutesByTo {
'/': typeof IndexRoute '/': typeof IndexRoute
'/ai-analysis': typeof AiAnalysisRouteWithChildren '/ai-analysis': typeof AiAnalysisRoute
'/core-stocks': typeof CoreStocksRoute '/core-stocks': typeof CoreStocksRoute
'/dashboard': typeof DashboardRoute '/dashboard': typeof DashboardRoute
'/hot-map': typeof HotMapRoute '/hot-map': typeof HotMapRoute
'/theme-history': typeof ThemeHistoryRoute '/theme-history': typeof ThemeHistoryRoute
'/themes': typeof ThemesRoute '/themes': typeof ThemesRoute
'/ai-analysis/$id': typeof AiAnalysisIdRoute
'/share/$code': typeof ShareCodeRoute '/share/$code': typeof ShareCodeRoute
'/stock/$code': typeof StockCodeRoute '/stock/$code': typeof StockCodeRoute
'/theme/$code': typeof ThemeCodeRoute '/theme/$code': typeof ThemeCodeRoute
@@ -111,14 +98,12 @@ export interface FileRoutesByTo {
export interface FileRoutesById { export interface FileRoutesById {
__root__: typeof rootRouteImport __root__: typeof rootRouteImport
'/': typeof IndexRoute '/': typeof IndexRoute
'/ai-analysis': typeof AiAnalysisRouteWithChildren '/ai-analysis': typeof AiAnalysisRoute
'/core-stocks': typeof CoreStocksRoute '/core-stocks': typeof CoreStocksRoute
'/dashboard': typeof DashboardRoute '/dashboard': typeof DashboardRoute
'/hot-map': typeof HotMapRoute '/hot-map': typeof HotMapRoute
'/theme-history': typeof ThemeHistoryRoute '/theme-history': typeof ThemeHistoryRoute
'/themes': typeof ThemesRoute '/themes': typeof ThemesRoute
'/ai-analysis/$id': typeof AiAnalysisIdRoute
'/ai-analysis/_index': typeof AiAnalysisIndexRoute
'/share/$code': typeof ShareCodeRoute '/share/$code': typeof ShareCodeRoute
'/stock/$code': typeof StockCodeRoute '/stock/$code': typeof StockCodeRoute
'/theme/$code': typeof ThemeCodeRoute '/theme/$code': typeof ThemeCodeRoute
@@ -133,7 +118,6 @@ export interface FileRouteTypes {
| '/hot-map' | '/hot-map'
| '/theme-history' | '/theme-history'
| '/themes' | '/themes'
| '/ai-analysis/$id'
| '/share/$code' | '/share/$code'
| '/stock/$code' | '/stock/$code'
| '/theme/$code' | '/theme/$code'
@@ -146,7 +130,6 @@ export interface FileRouteTypes {
| '/hot-map' | '/hot-map'
| '/theme-history' | '/theme-history'
| '/themes' | '/themes'
| '/ai-analysis/$id'
| '/share/$code' | '/share/$code'
| '/stock/$code' | '/stock/$code'
| '/theme/$code' | '/theme/$code'
@@ -159,8 +142,6 @@ export interface FileRouteTypes {
| '/hot-map' | '/hot-map'
| '/theme-history' | '/theme-history'
| '/themes' | '/themes'
| '/ai-analysis/$id'
| '/ai-analysis/_index'
| '/share/$code' | '/share/$code'
| '/stock/$code' | '/stock/$code'
| '/theme/$code' | '/theme/$code'
@@ -168,7 +149,7 @@ export interface FileRouteTypes {
} }
export interface RootRouteChildren { export interface RootRouteChildren {
IndexRoute: typeof IndexRoute IndexRoute: typeof IndexRoute
AiAnalysisRoute: typeof AiAnalysisRouteWithChildren AiAnalysisRoute: typeof AiAnalysisRoute
CoreStocksRoute: typeof CoreStocksRoute CoreStocksRoute: typeof CoreStocksRoute
DashboardRoute: typeof DashboardRoute DashboardRoute: typeof DashboardRoute
HotMapRoute: typeof HotMapRoute HotMapRoute: typeof HotMapRoute
@@ -251,40 +232,12 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ShareCodeRouteImport preLoaderRoute: typeof ShareCodeRouteImport
parentRoute: typeof rootRouteImport parentRoute: typeof rootRouteImport
} }
'/ai-analysis/_index': {
id: '/ai-analysis/_index'
path: ''
fullPath: '/ai-analysis'
preLoaderRoute: typeof AiAnalysisIndexRouteImport
parentRoute: typeof AiAnalysisRoute
}
'/ai-analysis/$id': {
id: '/ai-analysis/$id'
path: '/$id'
fullPath: '/ai-analysis/$id'
preLoaderRoute: typeof AiAnalysisIdRouteImport
parentRoute: typeof AiAnalysisRoute
}
} }
} }
interface AiAnalysisRouteChildren {
AiAnalysisIdRoute: typeof AiAnalysisIdRoute
AiAnalysisIndexRoute: typeof AiAnalysisIndexRoute
}
const AiAnalysisRouteChildren: AiAnalysisRouteChildren = {
AiAnalysisIdRoute: AiAnalysisIdRoute,
AiAnalysisIndexRoute: AiAnalysisIndexRoute,
}
const AiAnalysisRouteWithChildren = AiAnalysisRoute._addFileChildren(
AiAnalysisRouteChildren,
)
const rootRouteChildren: RootRouteChildren = { const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute, IndexRoute: IndexRoute,
AiAnalysisRoute: AiAnalysisRouteWithChildren, AiAnalysisRoute: AiAnalysisRoute,
CoreStocksRoute: CoreStocksRoute, CoreStocksRoute: CoreStocksRoute,
DashboardRoute: DashboardRoute, DashboardRoute: DashboardRoute,
HotMapRoute: HotMapRoute, HotMapRoute: HotMapRoute,
+4
View File
@@ -1,6 +1,7 @@
import * as React from 'react' import * as React from 'react'
import { Outlet, createRootRoute } from '@tanstack/react-router' import { Outlet, createRootRoute } from '@tanstack/react-router'
import { Toaster } from 'sonner' import { Toaster } from 'sonner'
import { ThemeToggle } from '../components/ThemeToggle'
export const Route = createRootRoute({ export const Route = createRootRoute({
component: RootComponent, component: RootComponent,
@@ -11,6 +12,9 @@ function RootComponent() {
<React.Fragment> <React.Fragment>
<Outlet /> <Outlet />
<Toaster position="top-center" richColors /> <Toaster position="top-center" richColors />
<div className="fixed bottom-4 right-4 z-50">
<ThemeToggle />
</div>
</React.Fragment> </React.Fragment>
) )
} }
-138
View File
@@ -1,138 +0,0 @@
import * as React from "react";
import { Link, createFileRoute } from "@tanstack/react-router";
import { ArrowLeft, BrainCircuit, Clock, Loader2, AlertCircle, RefreshCw, Wrench } from "lucide-react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import Markdown from "react-markdown";
import { Mermaid } from "../components/Mermaid";
import { fetchAiReport, regenerateAiReport } from "../lib/ai-analysis-api";
import { Button } from "../components/ui/button";
import { Card, CardContent } from "../components/ui/card";
import { toast } from "sonner";
export const Route = createFileRoute("/ai-analysis/$id")({
component: AiReportDetailPage,
});
function AiReportDetailPage() {
const { id } = Route.useParams();
const queryClient = useQueryClient();
const reportId = Number(id);
const { data: report, isLoading, isError, refetch } = useQuery({
queryKey: ["ai-report", reportId],
queryFn: () => fetchAiReport(reportId),
staleTime: 60_000,
retry: false,
});
const regenerateMutation = useMutation({
mutationFn: () => regenerateAiReport(reportId),
onSuccess: () => {
toast.success("重新生成完成");
queryClient.invalidateQueries({ queryKey: ["ai-report", reportId] });
queryClient.invalidateQueries({ queryKey: ["ai-reports"] });
},
onError: (err: Error) => {
toast.error(err.message || "重新生成失败");
},
});
return (
<div className="min-h-screen bg-[#0d1117] text-[#e6edf3]">
<div className="max-w-4xl mx-auto px-4 py-6">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3">
<Link to="/ai-analysis" className="text-[#8b949e] hover:text-[#e6edf3] transition-colors">
<ArrowLeft className="h-5 w-5" />
</Link>
<h1 className="text-xl md:text-2xl font-bold flex items-center gap-2">
<BrainCircuit className="h-5 w-5 text-[#58a6ff]" />
{report?.title || "AI 分析报告"}
</h1>
</div>
<Button
size="sm"
variant="outline"
onClick={() => regenerateMutation.mutate()}
disabled={regenerateMutation.isPending}
className="gap-1.5"
>
{regenerateMutation.isPending ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<RefreshCw className="h-3.5 w-3.5" />
)}
重新生成
</Button>
</div>
{/* Content */}
{isLoading ? (
<div className="flex flex-col items-center gap-3 py-20">
<Loader2 className="h-6 w-6 animate-spin text-[#58a6ff]" />
<p className="text-sm text-[#8b949e]">加载中...</p>
</div>
) : isError ? (
<div className="flex flex-col items-center gap-3 py-20">
<AlertCircle className="h-6 w-6 text-[#f85149]" />
<p className="text-sm text-[#8b949e]">报告加载失败</p>
<button onClick={() => refetch()} className="text-xs text-[#58a6ff] hover:underline">
点击重试
</button>
</div>
) : report ? (
<>
{/* Meta Info */}
<Card className="bg-[#161b22] border-[#30363d] mb-6">
<CardContent className="p-4">
<div className="flex flex-wrap items-center gap-4 text-sm text-[#8b949e]">
<span className="flex items-center gap-1.5">
<Clock className="h-3.5 w-3.5" />
{report.created_at}
</span>
<span>{report.model}</span>
<span>{report.tokens_used?.toLocaleString()} tokens</span>
{report.toolsUsed && report.toolsUsed.length > 0 && (
<span className="flex items-center gap-1.5">
<Wrench className="h-3.5 w-3.5" />
{report.toolsUsed.length} 个工具
</span>
)}
</div>
</CardContent>
</Card>
{/* Report Content */}
<Card className="bg-[#161b22] border-[#30363d]">
<CardContent className="p-4 md:p-6 ai-report-content">
<Markdown
components={{
code({ className, children, ...props }) {
const match = /language-(\w+)/.exec(className || "");
if (match && match[1] === "mermaid") {
return <Mermaid chart={String(children).replace(/\n$/, "")} />;
}
return (
<code className={className} {...props}>
{children}
</code>
);
},
}}
>
{report.content}
</Markdown>
</CardContent>
</Card>
{/* Disclaimer */}
<p className="text-xs text-[#6e7681] text-center mt-6">
本报告由 AI 生成,仅供参考,不构成投资建议
</p>
</>
) : null}
</div>
</div>
);
}
-103
View File
@@ -1,103 +0,0 @@
import * as React from "react";
import { Link, createFileRoute } from "@tanstack/react-router";
import { Clock, Zap, Loader2, AlertCircle, BrainCircuit } from "lucide-react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { fetchAiReports, triggerAiAnalysis } from "../lib/ai-analysis-api";
import { Button } from "../components/ui/button";
import { Card, CardContent } from "../components/ui/card";
import { toast } from "sonner";
export const Route = createFileRoute("/ai-analysis/_index")({
component: AiAnalysisIndex,
});
function AiAnalysisIndex() {
const queryClient = useQueryClient();
const { data: reports, isLoading, isError, refetch } = useQuery({
queryKey: ["ai-reports"],
queryFn: fetchAiReports,
staleTime: 30_000,
retry: false,
});
const triggerMutation = useMutation({
mutationFn: triggerAiAnalysis,
onSuccess: () => {
toast.success("AI 分析已开始,请稍候...");
queryClient.invalidateQueries({ queryKey: ["ai-reports"] });
},
onError: (err: Error) => {
toast.error(err.message || "触发失败");
},
});
return (
<>
<div className="flex justify-end mb-6">
<Button
size="sm"
onClick={() => triggerMutation.mutate()}
disabled={triggerMutation.isPending}
className="gap-1.5"
>
{triggerMutation.isPending ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Zap className="h-3.5 w-3.5" />
)}
立即分析
</Button>
</div>
{isLoading ? (
<div className="flex flex-col items-center gap-3 py-20">
<Loader2 className="h-6 w-6 animate-spin text-[#58a6ff]" />
<p className="text-sm text-[#8b949e]">加载中...</p>
</div>
) : isError ? (
<div className="flex flex-col items-center gap-3 py-20">
<AlertCircle className="h-6 w-6 text-[#f85149]" />
<p className="text-sm text-[#8b949e]">数据加载失败</p>
<button onClick={() => refetch()} className="text-xs text-[#58a6ff] hover:underline">
点击重试
</button>
</div>
) : reports && reports.length > 0 ? (
<div className="space-y-4">
{reports.map((report) => (
<Card key={report.id} className="bg-[#161b22] border-[#30363d] hover:border-[#58a6ff]/50 transition-colors">
<CardContent className="p-4 md:p-5">
<div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-[#e6edf3] mb-1">{report.title}</h3>
{report.summary && (
<p className="text-sm text-[#8b949e] line-clamp-2 mb-2">{report.summary}</p>
)}
<div className="flex flex-wrap items-center gap-3 text-xs text-[#8b949e]">
<span className="flex items-center gap-1">
<Clock className="h-3 w-3" />
{report.created_at}
</span>
<span>{report.model}</span>
<span>{report.tokens_used?.toLocaleString()} tokens</span>
</div>
</div>
<Link to="/ai-analysis/$id" params={{ id: String(report.id) }} className="text-xs text-[#58a6ff] shrink-0 hover:underline">
查看 →
</Link>
</div>
</CardContent>
</Card>
))}
</div>
) : (
<div className="flex flex-col items-center gap-3 py-20">
<BrainCircuit className="h-10 w-10 text-[#30363d]" />
<p className="text-sm text-[#8b949e]">暂无分析报告</p>
<p className="text-xs text-[#6e7681]">点击"立即分析"生成今日报告</p>
</div>
)}
</>
);
}
+125 -13
View File
@@ -1,28 +1,140 @@
import * as React from "react"; import * as React from "react";
import { Link, Outlet, createFileRoute } from "@tanstack/react-router"; import { Link, createFileRoute } from "@tanstack/react-router";
import { ArrowLeft, BrainCircuit } from "lucide-react"; import { ArrowLeft, Clock, Loader2, AlertCircle, RefreshCw, Wrench, Zap, Calendar } from "lucide-react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import Markdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { Mermaid } from "../components/Mermaid";
import { fetchAiLatestReport, triggerAiAnalysis, fetchAiReport } from "../lib/ai-analysis-api";
import { Button } from "../components/ui/button";
import { Card, CardContent } from "../components/ui/card";
import { toast } from "sonner";
export const Route = createFileRoute("/ai-analysis")({ export const Route = createFileRoute("/ai-analysis")({
component: AiAnalysisLayout, component: AiAnalysisPage,
}); });
function AiAnalysisLayout() { function AiAnalysisPage() {
const queryClient = useQueryClient();
const { data: report, isLoading, isError, refetch } = useQuery({
queryKey: ["ai-report-latest"],
queryFn: fetchAiLatestReport,
retry: false,
});
const triggerMutation = useMutation({
mutationFn: triggerAiAnalysis,
onSuccess: () => {
toast.success("AI 分析已开始,请稍候...");
queryClient.invalidateQueries({ queryKey: ["ai-report-latest"] });
},
onError: (err: Error) => {
toast.error(err.message || "触发失败");
},
});
return ( return (
<div className="min-h-screen bg-[#0d1117] text-[#e6edf3]"> <div className="min-h-screen bg-background text-foreground">
<div className="max-w-4xl mx-auto px-4 py-6"> <div className="max-w-4xl mx-auto px-3 sm:px-4 py-4 sm:py-6">
{/* Header */} {/* Header */}
<div className="flex items-center gap-3 mb-6"> <div className="flex items-center justify-between mb-4 sm:mb-6">
<Link to="/" className="text-[#8b949e] hover:text-[#e6edf3] transition-colors"> <div className="flex items-center gap-2 sm:gap-3">
<Link to="/" className="text-muted-foreground hover:text-foreground transition-colors">
<ArrowLeft className="h-5 w-5" /> <ArrowLeft className="h-5 w-5" />
</Link> </Link>
<h1 className="text-xl md:text-2xl font-bold flex items-center gap-2"> <h1 className="text-lg sm:text-xl md:text-2xl font-bold flex items-center gap-2">
<BrainCircuit className="h-5 w-5 text-[#58a6ff]" /> <Calendar className="h-5 w-5 text-primary" />
AI 市场分析 AI 收盘分析
</h1> </h1>
</div> </div>
<Button
size="sm"
onClick={() => triggerMutation.mutate()}
disabled={triggerMutation.isPending}
className="gap-1.5 text-xs sm:text-sm"
>
{triggerMutation.isPending ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Zap className="h-3.5 w-3.5" />
)}
<span className="hidden sm:inline">立即分析</span>
<span className="sm:hidden">分析</span>
</Button>
</div>
{/* Child routes render here */} {/* Content */}
<Outlet /> {isLoading ? (
<div className="flex flex-col items-center gap-3 py-20">
<Loader2 className="h-6 w-6 animate-spin text-primary" />
<p className="text-sm text-muted-foreground">加载中...</p>
</div>
) : isError || !report ? (
<div className="flex flex-col items-center gap-3 py-20">
<AlertCircle className="h-6 w-6 text-destructive" />
<p className="text-sm text-muted-foreground">暂无分析报告</p>
<p className="text-xs text-muted-foreground/60">点击"立即分析"生成今日报告</p>
</div>
) : (
<>
{/* Meta Info */}
<Card className="bg-card border-border mb-3 sm:mb-4">
<CardContent className="p-3 sm:p-4">
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 sm:gap-4 text-xs sm:text-sm text-muted-foreground">
<span className="flex items-center gap-1.5">
<Clock className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">{report.created_at}</span>
</span>
<span className="truncate">{report.model}</span>
<span>{report.tokens_used?.toLocaleString()} tokens</span>
{report.toolsUsed && report.toolsUsed.length > 0 && (
<span className="flex items-center gap-1.5">
<Wrench className="h-3.5 w-3.5 shrink-0" />
{report.toolsUsed.length} 个工具
</span>
)}
</div>
</CardContent>
</Card>
{/* Report Content */}
<Card className="bg-card border-border">
<CardContent className="p-3 sm:p-4 md:p-6 ai-report-content">
<Markdown
remarkPlugins={[remarkGfm]}
components={{
table({ children, ...props }) {
return (
<div className="overflow-x-auto -mx-3 sm:mx-0 px-3 sm:px-0">
<table {...props}>{children}</table>
</div>
);
},
code({ className, children, ...props }) {
const match = /language-(\w+)/.exec(className || "");
if (match && match[1] === "mermaid") {
return <Mermaid chart={String(children).replace(/\n$/, "")} />;
}
return (
<code className={className} {...props}>
{children}
</code>
);
},
}}
>
{report.content}
</Markdown>
</CardContent>
</Card>
{/* Disclaimer */}
<p className="text-xs text-muted-foreground/60 text-center mt-4 sm:mt-6">
本报告由 AI 生成,仅供参考,不构成投资建议
</p>
</>
)}
</div> </div>
</div> </div>
); );
+55 -55
View File
@@ -40,17 +40,17 @@ function DashboardPage() {
}, [autoRefresh, refetch]); }, [autoRefresh, refetch]);
return ( return (
<div className="min-h-screen bg-[#0d1117] text-[#e6edf3]"> <div className="min-h-screen bg-background text-foreground">
{/* ── 顶栏 ── */} {/* ── 顶栏 ── */}
<header className="sticky top-0 z-10 bg-[#0d1117]/95 backdrop-blur border-b border-[#21262d]"> <header className="sticky top-0 z-10 bg-background/95 backdrop-blur border-b border-border">
<div className="max-w-[1400px] mx-auto px-4 h-12 flex items-center justify-between"> <div className="max-w-[1400px] mx-auto px-4 h-12 flex items-center justify-between">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Link to="/" className="text-[#8b949e] hover:text-[#e6edf3] transition-colors"> <Link to="/" className="text-muted-foreground hover:text-foreground transition-colors">
<ArrowLeft className="h-5 w-5" /> <ArrowLeft className="h-5 w-5" />
</Link> </Link>
<h1 className="text-base font-semibold">市场看板</h1> <h1 className="text-base font-semibold">市场看板</h1>
</div> </div>
<div className="flex items-center gap-3 text-xs text-[#8b949e]"> <div className="flex items-center gap-3 text-xs text-muted-foreground">
{data?.updateTime && ( {data?.updateTime && (
<span>行情时间 {data.updateTime}</span> <span>行情时间 {data.updateTime}</span>
)} )}
@@ -59,13 +59,13 @@ function DashboardPage() {
type="checkbox" type="checkbox"
checked={autoRefresh} checked={autoRefresh}
onChange={(e) => setAutoRefresh(e.target.checked)} onChange={(e) => setAutoRefresh(e.target.checked)}
className="w-3 h-3 rounded border-[#30363d] bg-[#161b22] accent-[#58a6ff]" className="w-3 h-3 rounded border-border bg-muted accent-primary"
/> />
自动刷新 自动刷新
</label> </label>
<button <button
onClick={() => refetch()} onClick={() => refetch()}
className="text-[#8b949e] hover:text-[#e6edf3] transition-colors" className="text-muted-foreground hover:text-foreground transition-colors"
title="刷新" title="刷新"
> >
<RefreshCw className={`h-4 w-4 ${isFetching ? "animate-spin" : ""}`} /> <RefreshCw className={`h-4 w-4 ${isFetching ? "animate-spin" : ""}`} />
@@ -80,7 +80,7 @@ function DashboardPage() {
<DashboardSkeleton /> <DashboardSkeleton />
) : isError ? ( ) : isError ? (
<div className="flex flex-col items-center gap-3 py-20"> <div className="flex flex-col items-center gap-3 py-20">
<p className="text-sm text-[#8b949e]">数据加载失败</p> <p className="text-sm text-muted-foreground">数据加载失败</p>
<button onClick={() => refetch()} className="text-xs text-[#58a6ff] hover:underline"> <button onClick={() => refetch()} className="text-xs text-[#58a6ff] hover:underline">
点击重试 点击重试
</button> </button>
@@ -139,10 +139,10 @@ function IndicesRow({ indices }: { indices: MarketIndex[] }) {
function IndexCard({ index }: { index: MarketIndex }) { function IndexCard({ index }: { index: MarketIndex }) {
const isUp = index.changePct >= 0; const isUp = index.changePct >= 0;
return ( return (
<div className="bg-[#161b22] border border-[#21262d] rounded-lg p-3"> <div className="bg-card border border-border rounded-lg p-3">
<div className="flex items-center justify-between mb-1"> <div className="flex items-center justify-between mb-1">
<span className="text-xs text-[#8b949e] truncate">{index.name}</span> <span className="text-xs text-muted-foreground truncate">{index.name}</span>
<span className="text-[10px] text-[#484f58] tabular-nums">{index.code.replace(/\.(SH|SZ)$/, "")}</span> <span className="text-[10px] text-muted-foreground/50 tabular-nums">{index.code.replace(/\.(SH|SZ)$/, "")}</span>
</div> </div>
<div className={`text-xl font-bold tabular-nums ${isUp ? "text-[#f85149]" : "text-[#3fb950]"}`}> <div className={`text-xl font-bold tabular-nums ${isUp ? "text-[#f85149]" : "text-[#3fb950]"}`}>
{index.price.toLocaleString("zh-CN", { minimumFractionDigits: 2, maximumFractionDigits: 2 })} {index.price.toLocaleString("zh-CN", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
@@ -172,12 +172,12 @@ function AuctionSignalBar({ stats }: { stats: MarketDashboardData["marketStats"]
"#d29922"; "#d29922";
return ( return (
<div className="bg-[#161b22] border border-[#21262d] rounded-lg px-4 py-2 flex items-center gap-3"> <div className="bg-card border border-border rounded-lg px-4 py-2 flex items-center gap-3">
<Target className="h-4 w-4 shrink-0" style={{ color }} /> <Target className="h-4 w-4 shrink-0" style={{ color }} />
<span className="text-xs text-[#8b949e]">竞价信号</span> <span className="text-xs text-muted-foreground">竞价信号</span>
<span className="text-sm font-semibold" style={{ color }}>{signal}</span> <span className="text-sm font-semibold" style={{ color }}>{signal}</span>
{stats.auction?.date && ( {stats.auction?.date && (
<span className="text-[10px] text-[#484f58] ml-auto">{stats.auction.date}</span> <span className="text-[10px] text-muted-foreground/50 ml-auto">{stats.auction.date}</span>
)} )}
</div> </div>
); );
@@ -195,10 +195,10 @@ function MarketTemperatureSection({ stats }: { stats: MarketDashboardData["marke
temp.score >= 20 ? "#3fb950" : "#58a6ff"; temp.score >= 20 ? "#3fb950" : "#58a6ff";
return ( return (
<div className="bg-[#161b22] border border-[#21262d] rounded-lg p-4"> <div className="bg-card border border-border rounded-lg p-4">
<div className="flex items-center justify-between mb-3"> <div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Thermometer className="h-4 w-4 text-[#8b949e]" /> <Thermometer className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium">市场温度</span> <span className="text-sm font-medium">市场温度</span>
</div> </div>
<div className="flex items-baseline gap-2"> <div className="flex items-baseline gap-2">
@@ -223,7 +223,7 @@ function MarketTemperatureSection({ stats }: { stats: MarketDashboardData["marke
{/* 温度因子明细 */} {/* 温度因子明细 */}
{temp.factors && ( {temp.factors && (
<div className="mt-3 pt-2 border-t border-[#21262d] flex flex-wrap gap-x-4 gap-y-1 text-[10px] text-[#484f58]"> <div className="mt-3 pt-2 border-t border-border flex flex-wrap gap-x-4 gap-y-1 text-[10px] text-muted-foreground/50">
<span>涨跌 {temp.factors.advanceScore > 0 ? "+" : ""}{temp.factors.advanceScore}</span> <span>涨跌 {temp.factors.advanceScore > 0 ? "+" : ""}{temp.factors.advanceScore}</span>
<span>中位 {temp.factors.medianScore > 0 ? "+" : ""}{temp.factors.medianScore}</span> <span>中位 {temp.factors.medianScore > 0 ? "+" : ""}{temp.factors.medianScore}</span>
<span>强弱 {temp.factors.strongScore > 0 ? "+" : ""}{temp.factors.strongScore}</span> <span>强弱 {temp.factors.strongScore > 0 ? "+" : ""}{temp.factors.strongScore}</span>
@@ -239,7 +239,7 @@ function MarketTemperatureSection({ stats }: { stats: MarketDashboardData["marke
function StatItem({ label, value, valueColor }: { label: string; value: string; valueColor?: string }) { function StatItem({ label, value, valueColor }: { label: string; value: string; valueColor?: string }) {
return ( return (
<div> <div>
<div className="text-[10px] text-[#484f58] mb-0.5">{label}</div> <div className="text-[10px] text-muted-foreground/50 mb-0.5">{label}</div>
<div className="text-sm font-medium tabular-nums" style={valueColor ? { color: valueColor } : undefined}> <div className="text-sm font-medium tabular-nums" style={valueColor ? { color: valueColor } : undefined}>
{value} {value}
</div> </div>
@@ -259,13 +259,13 @@ function AdvanceDeclineBar({ stats }: { stats: MarketDashboardData["marketStats"
const downPct = (stats.downCount / total) * 100; const downPct = (stats.downCount / total) * 100;
return ( return (
<div className="bg-[#161b22] border border-[#21262d] rounded-lg p-4"> <div className="bg-card border border-border rounded-lg p-4">
<div className="flex items-center justify-between mb-3"> <div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<BarChart3 className="h-4 w-4 text-[#8b949e]" /> <BarChart3 className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium">涨跌家数分布</span> <span className="text-sm font-medium">涨跌家数分布</span>
</div> </div>
<span className="text-[10px] text-[#484f58]"> <span className="text-[10px] text-muted-foreground/50">
涨停 {stats.limitUp} 炸板 {stats.limitBreak} 跌停 {stats.limitDown} 共 {total} 只 涨停 {stats.limitUp} 炸板 {stats.limitBreak} 跌停 {stats.limitDown} 共 {total} 只
</span> </span>
</div> </div>
@@ -279,17 +279,17 @@ function AdvanceDeclineBar({ stats }: { stats: MarketDashboardData["marketStats"
<div className="flex items-center gap-4 text-xs"> <div className="flex items-center gap-4 text-xs">
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<span className="w-2 h-2 rounded-full bg-[#f85149]" /> <span className="w-2 h-2 rounded-full bg-[#f85149]" />
<span className="text-[#8b949e]">上涨</span> <span className="text-muted-foreground">上涨</span>
<span className="font-medium tabular-nums">{stats.upCount}</span> <span className="font-medium tabular-nums">{stats.upCount}</span>
</div> </div>
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<span className="w-2 h-2 rounded-full bg-[#484f58]" /> <span className="w-2 h-2 rounded-full bg-[#484f58]" />
<span className="text-[#8b949e]">平盘</span> <span className="text-muted-foreground">平盘</span>
<span className="font-medium tabular-nums">{stats.flatCount}</span> <span className="font-medium tabular-nums">{stats.flatCount}</span>
</div> </div>
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<span className="w-2 h-2 rounded-full bg-[#3fb950]" /> <span className="w-2 h-2 rounded-full bg-[#3fb950]" />
<span className="text-[#8b949e]">下跌</span> <span className="text-muted-foreground">下跌</span>
<span className="font-medium tabular-nums">{stats.downCount}</span> <span className="font-medium tabular-nums">{stats.downCount}</span>
</div> </div>
</div> </div>
@@ -304,10 +304,10 @@ function SectorStrengthList({ sectors }: { sectors: SectorStrengthItem[] }) {
if (sectors.length === 0) return null; if (sectors.length === 0) return null;
return ( return (
<div className="bg-[#161b22] border border-[#21262d] rounded-lg p-4"> <div className="bg-card border border-border rounded-lg p-4">
<div className="flex items-center justify-between mb-3"> <div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Zap className="h-4 w-4 text-[#8b949e]" /> <Zap className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium">行业强度榜 TOP{sectors.length}</span> <span className="text-sm font-medium">行业强度榜 TOP{sectors.length}</span>
</div> </div>
</div> </div>
@@ -328,7 +328,7 @@ function SectorRow({ sector }: { sector: SectorStrengthItem }) {
return ( return (
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<span className="text-xs w-20 shrink-0 truncate" title={sector.name}>{sector.name}</span> <span className="text-xs w-20 shrink-0 truncate" title={sector.name}>{sector.name}</span>
<div className="flex-1 h-4 bg-[#0d1117] rounded-sm overflow-hidden relative"> <div className="flex-1 h-4 bg-background rounded-sm overflow-hidden relative">
<div <div
className="h-full rounded-sm transition-all duration-500" className="h-full rounded-sm transition-all duration-500"
style={{ style={{
@@ -340,12 +340,12 @@ function SectorRow({ sector }: { sector: SectorStrengthItem }) {
/> />
</div> </div>
<div className="flex items-center gap-2 shrink-0 text-[10px] tabular-nums w-44 justify-end"> <div className="flex items-center gap-2 shrink-0 text-[10px] tabular-nums w-44 justify-end">
<span className="text-[#8b949e]">强度 {sector.strength}</span> <span className="text-muted-foreground">强度 {sector.strength}</span>
<span className={isUp ? "text-[#f85149]" : "text-[#3fb950]"}> <span className={isUp ? "text-[#f85149]" : "text-[#3fb950]"}>
{isUp ? "+" : ""}{sector.changePct.toFixed(2)}% {isUp ? "+" : ""}{sector.changePct.toFixed(2)}%
</span> </span>
<span className="text-[#8b949e]">宽度 {sector.breadthPct}%</span> <span className="text-muted-foreground">宽度 {sector.breadthPct}%</span>
<span className="text-[#8b949e]">强 {sector.strongCount}</span> <span className="text-muted-foreground">强 {sector.strongCount}</span>
</div> </div>
</div> </div>
); );
@@ -358,10 +358,10 @@ function ConceptStrengthList({ concepts }: { concepts: MarketDashboardData["conc
if (concepts.length === 0) return null; if (concepts.length === 0) return null;
return ( return (
<div className="bg-[#161b22] border border-[#21262d] rounded-lg p-4"> <div className="bg-card border border-border rounded-lg p-4">
<div className="flex items-center justify-between mb-3"> <div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<TrendingUp className="h-4 w-4 text-[#8b949e]" /> <TrendingUp className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium">概念板块热度 TOP{concepts.length}</span> <span className="text-sm font-medium">概念板块热度 TOP{concepts.length}</span>
</div> </div>
</div> </div>
@@ -395,15 +395,15 @@ function ConceptStrengthList({ concepts }: { concepts: MarketDashboardData["conc
============================================================ */ ============================================================ */
function EventSidebar({ events }: { events: MarketDashboardData["events"] }) { function EventSidebar({ events }: { events: MarketDashboardData["events"] }) {
return ( return (
<div className="bg-[#161b22] border border-[#21262d] rounded-lg p-4"> <div className="bg-card border border-border rounded-lg p-4">
<div className="flex items-center gap-2 mb-3"> <div className="flex items-center gap-2 mb-3">
<Newspaper className="h-4 w-4 text-[#8b949e]" /> <Newspaper className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium">事件情报</span> <span className="text-sm font-medium">事件情报</span>
</div> </div>
<div className="space-y-3"> <div className="space-y-3">
{events.length === 0 ? ( {events.length === 0 ? (
<p className="text-xs text-[#484f58]">暂无事件</p> <p className="text-xs text-muted-foreground/50">暂无事件</p>
) : ( ) : (
events.map((evt, i) => ( events.map((evt, i) => (
<EventItem key={i} event={evt} /> <EventItem key={i} event={evt} />
@@ -421,20 +421,20 @@ function EventItem({ event }: { event: MarketDashboardData["events"][0] }) {
event.type === "hot" ? "text-[#d29922] bg-[#d29922]/10 border-[#d29922]/30" : event.type === "hot" ? "text-[#d29922] bg-[#d29922]/10 border-[#d29922]/30" :
event.type === "skyrocket" ? "text-[#f778ba] bg-[#f778ba]/10 border-[#f778ba]/30" : event.type === "skyrocket" ? "text-[#f778ba] bg-[#f778ba]/10 border-[#f778ba]/30" :
event.type === "dragon_tiger" ? "text-[#a371f7] bg-[#a371f7]/10 border-[#a371f7]/30" : event.type === "dragon_tiger" ? "text-[#a371f7] bg-[#a371f7]/10 border-[#a371f7]/30" :
event.type === "limit_break" ? "text-[#8b949e] bg-[#8b949e]/10 border-[#8b949e]/30" : event.type === "limit_break" ? "text-muted-foreground bg-[#8b949e]/10 border-[#8b949e]/30" :
event.type === "anomaly" ? "text-[#58a6ff] bg-[#58a6ff]/10 border-[#58a6ff]/30" : event.type === "anomaly" ? "text-[#58a6ff] bg-[#58a6ff]/10 border-[#58a6ff]/30" :
"text-[#8b949e] bg-[#8b949e]/10 border-[#8b949e]/30"; "text-muted-foreground bg-[#8b949e]/10 border-[#8b949e]/30";
return ( return (
<div className="border-l-2 border-[#21262d] pl-3"> <div className="border-l-2 border-border pl-3">
<div className="flex items-center gap-2 mb-0.5"> <div className="flex items-center gap-2 mb-0.5">
<span className={`text-[9px] font-medium px-1 py-0.5 rounded border ${labelColor}`}> <span className={`text-[9px] font-medium px-1 py-0.5 rounded border ${labelColor}`}>
{event.label} {event.label}
</span> </span>
</div> </div>
<p className="text-xs text-[#e6edf3] leading-relaxed">{event.name}</p> <p className="text-xs text-foreground leading-relaxed">{event.name}</p>
{event.detail && ( {event.detail && (
<p className="text-[10px] text-[#484f58] mt-0.5">{event.detail}</p> <p className="text-[10px] text-muted-foreground/50 mt-0.5">{event.detail}</p>
)} )}
</div> </div>
); );
@@ -448,35 +448,35 @@ function DashboardSkeleton() {
<div className="space-y-4"> <div className="space-y-4">
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3"> <div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3">
{Array.from({ length: 5 }).map((_, i) => ( {Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="bg-[#161b22] border border-[#21262d] rounded-lg p-3 animate-pulse"> <div key={i} className="bg-card border border-border rounded-lg p-3 animate-pulse">
<div className="h-3 w-16 bg-[#21262d] rounded mb-2" /> <div className="h-3 w-16 bg-muted rounded mb-2" />
<div className="h-6 w-24 bg-[#21262d] rounded mb-1" /> <div className="h-6 w-24 bg-muted rounded mb-1" />
<div className="h-3 w-20 bg-[#21262d] rounded" /> <div className="h-3 w-20 bg-muted rounded" />
</div> </div>
))} ))}
</div> </div>
<div className="bg-[#161b22] border border-[#21262d] rounded-lg p-4 animate-pulse"> <div className="bg-card border border-border rounded-lg p-4 animate-pulse">
<div className="h-5 w-24 bg-[#21262d] rounded mb-4" /> <div className="h-5 w-24 bg-muted rounded mb-4" />
<div className="grid grid-cols-3 gap-3"> <div className="grid grid-cols-3 gap-3">
{Array.from({ length: 6 }).map((_, i) => ( {Array.from({ length: 6 }).map((_, i) => (
<div key={i}> <div key={i}>
<div className="h-2 w-16 bg-[#21262d] rounded mb-1" /> <div className="h-2 w-16 bg-muted rounded mb-1" />
<div className="h-4 w-12 bg-[#21262d] rounded" /> <div className="h-4 w-12 bg-muted rounded" />
</div> </div>
))} ))}
</div> </div>
</div> </div>
<div className="bg-[#161b22] border border-[#21262d] rounded-lg p-4 animate-pulse"> <div className="bg-card border border-border rounded-lg p-4 animate-pulse">
<div className="h-5 w-32 bg-[#21262d] rounded mb-3" /> <div className="h-5 w-32 bg-muted rounded mb-3" />
<div className="h-5 w-full bg-[#21262d] rounded-full" /> <div className="h-5 w-full bg-muted rounded-full" />
</div> </div>
<div className="bg-[#161b22] border border-[#21262d] rounded-lg p-4 animate-pulse"> <div className="bg-card border border-border rounded-lg p-4 animate-pulse">
<div className="h-5 w-40 bg-[#21262d] rounded mb-3" /> <div className="h-5 w-40 bg-muted rounded mb-3" />
{Array.from({ length: 8 }).map((_, i) => ( {Array.from({ length: 8 }).map((_, i) => (
<div key={i} className="flex items-center gap-3 mb-2"> <div key={i} className="flex items-center gap-3 mb-2">
<div className="h-3 w-16 bg-[#21262d] rounded" /> <div className="h-3 w-16 bg-muted rounded" />
<div className="flex-1 h-4 bg-[#21262d] rounded" /> <div className="flex-1 h-4 bg-muted rounded" />
<div className="h-3 w-24 bg-[#21262d] rounded" /> <div className="h-3 w-24 bg-muted rounded" />
</div> </div>
))} ))}
</div> </div>
+1 -1
View File
@@ -211,7 +211,7 @@ function Index() {
A股走势追踪 A股走势追踪
</h1> </h1>
<p className="text-sm md:text-base text-muted-foreground">创建股票集合,分享历史走势</p> <p className="text-sm md:text-base text-muted-foreground">创建股票集合,分享历史走势</p>
<div className="mt-3 flex items-center justify-center gap-2"> <div className="mt-3 flex flex-wrap items-center justify-center gap-2">
<Link to="/dashboard"> <Link to="/dashboard">
<Button variant="outline" size="sm" className="gap-1.5 text-xs"> <Button variant="outline" size="sm" className="gap-1.5 text-xs">
<BarChart3 className="h-3.5 w-3.5" /> <BarChart3 className="h-3.5 w-3.5" />
+61 -29
View File
@@ -149,30 +149,43 @@
/* AI Report Content Styles */ /* AI Report Content Styles */
.ai-report-content { .ai-report-content {
color: #c9d1d9; color: var(--card-foreground);
line-height: 1.7; line-height: 1.6;
font-size: 14px;
}
@media (min-width: 640px) {
.ai-report-content {
font-size: 15px;
}
} }
.ai-report-content h1, .ai-report-content h1,
.ai-report-content h2, .ai-report-content h2,
.ai-report-content h3, .ai-report-content h3,
.ai-report-content h4 { .ai-report-content h4 {
color: #e6edf3; color: var(--foreground);
font-weight: 600; font-weight: 600;
margin-top: 1.5em; margin-top: 1.2em;
margin-bottom: 0.5em; margin-bottom: 0.5em;
} }
.ai-report-content h1 { font-size: 1.5em; } .ai-report-content h1 { font-size: 1.4em; }
.ai-report-content h2 { font-size: 1.3em; } .ai-report-content h2 { font-size: 1.2em; }
.ai-report-content h3 { font-size: 1.15em; } .ai-report-content h3 { font-size: 1.05em; }
@media (min-width: 640px) {
.ai-report-content h1 { font-size: 1.5em; }
.ai-report-content h2 { font-size: 1.3em; }
.ai-report-content h3 { font-size: 1.15em; }
}
.ai-report-content p { .ai-report-content p {
margin-bottom: 0.8em; margin-bottom: 0.7em;
} }
.ai-report-content strong { .ai-report-content strong {
color: #e6edf3; color: var(--foreground);
font-weight: 600; font-weight: 600;
} }
@@ -183,59 +196,78 @@
} }
.ai-report-content li { .ai-report-content li {
margin-bottom: 0.3em; margin-bottom: 0.25em;
} }
.ai-report-content blockquote { .ai-report-content blockquote {
border-left: 3px solid #30363d; border-left: 3px solid var(--border);
padding-left: 1em; padding-left: 0.8em;
margin: 0.8em 0; margin: 0.7em 0;
color: #8b949e; color: var(--muted-foreground);
font-size: 0.95em;
} }
.ai-report-content code { .ai-report-content code {
background: #0d1117; background: var(--muted);
padding: 0.15em 0.4em; padding: 0.15em 0.35em;
border-radius: 4px; border-radius: 4px;
font-size: 0.9em; font-size: 0.85em;
color: #79c0ff; color: var(--primary);
} }
.ai-report-content pre { .ai-report-content pre {
background: #0d1117; background: var(--muted);
padding: 1em; padding: 0.8em;
border-radius: 6px; border-radius: 6px;
overflow-x: auto; overflow-x: auto;
margin: 0.8em 0; margin: 0.7em 0;
-webkit-overflow-scrolling: touch;
} }
.ai-report-content pre code { .ai-report-content pre code {
background: none; background: none;
padding: 0; padding: 0;
color: #c9d1d9; color: var(--card-foreground);
font-size: 0.85em;
}
.ai-report-content table-wrapper {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
margin: 0.7em 0;
} }
.ai-report-content table { .ai-report-content table {
width: 100%; width: 100%;
border-collapse: collapse; border-collapse: collapse;
margin: 0.8em 0; min-width: 280px;
} }
.ai-report-content th, .ai-report-content th,
.ai-report-content td { .ai-report-content td {
border: 1px solid #30363d; border: 1px solid var(--border);
padding: 0.5em 0.8em; padding: 0.4em 0.6em;
text-align: left; text-align: left;
font-size: 0.9em;
white-space: nowrap;
}
@media (min-width: 640px) {
.ai-report-content th,
.ai-report-content td {
padding: 0.5em 0.8em;
font-size: 1em;
}
} }
.ai-report-content th { .ai-report-content th {
background: #21262d; background: var(--muted);
color: #e6edf3; color: var(--foreground);
font-weight: 600; font-weight: 600;
} }
.ai-report-content hr { .ai-report-content hr {
border: none; border: none;
border-top: 1px solid #30363d; border-top: 1px solid var(--border);
margin: 1.5em 0; margin: 1.2em 0;
} }