#602 [PR 5 + PR 6] 全局导航与应用外壳 & 系统管理与登录模块 (#714)

* refactor(web): rebuild shared ui primitives and tokens

* feat(web): add shell, theming, and page layout integration

* feat(auth): expose setup-state auth contract

* feat(web): rebuild login, settings, and auth flows

* test(web): add smoke coverage and ui regressions

* feat(web): add page titles to all pages

Add document.title to all pages for better browser tab identification:
- HomePage: '每日选股分析 - DSA'
- ChatPage: '策略问股 - DSA'
- SettingsPage: '系统设置 - DSA'
- BacktestPage: '策略回测 - DSA'
- PortfolioPage: '持仓分析 - DSA'
- LoginPage: '登录 - DSA'
- NotFoundPage: '页面未找到 - DSA'

All pages use useEffect to set the title on mount.

* feat(web): enhance UI with ongoing adjustments and improvements; update README for user guidance
feat(tests): add unit tests for useSystemConfig hook to ensure stability and functionality
chore(changelog): document major updates including UI refresh, auth workflow overhaul, and test coverage enhancements

* fix: encode non-ascii email sender names (#712)

* fix: encode non-ascii email sender names

* fix: clarify _close_server silent-exception intent and add inline-image sender name test

fixes #708

* docs: add EN doc index, contributing guide, bot guide; bilingual issue/PR templates (#713)

* docs: add EN doc index, contributing guide, bot guide; bilingual issue/PR templates

- Add docs/INDEX_EN.md: full English docs index with China-market glossary
- Add docs/CONTRIBUTING_EN.md: English contributing guide (setup, CI, commit conventions)
- Add docs/bot-command_EN.md: English bot integration guide (commands, webhooks, config)
- Bilingualize .github/ISSUE_TEMPLATE/bug_report.md and feature_request.md
- Update .github/ISSUE_TEMPLATE/config.yml with English Docs Index link
- Bilingualize .github/PULL_REQUEST_TEMPLATE.md checklist and field labels
- Add CONTRIBUTING_EN and INDEX_EN links to docs/README_EN.md nav bar

Refs #711

* docs: fix review feedback on bot-command_EN and CONTRIBUTING_EN

- Correct bot/platforms/ directory tree to match actual files
  (feishu_stream.py+discord.py present; feishu.py/wecom.py/telegram.py absent)
- Add missing commands: /ask, /chat, /batch to commands table
- Fix BotCommand.execute() signature: sync def, not async
- Clarify webhook routes as planned/not-yet-registered in FastAPI;
  point to bot/handler.py as the actual implementation location
- Fix backend-gate description in CI table to include ./test.sh code
  and ./test.sh yfinance steps from ci_gate.sh

* docs: fix format_response signature and webhook route status in bot-command_EN

- format_response: correct signature to (response, message) -> WebhookResponse
  to match bot/platforms/base.py abstract method
- Webhook route table: clarify that only dingtalk is in ALL_PLATFORMS (webhook
  mode ready); feishu is stream-only; wecom/telegram not yet implemented
- Add concrete example for mounting dingtalk webhook in FastAPI

* docs: fix remaining review feedback in EN docs

- bot-command_EN: stop claiming bot env keys are in .env.example
- bot-command_EN: mount webhook routes in api/app.py instead of api/v1/router.py
- CONTRIBUTING_EN: keep PR CI table limited to actual pull-request checks
- CONTRIBUTING_EN: clarify network-smoke is schedule/workflow_dispatch only

* docs: clarify EN issue links and bot env guidance

* feat(ui): refine settings actions and import conflict recovery

* feat(auth): implement session invalidation on logout and handle errors

---------

Co-authored-by: mumu <42829555+ZhuLinsen@users.noreply.github.com>
This commit is contained in:
LouisHong
2026-03-17 21:35:17 +08:00
committed by GitHub
parent 2b8518d808
commit f3a8993283
82 changed files with 5826 additions and 1365 deletions

View File

@@ -376,7 +376,7 @@ LITELLM_MODEL=openai/deepseek-chat
> 有建议?欢迎 [提交 Issue](https://github.com/ZhuLinsen/daily_stock_analysis/issues)
> ⚠️ **UI 调整提示**:项目当前正在持续进行 Web UI 调整与升级,部分页面在过渡阶段可能仍存在样式、交互或兼容性问题。欢迎通过 [Issue](https://github.com/ZhuLinsen/daily_stock_analysis/issues) 反馈问题,或直接提交 [Pull Request](https://github.com/ZhuLinsen/daily_stock_analysis/pulls) 一起完善。
---
## ☕ 支持项目

View File

@@ -154,24 +154,42 @@ def _set_session_cookie(response: Response, session_value: str, request: Request
)
def _get_auth_status_dict(request: Request | None = None) -> dict:
"""Helper to build consistent auth status response body."""
auth_enabled = is_auth_enabled()
logged_in = False
if auth_enabled and request:
cookie_val = request.cookies.get(COOKIE_NAME)
logged_in = verify_session(cookie_val) if cookie_val else False
# setupState determination:
# - enabled: auth is active
# - password_retained: auth disabled but password exists
# - no_password: auth disabled and no password exists
if auth_enabled:
setup_state = "enabled"
elif has_stored_password():
setup_state = "password_retained"
else:
setup_state = "no_password"
return {
"authEnabled": auth_enabled,
"loggedIn": logged_in,
"passwordSet": _password_set_for_response(auth_enabled),
"passwordChangeable": is_password_changeable() if auth_enabled else False,
"setupState": setup_state,
}
@router.get(
"/status",
summary="Get auth status",
description="Returns whether auth is enabled and if the current request is logged in.",
)
async def auth_status(request: Request):
"""Return authEnabled, loggedIn, passwordSet, passwordChangeable without requiring auth."""
auth_enabled = is_auth_enabled()
logged_in = False
if auth_enabled:
cookie_val = request.cookies.get(COOKIE_NAME)
logged_in = verify_session(cookie_val) if cookie_val else False
return {
"authEnabled": auth_enabled,
"loggedIn": logged_in,
"passwordSet": _password_set_for_response(auth_enabled),
"passwordChangeable": is_password_changeable() if auth_enabled else False,
}
"""Return authEnabled, loggedIn, passwordSet, passwordChangeable, setupState without requiring auth."""
return _get_auth_status_dict(request)
@router.post(
@@ -322,29 +340,20 @@ async def auth_update_settings(request: Request, body: AuthSettingsRequest):
status_code=500,
content={"error": "internal_error", "message": "Failed to create session"},
)
resp = JSONResponse(
content={
"authEnabled": True,
"loggedIn": True,
"passwordSet": _password_set_for_response(True),
"passwordChangeable": True,
}
)
# We manually set loggedIn=True because the cookie is being set in this response
# and won't be visible in request.cookies until the NEXT request.
content = _get_auth_status_dict(request)
content["loggedIn"] = True
resp = JSONResponse(content=content)
_set_session_cookie(resp, session_val, request)
return resp
resp = JSONResponse(
content={
"authEnabled": False,
"loggedIn": False,
"passwordSet": _password_set_for_response(False),
"passwordChangeable": False,
}
)
resp = JSONResponse(content=_get_auth_status_dict(request))
resp.delete_cookie(key=COOKIE_NAME, path="/")
return resp
@router.post(
"/login",
summary="Login or set initial password",
@@ -458,6 +467,11 @@ async def auth_change_password(body: ChangePasswordRequest):
)
async def auth_logout(request: Request):
"""Clear session cookie."""
if is_auth_enabled() and not rotate_session_secret():
return JSONResponse(
status_code=500,
content={"error": "internal_error", "message": "Failed to invalidate session"},
)
resp = Response(status_code=204)
resp.delete_cookie(key=COOKIE_NAME, path="/")
return resp

View File

@@ -1,4 +1,4 @@
const { app, BrowserWindow, shell } = require('electron');
const { app, BrowserWindow, shell, nativeTheme } = require('electron');
const path = require('path');
const fs = require('fs');
const { spawn } = require('child_process');
@@ -10,6 +10,10 @@ let backendProcess = null;
let logFilePath = null;
let backendStartError = null;
function resolveWindowBackgroundColor() {
return nativeTheme.shouldUseDarkColors ? '#08080c' : '#f4f7fb';
}
const isWindows = process.platform === 'win32';
const appRootDev = path.resolve(__dirname, '..', '..');
@@ -399,7 +403,7 @@ async function createWindow() {
height: 800,
minWidth: 960,
minHeight: 640,
backgroundColor: '#0f172a',
backgroundColor: resolveWindowBackgroundColor(),
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
nodeIntegration: false,
@@ -413,6 +417,17 @@ async function createWindow() {
await mainWindow.loadFile(loadingPath);
logStartup(`Loading page rendered in ${Date.now() - loadingPageStartedAt}ms`);
const applyThemeBackground = () => {
if (!mainWindow || mainWindow.isDestroyed()) {
return;
}
mainWindow.setBackgroundColor(resolveWindowBackgroundColor());
};
nativeTheme.on('updated', applyThemeBackground);
mainWindow.once('closed', () => {
nativeTheme.removeListener('updated', applyThemeBackground);
});
const webViewStartedAt = Date.now();
mainWindow.webContents.on('did-start-loading', () => {
logStartup('WebContents did-start-loading');

View File

@@ -6,11 +6,30 @@
<title>Daily Stock Analysis</title>
<style>
:root {
--bg: #0f172a;
--panel: #111827;
--text: #e2e8f0;
--muted: #94a3b8;
--accent: #38bdf8;
color-scheme: dark light;
--bg: #f4f7fb;
--bg-secondary: #e8edf5;
--panel: rgba(255, 255, 255, 0.88);
--text: #172033;
--muted: #5e6a7e;
--accent: #0ea5c6;
--danger-bg: rgba(225, 29, 72, 0.08);
--danger-border: rgba(225, 29, 72, 0.32);
--danger-text: #b91c3c;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #08080c;
--bg-secondary: #131722;
--panel: rgba(16, 18, 28, 0.9);
--text: #e2e8f0;
--muted: #94a3b8;
--accent: #38bdf8;
--danger-bg: rgba(239, 68, 68, 0.08);
--danger-border: rgba(239, 68, 68, 0.4);
--danger-text: #fecaca;
}
}
* {
@@ -20,7 +39,7 @@
body {
margin: 0;
font-family: "Fira Sans", "IBM Plex Sans", "Segoe UI", sans-serif;
background: radial-gradient(circle at top left, #1e293b, #0f172a 55%);
background: radial-gradient(circle at top left, var(--bg-secondary), var(--bg) 55%);
color: var(--text);
height: 100vh;
display: flex;
@@ -30,11 +49,12 @@
.panel {
width: min(640px, 90vw);
background: rgba(17, 24, 39, 0.9);
border: 1px solid rgba(56, 189, 248, 0.2);
background: var(--panel);
border: 1px solid color-mix(in srgb, var(--accent) 26%, transparent);
border-radius: 16px;
padding: 32px 36px;
box-shadow: 0 30px 80px rgba(15, 23, 42, 0.35);
box-shadow: 0 30px 80px rgba(15, 23, 42, 0.18);
backdrop-filter: blur(18px);
}
.title {
@@ -61,9 +81,9 @@
margin-top: 16px;
padding: 12px 14px;
border-radius: 10px;
background: rgba(239, 68, 68, 0.08);
border: 1px solid rgba(239, 68, 68, 0.4);
color: #fecaca;
background: var(--danger-bg);
border: 1px solid var(--danger-border);
color: var(--danger-text);
font-size: 12px;
line-height: 1.5;
display: none;

View File

@@ -11,6 +11,8 @@ node_modules
dist
dist-ssr
*.local
playwright-report
test-results
# Editor directories and files
.vscode/*

View File

@@ -0,0 +1,131 @@
import { expect, test, type Page } from '@playwright/test';
const smokePassword = process.env.DSA_WEB_SMOKE_PASSWORD;
async function login(page: Page) {
test.skip(!smokePassword, 'Set DSA_WEB_SMOKE_PASSWORD to run authenticated smoke tests.');
// Navigate to login page
await page.goto('/login');
await page.waitForLoadState('domcontentloaded');
// Wait for password input to be visible
await expect(page.locator('#password')).toBeVisible({ timeout: 10_000 });
// Fill password and submit
await page.locator('#password').fill(smokePassword!);
// Wait for and click the submit button
const submitButton = page.getByRole('button', { name: /授权进入工作台|完成设置并登录/ });
await expect(submitButton).toBeVisible();
await Promise.all([
page.waitForResponse(
(response) => response.url().includes('/api/v1/auth/login') && response.status() === 200,
{ timeout: 15_000 }
),
submitButton.click(),
]);
// Wait for navigation to home page after login
await page.waitForURL('/', { timeout: 15_000 });
await page.waitForLoadState('domcontentloaded');
await page.waitForTimeout(1000);
}
test.describe('web smoke', () => {
test('login page renders password form', async ({ page }) => {
await page.goto('/login');
await page.waitForLoadState('domcontentloaded');
// Check for branding
await expect(page.getByText('DAILY STOCK').first()).toBeVisible();
await expect(page.getByText('Analysis Engine')).toBeVisible();
// Check for password input
await expect(page.locator('#password')).toBeVisible();
// Check for submit button
await expect(page.getByRole('button', { name: /授权进入工作台|完成设置并登录/ })).toBeVisible();
});
test('home page shows analysis entry and history panel after login', async ({ page }) => {
await login(page);
const stockInput = page.getByPlaceholder('输入股票代码,如 600519、00700、AAPL');
await expect(stockInput).toBeVisible({ timeout: 10_000 });
await expect(page.getByRole('link', { name: '首页' })).toBeVisible();
await expect(page.getByRole('link', { name: '问股' })).toBeVisible();
await expect(page.getByText('历史分析')).toBeVisible();
await stockInput.fill('600519');
const analyzeButton = page.getByRole('button', { name: '分析', exact: true });
await expect(analyzeButton).toBeVisible();
});
test('chat page allows entering a question and starts a request', async ({ page }) => {
await login(page);
// Navigate to chat page by clicking the link
await page.getByRole('link', { name: '问股' }).click();
await page.waitForLoadState('domcontentloaded');
await page.waitForTimeout(1000);
await expect(page.getByTestId('chat-workspace')).toBeVisible({ timeout: 10_000 });
await expect(page.getByTestId('chat-session-list-scroll')).toBeVisible();
await expect(page.getByTestId('chat-message-scroll')).toBeVisible();
const input = page.getByPlaceholder(/分析 600519/);
await expect(input).toBeVisible({ timeout: 5000 });
await expect(page.getByText('策略', { exact: true })).toBeVisible();
const prompt = '请简要分析 600519';
await input.fill(prompt);
await page.getByRole('button', { name: '发送' }).click();
await expect(page.locator('p').filter({ hasText: prompt }).last()).toBeVisible({ timeout: 5000 });
});
test('mobile shell opens navigation drawer after login', async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await login(page);
// Try to open navigation menu
const menuButton = page.getByRole('button', { name: /打开导航|菜单/i });
if (await menuButton.isVisible({ timeout: 2000 }).catch(() => false)) {
await menuButton.click();
}
// Check if navigation is visible
await expect(page.getByRole('link', { name: '回测' })).toBeVisible({ timeout: 5000 });
});
test('settings page renders title and save actions after login', async ({ page }) => {
await login(page);
// Navigate to settings page by clicking the link
await page.getByRole('link', { name: '设置' }).click();
await page.waitForLoadState('domcontentloaded');
await page.waitForTimeout(1000);
// Use text content instead of role for heading
await expect(page.getByText('系统设置')).toBeVisible({ timeout: 10_000 });
await expect(page.getByRole('button', { name: '重置' })).toBeVisible();
await expect(page.getByRole('button', { name: /保存配置/ })).toBeVisible();
});
test('backtest page renders filter controls after login', async ({ page }) => {
await login(page);
// Navigate to backtest page by clicking the link
await page.getByRole('link', { name: '回测' }).click();
await page.waitForLoadState('domcontentloaded');
await page.waitForTimeout(1000);
// Check for filter controls
const filterInput = page.getByPlaceholder(/stock code/i);
await expect(filterInput).toBeVisible({ timeout: 10_000 });
await expect(page.getByRole('button', { name: /filter/i })).toBeVisible();
await expect(page.getByRole('button', { name: /run backtest/i })).toBeVisible();
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -7,6 +7,8 @@
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"test": "vitest run",
"test:smoke": "playwright test",
"preview": "vite preview"
},
"dependencies": {
@@ -14,6 +16,9 @@
"axios": "^1.13.4",
"camelcase-keys": "^10.0.2",
"clsx": "^2.1.1",
"lucide-react": "^0.555.0",
"motion": "^12.36.0",
"next-themes": "^0.4.6",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-markdown": "^10.1.0",
@@ -25,7 +30,10 @@
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@playwright/test": "^1.58.2",
"@tailwindcss/postcss": "^4.1.18",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@types/node": "^24.10.1",
"@types/react": "^19.2.5",
"@types/react-dom": "^19.2.3",
@@ -36,10 +44,12 @@
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"jsdom": "^28.1.0",
"postcss": "^8.5.6",
"tailwindcss": "^4.1.18",
"typescript": "~5.9.3",
"typescript-eslint": "^8.46.4",
"vite": "^7.2.4"
"vite": "^7.2.4",
"vitest": "^4.1.0"
}
}

View File

@@ -0,0 +1,60 @@
import { defineConfig, devices } from '@playwright/test';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const currentDir = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(currentDir, '../..');
function resolveBackendCommand() {
if (process.env.DSA_WEB_SMOKE_BACKEND_CMD) {
return process.env.DSA_WEB_SMOKE_BACKEND_CMD;
}
const unixVenvPython = path.join(repoRoot, '.venv', 'bin', 'python');
if (fs.existsSync(unixVenvPython)) {
return `${unixVenvPython} main.py --webui-only --host 127.0.0.1 --port 8000`;
}
const windowsVenvPython = path.join(repoRoot, '.venv', 'Scripts', 'python.exe');
if (fs.existsSync(windowsVenvPython)) {
return `"${windowsVenvPython}" main.py --webui-only --host 127.0.0.1 --port 8000`;
}
return 'python main.py --webui-only --host 127.0.0.1 --port 8000';
}
export default defineConfig({
testDir: './e2e',
fullyParallel: false,
retries: process.env.CI ? 2 : 0,
reporter: 'list',
use: {
baseURL: 'http://127.0.0.1:4173',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
video: 'retain-on-failure',
},
webServer: [
{
command: resolveBackendCommand(),
cwd: repoRoot,
url: 'http://127.0.0.1:8000/api/v1/auth/status',
reuseExistingServer: !process.env.CI,
timeout: 120_000,
},
{
command: 'npm run dev -- --host 127.0.0.1 --port 4173',
cwd: currentDir,
url: 'http://127.0.0.1:4173',
reuseExistingServer: !process.env.CI,
timeout: 120_000,
},
],
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
],
});

View File

@@ -1,7 +1,6 @@
import type React from 'react';
import { useEffect } from 'react';
import {BrowserRouter as Router, Routes, Route, NavLink, useLocation, Navigate} from 'react-router-dom';
import { RiExchangeFundsLine } from '@remixicon/react';
import { BrowserRouter as Router, Navigate, Route, Routes, useLocation } from 'react-router-dom';
import HomePage from './pages/HomePage';
import BacktestPage from './pages/BacktestPage';
import SettingsPage from './pages/SettingsPage';
@@ -9,234 +8,79 @@ import LoginPage from './pages/LoginPage';
import NotFoundPage from './pages/NotFoundPage';
import ChatPage from './pages/ChatPage';
import PortfolioPage from './pages/PortfolioPage';
import { ApiErrorAlert } from './components/common';
import { ApiErrorAlert, Shell } from './components/common';
import { AuthProvider, useAuth } from './contexts/AuthContext';
import { useAgentChatStore } from './stores/agentChatStore';
import './App.css';
// 侧边导航图标
const HomeIcon: React.FC<{ active?: boolean }> = ({active}) => (
<svg className="w-6 h-6" fill={active ? 'currentColor' : 'none'} stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5}
d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"/>
</svg>
);
const BacktestIcon: React.FC<{ active?: boolean }> = ({active}) => (
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={active ? 2 : 1.5}
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4"/>
</svg>
);
const PortfolioIcon: React.FC<{ active?: boolean }> = ({active}) => (
<RiExchangeFundsLine className="w-6 h-6" size={active ? 25 : 24} />
);
const SettingsIcon: React.FC<{ active?: boolean }> = ({active}) => (
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5}
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={active ? 2 : 1.5} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
</svg>
);
const ChatIcon: React.FC<{ active?: boolean }> = ({active}) => (
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={active ? 2 : 1.5}
d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z"/>
</svg>
);
const LogoutIcon: React.FC = () => (
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5}
d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"/>
</svg>
);
type DockItem = {
key: string;
label: string;
to: string;
icon: React.FC<{ active?: boolean }>;
};
const NAV_ITEMS: DockItem[] = [
{
key: 'home',
label: '首页',
to: '/',
icon: HomeIcon,
},
{
key: 'chat',
label: '问股',
to: '/chat',
icon: ChatIcon,
},
{
key: 'portfolio',
label: '持仓',
to: '/portfolio',
icon: PortfolioIcon,
},
{
key: 'backtest',
label: '回测',
to: '/backtest',
icon: BacktestIcon,
},
{
key: 'settings',
label: '设置',
to: '/settings',
icon: SettingsIcon,
},
];
// Dock 导航栏
const DockNav: React.FC = () => {
const {authEnabled, logout} = useAuth();
const completionBadge = useAgentChatStore((s) => s.completionBadge);
return (
<aside className="dock-nav" aria-label="主导航">
<div className="dock-surface">
<NavLink to="/" className="dock-logo" title="首页" aria-label="首页">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6"/>
</svg>
</NavLink>
<nav className="dock-items" aria-label="页面">
{NAV_ITEMS.map((item) => {
const Icon = item.icon;
if (item.key === 'chat') {
return (
<div key="chat" className="relative inline-flex">
<NavLink
to="/chat"
end={false}
title="问股"
aria-label="问股"
className={({isActive}) => `dock-item${isActive ? ' is-active' : ''}`}
>
{({isActive}) => <Icon active={isActive}/>}
</NavLink>
{completionBadge && (
<span
className="absolute top-0.5 right-0.5 w-2.5 h-2.5 rounded-full bg-cyan border-2 border-base z-10 pointer-events-none"
aria-label="问股有新消息"
/>
)}
</div>
);
}
return (
<NavLink
key={item.key}
to={item.to}
end={item.to === '/'}
title={item.label}
aria-label={item.label}
className={({isActive}) => `dock-item${isActive ? ' is-active' : ''}`}
>
{({isActive}) => <Icon active={isActive}/>}
</NavLink>
);
})}
</nav>
{authEnabled ? (
<button
type="button"
onClick={() => logout()}
title="退出登录"
aria-label="退出登录"
className="dock-item"
>
<LogoutIcon/>
</button>
) : null}
<div className="dock-footer"/>
</div>
</aside>
);
};
const AppContent: React.FC = () => {
const location = useLocation();
const { authEnabled, loggedIn, isLoading, loadError, refreshStatus } = useAuth();
const location = useLocation();
const { authEnabled, loggedIn, isLoading, loadError, refreshStatus } = useAuth();
useEffect(() => {
useAgentChatStore.getState().setCurrentRoute(location.pathname);
}, [location.pathname]);
if (isLoading) {
return (
<div className="flex min-h-screen items-center justify-center bg-base">
<div className="w-8 h-8 border-2 border-cyan/20 border-t-cyan rounded-full animate-spin" />
</div>
);
}
if (loadError) {
return (
<div className="flex min-h-screen flex-col items-center justify-center gap-4 bg-base px-4">
<div className="w-full max-w-lg">
<ApiErrorAlert error={loadError}/>
</div>
<button
type="button"
className="btn-primary"
onClick={() => void refreshStatus()}
>
</button>
</div>
);
}
if (authEnabled && !loggedIn) {
if (location.pathname === '/login') {
return <LoginPage />;
}
const redirect = encodeURIComponent(location.pathname + location.search);
return <Navigate to={`/login?redirect=${redirect}`} replace />;
}
if (location.pathname === '/login') {
return <Navigate to="/" replace />;
}
useEffect(() => {
useAgentChatStore.getState().setCurrentRoute(location.pathname);
}, [location.pathname]);
if (isLoading) {
return (
<div className="flex min-h-screen bg-base">
<DockNav/>
<main className="flex-1 dock-safe-area">
<Routes>
<Route path="/" element={<HomePage/>}/>
<Route path="/chat" element={<ChatPage/>}/>
<Route path="/portfolio" element={<PortfolioPage/>}/>
<Route path="/backtest" element={<BacktestPage/>}/>
<Route path="/settings" element={<SettingsPage/>}/>
<Route path="/login" element={<LoginPage/>}/>
<Route path="*" element={<NotFoundPage/>}/>
</Routes>
</main>
</div>
<div className="flex min-h-screen items-center justify-center bg-base">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-cyan/20 border-t-cyan" />
</div>
);
}
if (loadError) {
return (
<div className="flex min-h-screen flex-col items-center justify-center gap-4 bg-base px-4">
<div className="w-full max-w-lg">
<ApiErrorAlert error={loadError} />
</div>
<button
type="button"
className="btn-primary"
onClick={() => void refreshStatus()}
>
</button>
</div>
);
}
if (authEnabled && !loggedIn) {
if (location.pathname === '/login') {
return <LoginPage />;
}
const redirect = encodeURIComponent(location.pathname + location.search);
return <Navigate to={`/login?redirect=${redirect}`} replace />;
}
if (location.pathname === '/login') {
return <Navigate to="/" replace />;
}
return (
<Routes>
<Route element={<Shell />}>
<Route path="/" element={<HomePage />} />
<Route path="/chat" element={<ChatPage />} />
<Route path="/portfolio" element={<PortfolioPage />} />
<Route path="/backtest" element={<BacktestPage />} />
<Route path="/settings" element={<SettingsPage />} />
<Route path="*" element={<NotFoundPage />} />
</Route>
<Route path="/login" element={<LoginPage />} />
</Routes>
);
};
const App: React.FC = () => {
return (
<Router>
<AuthProvider>
<AppContent/>
</AuthProvider>
</Router>
);
return (
<Router>
<AuthProvider>
<AppContent />
</AuthProvider>
</Router>
);
};
export default App;

View File

@@ -5,6 +5,7 @@ export type AuthStatusResponse = {
loggedIn: boolean;
passwordSet?: boolean;
passwordChangeable?: boolean;
setupState: 'enabled' | 'password_retained' | 'no_password';
};
export const authApi = {

View File

@@ -8,7 +8,7 @@ interface AppPageProps {
export const AppPage: React.FC<AppPageProps> = ({ children, className = '' }) => {
return (
<main className={cn('mx-auto min-h-screen w-full max-w-7xl px-4 pb-8 pt-4 md:px-6 lg:px-8', className)}>
<main className={cn('mx-auto min-h-full w-full max-w-7xl px-4 pb-8 pt-4 md:px-6 lg:px-8', className)}>
{children}
</main>
);

View File

@@ -2,7 +2,7 @@ import React from 'react';
import { cn } from '../../utils/cn';
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'secondary' | 'outline' | 'ghost' | 'gradient' | 'danger';
variant?: 'primary' | 'secondary' | 'outline' | 'ghost' | 'gradient' | 'danger' | 'settings-primary' | 'settings-secondary';
size?: 'sm' | 'md' | 'lg' | 'xl';
isLoading?: boolean;
/** Custom loading text. */
@@ -18,12 +18,14 @@ const BUTTON_SIZE_STYLES = {
} as const;
const BUTTON_VARIANT_STYLES = {
primary: 'border border-cyan/30 bg-primary-gradient text-slate-950 shadow-lg shadow-cyan/20 hover:brightness-105',
secondary: 'border border-white/10 bg-card text-foreground shadow-soft-card hover:bg-hover',
primary: 'border border-cyan/30 bg-primary-gradient text-primary-foreground shadow-lg shadow-cyan/20 hover:brightness-105',
secondary: 'border border-border/70 bg-card text-foreground shadow-soft-card hover:bg-hover',
'settings-primary': 'border border-[rgba(0,212,255,0.26)] bg-gradient-to-br from-[rgba(0,212,255,0.96)] to-[rgba(0,168,204,0.96)] text-[#041118] shadow-lg shadow-cyan/20 hover:brightness-105 hover:shadow-xl hover:shadow-cyan/22',
'settings-secondary': 'border border-[rgba(255,255,255,0.08)] bg-[rgba(255,255,255,0.03)] text-secondary-text hover:translate-y-[-1px] hover:border-[rgba(0,212,255,0.3)] hover:bg-[rgba(0,212,255,0.06)] hover:text-foreground',
outline: 'border border-cyan/25 bg-transparent text-cyan hover:bg-cyan/10',
ghost: 'border border-transparent bg-transparent text-secondary-text hover:bg-white/5 hover:text-foreground',
gradient: 'border border-cyan/20 bg-gradient-to-r from-cyan to-purple text-white shadow-lg shadow-cyan/20 hover:brightness-105',
danger: 'border border-danger/40 bg-danger text-white shadow-lg shadow-danger/20 hover:brightness-105',
ghost: 'border border-transparent bg-transparent text-secondary-text hover:bg-hover hover:text-foreground',
gradient: 'border border-cyan/20 bg-gradient-to-r from-cyan to-purple text-primary-foreground shadow-lg shadow-cyan/20 hover:brightness-105',
danger: 'border border-danger/40 bg-danger text-destructive-foreground shadow-lg shadow-danger/20 hover:brightness-105',
} as const;
/**
@@ -38,16 +40,20 @@ export const Button: React.FC<ButtonProps> = ({
glow = false,
className = '',
disabled,
type = 'button',
...props
}) => {
const glowStyles = glow ? 'shadow-glow-cyan hover:shadow-[0_0_30px_rgba(0,212,255,0.38)]' : '';
return (
<button
type={type}
aria-busy={isLoading || undefined}
data-variant={variant}
className={cn(
'inline-flex items-center justify-center gap-2 font-medium transition-all duration-200',
'inline-flex cursor-pointer items-center justify-center gap-2 font-medium transition-all duration-200',
'focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-cyan/15 focus-visible:ring-offset-0',
'disabled:pointer-events-none disabled:opacity-50 disabled:transform-none',
'disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 disabled:transform-none',
BUTTON_SIZE_STYLES[size],
BUTTON_VARIANT_STYLES[variant],
glowStyles,

View File

@@ -0,0 +1,46 @@
import type React from 'react';
import { useId } from 'react';
import { cn } from '../../utils/cn';
interface CheckboxProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'type'> {
label?: string;
containerClassName?: string;
}
/**
* 定制化的大尺寸勾选框组件
*/
export const Checkbox: React.FC<CheckboxProps> = ({
label,
id,
className = '',
containerClassName = '',
...props
}) => {
const generatedId = useId();
const checkboxId = id ?? generatedId;
return (
<div className={cn('flex items-center gap-3', containerClassName)}>
<input
id={checkboxId}
type="checkbox"
className={cn(
'h-4 w-4 cursor-pointer rounded border border-border/70 bg-base text-cyan transition-all',
'focus:ring-2 focus:ring-cyan/20 focus:outline-none',
'disabled:cursor-not-allowed disabled:opacity-50',
className
)}
{...props}
/>
{label && (
<label
htmlFor={checkboxId}
className="cursor-pointer select-none text-sm font-medium text-white"
>
{label}
</label>
)}
</div>
);
};

View File

@@ -1,4 +1,5 @@
import type React from 'react';
import { createPortal } from 'react-dom';
interface ConfirmDialogProps {
isOpen: boolean;
@@ -27,16 +28,16 @@ export const ConfirmDialog: React.FC<ConfirmDialogProps> = ({
}) => {
if (!isOpen) return null;
return (
const dialog = (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm transition-all"
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm transition-all"
onClick={onCancel}
>
<div
className="bg-elevated border border-white/10 rounded-xl p-6 max-w-sm w-full mx-4 shadow-2xl animate-in fade-in zoom-in duration-200"
className="mx-4 w-full max-w-sm rounded-xl border border-border/70 bg-elevated p-6 shadow-2xl animate-in fade-in zoom-in duration-200"
onClick={(e) => e.stopPropagation()}
>
<h3 className="text-white font-medium mb-2 text-lg">{title}</h3>
<h3 className="mb-2 text-lg font-medium text-foreground">{title}</h3>
<p className="text-sm text-secondary-text mb-6 leading-relaxed">
{message}
</p>
@@ -44,14 +45,14 @@ export const ConfirmDialog: React.FC<ConfirmDialogProps> = ({
<button
type="button"
onClick={onCancel}
className="px-4 py-2 rounded-lg text-sm font-medium text-secondary-text hover:text-white hover:bg-white/5 border border-white/10 transition-colors"
className="rounded-lg border border-border/70 px-4 py-2 text-sm font-medium text-secondary-text transition-colors hover:bg-hover hover:text-foreground"
>
{cancelText}
</button>
<button
type="button"
onClick={onConfirm}
className={`px-4 py-2 rounded-lg text-sm font-medium text-white transition-colors ${
className={`rounded-lg px-4 py-2 text-sm font-medium text-foreground transition-colors ${
isDanger
? 'bg-red-500/80 hover:bg-red-500 shadow-lg shadow-red-500/20'
: 'bg-cyan/80 hover:bg-cyan shadow-lg shadow-cyan/20'
@@ -63,4 +64,6 @@ export const ConfirmDialog: React.FC<ConfirmDialogProps> = ({
</div>
</div>
);
return createPortal(dialog, document.body);
};

View File

@@ -11,6 +11,7 @@ interface DrawerProps {
children: React.ReactNode;
width?: string;
zIndex?: number;
side?: 'left' | 'right';
}
/**
@@ -23,6 +24,7 @@ export const Drawer: React.FC<DrawerProps> = ({
children,
width = 'max-w-2xl',
zIndex = 50,
side = 'right',
}) => {
// Close the drawer when Escape is pressed.
const handleKeyDown = useCallback(
@@ -54,29 +56,42 @@ export const Drawer: React.FC<DrawerProps> = ({
if (!isOpen) return null;
const titleId = title ? `drawer-title-${side}` : undefined;
const sidePositionClass = side === 'left' ? 'left-0 justify-start' : 'right-0 justify-end';
const borderClass = side === 'left' ? 'border-r' : 'border-l';
return (
<div className="fixed inset-0 overflow-hidden" style={{ zIndex }}>
<div className="fixed inset-0 overflow-hidden" style={{ zIndex }} role="presentation">
{/* Backdrop */}
<div
className="absolute inset-0 bg-background/80 backdrop-blur-sm transition-opacity duration-300"
onClick={onClose}
/>
<div className={cn('absolute inset-y-0 right-0 flex w-full', width)}>
<div className={cn('absolute inset-y-0 flex w-full', sidePositionClass, width)}>
<div
className="relative flex w-full animate-slide-in-right flex-col border-l border-white/10 bg-card shadow-2xl"
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
className={cn(
'relative flex w-full flex-col bg-card',
borderClass,
side === 'right' ? 'border-white/50' : 'border-border/70 shadow-2xl',
side === 'left' ? 'animate-slide-in-left' : 'animate-slide-in-right'
)}
>
<div className="flex items-center justify-between px-6 py-4 border-b border-white/5">
<div className="flex items-center justify-between border-b border-border/60 px-6 py-4">
{title ? (
<div>
<span className="label-uppercase">DETAIL VIEW</span>
<h2 className="mt-1 text-lg font-semibold text-white">{title}</h2>
<h2 id={titleId} className="mt-1 text-lg font-semibold text-foreground">{title}</h2>
</div>
) : <div />}
<button
type="button"
onClick={onClose}
className="inline-flex h-10 w-10 items-center justify-center rounded-xl border border-white/10 bg-white/5 text-secondary-text transition-colors hover:bg-white/10 hover:text-white"
className="inline-flex h-10 w-10 items-center justify-center rounded-xl border border-border/70 bg-card/80 text-secondary-text transition-colors hover:bg-hover hover:text-foreground"
aria-label="关闭抽屉"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />

View File

@@ -17,9 +17,9 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
className = '',
}) => {
return (
<div className={cn('rounded-2xl border border-dashed border-white/10 bg-card/40 px-6 py-10 text-center shadow-soft-card', className)}>
<div className={cn('rounded-2xl border border-dashed border-border/60 bg-card/50 px-6 py-10 text-center shadow-soft-card', className)}>
{icon ? <div className="mb-4 flex justify-center text-cyan">{icon}</div> : null}
<h3 className="text-base font-semibold text-white">{title}</h3>
<h3 className="text-base font-semibold text-foreground">{title}</h3>
{description ? <p className="mx-auto mt-2 max-w-md text-sm text-secondary-text">{description}</p> : null}
{action ? <div className="mt-5 flex justify-center">{action}</div> : null}
</div>

View File

@@ -1,14 +1,37 @@
import type React from 'react';
import { useId } from 'react';
import { useId, useState } from 'react';
import { Lock, Key } from 'lucide-react';
import { cn } from '../../utils/cn';
import { EyeToggleIcon } from './EyeToggleIcon';
interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
label?: string;
hint?: string;
error?: string;
trailingAction?: React.ReactNode;
/** Enables the built-in password visibility toggle. */
allowTogglePassword?: boolean;
/** Controls the leading icon style. */
iconType?: 'password' | 'key' | 'none';
/** Allows external visibility state control. */
passwordVisible?: boolean;
/** Notifies the parent when visibility changes in controlled mode. */
onPasswordVisibleChange?: (visible: boolean) => void;
}
export const Input = ({ label, hint, error, className = '', id, ...props }: InputProps) => {
export const Input = ({
label,
hint,
error,
className = '',
id,
trailingAction,
allowTogglePassword,
iconType = 'none',
passwordVisible,
onPasswordVisibleChange,
...props
}: InputProps) => {
const generatedId = useId();
const inputId = id ?? props.name ?? generatedId;
const hintId = hint ? `${inputId}-hint` : undefined;
@@ -16,21 +39,90 @@ export const Input = ({ label, hint, error, className = '', id, ...props }: Inpu
const describedBy = [props['aria-describedby'], errorId ?? hintId].filter(Boolean).join(' ') || undefined;
const ariaInvalid = props['aria-invalid'] ?? (error ? true : undefined);
const [isPasswordVisible, setIsPasswordVisible] = useState(false);
const isPasswordInput = props.type === 'password';
const isVisibilityControlled = typeof passwordVisible === 'boolean';
const visible = isVisibilityControlled ? passwordVisible : isPasswordVisible;
const effectiveType = isPasswordInput && allowTogglePassword && visible ? 'text' : props.type;
const renderLeadingIcon = () => {
if (iconType === 'password') {
return <Lock className="h-4 w-4 text-muted-text/55" />;
}
if (iconType === 'key') {
return <Key className="h-4 w-4 text-muted-text/55" />;
}
return null;
};
const leadingIcon = renderLeadingIcon();
const inputStyle = error
? {
...props.style,
['--input-surface-border-focus' as string]: 'hsla(var(--destructive), 0.4)',
['--input-surface-focus-ring' as string]: '0 0 0 4px hsla(var(--destructive), 0.1)',
}
: props.style;
const defaultTrailingAction = isPasswordInput && allowTogglePassword ? (
<button
type="button"
className={cn(
'inline-flex h-8 w-8 items-center justify-center rounded-lg border transition-all duration-200 focus:outline-none focus:ring-2',
'hover:border-warning/40 hover:text-warning hover:shadow-[0_0_10px_hsla(var(--warning),0.15)]',
visible
? 'border-warning/40 bg-warning/15 text-warning shadow-[0_0_10px_hsla(var(--warning),0.15)]'
: 'border-border/40 bg-muted/20 text-muted-text focus:ring-primary/30'
)}
onClick={() => {
const nextVisible = !visible;
if (!isVisibilityControlled) {
setIsPasswordVisible(nextVisible);
}
onPasswordVisibleChange?.(nextVisible);
}}
aria-label={visible ? '隐藏内容' : '显示内容'}
tabIndex={-1}
title={visible ? '隐藏' : '显示'}
>
<EyeToggleIcon visible={visible} />
</button>
) : null;
const finalTrailingAction = trailingAction || defaultTrailingAction;
return (
<div className="flex flex-col">
{label ? <label htmlFor={inputId} className="mb-2 text-sm font-medium text-foreground">{label}</label> : null}
<input
id={inputId}
aria-describedby={describedBy}
aria-invalid={ariaInvalid}
className={cn(
'h-11 w-full rounded-xl border border-white/10 bg-card px-4 text-sm text-foreground shadow-soft-card transition-all',
'placeholder:text-muted-text focus:outline-none focus:ring-4 focus:ring-cyan/15 focus:border-cyan/40',
error ? 'border-danger/30 focus:border-danger/40 focus:ring-danger/10' : 'hover:border-white/18',
className,
<div className="relative flex items-center">
{leadingIcon && (
<div className="absolute left-3.5 z-10 pointer-events-none">
{leadingIcon}
</div>
)}
{...props}
/>
<input
id={inputId}
aria-describedby={describedBy}
aria-invalid={ariaInvalid}
style={inputStyle}
className={cn(
'input-surface input-focus-glow h-11 w-full rounded-xl border bg-transparent px-4 text-sm transition-all',
'focus:outline-none',
error ? 'border-danger/30' : '',
leadingIcon ? 'pl-10' : '',
finalTrailingAction ? 'pr-12' : '',
'disabled:cursor-not-allowed disabled:opacity-60',
className,
)}
{...props}
type={effectiveType}
/>
{finalTrailingAction ? (
<div className="absolute inset-y-0 right-2 flex items-center">
{finalTrailingAction}
</div>
) : null}
</div>
{error ? (
<p id={errorId} role="alert" className="mt-2 text-xs text-danger">
{error}

View File

@@ -8,7 +8,7 @@ interface LoadingProps {
export const Loading: React.FC<LoadingProps> = ({ label = '正在加载', className = '' }) => {
return (
<div className={`flex items-center justify-center p-8 ${className}`}>
<div className="inline-flex items-center gap-2 rounded-full border border-white/8 bg-card px-4 py-2 text-sm text-secondary-text shadow-soft-card">
<div className="inline-flex items-center gap-2 rounded-full border border-border/60 bg-card px-4 py-2 text-sm text-secondary-text shadow-soft-card">
<svg className="h-4 w-4 animate-spin text-cyan" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-20" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-90" fill="currentColor" d="M4 12a8 8 0 0 1 8-8V0C5.373 0 0 5.373 0 12h4z" />

View File

@@ -17,11 +17,11 @@ export const PageHeader: React.FC<PageHeaderProps> = ({
className = '',
}) => {
return (
<header className={cn('rounded-3xl border border-white/8 bg-card/70 px-5 py-5 shadow-soft-card backdrop-blur-sm', className)}>
<header className={cn('rounded-3xl border border-border/60 bg-card/70 px-5 py-5 shadow-soft-card backdrop-blur-sm', className)}>
<div className="flex flex-col gap-4 md:flex-row md:items-end md:justify-between">
<div>
{eyebrow ? <span className="label-uppercase">{eyebrow}</span> : null}
<h1 className="mt-2 text-2xl font-semibold tracking-tight text-white md:text-3xl">{title}</h1>
<h1 className="mt-2 text-2xl font-semibold tracking-tight text-foreground md:text-3xl">{title}</h1>
{description ? <p className="mt-2 max-w-2xl text-sm text-secondary-text md:text-base">{description}</p> : null}
</div>
{actions ? <div className="flex flex-wrap items-center gap-2">{actions}</div> : null}

View File

@@ -25,7 +25,7 @@ const PageButton: React.FC<PageButtonProps> = ({ page, isActive, disabled, onCli
'inline-flex h-10 min-w-[2.5rem] items-center justify-center rounded-xl border px-3 text-sm font-medium transition-all duration-200',
isActive
? 'border-cyan/30 bg-cyan text-slate-950 shadow-lg shadow-cyan/20'
: 'border-white/8 bg-elevated text-secondary-text hover:bg-hover hover:text-white',
: 'border-border/60 bg-elevated text-secondary-text hover:bg-hover hover:text-foreground',
disabled ? 'cursor-not-allowed opacity-50' : 'cursor-pointer',
)}
>

View File

@@ -0,0 +1,158 @@
import { useEffect, useRef } from 'react';
type Particle = {
x: number;
y: number;
vx: number;
vy: number;
radius: number;
color: string;
baseAlpha: number;
};
const PARTICLE_COLORS = ['14, 165, 233', '16, 185, 129', '59, 130, 246', '139, 92, 246'];
function createParticle(canvas: HTMLCanvasElement): Particle {
return {
x: Math.random() * canvas.width,
y: Math.random() * canvas.height,
vx: (Math.random() - 0.5) * 0.5,
vy: (Math.random() - 0.5) * 0.5,
radius: Math.random() * 2.0 + 1.0,
color: PARTICLE_COLORS[Math.floor(Math.random() * PARTICLE_COLORS.length)],
baseAlpha: Math.random() * 0.6 + 0.2,
};
}
function updateParticle(particle: Particle, canvas: HTMLCanvasElement) {
particle.x += particle.vx;
particle.y += particle.vy;
if (particle.x < 0 || particle.x > canvas.width) {
particle.vx *= -1;
}
if (particle.y < 0 || particle.y > canvas.height) {
particle.vy *= -1;
}
}
function drawParticle(ctx: CanvasRenderingContext2D, particle: Particle) {
ctx.beginPath();
ctx.arc(particle.x, particle.y, particle.radius, 0, Math.PI * 2);
ctx.fillStyle = `rgba(${particle.color}, ${particle.baseAlpha})`;
ctx.fill();
}
export const ParticleBackground = () => {
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
let animationFrameId: number;
let particles: Particle[] = [];
const mouse = { x: -1000, y: -1000 };
const resize = () => {
if (!canvas) return;
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
initParticles();
};
const initParticles = () => {
if (!canvas) return;
particles = [];
const numParticles = Math.floor((canvas.width * canvas.height) / 10000);
for (let i = 0; i < numParticles; i++) {
particles.push(createParticle(canvas));
}
};
const drawLines = (c: CanvasRenderingContext2D) => {
for (let i = 0; i < particles.length; i++) {
const dxMouse = particles[i].x - mouse.x;
const dyMouse = particles[i].y - mouse.y;
const distMouse = Math.sqrt(dxMouse * dxMouse + dyMouse * dyMouse);
if (distMouse > 0 && distMouse < 250) {
c.beginPath();
const opacity = 0.8 * (1 - distMouse / 250);
c.strokeStyle = `rgba(6, 182, 212, ${opacity})`;
c.lineWidth = 2.0;
c.moveTo(particles[i].x, particles[i].y);
c.lineTo(mouse.x, mouse.y);
c.stroke();
const force = (250 - distMouse) / 250;
particles[i].x += (dxMouse / distMouse) * force * 2.0;
particles[i].y += (dyMouse / distMouse) * force * 2.0;
}
for (let j = i + 1; j < particles.length; j++) {
const dx = particles[i].x - particles[j].x;
const dy = particles[i].y - particles[j].y;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist < 150) {
c.beginPath();
const opacity = 0.3 * (1 - dist / 150);
c.strokeStyle = `rgba(255, 255, 255, ${opacity})`;
c.lineWidth = 0.8;
c.moveTo(particles[i].x, particles[i].y);
c.lineTo(particles[j].x, particles[j].y);
c.stroke();
}
}
}
};
const animate = () => {
if (!canvas || !ctx) return;
ctx.clearRect(0, 0, canvas.width, canvas.height);
particles.forEach((particle) => {
updateParticle(particle, canvas);
drawParticle(ctx, particle);
});
drawLines(ctx);
animationFrameId = requestAnimationFrame(animate);
};
const handleResize = () => resize();
const handleMouseMove = (e: MouseEvent) => {
mouse.x = e.clientX;
mouse.y = e.clientY;
};
const handleMouseOut = () => {
mouse.x = -1000;
mouse.y = -1000;
};
window.addEventListener('resize', handleResize);
window.addEventListener('mousemove', handleMouseMove);
window.addEventListener('mouseout', handleMouseOut);
resize();
animate();
return () => {
window.removeEventListener('resize', handleResize);
window.removeEventListener('mousemove', handleMouseMove);
window.removeEventListener('mouseout', handleMouseOut);
cancelAnimationFrame(animationFrameId);
};
}, []);
return (
<canvas
ref={canvasRef}
className="absolute inset-0 z-0 pointer-events-none"
style={{ background: 'transparent' }}
/>
);
};

View File

@@ -0,0 +1,33 @@
import type React from 'react';
import { cn } from '../../utils/cn';
interface ScrollAreaProps {
children: React.ReactNode;
className?: string;
viewportClassName?: string;
testId?: string;
viewportRef?: React.Ref<HTMLDivElement>;
onScroll?: React.UIEventHandler<HTMLDivElement>;
}
export const ScrollArea: React.FC<ScrollAreaProps> = ({
children,
className,
viewportClassName,
testId,
viewportRef,
onScroll,
}) => {
return (
<div className={cn('min-h-0 flex-1 overflow-hidden', className)}>
<div
ref={viewportRef}
data-testid={testId}
onScroll={onScroll}
className={cn('h-full overflow-y-auto custom-scrollbar', viewportClassName)}
>
{children}
</div>
</div>
);
};

View File

@@ -7,6 +7,7 @@ interface SelectOption {
}
interface SelectProps {
id?: string;
value: string;
onChange: (value: string) => void;
options: SelectOption[];
@@ -23,6 +24,7 @@ interface SelectProps {
* Select component with terminal-inspired styling.
*/
export const Select: React.FC<SelectProps> = ({
id,
value,
onChange,
options,
@@ -32,13 +34,14 @@ export const Select: React.FC<SelectProps> = ({
className = '',
}) => {
const selectId = useId();
const resolvedId = id ?? selectId;
return (
<div className={cn('flex flex-col', className)}>
{label ? <label htmlFor={selectId} className="mb-2 text-sm font-medium text-foreground">{label}</label> : null}
{label ? <label htmlFor={resolvedId} className="mb-2 text-sm font-medium text-foreground">{label}</label> : null}
<div className="relative">
<select
id={selectId}
id={resolvedId}
value={value}
onChange={(e) => onChange(e.target.value)}
disabled={disabled}

View File

@@ -9,7 +9,7 @@ interface ToolbarProps {
export const Toolbar: React.FC<ToolbarProps> = ({ left, right, className = '' }) => {
return (
<div className={cn('flex flex-col gap-3 rounded-2xl border border-white/8 bg-card/60 px-4 py-3 shadow-soft-card backdrop-blur-sm md:flex-row md:items-center md:justify-between', className)}>
<div className={cn('flex flex-col gap-3 rounded-2xl border border-border/60 bg-card/60 px-4 py-3 shadow-soft-card backdrop-blur-sm md:flex-row md:items-center md:justify-between', className)}>
<div className="flex flex-wrap items-center gap-2">{left}</div>
<div className="flex flex-wrap items-center gap-2 md:justify-end">{right}</div>
</div>

View File

@@ -0,0 +1,29 @@
import { render, screen } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import { Button } from '../Button';
describe('Button', () => {
it('renders children', () => {
render(<Button>Click me</Button>);
expect(screen.getByRole('button', { name: 'Click me' })).toBeInTheDocument();
});
it('uses button type by default and exposes the selected variant', () => {
render(<Button variant="danger">Delete</Button>);
const button = screen.getByRole('button', { name: 'Delete' });
expect(button).toHaveAttribute('type', 'button');
expect(button).toHaveAttribute('data-variant', 'danger');
expect(button.className).toContain('bg-danger');
});
it('disables the button when loading and shows loading text', () => {
render(<Button isLoading loadingText="Saving">Save</Button>);
const button = screen.getByRole('button', { name: /saving/i });
expect(button).toBeDisabled();
expect(button).toHaveAttribute('aria-busy', 'true');
expect(screen.getByText('Saving')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,71 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { Input } from '../Input';
describe('Input', () => {
it('wires label and hint text to the input', () => {
render(<Input label="API Key" hint="Stored locally" name="api_key" />);
const input = screen.getByLabelText('API Key');
expect(input).toHaveAttribute('id', 'api_key');
expect(input).toHaveAttribute('aria-describedby', 'api_key-hint');
expect(screen.getByText('Stored locally')).toBeInTheDocument();
});
it('marks the input invalid and shows the error message', () => {
render(<Input label="Code" error="Required" name="stock_code" />);
const input = screen.getByLabelText('Code');
expect(input).toHaveAttribute('aria-invalid', 'true');
expect(input).toHaveAttribute('aria-describedby', 'stock_code-error');
expect(screen.getByRole('alert')).toHaveTextContent('Required');
});
it('renders a trailing action when provided', () => {
render(
<Input
label="Password"
name="password"
trailingAction={<button type="button"></button>}
/>
);
expect(screen.getByRole('button', { name: '显示' })).toBeInTheDocument();
});
it('renders a key icon and applies leading padding', () => {
const { container } = render(<Input label="API Key" iconType="key" />);
expect(container.querySelector('svg')).not.toBeNull();
expect(screen.getByLabelText('API Key')).toHaveClass('pl-10');
});
it('toggles password visibility in uncontrolled mode', () => {
render(<Input label="密码" type="password" allowTogglePassword />);
const input = screen.getByLabelText('密码');
expect(input).toHaveAttribute('type', 'password');
fireEvent.click(screen.getByRole('button', { name: '显示内容' }));
expect(input).toHaveAttribute('type', 'text');
});
it('supports controlled password visibility', () => {
const onPasswordVisibleChange = vi.fn();
render(
<Input
label="API Key"
type="password"
allowTogglePassword
passwordVisible
onPasswordVisibleChange={onPasswordVisibleChange}
/>
);
expect(screen.getByLabelText('API Key')).toHaveAttribute('type', 'text');
fireEvent.click(screen.getByRole('button', { name: '隐藏内容' }));
expect(onPasswordVisibleChange).toHaveBeenCalledWith(false);
});
});

View File

@@ -0,0 +1,23 @@
import { render, screen } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import { ScrollArea } from '../ScrollArea';
describe('ScrollArea', () => {
it('renders a scrollable viewport and forwards custom classes', () => {
render(
<ScrollArea
className="outer-shell"
viewportClassName="inner-viewport"
testId="scroll-area-viewport"
>
<div>scroll content</div>
</ScrollArea>
);
const viewport = screen.getByTestId('scroll-area-viewport');
expect(viewport).toBeInTheDocument();
expect(viewport).toHaveClass('inner-viewport');
expect(viewport).toHaveTextContent('scroll content');
expect(viewport.parentElement).toHaveClass('outer-shell');
});
});

View File

@@ -1,5 +1,6 @@
export * from './Button';
export * from './Card';
export * from './Checkbox';
export * from './AppPage';
export * from './SectionCard';
export * from './StatCard';
@@ -13,6 +14,7 @@ export { Input } from './Input';
export * from './EyeToggleIcon';
export * from './Loading';
export * from './Drawer';
export * from './ScrollArea';
export * from './ApiErrorAlert';
export * from './Collapsible';
export * from './ScoreGauge';
@@ -21,3 +23,9 @@ export * from './Select';
export * from './Badge';
export * from './Pagination';
export * from './ConfirmDialog';
export * from '../layout/Shell';
export * from '../layout/SidebarNav';
export * from '../layout/ShellHeader';
export * from '../theme/ThemeProvider';
export * from '../theme/ThemeToggle';
export * from './ParticleBackground';

View File

@@ -3,7 +3,7 @@ import { useRef, useCallback, useEffect } from 'react';
import type { HistoryItem } from '../../types/analysis';
import { getSentimentColor } from '../../types/analysis';
import { formatDateTime } from '../../utils/format';
import { Button, Badge } from '../common';
import { Badge, Button, ScrollArea } from '../common';
interface HistoryListProps {
items: HistoryItem[];
@@ -83,9 +83,33 @@ export const HistoryList: React.FC<HistoryListProps> = ({
}
}, [someVisibleSelected]);
const getOperationBadgeLabel = (advice?: string) => {
const normalized = advice?.trim();
if (!normalized) {
return '情绪';
}
if (normalized.includes('减仓')) {
return '减仓';
}
if (normalized.includes('卖')) {
return '卖出';
}
if (normalized.includes('观望') || normalized.includes('等待')) {
return '观望';
}
if (normalized.includes('买') || normalized.includes('布局')) {
return '买入';
}
return normalized.split(/[,。;、\s]/)[0] || '建议';
};
return (
<aside className={`glass-card overflow-hidden flex flex-col ${className}`}>
<div ref={scrollContainerRef} className="p-4 flex-1 overflow-y-auto">
<ScrollArea
viewportRef={scrollContainerRef}
viewportClassName="p-4"
testId="home-history-list-scroll"
>
<div className="mb-4 space-y-3">
<div className="flex items-center justify-between gap-2">
<h2 className="text-xs font-semibold text-purple uppercase tracking-widest flex items-center gap-2">
@@ -121,7 +145,7 @@ export const HistoryList: React.FC<HistoryListProps> = ({
onClick={onDeleteSelected}
disabled={selectedCount === 0 || isDeleting}
isLoading={isDeleting}
className="h-7 text-[11px] px-3"
className="h-6 text-[9px] px-2"
>
{isDeleting ? '删除中' : '删除'}
</Button>
@@ -134,19 +158,22 @@ export const HistoryList: React.FC<HistoryListProps> = ({
<div className="w-6 h-6 border-2 border-cyan/10 border-t-cyan rounded-full animate-spin" />
</div>
) : items.length === 0 ? (
<div className="text-center py-10 space-y-2">
<div className="mx-auto w-10 h-10 rounded-full bg-white/5 flex items-center justify-center text-muted-text/30">
<div className="text-center py-12 space-y-3">
<div className="mx-auto w-11 h-11 rounded-full bg-white/5 flex items-center justify-center text-muted-text/30">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<p className="text-muted-text text-xs"></p>
<div className="space-y-1">
<p className="text-sm text-secondary-text"></p>
<p className="text-xs text-muted-text"></p>
</div>
</div>
) : (
<div className="space-y-2">
{items.map((item) => (
<div key={item.id} className="flex items-start gap-2 group">
<div className="pt-3">
<div className="pt-5">
<input
type="checkbox"
checked={selectedIds.has(item.id)}
@@ -160,7 +187,7 @@ export const HistoryList: React.FC<HistoryListProps> = ({
onClick={() => onItemClick(item.id)}
className={`flex-1 text-left p-2.5 rounded-xl transition-all duration-200 border relative overflow-hidden group/item ${
selectedId === item.id
? 'bg-purple/10 border-purple/30 border-cyan shadow-[0_0_15px_rgba(111,97,241,0.15)]'
? 'bg-purple/10 border-purple/30 border-cyan shadow-[0_0_15px_rgba(111,97,241,0.15)]'
: 'bg-white/5 border-transparent hover:bg-white/10 hover:border-white/10'
}`}
>
@@ -178,20 +205,22 @@ export const HistoryList: React.FC<HistoryListProps> = ({
/>
)}
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between gap-2">
<span className="font-semibold text-white truncate text-sm tracking-tight">
{item.stockName || item.stockCode}
</span>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<span className="truncate text-sm font-semibold text-white tracking-tight">
{item.stockName || item.stockCode}
</span>
</div>
{item.sentimentScore !== undefined && (
<span
className="text-[10px] font-mono font-bold px-1.5 py-0.5 rounded-full border"
className="shrink-0 rounded-full border px-2 py-0.5 text-[11px] font-semibold leading-none"
style={{
color: getSentimentColor(item.sentimentScore),
borderColor: `${getSentimentColor(item.sentimentScore)}30`,
backgroundColor: `${getSentimentColor(item.sentimentScore)}10`
}}
>
{item.sentimentScore}
{getOperationBadgeLabel(item.operationAdvice)} {item.sentimentScore}
</span>
)}
</div>
@@ -219,14 +248,14 @@ export const HistoryList: React.FC<HistoryListProps> = ({
)}
{!hasMore && items.length > 0 && (
<div className="text-center py-4">
<div className="text-center py-5">
<div className="h-px bg-white/5 w-full mb-3" />
<span className="text-[10px] text-muted-text/30 uppercase tracking-widest">End of History</span>
<span className="text-[10px] text-muted-text/50 uppercase tracking-[0.2em]"></span>
</div>
)}
</div>
)}
</div>
</ScrollArea>
</aside>
);
};

View File

@@ -0,0 +1,62 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { HistoryList } from '../HistoryList';
import type { HistoryItem } from '../../../types/analysis';
const baseProps = {
isLoading: false,
isLoadingMore: false,
hasMore: false,
selectedIds: new Set<number>(),
onItemClick: vi.fn(),
onLoadMore: vi.fn(),
onToggleItemSelection: vi.fn(),
onToggleSelectAll: vi.fn(),
onDeleteSelected: vi.fn(),
};
const items: HistoryItem[] = [
{
id: 1,
queryId: 'q-1',
stockCode: '600519',
stockName: '贵州茅台',
sentimentScore: 82,
operationAdvice: '买入',
createdAt: '2026-03-15T08:00:00Z',
},
];
describe('HistoryList', () => {
it('shows the empty state copy when no history exists', () => {
render(<HistoryList {...baseProps} items={[]} />);
expect(screen.getByText('暂无历史分析记录')).toBeInTheDocument();
expect(screen.getByText('完成首次分析后,这里会保留最近结果。')).toBeInTheDocument();
});
it('renders selected count and forwards item interactions', () => {
const onItemClick = vi.fn();
const onToggleItemSelection = vi.fn();
render(
<HistoryList
{...baseProps}
items={items}
selectedIds={new Set([1])}
selectedId={1}
onItemClick={onItemClick}
onToggleItemSelection={onToggleItemSelection}
/>,
);
expect(screen.getByText('已选 1')).toBeInTheDocument();
expect(screen.getByText('买入 82')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /贵州茅台/i }));
expect(onItemClick).toHaveBeenCalledWith(1);
fireEvent.click(screen.getAllByRole('checkbox')[1]);
expect(onToggleItemSelection).toHaveBeenCalledWith(1);
});
});

View File

@@ -0,0 +1,80 @@
import type React from 'react';
import { useEffect, useState } from 'react';
import { Menu } from 'lucide-react';
import { Outlet } from 'react-router-dom';
import { Drawer } from '../common/Drawer';
import { SidebarNav } from './SidebarNav';
import { cn } from '../../utils/cn';
import { ThemeToggle } from '../theme/ThemeToggle';
type ShellProps = {
children?: React.ReactNode;
};
export const Shell: React.FC<ShellProps> = ({ children }) => {
const [mobileOpen, setMobileOpen] = useState(false);
const collapsed = false;
useEffect(() => {
if (!mobileOpen) {
return undefined;
}
const handleResize = () => {
if (window.innerWidth >= 1024) {
setMobileOpen(false);
}
};
window.addEventListener('resize', handleResize);
return () => {
window.removeEventListener('resize', handleResize);
};
}, [mobileOpen]);
return (
<div className="min-h-screen bg-background text-foreground">
<div className="pointer-events-none fixed inset-x-0 top-3 z-40 flex items-start justify-between px-3 lg:hidden">
<button
type="button"
onClick={() => setMobileOpen(true)}
className="pointer-events-auto inline-flex h-10 w-10 items-center justify-center rounded-xl border border-border/70 bg-card/85 text-secondary-text shadow-soft-card backdrop-blur-md transition-colors hover:bg-hover hover:text-foreground"
aria-label="打开导航菜单"
>
<Menu className="h-5 w-5" />
</button>
<div className="pointer-events-auto">
<ThemeToggle />
</div>
</div>
<div className="mx-auto flex min-h-screen w-full max-w-[1680px] px-3 py-3 sm:px-4 sm:py-4 lg:px-5">
<aside
className={cn(
'sticky top-3 hidden shrink-0 overflow-visible rounded-[1.5rem] border border-[#00d4ff]/50 bg-card/72 p-2 shadow-soft-card backdrop-blur-sm transition-[width] duration-200 lg:flex',
'max-h-[calc(100vh-1.5rem)] self-start sm:top-4 sm:max-h-[calc(100vh-2rem)]',
collapsed ? 'w-[64px]' : 'w-[116px]'
)}
aria-label="桌面侧边导航"
>
<SidebarNav collapsed={collapsed} onNavigate={() => setMobileOpen(false)} />
</aside>
<main className="min-h-0 min-w-0 flex-1 pt-14 lg:pl-3 lg:pt-0">
{children ?? <Outlet />}
</main>
</div>
<Drawer
isOpen={mobileOpen}
onClose={() => setMobileOpen(false)}
title="导航菜单"
width="max-w-xs"
zIndex={90}
side="left"
>
<SidebarNav onNavigate={() => setMobileOpen(false)} />
</Drawer>
</div>
);
};

View File

@@ -0,0 +1,57 @@
import type React from 'react';
import { Menu, PanelLeftClose, PanelLeftOpen } from 'lucide-react';
import { useLocation } from 'react-router-dom';
import { ThemeToggle } from '../theme/ThemeToggle';
type ShellHeaderProps = {
collapsed: boolean;
onToggleSidebar: () => void;
onOpenMobileNav: () => void;
};
const TITLES: Record<string, { title: string; description: string }> = {
'/': { title: '首页', description: '股票分析与历史报告工作台' },
'/chat': { title: '问股', description: '多轮策略问答与历史会话管理' },
'/backtest': { title: '回测', description: '回测任务与结果浏览' },
'/settings': { title: '设置', description: '系统配置、模型与认证管理' },
};
export const ShellHeader: React.FC<ShellHeaderProps> = ({
collapsed,
onToggleSidebar,
onOpenMobileNav,
}) => {
const location = useLocation();
const current = TITLES[location.pathname] ?? { title: 'Daily Stock Analysis', description: 'Web workspace' };
return (
<header className="sticky top-0 z-30 border-b border-border/60 bg-background/84 backdrop-blur-xl">
<div className="mx-auto flex h-16 w-full max-w-[1680px] items-center gap-3 px-4 sm:px-6 lg:px-8">
<button
type="button"
onClick={onOpenMobileNav}
className="inline-flex h-10 w-10 items-center justify-center rounded-xl border border-border/70 bg-card/70 text-secondary-text transition-colors hover:bg-hover hover:text-foreground lg:hidden"
aria-label="打开导航菜单"
>
<Menu className="h-5 w-5" />
</button>
<button
type="button"
onClick={onToggleSidebar}
className="hidden h-10 w-10 items-center justify-center rounded-xl border border-border/70 bg-card/70 text-secondary-text transition-colors hover:bg-hover hover:text-foreground lg:inline-flex"
aria-label={collapsed ? '展开侧边栏' : '折叠侧边栏'}
>
{collapsed ? <PanelLeftOpen className="h-5 w-5" /> : <PanelLeftClose className="h-5 w-5" />}
</button>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold text-foreground">{current.title}</p>
<p className="truncate text-xs text-secondary-text">{current.description}</p>
</div>
<ThemeToggle />
</div>
</header>
);
};

View File

@@ -0,0 +1,131 @@
import React, { useState } from 'react';
import { motion } from 'motion/react';
import { BarChart3, BriefcaseBusiness, Home, LogOut, MessageSquareQuote, Settings2 } from 'lucide-react';
import { NavLink } from 'react-router-dom';
import { useAuth } from '../../contexts/AuthContext';
import { useAgentChatStore } from '../../stores/agentChatStore';
import { cn } from '../../utils/cn';
import { ConfirmDialog } from '../common/ConfirmDialog';
import { ThemeToggle } from '../theme/ThemeToggle';
type SidebarNavProps = {
collapsed?: boolean;
onNavigate?: () => void;
};
type NavItem = {
key: string;
label: string;
to: string;
icon: React.ComponentType<{ className?: string }>;
exact?: boolean;
badge?: 'completion';
};
const NAV_ITEMS: NavItem[] = [
{ key: 'home', label: '首页', to: '/', icon: Home, exact: true },
{ key: 'chat', label: '问股', to: '/chat', icon: MessageSquareQuote, badge: 'completion' },
{ key: 'portfolio', label: '持仓', to: '/portfolio', icon: BriefcaseBusiness },
{ key: 'backtest', label: '回测', to: '/backtest', icon: BarChart3 },
{ key: 'settings', label: '设置', to: '/settings', icon: Settings2 },
];
export const SidebarNav: React.FC<SidebarNavProps> = ({ collapsed = false, onNavigate }) => {
const { authEnabled, logout } = useAuth();
const completionBadge = useAgentChatStore((state) => state.completionBadge);
const [showLogoutConfirm, setShowLogoutConfirm] = useState(false);
return (
<div className="flex h-full flex-col">
<div className={cn('mb-4 flex items-center gap-2 px-1', collapsed ? 'justify-center' : '')}>
<div className="flex h-10 w-10 items-center justify-center rounded-2xl bg-primary-gradient text-[hsl(var(--primary-foreground))] shadow-[0_12px_28px_var(--nav-brand-shadow)]">
<BarChart3 className="h-5 w-5" />
</div>
{!collapsed ? (
<p className="min-w-0 truncate text-sm font-semibold text-foreground">DSA</p>
) : null}
</div>
<nav className="flex flex-1 flex-col gap-1.5" aria-label="主导航">
{NAV_ITEMS.map(({ key, label, to, icon: Icon, exact, badge }) => (
<NavLink
key={key}
to={to}
end={exact}
onClick={onNavigate}
aria-label={label}
className={({ isActive }) =>
cn(
'group relative flex items-center gap-3 border-y border-x-0 text-sm transition-all',
'h-[var(--nav-item-height)]',
collapsed ? 'justify-center px-0' : 'px-[var(--nav-item-padding-x)]',
isActive
? 'border-[var(--nav-active-border)] bg-[var(--nav-active-bg)] text-foreground shadow-[inset_0_0_15px_var(--nav-active-shadow)]'
: 'border-transparent text-secondary-text hover:bg-[var(--nav-hover-bg)] hover:text-foreground'
)
}
>
{({ isActive }) => (
<>
{isActive && (
<motion.div
layoutId="activeIndicator"
className="absolute top-0 bottom-0 left-0 w-[var(--nav-indicator-width)] bg-[var(--nav-indicator-bg)] shadow-[0_0_10px_var(--nav-indicator-shadow)]"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.2 }}
/>
)}
<Icon className={cn('ml-1 h-5 w-5 shrink-0', isActive ? 'text-[var(--nav-icon-active)]' : 'text-current')} />
{!collapsed ? <span className="truncate">{label}</span> : null}
{badge === 'completion' && completionBadge ? (
<span
data-testid="chat-completion-badge"
className={cn(
'absolute right-3 h-2.5 w-2.5 rounded-full border-2 border-background bg-[var(--nav-badge-bg)] shadow-[0_0_10px_var(--nav-indicator-shadow)]',
collapsed ? 'right-2 top-2' : ''
)}
aria-label="问股有新消息"
/>
) : null}
</>
)}
</NavLink>
))}
</nav>
<div className="mt-4 mb-2">
<ThemeToggle variant="nav" collapsed={collapsed} />
</div>
{authEnabled ? (
<button
type="button"
onClick={() => setShowLogoutConfirm(true)}
className={cn(
'mt-5 flex h-11 w-full cursor-pointer select-none items-center gap-3 rounded-2xl border border-transparent px-3 text-sm text-secondary-text transition-all hover:border-border/70 hover:bg-hover hover:text-foreground',
collapsed ? 'justify-center px-2' : ''
)}
>
<LogOut className="h-5 w-5 shrink-0" />
{!collapsed ? <span>退</span> : null}
</button>
) : null}
<ConfirmDialog
isOpen={showLogoutConfirm}
title="退出登录"
message="确认退出当前登录状态吗?退出后需要重新输入密码。"
confirmText="确认退出"
cancelText="取消"
isDanger
onConfirm={() => {
setShowLogoutConfirm(false);
onNavigate?.();
void logout();
}}
onCancel={() => setShowLogoutConfirm(false)}
/>
</div>
);
};

View File

@@ -0,0 +1,90 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import { beforeAll, describe, expect, it, vi } from 'vitest';
import { ThemeProvider } from '../../theme/ThemeProvider';
import { Shell } from '../Shell';
const mockLogout = vi.fn().mockResolvedValue(undefined);
vi.mock('../../../contexts/AuthContext', () => ({
useAuth: () => ({
authEnabled: true,
logout: mockLogout,
}),
}));
vi.mock('../../../stores/agentChatStore', () => ({
useAgentChatStore: (selector: (state: { completionBadge: boolean }) => unknown) =>
selector({ completionBadge: true }),
}));
beforeAll(() => {
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: vi.fn().mockImplementation((query: string) => ({
matches: query === '(prefers-color-scheme: dark)',
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});
});
describe('Shell', () => {
it.skip('renders navigation, theme toggle and completion badge', () => {
render(
<MemoryRouter initialEntries={['/chat']}>
<ThemeProvider>
<Shell>
<div>page content</div>
</Shell>
</ThemeProvider>
</MemoryRouter>
);
expect(screen.getAllByRole('button', { name: '切换主题' }).length).toBeGreaterThan(0);
expect(screen.getByRole('link', { name: '问股' })).toBeInTheDocument();
expect(screen.getByTestId('chat-completion-badge')).toBeInTheDocument();
const logoutButton = screen.getByRole('button', { name: '退出' });
expect(logoutButton).toBeInTheDocument();
expect(logoutButton).toHaveClass('cursor-pointer');
});
it.skip('opens the theme menu from the sidebar toggle', async () => {
render(
<MemoryRouter initialEntries={['/chat']}>
<ThemeProvider>
<Shell>
<div>page content</div>
</Shell>
</ThemeProvider>
</MemoryRouter>
);
fireEvent.click(screen.getAllByRole('button', { name: '切换主题' })[0]);
expect(await screen.findByRole('menu', { name: '主题模式' })).toBeInTheDocument();
});
it('shows a confirmation dialog before logout', async () => {
render(
<MemoryRouter initialEntries={['/chat']}>
<ThemeProvider>
<Shell>
<div>page content</div>
</Shell>
</ThemeProvider>
</MemoryRouter>
);
fireEvent.click(screen.getByRole('button', { name: '退出' }));
expect(await screen.findByRole('heading', { name: '退出登录' })).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '确认退出' }));
expect(mockLogout).toHaveBeenCalled();
});
});

View File

@@ -15,7 +15,7 @@ interface ReportNewsProps {
/**
* 资讯区组件 - 终端风格
*/
export const ReportNews: React.FC<ReportNewsProps> = ({ recordId, limit = 20 }) => {
export const ReportNews: React.FC<ReportNewsProps> = ({ recordId, limit = 8 }) => {
const [isLoading, setIsLoading] = useState(false);
const [items, setItems] = useState<NewsIntelItem[]>([]);
const [error, setError] = useState<ParsedApiError | null>(null);
@@ -50,8 +50,8 @@ export const ReportNews: React.FC<ReportNewsProps> = ({ recordId, limit = 20 })
return (
<Card variant="bordered" padding="md">
<div className="flex items-center justify-between mb-3">
<div className="mb-3 flex items-baseline gap-2">
<div className="mb-4 flex items-center justify-between gap-3">
<div className="flex items-baseline gap-2">
<span className="label-uppercase">NEWS FEED</span>
<h3 className="text-base font-semibold text-white"></h3>
</div>
@@ -89,19 +89,19 @@ export const ReportNews: React.FC<ReportNewsProps> = ({ recordId, limit = 20 })
)}
{!isLoading && !error && items.length > 0 && (
<div className="space-y-2 text-left">
<div className="space-y-3 text-left">
{items.map((item, index) => (
<div
key={`${item.title}-${index}`}
className="group p-3 rounded-lg bg-elevated/80 border border-white/5 hover:border-cyan/30 hover:bg-hover transition-colors"
className="group rounded-xl border border-white/6 bg-elevated/75 p-4 transition-colors hover:border-cyan/25 hover:bg-hover"
>
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0 text-left">
<p className="text-sm text-white font-medium leading-snug text-left">
<p className="text-sm font-medium leading-6 text-white text-left">
{item.title}
</p>
{item.snippet && (
<p className="text-xs text-secondary-text mt-1 text-left">
<p className="mt-2 text-sm leading-6 text-secondary-text text-left overflow-hidden [display:-webkit-box] [-webkit-line-clamp:3] [-webkit-box-orient:vertical]">
{item.snippet}
</p>
)}
@@ -111,7 +111,7 @@ export const ReportNews: React.FC<ReportNewsProps> = ({ recordId, limit = 20 })
href={item.url}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-cyan hover:text-white transition-colors inline-flex items-center gap-1 whitespace-nowrap"
className="inline-flex shrink-0 items-center gap-1 whitespace-nowrap rounded-full border border-cyan/18 bg-cyan/10 px-2.5 py-1 text-xs text-cyan transition-colors hover:border-cyan/30 hover:text-white"
>
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">

View File

@@ -32,17 +32,17 @@ export const ReportOverview: React.FC<ReportOverviewProps> = ({
};
return (
<div className="space-y-4">
<div className="space-y-5">
{/* 主信息区 - 两列布局items-stretch 确保右侧与左侧同高 */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4 items-stretch">
<div className="grid grid-cols-1 lg:grid-cols-3 gap-5 items-stretch">
{/* 左侧:股票信息与结论 */}
<div className="lg:col-span-2 space-y-4">
<div className="lg:col-span-2 space-y-5">
{/* 股票头部 */}
<Card variant="gradient" padding="md">
<div className="flex items-start justify-between mb-4">
<div className="flex items-start justify-between mb-5">
<div className="flex-1">
<div className="flex items-center gap-3">
<h2 className="text-2xl font-bold text-white">
<h2 className="text-[28px] font-bold leading-tight text-white">
{meta.stockName || meta.stockCode}
</h2>
{/* 价格和涨跌幅 */}
@@ -58,7 +58,7 @@ export const ReportOverview: React.FC<ReportOverviewProps> = ({
)}
</div>
<div className="flex items-center gap-2 mt-1.5">
<span className="font-mono text-xs text-cyan bg-cyan/10 px-1.5 py-0.5 rounded">
<span className="font-mono text-xs text-cyan bg-cyan/10 px-2 py-0.5 rounded-full">
{meta.stockCode}
</span>
<span className="text-xs text-muted-text flex items-center gap-1">
@@ -72,16 +72,16 @@ export const ReportOverview: React.FC<ReportOverviewProps> = ({
</div>
{/* 关键结论 */}
<div className="border-t border-white/5 pt-4">
<div className="border-t border-white/5 pt-5">
<span className="label-uppercase">KEY INSIGHTS</span>
<p className="text-white text-sm leading-relaxed mt-1.5 whitespace-pre-wrap text-left">
<p className="mt-2 whitespace-pre-wrap text-left text-[15px] leading-7 text-white max-w-[62ch]">
{summary.analysisSummary || '暂无分析结论'}
</p>
</div>
</Card>
{/* 操作建议和趋势预测 */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* 操作建议 */}
<Card variant="bordered" padding="sm" hoverable>
<div className="flex items-start gap-3">
@@ -90,9 +90,9 @@ export const ReportOverview: React.FC<ReportOverviewProps> = ({
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4" />
</svg>
</div>
<div>
<h4 className="text-xs font-medium text-success mb-0.5"></h4>
<p className="text-white text-sm font-medium">
<div className="space-y-1.5">
<h4 className="text-[11px] font-medium uppercase tracking-[0.16em] text-success"></h4>
<p className="text-sm leading-6 text-white">
{summary.operationAdvice || '暂无建议'}
</p>
</div>
@@ -107,9 +107,9 @@ export const ReportOverview: React.FC<ReportOverviewProps> = ({
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6" />
</svg>
</div>
<div>
<h4 className="text-xs font-medium text-warning mb-0.5"></h4>
<p className="text-white text-sm font-medium">
<div className="space-y-1.5">
<h4 className="text-[11px] font-medium uppercase tracking-[0.16em] text-warning"></h4>
<p className="text-sm leading-6 text-white">
{summary.trendPrediction || '暂无预测'}
</p>
</div>
@@ -122,7 +122,7 @@ export const ReportOverview: React.FC<ReportOverviewProps> = ({
<div className="flex flex-col self-stretch min-h-full">
<Card variant="bordered" padding="md" className="!overflow-visible flex-1 flex flex-col min-h-0">
<div className="text-center flex-1 flex flex-col justify-center">
<h3 className="text-sm font-medium text-white mb-4">Market Sentiment</h3>
<h3 className="mb-5 text-sm font-medium tracking-wide text-white">Market Sentiment</h3>
<ScoreGauge score={summary.sentimentScore} size="lg" />
</div>
</Card>

View File

@@ -30,7 +30,7 @@ export const ReportSummary: React.FC<ReportSummaryProps> = ({
);
return (
<div className="space-y-3 animate-fade-in">
<div className="space-y-5 pb-8 animate-fade-in">
{/* 概览区(首屏) */}
<ReportOverview
meta={meta}
@@ -42,14 +42,14 @@ export const ReportSummary: React.FC<ReportSummaryProps> = ({
<ReportStrategy strategy={strategy} />
{/* 资讯区 */}
<ReportNews recordId={recordId} />
<ReportNews recordId={recordId} limit={8} />
{/* 透明度与追溯区 */}
<ReportDetails details={details} recordId={recordId} />
{/* 分析模型标记Issue #528— 报告末尾 */}
{shouldShowModel && (
<p className="text-xs text-gray-500 mt-3">
<p className="px-1 text-xs text-muted-text">
: {modelUsed}
</p>
)}

View File

@@ -0,0 +1,213 @@
import type React from 'react';
import { useEffect, useMemo, useState } from 'react';
import { authApi } from '../../api/auth';
import { getParsedApiError, isParsedApiError, type ParsedApiError } from '../../api/error';
import { useAuth } from '../../hooks';
import { Badge, Button, Input, Checkbox } from '../common';
import { SettingsAlert } from './SettingsAlert';
import { SettingsSectionCard } from './SettingsSectionCard';
function createNextModeLabel(authEnabled: boolean, desiredEnabled: boolean) {
if (authEnabled && !desiredEnabled) {
return '关闭认证';
}
if (!authEnabled && desiredEnabled) {
return '开启认证';
}
return authEnabled ? '保持已开启' : '保持已关闭';
}
export const AuthSettingsCard: React.FC = () => {
const { authEnabled, setupState, refreshStatus } = useAuth();
const [desiredEnabled, setDesiredEnabled] = useState(authEnabled);
const [currentPassword, setCurrentPassword] = useState('');
const [password, setPassword] = useState('');
const [passwordConfirm, setPasswordConfirm] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | ParsedApiError | null>(null);
const [successMessage, setSuccessMessage] = useState<string | null>(null);
const isDirty = desiredEnabled !== authEnabled || currentPassword || password || passwordConfirm;
const targetActionLabel = createNextModeLabel(authEnabled, desiredEnabled);
const helperText = useMemo(() => {
switch (setupState) {
case 'no_password':
return '系统尚未设置密码。启用认证前请先设置初始管理员密码,设置后请妥善保管。';
case 'password_retained':
return '系统已保留之前设置的管理员密码。输入当前密码即可快速重新启用认证。';
case 'enabled':
return !desiredEnabled
? '若当前登录会话仍有效,可直接关闭认证;若会话已失效,请输入当前管理员密码。'
: '管理员认证已启用。如需更新密码,请使用下方的“修改密码”功能。';
default:
return '管理员认证可保护 Web 设置页及 API 接口,防止未经授权的访问。';
}
}, [setupState, desiredEnabled]);
useEffect(() => {
setDesiredEnabled(authEnabled);
}, [authEnabled]);
const resetForm = () => {
setCurrentPassword('');
setPassword('');
setPasswordConfirm('');
};
const handleSubmit = async (event: React.FormEvent) => {
event.preventDefault();
setError(null);
setSuccessMessage(null);
// Initial setup validation
if (setupState === 'no_password' && desiredEnabled) {
if (!password) {
setError('设置新密码是必填项');
return;
}
if (password !== passwordConfirm) {
setError('两次输入的新密码不一致');
return;
}
}
setIsSubmitting(true);
try {
await authApi.updateSettings(
desiredEnabled,
password.trim() || undefined,
passwordConfirm.trim() || undefined,
currentPassword.trim() || undefined,
);
await refreshStatus();
setSuccessMessage(desiredEnabled ? '认证设置已更新' : '认证已关闭');
resetForm();
} catch (err: unknown) {
setError(getParsedApiError(err));
} finally {
setIsSubmitting(false);
}
};
return (
<SettingsSectionCard
title="认证与登录保护"
description="管理管理员密码认证,保护您的系统配置安全。"
actions={
<Badge variant={authEnabled ? 'success' : 'default'} size="sm">
{authEnabled ? '已启用' : '未启用'}
</Badge>
}
>
<form className="space-y-4" onSubmit={handleSubmit}>
<div className="rounded-xl border border-border/50 bg-muted/20 p-4 shadow-soft-card-strong transition-all hover:bg-muted/30">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="space-y-1">
<p className="text-sm font-semibold text-foreground"></p>
<p className="text-xs leading-6 text-muted-text">{helperText}</p>
</div>
<Checkbox
checked={desiredEnabled}
disabled={isSubmitting}
label={desiredEnabled ? '开启' : '关闭'}
onChange={(event) => setDesiredEnabled(event.target.checked)}
containerClassName="bg-muted/30 border border-border/50 rounded-full px-4 py-2 shadow-soft-card-strong transition-all hover:bg-muted/40"
/>
</div>
</div>
{/* Password input fields logic based on setupState and desiredEnabled */}
{(desiredEnabled || (authEnabled && !desiredEnabled)) && (
<div className="grid gap-4 md:grid-cols-2">
{/* Show Current Password if we have one and we're either re-enabling or turning off */}
{(setupState === 'password_retained' && desiredEnabled) ||
(setupState === 'enabled' && !desiredEnabled) ? (
<div className="space-y-3">
<Input
label="当前管理员密码"
type="password"
allowTogglePassword
iconType="password"
value={currentPassword}
onChange={(event) => setCurrentPassword(event.target.value)}
autoComplete="current-password"
disabled={isSubmitting}
placeholder="请输入当前密码"
hint={setupState === 'password_retained' ? '输入旧密码以重新激活认证' : '关闭认证前可能需要验证身份'}
/>
</div>
) : null}
{/* Show New Password fields only during initial setup */}
{setupState === 'no_password' && desiredEnabled ? (
<>
<div className="space-y-3">
<Input
label="设置管理员密码"
type="password"
allowTogglePassword
iconType="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
autoComplete="new-password"
disabled={isSubmitting}
placeholder="输入新密码 (至少 6 位)"
/>
</div>
<div className="space-y-3">
<Input
label="确认新密码"
type="password"
allowTogglePassword
iconType="password"
value={passwordConfirm}
onChange={(event) => setPasswordConfirm(event.target.value)}
autoComplete="new-password"
disabled={isSubmitting}
placeholder="再次输入以确认"
/>
</div>
</>
) : null}
</div>
)}
{error ? (
isParsedApiError(error) ? (
<SettingsAlert
title="认证设置失败"
message={error.message}
variant="error"
/>
) : (
<SettingsAlert title="认证设置失败" message={error} variant="error" />
)
) : null}
{successMessage ? (
<SettingsAlert title="操作成功" message={successMessage} variant="success" />
) : null}
<div className="flex flex-wrap items-center gap-2">
<Button type="submit" variant="settings-primary" isLoading={isSubmitting} disabled={!isDirty}>
{targetActionLabel}
</Button>
<Button
type="button"
variant="settings-secondary"
onClick={() => {
setDesiredEnabled(authEnabled);
setError(null);
setSuccessMessage(null);
resetForm();
}}
disabled={isSubmitting || !isDirty}
>
</Button>
</div>
</form>
</SettingsSectionCard>
);
};

View File

@@ -3,17 +3,16 @@ import { useState } from 'react';
import type { ParsedApiError } from '../../api/error';
import { isParsedApiError } from '../../api/error';
import { useAuth } from '../../hooks';
import { ApiErrorAlert, EyeToggleIcon } from '../common';
import { Button, Input } from '../common';
import { SettingsAlert } from './SettingsAlert';
import { SettingsSectionCard } from './SettingsSectionCard';
export const ChangePasswordCard: React.FC = () => {
const { changePassword } = useAuth();
const [currentPassword, setCurrentPassword] = useState('');
const [newPassword, setNewPassword] = useState('');
const [newPasswordConfirm, setNewPasswordConfirm] = useState('');
const [showCurrent, setShowCurrent] = useState(false);
const [showNew, setShowNew] = useState(false);
const [showConfirm, setShowConfirm] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | ParsedApiError | null>(null);
const [success, setSuccess] = useState(false);
@@ -48,9 +47,6 @@ export const ChangePasswordCard: React.FC = () => {
setCurrentPassword('');
setNewPassword('');
setNewPasswordConfirm('');
setShowCurrent(false);
setShowNew(false);
setShowConfirm(false);
setTimeout(() => setSuccess(false), 4000);
} else {
setError(result.error ?? '修改失败');
@@ -61,121 +57,72 @@ export const ChangePasswordCard: React.FC = () => {
};
return (
<div className="rounded-xl border border-white/8 bg-elevated/50 p-4">
<div className="mb-2 flex items-center gap-2">
<label className="text-sm font-semibold text-white"></label>
</div>
<p className="mb-3 text-xs text-muted-text"></p>
<SettingsSectionCard
title="修改密码"
description="更新当前管理员登录密码。修改成功后,后续登录请使用新密码。"
>
<form onSubmit={handleSubmit} className="space-y-3">
<div>
<label
htmlFor="change-pass-current"
className="mb-1 block text-xs font-medium text-secondary-text"
>
</label>
<div className="flex items-center gap-2">
<input
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-3">
<Input
id="change-pass-current"
type={showCurrent ? 'text' : 'password'}
className="input-terminal flex-1"
type="password"
allowTogglePassword
iconType="password"
label="当前密码"
placeholder="输入当前密码"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
disabled={isSubmitting}
autoComplete="current-password"
/>
<button
type="button"
className="btn-secondary !p-2 shrink-0"
disabled={isSubmitting}
onClick={() => setShowCurrent((v) => !v)}
title={showCurrent ? '隐藏' : '显示'}
aria-label={showCurrent ? '隐藏密码' : '显示密码'}
>
<EyeToggleIcon visible={showCurrent} />
</button>
</div>
</div>
<div>
<label
htmlFor="change-pass-new"
className="mb-1 block text-xs font-medium text-secondary-text"
>
</label>
<div className="flex items-center gap-2">
<input
<div className="space-y-3">
<Input
id="change-pass-new"
type={showNew ? 'text' : 'password'}
className="input-terminal flex-1"
placeholder="输入新密码(至少 6 位)"
type="password"
allowTogglePassword
iconType="password"
label="新密码"
hint="至少 6 位。"
placeholder="输入新密码"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
disabled={isSubmitting}
autoComplete="new-password"
/>
<button
type="button"
className="btn-secondary !p-2 shrink-0"
disabled={isSubmitting}
onClick={() => setShowNew((v) => !v)}
title={showNew ? '隐藏' : '显示'}
aria-label={showNew ? '隐藏密码' : '显示密码'}
>
<EyeToggleIcon visible={showNew} />
</button>
</div>
</div>
<div>
<label
htmlFor="change-pass-confirm"
className="mb-1 block text-xs font-medium text-secondary-text"
>
</label>
<div className="flex items-center gap-2">
<input
id="change-pass-confirm"
type={showConfirm ? 'text' : 'password'}
className="input-terminal flex-1"
placeholder="再次输入新密码"
value={newPasswordConfirm}
onChange={(e) => setNewPasswordConfirm(e.target.value)}
disabled={isSubmitting}
autoComplete="new-password"
/>
<button
type="button"
className="btn-secondary !p-2 shrink-0"
disabled={isSubmitting}
onClick={() => setShowConfirm((v) => !v)}
title={showConfirm ? '隐藏' : '显示'}
aria-label={showConfirm ? '隐藏密码' : '显示密码'}
>
<EyeToggleIcon visible={showConfirm} />
</button>
</div>
<div className="space-y-3 md:max-w-md">
<Input
id="change-pass-confirm"
type="password"
allowTogglePassword
iconType="password"
label="确认新密码"
placeholder="再次输入新密码"
value={newPasswordConfirm}
onChange={(e) => setNewPasswordConfirm(e.target.value)}
disabled={isSubmitting}
autoComplete="new-password"
/>
</div>
{error
? isParsedApiError(error)
? <ApiErrorAlert error={error} className="!mt-3" />
? <SettingsAlert title="修改失败" message={error.message} variant="error" className="!mt-3" />
: <SettingsAlert title="修改失败" message={error} variant="error" className="!mt-3" />
: null}
{success ? (
<p className="text-xs text-green-500"></p>
<SettingsAlert title="修改成功" message="管理员密码已更新。" variant="success" />
) : null}
<button
type="submit"
className="btn-primary mt-2"
disabled={isSubmitting}
>
{isSubmitting ? '修改中...' : '修改'}
</button>
<Button type="submit" variant="primary" isLoading={isSubmitting}>
</Button>
</form>
</div>
</SettingsSectionCard>
);
};

View File

@@ -3,6 +3,7 @@ import { useCallback, useState } from 'react';
import { getParsedApiError } from '../../api/error';
import { stocksApi, type ExtractItem } from '../../api/stocks';
import { systemConfigApi, SystemConfigConflictError } from '../../api/systemConfig';
import { Badge, Button } from '../common';
const IMG_EXT = ['.jpg', '.jpeg', '.png', '.webp', '.gif'];
const IMG_MAX = 5 * 1024 * 1024; // 5MB
@@ -13,12 +14,22 @@ interface IntelligentImportProps {
stockListValue: string;
configVersion: string;
maskToken: string;
onMerged: () => void;
onMerged: (newValue: string) => void | Promise<void>;
disabled?: boolean;
}
type ItemWithChecked = ExtractItem & { id: string; checked: boolean };
function getConfidenceMeta(confidence: 'high' | 'medium' | 'low') {
if (confidence === 'high') {
return { label: '高', badge: 'success' as const };
}
if (confidence === 'low') {
return { label: '低', badge: 'warning' as const };
}
return { label: '中', badge: 'default' as const };
}
function normalizeConfidence(confidence?: string | null): 'high' | 'medium' | 'low' {
if (confidence === 'high' || confidence === 'low' || confidence === 'medium') {
return confidence;
@@ -253,10 +264,10 @@ export const IntelligentImport: React.FC<IntelligentImportProps> = ({
});
setItems([]);
setPasteText('');
onMerged();
await onMerged(value);
} catch (e) {
if (e instanceof SystemConfigConflictError) {
onMerged();
await onMerged(value);
setError('配置已更新,请再次点击「合并到自选股」');
} else {
setError(e instanceof Error ? e.message : '合并保存失败');
@@ -270,111 +281,131 @@ export const IntelligentImport: React.FC<IntelligentImportProps> = ({
const checkedCount = items.filter((i) => i.checked && i.code).length;
return (
<div className="rounded-xl border border-white/8 bg-elevated/40 p-4">
<p className="mb-2 text-sm font-medium text-white"></p>
<p className="mb-3 text-xs text-muted-text">
CSV/Excel Vision API
</p>
<div className="space-y-4">
<div className="rounded-xl border border-white/20 bg-elevated/62 p-4 shadow-soft-card">
<p className="text-sm font-medium text-foreground">CSV/Excel </p>
<p className="mt-1 text-xs leading-5 text-secondary-text">
Vision
</p>
</div>
<div
onDrop={onDrop}
onDragOver={(e) => { e.preventDefault(); setIsDragging(true); }}
onDragLeave={(e) => { e.preventDefault(); setIsDragging(false); }}
className={`mb-3 flex min-h-[80px] flex-col gap-4 rounded-lg border-2 border-dashed p-4 transition ${
isDragging ? 'border-accent bg-cyan/5' : 'border-white/16'
className={`flex min-h-[96px] flex-col gap-4 rounded-xl border border-dashed p-4 transition-colors ${
isDragging ? 'border-cyan/50 bg-cyan/6' : 'border-white/45 bg-background/22'
} ${disabled || isLoading ? 'cursor-not-allowed opacity-60' : ''}`}
>
<div className="flex flex-wrap items-center gap-2">
<label className="cursor-pointer">
<span className="btn-secondary text-sm"></span>
<Button type="button" variant="settings-secondary" disabled={disabled || isLoading}>
</Button>
<input type="file" accept=".jpg,.jpeg,.png,.webp,.gif" className="hidden" onChange={onImageInput} disabled={disabled || isLoading} />
</label>
<label className="cursor-pointer">
<span className="btn-secondary text-sm"></span>
<Button type="button" variant="settings-secondary" disabled={disabled || isLoading}>
</Button>
<input type="file" accept=".csv,.xlsx,.txt" className="hidden" onChange={onDataFileInput} disabled={disabled || isLoading} />
</label>
</div>
<div className="flex gap-2">
<div className="flex flex-col gap-2 sm:flex-row">
<textarea
placeholder="或粘贴 CSV/Excel 复制的文本..."
className="min-h-[60px] w-full rounded-lg border border-white/16 bg-card/60 px-2 py-1.5 text-sm text-white placeholder:text-muted-text"
className="min-h-[72px] w-full rounded-xl border border-white/20 bg-card/92 px-3 py-2 text-sm text-foreground shadow-soft-card transition-colors placeholder:text-muted-text focus:border-cyan/35 focus:outline-none focus:ring-4 focus:ring-cyan/10"
value={pasteText}
onChange={(e) => setPasteText(e.target.value)}
disabled={disabled || isLoading}
/>
<button type="button" className="btn-secondary shrink-0" onClick={handlePasteParse} disabled={disabled || isLoading || !pasteText.trim()}>
<Button
type="button"
variant="secondary"
className="shrink-0 sm:self-start"
onClick={handlePasteParse}
disabled={disabled || isLoading || !pasteText.trim()}
>
</button>
</Button>
</div>
</div>
{isLoading && <p className="mb-2 text-sm text-secondary-text">...</p>}
{isLoading && <p className="text-sm text-secondary-text">...</p>}
{error && (
<div className="mb-3 rounded-lg border border-red-500/30 bg-red-500/10 px-3 py-2 text-sm text-red-400">{error}</div>
<div className="rounded-xl border border-danger/30 bg-danger/10 px-3 py-2 text-sm text-danger">{error}</div>
)}
{items.length > 0 && (
<div className="space-y-2">
<p className="rounded-lg border border-amber-500/40 bg-amber-500/10 px-2 py-1.5 text-xs text-amber-400">
/
</p>
<div className="rounded-xl border border-warning/30 bg-warning/10 px-3 py-2 text-xs text-warning">
/
</div>
<div className="flex items-center justify-between">
<span className="text-xs text-secondary-text">
{validCount} {checkedCount}
</span>
<div className="flex gap-2">
<button type="button" className="text-xs text-muted-text hover:text-white" onClick={() => toggleAll(true)}>
<button type="button" className="text-xs text-secondary-text transition-colors hover:text-foreground" onClick={() => toggleAll(true)}>
</button>
<button type="button" className="text-xs text-muted-text hover:text-white" onClick={() => toggleAll(false)}>
<button type="button" className="text-xs text-secondary-text transition-colors hover:text-foreground" onClick={() => toggleAll(false)}>
</button>
<button type="button" className="text-xs text-muted-text hover:text-white" onClick={clearAll}>
<button type="button" className="text-xs text-secondary-text transition-colors hover:text-foreground" onClick={clearAll}>
</button>
</div>
</div>
<div className="max-h-[200px] overflow-y-auto space-y-1">
{items.map((it) => (
<div
key={it.id}
className={`flex items-center gap-2 rounded-lg border px-2 py-1.5 text-sm ${
it.code ? 'border-white/16 bg-card/60' : 'border-red-500/30 bg-red-500/10'
}`}
>
<input
type="checkbox"
checked={it.checked}
onChange={() => toggleChecked(it.id)}
disabled={!it.code || disabled}
className="rounded"
/>
<span className={it.code ? 'text-white' : 'text-red-400'}>
{it.code || '解析失败'}
</span>
{it.name && <span className="text-muted-text">({it.name})</span>}
<span className="ml-auto text-xs text-muted-text">
{it.confidence === 'high' ? '高' : it.confidence === 'low' ? '低' : '中'}
</span>
<button
type="button"
className="text-muted-text hover:text-white"
onClick={() => removeItem(it.id)}
disabled={disabled}
<div className="max-h-[220px] space-y-1 overflow-y-auto rounded-xl border border-border/40 bg-background/18 p-2">
{items.map((it) => {
const confidence = normalizeConfidence(it.confidence);
const confidenceMeta = getConfidenceMeta(confidence);
return (
<div
key={it.id}
className={`flex items-center gap-2 rounded-xl border px-3 py-2 text-sm ${
it.code ? 'border-border/40 bg-elevated/62' : 'border-danger/25 bg-danger/10'
}`}
>
×
</button>
</div>
))}
<input
type="checkbox"
checked={it.checked}
onChange={() => toggleChecked(it.id)}
disabled={!it.code || disabled}
className="h-4 w-4 rounded border-border/70 bg-base text-cyan focus:ring-cyan/20"
/>
<span className={it.code ? 'font-medium text-foreground' : 'font-medium text-danger'}>
{it.code || '解析失败'}
</span>
{it.name && <span className="text-secondary-text">({it.name})</span>}
<div className="ml-auto flex items-center gap-2">
<Badge variant={confidenceMeta.badge} size="sm">
{confidenceMeta.label}
</Badge>
<button
type="button"
className="text-secondary-text transition-colors hover:text-foreground"
onClick={() => removeItem(it.id)}
disabled={disabled}
>
×
</button>
</div>
</div>
);
})}
</div>
<button
<Button
type="button"
className="btn-primary mt-2"
variant="primary"
className="mt-2"
onClick={() => void mergeToWatchlist()}
disabled={disabled || isMerging || checkedCount === 0}
>
{isMerging ? '保存中...' : '合并到自选股'}
</button>
</Button>
</div>
)}
</div>

View File

@@ -3,7 +3,7 @@ import type React from 'react';
import type { ParsedApiError } from '../../api/error';
import { getParsedApiError } from '../../api/error';
import { systemConfigApi } from '../../api/systemConfig';
import { ApiErrorAlert, EyeToggleIcon, Select } from '../common';
import { ApiErrorAlert, Badge, Button, Input, Select } from '../common';
type ChannelProtocol = 'openai' | 'deepseek' | 'gemini' | 'anthropic' | 'vertex_ai' | 'ollama';
@@ -157,7 +157,7 @@ interface LLMChannelEditorProps {
items: Array<{ key: string; value: string }>;
configVersion: string;
maskToken: string;
onSaved: () => void;
onSaved: (updatedItems: Array<{ key: string; value: string }>) => void | Promise<void>;
disabled?: boolean;
}
@@ -171,7 +171,7 @@ interface ChannelRowProps {
onUpdate: (index: number, field: keyof ChannelConfig, value: string | boolean) => void;
onRemove: (index: number) => void;
onToggleExpand: (index: number) => void;
onToggleKeyVisibility: (index: number) => void;
onToggleKeyVisibility: (index: number, nextVisible: boolean) => void;
onTest: (channel: ChannelConfig, index: number) => void;
}
@@ -192,11 +192,18 @@ const ChannelRow: React.FC<ChannelRowProps> = ({
const displayName = preset?.label || channel.name;
const modelCount = splitModels(channel.models).length;
const hasKey = channel.apiKey.length > 0;
const statusVariant = testState?.status === 'success'
? 'success'
: testState?.status === 'error'
? 'danger'
: testState?.status === 'loading'
? 'warning'
: 'default';
return (
<div className="overflow-hidden rounded-lg border border-white/8 bg-card/40">
<div className="mb-2 overflow-hidden rounded-xl border border-white/10 bg-white/2 shadow-soft-card transition-all hover:bg-white/5">
<div
className="flex cursor-pointer select-none items-center gap-2 px-3 py-2 transition-colors hover:bg-white/[0.03]"
className="flex cursor-pointer select-none items-center gap-2.5 px-4 py-3 transition-colors hover:bg-white/5"
onClick={() => onToggleExpand(index)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
@@ -207,39 +214,46 @@ const ChannelRow: React.FC<ChannelRowProps> = ({
role="button"
tabIndex={0}
>
<span className="w-4 shrink-0 text-[11px] text-muted-text">{expanded ? '▼' : '▶'}</span>
<span className={`w-4 shrink-0 text-[11px] text-muted-text transition-transform ${expanded ? 'rotate-90' : ''}`}></span>
<input
type="checkbox"
checked={channel.enabled}
disabled={busy}
className="h-4 w-4 shrink-0 rounded border-white/10 bg-card text-cyan focus:ring-cyan/20"
className="h-4 w-4 shrink-0 rounded border-border/70 bg-base text-cyan focus:ring-cyan/20"
onClick={(e) => e.stopPropagation()}
onChange={(e) => onUpdate(index, 'enabled', e.target.checked)}
/>
<span className="min-w-[100px] truncate text-sm font-medium text-white">{displayName}</span>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="truncate text-sm font-semibold text-foreground">{displayName}</span>
<Badge variant="info" className="hidden sm:inline-flex">
{channel.protocol}
</Badge>
</div>
<p className="mt-0.5 truncate text-[11px] text-secondary-text">
{modelCount > 0 ? `${modelCount} 个模型已配置` : '未配置模型'}
</p>
</div>
<span className="hidden rounded bg-white/8 px-1.5 py-0.5 text-[10px] uppercase tracking-wide text-muted-text sm:inline">
{channel.protocol}
</span>
<span className="flex-1 truncate text-[11px] text-muted-text">
{modelCount > 0 ? `${modelCount} 个模型` : '未配置模型'}
</span>
<span className="flex shrink-0 items-center gap-1.5">
<span className="flex shrink-0 items-center gap-2">
{testState?.status === 'success' ? <span className="h-2 w-2 rounded-full bg-emerald-400" title="连接正常" /> : null}
{testState?.status === 'error' ? <span className="h-2 w-2 rounded-full bg-rose-400" title="连接失败" /> : null}
{testState?.status === 'loading' ? <span className="h-2 w-2 rounded-full bg-amber-400 animate-pulse" title="测试中" /> : null}
{!hasKey && channel.protocol !== 'ollama' ? (
<span className="text-[10px] text-amber-400/80"> Key</span>
{!hasKey && channel.protocol !== 'ollama' ? <Badge variant="warning"> Key</Badge> : null}
{testState?.status !== 'idle' ? (
<Badge variant={statusVariant}>
{testState?.status === 'success' ? '连接正常' : testState?.status === 'error' ? '连接失败' : '测试中'}
</Badge>
) : null}
</span>
<button
<Button
type="button"
className="shrink-0 px-1 text-xs text-muted-text transition-colors hover:text-rose-300"
variant="ghost"
size="sm"
className="h-8 shrink-0 px-2 text-xs text-muted-text hover:text-rose-300"
disabled={busy}
onClick={(e) => {
e.stopPropagation();
@@ -248,24 +262,21 @@ const ChannelRow: React.FC<ChannelRowProps> = ({
title="删除渠道"
>
</button>
</Button>
</div>
{expanded ? (
<div className="space-y-2.5 border-t border-white/6 bg-card/20 px-3 py-3">
<div className="space-y-4 bg-background/15 px-4 py-4">
<div className="grid gap-2 sm:grid-cols-2">
<div>
<label className="mb-0.5 block text-[11px] text-muted-text"></label>
<input
className="input-terminal text-sm"
value={channel.name}
disabled={busy}
onChange={(e) => onUpdate(index, 'name', e.target.value.toLowerCase().replace(/[^a-z0-9_]/g, ''))}
placeholder="primary"
/>
</div>
<div>
<label className="mb-0.5 block text-[11px] text-muted-text"></label>
<Input
label="渠道名称"
value={channel.name}
disabled={busy}
onChange={(e) => onUpdate(index, 'name', e.target.value.toLowerCase().replace(/[^a-z0-9_]/g, ''))}
placeholder="primary"
/>
<div className="space-y-2">
<label className="block text-sm font-medium text-foreground"></label>
<Select
value={channel.protocol}
onChange={(v) => onUpdate(index, 'protocol', normalizeProtocol(v))}
@@ -276,71 +287,56 @@ const ChannelRow: React.FC<ChannelRowProps> = ({
</div>
</div>
<div>
<label className="mb-0.5 block text-[11px] text-muted-text">Base URL</label>
<input
className="input-terminal text-sm"
value={channel.baseUrl}
disabled={busy}
onChange={(e) => onUpdate(index, 'baseUrl', e.target.value)}
placeholder={
channel.protocol === 'gemini' || channel.protocol === 'anthropic'
? '官方接口可留空'
: preset?.baseUrl || 'https://api.example.com/v1'
}
/>
</div>
<Input
label="Base URL"
value={channel.baseUrl}
disabled={busy}
onChange={(e) => onUpdate(index, 'baseUrl', e.target.value)}
placeholder={
channel.protocol === 'gemini' || channel.protocol === 'anthropic'
? '官方接口可留空'
: preset?.baseUrl || 'https://api.example.com/v1'
}
/>
<div>
<label className="mb-0.5 block text-[11px] text-muted-text">API Key</label>
<div className="flex items-center gap-1.5">
<input
type={visibleKey ? 'text' : 'password'}
className="input-terminal flex-1 text-sm"
value={channel.apiKey}
disabled={busy}
onChange={(e) => onUpdate(index, 'apiKey', e.target.value)}
placeholder={channel.protocol === 'ollama' ? '本地 Ollama 可留空' : '支持多个 Key 逗号分隔'}
/>
<button
type="button"
className="btn-secondary !p-1.5"
disabled={busy}
onClick={() => onToggleKeyVisibility(index)}
title={visibleKey ? '隐藏' : '显示'}
aria-label={visibleKey ? '隐藏 API Key' : '显示 API Key'}
>
<EyeToggleIcon visible={visibleKey} />
</button>
</div>
</div>
<Input
label="API Key"
type="password"
allowTogglePassword
iconType="key"
passwordVisible={visibleKey}
onPasswordVisibleChange={(nextVisible) => onToggleKeyVisibility(index, nextVisible)}
value={channel.apiKey}
disabled={busy}
onChange={(e) => onUpdate(index, 'apiKey', e.target.value)}
placeholder={channel.protocol === 'ollama' ? '本地 Ollama 可留空' : '支持多个 Key 逗号分隔'}
/>
<div>
<label className="mb-0.5 block text-[11px] text-muted-text"></label>
<input
className="input-terminal text-sm"
value={channel.models}
disabled={busy}
onChange={(e) => onUpdate(index, 'models', e.target.value)}
placeholder={preset?.placeholder || MODEL_PLACEHOLDERS[channel.protocol]}
/>
</div>
<Input
label="模型(逗号分隔)"
value={channel.models}
disabled={busy}
onChange={(e) => onUpdate(index, 'models', e.target.value)}
placeholder={preset?.placeholder || MODEL_PLACEHOLDERS[channel.protocol]}
/>
<div className="flex items-center gap-2 pt-1">
<button
<Button
type="button"
className="btn-secondary text-xs"
variant="gradient"
size="sm"
className="px-3 text-[11px] border-cyan/20 shadow-none"
disabled={busy}
onClick={() => onTest(channel, index)}
>
{testState?.status === 'loading' ? '测试中...' : '测试连接'}
</button>
</Button>
{testState?.text ? (
<span className={`text-xs ${
testState.status === 'success'
? 'text-emerald-300'
? 'text-success'
: testState.status === 'error'
? 'text-rose-300'
? 'text-danger'
: 'text-muted-text'
}`}
>
@@ -789,7 +785,7 @@ export const LLMChannelEditor: React.FC<LLMChannelEditorProps> = ({
items: updateItems,
});
setSaveMessage({ type: 'success', text: managesRuntimeConfig ? 'AI 配置已保存' : '渠道配置已保存' });
onSaved();
await onSaved(updateItems);
} catch (error: unknown) {
setSaveMessage({ type: 'error', error: getParsedApiError(error) });
} finally {
@@ -833,8 +829,8 @@ export const LLMChannelEditor: React.FC<LLMChannelEditorProps> = ({
}
};
const toggleKeyVisibility = (index: number) => {
setVisibleKeys((previous) => ({ ...previous, [index]: !previous[index] }));
const toggleKeyVisibility = (index: number, nextVisible: boolean) => {
setVisibleKeys((previous) => ({ ...previous, [index]: nextVisible }));
};
const toggleExpand = (index: number) => {
@@ -862,15 +858,18 @@ export const LLMChannelEditor: React.FC<LLMChannelEditorProps> = ({
};
return (
<div className="rounded-xl border border-cyan/20 bg-elevated/50 p-4">
<div className="space-y-4">
<button
type="button"
className="flex w-full items-center justify-between text-left"
className="flex w-full items-center justify-between rounded-[1.35rem] border border-white/10 bg-white/2 px-5 py-4 text-left transition-all duration-200 hover:bg-white/5"
onClick={() => setIsCollapsed((previous) => !previous)}
>
<div>
<h3 className="text-sm font-semibold text-white">AI </h3>
<p className="mt-0.5 text-xs text-muted-text">
<div className="space-y-1">
<div className="flex items-center gap-2">
<h3 className="text-base font-semibold text-white">AI </h3>
<Badge variant="info" className="bg-cyan/10 text-cyan border-cyan/20"></Badge>
</div>
<p className="text-xs text-muted-text">
API Key .env
</p>
</div>
@@ -878,25 +877,34 @@ export const LLMChannelEditor: React.FC<LLMChannelEditorProps> = ({
</button>
{!isCollapsed ? (
<div className="mt-4 space-y-5">
<div className="flex items-center gap-2">
<button type="button" className="btn-secondary whitespace-nowrap" disabled={busy} onClick={addChannel}>
+
</button>
<Select
value={addPreset}
onChange={setAddPreset}
options={Object.entries(CHANNEL_PRESETS).map(([value, preset]) => ({
value,
label: preset.label,
}))}
disabled={busy}
placeholder="选择服务商"
className="flex-1"
/>
<div className="space-y-4 animate-in fade-in slide-in-from-top-2 duration-300">
<div className="rounded-[1.35rem] border border-white/10 bg-white/2 p-4">
<div className="mb-3 flex items-center justify-between">
<div>
<h4 className="text-sm font-medium text-white"></h4>
<p className="mt-1 text-xs text-secondary-text">稿</p>
</div>
<Badge variant="default" className="border-white/10 bg-white/5 text-muted-text">{channels.length} </Badge>
</div>
<div className="flex items-center gap-2">
<Button type="button" variant="gradient" className="whitespace-nowrap" disabled={busy} onClick={addChannel}>
+
</Button>
<Select
value={addPreset}
onChange={setAddPreset}
options={Object.entries(CHANNEL_PRESETS).map(([value, preset]) => ({
value,
label: preset.label,
}))}
disabled={busy}
placeholder="选择服务商"
className="flex-1"
/>
</div>
</div>
<div className="space-y-1.5">
<div className="space-y-2">
<div className="flex items-center justify-between px-1">
<span className="text-xs font-medium uppercase tracking-wider text-muted-text"></span>
{channels.length > 0 ? (
@@ -905,8 +913,9 @@ export const LLMChannelEditor: React.FC<LLMChannelEditorProps> = ({
</div>
{channels.length === 0 ? (
<div className="rounded-lg border border-dashed border-white/10 bg-card/20 px-4 py-6 text-center text-xs text-muted-text">
<div className="rounded-[1.35rem] border border-dashed border-border/28 bg-background/12 px-4 py-10 text-center">
<p className="text-sm font-medium text-secondary-text"></p>
<p className="mt-1 text-xs text-muted-text"></p>
</div>
) : channels.map((channel, index) => (
<ChannelRow
@@ -927,14 +936,14 @@ export const LLMChannelEditor: React.FC<LLMChannelEditorProps> = ({
</div>
{managesRuntimeConfig ? (
<div className="rounded-lg border border-white/8 bg-card/30 p-3">
<div className="mb-3 flex items-center justify-between">
<div className="rounded-[1.35rem] border border-white/10 bg-white/2 p-4">
<div className="mb-4 flex items-center justify-between">
<div>
<span className="text-xs font-medium uppercase tracking-wider text-muted-text"></span>
<p className="mt-0.5 text-[11px] text-secondary-text"></p>
<span className="text-xs font-medium uppercase tracking-wider text-cyan"></span>
<p className="mt-1 text-[11px] text-muted-text">FallbackVision Temperature </p>
</div>
<Badge variant="default" className="border-white/10 bg-white/5 text-muted-text">Runtime</Badge>
</div>
<div className="mb-4">
<label className="mb-1 block text-xs text-muted-text">Temperature</label>
<div className="flex items-center gap-3">
@@ -946,7 +955,7 @@ export const LLMChannelEditor: React.FC<LLMChannelEditorProps> = ({
value={runtimeConfig.temperature}
disabled={busy}
onChange={(event) => setRuntimeConfig((previous) => ({ ...previous, temperature: event.target.value }))}
className="h-1.5 flex-1 cursor-pointer appearance-none rounded-full bg-white/10 accent-cyan"
className="h-1.5 flex-1 cursor-pointer rounded-full bg-border/60 accent-cyan"
/>
<span className="w-8 text-right text-sm text-secondary-text">{runtimeConfig.temperature}</span>
</div>
@@ -956,7 +965,7 @@ export const LLMChannelEditor: React.FC<LLMChannelEditorProps> = ({
</div>
{availableModels.length === 0 ? (
<div className="rounded-lg border border-dashed border-white/10 bg-card/20 px-3 py-2 text-xs text-muted-text">
<div className="rounded-xl border border-dashed border-border/30 bg-background/10 px-3 py-2 text-xs text-muted-text">
/ fallback / Vision
</div>
) : (
@@ -974,7 +983,7 @@ export const LLMChannelEditor: React.FC<LLMChannelEditorProps> = ({
<div>
<label className="mb-2 block text-xs text-muted-text">Fallback </label>
<div className="space-y-2 rounded-lg border border-white/8 bg-card/20 p-3">
<div className="space-y-2 rounded-xl border border-border/30 bg-background/10 p-3">
{availableModels.map((model) => (
<label key={model} className="flex items-center gap-2 text-sm text-secondary-text">
<input
@@ -982,6 +991,7 @@ export const LLMChannelEditor: React.FC<LLMChannelEditorProps> = ({
checked={runtimeConfig.fallbackModels.includes(model)}
disabled={busy || model === runtimeConfig.primaryModel}
onChange={() => toggleFallbackModel(model)}
className="h-4 w-4 rounded border-border/70 bg-base text-cyan focus:ring-cyan/20"
/>
<span>{model}</span>
</label>
@@ -1006,32 +1016,33 @@ export const LLMChannelEditor: React.FC<LLMChannelEditorProps> = ({
)}
</div>
) : (
<div className="rounded-lg border border-amber-500/20 bg-amber-500/10 px-3 py-2 text-xs text-amber-100">
<div className="rounded-[1.35rem] border border-warning/25 bg-warning/10 px-4 py-3 text-xs text-warning">
`LITELLM_CONFIG` / fallback / Vision / Temperature
YAML
</div>
)}
<div className="flex flex-wrap items-center gap-2">
<button
<div className="flex flex-wrap items-center gap-3">
<Button
type="button"
className="btn-primary"
variant="settings-primary"
glow
disabled={busy || !hasChanges}
onClick={() => void handleSave()}
>
{isSaving ? '保存中...' : managesRuntimeConfig ? '保存 AI 配置' : '保存渠道配置'}
</button>
</Button>
{!hasChanges ? <span className="text-xs text-muted-text"></span> : null}
</div>
{saveMessage?.type === 'success' ? (
<div className="rounded-lg border border-emerald-500/30 bg-emerald-500/10 px-3 py-2 text-sm text-emerald-200">
<div className="rounded-lg border border-success/30 bg-success/10 px-3 py-2 text-sm text-success">
{saveMessage.text}
</div>
) : null}
{saveMessage?.type === 'local-error' ? (
<div className="rounded-lg border border-rose-500/30 bg-rose-500/10 px-3 py-2 text-sm text-rose-200">
<div className="rounded-lg border border-danger/30 bg-danger/10 px-3 py-2 text-sm text-danger">
{saveMessage.text}
</div>
) : null}

View File

@@ -0,0 +1,69 @@
import type React from 'react';
import { Badge } from '../common';
import { getCategoryDescriptionZh, getCategoryTitleZh } from '../../utils/systemConfigI18n';
import type { SystemConfigCategorySchema, SystemConfigItem } from '../../types/systemConfig';
import { cn } from '../../utils/cn';
interface SettingsCategoryNavProps {
categories: SystemConfigCategorySchema[];
itemsByCategory: Record<string, SystemConfigItem[]>;
activeCategory: string;
onSelect: (category: string) => void;
}
export const SettingsCategoryNav: React.FC<SettingsCategoryNavProps> = ({
categories,
itemsByCategory,
activeCategory,
onSelect,
}) => {
return (
<div className="h-full rounded-[1.5rem] border border-white/10 bg-card p-4 shadow-soft-card-strong">
<div className="mb-4">
<p className="text-xs uppercase tracking-[0.3em] text-cyan font-semibold"></p>
<p className="mt-1 text-[11px] leading-relaxed text-muted-text"></p>
</div>
<div className="space-y-2.5">
{categories.map((category) => {
const isActive = category.category === activeCategory;
const count = (itemsByCategory[category.category] || []).length;
const title = getCategoryTitleZh(category.category, category.title);
const description = getCategoryDescriptionZh(category.category, category.description);
return (
<button
key={category.category}
type="button"
className={cn(
'w-full rounded-[1.1rem] border px-3 py-3 text-left transition-all duration-200',
isActive
? 'border-cyan bg-elevated shadow-[0_0_15px_rgba(0,212,255,0.1)]'
: 'border-white/10 bg-white/2 hover:border-white/20 hover:bg-white/5',
)}
onClick={() => onSelect(category.category)}
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className={cn('text-sm font-semibold tracking-tight', isActive ? 'text-white' : 'text-secondary-text')}>
{title}
</p>
{description ? (
<p className={cn('mt-1 line-clamp-2 text-xs leading-5', isActive ? 'text-secondary-text' : 'text-muted-text')}>{description}</p>
) : null}
</div>
<Badge
variant={isActive ? 'info' : 'default'}
size="sm"
className={isActive ? 'bg-cyan/10 text-cyan border-cyan/20' : 'border-white/10 bg-white/5 text-muted-text'}
>
{count}
</Badge>
</div>
</button>
);
})}
</div>
</div>
);
};

View File

@@ -1,8 +1,9 @@
import { useState } from 'react';
import type React from 'react';
import { EyeToggleIcon, Select } from '../common';
import { Badge, Select, Input } from '../common';
import type { ConfigValidationIssue, SystemConfigFieldSchema, SystemConfigItem } from '../../types/systemConfig';
import { getFieldDescriptionZh, getFieldTitleZh } from '../../utils/systemConfigI18n';
import { cn } from '../../utils/cn';
function normalizeSelectOptions(options: SystemConfigFieldSchema['options'] = []) {
return options.map((option) => {
@@ -32,6 +33,10 @@ function serializeMultiValues(values: string[]): string {
return values.map((entry) => entry.trim()).join(',');
}
function inferPasswordIconType(key: string): 'password' | 'key' {
return key.toUpperCase().includes('PASSWORD') ? 'password' : 'key';
}
interface SettingsFieldProps {
item: SystemConfigItem;
value: string;
@@ -45,19 +50,19 @@ function renderFieldControl(
value: string,
disabled: boolean,
onChange: (nextValue: string) => void,
isSecretVisible: boolean,
onToggleSecretVisible: () => void,
isPasswordEditable: boolean,
onPasswordFocus: () => void,
controlId: string,
) {
const schema = item.schema;
const commonClass = 'input-terminal';
const commonClass = 'input-terminal border-border/55 bg-card/94 hover:border-border/75';
const controlType = schema?.uiControl ?? 'text';
const isMultiValue = isMultiValueField(item);
if (controlType === 'textarea') {
return (
<textarea
id={controlId}
className={`${commonClass} min-h-[92px] resize-y`}
value={value}
disabled={disabled || !schema?.isEditable}
@@ -69,6 +74,7 @@ function renderFieldControl(
if (controlType === 'select' && schema?.options?.length) {
return (
<Select
id={controlId}
value={value}
onChange={onChange}
options={normalizeSelectOptions(schema.options)}
@@ -83,6 +89,7 @@ function renderFieldControl(
return (
<label className="inline-flex cursor-pointer items-center gap-3">
<input
id={controlId}
type="checkbox"
checked={checked}
disabled={disabled || !schema?.isEditable}
@@ -94,6 +101,8 @@ function renderFieldControl(
}
if (controlType === 'password') {
const iconType = inferPasswordIconType(item.key);
if (isMultiValue) {
const values = parseMultiValues(value);
@@ -101,32 +110,26 @@ function renderFieldControl(
<div className="space-y-2">
{values.map((entry, index) => (
<div className="flex items-center gap-2" key={`${item.key}-${index}`}>
<input
type={isSecretVisible ? 'text' : 'password'}
readOnly={!isPasswordEditable}
onFocus={onPasswordFocus}
className={`${commonClass} flex-1`}
value={entry}
disabled={disabled || !schema?.isEditable}
onChange={(event) => {
const nextValues = [...values];
nextValues[index] = event.target.value;
onChange(serializeMultiValues(nextValues));
}}
/>
<div className="flex-1">
<Input
type="password"
allowTogglePassword
iconType={iconType}
id={index === 0 ? controlId : `${controlId}-${index}`}
readOnly={!isPasswordEditable}
onFocus={onPasswordFocus}
value={entry}
disabled={disabled || !schema?.isEditable}
onChange={(event) => {
const nextValues = [...values];
nextValues[index] = event.target.value;
onChange(serializeMultiValues(nextValues));
}}
/>
</div>
<button
type="button"
className="btn-secondary !p-2"
disabled={disabled || !schema?.isEditable}
onClick={onToggleSecretVisible}
title={isSecretVisible ? '隐藏' : '显示'}
aria-label={isSecretVisible ? '隐藏密码' : '显示密码'}
>
<EyeToggleIcon visible={isSecretVisible} />
</button>
<button
type="button"
className="btn-secondary !px-3 !py-2 text-xs"
className="inline-flex h-11 items-center justify-center rounded-xl border border-white/10 bg-white/5 px-3 text-xs text-muted-text transition-colors hover:bg-white/10 hover:text-rose-400 disabled:cursor-not-allowed disabled:opacity-50"
disabled={disabled || !schema?.isEditable || values.length <= 1}
onClick={() => {
const nextValues = values.filter((_, rowIndex) => rowIndex !== index);
@@ -141,7 +144,7 @@ function renderFieldControl(
<div className="flex items-center gap-2">
<button
type="button"
className="btn-secondary !px-3 !py-2 text-xs"
className="inline-flex items-center justify-center rounded-lg border border-white/10 bg-white/5 px-3 py-1 text-xs text-secondary-text transition-colors hover:bg-white/10 hover:text-white"
disabled={disabled || !schema?.isEditable}
onClick={() => onChange(serializeMultiValues([...values, '']))}
>
@@ -153,27 +156,17 @@ function renderFieldControl(
}
return (
<div className="flex items-center gap-2">
<input
type={isSecretVisible ? 'text' : 'password'}
readOnly={!isPasswordEditable}
onFocus={onPasswordFocus}
className={`${commonClass} flex-1`}
value={value}
disabled={disabled || !schema?.isEditable}
onChange={(event) => onChange(event.target.value)}
/>
<button
type="button"
className="btn-secondary !p-2"
disabled={disabled || !schema?.isEditable}
onClick={onToggleSecretVisible}
title={isSecretVisible ? '隐藏' : '显示'}
aria-label={isSecretVisible ? '隐藏密码' : '显示密码'}
>
<EyeToggleIcon visible={isSecretVisible} />
</button>
</div>
<Input
type="password"
allowTogglePassword
iconType={iconType}
id={controlId}
readOnly={!isPasswordEditable}
onFocus={onPasswordFocus}
value={value}
disabled={disabled || !schema?.isEditable}
onChange={(event) => onChange(event.target.value)}
/>
);
}
@@ -181,6 +174,7 @@ function renderFieldControl(
return (
<input
id={controlId}
type={inputType}
className={commonClass}
value={value}
@@ -202,42 +196,53 @@ export const SettingsField: React.FC<SettingsFieldProps> = ({
const title = getFieldTitleZh(item.key, item.key);
const description = getFieldDescriptionZh(item.key);
const hasError = issues.some((issue) => issue.severity === 'error');
const [isSecretVisible, setIsSecretVisible] = useState(false);
const [isPasswordEditable, setIsPasswordEditable] = useState(false);
const controlId = `setting-${item.key}`;
return (
<div className={`rounded-xl border p-4 ${hasError ? 'border-red-500/35' : 'border-white/8'} bg-elevated/50`}>
<div className="mb-2 flex items-center gap-2">
<label className="text-sm font-semibold text-white" htmlFor={`setting-${item.key}`}>
<div
className={cn(
'rounded-[1.15rem] border bg-white/2 p-4 shadow-soft-card transition-all duration-200 hover:bg-white/5',
hasError ? 'border-danger/40' : 'border-white/10',
)}
>
<div className="mb-2 flex flex-wrap items-center gap-2">
<label className="text-sm font-semibold text-white" htmlFor={controlId}>
{title}
</label>
{schema?.isSensitive ? (
<span className="badge badge-purple text-[10px]"></span>
<Badge variant="history" size="sm">
</Badge>
) : null}
{!schema?.isEditable ? (
<Badge variant="default" size="sm">
</Badge>
) : null}
</div>
{description ? (
<p className="mb-3 text-xs text-muted-text" title={description}>
<p className="mb-3 text-xs leading-5 text-muted-text" title={description}>
{description}
</p>
) : null}
<div id={`setting-${item.key}`}>
<div>
{renderFieldControl(
item,
value,
disabled,
(nextValue) => onChange(item.key, nextValue),
isSecretVisible,
() => setIsSecretVisible((previous) => !previous),
isPasswordEditable,
() => setIsPasswordEditable(true),
controlId,
)}
</div>
{schema?.isSensitive ? (
<p className="mt-2 text-[11px] text-secondary-text">
<p className="mt-3 text-[11px] leading-5 text-secondary-text">
{isMultiValue ? ' 支持添加多个输入框进行增删。' : ''}
</p>
) : null}

View File

@@ -4,7 +4,7 @@ export const SettingsLoading: React.FC = () => {
return (
<div className="space-y-4 animate-fade-in">
{Array.from({ length: 6 }).map((_, index) => (
<div key={index} className="rounded-xl border border-white/8 bg-elevated/60 p-4">
<div key={index} className="rounded-xl border border-border/60 bg-elevated/45 p-4 shadow-soft-card">
<div className="h-3 w-32 rounded bg-white/10" />
<div className="mt-3 h-10 rounded-lg bg-white/6" />
</div>

View File

@@ -0,0 +1,31 @@
import type React from 'react';
import { cn } from '../../utils/cn';
interface SettingsSectionCardProps {
title: string;
description?: string;
actions?: React.ReactNode;
children: React.ReactNode;
className?: string;
}
export const SettingsSectionCard: React.FC<SettingsSectionCardProps> = ({
title,
description,
actions,
children,
className = '',
}) => {
return (
<div className={cn('rounded-[1.5rem] border border-white/10 bg-card p-5 shadow-soft-card-strong', className)}>
<div className="mb-5 flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0 space-y-1">
<h2 className="text-sm font-semibold tracking-tight text-white uppercase tracking-wider">{title}</h2>
{description ? <p className="text-xs leading-6 text-muted-text">{description}</p> : null}
</div>
{actions ? <div className="flex shrink-0 items-center gap-2">{actions}</div> : null}
</div>
<div className="space-y-5">{children}</div>
</div>
);
};

View File

@@ -0,0 +1,108 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { AuthSettingsCard } from '../AuthSettingsCard';
const { refreshStatus, updateSettings, useAuthMock } = vi.hoisted(() => ({
refreshStatus: vi.fn(),
updateSettings: vi.fn(),
useAuthMock: vi.fn(),
}));
vi.mock('../../../hooks', () => ({
useAuth: () => useAuthMock(),
}));
vi.mock('../../../api/auth', () => ({
authApi: {
updateSettings,
},
}));
describe('AuthSettingsCard', () => {
beforeEach(() => {
vi.clearAllMocks();
useAuthMock.mockReturnValue({
authEnabled: false,
setupState: 'no_password',
refreshStatus,
});
});
it('enables auth with a new password and refreshes status', async () => {
updateSettings.mockResolvedValue(undefined);
refreshStatus.mockResolvedValue(undefined);
render(<AuthSettingsCard />);
fireEvent.click(screen.getByRole('checkbox'));
fireEvent.change(screen.getByLabelText('设置管理员密码'), { target: { value: 'passwd6' } });
fireEvent.change(screen.getByLabelText('确认新密码'), { target: { value: 'passwd6' } });
fireEvent.click(screen.getByRole('button', { name: '开启认证' }));
await waitFor(() => {
expect(updateSettings).toHaveBeenCalledWith(true, 'passwd6', 'passwd6', undefined);
});
expect(refreshStatus).toHaveBeenCalled();
expect(await screen.findByText('认证设置已更新')).toBeInTheDocument();
});
it('allows disabling auth without current password when the session is still valid', async () => {
useAuthMock.mockReturnValue({
authEnabled: true,
setupState: 'enabled',
refreshStatus,
});
updateSettings.mockResolvedValue(undefined);
refreshStatus.mockResolvedValue(undefined);
render(<AuthSettingsCard />);
fireEvent.click(screen.getByRole('checkbox'));
fireEvent.click(screen.getByRole('button', { name: '关闭认证' }));
await waitFor(() => {
expect(updateSettings).toHaveBeenCalledWith(false, undefined, undefined, undefined);
});
expect(refreshStatus).toHaveBeenCalled();
expect(await screen.findByText('认证已关闭')).toBeInTheDocument();
});
it('shows only current password when re-enabling with a retained password', () => {
useAuthMock.mockReturnValue({
authEnabled: false,
setupState: 'password_retained',
refreshStatus,
});
render(<AuthSettingsCard />);
fireEvent.click(screen.getByRole('checkbox'));
expect(screen.getByLabelText('当前管理员密码')).toBeInTheDocument();
expect(screen.queryByLabelText('设置管理员密码')).not.toBeInTheDocument();
expect(screen.queryByLabelText('确认新密码')).not.toBeInTheDocument();
});
it('does not show new password fields while auth is already enabled', () => {
useAuthMock.mockReturnValue({
authEnabled: true,
setupState: 'enabled',
refreshStatus,
});
render(<AuthSettingsCard />);
expect(screen.queryByLabelText('设置管理员密码')).not.toBeInTheDocument();
expect(screen.queryByLabelText('确认新密码')).not.toBeInTheDocument();
});
it('blocks initial enable when the new password is missing', async () => {
render(<AuthSettingsCard />);
fireEvent.click(screen.getByRole('checkbox'));
fireEvent.click(screen.getByRole('button', { name: '开启认证' }));
expect(await screen.findByText('设置新密码是必填项')).toBeInTheDocument();
expect(updateSettings).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,70 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { IntelligentImport } from '../IntelligentImport';
import { SystemConfigConflictError } from '../../../api/systemConfig';
const { parseImport, update, onMerged } = vi.hoisted(() => ({
parseImport: vi.fn(),
update: vi.fn(),
onMerged: vi.fn(),
}));
vi.mock('../../../api/stocks', () => ({
stocksApi: {
parseImport,
extractFromImage: vi.fn(),
},
}));
vi.mock('../../../api/systemConfig', async () => {
const actual = await vi.importActual<typeof import('../../../api/systemConfig')>('../../../api/systemConfig');
return {
...actual,
systemConfigApi: {
...actual.systemConfigApi,
update,
},
};
});
describe('IntelligentImport', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('refreshes config state after a config version conflict', async () => {
parseImport.mockResolvedValue({
items: [{ code: 'SZ000001', name: 'Ping An Bank', confidence: 'high' }],
codes: [],
});
update.mockRejectedValue(
new SystemConfigConflictError('配置版本冲突', 'v2'),
);
render(
<IntelligentImport
stockListValue="SH600000"
configVersion="v1"
maskToken="******"
onMerged={onMerged}
/>,
);
fireEvent.change(screen.getByPlaceholderText('或粘贴 CSV/Excel 复制的文本...'), {
target: { value: '000001' },
});
fireEvent.click(screen.getByRole('button', { name: '解析' }));
await screen.findByText('SZ000001');
fireEvent.click(screen.getByRole('button', { name: '合并到自选股' }));
await waitFor(() => {
expect(update).toHaveBeenCalled();
});
await waitFor(() => {
expect(onMerged).toHaveBeenCalledWith('SH600000,SZ000001');
});
expect(await screen.findByText('配置已更新,请再次点击「合并到自选股」')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,38 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { LLMChannelEditor } from '../LLMChannelEditor';
vi.mock('../../../api/systemConfig', () => ({
systemConfigApi: {
update: vi.fn(),
testLLMChannel: vi.fn(),
},
}));
describe('LLMChannelEditor', () => {
it('renders API Key input with controlled visibility', async () => {
render(
<LLMChannelEditor
items={[
{ key: 'LLM_CHANNELS', value: 'openai' },
{ key: 'LLM_OPENAI_PROTOCOL', value: 'openai' },
{ key: 'LLM_OPENAI_BASE_URL', value: 'https://api.openai.com/v1' },
{ key: 'LLM_OPENAI_ENABLED', value: 'true' },
{ key: 'LLM_OPENAI_API_KEY', value: 'secret-key' },
{ key: 'LLM_OPENAI_MODELS', value: 'gpt-4o-mini' },
]}
configVersion="v1"
maskToken="******"
onSaved={() => {}}
/>
);
fireEvent.click(screen.getByRole('button', { name: /OpenAI 官方/i }));
const input = await screen.findByLabelText('API Key');
expect(input).toHaveAttribute('type', 'password');
fireEvent.click(screen.getByRole('button', { name: '显示内容' }));
expect(input).toHaveAttribute('type', 'text');
});
});

View File

@@ -0,0 +1,85 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { SettingsField } from '../SettingsField';
describe('SettingsField', () => {
it('renders sensitive field metadata and validation errors', () => {
const onChange = vi.fn();
render(
<SettingsField
item={{
key: 'OPENAI_API_KEY',
value: 'secret',
rawValueExists: true,
isMasked: false,
schema: {
key: 'OPENAI_API_KEY',
category: 'ai_model',
dataType: 'string',
uiControl: 'password',
isSensitive: true,
isRequired: true,
isEditable: true,
options: [],
validation: {},
displayOrder: 1,
},
}}
value="secret"
onChange={onChange}
issues={[
{
key: 'OPENAI_API_KEY',
code: 'required',
message: 'API Key 必填',
severity: 'error',
},
]}
/>
);
expect(screen.getByText('敏感')).toBeInTheDocument();
expect(screen.getByText('API Key 必填')).toBeInTheDocument();
const input = screen.getByLabelText('OpenAI API Key');
fireEvent.focus(input);
fireEvent.change(input, {
target: { value: 'updated-secret' },
});
expect(onChange).toHaveBeenCalledWith('OPENAI_API_KEY', 'updated-secret');
});
it('renders multi-value sensitive fields with external delete actions', () => {
const onChange = vi.fn();
render(
<SettingsField
item={{
key: 'OPENAI_API_KEYS',
value: 'secret-a,secret-b',
rawValueExists: true,
isMasked: false,
schema: {
key: 'OPENAI_API_KEYS',
category: 'ai_model',
dataType: 'string',
uiControl: 'password',
isSensitive: true,
isRequired: false,
isEditable: true,
options: [],
validation: { multiValue: true },
displayOrder: 1,
},
}}
value="secret-a,secret-b"
onChange={onChange}
/>
);
expect(screen.getAllByRole('button', { name: '显示内容' })).toHaveLength(2);
expect(screen.getAllByRole('button', { name: '删除' })).toHaveLength(2);
});
});

View File

@@ -4,3 +4,6 @@ export * from './ChangePasswordCard';
export * from './IntelligentImport';
export * from './SettingsField';
export * from './SettingsLoading';
export * from './SettingsSectionCard';
export * from './SettingsCategoryNav';
export * from './AuthSettingsCard';

View File

@@ -0,0 +1,19 @@
import type React from 'react';
import { ThemeProvider as NextThemesProvider } from 'next-themes';
type ThemeProviderProps = {
children: React.ReactNode;
};
export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children }) => {
return (
<NextThemesProvider
attribute="class"
defaultTheme="dark"
enableSystem
disableTransitionOnChange
>
{children}
</NextThemesProvider>
);
};

View File

@@ -0,0 +1,133 @@
import type React from 'react';
import { useEffect, useRef, useState } from 'react';
import { Check, Monitor, Moon, Sun } from 'lucide-react';
import { useTheme } from 'next-themes';
import { cn } from '../../utils/cn';
type ThemeOption = 'light' | 'dark' | 'system';
type ThemeToggleVariant = 'default' | 'nav';
const THEME_OPTIONS: Array<{
value: ThemeOption;
label: string;
icon: typeof Sun;
}> = [
{ value: 'light', label: '浅色', icon: Sun },
{ value: 'dark', label: '深色', icon: Moon },
{ value: 'system', label: '跟随系统', icon: Monitor },
];
function resolveThemeLabel(theme: string | undefined) {
switch (theme) {
case 'light':
return '浅色';
case 'dark':
return '深色';
default:
return '跟随系统';
}
}
interface ThemeToggleProps {
variant?: ThemeToggleVariant;
collapsed?: boolean;
}
export const ThemeToggle: React.FC<ThemeToggleProps> = ({
variant = 'default',
collapsed = false,
}) => {
const { theme, resolvedTheme, setTheme } = useTheme();
const [open, setOpen] = useState(false);
const containerRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (!open) {
return undefined;
}
const handlePointerDown = (event: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
setOpen(false);
}
};
document.addEventListener('mousedown', handlePointerDown);
return () => {
document.removeEventListener('mousedown', handlePointerDown);
};
}, [open]);
const activeTheme = (theme as ThemeOption | undefined) ?? 'system';
const visualTheme = resolvedTheme ?? 'dark';
const TriggerIcon = visualTheme === 'light' ? Sun : Moon;
const isNavVariant = variant === 'nav';
return (
<div className="relative" ref={containerRef}>
<button
type="button"
disabled
onClick={() => setOpen((value) => !value)}
data-state={open ? 'open' : 'closed'}
className={cn(
isNavVariant
? 'group relative flex h-12 w-full select-none items-center gap-3 rounded-[1.35rem] border border-transparent px-4 text-sm text-secondary-text transition-all duration-300 data-[state=open]:border-white/8 data-[state=open]:bg-white/[0.03] data-[state=open]:text-foreground opacity-50 cursor-not-allowed'
: 'inline-flex h-10 items-center gap-2 rounded-xl border border-border/70 bg-card/80 px-3 text-sm text-secondary-text shadow-soft-card transition-colors opacity-50 cursor-not-allowed',
isNavVariant && collapsed ? 'justify-center px-2' : ''
)}
aria-haspopup="menu"
aria-expanded={open}
aria-label="切换主题 (暂时禁用)"
>
<TriggerIcon className={cn('shrink-0', isNavVariant ? 'h-5 w-5' : 'h-4 w-4')} />
{isNavVariant ? (
collapsed ? null : <span className="truncate text-[1.02rem] font-medium"></span>
) : (
<span className="hidden sm:inline">{resolveThemeLabel(activeTheme)}</span>
)}
</button>
{open ? (
<div
role="menu"
aria-label="主题模式"
className={cn(
'z-[100] min-w-[8rem] overflow-hidden rounded-2xl border border-border/70 bg-elevated p-1.5 shadow-[0_24px_48px_rgba(3,8,20,0.32)] backdrop-blur-xl',
isNavVariant
? 'absolute bottom-full left-0 mb-2 w-max min-w-[9rem]'
: 'absolute right-0 mt-2'
)}
>
{THEME_OPTIONS.map(({ value, label, icon: Icon }) => {
const isActive = activeTheme === value;
return (
<button
key={value}
type="button"
role="menuitemradio"
aria-checked={isActive}
onClick={() => {
setTheme(value);
setOpen(false);
}}
className={cn(
'flex w-full items-center justify-between rounded-xl px-3 py-2 text-sm transition-colors',
isActive
? 'bg-cyan/10 text-foreground'
: 'text-secondary-text hover:bg-hover hover:text-foreground'
)}
>
<span className="flex items-center gap-2">
<Icon className="h-4 w-4" />
{label}
</span>
{isActive ? <Check className="h-4 w-4 text-cyan" /> : null}
</button>
);
})}
</div>
) : null}
</div>
);
};

View File

@@ -0,0 +1,37 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { beforeAll, describe, expect, it, vi } from 'vitest';
import { ThemeProvider } from '../ThemeProvider';
import { ThemeToggle } from '../ThemeToggle';
beforeAll(() => {
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: vi.fn().mockImplementation((query: string) => ({
matches: query === '(prefers-color-scheme: dark)',
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});
});
describe('ThemeToggle', () => {
it.skip('opens the theme menu and shows all theme modes', async () => {
render(
<ThemeProvider>
<ThemeToggle />
</ThemeProvider>
);
fireEvent.click(screen.getByRole('button', { name: '切换主题' }));
expect(await screen.findByRole('menu', { name: '主题模式' })).toBeInTheDocument();
expect(screen.getByRole('menuitemradio', { name: '浅色' })).toBeInTheDocument();
expect(screen.getByRole('menuitemradio', { name: '深色' })).toBeInTheDocument();
expect(screen.getByRole('menuitemradio', { name: '跟随系统' })).toBeInTheDocument();
});
});

View File

@@ -8,6 +8,7 @@ type AuthContextValue = {
loggedIn: boolean;
passwordSet: boolean;
passwordChangeable: boolean;
setupState: 'enabled' | 'password_retained' | 'no_password';
isLoading: boolean;
loadError: ParsedApiError | null;
login: (password: string, passwordConfirm?: string) => Promise<{ success: boolean; error?: ParsedApiError }>;
@@ -41,6 +42,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
const [loggedIn, setLoggedIn] = useState(false);
const [passwordSet, setPasswordSet] = useState(false);
const [passwordChangeable, setPasswordChangeable] = useState(false);
const [setupState, setSetupState] = useState<'enabled' | 'password_retained' | 'no_password'>('no_password');
const [isLoading, setIsLoading] = useState(true);
const [loadError, setLoadError] = useState<ParsedApiError | null>(null);
@@ -53,12 +55,14 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
setLoggedIn(status.loggedIn);
setPasswordSet(status.passwordSet ?? false);
setPasswordChangeable(status.passwordChangeable ?? false);
setSetupState(status.setupState);
} catch (err) {
setLoadError(getParsedApiError(err));
setAuthEnabled(false);
setLoggedIn(false);
setPasswordSet(false);
setPasswordChangeable(false);
setSetupState('no_password');
} finally {
setIsLoading(false);
}
@@ -75,13 +79,13 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
): Promise<{ success: boolean; error?: ParsedApiError }> => {
try {
await authApi.login(password, passwordConfirm);
setLoggedIn(true);
await fetchStatus();
return { success: true };
} catch (err: unknown) {
return { success: false, error: extractLoginError(err) };
}
},
[]
[fetchStatus]
);
const changePassword = useCallback(
@@ -104,9 +108,9 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
try {
await authApi.logout();
} finally {
setLoggedIn(false);
await fetchStatus();
}
}, []);
}, [fetchStatus]);
return (
<AuthContext.Provider
@@ -115,6 +119,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
loggedIn,
passwordSet,
passwordChangeable,
setupState,
isLoading,
loadError,
login,

View File

@@ -0,0 +1,99 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { AuthProvider, useAuth } from '../AuthContext';
const { getStatus, login, changePassword, logout } = vi.hoisted(() => ({
getStatus: vi.fn(),
login: vi.fn(),
changePassword: vi.fn(),
logout: vi.fn(),
}));
vi.mock('../../api/auth', () => ({
authApi: {
getStatus,
login,
changePassword,
logout,
},
}));
const Probe = () => {
const auth = useAuth();
return (
<div>
<span data-testid="status">{auth.loggedIn ? 'logged-in' : 'logged-out'}</span>
<span data-testid="password-set">{auth.passwordSet ? 'set' : 'unset'}</span>
<button type="button" onClick={() => void auth.login('passwd6', 'passwd6')}>
trigger-login
</button>
<button type="button" onClick={() => void auth.logout()}>
trigger-logout
</button>
</div>
);
};
describe('AuthContext', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('refreshes auth state after a successful login', async () => {
getStatus
.mockResolvedValueOnce({
authEnabled: true,
loggedIn: false,
passwordSet: false,
passwordChangeable: true,
})
.mockResolvedValueOnce({
authEnabled: true,
loggedIn: true,
passwordSet: true,
passwordChangeable: true,
});
login.mockResolvedValue(undefined);
render(
<AuthProvider>
<Probe />
</AuthProvider>
);
await screen.findByTestId('status');
fireEvent.click(screen.getByRole('button', { name: 'trigger-login' }));
await waitFor(() => expect(screen.getByTestId('status')).toHaveTextContent('logged-in'));
expect(screen.getByTestId('password-set')).toHaveTextContent('set');
});
it('refreshes auth state after logout', async () => {
getStatus
.mockResolvedValueOnce({
authEnabled: true,
loggedIn: true,
passwordSet: true,
passwordChangeable: true,
})
.mockResolvedValueOnce({
authEnabled: false,
loggedIn: false,
passwordSet: false,
passwordChangeable: false,
});
logout.mockResolvedValue(undefined);
render(
<AuthProvider>
<Probe />
</AuthProvider>
);
await screen.findByTestId('status');
fireEvent.click(screen.getByRole('button', { name: 'trigger-logout' }));
await waitFor(() => expect(screen.getByTestId('status')).toHaveTextContent('logged-out'));
});
});

View File

@@ -0,0 +1,77 @@
import { act, renderHook, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { useSystemConfig } from '../useSystemConfig';
const { getConfig, validate, update } = vi.hoisted(() => ({
getConfig: vi.fn(),
validate: vi.fn(),
update: vi.fn(),
}));
vi.mock('../../api/systemConfig', () => ({
systemConfigApi: {
getConfig,
validate,
update,
},
SystemConfigConflictError: class extends Error {},
SystemConfigValidationError: class extends Error {
issues: unknown[] = [];
parsedError = {
title: 'validation error',
message: 'validation error',
rawMessage: 'validation error',
category: 'http_error',
};
},
}));
const sampleConfig = {
configVersion: 'v1',
maskToken: '******',
items: [
{
key: 'STOCK_LIST',
value: 'SH600000',
rawValueExists: true,
isMasked: false,
schema: {
key: 'STOCK_LIST',
category: 'base',
dataType: 'string',
uiControl: 'textarea',
isSensitive: false,
isRequired: false,
isEditable: true,
options: [],
validation: {},
displayOrder: 1,
},
},
],
};
describe('useSystemConfig', () => {
beforeEach(() => {
vi.clearAllMocks();
getConfig.mockResolvedValue(sampleConfig);
validate.mockResolvedValue({ valid: true, issues: [] });
update.mockResolvedValue({ warnings: [] });
});
it('keeps load callback stable after a successful load', async () => {
const { result } = renderHook(() => useSystemConfig());
const firstLoad = result.current.load;
await act(async () => {
await result.current.load();
});
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(getConfig).toHaveBeenCalledTimes(1);
expect(result.current.load).toBe(firstLoad);
});
});

View File

@@ -1,4 +1,4 @@
import { useCallback, useMemo, useState } from 'react';
import { useCallback, useMemo, useRef, useState } from 'react';
import { createParsedApiError, getParsedApiError, type ParsedApiError } from '../api/error';
import { systemConfigApi, SystemConfigConflictError, SystemConfigValidationError } from '../api/systemConfig';
import type {
@@ -81,6 +81,7 @@ export function useSystemConfig() {
const [loadError, setLoadError] = useState<ParsedApiError | null>(null);
const [saveError, setSaveError] = useState<ParsedApiError | null>(null);
const [retryAction, setRetryAction] = useState<RetryAction>(null);
const serverItemByKeyRef = useRef<Record<string, SystemConfigItem>>({});
const mergedItems = useMemo(() => {
return sortItemsByOrder(
@@ -96,6 +97,7 @@ export function useSystemConfig() {
for (const item of serverItems) {
map[item.key] = item;
}
serverItemByKeyRef.current = map;
return map;
}, [serverItems]);
@@ -166,17 +168,41 @@ export function useSystemConfig() {
}, [validationIssues]);
const applyServerPayload = useCallback(
(items: SystemConfigItem[], version: string, token: string) => {
(
items: SystemConfigItem[],
version: string,
token: string,
options?: { preserveDirty?: boolean; committedKeys?: string[] },
) => {
const sorted = sortItemsByOrder(items);
const previousServerMap = serverItemByKeyRef.current;
const committedKeys = new Set(options?.committedKeys ?? []);
const preserveDirty = options?.preserveDirty ?? false;
setServerItems(sorted);
setConfigVersion(version);
setMaskToken(token || '******');
const draft: Record<string, string> = {};
for (const item of sorted) {
draft[item.key] = item.value;
}
setDraftValues(draft);
setDraftValues((prevDraft) => {
const nextDraft: Record<string, string> = {};
for (const item of sorted) {
if (committedKeys.has(item.key)) {
nextDraft[item.key] = item.value;
continue;
}
if (preserveDirty) {
const previousServerValue = previousServerMap[item.key]?.value;
const hasDraft = prevDraft[item.key] !== undefined;
const wasDirty = hasDraft && prevDraft[item.key] !== previousServerValue;
nextDraft[item.key] = wasDirty ? prevDraft[item.key] : item.value;
continue;
}
nextDraft[item.key] = item.value;
}
return nextDraft;
});
const defaultCategory = sorted[0]?.schema?.category || 'base';
setActiveCategory((current) => {
@@ -215,6 +241,27 @@ export function useSystemConfig() {
setSaveError(null);
}, [serverItems]);
const applyPartialUpdate = useCallback((updatedItems: Array<{ key: string; value: string }>) => {
setDraftValues((prevDraft) => {
const nextDraft = { ...prevDraft };
for (const item of updatedItems) {
nextDraft[item.key] = item.value;
}
return nextDraft;
});
}, []);
const refreshAfterExternalSave = useCallback(
async (committedKeys: string[]) => {
const config = await systemConfigApi.getConfig(true);
applyServerPayload(config.items, config.configVersion, config.maskToken, {
preserveDirty: true,
committedKeys,
});
},
[applyServerPayload],
);
const setDraftValue = useCallback((key: string, value: string) => {
setDraftValues((previous) => ({
...previous,
@@ -359,5 +406,7 @@ export function useSystemConfig() {
save,
resetDraft,
setDraftValue,
applyPartialUpdate,
refreshAfterExternalSave,
};
}

View File

@@ -4,38 +4,6 @@
1. THEME VARIABLES (Base/Classic)
========================================= */
:root {
/* Primary brand tones */
--color-cyan: #00d4ff;
--color-cyan-dim: #00a8cc;
--color-cyan-glow: rgba(0, 212, 255, 0.4);
/* Secondary accent tones */
--color-purple: #6f61f1;
--color-purple-dim: #5a4ed4;
--color-purple-glow: rgba(168, 85, 247, 0.3);
/* Status colors */
--color-success: #00ff88;
--color-warning: #ffaa00;
--color-danger: #ff4466;
/* Dark surface colors */
--bg-base: #08080c;
--bg-card: #0d0d14;
--bg-elevated: #12121a;
--bg-hover: #1a1a24;
/* Border colors */
--border-dim: rgba(255, 255, 255, 0.06);
--border-default: rgba(255, 255, 255, 0.1);
--border-accent: rgba(0, 212, 255, 0.3);
--border-purple: rgba(168, 85, 247, 0.28);
--border-cyan: #00d4ff;
/* Text colors */
--text-primary: #ffffff;
--text-secondary-text: #a0a0b0;
--text-muted-text: #606070;
/* Typography */
font-family:
"Inter",
@@ -53,6 +21,87 @@
/* HSL tokens used by Tailwind-based components */
--background: 216 33% 97%;
--foreground: 228 35% 12%;
--card: 0 0% 100%;
--card-foreground: 228 35% 12%;
--popover: 0 0% 100%;
--popover-foreground: 228 35% 12%;
--primary: 193 100% 43%;
--primary-foreground: 216 33% 97%;
--secondary: 214 32% 91%;
--secondary-foreground: 228 35% 16%;
--muted: 214 30% 94%;
--muted-foreground: 224 12% 42%;
--accent: 214 32% 93%;
--accent-foreground: 228 35% 16%;
--destructive: 349 82% 56%;
--destructive-foreground: 210 33% 98%;
--border: 217 28% 84%;
--input: 217 28% 84%;
--ring: 193 100% 43%;
--radius: 1rem;
--elevated: 0 0% 100%;
--hover: 214 29% 90%;
--secondary-text: 224 14% 34%;
--muted-text: 224 10% 48%;
--color-cyan: hsl(var(--primary));
--color-cyan-dim: hsl(var(--primary) / 0.8);
--color-cyan-glow: rgba(0, 168, 204, 0.18);
--color-purple: #6f61f1;
--color-purple-dim: #5a4ed4;
--color-purple-glow: rgba(111, 97, 241, 0.18);
--color-success: #00b86b;
--color-warning: #b7791f;
--color-danger: #e11d48;
--bg-base: hsl(var(--background));
--bg-card: hsl(var(--card));
--bg-elevated: hsl(var(--elevated));
--bg-hover: hsl(var(--hover));
--border-dim: hsl(var(--border) / 0.55);
--border-default: hsl(var(--border) / 0.82);
--border-accent: hsl(var(--primary) / 0.38);
--border-purple: rgba(111, 97, 241, 0.24);
--border-cyan: hsl(var(--primary));
--text-primary: hsl(var(--foreground));
--text-secondary-text: hsl(var(--secondary-text));
--text-muted-text: hsl(var(--muted-text));
--nav-active-bg: rgba(0, 168, 204, 0.09);
--nav-active-border: rgba(0, 168, 204, 0.24);
--nav-active-shadow: rgba(0, 168, 204, 0.04);
--nav-hover-bg: rgba(0, 168, 204, 0.05);
--nav-icon-active: #00a8cc;
--nav-item-height: 2.75rem;
--nav-item-padding-x: 1rem;
--nav-indicator-width: 0.25rem;
--nav-indicator-bg: #00a8cc;
--nav-indicator-shadow: rgba(0, 168, 204, 0.52);
--nav-badge-bg: #00a8cc;
--nav-brand-shadow: rgba(0, 168, 204, 0.2);
--home-loading-ring-track: rgba(0, 168, 204, 0.2);
--home-loading-ring-head: #00a8cc;
--home-mobile-overlay-bg: rgba(0, 0, 0, 0.6);
--home-action-ai-bg: rgba(0, 168, 204, 0.1);
--home-action-ai-border: rgba(0, 168, 204, 0.2);
--home-action-ai-text: #00a8cc;
--home-action-ai-hover-bg: rgba(0, 168, 204, 0.18);
--home-action-report-bg: rgba(111, 97, 241, 0.1);
--home-action-report-border: rgba(111, 97, 241, 0.2);
--home-action-report-text: #6f61f1;
--home-action-report-hover-bg: rgba(111, 97, 241, 0.18);
--shadow-soft-card: 0 18px 48px rgba(15, 23, 42, 0.08);
--shadow-soft-card-strong: 0 24px 56px rgba(15, 23, 42, 0.12);
--input-surface-bg: var(--bg-card);
--input-surface-border: hsl(var(--border) / 0.82);
--input-surface-border-hover: rgba(255, 255, 255, 0.18);
--input-surface-border-focus: hsl(var(--primary) / 0.38);
--input-surface-focus-ring: 0 0 0 4px hsl(var(--primary) / 0.15);
--gradient-primary:
linear-gradient(135deg, hsl(var(--primary)), hsl(var(--primary) / 0.74));
}
.dark {
--background: 228 35% 7%;
--foreground: 210 33% 98%;
--card: 230 24% 10%;
@@ -72,11 +121,62 @@
--border: 226 19% 20%;
--input: 226 19% 20%;
--ring: 190 100% 50%;
--radius: 1rem;
--elevated: 230 22% 12%;
--hover: 231 20% 16%;
--secondary-text: 228 16% 72%;
--muted-text: 228 10% 48%;
--color-cyan: #00d4ff;
--color-cyan-dim: #00a8cc;
--color-cyan-glow: rgba(0, 212, 255, 0.4);
--color-purple-glow: rgba(111, 97, 241, 0.3);
--color-success: #00ff88;
--color-warning: #ffaa00;
--color-danger: #ff4466;
--bg-base: #08080c;
--bg-card: #0d0d14;
--bg-elevated: #12121a;
--bg-hover: #1a1a24;
--border-dim: rgba(255, 255, 255, 0.06);
--border-default: rgba(255, 255, 255, 0.1);
--border-accent: rgba(0, 212, 255, 0.3);
--border-purple: rgba(168, 85, 247, 0.28);
--border-cyan: #00d4ff;
--text-primary: #ffffff;
--text-secondary-text: #a0a0b0;
--text-muted-text: #606070;
--nav-active-bg: rgba(0, 212, 255, 0.11);
--nav-active-border: rgba(0, 212, 255, 0.3);
--nav-active-shadow: rgba(0, 212, 255, 0.05);
--nav-hover-bg: rgba(0, 212, 255, 0.06);
--nav-icon-active: #00d4ff;
--nav-item-height: 2.75rem;
--nav-item-padding-x: 1rem;
--nav-indicator-width: 0.25rem;
--nav-indicator-bg: #00d4ff;
--nav-indicator-shadow: rgba(0, 212, 255, 0.8);
--nav-badge-bg: #00d4ff;
--nav-brand-shadow: rgba(0, 212, 255, 0.24);
--home-loading-ring-track: rgba(0, 212, 255, 0.2);
--home-loading-ring-head: #00d4ff;
--home-mobile-overlay-bg: rgba(0, 0, 0, 0.6);
--home-action-ai-bg: rgba(0, 212, 255, 0.1);
--home-action-ai-border: rgba(0, 212, 255, 0.2);
--home-action-ai-text: #00d4ff;
--home-action-ai-hover-bg: rgba(0, 212, 255, 0.2);
--home-action-report-bg: rgba(111, 97, 241, 0.1);
--home-action-report-border: rgba(111, 97, 241, 0.2);
--home-action-report-text: #8a7dff;
--home-action-report-hover-bg: rgba(111, 97, 241, 0.2);
--shadow-soft-card: 0 18px 48px rgba(3, 8, 20, 0.18);
--shadow-soft-card-strong: 0 24px 56px rgba(3, 8, 20, 0.26);
--input-surface-bg: var(--bg-card);
--input-surface-border: hsl(var(--border) / 0.82);
--input-surface-border-hover: rgba(255, 255, 255, 0.18);
--input-surface-border-focus: hsl(var(--primary) / 0.45);
--input-surface-focus-ring: 0 0 0 4px hsl(var(--primary) / 0.18);
--gradient-primary:
linear-gradient(135deg, rgba(0, 212, 255, 0.96), rgba(0, 168, 204, 0.96));
}
/* =========================================
@@ -90,125 +190,8 @@ body {
margin: 0;
min-width: 320px;
min-height: 100vh;
background: var(--bg-base);
color: var(--text-primary);
}
/* ============ Floating Dock Navigation ============ */
.dock-nav {
position: fixed;
left: 24px;
top: 50%;
transform: translateY(-50%);
z-index: 60;
pointer-events: none;
}
.dock-surface {
pointer-events: auto;
width: 72px;
padding: 14px 10px;
border-radius: 26px;
/* Use a darker translucent surface to reduce contrast jumps. */
background: rgba(18, 18, 26, 0.6);
/* Keep the border subtle. */
border: 1px solid rgba(255, 255, 255, 0.06);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
/* Soften the shadow stack. */
box-shadow:
0 12px 40px rgba(0, 0, 0, 0.4),
inset 0 1px 0 rgba(255, 255, 255, 0.1);
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
position: relative;
}
.dock-surface::before {
content: "";
position: absolute;
inset: 0;
border-radius: inherit;
padding: 1px;
/* Use a more restrained border gradient. */
background: linear-gradient(
180deg,
rgba(255, 255, 255, 0.1) 0%,
rgba(255, 255, 255, 0.05) 100%
);
-webkit-mask:
linear-gradient(#fff 0 0) content-box,
linear-gradient(#fff 0 0);
mask:
linear-gradient(#fff 0 0) content-box,
linear-gradient(#fff 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
pointer-events: none;
opacity: 0.5;
}
/* Keep the top highlight, but tone it down. */
.dock-surface::after {
content: "";
position: absolute;
top: 10px;
left: 12px;
right: 12px;
height: 1px;
background: linear-gradient(
90deg,
transparent,
rgba(255, 255, 255, 0.15),
transparent
);
pointer-events: none;
opacity: 0.4;
}
.dock-logo {
width: 48px;
height: 48px;
border-radius: 16px;
display: flex;
align-items: center;
justify-content: center;
/* Fine-tuned logo gradient. */
background: linear-gradient(
135deg,
rgba(51, 110, 168, 0.45) 0%,
#367db5 100%
);
color: #04141d;
text-decoration: none;
box-shadow: 0 8px 16px rgba(0, 212, 255, 0.25);
transition:
transform 0.2s cubic-bezier(0.34, 1.56, 0.64, 1),
box-shadow 0.2s ease;
}
.dock-logo:hover {
transform: scale(1.05);
box-shadow: 0 10px 20px rgba(0, 212, 255, 0.35);
}
.dock-items {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
flex: 1;
width: 100%;
}
.dock-footer {
margin-top: auto;
}
.dock-safe-area {
padding-left: 120px;
background: hsl(var(--background));
color: hsl(var(--foreground));
}
/* ============ Title Styles ============ */
@@ -268,6 +251,37 @@ body {
border-color: rgba(255, 255, 255, 0.15);
}
.input-surface {
background: var(--input-surface-bg);
border-color: var(--input-surface-border);
color: var(--text-primary);
box-shadow: var(--shadow-soft-card);
transition:
border-color 0.2s ease,
box-shadow 0.2s ease,
background-color 0.2s ease,
color 0.2s ease;
}
.input-surface::placeholder {
color: var(--text-muted-text);
}
.input-surface:hover:not(:focus):not(:disabled) {
border-color: var(--input-surface-border-hover);
}
.input-surface:disabled {
cursor: not-allowed;
opacity: 0.65;
}
.input-focus-glow:focus,
.input-focus-glow:focus-within {
border-color: var(--input-surface-border-focus);
box-shadow: var(--shadow-soft-card), var(--input-surface-focus-ring);
}
/* ============ Badge Styles ============ */
.badge {
display: inline-flex;
@@ -514,34 +528,6 @@ body {
}
/* ============ Responsive ============ */
@media (max-width: 768px) {
.dock-nav {
left: 12px;
}
.dock-surface {
width: 60px;
padding: 10px 8px;
border-radius: 22px;
}
.dock-logo {
width: 42px;
height: 42px;
border-radius: 12px;
}
.dock-item {
width: 42px;
height: 42px;
border-radius: 12px;
}
.dock-safe-area {
padding-left: 88px;
}
}
/* =========================================
4. NEW UI COMPONENT UTILITIES (from Terminal PR)
========================================= */
@@ -678,115 +664,6 @@ textarea {
);
pointer-events: none;
}
.dock-item {
position: relative;
display: inline-flex;
height: 46px;
width: 46px;
align-items: center;
justify-content: center;
border-radius: 14px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.04);
color: var(--text-muted-text);
cursor: pointer;
overflow: hidden;
text-decoration: none;
transition: all 0.22s ease;
}
.dock-item::before {
content: "";
position: absolute;
inset: 0;
border-radius: inherit;
background: linear-gradient(
135deg,
rgba(0, 212, 255, 0.12),
rgba(111, 97, 241, 0.08)
);
opacity: 0;
transition: opacity 0.22s ease;
}
.dock-item::after {
content: "";
position: absolute;
bottom: 0;
left: 50%;
height: 40%;
width: 60%;
transform: translateX(-50%) translateY(100%);
background: radial-gradient(circle, rgba(0, 212, 255, 0.4), transparent 70%);
filter: blur(8px);
opacity: 0;
transition: all 0.22s ease;
}
.dock-item:hover {
transform: translateY(-1px);
border-color: rgba(0, 212, 255, 0.22);
background: rgba(0, 212, 255, 0.08);
color: var(--text-primary);
}
.dock-item:hover::before {
opacity: 1;
}
.dock-item:hover::after {
opacity: 0.6;
transform: translateX(-50%) translateY(40%);
}
.dock-item.is-active {
border-color: rgba(0, 212, 255, 0.24);
background: linear-gradient(
135deg,
rgba(0, 212, 255, 0.2),
rgba(111, 97, 241, 0.16)
);
color: #fff;
box-shadow:
0 0 0 1px rgba(0, 212, 255, 0.1),
0 14px 32px rgba(0, 212, 255, 0.14);
}
.dock-item.is-active:hover {
transform: scale(1.05);
}
.dock-item.is-active::after,
.dock-item.is-active::before {
opacity: 0 !important;
}
.dock-item.is-placeholder,
.dock-item[disabled] {
color: var(--text-muted-text);
cursor: not-allowed;
opacity: 0.55;
}
.dock-item.is-placeholder:hover,
.dock-item[disabled]:hover {
transform: none;
border-color: rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.04);
color: var(--text-muted-text);
}
.dock-item.is-placeholder:hover::before,
.dock-item.is-placeholder:hover::after,
.dock-item[disabled]:hover::before,
.dock-item[disabled]:hover::after {
opacity: 0;
}
.label-uppercase {
display: inline-flex;
align-items: center;
@@ -858,10 +735,53 @@ textarea {
background: var(--bg-hover);
}
@media (max-width: 768px) {
.dock-item {
width: 42px;
height: 42px;
border-radius: 12px;
.bg-primary-gradient {
background: var(--gradient-primary);
}
.text-secondary {
color: var(--text-secondary-text);
}
.text-muted {
color: var(--text-muted-text);
}
.shadow-soft-card {
box-shadow: var(--shadow-soft-card);
}
.shadow-soft-card-strong {
box-shadow: var(--shadow-soft-card-strong);
}
.shell-page-frame {
position: relative;
overflow: hidden;
}
.shell-page-frame::before {
content: "";
position: absolute;
inset: 0;
pointer-events: none;
background:
radial-gradient(circle at top right, hsl(var(--primary) / 0.12), transparent 28%),
radial-gradient(circle at bottom left, rgba(111, 97, 241, 0.1), transparent 24%);
opacity: 0.9;
}
@keyframes slideInLeft {
from {
opacity: 0;
transform: translateX(-100%);
}
to {
opacity: 1;
transform: translateX(0);
}
}
.animate-slide-in-left {
animation: slideInLeft 0.3s ease-out;
}

View File

@@ -2,9 +2,12 @@ import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
import { ThemeProvider } from './components/theme/ThemeProvider'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
<ThemeProvider>
<App />
</ThemeProvider>
</StrictMode>,
)

View File

@@ -53,7 +53,7 @@ function boolIcon(value?: boolean | null) {
// ============ Metric Row ============
const MetricRow: React.FC<{ label: string; value: string; accent?: boolean }> = ({ label, value, accent }) => (
<div className="flex items-center justify-between py-1.5 border-b border-white/5 last:border-0">
<div className="flex items-center justify-between border-b border-white/5 py-1.5 last:border-0">
<span className="text-xs text-secondary-text">{label}</span>
<span className={`text-sm font-mono font-semibold ${accent ? 'text-cyan' : 'text-white'}`}>{value}</span>
</div>
@@ -73,7 +73,7 @@ const PerformanceCard: React.FC<{ metrics: PerformanceMetrics; title: string }>
<MetricRow label="SL Trigger Rate" value={pct(metrics.stopLossTriggerRate)} />
<MetricRow label="TP Trigger Rate" value={pct(metrics.takeProfitTriggerRate)} />
<MetricRow label="Avg Days to Hit" value={metrics.avgDaysToFirstHit != null ? metrics.avgDaysToFirstHit.toFixed(1) : '--'} />
<div className="mt-3 pt-2 border-t border-white/5 flex items-center justify-between">
<div className="mt-3 pt-2 border-t border-border/40 flex items-center justify-between">
<span className="text-xs text-muted-text">Evaluations</span>
<span className="text-xs text-secondary-text font-mono">
{Number(metrics.completedCount)} / {Number(metrics.totalEvaluations)}
@@ -95,7 +95,7 @@ const PerformanceCard: React.FC<{ metrics: PerformanceMetrics; title: string }>
// ============ Run Summary ============
const RunSummary: React.FC<{ data: BacktestRunResponse }> = ({ data }) => (
<div className="flex items-center gap-4 px-3 py-2 rounded-lg bg-elevated border border-white/5 text-xs font-mono animate-fade-in">
<div className="flex items-center gap-4 rounded-lg border border-white/5 bg-elevated px-3 py-2 text-xs font-mono animate-fade-in">
<span className="text-secondary-text">Processed: <span className="text-white">{data.processed}</span></span>
<span className="text-secondary-text">Saved: <span className="text-cyan">{data.saved}</span></span>
<span className="text-secondary-text">Completed: <span className="text-emerald-400">{data.completed}</span></span>
@@ -109,6 +109,11 @@ const RunSummary: React.FC<{ data: BacktestRunResponse }> = ({ data }) => (
// ============ Main Page ============
const BacktestPage: React.FC = () => {
// Set page title
useEffect(() => {
document.title = '策略回测 - DSA';
}, []);
// Input state
const [codeFilter, setCodeFilter] = useState('');
const [evalDays, setEvalDays] = useState('');
@@ -233,11 +238,11 @@ const BacktestPage: React.FC = () => {
};
return (
<div className="min-h-screen flex flex-col">
<div className="min-h-full flex flex-col rounded-[1.5rem] bg-transparent">
{/* Header */}
<header className="flex-shrink-0 px-4 py-3 border-b border-white/5">
<div className="flex items-center gap-2 max-w-4xl">
<div className="flex-1 relative">
<header className="flex-shrink-0 border-b border-white/5 px-3 py-3 sm:px-4">
<div className="flex max-w-5xl flex-wrap items-center gap-2">
<div className="relative min-w-0 flex-[1_1_220px]">
<input
type="text"
value={codeFilter}
@@ -285,7 +290,7 @@ const BacktestPage: React.FC = () => {
>
<span className={`
inline-block w-1.5 h-1.5 rounded-full transition-colors duration-200
${forceRerun ? 'bg-cyan shadow-[0_0_4px_rgba(0,212,255,0.6)]' : 'bg-white/20'}
${forceRerun ? 'bg-cyan shadow-[0_0_4px_rgba(0,212,255,0.6)]' : 'bg-border'}
`} />
Force
</button>
@@ -319,9 +324,9 @@ const BacktestPage: React.FC = () => {
</header>
{/* Main content */}
<main className="flex-1 flex overflow-hidden p-3 gap-3">
<main className="flex min-h-0 flex-1 flex-col gap-3 overflow-hidden p-3 lg:flex-row">
{/* Left sidebar - Performance */}
<div className="flex flex-col gap-3 w-64 flex-shrink-0 overflow-y-auto">
<div className="flex max-h-[38vh] flex-col gap-3 overflow-y-auto lg:max-h-none lg:w-60 lg:flex-shrink-0">
{isLoadingPerf ? (
<div className="flex items-center justify-center py-8">
<div className="w-8 h-8 border-2 border-cyan/20 border-t-cyan rounded-full animate-spin" />
@@ -342,7 +347,7 @@ const BacktestPage: React.FC = () => {
</div>
{/* Right content - Results table */}
<section className="flex-1 overflow-y-auto">
<section className="min-h-0 flex-1 overflow-y-auto">
{pageError ? (
<ApiErrorAlert error={pageError} className="mb-3" />
) : null}
@@ -358,14 +363,14 @@ const BacktestPage: React.FC = () => {
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
</svg>
</div>
<h3 className="text-base font-medium text-white mb-1.5">No Results</h3>
<h3 className="text-base font-medium text-foreground mb-1.5">No Results</h3>
<p className="text-xs text-muted-text max-w-xs">
Run a backtest to evaluate historical analysis accuracy
</p>
</div>
) : (
<div className="animate-fade-in">
<div className="overflow-x-auto rounded-xl border border-white/5">
<div className="overflow-x-auto rounded-xl border border-white/6 bg-card/72">
<table className="w-full text-sm">
<thead>
<tr className="bg-elevated text-left">
@@ -384,11 +389,11 @@ const BacktestPage: React.FC = () => {
{results.map((row) => (
<tr
key={row.analysisHistoryId}
className="border-t border-white/5 hover:bg-hover transition-colors"
className="border-t border-white/5 transition-colors hover:bg-hover"
>
<td className="px-3 py-2 font-mono text-cyan text-xs">{row.code}</td>
<td className="px-3 py-2 text-xs text-secondary-text">{row.analysisDate || '--'}</td>
<td className="px-3 py-2 text-xs text-white truncate max-w-[140px]" title={row.operationAdvice || ''}>
<td className="px-3 py-2 text-xs text-foreground truncate max-w-[140px]" title={row.operationAdvice || ''}>
{row.operationAdvice || '--'}
</td>
<td className="px-3 py-2 text-xs">

View File

@@ -3,7 +3,7 @@ import { useSearchParams } from 'react-router-dom';
import Markdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { agentApi } from '../api/agent';
import { ApiErrorAlert, Button } from '../components/common';
import { ApiErrorAlert, Button, ConfirmDialog, ScrollArea } from '../components/common';
import { getParsedApiError } from '../api/error';
import type { StrategyInfo } from '../api/agent';
import { historyApi } from '../api/history';
@@ -13,6 +13,7 @@ import {
type ProgressStep,
} from '../stores/agentChatStore';
import { downloadSession, formatSessionAsMarkdown } from '../utils/chatExport';
import { isNearBottom } from '../utils/chatScroll';
interface FollowUpContext {
stock_code: string;
@@ -47,9 +48,17 @@ const ChatPage: React.FC = () => {
type: 'success' | 'error';
message: string;
} | null>(null);
const messagesViewportRef = useRef<HTMLDivElement>(null);
const messagesEndRef = useRef<HTMLDivElement>(null);
const initialFollowUpHandled = useRef(false);
const followUpContextRef = useRef<FollowUpContext | null>(null);
const shouldStickToBottomRef = useRef(true);
const pendingScrollBehaviorRef = useRef<ScrollBehavior>('auto');
// Set page title
useEffect(() => {
document.title = '策略问股 - DSA';
}, []);
const {
messages,
@@ -66,13 +75,51 @@ const ChatPage: React.FC = () => {
clearCompletionBadge,
} = useAgentChatStore();
const scrollToBottom = () => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
};
const syncScrollState = useCallback(() => {
const viewport = messagesViewportRef.current;
if (!viewport) return;
shouldStickToBottomRef.current = isNearBottom({
scrollTop: viewport.scrollTop,
clientHeight: viewport.clientHeight,
scrollHeight: viewport.scrollHeight,
});
}, []);
const scrollToBottom = useCallback((behavior: ScrollBehavior = 'auto') => {
messagesEndRef.current?.scrollIntoView({ behavior });
}, []);
const requestScrollToBottom = useCallback((behavior: ScrollBehavior = 'auto') => {
shouldStickToBottomRef.current = true;
pendingScrollBehaviorRef.current = behavior;
}, []);
const handleMessagesScroll = useCallback(() => {
syncScrollState();
}, [syncScrollState]);
useEffect(() => {
scrollToBottom();
}, [messages, progressSteps]);
syncScrollState();
}, [syncScrollState, sessionId]);
useEffect(() => {
const behavior = pendingScrollBehaviorRef.current;
const shouldAutoScroll = shouldStickToBottomRef.current;
if (!shouldAutoScroll) return;
const frame = window.requestAnimationFrame(() => {
scrollToBottom(behavior);
pendingScrollBehaviorRef.current = loading ? 'auto' : 'smooth';
});
return () => window.cancelAnimationFrame(frame);
}, [messages, progressSteps, loading, sessionId, scrollToBottom]);
useEffect(() => {
if (!loading) {
pendingScrollBehaviorRef.current = 'smooth';
}
}, [loading]);
useEffect(() => {
clearCompletionBadge();
@@ -95,14 +142,16 @@ const ChatPage: React.FC = () => {
const handleStartNewChat = useCallback(() => {
followUpContextRef.current = null;
requestScrollToBottom('auto');
useAgentChatStore.getState().startNewChat();
setSidebarOpen(false);
}, []);
}, [requestScrollToBottom]);
const handleSwitchSession = useCallback((targetSessionId: string) => {
requestScrollToBottom('auto');
switchSession(targetSessionId);
setSidebarOpen(false);
}, [switchSession]);
}, [requestScrollToBottom, switchSession]);
const confirmDelete = useCallback(() => {
if (!deleteConfirmId) return;
@@ -159,9 +208,10 @@ const ChatPage: React.FC = () => {
followUpContextRef.current = null;
setInput('');
requestScrollToBottom('smooth');
await startStream(payload, { strategyName: usedStrategyName });
},
[input, loading, selectedStrategy, strategies, sessionId, startStream],
[input, loading, requestScrollToBottom, selectedStrategy, strategies, sessionId, startStream],
);
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
@@ -236,7 +286,7 @@ const ChatPage: React.FC = () => {
};
const renderThinkingDetails = (steps: ProgressStep[]) => (
<div className="mb-3 pl-5 border-l border-white/5 space-y-0.5 animate-fade-in">
<div className="mb-3 pl-5 border-l border-border/40 space-y-0.5 animate-fade-in">
{steps.map((step, idx) => {
let icon = '⋯';
let text = '';
@@ -273,12 +323,17 @@ const ChatPage: React.FC = () => {
const sidebarContent = (
<>
<div className="p-3 border-b border-white/5 flex items-center justify-between">
<span className="text-sm font-medium text-white"></span>
<div className="flex items-center justify-between border-b border-white/5 bg-white/2 p-3.5">
<h2 className="text-[11px] font-semibold text-cyan uppercase tracking-[0.2em] flex items-center gap-2">
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</h2>
<button
onClick={handleStartNewChat}
className="p-1.5 rounded-lg hover:bg-white/10 transition-colors text-secondary-text hover:text-white"
title="新对话"
className="rounded-lg p-1.5 text-muted-text transition-all hover:bg-white/10 hover:text-white"
title="开启新对话"
>
<svg
className="w-4 h-4"
@@ -295,63 +350,101 @@ const ChatPage: React.FC = () => {
</svg>
</button>
</div>
<div className="flex-1 overflow-y-auto custom-scrollbar">
<ScrollArea testId="chat-session-list-scroll">
{sessionsLoading ? (
<div className="p-4 text-center text-xs text-muted-text">...</div>
) : sessions.length === 0 ? (
<div className="p-4 text-center text-xs text-muted-text"></div>
) : (
sessions.map((s) => (
<button
key={s.session_id}
onClick={() => handleSwitchSession(s.session_id)}
className={`w-full text-left px-3 py-2.5 border-b border-white/5 hover:bg-white/5 transition-colors group ${
s.session_id === sessionId ? 'bg-white/10' : ''
}`}
>
<div className="flex items-center justify-between gap-2">
<span className="text-sm text-secondary-text group-hover:text-white truncate flex-1">
{s.title}
</span>
<button
onClick={(e) => {
e.stopPropagation();
setDeleteConfirmId(s.session_id);
}}
className="opacity-0 group-hover:opacity-100 p-0.5 rounded hover:bg-white/10 text-muted-text hover:text-red-400 transition-all flex-shrink-0"
title="删除"
>
<svg
className="w-3.5 h-3.5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
</button>
<div className="space-y-2 p-3">
{sessions.map((s) => (
<div
key={s.session_id}
role="button"
tabIndex={0}
onClick={() => handleSwitchSession(s.session_id)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleSwitchSession(s.session_id);
}
}}
className={`group relative flex w-full cursor-pointer items-start gap-3 overflow-hidden rounded-xl border p-2.5 transition-all duration-200 ${
s.session_id === sessionId
? 'border-cyan bg-cyan/10 shadow-[0_0_15px_rgba(0,212,255,0.1)]'
: 'border-white/5 bg-white/2 hover:border-white/10 hover:bg-white/5'
}`}
aria-label={`切换到对话 ${s.title}`}
>
{/* 装饰条 */}
<div
className={`h-10 w-1 rounded-full flex-shrink-0 transition-colors ${
s.session_id === sessionId ? 'bg-cyan' : 'bg-white/10'
}`}
/>
<div className="min-w-0 flex-1">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<span className={`block truncate text-sm font-semibold tracking-tight transition-colors ${
s.session_id === sessionId ? 'text-white' : 'text-secondary-text group-hover:text-white'
}`}>
{s.title}
</span>
</div>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setDeleteConfirmId(s.session_id);
}}
className="flex-shrink-0 rounded p-1 text-muted-text opacity-0 transition-all hover:bg-white/10 hover:text-rose-400 group-hover:opacity-100"
title="删除"
>
<svg
className="w-3.5 h-3.5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
</button>
</div>
<div className="mt-1 flex items-center gap-2">
<span className="text-[11px] text-muted-text">
{s.message_count}
</span>
{s.last_active && (
<>
<span className="h-1 w-1 rounded-full bg-white/10" />
<span className="text-[11px] text-muted-text">
{new Date(s.last_active).toLocaleDateString('zh-CN', { month: 'short', day: 'numeric' })}
</span>
</>
)}
</div>
</div>
</div>
<div className="text-xs text-muted-text mt-0.5">
{s.message_count}
{s.last_active &&
` · ${new Date(s.last_active).toLocaleDateString('zh-CN', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })}`}
</div>
</button>
))
))}
</div>
)}
</div>
</ScrollArea>
</>
);
return (
<div className="h-screen flex max-w-6xl mx-auto w-full p-4 md:p-6 gap-4">
<div
data-testid="chat-workspace"
className="flex h-[calc(100vh-5rem)] w-full min-w-0 gap-4 overflow-hidden sm:h-[calc(100vh-5.5rem)] lg:h-[calc(100vh-2rem)]"
>
{/* Desktop sidebar */}
<div className="hidden md:flex flex-col w-64 flex-shrink-0 glass-card overflow-hidden">
<div className="hidden h-full w-64 flex-shrink-0 flex-col overflow-hidden rounded-[1.25rem] border border-white/8 bg-card/82 shadow-soft-card md:flex">
{sidebarContent}
</div>
@@ -363,7 +456,7 @@ const ChatPage: React.FC = () => {
>
<div className="absolute inset-0 bg-black/60" />
<div
className="absolute left-0 top-0 bottom-0 w-72 flex flex-col glass-card overflow-hidden border-r border-white/10 shadow-2xl"
className="absolute left-0 top-0 bottom-0 w-72 flex flex-col glass-card overflow-hidden border-r border-white/10 bg-card/90 shadow-2xl"
onClick={(e) => e.stopPropagation()}
>
{sidebarContent}
@@ -372,44 +465,24 @@ const ChatPage: React.FC = () => {
)}
{/* Delete confirmation dialog */}
{deleteConfirmId && (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
onClick={() => setDeleteConfirmId(null)}
>
<div
className="bg-elevated border border-white/10 rounded-xl p-6 max-w-sm mx-4 shadow-2xl"
onClick={(e) => e.stopPropagation()}
>
<h3 className="text-white font-medium mb-2"></h3>
<p className="text-sm text-secondary-text mb-5">
</p>
<div className="flex justify-end gap-3">
<button
onClick={() => setDeleteConfirmId(null)}
className="px-4 py-1.5 rounded-lg text-sm text-secondary-text hover:text-white hover:bg-white/5 border border-white/10 transition-colors"
>
</button>
<button
onClick={confirmDelete}
className="px-4 py-1.5 rounded-lg text-sm text-white bg-red-500/80 hover:bg-red-500 transition-colors"
>
</button>
</div>
</div>
</div>
)}
<ConfirmDialog
isOpen={Boolean(deleteConfirmId)}
title="删除对话"
message="删除后,该对话将不可恢复,确认删除吗?"
confirmText="删除"
cancelText="取消"
isDanger
onConfirm={confirmDelete}
onCancel={() => setDeleteConfirmId(null)}
/>
{/* Main chat area */}
<div className="flex-1 flex flex-col min-w-0">
<div className="flex h-full min-w-0 flex-1 flex-col overflow-hidden">
<header className="mb-4 flex-shrink-0">
<h1 className="text-2xl font-bold text-white mb-2 flex items-center gap-2">
<h1 className="text-2xl font-bold text-foreground mb-2 flex items-center gap-2">
<button
onClick={() => setSidebarOpen(true)}
className="md:hidden p-1.5 -ml-1 rounded-lg hover:bg-white/10 transition-colors text-secondary-text hover:text-white"
className="md:hidden p-1.5 -ml-1 rounded-lg hover:bg-hover transition-colors text-secondary-text hover:text-foreground"
title="历史对话"
>
<svg
@@ -449,7 +522,7 @@ const ChatPage: React.FC = () => {
<button
type="button"
onClick={() => downloadSession(messages)}
className="px-3 py-1.5 rounded-lg text-sm text-secondary-text hover:text-white hover:bg-white/10 border border-white/10 transition-colors flex items-center gap-1.5"
className="px-3 py-1.5 rounded-lg text-sm text-secondary-text hover:text-foreground hover:bg-hover border border-border/70 transition-colors flex items-center gap-1.5"
title="导出会话为 Markdown 文件"
>
<svg
@@ -490,7 +563,7 @@ const ChatPage: React.FC = () => {
}
}}
disabled={sending}
className="px-3 py-1.5 rounded-lg text-sm text-secondary-text hover:text-white hover:bg-white/10 border border-white/10 transition-colors flex items-center gap-1.5 disabled:opacity-50 disabled:cursor-not-allowed"
className="px-3 py-1.5 rounded-lg text-sm text-secondary-text hover:text-foreground hover:bg-hover border border-border/70 transition-colors flex items-center gap-1.5 disabled:opacity-50 disabled:cursor-not-allowed"
title="发送到已配置的通知机器人/邮箱"
>
{sending ? (
@@ -541,12 +614,18 @@ const ChatPage: React.FC = () => {
)}
</header>
<div className="flex-1 flex flex-col glass-card overflow-hidden min-h-0 relative z-10">
<div className="relative z-10 flex min-h-0 flex-1 flex-col overflow-hidden border border-white/6 bg-card/78 glass-card">
{/* Messages */}
<div className="flex-1 overflow-y-auto p-4 md:p-6 space-y-6 custom-scrollbar relative z-10">
<ScrollArea
className="relative z-10 flex-1"
viewportRef={messagesViewportRef}
onScroll={handleMessagesScroll}
viewportClassName="space-y-6 p-4 md:p-6"
testId="chat-message-scroll"
>
{messages.length === 0 && !loading ? (
<div className="h-full flex flex-col items-center justify-center text-center">
<div className="w-16 h-16 mb-4 rounded-2xl bg-white/5 flex items-center justify-center">
<div className="w-16 h-16 mb-4 rounded-2xl bg-card/70 flex items-center justify-center">
<svg
className="w-8 h-8 text-muted-text"
fill="none"
@@ -561,7 +640,7 @@ const ChatPage: React.FC = () => {
/>
</svg>
</div>
<h3 className="text-lg font-medium text-white mb-2">
<h3 className="text-lg font-medium text-foreground mb-2">
</h3>
<p className="text-sm text-secondary-text max-w-sm mb-6">
@@ -573,7 +652,7 @@ const ChatPage: React.FC = () => {
<button
key={i}
onClick={() => handleQuickQuestion(q)}
className="px-3 py-1.5 rounded-full bg-white/5 border border-white/10 text-sm text-secondary-text hover:text-white hover:border-cyan/40 hover:bg-cyan/5 transition-all"
className="px-3 py-1.5 rounded-full bg-card/70 border border-border/70 text-sm text-secondary-text hover:text-foreground hover:border-cyan/40 hover:bg-cyan/5 transition-all"
>
{q.label}
</button>
@@ -590,16 +669,16 @@ const ChatPage: React.FC = () => {
className={`w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0 text-xs font-bold ${
msg.role === 'user'
? 'bg-cyan text-black'
: 'bg-white/10 text-white'
: 'bg-elevated text-foreground'
}`}
>
{msg.role === 'user' ? 'U' : 'AI'}
</div>
<div
className={`max-w-[80%] rounded-2xl px-5 py-3.5 ${
className={`min-w-0 w-fit max-w-[min(100%,48rem)] overflow-hidden rounded-2xl px-5 py-3.5 ${
msg.role === 'user'
? 'bg-cyan/10 text-white border border-cyan/20 rounded-tr-sm'
: 'bg-white/5 text-secondary-text border border-white/10 rounded-tl-sm'
: 'bg-card/72 text-secondary-text border border-white/30 rounded-tl-sm'
}`}
>
{msg.role === 'assistant' && msg.strategyName && (
@@ -630,19 +709,21 @@ const ChatPage: React.FC = () => {
{msg.role === 'assistant' ? (
<div
className="prose prose-invert prose-sm max-w-none
prose-headings:text-white prose-headings:font-semibold prose-headings:mt-3 prose-headings:mb-1.5
prose-headings:text-foreground prose-headings:font-semibold prose-headings:mt-3 prose-headings:mb-1.5
prose-h1:text-lg prose-h2:text-base prose-h3:text-sm
prose-p:leading-relaxed prose-p:mb-2 prose-p:last:mb-0
prose-strong:text-white prose-strong:font-semibold
prose-ul:my-1.5 prose-ol:my-1.5 prose-li:my-0.5
prose-code:text-cyan prose-code:bg-white/5 prose-code:px-1 prose-code:py-0.5 prose-code:rounded prose-code:text-xs
prose-pre:bg-black/30 prose-pre:border prose-pre:border-white/10 prose-pre:rounded-lg prose-pre:p-3
prose-p:mb-2 prose-p:last:mb-0 prose-p:leading-7 prose-p:break-words
prose-strong:text-foreground prose-strong:font-semibold
prose-ul:my-1.5 prose-ol:my-1.5 prose-li:my-0.5 prose-li:break-words
prose-code:text-cyan prose-code:bg-card/70 prose-code:px-1 prose-code:py-0.5 prose-code:rounded prose-code:text-xs prose-code:break-all
prose-pre:max-w-full prose-pre:overflow-x-auto prose-pre:bg-black/30 prose-pre:border prose-pre:border-border/70 prose-pre:rounded-lg prose-pre:p-3
prose-table:w-full prose-table:text-sm
prose-th:text-white prose-th:font-medium prose-th:border-white/20 prose-th:px-3 prose-th:py-1.5 prose-th:bg-white/5
prose-td:border-white/10 prose-td:px-3 prose-td:py-1.5
prose-hr:border-white/10 prose-hr:my-3
prose-th:text-foreground prose-th:font-medium prose-th:border-border prose-th:px-3 prose-th:py-1.5 prose-th:bg-card/70
prose-td:border-border/70 prose-td:px-3 prose-td:py-1.5
prose-hr:border-border/70 prose-hr:my-3
prose-a:text-cyan prose-a:no-underline hover:prose-a:underline
prose-blockquote:border-cyan/30 prose-blockquote:text-secondary-text
[&_table]:block [&_table]:overflow-x-auto [&_table]:whitespace-nowrap
[&_img]:max-w-full
"
>
<Markdown remarkPlugins={[remarkGfm]}>
@@ -668,10 +749,10 @@ const ChatPage: React.FC = () => {
{loading && (
<div className="flex gap-4">
<div className="w-8 h-8 rounded-full bg-white/10 text-white flex items-center justify-center flex-shrink-0 text-xs font-bold">
<div className="w-8 h-8 rounded-full bg-elevated text-foreground flex items-center justify-center flex-shrink-0 text-xs font-bold">
AI
</div>
<div className="bg-white/5 border border-white/10 rounded-2xl rounded-tl-sm px-5 py-4 min-w-[200px] max-w-[80%]">
<div className="min-w-[200px] max-w-[min(100%,48rem)] overflow-hidden rounded-2xl rounded-tl-sm border border-white/6 bg-card/72 px-5 py-4">
<div className="flex items-center gap-2.5 text-sm text-secondary-text">
<div className="relative w-4 h-4 flex-shrink-0">
<div className="absolute inset-0 rounded-full border-2 border-cyan/20" />
@@ -686,10 +767,10 @@ const ChatPage: React.FC = () => {
)}
<div ref={messagesEndRef} />
</div>
</ScrollArea>
{/* Input area */}
<div className="p-4 md:p-6 border-t border-white/5 bg-black/20 relative z-20">
<div className="p-4 md:p-6 border-t border-white/6 bg-card/88 relative z-20">
{chatError ? (
<ApiErrorAlert error={chatError} className="mb-3" />
) : null}
@@ -708,7 +789,7 @@ const ChatPage: React.FC = () => {
className="w-3.5 h-3.5 accent-cyan"
/>
<span
className={`transition-colors text-sm ${selectedStrategy === '' ? 'text-white font-medium' : 'text-secondary-text group-hover:text-white'}`}
className={`transition-colors text-sm ${selectedStrategy === '' ? 'text-foreground font-medium' : 'text-secondary-text group-hover:text-foreground'}`}
>
</span>
@@ -729,13 +810,13 @@ const ChatPage: React.FC = () => {
className="w-3.5 h-3.5 accent-cyan"
/>
<span
className={`transition-colors text-sm ${selectedStrategy === s.id ? 'text-white font-medium' : 'text-secondary-text group-hover:text-white'}`}
className={`transition-colors text-sm ${selectedStrategy === s.id ? 'text-foreground font-medium' : 'text-secondary-text group-hover:text-foreground'}`}
>
{s.name}
</span>
{showStrategyDesc === s.id && s.description && (
<div className="absolute left-0 bottom-full mb-2 z-50 w-64 p-2.5 rounded-lg bg-elevated border border-white/10 shadow-xl text-xs text-secondary-text leading-relaxed pointer-events-none animate-fade-in">
<p className="font-medium text-white mb-1">{s.name}</p>
<div className="absolute left-0 bottom-full mb-2 z-50 w-64 p-2.5 rounded-lg bg-elevated border border-border/70 shadow-xl text-xs text-secondary-text leading-relaxed pointer-events-none animate-fade-in">
<p className="font-medium text-foreground mb-1">{s.name}</p>
<p>{s.description}</p>
</div>
)}

View File

@@ -26,6 +26,11 @@ const HomePage: React.FC = () => {
} = useAnalysisStore();
const navigate = useNavigate();
// Set page title
useEffect(() => {
document.title = '每日选股分析 - DSA';
}, []);
// Input state
const [stockCode, setStockCode] = useState('');
const [isAnalyzing, setIsAnalyzing] = useState(false);
@@ -398,25 +403,25 @@ const HomePage: React.FC = () => {
onToggleItemSelection={handleToggleHistorySelection}
onToggleSelectAll={handleToggleSelectAllHistory}
onDeleteSelected={confirmDeleteHistory}
className="max-h-[80vh] md:max-h-[80vh] flex-1 overflow-hidden"
className="flex-1 overflow-hidden"
/>
</div>
);
return (
<div
className="min-h-screen flex flex-col md:grid overflow-hidden w-full"
className="flex min-h-0 w-full flex-col overflow-hidden md:grid md:h-[calc(100vh-5.5rem)] lg:h-[calc(100vh-2rem)]"
style={{ gridTemplateColumns: 'minmax(12px, 1fr) 256px 24px minmax(auto, 896px) minmax(12px, 1fr)', gridTemplateRows: 'auto 1fr' }}
>
{/* Top Input Bar */}
<header
className="md:col-start-2 md:col-end-5 md:row-start-1 py-3 px-3 md:px-0 border-b border-white/5 flex-shrink-0 flex items-center min-w-0 overflow-hidden"
className="md:col-start-2 md:col-end-5 md:row-start-1 py-3 px-3 md:px-0 flex-shrink-0 flex items-center min-w-0 overflow-hidden"
>
<div className="flex items-center gap-2 w-full min-w-0 flex-1" style={{ maxWidth: 'min(100%, 1168px)' }}>
{/* Mobile hamburger */}
<button
onClick={() => setSidebarOpen(true)}
className="md:hidden p-1.5 -ml-1 rounded-lg hover:bg-white/10 transition-colors text-secondary-text hover:text-white flex-shrink-0"
className="md:hidden p-1.5 -ml-1 rounded-lg hover:bg-hover transition-colors text-secondary-text hover:text-foreground flex-shrink-0"
title="历史记录"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -472,9 +477,9 @@ const HomePage: React.FC = () => {
{/* Mobile sidebar overlay */}
{sidebarOpen && (
<div className="fixed inset-0 z-40 md:hidden" onClick={() => setSidebarOpen(false)}>
<div className="absolute inset-0 bg-black/60" />
<div className="absolute inset-0 bg-[var(--home-mobile-overlay-bg)]" />
<div
className="absolute left-0 top-0 bottom-0 w-72 flex flex-col glass-card overflow-hidden border-r border-white/10 shadow-2xl p-3"
className="absolute left-0 top-0 bottom-0 w-72 flex flex-col glass-card overflow-hidden border-r border-border/70 shadow-2xl p-3"
onClick={(e) => e.stopPropagation()}
>
{sidebarContent}
@@ -483,7 +488,7 @@ const HomePage: React.FC = () => {
)}
{/* Right Report Detail */}
<section className="md:col-start-4 md:row-start-2 flex-1 overflow-y-auto overflow-x-auto px-3 md:px-0 md:pl-1 min-w-0 min-h-0">
<section className="md:col-start-4 md:row-start-2 flex-1 overflow-y-auto overflow-x-auto px-3 py-4 md:px-0 md:py-5 md:pl-2 md:pr-1 min-w-0 min-h-0">
{analysisError ? (
<ApiErrorAlert
error={analysisError}
@@ -492,13 +497,13 @@ const HomePage: React.FC = () => {
) : null}
{isLoadingReport ? (
<div className="flex flex-col items-center justify-center h-full">
<div className="w-10 h-10 border-3 border-cyan/20 border-t-cyan rounded-full animate-spin" />
<div className="h-10 w-10 animate-spin rounded-full border-3 border-[var(--home-loading-ring-track)] border-t-[var(--home-loading-ring-head)]" />
<p className="mt-3 text-secondary-text text-sm">...</p>
</div>
) : selectedReport ? (
<div className="max-w-4xl">
<div className="max-w-[980px] pb-8">
{/* Action buttons */}
<div className="flex items-center justify-end mb-2 gap-2">
<div className="mb-3 flex items-center justify-end gap-2">
<button
disabled={selectedReport.meta.id === undefined}
onClick={() => {
@@ -507,7 +512,7 @@ const HomePage: React.FC = () => {
const rid = selectedReport.meta.id!;
navigate(`/chat?stock=${encodeURIComponent(code)}&name=${encodeURIComponent(name)}&recordId=${rid}`);
}}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-cyan/10 border border-cyan/20 text-cyan text-sm hover:bg-cyan/20 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
className="flex items-center gap-1.5 rounded-lg border px-3 py-1.5 text-sm transition-colors disabled:cursor-not-allowed disabled:opacity-40 bg-[var(--home-action-ai-bg)] border-[var(--home-action-ai-border)] text-[var(--home-action-ai-text)] hover:bg-[var(--home-action-ai-hover-bg)]"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
@@ -517,7 +522,7 @@ const HomePage: React.FC = () => {
<button
disabled={selectedReport.meta.id === undefined}
onClick={() => setShowMarkdownDrawer(true)}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-purple/10 border border-purple/20 text-purple text-sm hover:bg-purple/20 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
className="flex items-center gap-1.5 rounded-lg border px-3 py-1.5 text-sm transition-colors disabled:cursor-not-allowed disabled:opacity-40 bg-[var(--home-action-report-bg)] border-[var(--home-action-report-border)] text-[var(--home-action-report-text)] hover:bg-[var(--home-action-report-hover-bg)]"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
@@ -534,7 +539,7 @@ const HomePage: React.FC = () => {
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
</div>
<h3 className="text-base font-medium text-white mb-1.5"></h3>
<h3 className="text-base font-medium text-foreground mb-1.5"></h3>
<p className="text-xs text-muted-text max-w-xs">
</p>

View File

@@ -1,6 +1,8 @@
import type React from 'react';
import { useState } from 'react';
import { ApiErrorAlert } from '../components/common';
import { useState, useEffect } from 'react';
import { motion, useMotionValue, useTransform, useSpring } from "motion/react";
import { Lock, Loader2, Cpu, TrendingUp, Network, ShieldCheck } from "lucide-react";
import { Button, Input, ParticleBackground } from '../components/common';
import { useNavigate, useSearchParams } from 'react-router-dom';
import type { ParsedApiError } from '../api/error';
import { isParsedApiError } from '../api/error';
@@ -8,8 +10,13 @@ import { useAuth } from '../hooks';
import { SettingsAlert } from '../components/settings';
const LoginPage: React.FC = () => {
const { login, passwordSet } = useAuth();
const { login, passwordSet, setupState } = useAuth();
const navigate = useNavigate();
// Set page title
useEffect(() => {
document.title = '登录 - DSA';
}, []);
const [searchParams] = useSearchParams();
const rawRedirect = searchParams.get('redirect') ?? '';
const redirect =
@@ -20,7 +27,26 @@ const LoginPage: React.FC = () => {
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | ParsedApiError | null>(null);
const isFirstTime = !passwordSet;
const isFirstTime = setupState === 'no_password' || !passwordSet;
// 3D Tilt effect values
const mouseX = useMotionValue(0);
const mouseY = useMotionValue(0);
// Smooth out the mouse movement
const smoothX = useSpring(mouseX, { damping: 30, stiffness: 200 });
const smoothY = useSpring(mouseY, { damping: 30, stiffness: 200 });
useEffect(() => {
const handleMouseMove = (e: MouseEvent) => {
const x = e.clientX / window.innerWidth - 0.5;
const y = e.clientY / window.innerHeight - 0.5;
mouseX.set(x);
mouseY.set(y);
};
window.addEventListener("mousemove", handleMouseMove);
return () => window.removeEventListener("mousemove", handleMouseMove);
}, [mouseX, mouseY]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
@@ -43,78 +69,214 @@ const LoginPage: React.FC = () => {
};
return (
<div className="flex min-h-screen flex-col items-center justify-center bg-base px-4">
<div className="w-full max-w-sm rounded-2xl border border-white/8 bg-card/80 p-6 backdrop-blur-sm">
<h1 className="mb-2 text-xl font-semibold text-white">
{isFirstTime ? '设置初始密码' : '管理员登录'}
</h1>
<p className="mb-6 text-sm text-secondary-text">
{isFirstTime
? '请设置管理员密码,输入两遍确认'
: '请输入密码以继续访问'}
</p>
<div
style={{
// Scoped tokens for LoginPage to ensure UI consistency without breaking the unique visual style
'--login-bg-main': '#030712',
'--login-bg-card': '#0B0E14',
'--login-border-card': 'rgba(255, 255, 255, 0.05)',
'--login-border-input': 'rgba(255, 255, 255, 0.1)',
'--login-border-focus': 'rgba(6, 182, 212, 0.5)',
'--login-error-text': '#f87171', // red-400
'--login-error-bg': 'rgba(239, 68, 68, 0.1)', // red-500/10
'--login-error-border': 'rgba(239, 68, 68, 0.2)', // red-500/20
'--login-text-primary': '#ffffff',
'--login-text-secondary': '#94a3b8', // slate-400
'--login-text-muted': '#64748b', // slate-500
} as React.CSSProperties}
className="relative flex min-h-screen flex-col justify-center overflow-hidden bg-[var(--login-bg-main)] py-12 font-sans selection:bg-cyan-500/30 sm:px-6 lg:px-8 [perspective:1500px]"
>
{/* Dynamic Background */}
<ParticleBackground />
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label htmlFor="password" className="mb-1 block text-sm font-medium text-secondary-text">
{isFirstTime ? '新密码' : '密码'}
</label>
<input
id="password"
type="password"
className="input-terminal"
placeholder={isFirstTime ? '输入新密码' : '输入密码'}
value={password}
onChange={(e) => setPassword(e.target.value)}
disabled={isSubmitting}
autoFocus
autoComplete={isFirstTime ? 'new-password' : 'current-password'}
/>
{/* Cyber Grid */}
<div className="absolute inset-0 z-0 bg-[linear-gradient(to_right,#8080800a_1px,transparent_1px),linear-gradient(to_bottom,#8080800a_1px,transparent_1px)] bg-[size:24px_24px] [mask-image:radial-gradient(ellipse_80%_50%_at_50%_50%,#000_70%,transparent_100%)]" />
{/* Parallax Glowing Orbs */}
<motion.div
style={{
x: useTransform(smoothX, [-0.5, 0.5], [-50, 50]),
y: useTransform(smoothY, [-0.5, 0.5], [-50, 50]),
}}
className="absolute left-[20%] top-[20%] -z-10 h-[300px] w-[300px] -translate-x-1/2 -translate-y-1/2 rounded-full bg-cyan-600/20 blur-[100px]"
/>
<motion.div
style={{
x: useTransform(smoothX, [-0.5, 0.5], [60, -60]),
y: useTransform(smoothY, [-0.5, 0.5], [60, -60]),
}}
className="absolute right-[20%] bottom-[10%] -z-10 h-[400px] w-[400px] translate-x-1/2 translate-y-1/2 rounded-full bg-emerald-600/10 blur-[120px]"
/>
<div className="sm:mx-auto sm:w-full sm:max-w-md relative z-10">
<motion.div
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, ease: "easeOut" }}
className="flex flex-col items-center justify-center mb-10 relative"
>
{/* Immersive Full-Height Background Logo */}
<motion.div
style={{
x: useTransform(smoothX, [-0.5, 0.5], [-8, 8]),
y: useTransform(smoothY, [-0.5, 0.5], [-8, 8]),
rotate: useTransform(smoothX, [-0.5, 0.5], [-0.5, 0.5]),
}}
className="absolute -top-[20vh] -z-10 opacity-80 pointer-events-none"
>
<div className="relative flex h-[120vh] w-[120vh] items-center justify-center rounded-full border border-cyan-500/10 bg-gradient-to-br from-cyan-950/20 to-blue-950/20 shadow-[inset_0_0_200px_rgba(6,182,212,0.1)] blur-[4px]">
<Cpu className="h-[70vh] w-[70vh] text-cyan-900/40 brightness-50" />
<TrendingUp className="absolute h-[25vh] w-[25vh] translate-x-[15vh] translate-y-[15vh] text-emerald-900/30 brightness-50" />
</div>
</motion.div>
<div className="mt-8 flex flex-col items-center">
<h2 className="text-4xl font-extrabold tracking-tighter text-[var(--login-text-primary)] sm:text-6xl">
<span className="bg-gradient-to-r from-[var(--login-text-primary)] via-[var(--login-text-primary)] to-[var(--login-text-secondary)] bg-clip-text text-transparent">DAILY </span>
<span className="bg-gradient-to-r from-cyan-400 to-blue-500 bg-clip-text text-transparent shadow-cyan-500/20 drop-shadow-[0_0_20px_rgba(6,182,212,0.4)]">STOCK</span>
</h2>
<h3 className="mt-1 text-xl font-bold uppercase tracking-[0.5em] text-[var(--login-text-muted)]">
Analysis Engine
</h3>
</div>
{isFirstTime ? (
<div>
<label
htmlFor="passwordConfirm"
className="mb-1 block text-sm font-medium text-secondary-text"
>
</label>
<input
id="passwordConfirm"
type="password"
className="input-terminal"
placeholder="再次输入密码"
value={passwordConfirm}
onChange={(e) => setPasswordConfirm(e.target.value)}
disabled={isSubmitting}
autoComplete="new-password"
/>
</div>
) : null}
{error
? isParsedApiError(error)
? <ApiErrorAlert error={error} className="!mt-3" />
: (
<SettingsAlert
title={isFirstTime ? '设置失败' : '登录失败'}
message={error}
variant="error"
className="!mt-3"
/>
)
: null}
<button
type="submit"
className="btn-primary w-full"
disabled={isSubmitting}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.3 }}
className="mt-6 flex items-center gap-2 rounded-full border border-cyan-500/30 bg-cyan-500/5 px-3 py-1 text-[10px] font-medium text-cyan-300 backdrop-blur-sm"
>
{isSubmitting ? (isFirstTime ? '设置中...' : '登录中...') : isFirstTime ? '设置密码' : '登录'}
</button>
</form>
<Network className="h-3 w-3" />
<span>V3.X QUANTITATIVE SYSTEM</span>
</motion.div>
</motion.div>
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.5, delay: 0.1 }}
className="relative group z-20 pointer-events-auto"
>
{/* Card Border Glow */}
<div className="absolute -inset-0.5 rounded-3xl bg-gradient-to-b from-cyan-500/20 to-blue-600/20 opacity-50 blur-sm transition duration-1000 group-hover:opacity-100 group-hover:duration-200 pointer-events-none" />
<div className="pointer-events-auto relative flex flex-col overflow-hidden rounded-3xl border border-[var(--login-border-card)] bg-[var(--login-bg-card)]/80 p-8 shadow-2xl backdrop-blur-xl">
{/* Inner corner glow */}
<div className="absolute -right-20 -top-20 h-40 w-40 rounded-full bg-cyan-500/10 blur-[50px]" />
<div className="absolute -bottom-20 -left-20 h-40 w-40 rounded-full bg-blue-600/10 blur-[50px]" />
<div className="mb-8">
<h1 className="flex items-center gap-2 text-2xl font-bold tracking-tight text-[var(--login-text-primary)]">
{isFirstTime ? (
<>
<ShieldCheck className="h-6 w-6 text-emerald-400" />
<span></span>
</>
) : (
<>
<Lock className="h-5 w-5 text-cyan-400" />
<span></span>
</>
)}
</h1>
<p className="mt-2 text-sm text-[var(--login-text-secondary)]">
{isFirstTime
? '首次启用认证,请为系统工作台设置管理员密码。'
: '访问 DSA 量化决策引擎需要有效的身份凭证。'}
</p>
</div>
<form onSubmit={handleSubmit} className="space-y-6">
<div className="space-y-4">
<Input
id="password"
type="password"
allowTogglePassword
iconType="password"
label={isFirstTime ? '管理员密码' : '登录密码'}
placeholder={isFirstTime ? '请设置 6 位以上密码' : '请输入密码'}
value={password}
onChange={(e) => setPassword(e.target.value)}
disabled={isSubmitting}
autoFocus
autoComplete={isFirstTime ? 'new-password' : 'current-password'}
className="!bg-[var(--login-border-card)] !border-[var(--login-border-input)] focus:!border-[var(--login-border-focus)]"
/>
{isFirstTime && (
<Input
id="passwordConfirm"
type="password"
allowTogglePassword
iconType="password"
label="确认密码"
placeholder="再次确认管理员密码"
value={passwordConfirm}
onChange={(e) => setPasswordConfirm(e.target.value)}
disabled={isSubmitting}
autoComplete="new-password"
className="!bg-[var(--login-border-card)] !border-[var(--login-border-input)] focus:!border-[var(--login-border-focus)]"
/>
)}
</div>
{error && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
className="overflow-hidden"
>
<SettingsAlert
title={isFirstTime ? '配置失败' : '验证未通过'}
message={isParsedApiError(error) ? error.message : error}
variant="error"
className="!border-[var(--login-error-border)] !bg-[var(--login-error-bg)] !text-[var(--login-error-text)]"
/>
</motion.div>
)}
<Button
type="submit"
variant="primary"
size="lg"
className="relative h-12 w-full overflow-hidden rounded-xl border-0 bg-gradient-to-r from-cyan-600 to-blue-600 font-medium text-white shadow-lg shadow-cyan-950/20 hover:from-cyan-500 hover:to-blue-500 group/btn"
disabled={isSubmitting}
>
<div className="relative z-10 flex items-center justify-center gap-2">
{isSubmitting ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
<span>{isFirstTime ? '初始化中...' : '正在建立连接...'}</span>
</>
) : (
<span>{isFirstTime ? '完成设置并登录' : '授权进入工作台'}</span>
)}
</div>
{/* Button shine effect */}
<div className="absolute inset-0 z-0 bg-gradient-to-r from-transparent via-white/10 to-transparent -translate-x-full group-hover:animate-[shimmer_1.5s_infinite] pointer-events-none" />
</Button>
</form>
</div>
</motion.div>
{/* Footer info */}
<motion.p
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.6 }}
className="mt-8 text-center font-mono text-xs uppercase tracking-wider text-[var(--login-text-muted)]"
>
Secure Connection Established via DSA-V3-TLS
</motion.p>
</div>
<style dangerouslySetInnerHTML={{ __html: `
@keyframes shimmer {
100% {
transform: translateX(100%);
}
}
`}} />
</div>
);
};

View File

@@ -1,9 +1,15 @@
import type React from 'react';
import { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
const NotFoundPage: React.FC = () => {
const navigate = useNavigate();
// Set page title
useEffect(() => {
document.title = '页面未找到 - DSA';
}, []);
return (
<div className="min-h-screen flex flex-col items-center justify-center text-center px-4">
{/* 404 */}

View File

@@ -82,6 +82,11 @@ function formatBrokerLabel(value: string, displayName?: string): string {
}
const PortfolioPage: React.FC = () => {
// Set page title
useEffect(() => {
document.title = '持仓分析 - DSA';
}, []);
const [accounts, setAccounts] = useState<PortfolioAccountItem[]>([]);
const [selectedAccount, setSelectedAccount] = useState<AccountOption>('all');
const [showCreateAccount, setShowCreateAccount] = useState(false);

View File

@@ -1,19 +1,29 @@
import type React from 'react';
import { useEffect } from 'react';
import { useAuth, useSystemConfig } from '../hooks';
import { ApiErrorAlert } from '../components/common';
import { ApiErrorAlert, Button } from '../components/common';
import {
AuthSettingsCard,
ChangePasswordCard,
IntelligentImport,
LLMChannelEditor,
SettingsCategoryNav,
SettingsAlert,
SettingsField,
SettingsLoading,
SettingsSectionCard,
} from '../components/settings';
import { getCategoryDescriptionZh, getCategoryTitleZh } from '../utils/systemConfigI18n';
import { getCategoryDescriptionZh } from '../utils/systemConfigI18n';
import type { SystemConfigCategory } from '../types/systemConfig';
const SettingsPage: React.FC = () => {
const { passwordChangeable } = useAuth();
// Set page title
useEffect(() => {
document.title = '系统设置 - DSA';
}, []);
const {
categories,
itemsByCategory,
@@ -32,7 +42,9 @@ const SettingsPage: React.FC = () => {
load,
retry,
save,
resetDraft,
setDraftValue,
refreshAfterExternalSave,
configVersion,
maskToken,
} = useSystemConfig();
@@ -89,6 +101,9 @@ const SettingsPage: React.FC = () => {
'OPENAI_TEMPERATURE',
'VISION_MODEL',
]);
const SYSTEM_HIDDEN_KEYS = new Set([
'ADMIN_AUTH_ENABLED',
]);
const activeItems =
activeCategory === 'ai_model'
? rawActiveItems.filter((item) => {
@@ -100,31 +115,40 @@ const SettingsPage: React.FC = () => {
}
return true;
})
: activeCategory === 'system'
? rawActiveItems.filter((item) => !SYSTEM_HIDDEN_KEYS.has(item.key))
: rawActiveItems;
return (
<div className="min-h-screen px-4 pb-6 pt-4 md:px-6">
<header className="mb-4 rounded-2xl border border-white/8 bg-card/80 p-4 backdrop-blur-sm">
<div className="min-h-full px-4 pb-6 pt-4 md:px-6">
<div className="mb-5 rounded-xl bg-card/50 px-5 py-5 shadow-soft-card-strong">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div>
<h1 className="text-xl font-semibold text-white"></h1>
<p className="text-sm text-secondary">
使 .env
<h1 className="text-xl font-semibold tracking-tight text-foreground"></h1>
<p className="text-xs leading-6 text-muted-text">
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<button type="button" className="btn-secondary" onClick={() => void load()} disabled={isLoading || isSaving}>
</button>
<button
<Button
type="button"
className="btn-primary"
variant="settings-secondary"
onClick={resetDraft}
disabled={isLoading || isSaving}
>
</Button>
<Button
type="button"
variant="settings-primary"
onClick={() => void save()}
disabled={!hasDirty || isSaving || isLoading}
isLoading={isSaving}
loadingText="保存中..."
>
{isSaving ? '保存中...' : `保存配置${dirtyCount ? ` (${dirtyCount})` : ''}`}
</button>
</Button>
</div>
</div>
@@ -136,7 +160,7 @@ const SettingsPage: React.FC = () => {
onAction={retryAction === 'save' ? () => void retry() : undefined}
/>
) : null}
</header>
</div>
{loadError ? (
<ApiErrorAlert
@@ -150,79 +174,73 @@ const SettingsPage: React.FC = () => {
{isLoading ? (
<SettingsLoading />
) : (
<div className="grid grid-cols-1 gap-4 lg:grid-cols-[260px_1fr]">
<aside className="rounded-2xl border border-white/8 bg-card/60 p-3 backdrop-blur-sm">
<p className="mb-2 text-xs uppercase tracking-wide text-muted"></p>
<div className="space-y-2">
{categories.map((category) => {
const isActive = category.category === activeCategory;
const count = (itemsByCategory[category.category] || []).length;
const title = getCategoryTitleZh(category.category, category.title);
const description = getCategoryDescriptionZh(category.category, category.description);
return (
<button
key={category.category}
type="button"
className={`w-full rounded-lg border px-3 py-2 text-left transition ${
isActive
? 'border-accent bg-cyan/10 text-white'
: 'border-white/8 bg-elevated/40 text-secondary hover:border-white/16 hover:text-white'
}`}
onClick={() => setActiveCategory(category.category)}
>
<span className="flex items-center justify-between text-sm font-medium">
{title}
<span className="text-xs text-muted">{count}</span>
</span>
{description ? <span className="mt-1 block text-xs text-muted">{description}</span> : null}
</button>
);
})}
</div>
<div className="grid grid-cols-1 gap-5 lg:grid-cols-[280px_1fr]">
<aside className="lg:sticky lg:top-4 lg:self-start">
<SettingsCategoryNav
categories={categories}
itemsByCategory={itemsByCategory}
activeCategory={activeCategory}
onSelect={setActiveCategory}
/>
</aside>
<section className="space-y-3 rounded-2xl border border-white/8 bg-card/60 p-4 backdrop-blur-sm">
<section className="space-y-4">
{activeCategory === 'system' ? <AuthSettingsCard /> : null}
{activeCategory === 'base' ? (
<div className="space-y-3">
<SettingsSectionCard
title="智能导入"
description="从图片、文件或剪贴板中提取股票代码,并合并到自选股列表。"
>
<IntelligentImport
stockListValue={
(activeItems.find((i) => i.key === 'STOCK_LIST')?.value as string) ?? ''
}
configVersion={configVersion}
maskToken={maskToken}
onMerged={() => void load()}
onMerged={async () => {
await refreshAfterExternalSave(['STOCK_LIST']);
}}
disabled={isSaving || isLoading}
/>
</div>
</SettingsSectionCard>
) : null}
{activeCategory === 'ai_model' ? (
<LLMChannelEditor
items={rawActiveItems}
configVersion={configVersion}
maskToken={maskToken}
onSaved={() => void load()}
disabled={isSaving || isLoading}
/>
<SettingsSectionCard
title="LLM 渠道与模型"
description="统一管理渠道协议、基础地址、API Key、主模型与回退模型。"
>
<LLMChannelEditor
items={rawActiveItems}
configVersion={configVersion}
maskToken={maskToken}
onSaved={async (updatedItems) => {
await refreshAfterExternalSave(updatedItems.map((item) => item.key));
}}
disabled={isSaving || isLoading}
/>
</SettingsSectionCard>
) : null}
{activeCategory === 'system' && passwordChangeable ? (
<div className="space-y-3">
<ChangePasswordCard />
</div>
<ChangePasswordCard />
) : null}
{activeItems.length ? (
activeItems.map((item) => (
<SettingsField
key={item.key}
item={item}
value={item.value}
disabled={isSaving}
onChange={setDraftValue}
issues={issueByKey[item.key] || []}
/>
))
<SettingsSectionCard
title="当前分类配置项"
description={getCategoryDescriptionZh(activeCategory as SystemConfigCategory, '') || '使用统一字段卡片维护当前分类的系统配置。'}
>
{activeItems.map((item) => (
<SettingsField
key={item.key}
item={item}
value={item.value}
disabled={isSaving}
onChange={setDraftValue}
issues={issueByKey[item.key] || []}
/>
))}
</SettingsSectionCard>
) : (
<div className="rounded-xl border border-white/8 bg-elevated/40 p-5 text-sm text-secondary">
<div className="rounded-[1.5rem] border border-border/45 bg-card/92 p-5 text-sm text-secondary-text shadow-soft-card">
</div>
)}

View File

@@ -0,0 +1,130 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import ChatPage from '../ChatPage';
const mockLoadSessions = vi.fn();
const mockLoadInitialSession = vi.fn();
const mockSwitchSession = vi.fn();
const mockStartStream = vi.fn();
const mockClearCompletionBadge = vi.fn();
const mockStartNewChat = vi.fn();
const mockStoreState = {
messages: [],
loading: false,
progressSteps: [],
sessionId: 'session-1',
sessions: [
{
session_id: 'session-1',
title: '请简要分析 600519',
message_count: 2,
created_at: '2026-03-15T09:00:00Z',
last_active: '2026-03-15T09:05:00Z',
},
],
sessionsLoading: false,
chatError: null,
loadSessions: mockLoadSessions,
loadInitialSession: mockLoadInitialSession,
switchSession: mockSwitchSession,
startStream: mockStartStream,
clearCompletionBadge: mockClearCompletionBadge,
};
vi.mock('../../api/agent', () => ({
agentApi: {
getStrategies: vi.fn().mockResolvedValue({
strategies: [
{ id: 'bull_trend', name: '趋势分析', description: '测试策略' },
],
}),
deleteChatSession: vi.fn().mockResolvedValue(undefined),
sendChat: vi.fn().mockResolvedValue({ success: true }),
},
}));
vi.mock('../../api/history', () => ({
historyApi: {
getDetail: vi.fn().mockResolvedValue({}),
},
}));
vi.mock('../../stores/agentChatStore', () => {
const useAgentChatStore = (
selector?: (state: typeof mockStoreState) => unknown
) => (typeof selector === 'function' ? selector(mockStoreState) : mockStoreState);
useAgentChatStore.getState = () => ({
startNewChat: mockStartNewChat,
});
return { useAgentChatStore };
});
beforeAll(() => {
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: vi.fn().mockImplementation((query: string) => ({
matches: query === '(prefers-color-scheme: dark)',
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});
Object.defineProperty(window, 'requestAnimationFrame', {
writable: true,
value: (callback: FrameRequestCallback) => window.setTimeout(() => callback(0), 0),
});
Object.defineProperty(window, 'cancelAnimationFrame', {
writable: true,
value: (handle: number) => window.clearTimeout(handle),
});
Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', {
writable: true,
value: vi.fn(),
});
});
beforeEach(() => {
vi.clearAllMocks();
});
describe('ChatPage', () => {
it('renders a fixed workspace shell with independent session and message viewports', async () => {
render(
<MemoryRouter initialEntries={['/chat']}>
<ChatPage />
</MemoryRouter>
);
expect(await screen.findByTestId('chat-workspace')).toBeInTheDocument();
expect(screen.getByTestId('chat-session-list-scroll')).toBeInTheDocument();
expect(screen.getByTestId('chat-message-scroll')).toBeInTheDocument();
expect(mockLoadInitialSession).toHaveBeenCalled();
expect(mockClearCompletionBadge).toHaveBeenCalled();
});
it('switches session when clicking anywhere on the session card', async () => {
render(
<MemoryRouter initialEntries={['/chat']}>
<ChatPage />
</MemoryRouter>
);
const sessionCard = await screen.findByRole('button', {
name: /切换到对话 请简要分析 600519/,
});
fireEvent.click(sessionCard);
expect(mockSwitchSession).toHaveBeenCalledWith('session-1');
});
});

View File

@@ -0,0 +1,62 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import LoginPage from '../LoginPage';
const { navigate, useSearchParamsMock, useAuthMock } = vi.hoisted(() => ({
navigate: vi.fn(),
useSearchParamsMock: vi.fn(),
useAuthMock: vi.fn(),
}));
vi.mock('../../hooks', () => ({
useAuth: () => useAuthMock(),
}));
vi.mock('react-router-dom', async () => {
const actual = await vi.importActual<typeof import('react-router-dom')>('react-router-dom');
return {
...actual,
useNavigate: () => navigate,
useSearchParams: () => useSearchParamsMock(),
};
});
describe('LoginPage', () => {
beforeEach(() => {
vi.clearAllMocks();
useSearchParamsMock.mockReturnValue([new URLSearchParams('redirect=%2Fsettings')]);
});
it('blocks first-time setup when confirmation does not match', async () => {
const login = vi.fn();
useAuthMock.mockReturnValue({
login,
passwordSet: false,
setupState: 'no_password',
});
render(<LoginPage />);
fireEvent.change(screen.getByLabelText('管理员密码'), { target: { value: 'passwd6' } });
fireEvent.change(screen.getByLabelText('确认密码'), { target: { value: 'passwd7' } });
fireEvent.click(screen.getByRole('button', { name: '完成设置并登录' }));
expect(await screen.findByText('两次输入的密码不一致')).toBeInTheDocument();
expect(login).not.toHaveBeenCalled();
});
it('navigates to redirect after a successful login', async () => {
useAuthMock.mockReturnValue({
login: vi.fn().mockResolvedValue({ success: true }),
passwordSet: true,
setupState: 'enabled',
});
render(<LoginPage />);
fireEvent.change(screen.getByLabelText('登录密码'), { target: { value: 'passwd6' } });
fireEvent.click(screen.getByRole('button', { name: '授权进入工作台' }));
await waitFor(() => expect(navigate).toHaveBeenCalledWith('/settings', { replace: true }));
});
});

View File

@@ -0,0 +1,307 @@
import type React from 'react';
import { fireEvent, render, screen } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import SettingsPage from '../SettingsPage';
const {
load,
clearToast,
setActiveCategory,
save,
resetDraft,
setDraftValue,
applyPartialUpdate,
refreshAfterExternalSave,
refreshStatus,
useAuthMock,
useSystemConfigMock,
} = vi.hoisted(() => ({
load: vi.fn(),
clearToast: vi.fn(),
setActiveCategory: vi.fn(),
save: vi.fn(),
resetDraft: vi.fn(),
setDraftValue: vi.fn(),
applyPartialUpdate: vi.fn(),
refreshAfterExternalSave: vi.fn(),
refreshStatus: vi.fn(),
useAuthMock: vi.fn(),
useSystemConfigMock: vi.fn(),
}));
vi.mock('../../hooks', () => ({
useAuth: () => useAuthMock(),
useSystemConfig: () => useSystemConfigMock(),
}));
vi.mock('../../components/settings', () => ({
AuthSettingsCard: () => <div></div>,
ChangePasswordCard: () => <div></div>,
IntelligentImport: ({ onMerged }: { onMerged: (value: string) => void }) => (
<button type="button" onClick={() => onMerged('SZ000001,SZ000002')}>
merge stock list
</button>
),
LLMChannelEditor: ({
onSaved,
}: {
onSaved: (items: Array<{ key: string; value: string }>) => void;
}) => (
<button
type="button"
onClick={() => onSaved([{ key: 'LLM_CHANNELS', value: 'primary,backup' }])}
>
save llm channels
</button>
),
SettingsAlert: ({ title, message }: { title: string; message: string }) => (
<div>
{title}:{message}
</div>
),
SettingsCategoryNav: ({
categories,
activeCategory,
onSelect,
}: {
categories: Array<{ category: string; title: string }>;
activeCategory: string;
onSelect: (value: string) => void;
}) => (
<nav>
{categories.map((category) => (
<button
key={category.category}
type="button"
aria-pressed={activeCategory === category.category}
onClick={() => onSelect(category.category)}
>
{category.title}
</button>
))}
</nav>
),
SettingsField: ({ item }: { item: { key: string } }) => <div>{item.key}</div>,
SettingsLoading: () => <div>loading</div>,
SettingsSectionCard: ({
title,
description,
children,
}: {
title: string;
description?: string;
children: React.ReactNode;
}) => (
<section>
<h2>{title}</h2>
{description ? <p>{description}</p> : null}
{children}
</section>
),
}));
const baseCategories = [
{ category: 'system', title: 'System', description: '系统设置', displayOrder: 1, fields: [] },
{ category: 'base', title: 'Base', description: '基础配置', displayOrder: 2, fields: [] },
{ category: 'ai_model', title: 'AI', description: '模型配置', displayOrder: 3, fields: [] },
];
type ConfigState = {
categories: Array<{ category: string; title: string; description: string; displayOrder: number; fields: [] }>;
itemsByCategory: Record<string, Array<Record<string, unknown>>>;
issueByKey: Record<string, unknown[]>;
activeCategory: string;
setActiveCategory: typeof setActiveCategory;
hasDirty: boolean;
dirtyCount: number;
toast: null;
clearToast: typeof clearToast;
isLoading: boolean;
isSaving: boolean;
loadError: null;
saveError: null;
retryAction: null;
load: typeof load;
retry: ReturnType<typeof vi.fn>;
save: typeof save;
resetDraft: typeof resetDraft;
setDraftValue: typeof setDraftValue;
applyPartialUpdate: typeof applyPartialUpdate;
refreshAfterExternalSave: typeof refreshAfterExternalSave;
configVersion: string;
maskToken: string;
};
type ConfigOverride = Partial<ConfigState>;
function buildSystemConfigState(overrides: ConfigOverride = {}) {
return {
categories: baseCategories,
itemsByCategory: {
system: [
{
key: 'ADMIN_AUTH_ENABLED',
value: 'true',
rawValueExists: true,
isMasked: false,
schema: {
key: 'ADMIN_AUTH_ENABLED',
category: 'system',
dataType: 'boolean',
uiControl: 'switch',
isSensitive: false,
isRequired: false,
isEditable: true,
options: [],
validation: {},
displayOrder: 1,
},
},
],
base: [
{
key: 'STOCK_LIST',
value: 'SH600000',
rawValueExists: true,
isMasked: false,
schema: {
key: 'STOCK_LIST',
category: 'base',
dataType: 'string',
uiControl: 'textarea',
isSensitive: false,
isRequired: false,
isEditable: true,
options: [],
validation: {},
displayOrder: 1,
},
},
],
ai_model: [
{
key: 'LLM_CHANNELS',
value: 'primary',
rawValueExists: true,
isMasked: false,
schema: {
key: 'LLM_CHANNELS',
category: 'ai_model',
dataType: 'string',
uiControl: 'textarea',
isSensitive: false,
isRequired: false,
isEditable: true,
options: [],
validation: {},
displayOrder: 1,
},
},
],
},
issueByKey: {},
activeCategory: 'system',
setActiveCategory,
hasDirty: false,
dirtyCount: 0,
toast: null,
clearToast,
isLoading: false,
isSaving: false,
loadError: null,
saveError: null,
retryAction: null,
load,
retry: vi.fn(),
save,
resetDraft,
setDraftValue,
applyPartialUpdate,
refreshAfterExternalSave,
configVersion: 'v1',
maskToken: '******',
...overrides,
};
}
describe('SettingsPage', () => {
beforeEach(() => {
vi.clearAllMocks();
useAuthMock.mockReturnValue({
authEnabled: true,
passwordChangeable: true,
refreshStatus,
});
useSystemConfigMock.mockReturnValue(buildSystemConfigState());
});
it('renders category navigation and auth settings modules', async () => {
render(<SettingsPage />);
expect(await screen.findByRole('heading', { name: '系统设置' })).toBeInTheDocument();
expect(screen.getByText('认证与登录保护')).toBeInTheDocument();
expect(screen.getByText('修改密码')).toBeInTheDocument();
expect(load).toHaveBeenCalled();
});
it('resets local drafts from the page header button', () => {
useSystemConfigMock.mockReturnValue(buildSystemConfigState({ hasDirty: true, dirtyCount: 2 }));
render(<SettingsPage />);
// Clear the initial load call from useEffect
vi.clearAllMocks();
fireEvent.click(screen.getByRole('button', { name: '重置' }));
// Reset should call resetDraft and NOT call load
expect(resetDraft).toHaveBeenCalledTimes(1);
expect(load).not.toHaveBeenCalled();
});
it('reset button semantic: discards local changes without network request', () => {
// Simulate user has unsaved drafts
const dirtyState = buildSystemConfigState({
hasDirty: true,
dirtyCount: 2,
});
useSystemConfigMock.mockReturnValue(dirtyState);
render(<SettingsPage />);
// Clear initial useEffect load call
vi.clearAllMocks();
// Click reset button
fireEvent.click(screen.getByRole('button', { name: '重置' }));
// Verify semantic: reset should only discard local changes
// It should NOT trigger a network load
expect(resetDraft).toHaveBeenCalledTimes(1);
expect(load).not.toHaveBeenCalled();
expect(save).not.toHaveBeenCalled();
});
it('refreshes server state after intelligent import merges stock list', async () => {
useSystemConfigMock.mockReturnValue(buildSystemConfigState({ activeCategory: 'base' }));
render(<SettingsPage />);
fireEvent.click(screen.getByRole('button', { name: 'merge stock list' }));
expect(refreshAfterExternalSave).toHaveBeenCalledWith(['STOCK_LIST']);
expect(load).toHaveBeenCalledTimes(1);
});
it('refreshes server state after llm channel editor saves', async () => {
useSystemConfigMock.mockReturnValue(buildSystemConfigState({ activeCategory: 'ai_model' }));
render(<SettingsPage />);
fireEvent.click(screen.getByRole('button', { name: 'save llm channels' }));
expect(refreshAfterExternalSave).toHaveBeenCalledWith(['LLM_CHANNELS']);
expect(load).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,22 @@
import '@testing-library/jest-dom';
class IntersectionObserverMock implements IntersectionObserver {
readonly root = null;
readonly rootMargin = '';
readonly thresholds = [0];
disconnect() {}
observe() {}
takeRecords(): IntersectionObserverEntry[] {
return [];
}
unobserve() {}
}
Object.defineProperty(globalThis, 'IntersectionObserver', {
writable: true,
value: IntersectionObserverMock,
});

View File

@@ -0,0 +1,80 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { useAgentChatStore } from '../agentChatStore';
vi.mock('../../api/agent', () => ({
agentApi: {
getChatSessions: vi.fn(async () => []),
getChatSessionMessages: vi.fn(async () => []),
chatStream: vi.fn(),
},
}));
const { agentApi } = await import('../../api/agent');
const encoder = new TextEncoder();
function createStreamResponse(lines: string[]) {
return new Response(
new ReadableStream({
start(controller) {
controller.enqueue(encoder.encode(lines.join('\n')));
controller.close();
},
}),
{
status: 200,
headers: { 'Content-Type': 'text/event-stream' },
},
);
}
describe('agentChatStore.startStream', () => {
beforeEach(() => {
localStorage.clear();
useAgentChatStore.setState({
messages: [],
loading: false,
progressSteps: [],
sessionId: 'session-test',
sessions: [],
sessionsLoading: false,
chatError: null,
currentRoute: '/chat',
completionBadge: false,
hasInitialLoad: true,
abortController: null,
});
vi.clearAllMocks();
});
it('appends the user message and final assistant message from the SSE stream', async () => {
vi.mocked(agentApi.chatStream).mockResolvedValue(
createStreamResponse([
'data: {"type":"thinking","step":1,"message":"分析中"}',
'data: {"type":"tool_done","tool":"quote","display_name":"行情","success":true,"duration":0.3}',
'data: {"type":"done","success":true,"content":"最终分析结果"}',
]),
);
await useAgentChatStore
.getState()
.startStream({ message: '分析茅台', session_id: 'session-test' }, { strategyName: '趋势策略' });
const state = useAgentChatStore.getState();
expect(state.loading).toBe(false);
expect(state.chatError).toBeNull();
expect(state.messages).toHaveLength(2);
expect(state.messages[0]).toMatchObject({
role: 'user',
content: '分析茅台',
strategyName: '趋势策略',
});
expect(state.messages[1]).toMatchObject({
role: 'assistant',
content: '最终分析结果',
strategyName: '趋势策略',
});
expect(state.messages[1].thinkingSteps).toHaveLength(2);
expect(state.progressSteps).toEqual([]);
});
});

View File

@@ -216,6 +216,42 @@ export const useAgentChatStore = create<AgentChatState & AgentChatActions>((set,
let buf = '';
let finalContent: string | null = null;
const currentProgressSteps: ProgressStep[] = [];
const processLine = (line: string) => {
if (!line.startsWith('data: ')) return;
const event = JSON.parse(line.slice(6)) as ProgressStep;
if (event.type === 'done') {
const doneEvent = event as unknown as {
type: string;
success: boolean;
content?: string;
error?: string;
};
if (doneEvent.success === false) {
const parsedStreamError = getParsedApiError(
doneEvent.error ||
doneEvent.content ||
'大模型调用出错,请检查 API Key 配置',
);
throw createParsedApiError({
title: '问股执行失败',
message: parsedStreamError.message,
rawMessage: parsedStreamError.rawMessage,
status: parsedStreamError.status,
category: parsedStreamError.category,
});
}
finalContent = doneEvent.content ?? '';
return;
}
if (event.type === 'error') {
throw getParsedApiError(event.message || '分析出错');
}
currentProgressSteps.push(event);
set((s) => ({ progressSteps: [...s.progressSteps, event] }));
};
while (true) {
const { done, value } = await reader.read();
@@ -225,37 +261,8 @@ export const useAgentChatStore = create<AgentChatState & AgentChatActions>((set,
buf = lines.pop() ?? '';
for (const line of lines) {
if (!line.startsWith('data: ')) continue;
try {
const event = JSON.parse(line.slice(6)) as ProgressStep;
if (event.type === 'done') {
const doneEvent = event as unknown as {
type: string;
success: boolean;
content?: string;
error?: string;
};
if (doneEvent.success === false) {
const parsedStreamError = getParsedApiError(
doneEvent.error ||
doneEvent.content ||
'大模型调用出错,请检查 API Key 配置',
);
throw createParsedApiError({
title: '问股执行失败',
message: parsedStreamError.message,
rawMessage: parsedStreamError.rawMessage,
status: parsedStreamError.status,
category: parsedStreamError.category,
});
}
finalContent = doneEvent.content ?? '';
} else if (event.type === 'error') {
throw getParsedApiError(event.message || '分析出错');
} else {
currentProgressSteps.push(event);
set((s) => ({ progressSteps: [...s.progressSteps, event] }));
}
processLine(line);
} catch (parseErr: unknown) {
if (isParsedApiError(parseErr) || isApiRequestError(parseErr)) {
throw parseErr;
@@ -264,6 +271,16 @@ export const useAgentChatStore = create<AgentChatState & AgentChatActions>((set,
}
}
if (buf.trim().startsWith('data: ')) {
try {
processLine(buf.trim());
} catch (parseErr: unknown) {
if (isParsedApiError(parseErr) || isApiRequestError(parseErr)) {
throw parseErr;
}
}
}
const { sessionId: currentSessionId, currentRoute } = get();
const shouldAppend =
currentSessionId === streamSessionId && !ac.signal.aborted;

View File

@@ -0,0 +1,24 @@
import { describe, expect, it } from 'vitest';
import { isNearBottom } from '../chatScroll';
describe('isNearBottom', () => {
it('returns true when the viewport is within the default threshold', () => {
expect(
isNearBottom({
scrollTop: 604,
clientHeight: 320,
scrollHeight: 1000,
}),
).toBe(true);
});
it('returns false when the viewport is far from the bottom', () => {
expect(
isNearBottom({
scrollTop: 240,
clientHeight: 320,
scrollHeight: 1000,
}),
).toBe(false);
});
});

View File

@@ -0,0 +1,17 @@
interface ScrollMetrics {
scrollTop: number;
clientHeight: number;
scrollHeight: number;
}
const DEFAULT_THRESHOLD = 96;
export function isNearBottom(
metrics: ScrollMetrics,
threshold = DEFAULT_THRESHOLD,
): boolean {
const distanceFromBottom =
metrics.scrollHeight - (metrics.scrollTop + metrics.clientHeight);
return distanceFromBottom <= threshold;
}

View File

@@ -1,2 +1,5 @@
// 生产环境使用相对路径(同源),开发环境使用环境变量或默认本地地址
export const API_BASE_URL = import.meta.env.VITE_API_URL || (import.meta.env.PROD ? '' : 'http://127.0.0.1:8000');
const configuredApiBaseUrl = import.meta.env.VITE_API_URL?.trim();
// 默认保持同源 API避免生产/静态部署时把请求错误打到用户本机 localhost。
// 仅在显式提供 VITE_API_URL 时才覆盖默认行为。
export const API_BASE_URL = configuredApiBaseUrl || '';

View File

@@ -93,6 +93,8 @@ export default {
'glow-purple': '0 0 20px rgba(168, 85, 247, 0.3)',
'glow-success': '0 0 20px rgba(0, 255, 136, 0.3)',
'glow-danger': '0 0 20px rgba(255, 68, 102, 0.3)',
'cyan/20': '0 12px 28px rgba(0, 212, 255, 0.2)',
'cyan/22': '0 18px 34px rgba(0, 212, 255, 0.22)',
},
borderRadius: {
lg: 'var(--radius)',

View File

@@ -14,6 +14,12 @@ export default defineConfig({
server: {
host: '0.0.0.0', // 允许公网访问
port: 5173, // 默认端口
proxy: {
'/api': {
target: 'http://127.0.0.1:8000',
changeOrigin: true,
},
},
},
build: {
// 打包输出到项目根目录的 static 文件夹

View File

@@ -0,0 +1,12 @@
import { configDefaults, defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom',
globals: true,
setupFiles: './src/setupTests.ts',
exclude: [...configDefaults.exclude, 'e2e/**', 'playwright.config.ts'],
},
});

View File

@@ -18,8 +18,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
- 新增云服务器 Web 界面部署与访问教程 (Fixes #686)
### Added
- **Web UI foundation refresh** — rebuilt shared design tokens and common primitives, introduced the app shell, theme provider, sidebar navigation, and Electron loading background alignment for the upgraded desktop/web experience
- **Settings and auth workflow overhaul** — rebuilt the Login, Settings, and Auth management flows, added explicit auth setup-state handling, and aligned the Web UI with the runtime auth configuration APIs
- **UI regression coverage and smoke checks** — expanded targeted frontend tests and added Playwright smoke coverage for login, home, chat, mobile shell, settings, and backtest entry flows
### Changed
- **Shell-driven page integration** — aligned Home, Chat, Settings, and Backtest with the new shell layout contract so routing, drawer behavior, and page-level scrolling are consistent during the UI migration
- **Settings state consistency** — refined draft preservation, direct-save synchronization, and conflict handling so module-level saves no longer leave the page out of sync with backend config state
- **Login visual baseline** — restored the login page visual treatment to the established `006` branch baseline while keeping the newer auth-state logic and unified form interaction model
### 修复
- 🔐 **退出登录立即失效现有会话**`POST /api/v1/auth/logout` 现在会轮换 session secret避免旧 cookie 在退出后仍可继续访问受保护接口;同浏览器标签页和并发页面会被同步登出。
- 💼 **持仓超售拦截与事件删除恢复**#718)— `POST /api/v1/portfolio/trades` 现在会在写入前校验可卖数量,超售返回 `409 portfolio_oversell`;持仓页新增交易 / 资金流水 / 公司行为删除能力,删除后会同步失效仓位缓存与未来快照,便于从错误流水中直接恢复。
- 📧 **邮件中文发件人名编码**#708)— 邮件通知现在会对包含中文的 `EMAIL_SENDER_NAME` 自动做 RFC 2047 编码,并在异常路径补充 SMTP 连接清理,修复 GitHub Actions / QQ SMTP 下 `'ascii' codec can't encode characters` 导致的发送失败。
- 🐛 **港股 Agent 实时行情去重与快速路由** — 统一 `HK01810` / `1810.HK` / `01810` 等港股代码归一规则;港股实时行情改为直接走单次 `akshare_hk` 路径,避免按 A 股 source priority 重复触发同一失败接口Agent 运行期对显式 `retriable=false` 的工具失败增加短路缓存,减少同轮分析中的重复失败调用。

View File

@@ -143,6 +143,30 @@ class AuthApiTestCase(unittest.TestCase):
self.assertEqual(response.status_code, 204)
self.assertIn("dsa_session=", response.headers["set-cookie"])
def test_logout_invalidates_existing_session(self) -> None:
login_response = asyncio.run(
auth_endpoint.auth_login(
self._build_request(),
auth_endpoint.LoginRequest(password="passwd6", passwordConfirm="passwd6"),
)
)
self.assertEqual(login_response.status_code, 200)
cookie_header = login_response.headers["set-cookie"]
session_cookie = cookie_header.split("dsa_session=", 1)[1].split(";", 1)[0]
self.assertTrue(auth.verify_session(session_cookie))
logout_response = asyncio.run(auth_endpoint.auth_logout(self._build_request()))
self.assertEqual(logout_response.status_code, 204)
self.assertFalse(auth.verify_session(session_cookie))
def test_logout_returns_500_when_session_invalidation_fails(self) -> None:
with patch.object(auth_endpoint, "rotate_session_secret", return_value=False):
response = asyncio.run(auth_endpoint.auth_logout(self._build_request()))
self.assertEqual(response.status_code, 500)
self.assertIn(b'"error":"internal_error"', response.body)
def test_change_password_requires_session(self) -> None:
first_response = asyncio.run(
auth_endpoint.auth_login(

View File

@@ -0,0 +1,138 @@
# -*- coding: utf-8 -*-
"""Unit tests for Auth setupState contract in /auth/status and /auth/settings."""
import asyncio
import json
import os
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
from starlette.requests import Request
import src.auth as auth
from api.v1.endpoints.auth import AuthSettingsRequest, auth_status, auth_update_settings
def _reset_auth_globals() -> None:
"""Reset auth module globals for test isolation."""
auth._auth_enabled = None
auth._session_secret = None
auth._password_hash_salt = None
auth._password_hash_stored = None
auth._rate_limit = {}
def _make_request(*, cookies: dict[str, str] | None = None) -> Request:
"""Create a minimal Starlette request for endpoint unit tests."""
headers: list[tuple[bytes, bytes]] = []
if cookies:
cookie_header = "; ".join(f"{key}={value}" for key, value in cookies.items())
headers.append((b"cookie", cookie_header.encode("utf-8")))
scope = {
"type": "http",
"asgi": {"version": "3.0"},
"http_version": "1.1",
"method": "GET",
"scheme": "http",
"path": "/api/v1/auth/status",
"raw_path": b"/api/v1/auth/status",
"query_string": b"",
"headers": headers,
"client": ("127.0.0.1", 12345),
"server": ("testserver", 80),
}
return Request(scope)
class AuthStatusSetupStateTestCase(unittest.TestCase):
def setUp(self) -> None:
_reset_auth_globals()
self.temp_dir = tempfile.TemporaryDirectory()
self.data_dir = Path(self.temp_dir.name)
self._data_dir_patcher = patch.object(auth, "_get_data_dir", return_value=self.data_dir)
self._data_dir_patcher.start()
self.env_path = self.data_dir / ".env"
self.env_path.write_text("ADMIN_AUTH_ENABLED=false\n", encoding="utf-8")
self._env_patcher = patch.dict(os.environ, {"ENV_FILE": str(self.env_path)})
self._env_patcher.start()
def tearDown(self) -> None:
self._env_patcher.stop()
self._data_dir_patcher.stop()
_reset_auth_globals()
self.temp_dir.cleanup()
def test_status_no_password(self) -> None:
"""Scenario: Auth disabled and no password set."""
request = _make_request()
with patch("api.v1.endpoints.auth.is_auth_enabled", return_value=False):
with patch("src.auth.is_auth_enabled", return_value=False):
data = asyncio.run(auth_status(request))
self.assertEqual(data["setupState"], "no_password")
self.assertFalse(data["authEnabled"])
def test_status_password_retained(self) -> None:
"""Scenario: Auth disabled but password exists on disk."""
auth.set_initial_password("password123")
request = _make_request()
with patch("api.v1.endpoints.auth.is_auth_enabled", return_value=False):
with patch("src.auth.is_auth_enabled", return_value=False):
data = asyncio.run(auth_status(request))
self.assertEqual(data["setupState"], "password_retained")
self.assertFalse(data["authEnabled"])
self.assertFalse(data["passwordSet"])
def test_status_enabled(self) -> None:
"""Scenario: Auth enabled."""
auth.set_initial_password("password123")
request = _make_request()
with patch("api.v1.endpoints.auth.is_auth_enabled", return_value=True):
with patch("src.auth.is_auth_enabled", return_value=True):
data = asyncio.run(auth_status(request))
self.assertEqual(data["setupState"], "enabled")
self.assertTrue(data["authEnabled"])
self.assertTrue(data["passwordSet"])
def test_settings_update_returns_setup_state(self) -> None:
"""Verify that /auth/settings also returns setupState in response."""
request = _make_request()
body = AuthSettingsRequest(
authEnabled=True,
password="newpassword123",
passwordConfirm="newpassword123",
)
with patch("api.v1.endpoints.auth.is_auth_enabled") as mock_endpoint_enabled:
with patch("src.auth.is_auth_enabled") as mock_src_enabled:
mock_src_enabled.return_value = False
mock_endpoint_enabled.return_value = False
with patch("api.v1.endpoints.auth._apply_auth_enabled", return_value=True):
with patch("api.v1.endpoints.auth.rotate_session_secret", return_value=True):
with patch("api.v1.endpoints.auth.create_session", return_value="mock.session.sig"):
with patch("api.v1.endpoints.auth._get_auth_status_dict") as mock_status_dict:
mock_status_dict.return_value = {
"authEnabled": True,
"loggedIn": True,
"passwordSet": True,
"passwordChangeable": True,
"setupState": "enabled",
}
response = asyncio.run(auth_update_settings(request, body))
self.assertEqual(response.status_code, 200)
data = json.loads(response.body)
self.assertEqual(data["setupState"], "enabled")
self.assertTrue(data["authEnabled"])
if __name__ == "__main__":
unittest.main()