chore: initialize deployable website and CMS

This commit is contained in:
Codex User
2026-09-08 11:57:17 +08:00
commit 3a99ae30c5
57 changed files with 11205 additions and 0 deletions
+164
View File
@@ -0,0 +1,164 @@
import { useEffect, useState } from 'react'
import { useParams, Link } from 'react-router-dom'
function Radar({scores}:{scores:Record<string,number>}){
const keys=['value1','value2','value3','value4']
const labels:{[k:string]:string}={value1:'价值创造',value2:'价值传递',value3:'价值交付',value4:'价值支持'}
const vals = keys.map(k=> scores[k]||0)
const max=5
const size=200, cx=100, cy=100, r=80
const points = vals.map((v,i)=>{
const angle = -90 + (360/keys.length)*i
const rad = angle*Math.PI/180
const rr = (v/max)*r
return `${cx + rr*Math.cos(rad)},${cy + rr*Math.sin(rad)}`
}).join(' ')
const grid = [0.25,0.5,0.75,1].map(s=>{
const pts = keys.map((_,i)=>{
const angle=-90+(360/keys.length)*i
const rad=angle*Math.PI/180
const rr=r*s
return `${cx+rr*Math.cos(rad)},${cy+rr*Math.sin(rad)}`
}).join(' ')
return <polygon key={s} points={pts} fill="none" stroke="#e2e8f0" strokeWidth={1} />
})
return (
<div style={{textAlign:'center'}}>
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`}>
{grid}
{keys.map((k,i)=>{
const angle=-90+(360/keys.length)*i
const rad=angle*Math.PI/180
return <line key={k} x1={cx} y1={cy} x2={cx+r*Math.cos(rad)} y2={cy+r*Math.sin(rad)} stroke="#e2e8f0" />
})}
<polygon points={points} fill="rgba(22,62,112,0.2)" stroke="#163E70" strokeWidth={2} />
{vals.map((v,i)=>{
const angle=-90+(360/keys.length)*i
const rad=angle*Math.PI/180
const rr=(v/max)*r
return <circle key={i} cx={cx+rr*Math.cos(rad)} cy={cy+rr*Math.sin(rad)} r={4} fill="#163E70" />
})}
</svg>
<div style={{display:'flex', justifyContent:'space-around', fontSize:11, color:'#475569'}}>
{keys.map(k=> <span key={k}>{labels[k]}:{scores[k]}</span>)}
</div>
</div>
)
}
export default function Report(){
const {token}=useParams()
const [data,setData]=useState<any>(null)
const [loading,setLoading]=useState(true)
const [err,setErr]=useState('')
const [lead,setLead]=useState({name:'',company:'',phone:''})
const [leadDone,setLeadDone]=useState(false)
const [showFull,setShowFull]=useState(false)
useEffect(()=>{
fetch(`/api/diagnosis/reports/${token}`).then(r=>r.json()).then(j=>{
if(j.code!==0) throw new Error(j.msg)
setData(j.data)
}).catch(e=> setErr(e.message)).finally(()=> setLoading(false))
},[token])
if(loading) return <div className="container section">... &lt;5s</div>
if(err) return <div className="container section"><div className="placeholder">{err}</div><Link to="/diagnosis" className="btn btn-primary"></Link></div>
if(!data) return null
const scores=data.scores, amb=data.amb, gap=data.gap, rc=data.root_cause, ps=data.path_suggestion
const shareUrl=`${location.origin}/diagnosis/share/${data.share_token}`
const submitLead=async()=>{
if(lead.phone && !/^1[3-9]\d{9}$/.test(lead.phone)) return alert('手机号格式错误')
const r=await fetch(`/api/diagnosis/reports/${token}/lead`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(lead)}).then(r=>r.json())
if(r.code===0){ setLeadDone(true); setShowFull(true) }
else alert(r.msg)
}
return (
<div>
<div className="page-header">
<div className="container">
<h1></h1>
<p></p>
<p style={{fontSize:12, color:'rgba(255,255,255,0.7)'}}>ID {data.token} · {new Date(data.expires_at).toLocaleDateString()}</p>
</div>
</div>
<section className="section">
<div className="container" style={{maxWidth:800}}>
<div className="card">
<h3>1. </h3>
<Radar scores={scores} />
<div style={{marginTop:12, display:'grid', gridTemplateColumns:'1fr 1fr', gap:10, fontSize:13}}>
{Object.keys(amb).map(k=> <div key={k} style={{background:'#f8fafc', padding:8, borderRadius:6}}>{k}:{amb[k]}·{scores[k]}</div>)}
</div>
</div>
<div className="card">
<h3>2. </h3>
<table className="table"><thead><tr><th></th><th></th><th></th><th>(5)</th></tr></thead>
<tbody>{Object.keys(scores).map(k=> <tr key={k}><td>{k}</td><td>{scores[k]}</td><td>{amb[k]}</td><td>{gap[k]}</td></tr>)}</tbody>
</table>
</div>
<div className="card">
<h3>3. </h3>
<p style={{fontSize:14}}>{rc?.label}{rc?.reason}</p>
<p style={{fontSize:12,color:'#64748b'}}></p>
</div>
<div className="card">
<h3>4. </h3>
<p><strong>{ps?.stage}</strong> - {ps?.recommendation} {ps?.overall}</p>
<p style={{fontSize:12,color:'#475569'}}></p>
</div>
{!showFull && (
<div className="card" style={{border:'2px solid #163E70'}}>
<h3>5. </h3>
<p style={{fontSize:13, color:'#64748b'}}> + </p>
<div className="form-row" style={{marginTop:12}}>
<div className="field"><label></label><input value={lead.name} onChange={e=> setLead({...lead,name:e.target.value})} /></div>
<div className="field"><label></label><input value={lead.company} onChange={e=> setLead({...lead,company:e.target.value})} /></div>
</div>
<div className="field"><label></label><input value={lead.phone} onChange={e=> setLead({...lead,phone:e.target.value})} placeholder="11位手机号" /></div>
<button className="btn btn-primary" onClick={submitLead}></button>
<button className="btn btn-ghost" style={{marginLeft:8}} onClick={()=> setShowFull(true)}></button>
</div>
)}
{showFull && (
<>
<div className="card">
<h3>5. </h3>
<p style={{fontSize:13}}>{ps?.stage==='A态入门'?'推荐:A态入门方案 · 单点突破':'M态进阶'===ps?.stage?'推荐:M态进阶方案 · 机制完善':'推荐:B态全案 · 系统化构建'}</p>
<Link to="/services" className="btn btn-secondary"></Link>
</div>
<div className="card">
<h3>6. </h3>
<p style={{fontSize:13}}> / / 400线</p>
<div style={{display:'flex',gap:10}}>
<Link to="/contact" className="btn btn-primary"></Link>
<a href={`tel:400`} className="btn btn-ghost">400线</a>
</div>
</div>
</>
)}
<div className="card" style={{background:'#fff7e6'}}>
<h3>7. </h3>
<p style={{fontSize:12,color:'#92400e'}}></p>
</div>
<div style={{display:'flex', gap:10, flexWrap:'wrap', marginTop:16}}>
<a href={`/api/diagnosis/reports/${token}/pdf`} className="btn btn-primary">PDF</a>
<button className="btn btn-ghost" onClick={()=> navigator.clipboard.writeText(shareUrl).then(()=> alert('分享链接已复制:'+shareUrl))}></button>
<Link to="/diagnosis" className="btn btn-ghost"></Link>
</div>
<div style={{fontSize:11,color:'#94a3b8', marginTop:8}}>{shareUrl} · </div>
</div>
</section>
</div>
)
}
+24
View File
@@ -0,0 +1,24 @@
import { useEffect, useState } from 'react'
import { useParams } from 'react-router-dom'
export default function Share(){
const {shareToken}=useParams()
const [data,setData]=useState<any>(null)
const [err,setErr]=useState('')
useEffect(()=>{
fetch(`/api/diagnosis/share/${shareToken}`).then(r=>r.json()).then(j=>{
if(j.code!==0) throw new Error(j.msg)
setData(j.data)
}).catch(e=> setErr(e.message))
},[shareToken])
if(err) return <div className="container section"><div className="placeholder">{err}</div></div>
if(!data) return <div className="container section">...</div>
return (
<div className="container section">
<h2></h2>
<p style={{fontSize:12,color:'#64748b'}}></p>
<div className="card"><pre style={{whiteSpace:'pre-wrap',fontSize:12}}>{JSON.stringify({scores:data.scores, amb:data.amb, gap:data.gap}, null, 2)}</pre></div>
<a href={`/api/diagnosis/reports/${data.token}/pdf`} className="btn btn-primary">PDF</a>
</div>
)
}
+107
View File
@@ -0,0 +1,107 @@
import { useEffect, useState } from 'react'
import { Link, useNavigate } from 'react-router-dom'
type Q = { id:string, group_key:string, group_name:string, type:string, title:string, options:any[], sort_order:number }
const STORAGE_KEY='diagnosis_progress'
export default function Diagnosis(){
const [questions,setQuestions]=useState<Q[]>([])
const [answers,setAnswers]=useState<Record<string,any>>({})
const [idx,setIdx]=useState(0)
const nav=useNavigate()
const [loading,setLoading]=useState(true)
useEffect(()=>{
fetch('/api/diagnosis/questions').then(r=>r.json()).then(j=>{
const list = (j.data||[]).sort((a:Q,b:Q)=> b.sort_order - a.sort_order)
setQuestions(list)
// 断点续填
const saved = localStorage.getItem(STORAGE_KEY)
if(saved){
try{ const p=JSON.parse(saved); if(p.answers) setAnswers(p.answers); if(typeof p.idx==='number') setIdx(p.idx)}catch{}
}
setLoading(false)
})
},[])
useEffect(()=>{
localStorage.setItem(STORAGE_KEY, JSON.stringify({answers, idx}))
},[answers, idx])
const q = questions[idx]
const progress = questions.length? Math.round(((idx)/questions.length)*100):0
const answeredCount = Object.keys(answers).length
const handleAnswer=(val:any)=>{
const next={...answers, [q.id]:val}
setAnswers(next)
}
const nextQ=()=>{
if(idx<questions.length-1) setIdx(idx+1)
else submit()
}
const prevQ=()=> setIdx(Math.max(0, idx-1))
const submit=async()=>{
// 创建会话并提交
const sess = await fetch('/api/diagnosis/sessions',{method:'POST'}).then(r=>r.json())
const token = sess.data.token
await fetch(`/api/diagnosis/sessions/${token}`,{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify({answers})})
const rep = await fetch(`/api/diagnosis/sessions/${token}/submit`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({answers})}).then(r=>r.json())
localStorage.removeItem(STORAGE_KEY)
nav(`/diagnosis/report/${rep.data.token}`)
}
if(loading) return <div className="container section">... &lt;2s</div>
if(!q) return <div className="container section">CMS后台配置</div>
const isAnswered = answers[q.id]!==undefined
return (
<div>
<div className="page-header">
<div className="container">
<h1></h1>
<p>15-20 · · · </p>
<div style={{marginTop:12, background:'rgba(255,255,255,0.2)', height:6, borderRadius:6}}>
<div style={{width:`${progress}%`, height:'100%', background:'#fff', borderRadius:6}} />
</div>
<div style={{fontSize:12, color:'rgba(255,255,255,0.8)', marginTop:4}}>{idx+1}/{questions.length} · {answeredCount} · </div>
</div>
</div>
<section className="section">
<div className="container" style={{maxWidth:720}}>
<div className="card">
<div style={{fontSize:12, color:'#0f6b5b', fontWeight:800}}>{q.group_name} · {q.type==='scale'?'量表 1-5':q.type==='multiple'?'多选':'单选'}</div>
<h3 style={{marginTop:6}}>{idx+1}. {q.title}</h3>
<div style={{marginTop:12, display:'grid', gap:8}}>
{q.options.map((opt:any)=> {
const selected = q.type==='multiple' ? (Array.isArray(answers[q.id]) && answers[q.id].includes(opt.label)) : answers[q.id]===opt.label
return (
<label key={opt.label} style={{border: selected?'2px solid #163E70':'1px solid #e2e8f0', background: selected?'#eff6ff':'#fff', padding:'10px 12px', borderRadius:8, cursor:'pointer', display:'flex', justifyContent:'space-between'}}>
<span><input type={q.type==='multiple'?'checkbox':'radio'} checked={!!selected} onChange={()=>{
if(q.type==='multiple'){
const arr=Array.isArray(answers[q.id])? [...answers[q.id]]:[]
if(selected) handleAnswer(arr.filter(v=>v!==opt.label))
else handleAnswer([...arr, opt.label])
} else handleAnswer(opt.label)
}} style={{marginRight:8}} />{opt.label}</span>
<span style={{fontSize:11,color:'#64748b'}}>{opt.score}</span>
</label>
)
})}
</div>
<div style={{display:'flex', gap:10, marginTop:16}}>
<button className="btn btn-ghost" onClick={prevQ} disabled={idx===0}></button>
<button className="btn btn-primary" style={{flex:1}} onClick={nextQ} disabled={!isAnswered}>{idx===questions.length-1?'提交生成报告':'下一步'}</button>
</div>
<div style={{fontSize:11, color:'#94a3b8', marginTop:8}}> · · </div>
</div>
<div style={{marginTop:12, display:'flex', gap:10}}>
<Link to="/self-check" className="btn btn-ghost"></Link>
<button className="btn btn-ghost" onClick={()=>{localStorage.removeItem(STORAGE_KEY); setAnswers({}); setIdx(0)}}></button>
</div>
</div>
</section>
</div>
)
}