feat: initial commit for TheFarmer project

This commit is contained in:
Karriis
2026-02-18 13:52:06 +08:00
commit 8ceb5fa9db
420 changed files with 61918 additions and 0 deletions

42
web/src/App.css Normal file
View File

@@ -0,0 +1,42 @@
#root {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.react:hover {
filter: drop-shadow(0 0 2em #61dafbaa);
}
@keyframes logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: no-preference) {
a:nth-of-type(2) .logo {
animation: logo-spin infinite 20s linear;
}
}
.card {
padding: 2em;
}
.read-the-docs {
color: #888;
}

24
web/src/App.tsx Normal file
View File

@@ -0,0 +1,24 @@
import { BrowserRouter as Router, Routes, Route, Navigate } from "react-router-dom"
import Login from "./pages/Login"
import Dashboard from "./pages/Dashboard"
function App() {
return (
<Router>
<div className="min-h-screen">
<div>
<Routes>
<Route path="/login" element={<Login />} />
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/" element={<Navigate to="/login" replace />} />
</Routes>
</div>
<footer className="fixed bottom-0 left-0 right-0 py-2 text-center text-xs text-slate-400 bg-white/70 backdrop-blur-md border-t border-white/60">
Made By Karriis
</footer>
</div>
</Router>
)
}
export default App

Binary file not shown.

BIN
web/src/assets/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

1
web/src/assets/react.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@@ -0,0 +1,244 @@
import { useEffect, useState } from "react"
import { Sprout, Lock, Droplets, Bug, Skull, Shovel, Flower, Ban, Hand } from "lucide-react"
import { cn } from "@/lib/utils"
export interface LandData {
id: number
type: 'locked' | 'empty' | 'planted'
status?: 'growing' | 'mature' | 'dead'
plantName?: string
plantId?: number
phase?: number
phaseName?: string
fertilized?: boolean
needs?: {
water: boolean
weed: boolean
bug: boolean
}
canSteal?: boolean
couldUnlock?: boolean
unlockCondition?: {
needLevel: number
needGold: number
}
}
interface FarmGridProps {
lands: LandData[]
isLoading?: boolean
onLandClick?: (land: LandData) => void
onLandUnlock?: (land: LandData) => void
selectionEnabled?: boolean
selectedLandIds?: number[]
userLevel?: number
userGold?: number
unlockingLandIds?: number[]
}
export function FarmGrid({ lands, isLoading = false, onLandClick, onLandUnlock, selectionEnabled = false, selectedLandIds = [], userLevel = 0, userGold = 0, unlockingLandIds = [] }: FarmGridProps) {
const [plantImageMap, setPlantImageMap] = useState<Record<number, string>>({})
useEffect(() => {
let cancelled = false
fetch('/shop_plants_organized/mapping.csv')
.then(res => res.text())
.then(text => {
if (cancelled) return
const lines = text.split(/\r?\n/).filter(Boolean)
if (lines.length <= 1) {
setPlantImageMap({})
return
}
const map: Record<number, string> = {}
for (let i = 1; i < lines.length; i += 1) {
const line = lines[i].trim()
if (!line) continue
const parts = line.split(',')
if (parts.length < 5) continue
const plantId = Number(parts[2])
const fileName = parts[4]
if (!Number.isNaN(plantId) && fileName) {
map[plantId] = fileName
}
}
setPlantImageMap(map)
})
.catch(() => {
if (!cancelled) setPlantImageMap({})
})
return () => {
cancelled = true
}
}, [])
// Generate default 18 slots if lands is empty (initial state)
const displayLands = lands.length > 0 ? lands : Array.from({ length: 18 }, (_, i) => ({
id: i + 1,
type: 'locked' as const
}))
if (isLoading && lands.length === 0) {
return (
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-6 gap-4">
{Array.from({ length: 18 }).map((_, i) => (
<div key={i} className="aspect-square rounded-3xl bg-white/20 animate-pulse border-2 border-dashed border-white/30" />
))}
</div>
)
}
return (
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-6 gap-4 p-4 bg-stone-100/50 rounded-[2rem] border border-stone-200/50 shadow-inner">
{displayLands.map((land) => (
<LandCard
key={land.id}
land={land}
plantImageMap={plantImageMap}
onClick={() => onLandClick?.(land)}
onUnlock={() => onLandUnlock?.(land)}
userLevel={userLevel}
userGold={userGold}
isUnlocking={unlockingLandIds.includes(land.id)}
selected={selectionEnabled && selectedLandIds.includes(land.id)}
selectionEnabled={selectionEnabled}
/>
))}
</div>
)
}
function LandCard({ land, plantImageMap, onClick, onUnlock, userLevel, userGold, isUnlocking, selected, selectionEnabled }: { land: LandData, plantImageMap: Record<number, string>, onClick?: () => void, onUnlock?: () => void, userLevel: number, userGold: number, isUnlocking: boolean, selected?: boolean, selectionEnabled?: boolean }) {
const isLocked = land.type === 'locked'
const isEmpty = land.type === 'empty'
const isPlanted = land.type === 'planted'
const isDead = land.status === 'dead'
const isMature = land.status === 'mature'
const isGrowing = land.status === 'growing'
const isFertilized = land.fertilized === true
const plantImage = land.plantId ? plantImageMap[land.plantId] : undefined
const needLevel = land.unlockCondition?.needLevel ?? 0
const needGold = land.unlockCondition?.needGold ?? 0
const canUnlock = isLocked && (import.meta.env.DEV ? true : (!!land.couldUnlock && userLevel >= needLevel && userGold >= needGold))
return (
<div
onClick={!isLocked ? onClick : undefined}
className={cn(
"aspect-square rounded-2xl relative transition-all duration-300 group select-none",
"flex flex-col items-center justify-center p-2",
"border-2",
selected && "ring-2 ring-rose-400 border-rose-200",
isLocked && "bg-slate-100 border-slate-200 text-slate-300",
isEmpty && "bg-[#F3E5D0] border-[#E6D5BC] text-[#C4B29B] hover:border-[#D4C3AA]",
isPlanted && isGrowing && "bg-green-50 border-green-100 text-green-600",
isPlanted && isMature && "bg-amber-50 border-amber-100 text-amber-600 shadow-[0_4px_12px_-4px_rgba(251,191,36,0.4)]",
isPlanted && isDead && "bg-purple-50 border-purple-100 text-purple-400",
!isLocked && "hover:scale-[1.02] hover:shadow-sm cursor-pointer active:scale-[0.98]",
selectionEnabled && !isLocked && "hover:ring-2 hover:ring-rose-200"
)}>
{/* Status Badge (ID) */}
<span className={cn(
"absolute top-2 left-2 text-[10px] font-bold px-1.5 py-0.5 rounded-full",
isLocked ? "bg-slate-200 text-slate-400" : "bg-white/60 backdrop-blur-sm text-slate-500"
)}>
#{land.id}
</span>
{canUnlock && (
<div className="absolute inset-x-0 bottom-2 flex items-center justify-center px-2">
<button
className={cn(
"px-3 py-1 rounded-full text-[11px] font-bold bg-emerald-500 text-white shadow-sm",
isUnlocking ? "opacity-70 cursor-not-allowed" : "hover:bg-emerald-600"
)}
onClick={(e) => {
e.stopPropagation()
if (!isUnlocking) onUnlock?.()
}}
disabled={isUnlocking}
>
{isUnlocking ? "解锁中" : `解锁(${needGold})`}
</button>
</div>
)}
{/* Main Icon */}
<div className="flex flex-col items-center gap-1 z-10">
{isLocked && <Lock className="h-6 w-6 opacity-50" />}
{isEmpty && <Shovel className="h-6 w-6 opacity-50" />}
{isPlanted && (
<>
{plantImage ? (
<img
src={`/shop_plants_organized/${plantImage}`}
alt={land.plantName || '作物'}
className={cn(
"h-12 w-12 object-contain",
isDead && "grayscale opacity-60",
isMature && "animate-bounce"
)}
style={isMature ? { animationDuration: '3s' } : undefined}
/>
) : (
<>
{isDead && <Skull className="h-8 w-8 animate-pulse" />}
{isGrowing && <Sprout className="h-8 w-8" />}
{isMature && <Flower className="h-8 w-8 animate-bounce" style={{ animationDuration: '3s' }} />}
</>
)}
<span className={cn(
"text-[11px] font-semibold text-center line-clamp-1 max-w-full px-1",
isDead && "text-purple-500",
isMature && "text-amber-600",
isGrowing && "text-green-600"
)}>
{land.plantName || '等待中...'}
</span>
<span className={cn(
"text-[10px] font-medium",
isFertilized ? "text-emerald-600" : "text-slate-500"
)}>
{`${land.phaseName || '未知'}/${isFertilized ? '已施肥' : '未施肥'}`}
</span>
</>
)}
</div>
{/* Action Indicators */}
{isPlanted && !isDead && land.needs && (
<div className="absolute -top-1 -right-1 flex flex-col gap-1">
{land.needs.water && (
<div className="h-6 w-6 rounded-full bg-blue-400 text-white flex items-center justify-center shadow-sm border-2 border-white animate-bounce">
<Droplets className="h-3 w-3" />
</div>
)}
{land.needs.bug && (
<div className="h-6 w-6 rounded-full bg-rose-400 text-white flex items-center justify-center shadow-sm border-2 border-white animate-pulse">
<Bug className="h-3 w-3" />
</div>
)}
{land.needs.weed && (
<div className="h-6 w-6 rounded-full bg-purple-400 text-white flex items-center justify-center shadow-sm border-2 border-white">
<Ban className="h-3 w-3" />
</div>
)}
</div>
)}
{/* Steal Indicator */}
{land.canSteal && (
<div className="absolute top-1 right-1 h-6 w-6 rounded-full bg-orange-400 text-white flex items-center justify-center shadow-sm border-2 border-white animate-bounce z-20">
<Hand className="h-3 w-3" />
</div>
)}
{/* Background decoration for mature */}
{isMature && (
<div className="absolute inset-0 bg-gradient-to-br from-amber-100/0 via-amber-100/0 to-amber-200/30 rounded-2xl" />
)}
</div>
)
}

