feat: 完整 MVP — 剁手小黑屋反冲动消费 Web App

- 数据层: Prisma 7 + SQLite (better-sqlite3 adapter), Item 模型
- API: POST/GET/PATCH /api/items, 商品 CRUD 与状态流转
- 关押表单: 图片拖拽上传 + Canvas 压缩, Base64 存储
- 首页看守所: 72h 倒计时 Hook, 省钱看板, 冷却/释放卡片交互
- PWA: manifest.ts, apple-icon, viewport 沉浸配置, iOS 引导组件
- 部署: Dockerfile 多阶段构建, docker-compose, Jenkinsfile CI/CD
- 图标: 全套专属像素风锁头+购物车图标 (favicon/96/180/192/512/OG)
This commit is contained in:
2026-02-25 18:14:38 +08:00
committed by 田东生
parent 97d5eb72ba
commit 5bf98753f1
37 changed files with 9124 additions and 79 deletions
+237
View File
@@ -0,0 +1,237 @@
"use client";
import { useState, useRef, type ChangeEvent, type FormEvent } from "react";
import { useRouter } from "next/navigation";
import { Upload, ShieldAlert, ArrowLeft, Loader2 } from "lucide-react";
const MAX_IMAGE_WIDTH = 800;
const MAX_IMAGE_HEIGHT = 800;
const IMAGE_QUALITY = 0.7;
function compressImage(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = (e) => {
const img = new Image();
img.onload = () => {
const canvas = document.createElement("canvas");
let { width, height } = img;
if (width > MAX_IMAGE_WIDTH || height > MAX_IMAGE_HEIGHT) {
const ratio = Math.min(
MAX_IMAGE_WIDTH / width,
MAX_IMAGE_HEIGHT / height
);
width = Math.round(width * ratio);
height = Math.round(height * ratio);
}
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext("2d")!;
ctx.drawImage(img, 0, 0, width, height);
resolve(canvas.toDataURL("image/jpeg", IMAGE_QUALITY));
};
img.onerror = reject;
img.src = e.target?.result as string;
};
reader.onerror = reject;
reader.readAsDataURL(file);
});
}
export default function AddItemPage() {
const router = useRouter();
const fileInputRef = useRef<HTMLInputElement>(null);
const [name, setName] = useState("");
const [price, setPrice] = useState("");
const [link, setLink] = useState("");
const [imagePreview, setImagePreview] = useState<string | null>(null);
const [imageBase64, setImageBase64] = useState<string>("");
const [submitting, setSubmitting] = useState(false);
const [dragOver, setDragOver] = useState(false);
async function handleImageFile(file: File) {
if (!file.type.startsWith("image/")) return;
const compressed = await compressImage(file);
setImageBase64(compressed);
setImagePreview(compressed);
}
function handleFileChange(e: ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (file) handleImageFile(file);
}
function handleDrop(e: React.DragEvent) {
e.preventDefault();
setDragOver(false);
const file = e.dataTransfer.files?.[0];
if (file) handleImageFile(file);
}
async function handleSubmit(e: FormEvent) {
e.preventDefault();
if (!name || !price || !link || !imageBase64) return;
setSubmitting(true);
try {
const res = await fetch("/api/items", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name,
price: parseFloat(price),
link,
image: imageBase64,
}),
});
if (res.ok) {
router.push("/");
}
} finally {
setSubmitting(false);
}
}
return (
<div className="min-h-screen bg-zinc-950 text-zinc-100">
<div className="mx-auto max-w-lg px-4 py-8">
<button
onClick={() => router.push("/")}
className="mb-6 flex items-center gap-1.5 text-sm text-zinc-500 transition hover:text-zinc-300"
>
<ArrowLeft size={16} />
</button>
<div className="mb-8 flex items-center gap-3">
<ShieldAlert className="text-red-500" size={28} />
<h1 className="text-2xl font-bold tracking-tight"></h1>
</div>
<form onSubmit={handleSubmit} className="space-y-5">
{/* 商品名称 */}
<div>
<label className="mb-1.5 block text-sm font-medium text-zinc-400">
</label>
<input
type="text"
required
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="例:机械键盘 / AirPods Max"
className="w-full rounded-lg border border-zinc-800 bg-zinc-900 px-4 py-2.5 text-zinc-100 placeholder:text-zinc-600 focus:border-red-500/50 focus:outline-none focus:ring-1 focus:ring-red-500/30"
/>
</div>
{/* 价格 */}
<div>
<label className="mb-1.5 block text-sm font-medium text-zinc-400">
</label>
<input
type="number"
required
min="0"
step="0.01"
value={price}
onChange={(e) => setPrice(e.target.value)}
placeholder="0.00"
className="w-full rounded-lg border border-zinc-800 bg-zinc-900 px-4 py-2.5 text-zinc-100 placeholder:text-zinc-600 focus:border-red-500/50 focus:outline-none focus:ring-1 focus:ring-red-500/30"
/>
</div>
{/* 购买链接 */}
<div>
<label className="mb-1.5 block text-sm font-medium text-zinc-400">
</label>
<input
type="url"
required
value={link}
onChange={(e) => setLink(e.target.value)}
placeholder="https://..."
className="w-full rounded-lg border border-zinc-800 bg-zinc-900 px-4 py-2.5 text-zinc-100 placeholder:text-zinc-600 focus:border-red-500/50 focus:outline-none focus:ring-1 focus:ring-red-500/30"
/>
</div>
{/* 图片上传 */}
<div>
<label className="mb-1.5 block text-sm font-medium text-zinc-400">
</label>
<input
ref={fileInputRef}
type="file"
accept="image/*"
onChange={handleFileChange}
className="hidden"
/>
<div
onClick={() => fileInputRef.current?.click()}
onDragOver={(e) => {
e.preventDefault();
setDragOver(true);
}}
onDragLeave={() => setDragOver(false)}
onDrop={handleDrop}
className={`flex cursor-pointer flex-col items-center justify-center rounded-lg border-2 border-dashed transition ${
dragOver
? "border-red-500 bg-red-500/5"
: "border-zinc-700 bg-zinc-900 hover:border-zinc-600"
} ${imagePreview ? "p-2" : "p-8"}`}
>
{imagePreview ? (
<img
src={imagePreview}
alt="预览"
className="max-h-48 rounded object-contain"
/>
) : (
<>
<Upload className="mb-2 text-zinc-600" size={32} />
<p className="text-sm text-zinc-500">
</p>
<p className="mt-1 text-xs text-zinc-600">
800px
</p>
</>
)}
</div>
{imagePreview && (
<button
type="button"
onClick={() => {
setImagePreview(null);
setImageBase64("");
}}
className="mt-2 text-xs text-zinc-500 hover:text-red-400"
>
</button>
)}
</div>
{/* 提交 */}
<button
type="submit"
disabled={submitting || !name || !price || !link || !imageBase64}
className="flex w-full items-center justify-center gap-2 rounded-lg bg-red-600 py-3 font-semibold text-white transition hover:bg-red-700 disabled:cursor-not-allowed disabled:opacity-40"
>
{submitting ? (
<Loader2 className="animate-spin" size={18} />
) : (
<ShieldAlert size={18} />
)}
{submitting ? "正在关押中…" : "确认关入小黑屋"}
</button>
</form>
</div>
</div>
);
}
+22
View File
@@ -0,0 +1,22 @@
import { prisma } from "@/lib/prisma";
import { NextResponse } from "next/server";
export async function PATCH(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
const body = await request.json();
const { status } = body;
if (!status || !["saved", "bought"].includes(status)) {
return NextResponse.json({ error: "无效的状态值" }, { status: 400 });
}
const item = await prisma.item.update({
where: { id },
data: { status },
});
return NextResponse.json(item);
}
+24
View File
@@ -0,0 +1,24 @@
import { prisma } from "@/lib/prisma";
import { NextResponse } from "next/server";
export async function GET() {
const items = await prisma.item.findMany({
orderBy: { createdAt: "desc" },
});
return NextResponse.json(items);
}
export async function POST(request: Request) {
const body = await request.json();
const { name, price, link, image } = body;
if (!name || price == null || !link || !image) {
return NextResponse.json({ error: "缺少必填字段" }, { status: 400 });
}
const item = await prisma.item.create({
data: { name, price: Number(price), link, image },
});
return NextResponse.json(item, { status: 201 });
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 439 B

+10
View File
@@ -0,0 +1,10 @@
@import "tailwindcss";
@theme inline {
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
}
body {
font-family: var(--font-sans), system-ui, sans-serif;
}
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

+53
View File
@@ -0,0 +1,53 @@
import type { Metadata, Viewport } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import PwaInstallGuide from "@/components/PwaInstallGuide";
import "./globals.css";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const viewport: Viewport = {
themeColor: "#000000",
width: "device-width",
initialScale: 1,
maximumScale: 1,
userScalable: false,
viewportFit: "cover",
};
export const metadata: Metadata = {
title: "剁手小黑屋 | ImpulseJail",
description: "反冲动消费工具——把想买的商品关进小黑屋冷却 72 小时",
appleWebApp: {
capable: true,
title: "小黑屋",
statusBarStyle: "black-translucent",
},
formatDetection: {
telephone: false,
},
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="zh-CN">
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
{children}
<PwaInstallGuide />
</body>
</html>
);
}
+25
View File
@@ -0,0 +1,25 @@
import type { MetadataRoute } from "next";
export default function manifest(): MetadataRoute.Manifest {
return {
name: "剁手小黑屋",
short_name: "小黑屋",
description: "反冲动消费,72小时冷静期",
start_url: "/",
display: "standalone",
background_color: "#000000",
theme_color: "#000000",
icons: [
{
src: "/icon-192.png",
sizes: "192x192",
type: "image/png",
},
{
src: "/icon-512.png",
sizes: "512x512",
type: "image/png",
},
],
};
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 794 KiB

+209
View File
@@ -0,0 +1,209 @@
"use client";
import { useEffect, useState, useCallback } from "react";
import Link from "next/link";
import {
ShieldAlert,
Plus,
Lock,
PartyPopper,
ShoppingCart,
Timer,
Ban,
Inbox,
} from "lucide-react";
import { useCountdown } from "@/hooks/useCountdown";
interface Item {
id: string;
name: string;
price: number;
link: string;
image: string;
status: string;
createdAt: string;
}
function ItemCard({
item,
onStatusChange,
}: {
item: Item;
onStatusChange: () => void;
}) {
const { display, isExpired } = useCountdown(item.createdAt);
const [updating, setUpdating] = useState(false);
async function updateStatus(status: "saved" | "bought") {
setUpdating(true);
try {
await fetch(`/api/items/${item.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ status }),
});
if (status === "bought") {
window.open(item.link, "_blank");
}
onStatusChange();
} finally {
setUpdating(false);
}
}
return (
<div className="overflow-hidden rounded-xl border border-zinc-800 bg-zinc-900/80 transition hover:border-zinc-700">
<div className="flex gap-4 p-4">
{/* 商品图片 */}
<div className="h-24 w-24 shrink-0 overflow-hidden rounded-lg border border-zinc-800 bg-zinc-800">
<img
src={item.image}
alt={item.name}
className="h-full w-full object-cover"
/>
</div>
{/* 商品信息 */}
<div className="flex min-w-0 flex-1 flex-col justify-between">
<div>
<h3 className="truncate font-semibold text-zinc-100">
{item.name}
</h3>
<p className="mt-0.5 text-lg font-bold text-red-400">
¥{item.price.toLocaleString("zh-CN", { minimumFractionDigits: 2 })}
</p>
</div>
{/* 倒计时 */}
<div className="flex items-center gap-1.5">
<Timer size={14} className="text-zinc-500" />
{isExpired ? (
<span className="text-sm font-medium text-emerald-400">
</span>
) : (
<span className="font-mono text-sm font-bold text-red-500">
{display}
</span>
)}
</div>
</div>
</div>
{/* 操作按钮区 */}
<div className="border-t border-zinc-800 px-4 py-3">
{isExpired ? (
<div className="flex gap-2">
<button
onClick={() => updateStatus("saved")}
disabled={updating}
className="flex flex-1 items-center justify-center gap-1.5 rounded-lg bg-emerald-600 py-2 text-sm font-semibold text-white transition hover:bg-emerald-700 disabled:opacity-50"
>
<PartyPopper size={15} />
</button>
<button
onClick={() => updateStatus("bought")}
disabled={updating}
className="flex flex-1 items-center justify-center gap-1.5 rounded-lg border border-zinc-700 py-2 text-sm font-medium text-zinc-400 transition hover:border-zinc-600 hover:text-zinc-300 disabled:opacity-50"
>
<ShoppingCart size={15} />
</button>
</div>
) : (
<button
disabled
className="flex w-full items-center justify-center gap-1.5 rounded-lg bg-zinc-800 py-2 text-sm font-medium text-zinc-500"
>
<Lock size={14} />
</button>
)}
</div>
</div>
);
}
export default function HomePage() {
const [items, setItems] = useState<Item[]>([]);
const [loading, setLoading] = useState(true);
const fetchItems = useCallback(async () => {
const res = await fetch("/api/items");
const data: Item[] = await res.json();
setItems(data);
setLoading(false);
}, []);
useEffect(() => {
fetchItems();
}, [fetchItems]);
const coolingItems = items.filter((i) => i.status === "cooling");
const savedTotal = items
.filter((i) => i.status === "saved")
.reduce((sum, i) => sum + i.price, 0);
return (
<div className="min-h-screen bg-zinc-950 text-zinc-100">
<div className="mx-auto max-w-lg px-4 py-8">
{/* Header */}
<div className="mb-6 flex items-center justify-between">
<div className="flex items-center gap-2.5">
<ShieldAlert className="text-red-500" size={28} />
<h1 className="text-xl font-bold tracking-tight"></h1>
</div>
<Link
href="/add"
className="flex items-center gap-1.5 rounded-lg bg-red-600 px-4 py-2 text-sm font-semibold text-white transition hover:bg-red-700"
>
<Plus size={16} />
</Link>
</div>
{/* 省钱看板 */}
<div className="mb-6 rounded-xl border border-emerald-500/20 bg-emerald-950/30 p-4">
<div className="flex items-center gap-2 text-sm text-emerald-400/70">
<Ban size={16} />
</div>
<p className="mt-1 text-3xl font-bold text-emerald-400">
¥{savedTotal.toLocaleString("zh-CN", { minimumFractionDigits: 2 })}
</p>
</div>
{/* 在押商品列表 */}
<div className="mb-4 flex items-center gap-2 text-sm font-medium text-zinc-400">
<Lock size={14} />
{coolingItems.length}
</div>
{loading ? (
<div className="flex justify-center py-16">
<div className="h-6 w-6 animate-spin rounded-full border-2 border-zinc-700 border-t-red-500" />
</div>
) : coolingItems.length === 0 ? (
<div className="flex flex-col items-center py-16 text-zinc-600">
<Inbox size={48} className="mb-3" />
<p className="text-sm"></p>
<p className="mt-1 text-xs"></p>
</div>
) : (
<div className="space-y-3">
{coolingItems.map((item) => (
<ItemCard
key={item.id}
item={item}
onStatusChange={fetchItems}
/>
))}
</div>
)}
</div>
</div>
);
}
+99
View File
@@ -0,0 +1,99 @@
"use client";
import { useState, useEffect } from "react";
import { AnimatePresence, motion } from "framer-motion";
import { X, Share } from "lucide-react";
const DISMISS_KEY = "pwa-guide-dismissed-at";
const SUPPRESS_DAYS = 7;
function isDismissed(): boolean {
try {
const raw = localStorage.getItem(DISMISS_KEY);
if (!raw) return false;
const dismissedAt = Number(raw);
return Date.now() - dismissedAt < SUPPRESS_DAYS * 24 * 60 * 60 * 1000;
} catch {
return false;
}
}
function dismiss() {
try {
localStorage.setItem(DISMISS_KEY, String(Date.now()));
} catch {}
}
export default function PwaInstallGuide() {
const [visible, setVisible] = useState(false);
useEffect(() => {
const isIos = /iPhone|iPad|iPod/i.test(navigator.userAgent);
const isStandalone =
("standalone" in navigator && (navigator as never)["standalone"]) ||
window.matchMedia("(display-mode: standalone)").matches;
if (isIos && !isStandalone && !isDismissed()) {
setVisible(true);
}
}, []);
function handleClose() {
dismiss();
setVisible(false);
}
return (
<AnimatePresence>
{visible && (
<motion.div
initial={{ y: 100, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: 100, opacity: 0 }}
transition={{ type: "spring", damping: 25, stiffness: 300 }}
className="fixed inset-x-0 bottom-0 z-50 px-4 pb-6"
>
<div className="relative mx-auto max-w-lg rounded-2xl border border-zinc-800 bg-zinc-900/95 p-4 shadow-2xl shadow-black/60 backdrop-blur-md">
<button
onClick={handleClose}
className="absolute right-3 top-3 rounded-full p-1 text-zinc-500 transition hover:bg-zinc-800 hover:text-zinc-300"
aria-label="关闭"
>
<X size={16} />
</button>
<p className="pr-6 text-sm leading-relaxed text-zinc-300">
💡
<span className="mx-1 inline-flex translate-y-0.5">
<Share size={14} className="text-blue-400" />
</span>
<span className="font-semibold text-zinc-100">
</span>
App 使
</p>
{/* 底部小三角指示 Safari 分享按钮位置 */}
<div className="mt-3 flex justify-center">
<motion.div
animate={{ y: [0, 4, 0] }}
transition={{ repeat: Infinity, duration: 1.5 }}
className="text-zinc-600"
>
<svg
width="20"
height="10"
viewBox="0 0 20 10"
fill="currentColor"
>
<polygon points="10,10 0,0 20,0" />
</svg>
</motion.div>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
);
}
+43
View File
@@ -0,0 +1,43 @@
"use client";
import { useState, useEffect } from "react";
const COOLING_HOURS = 72;
const COOLING_MS = COOLING_HOURS * 60 * 60 * 1000;
export interface CountdownResult {
hours: number;
minutes: number;
seconds: number;
isExpired: boolean;
display: string;
}
export function useCountdown(createdAt: string): CountdownResult {
const [now, setNow] = useState(Date.now());
useEffect(() => {
const timer = setInterval(() => setNow(Date.now()), 1000);
return () => clearInterval(timer);
}, []);
const created = new Date(createdAt).getTime();
const deadline = created + COOLING_MS;
const remaining = Math.max(0, deadline - now);
const totalSeconds = Math.floor(remaining / 1000);
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = totalSeconds % 60;
const pad = (n: number) => n.toString().padStart(2, "0");
const display = `${pad(hours)}:${pad(minutes)}:${pad(seconds)}`;
return {
hours,
minutes,
seconds,
isExpired: remaining === 0,
display,
};
}
+14
View File
@@ -0,0 +1,14 @@
import { PrismaClient } from "@/generated/prisma/client";
import { PrismaBetterSqlite3 } from "@prisma/adapter-better-sqlite3";
const globalForPrisma = globalThis as unknown as { prisma: PrismaClient };
function createClient() {
const url = process.env.DATABASE_URL ?? "file:./dev.db";
const adapter = new PrismaBetterSqlite3({ url });
return new PrismaClient({ adapter });
}
export const prisma = globalForPrisma.prisma || createClient();
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;