更换 UI
This commit is contained in:
+11
-5
@@ -1,10 +1,16 @@
|
||||
import { ErrorBlock, NavBar } from 'antd-mobile'
|
||||
|
||||
// 首页: 暂留占位, 后续接入监控告警 / 个性化推荐
|
||||
// 使用 antd-mobile ErrorBlock 仿原生空状态页
|
||||
export default function HomePage() {
|
||||
return (
|
||||
<div className="placeholder-page">
|
||||
<div className="icon">⌂</div>
|
||||
<div>首页</div>
|
||||
<div style={{ fontSize: 12, opacity: .7 }}>后续接入: 监控告警 / 个性化推荐 / 收藏的指标</div>
|
||||
<div className="page-with-navbar">
|
||||
<NavBar back={null}>首页</NavBar>
|
||||
<ErrorBlock
|
||||
status="empty"
|
||||
title="首页"
|
||||
description="后续接入: 监控告警 / 个性化推荐 / 收藏的指标"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+53
-23
@@ -1,7 +1,10 @@
|
||||
import { useState } from 'react'
|
||||
import { useNavigate, useLocation, Navigate } from 'react-router-dom'
|
||||
import { Button, Card, Checkbox, Input, Toast } from 'antd-mobile'
|
||||
import { useAuth } from '../auth/AuthContext.jsx'
|
||||
|
||||
// 登录页 (未走 MobileShell, 独立全屏布局)
|
||||
// 使用 antd-mobile: Input / Checkbox / Button / Card / Toast
|
||||
export default function LoginPage() {
|
||||
const { user, login } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
@@ -22,6 +25,7 @@ export default function LoginPage() {
|
||||
setBusy(true)
|
||||
try {
|
||||
await login(email.trim(), password, remember)
|
||||
Toast.show({ icon: 'success', content: '登录成功' })
|
||||
navigate(from, { replace: true })
|
||||
} catch (e) {
|
||||
setErr(e.message)
|
||||
@@ -32,30 +36,56 @@ export default function LoginPage() {
|
||||
|
||||
return (
|
||||
<div className="login-page">
|
||||
<form className="login-card" onSubmit={onSubmit}>
|
||||
<div className="login-title">iBoard</div>
|
||||
<form className="login-form" onSubmit={onSubmit}>
|
||||
<div className="login-brand">iBoard</div>
|
||||
<div className="login-subtitle">登录后继续</div>
|
||||
{err && <div className="login-error">{err}</div>}
|
||||
<div className="login-field">
|
||||
<label htmlFor="email">邮箱</label>
|
||||
<input id="email" type="email" autoComplete="username" required value={email} onChange={(e) => setEmail(e.target.value)} />
|
||||
</div>
|
||||
<div className="login-field">
|
||||
<label htmlFor="password">密码</label>
|
||||
<input id="password" type="password" autoComplete="current-password" required value={password} onChange={(e) => setPassword(e.target.value)} />
|
||||
</div>
|
||||
<label className="login-options">
|
||||
<input type="checkbox" checked={remember} onChange={(e) => setRemember(e.target.checked)} />
|
||||
<span>记住登录 (7 天免登录)</span>
|
||||
</label>
|
||||
<button className="login-btn" type="submit" disabled={busy}>
|
||||
{busy ? '登录中...' : '登录'}
|
||||
</button>
|
||||
<div className="login-hint">
|
||||
默认账号: admin@iboard.local<br />
|
||||
首次登录后请尽快修改密码
|
||||
</div>
|
||||
<Card className="login-card">
|
||||
{err && <div className="login-error">{err}</div>}
|
||||
<div className="login-field">
|
||||
<label htmlFor="email">邮箱</label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
autoComplete="username"
|
||||
required
|
||||
value={email}
|
||||
onChange={setEmail}
|
||||
placeholder="请输入邮箱"
|
||||
/>
|
||||
</div>
|
||||
<div className="login-field">
|
||||
<label htmlFor="password">密码</label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
value={password}
|
||||
onChange={setPassword}
|
||||
placeholder="请输入密码"
|
||||
/>
|
||||
</div>
|
||||
<div className="login-options">
|
||||
<Checkbox checked={remember} onChange={setRemember} block>
|
||||
记住登录 (7 天免登录)
|
||||
</Checkbox>
|
||||
</div>
|
||||
<Button
|
||||
block
|
||||
color="primary"
|
||||
size="large"
|
||||
type="submit"
|
||||
loading={busy}
|
||||
loadingText="登录中"
|
||||
>
|
||||
登录
|
||||
</Button>
|
||||
<div className="login-hint">
|
||||
默认账号: admin@iboard.local<br />
|
||||
首次登录后请尽快修改密码
|
||||
</div>
|
||||
</Card>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+31
-18
@@ -1,36 +1,49 @@
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Avatar, List, NavBar, Toast } from 'antd-mobile'
|
||||
import { useAuth } from '../auth/AuthContext.jsx'
|
||||
|
||||
// 我的页: 顶部 NavBar + 用户头像卡片 + 信息列表
|
||||
// 使用 antd-mobile: NavBar / List / Avatar / Toast
|
||||
export default function MePage() {
|
||||
const { user, logout } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
|
||||
async function onLogout() {
|
||||
if (!confirm('确定退出登录?')) return
|
||||
const confirmed = window.confirm('确定退出登录?')
|
||||
if (!confirmed) return
|
||||
await logout()
|
||||
Toast.show({ icon: 'success', content: '已退出' })
|
||||
navigate('/login', { replace: true })
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="me-header">
|
||||
<div className="me-page">
|
||||
<NavBar back={null} className="me-navbar">我的</NavBar>
|
||||
<div className="me-profile">
|
||||
<Avatar
|
||||
className="me-avatar"
|
||||
src={user?.avatar}
|
||||
style={{ '--size': '64px', '--border-radius': '50%' }}
|
||||
>
|
||||
{(user?.name || '?').slice(0, 1).toUpperCase()}
|
||||
</Avatar>
|
||||
<div className="me-name">{user?.name || '用户'}</div>
|
||||
<div className="me-email">{user?.email}</div>
|
||||
</div>
|
||||
<div className="me-list">
|
||||
<div className="list-item">
|
||||
<span className="label">用户 ID</span>
|
||||
<span className="value">#{user?.id}</span>
|
||||
</div>
|
||||
<div className="list-item">
|
||||
<span className="label">注册时间</span>
|
||||
<span className="value">{user?.createdAt ? new Date(user.createdAt).toLocaleDateString() : '-'}</span>
|
||||
</div>
|
||||
<div className="list-item" onClick={onLogout} role="button" style={{ cursor: 'pointer' }}>
|
||||
<span className="label danger">退出登录</span>
|
||||
<span className="value">›</span>
|
||||
</div>
|
||||
</div>
|
||||
<List className="me-list" mode="card">
|
||||
<List.Item extra={'#' + (user?.id || '-')}>用户 ID</List.Item>
|
||||
<List.Item extra={user?.createdAt ? new Date(user.createdAt).toLocaleDateString() : '-'}>
|
||||
注册时间
|
||||
</List.Item>
|
||||
<List.Item
|
||||
onClick={onLogout}
|
||||
clickable
|
||||
arrowIcon={false}
|
||||
className="me-logout"
|
||||
>
|
||||
退出登录
|
||||
</List.Item>
|
||||
</List>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
import { ErrorBlock, NavBar } from 'antd-mobile'
|
||||
|
||||
// 监控: 暂留占位
|
||||
// 使用 antd-mobile ErrorBlock 仿原生空状态页
|
||||
export default function MonitorPage() {
|
||||
return (
|
||||
<div className="placeholder-page">
|
||||
<div className="icon">◉</div>
|
||||
<div>监控</div>
|
||||
<div style={{ fontSize: 12, opacity: .7 }}>后续接入: 指标阈值告警 / 时序趋势</div>
|
||||
<div className="page-with-navbar">
|
||||
<NavBar back={null}>监控</NavBar>
|
||||
<ErrorBlock
|
||||
status="empty"
|
||||
title="监控"
|
||||
description="后续接入: 指标阈值告警 / 时序趋势"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useParams, useNavigate, Link } from 'react-router-dom'
|
||||
import { useParams, useNavigate } from 'react-router-dom'
|
||||
import { NavBar } from 'antd-mobile'
|
||||
import { api } from '../../api/client.js'
|
||||
|
||||
// 文章详情: /macro/articles/:slug
|
||||
// 使用 antd-mobile NavBar 提供原生风格的返回按钮
|
||||
export default function ArticleDetail() {
|
||||
const { slug } = useParams()
|
||||
const navigate = useNavigate()
|
||||
@@ -20,16 +22,19 @@ export default function ArticleDetail() {
|
||||
return () => { cancelled = true }
|
||||
}, [slug])
|
||||
|
||||
if (loading) return <div className="loading">加载中...</div>
|
||||
if (err) return <div className="error-banner">{err}</div>
|
||||
if (!article) return <div className="empty">文章不存在</div>
|
||||
|
||||
return (
|
||||
<div className="article-detail">
|
||||
<div className="article-back" onClick={() => navigate(-1)} role="button">‹ 返回</div>
|
||||
<h1>{article.title}</h1>
|
||||
<div className="meta">{article.date}</div>
|
||||
<div className="article-content" dangerouslySetInnerHTML={{ __html: article.contentHtml }} />
|
||||
<div className="article-page">
|
||||
<NavBar onBack={() => navigate(-1)}>文章详情</NavBar>
|
||||
{loading && <div className="loading">加载中...</div>}
|
||||
{err && <div className="error-banner">{err}</div>}
|
||||
{!loading && !err && !article && <div className="empty">文章不存在</div>}
|
||||
{!loading && !err && article && (
|
||||
<div className="article-detail">
|
||||
<h1>{article.title}</h1>
|
||||
<div className="meta">{article.date}</div>
|
||||
<div className="article-content" dangerouslySetInnerHTML={{ __html: article.contentHtml }} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { List } from 'antd-mobile'
|
||||
import { api } from '../../api/client.js'
|
||||
|
||||
// 经济文章: 列表, 点进 /macro/articles/:slug
|
||||
// 使用 antd-mobile List 仿原生设置/通讯录样式
|
||||
export default function ArticlesView() {
|
||||
const [articles, setArticles] = useState([])
|
||||
const [err, setErr] = useState('')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const navigate = useNavigate()
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
@@ -25,15 +28,23 @@ export default function ArticlesView() {
|
||||
return (
|
||||
<div>
|
||||
<div className="page-title">经济文章</div>
|
||||
<div className="list">
|
||||
<List className="articles-list" mode="card">
|
||||
{articles.map((a) => (
|
||||
<Link key={a.id} to={`/macro/articles/${encodeURIComponent(a.slug)}`} className="list-item">
|
||||
<div className="list-item-title">{a.title}</div>
|
||||
<div className="list-item-meta">{a.date}</div>
|
||||
<div className="list-item-excerpt">{a.excerpt}</div>
|
||||
</Link>
|
||||
<List.Item
|
||||
key={a.id}
|
||||
title={a.title}
|
||||
description={
|
||||
<>
|
||||
<div className="list-item-meta">{a.date}</div>
|
||||
<div className="list-item-excerpt">{a.excerpt}</div>
|
||||
</>
|
||||
}
|
||||
arrowIcon
|
||||
clickable
|
||||
onClick={() => navigate('/macro/articles/' + encodeURIComponent(a.slug))}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</List>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts'
|
||||
import { CapsuleTabs, Card } from 'antd-mobile'
|
||||
import { api } from '../../api/client.js'
|
||||
|
||||
// 经济数据: 从 /api/indicators 拉所有指标, 顶上一个 chip 行切换, 下面渲染图表+表格
|
||||
// 经济数据: 顶部 CapsuleTabs 选指标, 下面渲染图表 + 表格
|
||||
// 使用 antd-mobile: CapsuleTabs / Card
|
||||
// 多 groupName 的指标: 在 chip 文本中携带分组前缀以便区分
|
||||
export default function DataView() {
|
||||
const [indicators, setIndicators] = useState([])
|
||||
const [selected, setSelected] = useState(null)
|
||||
@@ -27,37 +30,32 @@ export default function DataView() {
|
||||
if (err) return <div className="error-banner">{err}</div>
|
||||
if (!indicators.length) return <div className="empty">暂无指标数据, 请先执行 npm run db:seed</div>
|
||||
|
||||
// 按 groupName 分组, chip 行按组排列
|
||||
const groups = groupBy(indicators, 'groupName')
|
||||
// 为 chip label 计算: 多个 group 时显示 "分组 / 标题", 单个 group 只显示标题
|
||||
const groupSet = new Set(indicators.map((i) => i.groupName || ''))
|
||||
const showGroupPrefix = groupSet.size > 1
|
||||
const labelOf = (i) => {
|
||||
const t = (i.data && i.data.title) || i.name
|
||||
if (showGroupPrefix && i.groupName) return i.groupName + ' · ' + t
|
||||
return t
|
||||
}
|
||||
|
||||
const cur = indicators.find((i) => i.name === selected)
|
||||
const chartData = cur ? buildMonthlySeries(cur.data) : []
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="page-title">经济数据</div>
|
||||
{Object.entries(groups).map(([groupName, list]) => (
|
||||
<div key={groupName}>
|
||||
<div className="section-title">{groupName}</div>
|
||||
<div className="chip-row">
|
||||
{list.map((i) => (
|
||||
<button
|
||||
key={i.name}
|
||||
className={'chip' + (selected === i.name ? ' active' : '')}
|
||||
onClick={() => setSelected(i.name)}
|
||||
>
|
||||
{shortTitle(i)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<CapsuleTabs activeKey={selected || undefined} onChange={setSelected}>
|
||||
{indicators.map((i) => (
|
||||
<CapsuleTabs.Tab key={i.name} title={labelOf(i)} />
|
||||
))}
|
||||
</CapsuleTabs>
|
||||
|
||||
{cur && (
|
||||
<div style={{ padding: '0 12px 12px' }}>
|
||||
<div className="card">
|
||||
<div style={{ fontWeight: 600, marginBottom: 4 }}>{cur.data.title || cur.name}</div>
|
||||
<Card className="chart-card">
|
||||
<div style={{ fontSize: 12, color: 'var(--color-muted)', marginBottom: 8 }}>
|
||||
单位 {cur.data.unit || '-'} · 来源 {cur.data.source || 'seed'}
|
||||
{labelOf(cur)} · 单位 {cur.data.unit || '-'} · 来源 {cur.data.source || 'seed'}
|
||||
</div>
|
||||
<div className="chart-wrap">
|
||||
<ResponsiveContainer width="100%" height={260}>
|
||||
@@ -67,7 +65,7 @@ export default function DataView() {
|
||||
<YAxis tick={{ fontSize: 11 }} />
|
||||
<Tooltip />
|
||||
<Legend />
|
||||
<Line type="monotone" dataKey="value" name={cur.data.unit || '值'} stroke="#2563eb" strokeWidth={2} dot={{ r: 3 }} />
|
||||
<Line type="monotone" dataKey="value" name={cur.data.unit || '值'} stroke="#1677ff" strokeWidth={2} dot={{ r: 3 }} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
@@ -83,27 +81,14 @@ export default function DataView() {
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function groupBy(arr, key) {
|
||||
return arr.reduce((acc, x) => {
|
||||
const k = x[key] || '其它'
|
||||
;(acc[k] = acc[k] || []).push(x)
|
||||
return acc
|
||||
}, {})
|
||||
}
|
||||
|
||||
function shortTitle(i) {
|
||||
if (i.data && i.data.title) return i.data.title
|
||||
return i.name
|
||||
}
|
||||
|
||||
// 把 data.rows: [{month, value}, ...] 直接返回, 服务端已经按月排好
|
||||
function buildMonthlySeries(data) {
|
||||
return (data && data.rows) || []
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts'
|
||||
import { CapsuleTabs, Card } from 'antd-mobile'
|
||||
import { api } from '../../api/client.js'
|
||||
|
||||
// 策略研究: 顶部 chip 行选策略, 下面显示 4 张图 (debt / gdp / debtToGdp / interestToGdp) + 表格
|
||||
// 策略研究: 顶部 CapsuleTabs 选策略, 下面 4 张图 + 表格
|
||||
// 使用 antd-mobile: CapsuleTabs / Card
|
||||
const SERIES = [
|
||||
{ key: 'debt', label: '政府债务 (万亿美元)', color: '#8884d8' },
|
||||
{ key: 'gdp', label: 'GDP (万亿美元)', color: '#3cb44b' },
|
||||
{ key: 'debtToGdp', label: '债务/GDP (%)', color: '#4363d8' },
|
||||
{ key: 'interestToGdp', label: '债务利息/GDP (%)', color: '#f58231' },
|
||||
{ key: 'debt', label: '政府债务 (万亿美元)', color: '#8884d8' },
|
||||
{ key: 'gdp', label: 'GDP (万亿美元)', color: '#3cb44b' },
|
||||
{ key: 'debtToGdp', label: '债务/GDP (%)', color: '#4363d8' },
|
||||
{ key: 'interestToGdp', label: '债务利息/GDP (%)', color: '#f58231' },
|
||||
]
|
||||
|
||||
export default function StrategyView() {
|
||||
@@ -40,22 +42,14 @@ export default function StrategyView() {
|
||||
return (
|
||||
<div>
|
||||
<div className="page-title">策略研究</div>
|
||||
<div className="chip-row" style={{ paddingLeft: 12 }}>
|
||||
<CapsuleTabs activeKey={selected || undefined} onChange={setSelected}>
|
||||
{strategies.map((s) => (
|
||||
<button
|
||||
key={s.name}
|
||||
className={'chip' + (selected === s.name ? ' active' : '')}
|
||||
onClick={() => setSelected(s.name)}
|
||||
>
|
||||
{s.description || s.name}
|
||||
</button>
|
||||
<CapsuleTabs.Tab key={s.name} title={s.description || s.name} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
</CapsuleTabs>
|
||||
{cur && (
|
||||
<div style={{ padding: '0 12px 12px' }}>
|
||||
<div className="card">
|
||||
<div style={{ fontWeight: 600, marginBottom: 4 }}>{cur.description || cur.name}</div>
|
||||
<Card className="chart-card">
|
||||
<div style={{ fontSize: 12, color: 'var(--color-muted)', marginBottom: 8 }}>
|
||||
数据 {rows.length} 条 · 来源 {cur.data.source || 'seed'}
|
||||
</div>
|
||||
@@ -100,9 +94,9 @@ export default function StrategyView() {
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user