View File

@@ -0,0 +1,108 @@
import { useCallback, useEffect, useState } from "react"
import { Users, User, Crown, RefreshCw } from "lucide-react"
import { ScrollArea } from "@/components/ui/scroll-area"
import { cn } from "@/lib/utils"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
interface Friend {
uin: string
userName: string
headPic: string
yellowLevel: number
exp: number
money: number
}
export function FriendList({ email, onVisit }: { email: string, onVisit: (uid: string, name: string) => void }) {
const [friends, setFriends] = useState<Friend[]>([])
const [isLoading, setIsLoading] = useState(false)
const fetchFriends = useCallback(async () => {
if (!email) return
setIsLoading(true)
try {
const res = await fetch(`/api/friends?email=${email}`)
const json = await res.json()
if (json.friends) {
setFriends(json.friends)
}
} catch (e) {
console.error(e)
} finally {
setIsLoading(false)
}
}, [email])
useEffect(() => {
fetchFriends()
}, [fetchFriends])
return (
<div className="bg-white/60 backdrop-blur-md rounded-[2.5rem] p-6 shadow-sm border border-white/50 h-[600px] flex flex-col">
<div className="flex items-center justify-between mb-6 px-2">
<h2 className="text-xl font-bold text-slate-700 flex items-center gap-2">
<span className="p-2 bg-pink-100 text-pink-600 rounded-xl">
<Users className="h-5 w-5" />
</span>
<span className="text-sm font-normal text-slate-400 ml-2 bg-white/50 px-2 py-0.5 rounded-full">
{friends.length}
</span>
</h2>
<button
onClick={fetchFriends}
className="p-2 hover:bg-white/50 rounded-full transition-colors text-slate-400 hover:text-slate-600"
>
<RefreshCw className={cn("h-5 w-5", isLoading && "animate-spin")} />
</button>
</div>
<ScrollArea className="flex-1 -mr-4 pr-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{friends.map((friend) => (
<div
key={friend.uin}
className="flex items-center gap-4 p-3 rounded-2xl bg-white/40 hover:bg-white/70 border border-transparent hover:border-pink-100 transition-all group cursor-pointer"
onClick={() => onVisit(friend.uin, friend.userName)}
>
<div className="relative">
<Avatar className="h-12 w-12 border-2 border-white shadow-sm ring-2 ring-transparent group-hover:ring-pink-200 transition-all">
<AvatarImage src={friend.headPic} />
<AvatarFallback className="bg-slate-100 text-slate-400">
<User className="h-6 w-6" />
</AvatarFallback>
</Avatar>
{friend.yellowLevel > 0 && (
<div className="absolute -top-1 -right-1 bg-yellow-400 text-white text-[10px] px-1 rounded-full flex items-center shadow-sm border border-white">
<Crown className="h-2 w-2 mr-0.5" /> Lv{friend.yellowLevel}
</div>
)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between">
<span className="font-bold text-slate-700 truncate">{friend.userName}</span>
</div>
<div className="flex items-center gap-3 text-xs text-slate-500 mt-1">
<span className="bg-yellow-50 text-yellow-600 px-1.5 py-0.5 rounded">
💰 {friend.money.toLocaleString()}
</span>
<span className="bg-blue-50 text-blue-600 px-1.5 py-0.5 rounded">
Exp {friend.exp}
</span>
</div>
</div>
</div>
))}
{friends.length === 0 && !isLoading && (
<div className="col-span-full h-40 flex flex-col items-center justify-center text-slate-400 gap-2">
<Users className="h-10 w-10 opacity-20" />
<span className="text-sm"></span>
</div>
)}
</div>
</ScrollArea>
</div>
)
}

View File

