mirror of
https://github.com/ZhuLinsen/daily_stock_analysis
synced 2026-09-20 10:53:33 +08:00
* 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:
@@ -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) 一起完善。
|
||||
---
|
||||
|
||||
## ☕ 支持项目
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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;
|
||||
|
||||
2
apps/dsa-web/.gitignore
vendored
2
apps/dsa-web/.gitignore
vendored
@@ -11,6 +11,8 @@ node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
playwright-report
|
||||
test-results
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
|
||||
131
apps/dsa-web/e2e/smoke.spec.ts
Normal file
131
apps/dsa-web/e2e/smoke.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
1426
apps/dsa-web/package-lock.json
generated
1426
apps/dsa-web/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
60
apps/dsa-web/playwright.config.ts
Normal file
60
apps/dsa-web/playwright.config.ts
Normal 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'] },
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -5,6 +5,7 @@ export type AuthStatusResponse = {
|
||||
loggedIn: boolean;
|
||||
passwordSet?: boolean;
|
||||
passwordChangeable?: boolean;
|
||||
setupState: 'enabled' | 'password_retained' | 'no_password';
|
||||
};
|
||||
|
||||
export const authApi = {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
46
apps/dsa-web/src/components/common/Checkbox.tsx
Normal file
46
apps/dsa-web/src/components/common/Checkbox.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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',
|
||||
)}
|
||||
>
|
||||
|
||||
158
apps/dsa-web/src/components/common/ParticleBackground.tsx
Normal file
158
apps/dsa-web/src/components/common/ParticleBackground.tsx
Normal 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' }}
|
||||
/>
|
||||
);
|
||||
};
|
||||
33
apps/dsa-web/src/components/common/ScrollArea.tsx
Normal file
33
apps/dsa-web/src/components/common/ScrollArea.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
29
apps/dsa-web/src/components/common/__tests__/Button.test.tsx
Normal file
29
apps/dsa-web/src/components/common/__tests__/Button.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
71
apps/dsa-web/src/components/common/__tests__/Input.test.tsx
Normal file
71
apps/dsa-web/src/components/common/__tests__/Input.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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';
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
80
apps/dsa-web/src/components/layout/Shell.tsx
Normal file
80
apps/dsa-web/src/components/layout/Shell.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
57
apps/dsa-web/src/components/layout/ShellHeader.tsx
Normal file
57
apps/dsa-web/src/components/layout/ShellHeader.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
131
apps/dsa-web/src/components/layout/SidebarNav.tsx
Normal file
131
apps/dsa-web/src/components/layout/SidebarNav.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
90
apps/dsa-web/src/components/layout/__tests__/Shell.test.tsx
Normal file
90
apps/dsa-web/src/components/layout/__tests__/Shell.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
213
apps/dsa-web/src/components/settings/AuthSettingsCard.tsx
Normal file
213
apps/dsa-web/src/components/settings/AuthSettingsCard.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">主模型、Fallback、Vision 与 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}
|
||||
|
||||
69
apps/dsa-web/src/components/settings/SettingsCategoryNav.tsx
Normal file
69
apps/dsa-web/src/components/settings/SettingsCategoryNav.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
31
apps/dsa-web/src/components/settings/SettingsSectionCard.tsx
Normal file
31
apps/dsa-web/src/components/settings/SettingsSectionCard.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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';
|
||||
|
||||
19
apps/dsa-web/src/components/theme/ThemeProvider.tsx
Normal file
19
apps/dsa-web/src/components/theme/ThemeProvider.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
133
apps/dsa-web/src/components/theme/ThemeToggle.tsx
Normal file
133
apps/dsa-web/src/components/theme/ThemeToggle.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
99
apps/dsa-web/src/contexts/__tests__/AuthContext.test.tsx
Normal file
99
apps/dsa-web/src/contexts/__tests__/AuthContext.test.tsx
Normal 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'));
|
||||
});
|
||||
});
|
||||
77
apps/dsa-web/src/hooks/__tests__/useSystemConfig.test.tsx
Normal file
77
apps/dsa-web/src/hooks/__tests__/useSystemConfig.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>,
|
||||
)
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
130
apps/dsa-web/src/pages/__tests__/ChatPage.test.tsx
Normal file
130
apps/dsa-web/src/pages/__tests__/ChatPage.test.tsx
Normal 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');
|
||||
});
|
||||
});
|
||||
62
apps/dsa-web/src/pages/__tests__/LoginPage.test.tsx
Normal file
62
apps/dsa-web/src/pages/__tests__/LoginPage.test.tsx
Normal 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 }));
|
||||
});
|
||||
});
|
||||
307
apps/dsa-web/src/pages/__tests__/SettingsPage.test.tsx
Normal file
307
apps/dsa-web/src/pages/__tests__/SettingsPage.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
22
apps/dsa-web/src/setupTests.ts
Normal file
22
apps/dsa-web/src/setupTests.ts
Normal 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,
|
||||
});
|
||||
80
apps/dsa-web/src/stores/__tests__/agentChatStore.test.ts
Normal file
80
apps/dsa-web/src/stores/__tests__/agentChatStore.test.ts
Normal 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([]);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
24
apps/dsa-web/src/utils/__tests__/chatScroll.test.ts
Normal file
24
apps/dsa-web/src/utils/__tests__/chatScroll.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
17
apps/dsa-web/src/utils/chatScroll.ts
Normal file
17
apps/dsa-web/src/utils/chatScroll.ts
Normal 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;
|
||||
}
|
||||
@@ -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 || '';
|
||||
|
||||
@@ -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)',
|
||||
|
||||
@@ -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 文件夹
|
||||
|
||||
12
apps/dsa-web/vitest.config.ts
Normal file
12
apps/dsa-web/vitest.config.ts
Normal 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'],
|
||||
},
|
||||
});
|
||||
@@ -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` 的工具失败增加短路缓存,减少同轮分析中的重复失败调用。
|
||||
|
||||
@@ -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(
|
||||
|
||||
138
tests/test_auth_status_setup_state.py
Normal file
138
tests/test_auth_status_setup_state.py
Normal 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()
|
||||
Reference in New Issue
Block a user