Files
ameiba_cn_new/cms/admin/src/App.tsx
T

354 lines
38 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useEffect } from 'react'
const API_BASE = '' // 同域,相对路径
const SIDEBAR = [
{ key: 'config', label: '1 全局配置' },
{ key: 'texts', label: '2 全站文案' },
{ key: 'home', label: '3 首页模块' },
{ key: 'ameba', label: '4 阿美巴页面' },
{ key: 'evolution', label: '5 企业进化' },
{ key: 'methods', label: '6 方法与能力' },
{ key: 'services', label: '7 产品服务' },
{ key: 'cases', label: '8 案例管理' },
{ key: 'articles', label: '9 认知观察' },
{ key: 'selfcheck', label: '10 自我对照' },
{ key: 'diag_q', label: '11 诊断问卷' },
{ key: 'diag_config', label: '12 诊断配置' },
{ key: 'diag_reports', label: '13 诊断记录' },
{ key: 'diag_leads', label: '14 留资统计' },
{ key: 'contact_leads', label: '15 咨询线索' },
{ key: 'password', label: '⚙ 修改密码' },
]
function useAuth(){
const [token,setToken]=useState(localStorage.getItem('cms_token')||'')
useEffect(()=>{
const onUnauthorized=()=>setToken('')
window.addEventListener('cms:unauthorized',onUnauthorized)
return ()=>window.removeEventListener('cms:unauthorized',onUnauthorized)
},[])
const login=async(u:string,p:string)=>{
const r=await fetch('/api/auth/login',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({username:u,password:p})})
const j=await r.json()
if(j.code!==0) throw new Error(j.msg)
localStorage.setItem('cms_token', j.data.token)
setToken(j.data.token)
}
const logout=()=>{localStorage.removeItem('cms_token'); setToken('')}
return {token,login,logout}
}
async function api(path:string, token:string, opts:RequestInit={}){
const headers:any={...(opts.headers||{})}
if(token) headers['Authorization']='Bearer '+token
if(!(opts.body instanceof FormData)) headers['Content-Type']='application/json'
const response=await fetch(path,{...opts, headers})
const result=await response.json()
if(response.status===401 || result.code===401){
localStorage.removeItem('cms_token')
window.dispatchEvent(new Event('cms:unauthorized'))
}
return result
}
const FIELD_LABELS:Record<string,string> = {
site_title:'网站标题', slogan:'网站标语', seo_title:'SEO标题', seo_desc:'SEO描述', seo_keywords:'SEO关键词', hotline:'热线电话', contact_email:'联系邮箱', contact_address:'联系地址', cta_text:'行动按钮文字', cta_link:'行动按钮链接', qr_image_url:'二维码图片', qr_target_url:'二维码跳转链接', qr_label:'二维码旁文案',
brand_position:'品牌定位', mission:'使命/介绍', philosophy:'经营哲学', practice:'服务理念', steps:'步骤', scenarios:'适用场景', description:'描述',
original_state:'原始状态', goal:'目标', gap:'差距', root_cause:'根因', ameba_intervention:'阿美巴介入', evolution_path:'进化路径', actions:'行动', phased_result:'阶段性结果', continuous_iteration:'持续迭代',
stage:'适用阶段', intro:'简介', service_content:'服务内容', delivery:'交付内容', suitable:'适合对象', price_unit:'价格单位', advantage:'核心优势', detail_html:'详情正文',
guide:'引导语', questions:'问题配置', logic_text:'结果逻辑说明', next_guide:'下一步引导',
}
const fieldLabel=(key:string)=>FIELD_LABELS[key] ? `${FIELD_LABELS[key]}(${key})` : key
export default function App(){
const {token,login,logout}=useAuth()
const [user,setUser]=useState('')
const [pwd,setPwd]=useState('')
const [err,setErr]=useState('')
const [cur,setCur]=useState('config')
if(!token) return (
<div className="login">
<h3>CMS后台登录</h3>
<input placeholder="账号" value={user} onChange={e=>setUser(e.target.value)} />
<input placeholder="密码" type="password" value={pwd} onChange={e=>setPwd(e.target.value)} />
{err && <div style={{color:'#e11d48',fontSize:12}}>{err}</div>}
<button className="btn" style={{width:'100%',marginTop:12}} onClick={async()=>{
try{ await login(user,pwd); setErr('')}catch(e:any){setErr(e.message)}
}}>登录</button>
</div>
)
return (
<div className="layout">
<div className="sidebar">
<h2>阿美巴 CMS后台<br/><small style={{color:'#64748b'}}>V1.1 FINAL 内容管理</small></h2>
{SIDEBAR.map(s=> <div key={s.key} className={'nav-item'+(cur===s.key?' active':'')} onClick={()=>setCur(s.key)}>{s.label}</div>)}
<div style={{padding:20}}><button className="btn btn-ghost" onClick={logout}>退出登录</button></div>
</div>
<div className="main">
<div className="topbar"><h2>{SIDEBAR.find(s=>s.key===cur)?.label}</h2><span style={{fontSize:12,color:'#94a3b8'}}>内容与布局解耦 · 只改内容不改架构</span></div>
{cur==='config' && <Config token={token} />}
{cur==='texts' && <Texts token={token} />}
{cur==='home' && <Home token={token} />}
{cur==='ameba' && <Ameba token={token} />}
{cur==='evolution' && <Evolution token={token} />}
{cur==='methods' && <Methods token={token} />}
{cur==='services' && <Services token={token} />}
{cur==='cases' && <Cases token={token} />}
{cur==='articles' && <Articles token={token} />}
{cur==='selfcheck' && <SelfCheck token={token} />}
{cur==='diag_q' && <DiagQ token={token} />}
{cur==='diag_config' && <DiagConfig token={token} />}
{cur==='diag_reports' && <DiagReports token={token} />}
{cur==='diag_leads' && <DiagLeads token={token} />}
{cur==='contact_leads' && <ContactLeads token={token} />}
{cur==='password' && <ChangePassword token={token} />}
</div>
</div>
)
}
function Texts({token}:{token:string}){
const [list,setList]=useState<any[]>([])
useEffect(()=>{api('/api/cms/texts',token).then(j=>setList(j.data||[]))},[])
const update=(i:number,key:string,value:any)=>{const n=[...list]; n[i]={...n[i],[key]:value}; setList(n)}
const save=()=>api('/api/cms/texts',token,{method:'PUT',body:JSON.stringify(list)}).then(j=>alert(j.msg||'保存成功'))
const groups=list.reduce((acc:any,it:any)=>{const group=it.id.split('.')[0]; (acc[group] ||= []).push(it); return acc},{})
const groupNames:any={home:'首页(Home)',global_cta:'全站行动区(Global CTA)',ameba:'阿美巴页面(Ameba)',evolution:'企业进化页面(Evolution)',methods:'方法与能力(Methods)',services:'产品服务(Services)',cases:'实践案例(Cases)',insights:'认知观察(Insights)',contact:'联系页(Contact)',diagnosis:'诊断页(Diagnosis)',selfcheck:'轻量对照(Self Check)',footer:'页脚(Footer)'}
return <div className="card"><h3>全站文案</h3><p className="hint">这里的内容会覆盖首页、阿美巴页和全站行动区的默认文字。保存后刷新前台即可看到。</p>
{Object.entries(groups).map(([group,items]:any)=><div key={group} className="editor-block"><h4>{groupNames[group] || group}</h4>{items.map((it:any)=><div key={it.id} className="field"><label>{it.label} <small>({it.id})</small></label><textarea value={it.value||''} onChange={e=>update(list.findIndex(x=>x.id===it.id),'value',e.target.value)} /></div>)}</div>)}
<button className="btn" onClick={save}>保存全站文案</button>
</div>
}
function ContactLeads({token}:{token:string}){
const [list,setList]=useState<any[]>([])
useEffect(()=>{api('/api/cms/contact-leads',token).then(j=> setList(j.data||[]))},[])
return <div className="card"><h3>咨询线索(最近200条)</h3>
<table className="table"><thead><tr><th>姓名</th><th>企业</th><th>手机</th><th>需求</th><th>时间</th></tr></thead>
<tbody>{list.map(l=><tr key={l.id}><td>{l.name}</td><td>{l.company}</td><td>{l.phone}</td><td>{l.need}</td><td>{l.created_at}</td></tr>)}</tbody>
</table>
</div>
}
// —— 全局配置
function Config({token}:{token:string}){
const [f,setF]=useState<any>({})
useEffect(()=>{api('/api/cms/config',token).then(j=> setF(j.data||{}))},[])
const save=()=> api('/api/cms/config',token,{method:'PUT',body:JSON.stringify(f)}).then(j=> alert(j.msg||'保存成功'))
return <div className="card">
<h3>全局配置</h3>
{['site_title','slogan','seo_title','seo_desc','seo_keywords','hotline','contact_email','contact_address','cta_text','cta_link','qr_target_url','qr_label'].map(k=>(
<div key={k} className="field"><label>{fieldLabel(k)}</label><input value={f[k]||''} onChange={e=> setF({...f,[k]:e.target.value})} /></div>
))}
<div className="field"><label>{fieldLabel('qr_image_url')}</label><input value={f.qr_image_url||''} onChange={e=>setF({...f,qr_image_url:e.target.value})}/><Upload token={token} onUrl={url=>setF({...f,qr_image_url:url})}/>{f.qr_image_url&&<img src={f.qr_image_url} alt="二维码预览" style={{width:120,height:120,objectFit:'contain',marginTop:8,border:'1px solid #e2e8f0',borderRadius:8}}/>}</div>
<button className="btn" onClick={save}>保存</button>
</div>
}
function Home({token}:{token:string}){
const [list,setList]=useState<any[]>([])
useEffect(()=>{api('/api/cms/home-sections',token).then(j=> setList(j.data||[]))},[])
const save=()=> api('/api/cms/home-sections',token,{method:'PUT',body:JSON.stringify(list)}).then(()=> alert('保存成功'))
return <div className="card">
<h3>首页板块开关与排序(visible/sort_order)</h3>
<table className="table"><thead><tr><th>标识 key(Key)</th><th>标题</th><th>副标题</th><th>显示</th><th>排序</th></tr></thead>
<tbody>{list.map((it,i)=>(
<tr key={it.id}><td>{it.key}</td><td><input value={it.title} onChange={e=>{const n=[...list]; n[i].title=e.target.value; setList(n)}}/></td><td><input value={it.subtitle} onChange={e=>{const n=[...list]; n[i].subtitle=e.target.value; setList(n)}}/></td><td><input type="checkbox" checked={!!it.visible} onChange={e=>{const n=[...list];n[i].visible=e.target.checked?1:0; setList(n)}}/></td><td><input type="number" value={it.sort_order} onChange={e=>{const n=[...list];n[i].sort_order=Number(e.target.value); setList(n)}} /></td></tr>
))}</tbody></table>
<button className="btn" style={{marginTop:12}} onClick={save}>保存首页配置</button>
</div>
}
function Ameba({token}:{token:string}){
const [f,setF]=useState<any>({}); const [ex,setEx]=useState<any[]>([]); const [ne,setNe]=useState<any>({}); const [editingExpert,setEditingExpert]=useState(false)
useEffect(()=>{api('/api/cms/ameba',token).then(j=> setF(j.data||{})); api('/api/cms/experts',token).then(j=> setEx(j.data||[]))},[])
return <>
<div className="card"><h3>阿美巴页面文案</h3>
{['brand_position','mission','philosophy','practice'].map(k=>(
<div key={k} className="field"><label>{fieldLabel(k)}</label><textarea value={f[k]||''} onChange={e=> setF({...f,[k]:e.target.value})} /></div>
))}
<button className="btn" onClick={()=> api('/api/cms/ameba',token,{method:'PUT',body:JSON.stringify(f)}).then(()=> alert('保存成功'))}>保存阿美巴</button>
</div>
<div className="card"><h3>专家网络(增删改、上下架、排序)</h3>
<table className="table"><thead><tr><th>姓名</th><th>头衔</th><th>排序</th><th>状态</th><th>操作</th></tr></thead>
<tbody>{ex.map(e=> <tr key={e.id}><td>{e.name}</td><td>{e.title}</td><td>{e.sort_weight}</td><td>{e.status}</td><td><button onClick={()=>{setNe({...e});setEditingExpert(true)}}>编辑</button> <button onClick={()=>{if(confirm('删除?')) api('/api/cms/experts/'+e.id,token,{method:'DELETE'}).then(j=>{if(j.code===0)setEx(ex.filter(x=>x.id!==e.id))})}}>删除</button></td></tr>)}</tbody></table>
<div className="form-row" style={{marginTop:12}}>
<div className="field"><label>姓名</label><input value={ne.name||''} onChange={e=> setNe({...ne,name:e.target.value})}/></div>
<div className="field"><label>头衔</label><input value={ne.title||''} onChange={e=> setNe({...ne,title:e.target.value})}/></div>
</div>
<div className="field"><label>简介</label><textarea value={ne.bio||''} onChange={e=> setNe({...ne,bio:e.target.value})}/></div>
<div className="form-row"><div className="field"><label>排序</label><input type="number" value={ne.sort_weight||0} onChange={e=> setNe({...ne,sort_weight:Number(e.target.value)})}/></div><div className="field"><label>状态</label><select value={ne.status||'published'} onChange={e=> setNe({...ne,status:e.target.value})}><option value="published">published</option><option value="draft">draft</option></select></div></div>
<Upload token={token} onUrl={url=> setNe({...ne,avatar_url:url})} />
{ne.avatar_url && <img src={ne.avatar_url} style={{width:80,height:80,objectFit:'cover',borderRadius:8}}/>}
<button className="btn" onClick={()=> api(editingExpert?'/api/cms/experts/'+ne.id:'/api/cms/experts',token,{method:editingExpert?'PUT':'POST',body:JSON.stringify(ne)}).then(j=>{if(j.code===0){const saved={...ne,id:editingExpert?ne.id:j.data?.id};setEx(editingExpert?ex.map(x=>x.id===saved.id?saved:x):[...ex,saved]);setNe({});setEditingExpert(false);alert('保存成功')}else alert(j.msg||'保存失败')})}>{editingExpert?'保存专家':'新增专家'}</button>{editingExpert&&<button className="btn btn-ghost" style={{marginLeft:8}} onClick={()=>{setNe({});setEditingExpert(false)}}>取消编辑</button>}
</div>
</>
}
function Evolution({token}:{token:string}){
const [thought,setThought]=useState(''); const [fvs,setFvs]=useState<any[]>([]); const [ambs,setAmbs]=useState<any[]>([])
useEffect(()=>{api('/api/cms/evolution/thought',token).then(j=> setThought(j.data?.content||'')); api('/api/cms/four-values',token).then(j=> setFvs(j.data||[])); api('/api/cms/amb-states',token).then(j=> setAmbs(j.data||[]))},[])
const update=(setter:any,list:any[],i:number,key:string,value:any)=>{const n=[...list]; n[i]={...n[i],[key]:value}; setter(n)}
const saveValues=()=>Promise.all(fvs.map(v=>api('/api/cms/four-values/'+v.id,token,{method:'PUT',body:JSON.stringify(v)}))).then(()=>alert('四大价值保存成功'))
const saveAmbs=()=>Promise.all(ambs.map(a=>api('/api/cms/amb-states/'+a.id,token,{method:'PUT',body:JSON.stringify(a)}))).then(()=>alert('A/M/B 三态保存成功'))
return <>
<div className="card"><h3>企业进化思想(Enterprise Evolution Thought)</h3><div className="field"><label>正文(Content)</label><textarea value={thought} onChange={e=> setThought(e.target.value)} /></div><button className="btn" onClick={()=> api('/api/cms/evolution/thought',token,{method:'PUT',body:JSON.stringify({content:thought})}).then(()=> alert('保存成功'))}>保存</button></div>
<div className="card"><h3>四大价值(Four Values)</h3><p className="hint">点击“查看详情”进入前台对应页面;这里的名称、定义、说明、排序和发布状态都会同步到对应详情页。</p>{fvs.map((v,i)=><div key={v.id} className="editor-block"><div className="form-row"><div className="field"><label>标识 key(Key)</label><input value={v.key||''} onChange={e=>update(setFvs,fvs,i,'key',e.target.value)}/></div><div className="field"><label>名称(Name)</label><input value={v.name||''} onChange={e=>update(setFvs,fvs,i,'name',e.target.value)}/></div></div><div className="field"><label>定义/摘要(Definition)</label><textarea value={v.definition||''} onChange={e=>update(setFvs,fvs,i,'definition',e.target.value)}/></div><div className="field"><label>详情说明(Description)</label><textarea value={v.description||''} onChange={e=>update(setFvs,fvs,i,'description',e.target.value)}/></div><div className="form-row"><div className="field"><label>排序(Sort Order)</label><input type="number" value={v.sort_order||0} onChange={e=>update(setFvs,fvs,i,'sort_order',Number(e.target.value))}/></div><div className="field"><label>状态(Status)</label><select value={v.status||'published'} onChange={e=>update(setFvs,fvs,i,'status',e.target.value)}><option value="published">已发布(published)</option><option value="draft">草稿(draft)</option></select></div></div><div className="hint">前台对应:/evolution/values/{v.key || 'key'}</div></div>)}<button className="btn" onClick={saveValues}>保存四大价值</button></div>
<div className="card"><h3>A/M/B 三态(A/M/B States)</h3><p className="hint">原“固定说明不可删”约束已取消。名称、定义、描述和说明均可修改;说明为空也允许保存。</p>{ambs.map((a,i)=><div key={a.id} className="editor-block"><div className="form-row"><div className="field"><label>状态 key(Key)</label><input value={a.key||''} disabled /></div><div className="field"><label>名称(Name)</label><input value={a.name||''} onChange={e=>update(setAmbs,ambs,i,'name',e.target.value)}/></div></div><div className="field"><label>定义(Definition)</label><textarea value={a.definition||''} onChange={e=>update(setAmbs,ambs,i,'definition',e.target.value)}/></div><div className="field"><label>状态描述(Description)</label><textarea value={a.description||''} onChange={e=>update(setAmbs,ambs,i,'description',e.target.value)}/></div><div className="field"><label>固定说明(Fixed Note,可修改)</label><textarea value={a.fixed_note||''} onChange={e=>update(setAmbs,ambs,i,'fixed_note',e.target.value)}/></div><div className="hint">前台对应:/evolution/amb/{a.key}</div></div>)}<button className="btn" onClick={saveAmbs}>保存 A/M/B 三态</button></div>
</>
}
function Methods({token}:{token:string}){
const [proc,setProc]=useState<any>({}); const [sys,setSys]=useState<any[]>([]); const [f,setF]=useState<any>({status:'published'}); const [editing,setEditing]=useState(false)
useEffect(()=>{api('/api/cms/evolution-process',token).then(j=> setProc(j.data||{})); api('/api/cms/ten-systems',token).then(j=> setSys(j.data||[]))},[])
const edit=(item:any)=>{setF({...item}); setEditing(true)}
const save=()=>api(editing?'/api/cms/ten-systems/'+f.id:'/api/cms/ten-systems',token,{method:editing?'PUT':'POST',body:JSON.stringify(f)}).then(j=>{if(j.code===0){const saved={...f,id:editing?f.id:j.data?.id};setSys(editing?sys.map(x=>x.id===saved.id?saved:x):[...sys,saved]);setF({status:'published'});setEditing(false);alert('保存成功')}else alert(j.msg||'保存失败')})
return <>
<div className="card"><h3>企业进化梳理</h3>{['steps','scenarios','description'].map(k=> <div key={k} className="field"><label>{fieldLabel(k)}</label><textarea value={proc[k]||''} onChange={e=> setProc({...proc,[k]:e.target.value})} /></div>)}<button className="btn" onClick={()=> api('/api/cms/evolution-process',token,{method:'PUT',body:JSON.stringify(proc)}).then(()=> alert('保存成功'))}>保存</button></div>
<div className="card"><h3>十大系统</h3><p className="hint">前台“方法与能力”页面的卡片内容均可在这里编辑。</p><table className="table"><thead><tr><th>名称</th><th>排序</th><th>状态</th><th>操作</th></tr></thead><tbody>{sys.map(s=> <tr key={s.id}><td>{s.name}</td><td>{s.sort_order}</td><td>{s.status}</td><td><button onClick={()=>edit(s)}>编辑</button> <button onClick={()=>{if(confirm('删除?')) api('/api/cms/ten-systems/'+s.id,token,{method:'DELETE'}).then(j=>{if(j.code===0)setSys(sys.filter(x=>x.id!==s.id))})}}>删除</button></td></tr>)}</tbody></table><div className="editor-block"><h4>{editing?'编辑系统':'新增系统'}</h4><div className="field"><label>名称(Name)</label><input value={f.name||''} onChange={e=>setF({...f,name:e.target.value})}/></div><div className="field"><label>说明(Description)</label><textarea value={f.description||''} onChange={e=>setF({...f,description:e.target.value})}/></div><div className="form-row"><div className="field"><label>排序(Sort Order)</label><input type="number" value={f.sort_order||0} onChange={e=>setF({...f,sort_order:Number(e.target.value)})}/></div><div className="field"><label>状态(Status)</label><select value={f.status||'published'} onChange={e=>setF({...f,status:e.target.value})}><option value="published">已发布</option><option value="draft">草稿</option></select></div></div><button className="btn" onClick={save}>{editing?'保存修改':'新增系统'}</button>{editing&&<button className="btn btn-ghost" style={{marginLeft:8}} onClick={()=>{setF({status:'published'});setEditing(false)}}>取消编辑</button>}</div></div>
</>
}
function Services({token}:{token:string}){
const [list,setList]=useState<any[]>([]); const [f,setF]=useState<any>({status:'published',type:'custom'}); const [editing,setEditing]=useState(false)
useEffect(()=>{api('/api/cms/services',token).then(j=> setList(j.data||[]))},[])
const save=()=>api(editing?'/api/cms/services/'+f.id:'/api/cms/services',token,{method:editing?'PUT':'POST',body:JSON.stringify(f)}).then(j=>{if(j.code===0){const saved={...f,id:editing?f.id:j.data?.id};setList(editing?list.map(x=>x.id===saved.id?saved:x):[...list,saved]);setF({status:'published',type:'custom'});setEditing(false);alert('保存成功')}else alert(j.msg||'保存失败')})
return <>
<div className="card"><h3>产品服务列表(上下架、排序、价格校验)</h3><table className="table"><thead><tr><th>名称</th><th>类型</th><th>价格</th><th>状态</th><th>排序</th><th>操作</th></tr></thead><tbody>{list.map(s=> <tr key={s.id}><td>{s.name}</td><td>{s.type}</td><td>{s.price||'-'}</td><td>{s.status}</td><td>{s.sort_weight}</td><td><button onClick={()=>{setF({...s});setEditing(true)}}>编辑</button> <button onClick={()=>{if(confirm('删除?')) api('/api/cms/services/'+s.id,token,{method:'DELETE'}).then(j=>{if(j.code===0)setList(list.filter(x=>x.id!==s.id))})}}>删除</button></td></tr>)}</tbody></table></div>
<div className="card"><h3>{editing?'编辑产品':'新增产品'}</h3>
<div className="form-row"><div className="field"><label>名称*</label><input value={f.name||''} onChange={e=> setF({...f,name:e.target.value})}/></div><div className="field"><label>路径标识(slug)</label><input value={f.slug||''} onChange={e=> setF({...f,slug:e.target.value})}/></div></div>
<div className="form-row"><div className="field"><label>类型</label><select value={f.type||'custom'} onChange={e=> setF({...f,type:e.target.value})}><option value="standard">标准化(显示价格)</option><option value="custom">定制(咨询/获取方案)</option></select></div><div className="field"><label>价格</label><input value={f.price||''} onChange={e=> setF({...f,price:e.target.value})} placeholder="仅标准化需要"/></div></div>
{['stage','intro','service_content','delivery','suitable','price_unit','advantage','detail_html'].map(k=><div key={k} className="field"><label>{fieldLabel(k)}</label><textarea value={f[k]||''} onChange={e=>setF({...f,[k]:e.target.value})}/></div>)}
<div className="form-row"><div className="field"><label>状态</label><select value={f.status||'published'} onChange={e=> setF({...f,status:e.target.value})}><option value="published">published</option><option value="draft">draft</option></select></div><div className="field"><label>排序</label><input type="number" value={f.sort_weight||0} onChange={e=> setF({...f,sort_weight:Number(e.target.value)})}/></div></div>
<button className="btn" onClick={save}>{editing?'保存修改':'新增产品'}</button>{editing&&<button className="btn btn-ghost" style={{marginLeft:8}} onClick={()=>{setF({status:'published',type:'custom'});setEditing(false)}}>取消编辑</button>}
</div>
</>
}
function Cases({token}:{token:string}){
const [list,setList]=useState<any[]>([]); const [f,setF]=useState<any>({status:'published'}); const [editing,setEditing]=useState(false)
useEffect(()=>{api('/api/cms/cases',token).then(j=> setList(j.data||[]))},[])
const save=()=>api(editing?'/api/cms/cases/'+f.id:'/api/cms/cases',token,{method:editing?'PUT':'POST',body:JSON.stringify(f)}).then(j=>{if(j.code===0){const saved={...f,id:editing?f.id:j.data?.id};setList(editing?list.map(x=>x.id===saved.id?saved:x):[...list,saved]);setF({status:'published'});setEditing(false);alert('保存成功')}else alert(j.msg||'保存失败')})
return <>
<div className="card"><h3>案例列表(9段内容可编辑)</h3><table className="table"><thead><tr><th>标题</th><th>行业</th><th>状态</th><th>排序</th><th>操作</th></tr></thead><tbody>{list.map(c=> <tr key={c.id}><td>{c.title}</td><td>{c.industry}</td><td>{c.status}</td><td>{c.sort_weight}</td><td><button onClick={()=>{setF({...c});setEditing(true)}}>编辑</button> <button onClick={()=>{if(confirm('删除?')) api('/api/cms/cases/'+c.id,token,{method:'DELETE'}).then(j=>{if(j.code===0)setList(list.filter(x=>x.id!==c.id))})}}>删除</button></td></tr>)}</tbody></table></div>
<div className="card"><h3>{editing?'编辑案例':'新增案例'}</h3>
<div className="form-row"><div className="field"><label>标题*</label><input value={f.title||''} onChange={e=> setF({...f,title:e.target.value})}/></div><div className="field"><label>行业</label><input value={f.industry||''} onChange={e=> setF({...f,industry:e.target.value})}/></div></div>
{['original_state','goal','gap','root_cause','ameba_intervention','evolution_path','actions','phased_result','continuous_iteration'].map(k=>(
<div key={k} className="field"><label>{fieldLabel(k)}</label><textarea value={f[k]||''} onChange={e=> setF({...f,[k]:e.target.value})} /></div>
))}
<div className="form-row"><div className="field"><label>状态</label><select value={f.status||'published'} onChange={e=> setF({...f,status:e.target.value})}><option value="published">published</option><option value="draft">draft</option></select></div><div className="field"><label>排序</label><input type="number" value={f.sort_weight||0} onChange={e=> setF({...f,sort_weight:Number(e.target.value)})}/></div></div>
<button className="btn" onClick={save}>{editing?'保存修改':'新增案例'}</button>{editing&&<button className="btn btn-ghost" style={{marginLeft:8}} onClick={()=>{setF({status:'published'});setEditing(false)}}>取消编辑</button>}
</div>
</>
}
function Articles({token}:{token:string}){
const [list,setList]=useState<any[]>([]); const [f,setF]=useState<any>({status:'published'}); const [editing,setEditing]=useState(false)
useEffect(()=>{api('/api/cms/articles',token).then(j=> setList(j.data||[]))},[])
const save=()=>api(editing?'/api/cms/articles/'+f.id:'/api/cms/articles',token,{method:editing?'PUT':'POST',body:JSON.stringify(f)}).then(j=>{if(j.code===0){const saved={...f,id:editing?f.id:j.data?.id};setList(editing?list.map(x=>x.id===saved.id?saved:x):[...list,saved]);setF({status:'published'});setEditing(false);alert('保存成功')}else alert(j.msg||'保存失败')})
return <>
<div className="card"><h3>文章列表</h3><table className="table"><thead><tr><th>标题</th><th>分类</th><th>推荐权重</th><th>状态</th><th>操作</th></tr></thead><tbody>{list.map(a=> <tr key={a.id}><td>{a.title}</td><td>{a.category}</td><td>{a.recommend_weight}</td><td>{a.status}</td><td><button onClick={()=>{setF({...a});setEditing(true)}}>编辑</button> <button onClick={()=>{if(confirm('删除?')) api('/api/cms/articles/'+a.id,token,{method:'DELETE'}).then(j=>{if(j.code===0)setList(list.filter(x=>x.id!==a.id))})}}>删除</button></td></tr>)}</tbody></table></div>
<div className="card"><h3>{editing?'编辑文章':'新增文章'}(富文本)</h3>
<div className="field"><label>标题*</label><input value={f.title||''} onChange={e=> setF({...f,title:e.target.value})}/></div>
<div className="form-row"><div className="field"><label>路径标识(slug)</label><input value={f.slug||''} onChange={e=>setF({...f,slug:e.target.value})}/></div><div className="field"><label>分类</label><input value={f.category||''} onChange={e=>setF({...f,category:e.target.value})}/></div></div>
<div className="field"><label>摘要</label><textarea value={f.subtitle||''} onChange={e=>setF({...f,subtitle:e.target.value})}/></div>
<div className="field"><label>正文(富文本 HTML)</label><textarea value={f.content_html||''} onChange={e=> setF({...f,content_html:e.target.value})} /></div>
<Upload token={token} onUrl={url=> setF({...f,cover_url:url})} />
{f.cover_url && <img src={f.cover_url} style={{width:120,height:80,objectFit:'cover'}}/>}
<div className="form-row"><div className="field"><label>状态</label><select value={f.status||'published'} onChange={e=> setF({...f,status:e.target.value})}><option value="published">published</option><option value="draft">draft</option></select></div><div className="field"><label>推荐权重</label><input type="number" value={f.recommend_weight||0} onChange={e=> setF({...f,recommend_weight:Number(e.target.value)})}/></div></div>
<button className="btn" onClick={save}>{editing?'保存修改':'新增文章'}</button>{editing&&<button className="btn btn-ghost" style={{marginLeft:8}} onClick={()=>{setF({status:'published'});setEditing(false)}}>取消编辑</button>}
</div>
</>
}
function SelfCheck({token}:{token:string}){
const [f,setF]=useState<any>({})
useEffect(()=>{api('/api/cms/self-check',token).then(j=> setF(j.data||{}))},[])
return <div className="card"><h3>轻量自我对照</h3>
{['guide','questions','logic_text','next_guide'].map(k=> <div key={k} className="field"><label>{fieldLabel(k)}</label><textarea value={f[k]||''} onChange={e=> setF({...f,[k]:e.target.value})} /></div>)}
<p style={{fontSize:11,color:'#92400e'}}>约束:一期保持轻量,不做复杂评分和自动诊断报告</p>
<button className="btn" onClick={()=> api('/api/cms/self-check',token,{method:'PUT',body:JSON.stringify(f)}).then(()=> alert('保存成功'))}>保存</button>
</div>
}
function Upload({token,onUrl}:{token:string,onUrl:(u:string)=>void}){
const up=(e:any)=>{
const file=e.target.files[0]; if(!file) return;
const fd=new FormData(); fd.append('file',file);
fetch('/api/cms/upload',{method:'POST',headers:{Authorization:'Bearer '+token},body:fd}).then(r=>r.json()).then(j=>{ if(j.code===0) onUrl(j.data.url); else alert(j.msg)})
}
return <div className="field"><label>图片上传</label><input type="file" onChange={up} accept="image/*" /></div>
}
function DiagQ({token}:{token:string}){
const [list,setList]=useState<any[]>([]); const [f,setF]=useState<any>({group_key:'value1',type:'single',sort_order:0,status:'published'}); const [editing,setEditing]=useState(false)
useEffect(()=>{api('/api/cms/diagnosis/questions',token).then(j=> setList(j.data||[]))},[])
const save=()=>{let opts=[]; try{opts=f.options_text?JSON.parse(f.options_text):(typeof f.options==='string'?JSON.parse(f.options||'[]'):(f.options||[]))}catch{return alert('选项JSON格式错误')} const path=editing?'/api/cms/diagnosis/questions/'+f.id:'/api/cms/diagnosis/questions'; return api(path,token,{method:editing?'PUT':'POST',body:JSON.stringify({...f,options:opts})}).then(j=>{if(j.code===0){const saved={...f,options:JSON.stringify(opts),options_text:JSON.stringify(opts,null,2),id:editing?f.id:j.data?.id};setList(editing?list.map(x=>x.id===saved.id?saved:x):[...list,saved]);setF({group_key:'value1',type:'single',sort_order:0,status:'published'});setEditing(false);alert('保存成功')}else alert(j.msg||'保存失败')})}
return <>
<div className="card"><h3>诊断问卷题目(按四大价值分组)</h3>
<table className="table"><thead><tr><th>分组</th><th>标题</th><th>类型</th><th>排序</th><th>状态</th><th>操作</th></tr></thead>
<tbody>{list.map(q=> <tr key={q.id}><td>{q.group_name}</td><td style={{maxWidth:200,overflow:'hidden',textOverflow:'ellipsis',whiteSpace:'nowrap'}}>{q.title}</td><td>{q.type}</td><td>{q.sort_order}</td><td>{q.status}</td><td><button onClick={()=>{setF({...q,options_text:typeof q.options==='string'?q.options:JSON.stringify(q.options||[],null,2)});setEditing(true)}}>编辑</button> <button onClick={()=>{if(confirm('删除?')) api('/api/cms/diagnosis/questions/'+q.id,token,{method:'DELETE'}).then(j=>{if(j.code===0)setList(list.filter(x=>x.id!==q.id))})}}>删除</button></td></tr>)}</tbody>
</table>
</div>
<div className="card"><h3>{editing?'编辑题目':'新增题目'}</h3>
<div className="form-row"><div className="field"><label>分组key</label><select value={f.group_key} onChange={e=> setF({...f,group_key:e.target.value})}><option value="value1">value1 价值创造</option><option value="value2">value2 价值传递</option><option value="value3">value3 价值交付</option><option value="value4">value4 价值支持</option></select></div><div className="field"><label>类型</label><select value={f.type} onChange={e=> setF({...f,type:e.target.value})}><option value="single">单选</option><option value="multiple">多选</option><option value="scale">量表1-5</option></select></div></div>
<div className="field"><label>标题*</label><input value={f.title||''} onChange={e=> setF({...f,title:e.target.value})} /></div>
<div className="field"><label>选项(JSON数组 [{label,score}])</label><textarea value={f.options_text||''} onChange={e=> setF({...f,options_text:e.target.value})} placeholder='[{"label":"非常清晰","score":5}]' /></div>
<div className="form-row"><div className="field"><label>排序</label><input type="number" value={f.sort_order||0} onChange={e=> setF({...f,sort_order:Number(e.target.value)})}/></div><div className="field"><label>状态</label><select value={f.status} onChange={e=> setF({...f,status:e.target.value})}><option value="published">published</option><option value="draft">draft</option></select></div></div>
<button className="btn" onClick={save}>{editing?'保存修改':'新增题目'}</button>{editing&&<button className="btn btn-ghost" style={{marginLeft:8}} onClick={()=>{setF({group_key:'value1',type:'single',sort_order:0,status:'published'});setEditing(false)}}>取消编辑</button>}
</div>
</>
}
function DiagConfig({token}:{token:string}){
const [cfg,setCfg]=useState<any>({weights:{},thresholds:{},templates:{},share_expire_days:30})
useEffect(()=>{api('/api/cms/diagnosis/config',token).then(j=> setCfg(j.data||cfg))},[])
return <div className="card"><h3>诊断算法参数与模板</h3>
<div className="field"><label>权重(JSON)</label><textarea value={JSON.stringify(cfg.weights||{},null,2)} onChange={e=>{try{setCfg({...cfg,weights:JSON.parse(e.target.value)})}catch{}}} /></div>
<div className="field"><label>阈值(B_max, M_max)</label><textarea value={JSON.stringify(cfg.thresholds||{},null,2)} onChange={e=>{try{setCfg({...cfg,thresholds:JSON.parse(e.target.value)})}catch{}}} /></div>
<div className="field"><label>模板(免责声明等)</label><textarea value={JSON.stringify(cfg.templates||{},null,2)} onChange={e=>{try{setCfg({...cfg,templates:JSON.parse(e.target.value)})}catch{}}} /></div>
<div className="field"><label>分享有效期(天)</label><input type="number" value={cfg.share_expire_days||30} onChange={e=> setCfg({...cfg,share_expire_days:Number(e.target.value)})}/></div>
<button className="btn" onClick={()=> api('/api/cms/diagnosis/config',token,{method:'PUT',body:JSON.stringify(cfg)}).then(()=> alert('保存成功'))}>保存配置</button>
</div>
}
function DiagReports({token}:{token:string}){
const [list,setList]=useState<any[]>([])
useEffect(()=>{api('/api/cms/diagnosis/reports',token).then(j=> setList(j.data||[]))},[])
return <div className="card"><h3>诊断记录(最近100)</h3>
<table className="table"><thead><tr><th>报告token</th><th>创建时间</th><th>过期</th><th>分享</th></tr></thead>
<tbody>{list.map(r=> <tr key={r.id}><td style={{fontSize:10}}>{r.token.slice(0,8)}...</td><td>{r.created_at}</td><td>{r.expires_at}</td><td>{r.share_token.slice(0,8)}...</td></tr>)}</tbody>
</table>
</div>
}
function DiagLeads({token}:{token:string}){
const [leads,setLeads]=useState<any[]>([]); const [stats,setStats]=useState<any>({})
useEffect(()=>{api('/api/cms/diagnosis/leads',token).then(j=> setLeads(j.data||[])); api('/api/cms/diagnosis/stats',token).then(j=> setStats(j.data||{}))},[])
return <>
<div className="card"><h3>数据统计</h3><p>会话:{stats.totalSessions} · 报告:{stats.totalReports} · 留资:{stats.totalLeads}</p></div>
<div className="card"><h3>留资列表(可导出)</h3>
<table className="table"><thead><tr><th>姓名</th><th>企业</th><th>手机</th><th>时间</th></tr></thead>
<tbody>{leads.map(l=> <tr key={l.id}><td>{l.name}</td><td>{l.company}</td><td>{l.phone}</td><td>{l.created_at}</td></tr>)}</tbody>
</table>
<button className="btn btn-ghost" onClick={()=>{
const rows=[['姓名','企业','手机','时间'], ...leads.map(l=>[l.name,l.company,l.phone,l.created_at])]
const csv=rows.map(r=> r.map(v=>`"${v||''}"`).join(',')).join('\n')
const blob=new Blob([csv],{type:'text/csv'}); const url=URL.createObjectURL(blob); const a=document.createElement('a'); a.href=url; a.download='leads.csv'; a.click()
}}>导出CSV</button>
</div>
</>
}
function ChangePassword({token}:{token:string}){
const [oldPwd,setOldPwd]=useState('')
const [newPwd,setNewPwd]=useState('')
const [confirmPwd,setConfirmPwd]=useState('')
const [msg,setMsg]=useState('')
const [err,setErr]=useState('')
const submit=async()=>{
setMsg(''); setErr('')
if(!oldPwd || !newPwd || !confirmPwd){ setErr('请填写所有字段'); return }
if(newPwd.length<6){ setErr('新密码长度至少6位'); return }
if(newPwd!==confirmPwd){ setErr('两次密码不一致'); return }
const r=await api('/api/auth/change-password',token,{method:'POST',body:JSON.stringify({oldPassword:oldPwd,newPassword:newPwd})})
if(r.code===0){ setMsg('密码修改成功!'); setOldPwd(''); setNewPwd(''); setConfirmPwd('') }
else setErr(r.msg||'修改失败')
}
return <div className="card">
<h3>修改密码</h3>
<div className="field"><label>旧密码</label><input type="password" value={oldPwd} onChange={e=>setOldPwd(e.target.value)} /></div>
<div className="field"><label>新密码(至少6位)</label><input type="password" value={newPwd} onChange={e=>setNewPwd(e.target.value)} /></div>
<div className="field"><label>确认新密码</label><input type="password" value={confirmPwd} onChange={e=>setConfirmPwd(e.target.value)} /></div>
{msg && <div style={{color:'#059669',fontSize:13,margin:'8px 0'}}>{msg}</div>}
{err && <div style={{color:'#e11d48',fontSize:13,margin:'8px 0'}}>{err}</div>}
<button className="btn" onClick={submit}>修改密码</button>
</div>
}