@@ -0,0 +1,265 @@
import { useCallback, useEffect, useRef, useState } from "react"
import { ScrollArea } from "@/components/ui/scroll-area"
import { cn } from "@/lib/utils"
import { io, Socket } from "socket.io-client"
import { Terminal, Info, AlertTriangle, CheckCircle2, Clock } from "lucide-react"
export interface LogEntry {
id: string
tag: string
msg: string
type: 'info' | 'warn' | 'error' | 'success'
time: number
}
interface LogPanelProps {
className?: string
email?: string
}
interface BotLogPayload {
tag?: string
msg?: unknown
type?: LogEntry['type']
time?: number
}
interface BotErrorPayload {
message?: string
}
interface BotStatusPayload {
status?: string
}
export function LogPanel({ className, email }: LogPanelProps) {
const [logs, setLogs] = useState<LogEntry[]>([])
const [isConnected, setIsConnected] = useState(false)
const scrollRef = useRef<HTMLDivElement>(null)
const socketRef = useRef<Socket | null>(null)
const retryCountRef = useRef(0)
const disconnectNotifiedRef = useRef(false)
const retryNotifiedRef = useRef(false)
const addLog = useCallback((log: Omit<LogEntry, 'id'>) => {
setLogs(prev => [...prev.slice(-200), { ...log, id: Math.random().toString(36).slice(2, 11) }])
}, [])
const addSystemLog = useCallback((msg: string, type: LogEntry['type'] = 'info') => {
addLog({ tag: 'System', msg, type, time: Date.now() })
}, [addLog])
useEffect(() => {
if (!email) return
const loadHistory = async () => {
try {
const res = await fetch(`/api/logs?email=${encodeURIComponent(email)}`)
if (!res.ok) return
const data = await res.json()
if (!Array.isArray(data.logs)) return
const normalized = data.logs.map((entry: LogEntry) => ({
id: entry.id || Math.random().toString(36).slice(2, 11),
tag: entry.tag || 'System',
msg: entry.msg || '',
type: entry.type || 'info',
time: entry.time || Date.now()
}))
setLogs(normalized)
} catch {
addSystemLog('历史日志加载失败', 'warn')
}
}
loadHistory()
// Connect to WebSocket
// In production this should be an env var, here we assume same host
const socket = io(import.meta.env.VITE_WS_GATEWAY_URL || '/', {
transports: ['websocket'],
reconnectionAttempts: 3,
reconnectionDelay: 1000,
reconnectionDelayMax: 3000
})
socketRef.current = socket
socket.on('connect', () => {
setIsConnected(true)
retryCountRef.current = 0
disconnectNotifiedRef.current = false
retryNotifiedRef.current = false
addSystemLog('已连接到农场服务器')
})
socket.on('disconnect', () => {
setIsConnected(false)
if (!disconnectNotifiedRef.current) {
disconnectNotifiedRef.current = true
addSystemLog('与服务器断开连接', 'warn')
}
})
socket.on('reconnect_attempt', (attempt: number) => {
retryCountRef.current = attempt
if (!retryNotifiedRef.current) {
retryNotifiedRef.current = true
addSystemLog('连接断开正在重试最多3次', 'warn')
}
})
socket.on('reconnect_failed', () => {
addSystemLog('连接失败,请重新绑定', 'error')
})
socket.on(`bot-log-${email}`, (data: unknown) => {
const payload = (typeof data === 'object' && data !== null ? data : {}) as BotLogPayload
// 格式化日志内容(如果它是对象)
const rawMsg = payload.msg
let message = ''
if (typeof rawMsg === 'string') {
message = rawMsg
} else if (rawMsg === null || rawMsg === undefined) {
message = ''
} else if (typeof rawMsg === 'object') {
try {
message = JSON.stringify(rawMsg)
} catch {
message = String(rawMsg)
}
} else {
message = String(rawMsg)
}
const normalizedType: LogEntry['type'] =
payload.type === 'info' || payload.type === 'warn' || payload.type === 'error' || payload.type === 'success'
? payload.type
: 'info'
addLog({
tag: payload.tag || 'System',
msg: message,
type: normalizedType,
time: payload.time || Date.now()
})
})
socket.on(`bot-error-${email}`, (data: unknown) => {
const payload = (typeof data === 'object' && data !== null ? data : {}) as BotErrorPayload
addLog({
tag: 'Error',
msg: payload.message || '未知错误',
type: 'error',
time: Date.now()
})
})
socket.on(`bot-status-${email}`, (data: unknown) => {
const payload = (typeof data === 'object' && data !== null ? data : {}) as BotStatusPayload
const statusText = payload.status || 'unknown'
addSystemLog(`Bot状态更新: ${statusText}`, statusText === 'running' ? 'success' : 'warn')
})
return () => {
socket.off(`bot-log-${email}`)
socket.off(`bot-error-${email}`)
socket.off(`bot-status-${email}`)
socket.off('connect')
socket.off('disconnect')
socket.off('reconnect_attempt')
socket.off('reconnect_failed')
socket.disconnect()
}
}, [email, addLog, addSystemLog])
// Auto-scroll to bottom
useEffect(() => {
if (scrollRef.current) {
const scrollElement = scrollRef.current.querySelector('[data-radix-scroll-area-viewport]')
if (scrollElement) {
scrollElement.scrollTop = scrollElement.scrollHeight
}
}
}, [logs])
return (
<div className={cn(
"flex flex-col h-full bg-white/60 backdrop-blur-md border border-white/50 shadow-sm rounded-3xl overflow-hidden transition-all duration-300",
className
)}>
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-stone-100/50 bg-white/40">
<div className="flex items-center gap-2">
<div className="p-1.5 bg-indigo-100 rounded-lg">
<Terminal className="h-4 w-4 text-indigo-600" />
</div>
<h2 className="font-bold text-slate-700"></h2>
</div>
<div className="flex items-center gap-2">
<span className={cn(
"h-2 w-2 rounded-full animate-pulse",
isConnected ? "bg-green-500" : "bg-red-500"
)} />
<span className="text-xs text-slate-400 font-medium">
{isConnected ? 'Live' : 'Offline'}
</span>
</div>
</div>
{/* Logs Area */}
<ScrollArea className="flex-1 p-4" ref={scrollRef}>
<div className="flex flex-col gap-3 min-h-[300px]">
{logs.length === 0 && (
<div className="flex flex-col items-center justify-center h-full text-slate-400 py-12 gap-2">
<Clock className="h-8 w-8 opacity-20" />
<p className="text-sm font-medium opacity-60">...</p>
</div>
)}
{logs.map((log) => (
<div
key={log.id}
className={cn(
"group flex items-start gap-3 p-3 rounded-2xl text-sm transition-all hover:bg-white/60",
"border border-transparent hover:border-white/50 hover:shadow-sm animate-in slide-in-from-bottom-2 fade-in duration-300",
log.type === 'error' && "bg-red-50/50 border-red-100/50",
log.type === 'warn' && "bg-amber-50/50 border-amber-100/50"
)}
>
{/* Icon */}
<div className={cn(
"mt-0.5 shrink-0 h-6 w-6 rounded-full flex items-center justify-center border shadow-sm",
log.type === 'info' && "bg-white border-slate-100 text-slate-400",
log.type === 'success' && "bg-green-100 border-green-200 text-green-600",
log.type === 'warn' && "bg-amber-100 border-amber-200 text-amber-600",
log.type === 'error' && "bg-red-100 border-red-200 text-red-600"
)}>
{log.type === 'info' && <Info className="h-3 w-3" />}
{log.type === 'success' && <CheckCircle2 className="h-3 w-3" />}
{log.type === 'warn' && <AlertTriangle className="h-3 w-3" />}
{log.type === 'error' && <AlertTriangle className="h-3 w-3" />}
</div>
{/* Content */}
<div className="flex-1 min-w-0 space-y-0.5">
<div className="flex items-center gap-2">
<span className="text-[10px] font-bold uppercase tracking-wider text-slate-400 bg-slate-100 px-1.5 py-0.5 rounded-md">
{log.tag}
</span>
<span className="text-[10px] text-slate-300 tabular-nums">
{new Date(log.time).toLocaleTimeString()}
</span>
</div>
<p className={cn(
"text-slate-600 leading-relaxed break-words font-medium",
log.type === 'error' && "text-red-600",
log.type === 'warn' && "text-amber-700"
)}>
{log.msg}
</p>
</div>
</div>
))}
</div>
</ScrollArea>
</div>
)
}

View File

