修改架构,移动端优先。

save
This commit is contained in:
cheney
2026-08-03 18:05:50 +08:00
committed by Cheney
parent 1c07b417ce
commit 08b3655a38
71 changed files with 2524 additions and 290 deletions
+11 -2
View File
@@ -9,7 +9,8 @@ jobs:
- uses: https://gitea.com/actions/checkout@v4
- name: Build Docker image
run: docker build -t iboard:latest .
# DATABASE_URL 仅在 prisma generate 阶段需要, 可用任何合法占位串
run: docker build -t iboard:latest --build-arg DATABASE_URL=mysql://build:build@127.0.0.1:3306/build .
- name: List images
run: docker images
@@ -21,4 +22,12 @@ jobs:
run: docker system prune --volumes -f
- name: Run container
run: docker run -d --name iboard -p 3080:3000 iboard
# DATABASE_URL 通过 gitea Actions secret 注入, 不进镜像层
run: |
docker run -d --name iboard -p 3001:3001 \
-e DATABASE_URL="${{ secrets.DATABASE_URL }}" \
-e JWT_SECRET="${{ secrets.JWT_SECRET }}" \
iboard:latest
env:
DATABASE_URL: ${{ secrets.DATABASE_URL }}
JWT_SECRET: ${{ secrets.JWT_SECRET }}
+44 -23
View File
@@ -1,39 +1,60 @@
# 构建阶段
FROM node:24-alpine AS builder
# ===============================================================
# iBoard 一体化镜像: 静态前端 (dist/) + Express API (server/) 同进程
# 构建: docker build --build-arg DATABASE_URL=... -t iboard:latest .
# 运行: docker run -d --name iboard -p 3001:3001 -e DATABASE_URL=... iboard:latest
# ===============================================================
FROM node:22-alpine AS builder
# 设置工作目录
WORKDIR /app
# 复制package.json和package-lock.json
# 国内构建时可换源
RUN npm config set registry https://registry.npmmirror.com
# 先拷贝 package 文件, 利用 Docker 缓存
COPY package*.json ./
COPY prisma ./prisma/
COPY prisma.config.ts ./
COPY generated ./generated/
# 设置淘宝源并安装依赖
RUN npm config set registry https://registry.npmmirror.com && npm install
# 装全量依赖 (dev 也装, 后面 build 要用)
RUN npm install
# 复制项目文件
# 拷贝源码
COPY . .
# 构建项目
# 构建前端 (输出到 dist/) 与 Prisma 客户端
RUN npx prisma generate
RUN npm run build
# 生产阶段
FROM node:24-alpine
# 运行时阶段
FROM node:22-alpine
# 设置工作目录
WORKDIR /app
# 复制必要的文件
COPY --from=builder /app/package*.json ./
COPY --from=builder /app/.next ./.next
COPY --from=builder /app/content ./content/
# 复制public目录(如果存在)
COPY --from=builder /app/public* ./public/
# 阿里源加速
RUN npm config set registry https://registry.npmmirror.com
# 设置淘宝源并安装生产依赖
RUN npm config set registry https://registry.npmmirror.com && npm install --only=production
# 只装运行时依赖
COPY package*.json ./
RUN npm install --omit=dev
# 暴露 3000 端口
EXPOSE 3000
# 拷贝构建产物
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/server ./server
COPY --from=builder /app/generated ./generated
COPY --from=builder /app/prisma ./prisma
COPY --from=builder /app/prisma.config.ts ./prisma.config.ts
COPY --from=builder /app/content ./content
COPY --from=builder /app/public ./public
# 启动生产服务器
CMD ["npm", "start"]
# 健康检查
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget -qO- http://127.0.0.1:3001/api/health || exit 1
EXPOSE 3001
ENV NODE_ENV=production
ENV PORT=3001
# Node 22 默认未开 strip-types, 显式开启以便加载 generated/prisma/*.ts
CMD ["node", "--experimental-strip-types", "--no-warnings", "server/index.js"]
-11
View File
@@ -1,11 +0,0 @@
const Footer = () => {
return (
<footer className="footer">
<div className="footer-content">
<p>© 2026 iBoard. 收集和整理经济数据,以策略为主体重新组织。</p>
</div>
</footer>
)
}
export default Footer
-18
View File
@@ -1,18 +0,0 @@
import Link from 'next/link'
const Navbar = () => {
return (
<nav className="navbar">
<div className="logo">
<Link href="/">iBoard</Link>
</div>
<div className="nav-links">
<Link href="/">首页</Link>
<Link href="/data">经济数据</Link>
<Link href="/strategy">策略研究</Link>
</div>
</nav>
)
}
export default Navbar
File diff suppressed because one or more lines are too long
+127
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="6" fill="#1f2937"/><text x="16" y="22" text-anchor="middle" font-family="Arial" font-size="16" font-weight="bold" fill="#fff">i</text></svg>

After

Width:  |  Height:  |  Size: 233 B

