StrategyConfigApp.tsx 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574
  1. import { useCallback, useEffect, useMemo, useState } from "react";
  2. import {
  3. Alert,
  4. Button,
  5. Form,
  6. Input,
  7. InputNumber,
  8. Modal,
  9. Select,
  10. Space,
  11. Switch,
  12. Table,
  13. Tag,
  14. Typography,
  15. message,
  16. } from "antd";
  17. import type { ColumnsType } from "antd/es/table";
  18. import { PlusOutlined, ReloadOutlined } from "@ant-design/icons";
  19. import EllipsisCell from "./EllipsisCell";
  20. const API_BASE_URL =
  21. import.meta.env.VITE_API_BASE_URL ?? "/demand/api/v1";
  22. const getResolvedApiBaseUrl = () => {
  23. if (API_BASE_URL.startsWith("http://") || API_BASE_URL.startsWith("https://")) {
  24. return API_BASE_URL;
  25. }
  26. return new URL(API_BASE_URL, window.location.origin).toString();
  27. };
  28. type StrategyConfigItem = {
  29. strategy_id: string;
  30. name: string;
  31. version: string;
  32. params: Record<string, unknown>;
  33. active: boolean;
  34. daily_write_limit: number;
  35. priority: number;
  36. registered: boolean;
  37. create_time: string | null;
  38. updated_time: string | null;
  39. };
  40. type StrategyConfigResponse = {
  41. items: StrategyConfigItem[];
  42. };
  43. type AvailableStrategyItem = {
  44. strategy_id: string;
  45. name: string;
  46. version: string;
  47. };
  48. type AvailableStrategyResponse = {
  49. items: AvailableStrategyItem[];
  50. };
  51. function formatParamsPreview(params: Record<string, unknown>): string {
  52. const text = JSON.stringify(params ?? {}, null, 0);
  53. if (!text || text === "{}") {
  54. return "-";
  55. }
  56. return text.length > 80 ? `${text.slice(0, 80)}…` : text;
  57. }
  58. function formatExperimentNumber(value: number | null | undefined): string {
  59. if (value === undefined || value === null) {
  60. return "-";
  61. }
  62. return String(value);
  63. }
  64. const PARAMS_HINT = "策略运行阈值等参数,JSON 格式";
  65. const DAILY_LIMIT_HINT = "0 表示不限制,直到 staging 全部写入";
  66. const PRIORITY_HINT =
  67. "数值越小同批次越先选取;同名需求一旦写入 Hive,其他 priority 不可再写,仅同 priority 可重复";
  68. function parseParamsJson(raw: string): Record<string, unknown> {
  69. const trimmed = raw.trim();
  70. if (!trimmed) {
  71. return {};
  72. }
  73. const parsed: unknown = JSON.parse(trimmed);
  74. if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
  75. throw new Error("params 必须是 JSON 对象");
  76. }
  77. return parsed as Record<string, unknown>;
  78. }
  79. async function readErrorDetail(response: Response): Promise<string> {
  80. const text = await response.text();
  81. if (!text) {
  82. return `HTTP ${response.status}`;
  83. }
  84. try {
  85. const payload = JSON.parse(text) as { detail?: unknown };
  86. if (typeof payload.detail === "string") {
  87. return payload.detail;
  88. }
  89. } catch {
  90. /* 非 JSON 响应 */
  91. }
  92. return text;
  93. }
  94. function compareStrategyConfigs(a: StrategyConfigItem, b: StrategyConfigItem): number {
  95. if (a.active !== b.active) {
  96. return a.active ? -1 : 1;
  97. }
  98. const priorityA = a.priority ?? 0;
  99. const priorityB = b.priority ?? 0;
  100. if (priorityA !== priorityB) {
  101. return priorityA - priorityB;
  102. }
  103. return a.strategy_id.localeCompare(b.strategy_id);
  104. }
  105. export default function StrategyConfigApp() {
  106. const [loading, setLoading] = useState(false);
  107. const [error, setError] = useState("");
  108. const [items, setItems] = useState<StrategyConfigItem[]>([]);
  109. const [availableItems, setAvailableItems] = useState<AvailableStrategyItem[]>([]);
  110. const [createOpen, setCreateOpen] = useState(false);
  111. const [editOpen, setEditOpen] = useState(false);
  112. const [editingItem, setEditingItem] = useState<StrategyConfigItem | null>(null);
  113. const [createForm] = Form.useForm();
  114. const [editForm] = Form.useForm();
  115. const [submitting, setSubmitting] = useState(false);
  116. const [togglingId, setTogglingId] = useState<string | null>(null);
  117. const fetchConfigs = useCallback(async () => {
  118. setLoading(true);
  119. setError("");
  120. try {
  121. const resolvedBase = getResolvedApiBaseUrl();
  122. const baseWithSlash = resolvedBase.endsWith("/")
  123. ? resolvedBase
  124. : `${resolvedBase}/`;
  125. const url = new URL("strategy-configs", baseWithSlash);
  126. const response = await fetch(url.toString(), {
  127. method: "GET",
  128. headers: { Accept: "application/json" },
  129. });
  130. if (!response.ok) {
  131. throw new Error(await readErrorDetail(response));
  132. }
  133. const payload = (await response.json()) as StrategyConfigResponse;
  134. setItems(payload.items ?? []);
  135. } catch (queryError) {
  136. setError(
  137. queryError instanceof Error ? queryError.message : "加载策略配置失败",
  138. );
  139. } finally {
  140. setLoading(false);
  141. }
  142. }, []);
  143. const fetchAvailable = useCallback(async () => {
  144. try {
  145. const resolvedBase = getResolvedApiBaseUrl();
  146. const baseWithSlash = resolvedBase.endsWith("/")
  147. ? resolvedBase
  148. : `${resolvedBase}/`;
  149. const url = new URL("strategy-configs/available", baseWithSlash);
  150. const response = await fetch(url.toString(), {
  151. method: "GET",
  152. headers: { Accept: "application/json" },
  153. });
  154. if (!response.ok) {
  155. throw new Error(await readErrorDetail(response));
  156. }
  157. const payload = (await response.json()) as AvailableStrategyResponse;
  158. setAvailableItems(payload.items ?? []);
  159. } catch (queryError) {
  160. message.error(
  161. queryError instanceof Error ? queryError.message : "加载可添加策略失败",
  162. );
  163. }
  164. }, []);
  165. useEffect(() => {
  166. void fetchConfigs();
  167. }, [fetchConfigs]);
  168. const sortedItems = useMemo(
  169. () => [...items].sort(compareStrategyConfigs),
  170. [items],
  171. );
  172. const openCreateModal = () => {
  173. createForm.resetFields();
  174. createForm.setFieldsValue({
  175. paramsText: "{}",
  176. daily_write_limit: 0,
  177. priority: 0,
  178. active: false,
  179. });
  180. setCreateOpen(true);
  181. void fetchAvailable();
  182. };
  183. const openEditModal = (item: StrategyConfigItem) => {
  184. setEditingItem(item);
  185. editForm.setFieldsValue({
  186. paramsText: JSON.stringify(item.params ?? {}, null, 2),
  187. daily_write_limit: item.daily_write_limit ?? 0,
  188. priority: item.priority ?? 0,
  189. active: item.active,
  190. });
  191. setEditOpen(true);
  192. };
  193. const handleCreate = async () => {
  194. try {
  195. const values = await createForm.validateFields();
  196. setSubmitting(true);
  197. const params = parseParamsJson(String(values.paramsText ?? ""));
  198. const resolvedBase = getResolvedApiBaseUrl();
  199. const baseWithSlash = resolvedBase.endsWith("/")
  200. ? resolvedBase
  201. : `${resolvedBase}/`;
  202. const url = new URL("strategy-configs", baseWithSlash);
  203. const response = await fetch(url.toString(), {
  204. method: "POST",
  205. headers: {
  206. Accept: "application/json",
  207. "Content-Type": "application/json",
  208. },
  209. body: JSON.stringify({
  210. strategy_id: values.strategy_id,
  211. params,
  212. daily_write_limit: Number(values.daily_write_limit ?? 0),
  213. priority: Number(values.priority ?? 0),
  214. active: Boolean(values.active),
  215. }),
  216. });
  217. if (!response.ok) {
  218. throw new Error(await readErrorDetail(response));
  219. }
  220. message.success("策略配置已添加");
  221. setCreateOpen(false);
  222. await fetchConfigs();
  223. } catch (submitError) {
  224. if (submitError instanceof Error) {
  225. message.error(submitError.message);
  226. }
  227. } finally {
  228. setSubmitting(false);
  229. }
  230. };
  231. const handleEdit = async () => {
  232. if (!editingItem) {
  233. return;
  234. }
  235. try {
  236. const values = await editForm.validateFields();
  237. setSubmitting(true);
  238. const params = parseParamsJson(String(values.paramsText ?? ""));
  239. const resolvedBase = getResolvedApiBaseUrl();
  240. const baseWithSlash = resolvedBase.endsWith("/")
  241. ? resolvedBase
  242. : `${resolvedBase}/`;
  243. const url = new URL(`strategy-configs/${encodeURIComponent(editingItem.strategy_id)}`, baseWithSlash);
  244. const response = await fetch(url.toString(), {
  245. method: "PUT",
  246. headers: {
  247. Accept: "application/json",
  248. "Content-Type": "application/json",
  249. },
  250. body: JSON.stringify({
  251. params,
  252. daily_write_limit: Number(values.daily_write_limit ?? 0),
  253. priority: Number(values.priority ?? 0),
  254. active: Boolean(values.active),
  255. }),
  256. });
  257. if (!response.ok) {
  258. throw new Error(await readErrorDetail(response));
  259. }
  260. message.success("策略配置已更新");
  261. setEditOpen(false);
  262. setEditingItem(null);
  263. await fetchConfigs();
  264. } catch (submitError) {
  265. if (submitError instanceof Error) {
  266. message.error(submitError.message);
  267. }
  268. } finally {
  269. setSubmitting(false);
  270. }
  271. };
  272. const handleToggleActive = async (item: StrategyConfigItem) => {
  273. setTogglingId(item.strategy_id);
  274. try {
  275. const resolvedBase = getResolvedApiBaseUrl();
  276. const baseWithSlash = resolvedBase.endsWith("/")
  277. ? resolvedBase
  278. : `${resolvedBase}/`;
  279. const url = new URL(
  280. `strategy-configs/${encodeURIComponent(item.strategy_id)}/active`,
  281. baseWithSlash,
  282. );
  283. const response = await fetch(url.toString(), {
  284. method: "PATCH",
  285. headers: {
  286. Accept: "application/json",
  287. "Content-Type": "application/json",
  288. },
  289. body: JSON.stringify({ active: !item.active }),
  290. });
  291. if (!response.ok) {
  292. throw new Error(await readErrorDetail(response));
  293. }
  294. message.success(item.active ? "策略已暂停" : "策略已开始");
  295. await fetchConfigs();
  296. } catch (toggleError) {
  297. message.error(
  298. toggleError instanceof Error ? toggleError.message : "操作失败",
  299. );
  300. } finally {
  301. setTogglingId(null);
  302. }
  303. };
  304. const columns: ColumnsType<StrategyConfigItem> = useMemo(
  305. () => [
  306. {
  307. title: "策略 ID",
  308. dataIndex: "strategy_id",
  309. width: 220,
  310. render: (value) => <EllipsisCell value={value} />,
  311. },
  312. {
  313. title: "策略名称",
  314. dataIndex: "name",
  315. width: 160,
  316. render: (value) => value ?? "-",
  317. },
  318. {
  319. title: "版本",
  320. dataIndex: "version",
  321. width: 100,
  322. render: (value) => value ?? "-",
  323. },
  324. {
  325. title: "状态",
  326. dataIndex: "active",
  327. width: 100,
  328. render: (active: boolean) => (
  329. <Tag color={active ? "green" : "default"}>
  330. {active ? "运行中" : "已暂停"}
  331. </Tag>
  332. ),
  333. },
  334. {
  335. title: "代码注册",
  336. dataIndex: "registered",
  337. width: 110,
  338. render: (registered: boolean) => (
  339. <Tag color={registered ? "blue" : "orange"}>
  340. {registered ? "已注册" : "未注册"}
  341. </Tag>
  342. ),
  343. },
  344. {
  345. title: "日写入上限",
  346. dataIndex: "daily_write_limit",
  347. width: 110,
  348. render: (value: number) => formatExperimentNumber(value),
  349. },
  350. {
  351. title: "优先级",
  352. dataIndex: "priority",
  353. width: 90,
  354. render: (value: number) => formatExperimentNumber(value),
  355. },
  356. {
  357. title: "运行参数",
  358. dataIndex: "params",
  359. render: (params: Record<string, unknown>) => (
  360. <EllipsisCell value={formatParamsPreview(params)} />
  361. ),
  362. },
  363. {
  364. title: "更新时间",
  365. dataIndex: "updated_time",
  366. width: 180,
  367. render: (value) => value ?? "-",
  368. },
  369. {
  370. title: "操作",
  371. key: "actions",
  372. width: 220,
  373. fixed: "right",
  374. render: (_, record) => (
  375. <Space size="small">
  376. <Button type="link" size="small" onClick={() => openEditModal(record)}>
  377. 修改
  378. </Button>
  379. <Button
  380. type="link"
  381. size="small"
  382. loading={togglingId === record.strategy_id}
  383. onClick={() => void handleToggleActive(record)}
  384. >
  385. {record.active ? "暂停" : "开始"}
  386. </Button>
  387. </Space>
  388. ),
  389. },
  390. ],
  391. [togglingId],
  392. );
  393. return (
  394. <div className="page">
  395. <div className="hero">
  396. <Typography.Title level={2} className="hero-title">
  397. 需求策略配置
  398. </Typography.Title>
  399. <div className="hero-subtitle">
  400. <Tag color="blue">策略管理</Tag>
  401. <Tag color="cyan">参数配置</Tag>
  402. <Tag color="green">启停控制</Tag>
  403. <Tag color="purple">实验写入 ODPS</Tag>
  404. </div>
  405. </div>
  406. <div className="dashboard-shell">
  407. <div className="panel-sheet">
  408. <section className="panel-section panel-section--table">
  409. <div className="table-toolbar">
  410. <Space wrap>
  411. <Button
  412. type="primary"
  413. icon={<PlusOutlined />}
  414. onClick={openCreateModal}
  415. disabled={loading}
  416. >
  417. 添加策略
  418. </Button>
  419. <Button
  420. icon={<ReloadOutlined />}
  421. onClick={() => void fetchConfigs()}
  422. loading={loading}
  423. >
  424. 刷新
  425. </Button>
  426. </Space>
  427. </div>
  428. {error ? (
  429. <Alert
  430. type="error"
  431. showIcon
  432. message={error}
  433. style={{ marginBottom: 16 }}
  434. />
  435. ) : null}
  436. <Table<StrategyConfigItem>
  437. rowKey="strategy_id"
  438. loading={loading}
  439. columns={columns}
  440. dataSource={sortedItems}
  441. pagination={false}
  442. scroll={{ x: 1200 }}
  443. locale={{ emptyText: "暂无策略配置,点击「添加策略」创建" }}
  444. />
  445. </section>
  446. </div>
  447. </div>
  448. <Modal
  449. title="添加策略配置"
  450. open={createOpen}
  451. onCancel={() => setCreateOpen(false)}
  452. onOk={() => void handleCreate()}
  453. confirmLoading={submitting}
  454. okText="添加"
  455. cancelText="取消"
  456. destroyOnClose
  457. width={640}
  458. >
  459. <Form form={createForm} layout="vertical">
  460. <Form.Item
  461. label="策略"
  462. name="strategy_id"
  463. rules={[{ required: true, message: "请选择策略" }]}
  464. >
  465. <Select
  466. placeholder="选择要添加的策略"
  467. options={availableItems.map((item) => ({
  468. value: item.strategy_id,
  469. label: `${item.name} (${item.strategy_id})`,
  470. }))}
  471. notFoundContent="暂无可添加策略"
  472. />
  473. </Form.Item>
  474. <Form.Item
  475. label="日写入上限"
  476. name="daily_write_limit"
  477. extra={DAILY_LIMIT_HINT}
  478. >
  479. <InputNumber min={0} precision={0} style={{ width: "100%" }} />
  480. </Form.Item>
  481. <Form.Item
  482. label="优先级"
  483. name="priority"
  484. extra={PRIORITY_HINT}
  485. >
  486. <InputNumber min={0} precision={0} style={{ width: "100%" }} />
  487. </Form.Item>
  488. <Form.Item
  489. label="运行参数 (JSON)"
  490. name="paramsText"
  491. rules={[{ required: true, message: "请输入运行参数" }]}
  492. extra={PARAMS_HINT}
  493. >
  494. <Input.TextArea rows={8} placeholder="{}" />
  495. </Form.Item>
  496. <Form.Item label="创建后立即开始" name="active" valuePropName="checked">
  497. <Switch />
  498. </Form.Item>
  499. </Form>
  500. </Modal>
  501. <Modal
  502. title={editingItem ? `修改策略:${editingItem.name}` : "修改策略配置"}
  503. open={editOpen}
  504. onCancel={() => {
  505. setEditOpen(false);
  506. setEditingItem(null);
  507. }}
  508. onOk={() => void handleEdit()}
  509. confirmLoading={submitting}
  510. okText="保存"
  511. cancelText="取消"
  512. destroyOnClose
  513. width={640}
  514. >
  515. <Form form={editForm} layout="vertical">
  516. <Form.Item
  517. label="日写入上限"
  518. name="daily_write_limit"
  519. extra={DAILY_LIMIT_HINT}
  520. >
  521. <InputNumber min={0} precision={0} style={{ width: "100%" }} />
  522. </Form.Item>
  523. <Form.Item
  524. label="优先级"
  525. name="priority"
  526. extra={PRIORITY_HINT}
  527. >
  528. <InputNumber min={0} precision={0} style={{ width: "100%" }} />
  529. </Form.Item>
  530. <Form.Item
  531. label="运行参数 (JSON)"
  532. name="paramsText"
  533. rules={[{ required: true, message: "请输入运行参数" }]}
  534. extra={PARAMS_HINT}
  535. >
  536. <Input.TextArea rows={10} />
  537. </Form.Item>
  538. <Form.Item label="运行状态" name="active" valuePropName="checked">
  539. <Switch checkedChildren="运行中" unCheckedChildren="已暂停" />
  540. </Form.Item>
  541. </Form>
  542. </Modal>
  543. </div>
  544. );
  545. }