@@ -1,7 +1,10 @@
const state = {
active : "home" ,
data : null ,
tenant : "科普·无界" ,
opFilter : "all" ,
finFilter : "已签单" ,
projectView : null ,
chart : null ,
chart2 : null ,
productPlatform : "all" ,
@@ -47,7 +50,7 @@ function renderTable(headers, rows, rowClicks) {
}
async function load ( ) {
state . data = await api ( " /api/bootstrap" ) ;
state . data = await api ( ` /api/bootstrap?tenant= ${ encodeURIComponent ( state . tenant ) } ` ) ;
render ( ) ;
}
@@ -93,30 +96,38 @@ function render() {
function renderHome ( ) {
const { summary , financeMonthly } = state . data ;
const m = summary . metrics ;
const rows1 = [
[ "年度累计签约" , money ( m . signed _annual || m . signed _amount ) ] ,
[ "Q2 累计签约" , money ( m . signed _q2 || 0 ) ] ,
[ "本月新增签约" , money ( m . signed _month || 0 ) ] ,
[ "合同流程中" , money ( m . pipeline _amount ) ] ,
] ;
const rows2 = [
[ "年度累计确收" , money ( m . revenue _annual ) ] ,
[ "Q2 累计确收" , money ( m . revenue _q2 ) ] ,
[ "本月新增确收" , money ( m . monthly _revenue ) ] ,
[ "已签约未执行" , money ( m . signed _not _executed ) ] ,
] ;
const rows3 = [
[ "年度累计毛利" , money ( m . gross _annual ) ] ,
[ "Q2 累计毛利" , money ( m . gross _q2 ) ] ,
[ "本月新增毛利" , money ( m . monthly _net _profit ) ] ,
[ "合同毛利率" , m . revenue _annual ? Math . round ( m . gross _annual / m . revenue _annual * 100 ) + "%" : "—" ] ,
] ;
const tblCard = ( title , rows ) => card ( ` <h3 class="text-sm font-bold text-slate-700 mb-3"> ${ title } </h3><table class="w-full text-sm"><tbody> ${ rows . map ( ( [ label , value ] ) => ` <tr class="border-b border-slate-100 last:border-0"><td class="py-2 pr-4 text-slate-500"> ${ label } </td><td class="py-2 text-right font-semibold text-slate-800"> ${ value } </td></tr> ` ) . join ( "" ) } </tbody></table> ` , "p-4" ) ;
document . querySelector ( "#home" ) . innerHTML = `
<div class="grid gap-5">
<div class="grid grid-cols-4 gap-3">
<div class="grid grid-cols-6 gap-3">
${ [
[ "P0 客户数 " , m . p0 _customer s, "projects" ] ,
[ "跟进中销售机会 " , m . active _ sale s, "project s" ] ,
[ "已签约执行项目 " , m . execution _proje cts , "proje cts" ] ,
[ "有风险项目 " , m. risk _projects , "projects " ] ,
[ "本月收入 " , money ( m . monthly _revenue ) , "finance" ] ,
[ "重点项目 " , m . total _project s, "projects" ] ,
[ "业务方案 " , m . total _propo sals, "proposal s" ] ,
[ "产品版本 " , m . total _produ cts , "produ cts" ] ,
[ "本月确收 " , money ( m . monthly _revenue ) , "finance " ] ,
[ "本月毛利 " , money ( m . monthly _gross || m . monthly _net _profit ) , "finance" ] ,
[ "本月净利" , money ( m . monthly _net _profit ) , "finance" ] ,
[ "即将上线版本" , m . upcoming _products , "products" ] ,
[ "已签约未执行" , money ( m . signed _not _executed ) , "finance" ] ,
] . map ( ( [ label , value , tab ] ) => ` <button class="metric-card" onclick="switchTab(' ${ tab } ')"><span class="flex items-center gap-2 text-xs text-slate-500"><i data-lucide="gauge"></i> ${ label } </span><strong class="mt-2 block text-2xl"> ${ value } </strong></button> ` ) . join ( "" ) }
</div>
<div class="grid grid-cols-4 gap-3" >
${ [
[ "已签约合同总额" , money ( m . signed _amount ) , "projects" ] ,
[ "合同流程中" , money ( m . pipeline _amount ) , "projects" ] ,
[ "年度累计确收" , money ( m . revenue _annual ) , "finance" ] ,
[ "Q2 累计确收" , money ( m . revenue _q2 ) , "finance" ] ,
[ "年度累计毛利" , money ( m . gross _annual ) , "finance" ] ,
[ "Q2 累计毛利" , money ( m . gross _q2 ) , "finance" ] ,
] . map ( ( [ label , value , tab ] ) => ` <button class="metric-card" onclick="switchTab(' ${ tab } ')"><span class="flex items-center gap-2 text-xs text-slate-500"><i data-lucide="trending-up"></i> ${ label } </span><strong class="mt-2 block text-2xl"> ${ value } </strong></button> ` ) . join ( "" ) }
</div>
<div class="grid grid-cols-3 gap-5"> ${ tblCard ( "合同金额" , rows1 ) } ${ tblCard ( "确收金额" , rows2 ) } ${ tblCard ( "确收毛利" , rows3 ) } </div >
<div class="grid grid-cols-2 gap-5">
${ card ( ` <div class="mb-3 flex items-center justify-between"><h2 class="text-sm font-bold text-slate-600">财务趋势</h2> ${ badge ( "YYYY-MM" ) } </div><div style="position:relative;height:140px"><canvas id="financeChart"></canvas></div> ` , "p-4" ) }
${ card ( ` <h2 class="text-lg font-bold">风险提醒</h2><div class="mt-3 grid gap-2"> ${ ( summary . risks . length ? summary . risks : [ { title : "暂无高风险" , content : "当前无明确阻塞,按周更新即可。" } ]).map((r) => ` < div class = "rounded-md border border-amber-200 bg-amber-50 p-3" > < p class = "font-bold text-amber-900" > $ { r . title } </p><p class="mt-1 text-sm text-amber-800 break-words"> ${ r . content } </p></div> ` ) . join ( "" ) } < / d i v > ` , " p - 5 " ) }
@@ -170,56 +181,244 @@ window.createSales = (event) => createResource(event, "sales");
window . createProposal = ( event ) => createResource ( event , "proposals" ) ;
window . createOperation = ( event ) => createResource ( event , "operations" ) ;
window . createProduct = ( event ) => createResource ( event , "products" ) ;
window . createFinance = ( event ) => createResource ( event , "finance" ) ;
window . openTaskForm = ( projectId , taskId ) => {
const drawer = document . querySelector ( ` #task-drawer- ${ projectId } ` ) ;
const titleEl = drawer . querySelector ( ".task-drawer-title" ) ;
if ( taskId === null ) {
document . querySelector ( ` #task-id- ${ projectId } ` ) . value = "" ;
document . querySelector ( ` #task-name- ${ projectId } ` ) . value = "" ;
document . querySelector ( ` #task-phase- ${ projectId } ` ) . value = "商务洽谈" ;
document . querySelector ( ` #task-owner- ${ projectId } ` ) . value = "" ;
document . querySelector ( ` #task-due- ${ projectId } ` ) . value = "" ;
document . querySelector ( ` #task-notes- ${ projectId } ` ) . value = "" ;
document . querySelector ( ` #task-blockers- ${ projectId } ` ) . value = "" ;
document . querySelector ( ` #task-submit-btn- ${ projectId } ` ) . textContent = "确认新增" ;
if ( titleEl ) titleEl . textContent = "新增任务" ;
} else {
const task = ( state . data . tasks || [ ] ) . find ( ( t ) => t . id === taskId ) ;
if ( ! task ) return ;
document . querySelector ( ` #task-id- ${ projectId } ` ) . value = task . id ;
document . querySelector ( ` #task-name- ${ projectId } ` ) . value = task . task || "" ;
document . querySelector ( ` #task-phase- ${ projectId } ` ) . value = task . phase || "商务洽谈" ;
document . querySelector ( ` #task-owner- ${ projectId } ` ) . value = task . owner || "" ;
document . querySelector ( ` #task-due- ${ projectId } ` ) . value = task . due _date || "" ;
document . querySelector ( ` #task-notes- ${ projectId } ` ) . value = task . notes || "" ;
document . querySelector ( ` #task-blockers- ${ projectId } ` ) . value = task . blockers || "" ;
document . querySelector ( ` #task-submit-btn- ${ projectId } ` ) . textContent = "保存修改" ;
if ( titleEl ) titleEl . textContent = "编辑任务" ;
}
drawer . classList . add ( "open" ) ;
} ;
window . closeTaskDrawer = ( projectId ) => {
document . querySelector ( ` #task-drawer- ${ projectId } ` ) . classList . remove ( "open" ) ;
} ;
window . submitTaskForm = async ( event , projectId ) => {
event . preventDefault ( ) ;
const data = Object . fromEntries ( new FormData ( event . currentTarget ) . entries ( ) ) ;
data . project _id = Number ( projectId ) ;
const taskId = data . task _id ;
delete data . task _id ;
try {
if ( taskId ) {
await api ( ` /api/tasks/ ${ taskId } ` , { method : "PUT" , body : JSON . stringify ( { data } ) } ) ;
} else {
await api ( "/api/tasks" , { method : "POST" , body : JSON . stringify ( { data } ) } ) ;
}
await load ( ) ;
} catch ( error ) {
alert ( "保存失败:" + error . message ) ;
}
} ;
window . createFinance = async ( event ) => {
event . preventDefault ( ) ;
const form = event . currentTarget ;
const data = Object . fromEntries ( new FormData ( form ) . entries ( ) ) ;
data . tenant = state . tenant ;
data . sign _amount = parseFloat ( data . sign _amount ) || 0 ;
for ( const m of [ "2026-06" , "2026-07" , "2026-08" , "2026-09" ] ) {
const k = m . replace ( "-" , "_" ) ;
data [ "rev_" + k ] = parseFloat ( data [ "rev_" + k ] ) || 0 ;
data [ "gross_" + k ] = parseFloat ( data [ "gross_" + k ] ) || 0 ;
}
const pfId = data . pf _id ;
delete data . pf _id ;
try {
if ( pfId ) {
await api ( "/api/projectFinances/" + pfId , { method : "PUT" , body : JSON . stringify ( { data } ) } ) ;
} else {
await api ( "/api/projectFinances" , { method : "POST" , body : JSON . stringify ( { data } ) } ) ;
}
form . reset ( ) ;
document . querySelector ( "#pf-id-input" ) . value = "" ;
document . querySelector ( "#financeModalTitle" ) . textContent = "新增项目财务" ;
closeFinanceModal ( ) ;
await load ( ) ;
} catch ( error ) {
alert ( "保存失败:" + error . message ) ;
}
} ;
window . switchTab = switchTab ;
window . switchTenant = ( tenant ) => {
state . tenant = tenant ;
state . projectView = null ;
const label = tenant . replace ( "·无界" , "" ) ;
document . querySelector ( "#workspaceTitle" ) . textContent = label + " OPC 工作台" ;
load ( ) ;
} ;
function renderProjects ( ) {
// Merge sales_leads and operation_projects into one table
const salesItems = state . data . sales . map ( ( x ) => ( {
name : x . target _customer ,
version : "" ,
type : "opportunity" ,
status : x . status ,
amount : 0 ,
stage : "" ,
files : 0 ,
followup : x . latest _follow _up _record ,
resource : "sales" ,
id : x . id ,
} ) ) ;
const opItems = state . data . operations . map ( ( x ) => ( {
name : x . project _name ,
version : x . project _version ,
type : x . project _type ,
status : x . project _status ,
amount : x . expected _contract _amount || 0 ,
stage : x . current _stage || x . sop _stage ,
files : x . files . length ,
followup : x . latest _follow _up _record ,
resource : "operations" ,
id : x . id ,
} ) ) ;
const allItems = [ ... salesItems , ... opItems ] ;
const items = state . opFilter === "all" ? allItems : allItems . filter ( ( x ) => x . type === state . opFilter || ( state . opFilter === "opportunity" && x . type === "opportunity" ) ) ;
const rows = items . map ( ( x ) => [ ` <strong> ${ x . name } </strong> ${ x . version ? ` <p class="text-xs text-slate-500"> ${ x . version } </p> ` : "" } ` , badge ( x . type ) , badge ( x . status ) , x . amount ? money ( x . amount ) : "—" , text ( x . stage ) , text ( x . followup ) ] ) ;
const clicks = items . map ( ( x ) => ( { resource : x . resource , id : x . id } ) ) ;
// 二级页面:项目任务详情
if ( state . projectView ) {
return renderProjectTasks ( state . projectView ) ;
}
const items = state . data . operations ;
const rows = items . map ( ( x ) => [
` <strong> ${ x . project _name } </strong> ` ,
text ( x . customer _need || x . notes ) ,
badge ( x . current _stage || x . project _status ) ,
x . expected _contract _amount ? money ( x . expected _contract _amount ) : "—" ,
text ( x . owner || "—" ) ,
` <button class="btn btn-ghost btn-sm text-blue-600" onclick="event.stopPropagation(); state.projectView= ${ x . id } ; renderProjects()"><i data-lucide="eye"></i>查看</button> `
] ) ;
document . querySelector ( "#projects" ) . innerHTML = ` <div class="grid gap-4">
<div id="project-form">
${ card ( formHtml ( [
{ label : "项目名称" , input : ` <input name="project_name" required> ` } ,
{ label: "当前阶段", input: ` < select name = "current_stage" > < option > 商务洽谈 < / o p t i o n > < o p t i o n > 系 统 上 线 < / o p t i o n > < o p t i o n > 团 队 分 工 < / o p t i o n > < o p t i o n > 项 目 交 付 < / o p t i o n > < o p t i o n > 上 线 推 广 < / o p t i o n > < o p t i o n > 结 项 验 收 < / o p t i o n > < / s e l e c t > ` } ,
{ label : "项目金额" , input : ` <input name="expected_contract_amount" type="number" step="0.01" placeholder="万元"> ` } ,
{ label : "负责人" , input : ` <input name="owner"> ` } ,
] , { handler : "createOperation" , text : "新增项目" } ) , "p-4" ) }
< / d i v >
$ { renderTable ( [ "项目" , "项目说明" , "当前阶段" , "项目金额" , "负责人" , "进展" ] , rows , items . map ( ( x ) => ( { resource : "operations" , id : x . id } ) ) ) }
< / d i v > ` ;
if ( window . lucide ) window . lucide . createIcons ( ) ;
}
function renderProjectTasks ( projectId ) {
const project = state . data . operations . find ( ( x ) => x . id === projectId ) ;
if ( ! project ) { state . projectView = null ; renderProjects ( ) ; return ; }
const tasks = ( state . data . tasks || [ ] ) . filter ( ( t ) => t . project _id === projectId ) ;
const phases = [ "商务洽谈" , "系统上线" , "团队分工" , "项目交付" , "上线推广" , "结项验收" ] ;
document . querySelector ( "#projects" ) . innerHTML = ` <div class="grid gap-4">
<div class="flex items-center justify-between px-5">
<button class="btn btn-ghost btn-sm" onclick="state.projectView=null;renderProjects()"><i data-lucide="arrow-left"></i>返回项目列表</button>
<div class="flex items-center gap-3">
<span class="text-sm font-semibold text-slate-700"> ${ project . project _name } </span>
<button class="btn btn-primary btn-sm" onclick="openTaskForm( ${ projectId } , null)"><i data-lucide="plus"></i>新增任务</button>
</div>
</div>
<div class="task-page-wrap">
<div class="task-body">
${ phases . map ( ( phase ) => {
const pt = tasks . filter ( ( t ) => t . phase === phase ) ;
if ( ! pt . length ) return "" ;
return ` <div class="task-group"><div class="task-group-hd"><span class="task-group-icon"><i data-lucide="layers"></i></span><span class="task-group-label"> ${ phase } </span><span class="task-group-n"> ${ pt . length } </span></div><div class="task-group-list" data-phase=" ${ phase } " ondrop="handleTaskDrop(event, ${ projectId } , ' ${ phase } ')" ondragover="event.preventDefault(); event.currentTarget.classList.add('drag-over')" ondragleave="event.currentTarget.classList.remove('drag-over')"> ${ pt . map ( ( t ) => ` <div class="task-row ${ t . status === 'done' ? 'task-done' : '' } " data-id=" ${ t . id } " draggable="true" ondragstart="handleTaskDragStart(event, ${ t . id } )" ondragend="event.currentTarget.classList.remove('dragging')"><span class="task-dot" onclick="event.stopPropagation(); toggleTaskDone( ${ t . id } , ${ projectId } )"><i data-lucide=" ${ t . status === 'done' ? 'check-circle' : 'circle' } "></i></span><span class="task-grip"><i data-lucide="grip-vertical"></i></span><div class="task-main" onclick="openTaskForm( ${ projectId } , ${ t . id } )"><span class="task-name"> ${ t . task } </span> ${ t . notes ? ` <span class="task-desc"> ${ t . notes } </span> ` : "" } ${ t . blockers ? ` <span class="task-blocker">⚠ ${ t . blockers } </span> ` : "" } </div><span class="task-col"> ${ t . owner || "" } </span><span class="task-col-badge"> ${ t . due _date || "" } </span></div> ` ) . join ( "" ) } </div></div> ` ;
} ).join("")}
</div>
<div id="task-drawer- ${ projectId } " class="task-drawer">
<div class="task-drawer-hd"><span class="task-drawer-title">编辑任务</span><div class="flex items-center gap-2"><button type="button" class="btn btn-ghost btn-sm text-red-600 hover:bg-red-50" onclick="deleteTask( ${ projectId } )"><i data-lucide="trash-2"></i>删除</button><button class="task-close" onclick="closeTaskDrawer( ${ projectId } )"><i data-lucide="x"></i></button></div></div>
<form class="task-drawer-form" onsubmit="submitTaskForm(event, ${ projectId } )">
<input type="hidden" name="task_id" id="task-id- ${ projectId } " value="">
<label class="task-field"><span>任务名称</span><input name="task" required id="task-name- ${ projectId } "></label>
<label class="task-field"><span>任务阶段</span><select name="phase" id="task-phase- ${ projectId } "> ${ phases . map ( ( p ) => ` <option> ${ p } </option> ` ) . join ( "" ) } </select></label>
<label class="task-field"><span>负责人</span><input name="owner" id="task-owner- ${ projectId } "></label>
<label class="task-field"><span>截止时间</span><input name="due_date" type="date" id="task-due- ${ projectId } "></label>
<label class="task-field"><span>任务说明</span><textarea name="notes" rows="3" id="task-notes- ${ projectId } "></textarea></label>
<label class="task-field"><span>卡点&备注</span><textarea name="blockers" rows="2" id="task-blockers- ${ projectId } " placeholder="风险卡点、依赖项等"></textarea></label>
<div class="flex justify-end gap-2 mt-4 pt-3 border-t border-slate-100">
<button type="button" class="btn btn-ghost btn-sm" onclick="closeTaskDrawer( ${ projectId } )">取消</button>
<button type="submit" class="btn btn-primary btn-sm" id="task-submit-btn- ${ projectId } ">确认新增</button>
</div>
</form>
</div>
</div>
</div> ` ;
if ( window . lucide ) window . lucide . createIcons ( ) ;
}
function showTaskModal ( projectId ) {
const project = state . data . operations . find ( ( x ) => x . id === projectId ) ;
const tasks = ( state . data . tasks || [ ] ) . filter ( ( t ) => t . project _id === projectId ) ;
const phases = [ "商务洽谈" , "系统上线" , "团队分工" , "项目交付" , "上线推广" , "结项验收" ] ;
document . querySelector ( "#taskModal" ) . innerHTML = ` <div class="task-overlay" onclick="closeTaskModal()"><div class="task-panel" onclick="event.stopPropagation()"><div class="task-header"><h2 class="task-title"> ${ project . project _name } · 任务清单</h2><div class="flex items-center gap-3"><button class="btn btn-primary btn-sm" onclick="event.stopPropagation(); openTaskForm( ${ projectId } , null)"><i data-lucide="plus"></i>新增任务</button><button class="task-close" onclick="closeTaskModal()"><i data-lucide="x"></i></button></div></div><div class="task-body-wrap">
<div class="task-body">
${ phases . map ( ( phase ) => {
const pt = tasks . filter ( ( t ) => t . phase === phase ) ;
if ( ! pt . length ) return "" ;
return ` <div class="task-group"><div class="task-group-hd"><span class="task-group-icon"><i data-lucide="layers"></i></span><span class="task-group-label"> ${ phase } </span><span class="task-group-n"> ${ pt . length } </span></div><div class="task-group-list"> ${ pt . map ( ( t ) => ` <div class="task-row" data-id=" ${ t . id } " onclick="event.stopPropagation(); openTaskForm( ${ projectId } , ${ t . id } )"><span class="task-dot"><i data-lucide=" ${ t . status === 'done' ? 'check-circle' : 'circle' } "></i></span><div class="task-main"><span class="task-name"> ${ t . task } </span> ${ t . notes ? ` <span class="task-desc"> ${ t . notes } </span> ` : "" } ${ t . blockers ? ` <span class="task-blocker">⚠ ${ t . blockers } </span> ` : "" } </div><span class="task-col"> ${ t . owner || "" } </span><span class="task-col-badge"> ${ t . due _date || "" } </span></div> ` ) . join ( "" ) } </div></div> ` ;
} ).join("")}
</div>
<div id="task-drawer- ${ projectId } " class="task-drawer">
<div class="task-drawer-hd"><span class="task-drawer-title">编辑任务</span><button class="task-close" onclick="closeTaskDrawer( ${ projectId } )"><i data-lucide="x"></i></button></div>
<form class="task-drawer-form" onsubmit="submitTaskForm(event, ${ projectId } )">
<input type="hidden" name="task_id" id="task-id- ${ projectId } " value="">
<label class="task-field"><span>任务名称</span><input name="task" required id="task-name- ${ projectId } "></label>
<label class="task-field"><span>任务阶段</span><select name="phase" id="task-phase- ${ projectId } "> ${ phases . map ( ( p ) => ` <option> ${ p } </option> ` ) . join ( "" ) } </select></label>
<label class="task-field"><span>负责人</span><input name="owner" id="task-owner- ${ projectId } "></label>
<label class="task-field"><span>截止时间</span><input name="due_date" type="date" id="task-due- ${ projectId } "></label>
<label class="task-field"><span>任务说明</span><textarea name="notes" rows="3" id="task-notes- ${ projectId } "></textarea></label>
<label class="task-field"><span>卡点&备注</span><textarea name="blockers" rows="2" id="task-blockers- ${ projectId } " placeholder="风险卡点、依赖项等"></textarea></label>
<div class="flex justify-end gap-2 mt-4 pt-3 border-t border-slate-100">
<button type="button" class="btn btn-ghost btn-sm" onclick="closeTaskDrawer( ${ projectId } )">取消</button>
<button type="submit" class="btn btn-primary btn-sm" id="task-submit-btn- ${ projectId } ">确认新增</button>
</div>
</form>
</div>
</div></div></div> ` ;
document . querySelector ( "#taskModal" ) . classList . add ( "active" ) ;
if ( window . lucide ) window . lucide . createIcons ( ) ;
}
window . closeTaskModal = ( ) => {
document . querySelector ( "#taskModal" ) . classList . remove ( "active" ) ;
document . querySelector ( "#taskModal" ) . innerHTML = "" ;
} ;
function renderProposals ( ) {
const proposalRows = state . data . proposals . map ( ( p ) => [ p . customer _or _project _name , p . version , badge ( p . status ) , p . files . length + " 个" ] ) ;
const proposalClicks = state . data . proposals . map ( ( p ) => ( { resource : "proposals" , id : p . id } ) ) ;
document . querySelector ( "#proposals" ) . innerHTML = ` <div class="grid gap-4">
${ card ( formHtml ( [
{ label : "业务机会 " , input : ` <input name="target_ customer" required placeholder="客户名称 "> ` } ,
{ label: "优先级 ", input: ` < selec t name = "priority" > < option > P0 < / o p t i o n > < o p t i o n s e l e c t e d > P 1 < / o p t i o n > < o p t i o n > P 2 < / o p t i o n > < o p t i o n > P 3 < / o p t i o n > < / s e l e c t > ` } ,
{ label : "状态" , input : ` < select name= "status"><option>待跟进</option><option>跟进中</option><option>方案中</option><option>商务谈判</option><option>已签约</option><option>暂缓</option><option>已丢单</option></select > ` } ,
] , { handler : "createSales " , text : ` <i data-lucide="plus"></i>新增业务机会 ` } ) , "p-4" ) }
$ { card ( formHtml ( [
{ label : "项目名称" , input : ` <input name="project_name" required> ` } ,
{ label : "项目版本" , input : ` <input name="project_version" value="v1.0"> ` } ,
{ label : "项目类型" , input : ` <select name="project_type"><option value="opportunity">业务机会项目</option><option value="execution">已签约执行项目</option></select> ` } ,
{ label : "状态" , input : ` <input name="project_status" value="线索发现"> ` } ,
] , { handler : "createOperation" , text : "新增项目" } ) , "p-4" ) }
< div class = "flex gap-2" > $ { [ [ "all" , "全部" ] , [ "opportunity" , "业务机会" ] , [ "execution" , "已签约执行" ] ] . map ( ( [ k , v ] ) => ` <button class="btn ${ state . opFilter === k ? "btn-primary" : "btn-ghost" } " onclick="state.opFilter=' ${ k } '; renderProjects()"> ${ v } </button> ` ) . join ( "" ) } < / d i v >
$ { renderTable ( [ "项目/客户" , "类型" , "状态" , "金额" , "当前阶段" , "最新跟进" ] , rows , clicks ) }
{ label : "客户/项目 " , input : ` <input name="customer_or_project_name " required placeholder="如:信达生物 "> ` } ,
{ label: "版本号 ", input: ` < inpu t name = "version" required placeholder = "v1.0" > ` },
{ label: "状态", input: ` < select name= "status"> < option > 草稿 < / o p t i o n > < o p t i o n > 内 部 评 审 < / o p t i o n > < o p t i o n s e l e c t e d > 已 提 交 客 户 < / o p t i o n > < o p t i o n > 客 户 反 馈 中 < / o p t i o n > < o p t i o n > 已 确 认 < / o p t i o n > < o p t i o n > 已 归 档 < / o p t i o n > < / s e l e c t > ` } ,
] , { handler : "createProposal " , text : "新增版本" } ) , "p-4" ) }
$ { renderTable ( [ "客户/项目" , "版本号" , "状态" , "文件数" ] , proposalRows , proposalClicks ) }
< / d i v > ` ;
}
function fileGroup ( module , ownerId , version , category , files ) {
return ` <div class="rounded-md border border-slate-200 px-3 py-2">
<div class="flex items-center justify-between gap-3"><p class="text-[13px] font-semibold text-slate-800"> ${ category } </p><label class="inline-flex cursor-pointer items-center gap-1 rounded-md border border-slate-200 px-2 py-1 text-[12px] font-medium text-slate-600 hover:bg-slate-50"><i data-lucide="upload"></i>上传<input class="hidden" type="file" onchange="uploadFile(event,' ${ module } ', ${ ownerId } ,' ${ version } ',' ${ category } ')"></label></div>
<div class="mt-2 grid gap-1.5"> ${ files . length ? files . map ( fileItem ) . join ( "" ) : ` <p class="text-[12px] text-slate-400">暂无文件</p> ` } </div>
</div> ` ;
}
function fileItem ( file ) {
return ` <div class="flex items-center justify-between gap-2 rounded-md bg-slate-50 px-2 py-1.5 text-[13px]"><div class="min-w-0 flex-1"><p class="truncate font-medium text-slate-800"> ${ file . file _name } </p><div class="mt-0.5 flex gap-3"><a class="file-link inline-flex items-center gap-1" target="_blank" href="/api/files/ ${ file . id } /content?inline=true"><i data-lucide="eye"></i>预览</a><a class="file-link inline-flex items-center gap-1 text-slate-600" href="/api/files/ ${ file . id } /content?inline=false"><i data-lucide="download"></i>下载</a></div></div><button class="btn btn-ghost btn-sm text-red-600" onclick="deleteFile( ${ file . id } )" title="删除"><i data-lucide="trash-2"></i></button></div> ` ;
}
window . deleteFile = async ( fileId ) => {
if ( ! confirm ( "确认删除此文件?" ) ) return ;
await api ( ` /api/files/ ${ fileId } ` , { method : "DELETE" } ) ;
await load ( ) ;
closeDrawer ( ) ;
} ;
window . uploadFile = async ( event , module , ownerId , version , category ) => {
const file = event . target . files [ 0 ] ;
if ( ! file ) return ;
const form = new FormData ( ) ;
form . append ( "module" , module ) ;
form . append ( "owner_id" , ownerId ) ;
form . append ( "owner_version" , version ) ;
form . append ( "file_category" , category ) ;
form . append ( "file" , file ) ;
await api ( "/api/files/upload" , { method : "POST" , body : form } ) ;
await load ( ) ;
} ;
function renderProducts ( ) {
const items = state . productPlatform === "all" ? state . data . products : state . data . products . filter ( ( x ) => ( x . platform || "" ) === state . productPlatform ) ;
document . querySelector ( "#products" ) . innerHTML = ` <div class="grid gap-4">
@@ -236,19 +435,107 @@ function renderProducts() {
}
function renderFinance ( ) {
const row s = state . data . finance . map ( ( x ) => [ x . month , badge ( x . record _type === "revenue" ? "收入" : "成本/费用" ) , x . category , money ( x . amount ) , x . occurred _date , text ( x . notes ) ] ) ;
const pf s = state . data . projectFinances || [ ] ;
const ops = state . data . operations || [ ] ;
const fmTypesByTenant = {
"科普·无界" : [ "科普音频" , "科普视频" , "科普文章" , "全品类科普" ] ,
"科研·无界" : [ "真实世界研究" , "调研问卷" , "病例征集" , "患者招募" ] ,
"医患·无界" : [ "医患运营" , "患者管理" , "患教会" , "创新支付" , "电商" , "其他" ] ,
} ;
const fmTypes = fmTypesByTenant [ state . tenant ] || fmTypesByTenant [ "科普·无界" ] ;
const tenantOps = ( state . data . operations || [ ] ) . filter ( o => ( o . project _name || "" ) . includes ( state . tenant . replace ( "·无界" , "" ) ) || o . tenant === state . tenant ) ;
const months = [ "2026-06" , "2026-07" , "2026-08" , "2026-09" ] ;
const monthLabels = [ "6月" , "7月" , "8月" , "9月" ] ;
// Aggregates
const signed = pfs . filter ( x => x . status === "已签单" ) ;
const pending = pfs . filter ( x => x . status !== "已签单" ) ;
const sumSign = signed . reduce ( ( s , x ) => s + ( x . sign _amount || 0 ) , 0 ) ;
const sumPending = pending . reduce ( ( s , x ) => s + ( x . sign _amount || 0 ) , 0 ) ;
const monthRev = months . map ( m => pfs . reduce ( ( s , x ) => s + ( x [ "rev_" + m . replace ( "-" , "_" ) ] || 0 ) , 0 ) ) ;
const monthGross = months . map ( m => pfs . reduce ( ( s , x ) => s + ( x [ "gross_" + m . replace ( "-" , "_" ) ] || 0 ) , 0 ) ) ;
const renderPfRow = ( pf ) => {
const mCols = months . map ( m => {
const rev = pf [ "rev_" + m . replace ( "-" , "_" ) ] || 0 ;
const gross = pf [ "gross_" + m . replace ( "-" , "_" ) ] || 0 ;
return ` <td class="p-2 text-right whitespace-nowrap"><span class=" ${ rev ? 'text-blue-700 font-medium' : 'text-slate-300' } "> ${ rev ? money ( rev ) : '—' } </span><br><span class="text-xs ${ gross ? 'text-green-600' : 'text-slate-300' } "> ${ gross ? money ( gross ) : '—' } </span></td> ` ;
} ) . join ( "" ) ;
const totalRev = months . reduce ( ( s , m ) => s + ( pf [ "rev_" + m . replace ( "-" , "_" ) ] || 0 ) , 0 ) ;
const totalGross = months . reduce ( ( s , m ) => s + ( pf [ "gross_" + m . replace ( "-" , "_" ) ] || 0 ) , 0 ) ;
const totalCol = ` <td class="p-2 text-right whitespace-nowrap font-semibold"><span class=" ${ totalRev ? 'text-blue-700' : 'text-slate-300' } "> ${ totalRev ? money ( totalRev ) : '—' } </span><br><span class="text-xs ${ totalGross ? 'text-green-600' : 'text-slate-300' } "> ${ totalGross ? money ( totalGross ) : '—' } </span></td> ` ;
return ` <tr class="border-b border-slate-100 hover:bg-slate-50 cursor-pointer" onclick="openPfEditModal( ${ pf . id } )"><td class="p-2 text-sm font-medium"> ${ pf . customer _name } </td><td class="p-2 text-sm"> ${ pf . business _type } </td><td class="p-2 text-sm"> ${ pf . status === "已签单" ? badge ( "已签" ) : badge ( pf . status , "amber" ) } </td><td class="p-2 text-right text-sm"> ${ money ( pf . sign _amount ) } </td> ${ mCols } ${ totalCol } <td class="p-2 text-sm text-slate-500"> ${ pf . sales _person || "" } </td></tr> ` ;
} ;
document . querySelector ( "#finance" ) . innerHTML = ` <div class="grid gap-4">
${ card ( ` <h2 class="mb-4 text-lg font-bold">收入、毛利、成本/费用、净利月度曲线</h2><div style="position:relative;height:300px"><canvas id="financeChart2"></canvas></div> ` , "p-5" ) }
${ card ( formHtml ( [
{ label : "月份" , input : ` <input name="month" required placeholder="YYYY-MM" pattern=" \\ d{4}- \\ d{2}"> ` } ,
{ label: "类型", input: ` < select name = "record_type" > < option value = "revenue" > 收入 < / o p t i o n > < o p t i o n v a l u e = " c o s t _ e x p e n s e " > 成 本 / 费 用 < / o p t i o n > < / s e l e c t > ` } ,
{ label : "分类" , input : ` <input name="category" required> ` } ,
{ label : "金额/万" , input : ` <input name="amount" type="number" step="0.01" required> ` } ,
{ label : "发生日期" , input : ` <input name="occurred_date" type="date"> ` } ,
] , { handler : "createFinance" , text : "新增明细" } ) , "p-4" ) }
$ { renderTable ( [ "月份 " , "类型 " , "分类" , "金额" , "发生日期" , "备注" ] , r ows ) }
<div class="grid grid-cols-6 gap-3">
${ [ [ "已签项目" , "" + signed . length ] , [ "签约金额" , money ( sumSign ) ] , [ "待签项目" , "" + pending . length ] , [ "待签金额" , money ( sumPending ) ] , [ "本月确收" , money ( monthRev [ 0 ] ) ] , [ "本月毛利" , money ( monthGross [ 0 ] ) ] ] . map ( ( [ l , v ] ) => ` <div class="bg-white rounded-lg border border-slate-200 p-3 text-center"><p class="text-xs text-slate-500"> ${ l } </p><p class="text-xl font-bold text-slate-800"> ${ v } </p></div> ` ) . join ( "" ) }
</div>
${ card ( ` <div class="flex items-center justify-between cursor-pointer" onclick="toggleFinanceChart()"><h2 class="text-lg font-bold text-slate-700">月度趋势</h2><i data-lucide="chevron-down" class="transition-transform rotate-90" id="financeChartIcon"></i></div><div id="financeChartWrap" class="hidden mt-4"><div style="position:relative;height:300px"><canvas id="financeChart2"></canvas></div></div > `, "p-5" ) }
<div class="flex justify-end"><button class="btn btn-primary btn-sm" onclick="openFinanceModal()"><i data-lucide="plus"></i>新增财务项目</button></div>
<div id="financeModal" class="hidden fixed inset-0 z-50 flex items-center justify-center bg-black/40" onclick="closeFinanceModal()"><div class="bg-white rounded-2xl shadow-2xl w-full max-w-5xl mx-4 max-h-[92vh] overflow-y-auto" onclick="event.stopPropagation()"><div class="sticky top-0 z-10 bg-white/95 backdrop-blur border-b border-slate-100 px-8 py-5 flex items-center justify-between"><div><h3 class="text-xl font-bold text-slate-800" id="financeModalTitle">新增项目财务</h3><p class="text-xs text-slate-400 mt-0.5">填写项目财务信息,标记月度确收与毛利预估</p></div><button class="btn btn-ghost btn-sm rounded-full w-8 h-8 p-0" onclick="closeFinanceModal()"><i data-lucide="x"></i></button></div><form onsubmit="createFinance(event)" class="p-8 grid gap-6"><input type="hidden" name="pf_id" id="pf-id-input" value=""><div class="grid grid-cols-2 gap-6"><div class="bg-slate-50 rounded-xl p-5"><h4 class="text-sm font-semibold text-slate-500 mb-4 flex items-center gap-2"><i data-lucide="briefcase-business"></i>基本信息</h4><div class="grid gap-4"><label class="block"><span class="text-xs font-medium text-slate-500">项目</span><select name="project_id" class="mt-1 block w-full rounded-lg border border-slate-200 px-3 py-2.5 text-sm bg-white"> ${ tenantOps . map ( o => ` <option value=" ${ o . id } "> ${ o . project _name } </option> ` ) . join ( "" ) } </select></label><label class="block"><span class="text-xs font-medium text-slate-500">业务类型</span><select name="business_type" class="mt-1 block w-full rounded-lg border border-slate-200 px-3 py-2.5 text-sm bg-white"> ${ fmTypes . map ( t => ` <option> ${ t } </option> ` ) . join ( "" ) } </select></label><label class="block"><span class="text-xs font-medium text-slate-500">客户名称</span><input name="customer_name" class="mt-1 block w-full rounded-lg border border-slate-200 px-3 py-2.5 text-sm"></label></div></div><div class="bg-slate-50 rounded-xl p-5"><h4 class="text-sm font-semibold text-slate-500 mb-4 flex items-center gap-2"><i data-lucide="banknote"></i>签约信息</h4><div class="grid gap-4"><label class="block"><span class="text-xs font-medium text-slate-500">签约金额(万元)</span><input name="sign_amount" type="number" step="0.01" class="mt-1 block w-full rounded-lg border border-slate-200 px-3 py-2.5 text-sm" placeholder="0.00"></label><label class="block"><span class="text-xs font-medium text-slate-500">签约月份</span><input name="sign_month" class="mt-1 block w-full rounded-lg border border-slate-200 px-3 py-2.5 text-sm" placeholder="如 2026-06"></label><label class="block"><span class="text-xs font-medium text-slate-500">项目状态</span><select name="status" class="mt-1 block w-full rounded-lg border border-slate-200 px-3 py-2.5 text-sm bg-white"><option>已签单</option><option>待签</option></select></label></div></div></div><div class="bg-slate-50 rounded-xl p-5"><h4 class="text-sm font-semibold text-slate-500 mb-4 flex items-center gap-2"><i data-lucide="calendar"></i>月度确收 & 毛利预估(万元)</h4><div class="grid grid-cols-4 gap-3"> ${ [ "06" , "07" , "08" , "09" ] . map ( m => ` <div class="bg-white rounded-lg border border-slate-200 p-3 text-center"><p class="text-xs font-semibold text-slate-400 mb-2"> ${ m } 月</p><div class="grid grid-cols-2 gap-2"><div><p class="text-[10px] text-slate-400 mb-0.5">确收</p><input name="rev_2026_ ${ m } " type="number" step="0.01" class="w-full rounded-md border border-slate-200 px-2 py-1.5 text-sm text-center" placeholder="—"></div><div><p class="text-[10px] text-slate-400 mb-0.5">毛利</p><input name="gross_2026_ ${ m } " type="number" step="0.01" class="w-full rounded-md border border-slate-200 px-2 py-1.5 text-sm text-center" placeholder="—"></div></div></div> ` ) . join ( "" ) } </div><div class="grid grid-cols-2 gap-3 mt-3"><div class="bg-white rounded-lg border border-slate-200 p-3 text-center"><p class="text-xs font-semibold text-slate-400 mb-1">总确收</p><input name="total_rev" id="total-rev-display" class="w-full rounded-md border border-slate-200 px-2 py-1.5 text-sm text-center font-bold text-blue-700" readonly placeholder="自动计算"></div><div class="bg-white rounded-lg border border-slate-200 p-3 text-center"><p class="text-xs font-semibold text-slate-400 mb-1">总毛利</p><input name="total_gross" id="total-gross-display" class="w-full rounded-md border border-slate-200 px-2 py-1.5 text-sm text-center font-bold text-green-600" readonly placeholder="自动计算"></div></div></div><div class="flex justify-end gap-3 pt-2"><button type="button" class="btn btn-ghost btn-sm px-6" onclick="closeFinanceModal()">取消</button><button type="submit" class="btn btn-primary btn-sm px-8">保存</button></div></form></div></div>
${ card ( ` <h3 class="font-bold text-slate-700 mb-3">项目明细 <span class="text-slate-400 font-normal">( ${ pfs . length } )</span></h3><div class="flex gap-2 mb-3"> ${ [ [ "已签单 " , "已签 " ] , [ "待签" , "待签" ] ] . map ( ( [ k , v ] ) => ` <button class="btn btn-sm ${ state . finFilter === k ? 'btn-primary' : 'btn-ghost' } " onclick="state.finFilter=' ${ k } ';renderFinance()"> ${ v } ( ${ pfs . filter ( x => x . status === k ) . length } )</button> ` ) . join ( "" ) } </div><div class="overflow-x-auto"><table class="w-full text-sm"><thead><tr class="bg-slate-50 border-b border-slate-200"><th class="p-2 text-left font-semibold">客户</th><th class="p-2 text-left font-semibold">类型</th><th class="p-2 text-left font-semibold">状态</th><th class="p-2 text-right font-semibold">签约金额</th> ${ monthLabels . map ( l => ` <th class="p-2 text-center font-semibold"> ${ l } <br><span class="text-xs text-slate-400">确收/毛利</span></th> ` ) . join ( "" ) } <th class="p-2 text-center font-semibold">总计<br><span class="text-xs text-slate-400">确收/毛利</span></th><th class="p-2 text-left font-semibold">销售</th></tr></thead><tbody> ${ pfs . filter ( x => x . status === state . finFilter ) . map ( renderPfR ow) . join ( "" ) } </tbody></table></div> ` , "p-4" ) }
</div> ` ;
renderChartOn ( "financeChart2" , state . data . financeMonthly ) ;
if ( window . lucide ) window . lucide . createIcons ( ) ;
}
window . openFinanceModal = ( ) => {
const modal = document . querySelector ( "#financeModal" ) ;
modal . classList . remove ( "hidden" ) ;
} ;
window . closeFinanceModal = ( ) => {
const modal = document . querySelector ( "#financeModal" ) ;
modal . classList . add ( "hidden" ) ;
} ;
window . openPfEditModal = ( pfId ) => {
const pf = ( state . data . projectFinances || [ ] ) . find ( x => x . id === pfId ) ;
if ( ! pf ) return ;
document . querySelector ( "#pf-id-input" ) . value = pf . id ;
document . querySelector ( "#financeModalTitle" ) . textContent = "编辑项目财务" ;
const form = document . querySelector ( "#financeModal form" ) ;
form . querySelector ( '[name="project_id"]' ) . value = pf . project _id || "" ;
form . querySelector ( '[name="business_type"]' ) . value = pf . business _type || "" ;
form . querySelector ( '[name="customer_name"]' ) . value = pf . customer _name || "" ;
form . querySelector ( '[name="sign_amount"]' ) . value = pf . sign _amount || "" ;
form . querySelector ( '[name="sign_month"]' ) . value = pf . sign _month || "" ;
form . querySelector ( '[name="status"]' ) . value = pf . status || "已签单" ;
for ( const m of [ "06" , "07" , "08" , "09" ] ) {
form . querySelector ( '[name="rev_2026_' + m + '"]' ) . value = pf [ "rev_2026_" + m ] || "" ;
form . querySelector ( '[name="gross_2026_' + m + '"]' ) . value = pf [ "gross_2026_" + m ] || "" ;
}
// Calculate totals
const totalRev = ( pf . rev _2026 _06 || 0 ) + ( pf . rev _2026 _07 || 0 ) + ( pf . rev _2026 _08 || 0 ) + ( pf . rev _2026 _09 || 0 ) ;
const totalGross = ( pf . gross _2026 _06 || 0 ) + ( pf . gross _2026 _07 || 0 ) + ( pf . gross _2026 _08 || 0 ) + ( pf . gross _2026 _09 || 0 ) ;
document . querySelector ( "#total-rev-display" ) . value = money ( totalRev ) ;
document . querySelector ( "#total-gross-display" ) . value = money ( totalGross ) ;
openFinanceModal ( ) ;
} ;
window . toggleFinanceChart = ( ) => {
const wrap = document . querySelector ( "#financeChartWrap" ) ;
const icon = document . querySelector ( "#financeChartIcon" ) ;
if ( ! wrap ) return ;
wrap . classList . toggle ( "hidden" ) ;
icon . classList . toggle ( "rotate-90" ) ;
if ( ! wrap . classList . contains ( "hidden" ) && ! state . chart2 ) renderFinanceChart ( ) ;
} ;
function renderFinanceChart ( ) {
const { financeMonthly } = state . data ;
const canvas = document . querySelector ( "#financeChart2" ) ;
if ( ! canvas || ! window . Chart ) return ;
if ( state . chart2 ) state . chart2 . destroy ( ) ;
state . chart2 = new Chart ( canvas , {
type : "line" ,
data : {
labels : financeMonthly . map ( ( x ) => x . month ) ,
datasets : [
{ label : "月度确收" , data : financeMonthly . map ( ( x ) => x . revenue ) , borderColor : "#2563eb" , tension : 0.3 } ,
{ label : "月度毛利" , data : financeMonthly . map ( ( x ) => x . net _profit ) , borderColor : "#059669" , tension : 0.3 } ,
] ,
} ,
options : { responsive : true , maintainAspectRatio : false , plugins : { legend : { position : "bottom" , labels : { boxWidth : 12 , font : { size : 11 } } } } , scales : { x : { ticks : { font : { size : 11 } } , grid : { display : false } } , y : { ticks : { font : { size : 11 } , callback : ( v ) => v + "万" } } } } ,
} ) ;
}
function renderChartOn ( id , data ) {
@@ -270,11 +557,13 @@ function renderChartOn(id, data) {
} ) ;
}
function drawerField ( icon , label , name , value , multiline = false ) {
function drawerField ( icon , label , name , value , multiline = false , customControl = null ) {
const initialValue = text ( value ) ;
const control = multiline
? ` <textarea name=" ${ name } " rows="2" class="drawer-value drawer-textarea" data-original=" ${ initialValue } "> ${ initialValue } </textarea> `
: ` <input name=" ${ name } " value=" ${ initialValue } " class="drawer-value" data-original=" ${ initialValue } "> ` ;
const control = customControl
? customControl
: multiline
? ` <textarea name=" ${ name } " rows="2" class="drawer-value drawer-textarea" data-original=" ${ initialValue } "> ${ initialValue } </textarea> `
: ` <input name=" ${ name } " value=" ${ initialValue } " class="drawer-value" data-original=" ${ initialValue } "> ` ;
return ` <div class="drawer-field">
<div class="drawer-field-label"><i data-lucide=" ${ icon } "></i><span> ${ label } </span></div>
<div class="drawer-field-control"> ${ control } </div>
@@ -288,14 +577,14 @@ function openDrawer(resource, id) {
const fields = resource === "sales"
? [ [ "target_customer" , "业务机会" ] , [ "priority" , "优先级" ] , [ "status" , "状态" ] ]
: resource === "operations"
? [ [ "project_name" , "项目名称" ] , [ "project_version " , "项目版本 " ] , [ "project_status" , "项目状态" ] , [ "current_stage" , "当前阶段" ] , [ "target_customer" , "业务机会" ] , [ "customer_need" , "客户需求 " ] , [ "expected_contract_amount" , "预计签约 金额" ] , [ "expected_sign_date" , "预计签约时间" ] , [ "sign_probability" , "签约概率" ] , [ "sop_stage" , "SOP 阶段" ] , [ "execution_progress" , "执行进度" ] , [ "current_deliverable" , "当前交付物" ] , [ "risks" , "风险与阻塞" ] , [ "next_action" , "下一步动作 " ] ]
? [ [ "project_name" , "项目名称" ] , [ "owner " , "负责人 " ] , [ "expected_sign_date" , "截止时间 " ] , [ "expected_contract_amount" , "金额" ] , [ "notes" , "项目说明 " ] ]
: resource === "proposals"
? [ [ "customer_or_project_name" , "客户/项目" ] , [ "version" , "版本号" ] , [ "description" , "版本说明" ] , [ "created_date" , "创建日期" ] , [ "status" , "状态" ] ]
: [ [ "product_name" , "产品名称" ] , [ "version" , "版本号" ] , [ "version_goal" , "版本目标" ] , [ "feature_list" , "核心功能清单" ] , [ "platform" , "平台" ] , [ "launch_date" , "上线日期" ] , [ "status" , "当前状态" ] , [ "notes" , "备注" ] ] ;
const fieldIcons = {
target _customer : "user" , priority : "flag" , status : "circle-dot" ,
project _name : "briefcase-business" , project _version : "git-branch" , project _status : "circle-dot" , current _stage : "map-pin" ,
customer _need : "file-text" , expected _contract _amount : "banknote" , expected _sign _date : "calendar" ,
owner : "user" , customer_need : "file-text" , expected _contract _amount : "banknote" , expected _sign _date : "calendar" ,
sign _probability : "percent" , sop _stage : "list-checks" , execution _progress : "activity" ,
current _deliverable : "package" , risks : "alert-triangle" , next _action : "arrow-right" ,
product _name : "box" , version : "tag" , version _goal : "target" , feature _list : "list" , platform : "layers" ,
@@ -304,26 +593,15 @@ function openDrawer(resource, id) {
const multilineFields = [ "customer_need" , "current_deliverable" , "risks" , "next_action" , "version_goal" , "feature_list" , "notes" ] ;
const followupTarget = resource === "sales" ? "sales" : resource === "proposals" ? "proposal" : resource === "operations" ? "operation" : resource === "products" ? "product" : "" ;
const title = item . target _customer || item . project _name || item . customer _or _project _name || item . product _name ;
drawer . innerHTML = ` <div class="drawer-panel"><div class="sticky top-0 z-10 flex items-center justify-between border-b border-slate-200 bg-white/95 px-5 py-3 backdrop-blur"><div><p class="text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-400">Detail Drawer</p><div class="flex items-center gap-2"><h2 class="drawer-title text-[17px] font-semibold leading-6 text-slate-900"> ${ title } </h2><span id="drawerSaveStatus" class="save-status"></span></div></div><button class="btn btn-ghost btn-sm" onclick="closeDrawer()">关闭</button></div><div class="grid gap-5 p-5">
drawer . innerHTML = ` <div class="drawer-panel"><div class="sticky top-0 z-10 flex items-center justify-between border-b border-slate-200 bg-white/95 px-5 py-3 backdrop-blur"><div><p class="text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-400">Detail Drawer</p><div class="flex items-center gap-2"><h2 class="drawer-title text-[17px] font-semibold leading-6 text-slate-900"> ${ title } </h2><span id="drawerSaveStatus" class="save-status"></span></div></div><div class="flex items-center gap-2"><button class="btn btn-ghost btn-sm text-red-600 hover:bg-red-50" onclick="deleteDrawerItem(' ${ resource } ', ${ id } )"><i data-lucide="trash-2"></i>删除</button>< button class="btn btn-ghost btn-sm" onclick="closeDrawer()">关闭</button></div></div>< div class="grid gap-5 p-5">
<section>
<h3 class="drawer-section-title">属性</h3>
<form id="drawerForm" class="drawer-fields">${ fields . map ( ( [ key , label ] ) => drawerField ( fieldIcons [ key ] || "circle" , label , key , item [ key ] , multilineFields . includes ( key ) ) ) . join ( "" ) } </form>
<form id="drawerForm" class="drawer-fields">
${ drawerField ( "map-pin" , "当前阶段" , "current_stage" , "" , false , ` <select name="current_stage" class="drawer-value" onchange="saveDrawerField(this,' ${ resource } ', ${ id } )"> ${ [ "商务洽谈" , "系统上线" , "团队分工" , "项目交付" , "上线推广" , "结项验收" ] . map ( ( s ) => ` <option ${ s === item . current _stage ? "selected" : "" } > ${ s } </option> ` ) . join ( "" ) } </select> ` ) }
${ fields . map ( ( [ key , label ] ) => drawerField ( fieldIcons [ key ] || "circle" , label , key , item [ key ] , multilineFields . includes ( key ) ) ) . join ( "" ) }
</form>
</section>
${ resource === "proposals" ? ` <section><h3 class="drawer-section-title">方案文件</h3><div class="grid gap-2"> ${ [ "方案" , "成本" , "SOP" , "财务流程" ] . map ( ( cat ) => fileGroup ( "proposal" , item . id , item . version , cat , item . files . filter ( ( f ) => f . file _category === cat ) ) ) . join ( "" ) } </div></section> ` : "" }
${ resource === "operations" ? ( ( ) => {
const tasks = ( state . data . tasks || [ ] ) . filter ( ( t ) => t . project _id === id ) ;
if ( ! tasks . length ) return "" ;
const phases = [ ... new Set ( tasks . map ( ( t ) => t . phase ) ) ] ;
return ` <section><h3 class="drawer-section-title">项目任务</h3><div class="grid gap-3"> ${ phases . map ( ( phase ) => {
const pt = tasks . filter ( ( t ) => t . phase === phase ) ;
return ` <div class="rounded-md border border-slate-200 p-3"><p class="text-[13px] font-semibold text-slate-700 mb-2"><i data-lucide="layers" style="width:14px;height:14px;display:inline;vertical-align:-2px;margin-right:4px"></i> ${ phase } </p><div class="grid gap-1.5"> ${ pt . map ( ( t ) => {
const due = t . due _date ? ` <span class="text-[11px] text-slate-400 ml-1">📅 ${ t . due _date } </span> ` : "" ;
const owner = t . owner ? ` <span class="text-[11px] text-slate-500 ml-1">👤 ${ t . owner } </span> ` : "" ;
const blocker = t . blockers ? ` <p class="text-[11px] text-red-600 mt-0.5">⚠ ${ t . blockers } </p> ` : "" ;
return ` <div class="rounded bg-slate-50 px-2.5 py-1.5"><p class="text-[12px] text-slate-800"><strong> ${ t . milestone ? t . milestone + ": " : "" } </strong> ${ t . task } ${ due } ${ owner } </p> ${ blocker } </div> ` ;
} ).join("")}</div></div> ` ;
} ).join("")}</div></section> ` ;
} )() : ""}
${ followupTarget ? ` <section>
<h3 class="drawer-section-title">活动 / 跟进</h3>
<div class="grid gap-2"> ${ ( item . followups || [ ] ) . map ( ( f ) => ` <div class="activity-item"><div class="activity-icon"><i data-lucide="message-square"></i></div><div class="min-w-0 flex-1"><div class="flex justify-between gap-3 text-[12px] text-slate-500"><span> ${ f . follower } · ${ f . follow _up _method } </span><span> ${ f . followed _at } </span></div><div class="mt-1 leading-5 text-slate-800 rich-content" data-html=" ${ encodeURIComponent ( f . content || '' ) } "></div> ${ f . next _action ? ` <p class="mt-1 leading-5 text-blue-700">下一步: ${ text ( f . next _action ) } </p> ` : "" } </div><button class="activity-delete" onclick="deleteFollowup(event, ${ f . id } , ' ${ resource } ', ${ item . id } )" title="删除评论"><i data-lucide="trash-2"></i></button></div> ` ) . join ( "" ) } </div>
@@ -388,7 +666,7 @@ function bindDrawerAutosave(resource, id, item) {
field . addEventListener ( "keydown" , ( event ) => {
if ( event . key === "Enter" && field . tagName !== "TEXTAREA" ) field . blur ( ) ;
} ) ;
field . addEventListener ( "blur" , async ( ) => {
const doSave = async ( ) => {
const value = field . value ;
if ( value === field . dataset . original ) return ;
const previous = field . dataset . original ;
@@ -408,13 +686,88 @@ function bindDrawerAutosave(resource, id, item) {
setDrawerSaveStatus ( "保存失败" , "danger" ) ;
alert ( ` 自动保存失败: ${ error . message } ` ) ;
}
} ) ;
} ;
field . addEventListener ( "blur" , doSave ) ;
if ( field . tagName === "SELECT" ) field . addEventListener ( "change" , doSave ) ;
} ) ;
}
window . openDrawer = openDrawer ;
window . deleteDrawerItem = async ( resource , id ) => {
if ( ! confirm ( "确认删除?此操作不可撤销。" ) ) return ;
try {
await api ( ` /api/ ${ resource } / ${ id } ` , { method : "DELETE" } ) ;
closeDrawer ( ) ;
await load ( ) ;
} catch ( error ) {
alert ( "删除失败:" + error . message ) ;
}
} ;
window . toggleTaskDone = async ( taskId , projectId ) => {
const task = ( state . data . tasks || [ ] ) . find ( ( t ) => t . id === taskId ) ;
if ( ! task ) return ;
const newStatus = task . status === "done" ? "" : "done" ;
try {
await api ( ` /api/tasks/ ${ taskId } ` , { method : "PUT" , body : JSON . stringify ( { data : { status : newStatus } } ) } ) ;
await load ( ) ;
} catch ( error ) {
alert ( "更新失败:" + error . message ) ;
}
} ;
window . deleteTask = async ( projectId ) => {
const taskId = document . querySelector ( ` #task-id- ${ projectId } ` ) . value ;
if ( ! taskId ) return ;
if ( ! confirm ( "确认删除该任务?此操作不可撤销。" ) ) return ;
try {
await api ( ` /api/tasks/ ${ taskId } ` , { method : "DELETE" } ) ;
closeTaskDrawer ( projectId ) ;
await load ( ) ;
} catch ( error ) {
alert ( "删除失败:" + error . message ) ;
}
} ;
let dragTaskId = null ;
window . handleTaskDragStart = ( event , taskId ) => {
dragTaskId = taskId ;
event . currentTarget . classList . add ( "dragging" ) ;
event . dataTransfer . effectAllowed = "move" ;
} ;
window . handleTaskDrop = async ( event , projectId , phase ) => {
event . preventDefault ( ) ;
event . currentTarget . classList . remove ( "drag-over" ) ;
const target = event . currentTarget ;
if ( ! dragTaskId ) return ;
// Find the dragged element and insert after the nearest task
const dragged = document . querySelector ( ` .task-row[data-id=" ${ dragTaskId } "] ` ) ;
if ( ! dragged ) return ;
const afterElement = getDragAfterElement ( target , event . clientY ) ;
if ( afterElement ) {
target . insertBefore ( dragged , afterElement ) ;
} else {
target . appendChild ( dragged ) ;
}
dragged . classList . remove ( "dragging" ) ;
// Update sort_order in DB
const rows = [ ... target . querySelectorAll ( ".task-row" ) ] ;
const updates = rows . map ( ( row , i ) => ( { id : parseInt ( row . dataset . id ) , sort _order : i } ) ) ;
try {
await api ( ` /api/tasks/batch-sort ` , { method : "POST" , body : JSON . stringify ( { items : updates } ) } ) ;
} catch ( e ) { /* non-critical */ }
dragTaskId = null ;
} ;
function getDragAfterElement ( container , y ) {
const elements = [ ... container . querySelectorAll ( ".task-row:not(.dragging)" ) ] ;
return elements . reduce ( ( closest , child ) => {
const box = child . getBoundingClientRect ( ) ;
const offset = y - box . top - box . height / 2 ;
if ( offset < 0 && offset > closest . offset ) {
return { offset , element : child } ;
}
return closest ;
} , { offset : Number . NEGATIVE _INFINITY } ) . element ;
}
window . closeDrawer = ( ) => document . querySelector ( "#drawer" ) . classList . remove ( "open" ) ;
window . squireInstances = { } ;
window . squireCmd = ( cmd ) => {