feat: 城中村租房平台 - 租客浏览、房东发布房源、图片上传

This commit is contained in:
Cuishibing
2026-03-22 10:15:31 +08:00
parent f03cf328dd
commit ce9dfae7c5
15 changed files with 1682 additions and 87 deletions

View File

@@ -1,65 +1,186 @@
import Image from "next/image";
"use client";
import { useState, useEffect, Suspense } from "react";
import Link from "next/link";
import { useSearchParams } from "next/navigation";
interface House {
id: string;
title: string;
description: string;
price: number;
district: string;
address: string;
phone: string;
images: string[];
createdAt: string;
}
function HomeContent() {
const searchParams = useSearchParams();
const [houses, setHouses] = useState<House[]>([]);
const [districts, setDistricts] = useState<string[]>([]);
const [loading, setLoading] = useState(true);
const [district, setDistrict] = useState(searchParams.get("district") || "全部");
const [keyword, setKeyword] = useState(searchParams.get("keyword") || "");
useEffect(() => {
fetchDistricts();
fetchHouses();
}, [district]);
async function fetchDistricts() {
try {
const res = await fetch("/api/districts");
const data = await res.json();
setDistricts(["全部", ...(data.districts || [])]);
} catch (error) {
console.error("获取地区失败", error);
setDistricts(["全部"]);
}
}
async function fetchHouses() {
setLoading(true);
try {
const params = new URLSearchParams();
if (district !== "全部") params.set("district", district);
if (keyword) params.set("keyword", keyword);
const res = await fetch(`/api/houses?${params}`);
const data = await res.json();
setHouses(data.houses || []);
} catch (error) {
console.error("获取房屋失败", error);
} finally {
setLoading(false);
}
}
function handleSearch(e: React.FormEvent) {
e.preventDefault();
fetchHouses();
}
function formatDate(dateStr: string) {
const date = new Date(dateStr);
const now = new Date();
const diff = Math.floor((now.getTime() - date.getTime()) / (1000 * 60 * 60 * 24));
if (diff === 0) return "今天";
if (diff === 1) return "昨天";
if (diff < 7) return `${diff}天前`;
return `${Math.floor(diff / 7)}周前`;
}
export default function Home() {
return (
<div className="flex flex-col flex-1 items-center justify-center bg-zinc-50 font-sans dark:bg-black">
<main className="flex flex-1 w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
<Image
className="dark:invert"
src="/next.svg"
alt="Next.js logo"
width={100}
height={20}
priority
/>
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
To get started, edit the page.tsx file.
</h1>
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
Looking for a starting point or more instructions? Head over to{" "}
<a
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
<div className="min-h-screen pb-20">
<header className="bg-white sticky top-0 z-10 shadow-sm">
<div className="px-4 py-3">
<h1 className="text-xl font-bold text-orange-500 mb-3">🏠 </h1>
<form onSubmit={handleSearch} className="flex gap-2">
<select
value={district}
onChange={(e) => setDistrict(e.target.value)}
className="px-3 py-2 rounded-lg border border-gray-200 text-sm bg-white flex-shrink-0"
>
Templates
</a>{" "}
or the{" "}
<a
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Learning
</a>{" "}
center.
</p>
</div>
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
<a
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
className="dark:invert"
src="/vercel.svg"
alt="Vercel logomark"
width={16}
height={16}
{districts.map((d) => (
<option key={d} value={d}>{d}</option>
))}
</select>
<input
type="text"
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
placeholder="搜索区域、小区名称..."
className="flex-1 px-3 py-2 rounded-lg border border-gray-200 text-sm"
/>
Deploy Now
</a>
<a
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Documentation
</a>
<button type="submit" className="px-4 py-2 bg-orange-500 text-white rounded-lg text-sm font-medium">
</button>
</form>
</div>
</header>
<main className="px-4 py-4">
{loading ? (
<div className="space-y-4">
{[1, 2, 3].map((i) => (
<div key={i} className="bg-white rounded-xl p-4 animate-pulse">
<div className="bg-gray-200 h-40 rounded-lg mb-3"></div>
<div className="bg-gray-200 h-5 w-3/4 rounded mb-2"></div>
<div className="bg-gray-200 h-4 w-1/2 rounded"></div>
</div>
))}
</div>
) : houses.length === 0 ? (
<div className="text-center py-20">
<div className="text-6xl mb-4">🏠</div>
<p className="text-gray-500 mb-2"></p>
<p className="text-gray-400 text-sm"></p>
</div>
) : (
<div className="space-y-4">
{houses.map((house) => (
<Link key={house.id} href={`/house/${house.id}`}>
<article className="bg-white rounded-xl overflow-hidden shadow-sm hover:shadow-md transition-shadow">
<div className="relative">
{house.images && house.images.length > 0 ? (
<img
src={house.images[0]}
alt={house.title}
className="w-full h-48 object-cover"
/>
) : (
<div className="w-full h-48 bg-gradient-to-br from-orange-100 to-orange-200 flex items-center justify-center text-6xl">
🏠
</div>
)}
<span className="absolute top-2 left-2 bg-orange-500 text-white text-xs px-2 py-1 rounded">
{house.district}
</span>
</div>
<div className="p-4">
<h3 className="font-semibold text-gray-900 mb-1 line-clamp-1">{house.title}</h3>
<p className="text-gray-500 text-sm mb-2 line-clamp-1">{house.address}</p>
<div className="flex items-center justify-between">
<span className="text-orange-500 font-bold text-lg">
¥{house.price}<span className="text-gray-400 text-sm font-normal">/</span>
</span>
<span className="text-gray-400 text-xs">{formatDate(house.createdAt)}</span>
</div>
</div>
</article>
</Link>
))}
</div>
)}
</main>
<nav className="fixed bottom-0 left-0 right-0 bg-white border-t border-gray-200 px-6 py-2 flex justify-between items-center max-w-lg mx-auto">
<div className="flex flex-col items-center text-orange-500">
<span className="text-xl">🏠</span>
<span className="text-xs mt-1"></span>
</div>
<Link href="/owner" className="flex flex-col items-center text-gray-400 hover:text-orange-500 transition-colors">
<span className="text-xl">🏗</span>
<span className="text-xs mt-1"></span>
</Link>
</nav>
</div>
);
}
export default function Home() {
return (
<Suspense fallback={
<div className="min-h-screen flex items-center justify-center">
<div className="text-center">
<div className="text-4xl mb-2 animate-bounce">🏠</div>
<p className="text-gray-500">...</p>
</div>
</div>
}>
<HomeContent />
</Suspense>
);
}