@@ -0,0 +1,274 @@
import { useCallback, useEffect, useState } from "react"
import { RefreshCw, Coins, Search } from "lucide-react"
import { ScrollArea } from "@/components/ui/scroll-area"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { cn } from "@/lib/utils"
interface GoodsItem {
goodsId: number
itemId: number
name: string
price: number
limitCount: number
boughtNum: number
unlocked: boolean
itemCount: number
}
export function ShopPanel({ email, refreshKey }: { email: string, refreshKey?: number }) {
const [goods, setGoods] = useState<GoodsItem[]>([])
const [filteredGoods, setFilteredGoods] = useState<GoodsItem[]>([])
const [isLoading, setIsLoading] = useState(false)
const [isBuying, setIsBuying] = useState(false)
const [searchQuery, setSearchQuery] = useState("")
const [buyDialogOpen, setBuyDialogOpen] = useState(false)
const [selectedGoods, setSelectedGoods] = useState<GoodsItem | null>(null)
const [buyAmount, setBuyAmount] = useState(1)
const [message, setMessage] = useState("")
const [imageMap, setImageMap] = useState<Record<number, string>>({})
const fetchShop = useCallback(async () => {
if (!email) return
setIsLoading(true)
try {
const res = await fetch(`/api/shop?email=${email}`)
const json = await res.json()
if (json.goods) {
setGoods(json.goods)
setFilteredGoods(json.goods)
}
} catch (e) {
console.error(e)
} finally {
setIsLoading(false)
}
}, [email])
useEffect(() => {
fetchShop()
}, [fetchShop, refreshKey])
useEffect(() => {
if (!searchQuery) {
setFilteredGoods(goods)
} else {
const query = searchQuery.toLowerCase()
setFilteredGoods(goods.filter(g => g.name.toLowerCase().includes(query)))
}
}, [searchQuery, goods])
useEffect(() => {
let cancelled = false
fetch('/shop_plants_organized/mapping.csv')
.then(res => res.text())
.then(text => {
if (cancelled) return
const lines = text.split(/\r?\n/).filter(Boolean)
if (lines.length <= 1) {
setImageMap({})
return
}
const map: Record<number, string> = {}
for (let i = 1; i < lines.length; i += 1) {
const line = lines[i].trim()
if (!line) continue
const parts = line.split(',')
if (parts.length < 5) continue
const seedId = Number(parts[0])
const fileName = parts[4]
if (!Number.isNaN(seedId) && fileName) {
map[seedId] = fileName
}
}
setImageMap(map)
})
.catch(() => {
if (!cancelled) setImageMap({})
})
return () => {
cancelled = true
}
}, [])
const openBuyDialog = (item: GoodsItem) => {
setSelectedGoods(item)
setBuyAmount(1)
setMessage("")
setBuyDialogOpen(true)
}
const handleBuy = async () => {
if (!selectedGoods) return
setIsBuying(true)
setMessage("")
try {
const res = await fetch('/api/shop/buy', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email,
goodsId: selectedGoods.goodsId,
count: buyAmount,
price: selectedGoods.price
})
})
const json = await res.json()
if (json.success) {
setMessage(`购买成功!获得 ${selectedGoods.name} x${buyAmount * selectedGoods.itemCount}`)
setTimeout(() => {
setBuyDialogOpen(false)
fetchShop() // Refresh limits/stock if any
}, 1500)
} else {
setMessage(`购买失败: ${json.error}`)
}
} catch (error) {
console.error(error)
setMessage("网络错误")
} finally {
setIsBuying(false)
}
}
return (
<div className="h-full flex flex-col gap-4">
<div className="flex justify-between items-center gap-2">
<div className="relative flex-1">
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="搜索种子..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-8"
/>
</div>
<Button
variant="outline"
size="icon"
onClick={fetchShop}
disabled={isLoading}
className={cn("transition-all", isLoading && "animate-spin")}
>
<RefreshCw className="h-4 w-4" />
</Button>
</div>
<ScrollArea className="flex-1 rounded-md border p-4 bg-muted/20">
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-3">
{filteredGoods.map((item) => (
<div
key={item.goodsId}
className={cn(
"relative flex flex-col items-center justify-between p-3 rounded-lg border bg-card transition-all hover:shadow-md group",
!item.unlocked && "opacity-60 grayscale"
)}
>
{imageMap[item.itemId] && (
<div className="w-full aspect-square rounded-md overflow-hidden bg-muted/30 mb-2">
<img
src={`/shop_plants_organized/${imageMap[item.itemId]}`}
alt={item.name}
className="w-full h-full object-contain"
/>
</div>
)}
<div className="text-center mb-2">
<div className="font-medium text-sm truncate w-full" title={item.name}>{item.name}</div>
<div className="text-xs text-muted-foreground">ID: {item.itemId}</div>
</div>
<div className="flex items-center gap-1 text-amber-500 font-bold text-sm mb-3">
<Coins className="h-3 w-3" />
{item.price}
</div>
<Button
size="sm"
variant={item.unlocked ? "default" : "secondary"}
className="w-full h-7 text-xs"
disabled={!item.unlocked}
onClick={() => openBuyDialog(item)}
>
{item.unlocked ? "购买" : "未解锁"}
</Button>
{item.limitCount > 0 && (
<div className="absolute top-1 right-1 text-[10px] bg-red-100 text-red-600 px-1 rounded">
{item.boughtNum}/{item.limitCount}
</div>
)}
</div>
))}
{filteredGoods.length === 0 && !isLoading && (
<div className="col-span-full text-center text-muted-foreground py-10">
</div>
)}
</div>
</ScrollArea>
<Dialog open={buyDialogOpen} onOpenChange={setBuyDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle> {selectedGoods?.name}</DialogTitle>
<DialogDescription>
: {selectedGoods?.price}
</DialogDescription>
</DialogHeader>
<div className="py-4 flex flex-col gap-4">
<div className="flex items-center justify-center gap-4">
<Button
variant="outline"
size="icon"
onClick={() => setBuyAmount(Math.max(1, buyAmount - 1))}
>
-
</Button>
<div className="text-xl font-bold w-12 text-center">{buyAmount}</div>
<Button
variant="outline"
size="icon"
onClick={() => setBuyAmount(buyAmount + 1)}
>
+
</Button>
</div>
<div className="text-center text-sm">
: <span className="text-amber-600 font-bold text-lg">{(selectedGoods?.price || 0) * buyAmount}</span>
</div>
{message && (
<div className={cn(
"p-2 rounded text-sm text-center",
message.includes("成功") ? "bg-green-100 text-green-700" : "bg-red-100 text-red-700"
)}>
{message}
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setBuyDialogOpen(false)}></Button>
<Button onClick={handleBuy} disabled={isBuying}>
{isBuying ? "购买中..." : "确认购买"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}

View File

@@ -0,0 +1,126 @@
import { User, TrendingUp, Activity } from "lucide-react"
import { cn } from "@/lib/utils"
interface StatusBarProps {
nickname?: string
level?: number
exp?: number
nextLevelExp?: number
gold?: number
tickets?: number
normalFertilizerHours?: number
organicFertilizerHours?: number
status?: 'running' | 'stopped' | 'error'
avatarUrl?: string
code?: string
}
export function StatusBar({
nickname = "Farmer",
level = 1,
exp = 0,
nextLevelExp = 100,
gold = 0,
tickets = 0,
normalFertilizerHours = 0,
organicFertilizerHours = 0,
status = 'stopped',
avatarUrl,
code
}: StatusBarProps) {
const expPercentage = Math.min(100, Math.max(0, (exp / nextLevelExp) * 100))
return (
<div className="w-full bg-white/60 backdrop-blur-md border border-white/50 shadow-sm rounded-3xl p-4 flex flex-col md:flex-row items-center justify-between gap-4 transition-all hover:shadow-md">
{/* User Info */}
<div className="flex items-center gap-4 w-full md:w-auto">
<div className="h-12 w-12 rounded-full bg-gradient-to-br from-blue-200 to-pink-200 flex items-center justify-center shadow-inner ring-2 ring-white overflow-hidden">
{avatarUrl ? (
<img src={avatarUrl} alt="Avatar" className="h-full w-full object-cover" />
) : (
<User className="h-6 w-6 text-slate-600" />
)}
</div>
<div className="flex flex-col">
<div className="flex items-center gap-2">
<span className="font-bold text-slate-700 text-lg">{nickname}</span>
{import.meta.env.DEV && code && (
<span className="text-[10px] text-slate-600 bg-slate-100 px-2 py-0.5 rounded-full font-mono">
{code}
</span>
)}
</div>
<div className="flex items-center gap-2 text-xs text-slate-500 font-medium">
<span className={cn(
"flex items-center gap-1 px-2 py-0.5 rounded-full",
status === 'running' ? "bg-green-100 text-green-700" : "bg-slate-100 text-slate-600"
)}>
<Activity className="h-3 w-3" />
{status === 'running' ? '运行中' : '已停止'}
</span>
<span className="bg-yellow-100 text-yellow-700 px-2 py-0.5 rounded-full flex items-center gap-1">
{level}
</span>
</div>
</div>
</div>
{/* Stats Group */}
<div className="flex items-center gap-4 w-full md:w-auto justify-between md:justify-end flex-1">
{/* Experience Bar */}
<div className="flex flex-col gap-1 flex-1 max-w-[200px]">
<div className="flex justify-between text-xs text-slate-500 font-medium px-1">
<span className="flex items-center gap-1"><TrendingUp className="h-3 w-3" /> </span>
<span>{Math.floor(expPercentage)}%</span>
</div>
<div className="h-2.5 w-full bg-slate-100 rounded-full overflow-hidden border border-slate-200/50">
<div
className="h-full bg-gradient-to-r from-blue-300 to-purple-300 transition-all duration-500 ease-out"
style={{ width: `${expPercentage}%` }}
/>
</div>
</div>
<div className="bg-sky-50/80 border border-sky-100 px-4 py-2 rounded-2xl flex items-center gap-3 shadow-sm min-w-[120px]">
<div className="bg-sky-200 p-1.5 rounded-full">
<img src="/verified_items/1002_点券_50_bd489f54.png" alt="点券" className="h-5 w-5 object-contain" />
</div>
<div className="flex flex-col leading-none">
<span className="text-[10px] text-sky-600 font-bold uppercase tracking-wider"></span>
<span className="text-lg font-bold text-slate-700 font-mono">{tickets.toLocaleString()}</span>
</div>
</div>
<div className="bg-emerald-50/80 border border-emerald-100 px-4 py-2 rounded-2xl flex items-center gap-3 shadow-sm min-w-[140px]">
<div className="bg-emerald-200 p-1.5 rounded-full">
<img src="/verified_items/1011_普通化肥容器_12_3ad710ed.png" alt="普通化肥" className="h-5 w-5 object-contain" />
</div>
<div className="flex flex-col leading-none">
<span className="text-[10px] text-emerald-600 font-bold uppercase tracking-wider"></span>
<span className="text-lg font-bold text-slate-700 font-mono">{normalFertilizerHours.toFixed(1)}h</span>
</div>
</div>
<div className="bg-teal-50/80 border border-teal-100 px-4 py-2 rounded-2xl flex items-center gap-3 shadow-sm min-w-[140px]">
<div className="bg-teal-200 p-1.5 rounded-full">
<img src="/verified_items/1012_有机化肥容器_49_fe1c87b7.png" alt="有机化肥" className="h-5 w-5 object-contain" />
</div>
<div className="flex flex-col leading-none">
<span className="text-[10px] text-teal-600 font-bold uppercase tracking-wider"></span>
<span className="text-lg font-bold text-slate-700 font-mono">{organicFertilizerHours.toFixed(1)}h</span>
</div>
</div>
<div className="bg-yellow-50/80 border border-yellow-100 px-4 py-2 rounded-2xl flex items-center gap-3 shadow-sm min-w-[120px]">
<div className="bg-yellow-200 p-1.5 rounded-full">
<img src="/verified_items/1001_金币_4_65088be3.png" alt="金币" className="h-5 w-5 object-contain" />
</div>
<div className="flex flex-col leading-none">
<span className="text-[10px] text-yellow-600 font-bold uppercase tracking-wider"></span>
<span className="text-lg font-bold text-slate-700 font-mono">{gold.toLocaleString()}</span>
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,79 @@
import { useEffect, useState } from "react"
import { Cpu, Database, Activity, ChevronLeft, ChevronRight } from "lucide-react"
interface SystemStats {
memory: {
rss: number
heapTotal: number
heapUsed: number
}
uptime: number
activeBots: number
}
export function SystemMonitor() {
const [stats, setStats] = useState<SystemStats | null>(null)
const [collapsed, setCollapsed] = useState(true)
useEffect(() => {
const fetchStats = async () => {
try {
const response = await fetch('/api/system/stats')
if (response.ok) {
const data = await response.json()
setStats(data)
}
} catch (error) {
console.error('Failed to fetch system stats:', error)
}
}
fetchStats()
const interval = setInterval(fetchStats, 5000) // Update every 5s
return () => clearInterval(interval)
}, [])
if (!stats) {
return (
<div className="fixed bottom-4 right-4 z-50 bg-white/80 backdrop-blur-md border border-white/50 shadow-lg rounded-2xl p-3 flex items-center gap-2 text-xs font-medium text-slate-500">
<Activity className="h-3 w-3 animate-spin" />
<span>Loading stats...</span>
</div>
)
}
return (
<div
className={`fixed bottom-4 right-4 z-50 bg-white/80 backdrop-blur-md border border-white/50 shadow-lg rounded-2xl text-xs font-medium text-slate-600 transition-all hover:opacity-100 opacity-70 overflow-hidden h-20 ${collapsed ? "w-8" : "w-56"}`}
>
<button
className="absolute right-1 top-1/2 -translate-y-1/2 h-6 w-6 rounded-full bg-white/80 border border-white/70 text-slate-500 hover:text-slate-700 transition"
onClick={() => setCollapsed(prev => !prev)}
title={collapsed ? "展开" : "收起"}
>
{collapsed ? <ChevronLeft className="h-4 w-4 mx-auto" /> : <ChevronRight className="h-4 w-4 mx-auto" />}
</button>
<div className={`p-3 flex flex-col gap-2 transition-all ${collapsed ? "opacity-0 pointer-events-none -translate-x-2" : "opacity-100"}`}>
<div className="flex items-center gap-2" title="系统运行时间">
<Activity className="h-3 w-3 text-blue-500" />
<span>: {formatUptime(stats.uptime)}</span>
</div>
<div className="flex items-center gap-2" title={`总占用: ${stats.memory.rss}MB | JS对象: ${stats.memory.heapUsed}MB`}>
<Database className="h-3 w-3 text-purple-500" />
<span>: {stats.memory.rss} MB (: {stats.memory.heapUsed} MB)</span>
</div>
<div className="flex items-center gap-2" title="当前在线挂机账号数">
<Cpu className="h-3 w-3 text-green-500" />
<span>: {stats.activeBots}</span>
</div>
</div>
</div>
)
}
function formatUptime(seconds: number) {
const h = Math.floor(seconds / 3600)
const m = Math.floor((seconds % 3600) / 60)
const s = seconds % 60
return `${h}h ${m}m ${s}s`
}

View File

@@ -0,0 +1,595 @@
import { useCallback, useEffect, useState, type ComponentType, type SVGProps } from "react"
import { Package, Leaf, Apple, Zap, RefreshCw, CheckCircle2, Circle, Coins } from "lucide-react"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { ScrollArea } from "@/components/ui/scroll-area"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { cn } from "@/lib/utils"
interface Item {
id: number
uid: number
name: string
count: number
type: 'seed' | 'produce' | 'other'
}
interface WarehouseData {
seeds: Item[]
produce: Item[]
others: Item[]
}
interface Gain {
id: number
name: string
count: number
}
export function WarehousePanel({ email, refreshKey, onRefresh }: { email: string, refreshKey?: number, onRefresh?: () => void }) {
const [data, setData] = useState<WarehouseData>({ seeds: [], produce: [], others: [] })
const [isLoading, setIsLoading] = useState(false)
const [isSelling, setIsSelling] = useState(false)
const [selectedItems, setSelectedItems] = useState<Set<string>>(new Set()) // Format: "id-uid"
const [isSelectionMode, setIsSelectionMode] = useState(false)
const [activeTab, setActiveTab] = useState("seeds")
const [message, setMessage] = useState("")
const [seedImageMap, setSeedImageMap] = useState<Record<number, string>>({})
const [nameImageMap, setNameImageMap] = useState<Record<string, string>>({})
const [verifiedItemMap, setVerifiedItemMap] = useState<Record<number, string>>({})
const [useDialogOpen, setUseDialogOpen] = useState(false)
const [useTargets, setUseTargets] = useState<Item[]>([])
const [useQuantities, setUseQuantities] = useState<Record<string, number>>({})
const [gainDialogOpen, setGainDialogOpen] = useState(false)
const [gains, setGains] = useState<Gain[]>([])
const fetchWarehouse = useCallback(async (silent = false) => {
if (!email) return
if (!silent) setIsLoading(true)
try {
const res = await fetch(`/api/warehouse?email=${email}`)
const json = await res.json()
if (json.seeds) {
setData(json)
}
} catch (e) {
console.error(e)
} finally {
if (!silent) setIsLoading(false)
}
}, [email])
useEffect(() => {
fetchWarehouse()
const interval = setInterval(() => fetchWarehouse(true), 5000)
return () => clearInterval(interval)
}, [fetchWarehouse, refreshKey])
useEffect(() => {
let cancelled = false
fetch('/shop_plants_organized/mapping.csv')
.then(res => res.text())
.then(text => {
if (cancelled) return
const lines = text.split(/\r?\n/).filter(Boolean)
if (lines.length <= 1) {
setSeedImageMap({})
setNameImageMap({})
return
}
const map: Record<number, string> = {}
const nameMap: Record<string, string> = {}
for (let i = 1; i < lines.length; i += 1) {
const line = lines[i].trim()
if (!line) continue
const parts = line.split(',')
if (parts.length < 5) continue
const seedId = Number(parts[0])
const fileName = parts[4]
if (!Number.isNaN(seedId) && fileName) {
map[seedId] = fileName
}
if (fileName) {
const name = fileName.split('_from_')[0]
if (name) nameMap[name] = fileName
}
}
setSeedImageMap(map)
setNameImageMap(nameMap)
})
.catch(() => {
if (!cancelled) {
setSeedImageMap({})
setNameImageMap({})
}
})
return () => {
cancelled = true
}
}, [])
useEffect(() => {
let cancelled = false
fetch('/verified_items/mapping.json')
.then(res => res.json())
.then((list: Array<{ id: string, path: string }>) => {
if (cancelled || !Array.isArray(list)) return
const map: Record<number, string> = {}
for (const item of list) {
const id = Number(item.id)
if (!Number.isNaN(id) && item.path) {
map[id] = item.path
}
}
setVerifiedItemMap(map)
})
.catch(() => {
if (!cancelled) setVerifiedItemMap({})
})
return () => {
cancelled = true
}
}, [])
const toggleSelection = (item: Item) => {
const key = `${item.id}-${item.uid}`
const newSet = new Set(selectedItems)
if (newSet.has(key)) {
newSet.delete(key)
} else {
newSet.add(key)
}
setSelectedItems(newSet)
}
const collectSelectedItems = useCallback(() => {
if (selectedItems.size === 0) return []
const allItems = [...data.seeds, ...data.produce, ...data.others]
const result: Item[] = []
selectedItems.forEach(key => {
const [idStr, uidStr] = key.split('-')
const id = Number(idStr)
const uid = Number(uidStr)
const item = allItems.find(i => i.id === id && i.uid === uid)
if (item) result.push(item)
})
return result
}, [data.others, data.produce, data.seeds, selectedItems])
const closeUseDialog = useCallback(() => {
setUseDialogOpen(false)
setUseTargets([])
setUseQuantities({})
}, [])
const openUseDialogForItems = useCallback((list: Item[]) => {
if (list.length === 0) return
const nextQuantities: Record<string, number> = {}
list.forEach(item => {
nextQuantities[`${item.id}-${item.uid}`] = 1
})
setUseTargets(list)
setUseQuantities(nextQuantities)
setUseDialogOpen(true)
}, [])
const openUseDialog = useCallback(() => {
const list = collectSelectedItems()
openUseDialogForItems(list)
}, [collectSelectedItems, openUseDialogForItems])
const updateUseQuantity = useCallback((key: string, value: string, maxCount: number) => {
const parsed = Number(value)
const next = Number.isNaN(parsed) ? 1 : Math.floor(parsed)
const clamped = Math.max(1, Math.min(maxCount, next))
setUseQuantities(prev => {
if (prev[key] === clamped) return prev
return { ...prev, [key]: clamped }
})
}, [])
const handleSell = async () => {
if (selectedItems.size === 0) return
if (activeTab === 'others') {
openUseDialog()
return
}
setIsSelling(true)
setMessage("")
const selectedList = collectSelectedItems()
if (selectedList.length === 0) {
setIsSelling(false)
return
}
const itemsToSell: { id: number, count: number, uid: number }[] = []
selectedList.forEach(item => {
itemsToSell.push({ id: item.id, count: item.count, uid: item.uid })
})
try {
const res = await fetch('/api/warehouse/sell', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, items: itemsToSell })
})
const json = await res.json()
if (json.success) {
setMessage(`出售成功!获得 ${json.gold} 金币`)
setSelectedItems(new Set())
setIsSelectionMode(false)
fetchWarehouse()
onRefresh?.()
} else {
setMessage(`出售失败: ${json.error}`)
}
} catch (error) {
console.error(error)
setMessage("网络错误")
} finally {
setIsSelling(false)
setTimeout(() => setMessage(""), 3000)
}
}
const handleUse = async () => {
if (useTargets.length === 0) return
setIsSelling(true)
setMessage("")
setGains([])
const itemsToUse: { id: number, count: number, uid: number }[] = []
useTargets.forEach(item => {
const key = `${item.id}-${item.uid}`
const selectedCount = useQuantities[key] ?? 1
const count = Math.max(1, Math.min(item.count, selectedCount))
itemsToUse.push({ id: item.id, count, uid: item.uid })
})
if (itemsToUse.length === 0) {
setIsSelling(false)
return
}
try {
const res = await fetch('/api/warehouse/use', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, items: itemsToUse })
})
const json = await res.json()
if (json.success) {
setMessage(json.message || "使用成功")
if (Array.isArray(json.gains) && json.gains.length > 0) {
setGains(json.gains)
setGainDialogOpen(true)
}
setSelectedItems(new Set())
setIsSelectionMode(false)
fetchWarehouse()
onRefresh?.()
closeUseDialog()
} else {
setMessage(`使用失败: ${json.error}`)
}
} catch (error) {
console.error(error)
setMessage("网络错误")
} finally {
setIsSelling(false)
setTimeout(() => setMessage(""), 3000)
}
}
const getGainImage = (id: number, name?: string) => {
if (verifiedItemMap[id]) return `/${verifiedItemMap[id]}`
if (seedImageMap[id]) return `/shop_plants_organized/${seedImageMap[id]}`
if (name && nameImageMap[name]) return `/shop_plants_organized/${nameImageMap[name]}`
return undefined
}
return (
<div className="bg-white/60 backdrop-blur-md rounded-[2.5rem] p-6 shadow-sm border border-white/50 h-[600px] flex flex-col relative overflow-hidden">
<Dialog open={useDialogOpen} onOpenChange={(open) => { if (!open) closeUseDialog() }}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>使</DialogTitle>
</DialogHeader>
<div className="space-y-3">
{useTargets.map(item => {
const key = `${item.id}-${item.uid}`
const value = useQuantities[key] ?? 1
return (
<div key={key} className="flex items-center justify-between gap-3">
<div className="flex-1 min-w-0">
<div className="text-sm font-semibold text-slate-700 truncate">{item.name}</div>
<div className="text-xs text-slate-400"> {item.count}</div>
</div>
<div className="w-24">
<Input
type="number"
min={1}
max={item.count}
value={value}
onChange={(event) => updateUseQuantity(key, event.target.value, item.count)}
className="text-center"
/>
</div>
</div>
)
})}
</div>
<div className="flex items-center justify-end gap-2 pt-2">
<Button variant="ghost" onClick={closeUseDialog}>
</Button>
<Button onClick={handleUse} disabled={isSelling}>
使
</Button>
</div>
</DialogContent>
</Dialog>
<Dialog open={gainDialogOpen} onOpenChange={setGainDialogOpen}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3 max-h-[60vh] overflow-y-auto pr-1">
{gains.map(gain => {
const imagePath = getGainImage(gain.id, gain.name)
return (
<div key={`${gain.id}-${gain.count}`} className="flex items-center gap-3 p-3 rounded-2xl bg-slate-50 border border-slate-100">
{imagePath ? (
<div className="w-12 h-12 rounded-full bg-white shadow-sm flex items-center justify-center overflow-hidden">
<img src={imagePath} alt={gain.name} className="w-9 h-9 object-contain" />
</div>
) : (
<div className="w-12 h-12 rounded-full bg-white shadow-sm flex items-center justify-center text-slate-400">
<Package className="w-6 h-6" />
</div>
)}
<div className="min-w-0 flex-1">
<div className="text-sm font-semibold text-slate-700 break-words">{gain.name}</div>
<div className="text-xs text-slate-400">x{gain.count}</div>
</div>
</div>
)
})}
</div>
<div className="flex items-center justify-end pt-2">
<Button onClick={() => setGainDialogOpen(false)}></Button>
</div>
</DialogContent>
</Dialog>
<div className="flex items-center justify-between mb-6 px-2">
<h2 className="text-xl font-bold text-slate-700 flex items-center gap-2">
<span className="p-2 bg-orange-100 text-orange-600 rounded-xl">
<Package className="h-5 w-5" />
</span>
</h2>
<div className="flex items-center gap-2">
{message && (
<span className="text-sm font-medium text-green-600 animate-fade-in bg-green-50 px-3 py-1 rounded-full border border-green-100">
{message}
</span>
)}
<Button
variant={isSelectionMode ? "secondary" : "ghost"}
size="sm"
onClick={() => {
setIsSelectionMode(!isSelectionMode)
setSelectedItems(new Set())
}}
className="rounded-xl"
>
{isSelectionMode ? "取消选择" : (activeTab === 'others' ? "批量使用" : "批量出售")}
</Button>
<button
onClick={() => fetchWarehouse()}
className="p-2 hover:bg-white/50 rounded-full transition-colors text-slate-400 hover:text-slate-600"
>
<RefreshCw className={cn("h-5 w-5", isLoading && "animate-spin")} />
</button>
</div>
</div>
<Tabs value={activeTab} className="flex-1 flex flex-col min-h-0" onValueChange={(val) => {
setActiveTab(val)
setSelectedItems(new Set())
setIsSelectionMode(false)
}}>
<TabsList className="bg-slate-100/50 p-1 rounded-2xl w-full justify-start mb-4">
<TabsTrigger value="seeds" className="rounded-xl data-[state=active]:bg-white data-[state=active]:text-green-600 data-[state=active]:shadow-sm flex-1">
<Leaf className="h-4 w-4 mr-2" />
</TabsTrigger>
<TabsTrigger value="produce" className="rounded-xl data-[state=active]:bg-white data-[state=active]:text-red-500 data-[state=active]:shadow-sm flex-1">
<Apple className="h-4 w-4 mr-2" />
</TabsTrigger>
<TabsTrigger value="others" className="rounded-xl data-[state=active]:bg-white data-[state=active]:text-purple-600 data-[state=active]:shadow-sm flex-1">
<Zap className="h-4 w-4 mr-2" />
</TabsTrigger>
</TabsList>
<ScrollArea className="flex-1 pr-4 -mr-4">
<TabsContent value="seeds" className="mt-0">
<ItemGrid
items={data.seeds}
color="green"
icon={Leaf}
emptyMsg="没有种子啦"
isSelectionMode={isSelectionMode}
selectedItems={selectedItems}
onToggle={toggleSelection}
seedImageMap={seedImageMap}
nameImageMap={nameImageMap}
verifiedItemMap={verifiedItemMap}
/>
</TabsContent>
<TabsContent value="produce" className="mt-0">
<ItemGrid
items={data.produce}
color="red"
icon={Apple}
emptyMsg="空空如也"
isSelectionMode={isSelectionMode}
selectedItems={selectedItems}
onToggle={toggleSelection}
seedImageMap={seedImageMap}
nameImageMap={nameImageMap}
verifiedItemMap={verifiedItemMap}
/>
</TabsContent>
<TabsContent value="others" className="mt-0">
<ItemGrid
items={data.others}
color="purple"
icon={Zap}
emptyMsg="啥也没有"
isSelectionMode={isSelectionMode}
selectedItems={selectedItems}
onToggle={toggleSelection}
onItemClick={(item) => {
if (isSelectionMode) return
setSelectedItems(new Set())
openUseDialogForItems([item])
}}
seedImageMap={seedImageMap}
nameImageMap={nameImageMap}
verifiedItemMap={verifiedItemMap}
/>
</TabsContent>
</ScrollArea>
</Tabs>
{isSelectionMode && selectedItems.size > 0 && (
<div className="absolute bottom-6 left-1/2 -translate-x-1/2 z-10 animate-in slide-in-from-bottom-4 fade-in duration-300">
<Button
onClick={handleSell}
disabled={isSelling}
className={cn(
"rounded-2xl px-8 shadow-lg text-white border-0",
activeTab === 'others'
? "shadow-purple-200 bg-gradient-to-r from-purple-400 to-indigo-500 hover:from-purple-500 hover:to-indigo-600"
: "shadow-orange-200 bg-gradient-to-r from-orange-400 to-red-500 hover:from-orange-500 hover:to-red-600"
)}
>
{isSelling ? (
<RefreshCw className="h-4 w-4 mr-2 animate-spin" />
) : (
activeTab === 'others' ? <Zap className="h-4 w-4 mr-2" /> : <Coins className="h-4 w-4 mr-2" />
)}
{activeTab === 'others' ? `确认使用 (${selectedItems.size})` : `确认出售 (${selectedItems.size})`}
</Button>
</div>
)}
</div>
)
}
function ItemGrid({
items,
color,
icon: Icon,
emptyMsg,
isSelectionMode,
selectedItems,
onToggle,
onItemClick,
seedImageMap,
nameImageMap,
verifiedItemMap
}: {
items: Item[],
color: string,
icon: ComponentType<SVGProps<SVGSVGElement>>,
emptyMsg: string,
isSelectionMode: boolean,
selectedItems: Set<string>,
onToggle: (item: Item) => void,
onItemClick?: (item: Item) => void,
seedImageMap: Record<number, string>,
nameImageMap: Record<string, string>,
verifiedItemMap: Record<number, string>
}) {
if (items.length === 0) {
return (
<div className="h-40 flex flex-col items-center justify-center text-slate-400 gap-2">
<Package className="h-10 w-10 opacity-20" />
<span className="text-sm">{emptyMsg}</span>
</div>
)
}
const colorStyles = {
green: "bg-green-50 border-green-100 text-green-700",
red: "bg-red-50 border-red-100 text-red-700",
purple: "bg-purple-50 border-purple-100 text-purple-700",
}
return (
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3 pb-20">
{items.map(item => {
const key = `${item.id}-${item.uid}`
const isSelected = selectedItems.has(key)
const imagePath = item.type === 'seed'
? seedImageMap[item.id]
? `/shop_plants_organized/${seedImageMap[item.id]}`
: undefined
: item.type === 'produce'
? nameImageMap[item.name]
? `/shop_plants_organized/${nameImageMap[item.name]}`
: undefined
: item.type === 'other'
? verifiedItemMap[item.id]
? `/${verifiedItemMap[item.id]}`
: undefined
: undefined
return (
<div
key={key}
onClick={() => {
if (isSelectionMode) {
onToggle(item)
return
}
onItemClick?.(item)
}}
className={cn(
"relative group flex flex-col items-center p-4 rounded-2xl border transition-all duration-300",
colorStyles[color as keyof typeof colorStyles] || colorStyles.green,
isSelectionMode ? "cursor-pointer hover:shadow-md" : (onItemClick ? "cursor-pointer hover:shadow-md" : "cursor-default hover:scale-[1.02]"),
isSelected && "ring-2 ring-offset-2 ring-orange-400 bg-orange-50 border-orange-200"
)}
>
{isSelectionMode && (
<div className="absolute top-2 right-2">
{isSelected ? (
<CheckCircle2 className="h-5 w-5 text-orange-500 fill-white" />
) : (
<Circle className="h-5 w-5 text-slate-300" />
)}
</div>
)}
{imagePath ? (
<div className="bg-white/60 p-2 rounded-full mb-2 shadow-sm">
<img src={imagePath} alt={item.name} className="h-10 w-10 object-contain" />
</div>
) : (
<div className="bg-white/60 p-3 rounded-full mb-2 shadow-sm">
<Icon className="h-6 w-6" />
</div>
)}
<span className="font-bold text-sm text-center line-clamp-1">{item.name}</span>
<span className="text-xs opacity-70 mt-1">x{item.count}</span>
</div>
)
})}
</div>
)
}

