Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
79341bb8cc | ||
|
|
0f74602036 | ||
|
|
08b3655a38 |
@@ -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 }}
|
||||
@@ -1,32 +1,32 @@
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# production
|
||||
/build
|
||||
/out
|
||||
/.next
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
# Editor directories and files
|
||||
.idea
|
||||
.vscode
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# production
|
||||
/build
|
||||
/out
|
||||
/.next
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
# Editor directories and files
|
||||
.idea
|
||||
.vscode
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
@@ -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"]
|
||||
@@ -1,11 +0,0 @@
|
||||
const Footer = () => {
|
||||
return (
|
||||
<footer className="footer">
|
||||
<div className="footer-content">
|
||||
<p>© 2026 iBoard. 收集和整理经济数据,以策略为主体重新组织。</p>
|
||||
</div>
|
||||
</footer>
|
||||
)
|
||||
}
|
||||
|
||||
export default Footer
|
||||
@@ -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
|
||||
@@ -1,17 +1,17 @@
|
||||
---
|
||||
title: GDP增长分析与展望
|
||||
---
|
||||
|
||||
GDP是衡量一个国家经济规模和增长速度的重要指标。本文将分析当前全球主要经济体的GDP增长情况,探讨影响GDP增长的因素,并对未来经济走势进行展望。
|
||||
|
||||
首先,我们来看一下全球GDP增长的整体情况。根据国际货币基金组织(IMF)的最新数据,全球GDP增长率预计将在2026年达到3.5%左右,较2025年有所提升。
|
||||
|
||||
美国作为全球最大的经济体,其GDP增长率预计将保持在2.5%左右。消费、投资和出口是美国经济增长的主要驱动力。美联储的货币政策、政府的财政政策以及地缘政治风险等因素将影响美国经济的表现。
|
||||
|
||||
中国的GDP增长率预计将在5.0%左右。中国经济正在从投资驱动型向消费驱动型转变,科技创新、绿色发展等新动能正在成为经济增长的重要支撑。
|
||||
|
||||
欧元区的GDP增长率预计将在1.8%左右。欧元区经济面临着人口老龄化、结构性改革缓慢等挑战,但也受益于欧洲央行的宽松货币政策和复苏基金的支持。
|
||||
|
||||
影响GDP增长的因素是多方面的,包括货币政策、财政政策、技术进步、人口结构、地缘政治等。在当前全球经济不确定性增加的背景下,各国需要采取适当的政策措施来促进经济增长。
|
||||
|
||||
---
|
||||
title: GDP增长分析与展望
|
||||
---
|
||||
|
||||
GDP是衡量一个国家经济规模和增长速度的重要指标。本文将分析当前全球主要经济体的GDP增长情况,探讨影响GDP增长的因素,并对未来经济走势进行展望。
|
||||
|
||||
首先,我们来看一下全球GDP增长的整体情况。根据国际货币基金组织(IMF)的最新数据,全球GDP增长率预计将在2026年达到3.5%左右,较2025年有所提升。
|
||||
|
||||
美国作为全球最大的经济体,其GDP增长率预计将保持在2.5%左右。消费、投资和出口是美国经济增长的主要驱动力。美联储的货币政策、政府的财政政策以及地缘政治风险等因素将影响美国经济的表现。
|
||||
|
||||
中国的GDP增长率预计将在5.0%左右。中国经济正在从投资驱动型向消费驱动型转变,科技创新、绿色发展等新动能正在成为经济增长的重要支撑。
|
||||
|
||||
欧元区的GDP增长率预计将在1.8%左右。欧元区经济面临着人口老龄化、结构性改革缓慢等挑战,但也受益于欧洲央行的宽松货币政策和复苏基金的支持。
|
||||
|
||||
影响GDP增长的因素是多方面的,包括货币政策、财政政策、技术进步、人口结构、地缘政治等。在当前全球经济不确定性增加的背景下,各国需要采取适当的政策措施来促进经济增长。
|
||||
|
||||
展望未来,全球经济增长有望保持稳定,但仍面临着诸多挑战。科技创新、数字经济、绿色转型等将成为推动经济增长的重要力量。同时,各国需要加强国际合作,共同应对全球性挑战,如气候变化、贸易保护主义等。
|
||||
@@ -1,19 +1,19 @@
|
||||
---
|
||||
title: 消费趋势的变化与商业机会
|
||||
---
|
||||
|
||||
消费趋势的变化正在重塑商业格局。本文将分析当前消费市场的主要趋势,探讨这些变化对企业的影响,并为企业提供相应的战略建议。
|
||||
|
||||
首先,我们来看一下消费市场的整体情况。随着经济的发展和居民收入水平的提高,消费结构正在从生存型消费向发展型和享受型消费转变。
|
||||
|
||||
数字化消费已经成为主流。电子商务、移动支付、在线娱乐等数字消费方式正在改变人们的消费习惯。消费者越来越倾向于通过互联网和移动设备进行购物、娱乐和社交。
|
||||
|
||||
健康消费成为新的增长点。消费者对健康食品、健身服务、医疗保健等健康相关产品和服务的需求不断增加。这一趋势在疫情后更加明显,人们对健康的重视程度提高。
|
||||
|
||||
可持续消费理念逐渐深入人心。越来越多的消费者开始关注产品的环保性能,愿意为可持续产品支付溢价。企业的ESG表现也成为消费者选择品牌的重要因素。
|
||||
|
||||
个性化消费需求日益凸显。消费者希望获得个性化的产品和服务,而不是千篇一律的标准化产品。企业需要通过大数据、人工智能等技术来满足消费者的个性化需求。
|
||||
|
||||
这些消费趋势的变化对企业提出了新的挑战和机遇。企业需要调整产品和服务策略,以适应消费者需求的变化。同时,企业也需要利用数字技术来提升运营效率,改善客户体验。
|
||||
|
||||
---
|
||||
title: 消费趋势的变化与商业机会
|
||||
---
|
||||
|
||||
消费趋势的变化正在重塑商业格局。本文将分析当前消费市场的主要趋势,探讨这些变化对企业的影响,并为企业提供相应的战略建议。
|
||||
|
||||
首先,我们来看一下消费市场的整体情况。随着经济的发展和居民收入水平的提高,消费结构正在从生存型消费向发展型和享受型消费转变。
|
||||
|
||||
数字化消费已经成为主流。电子商务、移动支付、在线娱乐等数字消费方式正在改变人们的消费习惯。消费者越来越倾向于通过互联网和移动设备进行购物、娱乐和社交。
|
||||
|
||||
健康消费成为新的增长点。消费者对健康食品、健身服务、医疗保健等健康相关产品和服务的需求不断增加。这一趋势在疫情后更加明显,人们对健康的重视程度提高。
|
||||
|
||||
可持续消费理念逐渐深入人心。越来越多的消费者开始关注产品的环保性能,愿意为可持续产品支付溢价。企业的ESG表现也成为消费者选择品牌的重要因素。
|
||||
|
||||
个性化消费需求日益凸显。消费者希望获得个性化的产品和服务,而不是千篇一律的标准化产品。企业需要通过大数据、人工智能等技术来满足消费者的个性化需求。
|
||||
|
||||
这些消费趋势的变化对企业提出了新的挑战和机遇。企业需要调整产品和服务策略,以适应消费者需求的变化。同时,企业也需要利用数字技术来提升运营效率,改善客户体验。
|
||||
|
||||
对于投资者而言,消费趋势的变化也意味着投资机会的转移。需要关注那些能够适应消费趋势变化、具有创新能力的企业。
|
||||
@@ -1,19 +1,19 @@
|
||||
---
|
||||
title: 科技创新与经济增长
|
||||
---
|
||||
|
||||
科技创新是推动经济增长的重要动力。本文将分析当前科技创新的趋势,探讨其对经济增长的影响,并为政策制定者和投资者提供参考。
|
||||
|
||||
首先,我们来看一下当前科技创新的主要领域。人工智能、区块链、生物技术、可再生能源等领域的技术突破正在改变我们的生活和工作方式。
|
||||
|
||||
人工智能技术的快速发展正在各个行业产生深远影响。从自动驾驶到医疗诊断,从金融服务到制造业,AI正在提高效率,创造新的商业模式。
|
||||
|
||||
区块链技术的应用也在不断扩展。除了加密货币,区块链在供应链管理、数字身份、金融服务等领域的应用正在逐步落地。
|
||||
|
||||
生物技术的进步为医疗健康、农业等领域带来了新的机遇。基因编辑、精准医疗、合成生物学等技术的发展有望解决许多传统医学难以解决的问题。
|
||||
|
||||
可再生能源技术的突破正在加速能源转型。太阳能、风能、储能技术的成本不断下降,为实现碳中和目标提供了技术支持。
|
||||
|
||||
这些科技创新对经济增长的影响是多方面的。它们不仅提高了生产效率,还创造了新的产业和就业机会。同时,科技创新也带来了一些挑战,如就业结构的变化、隐私保护等问题。
|
||||
|
||||
---
|
||||
title: 科技创新与经济增长
|
||||
---
|
||||
|
||||
科技创新是推动经济增长的重要动力。本文将分析当前科技创新的趋势,探讨其对经济增长的影响,并为政策制定者和投资者提供参考。
|
||||
|
||||
首先,我们来看一下当前科技创新的主要领域。人工智能、区块链、生物技术、可再生能源等领域的技术突破正在改变我们的生活和工作方式。
|
||||
|
||||
人工智能技术的快速发展正在各个行业产生深远影响。从自动驾驶到医疗诊断,从金融服务到制造业,AI正在提高效率,创造新的商业模式。
|
||||
|
||||
区块链技术的应用也在不断扩展。除了加密货币,区块链在供应链管理、数字身份、金融服务等领域的应用正在逐步落地。
|
||||
|
||||
生物技术的进步为医疗健康、农业等领域带来了新的机遇。基因编辑、精准医疗、合成生物学等技术的发展有望解决许多传统医学难以解决的问题。
|
||||
|
||||
可再生能源技术的突破正在加速能源转型。太阳能、风能、储能技术的成本不断下降,为实现碳中和目标提供了技术支持。
|
||||
|
||||
这些科技创新对经济增长的影响是多方面的。它们不仅提高了生产效率,还创造了新的产业和就业机会。同时,科技创新也带来了一些挑战,如就业结构的变化、隐私保护等问题。
|
||||
|
||||
对于政策制定者而言,需要制定有利于科技创新的政策环境,包括加大研发投入、培养创新人才、完善知识产权保护等。对于投资者而言,需要关注科技创新的趋势,寻找具有增长潜力的投资机会。
|
||||
@@ -1,15 +1,15 @@
|
||||
---
|
||||
title: 全球贸易格局的变化与影响
|
||||
---
|
||||
|
||||
全球贸易格局正在经历深刻的变化。本文将分析当前全球贸易的现状,探讨主要经济体的贸易政策,以及这些变化对全球经济的影响。
|
||||
|
||||
首先,我们来看一下全球贸易的整体情况。尽管贸易保护主义有所抬头,但全球贸易总额仍然保持增长。新兴经济体在全球贸易中的比重不断提高,正在改变传统的贸易格局。
|
||||
|
||||
美国、欧盟、中国等主要经济体的贸易政策对全球贸易产生重要影响。美国的贸易保护主义措施、欧盟的绿色贸易政策、中国的扩大开放政策等,都在塑造着新的贸易规则。
|
||||
|
||||
全球供应链的重构也是当前贸易格局变化的重要特征。出于安全和效率的考虑,许多企业正在调整供应链布局,推动区域化和多元化。
|
||||
|
||||
这些变化对全球经济产生了深远的影响。一方面,贸易格局的调整可能会导致短期的经济波动;另一方面,新的贸易规则和供应链布局也为经济发展带来了新的机遇。
|
||||
|
||||
---
|
||||
title: 全球贸易格局的变化与影响
|
||||
---
|
||||
|
||||
全球贸易格局正在经历深刻的变化。本文将分析当前全球贸易的现状,探讨主要经济体的贸易政策,以及这些变化对全球经济的影响。
|
||||
|
||||
首先,我们来看一下全球贸易的整体情况。尽管贸易保护主义有所抬头,但全球贸易总额仍然保持增长。新兴经济体在全球贸易中的比重不断提高,正在改变传统的贸易格局。
|
||||
|
||||
美国、欧盟、中国等主要经济体的贸易政策对全球贸易产生重要影响。美国的贸易保护主义措施、欧盟的绿色贸易政策、中国的扩大开放政策等,都在塑造着新的贸易规则。
|
||||
|
||||
全球供应链的重构也是当前贸易格局变化的重要特征。出于安全和效率的考虑,许多企业正在调整供应链布局,推动区域化和多元化。
|
||||
|
||||
这些变化对全球经济产生了深远的影响。一方面,贸易格局的调整可能会导致短期的经济波动;另一方面,新的贸易规则和供应链布局也为经济发展带来了新的机遇。
|
||||
|
||||
对于企业和投资者而言,需要密切关注贸易格局的变化,调整战略以适应新的环境。同时,政府也需要制定相应的政策,促进贸易的健康发展。
|
||||
@@ -1,13 +1,13 @@
|
||||
---
|
||||
title: 利率走势分析与投资策略
|
||||
---
|
||||
|
||||
近期全球主要经济体的利率走势引起了市场的广泛关注。本文将分析当前利率环境,探讨未来可能的变化趋势,并为投资者提供相应的投资策略建议。
|
||||
|
||||
首先,我们来看一下美联储的货币政策走向。随着通胀压力的缓解,美联储已经开始考虑降息,但步伐可能会比较谨慎。欧洲央行和日本央行也在调整各自的货币政策,以适应经济形势的变化。
|
||||
|
||||
在中国,央行通过调整政策利率和LPR(贷款市场报价利率)来引导市场利率水平。近期LPR的下调反映了货币政策的宽松取向,这将有利于降低企业融资成本,刺激经济增长。
|
||||
|
||||
对于债券投资者而言,利率下行意味着债券价格上涨,这为债券投资提供了机会。但需要注意的是,不同期限的债券对利率变化的敏感度不同,投资者需要根据自己的风险承受能力选择合适的债券品种。
|
||||
|
||||
---
|
||||
title: 利率走势分析与投资策略
|
||||
---
|
||||
|
||||
近期全球主要经济体的利率走势引起了市场的广泛关注。本文将分析当前利率环境,探讨未来可能的变化趋势,并为投资者提供相应的投资策略建议。
|
||||
|
||||
首先,我们来看一下美联储的货币政策走向。随着通胀压力的缓解,美联储已经开始考虑降息,但步伐可能会比较谨慎。欧洲央行和日本央行也在调整各自的货币政策,以适应经济形势的变化。
|
||||
|
||||
在中国,央行通过调整政策利率和LPR(贷款市场报价利率)来引导市场利率水平。近期LPR的下调反映了货币政策的宽松取向,这将有利于降低企业融资成本,刺激经济增长。
|
||||
|
||||
对于债券投资者而言,利率下行意味着债券价格上涨,这为债券投资提供了机会。但需要注意的是,不同期限的债券对利率变化的敏感度不同,投资者需要根据自己的风险承受能力选择合适的债券品种。
|
||||
|
||||
对于股票投资者而言,利率下行通常有利于估值提升,特别是对成长型股票更为有利。但同时也需要关注经济基本面的变化,选择具有良好成长性和估值合理的股票。
|
||||
@@ -1,13 +1,13 @@
|
||||
---
|
||||
title: 2026年中国经济展望
|
||||
---
|
||||
|
||||
2026年中国经济将面临诸多挑战与机遇。随着全球经济格局的变化,中国经济正在从高速增长向高质量发展转型。本文将分析当前经济形势,预测未来趋势,并提出相应的政策建议。
|
||||
|
||||
首先,我们来看一下当前的宏观经济指标。GDP增长率、通胀水平、失业率等关键指标都显示中国经济正在稳步复苏。然而,外部环境的不确定性仍然存在,需要密切关注。
|
||||
|
||||
在产业结构方面,数字经济、绿色经济等新兴产业正在成为经济增长的新动力。传统产业的转型升级也在加速,这将为经济可持续发展注入新的活力。
|
||||
|
||||
对于投资者而言,2026年的投资策略需要更加注重长期价值,关注科技创新、消费升级等领域的投资机会。同时,风险管理也变得尤为重要,需要建立多元化的投资组合。
|
||||
|
||||
---
|
||||
title: 2026年中国经济展望
|
||||
---
|
||||
|
||||
2026年中国经济将面临诸多挑战与机遇。随着全球经济格局的变化,中国经济正在从高速增长向高质量发展转型。本文将分析当前经济形势,预测未来趋势,并提出相应的政策建议。
|
||||
|
||||
首先,我们来看一下当前的宏观经济指标。GDP增长率、通胀水平、失业率等关键指标都显示中国经济正在稳步复苏。然而,外部环境的不确定性仍然存在,需要密切关注。
|
||||
|
||||
在产业结构方面,数字经济、绿色经济等新兴产业正在成为经济增长的新动力。传统产业的转型升级也在加速,这将为经济可持续发展注入新的活力。
|
||||
|
||||
对于投资者而言,2026年的投资策略需要更加注重长期价值,关注科技创新、消费升级等领域的投资机会。同时,风险管理也变得尤为重要,需要建立多元化的投资组合。
|
||||
|
||||
总的来说,2026年中国经济有望保持稳健增长,但需要应对来自内外部的各种挑战。通过深化改革、扩大开放,中国经济将迎来新的发展机遇。
|
||||
@@ -1,117 +1,117 @@
|
||||
# 城投债化债:机制、影响与市场展望
|
||||
|
||||
## 引言
|
||||
|
||||
城投债作为地方政府融资的重要工具,在过去几十年为我国基础设施建设和经济发展做出了重要贡献。然而,随着债务规模的不断扩大,城投债风险逐渐引起市场关注。近期,市场对城投债化债的讨论升温,本文将深入分析城投债化债的核心机制、市场影响以及未来走势。
|
||||
|
||||
## 化债的核心逻辑:高利率换低利率
|
||||
|
||||
### 化债不是还本金
|
||||
|
||||
市场对城投债化债存在一个常见误解,认为化债就是偿还本金。事实上,化债的核心逻辑并非偿还本金,而是通过债务重组和利率优化,降低债务成本和风险。
|
||||
|
||||
城投债的本质是地方政府的隐性债务,其规模庞大,短期内完全偿还本金并不现实。化债的目标是通过调整债务结构,将高成本债务置换为低成本债务,从而减轻地方政府的利息负担,为经济发展创造更有利的金融环境。
|
||||
|
||||
### 高利率向低利率的转化
|
||||
|
||||
过去几年,城投债发行利率普遍较高,部分地区甚至超过6%。高利率不仅增加了地方政府的偿债压力,也限制了地方财政的可持续性。化债的关键在于将这些高利率债务转换为低利率债务。
|
||||
|
||||
具体而言,化债通过以下方式实现利率优化:
|
||||
- **债务置换**:将高利率城投债置换为低利率的政府债券或银行贷款
|
||||
- **展期续作**:通过债务展期,延长还款期限,同时降低票面利率
|
||||
- **重组整合**:对城投平台进行整合,提高信用评级,从而降低融资成本
|
||||
|
||||
## 银行在化债中的角色:存款利率下调的逻辑
|
||||
|
||||
### 银行降低存款利率的必要性
|
||||
|
||||
为了支持城投债化债,银行体系需要提供低成本资金。降低存款利率成为银行吸储并提供低利率资金的重要手段。
|
||||
|
||||
银行降低存款利率主要基于以下考虑:
|
||||
1. **降低负债成本**:通过下调存款利率,银行可以降低资金成本,为后续的低利率贷款创造空间
|
||||
2. **引导资金流向**:存款利率下调后,部分资金可能流向债券市场,增加债券需求,从而降低债券发行利率
|
||||
3. **支持实体经济**:低利率环境有助于降低企业融资成本,支持经济复苏
|
||||
|
||||
### 低利率置换城投债的机制
|
||||
|
||||
银行通过以下步骤实现低利率置换城投债:
|
||||
|
||||
1. **吸收低成本存款**:通过降低存款利率,吸引储户存款,获取低成本资金
|
||||
2. **提供低利率贷款**:向城投平台提供低利率贷款,用于置换高利率债务
|
||||
3. **参与债务重组**:直接购买或承销城投债,为城投平台提供资金支持
|
||||
|
||||
这种机制不仅帮助城投平台降低了融资成本,也为银行提供了相对安全的资产配置渠道,实现了双赢。
|
||||
|
||||
## 市场影响:中期低利率环境的形成
|
||||
|
||||
### 化债目标下的利率政策
|
||||
|
||||
为了实现城投债化债的目标,货币政策需要维持宽松,市场中期将保持低利率或利率下行趋势。
|
||||
|
||||
从政策层面看,央行通过以下措施引导利率下行:
|
||||
- **降准降息**:通过降低存款准备金率和政策利率,向市场释放流动性
|
||||
- **结构性货币政策**:通过再贷款、再贴现等工具,定向支持城投平台等重点领域
|
||||
- **预期管理**:通过政策沟通,引导市场形成利率下行的预期
|
||||
|
||||
### 对不同市场主体的影响
|
||||
|
||||
#### 对城投平台的影响
|
||||
- **融资成本下降**:直接减轻利息负担,改善财务状况
|
||||
- **再融资环境改善**:低利率环境有助于城投债的顺利发行
|
||||
- **债务可持续性增强**:降低了债务违约风险,提高了信用评级
|
||||
|
||||
#### 对银行的影响
|
||||
- **利差收窄**:存款利率下调的同时,贷款利率也会下降,可能压缩银行利差
|
||||
- **资产质量改善**:城投债风险降低,银行资产质量得到提升
|
||||
- **业务结构调整**:需要寻找新的利润增长点,如中间业务收入
|
||||
|
||||
#### 对投资者的影响
|
||||
- **固定收益产品收益下降**:存款、债券等固定收益产品的收益率可能持续走低
|
||||
- **风险偏好变化**:部分投资者可能转向权益市场寻求更高收益
|
||||
- **资产配置调整**:需要重新平衡资产配置,寻找风险与收益的平衡点
|
||||
|
||||
## 市场展望与投资策略
|
||||
|
||||
### 短期展望(1-2年)
|
||||
|
||||
短期内,为了确保城投债化债的顺利推进,货币政策将保持宽松,市场利率有望继续下行。城投债违约风险将显著降低,信用利差可能收窄。
|
||||
|
||||
### 中期展望(2-5年)
|
||||
|
||||
中期来看,随着化债工作的推进,城投债风险将进一步降低,但市场利率可能维持在较低水平。银行体系的资产负债结构将发生调整,金融市场的风险偏好可能逐步回升。
|
||||
|
||||
### 长期展望(5年以上)
|
||||
|
||||
长期而言,城投债化债完成后,地方政府的债务结构将更加合理,财政可持续性将得到提升。市场利率可能回归正常水平,但仍将保持相对稳定。
|
||||
|
||||
### 投资策略建议
|
||||
|
||||
针对当前的市场环境,投资者可以考虑以下策略:
|
||||
|
||||
1. **固定收益投资**:
|
||||
- 关注高评级城投债,受益于信用利差收窄
|
||||
- 适当配置利率债,受益于利率下行
|
||||
- 谨慎对待低评级城投债,仍需关注个体风险
|
||||
|
||||
2. **权益投资**:
|
||||
- 关注受益于低利率环境的行业,如房地产、公用事业等
|
||||
- 关注地方政府投资重点领域,如基础设施、新能源等
|
||||
- 关注银行板块,受益于资产质量改善
|
||||
|
||||
3. **资产配置**:
|
||||
- 保持多元化配置,分散风险
|
||||
- 根据自身风险承受能力,调整股债比例
|
||||
- 密切关注政策变化,及时调整投资策略
|
||||
|
||||
## 结论
|
||||
|
||||
城投债化债是一个长期而复杂的过程,其核心逻辑是通过高利率换低利率来降低债务成本和风险。银行体系通过降低存款利率,为化债提供低成本资金支持。为了实现化债目标,市场中期将维持低利率或利率下行趋势。
|
||||
|
||||
对于投资者而言,需要认识到化债过程中的投资机会和风险,制定相应的投资策略。同时,也要看到化债对经济长期发展的积极意义,为市场的稳定和繁荣创造有利条件。
|
||||
|
||||
## 参考文献
|
||||
|
||||
1. 《中国城投债市场发展报告》
|
||||
2. 《地方政府债务风险防控研究》
|
||||
3. 央行货币政策执行报告
|
||||
4. 财政部关于地方政府债务管理的相关文件
|
||||
# 城投债化债:机制、影响与市场展望
|
||||
|
||||
## 引言
|
||||
|
||||
城投债作为地方政府融资的重要工具,在过去几十年为我国基础设施建设和经济发展做出了重要贡献。然而,随着债务规模的不断扩大,城投债风险逐渐引起市场关注。近期,市场对城投债化债的讨论升温,本文将深入分析城投债化债的核心机制、市场影响以及未来走势。
|
||||
|
||||
## 化债的核心逻辑:高利率换低利率
|
||||
|
||||
### 化债不是还本金
|
||||
|
||||
市场对城投债化债存在一个常见误解,认为化债就是偿还本金。事实上,化债的核心逻辑并非偿还本金,而是通过债务重组和利率优化,降低债务成本和风险。
|
||||
|
||||
城投债的本质是地方政府的隐性债务,其规模庞大,短期内完全偿还本金并不现实。化债的目标是通过调整债务结构,将高成本债务置换为低成本债务,从而减轻地方政府的利息负担,为经济发展创造更有利的金融环境。
|
||||
|
||||
### 高利率向低利率的转化
|
||||
|
||||
过去几年,城投债发行利率普遍较高,部分地区甚至超过6%。高利率不仅增加了地方政府的偿债压力,也限制了地方财政的可持续性。化债的关键在于将这些高利率债务转换为低利率债务。
|
||||
|
||||
具体而言,化债通过以下方式实现利率优化:
|
||||
- **债务置换**:将高利率城投债置换为低利率的政府债券或银行贷款
|
||||
- **展期续作**:通过债务展期,延长还款期限,同时降低票面利率
|
||||
- **重组整合**:对城投平台进行整合,提高信用评级,从而降低融资成本
|
||||
|
||||
## 银行在化债中的角色:存款利率下调的逻辑
|
||||
|
||||
### 银行降低存款利率的必要性
|
||||
|
||||
为了支持城投债化债,银行体系需要提供低成本资金。降低存款利率成为银行吸储并提供低利率资金的重要手段。
|
||||
|
||||
银行降低存款利率主要基于以下考虑:
|
||||
1. **降低负债成本**:通过下调存款利率,银行可以降低资金成本,为后续的低利率贷款创造空间
|
||||
2. **引导资金流向**:存款利率下调后,部分资金可能流向债券市场,增加债券需求,从而降低债券发行利率
|
||||
3. **支持实体经济**:低利率环境有助于降低企业融资成本,支持经济复苏
|
||||
|
||||
### 低利率置换城投债的机制
|
||||
|
||||
银行通过以下步骤实现低利率置换城投债:
|
||||
|
||||
1. **吸收低成本存款**:通过降低存款利率,吸引储户存款,获取低成本资金
|
||||
2. **提供低利率贷款**:向城投平台提供低利率贷款,用于置换高利率债务
|
||||
3. **参与债务重组**:直接购买或承销城投债,为城投平台提供资金支持
|
||||
|
||||
这种机制不仅帮助城投平台降低了融资成本,也为银行提供了相对安全的资产配置渠道,实现了双赢。
|
||||
|
||||
## 市场影响:中期低利率环境的形成
|
||||
|
||||
### 化债目标下的利率政策
|
||||
|
||||
为了实现城投债化债的目标,货币政策需要维持宽松,市场中期将保持低利率或利率下行趋势。
|
||||
|
||||
从政策层面看,央行通过以下措施引导利率下行:
|
||||
- **降准降息**:通过降低存款准备金率和政策利率,向市场释放流动性
|
||||
- **结构性货币政策**:通过再贷款、再贴现等工具,定向支持城投平台等重点领域
|
||||
- **预期管理**:通过政策沟通,引导市场形成利率下行的预期
|
||||
|
||||
### 对不同市场主体的影响
|
||||
|
||||
#### 对城投平台的影响
|
||||
- **融资成本下降**:直接减轻利息负担,改善财务状况
|
||||
- **再融资环境改善**:低利率环境有助于城投债的顺利发行
|
||||
- **债务可持续性增强**:降低了债务违约风险,提高了信用评级
|
||||
|
||||
#### 对银行的影响
|
||||
- **利差收窄**:存款利率下调的同时,贷款利率也会下降,可能压缩银行利差
|
||||
- **资产质量改善**:城投债风险降低,银行资产质量得到提升
|
||||
- **业务结构调整**:需要寻找新的利润增长点,如中间业务收入
|
||||
|
||||
#### 对投资者的影响
|
||||
- **固定收益产品收益下降**:存款、债券等固定收益产品的收益率可能持续走低
|
||||
- **风险偏好变化**:部分投资者可能转向权益市场寻求更高收益
|
||||
- **资产配置调整**:需要重新平衡资产配置,寻找风险与收益的平衡点
|
||||
|
||||
## 市场展望与投资策略
|
||||
|
||||
### 短期展望(1-2年)
|
||||
|
||||
短期内,为了确保城投债化债的顺利推进,货币政策将保持宽松,市场利率有望继续下行。城投债违约风险将显著降低,信用利差可能收窄。
|
||||
|
||||
### 中期展望(2-5年)
|
||||
|
||||
中期来看,随着化债工作的推进,城投债风险将进一步降低,但市场利率可能维持在较低水平。银行体系的资产负债结构将发生调整,金融市场的风险偏好可能逐步回升。
|
||||
|
||||
### 长期展望(5年以上)
|
||||
|
||||
长期而言,城投债化债完成后,地方政府的债务结构将更加合理,财政可持续性将得到提升。市场利率可能回归正常水平,但仍将保持相对稳定。
|
||||
|
||||
### 投资策略建议
|
||||
|
||||
针对当前的市场环境,投资者可以考虑以下策略:
|
||||
|
||||
1. **固定收益投资**:
|
||||
- 关注高评级城投债,受益于信用利差收窄
|
||||
- 适当配置利率债,受益于利率下行
|
||||
- 谨慎对待低评级城投债,仍需关注个体风险
|
||||
|
||||
2. **权益投资**:
|
||||
- 关注受益于低利率环境的行业,如房地产、公用事业等
|
||||
- 关注地方政府投资重点领域,如基础设施、新能源等
|
||||
- 关注银行板块,受益于资产质量改善
|
||||
|
||||
3. **资产配置**:
|
||||
- 保持多元化配置,分散风险
|
||||
- 根据自身风险承受能力,调整股债比例
|
||||
- 密切关注政策变化,及时调整投资策略
|
||||
|
||||
## 结论
|
||||
|
||||
城投债化债是一个长期而复杂的过程,其核心逻辑是通过高利率换低利率来降低债务成本和风险。银行体系通过降低存款利率,为化债提供低成本资金支持。为了实现化债目标,市场中期将维持低利率或利率下行趋势。
|
||||
|
||||
对于投资者而言,需要认识到化债过程中的投资机会和风险,制定相应的投资策略。同时,也要看到化债对经济长期发展的积极意义,为市场的稳定和繁荣创造有利条件。
|
||||
|
||||
## 参考文献
|
||||
|
||||
1. 《中国城投债市场发展报告》
|
||||
2. 《地方政府债务风险防控研究》
|
||||
3. 央行货币政策执行报告
|
||||
4. 财政部关于地方政府债务管理的相关文件
|
||||
|
||||
@@ -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 |
@@ -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>
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
@@ -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
|
||||
@@ -3,31 +3,49 @@
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"tauri": "tauri",
|
||||
"dev:web": "vite",
|
||||
"dev:server": "node --experimental-strip-types --no-warnings server/index.js",
|
||||
"dev": "concurrently -n web,api -c blue,green \"npm run dev:web\" \"npm run dev:server\"",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"start": "node --experimental-strip-types --no-warnings server/index.js",
|
||||
"db:migrate": "prisma migrate deploy",
|
||||
"db:seed": "node scripts/seed.cjs",
|
||||
"icons": "node scripts/generate-icons.mjs",
|
||||
"tauri:dev": "tauri dev",
|
||||
"tauri:build": "tauri build",
|
||||
"tauri:android:init": "tauri android init",
|
||||
"tauri:android:dev": "tauri android dev",
|
||||
"tauri:android:build": "tauri android build",
|
||||
"tauri:android:apk": "tauri android build --apk",
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"dependencies": {
|
||||
"@prisma/adapter-mariadb": "^7.7.0",
|
||||
"@prisma/adapter-mariadb": "^7.9.1",
|
||||
"@prisma/client": "^7.7.0",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.4.5",
|
||||
"express": "^4.21.0",
|
||||
"gray-matter": "^4.0.3",
|
||||
"mariadb": "^3.5.2",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"marked": "^17.0.6",
|
||||
"next": "^14.2.3",
|
||||
"node-cron": "^4.2.1",
|
||||
"mariadb": "^3.5.3",
|
||||
"prisma": "^7.7.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-router-dom": "^6.26.0",
|
||||
"recharts": "^3.8.1"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"description": "",
|
||||
"devDependencies": {
|
||||
"@tauri-apps/api": "^2.0.0",
|
||||
"@tauri-apps/cli": "^2.0.0",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"axios": "^1.15.0",
|
||||
"cheerio": "^1.2.0",
|
||||
"csv-parser": "^3.2.0"
|
||||
"concurrently": "^9.0.0",
|
||||
"csv-parser": "^3.2.0",
|
||||
"sharp": "^0.33.0",
|
||||
"vite": "^5.4.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import '../styles/globals.css'
|
||||
|
||||
export default function App({ Component, pageProps }) {
|
||||
return <Component {...pageProps} />
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,14 @@
|
||||
// This file was generated by Prisma, and assumes you have installed the following:
|
||||
// npm install --save-dev prisma dotenv
|
||||
import "dotenv/config";
|
||||
import { defineConfig } from "prisma/config";
|
||||
|
||||
export default defineConfig({
|
||||
schema: "prisma/schema.prisma",
|
||||
migrations: {
|
||||
path: "prisma/migrations",
|
||||
},
|
||||
datasource: {
|
||||
url: process.env["DATABASE_URL"],
|
||||
},
|
||||
});
|
||||
// This file was generated by Prisma, and assumes you have installed the following:
|
||||
// npm install --save-dev prisma dotenv
|
||||
import "dotenv/config";
|
||||
import { defineConfig } from "prisma/config";
|
||||
|
||||
export default defineConfig({
|
||||
schema: "prisma/schema.prisma",
|
||||
migrations: {
|
||||
path: "prisma/migrations",
|
||||
},
|
||||
datasource: {
|
||||
url: process.env["DATABASE_URL"],
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,62 +1,62 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE `User` (
|
||||
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
||||
`name` VARCHAR(191) NOT NULL,
|
||||
`email` VARCHAR(191) NOT NULL,
|
||||
`password` VARCHAR(191) NOT NULL,
|
||||
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
`updatedAt` DATETIME(3) NOT NULL,
|
||||
|
||||
UNIQUE INDEX `User_email_key`(`email`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `Article` (
|
||||
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
||||
`title` VARCHAR(191) NOT NULL,
|
||||
`slug` VARCHAR(191) NOT NULL,
|
||||
`content` VARCHAR(191) NOT NULL,
|
||||
`date` VARCHAR(191) NOT NULL,
|
||||
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
`updatedAt` DATETIME(3) NOT NULL,
|
||||
|
||||
UNIQUE INDEX `Article_slug_key`(`slug`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `Strategy` (
|
||||
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
||||
`name` VARCHAR(191) NOT NULL,
|
||||
`description` VARCHAR(191) NOT NULL,
|
||||
`data` JSON NOT NULL,
|
||||
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
`updatedAt` DATETIME(3) NOT NULL,
|
||||
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `Indicator` (
|
||||
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
||||
`name` VARCHAR(191) NOT NULL,
|
||||
`groupName` VARCHAR(191) NOT NULL,
|
||||
`data` JSON NOT NULL,
|
||||
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
`updatedAt` DATETIME(3) NOT NULL,
|
||||
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `SowInventory` (
|
||||
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
||||
`month` VARCHAR(191) NOT NULL,
|
||||
`inventory` INTEGER NOT NULL,
|
||||
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
`updatedAt` DATETIME(3) NOT NULL,
|
||||
|
||||
UNIQUE INDEX `SowInventory_month_key`(`month`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
-- CreateTable
|
||||
CREATE TABLE `User` (
|
||||
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
||||
`name` VARCHAR(191) NOT NULL,
|
||||
`email` VARCHAR(191) NOT NULL,
|
||||
`password` VARCHAR(191) NOT NULL,
|
||||
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
`updatedAt` DATETIME(3) NOT NULL,
|
||||
|
||||
UNIQUE INDEX `User_email_key`(`email`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `Article` (
|
||||
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
||||
`title` VARCHAR(191) NOT NULL,
|
||||
`slug` VARCHAR(191) NOT NULL,
|
||||
`content` VARCHAR(191) NOT NULL,
|
||||
`date` VARCHAR(191) NOT NULL,
|
||||
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
`updatedAt` DATETIME(3) NOT NULL,
|
||||
|
||||
UNIQUE INDEX `Article_slug_key`(`slug`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `Strategy` (
|
||||
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
||||
`name` VARCHAR(191) NOT NULL,
|
||||
`description` VARCHAR(191) NOT NULL,
|
||||
`data` JSON NOT NULL,
|
||||
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
`updatedAt` DATETIME(3) NOT NULL,
|
||||
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `Indicator` (
|
||||
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
||||
`name` VARCHAR(191) NOT NULL,
|
||||
`groupName` VARCHAR(191) NOT NULL,
|
||||
`data` JSON NOT NULL,
|
||||
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
`updatedAt` DATETIME(3) NOT NULL,
|
||||
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `SowInventory` (
|
||||
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
||||
`month` VARCHAR(191) NOT NULL,
|
||||
`inventory` INTEGER NOT NULL,
|
||||
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
`updatedAt` DATETIME(3) NOT NULL,
|
||||
|
||||
UNIQUE INDEX `SowInventory_month_key`(`month`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- You are about to drop the `SowInventory` table. If the table is not empty, all the data it contains will be lost.
|
||||
|
||||
*/
|
||||
-- DropTable
|
||||
DROP TABLE `SowInventory`;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `Econ_SowInventory` (
|
||||
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
||||
`month` VARCHAR(191) NOT NULL,
|
||||
`inventory` INTEGER NOT NULL,
|
||||
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
`updatedAt` DATETIME(3) NOT NULL,
|
||||
|
||||
UNIQUE INDEX `Econ_SowInventory_month_key`(`month`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- You are about to drop the `SowInventory` table. If the table is not empty, all the data it contains will be lost.
|
||||
|
||||
*/
|
||||
-- DropTable
|
||||
DROP TABLE `SowInventory`;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `Econ_SowInventory` (
|
||||
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
||||
`month` VARCHAR(191) NOT NULL,
|
||||
`inventory` INTEGER NOT NULL,
|
||||
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
`updatedAt` DATETIME(3) NOT NULL,
|
||||
|
||||
UNIQUE INDEX `Econ_SowInventory_month_key`(`month`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
@@ -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,3 +1,3 @@
|
||||
# Please do not edit this file manually
|
||||
# It should be added in your version-control system (e.g., Git)
|
||||
provider = "mysql"
|
||||
# Please do not edit this file manually
|
||||
# It should be added in your version-control system (e.g., Git)
|
||||
provider = "mysql"
|
||||
|
||||
@@ -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 |
@@ -1,19 +1,6 @@
|
||||
# iBoard
|
||||
用于收集和整理经济数据,但是以策略为主体重新组织。
|
||||
- 首页主要是放经济文章
|
||||
- 数据主要是放各种经济数据和图表。
|
||||
- 策略是放按照经济理论组织的相关数据和图表的对比。
|
||||
|
||||
|
||||
## 爬虫
|
||||
- 基于 bun 的定时任务
|
||||
- 统一获取接口
|
||||
- 统一爬取接口
|
||||
```
|
||||
async function fetch(startDate = null, endDate = null)
|
||||
async function trigger(startDate = null, endDate = null)
|
||||
```
|
||||
不传递startDate和endDate,默认,默认获取最近30天的数据。
|
||||
|
||||
fecher 负责从目标网站获取原始数据
|
||||
scraper 负责检查数据库中的数据是否完整,调用 fecher 将数据存储到数据库。
|
||||
# iBoard
|
||||
用于收集和整理经济数据,但是以策略为主体重新组织。
|
||||
- 首页主要是放经济文章
|
||||
- 数据主要是放各种经济数据和图表。
|
||||
- 策略是放按照经济理论组织的相关数据和图表的对比。
|
||||
|
||||
|
||||
@@ -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)});
|
||||
@@ -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,62 +1,62 @@
|
||||
# 数据爬取脚本
|
||||
|
||||
本目录包含用于从各种数据源爬取经济数据的脚本。
|
||||
|
||||
## 目录结构
|
||||
|
||||
- `sow-inventory-scraper.js` - 爬取能繁母猪数量数据
|
||||
- `template-scraper.js` - 通用爬取脚本模板
|
||||
|
||||
## 依赖
|
||||
|
||||
- axios - 用于发送HTTP请求
|
||||
- cheerio - 用于解析HTML内容
|
||||
- csv-parser - 用于解析CSV格式数据
|
||||
|
||||
## 安装依赖
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
## 使用方法
|
||||
|
||||
1. **修改脚本配置**:
|
||||
- 打开相应的爬取脚本
|
||||
- 修改数据源URL和数据提取逻辑
|
||||
|
||||
2. **运行爬取脚本**:
|
||||
```bash
|
||||
node scripts/scraper/sow-inventory-scraper.js
|
||||
```
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **数据来源合规性**:确保爬取的数据来源符合相关法律法规
|
||||
2. **爬虫礼仪**:设置合理的请求间隔,避免对目标网站造成压力
|
||||
3. **数据验证**:爬取后对数据进行验证,确保数据准确性
|
||||
4. **错误处理**:脚本包含基本的错误处理,但可能需要根据实际情况进行调整
|
||||
|
||||
## 扩展指南
|
||||
|
||||
1. **添加新的爬取脚本**:
|
||||
- 复制 `template-scraper.js` 模板
|
||||
- 修改数据源URL和数据提取逻辑
|
||||
- 更新数据库存储逻辑
|
||||
|
||||
2. **修改数据提取逻辑**:
|
||||
- 使用Cheerio选择器提取HTML中的数据
|
||||
- 对于CSV数据,使用csv-parser解析
|
||||
- 对于JSON数据,直接处理响应数据
|
||||
|
||||
3. **数据存储**:
|
||||
- 脚本会自动将数据存储到数据库
|
||||
- 会检查数据是否已存在,存在则更新,不存在则创建
|
||||
|
||||
## 示例
|
||||
|
||||
爬取浙江省能繁母猪数量数据:
|
||||
|
||||
```bash
|
||||
node scripts/scraper/sow-inventory-scraper.js
|
||||
```
|
||||
# 数据爬取脚本
|
||||
|
||||
本目录包含用于从各种数据源爬取经济数据的脚本。
|
||||
|
||||
## 目录结构
|
||||
|
||||
- `sow-inventory-scraper.js` - 爬取能繁母猪数量数据
|
||||
- `template-scraper.js` - 通用爬取脚本模板
|
||||
|
||||
## 依赖
|
||||
|
||||
- axios - 用于发送HTTP请求
|
||||
- cheerio - 用于解析HTML内容
|
||||
- csv-parser - 用于解析CSV格式数据
|
||||
|
||||
## 安装依赖
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
## 使用方法
|
||||
|
||||
1. **修改脚本配置**:
|
||||
- 打开相应的爬取脚本
|
||||
- 修改数据源URL和数据提取逻辑
|
||||
|
||||
2. **运行爬取脚本**:
|
||||
```bash
|
||||
node scripts/scraper/sow-inventory-scraper.js
|
||||
```
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **数据来源合规性**:确保爬取的数据来源符合相关法律法规
|
||||
2. **爬虫礼仪**:设置合理的请求间隔,避免对目标网站造成压力
|
||||
3. **数据验证**:爬取后对数据进行验证,确保数据准确性
|
||||
4. **错误处理**:脚本包含基本的错误处理,但可能需要根据实际情况进行调整
|
||||
|
||||
## 扩展指南
|
||||
|
||||
1. **添加新的爬取脚本**:
|
||||
- 复制 `template-scraper.js` 模板
|
||||
- 修改数据源URL和数据提取逻辑
|
||||
- 更新数据库存储逻辑
|
||||
|
||||
2. **修改数据提取逻辑**:
|
||||
- 使用Cheerio选择器提取HTML中的数据
|
||||
- 对于CSV数据,使用csv-parser解析
|
||||
- 对于JSON数据,直接处理响应数据
|
||||
|
||||
3. **数据存储**:
|
||||
- 脚本会自动将数据存储到数据库
|
||||
- 会检查数据是否已存在,存在则更新,不存在则创建
|
||||
|
||||
## 示例
|
||||
|
||||
爬取浙江省能繁母猪数量数据:
|
||||
|
||||
```bash
|
||||
node scripts/scraper/sow-inventory-scraper.js
|
||||
```
|
||||
|
||||
@@ -1,352 +1,95 @@
|
||||
import axios from 'axios';
|
||||
import * as cheerio from 'cheerio';
|
||||
import { createPool } from 'mariadb';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
function loadDatabaseConfig() {
|
||||
const envPath = path.join(process.cwd(), '.env');
|
||||
if (fs.existsSync(envPath)) {
|
||||
console.log('读取 .env 文件...');
|
||||
const envContent = fs.readFileSync(envPath, 'utf-8');
|
||||
const lines = envContent.split('\n');
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmedLine = line.trim();
|
||||
if (trimmedLine.startsWith('DATABASE_URL=')) {
|
||||
const url = trimmedLine.substring('DATABASE_URL='.length).replace(/^"|"$/g, '');
|
||||
console.log('从 .env 文件获取数据库配置成功!');
|
||||
console.log(`DATABASE_URL: ${url}`);
|
||||
|
||||
// 解析URL
|
||||
const parts = url.split('://')[1].split('@');
|
||||
const auth = parts[0].split(':');
|
||||
const hostAndDb = parts[1].split('/');
|
||||
const hostAndPort = hostAndDb[0].split(':');
|
||||
|
||||
return {
|
||||
user: auth[0],
|
||||
password: auth[1],
|
||||
host: hostAndPort[0],
|
||||
port: parseInt(hostAndPort[1]),
|
||||
database: hostAndDb[1]
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log('未找到 .env 文件或 DATABASE_URL 配置,使用默认配置');
|
||||
return {
|
||||
host: '192.168.111.111',
|
||||
port: 3306,
|
||||
user: 'root',
|
||||
password: 'fullstack',
|
||||
database: 'iboard'
|
||||
};
|
||||
}
|
||||
|
||||
// 创建数据库连接池
|
||||
const config = loadDatabaseConfig();
|
||||
console.log('数据库配置:');
|
||||
console.log(` 主机: ${config.host}`);
|
||||
console.log(` 端口: ${config.port}`);
|
||||
console.log(` 用户: ${config.user}`);
|
||||
console.log(` 数据库: ${config.database}`);
|
||||
|
||||
let pool;
|
||||
|
||||
async function initPool() {
|
||||
if (!pool) {
|
||||
pool = createPool({
|
||||
host: config.host,
|
||||
port: config.port,
|
||||
user: config.user,
|
||||
password: config.password,
|
||||
database: config.database,
|
||||
connectionLimit: 5
|
||||
});
|
||||
|
||||
try {
|
||||
const conn = await pool.getConnection();
|
||||
console.log('数据库连接成功!');
|
||||
conn.release();
|
||||
} catch (error) {
|
||||
console.error('数据库连接失败:', error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
return pool;
|
||||
}
|
||||
|
||||
// 浙江数据开放网站URL(示例,实际需要根据真实网站修改)
|
||||
const ZHEJIANG_OPEN_DATA_URL = 'https://data.zj.gov.cn/';
|
||||
|
||||
// 爬虫名称,用于缓存表中的标识
|
||||
const SCRAPER_NAME = 'sow-inventory';
|
||||
|
||||
// 最小更新间隔(毫秒),默认30分钟
|
||||
const MIN_UPDATE_INTERVAL = 30 * 60 * 1000;
|
||||
|
||||
// 从数据库读取缓存
|
||||
async function readCache() {
|
||||
try {
|
||||
await initPool();
|
||||
const result = await pool.query('SELECT * FROM ScraperCache WHERE scraperName = ?', [SCRAPER_NAME]);
|
||||
return result.length > 0 ? result[0] : { scraperName: SCRAPER_NAME, lastDataHash: null, lastUpdateAt: null };
|
||||
} catch (error) {
|
||||
console.error('读取数据库缓存失败:', error.message);
|
||||
return { scraperName: SCRAPER_NAME, lastDataHash: null, lastUpdateAt: null };
|
||||
}
|
||||
}
|
||||
|
||||
// 写入数据库缓存
|
||||
async function writeCache(lastDataHash) {
|
||||
try {
|
||||
await initPool();
|
||||
await pool.query(
|
||||
'INSERT INTO ScraperCache (scraperName, lastDataHash, lastUpdateAt) VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE lastDataHash = VALUES(lastDataHash), lastUpdateAt = VALUES(lastUpdateAt)',
|
||||
[SCRAPER_NAME, lastDataHash, new Date()]
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('写入数据库缓存失败:', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// 计算数据hash,用于检测数据是否变化
|
||||
function computeDataHash(data) {
|
||||
const str = JSON.stringify(data);
|
||||
let hash = 0;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
const char = str.charCodeAt(i);
|
||||
hash = ((hash << 5) - hash) + char;
|
||||
hash = hash & hash;
|
||||
}
|
||||
return hash.toString();
|
||||
}
|
||||
|
||||
// 获取数据库中最新的月份
|
||||
async function getLatestMonth() {
|
||||
try {
|
||||
await initPool();
|
||||
const result = await pool.query('SELECT MAX(month) as latestMonth FROM Econ_SowInventory');
|
||||
return result[0].latestMonth || null;
|
||||
} catch (error) {
|
||||
console.error('查询最新月份失败:', error.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// 判断月份是否需要更新(只有比数据库中更新的月份才需要处理)
|
||||
function shouldUpdateMonth(month, latestDbMonth) {
|
||||
if (!latestDbMonth) return true;
|
||||
return month > latestDbMonth;
|
||||
}
|
||||
|
||||
// 检查是否需要发起HTTP请求(基于更新间隔)
|
||||
function shouldSkipRequest(cache) {
|
||||
if (!cache.lastUpdateAt) return false;
|
||||
|
||||
const now = new Date();
|
||||
const lastUpdate = new Date(cache.lastUpdateAt);
|
||||
const interval = now - lastUpdate;
|
||||
|
||||
return interval < MIN_UPDATE_INTERVAL;
|
||||
}
|
||||
|
||||
// 能繁母猪数量数据爬取函数(增量更新版本)
|
||||
async function scrapeSowInventory(incremental = true) {
|
||||
try {
|
||||
await initPool();
|
||||
console.log('='.repeat(50));
|
||||
console.log('开始爬取浙江省能繁母猪数量数据...');
|
||||
console.log(`增量更新模式: ${incremental ? '开启' : '关闭'}`);
|
||||
console.log('='.repeat(50));
|
||||
|
||||
// 0. 检查是否需要发起请求
|
||||
if (incremental) {
|
||||
const cache = await readCache();
|
||||
|
||||
if (shouldSkipRequest(cache)) {
|
||||
const lastUpdate = cache.lastUpdateAt ? new Date(cache.lastUpdateAt) : null;
|
||||
const hoursDiff = lastUpdate ? ((new Date() - lastUpdate) / (1000 * 60 * 60)).toFixed(2) : '未知';
|
||||
console.log('\n' + '='.repeat(50));
|
||||
console.log('跳过请求:距离上次更新不足30分钟');
|
||||
console.log(`上次更新时间: ${cache.lastUpdateAt}`);
|
||||
console.log(`距离现在: ${hoursDiff} 小时`);
|
||||
console.log('='.repeat(50));
|
||||
return;
|
||||
}
|
||||
|
||||
// 获取数据库中最新的月份
|
||||
const latestDbMonth = await getLatestMonth();
|
||||
if (latestDbMonth) {
|
||||
console.log(`数据库中最新月份: ${latestDbMonth}`);
|
||||
} else {
|
||||
console.log('数据库为空,将获取所有数据');
|
||||
}
|
||||
}
|
||||
|
||||
// 1. 发送HTTP请求获取页面内容
|
||||
console.log('\n正在请求数据源...');
|
||||
const response = await axios.get(ZHEJIANG_OPEN_DATA_URL, {
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36'
|
||||
}
|
||||
});
|
||||
|
||||
// 2. 解析HTML内容
|
||||
const $ = cheerio.load(response.data);
|
||||
|
||||
// 3. 提取数据(这里需要根据实际网站结构修改选择器)
|
||||
const allData = [];
|
||||
|
||||
$('table').each((tableIndex, table) => {
|
||||
$(table).find('tr').each((rowIndex, row) => {
|
||||
if (rowIndex > 0) { // 跳过表头
|
||||
const cells = $(row).find('td');
|
||||
if (cells.length >= 2) {
|
||||
const month = $(cells[0]).text().trim();
|
||||
const inventory = parseInt($(cells[1]).text().trim());
|
||||
|
||||
if (month && !isNaN(inventory)) {
|
||||
allData.push({
|
||||
month,
|
||||
inventory
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
console.log(`页面总数据量: ${allData.length} 条`);
|
||||
|
||||
// 4. 读取数据库缓存
|
||||
const cache = await readCache();
|
||||
|
||||
// 5. 计算当前数据hash,检测数据是否变化
|
||||
const currentDataHash = computeDataHash(allData);
|
||||
|
||||
// 6. 如果是增量更新且数据未变化,跳过更新
|
||||
if (incremental && cache.lastDataHash === currentDataHash) {
|
||||
console.log('\n' + '='.repeat(50));
|
||||
console.log('数据未发生变化,跳过数据库更新!');
|
||||
console.log(`上次更新时间: ${cache.lastUpdateAt || '未知'}`);
|
||||
console.log('='.repeat(50));
|
||||
return;
|
||||
}
|
||||
|
||||
// 7. 如果是增量更新,获取数据库中最新的月份用于过滤
|
||||
let latestDbMonth = null;
|
||||
if (incremental) {
|
||||
latestDbMonth = await getLatestMonth();
|
||||
}
|
||||
|
||||
// 8. 过滤需要更新的数据
|
||||
let dataToUpdate = allData;
|
||||
let skippedCount = 0;
|
||||
|
||||
if (incremental && latestDbMonth) {
|
||||
dataToUpdate = allData.filter(item => shouldUpdateMonth(item.month, latestDbMonth));
|
||||
skippedCount = allData.length - dataToUpdate.length;
|
||||
console.log(`\n增量更新: 跳过 ${skippedCount} 条已存在的数据`);
|
||||
console.log(`需要处理: ${dataToUpdate.length} 条新数据`);
|
||||
}
|
||||
|
||||
// 9. 如果没有需要更新的数据
|
||||
if (dataToUpdate.length === 0) {
|
||||
console.log('\n没有需要更新的数据!');
|
||||
return;
|
||||
}
|
||||
|
||||
// 10. 按月份排序(从旧到新)
|
||||
dataToUpdate.sort((a, b) => a.month.localeCompare(b.month));
|
||||
|
||||
console.log('\n开始处理数据...');
|
||||
console.log('-'.repeat(50));
|
||||
|
||||
let newCount = 0;
|
||||
let updateCount = 0;
|
||||
let errorCount = 0;
|
||||
|
||||
// 11. 存储数据到数据库
|
||||
for (const item of dataToUpdate) {
|
||||
try {
|
||||
// 检查数据是否已存在
|
||||
const existing = await pool.query('SELECT * FROM Econ_SowInventory WHERE month = ?', [item.month]);
|
||||
|
||||
if (existing.length > 0) {
|
||||
// 检查数据是否真的需要更新(值变化了才更新)
|
||||
if (existing[0].inventory !== item.inventory) {
|
||||
await pool.query(
|
||||
'UPDATE Econ_SowInventory SET inventory = ? WHERE month = ?',
|
||||
[item.inventory, item.month]
|
||||
);
|
||||
updateCount++;
|
||||
console.log(`更新数据: ${item.month} - ${item.inventory} (原值: ${existing[0].inventory})`);
|
||||
} else {
|
||||
console.log(`数据未变化,跳过: ${item.month}`);
|
||||
}
|
||||
} else {
|
||||
await pool.query(
|
||||
'INSERT INTO Econ_SowInventory (month, inventory) VALUES (?, ?)',
|
||||
[item.month, item.inventory]
|
||||
);
|
||||
newCount++;
|
||||
console.log(`新增数据: ${item.month} - ${item.inventory}`);
|
||||
}
|
||||
} catch (error) {
|
||||
errorCount++;
|
||||
console.error(`处理数据 ${item.month} 时出错:`, error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// 12. 更新数据库缓存
|
||||
await writeCache(currentDataHash);
|
||||
|
||||
// 13. 输出统计信息
|
||||
const now = new Date().toISOString();
|
||||
console.log('\n' + '='.repeat(50));
|
||||
console.log('数据爬取和存储完成!');
|
||||
console.log('统计信息:');
|
||||
console.log(` - 新增数据: ${newCount} 条`);
|
||||
console.log(` - 更新数据: ${updateCount} 条`);
|
||||
console.log(` - 跳过数据: ${skippedCount} 条`);
|
||||
console.log(` - 错误数量: ${errorCount} 条`);
|
||||
console.log(` - 本次更新时间: ${now}`);
|
||||
console.log('='.repeat(50));
|
||||
|
||||
} catch (error) {
|
||||
console.error('爬取数据时出错:', error.message);
|
||||
throw error;
|
||||
} finally {
|
||||
// 关闭数据库连接池
|
||||
if (pool) {
|
||||
await pool.end();
|
||||
console.log('数据库连接池已关闭');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 强制全量更新函数
|
||||
async function scrapeSowInventoryFull() {
|
||||
console.log('警告: 即将执行全量更新,这将更新所有数据!');
|
||||
console.log('如果只是想获取新数据,请使用 scrapeSowInventory() 函数。\n');
|
||||
await scrapeSowInventory(false);
|
||||
}
|
||||
|
||||
// 导出函数
|
||||
export {
|
||||
scrapeSowInventory,
|
||||
scrapeSowInventoryFull
|
||||
};
|
||||
|
||||
// 如果直接运行此文件
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
// 默认执行增量更新
|
||||
// 如果需要强制全量更新,使用: scrapeSowInventoryFull()
|
||||
scrapeSowInventory().catch(error => {
|
||||
console.error('执行失败:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
const axios = require('axios');
|
||||
const cheerio = require('cheerio');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { PrismaClient } = require('../../generated/prisma');
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
// 浙江数据开放网站URL(示例,实际需要根据真实网站修改)
|
||||
const ZHEJIANG_OPEN_DATA_URL = 'https://data.zj.gov.cn/';
|
||||
|
||||
// 能繁母猪数量数据爬取函数
|
||||
async function scrapeSowInventory() {
|
||||
try {
|
||||
console.log('开始爬取浙江省能繁母猪数量数据...');
|
||||
|
||||
// 1. 发送HTTP请求获取页面内容
|
||||
const response = await axios.get(ZHEJIANG_OPEN_DATA_URL, {
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36'
|
||||
}
|
||||
});
|
||||
|
||||
// 2. 解析HTML内容
|
||||
const $ = cheerio.load(response.data);
|
||||
|
||||
// 3. 提取数据(这里需要根据实际网站结构修改选择器)
|
||||
// 示例:假设数据在表格中
|
||||
const data = [];
|
||||
|
||||
$('table').each((tableIndex, table) => {
|
||||
$(table).find('tr').each((rowIndex, row) => {
|
||||
if (rowIndex > 0) { // 跳过表头
|
||||
const cells = $(row).find('td');
|
||||
if (cells.length >= 2) {
|
||||
const month = $(cells[0]).text().trim();
|
||||
const inventory = parseInt($(cells[1]).text().trim());
|
||||
|
||||
if (month && !isNaN(inventory)) {
|
||||
data.push({
|
||||
month,
|
||||
inventory
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
console.log(`爬取到 ${data.length} 条数据`);
|
||||
|
||||
// 4. 存储数据到数据库
|
||||
for (const item of data) {
|
||||
try {
|
||||
// 检查数据是否已存在
|
||||
const existing = await prisma.econ_SowInventory.findUnique({
|
||||
where: { month: item.month }
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
// 更新现有数据
|
||||
await prisma.econ_SowInventory.update({
|
||||
where: { month: item.month },
|
||||
data: { inventory: item.inventory }
|
||||
});
|
||||
console.log(`更新数据: ${item.month} - ${item.inventory}`);
|
||||
} else {
|
||||
// 创建新数据
|
||||
await prisma.econ_SowInventory.create({
|
||||
data: item
|
||||
});
|
||||
console.log(`新增数据: ${item.month} - ${item.inventory}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`处理数据 ${item.month} 时出错:`, error.message);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('数据爬取和存储完成!');
|
||||
|
||||
} catch (error) {
|
||||
console.error('爬取数据时出错:', error.message);
|
||||
} finally {
|
||||
// 关闭Prisma连接
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
// 导出函数
|
||||
module.exports = { scrapeSowInventory };
|
||||
|
||||
// 如果直接运行此文件
|
||||
if (require.main === module) {
|
||||
scrapeSowInventory();
|
||||
}
|
||||
|
||||
@@ -1,100 +1,100 @@
|
||||
const axios = require('axios');
|
||||
const cheerio = require('cheerio');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { PrismaClient } = require('../../generated/prisma');
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
// 数据源URL(需要根据实际情况修改)
|
||||
const DATA_SOURCE_URL = 'https://example.com/data';
|
||||
|
||||
// 通用数据爬取函数
|
||||
async function scrapeData() {
|
||||
try {
|
||||
console.log('开始爬取数据...');
|
||||
|
||||
// 1. 发送HTTP请求获取页面内容
|
||||
const response = await axios.get(DATA_SOURCE_URL, {
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36'
|
||||
}
|
||||
});
|
||||
|
||||
// 2. 解析HTML内容
|
||||
const $ = cheerio.load(response.data);
|
||||
|
||||
// 3. 提取数据(需要根据实际网站结构修改)
|
||||
const data = [];
|
||||
|
||||
// 示例:从表格中提取数据
|
||||
$('table').each((tableIndex, table) => {
|
||||
$(table).find('tr').each((rowIndex, row) => {
|
||||
if (rowIndex > 0) { // 跳过表头
|
||||
const cells = $(row).find('td');
|
||||
// 根据实际表格结构提取数据
|
||||
// 示例:假设第一列是日期,第二列是数值
|
||||
if (cells.length >= 2) {
|
||||
const date = $(cells[0]).text().trim();
|
||||
const value = parseFloat($(cells[1]).text().trim());
|
||||
|
||||
if (date && !isNaN(value)) {
|
||||
data.push({
|
||||
date,
|
||||
value
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
console.log(`爬取到 ${data.length} 条数据`);
|
||||
|
||||
// 4. 存储数据到数据库(需要根据实际表结构修改)
|
||||
for (const item of data) {
|
||||
try {
|
||||
// 这里需要根据实际表结构修改
|
||||
// 示例:存储到某个表
|
||||
/*
|
||||
const existing = await prisma.someTable.findUnique({
|
||||
where: { date: item.date }
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
// 更新现有数据
|
||||
await prisma.someTable.update({
|
||||
where: { date: item.date },
|
||||
data: { value: item.value }
|
||||
});
|
||||
console.log(`更新数据: ${item.date} - ${item.value}`);
|
||||
} else {
|
||||
// 创建新数据
|
||||
await prisma.someTable.create({
|
||||
data: item
|
||||
});
|
||||
console.log(`新增数据: ${item.date} - ${item.value}`);
|
||||
}
|
||||
*/
|
||||
} catch (error) {
|
||||
console.error(`处理数据时出错:`, error.message);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('数据爬取和存储完成!');
|
||||
|
||||
} catch (error) {
|
||||
console.error('爬取数据时出错:', error.message);
|
||||
} finally {
|
||||
// 关闭Prisma连接
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
// 导出函数
|
||||
module.exports = { scrapeData };
|
||||
|
||||
// 如果直接运行此文件
|
||||
if (require.main === module) {
|
||||
scrapeData();
|
||||
}
|
||||
const axios = require('axios');
|
||||
const cheerio = require('cheerio');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { PrismaClient } = require('../../generated/prisma');
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
// 数据源URL(需要根据实际情况修改)
|
||||
const DATA_SOURCE_URL = 'https://example.com/data';
|
||||
|
||||
// 通用数据爬取函数
|
||||
async function scrapeData() {
|
||||
try {
|
||||
console.log('开始爬取数据...');
|
||||
|
||||
// 1. 发送HTTP请求获取页面内容
|
||||
const response = await axios.get(DATA_SOURCE_URL, {
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36'
|
||||
}
|
||||
});
|
||||
|
||||
// 2. 解析HTML内容
|
||||
const $ = cheerio.load(response.data);
|
||||
|
||||
// 3. 提取数据(需要根据实际网站结构修改)
|
||||
const data = [];
|
||||
|
||||
// 示例:从表格中提取数据
|
||||
$('table').each((tableIndex, table) => {
|
||||
$(table).find('tr').each((rowIndex, row) => {
|
||||
if (rowIndex > 0) { // 跳过表头
|
||||
const cells = $(row).find('td');
|
||||
// 根据实际表格结构提取数据
|
||||
// 示例:假设第一列是日期,第二列是数值
|
||||
if (cells.length >= 2) {
|
||||
const date = $(cells[0]).text().trim();
|
||||
const value = parseFloat($(cells[1]).text().trim());
|
||||
|
||||
if (date && !isNaN(value)) {
|
||||
data.push({
|
||||
date,
|
||||
value
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
console.log(`爬取到 ${data.length} 条数据`);
|
||||
|
||||
// 4. 存储数据到数据库(需要根据实际表结构修改)
|
||||
for (const item of data) {
|
||||
try {
|
||||
// 这里需要根据实际表结构修改
|
||||
// 示例:存储到某个表
|
||||
/*
|
||||
const existing = await prisma.someTable.findUnique({
|
||||
where: { date: item.date }
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
// 更新现有数据
|
||||
await prisma.someTable.update({
|
||||
where: { date: item.date },
|
||||
data: { value: item.value }
|
||||
});
|
||||
console.log(`更新数据: ${item.date} - ${item.value}`);
|
||||
} else {
|
||||
// 创建新数据
|
||||
await prisma.someTable.create({
|
||||
data: item
|
||||
});
|
||||
console.log(`新增数据: ${item.date} - ${item.value}`);
|
||||
}
|
||||
*/
|
||||
} catch (error) {
|
||||
console.error(`处理数据时出错:`, error.message);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('数据爬取和存储完成!');
|
||||
|
||||
} catch (error) {
|
||||
console.error('爬取数据时出错:', error.message);
|
||||
} finally {
|
||||
// 关闭Prisma连接
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
// 导出函数
|
||||
module.exports = { scrapeData };
|
||||
|
||||
// 如果直接运行此文件
|
||||
if (require.main === module) {
|
||||
scrapeData();
|
||||
}
|
||||
|
||||
@@ -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); });
|
||||
@@ -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)});
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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); });
|
||||
@@ -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); });
|
||||
@@ -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();
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
#!/usr/bin/env bash
|
||||
# iBoard Android APK 打包
|
||||
# 用法: bash scripts/wsl/build-apk.sh [debug|release]
|
||||
set -euo pipefail
|
||||
|
||||
WIN_PROJECT='/mnt/d/workbench/iboard'
|
||||
cd "$WIN_PROJECT"
|
||||
|
||||
VARIANT="${1:-debug}"
|
||||
echo "构建变体: $VARIANT"
|
||||
|
||||
# 环境变量
|
||||
export ANDROID_HOME="${ANDROID_HOME:-$HOME/android-sdk}"
|
||||
export ANDROID_NDK_HOME="${ANDROID_NDK_HOME:-$ANDROID_HOME/ndk/27.2.12479018}"
|
||||
export PATH="$ANDROID_HOME/cmdline-tools/latest/bin:$ANDROID_HOME/platform-tools:$HOME/.cargo/bin:$PATH"
|
||||
|
||||
# 0. 首次需要 android 工程
|
||||
cd src-tauri
|
||||
if [ ! -d "gen/android" ]; then
|
||||
echo "=== 0. 初始化 Android 工程 (一次性) ==="
|
||||
npx tauri android init
|
||||
fi
|
||||
cd "$WIN_PROJECT"
|
||||
|
||||
# 1. 先构建前端, 让 Tauri android 工程能拿到 dist/
|
||||
echo "=== 1. 构建前端 ==="
|
||||
npm run build
|
||||
|
||||
# 2. 走不同路径
|
||||
cd src-tauri
|
||||
|
||||
# 全部 4 个 Android 架构的 rustBuild 任务 (Tauri 2 默认会编 4 个)
|
||||
RUST_BUILD_TASKS=(
|
||||
":app:rustBuildArm64Release"
|
||||
":app:rustBuildArmv7Release"
|
||||
":app:rustBuildX86_64Release"
|
||||
":app:rustBuildI686Release"
|
||||
)
|
||||
RUST_BUILD_TASKS_DBG=(
|
||||
":app:rustBuildArm64Debug"
|
||||
":app:rustBuildArmv7Debug"
|
||||
":app:rustBuildX86_64Debug"
|
||||
":app:rustBuildI686Debug"
|
||||
)
|
||||
SKIP_ARGS=()
|
||||
for t in "${RUST_BUILD_TASKS[@]}"; do SKIP_ARGS+=(-x "${t#:}"); done
|
||||
|
||||
if [ "$VARIANT" = "release" ]; then
|
||||
echo "[release] 手工 cargo 编 lib, gradle 只跑 packaging (跳过 rustBuild* 避免 --release 重复 bug)"
|
||||
|
||||
echo " [cargo] build lib release ..."
|
||||
cargo build \
|
||||
--target aarch64-linux-android \
|
||||
--features custom-protocol \
|
||||
--lib \
|
||||
--release
|
||||
|
||||
echo " [gradle] assembleRelease -x rustBuild* ..."
|
||||
cd gen/android
|
||||
./gradlew --no-daemon assembleRelease "${SKIP_ARGS[@]}"
|
||||
|
||||
APK_DIR="$WIN_PROJECT/src-tauri/gen/android/app/build/outputs/apk/release"
|
||||
echo
|
||||
echo "=== 产物 (未签名) ==="
|
||||
ls -lh "$APK_DIR"/*.apk 2>/dev/null || echo "(未找到 APK)"
|
||||
|
||||
echo
|
||||
echo "=== Release 签名 (按需) ==="
|
||||
echo "1. 生成 keystore (一次性):"
|
||||
echo " keytool -genkey -v -keystore ~/iboard-release.keystore -alias iboard -keyalg RSA -keysize 2048 -validity 10000"
|
||||
echo "2. 在 ~/.gradle/gradle.properties 写:"
|
||||
echo " IBOARD_UPLOAD_STORE_FILE=/home/$(whoami)/iboard-release.keystore"
|
||||
echo " IBOARD_UPLOAD_KEY_ALIAS=iboard"
|
||||
echo " IBOARD_UPLOAD_STORE_PASSWORD=..."
|
||||
echo " IBOARD_UPLOAD_KEY_PASSWORD=..."
|
||||
echo "3. 重跑本脚本"
|
||||
else
|
||||
echo "[debug] Tauri CLI debug 路径 (不传 --release 不会触发 bug), 直接走"
|
||||
npx tauri android build --apk
|
||||
|
||||
APK_DIR="$WIN_PROJECT/src-tauri/target/debug/bundle/android/app/build/outputs/apk/debug"
|
||||
echo
|
||||
echo "=== 产物 ==="
|
||||
ls -lh "$APK_DIR"/*.apk 2>/dev/null || echo "(未找到 APK)"
|
||||
fi
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -0,0 +1,2 @@
|
||||
[iboard-api] listening on http://127.0.0.1:3001
|
||||
[iboard-api] static dist: D:\workbench\iboard\dist
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
@@ -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)'}`)
|
||||
})
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,11 @@
|
||||
# Tauri 自动生成
|
||||
gen/
|
||||
target/
|
||||
|
||||
# Android 编译产物
|
||||
android/app/build/
|
||||
android/.gradle/
|
||||
android/build/
|
||||
android/local.properties
|
||||
android/captures/
|
||||
android/.idea/
|
||||
@@ -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"]
|
||||
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "默认能力: 允许 core API",
|
||||
"windows": ["main"],
|
||||
"permissions": [
|
||||
"core:default"
|
||||
]
|
||||
}
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 3.6 KiB |
|
After Width: | Height: | Size: 344 B |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 3.6 KiB |
|
After Width: | Height: | Size: 30 KiB |
@@ -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");
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
// Windows / Linux / macOS 桌面端入口
|
||||
// Android 不走这里, 走 .lib crate
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
iboard_lib::run();
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>,
|
||||
)
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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) || []
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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); }
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||