🎉 init: 小龙的工作空间
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.next/
|
||||
data/
|
||||
.env
|
||||
*.db
|
||||
*.db-journal
|
||||
*.db-wal
|
||||
@@ -0,0 +1,118 @@
|
||||
# PetCare — Fullstack Project
|
||||
|
||||
> 一款帮助宠物主人管理宠物健康、日常活动和成长记录的移动应用。
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
apps/
|
||||
├── web/ # Next.js 15 + TypeScript + Tailwind CSS
|
||||
└── api/ # Fastify 5 + TypeScript + SQLite (sql.js)
|
||||
|
||||
packages/
|
||||
├── shared-types/ # @shared/types — shared TypeScript interfaces
|
||||
└── shared-config/ # @shared/config — shared configuration
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Install all dependencies (root + workspaces)
|
||||
npm install
|
||||
|
||||
# Start both frontend and backend in dev mode
|
||||
npm run dev
|
||||
|
||||
# Or start individually
|
||||
npm run dev:web # http://localhost:3000
|
||||
npm run dev:api # http://localhost:3001
|
||||
```
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
# Build everything
|
||||
npm run build
|
||||
|
||||
# Or individually
|
||||
npm run build:web
|
||||
npm run build:api
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
# Run API tests
|
||||
npm test
|
||||
```
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### Auth
|
||||
- `POST /api/auth/register`
|
||||
- `POST /api/auth/login`
|
||||
- `GET /api/auth/me`
|
||||
|
||||
### Resources
|
||||
#### users
|
||||
- `GET /api/users` — List
|
||||
- `GET /api/users/:id` — Get
|
||||
- `POST /api/users` — Create
|
||||
- `PUT /api/users/:id` — Update
|
||||
- `DELETE /api/users/:id` — Delete
|
||||
|
||||
#### pets
|
||||
- `GET /api/pets` — List
|
||||
- `GET /api/pets/:id` — Get
|
||||
- `POST /api/pets` — Create
|
||||
- `PUT /api/pets/:id` — Update
|
||||
- `DELETE /api/pets/:id` — Delete
|
||||
|
||||
#### schedules
|
||||
- `GET /api/schedules` — List
|
||||
- `GET /api/schedules/:id` — Get
|
||||
- `POST /api/schedules` — Create
|
||||
- `PUT /api/schedules/:id` — Update
|
||||
- `DELETE /api/schedules/:id` — Delete
|
||||
|
||||
#### records
|
||||
- `GET /api/records` — List
|
||||
- `GET /api/records/:id` — Get
|
||||
- `POST /api/records` — Create
|
||||
- `PUT /api/records/:id` — Update
|
||||
- `DELETE /api/records/:id` — Delete
|
||||
|
||||
#### albums
|
||||
- `GET /api/albums` — List
|
||||
- `GET /api/albums/:id` — Get
|
||||
- `POST /api/albums` — Create
|
||||
- `PUT /api/albums/:id` — Update
|
||||
- `DELETE /api/albums/:id` — Delete
|
||||
|
||||
#### hospitals
|
||||
- `GET /api/hospitals` — List
|
||||
- `GET /api/hospitals/:id` — Get
|
||||
- `POST /api/hospitals` — Create
|
||||
- `PUT /api/hospitals/:id` — Update
|
||||
- `DELETE /api/hospitals/:id` — Delete
|
||||
|
||||
#### items
|
||||
- `GET /api/items` — List
|
||||
- `GET /api/items/:id` — Get
|
||||
- `POST /api/items` — Create
|
||||
- `PUT /api/items/:id` — Update
|
||||
- `DELETE /api/items/:id` — Delete
|
||||
|
||||
#### daily-logs
|
||||
- `GET /api/daily-logs` — List
|
||||
- `GET /api/daily-logs/:id` — Get
|
||||
- `POST /api/daily-logs` — Create
|
||||
- `PUT /api/daily-logs/:id` — Update
|
||||
- `DELETE /api/daily-logs/:id` — Delete
|
||||
|
||||
### System
|
||||
- `GET /api/health` — Health check
|
||||
|
||||
## Environment Variables
|
||||
|
||||
See `.env.example` for all available configuration.
|
||||
@@ -0,0 +1,7 @@
|
||||
node_modules/
|
||||
dist/
|
||||
data/
|
||||
.env
|
||||
*.db
|
||||
*.db-journal
|
||||
*.db-wal
|
||||
@@ -0,0 +1,111 @@
|
||||
# PetCare — Backend API
|
||||
|
||||
> 一款帮助宠物主人管理宠物健康、日常活动和成长记录的移动应用。
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Runtime**: Node.js
|
||||
- **Framework**: Fastify 5
|
||||
- **Language**: TypeScript
|
||||
- **Database**: SQLite (better-sqlite3)
|
||||
- **Auth**: JWT + bcrypt
|
||||
|
||||
## Getting Started
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
npm install
|
||||
|
||||
# Development (hot reload)
|
||||
npm run dev
|
||||
|
||||
# Build
|
||||
npm run build
|
||||
|
||||
# Production start
|
||||
npm run start
|
||||
|
||||
# Run tests
|
||||
npm test
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
src/
|
||||
├── index.ts # Server entry point
|
||||
├── db/
|
||||
│ ├── schema.ts # SQLite schema
|
||||
│ └── client.ts # Database client
|
||||
├── routes/
|
||||
│ ├── auth.ts # Auth routes (register/login/me)
|
||||
│ └── *.ts # CRUD routes
|
||||
├── services/
|
||||
│ └── *.ts # Business logic
|
||||
├── middleware/
|
||||
│ └── auth.ts # JWT middleware
|
||||
├── types/
|
||||
│ └── index.ts # TypeScript types
|
||||
└── __tests__/
|
||||
└── *.test.ts # Tests
|
||||
```
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### Auth
|
||||
- `POST /api/auth/register` — Register
|
||||
- `POST /api/auth/login` — Login
|
||||
- `GET /api/auth/me` — Current user (auth required)
|
||||
|
||||
### Resources
|
||||
#### pets
|
||||
- `GET /api/pets` — List all
|
||||
- `GET /api/pets/:id` — Get by ID
|
||||
- `POST /api/pets` — Create
|
||||
- `PUT /api/pets/:id` — Update
|
||||
- `DELETE /api/pets/:id` — Delete
|
||||
|
||||
#### schedules
|
||||
- `GET /api/schedules` — List all
|
||||
- `GET /api/schedules/:id` — Get by ID
|
||||
- `POST /api/schedules` — Create
|
||||
- `PUT /api/schedules/:id` — Update
|
||||
- `DELETE /api/schedules/:id` — Delete
|
||||
|
||||
#### records
|
||||
- `GET /api/records` — List all
|
||||
- `GET /api/records/:id` — Get by ID
|
||||
- `POST /api/records` — Create
|
||||
- `PUT /api/records/:id` — Update
|
||||
- `DELETE /api/records/:id` — Delete
|
||||
|
||||
#### albums
|
||||
- `GET /api/albums` — List all
|
||||
- `GET /api/albums/:id` — Get by ID
|
||||
- `POST /api/albums` — Create
|
||||
- `PUT /api/albums/:id` — Update
|
||||
- `DELETE /api/albums/:id` — Delete
|
||||
|
||||
#### hospitals
|
||||
- `GET /api/hospitals` — List all
|
||||
- `GET /api/hospitals/:id` — Get by ID
|
||||
- `POST /api/hospitals` — Create
|
||||
- `PUT /api/hospitals/:id` — Update
|
||||
- `DELETE /api/hospitals/:id` — Delete
|
||||
|
||||
#### items
|
||||
- `GET /api/items` — List all
|
||||
- `GET /api/items/:id` — Get by ID
|
||||
- `POST /api/items` — Create
|
||||
- `PUT /api/items/:id` — Update
|
||||
- `DELETE /api/items/:id` — Delete
|
||||
|
||||
#### daily-logs
|
||||
- `GET /api/daily-logs` — List all
|
||||
- `GET /api/daily-logs/:id` — Get by ID
|
||||
- `POST /api/daily-logs` — Create
|
||||
- `PUT /api/daily-logs/:id` — Update
|
||||
- `DELETE /api/daily-logs/:id` — Delete
|
||||
|
||||
### System
|
||||
- `GET /api/health` — Health check
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "petcare-api",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"build": "tsc",
|
||||
"start": "node dist/index.js",
|
||||
"test": "node --import tsx --test src/__tests__/*.test.ts",
|
||||
"test:watch": "node --import tsx --test --watch src/__tests__/*.test.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"fastify": "^5.0.0",
|
||||
"@fastify/cors": "^10.0.0",
|
||||
"@fastify/jwt": "^9.0.0",
|
||||
"sql.js": "^1.12.0",
|
||||
"bcrypt": "^5.1.0",
|
||||
"@shared/types": "*",
|
||||
"@shared/config": "*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/bcrypt": "^5.0.0",
|
||||
"typescript": "^5.6.0",
|
||||
"tsx": "^4.0.0",
|
||||
"pino-pretty": "^11.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
// Auth routes test
|
||||
import { describe, it, before, after } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { buildApp } from "../index.js";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
|
||||
let app: FastifyInstance;
|
||||
let token: string;
|
||||
|
||||
before(async () => {
|
||||
process.env.JWT_SECRET = "test-secret";
|
||||
process.env.DATABASE_URL = ":memory:";
|
||||
app = await buildApp();
|
||||
await app.ready();
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
describe("POST /api/auth/register", () => {
|
||||
it("registers a new user", async () => {
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/auth/register",
|
||||
payload: { username: "testuser", password: "password123" },
|
||||
});
|
||||
assert.equal(res.statusCode, 201);
|
||||
const body = res.json();
|
||||
assert.ok(body.token);
|
||||
assert.equal(body.user.username, "testuser");
|
||||
token = body.token;
|
||||
});
|
||||
|
||||
it("rejects duplicate username", async () => {
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/auth/register",
|
||||
payload: { username: "testuser", password: "password123" },
|
||||
});
|
||||
assert.equal(res.statusCode, 409);
|
||||
});
|
||||
|
||||
it("rejects short password", async () => {
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/auth/register",
|
||||
payload: { username: "user2", password: "123" },
|
||||
});
|
||||
assert.equal(res.statusCode, 400);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/auth/login", () => {
|
||||
it("logs in with correct credentials", async () => {
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/auth/login",
|
||||
payload: { username: "testuser", password: "password123" },
|
||||
});
|
||||
assert.equal(res.statusCode, 200);
|
||||
const body = res.json();
|
||||
assert.ok(body.token);
|
||||
token = body.token;
|
||||
});
|
||||
|
||||
it("rejects wrong password", async () => {
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/auth/login",
|
||||
payload: { username: "testuser", password: "wrongpassword" },
|
||||
});
|
||||
assert.equal(res.statusCode, 401);
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/auth/me", () => {
|
||||
it("returns current user with valid token", async () => {
|
||||
const res = await app.inject({
|
||||
method: "GET",
|
||||
url: "/api/auth/me",
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
});
|
||||
assert.equal(res.statusCode, 200);
|
||||
const body = res.json();
|
||||
assert.equal(body.data.username, "testuser");
|
||||
});
|
||||
|
||||
it("rejects without token", async () => {
|
||||
const res = await app.inject({
|
||||
method: "GET",
|
||||
url: "/api/auth/me",
|
||||
});
|
||||
assert.equal(res.statusCode, 401);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
// DailyLog CRUD test
|
||||
import { describe, it, before, after } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { buildApp } from "../index.js";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
|
||||
let app: FastifyInstance;
|
||||
let token: string;
|
||||
let createdId: string;
|
||||
|
||||
before(async () => {
|
||||
process.env.JWT_SECRET = "test-secret";
|
||||
process.env.DATABASE_URL = ":memory:";
|
||||
app = await buildApp();
|
||||
await app.ready();
|
||||
|
||||
// Register and login to get a token
|
||||
const regRes = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/auth/register",
|
||||
payload: { username: `test_${Date.now()}`, password: "password123" },
|
||||
});
|
||||
token = regRes.json().token;
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
describe("GET /api/daily_logs", () => {
|
||||
it("returns empty list", async () => {
|
||||
const res = await app.inject({
|
||||
method: "GET",
|
||||
url: "/api/daily_logs",
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
});
|
||||
assert.equal(res.statusCode, 200);
|
||||
const body = res.json();
|
||||
assert.ok(Array.isArray(body.data));
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/daily_logs", () => {
|
||||
it("creates a daily_log", async () => {
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/daily_logs",
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: {
|
||||
"petId": "00000000-0000-0000-0000-000000000001",
|
||||
"category": "sample-category",
|
||||
"value": "sample-value",
|
||||
"loggedAt": "sample-loggedat"
|
||||
},
|
||||
});
|
||||
assert.equal(res.statusCode, 201);
|
||||
const body = res.json();
|
||||
assert.ok(body.data.id);
|
||||
createdId = body.data.id;
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/daily_logs/:id", () => {
|
||||
it("returns the created daily_log", async () => {
|
||||
const res = await app.inject({
|
||||
method: "GET",
|
||||
url: `/api/daily_logs/${createdId}`,
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
});
|
||||
assert.equal(res.statusCode, 200);
|
||||
const body = res.json();
|
||||
assert.equal(body.data.id, createdId);
|
||||
});
|
||||
|
||||
it("returns 404 for nonexistent id", async () => {
|
||||
const res = await app.inject({
|
||||
method: "GET",
|
||||
url: `/api/daily_logs/nonexistent`,
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
});
|
||||
assert.equal(res.statusCode, 404);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PUT /api/daily_logs/:id", () => {
|
||||
it("updates the daily_log", async () => {
|
||||
const res = await app.inject({
|
||||
method: "PUT",
|
||||
url: `/api/daily_logs/${createdId}`,
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: {
|
||||
"petId": "00000000-0000-0000-0000-000000000001",
|
||||
"category": "sample-category",
|
||||
"value": "sample-value",
|
||||
"loggedAt": "sample-loggedat"
|
||||
},
|
||||
});
|
||||
assert.equal(res.statusCode, 200);
|
||||
});
|
||||
});
|
||||
|
||||
describe("DELETE /api/daily_logs/:id", () => {
|
||||
it("deletes the daily_log", async () => {
|
||||
const res = await app.inject({
|
||||
method: "DELETE",
|
||||
url: `/api/daily_logs/${createdId}`,
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
});
|
||||
assert.equal(res.statusCode, 204);
|
||||
});
|
||||
|
||||
it("returns 404 after delete", async () => {
|
||||
const res = await app.inject({
|
||||
method: "GET",
|
||||
url: `/api/daily_logs/${createdId}`,
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
});
|
||||
assert.equal(res.statusCode, 404);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
// Pet CRUD test
|
||||
import { describe, it, before, after } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { buildApp } from "../index.js";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
|
||||
let app: FastifyInstance;
|
||||
let token: string;
|
||||
let createdId: string;
|
||||
let payload: Record<string, unknown>;
|
||||
|
||||
before(async () => {
|
||||
process.env.JWT_SECRET = "test-secret";
|
||||
process.env.DATABASE_URL = ":memory:";
|
||||
app = await buildApp();
|
||||
await app.ready();
|
||||
|
||||
// Register and login to get a token
|
||||
const regRes = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/auth/register",
|
||||
payload: { username: `test_${Date.now()}`, password: "password123" },
|
||||
});
|
||||
token = regRes.json().token;
|
||||
|
||||
// Resolve user FK references with the registered user's real ID
|
||||
payload = {
|
||||
"userId": regRes.json().user.id,
|
||||
"name": "sample-name",
|
||||
"species": "sample-species",
|
||||
"breed": "sample-breed",
|
||||
"birthDate": "sample-birthdate",
|
||||
"weightKg": 1,
|
||||
"avatarUrl": "sample-avatarurl"
|
||||
};
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
describe("GET /api/pets", () => {
|
||||
it("returns empty list", async () => {
|
||||
const res = await app.inject({
|
||||
method: "GET",
|
||||
url: "/api/pets",
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
});
|
||||
assert.equal(res.statusCode, 200);
|
||||
const body = res.json();
|
||||
assert.ok(Array.isArray(body.data));
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/pets", () => {
|
||||
it("creates a pet", async () => {
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/pets",
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload,
|
||||
});
|
||||
assert.equal(res.statusCode, 201);
|
||||
const body = res.json();
|
||||
assert.ok(body.data.id);
|
||||
createdId = body.data.id;
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/pets/:id", () => {
|
||||
it("returns the created pet", async () => {
|
||||
const res = await app.inject({
|
||||
method: "GET",
|
||||
url: `/api/pets/${createdId}`,
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
});
|
||||
assert.equal(res.statusCode, 200);
|
||||
const body = res.json();
|
||||
assert.equal(body.data.id, createdId);
|
||||
});
|
||||
|
||||
it("returns 404 for nonexistent id", async () => {
|
||||
const res = await app.inject({
|
||||
method: "GET",
|
||||
url: `/api/pets/nonexistent`,
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
});
|
||||
assert.equal(res.statusCode, 404);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PUT /api/pets/:id", () => {
|
||||
it("updates the pet", async () => {
|
||||
const res = await app.inject({
|
||||
method: "PUT",
|
||||
url: `/api/pets/${createdId}`,
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload,
|
||||
});
|
||||
assert.equal(res.statusCode, 200);
|
||||
});
|
||||
});
|
||||
|
||||
describe("DELETE /api/pets/:id", () => {
|
||||
it("deletes the pet", async () => {
|
||||
const res = await app.inject({
|
||||
method: "DELETE",
|
||||
url: `/api/pets/${createdId}`,
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
});
|
||||
assert.equal(res.statusCode, 204);
|
||||
});
|
||||
|
||||
it("returns 404 after delete", async () => {
|
||||
const res = await app.inject({
|
||||
method: "GET",
|
||||
url: `/api/pets/${createdId}`,
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
});
|
||||
assert.equal(res.statusCode, 404);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
// Schedules CRUD test
|
||||
import { describe, it, before, after } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { buildApp } from "../index.js";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
|
||||
let app: FastifyInstance;
|
||||
let token: string;
|
||||
let createdId: string;
|
||||
let payload: Record<string, unknown>;
|
||||
|
||||
before(async () => {
|
||||
process.env.JWT_SECRET = "test-secret";
|
||||
process.env.DATABASE_URL = ":memory:";
|
||||
app = await buildApp();
|
||||
await app.ready();
|
||||
|
||||
// Register and login to get a token
|
||||
const regRes = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/auth/register",
|
||||
payload: { username: `test_${Date.now()}`, password: "password123" },
|
||||
});
|
||||
token = regRes.json().token;
|
||||
|
||||
// Resolve user FK references with the registered user's real ID
|
||||
payload = {
|
||||
"userId": regRes.json().user.id,
|
||||
"courseId": "sample-courseid",
|
||||
"classroomId": "sample-classroomid",
|
||||
"instructorId": "sample-instructorid",
|
||||
"dayOfWeek": 1,
|
||||
"startTime": "sample-starttime",
|
||||
"endTime": "sample-endtime"
|
||||
};
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
describe("GET /api/schedules", () => {
|
||||
it("returns empty list", async () => {
|
||||
const res = await app.inject({
|
||||
method: "GET",
|
||||
url: "/api/schedules",
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
});
|
||||
assert.equal(res.statusCode, 200);
|
||||
const body = res.json();
|
||||
assert.ok(Array.isArray(body.data));
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/schedules", () => {
|
||||
it("creates a schedule", async () => {
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/schedules",
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload,
|
||||
});
|
||||
assert.equal(res.statusCode, 201);
|
||||
const body = res.json();
|
||||
assert.ok(body.data.id);
|
||||
createdId = body.data.id;
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/schedules/:id", () => {
|
||||
it("returns the created schedule", async () => {
|
||||
const res = await app.inject({
|
||||
method: "GET",
|
||||
url: `/api/schedules/${createdId}`,
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
});
|
||||
assert.equal(res.statusCode, 200);
|
||||
const body = res.json();
|
||||
assert.equal(body.data.id, createdId);
|
||||
});
|
||||
|
||||
it("returns 404 for nonexistent id", async () => {
|
||||
const res = await app.inject({
|
||||
method: "GET",
|
||||
url: `/api/schedules/nonexistent`,
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
});
|
||||
assert.equal(res.statusCode, 404);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PUT /api/schedules/:id", () => {
|
||||
it("updates the schedule", async () => {
|
||||
const res = await app.inject({
|
||||
method: "PUT",
|
||||
url: `/api/schedules/${createdId}`,
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload,
|
||||
});
|
||||
assert.equal(res.statusCode, 200);
|
||||
});
|
||||
});
|
||||
|
||||
describe("DELETE /api/schedules/:id", () => {
|
||||
it("deletes the schedule", async () => {
|
||||
const res = await app.inject({
|
||||
method: "DELETE",
|
||||
url: `/api/schedules/${createdId}`,
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
});
|
||||
assert.equal(res.statusCode, 204);
|
||||
});
|
||||
|
||||
it("returns 404 after delete", async () => {
|
||||
const res = await app.inject({
|
||||
method: "GET",
|
||||
url: `/api/schedules/${createdId}`,
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
});
|
||||
assert.equal(res.statusCode, 404);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
// User CRUD test
|
||||
import { describe, it, before, after } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { buildApp } from "../index.js";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
|
||||
let app: FastifyInstance;
|
||||
let token: string;
|
||||
let createdId: string;
|
||||
|
||||
before(async () => {
|
||||
process.env.JWT_SECRET = "test-secret";
|
||||
process.env.DATABASE_URL = ":memory:";
|
||||
app = await buildApp();
|
||||
await app.ready();
|
||||
|
||||
// Register and login to get a token
|
||||
const regRes = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/auth/register",
|
||||
payload: { username: `test_${Date.now()}`, password: "password123" },
|
||||
});
|
||||
token = regRes.json().token;
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
describe("GET /api/users", () => {
|
||||
it("returns empty list", async () => {
|
||||
const res = await app.inject({
|
||||
method: "GET",
|
||||
url: "/api/users",
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
});
|
||||
assert.equal(res.statusCode, 200);
|
||||
const body = res.json();
|
||||
assert.ok(Array.isArray(body.data));
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/users", () => {
|
||||
it("creates a user", async () => {
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/users",
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: {
|
||||
"phone": "sample-phone",
|
||||
"nickname": "sample-nickname",
|
||||
"avatarUrl": "sample-avatarurl"
|
||||
},
|
||||
});
|
||||
assert.equal(res.statusCode, 201);
|
||||
const body = res.json();
|
||||
assert.ok(body.data.id);
|
||||
createdId = body.data.id;
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/users/:id", () => {
|
||||
it("returns the created user", async () => {
|
||||
const res = await app.inject({
|
||||
method: "GET",
|
||||
url: `/api/users/${createdId}`,
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
});
|
||||
assert.equal(res.statusCode, 200);
|
||||
const body = res.json();
|
||||
assert.equal(body.data.id, createdId);
|
||||
});
|
||||
|
||||
it("returns 404 for nonexistent id", async () => {
|
||||
const res = await app.inject({
|
||||
method: "GET",
|
||||
url: `/api/users/nonexistent`,
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
});
|
||||
assert.equal(res.statusCode, 404);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PUT /api/users/:id", () => {
|
||||
it("updates the user", async () => {
|
||||
const res = await app.inject({
|
||||
method: "PUT",
|
||||
url: `/api/users/${createdId}`,
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: {
|
||||
"phone": "sample-phone",
|
||||
"nickname": "sample-nickname",
|
||||
"avatarUrl": "sample-avatarurl"
|
||||
},
|
||||
});
|
||||
assert.equal(res.statusCode, 200);
|
||||
});
|
||||
});
|
||||
|
||||
describe("DELETE /api/users/:id", () => {
|
||||
it("deletes the user", async () => {
|
||||
const res = await app.inject({
|
||||
method: "DELETE",
|
||||
url: `/api/users/${createdId}`,
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
});
|
||||
assert.equal(res.statusCode, 204);
|
||||
});
|
||||
|
||||
it("returns 404 after delete", async () => {
|
||||
const res = await app.inject({
|
||||
method: "GET",
|
||||
url: `/api/users/${createdId}`,
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
});
|
||||
assert.equal(res.statusCode, 404);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
// SQLite database client (sql.js — pure WASM, no native deps)
|
||||
import initSqlJs, { type Database, type BindParams } from "sql.js";
|
||||
import { createTables } from "./schema.js";
|
||||
|
||||
let db: Database | null = null;
|
||||
let initPromise: Promise<Database> | null = null;
|
||||
|
||||
/** Initialize the database (call once at startup). */
|
||||
export async function initDb(dbPath?: string): Promise<Database> {
|
||||
if (db) return db;
|
||||
if (initPromise) return initPromise;
|
||||
|
||||
initPromise = (async () => {
|
||||
const SQL = await initSqlJs();
|
||||
const path = dbPath || process.env.DATABASE_URL || ":memory:";
|
||||
|
||||
// Try to load existing database from file
|
||||
let buffer: ArrayLike<number> | undefined;
|
||||
if (path !== ":memory:") {
|
||||
try {
|
||||
const fs = await import("node:fs/promises");
|
||||
const data = await fs.readFile(path);
|
||||
buffer = new Uint8Array(data);
|
||||
} catch {
|
||||
// File doesn't exist yet — start fresh
|
||||
}
|
||||
}
|
||||
|
||||
db = new SQL.Database(buffer);
|
||||
db.run("PRAGMA foreign_keys = ON");
|
||||
createTables(db);
|
||||
return db;
|
||||
})();
|
||||
|
||||
return initPromise;
|
||||
}
|
||||
|
||||
/** Get the initialized database (must call initDb first). */
|
||||
export function getDb(): Database {
|
||||
if (!db) throw new Error("Database not initialized. Call initDb() first.");
|
||||
return db;
|
||||
}
|
||||
|
||||
/** Save database to disk. */
|
||||
export async function saveDb(dbPath?: string): Promise<void> {
|
||||
if (!db) return;
|
||||
const path = dbPath || process.env.DATABASE_URL || "./data/app.db";
|
||||
if (path === ":memory:") return;
|
||||
const fs = await import("node:fs/promises");
|
||||
const { dirname } = await import("node:path");
|
||||
await fs.mkdir(dirname(path), { recursive: true });
|
||||
const data = db.export();
|
||||
await fs.writeFile(path, Buffer.from(data));
|
||||
}
|
||||
|
||||
export async function closeDb(): Promise<void> {
|
||||
if (db) {
|
||||
await saveDb();
|
||||
db.close();
|
||||
db = null;
|
||||
initPromise = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Helper: run a query and return all rows as objects
|
||||
export function queryAll<T = Record<string, unknown>>(sql: string, params: BindParams = []): T[] {
|
||||
const d = getDb();
|
||||
const stmt = d.prepare(sql);
|
||||
if (params) stmt.bind(params);
|
||||
const results: T[] = [];
|
||||
while (stmt.step()) {
|
||||
const row = stmt.getAsObject();
|
||||
results.push(row as unknown as T);
|
||||
}
|
||||
stmt.free();
|
||||
return results;
|
||||
}
|
||||
|
||||
// Helper: run a query and return the first row
|
||||
export function queryOne<T = Record<string, unknown>>(sql: string, params: BindParams = []): T | undefined {
|
||||
const rows = queryAll<T>(sql, params);
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
// Helper: run a mutation and return { changes, lastInsertRowid }
|
||||
export function execute(sql: string, params: BindParams = []): { changes: number; lastInsertRowid: number } {
|
||||
const d = getDb();
|
||||
d.run(sql, params);
|
||||
return {
|
||||
changes: d.getRowsModified(),
|
||||
lastInsertRowid: 0,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
// Auto-generated SQLite schema
|
||||
import type { Database } from "sql.js";
|
||||
|
||||
export function createTables(db: Database): void {
|
||||
const statements = [
|
||||
"CREATE TABLE IF NOT EXISTS users (\n id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),\n username TEXT NOT NULL UNIQUE,\n password_hash TEXT NOT NULL,\n nickname TEXT,\n role TEXT DEFAULT 'user',\n phone TEXT,\n avatar_url TEXT,\n created_at TEXT,\n updated_at TEXT\n);",
|
||||
"CREATE TABLE IF NOT EXISTS pets (\n id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),\n user_id TEXT NOT NULL REFERENCES users(id),\n name TEXT NOT NULL,\n species TEXT,\n breed TEXT,\n birth_date TEXT,\n weight_kg REAL,\n avatar_url TEXT,\n created_at TEXT,\n updated_at TEXT\n);",
|
||||
"CREATE TABLE IF NOT EXISTS schedules (\n id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),\n user_id TEXT REFERENCES users(id),\n course_id TEXT REFERENCES courses(id),\n classroom_id TEXT REFERENCES classrooms(id),\n instructor_id TEXT REFERENCES instructors(id),\n day_of_week INTEGER,\n start_time TEXT,\n end_time TEXT,\n created_at TEXT,\n updated_at TEXT\n);",
|
||||
"CREATE TABLE IF NOT EXISTS records (\n id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),\n user_id TEXT REFERENCES users(id),\n title TEXT NOT NULL,\n description TEXT,\n status TEXT,\n data TEXT,\n created_at TEXT,\n updated_at TEXT\n);",
|
||||
"CREATE TABLE IF NOT EXISTS albums (\n id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),\n user_id TEXT REFERENCES users(id),\n title TEXT,\n image_url TEXT NOT NULL,\n caption TEXT,\n taken_at TEXT,\n created_at TEXT,\n updated_at TEXT\n);",
|
||||
"CREATE TABLE IF NOT EXISTS hospitals (\n id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),\n user_id TEXT REFERENCES users(id),\n name TEXT NOT NULL,\n address TEXT,\n phone TEXT,\n rating REAL,\n lat REAL,\n lng REAL,\n created_at TEXT,\n updated_at TEXT\n);",
|
||||
"CREATE TABLE IF NOT EXISTS items (\n id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),\n user_id TEXT REFERENCES users(id),\n title TEXT NOT NULL,\n description TEXT,\n status TEXT,\n data TEXT,\n created_at TEXT,\n updated_at TEXT\n);",
|
||||
"CREATE TABLE IF NOT EXISTS daily-logs (\n id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),\n user_id TEXT REFERENCES users(id),\n title TEXT NOT NULL,\n data TEXT,\n created_at TEXT,\n updated_at TEXT\n);"
|
||||
];
|
||||
for (const sql of statements) {
|
||||
const trimmed = sql.trim();
|
||||
if (trimmed) db.run(trimmed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
// PetCare — Fastify Backend Server
|
||||
import Fastify from "fastify";
|
||||
import cors from "@fastify/cors";
|
||||
import fjwt from "@fastify/jwt";
|
||||
import { initDb, closeDb } from "./db/client.js";
|
||||
import { authRoutes } from "./routes/auth.js";
|
||||
import { petsRoutes } from "./routes/pets.js";
|
||||
import { schedulesRoutes } from "./routes/schedules.js";
|
||||
import { recordsRoutes } from "./routes/records.js";
|
||||
import { albumsRoutes } from "./routes/albums.js";
|
||||
import { hospitalsRoutes } from "./routes/hospitals.js";
|
||||
import { itemsRoutes } from "./routes/items.js";
|
||||
import { daily-logsRoutes } from "./routes/daily-logs.js";
|
||||
|
||||
const JWT_SECRET = process.env.JWT_SECRET || "change-me-in-production-4cc1f913";
|
||||
|
||||
export async function buildApp() {
|
||||
const app = Fastify({
|
||||
logger: {
|
||||
level: process.env.LOG_LEVEL || "info",
|
||||
transport: process.env.NODE_ENV !== "production"
|
||||
? { target: "pino-pretty", options: { colorize: true } }
|
||||
: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
// Init database
|
||||
await initDb();
|
||||
|
||||
// Plugins
|
||||
await app.register(cors, {
|
||||
origin: process.env.CORS_ORIGIN || "*",
|
||||
methods: ["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"],
|
||||
});
|
||||
|
||||
await app.register(fjwt, { secret: JWT_SECRET });
|
||||
|
||||
// Routes
|
||||
await app.register(authRoutes, { prefix: "/api/auth" });
|
||||
await app.register(petsRoutes, { prefix: "/api/pets" });
|
||||
await app.register(schedulesRoutes, { prefix: "/api/schedules" });
|
||||
await app.register(recordsRoutes, { prefix: "/api/records" });
|
||||
await app.register(albumsRoutes, { prefix: "/api/albums" });
|
||||
await app.register(hospitalsRoutes, { prefix: "/api/hospitals" });
|
||||
await app.register(itemsRoutes, { prefix: "/api/items" });
|
||||
await app.register(daily-logsRoutes, { prefix: "/api/daily-logs" });
|
||||
|
||||
// Health check
|
||||
app.get("/api/health", async () => ({ status: "ok", timestamp: new Date().toISOString() }));
|
||||
|
||||
// Graceful shutdown
|
||||
app.addHook("onClose", async () => {
|
||||
closeDb();
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
// Start server if called directly (not when imported by tests)
|
||||
const port = parseInt(process.env.PORT || "3001", 10);
|
||||
const host = process.env.HOST || "0.0.0.0";
|
||||
|
||||
async function main() {
|
||||
const app = await buildApp();
|
||||
try {
|
||||
await app.listen({ port, host });
|
||||
} catch (err) {
|
||||
app.log.error(err);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Guard: only run when executed directly, not when imported
|
||||
const isMain = process.argv[1] && (import.meta.url === `file://${process.argv[1]}` || import.meta.url.endsWith(process.argv[1]) || process.argv[1].endsWith("/src/index.ts") || process.argv[1].endsWith("/src/index.js"));
|
||||
if (isMain) {
|
||||
main();
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
// JWT Authentication Middleware
|
||||
import type { FastifyRequest, FastifyReply } from "fastify";
|
||||
import type { JwtPayload } from "../types/index.js";
|
||||
|
||||
/**
|
||||
* Verify JWT token and attach user to request.
|
||||
*/
|
||||
export async function authenticate(request: FastifyRequest, reply: FastifyReply): Promise<void> {
|
||||
try {
|
||||
await request.jwtVerify();
|
||||
} catch (err) {
|
||||
reply.status(401).send({ error: "Unauthorized", message: "Invalid or expired token", statusCode: 401 });
|
||||
}
|
||||
}
|
||||
|
||||
/** Helper to get typed user from request (after authenticate). */
|
||||
export function getUser(request: FastifyRequest): JwtPayload {
|
||||
return request.user as unknown as JwtPayload;
|
||||
}
|
||||
|
||||
/**
|
||||
* Require admin role.
|
||||
* Must be used after authenticate.
|
||||
*/
|
||||
export async function requireAdmin(request: FastifyRequest, reply: FastifyReply): Promise<void> {
|
||||
const user = request.user as unknown as JwtPayload | undefined;
|
||||
if (!user || user.role !== "admin") {
|
||||
reply.status(403).send({ error: "Forbidden", message: "Admin access required", statusCode: 403 });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Optional auth: attach user if token present, but don't fail if missing.
|
||||
*/
|
||||
export async function optionalAuth(request: FastifyRequest): Promise<void> {
|
||||
try {
|
||||
await request.jwtVerify();
|
||||
} catch {
|
||||
// No token or invalid — continue without user
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
// Authentication routes
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import bcrypt from "bcrypt";
|
||||
import { queryOne, execute } from "../db/client.js";
|
||||
import { authenticate } from "../middleware/auth.js";
|
||||
import type { User, RegisterInput, LoginInput, AuthResponse } from "../types/index.js";
|
||||
|
||||
const SALT_ROUNDS = 10;
|
||||
|
||||
export async function authRoutes(app: FastifyInstance): Promise<void> {
|
||||
// POST /api/auth/register
|
||||
app.post<{ Body: RegisterInput }>("/register", async (request, reply) => {
|
||||
const { username, password, nickname } = request.body;
|
||||
|
||||
if (!username || !password) {
|
||||
return reply.status(400).send({
|
||||
error: "Bad Request",
|
||||
message: "Username and password are required",
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
if (password.length < 6) {
|
||||
return reply.status(400).send({
|
||||
error: "Bad Request",
|
||||
message: "Password must be at least 6 characters",
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const existing = queryOne("SELECT id FROM users WHERE username = ?", [username]);
|
||||
if (existing) {
|
||||
return reply.status(409).send({
|
||||
error: "Conflict",
|
||||
message: "Username already exists",
|
||||
statusCode: 409,
|
||||
});
|
||||
}
|
||||
|
||||
const id = crypto.randomUUID();
|
||||
const passwordHash = await bcrypt.hash(password, SALT_ROUNDS);
|
||||
const now = new Date().toISOString();
|
||||
|
||||
execute(
|
||||
"INSERT INTO users (id, username, password_hash, nickname, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)",
|
||||
[id, username, passwordHash, nickname || username, now, now]
|
||||
);
|
||||
|
||||
const user = queryOne<User>(
|
||||
"SELECT id, username, nickname, role, created_at, updated_at FROM users WHERE id = ?",
|
||||
[id]
|
||||
);
|
||||
if (!user) {
|
||||
return reply.status(500).send({ error: "Internal Error", message: "Failed to create user", statusCode: 500 });
|
||||
}
|
||||
|
||||
const token = app.jwt.sign({ userId: id, username, role: user.role });
|
||||
|
||||
return reply.status(201).send({ token, user } satisfies AuthResponse);
|
||||
});
|
||||
|
||||
// POST /api/auth/login
|
||||
app.post<{ Body: LoginInput }>("/login", async (request, reply) => {
|
||||
const { username, password } = request.body;
|
||||
|
||||
if (!username || !password) {
|
||||
return reply.status(400).send({
|
||||
error: "Bad Request",
|
||||
message: "Username and password are required",
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const user = queryOne<User & { password_hash: string }>(
|
||||
"SELECT * FROM users WHERE username = ?",
|
||||
[username]
|
||||
);
|
||||
|
||||
if (!user) {
|
||||
return reply.status(401).send({
|
||||
error: "Unauthorized",
|
||||
message: "Invalid username or password",
|
||||
statusCode: 401,
|
||||
});
|
||||
}
|
||||
|
||||
const valid = await bcrypt.compare(password, user.password_hash);
|
||||
if (!valid) {
|
||||
return reply.status(401).send({
|
||||
error: "Unauthorized",
|
||||
message: "Invalid username or password",
|
||||
statusCode: 401,
|
||||
});
|
||||
}
|
||||
|
||||
const token = app.jwt.sign({ userId: user.id, username: user.username, role: user.role });
|
||||
|
||||
const { password_hash, ...safeUser } = user;
|
||||
|
||||
return { token, user: safeUser } satisfies AuthResponse;
|
||||
});
|
||||
|
||||
// GET /api/auth/me — current user info
|
||||
app.get("/me", { onRequest: [authenticate] }, async (request, reply) => {
|
||||
const jwtUser = request.user as unknown as { userId: string };
|
||||
const user = queryOne<User>(
|
||||
"SELECT id, username, nickname, role, created_at, updated_at FROM users WHERE id = ?",
|
||||
[jwtUser.userId]
|
||||
);
|
||||
|
||||
if (!user) {
|
||||
return reply.status(404).send({
|
||||
error: "Not Found",
|
||||
message: "User not found",
|
||||
statusCode: 404,
|
||||
});
|
||||
}
|
||||
|
||||
return { data: user };
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
// Auto-generated DailyLog routes
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { authenticate } from "../middleware/auth.js";
|
||||
import { DailyLogService } from "../services/daily_log.js";
|
||||
import type { CreateDailyLogInput, UpdateDailyLogInput } from "../types/index.js";
|
||||
|
||||
const service = new DailyLogService();
|
||||
|
||||
export async function daily_logsRoutes(app: FastifyInstance): Promise<void> {
|
||||
// All routes require authentication
|
||||
app.addHook("onRequest", authenticate);
|
||||
|
||||
// GET /api/daily_logs — list
|
||||
app.get("/", async (request, reply) => {
|
||||
const items = service.list();
|
||||
return { data: items };
|
||||
});
|
||||
|
||||
// GET /api/daily_logs/:id — get by id
|
||||
app.get<{ Params: { id: string } }>("/:id", async (request, reply) => {
|
||||
const item = service.getById(request.params.id);
|
||||
if (!item) {
|
||||
return reply.status(404).send({ error: "Not Found", message: "DailyLog not found", statusCode: 404 });
|
||||
}
|
||||
return { data: item };
|
||||
});
|
||||
|
||||
// POST /api/daily_logs — create
|
||||
app.post<{ Body: CreateDailyLogInput }>("/", async (request, reply) => {
|
||||
const item = service.create(request.body);
|
||||
return reply.status(201).send({ data: item });
|
||||
});
|
||||
|
||||
// PUT /api/daily_logs/:id — update
|
||||
app.put<{ Params: { id: string }; Body: UpdateDailyLogInput }>("/:id", async (request, reply) => {
|
||||
const item = service.update(request.params.id, request.body);
|
||||
if (!item) {
|
||||
return reply.status(404).send({ error: "Not Found", message: "DailyLog not found", statusCode: 404 });
|
||||
}
|
||||
return { data: item };
|
||||
});
|
||||
|
||||
// DELETE /api/daily_logs/:id — delete
|
||||
app.delete<{ Params: { id: string } }>("/:id", async (request, reply) => {
|
||||
const deleted = service.delete(request.params.id);
|
||||
if (!deleted) {
|
||||
return reply.status(404).send({ error: "Not Found", message: "DailyLog not found", statusCode: 404 });
|
||||
}
|
||||
return reply.status(204).send();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
// Auto-generated Pet routes
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { authenticate } from "../middleware/auth.js";
|
||||
import { PetService } from "../services/pet.js";
|
||||
import type { CreatePetInput, UpdatePetInput } from "../types/index.js";
|
||||
|
||||
const service = new PetService();
|
||||
|
||||
export async function petsRoutes(app: FastifyInstance): Promise<void> {
|
||||
// All routes require authentication
|
||||
app.addHook("onRequest", authenticate);
|
||||
|
||||
// GET /api/pets — list
|
||||
app.get("/", async (request, reply) => {
|
||||
const items = service.list();
|
||||
return { data: items };
|
||||
});
|
||||
|
||||
// GET /api/pets/:id — get by id
|
||||
app.get<{ Params: { id: string } }>("/:id", async (request, reply) => {
|
||||
const item = service.getById(request.params.id);
|
||||
if (!item) {
|
||||
return reply.status(404).send({ error: "Not Found", message: "Pet not found", statusCode: 404 });
|
||||
}
|
||||
return { data: item };
|
||||
});
|
||||
|
||||
// POST /api/pets — create
|
||||
app.post<{ Body: CreatePetInput }>("/", async (request, reply) => {
|
||||
const item = service.create(request.body);
|
||||
return reply.status(201).send({ data: item });
|
||||
});
|
||||
|
||||
// PUT /api/pets/:id — update
|
||||
app.put<{ Params: { id: string }; Body: UpdatePetInput }>("/:id", async (request, reply) => {
|
||||
const item = service.update(request.params.id, request.body);
|
||||
if (!item) {
|
||||
return reply.status(404).send({ error: "Not Found", message: "Pet not found", statusCode: 404 });
|
||||
}
|
||||
return { data: item };
|
||||
});
|
||||
|
||||
// DELETE /api/pets/:id — delete
|
||||
app.delete<{ Params: { id: string } }>("/:id", async (request, reply) => {
|
||||
const deleted = service.delete(request.params.id);
|
||||
if (!deleted) {
|
||||
return reply.status(404).send({ error: "Not Found", message: "Pet not found", statusCode: 404 });
|
||||
}
|
||||
return reply.status(204).send();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
// Auto-generated Schedules routes
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { authenticate } from "../middleware/auth.js";
|
||||
import { SchedulesService } from "../services/schedule.js";
|
||||
import type { CreateSchedulesInput, UpdateSchedulesInput } from "../types/index.js";
|
||||
|
||||
const service = new SchedulesService();
|
||||
|
||||
export async function schedulesRoutes(app: FastifyInstance): Promise<void> {
|
||||
// All routes require authentication
|
||||
app.addHook("onRequest", authenticate);
|
||||
|
||||
// GET /api/schedules — list
|
||||
app.get("/", async (request, reply) => {
|
||||
const items = service.list();
|
||||
return { data: items };
|
||||
});
|
||||
|
||||
// GET /api/schedules/:id — get by id
|
||||
app.get<{ Params: { id: string } }>("/:id", async (request, reply) => {
|
||||
const item = service.getById(request.params.id);
|
||||
if (!item) {
|
||||
return reply.status(404).send({ error: "Not Found", message: "Schedules not found", statusCode: 404 });
|
||||
}
|
||||
return { data: item };
|
||||
});
|
||||
|
||||
// POST /api/schedules — create
|
||||
app.post<{ Body: CreateSchedulesInput }>("/", async (request, reply) => {
|
||||
const item = service.create(request.body);
|
||||
return reply.status(201).send({ data: item });
|
||||
});
|
||||
|
||||
// PUT /api/schedules/:id — update
|
||||
app.put<{ Params: { id: string }; Body: UpdateSchedulesInput }>("/:id", async (request, reply) => {
|
||||
const item = service.update(request.params.id, request.body);
|
||||
if (!item) {
|
||||
return reply.status(404).send({ error: "Not Found", message: "Schedules not found", statusCode: 404 });
|
||||
}
|
||||
return { data: item };
|
||||
});
|
||||
|
||||
// DELETE /api/schedules/:id — delete
|
||||
app.delete<{ Params: { id: string } }>("/:id", async (request, reply) => {
|
||||
const deleted = service.delete(request.params.id);
|
||||
if (!deleted) {
|
||||
return reply.status(404).send({ error: "Not Found", message: "Schedules not found", statusCode: 404 });
|
||||
}
|
||||
return reply.status(204).send();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
// Auto-generated User routes
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { authenticate } from "../middleware/auth.js";
|
||||
import { UserService } from "../services/user.js";
|
||||
import type { CreateUserInput, UpdateUserInput } from "../types/index.js";
|
||||
|
||||
const service = new UserService();
|
||||
|
||||
export async function usersRoutes(app: FastifyInstance): Promise<void> {
|
||||
// All routes require authentication
|
||||
app.addHook("onRequest", authenticate);
|
||||
|
||||
// GET /api/users — list
|
||||
app.get("/", async (request, reply) => {
|
||||
const items = service.list();
|
||||
return { data: items };
|
||||
});
|
||||
|
||||
// GET /api/users/:id — get by id
|
||||
app.get<{ Params: { id: string } }>("/:id", async (request, reply) => {
|
||||
const item = service.getById(request.params.id);
|
||||
if (!item) {
|
||||
return reply.status(404).send({ error: "Not Found", message: "User not found", statusCode: 404 });
|
||||
}
|
||||
return { data: item };
|
||||
});
|
||||
|
||||
// POST /api/users — create
|
||||
app.post<{ Body: CreateUserInput }>("/", async (request, reply) => {
|
||||
const item = service.create(request.body);
|
||||
return reply.status(201).send({ data: item });
|
||||
});
|
||||
|
||||
// PUT /api/users/:id — update
|
||||
app.put<{ Params: { id: string }; Body: UpdateUserInput }>("/:id", async (request, reply) => {
|
||||
const item = service.update(request.params.id, request.body);
|
||||
if (!item) {
|
||||
return reply.status(404).send({ error: "Not Found", message: "User not found", statusCode: 404 });
|
||||
}
|
||||
return { data: item };
|
||||
});
|
||||
|
||||
// DELETE /api/users/:id — delete
|
||||
app.delete<{ Params: { id: string } }>("/:id", async (request, reply) => {
|
||||
const deleted = service.delete(request.params.id);
|
||||
if (!deleted) {
|
||||
return reply.status(404).send({ error: "Not Found", message: "User not found", statusCode: 404 });
|
||||
}
|
||||
return reply.status(204).send();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
// Auto-generated DailyLog service
|
||||
import { queryAll, queryOne, execute } from "../db/client.js";
|
||||
import type { DailyLog, CreateDailyLogInput, UpdateDailyLogInput } from "../types/index.js";
|
||||
|
||||
export class DailyLogService {
|
||||
/** List all daily_logs */
|
||||
list(): DailyLog[] {
|
||||
return queryAll<DailyLog>("SELECT * FROM daily_logs ORDER BY created_at DESC");
|
||||
}
|
||||
|
||||
/** Get by ID */
|
||||
getById(id: string): DailyLog | undefined {
|
||||
return queryOne<DailyLog>("SELECT * FROM daily_logs WHERE id = ?", [id]);
|
||||
}
|
||||
|
||||
/** Create */
|
||||
create(input: CreateDailyLogInput): DailyLog {
|
||||
const id = crypto.randomUUID();
|
||||
const now = new Date().toISOString();
|
||||
const hasCreatedAt = false;
|
||||
const hasUpdatedAt = false;
|
||||
const cols = ["id", "pet_id", "category", "value", "logged_at"];
|
||||
const placeholders = cols.map(() => "?").join(", ");
|
||||
const values = [id, input.petId ?? null, input.category ?? null, input.value ?? null, input.loggedAt ?? null];
|
||||
|
||||
execute(`INSERT INTO daily_logs (${cols.join(", ")}) VALUES (${placeholders})`, values);
|
||||
return this.getById(id)!;
|
||||
}
|
||||
|
||||
/** Update */
|
||||
update(id: string, input: UpdateDailyLogInput): DailyLog | undefined {
|
||||
const existing = this.getById(id);
|
||||
if (!existing) return undefined;
|
||||
|
||||
const sets: string[] = [];
|
||||
const values: unknown[] = [];
|
||||
if (input.petId !== undefined) { sets.push("pet_id = ?"); values.push(input.petId); }
|
||||
if (input.category !== undefined) { sets.push("category = ?"); values.push(input.category); }
|
||||
if (input.value !== undefined) { sets.push("value = ?"); values.push(input.value); }
|
||||
if (input.loggedAt !== undefined) { sets.push("logged_at = ?"); values.push(input.loggedAt); }
|
||||
|
||||
if (sets.length === 0) return existing;
|
||||
|
||||
|
||||
|
||||
values.push(id);
|
||||
execute(`UPDATE daily_logs SET ${sets.join(", ")} WHERE id = ?`, values);
|
||||
return this.getById(id)!;
|
||||
}
|
||||
|
||||
/** Delete */
|
||||
delete(id: string): boolean {
|
||||
const result = execute("DELETE FROM daily_logs WHERE id = ?", [id]);
|
||||
return result.changes > 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
// Auto-generated Pet service
|
||||
import { queryAll, queryOne, execute } from "../db/client.js";
|
||||
import type { Pet, CreatePetInput, UpdatePetInput } from "../types/index.js";
|
||||
|
||||
export class PetService {
|
||||
/** List all pets */
|
||||
list(): Pet[] {
|
||||
return queryAll<Pet>("SELECT * FROM pets ORDER BY created_at DESC");
|
||||
}
|
||||
|
||||
/** Get by ID */
|
||||
getById(id: string): Pet | undefined {
|
||||
return queryOne<Pet>("SELECT * FROM pets WHERE id = ?", [id]);
|
||||
}
|
||||
|
||||
/** Create */
|
||||
create(input: CreatePetInput): Pet {
|
||||
const id = crypto.randomUUID();
|
||||
const now = new Date().toISOString();
|
||||
const cols = ["id", "user_id", "name", "species", "breed", "birth_date", "weight_kg", "avatar_url", "created_at", "updated_at"];
|
||||
const values = [id, input.userId ?? null, input.name ?? null, input.species ?? null, input.breed ?? null, input.birthDate ?? null, input.weightKg ?? null, input.avatarUrl ?? null, now, now];
|
||||
|
||||
execute(`INSERT INTO pets (${cols.join(", ")}) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, values);
|
||||
return this.getById(id)!;
|
||||
}
|
||||
|
||||
/** Update */
|
||||
update(id: string, input: UpdatePetInput): Pet | undefined {
|
||||
const existing = this.getById(id);
|
||||
if (!existing) return undefined;
|
||||
|
||||
const sets: string[] = [];
|
||||
const values: unknown[] = [];
|
||||
if (input.userId !== undefined) { sets.push("user_id = ?"); values.push(input.userId); }
|
||||
if (input.name !== undefined) { sets.push("name = ?"); values.push(input.name); }
|
||||
if (input.species !== undefined) { sets.push("species = ?"); values.push(input.species); }
|
||||
if (input.breed !== undefined) { sets.push("breed = ?"); values.push(input.breed); }
|
||||
if (input.birthDate !== undefined) { sets.push("birth_date = ?"); values.push(input.birthDate); }
|
||||
if (input.weightKg !== undefined) { sets.push("weight_kg = ?"); values.push(input.weightKg); }
|
||||
if (input.avatarUrl !== undefined) { sets.push("avatar_url = ?"); values.push(input.avatarUrl); }
|
||||
|
||||
if (sets.length === 0) return existing;
|
||||
|
||||
sets.push("updated_at = ?");
|
||||
values.push(new Date().toISOString());
|
||||
|
||||
values.push(id);
|
||||
execute(`UPDATE pets SET ${sets.join(", ")} WHERE id = ?`, values);
|
||||
return this.getById(id)!;
|
||||
}
|
||||
|
||||
/** Delete */
|
||||
delete(id: string): boolean {
|
||||
const result = execute("DELETE FROM pets WHERE id = ?", [id]);
|
||||
return result.changes > 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
// Auto-generated Schedules service
|
||||
import { queryAll, queryOne, execute } from "../db/client.js";
|
||||
import type { Schedules, CreateSchedulesInput, UpdateSchedulesInput } from "../types/index.js";
|
||||
|
||||
export class SchedulesService {
|
||||
/** List all schedules */
|
||||
list(): Schedules[] {
|
||||
return queryAll<Schedules>("SELECT * FROM schedules ORDER BY created_at DESC");
|
||||
}
|
||||
|
||||
/** Get by ID */
|
||||
getById(id: string): Schedules | undefined {
|
||||
return queryOne<Schedules>("SELECT * FROM schedules WHERE id = ?", [id]);
|
||||
}
|
||||
|
||||
/** Create */
|
||||
create(input: CreateSchedulesInput): Schedules {
|
||||
const id = crypto.randomUUID();
|
||||
const now = new Date().toISOString();
|
||||
const cols = ["id", "user_id", "course_id", "classroom_id", "instructor_id", "day_of_week", "start_time", "end_time", "created_at", "updated_at"];
|
||||
const values = [id, input.userId ?? null, input.courseId ?? null, input.classroomId ?? null, input.instructorId ?? null, input.dayOfWeek ?? null, input.startTime ?? null, input.endTime ?? null, now, now];
|
||||
|
||||
execute(`INSERT INTO schedules (${cols.join(", ")}) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, values);
|
||||
return this.getById(id)!;
|
||||
}
|
||||
|
||||
/** Update */
|
||||
update(id: string, input: UpdateSchedulesInput): Schedules | undefined {
|
||||
const existing = this.getById(id);
|
||||
if (!existing) return undefined;
|
||||
|
||||
const sets: string[] = [];
|
||||
const values: unknown[] = [];
|
||||
if (input.userId !== undefined) { sets.push("user_id = ?"); values.push(input.userId); }
|
||||
if (input.courseId !== undefined) { sets.push("course_id = ?"); values.push(input.courseId); }
|
||||
if (input.classroomId !== undefined) { sets.push("classroom_id = ?"); values.push(input.classroomId); }
|
||||
if (input.instructorId !== undefined) { sets.push("instructor_id = ?"); values.push(input.instructorId); }
|
||||
if (input.dayOfWeek !== undefined) { sets.push("day_of_week = ?"); values.push(input.dayOfWeek); }
|
||||
if (input.startTime !== undefined) { sets.push("start_time = ?"); values.push(input.startTime); }
|
||||
if (input.endTime !== undefined) { sets.push("end_time = ?"); values.push(input.endTime); }
|
||||
|
||||
if (sets.length === 0) return existing;
|
||||
|
||||
sets.push("updated_at = ?");
|
||||
values.push(new Date().toISOString());
|
||||
|
||||
values.push(id);
|
||||
execute(`UPDATE schedules SET ${sets.join(", ")} WHERE id = ?`, values);
|
||||
return this.getById(id)!;
|
||||
}
|
||||
|
||||
/** Delete */
|
||||
delete(id: string): boolean {
|
||||
const result = execute("DELETE FROM schedules WHERE id = ?", [id]);
|
||||
return result.changes > 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
// Auto-generated User service
|
||||
import { queryAll, queryOne, execute } from "../db/client.js";
|
||||
import type { User, CreateUserInput, UpdateUserInput } from "../types/index.js";
|
||||
|
||||
export class UserService {
|
||||
/** List all users */
|
||||
list(): User[] {
|
||||
return queryAll<User>("SELECT * FROM users ORDER BY created_at DESC");
|
||||
}
|
||||
|
||||
/** Get by ID */
|
||||
getById(id: string): User | undefined {
|
||||
return queryOne<User>("SELECT * FROM users WHERE id = ?", [id]);
|
||||
}
|
||||
|
||||
/** Create */
|
||||
create(input: CreateUserInput): User {
|
||||
const id = crypto.randomUUID();
|
||||
const now = new Date().toISOString();
|
||||
const hasCreatedAt = true;
|
||||
const hasUpdatedAt = true;
|
||||
const cols = ["id", "phone", "nickname", "avatar_url", "created_at", "updated_at"];
|
||||
const placeholders = cols.map(() => "?").join(", ");
|
||||
const values = [id, input.phone ?? null, input.nickname ?? null, input.avatarUrl ?? null, now, now];
|
||||
|
||||
execute(`INSERT INTO users (${cols.join(", ")}) VALUES (${placeholders})`, values);
|
||||
return this.getById(id)!;
|
||||
}
|
||||
|
||||
/** Update */
|
||||
update(id: string, input: UpdateUserInput): User | undefined {
|
||||
const existing = this.getById(id);
|
||||
if (!existing) return undefined;
|
||||
|
||||
const sets: string[] = [];
|
||||
const values: unknown[] = [];
|
||||
if (input.phone !== undefined) { sets.push("phone = ?"); values.push(input.phone); }
|
||||
if (input.nickname !== undefined) { sets.push("nickname = ?"); values.push(input.nickname); }
|
||||
if (input.avatarUrl !== undefined) { sets.push("avatar_url = ?"); values.push(input.avatarUrl); }
|
||||
|
||||
if (sets.length === 0) return existing;
|
||||
|
||||
sets.push("updated_at = ?");
|
||||
values.push(new Date().toISOString());
|
||||
|
||||
values.push(id);
|
||||
execute(`UPDATE users SET ${sets.join(", ")} WHERE id = ?`, values);
|
||||
return this.getById(id)!;
|
||||
}
|
||||
|
||||
/** Delete */
|
||||
delete(id: string): boolean {
|
||||
const result = execute("DELETE FROM users WHERE id = ?", [id]);
|
||||
return result.changes > 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
// Augment Fastify request with JWT user
|
||||
import type { JwtPayload } from "./index.js";
|
||||
|
||||
declare module "fastify" {
|
||||
interface FastifyRequest {
|
||||
user?: JwtPayload;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
// Import and re-export shared entity types
|
||||
import type {
|
||||
User,
|
||||
Pet,
|
||||
Schedules,
|
||||
Records,
|
||||
Albums,
|
||||
Hospitals,
|
||||
Items,
|
||||
Daily-logs,
|
||||
ApiResponse,
|
||||
PaginatedResponse,
|
||||
ErrorResponse,
|
||||
} from "@shared/types";
|
||||
|
||||
export type {
|
||||
User,
|
||||
Pet,
|
||||
Schedules,
|
||||
Records,
|
||||
Albums,
|
||||
Hospitals,
|
||||
Items,
|
||||
Daily-logs,
|
||||
ApiResponse,
|
||||
PaginatedResponse,
|
||||
ErrorResponse,
|
||||
};
|
||||
|
||||
// Auto-generated types (from Model Contract)
|
||||
|
||||
export interface CreateUserInput {
|
||||
username: string;
|
||||
passwordHash: string;
|
||||
nickname?: string;
|
||||
role?: string;
|
||||
phone?: string;
|
||||
avatarUrl?: string;
|
||||
}
|
||||
|
||||
export interface CreatePetInput {
|
||||
userId: string;
|
||||
name: string;
|
||||
species?: string;
|
||||
breed?: string;
|
||||
birthDate?: string;
|
||||
weightKg?: number;
|
||||
avatarUrl?: string;
|
||||
}
|
||||
|
||||
export interface CreateSchedulesInput {
|
||||
userId?: string;
|
||||
courseId?: string;
|
||||
classroomId?: string;
|
||||
instructorId?: string;
|
||||
dayOfWeek?: number;
|
||||
startTime?: string;
|
||||
endTime?: string;
|
||||
}
|
||||
|
||||
export interface CreateRecordsInput {
|
||||
userId?: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
data?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface CreateAlbumsInput {
|
||||
userId?: string;
|
||||
title?: string;
|
||||
imageUrl: string;
|
||||
caption?: string;
|
||||
takenAt?: string;
|
||||
}
|
||||
|
||||
export interface CreateHospitalsInput {
|
||||
userId?: string;
|
||||
name: string;
|
||||
address?: string;
|
||||
phone?: string;
|
||||
rating?: number;
|
||||
lat?: number;
|
||||
lng?: number;
|
||||
}
|
||||
|
||||
export interface CreateItemsInput {
|
||||
userId?: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
data?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface CreateDaily-logsInput {
|
||||
userId?: string;
|
||||
title: string;
|
||||
data?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface UpdateUserInput {
|
||||
username?: string;
|
||||
passwordHash?: string;
|
||||
nickname?: string;
|
||||
role?: string;
|
||||
phone?: string;
|
||||
avatarUrl?: string;
|
||||
}
|
||||
|
||||
export interface UpdatePetInput {
|
||||
userId?: string;
|
||||
name?: string;
|
||||
species?: string;
|
||||
breed?: string;
|
||||
birthDate?: string;
|
||||
weightKg?: number;
|
||||
avatarUrl?: string;
|
||||
}
|
||||
|
||||
export interface UpdateSchedulesInput {
|
||||
userId?: string;
|
||||
courseId?: string;
|
||||
classroomId?: string;
|
||||
instructorId?: string;
|
||||
dayOfWeek?: number;
|
||||
startTime?: string;
|
||||
endTime?: string;
|
||||
}
|
||||
|
||||
export interface UpdateRecordsInput {
|
||||
userId?: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
data?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface UpdateAlbumsInput {
|
||||
userId?: string;
|
||||
title?: string;
|
||||
imageUrl?: string;
|
||||
caption?: string;
|
||||
takenAt?: string;
|
||||
}
|
||||
|
||||
export interface UpdateHospitalsInput {
|
||||
userId?: string;
|
||||
name?: string;
|
||||
address?: string;
|
||||
phone?: string;
|
||||
rating?: number;
|
||||
lat?: number;
|
||||
lng?: number;
|
||||
}
|
||||
|
||||
export interface UpdateItemsInput {
|
||||
userId?: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
data?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface UpdateDaily-logsInput {
|
||||
userId?: string;
|
||||
title?: string;
|
||||
data?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
// ─── Auth ───────────────────────────────────────────
|
||||
export interface LoginInput {
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface RegisterInput {
|
||||
username: string;
|
||||
password: string;
|
||||
nickname?: string;
|
||||
}
|
||||
|
||||
export interface AuthResponse {
|
||||
token: string;
|
||||
user: User;
|
||||
}
|
||||
|
||||
// ─── API ────────────────────────────────────────────
|
||||
// ─── JWT ────────────────────────────────────────────
|
||||
export interface JwtPayload {
|
||||
userId: string;
|
||||
username: string;
|
||||
role: string;
|
||||
iat?: number;
|
||||
exp?: number;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
// Type declarations for sql.js (no native types package available)
|
||||
declare module "sql.js" {
|
||||
export interface Database {
|
||||
run(sql: string, params?: BindParams): void;
|
||||
exec(sql: string): void;
|
||||
prepare(sql: string): Statement;
|
||||
export(): Uint8Array;
|
||||
close(): void;
|
||||
getRowsModified(): number;
|
||||
}
|
||||
|
||||
export interface Statement {
|
||||
bind(params?: BindParams): boolean;
|
||||
step(): boolean;
|
||||
getAsObject<T = Record<string, unknown>>(): T;
|
||||
getColumnNames(): string[];
|
||||
free(): boolean;
|
||||
}
|
||||
|
||||
export type BindParams = unknown[] | Record<string, unknown>;
|
||||
|
||||
export interface SqlJsStatic {
|
||||
Database: new (data?: ArrayLike<number>) => Database;
|
||||
}
|
||||
|
||||
export default function initSqlJs(config?: Record<string, unknown>): Promise<SqlJsStatic>;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": [
|
||||
"ES2022"
|
||||
],
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"declaration": true,
|
||||
"sourceMap": true
|
||||
},
|
||||
"include": [
|
||||
"src/**/*"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"dist",
|
||||
"src/__tests__"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Card } from "@/components/ui/Card";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { EmptyState } from "@/components/ui/EmptyState";
|
||||
|
||||
const mockPhotos = Array.from({ length: 8 }, (_, i) => ({
|
||||
id: `p${i}`,
|
||||
color: ["#FEE2E2", "#FEF3C7", "#D1FAE5", "#DBEAFE", "#EDE9FE", "#FCE7F3", "#E0E7FF", "#FFEDD5"][i],
|
||||
emoji: ["🐱", "🐶", "🐱", "🐶", "🐱", "🐱", "🐶", "🐱"][i],
|
||||
date: new Date(2026, 5, i + 1).toISOString(),
|
||||
}));
|
||||
|
||||
export default function AlbumPage() {
|
||||
const [photos] = useState(mockPhotos);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-gray-800">成长相册</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">记录宠物的成长瞬间</p>
|
||||
</div>
|
||||
<Button variant="primary" size="sm">+ 上传照片</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{photos.map(photo => (
|
||||
<div key={photo.id} className="aspect-square rounded-xl flex items-center justify-center text-4xl" style={{ backgroundColor: photo.color }}>
|
||||
{photo.emoji}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<p className="text-center text-xs text-gray-400">下拉加载更多照片</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Card } from "@/components/ui/Card";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Input } from "@/components/ui/Input";
|
||||
import { EmptyState } from "@/components/ui/EmptyState";
|
||||
|
||||
const CATEGORIES = [
|
||||
{ key: "food", icon: "🍖", label: "饮食" },
|
||||
{ key: "exercise", icon: "🏃", label: "运动" },
|
||||
{ key: "excretion", icon: "💩", label: "排泄" },
|
||||
{ key: "weight", icon: "⚖️", label: "体重" },
|
||||
];
|
||||
|
||||
interface LogEntry {
|
||||
id: string;
|
||||
category: string;
|
||||
icon: string;
|
||||
value: string;
|
||||
time: string;
|
||||
}
|
||||
|
||||
export default function DailyLogPage() {
|
||||
const [logs, setLogs] = useState<LogEntry[]>([]);
|
||||
const [category, setCategory] = useState("food");
|
||||
const [value, setValue] = useState("");
|
||||
|
||||
const addLog = () => {
|
||||
if (!value.trim()) return;
|
||||
const cat = CATEGORIES.find(c => c.key === category)!;
|
||||
setLogs(prev => [{
|
||||
id: Date.now().toString(),
|
||||
category: cat.key,
|
||||
icon: cat.icon,
|
||||
value,
|
||||
time: new Date().toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit" }),
|
||||
}, ...prev]);
|
||||
setValue("");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-gray-800">日常记录</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">记录宠物的饮食、运动、排泄和体重变化</p>
|
||||
</div>
|
||||
|
||||
{/* Quick Record */}
|
||||
<Card>
|
||||
<div className="space-y-3">
|
||||
<div className="flex gap-2">
|
||||
{CATEGORIES.map(c => (
|
||||
<button key={c.key} onClick={() => setCategory(c.key)}
|
||||
className={`flex-1 py-2 rounded-lg text-sm text-center transition-colors ${category === c.key ? "bg-blue-100 text-blue-700 font-medium" : "bg-gray-50 text-gray-500"}`}>
|
||||
{c.icon} {c.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Input placeholder="输入记录内容..." value={value} onChange={e => setValue(e.target.value)} onKeyDown={e => e.key === "Enter" && addLog()} />
|
||||
<Button onClick={addLog} size="sm">记录</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Log Timeline */}
|
||||
<div className="space-y-2">
|
||||
{logs.length > 0 ? logs.map(log => (
|
||||
<div key={log.id} className="flex items-center gap-3 px-4 py-2 bg-white rounded-lg border border-gray-100">
|
||||
<span className="text-xl">{log.icon}</span>
|
||||
<span className="flex-1 text-sm text-gray-700">{log.value}</span>
|
||||
<span className="text-xs text-gray-400">{log.time}</span>
|
||||
</div>
|
||||
)) : <EmptyState icon="📝" title="暂无记录" description="使用上方表单记录宠物日常" />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
body {
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Card } from "@/components/ui/Card";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Input } from "@/components/ui/Input";
|
||||
|
||||
const mockHospitals = [
|
||||
{ id: "h1", name: "宠颐生动物医院", address: "朝阳区建国路88号", phone: "010-88886666", rating: 4.8, distance: 1.2, services: ["疫苗", "体检", "手术", "美容"] },
|
||||
{ id: "h2", name: "瑞鹏宠物医院", address: "海淀区中关村大街1号", phone: "010-66668888", rating: 4.6, distance: 2.8, services: ["急诊", "住院", "化验"] },
|
||||
{ id: "h3", name: "佳雯宠物诊所", address: "西城区金融街16号", phone: "010-55556666", rating: 4.5, distance: 3.5, services: ["疫苗", "驱虫", "体检"] },
|
||||
];
|
||||
|
||||
export default function HospitalPage() {
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
const filtered = mockHospitals.filter(h =>
|
||||
h.name.includes(search) || h.address.includes(search)
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-gray-800">附近医院</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">查找附近的宠物医院</p>
|
||||
</div>
|
||||
|
||||
<Input placeholder="搜索医院名称或地址..." value={search} onChange={e => setSearch(e.target.value)} />
|
||||
|
||||
<div className="space-y-3">
|
||||
{filtered.map(h => (
|
||||
<Card key={h.id}>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-blue-100 flex items-center justify-center text-blue-500 flex-shrink-0">🏥</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-medium text-gray-800 truncate">{h.name}</h3>
|
||||
<span className="text-xs px-1.5 py-0.5 bg-yellow-100 text-yellow-700 rounded flex-shrink-0">⭐ {h.rating}</span>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-1">{h.address}</p>
|
||||
<p className="text-xs text-gray-400 mt-0.5">{h.distance}km · {h.phone}</p>
|
||||
<div className="flex gap-1 mt-2">
|
||||
{h.services.map(s => <span key={s} className="text-xs px-2 py-0.5 bg-gray-100 text-gray-600 rounded">{s}</span>)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { Metadata } from "next";
|
||||
import "./globals.css";
|
||||
import { Sidebar } from "@/components/layout/Sidebar";
|
||||
import { BottomNav } from "@/components/layout/BottomNav";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "PetCare",
|
||||
description: "一款帮助宠物主人管理宠物健康、日常活动和成长记录的移动应用。",
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="zh-CN">
|
||||
<body className="bg-gray-50 min-h-screen">
|
||||
<div className="flex">
|
||||
{/* Desktop Sidebar */}
|
||||
<Sidebar items={[{"name":"首页","href":"/home","icon":"🏠"},{"name":"宠物档案","href":"/pets","icon":"📋"},{"name":"健康日程","href":"/schedules","icon":"📅"},{"name":"日常记录","href":"/records","icon":"✏️"},{"name":"成长相册","href":"/albums","icon":"📸"}] } projectName="PetCare" />
|
||||
{/* Main Content */}
|
||||
<main className="flex-1 lg:pl-64 pb-20 lg:pb-0">
|
||||
<div className="max-w-2xl mx-auto px-4 py-6">
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
{/* Mobile Bottom Nav */}
|
||||
<BottomNav items={[{"name":"首页","href":"/home","icon":"🏠"},{"name":"宠物档案","href":"/pets","icon":"📋"},{"name":"健康日程","href":"/schedules","icon":"📅"},{"name":"日常记录","href":"/records","icon":"✏️"}] } />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import Link from "next/link";
|
||||
|
||||
export default function HomePage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Hero Section */}
|
||||
<section className="bg-gradient-to-br from-blue-500 to-blue-700 rounded-2xl p-6 text-white">
|
||||
<h1 className="text-2xl font-bold">PetCare</h1>
|
||||
<p className="mt-2 text-blue-100 text-sm">应用首页</p>
|
||||
</section>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<section>
|
||||
<h2 className="text-lg font-semibold text-gray-800 mb-3">快捷入口</h2>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Link key="0" href="/home" className="bg-white rounded-xl border border-gray-100 p-4 text-center hover:shadow-md transition-shadow">
|
||||
<span className="text-2xl block mb-1">📋</span>
|
||||
<span className="text-sm text-gray-600">宠物档案</span>
|
||||
</Link>
|
||||
<Link key="1" href="/home" className="bg-white rounded-xl border border-gray-100 p-4 text-center hover:shadow-md transition-shadow">
|
||||
<span className="text-2xl block mb-1">📅</span>
|
||||
<span className="text-sm text-gray-600">健康日程</span>
|
||||
</Link>
|
||||
<Link key="2" href="/home" className="bg-white rounded-xl border border-gray-100 p-4 text-center hover:shadow-md transition-shadow">
|
||||
<span className="text-2xl block mb-1">📝</span>
|
||||
<span className="text-sm text-gray-600">日常记录</span>
|
||||
</Link>
|
||||
<Link key="3" href="/home" className="bg-white rounded-xl border border-gray-100 p-4 text-center hover:shadow-md transition-shadow">
|
||||
<span className="text-2xl block mb-1">📸</span>
|
||||
<span className="text-sm text-gray-600">成长相册</span>
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Recent Activity / Stats */}
|
||||
<section>
|
||||
<h2 className="text-lg font-semibold text-gray-800 mb-3">今日概览</h2>
|
||||
<div className="bg-white rounded-xl border border-gray-100 p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">欢迎使用 PetCare</p>
|
||||
<p className="text-xs text-gray-400 mt-1">开始探索功能吧</p>
|
||||
</div>
|
||||
<span className="text-3xl">👋</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
import type { Pet, Schedule } from "@/types";
|
||||
import { Card } from "@/components/ui/Card";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { PetCard } from "@/components/pet/PetCard";
|
||||
import { ScheduleCard } from "@/components/pet/ScheduleCard";
|
||||
import { EmptyState } from "@/components/ui/EmptyState";
|
||||
|
||||
export default function PetDetailPage() {
|
||||
const params = useParams();
|
||||
const [pet, setPet] = useState<Pet | null>(null);
|
||||
const [schedules, setSchedules] = useState<Schedule[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
// TODO: Replace with actual API call
|
||||
const loadData = async () => {
|
||||
setLoading(true);
|
||||
// Simulated data
|
||||
setPet({
|
||||
id: params.id as string,
|
||||
ownerId: "u1",
|
||||
name: "毛球",
|
||||
species: "猫",
|
||||
breed: "英短",
|
||||
birthDate: "2023-03-15",
|
||||
weightKg: 4.5,
|
||||
avatarUrl: "",
|
||||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
setSchedules([
|
||||
{ id: "s1", petId: params.id as string, type: "vaccine", title: "年度疫苗", scheduledAt: "2026-07-01T09:00:00Z", completed: false },
|
||||
{ id: "s2", petId: params.id as string, type: "deworming", title: "季度驱虫", scheduledAt: "2026-06-15T10:00:00Z", completed: false },
|
||||
{ id: "s3", petId: params.id as string, type: "checkup", title: "体检", scheduledAt: "2026-05-20T14:00:00Z", completed: true },
|
||||
]);
|
||||
setLoading(false);
|
||||
};
|
||||
loadData();
|
||||
}, [params.id]);
|
||||
|
||||
if (loading) return <div className="flex justify-center py-16"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500" /></div>;
|
||||
if (!pet) return <EmptyState icon="🐾" title="未找到宠物" description="该宠物可能已被删除" />;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Pet Profile */}
|
||||
<div className="bg-gradient-to-br from-amber-100 to-orange-100 rounded-2xl p-6 text-center">
|
||||
<div className="w-24 h-24 mx-auto rounded-full bg-white shadow flex items-center justify-center text-5xl">
|
||||
{pet.avatarUrl ? <img src={pet.avatarUrl} alt={pet.name} className="w-full h-full rounded-full object-cover" /> : "🐱"}
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-gray-800 mt-3">{pet.name}</h1>
|
||||
<p className="text-sm text-gray-500">{pet.breed} · {pet.weightKg}kg · {pet.species}</p>
|
||||
</div>
|
||||
|
||||
{/* Health Schedule */}
|
||||
<section>
|
||||
<h2 className="text-lg font-semibold text-gray-800 mb-3">健康日程</h2>
|
||||
<div className="space-y-2">
|
||||
{schedules.length > 0 ? schedules.map(s => <ScheduleCard key={s.id} schedule={s} />) : <EmptyState icon="📅" title="暂无日程" />}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Quick Info */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Card className="text-center">
|
||||
<p className="text-2xl font-bold text-gray-800">{pet.weightKg}</p>
|
||||
<p className="text-xs text-gray-500">体重 (kg)</p>
|
||||
</Card>
|
||||
<Card className="text-center">
|
||||
<p className="text-2xl font-bold text-gray-800">{new Date().getFullYear() - new Date(pet.birthDate).getFullYear()}岁</p>
|
||||
<p className="text-xs text-gray-500">年龄</p>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
"use client";
|
||||
|
||||
import { Card } from "@/components/ui/Card";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
|
||||
const menuItems = [
|
||||
{ icon: "⚙️", label: "设置", href: "#" },
|
||||
{ icon: "🔔", label: "通知", href: "#" },
|
||||
{ icon: "❓", label: "帮助与反馈", href: "#" },
|
||||
{ icon: "📄", label: "关于我们", href: "#" },
|
||||
];
|
||||
|
||||
export default function ProfilePage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* User Info */}
|
||||
<div className="bg-gradient-to-br from-blue-500 to-blue-700 rounded-2xl p-6 text-white text-center">
|
||||
<div className="w-20 h-20 mx-auto rounded-full bg-white/20 flex items-center justify-center text-3xl backdrop-blur">😊</div>
|
||||
<h1 className="text-xl font-bold mt-3">用户昵称</h1>
|
||||
<p className="text-sm text-blue-100">138****1234</p>
|
||||
<Button variant="ghost" className="mt-3 bg-white/10 text-white hover:bg-white/20" size="sm">编辑资料</Button>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<Card className="text-center"><p className="text-xl font-bold text-gray-800">3</p><p className="text-xs text-gray-500">我的宠物</p></Card>
|
||||
<Card className="text-center"><p className="text-xl font-bold text-gray-800">12</p><p className="text-xs text-gray-500">健康日程</p></Card>
|
||||
<Card className="text-center"><p className="text-xl font-bold text-gray-800">56</p><p className="text-xs text-gray-500">日常记录</p></Card>
|
||||
</div>
|
||||
|
||||
{/* Menu */}
|
||||
<Card className="divide-y divide-gray-50 p-0">
|
||||
{menuItems.map((item, i) => (
|
||||
<a key={i} href={item.href} className="flex items-center gap-3 px-4 py-3 hover:bg-gray-50 transition-colors">
|
||||
<span className="text-lg">{item.icon}</span>
|
||||
<span className="flex-1 text-sm text-gray-700">{item.label}</span>
|
||||
<svg className="w-4 h-4 text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" /></svg>
|
||||
</a>
|
||||
))}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import type { Schedule } from "@/types";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { ScheduleCard } from "@/components/pet/ScheduleCard";
|
||||
import { Modal } from "@/components/ui/Modal";
|
||||
import { Input } from "@/components/ui/Input";
|
||||
import { EmptyState } from "@/components/ui/EmptyState";
|
||||
|
||||
const mockSchedules: Schedule[] = [
|
||||
{ id: "s1", petId: "p1", type: "vaccine", title: "年度疫苗加强针", scheduledAt: "2026-07-01T09:00:00Z", completed: false },
|
||||
{ id: "s2", petId: "p1", type: "deworming", title: "季度驱虫", scheduledAt: "2026-06-15T10:00:00Z", completed: false },
|
||||
{ id: "s3", petId: "p1", type: "checkup", title: "半年度体检", scheduledAt: "2026-05-20T14:00:00Z", completed: true },
|
||||
{ id: "s4", petId: "p1", type: "grooming", title: "美容护理", scheduledAt: "2026-06-10T11:00:00Z", completed: false },
|
||||
];
|
||||
|
||||
export default function SchedulePage() {
|
||||
const [schedules, setSchedules] = useState<Schedule[]>(mockSchedules);
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
|
||||
const toggleComplete = (id: string) => {
|
||||
setSchedules(prev => prev.map(s => s.id === id ? { ...s, completed: !s.completed } : s));
|
||||
};
|
||||
|
||||
const pending = schedules.filter(s => !s.completed);
|
||||
const done = schedules.filter(s => s.completed);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-gray-800">健康日程</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">管理宠物的疫苗、驱虫、体检安排</p>
|
||||
</div>
|
||||
<Button onClick={() => setShowModal(true)} variant="primary" size="sm">+ 添加日程</Button>
|
||||
</div>
|
||||
|
||||
{/* Pending */}
|
||||
<section>
|
||||
<h2 className="text-sm font-semibold text-gray-500 uppercase mb-2">待完成 ({pending.length})</h2>
|
||||
<div className="space-y-2">
|
||||
{pending.length > 0 ? pending.map(s => <ScheduleCard key={s.id} schedule={s} onToggle={() => toggleComplete(s.id)} />) : <EmptyState icon="✅" title="全部完成" />}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Completed */}
|
||||
{done.length > 0 && (
|
||||
<section>
|
||||
<h2 className="text-sm font-semibold text-gray-500 uppercase mb-2">已完成 ({done.length})</h2>
|
||||
<div className="space-y-2">
|
||||
{done.map(s => <ScheduleCard key={s.id} schedule={s} />)}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<Modal open={showModal} onClose={() => setShowModal(false)} title="添加日程">
|
||||
<div className="space-y-3">
|
||||
<Input label="标题" placeholder="如:年度疫苗" />
|
||||
<Input label="日期" type="date" />
|
||||
<div className="flex gap-2 justify-end">
|
||||
<Button variant="secondary" onClick={() => setShowModal(false)}>取消</Button>
|
||||
<Button variant="primary" onClick={() => setShowModal(false)}>保存</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
interface NavItem {
|
||||
name: string;
|
||||
href: string;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
export function BottomNav({ items }: { items: NavItem[] }) {
|
||||
const pathname = usePathname();
|
||||
|
||||
if (!items.length) return null;
|
||||
|
||||
return (
|
||||
<nav className="lg:hidden fixed bottom-0 inset-x-0 bg-white border-t border-gray-100 z-40">
|
||||
<div className="flex items-center justify-around h-16">
|
||||
{items.map((item) => {
|
||||
const active = pathname === item.href;
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={`flex flex-col items-center gap-0.5 px-3 py-1 ${
|
||||
active ? "text-blue-600" : "text-gray-400"
|
||||
}`}
|
||||
>
|
||||
<span className="text-xl">{item.icon}</span>
|
||||
<span className="text-[10px]">{item.name}</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
interface NavItem {
|
||||
name: string;
|
||||
href: string;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
interface SidebarProps {
|
||||
items: NavItem[];
|
||||
projectName: string;
|
||||
}
|
||||
|
||||
export function Sidebar({ items, projectName }: SidebarProps) {
|
||||
const pathname = usePathname();
|
||||
|
||||
return (
|
||||
<aside className="hidden lg:flex lg:flex-col lg:w-64 lg:fixed lg:inset-y-0 bg-white border-r border-gray-100">
|
||||
<div className="px-6 py-5 border-b border-gray-50">
|
||||
<h1 className="text-lg font-bold text-gray-800">{projectName}</h1>
|
||||
</div>
|
||||
<nav className="flex-1 px-3 py-4 space-y-1 overflow-y-auto">
|
||||
{items.map((item) => {
|
||||
const active = pathname === item.href;
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={`flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm transition-colors ${
|
||||
active ? "bg-blue-50 text-blue-700 font-medium" : "text-gray-600 hover:bg-gray-50"
|
||||
}`}
|
||||
>
|
||||
<span className="text-lg">{item.icon}</span>
|
||||
<span>{item.name}</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { Pet } from "@/types";
|
||||
import { Card } from "@/components/ui/Card";
|
||||
|
||||
interface PetCardProps {
|
||||
pet: Pet;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
export function PetCard({ pet, onClick }: PetCardProps) {
|
||||
return (
|
||||
<Card onClick={onClick} className="flex items-center gap-4">
|
||||
<div className="w-16 h-16 rounded-full bg-gradient-to-br from-amber-100 to-orange-200 flex items-center justify-center text-2xl overflow-hidden flex-shrink-0">
|
||||
{pet.avatarUrl ? (
|
||||
<img src={pet.avatarUrl} alt={pet.name} className="w-full h-full object-cover" />
|
||||
) : (
|
||||
"🐾"
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-semibold text-gray-800 truncate">{pet.name}</h3>
|
||||
<p className="text-sm text-gray-500">{pet.breed || pet.species} · {pet.weightKg}kg</p>
|
||||
</div>
|
||||
<svg className="w-5 h-5 text-gray-300 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { Schedule } from "@/types";
|
||||
|
||||
interface ScheduleCardProps {
|
||||
schedule: Schedule;
|
||||
onToggle?: () => void;
|
||||
}
|
||||
|
||||
export function ScheduleCard({ schedule, onToggle }: ScheduleCardProps) {
|
||||
const typeIcons: Record<string, string> = {
|
||||
vaccine: "💉",
|
||||
deworming: "💊",
|
||||
checkup: "🩺",
|
||||
grooming: "✂️",
|
||||
};
|
||||
|
||||
const date = new Date(schedule.scheduledAt);
|
||||
const isOverdue = date < new Date() && !schedule.completed;
|
||||
|
||||
return (
|
||||
<div className={`flex items-center gap-3 p-3 rounded-lg border ${schedule.completed ? "bg-gray-50 border-gray-100" : isOverdue ? "bg-red-50 border-red-200" : "bg-white border-gray-100"}`}>
|
||||
<span className="text-2xl">{typeIcons[schedule.type] || "📅"}</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className={`text-sm font-medium ${schedule.completed ? "text-gray-400 line-through" : "text-gray-800"}`}>{schedule.title}</p>
|
||||
<p className="text-xs text-gray-500">{date.toLocaleDateString("zh-CN", { month: "short", day: "numeric" })}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={onToggle}
|
||||
className={`w-6 h-6 rounded-full border-2 flex items-center justify-center flex-shrink-0 ${schedule.completed ? "bg-green-500 border-green-500" : "border-gray-300"}`}
|
||||
>
|
||||
{schedule.completed && <svg className="w-4 h-4 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" /></svg>}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
"use client";
|
||||
|
||||
import { type ButtonHTMLAttributes } from "react";
|
||||
|
||||
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: "primary" | "secondary" | "danger" | "ghost";
|
||||
size?: "sm" | "md" | "lg";
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
export function Button({ variant = "primary", size = "md", loading, children, className = "", disabled, ...props }: ButtonProps) {
|
||||
const base = "inline-flex items-center justify-center font-medium rounded-lg transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed";
|
||||
const variants = {
|
||||
primary: "bg-blue-600 text-white hover:bg-blue-700 focus:ring-blue-500",
|
||||
secondary: "bg-gray-100 text-gray-700 hover:bg-gray-200 focus:ring-gray-400",
|
||||
danger: "bg-red-600 text-white hover:bg-red-700 focus:ring-red-500",
|
||||
ghost: "text-gray-600 hover:bg-gray-100 focus:ring-gray-400",
|
||||
};
|
||||
const sizes = {
|
||||
sm: "px-3 py-1.5 text-sm",
|
||||
md: "px-4 py-2 text-sm",
|
||||
lg: "px-6 py-3 text-base",
|
||||
};
|
||||
|
||||
return (
|
||||
<button className={`${base} ${variants[variant]} ${sizes[size]} ${className}`} disabled={disabled || loading} {...props}>
|
||||
{loading && <svg className="animate-spin -ml-1 mr-2 h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" /><path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" /></svg>}
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
interface CardProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
export function Card({ children, className = "", onClick }: CardProps) {
|
||||
return (
|
||||
<div
|
||||
className={`bg-white rounded-xl shadow-sm border border-gray-100 p-4 ${onClick ? "cursor-pointer hover:shadow-md transition-shadow" : ""} ${className}`}
|
||||
onClick={onClick}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
interface EmptyStateProps {
|
||||
icon?: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
action?: ReactNode;
|
||||
}
|
||||
|
||||
export function EmptyState({ icon = "📭", title, description, action }: EmptyStateProps) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-center">
|
||||
<span className="text-5xl mb-4">{icon}</span>
|
||||
<h3 className="text-lg font-medium text-gray-700 mb-1">{title}</h3>
|
||||
{description && <p className="text-sm text-gray-500 mb-4">{description}</p>}
|
||||
{action}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
"use client";
|
||||
|
||||
import { type InputHTMLAttributes } from "react";
|
||||
|
||||
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
|
||||
label?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export function Input({ label, error, className = "", id, ...props }: InputProps) {
|
||||
return (
|
||||
<div className="w-full">
|
||||
{label && <label htmlFor={id} className="block text-sm font-medium text-gray-700 mb-1">{label}</label>}
|
||||
<input
|
||||
id={id}
|
||||
className={`w-full px-3 py-2 border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent ${error ? "border-red-300" : "border-gray-300"} ${className}`}
|
||||
{...props}
|
||||
/>
|
||||
{error && <p className="mt-1 text-xs text-red-500">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, type ReactNode } from "react";
|
||||
|
||||
interface ModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
title: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function Modal({ open, onClose, title, children }: ModalProps) {
|
||||
useEffect(() => {
|
||||
if (open) document.body.style.overflow = "hidden";
|
||||
else document.body.style.overflow = "";
|
||||
return () => { document.body.style.overflow = ""; };
|
||||
}, [open]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div className="absolute inset-0 bg-black/40" onClick={onClose} />
|
||||
<div className="relative bg-white rounded-2xl shadow-xl max-w-lg w-full mx-4 p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold">{title}</h2>
|
||||
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 text-xl leading-none">×</button>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
|
||||
// Generic data type for albums
|
||||
type Album = Record<string, unknown>;
|
||||
|
||||
/**
|
||||
* Fetch and manage albums data.
|
||||
*/
|
||||
export function useAlbums() {
|
||||
const [data, setData] = useState<Album[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const res = await fetch(`/api/albums`);
|
||||
const json = await res.json();
|
||||
setData(Array.isArray(json) ? json : json?.data || []);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Failed to load data");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
return { data, loading, error, refetch: fetchData };
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
|
||||
// Generic data type for daily-logs
|
||||
type Dailylog = Record<string, unknown>;
|
||||
|
||||
/**
|
||||
* Fetch and manage daily-logs data.
|
||||
*/
|
||||
export function useDailylogs() {
|
||||
const [data, setData] = useState<Dailylog[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const res = await fetch(`/api/daily-logs`);
|
||||
const json = await res.json();
|
||||
setData(Array.isArray(json) ? json : json?.data || []);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Failed to load data");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
return { data, loading, error, refetch: fetchData };
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
export function useDebounce<T>(value: T, delay: number = 300): T {
|
||||
const [debounced, setDebounced] = useState(value);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setDebounced(value), delay);
|
||||
return () => clearTimeout(timer);
|
||||
}, [value, delay]);
|
||||
|
||||
return debounced;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback } from "react";
|
||||
|
||||
interface UseFormOptions<T> {
|
||||
initialValues: T;
|
||||
onSubmit: (values: T) => Promise<void>;
|
||||
}
|
||||
|
||||
export function useForm<T extends Record<string, unknown>>({ initialValues, onSubmit }: UseFormOptions<T>) {
|
||||
const [values, setValues] = useState<T>(initialValues);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const setField = useCallback(<K extends keyof T>(field: K, value: T[K]) => {
|
||||
setValues(prev => ({ ...prev, [field]: value }));
|
||||
}, []);
|
||||
|
||||
const handleSubmit = useCallback(async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
await onSubmit(values);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Submit failed");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}, [values, onSubmit]);
|
||||
|
||||
return { values, setField, submitting, error, handleSubmit };
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
|
||||
// Generic data type for hospitals
|
||||
type Hospital = Record<string, unknown>;
|
||||
|
||||
/**
|
||||
* Fetch and manage hospitals data.
|
||||
*/
|
||||
export function useHospitals() {
|
||||
const [data, setData] = useState<Hospital[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const res = await fetch(`/api/hospitals`);
|
||||
const json = await res.json();
|
||||
setData(Array.isArray(json) ? json : json?.data || []);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Failed to load data");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
return { data, loading, error, refetch: fetchData };
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
|
||||
// Generic data type for pets
|
||||
type Pet = Record<string, unknown>;
|
||||
|
||||
/**
|
||||
* Fetch and manage pets data.
|
||||
*/
|
||||
export function usePets() {
|
||||
const [data, setData] = useState<Pet[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const res = await fetch(`/api/pets`);
|
||||
const json = await res.json();
|
||||
setData(Array.isArray(json) ? json : json?.data || []);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Failed to load data");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
return { data, loading, error, refetch: fetchData };
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
|
||||
// Generic data type for schedules
|
||||
type Schedule = Record<string, unknown>;
|
||||
|
||||
/**
|
||||
* Fetch and manage schedules data.
|
||||
*/
|
||||
export function useSchedules() {
|
||||
const [data, setData] = useState<Schedule[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const res = await fetch(`/api/schedules`);
|
||||
const json = await res.json();
|
||||
setData(Array.isArray(json) ? json : json?.data || []);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Failed to load data");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
return { data, loading, error, refetch: fetchData };
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
/* config options here */
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "petcare-web",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"type-check": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"next": "^15.0.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"@shared/types": "*",
|
||||
"@shared/config": "*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/react": "^19.0.0",
|
||||
"typescript": "^5.6.0",
|
||||
"tailwindcss": "^3.4.0",
|
||||
"postcss": "^8.4.0",
|
||||
"autoprefixer": "^10.4.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
// Auto-generated albums service
|
||||
import { api } from "./api";
|
||||
|
||||
export function post(data: Record<string, unknown>) {
|
||||
return api.post("/api/albums", data);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
// Auto-generated API client for PetCare
|
||||
|
||||
import { API_BASE_URL, API_PREFIX } from "@shared/config";
|
||||
|
||||
const API_BASE = `${API_BASE_URL}${API_PREFIX}`;
|
||||
|
||||
interface RequestOptions extends RequestInit {
|
||||
params?: Record<string, string | number>;
|
||||
}
|
||||
|
||||
async function request<T>(endpoint: string, options: RequestOptions = {}): Promise<T> {
|
||||
const { params, ...init } = options;
|
||||
let url = `${API_BASE}${endpoint}`;
|
||||
|
||||
if (params) {
|
||||
const searchParams = new URLSearchParams();
|
||||
Object.entries(params).forEach(([k, v]) => searchParams.set(k, String(v)));
|
||||
url += `?${searchParams.toString()}`;
|
||||
}
|
||||
|
||||
const res = await fetch(url, {
|
||||
...init,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...init.headers,
|
||||
},
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ message: res.statusText }));
|
||||
throw new Error(err.message || `API Error: ${res.status}`);
|
||||
}
|
||||
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export const api = {
|
||||
get: <T>(url: string, params?: Record<string, string | number>) =>
|
||||
request<T>(url, { method: "GET", params }),
|
||||
|
||||
post: <T>(url: string, data?: unknown) =>
|
||||
request<T>(url, { method: "POST", body: JSON.stringify(data) }),
|
||||
|
||||
put: <T>(url: string, data?: unknown) =>
|
||||
request<T>(url, { method: "PUT", body: JSON.stringify(data) }),
|
||||
|
||||
delete: <T>(url: string) =>
|
||||
request<T>(url, { method: "DELETE" }),
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
// Auto-generated daily-logs service
|
||||
import { api } from "./api";
|
||||
|
||||
export function post(data: Record<string, unknown>) {
|
||||
return api.post("/api/daily-logs", data);
|
||||
}
|
||||
|
||||
export function get() {
|
||||
return api.get("/api/daily-logs");
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
// Auto-generated hospitals service
|
||||
import { api } from "./api";
|
||||
|
||||
export function get() {
|
||||
return api.get("/api/hospitals");
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
// Auto-generated pets service
|
||||
import { api } from "./api";
|
||||
|
||||
export function post(data: Record<string, unknown>) {
|
||||
return api.post("/api/pets", data);
|
||||
}
|
||||
|
||||
export function get_id(id: string) {
|
||||
return api.get(`/api/pets/${id}`);
|
||||
}
|
||||
|
||||
export function put_id(id: string, data: Record<string, unknown>) {
|
||||
return api.put(`/api/pets/${id}`, data);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
// Auto-generated schedules service
|
||||
import { api } from "./api";
|
||||
|
||||
export function get() {
|
||||
return api.get("/api/schedules");
|
||||
}
|
||||
|
||||
export function post(data: Record<string, unknown>) {
|
||||
return api.post("/api/schedules", data);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { Config } from "tailwindcss";
|
||||
|
||||
const config: Config = {
|
||||
content: ["./app/**/*.{js,ts,jsx,tsx,mdx}", "./components/**/*.{js,ts,jsx,tsx,mdx}", "./hooks/**/*.{js,ts,jsx,tsx,mdx}", "./services/**/*.{js,ts,jsx,tsx,mdx}"],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
export default config;
|
||||
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2017",
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"esnext"
|
||||
],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
// Auto-generated types for PetCare
|
||||
|
||||
// ─── Base ───────────────────────────────────────────
|
||||
|
||||
|
||||
// ─── Base ───────────────────────────────────────────
|
||||
export interface User {
|
||||
id: string;
|
||||
phone: string;
|
||||
nickname: string;
|
||||
avatarUrl: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
export interface Pet {
|
||||
id?: string;
|
||||
userId: string;
|
||||
name: string;
|
||||
species?: string;
|
||||
breed?: string;
|
||||
birthDate?: string;
|
||||
weightKg?: number;
|
||||
avatarUrl?: string;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface Schedules {
|
||||
id: string;
|
||||
userId?: string;
|
||||
courseId?: string;
|
||||
classroomId?: string;
|
||||
instructorId?: string;
|
||||
dayOfWeek?: number;
|
||||
startTime?: string;
|
||||
endTime?: string;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface Records {
|
||||
id: string;
|
||||
userId?: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
status?: string;
|
||||
data?: Record<string, unknown>;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface Albums {
|
||||
id: string;
|
||||
userId?: string;
|
||||
title?: string;
|
||||
imageUrl: string;
|
||||
caption?: string;
|
||||
takenAt?: string;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface Hospitals {
|
||||
id: string;
|
||||
userId?: string;
|
||||
name: string;
|
||||
address?: string;
|
||||
phone?: string;
|
||||
rating?: number;
|
||||
lat?: number;
|
||||
lng?: number;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface Items {
|
||||
id: string;
|
||||
userId?: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
status?: string;
|
||||
data?: Record<string, unknown>;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface Daily-logs {
|
||||
id: string;
|
||||
userId?: string;
|
||||
title: string;
|
||||
data?: Record<string, unknown>;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
|
||||
// ─── API Responses ──────────────────────────────────
|
||||
export interface ApiResponse<T> {
|
||||
data: T;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface PaginatedResponse<T> extends ApiResponse<T[]> {
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
export interface ApiError {
|
||||
code: string;
|
||||
message: string;
|
||||
details?: Record<string, string[]>;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "petcare",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"workspaces": [
|
||||
"apps/*",
|
||||
"packages/*"
|
||||
],
|
||||
"scripts": {
|
||||
"dev": "node scripts/dev.mjs",
|
||||
"build": "node scripts/build.mjs",
|
||||
"dev:web": "npm run dev -w apps/web",
|
||||
"dev:api": "npm run dev -w apps/api",
|
||||
"build:web": "npm run build -w apps/web",
|
||||
"build:api": "npm run build -w apps/api",
|
||||
"test": "npm run test -w apps/api",
|
||||
"lint": "npm run lint -w apps/web"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"name": "@shared/config",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts"
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
// Shared configuration for fullstack project
|
||||
|
||||
/** API base URL — reads from env or defaults */
|
||||
export const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:3001";
|
||||
|
||||
/** API prefix for all endpoints */
|
||||
export const API_PREFIX = "/api";
|
||||
|
||||
/** Full API URL */
|
||||
export const API_URL = `${API_BASE_URL}${API_PREFIX}`;
|
||||
|
||||
/** Auth token storage key */
|
||||
export const AUTH_TOKEN_KEY = "auth_token";
|
||||
|
||||
/** Default page size for paginated endpoints */
|
||||
export const DEFAULT_PAGE_SIZE = 20;
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"declaration": true,
|
||||
"outDir": "./dist"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"name": "@shared/types",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts"
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
// Shared types for fullstack project
|
||||
// Auto-generated — used by both apps/web and apps/api
|
||||
|
||||
// ─── Entities ────────────────────────────────────────
|
||||
export interface User {
|
||||
id?: string;
|
||||
username: string;
|
||||
passwordHash: string;
|
||||
nickname?: string;
|
||||
role?: string;
|
||||
phone?: string;
|
||||
avatarUrl?: string;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface Pet {
|
||||
id?: string;
|
||||
userId: string;
|
||||
name: string;
|
||||
species?: string;
|
||||
breed?: string;
|
||||
birthDate?: string;
|
||||
weightKg?: number;
|
||||
avatarUrl?: string;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface Schedules {
|
||||
id: string;
|
||||
userId?: string;
|
||||
courseId?: string;
|
||||
classroomId?: string;
|
||||
instructorId?: string;
|
||||
dayOfWeek?: number;
|
||||
startTime?: string;
|
||||
endTime?: string;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface Records {
|
||||
id: string;
|
||||
userId?: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
status?: string;
|
||||
data?: Record<string, unknown>;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface Albums {
|
||||
id: string;
|
||||
userId?: string;
|
||||
title?: string;
|
||||
imageUrl: string;
|
||||
caption?: string;
|
||||
takenAt?: string;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface Hospitals {
|
||||
id: string;
|
||||
userId?: string;
|
||||
name: string;
|
||||
address?: string;
|
||||
phone?: string;
|
||||
rating?: number;
|
||||
lat?: number;
|
||||
lng?: number;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface Items {
|
||||
id: string;
|
||||
userId?: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
status?: string;
|
||||
data?: Record<string, unknown>;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface Daily-logs {
|
||||
id: string;
|
||||
userId?: string;
|
||||
title: string;
|
||||
data?: Record<string, unknown>;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
// ─── API Response Wrappers ───────────────────────────
|
||||
export interface ApiResponse<T> {
|
||||
data: T;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface PaginatedResponse<T> {
|
||||
data: T[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
export interface ErrorResponse {
|
||||
error: string;
|
||||
message: string;
|
||||
statusCode: number;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"declaration": true,
|
||||
"outDir": "./dist"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Build script — builds both web and api.
|
||||
* Usage: node scripts/build.mjs
|
||||
*/
|
||||
|
||||
import { execSync } from "node:child_process";
|
||||
|
||||
const ROOT = new URL("..", import.meta.url).pathname;
|
||||
|
||||
function run(cmd, cwd) {
|
||||
console.log(`\n🔨 ${cmd} (in ${cwd})`);
|
||||
execSync(cmd, { cwd, stdio: "inherit" });
|
||||
}
|
||||
|
||||
console.log("🏗️ Building fullstack project...\n");
|
||||
|
||||
try {
|
||||
run("npm run build", `${ROOT}/apps/api`);
|
||||
run("npm run build", `${ROOT}/apps/web`);
|
||||
console.log("\n✅ Build complete!");
|
||||
} catch (e) {
|
||||
console.error("\n❌ Build failed:", e.message);
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Dev script — starts both web and api in parallel.
|
||||
* Usage: node scripts/dev.mjs
|
||||
*/
|
||||
|
||||
import { spawn } from "node:child_process";
|
||||
|
||||
function start(name, command, args, cwd) {
|
||||
const child = spawn(command, args, {
|
||||
cwd,
|
||||
stdio: "inherit",
|
||||
shell: true,
|
||||
env: { ...process.env, FORCE_COLOR: "1" },
|
||||
});
|
||||
child.on("error", (err) => console.error(`[${name}] Failed: ${err.message}`));
|
||||
child.on("exit", (code) => {
|
||||
if (code !== 0 && code !== null) console.error(`[${name}] Exited with code ${code}`);
|
||||
});
|
||||
return child;
|
||||
}
|
||||
|
||||
const ROOT = new URL("..", import.meta.url).pathname;
|
||||
|
||||
console.log("🚀 Starting fullstack dev servers...\n");
|
||||
|
||||
const api = start("api", "npm", ["run", "dev"], `${ROOT}/apps/api`);
|
||||
const web = start("web", "npm", ["run", "dev"], `${ROOT}/apps/web`);
|
||||
|
||||
process.on("SIGINT", () => { api.kill(); web.kill(); process.exit(0); });
|
||||
process.on("SIGTERM", () => { api.kill(); web.kill(); process.exit(0); });
|
||||
Reference in New Issue
Block a user