Compare commits

..

2 commits

Author SHA1 Message Date
4557728932
Adds Docker setup and basic API endpoints
WHAT: Introduces Dockerfiles for development and production, Docker Compose configurations, a Makefile for common tasks, shell scripts for environment setup/teardown, a basic Express API with SQLite integration.
WHY: Enables easy setup and deployment of the application using Docker. Provides basic API endpoints for managing purchase items.
HOW:
- Creates `docker-compose.dev.yml` and `docker-compose.prod.yml` to define services and volumes.
- Introduces `Dockerfile.dev` and `Dockerfile.prod` to build container images with necessary dependencies.
- Adds `Makefile` with commands for building, running, and managing the application.
- Implements shell scripts for simplified Docker environment management.
- Sets up Express API with endpoints for CRUD operations on purchase items, using SQLite as the database.
- Uses `better-sqlite3` to connect and interact with the SQLite database.
2025-08-21 14:46:11 +08:00
3b638a9509
feat: Marks script as executable
Makes the `start.sh` script executable by changing its permissions.

This ensures that the script can be run directly without needing to
explicitly call `sh start.sh`.
2025-08-21 11:31:32 +08:00
21 changed files with 1955 additions and 20 deletions

View file

@ -17,6 +17,8 @@ NODE_MODULES = node_modules
# 包管理器自动检测(优先 pnpm否者回退 npm
PKG = $(shell command -v pnpm >/dev/null 2>&1 && echo pnpm || echo npm)
# Docker平台由 scripts/dev-up.sh 自动探测,可通过环境变量 DOCKER_PLATFORM 覆盖)
# 默认目标
.PHONY: help
help: ## 显示帮助信息
@ -146,5 +148,31 @@ quick-build: lint build ## 快速构建(检查代码并构建)
test-flow: clean-install lint build preview ## 完整测试流程
@echo "$(GREEN)完整测试流程完成!$(RESET)"
# ----- Docker 开发/生产 -----
.PHONY: dev-up
dev-up: ## 启动开发环境 (自动探测平台;可通过 DOCKER_PLATFORM=linux/arm64 覆盖)
@echo "$(GREEN)启动开发环境 (Docker)$(RESET)"
bash scripts/dev-up.sh
.PHONY: prod-build
prod-build: build ## 构建生产镜像 (前端在宿主机构建)
@echo "$(GREEN)构建生产镜像 (linux/amd64)$(RESET)"
bash scripts/prod-build.sh
.PHONY: prod-up
prod-up: ## 启动生产环境 (使用生产镜像)
@echo "$(GREEN)启动生产环境 (linux/amd64)$(RESET)"
bash scripts/prod-up.sh
.PHONY: dev-down
dev-down: ## 停止开发环境 (保留数据卷)
@echo "$(YELLOW)停止开发环境 (Docker)$(RESET)"
bash scripts/dev-down.sh
.PHONY: prod-down
prod-down: ## 停止生产环境 (保留数据卷)
@echo "$(YELLOW)停止生产环境 (Docker)$(RESET)"
bash scripts/prod-down.sh
# 设置默认目标
.DEFAULT_GOAL := help

21
docker/Dockerfile.dev Normal file
View file

@ -0,0 +1,21 @@
FROM node:20-bullseye-slim
ENV npm_config_registry=https://registry.npmmirror.com
ENV npm_config_build_from_source=true
# Build prerequisites for native modules (better-sqlite3)
RUN apt-get update \
&& apt-get install -y --no-install-recommends python3 make g++ \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
# Use host-built frontend; only run server here
COPY server/package.json server/tsconfig.json ./server/
COPY server/src ./server/src
RUN cd server && npm install --build-from-source && npm run build
EXPOSE 8080
CMD ["node", "server/dist/index.js"]

26
docker/Dockerfile.prod Normal file
View file

