447 lines
15 KiB
JavaScript
447 lines
15 KiB
JavaScript
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
|