View File

@@ -0,0 +1,46 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Avatar = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full",
className
)}
{...props}
/>
))
Avatar.displayName = "Avatar"
const AvatarImage = React.forwardRef<
HTMLImageElement,
React.ImgHTMLAttributes<HTMLImageElement>
>(({ className, ...props }, ref) => (
<img
ref={ref}
className={cn("aspect-square h-full w-full", className)}
{...props}
/>
))
AvatarImage.displayName = "AvatarImage"
const AvatarFallback = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"flex h-full w-full items-center justify-center rounded-full bg-slate-100",
className
)}
{...props}
/>
))
AvatarFallback.displayName = "AvatarFallback"
export { Avatar, AvatarImage, AvatarFallback }

View File

@@ -0,0 +1,56 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline:
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
}
)
Button.displayName = "Button"
export { Button }

View File

@@ -0,0 +1,79 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-lg border bg-card text-card-foreground shadow-sm",
className
)}
{...props}
/>
))
Card.displayName = "Card"
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
))
CardHeader.displayName = "CardHeader"
const CardTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn(
"text-2xl font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
CardTitle.displayName = "CardTitle"
const CardDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<p
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
CardDescription.displayName = "CardDescription"
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
))
CardContent.displayName = "CardContent"
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props}
/>
))
CardFooter.displayName = "CardFooter"
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }

