feat: 新增 Web 告警中心 MVP(P3) (#1334)

* feat: add web alert center MVP

* fix: guard alert rule list request ordering
This commit is contained in:
Alfred
2026-05-18 18:37:48 +08:00
committed by GitHub
parent 47f938f32d
commit a872a9e03e
21 changed files with 1715 additions and 4 deletions

View File

@@ -8,6 +8,7 @@ import LoginPage from './pages/LoginPage';
import NotFoundPage from './pages/NotFoundPage';
import ChatPage from './pages/ChatPage';
import PortfolioPage from './pages/PortfolioPage';
import AlertsPage from './pages/AlertsPage';
import { ApiErrorAlert, Shell } from './components/common';
import { AuthProvider, useAuth } from './contexts/AuthContext';
import { useAgentChatStore } from './stores/agentChatStore';
@@ -65,6 +66,7 @@ const AppContent: React.FC = () => {
<Route path="/chat" element={<ChatPage />} />
<Route path="/portfolio" element={<PortfolioPage />} />
<Route path="/backtest" element={<BacktestPage />} />
<Route path="/alerts" element={<AlertsPage />} />
<Route path="/settings" element={<SettingsPage />} />
<Route path="*" element={<NotFoundPage />} />
</Route>

View File

@@ -0,0 +1,142 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { alertsApi } from '../alerts';
const { get, post, deleteRequest } = vi.hoisted(() => ({
get: vi.fn(),
post: vi.fn(),
deleteRequest: vi.fn(),
}));
vi.mock('../index', () => ({
default: {
get,
post,
delete: deleteRequest,
},
}));
describe('alertsApi', () => {
beforeEach(() => {
get.mockReset();
post.mockReset();
deleteRequest.mockReset();
});
it('lists rules with snake_case query params and camelCase response fields', async () => {
get.mockResolvedValueOnce({
data: {
items: [
{
id: 1,
name: 'price rule',
target_scope: 'single_symbol',
target: '600519',
alert_type: 'price_cross',
parameters: { direction: 'above', price: 1800 },
severity: 'warning',
enabled: true,
source: 'api',
created_at: '2026-05-18T09:00:00',
updated_at: '2026-05-18T09:10:00',
},
],
total: 1,
page: 1,
page_size: 20,
},
});
const result = await alertsApi.listRules({
enabled: true,
alertType: 'price_cross',
targetScope: 'single_symbol',
page: 1,
pageSize: 20,
});
expect(get).toHaveBeenCalledWith('/api/v1/alerts/rules', {
params: {
enabled: true,
alert_type: 'price_cross',
target_scope: 'single_symbol',
page: 1,
page_size: 20,
},
});
expect(result.pageSize).toBe(20);
expect(result.items[0].targetScope).toBe('single_symbol');
expect(result.items[0].alertType).toBe('price_cross');
expect(result.items[0].updatedAt).toBe('2026-05-18T09:10:00');
});
it('creates rules with snake_case payload fields', async () => {
post.mockResolvedValueOnce({
data: {
id: 2,
name: 'change rule',
target_scope: 'single_symbol',
target: 'AAPL',
alert_type: 'price_change_percent',
parameters: { direction: 'down', change_pct: 3 },
severity: 'critical',
enabled: true,
source: 'api',
},
});
const created = await alertsApi.createRule({
name: 'change rule',
targetScope: 'single_symbol',
target: 'AAPL',
alertType: 'price_change_percent',
parameters: { direction: 'down', changePct: 3 },
severity: 'critical',
enabled: true,
});
expect(post).toHaveBeenCalledWith('/api/v1/alerts/rules', {
name: 'change rule',
target_scope: 'single_symbol',
target: 'AAPL',
alert_type: 'price_change_percent',
parameters: { direction: 'down', change_pct: 3 },
severity: 'critical',
enabled: true,
});
expect(created.parameters.changePct).toBe(3);
});
it('deletes, toggles, tests, and lists history endpoints', async () => {
deleteRequest.mockResolvedValueOnce({ data: { deleted: 1 } });
post
.mockResolvedValueOnce({ data: { id: 3, name: 'enabled', target_scope: 'single_symbol', target: 'MSFT', alert_type: 'volume_spike', parameters: { multiplier: 2 }, severity: 'warning', enabled: true, source: 'api' } })
.mockResolvedValueOnce({ data: { id: 3, name: 'disabled', target_scope: 'single_symbol', target: 'MSFT', alert_type: 'volume_spike', parameters: { multiplier: 2 }, severity: 'warning', enabled: false, source: 'api' } })
.mockResolvedValueOnce({ data: { rule_id: 3, status: 'not_triggered', triggered: false, observed_value: 1.2, message: 'not triggered' } });
get
.mockResolvedValueOnce({ data: { items: [{ id: 10, rule_id: 3, target: 'MSFT', status: 'skipped', observed_value: null, triggered_at: '2026-05-18T10:00:00' }], total: 1, page: 1, page_size: 20 } })
.mockResolvedValueOnce({ data: { items: [{ id: 11, trigger_id: 10, channel: 'wechat', attempt: 1, success: false, retryable: true, error_code: 'timeout', latency_ms: null }], total: 1, page: 1, page_size: 20 } });
await expect(alertsApi.deleteRule(3)).resolves.toEqual({ deleted: 1 });
await alertsApi.enableRule(3);
await alertsApi.disableRule(3);
const testResult = await alertsApi.testRule(3);
const triggers = await alertsApi.listTriggers({ ruleId: 3, status: 'skipped', page: 1, pageSize: 20 });
const notifications = await alertsApi.listNotifications({ triggerId: 10, success: false, page: 1, pageSize: 20 });
expect(deleteRequest).toHaveBeenCalledWith('/api/v1/alerts/rules/3');
expect(post).toHaveBeenNthCalledWith(1, '/api/v1/alerts/rules/3/enable');
expect(post).toHaveBeenNthCalledWith(2, '/api/v1/alerts/rules/3/disable');
expect(post).toHaveBeenNthCalledWith(3, '/api/v1/alerts/rules/3/test');
expect(testResult.ruleId).toBe(3);
expect(testResult.observedValue).toBe(1.2);
expect(get).toHaveBeenNthCalledWith(1, '/api/v1/alerts/triggers', {
params: { rule_id: 3, status: 'skipped', page: 1, page_size: 20 },
});
expect(get).toHaveBeenNthCalledWith(2, '/api/v1/alerts/notifications', {
params: { trigger_id: 10, success: false, page: 1, page_size: 20 },
});
expect(triggers.items[0].ruleId).toBe(3);
expect(notifications.items[0].triggerId).toBe(10);
expect(notifications.items[0].errorCode).toBe('timeout');
});
});

View File

@@ -0,0 +1,122 @@
import apiClient from './index';
import { toCamelCase } from './utils';
import type {
AlertDeleteResponse,
AlertNotificationListQuery,
AlertNotificationListResponse,
AlertRuleCreateRequest,
AlertRuleItem,
AlertRuleListQuery,
AlertRuleListResponse,
AlertRuleTestResponse,
AlertTriggerListQuery,
AlertTriggerListResponse,
} from '../types/alerts';
function omitUndefined(input: Record<string, unknown>): Record<string, unknown> {
return Object.fromEntries(
Object.entries(input).filter(([, value]) => value !== undefined),
);
}
function toSnakeRulePayload(payload: AlertRuleCreateRequest): Record<string, unknown> {
const request: Record<string, unknown> = {};
if (payload.name !== undefined) request.name = payload.name;
if (payload.targetScope !== undefined) request.target_scope = payload.targetScope;
if (payload.target !== undefined) request.target = payload.target;
if (payload.alertType !== undefined) request.alert_type = payload.alertType;
if (payload.severity !== undefined) request.severity = payload.severity;
if (payload.enabled !== undefined) request.enabled = payload.enabled;
if (payload.parameters !== undefined) {
request.parameters = omitUndefined({
direction: payload.parameters.direction,
price: payload.parameters.price,
change_pct: payload.parameters.changePct,
multiplier: payload.parameters.multiplier,
});
}
return request;
}
function toRuleListParams(query: AlertRuleListQuery = {}): Record<string, string | number | boolean> {
const params: Record<string, string | number | boolean> = {};
if (query.enabled !== undefined) params.enabled = query.enabled;
if (query.alertType) params.alert_type = query.alertType;
if (query.targetScope) params.target_scope = query.targetScope;
if (query.target) params.target = query.target;
if (query.source) params.source = query.source;
if (query.page !== undefined) params.page = query.page;
if (query.pageSize !== undefined) params.page_size = query.pageSize;
return params;
}
function toTriggerListParams(query: AlertTriggerListQuery = {}): Record<string, string | number> {
const params: Record<string, string | number> = {};
if (query.ruleId !== undefined) params.rule_id = query.ruleId;
if (query.target) params.target = query.target;
if (query.status) params.status = query.status;
if (query.page !== undefined) params.page = query.page;
if (query.pageSize !== undefined) params.page_size = query.pageSize;
return params;
}
function toNotificationListParams(query: AlertNotificationListQuery = {}): Record<string, string | number | boolean> {
const params: Record<string, string | number | boolean> = {};
if (query.triggerId !== undefined) params.trigger_id = query.triggerId;
if (query.channel) params.channel = query.channel;
if (query.success !== undefined) params.success = query.success;
if (query.page !== undefined) params.page = query.page;
if (query.pageSize !== undefined) params.page_size = query.pageSize;
return params;
}
export const alertsApi = {
async listRules(query: AlertRuleListQuery = {}): Promise<AlertRuleListResponse> {
const response = await apiClient.get<Record<string, unknown>>('/api/v1/alerts/rules', {
params: toRuleListParams(query),
});
return toCamelCase<AlertRuleListResponse>(response.data);
},
async createRule(payload: AlertRuleCreateRequest): Promise<AlertRuleItem> {
const response = await apiClient.post<Record<string, unknown>>(
'/api/v1/alerts/rules',
toSnakeRulePayload(payload),
);
return toCamelCase<AlertRuleItem>(response.data);
},
async deleteRule(ruleId: number): Promise<AlertDeleteResponse> {
const response = await apiClient.delete<Record<string, unknown>>(`/api/v1/alerts/rules/${ruleId}`);
return toCamelCase<AlertDeleteResponse>(response.data);
},
async enableRule(ruleId: number): Promise<AlertRuleItem> {
const response = await apiClient.post<Record<string, unknown>>(`/api/v1/alerts/rules/${ruleId}/enable`);
return toCamelCase<AlertRuleItem>(response.data);
},
async disableRule(ruleId: number): Promise<AlertRuleItem> {
const response = await apiClient.post<Record<string, unknown>>(`/api/v1/alerts/rules/${ruleId}/disable`);
return toCamelCase<AlertRuleItem>(response.data);
},
async testRule(ruleId: number): Promise<AlertRuleTestResponse> {
const response = await apiClient.post<Record<string, unknown>>(`/api/v1/alerts/rules/${ruleId}/test`);
return toCamelCase<AlertRuleTestResponse>(response.data);
},
async listTriggers(query: AlertTriggerListQuery = {}): Promise<AlertTriggerListResponse> {
const response = await apiClient.get<Record<string, unknown>>('/api/v1/alerts/triggers', {
params: toTriggerListParams(query),
});
return toCamelCase<AlertTriggerListResponse>(response.data);
},
async listNotifications(query: AlertNotificationListQuery = {}): Promise<AlertNotificationListResponse> {
const response = await apiClient.get<Record<string, unknown>>('/api/v1/alerts/notifications', {
params: toNotificationListParams(query),
});
return toCamelCase<AlertNotificationListResponse>(response.data);
},
};

View File

@@ -0,0 +1,217 @@
import type React from 'react';
import { useState } from 'react';
import { Button, Card, Checkbox, Input, Select } from '../common';
import type { AlertRuleCreateRequest, AlertSeverity, AlertType } from '../../types/alerts';
import { validateStockCode } from '../../utils/validation';
const ALERT_TYPE_OPTIONS = [
{ value: 'price_cross', label: '价格突破' },
{ value: 'price_change_percent', label: '涨跌幅' },
{ value: 'volume_spike', label: '成交量放大' },
];
const SEVERITY_OPTIONS = [
{ value: 'info', label: '提示' },
{ value: 'warning', label: '警告' },
{ value: 'critical', label: '严重' },
];
const PRICE_DIRECTION_OPTIONS = [
{ value: 'above', label: '上破' },
{ value: 'below', label: '下破' },
];
const CHANGE_DIRECTION_OPTIONS = [
{ value: 'up', label: '上涨达到' },
{ value: 'down', label: '下跌达到' },
];
interface AlertRuleFormProps {
onSubmit: (payload: AlertRuleCreateRequest) => Promise<boolean | void> | boolean | void;
isSubmitting?: boolean;
}
export const AlertRuleForm: React.FC<AlertRuleFormProps> = ({ onSubmit, isSubmitting = false }) => {
const [name, setName] = useState('');
const [target, setTarget] = useState('');
const [alertType, setAlertType] = useState<AlertType>('price_cross');
const [severity, setSeverity] = useState<AlertSeverity>('warning');
const [enabled, setEnabled] = useState(true);
const [priceDirection, setPriceDirection] = useState<'above' | 'below'>('above');
const [changeDirection, setChangeDirection] = useState<'up' | 'down'>('up');
const [price, setPrice] = useState('');
const [changePct, setChangePct] = useState('');
const [multiplier, setMultiplier] = useState('');
const [formError, setFormError] = useState<string | null>(null);
const resetParameters = (nextType: AlertType) => {
if (nextType === 'price_cross') {
setPriceDirection('above');
setPrice('');
} else if (nextType === 'price_change_percent') {
setChangeDirection('up');
setChangePct('');
} else {
setMultiplier('');
}
};
const parsePositiveNumber = (value: string, label: string): number | null => {
const parsed = Number(value);
if (!Number.isFinite(parsed) || parsed <= 0) {
setFormError(`${label}必须是大于 0 的数字`);
return null;
}
return parsed;
};
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
const targetValidation = validateStockCode(target);
if (!targetValidation.valid) {
setFormError(targetValidation.message ?? '股票代码格式不正确');
return;
}
let parameters: AlertRuleCreateRequest['parameters'];
if (alertType === 'price_cross') {
const parsedPrice = parsePositiveNumber(price, '价格阈值');
if (parsedPrice == null) return;
parameters = { direction: priceDirection, price: parsedPrice };
} else if (alertType === 'price_change_percent') {
const parsedChangePct = parsePositiveNumber(changePct, '涨跌幅阈值');
if (parsedChangePct == null) return;
parameters = { direction: changeDirection, changePct: parsedChangePct };
} else {
const parsedMultiplier = parsePositiveNumber(multiplier, '成交量倍数');
if (parsedMultiplier == null) return;
parameters = { multiplier: parsedMultiplier };
}
setFormError(null);
const submitted = await onSubmit({
name: name.trim() || undefined,
targetScope: 'single_symbol',
target: targetValidation.normalized,
alertType,
parameters,
severity,
enabled,
});
if (submitted === false) return;
setName('');
setTarget('');
setPrice('');
setChangePct('');
setMultiplier('');
setEnabled(true);
};
return (
<Card title="创建告警规则" subtitle="Web 告警中心" variant="bordered" padding="md">
<form className="space-y-4" onSubmit={(event) => void handleSubmit(event)}>
<div className="grid gap-4 md:grid-cols-2">
<Input
label="规则名称"
value={name}
onChange={(event) => setName(event.target.value)}
placeholder="可选,例如 茅台价格突破"
disabled={isSubmitting}
/>
<Input
label="标的代码"
value={target}
onChange={(event) => setTarget(event.target.value)}
placeholder="600519 / AAPL / hk00700"
disabled={isSubmitting}
/>
<Select
label="规则类型"
value={alertType}
options={ALERT_TYPE_OPTIONS}
disabled={isSubmitting}
onChange={(value) => {
const nextType = value as AlertType;
setAlertType(nextType);
resetParameters(nextType);
}}
/>
<Select
label="严重级别"
value={severity}
options={SEVERITY_OPTIONS}
disabled={isSubmitting}
onChange={(value) => setSeverity(value as AlertSeverity)}
/>
</div>
{alertType === 'price_cross' ? (
<div className="grid gap-4 md:grid-cols-2">
<Select
label="方向"
value={priceDirection}
options={PRICE_DIRECTION_OPTIONS}
disabled={isSubmitting}
onChange={(value) => setPriceDirection(value as 'above' | 'below')}
/>
<Input
label="价格阈值"
type="number"
min="0"
step="0.0001"
value={price}
onChange={(event) => setPrice(event.target.value)}
disabled={isSubmitting}
/>
</div>
) : null}
{alertType === 'price_change_percent' ? (
<div className="grid gap-4 md:grid-cols-2">
<Select
label="方向"
value={changeDirection}
options={CHANGE_DIRECTION_OPTIONS}
disabled={isSubmitting}
onChange={(value) => setChangeDirection(value as 'up' | 'down')}
/>
<Input
label="涨跌幅阈值(%"
type="number"
min="0"
step="0.01"
value={changePct}
onChange={(event) => setChangePct(event.target.value)}
disabled={isSubmitting}
/>
</div>
) : null}
{alertType === 'volume_spike' ? (
<Input
label="成交量放大倍数"
type="number"
min="0"
step="0.01"
value={multiplier}
onChange={(event) => setMultiplier(event.target.value)}
disabled={isSubmitting}
/>
) : null}
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<Checkbox
label="创建后立即启用"
checked={enabled}
onChange={(event) => setEnabled(event.target.checked)}
disabled={isSubmitting}
/>
<Button type="submit" isLoading={isSubmitting} loadingText="创建中...">
</Button>
</div>
{formError ? <p role="alert" className="text-sm text-danger">{formError}</p> : null}
</form>
</Card>
);
};

View File

@@ -0,0 +1,225 @@
import type React from 'react';
import { useState } from 'react';
import { Bell, Trash2 } from 'lucide-react';
import { Badge, Button, Card, ConfirmDialog, EmptyState, Pagination, Select } from '../common';
import type { AlertRuleItem, AlertType } from '../../types/alerts';
import { formatDateTime } from '../../utils/format';
export type AlertRuleEnabledFilter = 'all' | 'enabled' | 'disabled';
export type AlertTypeFilter = 'all' | AlertType;
export type AlertRuleBusyAction = 'test' | 'toggle' | 'delete';
export interface AlertRuleBusyState {
id: number;
action: AlertRuleBusyAction;
}
const ENABLED_FILTER_OPTIONS = [
{ value: 'all', label: '全部状态' },
{ value: 'enabled', label: '已启用' },
{ value: 'disabled', label: '已停用' },
];
const ALERT_TYPE_FILTER_OPTIONS = [
{ value: 'all', label: '全部类型' },
{ value: 'price_cross', label: '价格突破' },
{ value: 'price_change_percent', label: '涨跌幅' },
{ value: 'volume_spike', label: '成交量放大' },
];
const typeLabel: Record<AlertType, string> = {
price_cross: '价格突破',
price_change_percent: '涨跌幅',
volume_spike: '成交量放大',
};
const severityLabel: Record<string, string> = {
info: '提示',
warning: '警告',
critical: '严重',
};
function formatParameters(rule: AlertRuleItem): string {
if (rule.alertType === 'price_cross') {
return `${rule.parameters.direction === 'below' ? '下破' : '上破'} ${rule.parameters.price ?? '--'}`;
}
if (rule.alertType === 'price_change_percent') {
return `${rule.parameters.direction === 'down' ? '下跌' : '上涨'} ${rule.parameters.changePct ?? '--'}%`;
}
return `${rule.parameters.multiplier ?? '--'}x`;
}
interface AlertRuleListProps {
rules: AlertRuleItem[];
total: number;
page: number;
pageSize: number;
className?: string;
isLoading?: boolean;
enabledFilter: AlertRuleEnabledFilter;
alertTypeFilter: AlertTypeFilter;
onEnabledFilterChange: (value: AlertRuleEnabledFilter) => void;
onAlertTypeFilterChange: (value: AlertTypeFilter) => void;
onPageChange: (page: number) => void;
onToggleEnabled: (rule: AlertRuleItem) => void;
onDelete: (rule: AlertRuleItem) => void;
onTest: (rule: AlertRuleItem) => void;
busyRule?: AlertRuleBusyState | null;
}
export const AlertRuleList: React.FC<AlertRuleListProps> = ({
rules,
total,
page,
pageSize,
className,
isLoading = false,
enabledFilter,
alertTypeFilter,
onEnabledFilterChange,
onAlertTypeFilterChange,
onPageChange,
onToggleEnabled,
onDelete,
onTest,
busyRule = null,
}) => {
const [pendingDelete, setPendingDelete] = useState<AlertRuleItem | null>(null);
const totalPages = Math.max(1, Math.ceil(total / pageSize));
const isRuleBusy = (rule: AlertRuleItem) => busyRule?.id === rule.id;
const isRuleActionBusy = (rule: AlertRuleItem, action: AlertRuleBusyAction) => (
busyRule?.id === rule.id && busyRule.action === action
);
return (
<Card title="告警规则" subtitle={`${total} 条规则`} variant="bordered" padding="md" className={className}>
<div className="mb-4 grid gap-3 md:grid-cols-2">
<Select
label="启停状态"
value={enabledFilter}
options={ENABLED_FILTER_OPTIONS}
onChange={(value) => {
onEnabledFilterChange(value as AlertRuleEnabledFilter);
}}
/>
<Select
label="规则类型"
value={alertTypeFilter}
options={ALERT_TYPE_FILTER_OPTIONS}
onChange={(value) => {
onAlertTypeFilterChange(value as AlertTypeFilter);
}}
/>
</div>
{rules.length === 0 ? (
<div className="flex min-h-[220px] flex-1 items-center justify-center">
<EmptyState
icon={<Bell className="h-6 w-6" />}
title={isLoading ? '正在加载规则' : '暂无告警规则'}
description="创建规则后,后台评估任务会按轮询周期处理已启用的告警。"
/>
</div>
) : (
<div className="min-h-0 flex-1 overflow-x-auto">
<table className="w-full min-w-[860px] text-left text-sm">
<thead className="border-b border-border/60 text-xs uppercase text-muted-text">
<tr>
<th className="px-3 py-2 font-medium"></th>
<th className="px-3 py-2 font-medium"></th>
<th className="px-3 py-2 font-medium"></th>
<th className="px-3 py-2 font-medium"></th>
<th className="px-3 py-2 font-medium"></th>
<th className="px-3 py-2 font-medium"></th>
<th className="px-3 py-2 text-right font-medium"></th>
</tr>
</thead>
<tbody className="divide-y divide-border/40">
{rules.map((rule) => (
<tr key={rule.id} className="align-top">
<td className="px-3 py-3">
<div className="font-medium text-foreground">{rule.name}</div>
<div className="mt-1 text-xs text-muted-text">{rule.source}</div>
</td>
<td className="px-3 py-3 font-mono text-secondary-text">{rule.target}</td>
<td className="px-3 py-3">
<div className="flex flex-col items-start gap-1">
<Badge variant="info">{typeLabel[rule.alertType]}</Badge>
<Badge variant={rule.severity === 'critical' ? 'danger' : rule.severity === 'warning' ? 'warning' : 'default'}>
{severityLabel[rule.severity] ?? rule.severity}
</Badge>
</div>
</td>
<td className="px-3 py-3 text-secondary-text">{formatParameters(rule)}</td>
<td className="px-3 py-3">
<Badge variant={rule.enabled ? 'success' : 'default'}>
{rule.enabled ? '已启用' : '已停用'}
</Badge>
</td>
<td className="px-3 py-3 text-xs text-secondary-text">{formatDateTime(rule.updatedAt ?? rule.createdAt)}</td>
<td className="px-3 py-3">
<div className="flex justify-end gap-2">
<Button
size="xsm"
variant="outline"
onClick={() => onTest(rule)}
isLoading={isRuleActionBusy(rule, 'test')}
loadingText="测试中"
disabled={isRuleBusy(rule) && !isRuleActionBusy(rule, 'test')}
>
</Button>
<Button
size="xsm"
variant={rule.enabled ? 'secondary' : 'primary'}
onClick={() => onToggleEnabled(rule)}
isLoading={isRuleActionBusy(rule, 'toggle')}
loadingText={rule.enabled ? '停用中' : '启用中'}
disabled={isRuleBusy(rule) && !isRuleActionBusy(rule, 'toggle')}
>
{rule.enabled ? '停用' : '启用'}
</Button>
<Button
size="xsm"
variant="danger-subtle"
aria-label={`删除 ${rule.name}`}
onClick={() => setPendingDelete(rule)}
disabled={isRuleBusy(rule)}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<Pagination
currentPage={page}
totalPages={totalPages}
onPageChange={onPageChange}
className="mt-5"
/>
<ConfirmDialog
isOpen={pendingDelete != null}
title="删除告警规则"
message={pendingDelete ? `确认删除「${pendingDelete.name}」吗?该操作不会删除已有触发历史。` : ''}
confirmText="删除"
cancelText="取消"
isDanger
onConfirm={() => {
if (pendingDelete) {
onDelete(pendingDelete);
}
setPendingDelete(null);
}}
onCancel={() => setPendingDelete(null)}
/>
</Card>
);
};

View File

@@ -0,0 +1,82 @@
import type React from 'react';
import { Activity } from 'lucide-react';
import { Badge, Card, EmptyState, Loading } from '../common';
import type { AlertTriggerItem } from '../../types/alerts';
import { formatDateTime } from '../../utils/format';
const statusLabel: Record<string, string> = {
triggered: '已触发',
skipped: '已跳过',
degraded: '降级',
failed: '失败',
};
function statusVariant(status: string): 'success' | 'warning' | 'danger' | 'default' {
if (status === 'triggered') return 'success';
if (status === 'skipped' || status === 'degraded') return 'warning';
if (status === 'failed') return 'danger';
return 'default';
}
function formatNullable(value?: string | number | null): string {
if (value === null || value === undefined || value === '') return '--';
return String(value);
}
interface AlertTriggerHistoryProps {
triggers: AlertTriggerItem[];
isLoading?: boolean;
}
export const AlertTriggerHistory: React.FC<AlertTriggerHistoryProps> = ({ triggers, isLoading = false }) => {
return (
<Card title="触发历史" subtitle="评估记录" variant="bordered" padding="md">
{isLoading ? <Loading label="正在加载触发历史" /> : null}
{!isLoading && triggers.length === 0 ? (
<EmptyState
icon={<Activity className="h-6 w-6" />}
title="暂无触发历史"
description="后台评估会记录 triggered、skipped、degraded 和 failed 状态;正常未触发不会写入历史。"
/>
) : null}
{!isLoading && triggers.length > 0 ? (
<div className="overflow-x-auto">
<table className="w-full min-w-[780px] text-left text-sm">
<thead className="border-b border-border/60 text-xs uppercase text-muted-text">
<tr>
<th className="px-3 py-2 font-medium"></th>
<th className="px-3 py-2 font-medium"></th>
<th className="px-3 py-2 font-medium"></th>
<th className="px-3 py-2 font-medium"></th>
<th className="px-3 py-2 font-medium"></th>
<th className="px-3 py-2 font-medium"></th>
<th className="px-3 py-2 font-medium"></th>
</tr>
</thead>
<tbody className="divide-y divide-border/40">
{triggers.map((trigger) => (
<tr key={trigger.id} className="align-top">
<td className="px-3 py-3">
<Badge variant={statusVariant(trigger.status)}>
{statusLabel[trigger.status] ?? trigger.status}
</Badge>
</td>
<td className="px-3 py-3 font-mono text-secondary-text">{trigger.target}</td>
<td className="px-3 py-3 text-secondary-text">{formatNullable(trigger.observedValue)}</td>
<td className="px-3 py-3 text-secondary-text">{formatNullable(trigger.threshold)}</td>
<td className="px-3 py-3 text-secondary-text">{formatNullable(trigger.dataSource)}</td>
<td className="px-3 py-3 text-xs text-secondary-text">
{formatDateTime(trigger.dataTimestamp ?? trigger.triggeredAt)}
</td>
<td className="px-3 py-3 text-secondary-text">
{trigger.reason || trigger.diagnostics || '--'}
</td>
</tr>
))}
</tbody>
</table>
</div>
) : null}
</Card>
);
};

View File

@@ -0,0 +1,107 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { AlertRuleForm } from '../AlertRuleForm';
describe('AlertRuleForm', () => {
const onSubmit = vi.fn();
beforeEach(() => {
onSubmit.mockReset();
onSubmit.mockResolvedValue(undefined);
});
it('submits a price_cross rule payload', async () => {
render(<AlertRuleForm onSubmit={onSubmit} />);
fireEvent.change(screen.getByLabelText('规则名称'), { target: { value: '茅台价格突破' } });
fireEvent.change(screen.getByLabelText('标的代码'), { target: { value: '600519' } });
fireEvent.change(screen.getByLabelText('价格阈值'), { target: { value: '1800' } });
fireEvent.click(screen.getByRole('button', { name: '创建规则' }));
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith({
name: '茅台价格突破',
targetScope: 'single_symbol',
target: '600519',
alertType: 'price_cross',
parameters: { direction: 'above', price: 1800 },
severity: 'warning',
enabled: true,
});
});
});
it('submits a price_change_percent rule payload', async () => {
render(<AlertRuleForm onSubmit={onSubmit} />);
fireEvent.change(screen.getByLabelText('标的代码'), { target: { value: 'aapl' } });
fireEvent.change(screen.getByLabelText('规则类型'), { target: { value: 'price_change_percent' } });
fireEvent.change(screen.getByLabelText('方向'), { target: { value: 'down' } });
fireEvent.change(screen.getByLabelText('涨跌幅阈值(%'), { target: { value: '3.5' } });
fireEvent.change(screen.getByLabelText('严重级别'), { target: { value: 'critical' } });
fireEvent.click(screen.getByRole('button', { name: '创建规则' }));
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({
target: 'AAPL',
alertType: 'price_change_percent',
parameters: { direction: 'down', changePct: 3.5 },
severity: 'critical',
}));
});
});
it('submits a volume_spike rule payload and supports disabled creation', async () => {
render(<AlertRuleForm onSubmit={onSubmit} />);
fireEvent.change(screen.getByLabelText('标的代码'), { target: { value: 'msft' } });
fireEvent.change(screen.getByLabelText('规则类型'), { target: { value: 'volume_spike' } });
fireEvent.change(screen.getByLabelText('成交量放大倍数'), { target: { value: '2.5' } });
fireEvent.click(screen.getByLabelText('创建后立即启用'));
fireEvent.click(screen.getByRole('button', { name: '创建规则' }));
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({
target: 'MSFT',
alertType: 'volume_spike',
parameters: { multiplier: 2.5 },
enabled: false,
}));
});
});
it('rejects invalid numeric thresholds before submit', () => {
render(<AlertRuleForm onSubmit={onSubmit} />);
fireEvent.change(screen.getByLabelText('标的代码'), { target: { value: '600519' } });
fireEvent.change(screen.getByLabelText('价格阈值'), { target: { value: '0' } });
fireEvent.click(screen.getByRole('button', { name: '创建规则' }));
expect(screen.getByRole('alert')).toHaveTextContent('价格阈值必须是大于 0 的数字');
expect(onSubmit).not.toHaveBeenCalled();
});
it('rejects invalid stock code format before submit', () => {
render(<AlertRuleForm onSubmit={onSubmit} />);
fireEvent.change(screen.getByLabelText('标的代码'), { target: { value: 'aapl-2026' } });
fireEvent.change(screen.getByLabelText('价格阈值'), { target: { value: '200' } });
fireEvent.click(screen.getByRole('button', { name: '创建规则' }));
expect(screen.getByRole('alert')).toHaveTextContent('股票代码格式不正确');
expect(onSubmit).not.toHaveBeenCalled();
});
it('keeps form values when submit reports failure', async () => {
onSubmit.mockResolvedValueOnce(false);
render(<AlertRuleForm onSubmit={onSubmit} />);
fireEvent.change(screen.getByLabelText('标的代码'), { target: { value: 'aapl' } });
fireEvent.change(screen.getByLabelText('价格阈值'), { target: { value: '200' } });
fireEvent.click(screen.getByRole('button', { name: '创建规则' }));
await waitFor(() => expect(onSubmit).toHaveBeenCalled());
expect(screen.getByLabelText('标的代码')).toHaveValue('aapl');
expect(screen.getByLabelText('价格阈值')).toHaveValue(200);
});
});

