From ce9dfae7c56badf40cf1e46ecdce1cda4ad3b85c Mon Sep 17 00:00:00 2001 From: Cuishibing <643237029@qq.com> Date: Sun, 22 Mar 2026 10:15:31 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=9F=8E=E4=B8=AD=E6=9D=91=E7=A7=9F?= =?UTF-8?q?=E6=88=BF=E5=B9=B3=E5=8F=B0=20-=20=E7=A7=9F=E5=AE=A2=E6=B5=8F?= =?UTF-8?q?=E8=A7=88=E3=80=81=E6=88=BF=E4=B8=9C=E5=8F=91=E5=B8=83=E6=88=BF?= =?UTF-8?q?=E6=BA=90=E3=80=81=E5=9B=BE=E7=89=87=E4=B8=8A=E4=BC=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 4 + README.md | 51 ++-- app/api/auth/login/route.ts | 66 +++++ app/api/auth/me/route.ts | 48 ++++ app/api/auth/register/route.ts | 82 ++++++ app/api/districts/route.ts | 17 ++ app/api/houses/[id]/route.ts | 157 +++++++++++ app/api/houses/route.ts | 123 +++++++++ app/api/owner/houses/route.ts | 69 +++++ app/api/upload/route.ts | 53 ++++ app/house/[id]/page.tsx | 209 +++++++++++++++ app/layout.tsx | 22 +- app/owner/dashboard/page.tsx | 475 +++++++++++++++++++++++++++++++++ app/owner/page.tsx | 160 +++++++++++ app/page.tsx | 233 ++++++++++++---- 15 files changed, 1682 insertions(+), 87 deletions(-) create mode 100644 app/api/auth/login/route.ts create mode 100644 app/api/auth/me/route.ts create mode 100644 app/api/auth/register/route.ts create mode 100644 app/api/districts/route.ts create mode 100644 app/api/houses/[id]/route.ts create mode 100644 app/api/houses/route.ts create mode 100644 app/api/owner/houses/route.ts create mode 100644 app/api/upload/route.ts create mode 100644 app/house/[id]/page.tsx create mode 100644 app/owner/dashboard/page.tsx create mode 100644 app/owner/page.tsx diff --git a/.gitignore b/.gitignore index 5ef6a52..0b761c4 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,7 @@ yarn-error.log* # typescript *.tsbuildinfo next-env.d.ts + +# data (local storage) +data/ +uploads/ diff --git a/README.md b/README.md index e215bc4..576143f 100644 --- a/README.md +++ b/README.md @@ -1,36 +1,41 @@ -This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). +# 城中村租房平台 -## Getting Started +一个简洁的城中村租房信息平台,支持房东发布房源、租客浏览联系。 -First, run the development server: +## 功能特点 + +- **租客端**:无需登录,直接浏览和搜索房源 +- **房东端**:简单密码认证,发布和管理房源 +- **移动端优先**:适配手机访问的响应式设计 + +## 技术栈 + +- Next.js 16 + TypeScript +- TailwindCSS +- JSON文件存储(无需数据库) + +## 快速开始 ```bash +npm install npm run dev -# or -yarn dev -# or -pnpm dev -# or -bun dev ``` -Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. +访问 http://localhost:3000 -You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. +## 页面说明 -This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. +- `/` - 租客浏览页面(首页) +- `/house/[id]` - 房屋详情页 +- `/owner` - 房东登录/注册 +- `/owner/dashboard` - 房东管理后台 -## Learn More +## 数据存储 -To learn more about Next.js, take a look at the following resources: +数据存储在 `data/` 目录下的 JSON 文件中: +- `data/houses.json` - 房源数据 +- `data/users.json` - 房东账号数据 -- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. -- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. +## 部署 -You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! - -## Deploy on Vercel - -The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. - -Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. +本项目可部署到 Vercel,无需额外配置数据库。 diff --git a/app/api/auth/login/route.ts b/app/api/auth/login/route.ts new file mode 100644 index 0000000..c99e67e --- /dev/null +++ b/app/api/auth/login/route.ts @@ -0,0 +1,66 @@ +import { NextRequest, NextResponse } from 'next/server'; +import fs from 'fs/promises'; +import path from 'path'; +import crypto from 'crypto'; + +const USERS_FILE = path.join(process.cwd(), 'data/users.json'); + +interface User { + id: string; + username: string; + passwordHash: string; + token: string; + createdAt: string; +} + +async function readUsers(): Promise { + try { + const data = await fs.readFile(USERS_FILE, 'utf-8'); + return JSON.parse(data); + } catch { + return []; + } +} + +function hashPassword(password: string): string { + return crypto.createHash('sha256').update(password).digest('hex'); +} + +function generateToken(): string { + return crypto.randomBytes(32).toString('hex'); +} + +export async function POST(request: NextRequest) { + try { + const { username, password } = await request.json(); + + if (!username || !password) { + return NextResponse.json({ error: '用户名和密码不能为空' }, { status: 400 }); + } + + const users = await readUsers(); + const user = users.find(u => u.username === username); + + if (!user || user.passwordHash !== hashPassword(password)) { + return NextResponse.json({ error: '用户名或密码错误' }, { status: 401 }); + } + + const newToken = generateToken(); + user.token = newToken; + await fs.writeFile(USERS_FILE, JSON.stringify(users, null, 2)); + + const response = NextResponse.json({ success: true, username: user.username }); + response.cookies.set('auth_token', newToken, { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', + maxAge: 60 * 60 * 24 * 7, + path: '/' + }); + + return response; + } catch (error) { + console.error('Login error:', error); + return NextResponse.json({ error: '登录失败' }, { status: 500 }); + } +} diff --git a/app/api/auth/me/route.ts b/app/api/auth/me/route.ts new file mode 100644 index 0000000..697f25c --- /dev/null +++ b/app/api/auth/me/route.ts @@ -0,0 +1,48 @@ +import { NextRequest, NextResponse } from 'next/server'; +import fs from 'fs/promises'; +import path from 'path'; + +const USERS_FILE = path.join(process.cwd(), 'data/users.json'); + +interface User { + id: string; + username: string; + token: string; +} + +async function readUsers(): Promise { + try { + const data = await fs.readFile(USERS_FILE, 'utf-8'); + return JSON.parse(data); + } catch { + return []; + } +} + +export async function GET(request: NextRequest) { + try { + const token = request.cookies.get('auth_token')?.value; + + if (!token) { + return NextResponse.json({ user: null }); + } + + const users = await readUsers(); + const user = users.find(u => u.token === token); + + if (!user) { + return NextResponse.json({ user: null }); + } + + return NextResponse.json({ user: { username: user.username } }); + } catch (error) { + console.error('Get user error:', error); + return NextResponse.json({ user: null }); + } +} + +export async function DELETE(request: NextRequest) { + const response = NextResponse.json({ success: true }); + response.cookies.delete('auth_token'); + return response; +} diff --git a/app/api/auth/register/route.ts b/app/api/auth/register/route.ts new file mode 100644 index 0000000..a206c9f --- /dev/null +++ b/app/api/auth/register/route.ts @@ -0,0 +1,82 @@ +import { NextRequest, NextResponse } from 'next/server'; +import fs from 'fs/promises'; +import path from 'path'; +import crypto from 'crypto'; + +const DATA_DIR = path.join(process.cwd(), 'data'); +const USERS_FILE = path.join(DATA_DIR, 'users.json'); + +interface User { + id: string; + username: string; + passwordHash: string; + token: string; + createdAt: string; +} + +async function readUsers(): Promise { + try { + const data = await fs.readFile(USERS_FILE, 'utf-8'); + return JSON.parse(data); + } catch { + return []; + } +} + +async function writeUsers(users: User[]): Promise { + await fs.writeFile(USERS_FILE, JSON.stringify(users, null, 2)); +} + +function hashPassword(password: string): string { + return crypto.createHash('sha256').update(password).digest('hex'); +} + +function generateToken(): string { + return crypto.randomBytes(32).toString('hex'); +} + +export async function POST(request: NextRequest) { + try { + const { username, password } = await request.json(); + + if (!username || !password) { + return NextResponse.json({ error: '用户名和密码不能为空' }, { status: 400 }); + } + + if (username.length < 3 || password.length < 6) { + return NextResponse.json({ error: '用户名至少3位,密码至少6位' }, { status: 400 }); + } + + const users = await readUsers(); + + if (users.find(u => u.username === username)) { + return NextResponse.json({ error: '用户名已存在' }, { status: 400 }); + } + + const token = generateToken(); + const newUser: User = { + id: crypto.randomUUID(), + username, + passwordHash: hashPassword(password), + token, + createdAt: new Date().toISOString() + }; + + users.push(newUser); + await writeUsers(users); + + const response = NextResponse.json({ success: true, username }); + response.cookies.set('auth_token', token, { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', + maxAge: 60 * 60 * 24 * 7, + path: '/' + }); + + return response; + } catch (error) { + console.error('Register error:', error); + return NextResponse.json({ error: '注册失败' }, { status: 500 }); + } +} diff --git a/app/api/districts/route.ts b/app/api/districts/route.ts new file mode 100644 index 0000000..26bfe36 --- /dev/null +++ b/app/api/districts/route.ts @@ -0,0 +1,17 @@ +import { NextResponse } from 'next/server'; +import fs from 'fs/promises'; +import path from 'path'; + +const DATA_DIR = path.join(process.cwd(), 'data'); +const DISTRICTS_FILE = path.join(DATA_DIR, 'districts.json'); + +export async function GET() { + try { + const data = await fs.readFile(DISTRICTS_FILE, 'utf-8'); + const districts = JSON.parse(data); + return NextResponse.json({ districts }); + } catch (error) { + console.error('Get districts error:', error); + return NextResponse.json({ districts: [] }); + } +} \ No newline at end of file diff --git a/app/api/houses/[id]/route.ts b/app/api/houses/[id]/route.ts new file mode 100644 index 0000000..587203b --- /dev/null +++ b/app/api/houses/[id]/route.ts @@ -0,0 +1,157 @@ +import { NextRequest, NextResponse } from 'next/server'; +import fs from 'fs/promises'; +import path from 'path'; + +const DATA_DIR = path.join(process.cwd(), 'data'); +const HOUSES_FILE = path.join(DATA_DIR, 'houses.json'); +const USERS_FILE = path.join(DATA_DIR, 'users.json'); + +interface House { + id: string; + owner: string; + title: string; + description: string; + price: number; + district: string; + address: string; + phone: string; + images: string[]; + createdAt: string; +} + +interface User { + id: string; + username: string; + token: string; +} + +async function readFile(filePath: string, defaultValue: T): Promise { + try { + const data = await fs.readFile(filePath, 'utf-8'); + return JSON.parse(data); + } catch { + return defaultValue; + } +} + +async function getUserFromToken(token: string): Promise { + const users = await readFile(USERS_FILE, []); + return users.find(u => u.token === token) || null; +} + +async function readHouses(): Promise { + return readFile(HOUSES_FILE, []); +} + +async function writeHouses(houses: House[]): Promise { + await fs.writeFile(HOUSES_FILE, JSON.stringify(houses, null, 2)); +} + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const { id } = await params; + const houses = await readHouses(); + const house = houses.find(h => h.id === id); + + if (!house) { + return NextResponse.json({ error: '房屋不存在' }, { status: 404 }); + } + + return NextResponse.json({ house }); + } catch (error) { + console.error('Get house error:', error); + return NextResponse.json({ error: '获取房屋信息失败' }, { status: 500 }); + } +} + +export async function PUT( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const token = request.cookies.get('auth_token')?.value; + + if (!token) { + return NextResponse.json({ error: '请先登录' }, { status: 401 }); + } + + const user = await getUserFromToken(token); + if (!user) { + return NextResponse.json({ error: '用户不存在' }, { status: 401 }); + } + + const { id } = await params; + const houses = await readHouses(); + const houseIndex = houses.findIndex(h => h.id === id); + + if (houseIndex === -1) { + return NextResponse.json({ error: '房屋不存在' }, { status: 404 }); + } + + if (houses[houseIndex].owner !== user.username) { + return NextResponse.json({ error: '无权修改此房屋' }, { status: 403 }); + } + + const body = await request.json(); + const { title, description, price, district, address, phone, images } = body; + + houses[houseIndex] = { + ...houses[houseIndex], + title: title || houses[houseIndex].title, + description: description ?? houses[houseIndex].description, + price: price !== undefined ? Number(price) : houses[houseIndex].price, + district: district || houses[houseIndex].district, + address: address || houses[houseIndex].address, + phone: phone || houses[houseIndex].phone, + images: images || houses[houseIndex].images + }; + + await writeHouses(houses); + + return NextResponse.json({ success: true, house: houses[houseIndex] }); + } catch (error) { + console.error('Update house error:', error); + return NextResponse.json({ error: '更新房屋失败' }, { status: 500 }); + } +} + +export async function DELETE( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const token = request.cookies.get('auth_token')?.value; + + if (!token) { + return NextResponse.json({ error: '请先登录' }, { status: 401 }); + } + + const user = await getUserFromToken(token); + if (!user) { + return NextResponse.json({ error: '用户不存在' }, { status: 401 }); + } + + const { id } = await params; + const houses = await readHouses(); + const house = houses.find(h => h.id === id); + + if (!house) { + return NextResponse.json({ error: '房屋不存在' }, { status: 404 }); + } + + if (house.owner !== user.username) { + return NextResponse.json({ error: '无权删除此房屋' }, { status: 403 }); + } + + const newHouses = houses.filter(h => h.id !== id); + await writeHouses(newHouses); + + return NextResponse.json({ success: true }); + } catch (error) { + console.error('Delete house error:', error); + return NextResponse.json({ error: '删除房屋失败' }, { status: 500 }); + } +} diff --git a/app/api/houses/route.ts b/app/api/houses/route.ts new file mode 100644 index 0000000..feea2b6 --- /dev/null +++ b/app/api/houses/route.ts @@ -0,0 +1,123 @@ +import { NextRequest, NextResponse } from 'next/server'; +import fs from 'fs/promises'; +import path from 'path'; +import crypto from 'crypto'; + +const DATA_DIR = path.join(process.cwd(), 'data'); +const HOUSES_FILE = path.join(DATA_DIR, 'houses.json'); +const USERS_FILE = path.join(DATA_DIR, 'users.json'); + +interface House { + id: string; + owner: string; + title: string; + description: string; + price: number; + district: string; + address: string; + phone: string; + images: string[]; + createdAt: string; +} + +interface User { + id: string; + username: string; + token: string; +} + +async function readFile(filePath: string, defaultValue: T): Promise { + try { + const data = await fs.readFile(filePath, 'utf-8'); + return JSON.parse(data); + } catch { + return defaultValue; + } +} + +async function getUserFromToken(token: string): Promise { + const users = await readFile(USERS_FILE, []); + return users.find(u => u.token === token) || null; +} + +async function readHouses(): Promise { + return readFile(HOUSES_FILE, []); +} + +async function writeHouses(houses: House[]): Promise { + await fs.writeFile(HOUSES_FILE, JSON.stringify(houses, null, 2)); +} + +export async function GET(request: NextRequest) { + try { + const { searchParams } = new URL(request.url); + const district = searchParams.get('district'); + const keyword = searchParams.get('keyword'); + + let houses = await readHouses(); + + if (district && district !== '全部') { + houses = houses.filter(h => h.district === district); + } + + if (keyword) { + const kw = keyword.toLowerCase(); + houses = houses.filter(h => + h.title.toLowerCase().includes(kw) || + h.address.toLowerCase().includes(kw) || + h.description.toLowerCase().includes(kw) + ); + } + + houses.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); + + return NextResponse.json({ houses }); + } catch (error) { + console.error('Get houses error:', error); + return NextResponse.json({ error: '获取房屋列表失败' }, { status: 500 }); + } +} + +export async function POST(request: NextRequest) { + try { + const token = request.cookies.get('auth_token')?.value; + + if (!token) { + return NextResponse.json({ error: '请先登录' }, { status: 401 }); + } + + const user = await getUserFromToken(token); + if (!user) { + return NextResponse.json({ error: '用户不存在' }, { status: 401 }); + } + + const body = await request.json(); + const { title, description, price, district, address, phone, images } = body; + + if (!title || !price || !district || !address || !phone) { + return NextResponse.json({ error: '请填写完整信息' }, { status: 400 }); + } + + const house: House = { + id: crypto.randomUUID(), + owner: user.username, + title, + description: description || '', + price: Number(price), + district, + address, + phone, + images: images || [], + createdAt: new Date().toISOString() + }; + + const houses = await readHouses(); + houses.push(house); + await writeHouses(houses); + + return NextResponse.json({ success: true, house }); + } catch (error) { + console.error('Create house error:', error); + return NextResponse.json({ error: '创建房屋失败' }, { status: 500 }); + } +} diff --git a/app/api/owner/houses/route.ts b/app/api/owner/houses/route.ts new file mode 100644 index 0000000..0275926 --- /dev/null +++ b/app/api/owner/houses/route.ts @@ -0,0 +1,69 @@ +import { NextRequest, NextResponse } from 'next/server'; +import fs from 'fs/promises'; +import path from 'path'; + +const DATA_DIR = path.join(process.cwd(), 'data'); +const HOUSES_FILE = path.join(DATA_DIR, 'houses.json'); +const USERS_FILE = path.join(DATA_DIR, 'users.json'); + +interface House { + id: string; + owner: string; + title: string; + description: string; + price: number; + district: string; + address: string; + phone: string; + images: string[]; + createdAt: string; +} + +interface User { + id: string; + username: string; + token: string; +} + +async function readFile(filePath: string, defaultValue: T): Promise { + try { + const data = await fs.readFile(filePath, 'utf-8'); + return JSON.parse(data); + } catch { + return defaultValue; + } +} + +async function getUserFromToken(token: string): Promise { + const users = await readFile(USERS_FILE, []); + return users.find(u => u.token === token) || null; +} + +async function readHouses(): Promise { + return readFile(HOUSES_FILE, []); +} + +export async function GET(request: NextRequest) { + try { + const token = request.cookies.get('auth_token')?.value; + + if (!token) { + return NextResponse.json({ error: '请先登录' }, { status: 401 }); + } + + const user = await getUserFromToken(token); + if (!user) { + return NextResponse.json({ error: '用户不存在' }, { status: 401 }); + } + + const houses = await readHouses(); + const myHouses = houses + .filter(h => h.owner === user.username) + .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); + + return NextResponse.json({ houses: myHouses }); + } catch (error) { + console.error('Get my houses error:', error); + return NextResponse.json({ error: '获取房屋列表失败' }, { status: 500 }); + } +} diff --git a/app/api/upload/route.ts b/app/api/upload/route.ts new file mode 100644 index 0000000..e32c63c --- /dev/null +++ b/app/api/upload/route.ts @@ -0,0 +1,53 @@ +import { NextRequest, NextResponse } from 'next/server'; +import fs from 'fs/promises'; +import path from 'path'; + +const UPLOAD_DIR = path.join(process.cwd(), 'public/uploads'); + +export async function POST(request: NextRequest) { + try { + const formData = await request.formData(); + const file = formData.get('file') as File | null; + + if (!file) { + return NextResponse.json({ error: '请选择图片' }, { status: 400 }); + } + + const allowedTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']; + if (!allowedTypes.includes(file.type)) { + return NextResponse.json({ error: '仅支持 JPG、PNG、GIF、WebP 格式' }, { status: 400 }); + } + + const ext = file.name.split('.').pop() || 'jpg'; + const filename = `${Date.now()}-${Math.random().toString(36).slice(2)}.${ext}`; + const filepath = path.join(UPLOAD_DIR, filename); + + const buffer = await file.arrayBuffer(); + await fs.writeFile(filepath, Buffer.from(buffer)); + + const url = `/uploads/${filename}`; + return NextResponse.json({ url }); + } catch (error) { + console.error('Upload error:', error); + return NextResponse.json({ error: '上传失败' }, { status: 500 }); + } +} + +export async function DELETE(request: NextRequest) { + try { + const { searchParams } = new URL(request.url); + const filename = searchParams.get('filename'); + + if (!filename) { + return NextResponse.json({ error: '缺少文件名' }, { status: 400 }); + } + + const filepath = path.join(UPLOAD_DIR, path.basename(filename)); + await fs.unlink(filepath); + + return NextResponse.json({ success: true }); + } catch (error) { + console.error('Delete error:', error); + return NextResponse.json({ error: '删除失败' }, { status: 500 }); + } +} \ No newline at end of file diff --git a/app/house/[id]/page.tsx b/app/house/[id]/page.tsx new file mode 100644 index 0000000..4806a9d --- /dev/null +++ b/app/house/[id]/page.tsx @@ -0,0 +1,209 @@ +"use client"; + +import { useState, useEffect } from "react"; +import Link from "next/link"; +import { useParams, useRouter } from "next/navigation"; + +interface House { + id: string; + owner: string; + title: string; + description: string; + price: number; + district: string; + address: string; + phone: string; + images: string[]; + createdAt: string; +} + +export default function HouseDetail() { + const params = useParams(); + const router = useRouter(); + const [house, setHouse] = useState(null); + const [loading, setLoading] = useState(true); + const [currentImage, setCurrentImage] = useState(0); + const [showContact, setShowContact] = useState(false); + + useEffect(() => { + fetchHouse(); + }, []); + + async function fetchHouse() { + try { + const res = await fetch(`/api/houses/${params.id}`); + const data = await res.json(); + if (data.house) { + setHouse(data.house); + } + } catch (error) { + console.error("获取房屋详情失败", error); + } finally { + setLoading(false); + } + } + + function formatDate(dateStr: string) { + return new Date(dateStr).toLocaleDateString("zh-CN"); + } + + if (loading) { + return ( +
+
+
🏠
+

