first commit
This commit is contained in:
commit
83f3415084
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(npm run dev:*)"
|
||||
],
|
||||
"deny": [],
|
||||
"ask": []
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
node_modules/
|
||||
.env
|
||||
database/*.db
|
||||
database/*.db-shm
|
||||
database/*.db-wal
|
||||
*.log
|
||||
.DS_Store
|
||||
dist/
|
||||
build/
|
||||
coverage/
|
||||
.vscode/
|
||||
.idea/
|
||||
|
|
@ -0,0 +1,305 @@
|
|||
# 📍 Footprint - 打卡定位系统
|
||||
|
||||
一个基于 Node.js + React 的全栈打卡定位应用,支持记录地理位置、心情和备注,并为高级用户提供完整的 API 访问。
|
||||
|
||||
## ✨ 功能特性
|
||||
|
||||
### 基础功能
|
||||
- 🌍 **地理定位打卡** - 使用浏览器 Geolocation API 获取当前位置
|
||||
- 🕐 **时间记录** - 自动记录打卡时间
|
||||
- 😊 **心情记录** - 8种心情选项(开心、平静、难过、生气、疲惫、思考、喜爱、兴奋)
|
||||
- 📝 **备注功能** - 可添加文字备注
|
||||
- 🗺️ **地图展示** - 在地图上可视化所有打卡记录
|
||||
- 📊 **统计信息** - 查看总打卡次数、打卡天数等统计数据
|
||||
|
||||
### 高级功能
|
||||
- 🔐 **OAuth2 认证** - 支持 Google 和 GitHub 登录
|
||||
- ⭐ **高级用户系统** - 免费升级获取 API 访问权限
|
||||
- 🔑 **API Key 管理** - 为高级用户生成专属 API 密钥
|
||||
- 📡 **REST API** - 导出打卡数据(支持 JSON、CSV、GeoJSON 格式)
|
||||
- 📚 **API 文档** - 内置完整的 API 使用文档和示例代码
|
||||
- 🐳 **Docker 支持** - 一键容器化部署
|
||||
|
||||
## 🛠️ 技术栈
|
||||
|
||||
### 后端
|
||||
- **Node.js** + **Express** - Web 框架
|
||||
- **SQLite** (better-sqlite3) - 轻量级数据库
|
||||
- **Passport.js** - OAuth2 认证
|
||||
- **JWT** - Token 认证
|
||||
- **Helmet** - 安全防护
|
||||
- **Morgan** - 日志记录
|
||||
|
||||
### 前端
|
||||
- **React** 18 - UI 框架
|
||||
- **React Router** - 路由管理
|
||||
- **Axios** - HTTP 客户端
|
||||
- **Leaflet** + **React-Leaflet** - 地图组件
|
||||
- **Tailwind CSS** - 样式框架
|
||||
- **date-fns** - 日期处理
|
||||
|
||||
### 部署
|
||||
- **Docker** + **Docker Compose** - 容器化
|
||||
- **Nginx** - 前端静态文件服务
|
||||
|
||||
## 📦 项目结构
|
||||
|
||||
```
|
||||
footprint/
|
||||
├── backend/ # 后端服务
|
||||
│ ├── src/
|
||||
│ │ ├── config/ # 配置文件(数据库、Passport)
|
||||
│ │ ├── controllers/ # 控制器
|
||||
│ │ ├── middleware/ # 中间件(认证)
|
||||
│ │ ├── routes/ # 路由
|
||||
│ │ └── index.js # 入口文件
|
||||
│ ├── database/ # SQLite 数据库文件
|
||||
│ ├── package.json
|
||||
│ ├── Dockerfile
|
||||
│ └── .env.example # 环境变量示例
|
||||
│
|
||||
├── frontend/ # 前端应用
|
||||
│ ├── public/
|
||||
│ ├── src/
|
||||
│ │ ├── components/ # React 组件
|
||||
│ │ ├── pages/ # 页面组件
|
||||
│ │ ├── services/ # API 服务
|
||||
│ │ ├── utils/ # 工具函数(地理定位)
|
||||
│ │ ├── App.js # 主应用
|
||||
│ │ └── index.js # 入口文件
|
||||
│ ├── package.json
|
||||
│ ├── Dockerfile
|
||||
│ └── nginx.conf # Nginx 配置
|
||||
│
|
||||
├── docker-compose.yml # Docker Compose 配置
|
||||
├── .gitignore
|
||||
└── README.md
|
||||
```
|
||||
|
||||
## 🚀 快速开始
|
||||
|
||||
### 前置要求
|
||||
- Node.js 18+
|
||||
- npm 或 yarn
|
||||
- Docker 和 Docker Compose(可选,用于容器化部署)
|
||||
|
||||
### 方式一:本地开发
|
||||
|
||||
#### 1. 克隆项目并安装依赖
|
||||
|
||||
```bash
|
||||
# 安装后端依赖
|
||||
cd backend
|
||||
npm install
|
||||
|
||||
# 安装前端依赖
|
||||
cd ../frontend
|
||||
npm install
|
||||
```
|
||||
|
||||
#### 2. 配置环境变量
|
||||
|
||||
在 `backend` 目录下创建 `.env` 文件:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
编辑 `.env` 文件,配置 OAuth2 凭据:
|
||||
|
||||
```env
|
||||
# Google OAuth2
|
||||
GOOGLE_CLIENT_ID=你的Google客户端ID
|
||||
GOOGLE_CLIENT_SECRET=你的Google客户端密钥
|
||||
|
||||
# GitHub OAuth2
|
||||
GITHUB_CLIENT_ID=你的GitHub客户端ID
|
||||
GITHUB_CLIENT_SECRET=你的GitHub客户端密钥
|
||||
```
|
||||
|
||||
**获取 OAuth2 凭据:**
|
||||
- Google: https://console.cloud.google.com/
|
||||
- GitHub: https://github.com/settings/developers
|
||||
|
||||
#### 3. 初始化数据库
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
npm run init-db
|
||||
```
|
||||
|
||||
#### 4. 启动服务
|
||||
|
||||
```bash
|
||||
# 启动后端(在 backend 目录)
|
||||
npm run dev
|
||||
|
||||
# 启动前端(在 frontend 目录,新终端)
|
||||
cd ../frontend
|
||||
npm start
|
||||
```
|
||||
|
||||
访问:
|
||||
- 前端: http://localhost:3000
|
||||
- 后端: http://localhost:5000
|
||||
|
||||
### 方式二:Docker 部署
|
||||
|
||||
#### 1. 配置环境变量
|
||||
|
||||
在项目根目录创建 `.env` 文件(或修改 docker-compose.yml):
|
||||
|
||||
```env
|
||||
JWT_SECRET=your-jwt-secret-key
|
||||
SESSION_SECRET=your-session-secret
|
||||
GOOGLE_CLIENT_ID=your-google-client-id
|
||||
GOOGLE_CLIENT_SECRET=your-google-client-secret
|
||||
GITHUB_CLIENT_ID=your-github-client-id
|
||||
GITHUB_CLIENT_SECRET=your-github-client-secret
|
||||
```
|
||||
|
||||
#### 2. 构建并启动容器
|
||||
|
||||
```bash
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
#### 3. 查看日志
|
||||
|
||||
```bash
|
||||
docker-compose logs -f
|
||||
```
|
||||
|
||||
#### 4. 停止服务
|
||||
|
||||
```bash
|
||||
docker-compose down
|
||||
```
|
||||
|
||||
访问:
|
||||
- 前端: http://localhost:3000
|
||||
- 后端: http://localhost:5000
|
||||
|
||||
## 📖 API 文档
|
||||
|
||||
### 认证
|
||||
|
||||
所有高级 API 需要在请求头中包含 API Key:
|
||||
|
||||
```
|
||||
X-API-Key: your-api-key
|
||||
```
|
||||
|
||||
### 端点
|
||||
|
||||
#### 1. 获取所有打卡记录
|
||||
|
||||
```
|
||||
GET /api/v1/checkins
|
||||
```
|
||||
|
||||
**查询参数:**
|
||||
- `format` - 返回格式: `json` | `csv` | `geojson` (默认: json)
|
||||
- `start_date` - 开始日期 (可选)
|
||||
- `end_date` - 结束日期 (可选)
|
||||
- `limit` - 返回数量 (默认: 1000)
|
||||
|
||||
**示例:**
|
||||
|
||||
```bash
|
||||
curl -X GET "http://localhost:5000/api/v1/checkins?format=json" \
|
||||
-H "X-API-Key: your-api-key"
|
||||
```
|
||||
|
||||
#### 2. 获取统计信息
|
||||
|
||||
```
|
||||
GET /api/v1/stats
|
||||
```
|
||||
|
||||
**示例:**
|
||||
|
||||
```bash
|
||||
curl -X GET "http://localhost:5000/api/v1/stats" \
|
||||
-H "X-API-Key: your-api-key"
|
||||
```
|
||||
|
||||
### Python 示例
|
||||
|
||||
```python
|
||||
import requests
|
||||
|
||||
API_KEY = "your-api-key"
|
||||
BASE_URL = "http://localhost:5000/api/v1"
|
||||
|
||||
headers = {"X-API-Key": API_KEY}
|
||||
|
||||
# 获取 JSON 格式数据
|
||||
response = requests.get(f"{BASE_URL}/checkins", headers=headers)
|
||||
data = response.json()
|
||||
|
||||
# 下载 CSV
|
||||
response = requests.get(f"{BASE_URL}/checkins?format=csv", headers=headers)
|
||||
with open("checkins.csv", "w") as f:
|
||||
f.write(response.text)
|
||||
|
||||
# 下载 GeoJSON(可导入地图软件)
|
||||
response = requests.get(f"{BASE_URL}/checkins?format=geojson", headers=headers)
|
||||
with open("checkins.geojson", "w") as f:
|
||||
f.write(response.text)
|
||||
```
|
||||
|
||||
### JavaScript 示例
|
||||
|
||||
```javascript
|
||||
const API_KEY = "your-api-key";
|
||||
const BASE_URL = "http://localhost:5000/api/v1";
|
||||
|
||||
// 获取打卡记录
|
||||
fetch(`${BASE_URL}/checkins`, {
|
||||
headers: {
|
||||
'X-API-Key': API_KEY
|
||||
}
|
||||
})
|
||||
.then(res => res.json())
|
||||
.then(data => console.log(data));
|
||||
```
|
||||
|
||||
## 🔒 安全性
|
||||
|
||||
- ✅ Helmet.js 安全头
|
||||
- ✅ CORS 跨域保护
|
||||
- ✅ 速率限制
|
||||
- ✅ JWT Token 认证
|
||||
- ✅ API Key 认证
|
||||
- ✅ OAuth2 第三方登录
|
||||
- ✅ 环境变量管理敏感信息
|
||||
|
||||
## 📱 多客户端支持
|
||||
|
||||
### Web 浏览器
|
||||
直接访问部署的网址即可使用。
|
||||
|
||||
### 移动端
|
||||
由于使用响应式设计,可以在移动浏览器中使用。
|
||||
|
||||
### 第三方应用
|
||||
使用高级用户 API,任何客户端都可以集成:
|
||||
- 移动 App
|
||||
- 桌面应用
|
||||
- 命令行工具
|
||||
- 自动化脚本
|
||||
|
||||
## 🤝 贡献
|
||||
|
||||
欢迎提交 Issue 和 Pull Request!
|
||||
|
||||
## 📄 许可证
|
||||
|
||||
MIT License
|
||||
|
||||
## 🙏 致谢
|
||||
|
||||
- [OpenStreetMap](https://www.openstreetmap.org/) - 地图数据
|
||||
- [Leaflet](https://leafletjs.com/) - 地图库
|
||||
- [Tailwind CSS](https://tailwindcss.com/) - CSS 框架
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
# Server Configuration
|
||||
PORT=5000
|
||||
NODE_ENV=development
|
||||
|
||||
# Database
|
||||
DATABASE_PATH=./database/footprint.db
|
||||
|
||||
# JWT Secret
|
||||
JWT_SECRET=your-super-secret-jwt-key-change-this-in-production
|
||||
|
||||
# Session Secret
|
||||
SESSION_SECRET=your-session-secret-change-this-in-production
|
||||
|
||||
# OAuth2 - Google
|
||||
GOOGLE_CLIENT_ID=your-google-client-id
|
||||
GOOGLE_CLIENT_SECRET=your-google-client-secret
|
||||
GOOGLE_CALLBACK_URL=http://localhost:5000/auth/google/callback
|
||||
|
||||
# OAuth2 - GitHub
|
||||
GITHUB_CLIENT_ID=your-github-client-id
|
||||
GITHUB_CLIENT_SECRET=your-github-client-secret
|
||||
GITHUB_CALLBACK_URL=http://localhost:5000/auth/github/callback
|
||||
|
||||
# Frontend URL
|
||||
FRONTEND_URL=http://localhost:3000
|
||||
|
||||
# API Rate Limiting
|
||||
RATE_LIMIT_WINDOW_MS=900000
|
||||
RATE_LIMIT_MAX_REQUESTS=100
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
# 后端 Dockerfile
|
||||
FROM node:18-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# 复制package文件
|
||||
COPY package*.json ./
|
||||
|
||||
# 安装依赖
|
||||
RUN npm ci --only=production
|
||||
|
||||
# 复制源代码
|
||||
COPY src ./src
|
||||
|
||||
# 创建数据库目录
|
||||
RUN mkdir -p /app/database
|
||||
|
||||
# 暴露端口
|
||||
EXPOSE 5000
|
||||
|
||||
# 启动应用
|
||||
CMD ["node", "src/index.js"]
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,32 @@
|
|||
{
|
||||
"name": "footprint-backend",
|
||||
"version": "1.0.0",
|
||||
"description": "Footprint check-in system backend API",
|
||||
"main": "src/index.js",
|
||||
"scripts": {
|
||||
"start": "node src/index.js",
|
||||
"dev": "nodemon src/index.js",
|
||||
"init-db": "node src/config/initDb.js"
|
||||
},
|
||||
"keywords": ["footprint", "check-in", "geolocation"],
|
||||
"author": "",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"express": "^4.18.2",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.3.1",
|
||||
"better-sqlite3": "^9.2.2",
|
||||
"passport": "^0.7.0",
|
||||
"passport-google-oauth20": "^2.0.0",
|
||||
"passport-github2": "^0.1.12",
|
||||
"express-session": "^1.17.3",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"bcrypt": "^5.1.1",
|
||||
"express-rate-limit": "^7.1.5",
|
||||
"helmet": "^7.1.0",
|
||||
"morgan": "^1.10.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^3.0.2"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
const Database = require('better-sqlite3');
|
||||
const path = require('path');
|
||||
require('dotenv').config();
|
||||
|
||||
const dbPath = process.env.DATABASE_PATH || path.join(__dirname, '../../database/footprint.db');
|
||||
|
||||
// 创建数据库连接
|
||||
const db = new Database(dbPath);
|
||||
db.pragma('journal_mode = WAL');
|
||||
|
||||
// 初始化数据库表
|
||||
const initDb = () => {
|
||||
// 用户表
|
||||
const createUsersTable = `
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
oauth_provider TEXT NOT NULL,
|
||||
oauth_id TEXT NOT NULL,
|
||||
email TEXT,
|
||||
username TEXT NOT NULL,
|
||||
avatar_url TEXT,
|
||||
user_type TEXT DEFAULT 'basic' CHECK(user_type IN ('basic', 'premium')),
|
||||
api_key TEXT UNIQUE,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(oauth_provider, oauth_id)
|
||||
)
|
||||
`;
|
||||
|
||||
// 打卡记录表
|
||||
const createCheckinsTable = `
|
||||
CREATE TABLE IF NOT EXISTS checkins (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
latitude REAL NOT NULL,
|
||||
longitude REAL NOT NULL,
|
||||
location_name TEXT,
|
||||
address TEXT,
|
||||
mood TEXT,
|
||||
note TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
)
|
||||
`;
|
||||
|
||||
// 创建索引以提高查询性能
|
||||
const createIndexes = `
|
||||
CREATE INDEX IF NOT EXISTS idx_checkins_user_id ON checkins(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_checkins_created_at ON checkins(created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_users_api_key ON users(api_key);
|
||||
`;
|
||||
|
||||
try {
|
||||
db.exec(createUsersTable);
|
||||
db.exec(createCheckinsTable);
|
||||
db.exec(createIndexes);
|
||||
console.log('✅ Database initialized successfully');
|
||||
} catch (error) {
|
||||
console.error('❌ Error initializing database:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// 如果直接运行此文件,则初始化数据库
|
||||
if (require.main === module) {
|
||||
initDb();
|
||||
db.close();
|
||||
}
|
||||
|
||||
module.exports = { db, initDb };
|
||||
|
|
@ -0,0 +1,100 @@
|
|||
const passport = require('passport');
|
||||
const GoogleStrategy = require('passport-google-oauth20').Strategy;
|
||||
const GitHubStrategy = require('passport-github2').Strategy;
|
||||
const { db } = require('./initDb');
|
||||
const crypto = require('crypto');
|
||||
|
||||
// 序列化用户
|
||||
passport.serializeUser((user, done) => {
|
||||
done(null, user.id);
|
||||
});
|
||||
|
||||
// 反序列化用户
|
||||
passport.deserializeUser((id, done) => {
|
||||
try {
|
||||
const user = db.prepare('SELECT * FROM users WHERE id = ?').get(id);
|
||||
done(null, user);
|
||||
} catch (error) {
|
||||
done(error, null);
|
||||
}
|
||||
});
|
||||
|
||||
// Google OAuth Strategy
|
||||
if (process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET) {
|
||||
passport.use(
|
||||
new GoogleStrategy(
|
||||
{
|
||||
clientID: process.env.GOOGLE_CLIENT_ID,
|
||||
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
|
||||
callbackURL: process.env.GOOGLE_CALLBACK_URL,
|
||||
},
|
||||
(accessToken, refreshToken, profile, done) => {
|
||||
try {
|
||||
let user = db
|
||||
.prepare('SELECT * FROM users WHERE oauth_provider = ? AND oauth_id = ?')
|
||||
.get('google', profile.id);
|
||||
|
||||
if (!user) {
|
||||
const stmt = db.prepare(`
|
||||
INSERT INTO users (oauth_provider, oauth_id, email, username, avatar_url)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`);
|
||||
const result = stmt.run(
|
||||
'google',
|
||||
profile.id,
|
||||
profile.emails?.[0]?.value,
|
||||
profile.displayName,
|
||||
profile.photos?.[0]?.value
|
||||
);
|
||||
user = db.prepare('SELECT * FROM users WHERE id = ?').get(result.lastInsertRowid);
|
||||
}
|
||||
|
||||
return done(null, user);
|
||||
} catch (error) {
|
||||
return done(error, null);
|
||||
}
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// GitHub OAuth Strategy
|
||||
if (process.env.GITHUB_CLIENT_ID && process.env.GITHUB_CLIENT_SECRET) {
|
||||
passport.use(
|
||||
new GitHubStrategy(
|
||||
{
|
||||
clientID: process.env.GITHUB_CLIENT_ID,
|
||||
clientSecret: process.env.GITHUB_CLIENT_SECRET,
|
||||
callbackURL: process.env.GITHUB_CALLBACK_URL,
|
||||
},
|
||||
(accessToken, refreshToken, profile, done) => {
|
||||
try {
|
||||
let user = db
|
||||
.prepare('SELECT * FROM users WHERE oauth_provider = ? AND oauth_id = ?')
|
||||
.get('github', profile.id);
|
||||
|
||||
if (!user) {
|
||||
const stmt = db.prepare(`
|
||||
INSERT INTO users (oauth_provider, oauth_id, email, username, avatar_url)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`);
|
||||
const result = stmt.run(
|
||||
'github',
|
||||
profile.id,
|
||||
profile.emails?.[0]?.value,
|
||||
profile.username || profile.displayName,
|
||||
profile.photos?.[0]?.value
|
||||
);
|
||||
user = db.prepare('SELECT * FROM users WHERE id = ?').get(result.lastInsertRowid);
|
||||
}
|
||||
|
||||
return done(null, user);
|
||||
} catch (error) {
|
||||
return done(error, null);
|
||||
}
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
module.exports = passport;
|
||||
|
|
@ -0,0 +1,149 @@
|
|||
const { db } = require('../config/initDb');
|
||||
|
||||
// 获取所有打卡记录(API方式,使用API Key认证)
|
||||
const getAllCheckinsApi = (req, res) => {
|
||||
const userId = req.user.id;
|
||||
const { format = 'json', start_date, end_date, limit = 1000 } = req.query;
|
||||
|
||||
try {
|
||||
let query = 'SELECT * FROM checkins WHERE user_id = ?';
|
||||
const params = [userId];
|
||||
|
||||
// 添加日期过滤
|
||||
if (start_date) {
|
||||
query += ' AND created_at >= ?';
|
||||
params.push(start_date);
|
||||
}
|
||||
if (end_date) {
|
||||
query += ' AND created_at <= ?';
|
||||
params.push(end_date);
|
||||
}
|
||||
|
||||
query += ' ORDER BY created_at DESC LIMIT ?';
|
||||
params.push(parseInt(limit));
|
||||
|
||||
const checkins = db.prepare(query).all(...params);
|
||||
|
||||
// 根据格式返回数据
|
||||
if (format === 'csv') {
|
||||
return res.type('text/csv').send(convertToCSV(checkins));
|
||||
} else if (format === 'geojson') {
|
||||
return res.json(convertToGeoJSON(checkins));
|
||||
} else {
|
||||
return res.json({
|
||||
success: true,
|
||||
data: checkins,
|
||||
count: checkins.length,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching check-ins via API:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch check-ins' });
|
||||
}
|
||||
};
|
||||
|
||||
// 转换为CSV格式
|
||||
const convertToCSV = (checkins) => {
|
||||
if (checkins.length === 0) {
|
||||
return 'id,user_id,latitude,longitude,location_name,address,mood,note,created_at\n';
|
||||
}
|
||||
|
||||
const headers = Object.keys(checkins[0]).join(',');
|
||||
const rows = checkins.map((checkin) =>
|
||||
Object.values(checkin)
|
||||
.map((val) => (val === null ? '' : `"${val}"`))
|
||||
.join(',')
|
||||
);
|
||||
|
||||
return headers + '\n' + rows.join('\n');
|
||||
};
|
||||
|
||||
// 转换为GeoJSON格式
|
||||
const convertToGeoJSON = (checkins) => {
|
||||
return {
|
||||
type: 'FeatureCollection',
|
||||
features: checkins.map((checkin) => ({
|
||||
type: 'Feature',
|
||||
geometry: {
|
||||
type: 'Point',
|
||||
coordinates: [checkin.longitude, checkin.latitude],
|
||||
},
|
||||
properties: {
|
||||
id: checkin.id,
|
||||
location_name: checkin.location_name,
|
||||
address: checkin.address,
|
||||
mood: checkin.mood,
|
||||
note: checkin.note,
|
||||
created_at: checkin.created_at,
|
||||
},
|
||||
})),
|
||||
};
|
||||
};
|
||||
|
||||
// 获取统计信息(API方式)
|
||||
const getStatsApi = (req, res) => {
|
||||
const userId = req.user.id;
|
||||
|
||||
try {
|
||||
// 基础统计
|
||||
const basicStats = db
|
||||
.prepare(
|
||||
`
|
||||
SELECT
|
||||
COUNT(*) as total_checkins,
|
||||
COUNT(DISTINCT DATE(created_at)) as total_days,
|
||||
MIN(created_at) as first_checkin,
|
||||
MAX(created_at) as latest_checkin
|
||||
FROM checkins
|
||||
WHERE user_id = ?
|
||||
`
|
||||
)
|
||||
.get(userId);
|
||||
|
||||
// 按心情统计
|
||||
const moodStats = db
|
||||
.prepare(
|
||||
`
|
||||
SELECT mood, COUNT(*) as count
|
||||
FROM checkins
|
||||
WHERE user_id = ? AND mood IS NOT NULL
|
||||
GROUP BY mood
|
||||
ORDER BY count DESC
|
||||
`
|
||||
)
|
||||
.all(userId);
|
||||
|
||||
// 按月份统计
|
||||
const monthlyStats = db
|
||||
.prepare(
|
||||
`
|
||||
SELECT
|
||||
strftime('%Y-%m', created_at) as month,
|
||||
COUNT(*) as count
|
||||
FROM checkins
|
||||
WHERE user_id = ?
|
||||
GROUP BY month
|
||||
ORDER BY month DESC
|
||||
LIMIT 12
|
||||
`
|
||||
)
|
||||
.all(userId);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
basic: basicStats,
|
||||
by_mood: moodStats,
|
||||
by_month: monthlyStats,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching stats via API:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch statistics' });
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
getAllCheckinsApi,
|
||||
getStatsApi,
|
||||
};
|
||||
|
|
@ -0,0 +1,203 @@
|
|||
const { db } = require('../config/initDb');
|
||||
|
||||
// 创建打卡记录
|
||||
const createCheckin = (req, res) => {
|
||||
const { latitude, longitude, location_name, address, mood, note } = req.body;
|
||||
const userId = req.user.id;
|
||||
|
||||
// 验证必填字段
|
||||
if (!latitude || !longitude) {
|
||||
return res.status(400).json({ error: 'Latitude and longitude are required' });
|
||||
}
|
||||
|
||||
// 验证坐标范围
|
||||
if (latitude < -90 || latitude > 90 || longitude < -180 || longitude > 180) {
|
||||
return res.status(400).json({ error: 'Invalid coordinates' });
|
||||
}
|
||||
|
||||
try {
|
||||
const stmt = db.prepare(`
|
||||
INSERT INTO checkins (user_id, latitude, longitude, location_name, address, mood, note)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
const result = stmt.run(userId, latitude, longitude, location_name, address, mood, note);
|
||||
|
||||
const checkin = db
|
||||
.prepare('SELECT * FROM checkins WHERE id = ?')
|
||||
.get(result.lastInsertRowid);
|
||||
|
||||
res.status(201).json({
|
||||
message: 'Check-in created successfully',
|
||||
checkin,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error creating check-in:', error);
|
||||
res.status(500).json({ error: 'Failed to create check-in' });
|
||||
}
|
||||
};
|
||||
|
||||
// 获取用户的所有打卡记录
|
||||
const getUserCheckins = (req, res) => {
|
||||
const userId = req.user.id;
|
||||
const { limit = 50, offset = 0, start_date, end_date } = req.query;
|
||||
|
||||
try {
|
||||
let query = 'SELECT * FROM checkins WHERE user_id = ?';
|
||||
const params = [userId];
|
||||
|
||||
// 添加日期过滤
|
||||
if (start_date) {
|
||||
query += ' AND created_at >= ?';
|
||||
params.push(start_date);
|
||||
}
|
||||
if (end_date) {
|
||||
query += ' AND created_at <= ?';
|
||||
params.push(end_date);
|
||||
}
|
||||
|
||||
query += ' ORDER BY created_at DESC LIMIT ? OFFSET ?';
|
||||
params.push(parseInt(limit), parseInt(offset));
|
||||
|
||||
const checkins = db.prepare(query).all(...params);
|
||||
|
||||
// 获取总数
|
||||
let countQuery = 'SELECT COUNT(*) as total FROM checkins WHERE user_id = ?';
|
||||
const countParams = [userId];
|
||||
if (start_date) {
|
||||
countQuery += ' AND created_at >= ?';
|
||||
countParams.push(start_date);
|
||||
}
|
||||
if (end_date) {
|
||||
countQuery += ' AND created_at <= ?';
|
||||
countParams.push(end_date);
|
||||
}
|
||||
|
||||
const { total } = db.prepare(countQuery).get(...countParams);
|
||||
|
||||
res.json({
|
||||
checkins,
|
||||
pagination: {
|
||||
total,
|
||||
limit: parseInt(limit),
|
||||
offset: parseInt(offset),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching check-ins:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch check-ins' });
|
||||
}
|
||||
};
|
||||
|
||||
// 获取单个打卡记录
|
||||
const getCheckinById = (req, res) => {
|
||||
const { id } = req.params;
|
||||
const userId = req.user.id;
|
||||
|
||||
try {
|
||||
const checkin = db
|
||||
.prepare('SELECT * FROM checkins WHERE id = ? AND user_id = ?')
|
||||
.get(id, userId);
|
||||
|
||||
if (!checkin) {
|
||||
return res.status(404).json({ error: 'Check-in not found' });
|
||||
}
|
||||
|
||||
res.json({ checkin });
|
||||
} catch (error) {
|
||||
console.error('Error fetching check-in:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch check-in' });
|
||||
}
|
||||
};
|
||||
|
||||
// 删除打卡记录
|
||||
const deleteCheckin = (req, res) => {
|
||||
const { id } = req.params;
|
||||
const userId = req.user.id;
|
||||
|
||||
try {
|
||||
const result = db.prepare('DELETE FROM checkins WHERE id = ? AND user_id = ?').run(id, userId);
|
||||
|
||||
if (result.changes === 0) {
|
||||
return res.status(404).json({ error: 'Check-in not found' });
|
||||
}
|
||||
|
||||
res.json({ message: 'Check-in deleted successfully' });
|
||||
} catch (error) {
|
||||
console.error('Error deleting check-in:', error);
|
||||
res.status(500).json({ error: 'Failed to delete check-in' });
|
||||
}
|
||||
};
|
||||
|
||||
// 更新打卡记录
|
||||
const updateCheckin = (req, res) => {
|
||||
const { id } = req.params;
|
||||
const userId = req.user.id;
|
||||
const { mood, note, location_name } = req.body;
|
||||
|
||||
try {
|
||||
// 先检查记录是否存在
|
||||
const checkin = db
|
||||
.prepare('SELECT * FROM checkins WHERE id = ? AND user_id = ?')
|
||||
.get(id, userId);
|
||||
|
||||
if (!checkin) {
|
||||
return res.status(404).json({ error: 'Check-in not found' });
|
||||
}
|
||||
|
||||
const stmt = db.prepare(`
|
||||
UPDATE checkins
|
||||
SET mood = COALESCE(?, mood),
|
||||
note = COALESCE(?, note),
|
||||
location_name = COALESCE(?, location_name)
|
||||
WHERE id = ? AND user_id = ?
|
||||
`);
|
||||
|
||||
stmt.run(mood, note, location_name, id, userId);
|
||||
|
||||
const updated = db.prepare('SELECT * FROM checkins WHERE id = ?').get(id);
|
||||
|
||||
res.json({
|
||||
message: 'Check-in updated successfully',
|
||||
checkin: updated,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error updating check-in:', error);
|
||||
res.status(500).json({ error: 'Failed to update check-in' });
|
||||
}
|
||||
};
|
||||
|
||||
// 获取打卡统计信息
|
||||
const getCheckinStats = (req, res) => {
|
||||
const userId = req.user.id;
|
||||
|
||||
try {
|
||||
const stats = db
|
||||
.prepare(
|
||||
`
|
||||
SELECT
|
||||
COUNT(*) as total_checkins,
|
||||
COUNT(DISTINCT DATE(created_at)) as total_days,
|
||||
MIN(created_at) as first_checkin,
|
||||
MAX(created_at) as latest_checkin
|
||||
FROM checkins
|
||||
WHERE user_id = ?
|
||||
`
|
||||
)
|
||||
.get(userId);
|
||||
|
||||
res.json({ stats });
|
||||
} catch (error) {
|
||||
console.error('Error fetching stats:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch statistics' });
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
createCheckin,
|
||||
getUserCheckins,
|
||||
getCheckinById,
|
||||
deleteCheckin,
|
||||
updateCheckin,
|
||||
getCheckinStats,
|
||||
};
|
||||
|
|
@ -0,0 +1,107 @@
|
|||
const { db } = require('../config/initDb');
|
||||
const crypto = require('crypto');
|
||||
const jwt = require('jsonwebtoken');
|
||||
|
||||
// 获取当前用户信息
|
||||
const getCurrentUser = (req, res) => {
|
||||
try {
|
||||
const user = db.prepare('SELECT id, username, email, avatar_url, user_type, api_key, created_at FROM users WHERE id = ?').get(req.user.id);
|
||||
|
||||
if (!user) {
|
||||
return res.status(404).json({ error: 'User not found' });
|
||||
}
|
||||
|
||||
res.json({ user });
|
||||
} catch (error) {
|
||||
console.error('Error fetching user:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch user information' });
|
||||
}
|
||||
};
|
||||
|
||||
// 升级为高级用户
|
||||
const upgradeToPremium = (req, res) => {
|
||||
const userId = req.user.id;
|
||||
|
||||
try {
|
||||
// 生成API密钥
|
||||
const apiKey = 'fp_' + crypto.randomBytes(32).toString('hex');
|
||||
|
||||
const stmt = db.prepare(`
|
||||
UPDATE users
|
||||
SET user_type = 'premium', api_key = ?
|
||||
WHERE id = ?
|
||||
`);
|
||||
|
||||
stmt.run(apiKey, userId);
|
||||
|
||||
const updatedUser = db
|
||||
.prepare('SELECT id, username, email, avatar_url, user_type, api_key, created_at FROM users WHERE id = ?')
|
||||
.get(userId);
|
||||
|
||||
res.json({
|
||||
message: 'Successfully upgraded to premium',
|
||||
user: updatedUser,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error upgrading user:', error);
|
||||
res.status(500).json({ error: 'Failed to upgrade account' });
|
||||
}
|
||||
};
|
||||
|
||||
// 重新生成API密钥
|
||||
const regenerateApiKey = (req, res) => {
|
||||
const userId = req.user.id;
|
||||
|
||||
try {
|
||||
const user = db.prepare('SELECT user_type FROM users WHERE id = ?').get(userId);
|
||||
|
||||
if (user.user_type !== 'premium') {
|
||||
return res.status(403).json({ error: 'Premium account required' });
|
||||
}
|
||||
|
||||
const apiKey = 'fp_' + crypto.randomBytes(32).toString('hex');
|
||||
|
||||
db.prepare('UPDATE users SET api_key = ? WHERE id = ?').run(apiKey, userId);
|
||||
|
||||
res.json({
|
||||
message: 'API key regenerated successfully',
|
||||
api_key: apiKey,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error regenerating API key:', error);
|
||||
res.status(500).json({ error: 'Failed to regenerate API key' });
|
||||
}
|
||||
};
|
||||
|
||||
// 生成JWT Token(用于前端API调用)
|
||||
const generateToken = (req, res) => {
|
||||
const user = req.user;
|
||||
|
||||
const token = jwt.sign(
|
||||
{
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
user_type: user.user_type,
|
||||
},
|
||||
process.env.JWT_SECRET,
|
||||
{ expiresIn: '7d' }
|
||||
);
|
||||
|
||||
res.json({
|
||||
token,
|
||||
user: {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
avatar_url: user.avatar_url,
|
||||
user_type: user.user_type,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
getCurrentUser,
|
||||
upgradeToPremium,
|
||||
regenerateApiKey,
|
||||
generateToken,
|
||||
};
|
||||
|
|
@ -0,0 +1,100 @@
|
|||
require('dotenv').config();
|
||||
const express = require('express');
|
||||
const cors = require('cors');
|
||||
const helmet = require('helmet');
|
||||
const morgan = require('morgan');
|
||||
const session = require('express-session');
|
||||
const rateLimit = require('express-rate-limit');
|
||||
const passport = require('./config/passport');
|
||||
const { initDb } = require('./config/initDb');
|
||||
|
||||
// 初始化数据库
|
||||
initDb();
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 5000;
|
||||
|
||||
// 中间件
|
||||
app.use(helmet());
|
||||
app.use(morgan('combined'));
|
||||
app.use(
|
||||
cors({
|
||||
origin: process.env.FRONTEND_URL || 'http://localhost:3000',
|
||||
credentials: true,
|
||||
})
|
||||
);
|
||||
app.use(express.json());
|
||||
app.use(express.urlencoded({ extended: true }));
|
||||
|
||||
// Session配置
|
||||
app.use(
|
||||
session({
|
||||
secret: process.env.SESSION_SECRET || 'your-session-secret',
|
||||
resave: false,
|
||||
saveUninitialized: false,
|
||||
cookie: {
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
maxAge: 24 * 60 * 60 * 1000, // 24 hours
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
// Passport初始化
|
||||
app.use(passport.initialize());
|
||||
app.use(passport.session());
|
||||
|
||||
// 速率限制
|
||||
const limiter = rateLimit({
|
||||
windowMs: parseInt(process.env.RATE_LIMIT_WINDOW_MS) || 15 * 60 * 1000,
|
||||
max: parseInt(process.env.RATE_LIMIT_MAX_REQUESTS) || 100,
|
||||
message: 'Too many requests from this IP, please try again later.',
|
||||
});
|
||||
app.use('/api/', limiter);
|
||||
|
||||
// 路由
|
||||
app.use('/auth', require('./routes/auth'));
|
||||
app.use('/api/checkins', require('./routes/checkins'));
|
||||
app.use('/api/users', require('./routes/users'));
|
||||
app.use('/api/v1', require('./routes/api')); // 高级用户API
|
||||
|
||||
// 健康检查
|
||||
app.get('/health', (req, res) => {
|
||||
res.json({ status: 'ok', timestamp: new Date().toISOString() });
|
||||
});
|
||||
|
||||
// 根路由
|
||||
app.get('/', (req, res) => {
|
||||
res.json({
|
||||
message: 'Footprint API Server',
|
||||
version: '1.0.0',
|
||||
endpoints: {
|
||||
auth: '/auth',
|
||||
checkins: '/api/checkins',
|
||||
users: '/api/users',
|
||||
premium_api: '/api/v1',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// 404处理
|
||||
app.use((req, res) => {
|
||||
res.status(404).json({ error: 'Route not found' });
|
||||
});
|
||||
|
||||
// 错误处理
|
||||
app.use((err, req, res, next) => {
|
||||
console.error(err.stack);
|
||||
res.status(500).json({
|
||||
error: 'Something went wrong!',
|
||||
message: process.env.NODE_ENV === 'development' ? err.message : undefined,
|
||||
});
|
||||
});
|
||||
|
||||
// 启动服务器
|
||||
app.listen(PORT, () => {
|
||||
console.log(`✅ Server is running on port ${PORT}`);
|
||||
console.log(`📍 Environment: ${process.env.NODE_ENV || 'development'}`);
|
||||
console.log(`🔗 Frontend URL: ${process.env.FRONTEND_URL || 'http://localhost:3000'}`);
|
||||
});
|
||||
|
||||
module.exports = app;
|
||||
|
|
@ -0,0 +1,74 @@
|
|||
const jwt = require('jsonwebtoken');
|
||||
const { db } = require('../config/initDb');
|
||||
|
||||
// 验证JWT Token
|
||||
const authenticateToken = (req, res, next) => {
|
||||
const authHeader = req.headers['authorization'];
|
||||
const token = authHeader && authHeader.split(' ')[1]; // Bearer TOKEN
|
||||
|
||||
if (!token) {
|
||||
return res.status(401).json({ error: 'Access token required' });
|
||||
}
|
||||
|
||||
jwt.verify(token, process.env.JWT_SECRET, (err, user) => {
|
||||
if (err) {
|
||||
return res.status(403).json({ error: 'Invalid or expired token' });
|
||||
}
|
||||
req.user = user;
|
||||
next();
|
||||
});
|
||||
};
|
||||
|
||||
// 验证是否登录(使用session)
|
||||
const isAuthenticated = (req, res, next) => {
|
||||
if (req.isAuthenticated()) {
|
||||
return next();
|
||||
}
|
||||
res.status(401).json({ error: 'Not authenticated' });
|
||||
};
|
||||
|
||||
// 验证API Key(用于高级用户API访问)
|
||||
const authenticateApiKey = (req, res, next) => {
|
||||
const apiKey = req.headers['x-api-key'];
|
||||
|
||||
if (!apiKey) {
|
||||
return res.status(401).json({ error: 'API key required' });
|
||||
}
|
||||
|
||||
try {
|
||||
const user = db.prepare('SELECT * FROM users WHERE api_key = ?').get(apiKey);
|
||||
|
||||
if (!user) {
|
||||
return res.status(403).json({ error: 'Invalid API key' });
|
||||
}
|
||||
|
||||
if (user.user_type !== 'premium') {
|
||||
return res.status(403).json({ error: 'Premium account required for API access' });
|
||||
}
|
||||
|
||||
req.user = user;
|
||||
next();
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
};
|
||||
|
||||
// 验证高级用户权限
|
||||
const isPremiumUser = (req, res, next) => {
|
||||
if (!req.user) {
|
||||
return res.status(401).json({ error: 'Authentication required' });
|
||||
}
|
||||
|
||||
if (req.user.user_type !== 'premium') {
|
||||
return res.status(403).json({ error: 'Premium account required' });
|
||||
}
|
||||
|
||||
next();
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
authenticateToken,
|
||||
isAuthenticated,
|
||||
authenticateApiKey,
|
||||
isPremiumUser,
|
||||
};
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { getAllCheckinsApi, getStatsApi } = require('../controllers/apiController');
|
||||
const { authenticateApiKey } = require('../middleware/auth');
|
||||
|
||||
// 所有API路由都需要API Key认证
|
||||
router.use(authenticateApiKey);
|
||||
|
||||
// 获取所有打卡记录
|
||||
// 支持查询参数: format=json|csv|geojson, start_date, end_date, limit
|
||||
router.get('/checkins', getAllCheckinsApi);
|
||||
|
||||
// 获取统计信息
|
||||
router.get('/stats', getStatsApi);
|
||||
|
||||
module.exports = router;
|
||||
|
|
@ -0,0 +1,78 @@
|
|||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const passport = require('../config/passport');
|
||||
const jwt = require('jsonwebtoken');
|
||||
|
||||
// Google OAuth
|
||||
router.get('/google', passport.authenticate('google', { scope: ['profile', 'email'] }));
|
||||
|
||||
router.get(
|
||||
'/google/callback',
|
||||
passport.authenticate('google', { failureRedirect: `${process.env.FRONTEND_URL}/login` }),
|
||||
(req, res) => {
|
||||
const token = jwt.sign(
|
||||
{
|
||||
id: req.user.id,
|
||||
username: req.user.username,
|
||||
user_type: req.user.user_type,
|
||||
},
|
||||
process.env.JWT_SECRET,
|
||||
{ expiresIn: '7d' }
|
||||
);
|
||||
|
||||
// 重定向到前端,并在URL中带上token
|
||||
res.redirect(`${process.env.FRONTEND_URL}/?token=${token}`);
|
||||
}
|
||||
);
|
||||
|
||||
// GitHub OAuth
|
||||
router.get('/github', passport.authenticate('github', { scope: ['user:email'] }));
|
||||
|
||||
router.get(
|
||||
'/github/callback',
|
||||
passport.authenticate('github', { failureRedirect: `${process.env.FRONTEND_URL}/login` }),
|
||||
(req, res) => {
|
||||
const token = jwt.sign(
|
||||
{
|
||||
id: req.user.id,
|
||||
username: req.user.username,
|
||||
user_type: req.user.user_type,
|
||||
},
|
||||
process.env.JWT_SECRET,
|
||||
{ expiresIn: '7d' }
|
||||
);
|
||||
|
||||
// 重定向到前端,并在URL中带上token
|
||||
res.redirect(`${process.env.FRONTEND_URL}/?token=${token}`);
|
||||
}
|
||||
);
|
||||
|
||||
// 登出
|
||||
router.post('/logout', (req, res) => {
|
||||
req.logout((err) => {
|
||||
if (err) {
|
||||
return res.status(500).json({ error: 'Failed to logout' });
|
||||
}
|
||||
res.json({ message: 'Logged out successfully' });
|
||||
});
|
||||
});
|
||||
|
||||
// 检查认证状态
|
||||
router.get('/status', (req, res) => {
|
||||
if (req.isAuthenticated()) {
|
||||
res.json({
|
||||
authenticated: true,
|
||||
user: {
|
||||
id: req.user.id,
|
||||
username: req.user.username,
|
||||
email: req.user.email,
|
||||
avatar_url: req.user.avatar_url,
|
||||
user_type: req.user.user_type,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
res.json({ authenticated: false });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const {
|
||||
createCheckin,
|
||||
getUserCheckins,
|
||||
getCheckinById,
|
||||
deleteCheckin,
|
||||
updateCheckin,
|
||||
getCheckinStats,
|
||||
} = require('../controllers/checkinController');
|
||||
const { authenticateToken } = require('../middleware/auth');
|
||||
|
||||
// 所有路由都需要认证
|
||||
router.use(authenticateToken);
|
||||
|
||||
// 创建打卡
|
||||
router.post('/', createCheckin);
|
||||
|
||||
// 获取用户所有打卡记录
|
||||
router.get('/', getUserCheckins);
|
||||
|
||||
// 获取统计信息
|
||||
router.get('/stats', getCheckinStats);
|
||||
|
||||
// 获取单个打卡记录
|
||||
router.get('/:id', getCheckinById);
|
||||
|
||||
// 更新打卡记录
|
||||
router.put('/:id', updateCheckin);
|
||||
|
||||
// 删除打卡记录
|
||||
router.delete('/:id', deleteCheckin);
|
||||
|
||||
module.exports = router;
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const {
|
||||
getCurrentUser,
|
||||
upgradeToPremium,
|
||||
regenerateApiKey,
|
||||
} = require('../controllers/userController');
|
||||
const { authenticateToken, isPremiumUser } = require('../middleware/auth');
|
||||
|
||||
// 获取当前用户信息
|
||||
router.get('/me', authenticateToken, getCurrentUser);
|
||||
|
||||
// 升级为高级用户
|
||||
router.post('/upgrade', authenticateToken, upgradeToPremium);
|
||||
|
||||
// 重新生成API密钥(仅高级用户)
|
||||
router.post('/regenerate-api-key', authenticateToken, isPremiumUser, regenerateApiKey);
|
||||
|
||||
module.exports = router;
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
version: '3.8'
|
||||
|
||||
services:
|
||||
# 后端服务
|
||||
backend:
|
||||
build:
|
||||
context: ./backend
|
||||
dockerfile: Dockerfile
|
||||
container_name: footprint-backend
|
||||
ports:
|
||||
- "5000:5000"
|
||||
environment:
|
||||
- PORT=5000
|
||||
- NODE_ENV=production
|
||||
- DATABASE_PATH=/app/database/footprint.db
|
||||
- JWT_SECRET=${JWT_SECRET:-your-super-secret-jwt-key}
|
||||
- SESSION_SECRET=${SESSION_SECRET:-your-session-secret}
|
||||
- GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID}
|
||||
- GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET}
|
||||
- GOOGLE_CALLBACK_URL=${GOOGLE_CALLBACK_URL:-http://localhost:5000/auth/google/callback}
|
||||
- GITHUB_CLIENT_ID=${GITHUB_CLIENT_ID}
|
||||
- GITHUB_CLIENT_SECRET=${GITHUB_CLIENT_SECRET}
|
||||
- GITHUB_CALLBACK_URL=${GITHUB_CALLBACK_URL:-http://localhost:5000/auth/github/callback}
|
||||
- FRONTEND_URL=${FRONTEND_URL:-http://localhost:3000}
|
||||
volumes:
|
||||
- ./backend/database:/app/database
|
||||
- ./backend/.env:/app/.env
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- footprint-network
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:5000/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
|
||||
# 前端服务
|
||||
frontend:
|
||||
build:
|
||||
context: ./frontend
|
||||
dockerfile: Dockerfile
|
||||
container_name: footprint-frontend
|
||||
ports:
|
||||
- "3000:80"
|
||||
environment:
|
||||
- REACT_APP_API_URL=http://localhost:5000
|
||||
depends_on:
|
||||
- backend
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- footprint-network
|
||||
|
||||
networks:
|
||||
footprint-network:
|
||||
driver: bridge
|
||||
|
||||
volumes:
|
||||
db-data:
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
# 前端 Dockerfile - 多阶段构建
|
||||
FROM node:18-alpine as build
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# 复制package文件
|
||||
COPY package*.json ./
|
||||
|
||||
# 安装依赖
|
||||
RUN npm ci
|
||||
|
||||
# 复制源代码
|
||||
COPY . .
|
||||
|
||||
# 构建应用
|
||||
RUN npm run build
|
||||
|
||||
# 生产阶段 - 使用nginx服务静态文件
|
||||
FROM nginx:alpine
|
||||
|
||||
# 复制构建产物
|
||||
COPY --from=build /app/build /usr/share/nginx/html
|
||||
|
||||
# 复制nginx配置
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
# 暴露端口
|
||||
EXPOSE 80
|
||||
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Gzip压缩
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_min_length 1024;
|
||||
gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml+rss application/json;
|
||||
|
||||
# React Router支持
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# 静态资源缓存
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
# 安全头
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,45 @@
|
|||
{
|
||||
"name": "footprint-frontend",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-router-dom": "^6.20.1",
|
||||
"axios": "^1.6.2",
|
||||
"leaflet": "^1.9.4",
|
||||
"react-leaflet": "^4.2.1",
|
||||
"date-fns": "^3.0.0",
|
||||
"@heroicons/react": "^2.1.1",
|
||||
"http-proxy-middleware": "^2.0.6"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "react-scripts start",
|
||||
"build": "react-scripts build",
|
||||
"test": "react-scripts test",
|
||||
"eject": "react-scripts eject"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"extends": [
|
||||
"react-app"
|
||||
]
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
">0.2%",
|
||||
"not dead",
|
||||
"not op_mini all"
|
||||
],
|
||||
"development": [
|
||||
"last 1 chrome version",
|
||||
"last 1 firefox version",
|
||||
"last 1 safari version"
|
||||
]
|
||||
},
|
||||
"devDependencies": {
|
||||
"react-scripts": "5.0.1",
|
||||
"tailwindcss": "^3.4.0",
|
||||
"autoprefixer": "^10.4.16",
|
||||
"postcss": "^8.4.32"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#000000" />
|
||||
<meta name="description" content="Footprint - 记录你的每一个足迹" />
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
||||
<title>Footprint - 打卡定位系统</title>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>你需要启用 JavaScript 来运行此应用。</noscript>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
|
||||
monospace;
|
||||
}
|
||||
|
||||
/* Leaflet地图样式修复 */
|
||||
.leaflet-container {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Toast动画 */
|
||||
@keyframes slide-in {
|
||||
from {
|
||||
transform: translateX(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.animate-slide-in {
|
||||
animation: slide-in 0.3s ease-out;
|
||||
}
|
||||
|
||||
/* 对话框动画 */
|
||||
@keyframes fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-fade-in {
|
||||
animation: fade-in 0.2s ease-out;
|
||||
}
|
||||
|
|
@ -0,0 +1,124 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import { BrowserRouter as Router, Routes, Route, Navigate, Link } from 'react-router-dom';
|
||||
import LoginPage from './pages/LoginPage';
|
||||
import HomePage from './pages/HomePage';
|
||||
import PremiumPage from './pages/PremiumPage';
|
||||
import { authService } from './services/api';
|
||||
import { ToastProvider } from './components/Toast';
|
||||
import './App.css';
|
||||
|
||||
function App() {
|
||||
const [user, setUser] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [token, setToken] = useState(localStorage.getItem('token'));
|
||||
|
||||
useEffect(() => {
|
||||
// 检查URL中是否有token(OAuth回调后)
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const urlToken = urlParams.get('token');
|
||||
|
||||
if (urlToken) {
|
||||
localStorage.setItem('token', urlToken);
|
||||
setToken(urlToken);
|
||||
// 清除URL中的token参数
|
||||
window.history.replaceState({}, document.title, window.location.pathname);
|
||||
}
|
||||
|
||||
if (token) {
|
||||
loadUser();
|
||||
} else {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [token]);
|
||||
|
||||
const loadUser = async () => {
|
||||
try {
|
||||
const response = await authService.getStatus();
|
||||
if (response.data.authenticated) {
|
||||
setUser(response.data.user);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load user:', error);
|
||||
localStorage.removeItem('token');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
await authService.logout();
|
||||
localStorage.removeItem('token');
|
||||
setToken(null);
|
||||
setUser(null);
|
||||
} catch (error) {
|
||||
console.error('Logout failed:', error);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="inline-block animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
|
||||
<p className="mt-4 text-gray-600">加载中...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ToastProvider>
|
||||
<Router>
|
||||
{user && (
|
||||
<nav className="bg-white shadow-sm border-b">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex justify-between h-16">
|
||||
<div className="flex space-x-8">
|
||||
<Link
|
||||
to="/"
|
||||
className="inline-flex items-center px-1 pt-1 text-sm font-medium text-gray-900 hover:text-blue-600"
|
||||
>
|
||||
首页
|
||||
</Link>
|
||||
<Link
|
||||
to="/premium"
|
||||
className="inline-flex items-center px-1 pt-1 text-sm font-medium text-gray-900 hover:text-blue-600"
|
||||
>
|
||||
{user.user_type === 'premium' ? '⭐ API' : 'API'}
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="text-sm text-gray-700 hover:text-gray-900"
|
||||
>
|
||||
退出登录
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
)}
|
||||
|
||||
<Routes>
|
||||
<Route
|
||||
path="/login"
|
||||
element={user ? <Navigate to="/" /> : <LoginPage />}
|
||||
/>
|
||||
<Route
|
||||
path="/"
|
||||
element={user ? <HomePage user={user} /> : <Navigate to="/login" />}
|
||||
/>
|
||||
<Route
|
||||
path="/premium"
|
||||
element={user ? <PremiumPage user={user} /> : <Navigate to="/login" />}
|
||||
/>
|
||||
<Route path="*" element={<Navigate to="/" />} />
|
||||
</Routes>
|
||||
</Router>
|
||||
</ToastProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
|
|
@ -0,0 +1,174 @@
|
|||
import React, { useState } from 'react';
|
||||
import { checkinService } from '../services/api';
|
||||
import { getCurrentPosition, reverseGeocode } from '../utils/geolocation';
|
||||
import { useToast } from './Toast';
|
||||
|
||||
const CheckinForm = ({ onSuccess }) => {
|
||||
const toast = useToast();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [formData, setFormData] = useState({
|
||||
latitude: '',
|
||||
longitude: '',
|
||||
location_name: '',
|
||||
address: '',
|
||||
mood: '',
|
||||
note: '',
|
||||
});
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const moods = [
|
||||
{ emoji: '😊', label: '开心' },
|
||||
{ emoji: '😌', label: '平静' },
|
||||
{ emoji: '😔', label: '难过' },
|
||||
{ emoji: '😤', label: '生气' },
|
||||
{ emoji: '😴', label: '疲惫' },
|
||||
{ emoji: '🤔', label: '思考' },
|
||||
{ emoji: '😍', label: '喜爱' },
|
||||
{ emoji: '🎉', label: '兴奋' },
|
||||
];
|
||||
|
||||
const handleGetLocation = async () => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const position = await getCurrentPosition();
|
||||
const geocodeResult = await reverseGeocode(position.latitude, position.longitude);
|
||||
|
||||
setFormData({
|
||||
...formData,
|
||||
latitude: position.latitude.toFixed(6),
|
||||
longitude: position.longitude.toFixed(6),
|
||||
location_name: geocodeResult.location_name,
|
||||
address: geocodeResult.address,
|
||||
});
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!formData.latitude || !formData.longitude) {
|
||||
setError('请先获取当前位置');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const response = await checkinService.create({
|
||||
latitude: parseFloat(formData.latitude),
|
||||
longitude: parseFloat(formData.longitude),
|
||||
location_name: formData.location_name,
|
||||
address: formData.address,
|
||||
mood: formData.mood,
|
||||
note: formData.note,
|
||||
});
|
||||
|
||||
// 重置表单
|
||||
setFormData({
|
||||
latitude: '',
|
||||
longitude: '',
|
||||
location_name: '',
|
||||
address: '',
|
||||
mood: '',
|
||||
note: '',
|
||||
});
|
||||
|
||||
onSuccess(response.data.checkin);
|
||||
toast.success('打卡成功!');
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.error || '打卡失败,请重试');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow-sm p-6">
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-6">📍 打卡</h2>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{/* 获取位置按钮 */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleGetLocation}
|
||||
disabled={loading}
|
||||
className="w-full bg-blue-600 text-white px-6 py-3 rounded-lg hover:bg-blue-700 transition-colors disabled:bg-gray-400 disabled:cursor-not-allowed font-medium"
|
||||
>
|
||||
{loading ? '获取位置中...' : '📍 获取当前位置'}
|
||||
</button>
|
||||
|
||||
{/* 位置信息显示 */}
|
||||
{formData.latitude && (
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 space-y-2">
|
||||
<p className="text-sm text-gray-700">
|
||||
<span className="font-semibold">坐标:</span> {formData.latitude}, {formData.longitude}
|
||||
</p>
|
||||
<p className="text-sm text-gray-700">
|
||||
<span className="font-semibold">位置:</span> {formData.location_name}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 truncate">{formData.address}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 心情选择 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">选择心情</label>
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{moods.map((mood) => (
|
||||
<button
|
||||
key={mood.label}
|
||||
type="button"
|
||||
onClick={() => setFormData({ ...formData, mood: mood.label })}
|
||||
className={`p-3 rounded-lg border-2 transition-all ${
|
||||
formData.mood === mood.label
|
||||
? 'border-blue-600 bg-blue-50'
|
||||
: 'border-gray-200 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
<div className="text-2xl text-center">{mood.emoji}</div>
|
||||
<div className="text-xs text-center mt-1 text-gray-600">{mood.label}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 备注 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">备注(可选)</label>
|
||||
<textarea
|
||||
value={formData.note}
|
||||
onChange={(e) => setFormData({ ...formData, note: e.target.value })}
|
||||
rows="3"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
placeholder="记录此刻的想法..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 错误提示 */}
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-3 text-sm text-red-700">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 提交按钮 */}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || !formData.latitude}
|
||||
className="w-full bg-green-600 text-white px-6 py-3 rounded-lg hover:bg-green-700 transition-colors disabled:bg-gray-400 disabled:cursor-not-allowed font-medium"
|
||||
>
|
||||
{loading ? '提交中...' : '✅ 确认打卡'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CheckinForm;
|
||||
|
|
@ -0,0 +1,76 @@
|
|||
import React from 'react';
|
||||
import { format } from 'date-fns';
|
||||
import { zhCN } from 'date-fns/locale';
|
||||
|
||||
const CheckinList = ({ checkins, onUpdate }) => {
|
||||
const getMoodEmoji = (mood) => {
|
||||
const moodMap = {
|
||||
开心: '😊',
|
||||
平静: '😌',
|
||||
难过: '😔',
|
||||
生气: '😤',
|
||||
疲惫: '😴',
|
||||
思考: '🤔',
|
||||
喜爱: '😍',
|
||||
兴奋: '🎉',
|
||||
};
|
||||
return moodMap[mood] || '😊';
|
||||
};
|
||||
|
||||
const formatDate = (dateString) => {
|
||||
try {
|
||||
return format(new Date(dateString), 'yyyy年MM月dd日 HH:mm', { locale: zhCN });
|
||||
} catch {
|
||||
return dateString;
|
||||
}
|
||||
};
|
||||
|
||||
if (checkins.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<div className="text-6xl mb-4">📍</div>
|
||||
<p className="text-gray-600">还没有打卡记录</p>
|
||||
<p className="text-gray-500 text-sm mt-2">点击左侧"获取当前位置"开始你的第一次打卡吧!</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4 max-h-[600px] overflow-y-auto">
|
||||
{checkins.map((checkin) => (
|
||||
<div
|
||||
key={checkin.id}
|
||||
className="border border-gray-200 rounded-lg p-4 hover:shadow-md transition-shadow"
|
||||
>
|
||||
<div className="flex justify-between items-start mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
{checkin.mood && (
|
||||
<span className="text-2xl">{getMoodEmoji(checkin.mood)}</span>
|
||||
)}
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-900">
|
||||
{checkin.location_name || '未知地点'}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500">{formatDate(checkin.created_at)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-gray-600 mb-2">{checkin.address}</p>
|
||||
|
||||
{checkin.note && (
|
||||
<p className="text-sm text-gray-700 bg-gray-50 rounded p-2 mb-2">{checkin.note}</p>
|
||||
)}
|
||||
|
||||
<div className="flex justify-between items-center text-xs text-gray-500">
|
||||
<span>
|
||||
📍 {checkin.latitude.toFixed(4)}, {checkin.longitude.toFixed(4)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CheckinList;
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
import React from 'react';
|
||||
|
||||
const ConfirmDialog = ({ isOpen, onClose, onConfirm, title, message }) => {
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
{/* 背景遮罩 */}
|
||||
<div
|
||||
className="absolute inset-0 bg-black bg-opacity-50"
|
||||
onClick={onClose}
|
||||
></div>
|
||||
|
||||
{/* 对话框 */}
|
||||
<div className="relative bg-white rounded-lg shadow-xl p-6 max-w-md w-full mx-4 animate-fade-in">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">{title}</h3>
|
||||
<p className="text-gray-600 mb-6">{message}</p>
|
||||
|
||||
<div className="flex gap-3 justify-end">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-gray-700 bg-gray-100 rounded-lg hover:bg-gray-200 transition-colors"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
onConfirm();
|
||||
onClose();
|
||||
}}
|
||||
className="px-4 py-2 text-white bg-blue-600 rounded-lg hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
确认
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConfirmDialog;
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
import React from 'react';
|
||||
import { MapContainer, TileLayer, Marker, Popup } from 'react-leaflet';
|
||||
import 'leaflet/dist/leaflet.css';
|
||||
import L from 'leaflet';
|
||||
|
||||
// 修复 leaflet 默认图标问题
|
||||
delete L.Icon.Default.prototype._getIconUrl;
|
||||
L.Icon.Default.mergeOptions({
|
||||
iconRetinaUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-icon-2x.png',
|
||||
iconUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-icon.png',
|
||||
shadowUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-shadow.png',
|
||||
});
|
||||
|
||||
const MapView = ({ checkins }) => {
|
||||
if (checkins.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-gray-600">还没有打卡记录</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 计算地图中心点(所有打卡的平均位置)
|
||||
const center = checkins.reduce(
|
||||
(acc, checkin) => {
|
||||
acc.lat += checkin.latitude;
|
||||
acc.lng += checkin.longitude;
|
||||
return acc;
|
||||
},
|
||||
{ lat: 0, lng: 0 }
|
||||
);
|
||||
|
||||
center.lat /= checkins.length;
|
||||
center.lng /= checkins.length;
|
||||
|
||||
return (
|
||||
<div className="h-[600px] rounded-lg overflow-hidden border border-gray-200">
|
||||
<MapContainer
|
||||
center={[center.lat, center.lng]}
|
||||
zoom={13}
|
||||
style={{ height: '100%', width: '100%' }}
|
||||
>
|
||||
<TileLayer
|
||||
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
|
||||
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||
/>
|
||||
|
||||
{checkins.map((checkin) => (
|
||||
<Marker key={checkin.id} position={[checkin.latitude, checkin.longitude]}>
|
||||
<Popup>
|
||||
<div className="p-2">
|
||||
<h3 className="font-semibold mb-1">{checkin.location_name || '未知地点'}</h3>
|
||||
{checkin.mood && <p className="text-sm mb-1">心情: {checkin.mood}</p>}
|
||||
{checkin.note && <p className="text-sm text-gray-600 mb-1">{checkin.note}</p>}
|
||||
<p className="text-xs text-gray-500">
|
||||
{new Date(checkin.created_at).toLocaleString('zh-CN')}
|
||||
</p>
|
||||
</div>
|
||||
</Popup>
|
||||
</Marker>
|
||||
))}
|
||||
</MapContainer>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MapView;
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
import React, { createContext, useContext, useState } from 'react';
|
||||
|
||||
const ToastContext = createContext();
|
||||
|
||||
export const useToast = () => {
|
||||
const context = useContext(ToastContext);
|
||||
if (!context) {
|
||||
throw new Error('useToast must be used within ToastProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
export const ToastProvider = ({ children }) => {
|
||||
const [toasts, setToasts] = useState([]);
|
||||
|
||||
const showToast = (message, type = 'info') => {
|
||||
const id = Date.now();
|
||||
setToasts((prev) => [...prev, { id, message, type }]);
|
||||
|
||||
setTimeout(() => {
|
||||
setToasts((prev) => prev.filter((toast) => toast.id !== id));
|
||||
}, 3000);
|
||||
};
|
||||
|
||||
const success = (message) => showToast(message, 'success');
|
||||
const error = (message) => showToast(message, 'error');
|
||||
const info = (message) => showToast(message, 'info');
|
||||
const warning = (message) => showToast(message, 'warning');
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={{ success, error, info, warning }}>
|
||||
{children}
|
||||
<div className="fixed top-4 right-4 z-50 space-y-2">
|
||||
{toasts.map((toast) => (
|
||||
<div
|
||||
key={toast.id}
|
||||
className={`px-6 py-3 rounded-lg shadow-lg text-white min-w-[300px] animate-slide-in ${
|
||||
toast.type === 'success'
|
||||
? 'bg-green-500'
|
||||
: toast.type === 'error'
|
||||
? 'bg-red-500'
|
||||
: toast.type === 'warning'
|
||||
? 'bg-yellow-500'
|
||||
: 'bg-blue-500'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{toast.type === 'success' && <span>✅</span>}
|
||||
{toast.type === 'error' && <span>❌</span>}
|
||||
{toast.type === 'warning' && <span>⚠️</span>}
|
||||
{toast.type === 'info' && <span>ℹ️</span>}
|
||||
<span>{toast.message}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ToastContext.Provider>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
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;
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import './index.css';
|
||||
import App from './App';
|
||||
|
||||
const root = ReactDOM.createRoot(document.getElementById('root'));
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
|
|
@ -0,0 +1,126 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import { checkinService } from '../services/api';
|
||||
import CheckinForm from '../components/CheckinForm';
|
||||
import CheckinList from '../components/CheckinList';
|
||||
import MapView from '../components/MapView';
|
||||
|
||||
const HomePage = ({ user }) => {
|
||||
const [checkins, setCheckins] = useState([]);
|
||||
const [stats, setStats] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [view, setView] = useState('list'); // 'list' or 'map'
|
||||
|
||||
useEffect(() => {
|
||||
loadCheckins();
|
||||
loadStats();
|
||||
}, []);
|
||||
|
||||
const loadCheckins = async () => {
|
||||
try {
|
||||
const response = await checkinService.getAll({ limit: 100 });
|
||||
setCheckins(response.data.checkins);
|
||||
} catch (error) {
|
||||
console.error('加载打卡记录失败:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadStats = async () => {
|
||||
try {
|
||||
const response = await checkinService.getStats();
|
||||
setStats(response.data.stats);
|
||||
} catch (error) {
|
||||
console.error('加载统计信息失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCheckinSuccess = (newCheckin) => {
|
||||
setCheckins([newCheckin, ...checkins]);
|
||||
loadStats();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
{/* 头部 */}
|
||||
<header className="bg-white shadow-sm">
|
||||
<div className="max-w-7xl mx-auto px-4 py-4 sm:px-6 lg:px-8">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">📍 Footprint</h1>
|
||||
<p className="text-sm text-gray-600">你好, {user.username}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
{stats && (
|
||||
<div className="text-right text-sm">
|
||||
<p className="text-gray-600">
|
||||
总打卡: <span className="font-semibold">{stats.total_checkins}</span>
|
||||
</p>
|
||||
<p className="text-gray-600">
|
||||
打卡天数: <span className="font-semibold">{stats.total_days}</span>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* 主内容 */}
|
||||
<main className="max-w-7xl mx-auto px-4 py-8 sm:px-6 lg:px-8">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||
{/* 左侧:打卡表单 */}
|
||||
<div className="lg:col-span-1">
|
||||
<CheckinForm onSuccess={handleCheckinSuccess} />
|
||||
</div>
|
||||
|
||||
{/* 右侧:打卡记录或地图 */}
|
||||
<div className="lg:col-span-2">
|
||||
<div className="bg-white rounded-lg shadow-sm p-6">
|
||||
{/* 视图切换 */}
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h2 className="text-xl font-semibold text-gray-900">我的足迹</h2>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setView('list')}
|
||||
className={`px-4 py-2 rounded-lg transition-colors ${
|
||||
view === 'list'
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
列表
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setView('map')}
|
||||
className={`px-4 py-2 rounded-lg transition-colors ${
|
||||
view === 'map'
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
地图
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 内容区域 */}
|
||||
{loading ? (
|
||||
<div className="text-center py-12">
|
||||
<div className="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
||||
<p className="mt-2 text-gray-600">加载中...</p>
|
||||
</div>
|
||||
) : view === 'list' ? (
|
||||
<CheckinList checkins={checkins} onUpdate={loadCheckins} />
|
||||
) : (
|
||||
<MapView checkins={checkins} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default HomePage;
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
import React from 'react';
|
||||
import { authService } from '../services/api';
|
||||
|
||||
const LoginPage = () => {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 flex items-center justify-center px-4">
|
||||
<div className="max-w-md w-full bg-white rounded-2xl shadow-xl p-8">
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-4xl font-bold text-gray-800 mb-2">📍 Footprint</h1>
|
||||
<p className="text-gray-600">记录你的每一个足迹</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<button
|
||||
onClick={authService.loginWithGoogle}
|
||||
className="w-full flex items-center justify-center gap-3 bg-white border-2 border-gray-300 text-gray-700 px-6 py-3 rounded-lg hover:bg-gray-50 hover:border-gray-400 transition-all font-medium"
|
||||
>
|
||||
<svg className="w-5 h-5" viewBox="0 0 24 24">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
|
||||
/>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
|
||||
/>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
|
||||
/>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
|
||||
/>
|
||||
</svg>
|
||||
使用 Google 登录
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={authService.loginWithGithub}
|
||||
className="w-full flex items-center justify-center gap-3 bg-gray-800 text-white px-6 py-3 rounded-lg hover:bg-gray-900 transition-all font-medium"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
使用 GitHub 登录
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 text-center text-sm text-gray-500">
|
||||
<p>登录即表示您同意我们的服务条款和隐私政策</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoginPage;
|
||||
|
|
@ -0,0 +1,262 @@
|
|||
import React, { useState } from 'react';
|
||||
import { userService } from '../services/api';
|
||||
import { useToast } from '../components/Toast';
|
||||
import ConfirmDialog from '../components/ConfirmDialog';
|
||||
|
||||
const PremiumPage = ({ user }) => {
|
||||
const toast = useToast();
|
||||
const [userData, setUserData] = useState(user);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [confirmDialog, setConfirmDialog] = useState({ isOpen: false, type: '', message: '' });
|
||||
|
||||
const handleUpgrade = async () => {
|
||||
setConfirmDialog({
|
||||
isOpen: true,
|
||||
type: 'upgrade',
|
||||
title: '升级确认',
|
||||
message: '确定要升级为高级用户吗?升级后可获得API访问权限。',
|
||||
onConfirm: async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await userService.upgradeToPremium();
|
||||
setUserData(response.data.user);
|
||||
toast.success('升级成功!');
|
||||
} catch (error) {
|
||||
toast.error('升级失败: ' + (error.response?.data?.error || error.message));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleRegenerateKey = async () => {
|
||||
setConfirmDialog({
|
||||
isOpen: true,
|
||||
type: 'regenerate',
|
||||
title: '重新生成API密钥',
|
||||
message: '确定要重新生成API密钥吗?旧的密钥将立即失效。',
|
||||
onConfirm: async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await userService.regenerateApiKey();
|
||||
setUserData({ ...userData, api_key: response.data.api_key });
|
||||
toast.success('API密钥已重新生成');
|
||||
} catch (error) {
|
||||
toast.error('生成失败: ' + (error.response?.data?.error || error.message));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const copyToClipboard = (text) => {
|
||||
navigator.clipboard.writeText(text);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
const isPremium = userData.user_type === 'premium';
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 py-8">
|
||||
<div className="max-w-4xl mx-auto px-4">
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-8">高级用户 API</h1>
|
||||
|
||||
{!isPremium ? (
|
||||
<div className="bg-white rounded-lg shadow-sm p-8 text-center">
|
||||
<div className="text-6xl mb-4">⭐</div>
|
||||
<h2 className="text-2xl font-semibold mb-4">升级到高级用户</h2>
|
||||
<p className="text-gray-600 mb-6">
|
||||
升级后可以获得API访问权限,导出所有打卡数据,支持JSON、CSV、GeoJSON等多种格式
|
||||
</p>
|
||||
<button
|
||||
onClick={handleUpgrade}
|
||||
disabled={loading}
|
||||
className="bg-blue-600 text-white px-8 py-3 rounded-lg hover:bg-blue-700 transition-colors disabled:bg-gray-400"
|
||||
>
|
||||
{loading ? '处理中...' : '免费升级'}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{/* API密钥卡片 */}
|
||||
<div className="bg-white rounded-lg shadow-sm p-6">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h2 className="text-xl font-semibold">API 密钥</h2>
|
||||
<button
|
||||
onClick={handleRegenerateKey}
|
||||
disabled={loading}
|
||||
className="text-sm text-blue-600 hover:text-blue-700"
|
||||
>
|
||||
重新生成
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-50 rounded-lg p-4 font-mono text-sm break-all relative">
|
||||
{userData.api_key}
|
||||
<button
|
||||
onClick={() => copyToClipboard(userData.api_key)}
|
||||
className="absolute top-2 right-2 bg-blue-600 text-white px-3 py-1 rounded text-xs hover:bg-blue-700"
|
||||
>
|
||||
{copied ? '已复制!' : '复制'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-red-600 mt-2">⚠️ 请妥善保管您的API密钥,不要泄露给他人</p>
|
||||
</div>
|
||||
|
||||
{/* API文档 */}
|
||||
<div className="bg-white rounded-lg shadow-sm p-6">
|
||||
<h2 className="text-xl font-semibold mb-4">API 使用文档</h2>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* 端点1: 获取所有打卡记录 */}
|
||||
<div>
|
||||
<h3 className="font-semibold text-lg mb-2">1. 获取所有打卡记录</h3>
|
||||
<div className="bg-gray-50 rounded-lg p-4">
|
||||
<p className="text-sm text-gray-600 mb-2">端点:</p>
|
||||
<code className="block bg-gray-800 text-green-400 p-3 rounded mb-3">
|
||||
GET /api/v1/checkins
|
||||
</code>
|
||||
|
||||
<p className="text-sm text-gray-600 mb-2">请求头:</p>
|
||||
<code className="block bg-gray-800 text-green-400 p-3 rounded mb-3">
|
||||
X-API-Key: {userData.api_key}
|
||||
</code>
|
||||
|
||||
<p className="text-sm text-gray-600 mb-2">查询参数:</p>
|
||||
<ul className="text-sm space-y-1 mb-3">
|
||||
<li>
|
||||
• <code>format</code>: 返回格式 (json | csv | geojson),默认: json
|
||||
</li>
|
||||
<li>
|
||||
• <code>start_date</code>: 开始日期 (可选)
|
||||
</li>
|
||||
<li>
|
||||
• <code>end_date</code>: 结束日期 (可选)
|
||||
</li>
|
||||
<li>
|
||||
• <code>limit</code>: 返回数量限制,默认: 1000
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<p className="text-sm text-gray-600 mb-2">示例 (curl):</p>
|
||||
<code className="block bg-gray-800 text-green-400 p-3 rounded whitespace-pre-wrap break-all">
|
||||
{`curl -X GET "http://localhost:5000/api/v1/checkins?format=json" \\
|
||||
-H "X-API-Key: ${userData.api_key}"`}
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 端点2: 获取统计信息 */}
|
||||
<div>
|
||||
<h3 className="font-semibold text-lg mb-2">2. 获取统计信息</h3>
|
||||
<div className="bg-gray-50 rounded-lg p-4">
|
||||
<p className="text-sm text-gray-600 mb-2">端点:</p>
|
||||
<code className="block bg-gray-800 text-green-400 p-3 rounded mb-3">
|
||||
GET /api/v1/stats
|
||||
</code>
|
||||
|
||||
<p className="text-sm text-gray-600 mb-2">请求头:</p>
|
||||
<code className="block bg-gray-800 text-green-400 p-3 rounded mb-3">
|
||||
X-API-Key: {userData.api_key}
|
||||
</code>
|
||||
|
||||
<p className="text-sm text-gray-600 mb-2">示例 (curl):</p>
|
||||
<code className="block bg-gray-800 text-green-400 p-3 rounded whitespace-pre-wrap break-all">
|
||||
{`curl -X GET "http://localhost:5000/api/v1/stats" \\
|
||||
-H "X-API-Key: ${userData.api_key}"`}
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Python示例 */}
|
||||
<div>
|
||||
<h3 className="font-semibold text-lg mb-2">3. Python 示例代码</h3>
|
||||
<div className="bg-gray-50 rounded-lg p-4">
|
||||
<code className="block bg-gray-800 text-green-400 p-3 rounded whitespace-pre-wrap text-sm">
|
||||
{`import requests
|
||||
|
||||
API_KEY = "${userData.api_key}"
|
||||
BASE_URL = "http://localhost:5000/api/v1"
|
||||
|
||||
headers = {"X-API-Key": API_KEY}
|
||||
|
||||
# 获取JSON格式的打卡记录
|
||||
response = requests.get(f"{BASE_URL}/checkins", headers=headers)
|
||||
data = response.json()
|
||||
print(f"总记录数: {data['count']}")
|
||||
|
||||
# 获取CSV格式
|
||||
response = requests.get(
|
||||
f"{BASE_URL}/checkins?format=csv",
|
||||
headers=headers
|
||||
)
|
||||
with open("checkins.csv", "w") as f:
|
||||
f.write(response.text)
|
||||
|
||||
# 获取GeoJSON格式(可导入地图软件)
|
||||
response = requests.get(
|
||||
f"{BASE_URL}/checkins?format=geojson",
|
||||
headers=headers
|
||||
)
|
||||
with open("checkins.geojson", "w") as f:
|
||||
f.write(response.text)`}
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* JavaScript示例 */}
|
||||
<div>
|
||||
<h3 className="font-semibold text-lg mb-2">4. JavaScript 示例代码</h3>
|
||||
<div className="bg-gray-50 rounded-lg p-4">
|
||||
<code className="block bg-gray-800 text-green-400 p-3 rounded whitespace-pre-wrap text-sm">
|
||||
{`const API_KEY = "${userData.api_key}";
|
||||
const BASE_URL = "http://localhost:5000/api/v1";
|
||||
|
||||
// 获取打卡记录
|
||||
fetch(\`\${BASE_URL}/checkins\`, {
|
||||
headers: {
|
||||
'X-API-Key': API_KEY
|
||||
}
|
||||
})
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
console.log('打卡记录:', data);
|
||||
});
|
||||
|
||||
// 获取统计信息
|
||||
fetch(\`\${BASE_URL}/stats\`, {
|
||||
headers: {
|
||||
'X-API-Key': API_KEY
|
||||
}
|
||||
})
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
console.log('统计信息:', data);
|
||||
});`}
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 确认对话框 */}
|
||||
<ConfirmDialog
|
||||
isOpen={confirmDialog.isOpen}
|
||||
onClose={() => setConfirmDialog({ ...confirmDialog, isOpen: false })}
|
||||
onConfirm={confirmDialog.onConfirm}
|
||||
title={confirmDialog.title}
|
||||
message={confirmDialog.message}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PremiumPage;
|
||||
|
|
@ -0,0 +1,82 @@
|
|||
import axios from 'axios';
|
||||
|
||||
const API_BASE_URL = process.env.REACT_APP_API_URL || 'http://localhost:5000';
|
||||
|
||||
// 创建axios实例
|
||||
const api = axios.create({
|
||||
baseURL: API_BASE_URL,
|
||||
withCredentials: true,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
// 请求拦截器 - 添加token
|
||||
api.interceptors.request.use(
|
||||
(config) => {
|
||||
const token = localStorage.getItem('token');
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
},
|
||||
(error) => {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
// 响应拦截器 - 处理错误
|
||||
api.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
if (error.response?.status === 401) {
|
||||
localStorage.removeItem('token');
|
||||
window.location.href = '/login';
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
// 认证相关
|
||||
export const authService = {
|
||||
loginWithGoogle: () => {
|
||||
window.location.href = `${API_BASE_URL}/auth/google`;
|
||||
},
|
||||
loginWithGithub: () => {
|
||||
window.location.href = `${API_BASE_URL}/auth/github`;
|
||||
},
|
||||
logout: () => api.post('/auth/logout'),
|
||||
getStatus: () => api.get('/auth/status'),
|
||||
};
|
||||
|
||||
// 用户相关
|
||||
export const userService = {
|
||||
getCurrentUser: () => api.get('/api/users/me'),
|
||||
upgradeToPremium: () => api.post('/api/users/upgrade'),
|
||||
regenerateApiKey: () => api.post('/api/users/regenerate-api-key'),
|
||||
};
|
||||
|
||||
// 打卡相关
|
||||
export const checkinService = {
|
||||
create: (data) => api.post('/api/checkins', data),
|
||||
getAll: (params) => api.get('/api/checkins', { params }),
|
||||
getById: (id) => api.get(`/api/checkins/${id}`),
|
||||
update: (id, data) => api.put(`/api/checkins/${id}`, data),
|
||||
delete: (id) => api.delete(`/api/checkins/${id}`),
|
||||
getStats: () => api.get('/api/checkins/stats'),
|
||||
};
|
||||
|
||||
// 高级用户API相关
|
||||
export const premiumApiService = {
|
||||
getAllCheckins: (apiKey, params) =>
|
||||
axios.get(`${API_BASE_URL}/api/v1/checkins`, {
|
||||
headers: { 'X-API-Key': apiKey },
|
||||
params,
|
||||
}),
|
||||
getStats: (apiKey) =>
|
||||
axios.get(`${API_BASE_URL}/api/v1/stats`, {
|
||||
headers: { 'X-API-Key': apiKey },
|
||||
}),
|
||||
};
|
||||
|
||||
export default api;
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
const { createProxyMiddleware } = require('http-proxy-middleware');
|
||||
|
||||
module.exports = function(app) {
|
||||
app.use(
|
||||
'/auth',
|
||||
createProxyMiddleware({
|
||||
target: 'http://localhost:5000',
|
||||
changeOrigin: true,
|
||||
})
|
||||
);
|
||||
|
||||
app.use(
|
||||
'/api',
|
||||
createProxyMiddleware({
|
||||
target: 'http://localhost:5000',
|
||||
changeOrigin: true,
|
||||
})
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,87 @@
|
|||
// 获取当前位置
|
||||
export const getCurrentPosition = () => {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!navigator.geolocation) {
|
||||
reject(new Error('浏览器不支持地理定位'));
|
||||
return;
|
||||
}
|
||||
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(position) => {
|
||||
resolve({
|
||||
latitude: position.coords.latitude,
|
||||
longitude: position.coords.longitude,
|
||||
accuracy: position.coords.accuracy,
|
||||
});
|
||||
},
|
||||
(error) => {
|
||||
let errorMessage = '无法获取位置';
|
||||
switch (error.code) {
|
||||
case error.PERMISSION_DENIED:
|
||||
errorMessage = '用户拒绝了地理定位请求';
|
||||
break;
|
||||
case error.POSITION_UNAVAILABLE:
|
||||
errorMessage = '位置信息不可用';
|
||||
break;
|
||||
case error.TIMEOUT:
|
||||
errorMessage = '获取位置超时';
|
||||
break;
|
||||
default:
|
||||
errorMessage = '未知错误';
|
||||
}
|
||||
reject(new Error(errorMessage));
|
||||
},
|
||||
{
|
||||
enableHighAccuracy: true,
|
||||
timeout: 10000,
|
||||
maximumAge: 0,
|
||||
}
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
// 使用逆地理编码获取地址(使用Nominatim开放API)
|
||||
export const reverseGeocode = async (latitude, longitude) => {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`https://nominatim.openstreetmap.org/reverse?format=json&lat=${latitude}&lon=${longitude}&zoom=18&addressdetails=1`,
|
||||
{
|
||||
headers: {
|
||||
'Accept-Language': 'zh-CN',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('逆地理编码请求失败');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
return {
|
||||
address: data.display_name,
|
||||
location_name: data.name || data.address?.road || data.address?.suburb || '未知地点',
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('逆地理编码错误:', error);
|
||||
return {
|
||||
address: '无法获取地址',
|
||||
location_name: '未知地点',
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// 计算两点之间的距离(单位:公里)
|
||||
export const calculateDistance = (lat1, lon1, lat2, lon2) => {
|
||||
const R = 6371; // 地球半径(公里)
|
||||
const dLat = ((lat2 - lat1) * Math.PI) / 180;
|
||||
const dLon = ((lon2 - lon1) * Math.PI) / 180;
|
||||
const a =
|
||||
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
|
||||
Math.cos((lat1 * Math.PI) / 180) *
|
||||
Math.cos((lat2 * Math.PI) / 180) *
|
||||
Math.sin(dLon / 2) *
|
||||
Math.sin(dLon / 2);
|
||||
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||
return R * c;
|
||||
};
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
content: [
|
||||
"./src/**/*.{js,jsx,ts,tsx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
|
|
@ -0,0 +1,66 @@
|
|||
@echo off
|
||||
echo 📍 Footprint - 打卡定位系统 - 启动脚本
|
||||
echo.
|
||||
|
||||
REM 检查 Node.js
|
||||
where node >nul 2>nul
|
||||
if %ERRORLEVEL% NEQ 0 (
|
||||
echo ❌ 未检测到 Node.js,请先安装 Node.js 18+
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo ✅ Node.js 已安装
|
||||
echo.
|
||||
|
||||
REM 安装后端依赖
|
||||
if not exist "backend\node_modules" (
|
||||
echo 📦 安装后端依赖...
|
||||
cd backend
|
||||
call npm install
|
||||
cd ..
|
||||
)
|
||||
|
||||
REM 安装前端依赖
|
||||
if not exist "frontend\node_modules" (
|
||||
echo 📦 安装前端依赖...
|
||||
cd frontend
|
||||
call npm install
|
||||
cd ..
|
||||
)
|
||||
|
||||
REM 检查环境变量
|
||||
if not exist "backend\.env" (
|
||||
echo ⚠️ 未检测到 .env 文件,从模板创建...
|
||||
copy backend\.env.example backend\.env
|
||||
echo 📝 请编辑 backend\.env 文件,配置 OAuth2 凭据
|
||||
echo.
|
||||
)
|
||||
|
||||
REM 初始化数据库
|
||||
echo 🗄️ 初始化数据库...
|
||||
cd backend
|
||||
call npm run init-db
|
||||
cd ..
|
||||
echo.
|
||||
|
||||
REM 启动服务
|
||||
echo 🚀 启动服务...
|
||||
echo.
|
||||
echo ▶️ 启动后端服务 (http://localhost:5000)...
|
||||
start "Footprint Backend" cmd /k "cd backend && npm run dev"
|
||||
|
||||
timeout /t 3 /nobreak >nul
|
||||
|
||||
echo ▶️ 启动前端服务 (http://localhost:3000)...
|
||||
start "Footprint Frontend" cmd /k "cd frontend && npm start"
|
||||
|
||||
echo.
|
||||
echo ✅ 服务已启动!
|
||||
echo.
|
||||
echo 访问地址:
|
||||
echo - 前端: http://localhost:3000
|
||||
echo - 后端: http://localhost:5000
|
||||
echo.
|
||||
echo 关闭窗口即可停止服务
|
||||
pause
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
#!/bin/bash
|
||||
|
||||
echo "📍 Footprint - 打卡定位系统 - 启动脚本"
|
||||
echo ""
|
||||
|
||||
# 检查 Node.js
|
||||
if ! command -v node &> /dev/null; then
|
||||
echo "❌ 未检测到 Node.js,请先安装 Node.js 18+"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✅ Node.js 版本: $(node -v)"
|
||||
echo ""
|
||||
|
||||
# 检查是否已安装依赖
|
||||
if [ ! -d "backend/node_modules" ]; then
|
||||
echo "📦 安装后端依赖..."
|
||||
cd backend && npm install && cd ..
|
||||
fi
|
||||
|
||||
if [ ! -d "frontend/node_modules" ]; then
|
||||
echo "📦 安装前端依赖..."
|
||||
cd frontend && npm install && cd ..
|
||||
fi
|
||||
|
||||
# 检查环境变量
|
||||
if [ ! -f "backend/.env" ]; then
|
||||
echo "⚠️ 未检测到 .env 文件,从模板创建..."
|
||||
cp backend/.env.example backend/.env
|
||||
echo "📝 请编辑 backend/.env 文件,配置 OAuth2 凭据"
|
||||
echo ""
|
||||
fi
|
||||
|
||||
# 初始化数据库
|
||||
echo "🗄️ 初始化数据库..."
|
||||
cd backend && npm run init-db && cd ..
|
||||
echo ""
|
||||
|
||||
# 启动服务
|
||||
echo "🚀 启动服务..."
|
||||
echo ""
|
||||
|
||||
# 启动后端
|
||||
echo "▶️ 启动后端服务 (http://localhost:5000)..."
|
||||
cd backend && npm run dev &
|
||||
BACKEND_PID=$!
|
||||
|
||||
# 等待后端启动
|
||||
sleep 3
|
||||
|
||||
# 启动前端
|
||||
echo "▶️ 启动前端服务 (http://localhost:3000)..."
|
||||
cd frontend && npm start &
|
||||
FRONTEND_PID=$!
|
||||
|
||||
echo ""
|
||||
echo "✅ 服务已启动!"
|
||||
echo ""
|
||||
echo "访问地址:"
|
||||
echo " - 前端: http://localhost:3000"
|
||||
echo " - 后端: http://localhost:5000"
|
||||
echo ""
|
||||
echo "按 Ctrl+C 停止所有服务"
|
||||
|
||||
# 捕获退出信号
|
||||
trap "kill $BACKEND_PID $FRONTEND_PID; exit" INT TERM
|
||||
|
||||
# 等待
|
||||
wait
|
||||
Loading…
Reference in New Issue