mirror of
https://github.com/ZhuLinsen/daily_stock_analysis
synced 2026-09-20 10:53:33 +08:00
feat: 新增 Web 告警中心 MVP(P3) (#1334)
* feat: add web alert center MVP * fix: guard alert rule list request ordering
This commit is contained in:
@@ -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>
|
||||
|
||||
142
apps/dsa-web/src/api/__tests__/alerts.test.ts
Normal file
142
apps/dsa-web/src/api/__tests__/alerts.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
122
apps/dsa-web/src/api/alerts.ts
Normal file
122
apps/dsa-web/src/api/alerts.ts
Normal 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);
|
||||
},
|
||||
};
|
||||
217
apps/dsa-web/src/components/alerts/AlertRuleForm.tsx
Normal file
217
apps/dsa-web/src/components/alerts/AlertRuleForm.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
225
apps/dsa-web/src/components/alerts/AlertRuleList.tsx
Normal file
225
apps/dsa-web/src/components/alerts/AlertRuleList.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
82
apps/dsa-web/src/components/alerts/AlertTriggerHistory.tsx
Normal file
82
apps/dsa-web/src/components/alerts/AlertTriggerHistory.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 },
|
||||
];
|
||||
|
||||
|
||||
@@ -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']}>
|
||||
|
||||
319
apps/dsa-web/src/pages/AlertsPage.tsx
Normal file
319
apps/dsa-web/src/pages/AlertsPage.tsx
Normal 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;
|
||||
225
apps/dsa-web/src/pages/__tests__/AlertsPage.test.tsx
Normal file
225
apps/dsa-web/src/pages/__tests__/AlertsPage.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
125
apps/dsa-web/src/types/alerts.ts
Normal file
125
apps/dsa-web/src/types/alerts.ts
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 外部集成说明 |
|
||||
|
||||
|
||||
@@ -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></sub></sub> (Chinese-only) | Feishu, DingTalk, Discord, and related Bot configuration screenshots and notes |
|
||||
| [Real-Time Alert Center](alerts.md) <sub><sub></sub></sub> (Chinese-only) | EventMonitor baseline, alert contracts, storage evaluation, and phase boundaries |
|
||||
| [Real-Time Alert Center](alerts.md) <sub><sub></sub></sub> (Chinese-only) | EventMonitor baseline, Web rule management, alert contracts, and phase boundaries |
|
||||
| [Image Extraction Prompt](image-extract-prompt.md) <sub><sub></sub></sub> (Chinese-only) | Prompt and boundaries for extracting stock information from images |
|
||||
| [OpenClaw Skill Integration](openclaw-skill-integration.md) <sub><sub></sub></sub> (Chinese-only) | OpenClaw / Skill external integration notes |
|
||||
|
||||
|
||||
@@ -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 是文档和测试收口。若只回滚 P0,revert 对应 PR 即可;没有数据库、配置或用户数据迁移需要额外处理。
|
||||
- P1 新增 Alert API 代码和 `alert_rules` / `alert_triggers` / `alert_notifications` SQLite 表。最小回滚方式是 revert P1 PR;revert 会移除 API、service、repository、schema 和 ORM 定义,但已经由 `Base.metadata.create_all()` 创建的 SQLite 表与数据不会自动删除。如需清理,需要维护者在确认不再需要历史数据后手动删除相关表。
|
||||
- P3 是 Web 和文档改动。最小回滚方式是 revert P3 PR;不会删除已有规则、触发历史或 legacy JSON 配置。
|
||||
|
||||
BIN
docs/assets/issue-1202-p3-web-alert-center.png
Normal file
BIN
docs/assets/issue-1202-p3-web-alert-center.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 233 KiB |
@@ -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` 页面可做什么
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user