View File

@@ -0,0 +1,120 @@
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
const Dialog = DialogPrimitive.Root
const DialogTrigger = DialogPrimitive.Trigger
const DialogPortal = DialogPrimitive.Portal
const DialogClose = DialogPrimitive.Close
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
/>
))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
))
DialogContent.displayName = DialogPrimitive.Content.displayName
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left",
className
)}
{...props}
/>
)
DialogHeader.displayName = "DialogHeader"
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
DialogFooter.displayName = "DialogFooter"
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
DialogTitle.displayName = DialogPrimitive.Title.displayName
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
DialogDescription.displayName = DialogPrimitive.Description.displayName
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogTrigger,
DialogClose,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
}

View File

@@ -0,0 +1,24 @@
import * as React from "react"
import { cn } from "@/lib/utils"
export type InputProps = React.InputHTMLAttributes<HTMLInputElement>
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
ref={ref}
{...props}
/>
)
}
)
Input.displayName = "Input"
export { Input }

View File

@@ -0,0 +1,46 @@
import * as React from "react"
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
import { cn } from "@/lib/utils"
const ScrollArea = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
>(({ className, children, ...props }, ref) => (
<ScrollAreaPrimitive.Root
ref={ref}
className={cn("relative overflow-hidden", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
))
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
const ScrollBar = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
>(({ className, orientation = "vertical", ...props }, ref) => (
<ScrollAreaPrimitive.ScrollAreaScrollbar
ref={ref}
orientation={orientation}
className={cn(
"flex touch-none select-none transition-colors",
orientation === "vertical" &&
"h-full w-2.5 border-l border-transparent p-[1px]",
orientation === "horizontal" &&
"h-2.5 flex-col border-t border-transparent p-[1px]",
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-slate-300/50 hover:bg-slate-400/50 transition-colors" />
</ScrollAreaPrimitive.ScrollAreaScrollbar>
))
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
export { ScrollArea, ScrollBar }

View File

@@ -0,0 +1,55 @@
"use client"
import * as React from "react"
import * as TabsPrimitive from "@radix-ui/react-tabs"
import { cn } from "@/lib/utils"
const Tabs = TabsPrimitive.Root
const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
"inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground",
className
)}
{...props}
/>
))
TabsList.displayName = TabsPrimitive.List.displayName
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow",
className
)}
{...props}
/>
))
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn(
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
className
)}
{...props}
/>
))
TabsContent.displayName = TabsPrimitive.Content.displayName
export { Tabs, TabsList, TabsTrigger, TabsContent }