View File

@@ -0,0 +1,105 @@
import { fireEvent, render, screen } from '@testing-library/react';
import type React from 'react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { AlertRuleList } from '../AlertRuleList';
import type { AlertRuleItem } from '../../../types/alerts';
const rules: AlertRuleItem[] = [
{
id: 1,
name: '茅台价格突破',
targetScope: 'single_symbol',
target: '600519',
alertType: 'price_cross',
parameters: { direction: 'above', price: 1800 },
severity: 'warning',
enabled: true,
source: 'api',
createdAt: '2026-05-18T09:00:00',
updatedAt: '2026-05-18T09:30:00',
},
];
describe('AlertRuleList', () => {
const onEnabledFilterChange = vi.fn();
const onAlertTypeFilterChange = vi.fn();
const onPageChange = vi.fn();
const onToggleEnabled = vi.fn();
const onDelete = vi.fn();
const onTest = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
});
function renderList(overrides: Partial<React.ComponentProps<typeof AlertRuleList>> = {}) {
render(
<AlertRuleList
rules={rules}
total={40}
page={1}
pageSize={20}
enabledFilter="all"
alertTypeFilter="all"
onEnabledFilterChange={onEnabledFilterChange}
onAlertTypeFilterChange={onAlertTypeFilterChange}
onPageChange={onPageChange}
onToggleEnabled={onToggleEnabled}
onDelete={onDelete}
onTest={onTest}
{...overrides}
/>,
);
}
it('renders rules, filters, and pagination', () => {
renderList();
expect(screen.getByText('茅台价格突破')).toBeInTheDocument();
expect(screen.getByText('600519')).toBeInTheDocument();
expect(screen.getAllByText('价格突破').length).toBeGreaterThan(0);
expect(screen.getByText('上破 1800')).toBeInTheDocument();
fireEvent.change(screen.getByLabelText('启停状态'), { target: { value: 'enabled' } });
fireEvent.change(screen.getByLabelText('规则类型'), { target: { value: 'price_cross' } });
fireEvent.click(screen.getByRole('button', { name: '2' }));
expect(onEnabledFilterChange).toHaveBeenCalledWith('enabled');
expect(onAlertTypeFilterChange).toHaveBeenCalledWith('price_cross');
expect(onPageChange).toHaveBeenCalledWith(2);
});
it('runs test and toggles enabled state', () => {
renderList();
fireEvent.click(screen.getByRole('button', { name: '测试' }));
fireEvent.click(screen.getByRole('button', { name: '停用' }));
expect(onTest).toHaveBeenCalledWith(rules[0]);
expect(onToggleEnabled).toHaveBeenCalledWith(rules[0]);
});
it('shows loading text only for the active rule operation', () => {
renderList({ busyRule: { id: 1, action: 'toggle' } });
expect(screen.getByRole('button', { name: '测试' })).toBeDisabled();
expect(screen.getByRole('button', { name: '停用中' })).toHaveAttribute('aria-busy', 'true');
expect(screen.queryByRole('button', { name: '测试中' })).not.toBeInTheDocument();
});
it('confirms deletion before calling onDelete', async () => {
renderList();
fireEvent.click(screen.getByLabelText('删除 茅台价格突破'));
expect(await screen.findByRole('heading', { name: '删除告警规则' })).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '删除' }));
expect(onDelete).toHaveBeenCalledWith(rules[0]);
});
it('shows an empty state for no rules', () => {
renderList({ rules: [], total: 0 });
expect(screen.getByText('暂无告警规则')).toBeInTheDocument();
});
});

