一、实验目的:
- 在基础校园消费看板上增加动态视觉效果,掌握组件渐入动画、图表悬浮提示和自适应布局的方法。
- 掌握日期、消费类别和支付方式的组合筛选,使指标与图表根据同一数据范围更新。
- 理解图表选择事件与会话状态的关系,实现点击类别柱状图后联动其他模块。
- 制作消费排行榜、星期与类别热力图、预算进度仪表盘,从多个角度分析消费数据。
- 验证交互前后的统计口径、空数据处理和界面显示效果。
二、实验环境:
- 硬件:可运行 Python 和浏览器的计算机。
- 操作系统:macOS。
- 开发语言:Python 3.11。
- 主要依赖:Streamlit 1.66.0、Pandas 3.0.6、Plotly 7.1.0。
- 运行及截图环境:Codex 内置浏览器,通过本地服务访问。
- 数据来源:沿用基础实验的校园消费模拟数据,周期为 2026 年 9 月 1 日至 9 月 30 日。原始记录 908 条,清洗后保留 900 条。
- 验证工具:浏览器实际操作、Streamlit AppTest,以及独立金额汇总核对。
三、实验内容:
1. 进阶任务设计
本实验继续使用校园消费场景,在基础看板上完成三项进阶任务。页面顶部设置日期范围、消费类别和支付方式筛选,随后依次展示指标卡、类别对比与支付占比、每日趋势、热力图与预算仪表盘、消费排行榜和明细表。
| 进阶任务 | 本实验完成的内容 |
|---|---|
| 动态视觉效果优化 | 指标卡和图表渐入动画、悬浮提示、图表自适应宽度、窄屏单列布局 |
| 图表联动与筛选 | 日期区间、类别多选、支付方式选择;点击类别柱状图联动其他模块;清除联动 |
| 多维度分析 | 单笔消费前10名排行榜、星期与类别消费热力图、可设置预算的进度仪表盘 |
数据处理流程为:原始数据清洗 → 日期及类别、支付方式筛选 → 生成筛选数据集 → 应用柱状图选择的类别 → 更新指标、图表和明细。
类别柱状图始终展示顶部筛选条件下的类别对比,作为联动入口。点击其中一个类别后,其余分析模块仅展示该类别的数据。这样可以继续点击其他类别切换分析对象。修改顶部筛选条件时,程序自动清除上一次类别点击状态。
2. 进阶任务一:动态视觉效果优化
使用 CSS 关键帧定义透明度与垂直位移的变化,为指标卡添加 0.55 秒渐入效果,为图表添加 0.65 秒渐入效果。针对系统中选择减少动态效果的用户,通过媒体查询关闭动画。
使用 Plotly 的 hovertemplate 设置悬浮内容,显示日期、类别、金额或占比,避免仅靠坐标估计数值。图表使用容器宽度自适应;窗口宽度小于等于 700 像素时,多列布局转为单列,并缩小页面边距。
本次动态优化采用组件渐入与悬浮提示。数据源仍为本地 CSV,筛选操作触发页面重新计算,不设置定时刷新。
3. 进阶任务二:筛选与图表联动
日期筛选保留开始日期到结束日期之间的记录,包含两个端点。消费类别支持多选,支付方式支持"全部"或指定方式。三个条件取交集,得到基础筛选结果 base。
点击类别柱状图后,Streamlit 接收 Plotly 选择事件,回调函数读取所选点的类别,存入 st.session_state.linked。程序重新执行时,按照该类别从 base 中生成 view。指标卡、支付占比、每日趋势、热力图、预算进度、排行榜和明细统一使用 view,保证统计口径一致。
清除联动按钮把所选类别恢复为空,并更新柱状图组件键,使旧图表选择状态同步清除。顶部条件变化时执行相同处理,避免把旧类别带入新的筛选范围。
当类别选择为空或其他条件组合无数据时,页面显示提示并停止绘制;日期范围尚未选完整时,提示用户补全日期。每日趋势补齐所选区间中的日期,无消费日期显示为零。
4. 进阶任务三:多维度数据分析
消费排行榜。 通过 nlargest(10, 'amount') 选取当前范围金额最高的 10 笔记录,再使用水平条形图展示交易编号和金额,悬浮提示提供日期与类别。最高金额记录位于图表上方。
星期与类别热力图。 从消费日期提取星期编号,按消费类别和星期汇总金额,构造 5 行、7 列的矩阵。颜色越深表示累计金额越高,缺失组合补零。图中数字四舍五入到整数,悬浮提示保留两位小数。
热力图展示的是累计金额。不同星期在所选日期范围内出现次数可能不同,因此不能直接据此推断平均每日消费强度。
预算进度仪表盘。 由使用者输入当前筛选范围对应的预算,默认 40,000 元。预算使用率=当前消费金额÷当前预算×100%。未超预算时显示蓝色,超过预算后显示红色,并显示超出金额。仪表盘上限随实际使用率扩展,100% 位置保留阈值标记,预算最小为 1 元,避免除零。
5. 主要代码
以下为完整的看板主程序。代码未添加注释。
python
import pandas as pd
import plotly.express as px
import plotly.graph_objects as go
import streamlit as st
from data_utils import load_data, CATEGORIES, COLORS
def reset_link():
st.session_state.linked = None
st.session_state.chart_epoch = st.session_state.get('chart_epoch', 0) + 1
def select_category(key):
points = st.session_state[key]['selection']['points']
st.session_state.linked = points[0]['x'] if points else None
def filter_data(df, start, end, categories, payment):
mask = df['date'].between(pd.Timestamp(start), pd.Timestamp(end))
mask &= df['category'].isin(categories)
if payment != '全部':
mask &= df['payment'].eq(payment)
return df.loc[mask].copy()
def chart(fig, height=275):
fig.update_layout(template='plotly_white', height=height,
margin=dict(l=12, r=16, t=16, b=16),
font=dict(family='Arial, PingFang SC', size=13),
paper_bgcolor='white', plot_bgcolor='white',
xaxis_title=None, yaxis_title=None)
return fig
def main():
st.set_page_config(page_title='校园消费进阶看板', page_icon='📊', layout='wide')
st.markdown('''<style>
.stApp {background:#f4f7fb; color:#172b4d;}
.block-container {padding-top:2rem; max-width:1500px;}
h1 {font-size:2rem !important;}
h3 {font-size:1.08rem !important;}
[data-testid="stMetric"] {background:white; padding:14px 18px;
border:1px solid #e2e8f0; border-radius:12px; animation:enter .55s ease both;}
[data-testid="stMetricValue"] {color:#2563eb; font-size:1.7rem;}
[data-testid="stPlotlyChart"] {animation:enter .65s ease both;}
@keyframes enter {from {opacity:0; transform:translateY(12px);}
to {opacity:1; transform:translateY(0);}}
@media (prefers-reduced-motion:reduce) {
[data-testid="stMetric"], [data-testid="stPlotlyChart"] {animation:none;}}
@media (max-width:700px) {
.block-container {padding:1rem;}
[data-testid="stHorizontalBlock"] {flex-wrap:wrap;}
[data-testid="stColumn"] {min-width:100% !important; flex:1 1 100% !important;}}
</style>''', unsafe_allow_html=True)
_, df = load_data()
if 'linked' not in st.session_state:
st.session_state.linked = None
st.title('校园消费进阶数据看板')
st.caption('2026年9月 · 模拟数据 / 条件筛选 / 图表联动 / 多维分析')
f1, f2, f3 = st.columns([1.3, 1.5, 1])
dates = f1.date_input('日期范围', value=(df['date'].min().date(), df['date'].max().date()),
min_value=df['date'].min().date(), max_value=df['date'].max().date(),
on_change=reset_link)
categories = f2.multiselect('消费类别', CATEGORIES, default=CATEGORIES, on_change=reset_link)
payment = f3.selectbox('支付方式', ['全部', '校园卡', '微信', '支付宝'], on_change=reset_link)
if len(dates) != 2:
st.info('请选择完整的开始日期和结束日期。')
st.stop()
base = filter_data(df, dates[0], dates[1], categories, payment)
if base.empty:
st.info('当前筛选条件下没有消费记录,请调整条件。')
st.stop()
active = st.session_state.linked
view = base[base['category'].eq(active)].copy() if active else base.copy()
if active:
st.info(f'当前联动类别:{active}。指标、环形图、趋势、排行榜、热力图、预算及明细已同步更新。')
else:
st.caption('点击类别柱状图中的柱子可联动其他模块;类别柱状图保留筛选范围内的全局对比。')
st.button('清除图表联动', on_click=reset_link)
cols = st.columns(4)
cols[0].metric('消费总额(元)', f'{view["amount"].sum():,.2f}')
cols[1].metric('消费笔数', f'{len(view):,}')
cols[2].metric('平均每笔(元)', f'{view["amount"].mean():.2f}' if len(view) else '0.00')
cols[3].metric('最大单笔(元)', f'{view["amount"].max():.2f}' if len(view) else '0.00')
color_map = dict(zip(CATEGORIES, COLORS))
left, right = st.columns([1.2, 1])
with left:
st.subheader('类别对比 · 点击柱子联动')
group = base.groupby('category', as_index=False)['amount'].sum().sort_values('amount', ascending=False)
bar = px.bar(group, x='category', y='amount', color='category',
color_discrete_map=color_map, text_auto='.2f')
bar.update_layout(showlegend=False, clickmode='event+select')
bar.update_traces(hovertemplate='%{x}<br>金额:%{y:.2f} 元<extra></extra>')
key = f'category_chart_{st.session_state.get("chart_epoch", 0)}'
st.plotly_chart(chart(bar), key=key, width='stretch',
on_select=lambda: select_category(key), selection_mode='points')
with right:
st.subheader('支付方式 · 当前范围金额占比')
payments = view.groupby('payment', as_index=False)['amount'].sum()
pie = px.pie(payments, names='payment', values='amount', hole=.6,
color='payment', color_discrete_map={'校园卡':COLORS[0], '微信':COLORS[1], '支付宝':COLORS[3]})
pie.update_traces(textinfo='label+percent', textposition='outside',
hovertemplate='%{label}<br>%{value:.2f} 元<br>%{percent}<extra></extra>')
pie.update_layout(showlegend=False)
st.plotly_chart(chart(pie), width='stretch')
st.subheader('每日消费趋势 · 随筛选与类别点击更新')
daily = view.groupby('date')['amount'].sum().reindex(
pd.date_range(dates[0], dates[1]), fill_value=0).rename_axis('date').reset_index()
line = px.line(daily, x='date', y='amount', markers=True, color_discrete_sequence=[COLORS[0]])
line.update_traces(fill='tozeroy', fillcolor='rgba(37,99,235,.08)',
hovertemplate='%{x|%Y-%m-%d}<br>金额:%{y:.2f} 元<extra></extra>')
line.update_xaxes(tickformat='%m-%d')
st.plotly_chart(chart(line, 240), width='stretch')
left, right = st.columns([1.35, 1])
with left:
st.subheader('多维分析 · 星期与消费类别热力图')
weekdays = ['周一', '周二', '周三', '周四', '周五', '周六', '周日']
heat_data = view.assign(weekday=view['date'].dt.dayofweek)
heat = heat_data.pivot_table(index='category', columns='weekday', values='amount', aggfunc='sum')
heat = heat.reindex(index=CATEGORIES, columns=range(7)).fillna(0)
fig = px.imshow(heat.to_numpy(), x=weekdays, y=CATEGORIES,
color_continuous_scale='Blues', aspect='auto', text_auto='.0f')
fig.update_traces(hovertemplate='%{y} / %{x}<br>累计金额:%{z:.2f} 元<extra></extra>')
fig.update_layout(coloraxis_colorbar=dict(title='元'))
st.plotly_chart(chart(fig, 280), width='stretch')
st.caption('单元格表示当前范围内该星期与类别的累计金额;零值表示没有记录。')
with right:
st.subheader('预算进度 · 当前筛选范围')
budget = st.number_input('当前范围预算(元)', min_value=1.0, value=40000.0, step=1000.0)
ratio = view['amount'].sum() / budget * 100
gauge = go.Figure(go.Indicator(mode='gauge+number', value=ratio,
number={'suffix':'%', 'valueformat':'.1f'},
gauge={'axis':{'range':[0, max(100, float(ratio))]},
'bar':{'color':'#ef4444' if ratio > 100 else '#2563eb'},
'steps':[{'range':[0, 100], 'color':'#e8effa'}],
'threshold':{'value':100, 'line':{'color':'#f59e0b', 'width':3}}}))
st.plotly_chart(chart(gauge, 210), width='stretch')
difference = budget - view['amount'].sum()
st.caption(f'预算剩余 {difference:,.2f} 元' if difference >= 0 else f'已超预算 {-difference:,.2f} 元')
st.subheader('消费排行榜 · 当前范围金额最高的10笔记录')
top = view.nlargest(10, 'amount').sort_values('amount')
rank = px.bar(top, x='amount', y='transaction_id', orientation='h',
color='category', color_discrete_map=color_map,
hover_data={'date':True, 'category':True}, text_auto='.2f')
rank.update_yaxes(type='category')
st.plotly_chart(chart(rank, 325), width='stretch')
st.subheader('明细数据 · 当前筛选与联动结果')
display = view.copy()
display['date'] = display['date'].dt.strftime('%Y-%m-%d')
display.columns = ['交易编号', '消费日期', '消费类别', '支付方式', '消费金额(元)']
st.dataframe(display, hide_index=True, width='stretch', height=230,
column_config={'消费金额(元)':st.column_config.NumberColumn(format='%.2f')})
st.caption(f'当前展示 {len(view)} 条记录;预算由使用者按当前统计范围设定。数据仅用于课程实验。')
if __name__ == '__main__':
main()
数据读取与预处理模块 data_utils.py 如下,同样未添加注释:
python
from pathlib import Path
import pandas as pd
ROOT = Path(__file__).resolve().parent
CATEGORIES = ['餐饮', '购物', '交通', '学习', '文娱']
COLORS = ['#2563eb', '#14b8a6', '#f59e0b', '#8b5cf6', '#f472b6']
def load_data():
raw = pd.read_csv(ROOT / 'data' / 'campus_consumption_raw.csv')
df = raw.copy()
df['date'] = pd.to_datetime(df['date'], errors='coerce')
df['amount'] = pd.to_numeric(df['amount'], errors='coerce')
df = df.dropna(subset=['transaction_id', 'date', 'category', 'payment', 'amount'])
df = df[df['category'].isin(CATEGORIES)]
df = df[df['payment'].isin(['校园卡', '微信', '支付宝'])]
df = df[df['amount'].between(0, 500, inclusive='right')]
df = df[df['date'].between('2026-09-01', '2026-09-30')]
df = df.drop_duplicates(subset=['transaction_id']).sort_values(['date', 'transaction_id'])
return raw, df.reset_index(drop=True)
6. 运行方法
进入随附"校园消费进阶看板"目录,执行:
bash
python -m pip install -r requirements.txt
python -m streamlit run app.py
浏览器打开终端提示的本地地址即可使用。工程已附带数据,如需重新生成原始数据,可执行 python generate_data.py。本次截图运行时指定端口 8502,以便与基础实验同时运行。
7. 实验运行结果
以下图片均为本次进阶程序的实际运行截图。
图1 筛选控件与数据概览。 默认选择整月、全部类别和全部支付方式,显示 900 笔有效消费。