111
web/src/index.css Normal file
View File

@@ -0,0 +1,111 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@font-face {
font-family: 'MapleMono';
src: url('./assets/fonts/MapleMono-NF-CN-Regular.woff2') format('woff2');
font-weight: normal;
font-style: normal;
font-display: swap;
}
@layer base {
:root {
/* Base background - Very soft warm white */
--background: 0 0% 100%;
/* Foreground - Soft Charcoal, not harsh black */
--foreground: 0 0% 3.9%;
/* Card background - White with slight transparency for glassmorphism */
--card: 0 0% 100%;
--card-foreground: 0 0% 3.9%;
--popover: 0 0% 100%;
--popover-foreground: 0 0% 3.9%;
/* Primary - Soft Sky Blue (Pastel) */
--primary: 0 0% 9%;
--primary-foreground: 0 0% 98%;
/* Secondary - Light Pink (Pastel) */
--secondary: 0 0% 96.1%;
--secondary-foreground: 0 0% 9%;
/* Muted - Soft Lavender */
--muted: 0 0% 96.1%;
--muted-foreground: 0 0% 45.1%;
/* Accent - Mint Green or Buttercream (used for highlights) */
--accent: 0 0% 96.1%;
--accent-foreground: 0 0% 9%;
/* Destructive - Soft Coral/Salmon */
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 98%;
--border: 0 0% 89.8%;
--input: 0 0% 89.8%;
/* Ring - Matches Primary but slightly stronger */
--ring: 0 0% 3.9%;
--radius: 0.5rem; /* More rounded corners for softer feel */ --chart-1: 12 76% 61%; --chart-2: 173 58% 39%; --chart-3: 197 37% 24%; --chart-4: 43 74% 66%; --chart-5: 27 87% 67%;
}
.dark {
--background: 0 0% 3.9%;
--foreground: 0 0% 98%;
--card: 0 0% 3.9%;
--card-foreground: 0 0% 98%;
--popover: 0 0% 3.9%;
--popover-foreground: 0 0% 98%;
--primary: 0 0% 98%;
--primary-foreground: 0 0% 9%;
--secondary: 0 0% 14.9%;
--secondary-foreground: 0 0% 98%;
--muted: 0 0% 14.9%;
--muted-foreground: 0 0% 63.9%;
--accent: 0 0% 14.9%;
--accent-foreground: 0 0% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 0% 98%;
--border: 0 0% 14.9%;
--input: 0 0% 14.9%;
--ring: 0 0% 83.1%;
--chart-1: 220 70% 50%;
--chart-2: 160 60% 45%;
--chart-3: 30 80% 55%;
--chart-4: 280 65% 60%;
--chart-5: 340 75% 55%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground font-sans;
margin: 0;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}

6
web/src/lib/utils.ts Normal file
View File

@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

10
web/src/main.tsx Normal file
View File

@@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)