View File

@@ -1,6 +1,6 @@
import React, { useState } from 'react';
import { motion } from 'motion/react';
import { BarChart3, BriefcaseBusiness, Home, LogOut, MessageSquareQuote, Settings2 } from 'lucide-react';
import { BarChart3, Bell, BriefcaseBusiness, Home, LogOut, MessageSquareQuote, Settings2 } from 'lucide-react';
import { NavLink } from 'react-router-dom';
import { useAuth } from '../../contexts/AuthContext';
import { useAgentChatStore } from '../../stores/agentChatStore';
@@ -28,6 +28,7 @@ const NAV_ITEMS: NavItem[] = [
{ key: 'chat', label: '问股', to: '/chat', icon: MessageSquareQuote, badge: 'completion' },
{ key: 'portfolio', label: '持仓', to: '/portfolio', icon: BriefcaseBusiness },
{ key: 'backtest', label: '回测', to: '/backtest', icon: BarChart3 },
{ key: 'alerts', label: '告警', to: '/alerts', icon: Bell },
{ key: 'settings', label: '设置', to: '/settings', icon: Settings2 },
];

View File

@@ -62,6 +62,18 @@ describe('SidebarNav', () => {
expect(screen.getByRole('button', { name: '切换主题(折叠)' })).toBeInTheDocument();
});
it('renders the alerts navigation item and marks it active', () => {
render(
<MemoryRouter initialEntries={['/alerts']}>
<SidebarNav />
</MemoryRouter>,
);
const alertsLink = screen.getByRole('link', { name: '告警' });
expect(alertsLink).toHaveAttribute('href', '/alerts');
expect(alertsLink).toHaveClass('font-medium');
});
it('opens the logout confirmation and confirms logout', async () => {
render(
<MemoryRouter initialEntries={['/chat']}>

View File

@@ -0,0 +1,319 @@
import type React from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { BellRing } from 'lucide-react';
import { alertsApi } from '../api/alerts';
import type { ParsedApiError } from '../api/error';
import { getParsedApiError } from '../api/error';
import { AlertRuleForm } from '../components/alerts/AlertRuleForm';
import {
AlertRuleList,
type AlertRuleBusyState,
type AlertRuleEnabledFilter,
type AlertTypeFilter,
} from '../components/alerts/AlertRuleList';
import { AlertTriggerHistory } from '../components/alerts/AlertTriggerHistory';
import { ApiErrorAlert, AppPage, Card, EmptyState, InlineAlert, Loading, PageHeader } from '../components/common';
import type {
AlertNotificationItem,
AlertRuleCreateRequest,
AlertRuleItem,
AlertRuleTestResponse,
AlertTriggerItem,
AlertType,
} from '../types/alerts';
import { formatDateTime } from '../utils/format';
const PAGE_SIZE = 20;
function enabledFilterToQuery(value: AlertRuleEnabledFilter): boolean | undefined {
if (value === 'enabled') return true;
if (value === 'disabled') return false;
return undefined;
}
function alertTypeFilterToQuery(value: AlertTypeFilter): AlertType | undefined {
return value === 'all' ? undefined : value;
}
function testVariant(result: AlertRuleTestResponse): 'success' | 'warning' | 'danger' {
if (result.status === 'evaluation_error') return 'danger';
return result.triggered ? 'success' : 'warning';
}
const AlertsPage: React.FC = () => {
useEffect(() => {
document.title = '告警中心 - DSA';
}, []);
const [rules, setRules] = useState<AlertRuleItem[]>([]);
const [rulesTotal, setRulesTotal] = useState(0);
const [rulesPage, setRulesPage] = useState(1);
const [enabledFilter, setEnabledFilter] = useState<AlertRuleEnabledFilter>('all');
const [alertTypeFilter, setAlertTypeFilter] = useState<AlertTypeFilter>('all');
const [rulesLoading, setRulesLoading] = useState(false);
const [rulesError, setRulesError] = useState<ParsedApiError | null>(null);
const [rulesLoaded, setRulesLoaded] = useState(false);
const [triggers, setTriggers] = useState<AlertTriggerItem[]>([]);
const [triggersLoading, setTriggersLoading] = useState(false);
const [triggersError, setTriggersError] = useState<ParsedApiError | null>(null);
const [notifications, setNotifications] = useState<AlertNotificationItem[]>([]);
const [notificationsLoading, setNotificationsLoading] = useState(false);
const [notificationsError, setNotificationsError] = useState<ParsedApiError | null>(null);
const [createLoading, setCreateLoading] = useState(false);
const [createError, setCreateError] = useState<ParsedApiError | null>(null);
const [createSuccess, setCreateSuccess] = useState<string | null>(null);
const [busyRule, setBusyRule] = useState<AlertRuleBusyState | null>(null);
const [testResult, setTestResult] = useState<AlertRuleTestResponse | null>(null);
const rulesRequestIdRef = useRef(0);
const loadRules = useCallback(async (pageOverride?: number) => {
const requestId = rulesRequestIdRef.current + 1;
rulesRequestIdRef.current = requestId;
const isLatestRequest = () => rulesRequestIdRef.current === requestId;
const requestedPage = pageOverride ?? rulesPage;
const baseQuery = {
enabled: enabledFilterToQuery(enabledFilter),
alertType: alertTypeFilterToQuery(alertTypeFilter),
pageSize: PAGE_SIZE,
};
setRulesLoading(true);
try {
let response = await alertsApi.listRules({ ...baseQuery, page: requestedPage });
if (!isLatestRequest()) return null;
const lastPage = Math.max(1, Math.ceil(response.total / PAGE_SIZE));
if (response.items.length === 0 && response.total > 0 && requestedPage > lastPage) {
setRulesPage(lastPage);
response = await alertsApi.listRules({ ...baseQuery, page: lastPage });
if (!isLatestRequest()) return null;
} else if (pageOverride !== undefined && pageOverride !== rulesPage) {
setRulesPage(pageOverride);
}
setRules(response.items);
setRulesTotal(response.total);
setRulesError(null);
setRulesLoaded(true);
return response;
} catch (error) {
if (!isLatestRequest()) return null;
setRulesError(getParsedApiError(error));
return null;
} finally {
if (isLatestRequest()) {
setRulesLoading(false);
}
}
}, [alertTypeFilter, enabledFilter, rulesPage]);
const loadTriggers = useCallback(async () => {
setTriggersLoading(true);
try {
const response = await alertsApi.listTriggers({ page: 1, pageSize: PAGE_SIZE });
setTriggers(response.items);
setTriggersError(null);
} catch (error) {
setTriggersError(getParsedApiError(error));
} finally {
setTriggersLoading(false);
}
}, []);
const loadNotifications = useCallback(async () => {
setNotificationsLoading(true);
try {
const response = await alertsApi.listNotifications({ page: 1, pageSize: PAGE_SIZE });
setNotifications(response.items);
setNotificationsError(null);
} catch (error) {
setNotificationsError(getParsedApiError(error));
} finally {
setNotificationsLoading(false);
}
}, []);
useEffect(() => {
void loadRules();
}, [loadRules]);
useEffect(() => {
if (!rulesLoaded) return;
void loadTriggers();
void loadNotifications();
}, [loadNotifications, loadTriggers, rulesLoaded]);
const handleCreateRule = async (payload: AlertRuleCreateRequest) => {
setCreateLoading(true);
setCreateError(null);
setCreateSuccess(null);
try {
const created = await alertsApi.createRule(payload);
setCreateSuccess(`已创建告警规则「${created.name}`);
await loadRules(1);
return true;
} catch (error) {
setCreateError(getParsedApiError(error));
return false;
} finally {
setCreateLoading(false);
}
};
const handleToggleEnabled = async (rule: AlertRuleItem) => {
setBusyRule({ id: rule.id, action: 'toggle' });
try {
if (rule.enabled) {
await alertsApi.disableRule(rule.id);
} else {
await alertsApi.enableRule(rule.id);
}
await loadRules();
} catch (error) {
setRulesError(getParsedApiError(error));
} finally {
setBusyRule(null);
}
};
const handleDeleteRule = async (rule: AlertRuleItem) => {
setBusyRule({ id: rule.id, action: 'delete' });
try {
await alertsApi.deleteRule(rule.id);
await loadRules();
} catch (error) {
setRulesError(getParsedApiError(error));
} finally {
setBusyRule(null);
}
};
const handleTestRule = async (rule: AlertRuleItem) => {
setBusyRule({ id: rule.id, action: 'test' });
setTestResult(null);
try {
const result = await alertsApi.testRule(rule.id);
setTestResult(result);
} catch (error) {
setRulesError(getParsedApiError(error));
} finally {
setBusyRule(null);
}
};
return (
<AppPage className="space-y-5">
<PageHeader
eyebrow="Alert Center"
title="告警中心"
description="管理现有三类事件告警规则,执行一次性测试,并查看后台评估任务记录的触发历史。"
/>
{createError ? <ApiErrorAlert error={createError} onDismiss={() => setCreateError(null)} /> : null}
{createSuccess ? (
<InlineAlert
title="创建成功"
message={createSuccess}
variant="success"
action={(
<button type="button" className="text-sm underline" onClick={() => setCreateSuccess(null)}>
</button>
)}
/>
) : null}
{rulesError ? <ApiErrorAlert error={rulesError} onDismiss={() => setRulesError(null)} /> : null}
<div className="grid items-stretch gap-5 xl:grid-cols-[380px_minmax(0,1fr)]">
<AlertRuleForm onSubmit={handleCreateRule} isSubmitting={createLoading} />
<div className="flex h-full min-h-0 flex-col gap-4">
<AlertRuleList
className="flex h-full min-h-0 flex-col"
rules={rules}
total={rulesTotal}
page={rulesPage}
pageSize={PAGE_SIZE}
isLoading={rulesLoading}
enabledFilter={enabledFilter}
alertTypeFilter={alertTypeFilter}
onEnabledFilterChange={(value) => {
setEnabledFilter(value);
setRulesPage(1);
}}
onAlertTypeFilterChange={(value) => {
setAlertTypeFilter(value);
setRulesPage(1);
}}
onPageChange={setRulesPage}
onToggleEnabled={(rule) => void handleToggleEnabled(rule)}
onDelete={(rule) => void handleDeleteRule(rule)}
onTest={(rule) => void handleTestRule(rule)}
busyRule={busyRule}
/>
{testResult ? (
<InlineAlert
title="测试结果"
variant={testVariant(testResult)}
message={(
<span>
{testResult.message}
{' · 状态:'}
{testResult.status}
{' · 触发:'}
{testResult.triggered ? '是' : '否'}
{' · 观察值:'}
{testResult.observedValue == null ? '--' : String(testResult.observedValue)}
</span>
)}
/>
) : null}
</div>
</div>
{triggersError ? <ApiErrorAlert error={triggersError} onDismiss={() => setTriggersError(null)} /> : null}
<AlertTriggerHistory triggers={triggers} isLoading={triggersLoading} />
{notificationsError ? <ApiErrorAlert error={notificationsError} onDismiss={() => setNotificationsError(null)} /> : null}
<Card title="通知尝试记录" subtitle="通知结果" variant="bordered" padding="md">
{notificationsLoading ? <Loading label="正在加载通知尝试记录" /> : null}
{!notificationsLoading && notifications.length === 0 ? (
<EmptyState
icon={<BellRing className="h-6 w-6" />}
title="暂无通知尝试记录"
description="当前没有可展示的通知尝试明细;告警触发仍会按已配置通知渠道发送。"
/>
) : null}
{!notificationsLoading && notifications.length > 0 ? (
<div className="overflow-x-auto">
<table className="w-full min-w-[680px] text-left text-sm">
<thead className="border-b border-border/60 text-xs uppercase text-muted-text">
<tr>
<th className="px-3 py-2 font-medium"></th>
<th className="px-3 py-2 font-medium"></th>
<th className="px-3 py-2 font-medium"></th>
<th className="px-3 py-2 font-medium"></th>
<th className="px-3 py-2 font-medium"></th>
<th className="px-3 py-2 font-medium"></th>
</tr>
</thead>
<tbody className="divide-y divide-border/40">
{notifications.map((notification) => (
<tr key={notification.id}>
<td className="px-3 py-3">{notification.channel}</td>
<td className="px-3 py-3">{notification.success ? '成功' : '失败'}</td>
<td className="px-3 py-3">{notification.errorCode ?? '--'}</td>
<td className="px-3 py-3">{notification.latencyMs == null ? '--' : `${notification.latencyMs}ms`}</td>
<td className="px-3 py-3">{formatDateTime(notification.createdAt)}</td>
<td className="px-3 py-3">{notification.diagnostics ?? '--'}</td>
</tr>
))}
</tbody>
</table>
</div>
) : null}
</Card>
</AppPage>
);
};
export default AlertsPage;

View File

@@ -0,0 +1,225 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import AlertsPage from '../AlertsPage';
const {
listRules,
createRule,
deleteRule,
enableRule,
disableRule,
testRule,
listTriggers,
listNotifications,
} = vi.hoisted(() => ({
listRules: vi.fn(),
createRule: vi.fn(),
deleteRule: vi.fn(),
enableRule: vi.fn(),
disableRule: vi.fn(),
testRule: vi.fn(),
listTriggers: vi.fn(),
listNotifications: vi.fn(),
}));
vi.mock('../../api/alerts', () => ({
alertsApi: {
listRules,
createRule,
deleteRule,
enableRule,
disableRule,
testRule,
listTriggers,
listNotifications,
},
}));
const parsedError = {
title: '加载失败',
message: '告警 API 不可用',
rawMessage: '告警 API 不可用',
category: 'http_error' as const,
status: 500,
};
const rule = {
id: 1,
name: '茅台价格突破',
targetScope: 'single_symbol' as const,
target: '600519',
alertType: 'price_cross' as const,
parameters: { direction: 'above' as const, price: 1800 },
severity: 'warning' as const,
enabled: true,
source: 'api',
createdAt: '2026-05-18T09:00:00',
updatedAt: '2026-05-18T09:30:00',
};
function createDeferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((promiseResolve) => {
resolve = promiseResolve;
});
return { promise, resolve };
}
beforeEach(() => {
vi.clearAllMocks();
listRules.mockResolvedValue({ items: [rule], total: 1, page: 1, pageSize: 20 });
listTriggers.mockResolvedValue({
items: [
{
id: 10,
ruleId: 1,
target: '600519',
observedValue: 1801,
threshold: 1800,
reason: '600519 price above 1800',
dataSource: 'realtime_quote',
dataTimestamp: '2026-05-18T09:30:00',
triggeredAt: '2026-05-18T09:30:01',
status: 'triggered',
},
],
total: 1,
page: 1,
pageSize: 20,
});
listNotifications.mockResolvedValue({ items: [], total: 0, page: 1, pageSize: 20 });
testRule.mockResolvedValue({
ruleId: 1,
status: 'triggered',
triggered: true,
observedValue: 1801,
message: '600519 price above 1800',
});
createRule.mockResolvedValue(rule);
disableRule.mockResolvedValue({ ...rule, enabled: false });
enableRule.mockResolvedValue(rule);
deleteRule.mockResolvedValue({ deleted: 1 });
});
describe('AlertsPage', () => {
it('loads rules, trigger history, and notification empty state', async () => {
render(<AlertsPage />);
expect(await screen.findByText('茅台价格突破')).toBeInTheDocument();
expect(await screen.findByText('600519 price above 1800')).toBeInTheDocument();
expect(await screen.findByText('暂无通知尝试记录')).toBeInTheDocument();
expect(listRules).toHaveBeenCalledWith({
enabled: undefined,
alertType: undefined,
page: 1,
pageSize: 20,
});
expect(listTriggers).toHaveBeenCalledWith({ page: 1, pageSize: 20 });
expect(listNotifications).toHaveBeenCalledWith({ page: 1, pageSize: 20 });
});
it('runs a dry-run test and renders only declared response fields', async () => {
listTriggers.mockResolvedValueOnce({ items: [], total: 0, page: 1, pageSize: 20 });
render(<AlertsPage />);
fireEvent.click(await screen.findByRole('button', { name: '测试' }));
await waitFor(() => expect(testRule).toHaveBeenCalledWith(1));
expect(await screen.findByText('测试结果')).toBeInTheDocument();
expect(screen.getByText(/600519 price above 1800/)).toBeInTheDocument();
expect(screen.getByText(/观察值1801/)).toBeInTheDocument();
expect(screen.queryByText(/realtime_quote/)).not.toBeInTheDocument();
});
it('creates a rule through the page form and reloads rules', async () => {
render(<AlertsPage />);
await screen.findByText('茅台价格突破');
fireEvent.change(screen.getByLabelText('标的代码'), { target: { value: 'aapl' } });
fireEvent.change(screen.getByLabelText('价格阈值'), { target: { value: '200' } });
fireEvent.click(screen.getByRole('button', { name: '创建规则' }));
await waitFor(() => {
expect(createRule).toHaveBeenCalledWith(expect.objectContaining({
target: 'AAPL',
alertType: 'price_cross',
parameters: { direction: 'above', price: 200 },
}));
});
expect(await screen.findByText(/已创建告警规则/)).toBeInTheDocument();
});
it('keeps create form values when create API fails', async () => {
createRule.mockRejectedValueOnce({ parsedError });
render(<AlertsPage />);
await screen.findByText('茅台价格突破');
fireEvent.change(screen.getByLabelText('标的代码'), { target: { value: 'aapl' } });
fireEvent.change(screen.getByLabelText('价格阈值'), { target: { value: '200' } });
fireEvent.click(screen.getByRole('button', { name: '创建规则' }));
expect(await screen.findByText('加载失败')).toBeInTheDocument();
expect(screen.getByLabelText('标的代码')).toHaveValue('aapl');
expect(screen.getByLabelText('价格阈值')).toHaveValue(200);
});
it('clamps rules pagination when a mutation leaves the current page empty', async () => {
const page2Rule = { ...rule, id: 2, name: '第二页规则', target: 'AAPL' };
listRules
.mockResolvedValueOnce({ items: [rule], total: 21, page: 1, pageSize: 20 })
.mockResolvedValueOnce({ items: [page2Rule], total: 21, page: 2, pageSize: 20 })
.mockResolvedValueOnce({ items: [], total: 20, page: 2, pageSize: 20 })
.mockResolvedValue({ items: [rule], total: 20, page: 1, pageSize: 20 });
render(<AlertsPage />);
expect(await screen.findByText('茅台价格突破')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '2' }));
expect(await screen.findByText('第二页规则')).toBeInTheDocument();
fireEvent.click(screen.getByLabelText('删除 第二页规则'));
fireEvent.click(await screen.findByRole('button', { name: '删除' }));
await waitFor(() => expect(deleteRule).toHaveBeenCalledWith(2));
await waitFor(() => {
expect(listRules).toHaveBeenCalledWith({
enabled: undefined,
alertType: undefined,
page: 1,
pageSize: 20,
});
});
expect(await screen.findByText('茅台价格突破')).toBeInTheDocument();
});
it('keeps the latest rules response when filter requests resolve out of order', async () => {
const initialRequest = createDeferred<{ items: Array<typeof rule>; total: number; page: number; pageSize: number }>();
const filteredRequest = createDeferred<{ items: Array<typeof rule>; total: number; page: number; pageSize: number }>();
const staleRule = { ...rule, id: 3, name: '旧筛选规则', enabled: true };
const filteredRule = { ...rule, id: 4, name: '停用规则', enabled: false };
listRules
.mockReset()
.mockReturnValueOnce(initialRequest.promise)
.mockReturnValueOnce(filteredRequest.promise);
render(<AlertsPage />);
fireEvent.change(screen.getByLabelText('启停状态'), { target: { value: 'disabled' } });
await waitFor(() => expect(listRules).toHaveBeenCalledTimes(2));
filteredRequest.resolve({ items: [filteredRule], total: 1, page: 1, pageSize: 20 });
expect(await screen.findByText('停用规则')).toBeInTheDocument();
initialRequest.resolve({ items: [staleRule], total: 1, page: 1, pageSize: 20 });
await waitFor(() => expect(screen.queryByText('旧筛选规则')).not.toBeInTheDocument());
expect(screen.getByText('停用规则')).toBeInTheDocument();
});
it('renders API errors through ApiErrorAlert', async () => {
listRules.mockRejectedValueOnce({ parsedError });
render(<AlertsPage />);
expect(await screen.findByText('加载失败')).toBeInTheDocument();
expect(screen.getByText('告警 API 不可用')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,125 @@
export type AlertType = 'price_cross' | 'price_change_percent' | 'volume_spike';
export type AlertSeverity = 'info' | 'warning' | 'critical';
export type AlertTargetScope = 'single_symbol';
export type AlertDirection = 'above' | 'below' | 'up' | 'down';
export type AlertDryRunStatus = 'triggered' | 'not_triggered' | 'evaluation_error';
export type AlertTriggerStatus = 'triggered' | 'skipped' | 'degraded' | 'failed';
export interface AlertRuleParameters {
direction?: AlertDirection;
price?: number;
changePct?: number;
multiplier?: number;
}
export interface AlertRuleItem {
id: number;
name: string;
targetScope: AlertTargetScope;
target: string;
alertType: AlertType;
parameters: AlertRuleParameters;
severity: AlertSeverity;
enabled: boolean;
source: string;
cooldownPolicy?: Record<string, unknown> | null;
notificationPolicy?: Record<string, unknown> | null;
createdAt?: string | null;
updatedAt?: string | null;
}
export interface AlertRuleListResponse {
items: AlertRuleItem[];
total: number;
page: number;
pageSize: number;
}
export interface AlertRuleCreateRequest {
name?: string;
targetScope?: AlertTargetScope;
target: string;
alertType: AlertType;
parameters: AlertRuleParameters;
severity: AlertSeverity;
enabled?: boolean;
}
export interface AlertDeleteResponse {
deleted: number;
}
export interface AlertRuleTestResponse {
ruleId: number;
status: AlertDryRunStatus;
triggered: boolean;
observedValue?: unknown;
message: string;
}
export interface AlertTriggerItem {
id: number;
ruleId?: number | null;
target: string;
observedValue?: number | null;
threshold?: number | null;
reason?: string | null;
dataSource?: string | null;
dataTimestamp?: string | null;
triggeredAt?: string | null;
status: AlertTriggerStatus | string;
diagnostics?: string | null;
}
export interface AlertTriggerListResponse {
items: AlertTriggerItem[];
total: number;
page: number;
pageSize: number;
}
export interface AlertNotificationItem {
id: number;
triggerId?: number | null;
channel: string;
attempt: number;
success: boolean;
errorCode?: string | null;
retryable: boolean;
latencyMs?: number | null;
diagnostics?: string | null;
createdAt?: string | null;
}
export interface AlertNotificationListResponse {
items: AlertNotificationItem[];
total: number;
page: number;
pageSize: number;
}
export interface AlertRuleListQuery {
enabled?: boolean;
alertType?: AlertType;
targetScope?: AlertTargetScope;
target?: string;
source?: string;
page?: number;
pageSize?: number;
}
export interface AlertTriggerListQuery {
ruleId?: number;
target?: string;
status?: string;
page?: number;
pageSize?: number;
}
export interface AlertNotificationListQuery {
triggerId?: number;
channel?: string;
success?: boolean;
page?: number;
pageSize?: number;
}

View File

@@ -1,4 +1,4 @@
export const formatDateTime = (value?: string): string => {
export const formatDateTime = (value?: string | null): string => {
if (!value) return '—';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;

View File

@@ -25,6 +25,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
- [文档] 新增小白客户端安装与配置指南,说明桌面客户端下载、基础模型配置、新闻源配置和常见问题。
- [新功能] Web 首页个股分析支持选择策略。
- [新功能] 新增热点题材、事件驱动、成长质量和预期重估策略。
- [新功能] Web 新增告警中心 MVP支持现有三类告警规则的创建、列表、启停、删除、dry-run 测试和触发历史查看。
## [3.17.1] - 2026-05-16

View File

@@ -41,7 +41,7 @@
| --- | --- |
| [Bot 命令与接入](bot-command.md) | Bot 命令、Webhook、平台接入和回调说明 |
| [Bot 平台配置](bot/) | 飞书、钉钉、Discord 等 Bot 配置截图和补充说明 |
| [实时告警中心](alerts.md) | EventMonitor 基线、告警契约、存储评估和 Phase 边界 |
| [实时告警中心](alerts.md) | EventMonitor 基线、Web 规则管理、告警契约和 Phase 边界 |
| [图片识别 Prompt](image-extract-prompt.md) | 图片识别股票信息的 Prompt 与使用边界 |
| [OpenClaw Skill 集成](openclaw-skill-integration.md) | OpenClaw / Skill 外部集成说明 |

View File

@@ -42,7 +42,7 @@ This is the entry point for project documentation. The README covers the project
| --- | --- |
| [Bot Commands (EN)](bot-command_EN.md) | Bot commands, webhooks, platform integration, and callback behavior |
| [Bot Platform Docs](bot/) <sub><sub>![P2 Badge](https://img.shields.io/badge/P2-yellow?style=flat)</sub></sub> (Chinese-only) | Feishu, DingTalk, Discord, and related Bot configuration screenshots and notes |
| [Real-Time Alert Center](alerts.md) <sub><sub>![P2 Badge](https://img.shields.io/badge/P2-yellow?style=flat)</sub></sub> (Chinese-only) | EventMonitor baseline, alert contracts, storage evaluation, and phase boundaries |
| [Real-Time Alert Center](alerts.md) <sub><sub>![P3 Badge](https://img.shields.io/badge/P3-yellow?style=flat)</sub></sub> (Chinese-only) | EventMonitor baseline, Web rule management, alert contracts, and phase boundaries |
| [Image Extraction Prompt](image-extract-prompt.md) <sub><sub>![P2 Badge](https://img.shields.io/badge/P2-yellow?style=flat)</sub></sub> (Chinese-only) | Prompt and boundaries for extracting stock information from images |
| [OpenClaw Skill Integration](openclaw-skill-integration.md) <sub><sub>![P2 Badge](https://img.shields.io/badge/P2-yellow?style=flat)</sub></sub> (Chinese-only) | OpenClaw / Skill external integration notes |

View File

@@ -171,6 +171,27 @@ P2 不做:
- 不实现 `alert_cooldown``cooldown_policy``notification_policy` 执行语义。
- 不实现 MACD、KDJ、CCI、RSI、持仓风险或 Market Light 告警规则。
## P3 Web 告警中心 MVP
P3 在 WebUI 中新增 `/alerts` 告警中心入口,让用户不需要直接编辑 legacy JSON 即可管理当前三类运行时规则。
- 侧边栏新增“告警”入口,页面支持规则列表、分页、启停筛选和规则类型筛选。
- 规则创建表单只支持 `single_symbol` 目标范围和当前已可执行的三类规则:
- `price_cross``direction``above` / `below`,并填写 `price`
- `price_change_percent``direction``up` / `down`,并填写 `change_pct`
- `volume_spike`:填写 `multiplier`
- 规则操作支持启用、停用、删除和一次性 dry-run 测试。
- dry-run 测试只展示 `AlertRuleTestResponse` 已声明字段:规则 ID、状态、是否触发、观察值和消息`threshold``data_source``data_timestamp` 等扩展诊断字段需要后端 schema 明确暴露后再展示。
- 触发历史展示 P2 worker 已写入的 `triggered``skipped``degraded``failed` 记录;正常 `not_triggered` 仍不会写入历史。
- 通知尝试区域只查询现有 `GET /api/v1/alerts/notifications`;由于 P2 运行时不写 per-channel notification attempt当前通常显示“暂无通知尝试记录”空态不把触发状态推断为通知投递结果。
- Web 页面不暴露 `AGENT_EVENT_ALERT_RULES_JSON` 编辑入口,不自动迁移、删除或改写 legacy 配置。
P3 不做:
- 不新增或修改后端 API、schema、storage 或 worker 行为。
- 不实现规则编辑、target/source 高级筛选、watchlist/portfolio 目标、技术指标规则或 Market Light 联动。
- 不执行 `cooldown_policy` / `notification_policy`,不写 `alert_notifications`
## Phase 边界
- P0本文档、契约、存储评估和兼容测试。
@@ -197,3 +218,4 @@ P2 不做:
- P0 是文档和测试收口。若只回滚 P0revert 对应 PR 即可;没有数据库、配置或用户数据迁移需要额外处理。
- P1 新增 Alert API 代码和 `alert_rules` / `alert_triggers` / `alert_notifications` SQLite 表。最小回滚方式是 revert P1 PRrevert 会移除 API、service、repository、schema 和 ORM 定义,但已经由 `Base.metadata.create_all()` 创建的 SQLite 表与数据不会自动删除。如需清理,需要维护者在确认不再需要历史数据后手动删除相关表。
- P3 是 Web 和文档改动。最小回滚方式是 revert P3 PR不会删除已有规则、触发历史或 legacy JSON 配置。

Binary file not shown.

After

Width:  |  Height:  |  Size: 233 KiB

View File

@@ -1319,6 +1319,8 @@ AGENT_EVENT_ALERT_RULES_JSON=[{"stock_code":"600519","alert_type":"price_cross",
P2 worker 会把 `triggered``skipped``degraded``failed` 写入 `alert_triggers` 作为最小评估历史正常未触发不写历史。P2 不写 `alert_notifications`,也不执行 `cooldown_policy` / `notification_policy`
WebUI 的“告警”页面可以管理当前三类持久化规则、执行一次性 dry-run 测试并查看触发历史;详细边界见 [实时告警中心](alerts.md)。
## 持仓管理说明
### `/portfolio` 页面可做什么

View File

@@ -1181,6 +1181,8 @@ AGENT_EVENT_ALERT_RULES_JSON=[{"stock_code":"600519","alert_type":"price_cross",
The P2 worker writes `triggered`, `skipped`, `degraded`, and `failed` rows to `alert_triggers` as minimal evaluation history; normal non-triggered checks do not write history. P2 does not write `alert_notifications` and does not execute `cooldown_policy` / `notification_policy`.
The WebUI "Alerts" page can manage the current three persisted rule types, run one-shot dry-run tests, and view trigger history. See [Real-Time Alert Center](alerts.md) for detailed boundaries.
---
For more questions, please [submit an Issue](https://github.com/ZhuLinsen/daily_stock_analysis/issues)