refactor: 精简 UI 自定义样式,使用 Streamlit 原生组件替代 HTML 卡片

- 将大量自定义 CSS(主题色、卡片、徽章、alert 等)精简为仅保留字体设置
  - sidebar 标题/副标题/分隔线改用 st.header、st.caption、st.divider
  - KPI 指标卡片改用 st.metric
  - 作物推荐排行改用 st.container + st.progress
  - 种植建议的 alert 框改用 st.warning / st.success / st.info
  - 删除自定义 HTML footer,改用 st.caption
This commit is contained in:
zhenghu
2026-04-14 17:35:42 +08:00
parent 6af6a10636
commit e3ac2beb7a

320
app.py
View File

@@ -15,179 +15,11 @@ st.set_page_config(
initial_sidebar_state="expanded",
)
# ─── Custom CSS ─────────────────────────────────────────────────────────────
# ─── Minimal CSS ─────────────────────────────────────────────────────────────
st.markdown("""
<style>
:root {
--soil: #7c5e42;
--leaf: #4a7c59;
--leaf-light: #6b9e75;
--wheat: #d4a574;
--cream: #faf8f3;
--paper: #ffffff;
--ink: #2c2c2c;
--ink-muted: #5a5a5a;
--border: #e5e0d5;
--shadow: rgba(0,0,0,0.04);
}
html, body, [class*="css"] {
font-family: "PingFang SC", "Microsoft YaHei", "Noto Sans SC", sans-serif;
color: var(--ink);
}
.stApp {
background: var(--cream);
}
/* Sidebar */
[data-testid="stSidebar"] {
background: #f5f2eb;
border-right: 1px solid var(--border);
}
[data-testid="stSidebar"] .stSlider label,
[data-testid="stSidebar"] .stNumberInput label,
[data-testid="stSidebar"] .stSelectbox label {
color: var(--soil) !important;
font-size: 0.85rem;
font-weight: 500;
}
/* Metric cards */
.metric-card {
background: var(--paper);
border: 1px solid var(--border);
border-radius: 14px;
padding: 20px 18px;
text-align: center;
box-shadow: 0 2px 10px var(--shadow);
}
.metric-value {
font-size: 1.9rem;
font-weight: 700;
color: var(--leaf);
line-height: 1.1;
}
.metric-unit {
font-size: 0.8rem;
color: var(--ink-muted);
margin-top: 4px;
}
.metric-label {
font-size: 0.9rem;
color: var(--ink);
margin-top: 8px;
font-weight: 500;
}
/* Section headers */
.section-header {
font-size: 0.95rem;
font-weight: 600;
color: var(--soil);
padding-bottom: 8px;
margin-bottom: 14px;
margin-top: 22px;
border-bottom: 1px solid var(--border);
}
/* Crop badge */
.crop-badge {
display: inline-block;
background: #f3f6f3;
border: 1px solid var(--leaf-light);
border-radius: 999px;
padding: 4px 12px;
font-size: 0.82rem;
color: var(--leaf);
margin: 3px;
}
/* Recommendation card */
.rec-card {
background: var(--paper);
border: 1px solid var(--border);
border-radius: 12px;
padding: 14px 18px;
margin: 8px 0;
box-shadow: 0 1px 6px var(--shadow);
}
.rec-rank {
font-size: 1.2rem;
color: var(--wheat);
font-weight: 700;
}
.rec-crop {
font-size: 1rem;
color: var(--ink);
font-weight: 600;
}
.rec-score {
font-size: 0.85rem;
color: var(--leaf);
font-weight: 600;
}
/* Hero title */
.hero-title {
font-size: 1.6rem;
font-weight: 700;
color: var(--soil);
line-height: 1.2;
}
.hero-sub {
font-size: 0.85rem;
color: var(--ink-muted);
margin-top: 4px;
}
/* Alert boxes */
.alert-good {
background: #f4faf5;
border-left: 3px solid var(--leaf-light);
border-radius: 0 8px 8px 0;
padding: 12px 14px;
margin: 8px 0;
font-size: 0.9rem;
color: var(--ink);
}
.alert-warn {
background: #fdf9f3;
border-left: 3px solid var(--wheat);
border-radius: 0 8px 8px 0;
padding: 12px 14px;
margin: 8px 0;
font-size: 0.9rem;
color: var(--ink);
}
/* Sidebar title */
.sidebar-title {
font-size: 1.15rem;
font-weight: 700;
color: var(--soil);
margin-bottom: 2px;
}
.sidebar-sub {
font-size: 0.75rem;
color: var(--ink-muted);
margin-bottom: 12px;
}
/* Streamlit overrides */
.stSlider [data-baseweb="slider"] [data-testid="stTickBarMin"],
.stSlider [data-baseweb="slider"] [data-testid="stTickBarMax"] {
color: var(--ink-muted);
}
.stButton > button {
border-radius: 10px !important;
background: var(--leaf) !important;
border: none !important;
color: #fff !important;
}
.stButton > button:hover {
background: var(--leaf-light) !important;
}
</style>
""", unsafe_allow_html=True)
@@ -300,11 +132,11 @@ def rank_crops(ph, N, P, K, rainfall, temp, pesticide, area):
# ─── Sidebar Inputs ──────────────────────────────────────────────────────────
with st.sidebar:
st.markdown('<div class="sidebar-title">🌾 种植决策助手</div>', unsafe_allow_html=True)
st.markdown('<div class="sidebar-sub">根据土壤和气候,推荐适宜作物</div>', unsafe_allow_html=True)
st.markdown("<hr style='border:none;border-top:1px solid var(--border);margin:12px 0;'>", unsafe_allow_html=True)
st.header("🌾 种植决策助手")
st.caption("根据土壤和气候,推荐适宜作物")
st.divider()
st.markdown('<div class="section-header" style="margin-top:0">🧪 土壤参数</div>', unsafe_allow_html=True)
st.subheader("🧪 土壤参数")
col1, col2 = st.columns(2)
with col1:
ph = st.slider("pH 值", 4.0, 9.0, 6.5, 0.1)
@@ -313,18 +145,18 @@ with st.sidebar:
P = st.slider("磷 P (mg/kg)", 0, 100, 45, 5)
K = st.slider("钾 K (mg/kg)", 0, 150, 60, 5)
st.markdown('<div class="section-header">🌦 气象数据</div>', unsafe_allow_html=True)
st.subheader("🌦 气象数据")
col3, col4 = st.columns(2)
with col3:
rainfall = st.slider("降雨量 (mm/月)", 0, 400, 120, 10)
with col4:
temp = st.slider("温度 (°C)", 0, 45, 22, 1)
st.markdown('<div class="section-header">🌱 种植参数</div>', unsafe_allow_html=True)
st.subheader("🌱 种植参数")
area = st.number_input("种植面积 (公顷)", 0.1, 10000.0, 100.0, 10.0)
pesticide = st.slider("农药用量 (kg/ha)", 0, 200, 50, 5)
st.markdown('<div class="section-header">🎯 目标作物</div>', unsafe_allow_html=True)
st.subheader("🎯 目标作物")
selected_crop = st.selectbox(
"选择分析作物",
list(CROPS.keys()),
@@ -339,54 +171,22 @@ best_crop = rankings[0]
# ─── Main Layout ─────────────────────────────────────────────────────────────
st.markdown(f"""
<div style="display:flex; align-items:baseline; gap:12px; margin-bottom:4px;">
<div class="hero-title">种植决策助手</div>
</div>
<div class="hero-sub">输入土壤与气象条件,获得作物产量预测与种植建议</div>
""", unsafe_allow_html=True)
st.markdown("<br>", unsafe_allow_html=True)
st.title("🌾 种植决策助手")
st.caption("输入土壤与气象条件,获得作物产量预测与种植建议")
# KPI row
k1, k2, k3, k4 = st.columns(4)
with k1:
st.markdown(f"""
<div class="metric-card">
<div class="metric-value">{yph:,.0f}</div>
<div class="metric-unit">kg / 公顷</div>
<div class="metric-label">{CROPS[selected_crop]['emoji']} {selected_crop} 单产</div>
</div>""", unsafe_allow_html=True)
with k2:
st.markdown(f"""
<div class="metric-card">
<div class="metric-value">{ytotal/1000:,.1f}</div>
<div class="metric-unit">吨 / 总产量</div>
<div class="metric-label">📦 {area:.0f} 公顷总产</div>
</div>""", unsafe_allow_html=True)
with k3:
overall = np.mean(list(factors.values()))
st.markdown(f"""
<div class="metric-card">
<div class="metric-value">{overall*100:.1f}%</div>
<div class="metric-unit">综合适宜度</div>
<div class="metric-label">🎯 环境匹配指数</div>
</div>""", unsafe_allow_html=True)
with k4:
st.markdown(f"""
<div class="metric-card">
<div class="metric-value">{best_crop['emoji']}</div>
<div class="metric-unit">{best_crop['crop']} ({best_crop['score']*100:.0f}%)</div>
<div class="metric-label">🏆 最优推荐作物</div>
</div>""", unsafe_allow_html=True)
st.markdown("<br>", unsafe_allow_html=True)
k1, k2, k3, k4 = st.columns(4)
k1.metric(f"{CROPS[selected_crop]['emoji']} {selected_crop} 单产", f"{yph:,.0f} kg/ha")
k2.metric(f"📦 {area:.0f} 公顷总产", f"{ytotal/1000:,.1f}")
k3.metric("🎯 环境匹配指数", f"{overall*100:.1f}%")
k4.metric("🏆 最优推荐作物", f"{best_crop['emoji']} {best_crop['crop']}", f"匹配度 {best_crop['score']*100:.0f}%")
# ─── Charts Row ──────────────────────────────────────────────────────────────
col_left, col_right = st.columns([3, 2])
with col_left:
st.markdown('<div class="section-header">📊 影响因子雷达图</div>', unsafe_allow_html=True)
st.subheader("📊 影响因子雷达图")
factor_names = list(factors.keys())
factor_vals = [round(v * 100, 1) for v in factors.values()]
@@ -428,30 +228,17 @@ with col_left:
st.plotly_chart(fig_radar, use_container_width=True)
with col_right:
st.markdown('<div class="section-header">🏅 作物推荐排行</div>', unsafe_allow_html=True)
st.subheader("🏅 作物推荐排行")
for i, r in enumerate(rankings[:4]):
rank_icons = ["🥇", "🥈", "🥉", "4"]
bar_width = int(r['score'] * 100)
bar_color = r['color']
st.markdown(f"""
<div class="rec-card" style="margin-bottom:8px;">
<div style="display:flex; justify-content:space-between; align-items:center;">
<div>
<span class="rec-rank">{rank_icons[i]}</span>
<span class="rec-crop" style="margin-left:8px;">{r['emoji']} {r['crop']}</span>
</div>
<span class="rec-score">{r['score']*100:.1f}%</span>
</div>
<div style="background:#f0ece4; border-radius:4px; height:5px; margin-top:10px; overflow:hidden;">
<div style="width:{bar_width}%; height:100%; background:{bar_color}; border-radius:4px;"></div>
</div>
<div style="font-size:0.78rem; color:#7a7a7a; margin-top:6px;">
{r['yield_ha']:,.0f} kg/ha · 总产 {r['total_yield']/1000:,.1f}
</div>
</div>""", unsafe_allow_html=True)
with st.container(border=True):
c1, c2 = st.columns([3, 1])
c1.markdown(f"**{rank_icons[i]} {r['emoji']} {r['crop']}**")
c2.markdown(f"<div style='text-align:right; color:#4a7c59; font-weight:600;'>{r['score']*100:.1f}%</div>", unsafe_allow_html=True)
st.progress(int(r['score'] * 100), text=f"{r['yield_ha']:,.0f} kg/ha · 总产 {r['total_yield']/1000:,.1f}")
# ─── Sensitivity Analysis ─────────────────────────────────────────────────────
st.markdown('<div class="section-header">📈 产量敏感性分析</div>', unsafe_allow_html=True)
st.subheader("📈 产量敏感性分析")
sa_col1, sa_col2 = st.columns(2)
@@ -504,7 +291,7 @@ with sa_col2:
st.plotly_chart(fig_R, use_container_width=True)
# ─── All Crops Comparison Bar Chart ───────────────────────────────────────────
st.markdown('<div class="section-header">🌐 全作物产量对比</div>', unsafe_allow_html=True)
st.subheader("🌐 全作物产量对比")
crop_names = [f"{r['emoji']} {r['crop']}" for r in rankings]
crop_yields = [r['yield_ha'] for r in rankings]
@@ -530,61 +317,42 @@ fig_bar.update_layout(
st.plotly_chart(fig_bar, use_container_width=True)
# ─── Advisory Panel ───────────────────────────────────────────────────────────
st.markdown('<div class="section-header">💡 种植建议</div>', unsafe_allow_html=True)
st.subheader("💡 种植建议")
adv1, adv2 = st.columns(2)
with adv1:
crop_opt = CROPS[selected_crop]["optimal"]
advisories = []
if not (crop_opt["ph"][0] <= ph <= crop_opt["ph"][1]):
advisories.append(("warn", f"pH {ph} 偏离 {selected_crop} 适宜范围 {crop_opt['ph']},建议{'施石灰' if ph < crop_opt['ph'][0] else '施硫磺'}调节"))
st.warning(f"pH {ph} 偏离 {selected_crop} 适宜范围 {crop_opt['ph']},建议{'施石灰' if ph < crop_opt['ph'][0] else '施硫磺'}调节")
else:
advisories.append(("good", f"土壤 pH {ph} 处于 {selected_crop} 适宜范围内"))
st.success(f"土壤 pH {ph} 处于 {selected_crop} 适宜范围内")
if N < crop_opt["N"][0]:
advisories.append(("warn", f"氮肥不足({N} vs 建议 {crop_opt['N'][0]}-{crop_opt['N'][1]} mg/kg建议追施尿素"))
st.warning(f"氮肥不足({N} vs 建议 {crop_opt['N'][0]}-{crop_opt['N'][1]} mg/kg建议追施尿素")
elif N > crop_opt["N"][1]:
advisories.append(("warn", f"氮肥过量({N} mg/kg可能造成徒长建议减施"))
st.warning(f"氮肥过量({N} mg/kg可能造成徒长建议减施")
else:
advisories.append(("good", f"氮肥水平 {N} mg/kg 适宜"))
st.success(f"氮肥水平 {N} mg/kg 适宜")
if rainfall < crop_opt["rainfall"][0]:
advisories.append(("warn", f"降雨量不足,建议增加灌溉(缺水 {crop_opt['rainfall'][0]-rainfall} mm"))
st.warning(f"降雨量不足,建议增加灌溉(缺水 {crop_opt['rainfall'][0]-rainfall} mm")
elif rainfall > crop_opt["rainfall"][1]:
advisories.append(("warn", f"降雨量偏多,注意防涝排水"))
st.warning(f"降雨量偏多,注意防涝排水")
else:
advisories.append(("good", f"降雨量 {rainfall}mm 适合 {selected_crop} 生长"))
for typ, msg in advisories:
css_class = "alert-good" if typ == "good" else "alert-warn"
st.markdown(f'<div class="{css_class}">{msg}</div>', unsafe_allow_html=True)
st.success(f"降雨量 {rainfall}mm 适合 {selected_crop} 生长")
with adv2:
st.markdown(f"""
<div class="rec-card">
<div style="font-size:0.82rem; color:#7a7a7a; margin-bottom:12px;">
当前环境参数下适宜种植:
</div>
""", unsafe_allow_html=True)
badges = "".join([
f'<span class="crop-badge">{r["emoji"]} {r["crop"]} {r["score"]*100:.0f}%</span>'
for r in rankings if r['score'] > 0.6
])
st.markdown(f'{badges}</div>', unsafe_allow_html=True)
suitable = [r for r in rankings if r['score'] > 0.6]
if suitable:
st.info("当前环境参数下适宜种植:" + "".join([f"{r['emoji']} {r['crop']} ({r['score']*100:.0f}%)" for r in suitable]))
else:
st.info("当前环境参数下暂无特别适宜的作物")
st.markdown(f"""
<div style="margin-top:16px; font-size:0.88rem; color:#5a5a5a; line-height:1.7; background:#fff; border:1px solid #e5e0d5; border-radius:12px; padding:14px 16px;">
<b style="color:#4a7c59;">最优方案:</b>{best_crop['emoji']} {best_crop['crop']}<br>
预期单产:<span style="color:#4a7c59; font-weight:600;">{best_crop['yield_ha']:,.0f} kg/ha</span><br>
{area:.0f}公顷总产:<span style="color:#4a7c59; font-weight:600;">{best_crop['total_yield']/1000:,.1f} 吨</span>
</div>
""", unsafe_allow_html=True)
with st.container(border=True):
st.markdown(f"**最优方案:{best_crop['emoji']} {best_crop['crop']}**")
st.markdown(f"- 预期单产:**{best_crop['yield_ha']:,.0f} kg/ha**")
st.markdown(f"- {area:.0f} 公顷总产:**{best_crop['total_yield']/1000:,.1f} 吨**")
# ─── Footer ───────────────────────────────────────────────────────────────────
st.markdown("<br>", unsafe_allow_html=True)
st.markdown("""
<div style="text-align:center; font-size:0.78rem; color:#aaa; padding:14px; border-top:1px solid #e5e0d5;">
种植决策助手 · 基于 Cobb-Douglas 多因子产量模型 · 仅供参考
</div>
""", unsafe_allow_html=True)
st.divider()
st.caption("种植决策助手 · 基于 Cobb-Douglas 多因子产量模型 · 仅供参考")