chore: initialize deployable website and CMS
This commit is contained in:
@@ -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">生成报告中... <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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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">加载问卷... <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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user