fix: pre-launch UI hardening + KOL reduce-action type, proxy IP relay, settings redesign

Frontend half of the pre-launch audit campaign:

- types/index.ts + kol/KolPageClient.tsx: add missing 'reduce' KolAction
  (backend emits it; frontend lacked the type + color/label maps → undefined
  styling). Adds ACTION_COLOR/actionLabel/postActionLabel entries.
- proxy/[...path]/route.ts: relay real client IP (x-forwarded-for / x-real-ip)
  so the backend rate limiter buckets per-user instead of per-Next-server (BUG-02).
- Settings/BotConfigPanel redesign, paper-mode clarity, copy cleanup.
- Assorted page/display fixes, loading states, Pagination component.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
k
2026-05-29 11:57:43 +08:00
parent b76de36af0
commit d50c05b120
32 changed files with 1003 additions and 224 deletions
+4 -3
View File
@@ -1,6 +1,6 @@
'use client'
import { useEffect, useState } from 'react'
import { memo, useEffect, useState } from 'react'
import type { TrumpPost } from '@/types'
function fmtPct(n: number | null | undefined) {
@@ -93,7 +93,7 @@ interface PostRowProps {
onClick?: () => void
}
export default function PostRow({ post, selected, onClick }: PostRowProps) {
const PostRow = memo(function PostRow({ post, selected, onClick }: PostRowProps) {
const [expanded, setExpanded] = useState(false)
const impact = post.price_impact
@@ -260,6 +260,7 @@ export default function PostRow({ post, selected, onClick }: PostRowProps) {
)}
</div>
)
}
})
export default PostRow
export { SignalPill, SourceIcon, fmtPct, timeAgo, TimeAgo, LocalDateTime }
+1
View File
@@ -136,6 +136,7 @@ export default function Navbar() {
key={tab.key}
href={`/${locale}${tab.key === '/' ? '' : tab.key}`}
className={`nav-tab ${isActive(tab.key) ? 'active' : ''}`}
prefetch
>
{tab.label}
</Link>
+38 -7
View File
@@ -179,11 +179,20 @@ export default function OpenPositions() {
useEffect(() => { setMounted(true) }, [])
const [closeErr, setCloseErr] = useState('')
const [growId, setGrowId] = useState<number | null>(null)
// Separate error slot for the Grow toggle so a transient toggle failure
// doesn't squat in the panel-header `err` banner (which is otherwise
// owned by the 15s poll). Auto-clears after 4s.
const [growErr, setGrowErr] = useState('')
useEffect(() => {
if (!growErr) return
const t = setTimeout(() => setGrowErr(''), 4000)
return () => clearTimeout(t)
}, [growErr])
async function toggleGrow(p: OpenPosition) {
if (!address || growId !== null) return
const next = !p.grow_mode
setGrowId(p.trade_id)
setGrowId(p.trade_id); setGrowErr('')
try {
const env = await signRequest({
action: 'set_trade_grow', wallet: address,
@@ -194,9 +203,9 @@ export default function OpenPositions() {
x.trade_id === p.trade_id ? { ...x, grow_mode: r.grow_mode } : x))
} catch (e) {
if (isUserRejection(e)) {
setErr(isZh ? 'Grow 切换已取消' : 'Grow toggle cancelled')
setGrowErr(isZh ? 'Grow 切换已取消' : 'Grow toggle cancelled')
} else {
setErr(walletErrorLabel(e, isZh ? 'Grow 切换已取消' : 'Grow toggle cancelled', 90)
setGrowErr(walletErrorLabel(e, isZh ? 'Grow 切换已取消' : 'Grow toggle cancelled', 90)
|| (isZh ? 'Grow 切换失败' : 'grow toggle failed'))
}
} finally { setGrowId(null) }
@@ -237,7 +246,12 @@ export default function OpenPositions() {
void load()
const id = setInterval(() => { void load() }, POLL_MS)
return () => { cancelled = true; clearInterval(id) }
}, [address, isConnected, signMessageAsync, isZh])
// signMessageAsync and isZh are intentionally excluded: signMessageAsync is
// never called inside this effect (load() uses cached envelopes only), and
// isZh is a compile-time constant (always false). Including either would
// restart the 15-second poll timer on every wagmi reference change.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [address, isConnected])
// Trigger close. Two-step: opens modal, user confirms, we sign + POST.
async function confirmCloseTrade() {
@@ -257,8 +271,15 @@ export default function OpenPositions() {
setClosing(null)
setCloseState('idle')
} catch (e: unknown) {
setCloseErr(walletErrorLabel(e, isZh ? '已取消' : 'Cancelled', 120))
setCloseState('err')
// Distinguish a user-cancelled signature (benign, close the modal) from
// a real failure (keep modal open in 'err' state so user can retry or
// read the error).
if (isUserRejection(e)) {
setClosing(null); setCloseState('idle'); setCloseErr('')
} else {
setCloseErr(walletErrorLabel(e, isZh ? '已取消' : 'Cancelled', 120))
setCloseState('err')
}
}
}
@@ -351,6 +372,9 @@ export default function OpenPositions() {
{err && (
<span style={{ fontSize: 11, color: 'var(--down)' }}> {err}</span>
)}
{growErr && !err && (
<span style={{ fontSize: 11, color: 'var(--down)' }}> {growErr}</span>
)}
</div>
{/* Position list (or empty state) */}
@@ -396,7 +420,14 @@ export default function OpenPositions() {
presses "Close now", reads the impact summary, then confirms. */}
{closing && (
<div
onClick={() => closeState === 'idle' && setClosing(null)}
onClick={() => {
// Backdrop dismisses in both idle (never started) and err
// (failed, user wants out) states. NOT during signing/closing —
// the wallet popup or in-flight POST shouldn't be orphaned.
if (closeState === 'idle' || closeState === 'err') {
setClosing(null); setCloseState('idle'); setCloseErr('')
}
}}
style={{
position: 'fixed', inset: 0, zIndex: 1000,
background: 'rgba(0,0,0,0.55)',
+7 -2
View File
@@ -73,6 +73,12 @@ export default function SystemControl({ system }: { system: 'trump' | 'btc' }) {
if (!subscribed) { setErr(isZh ? '钱包尚未订阅,请先去设置页完成订阅。' : 'Wallet not subscribed — subscribe on the Settings page first.'); return }
if (autoOn === on) return
// Claim the busy slot BEFORE awaiting confirmSign — otherwise a rapid
// second click on the same pill (or the opposite pill) slips past the
// `if (busy) return` guard and queues a second sheet + second signature.
// We still clear it in `finally` below.
setBusy(true); setErr('')
// Show pre-signing confirmation sheet before triggering MetaMask
const confirmed = await confirmSign(on
? {
@@ -96,9 +102,8 @@ export default function SystemControl({ system }: { system: 'trump' | 'btc' }) {
danger: false,
}
)
if (!confirmed) return
if (!confirmed) { setBusy(false); return } // release the slot we claimed
setErr(''); setBusy(true)
try {
const env = await signRequest({
action: 'set_auto_trade', wallet: address,
+17 -4
View File
@@ -389,7 +389,12 @@ export default function BotConfigPanel() {
<span style={{ fontSize: 11, color: subPaperChoice === 'paper' ? 'var(--ink-2)' : 'var(--ink-4)', fontWeight: subPaperChoice === 'paper' ? 600 : 400 }}>Paper</span>
<Switch
on={subPaperChoice === 'live'}
onChange={v => setSubPaperChoice(v ? 'live' : 'paper')}
onChange={v => {
setSubPaperChoice(v ? 'live' : 'paper')
// Clear any stale subscribe-error from a previous attempt so
// the user sees a clean state for the new paper/live choice.
if (subState === 'err') { setSubState('idle'); setSubErr('') }
}}
tone="amber"
/>
<span style={{ fontSize: 11, color: subPaperChoice === 'live' ? 'var(--amber-ink)' : 'var(--ink-4)', fontWeight: subPaperChoice === 'live' ? 600 : 400 }}>Live</span>
@@ -492,7 +497,15 @@ export default function BotConfigPanel() {
)}
{/* Live mode: show key input */}
{!paperMode && (hlApiKeySet && !apiKey ? (
<button className="btn ghost" style={{ padding: '6px 14px', fontSize: 12, flexShrink: 0 }} onClick={() => setApiKey(' ')}>Rotate</button>
<button
className="btn ghost"
style={{ padding: '6px 14px', fontSize: 12, flexShrink: 0 }}
onClick={() => {
// Switch to edit mode AND clear any stale error / status from
// a previous failed save so the row isn't pre-stained red.
setApiKey(' '); setKeyState('idle'); setKeyErr('')
}}
>Rotate</button>
) : (
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<input
@@ -787,13 +800,13 @@ export default function BotConfigPanel() {
<div style={{ display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}>
<div className="num-field">
<span className="prefix">From</span>
<input type="datetime-local" value={fromLocal}
<input type="datetime-local" lang="en-US" value={fromLocal}
onChange={e => { setFromLocal(e.target.value); setDirty(true) }} style={{ width: 190 }} />
</div>
<span style={{ fontSize: 12, color: 'var(--ink-4)' }}></span>
<div className="num-field">
<span className="prefix">Until</span>
<input type="datetime-local" value={untilLocal}
<input type="datetime-local" lang="en-US" value={untilLocal}
onChange={e => { setUntilLocal(e.target.value); setDirty(true) }} style={{ width: 190 }} />
</div>
</div>
+43 -11
View File
@@ -3,6 +3,7 @@
import { useState, useMemo } from 'react'
import { useLocale } from 'next-intl'
import type { BotTrade, TrumpPost } from '@/types'
import Pagination from '@/components/ui/Pagination'
// ── Formatters ────────────────────────────────────────────────────────────────
function fmtMoney(n: number, opts: { sign?: boolean; decimals?: number } = {}) {
@@ -28,8 +29,11 @@ interface Props {
loading: boolean
}
const ASSETS = ['all', 'BTC', 'ETH', 'SOL'] as const
const SIDES = ['all', 'long', 'short'] as const
// ASSETS filter is derived dynamically from the trade set (see useMemo below)
// so new assets (TRUMP, BNB, DOGE, LINK, AAVE, ...) appear automatically.
const SIDES = ['all', 'long', 'short'] as const
const TRADES_PER_PAGE = 25
export default function TradeTable({ trades, posts, loading }: Props) {
const locale = useLocale()
@@ -38,6 +42,18 @@ export default function TradeTable({ trades, posts, loading }: Props) {
const [sideFilter, setSideFilter] = useState('all')
const [sourceFilter, setSourceFilter] = useState('all')
const [hidePaper, setHidePaper] = useState(false)
const [page, setPage] = useState(1)
// Distinct assets present in the loaded trade set — drives the asset filter.
// Dynamic so new perps (TRUMP, BNB, DOGE, LINK, AAVE, ...) appear without
// a code change; sorted alphabetically with BTC/ETH first for familiarity.
const assets: string[] = useMemo(() => {
const set = new Set<string>()
for (const t of trades) if (t.asset) set.add(t.asset)
const priority = ['BTC', 'ETH', 'SOL', 'TRUMP']
const rest = Array.from(set).filter(a => !priority.includes(a)).sort()
return ['all', ...priority.filter(a => set.has(a)), ...rest]
}, [trades])
// Distinct sources present in the loaded trade set — drives the filter UI.
// Includes 'unknown' as a bucket for trades whose trigger post was deleted.
@@ -65,13 +81,17 @@ export default function TradeTable({ trades, posts, loading }: Props) {
return acc
}, [trades])
const filtered = trades.filter(t => {
const filtered = useMemo(() => trades.filter(t => {
if (sourceFilter !== 'all' && (t.trigger_source || 'unknown') !== sourceFilter) return false
if (assetFilter !== 'all' && t.asset !== assetFilter) return false
if (sideFilter !== 'all' && t.side !== sideFilter) return false
if (hidePaper && t.is_paper) return false
return true
})
}), [trades, sourceFilter, assetFilter, sideFilter, hidePaper])
const totalPages = Math.max(1, Math.ceil(filtered.length / TRADES_PER_PAGE))
const safePage = Math.min(page, totalPages)
const pageRows = filtered.slice((safePage - 1) * TRADES_PER_PAGE, safePage * TRADES_PER_PAGE)
// Exclude externally-closed trades (pnl_usd null) from aggregates.
const priced = filtered.filter(t => t.pnl_usd !== null && t.pnl_usd !== undefined)
@@ -111,7 +131,7 @@ export default function TradeTable({ trades, posts, loading }: Props) {
return (
<button
key={src}
onClick={() => setSourceFilter(sourceFilter === src ? 'all' : src)}
onClick={() => { setSourceFilter(sourceFilter === src ? 'all' : src); setPage(1) }}
style={{
textAlign: 'left', cursor: 'pointer',
background: bg, borderRadius: 8, padding: '12px 14px',
@@ -146,7 +166,7 @@ export default function TradeTable({ trades, posts, loading }: Props) {
<div style={{ fontSize: 10, color: 'var(--ink-4)', marginTop: 8 }}>
{isZh ? '点击来源可筛选表格。' : 'Click a source to filter the table.'} {sourceFilter !== 'all' && (
<button
onClick={() => setSourceFilter('all')}
onClick={() => { setSourceFilter('all'); setPage(1) }}
style={{ marginLeft: 8, padding: '2px 8px', fontSize: 10,
border: '1px solid var(--line)', borderRadius: 4,
background: 'transparent', cursor: 'pointer', color: 'var(--ink-2)' }}
@@ -182,11 +202,11 @@ export default function TradeTable({ trades, posts, loading }: Props) {
{/* Filters */}
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap', alignItems: 'center', marginBottom: 14 }}>
<div className="nav-tabs">
{ASSETS.map(a => (
{assets.map(a => (
<button
key={a}
className={`nav-tab ${assetFilter === a ? 'active' : ''}`}
onClick={() => setAssetFilter(a)}
onClick={() => { setAssetFilter(a); setPage(1) }}
>
{a === 'all' ? (isZh ? '全部资产' : 'All assets') : a}
</button>
@@ -197,7 +217,7 @@ export default function TradeTable({ trades, posts, loading }: Props) {
<button
key={s}
className={`nav-tab ${sideFilter === s ? 'active' : ''}`}
onClick={() => setSideFilter(s)}
onClick={() => { setSideFilter(s); setPage(1) }}
>
{s === 'all' ? (isZh ? '全部方向' : 'All') : s === 'long' ? (isZh ? '做多' : 'Long') : (isZh ? '做空' : 'Short')}
</button>
@@ -212,7 +232,7 @@ export default function TradeTable({ trades, posts, loading }: Props) {
<input
type="checkbox"
checked={hidePaper}
onChange={e => setHidePaper(e.target.checked)}
onChange={e => { setHidePaper(e.target.checked); setPage(1) }}
/>
{isZh ? '隐藏模拟交易' : 'Hide paper trades'}
</label>
@@ -247,7 +267,7 @@ export default function TradeTable({ trades, posts, loading }: Props) {
</td>
</tr>
)}
{filtered.map(t => {
{pageRows.map(t => {
const tp = posts.find(p => p.id === t.trigger_post_id)
const roi =
t.entry_price && t.exit_price != null
@@ -326,6 +346,18 @@ export default function TradeTable({ trades, posts, loading }: Props) {
</table>
</div>
)}
{/* Pagination */}
{!loading && (
<Pagination
page={safePage}
total={totalPages}
count={filtered.length}
pageSize={TRADES_PER_PAGE}
onChange={setPage}
scrollTop={false}
/>
)}
</>
)
}
+204
View File
@@ -0,0 +1,204 @@
'use client'
import React from 'react'
interface PaginationProps {
page: number
total: number // total pages
count: number // total items
pageSize: number
onChange: (page: number) => void
/** Scroll to top on page change. Default true. */
scrollTop?: boolean
}
/**
* Shared pagination control used across all list pages.
*
* Visual design:
* 1 2 … 5 [6] 7 … 12 Showing 151180 of 340
*
* Active page uses brand orange. Ellipsis collapses distant pages.
* Prev/Next disabled at boundaries (dimmed, not hidden).
*/
export default function Pagination({
page, total, count, pageSize, onChange, scrollTop = true,
}: PaginationProps) {
if (total <= 1) return null
const from = (page - 1) * pageSize + 1
const to = Math.min(page * pageSize, count)
function go(p: number) {
if (p < 1 || p > total || p === page) return
onChange(p)
if (scrollTop) window.scrollTo({ top: 0, behavior: 'smooth' })
}
/** Page number list with ellipsis compression. */
function pages(): (number | '…')[] {
if (total <= 7) return Array.from({ length: total }, (_, i) => i + 1)
const out: (number | '…')[] = []
const push = (n: number | '…') => {
if (out[out.length - 1] !== n) out.push(n)
}
push(1)
if (page > 3) push('…')
for (let i = Math.max(2, page - 1); i <= Math.min(total - 1, page + 1); i++) push(i)
if (page < total - 2) push('…')
push(total)
return out
}
return (
<div style={wrapStyle}>
{/* ← Prev */}
<NavBtn onClick={() => go(page - 1)} disabled={page === 1} label="Previous page">
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
<path d="M9 11L5 7l4-4" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
<span>Prev</span>
</NavBtn>
{/* Page numbers */}
<div style={numsStyle}>
{pages().map((n, i) =>
n === '…' ? (
<span key={`e${i}`} style={ellipsisStyle}></span>
) : (
<PageBtn key={n} num={n as number} active={n === page} onClick={go} />
)
)}
</div>
{/* Next → */}
<NavBtn onClick={() => go(page + 1)} disabled={page === total} label="Next page">
<span>Next</span>
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
<path d="M5 3l4 4-4 4" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
</NavBtn>
{/* Count */}
<span style={countStyle}>
{from}{to} <span style={{ opacity: 0.5 }}>of</span> {count.toLocaleString()}
</span>
</div>
)
}
// ── Sub-components ────────────────────────────────────────────────────────────
function PageBtn({ num, active, onClick }: { num: number; active: boolean; onClick: (n: number) => void }) {
const [hover, setHover] = React.useState(false)
const bg = active
? 'var(--brand, #f5a524)'
: hover
? 'var(--bg-sunk)'
: 'transparent'
const color = active
? '#fff'
: hover
? 'var(--ink)'
: 'var(--ink-2)'
const border = active
? '1.5px solid var(--brand, #f5a524)'
: '1.5px solid var(--line)'
return (
<button
onClick={() => onClick(num)}
onMouseEnter={() => setHover(true)}
onMouseLeave={() => setHover(false)}
aria-current={active ? 'page' : undefined}
style={{
width: 34, height: 34,
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
borderRadius: 8,
border,
background: bg,
color,
fontSize: 13,
fontWeight: active ? 700 : 400,
cursor: 'pointer',
transition: 'background 0.12s, color 0.12s, border-color 0.12s',
boxShadow: active ? '0 1px 4px rgba(245,165,36,0.25)' : 'none',
flexShrink: 0,
}}
>
{num}
</button>
)
}
function NavBtn({
onClick, disabled, label, children,
}: {
onClick: () => void; disabled: boolean; label: string; children: React.ReactNode
}) {
const [hover, setHover] = React.useState(false)
return (
<button
onClick={onClick}
disabled={disabled}
aria-label={label}
onMouseEnter={() => !disabled && setHover(true)}
onMouseLeave={() => setHover(false)}
style={{
display: 'inline-flex', alignItems: 'center', gap: 5,
height: 34, padding: '0 12px',
borderRadius: 8,
border: '1.5px solid var(--line)',
background: hover && !disabled ? 'var(--bg-sunk)' : 'transparent',
color: disabled ? 'var(--ink-4)' : hover ? 'var(--ink)' : 'var(--ink-2)',
fontSize: 12,
fontWeight: 500,
cursor: disabled ? 'not-allowed' : 'pointer',
transition: 'background 0.12s, color 0.12s',
flexShrink: 0,
}}
>
{children}
</button>
)
}
// ── Styles ────────────────────────────────────────────────────────────────────
const wrapStyle: React.CSSProperties = {
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: 6,
padding: '28px 0 8px',
flexWrap: 'wrap',
}
const numsStyle: React.CSSProperties = {
display: 'flex',
alignItems: 'center',
gap: 4,
flexWrap: 'wrap',
justifyContent: 'center',
}
const ellipsisStyle: React.CSSProperties = {
width: 28,
textAlign: 'center',
fontSize: 13,
color: 'var(--ink-4)',
userSelect: 'none',
flexShrink: 0,
}
const countStyle: React.CSSProperties = {
fontSize: 11,
color: 'var(--ink-3)',
fontVariantNumeric: 'tabular-nums',
marginLeft: 6,
whiteSpace: 'nowrap',
}
+17 -1
View File
@@ -27,9 +27,18 @@ export interface SignConfirmOptions {
danger?: boolean
}
// Track the currently-mounted sheet so a second confirmSign() call can resolve
// the first one with `false` instead of leaving its promise dangling forever.
let _activeCancel: (() => void) | null = null
/** Renders a confirmation sheet and resolves true (confirm) or false (cancel). */
export function confirmSign(opts: SignConfirmOptions): Promise<boolean> {
return new Promise((resolve) => {
// If another sheet is already mounted, cancel it first so its awaiting
// caller resolves with `false` rather than hanging indefinitely.
if (_activeCancel) {
try { _activeCancel() } catch { /* ignore */ }
}
const existing = document.getElementById('sign-confirm-sheet-root')
if (existing?.parentNode) {
existing.parentNode.removeChild(existing)
@@ -43,6 +52,9 @@ export function confirmSign(opts: SignConfirmOptions): Promise<boolean> {
function cleanup(result: boolean) {
if (settled) return
settled = true
// Clear the active-cancel slot only if it still points to *this* sheet —
// a later confirmSign() may have already replaced it.
if (_activeCancel === cancelSelf) _activeCancel = null
root.unmount()
if (container.parentNode) {
container.parentNode.removeChild(container)
@@ -50,7 +62,11 @@ export function confirmSign(opts: SignConfirmOptions): Promise<boolean> {
resolve(result)
}
root.render(<Sheet {...opts} onConfirm={() => cleanup(true)} onCancel={() => cleanup(false)} />)
// Stable reference used both as the active-cancel handle and inside cleanup.
const cancelSelf = () => cleanup(false)
_activeCancel = cancelSelf
root.render(<Sheet {...opts} onConfirm={() => cleanup(true)} onCancel={cancelSelf} />)
})
}