diff --git a/docs/plans/2026-03-16-downstream-key-ui-and-site-links.md b/docs/plans/2026-03-16-downstream-key-ui-and-site-links.md new file mode 100644 index 0000000..902228e --- /dev/null +++ b/docs/plans/2026-03-16-downstream-key-ui-and-site-links.md @@ -0,0 +1,160 @@ +# Downstream Key UI And Site Links Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Restyle the downstream key page to match the restrained management-console pages and standardize site buttons across pages using the accounts page interaction. + +**Architecture:** Introduce one shared site link component based on the accounts page behavior, then update each affected page to consume it. Refactor downstream key layout styling toward existing `card`, `badge`, and `data-table` patterns without changing backend behavior. + +**Tech Stack:** React, TypeScript, React Router, Vitest, React Test Renderer + +--- + +### Task 1: Extract the shared site button + +**Files:** +- Create: `src/web/components/SiteBadgeLink.tsx` +- Test: `src/web/components/site-badge-link.test.tsx` +- Modify: `src/web/pages/Accounts.tsx` +- Modify: `src/web/index.css` + +**Step 1: Write the failing test** + +Add a component test that renders a site badge link and asserts: +- it renders the site name, +- it has the shared interactive class, +- clicking navigates to `/sites?focusSiteId=`. + +**Step 2: Run test to verify it fails** + +Run: `npm test -- src/web/components/site-badge-link.test.tsx` + +Expected: FAIL because the component does not exist yet. + +**Step 3: Write minimal implementation** + +- Create a small component that wraps `Link` from `react-router-dom`. +- Accept `siteId`, `siteName`, and optional badge class props. +- Reproduce the accounts page badge-link styling and fallback behavior. +- Move the accounts page site badge rendering to this component. +- Add any missing shared CSS class if accounts currently relies on page-local styling. + +**Step 4: Run test to verify it passes** + +Run: `npm test -- src/web/components/site-badge-link.test.tsx src/web/pages/accounts.segmented-connections.test.tsx` + +Expected: PASS + +**Step 5: Commit** + +```bash +git add src/web/components/SiteBadgeLink.tsx src/web/components/site-badge-link.test.tsx src/web/pages/Accounts.tsx src/web/index.css +git commit -m "refactor: extract shared site badge link" +``` + +### Task 2: Apply the shared site button across affected pages + +**Files:** +- Modify: `src/web/pages/Models.tsx` +- Modify: `src/web/pages/ProxyLogs.tsx` +- Modify: `src/web/pages/TokenRoutes.tsx` +- Modify: `src/web/pages/DownstreamKeys.tsx` +- Test: `src/web/pages/Models.marketplace-text.test.tsx` +- Test: `src/web/pages/tokenRoutes.group-collapse.test.tsx` +- Test: `src/web/pages/DownstreamKeys.test.tsx` + +**Step 1: Write failing assertions** + +Add or update tests to assert the affected pages render the shared site button class and, where practical, point to the focused site management route. + +**Step 2: Run test to verify it fails** + +Run: `npm test -- src/web/pages/Models.marketplace-text.test.tsx src/web/pages/tokenRoutes.group-collapse.test.tsx src/web/pages/DownstreamKeys.test.tsx` + +Expected: FAIL because the pages still use inconsistent site rendering. + +**Step 3: Write minimal implementation** + +- Replace page-local site label rendering with `SiteBadgeLink`. +- Only convert labels that represent navigable management-site references. +- Preserve non-site filters, external URLs, and unrelated badges. + +**Step 4: Run test to verify it passes** + +Run: `npm test -- src/web/pages/Models.marketplace-text.test.tsx src/web/pages/tokenRoutes.group-collapse.test.tsx src/web/pages/DownstreamKeys.test.tsx src/web/pages/ProxyLogs.server-driven.test.tsx` + +Expected: PASS + +**Step 5: Commit** + +```bash +git add src/web/pages/Models.tsx src/web/pages/ProxyLogs.tsx src/web/pages/TokenRoutes.tsx src/web/pages/DownstreamKeys.tsx src/web/pages/Models.marketplace-text.test.tsx src/web/pages/tokenRoutes.group-collapse.test.tsx src/web/pages/DownstreamKeys.test.tsx +git commit -m "feat: unify site badge navigation across pages" +``` + +### Task 3: Restyle downstream keys page + +**Files:** +- Modify: `src/web/pages/DownstreamKeys.tsx` +- Test: `src/web/pages/DownstreamKeys.test.tsx` +- Modify: `src/web/index.css` + +**Step 1: Write failing assertions** + +Add focused assertions for the downstream keys page structure where needed, such as shared management-page card classes, reduced bespoke styling hooks, or the new site badge placement. + +**Step 2: Run test to verify it fails** + +Run: `npm test -- src/web/pages/DownstreamKeys.test.tsx` + +Expected: FAIL after adding assertions that reflect the updated restrained layout. + +**Step 3: Write minimal implementation** + +- Reduce the page’s dashboard-like presentation. +- Align summary, filters, and batch action sections with the same management-console card language used in sites/accounts. +- Reuse shared badge/link patterns instead of one-off visual treatments where practical. +- Keep operations, data fetching, and drawer behavior unchanged. + +**Step 4: Run test to verify it passes** + +Run: `npm test -- src/web/pages/DownstreamKeys.test.tsx` + +Expected: PASS + +**Step 5: Commit** + +```bash +git add src/web/pages/DownstreamKeys.tsx src/web/pages/DownstreamKeys.test.tsx src/web/index.css +git commit -m "style: align downstream key page with management UI" +``` + +### Task 4: Final verification + +**Files:** +- Modify: none + +**Step 1: Run targeted verification** + +Run: +- `npm test -- src/web/components/site-badge-link.test.tsx` +- `npm test -- src/web/pages/DownstreamKeys.test.tsx` +- `npm test -- src/web/pages/Models.marketplace-text.test.tsx` +- `npm test -- src/web/pages/tokenRoutes.group-collapse.test.tsx` +- `npm test -- src/web/pages/ProxyLogs.server-driven.test.tsx` +- `npm test -- src/web/pages/accounts.segmented-connections.test.tsx` + +Expected: PASS + +**Step 2: Run broader sanity verification if time permits** + +Run: `npm test -- src/web/App.sidebar.test.ts src/web/App.sidebar-mobile.test.tsx` + +Expected: PASS + +**Step 3: Commit final polish** + +```bash +git add -A +git commit -m "test: verify downstream key UI and site link consistency" +``` diff --git a/src/web/components/SiteBadgeLink.tsx b/src/web/components/SiteBadgeLink.tsx new file mode 100644 index 0000000..c18bc1b --- /dev/null +++ b/src/web/components/SiteBadgeLink.tsx @@ -0,0 +1,37 @@ +import React from 'react'; +import { Link } from 'react-router-dom'; + +type SiteBadgeLinkProps = { + siteId?: number | null; + siteName?: string | null; + className?: string; + badgeClassName?: string; + badgeStyle?: React.CSSProperties; +}; + +export default function SiteBadgeLink({ + siteId, + siteName, + className = 'badge-link', + badgeClassName = 'badge badge-muted', + badgeStyle, +}: SiteBadgeLinkProps) { + const label = String(siteName || '').trim() || '-'; + const normalizedSiteId = Number(siteId); + + if (!Number.isFinite(normalizedSiteId) || normalizedSiteId <= 0) { + return ( + + {label} + + ); + } + + return ( + + + {label} + + + ); +} diff --git a/src/web/components/site-badge-link.test.tsx b/src/web/components/site-badge-link.test.tsx new file mode 100644 index 0000000..b01b69a --- /dev/null +++ b/src/web/components/site-badge-link.test.tsx @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest'; +import { create } from 'react-test-renderer'; +import { MemoryRouter } from 'react-router-dom'; +import SiteBadgeLink from './SiteBadgeLink.js'; + +describe('SiteBadgeLink', () => { + it('renders a focus-navigation link when site id is valid', () => { + const root = create( + + + , + ); + + const link = root.root.findByType('a'); + expect(String(link.props.href || '')).toContain('/sites?focusSiteId=7'); + expect(String(link.props.className || '')).toContain('badge-link'); + expect(root.root.findByProps({ className: 'badge badge-muted' }).children.join('')).toContain('Demo Site'); + + root.unmount(); + }); + + it('falls back to plain badge text when site id is invalid', () => { + const root = create( + + + , + ); + + expect(root.root.findAllByType('a')).toHaveLength(0); + expect(root.root.findByProps({ className: 'badge badge-muted' }).children.join('')).toContain('Unknown Site'); + + root.unmount(); + }); +}); diff --git a/src/web/index.css b/src/web/index.css index 3151f7e..ab00a2d 100644 --- a/src/web/index.css +++ b/src/web/index.css @@ -1891,6 +1891,149 @@ body { outline: none; } +.downstream-key-modal-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; +} + +.downstream-key-modal-field { + display: flex; + flex-direction: column; + gap: 6px; + min-width: 0; +} + +.downstream-key-modal-label { + font-size: 12px; + line-height: 1.4; + font-weight: 500; + color: var(--color-text-muted); +} + +.downstream-key-modal-field-full { + width: 100%; +} + +.downstream-key-modal-toggle { + display: flex; + align-items: center; + gap: 10px; + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + padding: 10px 12px; + background: var(--color-bg); + cursor: pointer; + min-height: 42px; +} + +.downstream-key-modal-toggle-title { + font-size: 13px; + line-height: 1.35; + font-weight: 600; + color: var(--color-text-primary); +} + +.downstream-key-modal-help { + font-size: 12px; + line-height: 1.55; + color: var(--color-text-muted); +} + +.downstream-key-modal-meta { + font-size: 12px; + line-height: 1.45; + color: var(--color-text-secondary); +} + +.downstream-key-modal-section-title { + font-size: 13px; + line-height: 1.35; + font-weight: 600; + color: var(--color-text-primary); +} + +.downstream-key-advanced { + display: flex; + flex-direction: column; + gap: 10px; +} + +.downstream-key-advanced-toggle { + width: 100%; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 10px 12px; + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + background: var(--color-bg); + color: var(--color-text-primary); + cursor: pointer; + font-size: 13px; + font-weight: 600; + line-height: 1.35; +} + +.downstream-key-advanced-toggle:hover { + border-color: color-mix(in srgb, var(--color-primary) 35%, var(--color-border)); + background: color-mix(in srgb, var(--color-primary) 5%, var(--color-bg)); +} + +.downstream-key-advanced-content { + display: flex; + flex-direction: column; + gap: 12px; + padding: 12px; + border: 1px solid var(--color-border-light); + border-radius: var(--radius-md); + background: color-mix(in srgb, var(--color-bg) 60%, var(--color-bg-card)); +} + +.downstream-key-advanced-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; +} + +.downstream-key-advanced-panel { + border: 1px solid var(--color-border-light); + border-radius: var(--radius-md); + padding: 12px; + display: flex; + flex-direction: column; + gap: 10px; + background: var(--color-bg-card); +} + +.downstream-key-modal-grid input, +.downstream-key-modal-grid textarea, +.downstream-key-advanced input, +.downstream-key-advanced textarea { + font-size: 13px; +} + +.downstream-key-modal-grid input::placeholder, +.downstream-key-modal-grid textarea::placeholder, +.downstream-key-advanced input::placeholder, +.downstream-key-advanced textarea::placeholder { + font-size: 13px; + color: color-mix(in srgb, var(--color-text-muted) 92%, white); +} + +.downstream-key-modal-grid textarea, +.downstream-key-advanced textarea { + line-height: 1.5; +} + +@media (max-width: 900px) { + .downstream-key-modal-grid, + .downstream-key-advanced-grid { + grid-template-columns: 1fr; + } +} + .route-enable-toggle { border: 1px solid transparent; transition: background-color 0.18s ease, color 0.18s ease, border-color 0.18s ease, box-shadow 0.18s ease, transform 0.18s ease; @@ -4724,4 +4867,4 @@ textarea { .route-card-grid { grid-template-columns: 1fr; } -} \ No newline at end of file +} diff --git a/src/web/pages/Accounts.tsx b/src/web/pages/Accounts.tsx index 1659ddd..3a9bd94 100644 --- a/src/web/pages/Accounts.tsx +++ b/src/web/pages/Accounts.tsx @@ -7,6 +7,7 @@ import ModernSelect from '../components/ModernSelect.js'; import { MobileCard, MobileField } from '../components/MobileCard.js'; import { useIsMobile } from '../components/useIsMobile.js'; import DeleteConfirmModal from '../components/DeleteConfirmModal.js'; +import SiteBadgeLink from '../components/SiteBadgeLink.js'; import { buildAddAccountPrereqHint, buildVerifyFailureHint, @@ -1634,21 +1635,12 @@ export default function Accounts() {
- - {a.site?.name || '-'} - - - ) : ( - - {a.site?.name || '-'} - + value={( + )} /> - {a.site?.url ? ( - - - {a.site?.name || '-'} - - - ) : ( - - {a.site?.name || '-'} - - )} + {(() => { diff --git a/src/web/pages/DownstreamKeys.test.tsx b/src/web/pages/DownstreamKeys.test.tsx index af8153b..c5730d6 100644 --- a/src/web/pages/DownstreamKeys.test.tsx +++ b/src/web/pages/DownstreamKeys.test.tsx @@ -132,7 +132,7 @@ beforeEach(() => { apiMock.getDownstreamApiKeysSummary.mockResolvedValue({ success: true, items: [buildSummaryItem()] }); apiMock.getDownstreamApiKeys.mockResolvedValue({ success: true, items: [buildRawItem()] }); apiMock.getRoutesLite.mockResolvedValue([ - { id: 11, modelPattern: 'team/default', displayName: '默认群组', enabled: true }, + { id: 11, modelPattern: 'claude-*', displayName: '默认群组', enabled: true }, { id: 12, modelPattern: 'gpt-4.1-mini', displayName: 'GPT 4.1 Mini', enabled: true }, ]); apiMock.getDownstreamApiKeyOverview.mockResolvedValue({ @@ -184,6 +184,8 @@ describe('DownstreamKeys page', () => { const text = collectText(root!.root); expect(text).toContain('下游密钥'); + expect(text).toContain('范围概览'); + expect(text).toContain('筛选与列表'); expect(text).toContain('smoke-key'); expect(text).toContain('sk-s****0315'); expect(text).toContain('默认群组'); @@ -264,9 +266,14 @@ describe('DownstreamKeys page', () => { }); await flushMicrotasks(); + expect(collectText(root!.root)).toContain('高级配置'); + expect(collectText(root!.root)).not.toContain('站点倍率 JSON'); + const inputs = root!.root.findAllByType('input'); + const tagInput = inputs.find((node) => node.props.placeholder === '输入标签后按回车或逗号,例如:移动端、VIP、项目A'); const nameInput = inputs.find((node) => node.props.placeholder === '例如:项目 A / 移动端'); const keyInput = inputs.find((node) => node.props.placeholder === 'sk-...'); + expect(tagInput?.props.style?.fontSize).toBe(13); await act(async () => { nameInput!.props.onChange({ target: { value: 'new-key' } }); keyInput!.props.onChange({ target: { value: 'sk-new-key-0315' } }); @@ -310,6 +317,50 @@ describe('DownstreamKeys page', () => { } }); + it('separates exact models from group routes in advanced config and uses single-column layout', async () => { + let root: ReturnType | null = null; + try { + await act(async () => { + root = create( + + + + + , + ); + }); + await flushMicrotasks(); + + const createBtn = root!.root.findAll((node) => node.type === 'button' && collectText(node).includes('新增下游密钥'))[0]; + await act(async () => { + createBtn.props.onClick(); + }); + await flushMicrotasks(); + + const advancedBtn = root!.root.findAll((node) => node.type === 'button' && collectText(node).includes('高级配置'))[0]; + await act(async () => { + advancedBtn.props.onClick(); + }); + await flushMicrotasks(); + + const panels = root!.root.findAll((node) => node.props.className === 'downstream-key-advanced-panel'); + const modelPanel = panels.find((node) => collectText(node).includes('模型白名单')); + const groupPanel = panels.find((node) => collectText(node).includes('群组范围')); + const advancedGrid = root!.root.findAll((node) => node.props.className === 'downstream-key-advanced-grid')[0]; + + expect(modelPanel).toBeTruthy(); + expect(groupPanel).toBeTruthy(); + expect(collectText(modelPanel!)).toContain('gpt-4.1-mini'); + expect(collectText(modelPanel!)).not.toContain('默认群组'); + expect(collectText(modelPanel!)).not.toContain('claude-*'); + expect(collectText(groupPanel!)).toContain('默认群组'); + expect(collectText(groupPanel!)).not.toContain('GPT 4.1 Mini'); + expect(advancedGrid.props.style?.gridTemplateColumns).toBe('1fr'); + } finally { + root?.unmount(); + } + }); + it('uses backend batch api for selected rows', async () => { let root: ReturnType | null = null; try { @@ -329,6 +380,7 @@ describe('DownstreamKeys page', () => { checkbox!.props.onChange({ target: { checked: true } }); }); await flushMicrotasks(); + expect(collectText(root!.root)).toContain('已选 1 个密钥'); const batchButton = root!.root.findAll((node) => node.type === 'button' && collectText(node).includes('批量启用'))[0]; await act(async () => { @@ -365,6 +417,12 @@ describe('DownstreamKeys page', () => { }); await flushMicrotasks(); + const advancedBtn = root!.root.findAll((node) => node.type === 'button' && collectText(node).includes('高级配置'))[0]; + await act(async () => { + advancedBtn.props.onClick(); + }); + await flushMicrotasks(); + const inputs = root!.root.findAllByType('input'); const nameInput = inputs.find((node) => node.props.placeholder === '例如:项目 A / 移动端'); const keyInput = inputs.find((node) => node.props.placeholder === 'sk-...'); diff --git a/src/web/pages/DownstreamKeys.tsx b/src/web/pages/DownstreamKeys.tsx index 13cd2a0..36ac5d6 100644 --- a/src/web/pages/DownstreamKeys.tsx +++ b/src/web/pages/DownstreamKeys.tsx @@ -170,6 +170,10 @@ function routeTitle(route: RouteSelectorItem): string { return displayName || route.modelPattern; } +function isGroupRouteOption(route: RouteSelectorItem): boolean { + return !isExactModelPattern(route.modelPattern); +} + function uniqStrings(values: string[]): string[] { return [...new Set(values.map((value) => String(value || '').trim()).filter(Boolean))]; } @@ -312,7 +316,7 @@ function TagChips({ maxVisible?: number; }) { if (!Array.isArray(tags) || tags.length === 0) { - return 无标签; + return 无标签; } const visible = tags.slice(0, maxVisible); @@ -320,11 +324,15 @@ function TagChips({ return (
{visible.map((tag) => ( - + {tag} ))} - {hidden > 0 ? {`+${hidden}`} : null} + {hidden > 0 ? {`+${hidden}`} : null}
); } @@ -391,7 +399,7 @@ function TagInput({ } }} placeholder={placeholder || '输入标签后按回车或逗号'} - style={{ width: '100%', border: 'none', outline: 'none', background: 'transparent', color: 'var(--color-text-primary)', padding: 0 }} + style={{ width: '100%', border: 'none', outline: 'none', background: 'transparent', color: 'var(--color-text-primary)', padding: 0, fontSize: 13, lineHeight: 1.45 }} />
{suggestionPool.length > 0 ? ( @@ -421,9 +429,9 @@ function SummaryMetric({ value: string; }) { return ( -
+
{label} - {value} + {value}
); } @@ -440,15 +448,7 @@ function resolveOverviewUsageByRange( function TrendChartFallback({ height = 260 }: { height?: number }) { return ( -
+
@@ -542,29 +542,7 @@ function RangeToggle({ range, onChange }: { range: Range; onChange: (r: Range) = function StatusBadge({ enabled }: { enabled: boolean }) { return ( - - + {enabled ? '启用' : '禁用'} ); @@ -660,7 +638,7 @@ function Drawer({ animation: presence.isVisible ? 'drawer-slide-in 0.3s cubic-bezier(0.22, 1, 0.36, 1) both' : 'drawer-slide-out 0.22s cubic-bezier(0.4, 0, 1, 1) both', }} > -
+
{item?.name || '--'} @@ -670,7 +648,7 @@ function Drawer({ {item?.keyMasked || '****'}
- + {item?.groupName ? `主分组 · ${item.groupName}` : '未分组'} @@ -682,22 +660,23 @@ function Drawer({
-
-
- 趋势 +
+
+
+
使用趋势
+
按选定时间窗口查看请求、Tokens 与成本变化。
+
+
- + + }> + +
- }> - - - -
- -
-
- 概览 +
+
+ 基础信息
{overviewLoading ? (
@@ -731,10 +710,8 @@ function Drawer({ )}
-
-
-
+
当前范围汇总
@@ -759,9 +736,8 @@ function Drawer({ {overview?.usage ? ( <> -
-
-
+
+
固定窗口对比
@@ -817,11 +793,13 @@ function EditorModal({ }) { const [modelSearch, setModelSearch] = useState(''); const [groupSearch, setGroupSearch] = useState(''); + const [advancedOpen, setAdvancedOpen] = useState(false); useEffect(() => { if (!open) { setModelSearch(''); setGroupSearch(''); + setAdvancedOpen(false); } }, [open]); @@ -829,6 +807,18 @@ function EditorModal({ () => uniqStrings(routeOptions.filter((item) => isExactModelPattern(item.modelPattern)).map((item) => item.modelPattern)).sort((a, b) => a.localeCompare(b)), [routeOptions], ); + const groupRouteOptions = useMemo( + () => routeOptions.filter(isGroupRouteOption), + [routeOptions], + ); + const validGroupRouteIdSet = useMemo( + () => new Set(groupRouteOptions.map((route) => route.id)), + [groupRouteOptions], + ); + const normalizedSelectedGroupRouteIds = useMemo( + () => uniqIds(form.selectedGroupRouteIds.filter((id) => validGroupRouteIdSet.has(id))), + [form.selectedGroupRouteIds, validGroupRouteIdSet], + ); const filteredModels = useMemo(() => { const keyword = modelSearch.trim().toLowerCase(); @@ -838,15 +828,15 @@ function EditorModal({ const filteredGroups = useMemo(() => { const keyword = groupSearch.trim().toLowerCase(); - if (!keyword) return routeOptions; - return routeOptions.filter((route) => { + if (!keyword) return groupRouteOptions; + return groupRouteOptions.filter((route) => { const title = routeTitle(route).toLowerCase(); return title.includes(keyword) || route.modelPattern.toLowerCase().includes(keyword); }); - }, [groupSearch, routeOptions]); + }, [groupRouteOptions, groupSearch]); const selectedModelCount = form.selectedModels.length; - const selectedGroupCount = form.selectedGroupRouteIds.length; + const selectedGroupCount = normalizedSelectedGroupRouteIds.length; const inputStyle: React.CSSProperties = { width: '100%', padding: '10px 12px', @@ -854,6 +844,8 @@ function EditorModal({ borderRadius: 'var(--radius-sm)', background: 'var(--color-bg)', color: 'var(--color-text-primary)', + fontSize: 13, + lineHeight: 1.45, }; return ( @@ -861,7 +853,7 @@ function EditorModal({ open={open} onClose={onClose} title={editingItem ? '编辑下游密钥' : '新增下游密钥'} - maxWidth={1040} + maxWidth={860} bodyStyle={{ display: 'flex', flexDirection: 'column', gap: 12 }} footer={( <> @@ -875,20 +867,20 @@ function EditorModal({ )} >
- 支持为每个下游密钥独立配置主分组、标签、过期时间、额度上限、模型白名单与群组范围。留空表示不限制。 + 支持为每个下游密钥独立配置分组、标签、额度与有效期。高级限制项可按需展开。
-
-
-
名称
+
+
+
名称
onChange((prev) => ({ ...prev, name: e.target.value }))} placeholder="例如:项目 A / 移动端" style={inputStyle} />
-
-
下游密钥
+
+
下游密钥
onChange((prev) => ({ ...prev, key: e.target.value }))} placeholder="sk-..." style={{ ...inputStyle, fontFamily: 'var(--font-mono)' }} />
-
-
主分组
+
+
主分组
onChange((prev) => ({ ...prev, groupName: e.target.value }))} @@ -897,42 +889,31 @@ function EditorModal({ style={inputStyle} />
-
-
请求额度
+
+
请求额度
onChange((prev) => ({ ...prev, maxRequests: e.target.value }))} placeholder="留空表示不限" style={inputStyle} />
-
-
成本额度
+
+
成本额度
onChange((prev) => ({ ...prev, maxCost: e.target.value }))} placeholder="留空表示不限" style={inputStyle} />
-
-
过期时间
+
+
过期时间
onChange((prev) => ({ ...prev, expiresAt: e.target.value }))} style={inputStyle} />
-
-
备注说明
+
+
备注说明