Files
Take-Photo-Calc/src/App.tsx
T
2026-08-28 22:25:25 +08:00

330 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useMemo } from "react";
import "./App.css";
interface Package {
id: string;
type: "single" | "dual";
photos: number;
price: number;
postProcessing: string;
note: string;
available: boolean;
isAddon: boolean;
}
const packages: Package[] = [
{ id: "s1", type: "single", photos: 1, price: 10, postProcessing: "调色简修", note: "一张不接,附加项", available: true, isAddon: true },
{ id: "s4", type: "single", photos: 4, price: 40, postProcessing: "调色简修", note: "", available: true, isAddon: false },
{ id: "s6", type: "single", photos: 6, price: 55, postProcessing: "调色简修", note: "", available: true, isAddon: false },
{ id: "s9", type: "single", photos: 9, price: 0, postProcessing: "", note: "暂不接", available: false, isAddon: false },
{ id: "d1", type: "dual", photos: 1, price: 15, postProcessing: "调色简修", note: "一张不接,附加项", available: true, isAddon: true },
{ id: "d2", type: "dual", photos: 2, price: 30, postProcessing: "调色简修", note: "", available: true, isAddon: false },
{ id: "d4", type: "dual", photos: 4, price: 65, postProcessing: "调色简修", note: "", available: true, isAddon: false },
{ id: "d6", type: "dual", photos: 6, price: 0, postProcessing: "", note: "暂不接", available: false, isAddon: false },
];
const COMBO_DISCOUNT = 5;
const DEPOSIT_RATE = 0.5;
/** 定金封顶:50% 超过该值时只收该金额,避免大额订单定金过高 */
const DEPOSIT_CAP = 50;
function formatMoney(n: number): string {
return n % 1 === 0 ? String(n) : n.toFixed(1);
}
/** 定金:默认应付的 50%;若 50% 超过 50 元则封顶 50 元(且不超过订单总额) */
function computeDeposit(finalPrice: number): { amount: number; isCapped: boolean; half: number } {
if (finalPrice <= 0) return { amount: 0, isCapped: false, half: 0 };
const half = finalPrice * DEPOSIT_RATE;
if (half > DEPOSIT_CAP) {
return { amount: Math.min(DEPOSIT_CAP, finalPrice), isCapped: true, half };
}
return { amount: half, isCapped: false, half };
}
function App() {
const [quantities, setQuantities] = useState<Record<string, number>>({});
const getQty = (id: string) => quantities[id] || 0;
const setQty = (id: string, val: number) => {
setQuantities((prev) => ({ ...prev, [id]: Math.max(0, val) }));
};
const selectedSingle = useMemo(
() => packages.filter((p) => p.type === "single" && getQty(p.id) > 0),
// eslint-disable-next-line react-hooks/exhaustive-deps
[quantities]
);
const selectedDual = useMemo(
() => packages.filter((p) => p.type === "dual" && getQty(p.id) > 0),
// eslint-disable-next-line react-hooks/exhaustive-deps
[quantities]
);
const singleSubtotal = selectedSingle.reduce((sum, p) => sum + p.price * getQty(p.id), 0);
const dualSubtotal = selectedDual.reduce((sum, p) => sum + p.price * getQty(p.id), 0);
const originalTotal = singleSubtotal + dualSubtotal;
const hasCombo = selectedSingle.length > 0 && selectedDual.length > 0;
const finalPrice = hasCombo ? originalTotal - COMBO_DISCOUNT : originalTotal;
const { amount: deposit, isCapped, half: depositHalf } = computeDeposit(finalPrice);
const balance = finalPrice - deposit;
const totalSinglePhotos = selectedSingle.reduce((sum, p) => sum + p.photos * getQty(p.id), 0);
const totalDualPhotos = selectedDual.reduce((sum, p) => sum + p.photos * getQty(p.id), 0);
const totalPhotos = totalSinglePhotos + totalDualPhotos;
const hasSelection = originalTotal > 0;
const singlePackages = packages.filter((p) => p.type === "single");
const dualPackages = packages.filter((p) => p.type === "dual");
return (
<div className="app">
{/* Header */}
<header className="header">
<div className="header-icon">📷</div>
<h1>晓螺</h1>
<h2>摄影套餐价格计算器</h2>
<p className="header-sub">按价目表原价;同时选单人与双人可再减 5 元</p>
</header>
{/* Package Selection */}
<section className="section">
<h2 className="section-title">
<span className="section-icon">🎨</span>
套餐自选
</h2>
<div className="package-group">
<h3 className="group-label">👤 独行者</h3>
<div className="package-grid">
{singlePackages.map((pkg) => (
<PackageCard
key={pkg.id}
pkg={pkg}
qty={getQty(pkg.id)}
onQtyChange={(val) => setQty(pkg.id, val)}
/>
))}
</div>
</div>
<div className="package-group">
<h3 className="group-label">👥 双人成行</h3>
<div className="package-grid">
{dualPackages.map((pkg) => (
<PackageCard
key={pkg.id}
pkg={pkg}
qty={getQty(pkg.id)}
onQtyChange={(val) => setQty(pkg.id, val)}
/>
))}
</div>
</div>
</section>
{/* Selected Items Summary */}
<section className="section">
<h2 className="section-title">
<span className="section-icon">📋</span>
已选套餐
{totalPhotos > 0 && <span className="photo-count-badge">共 {totalPhotos} 张</span>}
</h2>
{!hasSelection ? (
<div className="empty-state">
<div className="empty-icon">🛒</div>
<p>还没有选择任何套餐,请在上方添加</p>
</div>
) : (
<div className="selected-grid">
<div className="selected-column">
<h3 className="column-title">👤 独行者</h3>
{selectedSingle.length === 0 ? (
<p className="column-empty">未选择</p>
) : (
<>
{selectedSingle.map((pkg) => (
<div key={pkg.id} className="selected-item">
<span className="item-name">
单人 {pkg.photos} 张 × {getQty(pkg.id)}
</span>
<span className="item-price">¥{pkg.price * getQty(pkg.id)}</span>
</div>
))}
<div className="column-subtotal">
<span>小计 ({totalSinglePhotos} 张)</span>
<span>¥{singleSubtotal}</span>
</div>
</>
)}
</div>
<div className="selected-divider" />
<div className="selected-column">
<h3 className="column-title">👥 双人成行</h3>
{selectedDual.length === 0 ? (
<p className="column-empty">未选择</p>
) : (
<>
{selectedDual.map((pkg) => (
<div key={pkg.id} className="selected-item">
<span className="item-name">
双人 {pkg.photos} 张 × {getQty(pkg.id)}
</span>
<span className="item-price">¥{pkg.price * getQty(pkg.id)}</span>
</div>
))}
<div className="column-subtotal">
<span>小计 ({totalDualPhotos} 张)</span>
<span>¥{dualSubtotal}</span>
</div>
</>
)}
</div>
</div>
)}
</section>
{/* Price Calculation */}
{hasSelection && (
<section className="section price-section">
<h2 className="section-title">
<span className="section-icon">💰</span>
价格计算
</h2>
<div className="price-breakdown">
<div className="price-row">
<span>套餐小计</span>
<span>¥{formatMoney(originalTotal)}</span>
</div>
{hasCombo && (
<div className="price-row discount-row">
<span>组合套餐优惠</span>
<span className="discount-amount">-¥{formatMoney(COMBO_DISCOUNT)}</span>
</div>
)}
<div className="price-row total-row">
<span>应付金额</span>
<span className="final-price">¥{formatMoney(finalPrice)}</span>
</div>
<div className="price-row deposit-row">
<span>
定金
{isCapped ? (
<span className="deposit-hint">
({DEPOSIT_RATE * 100}% 为 {formatMoney(depositHalf)} 元,超过 ¥{DEPOSIT_CAP} 时封顶 ¥{DEPOSIT_CAP})
</span>
) : (
<span className="deposit-hint">(应付×{DEPOSIT_RATE * 100}%)</span>
)}
</span>
<span>¥{formatMoney(deposit)}</span>
</div>
<div className="price-row remaining-row">
<span>尾款</span>
<span>¥{formatMoney(balance)}</span>
</div>
</div>
{hasCombo && (
<div className="combo-tip">已选单人 + 双人,减免 ¥{formatMoney(COMBO_DISCOUNT)}</div>
)}
<div className="formula">
<p className="formula-line">
<span className="formula-label">订单:</span>
{hasCombo ? (
<span>
{formatMoney(originalTotal)} − {formatMoney(COMBO_DISCOUNT)} = <strong>{formatMoney(finalPrice)}</strong>
</span>
) : (
<span>
<strong>{formatMoney(finalPrice)}</strong>
</span>
)}
</p>
<p className="formula-line">
<span className="formula-label">定金:</span>
{isCapped ? (
<span>
应付 {formatMoney(finalPrice)} × {DEPOSIT_RATE} = {formatMoney(depositHalf)} 元 > ¥{DEPOSIT_CAP},封顶收{" "}
<strong>¥{formatMoney(deposit)}</strong>;尾款 <strong>¥{formatMoney(balance)}</strong>
</span>
) : finalPrice > 0 ? (
<span>
应付 {formatMoney(finalPrice)} × {DEPOSIT_RATE} = <strong>¥{formatMoney(deposit)}</strong>;尾款 <strong>¥{formatMoney(balance)}</strong>
</span>
) : null}
</p>
</div>
</section>
)}
{/* Footer */}
<footer className="footer">
<p>💡 单人 1 张 / 双人 1 张 为附加项,不单独接单</p>
<p>💡 同时选择单人 + 双人套餐可享组合优惠 ¥{formatMoney(COMBO_DISCOUNT)}</p>
<p>💡 定金默认应付的 {DEPOSIT_RATE * 100}%;当 {DEPOSIT_RATE * 100}% 超过 ¥{DEPOSIT_CAP} 时,定金封顶为 ¥{DEPOSIT_CAP}</p>
</footer>
</div>
);
}
function PackageCard({
pkg,
qty,
onQtyChange,
}: {
pkg: Package;
qty: number;
onQtyChange: (val: number) => void;
}) {
const typeLabel = pkg.type === "single" ? "单人" : "双人";
return (
<div className={`package-card ${!pkg.available ? "disabled" : ""} ${qty > 0 ? "active" : ""}`}>
{pkg.isAddon && <div className="addon-badge">附加项</div>}
{!pkg.available && <div className="unavailable-badge">暂不接</div>}
<div className="card-title">
{typeLabel} {pkg.photos} 张
</div>
{pkg.available ? (
<>
<div className="card-price">¥{pkg.price}</div>
<div className="card-detail">{pkg.postProcessing}</div>
</>
) : (
<div className="card-unavailable">暂不接单</div>
)}
{pkg.available && (
<div className="qty-control">
<button
className="qty-btn minus"
onClick={() => onQtyChange(qty - 1)}
disabled={qty === 0}
>
−
</button>
<span className="qty-value">{qty}</span>
<button className="qty-btn plus" onClick={() => onQtyChange(qty + 1)}>
+
</button>
</div>
)}
</div>
);
}
export default App;