feat: initial commit for TheFarmer project
This commit is contained in:
42
web/src/App.css
Normal file
42
web/src/App.css
Normal 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
24
web/src/App.tsx
Normal 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
|
||||
BIN
web/src/assets/fonts/MapleMono-NF-CN-Regular.woff2
Normal file
BIN
web/src/assets/fonts/MapleMono-NF-CN-Regular.woff2
Normal file
Binary file not shown.
BIN
web/src/assets/logo.png
Normal file
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
1
web/src/assets/react.svg
Normal 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 |
244
web/src/components/FarmGrid.tsx
Normal file
244
web/src/components/FarmGrid.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
108
web/src/components/FriendList.tsx
Normal file
108
web/src/components/FriendList.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
265
web/src/components/LogPanel.tsx
Normal file
265
web/src/components/LogPanel.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
274
web/src/components/ShopPanel.tsx
Normal file
274
web/src/components/ShopPanel.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
126
web/src/components/StatusBar.tsx
Normal file
126
web/src/components/StatusBar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
79
web/src/components/SystemMonitor.tsx
Normal file
79
web/src/components/SystemMonitor.tsx
Normal 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`
|
||||
}
|
||||
595
web/src/components/WarehousePanel.tsx
Normal file
595
web/src/components/WarehousePanel.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
46
web/src/components/ui/avatar.tsx
Normal file
46
web/src/components/ui/avatar.tsx
Normal 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 }
|
||||
56
web/src/components/ui/button.tsx
Normal file
56
web/src/components/ui/button.tsx
Normal 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 }
|
||||
79
web/src/components/ui/card.tsx
Normal file
79
web/src/components/ui/card.tsx
Normal 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 }
|
||||
120
web/src/components/ui/dialog.tsx
Normal file
120
web/src/components/ui/dialog.tsx
Normal 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,
|
||||
}
|
||||
24
web/src/components/ui/input.tsx
Normal file
24
web/src/components/ui/input.tsx
Normal 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 }
|
||||
46
web/src/components/ui/scroll-area.tsx
Normal file
46
web/src/components/ui/scroll-area.tsx
Normal 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 }
|
||||
55
web/src/components/ui/tabs.tsx
Normal file
55
web/src/components/ui/tabs.tsx
Normal 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
111
web/src/index.css
Normal 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
6
web/src/lib/utils.ts
Normal 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
10
web/src/main.tsx
Normal 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
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
238
web/src/pages/Login.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user