@ -0,0 +1,26 @@
FROM node:20-bullseye-slim
ENV npm_config_registry=https://registry.npmmirror.com
ENV npm_config_build_from_source=true
# Build prerequisites for native modules (better-sqlite3)
RUN apt-get update \
&& apt-get install -y --no-install-recommends python3 make g++ \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
# Install server deps in container (native modules match linux/amd64)
COPY server/package.json ./server/package.json
RUN cd server && npm install --omit=dev --build-from-source
# Copy server runtime and prebuilt frontend from host
COPY server/dist ./server/dist
COPY dist ./frontend
ENV PORT=8080
ENV DB_PATH=/data/purchase.db
EXPOSE 8080
CMD ["node", "server/dist/index.js"]

View file

@ -0,0 +1,20 @@
services:
api:
build:
context: ..
dockerfile: docker/Dockerfile.dev
image: dm-purchase-api:dev
platform: ${DOCKER_PLATFORM:-linux/amd64}
ports:
- "8080:8080"
environment:
- DB_PATH=/data/purchase.db
volumes:
- dm_db:/data
- ../dist:/app/frontend:ro
restart: unless-stopped
volumes:
dm_db:

View file

@ -0,0 +1,19 @@
services:
api:
build:
context: ..
dockerfile: docker/Dockerfile.prod
image: dm-purchase-api:prod
platform: linux/amd64
ports:
- "8080:8080"
environment:
- DB_PATH=/data/purchase.db
volumes:
- dm_db:/data
restart: unless-stopped
volumes:
dm_db:

14
scripts/dev-down.sh Normal file
View file

@ -0,0 +1,14 @@
#!/usr/bin/env bash
set -euo pipefail
echo "[Dream-Machine] 停止开发环境"
SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
ROOT_DIR=$(cd "$SCRIPT_DIR/.." && pwd)
echo "[步骤] 停止 Docker Compose (开发) (保留数据卷)"
(cd "$ROOT_DIR/docker" && docker compose -f docker-compose.dev.yml down)
echo "[完成] 开发环境已停止。数据库卷 dm_db 已保留。"

42
scripts/dev-up.sh Normal file
View file

@ -0,0 +1,42 @@
#!/usr/bin/env bash
set -euo pipefail
echo "[Dream-Machine] 开发环境启动"
# 自动探测宿主机平台,允许环境变量覆盖
HOST_OS=$(uname -s 2>/dev/null || echo unknown)
HOST_ARCH=$(uname -m 2>/dev/null || echo unknown)
# 如果用户误传了 darwin/arm64统一转换为 linux/arm64Docker 只支持 Linux 基础镜像)
if [ "${DOCKER_PLATFORM:-}" = "darwin/arm64" ] || [ "${DOCKER_PLATFORM:-}" = "Darwin/arm64" ]; then
DOCKER_PLATFORM=linux/arm64
fi
if [ -z "${DOCKER_PLATFORM:-}" ]; then
case "${HOST_OS}-${HOST_ARCH}" in
Darwin-arm64) DOCKER_PLATFORM=linux/arm64 ;;
Darwin-x86_64) DOCKER_PLATFORM=linux/amd64 ;;
Linux-x86_64) DOCKER_PLATFORM=linux/amd64 ;;
Linux-aarch64) DOCKER_PLATFORM=linux/arm64 ;;
*) DOCKER_PLATFORM=linux/amd64 ;;
esac
fi
echo "- 宿主机: ${HOST_OS}/${HOST_ARCH}"
echo "- 容器镜像平台: ${DOCKER_PLATFORM} (Docker 仅支持 linux/*;可通过 DOCKER_PLATFORM 覆盖)"
echo "- 将使用外部构建的前端 (dist/)"
SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
ROOT_DIR=$(cd "$SCRIPT_DIR/.." && pwd)
if [ ! -d "$ROOT_DIR/dist" ]; then
echo "[提示] 未检测到 dist/,将进行前端构建 (pnpm build)"
(cd "$ROOT_DIR" && pnpm run build)
fi
echo "[步骤] 启动 Docker Compose (开发)"
(cd "$ROOT_DIR/docker" && DOCKER_PLATFORM=${DOCKER_PLATFORM} docker compose -f docker-compose.dev.yml up -d --build)
echo "[完成] API: http://localhost:8080"