图2 点击购物类别后的联动结果。 点击"购物"柱子后,页面提示当前联动类别,消费总额更新为 13,987.33 元,消费笔数更新为 171 笔,平均每笔为 81.80 元。类别对比图保留其他类别,便于继续切换。

图3 每日趋势、热力图与预算仪表盘。 清除联动后恢复全量数据;预算为 40,000 元时使用率约 90.3%,剩余 3,862.89 元。

图4 消费排行榜与明细表。 排行榜展示当前范围最高的 10 笔记录,最高单笔为 149.27 元,交易编号为 T0503。明细表与当前筛选、联动范围一致。

图5 窄屏自适应布局。 在 390×844 像素视口下,筛选控件和指标卡按单列排列,页面宽度无横向溢出。

8. 结果验证与问题处理
通过独立汇总、页面测试和浏览器实际点击,对以下项目进行验证:
| 验证项目 | 操作或条件 | 结果 |
|---|---|---|
| 默认统计 | 整月、全部类别、全部支付方式 | 900 笔,36,137.11 元,平均 40.15 元,最大单笔 149.27 元 |
| 日期、类别及支付方式组合筛选 | 9 月 1---15 日,餐饮,微信 | 81 笔,2,003.15 元,平均 24.73 元,与独立汇总一致 |
| 类别点击联动 | 浏览器点击购物柱子 | 171 笔,13,987.33 元,其他模块同步更新 |
| 清除联动 | 点击清除按钮 | 恢复 900 笔、36,137.11 元 |
| 无数据处理 | 清空全部消费类别 | 显示无记录提示,不显示错误数值或程序异常 |
| 预算计算 | 全量数据、预算 40,000 元 | 使用率 90.3%,剩余 3,862.89 元 |
| 窄屏布局 | 390×844 像素视口 | 指标卡单列排列,页面内容宽度与视口宽度均为 390 像素 |
| 动画设置 | 检查指标卡样式 | 已应用 enter 渐入动画 |
主要问题及处理方法如下:
| 问题 | 解决方法 |
|---|---|
| 图表分别筛选容易出现统计范围不一致 | 统一构造 base 和 view,所有关联组件使用 view |
| 旧类别选择可能影响新的筛选条件 | 筛选控件变化时清除 linked,并更新图表组件键 |
| 点击一个类别后难以切换到其他类别 | 联动入口柱状图保留基础筛选结果,其余模块按所选类别更新 |
| 无消费日期造成趋势图时间轴不连续 | 重新索引到完整日期范围并补零 |
| 空类别选择使平均值出现无效结果 | 在绘图和指标计算前判断空数据并提示 |
| 预算与当前数据范围不一致造成误读 | 输入框明确标注为当前范围预算,并由使用者设定 |
| 热力图累计值容易被解释为平均水平 | 在图下标注累计金额,并在报告中说明日期分布差异 |
四、心得体会:
通过进阶实验,我进一步理解了交互式看板的数据更新过程。筛选控件和图表点击都在改变分析范围,因此需要先统一数据集,再更新各个组件。会话状态能够保存用户当前选择,而回调函数可以在页面重新计算前处理选择事件,实现图表联动。
在多维分析方面,排行榜适合定位高金额记录,热力图适合观察类别与星期的交叉分布,仪表盘适合展示目标完成比例。不同图表需要清楚的统计口径,尤其要区分累计金额与平均值,并说明预算适用的范围。
视觉优化应服务于阅读。适当的渐入动画和悬浮提示可以帮助用户理解页面变化,自适应布局能让看板在较窄的窗口中继续使用。验证时除了检查默认页面,还应覆盖组合筛选、联动清除和空数据状态,才能保证交互过程中的数值和界面保持一致。