Init: 初始化最新版本
This commit is contained in:
+328
@@ -0,0 +1,328 @@
|
||||
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_FLOOR = 50;
|
||||
|
||||
function formatMoney(n: number): string {
|
||||
return n % 1 === 0 ? String(n) : n.toFixed(1);
|
||||
}
|
||||
|
||||
/** 定金:默认 50%;若 50% 不足 50 元且订单超过 50 元,则定金为 50 元(且不超过订单总额) */
|
||||
function computeDeposit(finalPrice: number): { amount: number; isFloor50: boolean; half: number } {
|
||||
if (finalPrice <= 0) return { amount: 0, isFloor50: false, half: 0 };
|
||||
const half = finalPrice * DEPOSIT_RATE;
|
||||
if (finalPrice > DEPOSIT_FLOOR && half < DEPOSIT_FLOOR) {
|
||||
return { amount: Math.min(DEPOSIT_FLOOR, finalPrice), isFloor50: true, half };
|
||||
}
|
||||
return { amount: half, isFloor50: 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, isFloor50, 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>
|
||||
<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>
|
||||
定金
|
||||
{isFloor50 ? (
|
||||
<span className="deposit-hint">
|
||||
({DEPOSIT_RATE * 100}% 为 {formatMoney(depositHalf)} 元,不足 ¥{DEPOSIT_FLOOR};订单 > ¥{DEPOSIT_FLOOR} 时收 ¥{DEPOSIT_FLOOR} 定金)
|
||||
</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>
|
||||
{isFloor50 ? (
|
||||
<span>
|
||||
应付 {formatMoney(finalPrice)} × {DEPOSIT_RATE} = {formatMoney(depositHalf)} 元 < ¥{DEPOSIT_FLOOR},且应付 > ¥{DEPOSIT_FLOOR},收{" "}
|
||||
<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_FLOOR} 且订单 > ¥{DEPOSIT_FLOOR} 时,定金为 ¥{DEPOSIT_FLOOR}</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;
|
||||
Reference in New Issue
Block a user