24
scripts/prod-build.sh Normal file
View file

@ -0,0 +1,24 @@
#!/usr/bin/env bash
set -euo pipefail
echo "[Dream-Machine] 生产镜像构建"
echo "- 环境要求: 前端包 dist/ 已在宿主机构建完成"
echo "- 目标平台: linux/amd64"
SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
ROOT_DIR=$(cd "$SCRIPT_DIR/.." && pwd)
if [ ! -d "$ROOT_DIR/dist" ]; then
echo "[错误] 未检测到 dist/。请先在宿主机运行: pnpm run build"
exit 1
fi
echo "[步骤] 安装并构建后端 (宿主机)"
(cd "$ROOT_DIR/server" && pnpm install && pnpm run build)
echo "[步骤] 构建生产镜像 (仅打包、禁止在容器内构建)"
(cd "$ROOT_DIR/docker" && docker buildx build --platform linux/amd64 -f Dockerfile.prod -t dm-purchase-api:prod ..)
echo "[完成] 镜像: dm-purchase-api:prod"

14
scripts/prod-down.sh Normal file
View file

@ -0,0 +1,14 @@
#!/usr/bin/env bash
set -euo pipefail
echo "[Dream-Machine] 停止生产环境"
SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
ROOT_DIR=$(cd "$SCRIPT_DIR/.." && pwd)
echo "[步骤] 停止 Docker Compose (生产) (保留数据卷)"
(cd "$ROOT_DIR/docker" && docker compose -f docker-compose.prod.yml down)
echo "[完成] 生产环境已停止。数据库卷 dm_db 已保留。"

21
scripts/prod-up.sh Normal file
View file

@ -0,0 +1,21 @@
#!/usr/bin/env bash
set -euo pipefail
echo "[Dream-Machine] 生产环境启动"
echo "- 目标平台: linux/amd64"
echo "- 将使用宿主机构建的前端 (dist/)"
SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
ROOT_DIR=$(cd "$SCRIPT_DIR/.." && pwd)
if [ ! -d "$ROOT_DIR/dist" ]; then
echo "[错误] 未检测到 dist/。请先在宿主机运行: pnpm run build"
exit 1
fi
echo "[步骤] 启动 Docker Compose (生产)"
(cd "$ROOT_DIR/docker" && docker compose -f docker-compose.prod.yml up -d --build)
echo "[完成] API: http://localhost:8080"

36
server/package.json Normal file
View file

@ -0,0 +1,36 @@
{
"name": "dm-purchase-server",
"private": true,
"version": "0.1.0",
"type": "module",
"engines": {
"node": ">=18"
},
"scripts": {
"dev": "nodemon --watch src --exec ts-node src/index.ts",
"build": "tsc -b",
"start": "node dist/index.js",
"migrate": "ts-node src/migrate.ts"
},
"dependencies": {
"better-sqlite3": "^11.8.1",
"cors": "^2.8.5",
"express": "^4.21.2",
"zod": "^3.24.3"
},
"devDependencies": {
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"@types/node": "^22.14.1",
"nodemon": "^3.1.9",
"ts-node": "^10.9.2",
"typescript": "~5.7.2"
},
"pnpm": {
"allowedScripts": {
"better-sqlite3": true
}
}
}

1335
server/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load diff

195
server/src/index.ts Normal file
View file

@ -0,0 +1,195 @@
import express from 'express';
import cors from 'cors';
import Database from 'better-sqlite3';
import { z } from 'zod';
import fs from 'node:fs';
import path from 'node:path';
const app = express();
app.use(cors());
app.use(express.json());
const dbPath = process.env.DB_PATH || './data/purchase.db';
const db = new Database(dbPath);
// Ensure table exists
db.exec(`CREATE TABLE IF NOT EXISTS purchase_items (
id TEXT PRIMARY KEY,
product_code TEXT,
product_name TEXT NOT NULL,
price REAL NOT NULL,
original_price TEXT NOT NULL,
original_currency TEXT NOT NULL CHECK(original_currency IN ('SGD','USD','RMB')),
importance TEXT NOT NULL CHECK(importance IN ('必须','必须的必','可买','不急')),
platform TEXT NOT NULL,
notes TEXT,
category TEXT NOT NULL,
site TEXT NOT NULL,
batch TEXT NOT NULL,
purchased INTEGER NOT NULL,
weight REAL NOT NULL,
purchase_link TEXT
)`);
const StoreItemSchema = z.object({
id: z.string(),
productCode: z.string(),
productName: z.string(),
price: z.number(),
originalPrice: z.string(),
originalCurrency: z.enum(['SGD','USD','RMB']),
importance: z.enum(['必须','必须的必','可买','不急']),
platform: z.string(),
notes: z.string(),
category: z.string(),
site: z.string(),
batch: z.string(),
purchased: z.boolean(),
weight: z.number(),
purchaseLink: z.string().optional()
});
app.get('/health', (_req, res) => {
res.json({ ok: true });
});
type DbRow = {
id: string;
product_code: string | null;
product_name: string;
price: number;
original_price: string;
original_currency: 'SGD'|'USD'|'RMB';
category: string;
importance: string;
platform: string;
notes: string | null;
site: string;
batch: string;
purchased: number; // 0/1
weight: number;
purchase_link: string | null;
};
app.get('/api/items', (_req, res) => {
const rows = db.prepare('SELECT * FROM purchase_items ORDER BY rowid DESC').all() as DbRow[];
const items = rows.map((r: DbRow) => ({
id: r.id,
productCode: r.product_code ?? '',
productName: r.product_name,
price: r.price,
originalPrice: r.original_price,
originalCurrency: r.original_currency,
importance: r.importance as '必须'|'必须的必'|'可买'|'不急',
platform: r.platform,
notes: r.notes ?? '',
category: r.category,
site: r.site,
batch: r.batch,
purchased: !!r.purchased,
weight: r.weight,
purchaseLink: r.purchase_link ?? ''
}));
res.json(items);
});
app.post('/api/items', (req, res) => {
const parse = StoreItemSchema.safeParse(req.body);
if (!parse.success) {
return res.status(400).json({ error: parse.error.message });
}
const i = parse.data;
db.prepare(`INSERT INTO purchase_items (
id,product_code,product_name,price,original_price,original_currency,importance,platform,notes,category,site,batch,purchased,weight,purchase_link
) VALUES (@id,@product_code,@product_name,@price,@original_price,@original_currency,@importance,@platform,@notes,@category,@site,@batch,@purchased,@weight,@purchase_link)`)
.run({
id: i.id,
product_code: i.productCode,
product_name: i.productName,
price: i.price,
original_price: i.originalPrice,
original_currency: i.originalCurrency,
importance: i.importance,
platform: i.platform,
notes: i.notes ?? '',
category: i.category,
site: i.site,
batch: i.batch,
purchased: i.purchased ? 1 : 0,
weight: i.weight,
purchase_link: i.purchaseLink ?? ''
});
res.status(201).json({ ok: true });
});
app.put('/api/items/:id', (req, res) => {
const parse = StoreItemSchema.safeParse({ ...req.body, id: req.params.id });
if (!parse.success) {
return res.status(400).json({ error: parse.error.message });
}
const i = parse.data;
const changes = db.prepare(`UPDATE purchase_items SET
product_code=@product_code,
product_name=@product_name,
price=@price,
original_price=@original_price,
original_currency=@original_currency,
importance=@importance,
platform=@platform,
notes=@notes,
category=@category,
site=@site,
batch=@batch,
purchased=@purchased,
weight=@weight,
purchase_link=@purchase_link
WHERE id=@id`).run({
id: i.id,
product_code: i.productCode,
product_name: i.productName,
price: i.price,
original_price: i.originalPrice,
original_currency: i.originalCurrency,
importance: i.importance,
platform: i.platform,
notes: i.notes ?? '',
category: i.category,
site: i.site,
batch: i.batch,
purchased: i.purchased ? 1 : 0,
weight: i.weight,
purchase_link: i.purchaseLink ?? ''
});
if (changes.changes === 0) return res.status(404).json({ error: 'Not found' });
res.json({ ok: true });
});
app.delete('/api/items/:id', (req, res) => {
const info = db.prepare('DELETE FROM purchase_items WHERE id = ?').run(req.params.id);
if (info.changes === 0) return res.status(404).json({ error: 'Not found' });
res.json({ ok: true });
});
// Quick toggle purchased
app.patch('/api/items/:id/purchased', (req, res) => {
const purchased = !!req.body?.purchased;
const info = db.prepare('UPDATE purchase_items SET purchased = ? WHERE id = ?').run(purchased ? 1 : 0, req.params.id);
if (info.changes === 0) return res.status(404).json({ error: 'Not found' });
res.json({ ok: true });
});
const port = Number(process.env.PORT || 8080);
// Serve prebuilt frontend when present (production)
const frontendDir = path.resolve(process.cwd(), 'frontend');
if (fs.existsSync(frontendDir)) {
app.use(express.static(frontendDir));
app.get('*', (_req, res) => {
res.sendFile(path.join(frontendDir, 'index.html'));
});
}
app.listen(port, () => {
console.log(`[server] listening on http://0.0.0.0:${port}`);
});

