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
+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',
}