页面 中等
Hotel Concierge Request Board
A full-screen ops board for the concierge team showing guest requests in Kanban-style lanes (New · In Progress · Done). Interactive: advance requests through statuses, assign to self, filter by priority, and track live lane counts.
在 Lab 中打开
MCP
html css vanilla-js
目标: JS HTML
代码
/* ── Design tokens ─────────────────────────────────────────────────────────── */
:root {
--navy: #1a2b4a;
--navy-d: #0f1d36;
--navy-2: #2d4570;
--cream: #f7f3eb;
--cream-2: #ece5d4;
--bone: #fbf8f2;
--gold: #c9a649;
--gold-light: #e0c378;
--gold-d: #a88a2e;
--ink: #161e2c;
--ink-2: #2e3a52;
--warm-gray: #6c7280;
--line: rgba(22, 30, 44, 0.1);
--line-strong: rgba(22, 30, 44, 0.18);
--success: #4a7752;
--danger: #b34232;
--warning: #d99020;
--info: #4a6da0;
--font-display: "Cormorant Garamond", Georgia, serif;
--font-body: "Inter", system-ui, sans-serif;
--font-mono: "JetBrains Mono", ui-monospace, monospace;
--r-sm: 6px;
--r-md: 10px;
--r-lg: 16px;
--shadow-1: 0 1px 2px rgba(22, 30, 44, 0.06), 0 2px 8px rgba(22, 30, 44, 0.06);
--shadow-2: 0 12px 36px rgba(15, 29, 54, 0.16);
}
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html,
body {
height: 100%;
overflow: hidden;
}
body {
font-family: var(--font-body);
background: var(--cream);
color: var(--ink);
-webkit-font-smoothing: antialiased;
}
/* ── Layout ─────────────────────────────────────────────────────────────────── */
.app {
height: 100vh;
display: grid;
grid-template-columns: 230px 1fr;
}
/* ── Rail ───────────────────────────────────────────────────────────────────── */
.rail {
background: var(--navy);
color: var(--bone);
display: flex;
flex-direction: column;
padding: 22px 16px 14px;
}
.brand {
display: flex;
align-items: center;
gap: 12px;
padding: 0 4px 18px;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.brand-mark {
width: 38px;
height: 38px;
display: grid;
place-items: center;
border-radius: var(--r-md);
background: var(--gold);
color: var(--navy-d);
font-family: var(--font-display);
font-weight: 700;
font-size: 1.35rem;
flex-shrink: 0;
}
.brand-name {
font-family: var(--font-display);
font-size: 1.1rem;
font-weight: 700;
letter-spacing: 0.01em;
}
.brand-prop {
font-size: 0.7rem;
color: var(--gold-light);
letter-spacing: 0.08em;
text-transform: uppercase;
margin-top: 2px;
}
.nav {
margin-top: 18px;
display: flex;
flex-direction: column;
gap: 2px;
flex: 1;
}
.nav-item {
font-size: 0.86rem;
font-weight: 500;
color: rgba(251, 248, 242, 0.78);
text-decoration: none;
padding: 10px 12px;
border-radius: var(--r-md);
display: flex;
align-items: center;
gap: 10px;
}
.nav-item:hover {
background: rgba(255, 255, 255, 0.06);
color: var(--bone);
}
.nav-item.is-active {
background: rgba(201, 166, 73, 0.16);
color: var(--gold-light);
font-weight: 600;
}
.nav-dot {
width: 6px;
height: 6px;
border-radius: 999px;
background: var(--gold);
}
.nav-item:not(.is-active) .nav-dot {
display: none;
}
.rail-actions {
display: flex;
flex-direction: column;
gap: 6px;
margin-bottom: 14px;
}
.quick-btn {
background: var(--gold);
color: var(--navy-d);
border: none;
font-family: inherit;
font-weight: 600;
font-size: 0.84rem;
padding: 10px 12px;
border-radius: var(--r-md);
cursor: pointer;
text-align: left;
}
.quick-btn:hover {
background: var(--gold-light);
}
.rail-foot {
display: flex;
flex-direction: column;
gap: 2px;
padding-top: 12px;
border-top: 1px solid rgba(255, 255, 255, 0.08);
}
.clock {
font-family: var(--font-mono);
font-weight: 600;
font-size: 0.88rem;
color: var(--gold-light);
}
.agent {
font-size: 0.72rem;
color: rgba(251, 248, 242, 0.6);
letter-spacing: 0.04em;
}
/* ── Main ───────────────────────────────────────────────────────────────────── */
.main {
display: flex;
flex-direction: column;
overflow: hidden;
background: var(--cream);
}
.topbar {
display: flex;
justify-content: space-between;
align-items: flex-end;
gap: 20px;
padding: 22px 28px 16px;
border-bottom: 1px solid var(--line);
background: var(--cream);
flex-shrink: 0;
}
.kicker {
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.18em;
color: var(--gold-d);
font-weight: 600;
}
.topbar h1 {
font-family: var(--font-display);
font-weight: 700;
font-size: 1.95rem;
letter-spacing: -0.005em;
margin-top: 2px;
}
.topbar h1 span {
color: var(--navy-2);
font-weight: 500;
}
.topbar-right {
display: flex;
align-items: center;
gap: 14px;
}
.filter-group {
display: flex;
align-items: center;
gap: 6px;
}
.filter-label {
font-size: 0.72rem;
font-weight: 600;
color: var(--warm-gray);
text-transform: uppercase;
letter-spacing: 0.1em;
margin-right: 2px;
}
.chip {
background: transparent;
border: 1px solid var(--line-strong);
font-family: inherit;
font-size: 0.76rem;
font-weight: 600;
color: var(--ink-2);
padding: 6px 12px;
border-radius: 999px;
cursor: pointer;
transition: background 0.12s, color 0.12s, border-color 0.12s;
}
.chip:hover {
background: var(--cream-2);
}
.chip.is-active {
background: var(--gold);
color: var(--navy-d);
border-color: var(--gold);
}
/* ── Board (kanban) ─────────────────────────────────────────────────────────── */
.board {
flex: 1;
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0;
overflow: hidden;
}
.lane {
display: flex;
flex-direction: column;
overflow: hidden;
border-right: 1px solid var(--line);
}
.lane:last-child {
border-right: none;
}
.lane-head {
padding: 14px 18px 12px;
border-bottom: 1px solid var(--line);
display: flex;
align-items: center;
justify-content: space-between;
background: var(--bone);
flex-shrink: 0;
}
.lane-title {
display: flex;
align-items: center;
gap: 8px;
font-size: 0.78rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.1em;
}
.lane-dot {
width: 8px;
height: 8px;
border-radius: 999px;
}
.lane[data-lane="new"] .lane-dot {
background: var(--info);
}
.lane[data-lane="inprogress"] .lane-dot {
background: var(--warning);
}
.lane[data-lane="done"] .lane-dot {
background: var(--success);
}
.lane-count {
font-family: var(--font-mono);
font-size: 0.76rem;
font-weight: 700;
background: var(--cream);
color: var(--ink-2);
padding: 3px 9px;
border-radius: 999px;
border: 1px solid var(--line);
}
.lane-body {
flex: 1;
overflow-y: auto;
padding: 12px 14px;
display: flex;
flex-direction: column;
gap: 10px;
}
.lane-empty {
color: var(--warm-gray);
font-size: 0.84rem;
font-style: italic;
text-align: center;
padding: 32px 8px;
}
/* ── Request card ───────────────────────────────────────────────────────────── */
.card {
background: var(--bone);
border: 1px solid var(--line);
border-radius: var(--r-md);
padding: 14px 14px 10px;
box-shadow: var(--shadow-1);
display: flex;
flex-direction: column;
gap: 8px;
transition: box-shadow 0.12s;
}
.card:hover {
box-shadow: 0 4px 16px rgba(22, 30, 44, 0.1);
}
.card-top {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 6px;
}
.card-request {
font-weight: 600;
font-size: 0.9rem;
color: var(--ink);
line-height: 1.35;
}
.pri {
font-size: 0.62rem;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
padding: 3px 8px;
border-radius: 999px;
white-space: nowrap;
flex-shrink: 0;
}
.pri.high {
background: rgba(179, 66, 50, 0.14);
color: var(--danger);
}
.pri.normal {
background: rgba(74, 109, 160, 0.14);
color: var(--info);
}
.pri.low {
background: rgba(74, 119, 82, 0.12);
color: var(--success);
}
.card-meta {
display: flex;
align-items: center;
gap: 6px;
font-size: 0.76rem;
color: var(--warm-gray);
}
.card-room {
font-family: var(--font-mono);
font-weight: 700;
font-size: 0.78rem;
color: var(--navy-d);
background: var(--cream-2);
padding: 2px 7px;
border-radius: var(--r-sm);
}
.card-guest {
font-weight: 500;
color: var(--ink-2);
}
.card-sep {
color: var(--line-strong);
}
.card-time {
font-family: var(--font-mono);
font-size: 0.72rem;
color: var(--warm-gray);
font-variant-numeric: tabular-nums;
}
.card-assignee {
font-size: 0.72rem;
color: var(--warm-gray);
font-style: italic;
}
.card-assignee span {
color: var(--navy-2);
font-style: normal;
font-weight: 600;
}
.card-actions {
display: flex;
gap: 6px;
margin-top: 2px;
border-top: 1px solid var(--line);
padding-top: 8px;
}
.card-btn {
background: transparent;
border: 1px solid var(--line-strong);
font-family: inherit;
font-size: 0.74rem;
font-weight: 600;
color: var(--ink-2);
padding: 5px 10px;
border-radius: var(--r-sm);
cursor: pointer;
transition: background 0.1s, border-color 0.1s;
white-space: nowrap;
}
.card-btn:hover {
background: var(--cream-2);
border-color: var(--navy-2);
color: var(--navy-d);
}
.card-btn.advance {
background: var(--navy);
color: var(--bone);
border-color: var(--navy);
margin-left: auto;
}
.card-btn.advance:hover {
background: var(--navy-d);
}
.card-btn.assign {
color: var(--gold-d);
border-color: rgba(201, 166, 73, 0.4);
}
.card-btn.assign:hover {
background: rgba(201, 166, 73, 0.1);
}
/* done lane — muted */
.lane[data-lane="done"] .card {
opacity: 0.72;
}
.lane[data-lane="done"] .card:hover {
opacity: 1;
}
/* ── Toast ───────────────────────────────────────────────────────────────────── */
.toast {
position: fixed;
bottom: 24px;
left: 50%;
transform: translateX(-50%);
background: var(--navy-d);
color: var(--bone);
padding: 11px 22px;
border-radius: 999px;
font-size: 0.86rem;
font-weight: 600;
box-shadow: var(--shadow-2);
z-index: 100;
white-space: nowrap;
}
/* ── Responsive ─────────────────────────────────────────────────────────────── */
@media (max-width: 1100px) {
.app {
grid-template-columns: 200px 1fr;
}
}
@media (max-width: 960px) {
html,
body {
overflow: auto;
}
.app {
grid-template-columns: 1fr;
height: auto;
}
.rail {
flex-direction: row;
align-items: center;
flex-wrap: wrap;
gap: 10px;
padding: 14px 16px;
}
.nav {
display: none;
}
.rail-foot {
display: none;
}
.main {
overflow: visible;
}
.board {
grid-template-columns: 1fr;
overflow: visible;
height: auto;
}
.lane {
overflow: visible;
border-right: none;
border-bottom: 1px solid var(--line);
}
.lane-body {
overflow: visible;
max-height: none;
}
}
@media (max-width: 560px) {
.topbar {
flex-direction: column;
align-items: flex-start;
gap: 12px;
}
.filter-group {
flex-wrap: wrap;
}
}// ── Mock data ─────────────────────────────────────────────────────────────────
let requests = [
{
id: 1,
room: "204",
guest: "Mariana Sosa",
request: "Restaurant booking — Fado Maior at 20:00 on 9 Jun",
time: "08:14",
priority: "high",
status: "new",
assignee: null,
},
{
id: 2,
room: "311",
guest: "Thomas Reuter",
request: "Taxi to Humberto Delgado Airport — 10 Jun, 06:30",
time: "08:27",
priority: "high",
status: "new",
assignee: null,
},
{
id: 3,
room: "118",
guest: "Aiko Tanaka",
request: "Theatre tickets — Fado Ao Vivo, 2 seats, 10 Jun",
time: "08:51",
priority: "normal",
status: "new",
assignee: null,
},
{
id: 4,
room: "207",
guest: "Olivier Banks",
request: "Wake-up call at 05:45 on 10 Jun",
time: "09:02",
priority: "normal",
status: "new",
assignee: null,
},
{
id: 5,
room: "302",
guest: "Fátima Cervantes",
request: "Luggage storage after checkout until 18:00",
time: "09:15",
priority: "low",
status: "new",
assignee: null,
},
{
id: 6,
room: "405",
guest: "Carlos Mendes",
request: "Restaurant booking — Solar dos Presuntos, 9 Jun",
time: "07:58",
priority: "high",
status: "inprogress",
assignee: "Paulo",
},
{
id: 7,
room: "219",
guest: "Elena Vasquez",
request: "Taxi to Belém Cultural Centre — 9 Jun, 14:00",
time: "08:05",
priority: "normal",
status: "inprogress",
assignee: "Sofia",
},
{
id: 8,
room: "103",
guest: "Hassan Najjar",
request: "Extra towels and pillow to room",
time: "08:33",
priority: "low",
status: "inprogress",
assignee: null,
},
{
id: 9,
room: "315",
guest: "Pilar Romero",
request: "Wake-up call at 07:00 on 9 Jun — confirmed",
time: "07:44",
priority: "normal",
status: "done",
assignee: "Paulo",
},
{
id: 10,
room: "108",
guest: "Ruiqi Chen",
request: "Theatre tickets — Teatro Nacional, 8 Jun — booked",
time: "07:22",
priority: "high",
status: "done",
assignee: "Sofia",
},
{
id: 11,
room: "221",
guest: "Marc Dupuis",
request: "Luggage delivered to room on arrival",
time: "07:10",
priority: "low",
status: "done",
assignee: "Paulo",
},
];
let nextId = 12;
let activePriority = "all";
const LANE_LABELS = { new: "New", inprogress: "In Progress", done: "Done" };
const STATUS_ORDER = ["new", "inprogress", "done"];
// ── Toast helper ──────────────────────────────────────────────────────────────
const toast = document.getElementById("toast");
function showToast(msg) {
toast.textContent = msg;
toast.hidden = false;
clearTimeout(showToast._t);
showToast._t = setTimeout(() => (toast.hidden = true), 2200);
}
// ── Render board ──────────────────────────────────────────────────────────────
function renderBoard() {
const board = document.getElementById("board");
const filtered =
activePriority === "all" ? requests : requests.filter((r) => r.priority === activePriority);
board.innerHTML = STATUS_ORDER.map((lane) => {
const cards = filtered.filter((r) => r.status === lane);
const allInLane = requests.filter((r) => r.status === lane);
return `
<div class="lane" data-lane="${lane}">
<div class="lane-head">
<span class="lane-title">
<span class="lane-dot"></span>
${LANE_LABELS[lane]}
</span>
<span class="lane-count">${allInLane.length}</span>
</div>
<div class="lane-body">
${
cards.length === 0
? `<p class="lane-empty">No requests${activePriority !== "all" ? " for this filter" : ""}</p>`
: cards.map((card) => renderCard(card, lane)).join("")
}
</div>
</div>`;
}).join("");
// ── Wire card buttons ──────────────────────────────────────────────────────
board.addEventListener("click", handleCardAction);
}
function renderCard(r, lane) {
const canAdvance = lane !== "done";
const advanceLabel = lane === "new" ? "→ In Progress" : "→ Done";
const assignLabel = r.assignee ? `Assigned: ${r.assignee}` : "Assign to me";
return `
<div class="card" data-id="${r.id}">
<div class="card-top">
<div class="card-request">${r.request}</div>
<span class="pri ${r.priority}">${r.priority}</span>
</div>
<div class="card-meta">
<span class="card-room">${r.room}</span>
<span class="card-sep">·</span>
<span class="card-guest">${r.guest}</span>
<span class="card-sep">·</span>
<span class="card-time">${r.time}</span>
</div>
${
r.assignee
? `<div class="card-assignee">Assigned to <span>${r.assignee}</span></div>`
: `<div class="card-assignee">Unassigned</div>`
}
<div class="card-actions">
<button class="card-btn assign" data-action="assign" data-id="${r.id}">
${r.assignee ? "✓ " + r.assignee : "Assign to me"}
</button>
${
canAdvance
? `<button class="card-btn advance" data-action="advance" data-id="${r.id}">${advanceLabel}</button>`
: `<span class="card-btn" style="margin-left:auto;opacity:0.45;cursor:default">✓ Done</span>`
}
</div>
</div>`;
}
// ── Card action handler (event delegation) ────────────────────────────────────
function handleCardAction(e) {
// Guard: don't double-fire from re-renders adding new listeners
if (e._handled) return;
e._handled = true;
const btn = e.target.closest("button[data-action]");
if (!btn) return;
const id = parseInt(btn.dataset.id, 10);
const req = requests.find((r) => r.id === id);
if (!req) return;
if (btn.dataset.action === "advance") {
const idx = STATUS_ORDER.indexOf(req.status);
if (idx < STATUS_ORDER.length - 1) {
req.status = STATUS_ORDER[idx + 1];
showToast(`Request #${id} moved to "${LANE_LABELS[req.status]}"`);
renderBoard();
}
}
if (btn.dataset.action === "assign") {
if (!req.assignee) {
req.assignee = "Paulo";
showToast(`Assigned to Paulo — room ${req.room}`);
} else {
req.assignee = null;
showToast(`Assignment removed — room ${req.room}`);
}
renderBoard();
}
}
// ── Priority filter ───────────────────────────────────────────────────────────
document.querySelectorAll(".chip[data-pri]").forEach((btn) => {
btn.addEventListener("click", () => {
document.querySelectorAll(".chip[data-pri]").forEach((b) => b.classList.remove("is-active"));
btn.classList.add("is-active");
activePriority = btn.dataset.pri;
renderBoard();
});
});
// ── New request button (adds demo card) ───────────────────────────────────────
const REQUEST_TYPES = [
"Restaurant booking — Bica do Sapato, tonight 21:00",
"Taxi to Parque das Nações — 11 Jun, 09:00",
"Extra pillows and blanket for room",
"Wake-up call at 06:30 on 12 Jun",
"Luggage storage — 2 bags until 17:00",
];
const ROOMS = ["101", "112", "223", "317", "408", "205", "316"];
const GUESTS = ["Sofia Bellini", "Karl Henriksen", "Yuki Mori", "Ana Costa", "Luca Ferrari"];
const PRIS = ["high", "normal", "normal", "low"];
document.getElementById("btnNewRequest").addEventListener("click", () => {
const r = {
id: nextId++,
room: ROOMS[Math.floor(Math.random() * ROOMS.length)],
guest: GUESTS[Math.floor(Math.random() * GUESTS.length)],
request: REQUEST_TYPES[Math.floor(Math.random() * REQUEST_TYPES.length)],
time: new Date().toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit" }),
priority: PRIS[Math.floor(Math.random() * PRIS.length)],
status: "new",
assignee: null,
};
requests.unshift(r);
showToast(`New request added — room ${r.room}`);
renderBoard();
});
// ── Clock + date ──────────────────────────────────────────────────────────────
const clockEl = document.getElementById("clock");
const todayEl = document.getElementById("todayLabel");
function tick() {
const now = new Date();
clockEl.textContent = now.toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit" });
todayEl.textContent = now.toLocaleDateString("en-GB", {
weekday: "long",
day: "numeric",
month: "long",
});
}
tick();
setInterval(tick, 1000);
// ── Initial render ────────────────────────────────────────────────────────────
renderBoard();<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:wght@600;700&family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@500;700&display=swap"
/>
<link rel="stylesheet" href="style.css" />
<title>Concierge Board · Aurelia Hotels</title>
</head>
<body>
<div class="app">
<!-- ── Rail ── -->
<aside class="rail">
<div class="brand">
<span class="brand-mark">Æ</span>
<div>
<p class="brand-name">Aurelia Hotels</p>
<p class="brand-prop">Aurelia · Lisbon</p>
</div>
</div>
<nav class="nav">
<a class="nav-item" href="#">Dashboard</a>
<a class="nav-item" href="#">Reservations</a>
<a class="nav-item is-active" href="#"><span class="nav-dot"></span>Concierge</a>
<a class="nav-item" href="#">Housekeeping</a>
<a class="nav-item" href="#">Maintenance</a>
<a class="nav-item" href="#">Reports</a>
</nav>
<div class="rail-actions">
<button class="quick-btn" id="btnNewRequest">+ New request</button>
</div>
<footer class="rail-foot">
<span class="clock" id="clock">--:--</span>
<span class="agent">Concierge · Paulo</span>
</footer>
</aside>
<!-- ── Main ── -->
<section class="main">
<header class="topbar">
<div>
<p class="kicker">Concierge Operations</p>
<h1>Request Board · <span id="todayLabel">—</span></h1>
</div>
<div class="topbar-right">
<div class="filter-group">
<span class="filter-label">Priority</span>
<button class="chip is-active" data-pri="all">All</button>
<button class="chip" data-pri="high">High</button>
<button class="chip" data-pri="normal">Normal</button>
<button class="chip" data-pri="low">Low</button>
</div>
</div>
</header>
<!-- ── Board ── -->
<div class="board" id="board">
<!-- lanes rendered by JS -->
</div>
</section>
</div>
<div class="toast" id="toast" hidden role="status" aria-live="polite"></div>
<script src="script.js"></script>
</body>
</html>Hotel Concierge Request Board
A full-screen concierge operations board for Aurelia Hotels, displaying guest requests across three Kanban-style lanes — New, In Progress, and Done. Each card shows the room number, guest name, request type (restaurant booking, taxi, theatre tickets, wake-up call, luggage), submission time, and priority badge. Staff can advance a request forward through the workflow with the → Advance button, assign it to themselves, and filter the board by priority level. Live per-lane counts update instantly as cards move.