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:
320
app.py
320
app.py
@@ -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
|
||||
overall = np.mean(list(factors.values()))
|
||||
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.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 多因子产量模型 · 仅供参考")
|
||||
|
||||
Reference in New Issue
Block a user