继续提交

This commit is contained in:
2025-12-13 21:33:26 +08:00
parent 7a731d44e3
commit fa77e0a65f
2215 changed files with 392858 additions and 2 deletions

View File

@@ -0,0 +1,446 @@
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