Add business intelligence big screen template

This commit is contained in:
Codex
2026-08-07 15:01:58 +08:00
parent 4511997d6d
commit bd4d54a110
6 changed files with 211 additions and 9 deletions

View File

@@ -0,0 +1,14 @@
.screen{--bg:#07111f;--panel:#0c1b2d;--panel2:#0f2238;--line:#1d3a57;--lineBright:#2e668e;--text:#dcecff;--muted:#7791aa;--cyan:#28d7e5;--blue:#3f8cff;--purple:#8b6cf1;--green:#35c98a;--orange:#f1a33c;min-width:1180px;min-height:100vh;padding:12px 16px 16px;overflow:hidden;background:var(--bg);color:var(--text);font-family:"DIN Alternate","Microsoft YaHei",system-ui,sans-serif}
.header{height:64px;display:grid;grid-template-columns:1fr 1.35fr 1fr;align-items:center;gap:18px;border-bottom:1px solid #16324d}.headerLeft{display:flex;align-items:center;gap:7px;color:#7791aa;font-size:10px}.headerLeft a{width:27px;height:27px;display:grid;place-items:center;border:1px solid #244762;color:#8eb6d5;text-decoration:none}.headerLeft i{width:6px;height:6px;margin-left:4px;border-radius:50%;background:#53697e}.headerLeft i.online{background:var(--green);box-shadow:0 0 10px rgba(53,201,138,.6)}.headerLeft small{font-size:9px}.titleBlock{position:relative;text-align:center}.titleBlock h1{display:inline-block;margin:0 16px;color:#eaf6ff;font-size:24px;letter-spacing:3px;text-shadow:0 0 16px rgba(40,215,229,.25)}.titleBlock p{margin:4px 0 0;color:#4f7897;font-size:8px;letter-spacing:3px}.titleLine{display:inline-block;width:54px;height:1px;background:#2d668d;vertical-align:middle}.titleLine::after{content:"";display:block;width:6px;height:6px;margin-top:-3px;border:1px solid #3d88ad;transform:rotate(45deg)}.titleLine:nth-child(3)::after{margin-left:auto}.headerRight{display:flex;align-items:center;justify-content:flex-end;gap:6px}.headerRight button{height:28px;padding:0 9px;border:1px solid #244761;background:#0c1b2d;color:#86a5bd;cursor:pointer;font-size:8px}.headerRight>div{display:flex;flex-direction:column;align-items:flex-end;gap:2px;margin-left:7px}.headerRight strong{color:#d8edff;font-size:16px;letter-spacing:1px}.headerRight small{color:#68849b;font-size:7px}
.kpiRow{height:100px;display:grid;grid-template-columns:repeat(5,1fr);gap:10px;padding:10px 0}.kpiRow article{position:relative;display:flex;align-items:center;gap:12px;padding:10px 13px;border:1px solid #163650;background:#0b192a;overflow:hidden}.kpiIcon{width:39px;height:39px;display:grid;place-items:center;flex:0 0 auto;border:1px solid currentColor;background:#10263b;font-size:13px;font-weight:800}.cyan{color:var(--cyan)!important;background:#0c2a35!important}.blue{color:var(--blue)!important;background:#102646!important}.purple{color:var(--purple)!important;background:#211d3f!important}.green{color:var(--green)!important;background:#102d29!important}.orange{color:var(--orange)!important;background:#31271d!important}.kpiRow article>div{display:flex;flex-direction:column;gap:2px;min-width:0}.kpiRow p{margin:0;color:#7190aa;font-size:8px}.kpiRow strong{overflow:hidden;color:#e8f4ff;font-size:18px;letter-spacing:.5px;text-overflow:ellipsis;white-space:nowrap}.kpiRow small{color:#607f98;font-size:7px}.kpiRow small b{margin-right:4px;color:#35c98a}.kpiMark{position:absolute;right:-18px;bottom:-24px;width:58px;height:58px;border:1px solid #173956;transform:rotate(45deg)}
.dashboardGrid{height:calc(100vh - 64px - 100px - 176px);min-height:480px;display:grid;grid-template-columns:25% 48% 27%;gap:10px}.leftColumn,.centerColumn,.rightColumn{min-width:0;display:grid;grid-template-rows:1fr 1fr;gap:10px}.centerColumn{grid-template-rows:1.34fr .66fr}.panel{position:relative;min-height:0;display:flex;flex-direction:column;border:1px solid #183955;background:#0b192a;box-shadow:inset 0 0 24px rgba(6,15,27,.2)}.panel::before,.panel::after{content:"";position:absolute;width:13px;height:13px;border-color:#3b789b;pointer-events:none}.panel::before{top:-1px;left:-1px;border-top:2px solid #3b789b;border-left:2px solid #3b789b}.panel::after{right:-1px;bottom:-1px;border-right:2px solid #3b789b;border-bottom:2px solid #3b789b}.panel>header{height:36px;display:flex;align-items:center;justify-content:space-between;padding:0 11px;border-bottom:1px solid #15344f;background:#0d1e31}.panel>header>div:first-child{display:flex;align-items:center;gap:7px}.panel>header i{width:3px;height:13px;background:var(--cyan)}.panel h2{margin:0;color:#cfe5f6;font-size:10px;letter-spacing:.4px}.panel>header small{color:#496b85;font-size:6px;letter-spacing:.7px}.panelBody{min-height:0;flex:1;overflow:hidden}.liveBadge,.alertCount,.updateText,.deltaGood{color:#5f859f;font-size:7px}.liveBadge::before{content:"";display:inline-block;width:5px;height:5px;margin-right:5px;border-radius:50%;background:var(--cyan)}.deltaGood{color:#35c98a}.panelButton{height:23px;padding:0 7px;border:1px solid #26506c;background:#10253a;color:#7fa4bd;font-size:7px}.legend{display:flex;gap:9px;color:#6c8ba3;font-size:7px}.legend span{display:flex;align-items:center;gap:4px}.legend i{width:7px;height:7px;border-radius:1px}.cyanDot{background:var(--cyan)!important}.blueDot{background:var(--blue)!important}
.channelChart{height:100%;display:grid;grid-template-columns:38% 62%;align-items:center;padding:9px}.channelTotal{position:relative;width:108px;height:108px;display:flex;flex-direction:column;align-items:center;justify-content:center;margin:auto;border:10px solid #17354d;border-top-color:var(--cyan);border-right-color:var(--blue);border-radius:50%}.channelTotal::after{content:"";position:absolute;inset:6px;border:1px solid #27536d;border-radius:50%}.channelTotal span,.channelTotal small{position:relative;z-index:2;color:#64839c;font-size:7px}.channelTotal strong{position:relative;z-index:2;color:#e7f5ff;font-size:18px}.channelTotal i{position:absolute;right:-7px;top:9px;width:7px;height:7px;border:2px solid #0b192a;border-radius:50%;background:var(--cyan)}.channelList{display:flex;flex-direction:column;gap:7px}.channelList>div{position:relative}.channelList>div>div{display:flex;align-items:center;gap:5px;margin-bottom:3px}.legendDot{width:6px;height:6px;border-radius:1px}.channelList strong{flex:1;color:#9eb7ca;font-size:7px;font-weight:500}.channelList b{color:#cce3f4;font-size:7px}.track{width:calc(100% - 30px);height:3px;display:block;background:#173047}.track i{display:block;height:100%}.channelList>div>small{position:absolute;right:0;bottom:-1px;color:#5f819a;font-size:6px}
.funnel{height:100%;display:flex;flex-direction:column;justify-content:center;gap:4px;padding:8px 15px}.funnelRow{position:relative;display:flex;align-items:center;justify-content:center;min-height:30px}.funnelRow>span{position:absolute;left:0;width:17px;height:17px;display:grid;place-items:center;border:1px solid #285474;color:#6f99b6;font-size:6px}.funnelBar{height:28px;display:flex;align-items:center;justify-content:space-between;padding:0 8px;border-left:2px solid var(--cyan);background:#102a40;clip-path:polygon(6% 0,94% 0,100% 100%,0 100%)}.funnelBar strong{color:#9ec2d8;font-size:7px}.funnelBar b{color:#e3f2fc;font-size:8px}.funnelRow>small{position:absolute;right:0;color:#5d829b;font-size:7px}.funnelRow>i{position:absolute;bottom:-7px;color:#2d5c7a;font-size:8px;font-style:normal}
.trendPanel .panelBody{padding:4px 9px 7px}.trendChart{height:100%;display:grid;grid-template-columns:35px 1fr;padding-top:6px}.axisLabels{display:flex;flex-direction:column;justify-content:space-between;padding:0 5px 18px 0;color:#496c85;font-size:6px;text-align:right}.plot{position:relative;min-width:0}.gridLines{position:absolute;inset:0 0 18px;display:flex;flex-direction:column;justify-content:space-between}.gridLines i{height:1px;background:#17334a}.bars{position:absolute;inset:0 0 0;display:flex;align-items:flex-end;justify-content:space-around;padding-bottom:18px}.bars>div{position:relative;width:6%;height:100%;display:flex;align-items:flex-end;justify-content:center}.bars>div>span{width:65%;display:block;background:#14436a;border-top:2px solid var(--cyan)}.bars small{position:absolute;bottom:-15px;color:#496b83;font-size:5px;white-space:nowrap}.lineLayer{position:absolute;inset:0 2.8% 18px}.point{position:absolute;width:7px;height:7px;margin:-3px 0 0 -3px;border:2px solid #081321;border-radius:50%;background:var(--blue);box-shadow:0 0 8px rgba(63,140,255,.55)}.point>span{display:none;position:absolute;bottom:10px;left:50%;z-index:3;padding:4px 6px;border:1px solid #295676;background:#10253a;color:#bbd7e9;font-size:6px;white-space:nowrap;transform:translateX(-50%)}.point:hover>span{display:block}.point>b{position:absolute;top:2px;left:3px;height:2px;background:var(--blue);transform:rotate(-7deg);transform-origin:left center}
.orderPanel .panelBody{padding:0 8px}.orderTable{height:100%;display:flex;flex-direction:column}.orderHead,.orderRow{display:grid;grid-template-columns:1.1fr .6fr 1.1fr .9fr .7fr .8fr;align-items:center;gap:5px}.orderHead{height:24px;color:#55748d;font-size:6px}.orderRow{min-height:24px;border-top:1px solid #142f47;color:#718da3;font-size:6px}.orderRow strong{color:#99b4c8;font-weight:500}.orderRow b{color:#d3e7f5}.orderRow em{width:fit-content;padding:2px 4px;border-radius:2px;font-size:5px;font-style:normal}.paid{background:#12352f;color:#49ca93}.pending{background:#392b1b;color:#e9a145}.orderRow time{color:#55758e}
.metricSwitch{display:flex;border:1px solid #214760}.metricSwitch button{height:21px;padding:0 7px;border:0;background:#0b192a;color:#5e7f98;font-size:6px}.metricSwitch button.active{background:#1b5276;color:#bce5f4}.ranking{height:100%;display:flex;flex-direction:column;justify-content:space-around;padding:7px 11px}.ranking>div{display:grid;grid-template-columns:18px 30px 1fr 35px;align-items:center;gap:6px;min-height:26px}.rank{width:15px;height:15px;display:grid;place-items:center;border:1px solid #28516d;color:#64869f;font-size:6px}.rank1{border-color:#e9ad47;color:#f3c56f}.rank2{border-color:#7798ad;color:#a8c2d3}.rank3{border-color:#aa7349;color:#ce9368}.ranking>div>strong{color:#8eabc0;font-size:7px}.ranking>div>div{display:grid;grid-template-columns:1fr 42px;align-items:center;gap:5px}.ranking>div>div>span{height:4px;display:block;background:#173149}.ranking>div>div i{display:block;height:100%;background:var(--blue)}.ranking small{color:#9ab5c8;font-size:6px}.ranking>div>b{color:#35c98a;font-size:6px;text-align:right}
.alertList{height:100%;display:flex;flex-direction:column;justify-content:space-around;padding:5px 9px}.alertList article{display:flex;align-items:center;gap:8px;min-height:39px;padding:4px 0;border-bottom:1px solid #142e45}.alertList article:last-child{border-bottom:0}.alertIcon{width:25px;height:25px;display:grid;place-items:center;flex:0 0 auto;border:1px solid;border-radius:50%;font-size:8px;font-weight:700}.alert_high{border-color:#c74e4e;color:#f16d6d;background:#321b21}.alert_medium{border-color:#ba7932;color:#efaa57;background:#31251b}.alert_low{border-color:#7964c5;color:#a38ced;background:#221f3a}.alert_info{border-color:#267d73;color:#48c7b8;background:#122e31}.alertList article>div{min-width:0;flex:1}.alertList strong{display:block;overflow:hidden;color:#9eb6c8;font-size:7px;text-overflow:ellipsis;white-space:nowrap}.alertList p{margin:3px 0 0;color:#5d7a91;font-size:6px}.alertList time{color:#4e7088;font-size:6px}
.bottomPanel{height:166px;padding-top:10px}.bottomPanel>.panel{height:100%}.bottomPanel .panelBody{padding:7px 12px}.categories{height:100%;display:grid;grid-template-columns:repeat(5,1fr);gap:15px}.categories article{display:flex;flex-direction:column;justify-content:center}.categories article>div:first-child{display:flex;align-items:center;gap:7px}.categories article>div:first-child>span{color:#315b78;font-size:12px}.categories article>div>div{flex:1;display:flex;flex-direction:column;gap:2px}.categories strong{color:#a7c1d4;font-size:8px}.categories small{color:#57778f;font-size:6px}.categories article>div>b{color:#e5f3fc;font-size:13px}.categories article>div>b.targetMet{color:var(--green)}.targetTrack{position:relative;height:5px;margin:12px 0 13px;background:#173149}.targetTrack>span{display:block;height:100%;background:var(--cyan)}.targetTrack>i{position:absolute;top:-4px;width:1px;height:13px;background:#f1a33c}.targetTrack>small{position:absolute;top:10px;color:#6e879a;font-size:5px;transform:translateX(-50%)}
.toast{position:fixed;right:18px;bottom:17px;z-index:90;display:flex;align-items:center;gap:7px;padding:9px 12px;border:1px solid #28516d;background:#10253a;color:#aed1e5;font-size:7px}.toast span{width:17px;height:17px;display:grid;place-items:center;border:1px solid #2d9970;border-radius:50%;color:#49ca93}
@media(max-width:1450px){.screen{padding:8px 10px 10px}.header{height:58px}.kpiRow{height:90px}.dashboardGrid{height:calc(100vh - 58px - 90px - 156px)}.bottomPanel{height:146px}.titleBlock h1{font-size:20px}.kpiRow strong{font-size:15px}.channelTotal{width:92px;height:92px}.panel>header{height:32px}}
@media(prefers-reduced-motion:reduce){.online{box-shadow:none!important}}

7
app/bigscreen/README.md Normal file
View File

@@ -0,0 +1,7 @@
# 数据大屏模板
访问 `/bigscreen` 查看 16:9 经营数据大屏。所有展示数据来自 `app/data/big-screen.json`
大屏包含核心指标、分时成交趋势、渠道贡献、转化漏斗、区域排名、实时订单、经营告警和品类目标达成。页面提供实时时钟、刷新状态和浏览器全屏切换。
接入真实业务数据时,可保留现有数据结构,将 JSON 导入替换为接口或实时消息数据源。

116
app/bigscreen/page.tsx Normal file
View File

@@ -0,0 +1,116 @@
"use client";
import Link from "next/link";
import { useEffect, useMemo, useState } from "react";
import data from "../data/big-screen.json";
import styles from "./BigScreen.module.css";
export default function BigScreenPage() {
const [now, setNow] = useState(new Date());
const [autoRefresh, setAutoRefresh] = useState(true);
const [regionMetric, setRegionMetric] = useState<"amount" | "orders">("amount");
const [toast, setToast] = useState("");
useEffect(() => {
const timer = window.setInterval(() => setNow(new Date()), 1000);
return () => window.clearInterval(timer);
}, []);
const notify = (message: string) => {
setToast(message);
window.setTimeout(() => setToast(""), 2200);
};
const enterFullscreen = async () => {
try {
if (!document.fullscreenElement) await document.documentElement.requestFullscreen();
else await document.exitFullscreen();
} catch { notify("当前预览环境不支持全屏切换"); }
};
return (
<main className={styles.screen}>
<header className={styles.header}>
<div className={styles.headerLeft}><Link href="/" aria-label="返回后台"></Link><span> · </span><i className={autoRefresh ? styles.online : ""} /> <small>{autoRefresh ? "数据连接正常" : "自动刷新已暂停"}</small></div>
<div className={styles.titleBlock}><div className={styles.titleLine} /><h1>{data.title}</h1><div className={styles.titleLine} /><p>{data.subtitle}</p></div>
<div className={styles.headerRight}><button onClick={() => setAutoRefresh(!autoRefresh)}>{autoRefresh ? "暂停刷新" : "恢复刷新"}</button><button onClick={enterFullscreen}></button><div><strong>{now.toLocaleTimeString("zh-CN", { hour12: false })}</strong><small>{now.toLocaleDateString("zh-CN", { year: "numeric", month: "2-digit", day: "2-digit", weekday: "short" })}</small></div></div>
</header>
<section className={styles.kpiRow} aria-label="核心经营指标">
{data.kpis.map((kpi, index) => <article key={kpi.label}><span className={`${styles.kpiIcon} ${styles[kpi.tone]}`}>{["¥", "单", "客", "价", "%"][index]}</span><div><p>{kpi.label}</p><strong>{kpi.value}</strong><small><b>{kpi.delta}</b> {kpi.unit}</small></div><i className={styles.kpiMark} /></article>)}
</section>
<section className={styles.dashboardGrid}>
<div className={styles.leftColumn}>
<Panel title="渠道贡献分布" code="CHANNEL SHARE" extra={<span className={styles.liveBadge}></span>}>
<ChannelChart />
</Panel>
<Panel title="用户转化漏斗" code="CONVERSION FUNNEL" extra={<span className={styles.deltaGood}> +1.8%</span>}>
<FunnelChart />
</Panel>
</div>
<div className={styles.centerColumn}>
<Panel title="全时段成交趋势" code="HOURLY SALES TREND" extra={<div className={styles.legend}><span><i className={styles.cyanDot} /></span><span><i className={styles.blueDot} /></span></div>} className={styles.trendPanel}>
<TrendChart />
</Panel>
<Panel title="实时订单流水" code="REAL-TIME ORDERS" extra={<button className={styles.panelButton} onClick={() => notify("已刷新实时订单")}></button>} className={styles.orderPanel}>
<RealtimeOrders />
</Panel>
</div>
<div className={styles.rightColumn}>
<Panel title="区域业绩排行" code="REGIONAL RANKING" extra={<div className={styles.metricSwitch}><button className={regionMetric === "amount" ? styles.active : ""} onClick={() => setRegionMetric("amount")}></button><button className={regionMetric === "orders" ? styles.active : ""} onClick={() => setRegionMetric("orders")}></button></div>}>
<RegionRanking metric={regionMetric} />
</Panel>
<Panel title="实时告警中心" code="ALERT CENTER" extra={<span className={styles.alertCount}>{data.alerts.length} </span>}>
<AlertList />
</Panel>
</div>
</section>
<section className={styles.bottomPanel}>
<Panel title="业务品类目标达成" code="CATEGORY PERFORMANCE" extra={<span className={styles.updateText}> {data.updatedAt}</span>}>
<CategoryPerformance />
</Panel>
</section>
{toast && <div className={styles.toast}><span></span>{toast}</div>}
</main>
);
}
function Panel({ title, code, extra, children, className = "" }: { title: string; code: string; extra?: React.ReactNode; children: React.ReactNode; className?: string }) {
return <section className={`${styles.panel} ${className}`}><header><div><i /><h2>{title}</h2><small>{code}</small></div>{extra}</header><div className={styles.panelBody}>{children}</div></section>;
}
function ChannelChart() {
return <div className={styles.channelChart}><div className={styles.channelTotal}><span></span><strong>284.6</strong><small></small><i /></div><div className={styles.channelList}>{data.channels.map((item) => <div key={item.name}><div><span className={`${styles.legendDot} ${styles[item.tone]}`} /><strong>{item.name}</strong><b>{item.amount}</b></div><span className={styles.track}><i className={styles[item.tone]} style={{ width: `${item.value}%` }} /></span><small>{item.value}%</small></div>)}</div></div>;
}
function FunnelChart() {
return <div className={styles.funnel}>{data.funnel.map((item, index) => <div key={item.name} className={styles.funnelRow}><span>{index + 1}</span><div className={styles.funnelBar} style={{ width: `${44 + item.rate * .48}%` }}><strong>{item.name}</strong><b>{item.value.toLocaleString()}</b></div><small>{item.rate}%</small>{index < data.funnel.length - 1 && <i></i>}</div>)}</div>;
}
function TrendChart() {
const maxAmount = Math.max(...data.hourlyTrend.map((item) => item.amount));
const maxOrders = Math.max(...data.hourlyTrend.map((item) => item.orders));
const points = useMemo(() => data.hourlyTrend.map((item, index) => ({ x: (index / (data.hourlyTrend.length - 1)) * 100, y: 91 - (item.orders / maxOrders) * 72, item })), [maxOrders]);
return <div className={styles.trendChart} role="img" aria-label="今日每两小时成交金额和订单量趋势"><div className={styles.axisLabels}><span>100 </span><span>75 </span><span>50 </span><span>25 </span><span>0</span></div><div className={styles.plot}><div className={styles.gridLines}>{[0,1,2,3,4].map((line) => <i key={line} />)}</div><div className={styles.bars}>{data.hourlyTrend.map((item) => <div key={item.time}><span style={{ height: `${(item.amount / maxAmount) * 82}%` }} /><small>{item.time}</small></div>)}</div><div className={styles.lineLayer}>{points.map((point, index) => <div key={point.item.time} className={styles.point} style={{ left: `${point.x}%`, top: `${point.y}%` }}><i /><span>{point.item.orders.toLocaleString()} </span>{index < points.length - 1 && <b style={{ width: `${100 / (points.length - 1)}%` }} />}</div>)}</div></div></div>;
}
function RealtimeOrders() {
return <div className={styles.orderTable}><div className={styles.orderHead}><span></span><span></span><span></span><span></span><span></span><span></span></div>{data.realtimeOrders.map((order) => <div className={styles.orderRow} key={order.id}><span>{order.id}</span><span>{order.city}</span><strong>{order.customer}</strong><b>{order.amount}</b><em className={order.status === "已支付" ? styles.paid : styles.pending}>{order.status}</em><time>{order.time}</time></div>)}</div>;
}
function RegionRanking({ metric }: { metric: "amount" | "orders" }) {
const max = Math.max(...data.regions.map((item) => metric === "amount" ? item.amount : item.orders));
return <div className={styles.ranking}>{data.regions.map((region, index) => { const value = metric === "amount" ? region.amount : region.orders; return <div key={region.name}><span className={`${styles.rank} ${index < 3 ? styles[`rank${index + 1}`] : ""}`}>{index + 1}</span><strong>{region.name}</strong><div><span><i style={{ width: `${(value / max) * 100}%` }} /></span><small>{metric === "amount" ? `${region.amount.toFixed(1)}` : `${region.orders.toLocaleString()}`}</small></div><b>+{region.delta}%</b></div>; })}</div>;
}
function AlertList() {
return <div className={styles.alertList}>{data.alerts.map((alert) => <article key={alert.title}><span className={`${styles.alertIcon} ${styles[`alert_${alert.level}`]}`}>{alert.level === "high" ? "!" : alert.level === "medium" ? "△" : alert.level === "low" ? "◷" : "i"}</span><div><strong>{alert.title}</strong><p>{alert.detail}</p></div><time>{alert.time}</time></article>)}</div>;
}
function CategoryPerformance() {
return <div className={styles.categories}>{data.categories.map((category, index) => <article key={category.name}><div><span>{String(index + 1).padStart(2, "0")}</span><div><strong>{category.name}</strong><small> {category.amount}</small></div><b className={category.actual >= category.target ? styles.targetMet : ""}>{category.actual}%</b></div><div className={styles.targetTrack}><span style={{ width: `${category.actual}%` }} /><i style={{ left: `${category.target}%` }} /><small style={{ left: `${category.target}%` }}> {category.target}%</small></div></article>)}</div>;
}