本指南将项目的所有文件与源码完整收录。任何环境下,按照步骤创建目录与文件、粘贴代码、安装依赖、执行命令,即可 100% 复现。
运行环境
- Node.js: 建议 >= 16
- 包管理器: npm 或 yarn(以下以 npm 为例)
- 操作系统: macOS / Windows / Linux
- 推荐编辑器: VS Code(安装 ESLint、Prettier 插件更佳)
快速开始
- 在任意位置新建项目根目录,如
face-webpack-react - 按下文目录结构创建文件夹与文件,将对应代码粘贴进去
- 进入项目根目录执行:
npm install
- 单入口开发(SPA):
npm run dev
访问 http://localhost:3000/
- 多入口(每个节气单独页面)开发(举例“立春”):
npm run start lichun
访问 http://localhost:3000/lichun/
- 构建全部节气:
npm run build all
产物在 dist/<season>/
目录结构(请按此创建所有目录与文件)
- package.json
- tsconfig.json
- .babelrc
- .eslintrc.js
- .gitignore
- webpack.config.js
- webpack.multi.config.js
- scripts/
- season-command.js
- public/
- index.html
- season.html
- src/
- index.tsx
- App.tsx
- seasonEntry.tsx
- styles/
- global.css
- hooks/
- useTheme.ts
- data/
- seasons.ts
- components/
- ThemeProvider.tsx
- SeasonPage.tsx
- SeasonSelector.tsx
- ColorPalette.tsx
- ActionButtons.tsx
- SeasonCard.tsx
- Toast.tsx
- types/
- global.d.ts
- pages/ ← 预留(可为空)
- utils/ ← 预留(可为空)
根目录文件
package.json
{
"name": "face-webpack-react",
"version": "2.0.0",
"description": "二十四节气主题页面 - React重构版",
"main": "index.js",
"scripts": {
"dev": "webpack serve --mode development",
"build": "node scripts/season-command.js build",
"build:analyze": "ANALYZE=true webpack --mode production",
"start": "node scripts/season-command.js start",
"preview": "http-server dist -p 8080",
"clean": "rimraf dist",
"lint": "eslint \"src/**/*.{js,jsx,ts,tsx}\" --max-warnings=0",
"lint:fix": "eslint \"src/**/*.{js,jsx,ts,tsx}\" --fix",
"format": "prettier --write \"**/*.{js,jsx,ts,tsx,css,html,json,md}\"",
"type-check": "tsc --noEmit",
"dev:multi": "webpack serve --config webpack.multi.config.js --mode development",
"build:multi": "webpack --config webpack.multi.config.js --mode production",
"build:multi:all": "webpack --config webpack.multi.config.js --mode production",
"build:multi:analyze": "ANALYZE=true webpack --config webpack.multi.config.js --mode production"
},
"keywords": [
"react",
"webpack",
"二十四节气",
"主题页面",
"单页应用",
"传统文化"
],
"author": "Face Webpack Team",
"license": "MIT",
"dependencies": {
"core-js": "^3.45.1",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.8.0"
},
"devDependencies": {
"@babel/core": "^7.23.0",
"@babel/preset-env": "^7.23.0",
"@babel/preset-react": "^7.22.0",
"@babel/preset-typescript": "^7.23.0",
"@pmmmwh/react-refresh-webpack-plugin": "^0.6.1",
"@types/react": "^18.2.0",
"@types/react-dom": "^18.2.0",
"@typescript-eslint/eslint-plugin": "^6.0.0",
"@typescript-eslint/parser": "^6.0.0",
"babel-loader": "^9.1.3",
"css-loader": "^6.8.1",
"eslint": "^8.50.0",
"eslint-config-prettier": "^9.0.0",
"eslint-plugin-prettier": "^5.0.1",
"eslint-plugin-react": "^7.33.0",
"eslint-plugin-react-hooks": "^4.6.0",
"html-webpack-plugin": "^5.5.3",
"http-server": "^14.1.1",
"prettier": "^3.0.3",
"react-refresh": "^0.17.0",
"rimraf": "^5.0.5",
"style-loader": "^3.3.3",
"typescript": "^5.0.0",
"webpack": "^5.88.2",
"webpack-bundle-analyzer": "^4.9.0",
"webpack-cli": "^5.1.4",
"webpack-dev-server": "^4.15.1"
},
"engines": {
"node": ">=16.0.0"
},
"browserslist": [
">0.2%",
"not dead",
"not op_mini all"
]
}
tsconfig.json
{
"compilerOptions": {
"target": "ES2020",
"lib": ["DOM", "DOM.Iterable", "ES6"],
"allowJs": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": true,
"module": "ESNext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"baseUrl": "src",
"paths": {
"@/*": ["*"],
"@/components/*": ["components/*"],
"@/pages/*": ["pages/*"],
"@/hooks/*": ["hooks/*"],
"@/utils/*": ["utils/*"],
"@/styles/*": ["styles/*"]
}
},
"include": [
"src/**/*"
],
"exclude": [
"node_modules",
"dist"
]
}
.babelrc
{
"presets": [
[
"@babel/preset-env",
{
"targets": {
"browsers": [">0.2%", "not dead", "not op_mini all"]
},
"useBuiltIns": "usage",
"corejs": 3
}
],
[
"@babel/preset-react",
{
"runtime": "automatic"
}
],
"@babel/preset-typescript"
],
"plugins": [],
"env": {}
}
.eslintrc.js
module.exports = {
env: {
browser: true,
es2021: true,
node: true
},
extends: [
'eslint:recommended',
'@typescript-eslint/recommended',
'plugin:react/recommended',
'plugin:react-hooks/recommended',
'prettier'
],
parser: '@typescript-eslint/parser',
parserOptions: {
ecmaFeatures: {
jsx: true
},
ecmaVersion: 'latest',
sourceType: 'module'
},
plugins: [
'react',
'react-hooks',
'@typescript-eslint',
'prettier'
],
rules: {
'prettier/prettier': 'error',
'react/react-in-jsx-scope': 'off',
'react/prop-types': 'off',
'@typescript-eslint/explicit-function-return-type': 'off',
'@typescript-eslint/explicit-module-boundary-types': 'off',
'@typescript-eslint/no-explicit-any': 'warn',
'@typescript-eslint/no-unused-vars': 'warn',
'no-console': 'warn',
'no-debugger': 'error'
},
settings: {
react: {
version: 'detect'
}
}
};
.gitignore
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
**/node_modules
# roadhog-api-doc ignore
/src/utils/request-temp.js
_roadhog-api-doc
# production
/dist
/.vscode
# misc
.DS_Store
npm-debug.log*
yarn-error.log
/coverage
.idea
yarn.lock
package-lock.json
*bak
.vscode
lib
webpack.config.js(单入口 SPA)
/**
* Webpack 配置 - React重构版
* 单入口应用,支持代码分割和优化
*/
const path = require('path');
const webpack = require('webpack');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const ReactRefreshWebpackPlugin = require('@pmmmwh/react-refresh-webpack-plugin');
module.exports = (env = {}, argv = {}) => {
const isDev = argv.mode === 'development';
const isAnalyze = Boolean(env.ANALYZE || process.env.ANALYZE);
return {
// 构建模式
mode: isDev ? 'development' : 'production',
// 入口配置 - 单入口
entry: './src/index.tsx',
// 输出配置
output: {
path: path.resolve(__dirname, 'dist'),
filename: isDev ? '[name].js' : '[name].[contenthash:8].js',
chunkFilename: isDev ? '[name].chunk.js' : '[name].[contenthash:8].chunk.js',
assetModuleFilename: 'assets/[name].[contenthash:8][ext][query]',
publicPath: '/',
clean: true
},
// Source Map配置:生产使用 hidden-source-map,保留定位同时降低源码暴露
devtool: isDev ? 'cheap-module-source-map' : 'hidden-source-map',
// 启用持久化缓存:大幅缩短二次构建时间
cache: { type: 'filesystem' },
// 性能配置
performance: { hints: false },
// 模块处理规则
module: {
rules: [
{
// 处理TypeScript和JavaScript文件
test: /\.(ts|tsx|js|jsx)$/,
exclude: /node_modules/,
// 使用 .babelrc 基础配置,并在开发环境附加 react-refresh 插件,避免生产注入
use: {
loader: 'babel-loader',
options: {
plugins: [
isDev && require.resolve('react-refresh/babel')
].filter(Boolean)
}
}
},
// CSS文件处理
{
test: /\.css$/,
use: [
'style-loader',
{
loader: 'css-loader',
options: {
modules: false,
importLoaders: 1
}
}
]
},
// 图片资源处理
{
test: /\.(png|jpe?g|gif|svg|webp)$/i,
type: 'asset',
parser: {
dataUrlCondition: {
maxSize: 8 * 1024 // 8KB阈值
}
}
},
// 字体文件处理
{
test: /\.(woff2?|ttf|eot|otf)$/i,
type: 'asset/resource'
},
// 音视频文件处理
{
test: /\.(mp4|mp3|wav|ogg)$/i,
type: 'asset/resource'
}
]
},
// 解析配置
resolve: {
extensions: ['.ts', '.tsx', '.js', '.jsx', '.json'],
alias: {
'@': path.resolve(__dirname, 'src'),
'@/components': path.resolve(__dirname, 'src/components'),
'@/pages': path.resolve(__dirname, 'src/pages'),
'@/hooks': path.resolve(__dirname, 'src/hooks'),
'@/utils': path.resolve(__dirname, 'src/utils'),
'@/styles': path.resolve(__dirname, 'src/styles'),
'@/data': path.resolve(__dirname, 'src/data')
}
},
// 插件配置
plugins: [
// HTML模板处理插件
new HtmlWebpackPlugin({
template: 'public/index.html',
filename: 'index.html',
inject: true,
minify: isDev ? false : {
removeComments: true,
collapseWhitespace: true,
removeRedundantAttributes: true,
useShortDoctype: true,
removeEmptyAttributes: true,
removeStyleLinkTypeAttributes: true,
keepClosingSlash: true,
minifyJS: true,
minifyCSS: true,
minifyURLs: true
}
}),
// 定义环境变量
new webpack.DefinePlugin({
'process.env.NODE_ENV': JSON.stringify(isDev ? 'development' : 'production')
}),
// 构建进度显示插件
new webpack.ProgressPlugin(),
// 开发环境专用插件
...(isDev ? [
new ReactRefreshWebpackPlugin()
] : []),
// 按需产物分析(以静态报告输出到 dist/report.html)
...(isAnalyze ? [
new (require('webpack-bundle-analyzer').BundleAnalyzerPlugin)({
analyzerMode: 'static',
openAnalyzer: false,
reportFilename: path.resolve(__dirname, 'dist/report.html')
})
] : [])
],
// 优化配置
optimization: {
// 代码分割配置
splitChunks: {
chunks: 'all',
cacheGroups: {
// React相关库单独打包
react: {
test: /[\\/]node_modules[\\/](react|react-dom|react-router|react-router-dom)[\\/]/,
name: 'react-vendor',
chunks: 'all',
priority: 30
},
// 其他第三方库统一打包
vendors: {
test: /[\\/]node_modules[\\/]/,
name: 'vendors',
chunks: 'all',
priority: 20
},
// 公共代码单独打包
common: {
name: 'common',
minChunks: 2,
chunks: 'all',
priority: 10,
reuseExistingChunk: true
}
}
},
// 运行时代码单独打包
runtimeChunk: 'single',
// 生产环境优化
...(isDev ? {} : {
minimize: true,
usedExports: true
})
},
// 开发服务器配置
devServer: {
static: {
directory: path.resolve(__dirname, 'public')
},
compress: true,
port: 3000,
open: true,
hot: true,
historyApiFallback: true,
client: {
overlay: {
errors: true,
warnings: false
}
}
},
// 构建日志配置
stats: isDev ? 'minimal' : 'normal'
};
};
webpack.multi.config.js(多入口:按节气分别构建)
/**
* Webpack 多子项目构建配置
* 根据节气配置文件生成独立的子项目
*/
const path = require('path');
const webpack = require('webpack');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const ReactRefreshWebpackPlugin = require('@pmmmwh/react-refresh-webpack-plugin');
// 读取节气配置
const fs = require('fs');
// 读取节气配置文件
const seasonsPath = path.resolve(__dirname, 'src/data/seasons.ts');
const seasonsContent = fs.readFileSync(seasonsPath, 'utf8');
// 使用正则提取导出的 SEASONS 数组字面量内容(不依赖 TypeScript 运行时)
const seasonsMatch = seasonsContent.match(/export const SEASONS[^=]*=\s*\[([\s\S]*?)\];/);
if (!seasonsMatch) {
throw new Error('无法解析节气配置文件: 未找到 SEASONS 导出');
}
// 基于源文件内容动态求值,确保数据单一来源
// 说明:
// - 仅对受控的本地源码求值,不涉及外部输入,安全可控
// - 避免硬编码 SEASONS,降低维护成本,避免数据漂移
let SEASONS = [];
try {
const seasonsArrayCode = seasonsMatch[1];
// 使用 Function 包裹为数组并求值(支持单引号、尾逗号等 JS 语法)
SEASONS = new Function(`return [${seasonsArrayCode}]`)();
} catch (e) {
throw new Error(`解析节气配置失败: ${e.message}`);
}
/**
* 生成所有节气的webpack配置
* 每个节气都有独立的入口和输出
*/
const seasonApps = SEASONS.map(season => ({
name: season.id,
title: `${season.name} - 二十四节气主题页面`,
entry: './src/seasonEntry.tsx',
description: season.description,
date: season.date,
seasonData: season
}));
/**
* 生成单个节气的Webpack配置
* @param {Object} appConfig - 应用配置对象
* @param {boolean} isDev - 是否为开发模式
* @returns {Object} Webpack配置对象
*/
const makeSeasonConfig = ({ name, title, entry, description, date, seasonData }, isDev, isAnalyze) => ({
// 配置名称
name,
// 构建模式
mode: isDev ? 'development' : 'production',
// 入口配置
entry: { [name]: entry },
// 输出配置
output: {
// 输出目录:dist/节气名称/
path: path.resolve(__dirname, 'dist', name),
// 输出文件名模板
filename: isDev ? '[name].js' : '[name].[contenthash:8].js',
chunkFilename: isDev ? '[name].chunk.js' : '[name].[contenthash:8].chunk.js',
assetModuleFilename: 'assets/[name].[contenthash:8][ext][query]',
// 公共资源路径
publicPath: `/${name}/`,
// 构建前清理输出目录
clean: true
},
// Source Map配置
// 开发保留快速增量映射,生产使用 hidden-source-map 便于定位同时减少源码暴露
devtool: isDev ? 'cheap-module-source-map' : 'hidden-source-map',
// 构建缓存:显著提升二次构建和多项目并行构建速度
cache: { type: 'filesystem' },
// 性能配置
performance: { hints: false },
// 模块处理规则
module: {
rules: [
{
// 处理TypeScript和JavaScript文件
test: /\.(ts|tsx|js|jsx)$/,
exclude: /node_modules/,
use: {
loader: 'babel-loader',
options: {
// 使用 .babelrc 的基础配置,并在开发环境附加 react-refresh,避免生产注入
plugins: [
isDev && require.resolve('react-refresh/babel')
].filter(Boolean)
}
}
},
// CSS文件处理
{
test: /\.css$/,
use: [
'style-loader',
{
loader: 'css-loader',
options: {
modules: false,
importLoaders: 1
}
}
]
},
// 图片资源处理
{
test: /\.(png|jpe?g|gif|svg|webp)$/i,
type: 'asset',
parser: {
dataUrlCondition: {
maxSize: 8 * 1024 // 8KB阈值
}
}
},
// 字体文件处理
{
test: /\.(woff2?|ttf|eot|otf)$/i,
type: 'asset/resource'
},
// 音视频文件处理
{
test: /\.(mp4|mp3|wav|ogg)$/i,
type: 'asset/resource'
}
]
},
// 解析配置
resolve: {
extensions: ['.ts', '.tsx', '.js', '.jsx', '.json'],
alias: {
'@': path.resolve(__dirname, 'src'),
'@/components': path.resolve(__dirname, 'src/components'),
'@/pages': path.resolve(__dirname, 'src/pages'),
'@/hooks': path.resolve(__dirname, 'src/hooks'),
'@/utils': path.resolve(__dirname, 'src/utils'),
'@/styles': path.resolve(__dirname, 'src/styles'),
'@/data': path.resolve(__dirname, 'src/data')
}
},
// 插件配置
plugins: [
// HTML模板处理插件
new HtmlWebpackPlugin({
template: 'public/season.html',
filename: 'index.html',
inject: true,
title,
description,
date,
seasonId: name,
seasonData: seasonData,
chunks: [name],
minify: isDev ? false : {
removeComments: true,
collapseWhitespace: true,
removeRedundantAttributes: true,
useShortDoctype: true,
removeEmptyAttributes: true,
removeStyleLinkTypeAttributes: true,
keepClosingSlash: true,
minifyJS: true,
minifyCSS: true,
minifyURLs: true
}
}),
// 定义环境变量(仅保留 NODE_ENV,避免重复的注入渠道)
new webpack.DefinePlugin({
'process.env.NODE_ENV': JSON.stringify(isDev ? 'development' : 'production')
}),
// 构建进度显示插件
new webpack.ProgressPlugin(),
// 开发环境专用插件
...(isDev ? [
new ReactRefreshWebpackPlugin()
] : []),
// 按需分析构建产物体积
...(isAnalyze ? [
// 延迟加载以避免非 Analyze 构建的额外开销
new (require('webpack-bundle-analyzer').BundleAnalyzerPlugin)({
analyzerMode: 'static',
openAnalyzer: false,
reportFilename: path.resolve(__dirname, `dist/${name}/report.html`)
})
] : [])
],
// 优化配置
optimization: {
// 代码分割配置
splitChunks: {
chunks: 'all',
cacheGroups: {
// React相关库单独打包
react: {
test: /[\\/]node_modules[\\/](react|react-dom|react-router|react-router-dom)[\\/]/,
name: 'react-vendor',
chunks: 'all',
priority: 30
},
// 其他第三方库统一打包
vendors: {
test: /[\\/]node_modules[\\/]/,
name: 'vendors',
chunks: 'all',
priority: 20
},
// 公共代码单独打包
common: {
name: 'common',
minChunks: 2,
chunks: 'all',
priority: 10,
reuseExistingChunk: true
}
}
},
// 运行时代码单独打包
runtimeChunk: 'single',
// 生产环境优化
...(isDev ? {} : {
minimize: true,
// 让 webpack 按默认策略使用 package.json sideEffects,避免误伤三方包
usedExports: true
})
},
// 开发服务器配置
devServer: {
static: {
directory: path.resolve(__dirname, 'public')
},
compress: true,
port: 3000,
open: true,
hot: true,
// 在 TARGET 模式下,自动将根路径重写到对应子项目的 publicPath,便于直接打开正确页面
historyApiFallback: {
rewrites: [
{ from: /^\/$/, to: `/${name}/` }
]
},
client: {
overlay: {
errors: true,
warnings: false
}
}
},
// 构建日志配置
stats: isDev ? 'minimal' : 'normal'
});
/**
* Webpack配置导出函数
*
* 支持两种使用方式:
* 1. 同时构建所有节气:webpack --config webpack.multi.config.js
* 2. 单独构建某个节气:webpack --config webpack.multi.config.js --env TARGET=lichun
*
* @param {Object} env - 环境变量对象
* @param {string} env.TARGET - 指定构建的节气名称
* @param {Object} argv - webpack命令行参数
* @param {string} argv.mode - 构建模式(development/production)
*/
module.exports = (env = {}, argv = {}) => {
// 判断是否为开发模式
const isDev = argv.mode === 'development';
// 是否开启产物分析
const isAnalyze = Boolean(env.ANALYZE || process.env.ANALYZE);
// 如果指定了TARGET,只构建单个节气
if (env.TARGET) {
const meta = seasonApps.find(a => a.name === env.TARGET);
if (!meta) {
// 友好的错误提示,列出所有可用节气
throw new Error(
`Unknown TARGET "${env.TARGET}". Use one of: ${seasonApps.map(a => a.name).join(', ')}`
);
}
return makeSeasonConfig(meta, isDev, isAnalyze);
}
// 默认构建所有节气
// 返回数组配置,webpack会并行构建所有节气
return seasonApps.map(meta => makeSeasonConfig(meta, isDev, isAnalyze));
};
scripts 目录
scripts/season-command.js
#!/usr/bin/env node
/**
* 节气命令处理脚本
* 支持简化的命令格式:npm run build [节气名] 和 npm run start [节气名]
*/
const { execSync } = require('child_process');
const path = require('path');
// 24节气配置
const SEASONS = [
'lichun', 'yushui', 'jingzhe', 'chunfen', 'qingming', 'guyu',
'lixia', 'xiaoman', 'mangzhong', 'xiazhi', 'xiaoshu', 'dashu',
'liqiu', 'chushu', 'bailu', 'qiufen', 'hanlu', 'shuangjiang',
'lidong', 'xiaoxue', 'daxue', 'dongzhi', 'xiaohan', 'dahan'
];
// 节气中文名称映射
const SEASON_NAMES = {
'lichun': '立春', 'yushui': '雨水', 'jingzhe': '惊蛰', 'chunfen': '春分',
'qingming': '清明', 'guyu': '谷雨', 'lixia': '立夏', 'xiaoman': '小满',
'mangzhong': '芒种', 'xiazhi': '夏至', 'xiaoshu': '小暑', 'dashu': '大暑',
'liqiu': '立秋', 'chushu': '处暑', 'bailu': '白露', 'qiufen': '秋分',
'hanlu': '寒露', 'shuangjiang': '霜降', 'lidong': '立冬', 'xiaoxue': '小雪',
'daxue': '大雪', 'dongzhi': '冬至', 'xiaohan': '小寒', 'dahan': '大寒'
};
/**
* 显示帮助信息
*/
function showHelp() {
console.log('\n🌿 二十四节气构建工具\n');
console.log('用法:');
console.log(' npm run build [节气名] 构建指定节气');
console.log(' npm run start [节气名] 启动指定节气开发服务器');
console.log(' npm run build all 构建所有24个节气');
console.log(' npm run start all 启动所有节气开发服务器\n');
console.log('可用节气:');
SEASONS.forEach((season, index) => {
const name = SEASON_NAMES[season];
const padding = ' '.repeat(12 - season.length);
console.log(` ${season}${padding}${name}`);
});
console.log('\n示例:');
console.log(' npm run build lichun 构建立春主题');
console.log(' npm run start yushui 启动雨水开发服务器');
console.log(' npm run build all 构建所有节气\n');
}
/**
* 验证节气名称
* @param {string} seasonName - 节气名称
* @returns {string|null} - 返回有效的节气名称或null
*/
function validateSeason(seasonName) {
if (!seasonName) return null;
const lowerSeason = seasonName.toLowerCase();
// 直接匹配
if (SEASONS.includes(lowerSeason)) {
return lowerSeason;
}
// 中文名称匹配
for (const [key, value] of Object.entries(SEASON_NAMES)) {
if (value === seasonName) {
return key;
}
}
// 模糊匹配
const fuzzyMatch = SEASONS.find(season =>
season.includes(lowerSeason) || lowerSeason.includes(season)
);
return fuzzyMatch || null;
}
/**
* 执行构建命令
* @param {string} seasonName - 节气名称
*/
function buildSeason(seasonName) {
if (seasonName === 'all') {
console.log('🚀 开始构建所有24个节气...\n');
try {
execSync('webpack --config webpack.multi.config.js --mode production', {
stdio: 'inherit',
cwd: process.cwd()
});
console.log('\n✅ 所有节气构建完成!');
} catch (error) {
console.error('\n❌ 构建失败:', error.message);
process.exit(1);
}
return;
}
const validSeason = validateSeason(seasonName);
if (!validSeason) {
console.error(`❌ 无效的节气名称: ${seasonName}`);
console.log('\n可用节气:', SEASONS.join(', '));
process.exit(1);
}
const seasonName_zh = SEASON_NAMES[validSeason];
console.log(`🚀 开始构建${seasonName_zh}(${validSeason})主题...\n`);
try {
execSync(`webpack --config webpack.multi.config.js --mode production --env TARGET=${validSeason}`, {
stdio: 'inherit',
cwd: process.cwd()
});
console.log(`\n✅ ${seasonName_zh}主题构建完成!`);
console.log(`📁 输出目录: dist/${validSeason}/`);
} catch (error) {
console.error(`\n❌ ${seasonName_zh}主题构建失败:`, error.message);
process.exit(1);
}
}
/**
* 启动开发服务器
* @param {string} seasonName - 节气名称
*/
function startSeason(seasonName) {
if (seasonName === 'all') {
console.log('🚀 启动所有节气开发服务器...\n');
console.log('注意: 由于端口限制,建议逐个启动节气开发服务器');
console.log('可用命令: npm run start [节气名]\n');
return;
}
const validSeason = validateSeason(seasonName);
if (!validSeason) {
console.error(`❌ 无效的节气名称: ${seasonName}`);
console.log('\n可用节气:', SEASONS.join(', '));
process.exit(1);
}
const seasonName_zh = SEASON_NAMES[validSeason];
console.log(`🚀 启动${seasonName_zh}(${validSeason})开发服务器...\n`);
try {
execSync(`webpack serve --config webpack.multi.config.js --mode development --env TARGET=${validSeason}`, {
stdio: 'inherit',
cwd: process.cwd()
});
} catch (error) {
console.error(`\n❌ ${seasonName_zh}开发服务器启动失败:`, error.message);
process.exit(1);
}
}
/**
* 主函数
*/
function main() {
const args = process.argv.slice(2);
const command = args[0];
const seasonName = args[1];
// 显示帮助信息
if (!command || command === 'help' || command === '-h' || command === '--help' || seasonName === 'help') {
showHelp();
return;
}
// 处理命令
switch (command) {
case 'build':
if (!seasonName) {
console.error('❌ 请指定要构建的节气名称');
console.log('使用 npm run build help 查看帮助信息');
process.exit(1);
}
buildSeason(seasonName);
break;
case 'start':
if (!seasonName) {
console.error('❌ 请指定要启动的节气名称');
console.log('使用 npm run start help 查看帮助信息');
process.exit(1);
}
startSeason(seasonName);
break;
default:
console.error(`❌ 未知命令: ${command}`);
console.log('可用命令: build, start');
console.log('使用 npm run build help 查看帮助信息');
process.exit(1);
}
}
// 执行主函数
main();
public 目录
public/index.html(SPA 模板)
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>二十四节气主题页面 - React版</title>
<meta name="description" content="基于React的二十四节气主题页面,体验传统节气的独特魅力">
<meta name="keywords" content="二十四节气,React,传统文化,主题页面,节气">
<meta name="author" content="Face Webpack Team">
<!-- Open Graph / Facebook -->
<meta property="og:type" content="website">
<meta property="og:url" content="https://face-webpack-react.com/">
<meta property="og:title" content="二十四节气主题页面 - React版">
<meta property="og:description" content="基于React的二十四节气主题页面,体验传统节气的独特魅力">
<!-- Twitter -->
<meta property="twitter:card" content="summary_large_image">
<meta property="twitter:url" content="https://face-webpack-react.com/">
<meta property="twitter:title" content="二十四节气主题页面 - React版">
<meta property="twitter:description" content="基于React的二十四节气主题页面,体验传统节气的独特魅力">
<style>
/* 加载动画 */
.loading {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: #f5f5f5;
display: flex;
justify-content: center;
align-items: center;
z-index: 9999;
flex-direction: column;
}
.loading-spinner {
width: 40px;
height: 40px;
border: 4px solid #f3f3f3;
border-top: 4px solid #4CAF50;
border-radius: 50%;
animation: spin 1s linear infinite;
margin-bottom: 16px;
}
.loading-text {
color: #666;
font-size: 14px;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
/* 隐藏加载动画 */
.loading.hidden {
display: none;
}
</style>
...</html>
public/season.html(单节气页面模板)
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><%= htmlWebpackPlugin.options.title %></title>
<meta name="description" content="<%= htmlWebpackPlugin.options.description %>">
<meta name="keywords" content="二十四节气,<%= htmlWebpackPlugin.options.seasonId %>,传统文化,节气主题">
<meta name="author" content="Face Webpack Team">
<!-- Open Graph / Facebook -->
<meta property="og:type" content="website">
<meta property="og:url" content="<%= htmlWebpackPlugin.options.seasonData ? 'https://face-webpack-react.com/' + htmlWebpackPlugin.options.seasonId : '' %>">
<meta property="og:title" content="<%= htmlWebpackPlugin.options.title %>">
<meta property="og:description" content="<%= htmlWebpackPlugin.options.description %>">
<!-- Twitter -->
<meta property="twitter:card" content="summary_large_image">
<meta property="twitter:url" content="<%= htmlWebpackPlugin.options.seasonData ? 'https://face-webpack-react.com/' + htmlWebpackPlugin.options.seasonId : '' %>">
<meta property="twitter:title" content="<%= htmlWebpackPlugin.options.title %>">
<meta property="twitter:description" content="<%= htmlWebpackPlugin.options.description %>">
<style>
/* 加载动画 */
.loading {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: #f5f5f5;
display: flex;
justify-content: center;
align-items: center;
z-index: 9999;
flex-direction: column;
}
.loading-spinner {
width: 40px;
height: 40px;
border: 4px solid #f3f3f3;
border-top: 4px solid #4CAF50;
border-radius: 50%;
animation: spin 1s linear infinite;
margin-bottom: 16px;
}
.loading-text {
color: #666;
font-size: 14px;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
/* 隐藏加载动画 */
.loading.hidden {
display: none;
}
</style>
...</html>
src 目录
src/index.tsx
/**
* 应用入口文件
*/
import React from 'react';
import { createRoot } from 'react-dom/client';
import App from './App';
import './styles/global.css';
// 获取根元素
const container = document.getElementById('root');
if (!container) {
throw new Error('找不到根元素 #root');
}
// 创建React根
const root = createRoot(container);
// 渲染应用
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);
src/App.tsx
/**
* 主应用组件
* 使用React Router进行路由管理
*/
import React, { useEffect } from 'react';
import { BrowserRouter as Router, Routes, Route, useParams } from 'react-router-dom';
import { ThemeProvider } from '@/components/ThemeProvider';
import { SeasonPage } from '@/components/SeasonPage';
import { SeasonSelector } from '@/components/SeasonSelector';
import { useToast } from '@/components/Toast';
import { SEASONS } from '@/data/seasons';
// 节气详情页面组件
const SeasonDetailPage: React.FC = () => {
const { seasonId } = useParams<{ seasonId: string }>();
// 验证seasonId是否有效
const isValidSeason = seasonId && SEASONS.some(s => s.id === seasonId);
if (!isValidSeason) {
return (
<div className="error-page">
<div className="container">
<h1>节气不存在</h1>
<p>找不到指定的节气:{seasonId}</p>
<button
className="btn btn-primary"
onClick={() => window.location.href = '/'}
>
返回首页
</button>
</div>
</div>
);
}
return <SeasonPage seasonId={seasonId} />;
};
// 首页组件
const HomePage: React.FC = () => {
return (
<div className="home-page">
<div className="container">
<header className="home-header">
<h1>🌸 二十四节气主题页面</h1>
<p>体验传统节气的独特魅力,感受四季变换的美好</p>
</header>
<SeasonSelector />
<div className="home-content">
<SeasonPage />
</div>
</div>
</div>
);
};
// 键盘快捷键处理
const KeyboardHandler: React.FC = () => {
useEffect(() => {
const handleKeydown = (event: KeyboardEvent) => {
// 空格键随机切换主题
if (event.code === 'Space' && !(event.target as Element)?.matches('input, textarea')) {
event.preventDefault();
// 这里需要访问主题上下文,暂时通过事件处理
window.dispatchEvent(new CustomEvent('randomTheme'));
}
// ESC键返回首页
if (event.code === 'Escape') {
window.location.href = '/';
}
};
document.addEventListener('keydown', handleKeydown);
return () => document.removeEventListener('keydown', handleKeydown);
}, []);
return null;
};
// 主应用组件
const App: React.FC = () => {
const { ToastContainer } = useToast();
return (
<ThemeProvider>
<Router>
<div className="app">
<KeyboardHandler />
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/season/:seasonId" element={<SeasonDetailPage />} />
<Route path="*" element={<HomePage />} />
</Routes>
<ToastContainer />
</div>
</Router>
</ThemeProvider>
);
};
export default App;
src/seasonEntry.tsx
/**
* 节气专用入口文件
* 用于生成独立的节气页面
*/
import React from 'react';
import { createRoot } from 'react-dom/client';
import { SeasonPage } from '@/components/SeasonPage';
import { ThemeProvider } from '@/components/ThemeProvider';
import { useToast } from '@/components/Toast';
import './styles/global.css';
// 从全局变量获取节气数据
const seasonData = (window as any).__SEASON_DATA__ || null;
const seasonId = seasonData?.id || 'lichun';
// 节气专用应用组件
const SeasonApp: React.FC = () => {
const { ToastContainer } = useToast();
return (
<ThemeProvider>
<div className="app">
<SeasonPage seasonId={seasonId} />
<ToastContainer />
</div>
</ThemeProvider>
);
};
// 获取根元素
const container = document.getElementById('root');
if (!container) {
throw new Error('找不到根元素 #root');
}
// 创建React根
const root = createRoot(container);
// 渲染应用
root.render(
<React.StrictMode>
<SeasonApp />
</React.StrictMode>
);
src/styles/global.css
/* 全局样式文件 - React重构版 */
/* CSS变量定义 - 动态主题支持 */
:root {
/* 默认主题颜色 */
--primary-color: #4CAF50;
--secondary-color: #8BC34A;
--background-color: #F1F8E9;
--text-color: #2E7D32;
--accent-color: #C8E6C9;
--gradient: linear-gradient(135deg, #4CAF50 0%, #8BC34A 100%);
/* 字体设置 */
--font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
--font-family-serif: 'Times New Roman', '宋体', serif;
/* 间距设置 */
--spacing-xs: 4px;
--spacing-sm: 8px;
--spacing-md: 16px;
--spacing-lg: 24px;
--spacing-xl: 32px;
--spacing-xxl: 48px;
/* 圆角设置 */
--border-radius-sm: 4px;
--border-radius-md: 8px;
--border-radius-lg: 12px;
--border-radius-xl: 16px;
/* 阴影设置 */
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.12), 0 1px 2px rgba(0, 0, 0, 0.24);
--shadow-md: 0 3px 6px rgba(0, 0, 0, 0.16), 0 3px 6px rgba(0, 0, 0, 0.23);
--shadow-lg: 0 10px 20px rgba(0, 0, 0, 0.19), 0 6px 6px rgba(0, 0, 0, 0.23);
/* 过渡动画 */
--transition-fast: 0.2s ease-in-out;
--transition-normal: 0.3s ease-in-out;
--transition-slow: 0.5s ease-in-out;
}
/* 基础样式重置 */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html {
font-size: 16px;
scroll-behavior: smooth;
}
body {
font-family: var(--font-family);
line-height: 1.6;
color: var(--text-color);
background: var(--background-color);
transition: all var(--transition-normal);
min-height: 100vh;
}
/* 应用容器 */
.app {
min-height: 100vh;
display: flex;
flex-direction: column;
}
/* 容器样式 */
.container {
max-width: 1200px;
margin: 0 auto;
padding: 0 var(--spacing-md);
}
.container-fluid {
width: 100%;
padding: 0 var(--spacing-md);
}
/* 布局样式 */
.flex {
display: flex;
}
.flex-center {
display: flex;
justify-content: center;
align-items: center;
}
.flex-between {
display: flex;
justify-content: space-between;
align-items: center;
}
.flex-column {
display: flex;
flex-direction: column;
}
.flex-wrap {
flex-wrap: wrap;
}
/* 按钮样式 */
.btn {
display: inline-block;
padding: var(--spacing-sm) var(--spacing-lg);
border: none;
border-radius: var(--border-radius-md);
font-size: 1rem;
font-weight: 500;
text-decoration: none;
text-align: center;
cursor: pointer;
transition: all var(--transition-fast);
box-shadow: var(--shadow-sm);
}
.btn:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-md);
}
.btn-primary {
background: var(--gradient);
color: white;
}
.btn-secondary {
background: var(--secondary-color);
color: white;
}
.btn-outline {
background: transparent;
color: var(--primary-color);
border: 2px solid var(--primary-color);
}
.btn-outline:hover {
background: var(--primary-color);
color: white;
}
/* 卡片样式 */
.season-card {
background: white;
border-radius: var(--border-radius-lg);
box-shadow: var(--shadow-md);
padding: var(--spacing-lg);
margin: var(--spacing-md) 0;
transition: all var(--transition-normal);
}
.season-card:hover {
transform: translateY(-4px);
box-shadow: var(--shadow-lg);
}
.season-card h3 {
color: var(--primary-color);
margin-bottom: var(--spacing-md);
font-size: 1.3rem;
}
.card-content {
color: #666;
line-height: 1.8;
}
/* 首页样式 */
.home-page {
min-height: 100vh;
background: var(--background-color);
}
.home-header {
text-align: center;
padding: var(--spacing-xxl) 0;
background: var(--gradient);
color: white;
margin-bottom: var(--spacing-xxl);
}
.home-header h1 {
font-size: 3rem;
margin-bottom: var(--spacing-md);
text-shadow: 2px 2px 4px rgba(0,0,0,0.3);
}
.home-header p {
font-size: 1.2rem;
opacity: 0.9;
}
/* 节气选择器样式 */
.season-selector {
background: white;
border-radius: var(--border-radius-lg);
box-shadow: var(--shadow-md);
margin-bottom: var(--spacing-xxl);
overflow: hidden;
}
.selector-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--spacing-lg);
background: var(--accent-color);
}
.selector-header h2 {
color: var(--text-color);
margin: 0;
}
.selector-content {
padding: var(--spacing-lg);
}
.category-tabs {
display: flex;
gap: var(--spacing-sm);
margin-bottom: var(--spacing-lg);
flex-wrap: wrap;
}
.category-tab {
padding: var(--spacing-sm) var(--spacing-md);
border: 2px solid var(--primary-color);
background: transparent;
color: var(--primary-color);
border-radius: var(--border-radius-md);
cursor: pointer;
transition: all var(--transition-fast);
font-weight: 500;
}
.category-tab:hover,
.category-tab.active {
background: var(--primary-color);
color: white;
}
.seasons-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: var(--spacing-md);
}
.season-item {
border: 2px solid transparent;
border-radius: var(--border-radius-md);
cursor: pointer;
transition: all var(--transition-fast);
overflow: hidden;
}
.season-item:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-md);
}
.season-item.active {
border-color: var(--primary-color);
box-shadow: var(--shadow-md);
}
.season-preview {
background: white;
}
.season-color-bar {
height: 4px;
width: 100%;
}
.season-info {
padding: var(--spacing-md);
}
.season-info h4 {
color: var(--text-color);
margin-bottom: var(--spacing-xs);
font-size: 1.1rem;
}
.season-info p {
color: #666;
font-size: 0.9rem;
margin-bottom: var(--spacing-xs);
}
.season-date {
color: #999;
font-size: 0.8rem;
}
/* 节气页面样式 */
.season-page {
min-height: 100vh;
background: var(--background-color);
transition: all var(--transition-slow);
}
.season-header {
background: var(--gradient);
color: white;
padding: var(--spacing-xxl) 0;
text-align: center;
position: relative;
overflow: hidden;
}
.season-header::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><pattern id="grain" width="100" height="100" patternUnits="userSpaceOnUse"><circle cx="25" cy="25" r="1" fill="rgba(255,255,255,0.1)"/><circle cx="75" cy="75" r="1" fill="rgba(255,255,255,0.1)"/><circle cx="50" cy="10" r="0.5" fill="rgba(255,255,255,0.05)"/><circle cx="10" cy="60" r="0.5" fill="rgba(255,255,255,0.05)"/><circle cx="90" cy="40" r="0.5" fill="rgba(255,255,255,0.05)"/></pattern></defs><rect width="100" height="100" fill="url(%23grain)"/></svg>');
opacity: 0.3;
}
.season-title {
font-size: 3rem;
font-weight: bold;
margin-bottom: var(--spacing-md);
position: relative;
z-index: 1;
}
.season-subtitle {
font-size: 1.2rem;
opacity: 0.9;
position: relative;
z-index: 1;
}
.season-date {
font-size: 1rem;
opacity: 0.8;
position: relative;
z-index: 1;
}
.season-content {
padding: var(--spacing-xxl) 0;
}
.season-info {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: var(--spacing-lg);
margin-bottom: var(--spacing-xxl);
}
.season-poetry {
background: var(--accent-color);
border-radius: var(--border-radius-lg);
padding: var(--spacing-lg);
text-align: center;
font-style: italic;
font-size: 1.1rem;
color: var(--text-color);
margin: var(--spacing-lg) 0;
border-left: 4px solid var(--primary-color);
}
.color-palette {
display: flex;
gap: var(--spacing-md);
justify-content: center;
flex-wrap: wrap;
}
.color-item {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--spacing-sm);
}
.color-swatch {
width: 60px;
height: 60px;
border-radius: 50%;
border: 3px solid white;
box-shadow: var(--shadow-sm);
cursor: pointer;
transition: all var(--transition-fast);
}
.color-swatch:hover {
transform: scale(1.1);
box-shadow: var(--shadow-md);
}
.action-buttons {
display: flex;
gap: var(--spacing-md);
justify-content: center;
margin-top: var(--spacing-xl);
flex-wrap: wrap;
}
.season-footer {
background: rgba(var(--primary-color-rgb, 76, 175, 80), 0.1);
padding: var(--spacing-xl) 0;
text-align: center;
color: var(--text-color);
margin-top: auto;
}
/* 错误页面样式 */
.error-page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: var(--background-color);
}
.error-page .container {
text-align: center;
}
.error-page h1 {
color: var(--primary-color);
margin-bottom: var(--spacing-md);
}
.error-page p {
color: #666;
margin-bottom: var(--spacing-lg);
}
/* 动画效果 */
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateX(-20px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
.fade-in {
animation: fadeIn 0.6s ease-out;
}
.slide-in {
animation: slideIn 0.6s ease-out;
}
/* 主题切换动画 */
.theme-transition {
transition: all var(--transition-slow);
}
/* Toast 提示样式 */
.toast-container {
position: fixed;
top: 20px;
right: 20px;
z-index: 10000;
display: flex;
flex-direction: column;
gap: 10px;
}
.toast {
background: white;
border-radius: var(--border-radius-md);
box-shadow: var(--shadow-lg);
padding: 0;
min-width: 300px;
max-width: 400px;
transform: translateX(100%);
transition: all var(--transition-normal);
border-left: 4px solid var(--primary-color);
}
.toast-visible {
transform: translateX(0);
}
.toast-hidden {
transform: translateX(100%);
}
.toast-content {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--spacing-md) var(--spacing-lg);
}
.toast-message {
color: var(--text-color);
font-size: 14px;
line-height: 1.4;
flex: 1;
}
.toast-close {
background: none;
border: none;
color: #999;
font-size: 18px;
cursor: pointer;
padding: 0;
margin-left: var(--spacing-sm);
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
transition: all var(--transition-fast);
}
.toast-close:hover {
background: #f5f5f5;
color: #666;
}
.toast-success {
border-left-color: #4CAF50;
}
.toast-error {
border-left-color: #F44336;
}
.toast-info {
border-left-color: var(--primary-color);
}
/* 响应式设计 */
@media (max-width: 768px) {
.container {
padding: 0 var(--spacing-sm);
}
.season-title {
font-size: 2rem;
}
.season-subtitle {
font-size: 1rem;
}
.season-info {
grid-template-columns: 1fr;
gap: var(--spacing-md);
}
.season-header {
padding: var(--spacing-xl) 0;
}
.season-content {
padding: var(--spacing-xl) 0;
}
.seasons-grid {
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
}
.category-tabs {
justify-content: center;
}
.action-buttons {
flex-direction: column;
align-items: center;
}
}
@media (max-width: 480px) {
.season-title {
font-size: 1.5rem;
}
.season-card {
padding: var(--spacing-md);
}
.season-poetry {
padding: var(--spacing-md);
font-size: 1rem;
}
.color-swatch {
width: 40px;
height: 40px;
}
.seasons-grid {
grid-template-columns: 1fr;
}
}
src/hooks/useTheme.ts
/**
* 主题管理Hook
* 提供主题切换、应用和状态管理功能
*/
import { useState, useEffect, useCallback } from 'react';
import { SeasonData, getSeasonById, getRandomSeason, getCurrentSeason } from '@/data/seasons';
export interface ThemeState {
currentSeason: SeasonData;
isLoading: boolean;
error: string | null;
}
export const useTheme = () => {
const [themeState, setThemeState] = useState<ThemeState>(() => {
// 优先从全局变量获取节气数据(节气专用页面)
const globalSeasonData = (window as any).__SEASON_DATA__;
if (globalSeasonData) {
return {
currentSeason: globalSeasonData,
isLoading: false,
error: null
};
}
// 从URL参数获取初始主题
const urlParams = new URLSearchParams(window.location.search);
const themeFromUrl = urlParams.get('theme');
if (themeFromUrl) {
const season = getSeasonById(themeFromUrl);
if (season) {
return {
currentSeason: season,
isLoading: false,
error: null
};
}
}
// 从localStorage获取
const savedTheme = localStorage.getItem('currentSeasonTheme');
if (savedTheme) {
const season = getSeasonById(savedTheme);
if (season) {
return {
currentSeason: season,
isLoading: false,
error: null
};
}
}
// 默认使用当前节气
return {
currentSeason: getCurrentSeason(),
isLoading: false,
error: null
};
});
// 应用主题到DOM
const applyTheme = useCallback((season: SeasonData) => {
const root = document.documentElement;
const { theme } = season;
// 更新CSS变量
root.style.setProperty('--primary-color', theme.primaryColor);
root.style.setProperty('--secondary-color', theme.secondaryColor);
root.style.setProperty('--background-color', theme.backgroundColor);
root.style.setProperty('--text-color', theme.textColor);
root.style.setProperty('--accent-color', theme.accentColor);
root.style.setProperty('--gradient', theme.gradient);
// 更新自定义样式
if (season.customStyle) {
if (season.customStyle.fontFamily) {
root.style.setProperty('--font-family', season.customStyle.fontFamily);
}
if (season.customStyle.borderRadius) {
root.style.setProperty('--border-radius-md', season.customStyle.borderRadius);
}
}
// 更新页面标题和meta标签
document.title = `${season.name} - 二十四节气主题页面`;
const descriptionMeta = document.querySelector('meta[name="description"]');
if (descriptionMeta) {
descriptionMeta.setAttribute('content', `${season.name} - ${season.description}`);
}
// 更新body类名
document.body.className = `theme-${season.id} theme-transition`;
// 保存到localStorage
localStorage.setItem('currentSeasonTheme', season.id);
}, []);
// 切换主题
const setTheme = useCallback((seasonId: string) => {
const season = getSeasonById(seasonId);
if (!season) {
setThemeState(prev => ({
...prev,
error: `主题 ${seasonId} 不存在`
}));
return;
}
setThemeState(prev => ({
...prev,
currentSeason: season,
error: null
}));
applyTheme(season);
}, [applyTheme]);
// 随机切换主题
const setRandomTheme = useCallback(() => {
const randomSeason = getRandomSeason();
setTheme(randomSeason.id);
}, [setTheme]);
// 初始化时应用主题
useEffect(() => {
applyTheme(themeState.currentSeason);
}, [applyTheme, themeState.currentSeason]);
// 监听URL变化
useEffect(() => {
const handleUrlChange = () => {
const urlParams = new URLSearchParams(window.location.search);
const themeFromUrl = urlParams.get('theme');
if (themeFromUrl && themeFromUrl !== themeState.currentSeason.id) {
setTheme(themeFromUrl);
}
};
window.addEventListener('popstate', handleUrlChange);
return () => window.removeEventListener('popstate', handleUrlChange);
}, [setTheme, themeState.currentSeason.id]);
return {
...themeState,
setTheme,
setRandomTheme,
applyTheme
};
};
src/data/seasons.ts(较长,完整收录)
/**
* 二十四节气配置数据
* 使用TypeScript类型定义,提供更好的类型安全
*/
export interface SeasonTheme {
primaryColor: string;
secondaryColor: string;
backgroundColor: string;
textColor: string;
accentColor: string;
gradient: string;
}
export interface SeasonImages {
background?: string;
icon?: string;
pattern?: string;
}
export interface SeasonCustomStyle {
fontFamily?: string;
borderRadius?: string;
animation?: string;
}
export interface SeasonData {
id: string;
name: string;
englishName: string;
date: string;
description: string;
theme: SeasonTheme;
images?: SeasonImages;
poetry: string;
customStyle?: SeasonCustomStyle;
category: 'spring' | 'summer' | 'autumn' | 'winter';
order: number;
}
export const SEASONS: SeasonData[] = [
// 春季
{
id: 'lichun',
name: '立春',
englishName: 'Beginning of Spring',
date: '2024-02-04',
description: '立春是二十四节气中的第一个节气,标志着春天的开始。',
theme: {
primaryColor: '#4CAF50',
secondaryColor: '#8BC34A',
backgroundColor: '#F1F8E9',
textColor: '#2E7D32',
accentColor: '#C8E6C9',
gradient: 'linear-gradient(135deg, #4CAF50 0%, #8BC34A 100%)'
},
poetry: '立春一日,百草回芽。',
customStyle: {
fontFamily: 'serif',
borderRadius: '8px',
animation: 'springBounce'
},
category: 'spring',
order: 1
},
{
id: 'yushui',
name: '雨水',
englishName: 'Rain Water',
date: '2024-02-19',
description: '雨水节气,降雨开始,雨量渐增。',
theme: {
primaryColor: '#2196F3',
secondaryColor: '#64B5F6',
backgroundColor: '#E3F2FD',
textColor: '#1565C0',
accentColor: '#BBDEFB',
gradient: 'linear-gradient(135deg, #2196F3 0%, #64B5F6 100%)'
},
poetry: '雨水时节,万物复苏。',
customStyle: {
fontFamily: 'serif',
borderRadius: '12px',
animation: 'rainDrop'
},
category: 'spring',
order: 2
},
{
id: 'jingzhe',
name: '惊蛰',
englishName: 'Awakening of Insects',
date: '2024-03-05',
description: '惊蛰时节,春雷始鸣,惊醒蛰伏于地下冬眠的昆虫。',
theme: {
primaryColor: '#FF9800',
secondaryColor: '#FFB74D',
backgroundColor: '#FFF3E0',
textColor: '#E65100',
accentColor: '#FFE0B2',
gradient: 'linear-gradient(135deg, #FF9800 0%, #FFB74D 100%)'
},
poetry: '惊蛰雷动,万物复苏。',
customStyle: {
fontFamily: 'serif',
borderRadius: '6px',
animation: 'thunderShake'
},
category: 'spring',
order: 3
},
{
id: 'chunfen',
name: '春分',
englishName: 'Spring Equinox',
date: '2024-03-20',
description: '春分时节,昼夜平分,春暖花开。',
theme: {
primaryColor: '#E91E63',
secondaryColor: '#F48FB1',
backgroundColor: '#FCE4EC',
textColor: '#AD1457',
accentColor: '#F8BBD9',
gradient: 'linear-gradient(135deg, #E91E63 0%, #F48FB1 100%)'
},
poetry: '春分时节,百花齐放。',
customStyle: {
fontFamily: 'serif',
borderRadius: '10px',
animation: 'flowerBloom'
},
category: 'spring',
order: 4
},
{
id: 'qingming',
name: '清明',
englishName: 'Pure Brightness',
date: '2024-04-04',
description: '清明时节,天气清和,草木茂盛。',
theme: {
primaryColor: '#9C27B0',
secondaryColor: '#BA68C8',
backgroundColor: '#F3E5F5',
textColor: '#6A1B9A',
accentColor: '#E1BEE7',
gradient: 'linear-gradient(135deg, #9C27B0 0%, #BA68C8 100%)'
},
poetry: '清明时节雨纷纷,路上行人欲断魂。',
customStyle: {
fontFamily: 'serif',
borderRadius: '8px',
animation: 'gentleRain'
},
category: 'spring',
order: 5
},
{
id: 'guyu',
name: '谷雨',
englishName: 'Grain Rain',
date: '2024-04-19',
description: '谷雨时节,雨生百谷,春意盎然。',
theme: {
primaryColor: '#673AB7',
secondaryColor: '#9575CD',
backgroundColor: '#EDE7F6',
textColor: '#4527A0',
accentColor: '#D1C4E9',
gradient: 'linear-gradient(135deg, #673AB7 0%, #9575CD 100%)'
},
poetry: '谷雨时节,万物生长。',
customStyle: {
fontFamily: 'serif',
borderRadius: '12px',
animation: 'grainGrow'
},
category: 'spring',
order: 6
},
// 夏季
{
id: 'lixia',
name: '立夏',
englishName: 'Beginning of Summer',
date: '2024-05-05',
description: '立夏时节,夏季开始,万物繁茂。',
theme: {
primaryColor: '#F44336',
secondaryColor: '#EF5350',
backgroundColor: '#FFEBEE',
textColor: '#C62828',
accentColor: '#FFCDD2',
gradient: 'linear-gradient(135deg, #F44336 0%, #EF5350 100%)'
},
poetry: '立夏时节,绿树成荫。',
customStyle: {
fontFamily: 'serif',
borderRadius: '6px',
animation: 'summerHeat'
},
category: 'summer',
order: 7
},
{
id: 'xiaoman',
name: '小满',
englishName: 'Grain Buds',
date: '2024-05-20',
description: '小满时节,麦类等夏熟作物籽粒开始饱满。',
theme: {
primaryColor: '#FF5722',
secondaryColor: '#FF8A65',
backgroundColor: '#FBE9E7',
textColor: '#D84315',
accentColor: '#FFCCBC',
gradient: 'linear-gradient(135deg, #FF5722 0%, #FF8A65 100%)'
},
poetry: '小满时节,麦穗渐满。',
customStyle: {
fontFamily: 'serif',
borderRadius: '10px',
animation: 'grainRipen'
},
category: 'summer',
order: 8
},
{
id: 'mangzhong',
name: '芒种',
englishName: 'Grain in Ear',
date: '2024-06-05',
description: '芒种时节,有芒的麦子快收,有芒的稻子可种。',
theme: {
primaryColor: '#795548',
secondaryColor: '#A1887F',
backgroundColor: '#EFEBE9',
textColor: '#4E342E',
accentColor: '#D7CCC8',
gradient: 'linear-gradient(135deg, #795548 0%, #A1887F 100%)'
},
poetry: '芒种时节,农事繁忙。',
customStyle: {
fontFamily: 'serif',
borderRadius: '8px',
animation: 'harvestWork'
},
category: 'summer',
order: 9
},
{
id: 'xiazhi',
name: '夏至',
englishName: 'Summer Solstice',
date: '2024-06-21',
description: '夏至时节,白昼最长,夜晚最短。',
theme: {
primaryColor: '#FFC107',
secondaryColor: '#FFD54F',
backgroundColor: '#FFFDE7',
textColor: '#F57F17',
accentColor: '#FFF9C4',
gradient: 'linear-gradient(135deg, #FFC107 0%, #FFD54F 100%)'
},
poetry: '夏至时节,日长夜短。',
customStyle: {
fontFamily: 'serif',
borderRadius: '12px',
animation: 'sunShine'
},
category: 'summer',
order: 10
},
{
id: 'xiaoshu',
name: '小暑',
englishName: 'Minor Heat',
date: '2024-07-06',
description: '小暑时节,天气开始炎热,但还不是最热的时候。',
theme: {
primaryColor: '#FF9800',
secondaryColor: '#FFB74D',
backgroundColor: '#FFF3E0',
textColor: '#E65100',
accentColor: '#FFE0B2',
gradient: 'linear-gradient(135deg, #FF9800 0%, #FFB74D 100%)'
},
poetry: '小暑时节,热浪初起。',
customStyle: {
fontFamily: 'serif',
borderRadius: '6px',
animation: 'heatWave'
},
category: 'summer',
order: 11
},
{
id: 'dashu',
name: '大暑',
englishName: 'Major Heat',
date: '2024-07-22',
description: '大暑时节,一年中最热的时期。',
theme: {
primaryColor: '#F44336',
secondaryColor: '#EF5350',
backgroundColor: '#FFEBEE',
textColor: '#C62828',
accentColor: '#FFCDD2',
gradient: 'linear-gradient(135deg, #F44336 0%, #EF5350 100%)'
},
poetry: '大暑时节,酷热难当。',
customStyle: {
fontFamily: 'serif',
borderRadius: '10px',
animation: 'intenseHeat'
},
category: 'summer',
order: 12
},
// 秋季
{
id: 'liqiu',
name: '立秋',
englishName: 'Beginning of Autumn',
date: '2024-08-07',
description: '立秋时节,秋季开始,暑去凉来。',
theme: {
primaryColor: '#607D8B',
secondaryColor: '#90A4AE',
backgroundColor: '#ECEFF1',
textColor: '#37474F',
accentColor: '#CFD8DC',
gradient: 'linear-gradient(135deg, #607D8B 0%, #90A4AE 100%)'
},
poetry: '立秋时节,天高气爽。',
customStyle: {
fontFamily: 'serif',
borderRadius: '8px',
animation: 'autumnBreeze'
},
category: 'autumn',
order: 13
},
{
id: 'chushu',
name: '处暑',
englishName: 'End of Heat',
date: '2024-08-22',
description: '处暑时节,暑气渐消,秋意渐浓。',
theme: {
primaryColor: '#3F51B5',
secondaryColor: '#7986CB',
backgroundColor: '#E8EAF6',
textColor: '#283593',
accentColor: '#C5CAE9',
gradient: 'linear-gradient(135deg, #3F51B5 0%, #7986CB 100%)'
},
poetry: '处暑时节,暑气渐消。',
customStyle: {
fontFamily: 'serif',
borderRadius: '12px',
animation: 'coolDown'
},
category: 'autumn',
order: 14
},
{
id: 'bailu',
name: '白露',
englishName: 'White Dew',
date: '2024-09-07',
description: '白露时节,天气转凉,露凝而白。',
theme: {
primaryColor: '#00BCD4',
secondaryColor: '#4DD0E1',
backgroundColor: '#E0F2F1',
textColor: '#00695C',
accentColor: '#B2DFDB',
gradient: 'linear-gradient(135deg, #00BCD4 0%, #4DD0E1 100%)'
},
poetry: '白露时节,露凝而白。',
customStyle: {
fontFamily: 'serif',
borderRadius: '6px',
animation: 'dewForm'
},
category: 'autumn',
order: 15
},
{
id: 'qiufen',
name: '秋分',
englishName: 'Autumn Equinox',
date: '2024-09-22',
description: '秋分时节,昼夜平分,秋高气爽。',
theme: {
primaryColor: '#009688',
secondaryColor: '#4DB6AC',
backgroundColor: '#E0F2F1',
textColor: '#004D40',
accentColor: '#B2DFDB',
gradient: 'linear-gradient(135deg, #009688 0%, #4DB6AC 100%)'
},
poetry: '秋分时节,昼夜平分。',
customStyle: {
fontFamily: 'serif',
borderRadius: '10px',
animation: 'balance'
},
category: 'autumn',
order: 16
},
{
id: 'hanlu',
name: '寒露',
englishName: 'Cold Dew',
date: '2024-10-08',
description: '寒露时节,露气寒冷,将凝结也。',
theme: {
primaryColor: '#795548',
secondaryColor: '#A1887F',
backgroundColor: '#EFEBE9',
textColor: '#4E342E',
accentColor: '#D7CCC8',
gradient: 'linear-gradient(135deg, #795548 0%, #A1887F 100%)'
},
poetry: '寒露时节,露气寒冷。',
customStyle: {
fontFamily: 'serif',
borderRadius: '8px',
animation: 'coldDew'
},
category: 'autumn',
order: 17
},
{
id: 'shuangjiang',
name: '霜降',
englishName: 'Frost\'s Descent',
date: '2024-10-23',
description: '霜降时节,天气渐冷,开始有霜。',
theme: {
primaryColor: '#607D8B',
secondaryColor: '#90A4AE',
backgroundColor: '#ECEFF1',
textColor: '#37474F',
accentColor: '#CFD8DC',
gradient: 'linear-gradient(135deg, #607D8B 0%, #90A4AE 100%)'
},
poetry: '霜降时节,霜花满地。',
customStyle: {
fontFamily: 'serif',
borderRadius: '12px',
animation: 'frostForm'
},
category: 'autumn',
order: 18
},
// 冬季
{
id: 'lidong',
name: '立冬',
englishName: 'Beginning of Winter',
date: '2024-11-07',
description: '立冬时节,冬季开始,万物收藏。',
theme: {
primaryColor: '#3F51B5',
secondaryColor: '#7986CB',
backgroundColor: '#E8EAF6',
textColor: '#283593',
accentColor: '#C5CAE9',
gradient: 'linear-gradient(135deg, #3F51B5 0%, #7986CB 100%)'
},
poetry: '立冬时节,万物收藏。',
customStyle: {
fontFamily: 'serif',
borderRadius: '6px',
animation: 'winterStart'
},
category: 'winter',
order: 19
},
{
id: 'xiaoxue',
name: '小雪',
englishName: 'Minor Snow',
date: '2024-11-22',
description: '小雪时节,开始下雪,雪量不大。',
theme: {
primaryColor: '#E1F5FE',
secondaryColor: '#B3E5FC',
backgroundColor: '#F3F9FF',
textColor: '#01579B',
accentColor: '#E1F5FE',
gradient: 'linear-gradient(135deg, #E1F5FE 0%, #B3E5FC 100%)'
},
poetry: '小雪时节,雪花纷飞。',
customStyle: {
fontFamily: 'serif',
borderRadius: '10px',
animation: 'snowFall'
},
category: 'winter',
order: 20
},
{
id: 'daxue',
name: '大雪',
englishName: 'Major Snow',
date: '2024-12-07',
description: '大雪时节,雪量增大,地面积雪。',
theme: {
primaryColor: '#FFFFFF',
secondaryColor: '#F5F5F5',
backgroundColor: '#FAFAFA',
textColor: '#424242',
accentColor: '#EEEEEE',
gradient: 'linear-gradient(135deg, #FFFFFF 0%, #F5F5F5 100%)'
},
poetry: '大雪时节,银装素裹。',
customStyle: {
fontFamily: 'serif',
borderRadius: '8px',
animation: 'heavySnow'
},
category: 'winter',
order: 21
},
{
id: 'dongzhi',
name: '冬至',
englishName: 'Winter Solstice',
date: '2024-12-21',
description: '冬至时节,白昼最短,夜晚最长。',
theme: {
primaryColor: '#212121',
secondaryColor: '#424242',
backgroundColor: '#F5F5F5',
textColor: '#000000',
accentColor: '#E0E0E0',
gradient: 'linear-gradient(135deg, #212121 0%, #424242 100%)'
},
poetry: '冬至时节,日短夜长。',
customStyle: {
fontFamily: 'serif',
borderRadius: '12px',
animation: 'longNight'
},
category: 'winter',
order: 22
},
{
id: 'xiaohan',
name: '小寒',
englishName: 'Minor Cold',
date: '2025-01-05',
description: '小寒时节,天气寒冷,但还不是最冷的时候。',
theme: {
primaryColor: '#37474F',
secondaryColor: '#546E7A',
backgroundColor: '#ECEFF1',
textColor: '#263238',
accentColor: '#CFD8DC',
gradient: 'linear-gradient(135deg, #37474F 0%, #546E7A 100%)'
},
poetry: '小寒时节,寒气逼人。',
customStyle: {
fontFamily: 'serif',
borderRadius: '6px',
animation: 'coldWind'
},
category: 'winter',
order: 23
},
{
id: 'dahan',
name: '大寒',
englishName: 'Major Cold',
date: '2025-01-20',
description: '大寒时节,一年中最冷的时期。',
theme: {
primaryColor: '#263238',
secondaryColor: '#37474F',
backgroundColor: '#ECEFF1',
textColor: '#000000',
accentColor: '#CFD8DC',
gradient: 'linear-gradient(135deg, #263238 0%, #37474F 100%)'
},
poetry: '大寒时节,天寒地冻。',
customStyle: {
fontFamily: 'serif',
borderRadius: '10px',
animation: 'freeze'
},
category: 'winter',
order: 24
}
];
// 工具函数
export const getSeasonById = (id: string): SeasonData | undefined => {
return SEASONS.find(season => season.id === id);
};
export const getSeasonsByCategory = (category: SeasonData['category']): SeasonData[] => {
return SEASONS.filter(season => season.category === category);
};
export const getRandomSeason = (): SeasonData => {
const randomIndex = Math.floor(Math.random() * SEASONS.length);
return SEASONS[randomIndex];
};
export const getCurrentSeason = (): SeasonData => {
const now = new Date();
const currentMonth = now.getMonth() + 1;
const currentDay = now.getDate();
// 简化的当前节气判断逻辑
if (currentMonth >= 2 && currentMonth <= 4) {
return getSeasonsByCategory('spring')[0];
} else if (currentMonth >= 5 && currentMonth <= 7) {
return getSeasonsByCategory('summer')[0];
} else if (currentMonth >= 8 && currentMonth <= 10) {
return getSeasonsByCategory('autumn')[0];
} else {
return getSeasonsByCategory('winter')[0];
}
};
src/components/ThemeProvider.tsx
/**
* 主题提供者组件
* 为整个应用提供主题上下文
*/
import React, { createContext, useContext, ReactNode } from 'react';
import { useTheme, ThemeState } from '@/hooks/useTheme';
import { SeasonData } from '@/data/seasons';
interface ThemeContextType extends ThemeState {
setTheme: (seasonId: string) => void;
setRandomTheme: () => void;
applyTheme: (season: SeasonData) => void;
}
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
interface ThemeProviderProps {
children: ReactNode;
}
export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children }) => {
const theme = useTheme();
return (
<ThemeContext.Provider value={theme}>
{children}
</ThemeContext.Provider>
);
};
export const useThemeContext = (): ThemeContextType => {
const context = useContext(ThemeContext);
if (context === undefined) {
throw new Error('useThemeContext must be used within a ThemeProvider');
}
return context;
};
src/components/SeasonPage.tsx
/**
* 节气页面组件
* 展示单个节气的详细信息
*/
import React, { useEffect } from 'react';
import { useThemeContext } from './ThemeProvider';
import { SeasonCard } from './SeasonCard';
import { ColorPalette } from './ColorPalette';
import { ActionButtons } from './ActionButtons';
interface SeasonPageProps {
seasonId?: string;
}
export const SeasonPage: React.FC<SeasonPageProps> = ({ seasonId }) => {
const { currentSeason, setTheme, setRandomTheme, error } = useThemeContext();
// 如果指定了seasonId且与当前主题不同,则切换主题
useEffect(() => {
if (seasonId && seasonId !== currentSeason.id) {
setTheme(seasonId);
}
}, [seasonId, currentSeason.id, setTheme]);
if (error) {
return (
<div className="error-page">
<div className="container">
<h1>页面加载失败</h1>
<p>{error}</p>
<button
className="btn btn-primary"
onClick={() => window.location.reload()}
>
刷新页面
</button>
</div>
</div>
);
}
return (
<div className="season-page">
{/* 页面头部 */}
<header className="season-header">
<div className="container">
<h1 className="season-title">{currentSeason.name}</h1>
<p className="season-subtitle">{currentSeason.englishName}</p>
<p className="season-date">{currentSeason.date}</p>
</div>
</header>
{/* 页面内容 */}
<main className="season-content">
<div className="container">
{/* 节气信息卡片 */}
<div className="season-info">
<SeasonCard
title="节气简介"
content={currentSeason.description}
/>
<SeasonCard
title="节气日期"
content={currentSeason.date}
/>
<SeasonCard
title="英文名称"
content={currentSeason.englishName}
/>
</div>
{/* 节气诗词 */}
<div className="season-poetry">
<p>"{currentSeason.poetry}"</p>
</div>
{/* 主题色彩 */}
<SeasonCard title="主题色彩">
<ColorPalette theme={currentSeason.theme} />
</SeasonCard>
{/* 操作按钮 */}
<ActionButtons
onRandomTheme={setRandomTheme}
currentSeason={currentSeason}
/>
</div>
</main>
{/* 页面底部 */}
<footer className="season-footer">
<div className="container">
<p>© 2024 二十四节气主题页面 - {currentSeason.name}</p>
</div>
</footer>
</div>
);
};
src/components/SeasonSelector.tsx
/**
* 节气选择器组件
* 用于展示所有节气并支持切换
*/
import React, { useState } from 'react';
import { useThemeContext } from './ThemeProvider';
import { SEASONS, getSeasonsByCategory } from '@/data/seasons';
import { SeasonData } from '@/data/seasons';
export const SeasonSelector: React.FC = () => {
const { currentSeason, setTheme } = useThemeContext();
const [selectedCategory, setSelectedCategory] = useState<string>('all');
const [isOpen, setIsOpen] = useState(false);
const categories = [
{ id: 'all', name: '全部', seasons: SEASONS },
{ id: 'spring', name: '春季', seasons: getSeasonsByCategory('spring') },
{ id: 'summer', name: '夏季', seasons: getSeasonsByCategory('summer') },
{ id: 'autumn', name: '秋季', seasons: getSeasonsByCategory('autumn') },
{ id: 'winter', name: '冬季', seasons: getSeasonsByCategory('winter') }
];
const currentCategory = categories.find(cat => cat.id === selectedCategory);
const displaySeasons = currentCategory?.seasons || SEASONS;
const handleSeasonSelect = (season: SeasonData) => {
setTheme(season.id);
setIsOpen(false);
};
return (
<div className="season-selector">
<div className="selector-header">
<h2>选择节气主题</h2>
<button
className="btn btn-outline"
onClick={() => setIsOpen(!isOpen)}
>
{isOpen ? '收起' : '展开'}
</button>
</div>
{isOpen && (
<div className="selector-content">
{/* 分类选择 */}
<div className="category-tabs">
{categories.map(category => (
<button
key={category.id}
className={`category-tab ${selectedCategory === category.id ? 'active' : ''}`}
onClick={() => setSelectedCategory(category.id)}
>
{category.name}
</button>
))}
</div>
{/* 节气网格 */}
<div className="seasons-grid">
{displaySeasons.map(season => (
<div
key={season.id}
className={`season-item ${currentSeason.id === season.id ? 'active' : ''}`}
onClick={() => handleSeasonSelect(season)}
style={{
'--primary-color': season.theme.primaryColor,
'--secondary-color': season.theme.secondaryColor
} as React.CSSProperties}
>
<div className="season-preview">
<div
className="season-color-bar"
style={{ background: season.theme.gradient }}
/>
<div className="season-info">
<h4>{season.name}</h4>
<p>{season.englishName}</p>
<span className="season-date">{season.date}</span>
</div>
</div>
</div>
))}
</div>
</div>
)}
</div>
);
};
src/components/ColorPalette.tsx
/**
* 色彩调色板组件
*/
import React from 'react';
import { SeasonTheme } from '@/data/seasons';
interface ColorPaletteProps {
theme: SeasonTheme;
}
export const ColorPalette: React.FC<ColorPaletteProps> = ({ theme }) => {
const colors = [
{ name: '主色调', color: theme.primaryColor },
{ name: '辅助色', color: theme.secondaryColor },
{ name: '强调色', color: theme.accentColor }
];
return (
<div className="color-palette">
{colors.map(({ name, color }) => (
<div key={name} className="color-item">
<div
className="color-swatch"
style={{ backgroundColor: color }}
title={`${name}: ${color}`}
/>
<span>{name}</span>
</div>
))}
</div>
);
};
src/components/ActionButtons.tsx
/**
* 操作按钮组件
*/
import React from 'react';
import { SeasonData } from '@/data/seasons';
import { useToast } from './Toast';
interface ActionButtonsProps {
onRandomTheme: () => void;
currentSeason: SeasonData;
}
export const ActionButtons: React.FC<ActionButtonsProps> = ({
onRandomTheme,
currentSeason
}) => {
const { showToast } = useToast();
const handleShare = async () => {
const url = window.location.href;
const title = `${currentSeason.name} - 二十四节气主题页面`;
const text = `来看看这个美丽的${currentSeason.name}主题页面!`;
if (navigator.share) {
try {
await navigator.share({ title, text, url });
// 分享成功
showToast('分享成功!', 'success');
} catch (error: any) {
// 处理分享取消或其他错误
if (error.name === 'AbortError') {
// 用户取消了分享,静默处理
console.log('用户取消了分享');
} else {
// 其他错误,回退到复制链接
console.error('分享失败,回退到复制链接:', error);
fallbackToCopy(url);
}
}
} else {
// 不支持原生分享,直接复制链接
fallbackToCopy(url);
}
};
const fallbackToCopy = async (url: string) => {
try {
await navigator.clipboard.writeText(url);
showToast('链接已复制到剪贴板!', 'success');
} catch (error) {
console.error('复制到剪贴板失败:', error);
// 最后的回退方案:显示链接让用户手动复制
showToast(`请手动复制链接:${url}`, 'info');
}
};
return (
<div className="action-buttons">
<button
className="btn btn-primary"
onClick={onRandomTheme}
title="按空格键也可以随机切换"
>
随机切换主题
</button>
<button
className="btn btn-outline"
onClick={handleShare}
>
分享主题
</button>
</div>
);
};
src/components/SeasonCard.tsx
/**
* 节气信息卡片组件
*/
import React, { ReactNode } from 'react';
interface SeasonCardProps {
title: string;
content?: string;
children?: ReactNode;
}
export const SeasonCard: React.FC<SeasonCardProps> = ({
title,
content,
children
}) => {
return (
<div className="season-card">
<h3>{title}</h3>
{content && <p className="card-content">{content}</p>}
{children}
</div>
);
};
src/components/Toast.tsx
/**
* Toast 提示组件
* 用于显示操作反馈信息
*/
import React, { useState, useEffect } from 'react';
interface ToastProps {
message: string;
type?: 'success' | 'error' | 'info';
duration?: number;
onClose?: () => void;
}
export const Toast: React.FC<ToastProps> = ({
message,
type = 'info',
duration = 3000,
onClose
}) => {
const [isVisible, setIsVisible] = useState(true);
useEffect(() => {
const timer = setTimeout(() => {
setIsVisible(false);
setTimeout(() => onClose?.(), 300); // 等待动画完成
}, duration);
return () => clearTimeout(timer);
}, [duration, onClose]);
const getToastClass = () => {
const baseClass = 'toast';
const typeClass = `toast-${type}`;
const visibilityClass = isVisible ? 'toast-visible' : 'toast-hidden';
return `${baseClass} ${typeClass} ${visibilityClass}`;
};
return (
<div className={getToastClass()}>
<div className="toast-content">
<span className="toast-message">{message}</span>
<button
className="toast-close"
onClick={() => {
setIsVisible(false);
setTimeout(() => onClose?.(), 300);
}}
>
×
</button>
</div>
</div>
);
};
// Toast 管理器 Hook
export const useToast = () => {
const [toasts, setToasts] = useState<Array<{
id: string;
message: string;
type: 'success' | 'error' | 'info';
}>>([]);
const showToast = (message: string, type: 'success' | 'error' | 'info' = 'info') => {
const id = Date.now().toString();
setToasts(prev => [...prev, { id, message, type }]);
};
const removeToast = (id: string) => {
setToasts(prev => prev.filter(toast => toast.id !== id));
};
const ToastContainer: React.FC = () => (
<div className="toast-container">
{toasts.map(toast => (
<Toast
key={toast.id}
message={toast.message}
type={toast.type}
onClose={() => removeToast(toast.id)}
/>
))}
</div>
);
return { showToast, ToastContainer };
};
src/types/global.d.ts
// 全局类型声明:声明节气数据在 window 上的注入形态
// 目的:避免在读取 window.__SEASON_DATA__ 时出现 any,提供类型提示
import type { SeasonData } from '@/data/seasons';
declare global {
interface Window {
__SEASON_DATA__?: SeasonData;
}
}
export {};
常用命令
- 安装依赖:
npm install
- 单入口(SPA)开发(HMR + React Refresh):
npm run dev
访问 http://localhost:3000/
- 多入口(单个节气)开发:
npm run start lichun
将打开 http://localhost:3000/lichun/(可替换 lichun 为其他节气名)
- 构建单个节气(生产):
npm run build jingzhe
产物在 dist/jingzhe/
- 构建所有节气:
npm run build all
产物位于 dist/<season>/
- 产物分析(示例:多入口):
ANALYZE=true npm run build:multi
- 代码质量:
npm run lint
npm run lint:fix
npm run type-check
常见问题(FAQ)
-
访问空白页或 404
- 单入口模式访问根路径
/,多入口模式访问/<season>/ - 开发服务器
historyApiFallback已启用,确保使用上述命令启动
- 单入口模式访问根路径
-
别名导入报错
- 确认
tsconfig.json的baseUrl/paths与webpack的resolve.alias一致
- 确认
-
React Fast Refresh 不生效
- 使用
npm run dev或npm run start <season>,确保浏览器允许 HMR
- 使用
-
Node 版本过低
- 升级到 Node >= 16