first commit

This commit is contained in:
浪子
2025-12-29 18:14:05 +08:00
commit 83f3415084
46 changed files with 23959 additions and 0 deletions
+30
View File
@@ -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;"]
+28
View File
@@ -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;
}
+18521
View File
File diff suppressed because it is too large Load Diff
+45
View File
@@ -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"
}
}
+6
View File
@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
+15
View File
@@ -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>
+60
View File
@@ -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;
}
+124
View File
@@ -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中是否有tokenOAuth回调后)
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;
+174
View File
@@ -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;
+76
View File
@@ -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;
+41
View File
@@ -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;
+67
View File
@@ -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='&copy; <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;
+59
View File
@@ -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>
);
};
+12
View File
@@ -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;
}
+11
View File
@@ -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>
);
+126
View File
@@ -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;
+62
View File
@@ -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;
+262
View File
@@ -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访问权限导出所有打卡数据支持JSONCSVGeoJSON等多种格式
</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;
+82
View File
@@ -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;
+19
View File
@@ -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,
})
);
};
+87
View File
@@ -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;
};
+10
View File
@@ -0,0 +1,10 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
"./src/**/*.{js,jsx,ts,tsx}",
],
theme: {
extend: {},
},
plugins: [],
}