feat(bidding): add initial auction control and display pages
This commit introduces a lightweight, browser-based auction system. It consists of two main components that communicate in real-time using the `BroadcastChannel` API, enabling a serverless front-end experience: - `display.html`: The public-facing screen for attendees. It shows the current item, price with smooth animation, and deal announcements. It automatically generates a unique session ID. - `control.html`: The auctioneer's control panel. It uses the session ID from the display page to connect. It allows for managing the auction flow, including loading items from a CSV, starting bids, updating prices, and finalizing sales.
This commit is contained in:
202
bidding/control.html
Normal file
202
bidding/control.html
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>Auction Control</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: sans-serif;
|
||||||
|
margin: 20px;
|
||||||
|
background: #f5f5f5;
|
||||||
|
}
|
||||||
|
h1 { margin-bottom: 10px; }
|
||||||
|
.section {
|
||||||
|
background: #fff;
|
||||||
|
padding: 15px;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 2px 5px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
input, select, button, textarea {
|
||||||
|
margin: 5px 0;
|
||||||
|
padding: 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
cursor: pointer;
|
||||||
|
background: #007bff;
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
button:hover {
|
||||||
|
background: #0056b3;
|
||||||
|
}
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
th, td {
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
padding: 6px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
th {
|
||||||
|
background: #eee;
|
||||||
|
}
|
||||||
|
.history {
|
||||||
|
max-height: 200px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>🎛 拍卖控制台</h1>
|
||||||
|
<div>当前频道 ID: <b id="displayId"></b></div>
|
||||||
|
|
||||||
|
<!-- 上传 CSV -->
|
||||||
|
<div class="section">
|
||||||
|
<h2>上传标品列表 (Items.csv)</h2>
|
||||||
|
<input type="file" id="csvUpload" accept=".csv">
|
||||||
|
<select id="itemSelect"></select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 自定义输入 -->
|
||||||
|
<div class="section">
|
||||||
|
<h2>标品信息</h2>
|
||||||
|
<label>名称: <input type="text" id="itemName"></label><br>
|
||||||
|
<label>底价: <input type="number" id="itemBasePrice" value="0"></label><br>
|
||||||
|
<label>备注: <input type="text" id="itemRemark"></label><br>
|
||||||
|
<label>图片/影片链接: <input type="text" id="itemMedia"></label><br>
|
||||||
|
<button id="startAuction">开始竞拍</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 出价 -->
|
||||||
|
<div class="section">
|
||||||
|
<h2>竞价控制</h2>
|
||||||
|
<label>当前价格: <input type="number" id="currentPrice" value="0"></label><br>
|
||||||
|
<button id="updatePrice">更新价格</button>
|
||||||
|
<button id="dealItem">成交</button><br>
|
||||||
|
<label>得标者: <input type="text" id="winnerName" placeholder="留空则显示 兴 旺 发"></label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 成交历史 -->
|
||||||
|
<div class="section">
|
||||||
|
<h2>成交历史</h2>
|
||||||
|
<div>总金额: <span id="totalAmount">0</span> 元</div>
|
||||||
|
<div class="history">
|
||||||
|
<table id="historyTable">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>标品名称</th>
|
||||||
|
<th>成交价</th>
|
||||||
|
<th>得标者</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// 获取 displayId
|
||||||
|
const urlParams = new URLSearchParams(location.search);
|
||||||
|
const displayId = urlParams.get("display");
|
||||||
|
if (!displayId) {
|
||||||
|
alert("缺少 display 参数!请从 display.html 获取正确链接");
|
||||||
|
}
|
||||||
|
document.getElementById("displayId").textContent = displayId;
|
||||||
|
|
||||||
|
const channel = new BroadcastChannel("auction-" + displayId);
|
||||||
|
|
||||||
|
const itemSelect = document.getElementById("itemSelect");
|
||||||
|
const itemName = document.getElementById("itemName");
|
||||||
|
const itemBasePrice = document.getElementById("itemBasePrice");
|
||||||
|
const itemRemark = document.getElementById("itemRemark");
|
||||||
|
const itemMedia = document.getElementById("itemMedia");
|
||||||
|
const currentPrice = document.getElementById("currentPrice");
|
||||||
|
const winnerName = document.getElementById("winnerName");
|
||||||
|
const totalAmountEl = document.getElementById("totalAmount");
|
||||||
|
const historyTable = document.getElementById("historyTable").querySelector("tbody");
|
||||||
|
|
||||||
|
let totalAmount = 0;
|
||||||
|
let csvItems = [];
|
||||||
|
|
||||||
|
// 解析 CSV
|
||||||
|
document.getElementById("csvUpload").addEventListener("change", (e) => {
|
||||||
|
const file = e.target.files[0];
|
||||||
|
if (!file) return;
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (ev) => {
|
||||||
|
const lines = ev.target.result.split(/\r?\n/).filter(l => l.trim());
|
||||||
|
csvItems = lines.map(l => {
|
||||||
|
const [name, basePrice, remark, media] = l.split(",");
|
||||||
|
return { name, basePrice: Number(basePrice), remark, media };
|
||||||
|
});
|
||||||
|
refreshItemSelect();
|
||||||
|
};
|
||||||
|
reader.readAsText(file);
|
||||||
|
});
|
||||||
|
|
||||||
|
function refreshItemSelect() {
|
||||||
|
itemSelect.innerHTML = "";
|
||||||
|
csvItems.forEach((it, idx) => {
|
||||||
|
const opt = document.createElement("option");
|
||||||
|
opt.value = idx;
|
||||||
|
opt.textContent = it.name;
|
||||||
|
itemSelect.appendChild(opt);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
itemSelect.addEventListener("change", () => {
|
||||||
|
const idx = itemSelect.value;
|
||||||
|
if (csvItems[idx]) {
|
||||||
|
const it = csvItems[idx];
|
||||||
|
itemName.value = it.name;
|
||||||
|
itemBasePrice.value = it.basePrice;
|
||||||
|
itemRemark.value = it.remark;
|
||||||
|
itemMedia.value = it.media;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 开始竞拍
|
||||||
|
document.getElementById("startAuction").addEventListener("click", () => {
|
||||||
|
const msg = {
|
||||||
|
type: "showItem",
|
||||||
|
name: itemName.value,
|
||||||
|
basePrice: Number(itemBasePrice.value),
|
||||||
|
remark: itemRemark.value,
|
||||||
|
media: itemMedia.value
|
||||||
|
};
|
||||||
|
channel.postMessage(msg);
|
||||||
|
currentPrice.value = itemBasePrice.value;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 更新价格
|
||||||
|
document.getElementById("updatePrice").addEventListener("click", () => {
|
||||||
|
const msg = {
|
||||||
|
type: "updatePrice",
|
||||||
|
price: Number(currentPrice.value)
|
||||||
|
};
|
||||||
|
channel.postMessage(msg);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 成交
|
||||||
|
document.getElementById("dealItem").addEventListener("click", () => {
|
||||||
|
const price = Number(currentPrice.value);
|
||||||
|
const winner = winnerName.value || "";
|
||||||
|
const msg = { type: "deal", price, winner };
|
||||||
|
channel.postMessage(msg);
|
||||||
|
|
||||||
|
// 保存历史
|
||||||
|
const tr = document.createElement("tr");
|
||||||
|
tr.innerHTML = `<td>${itemName.value}</td><td>${price}</td><td>${winner || "兴 旺 发"}</td>`;
|
||||||
|
historyTable.appendChild(tr);
|
||||||
|
|
||||||
|
totalAmount += price;
|
||||||
|
totalAmountEl.textContent = totalAmount;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
189
bidding/display.html
Normal file
189
bidding/display.html
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<title>Auction Display</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: sans-serif;
|
||||||
|
background: #111;
|
||||||
|
color: #fff;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
height: 100vh;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.center {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.splash {
|
||||||
|
font-size: 2rem;
|
||||||
|
}
|
||||||
|
.item img,
|
||||||
|
.item video {
|
||||||
|
max-width: 60vw;
|
||||||
|
max-height: 40vh;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 0 20px rgba(0, 0, 0, 0.6);
|
||||||
|
}
|
||||||
|
.price {
|
||||||
|
font-size: 3rem;
|
||||||
|
color: #ffd700;
|
||||||
|
margin: 1rem 0;
|
||||||
|
transition: color 0.3s;
|
||||||
|
}
|
||||||
|
.deal {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
color: #00ff88;
|
||||||
|
}
|
||||||
|
.footer {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 10px;
|
||||||
|
width: 100%;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
color: #aaa;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<!-- 初次加载提示 -->
|
||||||
|
<div id="guide" class="center">
|
||||||
|
<p>请将此窗口拖曳到大屏幕并按下 <b>F11</b> 进行展示</p>
|
||||||
|
<p>控制端链接: <span id="controlLink"></span></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Splash Screen -->
|
||||||
|
<div id="splash" class="center hidden">
|
||||||
|
<div class="splash">🚀 Studio Name</div>
|
||||||
|
<div><img src="logo.png" alt="Logo" style="max-width: 200px" /></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 标品展示 -->
|
||||||
|
<div id="itemView" class="center hidden">
|
||||||
|
<div class="itemMedia"></div>
|
||||||
|
<div class="name"></div>
|
||||||
|
<div class="remark"></div>
|
||||||
|
<div class="price">¥0</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 成交界面 -->
|
||||||
|
<div id="dealView" class="center hidden">
|
||||||
|
<div class="deal">🎉 成交!</div>
|
||||||
|
<div class="finalPrice"></div>
|
||||||
|
<div class="winner"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 总金额 -->
|
||||||
|
<div class="footer">总金额: <span id="totalAmount">0</span> 元</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// 获取或生成频道 ID
|
||||||
|
function getOrCreateDisplayId() {
|
||||||
|
const urlParams = new URLSearchParams(location.search);
|
||||||
|
let id = urlParams.get("display");
|
||||||
|
if (!id) {
|
||||||
|
id =
|
||||||
|
Math.random().toString(36).slice(2, 12) + Date.now().toString(36);
|
||||||
|
location.href = location.pathname + "?display=" + id;
|
||||||
|
}
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
const displayId = getOrCreateDisplayId();
|
||||||
|
const channel = new BroadcastChannel("auction-" + displayId);
|
||||||
|
|
||||||
|
// DOM 元素
|
||||||
|
const guide = document.getElementById("guide");
|
||||||
|
const controlLink = document.getElementById("controlLink");
|
||||||
|
const splash = document.getElementById("splash");
|
||||||
|
const itemView = document.getElementById("itemView");
|
||||||
|
const dealView = document.getElementById("dealView");
|
||||||
|
const totalAmountEl = document.getElementById("totalAmount");
|
||||||
|
|
||||||
|
const itemMedia = itemView.querySelector(".itemMedia");
|
||||||
|
const itemName = itemView.querySelector(".name");
|
||||||
|
const itemRemark = itemView.querySelector(".remark");
|
||||||
|
const itemPrice = itemView.querySelector(".price");
|
||||||
|
|
||||||
|
const dealPrice = dealView.querySelector(".finalPrice");
|
||||||
|
const dealWinner = dealView.querySelector(".winner");
|
||||||
|
|
||||||
|
let currentPrice = 0;
|
||||||
|
let targetPrice = 0;
|
||||||
|
let totalAmount = 0;
|
||||||
|
|
||||||
|
// 设置控制端链接
|
||||||
|
controlLink.innerText =
|
||||||
|
location.origin + "/control.html?display=" + displayId;
|
||||||
|
|
||||||
|
// 展示 Splash
|
||||||
|
setTimeout(() => {
|
||||||
|
guide.classList.add("hidden");
|
||||||
|
splash.classList.remove("hidden");
|
||||||
|
setTimeout(() => {
|
||||||
|
splash.classList.add("hidden");
|
||||||
|
}, 2000);
|
||||||
|
}, 3000);
|
||||||
|
|
||||||
|
// 动态价格更新 (Lerp)
|
||||||
|
function animatePrice() {
|
||||||
|
if (Math.abs(targetPrice - currentPrice) > 1) {
|
||||||
|
currentPrice += (targetPrice - currentPrice) * 0.1;
|
||||||
|
itemPrice.textContent = "¥" + Math.round(currentPrice);
|
||||||
|
requestAnimationFrame(animatePrice);
|
||||||
|
} else {
|
||||||
|
currentPrice = targetPrice;
|
||||||
|
itemPrice.textContent = "¥" + currentPrice;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 监听消息
|
||||||
|
channel.onmessage = (ev) => {
|
||||||
|
const msg = ev.data;
|
||||||
|
if (msg.type === "showItem") {
|
||||||
|
splash.classList.add("hidden");
|
||||||
|
dealView.classList.add("hidden");
|
||||||
|
itemView.classList.remove("hidden");
|
||||||
|
|
||||||
|
// 更新标品信息
|
||||||
|
itemName.textContent = msg.name;
|
||||||
|
itemRemark.textContent = msg.remark || "";
|
||||||
|
if (msg.media) {
|
||||||
|
if (msg.media.endsWith(".mp4")) {
|
||||||
|
itemMedia.innerHTML = `<video src="${msg.media}" autoplay loop></video>`;
|
||||||
|
} else {
|
||||||
|
itemMedia.innerHTML = `<img src="${msg.media}" alt="">`;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
itemMedia.innerHTML = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
currentPrice = 0;
|
||||||
|
targetPrice = msg.basePrice || 0;
|
||||||
|
animatePrice();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.type === "updatePrice") {
|
||||||
|
targetPrice = msg.price;
|
||||||
|
animatePrice();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.type === "deal") {
|
||||||
|
itemView.classList.add("hidden");
|
||||||
|
dealView.classList.remove("hidden");
|
||||||
|
dealPrice.textContent = "成交价: ¥" + msg.price;
|
||||||
|
dealWinner.textContent = "得标者: " + (msg.winner || "兴 旺 发");
|
||||||
|
totalAmount += msg.price;
|
||||||
|
totalAmountEl.textContent = totalAmount;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user