@@ -170,56 +170,167 @@ 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 . openTaskForm = ( projectId , taskId ) => {
const form = document . querySelector ( ` #task-form- ${ projectId } ` ) ;
form . classList . remove ( "hidden" ) ;
if ( taskId === null ) {
// new task
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-submit-btn- ${ projectId } ` ) . textContent = "确认新增" ;
} else {
// edit task
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 = "保存修改" ;
}
form . scrollIntoView ( { behavior : "smooth" } ) ;
} ;
window . submitTaskForm = async ( event , projectId ) => {
event . preventDefault ( ) ;
const form = event . currentTarget ;
const data = Object . fromEntries ( new FormData ( form ) . entries ( ) ) ;
data . project _id = 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 } ) } ) ;
}
form . reset ( ) ;
form . classList . add ( "hidden" ) ;
await load ( ) ;
showTaskModal ( projectId ) ;
} catch ( error ) {
alert ( "保存失败:" + error . message ) ;
}
} ;
window . createFinance = ( event ) => createResource ( event , "finance" ) ;
window . switchTab = switchTab ;
function renderProjects ( ) {
// Merge sales_leads and operation_projects into one table
const salesItems = state . data . sale s. 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 } ) ) ;
const items = state . data . operations ;
const rows = item s. 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(); showTaskModal( ${ x . id } )"><i data-lucide="eye"></i>查看</button> `
] ) ;
document . querySelector ( "#projects" ) . innerHTML = ` <div class="grid gap-4">
${ card ( formHtml ( [
{ label : "业务机会" , input : ` <input name="target_customer" required placeholder="客户名称"> ` } ,
{ label: "优先级", input: ` < select 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 ", tex t: "新增项目" } ) , "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 ) }
<div class="flex items-center justify-between">
<div class="flex gap-2">
${ [ [ "all" , "全部" ] , [ "商务洽谈" , "洽谈" ] , [ "系统上线" , "系统" ] , [ "团队分工" , "团队" ] , [ "项目交付" , "交付" ] , [ "上线推广" , "推广" ] , [ "结项验收" , "验收" ] ] . map ( ( [ k , v ] ) => ` <button class="btn ${ state . opFilter === k ? "btn-primary" : "btn-ghost" } btn-sm" onclick="state.opFilter=' ${ k } '; renderProjects()"> ${ v } </button> ` ) . join ( "" ) }
</div>
<button class="btn btn-primary" onclick="document.querySelector('#project-form').classList.toggle('hidden')">
<i data-lucide="plus"></i>新增项目
</button>
</div>
<div id="project-form" class="hidden">
${ card ( formHtml ( [
{ label : "项目名称 " , inpu t: ` <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 > ` ;
}
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">
<form id="task-form- ${ projectId } " class="hidden task-form" onsubmit="submitTaskForm(event, ${ projectId } )">
<input type="hidden" name="task_id" id="task-id- ${ projectId } " value="">
<div class="grid grid-cols-2 gap-3">
<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 col-span-2"><span>任务说明</span><textarea name="notes" rows="2" id="task-notes- ${ projectId } "></textarea></label>
<label class="task-field col-span-2"><span>卡点&备注</span><textarea name="blockers" rows="2" id="task-blockers- ${ projectId } " placeholder="风险卡点、依赖项等"></textarea></label>
</div>
<div class="flex justify-end gap-2 mt-3">
<button type="button" class="btn btn-ghost btn-sm" onclick="document.querySelector('#task-form- ${ projectId } ').classList.add('hidden')">取消</button>
<button type="submit" class="btn btn-primary btn-sm" id="task-submit-btn- ${ projectId } ">确认新增</button>
</div>
</form>
${ 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></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="customer_or_project_name" required placeholder="如:信达生物"> ` } ,
{ label: "版本号", input: ` < input 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">
@@ -310,20 +421,6 @@ function openDrawer(resource, id) {
<form id="drawerForm" class="drawer-fields"> ${ 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>