feat: 城中村租房平台 - 租客浏览、房东发布房源、图片上传
This commit is contained in:
4
.gitignore
vendored
4
.gitignore
vendored
@@ -39,3 +39,7 @@ yarn-error.log*
|
|||||||
# typescript
|
# typescript
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
|
|
||||||
|
# data (local storage)
|
||||||
|
data/
|
||||||
|
uploads/
|
||||||
|
|||||||
51
README.md
51
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
|
```bash
|
||||||
|
npm install
|
||||||
npm run dev
|
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!
|
本项目可部署到 Vercel,无需额外配置数据库。
|
||||||
|
|
||||||
## 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.
|
|
||||||
|
|||||||
66
app/api/auth/login/route.ts
Normal file
66
app/api/auth/login/route.ts
Normal file
@@ -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<User[]> {
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
48
app/api/auth/me/route.ts
Normal file
48
app/api/auth/me/route.ts
Normal file
@@ -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<User[]> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
82
app/api/auth/register/route.ts
Normal file
82
app/api/auth/register/route.ts
Normal file
@@ -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<User[]> {
|
||||||
|
try {
|
||||||
|
const data = await fs.readFile(USERS_FILE, 'utf-8');
|
||||||
|
return JSON.parse(data);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeUsers(users: User[]): Promise<void> {
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
17
app/api/districts/route.ts
Normal file
17
app/api/districts/route.ts
Normal file
@@ -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: [] });
|
||||||
|
}
|
||||||
|
}
|
||||||
157
app/api/houses/[id]/route.ts
Normal file
157
app/api/houses/[id]/route.ts
Normal file
@@ -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<T>(filePath: string, defaultValue: T): Promise<T> {
|
||||||
|
try {
|
||||||
|
const data = await fs.readFile(filePath, 'utf-8');
|
||||||
|
return JSON.parse(data);
|
||||||
|
} catch {
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getUserFromToken(token: string): Promise<User | null> {
|
||||||
|
const users = await readFile<User[]>(USERS_FILE, []);
|
||||||
|
return users.find(u => u.token === token) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readHouses(): Promise<House[]> {
|
||||||
|
return readFile<House[]>(HOUSES_FILE, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeHouses(houses: House[]): Promise<void> {
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
123
app/api/houses/route.ts
Normal file
123
app/api/houses/route.ts
Normal file
@@ -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<T>(filePath: string, defaultValue: T): Promise<T> {
|
||||||
|
try {
|
||||||
|
const data = await fs.readFile(filePath, 'utf-8');
|
||||||
|
return JSON.parse(data);
|
||||||
|
} catch {
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getUserFromToken(token: string): Promise<User | null> {
|
||||||
|
const users = await readFile<User[]>(USERS_FILE, []);
|
||||||
|
return users.find(u => u.token === token) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readHouses(): Promise<House[]> {
|
||||||
|
return readFile<House[]>(HOUSES_FILE, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeHouses(houses: House[]): Promise<void> {
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
69
app/api/owner/houses/route.ts
Normal file
69
app/api/owner/houses/route.ts
Normal file
@@ -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<T>(filePath: string, defaultValue: T): Promise<T> {
|
||||||
|
try {
|
||||||
|
const data = await fs.readFile(filePath, 'utf-8');
|
||||||
|
return JSON.parse(data);
|
||||||
|
} catch {
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getUserFromToken(token: string): Promise<User | null> {
|
||||||
|
const users = await readFile<User[]>(USERS_FILE, []);
|
||||||
|
return users.find(u => u.token === token) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readHouses(): Promise<House[]> {
|
||||||
|
return readFile<House[]>(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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
53
app/api/upload/route.ts
Normal file
53
app/api/upload/route.ts
Normal file
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
209
app/house/[id]/page.tsx
Normal file
209
app/house/[id]/page.tsx
Normal file
@@ -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<House | null>(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 (
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!house) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex flex-col items-center justify-center">
|
||||||
|
<div className="text-6xl mb-4">😢</div>
|
||||||
|
<p className="text-gray-500 mb-4">房屋不存在或已下架</p>
|
||||||
|
<Link href="/" className="px-4 py-2 bg-orange-500 text-white rounded-lg">
|
||||||
|
返回首页
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const images = house.images && house.images.length > 0 ? house.images : [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50 pb-32">
|
||||||
|
<header className="bg-white sticky top-0 z-20 px-4 py-3 flex items-center gap-4 shadow-sm">
|
||||||
|
<button onClick={() => router.back()} className="text-2xl">
|
||||||
|
←
|
||||||
|
</button>
|
||||||
|
<h1 className="font-semibold text-gray-900 truncate flex-1">房屋详情</h1>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{images.length > 0 ? (
|
||||||
|
<div className="relative">
|
||||||
|
<div className="overflow-x-auto snap-x snap-mandatory flex">
|
||||||
|
{images.map((img, index) => (
|
||||||
|
<img
|
||||||
|
key={index}
|
||||||
|
src={img}
|
||||||
|
alt={`${house.title} ${index + 1}`}
|
||||||
|
className="w-full h-72 object-cover flex-shrink-0 snap-center"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{images.length > 1 && (
|
||||||
|
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 flex gap-2">
|
||||||
|
{images.map((_, index) => (
|
||||||
|
<button
|
||||||
|
key={index}
|
||||||
|
onClick={() => setCurrentImage(index)}
|
||||||
|
className={`w-2 h-2 rounded-full ${
|
||||||
|
index === currentImage ? "bg-white" : "bg-white/50"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="w-full h-72 bg-gradient-to-br from-orange-100 to-orange-200 flex items-center justify-center text-8xl">
|
||||||
|
🏠
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="px-4 py-4">
|
||||||
|
<div className="bg-white rounded-xl p-4 mb-4 shadow-sm">
|
||||||
|
<div className="flex items-start justify-between mb-3">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-bold text-gray-900 mb-1">{house.title}</h2>
|
||||||
|
<span className="inline-block bg-orange-100 text-orange-600 text-xs px-2 py-1 rounded">
|
||||||
|
{house.district}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<span className="text-2xl font-bold text-orange-500">¥{house.price}</span>
|
||||||
|
<span className="text-gray-400 text-sm">/月</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center text-gray-500 text-sm mb-3">
|
||||||
|
<span className="text-lg mr-2">📍</span>
|
||||||
|
<span>{house.address}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center text-gray-500 text-sm">
|
||||||
|
<span className="text-lg mr-2">👤</span>
|
||||||
|
<span>房东:{house.owner}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{house.description && (
|
||||||
|
<div className="bg-white rounded-xl p-4 mb-4 shadow-sm">
|
||||||
|
<h3 className="font-semibold text-gray-900 mb-2">房屋描述</h3>
|
||||||
|
<p className="text-gray-600 text-sm leading-relaxed whitespace-pre-wrap">
|
||||||
|
{house.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="bg-white rounded-xl p-4 shadow-sm">
|
||||||
|
<h3 className="font-semibold text-gray-900 mb-3">发布时间</h3>
|
||||||
|
<p className="text-gray-500 text-sm">{formatDate(house.createdAt)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="fixed bottom-0 left-0 right-0 bg-white border-t border-gray-200 px-4 py-4 max-w-lg mx-auto">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowContact(true)}
|
||||||
|
className="w-full py-4 bg-gradient-to-r from-orange-500 to-orange-600 text-white font-semibold rounded-xl shadow-lg active:scale-[0.98] transition-transform"
|
||||||
|
>
|
||||||
|
📞 联系房东
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showContact && (
|
||||||
|
<div className="fixed inset-0 bg-black/50 z-50 flex items-end">
|
||||||
|
<div className="bg-white w-full rounded-t-3xl p-6 animate-slide-up">
|
||||||
|
<div className="w-12 h-1 bg-gray-300 rounded-full mx-auto mb-6"></div>
|
||||||
|
<h3 className="text-xl font-bold text-gray-900 mb-4 text-center">联系方式</h3>
|
||||||
|
<div className="bg-orange-50 rounded-xl p-6 text-center mb-6">
|
||||||
|
<div className="text-4xl mb-2">📱</div>
|
||||||
|
<p className="text-3xl font-bold text-orange-500 tracking-wider">{house.phone}</p>
|
||||||
|
<p className="text-gray-500 text-sm mt-2">拨打前请说明在平台上看到</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<a
|
||||||
|
href={`tel:${house.phone}`}
|
||||||
|
className="flex-1 py-4 bg-orange-500 text-white font-semibold rounded-xl text-center"
|
||||||
|
>
|
||||||
|
立即拨打
|
||||||
|
</a>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowContact(false)}
|
||||||
|
className="flex-1 py-4 bg-gray-100 text-gray-700 font-semibold rounded-xl"
|
||||||
|
>
|
||||||
|
关闭
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<style jsx>{`
|
||||||
|
@keyframes slide-up {
|
||||||
|
from {
|
||||||
|
transform: translateY(100%);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.animate-slide-up {
|
||||||
|
animation: slide-up 0.3s ease-out;
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata, Viewport } from "next";
|
||||||
import { Geist, Geist_Mono } from "next/font/google";
|
import { Geist, Geist_Mono } from "next/font/google";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
|
||||||
@@ -13,8 +13,15 @@ const geistMono = Geist_Mono({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Create Next App",
|
title: "城中村租房 - 找房更简单",
|
||||||
description: "Generated by create next app",
|
description: "城中村租房信息平台,房东直租,租客免佣金",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const viewport: Viewport = {
|
||||||
|
width: "device-width",
|
||||||
|
initialScale: 1,
|
||||||
|
maximumScale: 1,
|
||||||
|
userScalable: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
@@ -23,11 +30,10 @@ export default function RootLayout({
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}>) {
|
}>) {
|
||||||
return (
|
return (
|
||||||
<html
|
<html lang="zh-CN">
|
||||||
lang="en"
|
<body className={`${geistSans.variable} ${geistMono.variable} min-h-screen bg-gray-50 antialiased`}>
|
||||||
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
|
{children}
|
||||||
>
|
</body>
|
||||||
<body className="min-h-full flex flex-col">{children}</body>
|
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
475
app/owner/dashboard/page.tsx
Normal file
475
app/owner/dashboard/page.tsx
Normal file
@@ -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<House[]>([]);
|
||||||
|
const [districts, setDistricts] = useState<string[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [showForm, setShowForm] = useState(false);
|
||||||
|
const [editingHouse, setEditingHouse] = useState<House | null>(null);
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
title: "",
|
||||||
|
description: "",
|
||||||
|
price: "",
|
||||||
|
district: "",
|
||||||
|
address: "",
|
||||||
|
phone: "",
|
||||||
|
});
|
||||||
|
const [images, setImages] = useState<string[]>([]);
|
||||||
|
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<HTMLInputElement>) {
|
||||||
|
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 (
|
||||||
|
<div className="min-h-screen bg-gray-50 pb-20">
|
||||||
|
<header className="bg-white px-4 py-3 flex items-center justify-between shadow-sm">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Link href="/" className="text-2xl text-gray-600">
|
||||||
|
←
|
||||||
|
</Link>
|
||||||
|
<h1 className="font-semibold text-gray-900">房源管理</h1>
|
||||||
|
</div>
|
||||||
|
<button onClick={handleLogout} className="text-gray-500 text-sm">
|
||||||
|
退出
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="px-4 py-4">
|
||||||
|
<button
|
||||||
|
onClick={() => openForm()}
|
||||||
|
className="w-full py-4 bg-gradient-to-r from-orange-500 to-orange-600 text-white font-semibold rounded-xl shadow-lg mb-4"
|
||||||
|
>
|
||||||
|
+ 发布新房源
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{[1, 2].map((i) => (
|
||||||
|
<div key={i} className="bg-white rounded-xl p-4 animate-pulse">
|
||||||
|
<div className="bg-gray-200 h-32 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-16">
|
||||||
|
<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) => (
|
||||||
|
<div key={house.id} className="bg-white rounded-xl overflow-hidden shadow-sm">
|
||||||
|
<Link href={`/house/${house.id}`}>
|
||||||
|
<div className="flex">
|
||||||
|
{house.images && house.images.length > 0 ? (
|
||||||
|
<img
|
||||||
|
src={house.images[0]}
|
||||||
|
alt={house.title}
|
||||||
|
className="w-28 h-28 object-cover flex-shrink-0"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="w-28 h-28 bg-gradient-to-br from-orange-100 to-orange-200 flex items-center justify-center text-3xl flex-shrink-0">
|
||||||
|
🏠
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex-1 p-3 min-w-0">
|
||||||
|
<h3 className="font-semibold text-gray-900 truncate">{house.title}</h3>
|
||||||
|
<p className="text-gray-500 text-sm truncate">{house.address}</p>
|
||||||
|
<div className="flex items-center justify-between mt-2">
|
||||||
|
<span className="text-orange-500 font-bold">
|
||||||
|
¥{house.price}/月
|
||||||
|
</span>
|
||||||
|
<span className="text-gray-400 text-xs">{house.district}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
<div className="border-t border-gray-100 flex">
|
||||||
|
<button
|
||||||
|
onClick={() => openForm(house)}
|
||||||
|
className="flex-1 py-2 text-center text-sm text-gray-600 hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
编辑
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDelete(house.id)}
|
||||||
|
className="flex-1 py-2 text-center text-sm text-red-500 hover:bg-red-50"
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{showForm && (
|
||||||
|
<div className="fixed inset-0 bg-black/50 z-50 overflow-y-auto">
|
||||||
|
<div className="min-h-screen py-8 px-4 flex items-start justify-center">
|
||||||
|
<div className="bg-white w-full max-w-md rounded-2xl overflow-hidden">
|
||||||
|
<header className="px-4 py-3 border-b border-gray-100 flex items-center justify-between">
|
||||||
|
<h2 className="font-semibold text-gray-900">
|
||||||
|
{editingHouse ? "编辑房源" : "发布新房源"}
|
||||||
|
</h2>
|
||||||
|
<button onClick={() => setShowForm(false)} className="text-2xl text-gray-400">
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
<form onSubmit={handleSubmit} className="p-4 space-y-4">
|
||||||
|
{error && (
|
||||||
|
<div className="bg-red-50 text-red-600 text-sm p-3 rounded-lg">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
标题 *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formData.title}
|
||||||
|
onChange={(e) => 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
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
月租(元) *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={formData.price}
|
||||||
|
onChange={(e) => 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
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
区域 *
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={formData.district}
|
||||||
|
onChange={(e) => setFormData({ ...formData, district: e.target.value })}
|
||||||
|
className="w-full px-3 py-2 rounded-lg border border-gray-200 focus:border-orange-500 outline-none bg-white"
|
||||||
|
>
|
||||||
|
{districts.map((d) => (
|
||||||
|
<option key={d} value={d}>{d}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
详细地址 *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formData.address}
|
||||||
|
onChange={(e) => 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
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
联系电话 *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="tel"
|
||||||
|
value={formData.phone}
|
||||||
|
onChange={(e) => 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
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
房屋描述
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={formData.description}
|
||||||
|
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||||
|
placeholder="描述房屋特点、配套设施、交通情况等..."
|
||||||
|
rows={3}
|
||||||
|
className="w-full px-3 py-2 rounded-lg border border-gray-200 focus:border-orange-500 outline-none resize-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
上传图片
|
||||||
|
</label>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<label className="flex-1 flex items-center justify-center py-3 bg-gray-100 rounded-lg cursor-pointer hover:bg-gray-200 transition">
|
||||||
|
<span className="text-sm text-gray-600">📁 选择文件</span>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
multiple
|
||||||
|
className="hidden"
|
||||||
|
onChange={handleImageUpload}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="flex-1 flex items-center justify-center py-3 bg-orange-50 rounded-lg cursor-pointer hover:bg-orange-100 transition">
|
||||||
|
<span className="text-sm text-orange-600">📷 拍照上传</span>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
capture="environment"
|
||||||
|
className="hidden"
|
||||||
|
onChange={handleImageUpload}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{uploading && (
|
||||||
|
<div className="text-center text-sm text-gray-500 py-2">
|
||||||
|
上传中...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{images.length > 0 && (
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
{images.map((url, index) => (
|
||||||
|
<div key={index} className="relative aspect-square rounded-lg overflow-hidden bg-gray-100">
|
||||||
|
<img src={url} alt={`图片${index + 1}`} className="w-full h-full object-cover" />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => removeImage(index)}
|
||||||
|
className="absolute top-1 right-1 w-6 h-6 bg-red-500 text-white rounded-full text-sm flex items-center justify-center"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-3 pt-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowForm(false)}
|
||||||
|
className="flex-1 py-3 bg-gray-100 text-gray-700 font-medium rounded-xl"
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={submitting}
|
||||||
|
className="flex-1 py-3 bg-orange-500 text-white font-medium rounded-xl disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{submitting ? "提交中..." : "保存"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
160
app/owner/page.tsx
Normal file
160
app/owner/page.tsx
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
|
export default function OwnerPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [isLogin, setIsLogin] = useState(true);
|
||||||
|
const [username, setUsername] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [user, setUser] = useState<{ username: string } | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
checkAuth();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function checkAuth() {
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/auth/me");
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.user) {
|
||||||
|
setUser(data.user);
|
||||||
|
router.push("/owner/dashboard");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("检查登录状态失败", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setError("");
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const endpoint = isLogin ? "/api/auth/login" : "/api/auth/register";
|
||||||
|
const res = await fetch(endpoint, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ username, password }),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
setError(data.error || "操作失败");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
router.push("/owner/dashboard");
|
||||||
|
} catch (error) {
|
||||||
|
setError("网络错误,请重试");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50">
|
||||||
|
<header className="bg-white px-4 py-3 flex items-center gap-4 shadow-sm">
|
||||||
|
<Link href="/" className="text-2xl text-gray-600">
|
||||||
|
←
|
||||||
|
</Link>
|
||||||
|
<h1 className="font-semibold text-gray-900">房东入口</h1>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="px-4 py-8">
|
||||||
|
<div className="text-center mb-8">
|
||||||
|
<div className="text-6xl mb-4">🏗️</div>
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900">
|
||||||
|
{isLogin ? "房东登录" : "房东注册"}
|
||||||
|
</h2>
|
||||||
|
<p className="text-gray-500 mt-2">
|
||||||
|
{isLogin ? "登录后管理您的房源" : "注册后发布和管理房源"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="bg-white rounded-2xl p-6 shadow-sm">
|
||||||
|
{error && (
|
||||||
|
<div className="bg-red-50 text-red-600 text-sm p-3 rounded-lg mb-4">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mb-4">
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
用户名
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
placeholder="请输入用户名(至少3位)"
|
||||||
|
className="w-full px-4 py-3 rounded-xl border border-gray-200 focus:border-orange-500 focus:ring-2 focus:ring-orange-200 outline-none transition"
|
||||||
|
required
|
||||||
|
minLength={3}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-6">
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
密码
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
placeholder="请输入密码(至少6位)"
|
||||||
|
className="w-full px-4 py-3 rounded-xl border border-gray-200 focus:border-orange-500 focus:ring-2 focus:ring-orange-200 outline-none transition"
|
||||||
|
required
|
||||||
|
minLength={6}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="w-full py-4 bg-gradient-to-r from-orange-500 to-orange-600 text-white font-semibold rounded-xl shadow-lg hover:shadow-xl transition disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{loading ? "处理中..." : (isLogin ? "登录" : "注册")}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="mt-4 text-center text-sm">
|
||||||
|
{isLogin ? (
|
||||||
|
<p className="text-gray-500">
|
||||||
|
还没有账号?{" "}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsLogin(false)}
|
||||||
|
className="text-orange-500 font-medium"
|
||||||
|
>
|
||||||
|
立即注册
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p className="text-gray-500">
|
||||||
|
已有账号?{" "}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsLogin(true)}
|
||||||
|
className="text-orange-500 font-medium"
|
||||||
|
>
|
||||||
|
立即登录
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p className="text-center text-gray-400 text-sm mt-8">
|
||||||
|
<Link href="/" className="text-orange-500">
|
||||||
|
← 返回找房
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
233
app/page.tsx
233
app/page.tsx
@@ -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 (
|
return (
|
||||||
<div className="flex flex-col flex-1 items-center justify-center bg-zinc-50 font-sans dark:bg-black">
|
<div className="min-h-screen pb-20">
|
||||||
<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">
|
<header className="bg-white sticky top-0 z-10 shadow-sm">
|
||||||
<Image
|
<div className="px-4 py-3">
|
||||||
className="dark:invert"
|
<h1 className="text-xl font-bold text-orange-500 mb-3">🏠 城中村租房</h1>
|
||||||
src="/next.svg"
|
<form onSubmit={handleSearch} className="flex gap-2">
|
||||||
alt="Next.js logo"
|
<select
|
||||||
width={100}
|
value={district}
|
||||||
height={20}
|
onChange={(e) => setDistrict(e.target.value)}
|
||||||
priority
|
className="px-3 py-2 rounded-lg border border-gray-200 text-sm bg-white flex-shrink-0"
|
||||||
/>
|
|
||||||
<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"
|
|
||||||
>
|
>
|
||||||
Templates
|
{districts.map((d) => (
|
||||||
</a>{" "}
|
<option key={d} value={d}>{d}</option>
|
||||||
or the{" "}
|
))}
|
||||||
<a
|
</select>
|
||||||
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
<input
|
||||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
type="text"
|
||||||
>
|
value={keyword}
|
||||||
Learning
|
onChange={(e) => setKeyword(e.target.value)}
|
||||||
</a>{" "}
|
placeholder="搜索区域、小区名称..."
|
||||||
center.
|
className="flex-1 px-3 py-2 rounded-lg border border-gray-200 text-sm"
|
||||||
</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}
|
|
||||||
/>
|
/>
|
||||||
Deploy Now
|
<button type="submit" className="px-4 py-2 bg-orange-500 text-white rounded-lg text-sm font-medium">
|
||||||
</a>
|
搜索
|
||||||
<a
|
</button>
|
||||||
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]"
|
</form>
|
||||||
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>
|
|
||||||
</div>
|
</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>
|
</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>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user