Files
mengyadriftbottle/mengyadriftbottle-frontend/src/App.jsx
2025-12-13 21:33:26 +08:00

447 lines
15 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useCallback, useEffect, useMemo, useState } from 'react'
import '@fortawesome/fontawesome-free/css/all.min.css'
import './App.css'
const API_BASE_URL = (import.meta.env.VITE_API_BASE_URL || '/api').replace(/\/$/, '')
const ADMIN_URL = import.meta.env.VITE_ADMIN_URL || 'http://localhost:5002/admin/login'
const DEFAULT_FORM = Object.freeze({
name: '',
message: '',
gender: '保密',
qq: '',
})
const BACKGROUND_IMAGES = Array.from({ length: 29 }, (_, index) => `/background/image${index + 1}.png`)
const formatCount = (value) => {
const num = typeof value === 'number' ? value : Number(value || 0)
return Number.isNaN(num) ? 0 : num
}
function App() {
const [formData, setFormData] = useState({ ...DEFAULT_FORM })
const [limits, setLimits] = useState({ name: 7, message: 100 })
const [stats, setStats] = useState({ total_bottles: 0 })
const [motto, setMotto] = useState('载入中...')
const [throwStatus, setThrowStatus] = useState('')
const [pickupStatus, setPickupStatus] = useState('')
const [currentBottle, setCurrentBottle] = useState(null)
const [cooldowns, setCooldowns] = useState({ throw: 0, pickup: 0 })
const [loadingAction, setLoadingAction] = useState({ throw: false, pickup: false })
const [reactionDisabled, setReactionDisabled] = useState(false)
const isThrowing = loadingAction.throw
const isPicking = loadingAction.pickup
const randomBackground = useMemo(() => {
if (!BACKGROUND_IMAGES.length) {
return ''
}
const index = Math.floor(Math.random() * BACKGROUND_IMAGES.length)
return BACKGROUND_IMAGES[index]
}, [])
useEffect(() => {
if (randomBackground) {
document.documentElement.style.setProperty('--app-background-image', `url(${randomBackground})`)
}
}, [randomBackground])
const startCooldown = useCallback((type, seconds = 5) => {
const duration = Math.max(1, Math.ceil(seconds))
setCooldowns((prev) => ({ ...prev, [type]: duration }))
}, [])
useEffect(() => {
const timer = setInterval(() => {
setCooldowns((prev) => {
const next = {
throw: prev.throw > 0 ? prev.throw - 1 : 0,
pickup: prev.pickup > 0 ? prev.pickup - 1 : 0,
}
if (next.throw === prev.throw && next.pickup === prev.pickup) {
return prev
}
return next
})
}, 1000)
return () => clearInterval(timer)
}, [])
const fetchConfig = useCallback(async () => {
try {
const response = await fetch(`${API_BASE_URL}/config`)
const data = await response.json()
if (data.success && data.config) {
setLimits({
name: data.config.name_limit ?? 7,
message: data.config.message_limit ?? 100,
})
}
} catch (error) {
console.error('Failed to fetch config', error)
}
}, [])
const fetchStats = useCallback(async () => {
try {
const response = await fetch(`${API_BASE_URL}/stats`)
const data = await response.json()
if (data.success && data.stats) {
setStats(data.stats)
}
} catch (error) {
console.error('Failed to fetch stats', error)
}
}, [])
const fetchMotto = useCallback(async () => {
try {
const response = await fetch(`${API_BASE_URL}/motto`)
const data = await response.json()
if (data.success) {
setMotto(data.motto)
}
} catch (error) {
console.error('Failed to fetch motto', error)
setMotto('良言一句三冬暖,恶语伤人六月寒')
}
}, [])
useEffect(() => {
fetchConfig()
fetchStats()
fetchMotto()
}, [fetchConfig, fetchStats, fetchMotto])
const handleInputChange = useCallback((event) => {
const { name, value } = event.target
if (name === 'qq' && value && /[^0-9]/.test(value)) {
return
}
setFormData((prev) => ({ ...prev, [name]: value }))
}, [])
const resetForm = useCallback(() => {
setFormData({ ...DEFAULT_FORM })
}, [])
const handleThrowSubmit = useCallback(
async (event) => {
event.preventDefault()
if (cooldowns.throw > 0 || isThrowing) {
return
}
setLoadingAction((prev) => ({ ...prev, throw: true }))
setThrowStatus('正在扔瓶子...')
try {
const response = await fetch(`${API_BASE_URL}/throw`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: formData.name,
message: formData.message,
gender: formData.gender,
qq: formData.qq,
}),
})
const data = await response.json()
if (response.ok && data.success) {
setThrowStatus('瓶子已成功扔出!祝你好运~')
resetForm()
fetchStats()
startCooldown('throw', 5)
} else {
const waitTime = data.wait_time ?? 0
setThrowStatus(`出错了: ${data.error || data.message || '未知错误'}`)
if (waitTime) {
startCooldown('throw', waitTime)
}
}
} catch (error) {
console.error('Throw request failed', error)
setThrowStatus('请求失败,请检查网络连接。')
} finally {
setLoadingAction((prev) => ({ ...prev, throw: false }))
}
},
[cooldowns.throw, isThrowing, formData, fetchStats, resetForm, startCooldown],
)
const handlePickup = useCallback(async () => {
if (cooldowns.pickup > 0 || isPicking) {
return
}
setLoadingAction((prev) => ({ ...prev, pickup: true }))
setPickupStatus('正在打捞瓶子...')
try {
const response = await fetch(`${API_BASE_URL}/pickup`)
const data = await response.json()
if (response.ok && data.success && data.bottle) {
setCurrentBottle(data.bottle)
setReactionDisabled(false)
setPickupStatus('捡到了一个瓶子!缘分来了~')
startCooldown('pickup', 5)
} else {
setCurrentBottle(null)
const waitTime = data.wait_time ?? 0
setPickupStatus(data.message || data.error || '海里没有瓶子了,或者出错了。')
if (waitTime) {
startCooldown('pickup', waitTime)
}
}
} catch (error) {
console.error('Pickup request failed', error)
setPickupStatus('请求失败,请检查网络连接。')
setCurrentBottle(null)
} finally {
setLoadingAction((prev) => ({ ...prev, pickup: false }))
}
}, [cooldowns.pickup, isPicking, startCooldown])
const handleReaction = useCallback(
async (reaction) => {
if (!currentBottle) {
return
}
setReactionDisabled(true)
try {
const response = await fetch(`${API_BASE_URL}/react`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ bottle_id: currentBottle.id, reaction }),
})
const data = await response.json()
if (response.ok && data.success) {
setCurrentBottle((prev) =>
prev
? {
...prev,
likes: reaction === 'like' ? formatCount(prev.likes) + 1 : prev.likes,
dislikes: reaction === 'dislike' ? formatCount(prev.dislikes) + 1 : prev.dislikes,
}
: prev,
)
setPickupStatus(
reaction === 'like' ? '感谢您的点赞!' : '已记录您的反馈,该瓶子被捡起的概率将会降低。',
)
} else {
setPickupStatus(data.error || '记录反馈时出错。')
setReactionDisabled(false)
}
} catch (error) {
console.error('Reaction request failed', error)
setPickupStatus('请求失败,请稍后再试。')
setReactionDisabled(false)
}
},
[currentBottle],
)
const nameCharCount = useMemo(() => formData.name.length, [formData.name])
const messageCharCount = useMemo(() => formData.message.length, [formData.message])
return (
<div className="page-wrapper">
<div className="background-overlay" aria-hidden="true" />
<div className="container">
<h1>
<i className="fas fa-heart heart-icon" /> 萌芽漂流瓶{' '}
<i className="fas fa-heart heart-icon" />
</h1>
<p className="tagline">让心意随海浪飘向远方邂逅那个懂你的人(´,,ω,,)...</p>
<div className="stats-container">
<p>
<i className="fas fa-wine-bottle" /> 海洋中共有{' '}
<span id="total-bottles">{formatCount(stats.total_bottles)}</span>
</p>
</div>
<div className="action-section throw-section">
<h2>
<i className="fas fa-paper-plane" /> 扔一个漂流瓶(,,ω,,)
</h2>
<form id="throw-bottle-form" onSubmit={handleThrowSubmit}>
<div>
<label htmlFor="name">
你的昵称:{' '}
<span className={`char-count ${nameCharCount >= limits.name ? 'char-count-limit' : ''}`}>
<span id="name-char-count">{nameCharCount}</span>/{' '}
<span id="name-limit">{limits.name}</span>
</span>
</label>
<input
id="name"
name="name"
type="text"
value={formData.name}
onChange={handleInputChange}
maxLength={limits.name}
placeholder="告诉对方你是谁..."
required
/>
</div>
<div>
<label htmlFor="message">
漂流瓶内容:{' '}
<span
className={`char-count ${
messageCharCount >= limits.message ? 'char-count-limit' : ''
}`}
>
<span id="message-char-count">{messageCharCount}</span>/{' '}
<span id="message-limit">{limits.message}</span>
</span>
</label>
<textarea
id="message"
name="message"
rows="4"
value={formData.message}
onChange={handleInputChange}
maxLength={limits.message}
placeholder="写下你想说的话,也许会有人懂..."
required
/>
</div>
<div>
<label htmlFor="gender">性别:</label>
<select id="gender" name="gender" value={formData.gender} onChange={handleInputChange}>
<option value="保密">保密</option>
<option value="男"></option>
<option value="女"></option>
</select>
</div>
<div>
<label htmlFor="qq">QQ号 (选填):</label>
<input
id="qq"
name="qq"
type="text"
value={formData.qq}
inputMode="numeric"
pattern="[0-9]*"
onChange={handleInputChange}
placeholder="填写QQ号展示头像..."
/>
</div>
{cooldowns.throw > 0 ? (
<div id="throw-cooldown" className="cooldown-timer">
<i className="fas fa-hourglass-half" /> 冷却中: <span id="throw-countdown">{cooldowns.throw}</span>
</div>
) : (
<button type="submit" id="throw-button" className="btn-throw" disabled={isThrowing}>
<i className="fas fa-paper-plane" /> {isThrowing ? '正在扔瓶子...' : '扔出去'}
</button>
)}
</form>
<p id="throw-status">{throwStatus}</p>
</div>
<div className="action-section pickup-section">
<h2>
<i className="fas fa-search-location" /> 捡一个漂流瓶(,,ω,,)
</h2>
{cooldowns.pickup > 0 ? (
<div id="pickup-cooldown" className="cooldown-timer">
<i className="fas fa-hourglass-half" /> 冷却中: <span id="pickup-countdown">{cooldowns.pickup}</span>
</div>
) : (
<button
type="button"
id="pickup-bottle-button"
className="btn-pickup"
onClick={handlePickup}
disabled={isPicking}
>
<i className="fas fa-hand-paper" /> {isPicking ? '正在打捞...' : '捡瓶子'}
</button>
)}
{currentBottle && (
<div id="bottle-display" className="appear">
<div className="bottle-header">
{currentBottle.qq_avatar_url ? (
<img id="bottle-avatar" src={currentBottle.qq_avatar_url} alt="QQ Avatar" />
) : null}
<h3>
来自 <span id="bottle-name">{currentBottle.name}</span>{' '}
<span className="gender-badge" id="bottle-gender">
{currentBottle.gender || '保密'}
</span>{' '}
的漂流瓶
</h3>
</div>
<div className="message-content">
<p id="bottle-message">{currentBottle.message}</p>
</div>
<div className="bottle-footer">
<div className="bottle-info">
<small>
<i className="far fa-clock" /> 时间: <span id="bottle-timestamp">{currentBottle.timestamp}</span>
</small>
<small>
<i className="fas fa-map-marker-alt" /> IP:{' '}
<span id="bottle-ip">{currentBottle.ip_address || '未知'}</span>
</small>
{currentBottle.qq_number ? (
<small id="bottle-qq-number">
<i className="fab fa-qq" /> QQ: <span id="qq-number-val">{currentBottle.qq_number}</span>
</small>
) : null}
</div>
<div className="bottle-reactions">
<button
type="button"
id="like-button"
className="reaction-btn like-btn"
onClick={() => handleReaction('like')}
disabled={reactionDisabled}
>
<i className="far fa-thumbs-up" /> <span id="like-count">{formatCount(currentBottle.likes)}</span>
</button>
<button
type="button"
id="dislike-button"
className="reaction-btn dislike-btn"
onClick={() => handleReaction('dislike')}
disabled={reactionDisabled}
>
<i className="far fa-thumbs-down" />{' '}
<span id="dislike-count">{formatCount(currentBottle.dislikes)}</span>
</button>
</div>
</div>
</div>
)}
<p id="pickup-status">{pickupStatus}</p>
</div>
<footer>
<p>
© 2025 萌芽漂流瓶-蜀ICP备2025151694号 <i className="fas fa-heart" />
</p>
</footer>
</div>
</div>
)
}
export default App