2339
web/src/pages/Dashboard.tsx Normal file

File diff suppressed because it is too large Load Diff

238
web/src/pages/Login.tsx Normal file
View File

@@ -0,0 +1,238 @@
import { useEffect, useState } from "react"
import { useNavigate } from "react-router-dom"
import { Heart, Star, Cloud, Loader2 } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"
import logo from "@/assets/logo.png"
export default function Login() {
const [isRegister, setIsRegister] = useState(false)
const [showPasswordLogin, setShowPasswordLogin] = useState(false)
const [email, setEmail] = useState("")
const [password, setPassword] = useState("")
const [confirmPassword, setConfirmPassword] = useState("")
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState("")
const [oauthEnabled, setOauthEnabled] = useState(false)
const navigate = useNavigate()
useEffect(() => {
const params = new URLSearchParams(window.location.search)
const oauthEmail = params.get('email')
const oauthStatus = params.get('oauth')
if (oauthStatus === 'success' && oauthEmail) {
const loadProfile = async () => {
let hasCode = false
try {
const res = await fetch(`/api/auth/profile?email=${encodeURIComponent(oauthEmail)}`)
if (res.ok) {
const data = await res.json()
hasCode = Boolean(data?.user?.hasCode)
}
} catch {
hasCode = false
}
const user = { email: oauthEmail, auth_provider: 'authentik', hasCode }
localStorage.setItem('farm_user', JSON.stringify(user))
localStorage.removeItem('farm_code')
navigate("/dashboard")
}
loadProfile()
return
}
if (params.get('error') === 'oauth_failed') {
setError("OAuth 登录失败,请重试")
}
}, [navigate])
useEffect(() => {
let cancelled = false
fetch('/api/auth/oauth/status')
.then((res) => res.json())
.then((data) => {
if (!cancelled) setOauthEnabled(Boolean(data?.enabled))
})
.catch(() => {
if (!cancelled) setOauthEnabled(false)
})
return () => {
cancelled = true
}
}, [])
const handleOAuthLogin = () => {
window.location.href = '/api/auth/oauth/authentik';
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!email || !password) return
if (isRegister && password !== confirmPassword) {
setError("两次输入的密码不一致")
return
}
setIsLoading(true)
setError("")
try {
const endpoint = isRegister ? '/api/auth/register' : '/api/auth/login'
const response = await fetch(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ email, password }),
})
const data = await response.json()
if (!response.ok) {
throw new Error(data.error || (isRegister ? '注册失败' : '登录失败'))
}
// Save user info to localStorage
localStorage.setItem('farm_user', JSON.stringify(data.user))
// Clear old code if exists
localStorage.removeItem('farm_code')
navigate("/dashboard")
} catch (err) {
const message = err instanceof Error ? err.message : '出错了'
setError(message || '出错了')
} finally {
setIsLoading(false)
}
}
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 via-pink-50 to-purple-50 p-4 overflow-hidden relative">
{/* Decorative Background Elements */}
<div className="absolute top-20 left-20 text-blue-200 animate-bounce delay-1000 duration-3000">
<Cloud size={64} fill="currentColor" className="opacity-50" />
</div>
<div className="absolute bottom-40 right-20 text-pink-200 animate-pulse delay-700">
<Heart size={48} fill="currentColor" className="opacity-50" />
</div>
<div className="absolute top-1/3 right-1/4 text-yellow-100 animate-spin-slow">
<Star size={32} fill="currentColor" className="opacity-60" />
</div>
<Card className="w-full max-w-md relative z-10 border-white/50 bg-white/80 backdrop-blur-sm shadow-[0_8px_30px_rgb(0,0,0,0.04)] rounded-3xl overflow-hidden transition-all duration-500">
<div className="absolute top-0 left-0 w-full h-2 bg-gradient-to-r from-blue-200 via-pink-200 to-purple-200"></div>
<CardHeader className="text-center pt-10 pb-2">
<div className="mx-auto p-2 rounded-full w-24 h-24 flex items-center justify-center mb-4 transition-transform hover:scale-105 duration-300">
<img src={logo} alt="Logo" className="h-full w-full object-contain drop-shadow-sm" />
</div>
<CardTitle className="text-3xl font-bold text-slate-700 tracking-tight">
{isRegister ? '加入谢尔达莱群岛' : '谢尔达莱群岛'}
</CardTitle>
<CardDescription className="text-slate-500 mt-2">
{isRegister ? '开启你的田园生活' : '欢迎回到你的温馨农场'} <br/>
<span className="text-xs text-pink-400"></span>
</CardDescription>
</CardHeader>
<form onSubmit={handleSubmit}>
<CardContent className="space-y-6 pt-6">
<div className="space-y-4">
{oauthEnabled && (
<Button
type="button"
onClick={handleOAuthLogin}
className="w-full h-12 text-lg rounded-2xl bg-gradient-to-r from-blue-300 to-pink-300 hover:from-blue-400 hover:to-pink-400 text-white border-0 shadow-lg shadow-blue-100 transition-all duration-300 hover:scale-[1.02] active:scale-[0.98]"
>
<div className="flex items-center gap-2">
<span className="text-base">使ID登录</span>
</div>
</Button>
)}
<button
type="button"
onClick={() => {
setShowPasswordLogin(!showPasswordLogin)
setError("")
}}
className="w-full text-sm text-slate-400 hover:text-slate-600 font-medium transition-colors"
>
{showPasswordLogin ? '收起账号密码登录' : '使用账号密码登录'}
</button>
{showPasswordLogin && (
<div className="space-y-4 pt-2 animate-in slide-in-from-top-2 fade-in duration-300">
<div className="space-y-2">
<Input
type="email"
placeholder="邮箱"
value={email}
onChange={(e) => setEmail(e.target.value)}
disabled={isLoading}
className="h-12 text-center text-lg bg-slate-50/50 border-slate-100 focus:border-blue-200 focus:ring-blue-100 rounded-2xl transition-all duration-300 hover:bg-white"
/>
<Input
type="password"
placeholder="密码"
value={password}
onChange={(e) => setPassword(e.target.value)}
disabled={isLoading}
className="h-12 text-center text-lg bg-slate-50/50 border-slate-100 focus:border-blue-200 focus:ring-blue-100 rounded-2xl transition-all duration-300 hover:bg-white"
/>
{isRegister && (
<Input
type="password"
placeholder="确认密码"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
disabled={isLoading}
className="h-12 text-center text-lg bg-slate-50/50 border-slate-100 focus:border-blue-200 focus:ring-blue-100 rounded-2xl transition-all duration-300 hover:bg-white animate-in slide-in-from-top-2 fade-in duration-300"
/>
)}
</div>
{error && (
<div className="text-center text-sm text-red-400 bg-red-50 py-2 rounded-lg animate-in fade-in slide-in-from-top-1">
{error}
</div>
)}
<Button
type="submit"
className="w-full h-12 text-lg rounded-2xl bg-gradient-to-r from-blue-300 to-pink-300 hover:from-blue-400 hover:to-pink-400 text-white border-0 shadow-lg shadow-blue-100 transition-all duration-300 hover:scale-[1.02] active:scale-[0.98]"
disabled={isLoading}
>
{isLoading ? (
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
) : (
isRegister ? '注册账号' : '立即登录'
)}
</Button>
<div className="flex items-center justify-center gap-2 text-sm text-slate-400">
<span>{isRegister ? '已有账号?' : '还没有账号?'}</span>
<button
type="button"
onClick={() => {
setIsRegister(!isRegister)
setError("")
setConfirmPassword("")
}}
className="text-blue-400 hover:text-blue-500 font-medium transition-colors hover:underline"
>
{isRegister ? '直接登录' : '注册一个'}
</button>
</div>
</div>
)}
</div>
</CardContent>
<CardFooter className="flex flex-col gap-4 pb-8" />
</form>
</Card>
<div className="absolute bottom-4 text-slate-300 text-sm font-light">
Karriis 🌸
</div>
</div>
)
}