加载中...

+
+
+ ); + } + + if (!house) { + return ( +
+
😢
+

房屋不存在或已下架

+ + 返回首页 + +
+ ); + } + + const images = house.images && house.images.length > 0 ? house.images : []; + + return ( +
+
+ +

房屋详情

+
+ + {images.length > 0 ? ( +
+
+ {images.map((img, index) => ( + {`${house.title} + ))} +
+ {images.length > 1 && ( +
+ {images.map((_, index) => ( +
+ )} +
+ ) : ( +
+ 🏠 +
+ )} + +
+
+
+
+

{house.title}

+ + {house.district} + +
+
+ ¥{house.price} + /月 +
+
+ +
+ 📍 + {house.address} +
+ +
+ 👤 + 房东:{house.owner} +
+
+ + {house.description && ( +
+

房屋描述

+

+ {house.description} +

+
+ )} + +
+

发布时间

+

{formatDate(house.createdAt)}

+
+
+ +
+ +
+ + {showContact && ( +
+
+
+

联系方式

+
+
📱
+

{house.phone}

+

拨打前请说明在平台上看到

+
+
+ + 立即拨打 + + +
+
+
+ )} + + +
+ ); +} diff --git a/app/layout.tsx b/app/layout.tsx index 976eb90..518ef6f 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,4 +1,4 @@ -import type { Metadata } from "next"; +import type { Metadata, Viewport } from "next"; import { Geist, Geist_Mono } from "next/font/google"; import "./globals.css"; @@ -13,8 +13,15 @@ const geistMono = Geist_Mono({ }); export const metadata: Metadata = { - title: "Create Next App", - description: "Generated by create next app", + title: "城中村租房 - 找房更简单", + description: "城中村租房信息平台,房东直租,租客免佣金", +}; + +export const viewport: Viewport = { + width: "device-width", + initialScale: 1, + maximumScale: 1, + userScalable: false, }; export default function RootLayout({ @@ -23,11 +30,10 @@ export default function RootLayout({ children: React.ReactNode; }>) { return ( - - {children} + + + {children} + ); } diff --git a/app/owner/dashboard/page.tsx b/app/owner/dashboard/page.tsx new file mode 100644 index 0000000..ca1d5a7 --- /dev/null +++ b/app/owner/dashboard/page.tsx @@ -0,0 +1,475 @@ +"use client"; + +import { useState, useEffect } from "react"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; + +interface House { + id: string; + title: string; + description: string; + price: number; + district: string; + address: string; + phone: string; + images: string[]; + createdAt: string; +} + +export default function OwnerDashboard() { + const router = useRouter(); + const [houses, setHouses] = useState([]); + const [districts, setDistricts] = useState([]); + const [loading, setLoading] = useState(true); + const [showForm, setShowForm] = useState(false); + const [editingHouse, setEditingHouse] = useState(null); + const [formData, setFormData] = useState({ + title: "", + description: "", + price: "", + district: "", + address: "", + phone: "", + }); + const [images, setImages] = useState([]); + const [uploading, setUploading] = useState(false); + const [error, setError] = useState(""); + const [submitting, setSubmitting] = useState(false); + + useEffect(() => { + checkAuth(); + }, []); + + async function fetchDistricts() { + try { + const res = await fetch("/api/districts"); + const data = await res.json(); + setDistricts(data.districts || []); + if (data.districts?.length > 0) { + setFormData(prev => ({ ...prev, district: data.districts[0] })); + } + } catch (error) { + console.error("获取地区失败", error); + } + } + + async function checkAuth() { + try { + const res = await fetch("/api/auth/me"); + const data = await res.json(); + if (!data.user) { + router.push("/owner"); + return; + } + fetchHouses(); + } catch (error) { + router.push("/owner"); + } + } + + async function fetchHouses() { + try { + const res = await fetch("/api/owner/houses"); + const data = await res.json(); + setHouses(data.houses || []); + } catch (error) { + console.error("获取房源失败", error); + } finally { + setLoading(false); + } + } + + async function handleLogout() { + await fetch("/api/auth/me", { method: "DELETE" }); + router.push("/owner"); + } + + function openForm(house?: House) { + fetchDistricts(); + if (house) { + setEditingHouse(house); + setFormData({ + title: house.title, + description: house.description, + price: String(house.price), + district: house.district, + address: house.address, + phone: house.phone, + }); + setImages(house.images); + } else { + setEditingHouse(null); + setFormData({ + title: "", + description: "", + price: "", + district: "", + address: "", + phone: "", + }); + setImages([]); + } + setError(""); + setShowForm(true); + } + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setError(""); + setSubmitting(true); + + try { + const body = { + title: formData.title, + description: formData.description, + price: Number(formData.price), + district: formData.district, + address: formData.address, + phone: formData.phone, + images, + }; + + let res; + if (editingHouse) { + res = await fetch(`/api/houses/${editingHouse.id}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + } else { + res = await fetch("/api/houses", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + } + + const data = await res.json(); + if (!res.ok) { + setError(data.error || "操作失败"); + return; + } + + setShowForm(false); + fetchHouses(); + } catch (error) { + setError("网络错误,请重试"); + } finally { + setSubmitting(false); + } + } + + async function handleDelete(id: string) { + if (!confirm("确定要删除这个房源吗?")) return; + + try { + const res = await fetch(`/api/houses/${id}`, { method: "DELETE" }); + if (res.ok) { + fetchHouses(); + } + } catch (error) { + alert("删除失败"); + } + } + + async function handleImageUpload(e: React.ChangeEvent) { + const files = e.target.files; + if (!files || files.length === 0) return; + + setUploading(true); + const newImages: string[] = []; + + for (let i = 0; i < files.length; i++) { + const file = files[i]; + const formData = new FormData(); + formData.append('file', file); + + try { + const res = await fetch('/api/upload', { + method: 'POST', + body: formData, + }); + const data = await res.json(); + if (data.url) { + newImages.push(data.url); + } + } catch (error) { + console.error('上传图片失败', error); + } + } + + setImages(prev => [...prev, ...newImages]); + setUploading(false); + e.target.value = ''; + } + + function removeImage(index: number) { + setImages(prev => prev.filter((_, i) => i !== index)); + } + + return ( +
+
+
+ + ← + +

房源管理

+
+ +
+ +
+ + + {loading ? ( +
+ {[1, 2].map((i) => ( +
+
+
+
+
+ ))} +
+ ) : houses.length === 0 ? ( +
+
🏠
+

您还没有发布房源

+

点击上方按钮发布您的第一套房源

+
+ ) : ( +
+ {houses.map((house) => ( +
+ +
+ {house.images && house.images.length > 0 ? ( + {house.title} + ) : ( +
+ 🏠 +
+ )} +
+

{house.title}

+

{house.address}

+
+ + ¥{house.price}/月 + + {house.district} +
+
+
+ +
+ + +
+
+ ))} +
+ )} +
+ + {showForm && ( +
+
+
+
+

+ {editingHouse ? "编辑房源" : "发布新房源"} +

+ +
+
+ {error && ( +
+ {error} +
+ )} + +
+ + setFormData({ ...formData, title: e.target.value })} + placeholder="如:南山区科技园精装单间" + className="w-full px-3 py-2 rounded-lg border border-gray-200 focus:border-orange-500 outline-none" + required + /> +
+ +
+
+ + setFormData({ ...formData, price: e.target.value })} + placeholder="1500" + className="w-full px-3 py-2 rounded-lg border border-gray-200 focus:border-orange-500 outline-none" + required + /> +
+
+ + +
+
+ +
+ + setFormData({ ...formData, address: e.target.value })} + placeholder="如:南山区科技园南区A栋" + className="w-full px-3 py-2 rounded-lg border border-gray-200 focus:border-orange-500 outline-none" + required + /> +
+ +
+ + setFormData({ ...formData, phone: e.target.value })} + placeholder="13800138000" + className="w-full px-3 py-2 rounded-lg border border-gray-200 focus:border-orange-500 outline-none" + required + /> +
+ +
+ +