feat(landing): 预测案例管理员可编辑 + 图片上传修复 + 构建hash

- 后端新增 GET/PUT /api/cfg/landing-cases 接口存取案例数据
- 上传接口自动创建 uploads 目录,修复目录不存在导致上传失败
- 前端预测案例支持管理员编辑标题/副标题/案例内容
- hero 图片初始不设默认值,configReady 门控渲染,消除刷新闪烁
- .umirc.ts 开启 hash:true 避免浏览器缓存旧 JS

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T71oHHhfhLAWAL9EuoFuYh
This commit is contained in:
zty 2026-07-29 22:19:41 -04:00
parent b26546d459
commit 1d987277c6
4 changed files with 1637 additions and 19 deletions

File diff suppressed because it is too large Load Diff

View File

@ -128,6 +128,7 @@ export default defineConfig({
mfsu: { mfsu: {
strategy: 'normal' strategy: 'normal'
}, },
hash: true,
esbuildMinifyIIFE: true, esbuildMinifyIIFE: true,
npmClient: 'pnpm' npmClient: 'pnpm'
}) })

View File

@ -1,11 +1,11 @@
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { history, useModel } from '@umijs/max' import { history, useModel } from '@umijs/max'
import { Modal, Input, Upload, message, Checkbox } from 'antd' import { Modal, Input, InputNumber, Upload, message, Checkbox } from 'antd'
import { EditOutlined, UploadOutlined, PlusOutlined, DeleteOutlined, SearchOutlined } from '@ant-design/icons' import { EditOutlined, UploadOutlined, PlusOutlined, DeleteOutlined, SearchOutlined, SaveOutlined } from '@ant-design/icons'
import { LOC_STOR_KEY } from '@/common/constants' import { LOC_STOR_KEY } from '@/common/constants'
import PhoneAuthModal from '@/components/phone-auth-modal' import PhoneAuthModal from '@/components/phone-auth-modal'
import api from '@/services/api' import api from '@/services/api'
import type { LandingHero, LandingNewsItem } from '@/services/api/config' import type { LandingHero, LandingNewsItem, LandingCaseItem } from '@/services/api/config'
import type { EcoMaterial } from '@/services/api/material' import type { EcoMaterial } from '@/services/api/material'
import { articles } from '@/common/articles' import { articles } from '@/common/articles'
import './landing.css' import './landing.css'
@ -23,10 +23,10 @@ const ChevronIcon = ({ dir }: { dir: 'left' | 'right' }) => (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.4} strokeLinecap="round" strokeLinejoin="round"><path d={dir === 'left' ? 'M15 5l-7 7 7 7' : 'M9 5l7 7-7 7'} /></svg> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.4} strokeLinecap="round" strokeLinejoin="round"><path d={dir === 'left' ? 'M15 5l-7 7 7 7' : 'M9 5l7 7-7 7'} /></svg>
) )
const FALLBACK_HERO_IMAGE = '/iapip-web/images/hero-interior.jpg'
const DEFAULT_HERO: LandingHero = { const DEFAULT_HERO: LandingHero = {
title: '装修住得安心,\n先把污染源头挡在门外', title: '装修住得安心,\n先把污染源头挡在门外',
description: '输入房间、环境与所用材料,系统用稳态质量平衡公式预测甲醛、苯、TVOC 等 6 项污染物浓度,判定是否超标,并溯源到具体污染材料,给出整改建议。', description: '输入房间、环境与所用材料,系统用稳态质量平衡公式预测甲醛、苯、TVOC 等 6 项污染物浓度,判定是否超标,并溯源到具体污染材料,给出整改建议。'
image: '/iapip-web/images/hero-interior.jpg'
} }
const DEFAULT_NEWS_TITLE = '读懂装修污染,从知道哪些是健康材料开始' const DEFAULT_NEWS_TITLE = '读懂装修污染,从知道哪些是健康材料开始'
@ -40,11 +40,13 @@ const stats = [
{ n: '6 项', t: '污染物 · 甲醛/苯/TVOC/氨/氡/VOC' }, { n: '6 项', t: '污染物 · 甲醛/苯/TVOC/氨/氡/VOC' },
{ n: '2 部', t: '国标依据 · 18883 / 50325' } { n: '2 部', t: '国标依据 · 18883 / 50325' }
] ]
const cases = [ const DEFAULT_CASES: LandingCaseItem[] = [
{ type: '住宅 · I类民用建筑', name: '锦绣华庭 · 主卧', meta: '主源:多层实木复合地板 · 人造板衣柜', w1: 82, v1: '0.18', w2: 36, v2: '0.08' }, { type: '住宅 · I类民用建筑', name: '锦绣华庭 · 主卧', meta: '主源:多层实木复合地板 · 人造板衣柜', w1: 82, v1: '0.18', w2: 36, v2: '0.08' },
{ type: '住宅 · I类民用建筑', name: '翠湖天地 · 儿童房', meta: '主源:人造板衣柜 · 壁纸基膜', w1: 95, v1: '0.21', w2: 32, v2: '0.07' }, { type: '住宅 · I类民用建筑', name: '翠湖天地 · 儿童房', meta: '主源:人造板衣柜 · 壁纸基膜', w1: 95, v1: '0.21', w2: 32, v2: '0.07' },
{ type: '酒店客房 · II类民用建筑', name: '云栖精选酒店 · 标准间', meta: '主源:木器漆饰面 · 软包', w1: 90, v1: '0.72', w2: 58, v2: '0.46' } { type: '酒店客房 · II类民用建筑', name: '云栖精选酒店 · 标准间', meta: '主源:木器漆饰面 · 软包', w1: 90, v1: '0.72', w2: 58, v2: '0.46' }
] ]
const DEFAULT_CASES_TITLE = '预测 → 识别 → 优化,你家的装修健康卫士'
const DEFAULT_CASES_SUB = '真实流程演示:从预测超标,到识别主要污染材料,再到优化复测达标。'
const steps = [ const steps = [
{ h: '录入房间与材料', p: '选择房间、填写面积层高与通风换气率,勾选所用装修材料及用量。' }, { h: '录入房间与材料', p: '选择房间、填写面积层高与通风换气率,勾选所用装修材料及用量。' },
{ h: '预测', p: '按稳态质量平衡 C = Σ(EFᵢ·Aᵢ)/(n·V) 计算 6 项污染物浓度,对照国标判定达标。' }, { h: '预测', p: '按稳态质量平衡 C = Σ(EFᵢ·Aᵢ)/(n·V) 计算 6 项污染物浓度,对照国标判定达标。' },
@ -124,6 +126,60 @@ export default function Landing() {
} catch { message.error('上传失败') } } catch { message.error('上传失败') }
} }
// --- 预测案例编辑 ---
const [cases, setCases] = useState<LandingCaseItem[]>(DEFAULT_CASES)
const [casesTitle, setCasesTitle] = useState(DEFAULT_CASES_TITLE)
const [casesSub, setCasesSub] = useState(DEFAULT_CASES_SUB)
const [caseEditOpen, setCaseEditOpen] = useState(false)
const [caseEditForm, setCaseEditForm] = useState<{ title: string; sub: string; items: LandingCaseItem[] }>({ title: '', sub: '', items: [] })
const [caseSaving, setCaseSaving] = useState(false)
useEffect(() => {
api.config.getLandingCases({ skipErrorHandler: true }).then(data => {
if (data) {
if (data.sectionTitle) setCasesTitle(data.sectionTitle)
if (data.sectionSub) setCasesSub(data.sectionSub)
if (data.items?.length) setCases(data.items)
}
}).catch(() => {})
}, [])
const openCaseEdit = () => {
setCaseEditForm({ title: casesTitle, sub: casesSub, items: cases.map(c => ({ ...c })) })
setCaseEditOpen(true)
}
const updateCaseField = (idx: number, field: keyof LandingCaseItem, val: any) => {
setCaseEditForm(prev => ({
...prev,
items: prev.items.map((c, i) => i === idx ? { ...c, [field]: val } : c)
}))
}
const addCase = () => {
setCaseEditForm(prev => ({
...prev,
items: [...prev.items, { type: '住宅 · I类民用建筑', name: '新案例', meta: '', w1: 50, v1: '0.10', w2: 30, v2: '0.05' }]
}))
}
const removeCase = (idx: number) => {
setCaseEditForm(prev => ({ ...prev, items: prev.items.filter((_, i) => i !== idx) }))
}
const saveCases = async () => {
setCaseSaving(true)
try {
await api.config.updateLandingCases({
sectionTitle: caseEditForm.title,
sectionSub: caseEditForm.sub,
items: caseEditForm.items
})
setCasesTitle(caseEditForm.title)
setCasesSub(caseEditForm.sub)
setCases(caseEditForm.items)
setCaseEditOpen(false)
message.success('预测案例已保存')
} catch { message.error('保存失败') }
finally { setCaseSaving(false) }
}
const openPredict = () => { const openPredict = () => {
if (hasToken()) history.push('/home') if (hasToken()) history.push('/home')
else setAuthOpen(true) else setAuthOpen(true)
@ -135,14 +191,23 @@ export default function Landing() {
// --- Hero 编辑 --- // --- Hero 编辑 ---
const [hero, setHero] = useState<LandingHero>(DEFAULT_HERO) const [hero, setHero] = useState<LandingHero>(DEFAULT_HERO)
const [configReady, setConfigReady] = useState(false)
const [editOpen, setEditOpen] = useState(false) const [editOpen, setEditOpen] = useState(false)
const [editForm, setEditForm] = useState<LandingHero>({}) const [editForm, setEditForm] = useState<LandingHero>({})
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
useEffect(() => { useEffect(() => {
Promise.all([
api.config.getLandingConfig({ skipErrorHandler: true }) api.config.getLandingConfig({ skipErrorHandler: true })
.then(data => { if (data) setHero({ ...DEFAULT_HERO, ...data }) }) .then(data => {
.catch(() => {}) setHero(prev => ({
...DEFAULT_HERO,
image: FALLBACK_HERO_IMAGE,
...(data || {}),
...(!data?.image ? { image: FALLBACK_HERO_IMAGE } : {})
}))
})
.catch(() => { setHero(prev => ({ ...prev, image: FALLBACK_HERO_IMAGE })) }),
api.config.getLandingNews({ skipErrorHandler: true }) api.config.getLandingNews({ skipErrorHandler: true })
.then(data => { .then(data => {
if (data) { if (data) {
@ -151,6 +216,7 @@ export default function Landing() {
} }
}) })
.catch(() => {}) .catch(() => {})
]).finally(() => setConfigReady(true))
}, []) }, [])
const openEdit = () => { const openEdit = () => {
@ -248,6 +314,8 @@ export default function Landing() {
} }
} }
if (!configReady) return <div className="lp" style={{ minHeight: '100vh' }} />
return ( return (
<div className="lp"> <div className="lp">
<header className="nav"> <header className="nav">
@ -288,7 +356,7 @@ export default function Landing() {
</div> </div>
</div> </div>
<div className="hero-visual"> <div className="hero-visual">
<img className="hero-img" src={hero.image} alt="室内装修实景" style={{ objectFit: 'cover', display: 'block' }} /> {hero.image && <img className="hero-img" src={hero.image} alt="室内装修实景" style={{ objectFit: 'cover', display: 'block' }} />}
{isAdmin && ( {isAdmin && (
<Upload accept="image/jpeg,image/png,image/webp,image/gif" showUploadList={false} beforeUpload={handleImageUpload}> <Upload accept="image/jpeg,image/png,image/webp,image/gif" showUploadList={false} beforeUpload={handleImageUpload}>
<button className="edit-icon hero-img-edit" title="更换图片"><UploadOutlined /></button> <button className="edit-icon hero-img-edit" title="更换图片"><UploadOutlined /></button>
@ -348,7 +416,7 @@ export default function Landing() {
<section className="sec" id="cases" style={{ background: 'var(--bg2)' }}> <section className="sec" id="cases" style={{ background: 'var(--bg2)' }}>
<div className="wrap"> <div className="wrap">
<div className="sec-head"> <div className="sec-head">
<div><div className="sec-tag"></div><h2 className="sec-h"> ,</h2><p className="sec-sub">真实流程演示:从预测超标,,</p></div> <div><div className="sec-tag"></div><h2 className="sec-h">{casesTitle}{isAdmin && <button className="edit-icon" title="编辑预测案例" onClick={openCaseEdit}><EditOutlined /></button>}</h2><p className="sec-sub">{casesSub}</p></div>
</div> </div>
<div className="cases-grid"> <div className="cases-grid">
{cases.map((c, i) => ( {cases.map((c, i) => (
@ -552,6 +620,78 @@ export default function Landing() {
</button> </button>
</div> </div>
</Modal> </Modal>
{/* 预测案例编辑弹窗 */}
<Modal
title="编辑预测案例"
open={caseEditOpen}
onOk={saveCases}
onCancel={() => setCaseEditOpen(false)}
confirmLoading={caseSaving}
okText="保存"
cancelText="取消"
width={720}
styles={{ body: { maxHeight: '65vh', overflowY: 'auto' } }}
>
<div style={{ display: 'flex', flexDirection: 'column', gap: 20 }}>
<div>
<div style={{ marginBottom: 6, fontWeight: 500 }}></div>
<Input value={caseEditForm.title} onChange={e => setCaseEditForm(f => ({ ...f, title: e.target.value }))} />
</div>
<div>
<div style={{ marginBottom: 6, fontWeight: 500 }}></div>
<Input value={caseEditForm.sub} onChange={e => setCaseEditForm(f => ({ ...f, sub: e.target.value }))} />
</div>
{caseEditForm.items.map((c, idx) => (
<div key={idx} className="news-edit-card">
<div className="news-edit-card-head">
<span style={{ fontWeight: 600 }}> {idx + 1}</span>
{caseEditForm.items.length > 1 && (
<button className="edit-icon" style={{ width: 28, height: 28 }} title="删除" onClick={() => removeCase(idx)}>
<DeleteOutlined style={{ color: '#bf4a30' }} />
</button>
)}
</div>
<div className="news-edit-row">
<div style={{ flex: 1 }}>
<div className="news-edit-label"></div>
<Input value={c.type} onChange={e => updateCaseField(idx, 'type', e.target.value)} placeholder="如: 住宅 · I类民用建筑" />
</div>
<div style={{ flex: 1 }}>
<div className="news-edit-label"></div>
<Input value={c.name} onChange={e => updateCaseField(idx, 'name', e.target.value)} placeholder="如: 锦绣华庭 · 主卧" />
</div>
</div>
<div>
<div className="news-edit-label"></div>
<Input value={c.meta} onChange={e => updateCaseField(idx, 'meta', e.target.value)} placeholder="如: 主源:多层实木复合地板 · 人造板衣柜" />
</div>
<div className="news-edit-row">
<div style={{ flex: 1 }}>
<div className="news-edit-label"></div>
<Input value={c.v1} onChange={e => updateCaseField(idx, 'v1', e.target.value)} placeholder="如: 0.18" />
</div>
<div style={{ flex: 1 }}>
<div className="news-edit-label"> %</div>
<InputNumber min={0} max={100} value={c.w1} onChange={v => updateCaseField(idx, 'w1', v ?? 0)} style={{ width: '100%' }} />
</div>
<div style={{ flex: 1 }}>
<div className="news-edit-label"></div>
<Input value={c.v2} onChange={e => updateCaseField(idx, 'v2', e.target.value)} placeholder="如: 0.08" />
</div>
<div style={{ flex: 1 }}>
<div className="news-edit-label"> %</div>
<InputNumber min={0} max={100} value={c.w2} onChange={v => updateCaseField(idx, 'w2', v ?? 0)} style={{ width: '100%' }} />
</div>
</div>
</div>
))}
<button className="news-add-btn" onClick={addCase}>
<PlusOutlined />
</button>
</div>
</Modal>
{/* 环保建材编辑弹窗 */} {/* 环保建材编辑弹窗 */}
<Modal <Modal
title="选择展示的环保建材" title="选择展示的环保建材"

View File

@ -0,0 +1,252 @@
import { HTTP_HEADER } from '@/common/constants'
import { AnyObj, ApcLimitStandard, CityInformation, MaterialCategory, ProjectSpaceCategory } from '@/common/types'
import { getLocalToken } from '@/common/utils'
import { request } from '@umijs/max'
export async function getProjSpaceCatList(query?: { project_category?: string }, options?: AnyObj) {
return request<ProjectSpaceCategory[]>(`/api/cfg/proj-space-cat`, {
method: 'GET',
headers: {
[HTTP_HEADER.AUTHORIZATION]: getLocalToken()
},
params: query,
...(options || {})
})
}
export async function getProjSpaceCat(project_category: string, options?: AnyObj) {
return request<ProjectSpaceCategory | null>(`/api/cfg/proj-space-cat/by`, {
method: 'GET',
headers: {
[HTTP_HEADER.AUTHORIZATION]: getLocalToken()
},
params: {
project_category
},
...(options || {})
})
}
export async function getMtrlCatList(options?: AnyObj) {
return request<MaterialCategory[]>(`/api/cfg/mtrl-cat`, {
method: 'GET',
headers: {
[HTTP_HEADER.AUTHORIZATION]: getLocalToken()
},
...(options || {})
})
}
export type MtrlCatPayload = {
pry_no_1?: number
first_category: string
second_categories: {
second_category: string
unit?: string
}[]
}
export async function createMtrlCat(body: MtrlCatPayload, options?: AnyObj) {
return request(`/api/cfg/mtrl-cat`, {
method: 'POST',
headers: {
[HTTP_HEADER.AUTHORIZATION]: getLocalToken()
},
data: body,
...(options || {})
})
}
export async function updateMtrlCat(body: MtrlCatPayload & { pry_no_1: number }, options?: AnyObj) {
return request(`/api/cfg/mtrl-cat`, {
method: 'PUT',
headers: {
[HTTP_HEADER.AUTHORIZATION]: getLocalToken()
},
data: body,
...(options || {})
})
}
export async function deleteMtrlCat(pry_no_1: number, options?: AnyObj) {
return request(`/api/cfg/mtrl-cat/${pry_no_1}`, {
method: 'DELETE',
headers: {
[HTTP_HEADER.AUTHORIZATION]: getLocalToken()
},
...(options || {})
})
}
export async function getCityInfoList(query?: { province?: string; city?: string }, options?: AnyObj) {
return request<CityInformation[]>(`/api/cfg/city-info`, {
method: 'GET',
headers: {
[HTTP_HEADER.AUTHORIZATION]: getLocalToken()
},
params: query,
...(options || {})
})
}
export async function getCityInfo(city: string, options?: AnyObj) {
return request<{
province: string
city: string
climatic_zone?: string
env_temp: number
env_hum: number
} | null>(`/api/cfg/city-info/by`, {
method: 'GET',
headers: {
[HTTP_HEADER.AUTHORIZATION]: getLocalToken()
},
params: {
city
},
...(options || {})
})
}
export async function getAPCLmtStdList(options?: AnyObj) {
return request<ApcLimitStandard[]>(`/api/cfg/apc-lmt-std`, {
method: 'GET',
headers: {
[HTTP_HEADER.AUTHORIZATION]: getLocalToken()
},
...(options || {})
})
}
export type LandingHero = {
title?: string
description?: string
image?: string
}
export async function getLandingConfig(options?: AnyObj) {
return request<LandingHero | null>('/api/cfg/landing', {
method: 'GET',
...(options || {})
})
}
export async function updateLandingConfig(body: LandingHero, options?: AnyObj) {
return request('/api/cfg/landing', {
method: 'PUT',
headers: {
[HTTP_HEADER.AUTHORIZATION]: getLocalToken()
},
data: body,
...(options || {})
})
}
export type LandingNewsItem = {
id: string
tag?: string
date?: string
title: string
summary?: string
image?: string
}
export type LandingNews = {
sectionTitle?: string
items?: LandingNewsItem[]
}
export async function getLandingNews(options?: AnyObj) {
return request<LandingNews | null>('/api/cfg/landing-news', {
method: 'GET',
...(options || {})
})
}
export async function updateLandingNews(body: LandingNews, options?: AnyObj) {
return request('/api/cfg/landing-news', {
method: 'PUT',
headers: {
[HTTP_HEADER.AUTHORIZATION]: getLocalToken()
},
data: body,
...(options || {})
})
}
export async function getArticleContent(id: string, options?: AnyObj) {
return request<{ html: string } | null>(`/api/cfg/article/${id}`, {
method: 'GET',
...(options || {})
})
}
export async function uploadArticleDoc(id: string, file: File) {
const formData = new FormData()
formData.append('file', file)
return request<{ html: string; title?: string }>(`/api/cfg/article/${id}/upload-doc`, {
method: 'POST',
headers: {
[HTTP_HEADER.AUTHORIZATION]: getLocalToken()
},
data: formData
})
}
export async function getCaseImages(options?: any) {
return request<Record<string, string>>('/api/cfg/case-images', { method: 'GET', ...(options || {}) })
}
export async function setCaseImage(index: number, url: string) {
return request<Record<string, string>>(`/api/cfg/case-images/${index}`, {
method: 'PUT',
headers: { [HTTP_HEADER.AUTHORIZATION]: getLocalToken() },
data: { url }
})
}
export type LandingCaseItem = {
type: string
name: string
meta?: string
w1?: number
v1?: string
w2?: number
v2?: string
}
export type LandingCases = {
sectionTitle?: string
sectionSub?: string
items?: LandingCaseItem[]
}
export async function getLandingCases(options?: AnyObj) {
return request<LandingCases | null>('/api/cfg/landing-cases', {
method: 'GET',
...(options || {})
})
}
export async function updateLandingCases(body: LandingCases, options?: AnyObj) {
return request('/api/cfg/landing-cases', {
method: 'PUT',
headers: {
[HTTP_HEADER.AUTHORIZATION]: getLocalToken()
},
data: body,
...(options || {})
})
}
export async function uploadLandingImage(file: File) {
const formData = new FormData()
formData.append('file', file)
return request<{ url: string }>('/api/cfg/landing/upload', {
method: 'POST',
headers: {
[HTTP_HEADER.AUTHORIZATION]: getLocalToken()
},
data: formData
})
}