Compare commits
No commits in common. "45577289328dce6cd8b8a6583e10240a1bcad10f" and "c597af273afda58dd1ec1716003dcca0b85793db" have entirely different histories.
4557728932
...
c597af273a
21 changed files with 20 additions and 1955 deletions
28
Makefile
28
Makefile
|
|
@ -17,8 +17,6 @@ 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: ## 显示帮助信息
|
||||||
|
|
@ -148,31 +146,5 @@ 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
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
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"]
|
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1,26 +0,0 @@
|
||||||
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"]
|
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1,20 +0,0 @@
|
||||||
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:
|
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1,19 +0,0 @@
|
||||||
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:
|
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1,14 +0,0 @@
|
||||||
#!/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 已保留。"
|
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1,42 +0,0 @@
|
||||||
#!/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"
|
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1,24 +0,0 @@
|
||||||
#!/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"
|
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1,14 +0,0 @@
|
||||||
#!/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 已保留。"
|
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
#!/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"
|
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1,36 +0,0 @@
|
||||||
{
|
|
||||||
"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
1335
server/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
|
|
@ -1,195 +0,0 @@
|
||||||
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}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1,23 +0,0 @@
|
||||||
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
3
server/src/types.d.ts
vendored
|
|
@ -1,3 +0,0 @@
|
||||||
declare module 'better-sqlite3';
|
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"target": "ES2020",
|
|
||||||
"module": "ES2022",
|
|
||||||
"moduleResolution": "Bundler",
|
|
||||||
"esModuleInterop": true,
|
|
||||||
"forceConsistentCasingInFileNames": true,
|
|
||||||
"skipLibCheck": true,
|
|
||||||
"outDir": "dist",
|
|
||||||
"resolveJsonModule": true,
|
|
||||||
"strict": true
|
|
||||||
},
|
|
||||||
"include": ["src"]
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1,43 +0,0 @@
|
||||||
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, hydrateFromServer } = usePurchaseStore();
|
const { items, totalBudget, getPurchasedTotal, getRemainingTotal } = usePurchaseStore();
|
||||||
const [currentDate, setCurrentDate] = useState("");
|
const [currentDate, setCurrentDate] = useState("");
|
||||||
|
|
||||||
const purchasedTotal = getPurchasedTotal();
|
const purchasedTotal = getPurchasedTotal();
|
||||||
|
|
@ -29,11 +29,6 @@ function HomePage() {
|
||||||
setCurrentDate(formattedDate);
|
setCurrentDate(formattedDate);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// hydrate from backend once
|
|
||||||
hydrateFromServer();
|
|
||||||
}, [hydrateFromServer]);
|
|
||||||
|
|
||||||
const handlePrint = () => {
|
const handlePrint = () => {
|
||||||
window.print();
|
window.print();
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
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;
|
||||||
|
|
@ -31,11 +30,10 @@ 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;
|
||||||
hydrateFromServer: () => Promise<void>;
|
addItem: (item: PurchaseItem) => void;
|
||||||
addItem: (item: PurchaseItem) => Promise<void>;
|
updateItem: (item: PurchaseItem) => void;
|
||||||
updateItem: (item: PurchaseItem) => Promise<void>;
|
deleteItem: (id: string) => void;
|
||||||
deleteItem: (id: string) => Promise<void>;
|
togglePurchased: (id: string) => void;
|
||||||
togglePurchased: (id: string) => Promise<void>;
|
|
||||||
|
|
||||||
// Calculations
|
// Calculations
|
||||||
getPurchasedTotal: () => number;
|
getPurchasedTotal: () => number;
|
||||||
|
|
@ -226,57 +224,21 @@ export const usePurchaseStore = create<PurchaseStore>()(
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
hydrateFromServer: async () => {
|
addItem: (item) => set((state) => ({ items: [...state.items, item] })),
|
||||||
try {
|
updateItem: (item) =>
|
||||||
const serverItems = await api.fetchItems();
|
set((state) => ({
|
||||||
set({ items: serverItems });
|
items: state.items.map((i) => (i.id === item.id ? item : i)),
|
||||||
} catch (e) {
|
})),
|
||||||
console.warn('hydrateFromServer failed, using local data');
|
deleteItem: (id) =>
|
||||||
}
|
set((state) => ({
|
||||||
},
|
items: state.items.filter((item) => item.id !== id),
|
||||||
addItem: async (item) => {
|
})),
|
||||||
set((state) => ({ items: [...state.items, item] }));
|
togglePurchased: (id) =>
|
||||||
try {
|
set((state) => ({
|
||||||
await api.createItem(item);
|
items: state.items.map((item) =>
|
||||||
} catch (e) {
|
item.id === id ? { ...item, purchased: !item.purchased } : item
|
||||||
// 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
Executable file → Normal file
0
start.sh
Executable file → Normal file
|
|
@ -10,16 +10,4 @@ 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