23
server/src/migrate.ts Normal file
View file

@ -0,0 +1,23 @@
import Database from 'better-sqlite3';
const dbPath = process.env.DB_PATH || './data/purchase.db';
const db = new Database(dbPath);
db.exec(`CREATE TABLE IF NOT EXISTS purchase_items (
id TEXT PRIMARY KEY,
item_name TEXT NOT NULL,
item_link TEXT,
category TEXT NOT NULL,
importance TEXT NOT NULL,
price REAL NOT NULL,
site TEXT NOT NULL,
batch TEXT,
status TEXT NOT NULL CHECK(status IN ('pending','purchased','cancelled')),
notes TEXT,
created_at TEXT NOT NULL,
updated_at TEXT
)`);
console.log('Migration complete. DB at', dbPath);

3
server/src/types.d.ts vendored Normal file
View file

@ -0,0 +1,3 @@
declare module 'better-sqlite3';

16
server/tsconfig.json Normal file
View file

@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ES2022",
"moduleResolution": "Bundler",
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true,
"outDir": "dist",
"resolveJsonModule": true,
"strict": true
},
"include": ["src"]
}

43
src/lib/api.ts Normal file
View file

@ -0,0 +1,43 @@
import type { PurchaseItem as StoreItem } from '@/store/purchase-store';
const API_BASE = import.meta.env.VITE_API_BASE || '';
export async function fetchItems(): Promise<StoreItem[]> {
const res = await fetch(`${API_BASE}/api/items`);
if (!res.ok) throw new Error('Failed to fetch items');
return res.json();
}
export async function createItem(item: StoreItem): Promise<void> {
const res = await fetch(`${API_BASE}/api/items`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(item),
});
if (!res.ok) throw new Error('Failed to create item');
}
export async function updateItem(item: StoreItem): Promise<void> {
const res = await fetch(`${API_BASE}/api/items/${encodeURIComponent(item.id)}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(item),
});
if (!res.ok) throw new Error('Failed to update item');
}
export async function deleteItemById(id: string): Promise<void> {
const res = await fetch(`${API_BASE}/api/items/${encodeURIComponent(id)}`, { method: 'DELETE' });
if (!res.ok) throw new Error('Failed to delete item');
}
export async function togglePurchased(id: string, purchased: boolean): Promise<void> {
const res = await fetch(`${API_BASE}/api/items/${encodeURIComponent(id)}/purchased`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ purchased }),
});
if (!res.ok) throw new Error('Failed to toggle purchased');
}

View file

@ -8,7 +8,7 @@ import { useEffect, useState } from "react";
import { DogMotivation } from "@/features/purchase/components/DogMotivation";
function HomePage() {
const { items, totalBudget, getPurchasedTotal, getRemainingTotal } = usePurchaseStore();
const { items, totalBudget, getPurchasedTotal, getRemainingTotal, hydrateFromServer } = usePurchaseStore();
const [currentDate, setCurrentDate] = useState("");
const purchasedTotal = getPurchasedTotal();
@ -29,6 +29,11 @@ function HomePage() {
setCurrentDate(formattedDate);
}, []);
useEffect(() => {
// hydrate from backend once
hydrateFromServer();
}, [hydrateFromServer]);
const handlePrint = () => {
window.print();
};

View file

@ -1,5 +1,6 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import * as api from '@/lib/api';
export type PurchaseItem = {
id: string;
@ -30,10 +31,11 @@ type PurchaseStore = {
setTotalBudget: (budget: number) => void;
setExchangeRates: (rates: { SGD?: number; USD?: number }) => void;
recalculatePrices: () => void;
addItem: (item: PurchaseItem) => void;
updateItem: (item: PurchaseItem) => void;
deleteItem: (id: string) => void;
togglePurchased: (id: string) => void;
hydrateFromServer: () => Promise<void>;
addItem: (item: PurchaseItem) => Promise<void>;
updateItem: (item: PurchaseItem) => Promise<void>;
deleteItem: (id: string) => Promise<void>;
togglePurchased: (id: string) => Promise<void>;
// Calculations
getPurchasedTotal: () => number;
@ -224,21 +226,57 @@ export const usePurchaseStore = create<PurchaseStore>()(
})
});
},
addItem: (item) => set((state) => ({ items: [...state.items, item] })),
updateItem: (item) =>
set((state) => ({
items: state.items.map((i) => (i.id === item.id ? item : i)),
})),
deleteItem: (id) =>
set((state) => ({
items: state.items.filter((item) => item.id !== id),
})),
togglePurchased: (id) =>
set((state) => ({
items: state.items.map((item) =>
item.id === id ? { ...item, purchased: !item.purchased } : item
),
})),
hydrateFromServer: async () => {
try {
const serverItems = await api.fetchItems();
set({ items: serverItems });
} catch (e) {
console.warn('hydrateFromServer failed, using local data');
}
},
addItem: async (item) => {
set((state) => ({ items: [...state.items, item] }));
try {
await api.createItem(item);
} catch (e) {
// rollback
set((state) => ({ items: state.items.filter((i) => i.id !== item.id) }));
throw e;
}
},
updateItem: async (item) => {
const prev = get().items;
set({ items: prev.map((i) => (i.id === item.id ? item : i)) });
try {
await api.updateItem(item);
} catch (e) {
set({ items: prev });
throw e;
}
},
deleteItem: async (id) => {
const prev = get().items;
set({ items: prev.filter((i) => i.id !== id) });
try {
await api.deleteItemById(id);
} catch (e) {
set({ items: prev });
throw e;
}
},
togglePurchased: async (id) => {
const prev = get().items;
const next = prev.map((i) => (i.id === id ? { ...i, purchased: !i.purchased } : i));
set({ items: next });
try {
const item = next.find((i) => i.id === id)!;
await api.togglePurchased(id, item.purchased);
} catch (e) {
set({ items: prev });
throw e;
}
},
getPurchasedTotal: () =>
get().items.reduce(
(sum, item) => sum + (item.purchased ? item.price : 0),

0
start.sh Normal file → Executable file
View file

View file

@ -10,4 +10,16 @@ export default defineConfig({
"@": path.resolve(__dirname, "./src"),
},
},
server: {
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true,
},
'/health': {
target: 'http://localhost:8080',
changeOrigin: true,
}
}
}
})