+15
View File
@@ -0,0 +1,15 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover" />
<meta name="theme-color" content="#1f2937" />
<title>iBoard - 经济数据与策略</title>
<script type="module" crossorigin src="/assets/index-z5hTmGIR.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DU437yrj.css">
</head>
<body>
<div id="root"></div>
</body>
</html>
+2 -2
View File
@@ -234,15 +234,15 @@ export type IndicatorOrderByWithRelationInput = {
export type IndicatorWhereUniqueInput = Prisma.AtLeast<{
id?: number
name?: string
AND?: Prisma.IndicatorWhereInput | Prisma.IndicatorWhereInput[]
OR?: Prisma.IndicatorWhereInput[]
NOT?: Prisma.IndicatorWhereInput | Prisma.IndicatorWhereInput[]
name?: Prisma.StringFilter<"Indicator"> | string
groupName?: Prisma.StringFilter<"Indicator"> | string
data?: Prisma.JsonFilter<"Indicator">
createdAt?: Prisma.DateTimeFilter<"Indicator"> | Date | string
updatedAt?: Prisma.DateTimeFilter<"Indicator"> | Date | string
}, "id">
}, "id" | "name">
export type IndicatorOrderByWithAggregationInput = {
id?: Prisma.SortOrder
+2 -2
View File
@@ -234,15 +234,15 @@ export type StrategyOrderByWithRelationInput = {
export type StrategyWhereUniqueInput = Prisma.AtLeast<{
id?: number
name?: string
AND?: Prisma.StrategyWhereInput | Prisma.StrategyWhereInput[]
OR?: Prisma.StrategyWhereInput[]
NOT?: Prisma.StrategyWhereInput | Prisma.StrategyWhereInput[]
name?: Prisma.StringFilter<"Strategy"> | string
description?: Prisma.StringFilter<"Strategy"> | string
data?: Prisma.JsonFilter<"Strategy">
createdAt?: Prisma.DateTimeFilter<"Strategy"> | Date | string
updatedAt?: Prisma.DateTimeFilter<"Strategy"> | Date | string
}, "id">
}, "id" | "name">
export type StrategyOrderByWithAggregationInput = {
id?: Prisma.SortOrder
+14
View File
@@ -0,0 +1,14 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover" />
<meta name="theme-color" content="#1f2937" />
<title>iBoard - 经济数据与策略</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
-14
View File
@@ -1,14 +0,0 @@
import { PrismaClient } from '../generated/prisma'
let prisma
if (process.env.NODE_ENV === 'production') {
prisma = new PrismaClient()
} else {
if (!global.prisma) {
global.prisma = new PrismaClient()
}
prisma = global.prisma
}
export default prisma
-5
View File
@@ -1,5 +0,0 @@
import '../styles/globals.css'
export default function App({ Component, pageProps }) {
return <Component {...pageProps} />
}
-71
View File
@@ -1,71 +0,0 @@
import Head from 'next/head'
import Navbar from '../../components/Navbar'
import Footer from '../../components/Footer'
import fs from 'fs'
import path from 'path'
import matter from 'gray-matter'
import { marked } from 'marked'
export default function Article({ article }) {
return (
<div className="container">
<Head>
<title>{article.title} - iBoard</title>
<link rel="icon" href="/favicon.ico" />
</Head>
<Navbar />
<main>
<div className="article-detail">
<h1 className="article-detail-title">{article.title}</h1>
<div className="article-detail-date">{article.date}</div>
<div className="article-detail-content" dangerouslySetInnerHTML={{ __html: article.contentHtml }} />
</div>
</main>
<Footer />
</div>
)
}
export async function getStaticPaths() {
const articlesDirectory = path.join(process.cwd(), 'content', 'articles')
const fileNames = fs.readdirSync(articlesDirectory)
const paths = fileNames
.filter(fileName => fileName.endsWith('.md'))
.map(fileName => ({
params: { slug: fileName.replace('.md', '') }
}))
return {
paths,
fallback: false
}
}
export async function getStaticProps({ params }) {
const articlesDirectory = path.join(process.cwd(), 'content', 'articles')
const fullPath = path.join(articlesDirectory, `${params.slug}.md`)
const fileContents = fs.readFileSync(fullPath, 'utf8')
const { data, content } = matter(fileContents)
// 从文件名中提取日期
const dateMatch = params.slug.match(/^(\d{4}-\d{2}-\d{2})-/)
const date = dateMatch ? dateMatch[1] : ''
// 将markdown转换为HTML
const contentHtml = marked(content)
return {
props: {
article: {
id: params.slug,
title: data.title || params.slug.replace(/\d{4}-\d{2}-\d{2}-/, ''),
date,
contentHtml
}
}
}
}
-90
View File
@@ -1,90 +0,0 @@
import Head from 'next/head'
import Link from 'next/link'
import Navbar from '../components/Navbar'
import Footer from '../components/Footer'
import { getPagedArticles } from '../utils/articleUtils'
export default function Home({ articles, totalPages, currentPage }) {
return (
<div className="container">
<Head>
<title>iBoard - 经济数据与理论</title>
<link rel="icon" href="/favicon.ico" />
</Head>
<Navbar />
<main>
<h1 className="title">
欢迎来到 iBoard
</h1>
<p className="description">
收集和整理经济数据,以策略为主体重新组织。
</p>
<div className="grid">
<div className="card">
<a href="#articles-section">
<h2>经济文章</h2>
</a>
<p>最新的经济分析和研究文章</p>
</div>
<div className="card">
<Link href="/data">
<h2>经济数据</h2>
</Link>
<p>深入分析经济数据和趋势</p>
</div>
<div className="card">
<Link href="/strategy">
<h2>策略研究</h2>
</Link>
<p>基于经济理论的数据分析框架</p>
</div>
</div>
<section id="articles-section" className="articles-section">
<div className="articles-grid">
{articles.map((article) => (
<Link key={article.id} href={`/articles/${article.id}`} className="article-card">
<div className="article-date">{article.date}</div>
<h3 className="article-title">{article.title}</h3>
<p className="article-excerpt">{article.excerpt}</p>
</Link>
))}
</div>
{totalPages > 1 && (
<div className="pagination">
{Array.from({ length: totalPages }, (_, index) => (
<Link key={index + 1} href={`/?page=${index + 1}`} className={`page-link ${currentPage === index + 1 ? 'active' : ''}`}>
{index + 1}
</Link>
))}
</div>
)}
</section>
</main>
<Footer />
</div>
)
}
export async function getServerSideProps({ query }) {
const page = parseInt(query.page) || 1
const { articles, total, totalPages } = getPagedArticles(page, 5)
return {
props: {
articles,
total,
totalPages,
currentPage: page
}
}
}
@@ -0,0 +1,5 @@
-- Add unique constraint on Strategy.name to enable idempotent upsert in seed script
ALTER TABLE `Strategy` ADD UNIQUE INDEX `Strategy_name_key`(`name`);
-- Add unique constraint on Indicator.name to enable idempotent upsert in seed script
ALTER TABLE `Indicator` ADD UNIQUE INDEX `Indicator_name_key`(`name`);
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="6" fill="#1f2937"/><text x="16" y="22" text-anchor="middle" font-family="Arial" font-size="16" font-weight="bold" fill="#fff">i</text></svg>

After

Width:  |  Height:  |  Size: 233 B

+17
View File
@@ -0,0 +1,17 @@
require('dotenv').config();
const mysql = require('mysql2/promise');
(async () => {
const u = new URL(process.env.DATABASE_URL);
const c = await mysql.createConnection({
host: u.hostname, port: Number(u.port),
user: decodeURIComponent(u.username),
password: decodeURIComponent(u.password),
database: u.pathname.replace(/^\//, ''),
});
// 保留 id 最小的, 删掉其余重名行
for (const t of ['Strategy', 'Indicator']) {
const [r] = await c.query('DELETE FROM ' + t + ' WHERE id NOT IN (SELECT min_id FROM (SELECT MIN(id) AS min_id FROM ' + t + ' GROUP BY name) AS keep)');
console.log('[' + t + '] 删除 ' + r.affectedRows + ' 条重复行');
}
await c.end();
})().catch(e=>{console.error(e);process.exit(1)});
+37
View File
@@ -0,0 +1,37 @@
// 从 public/favicon.svg 派生 Tauri 需要的各尺寸 PNG 图标
// 用法: node scripts/generate-icons.mjs
// 一次性脚本, 生成的图标随仓库提交, 不需要每次都跑
import sharp from 'sharp'
import path from 'node:path'
import fs from 'node:fs'
const SVG = path.join(process.cwd(), 'public', 'favicon.svg')
const OUT = path.join(process.cwd(), 'src-tauri', 'icons')
fs.mkdirSync(OUT, { recursive: true })
const svg = fs.readFileSync(SVG)
const base = sharp(svg)
// Tauri 桌面 + Android 需要的尺寸
const TARGETS = [
{ name: '32x32.png', size: 32 },
{ name: '128x128.png', size: 128 },
{ name: '128x128@2x.png', size: 256 },
{ name: 'icon.png', size: 1024 },
]
for (const t of TARGETS) {
const buf = await base.clone().resize(t.size, t.size).png().toBuffer()
fs.writeFileSync(path.join(OUT, t.name), buf)
console.log('wrote', t.name, t.size + 'x' + t.size)
}
// Windows .ico (单 256x256 frame, 简单够用)
const ico = await sharp(svg).resize(256, 256).png().toBuffer()
fs.writeFileSync(path.join(OUT, 'icon.ico'), ico)
console.log('wrote icon.ico (256x256 PNG-as-ICO)')
// macOS .icns: TaurCli 可用 png2icns 工具, 这里只放占位
// 如果未来要打 macOS DMG, 跑 cargo tauri icon 重新生成
fs.copyFileSync(path.join(OUT, 'icon.png'), path.join(OUT, 'icon.icns'))
console.log('wrote icon.icns (copy of icon.png, run cargo tauri icon to replace)')
+1
View File
File diff suppressed because one or more lines are too long
+164
View File
@@ -0,0 +1,164 @@
/**
* iBoard 基础数据初始化脚本
* 用法: npm run db:seed
* 幂等: 主键/唯一键冲突跳过, Strategy/Indicator 按 name 存在则更新
*/
require('dotenv').config();
const crypto = require('crypto');
const mysql = require('mysql2/promise');
function hashPassword(plain) {
const salt = crypto.randomBytes(16).toString('hex');
const hash = crypto.scryptSync(plain, salt, 64).toString('hex');
return 'scrypt$' + salt + '$' + hash;
}
function parseUrl(url) {
const u = new URL(url);
return {
host: u.hostname,
port: Number(u.port) || 3306,
user: decodeURIComponent(u.username),
password: decodeURIComponent(u.password),
database: u.pathname.replace(/^\//, ''),
};
}
async function insertIgnore(conn, sql, params) {
try {
const [r] = await conn.execute(sql, params);
return r.affectedRows;
} catch (e) {
if (e.code === 'ER_DUP_ENTRY') return 0;
throw e;
}
}
async function upsertByName(conn, table, payload, keyField) {
const fields = Object.keys(payload);
const placeholders = fields.map(() => '?').join(', ');
const updateClause = fields
.filter((f) => f !== keyField)
.map((f) => '`' + f + '` = VALUES(`' + f + '`)')
.join(', ');
const sql = 'INSERT INTO `' + table + '` (' + fields.map((f) => '`' + f + '`').join(', ') + ') VALUES (' + placeholders + ') ON DUPLICATE KEY UPDATE ' + updateClause;
const params = fields.map((f) => payload[f]);
const [r] = await conn.execute(sql, params);
return r.affectedRows;
}
async function main() {
const cfg = parseUrl(process.env.DATABASE_URL);
if (!cfg.host) throw new Error('DATABASE_URL 未配置或解析失败');
const conn = await mysql.createConnection(cfg);
console.log('已连接:', cfg.host + ':' + cfg.port + '/' + cfg.database);
const adminPwd = hashPassword('admin123');
const n1 = await insertIgnore(
conn,
'INSERT INTO `User` (name, email, password, createdAt, updatedAt) VALUES (?, ?, ?, NOW(3), NOW(3))',
['Admin', 'admin@iboard.local', adminPwd],
);
console.log('[User] 新增管理员: ' + n1 + ' 行 (admin@iboard.local / admin123)');
const strategies = [
{ name: 'us-debt-gdp', description: '美国政府债务/GDP分析',
data: { source: 'strategy.js', unit: '万亿美元', rows: [
{ year: '2019', debt: 22.7, gdp: 21.4, debtToGdp: 106.1, interestToGdp: 1.8 },
{ year: '2020', debt: 26.9, gdp: 20.9, debtToGdp: 128.7, interestToGdp: 1.6 },
{ year: '2021', debt: 28.4, gdp: 23.0, debtToGdp: 123.5, interestToGdp: 1.5 },
{ year: '2022', debt: 30.9, gdp: 25.4, debtToGdp: 121.6, interestToGdp: 1.6 },
{ year: '2023', debt: 33.1, gdp: 26.9, debtToGdp: 123.0, interestToGdp: 1.9 },
{ year: '2024', debt: 35.3, gdp: 28.5, debtToGdp: 123.9, interestToGdp: 2.1 },
]},
},
{ name: 'china-debt-gdp', description: '中国政府债务/GDP分析',
data: { source: 'strategy.js', unit: '万亿美元', rows: [
{ year: '2019', debt: 8.8, gdp: 14.3, debtToGdp: 61.5, interestToGdp: 1.9 },
{ year: '2020', debt: 10.3, gdp: 14.7, debtToGdp: 70.1, interestToGdp: 1.8 },
{ year: '2021', debt: 11.7, gdp: 16.1, debtToGdp: 72.7, interestToGdp: 1.7 },
{ year: '2022', debt: 13.1, gdp: 17.9, debtToGdp: 73.2, interestToGdp: 1.6 },
{ year: '2023', debt: 14.5, gdp: 19.4, debtToGdp: 74.7, interestToGdp: 1.6 },
{ year: '2024', debt: 15.9, gdp: 21.0, debtToGdp: 75.7, interestToGdp: 1.5 },
]},
},
];
let n2 = 0;
for (const s of strategies) {
n2 += await upsertByName(conn, 'Strategy',
{ name: s.name, description: s.description, data: JSON.stringify(s.data), updatedAt: new Date() },
'name');
}
console.log('[Strategy] upsert 策略: ' + n2 + ' 行 (1=新增, 2=更新)');
const indicators = [
{ name: 'china-bond-1y', groupName: 'china-bond',
data: { unit: '%', title: '中国国债1年', rows: [
{ month: '2024-01', value: 1.85 }, { month: '2024-02', value: 1.82 },
{ month: '2024-03', value: 1.78 }, { month: '2024-04', value: 1.75 },
{ month: '2024-05', value: 1.72 }, { month: '2024-06', value: 1.68 },
]}},
{ name: 'china-bond-10y', groupName: 'china-bond',
data: { unit: '%', title: '中国国债10年', rows: [
{ month: '2024-01', value: 2.65 }, { month: '2024-02', value: 2.62 },
{ month: '2024-03', value: 2.58 }, { month: '2024-04', value: 2.55 },
{ month: '2024-05', value: 2.52 }, { month: '2024-06', value: 2.48 },
]}},
{ name: 'us-bond-1y', groupName: 'us-bond',
data: { unit: '%', title: '美国国债1年', rows: [
{ month: '2024-01', value: 4.85 }, { month: '2024-02', value: 4.82 },
{ month: '2024-03', value: 4.78 }, { month: '2024-04', value: 4.65 },
{ month: '2024-05', value: 4.55 }, { month: '2024-06', value: 4.45 },
]}},
{ name: 'china-lpr-1y', groupName: 'china-lpr',
data: { unit: '%', title: '中国LPR 1年期', rows: [
{ month: '2024-01', value: 3.45 }, { month: '2024-02', value: 3.45 },
{ month: '2024-03', value: 3.45 }, { month: '2024-04', value: 3.40 },
{ month: '2024-05', value: 3.40 }, { month: '2024-06', value: 3.40 },
]}},
];
let n3 = 0;
for (const i of indicators) {
n3 += await upsertByName(conn, 'Indicator',
{ name: i.name, groupName: i.groupName, data: JSON.stringify(i.data), updatedAt: new Date() },
'name');
}
console.log('[Indicator] upsert 指标: ' + n3 + ' 行 (1=新增, 2=更新)');
const article = {
title: '欢迎使用 iBoard',
slug: 'welcome-to-iboard',
content: 'iBoard 用于收集和整理经济数据,以策略为主体重新组织。\n\n本文为示例条目,正式文章仍由 content/articles/*.md 文件承载。',
date: '2026-08-03',
};
const n4 = await insertIgnore(
conn,
'INSERT INTO `Article` (title, slug, content, date, createdAt, updatedAt) VALUES (?, ?, ?, ?, NOW(3), NOW(3))',
[article.title, article.slug, article.content, article.date],
);
console.log('[Article] 新增示例文章: ' + n4 + ' 行');
const sowRows = [
{ month: '2024-01', inventory: 4067 },
{ month: '2024-02', inventory: 4042 },
{ month: '2024-03', inventory: 3992 },
{ month: '2024-04', inventory: 3986 },
{ month: '2024-05', inventory: 3996 },
{ month: '2024-06', inventory: 4038 },
];
let n5 = 0;
for (const r of sowRows) {
n5 += await insertIgnore(
conn,
'INSERT INTO `Econ_SowInventory` (month, inventory, createdAt, updatedAt) VALUES (?, ?, NOW(3), NOW(3))',
[r.month, r.inventory],
);
}
console.log('[Econ_SowInventory] 新增: ' + n5 + ' 行');
await conn.end();
console.log('\nSeed 完成.');
}
main().catch((e) => { console.error('Seed 失败:', e); process.exit(1); });
+22
View File
@@ -0,0 +1,22 @@
require('dotenv').config();
const mysql = require('mysql2/promise');
(async () => {
const u = new URL(process.env.DATABASE_URL);
const c = await mysql.createConnection({
host: u.hostname, port: Number(u.port),
user: decodeURIComponent(u.username),
password: decodeURIComponent(u.password),
database: u.pathname.replace(/^\//, ''),
});
const [users] = await c.query('SELECT id,name,email,LEFT(password,20) AS pwd_prefix,createdAt FROM User');
console.log('User:'); console.table(users);
const [sow] = await c.query('SELECT * FROM Econ_SowInventory ORDER BY month');
console.log('Econ_SowInventory:'); console.table(sow);
const [str] = await c.query('SELECT id,name,description,JSON_LENGTH(data,"$.rows") AS row_count FROM Strategy');
console.log('Strategy:'); console.table(str);
const [ind] = await c.query('SELECT id,name,groupName,JSON_LENGTH(data,"$.rows") AS row_count FROM Indicator');
console.log('Indicator:'); console.table(ind);
const [art] = await c.query('SELECT id,title,slug,date FROM Article');
console.log('Article:'); console.table(art);
await c.end();
})().catch(e=>{console.error(e);process.exit(1)});
+31
View File
@@ -0,0 +1,31 @@
import 'dotenv/config'
import { PrismaMariaDb } from '@prisma/adapter-mariadb'
import { PrismaClient } from '../generated/prisma/client.ts'
function parseDatabaseUrl(url) {
const u = new URL(url)
return {
host: u.hostname,
port: Number(u.port) || 3306,
user: decodeURIComponent(u.username),
password: decodeURIComponent(u.password),
database: u.pathname.replace(/^\//, ''),
}
}
const p = new PrismaClient({ adapter: new PrismaMariaDb(parseDatabaseUrl(process.env.DATABASE_URL)) })
try {
const users = await p.user.findMany()
console.log('User count:', users.length, '->', users.map((u) => u.email).join(','))
const strategies = await p.strategy.findMany({ select: { id: true, name: true } })
console.log('Strategy count:', strategies.length, '->', strategies.map((s) => s.name).join(','))
const indicators = await p.indicator.findMany({ select: { id: true, name: true } })
console.log('Indicator count:', indicators.length, '->', indicators.map((i) => i.name).join(','))
const sow = await p.econ_SowInventory.findMany({ orderBy: { month: 'asc' } })
console.log('SowInventory count:', sow.length, '->', sow.map((s) => s.month + '=' + s.inventory).join(','))
} catch (e) {
console.error('ERR:', e.message)
process.exitCode = 1
} finally {
await p.$disconnect()
}
+22
View File
@@ -0,0 +1,22 @@
require('dotenv').config();
const mysql = require('mysql2/promise');
(async () => {
const url = new URL(process.env.DATABASE_URL);
const conn = await mysql.createConnection({
host: url.hostname,
port: Number(url.port),
user: decodeURIComponent(url.username),
password: decodeURIComponent(url.password),
database: url.pathname.replace(/^\//, ''),
});
const [tables] = await conn.query('SHOW TABLES');
console.log('TABLES:');
for (const row of tables) console.log(' -', Object.values(row)[0]);
const tableNames = ['User', 'Article', 'Strategy', 'Indicator', 'Econ_SowInventory'];
for (const t of tableNames) {
const [r] = await conn.query('SELECT COUNT(*) AS c FROM ' + t + '');
console.log(t + ': ' + r[0].c + ' rows');
}
await conn.end();
})().catch((e) => { console.error('ERR:', e.message); process.exit(1); });
+15
View File
@@ -0,0 +1,15 @@
const { PrismaClient } = require('../generated/prisma');
(async () => {
const p = new PrismaClient();
const tables = await p.$queryRawUnsafe('SHOW TABLES');
console.log('TABLES:', JSON.stringify(tables, null, 2));
const counts = {
User: await p.user.count(),
Article: await p.article.count(),
Strategy: await p.strategy.count(),
Indicator: await p.indicator.count(),
Econ_SowInventory: await p.econ_SowInventory.count(),
};
console.log('COUNTS:', JSON.stringify(counts, null, 2));
await p.$disconnect();
})().catch((e) => { console.error(e); process.exit(1); });
+22
View File
@@ -0,0 +1,22 @@
import 'dotenv/config';
import { PrismaClient } from '../generated/prisma/client.ts';
const url = process.env.DATABASE_URL;
const p = new PrismaClient({ datasourceUrl: url });
try {
const tables = await p.$queryRawUnsafe('SHOW TABLES');
console.log('TABLES:', JSON.stringify(tables, null, 2));
const counts = {
User: await p.user.count(),
Article: await p.article.count(),
Strategy: await p.strategy.count(),
Indicator: await p.indicator.count(),
Econ_SowInventory: await p.econ_SowInventory.count(),
};
console.log('COUNTS:', JSON.stringify(counts, null, 2));
} catch (e) {
console.error('ERR:', e.message);
process.exitCode = 1;
} finally {
await p.$disconnect();
}
+43
View File
@@ -0,0 +1,43 @@
#!/usr/bin/env bash
# iBoard Android APK 打包
# 在 WSL 内执行: bash scripts/wsl/build-apk.sh [debug|release]
# 默认 debug, release 需要先配签名 (见末尾)
set -euo pipefail
WIN_PROJECT='/mnt/d/workbench/iboard'
cd "$WIN_PROJECT"
VARIANT="${1:-debug}"
echo "构建变体: $VARIANT"
# 环境变量 (setup.sh 已写过, 防御性再写一次)
export ANDROID_HOME="${ANDROID_HOME:-$HOME/android-sdk}"
export ANDROID_NDK_HOME="${ANDROID_NDK_HOME:-$ANDROID_HOME/ndk/26.1.10909125}"
export PATH="$ANDROID_HOME/cmdline-tools/latest/bin:$ANDROID_HOME/platform-tools:$HOME/.cargo/bin:$PATH"
# 1. 先确保前端 dist/ 最新
echo "=== 1. 构建前端 ==="
npm run build
# 2. 进 src-tauri 跑 cargo tauri android build
echo "=== 2. 构建 APK ==="
cd src-tauri
if [ "$VARIANT" = "release" ]; then
# release 需要签名, 首次请先生成 keystore:
# keytool -genkey -v -keystore ~/iboard-release.keystore -alias iboard \
# -keyalg RSA -keysize 2048 -validity 10000
# 然后在 ~/.gradle/gradle.properties 配:
# IBOARD_UPLOAD_STORE_FILE=/path/to/iboard-release.keystore
# IBOARD_UPLOAD_KEY_ALIAS=iboard
# IBOARD_UPLOAD_STORE_PASSWORD=...
# IBOARD_UPLOAD_KEY_PASSWORD=...
npx tauri android build --release
else
npx tauri android build --debug
fi
echo
echo "=== 产物位置 ==="
APK_DIR="$WIN_PROJECT/src-tauri/target/$( [ "$VARIANT" = "release" ] && echo release || echo debug )/bundle/android/app/build/outputs/apk/$VARIANT"
ls -lh "$APK_DIR"/*.apk 2>/dev/null || echo "(未找到 APK, 请检查上面日志)"
+12
View File
@@ -0,0 +1,12 @@
#!/usr/bin/env bash
# 把刚打好的 APK 装到已连接的 adb 设备/模拟器
# 用法: bash scripts/wsl/install-apk.sh [debug|release]
set -euo pipefail
VARIANT="${1:-debug}"
WIN_PROJECT='/mnt/d/workbench/iboard'
export PATH="$HOME/android-sdk/platform-tools:$PATH"
APK_DIR="$WIN_PROJECT/src-tauri/target/$( [ "$VARIANT" = "release" ] && echo release || echo debug )/bundle/android/app/build/outputs/apk/$VARIANT"
APK=$(ls -1 "$APK_DIR"/*.apk | head -1)
echo "安装: $APK"
adb install -r "$APK"
+68
View File
@@ -0,0 +1,68 @@
#!/usr/bin/env bash
# iBoard Android 端 WSL 首次搭建脚本
# 在 WSL (Ubuntu 22.04+) 内执行: bash scripts/wsl/setup.sh
# 完成后即可运行: bash scripts/wsl/build-apk.sh
set -euo pipefail
# Windows 项目路径 (按需调整)
WIN_PROJECT='/mnt/d/workbench/iboard'
cd "$WIN_PROJECT"
echo "=== 1. 系统依赖 ==="
sudo apt update
sudo apt install -y \
build-essential curl wget file libssl-dev pkg-config \
unzip zip git \
openjdk-17-jdk-headless \
clang libgtk-3-dev libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev \
libsoup-3.0-dev libjavascriptcoregtk-4.1-dev
echo "=== 2. Rust ==="
if ! command -v cargo >/dev/null 2>&1; then
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
source "$HOME/.cargo/env"
fi
rustc --version
cargo --version
echo "=== 3. Android SDK + NDK ==="
export ANDROID_HOME="$HOME/Android/Sdk"
export ANDROID_NDK_HOME="$ANDROID_HOME/ndk/$(ls -1 $ANDROID_HOME/ndk 2>/dev/null | sort -V | tail -1 || echo 26.1.10909125)"
mkdir -p "$HOME/android-sdk/cmdline-tools"
cd /tmp
if [ ! -d "$HOME/android-sdk/cmdline-tools/latest" ]; then
echo "下载 Android cmdline-tools..."
wget -q https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip -O cmdline-tools.zip
unzip -q cmdline-tools.zip
mv cmdline-tools "$HOME/android-sdk/cmdline-tools/latest"
fi
export PATH="$HOME/android-sdk/cmdline-tools/latest/bin:$PATH"
yes | sdkmanager --licenses >/dev/null
sdkmanager --install "platforms;android-34" "build-tools;34.0.0" "platform-tools" \
"ndk;26.1.10909125" "cmake;3.22.1"
echo "ANDROID_HOME=$ANDROID_HOME"
echo "ANDROID_NDK_HOME=$ANDROID_NDK_HOME"
echo "=== 4. Node 依赖 (与 Windows 共用 node_modules 也行, 这里重新装) ==="
cd "$WIN_PROJECT"
# 跳过 Windows 二进制不兼容的 sharp 等, npm 会自动 rebuild
npm install
echo "=== 5. Tauri Android 工程初始化 (首次执行) ==="
cd "$WIN_PROJECT/src-tauri"
if [ ! -d "gen" ]; then
npx tauri android init
fi
echo
echo "=== 搭建完成 ==="
echo "接下来:"
echo " bash scripts/wsl/build-apk.sh # 打 debug APK"
echo " bash scripts/wsl/build-apk.sh release # 打 release APK (需要签名)"
echo
echo "如要永久生效, 把下面加进 ~/.bashrc:"
echo " export ANDROID_HOME=$ANDROID_HOME"
echo " export ANDROID_NDK_HOME=$ANDROID_NDK_HOME"
echo " export PATH=\$PATH:$HOME/android-sdk/cmdline-tools/latest/bin:$HOME/android-sdk/platform-tools"
View File
+2
View File
@@ -0,0 +1,2 @@
[iboard-api] listening on http://127.0.0.1:3001
[iboard-api] static dist: D:\workbench\iboard\dist
+49
View File
@@ -0,0 +1,49 @@
// 从 content/articles/*.md 读取文章列表和详情
import fs from 'node:fs'
import path from 'node:path'
import matter from 'gray-matter'
import { marked } from 'marked'
const DIR = path.join(process.cwd(), 'content', 'articles')
function readAll() {
if (!fs.existsSync(DIR)) return []
return fs.readdirSync(DIR)
.filter((f) => f.endsWith('.md'))
.map((fileName) => {
const fullPath = path.join(DIR, fileName)
const raw = fs.readFileSync(fullPath, 'utf8')
const { data, content } = matter(raw)
const slug = fileName.replace(/\.md$/, '')
const dateMatch = fileName.match(/^(\d{4}-\d{2}-\d{2})-/)
const date = dateMatch ? dateMatch[1] : ''
return {
slug,
date,
title: data.title || slug.replace(/^\d{4}-\d{2}-\d{2}-/, ''),
excerpt: content.replace(/\s+/g, ' ').trim().slice(0, 150) + (content.length > 150 ? '...' : ''),
}
})
.sort((a, b) => (a.date < b.date ? 1 : -1))
}
export function listArticles() {
return readAll().map(({ slug, title, date, excerpt }) => ({ slug, title, date, excerpt }))
}
export function getArticle(slug) {
// 防止路径穿越
if (slug.includes('/') || slug.includes('\\') || slug.includes('..')) return null
const fullPath = path.join(DIR, slug + '.md')
if (!fs.existsSync(fullPath)) return null
const raw = fs.readFileSync(fullPath, 'utf8')
const { data, content } = matter(raw)
const dateMatch = slug.match(/^(\d{4}-\d{2}-\d{2})-/)
const date = dateMatch ? dateMatch[1] : ''
return {
slug,
date,
title: data.title || slug.replace(/^\d{4}-\d{2}-\d{2}-/, ''),
contentHtml: marked.parse(content),
}
}
+18
View File
@@ -0,0 +1,18 @@
// 服务端 Prisma 单例, 使用 mariadb 适配器
import 'dotenv/config'
import { PrismaMariaDb } from '@prisma/adapter-mariadb'
import { PrismaClient } from '../generated/prisma/client.ts'
function parseDatabaseUrl(url) {
const u = new URL(url)
return {
host: u.hostname,
port: Number(u.port) || 3306,
user: decodeURIComponent(u.username),
password: decodeURIComponent(u.password),
database: u.pathname.replace(/^\//, ''),
}
}
const adapter = new PrismaMariaDb(parseDatabaseUrl(process.env.DATABASE_URL))
export const prisma = new PrismaClient({ adapter })
+47
View File
@@ -0,0 +1,47 @@
import 'dotenv/config'
import path from 'node:path'
import fs from 'node:fs'
import express from 'express'
import cors from 'cors'
import authRouter from './routes/auth.js'
import dataRouter from './routes/data.js'
import articlesRouter from './routes/articles.js'
const PORT = Number(process.env.PORT) || 3001
const DIST_DIR = path.join(process.cwd(), 'dist')
const HAS_DIST = fs.existsSync(DIST_DIR)
const app = express()
app.use(cors())
app.use(express.json())
// 健康检查
app.get('/api/health', (_req, res) => res.json({ ok: true, ts: Date.now() }))
// 业务路由
app.use('/api/auth', authRouter)
app.use('/api', dataRouter)
app.use('/api', articlesRouter)
// 静态资源 (Vite build 产物)
if (HAS_DIST) {
app.use(express.static(DIST_DIR, { index: false, maxAge: '1h', etag: true }))
// SPA fallback: 非 /api/* 的请求一律返回 index.html, 由前端路由处理
app.get(/^\/(?!api\/).*/, (_req, res, next) => {
const indexHtml = path.join(DIST_DIR, 'index.html')
if (!fs.existsSync(indexHtml)) return next()
res.sendFile(indexHtml)
})
}
// 统一错误处理
app.use((err, _req, res, _next) => {
console.error('[api error]', err)
res.status(500).json({ error: err.message || '服务器内部错误' })
})
app.listen(PORT, () => {
console.log(`[iboard-api] listening on http://127.0.0.1:${PORT}`)
console.log(`[iboard-api] static dist: ${HAS_DIST ? DIST_DIR : '(not built, API only)'}`)
})
+31
View File
@@ -0,0 +1,31 @@
import jwt from 'jsonwebtoken'
// 简易 JWT 鉴权: 解析 Authorization: Bearer xxx, 注入 req.user
// 失败时统一返回 401
export const JWT_SECRET = process.env.JWT_SECRET || 'iboard-dev-secret-change-me'
const TOKEN_TTL = '7d' // 7 天, 与"记住登录"语义一致
export function signToken(payload) {
return jwt.sign(payload, JWT_SECRET, { expiresIn: TOKEN_TTL })
}
export function authRequired(req, res, next) {
const h = req.headers.authorization || ''
const m = h.match(/^Bearer\s+(.+)$/)
if (!m) return res.status(401).json({ error: '未登录' })
try {
req.user = jwt.verify(m[1], JWT_SECRET)
next()
} catch (e) {
return res.status(401).json({ error: '登录已过期' })
}
}
export function authOptional(req, _res, next) {
const h = req.headers.authorization || ''
const m = h.match(/^Bearer\s+(.+)$/)
if (m) {
try { req.user = jwt.verify(m[1], JWT_SECRET) } catch { /* ignore */ }
}
next()
}
+19
View File
@@ -0,0 +1,19 @@
import { Router } from 'express'
import { authRequired } from '../middleware.js'
import { listArticles, getArticle } from '../articles.js'
const router = Router()
router.use(authRequired)
router.get('/articles', (_req, res) => {
res.json({ articles: listArticles() })
})
router.get('/articles/:slug', (req, res) => {
const article = getArticle(req.params.slug)
if (!article) return res.status(404).json({ error: '文章不存在' })
res.json({ article })
})
export default router
+46
View File
@@ -0,0 +1,46 @@
import { Router } from 'express'
import crypto from 'node:crypto'
import { prisma } from '../db.js'
import { authRequired, signToken } from '../middleware.js'
const router = Router()
// 校验密码: 接受 scrypt$salt$hash 格式
function verifyPassword(plain, stored) {
if (!stored || !stored.startsWith('scrypt$')) return false
const parts = stored.split('$')
if (parts.length !== 3) return false
const [, salt, hash] = parts
const computed = crypto.scryptSync(plain, salt, 64).toString('hex')
// 常数时间比较
const a = Buffer.from(computed, 'hex')
const b = Buffer.from(hash, 'hex')
if (a.length !== b.length) return false
return crypto.timingSafeEqual(a, b)
}
router.post('/login', async (req, res) => {
const { email, password } = req.body || {}
if (!email || !password) return res.status(400).json({ error: '邮箱或密码不能为空' })
const user = await prisma.user.findUnique({ where: { email: String(email).toLowerCase() } })
if (!user) return res.status(401).json({ error: '邮箱或密码错误' })
if (!verifyPassword(String(password), user.password)) {
return res.status(401).json({ error: '邮箱或密码错误' })
}
const payload = { sub: user.id, email: user.email, name: user.name }
const token = signToken(payload)
res.json({ token, user: { id: user.id, name: user.name, email: user.email, createdAt: user.createdAt } })
})
router.get('/me', authRequired, async (req, res) => {
const user = await prisma.user.findUnique({ where: { id: req.user.sub } })
if (!user) return res.status(401).json({ error: '用户不存在' })
res.json({ user: { id: user.id, name: user.name, email: user.email, createdAt: user.createdAt } })
})
router.post('/logout', (_req, res) => {
// JWT 无状态, 前端清 token 即可; 这里返回 200 让前端不用特判
res.json({ ok: true })
})
export default router
+30
View File
@@ -0,0 +1,30 @@
import { Router } from 'express'
import { prisma } from '../db.js'
import { authRequired } from '../middleware.js'
const router = Router()
router.use(authRequired)
router.get('/indicators', async (_req, res, next) => {
try {
const rows = await prisma.indicator.findMany({ orderBy: [{ groupName: 'asc' }, { name: 'asc' }] })
res.json({ indicators: rows })
} catch (e) { next(e) }
})
router.get('/strategies', async (_req, res, next) => {
try {
const rows = await prisma.strategy.findMany({ orderBy: { name: 'asc' } })
res.json({ strategies: rows })
} catch (e) { next(e) }
})
router.get('/sow', async (_req, res, next) => {
try {
const rows = await prisma.econ_SowInventory.findMany({ orderBy: { month: 'asc' } })
res.json({ rows })
} catch (e) { next(e) }
})
export default router
+11
View File
@@ -0,0 +1,11 @@
# Tauri 自动生成
gen/
target/
# Android 编译产物
android/app/build/
android/.gradle/
android/build/
android/local.properties
android/captures/
android/.idea/
+30
View File
@@ -0,0 +1,30 @@
[package]
name = "iboard"
version = "0.1.0"
description = "iBoard - 经济数据与策略"
authors = ["iBoard Team"]
edition = "2021"
rust-version = "1.77"
# 库目标: 桌面 + Android (cdylib) 都需要
[lib]
name = "iboard_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = [] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
# Android 平台需要的 JNI / Activity 启动能力
[target."cfg(target_os = \"android\")".dependencies]
tauri = { version = "2", features = [] }
[features]
# Tauri 的"自定义协议"模式让打包后的 webview 能从 tauri:// 加载资源
# 用于生产构建, dev 模式不需要
default = ["custom-protocol"]
custom-protocol = ["tauri/custom-protocol"]
+3
View File
@@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}
+9
View File
@@ -0,0 +1,9 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "默认能力: 允许 core API",
"windows": ["main"],
"permissions": [
"core:default"
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 344 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

+8
View File
@@ -0,0 +1,8 @@
// 跨平台入口: 桌面从这里走, Android 走 mobile_entry_point 宏
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.setup(|_app| Ok(()))
.run(tauri::generate_context!())
.expect("error while running iBoard");
}
+7
View File
@@ -0,0 +1,7 @@
// Windows / Linux / macOS 桌面端入口
// Android 不走这里, 走 .lib crate
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
iboard_lib::run();
}
+42
View File
@@ -0,0 +1,42 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "iBoard",
"version": "0.1.0",
"identifier": "com.iboard.app",
"build": {
"beforeDevCommand": "npm run dev",
"beforeBuildCommand": "npm run build",
"devUrl": "http://localhost:5173",
"frontendDist": "../dist"
},
"app": {
"windows": [
{
"title": "iBoard",
"width": 420,
"height": 820,
"minWidth": 360,
"minHeight": 640,
"resizable": true,
"fullscreen": false
}
],
"security": {
"csp": null
}
},
"bundle": {
"active": true,
"targets": "all",
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
],
"android": {
"minSdkVersion": 24
}
}
}
+40
View File
@@ -0,0 +1,40 @@
import { Routes, Route, Navigate, useLocation } from 'react-router-dom'
import MobileShell from './layouts/MobileShell.jsx'
import LoginPage from './pages/LoginPage.jsx'
import HomePage from './pages/HomePage.jsx'
import MacroPage from './pages/MacroPage.jsx'
import MonitorPage from './pages/MonitorPage.jsx'
import MePage from './pages/MePage.jsx'
import { useAuth } from './auth/AuthContext.jsx'
// 已登录访问 /login 自动回首页
function RedirectIfAuthed({ children }) {
const { user, loading } = useAuth()
if (loading) return null
if (user) return <Navigate to="/" replace />
return children
}
// 未登录访问受保护页面 → 跳 /login
function RequireAuth({ children }) {
const { user, loading } = useAuth()
const location = useLocation()
if (loading) return <div className="page-loading">加载中...</div>
if (!user) return <Navigate to="/login" state={{ from: location }} replace />
return children
}
export default function App() {
return (
<Routes>
<Route path="/login" element={<RedirectIfAuthed><LoginPage /></RedirectIfAuthed>} />
<Route element={<RequireAuth><MobileShell /></RequireAuth>}>
<Route path="/" element={<HomePage />} />
<Route path="/macro/*" element={<MacroPage />} />
<Route path="/monitor" element={<MonitorPage />} />
<Route path="/me" element={<MePage />} />
</Route>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
)
}
+43
View File
@@ -0,0 +1,43 @@
// API 调用封装: 自动带 JWT、401 自动清 token、抛统一错误
const TOKEN_KEY = 'iboard_token'
const REMEMBER_KEY = 'iboard_remember'
export function getToken() {
return localStorage.getItem(TOKEN_KEY) || ''
}
export function setToken(token) {
if (token) localStorage.setItem(TOKEN_KEY, token)
else localStorage.removeItem(TOKEN_KEY)
}
export function getRememberFlag() {
return localStorage.getItem(REMEMBER_KEY) === '1'
}
export function setRememberFlag(v) {
if (v) localStorage.setItem(REMEMBER_KEY, '1')
else localStorage.removeItem(REMEMBER_KEY)
}
export async function api(path, { method = 'GET', body, headers = {} } = {}) {
const token = getToken()
const res = await fetch('/api' + path, {
method,
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: 'Bearer ' + token } : {}),
...headers,
},
body: body ? JSON.stringify(body) : undefined,
})
if (res.status === 401) {
setToken('')
const data = await res.json().catch(() => ({}))
if (!path.startsWith('/auth/')) window.location.assign('/login')
throw new Error(data.error || '未登录')
}
const data = await res.json().catch(() => ({}))
if (!res.ok) throw new Error(data.error || '请求失败 (' + res.status + ')')
return data
}
+60
View File
@@ -0,0 +1,60 @@
import { createContext, useContext, useEffect, useState } from 'react'
import { api, getToken, setToken, setRememberFlag } from '../api/client.js'
const AuthContext = createContext(null)
export function AuthProvider({ children }) {
const [user, setUser] = useState(null)
const [loading, setLoading] = useState(true)
// 启动时: 有 token 就尝试拉 /me, 失败则清空
useEffect(() => {
let cancelled = false
async function bootstrap() {
if (!getToken()) {
setLoading(false)
return
}
try {
const data = await api('/auth/me')
if (!cancelled) setUser(data.user)
} catch {
if (!cancelled) {
setToken('')
setUser(null)
}
} finally {
if (!cancelled) setLoading(false)
}
}
bootstrap()
return () => { cancelled = true }
}, [])
async function login(email, password, remember) {
const data = await api('/auth/login', { method: 'POST', body: { email, password, remember: !!remember } })
setToken(data.token)
setRememberFlag(!!remember)
setUser(data.user)
return data.user
}
async function logout() {
try { await api('/auth/logout', { method: 'POST' }) } catch { /* ignore */ }
setToken('')
setRememberFlag(false)
setUser(null)
}
return (
<AuthContext.Provider value={{ user, loading, login, logout }}>
{children}
</AuthContext.Provider>
)
}
export function useAuth() {
const ctx = useContext(AuthContext)
if (!ctx) throw new Error('useAuth must be used inside AuthProvider')
return ctx
}
+26
View File
@@ -0,0 +1,26 @@
import { NavLink } from 'react-router-dom'
const TABS = [
{ to: '/', label: '首页', icon: '⌂' },
{ to: '/macro', label: '宏观', icon: '◫' },
{ to: '/monitor', label: '监控', icon: '◉' },
{ to: '/me', label: '我', icon: '☺' },
]
export default function BottomNav() {
return (
<nav className="bottom-nav" aria-label="主导航">
{TABS.map((t) => (
<NavLink
key={t.to}
to={t.to}
end={t.to === '/'}
className={({ isActive }) => 'bottom-nav-item' + (isActive ? ' active' : '')}
>
<span className="icon" aria-hidden="true">{t.icon}</span>
<span>{t.label}</span>
</NavLink>
))}
</nav>
)
}
+86
View File
@@ -0,0 +1,86 @@
import { useRef, useState, useEffect } from 'react'
// 滑屏容器 + 顶栏 tabs
// props:
// tabs: [{ key, label, render }]
// active: 当前激活 key
// onChange(key): 切换回调 (用户滑动或点击 tab)
export default function SwipeTabs({ tabs, active, onChange }) {
const idx = Math.max(0, tabs.findIndex((t) => t.key === active))
const [dragging, setDragging] = useState(false)
const [offsetPx, setOffsetPx] = useState(0)
const [animOff, setAnimOff] = useState(false)
const startX = useRef(0)
const containerRef = useRef(null)
// idx 变化时关闭拖动动画 (避免快速切换时滑动残影)
useEffect(() => { setOffsetPx(0); setAnimOff(false) }, [idx])
function onTouchStart(e) {
startX.current = e.touches[0].clientX
setDragging(true)
setAnimOff(true)
}
function onTouchMove(e) {
if (!dragging) return
const dx = e.touches[0].clientX - startX.current
const w = containerRef.current?.offsetWidth || window.innerWidth
// 限制左右溢出 (超过边界阻力增大)
const limited = Math.max(Math.min(dx, w * 0.4), -w * 0.4)
setOffsetPx(limited)
}
function onTouchEnd() {
if (!dragging) return
setDragging(false)
setAnimOff(false)
const w = containerRef.current?.offsetWidth || window.innerWidth
const dx = offsetPx
let next = idx
if (dx < -w * 0.18 && idx < tabs.length - 1) next = idx + 1
else if (dx > w * 0.18 && idx > 0) next = idx - 1
setOffsetPx(0)
if (next !== idx) onChange(tabs[next].key)
}
// 用 -33.33% × idx 作为基础位移, 再叠加拖动偏移
const basePct = idx * 33.3333
const dragPct = containerRef.current
? (offsetPx / containerRef.current.offsetWidth) * 100
: 0
const translate = `calc(-${basePct}% + ${dragPct}%)`
return (
<>
<div className="top-tabs" role="tablist">
{tabs.map((t) => (
<button
key={t.key}
role="tab"
aria-selected={t.key === active}
className={'top-tabs-item' + (t.key === active ? ' active' : '')}
onClick={() => onChange(t.key)}
>
{t.label}
</button>
))}
</div>
<div
ref={containerRef}
className="swipe-container"
onTouchStart={onTouchStart}
onTouchMove={onTouchMove}
onTouchEnd={onTouchEnd}
onTouchCancel={onTouchEnd}
>
<div
className={'swipe-track' + (animOff || dragging ? ' no-anim' : '')}
style={{ '--swipe-offset': translate, transform: `translateX(${translate})` }}
>
{tabs.map((t) => (
<div key={t.key} className="swipe-pane">{t.render()}</div>
))}
</div>
</div>
</>
)
}
+14
View File
@@ -0,0 +1,14 @@
import { Outlet } from 'react-router-dom'
import BottomNav from '../components/BottomNav.jsx'
// 移动端应用外壳: 底栏 + 滚动主区
export default function MobileShell() {
return (
<div className="app-root">
<main className="app-main">
<Outlet />
</main>
<BottomNav />
</div>
)
}
+16
View File
@@ -0,0 +1,16 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import App from './App.jsx'
import { AuthProvider } from './auth/AuthContext.jsx'
import './styles/globals.css'
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<BrowserRouter>
<AuthProvider>
<App />
</AuthProvider>
</BrowserRouter>
</React.StrictMode>,
)
+10
View File
@@ -0,0 +1,10 @@
// 首页: 暂留占位, 后续接入监控告警 / 个性化推荐
export default function HomePage() {
return (
<div className="placeholder-page">
<div className="icon">⌂</div>
<div>首页</div>
<div style={{ fontSize: 12, opacity: .7 }}>后续接入: 监控告警 / 个性化推荐 / 收藏的指标</div>
</div>
)
}
+61
View File
@@ -0,0 +1,61 @@
import { useState } from 'react'
import { useNavigate, useLocation, Navigate } from 'react-router-dom'
import { useAuth } from '../auth/AuthContext.jsx'
export default function LoginPage() {
const { user, login } = useAuth()
const navigate = useNavigate()
const location = useLocation()
const from = location.state?.from?.pathname || '/'
const [email, setEmail] = useState('admin@iboard.local')
const [password, setPassword] = useState('admin123')
const [remember, setRemember] = useState(true)
const [busy, setBusy] = useState(false)
const [err, setErr] = useState('')
if (user) return <Navigate to={from} replace />
async function onSubmit(e) {
e.preventDefault()
setErr('')
setBusy(true)
try {
await login(email.trim(), password, remember)
navigate(from, { replace: true })
} catch (e) {
setErr(e.message)
} finally {
setBusy(false)
}
}
return (
<div className="login-page">
<form className="login-card" onSubmit={onSubmit}>
<div className="login-title">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>
</form>
</div>
)
}
+62
View File
@@ -0,0 +1,62 @@
import { useState } from 'react'
import { useNavigate, useLocation, Routes, Route, Navigate } from 'react-router-dom'
import SwipeTabs from '../components/SwipeTabs.jsx'
import DataView from './macro/DataView.jsx'
import StrategyView from './macro/StrategyView.jsx'
import ArticlesView from './macro/ArticlesView.jsx'
import ArticleDetail from './macro/ArticleDetail.jsx'
// 宏观: 顶栏 3 tab (经济文章 / 经济数据 / 策略研究), 默认 经济数据
// /macro → 重定向到 /macro/data
// /macro/data → 经济数据 (默认)
// /macro/strategy→ 策略研究
// /macro/articles→ 经济文章
// /macro/articles/:slug → 文章详情
const TABS = [
{ key: 'data', label: '经济数据', path: '/macro/data' },
{ key: 'strategy', label: '策略研究', path: '/macro/strategy' },
{ key: 'articles', label: '经济文章', path: '/macro/articles' },
]
function activeFromPath(pathname) {
if (pathname.startsWith('/macro/articles')) return 'articles'
if (pathname.startsWith('/macro/strategy')) return 'strategy'
return 'data'
}
export default function MacroPage() {
const location = useLocation()
const navigate = useNavigate()
const active = activeFromPath(location.pathname)
const tabs = TABS.map((t) => ({
key: t.key,
label: t.label,
render: () => {
if (t.key === 'data') return <DataView />
if (t.key === 'strategy') return <StrategyView />
if (t.key === 'articles') return <ArticlesView />
return null
},
}))
function onChange(key) {
const next = TABS.find((t) => t.key === key)
if (next) navigate(next.path)
}
// 详情页 /macro/articles/:slug 单独走 React Router 渲染, 不参与滑屏
if (location.pathname.match(/^\/macro\/articles\/[^/]+$/)) {
return (
<Routes>
<Route path="articles/:slug" element={<ArticleDetail />} />
</Routes>
)
}
return (
<div>
<SwipeTabs tabs={tabs} active={active} onChange={onChange} />
</div>
)
}
+36
View File
@@ -0,0 +1,36 @@
import { useNavigate } from 'react-router-dom'
import { useAuth } from '../auth/AuthContext.jsx'
export default function MePage() {
const { user, logout } = useAuth()
const navigate = useNavigate()
async function onLogout() {
if (!confirm('确定退出登录?')) return
await logout()
navigate('/login', { replace: true })
}
return (
<div>
<div className="me-header">
<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>
</div>
)
}
+10
View File
@@ -0,0 +1,10 @@
// 监控: 暂留占位
export default function MonitorPage() {
return (
<div className="placeholder-page">
<div className="icon">◉</div>
<div>监控</div>
<div style={{ fontSize: 12, opacity: .7 }}>后续接入: 指标阈值告警 / 时序趋势</div>
</div>
)
}
+35
View File
@@ -0,0 +1,35 @@
import { useEffect, useState } from 'react'
import { useParams, useNavigate, Link } from 'react-router-dom'
import { api } from '../../api/client.js'
// 文章详情: /macro/articles/:slug
export default function ArticleDetail() {
const { slug } = useParams()
const navigate = useNavigate()
const [article, setArticle] = useState(null)
const [err, setErr] = useState('')
const [loading, setLoading] = useState(true)
useEffect(() => {
let cancelled = false
setLoading(true)
api('/articles/' + encodeURIComponent(slug))
.then((data) => { if (!cancelled) setArticle(data.article) })
.catch((e) => !cancelled && setErr(e.message))
.finally(() => !cancelled && setLoading(false))
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>
)
}
+39
View File
@@ -0,0 +1,39 @@
import { useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import { api } from '../../api/client.js'
// 经济文章: 列表, 点进 /macro/articles/:slug
export default function ArticlesView() {
const [articles, setArticles] = useState([])
const [err, setErr] = useState('')
const [loading, setLoading] = useState(true)
useEffect(() => {
let cancelled = false
setLoading(true)
api('/articles')
.then((data) => { if (!cancelled) setArticles(data.articles || []) })
.catch((e) => !cancelled && setErr(e.message))
.finally(() => !cancelled && setLoading(false))
return () => { cancelled = true }
}, [])
if (loading) return <div className="loading">加载中...</div>
if (err) return <div className="error-banner">{err}</div>
if (!articles.length) return <div className="empty">暂无文章</div>
return (
<div>
<div className="page-title">经济文章</div>
<div className="list">
{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>
))}
</div>
</div>
)
}
+109
View File
@@ -0,0 +1,109 @@
import { useEffect, useState } from 'react'
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts'
import { api } from '../../api/client.js'
// 经济数据: 从 /api/indicators 拉所有指标, 顶上一个 chip 行切换, 下面渲染图表+表格
export default function DataView() {
const [indicators, setIndicators] = useState([])
const [selected, setSelected] = useState(null)
const [err, setErr] = useState('')
const [loading, setLoading] = useState(true)
useEffect(() => {
let cancelled = false
setLoading(true)
api('/indicators')
.then((data) => {
if (cancelled) return
setIndicators(data.indicators || [])
if (data.indicators && data.indicators[0]) setSelected(data.indicators[0].name)
})
.catch((e) => !cancelled && setErr(e.message))
.finally(() => !cancelled && setLoading(false))
return () => { cancelled = true }
}, [])
if (loading) return <div className="loading">加载中...</div>
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')
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>
))}
{cur && (
<div style={{ padding: '0 12px 12px' }}>
<div className="card">
<div style={{ fontWeight: 600, marginBottom: 4 }}>{cur.data.title || cur.name}</div>
<div style={{ fontSize: 12, color: 'var(--color-muted)', marginBottom: 8 }}>
单位 {cur.data.unit || '-'} · 来源 {cur.data.source || 'seed'}
</div>
<div className="chart-wrap">
<ResponsiveContainer width="100%" height={260}>
<LineChart data={chartData} margin={{ top: 10, right: 16, left: 0, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="month" tick={{ fontSize: 11 }} />
<YAxis tick={{ fontSize: 11 }} />
<Tooltip />
<Legend />
<Line type="monotone" dataKey="value" name={cur.data.unit || '值'} stroke="#2563eb" strokeWidth={2} dot={{ r: 3 }} />
</LineChart>
</ResponsiveContainer>
</div>
<div style={{ maxHeight: 240, overflow: 'auto' }}>
<table className="data-table">
<thead>
<tr><th>月份</th><th style={{ textAlign: 'right' }}>数值</th></tr>
</thead>
<tbody>
{chartData.map((r) => (
<tr key={r.month}><td>{r.month}</td><td style={{ textAlign: 'right' }}>{r.value}</td></tr>
))}
</tbody>
</table>
</div>
</div>
</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) || []
}
+108
View File
@@ -0,0 +1,108 @@
import { useEffect, useState } from 'react'
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts'
import { api } from '../../api/client.js'
// 策略研究: 顶部 chip 行选策略, 下面显示 4 张图 (debt / gdp / debtToGdp / interestToGdp) + 表格
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' },
]
export default function StrategyView() {
const [strategies, setStrategies] = useState([])
const [selected, setSelected] = useState(null)
const [err, setErr] = useState('')
const [loading, setLoading] = useState(true)
useEffect(() => {
let cancelled = false
setLoading(true)
api('/strategies')
.then((data) => {
if (cancelled) return
setStrategies(data.strategies || [])
if (data.strategies && data.strategies[0]) setSelected(data.strategies[0].name)
})
.catch((e) => !cancelled && setErr(e.message))
.finally(() => !cancelled && setLoading(false))
return () => { cancelled = true }
}, [])
if (loading) return <div className="loading">加载中...</div>
if (err) return <div className="error-banner">{err}</div>
if (!strategies.length) return <div className="empty">暂无策略数据</div>
const cur = strategies.find((s) => s.name === selected)
const rows = cur ? cur.data.rows : []
return (
<div>
<div className="page-title">策略研究</div>
<div className="chip-row" style={{ paddingLeft: 12 }}>
{strategies.map((s) => (
<button
key={s.name}
className={'chip' + (selected === s.name ? ' active' : '')}
onClick={() => setSelected(s.name)}
>
{s.description || s.name}
</button>
))}
</div>
{cur && (
<div style={{ padding: '0 12px 12px' }}>
<div className="card">
<div style={{ fontWeight: 600, marginBottom: 4 }}>{cur.description || cur.name}</div>
<div style={{ fontSize: 12, color: 'var(--color-muted)', marginBottom: 8 }}>
数据 {rows.length} 条 · 来源 {cur.data.source || 'seed'}
</div>
{SERIES.map((s) => (
<div key={s.key} style={{ marginBottom: 12 }}>
<div style={{ fontSize: 13, fontWeight: 500, marginBottom: 4 }}>{s.label}</div>
<div className="chart-wrap">
<ResponsiveContainer width="100%" height={220}>
<LineChart data={rows} margin={{ top: 8, right: 12, left: 0, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="year" tick={{ fontSize: 11 }} />
<YAxis tick={{ fontSize: 11 }} />
<Tooltip />
<Legend />
<Line type="monotone" dataKey={s.key} name={s.label} stroke={s.color} strokeWidth={2} dot={{ r: 3 }} />
</LineChart>
</ResponsiveContainer>
</div>
</div>
))}
<div style={{ maxHeight: 220, overflow: 'auto', marginTop: 8 }}>
<table className="data-table">
<thead>
<tr>
<th>年份</th>
<th style={{ textAlign: 'right' }}>债务</th>
<th style={{ textAlign: 'right' }}>GDP</th>
<th style={{ textAlign: 'right' }}>债务/GDP</th>
<th style={{ textAlign: 'right' }}>利息/GDP</th>
</tr>
</thead>
<tbody>
{rows.map((r) => (
<tr key={r.year}>
<td>{r.year}</td>
<td style={{ textAlign: 'right' }}>{r.debt}</td>
<td style={{ textAlign: 'right' }}>{r.gdp}</td>
<td style={{ textAlign: 'right' }}>{r.debtToGdp}</td>
<td style={{ textAlign: 'right' }}>{r.interestToGdp}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
)}
</div>
)
}
+717
View File
@@ -0,0 +1,717 @@
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
background-color: #f5f5f5;
color: #333;
}
.container {
min-height: 100vh;
display: flex;
flex-direction: column;
}
/* Navbar */
.navbar {
background-color: #333;
color: white;
padding: 1rem;
display: flex;
justify-content: space-between;
align-items: center;
}
.logo {
font-size: 1.5rem;
font-weight: bold;
}
.logo a {
color: white;
text-decoration: none;
}
.nav-links {
display: flex;
gap: 1rem;
}
.nav-links a {
color: white;
text-decoration: none;
padding: 0.5rem;
border-radius: 4px;
}
.nav-links a:hover {
background-color: #555;
}
/* Global link styles */
a {
text-decoration: none;
cursor: pointer;
}
/* Main content */
main {
flex: 1;
padding: 2rem;
max-width: 1200px;
margin: 0 auto;
width: 100%;
}
.title {
font-size: 2.5rem;
margin-bottom: 1rem;
color: #333;
}
.description {
font-size: 1.2rem;
margin-bottom: 2rem;
color: #666;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 1.5rem;
}
.card {
background-color: white;
padding: 1.5rem;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
transition: transform 0.2s ease;
}
.card:hover {
transform: translateY(-5px);
}
.card h2 {
margin-bottom: 0.5rem;
color: #333;
}
.card p {
color: #666;
}
/* Footer */
.footer {
background-color: #333;
color: white;
padding: 1rem;
text-align: center;
margin-top: auto;
}
.footer-content {
max-width: 1200px;
margin: 0 auto;
}
/* Data page layout */
.data-layout {
display: flex;
gap: 2rem;
margin-top: 2rem;
}
.data-sidebar {
width: 250px;
flex-shrink: 0;
}
.data-content {
flex: 1;
min-width: 0;
}
.indicator-group {
margin-bottom: 1.5rem;
}
.group-title {
font-size: 1.1rem;
font-weight: 600;
color: #333;
margin-bottom: 0.75rem;
padding-bottom: 0.5rem;
border-bottom: 2px solid #ddd;
}
.indicator-list {
list-style: none;
padding: 0;
margin: 0;
}
.indicator-item {
padding: 0.75rem 1rem;
margin-bottom: 0.5rem;
background-color: white;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s ease;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.indicator-item:hover {
background-color: #f0f0f0;
transform: translateX(4px);
}
.indicator-item.active {
background-color: #333;
color: white;
}
.indicator-detail {
background-color: white;
padding: 2rem;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.indicator-title {
font-size: 1.8rem;
color: #333;
margin-bottom: 0.5rem;
}
.indicator-description {
color: #666;
margin-bottom: 2rem;
}
.chart-container {
margin-bottom: 3rem;
padding: 1rem;
background-color: #fafafa;
border-radius: 8px;
}
.data-table-container {
margin-top: 2rem;
}
.table-title {
font-size: 1.3rem;
color: #333;
margin-bottom: 1rem;
}
.data-table {
width: 100%;
border-collapse: collapse;
font-size: 0.95rem;
}
.data-table th,
.data-table td {
padding: 0.75rem 1rem;
text-align: left;
border-bottom: 1px solid #eee;
}
.data-table th {
background-color: #f5f5f5;
font-weight: 600;
color: #333;
}
.data-table tbody tr:hover {
background-color: #f9f9f9;
}
/* Articles section */
.articles-section {
margin-top: 4rem;
margin-bottom: 2rem;
}
.section-title {
font-size: 2rem;
color: #333;
margin-bottom: 1.5rem;
padding-bottom: 0.5rem;
border-bottom: 2px solid #ddd;
}
.articles-grid {
display: flex;
flex-direction: column;
gap: 1rem;
margin-bottom: 2rem;
}
.article-card {
background-color: white;
padding: 1.5rem;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
transition: all 0.2s ease;
cursor: pointer;
}
.article-card:hover {
background-color: #f9f9f9;
transform: translateX(5px);
}
.article-date {
font-size: 0.9rem;
color: #666;
margin-bottom: 0.75rem;
}
.article-title {
font-size: 1.2rem;
font-weight: 600;
color: #333;
margin-bottom: 0.75rem;
line-height: 1.4;
}
.article-excerpt {
color: #666;
line-height: 1.5;
font-size: 0.95rem;
}
/* Pagination */
.pagination {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
margin-top: 2rem;
}
.page-link {
padding: 0.5rem 1rem;
background-color: white;
border: 1px solid #ddd;
border-radius: 4px;
color: #333;
text-decoration: none;
transition: all 0.2s ease;
}
.page-link:hover {
background-color: #f0f0f0;
}
.page-link.active {
background-color: #333;
color: white;
border-color: #333;
}
/* Article detail page */
.article-detail {
max-width: 800px;
margin: 0 auto;
background-color: white;
padding: 2rem;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.article-detail-title {
font-size: 2.5rem;
color: #333;
margin-bottom: 1rem;
line-height: 1.2;
}
.article-detail-date {
font-size: 1rem;
color: #666;
margin-bottom: 2rem;
padding-bottom: 1rem;
border-bottom: 1px solid #eee;
}
.article-detail-content {
color: #333;
line-height: 1.6;
}
.article-detail-content h1,
.article-detail-content h2,
.article-detail-content h3,
.article-detail-content h4,
.article-detail-content h5,
.article-detail-content h6 {
margin-top: 2rem;
margin-bottom: 1rem;
color: #333;
}
.article-detail-content p {
margin-bottom: 1.5rem;
}
.article-detail-content ul,
.article-detail-content ol {
margin-bottom: 1.5rem;
padding-left: 2rem;
}
.article-detail-content li {
margin-bottom: 0.5rem;
}
.article-detail-content img {
max-width: 100%;
height: auto;
margin: 2rem 0;
border-radius: 8px;
}
@media (max-width: 900px) {
.data-layout {
flex-direction: column;
}
.data-sidebar {
width: 100%;
}
.pagination {
justify-content: center;
}
.article-detail {
padding: 1.5rem;
}
.article-detail-title {
font-size: 2rem;
}
.strategy-layout {
flex-direction: column;
}
.strategy-sidebar {
width: 100%;
}
}
/* Strategy page layout */
.strategy-layout {
display: flex;
gap: 2rem;
margin-top: 2rem;
}
.strategy-sidebar {
width: 250px;
flex-shrink: 0;
}
.strategy-content {
flex: 1;
min-width: 0;
}
.sidebar-title {
font-size: 1.1rem;
font-weight: 600;
color: #333;
margin-bottom: 0.75rem;
padding-bottom: 0.5rem;
border-bottom: 2px solid #ddd;
}
.strategy-list {
list-style: none;
padding: 0;
margin: 0;
}
.strategy-item {
padding: 0.75rem 1rem;
margin-bottom: 0.5rem;
background-color: white;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s ease;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.strategy-item:hover {
background-color: #f0f0f0;
transform: translateX(4px);
}
.strategy-item.active {
background-color: #333;
color: white;
}
.strategy-detail {
background-color: white;
padding: 2rem;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.strategy-title {
font-size: 1.8rem;
color: #333;
margin-bottom: 0.5rem;
}
.strategy-description {
color: #666;
margin-bottom: 2rem;
}
.chart-section {
margin-bottom: 3rem;
}
.chart-title {
font-size: 1.3rem;
color: #333;
margin-bottom: 1rem;
}
/* ================== 移动端优先布局 (mobile-first) ================== */
:root {
--color-bg: #f5f5f5;
--color-card: #ffffff;
--color-text: #333333;
--color-muted: #666666;
--color-border: #e5e7eb;
--color-primary: #1f2937;
--color-accent: #2563eb;
--color-danger: #dc2626;
--bottom-nav-h: 56px;
--top-tabs-h: 44px;
--safe-bottom: env(safe-area-inset-bottom, 0px);
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body, #root {
height: 100%;
background: var(--color-bg);
color: var(--color-text);
-webkit-font-smoothing: antialiased;
-webkit-tap-highlight-color: transparent;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC',
'Hiragino Sans GB', 'Microsoft YaHei', Roboto, sans-serif;
font-size: 14px;
overscroll-behavior-y: contain;
}
a { color: inherit; text-decoration: none; cursor: pointer; }
button { font: inherit; cursor: pointer; border: none; background: none; color: inherit; }
input, textarea, select { font: inherit; }
/* 应用主框架 */
.app-root {
display: flex;
flex-direction: column;
min-height: 100vh;
min-height: 100dvh;
max-width: 768px;
margin: 0 auto;
background: var(--color-bg);
position: relative;
}
.app-main {
flex: 1 1 auto;
padding-bottom: calc(var(--bottom-nav-h) + var(--safe-bottom) + 8px);
overflow-y: auto;
}
.app-main.has-top-tabs {
padding-top: var(--top-tabs-h);
}
/* 顶栏 tabs (宏观页用) */
.top-tabs {
position: sticky;
top: 0;
z-index: 10;
height: var(--top-tabs-h);
display: flex;
background: #fff;
border-bottom: 1px solid var(--color-border);
align-items: stretch;
}
.top-tabs-item {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
color: var(--color-muted);
position: relative;
user-select: none;
}
.top-tabs-item.active {
color: var(--color-primary);
font-weight: 600;
}
.top-tabs-item.active::after {
content: '';
position: absolute;
left: 30%;
right: 30%;
bottom: 6px;
height: 2px;
background: var(--color-primary);
border-radius: 2px;
}
/* 滑屏容器 */
.swipe-container {
position: relative;
overflow: hidden;
touch-action: pan-y;
}
.swipe-track {
display: flex;
width: 300%;
transform: translateX(var(--swipe-offset, 0%));
transition: transform 0.25s ease;
will-change: transform;
}
.swipe-track.no-anim { transition: none; }
.swipe-pane {
width: 33.3333%;
flex-shrink: 0;
overflow-y: auto;
}
/* 底部 tab */
.bottom-nav {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 20;
height: calc(var(--bottom-nav-h) + var(--safe-bottom));
padding-bottom: var(--safe-bottom);
display: flex;
background: #fff;
border-top: 1px solid var(--color-border);
max-width: 768px;
margin: 0 auto;
}
.bottom-nav-item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2px;
font-size: 11px;
color: var(--color-muted);
user-select: none;
}
.bottom-nav-item .icon { font-size: 20px; line-height: 1; }
.bottom-nav-item.active { color: var(--color-primary); font-weight: 600; }
/* 通用卡片 */
.card { background: #fff; border-radius: 10px; padding: 14px 16px; box-shadow: 0 1px 2px rgba(0,0,0,.04); }
.card + .card { margin-top: 10px; }
.page-title { font-size: 20px; font-weight: 700; padding: 14px 16px 4px; }
.page-desc { font-size: 13px; color: var(--color-muted); padding: 0 16px 12px; }
.section-title { font-size: 16px; font-weight: 600; padding: 14px 16px 8px; }
/* 列表 */
.list { padding: 0 12px; }
.list-item { background: #fff; padding: 14px; border-radius: 10px; margin-bottom: 10px; box-shadow: 0 1px 2px rgba(0,0,0,.04); display: block; }
.list-item-title { font-size: 15px; font-weight: 600; margin-bottom: 4px; line-height: 1.4; }
.list-item-meta { font-size: 12px; color: var(--color-muted); margin-bottom: 6px; }
.list-item-excerpt { font-size: 13px; color: var(--color-muted); line-height: 1.5; }
/* 图表 */
.chart-wrap { background: #fafafa; border-radius: 8px; padding: 8px; margin: 8px 0 16px; }
/* 表格 */
.data-table { width: 100%; border-collapse: collapse; font-size: 13px; }
.data-table th, .data-table td { padding: 8px 10px; text-align: left; border-bottom: 1px solid var(--color-border); }
.data-table th { background: #f8f8f8; font-weight: 600; }
/* 登录 */
.login-page { min-height: 100vh; min-height: 100dvh; display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 24px; background: linear-gradient(180deg, #1f2937 0%, #1f2937 35%, #f5f5f5 35%); }
.login-card { background: #fff; width: 100%; max-width: 380px; border-radius: 14px; padding: 24px; box-shadow: 0 4px 24px rgba(0,0,0,.1); }
.login-title { font-size: 20px; font-weight: 700; text-align: center; margin-bottom: 6px; }
.login-subtitle { text-align: center; color: var(--color-muted); margin-bottom: 20px; font-size: 13px; }
.login-field { display: flex; flex-direction: column; gap: 6px; margin-bottom: 14px; }
.login-field label { font-size: 12px; color: var(--color-muted); }
.login-field input { padding: 12px; border: 1px solid var(--color-border); border-radius: 8px; outline: none; }
.login-field input:focus { border-color: var(--color-primary); }
.login-options { display: flex; align-items: center; gap: 6px; margin-bottom: 18px; font-size: 13px; color: var(--color-muted); }
.login-btn { width: 100%; padding: 13px; background: var(--color-primary); color: #fff; border-radius: 8px; font-weight: 600; }
.login-btn:disabled { opacity: 0.6; }
.login-hint { text-align: center; font-size: 12px; color: var(--color-muted); margin-top: 14px; line-height: 1.6; }
.login-error { background: #fef2f2; color: var(--color-danger); padding: 10px; border-radius: 6px; font-size: 13px; margin-bottom: 12px; }
/* 我 */
.me-header { background: var(--color-primary); color: #fff; padding: 30px 16px 24px; }
.me-name { font-size: 18px; font-weight: 600; }
.me-email { font-size: 13px; opacity: .8; margin-top: 2px; }
.me-list { padding: 12px; }
.me-list .list-item { display: flex; align-items: center; justify-content: space-between; }
.me-list .list-item .label { font-size: 14px; }
.me-list .list-item .value { font-size: 13px; color: var(--color-muted); }
.me-list .danger { color: var(--color-danger); }
.loading, .page-loading { padding: 40px; text-align: center; color: var(--color-muted); }
.empty { padding: 40px; text-align: center; color: var(--color-muted); font-size: 13px; }
.error-banner { background: #fef2f2; color: var(--color-danger); padding: 10px 14px; font-size: 13px; border-radius: 6px; margin: 12px; }
/* 占位页 */
.placeholder-page { display: flex; flex-direction: column; align-items: center; justify-content: center; min-height: 60vh; padding: 24px; text-align: center; color: var(--color-muted); gap: 12px; }
.placeholder-page .icon { font-size: 48px; opacity: .6; }
/* 文章详情 */
.article-detail { background: #fff; padding: 18px 16px 24px; }
.article-detail h1 { font-size: 20px; line-height: 1.35; margin-bottom: 8px; }
.article-detail .meta { font-size: 12px; color: var(--color-muted); margin-bottom: 16px; padding-bottom: 10px; border-bottom: 1px solid var(--color-border); }
.article-content { font-size: 15px; line-height: 1.75; color: #333; }
.article-content h1, .article-content h2, .article-content h3 { margin: 1.2em 0 0.6em; line-height: 1.3; }
.article-content p { margin-bottom: 0.9em; }
.article-content ul, .article-content ol { margin: 0 0 1em 1.4em; }
.article-content img { max-width: 100%; border-radius: 6px; margin: 0.6em 0; }
.article-back { display: inline-flex; align-items: center; gap: 4px; padding: 8px 0; color: var(--color-accent); font-size: 13px; }
/* 侧边栏式选择器 (移动端改为顶部 chip 行) */
.chip-row { display: flex; gap: 8px; overflow-x: auto; padding: 8px 12px; }
.chip-row::-webkit-scrollbar { display: none; }
.chip { padding: 7px 12px; background: #fff; border: 1px solid var(--color-border); border-radius: 999px; font-size: 13px; white-space: nowrap; }
.chip.active { background: var(--color-primary); color: #fff; border-color: var(--color-primary); }
-52
View File
@@ -1,52 +0,0 @@
import fs from 'fs'
import path from 'path'
import matter from 'gray-matter'
export function getArticles() {
const articlesDirectory = path.join(process.cwd(), 'content', 'articles')
const fileNames = fs.readdirSync(articlesDirectory)
const articles = fileNames
.filter(fileName => fileName.endsWith('.md'))
.map(fileName => {
const fullPath = path.join(articlesDirectory, fileName)
const fileContents = fs.readFileSync(fullPath, 'utf8')
const { data, content } = matter(fileContents)
// 从文件名中提取日期(假设文件名格式为 YYYY-MM-DD-article-title.md)
const dateMatch = fileName.match(/^(\d{4}-\d{2}-\d{2})-/)
const date = dateMatch ? dateMatch[1] : ''
// 提取简介(前150个字符)
const excerpt = content.substring(0, 150) + '...'
return {
id: fileName.replace('.md', ''),
date,
title: data.title || fileName.replace(/\d{4}-\d{2}-\d{2}-/, '').replace('.md', ''),
excerpt,
content
}
})
// 按日期排序,最近的优先
return articles.sort((a, b) => {
if (a.date && b.date) {
return new Date(b.date) - new Date(a.date)
}
return 0
})
}
export function getPagedArticles(page = 1, pageSize = 5) {
const allArticles = getArticles()
const startIndex = (page - 1) * pageSize
const endIndex = startIndex + pageSize
return {
articles: allArticles.slice(startIndex, endIndex),
total: allArticles.length,
totalPages: Math.ceil(allArticles.length / pageSize),
currentPage: page
}
}
+22
View File
@@ -0,0 +1,22 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// 移动端优先: 默认按移动视口开发, 同时兼容桌面浏览器
export default defineConfig({
plugins: [react()],
server: {
host: '0.0.0.0',
port: 5173,
proxy: {
'/api': {
target: 'http://127.0.0.1:3001',
changeOrigin: true,
},
},
},
build: {
outDir: 'dist',
emptyOutDir: true,
sourcemap: true,
},
})