Compare commits
2 commits
c597af273a
...
4557728932
| Author | SHA1 | Date | |
|---|---|---|---|
| 4557728932 | |||
| 3b638a9509 |
21 changed files with 1955 additions and 20 deletions
28
Makefile
28
Makefile
|
|
@ -17,6 +17,8 @@ NODE_MODULES = node_modules
|
||||||
# 包管理器自动检测(优先 pnpm,否者回退 npm)
|
# 包管理器自动检测(优先 pnpm,否者回退 npm)
|
||||||
PKG = $(shell command -v pnpm >/dev/null 2>&1 && echo pnpm || echo npm)
|
PKG = $(shell command -v pnpm >/dev/null 2>&1 && echo pnpm || echo npm)
|
||||||
|
|
||||||
|
# Docker(平台由 scripts/dev-up.sh 自动探测,可通过环境变量 DOCKER_PLATFORM 覆盖)
|
||||||
|
|
||||||
# 默认目标
|
# 默认目标
|
||||||
.PHONY: help
|
.PHONY: help
|
||||||
help: ## 显示帮助信息
|
help: ## 显示帮助信息
|
||||||
|
|
@ -146,5 +148,31 @@ quick-build: lint build ## 快速构建(检查代码并构建)
|
||||||
test-flow: clean-install lint build preview ## 完整测试流程
|
test-flow: clean-install lint build preview ## 完整测试流程
|
||||||
@echo "$(GREEN)完整测试流程完成!$(RESET)"
|
@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
|
.DEFAULT_GOAL := help
|
||||||
21
docker/Dockerfile.dev
Normal file
21
docker/Dockerfile.dev
Normal 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
26
docker/Dockerfile.prod
Normal 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"]
|
||||||
|
|
||||||
|
|
||||||
20
docker/docker-compose.dev.yml
Normal file
20
docker/docker-compose.dev.yml
Normal 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:
|
||||||
|
|
||||||
|
|
||||||
19
docker/docker-compose.prod.yml
Normal file
19
docker/docker-compose.prod.yml
Normal 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
14
scripts/dev-down.sh
Normal 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
42
scripts/dev-up.sh
Normal 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/arm64(Docker 只支持 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
24
scripts/prod-build.sh
Normal 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
14
scripts/prod-down.sh
Normal 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
21
scripts/prod-up.sh
Normal 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
36
server/package.json
Normal 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
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
195
server/src/index.ts
Normal 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
23
server/src/migrate.ts
Normal 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
3
server/src/types.d.ts
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
declare module 'better-sqlite3';
|
||||||
|
|
||||||
|
|
||||||
16
server/tsconfig.json
Normal file
16
server/tsconfig.json
Normal 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
43
src/lib/api.ts
Normal 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');
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -8,7 +8,7 @@ import { useEffect, useState } from "react";
|
||||||
import { DogMotivation } from "@/features/purchase/components/DogMotivation";
|
import { DogMotivation } from "@/features/purchase/components/DogMotivation";
|
||||||
|
|
||||||
function HomePage() {
|
function HomePage() {
|
||||||
const { items, totalBudget, getPurchasedTotal, getRemainingTotal } = usePurchaseStore();
|
const { items, totalBudget, getPurchasedTotal, getRemainingTotal, hydrateFromServer } = usePurchaseStore();
|
||||||
const [currentDate, setCurrentDate] = useState("");
|
const [currentDate, setCurrentDate] = useState("");
|
||||||
|
|
||||||
const purchasedTotal = getPurchasedTotal();
|
const purchasedTotal = getPurchasedTotal();
|
||||||
|
|
@ -29,6 +29,11 @@ function HomePage() {
|
||||||
setCurrentDate(formattedDate);
|
setCurrentDate(formattedDate);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// hydrate from backend once
|
||||||
|
hydrateFromServer();
|
||||||
|
}, [hydrateFromServer]);
|
||||||
|
|
||||||
const handlePrint = () => {
|
const handlePrint = () => {
|
||||||
window.print();
|
window.print();
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import { persist } from 'zustand/middleware';
|
import { persist } from 'zustand/middleware';
|
||||||
|
import * as api from '@/lib/api';
|
||||||
|
|
||||||
export type PurchaseItem = {
|
export type PurchaseItem = {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -30,10 +31,11 @@ type PurchaseStore = {
|
||||||
setTotalBudget: (budget: number) => void;
|
setTotalBudget: (budget: number) => void;
|
||||||
setExchangeRates: (rates: { SGD?: number; USD?: number }) => void;
|
setExchangeRates: (rates: { SGD?: number; USD?: number }) => void;
|
||||||
recalculatePrices: () => void;
|
recalculatePrices: () => void;
|
||||||
addItem: (item: PurchaseItem) => void;
|
hydrateFromServer: () => Promise<void>;
|
||||||
updateItem: (item: PurchaseItem) => void;
|
addItem: (item: PurchaseItem) => Promise<void>;
|
||||||
deleteItem: (id: string) => void;
|
updateItem: (item: PurchaseItem) => Promise<void>;
|
||||||
togglePurchased: (id: string) => void;
|
deleteItem: (id: string) => Promise<void>;
|
||||||
|
togglePurchased: (id: string) => Promise<void>;
|
||||||
|
|
||||||
// Calculations
|
// Calculations
|
||||||
getPurchasedTotal: () => number;
|
getPurchasedTotal: () => number;
|
||||||
|
|
@ -224,21 +226,57 @@ export const usePurchaseStore = create<PurchaseStore>()(
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
addItem: (item) => set((state) => ({ items: [...state.items, item] })),
|
hydrateFromServer: async () => {
|
||||||
updateItem: (item) =>
|
try {
|
||||||
set((state) => ({
|
const serverItems = await api.fetchItems();
|
||||||
items: state.items.map((i) => (i.id === item.id ? item : i)),
|
set({ items: serverItems });
|
||||||
})),
|
} catch (e) {
|
||||||
deleteItem: (id) =>
|
console.warn('hydrateFromServer failed, using local data');
|
||||||
set((state) => ({
|
}
|
||||||
items: state.items.filter((item) => item.id !== id),
|
},
|
||||||
})),
|
addItem: async (item) => {
|
||||||
togglePurchased: (id) =>
|
set((state) => ({ items: [...state.items, item] }));
|
||||||
set((state) => ({
|
try {
|
||||||
items: state.items.map((item) =>
|
await api.createItem(item);
|
||||||
item.id === id ? { ...item, purchased: !item.purchased } : 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: () =>
|
getPurchasedTotal: () =>
|
||||||
get().items.reduce(
|
get().items.reduce(
|
||||||
(sum, item) => sum + (item.purchased ? item.price : 0),
|
(sum, item) => sum + (item.purchased ? item.price : 0),
|
||||||
|
|
|
||||||
0
start.sh
Normal file → Executable file
0
start.sh
Normal file → Executable file
|
|
@ -10,4 +10,16 @@ export default defineConfig({
|
||||||
"@": path.resolve(__dirname, "./src"),
|
"@": path.resolve(__dirname, "./src"),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
server: {
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://localhost:8080',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
'/health': {
|
||||||
|
target: 'http://localhost:8080',
|
||||||
|
changeOrigin: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue