@@ -5,6 +5,12 @@ function renderExpense() {
var money = function ( v ) { return '¥ ' + ( Number ( v || 0 ) ) . toLocaleString ( 'zh-CN' , { minimumFractionDigits : 0 , maximumFractionDigits : 2 } ) ; } ;
var money = function ( v ) { return '¥ ' + ( Number ( v || 0 ) ) . toLocaleString ( 'zh-CN' , { minimumFractionDigits : 0 , maximumFractionDigits : 2 } ) ; } ;
var esc = function ( s ) { return ( s || '' ) . replace ( /&/g , '&' ) . replace ( /</g , '<' ) . replace ( />/g , '>' ) . replace ( /"/g , '"' ) ; } ;
var esc = function ( s ) { return ( s || '' ) . replace ( /&/g , '&' ) . replace ( /</g , '<' ) . replace ( />/g , '>' ) . replace ( /"/g , '"' ) ; } ;
var EXPENSE _TYPES = [ '通用费用' , '平台采购' , '人力成本' , '研发费用' , '奖金' , '其他' ] ;
var EXPENSE _TYPES = [ '通用费用' , '平台采购' , '人力成本' , '研发费用' , '奖金' , '其他' ] ;
var STATUS _OPTIONS = [ '计入费用' , '暂不计入' ] ;
// 排序状态
if ( ! state . expenseSort ) state . expenseSort = { key : 'expense_month' , asc : true } ;
var sortKey = state . expenseSort . key ;
var sortAsc = state . expenseSort . asc ? 1 : - 1 ;
// 筛选状态
// 筛选状态
var view = state . expenseView || 'total' ;
var view = state . expenseView || 'total' ;
@@ -35,30 +41,41 @@ function renderExpense() {
} ) ;
} ) ;
}
}
// 排序
filtered . sort ( function ( a , b ) {
var va = a [ sortKey ] || '' , vb = b [ sortKey ] || '' ;
if ( sortKey === 'amount' || sortKey === 'incurred_amount' ) {
va = parseFloat ( va ) || 0 ; vb = parseFloat ( vb ) || 0 ;
}
if ( va < vb ) return - 1 * sortAsc ;
if ( va > vb ) return 1 * sortAsc ;
return 0 ;
} ) ;
var totalAmount = filtered . reduce ( function ( s , r ) { return s + ( r . amount || 0 ) ; } , 0 ) ;
var totalAmount = filtered . reduce ( function ( s , r ) { return s + ( r . amount || 0 ) ; } , 0 ) ;
var totalIncurred = filtered . reduce ( function ( s , r ) { return s + ( r . incurred _amount || 0 ) ; } , 0 ) ;
var totalIncurred = filtered . reduce ( function ( s , r ) { return s + ( r . incurred _amount || 0 ) ; } , 0 ) ;
// 月份下拉选项(去年/今年/明年)
// 月份下拉选项(去年/今年/明年)
var monthOpts = '<option value="">请选择</option> ' ;
var monthOpts = '' ;
for ( var yr = thisYear - 1 ; yr <= thisYear + 1 ; yr ++ )
for ( var yr = thisYear - 1 ; yr <= thisYear + 1 ; yr ++ )
for ( var m = 1 ; m <= 12 ; m ++ ) {
for ( var m = 1 ; m <= 12 ; m ++ ) {
var mv = yr + '-' + String ( m ) . padStart ( 2 , '0' ) ;
var mv = yr + '-' + String ( m ) . padStart ( 2 , '0' ) ;
monthOpts += '<option value="' + mv + '"' + ( mv === state . expenseMonth ? ' selected' : '' ) + ' >' + mv + '</option>' ;
monthOpts += '<option value="' + mv + '">' + mv + '</option>' ;
}
}
// 季度下拉
// 季度下拉
var qLabels = [ 'Q1 (1-3月)' , 'Q2 (4-6月)' , 'Q3 (7-9月)' , 'Q4 (10-12月)' ] ;
var qLabels = [ 'Q1 (1-3月)' , 'Q2 (4-6月)' , 'Q3 (7-9月)' , 'Q4 (10-12月)' ] ;
var quarterOpts = '' ;
var quarterOpts = '' ;
for ( var qi = 0 ; qi < 4 ; qi ++ ) {
for ( var qi = 0 ; qi < 4 ; qi ++ ) {
quarterOpts += '<option value="' + qi + '"' + ( String ( qi ) === state . expenseQuarter ? ' selected' : '' ) + ' >' + qLabels [ qi ] + '</option>' ;
quarterOpts += '<option value="' + qi + '">' + qLabels [ qi ] + '</option>' ;
}
}
// 月份/季度日期选择器
// 月份/季度日期选择器
var dateSelect = '' ;
var dateSelect = '' ;
if ( view === 'monthly' ) {
if ( view === 'monthly' ) {
dateSelect = '<span class="text-sm text-slate-500 ml-2">月份:</span><select onchange="setExpenseMonth(this.value)" class="text-sm font-medium py-1 px-2.5 cursor-pointer" style="appearance:none;-webkit-appearance:none;background-color:transparent;border:0;outline:none;background:url('data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 width=%2212%22 height=%2212%22 viewBox=%220 0 24 24%22 fill=%22none%22 stroke=%22%236b7280%22 stroke-width=%222%22 stroke-linecap=%22round%22 stroke-linejoin=%22round%22%3E%3Cpolyline points=%226 9 12 15 18 9%22%3E%3C/polyline%3E%3C/svg%3E') no-repeat right 4px center;padding-right:22px;min-height:30px">' + monthOpts + '</select>' ;
dateSelect = '<span class="text-sm text-slate-500 ml-2">月份:</span><select onchange="setExpenseMonth(this.value)" class="text-sm font-medium py-1 px-2.5 cursor-pointer" style="appearance:none;-webkit-appearance:none;background-color:transparent;border:0;outline:none;background:url('data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 width=%2212%22 height=%2212%22 viewBox=%220 0 24 24%22 fill=%22none%22 stroke=%22%236b7280%22 stroke-width=%222%22 stroke-linecap=%22round%22 stroke-linejoin=%22round%22%3E%3Cpolyline points=%226 9 12 15 18 9%22%3E%3C/polyline%3E%3C/svg%3E') no-repeat right 4px center;padding-right:22px;min-height:30px"><option value=""' + ( ! state . expenseMonth ? ' selected' : '' ) + '>请选择</option>' + monthOpts . replace ( 'value="' + state . expenseMonth + '"' , 'value="' + state . expenseMonth + '" selected' ) + '</select>' ;
} else if ( view === 'quarterly' ) {
} else if ( view === 'quarterly' ) {
dateSelect = '<span class="text-sm text-slate-500 ml-2">季度:</span><select onchange="setExpenseQuarter(this.value)" class="text-sm font-medium py-1 px-2.5 cursor-pointer" style="appearance:none;-webkit-appearance:none;background-color:transparent;border:0;outline:none;background:url('data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 width=%2212%22 height=%2212%22 viewBox=%220 0 24 24%22 fill=%22none%22 stroke=%22%236b7280%22 stroke-width=%222%22 stroke-linecap=%22round%22 stroke-linejoin=%22round%22%3E%3Cpolyline points=%226 9 12 15 18 9%22%3E%3C/polyline%3E%3C/svg%3E') no-repeat right 4px center;padding-right:22px;min-height:30px">' + quarterOpts + '</select>' ;
dateSelect = '<span class="text-sm text-slate-500 ml-2">季度:</span><select onchange="setExpenseQuarter(this.value)" class="text-sm font-medium py-1 px-2.5 cursor-pointer" style="appearance:none;-webkit-appearance:none;background-color:transparent;border:0;outline:none;background:url('data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 width=%2212%22 height=%2212%22 viewBox=%220 0 24 24%22 fill=%22none%22 stroke=%22%236b7280%22 stroke-width=%222%22 stroke-linecap=%22round%22 stroke-linejoin=%22round%22%3E%3Cpolyline points=%226 9 12 15 18 9%22%3E%3C/polyline%3E%3C/svg%3E') no-repeat right 4px center;padding-right:22px;min-height:30px">' + quarterOpts . replace ( 'value="' + state . expenseQuarter + '"' , 'value="' + state . expenseQuarter + '" selected' ) + '</select>' ;
}
}
// Toolbar
// Toolbar
@@ -68,21 +85,36 @@ function renderExpense() {
} ) ;
} ) ;
toolbar += '</select>' + dateSelect ;
toolbar += '</select>' + dateSelect ;
// 排序箭头
var sortArrow = function ( key ) {
var arrow = key === sortKey ? ( sortAsc === 1 ? ' ↑' : ' ↓' ) : '' ;
return '<span class="text-slate-400 text-xs cursor-pointer" onclick="toggleExpenseSort(\'' + key + '\')">' + arrow + '</span>' ;
} ;
// 表格行
// 表格行
var tableRows = '' ;
var tableRows = '' ;
if ( filtered . length ) {
if ( filtered . length ) {
filtered . forEach ( function ( r ) {
filtered . forEach ( function ( r ) {
tableRows += '<tr class="border-b border-slate-100 hover:bg-slate-50 cursor-pointer" onclick="openExpenseEdit(' + r . id + ')"><td class="p-2 font-medium text-center align-middle" style="width:200px">' + esc ( r . expense _type ) + '</td><td class="p-2 text-center align-middle" style="width:200px">' + ( r . expense _month || '—' ) + '</td><td class="p-2 text-center align-middle font-medium" style="width:200px">' + money ( r . amount ) + '</td><td class="p-2 text-center align-middle font-medium" style="width:200px">' + money ( r . incurred _amount ) + '</td><td class="p-2 text-center align-middle text-slate-500" style="width:200px">' + ( esc ( r . notes ) || '—' ) + '</td></tr> ' ;
var statusCls = r . status === '暂不计入' ? 'text-amber-600' : 'text-green-600 ' ;
tableRows += '<tr class="border-b border-slate-100 hover:bg-slate-50 cursor-pointer" onclick="openExpenseEdit(' + r . id + ')"><td class="p-2 text-center align-middle font-medium">' + ( r . expense _month || '—' ) + '</td><td class="p-2 font-medium text-center align-middle">' + esc ( r . expense _type ) + '</td><td class="p-2 text-center align-middle font-semibold ' + statusCls + '">' + esc ( r . status || '计入费用' ) + '</td><td class="p-2 text-center align-middle font-medium">' + money ( r . amount ) + '</td><td class="p-2 text-center align-middle font-medium">' + money ( r . incurred _amount ) + '</td><td class="p-2 text-center align-middle text-slate-500">' + ( esc ( r . notes ) || '—' ) + '</td></tr>' ;
} ) ;
} ) ;
} else {
} else {
var emptyMsg = view === 'monthly' ? '该月份暂无费用记录' : view === 'quarterly' ? '该季度暂无费用记录' : '暂无费用记录' ;
var emptyMsg = view === 'monthly' ? '该月份暂无费用记录' : view === 'quarterly' ? '该季度暂无费用记录' : '暂无费用记录' ;
tableRows = '<tr><td colspan="5 " class="p-6 text-center text-slate-400">' + emptyMsg + '</td></tr>' ;
tableRows = '<tr><td colspan="6 " class="p-6 text-center text-slate-400">' + emptyMsg + '</td></tr>' ;
}
}
// 模态框
// 模态框 monthOpts for form (with current value preselected)
var modalMonthOpts = '<option value="">请选择</option>' ;
for ( var yr2 = thisYear - 1 ; yr2 <= thisYear + 1 ; yr2 ++ )
for ( var m2 = 1 ; m2 <= 12 ; m2 ++ ) {
var mv2 = yr2 + '-' + String ( m2 ) . padStart ( 2 , '0' ) ;
modalMonthOpts += '<option value="' + mv2 + '">' + mv2 + '</option>' ;
}
var statusOpts = STATUS _OPTIONS . map ( function ( s ) { return '<option value="' + s + '">' + s + '</option>' ; } ) . join ( '' ) ;
var modal = '<div id="expenseModal" class="hidden fixed inset-0 z-50 flex items-center justify-center bg-black/40" onclick="closeExpenseModal()"><div class="bg-white rounded-2xl shadow-2xl w-full max-w-lg mx-4" onclick="event.stopPropagation()"><div class="sticky top-0 z-10 bg-white/95 backdrop-blur border-b border-slate-100 px-6 py-4 flex items-center justify-between"><h3 class="text-lg font-bold text-slate-800" id="expenseModalTitle">新增费用</h3><div class="flex items-center gap-2"><button class="btn btn-ghost btn-sm text-red-600 hidden" id="expenseDeleteBtn" onclick="deleteExpenseItem()">删除</button><button class="btn btn-ghost btn-sm rounded-full w-8 h-8 p-0" onclick="closeExpenseModal()"><i data-lucide="x"></i></button></div></div><form onsubmit="saveExpense(event)" class="p-6 grid gap-4" novalidate><input type="hidden" name="expense_id" value=""><div class="grid grid-cols-2 gap-3"><label class="block"><span class="text-sm text-slate-500 mb-1 block">费用类型 <span class="text-red-500">*</span></span><select name="expense_type" required class="form-ctrl bg-white"><option value="">请选择</option>' ;
var modal = '<div id="expenseModal" class="hidden fixed inset-0 z-50 flex items-center justify-center bg-black/40" onclick="closeExpenseModal()"><div class="bg-white rounded-2xl shadow-2xl w-full max-w-lg mx-4" onclick="event.stopPropagation()"><div class="sticky top-0 z-10 bg-white/95 backdrop-blur border-b border-slate-100 px-6 py-4 flex items-center justify-between"><h3 class="text-lg font-bold text-slate-800" id="expenseModalTitle">新增费用</h3><div class="flex items-center gap-2"><button class="btn btn-ghost btn-sm text-red-600 hidden" id="expenseDeleteBtn" onclick="deleteExpenseItem()">删除</button><button class="btn btn-ghost btn-sm rounded-full w-8 h-8 p-0" onclick="closeExpenseModal()"><i data-lucide="x"></i></button></div></div><form onsubmit="saveExpense(event)" class="p-6 grid gap-4" novalidate><input type="hidden" name="expense_id" value=""><div class="grid grid-cols-2 gap-3"><label class="block"><span class="text-sm text-slate-500 mb-1 block">费用类型 <span class="text-red-500">*</span></span><select name="expense_type" required class="form-ctrl bg-white"><option value="">请选择</option>' ;
EXPENSE _TYPES . forEach ( function ( t ) { modal += '<option>' + t + '</option>' ; } ) ;
EXPENSE _TYPES . forEach ( function ( t ) { modal += '<option>' + t + '</option>' ; } ) ;
modal += '</select></label><label class="block"><span class="text-sm text-slate-500 mb-1 block">月份 <span class="text-red-500">*</span></span><select name="expense_month" required class="form-ctrl bg-white">' + monthOpts + '</select></label></div><div class="grid grid-cols-2 gap-3"><label class="block"><span class="text-sm text-slate-500 mb-1 block">金额(元)</span><input name="amount" type="number" step="0.01" class="form-ctrl" placeholder="0.00"></label><label class="block"><span class="text-sm text-slate-500 mb-1 block">已发生金额(元)</span><input name="incurred_amount" type="number" step="0.01" class="form-ctrl" placeholder="0.00"></label></div><label class="block"><span class="text-sm text-slate-500 mb-1 block">费用说明</span><textarea name="notes" class="form-ctrl" rows="3" placeholder="备注信息"></textarea></label><div class="flex justify-end gap-3 pt-2"><button type="button" class="btn btn-ghost btn-sm px-6" onclick="closeExpenseModal()">取消</button><button type="submit" class="btn btn-primary btn-sm px-8">保存</button></div></form></div></div>' ;
modal += '</select></label><label class="block"><span class="text-sm text-slate-500 mb-1 block">月份 <span class="text-red-500">*</span></span><select name="expense_month" required class="form-ctrl bg-white">' + modalMo nthOpts + '</select></label></div><div class="grid grid-cols-2 gap-3"><label class="block"><span class="text-sm text-slate-500 mb-1 block">金额(元)</span><input name="amount" type="number" step="0.01" class="form-ctrl" placeholder="0.00"></label><label class="block"><span class="text-sm text-slate-500 mb-1 block">已发生金额(元)</span><input name="incurred_amount" type="number" step="0.01" class="form-ctrl" placeholder="0.00"></label></div><label class="block"><span class="text-sm text-slate-500 mb-1 block">状态</span><select name="status" class="form-ctrl bg-white">' + statusOpts + '</select></label><label class="block"><span class="text-sm text-slate-500 mb-1 block">费用说明</span><textarea name="notes" class="form-ctrl" rows="3" placeholder="备注信息"></textarea></label><div class="flex justify-end gap-3 pt-2"><button type="button" class="btn btn-ghost btn-sm px-6" onclick="closeExpenseModal()">取消</button><button type="submit" class="btn btn-primary btn-sm px-8">保存</button></div></form></div></div>' ;
var target = document . querySelector ( "#financeExpense" ) || document . querySelector ( "#financeTabExpense" ) || document . querySelector ( "#expense" ) ;
var target = document . querySelector ( "#financeExpense" ) || document . querySelector ( "#financeTabExpense" ) || document . querySelector ( "#expense" ) ;
if ( ! target ) return ;
if ( ! target ) return ;
@@ -94,12 +126,20 @@ function renderExpense() {
'<div class="metric-card p-3"><span class="flex items-center gap-2 text-sm text-slate-500">已发生金额</span><strong class="mt-2 block text-xl">' + money ( totalIncurred ) + '</strong></div>' +
'<div class="metric-card p-3"><span class="flex items-center gap-2 text-sm text-slate-500">已发生金额</span><strong class="mt-2 block text-xl">' + money ( totalIncurred ) + '</strong></div>' +
'</div>' +
'</div>' +
modal +
modal +
'<div class="card p-4"><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-center font-semibold" style="width:200px">费用类型</th><th class="p-2 text-center font-semibold" style="width:200px">月份</th><th class="p-2 text-center font-semibold" style="width:200px">金额</th><th class="p-2 text-center font-semibold" style="width:200px">已发生金额</th><th class="p-2 text-center font-semibold" style="width:200px ">费用说明</th></tr></thead><tbody>' + tableRows + '</tbody></table></div></div>' +
'<div class="card p-4"><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-center font-semibold cursor-pointer select-none" onclick="toggleExpenseSort(\'expense_month\')">月份' + sortArrow ( 'expense_month' ) + '</th><th class="p-2 text-center font-semibold cursor-pointer select-none" onclick="toggleExpenseSort(\'expense_type\')">费用类型' + sortArrow ( 'expense_type' ) + '</th><th class="p-2 text-center font-semibold cursor-pointer select-none" onclick="toggleExpenseSort(\'status\')">状态' + sortArrow ( 'status' ) + '</th><th class="p-2 text-center font-semibold cursor-pointer select-none" onclick="toggleExpenseSort(\'amount\')">金额' + sortArrow ( 'amount' ) + '</th><th class="p-2 text-center font-semibold cursor-pointer select-none" onclick="toggleExpenseSort(\'incurred_amount\')">已发生金额' + sortArrow ( 'incurred_amount' ) + '</th><th class="p-2 text-center font-semibold ">费用说明</th></tr></thead><tbody>' + tableRows + '</tbody></table></div></div>' +
'</div>' ;
'</div>' ;
if ( window . lucide ) window . lucide . createIcons ( ) ;
if ( window . lucide ) window . lucide . createIcons ( ) ;
}
}
window . toggleExpenseSort = function ( key ) {
var cur = state . expenseSort || { } ;
if ( cur . key === key ) { cur . asc = ! cur . asc ; }
else { cur . key = key ; cur . asc = true ; }
state . expenseSort = cur ;
renderExpense ( ) ;
} ;
window . setExpenseView = function ( v ) { state . expenseView = v ; renderExpense ( ) ; } ;
window . setExpenseView = function ( v ) { state . expenseView = v ; renderExpense ( ) ; } ;
window . setExpenseFilter = function ( v ) { state . expenseFilter = v ; renderExpense ( ) ; } ;
window . setExpenseFilter = function ( v ) { state . expenseFilter = v ; renderExpense ( ) ; } ;
window . setExpenseMonth = function ( v ) { state . expenseMonth = v ; renderExpense ( ) ; } ;
window . setExpenseMonth = function ( v ) { state . expenseMonth = v ; renderExpense ( ) ; } ;
@@ -109,6 +149,7 @@ window.openExpenseModal = function() {
var form = document . querySelector ( "#expenseModal form" ) ;
var form = document . querySelector ( "#expenseModal form" ) ;
form . reset ( ) ;
form . reset ( ) ;
form . querySelector ( '[name="expense_id"]' ) . value = "" ;
form . querySelector ( '[name="expense_id"]' ) . value = "" ;
form . querySelector ( '[name="status"]' ) . value = "计入费用" ;
document . querySelector ( "#expenseModalTitle" ) . textContent = "新增费用" ;
document . querySelector ( "#expenseModalTitle" ) . textContent = "新增费用" ;
document . querySelector ( "#expenseDeleteBtn" ) . classList . add ( "hidden" ) ;
document . querySelector ( "#expenseDeleteBtn" ) . classList . add ( "hidden" ) ;
document . querySelector ( "#expenseModal" ) . classList . remove ( "hidden" ) ;
document . querySelector ( "#expenseModal" ) . classList . remove ( "hidden" ) ;
@@ -130,6 +171,7 @@ window.openExpenseEdit = function(id) {
setVal ( "amount" , r . amount ) ;
setVal ( "amount" , r . amount ) ;
setVal ( "incurred_amount" , r . incurred _amount ) ;
setVal ( "incurred_amount" , r . incurred _amount ) ;
setVal ( "notes" , r . notes ) ;
setVal ( "notes" , r . notes ) ;
setVal ( "status" , r . status || "计入费用" ) ;
document . querySelector ( "#expenseModalTitle" ) . textContent = "编辑费用" ;
document . querySelector ( "#expenseModalTitle" ) . textContent = "编辑费用" ;
document . querySelector ( "#expenseDeleteBtn" ) . classList . remove ( "hidden" ) ;
document . querySelector ( "#expenseDeleteBtn" ) . classList . remove ( "hidden" ) ;
document . querySelector ( "#expenseModal" ) . classList . remove ( "hidden" ) ;
document . querySelector ( "#expenseModal" ) . classList . remove ( "hidden" ) ;
@@ -139,9 +181,9 @@ window.saveExpense = async function(event) {
event . preventDefault ( ) ;
event . preventDefault ( ) ;
var form = event . target ;
var form = event . target ;
var data = Object . fromEntries ( new FormData ( form ) . entries ( ) ) ;
var data = Object . fromEntries ( new FormData ( form ) . entries ( ) ) ;
// 数字字段空值转 0, 避免线上 MySQL 严格模式报错
if ( data . amount === '' || data . amount === undefined ) data . amount = 0 ;
if ( data . amount === '' || data . amount === undefined ) data . amount = 0 ;
if ( data . incurred _amount === '' || data . incurred _amount === undefined ) data . incurred _amount = 0 ;
if ( data . incurred _amount === '' || data . incurred _amount === undefined ) data . incurred _amount = 0 ;
if ( ! data . status ) data . status = '计入费用' ;
var isEdit = ! ! data . expense _id ;
var isEdit = ! ! data . expense _id ;
delete data . expense _id ;
delete data . expense _id ;