function isPhoneLikeSchool(v){var s=String(v==null?'':v).trim();if(!s)return false;var d=s.replace(/[\s\-()()+\/、,,.]/g,'');return /^[0-9]{7,}$/.test(d);}const DEALER_TYPES={resource:{name:'资源渠道推广经销商',fee:0,baseRate:0.20,firstN:3,assessment:'年度有效合作成交 ≥10 家(=20%分润)',color:'t-resource',note:'合同期内享全款实收客户20%最高分润;前3家签约后7-10工作日发放,第4家起每月5号对账、次月15号发放。'},experience:{name:'体验官经销商',fee:0,baseRate:0.20,firstN:3,assessment:'12个月考核周期,年度 ≥5 家维持20%分润;连续2月无新增暂停权限',color:'t-experience',note:'无需缴10万使用费,靠转介绍获提成;每成功转介绍1家全款顺延1月自用,累计5家全年免费;分期订单不计分润。'},campus:{name:'校区自用推广经销商',fee:20000,baseRate:0.20,firstN:5,cashFrom:6,assessment:'年度有效成交 ≥5 家;两年累计<10家第三年起降至10%;连续2月无新增暂停权限',color:'t-campus',note:'缴2万保证金开通;前5家仅抵扣自用时长(无现金分润),第6家起发20%分润。'},official:{name:'官方事业经销商',fee:100000,baseRate:0.30,firstN:3,assessment:'年度有效成交 ≥10 家(=30%分润);两年累计<10家第三年起降至20%',color:'t-official',note:'首年享全款客户30%最高分润;第二年缴10万继续享30%。'},staff:{name:'公司员工',fee:0,baseRate:0.045,assessment:'当月业绩归属给本人的交款合计 ≥10万元(=4.5%分润)',color:'t-staff',note:'公司内部员工;当月业绩归属给本人的交款合计达到10万元起,按全款客户4.5%计分润;未达标当月不计分润。'},};const STAGES={reported:{label:'已报备',cls:'st-reported'},following:{label:'跟进中',cls:'st-following'},experienced:{label:'已体验',cls:'st-experienced'},signed_full:{label:'全款',cls:'st-signed_full'},signed_installment:{label:'分期',cls:'st-signed_installment'},refunded:{label:'已退费',cls:'st-refunded'},lost:{label:'保护过期流失',cls:'st-lost'},terminated:{label:'已终止',cls:'st-lost'},cooperated:{label:'已合作',cls:'st-experienced'},};const DEALER_STAGES={lead:{label:'跟进中',cls:'st-following'},signed_full:{label:'全款',cls:'st-signed_full'},signed_installment:{label:'分期',cls:'st-signed_installment'},refunded:{label:'已退费',cls:'st-refunded'},exhausted:{label:'已消耗终止合作',cls:'st-exhausted'},terminated:{label:'已终止',cls:'st-lost'},cooperated:{label:'已合作',cls:'st-experienced'},};function dealerStage(d){if(d&&d.type==='staff')return{label:'—',cls:'st-lost'};let st=(d&&d.stage)||((d&&d.contractSigned)?'signed_full':'lead');if(st==='signed')st='signed_full';return DEALER_STAGES[st]||DEALER_STAGES.lead;}function dealerStageHtml(d){const s=dealerStage(d);return '<span class="st '+s.cls+'">'+s.label+'</span>';}function dealerStageOpts(d){if(d&&d.type==='staff')return'<option value="">—(空)</option>';let cur=(d&&d.stage)||((d&&d.contractSigned)?'signed_full':'lead');if(cur==='signed')cur='signed_full';return Object.entries(DEALER_STAGES).map(([k,v])=>'<option value="'+k+'"'+(cur===k?' selected':'')+'>'+v.label+'</option>').join('');}const PROTECTION_DAYS=60;const BUILD_ID='v2026.09.02-dealercomm';const SERVICE_CATEGORIES={course:{name:'教学课程',color:'#4f46e5',ic:'📚'},product:{name:'配套产品',color:'#0891b2',ic:'📦'},service:{name:'增值服务',color:'#d97706',ic:'✨'},};const SERVICES=[{id:'vr_meta',cat:'course',name:'VR元宇宙大场景',spec:'48节 (600秒/节)',unit:'制作费3000元/秒',value:86400000},{id:'ar_dual',cat:'course',name:'书写能力AR双师课',spec:'336节/年 (30分/节)',unit:'500元/分',value:5040000},{id:'online_res',cat:'course',name:'线上教研',spec:'37节/年 · 352套课件',value:4980},{id:'online_aff',cat:'course',name:'线上教务',spec:'37节/年',value:4980},{id:'ai_review',cat:'course',name:'AI书写一对一点评',spec:'不限次/年',value:19800},{id:'account',cat:'product',name:'账号定位服务',spec:'仅限合作校区使用',value:5000},{id:'train_off',cat:'service',name:'XR技能师训',spec:'不限次',unit:'4980元/1人次',value:4980},{id:'train_on',cat:'service',name:'硬笔线上6.0双师师训',spec:'不限次',unit:'4980元/1人次',value:4980},{id:'xr_maint',cat:'service',name:'XR场景定位维护服务费',spec:'年度',value:10000},{id:'help_onsite',cat:'service',name:'到校帮扶',spec:'每次到校记录开始/结束时间与反馈表附件',value:0},{id:'mat_claim',cat:'product',name:'教材/材料领取',spec:'按次登记:领取时间 / 名称 / 数量 / 备注(可附附件)',value:2000},{id:'stu_lift',cat:'service',name:'学生周内书写能力提升课',spec:'全龄段账号一套 (含6至12岁)',value:7000},];const DELIVERY_STATUS={pending:{label:'待交付',cls:'st-following'},partial:{label:'部分交付',cls:'st-experienced'},done:{label:'已交付',cls:'st-signed_full'},n_a:{label:'不适用',cls:'st-lost'},};const PACKAGE_PRICE=100000;const DB_VERSION=3;const PERM_GROUPS=[{id:'overview',name:'总览看板'},{id:'dealer',name:'经销商管理'},{id:'customer',name:'转介绍客户'},{id:'follow',name:'跟进记录'},{id:'delivery',name:'总部交付'},{id:'help',name:'到校帮扶'},{id:'helpreport',name:'到校帮扶成果'},{id:'finance',name:'财务收款'},{id:'report',name:'报表与规则'},{id:'datapriv',name:'数据隐私'},{id:'export',name:'数据导出'},{id:'system',name:'系统管理'},{id:'dealer_portal',name:'经销商端'},];const PERMS=[{key:'view_dashboard',label:'查看数据仪表盘总览',group:'overview'},{key:'view_kpi_amount',label:'查看金额 / 分润类指标',group:'overview'},{key:'view_dealers',label:'查看经销商列表 / 详情',group:'dealer'},{key:'view_dealer_amount',label:'查看经销商预缴费用 / 应发分润',group:'dealer'},{key:'view_dealer_comm',label:'查看分润来源明细',group:'dealer'},{key:'export_dealer_comm',label:'导出分润来源明细',group:'dealer'},{key:'add_dealers',label:'新增经销商',group:'dealer'},{key:'edit_dealers',label:'编辑经销商资料',group:'dealer'},{key:'del_dealers',label:'删除经销商',group:'dealer'},{key:'view_customers',label:'查看转介绍客户',group:'customer'},{key:'add_customers',label:'报备新客户',group:'customer'},{key:'convert_dealer',label:'客户转为经销商',group:'customer'},{key:'edit_customers',label:'编辑客户资料',group:'customer'},{key:'del_customers',label:'删除客户',group:'customer'},{key:'assign_cust_ops',label:'客户批量操作(批量指派对接员工 / 服务老师 / 批量补信息)',group:'customer'},{key:'edit_cust_quota',label:'登记体验课名额消耗',group:'customer'},{key:'view_followups',label:'查看跟进记录',group:'follow'},{key:'follow_customers',label:'写跟进 / 设置提醒',group:'follow'},{key:'view_delivery',label:'查看总部交付服务',group:'delivery'},{key:'edit_delivery',label:'编辑交付状态 / 记录',group:'delivery'},{key:'edit_delivery_other',label:'代改 / 代删他人的交付记录',group:'delivery'},{key:'
upload_delivery',label:'上传交付附件(反馈表等)',group:'delivery'},{key:'del_delivery_items',label:'删除交付子记录(帮扶/师训/安装/开通)',group:'delivery'},{key:'export_delivery',label:'导出总部交付服务(矩阵 / 明细)',group:'delivery'},{key:'view_finance',label:'查看财务收款',group:'finance'},{key:'view_finance_amount',label:'查看金额 / 分润数值',group:'finance'},{key:'add_finance',label:'登记交款记录',group:'finance'},{key:'edit_finance',label:'修改合同金额',group:'finance'},{key:'refund_finance',label:'办理退费 / 标记退费',group:'finance'},{key:'del_refunds',label:'删除退费记录',group:'finance'},{key:'del_payments',label:'删除交款记录',group:'finance'},{key:'export_reconcile',label:'导出财务对账',group:'finance'},{key:'pay_commission',label:'标记分红发放',group:'finance'},{key:'edit_comm_payout',label:'修改分红发放记录',group:'finance'},{key:'del_comm_payout',label:'删除分红发放记录',group:'finance'},{key:'edit_comm_adj',label:'调整客户分润金额',group:'finance'},{key:'edit_comm_adj_staff',label:'调整员工服务分润金额',group:'finance'},{key:'edit_comm_override',label:'设置订单分润',group:'finance'},{key:'edit_comm_override_staff',label:'设置订单员工分润',group:'finance'},{key:'view_reports',label:'查看转化·成交报表',group:'report'},{key:'view_serviceexpire',label:'查看服务到期提醒',group:'report'},{key:'view_map',label:'查看客户地图',group:'report'},{key:'view_region',label:'查看区域覆盖(省/市/区县成交覆盖)',group:'report'},{key:'view_rules',label:'查看业务规则红线',group:'report'},{key:'view_phone',label:'查看手机号(脱敏隐藏)',group:'datapriv'},{key:'view_address',label:'查看详细地址(省/市/区/门牌)',group:'datapriv'},{key:'edit_address',label:'编辑详细地址(省/市/区/门牌)',group:'datapriv'},{key:'export_data',label:'导出模块数据 / 全量数据',group:'export'},{key:'manage_users',label:'用户账号管理(增/改/停用)',group:'system'},{key:'edit_users',label:'编辑用户账号(改名 / 改角色 / 改密码)',group:'system'},{key:'del_users',label:'删除用户账号',group:'system'},{key:'del_depts',label:'删除部门',group:'system'},{key:'manage_roles',label:'角色与权限配置',group:'system'},{key:'del_roles',label:'删除角色',group:'system'},{key:'sys_reset',label:'清空 / 重置示例数据',group:'system'},{key:'view_todos',label:'查看待办事项',group:'system'},{key:'manage_reminders',label:'设置消息提醒',group:'system'},{key:'dp_dashboard',label:'经销商·我的看板',group:'dealer_portal'},{key:'dp_report',label:'经销商·报备客户',group:'dealer_portal'},{key:'dp_customers',label:'经销商·我的客户',group:'dealer_portal'},{key:'dp_followups',label:'经销商·我的跟进',group:'dealer_portal'},{key:'dp_finance',label:'经销商·我的财务',group:'dealer_portal'},{key:'view_helpon',label:'查看到校帮扶记录',group:'help'},{key:'edit_helpon',label:'编辑到校帮扶(添加/修改/扫码上传)',group:'help'},{key:'upload_helpon',label:'上传到校帮扶附件(拍照/相册/文件)',group:'help'},{key:'del_helpon',label:'删除到校帮扶记录',group:'help'},{key:'view_help_report',label:'查看到校帮扶成果报表(分客户/项目/次)',group:'helpreport'},{key:'edit_help_report',label:'编辑/修正到校帮扶成果识别结果',group:'helpreport'},{key:'export_help_report',label:'导出到校帮扶数据(分客户 CSV / 按次明细)',group:'helpreport'},{key:'view_selfcheck',label:'运行系统自检(🩺)',group:'system'},{key:'view_audit',label:'查看操作日志(📜)',group:'system'},{key:'view_items',label:'查看项目目录(价目表 / 年课包)',group:'system'},{key:'manage_items',label:'管理项目目录(修改 / 添加 / 隐藏)',group:'system'},{key:'dp_benefits',label:'经销商·我的权益',group:'dealer_portal'},{key:'dp_wechat_upload',label:'经销商·上传微信聊天记录',group:'dealer_portal'},{key:'dp_customer_edit',label:'经销商·修改客户信息',group:'dealer_portal'},{key:'dp_baobei',label:'经销商·查看指派报备客户',group:'dealer_portal'},{key:'export_ar_report',label:'导出 AI 报表数据(问数结果 CSV)',group:'report'},{key:'ai_report',label:'AI 生成报表(自然语言生成任意口径)',group:'report'},{key:'view_recycle',label:'查看回收站(已删客户 / 经销商快照)',group:'datapriv'},{key:'view_attachment',label:'查看附件(帮扶照片 / 交付反馈 / 聊天截图)',group:'datapriv'},];const OPERATOR_DEFAULT={view_map:false,view_recycle:false,view_attachment:false,view_region:false,edit_address:false,edit_users:false,view_dashboard:true,view_kpi_amount:true,view_dealers:true,view_dealer_amount:true,view_dealer_comm:false,export_dealer_comm:true,add_dealers:true,edit_dealers:true,del_dealers:false,view_customers:true,add_customers:true,convert_dealer:true,edit_customers:true,del_customers:false,assign_cust_ops:false,view_followups:true,follow_customers:true,view_delivery:true,edit_delivery:true,upload_delivery:true,del_delivery_items:false,export_delivery:false,edit_delivery_other:false,view_finance:true,view_finance_amount:true,add_finance:true,edit_finance:true,refund_finance:false,del_payments:false,del_refunds:false,export_reconcile:true,pay_commission:false,edit_comm_payout:false,del_comm_payout:false,view_reports:true,view_serviceexpire:true,view_rules:false,view_phone:true,view_address:true,export_data:false,manage_users:false,del_users:false,del_depts:false,manage_roles:false,del_roles:false,sys_reset:false,view_todos:true,manage_reminders:false,view_helpon:true,edit_helpon:true,upload_helpon:true,del_helpon:false,view_help_report:true,edit_help_report:true,export_help_report:false,view_selfcheck:true,view_audit:true,export_ar_report:false,ai_report:false,edit_comm_adj:false,edit_comm_adj_staff:false,edit_comm_override:false,edit_comm_override_staff:false,view_items:false,manage_items:false,edit_cust_quota:false};const DEALER_PORTAL_DEFAULT={dp_dashboard:true,dp_report:true,dp_customers:true,dp_followups:true,dp_finance:true,dp_benefits:true,dp_wechat_upload:true,dp_customer_edit:true,dp_baobei:false};
const PAGE_PERMS=[{"id":"dashboard","name":"数据仪表盘","icon":"📊","view":["view_dashboard"],"export":[],"edit":[],"delete":[]},{"id":"amountkpi","name":"数据仪表盘·金额指标","icon":"💰","view":["view_kpi_amount"],"export":[],"edit":[],"delete":[]},{"id":"dealers","name":"经销商管理","icon":"🏷️","view":["view_dealers"],"export":[],"edit":["edit_dealers"],"delete":["del_dealers"]},{"id":"amountdealer","name":"经销商·金额与分润","icon":"💵","view":["view_dealer_amount"],"export":[],"edit":[],"delete":[]},{"id":"dealeradd","name":"新增经销商","icon":"➕","view":[],"export":[],"edit":["add_dealers"],"delete":[]},{"id":"dealercomm","name":"分润来源明细","icon":"💸","view":["view_dealer_comm"],"export":["export_dealer_comm"],"edit":["edit_comm_adj"],"delete":[]},{"id":"dealercommstaff","name":"员工服务分润调整","icon":"👥","view":[],"export":[],"edit":["edit_comm_adj_staff"],"delete":[]},{"id":"commover","name":"订单分润设定","icon":"💰","view":[],"export":[],"edit":["edit_comm_override"],"delete":[]},{"id":"commoverstaff","name":"订单员工分润设定","icon":"👥","view":[],"export":[],"edit":["edit_comm_override_staff"],"delete":[]},{"id":"customers","name":"转介绍客户","icon":"🤝","view":["view_customers"],"export":[],"edit":["edit_customers"],"delete":["del_customers"]},{"id":"custadd","name":"报备新客户","icon":"🆕","view":[],"export":[],"edit":["add_customers"],"delete":[]},{"id":"custconv","name":"客户转为经销商","icon":"🔄","view":[],"export":[],"edit":["convert_dealer"],"delete":[]},{"id":"custops","name":"客户批量操作","icon":"☑️","view":[],"export":[],"edit":["assign_cust_ops"],"delete":[]},{"id":"custquota","name":"体验课名额","icon":"🎟","view":[],"export":[],"edit":["edit_cust_quota"],"delete":[]},{"id":"datapriv","name":"数据隐私(手机号 / 详细地址)","icon":"🔒","view":["view_phone"],"export":[],"edit":["edit_address"],"delete":[]},{"id":"addrview","name":"查看详细地址","icon":"🏠","view":["view_address"],"export":[],"edit":[],"delete":[]},{"id":"followups","name":"跟进记录","icon":"📝","view":["view_followups"],"export":[],"edit":["follow_customers"],"delete":[]},{"id":"delivery","name":"总部交付服务","icon":"📦","view":["view_delivery"],"export":["export_delivery"],"edit":["edit_delivery"],"delete":["del_delivery_items"]},{"id":"delivupload","name":"上传交付附件","icon":"📎","view":[],"export":[],"edit":["upload_delivery"],"delete":[]},{"id":"delivother","name":"代改他人交付记录","icon":"🔐","view":[],"export":[],"edit":["edit_delivery_other"],"delete":[]},{"id":"helpon","name":"到校帮扶","icon":"📷","view":["view_helpon"],"export":[],"edit":["edit_helpon"],"delete":["del_helpon"]},{"id":"helpupload","name":"上传到校帮扶附件","icon":"📷","view":[],"export":[],"edit":["upload_helpon"],"delete":[]},{"id":"helpreport","name":"到校帮扶成果","icon":"📈","view":["view_help_report"],"export":[],"edit":["edit_help_report"],"delete":[]},{"id":"hrvisit","name":"到校帮扶数据导出","icon":"🕒","view":[],"export":["export_help_report"],"edit":[],"delete":[]},{"id":"finance","name":"财务收款","icon":"💰","view":["view_finance"],"export":["export_reconcile"],"edit":["edit_finance"],"delete":["del_payments"]},{"id":"amountfinance","name":"财务·合同金额与交款","icon":"🧮","view":["view_finance_amount"],"export":[],"edit":[],"delete":[]},{"id":"finadd","name":"登记交款记录","icon":"💳","view":[],"export":[],"edit":["add_finance"],"delete":[]},{"id":"finrefund","name":"办理退费 / 标记退费","icon":"↩","view":[],"export":[],"edit":["refund_finance"],"delete":[]},{"id":"finpay","name":"标记分红发放","icon":"💵","view":[],"export":[],"edit":["pay_commission"],"delete":[]},{"id":"findel","name":"删除退费记录","icon":"🗑","view":[],"export":[],"edit":[],"delete":["del_refunds"]},{"id":"commpayout","name":"分红发放记录","icon":"🧾","view":[],"export":[],"edit":["edit_comm_payout"],"delete":["del_comm_payout"]},{"id":"reports","name":"转化·成交报表","icon":"📊","view":["view_reports"],"export":[],"edit":[],"delete":[]},{"id":"rules","name":"业务规则红线","icon":"⚖️","view":["view_rules"],"export":[],"edit":[],"delete":[]},{"id":"serviceexpire","name":"服务到期提醒","icon":"📅","view":["view_serviceexpire"],"export":[],"edit":[],"delete":[]},{"id":"map","name":"客户地图","icon":"🗺️","view":["view_map"],"export":[],"edit":[],"delete":[]},{"id":"region","name":"区域覆盖","icon":"📍","view":["view_region"],"export":[],"edit":[],"delete":[]},{"id":"todos","name":"待办事项","icon":"📋","view":["view_todos"],"export":[],"edit":[],"delete":[]},{"id":"users","name":"用户与权限","icon":"👤","view":["manage_users"],"export":[],"edit":["edit_users"],"delete":["del_users"]},{"id":"deptdel","name":"删除部门","icon":"🏢","view":[],"export":[],"edit":[],"delete":["del_depts"]},{"id":"reminders","name":"消息提醒","icon":"🔔","view":["manage_reminders"],"export":[],"edit":[],"delete":[]},{"id":"perms","name":"角色权限设置","icon":"🔐","view":["manage_roles"],"export":[],"edit":[],"delete":["del_roles"]},{"id":"items","name":"项目目录","icon":"🗂️","view":["view_items"],"export":[],"edit":["manage_items"],"delete":[]},{"id":"selfcheck","name":"系统自检与数据维护","icon":"🩺","view":["view_selfcheck"],"export":[],"edit":["sys_reset"],"delete":[]},{"id":"audit","name":"操作日志","icon":"📜","view":["view_audit"],"export":[],"edit":[],"delete":[]},{"id":"export","name":"数据导出","icon":"⬇️","view":[],"export":["export_data"],"edit":[],"delete":[]},{"id":"arexport","name":"AI 报表数据导出","icon":"📥","view":[],"export":["export_ar_report"],"edit":[],"delete":[]},{"id":"aireport","name":"AI 报表","icon":"🤖","view":["ai_report"],"export":[],"edit":[],"delete":[]},{"id":"recycle","name":"回收站(已删数据)","icon":"🗑","view":["view_recycle"],"export":[],"edit":[],"delete":[]},{"id":"attachview","name":"附件查看","icon":"📎","view":["view_attachment"],"export":[],"edit":[],"delete":[]}];
const DEALER_PAGE_PERMS=[{"id":"dp_dashboard","name":"我的看板","icon":"📊","view":["dp_dashboard"],"export":[],"edit":[],"delete":[]},{"id":"dp_report","name":"报备客户","icon":"➕","view":["dp_report"],"export":[],"edit":[],"delete":[]},{"id":"dp_customers","name":"我的客户","icon":"🤝","view":["dp_customers"],"export":[],"edit":["dp_customer_edit"],"delete":[]},{"id":"dp_followups","name":"我的跟进","icon":"📝","view":["dp_followups"],"export":[],"edit":["dp_wechat_upload"],"delete":[]},{"id":"dp_finance","name":"我的财务","icon":"💰","view":["dp_finance"],"export":[],"edit":[],"delete":[]},{"id":"dp_benefits","name":"我的权益","icon":"🎁","view":["dp_benefits"],"export":[],"edit":[],"delete":[]},{"id":"dp_baobei","name":"指派报备客户","icon":"📥","view":["dp_baobei"],"export":[],"edit":[],"delete":[]}];function canDealer(key){if(mode!=='dealer')return true;const rp=(DB&&DB.rolePerms&&DB.rolePerms.dealer)||DEALER_PORTAL_DEFAULT;return rp[key]!==false;}function defaultUsers(){return[{id:uid('u_'),username:'admin',password:'admin888',role:'admin',name:'系统管理员',phone:'',active:true},{id:uid('u_'),username:'operator',password:'op888',role:'operator',name:'运营操作员',phone:'',active:true},];}
let DB=null;function migrate(d){d=d||{};d.dealers=Array.isArray(d.dealers)?d.dealers:[];d.customers=Array.isArray(d.customers)?d.customers:[];d.deliveries=Array.isArray(d.deliveries)?d.deliveries:[];d.deliveries.forEach(x=>{if(x&&x.online&&!Array.isArray(x.online))x.online=[x.online];if(Array.isArray(x.helps)){x.helps.forEach(function(h){if(h){(h.attachments||[]).forEach(function(a){if(a&&!a.ocr){a.ocr={provider:'',raw:'',fields:{},status:'empty',updatedAt:'',updatedBy:''};}});/* ★ 2026-09-18 第 55 轮:老记录的识别结果兜底**不再**拿会话时刻填 start/end ——
那会让「从没识别过」看起来像「识别到了建档时刻」,且与 cols 真值打架。空就是空。 */
if(!h.recognized){h.recognized={school:'',principal:'',phone:'',students:0,teachers:0,start:'',end:'',accompany:'',transport:'',fee:0,hotel:0,sessions:[{label:'邀约到校',count:0,renew:0,rate:0},{label:'第一场',count:0,renew:0,rate:0},{label:'第二场',count:0,renew:0,rate:0}],satisfactions:{like:'',recognize:'',happy:''},suggestion:'',signDate:'',status:'empty',updatedAt:'',updatedBy:''};}}});}});d.dealers.forEach(x=>{if(!Array.isArray(x.followups))x.followups=[];x.amount=Number(x.amount)||0;if(!Array.isArray(x.payments))x.payments=[];x.nextPayAt=x.nextPayAt||'';x.fromCustomerId=x.fromCustomerId||'';x.contractSigned=!!x.contractSigned;x.stage=(x.type==='staff')?'':(x.stage||(x.contractSigned?'signed_full':'lead'));if(x.stage==='signed')x.stage='signed_full';x.prov=x.prov||'';x.city=x.city||'';x.district=x.district||'';x.addr=x.addr||'';x.userId=x.userId||'';});d.customers.forEach(c=>{if(!Array.isArray(c.followups))c.followups=[];c.remindAt=c.remindAt||'';c.reportDate=c.reportDate||'';c.stage=c.stage||'reported';c.amount=Number(c.amount)||0;c.installment=!!c.installment;c.refunded=!!c.refunded;c.signDate=c.signDate||'';if(!Array.isArray(c.payments))c.payments=[];c.convertedDealerId=c.convertedDealerId||'';c.fromCustomerId=c.fromCustomerId||'';c.intent=Array.isArray(c.intent)?c.intent:(c.intent?[c.intent]:[]);c.contractSigned=!!c.contractSigned;c.prov=c.prov||'';c.city=c.city||'';c.district=c.district||'';c.addr=c.addr||'';c.nextPayAt=c.nextPayAt||'';});d.users=Array.isArray(d.users)?d.users:[];if(!d.users.length)d.users=defaultUsers();d.users.forEach(u=>{if(u)u.dealerId=u.dealerId||'';});d.rolePerms=(d.rolePerms&&typeof d.rolePerms==='object')?d.rolePerms:{};if(!d.rolePerms.operator)d.rolePerms.operator={...OPERATOR_DEFAULT};if(!d.rolePerms.dealer)d.rolePerms.dealer={};Object.keys(DEALER_PORTAL_DEFAULT).forEach(k=>{if(d.rolePerms.dealer[k]===undefined)d.rolePerms.dealer[k]=DEALER_PORTAL_DEFAULT[k];});if(d.rolePerms.dealer.fields===undefined)d.rolePerms.dealer.fields={};Object.keys(d.rolePerms.dealer).forEach(k=>{if(k!=='fields'&&!(k in DEALER_PORTAL_DEFAULT))delete d.rolePerms.dealer[k];});d.roleMeta=(d.roleMeta&&typeof d.roleMeta==='object')?d.roleMeta:{};if(!d.roleMeta.operator)d.roleMeta.operator={name:'操作员'};Object.keys(d.rolePerms).forEach(k=>{if(k==='dealer')return;/* ★ 2026-09-18 第 57 轮 P3:经销商自助角色走独立 dp_* 键集(服务端净化只保留白名单内 8 键),**不得**套 migrateRolePerms —— 它无条件补出管理端键 export_data:false,会让每次保存都比出「未声明→false」,在审计里刷出与业务无关的「权限变更」噪音(实测 152/162 条)。 */d.rolePerms[k]=migrateRolePerms(d.rolePerms[k]);});if(d.rolePerms.operator){Object.keys(OPERATOR_DEFAULT).forEach(k=>{if(OPERATOR_DEFAULT[k]===true&&d.rolePerms.operator[k]===undefined)d.rolePerms.operator[k]=true;});}return d;}
function migrateRolePerms(rp){rp=rp||{};const map={add_dealers:'edit_dealers',add_customers:'edit_customers',add_finance:'edit_finance',convert_dealer:'add_dealers',view_dealer_amount:'view_dealers',view_finance_amount:'view_finance',view_kpi_amount:'view_dashboard',upload_delivery:'edit_delivery',refund_finance:'edit_customers',view_address:'view_dealers',manage_roles:'manage_users'};Object.entries(map).forEach(([fine,coarse])=>{if(rp[fine]===undefined&&rp[coarse]!==undefined)rp[fine]=rp[coarse];});if(rp.export_data===undefined)rp.export_data=false;if(rp.export_reconcile===undefined)rp.export_reconcile=(rp.edit_finance||rp.add_finance)?true:undefined;if(rp.view_delivery!==undefined&&rp.view_helpon===undefined)rp.view_helpon=rp.view_delivery;if(rp.edit_delivery!==undefined&&rp.edit_helpon===undefined)rp.edit_helpon=rp.edit_delivery;if(rp.upload_delivery!==undefined&&rp.upload_helpon===undefined)rp.upload_helpon=rp.upload_delivery;if(rp.view_helpon!==undefined&&rp.view_help_report===undefined)rp.view_help_report=rp.view_helpon;if(rp.view_reports!==undefined&&rp.view_serviceexpire===undefined)rp.view_serviceexpire=rp.view_reports;if(rp.edit_helpon!==undefined&&rp.edit_help_report===undefined)rp.edit_help_report=rp.edit_helpon;if(rp.manage_users===true){if(rp.view_selfcheck===undefined)rp.view_selfcheck=true;if(rp.view_audit===undefined)rp.view_audit=true;}return rp}
function load(
){return{dealers:[],customers:[],deliveries:[],users:defaultUsers(),rolePerms:{operator:{...OPERATOR_DEFAULT}},roleMeta:{operator:{name:'操作员'}},version:DB_VERSION};}
let _mem={};function lsGet(k){try{return localStorage.getItem(k);}catch(e){return(k in _mem)?_mem[k]:null;}}
function lsSet(k,v){try{localStorage.setItem(k,v);}catch(e){_mem[k]=v;}}
function lsDel(k){try{localStorage.removeItem(k);}catch(e){delete _mem[k];}}
/* ★ 2026-09-18 第 66 轮 D4 Ⓐ:保存链基线 + 差量重放。 背景:POST /api/db 是整库覆盖式,而 protectDeletes 首行 `if (isAdmin(payload)) return null;`(admin 豁免) + 前端 409 分支原实现「采信服务端 rev 后整库重试」+ restorePendSave 刻意跳过 rev ⇒ admin 挂起页面期间别人新增的**裸记录**(无交付/无交款/无签约的客户、经销商)会在下次保存时被静默删除。 做法:loadDB 时存基线快照;服务端一旦报 409(说明本地确实落后),就把「本地相对基线的差量」 重放到服务端**最新**快照上再提交 —— 他人新增得以保留、自己修改照旧生效、真实删除意图仍被尊重。 dealer 端快照被裁剪,不具备基线条件 ⇒ 不建立。 */var __DB_ARRS=['customers','dealers','deliveries','users','departments','items','packages'];/* 第 398 轮:增量保存(实体级差量)—— 保存基线 + 差量构建。基线 __saveBase = 最近一次与服务端同步成功时的库快照(仅管理端建立;dealer 不建立)。__buildPatch 对本地库 vs 基线按 id 三路求差(增/删/改);灰度 __PSAVE_GRAY 外的数组一旦有改动即返回 null,整次保存退回整库提交(防漏带)。 */
var __PSAVE_KEYS=['rolePerms','roleMeta','reminders','reminderSettings','notify','version','regionRadius','helpExtraKeys','navHidden'];
var __PSAVE_GRAY=['customers','items','packages'];
function __saveSnap(d){try{if(!d){window.__saveBase=null;return;}if(mode==='dealer'){window.__saveBase=null;return;}var o={};__DB_ARRS.forEach(function(k){o[k]=JSON.parse(JSON.stringify(Array.isArray(d[k])?d[k]:[]));});__PSAVE_KEYS.forEach(function(k){o[k]=JSON.parse(JSON.stringify(d[k]===undefined?null:d[k]));});window.__saveBase=o;}catch(_){window.__saveBase=null;}}
function __buildPatch(local,base){var out={arrays:{},keys:{}},i,j;for(i=0;i<__DB_ARRS.length;i++){var k=__DB_ARRS[i],L=local[k],B=Array.isArray(base[k])?base[k]:[];if(__PSAVE_GRAY.indexOf(k)<0){if(JSON.stringify(Array.isArray(L)?L:[])!==JSON.stringify(B))return null;continue;}if(!Array.isArray(L))continue;var bi={},li={};B.forEach(function(x){if(x&&x.id!==undefined)bi[String(x.id)]=x;});L.forEach(function(x){if(x&&x.id!==undefined)li[String(x.id)]=x;});var del=[],mod=[],add=[];Object.keys(bi).forEach(function(id){if(!(id in li))del.push(id);});L.forEach(function(x){if(!x||x.id===undefined)return;var id=String(x.id);if(!(id in bi)){add.push(x);return;}if(JSON.stringify(bi[id])!==JSON.stringify(x))mod.push([id,x]);});if(del.length||mod.length||add.length){out.arrays[k]={del:del,mod:mod,add:add};}}for(j=0;j<__PSAVE_KEYS.length;j++){var kk=__PSAVE_KEYS[j];var lv=(local[kk]===undefined)?null:local[kk];var bv=(base[kk]===undefined)?null:base[kk];if(JSON.stringify(lv)!==JSON.stringify(bv)){out.keys[kk]=lv;}}return out;}function __dbSnap(d){try{if(!d){window.__dbBase=null;return;}if(mode==='dealer'){window.__dbBase=null;return;}var o={};__DB_ARRS.forEach(function(k){o[k]=JSON.parse(JSON.stringify(Array.isArray(d[k])?d[k]:[]));});window.__dbBase=o;}catch(_){window.__dbBase=null;}}function __replayDiff(latest,local,base){ var out=Object.assign({},local); __DB_ARRS.forEach(function(k){ var Sv=latest?latest[k]:null, L=local[k]; if(!Array.isArray(Sv))return; /* ★ 无基线 ⇒ 无法辨别「本地缺的元素」是删除还是快照过期 ⇒ 保守取服务端最新,绝不误删(显式守卫,不靠绕路实现) */ if(!base){out[k]=Sv.slice();return;} if(!Array.isArray(L))return; var B=base?(base[k]||[]):[]; var bi={},li={}; B.forEach(function(x){if(x){if(x.id!==undefined)bi[String(x.id)]=x;}}); L.forEach(function(x){if(x){if(x.id!==undefined)li[String(x.id)]=x;}}); var del=[],mod=[],add=[]; Object.keys(bi).forEach(function(id){if(!(id in li))del.push(id);}); L.forEach(function(x){ if(!x)return; if(x.id===undefined)return; var id=String(x.id); if(!(id in bi)){add.push(x);return;} if(JSON.stringify(bi[id])!==JSON.stringify(x))mod.push([id,x]); }); var res=Sv.slice(); if(del.length){res=res.filter(function(x){if(!x)return true;return del.indexOf(String(x.id))===-1;});} var at={}; res.forEach(function(x,i){if(x){if(x.id!==undefined)at[String(x.id)]=i;}}); mod.forEach(function(p){if(at[p[0]]!==undefined)res[at[p[0]]]=p[1];}); add.forEach(function(x){if(at[String(x.id)]===undefined)res.push(x);}); out[k]=res; }); return out;}let _saveChain=Promise.resolve();let _savePending=0;let _saveQueued=0;var _saveHardFail=false;function save(obj){if(obj)return _doSave(obj).catch(()=>false);_saveQueued++;const p=_saveChain.then(()=>_doSave()).catch(()=>false);_saveChain=p.then(()=>undefined,()=>undefined);const _dec=()=>{_saveQueued=Math.max(0,_saveQueued-1);};p.then(_dec,_dec);return p;}function _doSave(obj){var __d4N=0;const isOverride=!!obj;if(_saveHardFail){toast('上次保存未成功:为防止重复写入,已暂停保存;请刷新页面或点「恢复保存」后重试');return Promise.resolve(false);}/* ★ 2026-09-17:一次性标志 —— 本次保存先不做帮扶附件识别(手机端保存免被 10~40 秒识别阻塞)。不设即零行为变化。 */const _deferHO=(!isOverride&&window.__deferHelpOcr)?1:0;if(!isOverride&&!DB)return Promise.resolve(false);lastSaveTs=Date.now();_savePending=(_savePending||0)+1;const done=()=>{_savePending=Math.max(0,(_savePending||0)-1);};const tryOnce=(__ff)=>{let data=isOverride?obj:DB;let __up=false,__pg=null;if(!__ff&&!isOverride&&mode!=='dealer'&&!svcTeacherViewOnly()&&window.__saveBase){__pg=__buildPatch(DB,window.__saveBase);if(__pg&&JSON.stringify(__pg).length<=65536)__up=true;else __pg=null;}if(_deferHO){if(__up)__pg.skipHelpOcr=true;else data=Object.assign({},data,{skipHelpOcr:true});}return apiPost('/api/db',__up?{rev:(typeof DB.rev==='number'?DB.rev:0),__patch:__pg}:data).then(r=>{if(r&&typeof r.rev==='number')DB.rev=r.rev;if(r&&r.fieldDenied){toast('已拦截 '+r.fieldDenied+' 处无权限的字段修改(服务端已回滚)');}if(r&&r.permDenied){window.__permDeniedFlag=1;toast('无权限:本次提交的权限变更已被服务端忽略(需「角色与权限配置」权限)');}if(r&&r.droppedDelivery){toast('本次有 '+r.droppedDelivery+' 条交付记录未保存:本角色无「编辑交付状态 / 记录」权限,服务端已回滚');}done();if(_saveQueued>1)return true;if(!isOverride&&r&&r.serverChanged===false){try{window.__attToken=r.attToken||window.__attToken;}catch(_){}__dbSnap(DB);__saveSnap(DB);return true;}return loadDB().then(()=>true).catch(()=>true);}).catch(e=>{if(__up&&!(e&&e.status===409)){return tryOnce(true);}if(e&&e.status===409){/* ★ 第 66 轮 D4 Ⓐ:原实现「采信服务端 rev 后整库重试」会绕过乐观锁 —— 把陈旧整库当作最新提交,静默删掉期间他人新增的裸记录。改为:回读服务端最新 ⇒ 把本地相对基线的差量重放到最新快照 ⇒ 用最新 rev 提交(重放上限 2 次)。 */__d4N++;if(__d4N>2){done();try{loadDB().then(function(){try{render();}catch(_){}});}catch(_){}toast(saveFailMsg()+'(数据持续被他人更新,已刷新为最新,请重新操作本次修改)');return false;}var __tgt=isOverride?obj:DB;if(mode==='dealer'){done();try{loadDB();}catch(_){}toast(saveFailMsg()+'(本机数据已落后,已刷新为最新,请重新操作本次修改)');return false;}if(!window.__dbBase){done();try{loadDB();}catch(_){}toast(saveFailMsg()+'(本机数据已落后,已刷新为最新,请重新操作本次修改)');return false;}return apiGet('/api/db').then(function(srv){var mg=__replayDiff(srv,__tgt,window.__dbBase);__DB_ARRS.forEach(function(k){if(mg){if(mg[k]!==undefined)__tgt[k]=mg[k];}});if(typeof srv.rev==='number')__tgt.rev=srv.rev;window.__dbBase=null;window.__saveBase=null;return tryOnce();}).catch(function(){done();return false;});}done();return Promise.reject(e);}).catch(e=>{try{var _ps=window.__saveStep||'?';window.__saveStep=(_ps.slice(0,7)==='failed@')?_ps:('failed@'+_ps);}catch(_){};try{window.__lastSaveErr=e;}catch(_){};try{console.error('[CRM save]',window.__saveStep,e&&(e.message||e));}catch(_){}try{_saveHardFail=true;}catch(_){};/* ★ 2026-09-18 第 62 轮:被服务端「拒绝」(403)与「其它故障(网络 / 401 登录失效 / 5xx)」必须分流 —— 拒绝类绝不能再走「整库暂存 → 稍后重放」:① 它确定不会成功(本机快照与服务端记录集不一致 ⇒ 被判删除),重放只会无限 403;② 暂存的是一份过期整库快照,一旦该角色日后被授予删除权限,重放会把期间别人的新增真删掉。正确做法:丢弃暂存 → 回读服务端真值(消除「本机少记录 ⇒ 永久 403」死锁)→ 明确告知需重做。 */if(e&&e.status===403){try{localStorage.removeItem('crm_pendSave');}catch(_){}done();try{loadDB().then(function(){try{render();}catch(_){}}).catch(function(){});}catch(_){}toast(saveFailMsg()+'(本次修改未生效:本机数据已落后于服务端或本角色无此权限,已自动刷新为最新数据,请重新操作)');return false;}/* ★ 2026-09-18 第 62 轮补正:401(登录失效)**不得**并入上面这条 —— 它不是「拒绝」:本机改动本身有效,只是没身份;丢弃暂存会让用户刷新重登后丢掉正在编辑的内容(原行为=保留)。故把 401 单独摘出,仍走下面「暂存 + 提示」常规路径(恢复既有行为)。*/var _noAuth=(e&&e.status===401)?1:0;try{if(mode==='admin')localStorage.setItem('crm_pendSave',JSON.stringify({data:data,ts:Date.now()}));}catch(_){};toast(_noAuth?'登录已失效,本次修改未保存到服务器(已暂存本机):请重新登录后点「恢复保存」':(saveFailMsg()+'(数据已暂存本机,稍后自动提示恢复)'));done();return false;});};return tryOnce();}
/* ★ 第 643 轮(主人 2026-10-27「修」):不再把内部调试标记(window.__saveStep,如 failed@? / unhandled)拼进用户文案 ——
第 642 轮实测用户会看到「保存失败[failed@?]:Failed to fetch」。调试信息仍由 _doSave 的 console.error('[CRM save]', …) 保留。 */
function saveFailMsg(){var e=window.__lastSaveErr;var m='未知错误';if(e){if(typeof e==='string')m=e;else if(e.status)m='HTTP '+e.status+(e.message?' · '+e.message:'');else if(e.message)m=e.message;else m=String(e);}return '保存失败:'+m;}
var _pendBarAt=0;function _nativeDialogBusy(){try{if(!window.__sysDialogBusy)return false;var t=window.__sysDialogAt||0;if(!t||(Date.now()-t)>180000){window.__sysDialogBusy=0;return false;}return true;}catch(_){return false;}}function restorePendSave(){try{var ps=localStorage.getItem('crm_pendSave');if(!ps)return false;var o=JSON.parse(ps);if(!o||!o.data||!o.ts)return false;Object.keys(o.data).forEach(function(k){DB[k]=o.data[k];});/* ★ 第 66 轮 D4:rev 一并恢复(原刻意跳过 ⇒ 提交时 rev 不落后 ⇒ 陈旧整库被静默写入、他人新增被删) */window.__saveStep='pend-restore';_saveHardFail=false;save().then(function(ok){if(ok){try{localStorage.removeItem('crm_pendSave');}catch(_){}toast('未同步的修改已恢复保存 ✅');}else{toast('恢复保存未成功,本机暂存已按服务端最新状态处理,请重新操作');}});return true;}catch(_){return false;}}function discardPendSave(){try{localStorage.removeItem('crm_pendSave');}catch(_){}toast('已丢弃未同步的暂存修改');}function showPendBar(mins){try{var old=document.getElementById('pendRestoreBar');if(old&&old.parentNode)old.parentNode.removeChild(old);var d=document.createElement('div');d.id='pendRestoreBar';d.style.cssText='position:fixed;left:50%;transform:translateX(-50%);bottom:20px;z-index:130;max-width:92vw;display:flex;align-items:center;gap:10px;background:#0f172a;color:#fff;padding:11px 14px;border-radius:12px;box-shadow:0 12px 32px rgba(2,6,23,.42);font-size:13px';var t=document.createElement('span');t.textContent='⚠️ 检测到 '+mins+' 分钟前有一次修改未保存到服务器';var b1=document.createElement('button');b1.className='btn sm';b1.textContent='恢复保存';b1.onclick=function(){try{d.parentNode.removeChild(d);}catch(_){}restorePendSave();};var b2=document.createElement('button');b2.className='btn ghost sm';b2.style.color='#e2e8f0';b2.style.borderColor='rgba(226,232,240,.35)';b2.textContent='丢弃';b2.onclick=function(){try{d.parentNode.removeChild(d);}catch(_){}discardPendSave();};var xb=document.createElement('span');xb.textContent='✕';xb.style.cssText='cursor:pointer;opacity:.65;padding:0 3px;font-size:15px';xb.onclick=function(){try{d.parentNode.removeChild(d);}catch(_){}};d.appendChild(t);d.appendChild(b1);d.appendChild(b2);d.appendChild(xb);document.body.appendChild(d);}catch(_){}}window.addEventListener('focus',function(){setTimeout(function(){try{window.__sysDialogBusy=0;}catch(_){}},400);});function checkPendSave(){try{if(mode!=='admin')return;if(_nativeDialogBusy())return;var ps=localStorage.getItem('crm_pendSave');if(!ps||!DB)return;var o=JSON.parse(ps);if(!o||!o.data||!o.ts)return;if(_pendBarAt&&Date.now()-_pendBarAt<600000)return;if(document.querySelector('.modal-bg.show'))return;var mins=Math.max(1,Math.round((Date.now()-o.ts)/60000));_pendBarAt=Date.now();showPendBar(mins);}catch(_){}}
setTimeout(checkPendSave,8000);setInterval(checkPendSave,60000);window.addEventListener('unhandledrejection',function(ev){try{console.error('[CRM unhandled]',ev.reason);var r=ev.reason;if(r&&(r.status||r.message)){window.__lastSaveErr=r;window.__saveStep='unhandled';}}catch(_){}});
function uid(p){return p+Math.random().toString(36).slice(2,8)+Date.now().toString(36).slice(-3);}
function dealerSignedFull(dealerId,atDate){var _qk={};return DB.customers.filter(c=>{const _h=(c&&c.ownerHist&&c.ownerHist.length)?(function(a,t){t=String(t||'').slice(0,10);let r=a[0];for(let i=0;i<a.length;i++){if(String(a[i].at||'').slice(0,10)<=t)r=a[i];else break;}return r;})(c.ownerHist,atDate):null;return (_h?String(_h.dealerId||''):String(c.dealerId||''))===dealerId&&(c.stage==='signed_full')&&!c.refunded;}).sort((a,b)=>new Date(a.signDate||a.reportDate)-new Date(b.signDate||b.reportDate)).filter(c=>{var k=custGroupKeyOf(c);if(_qk[k])return false;_qk[k]=1;return true;});}
function payOwnerStaffIdOf(c,p){if(!c)return '';const f=p&&p.pStaffId?DB.dealers.find(x=>x.id===p.pStaffId):null;if(f&&f.type==='staff'&&f.userId)return f.id;return payStaffDealerIdOf(c,p&&p.date);}
function staffMonthPaidTotal(dealerId){const now=new Date();const ym=now.getFullYear()+'-'+String(now.getMonth()+1).padStart(2,'0');let total=0;DB.customers.forEach(c=>{if(c.refunded||c.stage==='terminated'||c.installment)return;(c.payments||[]).forEach(p=>{if(p&&p.date&&String(p.date).indexOf(ym)===0&&payOwnerStaffIdOf(c,p)===dealerId)total+=Number(p.amount||0);});});return total;}
function dealerYears(d){if(!d||!d.signDate)return 0;return Math.floor((Date.now()-new Date(d.signDate).getTime())/31557600000);}
function dealerPaidTotal(d){return(d.payments||[]).reduce((s,p)=>s+Number(p.amount||0),0);}
function commissionRateFor(dealer,customer){if(!dealer)return 0;if(customer.installment)return 0;if(!customer.contractSigned)return 0;const _co=customer.commOverride;if(_co&&_co.mode==='rate'&&Number(_co.value)>0)return Number(_co.value);const cfg=DT(dealer.type);if(dealer.type==='campus'){const signed=dealerSignedFull(dealer.id,customer.signDate);const idx=signed.findIndex(c=>custGroupKeyOf(c)===custGroupKeyOf(customer));if(idx>=0&&idx>=cfg.cashFrom-1){return dealerYears(dealer)>=2&&signed.length<10?0.10:cfg.baseRate;}return 0;}
if(dealer.type==='staff'){return staffMonthlyPaid(dealer.id,payClearMonth(customer))>=100000?cfg.baseRate:0;}
if(dealer.type==='official'){if(dealerYears(dealer)>=2&&dealerSignedFull(dealer.id,customer.signDate).length<10)return 0.20;if(dealerYears(dealer)>=1&&dealerPaidTotal(dealer)<200000)return 0.20;return cfg.baseRate;}
return cfg.baseRate;}
function customerCommission(customer){const d=DB.dealers.find(x=>x.id===customer.dealerId);if(!d)return 0;if(customer.stage!=='signed_full'||customer.refunded)return 0;const rate=commissionRateFor(d,customer);return Math.round((customer.amount||0)*rate);}
/* 客户级分润(交款级业绩归属口径,2026-09-20 第 99 轮):Σ 各归属方分润;非全款 / 已退费 / 无档案经销商 → 回落整单公式。
rate = 有效比例(= 分润 / 合同额);算不出时回落客户档案经销商费率。
—— 全部交款未指定业绩归属时,与旧整单公式逐分一致(不误伤其它数据)。 */
function custCommissionOf(c){const d=DB.dealers.find(x=>x.id===c.dealerId);const base=commissionRateFor(d,c);const rows=commDealerSplitOf(c);const amt=Number(c.amount||0);const comm=rows.length?rows.reduce((s,r)=>s+Number(r.amt||0),0):customerCommission(c);const auto=rows.length?rows.reduce((s,r)=>s+Number(r.auto!=null?r.auto:r.amt),0):comm;const rate=(amt&&auto)?auto/amt:base;return{comm,rate};}
function payClearMonth(c){const ps=(c.payments||[]).filter(p=>p&&p.date).sort((a,b)=>String(a.date).localeCompare(String(b.date)));if(!ps.length)return String(c.signDate||'').slice(0,7);let acc=0;let last='';let found='';for(let i=0;i<ps.length;i++){acc+=Number(ps[i].amount||0);last=String(ps[i].date).slice(0,7);if(c.amount&&acc>=Number(c.amount)){found=last;break;}}return found||last;}
function staffMonthlyPaid(did,ym){let total=0;DB.customers.forEach(c=>{if(c.refunded||c.stage==='terminated'||c.installment)return;(c.payments||[]).forEach(p=>{if(p&&p.date&&String(p.date).indexOf(ym)===0&&payOwnerStaffIdOf(c,p)===did)total+=Number(p.amount||0);});});return total;}
/* 交款级业绩归属拆分(2026-09-20 第 99 轮 · 用户拍板「方案B」):
客户 c 的经销商侧应发分润,按**每笔交款的业绩归属经销商**(pDealerId 优先 → 缺省回落客户档案 dealerId)拆分。
规则一字不动:费率仍取 commissionRateFor(该笔归属经销商, 客户)(类型 / 校区前 N 家 / official 年限 / staff 月门槛全部沿用既有判据)。
缺省(全部笔归属 = 客户档案)时 Σround(笔金额×费率) 与旧 round(合同额×费率) 逐分一致(全库实测 0 尾差)。
返回 [{ownerId,amt,n,rate}];非全款成交 / 已退费 → []。 */
function commDealerSplitOf(c){if(!c||c.stage!=='signed_full'||c.refunded)return[];const base=DB.dealers.find(x=>x.id===c.dealerId);if(!base)return[];const ps=(c.payments||[]).filter(p=>p&&p.date);const m={};const at=k=>{if(!m[k])m[k]={ownerId:k,amt:0,n:0,rate:0};return m[k];};if(!ps.length){const r0=commissionRateFor(base,c);if(r0){const e0=at(c.dealerId);e0.rate=r0;e0.amt=Math.round((c.amount||0)*r0);}}else{ps.forEach(p=>{const fd=p.pDealerId?DB.dealers.find(x=>x.id===p.pDealerId):null;const _h=(c&&c.ownerHist&&c.ownerHist.length)?(function(a,t){t=String(t||'').slice(0,10);let r=a[0];for(let i=0;i<a.length;i++){if(String(a[i].at||'').slice(0,10)<=t)r=a[i];else break;}return r;})(c.ownerHist,p.date):null;const od=fd||(_h&&_h.dealerId?DB.dealers.find(x=>x.id===_h.dealerId):null)||base;const rt=commissionRateFor(od,c);const e=at(od.id);e.rate=rt;e.amt+=Math.round(Number(p.amount||0)*rt);e.n+=1;});}const arr=Object.keys(m).map(k=>m[k]);const _coF=(c.commOverride&&c.commOverride.mode==='fixed')?Math.round(Number(c.commOverride.value)||0):0;if(_coF>0&&!c.installment&&c.contractSigned){const _tot=arr.reduce((s2,r)=>s2+r.amt,0);if(arr.length<=1){const e=arr.length?arr[0]:at(c.dealerId);if(!arr.length)arr.push(e);e.rate=0;e.amt=_coF;}else if(_tot>0){let _acc=0;arr.forEach(r=>{r._sv=Math.round(_coF*r.amt/_tot);_acc+=r._sv;});let _bi=0,_bm=-1;arr.forEach((r,i)=>{if(r._sv>_bm){_bm=r._sv;_bi=i;}});arr[_bi]._sv+=_coF-_acc;arr.forEach(r=>{r.rate=0;r.amt=r._sv;delete r._sv;});}else{const _f=arr.find(r2=>r2.ownerId===c.dealerId);if(_f){_f.rate=0;_f.amt=_coF;}else{const _e=at(c.dealerId);_e.rate=0;_e.amt=_coF;arr.push(_e);}}}arr.forEach(r=>{r.auto=r.amt;r.adj=0;});const _ca=c.commAdj;if(_ca&&_ca.amts&&typeof _ca.amts==='object'){let _placed=0;Object.keys(_ca.amts).forEach(function(k){let r0=arr.find(function(x){return x.ownerId===k;});if(!r0){r0=at(k);r0.rate=0;r0.auto=0;r0.adj=0;r0.amt=0;arr.push(r0);}});arr.forEach(r=>{const _v=Math.round(Number(_ca.amts[r.ownerId]||0)||0);r.adj=_v;_placed+=_v;});let _tot2=0;Object.keys(_ca.amts).forEach(k=>{_tot2+=Math.round(Number(_ca.amts[k]||0)||0);});const _orph=_tot2-_placed;if(_orph!==0){let e=arr.find(r=>r.ownerId===c.dealerId);if(!e){e=at(c.dealerId);e.rate=commissionRateFor(base,c);e.auto=0;e.adj=0;e.amt=0;arr.push(e);}e.adj+=_orph;}arr.forEach(r=>{r.amt=r.auto+r.adj;});}else{const adj2=Math.round(Number((_ca&&_ca.amt)||0)||0);if(adj2){let tot=arr.reduce((s,r)=>s+r.auto,0);if(!(tot>0)){let e=arr.find(r=>r.ownerId===c.dealerId);if(!e){e=at(c.dealerId);e.rate=commissionRateFor(base,c);e.auto=0;e.adj=0;e.amt=0;arr.push(e);}e.adj=adj2;e.amt=adj2;}else{let acc=0;arr.forEach(r=>{const ad=Math.round(adj2*r.auto/tot);r.adj=ad;acc+=ad;});let bi=0,bm=-1;arr.forEach((r,i)=>{const v=r.auto+r.adj;if(v>bm){bm=v;bi=i;}});arr[bi].adj+=adj2-acc;arr.forEach(r=>{r.amt=r.auto+r.adj;});}}}return arr;}
/* 经销商侧分润归属汇总表(交款级):{经销商id: 应发分润}。按 DB.rev 缓存(库没变不重算,与项目既有缓存同款姿势)。 */
function commDealerOwnTbl(){const rv=(DB&&DB.rev)||0;if(commDealerOwnTbl._t&&commDealerOwnTbl._r===rv&&commDealerOwnTbl._d===DB)return commDealerOwnTbl._t;const m={};(DB.customers||[]).forEach(c=>{if(!c||c.stage!=='signed_full'||c.refunded)return;commDealerSplitOf(c).forEach(r=>{m[r.ownerId]=(m[r.ownerId]||0)+r.amt;});});commDealerOwnTbl._t=m;commDealerOwnTbl._r=rv;commDealerOwnTbl._d=DB;return m;}
/* 员工「服务客户」分润(交款级业绩归属员工):费率 / 月 10 万门槛判据一字不动,
只把「谁算这笔」从客户档案 serviceStaffId 改为**每笔交款的 pStaffId**(缺省仍回落服务人员 → 与旧口径逐分一致)。 */
/* ★ 第 468 轮(主人选定方案 A):本单「业绩归属员工」固定额 —— 该员工在本单应得的固定份额。
口径:F 按「各归属员工名下计入交款额」摊;尾差补给份额最大者(并列取 id 字典序最小 ⇒ 全单唯一,
每个员工都能独立算出同一份分配表 ⇒ Σ 恒等于 F)。只有一位归属员工 ⇒ 全额 F。
10 万考核线与全部前置闸门(分期 / 未签 / 退费 / 结转客户 / 员工自营排除)一律照旧 —— 只换「算多少」,不换「算不算」。
返回 0 = 本单不适用固定额 / 未达考核线 / 本员工不在分配内。 */
function staffFixShareOf(c,d){
if(!c||!d||d.type!=='staff')return 0;
const _os=c.commOverrideStaff;
if(!_os||_os.mode!=='fixed')return 0;
const F=Math.round(Number(_os.value)||0);
if(!(F>0))return 0;
if(!(c.stage==='signed_full'&&c.contractSigned&&!c.installment&&!c.refunded))return 0;
if(staffMonthlyPaid(d.id,payClearMonth(c))<100000)return 0;
const ps=(c.payments||[]).filter(p=>p&&p.date);
if(!ps.length)return (c.serviceStaffId===d.id)?F:0;
const m={};
ps.forEach(p=>{
const e=payOwnerStaffIdOf(c,p);
if(!e)return;
const _pd=(p&&p.pDealerId&&DB.dealers.find(x=>x.id===p.pDealerId))?p.pDealerId:c.dealerId;
if(_pd===e)return;
m[e]=Math.round((m[e]||0)+Number(p.amount||0));
});
const ids=Object.keys(m).sort();
if(!ids.length)return 0;
if(m[d.id]===undefined)return 0;
const sw=ids.reduce((s2,k)=>s2+m[k],0);
if(!(sw>0))return 0;
let _acc=0,_bi=ids[0],_bw=-1;
ids.forEach(function(k){_acc+=Math.round(F*m[k]/sw);if(m[k]>_bw){_bw=m[k];_bi=k;}});
let _v=Math.round(F*m[d.id]/sw);
if(d.id===_bi)_v+=F-_acc;
return _v>0?_v:0;
}
function staffServiceCommission(d){if(!d||d.type!=='staff')return 0;const cfg=DT(d.type);const _cf=d.commCarryFrom||{};let s=0;DB.customers.forEach(c=>{const _rawD=(c&&c.dealerId)||'';if(c&&c.ownerHist&&c.ownerHist.length){const _a=c.ownerHist;const _t=String(c.signDate||'').slice(0,10);let _r=_a[0];for(let _i=0;_i<_a.length;_i++){if(String(_a[_i].at||'').slice(0,10)<=_t)_r=_a[_i];else break;}c=Object.assign({},c,{dealerId:(_r.dealerId||c.dealerId),serviceStaffId:(_r.staffId||c.serviceStaffId),ownerHist:undefined});}if(!c||_rawD===d.id)return;/*第259轮·整户自营排除按「当前档案归属经销商」判(不按签约日回看 ownerHist)——与自检 #215 复算器同款;旧写法把签约日投影当自营判据,会把登记归属已换家的客户整户跳过*/if(Number(_cf[c.id]||0)>0)return;if(c.stage!=='signed_full'||!c.contractSigned||c.installment||c.refunded)return;const ps=(c.payments||[]).filter(p=>p&&p.date);const _stfOk=id=>{const x=id?DB.dealers.find(y=>y.id===id):null;return !!(x&&x.type==='staff'&&x.userId);};const owned=ps.some(p=>_stfOk(p&&p.pStaffId))?ps.some(p=>payOwnerStaffIdOf(c,p)===d.id):(c.serviceStaffId===d.id);const _caA=(c.commAdjStaff&&c.commAdjStaff.amts&&typeof c.commAdjStaff.amts==='object')?c.commAdjStaff.amts:null;if(!owned&&!ps.some(p=>p.pStaffId===d.id)&&!(_caA&&Math.round(Number(_caA[d.id]||0)||0)!==0))return;const _sa=_caA?Math.round(Number(_caA[d.id]||0)||0):(owned?Math.round(Number((c.commAdjStaff&&c.commAdjStaff.amt)||0)||0):0);/* ★ 第 466 轮:本单「业绩归属员工」手设(按比例)—— 只换费率,10 万考核线不动 */const _scRateOv=(c&&c.commOverrideStaff&&c.commOverrideStaff.mode==='rate'&&Number(c.commOverrideStaff.value)>0)?Number(c.commOverrideStaff.value):cfg.baseRate;if(staffMonthlyPaid(d.id,payClearMonth(c))<100000){s+=_sa;return;}if(c&&c.commOverrideStaff&&c.commOverrideStaff.mode==='fixed'){s+=staffFixShareOf(c,d)+_sa;return;}if(!ps.length){s+=Math.round((c.amount||0)*_scRateOv)+_sa;return;}ps.forEach(p=>{/*业绩归属闸门·第186轮*/const _pd=(p&&p.pDealerId&&DB.dealers.find(x=>x.id===p.pDealerId))?p.pDealerId:c.dealerId;if(_pd===d.id)return;if(payOwnerStaffIdOf(c,p)===d.id)s+=Math.round(Number(p.amount||0)*_scRateOv);});s+=_sa;});return s;}
function assessInfoOf(d){if(d.type==='staff'){const m=staffMonthPaidTotal(d.id);return{cur:m,need:100000,ok:m>=100000,period:'当月',expired:false};}
const need=(d.type==='resource'||d.type==='official')?10:5;
if(!d.signDate)return{cur:0,need,ok:false,period:'未签约',expired:false};
const now=new Date();let start=new Date(d.signDate);let end=new Date(d.signDate);end.setFullYear(end.getFullYear()+1);
while(now>=end){start=new Date(end);end=new Date(end);end.setFullYear(end.getFullYear()+1);}
const sISO=start.toISOString().slice(0,10);const eISO=end.toISOString().slice(0,10);
const cs=DB.customers.filter(c=>c.dealerId===d.id&&c.stage==='signed_full'&&!c.refunded);
const cur=cs.filter(c=>{const dt=c.signDate?new Date(c.signDate):((c.payments&&c.payments[0]&&new Date(c.payments[0].date))||start);return dt>=start&&dt<end;}).length;
let paused=false;if(d.type==='experience'){const twoMonthsAgo=new Date(now.getFullYear(),now.getMonth()-2,1);const recent=cs.filter(c=>{const dt=c.signDate?new Date(c.signDate):new Date(c.reportDate||0);return dt>=twoMonthsAgo;}).length;if(new Date(d.signDate)<twoMonthsAgo&&recent===0)paused=true;}
return{cur,need,ok:cur>=need,period:sISO+' 至 '+eISO,expired:now>=end,paused};}
function assessHTMLOf(d){const a=assessInfoOf(d);
if(a.period==='未签约')return '<span class="st st-following">未签约</span>';
if(a.period==='当月')return a.ok?'<span class="st st-signed_full">达标 4.5%</span>':'<span class="st st-following">未达标(当月'+money(a.cur)+'/10万)</span>';
if(a.paused)return '<span class="st st-lost">⚠️ 连续2月无新增·转介绍暂停</span>';
if(!a.expired)return '<span class="st st-following">考核中 '+a.cur+'/'+a.need+'('+a.period+')</span>';
return a.ok?'<span class="st st-signed_full">达标 '+a.cur+'/'+a.need+'</span>':'<span class="st st-following">未达标 '+a.cur+'/'+a.need+'('+a.period+')</span>';}
function assessTextOf(d){const a=assessInfoOf(d);if(a.period==='未签约')return '未签约';if(a.period==='当月')return a.ok?'达标(当月交款≥10万)':'未达标(当月交款'+money(a.cur)+'/10万)';return (a.ok?'达标':'未达标')+'('+a.cur+'/'+a.need+','+a.period+')';}
/* ★ 第 252 轮(2026-09-29 主人拍板 A 方案):经销商列表「对接员工」列 —— 由客户档案自动汇总,不手工登记。
口径:归属该经销商(c.dealerId===d.id)的客户,取其档案 serviceStaffId(对接员工 = 员工类经销商),
去重后按经销商名册解析姓名;员工类经销商自身即员工 ⇒ 该列显示「—」。
纯显示,不参与分润 / 计佣 / 可见范围(与客户档案「对接员工」的计佣链无关)。
★ 第 254 轮(2026-09-29 主人拍板「对接员工看客户档案」):撤掉第 253 轮「自家签约交款业绩归属优先」,
口径回到「名下客户档案的对接员工汇总」;并剔除「已转成经销商的客户」(c.convertedDealerId 有值 ⇒
该客户已毕业成经销商,不再是本经销商的在跟客户,其当初的对接人不计入本列)。
交款业绩归属(p.pStaffId)只用于分润 / 业绩口径,与经销商列表这一列无关。
★ 第 255 轮(2026-09-29 主人拍板「取来源客户档案」):★ 口径更正 —— 本列取的是「该经销商自己那份客户档案」
(= 签约来源客户 d.fromCustomerId 指向的客户)的对接员工,即「这家经销商当初由哪位员工对接 / 带进来」;
不是它名下客户的档案(第 252 / 254 轮取错了对象)。来源客户档案上对接员工为空 ⇒ 显示「—」,
不回落名下客户汇总。全库 84 家非员工类经销商 100% 由客户签约转来。纯显示,不参与分润 / 计佣 / 可见范围。 */
function dealerStaffIdsOf(d){if(!d||d.type==='staff')return [];
var src=null;if(d.fromCustomerId)src=DB.customers.find(function(c){return c.id===d.fromCustomerId;});
var sid=src?src.serviceStaffId:'';if(!sid)return [];
var st=DB.dealers.find(function(x){return x.id===sid;});if(!st||st.type!=='staff')return [];
return [{id:sid,name:st.name||''}];}
function dealerStaffTextOf(d){return dealerStaffIdsOf(d).map(function(x){return x.name;}).join('、');}
function dealerStaffCellHtml(d){if(d&&d.type==='staff')return '<span class="mtxt">—</span>';var a=dealerStaffIdsOf(d);
if(!a.length)return '<span class="mtxt">—</span>';var full=a.map(function(x){return x.name;}).join('、');
var shown=a.length<=2?full:(a.slice(0,2).map(function(x){return x.name;}).join('、')+' 等 '+a.length+' 人');
return '<span title="'+esc('对接员工:'+full)+'">'+fTxt('d_name',shown)+'</span>';}
function dealerStats(dealerId){const cs=DB.customers.filter(c=>c.dealerId===dealerId);const reported=cs.length;const experienced=cs.filter(c=>['experienced','signed_full','signed_installment','cooperated'].includes(c.stage)).length;const signedFull=cs.filter(c=>c.stage==='signed_full'&&!c.refunded).length;const signedAll=cs.filter(c=>['signed_full','signed_installment','cooperated'].includes(c.stage)&&!c.refunded).length;const refunded=cs.filter(c=>c.refunded).length;const dd=DB.dealers.find(x=>x.id===dealerId);const commission=(commDealerOwnTbl()[dealerId]||0)+(dd&&dd.type==='staff'?staffServiceCommission(dd):0)+Number(((dd||{}).commCarry)||0);return{reported,experienced,signedFull,signedAll,refunded,commission,convRate:reported?experienced/reported*100:0,closeRate:reported?signedAll/reported*100:0,fullCloseRate:reported?signedFull/reported*100:0};}
function stageStatsOf(cs){const total=cs.length;const experienced=cs.filter(c=>['experienced','signed_full','signed_installment','cooperated'].includes(c.stage)).length;const signedAll=cs.filter(c=>['signed_full','signed_installment','cooperated'].includes(c.stage)&&!c.refunded).length;const signedFull=cs.filter(c=>c.stage==='signed_full'&&!c.refunded).length;return{reported:total,experienced,signedAll,signedFull,convRate:total?experienced/total*100:0,closeRate:total?signedAll/total*100:0,signConv:experienced?signedAll/experienced*100:0};}
/* ===== 客户口径统一(2026-09-13:用户要求「crm与看板口径保持一致」→ 以经营看板为准)=====
与 server.js 经营看板 ③④ 完全同源:①新增客户=signDate 且未退费未终止 且不含「员工自营校区」;
②成交客户=有员工归属(ownerUserOf 兜底链)+ 全款/分期 + 未退费终止 + 不含员工自营校区;③「已合作」不计入成交。
客户看板 board.html 保持自身口径(员工自营校区单列页签)。 */
const OPS_STAFF_OWN=[{campus:'沁水水墨书法',principal:'孔二丽'},{campus:'童园少儿美术',principal:'柳博'},{campus:'童园少儿美术',principal:'柳博2'}];
function isStaffOwnCampus(c){return !!c&&OPS_STAFF_OWN.some(function(x){return x.campus===(c.school||'')&&x.principal===(c.name||'');});}
function isRefundTerminated(c){return !!c&&!!(c.refunded||c.stage==='refunded'||c.stage==='terminated');}
/* 交付矩阵状态标签(2026-09-24 第 162 轮):已退费 / 已终止客户继续留在矩阵,但名字后面带红色标签。
口径与 isRefundTerminated 同源(服务到期报表 / 客户看板 / 经营看板三端共用);
经销商侧另含 exhausted(已消耗终止合作)。
只做视觉标注:不改变矩阵行集、不改变完成率口径、不动任何数据。 */
function coopDeadLabel(o,isCust){if(!o)return '';var st=String(o.stage||'');if(st==='refunded')return '已退费';if(st==='terminated')return '已终止';if(!isCust){if(st==='exhausted')return '已消耗终止合作';}if(o.refunded===true)return '已退费';return '';}
function coopDeadTag(o,isCust){var t=coopDeadLabel(o,isCust);if(!t)return '';return '<span class="st st-refunded" style="margin-left:6px;font-size:11px;padding:1px 7px;vertical-align:middle;white-space:nowrap">'+t+'</span>';}
function staffUserOfCust(c){var d=(c&&c.serviceStaffId)?DB.dealers.find(function(x){return x.id===c.serviceStaffId;}):null;return (d&&d.type==='staff'&&d.userId)?(DB.users.find(function(u){return u.id===d.userId;})||null):null;}
function ownerUserOfCust(c){var u=staffUserOfCust(c);if(u)return u;var d=(c&&c.dealerId)?DB.dealers.find(function(x){return x.id===c.dealerId;}):null;return (d&&d.type==='staff'&&d.userId)?(DB.users.find(function(x){return x.id===d.userId;})||null):null;}
function opsNewCusts(){return DB.customers.filter(function(c){return c&&c.signDate&&!isRefundTerminated(c)&&!isStaffOwnCampus(c);});}
function opsDealtCusts(){return DB.customers.filter(function(c){return c&&ownerUserOfCust(c)&&!isStaffOwnCampus(c)&&c.signDate&&(c.stage==='signed_full'||c.stage==='signed_installment')&&!isRefundTerminated(c);});}
function globalStats(){const cs=DB.customers;const reported=cs.length;const experienced=cs.filter(c=>['experienced','signed_full','signed_installment','cooperated'].includes(c.stage)).length;const _dealtO=opsDealtCusts();const signedAll=_dealtO.length;const signedFull=_dealtO.filter(c=>c.stage==='signed_full').length;const refunded=cs.filter(c=>c.refunded).length;const _ot=commDealerOwnTbl();const commission=Object.keys(_ot).reduce((s,k)=>s+Number(_ot[k]||0),0)+DB.dealers.filter(d=>d.type==='staff').reduce((s,d)=>s+staffServiceCommission(d),0);return{reported,experienced,signedAll,signedFull,refunded,commission,convRate:reported?experienced/reported*100:0,closeRate:reported?signedAll/reported*100:0,fullCloseRate:reported?signedFull/reported*100:0,signConv:experienced?signedAll/experienced*100:0,commissionPaid:DB.dealers.reduce((s,d)=>s+commissionPaid(d),0)};}
function monthlyTrend(){const now=new Date();const arr=[];for(let i=11;i>=0;i--){const d=new Date(now.getFullYear(),now.getMonth()-i,1);arr.push({ym:d.getFullYear()+'-'+String(d.getMonth()+1).padStart(2,'0'),dealers:0,customers:0});}DB.dealers.forEach(d=>{if(d.type==='staff'||d.fromCustomerId)return;if(!d.signDate)return;const ym=String(d.signDate).slice(0,7);const m=arr.find(x=>x.ym===ym);if(m)m.dealers++;});DB.customers.forEach(c=>{if(isRefundTerminated(c))return;if(!c.signDate)return;if(isStaffOwnCampus(c))return;const ym=String(c.signDate).slice(0,7);const m=arr.find(x=>x.ym===ym);if(m)m.customers++;});return arr;}
function isProtectionExpired(c){if(['signed_full','signed_installment','refunded','lost','terminated','cooperated'].includes(c.stage))return false;const d=(Date.now()-new Date(c.reportDate))/86400000;return d>PROTECTION_DAYS;}
function applyProtectionExpiry(){let n=0;DB.customers.forEach(c=>{if(isProtectionExpired(c)){c.stage='lost';n++;}});return n;}
/* 第 149 轮 · 关联档案机制:同一客户多项目/多校区建多份档案(同编号同手机号),家数按客户去重
(第 151 轮起前端组键与服务端 custGroupKey 统一为「向上追到链根」) */
/* 第 151 轮:与服务端同一判定 —— 无编号时向上追溯关联链根(≤5 跳)。此前前端只看直接父(L:父id),
与服务端不一致,导致「母档案尚未签约 + 成员档案已建」时客户详情「关联档案」双向看不见。
索引按 DB.rev + 档案数缓存,避免每次比较都重建。 */
var _cgIdxCache=null,_cgIdxSig='';
function _cgById(){var sig=((DB&&DB.rev)||0)+'/'+((DB&&DB.customers||[]).length);if(_cgIdxCache&&_cgIdxSig===sig)return _cgIdxCache;var m={};(DB&&DB.customers||[]).forEach(function(x){if(x&&x.id)m[x.id]=x;});_cgIdxCache=m;_cgIdxSig=sig;return m;}
function custGroupKeyOf(c){if(!c)return '';if(c.custNo)return 'N:'+c.custNo;var idx=_cgById();var g=c,gu=0;while(g&&g.linkedRootId&&idx[g.linkedRootId]&&gu++<5)g=idx[g.linkedRootId];if(g&&g.custNo)return 'N:'+g.custNo;return 'I:'+((g&&g.id)||c.id||'');}
function linkedCustList(c){if(!c)return[];const k=custGroupKeyOf(c);return (DB.customers||[]).filter(function(x){return x&&x.id!==c.id&&custGroupKeyOf(x)===k;});}
/* 第 509 轮 · 关联档案「校区关系」标记:single=同一校区(编辑时同步客户本体)/ multi=不同校区(各档案完全独立) */
function custScopeOf(c){return (c&&c.campusScope==='multi')?'multi':'single';}
function linkedScopeOf(c){if(!c)return 'single';var m=(c.campusScope==='multi');linkedCustList(c).forEach(function(x){if(x&&x.campusScope==='multi')m=true;});return m?'multi':'single';}
function itemNamesOf(c){if(!c||!Array.isArray(c.itemIds)||!c.itemIds.length)return '';return c.itemIds.map(function(id){const it=(DB.items||[]).find(function(x){return x&&x.id===id;});return (it?it.name:id)+((c.itemQty&&Number(c.itemQty[id])>1)?('×'+c.itemQty[id]):'');}).join(' + ');}
function orderSeqOf(c){if(!c)return{seq:1,total:1};const all=[c].concat(linkedCustList(c)).filter(function(x){return x&&x.id;});const sorted=all.slice().sort(function(a,b){const da=String(a.reportDate||''),dv2=String(b.reportDate||'');if(da!==dv2)return da<dv2?-1:1;return String(a.id)<String(b.id)?-1:1;});let i=-1;for(let k=0;k<sorted.length;k++){if(sorted[k].id===c.id){i=k;break;}}return{seq:(i>=0?i+1:1),total:sorted.length};}
function linkedCustHtml(c){const list=linkedCustList(c);const selfItems=orderItemsTextOf(c);const mySeq=orderSeqOf(c);const chips=list.map(function(x){const nm=esc(x.school||x.name||'(未命名)');const its=orderItemsTextOf(x);const seq=(mySeq.total>=2)?(' · 第'+orderSeqOf(x).seq+'单'):'';return '<span class="tag yes" style="cursor:pointer" onclick="openCustomer(\''+esc(x.id)+'\')">'+nm+(x.custNo?' · '+esc(x.custNo):'')+seq+(its?(' · '+esc(its)):'')+'</span>';}).join(' ');const selfLine=selfItems?('<div class="mtxt" style="margin-bottom:6px">📦 本单项目:<b>'+esc(selfItems)+'</b>'+(mySeq.total>=2?('(第 '+mySeq.seq+' 单 / 共 '+mySeq.total+' 单)'):'')+'</div>'):'';return selfLine+(chips||'<span class="mtxt">暂无</span>')+'<div class="mtxt" style="margin-top:6px">同一客户的多项目/多校区/多地渠道档案:客户编号自动同号;手机号建立时带出原档案,可按校区各自修改;家数按客户去重(仍算 1 家);合同、交款、到期、交付、跟进各档案独立。本按钮只建档案、不带金额与项目;要卖课收款,请回客户列表点「+ 新增订单」。</div>';}
function lkBuyModeGet(){var rs=document.querySelectorAll('input[name="lk_buy"]');for(var i=0;i<rs.length;i++){if(rs[i].checked)return rs[i].value;}return 'pkg';}
function lkPkgOf(){return (DB.packages||[]).find(function(x){return x&&x.id==='pkg_annual';});}
function lkPkgPrice(){var p=lkPkgOf();var v=p?Number(p.price):0;return (v>0)?v:100000;}
function lkPkgCount(){var p=lkPkgOf();return (p&&Array.isArray(p.itemIds))?p.itemIds.length:0;}
function lkPkgMetaTxt(){return lkPkgCount()+' 项服务 · ¥'+lkPkgPrice();}
function lkBuyMode(){var m=lkBuyModeGet();
/* ★ 第 639 轮(主人 2026-10-27「A」):切换购买类型 ⇒ 复位「本单分润设定」两栏。
原因:分润值是按「上一模式勾选的项目标准」自动带出的(lkStdSuggest),切模式后勾选集合已变,
残留值会原样落到新订单上(第 638 轮实测:勾项目带出 8% → 切「🎟 体验课」→ 体验课订单继承 8%)。
口径:只在「模式真的变了」时复位;首次打开弹窗不复位;有值才提示(空的不打扰)。 */
var _pm=lkBuyMode._pm;lkBuyMode._pm=m;if(_pm!==undefined){if(_pm!==m)lkResetComm();}
var box=document.getElementById('lk_item_box');if(box)box.style.display=(m==='custom')?'':'none';
/* ★ 第 637 轮(主人 2026-10-27「A · 对齐」):购买类型补第三档「体验课」,与「修改订单项目」三档对齐。 */
var exb=document.getElementById('lk_exp_box');if(exb)exb.style.display=(m==='exp')?'':'none';
var ae=document.getElementById('lk_amount');if(ae){ae.readOnly=(m==='exp');if(m==='pkg'){ae.value=String(lkPkgPrice());}else if(m==='exp'){lkExpCalc();}else{lkItemsRecalc();}}
var cb=document.getElementById('lk_co_box');if(cb)cb.style.display='';var cb2=document.getElementById('lk_cos_box');if(cb2)cb2.style.display='';}
function lkCoMode(){var m=(document.getElementById('lk_co_mode')||{}).value||'';var box=document.getElementById('lk_co_val_box');if(box)box.style.display=(m==='rate'||m==='fixed')?'':'none';}
function lkCosMode(){var m=(document.getElementById('lk_cos_mode')||{}).value||'';var box=document.getElementById('lk_cos_val_box');if(box)box.style.display=(m==='rate'||m==='fixed')?'':'none';}
function lkCommToggle(){var b=document.getElementById('lk_comm_body');if(!b)return;var open=(b.style.display==='none');b.style.display=open?'':'none';var ar=document.getElementById('lk_comm_arrow');if(ar)ar.textContent=open?'收起 ▴':'展开 ▾';}
function lkCommSum(){var out=[];function one(m,v){if(!m||!m.value)return '';if(m.value==='inherit')return '按现有规则';var n=Number(String((v&&v.value)||'').replace(/[^0-9.]/g,''))||0;if(m.value==='rate')return (n>0?n+'%':'未填比例');if(m.value==='fixed')return (n>0?'¥'+n:'未填金额');return '';}var a=one(document.getElementById('lk_co_mode'),document.getElementById('lk_co_value')),b=one(document.getElementById('lk_cos_mode'),document.getElementById('lk_cos_value'));if(a)out.push('经销商 '+a);if(b)out.push('员工 '+b);var el=document.getElementById('lk_comm_sum');if(el)el.textContent=out.length?out.join(' · '):'按现有规则';}
function lkItemsFilter(){const q=String(((document.getElementById('lk_item_q')||{}).value)||'').trim().toLowerCase();Array.prototype.forEach.call(document.querySelectorAll('#lk_item_list [data-lkrow]'),function(el){el.style.display=(!q||String(el.getAttribute('data-lkname')||'').toLowerCase().indexOf(q)>=0)?'':'none';});}
/* ★ 第 639 轮:复位「本单分润设定」两栏(切换购买类型时调用;也供将来手动复位复用)。
注意:本区可能处于 HTML 转义区 ⇒ 函数体内不写 &&、不写 <、不写 >。 */
function lkResetComm(){
var had=false;
var ids=['lk_co_mode','lk_co_value','lk_co_note','lk_cos_mode','lk_cos_value','lk_cos_note'];
ids.forEach(function(id){
var el=document.getElementById(id);
if(!el)return;
if(String(el.value||'')!=='')had=true;
el.value='';
el.removeAttribute('data-auto');
});
var vb=document.getElementById('lk_co_val_box');if(vb)vb.style.display='none';
var vb2=document.getElementById('lk_cos_val_box');if(vb2)vb2.style.display='none';
/* ★ 第 639b 轮(复检修复):建议提示行必须一起清 —— 否则值已空、提示还写着「按比例 8%」,界面自相矛盾。 */
var hb=document.getElementById('lk_std_hint');if(hb)hb.innerHTML='';
if(typeof lkCommSum==='function')lkCommSum();
if(had)toast('已切换购买类型:本单分润设定已复位(需要请重新选择)');
}
function lkItemsRecalc(){let total=0;Array.prototype.forEach.call(document.querySelectorAll('input[name="lk_item_chk"]:checked'),function(x){const id=String(x.value||'');const pr=Number(x.getAttribute('data-price')||0)||0;const qe=document.getElementById('lk_qty_'+id);const q=Math.floor(Number((qe&&qe.value)||1)||0);total+=pr*(q>0?q:1);});const ae=document.getElementById('lk_amount');if(ae)ae.value=String(Math.round(total*100)/100);lkStdSuggest();}
/* ★ 第 466 轮:按项目目录标准自动带出本单分润(经销商侧 / 员工侧各自独立判)。
规则:勾选项目【全部】设了标准且模式一致 ⇒ 自动填;缺标准 / 模式混用 ⇒ 不填,只出提示。
操作员手改过(值 ≠ 上次自动填的值)⇒ 不再覆盖。 */
function lkStdPut(el,val){
if(!el)return;
var cur=String(el.value||'').trim();
var prev=String(el.getAttribute('data-auto')||'');
if(cur===''||cur===prev){ el.value=val; el.setAttribute('data-auto',val); }
}
function lkStdSuggest(){
try{
var hint=document.getElementById('lk_std_hint');
if(!hint)return;
var chks=Array.prototype.slice.call(document.querySelectorAll('input[name="lk_item_chk"]:checked'));
if(!chks.length){ hint.innerHTML=''; return; }
function calc(which){
var all=true,mode=null,mixed=false,sw=0,sr=0,sm=0,miss=[];
chks.forEach(function(x){
var id=String(x.value||'');
var it=(DB.items||[]).find(function(y){return y&&y.id===id;});
if(!it){all=false;miss.push(id);return;}
var v=Number(which==='d'?it.commValue:it.staffCommValue)||0;
if(!(v>0)){all=false;miss.push(it.name||id);return;}
var m=(which==='d')?((it.commMode==='fixed')?'fixed':'rate'):((it.staffCommMode==='fixed')?'fixed':'rate');
if(mode===null)mode=m; else if(mode!==m)mixed=true;
var qe=document.getElementById('lk_qty_'+id);
var q=Math.floor(Number((qe&&qe.value)||1)||0); if(!(q>0))q=1;
var up=Number(it.unitPrice)||0;
var w=up>0?up*q:q;
sw+=w;
if(m==='rate')sr+=w*v; else sm+=v*q;
});
if(!all||mixed)return {ok:false,miss:miss,mixed:mixed};
if(mode==='rate')return {ok:true,mode:'rate',value:(sw>0?sr/sw:0)};
return {ok:true,mode:'fixed',value:sm};
}
var D=calc('d'), S=calc('s');
var lines=[];
if(D.ok){
var dm=document.getElementById('lk_co_mode');
if(dm){ dm.value=D.mode; if(typeof lkCoMode==='function')lkCoMode(); }
if(D.mode==='rate'){ lkStdPut(document.getElementById('lk_co_value'),String(Math.round(D.value*10000)/100)); lines.push('经销商侧:按比例 '+Math.round(D.value*1000)/10+'%(按项目标准加权)'); }
else { lkStdPut(document.getElementById('lk_co_value'),String(Math.round(D.value))); lines.push('经销商侧:固定 ¥'+Math.round(D.value)+'/单(项目标准合计)'); }
} else if(D.mixed){ lines.push('经销商侧:所选项目「比例 / 固定」混用,未自动带出,请手填'); }
else if(D.miss.length){ lines.push('经销商侧:以下项目未设标准,未自动带出 —— '+D.miss.join('、')); }
if(S.ok){
var _sm=document.getElementById('lk_cos_mode');
if(_sm)_sm.value=S.mode;
if(S.mode==='rate'){ lkStdPut(document.getElementById('lk_cos_value'),String(Math.round(S.value*10000)/100)); lines.push('员工侧:按比例 '+Math.round(S.value*1000)/10+'%(按项目标准加权)'); }
else { lkStdPut(document.getElementById('lk_cos_value'),String(Math.round(S.value))); lines.push('员工侧:固定 ¥'+Math.round(S.value)+'(项目标准合计)'); }
}
else if(S.miss&&S.miss.length){ lines.push('员工侧:以下项目未设标准,未自动带出 —— '+S.miss.join('、')); }
hint.innerHTML=lines.length?('📐 '+lines.join(';')+'。<b>可手改</b>;改后不再自动覆盖。'):'';
}catch(e){}
}
function readLinkedItems(){return Array.prototype.map.call(document.querySelectorAll('input[name="lk_item_chk"]:checked'),function(x){return String(x.value||'');}).filter(function(s){return s;});}
/* ★ 第 637 轮(主人 2026-10-27「A · 对齐」):新增订单的「🎟 体验课」档 —— 与「✏️ 修改订单项目」同口径。
为什么:第 627 轮把体验课独立成第三种订单类型,只改了「修改订单项目」;新增侧仍是两档,
体验课混在「单项 / 组合 / 其它」的项目列表里,且合同额可手改 ⇒ 名额按「金额 ÷ 单价」兜底派生,能凭空多出名额。
口径:档位候选只列未隐藏的按名额项目(与 expTierOptsOf 同源);金额 = 单价 × 份数,只读不可手改。 */
function lkExpTierOf(){var r=document.querySelector('input[name="lk_exp_tier"]:checked');return r?String(r.value||''):'';}
function lkExpQty(){var el=document.getElementById('lk_exp_qty');var n=Math.floor(Number((el&&el.value)||1)||0);return (n>0)?n:1;}
function lkExpCalc(){
var box=document.getElementById('lk_exp_sum');var ae=document.getElementById('lk_amount');
var id=lkExpTierOf();var n=lkExpQty();
if(!id){if(box)box.innerHTML='<span style="color:#b45309">请先选择一个体验课档位</span>';return;}
var it=(DB.items||[]).find(function(x){return x&&String(x.id)===String(id);});
var pr=Number((it&&it.unitPrice)||0)||0;
if(!(pr>0)){if(box)box.innerHTML='<span style="color:#b45309">该档位未设置单价,无法建单:请先在「项目目录」补单价</span>';if(ae)ae.value='0';return;}
var amt=Math.round(pr*n*100)/100;
if(ae)ae.value=String(amt);
if(box)box.innerHTML='合同额 = <b>¥'+amt+'</b>(单价 ¥'+pr+' × '+n+' 份 · 名额 '+n+' 个)<br><span class="mtxt">体验课不计成交、不发客户编号;按名额消耗。</span>';
}
function lkMixGuard(el){
/* ★ 第 462 轮(主人拍板「体验课不与其他课程混单」):勾选互斥硬拦 ——
勾中体验课(按名额项目)⇒ 其它课程置灰并自动取消已勾;反之亦然。服务端 enforceQuotaMix 兜底。 */
var chks=Array.prototype.slice.call(document.querySelectorAll('input[name="lk_item_chk"]'));
var isQ=function(x){return x.getAttribute('data-q')==='1';};
if(el&&el.checked){var hit=false;chks.forEach(function(x){if(x!==el&&x.checked&&(isQ(x)!==isQ(el))){x.checked=false;hit=true;}});if(hit)toast('体验课不与其他课程混单:已自动取消冲突项');}
/* ★ 第 501 轮(主人 2026-10-17「3.禁混选」):同档互斥 —— 一单只保留最后勾选的那个体验课档位(如 298 档 / 680 档) */
if(el&&el.checked){var hit2=false;chks.forEach(function(x){if(x!==el&&x.checked&&isQ(x)&&isQ(el)){x.checked=false;hit2=true;}});if(hit2)toast('一单只能选一种体验课档位:已自动取消另一个档位');}
var hasQ=false,hasN=false;chks.forEach(function(x){if(x.checked){if(isQ(x))hasQ=true;else hasN=true;}});
chks.forEach(function(x){var dis=(hasQ&&!isQ(x))||(hasN&&isQ(x));x.disabled=dis;var row=x.closest?x.closest('label[data-lkrow]'):null;if(row)row.style.opacity=dis?'0.4':'';});
lkItemsRecalc();
}
/* 第 507 轮:关联档案 / 新增订单的手机号改为可编辑(同一客户多校区可登记不同号码)。
取值:留空允许;填写必须 11 位;不得占用「其他客户组」的手机号(同组同号合法)。 */
function readLkPhone(p){
var el=document.getElementById('lk_phone');
var v=el?String(el.value||'').trim():'';
if(!v)return '';
if(!/^1\d{10}$/.test(v)){toast('手机号需为 11 位数字(可留空)');return false;}
var gk=custGroupKeyOf(p);
var hit=(DB.customers||[]).find(function(x){return x&&x.id&&x.id!==p.id&&x.phone===v&&custGroupKeyOf(x)!==gk;});
if(hit){toast('该手机号已属于其他客户「'+(hit.name||'')+'」,不能用于本档案');return false;}
return v;
}
function orderToLinkedCust(pid){closeModal();openLinkedCustForm(pid);}
var _lkSaving=false;
/* 第 666 轮 · 添加关联档案的地址读取/提示切换(顶层函数:必须在 openLinkedCustForm 之前,否则跨作用域取不到)
第 677 轮 · UI 优化:加地址摘要 / 关系卡片选中态 / 地址区展开收起。地址元素一律留在 DOM 里
(lkAddrNow 靠 getElementById 取值 —— 把元素删掉会读成空串,新档地址就被清空了)。 */
var _lkBaseAddr=''; /* 第 677 轮:打开弹窗时的父档地址基线,用于区分「沿用」与「已改」 */
function lkAddrNow(){var g=function(i){var e=document.getElementById(i);return e?String(e.value||'').trim():'';};return {prov:g('lk_prov'),city:g('lk_city'),district:g('lk_district'),addr:g('lk_addr')};}
function lkAddrBriefText(){var g=lkAddrNow();return [g.prov,g.city,g.district,g.addr].filter(Boolean).join(' ');}
function lkAddrPaint(){var b=document.getElementById('lk_addr_brief');if(!b)return;var r=document.querySelector('input[name="lk_scope"]:checked');var m=!!(r&&r.value==='multi');var cur=lkAddrBriefText();var tx;if(!cur)tx=(m?'本档地址:':'原档案')+(m?'未填(可点「修改」补上)':'暂无地址(可点「修改」补上)');else if(m||cur!==_lkBaseAddr)tx='本档地址(已改):'+cur;else tx='沿用原档案:'+cur;b.textContent=tx;b.style.display=lkAddrOpen()?'none':'';}
function lkAddrOpen(){var f=document.getElementById('lk_addr_full');return !!(f&&!f.classList.contains('lk-hide'));}
function lkHintPaint(){var h=document.getElementById('lk_addr_hint');if(!h)return;var r=document.querySelector('input[name="lk_scope"]:checked');var m=!!(r&&r.value==='multi');h.style.display=lkAddrOpen()?'':'none';h.textContent=m?'地址:不同校区通常地址不同,请填本校区地址(可留空,事后在客户详情补)。':'地址:同一校区默认沿用原档案(可修改);改了地址会让本档与父档不一致。';}
function lkAddrToggle(){var f=document.getElementById('lk_addr_full');if(!f)return;var op=lkAddrOpen();if(op)f.classList.add('lk-hide');else f.classList.remove('lk-hide');var t=document.getElementById('lk_addr_tg');if(t)t.textContent=op?'修改':'收起';lkHintPaint();lkAddrPaint();}
function lkScopePaint(){var as=document.querySelectorAll('#lk_scope_box label');for(var i=0;i<as.length;i++){var inp=as[i].querySelector('input');if(inp&&inp.checked)as[i].classList.add('lk-scope-on');else as[i].classList.remove('lk-scope-on');}}
function lkScopeSyncAddr(){var r=document.querySelector('input[name="lk_scope"]:checked');var m=!!(r&&r.value==='multi');lkScopePaint();if(m&&!lkAddrOpen()){var f=document.getElementById('lk_addr_full');if(f)f.classList.remove('lk-hide');var t=document.getElementById('lk_addr_tg');if(t)t.textContent='收起';}lkHintPaint();lkAddrPaint();}
function openLinkedCustForm(pid){_lkSaving=false;const p=DB.customers.find(function(x){return x.id===pid;});if(!p){toast('未找到原档案');return;}if(!can('edit_customers')){toast('无权限:需「编辑客户」权限');return;}
const _lkCur0=linkedScopeOf(p);
_lkBaseAddr=[p.prov||'',p.city||'',p.district||'',p.addr||''].filter(Boolean).join(' ');
const _phEdit=canField('phone','e');
openModal(`
<div class="modal-head"><h3>+ 添加关联档案 · ${esc(p.name)}</h3><span class="x" onclick="closeModal()">×</span></div>
<div class="modal-body lk-form">
<div class="lk-sum">
<span>编号 <b>${esc(p.custNo||'尚未编号 · 本组签约后自动同号')}</b></span>
<span class="lk-dot">·</span>
<span>家数仍算 <b>1 家</b></span>
<details class="lk-sum-fold"><summary>说明</summary><div class="lk-sum-body">📌 自动沿用客户编号与手机号(可按本校区修改,各校区可登记不同号码);统计客户家数按客户去重,同一客户的多份档案仍算 1 家;合同、交款、到期、总部交付、跟进各档案独立。<br><br>📌 本操作只建一条档案,不填金额与项目:用于多校区 / 多渠道 / 合作档。要卖课或收款,请回客户列表点「+ 新增订单」。</div></details>
</div>
<div class="row2">
<div class="field"><label>客户姓名 *</label><input id="lk_name" value="${esc(p.name)}"><div class="hint" style="margin-top:4px">区分校区请填下面的「学校 / 校区名称」</div></div>
<div class="field"><label>手机号(沿用,可按校区改)</label><input id="lk_phone" value="${esc(p.phone||'')}"${_phEdit?'':' readonly style="background:#f5f5f7"'} inputmode="tel" maxlength="11" placeholder="11 位手机号"></div>
</div>
<div class="lk-sec">
<div class="lk-sec-h">校区<span class="lk-sec-r">归属:${esc(dealerName(p.dealerId)||'—')}</span></div>
<div class="field"><label>学校 / 校区名称</label><input id="lk_school" value="${esc(p.school||'')}" placeholder="如:南浔绘智美术"></div>
<div id="lk_scope_box" class="lk-scope">
<label><input type="radio" name="lk_scope" value="single"${_lkCur0==='single'?' checked':''} onchange="lkScopeSyncAddr()"><span><span class="lk-s-t">同一校区</span><span class="lk-s-d">多项目 / 多次下单(编辑时同步经销商与地址)</span></span></label>
<label><input type="radio" name="lk_scope" value="multi"${_lkCur0==='multi'?' checked':''} onchange="lkScopeSyncAddr()"><span><span class="lk-s-t">不同校区</span><span class="lk-s-d">各档地址、手机号独立</span></span></label>
</div>
</div>
<div class="field">
<label>地址(选填)<span class="lk-addr-tg" id="lk_addr_tg" onclick="lkAddrToggle()">修改</span></label>
<div class="lk-addr-brief" id="lk_addr_brief"></div>
<div id="lk_addr_full" class="lk-hide">${areaSelectHTML('lk',p.prov||'',p.city||'',p.district||'',p.addr||'')}</div>
<div class="mtxt" id="lk_addr_hint">地址:同一校区默认沿用原档案(可修改)。</div>
</div>
<div class="field"><label>合作项目(选填)</label>${intentMultiHTML('lk_intent',(p.intent&&p.intent[0])||'')}</div>
</div>
<div class="modal-foot"><button class="btn ghost" onclick="closeModal()">取消</button><button class="btn" id="lk_submit" onclick="saveLinkedCustomer('${esc(p.id)}')">建立关联档案</button></div>`);
try{bindAreaSelect('lk');lkScopeSyncAddr();}catch(_lkA0){}
}
/* 第 656 轮:客户姓名清洗 —— 去掉首尾中点「·」与空白。旧版「添加关联档案 / 新增订单」弹窗把父档案名 + 「·」预填进去(label 还写「建议带项目/校区后缀」),操作员不补后缀直接提交就落成「郭卫康·」这种半成品;姓名是业务字段(报备「校长姓名」→ 客户姓名),区分校区 / 项目另有 school / intent / itemIds 字段。 */
function lkNameClean(v){var s=String(v==null?'':v);return s.replace(/^[\s\u00B7\u30FB\u2022]+/,'').replace(/[\s\u00B7\u30FB\u2022]+$/,'');}
function saveLinkedCustomer(pid){
if(_lkSaving){toast('正在建立,请稍候…');return;}
if(_saveHardFail){toast('上次保存未成功:为防止重复写入,已暂停保存;请刷新页面或点「恢复保存」后重试');return;}
_lkSaving=true;
var _lb=document.getElementById('lk_submit');if(_lb){_lb.disabled=true;_lb.textContent='建立中…';}
var _lr=function(){_lkSaving=false;var b=document.getElementById('lk_submit');if(b){b.disabled=false;b.textContent='建立关联档案';}};
try{return _lkSaveLinked(pid,_lr);}catch(e){_lr();throw e;}
}
function _lkSaveLinked(pid,_lr){if(isPhoneLikeSchool((document.getElementById('lk_school')||{}).value||'')){toast('「所属校区/机构」不能填电话号码,请改填校区/机构名称');_lr();return;}const p=DB.customers.find(function(x){return x.id===pid;});if(!p||!can('edit_customers')){toast('无权限');_lr();return;}
const name=lkNameClean(document.getElementById('lk_name').value);if(!name){toast('请填写客户姓名');_lr();return;}
const phone=canField('phone','e')?readLkPhone(p):(p.phone||'');if(phone===false){_lr();return;}
const school=document.getElementById('lk_school').value.trim();const intent=readIntentMulti('lk_intent');
const _lkScopeEl=document.querySelector('input[name="lk_scope"]:checked');const _lkScope=(_lkScopeEl&&_lkScopeEl.value==='multi')?'multi':'single';
const _lkAraw=lkAddrNow();const _lkAr=rcFixArea(_lkAraw.prov,_lkAraw.city,_lkAraw.district);if(_lkAr.bad&&_lkAr.bad.length){toast('「'+_lkAr.bad.join('、')+'」与所选省/市不一致,请检查地址');_lr();return;}const _lkScopeTmp=(document.querySelector('input[name="lk_scope"]:checked')||{}).value;if(_lkScopeTmp!=='multi'&&(_lkAraw.prov!==(p.prov||'')||_lkAraw.city!==(p.city||'')||_lkAraw.district!==(p.district||'')||_lkAraw.addr!==(p.addr||''))){if(!confirm('这条档案选的是「同一校区」,但地址与父档不一致:\n父档:'+[p.prov,p.city,p.district,p.addr].filter(Boolean).join('')+'\n本档:'+[_lkAraw.prov,_lkAraw.city,_lkAraw.district,_lkAraw.addr].filter(Boolean).join('')+'\n\n确定按本档地址保存?')){_lr();return;}}const nc={id:uid('c_'),name,phone,school,intent,dealerId:p.dealerId,serviceStaffId:p.serviceStaffId||'',reportDate:today(),prov:_lkAr.prov,city:_lkAr.city,district:_lkAr.district,addr:_lkAraw.addr,stage:'reported',amount:0,installment:false,refunded:false,signDate:'',contractSigned:false,followups:[],campusScope:_lkScope,campusOf:(_lkScope==='single'?custCampusRepOf(p):''),linkedRootId:(p.linkedRootId||p.id)};
DB.customers.push(nc);save().then(function(ok){if(!ok){var _ix=DB.customers.indexOf(nc);if(_ix!==-1)DB.customers.splice(_ix,1);_lr();return;}closeModal();render();_lr();toast('关联档案已建立'+(nc.custNo?'(编号 '+nc.custNo+')':'(尚未编号 · 本组签约后自动同号)'));});}
function custOrderUpgradable(c){if(!c)return false;
if(['reported','following','experienced'].indexOf(String(c.stage||''))<0)return false;
if((c.payments||[]).length)return false;
if((c.refunds||[]).length||c.refunded)return false;
if(c.contractSigned||c.signDate)return false;
if(Number(c.amount||0)>0)return false;
if((c.itemIds||[]).length||c.pkgId)return false;
if(c.convertedDealerId)return false;
if(c.pushedDelivery)return false;
if((DB.deliveries||[]).some(function(d){return d&&d.customerId===c.id;}))return false;
return true;}
function openNewOrder(pid){const p=DB.customers.find(function(x){return x.id===pid;});if(!p){toast('未找到原档案');return;}if(!can('edit_customers')){toast('无权限:需「编辑客户」权限');return;}
const _phEdit=canField('phone','e');
const _up=custOrderUpgradable(p);const _lkItems=(DB.items||[]).filter(function(x){return x&&x.hidden!==true;});
const _lkRows=_lkItems.map(function(x){const pr=Number(x.unitPrice||0)||0;return '<label data-lkrow data-lkname="'+esc(x.name||'')+'" style="display:flex;align-items:center;gap:8px;padding:4px 2px;border-bottom:1px dashed var(--line);flex-wrap:wrap"><input type="checkbox" name="lk_item_chk" value="'+esc(x.id)+'" data-price="'+pr+'" data-q="'+(x.trackQuota===true?'1':'0')+'" onchange="lkMixGuard(this)"><span style="flex:1 1 140px;min-width:120px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">'+esc(x.name||x.id)+'</span><span class="mtxt" style="flex-shrink:0;white-space:nowrap">'+(pr>0?('¥'+pr):'单价未设置')+'</span><input type="number" id="lk_qty_'+esc(x.id)+'" min="0" step="1" value="1" style="width:56px;flex-shrink:0;padding:5px 4px;text-align:center" oninput="lkItemsRecalc()"><span class="mtxt" style="flex-shrink:0;white-space:nowrap">×份</span></label>';}).join('');
const _lkTiers=((DB.items||[]).filter(function(x){return x&&x.trackQuota===true&&x.hidden!==true;})).map(function(x){const pr=Number(x.unitPrice||0)||0;return '<label style="display:flex;align-items:center;gap:8px;padding:8px 6px;border-bottom:1px dashed var(--line)"><input type="radio" name="lk_exp_tier" value="'+esc(x.id)+'" data-price="'+pr+'" '+(pr>0?'':'disabled')+' onchange="lkExpCalc()"><span style="flex:1">'+esc(x.name||x.id)+'</span><span class="mtxt">'+(pr>0?('¥'+pr):'单价未设置')+'</span></label>';}).join('');
openModal(`
<div class="modal-head"><h3>+ 新增订单 · ${esc(p.name)}</h3><span class="x" onclick="closeModal()">×</span></div>
<div class="modal-body lk-order">
<div class="warn-box">📌 为「<b>${esc(p.name)}</b>」新增一张订单:沿用客户编号 <b>${esc(p.custNo||'(尚未编号 · 母档案有编号即同号,否则本组签约后自动同号)')}</b>;家数按客户去重(多单仍算 1 家);合同、交款、到期、交付、跟进各单独立。<div class="hint" style="margin-top:6px">只想先建一条档案、暂不填金额 ⇒ 点 <button type="button" class="btn ghost sm" onclick="orderToLinkedCust('${esc(p.id)}')">+ 添加关联档案</button>(客户列表行「⋯」菜单里也有这个入口)。</div>${_up?'<div class="hint" style="margin-top:6px;color:#166534">✅ 本单将直接写入该客户档案(当前为报备态、尚无订单),不会新建重复档案。</div>':''}</div>
<div class="row2">
<div class="field"><label>购买类型 *</label>
<div class="lk-opts">
<label class="lk-opt"><input type="radio" name="lk_buy" value="pkg" checked onchange="lkBuyMode()"><span><b>年课包</b>(整包 · ${lkPkgMetaTxt()})</span></label>
<label class="lk-opt"><input type="radio" name="lk_buy" value="custom" onchange="lkBuyMode()"><span><b>单项 / 组合 / 其它</b>(自选项目或手填金额)</span></label>
<label class="lk-opt"><input type="radio" name="lk_buy" value="exp" onchange="lkBuyMode()"><span><b>🎟 体验课</b>(按名额 · 不计成交 · 不发编号)</span></label>
</div>
<div class="hint">年课包 = 整包售卖(组成与价格在「项目目录」维护),无需逐项勾选</div>
</div>
<div class="field"><label>1 级项目(书法 / 美术)</label>${intentSingleHTML('lk_intent1',(p.intent&&p.intent[0])?[p.intent[0]]:[])}</div>
</div>
<div class="row2">
<div class="field"><label>${_up?'客户姓名(首单沿用,不改名)':'客户姓名(同客户多单保持同名) *'}</label><input id="lk_name" value="${esc(p.name)}"${_up?' readonly style="background:#f5f5f7"':''}></div>
<div class="field"><label>手机号(默认沿用,可按本校区修改)</label><input id="lk_phone" value="${esc(p.phone||'')}"${_phEdit?'':' readonly style="background:#f5f5f7"'} inputmode="tel" maxlength="11" placeholder="11 位手机号"></div>
</div>
<div class="row2">
<div class="field"><label>学校 / 校区名称</label><input id="lk_school" value="${esc(p.school||'')}"></div>
<div class="field"><label>归属经销商(沿用)</label><input value="${esc(dealerName(p.dealerId)||'—')}" readonly style="background:#f5f5f7"></div>
</div>
<div class="field" id="lk_item_box" style="display:none"><label>2 级项目(本单含哪些 · 可搜索)</label><input id="lk_item_q" placeholder="输入关键字过滤项目…" oninput="lkItemsFilter()" style="margin-bottom:6px"><div id="lk_item_list" style="max-height:260px;overflow:auto;border:1px solid var(--line);border-radius:8px;padding:6px">${_lkRows||'<span class="mtxt">(项目目录为空或已全部隐藏)</span>'}</div><div class="hint">勾选后自动带出单价合计;份数可逐项调整(单价未设置的项目按 0 计);<b>体验课不与其他课程混单</b>(勾选后冲突项自动置灰)</div><div class="mtxt" id="lk_std_hint" style="margin-top:6px;font-size:12px"></div></div>
<div class="field" id="lk_exp_box" style="display:none"><label>体验课档位 *</label><div id="lk_exp_tiers" style="max-height:32vh;overflow:auto;border:1px solid var(--line);border-radius:8px;padding:6px">${_lkTiers||'<span class="mtxt">项目目录里没有可售体验课档位,请先在「项目目录」添加</span>'}</div><div style="display:flex;align-items:center;gap:8px;margin-top:8px"><span class="mtxt">份数</span><input type="number" id="lk_exp_qty" min="1" step="1" value="1" style="width:80px" oninput="lkExpCalc()"><span class="mtxt">份(1 份 = 1 个名额)</span></div><div class="mtxt" id="lk_exp_sum" style="margin-top:8px"></div></div>
<div class="field"><label>合同额(自动合计,可手改)</label><input id="lk_amount" value="${lkPkgPrice()}" inputmode="decimal"></div>
<div class="field">
<div class="lk-comm-head" onclick="lkCommToggle()"><span class="lk-comm-t">分润设定(本单)</span><span class="lk-comm-s" id="lk_comm_sum">按现有规则</span><span class="lk-comm-a" id="lk_comm_arrow">展开 ▾</span></div>
<div id="lk_comm_body" style="display:none;margin-top:10px">
${can('edit_comm_override')?`
<div class="field" id="lk_co_box" style="display:none"><label>分润设定(本单)· 业绩归属经销商</label>
<select id="lk_co_mode" onchange="lkCoMode();lkCommSum()"><option value="">请选择(默认不设)</option><option value="inherit">按现有规则</option><option value="rate">按比例(%)</option><option value="fixed">固定金额(¥)</option></select>
<div id="lk_co_val_box" style="display:none;margin-top:6px"><div class="lk-inline"><input id="lk_co_value" inputmode="decimal" placeholder="比例 20 | 金额 3000" oninput="lkCommSum()"><input id="lk_co_note" placeholder="手设原因(备注,选填)"></div></div>
<div class="hint">「请选择」= 暂不设定;「按比例」= 本单分润率;「固定」= 本单分润固定总额(元)。分期 / 未签合同订单不参与分润。</div>
</div>` : ''}
${can('edit_comm_override_staff')?`
<div class="field" id="lk_cos_box" style="display:none"><label>分润设定(本单)· 业绩归属员工</label>
<select id="lk_cos_mode" onchange="lkCosMode();lkCommSum()"><option value="">请选择(默认不设)</option><option value="inherit">按现有规则</option><option value="rate">按比例(%)</option><option value="fixed">固定金额(¥)</option></select>
<div id="lk_cos_val_box" style="display:none;margin-top:6px"><div class="lk-inline"><input id="lk_cos_value" inputmode="decimal" placeholder="比例 3 | 金额 3000" oninput="lkCommSum()"><input id="lk_cos_note" placeholder="备注(选填)"></div></div>
<div class="hint">「按比例」覆盖 4.5% 基础费率;「固定」= 本单员工侧固定总额(元),多归属按交款占比摊、尾差给最大份额。当月交款合计 ≥ 10 万才计。分期 / 未签合同订单不参与分润。</div>
</div>` : ''}
</div>
<div class="hint">不设即按现有规则;分期 / 未签合同订单不参与分润。</div>
</div>
</div>
<div class="modal-foot"><button class="btn ghost" onclick="closeModal()">取消</button><button class="btn" onclick="saveNewOrder('${esc(p.id)}')">${_up?'写入本单':'建立订单'}</button></div>`);
lkBuyMode();
lkCommSum();
}
function saveNewOrder(pid){if(isPhoneLikeSchool((document.getElementById('lk_school')||{}).value||'')){toast('「所属校区/机构」不能填电话号码,请改填校区/机构名称');return;}const p=DB.customers.find(function(x){return x.id===pid;});if(!p||!can('edit_customers')){toast('无权限');return;}
const name=lkNameClean(document.getElementById('lk_name').value);if(!name){toast('请填写客户姓名');return;}
const phone=canField('phone','e')?readLkPhone(p):(p.phone||'');if(phone===false)return;
const school=document.getElementById('lk_school').value.trim();const intent=readIntentSingle('lk_intent1');
const _buy=lkBuyModeGet();var itemIds=(_buy==='pkg')?[]:readLinkedItems();var _expIid='',_expN=1,_expAmt=0;
if(_buy==='exp'){_expIid=lkExpTierOf();if(!_expIid){toast('请先选择一个体验课档位');return;}_expN=lkExpQty();var _eIt=(DB.items||[]).find(function(x){return x&&String(x.id)===String(_expIid);});var _ePr=Number((_eIt&&_eIt.unitPrice)||0)||0;if(!(_ePr>0)){toast('该体验课档位未设置单价,无法建单:请先在「项目目录」补单价');return;}_expAmt=Math.round(_ePr*_expN*100)/100;itemIds=[String(_expIid)];}{const _qS={};(DB.items||[]).forEach(function(x){if(x&&x.trackQuota===true)_qS[String(x.id)]=1;});const _mq=itemIds.some(function(id){return !!_qS[id];});const _mn=itemIds.some(function(id){return !_qS[id];});if(_mq&&_mn){toast('体验课不与其他课程混单:请只选体验课,或只选其他课程');return;}}const itemQty={};itemIds.forEach(function(id){const qe=document.getElementById('lk_qty_'+id);const q=Math.floor(Number((qe&&qe.value)||1)||0);itemQty[id]=q>0?q:1;});if(_buy==='exp')itemQty[String(_expIid)]=_expN;
const _amtEl=document.getElementById('lk_amount');const _amtRaw=Number(String((_amtEl&&_amtEl.value)||'').replace(/[^0-9.]/g,''))||0;var amt=Math.round(_amtRaw*100)/100;if(_buy==='exp')amt=_expAmt;
if(orderEmptyBlock(_buy,itemIds,amt)){toast('订单不能为空:请至少勾选一个项目,或填写合同金额');return;}
const nc={id:uid('c_'),name,phone,school,intent,dealerId:p.dealerId,serviceStaffId:p.serviceStaffId||'',reportDate:today(),prov:p.prov||'',city:p.city||'',district:p.district||'',addr:p.addr||'',stage:'reported',amount:amt,installment:false,refunded:false,signDate:'',contractSigned:false,followups:[],campusScope:linkedScopeOf(p),campusOf:custCampusRepOf(p),linkedRootId:(p.linkedRootId||p.id)};
if(itemIds.length){nc.itemIds=itemIds;nc.itemQty=itemQty;}if(_buy==='pkg'){nc.pkgId='pkg_annual';}if(can('edit_comm_override')){const _coM=document.getElementById('lk_co_mode');const _cm=_coM?String(_coM.value||'inherit'):'inherit';if(_cm==='rate'||_cm==='fixed'){const _cv=Number(String((document.getElementById('lk_co_value')||{}).value||'').replace(/[^0-9.]/g,''))||0;if(_cv>0){const _cn=String((document.getElementById('lk_co_note')||{}).value||'').slice(0,200);nc.commOverride=(_cm==='rate')?{mode:'rate',value:Math.min(1,Math.round((_cv/100)*10000)/10000),note:_cn}:{mode:'fixed',value:Math.min(1000000,Math.round(_cv)),note:_cn};}}}if(can('edit_comm_override_staff')){const _csM=String((document.getElementById('lk_cos_mode')||{}).value||'inherit');const _csV=Number(String((document.getElementById('lk_cos_value')||{}).value||'').replace(/[^0-9.]/g,''))||0;if((_csM==='rate'||_csM==='fixed')&&_csV>0){const _csN=String((document.getElementById('lk_cos_note')||{}).value||'').slice(0,200);nc.commOverrideStaff=(_csM==='rate')?{mode:'rate',value:Math.min(1,Math.round((_csV/100)*10000)/10000),note:_csN}:{mode:'fixed',value:Math.min(1000000,Math.round(_csV)),note:_csN};}}
if(custOrderUpgradable(p)&&canField('amount','e')){p.school=school;p.intent=intent;p.amount=amt;if(canField('phone','e'))p.phone=phone;if(itemIds.length){p.itemIds=itemIds;p.itemQty=itemQty;}if(_buy==='pkg')p.pkgId='pkg_annual';if(_buy==='exp'){var _rc637=applyExpCourseOrder(p,_expIid,_expN);if(_rc637==='noamt'){toast('无权限:体验课单按档位单价重算合同额,需「合同金额」编辑权');return;}}if(nc.commOverride)p.commOverride=nc.commOverride;if(nc.commOverrideStaff)p.commOverrideStaff=nc.commOverrideStaff;save().then(function(ok){if(!ok)return;closeModal();render();toast('订单已写入该客户档案(未新建重复档案)'+(_buy==='pkg'?'(年课包)':(itemIds.length?'(含 '+itemIds.length+' 个项目)':''))+(p.custNo?'(编号 '+p.custNo+')':''));});return;}
DB.customers.push(nc);save().then(function(ok){if(!ok)return;closeModal();render();toast('订单已建立'+(_buy==='pkg'?'(年课包)':(itemIds.length?'(含 '+itemIds.length+' 个项目)':''))+(nc.custNo?'(编号 '+nc.custNo+')':'(尚未编号 · 本组签约后自动同号)'));});}
function orderEmptyBlock(buy,itemIds,amt){return !(buy==='pkg')&&!(itemIds&&itemIds.length)&&!(Number(amt)>0);}
function findExistingReferral(phone,excludeId){if(!phone)return null;return DB.customers.find(c=>c.id!==excludeId&&!c.refunded&&c.phone===phone);}
function showDupCustomerModal(ex){openModal(`
<div class="modal-head"><h3>⚠ 手机号已报备</h3><span class="x" onclick="closeModal()">×</span></div>
<div class="modal-body">
<div class="warn-box" style="margin-bottom:0">
该客户手机号 <b>${ex.phone||'—'}</b> 已在系统中存在报备记录,请勿重复报备。<br><br>
同一客户以首次报备(手机号)为准,先报先得。<br><br>
<b>💡 若这是同一客户要报第二个项目 / 校区</b>(不算重复报备):请勿在此重复提交 —— 请到<b>客户列表</b>找到该客户,点「<b>+ 新增订单</b>」建立新订单:自动沿用客户编号,手机号默认带出、可按校区改;客户家数仍算 1 家;合同、交款、到期、总部交付、跟进各订单独立管理。(界面上没有「+ 新增订单」按钮时,请联系运营同事代为办理。)
</div>
</div>
<div class="modal-foot">
<button class="btn ghost" onclick="closeModal()">我知道了</button>
</div>`);}
function showDealerPhoneModal(phone,d){openModal(`
<div class="modal-head"><h3>⚠ 手机号与经销商冲突</h3><span class="x" onclick="closeModal()">×</span></div>
<div class="modal-body">
<div class="warn-bo
x" style="margin-bottom:0">
报备的客户手机号 <b>${phone||'—'}</b> 与经销商注册手机号相同,请确认未将经销商本人误作为转介绍客户报备;若确为不同自然人,请更换客户手机号后重试。
</div>
</div>
<div class="modal-foot">
<button class="btn ghost" onclick="closeModal()">我知道了</button>
</div>`);}
function findDelivery(dealerId,serviceId){return DB.deliveries.find(x=>x.dealerId===dealerId&&!x.customerId&&x.serviceId===serviceId);}
function findCustomerDelivery(customerId,serviceId){return DB.deliveries.find(x=>x.customerId===customerId&&x.serviceId===serviceId);}
function ownerDelivery(type,ownerId){const match=type==='customer'?(x=>x.customerId===ownerId):(x=>x.dealerId===ownerId&&!x.customerId);const items=DB.deliveries.filter(match);const total=SERVICES.length;const done=items.filter(x=>x.status==='done').length;const partial=items.filter(x=>x.status==='partial').length;const pending=items.filter(x=>x.status==='pending').length;const weighted=items.reduce((s,x)=>s+(x.status==='done'?1:x.status==='partial'?0.5:0),0);const completion=total?weighted/total*100:0;const deliveredValue=items.reduce((s,x)=>{const sv=SERVICES.find(y=>y.id===x.serviceId)?.value||0;return s+sv*(x.status==='done'?1:x.status==='partial'?0.5:0);},0);const totalValue=SERVICES.reduce((s,y)=>s+y.value,0);return{total,done,partial,pending,completion,deliveredValue,totalValue};}
function dealerDelivery(dealerId){return ownerDelivery('dealer',dealerId);}
function pushDelivery(type,id){const o=type==='customer'?DB.customers.find(x=>x.id===id):DB.dealers.find(x=>x.id===id);if(!o){toast('未找到记录');return;}if(type==='customer'&&(o.stage==='refunded'||o.stage==='terminated'||o.refunded)){toast('客户状态为「已退费/已终止」,禁止推送交付');return;}if(type==='customer'&&!o.contractSigned){toast('客户「'+esc(o.name)+'」尚未签订合同,已禁止推送交付:请先在客户详情勾选「已签合同」');return;}doPushDelivery(type,id,o);}
function doPushDelivery(type,id,o){o=o||(type==='customer'?DB.customers.find(x=>x.id===id):DB.dealers.find(x=>x.id===id));if(!o){toast('未找到记录');return;}if(type==='customer'&&(o.stage==='refunded'||o.stage==='terminated'||o.refunded)){toast('客户状态为「已退费/已终止」,禁止推送交付');return;}if(type==='customer'&&!o.contractSigned){toast('客户「'+esc(o.name)+'」尚未签订合同,禁止推送交付');return;}o.pushedDelivery=true;save().then(function(ok){if(!ok)return;render();toast('已推送至交付矩阵');});}
/* 2026-09-14「必须已签合同」:原「合同未签提示」弹窗(内含一个可绕过的「继续」按钮)已按用户要求整体移除,改为 pushDelivery / doPushDelivery 前端硬拦 + POST /api/db 兜底回退 + toggle 通道 403 三处对称拦截(自检 #129 守)。 */
function deliveryFullyDone(type,id){const items=type==='customer'?DB.deliveries.filter(x=>x.customerId===id):DB.deliveries.filter(x=>x.dealerId===id&&!x.customerId);return items.length>0&&items.every(x=>x.status==='done'||x.status==='n_a');}
function hasDeliveryRec(type,id){const items=type==='customer'?DB.deliveries.filter(x=>x.customerId===id):DB.deliveries.filter(x=>x.dealerId===id&&!x.customerId);return items.length>0;}
function isPushed(type,o){return !!(o&&(o.pushedDelivery||hasDeliveryRec(type,o.id)));}
/* 2026-09-14 交付矩阵白屏修复:状态/阶段「缺失·越界」安全取值(单一真相源)
事故:deliveries 有 1 条记录 status 为 undefined(由「保存子记录」路径新建记录时漏写 status 造成),
渲染时原写法(按 st 直接索引后再取 cls)抛 TypeError → 整个 renderDeliveryParts 中断 →
#dlMatrixCard 从未赋值 → 总部交付服务「客户交付矩阵」完全空白(用户报「看不到明细」)。
口径与既有实现同源,不自创:metaInfoCard / syncMetaStatus 的「有子记录 → 已交付(done)」规则;
frag58 既有的「索引不到就回退 pending」写法与「阶段索引不到回退 {label,cls}」写法同义。 */
function delivStatusKey(dv){if(!dv)return'pending';var st=dv.status;if(st&&DELIVERY_STATUS[st])return st;var hasSub=(dv.vr&&dv.vr.installs&&dv.vr.installs.length>0)||(Array.isArray(dv.online)&&dv.online.length>0)||(dv.trains&&dv.trains.length>0)||(dv.helps&&dv.helps.length>0)||(dv.materials&&dv.materials.length>0);return hasSub?'done':'pending';}
function delivStatusOf(dv){var k=delivStatusKey(dv);var v=DELIVERY_STATUS[k]||DELIVERY_STATUS.pending;return{key:k,label:v.label,cls:v.cls};}
function stageInfoOf(st){return STAGES[st]||{label:st||'—',cls:''};}
function unpushDelivery(type,id){const o=type==='customer'?DB.customers.find(x=>x.id===id):DB.dealers.find(x=>x.id===id);if(!o){toast('未找到记录');return;}if(hasDeliveryRec(type,id)){toast('总部交付服务已有交付记录,不可撤回推送');return;}showUnpushConfirm(type,id,o);}
function showUnpushConfirm(type,id,o){openModal(`<div class="modal-head"><h3>↩ 撤回推送确认</h3><span class="x" onclick="closeModal()">×</span></div><div class="modal-body"><div class="warn-box" style="margin-bottom:0">确定撤回 <b>${esc(o.name)}</b> 的交付推送吗?<br><br>撤回后该${type==='customer'?'客户':'经销商'}在「总部交付」中的推送状态将取消;已产生的交付记录会保留,之后可重新推送。请谨慎确认,防止误操作。</div></div><div class="modal-foot"><button class="btn ghost" onclick="closeModal()">取消</button><button class="btn danger" onclick="closeModal();doUnpushDelivery('${type}','${id}')">确认撤回</button></div>`);}
function doUnpushDelivery(type,id){const o=type==='customer'?DB.customers.find(x=>x.id===id):DB.dealers.find(x=>x.id===id);if(!o){toast('未找到记录');return;}if(hasDeliveryRec(type,id)){toast('总部交付服务已有交付记录,不可撤回推送');return;}o.pushedDelivery=false;save().then(function(ok){if(!ok)return;render();toast('已撤回推送');});}
function autoUnpushOnRefund(c){if(!c||!c.id)return false;const dead=!!(c.refunded||c.stage==='refunded'||c.stage==='terminated');if(!dead||!c.pushedDelivery)return false;if(hasDeliveryRec('customer',c.id))return false;c.pushedDelivery=false;return true;}
function pushCell(type,o){if(type==='dealer'&&o.type==='staff')return'—';const done=deliveryFullyDone(type,o.id);const pushed=isPushed(type,o);const canEdit=can('edit_delivery');if(type==='customer'&&!pushed&&(o.stage==='refunded'||o.stage==='terminated'||o.refunded)){const stLbl=(STAGES[o.stage]||{}).label||'已退费';return canEdit?`<button class="btn ghost sm" disabled style="opacity:.45;cursor:not-allowed" title="客户状态「${stLbl}」,禁止推送交付">🚫 推送交付</button>`:'—';}if(!pushed)return canEdit?`<button class="btn ghost sm" onclick="pushDelivery('${type}','${o.id}')">📤 推送交付</button>`:'—';if(done)return`<span class="pill" style="background:#dcfce7;color:#166534">✅ 已交付</span>`;if(hasDeliveryRec(type,o.id))return`<span class="pill" style="background:#dcfce7;color:#166534" title="总部交付服务已有交付记录,禁止撤回">🔄 交付中</span>`;return canEdit?`<button class="btn ghost sm" onclick="unpushDelivery('${type}','${o.id}')">✅已推送·撤回</button>`:`<span class="pill" style="background:#e0e7ff;color:#4338ca">✅ 已推送</span>`;}
function globalDelivery(){const dealers=(function(){const ids=new Set();DB.dealers.forEach(d=>{if(d.type!=="staff"&&isPushed('dealer',d))ids.add(d.id);});return ids.size;})();const items=DB.deliveries.filter(x=>!x.customerId);const il=items.length;const done=items.filter(x=>x.status==='done').length;const partial=items.filter(x=>x.status==='partial').length;const completion=dealers?(done+partial*0.5)/(dealers*SERVICES.length)*100:0;const totalValue=SERVICES.reduce((s,y)=>s+y.value,0)*dealers;const deliveredValue=items.reduce((s,x)=>{const sv=SERVICES.find(y=>y.id===x.serviceId)?.value||0;return s+sv*(x.status==='done'?1:x.status==='partial'?0.5:0);},0);return{dealers,items,done,partial,completion,totalValue,deliveredValue};}
function globalDeliveryCust(){const custTotal=(function(){const ids=new Set();DB.customers.forEach(c=>{if(isPushed('customer',c))ids.add(c.id);});return ids.size;})();const items=DB.deliveries.filter(x=>x.customerId);const il=items.length;const done=items.filter(x=>x.status==='done').length;const partial=items.filter(x=>x.status==='partial').length;const completion=custTotal?(done+partial*0.5)/(custTotal*SERVICES.length)*100:0;const deliveredValue=items.reduce((s,x)=>{const sv=SERVICES.find(y=>y.id===x.serviceId)?.value||0;return s+sv*(x.status==='done'?1:x.status==='partial'?0.5:0);},0);const deliveredCustomers=DB.customers.filter(c=>isPushed('customer',c)&&DB.deliveries.some(x=>x.customerId===c.id&&x.status==='done')).length;return{custTotal,items,done,partial,completion,deliveredValue,deliveredCustomers};}
function vrExpire(installDate,months){if(!installDate||!months)return'';const dt=new Date(installDate);if(isNaN(dt))return'';dt.setMonth(dt.getMonth()+Number(months));const y=dt.getFullYear(),m=String(dt.getMonth()+1).padStart(2,'0'),d=String(dt.getDate()).padStart(2,'0');return`${y}-${m}-${d}`;}
function vrDaysLeft(expire){if(!expire)return null;const e=new Date(expire);if(isNaN(e))return null;return Math.ceil((e-new Date())/86400000);}
function vrReminder(days){if(days===null)return{txt:'—',cls:'st-lost'};if(days<0)return{txt:`已过期 ${Math.abs(days)} 天`,cls:'st-refunded'};if(days<=30)return{txt:`${days} 天后到期 ⚠`,cls:'st-following'};return{txt:`${days} 天后到期`,cls:'st-signed_full'};}
let curView='dashboard';let mode='admin';let curDealerView='dashboard';let curDealerId='';let currentUser=null;const view=document.getElementById('view');function fmt(n){return(n||0).toLocaleString('zh-CN');}
function pct(n){return(n||0).toFixed(1)+'%';}
function money(n){return'¥'+fmt(Math.round(n||0));}
function amtMask(cond,n,prefix){if(mode==='dealer')return(prefix||'')+fmt(Math.round(n||0));return cond?((prefix||'')+fmt(Math.round(n||0))):'<span class="mtxt" style="color:#94a3b8">无权限</span>';}
function moneyMask(cond,n){if(mode==='dealer')return money(n);const lv=(cond===true||cond==='full')?'full':(cond==='mask'?'mask':'hidden');return lv==='full'?money(n):(lv==='mask'?'<span class="mtxt" style="color:#94a3b8">¥***</span>':'<span class="mtxt" style="color:#94a3b8">无权限</span>');}
function today(){return new Date().t
oISOString().slice(0,10);}
const PERM_FIELD_FB={view_kpi_amount:'amount_kpi',view_dealer_amount:'amount_dealer',view_finance_amount:['amount','payments'],view_phone:'phone',view_address:'addr'};const SENS_FIDS=['phone','addr','amount','payments','amount_kpi','amount_dealer'];function normLv(v){return v===true?'full':v===false?'hidden':(v==='full'||v==='mask'||v==='hidden')?v:null;}function fLevel(fid){if(mode==='dealer')return 'full';if(!currentUser)return 'hidden';const roles=(currentUser.roles&¤tUser.roles.length)?currentUser.roles:(currentUser.role?[currentUser.role]:[]);if(roles.indexOf('admin')>=0)return 'full';let out='hidden';roles.some(r=>{const rp=DB.rolePerms&&DB.rolePerms[r];if(!rp)return false;const f=rp.fields&&rp.fields[fid];let lv=f?(f.v===undefined?null:normLv(f.v)):null;if(!lv){const info=FIELDS[fid];const fb=info&&info.fb&&info.fb.v;const cv=fb?rp[fb]:undefined;lv=cv===undefined?((OPERATOR_DEFAULT[fb]===true)?'full':'hidden'):(cv?'full':'hidden');}if(lv==='full'){out='full';return true;}if(lv==='mask'&&out!=='full')out='mask';return false;});return out;}
function fTxt(fid,v){const lv=fLevel(fid);if(lv==='full')return (v===undefined||v===null||v==='')?'—':v;if(lv==='mask'){if(Array.isArray(v))return v.length?v.map(function(x){return String(x||'').slice(0,1)+'**';}).join('、'):'—';const s2=(v===undefined||v===null)?'':String(v);return s2?s2.slice(0,1)+'**':'—';}return '****';}
function fDateTxt(fid,v){const lv=fLevel(fid);if(lv==='full')return v||'—';if(lv==='mask')return v?String(v).slice(0,4)+'-**-**':'—';return '****';}
function fStageBadge(fid,st){const lv=fLevel(fid);const info=STAGES[st]||{cls:'',label:st||'—'};if(lv==='full')return '<span class="st '+info.cls+'">'+info.label+'</span>';return '<span class="st">'+(lv==='mask'?'***':'****')+'</span>';}
function fToggle(fid,html,signed){const lv=fLevel(fid);if(lv==='full')return html;return '<span class="mtxt">'+(lv==='mask'?'***':'****')+'</span>';}
function csvF(fid,full,maskVal){const lv=fLevel(fid);return lv==='full'?full:(lv==='mask'?(maskVal===undefined?'***':maskVal):'****');}
function can(perm){if(!currentUser)return false;const roles=(currentUser.roles&¤tUser.roles.length)?currentUser.roles:(currentUser.role?[currentUser.role]:[]);if(roles.indexOf('admin')>=0)return true;return roles.some(r=>{const rp=DB.rolePerms&&DB.rolePerms[r];if(!rp)return false;let v=rp[perm];if(v===undefined)v=(r==='dealer'?false:(OPERATOR_DEFAULT[perm]!==undefined?OPERATOR_DEFAULT[perm]:false));const pf=PERM_FIELD_FB[perm];if(pf){const farr=Array.isArray(pf)?pf:[pf];for(const fid of farr){const f=rp.fields&&rp.fields[fid];if(f&&(f.v===false||f.v==='hidden')){v=false;break;}}}return!!v;});}
const FIELDS={"c_name":{"label":"客户姓名","fb":{"v":"view_customers","e":"edit_customers","d":"del_customers","x":"export_data"}},"c_school":{"label":"学校/机构","fb":{"v":"view_customers","e":"edit_customers","d":"del_customers","x":"export_data"}},"phone":{"label":"手机号","fb":{"v":"view_phone","e":"edit_customers","d":"del_customers","x":"export_data"}},"addr":{"label":"详细地址","fb":{"v":"view_address","e":"edit_address","d":"del_customers","x":"export_data"}},"c_intent":{"label":"意向合作项目","fb":{"v":"view_customers","e":"edit_customers","d":"del_customers","x":"export_data"}},"c_stage":{"label":"客户状态","fb":{"v":"view_customers","e":"edit_customers","d":"del_customers","x":"export_data"}},"c_sign":{"label":"签约/合同信息","fb":{"v":"view_customers","e":"edit_customers","d":"del_customers","x":"export_data"}},"c_report":{"label":"报备日期","fb":{"v":"view_customers","e":"edit_customers","d":"del_customers","x":"export_data"}},"c_nextpay":{"label":"下次补款","fb":{"v":"view_customers","e":"edit_customers","d":"del_customers","x":"export_data"}},"c_installment":{"label":"是否分期订单","fb":{"v":"view_customers","e":"edit_customers","d":"del_customers","x":"export_data"}},"c_followups":{"label":"跟进记录","fb":{"v":"view_followups","e":"follow_customers","d":"del_customers","x":"export_data"}},"c_quota":{"label":"体验课名额","fb":{"v":"view_customers","e":"edit_cust_quota"}},"amount":{"label":"合同金额","fb":{"v":"view_finance_amount","e":"edit_finance","d":"del_customers","x":"export_reconcile"}},"payments":{"label":"交款/退款记录","fb":{"v":"view_finance_amount","e":"add_finance","d":"del_payments","x":"export_reconcile"}},"amount_kpi":{"label":"金额/分润指标","fb":{"v":"view_kpi_amount","e":"edit_finance","d":"del_customers","x":"export_data"}},"c_payowner":{"label":"业绩归属(按交款)","disp":true,"fb":{"v":"view_kpi_amount"}},"c_coord":{"label":"客户坐标(地图定位)","fb":{"v":"edit_address","e":"edit_address","d":"del_customers","x":"export_data"}},"d_stage":{"label":"客户状态","fb":{"v":"view_dealers","e":"edit_dealers","d":"del_dealers","x":"export_data"}},"d_rate":{"label":"分润比例","fb":{"v":"view_dealer_amount","e":"edit_dealers","d":"del_dealers","x":"export_data"}},"d_reported":{"label":"报备数","fb":{"v":"view_dealers","e":"edit_dealers","d":"del_dealers","x":"export_data"}},"d_signed":{"label":"全款成交数","fb":{"v":"view_dealers","e":"edit_dealers","d":"del_dealers","x":"export_data"}},"d_assess":{"label":"考核结果","fb":{"v":"view_dealers","e":"edit_dealers","d":"del_dealers","x":"export_data"}},"d_payowner":{"label":"业绩归属(按交款)","disp":true,"fb":{"v":"view_kpi_amount"}},"d_name":{"label":"经销商名称","fb":{"v":"view_dealers","e":"edit_dealers","d":"del_dealers","x":"export_data"}},"d_type":{"label":"类型/区域","fb":{"v":"view_dealers","e":"edit_dealers","d":"del_dealers","x":"export_data"}},"amount_dealer":{"label":"预缴费用/应发分润","fb":{"v":"view_dealer_amount","e":"edit_dealers","d":"del_dealers","x":"export_data"}},"d_sign":{"label":"签约/合同信息","fb":{"v":"view_dealers","e":"edit_dealers","d":"del_dealers","x":"export_data"}},"dv_service":{"label":"服务项目","fb":{"v":"view_delivery","e":"edit_delivery","d":"del_delivery_items","x":"export_data"}},"dv_status":{"label":"交付状态","fb":{"v":"view_delivery","e":"edit_delivery","d":"del_delivery_items","x":"export_data"}},"dv_date":{"label":"交付日期","fb":{"v":"view_delivery","e":"edit_delivery","d":"del_delivery_items","x":"export_data"}},"dv_notes":{"label":"数量/操作人/备注","fb":{"v":"view_delivery","e":"edit_delivery","d":"del_delivery_items","x":"export_data"}},"u_username":{"label":"登录账号","fb":{"v":"manage_users","e":"edit_users","d":"del_users","x":"export_data"}},"u_name":{"label":"姓名","fb":{"v":"manage_users","e":"edit_users","d":"del_users","x":"export_data"}},"u_role":{"label":"角色/状态","fb":{"v":"manage_users","e":"edit_users","d":"del_users","x":"export_data"}}};
const FIELDS_BY_PAGE={"dashboard":["amount_kpi","amount_dealer"],"customers":["c_name","c_school","phone","addr","c_intent","c_stage","c_sign","c_report","c_nextpay","c_installment","c_followups","amount","amount_kpi","d_name","d_type","c_payowner","c_coord"],"dealercomm":["amount","amount_kpi"],"finance":["amount","payments"],"dealers":["d_name","d_type","phone","addr","amount_dealer","d_sign","d_stage","d_rate","d_reported","d_signed","d_assess","d_payowner","dv_service","dv_status"],"delivery":["dv_service","dv_status","dv_date","dv_notes"],"users":["u_username","u_name","phone","u_role"],"reports":["amount","amount_kpi"]};
/* ★ 2026-09-18 第 68 轮(B 方案:字段「编辑」闸门口径 = 只认本页编辑权)
背景:字段行「编辑」列的闸键原先指向**别的模块页**(c_followups→follow_customers、addr/c_coord→edit_address、
amount/amount_kpi→edit_finance、dv_service/dv_status→edit_delivery…)⇒ ① 授权要跨页找键;② 徽标提示「需先开「X」页」
与用户直觉冲突。现口径:**字段能否编辑 = 它所在的任一页面的「编辑实体权」是否开启**。
· PAGE_EDIT_KEY:每页的「编辑实体键」(该页 edit 列里语义为「编辑本页实体」的那个键);
值为 null = 该页没有编辑入口(纯只读页)⇒ 该页字段行「编辑」列画「—」,不参与判定。
· fbEditKeys(fid):该字段出现的全部页面各自的本页编辑键(去重);空 = 该字段只在只读页出现。
· fbEditOn(rp,fid):净效果 = 上面这些键里**任一个**开着即放行(未声明 ⇒ 按 OPERATOR_DEFAULT 兜底,与旧口径同源)。
· FIELDS[*].fb.e 保留为**旧值留档 / 非本页场景兜底**(自检 perm_edit_rebind_guard 守护二者不漂移)。
⚠️ 影响(用户 2026-09-18 已确认):9 格真实收紧 + 1 格放宽;`edit_address`、`follow_customers` 的字段级绑定被解除。 */
const PAGE_EDIT_KEY={"customers":"edit_customers","dealers":"edit_dealers","delivery":"edit_delivery","finance":"edit_finance","users":"edit_users","dashboard":null,"dealercomm":null,"reports":null};
/* ★ 2026-09-18 第 68 轮(B 方案续:删除列同口径)
页面「删除」功能权限键(语义 = 删除本页实体)。无删除入口的页面为 null ⇒ 该页字段「删除」格不再画跨页徽标。
与 PAGE_EDIT_KEY 同键集、同口径:字段能否删除 = 是否已勾它所在页面的「删除」功能权限。
注意:删除列的字段级值仍是「没勾 = 不允许」(fDeny fail-closed,自检 #172 守护),本键只决定该格能否勾选 / 提示指向。 */
const PAGE_DEL_KEY={"customers":"del_customers","dealers":"del_dealers","delivery":"del_delivery_items","finance":"del_payments","users":"del_users","dashboard":null,"dealercomm":null,"reports":null};
/* ★ 2026-09-18 第 68 轮续 2(用户口径:统一规则 · 按页面级)
规则:字段「编辑」闸 = 该字段所在页面的「编辑实体权」(PAGE_EDIT_KEY)的并集;
服务端写回保护(protectSensitiveChanges)与本表逐 fid 同源 —— 由自检 perm_field_sync 断言(含哨兵)。
FB_EDIT_EXTRA 用于「页级键集表达不了、但确实是该字段真实写入口」的键:
c_followups ← follow_customers:「跟进记录」页的客户跟进入口(saveDealerCustFollowup / POST /api/wechat-import)
payments ← add_finance:「财务收款」页「登记交款」写入 payments(与「修改合同金额」edit_finance 并列)
⚠️ 新增/调整规则时三处必须同改:本表 · 服务端 FB_EDIT_KEYS · 自检 perm_field_sync 的登记名单。 */
const FB_EDIT_EXTRA={"c_followups":["follow_customers"],"c_quota":["edit_cust_quota"],"payments":["add_finance"]};
const FB_EDIT_IDX={};
Object.keys(FIELDS_BY_PAGE).forEach(function(pg){var k=PAGE_EDIT_KEY[pg];if(!k)return;(FIELDS_BY_PAGE[pg]||[]).forEach(function(fid){if(!FB_EDIT_IDX[fid])FB_EDIT_IDX[fid]=[];if(FB_EDIT_IDX[fid].indexOf(k)<0)FB_EDIT_IDX[fid].push(k);});});
Object.keys(FB_EDIT_EXTRA).forEach(function(fid){if(!FB_EDIT_IDX[fid])FB_EDIT_IDX[fid]=[];FB_EDIT_EXTRA[fid].forEach(function(k){if(FB_EDIT_IDX[fid].indexOf(k)<0)FB_EDIT_IDX[fid].push(k);});});
function fbEditKeys(fid){return FB_EDIT_IDX[fid]||[];}
function fbEditOn(rp,fid){const ks=FB_EDIT_IDX[fid]||[];if(!ks.length)return false;return ks.some(function(k){return rp[k]!==undefined?!!rp[k]:(OPERATOR_DEFAULT[k]===true);});}
const PERM_LABEL={};PERMS.forEach(p=>{PERM_LABEL[p.key]=p.label;});
const DEALER_FIELDS_BY_PAGE={"dp_dashboard":[],"dp_report":["c_name","phone","c_school","addr","c_intent"],"dp_customers":["c_name","c_school","phone","addr","c_intent","c_stage","c_sign","c_report","c_nextpay","c_followups","amount","payments"],"dp_followups":["c_followups"],"dp_finance":["amount","payments"],"dp_benefits":[]};
function initRolePerm(role){if(!DB.rolePerms)DB.rolePerms={};if(!DB.rolePerms[role])DB.rolePerms[role]=(role==='dealer')?{...DEALER_PORTAL_DEFAULT}:blankRolePerms();if(!DB.rolePerms[role].fields)DB.rolePerms[role].fields={};}
function opLabel(op){return {v:'可见级别',e:'编辑',d:'删除',x:'导出',view:'查看',export:'导出',edit:'编辑',delete:'删除'}[op]||op;}
function canDealerField(fid,op){if(mode!=='dealer')return true;const rp=(DB&&DB.rolePerms&&DB.rolePerms.dealer)||{};const f=rp.fields&&rp.fields[fid];if(f&&f[op]!==undefined)return !!f[op];return true;}
function dealerCanExport(){if(mode!=='dealer')return true;const f=(DB&&DB.rolePerms&&DB.rolePerms.dealer&&DB.rolePerms.dealer.fields)||{};const ks=Object.keys(f);if(!ks.length)return true;return ks.some(k=>f[k]&&f[k].x===true);}
function dealerExpPhone(p){return (canDealerField('phone','x')&&canDealerField('phone','v'))?(p||''):fmtPhone(p);}
function dealerExpAmount(v){return (canDealerField('amount','x')&&canDealerField('amount','v'))?v:'';}
function dpMoney(v){return canDealerField('amount','v')?money(v):'—';}
function dpPhone(p){return canDealerField('phone','v')?(p||'—'):fmtPhone(p);}
function canField(fid,op){if(!currentUser)return false;const roles=(currentUser.roles&¤tUser.roles.length)?currentUser.roles:(currentUser.role?[currentUser.role]:[]);if(roles.indexOf('admin')>=0)return true;return roles.some(r=>{const rp=DB.rolePerms&&DB.rolePerms[r];if(!rp)return false;const f=rp.fields&&rp.fields[fid];if(f&&f[op]!==undefined){const rv=f[op];return (op==='v'&&rv==='hidden')?false:!!rv;}const info=FIELDS[fid];if(!info)return true;if(op==='e')return fbEditOn(rp,fid);const fb=info.fb[op];if(!fb)return true;const v=rp[fb];if(v!==undefined)return !!v;return OPERATOR_DEFAULT[fb]===true;});}
function fDeny(fid,op){if(!currentUser)return false;const roles=(currentUser.roles&¤tUser.roles.length)?currentUser.roles:(currentUser.role?[currentUser.role]:[]);if(roles.indexOf('admin')>=0)return false;let sf=false,st=false,seen=false;roles.forEach(r=>{const rp=DB.rolePerms&&DB.rolePerms[r];if(!rp||r==='dealer')return;seen=true;const f=rp.fields&&rp.fields[fid];if(!f)return;const v=f[op];if(v===undefined)return;if(v===false||(op==='v'&&v==='hidden'))sf=true;else st=true;});/* d / x:【未勾 = 不允许】——必须被某个角色显式授权才放行;dealer 端 seen=false ⇒ 不受影响 */if((op==='d'||op==='x')&&seen)return !st;return sf&&!st;}
function exportColFid(kind){const M={'交付明细':{'服务项目':'dv_service','规格':'dv_service','子记录明细':'dv_service','交付状态':'dv_status','交付方式':'dv_status','操作人':'dv_notes','数量':'dv_notes','备注':'dv_notes','首次开通时间':'dv_date'},'交付矩阵':{'联系电话':'phone'},'财务':{'经销商':'d_name','客户姓名':'c_name','客户校区':'c_school','联系电话':'phone','合同总额':'amount','已交款':'amount','抵扣金额':'amount','实收金额':'amount','欠款':'amount','交款笔数':'payments','最近交款日期':'payments','日期':'payments','下次补款提醒':'c_nextpay'}};return M[kind]||null;}/* ★ 第 68 轮续 4(用户 2026-09-18「查看/编辑/删除/导出,全部排查」):导出粒度字段集。
四列有效性排查证实:v 列有服务端 staffScrubDb(遍历 SENS_FIELD_DATA)+ 前端 fLevel 两条通用消费链;
e / d 列有服务端 protectFieldOpDeny(遍历 F3_ALL_FIELD_DATA 全部 25 个字段)⇒ 三列对每个字段都真生效。
唯独 x(导出)没有任何通用机制,全库只有三处显式消费:
① 本函数 exportColFid 的三张「列名 → 字段」映射(导出时剔除无权限的列)
② 分润明细导出 exportDealerCommCsv 的 fDeny amount / amount_kpi
③ 经销商门户 canDealerField phone / amount(决定客户列表是否显示原值)
⇒ 32 个字段里只有这 12 个的 x 值会被真正读取,其余 20 个勾了毫无效果(死开关)。
本常量即那 12 个字段,运行时派生(单一数据源,永不会与 exportColFid 漂移)。 */const EXPORT_FIDS=(function(){var s=['dv_service','amount','amount_kpi','phone'];['交付明细','交付矩阵','财务'].forEach(function(k){var m=exportColFid(k);if(!m)return;Object.keys(m).forEach(function(h){var f=m[h];if(f&&s.indexOf(f)<0)s.push(f);});});return s;})();
function exportDropIdx(rows,kind){const m=exportColFid(kind);const drop={};if(!m||!rows||!rows.length)return drop;const head=rows[0]||[];head.forEach((h,i)=>{const fid=m[h];if(fid&&fDeny(fid,'x'))drop[i]=1;});if(kind==='交付矩阵'&&fDeny('dv_service','x')){head.forEach((h,i)=>{SERVICES.forEach(s=>{if(s.name===h)drop[i]=1;});});}return drop;}
function exportDropRows(rows,kind){const drop=exportDropIdx(rows,kind);const ks=Object.keys(drop);if(!ks.length)return rows;return rows.map(r=>r.filter((c,i)=>!drop[i]));}
function exportDropHint(kind){const m=exportColFid(kind);if(!m)return '';const names=Object.keys(m).filter(h=>fDeny(m[h],'x'));if(kind==='交付矩阵'&&fDeny('dv_service','x'))names.push('全部服务项目列');if(!names.length)return '';return '<div class="warn-box" style="margin-top:8px">已按「字段级导出权限」剔除 '+names.length+' 列:'+names.join('、')+'</div>';}
function addrView(c){const lv=fLevel('addr');if(lv==='full')return([c.prov,c.city,c.district,c.addr].filter(Boolean).join(' ')||'无地址');if(lv==='mask')return(([c.prov,c.city].filter(Boolean).join(' ')||'')+' **');return'(无权限)';}
function fieldLvOn(rp,fid){const f=rp.fields&&rp.fields[fid];if(f&&f.v!==undefined){const lv=normLv(f.v);if(lv)return lv;}const info=FIELDS[fid];const fb=info&&info.fb&&info.fb.v;if(!fb)return 'full';const cv=rp[fb];if(cv===undefined)return OPERATOR_DEFAULT[fb]===true?'full':'hidden';return cv?'full':'hidden';}
function fieldOn(rp,fid,op,isDealer){const f=rp.fields&&rp.fields[fid];if(f&&f[op]!==undefined){const rv=f[op];return (op==='v'&&rv==='hidden')?false:!!rv;}if(isDealer)return true;/* ★ 2026-09-18 第 65 轮:d(删除)/ x(导出)的运行时语义是「未勾 = 不允许」(见 fDeny 的 fail-closed 分支,自检 #172 守护)⇒ 矩阵不得再把「未声明」画成「跟随页级 ⇒ 勾选」,否则显示 ✔ 而实际被拦 —— 用户「明明勾了却不生效」的根源。经销商门户角色不参与 fDeny(roles.forEach 里 r==='dealer' 被跳过)⇒ 上面的 isDealer 分支保持原样。 */if(op==='d'||op==='x')return false;if(op==='e')return fbEditOn(rp,fid);/* ★ 2026-09-18 第 68 轮(B 方案)e 已改绑「本页编辑权」(见 PAGE_EDIT_KEY / fbEditOn),下面第 67 轮注释所述口径已被覆盖,仅留档。 */ /* ★ 2026-09-18 第 67 轮(替换第 66 轮 D5 Ⓐ 的 e 短路):e(编辑)改回「跟随页级功能权限」,两条依据 —— ① 页级 edit_* 是前端入口总闸:页级关闭时用户根本进不了编辑表单,字段级 e 无从生效,矩阵画 ✔ 就是假授权;② 跨页闸门(c_followups.e 的闸键 follow_customers 属「📝 跟进记录」页)原先一律写成「需先开上方」指错了地方 ⇒ 用户报障「上方已开,为啥还显示先开上方」。现口径:e 未声明 ⇒ 回退页级链(rp[闸键] ?? OPERATOR_DEFAULT[闸键]),与 v 列同源;显式值仍在最前,优先于一切。 */const info=FIELDS[fid];if(!info)return true;const fb=info.fb[op];if(!fb)return true;return rp[fb]!==undefined?!!rp[fb]:(OPERATOR_DEFAULT[fb]===true);}
function renderPermDiag(){const el=document.getElementById('permDiagBar');if(!el)return;const d=window.__permDiag||{};if(!d.status){el.style.display='none';el.textContent='';return;}el.style.display='block';el.className='perm-diag perm-diag-'+d.status;el.textContent=d.text;}
function clearFieldPerm(role,fid,op){const rp=pdRp(role);if(rp.fields&&rp.fields[fid]&&rp.fields[fid][op]!==undefined){delete rp.fields[fid][op];pdMark();toast('已恢复跟随:'+((FIELDS[fid]||{}).label||fid)+' · '+opLabel(op)+'(草稿,点「💾 保存权限」生效)');renderPerms();}}
function setFieldPerm(role,fid,op,val){const rp=pdRp(role);if(!rp.fields[fid])rp.fields[fid]={};rp.fields[fid][op]=val;pdMark();const f=FIELDS[fid]||{};const lbl=(f.label||fid)+' · '+opLabel(op);toast((val==='hidden'?'已隐藏:':(val==='mask'?'已设为脱敏:':(val?'已授权:':'已取消:')))+lbl+'(草稿,点「💾 保存权限」生效)');}
function fmtPhone(p){if(!p)return'—';if(!currentUser)return p;const lv=fLevel('phone');if(lv==='full')return p;if(lv==='hidden')return'****';const s=String(p);if(/^1\d{10}$/.test(s))return s.slice(0,3)+'****'+s.slice(7);if(s.length>=6)return s.slice(0,3)+'****'+s.slice(-2);return'****';}
function phoneEditField(id,val,label){if(canField('phone','e'))return`<div class="field"><label>${label}</label><input id="${id}" value="${val||''}" placeholder="选填"></div>`;return`<div class="field"><label>${label}</label><input value="${val?fmtPhone(val):''}" disabled placeholder="无查看手机号权限"></div>`;}
/* ★ 第 89 轮 · 模块工具栏「数据导出」分派表(修 P1/P2/P3)
P1:exportModule 的 finance 分支曾调到全文不存在的 finExport ⇒ 点击完全无反应(现转发 openFinExport);
P2:mountModuleTools 无差别给所有视图挂按钮,而 exportModule 只实现 12 个分支键
⇒ 未实现者点了只弹「该模块暂不支持数据导出」(假按钮);现按登记表渲染,未登记者不渲染;
P3:按钮可见性原统一用 export_data,与真实导出键错配 ⇒ 改为按模块对齐真实键。
口径:登记表内 = 有导出能力;未登记 = 无按钮(宁缺勿假)。
⚠️ 经销商端(mode==='dealer')一律保持原判定 dealerCanExport() 不变 —— 实测其 /api/db 已按经销商
过滤(本店可见 1 家经销商 / 其名下客户),无跨租户暴露,故不擅自收紧。 */
var MOD_EXPORT_DISPATCH={dashboard:'exportModule',dealers:'exportModule',customers:'exportModule',followups:'exportModule',delivery:'exportModule',finance:'openFinExport',reports:'exportModule',rules:'exportModule',users:'exportModule',perms:'exportModule'};
/* ★ 第 90 轮 · 遗留1:本表是「导出键」的**唯一事实来源** —— 按钮可见性(moduleToolsHTML) 与 exportModule 内部守卫共用同一张表,
避免两处各写一套导致漂移(第 89 轮遗留:可见性已对齐真实键,但函数内部守卫仍硬编码 export_data)。 */
/* ★ 第 91 轮 · 遗留(双源统一):本表**不再硬编码**,改为从 PAGE_PERMS[*].export[0] **派生**
⇒ PAGE_PERMS 成为「导出键」的唯一事实来源(第 90 轮遗留:本表与 PAGE_PERMS 内容一致,但属两张表 = 潜在双源)。 */
var MOD_EXPORT_PERM=(function(){var _m={};PAGE_PERMS.forEach(function(_p){if(_p&&_p.export&&_p.export.length)_m[_p.id]=_p.export[0];});return _m;})();
/* ★ 第 272 轮:统一「导出范围」弹框(照财务收款 openFinExport 范式)—— 默认「当前筛选结果」。
范围为「全局一次有效」:点导出 ⇒ 弹框 ⇒ 选定后写入 _EXP_SCOPE ⇒ 回调无参重入原导出入口 ⇒ 出口读完即清空。
这样做的好处:所有导出函数**签名一字不改**(既有守卫按精确串切片,改签名会连带判红)。 */
var EXPORT_SCOPE_REG={};
var _EXP_SCOPE=null;
var _expScopeCb=null;
function openExportScope(cfg){
var unit=cfg.unit||'条';
var h='';
h+='<div class="modal-head"><h3>📥 导出 '+esc(String(cfg.title||''))+'</h3><span class="x" onclick="closeModal()">×</span></div>';
h+='<div class="modal-body">';
h+='<div style="margin-bottom:14px"><div style="font-size:12.5px;font-weight:600;color:#334155;margin-bottom:6px;letter-spacing:.2px">导出范围</div>';
h+='<div style="border:1px solid var(--line);border-radius:9px;padding:10px 12px;margin-bottom:8px"><label style="display:flex;align-items:center;gap:8px;cursor:pointer"><input type="radio" name="ex_scope" id="ex_scope_filter" value="filter" checked style="flex:none;width:auto;margin:0"> <span>当前筛选结果 <b>'+cfg.filterN+'</b> '+unit+'</span></label><div class="mtxt" style="margin-left:24px;margin-top:2px">'+esc(cfg.filterNote||'包含当前页面的筛选条件与搜索关键字')+'</div></div>';
h+='<div style="border:1px solid var(--line);border-radius:9px;padding:10px 12px"><label style="display:flex;align-items:center;gap:8px;cursor:pointer"><input type="radio" name="ex_scope" id="ex_scope_all" value="all" style="flex:none;width:auto;margin:0"> <span>全部(忽略筛选) <b>'+cfg.allN+'</b> '+unit+'</span></label><div class="mtxt" style="margin-left:24px;margin-top:2px">不受任何筛选与关键字影响</div></div>';
h+='</div>';
h+='<div class="mtxt" style="margin-top:4px">导出为 CSV(UTF-8 带 BOM,可直接用 Excel 打开)。'+(cfg.desc||'')+'</div>';
if(cfg.warn)h+='<div class="warn-box" style="margin-top:8px">'+cfg.warn+'</div>';
h+='</div>';
h+='<div class="modal-foot"><button class="btn ghost" onclick="closeModal()">取消</button><button class="btn" onclick="execExportScope()">📥 导出 CSV</button></div>';
_expScopeCb=(typeof cfg.onExport==='function')?cfg.onExport:null;
openModal(h);
}
function execExportScope(){
var f=document.getElementById('ex_scope_filter');
var scope=(f&&f.checked)?'filter':'all';
var cb=_expScopeCb;_expScopeCb=null;closeModal();
if(typeof cb==='function'){_EXP_SCOPE=scope;try{cb();}catch(e){toast('导出失败:'+((e&&e.message)||e));}finally{_EXP_SCOPE=null;}}
}
/* 登记助手:把「当前筛选 / 全部」两口登记给某个导出 key(渲染时调用 ⇒ 永远与屏幕同步) */
function regExportScope(key,o){if(!key)return;EXPORT_SCOPE_REG[key]=o||null;}
function expScopeWarnAmount(){return fLevel('amount')!=='full'?'当前角色未开放「查看金额」字段权限,导出的金额列将为空。':'';}
/* ---- 各页「当前筛选」取数口(与渲染同源,供范围框计数与取数) ---- */
function custSearchBlob(c){
return ((c.custNo||'')+' '+(fTxt('c_name',c.name))+' '+(fLevel('c_school')==='full'?(c.school||''):'')+' '+(fLevel('phone')==='full'?(c.phone||''):'')+' '+(fLevel('addr')==='full'?([c.prov,c.city,c.district,c.addr].filter(Boolean).join(' ')):'')+' '+(fLevel('d_name')==='full'?svcTeacherText(c):'')+' '+(fLevel('d_name')==='full'?svcStaffText(c):'')+' '+itemNamesOf(c)+' '+orderKindTextOf(c)).toLowerCase().replace(/"/g,'');
}
/* ★ 第490轮:客户「跟进」展示与查询的唯一口径(禁在别处手写)。
─ custFollowStat(c):跟进条数 / 最近一条 / 最近跟进日 / 最近一次安排的下次跟进日;
─ followAgoDays(lastAt,today):距今天数(无记录或日期非法 ⇒ -1);
─ followLevelOf(days):色阶 none(从未/非法) / ok(≤7天) / mid(8~30天) / late(超30天);
─ followStateHit(c,state,today):高级筛选「跟进情况」的唯一判定;
─ custFollowCellHtml(c):客户列表「最近跟进」列(受 c_followups 字段可见范围约束)。
本块只读 c.followups(跟进记录结构未变),不写库、不参与任何金额计算。 */
function custFollowIsManual(f){
if(!f)return false;
var o=String(f.operator||'').trim();
if(o.indexOf('系统自动同步')>=0)return false;
if(!o&&String(f.method||'')==='报备系统')return false;
return true;
}
function custFollowStat(c){
var arr=(c&&Array.isArray(c.followups))?c.followups:[];
var st={n:arr.length,last:null,lastAt:'',nextAt:'',lastM:null,lastMAt:''};var nxDate='';
for(var i=0;i<arr.length;i++){
var f=arr[i]||{};var d=String(f.date||'').slice(0,10);
if(!st.last||d>=st.lastAt){st.last=f;st.lastAt=d;}
if(custFollowIsManual(f)&&(!st.lastM||d>=st.lastMAt)){st.lastM=f;st.lastMAt=d;}
if(f.nextAt&&d>=nxDate){nxDate=d;st.nextAt=String(f.nextAt).slice(0,10);}
}
return st;
}
function followAgoDays(lastAt,todayStr){
if(!lastAt||!todayStr)return -1;
var _u=function(s){var p=String(s).slice(0,10).split('-');if(p.length!==3)return NaN;var y=+p[0],m=+p[1],d=+p[2];if(!y||m<1||m>12||d<1||d>31)return NaN;var t0=Date.UTC(y,m-1,d);var dt0=new Date(t0);if(dt0.getUTCFullYear()!==y||dt0.getUTCMonth()!==m-1||dt0.getUTCDate()!==d)return NaN;return t0;};
var a=_u(lastAt),b=_u(todayStr);if(isNaN(a)||isNaN(b))return -1;
return Math.round((b-a)/86400000);
}
function followLevelOf(days){if(days<0)return 'none';if(days<=7)return 'ok';if(days<=30)return 'mid';return 'late';}
function followStateHit(c,state,todayStr){
var st=custFollowStat(c),days=st.lastAt?followAgoDays(st.lastAt,todayStr):-1;
if(state==='none')return days<0;
if(state==='d7')return days>=0&&days<=7;
if(state==='d30')return days>=0&&days<=30;
if(state==='over30')return days>30;
if(state==='hasNext')return !!st.nextAt;
if(state==='nextOver')return !!st.nextAt&&st.nextAt<todayStr;
return true;
}
function followOperatorOf(c){var st=custFollowStat(c);return st.last?String(st.last.operator||'').trim():'';}
function followOperatorItems(){var m={};(DB.customers||[]).forEach(function(c){(c.followups||[]).forEach(function(f){var o=String((f&&f.operator)||'').trim();if(o)m[o]=1;});});return Object.keys(m).sort().map(function(x){return {id:x,name:x};});}
function followStateOpts(){
var cur=custFilter.followState||'';
var a=[['','全部'],['none','从未跟进'],['d7','7 天内跟过'],['d30','30 天内跟过'],['over30','超 30 天未跟进'],['hasNext','已设下次跟进'],['nextOver','下次跟进已逾期']];
return a.map(function(x){return '<option value="'+x[0]+'"'+(cur===x[0]?' selected':'')+'>'+x[1]+'</option>';}).join('');
}
function custFollowCellHtml(c){
if(typeof fLevel==='function'&&fLevel('c_followups')==='hidden')return '<span class="mtxt">—</span>';
var st=custFollowStat(c);
if(!st.n)return '<span class="st st-red">● 从未跟进</span>';
var full=(typeof fLevel!=='function')||fLevel('c_followups')==='full';
var f=st.lastM||st.last;
var at=String((f&&f.date)||st.lastAt||'').slice(0,10);
var days=followAgoDays(at,today());var lv=followLevelOf(days);
var cls=lv==='ok'?'st-green':(lv==='mid'?'st-yellow':'st-red');
var raw=String((f&&f.content)||'').replace(/\s+/g,' ').trim();
var txt;
if(!full)txt='***';
else if(st.lastM)txt=(raw?(raw.length>18?(raw.slice(0,18)+'…'):raw):'(无内容)');
else txt='📮 报备 '+at.slice(5);
var who=full?String((f&&f.operator)||''):'***';
var ago=days<0?'—':(days===0?'今天':days+' 天前');
var tip='最近跟进:'+at+(who?(' · '+who):'')+'('+ago+')|共 '+st.n+' 条'+((full&&raw)?('|'+raw):'')+(st.nextAt?('|下次跟进 '+st.nextAt):'');
return '<span class="st '+cls+'" style="display:inline-block;max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;vertical-align:bottom" title="'+esc(tip)+'">'+esc(txt)+'</span>';
}
function custFollowTodoList(){return (DB.customers||[]).filter(function(c){return (c.stage==='reported'||c.stage==='following')&&!isProtectionExpired(c);});}
function custFollowOrdered(list){
return list.slice().sort(function(a,b){
var sa=custFollowStat(a).lastAt||'',sb=custFollowStat(b).lastAt||'';
if(!sa&&sb)return -1;if(sa&&!sb)return 1;
if(sa!==sb)return sa<sb?-1:1;
return String(b.reportDate||'').localeCompare(String(a.reportDate||''));
});
}
function openCustFollow(cid){
if(typeof fLevel==='function'&&fLevel('c_followups')==='hidden'){toast('无权限:不能查看跟进记录');return;}
openCustomerFollowup(cid);
}
/* 转介绍客户页:当前筛选(高级筛选 + 搜索关键字)后的档案列表 —— 与屏幕完全同源 */
function custFilteredList(withKw){
let list=DB.customers.slice().sort((a,b)=>new Date(b.reportDate)-new Date(a.reportDate));
if(custFilter.dealer)list=list.filter(c=>c.dealerId===custFilter.dealer);
if(custFilter.stage)list=list.filter(c=>c.stage===custFilter.stage);
if(custFilter.staff)list=list.filter(c=>c.serviceStaffId===custFilter.staff);
if(custFilter.teacher)list=list.filter(c=>(Array.isArray(c.serviceTeacherIds)?c.serviceTeacherIds:[]).indexOf(custFilter.teacher)>=0);
if(custFilter.mine)list=list.filter(function(c){return svcTeacherMine(c);});
if(custFilter.intent)list=list.filter(c=>Array.isArray(c.intent)?c.intent.includes(custFilter.intent):c.intent===custFilter.intent);
if(custFilter.orderKind)list=list.filter(c=>orderKindOf(c)===custFilter.orderKind);
if(custFilter.contract==='1')list=list.filter(c=>!!c.contractSigned);else if(custFilter.contract==='0')list=list.filter(c=>!c.contractSigned);
if(custFilter.install==='1')list=list.filter(c=>c.installment);else if(custFilter.install==='0')list=list.filter(c=>!c.installment);
if(custFilter.refund==='1')list=list.filter(c=>c.refunded);else if(custFilter.refund==='0')list=list.filter(c=>!c.refunded);
if(custFilter.reportStart)list=list.filter(c=>c.reportDate&&c.reportDate>=custFilter.reportStart);
if(custFilter.reportEnd)list=list.filter(c=>c.reportDate&&c.reportDate<=custFilter.reportEnd);
if(custFilter.signStart)list=list.filter(c=>c.signDate&&c.signDate>=custFilter.signStart);
if(custFilter.signEnd)list=list.filter(c=>c.signDate&&c.signDate<=custFilter.signEnd);
if(fLevel('amount')==='full'){if(custFilter.paidMin!=='')list=list.filter(c=>paidOf(c)>=(+custFilter.paidMin));if(custFilter.paidMax!=='')list=list.filter(c=>paidOf(c)<=(+custFilter.paidMax));}
if(custFilter.followKw&&fLevel('c_followups')==='full'){const fq=custFilter.followKw.trim().toLowerCase();list=list.filter(c=>(c.followups||[]).some(f=>((f.content||'')+' '+(f.method||'')+' '+(f.operator||'')).toLowerCase().includes(fq)));}
if(custFilter.payKw){const pq=custFilter.payKw.trim().toLowerCase();list=list.filter(c=>(c.payments||[]).some(p=>((p.note||'')+' '+(p.method||'')).toLowerCase().includes(pq)));}
if(custFilter.followState){const _ft=today();list=list.filter(c=>followStateHit(c,custFilter.followState,_ft));}
if(custFilter.followStaff)list=list.filter(c=>followOperatorOf(c)===custFilter.followStaff);
if(custFilter.followFrom)list=list.filter(c=>{const s=custFollowStat(c);return !!s.lastAt&&s.lastAt>=custFilter.followFrom;});
if(custFilter.followTo)list=list.filter(c=>{const s=custFollowStat(c);return !!s.lastAt&&s.lastAt<=custFilter.followTo;});
if(custFilter.addrKw){const aq=custFilter.addrKw.trim().toLowerCase();list=list.filter(c=>((c.prov||'')+' '+(c.city||'')+' '+(c.district||'')+' '+(c.addr||'')).toLowerCase().includes(aq));}
const kw=String(custFilter.kw||'').trim().toLowerCase();
if(withKw!==false&&kw)list=list.filter(c=>custSearchBlob(c).indexOf(kw)>=0);
return list;
}
/* 经销商页:行可搜文本 + 当前筛选列表(与 renderDealers 生效版同源) */
function dealerSearchBlob(d){
return (fTxt('d_name',d.name)+' '+(fLevel('d_type')==='full'?(d.region||''):'')+' '+(fLevel('phone')==='full'?(d.phone||''):'')+' '+(fLevel('addr')==='full'?([d.prov,d.city,d.district,d.addr].filter(Boolean).join(' ')):'')).replace(/"/g,'').toLowerCase();
}
function dealerFilteredList(withKw){
let list=DB.dealers.slice();
list=list.filter(d=>chipHit(dealerFilter.type,d.type));
if(dealerFilter.stage)list=list.filter(d=>d.stage===dealerFilter.stage);
if(dealerFilter.contract==='1')list=list.filter(d=>!!d.contractSigned);else if(dealerFilter.contract==='0')list=list.filter(d=>!d.contractSigned);
if(dealerFilter.signStart)list=list.filter(d=>d.signDate&&d.signDate>=dealerFilter.signStart);
if(dealerFilter.signEnd)list=list.filter(d=>d.signDate&&d.signDate<=dealerFilter.signEnd);
if(dealerFilter.addrKw){const aq=dealerFilter.addrKw.trim().toLowerCase();list=list.filter(d=>((d.prov||'')+' '+(d.city||'')+' '+(d.district||'')+' '+(d.addr||'')).toLowerCase().includes(aq));}
const q=String(dealerFilter.q||'').trim().toLowerCase();
if(withKw!==false&&q)list=list.filter(d=>dealerSearchBlob(d).indexOf(q)>=0);
return list;
}
/* 跟进页:全量记录 / 当前筛选记录(范围 + 经销商 + 客户 + 关键字) */
function followAllRecords(){
const rows=[];
DB.dealers.filter(d=>d.type!=="staff").forEach(d=>(d.followups||[]).forEach(f=>rows.push(Object.assign({},f,{kind:'经销商',who:d.name,dealer:d,customer:null}))));
DB.customers.forEach(c=>{const d=DB.dealers.find(x=>x.id===c.dealerId);(c.followups||[]).forEach(f=>rows.push(Object.assign({},f,{kind:'客户',who:(d?d.name+'·':'')+c.name,dealer:d,customer:c})));});
return rows;
}
function followRecordSearchBlob(it){
const kw=`${it.content} ${it.operator||''} ${it.method||''} ${it.customer?it.customer.name+' '+(it.customer.school||'')+' '+(it.customer.phone||''):''} ${it.dealer?it.dealer.name+' '+(it.dealer.phone||''):''} ${it.nextAt||''}`;
return kw.replace(/"/g,'').toLowerCase();
}
function followFilteredRecords(){
const scope=followFilter.scope;const rows=[];
if(scope==='dealer'){DB.dealers.filter(d=>d.type!=="staff").forEach(d=>{if(followFilter.dealer&&d.id!==followFilter.dealer)return;(d.followups||[]).forEach(f=>rows.push(Object.assign({},f,{kind:'经销商',who:d.name,dealer:d,customer:null})));});}
else{DB.customers.forEach(c=>{const d=DB.dealers.find(x=>x.id===c.dealerId);if(followFilter.dealer&&c.dealerId!==followFilter.dealer)return;if(followFilter.customer&&c.id!==followFilter.customer)return;(c.followups||[]).forEach(f=>rows.push(Object.assign({},f,{kind:'客户',who:(d?d.name+'·':'')+c.name,dealer:d,customer:c})));});}
const q=String(followFilter.q||'').trim().toLowerCase();
return q?rows.filter(it=>followRecordSearchBlob(it).indexOf(q)>=0):rows;
}
function moduleToolsHTML(key){
var _g=`<button class="btn ghost sm" onclick="openModuleGuide('${key}')">📖 资料</button>`;
if(mode==='dealer')return dealerCanExport()?_g+`<button class="btn ghost sm" onclick="exportModule('${key}')">⬇️ 数据导出</button>`:_g;
/* ★ 第 90 轮 · 遗留2:分润来源明细是 dealers 视图的子标签页,其 topbar 已有专用「导出分润明细」按钮
⇒ 此处不再重复挂「数据导出」(否则同页两按钮、文案相近、数据不同,极易误点)。 */
if(key==='dealers'&&typeof dealerPageTab!=='undefined'&&dealerPageTab==='comm')return _g;
var _fn=MOD_EXPORT_DISPATCH[key]||'';
if(!_fn)return _g;
if(!can(MOD_EXPORT_PERM[key]||'export_data'))return _g;
return _g+(_fn==='exportModule'?`<button class="btn ghost sm" onclick="exportModule('${key}')">⬇️ 数据导出</button>`:`<button class="btn ghost sm" onclick="${_fn}()">⬇️ 数据导出</button>`);
}
function mountModuleTools(key,sel){const root=sel?document.querySelector(sel):document;const tb=root?root.querySelector('.topbar'):null;if(!tb||tb.querySelector('.mod-tools'))return;const el=document.createElement('div');el.className='mod-tools';el.style.cssText='display:flex;gap:8px;margin-left:auto;flex-wrap:wrap;align-items:center';el.innerHTML=moduleToolsHTML(key);tb.appendChild(el);}
function injectGuideStyles(){if(document.getElementById('guideStyle'))return;const s=document.createElement('style');s.id='guideStyle';s.textContent=`
.guide-body{padding:18px 20px;line-height:1.65}
.guide-body p{margin:0 0 10px}
.guide-body .section-title{margin:16px 0 8px;font-size:13.5px;font-weight:700;color:var(--primary)}
.guide-ul{margin:0 0 8px;padding-left:20px}
.guide-ul li{margin:5px 0;font-size:13.5px;color:#334155}
.guide-body b{color:#0f172a}`;document.head.appendChild(s);}
function openModuleGuide(key){injectGuideStyles();const g=MODULE_GUIDES[key]||(mode==='dealer'?MODULE_GUIDES['dp_'+key]:null)||{title:'资料',html:'<p class="mtxt">该模块暂无说明资料。</p>'};openModal(`
<div class="modal-head"><h3>📖 ${g.title}</h3><span class="x" onclick="closeModal()">×</span></div>
<div class="modal-body guide-body">${g.html}</div>
<div class="modal-foot"><button class="btn ghost" onclick="closeModal()">关闭</button></div>`);}
const MODULE_GUIDES={dashboard:{title:'数据仪表盘 · 资料',html:`
<p class="mtxt">汇总经销商、客户、交付、分润的核心指标,随数据实时计算。</p>
<div class="section-title">关键指标</div>
<ul class="guide-ul">
<li><b>报备 / 体验 / 全款成交</b>:客户在不同阶段的累计数量。</li>
<li><b>应发分润</b>:按各经销商类型分润比例核算的合计金额。</li>
<li><b>交付完成率</b>:总部交付服务整体完成进度。</li>
</ul>
<div class="section-title">小贴士</div>
<ul class="guide-ul"><li>无仪表盘权限的账号进入系统后会自动跳转到其首个有权限的模块。</li></ul>`},dealers:{title:'经销商管理 · 资料',html:`
<p class="mtxt">管理四类经销商的档案、考核与合同签署状态。</p>
<div class="section-title">四类经销商</div>
<ul class="guide-ul">
<li><b>资源渠道</b>:预缴 0,分润 20%,年度考核 ≥10 家全款成交达标。</li>
<li><b>体验官</b>:预缴 0,自带客户成交分润 20%,≥5 家达标。</li>
<li><b>校区</b>:缴保证金(前 5 家仅抵扣自用时长),第 6 家起 20% 现金分润,≥5 家达标。</li>
<li><b>官方事业</b>:缴加盟费,分润 30%,≥10 家达标。</li>
</ul>
<div class="section-title">字段说明</div>
<ul class="guide-ul">
<li><b>预缴费用 / 分润比例</b>:由经销商类型决定。</li>
<li><b>报备 / 全款成交</b>:该经销商名下客户转化计数。</li>
<li><b>已签合同</b>:在列表直接点击「已签/未签」即可切换签约状态。</li>
</ul>
<ul class="guide-ul"><li>操作按钮(详情/跟进、编辑、删除)受角色权限控制。</li></ul>`},customers:{title:'转介绍客户 · 资料',html:`
<p class="mtxt">统一文字报备转介绍客户,系统按"首次报备时间"判定归属。</p>
<ul class="guide-ul">
<li><b>归属判定</b>:重复报备同一客户会提示冲突,且不计入后报备者的业绩。</li>
<li><b>保护周期</b>:报备后 ${PROTECTION_DAYS} 天保护期,过期未成交自动标记"保护过期流失"。</li>
<li><b>状态流转</b>:报备 → 跟进 → 体验 → 全款 / 分期成交;退费会退回对应分润。</li>
<li><b>已签合同</b>:在列表直接点击「已签/未签」即可切换签约状态。</li>
</ul>`},followups:{title:'跟进记录 · 资料',html:`
<p class="mtxt">经销商与客户的独立跟进时间线,可按主体筛选、关键词查询。</p>
<ul class="guide-ul">
<li><b>记录字段</b>:时间、方式(微信/电话/面谈…)、跟进人、内容、下次跟进提醒。</li>
<li>经销商跟进与客户跟进分别归集,便于总部统一掌握推进节奏。</li>
</ul>`},delivery:{title:'总部交付服务 · 资料',html:`
<p class="mtxt">总部向经销商 / 客户交付「元墨XR·硬笔课程合作包」各项服务。</p>
<ul class="guide-ul">
<li><b>服务状态</b>:待交付 / 部分交付 / 已交付,并汇总完成度。</li>
<li><b>服务项</b>:场景安装、线上开通、师训、体验教材、到校帮扶等。</li>
<li>点击矩阵单元格可记录交付明细,例如<b>到校帮扶</b>可记录每次开始 / 结束时间与反馈表附件。</li>
</ul>`},finance:{title:'财务收款 · 资料',html:`
<p class="mtxt">经销商合同 / 保证金,以及转介绍客户合同的交款与欠款管理。</p>
<ul class="guide-ul">
<li><b>经销商合同</b>:合同总额(授权后可在线修改)、分次登记交款、欠款、下次补款提醒。</li>
<li><b>客户合同</b>:全款 / 分期金额、交款、欠款。</li>
<li>仅拥有"登记 / 管理交款"权限的人员或本人可登记交款。</li>
</ul>`},reports:{title:'转化·成交报表 · 资料',html:`
<p class="mtxt">按经销商统计转化率、成交率与分润核算,用于月度对账。</p>
<ul class="guide-ul">
<li><b>漏斗</b>:报备 → 体验 → 全款成交 → 合计成交。</li>
<li><b>应发分润</b>:按各类型分润比例自动核算。</li>
<li>可使用顶部"数据导出"导出全量报表。</li>
</ul>`},rules:{title:'业务规则红线 · 资料',html:`
<p class="mtxt">内置《元墨VR经销商权益说明》核心条款,系统自动执行校验。</p>
<ul class="guide-ul">
<li><b>保护周期</b>:${PROTECTION_DAYS} 天,过期未成交视为流失。</li>
<li><b>考核门槛 / 分润比例</b>:以系统参数为准,详见各经销商类型说明。</li>
</ul>`},users:{title:'用户与权限 · 资料',html:`
<p class="mtxt">管理后台登录账号,分配管理员 / 操作员等角色。</p>
<ul class="guide-ul">
<li><b>启用 / 停用</b>:停用后该账号会被强制退出。</li>
<li>账号角色决定其可访问的模块与可执行的操作。</li>
</ul>`},perms:{title:'角色权限设置 · 资料',html:`
<p class="mtxt">为各角色分别配置可执行的操作权限,共 31 项细粒度权限,按 10 个分组管理。</p>
<ul class="guide-ul">
<li><b>管理员</b>角色始终拥有全部权限,不可在此修改或删除。</li>
<li>可<b>新增 / 重命名 / 删除</b>自定义角色;每组权限支持「全选 / 全不选」一键配置。</li>
<li>典型拆分示例:财务专员(仅 view_finance / add_finance / export_reconcile)、区域经理(经销商/客户增改但无删除与财务金额可见)、资料审核(仅 view_* 查看类)。</li>
<li>修改即时保存,已登录用户重新打开对应页面即按新权限自动拦截。</li>
</ul>`},dp_dashboard:{title:'我的看板 · 资料',html:`
<p class="mtxt">实时掌握您报备客户的转化与应得分润。</p>
<ul class="guide-ul">
<li>展示我的客户数、待跟进、已成交、应发分润与考核进度。</li>
<li>数据随报备与跟进实时更新。</li>
</ul>`},dp_report:{title:'报备转介绍客户 · 资料',html:`
<p class="mtxt">提交转介绍客户报备,系统按首次报备时间判定归属。</p>
<ul class="guide-ul">
<li><b>报备日期</b>由系统自动填写,不允许手动修改。</li>
<li>地址支持自动定位,自动填写省 / 市 / 区 / 详细地址。</li>
<li>重复报备同一客户会提示冲突。</li>
</ul>`},dp_customers:{title:'我的客户 · 资料',html:`
<p class="mtxt">您报备的全部转介绍客户及推进状态。</p>
<ul class="guide-ul">
<li>可查看每个客户的跟进时间线与合同签约状态。</li>
<li>状态由总部跟进更新,您可补充跟进记录。</li>
</ul>`},dp_followups:{title:'我的跟进 · 资料',html:`
<p class="mtxt">您名下客户的全部跟进时间线。</p>
<ul class="guide-ul">
<li>可添加跟进记录:方式、内容、下次跟进时间(用于提醒)。</li>
</ul>`},dp_finance:{title:'我的财务 · 资料',html:`
<p class="mtxt">您与总部的合同 / 保证金明细。</p>
<ul class="guide-ul">
<li>展示合同总额、已交款、欠款与下次补款提醒。</li>
<li>可分批登记交款,记录每笔交款日期与方式。</li>
</ul>`},dp_benefits:{title:'我的权益 · 资料',html:`
<p class="mtxt">您所签约经销商类型的权益与考核标准。</p>
<ul class="guide-ul">
<li>包含预缴费用、基础分润比例与考核门槛说明。</li>
<li>考核达标后方可享受对应分润比例。</li>
</ul>`},todos:{title:'待办事项 · 资料',html:`<p class="mtxt">根据提醒规则自动汇总出的今日待办,按事件类型分组。</p><div class="section-title">使用说明</div><ul class="guide-ul"><li>条目由「消息提醒」的规则生成,不需手工建。</li><li>勾选后条目置为已完成,仅影响本人视图。</li><li>左侧菜单「待办事项」旁的红色数字 = 未完成数量。</li></ul>`},helpon:{title:'到校帮扶 · 资料',html:`<p class="mtxt">记录到校帮扶的现场照片与帮扶单,支持图片识别自动填表。</p><div class="section-title">操作要点</div><ul class="guide-ul"><li>上传现场照片,系统识别学校/帮扶内容,识别结果可手动修正。</li><li>识别结果需确认后才入库,防止误录。</li><li>帮扶记录挂在对应客户档案下,可在客户详情查看历史。</li></ul>`},helpreport:{title:'到校帮扶成果 · 资料',html:`<p class="mtxt">汇总各客户的帮扶次数与成果,支持按客户下钻和全量导出。</p><ul class="guide-ul"><li>上方筛选可限定日期与客户。</li><li>「导出全部 CSV」= 当前筛选条件下的全量;客户行内导出 = 单客户。</li></ul>`},reminders:{title:'消息提醒 · 资料',html:`<p class="mtxt">配置各类业务事件的提醒规则(提前天数、受众、推送方式)。</p><div class="section-title">规则要点</div><ul class="guide-ul"><li>规则决定「待办事项」里出现哪些条目。</li><li>支持企业微信群机器人推送,需先在服务端配置 webhook。</li><li>修改规则后立即生效,无需重启。</li></ul>`},selfcheck:{title:'系统自检 · 资料',html:`<p class="mtxt">一键体检:后端进程、数据库、审计日志、数据一致性,发现异常可一键修复。</p><ul class="guide-ul"><li>每项给出「正常/异常」与详情,红色为需处理。</li><li>「一键修复」只处理可安全修复项,不会动业务数据。</li><li>建议每周跑一次,排查前也先跑一次。</li></ul>`},audit:{title:'操作日志 · 资料',html:`<p class="mtxt">记录谁在什么时候改了什么:客户、交款、交付、权限等关键变更均留痕。</p><ul class="guide-ul"><li>按类型/操作人/关键字筛选,定位历史改动。</li><li>数据库整体替换类操作会汇总为一条,避免刷屏。</li></ul>`},serviceexpire:{title:'服务到期提醒 · 资料',html:`<p class="mtxt">按服务期限列出即将/已到期的客户,提前推送提醒。</p><ul class="guide-ul"><li>到期天数按项目的服务时长与签约日自动算。</li><li>可对单条做服务期调整(延长/缩短),需有对应权限。</li></ul>`},map:{title:'客户地图 · 资料',html:`<p class="mtxt">把客户按地址落到地图上,直观看地域分布与密集度。</p><ul class="guide-ul"><li>坐标不全或无效的客户计入「未定位」,需补齐地址。</li><li>支持按筛选条件聚焦某一区域。</li></ul>`},aireport:{title:'AI 报表 · 资料',html:`<p class="mtxt">用自然语言提问,系统在本地按口径计算并返回表格。</p><div class="section-title">三条硬规矩</div><ul class="guide-ul"><li>AI 只把问题转成「报表规格」,<b>所有数字一律本地算</b>,可审计。</li><li>客户姓名/电话等敏感信息不出境。</li><li>查询只读,不会修改任何数据。</li></ul>`},};function downloadCSV(name,csv){const blob=new Blob([''+csv],{type:'text/csv;charset=utf-8'});const a=document.createElement('a');a.href=URL.createObjectURL(blob);a.download=name;document.body.appendChild(a);a.click();setTimeout(()=>{try{URL.revokeObjectURL(a.href);a.remove();}catch(e){}},2000);toast('已导出:'+name);}
function exportModule(key){var _exk=(typeof MOD_EXPORT_PERM!=='undefined'&&MOD_EXPORT_PERM[key])||'export_data';var _exn={delivery:'导出总部交付服务',finance:'导出财务对账'}[key]||'导出模块数据 / 全量数据';if(mode!=='dealer'&&!can(_exk)){toast('无权限:导出数据需「'+_exn+'」权限');return;}
if(mode==='dealer'&&!dealerCanExport()){toast('无权限:经销商数据导出权限已关闭');return;}
/* ★ 第 272 轮:本页若已登记「当前筛选 / 全部」取数口,且本次未指定范围 ⇒ 先弹范围框(默认当前筛选)。 */
var _rg=(typeof EXPORT_SCOPE_REG!=='undefined')?EXPORT_SCOPE_REG[key]:null;
if(_rg&&_EXP_SCOPE===null){var _fl=_rg.list(),_al=_rg.all();openExportScope({title:_rg.title||'数据',unit:_rg.unit||'条',desc:_rg.desc||'',filterNote:_rg.filterNote||'',filterN:_fl.length,allN:_al.length,warn:(typeof _rg.warn==='function')?_rg.warn():(_rg.warn||''),onExport:function(){exportModule(key);}});return;}
var _src=(_EXP_SCOPE==='filter'&&_rg)?_rg.list():null;
const ph=p=>mode==='dealer'?dealerExpPhone(p):(fLevel('phone')==='full'?(p||''):fmtPhone(p));const amt=v=>mode==='dealer'?dealerExpAmount(v):v;const t=today();let csv='',name='导出_'+t+'.csv';if(key==='dealers'){name='经销商管理_'+t+'.csv';csv='经销商名称,类型,对接员工,区域,联系电话,联系地址,预缴费用,已交款,欠款,报备数,全款成交,应发分润,考核,已签合同,达成合作日期\n';(_src||DB.dealers).forEach(d=>{const s=dealerStats(d.id);const assess=assessTextOf(d);csv+=`${csvCell(fTxt('d_name',d.name))},${csvCell(fTxt('d_type',DT(d.type).name))},${csvCell(fTxt('d_name',dealerStaffTextOf(d)||'—'))},${csvCell(fTxt('d_type',d.region||'—'))},${csvCell(ph(d.phone))},${csvCell(csvF('addr',[d.prov,d.city,d.district,d.addr].filter(Boolean).join(' '),(d.prov||'')+(d.city||'')+'**'))},${csvCell(csvF('amount_dealer',Number(d.amount||0)))},${csvCell(csvF('amount',paidOf(d)))},${csvCell(csvF('amount',owedOf(d)))},${csvCell(s.reported)},${csvCell(s.signedFull)},${csvCell(csvF('amount_dealer',s.commission))},${csvCell(assess)},${csvCell(d.contractSigned?'是':'否')},${csvCell(fDateTxt('d_sign',d.sign
Date||''))}\n`;});}
else if(key==='customers'){if(mode==='dealer'){const d=DB.dealers.find(x=>x.id===curDealerId);if(!d){toast('未登录经销商');return;}
name='我的客户_'+t+'.csv';csv='客户姓名,校区/机构,联系电话,意向项目,订单/2 级项目,报备日期,状态,金额,分期,退费,已签合同,达成合作日期,联系地址\n';DB.customers.filter(c=>c.dealerId===d.id).forEach(c=>{csv+=`${csvCell(c.name)},${csvCell(c.school||'')},${csvCell(ph(c.phone))},${csvCell((Array.isArray(c.intent)?c.intent.join('/'):c.intent||''))},${csvCell(orderItemsTextOf(c))},${csvCell(c.reportDate||'')},${csvCell(fLevel('c_stage')==='full'?stageInfoOf(c.stage).label:(fLevel('c_stage')==='mask'?'***':'****'))},${csvCell(amt(Number(c.amount||0)))},${csvCell(c.installment?'是':'否')},${csvCell(c.refunded?'是':'否')},${csvCell(c.contractSigned?'是':'否')},${csvCell(c.signDate?(fLevel('c_sign')==='full'?c.signDate:fDateTxt('c_sign',c.signDate)):'')},${csvCell([c.prov,c.city,c.district,c.addr].filter(Boolean).join(' '))}\n`;});}else{name='转介绍客户_'+t+'.csv';csv='客户编号,客户姓名,归属经销商,对接员工,业绩归属,服务老师,校区/机构,联系电话,合作项目,订单/2 级项目,报备日期,状态,金额,分期,退费,已签合同,达成合作日期,联系地址,下次补款日期,跟进记录数,最近跟进日期,最近跟进人,交款笔数\n';(_src||DB.customers).forEach(c=>{const d=DB.dealers.find(x=>x.id===c.dealerId);const sst=DB.dealers.find(x=>x.id===c.serviceStaffId);csv+=`${csvCell(c.custNo||'')},${csvCell(fTxt('c_name',c.name))},${csvCell(fTxt('d_name',d?d.name:'—'))},${csvCell(fTxt('u_name',sst?sst.name:'—'))},${csvCell(fTxt('u_name',payOwnerText(c)||'—'))},${csvCell(fTxt('d_name',svcTeacherText(c)||'—'))},${csvCell(fTxt('c_school',c.school||'—'))},${csvCell(ph(c.phone))},${csvCell(fTxt('c_intent',(Array.isArray(c.intent)?c.intent.join('/'):c.intent||'—')))},${csvCell(orderItemsTextOf(c))},${csvCell(fDateTxt('c_report',c.reportDate||''))},${csvCell(fLevel('c_stage')==='full'?stageInfoOf(c.stage).label:(fLevel('c_stage')==='mask'?'***':'****'))},${csvCell(csvF('amount',Number(c.amount||0)))},${csvCell(c.installment?'是':'否')},${csvCell(c.refunded?'是':'否')},${csvCell(c.contractSigned?'是':'否')},${csvCell(c.signDate?(fLevel('c_sign')==='full'?c.signDate:fDateTxt('c_sign',c.signDate)):'')},${csvCell(csvF('addr',[c.prov,c.city,c.district,c.addr].filter(Boolean).join(' '),(c.prov||'')+(c.city||'')+'**'))},${csvCell(fDateTxt('c_nextpay',c.nextPayAt||''))},${csvCell((c.followups||[]).length)},${csvCell(fLevel('c_followups')==='hidden'?'':custFollowStat(c).lastAt)},${csvCell(fLevel('c_followups')==='full'?(followOperatorOf(c)||''):'')},${csvCell((c.payments||[]).length)}\n`;});}}
else if(key==='followups'){if(mode==='dealer'){const d=DB.dealers.find(x=>x.id===curDealerId);if(!d){toast('未登录经销商');return;}
name='我的跟进_'+t+'.csv';csv='时间,方式,跟进人,内容\n';(d.followups||[]).forEach(f=>{csv+=`${csvCell(f.date||'')},${csvCell(f.method||'')},${csvCell(f.operator||'')},${csvCell((f.content||'').replace(/\n/g,' '))}\n`;});}else{name='跟进记录_'+t+'.csv';csv='时间,方式,跟进人,内容,归属类型,关联主体\n';const rows=(_src||followAllRecords());rows.sort((a,b)=>new Date(b.date||0)-new Date(a.date||0));rows.forEach(f=>{csv+=`${csvCell(f.date||'')},${csvCell(f.method||'')},${csvCell(f.operator||'')},${csvCell(csvF('c_followups',(f.content||'').replace(/\n/g,' ')))},${csvCell(f.kind)},${csvCell(f.kind==='经销商'?csvF('d_name',f.who||''):csvF('c_name',f.who||''))}\n`;});}}
else if(key==='delivery'){name='总部交付服务_'+t+'.csv';csv='主体类型,主体名称,服务项目,状态,交付日期,操作员,备注\n';DB.deliveries.forEach(x=>{const svc=SERVICES.find(s=>s.id===x.serviceId);let oType='',oName='';if(x.dealerId){const d=DB.dealers.find(z=>z.id===x.dealerId);oType='经销商';oName=d?d.name:'';}
else if(x.customerId){const c=DB.customers.find(z=>z.id===x.customerId);oType='客户';oName=c?c.name:'';}
csv+=`${csvCell(oType)},${csvCell(oName)},${csvCell((svc&&fLevel('dv_service')!=='hidden')?(fLevel('dv_service')==='full'?svc.name:fTxt('dv_service',svc.name)):'')},${csvCell(fLevel('dv_status')==='full'?(x.status||''):(fLevel('dv_status')==='mask'?'***':'****'))},${csvCell(fDateTxt('dv_date',x.date||''))},${csvCell(csvF('dv_notes',x.operator||'—'))},${csvCell(csvF('dv_notes',(x.notes||'').replace(/\n/g,' ')))}\n`;});}
else if(key==='finance'){if(mode==='dealer'){const d=DB.dealers.find(x=>x.id===curDealerId);if(!d){toast('未登录经销商');return;}
name='我的财务_'+t+'.csv';csv='项目,金额,说明\n';const paid=paidOf(d),owed=owedOf(d);csv+=`合同/保证金总额,${csvCell(amt(Number(d.amount||0)))},与总部合同/保证金\n`;csv+=`已交款,${csvCell(amt(paid))},\n`;csv+=`欠款,${csvCell(amt(owed))},\n`;(d.payments||[]).forEach(p=>{csv+=`交款记录-${csvCell(p.date||'')},${csvCell(amt(Number(p.amount||0)))},${csvCell(p.method||'')} ${csvCell(p.note||'')}\n`;});DB.customers.filter(c=>c.dealerId===d.id).forEach(c=>{csv+=`客户-${csvCell(c.name)} 合同额,${csvCell(amt(Number(c.amount||0)))},\n`;csv+=`客户-${csvCell(c.name)} 已交,${csvCell(amt(paidOf(c)))},\n`;csv+=`客户-${csvCell(c.name)} 欠款,${csvCell(amt(owedOf(c)))},\n`;});}else{openFinExport();return;}}
else if(key==='reports'){name='转化成交报表_'+t+'.csv';csv='经销商,类型,报备,体验,全款成交,合计成交,应发分润\n';DB.dealers.forEach(d=>{const s=dealerStats(d.id);csv+=`${csvCell(d.name)},${csvCell(DT(d.type).name)},${csvCell(s.reported)},${csvCell(s.experienced)},${csvCell(s.signedFull)},${csvCell(s.signedAll)},${csvCell(s.commission)}\n`;});}
else if(key==='rules'){name='业务规则参数_'+t+'.csv';csv='参数,值,说明\n';csv+=`客户保护周期(天),${csvCell(PROTECTION_DAYS)},报备后保护期,过期未成交视为流失\n`;Object.entries(DEALER_TYPES).forEach(([k,c])=>{csv+=`${csvCell(c.name)} 预缴费用,${csvCell(c.fee)},${csvCell(c.note||'')}\n`;});Object.entries(DEALER_TYPES).forEach(([k,c])=>{csv+=`${csvCell(c.name)} 基础分润,${csvCell(pct(c.baseRate*100))}%,${csvCell(c.assessment||'')}\n`;});}
else if(key==='users'){name='用户与权限_'+t+'.csv';csv='姓名,登录账号,部门,角色,状态,电话\n';DB.users.forEach(u=>{const rs=(u&&Array.isArray(u.roles)&&u.roles.length)?u.roles:[];const rn=rs.length?rs.map(roleNameOf).join('/'):'无角色';csv+=`${csvCell(u.name)},${csvCell(u.username||u.phone||'')},${csvCell(deptNameOf(u.deptId))},${csvCell(rn)},${csvCell(u.active?'启用':'停用')},${csvCell(ph(u.phone))}\n`;});}
else if(key==='perms'){name='角色权限矩阵_'+t+'.csv';const roles=Object.keys(DB.rolePerms);const permKeys=PERMS.map(p=>p.key),permLabels=PERMS.map(p=>p.label);csv='角色,'+permLabels.map(csvCell).join(',')+'\n';roles.forEach(r=>{const rp=DB.rolePerms[r]||{};const rn=(DB.roleMeta[r]&&DB.roleMeta[r].name)||r;csv+=`${csvCell(rn)},`+permKeys.ma
p(k=>rp[k]?'是':'否').join(',')+'\n';});}
else if(key==='dashboard'){name='数据仪表盘汇总_'+t+'.csv';const g=globalStats();const dg=globalDelivery();csv='指标,值\n';csv+=`经销商总数,${csvCell(DB.dealers.filter(d=>d.type!=="staff").length)}\n`;csv+=`转介绍客户总数,${csvCell(DB.customers.length)}\n`;csv+=`全款成交客户,${csvCell(g.signedFull)}\n`;csv+=`应发分润合计,${csvCell(g.commission)}\n`;csv+=`交付完成率,${csvCell(dg.completion)}%\n`;}
else if(key==='report'){const d=DB.dealers.find(x=>x.id===curDealerId);if(!d){toast('未登录经销商');return;}
name='我的报备_'+t+'.csv';csv='客户姓名,校区/机构,联系电话,意向项目,订单/2 级项目,报备日期,状态,已签合同,联系地址\n';DB.customers.filter(c=>c.dealerId===d.id).forEach(c=>{csv+=`${csvCell(c.name)},${csvCell(c.school||'')},${csvCell(ph(c.phone))},${csvCell((Array.isArray(c.intent)?c.intent.join('/'):c.intent||''))},${csvCell(orderItemsTextOf(c))},${csvCell(c.reportDate||'')},${csvCell(fLevel('c_stage')==='full'?stageInfoOf(c.stage).label:(fLevel('c_stage')==='mask'?'***':'****'))},${csvCell(c.contractSigned?'是':'否')},${csvCell([c.prov,c.city,c.district,c.addr].filter(Boolean).join(' '))}\n`;});}
else if(key==='benefits'){const d=DB.dealers.find(x=>x.id===curDealerId);if(!d){toast('未登录经销商');return;}
const cfg=DT(d.type);const s=dealerStats(d.id);name='我的权益_'+t+'.csv';csv='项目,值\n';csv+=`经销商类型,${csvCell(cfg.name)}\n`;csv+=`预缴费用,${csvCell(cfg.fee)}\n`;csv+=`基础分润比例,${csvCell(pct(cfg.baseRate*100))}%\n`;csv+=`考核标准,${csvCell(cfg.assessment)}\n`;csv+=`我的报备客户,${csvCell(s.reported)}\n`;csv+=`我的全款成交,${csvCell(s.signedFull)}\n`;csv+=`我的应发分润,${csvCell(s.commission)}\n`;}
else{toast('该模块暂不支持数据导出');return;}
downloadCSV(name,csv);}
function fitTableLocks(rootSel){try{var root=rootSel?document.querySelector(rootSel):document.getElementById('view');if(root){Array.prototype.forEach.call(root.querySelectorAll('table'),function(t){if(t.closest('.modal')||t.closest('.table-card')||t.closest('.tbl-lock')||t.closest('#dlMatrixCard'))return;var wrap=document.createElement('div');wrap.className='tbl-lock';t.parentNode.insertBefore(wrap,t);wrap.appendChild(t);});}fitLockHeights();}catch(e){}}
/* ★ 第 272 轮 · A 方案:统一「重绘后补挂」守护。
根因:topbar 上的模块工具按钮(📖资料 / ⬇️数据导出)与表格限高只在 render() 末尾挂一次,
任何绕过 render() 的「内部整页重绘」(高级筛选、切子页签、切子视图、关键字重画…)都会把挂件连同旧内容一起删掉,无人补挂。
修法:MutationObserver 盯 #view —— 结构一变就自动补挂(幂等 + 自防递归),将来新增页面/新增入口自动免疫。 */
var _pcoTimer=null,_pcoBusy=false;
/* ★ 第 543 轮:把「补挂」拆两段 —— 挂按钮必须**同帧**(微任务里同步),重活(fitTableLocks)仍走 50ms 防抖。
根因:整块重绘后 50ms 才挂「📖资料/⬇️数据导出」⇒ 页头右侧按钮被推开约 170px,每次切子页都抖一下
(客户·报备待处理 / 已成交未签 / 经销商列表 的按钮最明显)。 */
function pageChromeMount(){try{if(typeof mode!=='undefined'&&mode==='dealer'){mountModuleTools((typeof curDealerView!=='undefined'?curDealerView:''),'#dealerBody');}else{var v=document.getElementById('view');if(v&&v.querySelector('.topbar')&&typeof curView!=='undefined'){mountModuleTools(curView);}}}catch(e){}}
function pageChromeApply(){if(_pcoBusy)return;_pcoBusy=true;try{pageChromeMount();if(typeof mode!=='undefined'&&mode==='dealer'){fitTableLocks('#dealerBody');}else{fitTableLocks();}}catch(e){}_pcoBusy=false;}
function pageChromeSchedule(){if(_pcoTimer)clearTimeout(_pcoTimer);_pcoTimer=setTimeout(function(){_pcoTimer=null;pageChromeApply();},50);}
function pageChromeInit(){var v=document.getElementById('view');if(!v||!window.MutationObserver)return;var mo=new MutationObserver(function(ms){for(var i=0;i<ms.length;i++){if(ms[i].addedNodes.length||ms[i].removedNodes.length){pageChromeMount();pageChromeSchedule();return;}}});mo.observe(v,{childList:true,subtree:true});}
function pageChromeBoot(){var v=document.getElementById('view');if(!v){setTimeout(pageChromeBoot,300);return;}pageChromeInit();}
pageChromeBoot();
function fitLockHeights(){try{var vh=window.innerHeight;document.querySelectorAll('.tbl-lock,.table-card,#dlMatrixCard').forEach(function(el){var r=el.getBoundingClientRect();if(!r.height&&!r.width)return;if(r.top<-2||r.top>vh*0.9)return;var h=vh-r.top-18;if(h<280)return;el.style.maxHeight=Math.round(h)+'px';});}catch(e){}}
window.addEventListener('resize',function(){clearTimeout(window._fitLockT);window._fitLockT=setTimeout(fitLockHeights,180);});
window.addEventListener('resize',function(){clearTimeout(window._mapRzT);window._mapRzT=setTimeout(function(){try{if(typeof mapLive==='function'){var _m=mapLive();if(_m)_m.invalidateSize();}}catch(e){}},200);});
window.addEventListener('scroll',function(){clearTimeout(window._fitLockS);window._fitLockS=setTimeout(fitLockHeights,160);},{passive:true});
/* ★ 第 394 轮:滚动位置「逐页记忆 · 精准恢复」——替换原「className 猜配对」实现(会把 A 页位置错套到 B 页)。
语义:渲染前 _snapScroll() 把「当前视图键」的位置存入 __posMemo;渲染后 _restoreScroll()
按「当前视图键」精准恢复(无记忆 ⇒ 视口回顶)。视图键 = curView(+财务/经销商/帮扶子标签),存于 #view[data-pos-key]。 */
var __posMemo={};
/* 第 661 轮:滚动恢复「代次守卫」。save 成功后可能连续两次 render(loadDB 一次 + 回调一次),
第二次会拍到「view.innerHTML 重建后浏览器把 scrollTop 归零」的位置,把正确位置覆盖成 0
⇒ 客户列表在「添加关联档案 / 新增订单」保存后跳回页面顶部。
语义:上一轮恢复还没跑完(rAF 未执行,300ms 内)⇒ 本次不重新拍快照,沿用上一轮的正确值。 */
var __posGuard=null;
function __posKeyOf(){try{var k=(typeof curView!=='undefined'?curView:'');if(k==='finance'&&typeof finSub!=='undefined')k+=':'+finSub;else if(k==='dealers'&&typeof dealerPageTab!=='undefined')k+=':'+dealerPageTab;else if(k==='helpreport'&&typeof hrSub!=='undefined')k+=':'+hrSub;return k;}catch(e){return '';}}
function __posEls(){var a=[];try{var els=document.querySelectorAll('#view *');for(var i=0;i<els.length;i++){var el=els[i];var needY=el.scrollHeight>el.clientHeight+1,needX=el.scrollWidth>el.clientWidth+1;if(needY||needX){var cs=getComputedStyle(el);if((needY&&(cs.overflowY==='auto'||cs.overflowY==='scroll'))||(needX&&(cs.overflowX==='auto'||cs.overflowX==='scroll')))a.push(el);}}}catch(e){}return a;}
function _snapScroll(){var key='';try{key=view.getAttribute('data-pos-key')||'';}catch(e){}var de=document.scrollingElement||document.documentElement;var els=__posEls(),tops=[],lefts=[];for(var i=0;i<els.length;i++){tops.push(Math.round(els[i].scrollTop)||0);lefts.push(Math.round(els[i].scrollLeft)||0);}var snap={key:key,doc:Math.round(de.scrollTop)||0,els:tops,lefts:lefts};if(key){__posMemo[key]=snap;}return snap;}
function _restoreScroll(s){try{var key=__posKeyOf();var t=__posMemo[key];if(!t&&s&&typeof s.doc==='number')t=s;var de=document.scrollingElement||document.documentElement;if(t){de.scrollTop=t.doc||0;}else{de.scrollTop=0;}try{if(typeof fitLockHeights==='function')fitLockHeights();}catch(e){}var els=__posEls();if(t&&t.els){for(var i=0;i<els.length&&i<t.els.length;i++){if(t.els[i])els[i].scrollTop=t.els[i];if(t.lefts)els[i].scrollLeft=t.lefts[i]||0;}}view.setAttribute('data-pos-key',key);}catch(e){}}/* 第195轮:“无可访问页面”统一兜底。根因:原 render() 与 renderDashboard() 两处兜底都回落 'dashboard',当账号在管理端零权限时互相指回 ⇒ 无限递归爆栈。修法:抽出 firstAllowedView() 统一判定首个有权页;一个都没有则进 renderNoAccess() 终局提示页,不再转圈。维护:新增页面只需在 firstAllowedView() 的表里加一行(与 renderNav 的 allItems 同步)。 */function firstAllowedView(visOnly){var m=[['dashboard','view_dashboard'],['dealers','view_dealers'],['dealercomm','view_dealer_comm'],['customers','view_customers'],['custquota','view_customers'],['followups','view_followups'],['delivery','view_delivery'],['finance','view_finance'],['reports','view_reports'],['rules','view_rules'],['users','manage_users'],['perms','manage_roles'],['todos','view_todos'],['reminders','manage_reminders'],['serviceexpire','view_serviceexpire'],['aireport','ai_report'],['helpon','view_helpon'],['helpreport','view_help_report'],['audit','view_audit'],['items','view_items'],['selfcheck','view_selfcheck'],['map','view_map'],['region','view_region']];var r=null;m.forEach(function(p){if(r===null&&can(p[1])&&(!visOnly||!navHiddenOn(p[0])))r=p[0];});return r;}function renderNoAccess(){if(!view)return;view.innerHTML='<div class="card" style="max-width:520px;margin:48px auto;text-align:center;padding:34px 26px"><div style="font-size:46px;line-height:1;margin-bottom:14px">🔒</div><h2 style="margin:0 0 10px;font-size:18px">暂无可访问页面</h2><p class="mtxt" style="font-size:13px;line-height:1.8;margin-bottom:18px">当前账号没有任何已授权的功能页面。<br>请联系管理员在「角色权限设置」中为你的角色分配权限后再试。</p><button class="btn ghost" onclick="location.reload()">🔄 刷新重试</button></div>';}function render(){if(curView!=='map'){try{mapExitFull();}catch(e){}}var _cv=curView;var _posG={};var _posBusy=(__posGuard&&(Date.now()-__posGuard.at)<300);var _snap=_posBusy?null:_snapScroll();__posGuard={g:_posG,at:Date.now()};injectListTableStyle();if(location.hash&&location.hash.indexOf('#/m-help')===0){renderMHelpUpload();return;}if(!currentUser&&mode!=='dealer'){showLogin();return;}
if(mode==='admin'&¤tUser&&DB.users&&!DB.users.find(x=>x.id===currentUser.id&&x.active)){doLogout();return;}
renderNav();if(mode==='dealer'){if(!window.__navDealerLandingDone){window.__navDealerLandingDone=1;if(navHiddenOn(curDealerView)){var _dv=firstVisibleDealerView();if(_dv)curDealerView=_dv;}}return renderDealerPortal();}const viewPerm={region:'view_region',dashboard:'view_dashboard',dealers:'view_dealers',dealercomm:'view_dealer_comm',customers:'view_customers',custquota:'view_customers',followups:'view_followups',delivery:'view_delivery',helpon:'view_helpon',helpreport:'view_help_report',finance:'view_finance',reports:'view_reports',rules:'view_rules',users:'manage_users',perms:'manage_roles',todos:'view_todos',reminders:'manage_reminders',audit:'view_audit',items:'view_items',selfcheck:'view_selfcheck',serviceexpire:'view_serviceexpire',map:'view_map',aireport:'ai_report'};if(!can(viewPerm[curView])){var _fv=firstAllowedView();if(!_fv){renderNoAccess();return;}curView=_fv;}if(!window.__navLandingDone&&mode==='admin'&¤tUser){window.__navLandingDone=1;if(navHiddenOn(curView)){var _nv=firstAllowedView(1);if(_nv)curView=_nv;}}
if(curView==='dashboard')renderDashboard();else if(curView==='dealers')renderDealers();else if(curView==='dealercomm'){curView='dealers';dealerPageTab='comm';renderDealers();}else if(curView==='customers')renderCustomers();else if(curView==='custquota')renderQuotaPage();else if(curView==='followups')renderFollowups();else if(curView==='delivery')renderDelivery();else if(curView==='helpon')renderHelpOnsite();else if(curView==='helpreport')renderHelpReport();else if(curView==='finance')renderFinance();else if(curView==='reports')renderReports();else if(curView==='rules')renderRules();else if(curView==='users')renderUsers();else if(curView==='perms')renderPerms();else if(curView==='todos')renderTodos();else if(curView==='reminders')renderReminderSettings();else if(curView==='audit')renderAudit();else if(curView==='items')renderItems();else if(curView==='selfcheck')renderSelfcheck();else if(curView==='serviceexpire')renderServiceExpire();else if(curView==='map')renderMap();else if(curView==='region')renderRegionCover();else if(curView==='aireport')renderAiReport();mountModuleTools(curView);fitTableLocks();if(_cv===curView){requestAnimationFrame(function(){if(!(__posGuard&&__posGuard.g===_posG))return;__posGuard=null;_restoreScroll(_snap);fitLockHeights();});}}
/* ★ 第 550 轮(2026-10-20):导航页面可设置隐藏 —— 主人拍板 A 方案(全局配置 · 存 DB.navHidden)。
语义:**隐藏 ≠ 停用** —— 只是左侧菜单不显示,页面内入口(卡片 / 待办 / 详情按钮)仍可到达;
真要禁止某角色用某功能,请回收对应 view_* 权限(那是「停用」,本功能只管「菜单显示」)。
防死锁三件套(缺一条就可能把管理员锁在门外):
① perms(角色权限设置)**强制不可隐藏** —— 前端勾选框 disabled + 服务端 sanitize 剔除;
② 过滤后为空 ⇒ **回退显示全部**(绝不把侧栏清零成白板);
③ 首屏落地改为「第一个**有权且可见**的页」(不让用户停在菜单里根本没有的页上)。
单一真源说明:NAV_HIDE_ITEMS 与 renderNav 的菜单表**是两份**(renderNav 在转义区、结构不同),
由自检 renderfuncs「导航显示」断言强制两者 key 集合相等,防双源漂移。 */
var NAV_HIDE_ITEMS={admin:[['todos','📋','待办事项'],['dashboard','📊','数据仪表盘'],['helpon','📷','到校帮扶'],['helpreport','📈','到校帮扶成果'],['dealers','🏷️','经销商管理'],['customers','🤝','转介绍客户'],['custquota','🎟','体验课名额'],['followups','📝','跟进记录'],['delivery','📦','总部交付服务'],['finance','💰','财务收款'],['reports','📈','转化·成交报表'],['rules','⚖️','业务规则红线'],['users','👤','用户与权限'],['reminders','🔔','消息提醒'],['perms','🔐','角色权限设置'],['items','🗂️','项目目录'],['selfcheck','🩺','系统自检'],['audit','📜','操作日志'],['serviceexpire','📅','服务到期提醒'],['map','🗺️','客户地图'],['region','📍','区域覆盖'],['aireport','🤖','AI 报表']],dealer:[['dashboard','📊','我的看板','dp_dashboard'],['report','➕','报备客户','dp_report'],['customers','🤝','我的客户','dp_customers'],['followups','📝','我的跟进','dp_followups'],['finance','💰','我的财务','dp_finance'],['benefits','🎁','我的权益','dp_benefits']]};
function navHiddenArr(){try{var h=DB&&DB.navHidden;if(!h||typeof h!=='object')return [];var a=h[mode==='dealer'?'dealer':'admin'];return Array.isArray(a)?a:[];}catch(e){return [];}}
function navHiddenOn(k){return navHiddenArr().indexOf(k)>=0;}
function navVisible(items){var v=items.filter(function(it){return navHiddenOn(it[0])?false:true;});return v.length?v:items;}
/* ★ 第 550 轮 · fail-safe:渲染完成后按配置把菜单项隐藏(不参与 items 计算)。
不在过滤阶段做的原因:一旦那时出任何异常,nav.innerHTML 根本没赋值 ⇒ 整条导航空白。
现在的姿势 = 「最坏情况是隐藏不生效」,绝不可能让导航消失。 */
function navApplyHidden(){try{var els=document.querySelectorAll('#nav a[data-view]');[].forEach.call(els,function(el){var v=el.getAttribute('data-view');if(v)el.style.display=navHiddenOn(v)?'none':'';});}catch(e){}}
function firstVisibleDealerView(){var list=NAV_HIDE_ITEMS.dealer,r=null;list.forEach(function(it){if(r===null&&canDealer(it[3])&&!navHiddenOn(it[0]))r=it[0];});return r;}
function injectNavHideBar(){setTimeout(function(){try{if(!view)return;var old=document.getElementById('navHideBar');if(old&&old.parentNode)old.parentNode.removeChild(old);var bar=document.createElement('div');bar.id='navHideBar';bar.style.cssText='display:flex;align-items:center;gap:10px;margin:0 0 12px;flex-wrap:wrap';bar.innerHTML='<button class="btn ghost" onclick="navHideOpen()">🧭 导航显示设置</button><span style="font-size:12px;color:#64748b">控制左侧菜单显示哪些页面(隐藏 ≠ 停用;能不能进仍按「功能权限」判定)</span>';view.insertBefore(bar,view.firstChild);}catch(e){}},0);}
function navHideOpen(){var list=NAV_HIDE_ITEMS[mode==='dealer'?'dealer':'admin']||[],cur=navHiddenArr();var rows=list.map(function(it){var fixed=(it[0]==='perms'),on=cur.indexOf(it[0])<0;return '<label style="display:flex;align-items:center;gap:9px;padding:8px 6px;border-bottom:1px solid #f1f5f9;font-size:13px;cursor:'+(fixed?'not-allowed':'pointer')+'">'+'<input type="checkbox" data-nh="'+it[0]+'"'+(on?' checked':'')+(fixed?' disabled':'')+'>'+'<span style="font-size:15px;width:18px;text-align:center">'+it[1]+'</span>'+'<span>'+it[2]+'</span>'+(fixed?'<span style="margin-left:auto;font-size:11px;color:#94a3b8">不可隐藏(防锁死)</span>':'')+'</label>';}).join('');openModal('<div style="padding:2px 4px"><h3 style="margin:0 0 6px;font-size:16px">🧭 导航显示</h3><p style="font-size:12px;line-height:1.7;color:#64748b;margin:0 0 12px">勾选 = 在左侧菜单显示;取消 = 隐藏。<b>隐藏不等于停用</b>:有权限的人仍可通过页面内入口进入。配置对<b>所有账号、所有设备</b>生效。</p><div style="max-height:min(58vh,480px);overflow:auto;border:1px solid #eef2f7;border-radius:10px;padding:0 10px">'+rows+'</div><div style="display:flex;gap:8px;justify-content:flex-end;margin-top:14px"><button class="btn ghost" onclick="navHideAll(1)">全部显示</button><button class="btn ghost" onclick="closeModal()">取消</button><button class="btn" onclick="navHideSave()">💾 保存</button></div></div>');}
function navHideAll(on){document.querySelectorAll('input[data-nh]').forEach(function(el){if(el.disabled)return;el.checked=!!on;});}
function navHideSave(){try{var list=NAV_HIDE_ITEMS[mode==='dealer'?'dealer':'admin']||[],hidden=[];list.forEach(function(it){var el=document.querySelector('input[data-nh="'+it[0]+'"]');if(el&&!el.checked&&!el.disabled)hidden.push(it[0]);});if(!DB.navHidden||typeof DB.navHidden!=='object')DB.navHidden={admin:[],dealer:[]};DB.navHidden[mode==='dealer'?'dealer':'admin']=hidden;closeModal();render();save().then(function(ok){toast(ok?'导航显示已保存(全员生效)':'保存失败,请重试');});}catch(e){toast('保存失败:'+((e&&e.message)||e));}}
function navOrderKey(){return 'yuanmo_nav_order_'+(mode==='dealer'?'dealer':'admin');}
function loadNavOrder(){try{var a=JSON.parse(localStorage.getItem(navOrderKey())||'null');return Array.isArray(a)?a:null;}catch(e){return null;}}
function saveNavOrder(a){try{localStorage.setItem(navOrderKey(),JSON.stringify(a));}catch(e){}}
function sortNavItems(items){var order=loadNavOrder();if(!order||!order.length)return items;var map={};items.forEach(function(it){map[it[0]]=it;});var out=[];order.forEach(function(k){if(map[k]){out.push(map[k]);delete map[k];}});items.forEach(function(it,idx){if(!map[it[0]])return;var prev=idx>0?items[idx-1][0]:null;var pi=prev?(function(){for(var i=0;i<out.length;i++){if(out[i][0]===prev)return i;}return -1;})():-1;if(pi>=0)out.splice(pi+1,0,map[it[0]]);else out.push(map[it[0]]);delete map[it[0]];});return out;}
var navDragKey=null;
function navDragStart(k,ev){navDragKey=k;if(ev&&ev.dataTransfer){ev.dataTransfer.effectAllowed='move';try{ev.dataTransfer.setData('text/plain',k);}catch(_){}}}
function navDragOver(ev){if(ev)ev.preventDefault();}
function navDragEnter(el){if(el){el.style.background='#eef2ff';el.style.boxShadow='inset 3px 0 0 var(--primary)';}}
function navDragLeave(el){if(el){el.style.background='';el.style.boxShadow='';}}
function navDragEnd(){navDragKey=null;document.querySelectorAll('#nav a').forEach(function(a){a.style.background='';a.style.boxShadow='';});}
function navDrop(k,el){if(el){el.style.background='';el.style.boxShadow='';}if(!navDragKey||navDragKey===k){navDragKey=null;return;}var keys=[];document.querySelectorAll('#nav a').forEach(function(a){var v=a.getAttribute('data-view');if(v)keys.push(v);});var order=loadNavOrder();if(!order||!order.length){order=keys.slice();}else{keys.forEach(function(kk){if(order.indexOf(kk)<0)order.push(kk);});}var from=order.indexOf(navDragKey);var to=order.indexOf(k);if(from>=0&&to>=0){order.splice(from,1);order.splice(to,0,navDragKey);saveNavOrder(order);renderNav();}navDragKey=null;}
function renderNav(){const ms=document.getElementById('modeSwitch');if(ms)ms.querySelectorAll('button').forEach(b=>b.classList.toggle('active',b.dataset.mode===mode));const nav=document.getElementById('nav');if(mode==='dealer'){const items=[['dashboard','📊','我的看板','dp_dashboard'],['report','➕','报备客户','dp_report'],['customers','🤝','我的客户','dp_customers'],['followups','📝','我的跟进','dp_followups'],['finance','💰','我的财务','dp_finance'],['benefits','🎁','我的权益','dp_benefits']].filter(it=>canDealer(it[3]));const act=curDealerView;nav.innerHTML=sortNavItems(items).map(([v,ic,lb])=>{const badge=(v==='todos'&&todoCount()>0)?`<span style="margin-left:auto;background:#ef4444;color:#fff;font-size:10px;font-weight:700;padding:1px 6px;border-radius:9px">${todoCount()}</span>`:'';return`<a data-view="${v}" class="${v===act?'active':''}" draggable="true" style="cursor:grab" title="按住拖动排序" ondragstart="navDragStart('${v}',event)" ondragover="navDragOver(event)" ondragenter="navDragEnter(this)" ondragleave="navDragLeave(this)" ondrop="navDrop('${v}',this)" ondragend="navDragEnd()"><span class="ic">${ic}</span> ${lb}${badge}</a>`;}).join('');}else{const allItems=[['todos','📋','待办事项','view_todos'],['dashboard','📊','数据仪表盘','view_dashboard'],['helpon','📷','到校帮扶','view_helpon'],['helpreport','📈','到校帮扶成果','view_help_report'],['dealers','🏷️','经销商管理','view_dealers'],['customers','🤝','转介绍客户','view_customers'],['custquota','🎟','体验课名额','view_customers'],['followups','📝','跟进记录','view_followups'],['delivery','📦','总部交付服务','view_delivery'],['finance','💰','财务收款','view_finance'],['reports','📈','转化·成交报表','view_reports'],['rules','⚖️','业务规则红线','view_rules'],['users','👤','用户与权限','manage_users'],['reminders','🔔','消息提醒','manage_reminders'],['perms','🔐','角色权限设置','manage_roles'],['items','🗂️','项目目录','view_items'],['selfcheck','🩺','系统自检','view_selfcheck'],['audit','📜','操作日志','view_audit'],['serviceexpire','📅','服务到期提醒','view_serviceexpire'],['map','🗺️','客户地图','view_map'],['region','📍','区域覆盖','view_region'],['aireport','🤖','AI 报表','ai_report'],];const items=allItems.filter(it=>can(it[3]));const act=curView;nav.innerHTML=sortNavItems(items).map(([v,ic,lb])=>{const badge=(v==='todos'&&todoCount()>0)?`<span style="margin-left:auto;background:#ef4444;color:#fff;font-size:10px;font-weight:700;padding:1px 6px;border-radius:9px">${todoCount()}</span>`:'';return`<a data-view="${v}" class="${v===act?'active':''}" draggable="true" style="cursor:grab" title="按住拖动排序" ondragstart="navDragStart('${v}',event)" ondragover="navDragOver(event)" ondragenter="navDragEnter(this)" ondragleave="navDragLeave(this)" ondrop="navDrop('${v}',this)" ondragend="navDragEnd()"><span class="ic">${ic}</span> ${lb}${badge}</a>`;}).join('');}
const mb=document.getElementById('mbTag');if(mb)mb.textContent=mode==='dealer'?'经销商端':'管理端';const su=document.getElementById('sideUser');if(su){if(currentUser){su.style.display='flex';su.style.flexDirection='column';su.style.gap='8px';su.innerHTML=`<div style="display:flex;align-items:center;gap:10px;width:100%;min-width:0">
<span class="su-avatar" style="flex-shrink:0">${(currentUser.name||'?').slice(0,1)}</span>
<div style="flex:1;min-width:0;overflow:hidden">
<div class
="su-name" style="white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${currentUser.name||''}</div>
<div class="su-role" style="white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${(currentUser.roles&¤tUser.roles.length?currentUser.roles:(currentUser.role?[currentUser.role]:[])).map(roleNameOf).join('、')}</div>
</div>
<button class="btn ghost sm" style="flex-shrink:0;white-space:nowrap" onclick="doLogout()">退出</button>
</div>
<div class="su-ver" style="font-size:11px;color:#94a3b8;text-align:center">🔑 <a href="#" onclick="openMyPwd();return false;" style="color:var(--primary)">修改密码</a> · <a href="#" onclick="location.reload(true);return false;" style="color:var(--primary)">刷新页面</a><div style="opacity:.7;margin-top:2px">构建 ${BUILD_ID}</div></div>`;}else su.style.display='none';}navApplyHidden();}
function renderDashboard(){if(!can('view_dashboard')){const redirect=firstAllowedView();if(!redirect){renderNoAccess();return;}curView=redirect;render();return;}
const g=globalStats();let trend=monthlyTrend();while(trend.length>1&&(trend[0].dealers+trend[0].customers)===0)trend.shift();const tMax=Math.max(1,...trend.map(m=>Math.max(m.dealers,m.customers)));const dg=globalDelivery();const dealers=DB.dealers.filter(d=>d.type!=="staff");const totalD=dealers.length;const signedD=dealers.filter(d=>d.contractSigned).length;const unsignedD=totalD-signedD;const activeD=dealers.filter(d=>dealerStats(d.id).reported>0).length;const inactiveD=totalD-activeD;const totalFee=dealers.reduce((s,d)=>s+(Number(DT(d.type).fee)||0),0);const now=new Date();const ym=now.getFullYear()+'-'+String(now.getMonth()+1).padStart(2,'0');const newThisMonth=dealers.filter(d=>d.signDate&&d.signDate.indexOf(ym)===0).length;const byType=Object.keys(DEALER_TYPES).filter(t=>t!=='staff').map(t=>({t,name:DT(t).name.replace('经销商',''),color:DT(t).color,n:dealers.filter(d=>d.type===t).length}));const provMap={};dealers.forEach(d=>{const p=(d.prov||'').trim()||'未填写';provMap[p]=(provMap[p]||0)+1;});const byProv=Object.entries(provMap).sort((a,b)=>b[1]-a[1]).slice(0,6);const custProvMap={};DB.customers.forEach(c=>{const p=(c.prov||'').trim()||'未填写';custProvMap[p]=(custProvMap[p]||0)+1;});const byCustProv=Object.entries(custProvMap).sort((a,b)=>b[1]-a[1]).slice(0,10);const dlBar=(label,n,max,cls)=>`<div style="display:flex;align-items:center;gap:10px;padding:6px 0">
<span class="tag ${cls||''}" style="min-width:78px;justify-content:center">${label}</span>
<div style="flex:1;height:10px;background:#eef2f7;border-radius:6px;overflow:hidden"><i style="display:block;height:100%;width:${max?Math.round(n/max*100):0}%;background:var(--ring)"></i></div>
<b style="min-width:28px;text-align:right">${n}</b></div>`;const rank=dealers.map(d=>({d,...dealerStats(d.id)})).sort((a,b)=>b.signedAll-a.signedAll);let rows=rank.length?rank.map(r=>`
<tr>
<td><span class="tag ${DT(r.d.type).color}">${DT(r.d.type).name.replace('经销商','')}</span> ${r.d.name}</td>
<td>${r.reported}</td><td>${r.experienced}</td><td>${r.signedFull}</td>
<td><b>${pct(r.closeRate)}</b></td>
<td>${moneyMask(fLevel('amount_dealer'), r.commission)}</td>
</tr>`).join(''):'<tr><td colspan="6" class="empty">暂无经销商数据</td></tr>';const delivRows=dealers.map(d=>{const s=dealerDelivery(d.id);return`
<tr>
<td>${d.name}</td>
<td>${s.done}/${s.total}</td>
<td><div class="bar" style="width:120px"><i style="width:${s.completion}%"></i></div></td>
<td><b>${pct(s.completion)}</b></td>
</tr>`;}).join('')||'<tr><td colspan="4" class="empty">暂无交付数据</td></tr>';view.innerHTML=`
<div class="topbar">
<div><h1>数据仪表盘</h1><p>实时呈现转介绍线索转化、成交与总部交付全貌</p></div>
<div style="display:flex;gap:10px">
${can('export_data')?`<button class="btn ghost sm"onclick="exportJSON()">导出数据</button>`:''}
${can('sys_reset')?`<button class="btn sm"onclick="resetData()">重置示例</button>`:''}
</div>
</div>
${pwaHomeHint()}
<div class="grid kpis">
${kpiCard('📋','总报备客户',g.reported,'含所有转介绍线索')}
${kpiCard('🔄','整体转化率',pct(g.convRate),'报备 → 已体验及以上','#6366f1')}
${kpiCard('💰','整体成交率',pct(g.closeRate),'报备 → 签约成交','#16a34a')}
${kpiCard('✅','全款成交',g.signedFull,'未退费 · 不含员工自营校区','#0891b2')}
${kpiCard('🏆','应发分润',moneyMask(fLevel('amount_kpi'),g.commission),`全款实收×比例 · 已发 ${moneyMask(fLevel('amount_kpi'),g.commissionPaid)} · 待发 ${moneyMask(fLevel('amount_kpi'),Math.max(0, g.commission - g.commissionPaid))}`,'#d97706')}
</div>
<div class="card" style="margin-top:16px">
<div class="section-title" style="margin:0 0 12px">新增趋势(按达成合作日期 · 不含公司员工与员工自营校区)</div>
<div class="chart-scroll" style="display:flex;align-items:flex-end;gap:4px;height:205px;padding:8px 4px 0">
${trend.map((m,i)=>{
const cum=trend.slice(0,i+1).reduce((s,x)=>s+x.dealers+x.customers,0);
return `<div style="flex:1;display:flex;flex-direction:column;align-items:center;justify-content:flex-end;height:100%;gap:3px">
<div style="font-size:11px;font-weight:700;color:#334155;line-height:1">${m.dealers+m.customers}</div>
<div title="新增 ${m.dealers+m.customers}" style="width:22px;height:${(m.dealers+m.customers)?Math.max(4,(m.dealers+m.customers)/tMax*150):0}px;background:#16a34a;border-radius:3px 3px 0 0"></div>
<span style="font-size:10px;color:#64748b;white-space:nowrap">${Number(m.ym.slice(5))}月</span>
<span style="font-size:9px;color:#94a3b8;white-space:nowrap;line-height:1.2">累计 ${cum}</span>
</div>`;}).join(``)} </div>
<div style="display:flex;gap:16px;font-size:12px;color:#64748b;margin-top:10px">
<span><i style="display:inline-block;width:10px;height:10px;background:#16a34a;border-radius:2px;margin-right:5px"></i>新增客户</span>
</div>
</div>
<div
class="section-title">转化漏斗(报备 → 体验 → 签约)</div>
<div class="grid two-col">
<div class="card">${funnelHTML(g)}</div>
<div class="card">
<h4 style="font-size:14px;margin-bottom:12px">关键指标</h4>
<div class="grid" style="gap:12px">
${miniStat('报备客户总数', g.reported)}
${miniStat('进入体验阶段', g.experienced)}
${miniStat('签约成交(全款+分期)', g.signedAll)}
${miniStat('体验→签约转化率', pct(g.signConv))}
${miniStat('退费客户', g.refunded)}
${miniStat('已流失(保护过期)', DB.customers.filter(c=>c.stage==='lost').length)}
</div>
</div>
</div>
<div class="section-title">总部交付概览(年课包 ¥${fmt(PACKAGE_PRICE)})</div>
<div class="grid kpis" style="grid-template-columns:repeat(3,1fr)">
${kpiCard('📚','交付服务项',SERVICES.length,'教学课程·配套产品·增值服务')}
${kpiCard('✅','整体交付率',pct(dg.completion),`已交付 ${dg.done}/${dg.items.length}项`,'#16a34a')}
${kpiCard('👥','服务经销商',dg.dealers,'享受年课包权益')}
</div>
<div class="grid two-col">
<div class="card">
<h4 style="font-size:14px;margin-bottom:12px">各经销商交付完成度</h4>
<table><thead><tr><th>经销商</th><th>已完成/总项</th><th>进度</th><th>完成率</th></tr></thead>
<tbody>${delivRows}</tbody></table>
</div>
<div class="card">
<h4 style="font-size:14px;margin-bottom:12px">交付价值 Top 3</h4>
${(()=>{const top=dealers.map(d=>({d,...dealerDelivery(d.id)}))
.sort((a,b)=>b.deliveredValue-a.deliveredValue).slice(0,3);
return top.map((t,i)=>`<div style="display:flex;justify-content:space-between;padding:10px 0;border-bottom:1px dashed var(--line)"><span>${i+1}.${t.d.name}</span><b>${money(t.deliveredValue)}</b></div>`).join('')||'<div class="empty">暂无</div>';})()}
</div>
</div>
<div class="section-title">经销商总览</div>
<div class="grid kpis" style="grid-template-columns:repeat(5,1fr)">
${kpiCard('🏢','经销商总数',totalD,'在册合作经销商','#475569')}
${kpiCard('✍️','已签 / 未签合同',`${signedD}<span style="color:#94a3b8;font-size:13px">/${unsignedD}</span>`,'经销合同签署状态','#6366f1')}
${kpiCard('🆕','本月新增',newThisMonth,'当月签约经销','#0891b2')}
${kpiCard('🔥','活跃经销商',activeD,`有报备客户(待激活 ${inactiveD})`,'#16a34a')}
${kpiCard('💰','预缴保证金合计',moneyMask(fLevel('amount_dealer'),totalFee),'各类型使用费/保证金','#d97706')}
</div>
<div class="grid two-col">
<div class="card">
<h4 style="font-size:14px;margin-bottom:12px">经销商类型分布</h4>
${byType.length?byType.map(x=>dlBar(x.name,x.n,Math.max(...byType.map(t=>t.n)),x.color)).join(''):'<div class="empty">暂无经销商</div>'}
</div>
<div class="card">
<h4 style="font-size:14px;margin-bottom:12px">签约状态 & 区域分布</h4>
${dlBar('已签合同',signedD,totalD,'')}
${dlBar('未签合同',unsignedD,totalD,'')}
<div style="height:10px"></div>
<div class="mtxt" style="font-size:12px;margin-bottom:4px">按省份(Top ${byProv.length})</div>
${byProv.length?byProv.map(([p,n])=>dlBar(p,n,byProv[0][1],'')).join(''):'<div class="empty">暂无</div>'}
<div style="height:10px"></div>
<div class="mtxt" style="font-size:12px;margin-bottom:4px">客户区域分布(Top ${byCustProv.length})</div>
${byCustProv.length?byCustProv.map(([p,n])=>dlBar(p,n,byCustProv[0][1],'')).join(''):'<div class="empty">暂无</div>'}
</div>
</div>
<div class="section-title">经销商业绩排行</div>
<div class="card">
<table>
<thead><tr><th>经销商</th><th>报备</th><th>体验</th><th>全款成交</th><th>成交率</th><th>应发分润</th></tr></thead>
<tbody>${rows}</tbody>
</table>
</div>`;}
function kpiCard(ic,label,val,sub,color,tip){return`<div class="card kpi"${tip?` data-tip="${esc(tip)}"`:''}>
<div class="label"><span>${ic}</span>${label}</div>
<div class="val" style="${color?`color:${color}`:''}">${val}</div>
<div class="sub">${sub}</div></div>`;}
function finTipMoney(n){return '¥'+Math.abs(Math.round(n||0)).toLocaleString('zh-CN');}
function finScopeRows(kind){const eff=c=>!c.refunded&&c.stage!=='terminated';const pick=(kind==='deduct')?(c=>deductTotalOf(c)):(c=>actualPaidOf(c)-refundTotalOf(c));return DB.customers.filter(c=>!eff(c)).map(c=>({n:(c.name||'(未填姓名)'),v:Math.round(pick(c)*100)/100})).filter(r=>r.v!==0).sort((a,b)=>Math.abs(b.v)-Math.abs(a.v));}
function finTipDeduct(gap){const rows=finScopeRows('deduct');const list=rows.length?rows.map(r=>'· '+r.n+' '+(r.v>0?'+':'−')+finTipMoney(r.v)).join('\n'):'· (无)';return '抵扣金额 = 交款时用「抵扣」方式充抵、不再实际收款的那部分。\n\n本卡已剔除退费 / 终止客户。全部客户口径另含 '+finTipMoney(gap)+',来自:\n'+list+'\n\n其余 5 张卡片的口径都不含退费 / 终止客户。体验课订单(纯体验课档案)已整体剔除,不参与本页任何统计。';}
function finTipActual(gap){const eff=c=>!c.refunded&&c.stage!=='terminated';const rows=finScopeRows('net');const g=Math.round(gap||0);const list=rows.length?rows.map(r=>'· '+r.n+' '+(r.v>0?'+':'−')+finTipMoney(r.v)).join('\n'):'· (无)';const er=DB.customers.filter(eff).reduce((s,c)=>s+refundTotalOf(c),0);let t='实收金额 = 全部客户已交款 − 抵扣金额 − 退费。\n\n本卡统计「全部客户」(含已退费 / 已终止),其余 5 张卡已剔除这两类客户,故本卡较其余卡片'+(g<0?'少':(g>0?'多':'差'))+' '+finTipMoney(g)+'。\n\n差额来自已退费 / 终止客户的「实收 − 退费」净额:\n'+list;if(er>0)t+='\n· 有效客户退费 −'+finTipMoney(er);t+='\n\n本口径与经营看板「累计净收款」一致(体验课订单已整体剔除,不参与本页任何统计)。';return t;}
function kpiTipShow(el){const t=(el&&el.getAttribute)?el.getAttribute('data-tip'):'';if(!t)return;let b=document.getElementById('kpiTipBox');if(!b){b=document.createElement('div');b.id='kpiTipBox';b.className='kpi-tip';document.body.appendChild(b);}b.textContent=t;b.style.display='block';b.style.left='0px';b.style.top='0px';const r=el.getBoundingClientRect();const w=b.offsetWidth;const h=b.offsetHeight;let L=Math.max(8,Math.min(r.left,window.innerWidth-w-8));let T=r.bottom+8;if(T+h>window.innerHeight-8)T=Math.max(8,r.top-h-8);b.style.left=L+'px';b.style.top=T+'px';}
function kpiTipHide(){const b=document.getElementById('kpiTipBox');if(b)b.style.display='none';}
document.addEventListener('mouseover',function(e){const k=(e.target&&e.target.closest)?e.target.closest('.kpi[data-tip]'):null;if(k)kpiTipShow(k);});
document.addEventListener('mouseout',function(e){const f=(e.target&&e.target.closest)?e.target.closest('.kpi[data-tip]'):null;if(!f)return;const t=(e.relatedTarget&&e.relatedTarget.closest)?e.relatedTarget.closest('.kpi[data-tip]'):null;if(t===f)return;kpiTipHide();});
window.addEventListener('scroll',kpiTipHide,true);
function miniStat(k,v){return`<div style="display:flex;justify-content:space-between;padding:8px 0;border-bottom:1px dashed var(--line)">
<span class="mtxt">${k}</span><b>${v}</b></div>`;}
function funnelHTML(g,firstLabel,title){const steps=[{l:firstLabel||'报备线索',v:g.reported,c:g.reported},{l:'已体验及以上',v:g.experienced,c:g.experienced},{l:'已签约',v:g.signedAll,c:g.signedAll},];const max=Math.max(g.reported,1);return`<h4 style="font-size:14
px;margin-bottom:14px">${title||'线索转化漏斗'}</h4>
<div class="funnel">${steps.map(s=>{
const w = max? (s.v/max*100):0;
return `<div class="fstep"><div class="flabel">${s.l}</div><div class="fbar"><i style="width:${w}%"><span class="fval">${s.v}家</span></i></div></div>`;
}).join('')}</div>
<div style="margin-top:14px;font-size:12px;color:var(--muted)">
报备→体验及以上转化率 <b style="color:var(--primary)">${pct(g.convRate)}</b> | 报备→成交率 <b style="color:var(--ok)">${pct(g.closeRate)}</b> | 体验及以上→成交率 <b style="color:var(--info)">${pct(g.signConv)}</b>
</div>`;}
let dealerFilter={q:''};
function campusNote(t){return t==='campus'?'前5家仅抵扣·第6家起20%':'';}
// 通用搜索 debounce:连续输入时只最后一次停顿后触发 render(避免每次按键都重建 DOM)
// 修复"切换页面/编辑客户后再搜索结果空白"——旧实现只做 DOM 显示/隐藏,tr 是 render 时基于旧 kw 过滤的子集,新 kw 命中 0 个 tr 全部隐藏
function _schedSearch(fn){if(fn&&fn._schedT)clearTimeout(fn._schedT);fn._schedT=setTimeout(fn,300);}
function dealerSearch(v){dealerFilter.q=v;const q=(v||'').trim().toLowerCase();const rows=document.querySelectorAll('#dlrTable tr[data-search]');rows.forEach(tr=>{const hit=(tr.getAttribute('data-search')||'').toLowerCase().includes(q);tr.style.display=(!q||hit)?'':'none';});}
let custSub='all';
/* ★ 第 562 轮(主人 2026-10-20 拍板 A 方案):转介绍客户「未转经销商」子页面状态。
showConv 默认 false = 只看还没转成经销商的;勾上则把已转的(含已合作)也一起列。
kw 与「已成交未签合同」子页同范式:输入即落状态 → 重画回填 → 重画末尾重放过滤。 */
let custUnconvShowConv=false;
let custUnconvKw='';
/* ★ 第 163 轮:「已成交未签合同」子页面搜索词状态(修复「搜索后被自动刷新清空 ⇒ 全表重新展开」)。
口径与主表 custFilter.kw 完全一致:输入即落状态 → 重画回填 value → 重画末尾重放过滤。 */
let custDealKw='';
/* ===== 第 152 轮:转介绍客户页「已成交未签合同」子页面 =====
判据集中在本函数一处(服务端自检 cust_deal_nosign_guard 守它):
累计交款 > 0 且 未签合同 且 未退费 且 未终止 */
/* ★ 第 540 轮(主人 2026-10-19 拍板 B):本子页只展示「已成交未签合同」客户 ——
已成交 = 阶段为全款 / 分期;不含已退费 / 终止合作。
原判据「累计交款 > 0」会把只交过体验课 / 资料费、尚未成交的客户混进来(实测白燕 298 元)。 */
/* ★ 第 599 轮(主人 2026-10-23 拍板 A 方案 + 同意自检判据变更):订单「无需签订合同」三层判定 —— 唯一出口。
① 订单级显式设置 customers[].noSign(true = 无需签 / false = 必须签,压过项目默认);
② 纯体验课订单(orderKindOf==='exp',与第 462 轮「体验课不计成交」全系统口径同源);
③ 订单内全部项目的 items[].noSign === true(项目目录「本项目无需签订合同」)。
命中 ⇒ 不进「已成交未签合同」待签列表;**不影响分润 / 交付门槛 / 阶段 / 家数 / 成交统计**。
服务端自检 cust_deal_nosign_guard 用同口径复算(custNoSignExemptSrv),两端禁漂移。 */
function custNoSignItemSet(){
var s={};
(DB.items||[]).forEach(function(x){if(x&&x.noSign===true)s[String(x.id)]=1;});
return s;
}
function custNoSignExempt(c){
if(!c)return false;
if(c.noSign===true)return true;
if(c.noSign===false)return false;
if(orderKindOf(c)==='exp')return true;
var ids=Array.isArray(c.itemIds)?c.itemIds:[];
if(!ids.length)return false;
var s=custNoSignItemSet();
for(var i=0;i<ids.length;i++){if(!s[String(ids[i])])return false;}
return true;
}
/* 订单级设置:mode = inherit(跟随项目)/ none(本单无需签)/ required(本单必须签)。
走 /api/db/toggle 单字段通道(与「已签合同」开关同一姿势)—— 不发全库、不触发派生链。 */
function setCustNoSign(cid,mode){
if(!can('edit_customers')){toast('无权限:需「编辑客户」权限');return;}
var c=(DB.customers||[]).find(function(x){return x&&x.id===cid;});
if(!c){toast('未找到客户');return;}
var val=(mode==='none')?true:((mode==='required')?false:null);
var had=Object.prototype.hasOwnProperty.call(c,'noSign');
var before=c.noSign;
if(val===null){ if(!had){toast('本来就是「跟随项目」');return;} }
else if(c.noSign===val){toast('已经是该设置');return;}
if(val===null)delete c.noSign;else c.noSign=val;
render();
apiPost('/api/db/toggle',{type:'customer',id:cid,field:'noSign',value:val}).then(function(r){
if(r&&r.ok){
if(r&&typeof r.rev==='number')DB.rev=r.rev;
toast(val===null?'已恢复「跟随项目」':(val?'已标记:本单无需签订合同':'已标记:本单必须签订合同'));
render();
}else{
if(had)c.noSign=before;else delete c.noSign;
render();
toast('保存失败:'+((r&&r.error)||'未知错误'));
}
});
}
function custDealNoSignHit(c){
if(!c)return false;
if(c.stage!=='signed_full'&&c.stage!=='signed_installment')return false;
if(c.contractSigned)return false;
if(c.refunded||c.stage==='refunded')return false;
if(c.stage==='terminated')return false;
if(custNoSignExempt(c))return false;
return true;
}
/* 最近一笔交款日期(payments[].date 取最大;无则空串) */
function custLastPayDate(c){
var best='';
var ps=(c&&c.payments)||[];
for(var i=0;i<ps.length;i++){var d=ps[i]&&ps[i].date;if(d&&d>best)best=d;}
return best;
}
/* 命中清单:按最近交款日期倒序(新在前),同日期按编号升序 */
function custDealNoSignList(){
return (DB.customers||[]).filter(custDealNoSignHit).sort(function(a,b){
var da=custLastPayDate(a),d2=custLastPayDate(b);
if(da!==d2)return da<d2?1:-1;
return String(a.custNo||'').localeCompare(String(b.custNo||''));
});
}
/* ===== 第 562 轮(主人 2026-10-20 拍板 A 方案 + 纳入「已合作」+ 要「显示已转」):
转介绍客户「未转经销商」子页面 —— 罗列已成交(全款 / 分期)与已合作、但尚未转成经销商的客户档案。
判据唯一集中在 custUnconvHit 一处(服务端自检 cust_deal_unconv_guard 守它):
阶段 ∈ {全款, 分期, 已合作} 且 未退费 且 未终止 且 非员工自营校区;
默认只看「未转经销商」(convertedDealerId 为空),勾「显示已转」则连已转的一起列。
★ 为什么不用 c.installment 判分期:该字段是「是否分期订单(不计分润)」开关,
实测 2026-10-20 全库 0 条为真 ⇒ 判分期只能看 stage(signed_installment 25 家)。
★ 已合作(cooperated)= 已进入合作但金额为 0 的档案,主人明确要求一并纳入。 */
var CUST_UNCONV_STAGES=['signed_full','signed_installment','cooperated'];
function custUnconvStageOk(c){return CUST_UNCONV_STAGES.indexOf(c.stage)>=0;}
function custUnconvGroupOf(c){return c.stage==='signed_installment'?0:(c.stage==='signed_full'?1:2);}
function custUnconvStageText(c){return ['分期','全款','已合作'][custUnconvGroupOf(c)];}
function custUnconvHit(c, all){
if(!c)return false;
if(!custUnconvStageOk(c))return false;
if(c.refunded||c.stage==='refunded')return false;
if(c.stage==='terminated')return false;
if(isStaffOwnCampus(c))return false;
var showAll=(all===true)||(all===undefined&&custUnconvShowConv);
if(!showAll&&custUnconvConvOf(c))return false;
return true;
}
/* ★ 第 563 轮(主人 2026-10-20「王晨已是经销商」):**「是否已转经销商」按「客户组」判,不按单条档案判。**
同一客户的多张订单共享 custNo(或 linkedRootId),主档转了经销商 ⇒ 这一家其余档案也都算已转。
实测案例:王晨/杜亚琳 2026-08-026 主档已转经销商,其关联档案 c_v6rcfaoab 自身 convertedDealerId 为空
⇒ 旧口径把它当「未转」列进清单(错)。张瑞青(无 custNo / 无 linkedRootId)自成一组,不受影响。 */
var _cucRev='',_cucMap={},_cucNames={};
/* ★ 第 564 轮(主人 2026-10-21 拍板 ①A):**死链视同「未转」**。
已转经销商被删后,customers[].convertedDealerId 会指向一个不存在的经销商(删经销商不会清这个字段)。
旧实现直接返回那个死 id ⇒ 客户被判「已转」、从默认清单消失,行内又不给「转经销商」按钮 ⇒ 点不动。
现在:只有「经销商真实存在」的引用才算已转;死引用一律忽略(回落同组有效引用,再没有就当未转)。
判据唯一入口仍是本函数 + custUnconvConvIdsOf(只读助手,不参与「是否已转」的结论)。 */
function custUnconvAliveDealer(id){
if(!id)return '';
var ok=false;
(DB.dealers||[]).forEach(function(x){if(x&&x.id===id)ok=true;});
return ok?id:'';
}
function custUnconvConvOf(c){
if(!c)return '';
var _self=custUnconvAliveDealer(c.convertedDealerId);
if(_self)return _self;
var sig=(DB.rev||0)+'|'+((DB.customers||[]).length);
if(_cucRev!==sig){
_cucRev=sig;_cucMap={};_cucNames={};
(DB.customers||[]).forEach(function(x){
if(!x||!x.convertedDealerId)return;
var _aid=custUnconvAliveDealer(x.convertedDealerId);
if(!_aid)return;
var k=custGroupKeyOf(x);
if(!k)return;
if(!_cucMap[k])_cucMap[k]=_aid;
if(!_cucNames[k])_cucNames[k]=[];
if(_cucNames[k].indexOf(_aid)<0)_cucNames[k].push(_aid);
});
}
return _cucMap[custGroupKeyOf(c)]||'';
}
/* ★ 第 564 轮(②A):同组里若有多家不同的「已转经销商」,这里返回**全部**有效 id(自身有效则只有自身)。
行渲染 / 导出 / 搜索据此显示「共 N 家 + 全名」,不再拿第一家冒充其余档案的归属。 */
function custUnconvConvIdsOf(c){
if(!c)return [];
var _self=custUnconvAliveDealer(c.convertedDealerId);
if(_self)return [_self];
var cv=custUnconvConvOf(c);
if(!cv)return [];
var _arr=(_cucNames[custGroupKeyOf(c)]||[]).slice();
return _arr.length?_arr:[cv];
}
/* ★ 第 564 轮(①A):本档案自己的「已转经销商」是个死链(经销商已被删) */
function custUnconvDeadOf(c){
return !!(c&&c.convertedDealerId&&!custUnconvAliveDealer(c.convertedDealerId));
}
function custUnconvOrder(c){return custUnconvGroupOf(c)*2+(custUnconvConvOf(c)?1:0);}
/* ★ 第 592 轮(主人 2026-10-23 11:12 拍板 B 方案):**一行 = 一个客户组**。
旧版逐档案返回 ⇒ 同一客户的多张关联档案各占一行(勾「显示已转」实测 113 行 = 109 家,4 家各多 1 行)。
现在按 custGroupKeyOf 聚合:合同 / 已交 / 欠款取组内合计,签约日期取最早,阶段取最靠前(分期 > 全款 > 已合作)。
逐档操作(转经销商 / +交款)走 custUnconvPick:组内仅 1 个候选时直开,多候选先选单 ——
避免「给已转的那张再转一次」(同客户转两次会建出两个同名经销商)。 */
function custUnconvGroups(showAll){
var list=(DB.customers||[]).filter(function(c){return custUnconvHit(c,showAll);});
var m={},ord=[];
list.forEach(function(c){
var k=custGroupKeyOf(c)||('I:'+c.id);
if(!m[k]){m[k]=[];ord.push(k);}
m[k].push(c);
});
var out=ord.map(function(k){
var arr=m[k];
var root=arr.filter(function(x){return !x.linkedRootId;})[0]||arr[0];
var st=9;
arr.forEach(function(x){var gg=custUnconvGroupOf(x);if(gg<st)st=gg;});
var amt=0,pd=0,ow=0,sd='';
arr.forEach(function(x){
amt+=Number(x.amount||0);pd+=paidOf(x);ow+=owedOf(x);
var dd=String(x.signDate||'');if(dd&&(!sd||dd<sd))sd=dd;
});
var cv=custUnconvConvOf(root);
/* ★ 逐档「自己转没转」必须只认自身 —— custUnconvConvOf 是「按组判」(第 563 轮),
拿它筛未转档会恒空(组内任一档已转 ⇒ 全组都算已转)⇒「转剩下这单」永远出不来。 */
return {key:k,arr:arr,root:root,stage:st,amount:amt,paid:pd,owed:ow,signDate:sd,conv:cv,
pend:arr.filter(function(x){return !custUnconvAliveDealer(x.convertedDealerId);})};
});
return out.sort(function(a,b){
var oa=a.stage*2+(a.conv?1:0),ob=b.stage*2+(b.conv?1:0);
if(oa!==ob)return oa-ob;
if(a.owed!==b.owed)return b.owed-a.owed;
return String(a.root.custNo||'').localeCompare(String(b.root.custNo||''));
});
}
function custUnconvList(){return custUnconvGroups(custUnconvShowConv);}
/* 页签上的数字 = 默认口径(未转)家数,与「显示已转」勾选无关 ⇒ 勾选后数字不会跳 */
function custUnconvTabCount(){return custUnconvGroups(false).length;}
function custUnconvRaw(g){
if(!g||!g.arr)return '';
var parts=[];
g.arr.forEach(function(c){
var d=(DB.dealers||[]).find(function(x){return x.id===c.dealerId;});
var _nm=custUnconvConvIdsOf(c).map(function(id){var z=(DB.dealers||[]).find(function(x){return x.id===id;});return z?(z.name||''):'';}).filter(function(x){return !!x;});
parts.push([c.custNo||'',c.school||'',c.name||'',(d&&(d.name||''))||'',svcStaffNameOf(c)||'',custUnconvStageText(c),_nm.join('、'),(custUnconvDeadOf(c)?'原经销商已删除':'')].join(' '));
});
return parts.join(' ');
}
function custUnconvSumText(all){
all=all||custUnconvList();
var amtLv=fLevel('amount');
var kw=String(custUnconvKw||'').trim().toLowerCase();
var vis=kw?all.filter(function(g){return custUnconvRaw(g).toLowerCase().indexOf(kw)>=0;}):all;
var sp=0,so=0,nu=0,nconv=0;
vis.forEach(function(g){sp+=g.paid;so+=g.owed;if(!g.conv)nu++;else nconv++;});
/* ★ 第 564 轮(④B):勾了「显示已转经销商」时,金额统计的是**全部行**(含已转)⇒ 文案必须写明「全部」,
否则「未转 N 家」与「欠款 ¥X」两个口径并列,会被读成「这 N 家欠了这么多」。未勾选时不加前缀(本就同口径)。
★ 第 592 轮:行 = 客户组,nu / nconv 与 all.length 都按「家」计(不再按档案条数)。 */
var _pfx=(nconv>0)?'全部':'';
return (kw?('当前显示 '+vis.length+' / '+all.length+' 家'):('共 '+all.length+' 家'))
+' · 未转 '+nu+' 家 · '+_pfx+'已交款 '+moneyMask(amtLv,sp)+' · '+_pfx+'欠款 '+moneyMask(amtLv,so);
}
function custUnconvGrpSum(list){
var amtLv=fLevel('amount'),sa=0,sp=0,so=0;
list.forEach(function(g){sa+=g.amount;sp+=g.paid;so+=g.owed;});
return list.length+' 家 · 合同 '+moneyMask(amtLv,sa)+' · 已交 '+moneyMask(amtLv,sp)+' · 欠 '+moneyMask(amtLv,so);
}
function custUnconvStageName(st){return ['分期','全款','已合作'][st]||'';}
/* ★ 第 592 轮:「+交款」在客户组内的入口 —— 组内唯一可登记交款的档直出原按钮;
多档先弹选单(登记到错单会算错业绩归属与分润,故必须让操作者指定)。参数口径完全沿用 payQuickBtn。 */
function custUnconvPayBtn(g){
var ok=g.arr.filter(function(c){return !!payQuickBtn(c);});
if(!ok.length)return '';
if(ok.length===1)return payQuickBtn(ok[0]);
return '<button class="btn sm" title="快捷登记交款(本客户有多张订单,请先选单)" onclick="custUnconvPick(\''+esc(g.key)+'\',\'pay\')">+交款</button>';
}
function custUnconvRowHtml(g){
var root=g.root;
var d=(DB.dealers||[]).find(function(x){return x.id===root.dealerId;});
var cv=g.conv;
/* ★ 第 564 轮(①A + ②A):已转提示按「全部有效经销商」渲染 ——
① 死链时不再显示空白名,改成「原经销商已删除」;
② 同组多家不同经销商时显示「共 N 家」并把名字列全,不拿第一家冒充。
★ 第 592 轮:行 = 客户组 ⇒ 「是否已转」仍取组判(custUnconvConvOf);
组内还有未转档时额外给「转剩下这单」,避免未转的那张永远点不到。 */
var _ids=custUnconvConvIdsOf(root);
var _nm=_ids.map(function(id){var z=(DB.dealers||[]).find(function(x){return x.id===id;});return z?(z.name||''):'';}).filter(function(x){return !!x;});
var _nb=_nm.join('、');
var _multi=_nm.length>1;
var _selfOk=!!custUnconvAliveDealer(root.convertedDealerId);
var _dead=custUnconvDeadOf(root);
var amtLv=fLevel('amount');
var op='';
if(cv){
op+='<span class="pill" style="background:#dcfce7;color:#166534;white-space:nowrap" title="已转经销商:'+esc(_nb)+(_selfOk?'':'(同客户另一档案已转)')+(_dead?'(原经销商已删除)':'')+'">✅ 已转'+(_selfOk?'':(_multi?'(同户 · 共 '+_nm.length+' 家)':'(同户)'))+'</span>';
if(g.pend.length&&can('convert_dealer'))
op+=' <button class="btn ghost sm tap44" onclick="custUnconvPick(\''+esc(g.key)+'\',\'convert\')">🏷️ 转剩下这单</button>';
}else if(can('convert_dealer')){
op+='<button class="btn ghost sm tap44" onclick="custUnconvPick(\''+esc(g.key)+'\',\'convert\')">🏷️ 转经销商</button>';
}else{
op+='<span class="mtxt">未转</span>';
}
if(_dead&&!cv)op+=' <span class="mtxt" style="color:var(--danger)">原经销商已删除,请重新指定</span>';
op+=' '+custUnconvPayBtn(g);
var _n=g.arr.length;
var _tag=(_n>1)?(' <span class="pill" style="background:#eef2ff;color:#4338ca;border:1px solid #c7d2fe;white-space:nowrap" title="同一客户共 '+_n+' 张订单,本行金额为合计">共 '+_n+' 单</span>'):'';
var _tt=(_n>1)?(' title="含 '+_n+' 张订单合计"'):'';
return '<tr data-search="'+esc(custUnconvRaw(g))+'">'
+'<td style="white-space:nowrap">'+esc(root.custNo||'—')+'</td>'
+'<td class="wrap">'+fTxt('c_school',root.school)+'</td>'
+'<td>'+fTxt('c_name',root.name)+_tag+'</td>'
+'<td class="wrap">'+dealerNameHtml(d)+'</td>'
+'<td class="wrap">'+svcStaffCellHtml(root)+'</td>'
+'<td'+_tt+'>'+moneyMask(amtLv,g.amount)+'</td>'
+'<td'+_tt+'>'+moneyMask(amtLv,g.paid)+'</td>'
+'<td'+_tt+'>'+moneyMask(amtLv,g.owed)+'</td>'
+'<td style="white-space:nowrap">'+esc(g.signDate||'—')+'</td>'
+'<td style="white-space:nowrap">'+op+'</td>'
+'</tr>';
}
/* ★ 第 592 轮:客户组内逐档操作入口(转经销商 / +交款)。
组内只剩 1 个候选 ⇒ 直接开对应弹窗;多候选 ⇒ 先弹选单(列阶段 / 合同额 / 签约日 / 项目名)。 */
function custUnconvPick(gkey, action){
var g=custUnconvList().filter(function(x){return x.key===gkey;})[0];
if(!g){toast('未找到该客户,请刷新后重试');return;}
var cand=g.arr.slice();
if(action==='convert')cand=cand.filter(function(c){return !custUnconvAliveDealer(c.convertedDealerId);});
else cand=cand.filter(function(c){return !!payQuickBtn(c);});
if(!cand.length){toast(action==='convert'?'该客户已全部转为经销商':'当前账号无权登记交款');return;}
if(cand.length===1){if(action==='convert')openConvertDealer(cand[0].id);else openPayment('c',cand[0].id);return;}
var rows=cand.map(function(c,ix){
var items='';
try{items=(typeof orderItemsTextOf==='function')?(orderItemsTextOf(c)||''):'';}catch(e){items='';}
return '<label style="display:flex;gap:8px;align-items:flex-start;padding:9px 10px;border:1px solid var(--line);border-radius:8px;margin-bottom:8px;cursor:pointer">'
+'<input type="radio" name="cu_pick" value="'+esc(c.id)+'"'+(ix===0?' checked':'')+' style="margin-top:3px">'
+'<span style="font-size:13px;line-height:1.65"><b>'+esc(custUnconvStageText(c))+'</b> · 合同 '+money(Number(c.amount||0))+' · 已交 '+money(paidOf(c))
+'<br><span class="mtxt">签约 '+esc(c.signDate||'—')+(items?(' · '+esc(items)):'')+'</span></span></label>';
}).join('');
openModal('<div class="modal-head"><h3>选择要'+(action==='convert'?'转成经销商':'登记交款')+'的订单</h3><span class="x" onclick="closeModal()">×</span></div>'
+'<div class="modal-body"><div class="mtxt" style="margin-bottom:10px">「'+esc(g.root.school||'')+' / '+esc(g.root.name||'')+'」共 '+g.arr.length+' 张订单,请指定本次操作的是哪一张。</div>'+rows+'</div>'
+'<div class="modal-foot"><button class="btn ghost" onclick="closeModal()">取消</button>'
+'<button class="btn" onclick="custUnconvPickGo(\''+esc(action)+'\')">确定</button></div>');
}
function custUnconvPickGo(action){
var el=document.querySelector('input[name="cu_pick"]:checked');
if(!el){toast('请选择一张订单');return;}
var id=el.value;
closeModal();
if(action==='convert')openConvertDealer(id);else openPayment('c',id);
}
function custUnconvGrpHtml(g){
var all=custUnconvList().filter(function(x){return x.stage===g;});
if(!all.length)return '';
var name=['🔵 分期','🟢 全款','⚪ 已合作'][g];
var h='<tr data-grp="1"><td colspan="10" style="background:#f8fafc;font-weight:500;color:#475569;padding:8px 10px;border-top:1px solid #e2e8f0">'
+name+' '+custUnconvGrpSum(all)+'</td></tr>';
h+=all.map(custUnconvRowHtml).join('');
return h;
}
function renderCustUnconv(){
var view=document.getElementById('view');if(!view)return;
var list=custUnconvList();
var body=custUnconvGrpHtml(0)+custUnconvGrpHtml(1)+custUnconvGrpHtml(2);
if(!body){
body='<tr><td colspan="10" class="empty">'
+(custUnconvShowConv?'暂无记录':'暂无「未转经销商」客户 —— 已成交与已合作的客户都已转成经销商')
+'</td></tr>';
}
view.innerHTML=custSubSeg()+`
<div class="topbar"><div><h1>未转经销商客户</h1><p><span id="custUnconvSum">${custUnconvSumText(list)}</span></p></div>
${(can('export_data')||can('export_reconcile'))?`<button class="btn ghost" onclick="custUnconvExportCsv()">📤 导出 CSV</button>`:''}
</div>
<div class="search">
<input id="custUnconvSearch" class="list-search" value="${esc(custUnconvKw)}" placeholder="搜索 编号 / 校区 / 校长 / 经销商 / 对接员工 / 阶段" oninput="custUnconvSearch(this.value)" oncompositionend="custUnconvSearch(this.value)">
<label style="margin-left:10px;font-size:13px;white-space:nowrap;cursor:pointer;user-select:none"><input type="checkbox" id="custUnconvShowConv" ${custUnconvShowConv?'checked':''} onchange="custUnconvToggle(this.checked)"> 显示已转经销商</label>
</div>
<div class="card" style="overflow:auto">
<table id="custUnconvTbl"><thead><tr>
<th>客户编号</th><th>客户校区</th><th>校长</th><th>归属经销商</th><th>对接员工</th><th>合同总额</th><th>已交款金额</th><th>欠款金额</th><th>签约日期</th><th>操作</th>
</tr></thead><tbody id="custUnconvBody">${body}</tbody></table>
</div>`;
if(custUnconvKw)custUnconvSearch(custUnconvKw);
}
function custUnconvToggle(on){
custUnconvShowConv=!!on;
renderCustUnconv();
}
function custUnconvSearch(v){
var inp=document.getElementById('custUnconvSearch');
var raw=String((v===undefined?(inp?inp.value:''):v)||'');
custUnconvKw=raw;
var q=raw.trim().toLowerCase();
Array.prototype.forEach.call(document.querySelectorAll('#custUnconvTbl tbody tr[data-search]'),function(tr){
var hit=!q||String(tr.getAttribute('data-search')||'').toLowerCase().indexOf(q)>=0;
tr.style.display=hit?'':'none';
});
/* 搜索时把分组标题行也收起 —— 否则会出现「空组标题」 */
Array.prototype.forEach.call(document.querySelectorAll('#custUnconvTbl tbody tr[data-grp]'),function(tr){
tr.style.display=q?'none':'';
});
var s=document.getElementById('custUnconvSum');
if(s)s.textContent=custUnconvSumText();
}
function custUnconvExportCsv(){
if(!(can('export_data')||can('export_reconcile'))){toast('无权限:导出需「导出模块数据 / 全量数据」或「导出财务对账」权限');return;}
var all=custUnconvList();
if(!all.length){toast('没有可导出的记录');return;}
var q=String(custUnconvKw||'').trim().toLowerCase();
var _fList=q?all.filter(function(g){return custUnconvRaw(g).toLowerCase().indexOf(q)>=0;}):all;
if(_EXP_SCOPE===null){openExportScope({title:'未转经销商',unit:'条',desc:'一行 = 一个客户(同一客户的多张关联档案合并为一行,金额为组内合计;第 592 轮起)。',filterNote:q?('包含当前搜索关键字「'+q+'」'):'当前没有搜索关键字(与「全部」一致)',filterN:_fList.length,allN:all.length,onExport:function(){custUnconvExportCsv();}});return;}
var list=(_EXP_SCOPE==='filter')?_fList:all;
if(!list.length){toast('当前搜索没有匹配的记录');return;}
var cols=[
{h:'客户编号',fid:'c_name',g:function(g){return g.root.custNo||'';}},
{h:'客户校区',fid:'c_school',g:function(g){return fTxt('c_school',g.root.school);}},
{h:'校长',fid:'c_name',g:function(g){return fTxt('c_name',g.root.name);}},
{h:'归属经销商',fid:'d_name',g:function(g){var d=(DB.dealers||[]).find(function(x){return x.id===g.root.dealerId;});return fTxt('d_name',(d&&d.name)||'—');}},
{h:'对接员工',fid:'d_name',g:function(g){return fTxt('d_name',svcStaffNameOf(g.root)||'—');}},
{h:'阶段',fid:null,g:function(g){return custUnconvStageName(g.stage);}},
{h:'订单数',fid:null,g:function(g){return g.arr.length;}},
{h:'是否已转经销商',fid:'d_name',g:function(g){var _nm=custUnconvConvIdsOf(g.root).map(function(id){var z=(DB.dealers||[]).find(function(x){return x.id===id;});return z?(z.name||''):'';}).filter(function(x){return !!x;});var _selfOk=!!custUnconvAliveDealer(g.root.convertedDealerId);var _dead=custUnconvDeadOf(g.root);var _tail=(g.arr.length>1)?('·共 '+g.arr.length+' 单'+(g.pend.length?(',其中 '+g.pend.length+' 单未转'):'')):'';return g.conv?('是'+(_nm.length?'('+_nm.join('、')+')':'')+(_selfOk?'':(_dead?'·同户(原经销商已删除)':'·同户'))+_tail):(_dead?'否(原经销商已删除)':'否'+_tail);}},
{h:'合同总额',fid:'amount',g:function(g){return Number(g.amount||0);}},
{h:'已交款金额',fid:'amount',g:function(g){return Number(g.paid||0);}},
{h:'欠款金额',fid:'amount',g:function(g){return Number(g.owed||0);}},
{h:'签约日期',fid:'c_sign',g:function(g){return g.signDate||'';}}
];
var keep=[];
cols.forEach(function(col,i){if(!(col.fid&&fDeny(col.fid,'x')))keep.push(i);});
if(!keep.length){toast('无权限:当前角色对所有列均无导出权限');return;}
var out=[keep.map(function(i){return cols[i].h;})];
list.forEach(function(g){out.push(keep.map(function(i){return cols[i].g(g);}));});
var csv=out.map(function(r){
return r.map(function(x){
var t=String(x===undefined||x===null?'':x);
return /[",\n\r]/.test(t)?'"'+t.replace(/"/g,'""')+'"':t;
}).join(',');
}).join('\r\n');
var blob=new Blob(['\ufeff'+csv],{type:'text/csv;charset=utf-8'});
var a=document.createElement('a');
a.href=URL.createObjectURL(blob);
a.download='未转经销商客户_'+today()+'.csv';
document.body.appendChild(a);a.click();document.body.removeChild(a);
toast('已导出 '+list.length+' 家 · '+keep.length+' 列');
}
/* ★ 2026-10-14 第 457 轮(主人拍板 A+):客户列表「快捷登记交款」按钮 ——
客户列表(全部客户)与「已成交未签合同」子页行内各一处,直开既有「登记交款」弹窗。
权限闸与财务收款页同一把:view_finance(看财务)+ finCanReg(c)(登记权)+ 字段级 payments 编辑未关;
零新增权限键。登记时的「业绩归属经销商 / 员工」必填、已发放分润锁定、字段级遮罩全部沿用弹窗原闸门。 */
function payQuickBtn(c){
if(!c)return '';
try{ if(!can('view_finance')||!finCanReg('c',c.id)||fDeny('payments','e')) return ''; }catch(e){ return ''; }
return '<button class="btn sm" title="快捷登记交款" onclick="openPayment(\'c\',\''+c.id+'\')">+交款</button>';
}
/* ★ 2026-10-14 第 459 轮(主人「a 执行 · 尽量不左右拖动」):行操作收纳 ——
把低频行内按钮收进「⋯」浮层,高频操作留在行上,操作列从 439px 收到约 190px。
浮层挂 document.body + position:fixed 定位(绕开表格容器 overflow 裁剪,菜单不会被切掉)。
权限与可用性一律沿用既有判据(can / customerDeleteLock / dealerDeleteLock / quotaRootOf),不新造口径。
纯前端:不写库、不提交。 */
/* ★ 第 466 轮:建单后修改「本单分润设定」(业绩归属经销商 + 业绩归属员工)。
设错 / 忘设不必重建订单。权限各判各的:经销商侧 edit_comm_override / 员工侧 edit_comm_override_staff。
写入口径与「新增订单」弹窗一致(同一 sanitize 规范化)。 */
function openCommSet(cid){
var c=DB.customers.find(function(x){return x&&x.id===cid;});
if(!c){toast('未找到客户档案');return;}
var canD=can('edit_comm_override'),canS=can('edit_comm_override_staff');
if(!canD&&!canS){toast('无权限:需「设置订单分润」或「设置订单员工分润」');return;}
var _co=c.commOverride||{},_cs=c.commOverrideStaff||{};
var dm=(_co.mode==='rate'||_co.mode==='fixed')?_co.mode:'inherit';
var dv=(dm==='rate')?(Math.round(Number(_co.value||0)*10000)/100):(dm==='fixed'?(Number(_co.value)||0):'');
var sv=(Number(_cs.value)>0)?((_cs.mode==='fixed')?(Math.round(Number(_cs.value))):(Math.round(Number(_cs.value)*10000)/100)):'';
openModal(
'<div class="modal-head"><h3>💰 分润设定 · '+esc(c.name||'(未命名)')+'</h3><span class="x" onclick="closeModal()">×</span></div>'
+'<div class="modal-body">'
+(canD?('<div class="field"><label>业绩归属经销商分润</label><select id="cs_mode"><option value="inherit">按现有规则(默认)</option><option value="rate">按比例(%)</option><option value="fixed">固定金额(¥)</option></select></div>'
+'<div class="field"><label>值(比例填 20 = 20%;固定填元;「按现有规则」= 清除本单设定)</label><input id="cs_val" inputmode="decimal" value="'+dv+'"></div>'
+'<div class="field"><label>备注(选填)</label><input id="cs_note" value="'+esc(_co.note||'')+'"></div>'):'<div class="mtxt">(无「设置订单分润」权限:经销商侧不可改)</div>')
+(canS?('<div class="field"><label>业绩归属员工分润</label><select id="css_mode"><option value="inherit">按现有规则(默认)</option><option value="rate">按比例(%)</option><option value="fixed">固定金额(¥)</option></select></div>'
+'<div class="field"><label>值(比例填 3 = 3%;固定填元)</label><input id="css_val" inputmode="decimal" placeholder="留空 = 按现有规则(4.5%)" value="'+sv+'"></div>'
+'<div class="field"><label>员工侧备注(选填)</label><input id="css_note" value="'+esc(_cs.note||'')+'"></div>'
+'<div class="hint">「按比例」覆盖 4.5% 基础费率;「固定」= 本单员工侧固定总额(元),多归属按交款占比摊、尾差给最大份额。「当月交款合计 ≥ 10 万」考核线不变。分期 / 未签合同订单不参与分润。</div>'):'<div class="mtxt">(无「设置订单员工分润」权限:员工侧不可改)</div>')
+'</div>'
+'<div class="modal-foot"><button class="btn ghost" onclick="closeModal()">取消</button><button class="btn" onclick="saveCommSet(\''+esc(cid)+'\')">保存</button></div>');
var _ms=document.getElementById('cs_mode');if(_ms)_ms.value=dm;
var _ms2=document.getElementById('css_mode');if(_ms2)_ms2.value=((_cs.mode==='rate'||_cs.mode==='fixed')?_cs.mode:'inherit');
}
function saveCommSet(cid){
var c=DB.customers.find(function(x){return x&&x.id===cid;});
if(!c){toast('未找到客户档案');return;}
var canD=can('edit_comm_override'),canS=can('edit_comm_override_staff');
var oldD=c.commOverride,oldS=c.commOverrideStaff;
if(canD){
var m=String((document.getElementById('cs_mode')||{}).value||'inherit');
if(m==='inherit'){delete c.commOverride;}
else{
var v=Number(String((document.getElementById('cs_val')||{}).value||'').replace(/[^0-9.]/g,''))||0;
if(!(v>0)){c.commOverride=oldD;c.commOverrideStaff=oldS;toast('经销商侧:选了方式就要填值(大于 0)');return;}
var nt=String((document.getElementById('cs_note')||{}).value||'').slice(0,200);
c.commOverride=(m==='rate')?{mode:'rate',value:Math.min(1,Math.round((v/100)*10000)/10000),note:nt}:{mode:'fixed',value:Math.min(1000000,Math.round(v)),note:nt};
}
}
if(canS){
var sv=Number(String((document.getElementById('css_val')||{}).value||'').replace(/[^0-9.]/g,''))||0;
var sm=String((document.getElementById('css_mode')||{}).value||'inherit');
if((sm==='rate'||sm==='fixed')&&sv>0){c.commOverrideStaff=(sm==='rate')?{mode:'rate',value:Math.min(1,Math.round((sv/100)*10000)/10000),note:String((document.getElementById('css_note')||{}).value||'').slice(0,200)}:{mode:'fixed',value:Math.min(1000000,Math.round(sv)),note:String((document.getElementById('css_note')||{}).value||'').slice(0,200)};}
else{delete c.commOverrideStaff;}
}
commDealerOwnTbl._t=null;
save().then(function(ok){if(ok){closeModal();render();toast('已保存「'+(c.name||'')+'」的分润设定');}else{c.commOverride=oldD;c.commOverrideStaff=oldS;commDealerOwnTbl._t=null;}});
}
var ROW_MORE_HOST = null;
function rowMoreItems(kind, id) {
var it = [];
if (kind === 'cust') {
var c = null; (DB.customers || []).forEach(function (x) { if (x.id === id) c = x; });
if (!c) return it;
if (can('convert_dealer') && !c.convertedDealerId) it.push({ t: '转为经销商', fn: 'openConvertDealer' });
if (can('convert_dealer') && c.convertedDealerId) it.push({ t: '撤销转为经销商', fn: 'openRevertConvert' });
if (can('edit_customers')) it.push({ t: '+ 新增订单', fn: 'openNewOrder' });
if (can('edit_customers')) it.push({ t: '+ 添加关联档案', fn: 'openLinkedCustForm' });
/* ★ 第 502 轮(主人 2026-10-17「A + 两条都同意」):报备建单没让人选哪一种体验课 ⇒ 行菜单补「选择档位」入口 */
if (quotaTierPendingOf(c) && can('edit_customers')) it.push({ t: '🎟 选择体验课档位(待确认)', fn: 'openQuotaTierPick', arg: c.id });
if (can('edit_comm_override') || can('edit_comm_override_staff')) it.push({ t: '💰 分润设定', fn: 'openCommSet' });
if (can('edit_cust_quota')) {
var r = quotaRootOf(c);
if (r && r.quota && Number(r.quota.total) > 0) it.push({ t: '🎟 体验课名额', fn: 'openQuotaMeta', arg: r.id });
}
if (can('view_customers') && canField('c_followups', 'e')) it.push({ t: '✍️ 写跟进', fn: 'openCustFollow' });
if (can('del_customers')) {
var lk = customerDeleteLock(c);
if (lk) it.push({ t: '删除客户', dis: 1, lock: lk });
else it.push({ t: '删除客户', fn: 'delCustomer', danger: 1 });
}
return it;
}
if (kind === 'dealer') {
var d = null; (DB.dealers || []).forEach(function (x) { if (x.id === id) d = x; });
if (!d) return it;
if (can('pay_commission')) it.push({ t: '💰 发放分润', fn: 'openCommissionPayout' });
if (can('edit_dealers')) it.push({ t: '编辑', fn: 'openDealer' });
if (can('view_dealers')) it.push({ t: '详情 / 跟进', fn: 'openDealerDetail' });
if (can('del_dealers')) {
var lk2 = dealerDeleteLock(d);
if (lk2) it.push({ t: '删除经销商', dis: 1, lock: lk2 });
else it.push({ t: '删除经销商', fn: 'delDealer', danger: 1 });
}
return it;
}
return it;
}
function rowMoreBtn(kind, id) {
if (!rowMoreItems(kind, id).length) return '';
return '<button class="btn ghost sm" title="更多操作" onclick="openRowMore(event,\'' + kind + '\',\'' + id + '\')">⋯</button>';
}
function openRowMore(ev, kind, id) {
if (ev) { if (ev.preventDefault) ev.preventDefault(); if (ev.stopPropagation) ev.stopPropagation(); }
closeRowMore();
var it = rowMoreItems(kind, id);
if (!it.length) { toast('当前角色没有可用的行操作'); return; }
var host = document.createElement('div');
host.id = '__rowMore';
host.style.cssText = 'position:fixed;z-index:9999;background:#fff;border:1px solid #e2e8f0;border-radius:10px;box-shadow:0 8px 24px rgba(15,23,42,.16);padding:6px;min-width:172px';
host.innerHTML = it.map(function (x) {
if (x.dis) return '<button type="button" disabled title="禁止删除:' + esc(x.lock || '') + '" style="display:block;width:100%;text-align:left;background:transparent;border:0;padding:8px 12px;font-size:13px;border-radius:6px;color:#cbd5e1;cursor:not-allowed">' + esc(x.t) + '(不可删)</button>';
return '<button type="button" data-mi="' + it.indexOf(x) + '" style="display:block;width:100%;text-align:left;background:transparent;border:0;padding:8px 12px;font-size:13px;border-radius:6px;cursor:pointer;color:' + (x.danger ? '#dc2626' : '#334155') + '">' + esc(x.t) + '</button>';
}).join('');
document.body.appendChild(host);
var r = (ev && ev.currentTarget) ? ev.currentTarget.getBoundingClientRect() : null;
var x0 = r ? r.left : 200, y0 = r ? r.bottom + 4 : 200;
var w = host.offsetWidth, h = host.offsetHeight;
if (x0 + w > window.innerWidth - 8) x0 = Math.max(8, window.innerWidth - w - 8);
if (y0 + h > window.innerHeight - 8) y0 = Math.max(8, (r ? r.top - h - 4 : 200));
host.style.left = Math.round(x0) + 'px';
host.style.top = Math.round(y0) + 'px';
[].slice.call(host.querySelectorAll('button[data-mi]')).forEach(function (b) {
b.onclick = function () {
var x = it[+b.getAttribute('data-mi')];
closeRowMore();
if (!x || !x.fn) return;
try { window[x.fn](x.arg !== undefined ? x.arg : id); } catch (e) { toast('操作失败:' + ((e && e.message) || e)); }
};
});
ROW_MORE_HOST = host;
}
function closeRowMore() {
if (ROW_MORE_HOST && ROW_MORE_HOST.parentNode) ROW_MORE_HOST.parentNode.removeChild(ROW_MORE_HOST);
ROW_MORE_HOST = null;
}
if (!window.__rowMoreBound) {
window.__rowMoreBound = 1;
document.addEventListener('click', function (e) {
if (!ROW_MORE_HOST) return;
if (e.target && e.target.closest && e.target.closest('#__rowMore')) return;
closeRowMore();
}, true);
window.addEventListener('scroll', function () { closeRowMore(); }, true);
document.addEventListener('keydown', function (e) { if (e && e.key === 'Escape') closeRowMore(); });
}
/* 子页签(全部客户 / 已成交未签合同 N)—— 与 renderFinance 的 finSub 同范式 */
function custSubSeg(){
var n=custDealNoSignList().length;
return `<div class="seg" style="margin:0 0 18px">
<button class="seg-btn ${custSub==='all'?'active':''}" onclick="custSub='all';renderCustomers()">👥 全部客户</button>
<button class="seg-btn ${custSub==='dealnosign'?'active':''}" onclick="custSub='dealnosign';renderCustomers()">⚠️ 已成交未签合同 (${n})</button>
<button class="seg-btn ${custSub==='unconv'?'active':''}" onclick="custSub='unconv';renderCustomers()">🏷️ 未转经销商 (${custUnconvTabCount()})</button>
<button class="seg-btn ${custSub==='baobei'?'active':''}" onclick="custSub='baobei';renderCustomers()">⏳ 报备待处理 (${bpbCount})</button>
<button class="seg-btn ${custSub==='follow'?'active':''}" onclick="custSub='follow';renderCustomers()">📮 待跟进 (${custFollowTodoList().length})</button>
</div>`;
}
/* ★ 第 296 轮(主人 2026-10-04 要求新增):「报备待处理」子页签 ——
只读展示同步器三类「没能自动落成客户」的报备:待归属 / 人工暂缓 / 手机号已在库。
数据来自只读接口 GET /api/baobei/pending(服务端读同步器状态文件 + 报备记录回查)。 */
var bpbCount=0;
var _bpbLastAt=0;
function bpbRefreshCount(force){
var _now=Date.now();
if(!force&&_now-_bpbLastAt<60000)return;
_bpbLastAt=_now;
apiGet('/api/baobei/pending').then(function(d){
var c=(d&&d.counts)||{};
var n=(c.pending||0)+(c.hold||0);
if(n===bpbCount)return;
bpbCount=n;
if(custSub==='baobei')renderCustBaobeiPending();
else if(curView==='customers')renderCustomers();
}).catch(function(){});
}
function bpbTable(title,desc,rows,cols){
var head='';
for(var i=0;i<cols.length;i++)head+='<th>'+cols[i][1]+'</th>';
var body='';
if(rows.length){
for(var j=0;j<rows.length;j++){
body+='<tr>';
for(var k=0;k<cols.length;k++)body+='<td>'+cols[k][2](rows[j])+'</td>';
body+='</tr>';
}
}else body='<tr><td colspan="'+cols.length+'" class="empty">(无)</td></tr>';
return '<div class="card table-card" style="margin-top:14px">'
+'<div style="padding:12px 14px 8px"><b>'+title+'</b>'
+'<div class="mtxt" style="margin:4px 0 0;line-height:1.7">'+desc+'</div></div>'
+'<table><thead><tr>'+head+'</tr></thead><tbody>'+body+'</tbody></table></div>';
}
function renderCustBaobeiPending(){
var view=document.getElementById('view');if(!view)return;
view.innerHTML=custSubSeg()+`
<div class="topbar"><div><h1>报备待处理</h1><p>报备系统里有、但没能自动落成 CRM 客户的记录(需人工确认归属,或改报备)</p></div>
<button class="btn ghost" onclick="renderCustBaobeiPending()">🔄 刷新</button>
</div>
<div id="bpbWrap"><div class="card"><p class="empty">正在加载…</p></div></div>`;
apiGet('/api/baobei/pending').then(function(d){
var w=document.getElementById('bpbWrap');if(!w)return;
var c=(d&&d.counts)||{pending:0,hold:0,skipped:0,done:0};
bpbCount=(c.pending||0)+(c.hold||0);
var html='<div class="grid kpis" style="grid-template-columns:repeat(4,1fr)">'
+kpiCard('⏳','待归属',c.pending,'填写人对不上、也没有对接员工','#d97706')
+kpiCard('⏸','人工暂缓',c.hold,'归属能算出、但还没确认','#6366f1')
+kpiCard('⏭','手机号已在库',c.skipped,'查重跳过(未建客户)','#0891b2')
+kpiCard('✅','已自动导入',c.done,'已落成 CRM 报备客户','#16a34a')
+'</div>';
html+=bpbTable('⏳ 待归属('+c.pending+' 条)',
'这些报备的「填写人」在 CRM 经销商里查不到,该条也没有指派「对接员工」,所以没有归属、不能建客户。<br>'
+'处理方式:① 到报备系统把「填写人」改成正确的经销商;或 ② 在 CRM 里把这个人建成经销商 / 加进别名表。',
d.pending||[],[
['c','校区 / 机构',function(r){return esc(r.campusName||'—');}],
['o','填写人',function(r){return esc(r.ownerName||'(空)');}],
['d','报备日',function(r){return esc(r.at||'—');}],
['x','操作',function(r){return bpbAssignBtn(r);}]
]);
html+=bpbTable('⏸ 人工暂缓('+c.hold+' 条)',
'这些报备能算出归属(靠该条在报备系统里指派的「对接员工」兜底),但主人尚未确认,所以<b>暂不导入</b>。<br>'
+'处理方式:点行尾「指派」选定真正的归属经销商 —— 指派会<b>同时解除本条的暂缓并立即导入</b>(第 625 轮)。<br>'
+'若某行行尾是「—」而不是「指派」,说明这条暂缓<b>不是「归属待确认」</b>(指派也解不了),需先修报备数据。',
d.hold||[],[
['c','校区 / 机构',function(r){return esc(r.campusName||'—');}],
['o','填写人',function(r){return esc(r.ownerName||'(空)');}],
['a','能归到谁',function(r){return esc(r.dealerName||'—');}],
['d','报备日',function(r){return esc(r.at||'—');}],
['x','操作',function(r){return bpbAssignBtn(r);}]
]);
html+=bpbTable('⏭ 手机号已在库('+c.skipped+' 条)',
'同步器按手机号查重跳过了这些报备(CRM 里已有同号客户),<b>没有建新客户</b>。<br>'
+'提示:若是「同一客户报了第二个项目」,请勿重复报备 —— 请到客户列表找到该客户,点「+ 新增订单」建立新订单。',
(d.skipped||[]).slice(0,200),[
['c','校区 / 机构',function(r){return esc(r.campusName||'—');}],
['o','填写人',function(r){return esc(r.ownerName||'(空)');}],
['p','手机号',function(r){return esc(r.phone||'—');}],
['d','报备日',function(r){return esc(r.at||'—');}]
]);
if((d.skipped||[]).length>200)html+='<div class="hint" style="margin-top:10px">已跳过名单过长,仅显示前 200 条。</div>';
html+='<div class="hint" style="margin-top:14px">本页只读 | 同步器水位线 '+esc(fmtTime(d.watermark))
+' | 最近一轮 '+esc(fmtTime(d.lastRunAt))+'</div>';
w.innerHTML=html;
}).catch(function(e){
var w=document.getElementById('bpbWrap');
if(w)w.innerHTML='<div class="card"><p class="empty">加载失败:'+esc((e&&e.message)||'')+'</p></div>';
});
}
/* ★ 第 538 轮(主人「界面上直接点指派按钮」):报备待归属的人工指派入口。
权限复用 assign_cust_ops(与「指派对接员工/服务老师」同键);没有该权限的行显示「—」。
指派一次写进 nameMap,以后同一条原文自动归这家(解析器第 ⓪ 档)。 */
function bpbB64(s){try{return btoa(unescape(encodeURIComponent(String(s||''))));}catch(e){return '';}}
function bpbUnb64(s){try{return decodeURIComponent(escape(atob(String(s||''))));}catch(e){return '';}}
var BPB_ASSIGN=null;
function bpbAssignBtn(r){
if(typeof can!=='function'||!can('assign_cust_ops'))return '<span class="mtxt">—</span>';
/* ★ 第 626 轮(主人 2026-10-26「修:p3」):非归属类暂缓(hold 里没有 dealerId)**指派也解不了暂缓**
⇒ 不给按钮,避免"点了指派、显示成功、实际导入 0 条"(与第 539 轮那个坑同型)。
接口 /api/baobei/pending 对每行下发 canAssign:pending 恒 true、hold 看有无 dealerId。 */
if(r&&r.canAssign===false)return '<span class="mtxt" title="非归属类暂缓:指派解不了,需先修报备数据">—</span>';
/* ★ 第 554 轮:把本条报备的上下文一并带进弹窗(校区 / 项目用来辨认重名的两家) */
var _ac=[String(r.ownerName||''),String(r.baobeiId||''),String(r.campusName||''),(r.intent||[]).join('、')];
return '<button class="btn ghost" style="padding:2px 8px;font-size:12px" onclick="openBaobeiAssign(\''+bpbB64(JSON.stringify(_ac))+'\')">指派</button>';
}
function openBaobeiAssign(b64){
var cfg=null;try{cfg=JSON.parse(bpbUnb64(b64));}catch(_e){cfg=[bpbUnb64(b64)];}
var name=String((cfg&&cfg[0])||'');if(!name)return;
var recId=String((cfg&&cfg[1])||''),campus=String((cfg&&cfg[2])||''),ints=String((cfg&&cfg[3])||'');
BPB_ASSIGN={name:name,baobeiId:recId,dealerId:'',scope:recId?'one':'name'};
var ds=(DB.dealers||[]).slice().sort(function(a,b){return String(a.name||'').localeCompare(String(b.name||''),'zh');});
var opts='<option value="">— 选择经销商 —</option>';
ds.forEach(function(d){opts+='<option value="'+esc(d.id)+'">'+esc(d.name)+'('+(d.type==='staff'?'员工':'经销商')+')</option>';});
openModal(`
<div class="modal-head"><h3>指派报备归属</h3><span class="x" onclick="closeModal()">×</span></div>
<div class="modal-body">
<p style="margin:0 0 6px">填写人原文:<b>${esc(name)}</b></p>
${(campus||ints)?('<p class="hint" style="margin:0 0 10px">本条报备:'+esc(campus||'(无校区)')+(ints?' · 项目 '+esc(ints):' · 项目未填')+'</p>'):''}
${recId?('<div class="field"><label>指派范围</label>'+
'<label style="display:block;margin:2px 0"><input type="radio" name="bpbScope" value="one" checked onchange="bpbScope(\'one\')"> 仅这一条报备(推荐:不污染同名其它条)</label>'+
'<label style="display:block"><input type="radio" name="bpbScope" value="name" onchange="bpbScope(\'name\')"> 该填写人以后全部(同名所有报备都归这家)</label>'+
'</div>'):''}
<div class="field"><label>搜索经销商</label><input id="bpbKw" placeholder="输入名字筛选" oninput="bpbFilterDealers()"></div>
<div class="field"><label>归到哪一家</label><select id="bpbDealer" onchange="bpbPreview()">${opts}</select></div>
<div class="field"><label>备注(可选)</label><input id="bpbNote" placeholder="例如:主人确认 / 与 XX 同一人"></div>
<div id="bpbPrev" class="hint">选一家经销商,这里会显示影响范围。</div>
<div class="hint" style="margin-top:8px">「仅这一条」只影响本条报备,同名其它条不动;「以后全部」才会给这个填写人建永久记忆。经销商改名或删除后记忆自动失效;已导入的客户不会回退。</div>
</div>
<div class="modal-foot">
<button class="btn ghost" onclick="closeModal()">取消</button>
<button class="btn" id="bpbOk" onclick="bpbDoAssign()" disabled>确认指派</button>
</div>`);}
function bpbFilterDealers(){
var kw=String((document.getElementById('bpbKw')||{}).value||'').trim();
var sel=document.getElementById('bpbDealer');if(!sel)return;
for(var i=0;i<sel.options.length;i++){
var o=sel.options[i];if(!o.value)continue;
o.style.display=(!kw||String(o.textContent).indexOf(kw)>=0)?'':'none';
}
}
/* ★ 第 554 轮:单条指派 —— scope='one' 只把本条报备编号发给后端,不写名字记忆 */
function bpbScope(v){if(!BPB_ASSIGN)return;BPB_ASSIGN.scope=(v==='name'?'name':'one');bpbPreview();}
function bpbBody(extra){
var b={dealerId:BPB_ASSIGN.dealerId};
if(BPB_ASSIGN.scope==='one'&&BPB_ASSIGN.baobeiId){b.baobeiId=BPB_ASSIGN.baobeiId;if(BPB_ASSIGN.name)b.name=BPB_ASSIGN.name;}
else b.name=BPB_ASSIGN.name;
if(extra)for(var k in extra)b[k]=extra[k];
return b;
}
function bpbPreview(){
if(!BPB_ASSIGN)return;
var sel=document.getElementById('bpbDealer');var id=sel?sel.value:'';
BPB_ASSIGN.dealerId=id;
var ok=document.getElementById('bpbOk');if(ok)ok.disabled=!id;
var box=document.getElementById('bpbPrev');if(!box)return;
if(!id){box.textContent='选一家经销商,这里会显示影响范围。';return;}
if(BPB_ASSIGN.scope==='one'&&!BPB_ASSIGN.baobeiId){box.textContent='这一行没有报备编号,只能按「该填写人全部」指派。';return;}
box.textContent='正在预演…';
apiPost('/api/baobei/assign',bpbBody({dryRun:true})).then(function(d){
var c=(d&&d.counts)||{};
var t=(BPB_ASSIGN.scope==='one'?'本条报备:':'该填写人全部:')+'将新增 '+(c.add||0)+' 条';
if(c.done)t+='(该条此前已导入,本次无变化)';
if(c.skipPhone)t+=';跳过 '+(c.skipPhone)+' 条(手机号已在库)';
if(c.skipWeak)t+=';跳过 '+(c.skipWeak)+' 条(校区+经销商已在库)';
if(c.held)t+=';其中 '+(c.held)+' 条原在「人工暂缓」,本次一并解除';
if(c.heldKeep)t+=';另有 '+(c.heldKeep)+' 条在「人工暂缓」,本次不会导入';
box.textContent=t;
}).catch(function(e){box.textContent='预演失败:'+((e&&e.message)||'');});
}
function bpbDoAssign(){
if(!BPB_ASSIGN||!BPB_ASSIGN.dealerId)return;
var ok=document.getElementById('bpbOk');if(ok){ok.disabled=true;ok.textContent='指派中…';}
var note=String((document.getElementById('bpbNote')||{}).value||'').trim();
var _one=(BPB_ASSIGN.scope==='one'&&!!BPB_ASSIGN.baobeiId);
apiPost('/api/baobei/assign',bpbBody({note:note})).then(function(d){
var a=(d&&d.applied)||{};var pc=(d&&d.counts)||{};
var msg=(_one?'已指派本条 → ':'已指派「'+BPB_ASSIGN.name+'」→ ')+((d&&d.dealer&&d.dealer.name)||'')+';本次导入 '+(a.added||0)+' 条,跳过 '+(a.skipped||0)+' 条';
if(!a.added&&pc.done)msg+='(该填写人 '+pc.done+' 条此前已导入,本次无新增)';
if(d&&d.holdReleased)msg+=';解除人工暂缓 '+(d.holdReleased)+' 条';
toast(msg);
closeModal();
if(typeof bpbRefreshCount==='function')bpbRefreshCount();
if(typeof renderCustBaobeiPending==='function')renderCustBaobeiPending();
}).catch(function(e){
if(ok){ok.disabled=false;ok.textContent='确认指派';}
toast('指派失败:'+((e&&e.message)||''));
});
}
/* 行可搜文本(与屏幕展示同源;不含金额,避免遮罩被绕过) */
function custDealNoSignRaw(c){
var d=(DB.dealers||[]).find(function(x){return x.id===c.dealerId;});
return [c.custNo||'',c.school||'',c.name||'',(d&&(d.region||''))||'',(d&&(d.name||''))||'',svcStaffNameOf(c)||''].join(' ');
}
/* ★ 第 163 轮续:汇总行统一口径 —— 无搜索 →「共 N 家」;有搜索 →「当前显示 M / N 家」;
两种情形都带「已交款合计 / 欠款合计」(金额按当前可见行合计,与屏幕所见一致)。
旧写法在搜索函数里写死「当前显示 N 家」⇒ 清空搜索后金额合计再也回不来。 */
function custDealNoSignSumText(all){
all=all||custDealNoSignList();
var amtLv=fLevel('amount');
var kw=String(custDealKw||'').trim().toLowerCase();
var vis=kw?all.filter(function(c){return custDealNoSignRaw(c).toLowerCase().indexOf(kw)>=0;}):all;
var sp=0,so=0;
vis.forEach(function(c){sp+=paidOf(c);so+=owedOf(c);});
return (kw?('当前显示 '+vis.length+' / '+all.length+' 家'):('共 '+all.length+' 家'))
+' · 已交款合计 '+moneyMask(amtLv,sp)+' · 欠款合计 '+moneyMask(amtLv,so);
}
function custDealNoSignSearch(v){
var inp=document.getElementById('custDealSearch');
var raw=String((v===undefined?(inp?inp.value:''):v)||'');
custDealKw=raw; /* ★ 第 163 轮:搜索词落状态(不落状态 ⇒ 重画后丢词、全表重新展开) */
var q=raw.trim().toLowerCase();
Array.prototype.forEach.call(document.querySelectorAll('#custDealTbl tbody tr[data-search]'),function(tr){
var hit=!q||String(tr.getAttribute('data-search')||'').toLowerCase().indexOf(q)>=0;
tr.style.display=hit?'':'none';
});
/* ★ 第 163 轮续:汇总行统一走 custDealNoSignSumText(无搜索→共 N 家;有搜索→当前显示 M / N 家),
两种情形都带金额合计;旧写法在此写死「当前显示 N 家」会丢金额合计且清空后回不来 */
var s=document.getElementById('custDealSum');
if(s)s.textContent=custDealNoSignSumText();
}
/* 导出 CSV:与屏幕一致(跟随当前搜索);列级按 fDeny(fid,'x') 删除(与 exportDropRows 同口径) */
function custDealNoSignExportCsv(){
if(!(can('export_data')||can('export_reconcile'))){toast('无权限:导出需「导出模块数据 / 全量数据」或「导出财务对账」权限');return;}
var all=custDealNoSignList();
if(!all.length){toast('没有可导出的记录');return;}
/* ★ 第 272 轮:先问范围(默认当前搜索)。搜索词取状态 custDealKw(与屏幕同源,重画也不丢)。 */
var q=String(custDealKw||'').trim().toLowerCase();
var _fList=q?all.filter(function(c){return custDealNoSignRaw(c).toLowerCase().indexOf(q)>=0;}):all;
if(_EXP_SCOPE===null){openExportScope({title:'已成交未签合同',unit:'条',desc:'一行 = 一个已成交但未签合同的客户。',filterNote:q?('包含当前搜索关键字「'+q+'」'):'当前没有搜索关键字(与「全部」一致)',filterN:_fList.length,allN:all.length,onExport:function(){custDealNoSignExportCsv();}});return;}
var list=(_EXP_SCOPE==='filter')?_fList:all;
if(!list.length){toast('当前搜索没有匹配的记录');return;}
var cols=[
{h:'客户校区',fid:'c_school',g:function(c){return fTxt('c_school',c.school);}},
{h:'校长',fid:'c_name',g:function(c){return fTxt('c_name',c.name);}},
{h:'归属经销商',fid:'d_name',g:function(c){var d=(DB.dealers||[]).find(function(x){return x.id===c.dealerId;});return fTxt('d_name',(d&&d.name)||'—');}},
{h:'对接员工',fid:'d_name',g:function(c){return fTxt('d_name',svcStaffNameOf(c)||'—');}},
{h:'交款时间',fid:null,g:function(c){return custLastPayDate(c)||'—';}},
{h:'已交款金额',fid:'amount',g:function(c){return Number(paidOf(c));}},
{h:'欠款金额',fid:'amount',g:function(c){return Number(owedOf(c));}},
{h:'是否推送交付',fid:null,g:function(c){return custDealPushText(c);}}
];
var keep=[];
cols.forEach(function(col,i){if(!(col.fid&&fDeny(col.fid,'x')))keep.push(i);});
if(!keep.length){toast('无权限:当前角色对所有列均无导出权限');return;}
var out=[keep.map(function(i){return cols[i].h;})];
list.forEach(function(c){out.push(keep.map(function(i){return cols[i].g(c);}));});
var csv=out.map(function(r){
return r.map(function(x){
var t=String(x===undefined||x===null?'':x);
return /[",\n\r]/.test(t)?'"'+t.replace(/"/g,'""')+'"':t;
}).join(',');
}).join('\r\n');
var blob=new Blob(['\ufeff'+csv],{type:'text/csv;charset=utf-8'});
var a=document.createElement('a');
a.href=URL.createObjectURL(blob);
a.download='已成交未签合同客户_'+today()+'.csv';
document.body.appendChild(a);a.click();document.body.removeChild(a);
toast('已导出 '+list.length+' 家 · '+keep.length+' 列');
}
/* 推送交付列 = 只读状态(不做按钮:未签合同的客户点「推送交付」会被系统硬拦弹错,摆按钮会误导操作) */
function custDealPushText(c){
if(deliveryFullyDone('customer',c.id))return '✅ 已交付';
if(isPushed('customer',c))return '✅ 已推送';
return '⬜ 未推送';
}
/* 子页面渲染:8 列表格 */
function renderCustDealNoSign(){
var view=document.getElementById('view');if(!view)return;
var list=custDealNoSignList();
var amtLv=fLevel('amount');
var rows=list.length?list.map(function(c){
var d=(DB.dealers||[]).find(function(x){return x.id===c.dealerId;});
var pd=custLastPayDate(c);
return `<tr data-search="${esc(custDealNoSignRaw(c))}">
<td class="wrap">${fTxt('c_school',c.school)}</td>
<td>${fTxt('c_name',c.name)}</td>
<td class="wrap">${dealerNameHtml(d)}</td>
<td class="wrap">${svcStaffCellHtml(c)}</td>
<td>${pd||'—'}</td>
<td>${moneyMask(amtLv,paidOf(c))}</td>
<td>${moneyMask(amtLv,owedOf(c))}</td>
<td>${custDealPushText(c)}</td>
<td>${payQuickBtn(c)}</td>
</tr>`;
}).join(''):'<tr><td colspan="9" class="empty">暂无「已成交未签合同」客户</td></tr>';
view.innerHTML=custSubSeg()+`
<div class="topbar"><div><h1>已成交未签合同客户</h1><p><span id="custDealSum">${custDealNoSignSumText(list)}</span></p></div>
${(can('export_data')||can('export_reconcile'))?`<button class="btn ghost" onclick="custDealNoSignExportCsv()">📤 导出 CSV</button>`:''}
</div>
<div class="search">
<input id="custDealSearch" class="list-search" value="${esc(custDealKw)}" placeholder="搜索 编号 / 校区 / 校长 / 经销商 / 对接员工" oninput="custDealNoSignSearch(this.value)" oncompositionend="custDealNoSignSearch(this.value)">
</div>
<div class="card" style="overflow:auto">
<table id="custDealTbl"><thead><tr>
<th>客户校区</th><th>校长</th><th>归属经销商</th><th>对接员工</th><th>交款时间</th><th>已交款金额</th><th>欠款金额</th><th>是否推送交付</th><th>操作</th>
</tr></thead>
<tbody id="custDealBody">${rows}</tbody></table>
</div>`;
if(custDealKw)custDealNoSignSearch(custDealKw); /* ★ 第 163 轮:重画后重放过滤(同主表末尾 custSearch(custFilter.kw)) */
}
let custFilter={teacher:'',dealer:'',staff:'',stage:'',kw:'',signStart:'',signEnd:'',intent:'',contract:'',install:'',refund:'',reportStart:'',reportEnd:'',paidMin:'',paidMax:'',followKw:'',payKw:'',addrKw:'',followState:'',followStaff:'',followFrom:'',followTo:'',mine:false,orderKind:''};/* ★ 第490轮:客户列表「📮 待跟进」子页签 —— 只列还在推进(reported / following)且保护期未过的客户,
按「最近跟进」升序(从未跟进排最前)⇒ 一眼看出该跟谁。口径函数见 custFollowStat / custFollowOrdered。
本页只读,唯一写入口是「✍️ 写跟进」⇒ 复用既有 openCustomerFollowup(内含时间线与下次跟进时间)。 */
/* ★ 第515轮(主人 2026-10-18 拍板 A):本页补「搜索 / 筛选 / 导出」。
─ 取数口单一:custFollowFiltered() 同时供屏幕与导出(同第272轮 custFilteredList 范式,禁两处漂移)。
─ 与「全部客户」的 custFilter 完全隔离(切子页签互不串味);筛选状态只存会话内,不落库。
─ 权限:搜索串走 custSearchBlob(手机号 / 地址等按字段权限拼),无权限者搜不到,不越权。 */
var custFollowFilter={kw:'',state:'',dealer:'',staff:''};
var custFollowAdvOpen=false;
function custFollowBlob(c){
var d=DB.dealers.find(function(x){return x.id===c.dealerId;});
return (custSearchBlob(c)+' '+((d&&d.name)||'')).toLowerCase();
}
/* 跟进情况判定:在 followStateHit 之外补「8–30 天」一档(顶部色阶要能精确点中这一档;
followStateHit 的 d30 是 0–30 天、含 7 天内,不能直接拿来用)。 */
function custFollowStateHit(c,state,t){
if(!state)return true;
if(state==='d8to30'){var st=custFollowStat(c);var dd=st.lastAt?followAgoDays(st.lastAt,t):-1;return dd>=8&&dd<=30;}
return followStateHit(c,state,t);
}
function custFollowFiltered(){
var f=custFollowFilter,list=custFollowTodoList();
if(f.dealer)list=list.filter(function(c){return c.dealerId===f.dealer;});
if(f.staff)list=list.filter(function(c){return c.serviceStaffId===f.staff;});
if(f.state){var t=today();list=list.filter(function(c){return custFollowStateHit(c,f.state,t);});}
var kw=String(f.kw||'').trim().toLowerCase();
if(kw)list=list.filter(function(c){return custFollowBlob(c).indexOf(kw)>=0;});
return list;
}
function custFollowCondN(){var f=custFollowFilter;return (f.dealer?1:0)+(f.staff?1:0)+(f.state?1:0);}
function custFollowStateLabel(v){
var a=[['none','从未跟进'],['d7','7 天内跟过'],['d8to30','8–30 天跟过'],['over30','超 30 天未跟进'],['hasNext','已设下次跟进'],['nextOver','下次跟进已逾期']];
for(var i=0;i<a.length;i++)if(a[i][0]===v)return a[i][1];
return v;
}
function custFollowStateOpts(){
var cur=custFollowFilter.state||'';
var a=[['','全部'],['none','从未跟进'],['d7','7 天内跟过(≤7 天)'],['d8to30','8–30 天跟过'],['over30','超 30 天未跟进'],['hasNext','已设下次跟进'],['nextOver','下次跟进已逾期']];
return a.map(function(x){return '<option value="'+x[0]+'"'+(cur===x[0]?' selected':'')+'>'+x[1]+'</option>';}).join('');
}
function custFollowTagsHtml(){
var f=custFollowFilter,a=[];
if(f.dealer){var d=DB.dealers.find(function(x){return x.id===f.dealer;});a.push(['dealer','归属经销商:'+((d&&d.name)||'—')]);}
if(f.staff){var s=DB.dealers.find(function(x){return x.id===f.staff;});a.push(['staff','对接员工:'+((s&&s.name)||'—')]);}
if(f.state)a.push(['state','跟进:'+custFollowStateLabel(f.state)]);
if(!a.length)return '';
return '<div style="display:flex;gap:6px;flex-wrap:wrap;margin:-4px 0 10px">'+a.map(function(x){
return '<span class="pill" style="cursor:pointer" title="点击取消此项筛选" onclick="custFollowClear(\''+x[0]+'\')">'+esc(x[1])+' ×</span>';
}).join('')+'</div>';
}
function custFollowQuick(state){custFollowFilter.state=(custFollowFilter.state===state)?'':state;renderCustFollowBoard();}
function custFollowClear(k){if(k==='dealer'||k==='staff'||k==='state')custFollowFilter[k]='';renderCustFollowBoard();}
function toggleCustFollowAdv(){custFollowAdvOpen=!custFollowAdvOpen;var p=document.getElementById('custFollowAdv');if(p)p.style.display=custFollowAdvOpen?'block':'none';}
function resetCustFollowFilter(){custFollowFilter={kw:'',state:'',dealer:'',staff:''};renderCustFollowBoard();}
/* 导出当前筛选(与屏幕同源):列级按 fDeny(fid,'x') 删列,跟进三列受 c_followups 权限。 */
function custFollowExportCsv(){
if(!can('export_data')){toast('无权限:导出需「导出模块数据 / 全量数据」权限');return;}
var all=custFollowTodoList();
if(!all.length){toast('没有可导出的记录');return;}
var fl=custFollowFiltered();
if(_EXP_SCOPE===null){
openExportScope({title:'待跟进客户',unit:'家',desc:'一行 = 一个待跟进客户档案。',filterNote:'包含当前搜索关键字与筛选条件',filterN:fl.length,allN:all.length,onExport:function(){custFollowExportCsv();}});
return;
}
var list=(_EXP_SCOPE==='filter')?fl:all;
if(!list.length){toast('当前筛选没有匹配的记录');return;}
var fLv=(typeof fLevel==='function')?fLevel('c_followups'):'full';
var cols=[
{h:'客户编号',fid:null,g:function(c){return c.custNo||'';}},
{h:'客户姓名',fid:'c_name',g:function(c){return fTxt('c_name',c.name||'');}},
{h:'校区/机构',fid:'c_school',g:function(c){return fTxt('c_school',c.school||'');}},
{h:'联系电话',fid:'phone',g:function(c){return fLevel('phone')==='full'?(c.phone||''):fmtPhone(c.phone);}},
{h:'归属经销商',fid:'d_name',g:function(c){var d=DB.dealers.find(function(x){return x.id===c.dealerId;});return fTxt('d_name',(d&&d.name)||'—');}},
{h:'对接员工',fid:'d_name',g:function(c){return fTxt('d_name',svcStaffNameOf(c)||'—');}},
{h:'最近跟进',fid:'c_followups',g:function(c){if(fLv==='hidden')return '';return custFollowStat(c).lastAt||'';}},
{h:'跟进次数',fid:'c_followups',g:function(c){if(fLv==='hidden')return '';return custFollowStat(c).n;}},
{h:'下次跟进',fid:'c_followups',g:function(c){if(fLv==='hidden')return '';return custFollowStat(c).nextAt||'';}}
];
var keep=[];
cols.forEach(function(col,i){if(!(col.fid&&fDeny(col.fid,'x')))keep.push(i);});
if(!keep.length){toast('无权限:当前角色对所有列均无导出权限');return;}
var out=[keep.map(function(i){return cols[i].h;})];
list.forEach(function(c){out.push(keep.map(function(i){return cols[i].g(c);}));});
var csv=out.map(function(r){return r.map(function(v){return csvCell(v);}).join(',');}).join('\n');
downloadCSV('待跟进客户_'+today()+'.csv',csv);
}
function renderCustFollowBoard(){
var view=document.getElementById('view');if(!view)return;
var all=custFollowTodoList();
var list=custFollowOrdered(custFollowFiltered());
var t=today();var nNone=0,nOk=0,nMid=0,nLate=0;
all.forEach(function(c){var st=custFollowStat(c);var d=st.lastAt?followAgoDays(st.lastAt,t):-1;var lv=followLevelOf(d);
if(lv==='none')nNone++;else if(lv==='ok')nOk++;else if(lv==='mid')nMid++;else nLate++;});
var canW=(typeof canField==='function')?canField('c_followups','e'):false;
var _f=custFollowFilter;
var _pill=function(k,lv,cls){
return '<span class="st '+cls+'" style="cursor:pointer'+(_f.state===k?';outline:2px solid currentColor;outline-offset:1px;font-weight:600':'')+'" title="点击只看这一档,再点取消" onclick="custFollowQuick(\''+k+'\')">'+lv+'</span>';
};
var rows=list.length?list.map(function(c){
var d=DB.dealers.find(function(x){return x.id===c.dealerId;});
var st=custFollowStat(c);
return `<tr data-search="${esc((fTxt('c_name',c.name)||'')+' '+(c.school||'')+' '+(fLevel('phone')==='full'?(c.phone||''):'')+' '+(d?d.name:''))}">
<td class="wrap">${custNameHtml(c)}</td>
<td class="wrap">${dealerNameHtml(d)}</td>
<td class="wrap">${svcStaffCellHtml(c)}</td>
<td>${custFollowCellHtml(c)}</td>
<td style="text-align:center">${st.n}</td>
<td>${st.nextAt?(st.nextAt<t?'<span class="st st-red">'+st.nextAt+'</span>':'<span class="st st-green">'+st.nextAt+'</span>'):'<span class="mtxt">—</span>'}</td>
<td>${canW?`<button class="btn ghost sm" onclick="openCustFollow('${c.id}')">✍️ 写跟进</button>`:'<span class="mtxt">—</span>'}</td>
</tr>`;
}).join(''):('<tr><td colspan="7" class="empty">'+(all.length?'没有符合筛选条件的客户 <button class="btn ghost sm" style="margin-left:6px" onclick="resetCustFollowFilter()">清空筛选</button>':'没有需要跟进的客户')+'</td></tr>');
view.innerHTML=custSubSeg()+`
<div class="topbar"><div><h1>待跟进客户</h1><p>只列还在推进且保护期未过的客户,按「最近跟进」升序 —— 从未跟进排最前</p></div></div>
<div class="search">
<input id="custFollowSearch" class="list-search" value="${esc(_f.kw)}" placeholder="搜索 客户编号 / 姓名 / 校区 / 手机号 / 经销商 / 对接员工" oninput="sbxKw(this,'kw','cf')" oncompositionend="sbxKw(this,'kw','cf',1)">
<button class="btn ghost sm" type="button" onclick="toggleCustFollowAdv()">🔍 筛选${custFollowCondN()?(' · '+custFollowCondN()):''}</button>
<button class="btn ghost sm" onclick="resetCustFollowFilter()">重置</button>
<button class="btn ghost sm" onclick="custFollowExportCsv()">📥 导出这批</button>
</div>
<div style="display:flex;gap:6px;align-items:center;flex-wrap:wrap;margin-bottom:8px">
${_pill('none','从未跟进 '+nNone,'st-red')}
${_pill('over30','超 30 天 '+nLate,'st-red')}
${_pill('d8to30','8–30 天 '+nMid,'st-yellow')}
${_pill('d7','7 天内 '+nOk,'st-green')}
<span class="pill" style="margin-left:auto">筛选后 ${list.length} 家 / 共 ${all.length} 家</span>
</div>
${custFollowTagsHtml()}
<div class="card" id="custFollowAdv" style="${custFollowAdvOpen?'':'display:none;'}margin-bottom:14px;padding:16px 18px">
<div class="row2">
<div class="field"><label>归属经销商</label>${smartSelectHTML({id:'cf_dealer',items:DB.dealers.filter(d=>d.type!=='staff'),selectedId:_f.dealer,displayKey:'name',emptyLabel:'全部经销商',placeholder:'搜索经销商…'})}</div>
<div class="field"><label>对接员工</label>${smartSelectHTML({id:'cf_staff',items:DB.dealers.filter(d=>d.type==='staff'),selectedId:_f.staff,displayKey:'name',emptyLabel:'全部员工',placeholder:'搜索员工…'})}</div>
</div>
<div class="row2">
<div class="field"><label>跟进情况</label><select onchange="custFollowFilter.state=this.value;renderCustFollowBoard()">${custFollowStateOpts()}</select></div>
<div class="field"><label>说明</label><div class="mtxt" style="line-height:1.6">点上方色阶数字可直接筛,再点一次取消。搜索覆盖客户编号 / 姓名 / 校区 / 手机号 / 归属经销商 / 对接员工;手机号与地址按字段权限显示。</div></div>
</div>
</div>
<div class="card table-card"><table id="custFollowTbl">
<thead><tr><th>客户</th><th>归属经销商</th><th>对接员工</th><th>最近跟进</th><th style="text-align:center">跟进次数</th><th>下次跟进</th><th>操作</th></tr></thead>
<tbody id="custFollowBody">${rows}</tbody></table></div>`;
try{
bindSmartSelect('cf_dealer_wrap',function(v){custFollowFilter.dealer=v;renderCustFollowBoard();});
bindSmartSelect('cf_staff_wrap',function(v){custFollowFilter.staff=v;renderCustFollowBoard();});
}catch(e){}
}
function renderCustomers(){if(typeof bpbRefreshCount==='function')bpbRefreshCount();if(custSub==='dealnosign')return renderCustDealNoSign();if(custSub==='unconv')return renderCustUnconv();if(custSub==='baobei')return renderCustBaobeiPending();if(custSub==='follow')return renderCustFollowBoard();const vSel=custSelVisible()?1:0;let list=custFilteredList(false);/* ★ 第272轮:筛选逻辑上移 custFilteredList(),与「导出范围」同源,杜绝两处漂移。★ 第286轮:搜索关键字下沉为 withKw 开关 —— 屏幕渲染传 false(全量+DOM显隐,清空即可恢复)、导出传 true(仍按搜索词)。 */
let rows=list.length?custGroupOrdered(list).map(c=>{const d=DB.dealers.find(x=>x.id===c.dealerId);const _cc=custCommissionOf(c);const rate=_cc.rate;const comm=_cc.comm;const expired=isProtectionExpired(c);const selCell=vSel?('<td style="text-align:center"><input type="checkbox" class="cust-sel" value="'+c.id+'"></td>'):'';return`<tr data-search="${custSearchBlob(c)}"${custGrpRowAttr(c)}>
${selCell}
<td class="wrap cust-pin">${custNameHtml(c)}${c.custNo?'<div class="mtxt">' + esc(fTxt('c_name',c.custNo)) + '</div>':''}
${expired?'<span class="pill" style="background:#fee2e2;color:#b91c1c">保护过期</span>':''}${svcTeacherMineBadge(c)}<span class="cust-badges">${linkedBadge(c)}${custScopeBadge(c)}${custGrpBadge(c)}</span>
<td class="wrap">${dealerNameHtml(d)}</td>
<td class="wrap">${svcStaffCellHtml(c)}</td>
<td class="wrap">${payOwnerCellHtml(c)}</td>
<td class="wrap">${svcTeacherCellHtml(c)}</td>
<td class="wrap">${intentTag(c)}</td>
<td class="wrap">${custOrderCellHtml(c)}</td>
<td>${fDateTxt('c_report',c.reportDate)}</td>
<td>${fStageBadge('c_stage',c.stage)}</td>
<td>${c.installment?'<span class="pill">分期(不计分润)</span>':moneyMask(fLevel('amount'), c.amount)}</td>
<td>${moneyMask(fLevel('amount'), paidOf(c))}</td>
<td>${c.stage==='signed_full'&&!c.refunded?(fLevel('amount_kpi')==='full'?pct(rate*100)+' / '+moneyMask(fLevel('amount_kpi'), comm):(fLevel('amount_kpi')==='mask'?'<span class="mtxt" style="color:#94a3b8">*** / ¥***</span>':'<span class="mtxt" style="color:#94a3b8">无权限</span>')):'—'}</td>
<td>${c.signDate?(fLevel('c_sign')==='full'?signDateCN(c.signDate):fDateTxt('c_sign',c.signDate)):'—'}</td><td>${fToggle('c_sign',contractToggleHtml('customer',c.id,c.contractSigned),c.contractSigned)}</td>
<td>${pushCell('customer',c)}</td>
<td>
${payQuickBtn(c)}
${can('view_customers')?`<button class="btn ghost sm"onclick="openCustomer('${c.id}')">详情/跟进</button>`:''}
${c.convertedDealerId?'<span class="pill" style="background:#dcfce7;color:#166534">✅ 已转</span>':''}
${rowMoreBtn('cust',c.id)}
</td>
<td class="wrap" onclick="openCustFollow('${c.id}')" style="cursor:pointer" title="点此查看 / 写跟进">${custFollowCellHtml(c)}</td>
</tr>`;}).join(''):'(<tr><td colspan="'+(17+vSel)+'" class="empty">暂无转介绍客户</td></tr>)';view.innerHTML=custSubSeg()+`
<div class="topbar"><div><h1>转介绍客户</h1><p>统一文字报备,系统按首次报备时间判定归属,保护周期 ${PROTECTION_DAYS} 天</p></div>
<div style="overflow-x:auto;-webkit-overflow-scrolling:touch;padding-bottom:2px"><div style="display:flex;gap:8px;align-items:center;flex-wrap:nowrap;min-width:0">${can('add_customers')?`<button class="btn"onclick="openCustomer()">+新增转介绍报备</button>`:''}${can('add_customers')?`<button class="btn ghost"onclick="openCustomerImport()">📥 批量导入</button>`:''}<div class="cust-more-wrap"><button class="btn ghost" type="button" onclick="custMoreToggle(event)">⋯ 更多 ▾</button><div class="cust-more-menu" id="custMoreMenu" hidden>${(can('view_customers')&&!svcTeacherViewOnly())?`<button type="button" class="cust-more-item" onclick="custMoreClose();openCustBatch()">🛠 批量补信息</button>`:''}<button type="button" class="cust-more-item" onclick="custMoreClose();openPushSettings('delivery','企业微信推送设置(推送交付)','客户/经销商推送交付或撤回时,自动推送到企业微信群。')">🔔 推送设置</button><button type="button" class="cust-more-item" onclick="custMoreClose();openPushSettings('refund','企业微信推送设置(退费/终止)','客户办理退费、或状态改为「已终止」时,自动推送到企业微信群。')">🔔 退费推送设置</button>
${can('view_customers')?`<button type="button" class="cust-more-item" onclick="custMoreClose();openPushSettings('custnotice','企业微信推送设置(客户情况说明)','写跟进时勾选「📢 同时通报交付群」,才会把该条情况说明推送到交付企业微信群。')">🔔 情况说明推送设置</button>`:''}${can('view_recycle')?`<button type="button" class="cust-more-item" onclick="custMoreClose();openRecycle()">🗑 回收站<span id="recycleCount">${(DB.recycleBin&&DB.recycleBin.length)?('('+DB.recycleBin.length+')'):''}</span></button>`:''}</div></div></div></div></div>
<div class="search">
<input id="custSearchBox" class="list-search" placeholder="搜索客户姓名 / 校区 / 手机号 / 意向" value="${esc(custFilter.kw)}" oninput="custSearch(this.value)" oncompositionend="custSearch(this.value)">
<button class="btn ghost sm" type="button" onclick="toggleCustAdv()">🔍 高级筛选<span id="custAdvNum" class="cust-advnum">${custAdvCount()?'( · '+custAdvCount()+')':''}</span></button>
<button class="btn ghost sm" onclick="resetCustFilter()">重置</button>
<button class="btn ghost sm" id="custMineBtn" type="button" onclick="toggleCustMine()" title="只看指派给我作为服务老师的客户"${custFilter.mine?' style="background:#ecfdf5;border-color:#a7f3d0;color:#047857"':''}>👩🏫 我服务的客户</button>
<span class="cust-rescnt" id="custResCnt" data-total="${list.length}">共 ${list.length} 条</span>
</div>
${vSel?custSelBarHtml():''}
<div class="card" id="custAdvPanel" style="${custAdvOpen?'':'display:none;'}margin-bottom:14px;padding:16px 18px">
<div class="row2">
<div class="field"><label>归属经销商</label>${smartSelectHTML({id:'custFilter_dealer', items:DB.dealers, selectedId:custFilter.dealer, displayKey:'name', emptyLabel:'全部经销商', placeholder:'搜索经销商…'})}</div>
<div class="field"><label>对接员工</label>${smartSelectHTML({id:'custFilter_staff', items:DB.dealers.filter(d=>d.type==='staff'), selectedId:custFilter.staff, displayKey:'name', emptyLabel:'全部员工', placeholder:'搜索员工…'})}</div>
<div class="field"><label>服务老师</label>${smartSelectHTML({id:'custFilter_teacher', items:svcTeacherCandidates(), subKey:'sub', selectedId:custFilter.teacher, displayKey:'name', emptyLabel:'全部服务老师', placeholder:'搜索员工…'})}</div>
</div>
<div class="row2">
<div class="field"><label>状态</label><select onchange="custFilter.stage=this.value;renderCustomers()"><option value="">全部状态</option>${Object.entries(STAGES).map(([k,v])=>`<option value="${k}"${custFilter.stage===k?'selected':''}>${v.label}</option>`).join('')}</select></div>
<div class="field"><label>合作项目</label><select onchange="custFilter.intent=this.value;renderCustomers()"><option value="">全部意向</option>${intentOpts(custFilter.intent)}</select></div>
</div>
<div class="row2">
<div class="field"><label>报备日期</label><div style="display:flex;gap:6px;align-items:center"><input type="date" id="custReportStart" value="${esc(custFilter.reportStart)}" onchange="custFilter.reportStart=this.value;renderCustomers()"><span class="mtxt">~</span><input type="date" id="custReportEnd" value="${esc(custFilter.reportEnd)}" onchange="custFilter.reportEnd=this.value;renderCustomers()"></div></div>
<div class="field"><label>达成合作日期</label><div style="display:flex;gap:6px;align-items:center"><input type="date" id="custSignStart" value="${esc(custFilter.signStart)}" onchange="custFilter.signStart=this.value;renderCustomers()"><span class="mtxt">~</span><input type="date" id="custSignEnd" value="${esc(custFilter.signEnd)}" onchange="custFilter.signEnd=this.value;renderCustomers()"></div></div>
</div>
<div class="row2">
${fLevel('amount')==='full'?`<div class="field"><label>已交款金额范围</label><div style="display:flex;gap:6px;align-items:center"><input type="number" min="0" id="custPaidMin" placeholder="最低" value="${esc(custFilter.paidMin)}" onchange="custFilter.paidMin=this.value;rerenderKeepFocus(renderCustomers)"><span class="mtxt">~</span><input type="number" min="0" id="custPaidMax" placeholder="最高" value="${esc(custFilter.paidMax)}" onchange="custFilter.paidMax=this.value;rerenderKeepFocus(renderCustomers)"></div></div>`:`<div class="field"><label>已交款金额范围</label><div class="mtxt" style="color:#94a3b8">无「金额」查看权限,不按金额筛选</div></div>`}
<div class="field"><label>已签合同</label><select onchange="custFilter.contract=this.value;renderCustomers()"><option value="">全部</option><option value="1"${custFilter.contract==='1'?' selected':''}>已签</option><option value="0"${custFilter.contract==='0'?' selected':''}>未签</option></select></div>
</div>
<div class="row2">
<div class="field"><label>分期</label><select onchange="custFilter.install=this.value;renderCustomers()"><option value="">全部</option><option value="1"${custFilter.install==='1'?' selected':''}>分期</option><option value="0"${custFilter.install==='0'?' selected':''}>非分期</option></select></div>
<div class="field"><label>退费</label><select onchange="custFilter.refund=this.value;renderCustomers()"><option value="">全部</option><option value="1"${custFilter.refund==='1'?' selected':''}>已退费</option><option value="0"${custFilter.refund==='0'?' selected':''}>未退费</option></select></div>
</div>
<div class="row2">
${fLevel('c_followups')==='full'?`<div class="field"><label>跟进内容关键字</label><input id="custFollowKw" placeholder="搜跟进内容 / 方式 / 跟进人" value="${esc(custFilter.followKw)}" oninput="sbxKw(this,'followKw','cust')" oncompositionend="sbxKw(this,'followKw','cust',1)"></div>`:`<div class="field"><label>跟进内容关键字</label><div class="mtxt" style="color:#94a3b8">无「跟进记录」查看权限,不按跟进内容筛选</div></div>`}
<div class="field"><label>交款关键字</label><input id="custPayKw" placeholder="搜交款备注 / 方式" value="${esc(custFilter.payKw)}" oninput="sbxKw(this,'payKw','cust')" oncompositionend="sbxKw(this,'payKw','cust',1)"></div>
</div>
<div class="row2">
<div class="field"><label>联系地址</label><input id="custAddrKw" placeholder="搜省 / 市 / 区 / 详细地址" value="${esc(custFilter.addrKw)}" oninput="sbxKw(this,'addrKw','cust')" oncompositionend="sbxKw(this,'addrKw','cust',1)"></div>
<div class="field"><label>订单种类</label><select onchange="custFilter.orderKind=this.value;renderCustomers()">${orderKindOpts()}</select></div>
</div>
<div class="row2">
<div class="field"><label>跟进情况</label><select onchange="custFilter.followState=this.value;renderCustomers()">${followStateOpts()}</select></div>
<div class="field"><label>跟进人(最近一次)</label>${smartSelectHTML({id:'custFilter_fstaff', items:followOperatorItems(), selectedId:custFilter.followStaff, displayKey:'name', emptyLabel:'全部跟进人', placeholder:'搜索跟进人…'})}</div>
</div>
<div class="row2">
<div class="field"><label>最近跟进日期</label><div style="display:flex;gap:6px;align-items:center"><input type="date" id="custFollowFrom" value="${esc(custFilter.followFrom)}" onchange="custFilter.followFrom=this.value;renderCustomers()"><span class="mtxt">~</span><input type="date" id="custFollowTo" value="${esc(custFilter.followTo)}" onchange="custFilter.followTo=this.value;renderCustomers()"></div></div>
<div class="field"><label>说明</label><div class="mtxt" style="line-height:1.6">色点:7 天内绿 / 8–30 天黄 / 超 30 天或从未跟进红;「跟进人」取该客户最近一条跟进记录的跟进人</div></div>
</div>
</div>
<div class="card table-card" data-vsel="${vSel}"><table id="custTbl">
<thead><tr>${vSel?'<th style="width:36px;text-align:center"><input type="checkbox" id="custSelAll" title="全选当前列表(含搜索/筛选结果)"></th>':''}<th class="cust-pin">客户</th><th>归属经销商</th><th>对接员工</th><th>业绩归属(按交款)</th><th>服务老师</th><th>合作项目</th><th>订单/项目</th><th>报备日期</th><th>客户状态</th><th>金额</th><th>已交款</th><th>分润比例/金额</th><th>达成合作日期</th><th>已签合同</th><th>推送交付</th><th>操作</th><th>最近跟进</th></tr></thead>
<tbody id="custTable">${rows}</tbody></table></div>`;custSearch(custFilter.kw);bindSmartSelect('custFilter_dealer_wrap',v=>{custFilter.dealer=v;renderCustomers();});bindSmartSelect('custFilter_staff_wrap',v=>{custFilter.staff=v;renderCustomers();});bindSmartSelect('custFilter_teacher_wrap',v=>{custFilter.teacher=v;renderCustomers();});bindSmartSelect('custFilter_fstaff_wrap',v=>{custFilter.followStaff=v;renderCustomers();});try{custSelAfterRender();}catch(e){}try{if(typeof colLayoutApply==='function')colLayoutApply();}catch(e){}}
regExportScope('customers',{unit:'条',title:'转介绍客户',desc:'一行 = 一个转介绍客户档案(字段级权限照旧生效)。',list:function(){return custFilteredList(true);},all:function(){return DB.customers.slice();},warn:expScopeWarnAmount});
let custAdvOpen=false;function toggleCustAdv(){custAdvOpen=!custAdvOpen;const p=document.getElementById('custAdvPanel');if(p)p.style.display=custAdvOpen?'block':'none';}function resetCustFilter(){custFilter={teacher:'',dealer:'',staff:'',stage:'',kw:'',signStart:'',signEnd:'',intent:'',contract:'',install:'',refund:'',reportStart:'',reportEnd:'',paidMin:'',paidMax:'',followKw:'',payKw:'',addrKw:'',followState:'',followStaff:'',followFrom:'',followTo:'',mine:false,orderKind:''};renderCustomers();}function custSearch(v){custFilter.kw=v;const q=(v||'').trim().toLowerCase();const rows=document.querySelectorAll('#custTable tr[data-search]');rows.forEach(tr=>{const hit=(tr.getAttribute('data-search')||'').toLowerCase().includes(q);const _gk=tr.getAttribute('data-grp');if(_gk&&!q)tr.style.display=custGrpOpen[_gk]?'':'none';else tr.style.display=(!q||hit)?'':'none';});try{custSelAllSync();}catch(e){}try{custSelBarUpdate();}catch(e){}};if(typeof custResSync==='function'){try{custResSync();}catch(e){}}
let followFilter={scope:'customer',dealer:'',customer:'',q:'',hideAuto:true};function renderFollowups(){regExportScope('followups',{unit:'条',title:'跟进记录',desc:'一行 = 一条跟进记录(经销商跟进 / 客户跟进)。',list:followFilteredRecords,all:followAllRecords});const dealers=DB.dealers.filter(d=>d.type!=="staff");const scope=followFilter.scope;let customerSel='';if(scope==='customer'){const cs=followFilter.dealer?DB.customers.filter(c=>c.dealerId===followFilter.dealer):DB.customers;customerSel=`<div class="field"><label>客户</label>${smartSelectHTML({id:'ff_customer', items:cs, selectedId:followFilter.customer, displayKey:'name', emptyLabel:'全部客户', placeholder:'搜索客户姓名 / 校区…', optFn:c=>{ const s=c.school?('· '+c.school):''; return { main:(c.name||'未命名')+s, sub:fmtPhone(c.phone) }; }})}</div>`;}
let records=[];if(scope==='dealer'){dealers.for
Each(d=>{if(followFilter.dealer&&d.id!==followFilter.dealer)return;(d.followups||[]).forEach(f=>records.push({...f,dealer:d,customer:null}));});}else{DB.customers.forEach(c=>{const d=dealers.find(x=>x.id===c.dealerId);if(followFilter.dealer&&c.dealerId!==followFilter.dealer)return;if(followFilter.customer&&c.id!==followFilter.customer)return;(c.followups||[]).forEach(f=>records.push({...f,dealer:d,customer:c}));});}
var _autoN=0;if(followFilter.hideAuto){var _allN=records.length;records=records.filter(function(x){return !(String(x.operator||'').indexOf('系统自动同步')>=0||String(x.method||'')==='报备系统');});_autoN=_allN-records.length;}
records.sort((a,b)=>new Date(b.date)-new Date(a.date));const recHtml=records.length?records.map(it=>{const head=scope==='dealer'?`${it.dealer.name} · 经销商沟通`:`${custNameText(it.customer)}· ${it.dealer?it.dealer.name:'—'}`;const kw=`${it.content} ${it.operator||''} ${it.method||''} ${it.customer?it.customer.name+' '+(it.customer.school||'')+' '+(it.customer.phone||''):''} ${it.dealer?it.dealer.name+' '+(it.dealer.phone||''):''} ${it.nextAt||''}`;return`<div class="tl-item" data-search="${kw.replace(/"/g,'')}">
<div class="t">${it.date} · ${head}</div>
<div class="c">${it.content}</div>
<div class="op">跟进人:${it.operator||'—'} | 方式:${it.method||'—'}${it.source==='wechat'?' | <span class="pill" style="background:#e0e7ff;color:#4338ca">📱 微信导入</span>':''}</div>
${it.nextAt?`<div style="margin-top:4px">${nextAtBadge(it.nextAt)}</div>`:''}
</div>`;}).join(''):'';const todo=DB.customers.filter(c=>['reported','following'].includes(c.stage)&&!isProtectionExpired(c));todo.sort((a,b)=>{const ra=a.remindAt||'',rb=b.remindAt||'';const oa=(ra&&ra<today())?0:1,ob=(rb&&rb<today())?0:1;if(oa!==ob)return oa-ob;if(ra&&rb)return ra<rb?-1:1;if(ra)return-1;if(rb)return 1;return 0;});const cntOverdue=todo.filter(c=>c.remindAt&&c.remindAt<today()).length;const cntSet=todo.filter(c=>c.remindAt&&!(c.remindAt<today())).length;const cntNone=todo.filter(c=>!c.remindAt).length;const todoSummary=todo.length?`<div style="display:flex;gap:8px;flex-wrap:wrap;margin-bottom:12px">
<span class="st st-red">⏰ 已逾期 ${cntOverdue}</span>
<span class="st st-yellow">已设提醒 ${cntSet}</span>
<span class="pill">未设 ${cntNone}</span></div>`:'';const todoHtml=todo.length?todo.map(c=>{const d=DB.dealers.find(x=>x.id===c.dealerId);const left=Math.max(0,PROTECTION_DAYS-Math.floor((Date.now()-new Date(c.reportDate))/86400000));const overdue=c.remindAt&&c.remindAt<today();const remindBadge=overdue?`<span class="st st-red">已逾期</span>`:(c.remindAt?`<span class="st st-yellow">已设 ${c.remindAt}</span>`:`<span class="mtxt">未设提醒</span>`);return`<div style="padding:10px 0;border-bottom:1px dashed var(--line);display:flex;justify-content:space-between;align-items:center;gap:12px">
<div style="min-width:0">${custNameHtml(c)}<div class="mtxt">${d?d.name:'—'} · ${stageInfoOf(c.stage).label}</div></div>
<div style="text-align:right;flex-shrink:0">
<span class="pill">剩 ${left} 天保护期</span>
<div style="margin-top:6px;display:flex;gap:6px;align-items:center;justify-content:flex-end;flex-wrap:wrap">
${can('edit_customers')?`<button class="btn ghost sm" onclick="openRemindModal('${c.id}')">⏰ 设提醒${c.remindAt?(' · '+c.remindAt):''}</button>`:''}
${remindBadge}
</div>
<button class="btn ghost sm" style="margin-top:6px" onclick="openCustomer('${c.id}')">去跟进</button>
</div>
</div>`;}).join(''):'<div class="empty">全部客户均已推进或流失</div>';let nextRecs=[];if(scope==='dealer'){dealers.forEach(d=>{if(followFilter.dealer&&d.id!==followFilter.dealer)return;(d.followups||[]).forEach(f=>{if(f.nextAt)nextRecs.push({...f,dealer:d,customer:null});});});}else{DB.customers.forEach(c=>{const d=dealers.find(x=>x.id===c.dealerId);if(followFilter.dealer&&c.dealerId!==followFilter.dealer)return;if(followFilter.customer&&c.id!==followFilter.customer)return;(c.followups||[]).forEach(f=>{if(f.nextAt)nextRecs.push({...f,dealer:d,customer:c});});});}
nextRecs.sort((a,b)=>a.nextAt<b.nextAt?-1:a.nextAt>b.nextAt?1:0);const cntNextOverdue=nextRecs.filter(f=>f.nextAt<today()).length;const cntNextSoon=nextRecs.filter(f=>f.nextAt>=today()&&Math.floor((new Date(f.nextAt)-new Date(today()))/86400000)<=3).length;const nextSummary=nextRecs.length?`<div style="dis
play:flex;gap:8px;flex-wrap:wrap;margin-bottom:12px">
<span class="st st-red">🔔 已逾期 ${cntNextOverdue}</span>
<span class="st st-yellow">临近(≤3天) ${cntNextSoon}</span>
<span class="pill">共 ${nextRecs.length}</span></div>`:'';const nextHtml=nextRecs.length?nextRecs.map(f=>{const who=scope==='dealer'?f.dealer.name:(f.customer?custNameText(f.customer):'—');const goId=scope==='dealer'?f.dealer.id:(f.customer?f.customer.id:'');const goFn=scope==='dealer'?`openDealerFollowup('${goId}')`:`openCustomer('${goId}')`;const cc=fLevel('c_followups')==='full'?((f.content||'').length>20?(f.content.slice(0,20)+'…'):(f.content||'')):(fLevel('c_followups')==='mask'?'***':'****');return`<div style="padding:9px 0;border-bottom:1px dashed var(--line);display:flex;justify-content:space-between;align-items:center;gap:10px">
<div style="min-width:0"><b>${who}</b><div class="mtxt">上次跟进 ${f.date} · ${cc}</div></div>
<div style="text-align:right;flex-shrink:0">
${nextAtBadge(f.nextAt)}
<div style="margin-top:6px"><button class="btn ghost sm" onclick="${goFn}">去跟进</button></div>
</div>
</div>`;}).join(''):'<div class="empty">暂无已设置"下次跟进时间"的跟进记录</div>';const scopeLabel=scope==='dealer'?'经销商跟进':'客户跟进';const addLabel=scope==='dealer'?'+ 经销商跟进':'+ 客户跟进';view.innerHTML=`
<div class="topbar"><div><h1>跟进记录</h1><p>经销商与客户的独立跟进时间线,支持按主体筛选与关键词查询</p></div></div>
<div class="card" style="margin-bottom:16px">
<div class="seg">
<button class="seg-btn ${scope==='customer'?'active':''}" onclick="ff_toggle('customer')">🤝 客户跟进</button>
<button class="seg-btn ${scope==='dealer'?'active':''}" onclick="ff_toggle('dealer')">🏷️ 经销商跟进</button>
</div>
<div class="filter-row">
<div class="field"><label>经销商</label>${smartSelectHTML({id:'ff_dealer', items:dealers, selectedId:followFilter.dealer, displayKey:'name', emptyLabel:'全部经销商', placeholder:'搜索经销商…'})}</div>
${customerSel}
<div class="field"><label>姓名 / 校区 / 手机号 检索</label><input id="ff_q" value="${followFilter.q}" placeholder="搜索姓名 / 校区 / 手机号 / 内容…" oninput="ff_search(this.value)"></div>
<button class="btn ghost sm" onclick="ff_toggleAuto()" title="报备系统自动同步的「跟进」只是报备留痕,默认折叠">${followFilter.hideAuto?('👁 显示报备同步'+(_autoN?(' ('+_autoN+')'):'')):'🙈 折叠报备同步'}</button>
<button class="btn add-btn" onclick="ff_add()">${addLabel}</button>
</div>
</div>
<div class="grid two-col">
<div class="card"><h4 style="font-size:14px;margin-bottom:14px">${scopeLabel}时间线(${records.length})</h4>
<div class="timeline" id="ff_timeline">
${recHtml||'<div class="empty">暂无匹配记录</div>'}
<div class="empty" id="ff_empty" style="display:none">无匹配关键词的记录</div>
</div></div>
<div class="card">
<h4 style="font-size:14px;margin-bottom:12px">待跟进提醒(${todo.length})</h4>
${todoSummary}
${todoHtml}
</div>
</div>
<div class="card" style="margin-top:16px">
<h4 style="font-size:14px;margin-bottom:12px">🔔 下次跟进提醒(按时间排序,来自各条跟进记录)</h4>
${nextSummary}
${nextHtml}
</div>`;ff_search(followFilter.q);bindSmartSelect('ff_dealer_wrap',v=>ff_set('dealer',v));if(scope==='customer')bindSmartSelect('ff_customer_wrap',v=>ff_set('customer',v));}
function ff_toggle(s){followFilter.scope=s;followFilter.customer='';render();}function ff_toggleAuto(){followFilter.hideAuto=!followFilter.hideAuto;render();}
function ff_set(k,v){followFilter[k]=v;if(k==='dealer')followFilter.customer='';render();}
function ff_search(v){followFilter.q=v;const q=v.trim().toLowerCase();const items=document.querySelectorAll('#ff_timeline .tl-item');let visible=0;items.forEach(el=>{const hit=(el.getAttribute('data-search')||'').toLowerCase().includes(q);if(hit)visible++;el.style.display=(!q||hit)?'':'none';});const emptyEl=document.getElementById('ff_empty');if(emptyEl)emptyEl.style.display=(items.length&&visible===0&&q)?'':'none';}
function ff_add(){if(!DB.dealers.length){toast('请先添加经销商');return;}
if(followFilter.scope==='dealer'){const did=followFilter.dealer;if(!did){toast('请先在上方选择具体经销商');return;}
openDealerFollowup(did);}else{const cid=followFilter.customer;if(!cid){toast('请先在上方选择具体客户(可先选经销商再选客户)');return;}
openCustomerFollowup(cid);}}
function setRemind(cid,date){const c=DB.customers.find(x=>x.id===cid);if(!c)return;c.remindAt=(date
||'').trim();save().then(function(ok){if(!ok)return;render();toast(c.remindAt?('已设置提醒时间:'+c.remindAt):'已清除提醒时间');});}
function openRemindModal(cid){const c=DB.customers.find(x=>x.id===cid);if(!c)return;const d=DB.dealers.find(x=>x.id===c.dealerId);const overdue=c.remindAt&&c.remindAt<today();openModal(`
<div class="modal-head"><h3>⏰ 设置跟进提醒 · ${custNameText(c)}</h3><span class="x" onclick="closeModal()">×</span></div>
<div class="modal-body">
<div class="card" style="background:#f8fafc;padding:12px;margin-bottom:14px">
<div><b>客户:</b>${custNameHtml(c)} <span class="mtxt">${d?d.name:''}</span></div>
<div style="margin-top:6px" class="mtxt">当前状态:${stageInfoOf(c.stage).label}</div>
</div>
<div class="field"><label>提醒时间(下次跟进日期)</label>
<input type="date" id="rm_date" value="${c.remindAt||today()}" onchange="setRemind('${c.id}', this.value)"></div>
<div class="mtxt" style="margin-top:6px;color:#16a34a">✓ 选择日期后将自动保存</div>
<div class="mtxt" style="margin-top:8px">${overdue?'⚠ 当前提醒时间已逾期,请更新为新日期':'设置后将在"待跟进提醒"中按紧迫度排序并显示状态'}</div>
</div>
<div class="modal-foot">
<button class="btn ghost" onclick="clearRemind('${c.id}')">清除</button>
<button class="btn" onclick="saveRemind('${c.id}')">保存提醒</button>
</div>`);}
function saveRemind(cid){const el=document.getElementById('rm_date');if(!el)return;setRemind(cid,el.value);closeModal();}
function clearRemind(cid){setRemind(cid,'');closeModal();}
function nextAtBadge(date){if(!date)return'';const t=today();if(date<t)return`<span class="st st-red">🔔 下次跟进已逾期(${date})</span>`;const days=Math.floor((new Date(date)-new Date(t))/86400000);if(days<=3)return`<span class="st st-yellow">🔔 ${days}天后跟进(${date})</span>`;return`<span class="st st-green">🔔 下次跟进:${date}</span>`;}
/* ★ 第602轮(2026-10-23):跟进记录折叠 —— 客户详情 / 经销商跟进弹窗 / 客户写跟进弹窗 三处共用。
口径(主人拍板 D 方案 + 默认 2 条):默认只显示最近 FUP_SHOW(=2) 条,其余条留在 DOM 里 display:none
(DOM 直切切换,不重渲、不丢滚动位置、不重算任何金额);单条正文超过 FUP_CLIP(=120) 字 ⇒
只显示前 120 字 +「展开全文」。纯显示层:零写库、零权限键变更。 */
var FUP_SHOW=2, FUP_CLIP=120, _fupOpen={};
function fupKey(k){return String(k||'').replace(/[^A-Za-z0-9_-]/g,'');}
function fupItemHtml(f,k,i,opt){
opt=opt||{};
var kk=fupKey(k), cid='fupc_'+kk+'_'+i;
var raw=!!opt.raw, mask=(!raw)&&(typeof fLevel==='function')&&fLevel('c_followups')!=='full';
var txt=String((f&&f.content)||'');
var body;
if(!mask&&txt.length>FUP_CLIP){
body='<div class="c" id="'+cid+'"><span class="fup-clip">'+txt.slice(0,FUP_CLIP)+'…</span>'+
'<span class="fup-full" style="display:none">'+txt+'</span>'+
'<a style="color:var(--primary);cursor:pointer;font-size:12px;margin-left:4px" onclick="fupClipToggle(\''+cid+'\')">展开全文</a></div>';
}else{
body='<div class="c">'+(mask?'***':txt)+'</div>';
}
var line=opt.compact?(f.date+' · '+(f.method||'—')+' · '+(f.operator||'—'))
:(f.date+' · 方式:'+(f.method||'—')+' · 跟进人:'+(f.operator||'—'));
return '<div class="tl-item" data-fup-i="'+i+'"'+(i>=FUP_SHOW?' data-fup-more="1" style="display:none"':'')+'>'+
'<div class="t">'+line+'</div>'+body+
(f.nextAt?'<div style="margin-top:4px">'+nextAtBadge(f.nextAt)+'</div>':'')+
'</div>';
}
function fupListHtml(list,k,opt){
opt=opt||{};
if(!list||!list.length) return '<div class="empty">'+(opt.empty||'暂无跟进记录')+'</div>';
return list.map(function(f,i){return fupItemHtml(f,k,i,opt);}).join('');
}
function fupBtnInner(n,k){
var op=!!_fupOpen[fupKey(k)];
return '<a onclick="fupToggle(\''+fupKey(k)+'\','+n+')" style="display:inline-flex;align-items:center;gap:6px;cursor:pointer;font-size:12px;color:var(--primary);background:#eef2ff;border:1px solid #c7d2fe;border-radius:8px;padding:6px 12px">'+(op?'▴ 收起':'▾ 展开全部 '+n+' 条')+'</a>';
}
function fupMoreHtml(n,k){
if(!(n>FUP_SHOW)) return '';
return '<div id="fupbtn_'+fupKey(k)+'" style="margin:-4px 0 14px">'+fupBtnInner(n,k)+'</div>';
}
function fupToggle(k,n){
var kk=fupKey(k); if(!kk) return;
_fupOpen[kk]=!_fupOpen[kk];
var op=!!_fupOpen[kk];
var box=document.querySelector('[data-fup-list="'+kk+'"]');
if(box) Array.prototype.forEach.call(box.querySelectorAll('[data-fup-more]'),function(el){el.style.display=op?'':'none';});
var b=document.getElementById('fupbtn_'+kk);
if(b) b.innerHTML=fupBtnInner(n,kk);
}
function fupClipToggle(cid){
var el=document.getElementById(cid); if(!el) return;
var a=el.querySelector('.fup-clip'), f=el.querySelector('.fup-full'), ln=el.querySelector('a');
if(!a||!f) return;
var open=(f.style.display==='none');
f.style.display=open?'':'none';
a.style.display=open?'none':'';
if(ln) ln.textContent=open?'收起':'展开全文';
}
function openDealerFollowup(id){const d=DB.dealers.find(x=>x.id===id);if(!d)return;const tl=(d.followups||[]).slice().sort((a,b)=>new Date(b.date)-new Date(a.date));const _fk='d_'+id;const tlHtml=fupListHtml(tl,_fk,{raw:true,empty:'暂无经销商跟进记录'});openModal(`
<div class="modal-head"><h3>${d.name} · 经销商跟进</h3><span class="x" onclick="closeModal()">×</span></div>
<div class="modal-body">
<div class="grid three-col" style="margin-bottom:14px">
<div class="card" style="padding:12px"><div class="mtxt">类型</div><b>${DT(d.type).name}</b></div>
<div class="card" style="padding:12px"><div class="mtxt">预缴费用</div><b>${money(DT(d.type).fee)}</b></div>
<div class="card" style="padding:12px"><div class="mtxt">联系电话</div><b>${fmtPhone(d.phone)}</b></div>
</div>
<div class="section-title" style="margin:6px 0 10px">历史跟进(${tl.length})</div>
<div class="timeline" data-fup-list="${_fk}" style="margin-bottom:10px">${tlHtml}</div>${fupMoreHtml(tl.length,_fk)}
<div class="row2">
<div class="field"><label>跟进方式</label><select id="df_method"><option>微信</option><option>电话</option><option>面谈</option><option>会议</option><option>邮件</option><option>其他</option></select></div>
<div class="field"><label>跟进人</label><input id="df_op" value="${currentUser?currentUser.name:''}" placeholder="总部/销售姓名"></div>
</div>
<div class="field" style="margin-top:12px"><label>跟进内容</label><textarea id="df_content" rows="2" placeholder="如:确认预缴到账、安排师训、季度复盘、分润对账通知…"></textarea></div>
<div class="field" style="margin-top:12px"><label>下次跟进时间(选填,用于提醒)</label><input type="date" id="df_next"></div>
</div>
<div class="modal-foot">
<button class="btn ghost" onclick="closeModal()">关闭</button>
<button class="btn" onclick="saveDealerFollowup('${d.id}')">保存跟进</button>
</div>`);}
function saveDealerFollowup(id){const d=DB.dealers.find(x=>x.id===id);if(!d)return;if(!can('edit_dealers')&&!can('follow_customers')){toast('无权限:不能写跟进');return;}
const content=document.getElementById('df_content').value.trim();if(!content){toast('请填写跟进内容');return;}
d.followups=d.followups||[];d.followups.push({date:
today(),method:document.getElementById('df_method').value,operator:document.getElementById('df_op').value.trim(),content,nextAt:document.getElementById('df_next').value||''});save().then(ok=>{if(ok){closeModal();render();toast('已记录经销商跟进');}});}
function openDealerDetail(id){const d=DB.dealers.find(x=>x.id===id);if(!d)return;renderDealerDetail(d);}
function renderDealerDetail(d){const s=dealerStats(d.id);const cfg=DT(d.type);const locked=dealerLockedFields(d);let assess=assessHTMLOf(d);
const typeOpts=Object.entries(DEALER_TYPES).map(([k,c])=>`<option value="${k}" ${d.type===k?'selected':''}>${c.name}</option>`).join('');openModal(`
<div class="modal-head"><h3>${d.name} · 跟进详情</h3><span class="x" onclick="closeModal()">×</span></div>
<div class="modal-body cust-detail">
<div class="idbar">
<div class="idc"><div class="k">经销商类型</div><b>${cfg.name}</b><div class="mtxt">预缴 ${money(cfg.fee)}</div></div>
<div class="idc"><div class="k">考核状态</div>${assess}</div>
<div class="idc"><div class="k">对接员工</div>${d.type==='staff'?'<span class="mtxt">—(员工本人)</span>':(dealerStaffTextOf(d)?'<b>'+fTxt('d_name',dealerStaffTextOf(d))+'</b>':'<span class="mtxt">—</span>')}<div class="mtxt">(按该经销商签约前的客户档案)</div></div>
<div class="idc"><div class="k">所在区域</div><b>${fTxt('d_type',d.region||'—')}</b></div>
</div>
<div class="kpirow" style="grid-template-columns:1fr 1fr">
<div class="card"><div class="mtxt">应发分润</div><b style="color:#d97706">${money(s.commission)}</b><div class="mtxt">已发 ${money(commissionPaid(d))} · 待发 ${money(commissionPending(d))}</div></div>
<div class="card"><div class="mtxt">全款成交 / 发放</div><b>${s.signedFull} 家</b>${perfOwnerDealerBlockHtml(d)}<div style="display:flex;gap:6px;margin-top:8px;flex-wrap:wrap">${fLevel('amount_dealer')==='full'?`<button class="btn ghost sm" onclick="openCommissionPayout('${d.id}')">📋 发放记录</button>`:''}${can('pay_commission')&&commissionPending(d)>0?`<button class="btn sm" onclick="openCommissionPayout('${d.id}')">💰 标记发放</button>`:''}</div></div>
</div>
<div class="addr-line" style="margin-bottom:14px;padding:10px 12px;background:#f8fafc;border:1px solid var(--line);border-radius:8px">📍 <b>联系地址:</b><span id="dlAddrView">${fLevel('addr')==='full'?((d.prov||'')+' '+(d.city||'')+' '+(d.district||'')+' '+(d.addr||'')):(fLevel('addr')==='mask'?((d.prov||'')+(d.city||'')+'**'):'****')}</span>${d.region?`<span class="mtxt">(${fTxt('d_type',d.region)})</span>`:''}</div>
<div style="margin-bottom:14px">📄 <b>经销合同:</b>${fLevel('d_sign')==='full'?(contractHtml(d.contractSigned)+(d.contractSigned&&d.contractDate?'<span style="color:#166534;font-size:13px;margin-left:6px">签署日期:'+d.contractDate+'</span>':'')):('<span class="mtxt">'+(fLevel('d_sign')==='mask'?'***':'****')+'</span>')}</div>
${can('edit_dealers')?`<div class="cols2"><div class="colL"><div class="row2"><div class="field"><label>经销商类型</label><select id="u_type">${typeOpts}</select></div><div class="field"><label>🔒 签约/全款金额<span style="font-size:11px;color:#d97706;margin-left:4px">继承自财务收款模块·只读</span></label><input type="number"id="u_amount"value="${d.amount||0}"disabled style="background:#f1f5f9;color:#94a3b8;cursor:not-allowed"></div></div><div class="row2"><div class="field"><label>${locked.name?'🔒 经销商名称 <span style="font-size:11px;color:#d97706;margin-left:4px">继承自客户·只读</span>':'经销商名称'}</label><input id="u_name"value="${d.name}" ${(locked.name||!canField('d_name','e'))?'disabled style="background:#f1f5f9;color:#94a3b8;cursor:not-allowed"':''}></div>${locked.phone?`<div class="field"><label>🔒 联系电话 <span style="font-size:11px;color:#d97706;margin-left:4px">继承自客户·只读</span></label><input id="u_phone" value="${fLevel('phone')==='full'?(d.phone||''):(fLevel('phone')==='mask'?String(d.phone||'').replace(/^(\d{3})\d{4}/,'$1****'):'')}" disabled style="background:#f1f5f9;color:#94a3b8;cursor:not-allowed"></div>`:phoneEditField('u_phone',d.phone,'联系电话')}</div></div><div class="colR"><div class="section-title"style="margin:14px 0 8px">联系地址</div>${(locked.addr||!canField('addr','e'))?dealerAddrLockedHTML(d):areaSelectHTML('u',d.prov,d.city,d.district,d.addr)}<div class="row2"><div class="field"><label>所在区域</label><input id="u_region"value="${d.region||''}"placeholder="如:华东·杭州" ${canField('d_type','e')?'':'disabled style="background:#f1f5f9;color:#94a3b8"'}></div>${locked.signDate?`<div class="field"><label>🔒 达成合作日期<span style="font-size:11px;color:#d97706;margin-left:4px">继承自客户·只读</span></label><input type="date"id="u_sign"value="${d.signDate||''}" disabled style="background:#f1f5f9;color:#94a3b8;cursor:not-allowed"></div>`:`<div class="field"><label>达成合作日期</label><input type="date"id="u_sign"value="${d.signDate||''}" ${canField('d_sign','e')?'':'disabled style="background:#f1f5f9;color:#94a3b8"'}></div>`}</div>${locked.stage?`<div class="field"><label>🔒 客户状态<span style="font-size:11px;color:#d97706;margin-left:4px">${d.type==='staff'?'(空)':'继承自客户·只读'}</span></label><select id="u_stage" disabled style="background:#f1f5f9;color:#94a3b8;cursor:not-allowed">${dealerStageOpts(d)}</select></div>`:canField('d_stage','e')?`<div class="field"><label>客户状态</label><select id="u_stage">${dealerStageOpts(d)}</select></div>`:`<div class="field"><label>客户状态</label><select id="u_stage" disabled style="background:#f1f5f9;color:#94a3b8;cursor:not-allowed">${dealerStageOpts(d)}</select></div>`}</div></div>`:('<div class="mtxt" style="margin:10px 0">您当前角色无编辑经销商资料权限,可仅做跟进记录。</div>')}
${can('edit_dealers')?`<div class="section-title"style="margin:18px 0 10px">添加跟进记录 <button class="btn ghost sm" style="margin-left:auto" onclick="copyFollowupTemplate('dealer','${d.id}')">📋 复制模板</button><button class="btn sm" onclick="openQuickFollowup('dealer','${d.id}')">📥 快速回填</button></div><div class="row2"><div class="field"><label>跟进方式</label><select id="dealer_method"><option>微信</option><option>电话</option><option>面谈</option><option>会议</option><option>邮件</option><option>其他</option></select></div><div class="field"><label>跟进人</label><input id="df_op"value="${currentUser?currentUser.name:''}" placeholder="总部/销售姓名"></div></div><div class="field"style="margin-top:12px"><label>跟进内容</label><textarea id="df_content"rows="2"placeholder="如:确认预缴到账、安排师训、季度复盘、分润对账通知…"></textarea></div><div class="field"style="margin-top:12px"><
label>下次跟进时间(选填,用于提醒)</label><input type="date"id="df_next"></div>`:''}
</div>
<div class="modal-foot">
<button class="btn ghost" onclick="closeModal()">关闭</button>
${can('edit_dealers')?`<button class="btn"onclick="saveDealerDetail('${d.id}')">保存更新</button>`:''}
</div>`);if(can('edit_dealers'))setTimeout(()=>bindAreaSelect('u'),0);}
function dp_goto(v){curDealerView=v;render();}
function switchToAdmin(){let su=null;try{su=JSON.parse(localStorage.getItem('yuanmo_user')||'null');}catch(e){}if(su&&su.id){switchMode('admin');return;}mode='admin';curDealerId='';try{localStorage.removeItem('yuanmo_dealer');}catch(e){}showLogin();}
function dp_switchDealer(id){curDealerId=id;render();}
function toggleSidebar(){const sb=document.querySelector('.sidebar');const ov=document.getElementById('sidebarOverlay');if(sb)sb.classList.toggle('open');if(ov)ov.classList.toggle('show');}
async function dealerPhoneLogin(){const phone=(document.getElementById('dl_phone').value||'').trim();const pwd=document.getElementById('dl_pwd')?document.getElementById('dl_pwd').value:'';const err=document.getElementById('dl_err');if(err)err.textContent='';if(!/^1\d{10}$/.test(phone)){if(err)err.textContent='请输入有效的 11 位手机号';return;}
try{const d=await apiDealerLogin(phone,pwd);if(d.mustChangePwd){window.__pendLogin={resp:d,oldPwd:pwd};apiToken=d.adminToken||d.dealerToken||'';openModal(`<div class="modal-head"><h3>🔒 请修改初始密码</h3><a href="javascript:void(0)"onclick="doLogout()"style="margin-left:auto;font-size:13px;color:#9aa4b2;text-decoration:none">退出登录</a></div><div class="modal-body"><div class="warn-box">您当前使用的是初始密码,为保障账号与客户数据安全,请设置新密码后再进入系统。</div><div class="field"><label>新密码(至少 6 位)</label><input type="password"id="np_pwd"placeholder="请输入新密码"></div><div class="field"><label>确认新密码</label><input type="password"id="np_pwd2"placeholder="再次输入新密码"></div></div><div class="modal-foot"><button class="btn"onclick="submitDealerNewPwd()">确认修改并进入</button></div>`);}else{await finishLogin(d);}}catch(e){if(err)err.textContent=(e&&e.message)||'登录失败';}}
function setActiveToken(m){let t='';try{t=localStorage.getItem(m==='dealer'?'yuanmo_dealer_token':'yuanmo_token')||'';}catch(e){}apiToken=t;}
async function finishLogin(r){currentUser=(r.user&&r.user.id)?{id:r.user.id,name:r.user.name,role:r.user.role||'',roles:r.user.roles||(r.user.role?[r.user.role]:[])}:null;curDealerId=r.dealerId||'';mode=(r.mode==='admin'&¤tUser)?'admin':'dealer';
try{if(r.adminToken)localStorage.setItem('yuanmo_token',r.adminToken);else localStorage.removeItem('yuanmo_token');if(r.dealerToken)localStorage.setItem('yuanmo_dealer_token',r.dealerToken);else localStorage.removeItem('yuanmo_dealer_token');if(currentUser)localStorage.setItem('yuanmo_user',JSON.stringify(currentUser));else localStorage.removeItem('yuanmo_user');if(curDealerId)localStorage.setItem('yuanmo_dealer',JSON.stringify({dealerId:curDealerId,name:r.dealerName||''}));else localStorage.removeItem('yuanmo_dealer');localStorage.setItem('yuanmo_mode',mode);}catch(e){}
setActiveToken(mode);closeLoginScreen();await loadDB();if(mode==='admin')applyExpiry();render();connectWS();toast(mode==='admin'?'欢迎,'+currentUser.name:'欢迎,'+(r.dealerName||(r.user&&r.user.name)||'')+',已进入经销商端');}
async function switchMode(m){if(m===mode){render();return;}lastModeSwitchAt=Date.now();
if(m==='admin'){let su=null;try{su=JSON.parse(localStorage.getItem('yuanmo_user')||'null');}catch(e){}if(!su||!su.id){showLogin();return;}currentUser=su;/* 第195轮:管理端零权限账号不得切入(否则只会看到空白,且从源头避免超员工误点) */if(!firstAllowedView()){toast('你的账号没有管理端权限,请联系管理员开通');currentUser=null;return;}mode='admin';setActiveToken('admin');closeLoginScreen();await loadDB();render();connectWS();setTimeout(applyExpiry,150);return;}
let di=null;try{di=JSON.parse(localStorage.getItem('yuanmo_dealer')||'null');}catch(e){}
if(di&&di.dealerId){currentUser=null;mode='dealer';curDealerId=di.dealerId;setActiveToken('dealer');closeLoginScreen();await loadDB();render();connectWS();return;}
mode='dealer';curDealerId='';render();}
async function submitDealerNewPwd(){const pend=window.__pendLogin;if(!pend)return;const np=(document.getElementById('np_pwd')||{}).value||'';const np2=(document.getElementById('np_pwd2')||{}).value||'';if(np.length<6){toast('新密码至少 6 位');return;}if(np!==np2){toast('两次输入的密码不一致');return;}const _pwdEp=(pend&&pend.resp&&pend.resp.mode==='dealer')?'/api/dealer/password':'/api/user/password';try{const _pr=await apiPost(_pwdEp,{oldPassword:pend.oldPwd,newPassword:np});const _nr=Object.assign({},pend.resp);if(_pr&&_pr.adminToken)_nr.adminToken=_pr.adminToken;if(_pr&&_pr.dealerToken)_nr.dealerToken=_pr.dealerToken;window.__pendLogin=null;closeModal();await finishLogin(_nr);}catch(e){toast('修改失败:'+((e&&e.message)||e));}}
function dealerLogout(){doLogout();}
function myPwdWho(){try{if(typeof mode!=='undefined'&&mode==='dealer'){const d=((DB&&DB.dealers)||[]).find(x=>x.id===curDealerId);return (d&&d.name)||'经销商';}return (currentUser&¤tUser.name)||'当前账号';}catch(e){return '当前账号';}}
function openMyPwd(){openModal(`<div class="modal-head"><h3>🔑 修改我的登录密码</h3><span class="x" onclick="closeModal()">×</span></div>
<div class="modal-body">
<div class="hint" style="margin-bottom:10px">当前账号:<b>${esc(myPwdWho())}</b></div>
<div class="field"><label>原密码(现在用的那个)*</label><input id="mp_old" type="password" placeholder="请输入原密码" autocomplete="current-password"></div>
<div class="field"><label>新密码(至少 6 位)*</label><input id="mp_new" type="password" placeholder="请输入新密码" autocomplete="new-password"></div>
<div class="field"><label>确认新密码 *</label><input id="mp_new2" type="password" placeholder="请再输入一次" autocomplete="new-password"></div>
<div class="warn-box">改完立即生效。<b>其它设备(含手机)上的登录会失效</b>,需要用新密码重新登录;本机不用重新登录。新密码至少 6 位,且不能用 123456 / 888888 这类简单密码。</div>
</div>
<div class="modal-foot"><button class="btn ghost" onclick="closeModal()">取消</button><button class="btn" id="mp_ok" onclick="saveMyPwd()">确认修改</button></div>`);}
async function saveMyPwd(){const _dl=(typeof mode!=='undefined'&&mode==='dealer');const o=((document.getElementById('mp_old')||{}).value||'');const n1=((document.getElementById('mp_new')||{}).value||'');const n2=((document.getElementById('mp_new2')||{}).value||'');if(!o){toast('请输入原密码');return;}if(n1.length<6){toast('新密码至少 6 位');return;}if(n1!==n2){toast('两次输入的新密码不一致');return;}
const _b=document.getElementById('mp_ok');if(_b){_b.disabled=true;_b.textContent='提交中…';}
try{const r=await apiPost(_dl?'/api/dealer/password':'/api/user/password',{oldPassword:o,newPassword:n1});
try{if(r&&r.adminToken)localStorage.setItem('yuanmo_token',r.adminToken);if(r&&r.dealerToken)localStorage.setItem('yuanmo_dealer_token',r.dealerToken);}catch(e){}
try{setActiveToken(mode);}catch(e){}
closeModal();toast('密码已修改;其它设备需用新密码重新登录');
}catch(e){if(_b){_b.disabled=false;_b.textContent='确认修改';}toast('修改失败:'+((e&&e.message)||e));}}
function openDealerRegister(){const typeOpts=Object.entries(DEALER_TYPES).map(([k,c])=>`<option value="${k}">${c.name}(预缴${money(c.fee)}·${pct(c.baseRate*100)})</option>`).join('');openModal(`
<div class="modal-head"><h3>申请注册经销商</h3><span class="x" onclick="closeModal()">×</span></div>
<div class="modal-body">
<div class="warn-box">📌 提交即创建您的专属报备账号,<b>注册手机号即为登录账号</b>,后续凭此手机号进入报备端。信息提交后总部可统一完善预缴/分润等资料。</div>
<div class="field"><label>经销商名称 *</label><input id="rg_name" placeholder="如:星空教育·王老师"></div>
<div class="field"><label>经销商类型 *</label><select id="rg_type">${typeOpts}</select>
<div class="hint">不同类型决定预缴费用与分润比例,详见权益说明。</div></div>
<div class="row2">
<div class="field"><label>注册手机号 *</label><input id="rg_phone" inputmode="numeric" maxlength="11" placeholder="11 位手机号,作登录账号"></div>
<div class="field"><label>所在区域</label><input id="rg_region" placeholder="如:华东·杭州"></div>
</div>
<div class="section-title" style="margin:14px 0 8px">联系地址</div>
<div class="row2">
<div class="field"><label>省</label><input id="rg_prov" placeholder="
如:上海市"></div>
<div class="field"><label>市</label><input id="rg_city" placeholder="如:上海市"></div>
</div>
<div class="row2">
<div class="field"><label>县 / 区</label><input id="rg_district" placeholder="如:浦东新区"></div>
<div class="field"><label>详细地址</label><input id="rg_addr" placeholder="街道 / 门牌号"></div>
</div>
<div class="field"><label>达成合作日期</label><input type="date" id="rg_sign" value="${today()}"></div>
<div id="rg_err" style="color:var(--danger);font-size:12px;min-height:16px;margin-top:6px"></div>
</div>
<div class="modal-foot">
<button class="btn ghost" onclick="closeModal()">取消</button>
<button class="btn" onclick="saveDealerRegister()">提交注册</button>
</div>`);setTimeout(()=>{const n=document.getElementById('rg_name');if(n)n.focus();},60);}
function saveDealerRegister(){const name=(document.getElementById('rg_name').value||'').trim();const type=document.getElementById('rg_type').value;const phone=(document.getElementById('rg_phone').value||'').trim();const region=(document.getElementById('rg_region').value||'').trim();const prov=(document.getElementById('rg_prov').value||'').trim();const city=(document.getElementById('rg_city').value||'').trim();const district=(document.getElementById('rg_district').value||'').trim();const addr=(document.getElementById('rg_addr').value||'').trim();const signDate=document.getElementById('rg_sign').value||today();const err=document.getElementById('rg_err');if(err)err.textContent='';if(!name){if(err)err.textContent='请填写经销商名称';return;}
if(!/^1\d{10}$/.test(phone)){if(err)err.textContent='请输入有效的 11 位手机号(将作为登录账号)';return;}
if(DB.dealers.some(x=>x.phone===phone)){if(err)err.textContent='该手机号已注册,请直接登录或联系管理员';return;}
const d={id:uid('d_'),name,type,phone,region,prov,city,district,addr,signDate,note:'经销商自助注册',amount:0,payments:[],nextPayAt:'',followups:[]};DB.dealers.push(d);ensureStaffUser(d);curDealerId=d.id;save().then(ok=>{if(ok){closeModal();render();toast('注册成功,欢迎「'+name+'」,开始报备转介绍客户吧');}});}
function renderDealerLogin(){view.innerHTML=`
<div class="login-screen">
<div class="lock">📱</div>
<h2>经销商报备端</h2>
<p>请在手机浏览器打开本页,输入您的<b>注册手机号</b>即可报备转介绍客户</p>
<div class="card" style="text-align:left">
<div class="field"><label>本人手机号 <span style="color:var(--danger)">*</span></label>
<input id="dl_phone" inputmode="numeric" maxlength="11" placeholder="请输入 11 位手机号"></div>
<div class="field"><label>登录密码</label>
<input id="dl_pwd" type="password" placeholder="请输入登录密码(默认 888888)" onkeydown="if(event.key==='Enter')dealerPhoneLogin()"></div>
<div id="dl_err" style="color:var(--danger);font-size:12px;min-height:16px;margin-top:6px"></div>
<button class="btn" style="width:100%" onclick="dealerPhoneLogin()">进入我的报备端</button>
</div>
<div class="qr-hint">管理员可在「管理端 → 经销商管理」查看各经销商手机号与登录密码<br>并将本页链接分享给经销商在手机上打开</div>
<div class="acts">
<button class="btn ghost" onclick="copyShareLink()">📋 复制本页链接</button>
<button class="btn ghost" onclick="switchToAdmin()">返回管理端</button>
</div>
<div style="display:flex;align-items:center;gap:10px;margin:18px 0 10px;color:#64748b;font-size:12px"><div style="flex:1;height:1px;background:#e2e8f0"></div><span>还没有账号?</span><div style="flex:1;height:1px;background:#e2e8f0"></div></div>
<div class="warn-box">经销商账号由总部在「管理端 → 经销商管理」创建并设置密码,暂不支持自助注册。请联系管理员获取您的登录账号与密码。</div>
</div>`;}
function copyShareLink(){const u=location.href.split('#')[0];if(navigator.clipboard&&navigator.clipboard.writeText){navigator.clipboard.writeText(u).then(()=>toast('链接已复制,可发给经销商在手机打开')).catch(()=>toast('复制失败,请手动复制地址栏链接'));}else toast('请手动复制浏览器地址栏链接');}
function renderDealerPortal(){var _dsv=(typeof window!=='undefined')?window.scrollY:0;var _dview=curDealerView;if(!DB){renderDealerLogin();return;}
if(!DB.dealers.length){view.innerHTML=`<div class="card" style="max-width:560px;margin:40px auto;text-align:center">
<h2 style="margin-bottom:10px">📭 暂无可操作的经销商</h2>
<p class="mtxt" style="margin-bottom:16px">请先在「管理端 → 经销商管理」添加经销商,再切换到经销商操作端报备客户。</p>
<button class="btn" onclick="swit
chToAdmin()">返回管理端</button></div>`;return;}
if(!curDealerId){renderDealerLogin();return;}
const d=DB.dealers.find(x=>x.id===curDealerId);if(!d){curDealerId=null;renderDealerLogin();return;}
const bar=`<div class="dealer-bar">
<span class="portal-tag">📱 经销商操作端</span>
<div class="who">当前身份:<b>${d.name}</b> | ${DT(d.type).name}</div>
<div style="flex:1"></div>
<button class="btn ghost" onclick="openMyPwd()">🔑 修改密码</button>
<button class="btn ghost" onclick="dealerLogout()">退出登录</button>
<button class="btn ghost" onclick="switchToAdmin()">返回管理端</button>
</div>`;const dpAll=[['dashboard','📊','看板','dp_dashboard'],['report','➕','报备','dp_report'],['customers','🤝','客户','dp_customers'],['followups','📝','跟进','dp_followups'],['finance','💰','财务','dp_finance'],['benefits','🎁','权益','dp_benefits']];const dpVisible=dpAll.filter(it=>canDealer(it[3]));if(!dpVisible.find(it=>it[0]===curDealerView))curDealerView=dpVisible.length?dpVisible[0][0]:'dashboard';let body='';if(curDealerView==='report')body=dealerReportHTML(d);else if(curDealerView==='customers')body=dealerCustomersHTML(d);else if(curDealerView==='followups')body=dealerFollowupsHTML(d);else if(curDealerView==='benefits')body=dealerBenefitsHTML(d);else if(curDealerView==='finance')body=dealerFinanceHTML(d);else body=dealerDashboardHTML(d);const tabs=dpVisible;const tabsHTML=`<div class="dealer-tabs">${tabs.map(([v,ic,lb])=>`<a class="${v===curDealerView?'active':''}"onclick="dp_goto('${v}')"><span class="ti">${ic}</span>${lb}</a>`).join('')}</div>`;view.innerHTML=bar+`<div id="dealerBody">${body}</div>`+tabsHTML;mountModuleTools(curDealerView,'#dealerBody');if(curDealerView==='report')setTimeout(()=>bindAreaSelect('dr'),0);if(curDealerView==='customers')dpCustSearch(dpCustFilter.kw);if(curDealerView==='followups')dp_filter(dpfFilter.q);fitTableLocks('#dealerBody');if(_dview===curDealerView){requestAnimationFrame(function(){window.scrollTo(0,_dsv);fitLockHeights();});}}
/* ---- 【2026-09-18 第 68 轮】经销商端 · 服务老师可见性 ----
serviceTeacherIds 现为「员工用户 id」⇒ 员工类经销商账号若被指派为某客户的服务老师,
该客户在经销商端也要可见。读(列表 / KPI / 跟进)与写(改客户)必须同源判定,
并与服务端 svcTeacherDealerSees 保持一致,避免出现「看得到改不了」或反之。 */
function dealerSvcTeacher(d,c){
if(!d||!c)return false;
if(d.type!=='staff'||!d.userId)return false;
var a=Array.isArray(c.serviceTeacherIds)?c.serviceTeacherIds:[];
return a.indexOf(String(d.userId))>=0;
}
function curDealerObj(){return (DB.dealers||[]).find(function(x){return x.id===curDealerId;})||null;}
function dealerCanTouch(d,c){return !!(d&&c&&(c.dealerId===d.id||c.serviceStaffId===d.id||dealerSvcTeacher(d,c)));}
function dealerVisibleCusts(d){
if(!d)return [];
return (DB.customers||[]).filter(function(c){return c.dealerId===d.id||c.serviceStaffId===d.id||dealerSvcTeacher(d,c);});
}
function dealerTagHtml(d,c){
var sv=(c.serviceStaffId===d.id&&c.dealerId!==d.id);
var st=dealerSvcTeacher(d,c);
if(!sv&&!st)return '';
return ' <span class="st st-reported" style="margin-left:4px">'+(st?'服务老师':'服务')+'</span>';
}
function dealerDashboardHTML(d){const s=dealerStats(d.id);
const sv=stageStatsOf(DB.customers.filter(c=>(c.serviceStaffId===d.id&&c.dealerId!==d.id)||dealerSvcTeacher(d,c)));const myCs=dealerVisibleCusts(d);const todo=myCs.filter(c=>['reported','following'].includes(c.stage)&&!isProtectionExpired(c));return`
<div class="topbar"><div><h1>我的看板</h1><p>实时掌握您报备客户的转化与应得分润</p></div>
<button class="btn" onclick="dp_goto('report')">➕ 报备新客户</button></div>
${pwaHomeHint()}
<div class="grid kpis">
${kpiCard('📋','我报备的客户',s.reported,'含全部转介绍线索')}
${kpiCard('🛎️','我服务的客户',sv.reported,'对接服务的客户')}
${kpiCard('🔄','转化率·报备',pct(s.convRate),'报备→体验','#6366f1')}
${kpiCard('🔄','转化率·服务',pct(sv.convRate),'服务→体验','#6366f1')}
${kpiCard('💰','成交率·报备',pct(s.closeRate),'报备→签约','#16a34a')}
${kpiCard('💰','成交率·服务',pct(sv.closeRate),'服务→签约','#16a34a')}
</div>
<div class="grid two-col">
<div class="card">${funnelHTML(s,null,'我报备的客户 · 转化漏斗')}
<div style="margin-top:10px;font-size:12.5px;color:#475569">体验→签约转化率:<b>${pct(s.experienced?s.signedAll/s.experienced*100:0)}</b></div></div>
<div class="card">${funnelHTML(sv,'服务客户','我服务的客户 · 转化漏斗')}
<div style="margin-top:10px;font-size:12.5px;color:#475569">体验→签约转化率:<b>${pct(sv.experienced?sv.signedAll/sv.experienced*100:0)}</b></div></div>
</div>
<div class="section-title">待跟进提醒(${todo.length})</div>
<div class="card">${todo.length?todo.map(c=>{const left=Math.max(0,PROTECTION_DAYS-Math.floor((Date.now()-new Date(c.reportDate))/86400000));
return `<div style="padding:9px 0;border-bottom:1px dashed var(--line);display:flex;justify-content:space-between;align-items:center"><div>${custNameHtml(c)}</div><span class="pill">剩 ${left}天保护期</span></div>`;}).join(''):'<div class="empty">暂无待跟进客户</div>'}</div>`;}
function dealerFinanceHTML(d){const paid=paidOf(d),owed=owedOf(d),rem=nextPayReminder(d);const records=(d.payments||[]).slice().sort((a,b)=>(b.date||'').localeCompare(a.date||''));const myCs=DB.customers.filter(c=>c.dealerId===d.id);const signed=myCs.filter(c=>['signed_full','signed_installment'].includes(c.stage));const custContract=signed.reduce((s,c)=>s+Number(c.amount||0),0);const custPaid=signed.reduce((s,c)=>s+paidOf(c),0);const custOwed=Math.max(0,custContract-custPaid);return`
<div class="topbar"><div><h1>💰 我的财务</h1><p>您与总部的合同/保证金:合同总额、交款、欠款与下次补款提醒(可分次登记交款)</p></div>
<button class="btn" onclick="openPayment('d','${d.id}')">+ 登记交款</button></div>
<div class="grid kpis">
${kpiCard('📄','合同总额',dpMoney(d.amount),'与总部的合同/保证金')}
${kpiCard('✅
','已交款',dpMoney(paid),'累计已登记交款','#16a34a')}
${kpiCard('⚠️','欠款',dpMoney(owed),owed>0?'待补款':'已结清','#dc2626')}
${kpiCard('⏰','下次补款',rem.text,'提醒')}
</div>
<div class="section-title">我的交款记录(${records.length})</div>
<div class="card"><div class="tbl-h-scroll"><table class="tbl"><thead><tr><th>日期</th><th>方式</th><th>金额</th><th>备注</th></tr></thead><tbody>
${records.length?records.map(r=>`<tr><td>${r.date||''}</td><td>${r.method||''}</td><td>${dpMoney(r.amount)}</td><td class="tdw"><div class="nt-clamp" title="${esc(r.note||'')}">${esc(r.note||'')||'—'}</div></td></tr>`).join(''):'<tr><td colspan="4" class="empty">暂无交款记录,点击右上角「登记交款」开始分次交款</td></tr>'}
</tbody></table></div></div>
<div class="section-title">我报备客户的回款(只读 · 总部统一登记)</div>
<div class="card"><div class="grid kpis" style="grid-template-columns:repeat(3,1fr)">
${kpiCard('🤝','客户合同总额',dpMoney(custContract),'我报备已签客户')}
${kpiCard('✅','客户已交款',dpMoney(custPaid),'累计','#16a34a')}
${kpiCard('⚠️','客户欠款',dpMoney(custOwed),'待补款','#dc2626')}
</div></div>`;}
function dealerReportHTML(d){return`
<div class="topbar"><div><h1>报备转介绍客户</h1><p>提交报备后系统按首次报备判定归属,进入 ${PROTECTION_DAYS} 天保护期</p></div></div>
<div class="card" style="width:100%">
<div class="warn-box">📌 报备即进入 <b>${PROTECTION_DAYS} 天</b> 保护期,先报先得;若同名/同手机号已被其他经销商报备,将归属对方。</div>
<div class="row2">
<div class="field"><label>客户姓名 <span style="color:var(--danger)">*</span></label><input id="dr_name" placeholder="校区校长" ${canDealerField('c_name','e')?'':'disabled'}></div>
<div class="field"><label>手机号 <span style="color:var(--danger)">*</span></label><input id="dr_phone" placeholder="校长手机号,用于查重" ${canDealerField('phone','e')?'':'disabled'}></div>
</div>
<div class="row2">
<div class="field"><label>校区/机构 <span style="color:var(--danger)">*</span></label><input id="dr_school" placeholder="如:浦东第一幼儿园" ${canDealerField('c_school','e')?'':'disabled'}></div>
<div class="field"><label>意向合作项目</label>${canDealerField('c_intent','e')?intentMultiHTML('dr_intent',''):'<span class="mtxt">无编辑权限</span>'}</div>
</div>
<div class="section-title" style="margin:14px 0 8px;justify-content:space-between">
<span>联系地址</span>
<button type="button" id="drGeoBtn" class="geo-btn" onclick="drGeoLocate()" title="调用手机 GPS 自动填写省/市/区/详细地址,并保留人工修改">📍 自动定位</button>
</div>
<div id="dr_area_wrap">${canDealerField('addr','e')?areaSelectHTML('dr','','','','',true):'<span class="mtxt">无编辑权限</span>'}</div>
<div class="row2">
<div class="field"><label>报备日期 <span class="hint-inline" title="系统按首次报备时间自动锁定,不允许手动修改">🔒 系统自动填写</span></label><input type="date" id="dr_date" value="${today()}" readonly tabindex="-1" onfocus="this.blur()" style="background:#f5f5f7;cursor:not-allowed;color:#666"></div>
</div>
<div class="field" style="margin-top:12px"><label>首次沟通备注</label><textarea id="dr_note" rows="2"></textarea></div>
<div class="rp-actions" style="margin-top:14px;display:flex;gap:10px">
<button class="btn" onclick="dealerReportCustomer()">提交报备</button>
<button class="btn ghost" onclick="dp_goto('customers')">查看我的客户</button>
</div>
</div>`;}
function drGeoLocate(){geoFillAddr('dr','drGeoBtn');}
function dealerReportCustomer(){if(isPhoneLikeSchool((document.getElementById('dr_school')||{}).value||'')){toast('「所属校区/机构」不能填电话号码,请改填校区/机构名称');return;}const name=f('dr_name');const phone=f('dr_phone');const school=f('dr_school');const reportDate=today();const note=f('dr_note');const intent=readIntentMulti('dr_intent');const prov=document.getElem
entById('dr_prov').value.trim();const city=document.getElementById('dr_city').value.trim();const district=document.getElementById('dr_district').value.trim();const addr=document.getElementById('dr_addr').value.trim();if(!name){toast('请填写客户姓名');return;}
if(!/^1\d{10}$/.test(phone)){toast('请填写正确的 11 位客户手机号(用于查重,避免同一客户重复报备)');return;}
const dEx=DB.dealers.find(x=>x.phone===phone);if(dEx){showDealerPhoneModal(phone,dEx);return;}
const ex=findExistingReferral(phone);if(ex){showDupCustomerModal(ex);return;}
const me=DB.dealers.find(x=>x.id===curDealerId);DB.customers.push({id:uid('c_'),dealerId:curDealerId,name,phone,school,reportDate,intent,prov,city,district,addr,stage:'reported',amount:0,installment:false,refunded:false,signDate:'',followups:[],payments:[],nextPayAt:''});const c=DB.customers[DB.customers.length-1];c.followups.push({date:reportDate,method:'微信',operator:me?me.name:'经销商',content:note?('经销商线上报备:'+note):'经销商线上报备客户'});save().then(function(ok){if(!ok)return;render();toast('报备成功,进入保护期 '+PROTECTION_DAYS+' 天');});}
function openDealerEditCustomer(id){if(!canDealer('dp_customer_edit')){toast('无权限:不能修改客户信息');return;}const c=DB.customers.find(x=>x.id===id);if(!c)return;if(!dealerCanTouch(curDealerObj(),c)){toast('只能修改自己报备或对接服务的客户');return;}openModal(`<div class="modal-head"><h3>修改客户信息</h3><span class="x" onclick="closeModal()">×</span></div><div class="modal-body"><div class="row2"><div class="field"><label>客户姓名 <span style="color:var(--danger)">*</span></label><input id="de_name" value="${esc(c.name||'')}" placeholder="校区校长" ${canDealerField('c_name','e')?'':'disabled'}></div><div class="field"><label>手机号 <span style="color:var(--danger)">*</span></label><input id="de_phone" value="${esc(c.phone||'')}" placeholder="校长手机号" ${canDealerField('phone','e')?'':'disabled'}></div></div><div class="row2"><div class="field"><label>校区/机构</label><input id="de_school" value="${esc(c.school||'')}" placeholder="如:浦东第一幼儿园" ${canDealerField('c_school','e')?'':'disabled'}></div><div class="field"><label>意向合作项目</label>${canDealerField('c_intent','e')?intentMultiHTML('de_intent',c.intent):'<span class="mtxt">无编辑权限</span>'}</div></div><div class="section-title" style="margin:14px 0 8px;justify-content:space-between"><span>联系地址</span><button type="button" id="deGeoBtn" class="geo-btn" onclick="deGeoLocate()" title="调用手机 GPS 自动填写省/市/区/详细地址">📍 自动定位</button></div><div id="de_area_wrap">${canDealerField('addr','e')?areaSelectHTML('de',c.prov||'',c.city||'',c.district||'',c.addr||''):'<span class="mtxt">无编辑权限(地址:'+([c.prov,c.city,c.district,c.addr].filter(Boolean).join(' ')||'未填写')+')</span>'}</div><div class="row2"><div class="field"><label>报备日期 <span class="hint-inline" title="系统按首次报备时间自动锁定,不允许手动修改">🔒 系统自动填写</span></label><input type="date" value="${c.reportDate||''}" readonly tabindex="-1" onfocus="this.blur()" style="background:#f5f5f7;cursor:not-allowed;color:#666"></div></div><div class="field" style="margin-top:12px"><label>修改说明</label><textarea id="de_note" rows="2" placeholder="说明本次修改内容(选填)"></textarea></div></div><div class="modal-foot"><button class="btn ghost" onclick="closeModal()">取消</button><button class="btn" onclick="saveDealerEditCustomer('${c.id}')">保存修改</button></div>`);setTimeout(()=>bindAreaSelect('de'),0);}
function deGeoLocate(){geoFillAddr('de','deGeoBtn');}
function saveDealerEditCustomer(id){if(!canDealer('dp_customer_edit')){toast('无权限');return;}const c=DB.customers.find(x=>x.id===id);if(!c)return;if(!dealerCanTouch(curDealerObj(),c)){toast('只能修改自己报备或对接服务的客户');return;}const canE=fid=>canDealerField(fid,'e');const name=canE('c_name')?document.getElementById('de_name').value.trim():c.name;const phone=canE('phone')?document.getElementById('de_phone').value.trim():(c.phone||'');const school=canE('c_school')?document.getElementById('de_school').value.trim():(c.school||'');const intent=canE('c_intent')?readIntentMulti('de_intent'):(Array.isArray(c.intent)?c.intent:[]);const addrCan=canE('addr');const prov=addrCan?document.getElementById('de_prov').value.trim():(c.prov||'');const city=addrCan?document.getElementById('de_city').value.trim():(c.city||'');const district=addrCan?document.getElementById('de_district').value.trim():(c.district||'');const addr=addrCan?document.getElementById('de_addr').value.trim():(c.addr||'');const note=document.getElementById('de_note').value.trim();if(!name){toast('请填写客户姓名');return;}if(!/^1\d{10}$/.test(phone)){toast('请填写正确的 11 位客户手机号');return;}if(phone!==c.phone){const ex=DB.customers.find(x=>x.id!==id&&x.phone===phone);if(ex){showDupCustomerModal(ex);return;}}const oldIntent=(Array.isArray(c.intent)?c.intent:[]).join('、')||'未确定';const newIntent=intent.join('、')||'未确定';const changes=[];if(name!==c.name)changes.push('姓名 '+c.name+'→'+name);if(phone!==(c.phone||''))changes.push('手机号 '+(c.phone||'空')+'→'+phone);if(school!==(c.school||''))changes.push('学校 '+(c.school||'空')+'→'+(school||'空'));if(newIntent!==oldIntent)changes.push('意向 '+oldIntent+'→'+newIntent);const oldAddr=[c.prov,c.city,c.district,c.addr].filter(Boolean).join(' ')||'空';const newAddr=[prov,city,district,addr].filter(Boolean).join(' ')||'空';if(newAddr!==oldAddr)changes.push('地址 '+oldAddr+'→'+newAddr);c.name=name;c.phone=phone;c.school=school;c.intent=intent;c.prov=prov;c.city=city;c.district=district;c.addr=addr;if(changes.length||note){const me=DB.dealers.find(x=>x.id===curDealerId);c.followups=c.followups||[];c.followups.push({date:today(),method:'修改',operator:me?me.name:'经销商',content:'经销商修改客户信息:'+(changes.length?changes.join(';'):'无字段变更')+(note?('。说明:'+note):'')});}save().then(ok=>{if(ok){closeModal();render();toast('已保存修改');}});}
let dpCustFilter={kw:'',stage:'',intent:'',owner:'',prot:'',phone:'',paidMin:'',paidMax:''};let dpCustAdvOpen=false;
function dpCustProtState(c){if(['signed_full','signed_installment','refunded','lost','terminated','cooperated'].includes(c.stage))return 'done';return isProtectionExpired(c)?'expired':'active';}
function dpCustToggleAdv(){dpCustAdvOpen=!dpCustAdvOpen;const p=document.getElementById('dpCustAdvPanel');if(p)p.style.display=dpCustAdvOpen?'block':'none';}
function dpCustResetFilter(){dpCustFilter={kw:'',stage:'',intent:'',owner:'',prot:'',phone:'',paidMin:'',paidMax:''};dp_goto('customers');}
function dpCustSearch(v){dpCustFilter.kw=v;const q=(v||'').trim().toLowerCase();const rows=document.querySelectorAll('#dpCustTable tr[data-search], #dpBaobeiTable tr[data-search]');rows.forEach(tr=>{const hit=(tr.getAttribute('data-search')||'').toLowerCase().includes(q);tr.style.display=(!q||hit)?'':'none';});const bb=document.getElementById('dpBaobeiBlock');if(bb){const bn=bb.querySelectorAll('#dpBaobeiTable tr[data-search]');let bh=false;for(let i=0;i<bn.length;i++){if((bn[i].getAttribute('data-search')||'').toLowerCase().includes(q)){bh=true;break;}}bb.style.display=(q&&!bh)?'none':'';}}
function dealerCustomersHTML(d){const f=dpCustFilter;let cs=dealerVisibleCusts(d).sort((a,b)=>{const done=['signed_full','signed_installment','refunded','lost','terminated','cooperated'];const key=c=>done.includes(c.stage)?Infinity:(PROTECTION_DAYS-Math.floor((Date.now()-new Date(c.reportDate))/86400000));const ka=key(a),kb=key(b);return ka===kb?new Date(a.reportDate)-new Date(b.reportDate):ka-kb;});if(f.stage)cs=cs.filter(c=>c.stage===f.stage);if(f.intent)cs=cs.filter(c=>intentArr(c).includes(f.intent));if(f.owner==='mine')cs=cs.filter(c=>c.dealerId===d.id);else if(f.owner==='service')cs=cs.filter(c=>(c.serviceStaffId===d.id&&c.dealerId!==d.id)||dealerSvcTeacher(d,c));if(f.prot)cs=cs.filter(c=>dpCustProtState(c)===f.prot);if(f.phone){const pq=f.phone.trim().toLowerCase();cs=cs.filter(c=>(c.phone||'').toLowerCase().includes(pq));}if(canDealerField('amount','v')){if(f.paidMin!=='')cs=cs.filter(c=>paidOf(c)>=(+f.paidMin));if(f.paidMax!=='')cs=cs.filter(c=>paidOf(c)<=(+f.paidMax));}const rows=cs.length?cs.map(c=>{const left=Math.max(0,PROTECTION_DAYS-Math.floor((Date.now()-new Date(c.reportDate))/86400000));const done=['signed_full','signed_installment','refunded','lost','terminated','cooperated'].includes(c.stage);const prot=done?'—':(isProtectionExpired(c)?'<span class="st st-lost">已过期</span>':'剩 '+left+' 天');const kw=(fTxt('c_name',c.name)+' '+(fLevel('c_school')==='full'?(c.school||''):'')+' '+(fLevel('phone')==='full'?(c.phone||''):'')+' '+(fLevel('c_intent')==='full'?intentArr(c).join(' '):'')+' '+(fLevel('addr')==='full'?([c.prov,c.city,c.district,c.addr].filter(Boolean).join(' ')):'')+' '+(dealerName(c.dealerId)||'')+' '+(fLevel('c_stage')==='full'?stageInfoOf(c.stage).label:'')).toLowerCase().replace(/"/g,'');const callBtn=(c.phone&&/^1\d{10}$/.test(c.phone))?`<a class="btn ghost sm" href="tel:${c.phone}" title="拨打 ${c.phone}">📞 拨打</a>`:'';return `<tr data-search="${kw}">
<td>${custNameHtml(c)}${dealerTagHtml(d,c)}</td>
<td>${intentTag(c)}</td>
<td>${canDealerField('phone','v')?(c.phone||'—'):fmtPhone(c.phone)}</td>
<td>${dealerName(c.dealerId)||'—'}</td>
<td>${fStageBadge('c_stage',c.stage)}</td>
<td>${canDealerField('amount','v')?money(c.amount):'—'}</td>
<td>${canDealerField('amount','v')?money(paidOf(c)):'—'}</td>
<td>${canDealerField('amount','v')?money(owedOf(c)):'—'}</td>
<td>${prot}</td>
<td style="text-align:right;white-space:nowrap">${callBtn}${dealerCanTouch(d,c)&&canDealer('dp_customer_edit')?`<button class="btn ghost sm" onclick="openDealerEditCustomer('${c.id}')">✏️ 修改</button>`:''}${canDealerField('c_followups','e')?`<button class="btn ghost sm" onclick="openCustomerFollowup('${c.id}')">写跟进(${(c.followups||[]).length})</button>`:''}</td>
</tr>`;}).join(''):'<tr><td colspan="10" class="empty">尚无报备客户,去「报备客户」提交吧</td></tr>';return `
<div class="topbar"><div><h1>我的客户</h1><p>您报备的客户及您负责对接服务的客户(「服务」标签为对接客户)</p></div>
<button class="btn" onclick="dp_goto('report')">➕ 报备新客户</button></div>
<div class="search">
<input id="dpCustSearchBox" class="list-search" placeholder="搜索客户姓名 / 校区 / 手机号 / 意向(含指派报备客户)" value="${esc(dpCustFilter.kw)}" oninput="dpCustSearch(this.value)" oncompositionend="dpCustSearch(this.value)">
<button class="btn ghost sm" type="button" onclick="dpCustToggleAdv()">🔍 高级筛选</button>
<button class="btn ghost sm" onclick="dpCustResetFilter()">重置</button>
</div>
<div class="card" id="dpCustAdvPanel" style="${dpCustAdvOpen?'':'display:none;'}margin-bottom:14px;padding:16px 18px">
<div class="row2">
<div class="field"><label>客户状态</label><select onchange="dpCustFilter.stage=this.value;dp_goto('customers')"><option value="">全部状态</option>${Object.entries(STAGES).map(([k,v])=>`<option value="${k}"${dpCustFilter.stage===k?' selected':''}>${v.label}</option>`).join('')}</select></div>
<div class="field"><label>意向项目</label><select onchange="dpCustFilter.intent=this.value;dp_goto('customers')"><option value="">全部意向</option>${INTENT_OPTIONS.map(x=>`<option value="${x}"${dpCustFilter.intent===x?' selected':''}>${x}</option>`).join('')}</select></div>
</div>
<div class="row2">
<div class="field"><label>归属</label><select onchange="dpCustFilter.owner=this.value;dp_goto('customers')"><option value="">全部</option><option value="mine"${dpCustFilter.owner==='mine'?' selected':''}>我的报备</option><option value="service"${dpCustFilter.owner==='service'?' selected':''}>我服务的</option></select></div>
<div class="field"><label>保护期</label><select onchange="dpCustFilter.prot=this.value;dp_goto('customers')"><option value="">全部</option><option value="active"${dpCustFilter.prot==='active'?' selected':''}>保护中</option><option value="expired"${dpCustFilter.prot==='expired'?' selected':''}>已过期</option><option value="done"${dpCustFilter.prot==='done'?' selected':''}>已成交</option></select></div>
</div>
<div class="row2">
<div class="field"><label>手机号</label><input id="dpCustPhone" placeholder="搜索手机号" value="${esc(dpCustFilter.phone)}" oninput="sbxKw(this,'phone','dp')" oncompositionend="sbxKw(this,'phone','dp',1)"></div>
${canDealerField('amount','v')?`<div class="field"><label>已交款金额范围</label><div style="display:flex;gap:6px;align-items:center"><input type="number" min="0" id="dpCustPaidMin" placeholder="最低" value="${esc(dpCustFilter.paidMin)}" onchange="dpCustFilter.paidMin=this.value;dp_goto('customers')"><span class="mtxt">~</span><input type="number" min="0" id="dpCustPaidMax" placeholder="最高" value="${esc(dpCustFilter.paidMax)}" onchange="dpCustFilter.paidMax=this.value;dp_goto('customers')"></div></div>`:`<div class="field"><label>已交款金额范围</label><div class="mtxt" style="color:#94a3b8">无「金额」查看权限</div></div>`}
</div>
</div>
<div class="card table-card"><table id="dpCustTable" style="min-width:760px"><thead><tr><th>客户</th><th>意向项目</th><th>手机号</th><th>归属经销商</th><th>客户状态</th><th>金额</th><th>已收款</th><th>欠款</th><th>保护期</th><th>操作</th></tr></thead>
<tbody>${rows}</tbody></table></div>
${dealerBaobeiHTML()}`;}
/* ★ 2026-09-22:指派报备客户(只读)—— 报备系统管理端批量指定的「对接员工=当前经销商」的报备客户,
独立数据块 baobeiCustomers(服务端已按 dp_baobei 权限过滤 + 电话脱敏)。此处仅展示,无任何写操作按钮。 */
function dealerBaobeiHTML(){
if(!canDealer('dp_baobei'))return '';
const list=(DB&&Array.isArray(DB.baobeiCustomers))?DB.baobeiCustomers:[];
const fmt=function(iso){if(!iso)return '';try{const d=new Date(iso);const p=(n)=>String(n).padStart(2,'0');return d.getFullYear()+'-'+p(d.getMonth()+1)+'-'+p(d.getDate());}catch(e){return iso;}};
const rows=list.length?list.map(function(b){
const intents=(Array.isArray(b.intents)?b.intents:[]).map(function(x){return '<span class="tag">'+esc(x)+'</span>';}).join('')||'—';
const phone=b.principalPhone?(/^1\d{10}$/.test(b.principalPhone)?('<a class="btn ghost sm" href="tel:'+b.principalPhone+'">'+esc(b.principalPhone)+'</a>'):esc(b.principalPhone)):'—';
const bkw=esc((String(b.principalName||'')+' '+String(b.campusName||'')+' '+String(b.principalPhone||'')+' '+(Array.isArray(b.intents)?b.intents.join(' '):'')+' '+String(b.dealerName||'')).toLowerCase());
return '<tr data-search="'+bkw+'">'
+'<td>'+esc(b.principalName||'—')+' <span class="st st-reported" style="margin-left:4px">报备</span></td>'
+'<td>'+esc(b.campusName||'—')+'</td>'
+'<td>'+phone+'</td>'
+'<td>'+intents+'</td>'
+'<td>'+esc(b.dealerName||'—')+'</td>'
+'<td>'+esc(fmt(b.reportAt))+'</td>'
+'<td>'+esc(fmt(b.assignedAt))+'</td>'
+'<td style="color:#999">只读</td>'
+'</tr>';
}).join(''):'<tr><td colspan="8" class="empty">暂无指派给我的报备客户</td></tr>';
return '<div id="dpBaobeiBlock"><div class="section-title" style="margin-top:18px">📥 指派报备客户('+list.length+')</div>'
+'<div class="card table-card"><table id="dpBaobeiTable" style="min-width:760px"><thead><tr><th>客户·校长</th><th>校区名称</th><th>校长电话</th><th>合作意向</th><th>归属经销商</th><th>报备时间</th><th>指派时间</th><th>操作</th></tr></thead><tbody>'+rows+'</tbody></table></div></div>';
}
function isWechatBrowser(){try{return (navigator.userAgent||'').indexOf('MicroMessenger')>=0;}catch(e){return false;}}
function wxCopyLink(btn){var u=location.href;var ok=false;try{var inp=document.createElement('input');inp.value=u;document.body.appendChild(inp);inp.select();ok=document.execCommand('copy');document.body.removeChild(inp);}catch(e){ok=false;}if(!ok){try{prompt('请长按复制此链接:',u);}catch(e){}}if(btn){btn.textContent=ok?'✅ 已复制':'请长按复制';setTimeout(function(){btn.textContent='📋 复制本页链接';},2200);}}
function isMobileUA(){try{return /Android|iPhone|iPad|iPod|Mobile|HarmonyOS/i.test(navigator.userAgent||'');}catch(e){return false;}}
function pwaHostHintHTML(){try{var u=(navigator.userAgent||'').toLowerCase();return /iphone|ipad|ipod/.test(u)?'Safari':'Chrome / 系统浏览器';}catch(e){return '浏览器';}}
function pwaGuideHTML(short){var wx=isWechatBrowser();if(!wx&&!isMobileUA())return '';if(short)return '<div style="margin-top:6px;font-size:12.5px;line-height:1.7">💡 想更顺手:可把本站<b>添加到手机桌面</b>,像 App 一样全屏打开 —— iPhone 用 Safari「分享 ⬆️ → 添加到主屏幕」;安卓用浏览器「⋮ → 添加到主屏幕 / 安装应用」。</div>';return '<div style="margin-top:8px;padding-top:8px;border-top:1px dashed currentColor"><b>📲 添加到手机桌面(像 App 一样用)</b><div style="margin-top:4px;font-size:12.5px;line-height:1.75">'+(wx?'<div>① 先点右上角「···」→「在浏览器打开」</div>':'')+'<div>'+(wx?'②':'①')+' <b>iPhone</b>:用 <b>Safari</b> 打开 → 点底部「分享」⬆️ → 下滑找到「添加到主屏幕」</div><div>'+(wx?'③':'②')+' <b>安卓</b>:用 <b>Chrome / 系统浏览器</b> 打开 → 点右上角「⋮」→「添加到主屏幕」或「安装应用」</div></div><div style="margin-top:3px;font-size:12px;opacity:.85">加完后桌面有独立图标,打开即全屏,不用每次输网址。</div></div>';}
function wxUsageBoxHTML(){var wx=isWechatBrowser();var inner=wx?'<div style="margin-top:6px">✅ <b>粘贴聊天文字:可用</b> —— 长按选中微信聊天内容 → 复制 → 粘到下方文本框,系统照样能识别客户。</div><div style="margin-top:4px">⚠️ <b>上传截图 / 文件:不可靠</b> —— 微信浏览器会丢内容(不是故障)。要看截图请点右上角「···」→「在浏览器打开」。</div>':'<div style="margin-top:6px">✅ <b>上传文件 / 拖入 / 粘贴:都可用</b> —— 支持 txt / docx / pdf / 截图。系统会自动识别客户,并提取姓名、手机、邮箱、地址。</div>';return '<div class="warn-box" style="background:'+(wx?'#fffbeb':'#f0f9ff')+';border-color:'+(wx?'#fcd34d':'#93c5fd')+';color:'+(wx?'#92400e':'#1e40af')+';line-height:1.7"><b>'+(wx?'⚠️ 当前在微信内打开':'ℹ️ 使用说明(在浏览器中打开)')+'</b>'+inner+pwaGuideHTML(true)+'</div>';}
function wxInAppGuide(){var wx=isWechatBrowser();if(!wx&&!isMobileUA())return '';return `<div class="warn-box" style="background:${wx?'#fffbeb':'#f0f9ff'};border-color:${wx?'#fcd34d':'#93c5fd'};color:${wx?'#92400e':'#1e40af'};margin-bottom:12px;line-height:1.7"><b>${wx?'⚠️ 当前在微信内打开':'ℹ️ 手机上使用小贴士'}</b>${wx?'<div style="margin-top:6px">✅ <b>粘贴聊天文字:可用</b> —— 长按选中微信聊天内容 → 复制 → 点「📤 上传微信聊天记录」粘进文本框,系统照样能识别客户。</div><div style="margin-top:4px">⚠️ <b>上传截图 / 文件:不可靠</b> —— 微信浏览器会丢内容(不是故障)。请点右上角「···」→「在浏览器打开」,或复制链接到 Chrome / Safari 打开。</div>':'<div style="margin-top:6px">✅ 在浏览器里打开:<b>上传截图 / 文件、拖拽、粘贴都可用</b>。</div>'}${pwaGuideHTML(false)}${wx?'<button type="button" class="btn ghost" style="margin-top:10px;padding:8px 12px;font-size:13px" onclick="wxCopyLink(this)">📋 复制本页链接</button>':''}</div>`;}
function pwaHintDismiss(btn){try{localStorage.setItem('ym_pwa_hint_off','1');}catch(e){}try{var el=null;if(btn)el=btn.parentNode;if(el)el.parentNode.removeChild(el);}catch(e){}}
function pwaHomeHint(){try{if(!isMobileUA())return '';var st=false;try{if(window.matchMedia)st=!!matchMedia('(display-mode: standalone)').matches;}catch(e){}try{if(navigator.standalone===true)st=true;}catch(e){}if(st)return '';try{if(localStorage.getItem('ym_pwa_hint_off')==='1')return '';}catch(e){}return `<div class="pwa-hint" style="position:relative">${wxInAppGuide()}<button type="button" class="btn ghost tapext" style="position:absolute;top:4px;right:6px;padding:2px 8px;font-size:12px;opacity:.7;line-height:1.2" onclick="pwaHintDismiss(this)" title="不再提示" aria-label="不再提示">✕</button></div>`;}catch(e){return '';}}
function dealerFollowupsHTML(d){let rec=[];DB.customers.filter(function(c){return c.dealerId===d.id||c.serviceStaffId===d.id||dealerSvcTeacher(d,c);}).forEach(c=>{(c.followups||[]).forEach(f=>rec.push({...f,customer:c}));});rec.sort((a,b)=>new Date(b.date)-new Date(a.date));const html=rec.length?rec.map(it=>`<div class="tl-item" data-search="${(it.content+' '+(it.operator||'')+' '+(it.method||'')+' '+it.customer.name).replace(/"/g,'')}">
<div class="t">${it.date} · ${custNameText(it.customer)}</div>
<div class="c">${it.content}</div>
<div class="op">跟进人:${it.operator||'—'} | 方式:${it.method||'—'}${it.source==='wechat'?' | <span class="pill" style="background:#e0e7ff;color:#4338ca">📱 微信导入</span>':''}</div>
</div>`).join(''):'<div class="empty">暂无跟进记录</div>';return`
<div class="topbar"><div><h1>我的跟进</h1><p>上传微信聊天记录,自动识别客户并计入跟进</p></div><button class="btn" onclick="openWechatUpload()">📤 上传微信聊天记录</button></div>${wxInAppGuide()}
<div class="card"><div class="filter-row" style="margin-bottom:12px">
<div class="field"><label>关键词查询</label><input id="dpf_q" placeholder="搜索内容/跟进人/方式…" value="${esc(dpfFilter.q)}" oninput="dp_filter(this.value)" oncompositionend="dp_filter(this.value)"></div>
</div><div class="timeline" id="dpf_list">${html}<div class="empty" id="dpf_empty" style="display:none">无匹配记录</div></div></div>`;}
let dpfFilter={q:''};function dp_filter(v){dpfFilter.q=v||'';const q=(v||'').trim().toLowerCase();const items=document.querySelectorAll('#dpf_list .tl-item');let vis=0;items.forEach(el=>{const hit=!q||(el.getAttribute('data-search')||'').toLowerCase().includes(q);if(hit)vis++;el.style.display=hit?'':'none';});const e=document.getElementById('dpf_empty');if(e)e.style.display=(items.length&&vis===0&&q)?'':'none';}
let wechatFiles=[];let wechatParsed=null;
function openWechatUpload(){wechatFiles=[];wechatParsed=null;openModal('<div class="modal-head"><h3>📤 上传聊天记录</h3><span class="x" onclick="closeModal()">×</span></div><div class="modal-body"><div class="warn-box">支持 <b>txt / docx / pdf / 图片截图</b>。可直接<b>点选</b>、<b>拖入</b>或<b>粘贴</b>(Ctrl+V)聊天内容与截图。系统自动识别客户、提取姓名/手机/邮箱/地址等联系信息。</div>'+wxUsageBoxHTML()+'<div class="field"><label>① 选择文件 <span class="hint">(多选,txt/docx/pdf/图片,单个 ≤ 10MB)</span></label><input type="file" id="wc_file" multiple accept=".txt,.text,.csv,.log,.docx,.pdf,image/*" onchange="wechatHandleFiles(this)" style="padding:10px;border:1px dashed var(--ring);border-radius:8px;background:var(--soft);cursor:pointer;width:100%"></div><div id="wc_dropzone" class="mtxt" style="border:1px dashed var(--line);border-radius:8px;padding:18px;text-align:center;color:var(--muted);margin-bottom:10px;background:#fafbff;transition:all 150ms">② 拖入文件 / <b>Ctrl+V 粘贴</b> 聊天文字与截图 →</div><div class="field" style="margin-bottom:6px"><label>或直接粘贴聊天文字 <span class="hint">(点击下方框后 Ctrl+V)</span></label><textarea id="wc_paste" rows="3" placeholder="把微信聊天内容复制到这里……" oninput="wechatHandlePasteText(this.value)" style="width:100%;padding:10px;border:1px solid var(--line);border-radius:8px;font-size:13px;font-family:inherit;resize:vertical"></textarea></div><div id="wc_list" class="mtxt" style="margin-bottom:12px;line-height:1.8;min-height:8px"><div class="empty" style="padding:18px 0;font-size:12.5px">尚未添加文件</div></div>'+wechatPickFieldHTML()+'<button class="btn" style="width:100%" onclick="wechatParse()" id="wc_parse_btn">🔍 识别客户与联系信息</button><div id="wc_result" style="margin-top:14px"></div></div>',{onOpen:wechatSetupPaste,onClose:wechatTeardownPaste});}
function wechatOnPasteInput(){var ta=document.getElementById('wc_paste');wechatHandlePasteText(ta?String(ta.value||''):'');}
function wechatSetupPaste(){wechatPickSrv=[];const dz=document.getElementById('wc_dropzone');if(dz){wechat_dz=dz;dz.addEventListener('dragover',wechatOnDragOver);dz.addEventListener('dragleave',wechatOnDragLeave);dz.addEventListener('drop',wechatOnDrop);}const ta=document.getElementById('wc_paste');if(ta&&!ta._wcPasteBound){ta.addEventListener('paste',wechatOnTextareaPaste);ta._wcPasteBound=true;}if(ta&&!ta._wcPasteIn){ta.addEventListener('input',wechatOnPasteInput);ta._wcPasteIn=true;}document.addEventListener('paste',wechatOnDocPaste);wechat_pasteAttached=true;}
function wechatTeardownPaste(){if(wechat_dz){wechat_dz.removeEventListener('dragover',wechatOnDragOver);wechat_dz.removeEventListener('dragleave',wechatOnDragLeave);wechat_dz.removeEventListener('drop',wechatOnDrop);wechat_dz=null;}const ta=document.getElementById('wc_paste');if(ta&&ta._wcPasteBound){ta.removeEventListener('paste',wechatOnTextareaPaste);ta._wcPasteBound=false;}if(ta&&ta._wcPasteIn){ta.removeEventListener('input',wechatOnPasteInput);ta._wcPasteIn=false;}if(wechat_pasteAttached){document.removeEventListener('paste',wechatOnDocPaste);wechat_pasteAttached=false;}}
function wechatOnDragOver(e){e.preventDefault();if(wechat_dz){wechat_dz.style.borderColor='var(--primary)';wechat_dz.style.background='var(--primary-soft)';wechat_dz.style.color='var(--primary)';}}
function wechatOnDragLeave(e){e.preventDefault();if(wechat_dz){wechat_dz.style.borderColor='var(--line)';wechat_dz.style.background='#fafbff';wechat_dz.style.color='var(--muted)';}}
function wechatOnDrop(e){e.preventDefault();if(wechat_dz){wechat_dz.style.borderColor='var(--line)';wechat_dz.style.background='#fafbff';wechat_dz.style.color='var(--muted)';}const files=e.dataTransfer&&e.dataTransfer.files;if(files&&files.length){const inp=document.getElementById('wc_file');if(inp){try{inp.files=files;}catch(err){}wechatHandleFiles(inp);}}}
function wechatOnTextareaPaste(e){const items=e.clipboardData&&e.clipboardData.items;if(!items)return;let hasImage=false;for(const it of items){if(it.kind==='file'&&it.type.indexOf('image/')===0){const f=it.getAsFile();if(f){const name='粘贴截图-'+new Date().toLocaleTimeString('zh-CN',{hour12:false}).replace(/:/g,'')+'.png';wechatHandleFiles({files:[new File([f],name,{type:f.type||'image/png'})]});hasImage=true;}}}if(hasImage)e.preventDefault();}
function wechatOnDocPaste(e){const modal=document.querySelector('.modal-bg.show');if(!modal)return;const ta=document.getElementById('wc_paste');if(ta&&document.activeElement===ta)return;const items=e.clipboardData&&e.clipboardData.items;if(!items)return;let hasImage=false;for(const it of items){if(it.kind==='file'){const f=it.getAsFile();if(f){wechatHandleFiles({files:[f]});hasImage=true;}}else if(it.kind==='string'&&it.type==='text/plain'){it.getAsString(function(s){if(s&&s.trim()){if(ta){const sep=ta.value&&ta.value.trim()?'\n':'';ta.value+=sep+s;wechatHandlePasteText(ta.value);}}});}}if(hasImage)e.preventDefault();}
function wechatDropPasteItem(){var had=false;for(var _i=0;_i<wechatFiles.length;_i++){if(wechatFiles[_i].isPaste)had=true;}if(!had)return;wechatFiles=wechatFiles.filter(function(x){return!x.isPaste;});wechatRenderFiles();}
function wechatHandlePasteText(v){if(!v||!v.trim()){wechatDropPasteItem();wechatPickSrv=[];wechatPickRefresh();return;}wechatFiles=wechatFiles.filter(function(x){return!x.isPaste;});const item={id:'wc-paste-'+Date.now(),name:'粘贴的聊天内容.txt',type:'text/plain',kind:'txt',size:v.length,status:'ok',progress:100,text:v,isPaste:true};wechatFiles.push(item);wechatRenderFiles();wechatPickSrv=[];wechatPickRefresh();}
function wechatHandleFiles(input){const files=input.files;if(!files||!files.length)return;for(const f of files){const ext=(f.name.split('.').pop()||'').toLowerCase();const isImg=f.type&&f.type.indexOf('image/')===0;const isTxt=/^(txt|text|csv|log|md)$/.test(ext)||(f.type&&f.type.indexOf('text/')===0);const isDocx=ext==='docx';const isPdf=ext==='pdf';const maxSize=10*1024*1024;const id='wc-'+Date.now()+'-'+Math.random().toString(36).slice(2,8);const item={id:id,name:f.name,type:f.type||'',kind:'',size:f.size,status:'pending',progress:0,error:''};if(isImg){item.kind='image';}else if(isTxt){item.kind='txt';}else if(isDocx){item.kind='docx';}else if(isPdf){item.kind='pdf';}else{item.status='error';item.error='不支持的格式(请用 txt/docx/pdf/图片)';}if(f.size>maxSize){item.status='error';item.error='文件过大(> 10MB)';}wechatFiles.push(item);wechatRenderFiles();if(item.status==='error')continue;const reader=new FileReader();reader.onprogress=function(e){if(e.lengthComputable){item.progress=Math.round(e.loaded*100/e.total*0.5);wechatRenderFiles();}};reader.onload=function(){item.progress=50;item.status='parsing';wechatRenderFiles();if(isImg){item.dataUrl=reader.result;item.progress=100;item.status='ok';wechatRenderFiles();}else{const text=String(reader.result||'');item.text=text;item.progress=100;item.status='ok';wechatRenderFiles();}};reader.onerror=function(){item.status='error';item.error='读取失败';wechatRenderFiles();};if(isImg)reader.readAsDataURL(f);else reader.readAsText(f,'UTF-8');}}
function wechatRenderFiles(){if(typeof wechatPickRefresh==='function')wechatPickRefresh();const list=document.getElementById('wc_list');if(!list)return;if(!wechatFiles.length){list.innerHTML='<div class="empty" style="padding:18px 0;font-size:12.5px">尚未添加文件</div>';return;}const iconMap={txt:'📄',image:'🖼️',docx:'📝',pdf:'📕'};const statusBadge={pending:'<span style="color:#b45309">⏳ 等待中</span>',parsing:'<span style="color:#0891b2">⚙️ 解析中...</span>',ok:'<span style="color:#16a34a">✅ 就绪</span>',error:'<span style="color:#dc2626">❌ 错误</span>'};list.innerHTML=wechatFiles.map(function(x,i){const icon=iconMap[x.kind]||'📎';const size=x.size>1024?(x.size/1024>1024?(x.size/1024/1024).toFixed(1)+'MB':(x.size/1024).toFixed(0)+'KB'):x.size+'B';const pb=x.status==='pending'||x.status==='parsing'?'<div style="height:3px;background:#eef0f7;border-radius:2px;margin-top:4px;overflow:hidden"><div style="width:'+(x.progress||0)+'%;height:100%;background:var(--primary);transition:width 200ms"></div></div>':'';return '<div style="display:flex;align-items:center;gap:8px;padding:6px 8px;border:1px solid var(--line);border-radius:6px;margin-bottom:4px;background:'+(x.status==='error'?'#fef2f2':'#fff')+'"><div style="font-size:18px;line-height:1">'+icon+'</div><div style="flex:1;min-width:0"><div style="display:flex;align-items:center;gap:6px"><div style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:13px">'+esc(x.name)+'</div><div style="font-size:11px;color:var(--muted)">'+size+'</div><div style="font-size:11.5px">'+statusBadge[x.status]+'</div><div style="cursor:pointer;color:#dc2626;padding:0 4px;font-size:16px;line-height:1" onclick="wechatRemoveFile('+i+')">✕</div></div>'+(x.error?'<div style="color:#dc2626;font-size:11.5px;margin-top:2px">'+esc(x.error)+'</div>':pb)+'</div></div>';}).join('');}
function wechatRemoveFile(i){wechatFiles.splice(i,1);wechatRenderFiles();const res=document.getElementById('wc_result');if(res)res.innerHTML='';}
function wechatParse(){if(!wechatFiles.length){toast('请先选择聊天记录文件');return;}const pending=wechatFiles.filter(function(x){return x.status==='pending'||x.status==='parsing';});if(pending.length){toast('还有 '+(pending.length)+' 个文件正在处理中,请稍候');return;}const errs=wechatFiles.filter(function(x){return x.status==='error';});if(errs.length===wechatFiles.length){toast('所有文件解析失败,请检查文件');return;}const btn=document.getElementById('wc_parse_btn');if(btn){btn.disabled=true;btn.textContent='⏳ 识别中...';}// 转为后端接受的格式:image 转 dataUrl,docx/pdf 转 dataUrl+kind,txt 用 text
const files=[];for(const f of wechatFiles){if(f.status!=='ok')continue;if(f.kind==='image')files.push({name:f.name,type:f.type,dataUrl:f.dataUrl});else if(f.kind==='txt')files.push({name:f.name,type:f.type,text:f.text||''});else if(f.kind==='docx'||f.kind==='pdf'){if(f.dataUrl)files.push({name:f.name,type:f.type,dataUrl:f.dataUrl,kind:f.kind});}else if(f.dataUrl)files.push({name:f.name,type:f.type,dataUrl:f.dataUrl});}if(!files.length){toast('无可识别的文件内容');if(btn){btn.disabled=false;btn.textContent='🔍 识别客户与联系信息';}return;}apiPost('/api/wechat-parse',{files:files,dealerId:((typeof curDealerId!=='undefined'&&curDealerId)?curDealerId:''),pickCustomerId:(function(){var s=document.getElementById('wc_pick_customer');return s?s.value:'';})()}).then(function(d){if(btn){btn.disabled=false;btn.textContent='🔍 识别客户与联系信息';}if(!d){toast('解析失败:服务无响应');return;}wechatParsed=d.parsed||null;wechatRenderResult(d);wechatScrollResult();if(!d.ok)toast(d.error||'未能自动识别 —— 请在上方「指定客户」选定客户(选定即生效)');}).catch(function(e){if(btn){btn.disabled=false;btn.textContent='🔍 识别客户与联系信息';}toast('解析失败:'+(e&&e.message||''));});}
function wechatScopeCustomers(){var did=(typeof curDealerId!=='undefined'&&curDealerId)?curDealerId:'';if(!did)return [];var d=(DB.dealers||[]).find(function(x){return x.id===did;});return (DB.customers||[]).filter(function(c){return c.dealerId===did||c.serviceStaffId===did||dealerSvcTeacher(d,c);});}
var wechatPickSrv=[];
function wechatPickSel(){var e=document.getElementById('wc_pick_customer');return e?String(e.value||''):'';}
function wechatPickNameById(id){var list=wechatScopeCustomers();for(var i=0;i<list.length;i++){if(list[i].id===id)return String(list[i].name||'未命名');}return '';}
function wechatPickLocalText(){var out='';var tv='';try{var ta=document.getElementById('wc_paste');if(ta&&ta.value)tv=String(ta.value);}catch(e){}out+=tv+' ';for(var i=0;i<wechatFiles.length;i++){var f=wechatFiles[i];if(f&&f.kind==='txt'&&f.text){var s=String(f.text);if(f.isPaste&&s===tv)continue;out+=s+' ';}}return out;}
function wechatPickMatchKW(c,k){if(!k)return 9;var nm=String(c.name||'').toLowerCase();var sch=String(c.school||'').toLowerCase();var ph=String(c.phone||'').replace(/[^0-9]/g,'');var al=(Array.isArray(c.aliases)?c.aliases.join(' '):'').toLowerCase();if(nm===k)return 0;if(nm.indexOf(k)===0)return 1;if(nm.indexOf(k)>=0)return 2;if(sch&&sch.indexOf(k)>=0)return 3;if(ph&&ph.indexOf(k)>=0)return 4;if(al&&al.indexOf(k)>=0)return 5;return -1;}
function wechatPickTokens(text){var t=String(text==null?'':text).replace(/\s+/g,' ').trim();if(!t)return [];var segs=t.split(/[^0-9A-Za-z\u4e00-\u9fff]+/);var out=[];for(var i=0;i<segs.length;i++){var s=segs[i];if(s&&out.indexOf(s)<0)out.push(s);}if(!out.length)out.push(t);return out;}
function wechatPickBestKws(text,cap){var toks=wechatPickTokens(text);var used=[];var mx=cap||4;for(var i=0;i<toks.length&&used.length<mx;i++){var s=toks[i];var isNum=/^[0-9]+$/.test(s);var min=isNum?3:1;var got='';for(var k=s.length;k>=min;k--){var w=s.slice(0,k);if(wechatPickMatches(w).length){got=w;break;}}if(!got&&!isNum&&s.length>1){for(var q=0;q<s.length&&used.length<mx;q++){var ch=s.charAt(q);if(wechatPickMatches(ch).length&&used.indexOf(ch)<0)used.push(ch);}}else if(got&&used.indexOf(got)<0){used.push(got);}}return used;}
function wechatPickKwArr(kw){if(Array.isArray(kw)){var a=[];for(var i=0;i<kw.length;i++){var s=String(kw[i]==null?'':kw[i]).trim();if(s&&a.indexOf(s)<0)a.push(s);}return a;}var one=String(kw==null?'':kw).trim();return one?[one]:[];}
function wechatPickPreIn(s,t,min){var x=String(s||'');var n=Math.min(x.length,6);for(var k=n;k>=min;k--){if(t.indexOf(x.slice(0,k))>=0)return k;}return 0;}
function wechatPickMatches(kw){var ks=wechatPickKwArr(kw);var list=wechatScopeCustomers();var out=[];for(var i=0;i<list.length;i++){if(!ks.length){out.push({c:list[i],rank:9,why:'',len:0});continue;}var best=-1;for(var j=0;j<ks.length;j++){var r=wechatPickMatchKW(list[i],ks[j].toLowerCase());if(r>=0&&(best<0||r<best))best=r;}if(best>=0)out.push({c:list[i],rank:best,why:'',len:0});}out.sort(function(a,b){if(a.rank!==b.rank)return a.rank-b.rank;return String(a.c.name||'').localeCompare(String(b.c.name||''),'zh');});return out;}
function wechatPickMentions(text){var t=String(text||'');var list=wechatScopeCustomers();if(!t.trim()||!list.length)return [];var scList=[];for(var i=0;i<list.length;i++){var z=String(list[i].school||'');if(z.length>=3)scList.push({s:z,id:list[i].id});}var dg=t.replace(/[^0-9]/g,'');var out=[];for(var j=0;j<list.length;j++){var c=list[j];var hit=null;var nm=String(c.name||'');var nl=wechatPickPreIn(nm,t,2);if(nl){hit=(nl>=nm.length)?{rank:0,why:'聊天内容提到姓名',len:nl}:{rank:1,why:'聊天内容含姓名「'+nm.slice(0,nl)+'」',len:nl};}if(!hit){var al=(Array.isArray(c.aliases)?c.aliases:[]);var bl=0;var bw='';for(var k=0;k<al.length;k++){var a=String(al[k]||'');var gl=wechatPickPreIn(a,t,2);if(gl>bl){bl=gl;bw=a;}}if(bl){hit=(bl>=bw.length)?{rank:2,why:'聊天内容提到别名',len:bl}:{rank:3,why:'聊天内容含别名「'+bw.slice(0,bl)+'」',len:bl};}}if(!hit){var ph=String(c.phone||'').replace(/[^0-9]/g,'');if(ph.length>=7&&dg.indexOf(ph)>=0)hit={rank:4,why:'聊天内容含手机号',len:ph.length};}if(!hit){var sc=String(c.school||'');var sl=wechatPickPreIn(sc,t,3);if(sl){var pw=sc.slice(0,sl);var own=[];for(var q=0;q<scList.length;q++){if(scList[q].s.indexOf(pw)>=0)own.push(scList[q].id);}if(own.length===1&&own[0]===c.id)hit=(sl>=sc.length)?{rank:5,why:'聊天内容提到学校',len:sl}:{rank:5,why:'聊天内容含学校「'+pw+'」',len:sl};}}if(hit)out.push({c:c,rank:hit.rank,why:hit.why,len:hit.len});}out.sort(function(a,b){if(a.rank!==b.rank)return a.rank-b.rank;if(b.len!==a.len)return b.len-a.len;return String(a.c.name||'').localeCompare(String(b.c.name||''),'zh');});return out;}
function wechatPickBase(){var srv=wechatPickSrv||[];if(srv.length){var rows=srv.map(function(x){return {c:x,why:'',len:0,_score:(x&&x._score)||0};});rows.sort(function(a,b){return b._score-a._score;});return {mode:'srv',rows:rows.slice(0,40)};}var lt=wechatPickLocalText();var mt=wechatPickMentions(lt);if(mt.length)return {mode:'text',rows:mt.slice(0,40)};if(String(lt).replace(/[^0-9A-Za-z\u4e00-\u9fff]/g,'').length>=10)return {mode:'textnone',rows:wechatPickMatches('').slice(0,12)};return {mode:'all',rows:wechatPickMatches('').slice(0,12)};}
function wechatPickHeadText(mode,n){if(mode==='srv')return '按识别结果给出 '+n+' 个候选(★ 与聊天内容相关),点一下即选定';if(mode==='text')return '已从聊天内容里匹配出 '+n+' 家客户,请点选正确的这家';if(mode==='textnone')return '已读入聊天内容,但没匹配到本店客户 —— 请在下方输入姓名 / 学校 / 手机号缩小范围(下列为本店前 12 家)';return '本店共 '+wechatScopeCustomers().length+' 家客户,下方为前 12 家;也可直接输入姓名 / 学校 / 手机号缩小范围';}
function wechatPickListHTML(kw,selId){var sel=(selId===undefined)?wechatPickSel():String(selId||'');var ks=wechatPickKwArr(kw);var label=ks.join(' / ');var rows=[];var head='';if(ks.length){rows=wechatPickMatches(ks);if(!rows.length&&!Array.isArray(kw)){var used=wechatPickBestKws(label,4);if(used.length){var r2=wechatPickMatches(used);if(r2.length){rows=r2;label=used.join(' / ');}}}if(!rows.length)return '<div class="hint" style="padding:8px 10px;color:#b45309">没有匹配「'+esc(label)+'」的客户 —— 换姓名 / 学校 / 手机号试试,或清空看聊天内容的匹配结果</div>';var tot=rows.length;if(tot>60)rows=rows.slice(0,60);head='匹配「'+esc(label)+'」:'+tot+' 家'+(tot>60?'(列出前 60 家,可继续输入缩小范围)':'');}else{var b=wechatPickBase();rows=b.rows;head=wechatPickHeadText(b.mode,rows.length);}var html='<div class="hint" style="padding:6px 10px;border-bottom:1px solid var(--line)">'+head+'</div>';html+=rows.map(function(x){var c=x.c;var on=(c.id===sel);var tag=x.why?('<span style="color:#7c3aed;font-size:11px;margin-left:4px">'+esc(x.why)+'</span>'):(((x._score||0)>0)?'<span style="color:#7c3aed;font-size:11px;margin-left:4px">★ 相关</span>':'');return '<div onclick="wechatPickSelect(\''+c.id+'\')" style="padding:8px 10px;cursor:pointer;font-size:13px;display:flex;align-items:center;gap:6px;border-bottom:1px solid #f3f4f6;'+(on?'background:#eef2ff;font-weight:600':'')+'"><span style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">'+esc(c.name||'未命名')+(c.school?('<span style="color:var(--muted);font-size:12px"> · '+esc(c.school)+'</span>'):'')+tag+'</span>'+(on?'<span style="color:#4f46e5;font-size:12px">✓ 已选定</span>':'<span style="color:#9ca3af;font-size:12px">选择</span>')+'</div>';}).join('');return html;}
function wechatPickNoScopeHTML(){return '<div class="field" style="margin-bottom:10px"><label>指定客户 <span class="hint">(可选 · 留空 = 自动识别)</span></label><div class="hint" style="padding:8px 10px;border:1px dashed var(--line);border-radius:8px;color:#b45309;line-height:1.7">当前未选择经销商,暂无可指定客户 —— 请先在顶部选定经销商;留空则走自动识别。</div></div>';}
function wechatPickFieldHTML(){var n=wechatScopeCustomers().length;if(!n)return wechatPickNoScopeHTML();return '<div class="field" style="margin-bottom:10px"><label>指定客户 <span class="hint">(可选 · 留空 = 自动识别)</span></label><input id="wc_pick_q" autocomplete="off" placeholder="输入姓名 / 学校 / 手机号 实时筛出客户(本店 '+n+' 家)" oninput="wechatPickQuery(this.value)" onkeydown="wechatPickKey(event)" style="width:100%;padding:9px 10px;border:1px solid var(--line);border-radius:8px;font-size:13px;font-family:inherit;box-sizing:border-box"><input type="hidden" id="wc_pick_customer" value=""><div id="wc_pick_list" style="margin-top:6px;max-height:210px;overflow-y:auto;border:1px solid var(--line);border-radius:8px">'+wechatPickListHTML('','')+'</div><div class="hint" id="wc_pick_hint" style="margin-top:4px">'+wechatPickHintTail()+'</div></div>';}
function wechatScrollTopFor(el,box,headH){try{var cy=box.scrollTop+(el.getBoundingClientRect().top-box.getBoundingClientRect().top);return Math.max(0,cy-((headH||0)+8));}catch(e){return 0;}}
function wechatScrollResult(){setTimeout(function(){try{var el=document.getElementById('wc_result');if(!el)return;var box=el.closest?el.closest('.modal'):null;if(box){var head=box.querySelector('.modal-head');box.scrollTop=wechatScrollTopFor(el,box,head?head.offsetHeight:0);return;}if(el.scrollIntoView)el.scrollIntoView({block:'start'});}catch(e){try{var e2=document.getElementById('wc_result');if(e2&&e2.scrollIntoView)e2.scrollIntoView(true);}catch(_){}}},40);}
function wechatPickAutoKW(){try{var ta=document.getElementById('wc_paste');var t=ta?String(ta.value||''):'';t=t.trim();if(!t)return '';if(t.length>12)return '';if(t.indexOf('\n')>=0)return '';var used=wechatPickBestKws(t,4);if(used.length)return used;}catch(e){}return '';}
function wechatPickRefresh(){var box=document.getElementById('wc_pick_list');if(!box)return;var qi=document.getElementById('wc_pick_q');var kw=qi?String(qi.value||''):'';var nm=wechatPickNameById(wechatPickSel());if(kw.trim()&&nm&&kw===nm)kw='';if(!String(kw).trim())kw=wechatPickAutoKW();box.innerHTML=wechatPickListHTML(kw);wechatPickHint();}
function wechatPickQuery(v){var box=document.getElementById('wc_pick_list');if(box)box.innerHTML=wechatPickListHTML(String(v||''));wechatPickHint();}
function wechatPickSelect(id){var e=document.getElementById('wc_pick_customer');if(e)e.value=String(id||'');var nm=wechatPickNameById(id);var q=document.getElementById('wc_pick_q');if(q&&nm)q.value=nm;var box=document.getElementById('wc_pick_list');if(box)box.innerHTML=wechatPickListHTML(nm||'',String(id||''));wechatPickHint();wechatPickChanged();}
function wechatPickKey(ev){try{if(!ev)return;if(ev.key==='Enter'){if(ev.preventDefault)ev.preventDefault();var box=document.getElementById('wc_pick_list');var first='';if(box){var kids=box.children||[];for(var i=0;i<kids.length;i++){var el=kids[i];var oc=String((el&&el.getAttribute?el.getAttribute('onclick'):'')||'');if(oc.indexOf('wechatPickSelect')>=0){var mm=oc.match(/wechatPickSelect\('([^']+)'\)/);if(mm&&mm[1]){first=mm[1];break;}}}}if(first){wechatPickSelect(first);return;}var qi=document.getElementById('wc_pick_q');var rows=wechatPickMatches(qi?String(qi.value||''):'');if(rows.length)wechatPickSelect(rows[0].c.id);}else if(ev.key==='Escape'){var b2=document.getElementById('wc_pick_list');if(b2)b2.innerHTML=wechatPickListHTML('');}}catch(e){}}
function wechatPickHintTail(){return '在下方框输入姓名/学校/手机号,或在上方「粘贴聊天文字」框里输入短关键词,都会<b>实时筛出客户</b>;粘贴长文则<b>按内容自动匹配出客户</b>。<b>只要输入里有一个字或一段数字能对上就会出候选</b>:输入「孙」「孙老师」都能筛出姓孙的客户,「13800138000」能筛出对应客户。点选后<b>立即按该客户识别,并带出「✔ 确认计入跟进」</b>,无需再点识别按钮。留空 = 自动识别。';}
function wechatPickHint(){var h=document.getElementById('wc_pick_hint');if(!h)return;var id=wechatPickSel();var tail=wechatPickHintTail();if(!id){h.innerHTML=tail;return;}var list=wechatScopeCustomers();var c=null;for(var i=0;i<list.length;i++){if(list[i].id===id)c=list[i];}var nm=c?(String(c.name||'未命名')+(c.school?('('+c.school+')'):'')):'';h.innerHTML='已选定:<b>'+esc(nm)+'</b> —— 已按该客户识别;改选直接点上方列表。'+tail;}
function wechatPickSetServer(cands){wechatPickSrv=(Array.isArray(cands)?cands:[]).slice();}
function wechatPickChanged(){var id=wechatPickSel();var nm=wechatPickNameById(id);if(!wechatFiles.length){toast(nm?('已选定「'+nm+'」—— 请先粘贴或选择聊天记录文件'):'请先粘贴或选择聊天记录文件');return;}wechatParse();}
function wechatSortedCands(match){var c=((match&&match.candidates)||[]).slice();c.sort(function(a,b){return (b._score||0)-(a._score||0);});return c;}
function wechatCandOptions(match,preselectId){return wechatSortedCands(match).map(function(c){var tag=c._score>0?'★ ':'';return '<option value="'+c.id+'"'+(preselectId&&c.id===preselectId?' selected':'')+(c._score>0?' style="font-weight:600"':'')+'>'+tag+esc(c.name)+(c.school?(' · '+esc(c.school)):'')+(c.phone?(' · '+c.phone):'')+(c.aliases&&c.aliases.length?(' · ['+esc(c.aliases.join('、'))+']'):'')+'</option>';}).join('');}
function wechatToggleRepick(){var b=document.getElementById('wc_repick_box');var t=document.getElementById('wc_repick_toggle');if(!b)return;var open=(b.style.display==='none');b.style.display=open?'':'none';if(t)t.textContent=open?'不是这家?收起改选 ▴':'不是这家?改选客户 ▾';}
function wechatRenderResult(d){const p=d.parsed||{};const match=d.match||{};const res=document.getElementById('wc_result');if(!res)return;wechatPickSetServer(d&&d.match?d.match.candidates:[]);wechatPickRefresh();const errCount=(p.parseErrors||[]).length;let html='<div style="border-top:1px solid var(--line);padding-top:14px;margin-top:6px">';if(d&&d.ok===false){html+='<div class="warn-box" style="background:#fff7ed;border-color:#fdba74;color:#9a3412;margin-bottom:10px"><b>⚠️ 自动识别未完成</b><div style="margin-top:6px">'+esc(d.error||'未能从文件中识别出有效内容')+'</div><div style="margin-top:6px">你仍可从下方下拉框<b>直接选择客户</b>,把这段聊天内容计入该客户的跟进记录。</div></div>';}if(match.customer){const c=match.customer;html+='<div class="warn-box" style="background:#f0fdf4;border-color:#86efac;color:#166534">✅ 已识别客户:<b>'+esc(c.name)+'</b>'+(c.school?('('+esc(c.school)+')'):'')+' | 匹配方式:'+esc(match.matchBy)+(match.matchReason?(' | '+esc(match.matchReason)):'')+'</div>';html+='<div class="mtxt" style="margin-bottom:10px">将计入该客户跟进记录,来源标注「微信聊天记录上传」。</div>';html+='<button class="btn" style="width:100%" onclick="wechatImport(\''+c.id+'\')">✔ 确认计入跟进</button>';html+='<div style="margin-top:10px"><button class="btn ghost" style="width:100%;font-size:13px" onclick="wechatToggleRepick()" id="wc_repick_toggle">不是这家?改选客户 ▾</button></div>';html+='<div id="wc_repick_box" style="display:none;margin-top:8px"><div class="field"><label>改选客户(本经销商 '+esc(String((match.candidates||[]).length))+' 个,★ 为与聊天内容相关)</label><select id="wc_customer">'+wechatCandOptions(match,c.id)+'</select></div><div style="display:flex;gap:8px"><button class="btn" style="flex:1" onclick="wechatImportFromSelect()">✔ 确认计入所选客户</button><button class="btn ghost" onclick="wechatSetAliasPrompt()">📝 设置别名</button></div></div>';}else{const cands=match.candidates||[];const sorted=cands.slice().sort(function(a,b){return(b._score||0)-(a._score||0);});const topCount=Math.min(5,sorted.length);const top=sorted.slice(0,topCount);const rest=sorted.slice(topCount);html+='<div class="warn-box">⚠️ 未能自动识别客户 —— 请在下方<b>下拉框选择客户</b>,再点「确认计入跟进」。</div>';if(match.ambiguous&&match.ambiguous.length){html+='<div class="warn-box" style="background:#fffbeb;border-color:#fcd34d;color:#92400e">⚠️ 正文中提到「'+esc(match.ambiguous[0].key)+'」,对应 '+match.ambiguous.length+' 个客户:<b>'+esc(match.ambiguous.map(function(x){return x.name;}).join('、'))+'</b>。请先在下方确认是哪一位,再计入跟进。</div>';}if(match.chatKeys&&match.chatKeys.length){html+='<div class="mtxt" style="margin-bottom:8px;color:var(--muted)">💡 聊天中提到:<b>'+esc(match.chatKeys.join('、'))+'</b>。已按相关性排序,<b>★ 推荐</b>的可能是该客户的常用称呼。</div>';}// 智能推荐卡片(前 3 个有 _score > 0 的)
const recs=top.filter(function(c){return c._score>0;}).slice(0,3);if(recs.length){html+='<div style="display:flex;flex-wrap:wrap;gap:6px;margin-bottom:10px">';recs.forEach(function(c){const reason=(c._reasons||[]).slice(0,2).join(',')||'与聊天内容相关';html+='<div style="flex:1;min-width:140px;padding:8px 10px;border:1.5px solid var(--primary);border-radius:8px;background:var(--primary-soft);cursor:pointer" onclick="wechatQuickPick(\''+c.id+'\')"><div style="font-size:11px;color:var(--primary);font-weight:700">★ 推荐</div><div style="font-weight:600;font-size:13px;color:var(--ink);margin-top:2px">'+esc(c.name)+'</div><div style="font-size:11px;color:var(--muted);margin-top:2px">'+esc(reason)+'</div></div>';});html+='</div>';}const opts=wechatCandOptions(match,null);html+='<div class="field"><label>或从全部客户选择('+(sorted.length)+' 个,已按相关性排序)</label><select id="wc_customer">'+(opts||'<option value="">— 无客户可选 —</option>')+'</select></div>';html+='<div style="display:flex;gap:8px"><button class="btn" style="flex:1" onclick="wechatImportFromSelect()">✔ 确认计入跟进</button><button class="btn ghost" onclick="wechatSetAliasPrompt()">📝 设置别名</button></div><div class="mtxt" style="color:var(--muted);margin-top:6px">提示:如「<b>盼盼老师</b>」是某客户的常用称呼,可在「我的客户」档案里添加别名「<b>盼盼</b>」「<b>盼盼老师</b>」,以后系统将自动匹配。</div>';}// 联系信息汇总
html+='<div style="margin-top:14px;padding:12px;background:var(--soft);border-radius:8px"><div style="font-weight:600;font-size:13px;margin-bottom:8px;color:var(--ink)">📇 提取的联系信息</div>';const hasInfo=p.phones&&p.phones.length||p.emails&&p.emails.length||p.addresses&&p.addresses.length||p.speakers&&p.speakers.length;if(!hasInfo){html+='<div class="mtxt">未提取到联系信息</div>';}else{if(p.phones&&p.phones.length)html+='<div class="mtxt">📱 手机号:'+esc(p.phones.join('、'))+'</div>';if(p.emails&&p.emails.length)html+='<div class="mtxt">📧 邮箱:'+esc(p.emails.join('、'))+'</div>';if(p.addresses&&p.addresses.length)html+='<div class="mtxt">📍 地址:'+esc(p.addresses.slice(0,3).join(';'))+(p.addresses.length>3?' 等 '+(p.addresses.length)+' 处':'')+'</div>';if(p.speakers&&p.speakers.length)html+='<div class="mtxt">👤 对话人:'+esc(p.speakers.join('、'))+'</div>';}html+='</div>';if(errCount)html+='<div class="mtxt" style="color:#b45309;margin-top:8px">⚠️ '+errCount+' 个文件解析失败,已跳过</div>';html+='<div class="mtxt" style="background:#fafbff;padding:10px;border-radius:6px;margin-top:8px;max-height:140px;overflow:auto;white-space:pre-wrap;font-size:12px">'+esc((p.content||'').slice(0,500))+'</div>';html+='</div>';res.innerHTML=html;}
function wechatImport(customerId){var _raw='';try{var _ta=document.getElementById('wc_paste');_raw=_ta?_ta.value:'';}catch(e){}apiPost('/api/wechat-import',{customerId:customerId,content:((wechatParsed&&wechatParsed.content)||_raw||'')}).then(function(){closeModal();toast('已计入客户跟进记录');render();}).catch(function(e){toast('导入失败:'+(e&&e.message||''));});}
function wechatImportFromSelect(){const sel=document.getElementById('wc_customer');const id=sel?sel.value:'';if(!id){toast('请选择客户');return;}wechatImport(id);}
function wechatQuickPick(id){if(!id){toast('请选择客户');return;}wechatImport(id);}
function wechatSetAliasPrompt(){const sel=document.getElementById('wc_customer');const id=sel&&sel.value;if(!id){toast('请先在下拉框中选择一个客户');return;}const c=(DB.customers||[]).find(function(x){return x.id===id;});if(!c){toast('客户不存在');return;}const currentAliases=(c.aliases||[]).join('、');openModal('<div class="modal-head"><h3>📝 设置客户别名</h3><span class="x" onclick="closeModal()">×</span></div><div class="modal-body"><div class="warn-box">为「<b>'+esc(c.name)+'</b>'+(c.school?('('+esc(c.school)+')'):'')+'」设置别名后,下次上传聊天记录时若聊天中提到这些别名,系统将<b>自动匹配该客户</b>(零误判)。</div><div class="field"><label>别名(每行一个,最多 20 个)<span class="hint">(如「盼盼」、「盼盼老师」、「P老师」)</span></label><textarea id="aliases_input" rows="4" placeholder="每行一个别名">'+esc(currentAliases)+'</textarea><div class="hint">现有别名已预填,可编辑/删除/新增。</div></div></div><div class="modal-foot"><button class="btn ghost" onclick="closeModal()">取消</button><button class="btn" onclick="wechatSaveAliases(\''+c.id+'\')">保存别名</button></div>');}
function wechatSaveAliases(cid){const inp=document.getElementById('aliases_input');const txt=inp?inp.value:'';const arr=txt.split(/[\\n,,、]/).map(function(s){return s.trim();}).filter(Boolean);apiPost('/api/customer/aliases',{id:cid,aliases:arr}).then(function(d){if(d&&d.ok){toast('别名已保存,下次自动识别');closeModal();if(typeof render==='function')render();}else{toast((d&&d.error)||'保存失败');}}).catch(function(e){toast('保存失败:'+(e&&e.message||''));});}
function dealerBenefitsHTML(d){const cfg=DT(d.type);const s=dealerStats(d.id);const assess=assessHTMLOf(d);void(s
.signedFull>=5?'<span class="st st-signed_full">达标(≥5家)</span>':'<span class="st st-following">未达标('+s.signedFull+'/5)</span>');return`
<div class="topbar"><div><h1>我的权益</h1><p>您所签约经销商类型的权益与考核标准</p></div></div>
<div class="grid two-col">
<div class="card"><h4 style="font-size:14px;margin-bottom:12px">${cfg.name}</h4>
<div style="display:flex;gap:8px;flex-wrap:wrap;margin-bottom:12px"><span class="tag ${cfg.color}">${cfg.name.replace('经销商','')}</span>${assess}</div>
<div class="grid" style="gap:10px">
${miniStat('预缴费用', dpMoney(cfg.fee))}
${miniStat('基础分润比例', pct(cfg.baseRate*100))}
${miniStat('考核', cfg.assessment)}
</div>
<div style="margin-top:12px;font-size:12.5px;color:#475569;background:var(--soft);padding:12px;border-radius:10px">${cfg.note}</div>
</div>
<div class="card"><h4 style="font-size:14px;margin-bottom:12px">我的权益进度</h4>
<div class="grid" style="gap:10px">
${miniStat('已报备客户', s.reported)}
${miniStat('全款成交', s.signedFull)}
${miniStat('应发分润', dpMoney(s.commission))}
${miniStat('已发放', dpMoney(commissionPaid(d)))}
${miniStat('待发放', dpMoney(commissionPending(d)))}
${miniStat('已退费', s.refunded)}
</div></div>
</div>`;}
function openCustomerFollowup(cid){const c=DB.customers.find(x=>x.id===cid);if(!c)return;const d=DB.dealers.find(x=>x.id===c.dealerId);const tl=(c.followups||[]).slice().sort((a,b)=>new Date(b.date)-new Date(a.date));const _fk='c_'+cid;const tlHtml=fLevel('c_followups')==='hidden'?'<div class="empty">无权限查看跟进记录</div>':fupListHtml(tl,_fk,{empty:'暂无跟进',compact:true});openModal(`
<div class="modal-head"><h3>${custNameText(c)} · 写跟进</h3><span class="x" onclick="closeModal()">×</span></div>
<div class="modal-body fu-wide">
<div class="idbar" style="grid-template-columns:repeat(3,minmax(0,1fr));margin-bottom:12px">
<div class="idc"><div class="k">归属</div><b>${d?d.name:'—'}</b></div>
<div class="idc"><div class="k">状态</div><span class="st ${stageInfoOf(c.stage).cls}">${stageInfoOf(c.stage).label}</span></div>
<div class="idc"><div class="k">机构</div><b>${c.school||'—'}</b></div>
</div>
<div class="section-title" style="margin:4px 0 8px">历史跟进(${tl.length})</div>
<div class="timeline" data-fup-list="${_fk}" style="margin-bottom:8px">${tlHtml}</div>${fLevel('c_followups')==='hidden'?'':fupMoreHtml(tl.length,_fk)}
<div class="row3">
<div class="field"><label>跟进方式</label><select id="dcf_method"><option>微信</option><option>电话</option><option>面谈</option><option>体验课</option><option>专场会议</option><option>其他</option></select></div>
<div class="field"><label>跟进人</label><input id="dcf_op" value="${(currentUser&¤tUser.name)?currentUser.name:''}" placeholder="您的姓名"></div>
<div class="field"><label>下次跟进时间(选填)</label><input type="date" id="dcf_next"></div>
</div>
<div class="field" style="margin-top:12px"><label>跟进内容</label><textarea id="dcf_content" rows="3" placeholder="沟通进展、下一步动作…"></textarea></div>
<label style="display:flex;align-items:flex-start;gap:8px;margin-top:12px;font-size:13px;cursor:pointer;background:#fff7ed;border:1px solid #fed7aa;border-radius:10px;padding:10px 12px">
<input type="checkbox" id="dcf_pushgroup" style="width:auto;margin-top:2px">
<span><b>📢 同时通报交付群</b><span style="color:#9a3412;margin-left:6px">勾选后会把这段内容发到交付企业微信群,请确认内容适合共享</span></span>
</label>
</div>
<div class="modal-foot"><button class="btn ghost" onclick="closeModal()">关闭</button>
<button class="btn" onclick="saveDealerCustFollowup('${c.id}')">保存跟进</button></div>`);}
function saveDealerCustFollowup(cid){const c=DB.customers.find(x=>x.id===cid);if(!c)return;if(!can('follow_customers')){toast('无权限:不能写跟进');return;}
const content=document.getElementById('dcf_content').value.trim();if(!content){toast('请填写跟进内容');return;}
const _pgEl=document.getElementById('dcf_pushgroup');const _pushGrp=!!(_pgEl&&_pgEl.checked);
c.followups=c.followups||[];const _fuItem={id:'fu_'+Date.now().toString(36)+Math.random().toString(36).slice(2,7),date:today(),method:document.getElementById('dcf_method').value,operator:document.getElementById('dcf_op').value.trim(),content,nextAt:document.getElementById('dcf_next').value||''};if(_pushGrp)_fuItem.pushGroup=true;c.followups.push(_fuItem);save().then(ok=>{if(ok){closeModal();render();toast(_pushGrp?'跟进已记录,并已通报交付群':'跟进已记录');}});}
let delivFilter={dealer:'',cat:'',qDealer:'',qCustomer:'',qService:'',status:'',intent:'',coop:[]};var DELIV_COOP_LABEL={alive:'正常合作',refunded:'已退费',terminated:'已终止'};function delivCoopMatch(c,sel){if(!sel||!sel.length)return true;var lbl=coopDeadLabel(c,true);for(var i=0;i!==sel.length;i++){var k=sel[i];if(k==='alive'){if(!lbl)return true;}else if(k==='refunded'){if(lbl==='已退费')return true;}else if(k==='terminated'){if(lbl==='已终止')return true;}}return false;}function delivCoopPick(k,all){delivFilter.coop=all?[]:chipNext(delivFilter.coop,k);chipPaint('dlCoopChips',delivFilter.coop);renderDeliveryParts();}function delivCoopDead(){delivFilter.coop=['refunded','terminated'];delivAdvOpen=true;renderDelivery();}function delivCoopAlive(){delivFilter.coop=['alive'];delivAdvOpen=true;renderDelivery();}function delivDeadStat(){var r=0,t=0;DB.customers.forEach(function(c){if(!isPushed('customer',c))return;var lb=coopDeadLabel(c,true);if(lb==='已退费')r++;else if(lb==='已终止')t++;});return {refunded:r,terminated:t,total:r+t};}let delivSub='customer';let delivOwner={type:'dealer',id:'',serviceId:''};let _delivSearchTimer=null;function delivSearch(field,val,ev){if(_delivSearchTimer)clearTimeout(_delivSearchTimer);if(ev&&ev
.isComposing)return;_delivSearchTimer=setTimeout(()=>{delivFilter[field]=val.trim();renderDeliveryParts();},150);}
let vrEdit=null;let onlineEdit=null;let trainEdit=null;let helpEdit=null;let matEdit=null;let curMetaDv=null;function serviceMetaType(sid){if(sid==='vr_meta')return'vr';if(sid==='online_res'||sid==='online_aff'||sid==='ai_review'||sid==='ar_dual')return'online';if(sid==='train_off'||sid==='train_on')return'train';if(sid==='help_onsite')return'help';if(sid==='mat_claim')return'material';return'basic';}
function syncMetaStatus(mt){const sel=document.getElementById('dl_status');if(!sel)return;let hasSub=false;if(mt==='help')hasSub=Array.isArray(helpEdit)&&helpEdit.length>=1;else if(mt==='vr')hasSub=vrEdit&&Array.isArray(vrEdit.installs)&&vrEdit.installs.length>=1;else if(mt==='online')hasSub=Array.isArray(onlineEdit)&&onlineEdit.length>=1;else if(mt==='train')hasSub=Array.isArray(trainEdit)&&trainEdit.length>=1;else if(mt==='material')hasSub=Array.isArray(matEdit)&&matEdit.length>=1;const cur=sel.value;const _subSrc=(mt==='material')?matEdit:(helpEdit||trainEdit);if(hasSub&&cur==='pending'){sel.value='done';}else if(!hasSub&&cur!=='pending'&&cur!=='n_a'&&(mt==='help'||mt==='train'||mt==='material')&&(Array.isArray(_subSrc)&&_subSrc.length===0)){sel.value='pending';}}
function metaBadge(dv,mt,nx){if(!dv)return'';if(mt==='vr'){if(dv.vr&&dv.vr.installs&&dv.vr.installs.length){const r=nx?null:vrReminder(dv.expireAt?vrDaysLeft(dv.expireAt):null);return`<div class="mtxt" style="margin-top:3px"><span class="st ${r?r.cls:'st-lost'}">🎬 ${dv.vr.installs.length}次开通${dv.vr.devices?(' · '+dv.vr.devices+'台'):''}${(r&&r.txt && r.txt!=='—')?(' · '+r.txt):''}</span></div>`;}return'';}
if(mt==='online'){if(dv.online&&Array.isArray(dv.online)&&dv.online.length){const r=nx?null:vrReminder(dv.expireAt?vrDaysLeft(dv.expireAt):null);return`<div class="mtxt" style="margin-top:3px"><span class="st ${r?r.cls:'st-lost'}">🔓 ${dv.online.length}次开通${(r&&r.txt && r.txt!=='—')?(' · '+r.txt):''}</span></div>`;}
return'';}
if(mt==='train'){if(dv.trains&&dv.trains.length){const bid='trd_'+String(dv.id||uid('t')).replace(/[^a-zA-Z0-9_]/g,'');const detail=dv.trains.map(t=>`<div>${t.trainDate?esc(t.trainDate)+' ':''}${esc(t.teacher||'未填老师')}</div>`).join('');return`<div class="mtxt" style="margin-top:3px">👥 ${dv.trains.length}次师训 <span style="color:var(--primary);cursor:pointer;user-select:none" onclick="event.stopPropagation();dlTrainToggle('${bid}',this)">展开</span></div><div id="${bid}" style="display:none;margin-top:2px;font-size:12px;color:#475569;line-height:1.6">${detail}</div>`;}
return'';}
if(mt==='help'){if(dv.helps&&dv.helps.length){const hasFile=dv.helps.some(h=>h.attachments&&h.attachments.length);return`<div class="mtxt" style="margin-top:3px">🤝 ${dv.helps.length}次到校${hasFile?' · 含反馈表':''}</div>`;}
return'';}
if(mt==='material'){if(dv.materials&&dv.materials.length){return`<div class="mtxt" style="margin-top:3px"><span class="st st-signed_full">📦 ${dv.materials.length}次领取</span></div>`;}
return'';}
return'';}function dlTrainToggle(id,el){const d=document.getElementById(id);if(!d)return;const open=d.style.display!=='none';d.style.display=open?'none':'block';if(el)el.textContent=open?'展开':'收起';}
function renderDelivery(){if(!document.getElementById('dlMatrixStyle')){const st=document.createElement('style');st.id='dlMatrixStyle';st.textContent='#dlMatrixCard{max-height:62vh;overflow:auto}#dlMatrixCard table thead th{position:sticky;top:0;z-index:2}#dlMatrixCard table tbody td:first-child,#dlMatrixCard table thead th:first-child{position:sticky;left:0}#dlMatrixCard table tbody td:first-child{z-index:1;background:#fff;border-right:1px solid var(--line)}@media(hover:hover){#dlMatrixCard table tbody tr:hover td:first-child{background:linear-gradient(90deg,var(--primary) 0 3px,transparent 3px),var(--row-hover)}}#dlMatrixCard table thead th:first-child{z-index:3;background:#f8fafc;border-right:1px solid var(--line)}#dlMatrixCard{padding:0 20px 18px 0;border-left:21px solid var(--panel);border-top:19px solid var(--panel)}';document.head.appendChild(st);}
const isCust=delivSub==='customer';const g=isCust?globalDeliveryCust():globalDelivery();const seg=`<div class="seg deliv-seg" style="margin:0 0 10px">
<button class="seg-btn ${delivSub==='customer'?'active':''}" onclick="delivSub='customer';delivFilter.dealer='';delivFilter.intent='';delivFilter.coop=[];renderDelivery()">👥 客户交付</button>
<button class="seg-btn ${delivSub==='dealer'?'active':''}" onclick="delivSub='dealer';delivFilter.dealer='';delivFilter.intent='';delivFilter.coop=[];renderDelivery()">🏢 经销商交付</button>
</div>`;let kpiHTML;const _deadStat=delivDeadStat();if(isCust){kpiHTML=`
${kpiCard('📚','服务项总数',SERVICES.length,'合作包内交付项')}
${kpiCard('✅','客户交付完成率',pct(g.completion),`已交付 ${g.done}/应交付 ${g.custTotal*SERVICES.length}项`,'#16a34a')}
${kpiCard('👥','已交付客户',g.deliveredCustomers+' / '+g.custTotal,'享受总部交付服务','#4f46e5')}`;}else{kpiHTML=`
${kpiCard('📚','服务项总数',SERVICES.length,'合作包内交付项')}
${kpiCard('✅','整体交付完成率',pct(g.completion),`已交付 ${g.done}/应交付 ${g.dealers*SERVICES.length}项`,'#16a34a')}
${kpiCard('💰','年课包活动价',money(PACKAGE_PRICE),'合作期内一次性付清','#4f46e5')}`;}
view.innerHTML=`
<div class="topbar deliv-topbar"><div><h1>总部交付服务</h1><p>公司总部向${isCust?'转介绍客户':'经销商'}交付的「元墨XR·硬笔课程合作包」清单与完成度</p></div></div>
${seg}
<div class="grid kpis deliv-kpis" style="grid-template-columns:repeat(3,1fr)">${kpiHTML}</div>
${isCust&&_deadStat.total?('<div class="mtxt" style="margin:-6px 0 10px;padding:7px 10px;background:#FCEBEB;border:1px solid #f3c9c9;border-radius:9px;color:#b91c1c">⚠️ 本页含退费/终止客户 <b>'+_deadStat.total+'</b> 家(已退费 '+_deadStat.refunded+' · 已终止 '+_deadStat.terminated+') · <span style="cursor:pointer;text-decoration:underline" onclick="delivCoopDead()">只看这 '+_deadStat.total+' 家</span> · <span style="cursor:pointer;text-decoration:underline" onclick="delivCoopAlive()">排除它们</span></div>'):''}
<div class="section-title deliv-sec">${isCust?'客户交付矩阵':'经销商交付矩阵'}<span style="margin-left:auto;font-weight:400;font-size:12px;color:var(--muted);text-align:right;line-height:1.4">按 VR元宇宙 + AR双师课 未交付优先排序</span></div>
<div class="search" id="dlSearchBar">
<input id="dlSearchMain" class="list-search deliv-search" placeholder="${isCust?'🔍 搜索客户姓名 / 校区':'🔍 搜索经销商'}" value="${esc(isCust?delivFilter.qCustomer:delivFilter.qDealer)}">
<button class="btn ghost sm" onclick="toggleDelivAdv()">🔍 高级筛选</button>
<button class="btn ghost sm" id="dlResetBtn">重置</button>
${delivCanExport()?'<button class="btn sm" onclick="openDelivExport(\'matrix\')">📥 导出矩阵</button>':''}
${delivCanExport()?'<button class="btn ghost sm" onclick="openDelivExport(\'detail\')">📥 导出明细</button>':''}
</div>
<div id="delivAdvPanel" style="${delivAdvOpen?'':'display:none;'}margin-bottom:14px;padding:14px 16px;background:var(--soft);border:1px solid var(--line);border-radius:12px">
<div class="row2">
<div class="field"><label>归属经销商</label>${smartSelectHTML({id:'dlSelDealer',items:DB.dealers,selectedId:delivFilter.dealer,displayKey:'name',emptyLabel:'全部经销商',placeholder:'搜索经销商…'})}</div>
<div class="field"><label>服务类别</label><select id="dlSelCat"><option value="">全部类别</option>${Object.entries(SERVICE_CATEGORIES).map(([k,v])=>`<option value="${k}"${delivFilter.cat===k?'selected':''}>${v.name}</option>`).join('')}</select></div>
</div>
<div class="row2">
<div class="field"><label>服务项目</label><input id="dlSearchService" class="deliv-search" placeholder="🔍 搜索服务项目" value="${esc(delivFilter.qService||'')}"></div>
<div class="field"><label>交付状态</label><select id="dlSelStatus"><option value="">全部状态</option><option value="pending"${delivFilter.status==='pending'?' selected':''}>待交付(含部分)</option><option value="done"${delivFilter.status==='done'?' selected':''}>已交付</option></select></div>
</div>
${isCust?`
<div class="row2">
<div class="field"><label>合作项目</label><select id="dlSelIntent"><option value="">全部合作项目</option>${INTENT_OPTIONS.map(x=>`<option value="${x}"${delivFilter.intent===x?' selected':''}>${x}</option>`).join('')}</select></div>
<div class="field"><label>合作状态(可多选)</label><div class="chip-row" id="dlCoopChips"><button type="button" class="chip${(delivFilter.coop||[]).length?'':' active'}" data-k="" onclick="delivCoopPick('',1)">全部</button><button type="button" class="chip${(delivFilter.coop||[]).indexOf('alive')!==-1?' active':''}" data-k="alive" onclick="delivCoopPick('alive')">正常合作</button><button type="button" class="chip${(delivFilter.coop||[]).indexOf('refunded')!==-1?' active':''}" data-k="refunded" onclick="delivCoopPick('refunded')">已退费</button><button type="button" class="chip${(delivFilter.coop||[]).indexOf('terminated')!==-1?' active':''}" data-k="terminated" onclick="delivCoopPick('terminated')">已终止</button></div></div>
</div>`:''}
</div>
<div id="dlHint"></div>
<div class="card" id="dlMatrixCard" style="overflow:auto;max-height:calc(100vh - 240px)"></div>
<div class="section-title">${isCust?'各客户交付完成度':'各经销商交付完成度'}</div>
<div class="card"><table>
<thead><tr>${isCust?`<th>客户</th><th>所属经销商</th>`:`<th>经销商</th>`}<th>已完成/总项</th><th>部分交付</th><th>进度</th><th>完成率</th><th>已交付价值</th></tr></thead>
<tbody id="dlSummaryBody"></tbody>
</table></div>
<div class="section-title">服务清单(年课包·活动价 ¥${fmt(PACKAGE_PRICE)})</div>
<div id="dlCatalog"></div>`;bindDeliverySearch();renderDeliveryParts();}
let delivAdvOpen=false;function toggleDelivAdv(){delivAdvOpen=!delivAdvOpen;const p=document.getElementById('delivAdvPanel');if(p)p.style.display=delivAdvOpen?'block':'none';fitLockHeights();}function bindDeliverySearch(){const svc=document.getElementById('dlSearchService');if(svc){svc.addEventListener('input',e=>{delivSearch('qService',svc.value);});svc.addEventListener('compositionend',e=>delivSearch('qService',svc.value));}
const main=document.getElementById('dlSearchMain');if(main){const mf=delivSub==='customer'?'qCustomer':'qDealer';main.addEventListener('input',e=>{delivSearch(mf,main.value);});main.addEventListener('compositionend',e=>delivSearch(mf,main.value));}
const selD=document.getElementById('dlSelDealer');if(selD&&selD.tagName==='SELECT')selD.addEventListener('change',()=>{delivFilter.dealer=selD.value;renderDeliveryParts();});const selC=document.getElementById('dlSelCat');if(selC)selC.addEventListener('change',()=>{delivFilter.cat=selC.value;renderDeliveryParts();});const selS=document.getElementById('dlSelStatus');if(selS)selS.addEventListener('change',()=>{delivFilter.status=selS.value;renderDeliveryParts();});const selI=document.getElementById('dlSelIntent');if(selI)selI.addEventListener('change',()=>{delivFilter.intent=selI.value;renderDeliveryParts();});const reset=document.getElementById('dlResetBtn');if(reset)reset.addEventListener('click',()=>{delivFilter={dealer:'',cat:'',qDealer:'',qCustomer:'',qService:'',status:'',intent:'',coop:[]};renderDelivery();});bindSmartSelect('dlSelDealer_wrap',v=>{delivFilter.dealer=v;renderDeliveryParts();});}
function renderDeliveryParts(){const isCust=delivSub==='customer';const cats=Object.entries(SERVICE_CATEGORIES);const catalog=cats.map(([k,c])=>{let items=SERVICES.filter(s=>s.cat===k);if(delivFilter.qService){const q=delivFilter.qService.toLowerCase();items=items.filter(s=>(s.name+' '+s.spec).toLowerCase().includes(q));}
if(!items.length)return'';return`<div class="card" style="margin-bottom:14px">
<h4 style="font-size:14px;margin-bottom:12px;display:flex;align-items:center;gap:8px">
<span style="width:6px;height:16px;background:${c.color};border-radius:3px"></span>${c.ic} ${c.name}
</h4>
<table><thead><tr><th>服务项</th><th>规格</th><th>单价/说明</th><th style="text-align:right">市场价值</th></tr></thead><tbody>
${items.map(s=>`<tr><td><b>${s.name}</b></td><td>${s.spec}</td><td>${s.unit||'—'}</td><td style="text-align:right"><b style="color:${s.value>=1000000?'#dc2626':'#334155'}">${s.value?money(s.value):'—'}</b></td></tr>`).join('')}
</tbody></table>
</div>`;}).join('');const catEl=document.getElementById('dlCatalog');if(catEl)catEl.innerHTML=catalog;let listServices=(fLevel('dv_service')==='hidden')?[]:SERVICES.slice();if(delivFilter.cat)listServices=listServices.filter(s=>s.cat===delivFilter.cat);if(delivFilter.qService){const q=delivFilter.qService.toLowerCase();
listServices=listServices.filter(s=>(s.name+' '+s.spec).toLowerCase().includes(q));}
let ownerList=isCust?DB.customers.slice():DB.dealers.filter(d=>d.type!=="staff");ownerList=ownerList.filter(o=>isPushed(isCust?'customer':'dealer',o));if(delivFilter.dealer){ownerList=isCust?ownerList.filter(o=>o.dealerId===delivFilter.dealer):ownerList.filter(o=>o.id===delivFilter.dealer);}
if(isCust){if(delivFilter.qCustomer){const q=delivFilter.qCustomer.trim().toLowerCase();if(q){const exact=ownerList.filter(c=>c.name&&c.name.trim().toLowerCase()===q);ownerList=exact.length?exact:ownerList.filter(c=>(c.name+' '+(c.school||'')).toLowerCase().includes(q));}}}else{if(delivFilter.qDealer){const q=delivFilter.qDealer.trim().toLowerCase();if(q){const exact=ownerList.filter(d=>d.name&&d.name.trim().toLowerCase()===q);ownerList=exact.length?exact:ownerList.filter(d=>d.name.toLowerCase().includes(q));}}}
if(isCust&&delivFilter.intent){ownerList=ownerList.filter(c=>intentArr(c).includes(delivFilter.intent));}if(delivFilter.status){ownerList=ownerList.filter(o=>listServices.some(s=>{const dv=isCust?findCustomerDelivery(o.id,s.id):findDelivery(o.id,s.id);const st=dv?dv.status:'pending';return delivFilter.status==='pending'?(st==='pending'||st==='partial'):st===delivFilter.status;}));}
ownerList=delivSortOwners(ownerList,isCust);
const editable=can('edit_delivery');const ownerType=isCust?'customer':'dealer';const rowsHtml=ownerList.map(o=>{const cells=listServices.map(s=>{const dv=isCust?findCustomerDelivery(o.id,s.id):findDelivery(o.id,s.id);const st=dv?dv.status:'pending';const _ds=delivStatusOf(dv);const cls=_ds.cls;const label=_ds.label;const extra=metaBadge(dv,serviceMetaType(s.id),!!coopDeadLabel(o,isCust));const click=(editable&&fLevel('dv_status')==='full'&&fLevel('dv_service')==='full')?`onclick="openServiceMeta('${ownerType}','${o.id}','${s.id}')"`:'';return`<td style="${editable?'cursor:pointer':''}" ${click} title="${editable?'点击编辑':''}">
${fLevel('dv_status')==='full'?('<span class="st '+cls+'">'+label+'</span>'):('<span class="st">'+(fLevel('dv_status')==='mask'?'***':'****')+'</span>')}${extra}
</td>`;}).join('');const ownName=isCust?`${custNameHtml(o)}${coopDeadTag(o,isCust)}`:`<span class="tag ${DT(o.type).color}">${DT(o.type).name.replace('经销商','')}</span> ${fTxt('d_name',o.name)}${coopDeadTag(o,isCust)}`;const ownCol=isCust?`<td>${ownName}</td><td class="mtxt">${dealerName(o.dealerId)||'—'}</td>`:`<td>${ownName}</td>`;return`<tr${coopDeadLabel(o,isCust)?' class=\"dl-dead-row\"':''}>${ownCol}${cells}</tr>`;}).join('');const matrixCard=document.getElementById('dlMatrixCard');if(matrixCard)matrixCard.innerHTML=`<table style="min-width:${Math.max(560, 220 + listServices.length*120)}px">
<thead><tr><th style="min-width:200px">${isCust?'客户':'经销商'}</th>${isCust?'<th style="min-width:160px">所属经销商</th>':''}${listServices.map(s=>`<th style="min-width:120px">${fLevel('dv_service')==='full'?s.name:fTxt('dv_service',s.name)}</th>`).join('')}</tr></thead>
<tbody>${rowsHtml||`<tr><td colspan="${listServices.length+ (isCust?2:1)}"class="empty">无数据</td></tr>`}</tbody>
</table>
${listServices.length>4?'<div class="mtxt" style="margin-top:8px">← 横向滑动查看更多服务项目 →</div>':''}`;const sumRows=ownerList.map(o=>{const stt=ownerDelivery(ownerType,o.id);const ownName=isCust?`${custNameHtml(o)}${coopDeadTag(o,isCust)}`:`<span class="tag ${DT(o.type).color}">${DT(o.type).name.replace('经销商','')}</span> ${fTxt('d_name',o.name)}${coopDeadTag(o,isCust)}`;const dealerCol=isCust?`<td class="mtxt">${dealerName(o.dealerId)||'—'}</td>`:'';return`<tr>
<td>${ownName}</td>${dealerCol}
<td>${fLevel('dv_status')==='full'?(stt.done+'/'+stt.total):(fLevel('dv_status')==='mask'?'***':'****')}</td>
<td>${stt.partial>0?`+${stt.partial}部分`:'-'}</td>
<td><div class="bar" style="width:140px"><i style="width:${stt.completion}%"></i></div></td>
<td><b>${pct(stt.completion)}</b></td>
<td>${money(stt.deliveredValue)} <span class="mtxt">/ ${money(stt.totalValue)}</span></td>
</tr>`;}).join('')||`<tr><td colspan="${isCust?7:6}" class="empty">无数据</td></tr>`;const sumBody=document.getElementById('dlSummaryBody');if(sumBody)sumBody.innerHTML=sumRows;const hint=document.getElementById('dlHint');const qOwner=isCust?delivFilter.qCustomer:delivFilter.qDealer;const itx=(isCust&&delivFilter.intent)?(' · 合作项目「'+delivFilter.intent+'」'):'';if(hint)hint.innerHTML=(delivFilter.qService||qOwner||itx)?`<div class="mtxt" style="margin:4px 0 12px;padding:6px 10px;background:#eff6ff;border:1px solid #bfdbfe;border-radius:8px;color:#1d4ed8">🔎 搜索中:服务项目「${delivFilter.qService||'全部'}」${itx} · ${isCust?'客户':'经销商'}「${qOwner||'全部'}」 — 已过滤上方服务清单与下方交付矩阵,点"重置"恢复</div>`:'';}
function ownerObj(o){if(o.type==='customer'){const c=DB.customers.find(x=>x.id===o.id);if(!c)return null;return{ref:c,name:c.name,tagHtml:`<span class="tag" style="background:#dbeafe;color:#1e40af">${c.school||'—'}</span>`,label:`客户:${custNameText(c)}`};}
const d=DB.dealers.find(x=>x.id===o.id);if(!d)return null;return{ref:d,name:d.name,tagHtml:`<span class="tag ${DT(d.type).color}">${DT(d.type).name.replace('经销商','')}</span>`,label:`经销商:${d.name}`};}
function upsertDelivery(rawData,opts){const data=(rawData&&typeof rawData==='object')?Object.assign({},rawData):{};const _km={status:'dv_status',date:'dv_date',qty:'dv_notes',operator:'dv_notes',notes:'dv_notes'};const _drop=[];Object.keys(_km).forEach(k=>{if((k in data)&&fDeny(_km[k],'e')){delete data[k];_drop.push(k);}});if(_drop.length&&!Object.keys(data).length){toast('无权限:本角色已被关闭本条交付记录的全部可编辑字段('+_drop.join('、')+')');return;}const{type,id,serviceId}=delivOwner;let dv=type==='customer'?findCustomerDelivery(id,serviceId):findDelivery(id,s
erviceId);if(dv){if(dvOtherBlocked(dv)){toast('无权限:这条交付记录由「'+dvRecorderText(dv)+'」录入,不能修改');return;}var _dvtake=!dvIsMine(dv);Object.assign(dv,data);if(_dvtake){dv.opId=dvCurUserId();dv.opName=dvCurUserName();dv.opAt=dvNowStamp();}}
else{const rec={id:uid('dl_'),serviceId,...data};if(!rec.status)rec.status=delivStatusKey(rec);rec.opId=dvCurUserId();rec.opName=dvCurUserName();rec.opAt=dvNowStamp();if(type==='customer'){rec.customerId=id;const c=DB.customers.find(x=>x.id===id);rec.dealerId=c?c.dealerId||'':'';}
else rec.dealerId=id;DB.deliveries.push(rec);}
if(type==='customer'){const _pc=DB.customers.find(x=>x.id===id);if(_pc&&!_pc.pushedDelivery)_pc.pushedDelivery=true;}else{const _pd=DB.dealers.find(x=>x.id===id);if(_pd&&!_pd.pushedDelivery)_pd.pushedDelivery=true;}
save().then(ok=>{if(ok){if(!(opts&&opts.keep)){closeModal();render();}toast((opts&&opts.msg)||(_drop.length?('已保存,但「'+_drop.join('、')+'」因字段级权限未保存'):'已保存'));if(opts&&typeof opts.then==='function'){try{opts.then();}catch(e){}}}else if(opts&&opts.err){toast(opts.err);}});}
/* ===== 交付记录「录入人」与归属守卫(2026-09-14 用户要求,覆盖全部服务项目) =====
规则:① 新建交付记录时自动记录录入人(当前登录用户 id + 姓名快照 + 时间);
② 同一人可自由修改 / 删除自己录入的交付记录;
③ 甲不得修改 / 删除乙录入的交付记录 —— 除非持有「代改 / 代删他人的交付记录」(edit_delivery_other) 或为 admin;
④ 存量记录(opId 为空)视为历史公共记录,不受本规则约束(不回填、不猜测)。
服务端 protectWholeRecords 有同款归属守卫(opId / opName / opAt 三字段一律以保存前值为准,不可绕过)。 */
function dvCurUserId(){return (currentUser&¤tUser.id)||'';}
function dvCurUserName(){return (currentUser&¤tUser.name)||'';}
function dvNowStamp(){var d=new Date();var p=function(n){return String(n).padStart(2,'0');};return ''+d.getFullYear()+'-'+p(d.getMonth()+1)+'-'+p(d.getDate())+' '+p(d.getHours())+':'+p(d.getMinutes());}
/* ★ 第 351 轮:空白交付记录判定(与 dvOtherBlocked 共用;与 server.js 同名函数逐条同源)
语义:一条交付记录若「无任何子记录 + 状态未交付 + 无数量 / 备注 / 手动到期 / 到期时间」,即视为空白 ——
里面没有任何交付内容,此时不再按「他人录入」锁住后来人(可接管登记、可删除)。
有内容的记录规则一字不变:非本人录入且无代改权仍不可改、不可删。 */
function dvIsEmptyRec(dv){if(!dv)return false;var _de=(dv.vr&&Array.isArray(dv.vr.installs)&&dv.vr.installs.length>0)||(Array.isArray(dv.online)&&dv.online.length>0)||(Array.isArray(dv.trains)&&dv.trains.length>0)||(Array.isArray(dv.helps)&&dv.helps.length>0)||(Array.isArray(dv.materials)&&dv.materials.length>0);if(_de)return false;if(String(dv.status||'pending')!=='pending')return false;if(dv.qty&&String(dv.qty).trim())return false;if(dv.notes&&String(dv.notes).trim())return false;if(dv.expireManual)return false;if(dv.expireAt&&String(dv.expireAt).trim())return false;return true;}
function dvIsMine(dv){if(!dv)return true;if(!dv.opId)return true;var me=dvCurUserId();if(!me)return true;return dv.opId===me;}
function dvOtherBlocked(dv){if(dvIsMine(dv))return false;if(can('edit_delivery_other'))return false;if(dvIsEmptyRec(dv))return false;return true;}
function dvRecorderText(dv){if(!dv||!dv.opId)return '';return dv.opName||dv.opId;}
function dvRecorderLine(dv){
if(!dv||!dv.opId)return '<div class="mtxt" style="margin-top:6px">录入人:<b>—</b> <span style="color:#94a3b8">(历史记录,未记录录入人)</span></div>';
var who=esc(dv.opName||dv.opId);
var when=dv.opAt?(' · '+esc(dv.opAt)):'';
if(dvOtherBlocked(dv))return '<div class="mtxt" style="margin-top:6px">录入人:<b>'+who+'</b>'+when+' <span style="color:#dc2626;font-weight:700">⛔ 非本人录入,不可修改 / 删除</span></div>';
return '<div class="mtxt" style="margin-top:6px">录入人:<b>'+who+'</b>'+when+(dvIsEmptyRec(dv)?' <span style="color:#0369a1;font-weight:600">🫱 空白记录(无交付内容),任何操作人均可接管登记</span>':'')+'</div>';
}
function dvCurMeta(){try{var o=delivOwner;if(!o)return curMetaDv||null;return o.type==='customer'?findCustomerDelivery(o.id,o.serviceId):findDelivery(o.id,o.serviceId);}catch(e){return curMetaDv||null;}}
function dvDelGuard(){var dv=dvCurMeta();if(dv&&dvOtherBlocked(dv)){toast('无权限:这条交付记录由「'+dvRecorderText(dv)+'」录入,不能修改 / 删除');return false;}return true;}
function openVrMeta(id){openServiceMeta('dealer',id,'vr_meta');}
const DELIV_OPS=['线下师训','线上会议','线下到校指导'];function dlOpField(dv){const cur=dv&&DELIV_OPS.includes(dv.operator)?dv.operator:'线下师训';return`<div class="field"><label>交付方式</label><select id="dl_op">${DELIV_OPS.map(o=>`<option value="${o}"${o===cur?'selected':''}>${o}</option>`).join('')}</select></div>`;}
function openServiceMeta(type,id,serviceId){delivOwner={type,id,serviceId};const info=ownerObj(delivOwner);if(!info){toast('未找到对应记录');return;}
if(!can('edit_delivery')){toast('无权限:当前角色未开启「记录 / 编辑交付与安装」');return;}
const d=info.ref;const s=SERVICES.find(x=>x.id===serviceId);if(!s)return;const mt=serviceMetaType(serviceId);if(mt==='basic'){openDelivery(serviceId);return;}
const dv=type==='customer'?findCustomerDelivery(id,serviceId):findDelivery(id,serviceId);curMetaDv=dv;if(mt==='vr'){vrEdit=dv&&dv.vr?JSON.parse(JSON.stringify(dv.vr)):{devices:0,installs:[]};openModal(`
<div class="modal-head"><h3>VR元宇宙大场景 · 交付与开通</h3><span class="x" onclick="closeModal()">×</span></div>
<div class="modal-body lk-svc">
${metaInfoCard(s,dv)}
<div class="row2">
<div class="field"><label>VR设备数量(台)</label><input type="number" id="vr_devices" min="0" value="${vrEdit.devices||0}"></div>
${dlOpField(dv)}
</div>
<div class="field"><label>备注</label><textarea id="dl_notes" rows="2">${dv?dv.notes||'':''}</textarea></div>
<div class="section-title" style="margin:18px 0 10px">场景安装记录(可多次安装,记录每次场景/时间/时长)</div>
<div id="vrRemind" style="margin-bottom:10px"></div>
<div id="vrInstalls"></div>
<div class="card" style="background:#f8fafc;margin-top:10px">
<div style="font-size:13px;font-weight:700;margin-bottom:10px">+ 添加场景安装</div>
<div class="row2">
<div class="field"><label>场景名称</label><input id="vr_scene" placeholder="如:火星探险 / 海底世界"></div>
<div class="field"><label>开通时间</label><input type="date" id="vr_install" value="${today()}"></div>
</div>
<div class="field"><label>备注(可选)</label><input id="vr_note" placeholder="如 场景说明 / 安装人员 / 后续安排"></div><button class="btn sm" style="margin-top:6px" onclick="vrAddInstall()">添加该场景开通</button>
</div>
</div>
<div class="modal-foot">
<button class="btn ghost" onclick="closeModal()">取消</button>
<button class="btn" onclick="saveVrMeta()">保存</button>
</div>`);renderVrInstalls();}
else if(mt==='online'){onlineEdit=dv&&Array.isArray(dv.online)?JSON.parse(JSON.stringify(dv.online)):[];openModal(`
<div class="modal-head"><h3>${s.name} · 开通与到期</h3><span class="x" onclick="closeModal()">×</span></div>
<div class="modal-body">
${metaInfoCard(s,dv)}
${dlOpField(dv)}
<div class="section-title" style="margin:18px 0 10px">开通记录(可分次开通,记录每次开通时间 / 时长 / 到期 / 提醒)</div>
<div id="onRemind" style="margin-bottom:10px"></div>
<div id="onlineList"></div>
<div class="card" style="background:#f8fafc;margin-top:10px">
<div style="font-size:13px;font-weight:700;margin-bottom:10px">+ 添加开通记录</div>
<div class="row2">
<div class="field"><label>开通时间</label><input type="date" id="on_open" value="${today()}" oninput="onPreview()"></div>
</div>
<div id="onPreview" style="margin-bottom:8px"></div>
<div class="field"><label>备注(可选)</label><input id="on_note" placeholder="如 本次开通说明 / 操作人"></div><button class="btn sm" style="margin-top:6px" onclick="onlineAdd()">添加该次开通</button>
</div>
<div class="field"><label>备注</label><textarea id="dl_notes" rows="2">${dv?dv.notes||'':''}</textarea></div>
</div>
<div class="modal
-foot">
<button class="btn ghost" onclick="closeModal()">取消</button>
<button class="btn" onclick="saveOnlineMeta()">保存</button>
</div>`);renderOnlineMeta();}
else if(mt==='train'){trainEdit=dv&&Array.isArray(dv.trains)?JSON.parse(JSON.stringify(dv.trains)):[];openModal(`
<div class="modal-head"><h3>${s.name} · 师训记录</h3><span class="x" onclick="closeModal()">×</span></div>
<div class="modal-body">
${metaInfoCard(s,dv)}
${dlOpField(dv)}
<div class="section-title" style="margin:18px 0 10px">师训记录(可多次师训,记录每次时间与老师)</div>
<div id="trainList"></div>
<div class="card" style="background:#f8fafc;margin-top:10px">
<div style="font-size:13px;font-weight:700;margin-bottom:10px">+ 添加师训记录</div>
<div class="row2">
<div class="field"><label>师训时间</label><input type="date" id="tr_date" value="${today()}"></div>
<div class="field"><label>师训老师</label><input id="tr_teacher" placeholder="如 王老师 / 总部师训组"></div>
</div>
<div class="field"><label>备注(可选)</label><input id="tr_note" placeholder="如 师训内容 / 参与人数"></div><button class="btn sm" style="margin-top:6px" onclick="trainAdd()">添加该师训</button>
</div>
<div class="field"><label>备注</label><textarea id="dl_notes" rows="2">${dv?dv.notes||'':''}</textarea></div>
</div>
<div class="modal-foot">
<button class="btn ghost" onclick="closeModal()">取消</button>
<button class="btn" onclick="saveTrainMeta()">保存</button>
</div>`);renderTrainMeta();}
else if(mt==='help'){helpEdit=dv&&Array.isArray(dv.helps)?JSON.parse(JSON.stringify(dv.helps)):[];try{helpScanCtxKey=type+'|'+id;helpScanApplyPending();}catch(_he){}openModal(`
<div class="modal-head"><h3>${s.name} · 到校帮扶记录</h3><span class="x" onclick="closeModal()">×</span></div>
<div class="modal-body">
${metaInfoCard(s,dv)}
${dlOpField(dv)}
${can('edit_delivery')?`<button class="btn ghost sm" onclick="openHelpScan('${type}','${id}')">📱 手机扫码上传</button>`:''}
<div class="section-title" style="margin:18px 0 10px">到校帮扶记录(可多次到校,记录每次开始/结束时间与反馈表附件)</div>
<div id="helpList"></div>
${can('upload_delivery')?`<div class="card"style="background:#f8fafc;margin-top:10px"><div style="font-size:13px;font-weight:700;margin-bottom:10px">+ 添加到校帮扶</div><div class="field"><label>到校帮扶反馈表(拍照 / 相册 / 文件)</label><div style="display:flex;gap:8px;flex-wrap:wrap"><button type="button" class="btn sm" onclick="document.getElementById('hp_camera').click()">📷 拍照</button><button type="button" class="btn sm" onclick="document.getElementById('hp_gallery').click()">🖼 相册/文件</button></div><input type="file" id="hp_camera" accept="image/*" capture="environment" style="display:none" onchange="hpAddFiles(this)"><input type="file" id="hp_gallery" multiple accept="image/*,.pdf,.doc,.docx,.xls,.xlsx,.zip,.txt" style="display:none" onchange="hpAddFiles(this)"><div id="hpFileList" style="margin-top:8px"></div></div><div class="field"><label>备注(可选)</label><textarea id="hp_note"rows="2"placeholder="如 帮扶内容 / 参与人员"></textarea></div><button class="btn sm"style="margin-top:6px"onclick="helpAdd()">添加该次到校帮扶</button></div>`:'<div class="hint" style="margin-top:10px;color:#94a3b8">当前角色无「上传到校帮扶附件」权限,仅可查看已记录的到校帮扶。</div>'}
<div class="field" style="margin-top:12px"><label>总体备注</label><textarea id="dl_notes" rows="2">${dv?dv.notes||'':''}</textarea></div>
</div>
<div class="modal-foot">
<button class="btn ghost" onclick="closeModal()">取消</button>
<button class="btn" onclick="saveHelpMeta()">保存</button>
</div>`);renderHelpMeta();}
else if(mt==='material'){matEdit=dv&&Array.isArray(dv.materials)?JSON.parse(JSON.stringify(dv.materials)):[];openModal(`
<div class="modal-head"><h3>${s.name} · 领取记录</h3><span class="x" onclick="closeModal()">×</span></div>
<div class="modal-body">
${metaInfoCard(s,dv)}
${dlOpField(dv)}
<div class="section-title" style="margin:18px 0 10px">领取记录(可多次领取,记录每次领取时间 / 名称 / 数量 / 备注与附件)</div>
<div id="matList"></div>
${can('edit_delivery')?`<div class="card" style="background:#f8fafc;margin-top:10px">
<div style="font-size:13px;font-weight:700;margin-bottom:10px">+ 添加领取记录</div>
<div class="row2">
<div class="field"><label>领取时间</label><input type="date" id="mt_date" value="${today()}"></div>
<div class="field"><label>名称</label><input id="mt_name" list="mtNameOpts" placeholder="如:体验教材 / 品牌手册套装(可自由填写)" autocomplete="off"><datalist id="mtNameOpts"><option value="体验教材"></option><option value="启蒙教材"></option><option value="常规教材"></option><option value="练习本"></option><option value="品牌手册套装"></option></datalist></div>
</div>
<div class="row2">
<div class="field"><label>数量</label><input id="mt_qty" placeholder="如 3本 / 12本"></div>
<div class="field"></div>
</div>
${can('upload_delivery')?`<div class="field"><label>附件(拍照 / 相册 / 文件,可多选)</label><div style="display:flex;gap:8px;flex-wrap:wrap"><button type="button" class="btn sm" onclick="document.getElementById('mt_camera').click()">📷 拍照</button><button type="button" class="btn sm" onclick="document.getElementById('mt_gallery').click()">🖼 相册/文件</button></div><input type="file" id="mt_camera" accept="image/*" capture="environment" style="display:none" onchange="mtAddFiles(this)"><input type="file" id="mt_gallery" multiple accept="image/*,.pdf,.doc,.docx,.xls,.xlsx,.zip,.txt" style="display:none" onchange="mtAddFiles(this)"><div id="mtFileList" style="margin-top:8px"></div></div>`:'<div class="hint" style="margin-top:10px;color:#94a3b8">当前角色无「上传交付附件」权限,添加时不能带附件。</div>'}
<div class="field"><label>备注(可选)</label><textarea id="mt_note" rows="2" placeholder="如 领用人 / 用途说明"></textarea></div>
<button class="btn sm" style="margin-top:6px" onclick="matAdd()">添加本次领取</button>
</div>`:''}
<div class="field" style="margin-top:12px"><label>总体备注</label><textarea id="dl_notes" rows="2">${dv?dv.notes||'':''}</textarea></div>
</div>
<div class="modal-foot">
<button class="btn ghost" onclick="closeModal()">取消</button>
<button class="btn" onclick="saveMatMeta()">保存</button>
</div>`);renderMatMeta();}}
function metaInfoCard(s,dv){const info=ownerObj(delivOwner);if(!info)return'';let initStatus=(dv&&dv.status)||'pending';const mt=serviceMetaType(s.id);if(mt==='help'&&dv&&dv.helps&&dv.helps.length>=1&&initStatus==='pending')initStatus='done';if(mt==='vr'&&dv&&dv.vr&&dv.vr.installs&&dv.vr.installs.length>=1&&initStatus==='pending')initStatus='done';if(mt==='online'&&dv&&Array.isArray(dv.online)&&dv.online.length>=1&&initStatus==='pending')initStatus='done';if(mt==='train'&&dv&&dv.trains&&dv.trains.length>=1&&initStatus==='pending')initStatus='done';if(mt==='material'&&dv&&dv.materials&&dv.materials.length>=1&&initStatus==='pending')initStatus='done';const stOpts=Object.entries(DELIVERY_STATUS).map(([k,v])=>`<option value="${k}" ${initStatus===k?'selected':''}>${v.label}</option>`).join('');const kind=delivOwner.type==='customer'?'客户':'经销商';return`<div class="card" style="background:#f8fafc;padding:12px;margin-bottom:14px">
<div><b>${kind}:</b>${info.tagHtml} ${info.name}</div>
<div style="margin-top:6px"><b>服务项:</b>${s.name} | <b>规格:</b>${s.spec}</div>
<div style="margin-top:6px"><b>市场价值:</b><span style="color:#dc2626;font-weight:700">${s.value?money(s.value):'—'}</span></div>
<div style="margin-top:6px"><b>到期时间:</b><b style="color:#4f46e5">${dv&&dv.expireAt?dv.expireAt:'—'}</b>${dv&&dv.expireManual?' <span style="color:#d97706;font-weight:600">🔒 手动调整</span>':''}${dv&&dv.expireNote?' <span style="color:#64748b">· '+esc(dv.expireNote)+'</span>':''}</div>
${dvRecorderLine(dv)}
</div>
<div class="row2">
<div class="field"><label>交付状态</label><select id="dl_status"${fDeny('dv_status','e')?' disabled title="无权限:本角色已关闭「交付状态」的编辑(字段级)"':''}>${stOpts}</select></div>
<div class="field"><label>交付日期</label><input type="date" id="dl_date" value="${dv?dv.date||today():today()}"></div>
</div>`;}
function openDelivery(serviceId){const o=delivOwner;const info=ownerObj(
o);if(!info)return;const d=info.ref;const s=SERVICES.find(x=>x.id===serviceId);if(!s)return;const dv=o.type==='customer'?findCustomerDelivery(o.id,serviceId):findDelivery(o.id,serviceId);const stOpts=Object.entries(DELIVERY_STATUS).map(([k,v])=>`<option value="${k}" ${(dv&&dv.status===k)?'selected':''}>${v.label}</option>`).join('');const kind=o.type==='customer'?'客户':'经销商';openModal(`
<div class="modal-head"><h3>交付记录 · ${s.name}</h3><span class="x" onclick="closeModal()">×</span></div>
<div class="modal-body">
<div class="card" style="background:#f8fafc;padding:12px;margin-bottom:14px">
<div><b>${kind}:</b>${info.tagHtml} ${info.name}</div>
<div style="margin-top:6px"><b>服务项:</b>${s.name} | <b>规格:</b>${s.spec}</div>
<div style="margin-top:6px"><b>市场价值:</b><span style="color:#dc2626;font-weight:700">${s.value?money(s.value):'—'}</span></div>
${dvRecorderLine(dv)}
</div>
<div class="row2">
<div class="field"><label>交付状态</label><select id="dl_status"${fDeny('dv_status','e')?' disabled title="无权限:本角色已关闭「交付状态」的编辑(字段级)"':''}>${stOpts}</select></div>
<div class="field"><label>交付日期</label><input type="date" id="dl_date" value="${dv?dv.date||today():today()}"></div>
</div>
<div class="field"><label>交付数量/批次</label><input id="dl_qty" value="${dv?dv.qty||'':''}" placeholder="如 48节 / 200节 / 3本"></div>
${dlOpField(dv)}
<div class="field"><label>备注</label><textarea id="dl_notes" rows="2">${dv?dv.notes||'':''}</textarea></div>
</div>
<div class="modal-foot">
<button class="btn ghost" onclick="closeModal()">取消</button>
<button class="btn" onclick="saveDelivery()">保存</button>
</div>`);}
function saveDelivery(){const data={status:document.getElementById('dl_status').value,date:document.getElementById('dl_date').value,qty:document.getElementById('dl_qty').value.trim(),operator:document.getElementById('dl_op').value.trim(),notes:document.getElementById('dl_notes').value.trim(),};upsertDelivery(data);}
function subNoteVal(id){const el=document.getElementById(id);return el?(el.value||'').trim():'';}
function subNoteClear(id){const el=document.getElementById(id);if(el)el.value='';}
function subNoteHTML(kind,idx,note){const val=esc(note||'');if(!can('edit_delivery')){return val?`<div class="mtxt" style="margin-top:5px">备注:${val}</div>`:'';}
return `<div class="mtxt" style="margin-top:5px;display:flex;align-items:center;gap:6px;max-width:540px"><span style="flex:0 0 auto">备注:</span><input type="text" placeholder="添加备注…(说明 / 参与人员 / 后续安排)" value="${val}" onchange="subNoteSet('${kind}',${idx},this.value)" style="flex:1;min-width:0;padding:5px 9px;border:1px solid var(--line);border-radius:7px;font-size:12px;background:#fff"></div>`;}
function subNoteSet(kind,idx,note){if(!can('edit_delivery')||fDeny('dv_notes','e')){toast('无权限:修改交付备注需「记录 / 编辑交付与安装」权限(本角色「数量/操作人/备注」字段的编辑已关闭)');return;}if(!dvDelGuard())return;
note=String(note||'').slice(0,200);const arr=kind==='vr'?((vrEdit&&vrEdit.installs)||[]):kind==='online'?(onlineEdit||[]):kind==='train'?(trainEdit||[]):(helpEdit||[]);const it=arr[idx];if(!it)return;it.note=note;
const o=delivOwner;let dv=null;if(o&&DB)dv=o.type==='customer'?findCustomerDelivery(o.id,o.serviceId):findDelivery(o.id,o.serviceId);if(!dv)dv=curMetaDv||null;curMetaDv=dv;
let list=[];if(dv){if(kind==='vr')list=(dv.vr&&dv.vr.installs)||[];else if(kind==='online')list=Array.isArray(dv.online)?dv.online:[];else if(kind==='train')list=Array.isArray(dv.trains)?dv.trains:[];else list=Array.isArray(dv.helps)?dv.helps:[];}
let target=null;if(list.length){if(it.id)target=list.find(function(x){return x&&x.id===it.id;})||null;if(!target&&list.length===arr.length&&list[idx])target=list[idx];}
if(!target){toast('备注已记录,点底部「保存」后写入');return;}
target.note=note;save().then(function(ok){toast(ok?'备注已保存':'备注保存失败,请重试');});}
function renderVrInstalls(){const box=document.getElementById('vrInstalls');if(!box)return;const installs=vrEdit.installs||[];const dvExpireAt=curMetaDv?curMetaDv.expireAt:'';const rows=installs.length?installs.map((it,i)=>{
return`<div style="display:flex;justify-content:space-between;align-items:center;gap:10px;padding:10px 0;border-bottom:1px dashed var(--line)">
<div>
<b>🎬 ${it.scene||'未命名场景'}</b>
<div class="mtxt">开通时间:${it.installDate||'—'}</div>
${subNoteHTML('vr',i,it.note)}
</div>
<div style="text-align:right">
<div style="margin-top:5px">${can('edit_delivery')?`<button class="btn ghost sm" onclick="editVrInstall(${i})">修改</button>`:''}${can('del_delivery_items')?`<button class="btn ghost sm" onclick="vrDelInstall(${i})">删除</button>`:''}</div>
</div>
</div>`;}).join(''):'<div class="empty">尚无场景开通记录,请在下方添加</div>';box.innerHTML=rows;const rem=document.getElementById('vrRemind');if(rem){if(!installs.length)rem.innerHTML='<span class="mtxt">暂无开通记录</span>';else if(dvExpireAt){const d=vrDaysLeft(dvExpireAt);const r=vrReminder(d);rem.innerHTML=`<b>到期时间:</b><b style="color:#4f46e5">${dvExpireAt}</b> <span class="st ${r.cls}">${r.txt}</span>`;}else rem.innerHTML='';}}
function vrAddInstall(){if(fDeny('dv_service','e')){toast('无权限:本角色已关闭「服务项目」的编辑(字段级),无法新增场景开通记录');return;}const scene=(document.getElementById('vr_scene').value||'').trim();const installDate=document.getElementById('vr_install').value;if(!scene){toast('请填写场景名称');return;}
if(!installDate){toast('请选择开通时间');return;}
vrEdit.installs=vrEdit.installs||[];vrEdit.installs.push({id:uid('vi_'),scene,installDate,note:subNoteVal('vr_note')});syncMetaStatus('vr');document.getElementById('vr_scene').value='';subNoteClear('vr_note');renderVrInstalls();toast('已添加场景开通');}
function vrDelInstall(idx){if(!can('del_delivery_items')||fDeny('dv_service','d')){toast('无权限:删除交付子记录需「删除交付子记录」权限(本角色「服务项目」字段的删除已关闭)');return;}if(!dvDelGuard())return;const it=vrEdit.installs[idx];if(!it)return;if(!confirm('确认删除场景「'+(it.scene||'')+'」的安装记录?删除后不可恢复。'))return;vrEdit.installs.splice(idx,1);syncMetaStatus('vr');renderVrInstalls();}
function editVrInstall(idx){if(!can('edit_delivery')){toast('无权限');return;}if(!dvDelGuard())return;const it=vrEdit.installs[idx];if(!it)return;openModal(`<div class="modal-head"><h3>修改场景安装记录</h3><span class="x"onclick="closeModal()">×</span></div><div class="modal-body"><div class="field"><label>场景名称</label><input id="ev_scene"value="${esc(it.scene||'')}"></div><div class="field"><label>开通时间</label><input type="date"id="ev_install"value="${it.installDate||''}"></div><div class="field"><label>备注</label><input id="ev_note"value="${esc(it.note||'')}"></div></div><div class="modal-foot"><button class="btn ghost"onclick="closeModal()">取消</button><button class="btn"onclick="saveVrInstall(${idx})">保存修改</button></div>`);}
function saveVrInstall(idx){if(fDeny('dv_service','e')){toast('无权限:本角色已关闭「服务项目」的编辑(字段级),无法修改场景开通记录');return;}const it=vrEdit.installs[idx];if(!it)return;const scene=(document.getElementById('ev_scene').value||'').trim();const installDate=document.getElementById('ev_install').value;if(!scene){toast('请填写场景名称');return;}if(!installDate){toast('请选择开通时间');return;}it.scene=scene;it.installDate=installDate;it.note=subNoteVal('ev_note');upsertDelivery({vr:{devices:(vrEdit&&vrEdit.devices)||0,installs:vrEdit.installs||[]}});}
function saveVrMeta(){syncMetaStatus('vr');const data={status:document.getElementById('dl_status').value,date:document.getElementById('dl_date').value
,operator:document.getElementById('dl_op').value.trim(),notes:document.getElementById('dl_notes').value.trim(),vr:{devices:Number(document.getElementById('vr_devices').value)||0,installs:vrEdit.installs||[]}};upsertDelivery(data);}
function renderOnlineMeta(){const box=document.getElementById('onlineList');if(!box)return;const list=onlineEdit||[];const dvExpireAt=curMetaDv?curMetaDv.expireAt:'';box.innerHTML=list.length?list.map((it,i)=>{
return`<div style="display:flex;justify-content:space-between;align-items:center;gap:10px;padding:10px 0;border-bottom:1px dashed var(--line)">
<div>
<b>🔓 第 ${i+1} 次开通</b>
<div class="mtxt">开通时间:${it.openDate||'—'}</div>
${subNoteHTML('online',i,it.note)}
</div>
<div style="text-align:right">
<div style="margin-top:5px">${can('edit_delivery')?`<button class="btn ghost sm" onclick="editOnline(${i})">修改</button>`:''}${can('del_delivery_items')?`<button class="btn ghost sm" onclick="onlineDel(${i})">删除</button>`:''}</div>
</div>
</div>`;}).join(''):'<div class="empty">尚无开通记录,请在下方添加</div>';const rem=document.getElementById('onRemind');if(rem){if(!list.length)rem.innerHTML='<span class="mtxt">暂无开通记录</span>';else if(dvExpireAt){const d=vrDaysLeft(dvExpireAt);const r=vrReminder(d);rem.innerHTML=`<b>到期时间:</b><b style="color:#4f46e5">${dvExpireAt}</b> <span class="st ${r.cls}">${r.txt}</span>`;}else rem.innerHTML='';}}
function onPreview(){const openEl=document.getElementById('on_open');const p=document.getElementById('onPreview');if(!p)return;const openDate=openEl?openEl.value:'';p.innerHTML=openDate?`<span class="mtxt">🗓 开通:${openDate} | 到期时间将在保存后按规则自动计算</span>`:'';}
function onlineAdd(){if(fDeny('dv_service','e')){toast('无权限:本角色已关闭「服务项目」的编辑(字段级),无法新增线上开通记录');return;}const openDate=document.getElementById('on_open').value;if(!openDate){toast('请选择开通时间');return;}
onlineEdit=onlineEdit||[];onlineEdit.push({id:uid('on_'),openDate,note:subNoteVal('on_note')});syncMetaStatus('online');document.getElementById('on_open').value='';subNoteClear('on_note');const p=document.getElementById('onPreview');if(p)p.innerHTML='';renderOnlineMeta();toast('已添加开通记录');}
function onlineDel(idx){if(!can('del_delivery_items')||fDeny('dv_service','d')){toast('无权限:删除交付子记录需「删除交付子记录」权限(本角色「服务项目」字段的删除已关闭)');return;}if(!dvDelGuard())return;const it=onlineEdit[idx];if(!it)return;if(!confirm('确认删除第 '+(idx+1)+' 次开通记录('+(it.openDate||'')+')?删除后不可恢复。'))return;onlineEdit.splice(idx,1);syncMetaStatus('online');renderOnlineMeta();}
function editOnline(idx){if(!can('edit_delivery')){toast('无权限');return;}if(!dvDelGuard())return;const it=onlineEdit[idx];if(!it)return;openModal(`<div class="modal-head"><h3>修改开通记录</h3><span class="x"onclick="closeModal()">×</span></div><div class="modal-body"><div class="field"><label>开通时间</label><input type="date"id="eo_open"value="${it.openDate||''}"></div><div class="field"><label>备注</label><input id="eo_note"value="${esc(it.note||'')}"></div></div><div class="modal-foot"><button class="btn ghost"onclick="closeModal()">取消</button><button class="btn"onclick="saveOnline(${idx})">保存修改</button></div>`);}
function saveOnline(idx){if(fDeny('dv_service','e')){toast('无权限:本角色已关闭「服务项目」的编辑(字段级),无法修改线上开通记录');return;}const it=onlineEdit[idx];if(!it)return;const openDate=document.getElementById('eo_open').value;if(!openDate){toast('请选择开通时间');return;}it.openDate=openDate;it.note=subNoteVal('eo_note');upsertDelivery({online:onlineEdit||[]});}
function saveOnlineMeta(){syncMetaStatus('online');const data={status:document.getElementById('dl_status').value,date:document.getElementById('dl_date').value,operator:document.getElementById('dl_op').value.trim(),notes:document.getElementById('dl_notes').value.trim(),online:onlineEdit||[]};upsertDelivery(data);}
function renderTrainMeta(){const box=document.getElementById('trainList');if(!box)return;const list=trainEdit||[];box.innerHTML=list.length?list.map((t,i)=>`
<div style="display:flex;justify-content:space-between;align-items:center;gap:10px;padding:10px 0;border-bottom:1px dashed var(--line)">
<div><b>🧑🏫 ${t.teacher||'未填老师'}</b><div class="mtxt">师训时间:${t.trainDate||'—'}</div>${subNoteHTML('train',i,t.note)}</div>
<div>${can('edit_delivery')?`<button class="btn ghost sm" onclick="editTrain(${i})">修改</button>`:''}${can('del_delivery_items')?`<button class="btn ghost sm" onclick="trainDel(${i})">删除</button>`:''}</div>
</div>`).join(''):'<div class="empty">尚无师训记录,请在下方添加</div>';}
function trainAdd(){if(fDeny('dv_service','e')){toast('无权限:本角色已关闭「服务项目」的编辑(字段级),无法新增师训记录');return;}const trainDate=document.getElementById('tr_date').value;const teacher=(document.getElementById('tr_teacher').value||'').trim();if(!trainDate){toast('请选择师训时间');return;}
if(!teacher){toast('请填写师训老师');return;}
trainEdit=trainEdit||[];trainEdit.push({id:uid('tr_'),trainDate,teacher,note:subNoteVal('tr_note')});syncMetaStatus('train');document.getElementById('tr_teacher').value='';subNoteClear('tr_note');renderTrainMeta();toast('已添加师训记录');}
function trainDel(idx){if(!can('del_delivery_items')||fDeny('dv_service','d')){toast('无权限:删除交付子记录需「删除交付子记录」权限(本角色「服务项目」字段的删除已关闭)');return;}if(!dvDelGuard())return;const it=trainEdit[idx];if(!it)return;if(!confirm('确认删除师训记录('+(it.trainDate||'')+' / '+(it.teacher||'')+')?删除后不可恢复。'))return;trainEdit.splice(idx,1);syncMetaStatus('train');renderTrainMeta();}
function editTrain(idx){if(!can('edit_delivery')){toast('无权限');return;}if(!dvDelGuard())return;const it=trainEdit[idx];if(!it)return;openModal(`<div class="modal-head"><h3>修改师训记录</h3><span class="x"onclick="closeModal()">×</span></div><div class="modal-body"><div class="row2"><div class="field"><label>师训时间</label><input type="date"id="et_date"value="${it.trainDate||''}"></div><div class="field"><label>师训老师</label><input id="et_teacher"value="${esc(it.teacher||'')}"></div></div><div class="field"><label>备注</label><input id="et_note"value="${esc(it.note||'')}"></div></div><div class="modal-foot"><button class="btn ghost"onclick="closeModal()">取消</button><button class="btn"onclick="saveTrain(${idx})">保存修改</button></div>`);}
function saveTrain(idx){if(fDeny('dv_service','e')){toast('无权限:本角色已关闭「服务项目」的编辑(字段级),无法修改师训记录');return;}const it=trainEdit[idx];if(!it)return;const trainDate=document.getElementById('et_date').value;const teacher=(document.getElementById('et_teacher').value||'').trim();if(!trainDate){toast('请选择师训时间');return;}if(!teacher){toast('请填写师训老师');return;}it.trainDate=trainDate;it.teacher=teacher;it.note=subNoteVal('et_note');upsertDelivery({trains:trainEdit||[]});}
function saveTrainMeta(){syncMetaStatus('train');const data={status:document.getElementById('
dl_status').value,date:document.getElementById('dl_date').value,operator:document.getElementById('dl_op').value.trim(),notes:document.getElementById('dl_notes').value.trim(),trains:trainEdit||[]};upsertDelivery(data);}
function nowLocal(){const d=new Date();const p=n=>String(n).padStart(2,'0');return`${d.getFullYear()}-${p(d.getMonth()+1)}-${p(d.getDate())}T${p(d.getHours())}:${p(d.getMinutes())}`;}
function hpDur(start,end){if(!start||!end)return'';const s=new Date(start),e=new Date(end);if(isNaN(s)||isNaN(e)||e<s)return'';const mins=Math.round((e-s)/60000);if(mins<60)return`共 ${mins} 分钟`;const h=Math.floor(mins/60),m=mins%60;return`共 ${h} 小时${m?(' '+m+' 分'):''}`;}
function attSrc(a,size){if(!a)return'';if(a.missing)return'';if(a.dataUrl)return a.dataUrl;if(a.file){if(!window.__attToken)return'';return'/api/attachment/'+encodeURIComponent(a.file)+'?at='+encodeURIComponent(window.__attToken||'')+(size?'&size='+size:'');}return'';}
/* ★ 2026-09-18 第 55 轮:签名收回为**无参** —— 本函数产的是「识别结果的初值」,
而识别结果里的 start/end 语义是「**反馈表上写的**开始/结束时间」(AI 读数)。
旧版接受会话时刻并把 h.start/h.end 复制进来,正是「弹窗两栏显示建档时刻」的根因。
会话时刻由 h.start/h.end 自己承载,两者**必须分开**(第 55 轮根因链第 8 环)。 */
function emptyHelpRecognized(){return{school:'',principal:'',phone:'',students:0,teachers:0,start:'',end:'',accompany:'',transport:'',fee:0,hotel:0,sessions:[{label:'邀约到校',count:0,renew:0,rate:0},{label:'第一场',count:0,renew:0,rate:0},{label:'第二场',count:0,renew:0,rate:0}],satisfactions:{like:'',recognize:'',happy:''},suggestion:'',signDate:'',status:'empty',updatedAt:'',updatedBy:''};}
function newHelpRec(start,end){return{id:uid('hp_'),start:start||nowLocal(),end:end||nowLocal(),attachments:[],note:'',recognized:emptyHelpRecognized()};}
function viewAttach(helpIdx,attIdx){var list=helpEdit||[];var h=list[helpIdx];if(!h||!h.attachments)return;var a=h.attachments[attIdx];if(!a)return;if(a.missing){toast('附件文件已丢失');return;}var src=attSrc(a);if(!src){toast('附件暂不可用');return;}if(/^image\//.test(a.type||'')||/\.(jpe?g|png|webp|gif)$/i.test(a.name||'')){openModal('<div class="modal-head"><h3>查看附件</h3><span class="x" onclick="closeModal()">×</span></div><div class="modal-body" style="text-align:center"><img src="'+src+'" style="max-width:100%;max-height:70vh;border-radius:8px"></div>');}else{window.open(src,'_blank');}}
/* ★ 2026-09-17:识别状态徽标**单一真相源** —— 复用顶层 script 里的 helpReportAttachRecognized
(⏳待确认 / ✅已确认 / ⚠️有误差 / ⚠️识别失败 + 两通道分歧明细)。
此前 renderHelpAttach(到校帮扶页与交付弹窗渲染附件的**唯一**函数)完全不看 ocr
⇒ 同一份数据,报表页看得见识别状态、到校帮扶页看不见。 */
function helpOcrBadge(a){try{return (typeof helpReportAttachRecognized==='function')?helpReportAttachRecognized(a):'';}catch(e){return '';}}
function renderHelpAttach(atts,helpIdx){if(!atts||!atts.length)return'';return`<div style="display:flex;flex-wrap:wrap;gap:6px;margin-top:6px">${atts.map((a,attIdx)=>{
if(a.missing)return`<span style="display:inline-flex;align-items:center;gap:4px;padding:4px 8px;background:#fef2f2;border:1px dashed #f87171;border-radius:6px;font-size:12px;color:#dc2626">📎 附件已丢失</span>`;
var src=attSrc(a,'preview');var _hb=helpOcrBadge(a);if(/^image\//.test(a.type||'')&&src) return `<span style="display:inline-flex;flex-direction:column;align-items:flex-start;gap:2px;max-width:130px"><a href="javascript:void(0)"onclick="viewAttach(${helpIdx},${attIdx})"title="${esc(a.name)}"style="display:inline-block"><img src="${src}"style="width:56px;height:56px;object-fit:cover;border-radius:6px;border:1px solid var(--line)"></a>${_hb}</span>`;
if(src) return `<span style="display:inline-flex;flex-direction:column;align-items:flex-start;gap:2px;max-width:170px"><a href="javascript:void(0)"onclick="viewAttach(${helpIdx},${attIdx})"style="display:inline-flex;align-items:center;gap:4px;padding:4px 8px;background:#eef2ff;border:1px solid #c7d2fe;border-radius:6px;font-size:12px;color:#3730a3;text-decoration:none">📎 ${esc(a.name)}</a>${_hb}</span>`;
return `<span style="display:inline-flex;align-items:center;gap:4px;padding:4px 8px;background:var(--soft);border:1px dashed var(--line);border-radius:6px;font-size:12px;color:#94a3b8">📎 ${esc(a.name||'附件丢失')}</span>`;
}).join('')}</div>`;}
function renderHelpMeta(){const box=document.getElementById('helpList');if(!box)return;const list=helpEdit||[];box.innerHTML=list.length?list.map((h,i)=>`
<div style="padding:10px 0;border-bottom:1px dashed var(--line)">
<div style="display:flex;justify-content:space-between;align-items:center;gap:10px">
<div><b>🤝 第 ${i+1} 次到校</b> ${hpDur(h.start,h.end)?`<span class="mtxt">${hpDur(h.start,h.end)}</span>`:''}</div>
<div>${can('edit_delivery')?`<button class="btn ghost sm" onclick="editHelp(${i})">修改</button>`:''}${can('del_delivery_items')?`<button class="btn ghost sm" onclick="helpDel(${i})">删除</button>`:''}</div>
</div>
<div class="mtxt">开始:${h.start||'—'} | 结束:${h.end||'—'}</div>
${subNoteHTML('help',i,h.note)}
${renderHelpAttach(h.attachments,i)}
</div>`).join(''):'<div class="empty">尚无到校帮扶记录,请在下方添加</div>';}
/* ===== 教材/材料领取(第 410 轮 · 5 项合并为 1 项)=====
记录结构:dv.materials = [{id,date,name,qty,note,attachments[]}](按次登记,照到校帮扶 helps 范式)。
附件复用「拍照/相册/文件 → 存库 → 服务端自动落盘 uploads/」链路;不触发 AI 识别。 */
function newMatRec(date,name,qty,note){return{id:uid('mt_'),date:date||today(),name:name||'',qty:qty||'',note:note||'',attachments:[]};}
let mtFiles=[];
function mtAddFiles(input){if(!input.files||!input.files.length)return;const arr=Array.from(input.files);mtFiles=mtFiles.concat(arr);renderMtFileList();input.value='';}
function renderMtFileList(){const box=document.getElementById('mtFileList');if(!box)return;if(!mtFiles.length){box.innerHTML='';return;}let html='<div style="display:flex;flex-wrap:wrap;gap:6px;margin-top:6px">';mtFiles.forEach((f,i)=>{html+='<div style="position:relative;display:inline-flex;align-items:center;gap:6px;padding:4px 6px;background:var(--soft);border:1px solid var(--line);border-radius:8px"><span style="font-size:14px">📎</span><span style="font-size:12px;max-width:160px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">'+esc(f.name)+'</span><button type="button" class="x" style="font-size:14px;cursor:pointer" onclick="mtRemoveFile('+i+')">×</button></div>';});html+='</div>';box.innerHTML=html;}
function mtRemoveFile(i){mtFiles.splice(i,1);renderMtFileList();}
function viewMatAttach(i,attIdx){var it=(matEdit||[])[i];if(!it)return;var a=(it.attachments||[])[attIdx];if(!a)return;viewAttachByObject(a);}
function renderMatAttach(atts,matIdx){if(!atts||!atts.length)return'';return`<div style="display:flex;flex-wrap:wrap;gap:6px;margin-top:6px">${atts.map((a,attIdx)=>{
if(a.missing)return`<span style="display:inline-flex;align-items:center;gap:4px;padding:4px 8px;background:#fef2f2;border:1px dashed #f87171;border-radius:6px;font-size:12px;color:#dc2626">📎 附件已丢失</span>`;
var src=attSrc(a,'preview');
if(/^image\//.test(a.type||'')&&src)return`<a href="javascript:void(0)"onclick="viewMatAttach(${matIdx},${attIdx})"title="${esc(a.name||'')}"style="display:inline-block"><img src="${src}"style="width:56px;height:56px;object-fit:cover;border-radius:6px;border:1px solid var(--line)"></a>`;
if(src)return`<a href="javascript:void(0)"onclick="viewMatAttach(${matIdx},${attIdx})"style="display:inline-flex;align-items:center;gap:4px;padding:4px 8px;background:#eef2ff;border:1px solid #c7d2fe;border-radius:6px;font-size:12px;color:#3730a3;text-decoration:none">📎 ${esc(a.name||'附件')}</a>`;
return`<span style="display:inline-flex;align-items:center;gap:4px;padding:4px 8px;background:var(--soft);border:1px dashed var(--line);border-radius:6px;font-size:12px;color:#94a3b8">📎 ${esc(a.name||'附件丢失')}</span>`;
}).join('')}</div>`;}
function renderMatMeta(){const box=document.getElementById('matList');if(!box)return;const list=matEdit||[];box.innerHTML=list.length?list.map((it,i)=>`
<div style="padding:10px 0;border-bottom:1px dashed var(--line)">
<div style="display:flex;justify-content:space-between;align-items:center;gap:10px">
<div><b>📦 第 ${i+1} 次领取</b> ${esc(it.name||'')}</div>
<div>${can('edit_delivery')?`<button class="btn ghost sm" onclick="editMat(${i})">修改</button>`:''}${can('del_delivery_items')?`<button class="btn ghost sm" onclick="matDel(${i})">删除</button>`:''}</div>
</div>
<div class="mtxt">时间:${it.date||'—'} | 数量:${it.qty||'—'}${it.note?(' | 备注:'+esc(it.note)):''}</div>
${renderMatAttach(it.attachments,i)}
</div>`).join(''):'<div class="empty">尚无领取记录,请在下方添加</div>';}
function matAdd(){if(!can('edit_delivery')){toast('无权限:添加领取记录需「编辑交付状态 / 记录」权限');return;}if(fDeny('dv_service','e')){toast('无权限:本角色已关闭「服务项目」的编辑(字段级),无法新增领取记录');return;}
const _nm=(document.getElementById('mt_name').value||'').trim();if(!_nm){toast('请填写领取材料的名称,如:体验教材 / 品牌手册套装');return;}
const _dt=(document.getElementById('mt_date').value||'').trim()||today();
const _qt=(document.getElementById('mt_qty').value||'').trim();
const _nt=(document.getElementById('mt_note').value||'').trim();
const files=mtFiles||[];if(files.length&&!can('upload_delivery')){toast('无权限:上传交付附件需「上传交付附件」权限,请先移除所选附件');return;}
let oversize=0;const readers=files.map(f=>helpAttDataUrl(f).then(function(x){if(!x){oversize++;return null;}return x;}));
return Promise.all(readers).then(arr=>{const atts=arr.filter(Boolean);matEdit=matEdit||[];matEdit.push(Object.assign(newMatRec(_dt,_nm,_qt,_nt),{attachments:atts}));syncMetaStatus('material');mtFiles=[];renderMtFileList();const _c=document.getElementById('mt_name');if(_c)_c.value='';const _q=document.getElementById('mt_qty');if(_q)_q.value='';const _n=document.getElementById('mt_note');if(_n)_n.value='';renderMatMeta();upsertDelivery({materials:JSON.parse(JSON.stringify(matEdit||[]))},{keep:1,msg:oversize?('已添加并保存;'+oversize+' 个文件未加入(图片过大且压缩失败,请改用截图或重拍后重传)'):'已添加并保存',err:'添加保存失败,请重试'});});}
function matDel(idx){if(!can('del_delivery_items')||fDeny('dv_service','d')){toast('无权限:删除交付子记录需「删除交付子记录」权限(本角色「服务项目」字段的删除已关闭)');return;}if(!dvDelGuard())return;const it=(matEdit||[])[idx];if(!it)return;if(!confirm('确认删除第 '+(idx+1)+' 次领取记录('+(it.name||'')+')?删除后不可恢复。'))return;matEdit.splice(idx,1);syncMetaStatus('material');renderMatMeta();upsertDelivery({materials:JSON.parse(JSON.stringify(matEdit||[]))},{keep:1,msg:'已删除该次领取记录',err:'删除保存失败,请重试'});}
function editMat(idx){if(!can('edit_delivery')){toast('无权限');return;}if(!dvDelGuard())return;const it=(matEdit||[])[idx];if(!it)return;emKeepAtts=Array.isArray(it.attachments)?JSON.parse(JSON.stringify(it.attachments)):[];emFiles=[];openModal(`<div class="modal-head"><h3>修改领取记录</h3><span class="x"onclick="matEditCancel()">×</span></div><div class="modal-body"><div class="row2"><div class="field"><label>领取时间</label><input type="date"id="em_date"value="${esc(it.date||'')}"></div><div class="field"><label>名称</label><input id="em_name"value="${esc(it.name||'')}"></div></div><div class="row2"><div class="field"><label>数量</label><input id="em_qty"value="${esc(it.qty||'')}"placeholder="如 3本"></div><div class="field"></div></div><div class="field"><label>备注</label><textarea id="em_note"rows="2">${esc(it.note||'')}</textarea></div>${can('upload_delivery')?`<div class="field"><label>附件(点 × 删除已有,可补传新的)</label><div id="emAttList"></div><div style="display:flex;gap:8px;flex-wrap:wrap;margin-top:6px"><button type="button" class="btn sm" onclick="document.getElementById('em_camera').click()">📷 拍照</button><button type="button" class="btn sm" onclick="document.getElementById('em_gallery').click()">🖼 相册/文件</button></div><input type="file" id="em_camera" accept="image/*" capture="environment" style="display:none" onchange="emAddFiles(this)"><input type="file" id="em_gallery" multiple accept="image/*,.pdf,.doc,.docx,.xls,.xlsx,.zip,.txt" style="display:none" onchange="emAddFiles(this)"><div id="emFileList" style="margin-top:8px"></div></div>`:'<div class="hint" style="margin-top:10px;color:#94a3b8">当前角色无「上传交付附件」权限,不能管理附件。</div>'}</div><div class="modal-foot"><button class="btn ghost"onclick="matEditCancel()">取消</button><button class="btn"onclick="saveMat(${idx})">保存修改</button></div>`);renderEmAttList();renderEmFileList();}
function saveMat(idx){if(fDeny('dv_service','e')){toast('无权限:本角色已关闭「服务项目」的编辑(字段级),无法修改领取记录');return;}const it=(matEdit||[])[idx];if(!it)return;const _nm=(document.getElementById('em_name').value||'').trim();if(!_nm){toast('名称不能为空');return;}it.date=(document.getElementById('em_date').value||'').trim();it.name=_nm;it.qty=(document.getElementById('em_qty').value||'').trim();it.note=(document.getElementById('em_note').value||'').trim();const files=Array.isArray(emFiles)?emFiles:[];if(files.length&&!can('upload_delivery')){toast('无权限:上传交付附件需「上传交付附件」权限,请先移除所选附件');return;}let oversize=0;const readers=files.map(f=>helpAttDataUrl(f).then(function(x){if(!x){oversize++;return null;}return x;}));return Promise.all(readers).then(arr=>{const atts=arr.filter(Boolean);it.attachments=(Array.isArray(emKeepAtts)?emKeepAtts:[]).concat(atts);const _o=delivOwner;upsertDelivery({materials:JSON.parse(JSON.stringify(matEdit||[]))},{keep:1,msg:oversize?('已保存该次领取记录;'+oversize+' 个文件未加入(图片过大且压缩失败,请改用截图或重拍后重传)'):'已保存该次领取记录',err:'保存失败,请重试',then:function(){openServiceMeta(_o.type,_o.id,_o.serviceId);}});});}
function saveMatMeta(){syncMetaStatus('material');const data={status:document.getElementById('dl_status').value,date:document.getElementById('dl_date').value,operator:document.getElementById('dl_op').value.trim(),notes:document.getElementById('dl_notes').value.trim(),materials:matEdit||[]};upsertDelivery(data);}
/* ===== 第 411 轮:修改领取记录的附件管理 + 保存/取消回列表 ===== */
let emKeepAtts=[];let emFiles=[];
function emAddFiles(input){if(!input.files||!input.files.length)return;const arr=Array.from(input.files);emFiles=emFiles.concat(arr);renderEmFileList();input.value='';}
function renderEmFileList(){const box=document.getElementById('emFileList');if(!box)return;if(!emFiles.length){box.innerHTML='';return;}let html='<div style="display:flex;flex-wrap:wrap;gap:6px;margin-top:6px">';emFiles.forEach((f,i)=>{html+='<div style="position:relative;display:inline-flex;align-items:center;gap:6px;padding:4px 6px;background:var(--soft);border:1px solid var(--line);border-radius:8px"><span style="font-size:14px">📎</span><span style="font-size:12px;max-width:160px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">'+esc(f.name)+'</span><button type="button" class="x" style="font-size:14px;cursor:pointer" onclick="emRemoveFile('+i+')">×</button></div>';});html+='</div>';box.innerHTML=html;}
function emRemoveFile(i){emFiles.splice(i,1);renderEmFileList();}
function renderEmAttList(){const box=document.getElementById('emAttList');if(!box)return;if(!emKeepAtts.length){box.innerHTML='<div class="mtxt">暂无附件</div>';return;}let html='<div style="display:flex;flex-wrap:wrap;gap:6px">';emKeepAtts.forEach((a,i)=>{var isImg=/^image\//.test(a.type||'');var src=attSrc(a,'preview');var thumb=(isImg&&src)?('<img src="'+src+'" style="width:36px;height:36px;object-fit:cover;border-radius:4px">'):'<span style="font-size:16px">📎</span>';html+='<div style="position:relative;display:inline-flex;align-items:center;gap:6px;padding:4px 6px;background:var(--soft);border:1px solid var(--line);border-radius:8px"><span style="cursor:pointer" onclick="emViewAtt('+i+')">'+thumb+'</span><span style="font-size:12px;max-width:140px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">'+esc(a.name||'')+'</span><button type="button" class="x" style="font-size:14px;cursor:pointer" onclick="emKeepDel('+i+')">×</button></div>';});html+='</div>';box.innerHTML=html;}
function emKeepDel(i){emKeepAtts.splice(i,1);renderEmAttList();}
function emViewAtt(i){var a=emKeepAtts[i];if(a)viewAttachByObject(a);}
function matEditCancel(){const _o=delivOwner;openServiceMeta(_o.type,_o.id,_o.serviceId);}
let hpFiles=[];
function hpAddFiles(input){if(!input.files||!input.files.length)return;const arr=Array.from(input.files);hpFiles=hpFiles.concat(arr);renderHpFileList();input.value='';}
function renderHpFileList(){const box=document.getElementById('hpFileList');if(!box)return;if(!hpFiles.length){box.innerHTML='';return;}let html='<div style="display:flex;flex-wrap:wrap;gap:6px;margin-top:6px">';hpFiles.forEach((f,i)=>{const isImg=/^image\//.test(f.type);const thumb=isImg?`<img src="${f.__dataUrl||""}" style="width:36px;height:36px;object-fit:cover;border-radius:4px">`:`<span style="font-size:16px">📎</span>`;html+=`<div style="position:relative;display:inline-flex;align-items:center;gap:6px;padding:4px 6px;background:var(--soft);border:1px solid var(--line);border-radius:8px">${thumb}<span style="font-size:12px;max-width:120px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(f.name)}</span><button type="button" class="x" style="font-size:14px;cursor:pointer" onclick="hpRemoveFile(${i})">×</button></div>`;});html+='</div>';box.innerHTML=html;}
function hpRemoveFile(i){hpFiles.splice(i,1);renderHpFileList();}
function qrCanvas(text, cellSize) {
cellSize = cellSize || 4;
/* ---- GF(256) ---- */
const EXP = new Array(256), LOG = new Array(256);
(function () { let x = 1; for (let i = 0; i < 256; i++) { EXP[i] = x; LOG[x] = i; x <<= 1; if (x & 0x100) x ^= 0x11d; } })();
const gmul = (a, b) => (a && b) ? EXP[(LOG[a] + LOG[b]) % 255] : 0;
/* ---- Reed-Solomon ---- */
function polyMul(a, b) { const r = new Array(a.length + b.length - 1).fill(0); for (let i = 0; i < a.length; i++) for (let j = 0; j < b.length; j++) r[i + j] ^= gmul(a[i], b[j]); return r; }
function rsGen(deg) { let p = [1]; for (let i = 0; i < deg; i++) p = polyMul(p, [1, EXP[i]]); return p; }
function rsEncode(data, ecCount) { const g = rsGen(ecCount); const msg = data.concat(new Array(ecCount).fill(0)); for (let i = 0; i < data.length; i++) { const c = msg[i]; if (c) for (let j = 0; j < g.length; j++) msg[i + j] ^= gmul(g[j], c); } return data.concat(msg.slice(data.length)); }
/* ---- Version 表(纠错 M) ---- */
const V = [null, [26, 10, 1, 16], [44, 16, 1, 28], [70, 26, 2, 44], [100, 18, 4, 34], [134, 24, 4, 44], [172, 16, 6, 34], [196, 18, 6, 44], [242, 22, 8, 54], [292, 22, 10, 54], [346, 26, 12, 64]];
const ALIGN = { 1: [], 2: [6, 18], 3: [6, 22], 4: [6, 26], 5: [6, 30], 6: [6, 34], 7: [6, 22, 38], 8: [6, 24, 42], 9: [6, 26, 46], 10: [6, 28, 50] };
/* ---- Byte 编码 ---- */
const bytes = [];
for (let i = 0; i < text.length; i++) { const c = text.charCodeAt(i); if (c < 0x80) bytes.push(c); else if (c < 0x800) { bytes.push(0xc0 | (c >> 6), 0x80 | (c & 0x3f)); } else { bytes.push(0xe0 | (c >> 12), 0x80 | ((c >> 6) & 0x3f), 0x80 | (c & 0x3f)); } }
let version = 0;
for (let v = 1; v <= 10; v++) { const cap = V[v][3] * V[v][2] - 2; if (bytes.length + 2 <= cap) { version = v; break; } }
if (!version) return null;
const totalCW = V[version][0], ecPerBlock = V[version][1], blocks = V[version][2], dataPerBlock = V[version][3];
/* ---- 位流 ---- */
const bits = [];
const put = (val, len) => { for (let i = len - 1; i >= 0; i--) bits.push((val >> i) & 1); };
put(4, 4);
put(bytes.length, version <= 9 ? 8 : 16);
for (const b of bytes) put(b, 8);
const dataBits = blocks * dataPerBlock * 8;
let term = Math.min(4, dataBits - bits.length); for (let i = 0; i < term; i++) bits.push(0);
while (bits.length % 8) bits.push(0);
const pad = [0xec, 0x11];
for (let i = 0, pi = 0; bits.length < dataBits; i++, pi = (pi + 1) % 2) put(pad[pi], 8);
const dataCW = [];
for (let i = 0; i < bits.length; i += 8) { let b = 0; for (let j = 0; j < 8; j++) b = (b << 1) | bits[i + j]; dataCW.push(b); }
const groups = [];
const shortBlocks = blocks - (dataCW.length % blocks);
const shortLen = Math.floor(dataCW.length / blocks);
let off = 0;
for (let b = 0; b < blocks; b++) { const len = b < shortBlocks ? shortLen : shortLen + 1; const dc = dataCW.slice(off, off + len); off += len; groups.push({ dc, ec: rsEncode(dc, ecPerBlock) }); }
const inter = [];
for (let i = 0; i < shortLen + 1; i++) for (const g of groups) if (i < g.dc.length) inter.push(g.dc[i]);
for (let i = 0; i < ecPerBlock; i++) for (const g of groups) inter.push(g.ec[i]);
while (inter.length < totalCW) inter.push(0);
/* ---- 矩阵 ---- */
const size = version * 4 + 17;
const mat = Array.from({ length: size }, () => new Array(size).fill(-1));
function placeFinder(r, c) {
for (let dr = -1; dr <= 7; dr++) for (let dc = -1; dc <= 7; dc++) {
const rr = r + dr, cc = c + dc;
if (rr < 0 || rr >= size || cc < 0 || cc >= size) continue;
const v = (dr >= 0 && dr <= 6 && (dc === 0 || dc === 6)) || (dc >= 0 && dc <= 6 && (dr === 0 || dr === 6)) || (dr >= 2 && dr <= 4 && dc >= 2 && dc <= 4);
mat[rr][cc] = v ? 1 : 0;
}
}
function placeAlign(r, c) {
for (let dr = -2; dr <= 2; dr++) for (let dc = -2; dc <= 2; dc++) {
const rr = r + dr, cc = c + dc;
if (rr < 0 || rr >= size || cc < 0 || cc >= size) continue;
const v = Math.max(Math.abs(dr), Math.abs(dc)) !== 1;
if (mat[rr][cc] === -1) mat[rr][cc] = v ? 1 : 0;
}
}
placeFinder(0, 0); placeFinder(size - 7, 0); placeFinder(0, size - 7);
for (let i = 8; i < size - 8; i++) { mat[6][i] = (i % 2 === 0) ? 1 : 0; mat[i][6] = (i % 2 === 0) ? 1 : 0; }
const ap = ALIGN[version];
for (const r of ap) for (const c of ap) { if ((r <= 8 && c <= 8) || (r <= 8 && c >= size - 8) || (r >= size - 8 && c <= 8)) continue; placeAlign(r, c); }
mat[size - 8][8] = 1;
const maskFns = [
(r, c) => (r + c) % 2 === 0,
(r, c) => r % 2 === 0,
(r, c) => c % 3 === 0,
(r, c) => (r + c) % 3 === 0,
(r, c) => (Math.floor(r / 2) + Math.floor(c / 3)) % 2 === 0,
(r, c) => (r * c) % 2 + (r * c) % 3 === 0,
(r, c) => ((r * c) % 2 + (r * c) % 3) % 2 === 0,
(r, c) => ((r + c) % 2 + (r * c) % 3) % 2 === 0
];
function fmtBits(mask) {
const d = (0 << 3) | mask; // 纠错 M = 00
let v = d << 10;
const gen = 0x537;
for (let i = 14; i >= 10; i--) if ((v >> i) & 1) v ^= gen << (i - 10);
return ((d << 10) | v) ^ 0x5412;
}
function placeFormat(m, mk) {
const fmt = fmtBits(mk);
const p1 = [[8, 0], [8, 1], [8, 2], [8, 3], [8, 4], [8, 5], [8, 7], [8, 8], [7, 8], [5, 8], [4, 8], [3, 8], [2, 8], [1, 8], [0, 8]];
const p2 = [[size - 1, 8], [size - 2, 8], [size - 3, 8], [size - 4, 8], [size - 5, 8], [size - 6, 8], [size - 7, 8], [8, size - 8], [8, size - 7], [8, size - 6], [8, size - 5], [8, size - 4], [8, size - 3], [8, size - 2], [8, size - 1]];
for (let i = 0; i < 15; i++) { const b = (fmt >> (14 - i)) & 1; m[p1[i][0]][p1[i][1]] = b; m[p2[i][0]][p2[i][1]] = b; }
}
function placeVersion(m) {
let vv = version;
for (let i = 0; i < 12; i++) vv = (vv << 1) ^ ((vv >> 11) * 0x1f25);
const vi = (version << 12) | vv;
for (let i = 0; i < 18; i++) {
const b = (vi >> i) & 1;
const r = Math.floor(i / 3), c = (i % 3) + (size - 11);
m[r][c] = b;
m[c][r] = b;
}
}
function penalty(m) {
let p = 0;
for (let r = 0; r < size; r++) { let run = 1; for (let c = 1; c < size; c++) { if (m[r][c] === m[r][c - 1]) run++; else { if (run >= 5) p += 3 + run - 5; run = 1; } } if (run >= 5) p += 3 + run - 5; }
for (let c = 0; c < size; c++) { let run = 1; for (let r = 1; r < size; r++) { if (m[r][c] === m[r - 1][c]) run++; else { if (run >= 5) p += 3 + run - 5; run = 1; } } if (run >= 5) p += 3 + run - 5; }
for (let r = 0; r < size - 1; r++) for (let c = 0; c < size - 1; c++) { if (m[r][c] === m[r][c + 1] && m[r][c] === m[r + 1][c] && m[r][c] === m[r + 1][c + 1]) p += 3; }
return p;
}
let bestMat = null, bestPen = Infinity;
for (let mk = 0; mk < 8; mk++) {
const m = mat.map(row => row.slice());
let di = 0, up = true;
for (let c = size - 1; c > 0; c -= 2) {
if (c === 6) c--;
for (let k = 0; k < size; k++) {
const r = up ? size - 1 - k : k;
for (let cc = 0; cc < 2; cc++) {
const col = c - cc;
if (m[r][col] !== -1) continue;
let bit = 0;
if (di < inter.length) { bit = (inter[di >> 3] >> (7 - (di & 7))) & 1; di++; }
m[r][col] = maskFns[mk](r, col) ? bit ^ 1 : bit;
}
}
up = !up;
}
placeFormat(m, mk);
if (version >= 7) placeVersion(m);
const pen = penalty(m);
if (pen < bestPen) { bestPen = pen; bestMat = m; }
}
/* ---- 画 canvas ---- */
const margin = 4;
const canvas = document.createElement('canvas');
canvas.width = canvas.height = (size + margin * 2) * cellSize;
const ctx = canvas.getContext('2d');
ctx.fillStyle = '#fff';
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = '#000';
for (let r = 0; r < size; r++) for (let c = 0; c < size; c++) {
if (bestMat[r][c]) ctx.fillRect((c + margin) * cellSize, (r + margin) * cellSize, cellSize, cellSize);
}
return canvas.toDataURL('image/png');
}
function compressImage(file,maxDim,quality,maxBytes){return new Promise((resolve)=>{const url=URL.createObjectURL(file);const img=new Image();img.onload=()=>{URL.revokeObjectURL(url);let w=img.width,h=img.height;const scale=Math.min(1,maxDim/Math.max(w,h));w=Math.round(w*scale);h=Math.round(h*scale);const cv=document.createElement('canvas');cv.width=w;cv.height=h;cv.getContext('2d').drawImage(img,0,0,w,h);let q=quality;let dataUrl=cv.toDataURL('image/jpeg',q);while(dataUrl.length>(maxBytes||200*1024)&&q>0.4){q-=0.1;dataUrl=cv.toDataURL('image/jpeg',q);}resolve({name:file.name.replace(/\.(png|webp|gif|bmp)$/i,'.jpg'),type:'image/jpeg',dataUrl});};img.onerror=()=>{URL.revokeObjectURL(url);resolve(null);};img.src=url;});}
/* 2026-09-17 修:帮扶附件「上传前压缩」统一入口。
现象:3.32MB 原图未压缩直送 ⇒ dataUrl 长度 4.64MB 超过云端识别上限 4MB ⇒ att.ocr.status=error。
根因:两条桌面入口(saveQuickHelp / helpAdd)兜底 readRaw 的上限是 10MB,远超识别上限 ⇒ 一旦浏览器压缩失败
(图片解不开 / 画布异常),就把原图原样上传 —— 识别必然失败,用户只看到一句报错且无从补救。
定式:① 先压 1024px/q0.5;② 失败换 1600px/q0.6 重试一次;③ 再失败才直读原图,且只收 ≤2.2MB
(base64 约 2.94MB,给 4MB 上限留 25% 余量);④ 三级都不成 ⇒ 返回 null,调用方计数并明确提示,绝不送一张必败的图。
非图片(PDF / Office)保持原 10MB 上限不变。 */
/* 2026-09-17 修:送模型图的规格是「读得准不准」的决定性变量(实测手写表单 2048px 显著优于 1024px)。
旧档位把整张 A4 表单压到每字约 30 像素,校长姓名这类手写关键字段必然读不准;压缩函数里另有
一道 200KB 硬上限会二次降质。现改为「上限参数化 + 最长边 2048 / 质量 0.85」。 */
var HELP_IMG_MAX_DIM=2048;var HELP_IMG_Q=0.85;var HELP_IMG_MAX_B64=2.5*1024*1024;var HELP_IMG_MAX_RAW=2.2*1024*1024;var HELP_FILE_MAX_RAW=10*1024*1024;/* ★ 第 41 轮:服务端识别硬上限(callVisionJson.maxImageBytes=4MB,判 dataUrl 长度)留余量后的前端自检线 */var HELP_IMG_SERVER_MAX=3.9*1024*1024;
/* ⚠️ 片内读图必须自持(2026-09-17 实测陷阱):index.html 的 boot 块整体包在立即执行函数里,
它内部的几个工具函数(解析、请求、把文件读成 dataURL 的那个读取器)**全都不是全局的**,
分片代码取不到(jsdom 与真机一致:typeof 查出来是 "undefined")。
曾因此让「压缩失败 → 直读原图」这一档抛 ReferenceError ⇒ promise 链断掉 ⇒ 整次保存静默失败
(非图片附件也走这一档,等于 PDF / Office 全挂)。故本片自持一个读取器,作用域内自洽。 */
function helpRawDataUrl(f){return new Promise(function(res){var r=new FileReader();r.onload=function(){res({name:f.name,type:f.type,dataUrl:r.result});};r.onerror=function(){res(null);};r.readAsDataURL(f);});}
async function helpAttDataUrl(f){if(/^image\//.test(f.type)){var comp=null;try{comp=await compressImage(f,HELP_IMG_MAX_DIM,HELP_IMG_Q,HELP_IMG_MAX_B64);}catch(e){comp=null;}if(!comp){try{comp=await compressImage(f,1600,0.6,HELP_IMG_MAX_B64);}catch(e){comp=null;}}/* ★ 2026-09-17 第 42 轮:**撤销降精度兜底**(用户拍板「压缩不能以牺牲精准度为代价」)。实测真值对照(真表肉眼钉真值 15136244276):同一张图原档 837×1280 读作 15136244276(= 真值),降到 1024px 读作 15136242476、768px 读作 15136242176、512px 读作 18137108676(完全不同的号)⇒ 降分辨率**直接读错电话**;且图糊之后模型更纠结、生成 token 由 3132 涨到 5968,耗时反而从 12.2s 升到 21.6s(又慢又错)。故此处**不再降档重压**:压缩后仍超服务端上限即返回 null,由上层明确提示重拍/截图(铁律 #64:失败路径必须给可操作出路),绝不把可能读错的低清图送进模型。 */if(comp&&comp.dataUrl&&comp.dataUrl.length<=HELP_IMG_SERVER_MAX)return comp;if(f.size>HELP_IMG_MAX_RAW)return null;}else if(f.size>HELP_FILE_MAX_RAW){return null;}var _raw=await helpRawDataUrl(f);return _raw;}
/* ---------- ★ 2026-09-17 第 41 轮:「选图即识别」预识别通道(手机端提速)----------
报障:「手机上传图片,弹窗识别信息还是太慢了」。
归因(audit.log 实测):第 37 轮已解保存、第 39 轮已解弹窗**出现**,但弹窗里的**字**仍要等主通道
一次完整调用 —— 同一张手机反馈表实测 3.9~12.2 秒,期间屏幕上是空模板 + 黄条。
为何压不短:主通道 deepseek-flash 是**推理模型**,max_tokens 同时装推理与正文,第 31/33 轮被迫
上调两次到 8000(降回去即 reasoning 吃满、正文为空 ⇒ 用户随机看到「识别失败」);stream:false
又必须整段生成完才返回 ⇒ **单次调用耗时是天花板,压不动**。
故解法不是「让识别更快」,而是「**别让用户干等**」:把识别提前到**选图那一刻**启动,
用户确认缩略图 / 填备注 / 选时间的这几秒被利用起来,点保存时结果往往已就绪 ⇒ 弹窗一开就有字。
三条纪律:① 失败一律静默降级为「未识别」,绝不阻断保存;② 结果挂成 att.ocr(与在线识别同形),
保存后 helpOcrRun 直接命中 ⇒ 零网络等待; ③ 命中缓存仍走既有确认闸门,不新增任何写侧语义。 */
/* ---------- ★ 2026-09-17 第 41 轮回归修复(用户实测报障「预识别没回来就点保存 ⇒ 识别未成功」)----------
纯字段判定:fields 里是否**至少有一个真实值**。空对象 / 全空串 / 空数组一律不算。 */
function hrFieldsFilled(fields) {
if (!fields || typeof fields !== 'object') return false;
var ks = Object.keys(fields);
for (var i = 0; i < ks.length; i++) {
var v = fields[ks[i]];
if (v === undefined || v === null || v === '') continue;
if (Object.prototype.toString.call(v) === '[object Array]') { if (!v.length) continue; return true; }
return true;
}
return false;
}
/* 附件里「有没有**真的**识别结果」—— 缓存命中判定必须用它,不得用 `att.ocr.fields` 的存在性。
根因:migrate() 会给**每个**附件补一个空壳 ocr({fields:{},status:'empty'}),而 {} 是 truthy
⇒ 从未识别过的附件被判成「已识别」⇒ 主通道零请求 + 弹窗以「已完成」态打开 ⇒ 用户看到全空白。
(服务端自检 hos* 早已把「pending 却无字段」列为异常,此处与那条口径对齐。) */
function hrOcrFilled(oc) {
if (!oc || !oc.fields) return false;
if (!oc.status || oc.status === 'empty') return false;
return hrFieldsFilled(oc.fields);
}
function hrPreCnt(p) {
if (!p || !p.fields) return 0;
var n = 0;
['school', 'principal', 'phone', 'accompany', 'transport', 'suggestion'].forEach(function (k) { if (p.fields[k]) n++; });
if (p.fields.cols && p.fields.cols.length) n = p.fields.cols.length;
var rows = 0;
(p.fields.tables || []).forEach(function (t) { rows += ((t && t.rows) || []).length; });
return n + rows;
}
/* 把预识别结果挂成**与在线识别同形**的 att.ocr(保存后 helpOcrRun 见 fields 即直接命中) */
function hrPreToOcr(x, f) {
var p = f && f.__hrPre;
if (!x || !p || p.state !== 'ok' || !hrFieldsFilled(p.fields)) return x;
x.ocr = {
provider: p.provider || 'deepseek', model: p.model || '', raw: p.raw || '',
fields: p.fields, second: p.second || null, disputes: p.disputes || [],
status: 'pending', updatedAt: new Date().toLocaleString('zh-CN'),
updatedBy: 'AI(预识别)', ms: p.ms || 0, error: '',
/* ★ 2026-09-17 第 42 轮「快档预填 + 慢档复核」:把「本次读数有没有经过精确档复核」如实落库——
'recheck' = 已由含思考精确档复核(可放心核对);
'fastOnly' = 仅快档命中、复核档失败(**可能读错**,须重点核对,不得显示成「已识别」的好消息)。
这是审计事实,不是展示糖:事后要能分辨「这条库里的读数是精确档还是快档」。 */
stage: p.stage || '',
/* ★ 第 46 轮:降级档真读到的关键字段清单(degraded 态才有值)——
与 dictFixes / ruleWarns 同款「观测位落库」:事后要能分辨「这一栏到底复核过没有」。 */
degradedKeys: (p.degradedKeys || []).slice(),
/* ★ 2026-09-18 第 53 轮(方案 A):预识别是快档自读、无降级合并 ⇒ 来源天然全是本次读取;
但仍落成**同形字段**,避免「同一个 att.ocr 有时有这些键、有时没有」(自检 #187 要求 ocr 形态齐备)。 */
srcMap: p.srcMap || null,
unverified: (p.unverified || []).slice(),
fromBase: (p.fromBase || []).slice(),
/* ★ 2026-09-17 第 43 轮:把「精确复核改了哪几栏(改前→改后)」一并落库。
第 42 轮只落了 stage ⇒ **保存之后**核对弹窗里只看得见终值,用户无从知道「这一栏刚被改过」,
而复核恰恰是在「改掉旧读数」时才最需要人盯 —— 这条提示丢了,用户核对时最容易一眼放过。
与 disputes **分开存**:disputes 是「主通道 vs 免费第二通道」的分歧,本字段是
「同一主通道的快档 vs 精确档」的分歧,来源不同不可混谈
(且 disputes 会被后台第二通道整体覆盖,见 helpOcrRun 的 hrSecondEnqueue 分支)。 */
changed: (p.changed || []).slice()
};
return x;
}
/* 关键字段变化清单(第 42 轮):比较「快档」与「精确复核档」的读数,只比那 9 个「错一个就有业务后果」的字段。
用途:徽标据此提示「复核把哪几栏改了」,让用户知道该重点核对什么。
⚠️ 只做**提示**,绝不自动裁决 —— 分歧的裁决权永远在人工确认闸门(与第二视觉通道同一条纪律)。
★ 2026-09-17 第 43 轮:由「返回 key 数组」升级为「返回 {k,label,from,to} 明细」——
此前只算得出「改了几栏」,用户看不到「从什么改成了什么」,核对时无从下手(等于白提示)。
实测依据:真表 up_c692d… 快档读 13803466669(错)、精确档读 13802866629(对),
只报「修正 1 栏」的话,用户根本不知道该重点核哪一栏。
返回值仍是数组 ⇒ 既有 `.length` 用法(徽标「修正 N 栏」)逐字兼容。 */
var HR_CHG_LABELS = {
school: '学校名称', principal: '校长姓名', phone: '联系电话',
students: '学生总数', teachers: '教师总数', fee: '路费', hotel: '住宿费',
formType: '表单类型', signDate: '签字日期'
};
function hrPreChangedKeys(a, b) {
if (!a || !b) return [];
var out = [];
Object.keys(HR_CHG_LABELS).forEach(function (k) {
var x = (a[k] === undefined || a[k] === null) ? '' : String(a[k]).replace(/[\s\u3000]/g, '');
var y = (b[k] === undefined || b[k] === null) ? '' : String(b[k]).replace(/[\s\u3000]/g, '');
if (k === 'phone' || k === 'students' || k === 'teachers' || k === 'fee' || k === 'hotel') {
x = x.replace(/[^\d]/g, ''); y = y.replace(/[^\d]/g, '');
}
if (x !== y) out.push({ k: k, label: HR_CHG_LABELS[k], from: String(a[k] == null ? '' : a[k]), to: String(b[k] == null ? '' : b[k]) });
});
return out;
}
/* 取一张图的「保存用」载荷:优先复用预识别阶段已压好的结果,省掉保存时的二次压缩 */
function hrPrePayload(f) {
if (f && f.__dataUrl) return { name: f.__name || f.name, type: f.__type || f.type, dataUrl: f.__dataUrl, __preId: f.__preId || '' };
return null;
}
/* 启动一张图的预识别(幂等:已在跑 / 已出结果则不重复发起) */
function hrPreStart(f) {
if (!f || !/^image\//.test(f.type || '')) return null; /* 非图片无图可读,不预识别 */
if (f.__hrPre && f.__hrPre.state) return null; /* 幂等闸 */
/* 图指纹:让「保存后立刻识别」那条路能找回**这次已经在飞**的识别(见 helpOcrRun 的等待分支)。
登记表挂 window 而不是 att —— save() 成功后会 loadDB() 换掉整个 DB 对象,挂在 att 上会丢。 */
if (!f.__preId) f.__preId = 'pre_' + Date.now().toString(36) + '_' + Math.random().toString(36).slice(2, 7);
f.__hrPre = { state: 'running', at: Date.now(), fields: null, ms: 0 };
window.__hrPreQ = (window.__hrPreQ || Promise.resolve()).then(function () { return hrPreRun(f); });
window.__preWait = window.__preWait || {};
window.__preWait[f.__preId] = { p: window.__hrPreQ, f: f };
try { renderHoFiles(); } catch (e0) {}
return window.__hrPreQ;
}
async function hrPreRun(f) {
try {
var rec = await helpAttDataUrl(f); /* 复用保存时的同款压缩;结果留存 ⇒ 保存时不再压第二遍 */
if (!rec || !rec.dataUrl) { f.__hrPre.state = 'failed'; f.__hrPre.error = '图片过大或格式不支持'; return; }
f.__dataUrl = rec.dataUrl; f.__name = rec.name; f.__type = rec.type; /* ★ 顺带修:缩略图此前恒空白 */
try { renderHoFiles(); } catch (e1) {}
if (!can('edit_help_report') && !can('upload_helpon')) { f.__hrPre.state = 'failed'; f.__hrPre.error = '无识别权限'; return; }
/* ── 第一档「快档预填」(2026-09-17 第 42 轮):禁推理档,实测 ~2.8s(对照含思考档中位 ~12.9s)。
它的使命只是「让用户马上看到字」,**不是最终读数** ⇒ 拿到即挂 stage='fast' 并刷徽标,
随后无条件跑第二档精确复核覆盖它。禁思考会读错难认表的电话 ⇒ 故绝不停在第一档。 */
var j = await apiPost('/api/ai/help-scan', { image: rec.dataUrl, skipSecond: true, noThink: true });
if (j && j.ok !== false && hrFieldsFilled(j.fields)) {
f.__hrPre.state = 'ok'; f.__hrPre.stage = 'fast';
f.__hrPre.fields = j.fields; f.__hrPre.raw = j.raw || '';
f.__hrPre.provider = j.provider || 'deepseek'; f.__hrPre.model = j.model || ''; f.__hrPre.ms = j.ms || 0;
f.__hrPre.msFast = j.ms || 0; f.__hrPre.second = j.second || null; f.__hrPre.disputes = j.disputes || [];
try { renderHoFiles(); } catch (e1b) {}
} else {
/* 快档失败**不是**终局:这里不置 failed(否则徽标会先闪一下「未识别」再变绿),
静默记下原因,让精确档直接兜底 —— 失败路径同样有出路(铁律 #64)。 */
f.__hrPre.fastErr = (j && (j.error || j.note)) || '识别失败';
}
/* ── 第二档「精确复核」(含思考):~12.9s,回来后覆盖快档读数,并记下「改了哪几栏」。
⚠️ 复核档失败时**必须**保留快档值并把 stage 标成 'fastOnly' —— 绝不让「只跑过快档」冒充「已复核」,
否则就等于把「拿精度换速度」偷偷写进库(用户明确划的红线:不能牺牲精准度)。
★ 2026-09-17 第 46 轮 P0-2:把快档读数作为 mergeBase 一并送出 —— 精确档若撞顶(实测含表格的
转化/续费表连跑两次都擦顶 8000),服务端会改用「只读 9 个关键字段」的降级档重试,并用这个基做
读数组合(关键字段取降级档、表格/栏目保留快档)。不送基的话,一次降级就会把刚读到的表格抹掉。 */
var _base = (f.__hrPre.state === 'ok' && f.__hrPre.fields) ? f.__hrPre.fields : null;
var j2 = await apiPost('/api/ai/help-scan', { image: rec.dataUrl, skipSecond: true, mergeBase: _base });
if (j2 && j2.ok !== false && hrFieldsFilled(j2.fields)) {
var _prev = _base;
f.__hrPre.changed = _prev ? hrPreChangedKeys(_prev, j2.fields) : [];
f.__hrPre.state = 'ok';
/* ★ 第 46 轮:三态而非两态 ——
recheck = 完整精确档复核过(最可信);
degraded = 精确档撞顶、9 个关键字段已由降级档**含推理复核**,但 cols / tables 仍只有快档读数
(比 fastOnly 可信,比 recheck 弱 ⇒ 必须自成一态,绝不混进任何一边);
fastOnly = 复核彻底失败,全部读数只有禁推理快档(最弱)。 */
f.__hrPre.stage = j2.degraded ? 'degraded' : 'recheck';
f.__hrPre.degradedKeys = j2.degraded ? (j2.degradedKeys || []) : [];
/* ★ 2026-09-18 第 53 轮(方案 A):降级档的「逐键来源」与两个来源清单一并挂在预识别对象上 ——
hrPreToOcr 会读 p.srcMap / p.unverified / p.fromBase 落库;这里不存,落库就是 null/空
⇒ 「🕗 沿用快档 N 栏」徽标永远不亮(**静默失效**,正是本轮补的第一处同类缺口)。 */
f.__hrPre.srcMap = j2.srcMap || null;
f.__hrPre.unverified = (j2.unverified || []).slice();
f.__hrPre.fromBase = (j2.fromBase || []).slice();
f.__hrPre.firstError = j2.firstError || '';
f.__hrPre.fields = j2.fields; f.__hrPre.raw = j2.raw || '';
f.__hrPre.provider = j2.provider || 'deepseek'; f.__hrPre.model = j2.model || ''; f.__hrPre.ms = j2.ms || 0;
f.__hrPre.second = j2.second || null; f.__hrPre.disputes = j2.disputes || [];
} else if (f.__hrPre.state === 'ok') {
f.__hrPre.stage = 'fastOnly';
f.__hrPre.recheckErr = (j2 && (j2.error || j2.note)) || '复核档未返回内容';
} else {
f.__hrPre.state = 'failed'; f.__hrPre.error = (j2 && (j2.error || j2.note)) || f.__hrPre.fastErr || '识别失败';
}
} catch (e) {
/* 异常也可能发生在「快档已成、复核未完」之间 —— 此时快档值仍可用,但必须如实降级为 fastOnly。 */
if (f.__hrPre.state === 'ok') { f.__hrPre.stage = 'fastOnly'; f.__hrPre.recheckErr = (e && e.message) || '网络异常'; }
else { f.__hrPre.state = 'failed'; f.__hrPre.error = (e && e.message) || '网络异常'; }
}
try { renderHoFiles(); } catch (e2) {}
}
/* 预识别状态徽标:让等待可见,也让用户知道可以放心点保存 */
function hrPreBadge(f) {
var p = f && f.__hrPre;
if (!p || !p.state) return '';
var _chip = function (color, bg, bd, txt) {
return '<span style="font-size:11px;color:' + color + ';background:' + bg + ';border:1px solid ' + bd + ';border-radius:6px;padding:1px 5px">' + txt + '</span>';
};
if (p.state === 'running') return _chip('#92400e', '#fffbeb', '#fcd34d', '🤖 识别中…');
if (p.state !== 'ok') return _chip('#991b1b', '#fef2f2', '#fca5a5', '⚠️ 未识别');
/* ★ 第 42 轮新增两态:字已出来但还没精确复核 ⇒ 必须说清「可先核对、别当真值」,
不能像过去那样直接显示「✅ 已识别」(那会把快档读数伪装成最终结果)。 */
if (p.stage === 'fast') return _chip('#1e40af', '#eff6ff', '#93c5fd', '⚡ 先填 ' + hrPreCnt(p) + ' 栏 · 复核中…');
/* 仅快档命中(复核档失败):读数可能是错的,**不得**显示成好消息。 */
if (p.stage === 'fastOnly') return _chip('#92400e', '#fffbeb', '#fcd34d', '⚡ 仅快档 ' + hrPreCnt(p) + ' 栏 · 未复核');
/* ★ 第 46 轮:降级档态 —— 精确档撞顶后改用「只读 9 个关键字段」的降级档重试成功,
关键字段已被**含推理**重读,但表格 / 栏目仍只有快档读数。比「仅快档」可信、比「已复核」弱,
故单列一态如实说明;**仍然不是「已识别」那种好消息**。 */
if (p.stage === 'degraded') return _chip('#92400e', '#fffbeb', '#fcd34d', '⚠️ 关键字段已复核 · 表格未复核 ' + hrPreCnt(p) + ' 栏');
/* 已复核:若复核改过关键字段,把「改了几栏」摆出来,让用户知道该重点核哪里。 */
var _ch = (p.changed || []).length;
if (_ch) return _chip('#92400e', '#fffbeb', '#fcd34d', '✅ 已复核 ' + hrPreCnt(p) + ' 栏 · 修正 ' + _ch + ' 栏');
return _chip('#065f46', '#ecfdf5', '#6ee7b7', '✅ 已识别 ' + hrPreCnt(p) + ' 栏');
}
let helpOnsiteCust=null;let helpOnsiteFiles=[];
function renderHelpOnsite(){view.innerHTML=`<div class="topbar"><div><h1>📷 到校帮扶</h1><p>现场拍照上传反馈表(自动压缩)</p></div></div><div id="hoBody"></div>`;renderHelpCustPicker();}
function renderHelpCustPicker(){const box=document.getElementById('hoBody');if(!box)return;box.innerHTML=`<div class="search"><input id="hoSearch" placeholder="🔍 搜索客户姓名 / 校区" oninput="hoSearch(this.value)"></div><div id="hoCustList"></div>`;hoSearch('');}
function hoSearch(v){const q=(v||'').trim().toLowerCase();const ids=new Set();DB.customers.forEach(c=>{if(isPushed('customer',c))ids.add(c.id);});const list=DB.customers.filter(c=>ids.has(c.id)&&(c.name+' '+(c.school||'')).toLowerCase().includes(q));const box=document.getElementById('hoCustList');if(!box)return;box.innerHTML=list.length?list.map(c=>`<div class="card" style="padding:14px;margin-bottom:10px;cursor:pointer" onclick="openHelpOnsiteForm('${c.id}')"><b>${esc(c.name)}</b><span class="mtxt"> · ${esc(c.school||'')}</span></div>`).join(''):'<div class="empty">暂无已推送客户(先去客户管理点「推送交付」)</div>';}
function openHelpOnsiteForm(cid){const c=DB.customers.find(x=>x.id===cid);if(!c)return;helpOnsiteCust=cid;helpOnsiteFiles=[];const box=document.getElementById('hoBody');box.innerHTML=`<div class="card"><div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px"><b>🤝 ${esc(c.name)}</b><button class="btn ghost sm" onclick="renderHelpCustPicker()">← 返回</button></div><div class="field"><label>反馈表附件(自动压缩)</label><div style="display:flex;gap:8px"><button type="button" class="btn" style="flex:1" onclick="document.getElementById('ho_camera').click()">📷 拍照</button><button type="button" class="btn ghost" style="flex:1" onclick="document.getElementById('ho_gallery').click()">🖼 相册</button></div><input type="file" id="ho_camera" accept="image/*" capture="environment" style="display:none" onchange="hoAddFiles(this)"><input type="file" id="ho_gallery" multiple accept="image/*,.pdf,.doc,.docx,.xls,.xlsx,.zip,.txt" style="display:none" onchange="hoAddFiles(this)"><div id="hoFileList" style="margin-top:8px"></div></div><div class="field"><label>备注(可选)</label><textarea id="ho_note" rows="2" placeholder="帮扶内容 / 参与人员"></textarea></div><button class="btn" style="width:100%;margin-top:10px" onclick="saveQuickHelp()">保存到校帮扶</button></div><div id="hoRecList" style="margin-top:12px"></div>`;renderHoRecList(cid);}
function hoAddFiles(input){if(!input.files||!input.files.length)return;var _nf=Array.from(input.files);helpOnsiteFiles=helpOnsiteFiles.concat(_nf);renderHoFiles();input.value='';/* ★ 第 41 轮「选图即识别」:选中的那一刻就在后台压缩 + 识别,把等待藏进用户填表的几秒里 */_nf.forEach(function(f){try{hrPreStart(f);}catch(e){}});}
function renderHoFiles(){const box=document.getElementById('hoFileList');if(!box)return;if(!helpOnsiteFiles.length){box.innerHTML='';return;}let html='<div style="display:flex;flex-wrap:wrap;gap:6px;margin-top:6px">';helpOnsiteFiles.forEach((f,i)=>{const isImg=/^image\//.test(f.type);const thumb=isImg?`<img src="${f.__dataUrl||""}" style="width:40px;height:40px;object-fit:cover;border-radius:4px">`:`<span style="font-size:18px">📎</span>`;html+=`<div style="display:inline-flex;align-items:center;gap:6px;padding:4px 6px;background:var(--soft);border:1px solid var(--line);border-radius:8px">${thumb}<span style="font-size:12px;max-width:110px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(f.name)}</span>${hrPreBadge(f)}<button type="button" style="cursor:pointer;border:0;background:none;font-size:14px" onclick="hoRemoveFile(${i})">×</button></div>`;});html+='</div>';box.innerHTML=html;}
function hoRemoveFile(i){helpOnsiteFiles.splice(i,1);renderHoFiles();}
function getMHelpCode(){try{const h=location.hash||'';const i=h.indexOf('code=');return i>=0?h.slice(i+5).split('&')[0].toUpperCase():'';}catch(e){return '';}}
/* ---------- ★ 2026-09-17:「到校帮扶」模块的识别闭环 ----------
用户报障:「到校帮扶,手机上传图片,没有出现人工修正确认的信息」。
根因:saveQuickHelp 保存返回后只 toast + 重置表单,**从不读识别结果**,界面上既无「待确认」提示、
也无任何进入人工修正的入口(唯一入口 helpReportEditModal 只挂在「帮扶成果」报表页)——
违反铁律 #63「引导必须落在用户实际必到的那一步」。
此处补齐:①保存后逐张识别并自动打开一次核对界面;②该客户已上传记录列表(缩略图 + 识别状态 + 修正/重识别/删除)。 */
function helpOnsiteAfterSave(cid){
var dv=findCustomerDelivery(cid,'help_onsite');
if(!dv||!Array.isArray(dv.helps)||!dv.helps.length)return;
var idx=dv.helps.length-1;
var rec=dv.helps[idx]||{};
var atts=(rec.attachments||[]).filter(function(a){return a&&(a.file||a.dataUrl);});
if(!atts.length)return;
if(!can('edit_help_report')){toast('已保存。识别结果需「编辑到校帮扶成果」权限才能核对修正');return;}
/* ★ 2026-09-17 第 39 轮(用户报障「手机上传图片,人工修正确认弹窗太慢了」):
原顺序是「先识别、全部跑完才弹窗」⇒ 用户要空屏干等 37s+(单张实测)。现改为**弹窗先行**:
立即弹出核对界面(识别中态:顶部状态条 + 「保存修正」置灰),识别结果回来就自动逐栏填入。
先弹的另一个好处:万一张图识别失败,界面已经在那儿了,用户可直接手填(失败也有出路)。 */
/* ★ 第 41 轮:若每张附件都已带预识别结果(选图那一刻就跑完了),直接以「已完成」态开弹窗 —— 用户零等待 */
var _allPre=atts.every(function(a){return !!(a&&hrOcrFilled(a.ocr));});
try{helpReportEditModal(dv.id,idx,{ocr:_allPre?{state:'done',n:atts.length,m:atts.length,note:'识别已在选图时完成,请直接核对'}:{state:'running',n:0,m:atts.length}});}catch(e){}
helpOcrRun(dv.id,idx,{allAtts:true,liveModal:true});
}
function renderHoRecList(cid){
var box=document.getElementById('hoRecList');if(!box)return;
var dv=findCustomerDelivery(cid,'help_onsite');
var list=(dv&&Array.isArray(dv.helps))?dv.helps:[];
if(!list.length){box.innerHTML='<div class="empty">该客户暂无到校帮扶记录</div>';return;}
var canEdit=can('edit_help_report'),canDel=can('del_delivery_items'),dvId=dv.id;
var html='<div class="section-title" style="margin:4px 0 8px">📋 该客户已上传的帮扶记录('+list.length+')</div>';
list.forEach(function(h,idx){
html+='<div style="padding:10px 0;border-bottom:1px dashed var(--line)">';
html+='<div style="display:flex;justify-content:space-between;align-items:center;gap:8px"><b>第 '+(idx+1)+' 次到校</b><span class="mtxt">'+(h.start||'—')+'</span></div>';
html+='<div class="mtxt">结束:'+(h.end||'—')+(h.note?(' | '+(h.note)):'')+'</div>';
html+=renderHelpAttach(h.attachments,idx);
var acts='';
if(canEdit)acts+='<button class="btn ghost sm" onclick="helpOnsiteEdit(\''+dvId+'\','+idx+')">✏️ 修正识别结果</button>';
if(canEdit)acts+='<button class="btn ghost sm" onclick="helpOnsiteReOcr(\''+dvId+'\','+idx+')">🤖 重新识别</button>';
if(canDel)acts+='<button class="btn ghost sm" onclick="helpOnsiteDel(\''+dvId+'\','+idx+')">删除</button>';
if(acts)html+='<div style="display:flex;gap:6px;margin-top:8px;flex-wrap:wrap">'+acts+'</div>';
html+='</div>';
});
box.innerHTML=html;
}
function helpOnsiteEdit(dvId,idx){
if(!can('edit_help_report')){toast('无权限:需「编辑到校帮扶成果」权限');return;}
helpReportEditModal(dvId,idx);
}
function helpOnsiteReOcr(dvId,idx){
if(!can('edit_help_report')){toast('无权限:需「编辑到校帮扶成果」权限');return;}
/* ★ 第 39 轮:同款「弹窗先行」——立即弹核对界面(识别中态),不再等识别跑完。
★ 第 41 轮:**必须带 force** —— 否则会命中「选图即识别」写下的缓存,
用户点「重新识别」看起来毫无反应(这是最容易漏的一处:缓存本是提速手段,却把主动重读吃掉了)。 */
helpOcrRun(dvId,idx,{allAtts:true,liveModal:true,force:true});
}
function helpOnsiteDel(dvId,idx){
if(!can('del_delivery_items')){toast('无权限:删除到校帮扶记录需「删除交付子记录」权限');return;}
var dv=(DB.deliveries||[]).find(function(x){return x.id===dvId;});
if(!dv||!Array.isArray(dv.helps)||!dv.helps[idx])return;
if(!confirm('确认删除第 '+(idx+1)+' 次到校帮扶记录?删除后不可恢复。'))return;
var _cid=dv.customerId;
dv.helps.splice(idx,1);
save().then(function(ok){if(!ok)return;toast('已删除该次到校帮扶记录');setTimeout(function(){renderHoRecList(_cid);},300);});
}
let mHelpFiles=[];
function mhKey(){return 'mh_files_'+getMHelpCode();}
function mhPersist(){try{localStorage.setItem(mhKey(),JSON.stringify(mHelpFiles.map(function(f){return {id:f.id,name:f.name,type:f.type,dataUrl:f.dataUrl,uploaded:!!f.uploaded};})));}catch(e){}}
function mhRestore(){try{var raw=localStorage.getItem(mhKey());if(!raw)return 0;var arr=JSON.parse(raw);if(!Array.isArray(arr))return 0;mHelpFiles=arr.filter(function(f){return f&&f.dataUrl;});return mHelpFiles.length;}catch(e){return 0;}}
function mhCopyLink(){var u=location.href;try{var inp=document.createElement('input');inp.value=u;document.body.appendChild(inp);inp.select();document.execCommand('copy');document.body.removeChild(inp);alert('链接已复制:\n'+u+'\n\n请到 Chrome/Safari 粘贴打开');}catch(e){prompt('请长按复制此链接:',u);}}
function renderMHelpUpload(){var code=getMHelpCode();mhRestore();var isWechat=(navigator.userAgent||'').indexOf('MicroMessenger')>=0;view.innerHTML=`<div style="max-width:560px;margin:0 auto;padding:20px 16px">
<div class="card" style="padding:20px">
<div style="text-align:center;margin-bottom:16px"><div style="font-size:40px">📷</div><h1 style="font-size:20px;margin:8px 0">到校帮扶 · 上传反馈表</h1><div class="mtxt">上传码:<b style="font-size:18px;letter-spacing:2px;color:var(--primary)">${esc(code)}</b></div></div>
${code?'':`<div class="empty">无效的上传码</div>`}
${isWechat?'<div style="background:#fef2f2;border:1px solid #dc2626;border-radius:10px;padding:14px;margin-bottom:12px;font-size:14px;color:#991b1b;line-height:1.6"><b>⚠️ 微信内拍照/相册都会丢图</b><br>这是微信浏览器的限制,不是故障。请改用系统浏览器上传:<br>① 点右上角「···」→「在浏览器打开」<br>② 或复制下方链接到 Chrome/Safari 打开<br>③ 在浏览器里点「拍照」即可正常上传<button type="button" style="display:block;margin-top:10px;width:100%;padding:11px;background:#dc2626;color:#fff;border:0;border-radius:8px;font-size:14px;font-weight:600" onclick="mhCopyLink()">📋 复制本页链接</button></div>':''}
<div style="background:#fffbeb;border:1px solid #fcd34d;border-radius:10px;padding:10px 12px;margin-top:12px;font-size:13px;color:#92400e;line-height:1.6"><b>📐 请直接用本页「拍照」上传,不要用微信转发图片</b><br>微信转发会把图片压到长边 1280px,而本系统压缩<b>不会放大</b> —— 像素在上游丢了,再强的模型也读不回来。实测:源图长边小于 1280px 时,提升送图清晰度<b>零增益</b>。</div>
<div style="display:flex;gap:10px;margin:16px 0">
<button type="button" class="btn" style="flex:1;padding:16px;font-size:16px" onclick="var ua=navigator.userAgent||'';ua.indexOf('MicroMessenger')>=0?document.getElementById('mh_gallery').click():document.getElementById('mh_camera').click()">📷 拍照</button>
<button type="button" class="btn ghost" style="flex:1;padding:16px;font-size:16px" onclick="document.getElementById('mh_gallery').click()">🖼 相册</button>
</div>
<input type="file" id="mh_camera" accept="image/*" capture="environment" style="display:none" onchange="mHelpAddFiles(this)">
<input type="file" id="mh_gallery" multiple accept="image/*,.pdf,.doc,.docx,.xls,.xlsx,.zip,.txt" style="display:none" onchange="mHelpAddFiles(this)">
<div id="mhFileList" style="margin-bottom:14px"></div>
<button class="btn" style="width:100%;padding:16px;font-size:16px" id="mhUploadBtn" onclick="mHelpUpload()">📤 上传 0 张</button>
<div id="mhResult" style="margin-top:12px;text-align:center;font-weight:600"></div>
</div>
</div>`;renderMHelpFiles();if(mHelpFiles.some(function(f){return !f.uploaded;}))setTimeout(mHelpUpload,400);}
async function mHelpAddFiles(input){if(!input.files||!input.files.length)return;var files=Array.from(input.files);input.value='';for(var i=0;i<files.length;i++){var f=files[i];var item={id:'mf_'+Date.now().toString(36)+Math.random().toString(36).slice(2,7),name:f.name,type:f.type,dataUrl:'',uploaded:false};if(/^image\//.test(f.type)){var comp=null;try{comp=await Promise.race([compressImage(f,HELP_IMG_MAX_DIM,HELP_IMG_Q,HELP_IMG_MAX_B64),new Promise(function(res){setTimeout(function(){res(null);},8000);})]);}catch(e){comp=null;}if(comp){item.name=comp.name;item.type=comp.type;item.dataUrl=comp.dataUrl;}else{var r1=await helpRawDataUrl(f);if(r1&&r1.size<2*1024*1024)item.dataUrl=r1.dataUrl;}}else{var r2=await helpRawDataUrl(f);if(r2)item.dataUrl=r2.dataUrl;}if(item.dataUrl){mHelpFiles.push(item);mhPersist();renderMHelpFiles();}}if(mHelpFiles.some(function(f){return !f.uploaded;}))mHelpUpload();}
function renderMHelpFiles(){var box=document.getElementById('mhFileList');var btn=document.getElementById('mhUploadBtn');if(btn&&!btn.disabled){btn.style.background='';if(mHelpFiles.length&&mHelpFiles.every(function(f){return f.uploaded;})){btn.textContent='✅ 已上传 '+mHelpFiles.length+' 张';}else{btn.textContent='📤 上传('+mHelpFiles.length+' 张)';}}if(!box)return;if(!mHelpFiles.length){box.innerHTML='';return;}var html='<div style="display:flex;flex-wrap:wrap;gap:8px">';mHelpFiles.forEach(function(f,i){var isImg=/^image\//.test(f.type);var upMark=f.uploaded?'<span style="position:absolute;bottom:-5px;right:-5px;background:var(--ok);color:#fff;font-size:12px;font-weight:700;width:20px;height:20px;border-radius:50%;display:flex;align-items:center;justify-content:center;border:2px solid #fff">✓</span>':'';html+=`<div style="position:relative">${isImg?`<img src="${f.dataUrl||''}" style="width:64px;height:64px;object-fit:cover;border-radius:8px;border:1px solid var(--line)">`:`<div style="width:64px;height:64px;display:flex;align-items:center;justify-content:center;background:var(--soft);border-radius:8px;border:1px solid var(--line);font-size:24px">📎</div>`}${upMark}<button type="button" style="position:absolute;top:-6px;right:-6px;width:22px;height:22px;border-radius:50%;border:0;background:var(--danger);color:#fff;font-size:13px;line-height:1" onclick="mHelpRemoveFile(${i})">×</button></div>`;});html+='</div>';box.innerHTML=html;}
function mHelpRemoveFile(i){mHelpFiles.splice(i,1);renderMHelpFiles();mhPersist();}
async function mHelpUpload(){var code=getMHelpCode();if(!code){alert('无效的上传码');return;}var pending=mHelpFiles.filter(function(f){return !f.uploaded;});if(!pending.length){var box0=document.getElementById('mhResult');if(box0)box0.innerHTML='<span style="color:var(--ok)">✅ 全部已上传</span>';return;}var btn=document.getElementById('mhUploadBtn');if(btn){btn.disabled=true;btn.textContent='上传中…';}var result=pending.map(function(f){return {clientId:f.id,name:f.name,type:f.type,dataUrl:f.dataUrl};});try{var j=await apiPost('/api/upload',{code:code,files:result});pending.forEach(function(f){f.uploaded=true;});mhPersist();var box=document.getElementById('mhResult');if(box)box.innerHTML=`<span style="color:var(--ok);font-size:16px">✅ 已上传 ${j.added} 张</span>${j.skipped?`<span class="mtxt">(${j.skipped} 张超限跳过)</span>`:''}`;renderMHelpFiles();if(btn){btn.disabled=false;btn.style.background='var(--ok)';btn.textContent='✅ 已上传 '+mHelpFiles.length+' 张';}}catch(e){var box2=document.getElementById('mhResult');if(box2)box2.innerHTML=`<span style="color:var(--danger)">上传失败:${esc(e.message||'')}</span>`;if(btn){btn.disabled=false;btn.textContent='📤 重试上传('+mHelpFiles.length+' 张)';}}}
let helpScanCode='';let helpScanTimer=null;let helpScanOwner=null;let helpScanDeadline=0;let helpScanCtxKey='';var helpScanPending={};
async function openHelpScan(type,id){if(!can('edit_helpon')){toast('无权限:到校帮扶需「编辑到校帮扶」权限');return;}try{const j=await apiPost('/api/upload-init',{ownerType:type,ownerId:id,serviceId:'help_onsite'});helpScanCode=j.code;helpScanOwner={type:type,id:id};helpScanDeadline=Date.now()+15*60*1000;helpScanCtxKey=type+'|'+id;const url=j.uploadUrl||(location.origin+'/#/m-help?code='+j.code);const qrUrl='https://api.qrserver.com/v1/create-qr-code/?size=240x240&margin=4&data='+encodeURIComponent(url);openModal(`<div class="modal-head"><h3>📱 手机扫码拍照上传</h3><span class="x" onclick="closeHelpScan()">×</span></div>
<div class="modal-body" style="text-align:center">
<div class="mtxt" style="margin-bottom:10px">手机扫码后免登录,直接拍照上传反馈表</div>
<img src="${qrUrl}" style="width:240px;height:240px;border:1px solid var(--line);border-radius:8px" onerror="this.onerror=null;this.src='https://quickchart.io/qr?size=240&text='+encodeURIComponent('${url}')">
<div style="margin-top:12px;font-size:15px">上传码:<b style="letter-spacing:2px;color:var(--primary)">${j.code}</b></div>
<div class="mtxt" style="margin-top:4px">15 分钟内有效</div>
<div id="scanUploads" style="margin-top:14px;text-align:left"></div>
</div>
<div class="modal-foot"><button class="btn ghost" onclick="closeHelpScan()">关闭</button></div>`);helpScanPoll();}catch(e){toast('生成上传码失败:'+(e.message||''));}}
function closeHelpScan(){closeModal();}
function helpScanStop(){if(helpScanTimer){clearInterval(helpScanTimer);helpScanTimer=null;}helpScanCode='';helpScanDeadline=0;}
function helpScanPoll(){if(helpScanTimer)clearInterval(helpScanTimer);helpScanTimer=setInterval(async()=>{if(!helpScanCode)return;if(helpScanDeadline&&Date.now()>helpScanDeadline){helpScanStop();return;}try{const _lite=await apiGet('/api/upload?code='+helpScanCode+'&lite=1');const _hasNew=(_lite.files||[]).some(function(x){return x.id&&!helpScanAutoMergedIds[x.id];});const j=_hasNew?await apiGet('/api/upload?code='+helpScanCode):_lite;var nf=(j.files||[]).filter(f=>!helpScanAutoMergedIds[f.id||(f.name+'|'+(f.size||0))]);nf.forEach(f=>{helpScanAutoMergedIds[f.id||(f.name+'|'+(f.size||0))]=1;});if(nf.length)autoMergeScanUploads(nf);var serverFiles=j.files||[];if(serverFiles.length&&helpEdit&&helpEdit.length){var last=helpEdit[helpEdit.length-1];if(last&&last.attachments){var updated=false;last.attachments.forEach(function(a){if(!a.__serverId)return;var sf=serverFiles.find(function(x){return x.id===a.__serverId;});if(!sf)return;if(!a.ocr){a.ocr={provider:'',raw:'',fields:{},status:'empty',updatedAt:'',updatedBy:''};}if(sf.ocr&&sf.ocr.status!==a.ocr.status){a.ocr=sf.ocr;if(sf.ocr.status==='pending'&&last.recognized&&last.recognized.status==='empty'&&sf.ocr.fields){Object.keys(sf.ocr.fields).forEach(function(k){if(k in last.recognized)last.recognized[k]=sf.ocr.fields[k];});last.recognized.status='pending';last.recognized.updatedAt=sf.ocr.updatedAt;last.recognized.updatedBy='OCR(auto)';updated=true;}}});if(updated)renderHelpMeta();}}const box=document.getElementById('scanUploads');if(box){if(j.files&&j.files.length){box.innerHTML=`<div style="margin-top:10px">📥 已收到 ${j.files.length} 张:<div style="display:flex;flex-wrap:wrap;gap:6px;margin-top:6px">${j.files.map(f=>/^image\//.test(f.type)?(f.dataUrl?`<img src="${f.dataUrl}" style="width:56px;height:56px;object-fit:cover;border-radius:6px;border:1px solid var(--line)">`:`<div style="width:56px;height:56px;background:var(--soft);border-radius:6px;display:flex;align-items:center;justify-content:center;font-size:18px;border:1px solid var(--line)">📎</div>`):`<div style="padding:6px;background:var(--soft);border-radius:6px;font-size:12px">📎 ${esc(f.name)}</div>`).join('')}</div></div>`;}else{box.innerHTML='';}}}catch(e){if(String((e&&e.message)||'').indexOf('上传码')>=0)helpScanStop();}},2000);}
var helpScanAutoMergedIds={};
async function autoMergeScanUploads(files){
if(!files.length)return;
var _key=helpScanOwner?(helpScanOwner.type+'|'+helpScanOwner.id):'';
var _mk=function(f){return {__serverId:f.id,name:f.name,type:f.type,dataUrl:f.dataUrl,ocr:f.ocr||{provider:'',raw:'',fields:{},status:'empty',updatedAt:'',updatedBy:''}};};
if(_key){var _buf=helpScanPending[_key]=helpScanPending[_key]||[];files.forEach(function(f){if(!_buf.some(function(x){return x.__serverId&&x.__serverId===f.id;}))_buf.push(_mk(f));});}
var _live=(_key&&_key===helpScanCtxKey);
if(_live){
helpEdit=helpEdit||[];
var rec;
if(helpEdit.length){rec=helpEdit[helpEdit.length-1];}
else{rec=newHelpRec();helpEdit.push(rec);}
rec.attachments=rec.attachments||[];
files.forEach(function(f){if(!rec.attachments.some(function(x){return x.__serverId&&x.__serverId===f.id;}))rec.attachments.push(_mk(f));});
renderHelpMeta();
}
toast('📥 已收到 '+files.length+' 张照片('+(_live?'云端 AI 识别中,约 10-20 秒完成':'已暂存,重新打开该客户即可看到')+')');
}
function helpScanApplyPending(){if(!helpScanCtxKey)return;var _buf=helpScanPending[helpScanCtxKey];if(!_buf||!_buf.length)return;helpEdit=helpEdit||[];var rec=helpEdit.length?helpEdit[helpEdit.length-1]:null;if(!rec){rec=newHelpRec();helpEdit.push(rec);}rec.attachments=rec.attachments||[];_buf.forEach(function(f){var _sid=f.__serverId||f.id;if(!rec.attachments.some(function(x){return x.__serverId&&x.__serverId===_sid;}))rec.attachments.push(f);});renderHelpMeta();}
async function receiveScanUploads(){if(!helpScanCode){toast('无上传码');return;}try{const j=await apiGet('/api/upload?code='+helpScanCode);const files=j.files||[];if(!files.length){toast('尚未收到照片');return;}helpEdit=helpEdit||[];let rec;if(helpEdit.length){rec=helpEdit[helpEdit.length-1];}else{rec=newHelpRec();helpEdit.push(rec);}rec.attachments=rec.attachments||[];helpScanPending[helpScanCtxKey]=helpScanPending[helpScanCtxKey]||[];for(const f of files){const _o={__serverId:f.id,name:f.name,type:f.type,dataUrl:f.dataUrl,ocr:f.ocr||{provider:'',raw:'',fields:{},status:'empty',updatedAt:'',updatedBy:''}};if(!helpScanPending[helpScanCtxKey].some(function(x){return x.__serverId&&x.__serverId===f.id;}))helpScanPending[helpScanCtxKey].push(_o);if(!rec.attachments.some(function(x){return x.__serverId&&x.__serverId===f.id;}))rec.attachments.push(_o);}renderHelpMeta();syncMetaStatus('help');helpScanStop();toast('已接收 '+files.length+' 张照片,记得点「保存」');}catch(e){toast('接收失败:'+(e.message||''));}}
function saveQuickHelp(){if(!helpOnsiteCust){toast('请选择客户');return;}if(!can('edit_helpon')){toast('无权限:到校帮扶需「编辑到校帮扶」权限');return;}const _qdv=findCustomerDelivery(helpOnsiteCust,'help_onsite');if(_qdv&&dvOtherBlocked(_qdv)){toast('无权限:这条交付记录由「'+dvRecorderText(_qdv)+'」录入,不能修改');return;}/* ★ 2026-09-18 第 55 轮:同 helpAdd —— 不再手填(见该处注释)。 */
const start=nowLocal();const end=start;const files=helpOnsiteFiles||[];let oversize=0;/* ★ 第 41 轮:优先复用「选图即识别」预压好的图(省掉保存时的二次压缩),并把预识别结果一并挂成 att.ocr ⇒ 保存后点开核对界面零网络等待 */const readers=files.map(f=>Promise.resolve(hrPrePayload(f)||helpAttDataUrl(f)).then(function(x){if(!x){oversize++;return null;}/* ★ 第 41 轮修复:图指纹随附件落库 —— 供「保存后立刻识别」找回这次已在飞的预识别 */if(!x.__preId&&f.__preId)x.__preId=f.__preId;return hrPreToOcr(x,f);}));return Promise.all(readers).then(arr=>{const atts=arr.filter(Boolean);const c=DB.customers.find(x=>x.id===helpOnsiteCust);let dv=findCustomerDelivery(helpOnsiteCust,'help_onsite');if(!dv){dv={id:uid('dl_'),serviceId:'help_onsite',customerId:helpOnsiteCust,dealerId:c?c.dealerId||'':'',status:'done',helps:[],operator:'总部交付',notes:'',opId:dvCurUserId(),opName:dvCurUserName(),opAt:dvNowStamp()};DB.deliveries.push(dv);}dv.helps=dv.helps||[];dv.helps.push(Object.assign(newHelpRec(start,end),{attachments:atts,note:(document.getElementById('ho_note').value||'').trim()}));dv.status='done';if(c&&!c.pushedDelivery){c.pushedDelivery=true;}/* ★ 2026-09-17:本次保存先不做帮扶附件识别(服务端 skipHelpOcr)—— 云端多模态单张约 10~40 秒,
同步 await 会把整个保存请求卡住(多张线性叠加),手机端只看到长时间无响应。
改为:保存立即完成 → 随后 helpOcrRun 逐张识别 → 识别完自动打开人工核对界面。 */try{window.__deferHelpOcr=true;}catch(_e){}return save().then(function(ok){try{window.__deferHelpOcr=false;}catch(_e){}if(!ok)return;toast(oversize?`已保存;${oversize} 个文件未加入(图片过大且压缩失败,请改用截图或重拍后重传)`:'已保存到校帮扶');openHelpOnsiteForm(helpOnsiteCust);setTimeout(function(){helpOnsiteAfterSave(helpOnsiteCust);},300);});});}
function helpAdd(){if(fDeny('dv_service','e')){toast('无权限:本角色已关闭「服务项目」的编辑(字段级),无法新增到校帮扶记录');return;}if(!can('upload_helpon')){toast('无权限:到校帮扶附件需「上传到校帮扶附件」权限');return;}
/* ★ 2026-09-18 第 55 轮:不再手填开始/结束时间(用户:「不需要手动填写」)。
这两个键承载的是**本次到校会话时刻**(排序 / 时长用),取建档那一刻即可;
表上写的开始/结束时间由反馈表识别读数承载(recognized.start/end),见 emptyHelpRecognized。 */
const start=nowLocal();const end=start;
const files=hpFiles||[];let oversize=0;const readers=files.map(f=>helpAttDataUrl(f).then(function(x){if(!x){oversize++;return null;}return x;}));return Promise.all(readers).then(arr=>{const atts=arr.filter(Boolean);helpEdit=helpEdit||[];helpEdit.push(Object.assign(newHelpRec(start,end),{attachments:atts,note:(document.getElementById('hp_note').value||'').trim()}));syncMetaStatus('help');hpFiles=[];renderHpFileList();document.getElementById('hp_note').value='';renderHelpMeta();toast(oversize?`已添加;${oversize} 个文件未加入(图片过大且压缩失败,请改用截图或重拍后重传)`:'已添加到校帮扶记录');});}
function helpDel(idx){if(!can('del_delivery_items')||fDeny('dv_service','d')){toast('无权限:删除交付子记录需「删除交付子记录」权限(本角色「服务项目」字段的删除已关闭)');return;}if(!dvDelGuard())return;const it=helpEdit[idx];if(!it)return;if(!confirm('确认删除第 '+(idx+1)+' 次到校帮扶记录?删除后不可恢复。'))return;helpEdit.splice(idx,1);syncMetaStatus('help');renderHelpMeta();}
function editHelp(idx){if(!can('edit_helpon')){toast('无权限');return;}if(!dvDelGuard())return;const it=helpEdit[idx];if(!it)return;openModal(`<div class="modal-head"><h3>修改到校帮扶记录</h3><span class="x"onclick="closeModal()">×</span></div><div class="modal-body"><!-- ★ 2026-09-18 第 57 轮 P1:开始/结束时间不再手填(用户裁定「不需要手动填写及重复展示」)—— 表上读数由识别结果承载,人工修正在核对界面完成。 --><div class="field"><label>备注</label><input id="eh_note"value="${esc(it.note||'')}"></div></div><div class="modal-foot"><button class="btn ghost"onclick="closeModal()">取消</button><button class="btn"onclick="saveHelp(${idx})">保存修改</button></div>`);}
function saveHelp(idx){if(fDeny('dv_service','e')){toast('无权限:本角色已关闭「服务项目」的编辑(字段级),无法修改到校帮扶记录');return;}const it=helpEdit[idx];if(!it)return;/* ★ 2026-09-18 第 57 轮 P1:本弹窗只改「备注」——开始/结束时间不再手填(用户裁定「不需要手动填写及重复展示」);it.start / it.end 是会话来校时刻,原样保留不动,表上读数由识别结果承载。 */it.note=(document.getElementById('eh_note').value||'').trim();upsertDelivery({helps:helpEdit||[]});}
function saveHelpMeta(){syncMetaStatus('help');const data={status:document.getElementById('dl_status').value,date:document.getElementById('dl_date').value,operator:document.getElementById('dl_op').value.trim(),notes:document.getElementById('dl_notes').value.trim(),helps:helpEdit||[]};upsertDelivery(data);}
function dealerName(id){const d=DB.dealers.find(x=>x.id===id);return d?fTxt('d_name',d.name):'';}
function paidOf(c){return(c.payments||[]).reduce((s,p)=>s+Number(p.amount||0),0);}
/* ★ 第 428 轮(主人 2026-10-13「统一口径:已退费/合作终止客户欠款金额归零 · crm财务收款模块」):已终止(terminated)并入归零判据(与「已退费」同为终态)—— 财务收款全站「欠款」显示(客户表 / 经销商表 / 筛选 / 导出 / AI 报表 / 悬浮卡 / 我的财务)单点收敛于本函数;「下次补款提醒」的终态文案见 nextPayReminder(第 353 轮,不受影响)。 */
function owedOf(c){if(c.refunded||c.stage==='refunded'||c.stage==='terminated')return 0;return Math.max(0,Number(c.amount||0)-paidOf(c));}
function deductOf(p){return Math.max(0,Number((p&&p.deduct)||0));}
function actualOf(p){const a=Number((p&&p.amount)||0);return Math.max(0,a-deductOf(p));}
function actualPaidOf(c){return ((c&&c.payments)||[]).reduce((s,p)=>s+actualOf(p),0);}
function deductTotalOf(c){return ((c&&c.payments)||[]).reduce((s,p)=>s+deductOf(p),0);}function refundTotalOf(c){return ((c&&c.refunds)||[]).reduce((s,r)=>s+Number(r.amount||0),0);}
function payCalcActual(pref){const g=id=>{const e=document.getElementById(id);return e?(Number(e.value)||0):0;};const a=g(pref+'amt'),d=g(pref+'deduct');const out=document.getElementById(pref+'actual');if(out)out.value=Math.max(0,a-(d>0?d:0));}
function syncCustomerStage(c){if(!c)return;if(quotaOnlyArchive(c,DB))return;if(c.refunded)return;if(c.stage==='lost'||c.stage==='refunded'||c.stage==='terminated')return;const owed=owedOf(c);const amt=Number(c.amount||0);const paid=paidOf(c);if(c.convertedDealerId&&paid<=0){c.stage='cooperated';return;}if(amt<=0)return;if(owed===0){c.stage='signed_full';/*2026-09-14 A案:交清全款不再自动勾选「已签合同」,合同是否签署须人工确认*/}else{c.stage='signed_installment';}}function customerStageToDealerStage(cs){return cs==='refunded'?'refunded':(cs==='signed_full'||cs==='signed_installment'||cs==='cooperated')?cs:(cs==='lost'||cs==='terminated')?'terminated':'lead';}function syncConvertedDealer(c){if(!c||!c.convertedDealerId)return;const d=DB.dealers.find(x=>x.id===c.convertedDealerId);if(!d)return;d.name=c.name||'';d.phone=c.phone||'';d.prov=c.prov||'';d.city=c.city||'';d.district=c.district||'';d.addr=c.addr||'';d.signDate=c.signDate||'';d.stage=customerStageToDealerStage(c.stage);d.contractSigned=!!c.contractSigned;d.contractDate=c.contractDate||'';d.amount=Number(c.amount)||0;d.payments=(c.payments||[]).map(p=>({...p}));d.nextPayAt=c.nextPayAt||'';}function syncSignDateFromPayments(e){/*2026-09-14 I15:仅在未填写达成合作日期时同步最早交款日,不覆盖人工填写值*/if(!e||e.signDate)return;if(quotaOnlyArchive(e,DB))return;const ps=e.payments||[];if(!ps.length)return;const dates=ps.map(p=>p.date).filter(Boolean).sort();if(dates.length)e.signDate=dates[0];}
function owedDeadOf(o){return !!(o&&(o.refunded||o.stage==='refunded'||o.stage==='terminated'));}/* ★ 第 586 轮(主人 2026-10-23「按 A 改」):已退费并入灰显标注 —— 第 354 轮口径是「仅已终止标注、已退费保持原样」,本轮由主人拍板翻转;判据与 owedOf 归零同源,标签优先退费(与 nextPayReminder 同序)。 */function owedDeadLabel(o){if(!o)return '';if(o.refunded||o.stage==='refunded')return '已退费';if(o.stage==='terminated')return '已终止';return '';}function owedCellHtml(o,v){if(!owedDeadOf(o))return `<b style="color:${v>0?'#dc2626':'#16a34a'}">${moneyMask(fLevel('amount'), v)}</b>`;const tx=owedDeadLabel(o);return `<b style="color:#94a3b8" title="${tx}:欠款金额已归零,不作为应收">${moneyMask(fLevel('amount'), v)}</b><span class="mtxt" style="margin-left:4px">${tx}</span>`;}
function nextPayReminder(c){if(c.refunded||c.stage==='refunded')return{text:'已退费',cls:'st-refunded'};if(c.stage==='terminated')return{text:'已终止',cls:'st-lost'};const owed=owedOf(c);if(owed<=0){const due0=c.nextPayAt;return due0?{text:'已结清 ✓ · 下次补款 '+due0,cls:'st-signed_full'}:{text:'已结清 ✓',cls:'st-signed_full'};}const due=c.nextPayAt;if(due){const days=Math.round((new Date(due+'T00:00:00')-Date.now())/86400000);if(days<0)return{text:`已逾期 ${Math.abs(days)} 天 · 待补 ${money(owed)}`,cls:'st-lost'};if(days<=7)return{text:`${days} 天后到期(${due}) · 待补 ${money(owed)}`,cls:'st-following'};return{text:`下次补款 ${due} · 待补 ${money(owed)}`,cls:'st-experienced'};}
return{text:`待补款 ${money(owed)}`,cls:'st-following'};}
function chipHit(sel,t){return !sel||sel.length===0||sel.indexOf(t)!==-1;}
function chipNext(sel,k){const a=(sel||[]).slice();const i=a.indexOf(k);if(i===-1){a.push(k);}else{a.splice(i,1);}return a;}
function chipPaint(boxId,sel){const box=document.getElementById(boxId);if(!box)return;const a=sel||[];const bs=box.getElementsByTagName('button');Array.prototype.forEach.call(bs,function(b){const k=b.getAttribute('data-k')||'';b.className='chip'+((k?a.indexOf(k)!==-1:a.length===0)?' active':'');});}
function finTypeChipsHtml(){return `<div class="chip-row" id="finTypeChips"><button type="button" class="chip${finFilter.dtype.length?'':' active'}" data-k="" onclick="finTypePick('',1)">全部类型</button>${Object.keys(DEALER_TYPES).map(k=>`<button type="button" class="chip${finFilter.dtype.indexOf(k)!==-1?' active':''}" data-k="${k}" onclick="finTypePick('${k}')">${DT(k).name}</button>`).join('')}</div>`;}
function finTypePick(k,all){finFilter.dtype=all?[]:chipNext(finFilter.dtype,k);renderFinance();}
let finFilter={dealer:'',status:'',kw:'',amtMin:'',amtMax:'',paidMin:'',paidMax:'',owedMin:'',owedMax:'',staff:'',intent:'',contract:'',install:'',refund:'',reportStart:'',reportEnd:'',signStart:'',signEnd:'',payStart:'',payEnd:'',payKw:'',dtype:[]};let finSub='customer';let finAdvOpen=false;function toggleFinAdv(){finAdvOpen=!finAdvOpen;const id=finSub==='customer'?'finAdvPanel':'finAdvPanelD';const p=document.getElementById(id);if(p)p.style.display=finAdvOpen?'block':'none';}function resetFinFilter(){finFilter={dealer:'',status:'',kw:'',amtMin:'',amtMax:'',paidMin:'',paidMax:'',owedMin:'',owedMax:'',staff:'',intent:'',contract:'',install:'',refund:'',reportStart:'',reportEnd:'',signStart:'',signEnd:'',payStart:'',payEnd:'',payKw:'',dtype:[]};finDealerKw='';renderFinance();}function finSearch(v){finFilter.kw=v;const q=(v||'').trim().toLowerCase();let hits=0;document.querySelectorAll('#finTable .fin-row').forEach(el=>{const hit=(el.getAttribute('data-search')||'').toLowerCase().includes(q);el.style.display=(!q||hit)?'':'none';if(hit&&q)hits++;});const sum=document.getElementById('finMatchInfo');if(sum){if(!q)sum.innerHTML='';else if(hits)sum.innerHTML='🔍 命中 '+hits+' 个客户';else sum.innerHTML='🔍 无匹配(关键字:'+esc(q)+')';}}
function finAmtInRange(v,min,max){if(min!==''&&v<(+min))return false;if(max!==''&&v>(+max))return false;return true;}function finAmtChange(){finFilter.amtMin=document.getElementById('fin_amtMin').value.trim();finFilter.amtMax=document.getElementById('fin_amtMax').value.trim();finFilter.paidMin=document.getElementById('fin_paidMin').value.trim();finFilter.paidMax=document.getElementById('fin_paidMax').value.trim();finFilter.owedMin=document.getElementById('fin_owedMin').value.trim();finFilter.owedMax=document.getElementById('fin_owedMax').value.trim();renderFinance();}let finDealerKw='';function dealerCustomerKeywords(did){return DB.customers.filter(c=>c.dealerId===did).map(c=>(fTxt('c_name',c.name)+' '+(fLevel('c_school')==='full'?(c.school||''):'')+' '+(fLevel('phone')==='full'?(c.phone||''):''))).join(' ');}
function applyDealerFinSearch(v){const tbody=document.querySelector('#finDealerTable tbody');if(!tbody)return;const q=(v||'').trim().toLowerCase();const sum=document.getElementById('finDealerMatchInfo');if(!q){tbody.querySelectorAll(':scope > tr.fin-row').forEach(dEl=>{dEl.style.display='';dEl.style.opacity='';});tbody.querySelectorAll(':scope > tr.fin-cust-row').forEach(cEl=>{cEl.style.display='none';cEl.style.background='';cEl.style.boxShadow='';cEl.removeAttribute('data-hit');});if(sum)sum.innerHTML='';return;}
let dealSelfHits=0,custHits=0;const firstHit=[];tbody.querySelectorAll(':scope > tr.fin-row').forEach(dEl=>{const did=dEl.getAttribute('data-did');const custEls=tbody.querySelectorAll(':scope > tr.fin-cust-row[data-parent="'+did+'"]');let anyCust=false;custEls.forEach(cEl=>{const h=(cEl.getAttribute('data-search')||'').toLowerCase().includes(q);cEl.style.display=h?'':'none';if(h){cEl.style.background='#fef9c3';cEl.style.boxShadow='inset 3px 0 0 #f59e0b,0 1px 6px rgba(245,158,11,.18)';cEl.setAttribute('data-hit','1');custHits++;anyCust=true;if(firstHit.length===0)firstHit.push(cEl);}else{cEl.style.background='';cEl.style.boxShadow='';cEl.removeAttribute('data-hit');}});const selfHit=(dEl.getAttribute('data-search')||'').toLowerCase().includes(q);const dealerSelfHit=(dEl.getAttribute('data-search-self')||'').toLowerCase().includes(q);const show=!q||selfHit||anyCust;dEl.style.display=show?'':'none';dEl.style.opacity=(q&&s
how&&!dealerSelfHit&&anyCust)?'0.65':'';if(dealerSelfHit&&q)dealSelfHits++;});if(sum){if(!q)sum.innerHTML='';else if(custHits>0&&dealSelfHits>0)sum.innerHTML=`🔍 命中 <b style="color:#b45309">${custHits}</b> 个客户 + <b style="color:#1e40af">${dealSelfHits}</b> 个经销商 <a class="fin-scroll-btn" onclick="document.querySelector('#finDealerTable tr[data-hit]')?.scrollIntoView({behavior:'smooth',block:'center'})">↪ 跳转</a>`;else if(custHits>0)sum.innerHTML=`🔍 命中 <b style="color:#b45309">${custHits}</b> 个客户 <a class="fin-scroll-btn" onclick="document.querySelector('#finDealerTable tr[data-hit]')?.scrollIntoView({behavior:'smooth',block:'center'})">↪ 跳转到首个命中</a>`;else if(dealSelfHits>0)sum.innerHTML=`🔍 命中 <b style="color:#1e40af">${dealSelfHits}</b> 个经销商(按经销商名/手机号)`;else sum.innerHTML=`<span style="color:#b91c1c">🔍 无匹配</span> <span class="mtxt" style="margin-left:6px">(关键字:${String(q).replace(/[&<>"']/g,c=>({"&":"&","<":"<",">":">",'"':""","'":"'"})[c])},试试客户姓名/校区/手机号)</span>`;}
if(firstHit.length){try{firstHit[0].scrollIntoView({behavior:'smooth',block:'center'});}catch(_){}}}
function finDealerSearch(v){finDealerKw=v;applyDealerFinSearch(v);}
let _finTimer={top:null,dealer:null};function _finDebounce(kind,v){clearTimeout(_finTimer[kind]);_finTimer[kind]=setTimeout(()=>{if(kind==='top')finSearch(v);else finDealerSearch(v);},180);}
function dtName(d){const t=d&&DT(d.type);return t?t.name.replace('经销商',''):'未知';}
function dtColor(d){const t=d&&DT(d.type);return t?t.color:'st-lost';}
function custNameHtml(c){const sch=fTxt('c_school',(c&&c.school));const nm=fTxt('c_name',(c&&c.name));return`<span class="tag" style="background:#dbeafe;color:#1e40af" title="${esc(sch)}">${sch}</span> ${nm}`;}
function custNameText(c){return`${fTxt('c_school',(c&&c.school))} ${fTxt('c_name',(c&&c.name))}`;}
const INTENT_OPTIONS=['书法','美术'];function intentOpts(sel){const arr=[['','未确定'],...INTENT_OPTIONS.map(x=>[x,x])];return arr.map(([k,v])=>`<option value="${k}" ${sel===k?'selected':''}>${v}</option>`).join('');}
function intentArr(c){return Array.isArray(c&&c.intent)?c.intent:(c&&c.intent?[c.intent]:[]);}
/* ★ 第 492 轮(主人 2026-10-17 拍板 A 方案一期+二期):客户「订单种类」唯一判据。
返回 exp(纯体验课订单)/ pkg(年课包订单)/ course(普通课程订单)/ none(未建单)。
判据优先级:pkgId=pkg_annual > itemIds 全为按名额项目 > 有 itemIds > 无 itemIds。
⚠️ 年课包订单建单时不落 itemIds(第 456 轮设计),故必须靠 pkgId 识别(第 492 轮新增字段)。
⚠️ 本区是 HTML 转义区:`<` 写 <、`&&` 写 &&,否则浏览器把比较符当标签、整段脚本报废(本轮实测踩过)。 */
function orderKindOf(c){
if(!c)return 'none';
if(String(c.pkgId||'')==='pkg_annual')return 'pkg';
var ids=Array.isArray(c.itemIds)?c.itemIds:[];
if(!ids.length)return 'none';
var its=(DB&&DB.items)||[];
for(var i=0;i<ids.length;i++){
var it=null;
for(var j=0;j<its.length;j++){if(its[j]&&String(its[j].id)===String(ids[i])){it=its[j];break;}}
if(!(it&&it.trackQuota===true))return 'course';
}
return 'exp';
}
function orderKindTextOf(c){var k=orderKindOf(c);return k==='exp'?'体验课':(k==='pkg'?'年课包':(k==='course'?'课程订单':'未建单'));}
/* ★ 第 612 轮(主人 2026-10-25 A 方案:按对应项目显示):订单/项目统一显示取数(唯一出口)。
背景:年课包订单按规则不落 itemIds,只认 itemIds 的取数对它恒为空,
收款记录 / 客户与报备导出 / 关联档案卡 / 登记交款弹窗因此全显示「—」。
口径:年课包 ⇒ 「年课包」;其余 ⇒ itemNamesOf(2 级项目名 + 份数);都空 ⇒ 空串。
本区是 HTML 转义区,新增代码沿用同形态。 */
function orderItemsTextOf(c){
if(!c)return '';
if(String(c.pkgId||'')==='pkg_annual')return '年课包';
return itemNamesOf(c);
}
function orderKindOpts(){
var cur=custFilter.orderKind||'';
var a=[['','全部'],['exp','🎟 体验课'],['pkg','📦 年课包'],['course','📚 课程订单'],['none','— 未建单']];
return a.map(function(x){return '<option value="'+x[0]+'"'+(cur===x[0]?' selected':'')+'>'+x[1]+'</option>';}).join('');
}
function custOrderCellHtml(c){
var k=orderKindOf(c);
if(k==='none')return '<span class="mtxt">—</span>';
var nm=itemNamesOf(c);
/* ★ 第 495 轮(主人 2026-10-17「体验课重复显示了,去除下边的体验课」):项目名与徽标文案相同 ⇒ 不再重复显示。
判据 = nm 与 orderKindTextOf(c) 相同(徽标写的就是这个字);项目名有额外信息(如「体验课(书法)」「体验课×2」)仍显示。 */
/* ★ 第 503 轮(主人 2026-10-17「改按 B 方案」→「列太宽,全部改成悬停看全文」→「只留徽标」):
整格只显示一个徽标 —— 行高恒定(约 40px)、列宽约 94px;项目名 / 年课包名 / 档位提醒全部进 title,悬停看全文。 */
var _pk=(k==='pkg')?(DB.packages||[]).filter(function(x){return x&&x.id==='pkg_annual';})[0]:null;
var _qp=(k==='exp'&"aTierPendingOf(c))?true:false;
var _tip=(nm||((_pk&&_pk.name)||'')||orderKindTextOf(c))+(_qp?' · ⚠ 档位待确认':'');
var _po='<span class="ord-cell" style="display:inline-block;max-width:96px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;vertical-align:middle" title="'+esc(_tip)+'">';
if(k==='exp')return _po+'<span class="pill" style="background:#dcfce7;color:#15803d">🎟 体验课'+(_qp?' ⚠':'')+'</span></span>';
if(k==='pkg')return _po+'<span class="pill" style="background:#dbeafe;color:#1d4ed8">📦 年课包</span></span>';
return _po+'<span class="pill" style="background:#ede9fe;color:#6d28d9">📚 课程订单</span></span>';
}
/* ★ 第 493 轮(主人 2026-10-17「走 A,3.加」):客户详情「订单/项目」卡片。
与客户列表同一判据 orderKindOf(唯一真源),只读展示;建单入口复用 openNewOrder。
⚠️ 本区是 HTML 转义区:`<` 写 <、`&&` 写 &&。 */
function custOrderCardHtml(c){
var k=orderKindOf(c);
var nm=itemNamesOf(c);
var h='';
if(k==='none')h='<span class="mtxt">未建订单 — 可从下方建立</span>';
else if(k==='exp'){
var q=quotaOf(c);
h='<span class="pill" style="background:#dcfce7;color:#15803d">🎟 体验课</span>'
+(nm&&nm!==orderKindTextOf(c)?('<div class="mtxt" style="margin-top:4px">'+esc(nm)+'</div>'):'')
+(q?('<div class="mtxt">名额 共 '+Number(q.total||0)+' · '+(quotaTierPendingOf(c)?'<span style="color:#b45309">待定档(请先选择体验课档位)</span>':(quotaPaidEnoughOf(c)?('剩 '+quotaRemainOf(c)):'<span style="color:#b45309">待交款</span>'))+'</div>'):'')
+(quotaTierPendingOf(c)&&can('edit_customers')?('<div style="margin-top:6px"><button class="btn sm" onclick="openQuotaTierPick(\''+esc(c.id)+'\')">🎟 选择体验课档位</button></div>'):'');
}
else if(k==='pkg'){
var p=(DB.packages||[]).find(function(x){return x&&x.id==='pkg_annual';});
h='<span class="pill" style="background:#dbeafe;color:#1d4ed8">📦 年课包</span>'
+(p?('<div class="mtxt" style="margin-top:4px">'+esc(p.name||'')+'</div>'):'');
}
else h='<span class="pill" style="background:#ede9fe;color:#6d28d9">📚 课程订单</span>'
+(nm?('<div class="mtxt" style="margin-top:4px">'+esc(nm)+'</div>'):'');
if(can('edit_customers'))h+='<div style="margin-top:6px;display:flex;gap:6px;flex-wrap:wrap"><button class="btn ghost sm" onclick="openNewOrder(\''+esc(c.id)+'\')">+ 新增订单</button><button class="btn ghost sm" onclick="openOrderSetup(\''+esc(c.id)+'\')">'+(k==='none'?'✏️ 设置本单订单':'✏️ 修改订单项目')+'</button></div>';
/* ★ 第 599 轮(主人 2026-10-23 A 方案):本单「合同要求」—— 跟随项目 / 无需签 / 必须签。
只决定这单进不进「已成交未签合同」待签列表;不动分润、不动交付门槛、不改阶段。 */
if(can('edit_customers')){
var _nst=(c.noSign===true)?'none':((c.noSign===false)?'required':'inherit');
var _nlab=(_nst==='none')?'🚫 无需签(本单)':(_nst==='required'?'📝 必须签(本单)':'🔗 跟随项目');
var _nex=custNoSignExempt(c);
h+='<div class="mtxt" style="margin-top:8px">合同要求:<b>'+_nlab+'</b>'+(_nex?' · 不进「已成交未签合同」':' · 进待签列表')+'</div>'
+'<div style="margin-top:4px;display:flex;gap:6px;flex-wrap:wrap">'
+'<button class="btn ghost sm" onclick="setCustNoSign(\''+esc(c.id)+'\',\'inherit\')">跟随项目</button>'
+'<button class="btn ghost sm" onclick="setCustNoSign(\''+esc(c.id)+'\',\'none\')">无需签</button>'
+'<button class="btn ghost sm" onclick="setCustNoSign(\''+esc(c.id)+'\',\'required\')">必须签</button>'
+'</div>';
}
return h;
}
/* ★ 第 594 轮(主人 2026-10-23「B」):成交档案补录 / 修改「订单项目」。
背景:档案经「编辑客户」改成已签约 + 填金额时不会落 pkgId / itemIds ⇒
列表「订单/项目」显示「—」、详情交付清单为空、无法推交付;成交后原本没有任何补录入口。
本入口只写订单项目三键(pkgId / itemIds / itemQty)—— 不碰合同金额、交款记录、分润设定。
体验课(trackQuota)不走这里,它用「🎟 选择体验课档位」(名额按合同额算)。
⚠️ 本区是 HTML 转义区:`<` 写 <、`&&` 写 &&。 */
function orderSetupApply(c,mode,ids,qty){
if(!c)return false;
if(mode==='pkg'){c.pkgId='pkg_annual';delete c.itemIds;delete c.itemQty;return true;}
var arr=(Array.isArray(ids)?ids:[]).filter(function(x){return !!x;});
if(!arr.length)return false;
var seen={},keep=[];
arr.forEach(function(id){if(!seen[id]){seen[id]=1;keep.push(id);}});
c.pkgId='';c.itemIds=keep;var q={};
keep.forEach(function(id){var n=Math.floor(Number((qty||{})[id])||1);q[id]=(n>0)?n:1;});
c.itemQty=q;return true;
}
function orderSetupRecalc(){
var el=document.querySelector('input[name="os_mode"]:checked');
var mv=el?String(el.value||''):'';
var box=document.getElementById('os_course_box');
var eb=document.getElementById('os_exp_box');
if(box)box.style.display=(mv==='pkg'||mv==='exp')?'none':'block';
if(eb)eb.style.display=(mv==='exp')?'block':'none';
orderSetupExpCalc();
}
function orderSetupChk(el){
var q=(el&&el.parentNode)?el.parentNode.querySelector('input[name="os_qty"]'):null;
if(q)q.disabled=!el.checked;
}
/* ★ 第 627 轮(主人 2026-10-26「A」):体验课订单统一入口 —— 未建单 / 课程订单 / 体验课订单都能在这里设成体验课。
背景:旧版「设置本单订单」刻意排除 trackQuota 项目(x.trackQuota!==true)+ 已是体验课时直接 toast 早退
⇒ 邢军(298 体验课被记成全款成交)与「想换档位的体验课」两头都没有入口。
一次写对六件事:项目 / 份数 / 合同额(单价×份数)/ 阶段归位 / 清签约日 / 无同组成交则收编号。
「无同组成交则收编号」与自检 cust_no 同源(第 579 轮口径):组内有成交档案 ⇒ 编号保留(金叶翠形态)。
⚠️ 本区是 HTML 转义区:比较符与逻辑与一律用 HTML 实体形态,禁写裸符号。 */
function expTierOptsOf(c){
var cur=(c&&Array.isArray(c.itemIds)&&c.itemIds.length)?String(c.itemIds[0]):'';
var out=[];
(DB.items||[]).forEach(function(x){
if(!x||x.trackQuota!==true)return;
if(x.hidden!==true||String(x.id)===cur)out.push(x);
});
return out;
}
function applyExpCourseOrder(c,iid,qty){
if(!c||!iid)return 'bad';
var it=(DB.items||[]).find(function(x){return x&&String(x.id)===String(iid);});
if(!it)return 'bad';
var n=Math.floor(Number(qty)||1);if(!(n>0))n=1;
var pr=Number(it.unitPrice)||0;
if(!(pr>0))return 'noprice';
var amt=Math.round(pr*n*100)/100;
var _amtOk627=(canField('amount','e')===true);
if(amt!==Number(c.amount||0)&&!_amtOk627)return 'noamt';
c.pkgId='';c.itemIds=[String(iid)];
var q={};q[String(iid)]=n;c.itemQty=q;
c.amount=amt;
c.contractSigned=false;c.signDate='';
var usedN=0;
if(c.quota&&Array.isArray(c.quota.used))usedN=c.quota.used.reduce(function(s,u){return s+Math.max(0,Math.floor(Number(u&&u.n)||0));},0);
c.stage=(usedN>0)?'experienced':'reported';
delete c.quotaTierPending;
return '';
}
function orderSetupExpCalc(){
var el=document.querySelector('input[name="os_exp_tier"]:checked');
var nEl=document.getElementById('os_exp_qty');
var box=document.getElementById('os_exp_sum');
if(!box)return;
if(!el){box.innerHTML='';return;}
var pr=Number(el.getAttribute('data-price'))||0;
var n=Math.floor(Number(nEl&&nEl.value)||1);if(!(n>0))n=1;
var cur=Number(box.getAttribute('data-cur'))||0;
var amt=Math.round(pr*n*100)/100;
if(!(pr>0)){box.innerHTML='<span style="color:#b45309">该档位未设置单价,无法按金额核算名额</span>';return;}
var txt='合同额 = <b>¥'+amt+'</b>(单价 ¥'+pr+' × '+n+' 份;名额 '+n+' 个)';
if(amt!==cur)txt+='<br><span style="color:#b45309">本档案当前 ¥'+cur+' → 保存后改为 ¥'+amt+'</span>';
box.innerHTML=txt;
}
function openOrderSetup(cid){
var c=(DB.customers||[]).find(function(x){return x&&x.id===cid;});
if(!c){toast('未找到客户');return;}
if(!can('edit_customers')){toast('无权限:需「编辑客户」权限');return;}
var k=orderKindOf(c);
var curIds=Array.isArray(c.itemIds)?c.itemIds:[];
var curQty=c.itemQty||{};
var items=(DB.items||[]).filter(function(x){return x&&x.trackQuota!==true&&(x.hidden!==true||curIds.indexOf(String(x.id))>=0);});
var rows=items.map(function(x){
var on=curIds.indexOf(String(x.id))>=0;
var q=Math.floor(Number(curQty[x.id])||1);if(!(q>0))q=1;
return '<label style="display:flex;align-items:center;gap:8px;padding:6px 2px;border-bottom:1px dashed var(--line)">'
+'<input type="checkbox" name="os_item_chk" value="'+esc(x.id)+'" '+(on?'checked':'')+' style="width:auto" onchange="orderSetupChk(this)">'
+'<span style="flex:1">'+esc(x.name||x.id)+'</span>'
+'<span class="mtxt">份数</span>'
+'<input type="number" min="1" step="1" name="os_qty" data-iid="'+esc(x.id)+'" value="'+q+'" style="width:72px" '+(on?'':'disabled')+'>'
+'</label>';
}).join('');
var tiers=expTierOptsOf(c);
var curTier=(k==='exp'&&curIds.length)?String(curIds[0]):(tiers.length?String(tiers[0].id):'');
var curTierQty=(k==='exp')?(Math.floor(Number(curQty[curTier])||1)||1):1;
var tierRows=tiers.map(function(x){
var pr=Number(x.unitPrice)||0;
return '<label style="display:flex;align-items:center;gap:8px;padding:8px 2px;border-bottom:1px dashed var(--line)">'
+'<input type="radio" name="os_exp_tier" value="'+esc(x.id)+'" data-price="'+pr+'" '+(String(x.id)===curTier?'checked':'')+' style="width:auto" onchange="orderSetupExpCalc()">'
+'<span style="flex:1">'+esc(x.name||x.id)+'</span>'
+'<span class="mtxt">'+(pr>0?('¥'+pr):'单价未设置')+'</span></label>';
}).join('');
openModal(`
<div class="modal-head"><h3>✏️ 设置本单订单 · ${esc(custNameText(c))}</h3><span class="x" onclick="closeModal()">×</span></div>
<div class="modal-body">
<div class="warn-box">本单「订单/项目」当前:<b>${orderKindTextOf(c)}</b>。补上或改完后:客户列表会显示订单种类、客户详情能看到服务清单、可以推送交付。<br><b>选「课程项目 / 年课包」只改订单项目 —— 不改合同金额、不改交款记录、不影响分润。</b><br>选「体验课」会按档位单价重算合同额,并把档案归位为「已报备 / 已体验」(体验课不计成交、不发客户编号)。</div>
<div style="margin:12px 0">
<label style="display:block;padding:6px 0;cursor:pointer"><input type="radio" name="os_mode" value="pkg" ${k==='pkg'?'checked':''} style="width:auto" onchange="orderSetupRecalc()"> 📦 年课包(12 项服务打包 · 不落单项)</label>
<label style="display:block;padding:6px 0;cursor:pointer"><input type="radio" name="os_mode" value="course" ${(k==='course'||k==='none')?'checked':''} style="width:auto" onchange="orderSetupRecalc()"> 📚 课程项目(可多选,可填份数)</label>
<label style="display:block;padding:6px 0;cursor:pointer"><input type="radio" name="os_mode" value="exp" ${k==='exp'?'checked':''} style="width:auto" onchange="orderSetupRecalc()"> 🎟 体验课(按名额 · 不计成交)</label>
</div>
<div id="os_course_box" style="border:1px solid var(--line);border-radius:8px;padding:6px 10px;max-height:46vh;overflow:auto">${rows||'<span class="mtxt">项目目录里没有可售项目</span>'}</div>
<div id="os_exp_box" style="display:none;border:1px solid var(--line);border-radius:8px;padding:8px 10px">
<div class="mtxt" style="margin-bottom:4px">体验课档位</div>
${tierRows||'<span class="mtxt">项目目录里没有体验课档位,请先在「项目目录」添加</span>'}
<div style="display:flex;align-items:center;gap:8px;margin-top:10px"><span class="mtxt">份数</span><input type="number" id="os_exp_qty" min="1" step="1" value="${curTierQty}" style="width:80px" oninput="orderSetupExpCalc()"><span class="mtxt">份(1 份 = 1 个名额)</span></div>
<div class="mtxt" id="os_exp_sum" data-cur="${Number(c.amount)||0}" style="margin-top:8px"></div>
</div>
<div class="hint">体验课 = 先交款再参加、按名额消耗、不计成交、不发客户编号;已有消耗记录的档案改档位时阶段保持「已体验」。</div>
</div>
<div class="modal-foot"><button class="btn" onclick="saveOrderSetup('${esc(c.id)}')">💾 保存</button><button class="btn ghost" onclick="closeModal()">取消</button></div>`);
orderSetupRecalc();
}
function saveOrderSetup(cid){
var c=(DB.customers||[]).find(function(x){return x&&x.id===cid;});
if(!c){toast('未找到客户');return;}
if(!can('edit_customers')){toast('无权限:需「编辑客户」权限');return;}
var el=document.querySelector('input[name="os_mode"]:checked');
if(!el){toast('请先选择订单类型:年课包 / 课程项目 / 体验课');return;}
var mode=String(el.value||'');
if(mode==='exp'){
var t=document.querySelector('input[name="os_exp_tier"]:checked');
if(!t){toast('请先选择一个体验课档位');return;}
var iid=String(t.value||'');
var qEl=document.getElementById('os_exp_qty');
var qn=Math.floor(Number(qEl&&qEl.value)||1);if(!(qn>0))qn=1;
var rc=applyExpCourseOrder(c,iid,qn);
if(rc==='noprice'){toast('该体验课档位未设置单价,无法按金额核算名额,请先在「项目目录」补单价');return;}
if(rc==='noamt'){toast('无权限:改体验课档位会重算合同额,需「合同金额」编辑权');return;}
if(rc){toast('保存失败:订单项目未写入,请联系管理员');return;}
var _nm=itemNamesOf(c),_amt=Number(c.amount)||0;
save().then(function(ok){if(!ok)return;closeModal();render();toast('已设为体验课:'+_nm+'(合同额 ¥'+_amt+' · 名额 '+qn+' 个)');});
return;
}
var ids=[],qty={};
Array.prototype.forEach.call(document.querySelectorAll('input[name="os_item_chk"]:checked'),function(x){ids.push(String(x.value||''));});
Array.prototype.forEach.call(document.querySelectorAll('input[name="os_qty"]'),function(x){qty[String(x.getAttribute('data-iid')||'')]=Number(x.value||1);});
if(mode==='course'&&!ids.length){toast('请至少勾选一个课程项目(或改选「📦 年课包」)');return;}
if(!orderSetupApply(c,mode,ids,qty)){toast('保存失败:订单项目为空');return;}
save().then(function(ok){if(!ok)return;closeModal();render();toast(mode==='pkg'?'已设置本单订单:年课包(12 项打包;金额与交款未动)':'已设置本单订单:'+itemNamesOf(c)+'(金额与交款未动)');});
}
function intentTag(c){const a=intentArr(c);if(!a.length)return '<span class="mtxt">未确定</span>';if(fLevel('c_intent')!=='full')return '<span class="mtxt">'+fTxt('c_intent',a)+'</span>';return a.map(v=>`<span class="pill" style="background:#ede9fe;color:#6d28d9;margin-right:4px">${v}</span>`).join('');}
function intentMultiHTML(id,sel){const a=Array.isArray(sel)?sel:(sel?[sel]:[]);const boxes=INTENT_OPTIONS.map(v=>{const ck=a.includes(v)?'checked':'';return `<label class="intent-check"><input type="checkbox" name="${id}_chk" value="${v}" ${ck}><span>${v}</span></label>`;}).join('');return `<div class="intent-multi" id="${id}_multi">${boxes}<div class="hint">可多选;不勾选即视为「未确定」</div></div>`;}
function readIntentMulti(id){return Array.from(document.querySelectorAll('input[name="'+id+'_chk"]:checked')).map(x=>x.value);}
function intentSingleHTML(id,sel){const a=Array.isArray(sel)?sel:(sel?[sel]:[]);const cur=a[0]||'';const warn=a.length>1?`<div class="hint" style="color:#d97706">⚠ 历史多选值「${a.join(' + ')}」:本项目已改为单选,保存时只保留你选中的一项</div>`:'';return `<select id="${id}">${intentOpts(cur)}</select>${warn}`;}
function readIntentSingle(id){const el=document.getElementById(id);const v=el?el.value:'';return v?[v]:[];}
function dealerNameHtml(d){const r=fTxt('d_type',((d&&d.region)||''));const nm=fTxt('d_name',(d&&d.name));return`<span class="tag" style="background:#fef3c7;color:#92400e">${r}</span> <b>${nm}</b>`;}
function getFinEntity(type,id){return type==='d'?DB.dealers.find(x=>x.id===id):DB.customers.find(x=>x.id===id);}
function refreshFin(type){if(type==='d'){if(document.querySelector('#dealerBody'))dp_goto('finance');else if(document.querySelector('#finDealerTable'))renderFinance();}else if(document.querySelector('#finTable'))renderFinance();
/* ★ 第 457 轮:客户列表(全部客户 / 已成交未签合同子页)也承载交款数据 ⇒ 登记完刷新本页,
否则列表「已交款 / 欠款」列停在旧值(refreshFin 原只在财务页生效,在客户列表里是空操作)。
放在这里而非 addPayment 尾部 —— 删/改交款、退费登记等所有走 refreshFin 的路径一并受益。 */
else if(document.querySelector('#custTbl')||document.querySelector('#custDealTbl'))rerenderKeepFocus(renderCustomers);}
function renderFinance(){_snapScroll();const seg=`<div class="seg" style="margin:0 0 18px">
<button class="seg-btn ${finSub==='customer'?'active':''}" onclick="finSub='customer';renderFinance()">👥 转介绍客户财务</button>
<button class="seg-btn ${finSub==='dealer'?'active':''}" onclick="finSub='dealer';renderFinance()">🏢 经销商财务</button>
<button class="seg-btn ${finSub==='records'?'active':''}" onclick="finSub='records';renderFinance()">📄 收款记录</button>
</div>`;if(finSub==='dealer')renderFinDealer(seg);else if(finSub==='records')renderFinRecords(seg);else renderFinCustomer(seg);bindSmartSelect('finFilter_dealer_wrap',v=>{finFilter.dealer=v;renderFinance();});bindSmartSelect('finFilter_staff_wrap',v=>{finFilter.staff=v;renderFinance();});requestAnimationFrame(function(){_restoreScroll();});}
let finRecKw='',finRecAdv=false,finRecTimer=null,finRecRange={dateStart:'',dateEnd:'',amtMin:'',amtMax:'',hasNote:false,type:''};function toggleFinRecAdv(){finRecAdv=!finRecAdv;const p=document.getElementById('finRecAdvPanel');if(p)p.style.display=finRecAdv?'block':'none';}function finRecSet(k,v){finRecRange[k]=v;rerenderKeepFocus(renderFinance);}function finRecDebounce(){clearTimeout(finRecTimer);finRecTimer=setTimeout(()=>{const b=document.getElementById('finRecSearch');if(b)finRecKw=b.value;rerenderKeepFocus(renderFinance);},150);}function finRecInlineFilter(){const inp=document.getElementById('finRecSearch');const q=((inp?inp.value:finRecKw)||'').trim().toLowerCase();finRecKw=q;const tb=document.getElementById('finRecTbody');if(!tb)return;let payN=0,payS=0,payA=0,payD=0,refN=0,refS=0,shown=0,qn=0,qs=0,qrn=0,qrs=0;tb.querySelectorAll('tr[data-search]').forEach(tr=>{const hit=!q||(tr.getAttribute('data-search')||'').includes(q);tr.style.display=hit?'':'none';if(hit){shown++;const amt=Number(tr.getAttribute('data-amount')||0);if(tr.getAttribute('data-exp')==='1'){if(tr.getAttribute('data-type')==='refund'){qrn++;qrs+=amt;}else{qn++;qs+=amt;}}else{const act=Number(tr.getAttribute('data-actual')||0);if(tr.getAttribute('data-type')==='refund'){refN++;refS+=amt;}else{payN++;payS+=amt;payA+=act;payD+=(amt-act);}}}});const emp=document.getElementById('finRecEmptyRow');if(emp)emp.style.display=shown?'none':'';const sum=document.getElementById('finRecSummary');if(sum)sum.textContent='全部客户收退款流水 · 收款 '+payN+' 笔 '+money(payS)+' · 抵扣 '+money(payD)+' · 实收 '+money(payA)+' · 退费 '+refN+' 笔 '+money(refS)+' · 净额 '+money(payA-refS)+finRecExcludedNote()+finRecQuotaNote(qn,qs,qrn,qrs);}function resetFinRec(){finRecKw='';finRecRange={dateStart:'',dateEnd:'',amtMin:'',amtMax:'',hasNote:false,type:''};renderFinance();}function finRecExcludedNote(){try{const v=DB.customers.filter(c=>c.refunded||c.stage==='terminated').reduce((s,c)=>s+deductTotalOf(c),0);return v>0?' · 含退费/终止客户抵扣 '+money(v):'';}catch(_){return '';}}
function finRecQuotaNote(qn,qs,qrn,qrs){if(!qn&&!qrn)return '';return ' · 已剔除体验课收款 '+qn+' 笔 '+money(qs)+(qrn?(' · 体验课退费 '+qrn+' 笔 '+money(qrs)):'');}
function renderFinRecords(seg){const view=document.getElementById('view');if(!view)return;let rows=finDataList('filter',true).rows;const _qRows=rows.filter(r=>r.exp);const qn=_qRows.filter(r=>r.type!=='refund').length;const qs=_qRows.filter(r=>r.type!=='refund').reduce((s,r)=>s+r.amount,0);const qrn=_qRows.filter(r=>r.type==='refund').length;const qrs=_qRows.filter(r=>r.type==='refund').reduce((s,r)=>s+r.amount,0);const sumPay=rows.filter(r=>r.type!=='refund'&&!r.exp).reduce((s,r)=>s+r.amount,0);const sumDeduct=rows.filter(r=>r.type!=='refund'&&!r.exp).reduce((s,r)=>s+(r.deduct||0),0);const sumActual=rows.filter(r=>r.type!=='refund'&&!r.exp).reduce((s,r)=>s+(r.actual||0),0);const sumRef=rows.filter(r=>r.type==='refund'&&!r.exp).reduce((s,r)=>s+r.amount,0);view.innerHTML=`
<div class="topbar"><div><h1>收款记录</h1><p><span id="finRecSummary">全部客户收退款流水 · 收款 ${rows.filter(r=>r.type!=='refund'&&!r.exp).length} 笔 ${money(sumPay)} · 抵扣 ${money(sumDeduct)} · 实收 ${money(sumActual)} · 退费 ${rows.filter(r=>r.type==='refund'&&!r.exp).length} 笔 ${money(sumRef)} · 净额 ${money(sumActual-sumRef)}${finRecExcludedNote()}${finRecQuotaNote(qn,qs,qrn,qrs)}</span></p></div></div>
${seg}
<div class="search">
<input id="finRecSearch" class="list-search" placeholder="搜索客户 / 经销商 / 收款方式 / 备注 / 金额 / 日期" value="${esc(finRecKw)}" oninput="finRecInlineFilter()" onchange="finRecInlineFilter()">
<button class="btn ghost sm" type="button" onclick="toggleFinRecAdv()">🔍 高级筛选</button>
<button class="btn ghost sm" onclick="resetFinRec()">重置</button>
${can('export_reconcile')?'<button class="btn sm" onclick="openFinExport()">📥 导出</button>':''}
</div>
<div class="card" id="finRecAdvPanel" style="${finRecAdv?'':'display:none;'}margin-bottom:14px;padding:16px 18px">
<div class="row2">
<div class="field"><label>收款日期起</label><input type="date" id="finRecDateStart" value="${finRecRange.dateStart}" onchange="finRecSet('dateStart',this.value)"></div>
<div class="field"><label>收款日期止</label><input type="date" id="finRecDateEnd" value="${finRecRange.dateEnd}" onchange="finRecSet('dateEnd',this.value)"></div>
</div>
<div class="row2">
<div class="field"><label>金额下限(元)</label><input type="number" id="finRecAmtMin" min="0" value="${finRecRange.amtMin}" placeholder="如 1000" onchange="finRecSet('amtMin',this.value)" onkeydown="if(event.key==='Enter')this.blur()"></div>
<div class="field"><label>金额上限(元)</label><input type="number" id="finRecAmtMax" min="0" value="${finRecRange.amtMax}" placeholder="如 50000" onchange="finRecSet('amtMax',this.value)" onkeydown="if(event.key==='Enter')this.blur()"></div></div>
<div class="row2">
<div class="field"><label>类型</label><select onchange="finRecSet('type',this.value)"><option value=""${finRecRange.type?'':' selected'}>全部</option><option value="pay"${finRecRange.type==='pay'?' selected':''}>仅收款</option><option value="refund"${finRecRange.type==='refund'?' selected':''}>仅退费</option></select></div>
<div class="field"><label>备注筛选</label><label style="display:flex;align-items:center;gap:8px;cursor:pointer;padding:8px 0;font-size:13px;color:#334155"><input type="checkbox" id="finRecHasNote" ${finRecRange.hasNote?'checked':''} style="width:auto" onchange="finRecSet('hasNote',this.checked)"> 只看有备注的记录</label></div>
</div>
</div>
<div class="card table-card"><table><thead><tr><th>日期</th><th>类型</th><th>客户</th><th>订单/项目</th><th>归属经销商</th><th>业绩归属</th><th style="text-align:right">交款金额</th><th style="text-align:right">抵扣金额</th><th style="text-align:right">实收金额</th><th>方式</th><th>备注</th></tr></thead>
<tbody id="finRecTbody">${rows.map(r=>`<tr data-type="${r.type}" data-amount="${r.amount}" data-actual="${r.actual}" data-exp="${r.exp?1:0}" data-search="${esc(((r.cname||'')+' '+(r.dname||'')+' '+(r.items||'')+' '+(r.ownU||'')+' '+(r.method||'')+' '+(r.note||'')+' '+String(r.date||'')+' '+String(r.amount||'')+' '+(r.type==='refund'?'退费':'收款')).toLowerCase())}"><td>${r.date}</td><td>${r.type==='refund'?'<span style="color:var(--danger);font-weight:700">退费</span>':'<span style="color:var(--primary);font-size:12px">收款</span>'}</td><td><b style="cursor:pointer;border-bottom:1px dashed var(--primary)" onmouseenter="finCustTip(event,this,'${r.cid}')" onmouseleave="_finTipLeave()">${esc(r.cname)}</b></td><td>${esc(r.items||'—')}</td><td>${esc(r.dname)}</td><td>${esc(r.ownU||'—')}</td><td style="text-align:right${r.type==='refund'?';color:var(--danger);font-weight:700':''}">${r.type==='refund'?'-'+money(r.amount):money(r.amount)}</td><td style="text-align:right">${r.type==='refund'?'—':(r.deduct>0?money(r.deduct):'—')}</td><td style="text-align:right">${r.type==='refund'?'—':`<b style="color:#1d4ed8">${money(r.actual)}</b>`}</td><td>${r.type==='refund'?'<span style="color:var(--danger)">退费</span>':esc(r.method)}</td><td class="mtxt">${esc(r.note)}</td></tr>`).join('')}<tr id="finRecEmptyRow" style="display:none"><td colspan="11" class="empty">暂无收退款记录</td></tr></tbody></table></div>`;finRecInlineFilter();}
function renderFinDealer(seg){const _realD=DB.dealers.filter(d=>d.type!=="staff");const _effD=_realD.filter(d=>!d.refunded&&d.stage!=='terminated');const dTotalContract=_effD.reduce((s,d)=>s+Number(d.amount||0),0);const dTotalPaid=_effD.reduce((s,d)=>s+paidOf(d),0);const dTotalOwed=Math.max(0,dTotalContract-dTotalPaid);const dUnpaidCount=_effD.filter(d=>owedOf(d)>0).length;const dTotalDeduct=_effD.reduce((s,d)=>s+deductTotalOf(d),0);const dTotalActual=_effD.reduce((s,d)=>s+actualPaidOf(d),0)-_effD.reduce((s,d)=>s+refundTotalOf(d),0);const editableD=can('edit_finance');const canRegD=editableD&&!fDeny('payments','e');let dList=finDataList('filter',true).rows;const dFinRows=dList.map(d=>{const paid=paidOf(d),owed=owedOf(d),rem=nextPayReminder(d);const pcount=(d.payments||[]).length;const head=`<tr class="fin-row" data-did="${d.id}" data-search="${(fTxt('d_name',d.name)+' '+dtName(d)+' '+(fLevel('phone')==='full'?(d.phone||''):'')+' '+dealerCustomerKeywords(d.id)).toLowerCase()}" data-search-self="${(d.name+' '+dtName(d)+' '+
(fLevel('phone')==='full'?(d.phone||''):'')).toLowerCase()}">
<td><span class="tag ${dtColor(d)}">${dtName(d)}</span> ${d.name}</td>
<td>${fmtPhone(d.phone)}</td>
<td>${editableD&&fLevel('amount')==='full'?`<input style="width:120px;min-width:90px;padding:4px 6px;border:1px solid #cbd5e1;border-radius:6px;font-size:13px"type="number"min="0"step="0.01"value="${d.amount}"data-did="${d.id}"onchange="setContractAmount('d','${d.id}',this.value)"title="点击修改合同总额">`:moneyMask(fLevel('amount'), d.amount)}</td>
<td>${moneyMask(fLevel('amount'), paid)}</td>
<td>${owedCellHtml(d,owed)}</td>
<td>${pcount? pcount+' 笔' : '—'}</td>
<td><span class="st ${rem.cls}">${rem.text}</span></td>
<td>${canRegD?`<button class="btn sm"onclick="openPayment('d','${d.id}')">登记交款</button>`:'<span class="mtxt">只读</span>'}</td>
</tr>`;const cs=DB.customers.filter(c=>c.dealerId===d.id);const cust=cs.map(c=>{const cp=paidOf(c),co=owedOf(c),crm=nextPayReminder(c);const cpc=(c.payments||[]).length;return`<tr class="fin-cust-row" data-parent="${d.id}" data-search="${((c.custNo||'')+' '+(fTxt('c_name',c.name))+' '+(fLevel('c_school')==='full'?(c.school||''):'')+' '+(fLevel('phone')==='full'?(c.phone||''):'')).toLowerCase()}" style="display:none;background:#f8fafc">
<td style="padding-left:30px;color:#94a3b8;white-space:nowrap">└ 客户</td>
<td>${custNameHtml(c)}</td>
<td>${moneyMask(fLevel('amount'), c.amount)}</td>
<td>${moneyMask(fLevel('amount'), cp)}</td>
<td>${owedCellHtml(c,co)}</td>
<td>${cpc?cpc+' 笔':''}</td>
<td><span class="st ${crm.cls}">${crm.text}</span></td>
<td>${canRegD?`<button class="btn sm"onclick="openPayment('c','${c.id}')">登记交款</button>`:'<span class="mtxt">只读</span>'}</td>
</tr>`;}).join('');return head+cust;}).join('');view.innerHTML=`
<div class="topbar"><div><h1>财务收款</h1><p>经销商合同 / 保证金的交款与欠款管理(支持分次登记交款 · 合同总额可在线修改)</p></div></div>
${seg}
<div class="grid kpis" style="grid-template-columns:repeat(auto-fit,minmax(172px,1fr))">
${kpiCard('🏢','经销商合同总额',moneyMask(fLevel('amount'), dTotalContract),'经销商与总部合同/保证金')}
${kpiCard('✅','已交款',moneyMask(fLevel('amount'), dTotalPaid),'累计已登记交款','#16a34a')}
${kpiCard('➖','抵扣金额',moneyMask(fLevel('amount'), dTotalDeduct),'交款中以抵扣方式充抵的部分','#7c3aed')}
${kpiCard('💵','实收金额',moneyMask(fLevel('amount'), dTotalActual),'已交款 − 抵扣金额 − 退费','#1d4ed8')}
${kpiCard('⚠️','欠款合计',moneyMask(fLevel('amount'), dTotalOwed),'合同额 − 已交款(已剔除退费/终止)','#dc2626')}
${kpiCard('🕒','待补款经销商',dUnpaidCount+' 家','存在未结清合同(已剔除退费/终止)')}
</div>
<div class="card"><div class="section-title" style="margin:0 0 8px">经销商合同财务 · 经销商作为总部客户,其合同 / 保证金的交款与欠款(合同总额可在有「登记 / 管理交款」权限时修改)</div>
<div class="search">
<input id="finDealerSearch" class="list-search deliv-search" style="min-width:220px" placeholder="🔍 搜索经销商 / 客户姓名 / 校区 / 手机号" value="${esc(finDealerKw)}" oninput="_finDebounce('dealer',this.value)" oncompositionend="_finDebounce('dealer',this.value)">
<span id="finDealerMatchInfo" style="font-size:13px;color:#475569"></span>
<button class="btn ghost sm" type="button" onclick="toggleFinAdv()">🔍 高级筛选</button>
<button class="btn ghost sm" onclick="resetFinFilter()">重置</button>
${can('export_reconcile')?'<button class="btn sm" onclick="openFinExport()">📥 导出</button>':''}
</div>
<div id="finAdvPanelD" style="${finAdvOpen?'':'display:none;'}"margin:0 0 14px;padding:16px 18px;background:var(--panel);border:1px solid var(--line);border-radius:14px">
<div class="row2">
<div class="field"><label>经销商类型(可多选)</label>${finTypeChipsHtml()}</div>
<div class="field"><label>合同额</label><div style="display:flex;gap:6px;align-items:center"><input type="number" min="0" id="fin_amtMin" placeholder="最低" value="${esc(finFilter.amtMin)}" onchange="finFilter.amtMin=this.value;rerenderKeepFocus(renderFinance)"><span class="mtxt">~</span><input type="number" min="0" id="fin_amtMax" placeholder="最高" value="${esc(finFilter.amtMax)}" onchange="finFilter.amtMax=this.value;rerenderKeepFocus(renderFinance)"></div></div>
</div>
<div class="row2">
<div class="field"><label>已交款</label><div style="display:flex;gap:6px;align-items:center"><input type="number" min="0" id="fin_paidMin" placeholder="最低" value="${esc(finFilter.paidMin)}" onchange="finFilter.paidMin=this.value;rerenderKeepFocus(renderFinance)"><span class="mtxt">~</span><input type="number" min="0" id="fin_paidMax" placeholder="最高" value="${esc(finFilter.paidMax)}" onchange="finFilter.paidMax=this.value;rerenderKeepFocus(renderFinance)"></div></div>
<div class="field"><label>欠款</label><div style="display:flex;gap:6px;align-items:center"><input type="number" min="0" id="fin_owedMin" placeholder="最低" value="${esc(finFilter.owedMin)}" onchange="finFilter.owedMin=this.value;rerenderKeepFocus(renderFinance)"><span class="mtxt">~</span><input type="number" min="0" id="fin_owedMax" placeholder="最高" value="${esc(finFilter.owedMax)}" onchange="finFilter.owedMax=this.value;rerenderKeepFocus(renderFinance)"></div></div>
</div>
</div>
<div class="table-card"><table id="finDealerTable" style="min-width:980px">
<thead><tr><th>经销商</th><th>联系电话</th><th>合同总额${can('edit_finance')?'(可修改)':''}</th><th>已交款</th><th>欠款</th><th>交款记录</th><th>下次补款提醒</th><th>操作</th></tr></thead>
<tbody>${dFinRows}</tbody></table></div></div>`;if(finDealerKw.trim()){applyDealerFinSearch(finDealerKw);}}
function renderFinCustomer(seg){let list=finDataList('filter',true).rows;
const effective=DB.customers.filter(c=>!c.refunded&&c.stage!=='terminated'&&!quotaOnlyArchive(c,DB));const totalContract=effective.reduce((s,c)=>s+Number(c.amount||0),0);const totalP
aid=effective.reduce((s,c)=>s+paidOf(c),0);const totalOwed=Math.max(0,totalContract-totalPaid);const unpaidCount=effective.filter(c=>owedOf(c)>0).length;const totalDeduct=effective.reduce((s,c)=>s+deductTotalOf(c),0);const totalActual=DB.customers.reduce((s,c)=>s+(quotaOnlyArchive(c,DB)?0:actualPaidOf(c)),0)-DB.customers.reduce((s,c)=>s+(quotaOnlyArchive(c,DB)?0:refundTotalOf(c)),0);const totalDeductAll=DB.customers.reduce((s,c)=>s+(quotaOnlyArchive(c,DB)?0:deductTotalOf(c)),0);const finDeductGap=Math.round((totalDeductAll-totalDeduct)*100)/100;const finKpiGap=Math.round((totalActual-(totalPaid-totalDeduct))*100)/100;const dealRows=DB.dealers.map(d=>{const cs=effective.filter(c=>c.dealerId===d.id);const cAmt=cs.reduce((s,c)=>s+Number(c.amount||0),0);const cPaid=cs.reduce((s,c)=>s+paidOf(c),0);const cOwed=Math.max(0,cAmt-cPaid);const cUnpaid=cs.filter(c=>owedOf(c)>0).length;const cPay=cs.reduce((s,c)=>s+((c.payments||[]).length),0);return{d,cCnt:cs.length,cAmt,cPaid,cOwed,cUnpaid,cPay};});const rows=list.map(c=>{const d=DB.dealers.find(x=>x.id===c.dealerId);const paid=paidOf(c),owed=owedOf(c),rem=nextPayReminder(c);const ps=c.payments||[];const pcount=ps.length;const lastPay=pcount?ps[ps.length-1]:null;const editable=can('edit_finance');return`<tr class="fin-row" data-search="${((c.custNo||'')+' '+(fTxt('c_name',c.name))+' '+(fLevel('c_school')==='full'?(c.school||''):'')+' '+(fLevel('phone')==='full'?(c.phone||''):'')+' '+dealerName(c.dealerId)).toLowerCase()}">
<td><span class="tag ${dtColor(d)}">${dtName(d)}</span> ${d?d.name:'—'}</td>
<td>${custNameHtml(c)}</td>
<td>${editable&&fLevel('amount')==='full'?`<input style="width:120px;min-width:90px;padding:4px 6px;border:1px solid #cbd5e1;border-radius:6px;font-size:13px"type="number"min="0"step="0.01"value="${c.amount}"data-cid="${c.id}"onchange="setContractAmount('c','${c.id}',this.value)"title="点击修改合同总额">`:moneyMask(fLevel('amount'), c.amount)}</td>
<td>${moneyMask(fLevel('amount'), paid)}</td>
<td>${owedCellHtml(c,owed)}</td>
<td>${pcount? pcount+' 笔'+(lastPay?' · 最近 '+lastPay.date:'') : '—'}</td>
<td><span class="st ${rem.cls}">${rem.text}</span></td>
<td>${(can('add_finance')&&!fDeny('payments','e'))?`<button class="btn sm"onclick="openPayment('c','${c.id}')">登记交款</button>`:'<span class="mtxt">只读</span>'}</td>
</tr>`;}).join('');const empty=list.length?'':'<tr><td colspan="8" class="empty">无匹配客户</td></tr>';const dealRowsHtml=dealRows.map(r=>`<tr>
<td><span class="tag ${dtColor(r.d)}">${dtName(r.d)}</span> ${r.d?r.d.name:'—'}</td>
<td>${r.cCnt} 家</td>
<td><b>${moneyMask(fLevel('amount'), r.cAmt)}</b></td>
<td>${moneyMask(fLevel('amount'), r.cPaid)}</td>
<td><b style="color:${r.cOwed>0?'#dc2626':'#16a34a'}">${moneyMask(fLevel('amount'), r.cOwed)}</b></td>
<td>${r.cPay} 笔</td>
<td>${r.cUnpaid>0?`<span class="st st-following">${r.cUnpaid}家待补</span>`:'<span class="mtxt">—</span>'}</td>
</tr>`).join('');view.innerHTML=`
<div class="topbar"><div><h1>财务收款</h1><p>转介绍客户合同金额、交款与欠款管理(支持分次登记交款 · 合同总额可在线修改)</p></div></div>
${seg}
<div class="grid kpis" style="grid-template-columns:repeat(auto-fit,minmax(172px,1fr))">
${kpiCard('📄','合同总额',moneyMask(fLevel('amount'), totalContract),'全部客户合同金额(已剔除退费/终止)')}
${kpiCard('✅','已交款',moneyMask(fLevel('amount'), totalPaid),'累计已登记交款(已剔除退费/终止)','#16a34a')}
${kpiCard('➖','抵扣金额',moneyMask(fLevel('amount'), totalDeduct),'交款中以抵扣充抵的部分 <span class="kpi-i">ⓘ</span>','#7c3aed',finTipDeduct(finDeductGap))}
${kpiCard('💵','实收金额',moneyMask(fLevel('amount'), totalActual),'已交款 − 抵扣金额 − 退费 <span class="kpi-i">ⓘ</span>','#1d4ed8',finTipActual(finKpiGap))}
${kpiCard('⚠️','欠款合计',moneyMask(fLevel('amount'), totalOwed),'合同额 − 已交款(已剔除退费/终止)','#dc2626')}
${kpiCard('🕒','待补款客户',unpaidCount+' 家','存在未结清合同(已剔除退费/终止)')}
</div>
<div class="card"> <div class="search">
<input id="finSearch" class="list-search deliv-search" style="min-width:220px" placeholder="🔍 搜索客户姓名 / 校区 / 手机号" value="${esc(finFilter.kw)}" oninput="_finDebounce('top',this.value)" oncompositionend="_finDebounce('top',this.value)">
<span id="finMatchInfo" style="font-size:13px;color:#475569"></span>
<button class="btn ghost sm" type="button" onclick="toggleFinAdv()">🔍 高级筛选</button>
<button class="btn ghost sm" onclick="resetFinFilter()">重置</button>
${can('export_reconcile')?'<button class="btn sm" onclick="openFinExport()">📥 导出</button>':''}
</div>
<div id="finAdvPanel" style="${finAdvOpen?'':'display:none;'}"margin:0 0 14px;padding:16px 18px;background:var(--panel);border:1px solid var(--line);border-radius:14px">
<div class="row2">
<div class="field"><label>归属经销商</label>${smartSelectHTML({id:'finFilter_dealer', items:DB.dealers, selectedId:finFilter.dealer, displayKey:'name', subKey:'', emptyLabel:'全部经销商', placeholder:'搜索经销商…', optFn:d=>{const t=DT(d.type);return {main:(t.name.replace('经销商','')+'·'+d.name), sub:(d.campus||'')};}})}</div>
<div class="field"><label>对接员工</label>${smartSelectHTML({id:'finFilter_staff', items:DB.dealers.filter(d=>d.type==='staff'), selectedId:finFilter.staff, displayKey:'name', emptyLabel:'全部员工', placeholder:'搜索员工…'})}</div>
</div>
<div class="row2">
<div class="field"><label>状态</label><select onchange="finFilter.status=this.value;renderFinance()"><option value="">全部状态</option><option value="unpaid"${finFilter.status==='unpaid'?' selected':''}>待补款</option><option value="paid"${finFilter.status==='paid'?' selected':''}>已结清</option></select></div>
<div class="field"><label>合作项目</label><select onchange="finFilter.intent=this.value;renderFinance()"><option value="">全部</option>${intentOpts(finFilter.intent)}</select></div>
</div>
<div class="row2">
<div class="field"><label>已签合同</label><select onchange="finFilter.contract=this.value;renderFinance()"><option value="">全部</option><option value="1"${finFilter.contract==='1'?' selected':''}>已签</option><option value="0"${finFilter.contract==='0'?' selected':''}>未签</option></select></div>
<div class="field"><label>分期</label><select onchange="finFilter.install=this.value;renderFinance()"><option value="">全部</option><option value="1"${finFilter.install==='1'?' selected':''}>分期</option><option value="0"${finFilter.install==='0'?' selected':''}>非分期</option></select></div>
</div>
<div class="row2">
<div class="field"><label>退费</label><select onchange="finFilter.refund=this.value;renderFinance()"><option value="">全部</option><option value="1"${finFilter.refund==='1'?' selected':''}>已退费</option><option value="0"${finFilter.refund==='0'?' selected':''}>未退费</option></select></div>
<div class="field"><label>报备日期</label><div style="display:flex;gap:6px;align-items:center"><input type="date" id="finReportStart" value="${esc(finFilter.reportStart)}" onchange="finFilter.reportStart=this.value;renderFinance()"><span class="mtxt">~</span><input type="date" id="finReportEnd" value="${esc(finFilter.reportEnd)}" onchange="finFilter.reportEnd=this.value;renderFinance()"></div></div>
</div>
<div class="row2">
<div class="field"><label>达成合作日期</label><div style="display:flex;gap:6px;align-items:center"><input type="date" id="finSignStart" value="${esc(finFilter.signStart)}" onchange="finFilter.signStart=this.value;renderFinance()"><span class="mtxt">~</span><input type="date" id="finSignEnd" value="${esc(finFilter.signEnd)}" onchange="finFilter.signEnd=this.value;renderFinance()"></div></div>
<div class="field"><label>交款日期</label><div style="display:flex;gap:6px;align-items:center"><input type="date" id="finPayStart" value="${esc(finFilter.payStart)}" onchange="finFilter.payStart=this.value;renderFinance()"><span class="mtxt">~</span><input type="date" id="finPayEnd" value="${esc(finFilter.payEnd)}" onchange="finFilter.payEnd=this.value;renderFinance()"></div></div>
</div>
<div class="row2">
<div class="field"><label>合同额</label><div style="display:flex;gap:6px;align-items:center"><input type="number" min="0" id="fin_amtMin" placeholder="最低" value="${esc(finFilter.amtMin)}" onchange="finFilter.amtMin=this.value;rerenderKeepFocus(renderFinance)"><span class="mtxt">~</span><input type="number" min="0" id="fin_amtMax" placeholder="最高" value="${esc(finFilter.amtMax)}" onchange="finFilter.amtMax=this.value;rerenderKeepFocus(renderFinance)"></div></div>
<div class="field"><label>已交款</label><div style="display:flex;gap:6px;align-items:center"><input type="number" min="0" id="fin_paidMin" placeholder="最低" value="${esc(finFilter.paidMin)}" onchange="finFilter.paidMin=this.value;rerenderKeepFocus(renderFinance)"><span class="mtxt">~</span><input type="number" min="0" id="fin_paidMax" placeholder="最高" value="${esc(finFilter.paidMax)}" onchange="finFilter.paidMax=this.value;rerenderKeepFocus(renderFinance)"></div></div>
</div>
<div class="row2">
<div class="field"><label>欠款</label><div style="display:flex;gap:6px;align-items:center"><input type="number" min="0" id="fin_owedMin" placeholder="最低" value="${esc(finFilter.owedMin)}" onchange="finFilter.owedMin=this.value;rerenderKeepFocus(renderFinance)"><span class="mtxt">~</span><input type="number" min="0" id="fin_owedMax" placeholder="最高" value="${esc(finFilter.owedMax)}" onchange="finFilter.owedMax=this.value;rerenderKeepFocus(renderFinance)"></div></div>
<div class="field"><label>交款关键字</label><input id="finPayKw" placeholder="搜交款备注 / 方式" value="${esc(finFilter.payKw)}" oninput="sbxKw(this,'payKw','fin')" oncompositionend="sbxKw(this,'payKw','fin',1)"></div>
</div>
</div>
<div class="table-card"><table id="finTable" style="min-width:980px">
<thead><tr><th>经销商</th><th>客户</th><th>合同总额${can('edit_finance')?'(可修改)':''}</th><th>已交款</th><th>欠款</th><th>交款记录</th><th>下次补款提醒</th><th>操作</th></tr></thead>
<tbody>${rows||empty}</tbody>
</table></div></div>
<details style="margin-top:14px"><summary style="cursor:pointer;font-weight:600;color:#475569;padding:8px 12px;background:#f8fafc;border:1px solid #e2e8f0;border-radius:8px;list-style:none">📊 按经销商汇总 · 各经销商名下转介绍客户合同总额(点击展开)</summary>
<div class="tbl-h-scroll" style="margin-top:10px;padding:0 4px"><table style="min-width:760px">
<thead><tr><th>经销商</th><th>客户数</th><th>合同总额</th><th>已交款</th><th>欠款</th><th>交款笔数</th><th>待补款</th></tr></thead>
<tbody>${dealRowsHtml}</tbody></table></div>
</details>`;finSearch(finFilter.kw);}
function finCanReg(type,id){return type==='d'?(can('add_finance')||can('edit_finance')||curDealerId===id):(can('add_finance')||can('edit_finance'));}
function addPayment(type,id){if(!finCanReg(type,id)||fDeny('payments','e')){toast('无权限:本角色已被关闭「交款/退款记录」的编辑权限(字段级),无法登记交款');return;}
const e=getFinEntity(type,id);if(!e)return;const amt=Number(document.getElementById('pay_amt').value);if(!amt||amt<=0){toast('请填写有效的交款金额');return;}const __rawD=Number((document.getElementById('pay_deduct')||{}).value)||0;if(__rawD<0){toast('抵扣金额不能为负数');return;}const __ded=__rawD;if(__ded>amt){toast('抵扣金额不能大于交款金额');return;}if(!payOwnerRequired(type,'pay_','业绩归属经销商'))return;if(!payStaffOwnerRequired(type,'pay_'))return;
const wasFirstPay=!(e.payments&&e.payments.length);const payDate=document.getElementById('pay_date').value||today();
if(!Array.isArray(e.payments))e.payments=[];const __ow=payOwnerVals('pay_');e.payments.push(Object.assign({id:uid('p_'),date:payDate,amount:amt,deduct:__ded,method:document.getElementById('pay_method').value,note:document.getElementById('pay_note').value.trim()},__ow));e.nextPayAt=document.getElementById('pay_next').value||'';triggerReminder('payment',(type==='d'?'经销商「':'客户「')+e.name+'」登记交款 '+money(amt),'finance',id);if(typeof e.stage==='string')syncCustomerStage(e);if(type==='c'&&wasFirstPay&&!e.signDate&&!quotaOnlyArchive(e,DB)){e.signDate=payDate;}if(type==='c')syncConvertedDealer(e);syncSignDateFromPayments(e);save().then(function(ok){if(ok)toast('已登记交款 '+money(amt));});openPayment(type,id);refreshFin(type);askContractAmountAfterPay(type,id,amt);}function askContractAmountAfterPay(type,id,amt){if(type!=='c')return false;const e=getFinEntity(type,id);if(!e)return false;if(Number(e.amount||0)>0)return false;const paid=paidOf(e);const st=(STAGES[e.stage]||{}).label||e.stage||'—';const ed=!fDeny('amount','e')&&can('edit_finance');const inp=ed?'<div class="field"><label>合同金额(元)*</label><input type="number" id="cag_amount" min="0" step="0.01" placeholder="如 100000" style="font-weight:700"></div>':'';const foot=ed?'<button class="btn ghost" onclick="closeModal();openPayment(\'c\',\''+id+'\')">稍后处理</button><button class="btn ghost" onclick="jumpToContractAmount(\''+id+'\')">去财务页填写</button><button class="btn" onclick="saveContractAmountFromGate(\''+id+'\')">保存并推进阶段</button>':'<button class="btn" onclick="closeModal();openPayment(\'c\',\''+id+'\')">知道了</button>';const body=ed?('已登记交款 <b>'+money(amt)+'</b>(该客户累计已交款 <b>'+money(paid)+'</b>),但本客户的 <b>合同金额仍为 0</b>。<br><br>系统按合同金额判定阶段与欠款:金额为 0 时<b>阶段不会推进</b>(当前仍为「'+esc(st)+'」)、<b>欠款恒显示为 0</b>(看起来像已结清)。<br><br>请补填合同金额,保存后系统会自动把阶段更新为「全款 / 分期」。'):('已登记交款 <b>'+money(amt)+'</b>,但本客户的 <b>合同金额仍为 0</b>:阶段不会推进、欠款恒显示为 0(看起来像已结清)。<br><br>本角色无「修改合同金额」权限,请转告有权限的同事在「财务收款 → 转介绍客户财务」补填合同金额。');openModal('<div class="modal-head"><h3>⚠️ 请补填合同金额并确认阶段</h3><span class="x" onclick="closeModal()">×</span></div><div class="modal-body"><div class="warn-box">'+body+'</div>'+inp+'</div><div class="modal-foot">'+foot+'</div>');return true;}function saveContractAmountFromGate(id){const el=document.getElementById('cag_amount');const v=el?Number(el.value):NaN;if(!isFinite(v)||v<=0){toast('请输入大于 0 的合同金额');return;}doSetContractAmount('c',id,v);}function jumpToContractAmount(id){if(!can('view_finance')){toast('本角色无「查看财务收款」权限,请转告有权限的同事补填合同金额');closeModal();openPayment('c',id);return;}closeModal();curView='finance';finSub='customer';try{finFilter.kw='';finFilter.contract='';finFilter.install='';finFilter.refund='';finFilter.dtype=[];finFilter.staff='';finFilter.status='';}catch(e){}render();setTimeout(function(){const el=document.querySelector('#finTable input[data-cid="'+id+'"]');if(!el){toast('未定位到该客户的合同金额输入框,请在「财务收款」页搜索该客户后修改');return;}try{el.focus();el.select();}catch(e){}try{if(el.scrollIntoView)el.scrollIntoView({block:'center'});}catch(e){}const ob=el.style.boxShadow;el.style.boxShadow='0 0 0 3px #f59e0b';el.style.borderRadius='6px';setTimeout(function(){el.style.boxShadow=ob;},2600);},200);}
function delPayment(type,id,pid){if(!can('del_payments')||fDeny('payments','d')){toast('无权限:删除交款记录需「删除交款记录」权限(本角色「交款/退款记录」字段的删除已关闭)');return;}if(!finCanReg(type,id)){toast('无权限');return;}
const e=getFinEntity(type,id);if(!e)return;const pp=(e.payments||[]).find(p=>p.id===pid);if(!pp)return;if(type==='c'&&paidLockHit(e)&&!paidLockAfterPayout(e,pp)){toast('该客户分润已发放:交款记录已锁定,禁止删除(如需删除请先撤回对应发放记录)');return;}if(!confirm('确认删除这笔交款记录('+money(pp.amount)+' / '+(pp.date||'')+')?删除后不可恢复。'))return;e.payments=e.payments.filter(p=>p.id!==pid);if(typeof e.stage==='string')syncCustomerStage(e);if(type==='c')syncConvertedDealer(e);syncSignDateFromPayments(e);save().then(function(ok){if(ok)toast('已删除该交款记录');});openPayment(type,id);refreshFin(type);}
function editPayment(type,id,pid){if(!finCanReg(type,id)){toast('无权限');return;}const e=getFinEntity(type,id);if(!e)return;const p=(e.payments||[]).find(x=>x.id===pid);if(!p)return;const _plInf=(type==='c')?paidLockInfo(e):null;const _plk=!!_plInf&&!paidLockAfterPayout(e,p);const mopts=['微信','支付宝','银行转账','现金','对公转账','其他'].map(m=>`<option${p.method===m?' selected':''}>${m}</option>`).join('');openModal(`<div class="modal-head"><h3>修改交款记录</h3><span class="x"onclick="closeModal()">×</span></div><div class="modal-body">${_plk?('<div style="background:#fef3c7;border:1px solid #fcd34d;color:#92400e;border-radius:8px;padding:8px 10px;font-size:13px;margin-bottom:10px">⚠ 该客户分润已发放(已发 ¥'+money(_plInf.sum)+')——交款记录已锁定,仅「备注」可修改<br>如需修改其他内容:请先在经销商页「📋 发放记录」撤回/修改对应发放,再改本笔;或联系管理员</div>'):''}<div class="row2"><div class="field"><label>交款日期</label><input type="date"id="ep_date"value="${p.date||''}"${_plk?' disabled':''}></div><div class="field"><label>交款金额*</label><input type="number"id="ep_amt"value="${p.amount}"min="0"step="0.01"${_plk?' disabled':''}></div></div><div class="row2"><div class="field"><label>抵扣金额</label><input type="number"id="ep_deduct"value="${deductOf(p)}"min="0"step="0.01"oninput="payCalcActual('ep_')"onchange="payCalcActual('ep_')"${_plk?' disabled':''}></div><div class="field"><label>实收金额(自动 = 交款金额 − 抵扣金额)</label><input type="number"id="ep_actual"value="${actualOf(p)}"readonly style="background:#f1f5f9;font-weight:700"></div></div><div class="row2"><div class="field"><label>交款方式</label><select id="ep_method"${_plk?' disabled':''}>${mopts}</select></div><div class="field"><label>备注</label><input id="ep_note"value="${esc(p.note||'')}"></div></div>${type==='c'?payOwnerFields(type,id,'ep_',p.pDealerId||e.dealerId||'',p.pStaffId!==undefined?p.pStaffId:payStaffDealerIdOf(e),_plk):''}</div><div class="modal-foot"><button class="btn ghost"onclick="closeModal()">取消</button><button class="btn"onclick="savePaymentEdit('${type}','${id}','${pid}')">保存修改</button></div>`);}
function payStaffDealerIdOf(c,date){if(!c)return '';const _h=(c&&c.ownerHist&&c.ownerHist.length)?(function(a,t){t=String(t||'').slice(0,10);let r=a[0];for(let i=0;i<a.length;i++){if(String(a[i].at||'').slice(0,10)<=t)r=a[i];else break;}return r;})(c.ownerHist,date):null;const _ss=_h?_h.staffId:c.serviceStaffId;const _dd=_h?_h.dealerId:c.dealerId;const sd=_ss?DB.dealers.find(x=>x.id===_ss):null;if(sd&&sd.type==='staff'&&sd.userId)return sd.id;const dd=_dd?DB.dealers.find(x=>x.id===_dd):null;if(dd&&dd.type==='staff'&&dd.userId)return dd.id;return '';}
/* ★ 2026-09-30 第 270 轮(主人拍板 B 方案):登记交款「业绩归属员工」默认值 —— 取该客户**上一笔交款的收款人**
(无交款 / 最近各笔均无有效员工归属时,回落客户档案归属链:按今日回看 ownerHist ⇒ serviceStaffId ⇒ 员工类归属经销商)。
旧写法 payStaffDealerIdOf(e) 不传日期 ⇒ 内部回看取 ownerHist[0](最早那条)⇒ 会默认带出已被换掉的旧人
(郭慧慧:档案对接=黄高峰、上一笔仍归柳博 ⇒ 新增第二笔默认带出柳博而非收款实况),语义不通。
仅影响「登记交款」弹窗默认值(修改交款弹窗传本笔登记值,不受影响);默认值可手改。 */
function payStaffDefaultOf(c){if(!c)return '';const _ps=(c.payments||[]).slice().sort((a,b)=>String((a&&a.date)||'').localeCompare(String((b&&b.date)||'')));for(let _i=_ps.length-1;_i>=0;_i--){const _q=_ps[_i];const _f=_q&&_q.pStaffId?DB.dealers.find(x=>x.id===_q.pStaffId):null;if(_f&&_f.type==='staff'&&_f.userId)return _f.id;}return payStaffDealerIdOf(c,new Date(Date.now()-new Date().getTimezoneOffset()*6e4).toISOString().slice(0,10));}
/* smartSelect 关闭监听注册表(2026-09-26 第 172 轮):原先每次 bindSmartSelect 都往 document 挂一个
click 监听且从不移除 —— 而筛选栏 / 弹窗每次整体重建都会重新绑定,监听器随之线性累积(实测重建 30 次
= 30 个监听,单次点到面板外 30 个回调全跑、其中 29 个作用在已被重建掉的旧面板上)。
改为全局单监听 + 注册表:只挂一次,点击时顺带剔除已离屏的 wrap,数量恒等于「当前活着的下拉数」。 */
var _ssReg=[];
function _ssDocBind(){if(window.__ssDocBound)return;window.__ssDocBound=1;document.addEventListener('click',function(e){for(var i=_ssReg.length-1;i>=0;i--){var w=_ssReg[i];if(!document.contains(w)){_ssReg.splice(i,1);continue;}if(!w.contains(e.target)){try{if(w.__ssClose)w.__ssClose();}catch(_e){}}}});}
function _ssAdd(w,fn){for(var i=_ssReg.length-1;i>=0;i--){if(!document.contains(_ssReg[i]))_ssReg.splice(i,1);}w.__ssClose=fn;if(_ssReg.indexOf(w)<0)_ssReg.push(w);_ssDocBind();}
/* ★ 第 190 轮(2026-09-26 用户定规):分润按「每笔交款登记的业绩归属」核算 ⇒ 登记 / 修改交款
必须选「业绩归属经销商」(员工栏保持选填:无员工参与的交款可留空)。
退费仅在「系统无法自动冲减」时强制手选「冲减归属经销商」(该客户交款归属经销商不唯一;唯一时留空照旧自动冲减)。
口径:改归属去「修改交款」,不是改客户档案;改档案只影响「未登记归属」的交款。 */
function payOwnerRequired(type,pref,label){if(type!=='c')return true;const el=document.getElementById(pref+'owner_d');if(el&&el.value)return true;toast('请选择「'+label+'」—— 分润按每笔交款登记的业绩归属核算,不能留空');return false;}
function payOwnerAnyOf(pref,label){const d=document.getElementById(pref+'owner_d');const u=document.getElementById(pref+'owner_u');if((d&&d.value)||(u&&u.value))return true;toast('请选择「'+label+'」或「冲减归属员工」—— 分润按每笔交款登记的业绩归属核算,不能留空');return false;}
function refundOwnerRequired(c){if(!c)return false;const dids=new Set(),uids=new Set();((c.payments)||[]).forEach(function(p){if((+p.amount||0)<=0)return;const hit=p&&p.pDealerId?DB.dealers.find(function(x){return x.id===p.pDealerId;}):null;const did=hit?p.pDealerId:(c.dealerId||'');const d=did?DB.dealers.find(function(x){return x.id===did;}):null;if(d&&d.type!=='staff')dids.add(did);const uid=payOwnerStaffUidOf(c,p);if(uid)uids.add(uid);});return dids.size!==1&&uids.size!==1;}
/* 第 193 轮:退费「冲减归属」提交分流 —— 左栏「冲减归属经销商」现已可选员工类经销商(口径与交款弹窗统一),
选到员工类时落到 rStaffId(走员工线),非员工类落 rDealerId;右栏「冲减归属员工」优先。
抽成纯函数便于服务端自检运行时探针直接编译实跑(不依赖 DOM)。 */
function refundOwnerIsStaff(id){const x=id?DB.dealers.find(function(v){return v.id===id;}):null;return !!(x&&x.type==='staff');}
function refundOwnerSplit(dv,uv){const o={};const _s=uv||(refundOwnerIsStaff(dv)?dv:'');const _d=(dv&&!refundOwnerIsStaff(dv))?dv:'';if(_s)o.rStaffId=_s;if(_d)o.rDealerId=_d;return o;}
function refundOwnerVals(){const d=document.getElementById('rf_owner_d');const u=document.getElementById('rf_owner_u');return refundOwnerSplit(d?d.value:'',u?u.value:'');}
function payOwnerFields(type,id,pref,selD,selU,lock){if(type!=='c')return '';const e=getFinEntity(type,id)||{};const curD=selD!==undefined?selD:(e.dealerId||'');const curU=selU!==undefined?selU:payStaffDefaultOf(e);const dItems=DB.dealers.map(x=>({id:x.id,name:(x.name||'')+(x.type==='staff'?'(员工)':''),phone:x.phone||''}));const uItems=DB.dealers.filter(x=>x.type==='staff'&&x.userId).map(x=>{const u=DB.users.find(v=>v.id===x.userId);return {id:x.id,name:(u?u.name:x.name)||'',phone:(u&&u.phone)||x.phone||'',sub:'员工'};});return `<div class="row2"><div class="field"><label>业绩归属经销商</label>${smartSelectHTML({id:pref+'owner_d',items:dItems,selectedId:curD,displayKey:'name',placeholder:'搜索经销商 / 员工(可打拼音首字母)',emptyLabel:'',locked:!!lock})}</div><div class="field"><label>业绩归属员工</label>${smartSelectHTML({id:pref+'owner_u',items:uItems,selectedId:curU,displayKey:'name',subKey:'sub',placeholder:'搜索员工姓名(可打拼音首字母)',emptyLabel:'',locked:!!lock})}</div></div>${(!curU&&!lock)?`<div class="pay-owner-warn" id="${pref}owner_warn"><b>请选择「业绩归属员工」</b><div>本客户还没有「对接员工」,系统无法自动带出收款人。请在上方「业绩归属员工」里手动选择本次收款人 —— 不选将无法保存交款(分润归属的硬要求)。</div></div>`:''}`;}
document.addEventListener('input',function(ev){try{var t=ev.target;if(!t||!t.id||!/owner_u$/.test(t.id))return;var w=document.getElementById(t.id.replace(/owner_u$/,'owner_warn'));if(w)w.style.display=t.value?'none':'';}catch(e){}},true);
document.addEventListener('change',function(ev){try{var t=ev.target;if(!t||!t.id||!/owner_u$/.test(t.id))return;var w=document.getElementById(t.id.replace(/owner_u$/,'owner_warn'));if(w)w.style.display=t.value?'none':'';}catch(e){}},true);
function payOwnerVals(pref,kp){const o={};const d=document.getElementById(pref+'owner_d');const u=document.getElementById(pref+'owner_u');if(d&&d.value)o[(kp||'p')+'DealerId']=d.value;if(u&&u.value)o[(kp||'p')+'StaffId']=u.value;return o;}function payStaffOwnerRequired(type,pref){if(type!=='c')return true;const u=document.getElementById(pref+'owner_u');if(u&&u.value)return true;toast('请选择「业绩归属员工」—— 每笔交款都必须登记员工归属,不能留空');return false;}
function payOwnerStaffName(c,p){const first=p&&p.pStaffId?DB.dealers.find(x=>x.id===p.pStaffId):null;if(first&&first.type==='staff'&&first.userId){const u=DB.users.find(v=>v.id===first.userId);if(u)return u.name;}const sd=c&&c.serviceStaffId?DB.dealers.find(x=>x.id===c.serviceStaffId):null;if(sd&&sd.type==='staff'&&sd.userId){const u=DB.users.find(v=>v.id===sd.userId);if(u)return u.name;}const dd=c&&c.dealerId?DB.dealers.find(x=>x.id===c.dealerId):null;if(dd&&dd.type==='staff'&&dd.userId){const u=DB.users.find(v=>v.id===dd.userId);if(u)return u.name;}return '';}
/* 第 192 轮:交款级「员工归属」→ 员工账号 id(与服务端 payStaffOwnerOf 同链:pStaffId → serviceStaffId → 员工类 dealerId)。
供 refundOwnerRequired 判定「员工侧能否唯一自动冲减」,口径与服务端 #82 refundStaffAlloc 一致。 */
function payOwnerStaffUidOf(c,p){const f=p&&p.pStaffId?DB.dealers.find(x=>x.id===p.pStaffId):null;if(f&&f.type==='staff'&&f.userId&&DB.users.find(v=>v.id===f.userId))return f.userId;const sd=c&&c.serviceStaffId?DB.dealers.find(x=>x.id===c.serviceStaffId):null;if(sd&&sd.type==='staff'&&sd.userId&&DB.users.find(v=>v.id===sd.userId))return sd.userId;const dd=c&&c.dealerId?DB.dealers.find(x=>x.id===c.dealerId):null;if(dd&&dd.type==='staff'&&dd.userId&&DB.users.find(v=>v.id===dd.userId))return dd.userId;return '';}
function payOwnerDealerName(e,p){const id=(p&&p.pDealerId)||(e&&e.dealerId)||'';if(!id)return '';const dd=DB.dealers.find(x=>x.id===id);return dd?(dd.name||''):'';}
/* ★ 第 415 轮(2026-10-12 主人拍板):业绩归属列 —— 员工段与经销商段为同一人时只显示一个名字(避免同名重复显示) */
function payOwnerLabel(e,p){const sn=payOwnerStaffName(e,p)||'—';const pd=(p&&p.pDealerId&&e&&p.pDealerId!==e.dealerId)?((DB.dealers.find(x=>x.id===p.pDealerId)||{}).name||''):'';return sn+((pd&&pd!==sn)?' / '+esc(pd):'');}
/* 业绩归属(按交款级)显示助手(2026-09-14 用户要求:统一口径 = 按财务收款登记的交款业绩归属员工/经销商,与经营看板⑤⑦同源)。
仅用于「显示」,不改任何数据与算法:逐笔走 payOwnerStaffName(交款级 pStaffId 优先 → 服务人员 → 客户档案链)。 */
function payOwnerBreakdown(c){const m=new Map();((c&&c.payments)||[]).forEach(p=>{const a=Number((p&&p.amount)||0);if(!p||!p.date||a<=0)return;const nm=payOwnerStaffName(c,p)||'—';const cur=m.get(nm)||{name:nm,amt:0,n:0,first:String(p.date||'')};cur.amt+=a;cur.n++;if(!cur.first||String(p.date||'')<cur.first)cur.first=String(p.date||'');m.set(nm,cur);});return Array.from(m.values()).sort((a,b)=>b.amt-a.amt||String(a.first).localeCompare(String(b.first))||String(a.name).localeCompare(String(b.name),'zh-CN'));}
function payOwnerText(c){const rows=payOwnerBreakdown(c);return rows.length?rows.map(r=>r.name).join(' / '):'';}
function payOwnerTip(c){const rows=payOwnerBreakdown(c);if(!rows.length)return '该客户暂无交款记录';return '业绩归属(按交款级):'+rows.map(r=>fTxt('c_payowner',r.name)+' '+perfOwnerAmtTxt(r.amt)+'('+r.n+' 笔)').join(' · ');}
function payOwnerCellHtml(c){const rows=payOwnerBreakdown(c);if(!rows.length)return '<span class="mtxt">—</span>';const nmTxt=fTxt('c_payowner',rows.map(r=>r.name));const multi=rows.length>1?'<span class="pill" style="background:#e0e7ff;color:#3730a3;margin-left:4px">多归属</span>':'';return `<span title="${esc(payOwnerTip(c))}"><b>${nmTxt}</b>${multi}</span>`;}
/* ===== 员工业绩归属拆分说明(2026-09-14 用户要求)=====
口径:交款级业绩归属(每笔交款 pStaffId 优先 → 服务人员 → 客户档案链),
与「业绩归属(按交款)」列 / 服务端 payStaffOwnerOf / 经营看板⑤⑦ 同源。
规则:仅当该客户业绩归属 ≥2 名员工(剔除「未归属业绩」占位)时,
才在分润相关的说明处列出「归属每位员工的业绩金额」。
纯显示:不改分润算法、不改任何分润金额(分润金额仍全额归该行经销商)。 */
function perfOwnerNamed(c){return payOwnerBreakdown(c).filter(function(r){return r.name&&r.name!=='—';});}
function perfOwnerMulti(c){return perfOwnerNamed(c).length>=2;}
function perfOwnerAmtTxt(v){var lv=fLevel('amount');if(lv==='full')return money(v);if(lv==='mask')return '¥***';return '(无权限)';}
function perfOwnerDetailRows(c){return payOwnerBreakdown(c).map(function(r){return fTxt('c_payowner',r.name==='—'?'未归属业绩':r.name)+' '+perfOwnerAmtTxt(r.amt)+'('+r.n+' 笔)';});}
function perfOwnerNote(c){if(!perfOwnerMulti(c))return '';return '业绩归属:'+perfOwnerDetailRows(c).join('、');}
function perfOwnerJoin(reason,note){var a=[];if(reason)a.push(reason);if(note)a.push(note);return a.join(';');}
function perfOwnerMultiItems(d){var out=[];(DB.customers||[]).forEach(function(c){if(!c)return;if(!(c.dealerId===d.id||(d.type==='staff'&&c.serviceStaffId===d.id&&c.dealerId!==d.id)))return;if(!perfOwnerMulti(c))return;out.push(c);});return out.slice().sort(function(a,b){return String(a.name||'').localeCompare(String(b.name||''),'zh-CN');});}
function perfOwnerItemTip(c){return fTxt('c_name',(c&&c.name)?c.name:'(未命名)')+':'+perfOwnerDetailRows(c).join('、');}
function perfOwnerDealerTip(d){return perfOwnerMultiItems(d).map(perfOwnerItemTip).join(' | ');}
function perfOwnerDealerBadgeHtml(d){var its=perfOwnerMultiItems(d);if(!its.length)return '';return '<div style="margin-top:4px"><span class="pill" style="background:#e0e7ff;color:#3730a3" title="'+esc(perfOwnerDealerTip(d))+'">多归属 '+its.length+' 家</span></div>';}
function perfOwnerDealerBlockHtml(d){var its=perfOwnerMultiItems(d);if(!its.length)return '';var tb=its.map(function(c){return '<div style="margin-top:3px">'+esc(perfOwnerItemTip(c))+'</div>';}).join('');return '<div style="margin-top:8px;padding:8px 10px;background:#eef2ff;border:1px solid #c7d2fe;border-radius:8px;font-size:12px;color:#3730a3;line-height:1.7"><b>业绩归属多名员工</b> <span class="mtxt">(分润金额不拆分,仍全额计本经销商)</span>'+tb+'</div>';}
/* ===== 服务老师(2026-09-14 新增)前端:显示助手 + 多选选择器 =====
口径(用户拍板):customers[].serviceTeacherIds = 【员工用户 id】数组,一个客户可指定多名服务老师;
服务老师两种身份:员工类经销商走【经销商端】查看、管理端员工走【管理端】查看(2026-09-18 第 68 轮扩展);
服务老师角色登录后由服务端按行级只下发「被指派客户」,前端这里只负责「显示」与「批量指派」。 */
// [BT-2026-09-14] 客户列表页批量勾选/批量指定 之门禁助手
// 「服务老师视角」= 角色并集里只有 r_svcteacher(与 can() 的多角色取并集语义保持一致:
// 若同一账号同时拥有 operator 等管理角色,则不算服务老师视角,不隐藏批量操作)
function svcTeacherViewOnly(){
try{
if(!currentUser)return false;
var rs=(currentUser.roles&¤tUser.roles.length)?currentUser.roles:(currentUser.role?[currentUser.role]:[]);
if(rs.indexOf('admin')>=0)return false;
return rs.indexOf('r_svcteacher')>=0&&rs.every(function(r){return r==='r_svcteacher';});
}catch(e){return false;}
}
// 客户列表是否开放「勾选 + 批量指定」:需要查看客户权限 + 单独持有「批量操作」权限(assign_cust_ops),且不是纯服务老师视角
// 2026-09-14 权限加固:此前只校验 view_customers,导致「只看不改」的只读角色也能批量改写对接员工 / 服务老师 / 下次催款 / 合同金额;
// 现改为独立权限键 assign_cust_ops,可由管理员在「角色权限 → 客户批量操作」单独授予或收回;
// 服务端 protectSensitiveChanges 已同步补 serviceStaffId / serviceTeacherIds 两条回退,防绕过前端直连 API。
function custSelVisible(){try{return can('view_customers')&&can('assign_cust_ops')&&!svcTeacherViewOnly();}catch(e){return false;}}
function svcTeacherNames(c){const a=(c&&Array.isArray(c.serviceTeacherIds))?c.serviceTeacherIds:[];return a.map(function(id){const u=DB.users.find(function(x){return x.id===id;});return u?(u.name||u.username||''):'';}).filter(Boolean);}
function svcTeacherText(c){return svcTeacherNames(c).join(' / ');}
function svcTeacherCellHtml(c){
const ids=(c&&Array.isArray(c.serviceTeacherIds))?c.serviceTeacherIds:[];
if(!ids.length)return '<span class="mtxt">—</span>';
const txt=fTxt('d_name',svcTeacherText(c)||'—');
const multi=ids.length>1?'<span class="pill" style="background:#ecfdf5;color:#047857;margin-left:4px">多老师</span>':'';
return '<span title="'+esc('服务老师('+ids.length+' 人):'+svcTeacherText(c))+'"><b>'+txt+'</b>'+multi+'</span>';
}
function svcStaffNameOf(c){if(!c||!c.serviceStaffId)return '';var d=DB.dealers.find(function(x){return x.id===c.serviceStaffId;});return (d&&d.type==='staff')?(d.name||''):'';}
function svcStaffText(c){return svcStaffNameOf(c);}
function svcStaffCellHtml(c){var nm=svcStaffNameOf(c);if(!nm)return '<span class="mtxt">—</span>';return '<span title="'+esc('对接员工:'+nm)+'"><b>'+fTxt('d_name',nm)+'</b></span>';}
function injectSvcTeacherStyles(){
if(document.getElementById('svtStyle'))return;
const s=document.createElement('style');s.id='svtStyle';
s.textContent='.svt-multi{position:relative}'
+'.svt-box{display:flex;align-items:center;min-height:32px;padding:4px 26px 4px 8px;border:1px solid var(--line);border-radius:8px;background:#fff;cursor:pointer}'
+'.svt-chips{display:flex;gap:4px;flex-wrap:wrap}'
+'.svt-ph{color:#94a3b8;font-size:12.5px}'
+'.svt-chip{display:inline-flex;align-items:center;gap:4px;background:#ecfdf5;color:#047857;border:1px solid #a7f3d0;border-radius:999px;padding:1px 6px;font-size:12px}'
+'.svt-chip-x{cursor:pointer;font-weight:700;color:#059669}'
+'.svt-caret{position:absolute;right:8px;top:8px;color:#94a3b8;pointer-events:none}'
+'.svt-list{display:none;position:fixed;z-index:9999;background:#fff;border:1px solid var(--line);border-radius:9px;box-shadow:0 8px 24px rgba(15,23,42,.14);max-height:260px;overflow:auto}'
+'.svt-multi.open .svt-list{display:block}'
+'.svt-search{padding:6px;border-bottom:1px solid var(--line);position:sticky;top:0;background:#fff}'
+'.svt-sch{width:100%;padding:5px 8px;border:1px solid var(--line);border-radius:7px;font-size:12.5px}'
+'.svt-opt{display:flex;align-items:center;gap:6px;padding:6px 10px;cursor:pointer;font-size:13px}'
+'.svt-opt:hover{background:#f1f5f9}'
+'.svt-opt .svt-sub{color:#94a3b8;font-size:11.5px;margin-left:auto}'
+'.svt-opt .svt-ck{color:#059669;font-weight:700;min-width:12px}'
+'.svt-empty{display:none;padding:8px 10px;color:#94a3b8;font-size:12.5px}'
+'.svt-foot{display:flex;gap:10px;justify-content:flex-end;padding:6px 10px;border-top:1px solid var(--line);position:sticky;bottom:0;background:#fff}'
+'.svt-foot span{cursor:pointer;color:#4f46e5;font-size:12.5px}';
document.head.appendChild(s);
}
function svcTeacherMultiHTML(o){
o=o||{};const id=o.id;const items=o.items||[];const selected=Array.isArray(o.selected)?o.selected.slice():[];
injectSvcTeacherStyles();
const nm=function(v){const it=items.find(function(x){return (x.id||x.value)===v;});if(it)return it.name||'';const u=(DB.users||[]).find(function(x){return x.id===v;});return u?(u.name||u.username||v):v;};
const chips=selected.length?selected.map(function(v){return '<span class="svt-chip">'+esc(nm(v))+'<span class="svt-chip-x" data-del="'+esc(v)+'" title="移除">×</span></span>';}).join(''):'<span class="svt-ph">未指定(可多选)</span>';
const opts=items.map(function(it){
const v=it.id||it.value||'';const on=selected.indexOf(v)>=0;
return '<div class="svt-opt'+(on?' sel':'')+'" data-v="'+esc(v)+'" data-s="'+esc(((it.name||'')+' '+(it.sub||it.campus||'')+' '+(it.username||'')).toLowerCase())+'"><span>'+esc(it.name||'')+'</span>'+((it.sub||it.campus)?'<span class="svt-sub">'+esc(it.sub||it.campus)+'</span>':'')+'<span class="svt-ck">'+(on?'✓':'')+'</span></div>';
}).join('');
return '<div class="svt-multi" id="'+esc(id)+'_wrap"><div class="svt-box" data-toggle><span class="svt-chips">'+chips+'</span><span class="svt-caret">▾</span></div><div class="svt-list"><div class="svt-search"><input type="text" class="svt-sch" id="'+esc(id)+'_sch" name="'+esc(id)+'_q" placeholder="搜索员工姓名 / 校区…" autocomplete="off"></div><div class="svt-opts">'+opts+'</div><div class="svt-empty">无匹配员工</div><div class="svt-foot"><span class="svt-all">全选</span><span class="svt-none">清空</span></div></div></div>';
}
function bindSvcTeacherMulti(wrapId,onChange){
const wrap=document.getElementById(wrapId);if(!wrap||wrap.dataset.bound)return;wrap.dataset.bound='1';
const box=wrap.querySelector('.svt-box');const list=wrap.querySelector('.svt-list');const chips=wrap.querySelector('.svt-chips');const sch=wrap.querySelector('.svt-sch');
const nameOf=function(){const m={};wrap.querySelectorAll('.svt-opt').forEach(function(o){const sp=o.querySelector('span');m[o.getAttribute('data-v')]=sp?sp.textContent:'';});return m;};
const sync=function(){
const nmm=nameOf();const sel=[];
wrap.querySelectorAll('.svt-opt').forEach(function(o){const on=o.classList.contains('sel');if(on)sel.push(o.getAttribute('data-v'));const ck=o.querySelector('.svt-ck');if(ck)ck.textContent=on?'✓':'';});
chips.innerHTML=sel.length?sel.map(function(v){return '<span class="svt-chip">'+esc(nmm[v]||v)+'<span class="svt-chip-x" data-del="'+esc(v)+'" title="移除">×</span></span>';}).join(''):'<span class="svt-ph">未指定(可多选)</span>';
if(typeof onChange==='function'){try{onChange(sel.slice());}catch(e){}}
};
const filter=function(q){q=(q||'').trim().toLowerCase();let any=false;wrap.querySelectorAll('.svt-opt').forEach(function(o){const s=o.getAttribute('data-s')||'';const show=!q||s.indexOf(q)>=0;o.style.display=show?'':'none';if(show)any=true;});const e=wrap.querySelector('.svt-empty');if(e)e.style.display=any?'none':'block';};
const close=function(){wrap.classList.remove('open');if(sch)sch.value='';filter('');};
const open=function(){wrap.classList.add('open');const r=box.getBoundingClientRect();list.style.position='fixed';list.style.left=r.left+'px';list.style.top=(r.bottom+4)+'px';list.style.width=Math.max(r.width,230)+'px';setTimeout(function(){if(sch)sch.focus();},20);};
box.addEventListener('click',function(e){
const del=(e.target&&e.target.getAttribute)?e.target.getAttribute('data-del'):null;
if(del){const o=wrap.querySelector('.svt-opt[data-v="'+del+'"]');if(o)o.classList.remove('sel');sync();e.preventDefault();return;}
if(wrap.classList.contains('open'))close();else open();
});
if(sch)sch.addEventListener('input',function(){filter(sch.value);});
wrap.querySelectorAll('.svt-opt').forEach(function(o){o.addEventListener('click',function(e){e.stopPropagation();o.classList.toggle('sel');sync();});});
const all=wrap.querySelector('.svt-all');const none=wrap.querySelector('.svt-none');
if(all)all.addEventListener('click',function(e){e.stopPropagation();wrap.querySelectorAll('.svt-opt').forEach(function(o){if(o.style.display!=='none')o.classList.add('sel');});sync();});
if(none)none.addEventListener('click',function(e){e.stopPropagation();wrap.querySelectorAll('.svt-opt').forEach(function(o){o.classList.remove('sel');});sync();});
/* ★ 第 643 轮(主人 2026-10-27「修」):全局点击监听只挂一次。
旧写法每次 bind 都往 document 上挂一个 click,而「客户详情·修改」每次打开都会重建 wrap ⇒
监听器线性累积且永不释放(第 642 轮实测:打开 20 次详情,document.click 3 → 24,内存泄漏 + 点击退化)。
改为「单例监听 + 实例注册表」:新实例登记时顺手剔除已从 DOM 移除的旧实例(isConnected=false)⇒
监听器全程只有 1 个,闭包随注册表回收。关闭语义一字未改(仍是「点外面就关」)。 */
window.__svtReg = (window.__svtReg || []).filter(function (r) { return r && r.wrap && r.wrap.isConnected; });
window.__svtReg.push({ wrap: wrap, close: close });
if (!window.__svtDocBound) {
window.__svtDocBound = 1;
document.addEventListener('click', function (e) {
(window.__svtReg || []).slice().forEach(function (r) {
try { if (r && r.wrap && r.wrap.isConnected && !r.wrap.contains(e.target)) r.close(); } catch (_) {}
});
});
}
sync();
}
function svcTeacherSelValues(wrapId){
const w=document.getElementById(wrapId)||document.getElementById(wrapId+'_wrap');if(!w)return [];
return Array.prototype.slice.call(w.querySelectorAll('.svt-opt.sel')).map(function(o){return o.getAttribute('data-v');});
}
/* ---- 【2026-09-18 第 68 轮】服务老师:两种身份 + 「我服务的客户」 ----
语义变更:serviceTeacherIds 由「员工类经销商 id」改为「员工用户 id(users.id)」。
服务老师两种身份:① 员工类经销商(dealers.type==='staff' 且绑定 users 账号)⇒ 走【经销商端】查看;
② 管理端员工(users 有账号、无经销商记录)⇒ 走【管理端】查看。
本块只负责「候选名单 / 是否指派给我 / 列表入口筛选」,可见范围一律由服务端裁决(前后端不同源会失效)。 */
function svcTeacherCurUid(){try{return (currentUser&¤tUser.id)?String(currentUser.id):'';}catch(e){return '';}}
function svcTeacherMine(c){
var uid=svcTeacherCurUid();if(!uid||!c)return false;
var a=Array.isArray(c.serviceTeacherIds)?c.serviceTeacherIds:[];
return a.indexOf(uid)>=0;
}
function svcTeacherMineBadge(c){
if(!svcTeacherMine(c))return '';
return ' <span class="pill" style="background:#ecfdf5;color:#047857;border:1px solid #a7f3d0;white-space:nowrap" title="该客户已指派给你作为服务老师">👩🏫 我服务的</span>';
}
/* 第 196 轮 · 关联账户标记:同组(沿 linkedRootId 追链根 / 同客户编号)档案数 > 1 即视为关联组 */
var _lkSetCache=null,_lkSetSig='';
function linkedCustSet(){
var sig=((DB&&DB.rev)||0)+'/'+((DB&&DB.customers||[]).length);
if(_lkSetCache&&_lkSetSig===sig)return _lkSetCache;
var g={};
(DB&&DB.customers||[]).forEach(function(c){ if(!c||!c.id)return; var k=custGroupKeyOf(c); (g[k]=g[k]||[]).push(c.id); });
var s={};
Object.keys(g).forEach(function(k){ if(g[k].length>1)g[k].forEach(function(id){s[id]=1;}); });
_lkSetCache=s;_lkSetSig=sig;return s;
}
function isLinkedCust(c){return !!(c&&c.id&&linkedCustSet()[c.id]);}
function linkedBadge(c){
if(!isLinkedCust(c))return '';
return ' <span class="pill" style="background:#e0e7ff;color:#3730a3;border:1px solid #c7d2fe;white-space:nowrap" title="关联账户:同一客户的多项目 / 多校区档案,客户编号自动同号,手机号可按校区各自修改,客户家数按客户去重">🔗 关联</span>';
}
let custGrpOpen={};let _custGrpInfo=null;
let _multiCache=null,_multiSig='';
function custMultiScopeSet(){var sig=((DB&&DB.rev)||0)+'/'+((DB&&DB.customers||[]).length);if(_multiCache&&_multiSig===sig)return _multiCache;var mk={},cnt={};(DB&&DB.customers||[]).forEach(function(c){if(!c||!c.id)return;var k=custGroupKeyOf(c);cnt[k]=(cnt[k]||0)+1;if(c.campusScope==='multi')mk[k]=1;});var r={};Object.keys(mk).forEach(function(k){r[k]=cnt[k]||0;});_multiCache=r;_multiSig=sig;return r;}
function custScopeMulti(c){if(!c)return false;var m=custMultiScopeSet();return (m[custGroupKeyOf(c)]||0)>0;}
/* 第 659 轮:校区归属 campusOf —— 该档案所属校区的代表档案 id(空 = 自成校区)。
只影响客户列表折不折,不参与家数 / 编号 / 查重 / 分润 / 金额。 */
function custCampusOn(c){if(!c||!c.id)return false;var _a=[c].concat(linkedCustList(c)||[]);var _anyMulti=false;for(var i=0;i<_a.length;i++){if(_a[i]&&_a[i].campusScope==='multi'){_anyMulti=true;break;}}if(!_anyMulti)return false;for(var j=0;j<_a.length;j++){if(_a[j]&&_a[j].campusOf)return true;}return false;}
function custCampusRepOf(c){if(!c||!c.id)return '';var all=(DB&&DB.customers)||[];var byId={};for(var i=0;i<all.length;i++){if(all[i]&&all[i].id)byId[all[i].id]=all[i];}function up(x){if(x.campusOf)return byId[x.campusOf]||null;if(x.campusScope==='multi')return null;if(x.linkedRootId)return byId[x.linkedRootId]||null;return null;}var x=c,n=0;while(n++<8){var pp=up(x);if(!pp||pp.id===x.id)break;x=pp;}return x.id||c.id||'';}
function custFoldKeyOf(c){if(!c)return '';var k=custGroupKeyOf(c);if(custCampusOn(c))return k+'|@'+custCampusRepOf(c);return custScopeMulti(c)?(k+'|#'+c.id):k;}
function custScopeBadge(c){if(!c||!c.id)return '';if(custCampusOn(c)){var gi=_custGrpInfo&&_custGrpInfo[custFoldKeyOf(c)];if(!gi||gi.main!==c.id)return '';var sd={},cn=0;var ar=[c].concat(linkedCustList(c)||[]);for(var ai=0;ai<ar.length;ai++){var rp=custCampusRepOf(ar[ai]);if(rp&&!sd[rp]){sd[rp]=1;cn++;}}if(cn<2)return '';return ' <span class="pill" style="background:#f1f5f9;color:#475569;border:1px solid #cbd5e1;white-space:nowrap" title="本客户共 '+cn+' 个校区:同一校区的订单折叠成一行,不同校区的各占一行(改校区请到客户详情 → 🏫 校区)">🏫 本客户 '+cn+' 个校区</span>';}var m=custMultiScopeSet();var n=m[custGroupKeyOf(c)]||0;if(n<2)return '';return ' <span class="pill" style="background:#f1f5f9;color:#475569;border:1px solid #cbd5e1;white-space:nowrap" title="本客户有 '+n+' 个订单、分属不同校区:各订单独立、不折叠显示(改校区请到客户详情 → 🏫 校区)">🏫 不同校区 '+n+' 个</span>';}
function custGroupOrdered(list){var _g={},_order=[],out=[];list.forEach(function(c){var k=custGroupKeyOf(c);if(!_g[k]){_g[k]=[];_order.push(k);}_g[k].push(c);});_custGrpInfo={};_order.forEach(function(k){var arr=_g[k];var rootIx=-1;arr.forEach(function(x,ix){if(rootIx<0&&!x.linkedRootId)rootIx=ix;});var fg={},fo=[];arr.forEach(function(x){var f=custFoldKeyOf(x);if(!fg[f]){fg[f]=[];fo.push(f);}fg[f].push(x);});if(rootIx>=0){var rf=custFoldKeyOf(arr[rootIx]);var ri=fo.indexOf(rf);if(ri>0){fo.splice(ri,1);fo.unshift(rf);}}fo.forEach(function(f){var blk=fg[f];var gi={main:blk[0].id,n:blk.length};if(blk.length>1){var bi=-1;blk.forEach(function(x,ix){if(bi<0&&!x.linkedRootId)bi=ix;});if(bi>0){var r=blk.splice(bi,1)[0];blk.unshift(r);gi.main=r.id;}}_custGrpInfo[f]=gi;out.push.apply(out,blk);});});return out;}
function custGrpRowAttr(c){var k=custFoldKeyOf(c);var gi=_custGrpInfo&&_custGrpInfo[k];if(!gi||gi.n<2||gi.main===c.id)return '';return ' class="cust-subrow" data-grp="'+esc(k)+'" style="display:'+(custGrpOpen[k]?'':'none')+'"';}
function custGrpBadge(c){var k=custFoldKeyOf(c);var gi=_custGrpInfo&&_custGrpInfo[k];if(!gi||gi.n<2||gi.main!==c.id)return '';var op=!!custGrpOpen[k];return ' <span class="pill cust-grpb" data-grp="'+esc(k)+'" style="background:#eef2ff;color:#4338ca;border:1px solid #c7d2fe;cursor:pointer;white-space:nowrap" onclick="custGrpToggle(this.getAttribute(\'data-grp\'))" title="同一校区共 '+gi.n+' 张订单(第 657 轮:同一校区折叠、不同校区不折叠)—— 点击展开 / 收起">📦 共 '+gi.n+' 单 '+(op?'▴':'▾')+'</span>';}
function custGrpToggle(k){if(!k)return;custGrpOpen[k]=!custGrpOpen[k];var op=!!custGrpOpen[k];document.querySelectorAll('#custTable tr.cust-subrow').forEach(function(tr){if(tr.getAttribute('data-grp')===k)tr.style.display=op?'':'none';});var n=(_custGrpInfo&&_custGrpInfo[k])?_custGrpInfo[k].n:0;document.querySelectorAll('#custTable span.cust-grpb').forEach(function(b){if(b.getAttribute('data-grp')===k)b.innerHTML='📦 共 '+n+' 单 '+(op?'▴':'▾');});try{custSelAllSync();}catch(e){}try{custSelBarUpdate();}catch(e){}}
function toggleCustMine(){custFilter.mine=!custFilter.mine;renderCustomers();}
function svcTeacherCandidates(){
var out=[],seen={};
(DB.dealers||[]).forEach(function(d){
if(!d||d.type!=='staff'||!d.userId)return;
var uid=String(d.userId);if(seen[uid])return;seen[uid]=1;
var u=(DB.users||[]).find(function(x){return x.id===d.userId;});
out.push({id:uid,name:(u?(u.name||u.username):'')||d.name||uid,username:String((u&&u.username)||''),sub:'员工类经销商·走经销商端'});
});
(DB.users||[]).forEach(function(u){
if(!u)return;
if(u.dealerId)return; // 员工类经销商已由上一条收录,避免重复
if(u.active===false)return;
var rs=(u.roles&&u.roles.length)?u.roles:(u.role?[u.role]:[]);
if(rs.indexOf('admin')>=0)return; // 超管不参与业务指派
var uid=String(u.id);if(seen[uid])return;seen[uid]=1;
out.push({id:uid,name:u.name||u.username||uid,username:String(u.username||''),sub:(rs.indexOf('r_svcteacher')>=0?'管理端员工·服务老师角色':'管理端员工·走管理端')});
});
out.sort(function(a,b){
var pa=(a.sub.indexOf('员工类经销商')===0)?0:1,pb=(b.sub.indexOf('员工类经销商')===0)?0:1;
if(pa!==pb)return pa-pb;
return String(a.name).localeCompare(String(b.name),'zh-CN');
});
return out;
}
function refundOwnerFields(c,selD,selU){if(!c)return '';const dItems=DB.dealers.map(x=>({id:x.id,name:(x.name||'')+(x.type==='staff'?'(员工)':''),phone:x.phone||''}));const uItems=DB.dealers.filter(x=>x.type==='staff'&&x.userId).map(x=>{const u=DB.users.find(v=>v.id===x.userId);return {id:x.id,name:(u?u.name:x.name)||'',phone:(u&&u.phone)||x.phone||'',sub:'员工'};});return `<div class="row2"><div class="field"><label>冲减归属经销商</label>${smartSelectHTML({id:'rf_owner_d',items:dItems,selectedId:selD||'',displayKey:'name',placeholder:'搜索经销商 / 员工(可打拼音首字母)',emptyLabel:'(默认:唯一归属自动冲减)'})}</div><div class="field"><label>冲减归属员工</label>${smartSelectHTML({id:'rf_owner_u',items:uItems,selectedId:selU||'',displayKey:'name',subKey:'sub',placeholder:'搜索员工姓名(可打拼音首字母)',emptyLabel:'(默认:唯一归属自动冲减)'})}</div></div>`;}
function refundOwnerLabel(e,r){const nm=(id)=>{const d=DB.dealers.find(x=>x.id===id);if(!d)return '';if(d.type==='staff'){const u=DB.users.find(v=>v.id===d.userId);return u?u.name:(d.name||'');}return d.name||'';};const isStaff=(id)=>{const d=DB.dealers.find(x=>x.id===id);return d&&d.type==='staff';};const parts=[];if(r&&r.rStaffId&&isStaff(r.rStaffId))parts.push(nm(r.rStaffId));if(r&&r.rDealerId&&!isStaff(r.rDealerId))parts.push(nm(r.rDealerId));return parts.length?parts.join(' / '):'按交款归属自动冲减';}
function refundOwnerHint(c){if(!c)return '';const us=new Set();const ds=new Set();((c.payments)||[]).forEach((p)=>{if((+p.amount||0)<=0)return;const sn=payOwnerStaffName(c,p);if(sn)us.add(sn);const did=(p&&p.pDealerId)?p.pDealerId:(c.dealerId||'');const d=did?DB.dealers.find(x=>x.id===did):null;if(d&&d.type!=='staff')ds.add(did);});if(refundOwnerRequired(c)){return `<div class="warn-box" style="margin:8px 0">⚠️ 该客户交款归属不唯一或未登记归属:未指定冲减归属时,本笔退费将<b>全部计为无主</b>(不计入任何员工业绩/经销商收款)。如需冲减请在下方下拉手动指定归属。</div>`;}return '';}
function savePaymentEdit(type,id,pid){const e=getFinEntity(type,id);if(!e)return;const p=(e.payments||[]).find(x=>x.id===pid);if(!p)return;if(type==='c'&&paidLockHit(e)&&!paidLockAfterPayout(e,p)){const _M=['微信','支付宝','银行转账','现金','对公转账','其他'];const _nd=document.getElementById('ep_date').value||p.date;const _na=Number(document.getElementById('ep_amt').value);const _ndd=Number((document.getElementById('ep_deduct')||{}).value)||0;const _nm=document.getElementById('ep_method').value;const _no=payOwnerVals('ep_');const _curM=(_M.indexOf(p.method||'')>=0?(p.method):_M[0]);const _chg=(((_nd||'')!==(p.date||''))||(Math.abs(_na-Number(p.amount||0))>0.004)||(Math.abs(_ndd-Number(deductOf(p)||0))>0.004)||((_nm||'')!==(_curM||''))||((_no.pDealerId!==undefined)&&((_no.pDealerId||'')!==((p.pDealerId||e.dealerId||'')+'')))||((_no.pStaffId!==undefined)&&((_no.pStaffId||'')!==((p.pStaffId!==undefined?p.pStaffId:payStaffDealerIdOf(e))+''))));if(_chg){toast('该客户分润已发放:交款记录已锁定,仅「备注」可修改(如需改动请先撤回/修改对应发放记录)');return;}p.note=document.getElementById('ep_note').value.trim();save().then(function(ok){if(ok)toast('已修改交款记录(仅备注)');});openPayment(type,id);refreshFin(type);return;}const amt=Number(document.getElementById('ep_amt').value);if(!amt||amt<=0){toast('请填写有效金额');return;}const __rawD=Number((document.getElementById('ep_deduct')||{}).value)||0;if(__rawD<0){toast('抵扣金额不能为负数');return;}const __ded=__rawD;if(__ded>amt){toast('抵扣金额不能大于交款金额');return;}if(!payOwnerRequired(type,'ep_','业绩归属经销商'))return;if(!payStaffOwnerRequired(type,'ep_'))return;p.date=document.getElementById('ep_date').value||p.date;p.amount=amt;p.deduct=__ded;p.method=document.getElementById('ep_method').value;p.note=document.getElementById('ep_note').value.trim();const __ow2=payOwnerVals('ep_');if(__ow2.pDealerId!==undefined)p.pDealerId=__ow2.pDealerId;if(__ow2.pStaffId!==undefined)p.pStaffId=__ow2.pStaffId;if(typeof e.stage==='string')syncCustomerStage(e);if(type==='c')syncConvertedDealer(e);syncSignDateFromPayments(e);save().then(function(ok){if(ok)toast('已修改交款记录');});openPayment(type,id);refreshFin(type);}
/* ===== 财务收款导出(2026-09-12)=====
设计:三条子页签各自的高级筛选链 + 行内搜索关键字,统一收敛到 finDataList(),
渲染函数改为调用它(单一数据源,杜绝「屏幕筛了、导出没筛」);
导出走弹窗选范围(当前筛选 / 全部),复用交付导出的弹窗与 CSV 下载件。 */
function finDealerSearchStr(d){return ((fTxt('d_name',d.name))+' '+dtName(d)+' '+(fLevel('phone')==='full'?(d.phone||''):'')+' '+dealerCustomerKeywords(d.id)).toLowerCase();}
function finCustSearchStr(c){return ((c.custNo||'')+' '+(fTxt('c_name',c.name))+' '+(fLevel('c_school')==='full'?(c.school||''):'')+' '+(fLevel('phone')==='full'?(c.phone||''):'')+' '+dealerName(c.dealerId)).toLowerCase();}
function finRecSearchStr(r){return ((r.cname||'')+' '+(r.dname||'')+' '+(r.ownU||'')+' '+(r.method||'')+' '+(r.note||'')+' '+String(r.date||'')+' '+String(r.amount||'')+' '+(r.type==='refund'?'退费':'收款')).toLowerCase();}
function finDataList(scope,noKw){const isAll=(scope==='all');const sub=finSub;
if(sub==='records'){
let rows=[];DB.customers.forEach(c=>{(c.payments||[]).forEach(p=>{if(!p||!p.date)return;const d=DB.dealers.find(x=>x.id===c.dealerId);const _pd=p.pDealerId?DB.dealers.find(x=>x.id===p.pDealerId):null;rows.push({date:p.date,amount:Number(p.amount||0),deduct:Number(p.deduct||0),actual:actualOf(p),method:p.method||'',note:p.note||'',cid:c.id,cname:c.name,dname:_pd?_pd.name:(d?d.name:'—'),ownU:payOwnerStaffName(c,p)||'—',items:orderItemsTextOf(c),type:'pay',exp:quotaOnlyArchive(c,DB)});});(c.refunds||[]).forEach(r=>{if(!r||!r.date)return;const d=DB.dealers.find(x=>x.id===c.dealerId);rows.push({date:r.date,amount:Number(r.amount||0),deduct:0,actual:0,method:'退费',note:r.note||'',cid:c.id,cname:c.name,dname:d?d.name:'—',ownU:refundOwnerLabel(c,r),items:orderItemsTextOf(c),type:'refund',exp:quotaOnlyArchive(c,DB)});});});
rows.sort((a,b)=>b.date.localeCompare(a.date));
if(!isAll){const rg=finRecRange;if(rg.type)rows=rows.filter(r=>r.type===rg.type);if(rg.dateStart)rows=rows.filter(r=>r.date>=rg.dateStart);if(rg.dateEnd)rows=rows.filter(r=>r.date<=rg.dateEnd);if(rg.amtMin!=='')rows=rows.filter(r=>r.amount>=(+rg.amtMin));if(rg.amtMax!=='')rows=rows.filter(r=>r.amount<=(+rg.amtMax));if(rg.hasNote)rows=rows.filter(r=>String(r.note||'').trim()!=='');}
if(!isAll&&!noKw){const q=String(finRecKw||'').trim().toLowerCase();if(q)rows=rows.filter(r=>finRecSearchStr(r).indexOf(q)>=0);}
return {sub:'records',rows:rows};
}
if(sub==='dealer'){
let list=DB.dealers.slice();const f=finFilter;
if(!isAll){list=list.filter(d=>chipHit(f.dtype,d.type));if(f.amtMin!==''||f.amtMax!=='')list=list.filter(d=>finAmtInRange(Number(d.amount||0),f.amtMin,f.amtMax));if(f.paidMin!==''||f.paidMax!=='')list=list.filter(d=>finAmtInRange(paidOf(d),f.paidMin,f.paidMax));if(f.owedMin!==''||f.owedMax!=='')list=list.filter(d=>finAmtInRange(owedOf(d),f.owedMin,f.owedMax));}
if(!isAll&&!noKw){const q=String(finDealerKw||'').trim().toLowerCase();if(q)list=list.filter(d=>finDealerSearchStr(d).indexOf(q)>=0);}
return {sub:'dealer',rows:list};
}
let list=DB.customers.slice();const f2=finFilter;
if(!isAll){if(f2.dealer)list=list.filter(c=>c.dealerId===f2.dealer);if(f2.status==='unpaid')list=list.filter(c=>owedOf(c)>0);else if(f2.status==='paid')list=list.filter(c=>!c.refunded&&owedOf(c)<=0&&Number(c.amount||0)>0);if(f2.staff)list=list.filter(c=>c.serviceStaffId===f2.staff);if(f2.intent)list=list.filter(c=>Array.isArray(c.intent)?c.intent.includes(f2.intent):c.intent===f2.intent);if(f2.contract==='1')list=list.filter(c=>!!c.contractSigned);else if(f2.contract==='0')list=list.filter(c=>!c.contractSigned);if(f2.install==='1')list=list.filter(c=>c.installment);else if(f2.install==='0')list=list.filter(c=>!c.installment);if(f2.refund==='1')list=list.filter(c=>c.refunded);else if(f2.refund==='0')list=list.filter(c=>!c.refunded);if(f2.reportStart)list=list.filter(c=>c.reportDate&&c.reportDate>=f2.reportStart);if(f2.reportEnd)list=list.filter(c=>c.reportDate&&c.reportDate<=f2.reportEnd);if(f2.signStart)list=list.filter(c=>c.signDate&&c.signDate>=f2.signStart);if(f2.signEnd)list=list.filter(c=>c.signDate&&c.signDate<=f2.signEnd);if(f2.payStart)list=list.filter(c=>(c.payments||[]).some(p=>p.date&&p.date>=f2.payStart));if(f2.payEnd)list=list.filter(c=>(c.payments||[]).some(p=>p.date&&p.date<=f2.payEnd));if(f2.payKw){const pq=f2.payKw.trim().toLowerCase();list=list.filter(c=>(c.payments||[]).some(p=>((p.note||'')+' '+(p.method||'')).toLowerCase().includes(pq)));}if(f2.amtMin!==''||f2.amtMax!=='')list=list.filter(c=>finAmtInRange(Number(c.amount||0),f2.amtMin,f2.amtMax));if(f2.paidMin!==''||f2.paidMax!=='')list=list.filter(c=>finAmtInRange(paidOf(c),f2.paidMin,f2.paidMax));if(f2.owedMin!==''||f2.owedMax!=='')list=list.filter(c=>finAmtInRange(owedOf(c),f2.owedMin,f2.owedMax));}
if(!isAll&&!noKw){const q=String(finFilter.kw||'').trim().toLowerCase();if(q)list=list.filter(c=>finCustSearchStr(c).indexOf(q)>=0);}
return {sub:'customer',rows:list};
}
const FIN_SUB_NAME={customer:'转介绍客户财务',dealer:'经销商财务',records:'收款记录'};
function finExportTitle(){return '财务收款 · '+(FIN_SUB_NAME[finSub]||finSub);}
function finExportDesc(sub){if(sub==='dealer')return '一行 = 一个经销商(含公司员工账户)的合同 / 保证金、已交款、抵扣、实收、欠款与下次补款提醒。';if(sub==='records')return '一行 = 一笔收款或退费流水(含交款金额、抵扣、实收与业绩归属)。';return '一行 = 一个转介绍客户的合同金额、已交款、抵扣、实收、欠款与下次补款提醒。';}
function finMoneyCell(v){return fLevel('amount')==='full'?Math.round(Number(v||0)):'';}
function buildFinCsv(scope){const d=finDataList(scope,false);const sub=d.sub;const out=[];
if(sub==='dealer'){
out.push(['序号','经销商','类型','联系电话','合同总额','已交款','抵扣金额','实收金额','欠款','交款笔数','下次补款提醒']);
d.rows.forEach((x,i)=>{const paid=paidOf(x),owed=owedOf(x),rem=nextPayReminder(x);out.push([i+1,fTxt('d_name',x.name),dtName(x),(fLevel('phone')==='full'?(x.phone||''):''),finMoneyCell(x.amount),finMoneyCell(paid),finMoneyCell(deductTotalOf(x)),finMoneyCell(actualPaidOf(x)),finMoneyCell(owed),(x.payments||[]).length,(rem&&rem.text?rem.text:'')]);});
return out;
}
if(sub==='records'){
out.push(['序号','日期','类型','客户','订单/项目','归属经销商','业绩归属','交款金额','抵扣金额','实收金额','方式','备注']);
d.rows.forEach((r,i)=>{const isRef=(r.type==='refund');out.push([i+1,r.date,(isRef?'退费':'收款'),r.cname,(r.items||''),r.dname,(r.ownU||''),finMoneyCell(r.amount),(isRef?'':finMoneyCell(r.deduct)),(isRef?'':finMoneyCell(r.actual)),(isRef?'退费':(r.method||'')),(r.note||'')]);});
return out;
}
out.push(['序号','经销商','客户校区','客户姓名','合同总额','已交款','抵扣金额','实收金额','欠款','交款笔数','最近交款日期','下次补款提醒']);
d.rows.forEach((c,i)=>{const paid=paidOf(c),owed=owedOf(c),rem=nextPayReminder(c);const ps=c.payments||[];const last=ps.length?ps[ps.length-1]:null;out.push([i+1,dealerName(c.dealerId),fTxt('c_school',c.school),fTxt('c_name',c.name),finMoneyCell(c.amount),finMoneyCell(paid),finMoneyCell(deductTotalOf(c)),finMoneyCell(actualPaidOf(c)),finMoneyCell(owed),ps.length,(last&&last.date?last.date:''),(rem&&rem.text?rem.text:'')]);});
return out;
}
function openFinExport(){if(!can('export_reconcile')){toast('无权限:导出财务收款需「导出财务对账」权限');return;}
const f=finDataList('filter',false),a=finDataList('all',false);
let h='';
h+='<div class="modal-head"><h3>📥 导出 '+finExportTitle()+'</h3><span class="x" onclick="closeModal()">×</span></div>';
h+='<div class="modal-body">';
h+='<div style="margin-bottom:14px"><div style="font-size:12.5px;font-weight:600;color:#334155;margin-bottom:6px;letter-spacing:.2px">导出范围</div>';
h+='<div style="border:1px solid var(--line);border-radius:9px;padding:10px 12px;margin-bottom:8px"><label style="display:flex;align-items:center;gap:8px;cursor:pointer"><input type="radio" name="fx_scope" id="fx_scope_filter" value="filter" checked style="flex:none;width:auto;margin:0"> <span>当前筛选结果 <b>'+f.rows.length+'</b> 条</span></label><div class="mtxt" style="margin-left:24px;margin-top:2px">包含当前页签的高级筛选条件与搜索关键字</div></div>';
h+='<div style="border:1px solid var(--line);border-radius:9px;padding:10px 12px"><label style="display:flex;align-items:center;gap:8px;cursor:pointer"><input type="radio" name="fx_scope" id="fx_scope_all" value="all" style="flex:none;width:auto;margin:0"> <span>全部(忽略筛选) <b>'+a.rows.length+'</b> 条</span></label><div class="mtxt" style="margin-left:24px;margin-top:2px">不受任何筛选与关键字影响</div></div>';
h+='</div>';
h+='<div class="mtxt" style="margin-top:4px">导出为 CSV(UTF-8 带 BOM,可直接用 Excel 打开)。'+finExportDesc(finSub)+'</div>';
if(fLevel('amount')!=='full')h+='<div class="warn-box" style="margin-top:8px">当前角色未开放「查看金额」字段权限,导出的金额列将为空。</div>';
h+='</div>';
h+='<div class="modal-foot"><button class="btn ghost" onclick="closeModal()">取消</button><button class="btn" onclick="execFinExport()">📥 导出 CSV</button></div>';
openModal(h);
}
function execFinExport(){const selF=document.getElementById('fx_scope_filter');const scope=(selF&&selF.checked)?'filter':'all';const rows=exportDropRows(buildFinCsv(scope),'财务');if(!rows||rows.length<=1){toast('当前范围无可导出的数据');return;}const d=new Date();const p2=n=>String(n).padStart(2,'0');const ts=''+d.getFullYear()+p2(d.getMonth()+1)+p2(d.getDate())+p2(d.getHours())+p2(d.getMinutes());const nm='财务收款_'+(FIN_SUB_NAME[finSub]||finSub)+'_'+((scope==='all')?'全部':'筛选')+'_'+ts+'.csv';closeModal();delivDownloadCsv(nm,rows);}
function renderReports(){const g=globalStats();const rows=DB.dealers.map(d=>{const s=dealerStats(d.id);return`<tr>
<td><span class="tag ${DT(d.type).color}">${DT(d.type).name.replace('经销商','')}</span> ${d.name}</td>
<td>${s.reported}</td><td>${s.experienced}</td>
<td>${pct(s.convRate)}</td><td>${s.signedAll}</td><td><b>${pct(s.closeRate)}</b></td>
<td>${moneyMask(fLevel('amount'), commissionPaid(d))}</td>
<td>${moneyMask(fLevel('amount'), s.commission)}</td></tr>`;}).join('')||'<tr><td colspan="8" class="empty">暂无数据</td></tr>';const comm=DB.customers.filter(c=>c.stage==='signed_full'&&!c.refunded).map(c=>{const d=DB.dealers.find(x=>x.id===c.dealerId);const rate=commissionRateFor(d,c);const rows=[];if(rate>0&&d)rows.push(`<tr><td>${c.name}</td><td>${d.name}</td><td>${moneyMask(fLevel('amount'), c.amount)}</td>
<td>${fLevel('amount_kpi')==='full'?pct(rate*100):(fLevel('amount_kpi')==='mask'?'***':'****')}</td><td><b>${moneyMask(fLevel('amount_kpi'), Math.round((c.amount||0)*rate))}</b></td>
<td>${c.signDate||'—'}</td><td class="mtxt">${esc(perfOwnerNote(c)||'—')}</td></tr>`);const stf=c.serviceStaffId?DB.dealers.find(x=>x.id===c.serviceStaffId):null;if(stf&&stf.type==='staff'&&c.dealerId!==stf.id&&c.contractSigned&&!c.installment&&!c.refunded&&staffMonthlyPaid(stf.id,payClearMonth(c))>=100000)rows.push(`<tr><td>${c.name}</td><td>${stf.name}(服务)</td><td>${moneyMask(fLevel('amount'), c.amount)}</td>
<td>${pct(DT('staff').baseRate*100)}</td><td><b>${moneyMask(fLevel('amount'), Math.round((c.amount||0)*DT('staff').baseRate))}</b></td>
<td>${c.signDate||'—'}</td><td class="mtxt">${esc(perfOwnerNote(c)||'—')}</td></tr>`);return rows;}).flat().join('')||'<tr><td colspan="7" class="empty">暂无已成交分润</td></tr>';const months={};DB.customers.forEach(c=>{const m=(c.reportDate||'').slice(0,7);if(m)months[m]=(months[m]||0)+1;});const mk=Object.keys(months).sort();const maxM=Math.max(1,...Object.values(months));const trend=mk.length?mk.map(m=>`<div class="fstep"><div class="flabel">${m}</div>
<div class="fbar"><i style="width:${months[m]/maxM*100}%"><span class="fval">${months[m]}</span></i></div></div>`).join(''):'<div class="empty">暂无报备数据</div>';view.innerHTML=`
<div class="topbar"><div><h1>转化 · 成交报表</h1><p>转化率、成交率与分润核算</p></div></div>
<div class="grid kpis" style="grid-template-columns:repeat(4,1fr)">
${kpiCard('🔄','整体转化率',pct(g.convRate),'报备→体验')}
${kpiCard('💰','整体成交率',pct(g.closeRate),'报备→签约','#16a34a')}
${kpiCard('🎯','体验成交率',pct(g.signConv),'体验→签约','#0891b2')}
${kpiCard('🏆','累计应发分润',money(g.commission),'全款实收×比例','#d97706')}
</div>
<div class="section-title">📊 趋势图表</div>${repChartsHtml()}<div class="section-title">各经销商转化与成交对比</div>
<div class="card"><table>
<thead><tr><th>经销商</th><th>报备
</th><th>体验</th><th>转化率</th><th>签约</th><th>成交率</th><th>已发放</th><th>应发分润</th></tr></thead>
<tbody>${rows}</tbody></table></div>
<div class="grid two-col">
<div class="card"><h4 style="font-size:14px;margin-bottom:12px">月度报备趋势</h4>${trend}</div>
<div class="card"><h4 style="font-size:14px;margin-bottom:12px">分润发放规则提示</h4>
<p class="mtxt" style="font-size:12.5px;line-height:1.8">
• 资源渠道/官方事业:前3家签约后7-10工作日发放;第4家起每月5号对账、次月15号发放。<br>
• 体验官:前3家7-10工作日;第4家起月5号对账次月15号。<br>
• 校区自用:前5家仅抵扣时长,第6家起每月5号对账次月15号发20%。<br>
• 退费:未启动/启动≤60工作日退费,7-10工作日内退回对应分润;超60工作日原则上不退。</p>
</div>
</div>
<div class="section-title">分润明细(已签约全款 · 未退费)</div>
<div class="card"><table>
<thead><tr><th>客户</th><th>归属经销商</th><th>全款金额</th><th>分润比例</th><th>应发分润</th><th>签约日</th><th>说明</th></tr></thead>
<tbody>${comm}</tbody></table></div>`;}
function renderRules(){if(!can('view_rules')){curView='dashboard';render();return;}
const rules=[{t:'资源报备 · 文字为准',c:'info',p:'所有意向资源统一文字报备,口头、语音、电话报备不予认可。未通过系统报备达成成交,业绩分润不归属于该经销商。'},{t:'归属判定 · 首次报备',c:'info',p:'同一意向客户,以系统首次报备时间判定归属。本系统录入时自动查重(同名/同手机),先报先得。'},{t:'保护周期 · 60天',c:'warn',p:`资源报备保护周期为 2 个月(${PROTECTION_DAYS} 天),逾期未成交自动重新释放到市场(标记"保护过期流失")。`},{t:'退费退回分润',c:'warn',p:'客户退费须退回已发分润:未启动项目7日内全额退;启动≤60工作日期间退费亦全额退;启动超60工作日原则上不再有退费纠纷。'},{t:'分期订单不计分润',c:'warn',p:'体验官及各类经销商的"分期订单"不计入分润与考核业绩,仅全款实收客户计佣。'},{t:'红线① 扰乱价格',c:'danger',p:'严禁扰乱市场统一价格,禁止返现、平分利润、私下折扣等私下交易,一经发现罚款 10万元/次。'},{t:'红线② 私下授权',c:'danger',p:'严禁私下收取合作费、私下对外下放品牌授权,违者立即清退、没收所有分润,且不对私下交易客户与金额负责。'},{t:'红线③ 超标准承诺',c:'danger',p:'禁止向客户做出超出官方标准的口头、书面额外承诺。'},{t:'红线④ 严重违规',c:'danger',p:'严重违规立即取消所有分润、取消使用权限、清退出体系,并追究法律及口碑连带责任。'},{t:'服务分工',c:'info',p:'经销商负责邀约与产品使用体验沟通;公司精英销售负责谈单签合同;公司服务部承接后期全流程服务;双方共同长期维护客情关系。'},{t:'服务到期 · 客户',c:'info',p:'按交款金额自动计算到期:2 万元/月,交款不足 2 万整数倍时,余数按「服务项目开通那个月的天数」折算成天、向上取整;累计交款满 10 万=12 个月(套餐,取较晚者,套餐外余数同样折算);已交款=合同金额(视为全款)=12 个月;续交 10 万再 +12 个月。'},{t:'服务到期 · 官方事业经销商',c:'info',p:'交款 10 万元=12 个月,续交 10 万元再 +12 个月;套餐外余数按 2 万元/月折算(含天数);已交款 = 合同金额(视为全款)= 12 个月(特批价同样适用)。'},{t:'服务到期 · 资源渠道推广经销商',c:'info',p:'2 个月免费 + 转介绍 1 家全款客户 +2 个月。'},{t:'服务到期 · 体验官经销商',c:'info',p:'2 个月免费 + 转介绍 1 家全款客户 +1 个月。'},{t:'服务到期 · 校区自用推广经销商',c:'info',p:'按交款金额 2 万元/月(余数按「服务项目开通那个月的天数」折算、向上取整)+ 转介绍 1 家全款客户 +2 个月。'},{t:'服务到期 · 通用规则',c:'warn',p:'客户转经销商按转换后经销商类型规则执行;公司员工不参与到期计算;到期时间按各服务项目「安装/开通时间」起算(非交付创建时间),特殊情况/合同约定可手动修改;下次补款日期=客户 VR元宇宙大场景服务到期时间,自动同步财务收款。'},{t:'系统自检 · 防复发',c:'info',p:'凡是修复的问题,自动在系统自检中增加对应检查项,做到同类问题一次修复、永久防复发;自检发现异常可一键修复(数据类)或标红提示(代码类),并自动推送到管理员本人的企业微信。'},];view.innerHTML=`
<div class="topbar"><div><h1>业务规则 · 红线铁律</h1><p>内置《元墨VR经销商权益说明》核心条款,系统自动执行校验</p></div></div>
<div class="grid two-col">${rules.map(r=>`<div class="rule-card ${r.c}"><h4>${r.t}</h4><p>${r.p}</p></div>`).join('')}</div>`;}
function _mbg(){var mb=document.getElementById('modalBg');if(!mb){mb=document.createElement('div');mb.id='modalBg';mb.className='modal-bg';document.body.appendChild(mb);}
if(!mb.classList.contains('modal-bg'))mb.classList.add('modal-bg');
return mb;}
function _mdl(){var mb=_mbg();var m=document.getElementById('modal');if(!m||m.parentNode!==mb){m=document.createElement('div');m.id='modal';m.className='modal';while(mb.firstChild)mb.removeChild(mb.firstChild);mb.appendChild(m);}
if(!m.classList.contains('modal'))m.classList.add('modal');return m;}
function openModal(html,opts){var m=_mdl();m.innerHTML=html;bindOwnerSelects();if(m.querySelector('.payhist')){m.classList.add('modal-lg');m.classList.remove('modal-xl');m.classList.remove('modal-md');}else if(m.querySelector('.cust-detail')){m.classList.add('modal-xl');m.classList.remove('modal-lg');m.classList.remove('modal-md');}else if(m.querySelector('.fu-wide')||m.querySelector('.lk-order')||m.querySelector('.lk-svc')){m.classList.add('modal-md');m.classList.remove('modal-lg');m.classList.remove('modal-xl');}else{m.classList.remove('modal-lg');m.classList.remove('modal-xl');m.classList.remove('modal-md');}var mb=_mbg();mb.classList.add('show');m.scrollTop=0;if(opts&&typeof opts.onOpen==='function')try{opts.onOpen();}catch(e){}if(!mb._wcObserver){mb._wcObserver=true;new MutationObserver(function(){if(!mb.classList.contains('show')&&mb._wcOnClose){try{mb._wcOnClose();}catch(e){}mb._wcOnClose=null;}}).observe(mb,{attributes:true,attributeFilter:['class']});}if(opts&&typeof opts.onClose==='function')mb._wcOnClose=opts.onClose;}
function closeModal(){var mb=document.getElementById('modalBg');if(mb)mb.classList.remove('show');if(typeof __syncFlushPending==='function'){try{__syncFlushPending();}catch(e){}}}
document.addEventListener('keydown',function(e){if(e.key==='Escape')closeModal();});function dealerLockedFields(d){if(!d)return{};if(d.type==='staff')return{stage:true};if(d.fromCustomerId)return{name:true,phone:true,prov:true,city:true,district:true,addr:true,signDate:true,stage:true,contractSigned:true,contractDate:true,amount:true};return{};}
function openDealer(id){if(!id?!can('add_dealers'):!can('edit_dealers')){toast('无权限:仅管理员可'+(!id?'新增':'编辑')+'经销商');return;}
const d=id?DB.dealers.find(x=>x.id===id):null;const locked=dealerLockedFields(d);const typeOpts=Object.entries(DEALER_TYPES).map(([k,c])=>`<option value="${k}" ${d&&d.type===k?'selected':''}>${c.name}(预缴${money(c.fee)}·${pct(c.baseRate*100)})</option>`).join('');openModal(`
<div class="modal-head"><h3>${d?'编辑':'新增'}经销商</h3><span class="x" onclick="closeModal()">×</span></div>
<div class="modal-body">
<div class="field"><label>${locked.name?'🔒 经销商名称 <span style="font-size:11px;color:#d97706;margin-left:4px">继承自客户·只读</span>':'经销商名称 *'}</label><input id="f_name" value="${d?d.name:''}" placeholder="如:星空教育·王老师" ${locked.name?'disabled style="background:#f1f5f9;color:#94a3b8;cursor:not-allowed"':''}></div>
<div class="field"><label>经销商类型 *</label><select id="f_type">${typeOpts}</select>
<div class="hint">不同类型决定预缴费用与分润比例,详见权益说明。</div></div>
<div class="row2">
<div class="field"><label>${locked.phone?'🔒 联系电话 <span style="font-size:11px;color:#d97706;margin-left:4px">继承自客户·只读</span>':'联系电话'}</label><input id="f_phone" value="${d?d.phone||'':''}" placeholder="选填" ${locked.phone?'disabled style="background:#f1f5f9;color:#94a3b8;cursor:not-allowed"':''}></div>
<div class="field"><label>所在区域</label><input id="f_region" value="${d?d.region||'':''}" placeholder="如:华东·杭州"></div>
</div>
<div class="section-title" style="margin:14px 0 8
px">联系地址</div>
${locked.addr?dealerAddrLockedHTML(d):areaSelectHTML('f',(d&&d.prov)||'',(d&&d.city)||'',(d&&d.district)||'',(d&&d.addr)||'')}
${locked.signDate?`<div class="field"><label>🔒 达成合作日期<span style="font-size:11px;color:#d97706;margin-left:4px">继承自客户·只读</span></label><input type="date" id="f_sign" value="${d?d.signDate:today()}" disabled style="background:#f1f5f9;color:#94a3b8;cursor:not-allowed"></div>`:`<div class="field"><label>达成合作日期</label><input type="date" id="f_sign" value="${d?d.signDate:today()}"></div>`}
<div class="row2">
<div class="field"><label>登录密码</label><input id="f_pwd" type="password" placeholder="${d?'留空=不修改':'选填,默认888888'}"></div>
${locked.contractSigned?`<div class="field checkbox-field"><label style="display:flex;align-items:center;gap:8px;padding-top:18px"><input type="checkbox" id="f_contract" ${d&&d.contractSigned?'checked':''} disabled> <span>🔒 已签署经销合同<span style="font-size:11px;color:#d97706;margin-left:4px">继承自客户·只读</span></span></label></div>`:`<div class="field checkbox-field"><label style="display:flex;align-items:center;gap:8px;cursor:pointer;padding-top:18px"><input type="checkbox" id="f_contract" ${d&&d.contractSigned?'checked':''}> <span>已签署经销合同</span></label></div>`}
</div>
<div class="field"><label>备注</label><textarea id="f_note" rows="2">${d?d.note||'':''}</textarea></div>
</div>
<div class="modal-foot">
<button class="btn ghost" onclick="closeModal()">取消</button>
<button class="btn" onclick="saveDealer('${id||''}')">保存</button>
</div>`);setTimeout(()=>bindAreaSelect('f'),0);}
function ensureStaffUser(dealer){if(!dealer||dealer.type!=='staff')return;if(dealer.userId){if(DB.users.find(u=>u.id===dealer.userId))return;}let username=dealer.name||'';if(!username)return;if(DB.users.some(u=>u.username===username)){username=username+(dealer.phone?String(dealer.phone).slice(-4):'');}const u={id:uid('u_'),username,name:dealer.name,password:dealer.password||'888888',role:'',active:true,dealerId:dealer.id};DB.users.push(u);dealer.userId=u.id;}
function saveDealer(id){if(!id?!can('add_dealers'):!can('edit_dealers')){toast('无权限:仅管理员可'+(!id?'新增':'编辑')+'经销商');return;}
const name=document.getElementById('f_name').value.trim();const type=document.getElementById('f_type').value;if(!name){toast('请填写经销商名称');return;}
const pwd=document.getElementById('f_pwd').value;const willSign=document.getElementById('f_contract').checked;const signDate=f('f_sign');if(willSign&&!signDate){toast('已签署经销合同需填写达成合作日期');return;}if(id){const d=DB.dealers.find(x=>x.id===id);const wasSigned=d.contractSigned;const locked=dealerLockedFields(d);const upd={name:locked.name?d.name:name,type,phone:locked.phone?d.phone:f('f_phone'),prov:locked.prov?d.prov:f('f_prov'),city:locked.city?d.city:f('f_city'),district:locked.district?d.district:f('f_district'),addr:locked.addr?d.addr:f('f_addr'),region:f('f_region'),signDate:locked.signDate?d.signDate:signDate,note:f('f_note'),contractSigned:locked.contractSigned?d.contractSigned:willSign};if(pwd)upd.password=pwd;Object.assign(d,upd);ensureStaffUser(d);if(willSign&&!wasSigned){d.stage='signed_full';triggerReminder('dealer_contract','经销商「'+name+'」签订合同','dealers',id);}}
else{DB.dealers.push({id:uid('d_'),name,type,phone:f('f_phone'),prov:f('f_prov'),city:f('f_city'),district:f('f_district'),addr:f('f_addr'),region:f('f_region'),signDate,note:f('f_note'),password:pwd||'888888',contractSigned:willSign,stage:willSign?'signed_full':'lead'});ensureStaffUser(DB.dealers[DB.dealers.length-1]);if(willSign)triggerReminder('dealer_contract','经销商「'+name+'」签约','dealers','');}save().then(ok=>{if(ok){closeModal();render();toast('已保存');}});}
function delDealer(id){if(!can('del_dealers')){toast('无权限:仅管理员可删除经销商');return;}
const dl=DB.dealers.find(d=>d.id===id);if(!dl)return;const _dlk=dealerDeleteLock(dl);if(_dlk){toast('禁止删除:经销商「'+dl.name+'」'+_dlk);return;}const custCount=DB.customers.filter(c=>c.dealerId===id).length;const delivCount=DB.deliveries.filter(x=>x.dealerId===id).length;const convKeep=DB.customers.filter(c=>c&&c.convertedDealerId===id&&c.dealerId!==id).length;if(!confirm('确认删除经销商「'+dl.name+'」?\n将连带删除:\n· 名下客户 '+custCount+' 个\n· 交付记录 '+delivCount+' 条\n\n'+(convKeep?('⚠️ 另有 '+convKeep+' 个客户「已转为该经销商」:删除后他们会回到「转介绍客户 → 未转经销商」清单,需要重新指定。'):'')+'\n\n此操作不可撤销,确定继续?'))return;DB.dealers=DB.dealers.filter(d=>d.id!==id);DB.customers=DB.customers.filter(c=>c.dealerId!==id);DB.deliveries=DB.deliveries.filter(x=>x.dealerId!==id);save().then(function(ok){if(!ok)return;render();toast('已删除');});}
function openCustomer(id){const c=id?DB.customers.find(x=>x.id===id):null;if(c){renderCustomerDetail(c);return;}
openModal(`
<div class="modal-head"><h3>新增转介绍报备</h3><span class="x" onclick="closeModal()">×</span></div>
<div class="modal-body">
<div id="dupWarn"></div>
<div class="row2">
<div class="field"><label>客户姓名 *</label><input id="c_name" placeholder="客户联系人" oninput="checkDup()"></div>
<div class="field"><label>联系电话 <span style="color:var(--danger)">*</span></label><input id="c_phone" placeholder="手机号,用于查重" oninput="checkDup()"></div>
</div>
<div class="field"><label>所属校区 / 机构</label><input id="c_school" placeholder="如:阳光幼儿园"></div>
<div class="field"><label>意向合作项目</label>${intentSingleHTML('c_intent','')}</div>
<div class="section-title" style="margin:14px 0 8px">联系地址</div>
${areaSelectHTML('c','','','','')}
<div class="field"><label>归属经销商 *</label>${DB.dealers.length?smartSelectHTML({id:'c_dealer', items:DB.dealers, displayKey:'name', subKey:'', emptyLabel:'— 不指定(按系统自动判定归属
) —', placeholder:'搜索经销商名称 / 校区 / 手机号…', optFn:d=>{ const t=DT(d.type); return { main: (t.name.replace('经销商','')+'·'+d.name), sub: (d.campus?d.campus+' · ':'')+(d.phone||'') }; }}):'<input id="c_dealer_fallback" disabled placeholder="请先添加经销商">'}<input type="hidden" id="c_dealer" value="">
<div class="hint">系统按"首次报备时间"判定归属,重复报备将提示冲突。</div></div>
<div class="field"><label>对接员工 <span style="color:var(--muted)">选填</span></label>${smartSelectHTML({id:'c_staff', items:DB.dealers.filter(d=>d.type==='staff'), displayKey:'name', emptyLabel:'— 无需对接员工 —', placeholder:'搜索员工姓名…', optFn:d=>{return {main:d.name, sub:d.campus||d.phone||''};}})}<div class="hint">该员工负责客户后期跟进与服务,客户月度交款计入其考核。</div></div>
<div class="field"><label>报备日期 <span class="hint-inline" title="系统按提交时刻自动锁定,不允许手动修改">🔒 系统自动填写</span></label><input type="date" id="c_report" value="${today()}" readonly tabindex="-1" onfocus="this.blur()" style="background:#f5f5f7;cursor:not-allowed;color:#666"></div>
</div>
<div class="modal-foot">
<button class="btn ghost" onclick="closeModal()">取消</button>
<button class="btn" onclick="saveCustomer()">提交报备</button>
</div>`);bindSmartSelect('c_dealer_wrap');bindSmartSelect('c_staff_wrap');bindAreaSelect('c');}
function checkDup(){const name=document.getElementById('c_name').value.trim();const phone=document.getElementById('c_phone').value.trim();const box=document.getElementById('dupWarn');const ex=findExistingReferral(phone);if(ex){const d=DB.dealers.find(x=>x.id===ex.dealerId);box.innerHTML=`<div class="warn-box">⚠ 该客户(${ex.name})已于 ${ex.reportDate} 由「${d?d.name:'—'}」报备并占用归属,重复报备将不计入您的业绩。</div>`;}else box.innerHTML='';}
function saveCustomer(){if(isPhoneLikeSchool((document.getElementById('c_school')||{}).value||'')){toast('「所属校区/机构」不能填电话号码,请改填校区/机构名称');return;}const name=document.getElementById('c_name').value.trim();const dealerId=document.getElementById('c_dealer').value;if(!name){toast('请填写客户姓名');return;}
if(!DB.dealers.length){toast('请先添加经销商');return;}
if(!dealerId){toast('请选择归属经销商');return;}
const cphone=document.getElementById('c_phone').value.trim();if(!/^1\d{10}$/.test(cphone)){toast('请填写正确的 11 位客户手机号(用于查重,避免同一客户重复报备)');return;}
const dEx=DB.dealers.find(x=>x.phone===cphone);if(dEx){showDealerPhoneModal(cphone,dEx);return;}
const ex=findExistingReferral(cphone);if(ex){showDupCustomerModal(ex);return;}
const nc={id:uid('c_'),name,phone:document.getElementById('c_phone').value.trim(),school:document.getElementById('c_school').value.trim(),intent:readIntentSingle('c_intent'),dealerId,serviceStaffId:(document.getElementById('c_staff')||{}).value||'',reportDate:today(),prov:document.getElementById('c_prov').value.trim(),city:document.getElementById('c_city').value.trim(),district:document.getElementById('c_district').value.trim(),addr:document.getElementById('c_addr').value.trim(),stage:'reported',amount:0,installment:false,refunded:false,signDate:'',contractSigned:false,followups:[]};DB.customers.push(nc);triggerReminder('customer_new','新客户「'+name+'」已报备','customers',nc.id);save().then(ok=>{if(ok){closeModal();render();toast('报备成功');}});}
/* 第 665 轮 · 「校区」取代「关联方式」开关:详情里列校区、逐档可改所属校区
数据仍用 customers[].campusOf(该档案所属校区的代表档案 id;空 = 自成校区)。
展示/折叠由 custFoldKeyOf 自动推导:组内有 campusOf ⇒ 按校区分块;否则沿用旧口径。 */
function campusNameOf(x){return (x&&(((x.school||'').trim())||((x.name||'').trim())))||'未命名校区';}
function campusGroupsOf(c){var all=[c].concat(linkedCustList(c)||[]);var map={},order=[];all.forEach(function(x){if(!x)return;var rp=custCampusRepOf(x);if(!map[rp]){map[rp]=[];order.push(rp);}map[rp].push(x);});return {order:order,map:map,all:all};}
function campusBlockHTML(c){var g=campusGroupsOf(c);if(g.all.length<2)return '';var byId={};g.all.forEach(function(x){if(x)byId[x.id]=x;});var canEdit=(typeof can==='function'&&can('edit_customers'));var h='<div class="card" style="margin:10px 0;padding:10px 12px">';h+='<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-bottom:6px"><b>🏫 校区</b><span class="mtxt">共 '+g.order.length+' 个校区(同一校区的订单一组,列表里折叠成一行)</span></div>';h+='<div style="display:flex;gap:8px;flex-wrap:wrap;margin-bottom:8px">'+g.order.map(function(rp){return '<span class="pill" style="background:#eef2ff;color:#4338ca;border:1px solid #c7d2fe;white-space:nowrap">'+esc(campusNameOf(byId[rp]))+' · '+g.map[rp].length+' 单</span>';}).join('')+'</div>';h+='<div class="tblbox"><table class="tbl"><thead><tr><th style="width:42%">订单</th><th>所属校区</th></tr></thead><tbody>';g.all.forEach(function(x){var rp=custCampusRepOf(x);var opts=g.order.map(function(r){return '<option value="'+esc(r)+'"'+(r===rp?' selected':'')+'>'+esc(campusNameOf(byId[r]))+'('+g.map[r].length+' 单)</option>';}).join('');opts+='<option value="__new">+ 独立成新校区</option>';var its=(typeof orderItemsTextOf==='function'?(orderItemsTextOf(x)||''):'');h+='<tr><td>'+esc(custNameText(x))+(its?' · '+esc(its):'')+'</td><td>'+(canEdit?'<select onchange="campusAssign(\''+esc(x.id)+'\',this.value)">'+opts+'</select>':'<span class="mtxt">'+esc(campusNameOf(byId[rp]))+'</span>')+'</td></tr>';});h+='</tbody></table></div><div class="mtxt" style="margin-top:6px">改「所属校区」后立即生效:同一校区的订单在客户列表折叠成一行,不同校区各占一行。</div></div>';return h;}
function campusAssign(xid,val){var x=DB.customers.find(function(y){return y.id===xid;});if(!x){toast('未找到档案');return;}if(!can('edit_customers')){toast('无权限:需「编辑客户」权限');return;}var all=[x].concat(linkedCustList(x)||[]);var rep=custCampusRepOf(x);/* 第668轮:改动前先留快照(撤回用)。★ 记的是「改动前」的值,与第663轮切换段同口径 */var _c68B=all.map(function(y){return y?{s:String(y.campusScope||''),o:String(y.campusOf||''),f:{dealerId:(y.dealerId||''),prov:(y.prov||''),city:(y.city||''),district:(y.district||''),addr:(y.addr||''),phone:(y.phone||'')}}:null;});/* 第670轮:组根不能单独立成校区 —— 组内还有订单挂在它名下时,「独立」会让这些订单各自变成独立校区 ⇒ 整组被拆散(实测 3 档同一校区点根 ⇒ 3 行)。此处拦下,提示对子档操作;「谁当新代表」属 B 方案,未定前不擅自代替。 */if(val==='__new'&&rep&&rep===x.id&&all.some(function(y){return y&&y.id!==x.id&&custCampusRepOf(y)===rep;})){toast('这是本客户的主档案(同校区其它订单都归在它下面),不能单独成为新校区;请对需要独立的子订单操作。');return;}if(val==='__new'){if(rep&&rep!==x.id)all.forEach(function(y){if(y&&y.id!==x.id&&!y.campusOf)y.campusOf=rep;});if(x.campusOf)delete x.campusOf;all.forEach(function(y){if(y)y.campusScope='multi';});}else if(val&&val!==x.id){x.campusOf=val;}else{if(x.campusOf)delete x.campusOf;}/* 第668轮:此处原为「整组无 campusOf ⇒ 把 all 的 campusScope 全打回 single」。实测有害:整组已是「不同校区、各档独立」时点「+ 独立成新校区」,它会把整组标回同一校区 ⇒ 列表由 N 行合并成 1 行(与「独立」意图相反);且第 665 轮之后已无任何入口能把 multi 改回 single,这行只剩副作用,故删除。 *//* 第668轮:只有本次真的改了 scope/of 才留快照(无变化时写快照会让「撤回」变成空操作,并覆盖上一次真值得撤回的快照) */var _c68C=false;all.forEach(function(y,i){var b=_c68B[i];if(!y||!b)return;if(String(y.campusScope||'')!==b.s||String(y.campusOf||'')!==b.o)_c68C=true;});if(_c68C){var _c68At=new Date().toISOString();all.forEach(function(y,i){var b=_c68B[i];if(!y||!b)return;y.campusUndo={at:_c68At,scope:b.s,of:b.o,fields:b.f};});/* 第670轮:快照必须取「改前」值(b = _c68B 的改前副本)。668 原写法取的是改后的 y.campusScope / y.campusOf ⇒ 撤回只是把同样的值写回去,数据没回退却提示「已撤回」。 */}save().then(function(ok){if(!ok)return;renderCustomerDetail(DB.customers.find(function(y){return y.id===xid;})||x);toast('已更新所属校区');});}
function renderCustomerDetail(c){const d=DB.dealers.find(x=>x.id===c.dealerId);const _cc=custCommissionOf(c);const rate=_cc.rate;const comm=_cc.comm;const tl=(c.followups||[]).slice().sort((a,b)=>new Date(b.date)-new Date(a.date));const _fk='c_'+c.id;const tlHtml=fLevel('c_followups')==='hidden'?'<div class="empty">无权限查看跟进记录</div>':fupListHtml(tl,_fk,{empty:'暂无跟进记录'});const stageOpts=Object.entries(STAGES).filter(([k])=>!(k==='refunded'&&!can('refund_finance')&&c.stage!=='refunded')).map(([k,v])=>`<option value="${k}" ${c.stage===k?'selected':''}>${v.label}</option>`).join('');openModal(`
<div class="modal-head"><h3>${custNameHtml(c)} · 跟进详情 ${fStageBadge('c_stage',c.stage)}</h3><span class="x" onclick="closeModal()">×</span></div>
<div class="modal-body cust-detail">
${campusUndoBarHTML(c)}
${campusBlockHTML(c)}
<div class="idbar">
<div class="idc"><div class="k">归属经销商</div>${can('edit_customers')?smartSelectHTML({id:'u_dealer',items:DB.dealers,selectedId:c.dealerId,displayKey:'name',emptyLabel:'',placeholder:'选择归属经销商…'}):dealerNameHtml(d)}<div class="mtxt">${d?DT(d.type).name:''}</div></div>
<div class="idc"><div class="k">对接员工</div>${can('assign_cust_ops')?smartSelectHTML({id:'u_staff',items:DB.dealers.filter(x=>x.type==='staff'),selectedId:c.serviceStaffId||'',displayKey:'name',emptyLabel:'— 无 —',placeholder:'选择对接员工…'}):(c.serviceStaffId?'<b>'+dealerName(c.serviceStaffId)+'</b>':'<span class="mtxt">—</span>')}</div>
<div class="idc"><div class="k">服务老师</div>${can('assign_cust_ops')?svcTeacherMultiHTML({id:'u_teacher',items:svcTeacherCandidates(),selected:c.serviceTeacherIds}):('<b>'+fTxt('d_name',svcTeacherText(c)||'—')+'</b>')}</div>
<div class="idc"><div class="k">合作项目</div>${intentTag(c)}</div>
</div>
<div class="kpirow">
<div class="card"><div class="mtxt">应发分润</div><b style="color:#d97706">${moneyMask(fLevel('amount_kpi'),comm)}</b><div class="mtxt">比例 ${fLevel('amount_kpi')==='full'?pct(rate*100):(fLevel('amount_kpi')==='mask'?'***':'****')}</div></div>
<div class="card"><div class="mtxt">业绩归属(按交款)</div><b style="color:#4f46e5">${fTxt('c_payowner',payOwnerText(c)||'—')}</b><div class="mtxt" style="margin-top:4px;line-height:1.6">${payOwnerBreakdown(c).map(r=>fTxt('c_payowner',r.name)+' '+perfOwnerAmtTxt(r.amt)+'('+r.n+' 笔)').join('<br>')||'该客户暂无交款记录'}</div></div>
<div class="card"><div class="mtxt">订单 / 项目</div>${custOrderCardHtml(c)}</div>
</div>
${cdFoldOf('💰 分润发放历史',custPayoutHistHtml(c),{open:!cdPayEmpty(c),hint:(fLevel('amount_kpi')==='hidden')?'无查看权限':(cdPayEmpty(c)?'未发放':'')})}
${cdFoldOf('🔗 关联档案 / 订单归属',linkedCustHtml(c),{open:!!linkedCustList(c).length,hint:linkedCustList(c).length?('共 '+linkedCustList(c).length+' 份'):'暂无',act:can('edit_customers')?'<button class="btn ghost sm" onclick="event.preventDefault();event.stopPropagation();openLinkedCustForm(\''+esc(c.id)+'\')">+ 添加</button>':''})}
<div class="addr-line" style="margin-bottom:14px;padding:10px 12px
;background:#f8fafc;border:1px solid var(--line);border-radius:8px">📍 <b>联系地址:</b><span id="cAddrView">${addrView(c)}</span></div>
<div style="margin-bottom:14px">📄 <b>客户合同:</b>${fLevel('c_sign')==='full'?(contractHtml(c.contractSigned)+(c.contractSigned?(c.contractDate?'<span style="color:#166534;font-size:13px;margin-left:6px">签署日期:'+c.contractDate+'</span>':'<span style="color:#d97706;font-size:13px;margin-left:6px">已签·未填签署日期</span>'):'')):('<span class="mtxt">'+(fLevel('c_sign')==='mask'?'***':'****')+'</span>')}</div>
${can('edit_customers')?`<details class="cd-fold" id="cdEditBox" open><summary><span class="cd-f-t">✏️ 修改档案信息</span><span class="cd-f-h">姓名 / 校区 / 金额 / 阶段 / 地址</span></summary><div class="cd-f-b"><div class="cols2"><div class="colL"><div class="row2"><div class="field"><label>阶段状态</label>${canField('c_stage','e')?('<select id="u_stage">'+stageOpts+'</select>'):('<span class="mtxt">'+(fLevel('c_stage')==='mask'?'***':'****')+'</span>')}</div><div class="field"><label>签约/全款金额</label><input type="number"id="u_amount"value="${c.amount||0}"placeholder="全款实收金额"step="0.01" ${canField('amount','e')?'':'disabled style="background:#f1f5f9;color:#94a3b8"'}></div></div><div class="row2"><div class="field"><label>客户姓名</label><input id="u_name"value="${c.name||''}" ${canField('c_name','e')?'':'disabled style="background:#f1f5f9;color:#94a3b8"'}></div>${phoneEditField('u_phone',c.phone,'联系电话')}</div><div class="row2"><div class="field"><label>所属校区/机构</label><input id="u_school"value="${c.school||''}"placeholder="如:阳光幼儿园" ${canField('c_school','e')?'':'disabled style="background:#f1f5f9;color:#94a3b8"'}></div><div class="field"><label>达成合作日期</label><input type="date"id="u_sign"value="${c.signDate||''}" ${canField('c_sign','e')?'':'disabled style="background:#f1f5f9;color:#94a3b8"'}></div></div><div class="field"><label>意向合作项目</label>${canField('c_intent','e')?intentSingleHTML('u_intent',c.intent):('<span class="mtxt">'+fTxt('c_intent',c.intent)+'</span>')}</div></div><div class="colR">${fLevel('addr')==='hidden'?'':'<div class="section-title"style="margin:14px 0 8px">联系地址</div>'}${addressEditHTML('u',c.prov,c.city,c.district,c.addr)}<div class="row2"><div class="field"><label>是否分期订单</label>${canField('c_installment','e')?`<select id="u_install"><option value="0"${!c.installment?'selected':''}>否(计分润)</option><option value="1"${c.installment?'selected':''}>是(不计分润)</option></select>`:`<span class="mtxt">${c.installment?'是(不计分润)':'否(计分润)'}(无「是否分期订单」编辑权限)</span>`}</div><div class="field"><label>是否退费</label><span class="mtxt">${c.refunded?'已退费':'否'}(由「财务收款 · 退费记录」维护,办理或撤销请到那里操作)</span></div></div></div></div></div></details>`:('<div class="mtxt" style="margin:10px 0">您当前角色无「转介绍客户」编辑权限,看不到跟进记录入口。</div>')}
<div class="section-title" style="margin:18px 0 10px">跟进记录(${tl.length})</div>
<div class="timeline" data-fup-list="${_fk}" style="margin-bottom:8px">${tlHtml}</div>${fLevel('c_followups')==='hidden'?'':fupMoreHtml(tl.length,_fk)}
${can('edit_customers')&&canField('c_followups','e')?`<details class="cd-fold" id="cdFupForm"><summary><span class="cd-f-t">+ 写跟进记录</span><span class="cd-f-h">点此展开</span></summary><div class="cd-f-b"><div style="margin:6px 0 10px;display:flex;gap:8px"><button class="btn ghost sm" onclick="copyFollowupTemplate('customer','${c.id}')">📋 复制模板</button><button class="btn sm" onclick="openQuickFollowup('customer','${c.id}')">📥 快速回填</button></div><div class="row2"><div class="field"><label>跟进方式</label><select id="customer_method"><option>微信</option><option>电话</option><option>面谈</option><option>体验课</option><option>专场会议</option><option>其他</option></select></div><div class="field"><label>跟进人</label><input id="f_op"value="${currentUser?currentUser.name:''}" placeholder="经销商/销售姓名"></div></div><div class="field"><label>跟进内容</label><textarea id="f_content"rows="2"placeholder="文字报备:沟通进展、下一步动作…"></textarea></div><div class="field"><label>下次跟进时间(选填,用于提醒)</label><input type="date"id="f_next"></div></div></details>`:''}
</div>
<div class="modal-foot">
${can('convert_dealer')&&!c.convertedDealerId?`<button class="btn warn"onclick="openConvertDealer('${c.id}')">🏷️ 转为经销商</button>`:(c.convertedDealerId?`<span class="pill" style="background:#dcfce7;color:#166534">✅ 已转为经销商</span>${can('convert_dealer')?` <button class="btn ghost sm"onclick="openRevertConvert('${c.id}')">撤销</button>`:''}`:'')}
<button class="btn ghost" onclick="closeModal()">关闭</button>
${can('edit_customers')?`<button class="btn"onclick="saveCustomerUpdate('${c.id}')">保存更新</button>`:''}
</div>`);if(can('edit_customers')){setTimeout(()=>bindSmartSelect('u_dealer_wrap'),0);setTimeout(()=>bindAreaSelect('u'),0);if(can('assign_cust_ops')){setTimeout(()=>bindSmartSelect('u_staff_wrap'),0);setTimeout(()=>bindSvcTeacherMulti('u_teacher_wrap'),0);}setTimeout(cdAfterRender,0);}}
function copyFollowupTemplate(type,id){let name='';let tpl='';if(type==='customer'){const c=DB.customers.find(x=>x.id===id);if(!c)return;name=c.name;tpl='【跟进记录】'+c.name+'\n日期:'+today()+'\n方式:微信\n跟进人:'+(currentUser?currentUser.name:'')+'\n内容:\n- 沟通进展:\n- 客户意向:\n- 下一步动作:';}else{const d=DB.dealers.find(x=>x.id===id);if(!d)return;name=d.name;tpl='【经销商跟进】'+d.name+'\n日期:'+today()+'\n方式:微信\n跟进人:'+(currentUser?currentUser.name:'')+'\n内容:\n- 沟通进展:\n- 下一步动作:';}copyText(tpl);toast('已复制「'+name+'」的跟进模板,可粘贴到企业微信/微信');}
function copyText(text){if(navigator.clipboard&&navigator.clipboard.writeText){navigator.clipboard.writeText(text).catch(()=>fallbackCopy(text));}else{fallbackCopy(text);}}
function fallbackCopy(text){const ta=document.createElement('textarea');ta.value=text;ta.style.position='fixed';ta.style.opacity='0';document.body.appendChild(ta);ta.select();try{document.execCommand('copy');}catch(e){}document.body.removeChild(ta);}
function openQuickFollowup(type,id){const obj=type==='customer'?DB.customers.find(x=>x.id===id):DB.dealers.find(x=>x.id===id);if(!obj)return;openModal(`
<div class="modal-head"><h3>📥 快速回填跟进 · ${esc(obj.name||'')}</h3><span class="x" onclick="closeModal()">×</span></div>
<div class="modal-body">
<div class="mtxt" style="margin-bottom:10px">把微信/企业微信的沟通内容粘贴到下面,一键保存为跟进记录(日期默认今天、方式默认微信)</div>
<div class="field"><textarea id="qf_content" rows="7" placeholder="粘贴沟通内容…"></textarea></div>
<div class="row2">
<div class="field"><label>跟进方式</label><select id="qf_method"><option>微信</option><option>电话</option><option>面谈</option><option>会议</option><option>其他</option></select></div>
<div class="field"><label>跟进人</label><input id="qf_op" value="${currentUser?currentUser.name:''}"></div>
</div>
</div>
<div class="modal-foot">
<button class="btn ghost" onclick="closeModal()">取消</button>
<button class="btn" onclick="saveQuickFollowup('${type}','${id}')">保存跟进</button>
</div>
`);setTimeout(()=>{const t=document.getElementById('qf_content');if(t)t.focus();},60);}
function saveQuickFollowup(type,id){if(type==='customer'){if(!canField('c_followups','e')){toast('无权限:不能写跟进');return;}}else if(!can('edit_dealers')&&!can('follow_customers')){toast('无权限:不能写跟进');return;}const content=(document.getElementById('qf_content').value||'').trim();if(!content){toast('请粘贴跟进内容');return;}const obj=type==='customer'?DB.customers.find(x=>x.id===id):DB.dealers.find(x=>x.id===id);if(!obj)return;obj.followups=obj.followups||[];obj.followups.push({date:today(),method:document.getElementById('qf_method').value,operator:document.getElementById('qf_op').value.trim(),content,nextAt:''});save().then(ok=>{if(ok){closeModal();render();toast('跟进已回填');}});}
function commissionPaid(d){return Number(d&&d.commissionPaid||0);}
function commissionPending(d){const s=dealerStats(d.id);return Math.max(0,s.commission-commissionPaid(d));}
function openCommissionPayout(id){const d=DB.dealers.find(x=>x.id===id);if(!d)return;const s=dealerStats(id);const paid=commissionPaid(d);const pend=commissionPending(d);const canPay=can('pay_commission');const canEditPo=can('edit_comm_payout');const canDelPo=can('del_comm_payout');const poAct=canEditPo||canDelPo;const recs=(d.payouts||[]).slice().sort((a,b)=>String(b.date||'').localeCompare(String(a.date||'')));const recHtml=recs.length?`<table style="width:100%;border-collapse:collapse;font-size:12px;margin-top:6px"><thead><tr style="background:#f8fafc"><th style="padding:6px 8px;text-align:left;border-bottom:1px solid var(--line)">发放日期</th><th style="padding:6px 8px;text-align:right;border-bottom:1px solid var(--line)">金额(元)</th><th style="padding:6px 8px;text-align:left;border-bottom:1px solid var(--line)">对应客户</th><th style="padding:6px 8px;text-align:left;border-bottom:1px solid var(--line)">备注</th>${poAct?`<th style="padding:6px 8px;text-align:right;border-bottom:1px solid var(--line)">操作</th>`:''}</tr></thead><tbody>${recs.map(r=>`<tr><td style="padding:6px 8px;border-bottom:1px dashed var(--line)">${r.date||'—'}</td><td style="padding:6px 8px;text-align:right;font-weight:600;color:#16a34a">${money(r.amount)}</td><td style="padding:6px 8px;border-bottom:1px dashed var(--line)">${(Array.isArray(r.alloc)&&r.alloc.length)?`<button type="button" class="btn ghost sm" onclick="poAllocShow('${d.id}','${r.id}')">${r.alloc.length} 家</button>`:`<button type="button" class="btn ghost sm" onclick="poAllocShow('${d.id}','${r.id}')" title="历史记录无客户明细,点击按当前数据推算">推算</button>`}</td><td style="padding:6px 8px;color:#64748b">${esc(r.note||'')}</td>${poAct?`<td style="padding:6px 8px;border-bottom:1px dashed var(--line);text-align:right;white-space:nowrap">${canEditPo?`<button class="btn ghost sm" onclick="editCommissionPayout('${d.id}','${r.id}')">修改</button>`:''}${canDelPo?`<button class="btn danger sm" onclick="delCommissionPayout('${d.id}','${r.id}')">删</button>`:''}</td>`:''}</tr>`).join('')}</tbody></table>`:`<div class="mtxt" style="padding:8px 0">暂无发放记录</div>`;openModal(`
<div class="modal-head"><h3>💰 分润发放 · ${d.name}</h3><span class="x" onclick="closeModal()">×</span></div>
<div class="modal-body">
<div class="grid three-col" style="margin-bottom:12px">
<div class="card" style="padding:10px"><div class="mtxt">应发分润</div><b style="color:#d97706">${money(s.commission)}</b></div>
<div class="card" style="padding:10px"><div class="mtxt">已发放</div><b style="color:#16a34a">${money(paid)}</b></div>
<div class="card" style="padding:10px"><div class="mtxt">待发放</div><b style="color:#dc2626">${money(pend)}</b></div>
</div>
${perfOwnerDealerBlockHtml(d)}
${canPay?`<div class="section-title" style="margin:14px 0 8px">选择要发放的客户(勾谁发谁,可只发部分金额)</div><div id="cp_pick" style="max-height:320px;overflow:auto;border:1px solid var(--line);border-radius:8px">${cpPickHtml(d)}</div><div class="row2" style="margin-top:10px"><div class="field"><label>发放日期</label><input type="date" id="cp_date" value="${today()}"></div><div class="field"><label>备注</label><input id="cp_note" placeholder="如:月度对账发放"></div></div><div id="cp_prev" style="margin-top:8px"><div class="mtxt">默认全选;改动后这里显示本次合计</div></div><div id="cp_err" style="color:var(--danger);font-size:12px;min-height:16px;margin-top:6px"></div><input type="hidden" id="cp_amount" value="0">`:`<div class="mtxt" style="padding:10px 12px;background:#f8fafc;border:1px solid var(--line);border-radius:8px">当前账号无「标记分红发放」权限,仅可查看发放记录。</div>`}
<div class="section-title" style="margin:16px 0 4px">📋 发放记录(${recs.length})</div>${recHtml}<div id="po_alloc_box" style="margin-top:8px"></div>
</div>
<div class="modal-foot">
<button class="btn ghost" onclick="closeModal()">关闭</button>
${canPay?`<button class="btn" onclick="saveCommissionPayout('${id}')">确认发放</button>`:''}
</div>`);}
function poAllocShow(did,pid){var d=DB.dealers.find(function(x){return x&&x.id===did;});if(!d)return;var r=(d.payouts||[]).find(function(p){return p&&p.id===pid;});if(!r)return;var box=document.getElementById('po_alloc_box');if(!box)return;var froz=Array.isArray(r.alloc)?r.alloc:null;var a=(froz&&froz.length)?froz:(commPayoutTraceOf(d)[pid]||[]);var head='<div class="mtxt" style="margin-bottom:6px">'+((froz&&froz.length)?'该笔发放的客户明细(发放时冻结):':'该笔为历史记录,以下按当前数据推算:')+money(r.amount)+' · '+esc(r.date||'')+'</div>';if(!a.length){box.innerHTML=head+'<div class="empty">该笔发放未覆盖任何客户(可能全部用于结转)</div>';return;}var rows=a.map(function(x){var cc=DB.customers.find(function(c){return c&&c.id===x.cid;});var nm=(cc&&cc.name)||x.name||'(客户已删除)';return '<tr><td style="padding:6px 8px;border-bottom:1px dashed var(--line)">'+esc(nm)+'</td><td style="padding:6px 8px;border-bottom:1px dashed var(--line);text-align:right;font-weight:600">'+moneyMask(fLevel('amount_kpi'),x.amt)+'</td></tr>';}).join('');var sum=a.reduce(function(s,x){return s+Number(x.amt||0);},0);box.innerHTML=head+'<table style="width:100%;border-collapse:collapse;font-size:12px"><thead><tr style="background:#f8fafc"><th style="padding:6px 8px;text-align:left;border-bottom:1px solid var(--line)">客户</th><th style="padding:6px 8px;text-align:right;border-bottom:1px solid var(--line)">本笔覆盖</th></tr></thead><tbody>'+rows+'</tbody></table><div class="mtxt" style="margin-top:6px">明细合计 '+moneyMask(fLevel('amount_kpi'),sum)+' / 该笔发放 '+moneyMask(fLevel('amount_kpi'),r.amount)+'</div>';}
function cpPrevUpdate(id){var d=DB.dealers.find(function(x){return x&&x.id===id;});if(!d)return;var box=document.getElementById('cp_prev');if(!box)return;var el=document.getElementById('cp_amount');var amt=parseFloat(el&&el.value)||0;var pend=commissionPending(d);if(!(amt>0)){box.innerHTML='<div class="mtxt">填写金额后显示本次将覆盖的客户</div>';return;}if(amt>pend+0.005){box.innerHTML='<div class="mtxt" style="color:var(--danger)">本次金额超过待发放 '+money(pend)+'</div>';return;}var a=commAllocExtraOf(d,amt);if(!a.length){box.innerHTML='<div class="mtxt">本次发放不覆盖任何客户(待发分润为 0,或仅有结转)</div>';return;}var rows=a.map(function(x){return '<tr><td style="padding:6px 8px;border-bottom:1px dashed var(--line)">'+esc(x.name||'(未命名)')+'</td><td style="padding:6px 8px;border-bottom:1px dashed var(--line);text-align:right;font-weight:600">'+moneyMask(fLevel('amount_kpi'),x.amt)+'</td></tr>';}).join('');var sum=a.reduce(function(s,x){return s+Number(x.amt||0);},0);box.innerHTML='<div class="mtxt" style="margin:2px 0 4px">本次发放将覆盖 '+a.length+' 家客户,合计 '+money(sum)+'</div><table style="width:100%;border-collapse:collapse;font-size:12px"><thead><tr style="background:#f8fafc"><th style="padding:6px 8px;text-align:left;border-bottom:1px solid var(--line)">客户</th><th style="padding:6px 8px;text-align:right;border-bottom:1px solid var(--line)">本次覆盖</th></tr></thead><tbody>'+rows+'</tbody></table>';}
function cdFoldOf(title,body,o){o=o||{};var op=o.open?' open':'';var id=o.id?(' id="'+o.id+'"'):'';var hd=o.hint?('<span class="cd-f-h">'+o.hint+'</span>'):'';var ac=o.act?('<span class="cd-f-a">'+o.act+'</span>'):'';return '<details class="cd-fold"'+id+op+'><summary><span class="cd-f-t">'+title+'</span>'+hd+ac+'</summary><div class="cd-f-b">'+body+'</div></details>';}
function cdPayEmpty(c){if(!c||!c.id)return true;var any=false;(DB.dealers||[]).forEach(function(d){if(any||!d||!Array.isArray(d.payouts))return;d.payouts.forEach(function(p){if(any||!p||!Array.isArray(p.alloc))return;p.alloc.forEach(function(x){if(x&&x.cid===c.id&&Number(x.amt||0)>0)any=true;});});});if(any)return false;var info=(typeof paidLockInfo==='function')?paidLockInfo(c):null;return !(info&&Number(info.sum||0)>0);}
function cdAfterRender(){try{var narrow=!!(window.matchMedia&&matchMedia('(max-width:600px)').matches);var e=document.getElementById('cdEditBox');if(e&&narrow)e.removeAttribute('open');var eb=document.getElementById('cdEditBox');var dup=!!(eb&&eb.open===true);var av=document.getElementById('cAddrView');var p=av&&av.closest?av.closest('.addr-line'):null;if(dup&&p&&p.parentNode)p.parentNode.removeChild(p);}catch(_e){}}
function custPayoutHistHtml(c){if(!c||!c.id)return '<div class="mtxt">—</div>';if(fLevel('amount_kpi')==='hidden')return '<div class="empty">无权限查看分润发放</div>';var rows=[],frozen=0;(DB.dealers||[]).forEach(function(d){if(!d||!Array.isArray(d.payouts))return;d.payouts.forEach(function(p){if(!p||!Array.isArray(p.alloc))return;p.alloc.forEach(function(x){if(!x||x.cid!==c.id)return;var v=Number(x.amt||0);if(!(v>0))return;frozen+=v;rows.push({date:p.date||'',dealer:d.name||'',amt:v,total:Number(p.amount||0)});});});});rows.sort(function(a,b){return String(a.date).localeCompare(String(b.date));});frozen=Math.round(frozen*100)/100;var info=paidLockInfo(c);var total=info?Number(info.sum||0):frozen;var est=Math.max(0,Math.round((total-frozen)*100)/100);if(!rows.length&&!(est>0))return '<div class="empty">该客户分润尚未发放</div>';var head='<div class="mtxt" style="margin-bottom:6px">已发合计 '+moneyMask(fLevel('amount_kpi'),total)+((est>0)?('(其中 '+moneyMask(fLevel('amount_kpi'),est)+' 按当前数据推算)'):'')+'</div>';var tb='';if(rows.length){tb='<table style="width:100%;border-collapse:collapse;font-size:12px"><thead><tr style="background:#f8fafc"><th style="padding:6px 8px;text-align:left;border-bottom:1px solid var(--line)">发放日期</th><th style="padding:6px 8px;text-align:left;border-bottom:1px solid var(--line)">发放经销商</th><th style="padding:6px 8px;text-align:right;border-bottom:1px solid var(--line)">本客户覆盖</th><th style="padding:6px 8px;text-align:right;border-bottom:1px solid var(--line)">该笔发放总额</th></tr></thead><tbody>'+rows.map(function(r){return '<tr><td style="padding:6px 8px;border-bottom:1px dashed var(--line)">'+esc(r.date||'—')+'</td><td style="padding:6px 8px;border-bottom:1px dashed var(--line)">'+esc(r.dealer)+'</td><td style="padding:6px 8px;border-bottom:1px dashed var(--line);text-align:right;font-weight:600">'+moneyMask(fLevel('amount_kpi'),r.amt)+'</td><td style="padding:6px 8px;border-bottom:1px dashed var(--line);text-align:right;color:#64748b">'+moneyMask(fLevel('amount_kpi'),r.total)+'</td></tr>';}).join('')+'</tbody></table>';}if(est>0&&rows.length)tb+='<div class="mtxt" style="margin-top:6px;color:#b45309">另有 '+moneyMask(fLevel('amount_kpi'),est)+' 来自未留客户明细的历史发放,按当前数据推算</div>';return head+tb;}
function saveCommissionPayout(id){var d=DB.dealers.find(function(x){return x&&x.id===id;});if(!d)return;var err=document.getElementById('cp_err');var box=document.getElementById('cp_pick');if(!box){if(err)err.textContent='页面未就绪,请关闭重开';return;}var _de=document.getElementById('cp_date');var _ne=document.getElementById('cp_note');var date=(_de&&_de.value)||today();var note=((_ne&&_ne.value)||'').trim();var alloc=[],sum=0,bad='';[].slice.call(box.querySelectorAll('input.cp-pick')).forEach(function(cb){if(bad)return;if(!cb.checked)return;var cid=cb.getAttribute('data-cid');var max=Number(cb.getAttribute('data-max')||0);var el=box.querySelector('input.cp-amt[data-cid="'+cid+'"]');var v=Math.round(((parseFloat(el&&el.value)||0))*100)/100;if(!(v>0)){bad='勾选的客户里有金额为 0 的,请取消勾选或填写金额';return;}if(v>max+0.005){bad='发放金额不能超过该客户的未发分润';return;}alloc.push({cid:cid,name:(cb.getAttribute('data-name')||''),amt:v});sum+=v;});sum=Math.round(sum*100)/100;if(bad){if(err)err.textContent=bad;return;}if(!alloc.length){if(err)err.textContent='请至少勾选一家客户';return;}var pend=commissionPending(d);if(sum>pend+0.005){if(err)err.textContent='发放金额不能超过待发放 '+money(pend);return;}d.payouts=Array.isArray(d.payouts)?d.payouts:[];var _base=commissionPaid(d);d.payouts.push({id:uid('po_'),date:date,amount:sum,note:note,alloc:alloc});d.commissionPaid=_base+sum;save().then(function(ok){if(ok){closeModal();render();toast('已发放 '+money(sum)+',覆盖 '+alloc.length+' 家客户');}});}
function recomputeDealerCommissionPaid(d){if(!d)return;d.payouts=Array.isArray(d.payouts)?d.payouts:[];d.commissionPaid=d.payouts.reduce((s,p)=>s+Number(p&&p.amount||0),0);}
function editCommissionPayout(id,pid){const d=DB.dealers.find(x=>x.id===id);if(!d)return;if(!can('edit_comm_payout')){toast('无权限:修改分红发放记录需「修改分红发放记录」权限');return;}const r=(d.payouts||[]).find(p=>p.id===pid);if(!r)return;openModal(`
<div class="modal-head"><h3>✏️ 修改发放记录 · ${d.name}</h3><span class="x" onclick="closeModal()">×</span></div>
<div class="modal-body">
<div class="row2"><div class="field"><label>发放金额(元)</label><input type="number" id="epo_amount" min="0" step="0.01" value="${r.amount}"></div><div class="field"><label>发放日期</label><input type="date" id="epo_date" value="${r.date||''}"></div></div>
<div class="field"><label>备注</label><input id="epo_note" value="${esc(r.note||'')}"></div>
<div id="epo_err" style="color:var(--danger);font-size:12px;min-height:16px;margin-top:6px"></div>
<div class="mtxt" style="line-height:1.7">应发分润 ${money(dealerStats(id).commission)} · 除本笔外已发放 ${money(Math.max(0,commissionPaid(d)-Number(r.amount||0)))}</div>
</div>
<div class="modal-foot"><button class="btn ghost" onclick="closeModal()">取消</button><button class="btn" onclick="saveCommissionPayoutEdit('${id}','${pid}')">保存修改</button></div>`);}
function saveCommissionPayoutEdit(id,pid){const d=DB.dealers.find(x=>x.id===id);if(!d)return;if(!can('edit_comm_payout')){toast('无权限');return;}const r=(d.payouts||[]).find(p=>p.id===pid);if(!r)return;const err=document.getElementById('epo_err');const amt=parseFloat(document.getElementById('epo_amount').value)||0;const date=document.getElementById('epo_date').value||r.date||today();const note=(document.getElementById('epo_note').value||'').trim();if(amt<=0){if(err)err.textContent='请输入正确的发放金额';return;}const others=(d.payouts||[]).filter(p=>p.id!==pid).reduce((s,p)=>s+Number(p&&p.amount||0),0);const cap=Math.max(0,dealerStats(id).commission-others);if(amt>cap){if(err)err.textContent='发放金额不能超过可发放上限 '+money(cap)+'(应发分润 − 其它已发放)';return;}r.amount=amt;r.date=date;r.note=note;rebuildPayoutAllocs(d);recomputeDealerCommissionPaid(d);save().then(ok=>{if(ok){render();openCommissionPayout(id);toast('已更新该发放记录');}});}
function delCommissionPayout(id,pid){const d=DB.dealers.find(x=>x.id===id);if(!d)return;if(!can('del_comm_payout')){toast('无权限:删除分红发放记录需「删除分红发放记录」权限');return;}const r=(d.payouts||[]).find(p=>p.id===pid);if(!r)return;if(!confirm('确认删除这笔发放记录('+money(r.amount)+' / '+(r.date||'')+')?删除后不可恢复。'))return;d.payouts=(d.payouts||[]).filter(p=>p.id!==pid);rebuildPayoutAllocs(d);recomputeDealerCommissionPaid(d);save().then(ok=>{if(ok){render();openCommissionPayout(id);toast('已删除该发放记录');}});}
function openConvertDealer(id){if(!can('add_dealers')){toast('无权限:仅管理员可新增经销商');return;}
const c=DB.customers.find(x=>x.id===id);if(!c)return;if(c.convertedDealerId){toast('该客户已转为经销商');return;}
const typeOpts=Object.entries(DEALER_TYPES).map(([k,v])=>`<option value="${k}">${v.name}(预缴${money(v.fee)}·${pct(v.baseRate*100)})</option>`).join('');openModal(`
<div class="modal-head"><h3>转为经销商</h3><span class="x" onclick="closeModal()">×</span></div>
<div class="modal-body">
<div cl
ass="mtxt" style="margin-bottom:10px">将「<b>${custNameText(c)}</b>」由转介绍客户转为经销商。以下资料自动带入,转为后可在「经销商管理」继续完善预缴 / 分润等资料。</div>
<div class="row2">
<div class="field"><label>经销商类型 *</label><select id="cv_type">${typeOpts}</select></div>
<div class="field"><label>所在区域</label><input id="cv_region" value="${c.school||''}" placeholder="默认带入校区/机构,可改"></div>
</div>
<div class="field" style="margin-top:12px">${phoneEditField('cv_phone', c.phone, '联系电话')}</div>
</div>
<div class="modal-foot">
<button class="btn ghost" onclick="closeModal()">取消</button>
<button class="btn" onclick="confirmConvertDealer('${c.id}')">确认转为经销商</button>
</div>`);}
function confirmConvertDealer(id){if(!can('convert_dealer')){toast('无权限:仅拥有「客户转为经销商」权限的账号可操作');return;}
const c=DB.customers.find(x=>x.id===id);if(!c)return;if(c.convertedDealerId){toast('该客户已转为经销商');return;}
const type=document.getElementById('cv_type').value;const region=document.getElementById('cv_region').value.trim();const phoneEl=document.getElementById('cv_phone');const phone=phoneEl?phoneEl.value.trim():'';const did=uid('d_');DB.dealers.push({id:did,name:c.name,type,phone:phone||c.phone||'',region:region||c.school||'',prov:c.prov||'',city:c.city||'',district:c.district||'',addr:c.addr||'',signDate:c.signDate||today(),note:'由转介绍客户「'+custNameText(c)+'」签约经销合同后转入',amount:Number(c.amount)||0,nextPayAt:c.nextPayAt||'',payments:(c.payments||[]).map(p=>({...p})),contractSigned:!!c.contractSigned,followups:c.followups||[],fromCustomerId:c.id});ensureStaffUser(DB.dealers[DB.dealers.length-1]);c.convertedDealerId=did;save().then(ok=>{if(ok){closeModal();render();toast('已转为经销商:'+custNameText(c));}});}
// ===== I13 止血(2026-09-14):阶段与「达成合作日期」一致性反向守卫 =====
// 背景:stage 由 amount 推进(syncStage 在 amount<=0 时直接 return),而 custNo 只认 signDate
// → 「填了达成合作日期 + 金额留 0 + 阶段仍选已报备」会落进无人管的空隙(存量 2 家)。
// 判定集合必须与 isProtectionExpired 的补集保持一致:仍在保护期倒计时的阶段 = 未成交语义。
var STAGE_PRE_DEAL = ['reported', 'following', 'experienced'];
function custStageSignConflict(stage, signDate, c) {
if (!signDate) return '';
if (STAGE_PRE_DEAL.indexOf(String(stage)) < 0) return '';
var amt = Number((c && c.amount) || 0);
var lbl = ((STAGES[stage] || {}).label) || stage;
var tail = (amt <= 0 ? ',且「签约/全款金额」为 0' : '');
return '已填写「达成合作日期」(' + signDate + '),但阶段仍为「' + lbl + '」' + tail
+ '。两者互相矛盾,请先确认客户是否已达成合作:'
+ '已成交 → 请把阶段改为「全款」或「分期」并补齐签约金额;尚未成交 → 请清空「达成合作日期」。';
}
function custStageSignAcked(id, stage, signDate) {
return window.__custSignAck === String(id) + '|' + String(stage) + '|' + String(signDate);
}
function showCustStageSignBar(msg, id, stage, signDate) {
var old = document.getElementById('custSignWarn');
if (old && old.parentNode) old.parentNode.removeChild(old);
var anchor = document.getElementById('u_stage') || document.getElementById('u_sign');
var body = anchor ? anchor.closest('.modal-body') : document.querySelector('.modal-body');
if (!body) { toast(msg); return; }
var box = document.createElement('div');
box.id = 'custSignWarn';
box.style.cssText = 'background:#fffbeb;border:1px solid #fcd34d;border-left:4px solid #d97706;border-radius:8px;padding:10px 12px;margin:0 0 12px';
box.innerHTML = '<div style="font-weight:600;color:#92400e;margin-bottom:4px">⚠️ 阶段与「达成合作日期」不一致</div>'
+ '<div style="font-size:12.5px;color:#78350f;line-height:1.6">' + esc(msg) + '</div>'
+ '<div style="display:flex;gap:8px;margin-top:9px;flex-wrap:wrap">'
+ '<button type="button" class="btn ghost sm" onclick="custStageSignBarClose()">返回修改</button>'
+ '<button type="button" class="btn warn sm" data-ssid="' + esc(id) + '" data-ssstage="' + esc(stage) + '" data-sssign="' + esc(signDate) + '" onclick="custStageSignForce(this.getAttribute(\'data-ssid\'),this.getAttribute(\'data-ssstage\'),this.getAttribute(\'data-sssign\'))">我确认,仍然保存</button>'
+ '</div>';
body.insertBefore(box, body.firstChild);
if (body.scrollTop > 0) body.scrollTop = 0;
}
function custStageSignBarClose() {
var el = document.getElementById('custSignWarn');
if (el && el.parentNode) el.parentNode.removeChild(el);
}
function custStageSignForce(id, stage, signDate) {
window.__custSignAck = String(id) + '|' + String(stage) + '|' + String(signDate);
saveCustomerUpdate(id);
}
/* ★ 第 429 轮(主人 2026-10-13 拍板 A 方案「提示 + 兜底」· ① 操作侧):
经销商进入终态(已退费 / 已消耗终止合作 / 已终止)保存时,若名下仍有在营客户 ⇒
在弹窗顶部弹提示条(列出客户与欠款),可「返回修改」或「我确认,仍然保存」——不强制。
两条入口都接:经销商档案保存(saveDealerDetail)、客户档案保存(saveCustomerUpdate,
客户为「已转经销商」的来源客户且本次保存会把该经销商拉入终态时)。
范式照抄「阶段-签约冲突」提示条(custSignWarn):同一经销商 + 同一目标状态只提示一次。 */
function dealerDeadStageOn(st){return ['refunded','exhausted','terminated'].indexOf(String(st||''))>=0;}
function dealerDeadCustsOf(did){return (DB.customers||[]).filter(function(c){return c&&String(c.dealerId||'')===String(did||'')&&!(c.refunded||c.stage==='refunded'||c.stage==='terminated');});}
function dealerDeadAcked(did,stage){return window.__dealerDeadAck===String(did)+'|'+String(stage);}
function showDealerDeadCustBar(did,stage,list,fn){
var old=document.getElementById('dealerDeadWarn');
if(old&&old.parentNode)old.parentNode.removeChild(old);
var anchor=document.getElementById('u_stage')||document.getElementById('u_sign');
var body=anchor?anchor.closest('.modal-body'):document.querySelector('.modal-body');
if(!body){toast('该经销商名下还有 '+list.length+' 家在营客户,建议先转给其他经销商');return;}
var rows=list.slice(0,8).map(function(c){var owe=owedOf(c);return esc(c.name||'(未填姓名)')+(owe>0?('(欠 '+money(owe)+')'):'');}).join(' · ');
if(list.length>8)rows+=' 等共 '+list.length+' 家';
var lbl=(stage==='terminated')?'已终止':((stage==='exhausted')?'已消耗终止合作':'已退费');
var box=document.createElement('div');
box.id='dealerDeadWarn';
box.style.cssText='background:#fffbeb;border:1px solid #fcd34d;border-left:4px solid #d97706;border-radius:8px;padding:10px 12px;margin:0 0 12px';
box.innerHTML='<div style="font-weight:600;color:#92400e;margin-bottom:4px">⚠️ 该经销商名下还有 '+list.length+' 家在营客户</div>'
+'<div style="font-size:12.5px;color:#78350f;line-height:1.6">'+rows+'<br>办理「'+lbl+'」后,这些客户仍会挂在它的名下。建议先到「客户 → 修改 → 归属经销商」把它们转给其他经销商;如确认保留原样,可点「我确认,仍然保存」。</div>'
+'<div style="display:flex;gap:8px;margin-top:9px;flex-wrap:wrap">'
+'<button type="button" class="btn ghost sm" onclick="dealerDeadBarClose()">返回修改</button>'
+'<button type="button" class="btn warn sm" data-dd="'+esc(String(did))+'" data-ds="'+esc(String(stage))+'" data-df="'+esc(String(fn))+'" onclick="dealerDeadCustForce(this.getAttribute(\'data-dd\'),this.getAttribute(\'data-ds\'),this.getAttribute(\'data-df\'))">我确认,仍然保存</button>'
+'</div>';
body.insertBefore(box,body.firstChild);
if(body.scrollTop>0)body.scrollTop=0;
}
function dealerDeadBarClose(){var el=document.getElementById('dealerDeadWarn');if(el&&el.parentNode)el.parentNode.removeChild(el);}
function dealerDeadCustForce(did,stage,fn){window.__dealerDeadAck=String(did)+'|'+String(stage);if(fn==='d')saveDealerDetail(did);else if(fn&&fn.indexOf('c:')===0)saveCustomerUpdate(fn.slice(2));}
/* 第 662 轮 · 同步目标 =「同校区簇」:关联方式=同一校区 ⇒ 整组同步;
否则按 campusOf 只同步「与自己同一校区」的那几条(不同校区一律不动)。
⚠ 判据必须用 custCampusRepOf 比校区代表,不能拿 school 文本比(同名分校是真实存在的)。 */
function linkedSyncTargetsOf(c){if(!c)return [];var _all=linkedCustList(c);if(linkedScopeOf(c)==='single')return _all;if(typeof custCampusOn==='function'&&custCampusOn(c)){var _rp=custCampusRepOf(c);return _all.filter(function(t){return t&&custCampusRepOf(t)===_rp;});}return [];}
/* 第 663 轮 · 「撤回上次校区调整」三件套:快照读取 / 提示条 / 执行体
设计:切换「关联方式」时在每条受影响档案上留 campusUndo={at,scope,of,fields}(切换前值);
7 天内可在客户详情一键还原(标记 + 校区归属 + 当时被同步的地址/经销商/手机号)。 */
function campusUndoInfoOf(c){if(!c||!c.id)return null;var all=[c].concat(linkedCustList(c)||[]);var at='';all.forEach(function(x){if(x&&x.campusUndo&&x.campusUndo.at&&String(x.campusUndo.at)>at)at=String(x.campusUndo.at);});if(!at)return null;var n=0;all.forEach(function(x){if(x&&x.campusUndo&&String(x.campusUndo.at)===at)n++;});return {at:at,n:n};}
function campusUndoBarHTML(c){var inf=campusUndoInfoOf(c);if(!inf)return '';var dt=new Date(inf.at);var days=(Date.now()-dt.getTime())/86400000;if(!(days<=7))return '';var ts=fmtTime(inf.at).slice(0,16);return '<div class="warn-box" style="display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin:0 0 10px">⏪ <b>上次校区调整:'+esc(ts)+'</b><span class="mtxt">(涉及 '+inf.n+' 份档案,7 天内可撤回)</span>'+((typeof can==='function'&&can('edit_customers'))?'<button class="btn ghost sm" onclick="campusUndoApply(\''+esc(c.id)+'\')">撤回这次切换</button>':'')+'</div>';}
function campusUndoApply(pid){var p=DB.customers.find(function(x){return x.id===pid;});if(!p){toast('未找到档案');return;}if(!can('edit_customers')){toast('无权限:需「编辑客户」权限');return;}var inf=campusUndoInfoOf(p);if(!inf){toast('没有可撤回的校区切换');return;}var all=[p].concat(linkedCustList(p)||[]);var hit=all.filter(function(x){return x&&x.campusUndo&&String(x.campusUndo.at)===inf.at;});if(!confirm('撤回 '+fmtTime(inf.at).slice(0,16)+' 的这次校区调整?\n将还原 '+hit.length+' 份档案的校区标记 / 校区归属,以及当时被同步的地址、经销商、手机号。'))return;hit.forEach(function(x){var u=x.campusUndo||{};if(u.scope==='multi'||u.scope==='single')x.campusScope=u.scope;else delete x.campusScope;if(u.of)x.campusOf=u.of;else delete x.campusOf;var f=u.fields||{};['dealerId','prov','city','district','addr','phone'].forEach(function(k){if(f[k]!==undefined)x[k]=f[k];});if(f.dealerId!==undefined)DB.deliveries.forEach(function(dv){if(dv&&dv.customerId===x.id)dv.dealerId=f.dealerId;});delete x.campusUndo;});save().then(function(ok){if(!ok)return;closeModal();render();toast('已撤回上次校区调整('+hit.length+' 份档案)');});}
function saveCustomerUpdate(id){if(isPhoneLikeSchool((document.getElementById('u_school')||{}).value||'')){toast('「所属校区/机构」不能填电话号码,请改填校区/机构名称');return;}const c=DB.customers.find(x=>x.id===id);if(!c)return;if(!can('edit_customers')){toast('无权限');return;}
const contentEl=document.getElementById('f_content');const content=contentEl?contentEl.value.trim():'';if(content&&!canField('c_followups','e')){toast('无权限:不能写跟进');return;}
if(can('edit_customers')){const wasSigned=c.contractSigned;const wasStage=c.stage;const wasDealerId=c.dealerId;const nameEl=document.getElementById('u_name');const name=nameEl?(canField('c_name','e')?nameEl.value.trim():(c.name||'')):(c.name||'');if(!name&&fLevel('c_name')==='full'){toast('请填写客户姓名');return;}
const ndealer=(document.getElementById('u_dealer')||{}).value||'';if(!ndealer){toast('请选择归属经销商');return;}if(c.convertedDealerId&&ndealer!==wasDealerId){const _cdv=DB.dealers.find(x=>x.id===c.convertedDealerId);const _ndv=DB.dealers.find(x=>x.id===ndealer);const _dvN=DB.deliveries.filter(x=>x&&x.customerId===c.id).length;if(!confirm('「'+esc(custNameText(c))+'」已转为经销商'+(_cdv?'「'+esc(_cdv.name)+'」':'')+'。\n\n确认将其「归属经销商」修改为「'+(_ndv?esc(_ndv.name):ndealer)+'」?\n· 将写入归属历史(可追溯)\n· 将同步该客户 '+_dvN+' 条交付记录的归属\n· 已登记的交款业绩归属不受影响'))return;}const nstaff=(document.getElementById('u_staff')||{}).value||'';const nteacher=svcTeacherSelValues('u_teacher');
const cStageEl=document.getElementById('u_stage');const cStage=cStageEl?cStageEl.value:c.stage;const cSignEl=document.getElementById('u_sign');const cSignDate=cSignEl?cSignEl.value:(c.signDate||'');
/* 第 647 轮 · 退费状态只由「财务收款 · 退费记录」驱动:本弹窗不再改 refunded,也不许借改阶段绕道
★ 第 647 轮复检补:判「是否退费客户」改用真源标记 c.refunded(不再用阶段)——
阶段可能因历史数据与标记不一致;且「改成已退费」一律拦(不管有没有退费记录),
有记录却未标记 = 标记与记录已不一致,应到财务收款核对,而不是靠改阶段固化。 */
if(c.refunded&&cStage!=='refunded'){toast('该客户已登记退费:如需恢复,请到「财务收款 → 退费记录」删除对应退费记录,系统会按合同额与已交款重新计算阶段');return;}
if(cStage==='refunded'&&wasStage!=='refunded'){toast('退费状态请到「财务收款」办理:首次退费用「退费登记」(需填写退费金额与冲减归属);已退过费的请到「退费记录」核对');return;}
if(['signed_full','signed_installment'].includes(cStage)&&!cSignDate){toast('已签约客户需填写达成合作日期');return;}const _ssWarn=custStageSignConflict(cStage,cSignDate,c);if(_ssWarn&&!custStageSignAcked(id,cStage,cSignDate)){showCustStageSignBar(_ssWarn,id,cStage,cSignDate);return;}if(c.convertedDealerId){const _ddDead429=(cStage==='refunded'||cStage==='terminated');if(_ddDead429){const _ddDid429=String(c.convertedDealerId);const _ddTgt429=(cStage==='terminated')?'terminated':'refunded';if(!dealerDeadAcked(_ddDid429,_ddTgt429)){const _ddD429=DB.dealers.find(function(x){return x.id===_ddDid429;});if(_ddD429&&!dealerDeadStageOn(_ddD429.stage)){const _ddc429=dealerDeadCustsOf(_ddDid429);if(_ddc429.length){showDealerDeadCustBar(_ddDid429,_ddTgt429,_ddc429,'c:'+id);return;}}}}}
var _ap=document.getElementById('u_prov'),_ac=document.getElementById('u_city'),_ad=document.getElementById('u_district'),_aa=document.getElementById('u_addr');var _aHas=!!(_ap&&_ac&&_ad&&_aa);var _fa=_aHas?rcFixArea(_ap.value,_ac.value,_ad.value):{prov:(c.prov||''),city:(c.city||''),district:(c.district||''),bad:[]};if(_fa.bad.length){toast('「'+_fa.bad.join(' / ')+'」与标准行政区划不匹配,请在下拉中重新选择');return;}const upd={name,dealerId:ndealer,school:document.getElementById('u_school').value.trim(),intent:readIntentSingle('u_intent'),prov:_fa.prov,city:_fa.city,district:_fa.district,addr:_aHas?_aa.value.trim():(c.addr||''),stage:cStage,amount:parseFloat(document.getElementById('u_amount').value)||0,installment:(canField('c_installment','e')&&document.getElementById('u_install'))?document.getElementById('u_install').value==='1':c.installment,refunded:c.refunded,signDate:cSignDate};if(can('assign_cust_ops')){upd.serviceStaffId=nstaff;upd.serviceTeacherIds=nteacher;}if(canField('phone','e'))upd.phone=document.getElementById('u_phone').value.trim();Object.assign(c,upd);
/* 第 509 轮 · 同一校区 → 客户本体字段同步到关联档案;不同校区一律不动 */
try{var _lsE=document.querySelector('input[name="u_scope"]:checked');if(_lsE){var _lsNv=(_lsE.value==='multi')?'multi':'single';var _lsAll0=[c].concat(linkedCustList(c));var _lsChg=_lsAll0.some(function(x){return x&&((x.campusScope==='multi')?'multi':'single')!==_lsNv;});if(_lsChg){var _lsAt=new Date().toISOString();_lsAll0.forEach(function(x){if(!x)return;x.campusUndo={at:_lsAt,scope:(x.campusScope||''),of:(x.campusOf||''),fields:{dealerId:(x.dealerId||''),prov:(x.prov||''),city:(x.city||''),district:(x.district||''),addr:(x.addr||''),phone:(x.phone||'')}};});}/* 第 663 轮:切换只改标记、不再 delete campusOf —— 否则切回「不同校区」时校区结构永久丢失 */_lsAll0.forEach(function(x){if(x){x.campusScope=_lsNv;}});}}catch(_ls0){}
try{
var _lsSame=(linkedScopeOf(c)==='single');
{
var _lsT=linkedSyncTargetsOf(c);
if(_lsT.length){
var _lsK=['dealerId','prov','city','district','addr'];
if(upd.phone!==undefined&&canField('phone','e'))_lsK.push('phone');
var _lsTodo=[];
_lsT.forEach(function(t){
if(!t)return;
var kk=[];
_lsK.forEach(function(k){
var nv=(upd[k]===undefined?c[k]:upd[k]);
if(nv===undefined)return;
if(String(t[k]===undefined?'':t[k])!==String(nv))kk.push(k);
});
if(kk.length)_lsTodo.push({c:t,keys:kk});
});
if(_lsTodo.length){
var _lsLb={dealerId:'归属经销商',prov:'省',city:'市',district:'区/县',addr:'详细地址',phone:'手机号'};
var _lsLines=_lsTodo.map(function(x){return '· '+custNameText(x.c)+':'+x.keys.map(function(k){var ov=String((x.c[k]===undefined?'':x.c[k]));var nv2=String((upd[k]===undefined?c[k]:upd[k]));return (_lsLb[k]||k)+'「'+(ov||'空')+'」→「'+(nv2||'空')+'」';}).join('、');});
var _lsDc=false;
_lsTodo.forEach(function(x){if(x.keys.indexOf('dealerId')>=0)_lsDc=true;});
var _lsHead=_lsSame?('该客户标记为「同一校区」,将同步修改 '+_lsTodo.length+' 份关联档案:\n'):('本档案所属校区还有 '+_lsTodo.length+' 份档案,将同步修改:\n');if(confirm(_lsHead+_lsLines.join('\n')+(_lsDc?'\n(归属经销商变更会同步这些档案名下的交付记录归属)':'')+'\n\n取消 = 只改本档案。')){
_lsTodo.forEach(function(x){
x.keys.forEach(function(k){x.c[k]=(upd[k]===undefined?c[k]:upd[k]);});
if(x.keys.indexOf('dealerId')>=0){
DB.deliveries.forEach(function(dv){if(dv&&dv.customerId===x.c.id)dv.dealerId=ndealer;});
try{syncConvertedDealer(x.c);}catch(_ls1){}
}
});
window.__linkedSyncDone='已更新并同步 '+_lsTodo.length+' 份关联档案';
}
}
}
}
}catch(_ls2){}
/* 第 647 轮:退费状态一律由「退费记录」驱动(登记 / 修改 / 删除),本弹窗不再改 refunded、不再联动阶段 */syncCustomerStage(c);syncConvertedDealer(c);autoUnpushOnRefund(c);if(ndealer!==wasDealerId){DB.deliveries.forEach(x=>{if(x.customerId===c.id)x.dealerId=ndealer;});}
}
if(content){c.followups=c.followups||[];c.followups.push({date:today(),method:((document.getElementById('customer_method')||{}).value||''),operator:((document.getElementById('f_op')||{}).value||'').trim(),content,nextAt:((document.getElementById('f_next')||{}).value||'')});}
var _osTip594='';try{if((c.stage==='signed_full'||c.stage==='signed_installment')&&Number(c.amount)>0&&!c.pkgId&&!(Array.isArray(c.itemIds)&&c.itemIds.length))_osTip594='已更新 · ⚠ 本单还没设「订单/项目」,请点客户详情「订单/项目」→「✏️ 设置本单订单」补上';}catch(_os594){}
save().then(ok=>{if(ok){closeModal();render();toast(window.__linkedSyncDone||_osTip594||'已更新');window.__linkedSyncDone='';}});}
function customerDeleteLock(c){if(!c)return '';if(c.convertedDealerId)return '已转为经销商';var _cldn=DB.deliveries.filter(x=>x&&x.customerId===c.id).length;if(_cldn)return '已有交付记录 '+_cldn+' 条(须先删除交付记录)';if((c.payments||[]).length)return '已有交款记录';if((c.refunds||[]).length||c.refunded)return '已有退费记录';if(c.contractSigned||c.signDate)return '已签约';return '';}
// 经销商删除硬禁(2026-09-15 用户要求:有交付记录不允许删除客户及经销商,连级联也拦)。
// 同名下有交付记录 → 锁定;另检查「名下客户名下的交付记录」,防「只挂 customerId」的记录因客户被删而成孤儿。
function dealerDeleteLock(d){if(!d)return '';var _dln=DB.deliveries.filter(x=>x&&x.dealerId===d.id).length;if(_dln)return '名下交付记录 '+_dln+' 条(须先删除交付记录)';var _dlids=DB.customers.filter(c=>c&&c.dealerId===d.id).map(c=>c.id);var _dln2=DB.deliveries.filter(x=>x&&x.customerId&&_dlids.indexOf(x.customerId)>=0).length;if(_dln2)return '名下客户交付记录 '+_dln2+' 条(须先删除交付记录)';return '';}
function delCustomer(id){
if(!can('del_customers')){toast('无权限:当前角色未授予「删除客户」权限');return;}
const cu=DB.customers.find(c=>c.id===id);if(!cu)return;
const _lock=customerDeleteLock(cu);if(_lock){toast('禁止删除:该客户'+_lock);return;}
const delivCount=DB.deliveries.filter(x=>x.customerId===id).length;
const payCount=(cu.payments||[]).length;
const folCount=(cu.followups||[]).length;
const hasNote=!!(DB.boardNotes&&DB.boardNotes[id]);
if(delivCount||payCount||folCount||hasNote){openDelStrongConfirm(id,{delivCount:delivCount,payCount:payCount,folCount:folCount,hasNote:hasNote});return;}
if(!confirm('确认删除客户「'+cu.name+'」?\n将连带删除:\n· 交付记录 '+delivCount+' 条\n· 交款记录 '+payCount+' 条\n· 跟进记录 '+folCount+' 条\n\n删除后可在「回收站」整批恢复,确定继续?'))return;
doDelCustomer(id);
}
function doDelCustomer(id){DB.customers=DB.customers.filter(c=>c.id!==id);DB.deliveries=DB.deliveries.filter(x=>x.customerId!==id);save().then(function(ok){if(!ok)return;render();toast('已删除(可在回收站恢复)');refreshRecycleBadge();});}
function openDelStrongConfirm(id,info){const cu=DB.customers.find(c=>c.id===id);if(!cu)return;const lines=[];if(info.delivCount)lines.push('交付记录 '+info.delivCount+' 条');if(info.payCount)lines.push('交款记录 '+info.payCount+' 条');if(info.folCount)lines.push('跟进记录 '+info.folCount+' 条');if(info.hasNote)lines.push('看板备注 1 条');openModal(`<div class="modal-head"><h3>⚠ 二次确认:该客户已有业务痕迹</h3><span class="x" onclick="closeModal()">×</span></div><div class="modal-body"><div class="warn-box">客户 <b>${esc(cu.name)}</b> 名下已存在:<b>${esc(lines.join('、'))}</b>。<br>删除后会一并移出业务列表(<b>可在「回收站」整批恢复</b>)。<br><br>请输入客户姓名 <b>${esc(cu.name)}</b> 以确认删除:</div><div class="field" style="margin-top:12px"><input id="delConfirmName" data-name="${esc(cu.name)}" placeholder="逐字输入客户姓名" autocomplete="off" oninput="delConfirmGate()"></div><label style="display:flex;gap:6px;align-items:center;margin-top:10px;font-size:12px;color:#64748b"><input type="checkbox" id="delConfirmAck" onchange="delConfirmGate()"> 我已确认该客户确需删除(不是重复建档 / 误操作)</label></div><div class="modal-foot"><button class="btn ghost" onclick="closeModal()">取消</button><button class="btn" id="delConfirmGo" disabled style="opacity:.45;background:#dc2626;border-color:#dc2626" onclick="confirmDelStrong('${id}')">确认删除</button></div>`);setTimeout(delConfirmGate,0);}
function delConfirmGate(){const i=document.getElementById('delConfirmName');const b=document.getElementById('delConfirmGo');const a=document.getElementById('delConfirmAck');if(!i||!b)return;const ok=String(i.value||'').trim()===String(i.getAttribute('data-name')||'');const acked=!!(a&&a.checked);b.disabled=!(ok&&acked);b.style.opacity=b.disabled?.45:1;}
function confirmDelStrong(id){const cu=DB.customers.find(c=>c.id===id);if(!cu){closeModal();return;}const i=document.getElementById('delConfirmName');const a=document.getElementById('delConfirmAck');if(!i||String(i.value||'').trim()!==cu.name){toast('姓名不一致,已取消删除');return;}if(!a||!a.checked){toast('请先勾选「我已确认」再删除');return;}closeModal();doDelCustomer(id);}
function refreshRecycleBadge(){apiGet('/api/recycle/list').then(function(d){const n=(d&&d.total)||0;const e=document.getElementById('recycleCount');if(e)e.textContent=n?('('+n+')'):'';}).catch(function(){});}
function openRecycle(){return apiGet('/api/recycle/list').then(function(d){renderRecycle(d||{});}).catch(function(e){toast('回收站读取失败:'+((e&&e.message)||e));});}
function renderRecycle(d){const bs=d.batches||[];const rows=bs.length?bs.map(function(b){const names=b.items.map(function(x){return esc(x.name||x.itemId||'')+(x.type==='dealer'?'(经销商)':(x.type==='delivery'?'(交付记录)':''));}).join('、');const when=fmtTime(b.at);const btns=(d.canRestore?`<button class="btn sm" onclick="recycleRestore('${b.batchId}')">恢复</button>`:'')+(d.canPurge?`<button class="btn ghost sm" style="color:#dc2626" onclick="recyclePurge('${b.batchId}')">彻底删除</button>`:'');return `<tr><td style="white-space:nowrap">${esc(when)}</td><td style="white-space:nowrap">${esc(b.byName||'—')}</td><td class="wrap">${names}</td><td style="text-align:right;white-space:nowrap">${btns}</td></tr>`;}).join(''):'<tr><td colspan="4" class="empty">回收站为空</td></tr>';openModal(`<div class="modal-head"><h3>🗑 回收站(${bs.length} 批 · ${d.total||0} 条)</h3><span class="x" onclick="closeModal()">×</span></div><div class="modal-body"><div class="mtxt" style="margin-bottom:8px">客户 / 经销商 / 交付记录的删除会整批存在这里,点「恢复」即按原位置还原;「彻底删除」仅管理员可用且不可恢复。</div><div class="card table-card"><table><thead><tr><th>删除时间</th><th>操作人</th><th>内容</th><th></th></tr></thead><tbody>${rows}</tbody></table></div></div><div class="modal-foot"><button class="btn ghost" onclick="closeModal()">关闭</button></div>`);}
function recycleRestore(batchId){if(!confirm('恢复这批删除记录?它们会回到原来的位置。'))return;apiPost('/api/recycle/restore',{batchId:batchId}).then(function(r){closeModal();toast('已恢复 '+((r&&r.restored)||0)+' 条');return loadDB().then(function(){render();refreshRecycleBadge();openRecycle();});}).catch(function(e){toast('恢复失败:'+((e&&e.message)||e));});}
function recyclePurge(batchId){if(!confirm('彻底删除这一批?此操作不可恢复。'))return;apiPost('/api/recycle/purge',{batchId:batchId}).then(function(r){closeModal();toast('已彻底删除 '+((r&&r.purged)||0)+' 条');refreshRecycleBadge();openRecycle();}).catch(function(e){toast('操作失败:'+((e&&e.message)||e));});}
function esc(s){return String(s==null?'':s).replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c]));}
function signDateCN(s){if(!s)return'';const m=String(s).match(/^(\d{4})-(\d{1,2})-(\d{1,2})/);return m?(m[1]+'年'+Number(m[2])+'月'+Number(m[3])+'日'):s;}
function contractHtml(signed){return signed?'<span class="pill" style="background:#dcfce7;color:#166534;white-space:nowrap">✅ 已签</span>':'<span class="pill" style="background:#f1f5f9;color:#64748b;white-space:nowrap">⬜ 未签</span>';}
function contractToggleHtml(type,id,signed){const isDealer=type==='dealer';const o=type==='dealer'?DB.dealers.find(x=>x.id===id):DB.customers.find(x=>x.id===id);const inherited=isDealer&&o&&dealerLockedFields(o).contractSigned;const canEdit=isDealer?can('edit_dealers'):can('edit_customers');const pill=signed?'<span class="pill" style="background:#dcfce7;color:#166534;white-space:nowrap">✅ 已签</span>':'<span class="pill" style="background:#f1f5f9;color:#64748b;white-space:nowrap">⬜ 未签</span>';if(inherited){return '<span id="contractCell-'+type+'-'+id+'" title="继承自转介绍客户·只读">'+pill+'<span style="font-size:12px;margin-left:2px">🔒</span></span>';}if(!canEdit){return '<span id="contractCell-'+type+'-'+id+'">'+pill+'</span>';}return '<span id="contractCell-'+type+'-'+id+'"><button class="btn ghost sm tap44" onclick="toggleContract(\''+type+'\',\''+id+'\')" title="'+(signed?('已签'+(o&&o.contractDate?'('+o.contractDate+')':'')+' · 点击取消合同签署'):'未签 · 点击签署(弹窗填写日期)')+'" style="padding:2px 10px;cursor:pointer">'+pill+'</button></span>';}
function DT(t){return DEALER_TYPES[t]||{name:'未知类型',color:'st-lost',fee:0,baseRate:0,assessment:'',note:''};}
function injectListTableStyle(){if(document.getElementById('listTableStyle'))return;const s=document.createElement('style');s.id='listTableStyle';s.textContent=`
#dlrTable, #custTbl { width:auto; min-width:100%; }
#dlrTable th, #dlrTable td, #custTbl th, #custTbl td
{ white-space:nowrap; vertical-align:middle; }
#dlrTable td.wrap, #custTbl td.wrap { white-space:normal; }
#dlrTable td.wrap .tag, #dlrTable td.wrap b, #custTbl td.wrap .tag, #custTbl td.wrap b { white-space:nowrap; }
/* 徽标类(标签/状态/药丸)永不按字断行 */
.tag, .st, .pill { white-space:nowrap; }
.table-card { overflow:auto; max-height:calc(100vh - 200px); }
#todoTable { min-width:800px; }
#todoTable th:last-child, #todoTable td:last-child { min-width:112px; }
#auditTbl { min-width:760px; }
#todoTable td, #auditTbl td { white-space:normal; }
#todoTable td:nth-child(2) { min-width:260px; }
.table-card table { border-collapse:separate; border-spacing:0; }
.tbl-lock { overflow:auto; max-height:70vh; }
.tbl-lock table { border-collapse:separate; border-spacing:0; }
.table-card thead th { position:sticky; top:0; z-index:3; background:#f8fafc; box-shadow:0 1px 0 var(--line); }
.table-card tbody td:first-child { position:sticky; left:0; z-index:2; background:#fff; box-shadow:1px 0 0 var(--line); }
.table-card thead th:first-child { position:sticky; left:0; z-index:4; background:#f8fafc; box-shadow:1px 0 0 var(--line),0 1px 0 var(--line); }
@media(hover:hover){.table-card tbody tr:hover td:first-child { background:linear-gradient(90deg,var(--primary) 0 3px,transparent 3px),var(--row-hover); box-shadow:1px 0 0 var(--line); }}
/* ★ 第587轮:客户表「客户」列冻结 —— 横向滚动时停在勾选列右侧,不再跟着跑掉。
偏移量走 --cust-pin-l(由 custPinSync 量勾选列真实宽度写入),没有勾选列时为 0。 */
#custTbl td.cust-pin { position:sticky; left:0; z-index:2; background:#fff; box-shadow:1px 0 0 var(--line); }
#custTbl thead th.cust-pin { position:sticky; top:0; left:0; z-index:6; background:#f8fafc; box-shadow:1px 0 0 var(--line),0 1px 0 var(--line); }
/* 有勾选列时(外层 table-card 带 data-vsel="1")客户列右移到勾选列之后 */
.table-card[data-vsel="1"] #custTbl td.cust-pin { left:var(--cust-pin-l,42px); }
.table-card[data-vsel="1"] #custTbl thead th.cust-pin { left:var(--cust-pin-l,42px); }
@media(hover:hover){#custTbl tbody tr:hover td.cust-pin:not(:first-child) { background:var(--row-hover); }}
/* ★ 第590轮 A 方案:客户列限宽 —— 防「任意一条长校区名」把整列撑开(.tag 是 nowrap,
一条 25 字地址就能把客户列从 114px 顶到 299px)。只做事后限宽,不改行结构、不改类名,
cust-grpb / linkedBadge / cust-pin 等既有锚点一个都不动。
① 客户列给下限 176px(不被压成 114 太挤);② 校区名超 100px 省略,全称走 title 悬停;
③ 列内徽标缩为迷你(10px),四枚不再各占一行。 */
#custTbl td.cust-pin, #custTbl thead th.cust-pin { min-width:176px; }
#custTbl td.cust-pin .tag { max-width:100px; overflow:hidden; text-overflow:ellipsis; vertical-align:middle; }
#custTbl td.cust-pin .pill { font-size:10px; padding:1px 6px; border-radius:10px; }
/* ★ 第667轮(主人 2026-10-28「按 A 改」):客户列徽标横排 —— 不再一枚一行。
实测病因(Edge headless 量取,见 tools/_r667_measure_*.json):
列宽 176px、可用宽 150px;三枚徽标实测宽 50 / 101 / 73,合计 224 ⇒ 一行只放得下一枚;
行盒按 body 14px × 1.6 = 22.4px 撑高,10px 的徽标每行白占 22px。
做法:包一层 inline-flex 换行容器 + 徽标行高 1.15、内边距收窄(6 → 4px)。
边界:文案一字未改;cust-grpb / linkedBadge / custScopeBadge 锚点全保留;
只动排版与间距,不参与家数 / 编号 / 分润 / 金额口径。 */
#custTbl td.cust-pin .cust-badges { display:inline-flex; flex-wrap:wrap; align-items:center; gap:2px 2px; vertical-align:middle; max-width:100%; }
#custTbl td.cust-pin .cust-badges .pill { line-height:1.15; padding:1px 4px; }
/* ★ 第593轮(主人 2026-10-23 11:30「手机端显示太宽了」):第591轮 B 的窄屏放宽已撤除 ——
实测窄屏下校区名会占满客户列(176px / 列宽 189px),把客户姓名与徽标挤到下一行。
现在窄屏与宽屏同口径(100px 截断 + 省略号),要加回来请先问主人。 */
.table-card:not([style*="padding:0"]):not([style*="padding: 0"]) { padding:0 20px 18px 0; border-left:21px solid var(--panel); border-top:19px solid var(--panel); }
.pay-owner-warn { margin:10px 0 0; padding:9px 12px; border:1px solid #fecaca; background:#fef2f2; color:#b91c1c; border-radius:8px; font-size:12.5px; line-height:1.7; }
.pay-owner-warn b { display:block; margin-bottom:2px; }
`;document.head.appendChild(s);}
function injectGeoStyles(){if(document.getElementById('geoBtnStyle'))return;const s=document.createElement('style');s.id='geoBtnStyle';s.textContent=`
.geo-btn{display:inline-flex;align-items:center;gap:5px;background:#fff;color:var(--primary);border:1px solid var(--ring);padding:5px 12px;border-radius:8px;font-size:12.5px;font-weight:600;cursor:pointer;font-family:inherit;transition:.15s}
.geo-btn:hover{background:var(--primary-soft);border-color:var(--primary)}
.geo-btn:disabled{cursor:wait;opacity:.8}
.geo-btn.loading{background:var(--primary-soft);color:var(--primary);border-color:var(--primary);animation:geoPulse 1.2s ease-in-out infinite}
.geo-btn.ok{background:#dcfce7;color:#15803d;border-color:#86efac}
.geo-btn.err{background:#fee2e2;color:#b91c1c;border-color:#fca5a5}
@keyframes geoPulse{0%,100%{box-shadow:0 0 0 0 rgba(94,106,210,.4)}50%{box-shadow:0 0 0 6px rgba(94,106,210,0)}}
.geo-coord{display:block;margin-top:4px;font-size:11.5px;color:var(--muted)}
.hint-inline{display:inline-block;margin-left:6px;font-size:11.5px;color:var(--muted);font-weight:normal}
/* 可搜索下拉(smart-select)样式 */
.ss{position:relative;width:100%}
.ss-inp{width:100%;padding:8px 36px 8px 12px;border:1px solid var(--border);border-radius:8px;font-size:13px;background:#fff;cursor:pointer;color:#0f172a;text-align:left;font-family:inherit;height:38px;display:flex;align-items:center;box-sizing:border-box}.ss-cur{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;line-height:1.4}
.ss-inp::placeholder{color:#94a3b8}
.ss-inp:hover{border-color:var(--primary)}
.ss-caret{position:absolute;right:10px;top:50%;transform:translateY(-50%);color:#94a3b8;pointer-events:none;font-size:12px;transition:.15s}
.ss.open .ss-caret{transform:translateY(-50%) rotate(180deg);color:var(--primary)}
.ss-clear{position:absolute;right:30px;top:50%;transform:translateY(-50%);width:18px;height:18px;border-radius:50%;background:#e5e7eb;color:#475569;font-size:11px;line-height:18px;text-align:center;cursor:pointer;display:none;user-select:none;font-weight:700}
.ss.has-val .ss-clear{display:block}
.ss.ss-locked{opacity:.7}
.ss.ss-locked .ss-inp{cursor:not-allowed}
.ss.ss-locked .ss-clear{display:none}
.ss-list{position:absolute;left:0;right:0;top:calc(100% + 5px);background:#fff;border:1px solid var(--border);border-radius:8px;box-shadow:0 8px 24px rgba(0,0,0,.12);z-index:1000;display:none;flex-direction:column;max-height:300px}
.ss.open .ss-list{display:flex}
.ss-search{padding:8px 10px;border-bottom:1px solid #eef2f7;flex-shrink:0}
.ss-search input{width:100%;padding:6px 10px;border:1px solid var(--border);border-radius:6px;font-size:13px;font-family:inherit}
.ss-search input:focus{outline:none;border-color:var(--primary);box-shadow:0 0 0 3px var(--ring)}
.ss-opts{overflow-y:auto;max-height:240px;padding:4px 0}
.ss-opt{padding:8px 12px;cursor:pointer;line-height:1.45;display:flex;flex-direction:column;gap:2px}
.ss-opt:hover{background:#f5f7fb}
.ss-opt.sel{background:#eef2ff;color:var(--primary);font-weight:600}
.ss-opt .ss-main{font-size:14px}
.ss-opt .ss-sub{font-size:11.5px;color:#94a3b8;font-weight:normal}
.ss-opt.active{background:#eef2ff;outline:2px solid var(--ring);outline-offset:-2px}
.ss-empty{padding:16px 12px;color:#94a3b8;text-align:center;font-size:13px;display:none}
.ss-list.empty .ss-empty{display:block}
.ss-list.empty .ss-opts{display:none}
`;document.head.appendChild(s);}
var PY_BASE=19968,PY_L="ydkqsxhmzssxjbymgcczcpssqbycdscdqldylybsgjgyqzjjfgcclzzbwdwzdljpfjaawjjtmyyzwzhflpppqhgccjyymjqyxxgjxhsdsjnjjsmhmlzrxyfsngsyczglggllyjlmyzssecwkyyhqwjssggysyqejtwgdjhychmyxjtlxjyqbyxcldmrrjjwysrldzfpcbzjgbrcfslbczstzfxxchtrqggbdlsccscymmrfcajzbwwjjffclwbdfzqpxddwylgyfawjffxjpdftyyhhycyswccyqsclcxxwzzxnbgnnxbxlzsqcbsgpysazdgmdzbqbscldzzyybchbtstdbzgntnxqywqskbphglxeybfmjebjhhgqdjcysxstkzglyceglysmzxyalmeldccxgzyrcxsdltjzcqkcnnjwhjcczcqljdtstbnxbtyxceqsgkwdyflzqlyhjqspsfxlfpbsqxsxydcczylllsjxfhjxpjbcfbyabyxbcczbjyclwlczggbtssmddjcxpchjqdgjjscjfzkjzjqnlzwlslhdzbwjncjzyzsqnycqyrccgjwybrtwpyfdwejcskdzctbxhyzcyyjxzcfbzcmjyxxcdczottbzljefcgsssxfyrlnygmbdthjxsjjccsbxrytsyfbjdztgbcnclccczbsacyzqscjcshzqydslbbjllmqxlydzxsjjtcbxlcglqccwjbhcjcjjcfxjejjclbgxsxjmyjjqpfzasyjncydjxkjcdjszcbartcclajqmwnqnclllkbdbzzsyhqcltwlccrshllzncylnewyzykczxxgdkdmtcedejcsyys dqdfmsd jlhrwnqlsbglxhlgtgxbjjdcfyjsjyjcjmrnymgdcjczgjmzmgxmmryxkjnymsgmzjymklfxmbdtgfbhcjhkylpfmdslqjcsmtqgzsjlqdldgjycalcmzcsdjllnxdjffffjczfmzffpfkhkgdpkxktacjdhhzdddrrcfqyjkqccwjdxgwjlyllzgcfcqdsmlzpbjjblsbcjggdckkdezsqcckjgcgkdjtjdlzycqkljscgjcltfpcqczgwbjclsdjjbyjhsjddwgfsjgdaccctjlpsbkcgqjhzzljplgcgjjthjjyjzcjmlzlyqbgjwmljklzdcnjqsyzmljlljkywxmkjlhskjgbmclyymkxjqlbmclkmdmxkmyxwslblpsjqjcqxyjfjdjdxmxxllcrqbdyjbgmjwxggbcyxpjtgpepfgdjgbhbncfjyzjkjkhxqfgqckfhygghdgllsdjjxpoyaybngsxqnszswhbsxwhxwbczxcmndcbsbgbbtklylxgwxjjwaqzmywsjqkcgxsjjwjeqxsawetlzalyyysdzpyqascmtcshtzcfycyxyljsdcjjagyslcllyyymglqqqeldxhsccccadycjysfsgbfrlsjqdbxjpsjwsdrckgglgdkzjsbdgscsyqpxhctcldjlhmxmcgsyjgjdctmhltxzhylamohxjcltjfbqqgbfbdfehthsqhqswtcncxcdwhhwgxjlagmamcwgfjhcsntmydolbygwqwesjpmnmlrydzszccyqpcgcmgangpchshmdkjgztdppbfyhzhhgyfdzwkggzbldntsxhqeegzdxlzmmzjjrgszxghkgtjaxxgylyapsthhcghzydgxagkydxbhnhxkdfjnmyhylpmgecslnzhkxxlbzzlbmlsfbhhgsgyyggbhscyajtdwlxtsqcmzydqdqmmgdqllszhlsjzwbjgqswscelqajydytlqxthadnkzzsdelacqdwwcdgqqtddwcbccheqzblxpslzqgpzsznglydjcbdlxjtctajdkywnsyzljhhdzcwnyyzyomhychhhchjhcwcxhdnxlyscjycbclycwmypbkxyjlkqhtyhaxcsysgxasmchhdscrsgjbwqsgzjlwwschs hsqnhzsngndaqtbaalszmsstdqjcdgtccjaxplggbhhgcxzcspdmmhldgtybysjmxhmrcpccjzckzxshflqxccdhxezbchcccdykcjsxqhlxdhypjqxnlsyyddadjnhxqezascyayjgyfdghddxsppyzndhthrhhydpcjjhtcxmctlhbynyhmhzllhnamylllmdcppxhmxdkycyrdltxjchhznaclcclylncsxzjezlnnllghyqsnjhxynqtdkyjpychhwegkcttwlgqrlggtgtygyhpyhyljyqgcwyqkpyyytttclhyhllcyttkplkyzwgtwgpydqqczdqxskcqnmjjzzbxyqfjkdfbbtkhzkbjdjjkdjjtlbwfzpbtkjtztgpdgnthjyfalqmjgxbcclzfhzclllladpmxdjhlcclgyhdzfgyddgcykfgydxkjsebdhykdkdkhnaxxhbfbyyaxcqgabdjyjjdmljcsjzllbchbsxgjyddybejsbqwjlckcddtaccbkzdszypjdqsjbkktknjdjgyebgtlfyqkasdntcyhblgdzhbbydmjnagczyheyybcmcdtpfzjjhgcjpljhldwxjjkydcyksssmdwcdtqzlpbszdtwzxgzagykcywxlhlcpbcllojmmtsslcmbjcsdzkydczxgqjdsmcytzqqlwzqcxcsbpkdfqmddcdsddtdmfhtdyzjaqjaktpbdjywbdljhdrqxlxhaydhrjlklytwhllrllrcxylbwsrszzqymkzzhhkyhxksmzsyzgcjfbzbsqlfcjxxnxkxwwmsddyqwggqmmyhcdzttfgwybgsbtttbykgdhkygbelhdypjanflfdqkchjkcbyjtzbxhfdxbdaswhawajldyjsfhblcnndnqgcjnchxfjsrfwhzfmdrfjyhwzpdjhzjjymfcjzhynxfbytfwfwygdbnczzdnytxyemmqbsqehxfzmbmflzzsrpymjgsswcjsprtdjsjgxhjjgljjynzjjxhgjhymlpeyycsyjgqzswhglyrjlpdslcxmfsmwkcctnxnynbnjszhdzeptxmwywayysywlxjjsqxzdclaeelmcbjpclwbxsqhfwntffjtnjjhjjdxhglbyccfjlalhyyjldxhhycstdymncjtxywdrmdnqhwqcmfjdxzmhmayxjwmyzqdxdlmrspwwchajbxtgcybxysrrclmpamgcqjszyjrmxjsnxtblnbappybklxmyzkynldgyjtcchnlmzhhanqmpgwjtzmxxmllhgdzxyhxkrxycjmffjyhjfsbcsqlhxndycannmdcjcyprneytyqnyymbmsxndlylysljnlqyshhmllyclzjjjkymzcsfbzxxmstbjgnxynhlsnmcqscyznfzlhbrnnnylmnrtgcqysajskryhyjzfzdhzgzdwybsscskxsyhytsxgcqgxzzbhyxgscrhmkkbqczjyjymkqhzjfnbhmqhysnjntybknqmcjgqhwlsnqpwxkhljhyybqcbfcdsxdldspbzbskjjswzxsddxjseeegjscscmgclxxkwwyllymwwwgydkzjgggtggsycknjwnjpcxbjjtjtjwdssbjxzxnzxwmelptfsttllxcljxjjljsxctjswxlehhhgqrwasycskrybyaxwjejqcwqcqqcjqgxaldbzzyjgkgxpltjyfxjltpadkyqhpmatlcpdhkxmtxybhblefxdleegqdymsawhsmljtwyqxlyjzljeeyxbqqffnlyxrdsctgjgxywlkllxqkcctlhjlqmkkcgcyygllljdzgydhzwxbysjbzjdzgyzzhywdfqytyzdzyezklymhjjhtcmhmyzljnywzcsrkqyqltdxwcdrjalwskzwbdcqyncjsrszjjkcdcdtlzzcacqqczddxyplxcbqjylzllljddzjgyjyjsyxnyyynxjjkxdazwnrdljxyyrjlglldhxjcykywnqcclddnyyykychczhjscclgzqjgjwppcqqjssbzzhyjxjbxjfzbfbdsfnsfpzxhdwzndmpmtbczzbzdmyypqjrsdzsqzsqxbdgcpzjwdwcqjzgmdhzxmwwbkbpdgphtmjthzqmmbgcmbzjcfzhfcbbzmqcfmbcmcjxlabnjbbngmhyyjaptzgpmqbqdcgybjxlwzkydpdymgcftpfxyztzxdzxtgkmtybbclbjaskytssqyymscxfjeglsllszbqjjjaklyldlycctsqmcwfgkkbqxlllljyxtyltyxytdpjhnhgnkbyqlfjnyzbyyessessgdyhfhwtcjbsdzjtfdmxhcnjzymjwsrxjdzjjpdjbbsdjggfbkjbxdgjhmgwjjjgdllshzhhyyyyyysxwtypyccbdbbymzyccbtjfzywcbdlfwzcwjdxxhyhlhwczxjtczlcdpsdjczczlyxjjsjbhfxwbywxzpcdzcbdccjhjhmlrbjxxbylrddgjrrckdtgqsczwmlfytmwzcwjwxjywcsksbqqcctdqnhxnkxxhhkfftswoccjybcmpzzyjbnnzbbtghcdlscddydyfjpxyngbxbyqxcbhxcbsxtyzdmzysnssxlgkmzxltgdhkghqjsshqyhhcjyxglhzxcsnhekdtgqxqypkdhextykcnymtyjbkqystjxzlthhqtbyqhxbmyhsqckwwyllhcyylnneqxqwmcfbdccmljggxdqktlxkgnqcdgzjwwjjlxhhjdttnwchhxcxwherzjydjccdbqcdgdnyxcdhcqrxcbhzsqcbxwgqwytbxhfbymyhdyecmkkyaqyngwzslfykkqgyssqyshjgjcnxkzycxsbkyxhyylctyccqthysmgscpmmgcccccmnztasmgqcjhklosjylswtmqsyqkczljqqyplcycztcqqpbbqjzclpkhqcxyxxctdddsjcjffllchqxmjlwcjcxtspycxndtjsdjwhdqqjckxyamylsjhmlalykxcyydmamdjmlmcznnyybzkkyflmchcmlhxrcjjgsylnmtjggggywjqsrhcwjgcqhqzdcgdzjjzkjkgdzcgjjyjylhzxhcdqhhhestmhlfsbdjjyyshfyssczqcpbdrfrztzdgwkgsccgkwdqzrkmsycbcrcqbjyfaxpqkedhcjykbcjwhyjbqdzywnyszpddgspfpbaztklqyhbbzbnbpctzzybhnsdcpjmmcjcqmcjbzzdcmdlbpbblngqjtbtcagzbqbbdnjkljqylnbzqhgsjznggqqczkcxchbyjnbcgdgddzjanzgjkdndlzldwjljzlywtjndjzjhxyatncbgtzcsskmnjpjytslwjcfjwjjthcczflbgqnjzsyjbgbzyzlsslsbjhdwwqfslmmbbjdlajyzccctbnnrcwqhcdslqgdsdfdzhjtqqbsqlyyjzlgnhsdlctcbctktyccjtqkbpjlgmjzdmcsgpcnjkjjcyknhrcwccxmcncsszjxhbyhyzajyokcjdllcjjjthhgcsdjhjyczbywblwqcglzgjgqrhcczssbcnbcseydznljsqgxssjmecnsdjtbbdlthnwhqwcszexnqczgweskssbybstscsjccgbfsdcszlccglllzgczcthcnmjgyzazcmckcmtcmmlckbjygqljygpbldxrgqyxccsnhshgdznlchzjjcdccbcjflbfqbczstbqdnhxljcthjwjgylnlszypcjdscqjhjqkdxgbbajyemsmjtzcxlcjynyynhjbngzjgmjxltbsllrtpylclznxjhllhyllqqcqljymncwcxnljmcclnzldwdjjllnqggjxppskygyggbfzpdkfmghcxmcgdxjmcjsdycabxjdlnbcddygskjdjtxdjjyxmsaqazdzfsloxyjsjzdlblxxwxqqzbjzlfbblylwdsljhcjyzjgtdjcyfqzqczdcsxzzjlzcdzfchwsplmbqzmlpblffxjjnczylsjyyqzfpfzksymjjjhrdjzzxtjxglghtdxcskyswmmtcwybazbjhshfhgcxmhfqhycxyzftsjyzbxyxpzlchmzmbxhzzssyfdmncwdabazlxktcshhxkkjjzjsthygxsxyyhhhjwxkzxcsbzzwwhhcwtzzzpjxsnxqdjgzyzawllcwxzfxgyjyhxmkyydwsqmnjnaycyspmjkgwcqhylajjmzxhmmcnzhbhxclxtjpltxyjhdyylttxfszhysxsjbjyayrsmxyblckdlyhlxrlnllstyzyyqygyhesccsmcctccxhyqfprwrpbblfctntszlljmhwtcjjyzwcllmlmdwmbyssmsrbpdddlgjjbxccsrdqqwgwcsxfwzlxccebtdzmcyggddqsgtjmwljmymmsyhbbjdgyqccbdhxcscsbsjwjgjmbbkabfyfnshydxcylnemzgqcyhsszdlljcsqfzxxgpcxzgxjjgbmxyysnbddlbclhbfzdcyfbmgqrrmsszjysgtzncycdzcdgbjafjbdknzblcsscbfgzycjszlmlrczbzzldlsllyssqqzqlyxzlcgkbrjbrbzcycxzjzeeyfgklzlyyggysgzlfjhgtggkraajtzkzqdsshjjxdzmz yjlzyrzdqqhgjzxsscbtkjbbfrtjxllfqwjgslqtymblpzdjczagbdhzzrbgjhwnjtjxlhscfsmwlldqysjtxhzscfwjlbxftzlljrllqblcqmqjcacdfbbbhzczjlpyygjdtgwdcfccqyyyjysrccqzfklszzgffcqnwglhjycjjcclkzcejbjzzbpdccmhjgxdqdgdlzqmfgpsytscyfhwdjzhysxyycjcyhcwpbygxrylybhkjksfxtjjmmchhlltnyymcxxyzpdjjyccycwmdjjjqyrhllqxpsgtlwycljscbxjycbnmlrgjjtyzbsyzmsjyjhgfzqmsyxrszcwtlrtqzsstkxgqggsptgcdnjsgccclhmxggztqydjjzdlbzsdglhyqgggqhcscpchjhhgnygkggcmjdcllcccxqsftgzslllmlcskccbdjzzozlmmxtpzsxqgjcjyqxyexyqzcpshkzzysxcdfgmwqnlljxrfjdlysdccmjcqjjdhjnxdnrzccfqrhqgllgcxszsjdjljcytqjslnyxhszxcgjzyqpdlfhdjsbbcczgjjjjsjqdybssllcmyctmqtbhjqmnygjynqyqmdgcjkpdcgmyzgqllsllccmholzgdylfzsljcqzlylzcjesgnylljxgjqlyjyyyrnbcljsswcgqcjyllcldjyllzllbnylgqchxyhhoxccqayjkxhyklksxaqqccqkdkkcsgyxxyqxygwtjohthxpxxcsshcyeychzzcbwqbbwjqcscszsslzylgdesjzmmymcytsdsxxscjbqqsjylybsychdjdzywcbtjsydjhcyddjlbdjjsodzykysqkxxdhhgqjykhdyxmgmmmajdybbbppbcmhcpljzsmtxerxjmhqdstpjdcbssmssstdjtsdmltrcplzszmlqdjdmjmqpnqdxcfrnbfsdqqyxhyaykqyddlqyyysscbydslmqfgdsqbzmchdhcqchfdxtmqqscgqwwxsrgjcwtjsjzqmghjjrjhtfjbbcmzfxjhngfxxqywyyhwccdychhqmnmdmfcpbszppzzglmzfollcfwhmhsjzdcthlmcffytczgcyskjgxqdjzqbhbbzklaghgflshpcfcsnclpblsnjszsljjfpmtyjygbxlldlxpzjypjyhhzcywhcxlsjexfcqzywxkzhlladtmlymqjpwxxhxskdjjezrpxxzghmgghbhqlyjjjjjzszcfhjlchhnxjlqwzcgbmzdxbdhhypclglhhgfwlcfyytlhjjcjmncpjsckpnhjxdntcxdtestjctlsslstdlllwgyhdhrjzsfgxssyczskwhtdhwjslhtzdjdjzxxqgghlcdphccqfzlcjtclzpfstpdxnylgmclgycqhynsbchylgqyctmzybbymrfqykjsyslzdyjmpxyyssrhzjnyqtjdfzbogdwwrdcwhgygxmhmyylhmsmzgngcebmlqqmtcwctmhmxjpjjhfxyycsjchtybhstsyjdnjjqyslhynbyqhlcxcnzwsmylhfjxlwgbypjgtysylymzcktdwlgsmzsylmbmlzwqwqzssaqsycyrhssntsrapccpwcmgdhhqcdzxfjggzttsbjhgyglzysmyclllxbtyxhbbcjklsdmalhhycfygmqybjycqxjllljgclzgqlycjcctotyxmtmshllwcgbxymqmklpszzcxhhcyclctyjcyhxsgyxzkxlzwpjjbdhjwpjbgsqqxlxsdgmrslzcyzwsdcxkystzshbsccscplwsscjchalcgchssphylhfhhxjsxyllnylmcdhdxylcxlmcyhcldyahzcmddyspjtqjzlngjfsjshctsdszlblmssmnyymjqbjhecwcyydchqjjapswbgqybkfcmjwlzllyylszjdwhxpcbcmljbscgbhxlqhyrljxysdxhxzlldfhlslymjljyflyjycdnjlfsyjfnllcqyqfgjyhylclylmshdjcyhbzllnwlqxygyyhbmgdhxxhhlzzjyxczzzcyqzbngwpylcpkptkfmclghdgxzggwqbdxzzkzfbxxlcxjtbjfttbytszzdwslchzhsltjxhqlhyxxxywzyswdmzkhlxchzpyagchkjfsyh tjrlxfjcpjztwhplsxfcrhxshhkjxxyhzjdxjwylhyhmjdbflkhjxcmhcfajcfpqrxqxcyyyjygrpggscsxngwchkzdxhflxxhjjbyzhtqxnncyjjymswyjqrmhxzwfqsylzjzgbhwnslbgttcsebhxxwxyhhxyxnsqyxmljwrgyqlxbbcajsylbcytjzwhyzawlhorjmksczjxxxyxchcyqryxqjddsjbslyltsffyqlmdyjmjjcayxllscsxqzlhkxlwyxzhdnlrbhxjcdshlbrlmbrllaxhlllljlxxxlycrylcjcgjcmtlzllcyzzpzpcyawhjjfybdyxzsepckzdqyqpbpcjpdcyzbdbbcyydycnnpjmtmlrmfmmgwygbsjgcgsmdqqqztxmkqwgxllpjgtbqcdjjjfpkjkcxbljmswldtqjxldlppbscwkcqqbfqjczagzgmygbhyyfzyjndkzmbpjcspxthlfpnyygxjdbkxnhhjhzjxssrsdldxskzysybmsjlxyjhbzyslhxjpfxbqnbylljqkygzmcezzwmccsldlhzgwfwyxzmwcxtjngjhbyymcysbmhysmyayshqyzcgmjjmzcaahcbjbbhplxtxlsxsdjgjdhhxhtmxnbhnmlngsltsmrhnexqjxmxllyswqgdlbjhdcgjyqycmhwfmjybbbyjmjwjmdpwhxqldyapdfxxbcgjspckrsstjjmslbzzjfljjjlgxzgyxyxlszqkxbexyxhgcxbpldyhwecdwwcjmbtxchqyqxllxflyxlljlssfwdbzcmyjclmswtczbchqekcqbwlcgydblqppqzqfjqdjhymmcxtxdrmjwrhxcjzclqxdhynhyyhrslsrsywwzjymtltllgtccjzyabsckzcjyccqljsqxalmzyyywlwdxzxqdlljshgpjfjljhjabcqzdjgthhsstcyjfbswzlxjxrwgldlzrlzqtgsllllzlymxqgdzhgbdbghpbrlw hqbpfdwo whlypcbjcc dmbzpbzj cyqxldomcblzwpdwyygdstthcsqsccrsssyslfdbfntyjsydfndpdhtqzmbblxlcmtffgtjjqwbtmdpjwdnlbzcmmctgbdclqlfybhsymjylsdchdzjwjcctljcldtljjcpddpjdssdnnndbjlggjzxsxnlycybjjqxcbylzcfzppgecxzdcbztjjfgsjxzbnzyjqctyjwhsyczgymdjxttmpxsflzcdpslshxybzgtfmlcjdacbbmgdewycyzcdszcyhfldctygwhkjyylsjcxgywjcbhlcsncdbtzbsclyzczjssqdllmqyyhfllqllxfdyhabxggnywyypllsdbdllbjcyxjzmlhljdxyyqytdlllbbgbfdbbbbbzzmdbjhgclgmjjpgaehhbhcqxajhhhzchxyphjaxhbphjpgpzjqcqzgjjzcgddmqyybzcphyhybwhazyjawkfgdpfqcdlzmljxjpgalxcdaglmcgxmmzqymxdxpffdmmsshmpfmcmmkxksyzashdzkjscsmmzzzmsycnzzczhbmlhtmddnmsckjmztywmzmzzmjqhhdccjemxxkljstgwcsqlyjzllsjshdbpmhnldjczyhmxxhgzcjmdhxtjgrmxfwmckmwjdcksxhmmmfzzydkmsclcmpcgmwrbxqpzdsslcjkyxtmlgjyahzjgzjmcscxyhmmpmlgjxmhlmlgmlctkzmgjjszjsxszhsyjzjcdajzybdddjzgwdkgxfkdmsdjlfmehkzqkjbeypzxszcdwyjffmzjykttdzzefmzlbnpplblpbpszalltylkckqzggenqlgagkkydpxlgsxqqwqykqkclhyxxbltccecgmqyskychlcjnszkpydkcqcqljbdhdjhlasqlbydwqlwdgbqcrydddtjybkbwsadxddnpjdtctqdfxqqmgnseclstbgpkslctqqlpaydzklzygtcqapllkccylbqmqczqcljslqzdjxlddgbsadljjxzqdjyzahzljcyhdyjppypeakjyrmpcbymcxgllzllfqpylllmbsglcyqslrsysqtmxyxcgzbdcrysyztffbjzsmzqhcssccmlyxwtpzgxzjgzgljsgkddhtqggzllbjdzlcbzhyxyzhzfywxszymsdbzzyjgtsmtfxqyxjscdgslnmdlrytzlryycxqhtxsrtzcgyxbnqqcfhykmzjbzybkbpnlyjpblmcnqyzzzsjzhjctshgyzzjrdczhnbxglfxslkgjtcsssyllgzrzbbjzzklphbcsyslxyqbjfbnjczxcdwjjyjxbzdjjgggrsrjkmcmcjjsjywqshyhqjsrpjbtzlsnshrnypjtwbhklbsrjlcjwjqxkkysjycttlqzybbybwkjqdwgydcytjcjxckcwdkkzxsgkdzxwwydjqqytcytdjlxwkckkclcclzcqqdzlqlcsfqchqhsfsmkzzllbjjzbsjhtsjdysjqjpdlzcdchjkjzdjpycgmzwdjjbsjqzsyryhhxcbbjydssddzncglqmbssfcbfdzdlznfgfjgfsmptjqlmblgqcyyxbqkdjjqsrfkzdjdhczklbsdzcfjtplljgjhdxzcsszzxstcygkgckgyoqxjblzbbbgcdyjdgczjszlbjlsjfzgkqqjcgwczbzqtldxnjxbsxxpjshszcclwdqjjhxmfczpfzhqhqmqgkslxhtycgfrzgnqxcgpdlbhcsczclljblhbdcypcspbdymtzsgyhckcpcjgsjclnscdsldzxbmsdlddfjmkdjdhslzxlszqpqpgjllybdskgqlbzlslkyyhjttmcjyqtzzbszqztlljtyyllqllqyzjlbdzlslyyzymdfszsnhlxznczqzbbwcjrfbcyzmthblgjpmcnzcstlxshtccyylzblfeqhlxflcjlyljqcbzlzjghsstbrmhxzhjzclxfnbgxgtqjcztmsfzkjmssnxljkbhszxntnlzdndlmsjjgzjyjczxdgogwrwwqnztnfjscbzmhzjfyrdjsbscjzbjfzqzchzlxfxsbsqlzcgyftjdcszxqjbqmszkjrhxjzcgbjkhcggtjkjqalxbxfgdrdylxjcgdtsjxhjzjjcmzlcqsbtshqbxttxhxftrdkfjhzxjfjxrzcdlllcqsqqzqwqxswqtwgwbccgcglqzbclmqqczgzxzxljfrmycflxysqxxjkxrmjdcdhmyxbsqbhgcmwfwtgmxlzbgytgzwccdxyzxswgxyjyznbgpcjcqsyxcxrtfycgrhztxszzthcbfclsyxcljqmzlmplmxcjccflbysjyqhxjsxrxsjczzssljflczjrcrxhhzxqydmgjsjjhqcxhbcynsssrjbqlfxzqplmlxzkyxlxcjlcycrjzzlldlllsjygzqgyjwkjrwyhcpsgnrzgfzwfzznsxgxflzsxzzzbfcsyjdbrjkrdhhgxjljjtgxjxxstjtjxlyxqfcsgswmsbctlqzzwlzzkxjmltmjyhsddbxgzhdlbmyjfrzfcgclyjbpmlysmsxlszjqqhjzfxgfkfqbpxzgyyqxgztcqwyltlgwwgwhllfsfgdjmgmgbgkjfsyzzgzyzaflsspmlbflcwbjzcljjmzlpjjlymqdmxyyfbgygqzglxzdxqyxrqqqhsxxykqygjtyxfsfsllgnqchgycwfhcccfxbylybllzqxxxxxkqhhsshjdcfdsczjxcpzwhhhhhapylhalpqafyhxdyllkmzqgggddesnendltzgchybpcscjjhclljtolnjpzljlhymheydxdsqycddhgzpndzclzytllznteytgxlhslpjjbdgwxpcdntjcklkclwkllcasstkntdnqjttlyyzssssszzryjjqkcgbhhcrxrzydgcgcwcgshfbfpfjfcynakrgywyqbqbxfkjtszzxcwzddbbbqtbgtffznpcfpzxzpjqcbmqhkcyxyldkljnypkygggdcjxxeahpnzgctzcmxcxmmjxnkszqnmnjwbwhxjjyhclctmcsjdjctctpcnfdtnnpglllccjlsbblpgjcdtnjnlyadscffjfkwdpgzdwmedcclodaxnssnyzresdyjwjyjdbcfxnmwttbqlwstszgybljpxglbocbgpcbjftmxclhylzxcltpnclcdxtfzjshcrxsfydzdkntlbyjcyjglstgqcbxewzxbxklylhzlqzllzcqwgclgzjncjgcbndzgjdjxtzjxkcyycxqjyyxjjxgssjstssttppghtcsxwzdcsyfptfbchfbblzjclzzdbxgcxlhfxkfzflsyltywbmnjhsjbmddbcysccldxtcddqlyjjhmqllcsgljjsybpyyccyjdjantjjpwycmmgqxydqdhqhzhszxbfcwwzqswqrfkghxjqcyfbrxjhhfhjgzyqacmyfrhcyybyqwlpexcczsdyrltsdmqjyhmbbgmyyjprgnnbbcxyxbhyzdbdnghpmfrgbwbzmfjmmbcmzdcjjlcnyxyqgmlrygqccwhzlwdgcccgghcjjfyzcjhycbrrcmtzqzxhfcgdjxccjeaqcrjthpljcszdjrbcqhjdzrhxlyqjsymhzydwldfryhbbydcssccwbxglfzmczjtqsscfjmmxjcsjytdcghxcjwsnswlfemmjnmkllswtxhyyygcmmcwjdqdgcglljwjnkgpzggflccsczmcbltbhbqjsqdjbdjqcghglfjawbzyjjltjtdhjhctcbchflqmpwdshjytqwcnztjtlbypbpdyyyxsjkxwjyblxxncwcsybmaelyhkjmzzzbrxsaqjfljffhhhytczxrgqjmhsbgdzjwbwpjhzjdyjcqwqkdhxsqlzyymysdzgrxckkhjlepyhyscsyzlnmlqshljxbcstlhdqjpcycykppbnsxfyzjjrcemgsqmsxlxglrwgcstlrsxbygbzgztcpldjlslylymdtmtcpalcxpfjcjwtcyyjlblsbzljmyljbghdscssdmxmbcczsxwhamjczcbjmcnhjyjnsygchskqmczqdllkablwjqsbmocdtjjrlyjchjmybyqlrhetfjjfrfksryxfjdwdsxxlwsqjyslyxwjhsnlxyaxhbhawhhjcxlmyljcsqlkydttxbzskfdxgxsjhhsxxybssxdpwncmrptczczenygchqfjxkjbdmljcmqqxloqslyxxlylljdzbtlmhbfcttqqwlhogwblscalzxqlhterrjhlstmypyxjjxmqsjpbbnyxyjllyqylthyljyfmhgljdmllhfzwkzhljmlhljkljstlqxylmbhhlnbsxqchxcfxxlhyhhjgbyzzkbsscqdjqcsxjzsygzghmgshcsymxbebcqwwrbpyyjqtyqcwjhqqzycmwffhgzfrjfcdbxndqyzpcnhhjlfrzgpbxzdbbgzjctlgdgylcqmgchhbfyjlzyxkjlxpjesywmgngqzmlzjnsqxjqsyjtcbehsxfssfxzwfllbcxyjddddthwzsbjmqqyjjmqsxllddtkhhgbfmwdxysjqrnjtlgwdebdwcyygcdlkjhtmxmyjlxhybnwfymwfrxyjmhysctzzcfykmldhqdlgaqnlcryjblbsccqywlsbrrjwxhqsbhtydnhhgmmywytzcsqmtssccdalwdtcpjpycllqzyjswxwzzmmglmxclmxczmxmzsqtzppjqblfgxjzhbljjhxcjsnxmcxsccdlxcjjdcjcxsgqyclzxlzzxmxqrjmhrhzjphmfljlmlclqnldxclllfypngjysxcqhdcmjjszxhnfnxzmekmxhykyqlxsxdljxyhwdcwdzhqyybgybcyscfgfcjnzdyzzjqxrzrqjjymcanhrjtldbbyzbstjhxxztpbdmfgzzrfymtjgxcqbyxmbbfcckrjjjbjegrzgyclkxzdxkknsjkcljsbgyyzlqqjybzssqlllkjfcbktylcccdblcfpfglgadtzjqqggkqtsfcxbdkdxxhybbfttyhbclpdwtgdhrynnjcbtcsnyjqhkllldslndxxwbcjqsbxbfjjjcjdzbbxxbrmladgcsnccbjddcblfrzdswsbxbcllxslzdjcsjpylyxxybqfffbhjjjgbygjpmmmmsscljmtlyzjxswxtyledqpjmygqzjgdblqjwjqllsdgjgygmscljjxdtygjqgqjcgzcjgdzdshqgsjggcjhqxsnjlzdbxhsgzhcxyljxywyydfkqjhjfxdhctxjdrxysqtjxyebyyssyxjnncyzxfxcsyszhyyschshxzzzgzzcgfjdldylnpzgyjyzddqzpbxcbdztzczyxxyhgscxshcggqhjhgxwscdmdmehyxgcbdclzkkwytjzrclekestdbcykqqsayxcjxwwgsbhjszsdhcsjkjcxswxfctynydbzcczjqtzwjqdzczqzljchlnbhpydxpdxshgezdxfptjqtzzahyagncfcyyhxgnqmawxtcsjpdhhghmsmxqcxssbcqsjqxhtyylxbcqlmmszmjzjllcogcsaajzchjmchhcxzsxzdznleyjjzjbhqmjzsqtzpsxztdsxjjjlnyazphhyysrnqdthchayjyjhdzxzlswcltbzyecwcycrylcxnhzydzydyjdfrjjhtrsqtxyxjrjhojynhelxsfsfjzghpzsxzszdzcqzbyyklsgsjhczshdgqgxyzgxchxzjwyqwgyhksseqzzndzfkwyssdclzstsymcdhjxxyweyxczaydmpxmdsxybsqmjmzjmtzqlpjyqzcgqhxjhhhjxhlgdldjkcfdwbsxfzzyyschtydyjbhecxhjkgjfxbhszjfxbwhbdzfyzbcapnpgdydmsxhkhhmamlnbyjtmpxyjmcthjbzyfcgtyhwphftgzzezsbzegpbmdskbtycmhbllhgpzgxzjgzjyxzsbbqsczzlzccstpgxmjsftcczjzdjxcybzlfcjsazfgszlybcwzzbbzdzypdwyjgxzbdsysxlgzbzfygczxbzhzftpbgzgejbstgkdmfhyzzghzllzzgjqzlsfdjsscbzgpdlfzfzszyzyzsygcxcntxchczxtzzljfzgqsqqxzgqccccdjcdxzjhqjccgcctdlgscxdsyjjjccclcdcztccgcqjztezzjpbkkdgfcjfzqybjyqctyclmbdktjcbqzjdzfbjsbnjlgyjcsjdcqksgqkxclbzjdcjdqbxdjjjcccjnqbccmslyjcqmtjjrccjjnjnllchjcwjdbcqjczczpzzdzccdcyzczccjgsjbzdprndctjdcqtqcdtjnplcbcllccdsxkjzqdlzlbznbtjdcxfcccbccgjltqjpldcgzdbbzjcqdcjwtnllzlzccdwllxwzlxrsntqjccxkjljgdfqtddglrlajjtklymkqlldzytdyycggjwyxdhfrskstcdenqmrkqzhhgkcldazbkypbggpzfebzzdkdqspegjjghkczdzslysywqzwfqznlzzlzhwcgkypqglpgblplerjykccceyhsbzfwbnywkgzhyljczwhxzjzblfflgskhyjzeyjhlpllllcggxdrzelrhgklzzyhzlyqszzjzqljzflnbhgwlczcfjwspyxnlzlxgccbzbllcxbbbbxbbcbbcrnncccyrbbsyldcgqyyqxtgmqzgtzydyjhyfwdefzdjywlccctctjjcdedpwdztstqjhdymbjnyjdlxtsktfhndjxxbyxqsdqddtjtdsytgwscscqflshlglbcjbhddyzjyckwtydylbnddsdsacctoszyyebgexhqddhnwgyclxtdcystqmygzadccszzddlcclzrqsyseljsbymxshztembblcyylletdqyshymfqxkfhbfgnxsbychsbwjyhtqbpbsbwdzqlkgzskyghqjjhhxjxgfljkzlyycdclfwfghljgjcbxblybaqpqgztjplnccbxdjyqydymrbesjyyhkxxstmxrczjywxyhybmcflyzhqyzmqxdbxbcwzmslpdmyckfmjklzcyjycclhxfclydqzpzygyjyzmdxdzfyfyttqdchgsfczmlccyczxjcytjmkslpfhysnwllatpzcdzzcktjdhxctccypksmcccyyazhtjpcolzlyjbjxtfnsljjanrxcylmmnxjlmybcsysslcslljjgyldzdpqbfzzblfndsjkczfhhggqmjdsxycstxnqqjpyjbfcxdyqfbnqejdgyhbsrccfdyqbghyjdyzxgrhtkyleqdzntsmgklbcgbpyszbydgzsstjcssxzbhbscsbzczptafzlqflypybbjgszmnndjmthyskkbjtxhjcegbsmgyjzcjtmljshrzzqscxjqptzhmkdgxxjcljprmyygadyskqlsadhrskqxnxztcggydlmlwxjbwsycdbhjhcfcwzsjhwtkcljjshlyczjxemplpdcgltbzztlnjcwjgdtcfglpllqfjmzbabgyzlaktgdnczzbnzctdqjcjyjgmcttldgcszlmlhbglkfwnjzhdxphlfmjycjgxdtwzfrjejctzhydxykshwfzcqshknmqqhtchxmjdjskhxdjqbzzxympajqmcdbxlsklyghwrtscgscbpdbsgzwjhtlkssswgztlyytnxjgmjszsxfwnlskctxgxlsammlbwldccyaakqcqctmycfjbslxclzjclxxksbzqclhjphqplsxscgslnhpsfqqytxjjzlqldxzjjzdyydjntptfcdskjfsljhylzcjslbdhydgcjbdbyazxdzhzjnhhqbyknrjjqczmlljzgspldlclbblxklelxjlbjycrjhgcnlcqplzlznjtzljgyzdzpltqcssfdmnycxgbtjdcznbgbqyqjwgkfhdnbyqzqgbkpbbyzmtjdytblsqmbsxtbnpdxklemyycjynzctldykzzxddxhqshdgmzsjycctayrzlpwltlkxslzcggexclfxlkjrtlqjaqzncmbqdkkcxglczjzxjhptdjjmzqykqsecqzdshhadmlzfmmzbgntjnnlgbyjbrbtmlbyjdzxlcjlpldlpcqdhlhzlycblcxccjadqlmcmmsshmybbbskkbhrsxjjmxsdgnzpelbbragggfchgmsklltsjyycqlcskywyehywhbhqywbawykjldqftntkhjcgdqkcgphshcpdhtwtmssyhbwcrwxhjmkmzngwtmlkfghkjyldyycxwhyeclqhkqhtdqhhffldxqwgtyydasbpgyrzpjfyyzjcaqdzzdladtbbbjllcxdlmjsdwegygsjqjcfbxssdbdyzcxdnyxpfzydlajccbldxlsxydyexcyysdylwwnddahjsyaydgywgaxtjzdaxysrltdjssajfnejdxyzhlxdllchhsjnyqyqjxyjghkgjcyjchzlycdshwsgczyjxclcnxzjjyxxnfsmwffylcyllabmddhwdnjmcxstspmlqzhsfhzynztlldywlslxhymmylmbmwkyxyadtsylldjpybpwfxjlmmllhafdllaflbhhhbqjjtzjcjjjdjtffkmmmbymhygdchrddwrqjxnbysnmzdbyytbjhpybygtjwaahgqdqtmbstqjkbtsbkjldrbeqqhqmjjbdjwtgtbxpgbktlgjxjjjcdhxqdwjlhrfmjgwqhckryswgbtgwgbwhdwdwrfhwytjjxxxjyzyslphyypayxhxdkkxsaxyxerkkgykbdddpplcjlgqaewjksyyhdbplfjthkjltcjyhhgttbltzzcdlshqkcjqysdeeyhkyzyxxyysddjkllpwmcqhqgxhhcrmbxpllnqydqhxsxxwgdqbshyllpgjjtgyjkyphthyyktyezyenmdshlcrpqfbgfxzblbtlgxsjbswyysksflxlpplbbblbsfxfyzbsjssylpbbffffsscjdstgsxtryjcyffcytszbjtbctsbcdhrtjhbytcxyjeylxcbnebjdsysyhgsjzbkbydfzngenhhhthjhatfwgcstbgxklstywmtmbyxjskxscdejrcytwxzfhmymcxlznsdjtttxrycfyjsbsdyerxhgjxbbdeynjghxgckgscymblxjmsznskgxfbnbbthfjaabxwxfpxmyfhddtcxzspjrsywzdlybbjtyqwqjpzypzjznjpzjlzdfysbttslmptzrtdxqsjehbzyjzdhljsqmlhtxtjecxalzdspktlzkjqyfsygywpcpqfhqhytqxzjrsgtgsjczlbtxcdyyqssqcslxlzmacbcqbzyxhbsxlzdltcdjtylzjyytpzylltxjsjxhcbmydxcqrblzssfjzztnjydxmyjhlhpblcyxqjqqkzzscpzkswalqsblcczjsxgwwwygyagjbbctdkgjhkgtgpbkgyslbxbbckbmllxdzstbklggqkqlsbkkdfxrmdkbftpzfrtbbmfeeqgxkjpzsstlbzdpszqzsjchljqlzbpmsmmsxlqqnhknblnddnhhdhddjcyyeyfqgzlgsjgmjqgkhbbmxyxlycqwlwgcpbmjxcyzydrjbgtdjxeeshtmjsbwblwhlsffnypmgxqhpltbqpfbcwgdbcgjnxtbfzjgsddtjshxeawzzylgttqbwjkgxghlfkxdjtmszsqynzggswqsbhclsskmclzxyszqqxncjdqgzdlfnyklacjllzlmzznhydsshthxzlzjbbhqzwwycrdhlyqqjbeyfsgxthsrxwjhwfslmssgztdyeyqqwrslalhmjtqjcmxqbjjzjqlyzkxbyqxbjxshzssfglxmxzxfghkzssggmlclsarjxhslllmzxelglxydjytlfbhbpnlyzfbbhptgjkwetzhkjjxzxxglljlstgshjjyqlqzfkcgnndjsszfdbctwwseqfhqjbsaqtgypjlbxbmmywxgslzhglzgdyfljbyfdjfrgsfmbychqfbwjsyfagjphzbyyzffwodgrlbftmlbzgycgxcdjagzyyyyhytydwegazahxjlzxthlrmgrjxdcghneljjtgtbwjybjjbxjjtjteekgwscjplpsfazfqqbdlqjjtyyqlyzkdksqjyyjzldqcgjjyzjsycmraqchtejmbctyhybkmhycmjdcfhyyxwshctxrljgjshccyyyjltktsytmxgtcjtzaxyoczlylbszywjytsjyhbyshfjlygjxxtmzsyltxxypclqyjzyzyybnhmymdyylblhlsyygqlldjjymsoycbzgdlyxylcqyxtszegxhzglhwbljgeyxtwqmakbpqcgyshhegqcmwyywljyjhyyzlljjylhzyhmgsljljjcjjyclycjbcpcjzjmmylcjlnqljjjlxxjmlszljqlycmmhcfmmfpqqmfxlqmcffqmmmmhmznfhhjgtthhkhslnchhyqdxtmmjdcydyxyqmyqylcdcyjydaydcwmdydlxfffmmycqcwzzmabtbyctdmndzggdftypcgcyttssffwbdtzqssystwjjhjytsxxylbyqhhwhxezxwznnjzjjjjjjccchyyxbzsccyjtllcqxknjycywcqnccqyyoewyczdcjecchyjlbtzkycqwlpglyllgkdldlgkgqbgychjxy";
var PY_X={"丁": "z", "万": "w", "乐": "y", "乜": "n", "于": "xy", "仇": "jq", "从": "z", "仰": "y", "任": "r", "余": "xy", "俞": "y", "冉": "nr", "冯": "p", "包": "fp", "匡": "w", "区": "q", "单": "ds", "卜": "p", "卞": "p", "卻": "qx", "古": "k", "叶": "y", "司": "s", "吴": "w", "咸": "x", "堵": "z", "夏": "x", "姚": "y", "姬": "y", "宁": "z", "宓": "m", "家": "j", "容": "y", "宿": "sx", "封": "f", "尚": "s", "屈": "q", "嵇": "x", "干": "g", "平": "p", "幸": "x", "广": "gy", "庄": "z", "强": "q", "彭": "p", "怀": "h", "慎": "z", "戚": "q", "房": "p", "扶": "p", "支": "z", "方": "pw", "於": "y", "施": "y", "晁": "z", "景": "y", "暴": "p", "曾": "z", "朝": "z", "朱": "z", "杜": "t", "杭": "k", "查": "z", "柴": "z", "毋": "w", "池": "t", "汤": "t", "汪": "w", "沈": "st", "温": "y", "游": "y", "湛": "djstyz", "滑": "h", "潘": "fp", "焦": "q", "狄": "t", "王": "y", "甄": "z", "甘": "h", "盛": "s", "瞿": "q", "石": "s", "祁": "z", "祖": "z", "祝": "z", "童": "z", "竺": "z", "红": "h", "翟": "z", "能": "tx", "臧": "z", "舒": "y", "艾": "y", "茜": "x", "莘": "x", "董": "z", "蒲": "p", "蔚": "y", "蔡": "s", "藏": "z", "行": "x", "裴": "p", "褚": "z", "解": "x", "詹": "z", "许": "x", "谷": "ly", "贾": "j", "越": "y", "车": "j", "逄": "p", "逯": "l", "邓": "s", "邢": "x", "郗": "x", "郝": "s", "重": "tz", "钭": "t", "长": "z", "阚": "k", "阮": "y", "陶": "ty", "隗": "kw", "霍": "s", "鞠": "q", "齐": "q"};
function pyToks(t){t=String(t==null?"":t);var opts=[],prim="",i,c,p,x,j,k,m;for(i=0;i<t.length;i++){c=t.charCodeAt(i);p='';if(c>=PY_BASE&&c-PY_BASE<PY_L.length){k=PY_L.charAt(c-PY_BASE);if(k!==' ')p=k;}prim+=p;x=PY_X[t.charAt(i)]||'';if(x){m=[p];for(j=0;j<x.length;j++)m.push(x.charAt(j));opts.push({i:i,o:m});}}if(!opts.length)return prim?[prim]:[];var total=1;for(i=0;i<opts.length;i++)total*=opts[i].o.length;if(total>8){var only='';for(i=0;i<opts.length;i++)for(j=0;j<opts[i].o.length;j++){var q=opts[i].o[j];if(only.indexOf(q)<0)only+=q;}return prim?[prim,only]:[only];}var res=[],cur=[];function walk(n){if(n>=opts.length){var buf=prim.split(''),q;for(q=0;q<cur.length;q++)buf[opts[q].i]=cur[q];res.push(buf.join(''));return;}for(var z=0;z<opts[n].o.length;z++){cur[n]=opts[n].o[z];walk(n+1);}}walk(0);return res;}
/* 归属下拉绑定器(2026-09-26 第 172 轮):登记交款 / 修改交款 / 退费 共 6 个归属框改用全站统一的
smartSelectHTML 可搜索下拉(点开即列表 + 输入即时过滤 + ↑↓/回车/Esc + 清除 ×),
取代旧的「输入框先筛、再点原生下拉才能选」两步交互。openModal 在写入弹窗内容后自动调用本函数;
bindSmartSelect 自带 data-bound 幂等闸,重复调用安全;未出现的 id 直接跳过。 */
function bindOwnerSelects(){['pay_owner_d_wrap','pay_owner_u_wrap','ep_owner_d_wrap','ep_owner_u_wrap','rf_owner_d_wrap','rf_owner_u_wrap','ca_tr_from_wrap','ca_tr_to_wrap','cas_tr_from_wrap','cas_tr_to_wrap'].forEach(function(w){try{bindSmartSelect(w);}catch(e){}});}
function smartSelectHTML(o){o=o||{};const id=o.id;const items=o.items||[];const selId=o.selectedId||'';const placeholder=o.placeholder||'搜索…';const emptyLabel=(o.emptyLabel!==undefined&&o.emptyLabel!==null)?o.emptyLabel:'';const displayKey=o.displayKey||'name';const subKey=o.subKey||'';const noResultLabel=o.noResultLabel||'无匹配项';const opt
Fn=o.optFn||null;if(!document.getElementById('geoBtnStyle'))injectGeoStyles();let optsHtml='';if(emptyLabel!==''&&emptyLabel!==null&&emptyLabel!==undefined){optsHtml+=`<div class="ss-opt" data-value="" data-search="${esc(emptyLabel.toLowerCase())}"><span class="ss-main">${esc(emptyLabel)}</span></div>`;}
items.forEach(it=>{let main='',sub='';if(optFn){const r=optFn(it)||{};main=r.main||'';sub=r.sub||'';}else{main=it[displayKey]||'';sub=subKey?(it[subKey]||''):'';}
const val=it.id||it.value||'';const searchTxt=[main,sub,pyToks(main).join(' '),(fLevel('phone')==='full'?(it.phone||''):''),(fLevel('c_school')==='full'?(it.campus||''):'')].filter(Boolean).join(' ').toLowerCase();if(!val&&!main)return;optsHtml+=`<div class="ss-opt${val===selId?' sel':''}" data-value="${esc(val)}" data-search="${esc(searchTxt)}">
<span class="ss-main">${esc(main)}</span>${sub?`<span class="ss-sub">${esc(sub)}</span>`:''}
</div>`;});let curLabel='';if(selId){const found=items.find(it=>(it.id||it.value)===selId);if(found){if(optFn){curLabel=(optFn(found)||{}).main||'';}
else{curLabel=found[displayKey]||'';}}}
return`
<div class="ss${o.locked?' ss-locked':''}" id="${esc(id)}_wrap">
<input type="hidden" id="${esc(id)}" value="${esc(selId)}">
<button type="button" class="ss-inp" id="${esc(id)}_inp" data-toggle="ss"${o.locked?' disabled':''}><span class="ss-cur" style="${curLabel?'':'color:#94a3b8'}">${esc(curLabel||placeholder)}</span></button>
<span class="ss-clear" data-clear title="清除选择">×</span>
<span class="ss-caret">▾</span>
<div class="ss-list" id="${esc(id)}_list">
<div class="ss-search"><input type="text" class="ss-sch" id="${esc(id)}_sch" name="${esc(id)}_q" placeholder="${esc(placeholder)}" autocomplete="off"></div>
<div class="ss-opts">${optsHtml}</div>
<div class="ss-empty">${esc(noResultLabel)}</div>
</div>
</div>`;}
function bindSmartSelect(wrapId,onChange){const wrap=document.getElementById(wrapId);if(!wrap)return;if(wrap.dataset.bound)return;wrap.dataset.bound='1';const hidden=wrap.querySelector('input[type=hidden]');const inp=wrap.querySelector('.ss-inp');const curSpan=wrap.querySelector('.ss-cur');const list=wrap.querySelector('.ss-list');const sch=wrap.querySelector('.ss-sch');const opts=wrap.querySelectorAll('.ss-opt');const clear=wrap.querySelector('.ss-clear');const close=()=>{wrap.classList.remove('open');sch.value='';filter('');setActive(-1);list.style.position='';list.style.left='';list.style.top='';list.style.width='';};const open=()=>{wrap.classList.add('open');const _r=inp.getBoundingClientRect();list.style.position='fixed';list.style.left=_r.left+'px';list.style.top=(_r.bottom+5)+'px';list.style.width=_r.width+'px';setTimeout(()=>sch.focus(),30);};const setActive=(idx)=>{const vis=visibleOpts();vis.forEach((o,i)=>o.classList.toggle('active',i===idx));if(idx>=0&&vis[idx]){vis[idx].scrollIntoView({block:'nearest'});wrap.dataset.actIdx=idx;}
else wrap.dataset.actIdx=-1;};const visibleOpts=()=>Array.from(opts).filter(o=>o.style.display!=='none');const filter=(q)=>{q=(q||'').trim().toLowerCase();let any=false;opts.forEach(o=>{const s=o.getAttribute('data-search')||'';const show=!q||s.includes(q);o.style.display=show?'':'none';if(show)any=true;});list.classList.toggle('empty',!any);setActive(q?0:-1);};const selectVal=(val)=>{const target=Array.from(opts).find(o=>(o.getAttribute('data-value')||'')===val);let main='';opts.forEach(o=>o.classList.remove('sel'));if(target){target.classList.add('sel');main=target.querySelector('.ss-main')?.textContent||'';}
hidden.value=val;if(main){curSpan.textContent=main;curSpan.style.color='';}else{curSpan.textContent=inp.dataset.placeholder||'请选择…';curSpan.style.color='#94a3b8';}
wrap.classList.toggle('has-val',!!val);if(typeof onChange==='function'){try{onChange(val);}catch(e){console.error('smartSelect onChange error',e);}}};if(hidden.value)wrap.classList.add('has-val');inp.addEventListener('click',e=>{e.stopPropagation();if(wrap.classList.contains('open'))close();else open();});sch.addEventListener('input',()=>filter(sch.value));sch.addEventListener('keydown',e=>{const vis=visibleOpts();let idx=parseInt(wrap.dataset.actIdx||'-1',10);if(e.key==='ArrowDown'){e.preventDefault();idx=Math.min(idx+1,vis.length-1);setActive(idx);}
else if(e.key==='ArrowUp'){e.preventDefault();idx=Math.max(idx-1,0);setActive(idx);}
else if(e.key==='Enter'){if(idx>=0&&vis[idx]){e.
preventDefault();selectVal(vis[idx].getAttribute('data-value')||'');close();}}
else if(e.key==='Escape'){close();}});opts.forEach(o=>o.addEventListener('click',()=>{selectVal(o.getAttribute('data-value')||'');close();}));if(clear){clear.addEventListener('click',e=>{e.stopPropagation();selectVal('');filter('');opts.forEach(o=>o.classList.remove('sel'));curSpan.textContent=inp.dataset.placeholder||'请选择…';curSpan.style.color='#94a3b8';wrap.classList.remove('has-val');});}
_ssAdd(wrap,close);}
function areaItems(arr){return (arr||[]).map(s=>({id:s,name:s}));}
function areaSelectHTML(prefix,prov,city,dist,addrVal){const provs=Object.keys(AREA_DATA);const cities=(prov&&AREA_DATA[prov])?Object.keys(AREA_DATA[prov]):[];const districts=(prov&&city&&AREA_DATA[prov]&&AREA_DATA[prov][city])?AREA_DATA[prov][city]:[];return`<div class="row2"><div class="field"><label>省</label>${smartSelectHTML({id:prefix+'_prov',items:areaItems(provs),selectedId:prov,placeholder:'搜索省份…',emptyLabel:'(不选择)'})}</div><div class="field"><label>市</label>${smartSelectHTML({id:prefix+'_city',items:areaItems(cities),selectedId:city,placeholder:'搜索城市…',emptyLabel:'(不选择)'})}</div></div><div class="row2"><div class="field"><label>县/区</label>${smartSelectHTML({id:prefix+'_district',items:areaItems(districts),selectedId:dist,placeholder:'搜索县区…',emptyLabel:'(不选择)'})}</div><div class="field"><label>详细地址</label><input id="${prefix}_addr"value="${addrVal||''}"placeholder="街道 / 门牌号"></div></div>`;}
function bindAreaSelect(prefix){const refreshCity=()=>{const prov=document.getElementById(prefix+'_prov')?document.getElementById(prefix+'_prov').value:'';const cities=(prov&&AREA_DATA[prov])?Object.keys(AREA_DATA[prov]):[];const cityWrap=document.getElementById(prefix+'_city_wrap');if(cityWrap){cityWrap.outerHTML=smartSelectHTML({id:prefix+'_city',items:areaItems(cities),selectedId:'',placeholder:'搜索城市…',emptyLabel:'(不选择)'});bindSmartSelect(prefix+'_city_wrap',refreshDistrict);}refreshDistrict();};const refreshDistrict=()=>{const prov=document.getElementById(prefix+'_prov')?document.getElementById(prefix+'_prov').value:'';const city=document.getElementById(prefix+'_city')?document.getElementById(prefix+'_city').value:'';const districts=(prov&&city&&AREA_DATA[prov]&&AREA_DATA[prov][city])?AREA_DATA[prov][city]:[];const distWrap=document.getElementById(prefix+'_district_wrap');if(distWrap){distWrap.outerHTML=smartSelectHTML({id:prefix+'_district',items:areaItems(districts),selectedId:'',placeholder:'搜索县区…',emptyLabel:'(不选择)'});bindSmartSelect(prefix+'_district_wrap');}};bindSmartSelect(prefix+'_prov_wrap',refreshCity);bindSmartSelect(prefix+'_city_wrap',refreshDistrict);bindSmartSelect(prefix+'_district_wrap');}
async function geoFillAddr(prefix,btnId){injectGeoStyles();const btn=btnId?document.getElementById(btnId):null;const setBtn=(state,text)=>{if(btn){btn.classList.remove('loading','ok','err');if(state)btn.classList.add(state);btn.disabled=state==='loading';btn.textContent=text;}};if(!navigator.geolocation){toast('当前浏览器不支持定位');return;}
setBtn('loading','⌛ 正在定位…');const finish=(ok)=>{setTimeout(()=>{if(btn){btn.classList.remove('ok','err');btn.disabled=false;btn.textContent='📍 自动定位';}},ok?3500:4500);};navigator.geolocation.getCurrentPosition(async pos=>{const{latitude:lat,longitude:lng,accuracy}=pos.coords;const coordStr=`GPS定位 ${lat.toFixed(5)},${lng.toFixed(5)}(约${Math.round(accuracy||0)}米)`;setBtn('loading','⌛ 解析地址…');let prov='',city='',district='',street='';const tryOne=async(url)=>{const ctrl=new AbortController();const t=setTimeout(()=>ctrl.abort(),6000);const r=await fetch(url,{signal:ctrl.signal});clearTimeout(t);return await r.json();};try{const j=await tryOne(`https://nominatim.openstreetmap.org/reverse?format=jsonv2&lat=${lat}&lon=${lng}&accept-language=zh-CN&zoom=18&addressdetails=1`);const a=(j&&j.address)||{};prov=a.state||a.region||a.province||'';city=a.city||a.town||a.village||a.county||'';district=(a.city_district||(a.county&&a.county!==city?a.county:'')||a.suburb||a.neighbourhood||'')||'';street=[a.road,a.house_number?`${a.house_number}号`:''].filter(x=>x).join(' ');if(!prov&&!city&&!street)throw new Error('empty');}catch(_){try{const j=await tryOne(`https://photon.komoot.io/reverse?lon=${lng}&lat=${lat}&lang=zh`);const f=(j&&j.features&&j.features[0])?j.features[0].properties:{};prov=f.state||'';city=f.city||'';district=(f.county&&f.county!==city)?f.county:(f.suburb||f.neighbourhood||'');street=[f.street,f.name].filter(x=>x).join(' ');}catch(_e){}}
const fillIf=(id,v)=>{const el=document.getElementById(id);if(el&&v&&!el.value.trim())el.value=v;};fillIf(prefix+'_prov',prov);fillIf(prefix+'_city',city);fillIf(prefix+'_district',district);fillIf(prefix+'_addr',street||coordStr);const ok=!!(prov||city||street);if(ok){setBtn('ok','✅ 已定位(可继续修改)');toast('📍 定位成功,请核对并补充门牌号');}
else{setBtn('err','⚠️ 已填坐标,请补地址');toast('网络受限仅填入坐标,请手动补充详细地址');}
finish(ok);},err=>{setBtn('err','❌ 定位失败');const msg=err.code===1?'已取消定位权限,请在浏览器中允许':err.code===2?'定位不可用,请检查GPS/网络':'定位超时,请到室外空旷处重试';toast('❌ '+msg);finish(false);},{enableHighAccuracy:true,timeout:12000,maximumAge:60000});}
function f(id){return document.getElementById(id).value.trim();}
let toastTimer;function toast(msg){const t=document.getElementById('toast');t.textContent=msg;t.classList.add('show');clearTimeout(toastTimer);toastTimer=setTimeout(()=>t.classList.remove('show'),2200);}
function exportJSON(){if(!can('export_data')){toast('无权限:导出数据需「导出模块数据 / 全量数据」权限');return;}const blob=new Blob([JSON.stringify(DB,null,2)],{type:'application/json'});const a=document.createElement('a');a.href=URL.createObjectURL(blob);a.download='元墨CRM数据.json';a.click();toast('已导出');}
async function resetData(){if(!can('sys_reset')){toast('无权限:仅管理员可重置');return;}if(!confirm('确定重置为示例数据?当前所有数据将清空。此操作不可恢复,请确认已备份。'))return;try{await apiPost('/api/reset',{confirm:'RESET'});await loadDB();render();toast('已重置为示例数据');}catch(e){toast('重置失败:'+((e&&e.message)||e));}}
document.getElementById('nav').addEventListener('click',e=>{const a=e.target.closest('a');if(!a)return;if(mode==='dealer')curDealerView=a.dataset.view;else curView=a.dataset.view;render();if(window.innerWidth<=820)toggleSidebar();});docum
ent.getElementById('modeSwitch').addEventListener('click',e=>{const b=e.target.closest('button');if(!b)return;switchMode(b.dataset.mode);if(window.innerWidth<=820)toggleSidebar();});function seed(){const d1=uid('d_'),d2=uid('d_'),d3=uid('d_'),d4=uid('d_');const dayAgo=n=>{const d=new Date();d.setDate(d.getDate()-n);return d.toISOString().slice(0,10);};const customers=[mk('c_',d1,'周小明','浦东第一幼儿园','13900010001','2024-09-10','signed_full',88000,false,false,'2024-10-01',[{date:'2024-09-12',method:'面谈',operator:'陈总',content:'上门讲解XR教学方案,家长高度认可'},{date:'2024-09-28',method:'体验课',operator:'公司销售',content:'完成体验课,进入签约流程'},{date:'2024-10-01',method:'微信',operator:'公司销售',content:'全款签约88000,分润待发'}]),mk('c_',d1,'吴芳','徐汇艺术中心','13900010002','2024-09-20','signed_full',76000,false,false,'2024-10-15',[{date:'2024-09-22',method:'电话',operator:'陈总',content:'初步沟通需求'},{date:'2024-10-10',method:'体验课',operator:'公司销售',content:'体验后确定采购'},{date:'2024-10-15',method:'微信',operator:'公司销售',content:'签约76000'}]),mk('c_',d1,'郑浩','闵行早教机构','13900010003','2024-10-05','signed_full',92000,false,false,'2024-11-01',[{date:'2024-10-08',method:'面谈',operator:'陈总',content:'机构负责人意向强'},{date:'2024-10-30',method:'体验课',operator:'公司销售',content:'体验成交'},{date:'2024-11-01',method:'微信',operator:'公司销售',content:'全款92000签约'}]),mk('c_',d1,'孙莉','长宁小学','13900010004',dayAgo(18),'following',0,false,false,'',[{date:dayAgo(16),method:'微信',operator:'陈总',content:'已加微信,发送资料,待约体验'}]),mk('c_',d1,'钱伟','静安培训中心','13900010005',dayAgo(75),'reported',0,false,false,'',[{date:dayAgo(73),method:'电话',operator:'陈总',content:'首次电话沟通,了解预算'}]),mk('c_',d1,'冯雪','普陀幼儿园','13900010006','2025-01-05','signed_full',80000,false,false,'2025-01-20',[{date:'2025-01-08',method:'面谈',operator:'陈总',content:'面谈介绍权益'},{date:'2025-01-18',method:'体验课',operator:'公司销售',content:'体验成交'},{date:'2025-01-20',method:'微信',operator:'公司销售',content:'签约80000'}]),mk('c_',d2,'何静','天河双语园','13900020001','2024-10-20','signed_full',68000,false,false,'2024-11-10',[{date:'2024-10-22',method:'体验课',operator:'李校长',content:'作为体验官自带客户体验'},{date:'2024-11-10',method:'微信',operator:'公司销售',content:'签约68000,转介绍顺延1月'}]),mk('c_',d2,'曹颖','越秀早教','13900020002','2024-11-15','signed_full',72000,false,false,'2024-12-05',[{date:'2024-11-18',method:'面谈',operator:'李校长',content:'校长引荐'},{date:'2024-12-05',method:'微信',operator:'公司销售',content:'签约72000'}]),mk('c_',d2,'邓超','海珠幼儿园','13900020003',dayAgo(12),'experienced',0,false,false,'',[{date:dayAgo(10),method:'体验课',operator:'李校长',content:'已完成体验,待谈单'}]),mk('c_',d2,'薛敏','番禺机构','13900020004','2025-01-10','signed_installment',60000,true,false,'2025-01-25',[{date:'2025-01-12',method:'微信',operator:'李校长',content:'客户选择分期,不计分润'},{date:'2025-01-25',method:'微信',operator:'公司销售',content:'分期签约60000'}]),mk('c_',d3,'韩磊','朝阳一幼','13900030001','2024-11-05','signed_full',70000,false,false,'2024-11-25',[{date:'2024-11-08',method:'体验课',operator:'王主任',content:'校区自用推广,抵扣时长'},{date:'2024-11-25',method:'微信',operator:'公司销售',content:'前5家内,仅抵扣时长不计现金'}]),mk('c_',d3,'杨洋','海淀小学','13900030002','2024-11-20','signed_full',75000,false,false,'2024-12-10',[{date:'2024-11-22',method:'面谈',operator:'王主任',content:'校区负责人沟通'},{date:'2024-12-10',method:'微信',operator:'公司销售',content:'第2家,抵扣时长'}]),mk('c_',d3,'朱琳','西城幼儿园','13900030003','2024-12-05','signed_full',65000,false,false,'2024-12-20',[{date:'2024-12-08',method:'体验课',operator:'王主任',content:'体验成交'}]),mk('c_',d3,'秦风','东城机构','13900030004','2025-01-05','signed_full',80000,false,false,'2025-01-20',[{date:'2025-01-08',method:'面谈',operator:'王主任',content:'第4家'}]),mk('c_',d3,'许晴','丰台早教','13900030005','2025-01-20','signed_full',78000,false,false,'2025-02-05',[{date:'2025-01-22',method:'体验课',operator:'王主任',content:'第5家,累计5家全年免费'}]),mk('c_',d3,'罗刚','石景山园','13900030006','2025-02-10','signed_full',82000,false,false,'2025-02-25',[{date:'2025-02-12',method:'体验课',operator:'王主任',content:'第6家起,发20%现金分润'}]),mk('c_',d4,'高远','武侯幼儿园','13900040001','2024-08-25','signed_full',98000,false,false,'2024-09-15',[{date:'2024-08-28',method:'面谈',operato
r:'赵总',content:'官方事业经销商直推'},{date:'2024-09-15',method:'微信',operator:'公司销售',content:'30%分润签约98000'}]),mk('c_',d4,'林涛','锦江小学','13900040002','2024-09-15','signed_full',102000,false,false,'2024-10-10',[{date:'2024-09-18',method:'体验课',operator:'赵总',content:'体验强'},{date:'2024-10-10',method:'微信',operator:'公司销售',content:'签约102000'}]),mk('c_',d4,'黄丽','青羊机构','13900040003','2024-10-10','signed_full',95000,false,false,'2024-11-01',[{date:'2024-10-12',method:'面谈',operator:'赵总',content:'机构团购意向'}]),mk('c_',d4,'谢峰','成华早教','13900040004','2024-11-01','signed_full',88000,false,false,'2024-11-20',[{date:'2024-11-05',method:'体验课',operator:'赵总',content:'体验成交'}]),mk('c_',d4,'蔡琴','金牛幼儿园','13900040005','2024-11-20','refunded',90000,false,true,'2024-12-05',[{date:'2024-11-25',method:'面谈',operator:'赵总',content:'签约'},{date:'2024-12-08',method:'微信',operator:'赵总',content:'启动≤60工作日退费,退回30%分润'}]),mk('c_',d4,'汪洋','高新小学','13900040006','2024-12-15','signed_full',96000,false,false,'2025-01-05',[{date:'2024-12-18',method:'体验课',operator:'赵总',content:'第6家以上'}]),mk('c_',d4,'范冰','双流机构','13900040007','2024-12-28','signed_full',92000,false,false,'2025-01-15',[{date:'2025-01-02',method:'面谈',operator:'赵总',content:'签约'}]),mk('c_',d4,'唐磊','龙泉幼儿园','13900040008','2025-01-10','signed_full',88000,false,false,'2025-01-28',[{date:'2025-01-12',method:'体验课',operator:'赵总',content:'体验成交'}]),mk('c_',d4,'石磊','温江早教','13900040009','2025-01-25','signed_full',94000,false,false,'2025-02-10',[{date:'2025-01-28',method:'面谈',operator:'赵总',content:'签约'}]),mk('c_',d4,'袁泉','郫都小学','13900040010','2025-02-08','signed_full',90000,false,false,'2025-02-20',[{date:'2025-02-10',method:'体验课',operator:'赵总',content:'达成年度≥10家考核'}]),mk('c_',d4,'顾雯','新都幼儿园','13900040011',dayAgo(8),'following',0,false,false,'',[{date:dayAgo(6),method:'微信',operator:'赵总',content:'跟进中'}]),mk('c_',d4,'邵兵','都江堰机构','13900040012',dayAgo(3),'reported',0,false,false,'',[{date:dayAgo(2),method:'电话',operator:'赵总',content:'新报备线索'}]),];const setIntent=(nm,it)=>{const c=customers.find(x=>x.name===nm);if(c)c.intent=it;};setIntent('周小明','书法');setIntent('何静','美术');setIntent('韩磊','书法');setIntent('高远','美术');const seedDealers=[{id:d1,name:'星跃教育·陈总',type:'resource',phone:'13800001111',region:'华东·上海',signDate:'2024-09-01',note:'',prov:'上海市',city:'上海市',district:'浦东新区',addr:'张江高科技园区博云路2号',amount:0,nextPayAt:'',payments:[],followups:[{date:'2024-08-28',method:'会议',operator:'总部·渠道负责人',content:'签约资源渠道推广协议,明确20%分润与≥10家年度考核门槛'},{date:'2024-09-02',method:'微信',operator:'总部·渠道负责人',content:'确认0元预缴政策落地,开通经销商报备后台'},{date:'2024-12-05',method:'电话',operator:'总部·财务',content:'第4家起分润进入月度对账,提醒每月5号核对、次月15号发放'},{date:'2025-01-15',method:'面谈',operator:'总部·渠道负责人',content:'季度复盘:已成交6家,距年度考核10家还差4家'}]},{id:d2,name:'创想校区·李校长',type:'experience',phone:'13900002222',region:'华南·广州',signDate:'2024-10-15',note:'',prov:'广东省',city:'广州市',district:'天河区',addr:'天河路385号太古汇写字楼',amount:0,nextPayAt:'',payments:[],followups:[{date:'2024-10-12',method:'微信',operator:'总部·体验官运营',content:'体验官授权确认,无需缴10万,靠转介绍获20%提成'},{date:'2024-11-20',method:'面谈',operator:'总部·师训',content:'安排线下XR师训,提升自带客户转化率'},{date:'2025-01-10',method:'电话',operator:'总部·体验官运营',content:'提醒:连续2月无新增将暂停转介绍权限'}]},{id:d3,name:'童趣幼儿园·王主任',type:'campus',phone:'13700003333',region:'华北·北京',signDate:'2024-11-01',note:'',prov:'北京市',city:'北京市',district:'朝阳区',addr:'望京街9号望京SOHO塔1',amount:20000,nextPayAt:'',payments:[{id:'seed_d3_1',date:'2024-11-01',amount:20000,method:'银行转账',note:'校区保证金(前5家仅抵扣自用时长)'}],followups:[{date:'2024-10-28',method:'会议',operator:'总部·校区运营',content:'缴2万保证金开通,前5家仅抵扣自用时长政策说明'},{date:'2025-02-12',method:'微信',operator:'总部·校区运营',content:'第6家起触发20%现金分润,财务对接打款流程'}]},{id:d4,name:'智启科技·赵总',type:'official',phone:'13600004444',region:'西南·成都',signDate:'2024-08-20',note:'',prov:'四川省',city:'成都市',district:'武侯区',addr:'天府大道北段1700号环球中心',amount:100000,nextPayAt:'2025-03-30',payments:[{id:'seed_d4_1',date:'2024-08-20',amount:60000,method:'对公转账',note:'官方事业经销商加盟费(首期)'},{id:'seed_d4_2',date:'2025-01-20',amount:20000,method:'银行转账',note:'加盟费(二期)'}],
followups:[{date:'2024-08-15',method:'面谈',operator:'总部·事业部',content:'签约官方事业经销商,缴10万,享30%分润'},{date:'2024-12-20',method:'会议',operator:'总部·事业部',content:'年度复盘:已成交11家,达标30%分润比例'},{date:'2025-02-20',method:'微信',operator:'总部·财务',content:'退费客户蔡琴分润已退回,季度对账无误'}]},];seedDealers.forEach(d=>{d.contractSigned=(d.contractSigned!==undefined)?!!d.contractSigned:!!(d.signDate);});return{dealers:seedDealers,customers,deliveries:genDeliveries(d1,d2,d3,d4,customers),users:defaultUsers(),rolePerms:{operator:{...OPERATOR_DEFAULT}},roleMeta:{operator:{name:'操作员'}},version:DB_VERSION};function genDeliveries(d1,d2,d3,d4,customers){const plan={[d1]:{vr_meta:'done',ar_dual:'done',online_res:'done',online_aff:'done',ai_review:'done',account:'done',mat_claim:'done',train_off:'done',train_on:'done',stu_lift:'done',xr_maint:'partial'},[d2]:{vr_meta:'done',ar_dual:'done',online_res:'done',online_aff:'done',ai_review:'done',account:'done',mat_claim:'done',train_off:'pending',train_on:'done',stu_lift:'pending',xr_maint:'pending'},[d3]:{vr_meta:'done',ar_dual:'partial',online_res:'done',online_aff:'done',ai_review:'done',account:'done',mat_claim:'done',train_off:'pending',train_on:'pending',stu_lift:'pending',xr_maint:'pending'},[d4]:{vr_meta:'done',ar_dual:'done',online_res:'done',online_aff:'done',ai_review:'done',account:'done',mat_claim:'done',train_off:'done',train_on:'done',stu_lift:'done',xr_maint:'done'},};const dates={d1:'2024-10-10',d2:'2024-12-15',d3:'2024-12-20',d4:'2024-11-30'};const out=[];Object.entries(plan).forEach(([did,m])=>{Object.entries(m).forEach(([sid,st])=>{out.push({id:uid('dl_'),dealerId:did,serviceId:sid,status:st,date:st==='done'?dates[did]:st==='partial'?dates[did]:'',qty:'',operator:'总部交付',notes:''});});});const vrSeed={[d1]:{devices:8,installs:[{id:uid('vi_'),scene:'火星探险',installDate:'2024-10-12',durationMonths:12},{id:uid('vi_'),scene:'海底世界',installDate:'2024-12-20',durationMonths:12},{id:uid('vi_'),scene:'太空站',installDate:'2025-06-01',durationMonths:6},]},[d3]:{devices:3,installs:[{id:uid('vi_'),scene:'恐龙王国',installDate:'2025-02-10',durationMonths:12},]},};const onlineSeed={[d1]:[{id:uid('on_'),openDate:'2024-10-15',durationMonths:12}],[d2]:[{id:uid('on_'),openDate:'2024-12-20',durationMonths:12}],[d3]:[{id:uid('on_'),openDate:'2025-01-10',durationMonths:12}],[d4]:[{id:uid('on_'),openDate:'2024-11-01',durationMonths:12},{id:uid('on_'),openDate:'2025-06-01',durationMonths:6}],};const trainSeed={[d1]:[{id:uid('tr_'),trainDate:'2024-10-18',teacher:'总部师训组·王导师'},{id:uid('tr_'),trainDate:'2025-03-05',teacher:'李老师'}],[d4]:[{id:uid('tr_'),trainDate:'2024-11-05',teacher:'总部师训组·赵导师'},{id:uid('tr_'),trainDate:'2025-02-12',teacher:'总部师训组·王导师'}],};out.forEach(x=>{if(x.serviceId==='vr_meta'&&vrSeed[x.dealerId])x.vr=vrSeed[x.dealerId];if(['online_res','online_aff','ai_review'].includes(x.serviceId)&&onlineSeed[x.dealerId])x.online=onlineSeed[x.dealerId];if(['train_off','train_on'].includes(x.serviceId)&&trainSeed[x.dealerId])x.trains=trainSeed[x.dealerId];});const custPlan={'周小明':{vr_meta:'done',ar_dual:'done',online_res:'done',online_aff:'done',ai_review:'done',account:'done',mat_claim:'done',train_off:'done',train_on:'done',stu_lift:'done',xr_maint:'done'},'何静':{vr_meta:'done',ar_dual:'done',online_res:'done',online_aff:'done',ai_review:'done',account:'done',mat_claim:'done',train_off:'pending',train_on:'done',stu_lift:'pending',xr_maint:'pending'},'韩磊':{vr_meta:'done',ar_dual:'partial',online_res:'done',online_aff:'done',ai_review:'done',account:'done',mat_claim:'done'
,train_off:'pending',train_on:'pending',stu_lift:'pending',xr_maint:'pending'},'高远':{vr_meta:'done',ar_dual:'done',online_res:'done',online_aff:'done',ai_review:'done',account:'done',mat_claim:'done',train_off:'done',train_on:'done',stu_lift:'done',xr_maint:'done'},};const custDates={'周小明':'2024-10-15','何静':'2024-11-20','韩磊':'2024-11-30','高远':'2024-09-20'};customers.forEach(c=>{const pl=custPlan[c.name];if(!pl)return;const dt=custDates[c.name]||'';Object.entries(pl).forEach(([sid,st])=>{out.push({id:uid('dl_'),customerId:c.id,dealerId:c.dealerId||'',serviceId:sid,status:st,date:st==='done'?dt:st==='partial'?dt:'',qty:'',operator:'总部交付',notes:'(客户交付)'});});});return out;}
function mk(id,dealerId,name,school,phone,reportDate,stage,amount,installment,refunded,signDate,followups){return{id:uid(id),dealerId,name,school,phone,reportDate,stage,amount,installment,refunded,signDate,followups,remindAt:'',payments:[],nextPayAt:'',intent:'',contractSigned:['signed_full','signed_installment'].includes(stage)};}}
let apiToken='';let lastSaveTs=0;const CLIENT_ID=(typeof crypto!=='undefined'&&crypto.randomUUID)?crypto.randomUUID():('c'+Math.random().toString(36).slice(2)+Date.now().toString(36));let ws=null,wsRetry=0;function apiHeaders(){const h={'Content-Type':'application/json'};if(apiToken)h['Authorization']='Bearer '+apiToken;h['X-Client-Id']=CLIENT_ID;return h;}
// 【2026-09-16 P1-1】弱口令闸门回执:服务端 403 + code=PWD_CHANGE_REQUIRED ⇒ 清会话回登录页。
// 触发场景:浏览器关掉重开时 localStorage 仍存着 wp token ⇒ boot 恢复会话直接撞 403。
// 处理与 doLogout 同款(清 token / 身份 / localStorage),但不调 /api/logout(免无谓请求)。
// 不弹改密窗的原因:改密需要「原密码」,而此处已无处取得(登录时才输入过)——回登录页重走既有流程最稳。
let _pwdGateKickAt=0;function pwdGateKick(){if(Date.now()-_pwdGateKickAt<3000)return;_pwdGateKickAt=Date.now();try{window.__pendLogin=null;}catch(e){}apiToken='';currentUser=null;curDealerId='';try{localStorage.removeItem('yuanmo_token');localStorage.removeItem('yuanmo_dealer_token');localStorage.removeItem('yuanmo_user');localStorage.removeItem('yuanmo_dealer');localStorage.removeItem('yuanmo_mode');}catch(e){}try{if(ws)ws.close();}catch(e){}try{showLogin();}catch(e){}toast('请先用原密码重新登录,并完成初始密码修改');}
async function apiGet(p){const r=await fetch(p,{headers:apiHeaders()});if(!r.ok){let msg='HTTP '+r.status;let rv=null;let cd='';try{const j=await r.json();if(j&&j.error)msg=j.error;if(j&&typeof j.rev==='number')rv=j.rev;if(j&&j.code)cd=j.code;}catch(_){}const e=new Error(msg);e.status=r.status;if(rv!==null)e.rev=rv;if(cd)e.code=cd;if(cd==='PWD_CHANGE_REQUIRED')pwdGateKick();throw e;}return r.json();}
async function gzipBody(s){try{if(typeof CompressionStream!=='function')return null;const st=new Response(s).body.pipeThrough(new CompressionStream('gzip'));return await new Response(st).arrayBuffer();}catch(e){return null;}}async function apiPost(p,body){const s=JSON.stringify(body||{});const h=apiHeaders();let bd=s;if(s.length>=32768){const gz=await gzipBody(s);if(gz&&gz.byteLength<s.length){bd=gz;h['X-Body-Encoding']='gzip';}}const r=await fetch(p,{method:'POST',headers:h,body:bd});if(!r.ok){let msg='HTTP '+r.status;let rv=null;let cd='';try{const j=await r.json();if(j&&j.error)msg=j.error;if(j&&typeof j.rev==='number')rv=j.rev;if(j&&j.code)cd=j.code;}catch(_){}const e=new Error(msg);e.status=r.status;if(rv!==null)e.rev=rv;if(cd)e.code=cd;if(cd==='PWD_CHANGE_REQUIRED')pwdGateKick();throw e;}return r.json();}
async function apiLogin(username,password){return apiPost('/api/login',{username,password});}
async function apiDealerLogin(phone,password){return apiPost('/api/dealer/login',{phone,password});}
async function loadDB(){let d=await apiGet('/api/db');window.__attToken=d.attToken||'';if(mode==='dealer'){d.dealers=Array.isArray(d.dealers)?d.dealers:[];d.customers=Array.isArray(d.customers)?d.customers:[];d.deliveries=Array.isArray(d.deliveries)?d.deliveries:[];d.users=[];d.rolePerms=(d.rolePerms&&typeof d.rolePerms==='object')?d.rolePerms:{};if(!d.rolePerms.dealer)d.rolePerms.dealer={};Object.keys(DEALER_PORTAL_DEFAULT).forEach(k=>{if(d.rolePerms.dealer[k]===undefined)d.rolePerms.dealer[k]=DEALER_PORTAL_DEFAULT[k];});if(d.rolePerms.dealer.fields===undefined)d.rolePerms.dealer.fields={};Object.keys(d.rolePerms.dealer).forEach(k=>{if(k!=='fields'&&!(k in DEALER_PORTAL_DEFAULT))delete d.rolePerms.dealer[k];});d.roleMeta={};}else{d=migrate(d);}
DB=d;__dbSnap(d);__saveSnap(d);_saveHardFail=false;return DB;}
function connectWS(){if(ws){try{ws.close();}catch(e){}}
const proto=location.protocol==='https:'?'wss':'ws';try{ws=new WebSocket(`${proto}://${location.host}/ws?token=${encodeURIComponent(apiToken)}&cid=${encodeURIComponent(CLIENT_ID)}`);}catch(e){return;}
ws.onopen=()=>{var first=window.__wsEverOpen?0:1;window.__wsEverOpen=1;wsRetry=0;window.__wsLastFrame=Date.now();if(!first)__syncPull();};ws.onmessage=async(ev)=>{window.__wsLastFrame=Date.now();try{const m=JSON.parse(ev.data);if(m.type==='sync'){await onRemoteSync(m);}}catch(e){}};ws.onclose=()=>{wsRetry++;setTimeout(connectWS,Math.min(1000*wsRetry,30000));};ws.onerror=()=>{try{ws.close();}catch(e){}};}
let _syncTimer=null;let _syncDefer=null;let lastModeSwitchAt=0;
/* 2026-09-22 第 130 轮 · 数据同步兜底(B)。背景:数据同步此前 100% 依赖 WebSocket 单通道,
断线/锁屏/僵尸连接后不自愈补数据。实测四断点:① ws.onopen 只重置重连计数、不补拉;
② visibilitychange 只查构建版本、不拉数据;③ 切模式后 4s 内到达的 sync 被永久丢弃;
④ 模态/输入中只 loadDB 不 render,关窗后也不补刷。
此处补:重连补拉 / 回前台补拉 / 轻量 rev 轮询(变了才重画,避免无谓重渲染抖动)/ 应用层心跳。
一律走 __syncPull 单一出口,杜绝多处各拉一次。 */
function __syncPull(){try{if(mode==='dealer'){if(!curDealerId)return;}loadDB().then(function(){var mb=document.getElementById('modalBg');var mo=mb?mb.classList.contains('show'):false;var ae=document.activeElement;var ty=ae?((ae.tagName==='INPUT')||(ae.tagName==='TEXTAREA')||(ae.tagName==='SELECT')||(ae.isContentEditable===true)):false;if(mo||ty){window.__pendingSyncRender=1;return;}try{render();}catch(e){}}).catch(function(){});}catch(e){}}
function __syncFlushPending(){if(!window.__pendingSyncRender)return;window.__pendingSyncRender=0;try{render();}catch(e){}}
function revProbe(){try{if(document.hidden)return;var now=Date.now();if(now-(window.__revProbeAt||0)<3000)return;window.__revProbeAt=now;apiGet('/api/health').then(function(h){if(!h)return;if(typeof h.rev!=='number')return;if(typeof DB==='undefined')return;if(!DB)return;if(typeof DB.rev!=='number')return;if(h.rev!==DB.rev)__syncPull();}).catch(function(){});}catch(e){}}
function wsHeartbeat(){try{if(!ws)return;if(ws.readyState!==1)return;var last=window.__wsLastFrame||0;if(last>0){if(Date.now()-last>70000){try{ws.close();}catch(e){}return;}}try{ws.send('{"type":"ping"}');}catch(e){}}catch(e){}}
setInterval(revProbe,60000);setTimeout(revProbe,12000);setInterval(wsHeartbeat,25000);
function onRemoteSync(m){if(mode==='dealer'){if(m.scope!=='dealer:'+curDealerId)return;}if(lastModeSwitchAt){if(Date.now()-lastModeSwitchAt<4000){if(_syncDefer)return;_syncDefer=setTimeout(function(){_syncDefer=null;onRemoteSync(m);},4200);return;}}if(_syncTimer)return;_syncTimer=setTimeout(async()=>{_syncTimer=null;try{const mb=document.getElementById('modalBg');const modalOpen=mb&&mb.classList.contains('show');const ae=document.activeElement;const typing=ae&&(ae.tagName==='INPUT'||ae.tagName==='TEXTAREA'||ae.tagName==='SELECT'||ae.isContentEditable);await loadDB();if(modalOpen||typing){window.__pendingSyncRender=1;return;}if(curView==='map'&&typeof mapSoftRender==='function'){try{mapSoftRender();}catch(e){}return;}try{render();}catch(e){}}catch(e){}},300);}
function boot(){(async()=>{try{if(location.hash&&location.hash.indexOf('#/m-help')===0){const app=document.querySelector('.app');if(app)app.style.display='';renderMHelpUpload();return;}let m=null;try{m=localStorage.getItem('yuanmo_mode');}catch(e){}let di=null,su=null;try{di=JSON.parse(localStorage.getItem('yuanmo_dealer')||'null');su=JSON.parse(localStorage.getItem('yuanmo_user')||'null');}catch(e){}
const hasDealer=!!(di&&di.dealerId);const hasAdmin=!!(su&&su.id);
if(hasDealer&&hasAdmin){if(m==='dealer'){mode='dealer';curDealerId=di.dealerId;currentUser=null;setActiveToken('dealer');closeLoginScreen();await loadDB();render();connectWS();return;}mode='admin';currentUser=su;setActiveToken('admin');closeLoginScreen();await loadDB();applyExpiry();render();connectWS();return;}
if(hasDealer){mode='dealer';curDealerId=di.dealerId;currentUser=null;setActiveToken('dealer');closeLoginScreen();await loadDB();render();connectWS();return;}
if(hasAdmin){mode='admin';currentUser=su;setActiveToken('admin');closeLoginScreen();await loadDB();applyExpiry();render();connectWS();return;}
showLogin();}catch(err){console.error('CRM 启动失败:',err);if(err&&err.status===401){/* ★ 2026-09-24 第 164 轮:启动时撞 401(会话过期/服务端重启后 token 失效)⇒ 回登录页重登,不得再走 renderFatal —— 否则用户看到「本地缓存数据可能已损坏」的错误页,且该页无登录入口、按钮文案也不符实(只是退出登录)。清理逻辑与 pwdGateKick / doLogout 同款。 */try{apiToken='';currentUser=null;curDealerId='';localStorage.removeItem('yuanmo_token');localStorage.removeItem('yuanmo_dealer_token');localStorage.removeItem('yuanmo_user');localStorage.removeItem('yuanmo_dealer');localStorage.removeItem('yuanmo_mode');}catch(e){}try{if(ws)ws.close();}catch(e){}try{showLogin();}catch(e){}toast('登录已过期,请重新登录');}else{renderFatal(err);}}})();}
function applyExpiry(){try{const _exp=applyProtectionExpiry();if(_exp>0)save();}catch(e){}}
function showLogin(){const app=document.querySelector('.app');if(app)app.style.display='none';let lc=document.getElementById('loginScreen');if(!lc){lc=document.createElement('div');lc.id='loginScreen';document.body.appendChild(lc);}
lc.cl
assName='login-screen-full';lc.innerHTML=`
<div class="login-card">
<div class="lc-logo">元</div>
<h2>元墨XR经销商CRM</h2>
<p class="mtxt">经销商权益管理系统 · 管理后台</p>
<div id="loginErr" class="warn-box" style="display:none"></div>
<div class="field"><label>账号</label><input id="lg_user" placeholder="如 admin" autocomplete="username" onkeydown="if(event.key==='Enter')doLogin()"></div>
<div class="field"><label>密码</label><input id="lg_pwd" type="password" placeholder="请输入密码" autocomplete="current-password" onkeydown="if(event.key==='Enter')doLogin()"></div>
<button class="btn" style="width:100%;margin-top:8px" onclick="doLogin()">登录</button>
<div class="login-build">构建版本:${BUILD_ID} · 如与最新不一致请关闭本页重新打开,或点右上角用户菜单里的“刷新页面”</div>
</div>`;setTimeout(()=>{const u=document.getElementById('lg_user');if(u)u.focus();},60);}
function closeLoginScreen(){const ls=document.getElementById('loginScreen');if(ls)ls.remove();const app=document.querySelector('.app');if(app)app.style.display='';}
async function doLogin(){const err=document.getElementById('loginErr');const _ue=document.getElementById('lg_user'),_pe=document.getElementById('lg_pwd');if(!_ue||!_pe){const _m='登录界面未就绪(页面可能未加载完整),请稍候重试;若仍不行请按 Ctrl+F5 强制刷新后再登录。';if(err){err.style.display='';err.textContent=_m;}try{if(!err)toast(_m);}catch(e){}return;}const u=(_ue.value||'').trim();const p=(_pe.value||'');if(err)err.style.display='none';try{const d=/^1\d{10}$/.test(u)?await apiDealerLogin(u,p):await apiLogin(u,p);if(d.mustChangePwd){window.__pendLogin={resp:d,oldPwd:p};apiToken=d.adminToken||d.dealerToken||'';openModal(`<div class="modal-head"><h3>🔒 请修改初始密码</h3><a href="javascript:void(0)"onclick="doLogout()"style="margin-left:auto;font-size:13px;color:#9aa4b2;text-decoration:none">退出登录</a></div><div class="modal-body"><div class="warn-box">您当前使用的是初始密码,为保障账号与客户数据安全,请设置新密码后再进入系统。</div><div class="field"><label>新密码(至少 6 位)</label><input type="password"id="np_pwd"placeholder="请输入新密码"></div><div class="field"><label>确认新密码</label><input type="password"id="np_pwd2"placeholder="再次输入新密码"></div></div><div class="modal-foot"><button class="btn"onclick="submitDealerNewPwd()">确认修改并进入</button></div>`);}else{await finishLogin(d);}}catch(e){if(err){err.style.display='';err.textContent=(e&&e.message)||'登录失败';}}}
function doLogout(){try{apiPost('/api/logout',{}).catch(()=>{});}catch(e){}apiToken='';currentUser=null;curDealerId='';try{localStorage.removeItem('yuanmo_token');localStorage.removeItem('yuanmo_dealer_token');localStorage.removeItem('yuanmo_user');localStorage.removeItem('yuanmo_dealer');localStorage.removeItem('yuanmo_mode');}catch(e){}
if(ws){try{ws.close();}catch(e){}ws=null;}
showLogin();}
function enterDealerPortal(){currentUser=null;closeLoginScreen();mode='dealer';curDealerId='';render();}
function renderUsers(){if(!can('manage_users')){view.innerHTML='<div class="card"><p class="empty">无权限:仅拥有「用户账号管理」权限的账号可访问。</p></div>';return;}
const rows=DB.users.length?DB.users.map(u=>`
<tr>
<td><b>${fTxt('u_name',u.name)}</b></td>
<td>${fTxt('u_username',u.username)}</td>
<td>${deptNameOf(u.deptId)||'<span class="mtxt">—</span>'}</td>
<td>${fLevel('u_role')==='full'?rolesTagsHtml(u):('<span class="mtxt">'+(fLevel('u_role')==='mask'?'***':'****')+'</span>')}</td>
<td>${u.active?'<span class="st st-green">启用</span>':'<span class="st st-red">停用</span>'}</td>
<td>
${can('edit_users')?`<button class="btn ghost sm" onclick="openUser('${u.id}')">编辑</button>`:''}
${u.id!==currentUser.id&&can('del_users')?`<button class="btn danger sm"onclick="delUser('${u.id}')">删</button>`:''}
</td>
</tr>`).join(''):'<tr><td colspan="6" class="empty">暂无用户</td></tr>';view.innerHTML=`
<div class="topbar"><div><h1>用户与权限</h1><p>管理后台登录账号、部门与角色权限</p></div>
<button class="btn ghost" onclick="openPwdCenter()">看板密码</button>
${can('manage_users')?'<button class="btn ghost" onclick="openDept()">+ 新增部门</button>':''}
${can('edit_users')?'<button class="btn" onclick="openUser()">+ 新增用户</button>':''}</div>
${deptChipsHtml()}
<div class="card"><table>
<thead><tr><th>姓名</th><th>账号</th><th>部门</th><th>角色</th><th>状态</th><th>操作</th></tr></thead>
<tbody>${rows}</tbody></table></div>`;}
function openUser(id){if(!can('edit_users')){toast('无权限:需要「编辑用户账号」权限');return;}const u=id?DB.users.find(x=>x.id===id):null;const selRoles=(u&&Array.isArray(u.roles)&&u.roles.length)?u.roles:(u&&u.role?[u.role]:[]);const roleChecks=['admin'].concat(Object.keys(DB.rolePerms||{}).filter(r=>r!=='dealer')).map(rid=>{const ck=selRoles.indexOf(rid)>=0?'checked':'';return'<label class="role-check"><input type="checkbox" name="u_roles" value="'+rid+'" '+ck+'> <span>'+esc(rid==='admin'?'管理员(全部权限)':roleNameOf(rid))+'</span></label>';}).join('');openModal(`
<div class="modal-head"><h3>${u?'编辑':'新增'}用户</h3><span class="x" onclick="closeModal()">×</span></div>
<div class="modal-body">
<div class="field"><label>姓名 *</label><input id="u_name" value="${u?u.name:''}" placeholder="如:张三"></div>
<div class="field"><label>登录账号 *</label><input id="u_username" value="${u?u.username:''}" ${u?'disabled':''} pla
ceholder="登录用账号,唯一"></div>
<div class="field"><label>登录密码 *</label><input id="u_pwd" type="password" value="" placeholder="${u?'留空=不修改':'登录密码'}"></div>
<div class="field"><label>部门</label><select id="u_dept" ${u&&u.id===currentUser.id?'disabled':''}>${deptOptionsHtml(u?u.deptId:'')}</select><div class="hint">选填,用于组织归类;部门可在「+ 新增部门」中维护。${u&&u.id===currentUser.id?'本人账号部门由其他管理员调整。':''}</div></div>
<div class="field"><label>角色(可多选)</label><div class="role-checks">${roleChecks}</div></div>
<div class="field"><label>状态</label><select id="u_active"><option value="1" ${!u||u.active?'selected':''}>启用</option><option value="0" ${u&&!u.active?'selected':''}>停用</option></select></div>
${u&&u.id===currentUser.id?'<div class="hint">不能修改当前登录账号的角色与停用状态。</div>':''}
</div>
<div class="modal-foot">
<button class="btn ghost" onclick="closeModal()">取消</button>
<button class="btn" onclick="saveUser('${id||''}')">保存</button>
</div>`);}
function saveUser(id){if(!can('edit_users')){toast('无权限:需要「编辑用户账号」权限');return;}const name=f('u_name');const username=f('u_username');const pwd=document.getElementById('u_pwd').value;const roles=Array.from(document.querySelectorAll('input[name="u_roles"]:checked')).map(c=>c.value);const active=document.getElementById('u_active').value==='1';const deptEl=document.getElementById('u_dept');const deptId=deptEl?deptEl.value:'';if(!name||!username){toast('请填写姓名、账号');return;}
if(id){const u=DB.users.find(x=>x.id===id);if(!u)return;if(u.id===currentUser.id){u.name=name;if(pwd){u.password=pwd;const ld=u.dealerId?DB.dealers.find(d=>d.id===u.dealerId):null;if(ld)ld.password=pwd;}}else{u.name=name;if(pwd){u.password=pwd;const ld=u.dealerId?DB.dealers.find(d=>d.id===u.dealerId):null;if(ld)ld.password=pwd;}u.roles=roles;u.active=active;u.deptId=deptId;}}else{if(!pwd){toast('请填写登录密码');return;}if(DB.users.find(x=>x.username===username)){toast('账号已存在');return;}
DB.users.push({id:uid('u_'),username,name,password:pwd,roles,role:roles[0]||'',active,deptId});}
save().then(ok=>{if(ok){closeModal();render();toast('用户已保存');}});}
function delUser(id){if(!can('del_users')){toast('无权限:删除用户账号需「删除用户账号」权限');return;}if(!confirm('确认删除该用户?'))return;if(id===currentUser.id){toast('不能删除当前登录账号');return;}
DB.users=DB.users.filter(x=>x.id!==id);save().then(function(ok){if(!ok)return;render();toast('已删除');});}
function deptNameOf(id){if(!id)return'';const d=(DB.departments||[]).find(x=>x&&x.id===id);return d?d.name:'';}
function deptCount(id){return DB.users.filter(u=>u.deptId===id).length;}
function deptChipsHtml(){const ds=DB.departments||[];if(!ds.length)return'<div class="card"><p class="empty">暂无部门,点击右上「+ 新增部门」创建。</p></div>';return'<div class="card"><div style="display:flex;flex-wrap:wrap;gap:8px;align-items:center"><b style="margin-right:6px">部门('+ds.length+')</b>'+ds.map(d=>'<span class="tag t-experience" style="display:inline-flex;gap:6px;align-items:center;padding:4px 10px;font-size:13px">'+esc(d.name)+'<i class="mtxt" style="font-style:normal">'+deptCount(d.id)+'人</i>'+(can('manage_users')?'<a href="javascript:void(0)" style="text-decoration:none" onclick="openDept(\''+d.id+'\')">改</a>':'')+(can('del_depts')&&deptCount(d.id)===0?'<a href="javascript:void(0)" style="color:var(--danger);text-decoration:none" onclick="delDept(\''+d.id+'\')">删</a>':'')+'</span>').join('')+'</div></div>';}
function deptOptionsHtml(sel){return'<option value="">未分配</option>'+(DB.departments||[]).map(d=>'<option value="'+d.id+'" '+(sel===d.id?'selected':'')+'>'+esc(d.name)+'</option>').join('');}
function openBoardPwd(){if(!can('manage_users')){toast('无权限:需「用户账号管理」权限');return;}openModal(`
<div class="modal-head"><h3>设置看板密码</h3><span class="x" onclick="closeModal()">×</span></div>
<div class="modal-body">
<p style="margin:0 0 12px;color:var(--muted);font-size:12.5px">合作客户看板(board.html)的独立登录密码,与 CRM 账号无关。修改后看板所有已登录设备将退出,需用新密码重新登录。</p>
<div class="field"><label>新密码(至少 4 位)*</label><input id="bp_new" type="password" placeholder="输入新密码"></div>
<div class="field"><label>确认新密码 *</label><input id="bp_new2" type="password" placeholder="再次输入"></div>
</div>
<div class="modal-foot">
<button class="btn ghost" onclick="closeModal()">取消</button>
<button class="btn" onclick="saveBoardPwd()">确认修改</button>
</div>`);}
function saveBoardPwd(){const a=document.getElementById('bp_new');const b=document.getElementById('bp_new2');const n1=a?a.value:'';const n2=b?b.value:'';if(n1.length<4){toast('新密码至少 4 位');return;}if(n1!==n2){toast('两次输入的新密码不一致');return;}apiPost('/api/board/pwd',{new:n1}).then(()=>{closeModal();toast('看板密码已修改,看板需用新密码重新登录');}).catch(e=>{toast('修改失败:'+((e&&e.message)||''));});}
function openOpsPwd(){if(!can('manage_users')){toast('无权限:需「用户账号管理」权限');return;}openModal(`
<div class="modal-head"><h3>设置经营看板密码</h3><span class="x" onclick="closeModal()">×</span></div>
<div class="modal-body">
<p style="margin:0 0 12px;color:var(--muted);font-size:12.5px">经营看板(ops.html)的独立登录密码,与 CRM 账号、其它看板密码均无关。修改后经营看板所有已登录设备将退出,需用新密码重新登录。</p>
<div class="field"><label>新密码(至少 4 位)*</label><input id="op_new" type="password" placeholder="输入新密码"></div>
<div class="field"><label>确认新密码 *</label><input id="op_new2" type="password" placeholder="再次输入"></div>
</div>
<div class="modal-foot">
<button class="btn ghost" onclick="closeModal()">取消</button>
<button class="btn" onclick="saveOpsPwd()">确认修改</button>
</div>`);}
function saveOpsPwd(){const a=document.getElementById('op_new');const b=document.getElementById('op_new2');const n1=a?a.value:'';const n2=b?b.value:'';if(n1.length<4){toast('新密码至少 4 位');return;}if(n1!==n2){toast('两次输入的新密码不一致');return;}apiPost('/api/ops/pwd',{new:n1}).then(()=>{closeModal();toast('经营看板密码已修改,经营看板需用新密码重新登录');}).catch(e=>{toast('修改失败:'+((e&&e.message)||''));});}
function openPwdCenter(){if(!can('manage_users')){toast('无权限:需「用户账号管理」权限');return;}openModal(`
<div class="modal-head"><h3>🔑 看板密码</h3><span class="x" onclick="closeModal()">×</span></div>
<div class="modal-body">
<p style="margin:0 0 12px;color:var(--muted);font-size:12.5px">各看板使用独立登录密码,与 CRM 账号互不影响。点「修改」设置新密码;修改后该看板所有已登录设备将退出,需用新密码重新登录。</p>
<div style="border:1px solid var(--line);border-radius:10px;overflow:hidden">
<div style="display:flex;align-items:center;gap:10px;padding:10px 12px;border-bottom:1px solid var(--line)">
<div style="flex:1;min-width:0"><b style="font-size:13.5px">合作客户看板</b><div style="color:var(--muted);font-size:12px;margin-top:2px">/board · 给合作方查看</div></div>
<button class="btn ghost sm" onclick="openBoardPwd()">修改</button>
</div>
<div style="display:flex;align-items:center;gap:10px;padding:10px 12px;border-bottom:1px solid var(--line)">
<div style="flex:1;min-width:0"><b style="font-size:13.5px">经营看板</b><div style="color:var(--muted);font-size:12px;margin-top:2px">/ops · 独立密码</div></div>
<button class="btn ghost sm" onclick="openOpsPwd()">修改</button>
</div>
<div style="display:flex;align-items:center;gap:10px;padding:10px 12px;border-bottom:1px solid var(--line)">
<div style="flex:1;min-width:0"><b style="font-size:13.5px">月度看板</b><div style="color:var(--muted);font-size:12px;margin-top:2px">/month · 独立密码</div></div>
<button class="btn ghost sm" onclick="openMonthPwd()">修改</button>
</div>
<div style="display:flex;align-items:center;gap:10px;padding:10px 12px">
<div style="flex:1;min-width:0"><b style="font-size:13.5px">业绩目标看板</b><div style="color:var(--muted);font-size:12px;margin-top:2px">/perf · 管理员用此密码;员工用各自 CRM 账号登录,不在此处改</div></div>
<button class="btn ghost sm" onclick="openPerfPwd()">修改</button>
</div>
</div>
</div>
<div class="modal-foot">
<button class="btn ghost" onclick="closeModal()">关闭</button>
</div>`);}
function openPerfPwd(){if(!can('manage_users')){toast('无权限:需「用户账号管理」权限');return;}openModal(`
<div class="modal-head"><h3>设置业绩目标看板密码</h3><span class="x" onclick="closeModal()">×</span></div>
<div class="modal-body">
<p style="margin:0 0 12px;color:var(--muted);font-size:12.5px">业绩目标看板(perf.html)的独立登录密码,与 CRM 账号、其它看板密码均无关。员工登录用的是各自 CRM 账号的手机号+密码,不在此处修改。修改后看板所有已登录设备将退出,需用新密码重新登录。</p>
<div class="field"><label>新密码(至少 4 位)*</label><input id="pf_new" type="password" placeholder="输入新密码"></div>
<div class="field"><label>确认新密码 *</label><input id="pf_new2" type="password" placeholder="再次输入"></div>
</div>
<div class="modal-foot">
<button class="btn ghost" onclick="closeModal()">取消</button>
<button class="btn" onclick="savePerfPwd()">确认修改</button>
</div>`);}
function savePerfPwd(){const a=document.getElementById('pf_new');const b=document.getElementById('pf_new2');const n1=a?a.value:'';const n2=b?b.value:'';if(n1.length<4){toast('新密码至少 4 位');return;}if(n1!==n2){toast('两次输入的新密码不一致');return;}apiPost('/api/perf/pwd',{new:n1}).then(()=>{closeModal();toast('业绩目标看板密码已修改,看板需用新密码重新登录');}).catch(e=>{toast('修改失败:'+((e&&e.message)||''));});}
function openMonthPwd(){if(!can('manage_users')){toast('无权限:需「用户账号管理」权限');return;}openModal(`
<div class="modal-head"><h3>设置月度看板密码</h3><span class="x" onclick="closeModal()">×</span></div>
<div class="modal-body">
<p style="margin:0 0 12px;color:var(--muted);font-size:12.5px">月度看板(month.html)的独立登录密码,与 CRM 账号、经营看板密码均无关。修改后月度看板所有已登录设备将退出,需用新密码重新登录。</p>
<div class="field"><label>新密码(至少 4 位)*</label><input id="mt_new" type="password" placeholder="输入新密码"></div>
<div class="field"><label>确认新密码 *</label><input id="mt_new2" type="password" placeholder="再次输入"></div>
</div>
<div class="modal-foot">
<button class="btn ghost" onclick="closeModal()">取消</button>
<button class="btn" onclick="saveMonthPwd()">确认修改</button>
</div>`);}
function saveMonthPwd(){const a=document.getElementById('mt_new');const b=document.getElementById('mt_new2');const n1=a?a.value:'';const n2=b?b.value:'';if(n1.length<4){toast('新密码至少 4 位');return;}if(n1!==n2){toast('两次输入的新密码不一致');return;}apiPost('/api/month/pwd',{new:n1}).then(()=>{closeModal();toast('月度看板密码已修改,看板需用新密码重新登录');}).catch(e=>{toast('修改失败:'+((e&&e.message)||''));});}
function openDept(id){const d=id?(DB.departments||[]).find(x=>x.id===id):null;openModal(`
<div class="modal-head"><h3>${d?'修改':'新增'}部门</h3><span class="x" onclick="closeModal()">×</span></div>
<div class="modal-body">
<div class="field"><label>部门名称 *</label><input id="dp_name" value="${d?esc(d.name):''}" placeholder="如:销售一部"></div>
<div class="field"><label>备注</label><input id="dp_note" value="${d?esc(d.note||''):''}" placeholder="选填"></div>
</div>
<div class="modal-foot">
<button class="btn ghost" onclick="closeModal()">取消</button>
<button class="btn" onclick="saveDept('${id||''}')">保存</button>
</div>`);}
function saveDept(id){if(!can('manage_users')){toast('无权限:需「用户账号管理」权限');return;}const name=f('dp_name').trim();const note=document.getElementById('dp_note').value.trim();if(!name){toast('请填写部门名称');return;}const dup=(DB.departments||[]).find(x=>x.name===name&&x.id!==id);if(dup){toast('部门名称已存在');return;}
if(id){const d=DB.departments.find(x=>x.id===id);if(!d)return;d.name=name;d.note=note;}else{DB.departments=DB.departments||[];DB.departments.push({id:uid('dep_'),name,note,createdAt:new Date().toISOString()});}
save().then(ok=>{if(ok){closeModal();render();toast('部门已保存');}});}
function delDept(id){if(!can('del_depts')){toast('无权限:删除部门需「删除部门」权限');return;}const d=(DB.departments||[]).find(x=>x.id===id);if(!d)return;const n=deptCount(id);if(n>0){toast('该部门下还有 '+n+' 个用户,请先在编辑用户中调整部门');return;}if(!confirm('确认删除部门「'+d.name+'」?'))return;DB.departments=DB.departments.filter(x=>x.id!==id);save().then(function(ok){if(!ok)return;render();toast('已删除');});}
let curRoleId='operator';function roleNameOf(id){if(id==='admin')return'管理员';if(id==='dealer')return'经销商';const m=(permDraft&&permDraft.meta&&permDraft.meta[id])||(DB.roleMeta&&DB.roleMeta[id]);return(m&&m.name)?m.name:id;}
function roleTagHtml(rid){if(rid==='admin')return'<span class="tag t-official">管理员</span>';if(rid==='dealer')return'<span class="tag t-experience">经销商</span>';return'<span class="tag t-experience">'+esc(roleNameOf(rid))+'</span>';}
function rolesTagsHtml(u){const roles=(u&&Array.isArray(u.roles)&&u.roles.length)?u.roles:[];if(!roles.length)return'<span class="tag" style="background:#f1f5f9;color:#64748b">无角色</span>';return roles.map(roleTagHtml).join(' ');}
let permDraft=null;let permViewMode='edit';let permSearchKw='';let permOpenPages={};
var __permPos={};var __permPosCur='';var __permPosSkip=false;
/* ★ 第 541 轮:权限页滚动位置记忆 —— renderPerms() 绕过 render() 层的 _snapScroll/_restoreScroll,
这里按「视图 + 角色」自记一套:切 tab / 切角色 / 保存后重渲染都能停在原处;搜索态(临时视图)不写不恢复。 */
function permPosKey(){return 'perms:'+(permViewMode==='overview'?'ov':'ed:'+curRoleId);}
function permPosOnPage(){try{return !!(document.querySelector('.perm-tblwrap')||document.querySelector('.ov-tbl'));}catch(e){return false;}}
function permPosSnap(key){
if(!key)return;
if(!permPosOnPage())return;
try{
var de=document.scrollingElement||document.documentElement;
var els=__posEls();var a=[];
for(var i=0;i<els.length;i++)a.push(Math.round(els[i].scrollTop)||0);
__permPos[key]={doc:Math.round(de.scrollTop)||0,els:a,at:Date.now()};
}catch(e){}
}
function permPosRestore(key,els){
var t=__permPos[key];if(!t)return els;
try{
var de=document.scrollingElement||document.documentElement;
de.scrollTop=t.doc||0;
if(!els)els=__posEls();
for(var i=0;i<els.length;i++){if(i===t.els.length)break;els[i].scrollTop=t.els[i]||0;}
}catch(e){}
return els;
}
function permPosApply(){
var k=permPosKey();__permPosCur=k;
if(__permPosSkip){__permPosSkip=false;return;}
if(permSearchKw)return;
requestAnimationFrame(function(){
var els=permPosRestore(k);
setTimeout(function(){permPosRestore(k,els);},120);
});
}
function permSnap(){return JSON.parse(JSON.stringify({perms:DB.rolePerms||{},meta:DB.roleMeta||{}}));}
function pdEnsure(){if(!permDraft){permDraft=permSnap();permDraft.dirty=false;}if(!permDraft.meta)permDraft.meta={};if(!permDraft.perms)permDraft.perms={};return permDraft;}
function pdRp(role){pdEnsure();if(!permDraft.perms[role])permDraft.perms[role]=(role==='dealer')?JSON.parse(JSON.stringify(DEALER_PORTAL_DEFAULT)):blankRolePerms();if(!permDraft.perms[role].fields)permDraft.perms[role].fields={};return permDraft.perms[role];}
function pdMark(){if(permDraft)permDraft.dirty=true;const el=document.getElementById('permSaveBar');if(el)el.style.display='flex';}
function pdSyncBar(){const el=document.getElementById('permSaveBar');if(el)el.style.display=(permDraft&&permDraft.dirty)?'flex':'none';}
function setPermView(m){permViewMode=m;renderPerms();}
function renderPerms(){if(!can('manage_roles')){view.innerHTML='<div class="card"><p class="empty">无权限:仅拥有「角色与权限配置」权限的账号可访问。</p></div>';return;}
try{if(__permPosCur){if(!permSearchKw)permPosSnap(__permPosCur);}}catch(e){}
injectRoleStyles();renderPermDiag();if(permViewMode==='overview'){injectNavHideBar();renderPermsOverview();return;}injectNavHideBar();renderPermsEdit();if(permSearchKw)permSearchDo(permSearchKw);}
function renderPermsEdit(){
pdEnsure();const roleIds=Object.keys(permDraft.perms);if(!roleIds.includes(curRoleId))curRoleId='operator';const isDealer=curRoleId==='dealer';const rp=permDraft.perms[curRoleId]||pdRp(curRoleId);const defObj=isDealer?DEALER_PORTAL_DEFAULT:OPERATOR_DEFAULT;const isOp=curRoleId==='operator';
const tabs=roleIds.map(id=>{const isSys=(id==='operator'||id==='dealer');const act=`<span class="role-act">${id!=='dealer'?`<span class="role-ic" title="复制该角色权限" onclick="copyRole('${id}')">⧉</span>`:''}${!isSys?`<span class="role-ic" title="重命名" onclick="renameRole('${id}')">✎</span>`:''}${can('del_roles')&&!isSys?`<span class="role-ic del" title="删除" onclick="delRole('${id}')">✕</span>`:''}</span>`;return `<div class="role-tab ${id===curRoleId?'active':''}" onclick="selectRole('${id}')">${esc(roleNameOf(id))}${act}</div>`;}).join('')+`<div class="role-tab add" onclick="addRole()">+ 新增角色</div>`;
const pages=isDealer?DEALER_PAGE_PERMS:PAGE_PERMS;/* ★ 2026-09-18 第 68 轮续 3(用户定案「统一规则,按页面级」):把每页 4 列的键集预先算好,字段格的「编辑 / 删除」列直接查它 ⇒ 与页级格**同一数据源**(都来自 pages[].{view,edit,delete,export}),结构上不可能再出现「页级是 —、字段格却画成闸门」或「跨页反查」的漂移。 */const PGKS={};pages.forEach(p=>{PGKS[p.id]={view:p.view||[],edit:p.edit||[],delete:p.delete||[],export:p.export||[]};});const OPS=[['view','查看'],['edit','编辑'],['delete','删除'],['export','导出']];const FOPS=[['v','可见级别','该字段的展示级别:完整 / 脱敏 / 隐藏'],['e','编辑','该字段能否被编辑'],['d','删除','该字段能否被删除'],['x','导出','该字段能否随导出带出']];
const pv=k=>rp[k]!==undefined?!!rp[k]:!!defObj[k];
const cell=(entry,op)=>{const ks=entry[op]||[];if(!ks.length)return '<td class="pc-na" title="无此操作">—</td>';const onAll=ks.every(pv);const nOn=ks.filter(pv).length;const part=(nOn>0&&!onAll)?'<span class="pc-part" title="该格含 '+ks.length+' 个权限键,目前只授权了 '+nOn+' 个">'+nOn+'/'+ks.length+'</span>':'';const on=onAll;const title=ks.map(k=>PERM_LABEL[k]||k).join('、');let stick='';if(op==='view'&&!isDealer){const _pfk=(FIELDS_BY_PAGE[entry.id]||[]);const _pc=_pfk.filter(function(fid){const info=FIELDS[fid]||{};const fb=info.fb&&info.fb.v;if(!fb||ks.indexOf(fb)<0)return false;if(pv(fb))return false;const f=rp.fields&&rp.fields[fid];if(!f||f.v===undefined)return false;const L=normLv(f.v);return L&&L!=='hidden';});if(_pc.length)stick='<span class="fld-tag warn" title="⚠ 本页有 '+_pc.length+' 个字段已单独设置了展示级别,它们优先于本页功能权限 —— 关掉本开关不会改变这些字段的可见性:'+_pc.map(function(fid){return (FIELDS[fid]||{}).label||fid;}).join('、')+' ⇒ 想彻底关闭请到下方「字段可见范围」逐项点 ↺ 恢复「跟随」">⚠ '+_pc.length+' 字段已单独设置</span>';}const lbl=(ks.length===1)?('<span class="pc-lbl" title="'+esc(title)+'">'+esc(PERM_LABEL[ks[0]]||ks[0])+'</span>'):'';return `<td><input type="checkbox" ${on?'checked':''} title="${esc(title)}" onchange="setPagePerm('${curRoleId}','${entry.id}','${op}',this.checked)">${lbl}${part}${stick}</td>`;};
const opHead=OPS.map(o=>`<th>${o[1]}</th>`).join('');
const fieldHead=FOPS.map(o=>'<th class="pc-field-h"'+(o[2]?' title="'+o[2]+'"':'')+'>'+o[1]+'</th>').join('');
const gatePage=(pid,gk)=>{if(!gk)return null;let hit=null;pages.forEach(p=>{if(hit)return;['view','export','edit','delete'].forEach(o=>{(p[o]||[]).forEach(k=>{if(!hit&&k===gk)hit=p;});});});return (hit&&hit.id!==pid)?hit:null;};
const fieldCell=(fid,op,pid)=>{const f=FIELDS[fid];if(f&&f.disp&&op!=='v')return '<td class="pc-na" title="展示型字段:不参与编辑 / 删除 / 导出">—</td>';if(op==='x'&&EXPORT_FIDS.indexOf(fid)<0)return '<td class="pc-na" title="'+esc(f.label)+':本字段不参与任何导出(字段级导出权只对导出列过滤覆盖的 '+EXPORT_FIDS.length+' 个字段有意义)⇒ 无导出权可设">—</td>';const fd=(rp.fields&&rp.fields[fid])||{};const own=(fd[op]!==undefined);/* ★ 第 68 轮续 3:e / d 两列不再看「字段自带的管理端旧键」,只看**本页页级同名列**。colKs = 本页该列的权限键集(空 = 页级格渲染「—」)⇒ 字段格同列必须也是「—」。旧形态在经销商门户(!isDealer 把两列的页级判定整体关掉)会一律退回该字段自带的管理端旧键 ⇒ 页级已勾却显示「需先开上方」。v 列与 x 列的闸键恒为 null(gateOn 恒 true)—— 既有语义:仅做「跟随 / 独立授权」类标签的分流,绝不因页级该列为「—」而锁死(x 有 #172/#173 守护)⇒ 本行末尾刻意写 :null,改它等于偷换 x 列语义。 */const pgk=PGKS[pid]||{view:[],edit:[],delete:[],export:[]};const colKs=(op==='e')?pgk.edit:((op==='d')?pgk.delete:null);if(colKs&&!colKs.length)return '<td class="pc-na" title="本页「'+opLabel(op)+'」没有任何功能权限入口 ⇒ 字段级'+opLabel(op)+'权不适用">—</td>';const regK=(op==='e')?(PAGE_EDIT_KEY[pid]||null):((op==='d')?(PAGE_DEL_KEY[pid]||null):null);const gateK=colKs?((regK&&colKs.indexOf(regK)>=0)?regK:colKs[0]):null;const gateOn=colKs?((op==='e'&&!isDealer)?fbEditOn(rp,fid):pv(gateK)):(gateK?pv(gateK):true);const missKs=colKs?colKs.filter(k=>!pv(k)):[];const gp=gatePage(pid,gateK);const gpPg=gp?('「'+gp.name+'」页'):'上方该页';const tag=own?(gateOn?((op!=='v'&&fd[op]===false)?'<span class="fld-tag own deny" title="已显式关闭:本项不允许「'+opLabel(op)+'」(与「未设置」不同 —— 未设置=跟随上方 / 默认;本项被人工锁定为禁止)⇒ 点右侧 ↺ 可恢复">已改·禁止</span>':'<span class="fld-tag own" title="已单独设置,不再跟随上方功能权限">已改</span>'):'<span class="fld-tag warn" title="已单独设置,但'+gpPg+'的「'+opLabel(op)+'」未开启,本项暂不生效(设置值已保留,'+gpPg+'开启后自动生效)">已改·暂不生效</span>'):(!gateOn?(gp?'<span class="fld-tag warn fld-jump" data-gp="'+gp.id+'" data-gn="'+esc(gp.name)+'" title="本项目由'+gpPg+'的「'+opLabel(op)+'」功能权限控制(不在本页)⇒ 点此跳到该页开启" onclick="gateJump(this.dataset.gp,this.dataset.gn)">需先开'+gpPg+' ↗</span>':((missKs.length&&missKs.length<colKs.length)?'<span class="fld-tag warn" title="本页「'+opLabel(op)+'」格含 '+colKs.length+' 个权限键,还差:'+missKs.map(k=>(PERM_LABEL[k]||k)).join('、')+' ⇒ 该格开齐后本项自动生效">上方「'+opLabel(op)+'」还差 '+missKs.length+' 项</span>':'<span class="fld-tag warn" title="本页「'+opLabel(op)+'」功能权限未开启,本项目前不生效">需先开上方</span>')):((op==='e'&&!isDealer&&gateOn&&!pv(PAGE_EDIT_KEY[pid]))?'<span class="fld-tag inh" title="本编辑权来自该字段其它所在页的功能权限(该字段在多页出现,任一本页编辑权放行即生效)">随它页</span>':(!isDealer&&op==='d')?'<span class="fld-tag inh" title="字段级删除权:本页「删除」功能权限开启后可单独勾选;未勾 = 不允许删除(自检 #172 / #173 守护)">需单独勾选</span>':(!isDealer&&op==='x')?'<span class="fld-tag inh" title="导出类的字段级权限是「没勾 = 不允许」,必须在本格单独勾选才生效;不会跟随上方功能权限(自检 #172 / #173 守护)">需单独授权</span>':'<span class="fld-tag inh" title="未单独设置,跟随上方对应的功能权限">跟随</span>'));const undo=own?'<span class="fld-undo" title="清除单独设置,恢复跟随上方" onclick="clearFieldPerm(\''+curRoleId+'\',\''+fid+'\',\''+op+'\')">↺</span>':'';if(op==='v'&&!isDealer){const lv=fieldLvOn(rp,fid);const vFbK=(FIELDS[fid]||{}).fb&&FIELDS[fid].fb.v;const vLvTxt=({full:'完整',mask:'脱敏',hidden:'隐藏'})[lv]||lv;const vTag=(fd.v!==undefined&&vFbK&&!pv(vFbK)&&lv!=='hidden')?'<span class="fld-tag warn" title="⚠ 本字段已单独设为「'+vLvTxt+'」,而它所属页级功能权限「'+(PERM_LABEL[vFbK]||vFbK)+'」当前是关闭的 —— 字段级显式设置优先于页级开关,故实际净效果仍为「'+vLvTxt+'」。要让「关闭页级」真正生效,请点右侧 ↺ 恢复「跟随」。">已改·优先于页级</span>':tag;return `<td><select class="lv-sel" title="${esc(f.label)} · 展示级别" onchange="setFieldPerm('${curRoleId}','${fid}','v',this.value)"><option value="full"${lv==='full'?' selected':''}>完整(原样显示)</option><option value="mask"${lv==='mask'?' selected':''}>脱敏(打码)</option><option value="hidden"${lv==='hidden'?' selected':''}>隐藏(看不到)</option></select>`+vTag+undo+`</td>`;}const on=fieldOn(rp,fid,op,isDealer);if(!gateOn)return '<td class="fld-off" title="'+esc(f.label)+':'+gpPg+'的「'+opLabel(op)+'」未开启,字段级开关此时无效"><input type="checkbox" disabled'+(on?' checked':'')+'>'+tag+undo+'</td>';return '<td class="fld-cell"><input type="checkbox" class="fld-cb '+(own?'own':'inh')+'"'+(on?' checked':'')+' title="'+esc(f.label)+' · '+opLabel(op)+'" onchange="setFieldPerm(\''+curRoleId+'\',\''+fid+'\',\''+op+'\',this.checked)">'+tag+undo+'</td>';};
const pageSec=pages.map(page=>{const cells=OPS.map(o=>cell(page,o[0])).join('');const fids=isDealer?(DEALER_FIELDS_BY_PAGE[page.id]||[]):(FIELDS_BY_PAGE[page.id]||[]);const ownCnt=fids.filter(function(fid){const fd=(rp.fields&&rp.fields[fid])||{};return Object.keys(fd).length>0;}).length;const kwStr=page.name+' '+(page.view||[]).concat(page.export||[],page.edit||[],page.delete||[]).map(k=>PERM_LABEL[k]||k).join(' ')+' '+fids.map(function(fid){return (FIELDS[fid]||{}).label||fid;}).join(' ');const frows=fids.length?`<tr class="fld-tr" data-pg="${page.id}" style="${permOpenPages[page.id]?'':'display:none'}"><td colspan="5"><div class="fld-box"><div class="fld-title">字段可见范围 · ${esc(page.name)}<span class="fld-title-sub">只管「看得到多少」,改不了上方那行的功能权限</span><span class="fld-chips"><span class="fld-chip active" data-mode="all" onclick="fldFilter('${page.id}','all')">全部<b>${fids.length}</b></span><span class="fld-chip" data-mode="own" onclick="fldFilter('${page.id}','own')">已单独设置<b>${ownCnt}</b></span></span></div><table class="fld-tbl"><thead><tr><th style="text-align:left">字段</th>${fieldHead}</tr></thead><tbody>${fids.map(fid=>`<tr class="fld-frow" data-own="${(rp.fields&&rp.fields[fid]&&Object.keys(rp.fields[fid]).length)?'1':'0'}"><td class="fld-name">${esc((FIELDS[fid]||{}).label||fid)}</td>${FOPS.map(o=>fieldCell(fid,o[0],page.id)).join('')}</tr>`).join('')}</tbody></table></div></td></tr>`:'';return `<tr id="prow-${page.id}" class="page-tr${permOpenPages[page.id]?' expanded':''}" data-kw="${esc(kwStr.toLowerCase())}"><td class="pc-page"><span class="page-fold ${fids.length?'foldable':'leaf'}${fids.length&&ownCnt?' hasown':''}" id="fold-${page.id}" title="${fids.length?'展开/收起字段权限(蓝色圆标=可展开;展开后实心蓝底+整行高亮)':'该页面无字段级权限,不可展开'}" onclick="togglePageFold(event,'${page.id}')">${fids.length?(permOpenPages[page.id]?'▾ 字段':'▸ 字段'):'—'}</span><span class="page-ic">${page.icon}</span>${esc(page.name)}${fids.length?('<span class="fld-sum'+(ownCnt?' hasown':'')+'" title="本页共 '+fids.length+' 个字段级权限项,其中 '+ownCnt+' 项已单独设置">字段 '+fids.length+(ownCnt?' · 改 '+ownCnt:'')+'</span>'):''}<span class="page-act" onclick="event.stopPropagation()"><span class="page-btn" title="该页全部授权" onclick="setPageAll('${curRoleId}','${page.id}',true)">全选</span><span class="page-btn off" title="该页全部取消" onclick="setPageAll('${curRoleId}','${page.id}',false)">清空</span></span></td>${cells}</tr>`+frows;}).join('');
view.innerHTML=`
<div class="topbar perm-topbar"><div><h1>角色权限设置</h1><p>按「导航页面」配置 <b>查看/编辑/删除/导出</b> 功能权限;点页面名左侧「字段」按钮逐字段设可见级别;总览里点单元格直达该角色该页。管理员始终拥有全部权限</p></div><div class="perm-head-right"><div class="perm-viewtabs"><span class="pvt ${permViewMode==='overview'?'active':''}" onclick="setPermView('overview')">📋 总览对比</span><span class="pvt ${permViewMode==='edit'?'active':''}" onclick="setPermView('edit')">✏️ 编辑权限</span></div><div class="mod-tools">${moduleToolsHTML('perms')}</div></div></div>
<div id="permSaveBar" class="perm-savebar" style="display:none"><span>⚠️ 有未保存的权限更改(勾选仅存于本机草稿)</span><button class="btn sm" onclick="savePermDraft()">💾 保存权限</button><button class="btn ghost sm" onclick="discardPermDraft()">↩ 放弃更改</button></div>
<div class="role-tabs">${tabs}</div>
<div id="permDiagBar" class="perm-diag" style="display:none;margin:0 0 14px;padding:10px 14px;border-radius:8px;font-size:13px"></div> <div class="perm-guide"><span class="pg-item pg-a">① 功能权限 —— 能不能用<b>在页面行勾 查看 / 编辑 / 删除 / 导出</b></span><span class="pg-item pg-b">② 字段可见范围 —— 看得到多少<b>点页面名左侧「字段」按钮展开,逐字段设 完整 / 脱敏 / 隐藏</b></span></div>
<div class="card table-card perm-split-card">
<div class="perm-schbar" style="display:flex;align-items:center;gap:10px;flex-wrap:wrap;padding:0 2px 8px"><input id="permSearchKw" placeholder="🔍 搜页面 / 权限 / 字段名…" value="${esc(permSearchKw)}" oninput="permSearchDo(this.value)" style="flex:1;min-width:200px;max-width:340px;padding:7px 10px;border:1px solid var(--border);border-radius:8px;font-size:13px"><span class="mtxt">当前编辑:<b>${esc(roleNameOf(curRoleId))}</b>${isOp?'(系统默认角色,可改权限但不可删除)':''}</span></div>
<div class="perm-tblwrap"><table class="page-matrix-tbl"><thead><tr><th style="text-align:left">${isDealer?'经销商端页面':'导航页面'}<span class="th-sub">功能权限 · 能不能用</span></th>${opHead}</tr></thead><tbody>${pageSec}</tbody></table>
<div class="mtxt" style="margin-top:14px">上方一行 = <b>功能权限</b>(能不能用这个功能,服务端强制校验);展开后的字段行 = <b>字段可见范围</b>(只看得到多少)。字段行标记<b>跟随</b>表示未单独设置、随上方对应权限走;标<b>已改</b>的是单独设过的,点 ↺ 可恢复跟随;<b>置灰</b>说明上方那项功能权限没开,此刻设了也不生效。勾选后点右上角「💾 保存权限」一次性生效。</div></div>
</div>`;pdSyncBar();permPosApply();}
function togglePageFold(ev,pid){if(ev)ev.stopPropagation();const tr=document.getElementById('prow-'+pid);if(!tr)return;const f=tr.parentElement.querySelectorAll('tr.fld-tr[data-pg="'+pid+'"]');if(!f.length)return;const fold=document.getElementById('fold-'+pid);/* ★ 第 132 轮:搜索态下点折叠按钮 = 先清空搜索再展开(否则会打开一个「全部行都被过滤隐藏」的空框)。判据取「清空前用户看到的状态」,否则清空搜索后 permOpenPages 记忆会把展开态抢回去(点了却没展开)。 */const _wasShown=f[0].style.display!=='none';if(permSearchKw){const _si=document.getElementById('permSearchKw');if(_si)_si.value='';permSearchDo('');}const collapsing=_wasShown;permOpenPages[pid]=!collapsing;f.forEach(x=>{x.style.display=collapsing?'none':'';});if(fold){fold.textContent=collapsing?'▸ 字段':'▾ 字段';fold.classList.toggle('open',!collapsing);}tr.classList.toggle('expanded',!collapsing);}
/* ★ 2026-09-22 第 132 轮 A 方案(界面三态统一):把「字段区该展开还是收起 / 按钮该显示什么 /
行该不该高亮」收成**唯一出口** —— 搜索态 = 该页有字段命中才展开(临时视图,不污染记忆);
非搜索态 = 按 permOpenPages 记忆展开。⇒ 按钮文案 / 字段区显示 / 记忆三者由同一处决定,
不会再出现「按钮说已展开、实际是收起」或「清空搜索后记忆与显示脱钩 ⇒ 之后自己弹开」。
⚠️ 改这里之前先读自检项 perm_fold_state 的判据(含 3 类违规注入哨兵)。 */
function permFoldSync(){
const tbl=document.querySelector('.page-matrix-tbl');if(!tbl)return;
const searching=!!permSearchKw;
tbl.querySelectorAll('tbody tr.page-tr').forEach(function(tr){
const pid=(tr.id||'').replace('prow-','');
const subs=tbl.querySelectorAll('tr.fld-tr[data-pg="'+pid+'"]');
if(!subs.length)return;
let anyHit=0;
if(searching){subs[0].querySelectorAll('tr.fld-frow').forEach(function(r){if(!r.classList.contains('fld-hide'))anyHit=1;});}
const wantOpen=searching?!!anyHit:!!permOpenPages[pid];
subs.forEach(function(x){x.style.display=wantOpen?'':'none';});
const fold=document.getElementById('fold-'+pid);
if(fold){fold.textContent=wantOpen?'▾ 字段':'▸ 字段';if(wantOpen){fold.classList.add('open');}else{fold.classList.remove('open');}}
tr.classList.toggle('expanded',wantOpen);
});
}
function permSearchDo(v){permSearchKw=(v||'').trim().toLowerCase();const tbl=document.querySelector('.page-matrix-tbl');if(!tbl)return;
/* 只负责「算过滤」:页面行显隐 + 字段行 fld-hide;展开/按钮/高亮一律交给 permFoldSync(唯一出口)。
清空搜索 = 把过滤清干净(含芯片高亮复位),再由 permFoldSync 按记忆还原 ⇒ 记忆与显示永远一致。 */
tbl.querySelectorAll('tbody tr.page-tr').forEach(function(tr){
const kw=tr.getAttribute('data-kw')||'';
const hit=!permSearchKw||kw.indexOf(permSearchKw)>=0;
tr.style.display=hit?'':'none';
const pid=(tr.id||'').replace('prow-','');
const subs=tbl.querySelectorAll('tr.fld-tr[data-pg="'+pid+'"]');
if(!subs.length)return;
if(permSearchKw){
subs.forEach(function(x){x.querySelectorAll('tr.fld-frow').forEach(function(r){const nm=(r.querySelector('.fld-name')||{}).textContent||'';const ok=nm.toLowerCase().indexOf(permSearchKw)>=0;r.classList.toggle('fld-hide',!ok);});});
}else{
subs.forEach(function(x){x.querySelectorAll('tr.fld-frow').forEach(function(r){r.classList.remove('fld-hide');});const bar=x.querySelector('.fld-chips');if(bar)bar.querySelectorAll('.fld-chip').forEach(function(c){c.classList.toggle('active',c.getAttribute('data-mode')==='all');});const nn=x.querySelector('.fld-none');if(nn)nn.style.display='none';});
}
});
permFoldSync();}
/* ★ 第 71 轮:字段区筛选(全部 / 已单独设置)。纯 DOM 过滤,不改任何权限数据。 */
function fldFilter(pid,mode){
const hosts=document.querySelectorAll('tr.fld-tr[data-pg="'+pid+'"]');
hosts.forEach(function(host){
let shown=0;
host.querySelectorAll('tr.fld-frow').forEach(function(r){
const hid=(mode==='own')&&(r.getAttribute('data-own')!=='1');
r.classList.toggle('fld-hide',hid);
if(!hid)shown++;
});
const bar=host.querySelector('.fld-chips');
if(bar)bar.querySelectorAll('.fld-chip').forEach(function(c){c.classList.toggle('active',c.getAttribute('data-mode')===mode);});
let none=host.querySelector('.fld-none');
if(mode!=='all'&&shown===0){
if(!none){none=document.createElement('div');none.className='fld-none';none.textContent='本页没有已单独设置的字段项:全部字段都跟随上方的功能权限。';const tb=host.querySelector('.fld-tbl');if(tb&&tb.parentNode)tb.parentNode.insertBefore(none,tb.nextSibling);}
none.style.display='';
}else if(none){none.style.display='none';}
});
}
function selectRole(id){curRoleId=id;renderPerms();}
/* ★ 2026-09-20 第 110 轮续(新建角色基线 = 白纸):新角色不再按 OPERATOR_DEFAULT 播种 33 项权限,
改为 62 键逐键显式 false(见 blankRolePerms)—— 与「权限由管理员逐项授予」一致。
⚠️ 不可用空对象 {} 代替:未声明键会被 can() / 总览 pvOf / 编辑 pv 三处按 OPERATOR_DEFAULT 兜底成 true,
等于把 33 项权限换个方式静默开通。要省事请用「复制角色」复制现成角色再改差异项,
或在「+ 新增角色」弹窗里选「权限模板」(第 110 轮续 2 新增,见 roleTemplateList / 自检 D6)。 */
function blankRolePerms(){var o={};Object.keys(OPERATOR_DEFAULT).forEach(function(k){o[k]=false;});return o;}
/* ★ 2026-09-20 第 110 轮(续 2「新建并复制」):新增「权限模板」入口 —— 新建角色时可直接沿用某个现成角色的
全部权限(含字段级),省去逐项勾选。模板列表 roleTemplateList() **排除 admin / dealer**:
admin 为固定全开角色(库中未声明权限,选中只会复制出空壳);dealer 为独立门户权限(结构不同、copyRole 亦拒)。
⚠️ 复制必须深拷贝(JSON.parse(JSON.stringify(...)))—— 直接赋值会让新角色与模板角色共享同一对象,
改其中一个连带改另一个(哨兵 F10 专守此坑)。
空白分支(不选模板)仍是 62 键逐键显式 false 的白纸基线,不得退化为空对象 {}(哨兵 F9 专守)。
守卫 = 服务端自检 #51 perms_ux_draft 的 pf3Judge D6 六条 + 哨兵 F10~F12。 */
function roleTemplateList(){pdEnsure();var out=[{id:'',name:'空白 —— 不勾任何权限(默认)'}];Object.keys(permDraft.perms).forEach(function(id){if(id==='admin'||id==='dealer')return;var v=permDraft.perms[id];if(!v||typeof v!=='object')return;out.push({id:id,name:'复制自:'+roleNameOf(id)});});return out;}
function addRole(){window.__roleNameCtx={tpl:''};openRoleNameModal('',function(nm,tpl){
pdEnsure();const id=uid('r_');
/* ★ 2026-09-20 第 110 轮(续 2):串到「权限模板」——选了现成角色则**深拷贝**其权限(含字段级),未选则白纸基线。
admin / dealer 一律不作为模板(roleTemplateList 已排除,此处再挡一次,防前端被改后绕过)。 */
const _src=(tpl&&tpl!=='admin'&&tpl!=='dealer'&&permDraft.perms[tpl]&&typeof permDraft.perms[tpl]==='object')?tpl:'';
if(_src){permDraft.perms[id]=JSON.parse(JSON.stringify(permDraft.perms[_src]));}else{permDraft.perms[id]=blankRolePerms();}
permDraft.meta[id]={name:nm};permDraft.dirty=true;curRoleId=id;permViewMode='edit';renderPerms();
toast(_src?('已创建角色「'+nm+'」,权限复制自「'+roleNameOf(_src)+'」:请核对后点「💾 保存权限」生效'):('已创建角色「'+nm+'」,默认「无任何权限」(连首页都看不到):请逐项勾选后点「💾 保存权限」生效;要沿用现成角色请在新建时选「权限模板」,或点角色行上的 ⧉'));},'新增角色',true);}
function copyRole(id){if(id==='dealer'){toast('经销商为独立门户角色,无需复制');return;}pdEnsure();const nid=uid('r_');permDraft.perms[nid]=JSON.parse(JSON.stringify(permDraft.perms[id]||((id==='dealer')?DEALER_PORTAL_DEFAULT:blankRolePerms())));const baseName=roleNameOf(id)+'-副本';permDraft.meta[nid]={name:baseName};permDraft.dirty=true;curRoleId=nid;permViewMode='edit';window.__roleNameCtx={editId:nid};openRoleNameModal(baseName,function(nm){permDraft.meta[nid]={name:nm};renderPerms();toast('已复制角色,调整后点「💾 保存权限」生效');},'复制角色 · 修改名称');}
/* ★ 2026-09-18 第 72 轮(D1):原实现「先把草稿写进 DB,再 save()」有致命缺陷 ——
① 保存**成功之前** DB.rolePerms/roleMeta 已被草稿污染,失败无回滚;
② discardPermDraft() 只清 permDraft、不还原 DB,而它调用的 renderPerms() 会经 pdEnsure() 用**被污染的 DB** 重建草稿;
⇒「保存失败 → 点放弃更改」是**假放弃**:改动仍留在内存,之后任意一次无关保存把它静默落库。
第 72 轮先改为:只用临时提交体 save(Object.assign({},DB,{rolePerms,roleMeta})),**不就地改 DB**;
★ 第 110 轮再改为「最小包提交体」(实现见下方函数内注释):只提交 rev/rolePerms/roleMeta 三键(12.6KB),
且**自带写回**(成功且未被服务端回退时写回权限对象 + 刷 __dbSnap 并发基线),不再依赖 save() 链尾回读。
形态守卫 = 服务端自检 #51 perms_ux_draft 的 pf3Judge(第 2~11 条 + D4 六条 + D6 六条;哨兵 16 例;项数仍 198)。
(以下两条为**第 72 轮**的机制;第 110 轮起不再走 save() 链尾回读,改为保存成功后**自带写回**)
成功时 save() 内部链尾 loadDB() 会把服务端真值回读进 DB ⇒ 内存永不出现脏权限状态;
失败时 DB 保持干净、permDraft 原样保留(可重试),放弃即真放弃。
另:被服务端回退(无 manage_roles)时不再谎报成功,改为醒目提示 + 重渲染为服务端现值。 */
function savePermDraft(){if(!permDraft)return;const _pscRP=permDraft.perms;const _pscRM=permDraft.meta;window.__permDeniedFlag=0;window.__permDiag={status:'pending',text:'正在保存权限更改…'};renderPermDiag();
/* ★ 2026-09-20 第 110 轮「保存权限提速」:只提交权限对象(12.6KB),不再整库提交(527KB)。
动因(3001 副本实测):慢在「把 480KB 整库搬两趟」—— 上行 527KB 净 630ms + 保存成功后整库回读 320ms;
而本次真正要改的只有 rolePerms + roleMeta。最小包提交实测:6 张表数量与体积零损、响应 200。
附带收益:不再以整库旧快照覆盖 ⇒ 消除并发编辑时「把他人新增当删除」的风险。
⚠️ 不提交整库 ⇒ 必须自行把已保存的权限对象写回本地 DB 并刷新 __dbSnap 基线,
否则下一次整库保存会用旧 rolePerms 把刚保存的改动覆盖回去;DB.rev 同步为响应值,避免连续保存撞 409。
形态由服务端自检 #51 perms_ux_draft 的 pf3Judge 守:第 2~11 条(三键体 / 禁整库合并 / 自写回+刷基线 /
rev 同步 / 409 分支)+ 服务端「未提交顶层键保留」并集语义 2 条;哨兵 16 例(第 110 轮起三轮扩面,项数仍 198)。 */
var _pscTries=0;
var _pscBody=function(){return {rev:(typeof DB.rev==='number'?DB.rev:0),rolePerms:_pscRP,roleMeta:_pscRM};};
var _pscErr=function(){window.__permDiag={status:'err',text:'保存失败,更改仍保留在草稿中,可重试'};renderPermDiag();};
var _pscOk=function(r){try{
if(r&&typeof r.rev==='number')DB.rev=r.rev;
var _pscDeny=(r&&r.permDenied)?1:0;window.__permDeniedFlag=0;permDraft=null;
if(_pscDeny){window.__permDiag={status:'err',text:'⚠️ 无权限:本次权限变更已被服务端忽略(需「角色与权限配置」权限)'};renderPermDiag();try{renderPerms();}catch(_){}return;}
try{DB.rolePerms=_pscRP;DB.roleMeta=_pscRM;__dbSnap(DB);__saveSnap(DB);}catch(_){}
window.__permDiag={status:'ok',text:'✅ 权限更改已保存到服务器'};renderPermDiag();try{renderNav();}catch(_){}}catch(_){}};
var _pscRun=function(){return apiPost('/api/db',_pscBody()).then(_pscOk).catch(function(e){
if(e&&e.status===409&&_pscTries<2){_pscTries++;window.__permDiag={status:'pending',text:'数据已被他人更新,正在按最新版本重试…'};renderPermDiag();
return apiGet('/api/db').then(function(srv){if(srv&&typeof srv.rev==='number')DB.rev=srv.rev;return apiPost('/api/db',_pscBody()).then(_pscOk);}).catch(function(){_pscErr();});}
_pscErr();});};
_pscRun();}
function discardPermDraft(){if(!permDraft)return;permDraft=null;window.__permDiag={status:'',text:''};renderPermDiag();renderPerms();toast('已放弃未保存的权限更改');}
function gateJump(gid,gnm){if(!gid)return;if(permViewMode!=='edit'){try{gotoPermPage(curRoleId,gid);}catch(_){}return;}const el=document.getElementById('prow-'+gid);if(!el){try{gotoPermPage(curRoleId,gid);}catch(_){}return;}const fr=el.nextElementSibling;if(fr&&fr.className&&String(fr.className).indexOf('fld-tr')>=0&&fr.style.display==='none'){try{togglePageFold(null,gid);}catch(_){}}try{el.scrollIntoView({behavior:'smooth',block:'center'});}catch(_){}el.style.background='#fef9c3';setTimeout(function(){el.style.background='';},1500);if(gnm)toast('已跳到「'+gnm+'」页:把该页的「编辑」功能权限打开,本项立即生效');}
function gotoPermPage(rid,pid){permViewMode='edit';curRoleId=rid;__permPosSkip=true;permOpenPages[pid]=1;renderPerms();setTimeout(()=>{const el=document.getElementById('prow-'+pid);if(el){try{el.scrollIntoView({behavior:'smooth',block:'center'});}catch(_){}el.style.background='#fef9c3';setTimeout(()=>{el.style.background='';},1500);}},80);}
function renameRole(id){if(id==='operator'){toast('操作员为系统角色,不可改名');return;}if(permDraft&&permDraft.dirty){toast('有未保存的权限更改,请先「💾 保存权限」或「↩ 放弃更改」');return;}window.__roleNameCtx={editId:id};openRoleNameModal(roleNameOf(id),function(nm){pdEnsure();permDraft.meta[id]={name:nm};permDraft.dirty=true;renderPerms();toast('已重命名(点「💾 保存权限」生效)');},'重命名角色');}
function delRole(id){if(!can('del_roles')){toast('无权限:删除角色需「删除角色」权限');return;}if(id==='operator'){toast('操作员为系统角色,不可删除');return;}if(permDraft&&permDraft.dirty){toast('有未保存的权限更改,请先「💾 保存权限」或「↩ 放弃更改」');return;}
if(!confirm('确认删除该角色?使用该角色的用户将自动移除该角色。'))return;DB.users.forEach(u=>{if(u&&Array.isArray(u.roles)){const i=u.roles.indexOf(id);if(i>=0)u.roles.splice(i,1);}else if(u&&u.role===id){u.role='';}});delete DB.rolePerms[id];if(DB.roleMeta)delete DB.roleMeta[id];if(curRoleId===id)curRoleId='operator';save().then(function(ok){if(ok)toast('已删除角色');});renderPerms();}
function setPerm(role,key,val){pdRp(role)[key]=val;pdMark();}
function setPermGroup(role,gid,val){const rp=pdRp(role);PERMS.filter(p=>p.group===gid).forEach(p=>{rp[p.key]=val;});pdMark();renderPerms();toast(val?'已全选该组权限(草稿)':'已清空该组权限(草稿)');}
function openRoleNameModal(defVal,onOk,title,withTpl){window.__roleNameOk=onOk;
/* ★ 2026-09-20 第 110 轮(续 2):第 4 参 withTpl=true 时渲染「权限模板」下拉(仅「+ 新增角色」传)。
copyRole 不传该参 ⇒ 弹窗形态与旧版完全一致,零影响。 */
var _tplBlock='';
if(withTpl){var _opts=roleTemplateList().map(function(t){return '<option value="'+esc(t.id)+'">'+esc(t.name)+'</option>';}).join('');
_tplBlock='<div class="field"><label>权限模板</label><select id="rolename_tpl">'+_opts+'</select><div class="mtxt" style="margin-top:6px;line-height:1.6">默认「空白」= 新角色不含任何权限(连首页都看不到),建好后逐项勾选;也可选一个现成角色,直接沿用它全部权限(含字段级),建好后仍可逐项调整。</div></div>';}
openModal(`<div class="modal-head"><h3>${esc(title||'角色名称')}</h3><span class="x" onclick="closeModal()">×</span></div><div class="modal-body"><div class="field"><label>角色名称</label><input id="rolename_inp" value="${esc(defVal||'')}" placeholder="如:区域经理、财务专员"></div>`+_tplBlock+`</div><div class="modal-foot"><button class="btn ghost" onclick="closeModal()">取消</button><button class="btn" onclick="confirmRoleName()">确定</button></div>`);setTimeout(()=>{const i=document.getElementById('rolename_inp');if(i){i.focus();if(i.select)i.select();}const sl=document.getElementById('rolename_tpl');if(sl)sl.value='';},80);}
function confirmRoleName(){const i=document.getElementById('rolename_inp');const nm=((i&&i.value)||'').trim();if(!nm){toast('角色名不能为空');return;}pdEnsure();const ctx=window.__roleNameCtx||{};const dup=Object.keys(permDraft.meta).some(k=>k!==(ctx.editId||'')&&permDraft.meta[k]&&permDraft.meta[k].name===nm);if(dup){toast('已存在同名角色');return;}const _tplEl=document.getElementById('rolename_tpl');const _tpl=(_tplEl&&_tplEl.value)?_tplEl.value:'';
window.__roleNameCtx=window.__roleNameCtx||{};window.__roleNameCtx.tpl=_tpl;
closeModal();const cb=window.__roleNameOk;window.__roleNameOk=null;if(cb)cb(nm,_tpl);}
window.addEventListener('beforeunload',function(e){if(permDraft&&permDraft.dirty){e.preventDefault();e.returnValue='';}});
function renderPermsOverview(){pdEnsure();const rids=Object.keys(permDraft.perms).filter(r=>r!=='dealer');const roles=[['admin','管理员']].concat(rids.map(r=>[r,roleNameOf(r)]));const defOf=r=>(r==='dealer')?DEALER_PORTAL_DEFAULT:OPERATOR_DEFAULT;const pvOf=(rid,k)=>{if(rid==='admin')return true;const rp=permDraft.perms[rid]||{};return rp[k]!==undefined?!!rp[k]:!!defOf(rid)[k];};const head=roles.map(([rid,rn])=>`<th class="ov-role">${esc(rn)}</th>`).join('');const rows=PAGE_PERMS.map(page=>{const cells=roles.map(([rid])=>{const marks=[['view','看'],['edit','编'],['delete','删'],['export','出']].map(([op,ch])=>{const ks=page[op]||[];if(!ks.length)return '<span class="ov-m na" title="'+opLabel(op)+':无此操作">·</span>';const on=ks.every(k=>pvOf(rid,k));return `<span class="ov-m ${on?'on':'off'}" title="${esc(page.name)} · ${opLabel(op)}:${on?'已授权':'未授权'}">${ch}</span>`;}).join('');const clickable=rid!=='admin';return `<td class="${clickable?'ov-cell':''}" ${clickable?`title="点击编辑 ${esc(roleNameOf(rid))} · ${esc(page.name)}" onclick="gotoPermPage('${rid}','${page.id}')"`:''}>${marks}</td>`;}).join('');return `<tr><td class="ov-page">${page.icon} ${esc(page.name)}</td>${cells}</tr>`;}).join('');view.innerHTML=`
<div class="topbar perm-topbar"><div><h1>角色权限设置</h1><p>按「导航页面」配置 <b>查看/编辑/删除/导出</b> 功能权限;点页面名左侧「字段」按钮逐字段设可见级别;总览里点单元格直达该角色该页。管理员始终拥有全部权限</p></div><div class="perm-head-right"><div class="perm-viewtabs"><span class="pvt ${permViewMode==='overview'?'active':''}" onclick="setPermView('overview')">📋 总览对比</span><span class="pvt ${permViewMode==='edit'?'active':''}" onclick="setPermView('edit')">✏️ 编辑权限</span></div><div class="mod-tools">${moduleToolsHTML('perms')}</div></div></div>
<div id="permSaveBar" class="perm-savebar" style="display:none"><span>⚠️ 有未保存的权限更改(勾选仅存于本机草稿)</span><button class="btn sm" onclick="savePermDraft()">💾 保存权限</button><button class="btn ghost sm" onclick="discardPermDraft()">↩ 放弃更改</button></div>
<div class="card table-card">
<div class="perm-head">看 / 编 / 删 / 出 = 查看 / 编辑 / 删除 / 导出;<span class="ov-m on">看</span> 绿色为已授权,<span class="ov-m off">看</span> 灰色为未授权。<b>点击任意角色列的单元格</b>,直接跳到该角色该页面的编辑位置。<br>本表只呈现「功能权限(能不能用)」;每页还有一层「字段可见范围(看得到多少)」,需切到「编辑权限」视图点页面名左侧的「字段」按钮展开。</div>
<div class="mtxt" style="padding:0 2px 10px">📱「经销商」角色为独立门户权限(经销商端页面单独配置),不在本矩阵中——请切到「编辑权限」视图选择「经销商」进行配置。</div>
<div style="overflow:visible"><table class="ov-tbl"><thead><tr><th style="text-align:left;min-width:140px">导航页面</th>${head}</tr></thead><tbody>${rows}</tbody></table></div>
</div>`;pdSyncBar();permPosApply();}
function injectRoleStyles(){if(document.getElementById('rolePermsStyle'))return;const s=document.createElement('style');s.id='rolePermsStyle';s.textContent=`
.lv-sel{padding:2px 4px;font-size:12px;border:1px solid var(--border);border-radius:6px;background:#fff;color:#334155}
.th-sub{display:block;font-weight:400;font-size:11.5px;color:#94a3b8;margin-top:3px}
.fld-box{background:#f8fafc;border-top:1px solid #e2e8f0;border-bottom:1px solid #e2e8f0;padding:12px 0 10px;box-shadow:inset 3px 0 0 #c7d2fe}
.fld-title{display:flex;align-items:center;gap:8px;flex-wrap:wrap;font-size:13px;font-weight:600;color:#334155;padding:0 13px 10px}
.fld-title-sub{font-weight:400;font-size:12px;color:#94a3b8}
.fld-tbl{width:100%;border-collapse:collapse;table-layout:fixed}
.fld-tbl thead th{padding:7px 13px;font-size:11.5px;font-weight:600;color:#64748b;letter-spacing:.3px;border-bottom:1px solid #e2e8f0;text-align:center;background:#f1f5f9}
.fld-tbl thead th:first-child{text-align:left}
.fld-tbl tbody td{padding:6px 13px;border-top:1px solid #eef2f7;font-size:12.5px;text-align:center;color:#334155}
.fld-tbl tbody td.fld-name{text-align:left;color:#475569;font-weight:500}
.fld-tbl thead th:first-child{width:26%}
.fld-tbl thead th:nth-child(n+2){width:18.5%}
.page-matrix-tbl{table-layout:fixed;width:100%;min-width:760px}
.page-matrix-tbl thead th:first-child{width:32%}
.page-matrix-tbl thead th:nth-child(n+2){width:17%;text-align:center}
.page-matrix-tbl tbody td:nth-child(n+2){text-align:center}
.page-matrix-tbl tr.fld-tr>td{padding:0}
/* ★ 第 169 轮:权限页搜索栏常驻 —— 卡片上下分区(搜索栏不随表格滚动消失) */
.card.table-card.perm-split-card{display:flex;flex-direction:column;overflow:hidden}
.card.table-card.perm-split-card .perm-schbar{flex:0 0 auto}
.card.table-card.perm-split-card .perm-tblwrap{flex:1 1 auto;min-height:0;overflow:auto}
.fld-cell{white-space:nowrap;vertical-align:middle}
.fld-off{background:#f1f5f9;color:#94a3b8}
.fld-off input{opacity:.45}
.fld-tag{display:inline-block;margin-left:6px;padding:1px 7px;border-radius:999px;font-size:11px;line-height:16px;vertical-align:middle;font-weight:500;white-space:nowrap}
.fld-tag.inh{background:#f8fafc;color:#94a3b8;border:1px dashed #dbe2ea}
.fld-tag.own{background:#eef2ff;color:#4338ca;border:1px solid #c7d2fe}
.fld-tag.own.deny{background:#fef2f2;color:#b91c1c;border:1px solid #fecaca}
.fld-jump{cursor:pointer;text-decoration:underline dotted}
.fld-tag.warn{background:#fffbeb;color:#b45309;border:1px solid #fde68a}
.fld-undo{cursor:pointer;margin-left:4px;font-size:12px;color:#cbd5e1;transition:.15s}
.fld-undo:hover{color:var(--primary)}
input.fld-cb.inh{opacity:.6}
input.fld-cb.own{outline:1px solid #6366f1;outline-offset:1px}
.pc-part{display:inline-block;margin-left:4px;font-size:10.5px;color:#b45309;background:#fef3c7;border-radius:4px;padding:0 4px;vertical-align:middle}
.pc-lbl{display:inline-block;margin-left:6px;font-size:11px;color:#475569;line-height:1.35;vertical-align:middle;max-width:172px}
/* ★ 第 71 轮(2026-09-18 用户定案「不清冗余、优化 UI」):字段级权限区的视觉分层与筛选 */
.page-fold.foldable.hasown{color:var(--primary);background:#eef2ff;border-color:#c7d2fe}
.page-fold.foldable.hasown:hover{background:#e0e7ff;border-color:#a5b4fc}
.fld-sum{display:inline-block;margin-left:8px;padding:1px 7px;border-radius:999px;font-size:11px;line-height:16px;font-weight:500;background:#f1f5f9;color:#94a3b8;border:1px solid #e2e8f0;vertical-align:middle}
.fld-sum.hasown{background:#eef2ff;color:#4338ca;border-color:#c7d2fe;font-weight:600}
tr.page-tr.expanded .fld-sum{display:none}
.fld-chips{display:inline-flex;gap:6px;margin-left:auto}
.fld-chip{cursor:pointer;padding:2px 9px;border-radius:999px;font-size:11.5px;font-weight:500;color:#64748b;background:#fff;border:1px solid #e2e8f0;transition:.15s;user-select:none;white-space:nowrap}
.fld-chip:hover{border-color:#c7d2fe;color:var(--primary)}
.fld-chip.active{background:var(--primary);border-color:var(--primary);color:#fff;font-weight:600}
.fld-chip b{margin-left:2px}
@media(hover:hover){.fld-tbl tbody tr:hover td{background:var(--row-hover)}.fld-tbl tbody tr:hover td:first-child{background:linear-gradient(90deg,var(--primary) 0 3px,transparent 3px)}}
.fld-tbl tbody tr.fld-hide{display:none}
.fld-none{padding:14px 13px;font-size:12.5px;color:#94a3b8;background:#fff;border-top:1px solid #eef2f7}
.role-tabs{display:flex;flex-wrap:wrap;gap:6px;margin:0 0 10px}
.role-tab{display:inline-flex;align-items:center;gap:6px;padding:5px 11px;border:1px solid var(--border);border-radius:999px;background:#fff;color:#475569;cursor:pointer;font-size:13px;transition:.15s}
.role-tab:hover{border-color:var(--ring);color:var(--primary)}
.role-tab.active{background:var(--primary);border-color:var(--primary);color:#fff;font-weight:600}
.role-tab.add{color:var(--primary);border-style:dashed}
.role-act{display:inline-flex;gap:2px;margin-left:2px}
.role-ic{cursor:pointer;opacity:.55;font-size:12px;padding:0 2px}
.role-ic:hover{opacity:1}
.role-ic.del:hover{color:var(--danger)}
.perm-head{font-size:14px;color:#334155;padding:4px 2px 14px}
.perm-group{margin-bottom:16px;border:1px solid var(--border);border-radius:12px;overflow:hidden}
.perm-group-h{display:flex;align-items:center;gap:8px;padding:10px 14px;background:#f8fafc;font-weight:600;font-size:13.5px;color:#334155}
.perm-group-h .dot{width:8px;height:8px;border-radius:50%}
.perm-grp-act{margin-left:auto;display:inline-flex;gap:6px}
.perm-grp-ic{cursor:pointer;font-size:12px;font-weight:600;color:var(--primary);background:#fff;border:1px solid var(--border);border-radius:6px;padding:2px 8px;transition:.15s}
.perm-grp-ic:hover{border-color:var(--ring);background:#eef2ff}
.perm-grp-ic.off{color:#64748b}
.perm-grp-ic.off:hover{background:#f1f5f9}
.perm-tbl{width:100%;border-collapse:separate;border-spacing:0}
.perm-tbl td{padding:10px 14px;border-top:1px solid var(--border);font-size:13.5px}
.perm-tbl tr:first-child td{border-top:none}
.perm-tbl td:first-child{width:auto}
.perm-tbl td:last-child{width:64px;text-align:center}
.perm-diag{display:none;margin:10px 0;padding:8px 12px;border-radius:8px;font-size:13px}
.perm-diag-pending{background:#fef3c7;color:#92400e;border:1px solid #fbbf24;font-weight:600}
.perm-diag-ok{background:#dcfce7;color:#166534;border:1px solid #86efac;font-weight:600}
.perm-diag-err{background:#fee2e2;color:#b91c1c;border:1px solid #fca5a5;font-weight:600}
/* ★ 第 170 轮:权限页顶部占高压缩(表格起点 331px → 约 256px,桌面 1440×900 实测) */
.topbar.perm-topbar{margin-bottom:14px;align-items:flex-start}
.topbar.perm-topbar p{margin-top:4px;font-size:12.5px;line-height:1.4}
.perm-viewtabs{display:inline-flex;gap:0;border:1px solid var(--border);border-radius:9px;overflow:hidden}
.pvt{padding:8px 16px;font-size:13.5px;cursor:pointer;color:#475569;background:#fff;transition:.15s;white-space:nowrap}
.pvt:hover{color:var(--primary);background:#f8fafc}
.pvt.active{background:var(--primary);color:#fff;font-weight:600}
/* ★ 第 543 轮:总览/编辑 切换时「视图切换按钮」不再跳位。
根因:.topbar 为 flex-wrap:wrap + space-between,编辑视图说明文字更长(max-content 更宽)
⇒ 1366 / 1280 等宽度下按钮被挤到第二行且靠左(实测 x 1118→276、y 28→97)。
做法:桌面(≥821px)锁按钮不换行;文字区允许收缩换行(min-width:0)。移动端竖排(≤820px)不受影响。 */
.perm-head-right{display:flex;align-items:center;gap:10px;flex:0 0 auto}
.perm-head-right .mod-tools{margin-left:0}
@media(min-width:821px){
.topbar.perm-topbar{flex-wrap:nowrap}
.topbar.perm-topbar>div:first-child{min-width:0;flex:1 1 auto}
.topbar.perm-topbar .perm-viewtabs{flex:0 0 auto}
} .perm-guide{display:flex;gap:8px;flex-wrap:wrap;margin:0 0 10px} .pg-item{flex:1;min-width:250px;display:flex;flex-direction:column;gap:2px;padding:6px 10px;border-radius:9px;font-size:12px;font-weight:600;line-height:1.4} .pg-item b{font-weight:400;font-size:12px;opacity:.82} @media(min-width:1100px){.perm-guide{flex-wrap:nowrap} .pg-item{flex:1 1 0;min-width:0;flex-direction:row;align-items:center;gap:6px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} .pg-item b{display:inline;opacity:.85}} .pg-a{background:#eff6ff;border:1px solid #bfdbfe;color:#1d4ed8} .pg-b{background:#eef2ff;border:1px solid #c7d2fe;color:#4338ca}
.perm-savebar{display:none;align-items:center;justify-content:flex-end;gap:10px;margin:0 0 14px;padding:10px 14px;border-radius:9px;background:#fffbeb;border:1px solid #fbbf24;color:#92400e;font-size:13.5px;font-weight:600;position:sticky;top:0;z-index:20}
.page-fold{display:inline-flex;align-items:center;justify-content:center;gap:2px;min-width:20px;height:20px;padding:0 7px;border-radius:6px;font-size:11px;font-weight:600;user-select:none;transition:all .15s;white-space:nowrap}
.page-fold.foldable{cursor:pointer;color:#94a3b8;background:#f8fafc;border:1px solid #e2e8f0;font-weight:700}
.page-fold.foldable:hover{color:var(--primary);background:#eff6ff;border-color:#bfdbfe;transform:scale(1.1)}
.page-fold.foldable.open{color:#fff;background:var(--primary);border-color:var(--primary)}
.page-fold.foldable.open:hover{filter:brightness(1.08)}
.page-fold.leaf{color:#cbd5e1;cursor:default;border:1px dashed #e2e8f0;font-size:11px;padding:0 6px}
.page-fold.leaf:hover{color:#cbd5e1;transform:none}
tr.page-tr.expanded>td{background:#f0f7ff}
tr.page-tr.expanded>td.pc-page{box-shadow:inset 3px 0 0 var(--primary)}
tr.fld-tr>td{background:#fff}
.ov-tbl{width:100%;border-collapse:separate;border-spacing:0;font-size:13px}
.ov-tbl th{padding:9px 8px;border-bottom:2px solid var(--border);font-size:12.5px;color:#475569;white-space:nowrap}
.ov-tbl td{padding:8px;border-bottom:1px solid var(--line);text-align:center;white-space:nowrap}
@media(hover:hover){.ov-tbl tr:hover td{background:var(--row-hover)}.ov-tbl tr:hover td:first-child{background:linear-gradient(90deg,var(--primary) 0 3px,transparent 3px)}}
.ov-page{text-align:left!important;color:#334155;font-weight:500}
.ov-role{min-width:88px}
.ov-cell{cursor:pointer;transition:.12s}
.ov-cell:hover{background:#eef2ff!important}
.ov-m{display:inline-block;width:20px;text-align:center;border-radius:4px;font-size:12px;padding:2px 0;margin:0 1px}
.ov-m.on{background:#dcfce7;color:#166534;font-weight:700}
.ov-m.off{background:#f1f5f9;color:#cbd5e1}
.ov-m.na{color:#e2e8f0;background:transparent}
`;document.head.appendChild(s);}
const REMINDER_EVENTS={payment:{name:'财务收款·登记交款',icon:'💰',desc:'有人登记交款时提醒'},contract_signed:{name:'客户已签合同',icon:'📝',desc:'客户合同状态变为已签时提醒'},dealer_contract:{name:'经销商签约',icon:'🏷️',desc:'经销商签订合同/保证金时提醒'},customer_new:{name:'新增客户报备',icon:'➕',desc:'新增转介绍客户时提醒'},refund:{name:'客户退费',icon:'↩️',desc:'客户办理退费时提醒'},dealer_refund:{name:'经销商已退费',icon:'💸',desc:'经销商状态变为已退费时提醒'},dealer_exhausted:{name:'经销商已消耗终止合作',icon:'⏹',desc:'经销商状态变为已消耗终止合作时提醒'},dealer_terminated:{name:'经销商已终止合作',icon:'🚫',desc:'经销商状态变为已终止合作时提醒'},};
function nowStamp(){const d=new Date();const p=n=>String(n).padStart(2,'0');return d.getFullYear()+'-'+p(d.getMonth()+1)+'-'+p(d.getDate())+' '+p(d.getHours())+':'+p(d.getMinutes());}
function triggerReminder(event,detail,link,refId){if(!Array.isArray(DB.reminderSettings))DB.reminderSettings=[];if(!Array.isArray(DB.reminders))DB.reminders=[];const settings=DB.reminderSettings.filter(s=>s&&s.enabled&&s.event===event);if(!settings.length)return;const ev=REMINDER_EVENTS[event]||{name:event,icon:'🔔'};settings.forEach(s=>{DB.reminders.push({id:uid('rm_'),event,title:ev.name,detail:detail||'',targetRole:s.targetRole||'operator',createdAt:nowStamp(),done:false,doneBy:'',doneAt:'',link:link||'',refId:refId||''});});}
function todoCount(){if(!Array.isArray(DB.reminders)||!currentUser)return 0;const roles=(currentUser.roles&¤tUser.roles.length)?currentUser.roles:(currentUser.role?[currentUser.role]:[]);if(roles.indexOf('admin')>=0)return DB.reminders.filter(r=>!r.done).length;return DB.reminders.filter(r=>!r.done&&roles.indexOf(r.targetRole)>=0).length;}
function roleLabel(rid){if(rid==='admin')return'管理员';const m=DB.roleMeta&&DB.roleMeta[rid];return(m&&m.name)?m.name:rid;}
function myTodos(){if(!Array.isArray(DB.reminders)||!currentUser)return[];const roles=(currentUser.roles&¤tUser.roles.length)?currentUser.roles:(currentUser.role?[currentUser.role]:[]);let list=DB.reminders;if(roles.indexOf('admin')<0)list=list.filter(r=>roles.indexOf(r.targetRole)>=0);return list.slice().sort((a,b)=>{if(a.done!==b.done)return a.done?1:-1;return String(b.createdAt||'').localeCompare(String(a.createdAt||''));});}
function renderTodos(){const list=myTodos();const pending=list.filter(r=>!r.done).length;const done=list.filter(r=>r.done).length;const rows=list.length?list.map(r=>{const ev=REMINDER_EVENTS[r.event]||{icon:'🔔',name:r.event};return `<tr class="${r.done?'todo-done':''}"><td style="font-size:18px">${ev.icon}</td><td><b>${ev.name}</b><div class="mtxt">${esc(r.detail||'')}</div></td><td>${r.done?'<span class="st st-signed_full">已处理</span>':'<span class="st st-following">待处理</span>'}</td><td class="mtxt">${roleLabel(r.targetRole)}</td><td class="mtxt">${r.createdAt||''}</td><td>${r.done?`<span class="mtxt">${esc(r.doneBy||'')} ${esc(r.doneAt||'')}</span>`:`<button class="btn sm" onclick="markTodoDone('${r.id}')">标记完成</button>`}</td></tr>`;}).join(''):'<tr><td colspan="6" class="empty">暂无待办提醒</td></tr>';view.innerHTML=`<div class="topbar"><div><h1>📋 待办事项</h1><p>由「消息提醒」自动生成:财务收款、签合同等事件推送给对应角色</p></div><div>${can('manage_reminders')?'<button class="btn ghost sm" onclick="curView=\'reminders\';render()">🔔 消息提醒设置</button>':''}</div></div><div class="grid kpis" style="grid-template-columns:repeat(3,1fr)">${kpiCard('⏳','待处理',pending,'尚未标记完成','#d97706')}${kpiCard('✅','已处理',done,'已标记完成','#16a34a')}${kpiCard('📋','总计',list.length,'全部提醒','#6366f1')}</div><div class="card table-card"><table id="todoTable"><thead><tr><th></th><th>事项</th><th>状态</th><th>目标角色</th><th>时间</th><th>操作</th></tr></thead><tbody>${rows}</tbody></table></div>`;}
function rmdRoleHasTodos(rid){if(rid==='admin')return true;const rp=DB.rolePerms&&DB.rolePerms[rid];if(!rp)return false;let v=rp.view_todos;if(v===undefined)v=(rid==='dealer'?false:(OPERATOR_DEFAULT.view_todos!==undefined?OPERATOR_DEFAULT.view_todos:false));return !!v;}function renderReminderSettings(){if(!can('manage_reminders')){view.innerHTML='<div class="card"><p class="empty">无权限:仅拥有「设置消息提醒」权限的账号可访问。</p></div>';return;}if(!Array.isArray(DB.reminderSettings))DB.reminderSettings=[];const roles=[['admin','管理员'],['operator','操作员']].concat(Object.keys(DB.rolePerms||{}).filter(r=>r!=='operator'&&r!=='admin').map(r=>[r,roleLabel(r)]));const head=roles.map(([rid,rname])=>{const okTodos=rmdRoleHasTodos(rid);return `<th style="text-align:center;white-space:nowrap${okTodos?'':';opacity:.55'}" ${okTodos?'':'title="该角色未开启《查看待办事项》权限,勾选后也看不到提醒"'}${okTodos?'':' data-locked-col="1"'}>${rname}${okTodos?'':' ⚠'}</th>`;}).join('');const rows=Object.entries(REMINDER_EVENTS).map(([k,ev])=>{const cells=roles.map(([rid])=>{const okTodos=rmdRoleHasTodos(rid);const s=DB.reminderSettings.find(x=>x.event===k&&x.targetRole===rid);const on=!!(s&&s.enabled);if(!okTodos)return `<td style="text-align:center" title="该角色未开启《查看待办事项》权限,无法接收提醒"><input type="checkbox" disabled data-locked="1" ${on?'checked':''} style="cursor:not-allowed;opacity:.35"></td>`;return `<td style="text-align:center"><input type="checkbox" data-ev="${k}" data-role="${rid}" ${on?'checked':''} onchange="toggleReminderSetting('${k}','${rid}',this.checked)"></td>`;}).join('');return `<tr><td>${ev.icon} <b>${ev.name}</b><div class="mtxt">${ev.desc}</div></td>${cells}</tr>`;}).join('');view.innerHTML=`<div class="topbar"><div><h1>🔔 消息提醒设置</h1><p>勾选「事件 × 角色」后,该事件发生时自动生成待办提醒给对应角色</p></div><div><button class="btn ghost sm" onclick="curView='todos';render()">📋 查看待办</button></div></div><div class="card table-card"><table id="reminderTbl"><thead><tr><th style="min-width:220px">事件类型</th>${head}</tr></thead><tbody>${rows}</tbody></table></div><div class="hint" style="margin-top:12px">提示:财务收款、客户签合同、经销商签约、新增客户、客户退费等操作触发后,对应角色的「待办事项」会实时更新。<br>⚠ 标有 ⚠ 的角色未开启「查看待办事项」权限,勾选后也看不到提醒(已置灰)。需先到「角色权限 → 系统 → 查看待办事项」为其开启。</div>`;}
function toggleReminderSetting(event,role,val){if(!can('manage_reminders')){toast('无权限');return;}if(!rmdRoleHasTodos(role)){toast('该角色未开启《查看待办事项》权限,无法接收提醒');if(curView==='reminders')renderReminderSettings();return;}if(!Array.isArray(DB.reminderSettings))DB.reminderSettings=[];let s=DB.reminderSettings.find(x=>x.event===event&&x.targetRole===role);const _isNew=!s;if(!s){s={id:uid('rs_'),event,targetRole:role,enabled:true};DB.reminderSettings.push(s);}s.enabled=val;const _sid=s.id;save().then(ok=>{if(ok===false){const _t=DB.reminderSettings.find(x=>x.id===_sid);if(_t){if(_isNew)DB.reminderSettings=DB.reminderSettings.filter(x=>x.id!==_sid);else _t.enabled=!val;}if(curView==='reminders')renderReminderSettings();toast('勾选未保存成功,已还原(原因见上方提示)');return;}if(curView==='reminders')renderReminderSettings();toast(val?'已开启提醒':'已关闭提醒');});}
function markTodoDone(id){const r=DB.reminders.find(x=>x.id===id);if(!r)return;r.done=true;r.doneBy=(currentUser&¤tUser.name)||'';r.doneAt=nowStamp();save().then(function(ok){if(ok)toast('已标记完成');});renderTodos();}
function delTodo(id){const r=DB.reminders.find(x=>x.id===id);if(!r)return;if(!can('manage_reminders')){toast('无权限删除');return;}DB.reminders=DB.reminders.filter(x=>x.id!==id);save().then(function(ok){if(ok)toast('已删除');});renderTodos();}
const AUDIT_TYPES={login_ok:{label:'登录成功',cls:'st-signed_full'},login_fail:{label:'登录失败',cls:'st-refunded'},logout:{label:'退出登录',cls:'st-following'},db_save:{label:'保存数据',cls:'st-experienced'},db_owner_freeze:{label:'客户变更归属结转',cls:'st-experienced'},db_owner_hist:{label:'客户归属历史记录',cls:'st-experienced'},db_closed_at:{label:'客户终止日期记录',cls:'st-experienced'},db_toggle_contractSigned:{label:'切换合同签署状态',cls:'st-experienced'},db_toggle_pushedDelivery:{label:'切换交付推送标志',cls:'st-experienced'},db_toggle_contractDate:{label:'补录合同签署日期',cls:'st-experienced'},db_toggle_noSign:{label:'切换订单免签设置',cls:'st-experienced'},db_autopush:{label:'自动置推送标志',cls:'st-experienced'},db_autounpush:{label:'自动撤回推送标志',cls:'st-signed_installment'},db_deny_delete:{label:'删除被拦截',cls:'st-refunded'},db_quota_bounds_deny:{label:'名额越界被拦截',cls:'st-refunded'},db_quota_paid_deny:{label:'名额交款未到账被拦截',cls:'st-refunded'},db_refund_flag_deny:{label:'退费标记被单独改写拦截',cls:'st-refunded'},db_quota_tier_deny:{label:'体验课档位未确认被拦截',cls:'st-refunded'},db_quota_mix_deny:{label:'体验课混单被拦截',cls:'st-refunded'},db_push_deny:{label:'推送被拦截',cls:'st-refunded'},db_payment_owner_stamp:{label:'交款归属自动补登',cls:'st-experienced'},db_payment_paid_lock:{label:'已发放分润交款锁定',cls:'st-refunded'},db_geo_scrub_restore:{label:'坐标脱敏回流还原',cls:'st-refunded'},db_payout_diff:{label:'发放记录变更(解锁留痕)',cls:'st-refunded'},db_baobei_sync:{label:'报备系统自动同步',cls:'st-experienced'},db_baobei_assign:{label:'报备归属指派',cls:'st-experienced'},db_field_deny:{label:'字段级权限拦截',cls:'st-refunded'},db_perm_deny:{label:'权限变更被回退',cls:'st-refunded'},tv_rewind_deny:{label:'会话版本回退拦截',cls:'st-refunded'},db_reload_from_disk:{label:'从磁盘重载数据',cls:'st-lost'},db_archive:{label:'数据归档',cls:'st-experienced'},help_submit_stamp:{label:'到校帮扶提交时间落库',cls:'st-experienced'},reset:{label:'重置数据',cls:'st-lost'},selfcheck:{label:'运行系统自检',cls:'st-following'},selfcheck_fix:{label:'自检修复',cls:'st-experienced'},startup_check:{label:'启动自检',cls:'st-following'},auto_check:{label:'自动巡检',cls:'st-following'},board_login_fail:{label:'客户看板登录失败',cls:'st-refunded'},board_note:{label:'看板备注',cls:'st-following'},board_note_edit:{label:'看板备注·修改自己填的那条',cls:'st-following'},board_note_del:{label:'看板备注·删除自己填的那条',cls:'st-refunded'},board_pwd_change:{label:'客户看板改密',cls:'st-experienced'},ops_login_fail:{label:'经营看板登录失败',cls:'st-refunded'},ops_pwd_change:{label:'经营看板改密',cls:'st-experienced'},month_login_fail:{label:'月度看板登录失败',cls:'st-refunded'},month_pwd_change:{label:'月度看板改密',cls:'st-experienced'},tunnel_auto:{label:'隧道自动处理',cls:'st-following'},upload_init:{label:'附件上传',cls:'st-following'},wechat_parse:{label:'解析微信聊天记录',cls:'st-following'},wechat_import:{label:'导入微信聊天记录',cls:'st-experienced'},ocr_recognize:{label:'图片识别(多模态)',cls:'st-following'},help_rescan:{label:'AI 重读帮扶图片',cls:'st-following'},help_rescan_ui:{label:'AI 重读帮扶图片(手动触发)',cls:'st-following'},ai_help_scan:{label:'AI 识别填报反馈表',cls:'st-following'},ai_report_plan:{label:'AI 报表·生成口径',cls:'st-following'},ai_report_note:{label:'AI 报表·生成结语',cls:'st-following'},doc_scan:{label:'文档扫描裁剪',cls:'st-following'},customer_import:{label:'批量导入客户',cls:'st-experienced'},recycle_restore:{label:'回收站恢复',cls:'st-experienced'},recycle_purge:{label:'回收站彻底删除',cls:'st-lost'},customer_aliases_update:{label:'更新客户别名',cls:'st-following'},dealer_upgrade_official:{label:'经销商升级为官方',cls:'st-signed_full'},deliv_owner_deny:{label:'交付归属拦截',cls:'st-refunded'},db_delivery_drop:{label:'新增交付被丢弃',cls:'st-refunded'},notify_test:{label:'测试推送',cls:'st-following'},notify_send:{label:'发送通知',cls:'st-following'},notify_backup_report:{label:'备份报告推送',cls:'st-following'},notify_err_weekly:{label:'异常周报推送',cls:'st-yellow'},daemon_heal:{label:'守护进程自愈',cls:'st-signed_installment'},pwd_upgrade:{label:'口令加密升级',cls:'st-following'},dealer_pwd_change:{label:'修改密码(经销商)',cls:'st-experienced'},user_pwd_change:{label:'修改密码(管理端)',cls:'st-experienced'},pwd_gate_deny:{label:'弱口令未改密拦截',cls:'st-refunded'},export:{label:'导出数据',cls:'st-experienced'},export_deny:{label:'导出被拒',cls:'st-refunded'},log_rotate:{label:'日志轮转',cls:'st-following'},perf_login_ok:{label:'业绩目标看板登录成功',cls:'st-signed_full'},perf_login_fail:{label:'业绩目标看板登录失败',cls:'st-refunded'},perf_link_add:{label:'业绩看板·新增对接关系',cls:'st-experienced'},perf_link_remove:{label:'业绩看板·删除对接关系',cls:'st-refunded'},perf_target:{label:'业绩看板·设置业绩目标',cls:'st-experienced'},perf_note:{label:'业绩看板·备注',cls:'st-following'},perf_grant:{label:'业绩看板·授权查看全部',cls:'st-experienced'},perf_pwd_change:{label:'业绩看板·修改看板密码',cls:'st-following'},perf_hidestaff:{label:'业绩看板·隐藏/恢复员工',cls:'st-experienced'}};
function fmtTime(iso){if(!iso)return'';const d=new Date(iso);if(isNaN(d))return iso;const p=n=>String(n).padStart(2,'0');return d.getFullYear()+'-'+p(d.getMonth()+1)+'-'+p(d.getDate())+' '+p(d.getHours())+':'+p(d.getMinutes())+':'+p(d.getSeconds());}
var scShowAllNormals=false;var _scFixAllBusy=null;
function renderSelfcheckBody(body,d){const checks=d.checks||[];lastSelfcheckChecks=checks;lastSelfcheckCopy={};const allOk=!!d.ok;const isAdmin=!!(currentUser&&(currentUser.roles||[]).indexOf('admin')>=0);const bad=checks.filter(c=>!c.ok);const fixN=bad.filter(c=>c.fixable).length;const okN=checks.length-bad.length;
const fb=document.getElementById('scFixAllBtn');if(fb){if(_scFixAllBusy){fb.style.display='';fb.disabled=true;const _ci=Math.min(_scFixAllBusy.done+1,_scFixAllBusy.list.length);fb.textContent='⏳ 修复中 '+_ci+'/'+_scFixAllBusy.list.length+'…';}else if(isAdmin&&fixN>0){fb.className='btn danger sm';fb.style.display='';fb.disabled=false;fb.textContent='🔧 一键修复('+fixN+' 项)';}else if(isAdmin){fb.className='btn ghost sm';fb.style.display='';fb.disabled=true;fb.textContent='✅ 无需修复';}else{fb.disabled=false;fb.style.display='none';}}
const _row=c=>{const fixBtn=(!c.ok&&c.fixable&&isAdmin)?`<button class="btn sm" style="margin-left:auto" onclick="runSelfcheckFix('${c.key}')">🔧 一键修复</button>`:'';if(c.copyText)lastSelfcheckCopy[c.key]=c.copyText;const cpBtn=c.copyText?`<button class="btn ghost sm" style="margin-left:auto" onclick="copySelfcheckList('${c.key}')">📋 复制名单</button>`:'';return `<div style="display:flex;align-items:flex-start;gap:12px;padding:14px 16px;border-bottom:1px solid var(--line)"><div style="font-size:20px;line-height:1.5">${c.ok?'✅':'❌'}</div><div style="flex:1;min-width:0"><div style="font-weight:600;color:#1e293b">${esc(c.name)}</div><div class="mtxt" style="color:${c.ok?'#16a34a':'#dc2626'}">${esc(c.detail||'')}</div></div>${fixBtn}${cpBtn}</div>`;};
const badRows=bad.map(_row).join('');const okRows=checks.filter(c=>c.ok).map(_row).join('');
const head=allOk?`<div class="pill" style="background:#dcfce7;color:#166534;margin-bottom:12px">✅ 全部正常(共 ${checks.length} 项)</div>`:`<div id="scFixBar" style="display:flex;align-items:center;gap:10px;flex-wrap:wrap;background:#fef2f2;border:1px solid #fecaca;border-radius:10px;padding:10px 14px;margin-bottom:12px"><span style="color:#b91c1c;font-weight:600">❌ 发现 ${bad.length} 项异常${fixN?' · 其中 '+fixN+' 项可一键修复':''}</span><span class="mtxt" style="color:#64748b">已自动推送到微信</span>${(isAdmin&&fixN&&!_scFixAllBusy)?`<button class="btn danger sm" style="margin-left:auto" onclick="runSelfcheckFixAll()">🔧 一键修复(${fixN} 项)</button>`:''}${_scFixAllBusy?`<span style="margin-left:auto;color:#b45309;font-weight:600">⏳ 正在修复 ${Math.min(_scFixAllBusy.done+1,_scFixAllBusy.list.length)}/${_scFixAllBusy.list.length}…</span>`:''}</div>`;
const okBlock=okN>0?(scShowAllNormals?`<div onclick="scToggleNormals()" style="display:flex;align-items:center;gap:10px;padding:10px 16px;background:#f8fafc;cursor:pointer;user-select:none;border-bottom:1px solid var(--line)"><span class="mtxt">▾ 收起正常项(${okN} 项)</span></div>`+okRows:(allOk?`<div onclick="scToggleNormals()" style="display:flex;align-items:center;gap:10px;padding:10px 16px;background:#f8fafc;cursor:pointer;user-select:none"><span class="mtxt">▸ 展开查看全部明细</span></div>`:`<div onclick="scToggleNormals()" style="display:flex;align-items:center;gap:10px;padding:10px 16px;background:#f8fafc;cursor:pointer;user-select:none"><span class="mtxt">✅ 正常项 ${okN} 项 · 已折叠(点击展开查看全部)</span></div>`)):'';
body.innerHTML=head+badRows+okBlock;}function renderSelfcheck(){if(!can('view_selfcheck')){view.innerHTML='<div class="card"><p class="empty">无权限:需要「运行系统自检」权限。</p></div>';return;}view.innerHTML=`<div class="topbar"><div><h1>🩺 系统自检</h1><p>检查后端进程、审计日志、数据库与数据一致性,发现异常可一键修复</p></div><div><button id="scFixAllBtn" class="btn danger sm" style="display:none;margin-right:8px" onclick="runSelfcheckFixAll()">🔧 一键修复</button>${currentUser&&(currentUser.roles||[]).indexOf('admin')>=0?'<button class="btn ghost sm"onclick="openNotifySettings()">🔔 推送设置</button>':''}<button id="scRunBtn" class="btn ghost sm"onclick="runSelfcheck()">🔄 重新自检</button></div></div><div id="scStatus" class="mtxt" style="margin:-4px 2px 10px;color:#64748b"></div><div class="card"><div id="scBody"></div></div>`;const body=document.getElementById('scBody');if(lastSelfcheckChecks.length>0){renderSelfcheckBody(body,{checks:lastSelfcheckChecks,ok:lastSelfcheckChecks.every(c=>c.ok)});}else{body.innerHTML='<p class="empty">自检中…</p>';runSelfcheck();}}
/* ★ 第 177 轮:自检单飞 —— 同一时刻只允许一个 /api/selfcheck 在飞。
背景(CDP 真机实测):自检内部的合同标志「翻走 / 翻回」写探针会广播 sync(scope:all,**含发起者自己**)
⇒ 前端 onRemoteSync → loadDB → render → renderSelfcheck 发现结果缓存仍为空 → 再发一个自检
⇒ 服务端串行排队跑完两个,各自弹一次「自检完成」(用户看到 2 条相同提示)。
改法:复用在飞的 Promise —— 不重复发请求、不重复提示;请求结束(成功或失败)后释放。
⚠️ 勿改成「清空重发」:那正是本 bug 的成因。 */
let _scInFlight=null;
function runSelfcheck(){const _b=document.getElementById('scRunBtn'),_st=document.getElementById('scStatus');if(_b){if(!_b.__sct)_b.__sct=_b.innerHTML;_b.disabled=true;_b.style.opacity='.6';_b.style.cursor='wait';_b.innerHTML='⏳ 自检中…';}if(_st)_st.innerHTML='⏳ 正在自检,请稍候(约 5~15 秒,期间请勿重复点击)…';if(_scInFlight)return _scInFlight;const _t0=Date.now();_scInFlight=apiGet('/api/selfcheck').then(d=>{const body=document.getElementById('scBody');if(body)renderSelfcheckBody(body,d);const _b2=document.getElementById('scRunBtn');if(_b2){_b2.disabled=false;_b2.style.opacity='';_b2.style.cursor='';if(_b2.__sct)_b2.innerHTML=_b2.__sct;}const _st2=document.getElementById('scStatus');const _cks=(d&&d.checks)||[];const _n=_cks.length;const _bad=_cks.filter(c=>!c.ok).length;const _secs=((Date.now()-_t0)/1000).toFixed(1);if(_st2)_st2.innerHTML='🕒 上次自检 '+(d&&d.time?fmtTime(d.time):fmtTime(new Date().toISOString()))+' · 共 '+_n+' 项'+(_bad===0?',✅ 全部正常':',❌ '+_bad+' 项异常')+'(用时 '+_secs+' 秒)';toast(_bad===0?('自检完成:共 '+_n+' 项,全部正常'):('自检完成:发现 '+_bad+' 项异常,请查看下方列表'));}).catch(e=>{const body=document.getElementById('scBody');if(body)body.innerHTML=`<div class="empty" style="color:#dc2626">自检失败:${esc((e&&e.message)||'')}</div>`;const _b3=document.getElementById('scRunBtn');if(_b3){_b3.disabled=false;_b3.style.opacity='';_b3.style.cursor='';if(_b3.__sct)_b3.innerHTML=_b3.__sct;}const _st3=document.getElementById('scStatus');if(_st3)_st3.innerHTML='❌ 自检失败:'+esc((e&&e.message)||'')+'(可稍后重试)';toast('自检失败:'+((e&&e.message)||''));}).then(function(v){_scInFlight=null;return v;},function(e){_scInFlight=null;throw e;});return _scInFlight;}
function runSelfcheckFix(key){apiPost('/api/selfcheck/fix',{key:key}).then(d=>{toast('已修复 '+(d.fixed||0)+' 处');runSelfcheck();}).catch(e=>{toast('修复失败:'+((e&&e.message)||''));});}
function scToggleNormals(){scShowAllNormals=!scShowAllNormals;const _b=document.getElementById('scBody');if(_b)renderSelfcheckBody(_b,{checks:lastSelfcheckChecks,ok:lastSelfcheckChecks.every(c=>c.ok)});}
function scFixAllRender(){if(!_scFixAllBusy)return;const p='⏳ 修复中 '+Math.min(_scFixAllBusy.done+1,_scFixAllBusy.list.length)+'/'+_scFixAllBusy.list.length+'…';const fb=document.getElementById('scFixAllBtn');if(fb){fb.disabled=true;fb.textContent=p;fb.style.display='';}const bar=document.getElementById('scFixBar');if(bar){const _bt=bar.querySelector('button');if(_bt){_bt.disabled=true;_bt.textContent=p;}}}
function runSelfcheckFixAll(){if(_scFixAllBusy)return;const isAdmin=!!(currentUser&&(currentUser.roles||[]).indexOf('admin')>=0);if(!isAdmin){toast('仅管理员可修复');return;}const list=(lastSelfcheckChecks||[]).filter(c=>!c.ok&&c.fixable).map(c=>c.key);if(!list.length){toast('没有可一键修复的项');return;}const heavy=['server_alive','process','tunnel','daemon_reset'];list.sort((a,b)=>(heavy.indexOf(a)>=0?1:0)-(heavy.indexOf(b)>=0?1:0));_scFixAllBusy={list:list,done:0,okCnt:0,fixedSum:0,fails:[]};const bar=document.getElementById('scFixBar');if(bar&&heavy.some(k=>list.indexOf(k)>=0)){bar.insertAdjacentHTML('beforeend','<span class="mtxt" style="color:#b45309">(含服务重启/进程类修复,过程中页面可能短暂中断 1~3 秒,属正常)</span>');}scFixAllRender();const step=i=>{if(i>=list.length){const r=_scFixAllBusy;_scFixAllBusy=null;toast('一键修复完成:成功 '+r.okCnt+' 项(共 '+r.fixedSum+' 处)'+(r.fails.length?(',失败 '+r.fails.length+' 项('+r.fails.join(';')+')'):'')+',即将重新自检');setTimeout(runSelfcheck,r.list.some(k=>heavy.indexOf(k)>=0)?3500:800);return;}_scFixAllBusy.done=i;scFixAllRender();apiPost('/api/selfcheck/fix',{key:list[i]}).then(d=>{_scFixAllBusy.okCnt++;_scFixAllBusy.fixedSum+=((d&&d.fixed)||0);step(i+1);}).catch(e=>{_scFixAllBusy.fails.push(list[i]+'('+((e&&e.message)||'请求失败')+')');step(i+1);});};step(0);}
let lastSelfcheckChecks=[];let lastSelfcheckCopy={};
function copySelfcheckList(k){const t=(lastSelfcheckCopy&&lastSelfcheckCopy[k])||'';if(!t){toast('没有可复制的名单');return;}copyText(t);toast('名单已复制('+t.split('\n').length+' 行)');}
function openNotifySettings(){openPushSettings('selfcheck','企业微信推送设置(系统自检)','自检发现异常时,优先直接推送到管理员本人的企业微信;连接器不可用时,自动回落到下面配置的群机器人。');}
function openPushSettings(key,title,desc){const n=DB.notify||{};const push=n.push||{};const cfg=push[key]||{};const enabled=cfg.enabled!==false;const wh=cfg.webhook||'';openModal(`<div class="modal-head"><h3>🔔 ${title}</h3><span class="x" onclick="closeModal()">×</span></div><div class="modal-body"><p class="mtxt" style="margin-bottom:12px">${desc}</p><div class="field checkbox-field"><label style="display:flex;align-items:center;gap:8px;cursor:pointer"><input type="checkbox" id="pk_enabled" ${enabled?'checked':''}> <span>启用企业微信推送</span></label></div><div class="field"><label>群机器人 webhook 地址</label><input id="pk_webhook" value="${wh}" placeholder="留空则使用默认 webhook 地址"></div><div class="mtxt" style="margin-top:8px">获取:企业微信 → 建群 → 右上角「···」→ 群机器人 → 添加 → 复制 webhook 地址</div></div><div class="modal-foot"><button class="btn ghost" onclick="testPushSettings('${key}')">🧪 测试推送</button><button class="btn" onclick="savePushSettings('${key}')">保存</button></div>`);}
function savePushSettings(key){const enabled=document.getElementById('pk_enabled').checked;const wh=(document.getElementById('pk_webhook').value||'').trim();DB.notify=DB.notify||{};DB.notify.push=DB.notify.push||{};DB.notify.push[key]={enabled,webhook:wh};save().then(ok=>{if(ok){closeModal();toast('已保存推送设置');}});}
function testPushSettings(key){const wh=(document.getElementById('pk_webhook').value||'').trim();const eff=wh||((DB.notify&&DB.notify.webhook)||'');apiPost('/api/notify/test',{webhook:eff,pushKey:key}).then(d=>{toast(d.detail||(d.ok?'推送成功':'推送失败'));}).catch(e=>{toast('测试失败:'+((e&&e.message)||''));});}
let importParsed=null;
let importDone=null;
function impOnDrop(ev){try{ev.preventDefault();var dz=document.getElementById('imp_drop');if(dz){dz.style.borderColor='';dz.style.background='#fafbff';}var f=ev.dataTransfer&&ev.dataTransfer.files&&ev.dataTransfer.files[0];if(!f)return;if(!/\.(xlsx|xls)$/i.test(f.name||'')){toast('请拖入 .xlsx 或 .xls 格式的 Excel 文件');return;}handleImportFile({files:[f]});}catch(_){toast('拖入失败,请改用「选择文件」');}}
function openCustomerImport(){if(!can('add_customers')){toast('无权限:仅可报备客户');return;}importParsed=null;openModal(`
<div class="modal-head"><h3>📥 批量导入转介绍客户</h3><span class="x" onclick="closeModal()">×</span></div>
<div class="modal-body">
<div class="warn-box">上传 Excel(.xlsx/.xls),系统自动校验并匹配<b>归属经销商</b>、<b>对接员工</b>,识别<b>重复客户</b>(按手机号查重)。</div>
<div class="field" style="margin-top:12px"><label>选择 Excel 文件</label><input type="file" id="imp_file" accept=".xlsx,.xls" onclick="window.__sysDialogBusy=1;window.__sysDialogAt=Date.now()" onchange="window.__sysDialogBusy=0;handleImportFile(this)" style="padding:10px;border:1px dashed var(--ring);border-radius:8px;background:var(--soft);cursor:pointer;width:100%"></div>
<div id="imp_drop" ondragover="event.preventDefault();this.style.borderColor='#5E6AD2';this.style.background='#EEEFFC'" ondragleave="this.style.borderColor='';this.style.background='#fafbff'" ondrop="impOnDrop(event)" style="margin-top:10px;border:1.5px dashed var(--ring);border-radius:10px;background:#fafbff;padding:16px;text-align:center;color:var(--muted);font-size:12.5px;cursor:copy">📎 或直接把 .xlsx / .xls 文件拖到这里</div>
<div style="margin-top:10px"><a class="btn ghost sm" onclick="downloadImportTemplate()">📄 下载导入模板</a></div>
<div id="imp_result" style="margin-top:14px"></div>
</div>
<div class="modal-foot"><button class="btn ghost" onclick="closeModal()">关闭</button></div>
`);}
function handleImportFile(input){const f=input.files&&input.files[0];if(!f)return;window.__sysDialogBusy=0;const box=document.getElementById('imp_result');if(!/\.(xlsx|xls)$/i.test(f.name||'')){if(box)box.innerHTML='<div class="warn-box">请选择 .xlsx 或 .xls 格式的 Excel 文件(当前文件:'+esc(f.name||'')+')</div>';return;}if(f.size>15728640){if(box)box.innerHTML='<div class="warn-box">文件过大('+Math.round(f.size/1048576)+' MB)。批量导入建议不超过 15 MB,请拆分后分批导入。</div>';return;}if(box)box.innerHTML='<div class="empty" style="padding:20px 0">解析中…</div>';const rd=new FileReader();rd.onerror=function(){if(box)box.innerHTML='<div class="warn-box">文件读取失败,请重试或换一个文件</div>';};rd.onload=function(){try{const s=String(rd.result||'');const i=s.indexOf(',');const b64=i>=0?s.slice(i+1):s;apiPost('/api/customers/import-parse',{base64:b64}).then(function(d){importParsed=d;renderImportResult(d);}).catch(function(e){if(box)box.innerHTML='<div class="warn-box">'+((e&&e.message)||'解析失败')+'</div>';});}catch(e){if(box)box.innerHTML='<div class="warn-box">解析失败:'+esc(e.message||'')+'</div>';}};rd.readAsDataURL(f);}
function renderImportResult(d){const box=document.getElementById('imp_result');if(!box)return;const s=d.summary||{};const rows=d.rows||[];const cell=function(r){if(r.status==='ok')return '<span class="pill" style="background:#dcfce7;color:#166534">✅ 可导入</span>';if(r.status==='duplicate'){var why=r.error?esc(r.error):((r.dupCustomer&&r.dupCustomer.name)?('与已有客户「'+esc(r.dupCustomer.name)+'」重复'):'手机号重复');return '<span class="pill" style="background:#fef3c7;color:#92400e">⚠️ 重复('+why+')</span>';}return '<span class="pill" style="background:#fef2f2;color:#b91c1c">❌ '+esc(r.error||'失败')+'</span>';};const trs=rows.map(function(r){const dl=(r.status!=='error'&&r.matchedDealerName)?r.matchedDealerName:'';return '<tr><td>'+r.row+'</td><td>'+esc(r.name)+'</td><td>'+esc(r.phone)+'</td><td>'+esc(dl)+'</td><td>'+cell(r)+'</td></tr>';}).join('');let html='<div class="mtxt" style="margin-bottom:8px">共 <b>'+s.total+'</b> 条:<span style="color:#166534">✅ 可导入 '+s.ok+'</span> · <span style="color:#92400e">⚠️ 重复 '+s.duplicate+'</span> · <span style="color:#b91c1c">❌ 失败 '+s.error+'</span></div>';if(s.error)html+='<div class="warn-box" style="margin-bottom:8px">'+s.error+' 条失败(原因见明细),请修正后重新上传。</div>';html+='<div style="max-height:260px;overflow:auto;border:1px solid var(--line);border-radius:8px"><table style="width:100%"><thead><tr><th style="padding:8px">行</th><th style="padding:8px">客户</th><th style="padding:8px">电话</th><th style="padding:8px">匹配经销商</th><th style="padding:8px">状态</th></tr></thead><tbody>'+trs+'</tbody></table></div>';if(s.ok+s.duplicate>0){html+='<div class="field" style="margin-top:12px"><label>重复客户处理</label><select id="imp_dupmode"><option value="skip">跳过重复(保留原客户)</option><option value="overwrite">覆盖重复(用新数据覆盖)</option></select></div><div style="margin-top:12px;display:flex;gap:8px;justify-content:flex-end"><button class="btn ghost" onclick="closeModal()">取消</button><button class="btn" onclick="commitCustomerImport()">确认导入 '+(s.ok+s.duplicate)+' 条</button></div>';}box.innerHTML=html;}
function commitCustomerImport(){if(!importParsed)return;var mode=(document.getElementById('imp_dupmode')||{}).value||'skip';var src=importParsed.rows||[];var rows=src.filter(function(r){return r.status==='ok'||r.status==='duplicate';}).map(function(r){return {row:r.row,name:r.name,phone:r.phone,school:r.school,intent:r.intent,dealer:r.dealer,staff:r.staff,prov:r.prov,city:r.city,district:r.district,addr:r.addr,reportDate:r.reportDate,amount:r.amount,signDate:r.signDate,contractDate:r.contractDate,installment:r.installment,note:r.note};});if(!rows.length){toast('没有可导入的数据');return;}var failed=src.filter(function(r){return r.status==='error';}).map(function(r){return {row:r.row,name:r.name,phone:r.phone,dealer:r.dealer,reason:r.error||'解析失败'};});apiPost('/api/customers/import-commit',{rows:rows,dupMode:mode}).then(function(d){var seen={};failed.forEach(function(f){seen[(f.row||0)+'|'+(f.phone||'')]=1;});((d&&d.skippedRows)||[]).forEach(function(g){var k=(g.row||0)+'|'+(g.phone||'');if(seen[k])return;seen[k]=1;failed.push({row:g.row||0,name:g.name||'',phone:g.phone||'',dealer:g.dealer||'',reason:g.reason||'跳过'});});failed.sort(function(a,b){return (a.row||0)-(b.row||0);});importDone={result:d,failed:failed};renderImportDone();loadDB().then(function(){render();});}).catch(function(e){toast('导入失败:'+((e&&e.message)||''));});}
function renderImportDone(){var box=document.getElementById('imp_result');if(!box||!importDone)return;var d=importDone.result||{},failed=importDone.failed||[];var okN=Number(d.added||0)+Number(d.overwritten||0);var html='<div class="mtxt" style="margin-bottom:8px">导入完成:<b style="color:#166534">新增 '+Number(d.added||0)+'</b> · <b style="color:#0369a1">覆盖 '+Number(d.overwritten||0)+'</b> · <b style="color:#b91c1c">未成功 '+failed.length+'</b>(共 '+Number(d.total||(okN+failed.length))+' 条)</div>';if(!failed.length){html+='<div class="empty" style="padding:22px 0">✅ '+okN+' 条客户已全部成功导入</div>';}else{html+='<div class="warn-box" style="margin-bottom:8px">以下 <b>'+failed.length+'</b> 条<b>未成功导入</b>(原因见下表):修正后可重新上传,或先导出清单留存。</div>';html+='<div style="max-height:340px;overflow:auto;border:1px solid var(--line);border-radius:8px"><table style="width:100%"><thead><tr><th style="padding:8px">行</th><th style="padding:8px">客户姓名</th><th style="padding:8px">联系电话</th><th style="padding:8px">归属经销商</th><th style="padding:8px">未导入原因</th></tr></thead><tbody>'+failed.map(function(f){return '<tr><td>'+(f.row||'—')+'</td><td>'+esc(f.name||'—')+'</td><td>'+esc(f.phone||'—')+'</td><td>'+esc(f.dealer||'—')+'</td><td><span style="color:#b91c1c">'+esc(f.reason||'—')+'</span></td></tr>';}).join('')+'</tbody></table></div>';html+='<div style="margin-top:12px;display:flex;gap:8px;justify-content:flex-end"><button class="btn ghost sm" onclick="exportImportFailedCsv()">📥 导出未成功清单</button><button class="btn" onclick="closeModal()">完成</button></div>';}box.innerHTML=html;}
function exportImportFailedCsv(){if(!importDone)return;var failed=importDone.failed||[];if(!failed.length){toast('没有未成功导入的记录');return;}var rows=[['行号','客户姓名','联系电话','归属经销商','未导入原因']];failed.forEach(function(f){rows.push([f.row||'',f.name||'',f.phone||'',f.dealer||'',f.reason||'']);});var dt=new Date(),p2=function(n){return String(n).padStart(2,'0');};var ts=''+dt.getFullYear()+p2(dt.getMonth()+1)+p2(dt.getDate())+p2(dt.getHours())+p2(dt.getMinutes());delivDownloadCsv('未成功导入客户清单_'+ts+'.csv',rows);}
function downloadImportTemplate(){fetch('/api/customers/import-template',{headers:apiHeaders()}).then(function(r){if(!r.ok){return r.json().then(function(j){throw new Error((j&&j.error)||'下载失败');});}return r.blob();}).then(function(blob){const url=URL.createObjectURL(blob);const a=document.createElement('a');a.href=url;a.download='转介绍客户导入模板.xlsx';document.body.appendChild(a);a.click();a.remove();setTimeout(function(){URL.revokeObjectURL(url);},1000);}).catch(function(e){toast('下载模板失败:'+((e&&e.message)||''));});}
function renderAudit(){if(!can('view_audit')){view.innerHTML='<div class="card"><p class="empty">无权限:需要「查看操作日志」权限。</p></div>';return;}view.innerHTML=`<div class="topbar"><div><h1>📜 操作日志</h1><p>系统关键操作审计:登录、数据保存、导出、重置、改密等</p></div><button class="btn ghost sm"onclick="renderAudit()">🔄 刷新</button></div><div class="card table-card"><table id="auditTbl"><thead><tr><th>时间</th><th>事件</th><th>操作者</th><th>详情</th></tr></thead><tbody id="auditBody"><tr><td colspan="4" class="empty">加载中…</td></tr></tbody></table></div>`;apiGet('/api/audit').then(d=>{const rows=(d.logs||[]).map(l=>{const t=AUDIT_TYPES[l.type]||{label:'其他操作',cls:'st-following'};return `<tr><td class="mtxt"style="white-space:nowrap">${fmtTime(l.time)}</td><td><span class="st ${t.cls}">${t.label}</span></td><td>${esc(l.who||'')}</td><td class="mtxt">${esc(l.detail||'')}</td></tr>`;}).join('');(document.getElementById('auditBody')||{}).innerHTML=rows||'<tr><td colspan="4" class="empty">暂无日志</td></tr>';}).catch(e=>{(document.getElementById('auditBody')||{}).innerHTML=`<tr><td colspan="4" class="empty">加载失败:${esc((e&&e.message)||'')}</td></tr>`;});}
function renderFatal(err){const msg=(err&&err.message)?err.message:String(err);if(view){view.innerHTML=`<div class="card" style="border-left:5px solid var(--danger);max-width:640px;margin:40px auto">
<h2 style="color:var(--danger);margin-bottom:8px">⚠️ 页面加载出现异常</h2>
<p style="color:#475569;margin-bottom:10px">应用启动时出现异常。点击下方按钮,可清除本机登录记录(服务器上的数据不受影响),随后页面会自动重新载入。</p>
<pre style="background:#fef2f2;color:#b91c1c;padding:10px;border-radius:8px;font-size:12px;white-space:pre-wrap;margin-bottom:14px">${msg}</pre>
<button class="btn" onclick="forceReset()">清除本机登录记录并重新载入</button>
</div>`;}}
function forceReset(){try{localStorage.removeItem('yuanmo_token');localStorage.removeItem('yuanmo_dealer_token');localStorage.removeItem('yuanmo_user');localStorage.removeItem('yuanmo_dealer');localStorage.removeItem('yuanmo_mode');}catch(e){}
location.reload();}
try{boot();}
catch(err){try{var box=document.createElement('div');box.st
yle.cssText='padding:40px;font-family:sans-serif;line-height:1.8;color:#333;max-width:660px;margin:40px auto';box.innerHTML='<h2 style="color:#dc2626;margin-bottom:8px">⚠️ 页面初始化失败</h2>'
+'<p style="color:#475569;margin-bottom:10px">本系统已做兼容加固。若仍空白,多为浏览器缓存了旧版本所致:'
+'请按 <b>Ctrl+Shift+R</b>(Mac 用 <b>Cmd+Shift+R</b>)强制刷新,或清除该站点数据后重试;也可改用我们提供的离线单文件版本。</p>'
+'<pre style="background:#fef2f2;color:#b91c1c;padding:10px;border-radius:8px;font-size:12px;white-space:pre-wrap;margin-bottom:14px">'+String((err&&err.message)||err)+'</pre>';var v=document.getElementById('view');if(v)v.appendChild(box);else document.body.appendChild(box);}catch(_){}}
const AREA_DATA={"北京市":{"市辖区":["东城区","西城区","朝阳区","丰台区","石景山区","海淀区","门头沟区","房山区","通州区","顺义区","昌平区","大兴区","怀柔区","平谷区","密云区","延庆区"]},"天津市":{"市辖区":["和平区","河东区","河西区","南开区","河北区","红桥区","东丽区","西青区","津南区","北辰区","武清区","宝坻区","滨海新区","宁河区","静海区","蓟州区"]},"河北省":{"石家庄市":["长安区","桥西区","新华区","井陉矿区","裕华区","藁城区","鹿泉区","栾城区","井陉县","正定县","行唐县","灵寿县","高邑县","深泽县","赞皇县","无极县","平山县","元氏县","赵县","辛集市","晋州市","新乐市"],"唐山市":["路南区","路北区","古冶区","开平区","丰南区","丰润区","曹妃甸区","滦南县","乐亭县","迁西县","玉田县","遵化市","迁安市","滦州市"],"秦皇岛市":["海港区","山海关区","北戴河区","抚宁区","青龙满族自治县","昌黎县","卢龙县"],"邯郸市":["邯山区","丛台区","复兴区","峰峰矿区","肥乡区","永年区","临漳县","成安县","大名县","涉县","磁县","邱县","鸡泽县","广平县","馆陶县","魏县","曲周县","武安市"],"邢台市":["襄都区","信都区","任泽区","南和区","临城县","内丘县","柏乡县","隆尧县","宁晋县","巨鹿县","新河县","广宗县","平乡县","威县","清河县","临西县","南宫市","沙河市"],"保定市":["竞秀区","莲池区","满城区","清苑区","徐水区","涞水县","阜平县","定兴县","唐县","高阳县","容城县","涞源县","望都县","安新县","易县","曲阳县","蠡县","顺平县","博野县","雄县","涿州市","定州市","安国市","高碑店市"],"张家口市":["桥东区","桥西区","宣化区","下花园区","万全区","崇礼区","张北县","康保县","沽源县","尚义县","蔚县","阳原县","怀安县","怀来县","涿鹿县","赤城县"],"承德市":["双桥区","双滦区","鹰手营子矿区","承德县","兴隆县","滦平县","隆化县","丰宁满族自治县","宽城满族自治县","围场满族蒙古族自治县","平泉市"],"沧州市":["新华区","运河区","沧县","青县","东光县","海兴县","盐山县","肃宁县","南皮县","吴桥县","献县","孟村回族自治县","泊头市","任丘市","黄骅市","河间市"],"廊坊市":["安次区","广阳区","固安县","永清县","香河县","大城县","文安县","大厂回族自治县","霸州市","三河市"],"衡水市":["桃城区","冀州区","枣强县","武邑县","武强县","饶阳县","安平县","故城县","景县","阜城县","深州市"]},"山西省":{"太原市":["小店区","迎泽区","杏花岭区","尖草坪区","万柏林区","晋源区","清徐县","阳曲县","娄烦县","古交市"],"大同市":["新荣区","平城区","云冈区","云州区","阳高县","天镇县","广灵县","灵丘县","浑源县","左云县"],"阳泉市":["城区","矿区","郊区","平定县","盂县"],"长治市":["潞州区","上党区","屯留区","潞城区","襄垣县","平顺县","黎城县","壶关县","长子县","武乡县","沁县","沁源县"],"晋城市":["城区","沁水县","阳城县","陵川县","泽州县","高平市"],"朔州市":["朔城区","平鲁区","山阴县","应县","右玉县","怀仁市"],"晋中市":["榆次区","太谷区","榆社县","左权县","和顺县","昔阳县","寿阳县","祁县","平遥县","灵石县","介休市"],"运城市":["盐湖区","临猗县","万荣县","闻喜县","稷山县","新绛县","绛县","垣曲县","夏县","平陆县","芮城县","永济市","河津市"],"忻州市":["忻府区","定襄县","五台县","代县","繁峙县","宁武县","静乐县","神池县","五寨县","岢岚县","河曲县","保德县","偏关县","原平市"],"临汾市":["尧都区","曲沃县","翼城县","襄汾县","洪洞县","古县","安泽县","浮山县","吉县","乡宁县","大宁县","隰县","永和县","蒲县","汾西县","侯马市","霍州市"],"吕梁市":["离石区","文水县","交城县","兴县","临县","柳林县","石楼县","岚县","方山县","中阳县","交口县","孝义市","汾阳市"]},"内蒙古自治区":{"呼和浩特市":["新城区","回民区","玉泉区","赛罕区","土默特左旗","托克托县","和林格尔县","清水河县","武川县"],"包头市":["东河区","昆都仑区","青山区","石拐区","白云鄂博矿区","九原区","土默特右旗","固阳县","达尔罕茂明安联合旗"],"乌海市":["海勃湾区","海南区","乌达区"],"赤峰市":["红山区","元宝山区","松山区","阿鲁科尔沁旗","巴林左旗","巴林右旗","林西县","克什克腾旗","翁牛特旗","喀喇沁旗","宁城县","敖汉旗"],"通辽市":["科尔沁区","科尔沁左翼中旗","科尔沁左翼后旗","开鲁县","库伦旗","奈曼旗","扎鲁特旗","霍林郭勒市"],"鄂尔多斯市":["东胜区","康巴什区","达拉特旗","准格尔旗","鄂托克前旗","鄂托克旗","杭锦旗","乌审旗","伊金霍洛旗"],"呼伦贝尔市":["海拉尔区","扎赉诺尔区","阿荣旗","莫力达瓦达斡尔族自治旗","鄂伦春自治旗","鄂温克族自治旗","陈巴尔虎旗","新巴尔虎左旗","新巴尔虎右旗","满洲里市","牙克石市","扎兰屯市","额尔古纳市","根河市"],"巴彦淖尔市":["临河区","五原县","磴口县","乌拉特前旗","乌拉特中旗","乌拉特后旗","杭锦后旗"],"乌兰察布市":["集宁区","卓资县","化德县","商都县","兴和县","凉城县","察哈尔右翼前旗","察哈尔右翼中旗","察哈尔右翼后旗","四子王旗","丰镇市"],"兴安盟":["乌兰浩特市","阿尔山市","科尔沁右翼前旗","科尔沁右翼中旗","扎赉特旗","突泉县"],"锡林郭勒盟":["二连浩特市","锡林浩特市","阿巴嘎旗","苏尼特左旗","苏尼特右旗","东乌珠穆沁旗","西乌珠穆沁旗","太仆寺旗","镶黄旗","正镶白旗","正蓝旗","多伦县"],"阿拉善盟":["阿拉善左旗","阿拉善右旗","额济纳旗"]},"辽宁省":{"沈阳市":["和平区","沈河区","大东区","皇姑区","铁西区","苏家屯区","浑南区","沈北新区","于洪区","辽中区","康平县","法库县","新民市"],"大连市":["中山区","西岗区","沙河口区","甘井子区","旅顺口区","金州区","普兰店区","长海县","瓦房店市","庄河市"],"鞍山市":["铁东区","铁西区","立山区","千山区","台安县","岫岩满族自治县","海城市"],"抚顺市":["新抚区","东洲区","望花区","顺城区","抚顺县","新宾满族自治县","清原满族自治县"],"本溪市":["平山区","溪湖区","明山区","南芬区","本溪满族自治县","桓仁满族自治县"],"丹东市":["元宝区","振兴区","振安区","宽甸满族自治县","东港市","凤城市"],"锦州市":["古塔区","凌河区","太和区","黑山县","义县","凌海市","北镇市"],"营口市":["站前区","西市区","鲅鱼圈区","老边区","盖州市","大石桥市"],"阜新市":["海州区","新邱区","太平区","清河门区","细河区","阜新蒙古族自治县","彰武县"],"辽阳市":["白塔区","文圣区","宏伟区","弓长岭区","太子河区","辽阳县","灯塔市"],"盘锦市":["双台子区","兴隆台区","大洼区","盘山县"],"铁岭市":["银州区","清河区","铁岭县","西丰县","昌图县","调兵山市","开原市"],"朝阳市":["双塔区","龙城区","朝阳县","建平县","喀喇沁左翼蒙古族自治县","北票市","凌源市"],"葫芦岛市":["连山区","龙港区","南票区","绥中县","建昌县","兴城市"]},"吉林省":{"长春市":["南关区","宽城区","朝阳区","二道区","绿园区","双阳区","九台区","农安县","榆树市","德惠市","公主岭市"],"吉林市":["昌邑区","龙潭区","船营区","丰满区","永吉县","蛟河市","桦甸市","舒兰市","磐石市"],"四平市":["铁西区","铁东区","梨树县","伊通满族自治县","双辽市"],"辽源市":["龙山区","西安区","东丰县","东辽县"],"通化市":["东昌区","二道江区","通化县","辉南县","柳河县","梅河口市","集安市"],"白山市":["浑江区","江源区","抚松县","靖宇县","长白朝鲜族自治县","临江市"],"松原市":["宁江区","前郭尔罗斯蒙古族自治县","长岭县","乾安县","扶余市"],"白城市":["洮北区","镇赉县","通榆县","洮南市","大安市"],"延边朝鲜族自治州":["延吉市","图们市","敦化市","珲春市","龙井市","和龙市","汪清县","安图县"]},"黑龙江省":{"哈尔滨市":["道里区","南岗区","道外区","平房区","松北区","香坊区","呼兰区","阿城区","双城区","依兰县","方正县","宾县","巴彦县","木兰县","通河县","延寿县","尚志市","五常市"],"齐齐哈尔市":["龙沙区","建华区","铁锋区","昂昂溪区","富拉尔基区","碾子山区","梅里斯达斡尔族区","龙江县","依安县","泰来县","甘南县","富裕县","克山县","克东县","拜泉县","讷河市"],"鸡西市":["鸡冠区","恒山区","滴道区","梨树区","城子河区","麻山区","鸡东县","虎林市","密山市"],"鹤岗市":["向阳区","工农区","南山区","兴安区","东山区","兴山区","萝北县","绥滨县"],"双鸭山市":["尖山区","岭东区","四方台区","宝山区","集贤县","友谊县","宝清县","饶河县"],"大庆市":["萨尔图区","龙凤区","让胡路区","红岗区","大同区","肇州县","肇源县","林甸县","杜尔伯特蒙古族自治县"],"伊春市":["伊美区","乌翠区","友好区","嘉荫县","汤旺县","丰林县","大箐山县","南岔县","金林区","铁力市"],"佳木斯市":["向阳区","前进区","东风区","郊区","桦南县","桦川县","汤原县","同江市","富锦市","抚远市"],"七台河市":["新兴区","桃山区","茄子河区","勃利县"],"牡丹江市":["东安区","阳明区","爱民区","西安区","林口县","绥芬河市","海林市","宁安市","穆棱市","东宁市"],"黑河市":["爱辉区","逊克县","孙吴县","北安市","五大连池市","嫩江市"],"绥化市":["北林区","望奎县","兰西县","青冈县","庆安县","明水县","绥棱县","安达市","肇东市","海伦市"],"大兴安岭地区":["漠河市","呼玛县","塔河县"]},"上海市":{"市辖区":["黄浦区","徐汇区","长宁区","静安区","普陀区","虹口区","杨浦区","闵行区","宝山区","嘉定区","浦东新区","金山区","松江区","青浦区","奉贤区","崇明区"]},"江苏省":{"南京市":["玄武区","秦淮区","建邺区","鼓楼区","浦口区","栖霞区","雨花台区","江宁区","六合区","溧水区","高淳区"],"无锡市":["锡山区","惠山区","滨湖区","梁溪区","新吴区","江阴市","宜兴市"],"徐州市":["鼓楼区","云龙区","贾汪区","泉山区","铜山区","丰县","沛县","睢宁县","新沂市","邳州市"],"常州市":["天宁区","钟楼区","新北区","武进区","金坛区","溧阳市"],"苏州市":["虎丘区","吴中区","相城区","姑苏区","吴江区","常熟市","张家港市","昆山市","太仓市"],"南通市":["通州区","崇川区","海门区","如东县","启东市","如皋市","海安市"],"连云港市":["连云区","海州区","赣榆区","东海县","灌云县","灌南县"],"淮安市":["淮安区","淮阴区","清江浦区","洪泽区","涟水县","盱眙县","金湖县"],"盐城市":["亭湖区","盐都区","大丰区","响水县","滨海县","阜宁县","射阳县","建湖县","东台市"],"扬州市":["广陵区","邗江区","江都区","宝应县","仪征市","高邮市"],"镇江市":["京口区","润州区","丹徒区","丹阳市","扬中市","句容市"],"泰州市":["海陵区","高港区","姜堰区","兴化市","靖江市","泰兴市"],"宿迁市":["宿城区","宿豫区","沭阳县","泗阳县","泗洪县"]},"浙江省":{"杭州市":["上城区","拱墅区","西湖区","滨江区","萧山区","余杭区","富阳区","临安区","临平区","钱塘区","桐庐县","淳安县","建德市"],"宁波市":["海曙区","江北区","北仑区","镇海区","鄞州区","奉化区","象山县","宁海县","余姚市","慈溪市"],"温州市":["鹿城区","龙湾区","瓯海区","洞头区","永嘉县","平阳县","苍南县","文成县","泰顺县","瑞安市","乐清市","龙港市"],"嘉兴市":["南湖区","秀洲区","嘉善县","海盐县","海宁市","平湖市","桐乡市"],"湖州市":["吴兴区","南浔区","德清县","长兴县","安吉县"],"绍兴市":["越城区","柯桥区","上虞区","新昌县","诸暨市","嵊州市"],"金华市":["婺城区","金东区","武义县","浦江县","磐安县","兰溪市","义乌市","东阳市","永康市"],"衢州市":["柯城区","衢江区","常山县","开化县","龙游县","江山市"],"舟山市":["定海区","普陀区","岱山县","嵊泗县"],"台州市":["椒江区","黄岩区","路桥区","三门县","天台县","仙居县","温岭市","临海市","玉环市"],"丽水市":["莲都区","青田县","缙云县","遂昌县","松阳县","云和县","庆元县","景宁畲族自治县","龙泉市"]},"安徽省":{"合肥市":["瑶海区","庐阳区","蜀山区","包河区","长丰县","肥东县","肥西县","庐江县","巢湖市"],"芜湖市":["镜湖区","鸠江区","弋江区","湾沚区","繁昌区","南陵县","无为市"],"蚌埠市":["龙子湖区","蚌山区","禹会区","淮上区","怀远县","五河县","固镇县"],"淮南市":["大通区","田家庵区","谢家集区","八公山区","潘集区","凤台县","寿县"],"马鞍山市":["花山区","雨山区","博望区","当涂县","含山县","和县"],"淮北市":["杜集区","相山区","烈山区","濉溪县"],"铜陵市":["铜官区","义安区","郊区","枞阳县"],"安庆市":["迎江区","大观区","宜秀区","怀宁县","太湖县","宿松县","望江县","岳西县","桐城市","潜山市"],"黄山市":["屯溪区","黄山区","徽州区","歙县","休宁县","黟县","祁门县"],"滁州市":["琅琊区","南谯区","来安县","全椒县","定远县","凤阳县","天长市","明光市"],"阜阳市":["颍州区","颍东区","颍泉区","临泉县","太和县","阜南县","颍上县","界首市"],"宿州市":["埇桥区","砀山县","萧县","灵璧县","泗县"],"六安市":["金安区","裕安区","叶集区","霍邱县","舒城县","金寨县","霍山县"],"亳州市":["谯城区","涡阳县","蒙城县","利辛县"],"池州市":["贵池区","东至县","石台县","青阳县"],"宣城市":["宣州区","郎溪县","泾县","绩溪县","旌德县","宁国市","广德市"]},"福建省":{"福州市":["鼓楼区","台江区","仓山区","马尾区","晋安区","长乐区","闽侯县","连江县","罗源县","闽清县","永泰县","平潭县","福清市"],"厦门市":["思明区","海沧区","湖里区","集美区","同安区","翔安区"],"莆田市":["城厢区","涵江区","荔城区","秀屿区","仙游县"],"三明市":["三元区","沙县区","明溪县","清流县","宁化县","大田县","尤溪县","将乐县","泰宁县","建宁县","永安市"],"泉州市":["鲤城区","丰泽区","洛江区","泉港区","惠安县","安溪县","永春县","德化县","金门县","石狮市","晋江市","南安市"],"漳州市":["芗城区","龙文区","龙海区","长泰区","云霄县","漳浦县","诏安县","东山县","南靖县","平和县","华安县"],"南平市":["延平区","建阳区","顺昌县","浦城县","光泽县","松溪县","政和县","邵武市","武夷山市","建瓯市"],"龙岩市":["新罗区","永定区","长汀县","上杭县","武平县","连城县","漳平市"],"宁德市":["蕉城区","霞浦县","古田县","屏南县","寿宁县","周宁县","柘荣县","福安市","福鼎市"]},"江西省":{"南昌市":["东湖区","西湖区","青云谱区","青山湖区","新建区","红谷滩区","南昌县","安义县","进贤县"],"景德镇市":["昌江区","珠山区","浮梁县","乐平市"],"萍乡市":["安源区","湘东区","莲花县","上栗县","芦溪县"],"九江市":["濂溪区","浔阳区","柴桑区","武宁县","修水县","永修县","德安县","都昌县","湖口县","彭泽县","瑞昌市","共青城市","庐山市"],"新余市":["渝水区","分宜县"],"鹰潭市":["月湖区","余江区","贵溪市"],"赣州市":["章贡区","南康区","赣县区","信丰县","大余县","上犹县","崇义县","安远县","定南县","全南县","宁都县","于都县","兴国县","会昌县","寻乌县","石城县","瑞金市","龙南市"],"吉安市":["吉州区","青原区","吉安县","吉水县","峡江县","新干县","永丰县","泰和县","遂川县","万安县","安福县","永新县","井冈山市"],"宜春市":["袁州区","奉新县","万载县","上高县","宜丰县","靖安县","铜鼓县","丰城市","樟树市","高安市"],"抚州市":["临川区","东乡区","南城县","黎川县","南丰县","崇仁县","乐安县","宜黄县","金溪县","资溪县","广昌县"],"上饶市":["信州区","广丰区","广信区","玉山县","铅山县","横峰县","弋阳县","余干县","鄱阳县","万年县","婺源县","德兴市"]},"山东省":{"济南市":["历下区","市中区","槐荫区","天桥区","历城区","长清区","章丘区","济阳区","莱芜区","钢城区","平阴县","商河县"],"青岛市":["市南区","市北区","黄岛区","崂山区","李沧区","城阳区","即墨区","胶州市","平度市","莱西市"],"淄博市":["淄川区","张店区","博山区","临淄区","周村区","桓台县","高青县","沂源县"],"枣庄市":["市中区","薛城区","峄城区","台儿庄区","山亭区","滕州市"],"东营市":["东营区","河口区","垦利区","利津县","广饶县"],"烟台市":["芝罘区","福山区","牟平区","莱山区","蓬莱区","龙口市","莱阳市","莱州市","招远市","栖霞市","海阳市"],"潍坊市":["潍城区","寒亭区","坊子区","奎文区","临朐县","昌乐县","青州市","诸城市","寿光市","安丘市","高密市","昌邑市"],"济宁市":["任城区","兖州区","微山县","鱼台县","金乡县","嘉祥县","汶上县","泗水县","梁山县","曲阜市","邹城市"],"泰安市":["泰山区","岱岳区","宁阳县","东平县","新泰市","肥城市"],"威海市":["环翠区","文登区","荣成市","乳山市"],"日照市":["东港区","岚山区","五莲县","莒县"],"临沂市":["兰山区","罗庄区","河东区","沂南县","郯城县","沂水县","兰陵县","费县","平邑县","莒南县","蒙阴县","临沭县"],"德州市":["德城区","陵城区","宁津县","庆云县","临邑县","齐河县","平原县","夏津县","武城县","乐陵市","禹城市"],"聊城市":["东昌府区","茌平区","阳谷县","莘县","东阿县","冠县","高唐县","临清市"],"滨州市":["滨城区","沾化区","惠民县","阳信县","无棣县","博兴县","邹平市"],"菏泽市":["牡丹区","定陶区","曹县","单县","成武县","巨野县","郓城县","鄄城县","东明县"]},"河南省":{"郑州市":["中原区","二七区","管城回族区","金水区","上街区","惠济区","中牟县","巩义市","荥阳市","新密市","新郑市","登封市"],"开封市":["龙亭区","顺河回族区","鼓楼区","禹王台区","祥符区","杞县","通许县","尉氏县","兰考县"],"洛阳市":["老城区","西工区","瀍河回族区","涧西区","偃师区","孟津区","洛龙区","新安县","栾川县","嵩县","汝阳县","宜阳县","洛宁县","伊川县"],"平顶山市":["新华区","卫东区","石龙区","湛河区","宝丰县","叶县","鲁山县","郏县","舞钢市","汝州市"],"安阳市":["文峰区","北关区","殷都区","龙安区","安阳县","汤阴县","滑县","内黄县","林州市"],"鹤壁市":["鹤山区","山城区","淇滨区","浚县","淇县"],"新乡市":["红旗区","卫滨区","凤泉区","牧野区","新乡县","获嘉县","原阳县","延津县","封丘县","卫辉市","辉县市","长垣市"],"焦作市":["解放区","中站区","马村区","山阳区","修武县","博爱县","武陟县","温县","沁阳市","孟州市"],"濮阳市":["华龙区","清丰县","南乐县","范县","台前县","濮阳县"],"许昌市":["魏都区","建安区","鄢陵县","襄城县","禹州市","长葛市"],"漯河市":["源汇区","郾城区","召陵区","舞阳县","临颍县"],"三门峡市":["湖滨区","陕州区","渑池县","卢氏县","义马市","灵宝市"],"南阳市":["宛城区","卧龙区","南召县","方城县","西峡县","镇平县","内乡县","淅川县","社旗县","唐河县","新野县","桐柏县","邓州市"],"商丘市":["梁园区","睢阳区","民权县","睢县","宁陵县","柘城县","虞城县","夏邑县","永城市"],"信阳市":["浉河区","平桥区","罗山县","光山县","新县","商城县","固始县","潢川县","淮滨县","息县"],"周口市":["川汇区","淮阳区","扶沟县","西华县","商水县","沈丘县","郸城县","太康县","鹿邑县","项城市"],"驻马店市":["驿城区","西平县","上蔡县","平舆县","正阳县","确山县","泌阳县","汝南县","遂平县","新蔡县"]},"湖北省":{"武汉市":["江岸区","江汉区","硚口区","汉阳区","武昌区","青山区","洪山区","东西湖区","汉南区","蔡甸区","江夏区","黄陂区","新洲区"],"黄石市":["黄石港区","西塞山区","下陆区","铁山区","阳新县","大冶市"],"十堰市":["茅箭区","张湾区","郧阳区","郧西县","竹山县","竹溪县","房县","丹江口市"],"宜昌市":["西陵区","伍家岗区","点军区","猇亭区","夷陵区","远安县","兴山县","秭归县","长阳土家族自治县","五峰土家族自治县","宜都市","当阳市","枝江市"],"襄阳市":["襄城区","樊城区","襄州区","南漳县","谷城县","保康县","老河口市","枣阳市","宜城市"],"鄂州市":["梁子湖区","华容区","鄂城区"],"荆门市":["东宝区","掇刀区","沙洋县","钟祥市","京山市"],"孝感市":["孝南区","孝昌县","大悟县","云梦县","应城市","安陆市","汉川市"],"荆州市":["沙市区","荆州区","公安县","江陵县","石首市","洪湖市","松滋市","监利市"],"黄冈市":["黄州区","团风县","红安县","罗田县","英山县","浠水县","蕲春县","黄梅县","麻城市","武穴市"],"咸宁市":["咸安区","嘉鱼县","通城县","崇阳县","通山县","赤壁市"],"随州市":["曾都区","随县","广水市"],"恩施土家族苗族自治州":["恩施市","利川市","建始县","巴东县","宣恩县","咸丰县","来凤县","鹤峰县"]},"湖南省":{"长沙市":["芙蓉区","天心区","岳麓区","开福区","雨花区","望城区","长沙县","浏阳市","宁乡市"],"株洲市":["荷塘区","芦淞区","石峰区","天元区","渌口区","攸县","茶陵县","炎陵县","醴陵市"],"湘潭市":["雨湖区","岳塘区","湘潭县","湘乡市","韶山市"],"衡阳市":["珠晖区","雁峰区","石鼓区","蒸湘区","南岳区","衡阳县","衡南县","衡山县","衡东县","祁东县","耒阳市","常宁市"],"邵阳市":["双清区","大祥区","北塔区","新邵县","邵阳县","隆回县","洞口县","绥宁县","新宁县","城步苗族自治县","武冈市","邵东市"],"岳阳市":["岳阳楼区","云溪区","君山区","岳阳县","华容县","湘阴县","平江县","汨罗市","临湘市"],"常德市":["武陵区","鼎城区","安乡县","汉寿县","澧县","临澧县","桃源县","石门县","津市市"],"张家界市":["永定区","武陵源区","慈利县","桑植县"],"益阳市":["资阳区","赫山区","南县","桃江县","安化县","沅江市"],"郴州市":["北湖区","苏仙区","桂阳县","宜章县","永兴县","嘉禾县","临武县","汝城县","桂东县","安仁县","资兴市"],"永州市":["零陵区","冷水滩区","东安县","双牌县","道县","江永县","宁远县","蓝山县","新田县","江华瑶族自治县","祁阳市"],"怀化市":["鹤城区","中方县","沅陵县","辰溪县","溆浦县","会同县","麻阳苗族自治县","新晃侗族自治县","芷江侗族自治县","靖州苗族侗族自治县","通道侗族自治县","洪江市"],"娄底市":["娄星区","双峰县","新化县","冷水江市","涟源市"],"湘西土家族苗族自治州":["吉首市","泸溪县","凤凰县","花垣县","保靖县","古丈县","永顺县","龙山县"]},"广东省":{"广州市":["荔湾区","越秀区","海珠区","天河区","白云区","黄埔区","番禺区","花都区","南沙区","从化区","增城区"],"韶关市":["武江区","浈江区","曲江区","始兴县","仁化县","翁源县","乳源瑶族自治县","新丰县","乐昌市","南雄市"],"深圳市":["罗湖区","福田区","南山区","宝安区","龙岗区","盐田区","龙华区","坪山区","光明区"],"珠海市":["香洲区","斗门区","金湾区"],"汕头市":["龙湖区","金平区","濠江区","潮阳区","潮南区","澄海区","南澳县"],"佛山市":["禅城区","南海区","顺德区","三水区","高明区"],"江门市":["蓬江区","江海区","新会区","台山市","开平市","鹤山市","恩平市"],"湛江市":["赤坎区","霞山区","坡头区","麻章区","遂溪县","徐闻县","廉江市","雷州市","吴川市"],"茂名市":["茂南区","电白区","高州市","化州市","信宜市"],"肇庆市":["端州区","鼎湖区","高要区","广宁县","怀集县","封开县","德庆县","四会市"],"惠州市":["惠城区","惠阳区","博罗县","惠东县","龙门县"],"梅州市":["梅江区","梅县区","大埔县","丰顺县","五华县","平远县","蕉岭县","兴宁市"],"汕尾市":["城区","海丰县","陆河县","陆丰市"],"河源市":["源城区","紫金县","龙川县","连平县","和平县","东源县"],"阳江市":["江城区","阳东区","阳西县","阳春市"],"清远市":["清城区","清新区","佛冈县","阳山县","连山壮族瑶族自治县","连南瑶族自治县","英德市","连州市"],"东莞市":["东莞市"],"中山市":["中山市"],"潮州市":["湘桥区","潮安区","饶平县"],"揭阳市":["榕城区","揭东区","揭西县","惠来县","普宁市"],"云浮市":["云城区","云安区","新兴县","郁南县","罗定市"]},"广西壮族自治区":{"南宁市":["兴宁区","青秀区","江南区","西乡塘区","良庆区","邕宁区","武鸣区","隆安县","马山县","上林县","宾阳县","横州市"],"柳州市":["城中区","鱼峰区","柳南区","柳北区","柳江区","柳城县","鹿寨县","融安县","融水苗族自治县","三江侗族自治县"],"桂林市":["秀峰区","叠彩区","象山区","七星区","雁山区","临桂区","阳朔县","灵川县","全州县","兴安县","永福县","灌阳县","龙胜各族自治县","资源县","平乐县","恭城瑶族自治县","荔浦市"],"梧州市":["万秀区","长洲区","龙圩区","苍梧县","藤县","蒙山县","岑溪市"],"北海市":["海城区","银海区","铁山港区","合浦县"],"防城港市":["港口区","防城区","上思县","东兴市"],"钦州市":["钦南区","钦北区","灵山县","浦北县"],"贵港市":["港北区","港南区","覃塘区","平南县","桂平市"],"玉林市":["玉州区","福绵区","容县","陆川县","博白县","兴业县","北流市"],"百色市":["右江区","田阳区","田东县","德保县","那坡县","凌云县","乐业县","田林县","西林县","隆林各族自治县","靖西市","平果市"],"贺州市":["八步区","平桂区","昭平县","钟山县","富川瑶族自治县"],"河池市":["金城江区","宜州区","南丹县","天峨县","凤山县","东兰县","罗城仫佬族自治县","环江毛南族自治县","巴马瑶族自治县","都安瑶族自治县","大化瑶族自治县"],"来宾市":["兴宾区","忻城县","象州县","武宣县","金秀瑶族自治县","合山市"],"崇左市":["江州区","扶绥县","宁明县","龙州县","大新县","天等县","凭祥市"]},"海南省":{"海口市":["秀英区","龙华区","琼山区","美兰区"],"三亚市":["海棠区","吉阳区","天涯区","崖州区"],"三沙市":["西沙区","南沙区"],"儋州市":["儋州市"]},"重庆市":{"市辖区":["万州区","涪陵区","渝中区","大渡口区","沙坪坝区","九龙坡区","南岸区","北碚区","綦江区","大足区","巴南区","黔江区","长寿区","江津区","合川区","永川区","南川区","璧山区","铜梁区","潼南区","荣昌区","开州区","梁平区","武隆区","两江新区","城口县","丰都县","垫江县","忠县","云阳县","奉节县","巫山县","巫溪县","石柱土家族自治县","秀山土家族苗族自治县","酉阳土家族苗族自治县","彭水苗族土家族自治县"]},"四川省":{"成都市":["锦江区","青羊区","金牛区","武侯区","成华区","龙泉驿区","青白江区","新都区","温江区","双流区","郫都区","新津区","金堂县","大邑县","蒲江县","都江堰市","彭州市","邛崃市","崇州市","简阳市"],"自贡市":["自流井区","贡井区","大安区","沿滩区","荣县","富顺县"],"攀枝花市":["东区","西区","仁和区","米易县","盐边县"],"泸州市":["江阳区","纳溪区","龙马潭区","泸县","合江县","叙永县","古蔺县"],"德阳市":["旌阳区","罗江区","中江县","广汉市","什邡市","绵竹市"],"绵阳市":["涪城区","游仙区","安州区","三台县","盐亭县","梓潼县","北川羌族自治县","平武县","江油市"],"广元市":["利州区","昭化区","朝天区","旺苍县","青川县","剑阁县","苍溪县"],"遂宁市":["船山区","安居区","蓬溪县","大英县","射洪市"],"内江市":["市中区","东兴区","威远县","资中县","隆昌市"],"乐山市":["市中区","沙湾区","五通桥区","金口河区","犍为县","井研县","夹江县","沐川县","峨边彝族自治县","马边彝族自治县","峨眉山市"],"南充市":["顺庆区","高坪区","嘉陵区","南部县","营山县","蓬安县","仪陇县","西充县","阆中市"],"眉山市":["东坡区","彭山区","仁寿县","洪雅县","丹棱县","青神县"],"宜宾市":["翠屏区","南溪区","叙州区","江安县","长宁县","高县","珙县","筠连县","兴文县","屏山县"],"广安市":["广安区","前锋区","岳池县","武胜县","邻水县","华蓥市"],"达州市":["通川区","达川区","宣汉县","开江县","大竹县","渠县","万源市"],"雅安市":["雨城区","名山区","荥经县","汉源县","石棉县","天全县","芦山县","宝兴县"],"巴中市":["巴州区","恩阳区","通江县","南江县","平昌县"],"资阳市":["雁江区","安岳县","乐至县"],"阿坝藏族羌族自治州":["马尔康市","汶川县","理县","茂县","松潘县","九寨沟县","金川县","小金县","黑水县","壤塘县","阿坝县","若尔盖县","红原县"],"甘孜藏族自治州":["康定市","泸定县","丹巴县","九龙县","雅江县","道孚县","炉霍县","甘孜县","新龙县","德格县","白玉县","石渠县","色达县","理塘县","巴塘县","乡城县","稻城县","得荣县"],"凉山彝族自治州":["西昌市","会理市","木里藏族自治县","盐源县","德昌县","会东县","宁南县","普格县","布拖县","金阳县","昭觉县","喜德县","冕宁县","越西县","甘洛县","美姑县","雷波县"]},"贵州省":{"贵阳市":["南明区","云岩区","花溪区","乌当区","白云区","观山湖区","开阳县","息烽县","修文县","清镇市"],"六盘水市":["钟山区","六枝特区","水城区","盘州市"],"遵义市":["红花岗区","汇川区","播州区","桐梓县","绥阳县","正安县","道真仡佬族苗族自治县","务川仡佬族苗族自治县","凤冈县","湄潭县","余庆县","习水县","赤水市","仁怀市"],"安顺市":["西秀区","平坝区","普定县","镇宁布依族苗族自治县","关岭布依族苗族自治县","紫云苗族布依族自治县"],"毕节市":["七星关区","大方县","金沙县","织金县","纳雍县","威宁彝族回族苗族自治县","赫章县","黔西市"],"铜仁市":["碧江区","万山区","江口县","玉屏侗族自治县","石阡县","思南县","印江土家族自治县","德江县","沿河土家族自治县","松桃苗族自治县"],"黔西南布依族苗族自治州":["兴义市","兴仁市","普安县","晴隆县","贞丰县","望谟县","册亨县","安龙县"],"黔东南苗族侗族自治州":["凯里市","黄平县","施秉县","三穗县","镇远县","岑巩县","天柱县","锦屏县","剑河县","台江县","黎平县","榕江县","从江县","雷山县","麻江县","丹寨县"],"黔南布依族苗族自治州":["都匀市","福泉市","荔波县","贵定县","瓮安县","独山县","平塘县","罗甸县","长顺县","龙里县","惠水县","三都水族自治县"]},"云南省":{"昆明市":["五华区","盘龙区","官渡区","西山区","东川区","呈贡区","晋宁区","富民县","宜良县","石林彝族自治县","嵩明县","禄劝彝族苗族自治县","寻甸回族彝族自治县","安宁市"],"曲靖市":["麒麟区","沾益区","马龙区","陆良县","师宗县","罗平县","富源县","会泽县","宣威市"],"玉溪市":["红塔区","江川区","通海县","华宁县","易门县","峨山彝族自治县","新平彝族傣族自治县","元江哈尼族彝族傣族自治县","澄江市"],"保山市":["隆阳区","施甸县","龙陵县","昌宁县","腾冲市"],"昭通市":["昭阳区","鲁甸县","巧家县","盐津县","大关县","永善县","绥江县","镇雄县","彝良县","威信县","水富市"],"丽江市":["古城区","玉龙纳西族自治县","永胜县","华坪县","宁蒗彝族自治县"],"普洱市":["思茅区","宁洱哈尼族彝族自治县","墨江哈尼族自治县","景东彝族自治县","景谷傣族彝族自治县","镇沅彝族哈尼族拉祜族自治县","江城哈尼族彝族自治县","孟连傣族拉祜族佤族自治县","澜沧拉祜族自治县","西盟佤族自治县"],"临沧市":["临翔区","凤庆县","云县","永德县","镇康县","双江拉祜族佤族布朗族傣族自治县","耿马傣族佤族自治县","沧源佤族自治县"],"楚雄彝族自治州":["楚雄市","禄丰市","双柏县","牟定县","南华县","姚安县","大姚县","永仁县","元谋县","武定县"],"红河哈尼族彝族自治州":["个旧市","开远市","蒙自市","弥勒市","屏边苗族自治县","建水县","石屏县","泸西县","元阳县","红河县","金平苗族瑶族傣族自治县","绿春县","河口瑶族自治县"],"文山壮族苗族自治州":["文山市","砚山县","西畴县","麻栗坡县","马关县","丘北县","广南县","富宁县"],"西双版纳傣族自治州":["景洪市","勐海县","勐腊县"],"大理白族自治州":["大理市","漾濞彝族自治县","祥云县","宾川县","弥渡县","南涧彝族自治县","巍山彝族回族自治县","永平县","云龙县","洱源县","剑川县","鹤庆县"],"德宏傣族景颇族自治州":["瑞丽市","芒市","梁河县","盈江县","陇川县"],"怒江傈僳族自治州":["泸水市","福贡县","贡山独龙族怒族自治县","兰坪白族普米族自治县"],"迪庆藏族自治州":["香格里拉市","德钦县","维西傈僳族自治县"]},"西藏自治区":{"拉萨市":["城关区","堆龙德庆区","达孜区","林周县","当雄县","尼木县","曲水县","墨竹工卡县"],"日喀则市":["桑珠孜区","南木林县","江孜县","定日县","萨迦县","拉孜县","昂仁县","谢通门县","白朗县","仁布县","康马县","定结县","仲巴县","亚东县","吉隆县","聂拉木县","萨嘎县","岗巴县"],"昌都市":["卡若区","江达县","贡觉县","类乌齐县","丁青县","察雅县","八宿县","左贡县","芒康县","洛隆县","边坝县"],"林芝市":["巴宜区","工布江达县","墨脱县","波密县","察隅县","朗县","米林市"],"山南市":["乃东区","扎囊县","贡嘎县","桑日县","琼结县","曲松县","措美县","洛扎县","加查县","隆子县","浪卡子县","错那市"],"那曲市":["色尼区","嘉黎县","比如县","聂荣县","安多县","申扎县","索县","班戈县","巴青县","尼玛县","双湖县"],"阿里地区":["普兰县","札达县","噶尔县","日土县","革吉县","改则县","措勤县"]},"陕西省":{"西安市":["新城区","碑林区","莲湖区","灞桥区","未央区","雁塔区","阎良区","临潼区","长安区","高陵区","鄠邑区","蓝田县","周至县"],"铜川市":["王益区","印台区","耀州区","宜君县"],"宝鸡市":["渭滨区","金台区","陈仓区","凤翔区","岐山县","扶风县","眉县","陇县","千阳县","麟游县","凤县","太白县"],"咸阳市":["秦都区","杨陵区","渭城区","三原县","泾阳县","乾县","礼泉县","永寿县","长武县","旬邑县","淳化县","武功县","兴平市","彬州市"],"渭南市":["临渭区","华州区","潼关县","大荔县","合阳县","澄城县","蒲城县","白水县","富平县","韩城市","华阴市"],"延安市":["宝塔区","安塞区","延长县","延川县","志丹县","吴起县","甘泉县","富县","洛川县","宜川县","黄龙县","黄陵县","子长市"],"汉中市":["汉台区","南郑区","城固县","洋县","西乡县","勉县","宁强县","略阳县","镇巴县","留坝县","佛坪县"],"榆林市":["榆阳区","横山区","府谷县","靖边县","定边县","绥德县","米脂县","佳县","吴堡县","清涧县","子洲县","神木市"],"安康市":["汉滨区","汉阴县","石泉县","宁陕县","紫阳县","岚皋县","平利县","镇坪县","白河县","旬阳市"],"商洛市":["商州区","洛南县","丹凤县","商南县","山阳县","镇安县","柞水县"]},"甘肃省":{"兰州市":["城关区","七里河区","西固区","安宁区","红古区","永登县","皋兰县","榆中县"],"嘉峪关市":["嘉峪关市"],"金昌市":["金川区","永昌县"],"白银市":["白银区","平川区","靖远县","会宁县","景泰县"],"天水市":["秦州区","麦积区","清水县","秦安县","甘谷县","武山县","张家川回族自治县"],"武威市":["凉州区","民勤县","古浪县","天祝藏族自治县"],"张掖市":["甘州区","肃南裕固族自治县","民乐县","临泽县","高台县","山丹县"],"平凉市":["崆峒区","泾川县","灵台县","崇信县","庄浪县","静宁县","华亭市"],"酒泉市":["肃州区","金塔县","瓜州县","肃北蒙古族自治县","阿克塞哈萨克族自治县","玉门市","敦煌市"],"庆阳市":["西峰区","庆城县","环县","华池县","合水县","正宁县","宁县","镇原县"],"定西市":["安定区","通渭县","陇西县","渭源县","临洮县","漳县","岷县"],"陇南市":["武都区","成县","文县","宕昌县","康县","西和县","礼县","徽县","两当县"],"临夏回族自治州":["临夏市","临夏县","康乐县","永靖县","广河县","和政县","东乡族自治县","积石山保安族东乡族撒拉族自治县"],"甘南藏族自治州":["合作市","临潭县","卓尼县","舟曲县","迭部县","玛曲县","碌曲县","夏河县"]},"青海省":{"西宁市":["城东区","城中区","城西区","城北区","湟中区","湟源县","大通回族土族自治县"],"海东市":["乐都区","平安区","民和回族土族自治县","互助土族自治县","化隆回族自治县","循化撒拉族自治县"],"海北藏族自治州":["门源回族自治县","祁连县","海晏县","刚察县"],"黄南藏族自治州":["同仁市","尖扎县","泽库县","河南蒙古族自治县"],"海南藏族自治州":["共和县","同德县","贵德县","兴海县","贵南县"],"果洛藏族自治州":["玛沁县","班玛县","甘德县","达日县","久治县","玛多县"],"玉树藏族自治州":["玉树市","杂多县","称多县","治多县","囊谦县","曲麻莱县"],"海西蒙古族藏族自治州":["格尔木市","德令哈市","茫崖市","乌兰县","都兰县","天峻县"]},"宁夏回族自治区":{"银川市":["兴庆区","西夏区","金凤区","永宁县","贺兰县","灵武市"],"石嘴山市":["大武口区","惠农区","平罗县"],"吴忠市":["利通区","红寺堡区","盐池县","同心县","青铜峡市"],"固原市":["原州区","西吉县","隆德县","泾源县","彭阳县"],"中卫市":["沙坡头区","中宁县","海原县"]},"新疆维吾尔自治区":{"乌鲁木齐市":["天山区","沙依巴克区","新市区","水磨沟区","头屯河区","达坂城区","米东区","乌鲁木齐县"],"克拉玛依市":["独山子区","克拉玛依区","白碱滩区","乌尔禾区"],"吐鲁番市":["高昌区","鄯善县","托克逊县"],"哈密市":["伊州区","巴里坤哈萨克自治县","伊吾县"],"昌吉回族自治州":["昌吉市","阜康市","呼图壁县","玛纳斯县","奇台县","吉木萨尔县","木垒哈萨克自治县"],"博尔塔拉蒙古自治州":["博乐市","阿拉山口市","精河县","温泉县"],"巴音郭楞蒙古自治州":["库尔勒市","轮台县","尉犁县","若羌县","且末县","焉耆回族自治县","和静县","和硕县","博湖县"],"阿克苏地区":["阿克苏市","温宿县","库车市","沙雅县","新和县","拜城县","乌什县","阿瓦提县","柯坪县"],"克孜勒苏柯尔克孜自治州":["阿图什市","阿克陶县","阿合奇县","乌恰县"],"喀什地区":["喀什市","疏附县","疏勒县","英吉沙县","泽普县","莎车县","叶城县","麦盖提县","岳普湖县","伽师县","巴楚县","塔什库尔干塔吉克自治县"],"和田地区":["和田市","和田县","墨玉县","皮山县","洛浦县","策勒县","于田县","民丰县"],"伊犁哈萨克自治州":["伊宁市","奎屯市","霍尔果斯市","伊宁县","察布查尔锡伯自治县","霍城县","巩留县","新源县","昭苏县","特克斯县","尼勒克县"],"塔城地区":["塔城市","乌苏市","沙湾市","额敏县","托里县","裕民县","和布克赛尔蒙古自治县"],"阿勒泰地区":["阿勒泰市","布尔津县","富蕴县","福海县","哈巴河县","青河县","吉木乃县"],"自治区直辖县级行政区划":["石河子市","阿拉尔市","图木舒克市","五家渠市","北屯市","铁门关市","双河市","可克达拉市","昆玉市","胡杨河市","新星市","白杨市"]},"香港特别行政区":{"香港特别行政区":[]},"澳门特别行政区":{"澳门特别行政区":[]},"台湾省":{"台湾省":[]}};
let _map=null,_measureMode=false,_measurePts=[],_mapLayer=null,_measureLayer=null,_measures=[],_pendingMarker=null,_measureSeq=0,mmFromId='',mmToId='',_mmPendingName='',_mmLastPair='',_mmLastD=0,_mmLastId='',mapTab='map';let mapFilter={kw:'',dealer:'',stage:[],intent:''};let cfComposing=false;
let _lastGeoQuery=null,_nearestLayer=null;let _lastMapView=null;/* 第480轮:离开地图页时的视图(中心+缩放),切页回来原样恢复 */
function injectMapStyles(){if(document.getElementById('mapStyle'))return;const s=document.createElement('style');s.id='mapStyle';s.textContent='#map{position:relative;height:70vh;width:100%;border-radius:12px;border:1px solid #e2e8f0}body.map-full{overflow:hidden}body.map-full #map{position:fixed!important;top:0!important;left:0!important;right:0!important;bottom:0!important;width:auto!important;height:auto!important;border-radius:0!important;border:0!important;z-index:250!important}.map-fs-btn{position:absolute;right:12px;top:12px;z-index:1100;display:inline-flex;align-items:center;height:38px;padding:0 14px;border:1px solid #e2e8f0;border-radius:10px;background:rgba(255,255,255,.97);color:#334155;font-size:13px;font-family:inherit;font-weight:600;line-height:1;cursor:pointer;box-shadow:0 2px 8px rgba(15,23,42,.14);transition:background 160ms,color 160ms}.map-fs-btn:hover{background:#f1f5f9;color:#0f172a}body.map-full .map-fs-btn{position:fixed;right:16px;top:16px;z-index:1102}@media(max-width:820px){.map-fs-btn{right:10px;top:10px;height:36px;padding:0 12px;font-size:12.5px}body.map-full .map-fs-btn{right:10px;top:calc(10px + env(safe-area-inset-top))}}.leaflet-popup-content{font-size:13px;margin:12px 14px}.leaflet-popup-content-wrapper{border-radius:12px;box-shadow:0 8px 24px rgba(15,23,42,.18)}.mm-dot{width:14px;height:14px;border-radius:50%;background:#8b5cf6;border:2px solid #fff;box-shadow:0 0 0 1.5px #7c3aed}.mm-dot-wrap{background:transparent!important;border:none!important}.mm-lbl{background:rgba(255,255,255,.97);border:1px solid #c4b5fd;color:#5b21b6;font-size:12px;font-weight:600;padding:2px 7px;border-radius:8px;box-shadow:0 1px 4px rgba(15,23,42,.14)}.mm-lbl:before{display:none}.mm-name{background:rgba(255,255,255,.97);border:1px solid #c4b5fd;color:#5b21b6;font-size:12px;padding:2px 7px;border-radius:8px;white-space:nowrap}.mm-name:before{display:none}.unloc-item{display:flex;justify-content:space-between;align-items:center;padding:8px 10px;border-bottom:1px solid #e2e8f0;font-size:12.5px}.map-flag-wrap{background:transparent!important;border:none!important}.flag-pin{font-size:30px;line-height:36px;text-align:center;filter:drop-shadow(0 2px 3px rgba(0,0,0,.45)) drop-shadow(0 0 2px #fff);text-shadow:0 0 2px #fff, 1px 0 0 #fff, -1px 0 0 #fff, 0 1px 0 #fff, 0 -1px 0 #fff}.map-popup{min-width:200px}.map-popup .mp-title{display:flex;align-items:center;gap:6px;font-size:14.5px;font-weight:700;color:#0f172a;padding-bottom:8px;margin-bottom:2px;border-bottom:1px solid #e2e8f0}.map-popup .mp-row{display:flex;justify-content:space-between;align-items:center;gap:16px;padding:5px 0}.map-popup .mp-row+.mp-row{border-top:1px dashed #f1f5f9}.map-popup .mp-k{color:#94a3b8;font-size:12px;flex-shrink:0;white-space:nowrap}.map-popup .mp-v{color:#334155;font-size:12.5px;font-weight:600;text-align:right;word-break:break-all}.cf-bar{display:flex;align-items:center;gap:8px;flex-wrap:wrap;background:var(--panel);border:1px solid var(--line);border-radius:var(--r-lg);padding:10px 12px;margin-bottom:14px;box-shadow:var(--shadow-sm)}.cf-bar input,.cf-bar select{height:38px;padding:8px 12px;border:1px solid var(--line);border-radius:9px;font-size:13px;font-family:inherit;background:#fff;color:#334155;box-sizing:border-box;transition:border-color 160ms var(--ease),box-shadow 160ms var(--ease)}.cf-bar input:focus,.cf-bar select:focus{outline:none;border-color:var(--ring);box-shadow:0 0 0 3px var(--primary-soft)}.cf-bar .cf-kw{flex:1;min-width:220px}.cf-bar .cf-sel{flex:0 0 168px;min-width:0;cursor:pointer}.cf-bar .cf-num{flex:0 0 64px;width:64px;padding:0 8px;text-align:center}.cf-bar .cf-r{display:flex;align-items:center;gap:6px;color:#475569;font-size:13px;white-space:nowrap}.cf-bar .cf-info{font-size:12.5px;color:var(--muted);white-space:nowrap;margin-left:auto}@media(max-width:820px){.cf-bar{padding:10px}.cf-bar .cf-kw{flex:1 1 100%}.cf-bar .cf-sel{flex:1 1 calc(50% - 4px)}.cf-bar .cf-info{margin-left:0}.cf-bar input,.cf-bar select{height:42px;font-size:15px}}.cf-msel{position:relative;flex:0 0 168px;min-width:0}.cf-bar .cf-msel-btn{width:100%;height:38px;padding:8px 26px 8px 12px;border:1px solid var(--line);border-radius:9px;font-size:13px;font-family:inherit;background:#fff;color:#334155;box-sizing:border-box;cursor:pointer;text-align:left;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;position:relative}.cf-msel-btn::after{content:"";position:absolute;right:11px;top:50%;margin-top:-2px;width:0;height:0;border-left:4px solid transparent;border-right:4px solid transparent;border-top:5px solid #94a3b8}.cf-bar .cf-msel-btn:focus{outline:none;border-color:var(--ring);box-shadow:0 0 0 3px var(--primary-soft)}.cf-msel-panel{display:none;position:absolute;z-index:1500;top:calc(100% + 4px);left:0;min-width:100%;max-height:272px;overflow:auto;background:#fff;border:1px solid var(--line);border-radius:10px;box-shadow:0 12px 32px rgba(15,23,42,.14);padding:6px}.cf-msel.open .cf-msel-panel{display:block}.cf-bar .cf-msel-panel .cf-opt{display:flex;align-items:center;gap:8px;padding:8px 10px;border-radius:7px;font-size:13px;color:#334155;cursor:pointer;white-space:nowrap}.cf-bar .cf-msel-panel .cf-opt:hover{background:#f1f5f9}.cf-bar .cf-msel-panel .cf-opt input{height:auto;min-height:0;padding:0;margin:0;flex:0 0 auto;cursor:pointer}.cf-msel-foot{display:flex;gap:14px;padding:8px 0 4px;margin-top:4px;border-top:1px solid var(--line);font-size:12.5px}.cf-msel-foot a{color:#0284c7;font-weight:600;text-decoration:none}@media(max-width:820px){.cf-msel{flex:1 1 calc(50% - 4px)}.cf-bar .cf-msel-btn{height:42px;font-size:15px}}';document.head.appendChild(s);}
function wgs84ToGcj02(lat,lng){const a=6378245.0,ee=0.00669342162296594323;function outOfChina(la,ln){return ln<72.004||ln>137.8347||la<0.8293||la>55.8271;}function tLat(x,y){let r=-100.0+2.0*x+3.0*y+0.2*y*y+0.1*x*y+0.2*Math.sqrt(Math.abs(x));r+=(20.0*Math.sin(6.0*x*Math.PI)+20.0*Math.sin(2.0*x*Math.PI))*2.0/3.0;r+=(20.0*Math.sin(y*Math.PI)+40.0*Math.sin(y/3.0*Math.PI))*2.0/3.0;r+=(160.0*Math.sin(y/12.0*Math.PI)+320*Math.sin(y*Math.PI/30.0))*2.0/3.0;return r;}function tLng(x,y){let r=300.0+x+2.0*y+0.1*x*x+0.1*x*y+0.1*Math.sqrt(Math.abs(x));r+=(20.0*Math.sin(6.0*x*Math.PI)+20.0*Math.sin(2.0*x*Math.PI))*2.0/3.0;r+=(20.0*Math.sin(x*Math.PI)+40.0*Math.sin(x/3.0*Math.PI))*2.0/3.0;r+=(150.0*Math.sin(x/12.0*Math.PI)+300.0*Math.sin(x/30.0*Math.PI))*2.0/3.0;return r;}if(outOfChina(lat,lng))return{lat:lat,lng:lng};let dLat=tLat(lng-105.0,lat-35.0);let dLng=tLng(lng-105.0,lat-35.0);const radLat=lat/180.0*Math.PI;let magic=Math.sin(radLat);magic=1-ee*magic*magic;const sqrtMagic=Math.sqrt(magic);dLat=(dLat*180.0)/((a*(1-ee))/(magic*sqrtMagic)*Math.PI);dLng=(dLng*180.0)/(a/sqrtMagic*Math.cos(radLat)*Math.PI);return{lat:lat+dLat,lng:lng+dLng};}
function g2(lat,lng){const p=wgs84ToGcj02(lat,lng);return [p.lat,p.lng];}
function haversine(a,b){const R=6371;const dLat=(b.lat-a.lat)*Math.PI/180;const dLng=(b.lng-a.lng)*Math.PI/180;const la=a.lat*Math.PI/180;const lb=b.lat*Math.PI/180;const h=Math.sin(dLat/2)*Math.sin(dLat/2)+Math.cos(la)*Math.cos(lb)*Math.sin(dLng/2)*Math.sin(dLng/2);return 2*R*Math.asin(Math.sqrt(h));}
function isValidCoord(c){const lat=Number(c&&c.lat),lng=Number(c&&c.lng);if(!isFinite(lat)||!isFinite(lng))return false;if(lat===0&&lng===0)return false;if(lat<-90||lat>90||lng<-180||lng>180)return false;return true;}
function mapClearOverlays(){if(_mapLayer){_map.removeLayer(_mapLayer);}_mapLayer=L.layerGroup().addTo(_map);return _mapLayer;}
function ensureMeasureLayer(){if(!_measureLayer){_measureLayer=L.layerGroup().addTo(_map);}return _measureLayer;}
function mapDeleteMeasure(id){const i=_measures.findIndex(m=>m.id===id);if(i<0)return;if(_measureLayer)_measureLayer.removeLayer(_measures[i].layer);_measures.splice(i,1);_mmLastPair='';_mmLastD=0;_mmLastId='';toast('已删除该测量结果');}
function mapClearMeasures(){if(_measureLayer)_measureLayer.clearLayers();_measures=[];_measurePts=[];_pendingMarker=null;_mmPendingName='';_mmLastPair='';_mmLastD=0;_mmLastId='';mmPanelRender();toast('已清除全部测距结果');}
function fmtMapDist(km){const v=Number(km);if(!isFinite(v))return '—';const m=Math.round(v*1000);if(m<1000)return m+' 米';return (m/1000).toFixed(2)+' 公里('+m+' 米)';}
function mmDotIcon(){return L.divIcon({className:'mm-dot-wrap',html:'<div class="mm-dot"></div>',iconSize:[14,14],iconAnchor:[7,7]});}
function mmPxInfo(){const mv=mapLive();if(!mv)return null;const z=mv.getZoom();const lat=mv.getCenter().lat;const m=156543.03392*Math.cos(lat*Math.PI/180)/Math.pow(2,z);return {z:z,m:m};}
function mmTipHtml(){const p=mmPxInfo();if(!p)return '';const mm=p.m<10?p.m.toFixed(1):String(Math.round(p.m));if(p.z<14)return '📍 当前缩放 '+p.z+' 级 · 1 像素 ≈ '+mm+' 米 · <span style="color:#b45309;font-weight:600">建议放大到 14 级以上,或直接用上方「选客户」</span>';return '📍 当前缩放 '+p.z+' 级 · 1 像素 ≈ '+mm+' 米 · 精度良好';}
function mapSnapCustomer(ll){const mv=mapLive();if(!mv||!ll)return null;if(mv.getZoom()<10)return null;const pt=mv.latLngToContainerPoint(ll);const list=cfLocated();let best=null,bestD=1e9;for(let i=0;i<list.length;i++){const c=list[i];if(!isValidCoord(c))continue;const q=g2(c.lat,c.lng);const cp=mv.latLngToContainerPoint(q);const dx=cp.x-pt.x,dy=cp.y-pt.y;const dd=Math.sqrt(dx*dx+dy*dy);if(dd<bestD){bestD=dd;best={c:c,q:q};}}if(!best)return null;if(bestD>22)return null;return {lat:best.q[0],lng:best.q[1],name:String(best.c.school||best.c.name||'未命名'),px:bestD};}
function mmCustomerByName(name){const v=String(name||'').trim();if(!v)return null;const list=cfLocated();for(let i=0;i<list.length;i++){const c=list[i];if(String(c.school||c.name||'').trim()===v)return c;}return null;}
function mmPopupBody(id,d){return '两点间距 <b>'+fmtMapDist(d)+'</b><br><a href="javascript:void(0)" onclick="mapDeleteMeasure(\''+id+'\')" style="color:#dc2626;font-weight:600;text-decoration:none">🗑️ 删除此测量</a> · <a href="javascript:void(0)" onclick="mapCopyMeasure(\''+id+'\')" style="color:#0284c7;font-weight:600;text-decoration:none">📋 复制</a>';}
function mmPanelHtml(){if(!_measureMode)return '';const loc=cfLocated();let opts='';for(let i=0;i<loc.length;i++){const c=loc[i];opts+='<option value="'+esc(String(c.school||c.name||'—'))+'"></option>';}return '<div class="card" style="padding:12px 16px;margin-bottom:12px;border-left:4px solid #7c3aed"><div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap"><span style="font-size:13px;font-weight:600;color:#5b21b6;white-space:nowrap">📏 测距</span><input id="mmFrom" list="mmCustOpts" placeholder="起点客户(可搜索)" autocomplete="off" oninput="mmPick(\'from\',1)" onchange="mmPick(\'from\')" style="height:34px;box-sizing:border-box;border:1px solid var(--line);border-radius:9px;padding:0 10px;font-size:13px;width:190px"><span style="color:#94a3b8">→</span><input id="mmTo" list="mmCustOpts" placeholder="终点客户(可搜索)" autocomplete="off" oninput="mmPick(\'to\',1)" onchange="mmPick(\'to\')" style="height:34px;box-sizing:border-box;border:1px solid var(--line);border-radius:9px;padding:0 10px;font-size:13px;width:190px"><button class="btn" onclick="mmRun()">算距离</button><span id="mmResult" style="font-size:13.5px;font-weight:700;color:#0f172a"></span></div><div id="mmTip" style="margin-top:6px;font-size:12px;color:#64748b">'+mmTipHtml()+'</div><datalist id="mmCustOpts">'+opts+'</datalist></div>';}
function mmPanelRender(){const box=document.getElementById('mapMeasureCard');if(!box)return;box.innerHTML=mmPanelHtml();try{if(_map&&!_map._mmZoomBound){_map._mmZoomBound=1;_map.on('zoomend',function(){const t=document.getElementById('mmTip');if(t)t.innerHTML=mmTipHtml();});}}catch(e){}}
function mmPick(which,quiet){const el=document.getElementById(which==='from'?'mmFrom':'mmTo');if(!el)return;const c=mmCustomerByName(el.value);if(!c){if(!quiet){toast('未找到客户「'+el.value+'」,请从下拉里选');el.value='';}return;}if(which==='from'){mmFromId=c.id;}else{mmToId=c.id;}if(mmFromId&&mmToId)mmRun();}
function mmRun(){const box=document.getElementById('mmResult');if(!mmFromId||!mmToId){if(box)box.innerHTML='<span style="color:#64748b;font-weight:400">请选择起点和终点客户</span>';return;}if(mmFromId===mmToId){if(box)box.innerHTML='<span style="color:#dc2626">起点和终点是同一家</span>';return;}const a=DB.customers.find(function(x){return x.id===mmFromId;});const b=DB.customers.find(function(x){return x.id===mmToId;});if(!a||!b){if(box)box.innerHTML='<span style="color:#dc2626">客户已被删除,请重选</span>';return;}const _k=mmFromId+'|'+mmToId;if(_mmLastPair===_k){if(box)box.innerHTML=fmtMapDist(_mmLastD);return;}if(_mmLastId){const _oi=_measures.findIndex(function(x){return x.id===_mmLastId;});if(_oi>=0){try{_measureLayer.removeLayer(_measures[_oi].layer);}catch(e){}_measures.splice(_oi,1);}_mmLastId='';}const ga=g2(a.lat,a.lng),gb=g2(b.lat,b.lng);const d=haversine({lat:ga[0],lng:ga[1]},{lat:gb[0],lng:gb[1]});const id='m'+(++_measureSeq);const names={a:String(a.school||a.name||''),b:String(b.school||b.name||'')};const sub=mapDrawMeasureLine(id,{lat:ga[0],lng:ga[1]},{lat:gb[0],lng:gb[1]},d,false,names);_measures.push({id:id,layer:sub,pts:[{lat:ga[0],lng:ga[1]},{lat:gb[0],lng:gb[1]}],d:d,names:names});if(box)box.innerHTML=fmtMapDist(d);_mmLastPair=_k;_mmLastD=d;_mmLastId=id;const mv=mapLive();if(mv)try{mv.fitBounds([[ga[0],ga[1]],[gb[0],gb[1]]],{padding:[70,70],maxZoom:14});}catch(e){}toast('「'+names.a+'」→「'+names.b+'」直线 '+fmtMapDist(d));}
function mapCopyMeasure(id){const m=_measures.find(function(x){return x.id===id;});if(!m)return;const txt='两地直线距离 '+fmtMapDist(m.d);try{if(navigator.clipboard&&navigator.clipboard.writeText){navigator.clipboard.writeText(txt).then(function(){toast('已复制:'+txt);},function(){toast(txt);});}else{toast(txt);}}catch(e){toast(txt);}}
function mapFlagIcon(){return L.divIcon({className:'map-flag-wrap',html:'<div class="flag-pin">🚩</div>',iconSize:[36,36],iconAnchor:[7,34],popupAnchor:[3,-28]});}
function mapPopupHTML(c){const stg=STAGES[c.stage]||{label:c.stage||'—',cls:'st-lost'};const intent=(Array.isArray(c.intent)&&c.intent.length)?c.intent.join(' / '):(c.intent||'未确定');const school=c.school||c.name||'未填校区';return '<div class="map-popup"><div class="mp-title">🏫 '+esc(school)+'</div><div class="mp-row"><span class="mp-k">联系人</span><span class="mp-v">'+esc(c.name||'—')+'</span></div><div class="mp-row"><span class="mp-k">客户状态</span><span class="mp-v"><span class="st '+stg.cls+'">'+esc(stg.label)+'</span></span></div><div class="mp-row"><span class="mp-k">合作项目</span><span class="mp-v">'+esc(intent)+'</span></div></div>';}
// ===== 客户地图 / 区域冲突:共享搜索筛选(2026-09-15)=====
// 设计约束(沿用项目既有教训):
// ① 筛选条只在 renderMap/renderRegionConflict 首次渲染时构建;
// 之后一律只局部刷新(#cfList / #mapUnlocWrap / #mapStats / 标记图层),
// 零 DOM 重建 ⇒ 输入框不丢焦点、中文输入法组字期间不断字。
// ② mapFilter 全空时 cfMatch 直接短路 return true ⇒ 默认口径与原实现逐字节一致。
function cfDealerName(id){if(!id)return '';const d=(DB.dealers||[]).find(x=>x.id===id);return d?d.name:'';}
function cfActive(){const f=mapFilter;return !!(String(f.kw||'').trim()||f.dealer||(cfStageSel().length&&!cfStageAll())||f.intent);}
function cfMatch(c){
if(!c)return false;
if(!cfActive())return true;
const f=mapFilter;
if(f.dealer&&c.dealerId!==f.dealer)return false;
const sels=cfStageSel();if(sels.length&&!cfStageAll()&&sels.indexOf(c.stage||'')<0)return false;
if(f.intent){const its=Array.isArray(c.intent)?c.intent:(c.intent?[c.intent]:[]);if(its.indexOf(f.intent)<0)return false;}
const kw=String(f.kw||'').trim().toLowerCase();
if(kw){const hay=[c.name,c.school,c.phone,c.prov,c.city,c.district,c.addr,cfDealerName(c.dealerId)].filter(Boolean).join(' ').toLowerCase();if(hay.indexOf(kw)<0)return false;}
return true;
}
function cfLocated(all){return (all||DB.customers).filter(isValidCoord).filter(cfMatch);}
function cfUnlocated(){return DB.customers.filter(c=>!isValidCoord(c)).filter(cfMatch);}
function cfStatsText(){
const all=DB.customers.length;const ln=cfLocated().length;const un=cfUnlocated().length;
const base='共 '+all+' 个客户 · 已定位 '+ln+' · 未定位 '+un;
return cfActive()?(base+' · 🔎 已按筛选显示(命中 '+(ln+un)+' 家)'):base;
}
function cfMatchInfoText(){if(!cfActive())return "";return "命中 "+cfLocated().length+" 家已定位客户";}
// 全库已定位(不受筛选影响):筛选时作为「另一方」参与比对
function cfAllLocated(){return DB.customers.filter(isValidCoord);}
function cfPairs(located,radius){
// 口径(2026-09-15 用户确认):命中客户作为一方,与全库所有已定位客户两两比较 ⇒ 涉事任一方命中即列出。
// 无筛选时 all===located,去重后与旧「i<j 双重循环」的结果集合、排序顺序逐项一致(零回归)。
const all=cfActive()?cfAllLocated():located;
const seen=Object.create(null);const pairs=[];
for(let i=0;i<located.length;i++){const a=located[i];for(let j=0;j<all.length;j++){const b=all[j];if(a.id===b.id)continue;const ka=a.id,kb=b.id;const k=ka<kb?(ka+'|'+kb):(kb+'|'+ka);if(seen[k])continue;seen[k]=1;const d=haversine({lat:a.lat,lng:a.lng},{lat:b.lat,lng:b.lng});if(d<=radius){pairs.push({a,b,d});}}}
pairs.sort((x,y)=>x.d-y.d);
return pairs;
}
function cfStatsConflict(pairs,located,radius){
let t='以客户为中心半径 '+radius+' 公里内存在其他客户,共 '+pairs.length+' 组冲突';
if(cfActive())t+='(已筛选:'+located.length+' 家命中客户 × 全库 '+cfAllLocated().length+' 家已定位客户)';
return t;
}
// 命中片段:与 cfMatch 使用完全相同的字段拼接顺序 ⇒ 命中必有据可循(零口径分叉)
function cfMatchHint(c){const kw=String(mapFilter.kw||'').trim();if(!kw)return '';
const full=[c.name,c.school,c.phone,c.prov,c.city,c.district,c.addr,cfDealerName(c.dealerId)].filter(Boolean).join(' ');
const low=kw.toLowerCase();const i=full.toLowerCase().indexOf(low);if(i<0)return '';
const s=Math.max(0,i-6);const e=Math.min(full.length,i+kw.length+6);
return '<div class="mtxt" style="margin-top:3px">🔎 命中 '+(s>0?'…':'')+esc(full.slice(s,e))+(e<full.length?'…':'')+'</div>';}
function cfRowsHTML(pairs,radius){
if(!pairs.length){
if(cfActive())return '<div class="empty" style="padding:40px;text-align:center;color:#94a3b8">🔎 当前筛选条件下暂无区域冲突(已比对全库 '+cfAllLocated().length+' 家已定位客户) · <a href="javascript:void(0)" onclick="cfReset()" style="color:#0284c7;font-weight:600">清除筛选</a></div>';
return '<div class="empty" style="padding:40px;text-align:center;color:#94a3b8">🎉 暂无区域冲突客户(半径 '+radius+' 公里内无重叠)</div>';
}
return pairs.map(p=>{const sa=STAGES[p.a.stage]||{label:p.a.stage||'—',cls:'st-lost'};const sb=STAGES[p.b.stage]||{label:p.b.stage||'—',cls:'st-lost'};const ia=(Array.isArray(p.a.intent)&&p.a.intent.length)?p.a.intent.join(' / '):(p.a.intent||'未确定');const ib=(Array.isArray(p.b.intent)&&p.b.intent.length)?p.b.intent.join(' / '):(p.b.intent||'未确定');return '<div class="card" style="padding:14px 16px;display:flex;gap:14px;align-items:center;flex-wrap:wrap"><div style="flex:1;min-width:220px"><div style="font-weight:700;color:#0f172a;font-size:14px">🏫 '+esc(p.a.school||p.a.name)+'</div><div class="mtxt" style="margin-top:3px">联系人:'+esc(p.a.name||'—')+'</div>'+cfMatchHint(p.a)+'<div style="margin-top:5px"><span class="st '+sa.cls+'">'+esc(sa.label)+'</span> <span class="mtxt">合作项目:'+esc(ia)+'</span></div></div><div style="display:flex;align-items:center;gap:6px;color:#dc2626;font-weight:700;font-size:13px">↔ '+p.d.toFixed(1)+' 公里</div><div style="flex:1;min-width:220px"><div style="font-weight:700;color:#0f172a;font-size:14px">🏫 '+esc(p.b.school||p.b.name)+'</div><div class="mtxt" style="margin-top:3px">联系人:'+esc(p.b.name||'—')+'</div>'+cfMatchHint(p.b)+'<div style="margin-top:5px"><span class="st '+sb.cls+'">'+esc(sb.label)+'</span> <span class="mtxt">合作项目:'+esc(ib)+'</span></div></div></div>';}).join('');
}
let _unlocReason=null;
function unlocReasonItems(g){return (g.items||[]).filter(it=>{const c=DB.customers.find(x=>x.id===it.id);return c&&cfMatch(c);});}
function unlocGroupCard(g){
const items=unlocReasonItems(g);
const rows=items.map(it=>{
const c=DB.customers.find(x=>x.id===it.id)||{id:it.id,name:it.name};
const fail=it.geoFail?('<span class="mtxt">(已失败 '+it.geoFail+' 次'+(it.geoFailErr?':'+esc(it.geoFailErr):'')+')</span>'):'';
return '<div class="unloc-item"><span>'+esc(it.school||it.name)+' · '+addrView(c)+fail+'</span>'+'<span style="display:flex;align-items:center;gap:8px;white-space:nowrap"><span class="mtxt">'+esc(it.dealerName||'—')+'</span><a href="javascript:void(0)" onclick="openCustomer(\''+it.id+'\')" style="color:#0284c7;font-weight:600">去补地址</a></span></div>';
}).join('');
return '<div class="card" style="margin-top:10px;padding:12px 14px"><h4 style="font-size:13.5px;margin:0 0 3px">'+esc(g.label)+'('+items.length+')</h4><div class="mtxt" style="margin-bottom:'+(items.length?'6px':'0')+'">'+esc(g.hint)+'</div>'+(items.length?('<div>'+rows+'</div>'):'<div class="mtxt">当前无此类客户</div>')+'</div>';
}
function cfUnlocHTML(){
const un=cfUnlocated();
if(!_unlocReason||_unlocReason.rev!==DB.rev){return '<div class="card" style="margin-top:14px;padding:12px 14px"><h4 style="font-size:14px;margin:0">未定位客户('+un.length+')</h4><div class="mtxt" style="margin-top:4px">原因分型加载中…</div></div>';}
const d=_unlocReason.data||{};const gs=d.groups||[];
const shown=gs.reduce((s,g)=>s+unlocReasonItems(g).length,0);
return '<div style="margin-top:14px;display:flex;align-items:baseline;gap:8px;flex-wrap:wrap"><h4 style="font-size:14px;margin:0">未定位客户分型</h4><span class="mtxt">共 '+d.total+' 家未定位'+(cfActive()?(' · 已按筛选显示 '+shown+' 家'):'')+' · 按「该谁处理」分组 · 点「去补地址」直接进客户详情</span></div>'+gs.map(unlocGroupCard).join('');
}
function mapLoadUnlocReason(){apiGet('/api/geo/unloc-reason').then(r=>{if(!r||!r.groups)return;_unlocReason={rev:DB.rev,data:r};const w=document.getElementById('mapUnlocWrap');if(w)w.innerHTML=cfUnlocHTML();}).catch(()=>{});}
function cfBarHTML(withRadius){
const f=mapFilter;
const dOpts=(DB.dealers||[]).map(d=>'<option value="'+esc(d.id)+'"'+(f.dealer===d.id?' selected':'')+'>'+esc(d.name)+'</option>').join('');
const sels=Array.isArray(f.stage)?f.stage:[];const sOpts=Object.keys(STAGES).map(k=>'<label class="cf-opt"><input type="checkbox" value="'+esc(k)+'"'+(sels.indexOf(k)>=0?' checked':'')+' onchange="cfToggleStage(this.value,this.checked)"><span>'+esc(STAGES[k].label)+'</span></label>').join('');
const iSet=[];DB.customers.forEach(c=>{(Array.isArray(c.intent)?c.intent:(c.intent?[c.intent]:[])).forEach(x=>{if(x&&iSet.indexOf(x)<0)iSet.push(x);});});
const iOpts=iSet.map(x=>'<option value="'+esc(x)+'"'+(f.intent===x?' selected':'')+'>'+esc(x)+'</option>').join('');
const rIn=withRadius?('<span class="cf-r">📍 冲突半径 <input type="number" id="cfRegionRadius" class="cf-num" value="'+esc(String(DB.regionRadius))+'" min="0.1" step="0.1" onchange="setRegionRadius(this.value)"> 公里</span>'):'';
return '<div class="cf-bar" id="mapFilterBar">'
+'<input id="mapFilterKw" class="cf-kw" placeholder="搜索客户姓名 / 校区 / 手机号 / 地址 / 归属经销商" value="'+esc(f.kw)+'" oninput="cfKw(this.value)" oncompositionstart="cfComposing=true" oncompositionend="cfComposing=false;cfKw(this.value)">'
+'<select id="mapFilterDealer" class="cf-sel" onchange="cfSetVal(\'dealer\',this.value)"><option value="">全部经销商</option>'+dOpts+'</select>'
+'<div class="cf-msel" id="mapFilterStageWrap"><button type="button" id="mapFilterStage" class="cf-sel cf-msel-btn" onclick="cfStagePanel(event)">'+esc(cfStageBtnText())+'</button><div class="cf-msel-panel" id="mapFilterStagePanel">'+sOpts+'<div class="cf-msel-foot"><a href="javascript:void(0)" onclick="cfStageAllSet(true)">全选</a><a href="javascript:void(0)" onclick="cfStageAllSet(false)">清空</a><a href="javascript:void(0)" onclick="cfStagePanel(null)">关闭</a></div></div></div>'
+'<select id="mapFilterIntent" class="cf-sel" onchange="cfSetVal(\'intent\',this.value)"><option value="">全部合作项目</option>'+iOpts+'</select>'
+rIn
+'<button class="btn ghost sm" onclick="cfReset()">重置</button>'
+'<span id="cfMatchInfo" class="cf-info">'+esc(cfMatchInfoText())+'</span>'
+'</div>';
}
function cfKw(v){if(cfComposing)return;mapFilter.kw=v||'';if(window._cfKwT)clearTimeout(window._cfKwT);window._cfKwT=setTimeout(cfRefresh,250);}
function cfSetVal(k,v){mapFilter[k]=v;cfRefresh();}
function cfStageSel(){return Array.isArray(mapFilter.stage)?mapFilter.stage.filter(function(k){return !!STAGES[k];}):[];}
function cfStageAll(){return cfStageSel().length>=Object.keys(STAGES).length;}
function cfStageBtnText(){const a=cfStageSel();if(!a.length||a.length>=Object.keys(STAGES).length)return '全部客户状态';if(a.length===1)return (STAGES[a[0]]&&STAGES[a[0]].label)||a[0];return '已选 '+a.length+' 个状态';}
function cfToggleStage(k,on){const a=cfStageSel().slice();const i=a.indexOf(k);if(on){if(i<0)a.push(k);}else if(i>=0)a.splice(i,1);mapFilter.stage=a;cfStageSyncUI();cfRefresh();}
function cfStageAllSet(on){mapFilter.stage=on?Object.keys(STAGES):[];cfStageSyncUI();cfRefresh();}
function cfStageSyncUI(){const b=document.getElementById('mapFilterStage');if(b)b.textContent=cfStageBtnText();const p=document.getElementById('mapFilterStagePanel');if(p){const cur=cfStageSel();[].slice.call(p.querySelectorAll('input[type=checkbox]')).forEach(function(x){x.checked=cur.indexOf(x.value)>=0;});}const mi=document.getElementById('cfMatchInfo');if(mi)mi.textContent=cfMatchInfoText();}
var cfStageDocBound=false;
function cfStagePanel(ev){const w=document.getElementById('mapFilterStageWrap');if(!w)return;if(ev===null){w.classList.remove('open');return;}if(ev&&ev.stopPropagation)ev.stopPropagation();w.classList.toggle('open');if(!cfStageDocBound){cfStageDocBound=true;document.addEventListener('click',function(e){const x=document.getElementById('mapFilterStageWrap');if(x&&x!==e.target&&!x.contains(e.target))x.classList.remove('open');});}}
function cfReset(){
var _hadGeo=!!_lastGeoQuery;mapFilter={kw:'',dealer:'',stage:[],intent:''};_lastGeoQuery=null;_lastMapView=null;if(_nearestLayer)_nearestLayer.clearLayers();var _ncb=document.getElementById('mapNearestCard');if(_ncb)_ncb.innerHTML='';cfRefresh();
const kw=document.getElementById('mapFilterKw');if(kw)kw.value='';
const d=document.getElementById('mapFilterDealer');if(d)d.value='';
const s=document.getElementById('mapFilterStage');if(s)s.textContent=cfStageBtnText();
const sp=document.getElementById('mapFilterStagePanel');if(sp){[].slice.call(sp.querySelectorAll('input[type=checkbox]')).forEach(function(x){x.checked=false;});}
const i=document.getElementById('mapFilterIntent');if(i)i.value='';
const mi=document.getElementById('cfMatchInfo');if(mi)mi.textContent=cfMatchInfoText();
toast('已重置筛选'+(_hadGeo?'与定位':'条件'));
}
function cfRenderList(){
const el=document.getElementById('cfList');if(!el){renderRegionConflict();return;}
const radius=Number(DB.regionRadius)||1;const located=cfLocated();const pairs=cfPairs(located,radius);
el.innerHTML=cfRowsHTML(pairs,radius);
const st=document.getElementById('cfStats');if(st)st.textContent=cfStatsConflict(pairs,located,radius);
const mi=document.getElementById('cfMatchInfo');if(mi)mi.textContent=cfMatchInfoText();
}
function mapSoftRender(){var _el=document.getElementById('map');if(_map&&_el&&_el.isConnected){injectMapStyles();mapApplyFilter(true);return;}renderMap();}
function mapApplyFilter(keepView){
if(typeof L==="undefined"||!window.L||!_map){renderMap();return;}
const located=cfLocated();
const lg=mapClearOverlays();
located.forEach(c=>{L.marker(g2(c.lat,c.lng),{icon:mapFlagIcon(),riseOnHover:true}).bindPopup(mapPopupHTML(c)).addTo(lg);});
const st=document.getElementById('mapStats');if(st)st.textContent=cfStatsText();
const uw=document.getElementById('mapUnlocWrap');if(uw)uw.innerHTML=cfUnlocHTML();
if(!_unlocReason||_unlocReason.rev!==DB.rev)mapLoadUnlocReason();/* 数据被改过(rev 变)时重取分型,避免块停在「加载中」 */
const mi=document.getElementById('cfMatchInfo');if(mi)mi.textContent=cfMatchInfoText();
if(_lastGeoQuery){try{mapShowNearest(_lastGeoQuery,{popup:false});}catch(e){}}
if(located.length&&!keepView){var _fb=located.map(c=>g2(c.lat,c.lng));if(_lastGeoQuery)_fb.push(g2(_lastGeoQuery.lat,_lastGeoQuery.lng));try{_map.fitBounds(_fb,{padding:[40,40],maxZoom:14,animate:false});}catch(e){}}
}
function cfRefresh(){
if(document.getElementById('cfList')){cfRenderList();return;}
if(document.getElementById('mapFilterBar')){mapApplyFilter();return;}
renderMap();
}
// ===== 客户地图 / 区域冲突 筛选 END =====
function mapFsOn(){try{return document.body.classList.contains('map-full');}catch(e){return false;}}
function mapSyncFsBtn(){var b=document.getElementById('mapFsBtn');if(!b)return;var on=mapFsOn();b.textContent=on?'退出全屏':'全屏';b.title=on?'退出全屏(Esc)':'全屏查看地图(Esc 退出)';}
function mapSetFull(on){var b=document.body;if(on){b.classList.add('map-full');}else{b.classList.remove('map-full');}mapSyncFsBtn();var m=mapLive();if(m){setTimeout(function(){try{m.invalidateSize();var _fs=DB.customers.filter(isValidCoord).filter(cfMatch);if(_fs.length)m.fitBounds(_fs.map(function(c){return g2(c.lat,c.lng);}),{padding:[40,40],maxZoom:14,animate:false});}catch(e){}},60);}}
function mapToggleFull(){mapSetFull(!mapFsOn());}
function mapExitFull(){if(mapFsOn())mapSetFull(false);}
function mapFsKeyBind(){if(window.__mapFsKey)return;window.__mapFsKey=1;document.addEventListener('keydown',function(e){if(e.key==='Escape'||e.keyCode===27)mapExitFull();});}
// 【第 176 轮】客户地图全屏:只切 body.map-full 类走 fixed 定位;
// 祖先(.app/.main)不得新增 transform/filter/will-change,否则 fixed 会退化为相对祖先定位。
function mapLive(){try{var c=document.getElementById('map');if(!_map||!c||!c.isConnected)return null;return _map;}catch(e){return null;}}function mapRestoreSession(){try{if(_lastGeoQuery)mapShowNearest(_lastGeoQuery,{popup:false});}catch(e){}try{_measures.forEach(function(m){if(!m||!m.pts||m.pts.length!==2)return;m.layer=mapDrawMeasureLine(m.id,m.pts[0],m.pts[1],m.d,false,m.names);});}catch(e){}try{if(_measureMode){var _mlR=ensureMeasureLayer();if(_measurePts&&_measurePts.length===1){_pendingMarker=L.marker([_measurePts[0].lat,_measurePts[0].lng],{draggable:false,icon:mmDotIcon()}).addTo(_mlR);}}}catch(e){}}
function renderMap(){if(!can('view_map')){view.innerHTML='<div class="card" style="padding:40px;text-align:center;color:#94a3b8">无权限:查看客户地图需「查看客户地图」权限</div>';return;}
if(mapTab==='region'){renderRegionCover();return;}
if(mapTab==='conflict'){mapExitFull();renderRegionConflict();return;}
const located=DB.customers.filter(isValidCoord).filter(cfMatch);const unlocated=DB.customers.filter(c=>!isValidCoord(c)).filter(cfMatch);
view.innerHTML=`<div class="seg" style="margin:0 0 14px"><button class="seg-btn active" onclick="mapTab='map';renderMap()">🗺️ 地图</button><button class="seg-btn" onclick="mapTab='conflict';renderMap()">⚠️ 区域冲突</button>${can('view_region')?'<button class="seg-btn" onclick="mapTab=\'region\';renderMap()">📍 区域覆盖</button>':''}</div><div class="topbar"><div><h1>🗺️ 客户地图</h1><p id="mapStats">${cfStatsText()}</p></div></div><div class="search"><input id="mapQuery" placeholder="输入地名/区县/地址查询坐标…" onkeydown="if(event.key==='Enter')mapQueryGeocode()"><button class="btn" onclick="mapQueryGeocode()">🔍 定位</button><button class="btn ghost" onclick="mapToggleMeasure()">📏 测量距离</button><button class="btn ghost" onclick="mapClearMeasures()">🗑️ 清除测距</button>${unlocated.length?'<button class="btn ghost" onclick="mapBatchLocate()">🔄 一键定位缺失('+unlocated.length+')</button>':''}<span style="display:flex;align-items:center;gap:6px;padding:0 4px;color:#475569;font-size:13px;white-space:nowrap">📍 区域保护半径 <input type="number"id="regionRadiusInput"value="${DB.regionRadius}"min="0.1"step="0.1"style="width:64px;height:38px;box-sizing:border-box;border:1px solid var(--line);border-radius:9px;padding:0 8px;font-size:13px"onchange="setRegionRadius(this.value)"> 公里</span></div>${cfBarHTML()}<div id="mapMeasureCard"></div><div id="mapNearestCard"></div><div style="position:relative"><div id="map"></div><button type="button" id="mapFsBtn" class="map-fs-btn" onclick="mapToggleFull()" title="全屏查看地图(Esc 退出)">全屏</button></div><div id="mapUnlocWrap">${cfUnlocHTML()}</div>`;
injectMapStyles();mapFsKeyBind();mapSyncFsBtn();if(!window.L){toast('地图库未加载,请刷新重试');return;}
if(_map){try{_map.remove();}catch(e){}_map=null;}var _rv=_lastMapView;/* 第480轮:进入时快照,之后建新图 / 挂监听均不影响本次恢复 */_measureLayer=null;_nearestLayer=null;_pendingMarker=null;/* 第479轮:定位与测距属会话级状态,切页保留,进页由恢复函数重建图层 */var _rvc=(_rv&&_rv.c&&_rv.c.length===2)?_rv.c:(located.length?[located.reduce(function(a,c){return a+c.lat;},0)/located.length,located.reduce(function(a,c){return a+c.lng;},0)/located.length]:[35,105]);var _rvz=(_rv&&typeof _rv.z==='number')?_rv.z:(located.length?10:5);_map=L.map('map').setView(_rvc,_rvz);L.tileLayer('https://wprd{s}.is.autonavi.com/appmaptile?x={x}&y={y}&z={z}&lang=zh_cn&size=1&scl=1&style=7',{subdomains:['01','02','03','04'],maxZoom:18,attribution:'高德地图'}).addTo(_map);_map.on('click',e=>mapMeasureTap(e));
setTimeout(()=>{var _mk=mapLive();if(!_mk)return;try{_mk.invalidateSize();if(_rv&&_rv.c&&_rv.c.length===2){_mk.setView(_rv.c,(typeof _rv.z==='number')?_rv.z:8,{animate:false});}else{var _fbR=located.map(c=>g2(c.lat,c.lng));if(_lastGeoQuery)_fbR.push(g2(_lastGeoQuery.lat,_lastGeoQuery.lng));if(_fbR.length){_mk.fitBounds(_fbR,{padding:[40,40],maxZoom:14,animate:false});}}_mk.on('moveend',function(){try{var _mc=_mk.getCenter();_lastMapView={c:[_mc.lat,_mc.lng],z:_mk.getZoom()};}catch(e){}});}catch(e){}},50);const lg=mapClearOverlays();
located.forEach(c=>{L.marker(g2(c.lat,c.lng),{icon:mapFlagIcon(),riseOnHover:true}).bindPopup(mapPopupHTML(c)).addTo(lg);});mapRestoreSession();mapLoadUnlocReason();mmPanelRender();}
function renderNearestCard(dn,c,km){const box=document.getElementById('mapNearestCard');if(!box)return;if(!c){box.innerHTML='<div class="card" style="padding:12px 16px;margin-bottom:12px;border-left:4px solid #f59e0b;display:flex;align-items:center;gap:8px;flex-wrap:wrap"><span style="font-size:13px;color:#64748b">📍 '+esc(dn)+'</span><span class="mtxt">'+(cfActive()?'当前筛选条件下暂无已定位客户':'系统中暂无有效坐标的客户')+',无法计算最近距离</span></div>';return;}const stg=STAGES[c.stage]||{label:c.stage||'—',cls:'st-lost'};const intent=(Array.isArray(c.intent)&&c.intent.length)?c.intent.join(' / '):(c.intent||'未确定');box.innerHTML='<div class="card" style="padding:14px 16px;margin-bottom:12px;border-left:4px solid #16a34a"><div style="font-size:12.5px;color:#64748b">📍 查询位置:'+esc(dn)+'</div><div style="margin-top:6px;display:flex;align-items:center;gap:8px;flex-wrap:wrap"><span style="font-size:13px;color:#64748b">🏫 最近客户'+(cfActive()?'(当前筛选范围内)':'')+':</span><span style="font-weight:700;color:#0f172a;font-size:14px">'+esc(c.school||c.name)+'</span><span class="st '+stg.cls+'">'+esc(stg.label)+'</span><span style="font-size:12.5px;color:#64748b">合作项目:'+esc(intent)+'</span></div><div style="margin-top:8px;font-size:14px;font-weight:700;color:#0f172a">📏 直线距离 <span style="color:#16a34a;font-size:18px;font-weight:800">'+km+'</span> 公里</div></div>';}
function mapShowNearest(q,opts){if(!q||!isFinite(q.lat)||!isFinite(q.lng))return;opts=opts||{};const dn=q.dn||'';if(!_mapLayer)_mapLayer=mapClearOverlays();if(!_nearestLayer)_nearestLayer=L.layerGroup().addTo(_map);_nearestLayer.clearLayers();var _qmk=L.circleMarker(g2(q.lat,q.lng),{radius:8,color:'#d97706',fillColor:'#f59e0b',fillOpacity:0.95,weight:2}).bindPopup('<b>查询点</b><br>'+esc(dn)).addTo(_nearestLayer);if(opts.popup!==false)_qmk.openPopup();const located=cfLocated();if(located.length){const nearest=located.map(c=>({c:c,d:haversine({lat:q.lat,lng:q.lng},c)})).sort((a,b)=>a.d-b.d)[0];const km=nearest.d.toFixed(1);L.polyline([g2(q.lat,q.lng),g2(nearest.c.lat,nearest.c.lng)],{color:'#16a34a',weight:2,dashArray:'6,4'}).addTo(_nearestLayer).bindPopup('距离最近客户「'+esc(nearest.c.school||nearest.c.name)+'」 <b>'+km+' 公里</b>');renderNearestCard(dn,nearest.c,km);if(opts.toast)toast('已定位到「'+dn+'」;最近客户「'+(nearest.c.school||nearest.c.name)+'」直线距离 '+km+' 公里');}else{renderNearestCard(dn,null,null);if(opts.toast)toast('已定位到「'+dn+'」,但'+(cfActive()?'当前筛选条件下暂无已定位客户':'系统中暂无有效坐标的客户'));}if(opts.view){var _mv=mapLive();if(_mv)_mv.setView(g2(q.lat,q.lng),10);}}
function mapQueryGeocode(){const q=(document.getElementById('mapQuery')||{}).value||'';if(!q.trim()){toast('请输入地址');return;}apiPost('/api/geocode',{q:q.trim()}).then(r=>{if(r&&isFinite(r.lat)&&isFinite(r.lng)&&(r.lat||r.lng)){_lastGeoQuery={lat:r.lat,lng:r.lng,dn:(r.display_name||q)};mapShowNearest(_lastGeoQuery,{toast:true,view:true});}else{toast('未匹配到坐标:'+((r&&r.error)||'请尝试更简短的关键词(如区县名)'));}}).catch(e=>toast('定位失败:'+((e&&e.message)||e)));}
function mapToggleMeasure(){_measureMode=!_measureMode;_measurePts=[];_mmPendingName='';if(_pendingMarker&&_measureLayer){_measureLayer.removeLayer(_pendingMarker);_pendingMarker=null;}mmPanelRender();toast(_measureMode?'测距模式:可直接在地图上点起点和终点(靠近客户会自动吸附),也可在上方选两家客户直接算':'已退出测距模式');}
function mapDrawMeasureLine(id,p1,p2,d,open,names){const ml=ensureMeasureLayer();const sub=L.layerGroup().addTo(ml);const mkA=L.marker([p1.lat,p1.lng],{draggable:true,icon:mmDotIcon(),autoPan:false}).addTo(sub);const mkB=L.marker([p2.lat,p2.lng],{draggable:true,icon:mmDotIcon(),autoPan:false}).addTo(sub);const line=L.polyline([[p1.lat,p1.lng],[p2.lat,p2.lng]],{color:'#7c3aed',weight:2}).addTo(sub);const nmA=names&&names.a?String(names.a):'',nmB=names&&names.b?String(names.b):'';if(nmA)mkA.bindTooltip('起点:'+esc(nmA),{permanent:true,direction:'top',offset:[0,-10],className:'mm-name'});if(nmB)mkB.bindTooltip('终点:'+esc(nmB),{permanent:true,direction:'top',offset:[0,-10],className:'mm-name'});function mmRecalc(){const a=mkA.getLatLng(),b=mkB.getLatLng();const dd=haversine({lat:a.lat,lng:a.lng},{lat:b.lat,lng:b.lng});line.setLatLngs([[a.lat,a.lng],[b.lat,b.lng]]);line.setTooltipContent('↔ '+fmtMapDist(dd));line.setPopupContent(mmPopupBody(id,dd));const rec=_measures.find(function(x){return x.id===id;});if(rec){rec.pts=[{lat:a.lat,lng:a.lng},{lat:b.lat,lng:b.lng}];rec.d=dd;}return dd;}mkA.on('drag',mmRecalc);mkB.on('drag',mmRecalc);mkA.on('dragend',function(){toast('已调整起点:'+fmtMapDist(mmRecalc()));});mkB.on('dragend',function(){toast('已调整终点:'+fmtMapDist(mmRecalc()));});line.bindTooltip('↔ '+fmtMapDist(d),{permanent:true,direction:'center',className:'mm-lbl'});line.bindPopup(mmPopupBody(id,d));if(open)line.openPopup();return sub;}
function mapMeasureTap(e){if(!_measureMode)return;const snap=mapSnapCustomer(e.latlng);const pt=snap?{lat:snap.lat,lng:snap.lng}:{lat:e.latlng.lat,lng:e.latlng.lng};_measurePts.push(pt);const ml=ensureMeasureLayer();if(_measurePts.length===1){if(_pendingMarker){ml.removeLayer(_pendingMarker);}_pendingMarker=L.marker([pt.lat,pt.lng],{draggable:false,icon:mmDotIcon()}).addTo(ml);_mmPendingName=snap?String(snap.name):'';if(snap){_pendingMarker.bindTooltip('起点:'+esc(snap.name),{permanent:true,direction:'top',offset:[0,-10],className:'mm-name'});}toast(snap?'已吸附到客户「'+snap.name+'」,请点击终点':'已选起点,请点击终点');return;}const d=haversine(_measurePts[0],_measurePts[1]);if(_pendingMarker){ml.removeLayer(_pendingMarker);_pendingMarker=null;}const id='m'+(++_measureSeq);const secondName=snap?String(snap.name):'';const names=(_mmPendingName||secondName)?{a:_mmPendingName||'',b:secondName||''}:null;const sub=mapDrawMeasureLine(id,_measurePts[0],_measurePts[1],d,true,names);_measures.push({id:id,layer:sub,pts:[{lat:_measurePts[0].lat,lng:_measurePts[0].lng},{lat:_measurePts[1].lat,lng:_measurePts[1].lng}],d:d,names:names});toast((snap?'已吸附到「'+snap.name+'」· ':'')+'两点间距 '+fmtMapDist(d));_measurePts=[];_mmPendingName='';}
function mapBatchLocate(){apiPost('/api/geocode/batch',{}).then(r=>{if(r&&r.ok){toast('已入队 '+r.queued+' 个客户,后台定位中…');mapPollStatus();}else{toast('入队失败');}}).catch(e=>toast('入队失败:'+((e&&e.message)||e)));}
function mapPollStatus(){apiGet('/api/geocode/status').then(s=>{if(s.pending>0||s.done+s.failed<s.total){setTimeout(mapPollStatus,2000);}else{toast('定位完成');loadDB().then(()=>render());}}).catch(()=>{});}
function setRegionRadius(val){const v=Number(val);if(!isFinite(v)||v<=0){toast('请输入有效的区域保护半径(>0 公里)');renderMap();return;}DB.regionRadius=v;save().then(function(ok){if(ok)toast('区域保护半径已更新为 '+v+' 公里');});if(document.getElementById('cfList'))cfRenderList();}
/* ===== 第 518 轮 · 区域覆盖(省 → 市 → 区县 · 成交覆盖 + 空白市场)
口径(主人 2026-10-18 拍板 ①):当前在约 = 全款 signed_full + 分期 signed_installment,且未退费。
家数按客户去重(custGroupKeyOf:同一客户多档案算 1 家);空白地区由 AREA_DATA 补全。
地区名容错:精确 → 去后缀 → 唯一包含(多命中一律不猜,算「地区待规范」)。 ===== */
var RC_DEAL_STAGES=['signed_full','signed_installment'];
var RC_LEAD_STAGES=['reported','following','experienced','lost'];
var rcState={prov:'',prev:'',open:{}};
function rcNormIn(v,keys){
var s=String(v==null?'':v).trim(); if(!s) return '';
if(keys.indexOf(s)>=0) return s;
var strip=function(x){return String(x).replace(/(特别行政区|自治区|自治州|地区|省|市|盟|县|区|旗)$/,'');};
var core=strip(s);
var eq=keys.filter(function(k){return k===core||strip(k)===core;});
if(eq.length===1) return eq[0];
if(eq.length>1) return '';
var sub=[];
if(core.length>=2) sub=keys.filter(function(k){return k.indexOf(core)>=0;});
if(sub.length===1) return sub[0];
if(sub.length>1) return '';
var raw=keys.filter(function(k){return k.indexOf(s)>=0;});
return raw.length===1?raw[0]:'';
}
function rcAreaOf(c){
var provs=Object.keys(AREA_DATA);
var p=rcNormIn(c.prov,provs);
var cs=(p&&AREA_DATA[p])?Object.keys(AREA_DATA[p]):[];
var ct=rcNormIn(c.city,cs);
if(!ct&&cs.length===1) ct=cs[0];
var ds=(p&&ct&&AREA_DATA[p][ct])?AREA_DATA[p][ct]:[];
var d=rcNormIn(c.district,ds);
return {p:p,c:ct,d:d,ok:!!(p&&ct&&d)};
}
function rcIsDeal(c){return RC_DEAL_STAGES.indexOf(c.stage)>=0&&!c.refunded;}
function rcFixArea(p,c,d){
var P0=String(p==null?'':p).trim(),C0=String(c==null?'':c).trim(),D0=String(d==null?'':d).trim();
var P=rcNormIn(P0,Object.keys(AREA_DATA));
var cs=(P&&AREA_DATA[P])?Object.keys(AREA_DATA[P]):[];
var C=rcNormIn(C0,cs);if(!C&&P&&cs.length===1)C=cs[0];
var ds=(P&&C&&AREA_DATA[P][C])?AREA_DATA[P][C]:[];
var D=rcNormIn(D0,ds);
var bad=[];if(P0&&!P)bad.push('省');if(C0&&!C)bad.push('市');if(D0&&!D)bad.push('区县');
return {prov:P,city:C,district:D,bad:bad,changed:(P!==P0||C!==C0||D!==D0)};
}
function rcAgg(){
var P={},S={orders:0,fam:0,bad:0,none:0,recent:''},seen={};
(DB.customers||[]).forEach(function(c){
var a=rcAreaOf(c);
if(!a.ok){ if(String(c.prov||'')+String(c.city||'')+String(c.district||'')) S.bad++; else S.none++; return; }
var p=P[a.p]||(P[a.p]={orders:0,fams:{},recent:'',leads:0,cities:{}});
var ct=p.cities[a.c]||(p.cities[a.c]={orders:0,fams:{},recent:'',leads:0,dists:{}});
var dd=ct.dists[a.d]||(ct.dists[a.d]={orders:0,fams:{},recent:'',leads:0});
if(rcIsDeal(c)){
var gk=custGroupKeyOf(c)||c.id,sd=String(c.signDate||'');
[p,ct,dd].forEach(function(o){o.orders++;if(!o.fams[gk])o.fams[gk]=1;if(sd>o.recent)o.recent=sd;});
S.orders++;seen[gk]=1;if(sd>S.recent)S.recent=sd;
}else if(RC_LEAD_STAGES.indexOf(c.stage)>=0){p.leads++;ct.leads++;dd.leads++;}
});
Object.keys(P).forEach(function(p){
var o=P[p];o.fam=Object.keys(o.fams).length;
Object.keys(o.cities).forEach(function(k){
var ct=o.cities[k];ct.fam=Object.keys(ct.fams).length;
Object.keys(ct.dists).forEach(function(dk){ct.dists[dk].fam=Object.keys(ct.dists[dk].fams).length;});
});
});
S.fam=Object.keys(seen).length;
S.provN=Object.keys(P).filter(function(p){return P[p].orders>0;}).length;
return {P:P,S:S};
}
function rcTabBar(active){
var it=[['map','🗺️ 地图'],['region','📍 区域覆盖'],['conflict','⚠️ 区域冲突']];
return '<div class="seg" style="margin:0 0 14px">'+it.map(function(x){
return '<button class="seg-btn'+(x[0]===active?' active':'')+'" onclick="mapTab=\''+x[0]+'\';renderMap()">'+x[1]+'</button>';
}).join('')+'</div>';
}
function rcEnter(p){rcState.prev=rcState.prov||'';rcState.prov=p;rcState.open={};renderRegionCover();}
function rcBack(){rcState.prov=rcState.prev||'';rcState.prev='';rcState.open={};renderRegionCover();}
function rcToggle(k){rcState.open[k]=!rcState.open[k];renderRegionCover();}
function renderRegionCover(){
if(!can('view_region')){view.innerHTML='<div class="card" style="padding:40px;text-align:center;color:#94a3b8">无权限:查看区域覆盖需「查看区域覆盖」权限</div>';return;}
var agg=rcAgg(),P=agg.P,S=agg.S;
var provs=Object.keys(P).filter(function(p){return P[p].orders>0;}).sort(function(a,b){return P[b].orders-P[a].orders;});
var all=Object.keys(AREA_DATA);
var blankN=all.filter(function(p){return !(P[p]&&P[p].orders>0);}).length;
var blankLeadN=all.filter(function(p){return !(P[p]&&P[p].orders>0)&&P[p]&&P[p].leads>0;}).length;
var head='<div class="topbar"><div><h1>📍 区域覆盖</h1><p>口径:当前在约(全款 + 分期 · 未退费)· 家数按客户去重 · 灰色为空白地区'+(rcState.prov==='__blank__'?' · <b style="color:#854F0B">当前:仅空白省份</b>':'')+'</p></div></div>';
var kpi='<div class="grid kpis" style="grid-template-columns:repeat(4,1fr)">'
+kpiCard('🗺️','覆盖省份',S.provN+'<span style="color:#94a3b8;font-size:13px"> / '+all.length+'</span>','有成交客户的省份')
+kpiCard('✅','成交家数',S.fam,'同一客户多档案算 1 家','#16a34a')
+kpiCard('📦','成交订单',S.orders,'全款 + 分期','#0891b2')
+kpiCard('🗓️','最近成交',S.recent||'—','签约日期最大值')
+'</div>';
var sel='<div class="card" style="padding:12px 14px;display:flex;gap:10px;align-items:center;flex-wrap:wrap">'
+'<label style="font-size:13px;color:#64748b">省份</label>'
+'<select id="rcProv" onchange="rcState.prov=this.value;rcState.prev=\'\';rcState.open={};renderRegionCover()" style="padding:6px 10px;border:1px solid #d3d1c7;border-radius:6px;font-size:13px;min-width:200px">'
+'<option value="">全部省份('+provs.length+' 个有成交)</option>'
+'<option value="__blank__"'+(rcState.prov==='__blank__'?' selected':'')+'>○ 仅空白省份('+blankN+' 个'+(blankLeadN?' · 其中 '+blankLeadN+' 个有报备':'')+')</option>'
+all.map(function(p){var n=P[p]?P[p].orders:0;return '<option value="'+esc(p)+'"'+(rcState.prov===p?' selected':'')+'>'+esc(p)+(n?'('+n+' 单)':'(空白)')+'</option>';}).join('')
+'</select>'
+'<button class="btn ghost sm" onclick="rcExport()">⬇ 导出 CSV</button>'
+(S.bad+S.none?'<span class="mtxt" style="font-size:12px;color:#d97706;margin-left:auto">另有 '+(S.bad+S.none)+' 条客户地区未填或不规范,未计入覆盖(按下方口径单列)</span>':'')
+'</div>';
var body=rcState.prov==='__blank__'?rcBlankBody(P,all):(rcState.prov?rcProvBody(rcState.prov,P):rcAllProvBody(P,provs,all));
view.innerHTML=(curView==='map'?rcTabBar('region'):'')+head+kpi+sel+body;
}
function rcAllProvBody(P,provs,all){
var th='<thead><tr><th>省份</th><th style="text-align:right">成交</th><th style="text-align:right">覆盖城市</th><th style="text-align:right">覆盖区县</th><th>最近成交</th><th></th></tr></thead>';
var rows=provs.map(function(p){
var o=P[p],cities=AREA_DATA[p]||{},allC=Object.keys(cities),allD=0;
allC.forEach(function(k){allD+=(cities[k]||[]).length;});
var covC=0,covD=0;
Object.keys(o.cities).forEach(function(k){
var cc=o.cities[k];
if(cc.orders>0){covC++;Object.keys(cc.dists).forEach(function(dk){if(cc.dists[dk].orders>0)covD++;});}
});
return '<tr style="cursor:pointer" onclick="rcEnter(\''+esc(p)+'\')">'
+'<td><b>'+esc(p)+'</b></td>'
+'<td style="text-align:right">'+o.fam+' 家 / '+o.orders+' 单</td>'
+'<td style="text-align:right">'+covC+' / '+allC.length+'</td>'
+'<td style="text-align:right">'+covD+' / '+allD+'</td>'
+'<td class="mtxt">'+(o.recent||'—')+'</td>'
+'<td style="text-align:right;color:#185FA5;font-size:12px">查看 ›</td></tr>';
}).join('')||'<tr><td colspan="6" class="empty">暂无成交客户</td></tr>';
var blank=all.filter(function(p){return !(P[p]&&P[p].orders>0);});
var bhtml=blank.length?'<details style="margin-top:12px"><summary style="cursor:pointer;font-size:13px;color:#64748b;padding:8px 2px">○ 空白省份(有行政区划但暂无成交)'+blank.length+' 个 · 点开查看</summary><div style="display:flex;flex-wrap:wrap;gap:6px;padding:8px 2px">'+blank.map(function(p){return '<span style="font-size:12px;color:#888780;background:#f5f5f3;border:1px solid #e7e5e0;border-radius:6px;padding:3px 8px">'+esc(p)+'</span>';}).join('')+'</div></details>':'';
return '<div class="card"><table>'+th+'<tbody>'+rows+'</tbody></table>'+bhtml+'</div>'
+'<div class="mtxt" style="font-size:12px;margin-top:10px;line-height:1.7">说明:省份行点击进入市级明细;空白省份只统计「有成交」,报备/跟进中的省份不计入。导出 CSV 含省/市/区县三级完整清单。</div>';
}
function rcBlankBody(P,all){
var list=all.filter(function(p){return !(P[p]&&P[p].orders>0);});
list.sort(function(a,b){var la=(P[a]&&P[a].leads)||0,lb=(P[b]&&P[b].leads)||0;return lb-la;});
var withLead=list.filter(function(p){return P[p]&&P[p].leads>0;}).length;
var th='<thead><tr><th>省份</th><th style="text-align:right">成交</th><th style="text-align:right">覆盖城市</th><th style="text-align:right">区县数</th><th style="text-align:right">报备</th><th></th></tr></thead>';
var rows=list.map(function(p){
var o=P[p]||{orders:0,leads:0,cities:{}};
var cities=Object.keys(AREA_DATA[p]||{}),dn=0;
cities.forEach(function(k){dn+=(AREA_DATA[p][k]||[]).length;});
var lg=o.leads||0;
return '<tr style="cursor:pointer" onclick="rcEnter(\''+esc(p)+'\')">'
+'<td><b>'+esc(p)+'</b>'+(lg?'':' <span style="font-size:11px;color:#888780;background:#f1f5f9;padding:1px 6px;border-radius:9px">无人问津</span>')+'</td>'
+'<td style="text-align:right;color:#b4b2a9">0</td>'
+'<td style="text-align:right;color:#888780">0 / '+cities.length+'</td>'
+'<td style="text-align:right;color:#888780">'+dn+'</td>'
+'<td style="text-align:right">'+(lg?'<b style="color:#854F0B">'+lg+'</b>':'<span style="color:#b4b2a9">0</span>')+'</td>'
+'<td style="text-align:right;color:#185FA5;font-size:12px">查看 ›</td></tr>';
}).join('')||'<tr><td colspan="6" class="empty">所有省份均已有成交</td></tr>';
return '<div class="card" style="padding:12px 14px;margin-bottom:12px;display:flex;gap:16px;align-items:center;flex-wrap:wrap">'
+'<span style="font-size:13px;color:#64748b">空白省份合计 <b style="color:#2C2C2A">'+list.length+'</b> 个</span>'
+'<span style="font-size:12.5px;color:#854F0B">已有报备未成交 <b>'+withLead+'</b> 个</span>'
+'<span style="font-size:12.5px;color:#888780">完全无人问津 <b>'+(list.length-withLead)+'</b> 个</span>'
+'<span class="mtxt" style="font-size:12px;margin-left:auto">点省份可下钻,看该省空白市 / 区县的报备线索</span></div>'
+'<div class="card"><table>'+th+'<tbody>'+rows+'</tbody></table></div>';
}
function rcProvBody(prov,P){
var o=P[prov]||{orders:0,fams:{},fam:0,recent:'',leads:0,cities:{}};
var cities=Object.keys(AREA_DATA[prov]||{});
var cov=cities.filter(function(c){return o.cities[c]&&o.cities[c].orders>0;});
var blank=cities.filter(function(c){return !(o.cities[c]&&o.cities[c].orders>0);});
cov.sort(function(a,b){return o.cities[b].orders-o.cities[a].orders;});
var head='<div class="topbar" style="margin-top:4px"><div><h2 style="font-size:16px;margin:0 0 4px">'+esc(prov)+'</h2>'
+'<p>有成交 '+(o.fam||0)+' 家 / '+o.orders+' 单 · 覆盖 '+cov.length+' / '+cities.length+' 市</p></div>'
+'<div><button class="btn ghost sm" onclick="rcBack()">'+(rcState.prev==='__blank__'?'← 返回空白省份':'← 返回全部省份')+'</button></div></div>';
var th='<thead><tr><th>城市 / 区县</th><th style="text-align:right">成交</th><th style="text-align:right">覆盖 / 报备</th><th>最近成交</th><th></th></tr></thead>';
var rows=cov.map(function(c){return rcCityRows(prov,c,o.cities[c]);}).join('')
+blank.map(function(c){return rcCityRows(prov,c,o.cities[c]||null);}).join('');
return head+'<div class="card"><table>'+th+'<tbody>'+rows+'</tbody></table></div>'
+'<div class="mtxt" style="font-size:12px;margin-top:10px;line-height:1.7">点城市展开区县;灰色「空白」城市也可点开,看该市各区县是「已有报备」还是「无人问津」。</div>';
}
function rcCityRows(prov,city,co){
var has=!!co&&co.orders>0,key=prov+'|'+city,open=!!rcState.open[key];
var allD=(AREA_DATA[prov]&&AREA_DATA[prov][city])||[];
var covD=0;if(co){Object.keys(co.dists).forEach(function(k){if(co.dists[k].orders>0)covD++;});}
var leadTxt=has?(co.leads?('报备 '+co.leads):'—'):'—';
var leadBadge=(co&&co.leads)?'<span style="color:#854F0B;font-size:11px"> · 报备 '+co.leads+'</span>':'';
var main='<tr style="cursor:pointer'+(has?'':'')+'" onclick="rcToggle(\''+esc(key)+'\')">'
+'<td><span style="color:#94a3b8;display:inline-block;width:14px">'+(open?'▾':'▸')+'</span><b'+(has?'':' style="color:#888780;font-weight:400"')+'>'+esc(city)+'</b>'
+(has?'':' <span style="font-size:11px;color:#888780;background:#f1f5f9;padding:1px 6px;border-radius:9px">空白</span>')+'</td>'
+'<td style="text-align:right">'+(has?(co.fam+' 家 / '+co.orders+' 单'):'—')+'</td>'
+'<td style="text-align:right">'+(covD+' / '+allD.length)+leadBadge+'</td>'
+'<td class="mtxt">'+(has?(co.recent||'—'):'—')+'</td>'
+'<td style="text-align:right">'+(has?'<span class="btn ghost sm" onclick="event.stopPropagation();rcCusts(\''+esc(prov)+'\',\''+esc(city)+'\',\'\')">明细</span>':'')+'</td>'
+'</tr>';
if(!open) return main;
var drows=allD.map(function(d){
var dd=(co&&co.dists[d]&&co.dists[d].orders>0)?co.dists[d]:null;
var state=dd?('已覆盖 · '+dd.fam+' 家'):'';
if(!dd){
var lg=rcLeadAt(prov,city,d);
state=lg?('已有报备 '+lg+' 家'):'无人问津';
}
return '<tr style="background:#fafaf9">'
+'<td style="padding-left:34px;font-size:12.5px;color:'+(dd?'#334155':'#888780')+'">'+esc(d)+'</td>'
+'<td style="text-align:right;font-size:12.5px">'+(dd?(dd.fam+' / '+dd.orders+' 单'):'—')+'</td>'
+'<td style="text-align:right;font-size:12px;color:'+(dd?'#3B6D11':(state==='无人问津'?'#b4b2a9':'#854F0B'))+'">'+state+'</td>'
+'<td class="mtxt" style="font-size:12px">'+(dd?(dd.recent||'—'):'—')+'</td>'
+'<td style="text-align:right">'+(dd?'<span class="btn ghost sm" onclick="rcCusts(\''+esc(prov)+'\',\''+esc(city)+'\',\''+esc(d)+'\')">明细</span>':'')+'</td>'
+'</tr>';
}).join('');
return main+drows;
}
function rcLeadAt(prov,city,dist){
var n=0;
(DB.customers||[]).forEach(function(c){
if(RC_LEAD_STAGES.indexOf(c.stage)<0)return;
var a=rcAreaOf(c);if(a.ok&&a.p===prov&&a.c===city&&a.d===dist)n++;
});
return n;
}
function rcCusts(prov,city,dist){
var list=(DB.customers||[]).filter(function(x){
var a=rcAreaOf(x);return a.ok&&a.p===prov&&(!city||a.c===city)&&(!dist||a.d===dist);
});
var deals=list.filter(rcIsDeal);
var leads=list.filter(function(x){return RC_LEAD_STAGES.indexOf(x.stage)>=0;});
var title=[prov,city,dist].filter(Boolean).join(' · ');
var rowDeal=function(c){
var st=(typeof stageInfoOf==='function')?stageInfoOf(c.stage).label:(c.stage||'');
return '<tr><td>'+esc(c.name||'')+'</td><td class="mtxt">'+esc(c.school||'')+'</td><td>'+esc(st)+'</td>'
+'<td class="mtxt">'+esc(c.signDate||'—')+'</td></tr>';
};
var rowLead=function(c){
var st=(typeof stageInfoOf==='function')?stageInfoOf(c.stage).label:(c.stage||'');
return '<tr><td>'+esc(c.name||'')+'</td><td class="mtxt">'+esc(c.school||'')+'</td><td>'+esc(st)+'</td>'
+'<td class="mtxt">'+esc(c.followups&&c.followups.length?c.followups[c.followups.length-1].date:'—')+'</td></tr>';
};
var html='<div class="modal-head"><h3>📍 '+esc(title)+'</h3><span class="x" onclick="closeModal()">×</span></div><div class="modal-body">'
+'<div class="section-title">已成交('+deals.length+')</div>'
+(deals.length?'<table><thead><tr><th>客户</th><th>校区/机构</th><th>状态</th><th>签约日</th></tr></thead><tbody>'+deals.map(rowDeal).join('')+'</tbody></table>':'<div class="empty">该地区暂无成交客户</div>')
+'<div class="section-title" style="margin-top:16px">报备 / 跟进中('+leads.length+')</div>'
+(leads.length?'<table><thead><tr><th>客户</th><th>校区/机构</th><th>状态</th><th>最近跟进</th></tr></thead><tbody>'+leads.map(rowLead).join('')+'</tbody></table>':'<div class="empty">该地区暂无报备或跟进中的客户</div>')
+'</div><div class="modal-foot"><button class="btn ghost" onclick="closeModal()">关闭</button></div>';
openModal(html);
}
function rcExport(){
var agg=rcAgg(),P=agg.P;
var rows=[['省份','城市','区县','成交家数','成交订单','最近成交','报备/跟进','覆盖状态']];
if(rcState.prov==='__blank__'){
rows=[['省份','城市数','区县数','成交家数','成交订单','报备/跟进','覆盖状态']];
var bl=Object.keys(AREA_DATA).filter(function(p){return !(P[p]&&P[p].orders>0);});
bl.sort(function(a,b){return ((P[b]&&P[b].leads)||0)-((P[a]&&P[a].leads)||0);});
bl.forEach(function(p){
var cs=Object.keys(AREA_DATA[p]||{}),dn=0;
cs.forEach(function(k){dn+=(AREA_DATA[p][k]||[]).length;});
rows.push([p,cs.length,dn,0,0,(P[p]&&P[p].leads)||0,'空白']);
});
}else if(rcState.prov){
var prov=rcState.prov,o=P[prov]||{cities:{}};
Object.keys(AREA_DATA[prov]||{}).forEach(function(city){
var co=o.cities[city];
(AREA_DATA[prov][city]||[]).forEach(function(d){
var dd=co&&co.dists[d]?co.dists[d]:null;
var lg=dd?dd.leads:rcLeadAt(prov,city,d);
rows.push([prov,city,d,dd?dd.fam:0,dd?dd.orders:0,dd?(dd.recent||''):'',lg,dd&&dd.orders>0?'已覆盖':'空白']);
});
});
}else{
Object.keys(P).sort(function(a,b){return P[b].orders-P[a].orders;}).forEach(function(p){
var o=P[p];
Object.keys(o.cities).forEach(function(city){
var co=o.cities[city];
Object.keys(co.dists).forEach(function(d){
var dd=co.dists[d];
if(!(dd.orders>0))return;
rows.push([p,city,d,dd.fam,dd.orders,dd.recent||'',dd.leads,'已覆盖']);
});
});
});
}
var csv=rows.map(function(r){return r.map(function(x){return '"'+String(x==null?'':x).replace(/"/g,'""')+'"';}).join(',');}).join('\r\n');
var tag=rcState.prov==='__blank__'?'空白省份':(rcState.prov||'全国');
downloadCSV('区域覆盖_'+tag+'_'+today()+'.csv',csv);
toast('已导出 '+rows.length+' 行(含空白地区)');
}
function renderRegionConflict(){const tabBar='<div class="seg" style="margin:0 0 14px"><button class="seg-btn" onclick="mapTab=\'map\';renderMap()">🗺️ 地图</button><button class="seg-btn active" onclick="mapTab=\'conflict\';renderMap()">⚠️ 区域冲突</button>'+(can('view_region')?'<button class="seg-btn" onclick="mapTab=\'region\';renderMap()">📍 区域覆盖</button>':'')+'</div>';const located=DB.customers.filter(isValidCoord).filter(cfMatch);const radius=Number(DB.regionRadius)||1;const pairs=cfPairs(located,radius);
const rows=cfRowsHTML(pairs,radius);
injectMapStyles();view.innerHTML=tabBar+cfBarHTML(true)+'<div class="topbar"><div><h1>⚠️ 区域冲突</h1><p id="cfStats">'+cfStatsConflict(pairs,located,radius)+'</p></div><div><button class="btn ghost sm" onclick="mapTab=\'map\';renderMap()">🗺️ 返回地图</button></div></div><div id="cfList">'+rows+'</div>';}
let dlrAdvOpen=false;
dealerFilter={q:dealerFilter.q||'',type:[],stage:'',contract:'',signStart:'',signEnd:'',addrKw:''};
function addRefund(type,id){if(type!=='c'||!can('refund_finance')){toast('无权限:办理退费需「办理退费/标记退费」权限');return;}
const c=getFinEntity(type,id);if(!c)return;const amt=Number(document.getElementById('refund_amt').value);if(!amt||amt<=0){toast('请填写有效的退费金额');return;}if(refundOwnerRequired(c)&&!payOwnerAnyOf('rf_','冲减归属经销商'))return;
const paid=paidOf(c);const alreadyRefunded=(c.refunds||[]).reduce((s,r)=>s+Number(r.amount||0),0);if(alreadyRefunded+amt>paid+0.001){toast('退费总额不能超过已交款金额(已退 '+money(alreadyRefunded)+',已交 '+money(paid)+')');return;}
const date=document.getElementById('refund_date').value||today();const note=document.getElementById('refund_note').value.trim();
const prevStage=(c.refunds&&c.refunds.length)?(c.refunds[0].prevStage||c.stage):c.stage;
if(!Array.isArray(c.refunds))c.refunds=[];const __rw=refundOwnerVals();c.refunds.push(Object.assign({id:uid('rf_'),date:date,amount:amt,note:note,prevStage:prevStage},__rw));
c.stage='refunded';c.refunded=true;c.remindAt='';c.nextPayAt='';syncConvertedDealer(c);triggerReminder('refund','客户「'+c.name+'」办理退费 '+money(amt)+'('+(note||'无备注')+')','customers',c.id);
const _autoUnpush=autoUnpushOnRefund(c);
save().then(function(ok){if(ok)toast('已登记退费 '+money(amt)+',客户状态已更新为「已退费」,欠款已清零'+(_autoUnpush?';已自动撤回交付推送':''));});openPayment(type,id);refreshFin(type);}
function refundEditNewTotal(c,rid,newAmt){return ((c&&c.refunds)||[]).reduce(function(s,x){return s+(x&&x.id===rid?Number(newAmt||0):Number((x&&x.amount)||0));},0);}
function editRefund(type,id,rid){if(type!=='c'||!can('refund_finance')){toast('无权限:修改退费记录需「办理退费/标记退费」权限');return;}
const c=getFinEntity(type,id);if(!c)return;const rr=(c.refunds||[]).find(r=>r.id===rid);if(!rr)return;
openModal(`<div class="modal-head"><h3>修改退费记录</h3><span class="x"onclick="closeModal()">×</span></div><div class="modal-body"><div class="row2"><div class="field"><label>退费日期</label><input type="date"id="er_date"value="${rr.date||''}"></div><div class="field"><label>退费金额*</label><input type="number"id="er_amt"value="${rr.amount}"min="0"step="0.01"></div></div><div class="field"><label>退费备注</label><input id="er_note"value="${esc(rr.note||'')}"placeholder="如:客户取消合作 / 部分退费"></div>${refundOwnerFields(c,rr.rDealerId||'',rr.rStaffId||'')}${refundOwnerHint(c)}<div style="margin-top:8px"><span class="mtxt">保存后按新值计入收款记录与看板;客户状态不变,不重发提醒与推送</span></div></div><div class="modal-foot"><button class="btn ghost"onclick="closeModal()">取消</button><button class="btn"onclick="saveRefundEdit('${type}','${id}','${rid}')">保存修改</button></div>`);}
function saveRefundEdit(type,id,rid){if(type!=='c'||!can('refund_finance')){toast('无权限:修改退费记录需「办理退费/标记退费」权限');return;}
const c=getFinEntity(type,id);if(!c)return;const rr=(c.refunds||[]).find(r=>r.id===rid);if(!rr)return;
const amt=Number(document.getElementById('er_amt').value);if(!amt||amt<=0){toast('请填写有效的退费金额');return;}
const paid=paidOf(c);const _nt=refundEditNewTotal(c,rid,amt);if(_nt>paid+0.001){toast('退费总额不能超过已交款金额(修改后共退 '+money(_nt)+',已交 '+money(paid)+')');return;}
if(refundOwnerRequired(c)&&!payOwnerAnyOf('rf_','冲减归属经销商'))return;
rr.date=document.getElementById('er_date').value||rr.date;rr.amount=amt;rr.note=document.getElementById('er_note').value.trim();
delete rr.rDealerId;delete rr.rStaffId;const __rwE=refundOwnerVals();if(__rwE.rDealerId)rr.rDealerId=__rwE.rDealerId;if(__rwE.rStaffId)rr.rStaffId=__rwE.rStaffId;
save().then(function(ok){if(ok)toast('已修改退费记录');});openPayment(type,id);refreshFin(type);}
function addressEditHTML(prefix,prov,city,dist,addrVal){var lv=(typeof fLevel==='function')?fLevel('addr'):'full';if(lv==='hidden')return '';if(lv==='full'&&canField('addr','e'))return areaSelectHTML(prefix,prov,city,dist,addrVal);var isMask=(lv==='mask');var txt=isMask?((([prov,city].filter(Boolean).join(' ')||'')+' **').trim()):([prov,city,dist,addrVal].filter(Boolean).join(' ')||'未填写');var ph=isMask?'无「查看详细地址」权限':'无「编辑详细地址」权限';return `<div class="field"><label>联系地址</label><input value="${txt}" disabled style="background:#f1f5f9;color:#94a3b8;cursor:not-allowed" placeholder="${ph}"></div>`;}
const areaCell=function(r){if(r.status!=='ok')return '';const parts=[r.prov,r.city,r.district].filter(function(x){return x;}).join(' ');if(r.areaWarning){return '<span style="color:#d97706">'+(parts?esc(parts)+' · ':'')+esc(r.areaWarning)+'</span>';}return parts?esc(parts):'<span style="color:#94a3b8">未填写</span>';};
function bindDealerFilter(){const g=function(id){return document.getElementById(id);};/* 第 217 轮:经销商类型改为多选胶囊,点击即生效,不再需要 change 监听 */const st=g('dlrFilter_stage');if(st)st.addEventListener('change',()=>{dealerFilter.stage=st.value;renderDealers();});const ct=g('dlrFilter_contract');if(ct)ct.addEventListener('change',()=>{dealerFilter.contract=ct.value;renderDealers();});const ad=g('dlrFilter_addr');if(ad){ad.addEventListener('input',()=>{dealerFilter.addrKw=ad.value;renderDealers();});}const ss=g('dlrFilter_signStart');if(ss)ss.addEventListener('change',()=>{dealerFilter.signStart=ss.value;renderDealers();});const se=g('dlrFilter_signEnd');if(se)se.addEventListener('change',()=>{dealerFilter.signEnd=se.value;renderDealers();});}
function confirmCancelContract(type,id){
const o=type==='customer'?DB.customers.find(x=>x.id===id):DB.dealers.find(x=>x.id===id);
if(!o){return;}
o.contractSigned=false;o.contractDate='';
if(type==='customer'){if(owedOf(o)>0&&o.stage==='signed_full')syncCustomerStage(o);if(o.convertedDealerId)syncConvertedDealer(o);}
closeModal();
const cellEl=document.getElementById('contractCell-'+type+'-'+id);
if(cellEl)cellEl.innerHTML=contractToggleHtml(type,id,false);
try{if(typeof renderNav==='function')renderNav();}catch(_){}
save().then(ok=>{if(ok)toast('已取消合同签署');});
}
function confirmContractSign(type,id){
const o=type==='customer'?DB.customers.find(x=>x.id===id):DB.dealers.find(x=>x.id===id);
if(!o){return;}
let stageBumped=false;
const el=document.getElementById('contract_sign_date');
const d=el?el.value:'';
if(!d){toast('请选择合同签署日期');return;}
const sdEl=document.getElementById('contract_signdate_fill');
if(sdEl){const sd=sdEl.value;if(!sd){toast('请填写达成合作日期');return;}o.signDate=sd;}
o.contractDate=d;o.contractSigned=true;
if(type==='dealer'&&o.stage==='lead')o.stage='signed_full';
if(type==='customer'){if(owedOf(o)===0&&Number(o.amount||0)>0&&o.stage!=='refunded'&&o.stage!=='terminated'&&o.stage!=='lost'&&o.stage!=='cooperated')o.stage='signed_full';else if(o.stage==='reported'||o.stage==='following'||o.stage==='experienced')o.stage='cooperated';stageBumped=true;if(o.convertedDealerId)syncConvertedDealer(o);}
closeModal();
const cellEl=document.getElementById('contractCell-'+type+'-'+id);
if(cellEl)cellEl.innerHTML=contractToggleHtml(type,id,true);
if(type==='dealer'){const stageCell=document.getElementById('stageCell-'+id);if(stageCell){stageCell.innerHTML=dealerStageHtml(o);}}
if(type==='dealer')triggerReminder('dealer_contract','经销商「'+o.name+'」签订合同','dealers',id);
if(type==='customer')triggerReminder('contract_signed','客户「'+o.name+'」已签合同('+d+')','customers',id);
try{if(typeof renderNav==='function')renderNav();}catch(_){}
save().then(ok=>{if(ok){toast('已签署合同('+d+')');if(stageBumped)render();}});
}
function confirmRevertConvert(id){if(!can('convert_dealer')){toast('无权限');return;}
const c=DB.customers.find(x=>x.id===id);if(!c)return;const did=c.convertedDealerId;if(!did){toast('该客户未转为经销商');return;}
const held=DB.customers.filter(x=>x.dealerId===did&&x.id!==c.id).map(x=>x.id);if(held.length){c.heldCustomers=held;DB.customers.forEach(x=>{if(held.indexOf(x.id)>=0)x.dealerId='';});DB.deliveries.forEach(dv=>{if(dv.customerId&&held.indexOf(dv.customerId)>=0&&dv.dealerId===did)dv.dealerId='';});}else{c.heldCustomers=[];}
DB.deliveries=DB.deliveries.filter(x=>!(x.dealerId===did&&!x.customerId));DB.deliveries.forEach(dv=>{if(dv&&dv.dealerId===did)dv.dealerId='';});DB.dealers=DB.dealers.filter(d=>d.id!==did);c.convertedDealerId='';closeModal();save().then(ok=>{if(ok){render();toast('已撤销转为经销商');}});}
function dealerAddrLockedHTML(d){const _alv=fLevel('addr');const txt=_alv==='full'?([d.prov,d.city,d.district,d.addr].filter(Boolean).join(' ')||'未填写'):(_alv==='mask'?((d.prov||'')+(d.city||'')+'**'):'****');return '<div class="field"><label>🔒 联系地址 <span style="font-size:11px;color:#d97706;margin-left:4px">继承自客户·只读</span></label><input value="'+esc(txt)+'" disabled style="background:#f1f5f9;color:#94a3b8;cursor:not-allowed"></div>';}
function delRefund(type,id,rid){if(type!=='c'||!can('refund_finance')||!can('del_refunds')){toast('无权限:删除退费记录需「删除退费记录」权限');return;}
const c=getFinEntity(type,id);if(!c)return;const rr=(c.refunds||[]).find(r=>r.id===rid);if(!rr)return;if(!confirm('确认删除这笔退费记录('+money(rr.amount)+' / '+(rr.date||'')+')?'))return;
c.refunds=c.refunds.filter(r=>r.id!==rid);if(!c.refunds.length){c.refunded=false;if(rr.prevStage){c.stage=rr.prevStage;}else{c.stage='reported';syncCustomerStage(c);}}syncConvertedDealer(c);
save().then(function(ok){if(ok)toast('已删除退费记录');});openPayment(type,id);refreshFin(type);}
function doSetContractAmount(type,id,v){if(fDeny('amount','e')){toast('无权限:本角色已被关闭「合同金额」的编辑权限(字段级)');closeModal();return;}const e=getFinEntity(type,id);if(!e)return;e.amount=Number(v);if(type==='c'&&typeof e.stage==='string')syncCustomerStage(e);if(type==='c')syncConvertedDealer(e);syncSignDateFromPayments(e);save().then(function(ok){if(ok)toast('已更新合同总额:'+money(Number(v)));});refreshFin(type);closeModal();openPayment(type,id);}
function fillAreaSelect(prefix,prov,city,district,addr,req){const wrap=document.getElementById(prefix+'_area_wrap');const addrEl=document.getElementById(prefix+'_addr');const addrVal=(addrEl&&addrEl.value.trim())?addrEl.value:(addr||'');if(wrap){wrap.innerHTML=areaSelectHTML(prefix,prov,city,district,addrVal,req);bindAreaSelect(prefix);}else if(addrEl&&addr&&!addrEl.value.trim()){addrEl.value=addr;}}
function openCancelContractModal(type,id){
const o=type==='customer'?DB.customers.find(x=>x.id===id):DB.dealers.find(x=>x.id===id);
if(!o){toast('未找到记录');return;}
openModal(`<div class="modal-head"><h3>⚠️ 取消合同签署</h3><span class="x" onclick="closeModal()">×</span></div><div class="modal-body"><div class="warn-box" style="margin-bottom:0">客户 <b>${esc(o.name||'')}</b> 当前为「已签合同」${o.contractDate?'(签署日期:'+esc(o.contractDate)+')':''}。<br><br>确认取消合同签署吗?取消后合同签订状态将变为「未签」,同时清空合同签署日期。</div></div><div class="modal-foot"><button class="btn ghost" onclick="closeModal()">取消</button><button class="btn warn" onclick="confirmCancelContract('${type}','${id}')">确认取消签署</button></div>`);
}
function openContractSignModal(type,id){
const o=type==='customer'?DB.customers.find(x=>x.id===id):DB.dealers.find(x=>x.id===id);
if(!o){toast('未找到记录');return;}
openModal(`<div class="modal-head"><h3>✍️ 合同签署</h3><span class="x" onclick="closeModal()">×</span></div><div class="modal-body"><div class="mtxt" style="margin-bottom:8px">${type==='customer'?'客户':'经销商'}:<b>${esc(o.name||'')}</b></div><div class="field"><label>合同签署日期</label><input type="date" id="contract_sign_date" value="${esc(o.contractDate||today())}"></div>${o.signDate?'':`<div class="field"><label>达成合作日期(必填·该${type==='customer'?'客户':'经销商'}记录缺失)</label><input type="date" id="contract_signdate_fill" value="${esc(o.reportDate||today())}"></div>`}</div><div class="modal-foot"><button class="btn ghost" onclick="closeModal()">取消</button><button class="btn" onclick="confirmContractSign('${type}','${id}')">确认签署</button></div>`);
}
function openRevertConvert(id){if(!can('convert_dealer')){toast('无权限:仅拥有「客户转为经销商」权限的账号可操作');return;}
const c=DB.customers.find(x=>x.id===id);if(!c)return;if(!c.convertedDealerId){toast('该客户未转为经销商');return;}
const d=DB.dealers.find(x=>x.id===c.convertedDealerId);const _held=DB.customers.filter(x=>x&&x.dealerId===c.convertedDealerId&&x.id!==c.id);openModal(`<div class="modal-head"><h3>⚠️ 撤销转为经销商</h3><span class="x" onclick="closeModal()">×</span></div><div class="modal-body"><div class="warn-box" style="margin-bottom:0">确认撤销客户 <b>${esc(c.name||'')}</b> 的「转为经销商」操作吗?<br><br>将删除对应的经销商「<b>${esc(d?d.name:'')}</b>」记录,客户将恢复为普通转介绍客户。</div>${_held.length?`<div class="warn-box" style="margin-top:8px"><b>⚠️ 该经销商名下还挂着 ${_held.length} 个客户:</b>${esc(_held.slice(0,8).map(x=>x.name||'?').join('、'))}${_held.length>8?' 等':''}<br>撤销后这些客户的「归属经销商」将被清空(之后可在客户详情中重新指定)。</div>`:''}</div><div class="modal-foot"><button class="btn ghost" onclick="closeModal()">取消</button><button class="btn warn" onclick="confirmRevertConvert('${c.id}')">确认撤销</button></div>`);}
function dlrTypeChipsHtml(){return `<div class="chip-row" id="dlrTypeChips"><button type="button" class="chip${dealerFilter.type.length?'':' active'}" data-k="" onclick="dlrTypePick('',1)">全部类型</button>${Object.entries(DEALER_TYPES).map(([k,v])=>`<button type="button" class="chip${dealerFilter.type.indexOf(k)!==-1?' active':''}" data-k="${k}" onclick="dlrTypePick('${k}')">${v.name}</button>`).join('')}</div>`;}
function dlrTypePick(k,all){dealerFilter.type=all?[]:chipNext(dealerFilter.type,k);renderDealers();}
function resetDealerFilter(){dealerFilter={q:'',type:[],stage:'',contract:'',signStart:'',signEnd:'',addrKw:''};renderDealers();}
function setPageAll(role,pageId,val){const pages=(role==='dealer')?DEALER_PAGE_PERMS:PAGE_PERMS;const page=pages.find(p=>p.id===pageId);if(!page)return;const ks=[].concat(page.view||[],page.export||[],page.edit||[],page.delete||[]);if(!ks.length)return;const rp=pdRp(role);ks.forEach(k=>{rp[k]=val;});pdMark();renderPerms();toast((val?'已全选「':'已清空「')+page.name+'」(草稿,点「💾 保存权限」生效)');}
function setPagePerm(role,entryId,op,val){let entry=(role==='dealer')?DEALER_PAGE_PERMS.find(p=>p.id===entryId):PAGE_PERMS.find(p=>p.id===entryId);if(!entry)return;const ks=entry[op]||[];if(!ks.length)return;const rp=pdRp(role);ks.forEach(k=>{rp[k]=val;});pdMark();toast((val?'已授权:':'已取消:')+entry.name+' · '+opLabel(op)+'(草稿,点「💾 保存权限」生效)');}
function toggleDlrAdv(){dlrAdvOpen=!dlrAdvOpen;const p=document.getElementById('dlrAdvPanel');if(p)p.style.display=dlrAdvOpen?'block':'none';}
function togglePermGroup(h){const g=h.parentElement;if(!g)return;g.classList.toggle('collapsed');}
function toggleContract(type,id){const o=type==='customer'?DB.customers.find(x=>x.id===id):DB.dealers.find(x=>x.id===id);if(!o){toast('未找到记录');return;}if(type==='dealer'&&dealerLockedFields(o).contractSigned){toast('已签合同继承自转介绍客户,不可修改');return;}const willSign=!o.contractSigned;if(willSign){openContractSignModal(type,id);return;}if(type==='customer'){openCancelContractModal(type,id);return;}
// 乐观更新:先内存+UI更新(按钮立即变绿),再异步轻量保存
// 后端走 /api/db/toggle(白名单 contractSigned,不触发图片识别 / 到期重算 / 全库备份),理想 <500ms
o.contractSigned=willSign;if(!willSign)o.contractDate='';if(type==='dealer'&&willSign&&o.stage==='lead')o.stage='signed_full';if(type==='customer'&&o.convertedDealerId)syncConvertedDealer(o);
const cellId='contractCell-'+type+'-'+id;const cellEl=document.getElementById(cellId);if(cellEl)cellEl.innerHTML=contractToggleHtml(type,id,willSign);
// 经销商签约联动 lead→signed:刷新该行的阶段徽标
if(type==='dealer'){const stageCell=document.getElementById('stageCell-'+id);if(stageCell){stageCell.innerHTML=dealerStageHtml(o);}}
// 触发消息提醒(与原行为一致)
if(type==='dealer'&&willSign)triggerReminder('dealer_contract','经销商「'+o.name+'」签订合同','dealers',id);
if(type==='customer'&&willSign)triggerReminder('contract_signed','客户「'+o.name+'」已签合同'+(o.signDate?'('+o.signDate+')':''),'customers',id);
// 同步待办计数徽标
try{if(typeof renderNav==='function')renderNav();}catch(_){}
// 异步轻量保存——不重渲染整个页面(乐观更新已让 UI 即时反馈)
const t0=Date.now();apiPost('/api/db/toggle',{type:type,id:id,field:'contractSigned',value:willSign,clearDate:!willSign}).then(r=>{if(r&&typeof r.rev==='number')DB.rev=r.rev;const ms=Date.now()-t0;toast(willSign?('已标记签约(' + ms + 'ms)'):('已取消签约(' + ms + 'ms)'));}).catch(e=>{toast('保存失败:'+((e&&e.message)||e));if(cellEl)cellEl.innerHTML=contractToggleHtml(type,id,!willSign);o.contractSigned=!willSign;});
}
function setContractAmount(type,id,val){if(type!=='d'&&!canField('amount','e')){toast('无权限:仅授权人员可修改合同金额');return;}
const e=getFinEntity(type,id);if(!e)return;const v=Number(val);if(!isFinite(v)||v<0){toast('请输入有效的合同金额');refreshFin(type);return;}
openModal(`<div class="modal-head"><h3>⚠️ 确认修改合同金额</h3><span class="x"onclick="closeModal()">×</span></div><div class="modal-body"><p style="margin:0 0 14px;color:#334155;font-size:14px">是否确认修改合同金额?</p><div style="background:#f8fafc;border:1px solid #e2e8f0;border-radius:10px;padding:14px 16px;display:flex;gap:20px;align-items:center;flex-wrap:wrap"><div><div class="mtxt">原合同总额</div><b style="font-size:17px">${money(e.amount)}</b></div><div style="color:#94a3b8;font-size:20px">→</div><div><div class="mtxt" style="color:#dc2626">新合同总额</div><b style="font-size:17px;color:#dc2626">${money(v)}</b></div></div><p class="mtxt" style="margin-top:14px">点击「确认修改」执行金额变更;点击「取消」放弃本次修改,金额保持不变。</p></div><div class="modal-foot"><button class="btn ghost"onclick="closeModal();openPayment('${type}','${id}')">取消</button><button class="btn"style="background:#dc2626"onclick="doSetContractAmount('${type}','${id}',${v})">确认修改</button></div>`);}
function openPayment(type,id){if(!can('view_finance')&&!(type==='d'&&curDealerId===id)){toast('无权限');return;}
const e=getFinEntity(type,id);if(!e)return;const d=type==='d'?null:DB.dealers.find(x=>x.id===e.dealerId);const paid=paidOf(e),owed=owedOf(e);const actualPaid=actualPaidOf(e),deductTotal=deductTotalOf(e);const canReg=finCanReg(type,id)&&!fDeny('payments','e');const canEditAmt=canField('amount','e')||(type==='d'&&curDealerId===id);const canRefund=type==='c'&&can('refund_finance');const title=type==='d'?('我的财务 · '+e.name):('登记交款 · '+e.name);const refundList=((e.refunds||[]).slice().reverse().map(r=>`<tr><td>${r.date||'—'}</td><td><b style="color:#dc2626">${money(r.amount)}</b></td><td title="${esc(refundOwnerLabel(e,r))}">${esc(refundOwnerLabel(e,r))}</td><td class="tdw"><div class="nt-clamp" title="${esc(r.note||'')}">${esc(r.note||'')||'—'}</div></td><td>${canRefund?`<button class="btn ghost sm"onclick="editRefund('${type}','${id}','${r.id}')">修改</button>`:''}${canRefund&&can('del_refunds')?`<button class="btn ghost sm"style="margin-left:4px"onclick="delRefund('${type}','${id}','${r.id}')">删除</button>`:''}</td></tr>`).join(''));const _payLv=fLevel('payments');const list=(_payLv==='hidden'?[]:((e.payments||[]).slice().reverse().map(p=>`<tr>
<td>${p.date||'—'}</td><td><b>${moneyMask(fLevel('payments'),p.amount)}</b></td><td>${deductOf(p)>0?moneyMask(fLevel('payments'),deductOf(p)):'—'}</td><td><b style="color:#1d4ed8">${moneyMask(fLevel('payments'),actualOf(p))}</b></td><td>${p.method||'—'}</td><td title="${type==='c'?esc(fTxt('c_payowner',payOwnerLabel(e,p))):''}">${type==='c'?esc(fTxt('c_payowner',payOwnerLabel(e,p))):'—'}</td><td title="${type==='c'?esc(fTxt('c_payowner',payOwnerDealerName(e,p))):''}">${type==='c'?esc(fTxt('c_payowner',payOwnerDealerName(e,p))):'—'}</td><td class="tdw"><div class="nt-clamp" title="${esc(p.note||'')}">${esc(p.note||'')||'—'}</div></td>
<td>${canReg?`<button class="btn ghost sm"onclick="editPayment('${type}','${id}','${p.id}')">修改</button>`:''}${canReg&&can('del_payments')?`<button class="btn ghost sm"style="margin-left:4px"onclick="delPayment('${type}','${id}','${p.id}')">删除</button>`:''}</td>
</tr>`).join('')||'<tr><td colspan="9" class="empty">'+(_payLv==='hidden'?'无权限查看交款记录':'暂无交款记录')+'</td></tr>'));openModal(`
<div class="modal-head"><h3>${title}</h3><span class="x" onclick="closeModal()">×</span></div>
<div class="modal-body">
${(type==='c'&&(String(e.pkgId||'')==='pkg_annual'||(Array.isArray(e.itemIds)&&e.itemIds.length)))?('<div class="mtxt" style="margin-bottom:8px">📦 本单项目:<b>'+esc(orderItemsTextOf(e))+'</b></div>'):''}
<div style="display:flex;gap:8px;flex-wrap:wrap;margin-bottom:12px">
<div style="flex:1;min-width:110px;background:#f8fafc;border:1px solid #e2e8f0;border-radius:10px;padding:8px 10px"><div style="font-size:11px;color:#64748b">合同总额</div>${canEditAmt&&fLevel('amount')==='full'?`<input style="width:100%;font-size:15px;font-weight:700;border:1px solid #cbd5e1;border-radius:6px;padding:2px 4px"type="number"min="0"step="0.01"value="${e.amount}"onchange="setContractAmount('${type}','${id}',this.value)"title="修改合同总额">`:`<b style="font-size:15px">${moneyMask(fLevel('amount'),e.amount)}</b>`}</div>
<div style="flex:1;min-width:110px;background:#f0fdf4;border:1px solid #bbf7d0;border-radius:10px;padding:8px 10px"><div style="font-size:11px;color:#16a34a">已交款</div><b style="font-size:15px;color:#16a34a">${moneyMask(fLevel('amount'), paid)}</b></div><div style="flex:1;min-width:110px;background:#eff6ff;border:1px solid #bfdbfe;border-radius:10px;padding:8px 10px"><div style="font-size:11px;color:#1d4ed8">实收合计</div><b style="font-size:15px;color:#1d4ed8">${moneyMask(fLevel('amount'), actualPaid)}</b>${deductTotal>0?`<div style="font-size:10.5px;color:#64748b">抵扣 ${moneyMask(fLevel('amount'), deductTotal)}</div>`:''}</div>
<div style="flex:1;min-width:110px;background:#fef2f2;border:1px solid #fecaca;border-radius:10px;padding:8px 10px"><div style="font-size:11px;color:#dc2626">欠款</div><b style="font-size:15px;color:${owed>0?'#dc2626':'#16a34a'}">${moneyMask(fLevel('amount'), owed)}</b></div>
${type==='d'?'':`<div style="flex:1;min-width:110px;background:#f8fafc;border:1px solid #e2e8f0;border-radius:10px;padding:8px 10px"><div style="font-size:11px;color:#64748b">归属经销商</div><b style="font-size:13px">${d?d.name:'—'}</b></div>`}
</div>
${canReg?`<div class="section-title"style="margin-top:2px">登记一笔交款(可分次)</div><div class="row2"><div class="field"><label>交款日期</label><input type="date"id="pay_date"value="${today()}"></div><div class="field"><label>交款金额*</label><input type="number"id="pay_amt"placeholder="本次交款金额"min="0"step="0.01"oninput="payCalcActual('pay_')"onchange="payCalcActual('pay_')"></div></div><div class="row2"><div class="field"><label>抵扣金额</label><input type="number"id="pay_deduct"value="0"min="0"step="0.01"oninput="payCalcActual('pay_')"onchange="payCalcActual('pay_')"></div><div class="field"><label>实收金额(自动 = 交款金额 − 抵扣金额)</label><input type="number"id="pay_actual"value="0"readonly style="background:#f1f5f9;font-weight:700"></div></div><div class="row2"><div class="field"><label>交款方式</label><select id="pay_method"><option>微信</option><option>支付宝</option><option>银行转账</option><option>现金</option><option>对公转账</option><option>其他</option></select></div><div class="field"><label>下次补款日期</label><input type="date"id="pay_next"value="${e.nextPayAt||''}"></div></div><div class="field"><label>备注</label><input id="pay_note"placeholder="如:第1期 / 定金 / 尾款"></div>${type==='c'?payOwnerFields(type,id,'pay_'):''}`:'<div class="mtxt" style="margin-top:8px">当前账号无登记权限,仅可查看。</div>'}
${canRefund?`<div class="section-title" style="margin-top:16px;border-top:1px dashed #fecaca;padding-top:14px">💸 办理退费(可分次)</div><div class="row2"><div class="field"><label>退费日期</label><input type="date"id="refund_date"value="${today()}"></div><div class="field"><label>退费金额*</label><input type="number"id="refund_amt"placeholder="本次退费金额"min="0"step="0.01"></div></div><div class="field"><label>退费备注</label><input id="refund_note"placeholder="如:客户取消合作 / 部分退费"></div>${refundOwnerFields(e)}${refundOwnerHint(e)}<div style="margin-top:8px;display:flex;gap:8px;align-items:center;flex-wrap:wrap"><button class="btn"style="background:#dc2626"onclick="addRefund('${type}','${id}')">💸 保存退费</button><span class="mtxt">保存后:状态→已退费 · 欠款清零 · 合同总额与已交款不变</span></div>`:''}${canRefund&&refundList?`<div class="section-title" style="margin-top:14px">已登记退费记录</div><div class="card" style="margin:0;padding:10px 12px;overflow-x:auto"><table class="payhist"><colgroup><col style="width:92px"><col style="width:92px"><col style="width:150px"><col><col style="width:132px"></colgroup><thead><tr><th>退费日期</th><th>金额</th><th>冲减归属</th><th>备注</th><th></th></tr></thead><tbody>${refundList}</tbody></table></div>`:''}
<div class="section-title" style="margin-top:14px">已登记交款记录</div>
<div class="card" style="margin:0;padding:10px 12px;overflow-x:auto"><table class="payhist">
<colgroup><col style="width:84px"><col style="width:86px"><col style="width:68px"><col style="width:86px"><col style="width:56px"><col style="width:96px"><col style="width:100px"><col><col style="width:132px"></colgroup>
<thead><tr><th>日期</th><th>交款金额</th><th>抵扣</th><th>实收</th><th>方式</th><th>业绩归属</th><th>归属经销商</th><th>备注</th><th></th></tr></thead>
<tbody id="payList">${list}</tbody></table></div>
<div class="modal-foot" style="padding:0;margin-top:14px">
<button class="btn ghost" onclick="closeModal()">关闭</button>
${canReg?`<button class="btn"onclick="addPayment('${type}','${id}')">保存交款记录</button>`:''}
</div>
</div>`);
/* ★ 2026-10-14 第 457 轮(主人拍板 A+):从客户列表「+交款」进入时,金额框自动聚焦 + 回车即提交 */
try{ if(document.querySelector('#custTbl')||document.querySelector('#custDealTbl')){ var _qa=document.getElementById('pay_amt');
if(_qa){ _qa.focus(); _qa.onkeydown=function(ev){ ev=ev||window.event;
if(ev&&(ev.key==='Enter'||ev.keyCode===13)){ ev.preventDefault(); addPayment(type,id); } }; } } }catch(e){}
}
function renderDealers(){_snapScroll();if(dealerPageTab==='comm'&&can('view_dealer_comm')){renderDealerComm();return;}if(dealerPageTab==='comm')dealerPageTab='list';regExportScope('dealers',{unit:'条',title:'经销商管理',desc:'一行 = 一个经销商档案(字段级权限照旧生效)。',list:function(){return dealerFilteredList(true);},all:function(){return DB.dealers.slice();},warn:expScopeWarnAmount});let list=dealerFilteredList(false);/* ★ 第272轮:筛选逻辑上移 dealerFilteredList(),与「导出范围」同源。★ 第286轮:同 custFilteredList —— 渲染传 false、导出传 true。★ 第287轮:本份(生效份)补登记 regExportScope('dealers',…) —— 原登记只写在被覆盖的死份里,导致本页导出从不弹「当前筛选 / 全部」范围框。 */
let rows=list.length?list.map(d=>{const s=dealerStats(d.id);const cfg=DT(d.type);let assess='—';if(d.type==='resource'||d.type==='official'){assess=s.signedFull>=10?'<span class="st st-signed_full">达标</span>':'<span class="st st-following">未达标('+s.signedFull+'/10)</span>';}
else if(d.type==='experience'){assess=s.signedFull>=5?'<span class="st st-signed_full">达标</span>':'<span class="st st-following">未达标('+s.signedFull+'/5)</span>';}
else if(d.type==='campus'){assess=s.signedFull>=5?'<span class="st st-signed_full">达标</span>':'<span class="st st-following">未达标('+s.signedFull+'/5)</span>';}
else if(d.type==='staff'){const mp=staffMonthPaidTotal(d.id);assess=mp>=100000?'<span class="st st-signed_full">达标 4.5%</span>':'<span class="st st-following">未达标(当月'+money(mp)+'/10万)</span>';}
return`<tr data-search="${(fTxt('d_name',d.name)+' '+(fLevel('d_type')==='full'?(d.region||''):'')+' '+(fLevel('phone')==='full'?(d.phone||''):'')+' '+(fLevel('addr')==='full'?([d.prov,d.city,d.district,d.addr].filter(Boolean).join(' ')):'')).replace(/"/g,'')}">
<td class="wrap"><b>${fTxt('d_name',d.name)}</b><div class="mtxt">${fTxt('d_type',d.region||'')}</div></td>
<td>${fLevel('d_type')==='full'?('<span class="tag '+cfg.color+'">'+cfg.name+'</span>'):('<span class="tag '+cfg.color+'">'+(fLevel('d_type')==='mask'?'***':'****')+'</span>')}</td>
<td>${dealerStaffCellHtml(d)}</td>
<td><span id="stageCell-${d.id}">${fLevel('d_stage')==='full'?dealerStageHtml(d):'<span class="st">'+(fLevel('d_stage')==='mask'?'***':'****')+'</span>'}</span></td>
<td>${moneyMask(fLevel('amount_dealer'), cfg.fee)}</td>
<td>${moneyMask(fLevel('amount_dealer'), paidOf(d))}</td>
<td>${fLevel('d_rate')==='full'?pct(cfg.baseRate*100)+'<div class="mtxt">'+campusNote(d.type)+'</div>':('<span class="mtxt">'+(fLevel('d_rate')==='mask'?'***':'****')+'</span>')}</td>
<td>${fLevel('d_reported')==='full'?s.reported:(fLevel('d_reported')==='mask'?'***':'****')}</td><td>${fLevel('d_signed')==='full'?s.signedFull:(fLevel('d_signed')==='mask'?'***':'****')}</td>
<td>${moneyMask(fLevel('amount_dealer'), s.commission)}<div class="mtxt">已发 ${moneyMask(fLevel('amount_dealer'), commissionPaid(d))}</div>${fLevel('d_payowner')==='full'?perfOwnerDealerBadgeHtml(d):''}</td>
<td>${fLevel('d_assess')==='full'?assess:(fLevel('d_assess')==='mask'?'***':'****')}</td>
<td>${d.signDate?(fLevel('d_sign')==='full'?signDateCN(d.signDate):fDateTxt('d_sign',d.signDate)):'—'}</td><td>${fToggle('d_sign',contractToggleHtml('dealer',d.id,d.contractSigned),d.contractSigned)}</td>
<td style="white-space:nowrap">
${can('view_dealers')?`<button class="btn ghost sm"onclick="openDealerDetail('${d.id}')">详情/跟进</button>`:''}
${rowMoreBtn('dealer',d.id)}
</td>
</tr>`;}).join(''):'<tr><td colspan="14" class="empty">暂无经销商,点击右上角添加</td></tr>';view.innerHTML=`
<div class="topbar"><div><h1>经销商管理</h1><p>四类经销商权益与考核标准一览</p></div>
${can('add_dealers')?`<button class="btn"onclick="openDealer()">+新增经销商</button>`:''}</div>
${dealerTabBar()}
<div class="search">
<input id="dlrSearch" class="list-search" placeholder="🔍 搜索经销商名称 / 区域 / 手机号" value="${esc(dealerFilter.q)}" oninput="dealerSearch(this.value)" oncompositionend="dealerSearch(this.value)">
<button class="btn ghost sm" type="button" onclick="toggleDlrAdv()">🔍 高级筛选</button>
<button class="btn ghost sm" onclick="resetDealerFilter()">重置</button>
</div>
<div class="card" id="dlrAdvPanel" style="${dlrAdvOpen?'':'display:none;'}margin-bottom:14px;padding:16px 18px">
<div class="row2">
<div class="field"><label>经销商类型(可多选)</label>${dlrTypeChipsHtml()}</div>
<div class="field"><label>客户状态</label><select id="dlrFilter_stage"><option value="">全部状态</option>${Object.entries(STAGES).map(([k,v])=>`<option value="${k}"${dealerFilter.stage===k?'selected':''}>${v.label}</option>`).join('')}</select></div>
</div>
<div class="row2">
<div class="field"><label>已签合同</label><select id="dlrFilter_contract"><option value="">全部</option><option value="1"${dealerFilter.contract==='1'?'selected':''}>已签</option><option value="0"${dealerFilter.contract==='0'?'selected':''}>未签</option></select></div>
<div class="field"><label>地址关键词</label><input id="dlrFilter_addr" placeholder="省 / 市 / 区 / 详细地址" value="${esc(dealerFilter.addrKw)}"></div>
</div>
<div class="row2">
<div class="field"><label>达成合作日期(起)</label><input type="date" id="dlrFilter_signStart" value="${dealerFilter.signStart}"></div>
<div class="field"><label>达成合作日期(止)</label><input type="date" id="dlrFilter_signEnd" value="${dealerFilter.signEnd}"></div>
</div>
</div>
<div class="card table-card"><table id="dlrTable">
<thead><tr><th>名称</th><th>类型</th><th>对接员工</th><th>客户状态</th><th>预缴费用</th><th>已交款</th><th>分润比例</th><th>报备</th><th>已交款</th><th>应发分润</th><th>考核</th><th>达成合作日期</th><th>已签合同</th><th>操作</th></tr></thead>
<tbody>${rows}</tbody></table></div>
<div class="section-title">四类经销商权益速览</div>
<div class="grid three-col">
${Object.entries(DEALER_TYPES).map(([k,c])=>`<div class="card"><span class="tag ${c.color}">${c.name}</span><p class="mtxt"style="margin:10px 0">${c.note}</p><div style="font-size:12px"><b>考核:</b>${c.assessment}</div><div style="font-size:12px;margin-top:4px"><b>预缴:</b>${money(c.fee)}|<b>基础分润:</b>${pct(c.baseRate*100)}</div></div>`).join('')}
</div>`;if(typeof colLayoutApply==='function')colLayoutApply('crm_dealer');dealerSearch(dealerFilter.q);bindDealerFilter();requestAnimationFrame(function(){_restoreScroll();});}
function geoFillAddr(prefix,btnId){injectGeoStyles();const btn=btnId?document.getElementById(btnId):null;const setBtn=(state,text)=>{if(btn){btn.classList.remove('loading','ok','err');if(state)btn.classList.add(state);btn.disabled=state==='loading';btn.textContent=text;}};if(!navigator.geolocation){toast('当前浏览器不支持定位');return;}
setBtn('loading','⌛ 正在定位…');const finish=(ok)=>{setTimeout(()=>{if(btn){btn.classList.remove('ok','err');btn.disabled=false;btn.textContent='📍 自动定位';}},ok?3500:4500);};navigator.geolocation.getCurrentPosition(async pos=>{const{latitude:lat,longitude:lng,accuracy}=pos.coords;const coordStr=`GPS定位 ${lat.toFixed(5)},${lng.toFixed(5)}(约${Math.round(accuracy||0)}米)`;setBtn('loading','⌛ 解析地址…');let prov='',city='',district='',street='';const tryOne=async(url)=>{const ctrl=new AbortController();const t=setTimeout(()=>ctrl.abort(),6000);const r=await fetch(url,{signal:ctrl.signal});clearTimeout(t);return await r.json();};try{const j=await tryOne(`https://nominatim.openstreetmap.org/reverse?format=jsonv2&lat=${lat}&lon=${lng}&accept-language=zh-CN&zoom=18&addressdetails=1`);const a=(j&&j.address)||{};prov=a.state||a.region||a.province||'';city=a.city||a.town||a.village||a.county||'';district=(a.city_district||(a.county&&a.county!==city?a.county:'')||a.suburb||a.neighbourhood||'')||'';street=[a.road,a.house_number?`${a.house_number}号`:''].filter(x=>x).join(' ');if(!prov&&!city&&!street)throw new Error('empty');}catch(_){try{const j=await tryOne(`https://photon.komoot.io/reverse?lon=${lng}&lat=${lat}&lang=zh`);const f=(j&&j.features&&j.features[0])?j.features[0].properties:{};prov=f.state||'';city=f.city||'';district=(f.county&&f.county!==city)?f.county:(f.suburb||f.neighbourhood||'');street=[f.street,f.name].filter(x=>x).join(' ');}catch(_e){}}
const normArea=(name,keys)=>{name=String(name||'').trim();if(!name)return '';if(keys.indexOf(name)>=0)return name;const strip=s=>s.replace(/(特别行政区|自治区|自治州|地区|省|市|盟|县|区|旗)$/,'');const core=strip(name);return keys.find(k=>k===core||strip(k)===core)||'';};
const provs=Object.keys(AREA_DATA);const provKey=normArea(prov,provs);
const cityKeys=(provKey&&AREA_DATA[provKey])?Object.keys(AREA_DATA[provKey]):[];
const cityKey=normArea(city,cityKeys);
const distKeys=(provKey&&cityKey&&AREA_DATA[provKey]&&AREA_DATA[provKey][cityKey])?AREA_DATA[provKey][cityKey]:[];
const distKey=normArea(district,distKeys);
fillAreaSelect(prefix,provKey,cityKey,distKey,street,(prefix==='dr'));
const ok=!!(provKey||cityKey||street);if(ok){setBtn('ok','✅ 已定位(可继续修改)');toast('📍 定位成功,请核对并补充门牌号');}
else{setBtn('err','⚠️ 已填坐标,请补地址');toast('网络受限仅填入坐标,请手动补充详细地址');}
finish(ok);},err=>{setBtn('err','❌ 定位失败');const msg=err.code===1?'已取消定位权限,请在浏览器中允许':err.code===2?'定位不可用,请检查GPS/网络':'定位超时,请到室外空旷处重试';toast('❌ '+msg);finish(false);},{enableHighAccuracy:true,timeout:12000,maximumAge:60000});}
function saveDealerDetail(id){const d=DB.dealers.find(x=>x.id===id);if(!d)return;if(can('edit_dealers')){const name=document.getElementById('u_name').value.trim();if(!name){toast('请填写经销商名称');return;}
const wasStage=d.stage;const locked=dealerLockedFields(d);const upd={name:locked.name?d.name:name,type:document.getElementById('u_type').value,amount:d.amount,prov:locked.prov?d.prov:document.getElementById('u_prov').value.trim(),city:locked.city?d.city:document.getElementById('u_city').value.trim(),district:locked.district?d.district:document.getElementById('u_district').value.trim(),addr:locked.addr?d.addr:document.getElementById('u_addr').value.trim(),region:document.getElementById('u_region').value.trim(),signDate:locked.signDate?d.signDate:(document.getElementById('u_sign').value||''),stage:locked.stage?d.stage:document.getElementById('u_stage').value};if(canField('phone','e'))upd.phone=locked.phone?d.phone:document.getElementById('u_phone').value.trim();if(dealerDeadStageOn(upd.stage)&&upd.stage!==wasStage&&!dealerDeadAcked(id,upd.stage)){const _ddc429=dealerDeadCustsOf(id);if(_ddc429.length){showDealerDeadCustBar(id,upd.stage,_ddc429,'d');return;}}Object.assign(d,upd);if(upd.stage!==wasStage){if(upd.stage==='refunded')triggerReminder('dealer_refund','经销商「'+name+'」已退费','dealers',id);else if(upd.stage==='exhausted')triggerReminder('dealer_exhausted','经销商「'+name+'」已消耗终止合作','dealers',id);else if(upd.stage==='terminated')triggerReminder('dealer_terminated','经销商「'+name+'」已终止合作','dealers',id);}}
if(can('edit_dealers')){const content=((document.getElementById('df_content')||{}).value||'').trim();if(content){d.followups=d.followups||[];d.followups.push({date:today(),method:((document.getElementById('dealer_method')||{}).value||''),operator:((document.getElementById('df_op')||{}).value||'').trim(),content,nextAt:((document.getElementById('df_next')||{}).value||'')});}}
save().then(ok=>{if(ok){closeModal();render();toast('已保存经销商更新');}});}
/* ===== 表格列布局(2026-10-11 第 414 轮):列宽拖拽 + 列序拖拽 + 本机记忆 =====
需求(主人):客户看板 / 客户管理列表 / 交付矩阵 —— 像 Excel 一样拖列宽、拖列序,本机记住。
设计(零侵入,五条):
① 不改任何渲染函数、不改源码里的默认列宽与列序 ⇒ 用户没调过时,页面与改动前逐字节一致。
board.html 的 14 条 table th:nth-child(n){width:Mpx} 与 table-layout:fixed 原样保留,
自检 #199 board_colw_guard / #200 board_note_layout_guard 判据一字未动。
② 调过之后:偏好存本机 localStorage(键前缀 crm_col_layout_v1:);每次渲染后用 colgroup
覆盖列宽 + 重排 DOM 单元格实现列序 ⇒ 轮询重渲染不会把设置弹回。
③ 首列(编号 / 序号 / 客户)是冻结列,固定第 1 位、不参与换序;仅鼠标指针(非触屏)启用。
④ 「恢复默认」= 删偏好 + 拆 colgroup + 还原 table-layout / min-width + 按默认顺序重排,
全程只动 DOM,不触发重渲染。
⑤ 本引擎不写库、不写源码:内部不得出现任何向后端提交或写盘的调用(守卫 #252 反断言)。
⚠️ 抓手用 absolute 定位 ⇒ 只在 th 计算样式为 static 时才补 position:relative;
board.html 的表头是 position:sticky(吸附表头),绝不能覆盖成 relative。 */
var COL_LS = 'crm_col_layout_v1:';
var COL_BASE = new WeakMap();
var COL_DRAG = null;
/* ★ 2026-10-14 第 459 轮(主人「a 执行」):列显示开关 —— 与列宽/列序同一套本机记忆(crm_col_layout_v1:)。
① COL_DEFAULT_HIDDEN = 各表「默认隐藏」的列名(用户没调过就按此显示,页面更窄);
② cfg.hidden = [列名…] 存同一本机键;勾选即存即生效,轮询重渲染不弹回;
③ 首列(勾选 / 编号 / 客户)不可隐藏(与「首列固定」同纪律);
④ 纯本机:不写库、不提交。表格容器 overflow 与列宽拖拽、列序拖拽互不影响。 */
var COL_DEFAULT_HIDDEN = {
crm_cust: ['服务老师', '报备日期'],
crm_dealer: ['预缴费用', '达成合作日期'],
deliv_matrix: []
};
var COL_TITLE = { crm_cust: '客户列表', crm_dealer: '经销商管理', deliv_matrix: '交付矩阵', board_cust: '客户看板' };
function colHiddenOf(key) {
var cfg = colLoad(key);
var out = (cfg && cfg.hidden) ? cfg.hidden.slice() : (COL_DEFAULT_HIDDEN[key] || []).slice();
if (key === 'crm_cust') out = out.filter(function (k) { return k !== '客户'; }); /* ★ 第587轮:客户列为主冻结列,旧偏好里存过也不隐藏 */
return out;
}
function colApplyHidden(tbl, hidden) {
var ks = colKeysOf(tbl); if (!ks.length) return;
var hide = {}; (hidden || []).forEach(function (k) { hide[k] = 1; });
var secs = [tbl.tHead].concat([].slice.call(tbl.tBodies));
secs.forEach(function (sec) {
if (!sec) return;
[].slice.call(sec.rows).forEach(function (tr) {
if (tr.cells.length !== ks.length) return; /* 小计行 / 空数据行不参与 */
[].slice.call(tr.cells).forEach(function (c, i) { c.style.display = hide[ks[i]] ? 'none' : ''; });
});
});
var cg = tbl.querySelector('colgroup[data-cl]');
if (cg) [].slice.call(cg.children).forEach(function (co, i) { co.style.display = hide[ks[i]] ? 'none' : ''; });
}
function colSettingsSave(key, ks, box) {
var hid = [];
[].slice.call(box.querySelectorAll('input[data-col-idx]')).forEach(function (cb) {
if (cb.checked) return;
var k = ks[+cb.getAttribute('data-col-idx')];
if (k && k !== ks[0]) hid.push(k); /* 首列不可隐藏 */
});
hid = hid.filter(function (k) { return k !== '客户'; }); /* ★ 第587轮:客户列为主冻结列,禁止隐藏 */
var cfg = colLoad(key) || {};
cfg.hidden = hid;
colSave(key, cfg);
colLayoutApply(key);
}
function colSettingsReset(key) {
var cfg = colLoad(key) || {};
delete cfg.hidden; /* 回到「默认隐藏」清单 */
if (cfg.order || cfg.widths) colSave(key, cfg); else colSave(key, null);
closeModal();
colLayoutApply(key);
toast('已恢复默认列显示');
}
function colSettingsOpen(t) {
var tbl = document.querySelector(t.sel); if (!tbl) return;
var ks = colKeysOf(tbl); if (!ks.length) return;
var hid = colHiddenOf(t.key);
var rows = ks.map(function (k, i) {
var on = hid.indexOf(k) < 0;
var fixed = i === 0 || k === '客户';
return '<label style="display:flex;align-items:center;gap:8px;padding:5px 0;font-size:13px;cursor:pointer">' +
'<input type="checkbox" data-col-idx="' + i + '"' + (on ? ' checked' : '') + (fixed ? ' disabled' : '') + '> ' + esc(k) +
(fixed ? ' <span class="mtxt">(固定显示)</span>' : '') + '</label>';
}).join('');
openModal('<div class="modal-head"><h3>⚙ 列设置 · ' + esc(COL_TITLE[t.key] || t.key) + '</h3><span class="x" onclick="closeModal()">×</span></div>' +
'<div class="modal-body"><div class="mtxt" style="margin-bottom:8px">勾掉不需要的列,表格立刻变窄(列宽、列序拖拽不受影响)。设置只保存在本机浏览器。</div>' +
'<div style="max-height:52vh;overflow:auto">' + rows + '</div></div>' +
'<div class="modal-foot"><button class="btn ghost" onclick="colSettingsReset(\'' + t.key + '\')">恢复默认列</button>' +
'<button class="btn" onclick="closeModal()">完成</button></div>');
var box = document.getElementById('modal'); if (!box) return;
[].slice.call(box.querySelectorAll('input[data-col-idx]')).forEach(function (cb) {
cb.onchange = function () { colSettingsSave(t.key, ks, box); };
});
}
function colKeysOf(tbl) {
var tr = tbl && tbl.tHead && tbl.tHead.rows[0]; if (!tr) return [];
var out = [], seen = {};
[].slice.call(tr.cells).forEach(function (th) {
var t = (th.textContent || '').replace(/\s+/g, ' ').trim() || '列';
seen[t] = (seen[t] || 0) + 1;
out.push(seen[t] > 1 ? (t + '#' + seen[t]) : t);
});
return out;
}
function colLoad(k) { try { var s = localStorage.getItem(COL_LS + k); return s ? JSON.parse(s) : null; } catch (e) { return null; } }
function colSave(k, v) { try { if (v) { localStorage.setItem(COL_LS + k, JSON.stringify(v)); } else { localStorage.removeItem(COL_LS + k); } } catch (e) {} }
function colSnapshot(tbl) {
var tr = tbl && tbl.tHead && tbl.tHead.rows[0]; if (!tr) return {};
var ks = colKeysOf(tbl), o = {};
[].slice.call(tr.cells).forEach(function (th, i) { o[ks[i]] = Math.round(th.getBoundingClientRect().width) || 100; });
return o;
}
function colApplyOrder(tbl, order) {
var cur = colKeysOf(tbl); if (cur.length < 2) return;
var pos = {}; cur.forEach(function (k, i) { pos[k] = i; });
/* ★ 第587轮:客户列为主冻结列 —— 位置钉死,不参与换序(旧偏好里被拖走过也要拉回原位) */
var pinK = pos['客户'] !== undefined ? '客户' : '';
var pinI = pinK ? pos[pinK] : -1;
var want = [];
order.forEach(function (k) { if (pos[k] !== undefined && want.indexOf(k) < 0) want.push(k); });
cur.forEach(function (k) { if (want.indexOf(k) < 0) want.push(k); });
if (pinK) { want = want.filter(function (k) { return k !== pinK; }); want.splice(pinI, 0, pinK); }
if (want.join('|') === cur.join('|')) return;
var idx = want.map(function (k) { return pos[k]; });
var secs = [tbl.tHead].concat([].slice.call(tbl.tBodies));
secs.forEach(function (sec) {
if (!sec) return;
[].slice.call(sec.rows).forEach(function (tr) {
if (tr.cells.length !== cur.length) return; /* 小计行 / 空数据行(含 colspan)不参与重排 */
var cs = [].slice.call(tr.cells);
idx.forEach(function (i) { tr.appendChild(cs[i]); });
});
});
}
function colApplyWidth(tbl, widths) {
var ks = colKeysOf(tbl); if (!ks.length) return;
var sn = colSnapshot(tbl);
var cg = tbl.querySelector('colgroup[data-cl]');
if (!cg) { cg = document.createElement('colgroup'); cg.setAttribute('data-cl', '1'); tbl.insertBefore(cg, tbl.firstChild); }
while (cg.firstChild) cg.removeChild(cg.firstChild);
var tot = 0;
ks.forEach(function (k) {
var w = Math.max(40, Math.round((widths && widths[k]) || sn[k] || 100));
/* ★ 第591轮 A(主人 2026-10-23「A+B」):客户表「客户」列的下限必须落在列宽引擎里。
为什么:一旦用户有列宽偏好,本函数会设 table-layout:fixed,而 fixed 布局下 td 的
min-width 被浏览器忽略(Edge 18 列实测:旧偏好 114px 仍渲染 114px)⇒ 590 轮的 CSS 下限对该场景失效。
只作用于 custTbl 的「客户」列;其余表 / 其余列一律不受影响。 */
if (tbl.id === 'custTbl' && k === '客户') w = Math.max(176, w);
tot += w;
var co = document.createElement('col'); co.style.width = w + 'px'; cg.appendChild(co);
});
if (tbl.getAttribute('data-cl-min') == null) tbl.setAttribute('data-cl-min', tbl.style.minWidth || '');
var m0 = parseInt(tbl.getAttribute('data-cl-min'), 10); if (isNaN(m0)) m0 = 0;
tbl.style.minWidth = Math.max(m0, tot) + 'px';
tbl.style.tableLayout = 'fixed';
}
function colMarkHeads(tbl) {
var tr = tbl && tbl.tHead && tbl.tHead.rows[0]; if (!tr) return;
var ks = colKeysOf(tbl);
[].slice.call(tr.cells).forEach(function (th, i) {
th.setAttribute('data-cl-key', ks[i]);
try { if (window.getComputedStyle(th).position === 'static') th.style.position = 'relative'; } catch (e) {}
if (!th.querySelector('[data-cl-rz]')) {
var r = document.createElement('i');
r.setAttribute('data-cl-rz', '1');
r.style.cssText = 'position:absolute;top:0;right:0;width:8px;height:100%;cursor:col-resize;z-index:6;';
th.appendChild(r);
}
});
}
function colOnMove(e) {
var d = COL_DRAG; if (!d) return;
if (d.mode === 'w') {
var nw = Math.max(40, Math.round(d.w0 + e.clientX - d.x0));
d.cfg.widths[d.col] = nw;
var cg = d.tbl.querySelector('colgroup[data-cl]');
if (cg) { var ks = colKeysOf(d.tbl), i = ks.indexOf(d.col); if (cg.children[i]) cg.children[i].style.width = nw + 'px'; }
d.moved = 1; e.preventDefault(); return;
}
if (Math.abs(e.clientX - d.x0) < 5) return;
var el = document.elementFromPoint(e.clientX, e.clientY);
var th2 = el && el.closest ? el.closest('th') : null;
if (!th2 || !d.tbl.tHead || th2.parentNode !== d.tbl.tHead.rows[0]) return;
var k2 = th2.getAttribute('data-cl-key');
if (!k2 || k2 === d.col || k2 === d.first) return; /* 首列不参与换序 */
var cur = colKeysOf(d.tbl), a = cur.indexOf(d.col), b = cur.indexOf(k2);
if (a < 1 || b < 1) return;
cur.splice(a, 1); cur.splice(b, 0, d.col);
d.cfg.order = cur.slice();
colApplyOrder(d.tbl, cur);
d.moved = 1; e.preventDefault();
}
function colOnUp() {
var d = COL_DRAG; if (!d) return;
COL_DRAG = null;
try { document.body.style.userSelect = ''; } catch (e) {}
if (d.moved) { colSave(d.key, d.cfg); colLayoutApply(d.key); }
}
function colBindDrag(tbl, key) {
if (tbl.__clD) return; tbl.__clD = 1;
tbl.addEventListener('pointerdown', function (e) {
if (e.pointerType && e.pointerType !== 'mouse') return; /* 触屏不启用(手机端保持默认排法) */
var th = e.target.closest ? e.target.closest('th') : null;
if (!th || !tbl.tHead || th.parentNode !== tbl.tHead.rows[0]) return;
var ks = colKeysOf(tbl), idx = [].slice.call(tbl.tHead.rows[0].cells).indexOf(th);
if (idx < 0) return;
var isRz = !!(e.target.getAttribute && e.target.getAttribute('data-cl-rz'));
if (!isRz && idx === 0) return; /* 首列固定 */
if (!isRz && ks[idx] === '客户') return; /* ★ 第587轮:客户列为主冻结列,不参与换序 */
var cfg = colLoad(key) || {};
if (!cfg.widths) cfg.widths = colSnapshot(tbl); /* 首次拖动:先量取「自动列宽」下各列真实宽度,再上锁 ⇒ 视觉零跳变 */
if (!cfg.order) cfg.order = ks.slice();
colApplyWidth(tbl, cfg.widths);
COL_DRAG = { mode: isRz ? 'w' : 'o', tbl: tbl, key: key, col: ks[idx], first: ks[0], x0: e.clientX, w0: th.getBoundingClientRect().width, cfg: cfg };
try { document.body.style.userSelect = 'none'; } catch (err) {}
e.preventDefault();
});
}
function colBar(t) {
var first = document.querySelector(t.sel); if (!first) return;
var anchor = first.closest('.tbl-wrap') || first.closest('.table-card') || first;
if (anchor.parentNode && /Card$/.test(anchor.parentNode.id || '')) anchor = anchor.parentNode;
var host = anchor.parentNode; if (!host) return;
var bid = 'colBar-' + t.key;
if (host.querySelector('#' + bid)) return;
var bar = document.createElement('div'); bar.id = bid;
bar.style.cssText = t.key === 'crm_cust' ? 'display:flex;align-items:center;gap:8px;margin:0;padding:7px 14px;font-size:11.5px;color:#94a3b8;flex-wrap:wrap;border:1px solid var(--line);border-bottom:0;border-radius:var(--r-lg) var(--r-lg) 0 0;background:var(--panel)' : 'display:flex;align-items:center;gap:8px;margin:0;padding:3px 0 6px;font-size:11.5px;color:#94a3b8;flex-wrap:wrap';
var sp = document.createElement('span');
sp.textContent = '可拖动表头右边界调整列宽、左右拖动表头调整列顺序(首列固定);设置保存在本机浏览器';
sp.style.cssText = 'min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap';sp.title = sp.textContent;
var bt = document.createElement('button');
bt.type = 'button'; bt.className = 'btn ghost sm'; bt.style.marginLeft = 'auto';
bt.textContent = '恢复默认列宽 / 顺序';
bt.onclick = function () { colLayoutReset(t.key); };
bar.appendChild(sp); bar.appendChild(bt);
var bs = document.createElement('button');
bs.type = 'button'; bs.className = 'btn ghost sm'; bs.textContent = '⚙ 列设置';
bs.onclick = function () { colSettingsOpen(t); };
bar.appendChild(bs);
host.insertBefore(bar, anchor);
}
function colLayoutReset(key) {
colSave(key, null);
var t = null; window.COL_TARGETS.forEach(function (x) { if (x.key === key) t = x; });
if (!t) return;
[].slice.call(document.querySelectorAll(t.sel)).forEach(function (tbl) {
var cg = tbl.querySelector('colgroup[data-cl]');
if (cg && cg.parentNode) cg.parentNode.removeChild(cg);
tbl.style.tableLayout = '';
var m0 = tbl.getAttribute('data-cl-min');
if (m0 != null) { tbl.style.minWidth = m0; tbl.removeAttribute('data-cl-min'); }
var base = COL_BASE.get(tbl); if (base) colApplyOrder(tbl, base);
var tr = tbl.tHead && tbl.tHead.rows[0];
if (tr) [].slice.call(tr.cells).forEach(function (th) { th.style.position = ''; });
colApplyHidden(tbl, colHiddenOf(key)); /* ★ 第 459 轮:恢复默认列显示 */
});
}
/* ★ 第587轮:客户表「客户」列冻结 —— 量取勾选列真实宽度写进 CSS 变量 --cust-pin-l
(不写死 36px:列宽被拖过 / 不同角色有没有勾选列,两种情况都不会错位) */
function custPinSync(tbl) {
if (!tbl || tbl.id !== 'custTbl') return;
var host = tbl.closest ? tbl.closest('.table-card') : null;
var on = !!(host && host.getAttribute('data-vsel') === '1');
var w = 0;
if (on) {
var th = (tbl.tHead && tbl.tHead.rows[0]) ? tbl.tHead.rows[0].cells[0] : null;
if (th) w = Math.round(th.getBoundingClientRect().width) || 0;
}
tbl.style.setProperty('--cust-pin-l', (on ? (w > 0 ? w : 42) : 0) + 'px');
}
function colLayoutApply(only) {
if (!window.COL_TARGETS) return;
window.COL_TARGETS.forEach(function (t) {
if (only && t.key !== only) return;
[].slice.call(document.querySelectorAll(t.sel)).forEach(function (tbl) {
if (!COL_BASE.has(tbl)) COL_BASE.set(tbl, colKeysOf(tbl));
var cfg = colLoad(t.key);
if (cfg) { if (cfg.order) colApplyOrder(tbl, cfg.order); if (cfg.widths) colApplyWidth(tbl, cfg.widths); }
colApplyHidden(tbl, colHiddenOf(t.key)); /* ★ 第 459 轮:列显示开关 */
colBindDrag(tbl, t.key);
colMarkHeads(tbl);
custPinSync(tbl); /* ★ 第587轮:同步客户列冻结偏移(列宽/勾选列变化后仍对齐) */
});
colBar(t);
});
}
window.COL_TARGETS = window.COL_TARGETS || [];
window.COL_TARGETS.push({ key: 'crm_cust', sel: '#custTbl' });
window.COL_TARGETS.push({ key: 'deliv_matrix', sel: '#dlMatrixCard > table' });
window.COL_TARGETS.push({ key: 'crm_dealer', sel: '#dlrTable' }); /* ★ 第 459 轮:经销商表接入列偏好 */
if (!window.__clGlobal) { window.__clGlobal = 1; document.addEventListener('pointermove', colOnMove, true); document.addEventListener('pointerup', colOnUp, true); document.addEventListener('pointercancel', colOnUp, true); }
renderDeliveryParts=function(){const isCust=delivSub==='customer';const cats=Object.entries(SERVICE_CATEGORIES);const catalog=cats.map(([k,c])=>{let items=SERVICES.filter(s=>s.cat===k);if(delivFilter.qService){const q=delivFilter.qService.toLowerCase();items=items.filter(s=>(s.name+' '+s.spec).toLowerCase().includes(q));}
if(!items.length)return'';return`<div class="card" style="margin-bottom:14px">
<h4 style="font-size:14px;margin-bottom:12px;display:flex;align-items:center;gap:8px">
<span style="width:6px;height:16px;background:${c.color};border-radius:3px"></span>${c.ic} ${c.name}
</h4>
<table><thead><tr><th>服务项</th><th>规格</th><th>单价/说明</th><th style="text-align:right">市场价值</th></tr></thead><tbody>
${items.map(s=>`<tr><td><b>${s.name}</b></td><td>${s.spec}</td><td>${s.unit||'—'}</td><td style="text-align:right"><b style="color:${s.value>=1000000?'#dc2626':'#334155'}">${s.value?money(s.value):'—'}</b></td></tr>`).join('')}
</tbody></table>
</div>`;}).join('');const catEl=document.getElementById('dlCatalog');if(catEl)catEl.innerHTML=catalog;let listServices=(fLevel('dv_service')==='hidden')?[]:SERVICES.slice();if(delivFilter.cat)listServices=listServices.filter(s=>s.cat===delivFilter.cat);if(delivFilter.qService){const q=delivFilter.qService.toLowerCase();listServices=listServices.filter(s=>(s.name+' '+s.spec).toLowerCase().includes(q));}
let ownerList=isCust?DB.customers.slice():DB.dealers.filter(d=>d.type!=="staff");ownerList=ownerList.filter(o=>isPushed(isCust?'customer':'dealer',o));if(delivFilter.dealer){ownerList=isCust?ownerList.filter(o=>o.dealerId===delivFilter.dealer):ownerList.filter(o=>o.id===delivFilter.dealer);}
if(isCust){if(delivFilter.qCustomer){const q=delivFilter.qCustomer.trim().toLowerCase();if(q){const exact=ownerList.filter(c=>c.name&&c.name.trim().toLowerCase()===q);ownerList=exact.length?exact:ownerList.filter(c=>(c.name+' '+(c.school||'')).toLowerCase().includes(q));}}}else{if(delivFilter.qDealer){const q=delivFilter.qDealer.trim().toLowerCase();if(q){const exact=ownerList.filter(d=>d.name&&d.name.trim().toLowerCase()===q);ownerList=exact.length?exact:ownerList.filter(d=>d.name.toLowerCase().includes(q));}}}
if(isCust&&delivFilter.intent){ownerList=ownerList.filter(c=>intentArr(c).includes(delivFilter.intent));}if(delivFilter.status){ownerList=ownerList.filter(o=>listServices.some(s=>{const dv=isCust?findCustomerDelivery(o.id,s.id):findDelivery(o.id,s.id);const st=dv?dv.status:'pending';return delivFilter.status==='pending'?(st==='pending'||st==='partial'):st===delivFilter.status;}));}
if(isCust&&delivFilter.coop&&delivFilter.coop.length){ownerList=ownerList.filter(c=>delivCoopMatch(c,delivFilter.coop));}
ownerList=delivSortOwners(ownerList,isCust);
const editable=can('edit_delivery');const ownerType=isCust?'customer':'dealer';const rowsHtml=ownerList.map(o=>{const cells=listServices.map(s=>{const dv=isCust?findCustomerDelivery(o.id,s.id):findDelivery(o.id,s.id);const st=dv?dv.status:'pending';const _ds=delivStatusOf(dv);const cls=_ds.cls;const label=_ds.label;const extra=metaBadge(dv,serviceMetaType(s.id),!!coopDeadLabel(o,isCust));const click=(editable&&fLevel('dv_status')==='full'&&fLevel('dv_service')==='full')?`onclick="openServiceMeta('${ownerType}','${o.id}','${s.id}')"`:'';return`<td style="${editable?'cursor:pointer':''}" ${click} title="${editable?'点击编辑':''}">
${fLevel('dv_status')==='full'?('<span class="st '+cls+'">'+label+'</span>'):('<span class="st">'+(fLevel('dv_status')==='mask'?'***':'****')+'</span>')}${extra}
</td>`;}).join('');const ownName=isCust?`${custNameHtml(o)}${coopDeadTag(o,isCust)}`:`<span class="tag ${DT(o.type).color}">${DT(o.type).name.replace('经销商','')}</span> ${fTxt('d_name',o.name)}${coopDeadTag(o,isCust)}`;const ownCol=isCust?`<td>${ownName}<div class="mtxt" style="margin-top:2px">${intentTag(o)}</div></td><td class="mtxt">${dealerName(o.dealerId)||'—'}</td>`:`<td>${ownName}</td>`;return`<tr${coopDeadLabel(o,isCust)?' class=\"dl-dead-row\"':''}>${ownCol}${cells}</tr>`;}).join('');const matrixCard=document.getElementById('dlMatrixCard');if(matrixCard)matrixCard.innerHTML=`<table style="min-width:${Math.max(560, 220 + listServices.length*120)}px">
<thead><tr><th style="min-width:200px">${isCust?'客户':'经销商'}</th>${isCust?'<th style="min-width:160px">所属经销商</th>':''}${listServices.map(s=>`<th style="min-width:120px">${fLevel('dv_service')==='full'?s.name:fTxt('dv_service',s.name)}</th>`).join('')}</tr></thead>
<tbody>${rowsHtml||`<tr><td colspan="${listServices.length+ (isCust?2:1)}"class="empty">无数据</td></tr>`}</tbody>
</table>
${listServices.length>4?'<div class="mtxt" style="margin-top:8px">← 横向滑动查看更多服务项目 →</div>':''}`;const sumRows=ownerList.map(o=>{const stt=ownerDelivery(ownerType,o.id);const ownName=isCust?`${custNameHtml(o)}${coopDeadTag(o,isCust)}`:`<span class="tag ${DT(o.type).color}">${DT(o.type).name.replace('经销商','')}</span> ${fTxt('d_name',o.name)}${coopDeadTag(o,isCust)}`;const dealerCol=isCust?`<td class="mtxt">${dealerName(o.dealerId)||'—'}</td>`:'';return`<tr>
<td>${ownName}</td>${dealerCol}
<td>${fLevel('dv_status')==='full'?(stt.done+'/'+stt.total):(fLevel('dv_status')==='mask'?'***':'****')}</td>
<td>${stt.partial>0?`+${stt.partial}部分`:'-'}</td>
<td><div class="bar" style="width:140px"><i style="width:${stt.completion}%"></i></div></td>
<td><b>${pct(stt.completion)}</b></td>
<td>${money(stt.deliveredValue)} <span class="mtxt">/ ${money(stt.totalValue)}</span></td>
</tr>`;}).join('')||`<tr><td colspan="${isCust?7:6}" class="empty">无数据</td></tr>`;const sumBody=document.getElementById('dlSummaryBody');if(sumBody)sumBody.innerHTML=sumRows;const hint=document.getElementById('dlHint');const qOwner=isCust?delivFilter.qCustomer:delivFilter.qDealer;const itx=(isCust&&delivFilter.intent)?(' · 合作项目「'+delivFilter.intent+'」'):'';const ctx=(isCust&&delivFilter.coop&&delivFilter.coop.length)?(' · 合作状态「'+delivFilter.coop.map(function(k){return DELIV_COOP_LABEL[k]||k;}).join('+')+'」'):'';if(hint)hint.innerHTML=(delivFilter.qService||qOwner||itx||ctx)?`<div class="mtxt" style="margin:4px 0 12px;padding:6px 10px;background:#eff6ff;border:1px solid #bfdbfe;border-radius:8px;color:#1d4ed8">🔎 搜索中:服务项目「${delivFilter.qService||'全部'}」${itx}${ctx} · ${isCust?'客户':'经销商'}「${qOwner||'全部'}」 — 已过滤上方服务清单与下方交付矩阵,点"重置"恢复</div>`:'';try{if(typeof colLayoutApply==='function')colLayoutApply();}catch(e){}};
let commFilter={q:'',type:[],onlyEarned:false,onlyUnpaid:false,onlyInst:false,onlyUndeliv:false,fdFrom:'',fdTo:'',onlyNoComm:false};
/* ★ 第 529 轮(2026-10-19 主人拍板「A + 1(只要核心 5 项)+ 并入」):分润来源明细展示课程产品交付情况。
口径:只看核心 5 项(VR / AR双师 / 线上教研 / 线上教务 / AI点评);分母 = 该订单实购的核心项数(年课包=5,单项订单=1);
已交付 = status done 或 n_a;partial 计「部分」;无记录 = 待交付。纯读 DB.deliveries,零写入、零业务口径变化。 */
var COMM_CORE_ITEMS=['vr_meta','ar_dual','online_res','online_aff','ai_review'];
var _cdvRev=null,_cdvIdx=null;
function commDelivRank(st){return (st==='done'||st==='n_a')?3:(st==='partial'?2:1);}
function commDelivIdx(){
if(_cdvRev===DB.rev&&_cdvIdx)return _cdvIdx;
var m={};
(DB.deliveries||[]).forEach(function(dv){
if(!dv||!dv.customerId)return;
var g=m[dv.customerId]||(m[dv.customerId]={});
var p=g[dv.serviceId];
var st=dv.status||'pending';
if(!p||commDelivRank(st)>commDelivRank(p.status))g[dv.serviceId]={status:st,date:dv.date||'',operator:dv.operator||'',expireAt:dv.expireAt||''};
});
_cdvRev=DB.rev;_cdvIdx=m;return m;
}
function commCustOf(id){if(!id)return null;return (DB.customers||[]).filter(function(x){return x&&x.id===id;})[0]||null;}
function commDelivOf(c){
if(!c)return null;
var ids=null;
if(c.pkgId){var pk=(DB.packages||[]).filter(function(x){return x.id===c.pkgId;})[0];if(pk&&pk.itemIds&&pk.itemIds.length)ids=pk.itemIds;}
if(!ids&&Array.isArray(c.itemIds)&&c.itemIds.length)ids=c.itemIds;
if(!ids)return null;
var mine=commDelivIdx()[c.id]||{};
var rows=[];
COMM_CORE_ITEMS.forEach(function(id){
if(ids.indexOf(id)<0)return;
var it=(DB.items||[]).filter(function(x){return x.id===id;})[0]||{name:id};
var r=mine[id]||null;
rows.push({id:id,name:it.name||id,status:r?r.status:'',date:r?r.date:'',operator:r?r.operator:'',expireAt:r?r.expireAt:''});
});
if(!rows.length)return null;
var done=0,partial=0;
rows.forEach(function(x){if(x.status==='done'||x.status==='n_a')done++;else if(x.status==='partial')partial++;});
return {rows:rows,done:done,partial:partial,total:rows.length};
}
function commDelivCellHtml(c){
if(!c)return '<span class="mtxt">—</span>';
if(typeof fLevel==='function'&&fLevel('dv_service')==='hidden')return '<span class="mtxt" title="无「服务项目」查看权限">—</span>';
var o=commDelivOf(c);
if(!o)return '<span class="mtxt" title="该订单未登记课程项目">—</span>';
if(typeof fLevel==='function'&&fLevel('dv_status')!=='full')return '<span class="mtxt">***</span>';
var col=o.done>=o.total?'#16a34a':((o.done+o.partial)>0?'#d97706':'#94a3b8');
var pct=o.total?Math.round(o.done/o.total*100):0;
return '<div style="white-space:nowrap;cursor:pointer" title="点击查看课程交付明细" onclick="commDelivTip(\''+c.id+'\',this)">'
+'<b style="color:'+col+'">'+o.done+'/'+o.total+'</b>'
+(o.partial?'<span class="mtxt"> · 部分'+o.partial+'</span>':'')
+'<div class="bar" style="width:64px;margin-top:3px"><i style="width:'+pct+'%;background:'+col+'"></i></div>'
+'</div>';
}
function commDelivTip(cid,el){
var c=commCustOf(cid);
if(!c)return;
var o=commDelivOf(c);
if(!o)return;
var old=document.getElementById('commDvTip');
if(old&&old.parentNode)old.parentNode.removeChild(old);
var d=document.createElement('div');
d.id='commDvTip';
d.style.cssText='position:fixed;z-index:9999;background:#fff;border:1px solid var(--line);border-radius:12px;box-shadow:0 10px 30px rgba(15,23,42,.16);padding:10px 12px;min-width:272px;max-width:340px;font-size:12.5px';
var h='<div style="font-weight:700;margin-bottom:6px">'+esc(c.name||'')+' · 课程交付 '+o.done+'/'+o.total+'</div>';
o.rows.forEach(function(r){
var ico=r.status==='done'?'✅':(r.status==='n_a'?'➖':(r.status==='partial'?'🟡':'⬜'));
var lb=r.status==='done'?'已交付':(r.status==='n_a'?'不适用':(r.status==='partial'?'部分交付':'待交付'));
h+='<div style="display:flex;gap:8px;padding:3px 0;border-top:1px dashed var(--line)"><span>'+ico+'</span><span style="flex:1">'+esc(r.name)+'</span><span class="mtxt">'+lb+(r.date?(' · '+esc(r.date)):'')+'</span></div>';
});
h+='<div class="mtxt" style="margin-top:6px">口径:核心 5 项;已交付 = 已完成 / 不适用;无记录 = 待交付</div>';
d.innerHTML=h;
document.body.appendChild(d);
var b=el.getBoundingClientRect(),top=b.bottom+6,left=b.left;
if(top+d.offsetHeight>window.innerHeight-8)top=Math.max(8,b.top-d.offsetHeight-6);
if(left+d.offsetWidth>window.innerWidth-8)left=Math.max(8,window.innerWidth-d.offsetWidth-8);
d.style.top=top+'px';d.style.left=left+'px';
setTimeout(function(){
function hd(ev){if(d.contains(ev.target))return;if(d.parentNode)d.parentNode.removeChild(d);document.removeEventListener('click',hd,true);}
document.addEventListener('click',hd,true);
},0);
}
function commTypePick(k,all){commFilter.type=all?[]:chipNext(commFilter.type,k);chipPaint('commTypeChips',commFilter.type);commRenderTable();}
function commPaidOf(c){return (c.payments||[]).reduce((s,p)=>s+Number(p.amount||0),0);}
function commFullPayDate(c){const ps=(c.payments||[]).filter(p=>p&&p.date).sort((a,b)=>String(a.date).localeCompare(String(b.date)));if(!ps.length)return String(c.signDate||'');let acc=0,found='';for(let i=0;i<ps.length;i++){acc+=Number(ps[i].amount||0);if(c.amount&&acc>=Number(c.amount)){found=String(ps[i].date);break;}}return found||String(ps[ps.length-1].date);}
function commRateNote(d,c,rate,isPerf){const _soN=c.commOverrideStaff;if(!isPerf&&d&&d.type==='staff'&&_soN&&Number(_soN.value)>0&&c.contractSigned&&!c.installment&&c.stage==='signed_full'&&!c.refunded)return (_soN.mode==='fixed')?('本单员工分润设定:固定 ¥'+Math.round(Number(_soN.value))):('本单员工分润设定:'+Math.round(Number(_soN.value)*1000)/10+'%');const _coN=c.commOverride;if(isPerf&&_coN&&(_coN.mode==='rate'||_coN.mode==='fixed')&&Number(_coN.value)>0&&c.contractSigned&&!c.installment&&c.stage==='signed_full'&&!c.refunded)return (_coN.mode==='rate')?('本单分润手设:'+Math.round(Number(_coN.value)*1000)/10+'%'):('本单分润手设:固定 ¥'+Math.round(Number(_coN.value)));if(rate>0)return '';if(c.stage==='signed_installment')return '分期订单不计分润';if(c.installment)return '分期订单不计分润';if(!c.contractSigned)return '未签合同,暂不计分润';if(c.refunded)return '已退费,不计分润';if(c.stage!=='signed_full')return '非全款成交';const cfg=DT(d.type);if(d.type==='campus'){const idx=dealerSignedFull(d.id).findIndex(x=>custGroupKeyOf(x)===custGroupKeyOf(c));if(idx>=0&&idx<(cfg.cashFrom||5)-1)return '前'+((cfg.cashFrom||6)-1)+'家抵扣自用时长,无现金分润';if(dealerYears(d)>=2&&dealerSignedFull(d.id).length<10)return '两年累计不足10家,第三年降至10%';}if(d.type==='staff')return '当月交款合计未达10万考核线,暂不计分润';return '—';}
function commSortItems(items){var _c=[],_r=[];(items||[]).forEach(function(x){if(x?x.carry:false){_c.push(x)}else{_r.push(x)}});_r.sort(function(a,b){var da=String((a||{}).fullDate||'').slice(0,10);var db=String((b||{}).fullDate||'').slice(0,10);if(!da){return !db?0:1}if(!db){return -1}return da.localeCompare(db)});return _c.concat(_r)}
/* ★ 第 416 轮(2026-10-12 主人「改」拍板):分期客户在「分润来源明细」的来源行归属=该户逐笔交款的登记业绩归属(pDealerId → 无则按笔日期回看 ownerHist → 再回落客户档案,与 commDealerSplitOf 同款回落链);旧「直读客户档案 c.dealerId」形态禁回退(自检 #229 守)。 */
function commInstRowOwnerIds(c){if(!c)return[];const ps=(c.payments||[]).filter(p=>p&&p.date);const _s={};if(!ps.length){const _b=DB.dealers.find(x=>x.id===c.dealerId);if(_b)_s[_b.id]=1;return Object.keys(_s);}ps.forEach(p=>{const fd=p.pDealerId?DB.dealers.find(x=>x.id===p.pDealerId):null;const _h=(c&&c.ownerHist&&c.ownerHist.length)?(function(a,t){t=String(t||'').slice(0,10);let r=a[0];for(let i=0;i<a.length;i++){if(String(a[i].at||'').slice(0,10)<=t)r=a[i];else break;}return r;})(c.ownerHist,p.date):null;const od=fd||(_h&&_h.dealerId?DB.dealers.find(x=>x.id===_h.dealerId):null)||DB.dealers.find(x=>x.id===c.dealerId);if(od)_s[od.id]=1;});return Object.keys(_s);}
function commBuildData(){return DB.dealers.slice().sort((a,b)=>dealerStats(b.id).commission-dealerStats(a.id).commission).map(d=>{const items=[];const _mine=[],_other=[];DB.customers.forEach(c=>{if(!c||c.refunded)return;if(c.stage!=='signed_full'&&c.stage!=='signed_installment')return;if(c.stage==='signed_full'){commDealerSplitOf(c).forEach(r=>{if(r.ownerId!==d.id)return;const od=DB.dealers.find(x=>x.id===r.ownerId)||d;const mine=(c.dealerId===d.id);if(!mine&&!(r.amt>0))return;const row={cid:c.id,mine:mine,inst:(c.stage==='signed_installment'||!!c.installment),adj:Number(r.adj||0)||0,name:c.name||'(未命名)',src:'业绩归属',paid:commPaidOf(c),fullDate:commFullPayDate(c),rate:r.rate,comm:r.amt,note:commRateNote(od,c,r.rate,true),ownerNote:perfOwnerNote(c),adjNote:(c.commAdj&&((c.commAdj.notes&&c.commAdj.notes[r.ownerId])||c.commAdj.note))||''};(mine?_mine:_other).push(row);});}else if(commInstRowOwnerIds(c).indexOf(d.id)>=0){_mine.push({cid:c.id,mine:true,inst:(c.stage==='signed_installment'||!!c.installment),adj:0,name:c.name||'(未命名)',src:'业绩归属',paid:commPaidOf(c),fullDate:commFullPayDate(c),rate:0,comm:0,note:commRateNote(d,c,0),ownerNote:perfOwnerNote(c)});}});_mine.concat(_other).forEach(x=>items.push(x));if(d.type==='staff'){const _dupOwn={};items.forEach(x=>{if(x.cid&&!_dupOwn[x.cid])_dupOwn[x.cid]=x;});DB.customers.filter(c=>staffSvcRowHit(d,c)||staffTrRowHit(d,c)).forEach(c=>{const _sv=staffSvcAutoOf(d,c);const rate=_sv.rate;const amt=_sv.auto;const _caA=(c.commAdjStaff&&c.commAdjStaff.amts&&typeof c.commAdjStaff.amts==='object')?c.commAdjStaff.amts:null;const _sadj=_caA?Math.round(Number(_caA[d.id]||0)||0):Math.round(Number((c.commAdjStaff&&c.commAdjStaff.amt)||0)||0);/*第221轮续·强制合并:同一客户在同一经销商名下恒只出一行;若该客户已由业绩归属段出行,则把员工分润并入该行(金额相加、不丢钱)*/const _ex=_dupOwn[c.id];if(_ex){_ex.comm=_ex.comm+amt+_sadj;if((amt+_sadj)!==0)_ex.staffAdj=true;return;}items.push({cid:c.id,mine:false,inst:(c.stage==='signed_installment'||!!c.installment),adjStaff:true,adj:typeof _sadj==='number'?_sadj:0,name:c.name||'(未命名)',src:'业绩归属',paid:commPaidOf(c),fullDate:commFullPayDate(c),rate,comm:amt+_sadj,note:commRateNote(d,c,rate),ownerNote:perfOwnerNote(c),adjNote:(c.commAdjStaff&&((c.commAdjStaff.notes&&c.commAdjStaff.notes[d.id])||c.commAdjStaff.note))||''});});}var _cfk=Object.keys(d.commCarryFrom||{}).filter(k=>Number((d.commCarryFrom||{})[k]||0)>0);var _cfn=function(k){var _cc=DB.customers.find(x=>x&&x.id===k);return _cc?(_cc.name||'(未命名)'):'(客户已删除)';};var _cfd=function(k){var _cc=DB.customers.find(x=>x&&x.id===k);return _cc?commFullPayDate(_cc):'';};var _cfNote='客户已变更归属:该客户在本经销商的已发放分润已冻结保留,未发放部分已随客户迁往新归属';if(_cfk.length){_cfk.slice().reverse().forEach(k=>{items.unshift({carry:true,cid:k,inst:false,name:_cfn(k),src:'结转',paid:0,fullDate:_cfd(k),rate:0,comm:Number((d.commCarryFrom||{})[k]||0),note:_cfNote,ownerNote:''});});}else if(Number(d.commCarry||0)>0)items.unshift({carry:true,id:'__carry__',inst:false,name:'(本经销商已迁出的客户)',src:'结转',paid:0,fullDate:'',rate:0,comm:Number(d.commCarry||0),note:_cfNote,ownerNote:''});return {dealer:d,items:commSortItems(items)};});}
function commVisible(g){const q=(commFilter.q||'').trim().toLowerCase();let items=g.items;if(!chipHit(commFilter.type,g.dealer.type))items=[];if(q){items=items.filter(i=>String(g.dealer.name||'').toLowerCase().includes(q)||String(i.name).toLowerCase().includes(q));const rk=s2=>{const n=String(s2||'').toLowerCase();return n===q?0:(n.startsWith(q)?1:2);};const dExact=String(g.dealer.name||'').toLowerCase()===q;items=items.slice().sort((a,b)=>(dExact?0:rk(a.name))-(dExact?0:rk(b.name)));}if(commFilter.onlyEarned)items=items.filter(i=>i.comm>0);if(commFilter.onlyUnpaid){commAlloc(g);items=items.filter(i=>(i.unpaid||0)>0);}if(commFilter.onlyInst)items=items.filter(i=>i.inst);if(commFilter.onlyUndeliv)items=items.filter(function(i){var _o=i.cid?commDelivOf(commCustOf(i.cid)):null;return !!_o&&_o.done<_o.total;});const fd1=commFilter.fdFrom||'',fd2=commFilter.fdTo||'';if(fd1||fd2)items=items.filter(i=>{const dt=String(i.fullDate||'').slice(0,10);if(!dt)return false;if(fd1&&dt<fd1)return false;if(fd2&&dt>fd2)return false;return true;});return items;}
function commHasFilter(){return !!(String(commFilter.q||'').trim()||commFilter.type.length||commFilter.onlyEarned||commFilter.onlyUnpaid||commFilter.onlyInst||commFilter.onlyUndeliv||commFilter.onlyNoComm||commFilter.fdFrom||commFilter.fdTo);}
/* ★ 第 577 轮(2026-10-22 主人「A+B」拍板):分润来源明细补齐「无分润的经销商」——
① 空态行给出具体原因(名下无客户 / 有客户但没签约 / 只有分期 / 已退费);
② 新增「仅看无分润」筛选;
③ 无分润的经销商单独成段 + 分区标题,可折叠(默认展开,原有可见性不降)。
连带修:搜索无分润经销商名时不再被过滤掉(旧 commGroups 只按 items 命中判,空组必被剔除)。 */
var commNoCommOpen=true;
function commNoCommToggle(){commNoCommOpen=!commNoCommOpen;commRenderTable();}
function commItemFilterOn(){return !!(commFilter.onlyEarned||commFilter.onlyUnpaid||commFilter.onlyInst||commFilter.onlyUndeliv||commFilter.fdFrom||commFilter.fdTo);}
function commDealerDead(d){var st=String((d&&d.stage)||'');return !!((d&&d.refunded===true)||st==='refunded'||st==='terminated'||st==='exhausted');}
function commEmptyReasonOf(d){try{const cs=(DB.customers||[]).filter(c=>c&&c.dealerId===d.id);if(!cs.length)return '名下暂无客户(还没建过客户档案)';const live=cs.filter(c=>!c.refunded);const sf=live.filter(c=>c.stage==='signed_full').length;const si=live.filter(c=>c.stage==='signed_installment').length;const wt=live.length-sf-si;if(sf>0)return '名下 '+cs.length+' 户,其中全款 '+sf+' 户但分润比例为 0(前 N 家抵扣自用 / 未满年限等)';if(si>0)return '名下 '+cs.length+' 户,其中分期 '+si+' 户(分期订单不计分润)'+(wt>0?',未签约 '+wt+' 户':'');if(wt>0)return '名下 '+cs.length+' 户,'+wt+' 户均未签约';return '名下 '+cs.length+' 户,均已退费';}catch(e){return '';}}
function commNoCommKeep(g){const items=(g.items||[]);const n=items.length;/* 死档(退费/终止/耗尽)且该组应发合计为 0 ⇒ 不显示;有真实分润或结转的照常显示(历史账不删) */if(commDealerDead(g.dealer)&&items.reduce(function(s,i){return s+Number((i&&i.comm)||0);},0)===0)return false;if(commFilter.onlyNoComm)return n===0;if(!commHasFilter())return true;if(n>0)return commVisible(g).length>0;if(commItemFilterOn())return false;const q=String(commFilter.q||'').trim().toLowerCase();return !q||String(g.dealer.name||'').toLowerCase().indexOf(q)>=0;}
function commGroups(){return commBuildData().filter(g=>chipHit(commFilter.type,g.dealer.type)).filter(g=>commNoCommKeep(g));}
function commSpreadOf(rows,payouts,carry){var po=(payouts||[]).map(function(p){return{id:p&&p.id,d:String((p&&p.date)||'').slice(0,10),a:Math.max(0,Number((p&&p.amount)||0)||0),al:(p&&Array.isArray(p.alloc))?p.alloc:null};}).sort(function(a,b){return a.d.localeCompare(b.d);});var left=Math.max(0,Number(carry||0));var pi=0;while(left>0&&pi<po.length){var u=Math.min(po[pi].a,left);po[pi].a-=u;left-=u;if(po[pi].a<=0)pi++;}var ord=(rows||[]).slice().sort(function(a,b){return String(a.fullDate||'').localeCompare(String(b.fullDate||''));});var rem={};ord.forEach(function(r){rem[r.cid]=Math.max(0,Number(r.comm||0));});var byPid={},byCid={},paidAt={};ord.forEach(function(r){byCid[r.cid]=0;});po.forEach(function(p){var amt=p.a;if(!(amt>0))return;var out=[];if(p.al&&p.al.length){p.al.forEach(function(x){if(!x||!x.cid)return;var cap=rem[x.cid]||0;if(!(cap>0))return;var use=Math.min(Math.max(0,Number(x.amt)||0),cap);if(!(use>0))return;var v=Math.round(use*100)/100;out.push({cid:x.cid,name:x.name||'',amt:v});byCid[x.cid]=Math.round(((byCid[x.cid]||0)+v)*100)/100;rem[x.cid]=cap-use;paidAt[x.cid]=p.d;});}else{var need=amt;for(var i=0;i<ord.length&&need>0;i++){var r2=ord[i];var cap2=rem[r2.cid]||0;if(!(cap2>0))continue;var use2=Math.min(cap2,need);need-=use2;rem[r2.cid]=cap2-use2;var v2=Math.round(use2*100)/100;out.push({cid:r2.cid,name:r2.name||'',amt:v2});byCid[r2.cid]=Math.round(((byCid[r2.cid]||0)+v2)*100)/100;paidAt[r2.cid]=p.d;}}if(out.length)byPid[p.id]=out;});return{byPid:byPid,byCid:byCid,paidAt:paidAt};}
function commGroupOf(dealerId){var gs=commBuildData()||[];for(var i=0;i<gs.length;i++){if(gs[i]&&gs[i].dealer&&gs[i].dealer.id===dealerId)return gs[i];}return null;}
function commAllocExtraOf(d,extra){if(!d||!(extra>0))return[];var g=commGroupOf(d.id);if(!g)return[];commAlloc(g);var ord=(g.items||[]).slice().sort(function(a,b){return String(a.fullDate||'').localeCompare(String(b.fullDate||''));});var left=Number(extra),out=[];ord.forEach(function(i){if(!i||i.carry||left<=0)return;var rem=Math.max(0,Number(i.unpaid||0));if(rem<=0)return;var use=Math.min(rem,left);left-=use;out.push({cid:i.cid,name:i.name||'',amt:Math.round(use*100)/100});});return out;}
function commPayoutTraceOf(d){if(!d)return{};var g=commGroupOf(d.id);if(!g)return{};var rows=(g.items||[]).filter(function(i){return i&&!i.carry;}).map(function(i){return{cid:i.cid,name:i.name||'',comm:Number(i.comm||0),fullDate:i.fullDate||''};});return commSpreadOf(rows,d.payouts||[],Number(d.commCarry||0)).byPid;}
function rebuildPayoutAllocs(d){if(!d||!Array.isArray(d.payouts))return 0;var had={};d.payouts.forEach(function(p){if(!p||!Array.isArray(p.alloc))return;had[p.id]=1;var s=0;p.alloc.forEach(function(x){s+=Number(x&&x.amt||0)||0;});if(Math.abs(s-Number(p.amount||0))>0.01)delete p.alloc;});var tr=commPayoutTraceOf(d),n=0;d.payouts.forEach(function(p){if(!p||!had[p.id])return;var a=tr[p.id]||[];if(a.length){p.alloc=a;n++;}else{delete p.alloc;}});return n;}
function commAlloc(g){var rows=(g.items||[]).filter(function(i){return i&&!i.carry;}).map(function(i){return{cid:i.cid,name:i.name||'',comm:Number(i.comm||0),fullDate:i.fullDate||''};});var sp=commSpreadOf(rows,(g.dealer&&g.dealer.payouts)||[],Number((g.dealer&&g.dealer.commCarry)||0));(g.items||[]).forEach(function(i){if(!i)return;if(i.carry){i.paidOut=i.comm;i.unpaid=0;i.paidAt='结转';return;}var got=Number(sp.byCid[i.cid]||0);i.paidOut=got;i.unpaid=Math.max(0,Math.round((Number(i.comm||0)-got)*100)/100);i.paidAt=sp.paidAt[i.cid]||'';});return g;}
function commPayoutPickRows(d){var g=commGroupOf(d.id);if(!g)return[];commAlloc(g);var out=[];(g.items||[]).forEach(function(i){if(!i||i.carry)return;var un=Number(i.unpaid||0);if(!(un>0.005))return;out.push({cid:i.cid,name:i.name||'',fullDate:i.fullDate||'',comm:Number(i.comm||0),paid:Number(i.paidOut||0),unpaid:Math.round(un*100)/100});});return out;}
function cpPickHtml(d){var rows=commPayoutPickRows(d);if(!rows.length)return '<div class="empty" style="padding:18px 12px">该经销商没有待发放的分润</div>';var tr=rows.map(function(r){var nm=esc(r.name||'(未命名)');var dv=String(r.unpaid);return '<tr><td style="padding:5px 8px;text-align:center"><input type="checkbox" class="cp-pick" data-cid="'+r.cid+'" data-name="'+nm+'" data-max="'+dv+'" checked onchange="cpPickCalc(\''+d.id+'\')"></td><td style="padding:5px 8px">'+nm+'</td><td style="padding:5px 8px;color:#64748b">'+esc(r.fullDate||'—')+'</td><td style="padding:5px 8px;text-align:right">'+moneyMask(fLevel('amount_kpi'),r.comm)+'</td><td style="padding:5px 8px;text-align:right;color:#64748b">'+moneyMask(fLevel('amount_kpi'),r.paid)+'</td><td style="padding:5px 8px;text-align:right;color:#d97706;font-weight:600">'+moneyMask(fLevel('amount_kpi'),r.unpaid)+'</td><td style="padding:5px 8px;text-align:right"><input type="number" class="cp-amt" data-cid="'+r.cid+'" min="0" step="0.01" max="'+dv+'" value="'+dv+'" style="width:104px;text-align:right" oninput="cpPickCalc(\''+d.id+'\')"></td></tr>';}).join('');var sum=rows.reduce(function(s,r){return s+r.unpaid;},0);return '<table style="width:100%;border-collapse:collapse;font-size:12px"><thead><tr style="background:#f8fafc"><th style="padding:6px 8px;text-align:center;width:34px">选</th><th style="padding:6px 8px;text-align:left">客户</th><th style="padding:6px 8px;text-align:left">全款时间</th><th style="padding:6px 8px;text-align:right">应发</th><th style="padding:6px 8px;text-align:right">已发</th><th style="padding:6px 8px;text-align:right">未发</th><th style="padding:6px 8px;text-align:right">本次发放</th></tr></thead><tbody>'+tr+'</tbody><tfoot><tr><td colspan="6" style="padding:6px 8px;border-top:1px solid var(--line);font-weight:600">合计('+rows.length+' 家待发)</td><td style="padding:6px 8px;border-top:1px solid var(--line);text-align:right;font-weight:600;color:#0C447C">'+moneyMask(fLevel('amount_kpi'),sum)+'</td></tr></tfoot></table>';}
function cpPickCalc(id){var box=document.getElementById('cp_pick');if(!box)return;var cbs=[].slice.call(box.querySelectorAll('input.cp-pick'));var sum=0,n=0,bad='';cbs.forEach(function(cb){var cid=cb.getAttribute('data-cid');var max=Number(cb.getAttribute('data-max')||0);var el=box.querySelector('input.cp-amt[data-cid="'+cid+'"]');if(!cb.checked){if(el)el.disabled=true;return;}if(el)el.disabled=false;var v=parseFloat(el&&el.value)||0;if(v<0){bad='有客户的发放金额为负数';}else if(v>max+0.005){bad='有客户填超了未发分润';}else{sum+=v;n++;}if(el)el.style.borderColor=(v<0||v>max+0.005)?'#dc2626':'';});sum=Math.round(sum*100)/100;var hid=document.getElementById('cp_amount');if(hid)hid.value=String(sum);var prev=document.getElementById('cp_prev');if(prev)prev.innerHTML='<div class="mtxt" style="margin-bottom:4px">本次发放合计 <b style="color:#0C447C;font-size:14px">'+money(sum)+'</b> · 覆盖 '+n+' 家</div>'+(bad?'<div class="mtxt" style="color:var(--danger);font-weight:600">'+esc(bad)+'</div>':'');}
/* ★ 第 421 轮(主人 2026-10-12 定规):已发放分润客户 ⇒ 交款记录锁定(禁改/禁删,仅备注可改)。
判定 = 该客户在任一「有发放流水的经销商」组内被发放覆盖(paidOut>0,含部分覆盖);
与分红发放摊派同源(commBuildData + commAlloc),跳过结转行(与服务端 ofPaidOutOf 对齐)。 */
var _pllRev=null,_pllSet=null,_pllSum=null;
function paidLockInfo(c){if(!c||!c.id)return null;/* ★ 第 443 轮(主人「A 方案+自检」授权):优先读服务端快照 c.commLock —— 锁不再随分润规则漂移。空则回落实时重算(与后端 _plkLockedInSnap 同口径:快照优先 + 回落)。 */var _cLK=(c.commLock&&typeof c.commLock==='object'&&!Array.isArray(c.commLock))?c.commLock:null;if(_cLK)return{sum:Number(_cLK.sum||0)};try{if(_pllRev!==DB.rev||!_pllSet){_pllSet=new Set();_pllSum={};commBuildData().forEach(function(g){if(!(g&&g.dealer&&Array.isArray(g.dealer.payouts)&&g.dealer.payouts.length))return;commAlloc(g);(g.items||[]).forEach(function(it){if(!it||it.carry)return;var v=Number(it.paidOut||0);if(v>0){_pllSet.add(it.cid);_pllSum[it.cid]=(_pllSum[it.cid]||0)+v;}});});_pllRev=DB.rev;}}catch(e){}return _pllSet.has(c.id)?{sum:_pllSum[c.id]||0}:null;}
function paidLockHit(c){return !!paidLockInfo(c);}
function paidLockAfterPayout(c,p){try{if(!c||!p)return false;var _s=c.commLock;if(!_s||typeof _s!=='object'||Array.isArray(_s)||!Array.isArray(_s.ds)||!_s.ds.length)return false;var _last='';_s.ds.forEach(function(id){var m=(DB.dealers||[]).find(function(x){return x&&x.id===id;});if(!m||!Array.isArray(m.payouts))return;m.payouts.forEach(function(x){var dd=String((x&&x.date)||'').slice(0,10);if(/^\d{4}-\d{2}-\d{2}$/.test(dd)&&(!_last||dd>_last))_last=dd;});});if(!_last)return false;var d=String((p&&p.date)||'').slice(0,10);if(!/^\d{4}-\d{2}-\d{2}$/.test(d))return false;return d>_last;}catch(e){return false;}}
function paidLockPaidSnapHit(c){return !!(c&&c.commPaidSnap&&typeof c.commPaidSnap==='object'&&!Array.isArray(c.commPaidSnap));}
function commAdjGateRows(c){const rows=commDealerSplitOf(c);const data=commBuildData();return rows.map(function(r){var g=null;for(var i=0;i<data.length;i++){if(data[i].dealer&&data[i].dealer.id===r.ownerId){g=data[i];break;}}var mineComm=Number(r.amt||0);var minePaid=0;if(g){commAlloc(g);(g.items||[]).forEach(function(it){if(it.cid===c.id&&!it.carry)minePaid=Number(it.paidOut||0);});}var d=DB.dealers.find(function(x){return x.id===r.ownerId;})||{};var st=dealerStats(r.ownerId);var poolComm=Number(st.commission||0);var poolPaid=Number(d.commissionPaid||0);return{name:d.name||'(未知)',isMine:(r.ownerId===c.dealerId),ownerId:r.ownerId,auto:Number(r.auto||0),adjNow:Number(r.adj||0),noteNow:(c.commAdj&&c.commAdj.notes&&c.commAdj.notes[r.ownerId])||'',mineComm:mineComm,minePaid:minePaid,poolComm:poolComm,poolPaid:poolPaid,gateOk:(poolComm+0.0001>=poolPaid)};});}function commAdjOf(c){const a=c&&c.commAdj;if(!a)return 0;if(a.amts&&typeof a.amts==='object'){let s=0;Object.keys(a.amts).forEach(k=>{const v=Math.round(Number(a.amts[k]||0)||0);if(isFinite(v))s+=v;});return s;}const v=Number(a.amt);return isFinite(v)?v:0;}function commAdjRowTpl(gr,_gs){return `<div style="display:grid;${_gs};border-top:1px solid var(--line);padding:5px 0;align-items:center"><div style="padding:0 8px">${esc(gr.name)}${gr.isMine?' <span style="color:var(--muted)">档案</span>':''}</div><div style="padding:0 8px">${moneyMask(fLevel('amount_kpi'),gr.auto)}</div><div style="padding:0 8px">${moneyMask(fLevel('amount_kpi'),gr.minePaid)}</div><div style="padding:0 6px"><input type="number" class="ca_amt_row" data-owner="${esc(gr.ownerId)}" step="1" value="${gr.adjNow||''}" placeholder="0" style="width:100%;box-sizing:border-box;padding:4px 6px;border:1px solid var(--line);border-radius:6px;background:var(--panel);color:var(--ink);font-size:inherit;text-align:right"></div><div style="padding:0 6px"><input type="text" class="ca_note_row" data-owner="${esc(gr.ownerId)}" value="${esc(gr.noteNow)}" placeholder="留空用总备注" style="width:100%;box-sizing:border-box;padding:4px 6px;border:1px solid var(--line);border-radius:6px;background:var(--panel);color:var(--ink);font-size:inherit"></div><div style="padding:0 8px;color:${gr.gateOk?'var(--ok)':'var(--danger)'}">${moneyMask(fLevel('amount_kpi'),gr.poolComm)} / ${moneyMask(fLevel('amount_kpi'),gr.poolPaid)}${gr.gateOk?' 通过':' 不通过'}</div></div>`;}
function openCommAdj(cid){const c=DB.customers.find(x=>x.id===cid);if(!c)return;if(!can('edit_comm_adj')){toast('无权限:调整客户分润金额需「调整客户分润金额」权限');return;}const cc=custCommissionOf(c);const adj=commAdjOf(c);const auto=cc.comm-adj;const gateRows=commAdjGateRows(c);const _caTrStf={};gateRows.forEach(function(gr){_caTrStf[gr.ownerId]=1;});const _caTrMore=DB.dealers.filter(function(d){return d&&d.type==='staff'&&!_caTrStf[d.id];}).sort(function(a,b){return String(a.name||'').localeCompare(String(b.name||''),'zh');});window.__caTrFromAll=gateRows.map(function(gr){return {id:gr.ownerId,name:gr.name||''};});window.__caTrAll=window.__caTrFromAll.concat(_caTrMore.map(function(d){return {id:d.id,name:d.name||''};}));const _gs=(typeof innerWidth!=='undefined'&&innerWidth<=640)?'grid-template-columns:1fr .55fr .5fr .55fr 1.05fr .95fr;font-size:11px':'grid-template-columns:1fr .7fr .6fr .7fr 1.45fr 1.15fr;font-size:12px';const note=(c.commAdj&&c.commAdj.note)||'';const by=(c.commAdj&&c.commAdj.by)||'';const at=(c.commAdj&&c.commAdj.at)||'';openModal(`<div class="modal-head"><h3>✏️ 调整分润金额 · ${esc(c.name||'(未命名)')}</h3><span class="x" onclick="closeModal()">×</span></div><div class="modal-body"><div class="grid three-col" style="margin-bottom:12px"><div class="card" style="padding:10px"><div class="mtxt">系统自动算</div><b>${moneyMask(fLevel('amount_kpi'),auto)}</b></div><div class="card" style="padding:10px"><div class="mtxt">当前人工调整</div><b style="color:#d97706">${adj?((adj>0?'+':'')+moneyMask(fLevel('amount_kpi'),adj)):'—'}</b></div><div class="card" style="padding:10px"><div class="mtxt">调整后应发</div><b style="color:#16a34a">${moneyMask(fLevel('amount_kpi'),cc.comm)}</b></div></div><div class="row2"><div class="field"><label>原因 / 总备注</label><input id="ca_note" value="${esc(note)}" placeholder="如:特批比例 / 补偿;与逐家备注至少填一处"></div><div class="field"><label>调整金额</label><div class="mtxt" style="padding-top:6px">按归属方逐家填写(见下表)</div></div></div>${(by||at)?`<div class="mtxt">上次调整:${esc(by)} ${esc(fmtTime(at).slice(0,10))}</div>`:''}<div id="ca_err" style="color:var(--danger);font-size:12px;min-height:16px;margin-top:6px"></div><div class="mtxt" style="margin-bottom:6px">本客户分润涉及 ${gateRows.length} 个归属方,请分别填写每家的调整金额(正数加、负数减、留空为 0);保存时逐个校验账户:</div><div id="ca_gate_tbl" style="border:1px solid var(--line);border-radius:8px;overflow:hidden;margin-bottom:8px"><div style="display:grid;${_gs};background:var(--bg);color:var(--muted);padding:5px 0"><div style="padding:0 8px">归属方</div><div style="padding:0 8px">本行自动</div><div style="padding:0 8px">本户已发</div><div style="padding:0 8px">调整金额</div><div style="padding:0 8px">备注</div><div style="padding:0 8px">账户闸门 应发/已发</div></div>${gateRows.map(function(gr){return commAdjRowTpl(gr,_gs);}).join('')}</div><div class="mtxt" style="margin:2px 0 4px">也可把一家的分润转给另一家(金额填正数:从「从」扣、加到「给」;「给」可直接选本客户归属方以外的员工,下拉内可打字搜索):</div><div style="display:flex;gap:6px;align-items:center;flex-wrap:wrap;margin-bottom:8px"><span class="mtxt">从</span><span style="display:inline-block;width:140px;vertical-align:middle">${smartSelectHTML({id:'ca_tr_from',items:window.__caTrFromAll,selectedId:(gateRows[0]?gateRows[0].ownerId:''),placeholder:'搜索名字…',displayKey:'name'})}</span><span class="mtxt">转</span><input type="number" id="ca_tr_amt" step="1" placeholder="金额" style="width:92px;padding:4px 6px;border:1px solid var(--line);border-radius:6px;background:var(--panel);color:var(--ink);font-size:inherit"><span class="mtxt">给</span><span style="display:inline-block;width:150px;vertical-align:middle">${smartSelectHTML({id:'ca_tr_to',items:window.__caTrAll,selectedId:(gateRows[1]?gateRows[1].ownerId:(gateRows[0]?gateRows[0].ownerId:'')),placeholder:'搜索名字…',displayKey:'name'})}</span><button type="button" class="btn ghost sm" onclick="applyCommAdjTransfer()">记入</button></div><div class="mtxt">保存时会校验每个归属方账户的「应发分润」不低于「已发放」,任一不通过即拒绝保存。原有分润规则不参与改写,此金额为人工加减项。</div></div><div class="modal-foot"><button class="btn ghost" onclick="closeModal()">取消</button>${adj?`<button class="btn ghost" onclick="clearCommAdj('${cid}')">恢复自动值</button>`:''}<button class="btn" onclick="saveCommAdj('${cid}')">保存调整</button></div>`);}
function applyCommAdjTransfer(){const f=document.getElementById('ca_tr_from'),t=document.getElementById('ca_tr_to'),a=document.getElementById('ca_tr_amt'),err=document.getElementById('ca_err');if(!f||!t||!a)return;const fid=f.value,tid=t.value;const n=Math.round(Number(a.value));if(!fid||!tid){if(err)err.textContent='转移:请选择转出方与转入方';return;}if(fid===tid){if(err)err.textContent='转移:转出方与转入方不能相同';return;}if(!isFinite(n)||!n){if(err)err.textContent='转移:请填写转移金额(正数)';return;}const fi=document.querySelector('.ca_amt_row[data-owner="'+fid+'"]');let ti=document.querySelector('.ca_amt_row[data-owner="'+tid+'"]');if(!fi){if(err)err.textContent='转移:找不到对应归属行(请重开弹窗)';return;}if(!ti){const dt=DB.dealers.find(function(x){return x&&x.id===tid;});const chip=document.getElementById('ca_gate_tbl');if(!dt||!chip){if(err)err.textContent='转移:找不到对应归属行(请重开弹窗)';return;}const _stx=dealerStats(tid);const _pc=Number(_stx.commission||0);const _pp=Number(dt.commissionPaid||0);const _gok=(_pc+0.0001>=_pp);const _gs2=(typeof innerWidth!=='undefined'&&innerWidth<=640)?'grid-template-columns:1fr .55fr .5fr .55fr 1.05fr .95fr;font-size:11px':'grid-template-columns:1fr .7fr .6fr .7fr 1.45fr 1.15fr;font-size:12px';chip.insertAdjacentHTML('beforeend',commAdjRowTpl({name:dt.name||'(未知)',isMine:false,ownerId:tid,auto:0,minePaid:0,adjNow:'',noteNow:'',poolComm:_pc,poolPaid:_pp,gateOk:_gok},_gs2));ti=document.querySelector('.ca_amt_row[data-owner="'+tid+'"]');if(window.__caTrFromAll&&!window.__caTrFromAll.some(function(o){return o.id===tid;})){window.__caTrFromAll.push({id:tid,name:dt.name||''});caTrFromRebuild();}if(window.__caTrAll&&!window.__caTrAll.some(function(o){return o.id===tid;}))window.__caTrAll.push({id:tid,name:dt.name||''});if(!ti){if(err)err.textContent='转移:新建归属行失败(请重开弹窗)';return;}}fi.value=String((Math.round(Number(fi.value)||0))-n);ti.value=String((Math.round(Number(ti.value)||0))+n);const _nmOf=function(list,id){const o=(list||[]).find(function(x){return x.id===id;});return (o&&o.name)?o.name:id;};const fnm=_nmOf(window.__caTrFromAll,fid);const tnm=_nmOf(window.__caTrAll,tid);const fe=document.querySelector('.ca_note_row[data-owner="'+fid+'"]');const te=document.querySelector('.ca_note_row[data-owner="'+tid+'"]');if(fe)fe.value=(fe.value?fe.value+';':'')+'转给'+tnm+' ¥'+n;if(te)te.value=(te.value?te.value+';':'')+'从'+fnm+'转入 ¥'+n;if(err)err.textContent='';a.value='';}
function caTrFromRebuild(){try{const w=document.getElementById('ca_tr_from_wrap');if(!w)return;const h=document.getElementById('ca_tr_from');const cur=h?h.value:'';w.outerHTML=smartSelectHTML({id:'ca_tr_from',items:(window.__caTrFromAll||[]),selectedId:cur,placeholder:'搜索名字…',displayKey:'name'});bindSmartSelect('ca_tr_from_wrap');}catch(e){}}
function saveCommAdj(cid){if(!can('edit_comm_adj')){toast('无权限:调整客户分润金额需「调整客户分润金额」权限');return;}const c=DB.customers.find(x=>x.id===cid);if(!c)return;const err=document.getElementById('ca_err');const note=(document.getElementById('ca_note').value||'').trim();const _ins=document.querySelectorAll('.ca_amt_row');const amts={};let total=0;let _badIn='';for(let i=0;i<_ins.length;i++){const _oid=_ins[i].getAttribute('data-owner');const _raw=(_ins[i].value||'').trim();if(!_raw||!_oid)continue;const _v=Math.round(Number(_raw));if(!isFinite(_v)){_badIn=_raw;break;}if(!_v)continue;amts[_oid]=_v;total+=_v;}const notes={};const _nins=document.querySelectorAll('.ca_note_row');for(let i=0;i<_nins.length;i++){const _noid=_nins[i].getAttribute('data-owner');const _nv=(_nins[i].value||'').trim();if(_noid&&_nv)notes[_noid]=_nv;}if(_badIn){if(err)err.textContent='调整金额不是有效数字:'+_badIn;return;}if(!Object.keys(amts).length){if(err)err.textContent='请至少填写一家的调整金额(全部留空 / 0 等于取消调整,请点「恢复自动值」)';return;}if(!note&&!Object.keys(notes).length){if(err)err.textContent='请填写调整原因或至少一家的备注(留痕需要)';return;}const old=c.commAdj;c.commAdj={amts,notes,amt:total,note,at:new Date().toISOString(),by:(currentUser&¤tUser.name)||''};commDealerOwnTbl._t=null;const bad=[];const seen={};commDealerSplitOf(c).forEach(r=>{if(seen[r.ownerId])return;seen[r.ownerId]=1;const dd=DB.dealers.find(x=>x.id===r.ownerId);if(!dd)return;const s=dealerStats(dd.id);if(s.commission+0.0001<commissionPaid(dd))bad.push(dd.name+'(应发 '+money(s.commission)+' < 已发 '+money(commissionPaid(dd))+')');});const cc2=custCommissionOf(c);if(cc2.comm<0)bad.push('调整后该客户应发为负('+money(cc2.comm)+')');if(bad.length){c.commAdj=old;commDealerOwnTbl._t=null;if(err)err.textContent='已拒绝:'+bad.join(';');return;}save().then(ok=>{if(ok){closeModal();render();toast('已调整「'+(c.name||'')+'」分润金额 '+(total>0?'+':'')+money(total));}else{c.commAdj=old;commDealerOwnTbl._t=null;}});}function clearCommAdj(cid){if(!can('edit_comm_adj')){toast('无权限:调整客户分润金额需「调整客户分润金额」权限');return;}const c=DB.customers.find(x=>x.id===cid);if(!c||!c.commAdj)return;if(!confirm('确认恢复为系统自动计算(清除人工调整)?'))return;delete c.commAdj;commDealerOwnTbl._t=null;save().then(ok=>{if(ok){closeModal();render();toast('已恢复自动计算');}});}function commAdjStaffOf(c){const a=c&&c.commAdjStaff;if(!a)return 0;if(a.amts&&typeof a.amts==='object'){let s=0;Object.keys(a.amts).forEach(function(k){const v=Math.round(Number(a.amts[k]||0)||0);if(isFinite(v))s+=v;});return s;}const v=Number(a.amt);return isFinite(v)?v:0;}function staffSvcRowHit(d,c){if(!d||d.type!=='staff'||!c)return false;const _rawD=(c&&c.dealerId)||'';if(c&&c.ownerHist&&c.ownerHist.length){const _a=c.ownerHist;const _t=String(c.signDate||'').slice(0,10);let _r=_a[0];for(let _i=0;_i<_a.length;_i++){if(String(_a[_i].at||'').slice(0,10)<=_t)_r=_a[_i];else break;}c=Object.assign({},c,{dealerId:(_r.dealerId||c.dealerId),serviceStaffId:(_r.staffId||c.serviceStaffId),ownerHist:undefined});}if(_rawD===d.id)return false;/*第259轮·整户自营排除按「当前档案归属经销商」判(不按签约日回看 ownerHist)——与金额函数 staffServiceCommission 同款*/if(Number(((d.commCarryFrom||{})[c.id])||0)>0)return false;if(c.stage!=='signed_full'&&c.stage!=='signed_installment')return false;if(c.refunded)return false;const ps=(c.payments||[]).filter(p=>p&&p.date);const _stfOk=id=>{const x=id?DB.dealers.find(y=>y.id===id):null;return !!(x&&x.type==='staff'&&x.userId);};if(ps.some(p=>_stfOk(p&&p.pStaffId)))return ps.some(p=>payOwnerStaffIdOf(c,p)===d.id);return c.serviceStaffId===d.id;}function staffTrRowHit(d,c){if(!d||!c||d.type!=='staff')return false;if((c.dealerId||'')===d.id)return false;if(Number(((d.commCarryFrom||{})[c.id])||0)>0)return false;if(c.stage!=='signed_full'||!c.contractSigned||c.installment||c.refunded)return false;return !!(c.commAdjStaff&&c.commAdjStaff.amts&&typeof c.commAdjStaff.amts==='object'&&Math.round(Number(c.commAdjStaff.amts[d.id]||0)||0)!==0);}/* ★ 第 220 轮:员工服务行「这笔算谁」的口径与金额函数 staffSvcAutoOf / staffServiceCommission 对齐 —— 存在有效笔级业绩归属员工时按每笔业绩归属命中,否则回落客户档案「对接员工」。旧写法只认 c.serviceStaffId,会把「业绩归属=付琳、对接员工=柳博」的客户错列进柳博组。 */function staffSvcAutoOf(d,c){if(c&&c.ownerHist&&c.ownerHist.length){const _a=c.ownerHist;const _t=String(c.signDate||'').slice(0,10);let _r=_a[0];for(let _i=0;_i<_a.length;_i++){if(String(_a[_i].at||'').slice(0,10)<=_t)_r=_a[_i];else break;}c=Object.assign({},c,{dealerId:(_r.dealerId||c.dealerId),serviceStaffId:(_r.staffId||c.serviceStaffId),ownerHist:undefined});}if(Number(((d.commCarryFrom||{})[c.id])||0)>0)return{rate:0,auto:0};const fullOk=c.stage==='signed_full'&&c.contractSigned&&!c.installment;if(c&&c.commOverrideStaff&&c.commOverrideStaff.mode==='fixed'){const _fx=fullOk?staffFixShareOf(c,d):0;return{rate:0,auto:_fx};}const _scOv=(c&&c.commOverrideStaff&&c.commOverrideStaff.mode==='rate'&&Number(c.commOverrideStaff.value)>0)?Number(c.commOverrideStaff.value):DT('staff').baseRate;const rate=fullOk&&staffMonthlyPaid(d.id,payClearMonth(c))>=100000?_scOv:0;const ps=(c.payments||[]).filter(p=>p&&p.date);const amt=ps.length?ps.reduce((s,p)=>{/*业绩归属闸门·第186轮·明细*/const _pd=(p&&p.pDealerId&&DB.dealers.find(x=>x.id===p.pDealerId))?p.pDealerId:c.dealerId;if(_pd===d.id)return s;return s+(payOwnerStaffIdOf(c,p)===d.id?Math.round(Number(p.amount||0)*rate):0);},0):Math.round((c.amount||0)*rate);return{rate,auto:amt};}function casRowTpl(gr,cgs){return `<div style="display:grid;${cgs};border-top:1px solid var(--line);padding:5px 0;align-items:center"><div style="padding:0 8px">${esc(gr.name)}</div><div style="padding:0 8px">${moneyMask(fLevel('amount_kpi'),gr.auto)}</div><div style="padding:0 6px"><input type="number" class="cas_amt_row" data-staff="${esc(gr.ownerId)}" step="1" value="${gr.adjNow||''}" placeholder="0" style="width:100%;box-sizing:border-box;padding:4px 6px;border:1px solid var(--line);border-radius:6px;background:var(--panel);color:var(--ink);font-size:inherit;text-align:right"></div><div style="padding:0 6px"><input type="text" class="cas_note_row" data-staff="${esc(gr.ownerId)}" value="${esc(gr.noteNow)}" placeholder="留空用总备注" style="width:100%;box-sizing:border-box;padding:4px 6px;border:1px solid var(--line);border-radius:6px;background:var(--panel);color:var(--ink);font-size:inherit"></div></div>`;}
function applyStaffCommAdjTransfer(cid){const c=DB.customers.find(x=>x.id===cid);if(!c)return;const f=document.getElementById('cas_tr_from'),t=document.getElementById('cas_tr_to'),a=document.getElementById('cas_tr_amt'),err=document.getElementById('cas_err');if(!f||!t||!a)return;const fid=f.value,tid=t.value;const n=Math.round(Number(a.value));if(!fid||!tid){if(err)err.textContent='转移:请选择转出方与转入方';return;}if(fid===tid){if(err)err.textContent='转移:转出方与转入方不能相同';return;}if(!isFinite(n)||!n){if(err)err.textContent='转移:请填写转移金额(正数)';return;}const fi=document.querySelector('.cas_amt_row[data-staff="'+fid+'"]');let ti=document.querySelector('.cas_amt_row[data-staff="'+tid+'"]');if(!fi){if(err)err.textContent='转移:找不到对应行(请重开弹窗)';return;}if(!ti){const dt=DB.dealers.find(function(x){return x&&x.id===tid;});const chip=document.getElementById('cas_gate_tbl');if(!dt||!chip){if(err)err.textContent='转移:找不到对应行(请重开弹窗)';return;}const _svb=staffSvcAutoOf(dt,c);const _cgs2=(typeof innerWidth!=='undefined'&&innerWidth<=640)?'grid-template-columns:1fr .7fr .8fr 1.2fr;font-size:11px':'grid-template-columns:1.1fr .8fr .9fr 1.4fr;font-size:12px';chip.insertAdjacentHTML('beforeend',casRowTpl({name:dt.name||'(未知)',ownerId:tid,auto:_svb.auto,adjNow:'',noteNow:''},_cgs2));ti=document.querySelector('.cas_amt_row[data-staff="'+tid+'"]');if(window.__casFromAll&&!window.__casFromAll.some(function(o){return o.id===tid;})){window.__casFromAll.push({id:tid,name:dt.name||''});casTrFromRebuild();}if(window.__casTrAll&&!window.__casTrAll.some(function(o){return o.id===tid;}))window.__casTrAll.push({id:tid,name:dt.name||''});if(!ti){if(err)err.textContent='转移:新建行失败(请重开弹窗)';return;}}fi.value=String((Math.round(Number(fi.value)||0))-n);ti.value=String((Math.round(Number(ti.value)||0))+n);const _nmOf=function(list,id){const o=(list||[]).find(function(x){return x.id===id;});return (o&&o.name)?o.name:id;};const fnm=_nmOf(window.__casFromAll,fid);const tnm=_nmOf(window.__casTrAll,tid);const fe=document.querySelector('.cas_note_row[data-staff="'+fid+'"]');const te=document.querySelector('.cas_note_row[data-staff="'+tid+'"]');if(fe)fe.value=(fe.value?fe.value+';':'')+'转给'+tnm+' ¥'+n;if(te)te.value=(te.value?te.value+';':'')+'从'+fnm+'转入 ¥'+n;if(err)err.textContent='';a.value='';}
function casTrFromRebuild(){try{const w=document.getElementById('cas_tr_from_wrap');if(!w)return;const h=document.getElementById('cas_tr_from');const cur=h?h.value:'';w.outerHTML=smartSelectHTML({id:'cas_tr_from',items:(window.__casFromAll||[]),selectedId:cur,placeholder:'搜索名字…',displayKey:'name'});bindSmartSelect('cas_tr_from_wrap');}catch(e){}}
function openStaffCommAdj(cid,did){const c=DB.customers.find(x=>x.id===cid);if(!c)return;if(!can('edit_comm_adj_staff')){toast('无权限:调整员工服务分润金额需「调整员工服务分润金额」权限');return;}const d=(did&&DB.dealers.find(x=>x.id===did))||DB.dealers.find(x=>x.id===c.serviceStaffId);if(!d){toast('该客户没有服务员工,无法调整');return;}const sv=staffSvcAutoOf(d,c);const adj=commAdjStaffOf(c);const paidTotal=commissionPaid(d);const note=(c.commAdjStaff&&c.commAdjStaff.note)||'';const by=(c.commAdjStaff&&c.commAdjStaff.by)||'';const at=(c.commAdjStaff&&c.commAdjStaff.at)||'';const _casA=(c.commAdjStaff&&c.commAdjStaff.amts&&typeof c.commAdjStaff.amts==='object')?c.commAdjStaff.amts:null;const _casN=(c.commAdjStaff&&c.commAdjStaff.notes&&typeof c.commAdjStaff.notes==='object')?c.commAdjStaff.notes:null;const _casIds=[d.id];if(_casA)Object.keys(_casA).forEach(function(k){if(k!==d.id&&Math.round(Number(_casA[k]||0)||0)!==0)_casIds.push(k);});const casRows=_casIds.map(function(rid){const dd2=DB.dealers.find(function(x){return x.id===rid;})||{id:rid,name:'(未知)'};const _svb=staffSvcAutoOf(dd2,c);const _now=_casA?Math.round(Number(_casA[rid]||0)||0):(rid===d.id?adj:0);return {ownerId:rid,name:dd2.name||'(未知)',auto:_svb.auto,adjNow:(_now||''),noteNow:(_casN&&_casN[rid])||''};});const _casMore=DB.dealers.filter(function(x){return x&&x.type==='staff'&&_casIds.indexOf(x.id)<0;}).sort(function(a,b){return String(a.name||'').localeCompare(String(b.name||''),'zh');});window.__casFromAll=casRows.map(function(r){return {id:r.ownerId,name:r.name||''};});window.__casTrAll=window.__casFromAll.concat(_casMore.map(function(x){return {id:x.id,name:x.name||''};}));const cgs=(typeof innerWidth!=='undefined'&&innerWidth<=640)?'grid-template-columns:1fr .7fr .8fr 1.2fr;font-size:11px':'grid-template-columns:1.1fr .8fr .9fr 1.4fr;font-size:12px';openModal(`<div class="modal-head"><h3>✏️ 调整员工服务分润 · ${esc(c.name||'(未命名)')}</h3><span class="x" onclick="closeModal()">×</span></div><div class="modal-body"><div class="mtxt" style="margin-bottom:8px">服务员工:<b>${esc(d.name||'')}</b></div><div class="grid three-col" style="margin-bottom:12px"><div class="card" style="padding:10px"><div class="mtxt">系统自动算</div><b>${moneyMask(fLevel('amount_kpi'),sv.auto)}</b></div><div class="card" style="padding:10px"><div class="mtxt">当前人工调整</div><b style="color:#d97706">${adj?((adj>0?'+':'')+moneyMask(fLevel('amount_kpi'),adj)):'—'}</b></div><div class="card" style="padding:10px"><div class="mtxt">调整后应发</div><b style="color:#16a34a">${moneyMask(fLevel('amount_kpi'),sv.auto+adj)}</b></div></div><div class="row2"><div class="field"><label>原因 / 总备注</label><input id="cas_note" value="${esc(note)}" placeholder="如:特批比例 / 补偿;与逐家备注至少填一处"></div><div class="field"><label>调整金额</label><div class="mtxt" style="padding-top:6px">按员工逐家填写(见下表)</div></div></div>${(by||at)?`<div class="mtxt">上次调整:${esc(by)} ${esc(fmtTime(at).slice(0,10))}</div>`:''}<div id="cas_err" style="color:var(--danger);font-size:12px;min-height:16px;margin-top:6px"></div><div class="mtxt" style="margin-bottom:6px">本客户服务分润涉及 ${casRows.length} 名员工,请分别填写每家的调整金额(正数加、负数减、留空为 0);保存时逐个校验:</div><div id="cas_gate_tbl" style="border:1px solid var(--line);border-radius:8px;overflow:hidden;margin-bottom:8px"><div style="display:grid;${cgs};background:var(--bg);color:var(--muted);padding:5px 0"><div style="padding:0 8px">员工</div><div style="padding:0 8px">本行自动</div><div style="padding:0 8px">调整金额</div><div style="padding:0 8px">备注</div></div>${casRows.map(function(gr){return casRowTpl(gr,cgs);}).join('')}</div><div class="mtxt" style="margin:2px 0 4px">也可把一人的分润转给另一人(金额填正数:从「从」扣、加到「给」;「给」可直接选本客户服务员工以外的员工,下拉内可打字搜索):</div><div style="display:flex;gap:6px;align-items:center;flex-wrap:wrap;margin-bottom:8px"><span class="mtxt">从</span><span style="display:inline-block;width:140px;vertical-align:middle">${smartSelectHTML({id:'cas_tr_from',items:window.__casFromAll,selectedId:(casRows[0]?casRows[0].ownerId:''),placeholder:'搜索名字…',displayKey:'name'})}</span><span class="mtxt">转</span><input type="number" id="cas_tr_amt" step="1" placeholder="金额" style="width:92px;padding:4px 6px;border:1px solid var(--line);border-radius:6px;background:var(--panel);color:var(--ink);font-size:inherit"><span class="mtxt">给</span><span style="display:inline-block;width:150px;vertical-align:middle">${smartSelectHTML({id:'cas_tr_to',items:window.__casTrAll,selectedId:(casRows[1]?casRows[1].ownerId:(casRows[0]?casRows[0].ownerId:'')),placeholder:'搜索名字…',displayKey:'name'})}</span><button type="button" class="btn ghost sm" onclick="applyStaffCommAdjTransfer('${cid}')">记入</button></div><div class="mtxt">保存时会校验每个员工的「应发分润」不低于「已发放」,且每笔调整必须真实落到该员工名下;原有分润规则不参与改写,此金额为人工加减项。</div></div><div class="modal-foot"><button class="btn ghost" onclick="closeModal()">取消</button>${adj?`<button class="btn ghost" onclick="clearStaffCommAdj('${cid}')">恢复自动值</button>`:''}<button class="btn" onclick="saveStaffCommAdj('${cid}')">保存调整</button></div>`);}function saveStaffCommAdj(cid,did){if(!can('edit_comm_adj_staff')){toast('无权限:调整员工服务分润金额需「调整员工服务分润金额」权限');return;}const c=DB.customers.find(x=>x.id===cid);if(!c)return;const err=document.getElementById('cas_err');const note=(document.getElementById('cas_note').value||'').trim();const _ins=document.querySelectorAll('.cas_amt_row');const amts={};let total=0;let _badIn='';for(let i=0;i<_ins.length;i++){const _sid=_ins[i].getAttribute('data-staff');const _raw=(_ins[i].value||'').trim();if(!_raw||!_sid)continue;const _v=Math.round(Number(_raw));if(!isFinite(_v)){_badIn=_raw;break;}if(!_v)continue;amts[_sid]=_v;total+=_v;}const notes={};const _nins=document.querySelectorAll('.cas_note_row');for(let i=0;i<_nins.length;i++){const _noid=_nins[i].getAttribute('data-staff');const _nv=(_nins[i].value||'').trim();if(_noid&&_nv)notes[_noid]=_nv;}if(_badIn){if(err)err.textContent='调整金额不是有效数字:'+_badIn;return;}if(!Object.keys(amts).length){if(err)err.textContent='请至少填写一名的调整金额(全部留空 / 0 等于取消调整,请点「恢复自动值」)';return;}if(!note&&!Object.keys(notes).length){if(err)err.textContent='请填写调整原因或至少一家的备注(留痕需要)';return;}const d=(did&&DB.dealers.find(x=>x.id===did))||DB.dealers.find(x=>x.id===c.serviceStaffId);if(!d){if(err)err.textContent='该客户没有服务员工,无法调整';return;}const _old=c.commAdjStaff;const _pre={};c.commAdjStaff=undefined;Object.keys(amts).forEach(function(sid){const s2=DB.dealers.find(x=>x.id===sid);if(s2)_pre[sid]=staffServiceCommission(s2);});c.commAdjStaff={amts,notes,amt:total,note,at:new Date().toISOString(),by:(currentUser&¤tUser.name)||''};const bad=[];Object.keys(amts).forEach(function(sid){const s2=DB.dealers.find(x=>x.id===sid);if(!s2){bad.push('找不到员工 '+sid);return;}const post=staffServiceCommission(s2);const delta=post-(_pre[sid]||0);if(delta!==amts[sid])bad.push((s2.name||sid)+' 的调整未落到应发(差额 '+delta+' 期望 '+amts[sid]+';该员工在此客户不产生服务分润)');else if(post+0.0001<commissionPaid(s2))bad.push((s2.name||sid)+'(应发 '+money(post)+' < 已发 '+money(commissionPaid(s2))+')');});const sv=staffSvcAutoOf(d,c);const _dA=Math.round(Number(amts[d.id]||0)||0);if(_dA&&sv.auto+_dA<0)bad.push('该客户服务分润为负('+money(sv.auto+_dA)+')');if(bad.length){c.commAdjStaff=_old;if(err)err.textContent='已拒绝:'+bad.join(';');return;}save().then(ok=>{if(ok){closeModal();render();toast('已调整「'+(c.name||'')+'」员工服务分润 '+(total>0?'+':'')+money(total));}else{c.commAdjStaff=_old;}});}function clearStaffCommAdj(cid){if(!can('edit_comm_adj_staff')){toast('无权限:调整员工服务分润金额需「调整员工服务分润金额」权限');return;}const c=DB.customers.find(x=>x.id===cid);if(!c||!c.commAdjStaff)return;if(!confirm('确认恢复为系统自动计算(清除人工调整)?'))return;delete c.commAdjStaff;save().then(ok=>{if(ok){closeModal();render();toast('已恢复自动计算');}});}let dealerPageTab='list';
function dealerTabBar(){if(dealerPageTab==='comm'&&!can('view_dealer_comm'))dealerPageTab='list';const commBtn=can('view_dealer_comm')?`<button type="button" class="${dealerPageTab==='comm'?'active':''}" onclick="setDealerTab('comm')">💸 分润来源明细</button>`:'';return `<div class="dealer-page-tabs"><button type="button" class="${dealerPageTab==='list'?'active':''}" onclick="setDealerTab('list')">经销商列表</button>${commBtn}</div>`;}
function setDealerTab(t){dealerPageTab=(t==='comm')?'comm':'list';render();} /* ★ 第 252 轮:原只调 renderDealers() ⇒ 跳过 mountModuleTools,切回列表后顶栏「资料 / 数据导出」按钮丢失 */
function renderDealerComm(){if(!can('view_dealer_comm')){dealerPageTab='list';curView='dealers';render();return;}const typeChips=`<div class="chip-row" id="commTypeChips"><button type="button" class="chip${commFilter.type.length?'':' active'}" data-k="" onclick="commTypePick('',1)">全部类型</button>${Object.entries(DEALER_TYPES).map(([k,v])=>`<button type="button" class="chip${commFilter.type.indexOf(k)!==-1?' active':''}" data-k="${k}" onclick="commTypePick('${k}')">${v.name}</button>`).join('')}</div>`;view.innerHTML=`<div class="topbar"><div><h1>经销商管理</h1><p>分润来源明细 · 全部经销商应发分润的客户级来源(实收交款、全款时间、分润比例与金额)</p></div><div><button class="btn sm" onclick="exportDealerCommCsv()">⬇ 导出分润明细 CSV</button></div></div>${dealerTabBar()}<div class="grid kpis comm-kpis" id="commKpis"></div><div class="search compact" style="margin-top:14px"><input id="commQ" placeholder="搜索经销商 / 客户" value="${esc(commFilter.q||'')}" oninput="commFilter.q=this.value;commRenderTable()" oncompositionend="commFilter.q=this.value;commRenderTable()">${typeChips}<label style="white-space:nowrap;font-size:13px;display:flex;align-items:center;gap:4px"><input type="checkbox" id="commEarned" ${commFilter.onlyEarned?'checked':''} onchange="commFilter.onlyEarned=this.checked;commRenderTable()"> 只看有分润</label><label style="white-space:nowrap;font-size:13px;display:flex;align-items:center;gap:4px"><input type="checkbox" id="commNoComm" ${commFilter.onlyNoComm?'checked':''} onchange="commFilter.onlyNoComm=this.checked;commRenderTable()"> 仅看无分润</label><label style="white-space:nowrap;font-size:13px;display:flex;align-items:center;gap:4px"><input type="checkbox" id="commUnpaid" ${commFilter.onlyUnpaid?'checked':''} onchange="commFilter.onlyUnpaid=this.checked;commRenderTable()"> 只看未分润</label><label style="white-space:nowrap;font-size:13px;display:flex;align-items:center;gap:4px"><input type="checkbox" id="commInst" ${commFilter.onlyInst?'checked':''} onchange="commFilter.onlyInst=this.checked;commRenderTable()"> 只看分期</label><label style="white-space:nowrap;font-size:13px;display:flex;align-items:center;gap:4px"><input type="checkbox" id="commUndeliv" ${commFilter.onlyUndeliv?'checked':''} onchange="commFilter.onlyUndeliv=this.checked;commRenderTable()"> 只看未交完</label><label style="white-space:nowrap;font-size:13px;display:flex;align-items:center;gap:4px;flex-wrap:wrap;min-width:0;max-width:100%">全款时间<input type="date" id="commFdFrom" style="min-width:0;flex:1 1 104px;max-width:100%" value="${commFilter.fdFrom||''}" onchange="commFilter.fdFrom=this.value;commRenderTable()"><span style="opacity:.55">至</span><input type="date" id="commFdTo" style="min-width:0;flex:1 1 104px;max-width:100%" value="${commFilter.fdTo||''}" onchange="commFilter.fdTo=this.value;commRenderTable()"></label><button class="btn ghost sm" type="button" onclick="resetCommFilter()">重置</button></div><div class="card table-card"><table><thead><tr><th>经销商</th><th>对接员工</th><th>客户</th><th>课程交付</th><th>来源</th><th>已交款</th><th>全款时间</th><th>分润比例</th><th>分润金额</th><th>已发分润</th><th>未发分润</th><th>发放时间</th><th>说明</th></tr></thead><tbody id="commTblBody"></tbody></table></div><div class="hint" style="margin-top:12px">口径说明:分润按客户全额交清(实收累计≥合同金额)当月考核归属;员工类型按其当月业绩归属给本人的交款合计≥10万计4.5%;分期、退费、未签合同客户不计分润(仍会列出作为来源,说明列标注原因);校区类前若干家抵扣自用不发现金。「全款时间」为实收累计达到合同金额的那笔交款日期。「已发/未发分润」按该经销商累计已发放金额,依客户全款时间先后顺序抵扣到各来源行(先扣除已结转额),未发=应发−已发。客户变更归属后,原归属处已发放的分润以「结转」行保留,未发放部分随客户迁往新归属、按新经销商口径重算。<br>筛选:「全款时间」按实收达到合同金额那笔交款日期做区间筛选(含起止当日;无全款时间的「结转」行不参与日期筛选)。任一筛选生效时,列表与「导出分润明细 CSV」同步只列命中项 —— 导出即当前所见。「只看未分润」只列未发分润>0 的来源行(已结清行不列出);「仅看无分润」只列没有任何分润来源的经销商,并在该行下方注明原因(名下无客户 / 有客户但没签约 / 只有分期);已退费 / 已终止合作 / 已消耗终止合作的经销商不列出(它们本就不参与分润);与「只看有分润」可叠加使用。「只看分期」只列分期客户(说明列标注「分期订单不计分润」)的来源行。「发放时间」为该来源行被发放流水填满那笔的日期(按发放流水日期先后、先扣结转额后逐行推导;部分发放标「· 部分」,结转行标「结转」,未发显示「未发放」)。「人工调整」小字随行列出调整金额与原因(客户侧与员工侧调整同格式)。</div>`;commRenderTable();}
function commStaffCellOf(d,i){if(!d||d.type==='staff')return '';var c=(i&&i.cid)?DB.customers.find(function(x){return x&&x.id===i.cid;}):null;return (c&&svcStaffNameOf(c))||'';}
function commRenderTable(){const tb=document.getElementById('commTblBody');if(!tb)return;const groups=commGroups();let totalComm=0,totalRows=0,earnedRows=0,totalPaid=0,totalUnpaid=0,totalPaidAmt=0;const gW=groups.filter(g=>commVisible(g).length>0),gE=groups.filter(g=>commVisible(g).length===0);const _grp=g=>{const items=commVisible(g);commAlloc(g);const _ncHide=(!items.length&&!commNoCommOpen)?' style="display:none"':'';totalComm+=items.reduce((s,i)=>s+i.comm,0);totalPaid+=items.reduce((s,i)=>s+(i.paidOut||0),0);totalUnpaid+=items.reduce((s,i)=>s+(i.unpaid||0),0);totalPaidAmt+=items.reduce((s,i)=>s+(i.paid||0),0);totalRows+=items.length;earnedRows+=items.filter(i=>i.comm>0).length;const cfg=DT(g.dealer.type);const rows=items.length?items.map(i=>`<tr><td><b>${esc(g.dealer.name)}</b><div class="mtxt">${cfg.name}</div></td><td class="mtxt">${esc(commStaffCellOf(g.dealer,i))||'—'}</td><td>${esc(i.name)}${(i.cid&&!i.carry&&!i.adjStaff&&can('edit_comm_adj'))?`<button type="button" class="btn ghost sm" style="margin-left:4px" title="调整该客户的分润(可分别填写各归属方的调整金额)" onclick="openCommAdj('${i.cid}')">调整</button>`:''}${(i.cid&&(i.adjStaff||i.staffAdj)&&can('edit_comm_adj_staff'))?`<button type="button" class="btn ghost sm" style="margin-left:4px" onclick="openStaffCommAdj('${i.cid}','${g.dealer.id}')">调整</button>`:''}${i.adj?`<div class="mtxt" style="color:#d97706">人工调整 ${i.adj>0?'+':''}${moneyMask(fLevel('amount_kpi'),i.adj)}${i.adjNote?(' · '+esc(i.adjNote)):''}</div>`:''}</td><td>${i.cid?commDelivCellHtml(commCustOf(i.cid)):'<span class="mtxt">—</span>'}</td><td>${i.src}</td><td>${moneyMask(fLevel('amount'),i.paid)}</td><td>${i.fullDate||'—'}</td><td>${fLevel('amount_kpi')==='full'?(i.rate>0?(i.rate*100).toFixed(1).replace(/\.0$/,'')+'%':'—'):(fLevel('amount_kpi')==='mask'?'***':'****')}</td><td><b style="color:${i.comm>0?'#16a34a':'#94a3b8'}">${moneyMask(fLevel('amount_kpi'),i.comm)}</b></td><td style="color:#16a34a">${moneyMask(fLevel('amount_kpi'),i.paidOut||0)}</td><td style="color:${(i.unpaid||0)>0?'#dc2626':'#94a3b8'}">${moneyMask(fLevel('amount_kpi'),i.unpaid||0)}</td><td class="mtxt">${i.paidAt?esc(i.paidAt)+(i.unpaid>0?' · 部分':''):((i.comm>0&&(i.unpaid||0)>0)?'未发放':'—')}</td><td class="mtxt">${esc(perfOwnerJoin(i.note,i.ownerNote)||'—')}</td></tr>`).join(''):`<tr${_ncHide}><td><b>${esc(g.dealer.name)}</b><div class="mtxt">${cfg.name}</div></td><td colspan="12" class="empty">暂无符合条件的分润来源<div class="mtxt" style="margin-top:3px">${esc(commEmptyReasonOf(g.dealer))}</div></td></tr>`;const sub=items.reduce((s,i)=>s+i.comm,0);const subPaid=items.reduce((s,i)=>s+(i.paidOut||0),0);const subUnpaid=items.reduce((s,i)=>s+(i.unpaid||0),0);const subAmt=items.reduce((s,i)=>s+(i.paid||0),0);return rows+`<tr${_ncHide} style="background:#f8fafc;font-weight:700"><td colspan="5" style="text-align:right">「${esc(g.dealer.name)}」小计(${items.length}条来源)</td><td>${moneyMask(fLevel('amount'),subAmt)}</td><td colspan="2" class="mtxt" style="text-align:center">—</td><td>${moneyMask(fLevel('amount_kpi'),sub)}</td><td style="color:#16a34a">${moneyMask(fLevel('amount_kpi'),subPaid)}</td><td style="color:#dc2626">${moneyMask(fLevel('amount_kpi'),subUnpaid)}</td><td class="mtxt" style="text-align:center">—</td><td class="mtxt">${cfg.assessment||''}</td></tr>`;};const body=gW.map(_grp).join('')+(gE.length?(`<tr class="comm-nc-head" onclick="commNoCommToggle()" style="cursor:pointer;background:#f1f5f9"><td colspan="13" style="text-align:center;color:#475569;font-weight:700;font-size:12.5px">— 以下 ${gE.length} 家经销商暂无分润来源(${commNoCommOpen?'点此收起 ▴':'点此展开 ▾'})—</td></tr>`+gE.map(_grp).join('')):'');tb.innerHTML=body+`<tr style="font-weight:800;background:#eef2ff"><td colspan="5" style="text-align:right">总计</td><td>${moneyMask(fLevel('amount'),totalPaidAmt)}</td><td colspan="2" class="mtxt" style="text-align:center">—</td><td>${moneyMask(fLevel('amount_kpi'),totalComm)}</td><td>${moneyMask(fLevel('amount_kpi'),totalPaid)}</td><td>${moneyMask(fLevel('amount_kpi'),totalUnpaid)}</td><td class="mtxt" style="text-align:center">—</td><td class="mtxt">${earnedRows}/${totalRows} 条有分润</td></tr>`;const ks=document.getElementById('commKpis');if(ks)ks.innerHTML=kpiCard('💸','应发分润合计',moneyMask(fLevel('amount_kpi'),totalComm),'按当前筛选条件','#16a34a')+kpiCard('🤝','分润来源条目',totalRows,earnedRows+' 条产生分润','#6366f1')+kpiCard('🏷️','经销商数',groups.length,'有分润 '+gW.length+' 家 · 无分润 '+gE.length+' 家','#d97706')+kpiCard('✅','已发放合计',moneyMask(fLevel('amount_kpi'),totalPaid),'按发放记录累计','#16a34a')+kpiCard('⏳','待发放合计',moneyMask(fLevel('amount_kpi'),totalUnpaid),'应发−已发','#dc2626');}
function resetCommFilter(){commFilter={q:'',type:[],onlyEarned:false,onlyUnpaid:false,onlyInst:false,onlyUndeliv:false,fdFrom:'',fdTo:'',onlyNoComm:false};const q=document.getElementById('commQ');if(q)q.value='';chipPaint('commTypeChips',commFilter.type);const e=document.getElementById('commEarned');if(e)e.checked=false;const u=document.getElementById('commUnpaid');if(u)u.checked=false;const ncm=document.getElementById('commNoComm');if(ncm)ncm.checked=false;const ii=document.getElementById('commInst');if(ii)ii.checked=false;const iu=document.getElementById('commUndeliv');if(iu)iu.checked=false;const f1=document.getElementById('commFdFrom');if(f1)f1.value='';const f2=document.getElementById('commFdTo');if(f2)f2.value='';commRenderTable();}
function exportDealerCommCsv(){if(!can('view_dealer_comm')||!can('export_dealer_comm')){toast('无权限:导出分润来源明细需「导出分润来源明细」权限');return;}const fa=fLevel('amount')==='full'&&!fDeny('amount','x');const fk=fLevel('amount_kpi')==='full'&&!fDeny('amount_kpi','x');/* ★ 第 272 轮:先问范围(默认当前筛选) */if(_EXP_SCOPE===null){openExportScope({title:'分润来源明细',unit:'家',desc:'一行 = 一个经销商的每条分润来源。',filterNote:'包含当前类型筛选、搜索与明细筛选',filterN:commGroups().length,allN:commBuildData().length,onExport:function(){exportDealerCommCsv();}});return;}/* 「全部」分支:与筛选分支同构,仅数据源换成 commBuildData()(下方 const groups=commGroups(); 原样保留,供既有守卫断言「导出与列表同源」) */if(_EXP_SCOPE==='all'){const _ag=commSortByHomes(commBuildData());const _arows=[];_ag.forEach(g=>{commAlloc(g);const _ai=commVisible(g);const _acfg=DT(g.dealer.type);commCsvRows(g.dealer.name,_acfg.name,_ai,fa,fk,function(i){return commStaffCellOf(g.dealer,i);}).forEach(r=>_arows.push(r));});if(!delivDownloadCsv('分润来源明细_'+today()+'.csv',[commCsvHead()].concat(_arows)))return;toast('已按全部导出分润来源明细 CSV('+_ag.length+' 家 / '+_arows.length+' 条来源)');return;}const groups=commSortByHomes(commGroups());const rows=[];groups.forEach(g=>{commAlloc(g);const items=commVisible(g);const cfg=DT(g.dealer.type);commCsvRows(g.dealer.name,cfg.name,items,fa,fk,function(i){return commStaffCellOf(g.dealer,i);}).forEach(r=>rows.push(r));});if(!delivDownloadCsv('分润来源明细_'+today()+'.csv',[commCsvHead()].concat(rows)))return;toast('已按当前筛选导出分润来源明细 CSV('+groups.length+' 家 / '+groups.reduce((s,g)=>s+commVisible(g).length,0)+' 条来源)');}
let custBatchSel={};
function _cbMonthKeys(n){var ks=[];var d=new Date();for(var i=n-1;i>=0;i--){var t=new Date(d.getFullYear(),d.getMonth()-i,1);ks.push(t.getFullYear()+'-'+String(t.getMonth()+1).padStart(2,'0'));}return ks;}
function _svgVBars(items,opt){opt=opt||{};var W=340,H=190,L=30,B=24,T=16;var max=1;items.forEach(function(i){if(i.value>max)max=i.value;});var n=Math.max(1,items.length);var iw=(W-L-6)/n;var s='';
for(var i=0;i<items.length;i++){var v=items[i].value;var h=v>0?Math.max(2,Math.round(v/max*(H-T-B))):0;var x=L+i*iw+iw*0.2;var w=iw*0.6;var y=H-B-h;
var lab='';
if(opt.money){var lv=fLevel('amount');lab=(lv==='full')?(v>=10000?(Math.round(v/1000)/10)+'万':String(v)):((lv==='mask')?'***':'');}
else{lab=String(v);}
s+='<rect x="'+x+'" y="'+y+'" width="'+w+'" height="'+h+'" rx="3" fill="'+(opt.color||'#4f46e5')+'" opacity="0.88"><title>'+items[i].label+':'+(opt.money?moneyMask(fLevel('amount'),v):v)+'</title></rect>';
if(lab!=='')s+='<text x="'+(x+w/2)+'" y="'+(y-4)+'" font-size="9" fill="#64748b" text-anchor="middle">'+lab+'</text>';
s+='<text x="'+(L+i*iw+iw/2)+'" y="'+(H-8)+'" font-size="9" fill="#94a3b8" text-anchor="middle">'+items[i].label.slice(2)+'</text>';}
return '<svg viewBox="0 0 '+W+' '+H+'" style="width:100%;height:auto;display:block">'+s+'<line x1="'+L+'" y1="'+(H-B)+'" x2="'+(W-4)+'" y2="'+(H-B)+'" stroke="#e2e8f0"/></svg>';}
function _svgHBars(ds){if(!ds.length)return '<div class="empty">暂无成交数据</div>';var rowH=24,T=4,W=340,nw=92,bw=W-nw-58;var max=1;ds.forEach(function(x){if(x.full>max)max=x.full;});var s='';
ds.forEach(function(x,i){var y=T+i*rowH;var w=x.full>0?Math.max(3,Math.round(x.full/max*bw)):0;var nm=x.name.length>7?x.name.slice(0,7)+'…':x.name;
s+='<text x="0" y="'+(y+14)+'" font-size="10.5" fill="#334155">'+esc(nm)+'</text>';
s+='<rect x="'+nw+'" y="'+(y+4)+'" width="'+w+'" height="12" rx="3" fill="#4f46e5" opacity="0.85"><title>'+esc(x.name)+'('+esc(x.type)+'):全款成交 '+x.full+' 家 / 合计成交 '+x.all+' 家</title></rect>';
s+='<text x="'+(W-2)+'" y="'+(y+14)+'" font-size="10" fill="#64748b" text-anchor="end">'+x.full+'/'+x.all+'</text>';});
return '<svg viewBox="0 0 '+W+' '+(T+ds.length*rowH+8)+'" style="width:100%;height:auto;display:block">'+s+'</svg>';}
function repChartsHtml(){try{
var mk=_cbMonthKeys(12);
// 新增趋势与仪表盘同口径(2026-09-09 修复):直接复用 monthlyTrend()——客户排除 refunded/terminated、经销商排除 staff/fromCustomerId、柱=经销商+客户合计
var trendData=(typeof monthlyTrend==='function')?monthlyTrend():[];
while(trendData.length>1&&(trendData[0].dealers+trendData[0].customers)===0)trendData.shift();
var sItems=trendData.map(function(m){return{label:m.ym,value:m.dealers+m.customers};});
var payCnt={};DB.customers.forEach(function(c){(c.payments||[]).forEach(function(p){var m=(p.date||'').slice(0,7);if(m)payCnt[m]=(payCnt[m]||0)+Number(p.amount||0);});});
var pItems=mk.map(function(m){return{label:m,value:Math.round(payCnt[m]||0)};});
var payTotal=pItems.reduce(function(s,i){return s+i.value;},0);
var ds=DB.dealers.filter(function(d){return d.type!=='staff';}).map(function(d){var s=dealerStats(d.id);return{name:d.name||'',type:(DT(d.type)||{}).name||'',full:s.signedFull||0,all:s.signedAll||0};}).filter(function(x){return x.all>0;}).sort(function(a,b){return (b.full-a.full)||(b.all-a.all);}).slice(0,10);
return '<div class="card" style="margin-bottom:16px"><div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(300px,1fr));gap:18px">'+
'<div><h4 style="font-size:14px;margin-bottom:8px">📈 近12个月新增趋势(按达成合作日期 · 不含公司员工与员工自营校区)</h4>'+_svgVBars(sItems,{color:'#4f46e5'})+'</div>'+
'<div><h4 style="font-size:14px;margin-bottom:8px">💰 近12个月回款趋势(合计 '+moneyMask(fLevel('amount'),payTotal)+')</h4>'+_svgVBars(pItems,{color:'#16a34a',money:true})+'</div>'+
'<div><h4 style="font-size:14px;margin-bottom:8px">🏅 经销商业绩排行 TOP10(全款/合计成交)</h4>'+_svgHBars(ds)+'</div>'+
'</div></div>';}catch(e){return '<div class="card mtxt" style="color:#dc2626">图表渲染失败:'+esc((e&&e.message)||e)+'</div>';}}
// ===== 客户列表页 · 批量勾选 + 批量指定(2026-09-14 用户要求:先搜索/筛选,再勾选,再批量指定)=====
// 勾选状态 custBatchSel 是唯一真相源:列表页勾选、快捷选择、两个批量指定弹窗、批量补信息 共用它。
// 勾选事件走「事件委托 + 类名」(不用行内 onchange):列表每次重绘都换新元素,委托到 document 免重新绑定,
// 同时避免在模板字符串里嵌套引号。用 window 标记防重复注册。
if(!window.__custSelDelegated){window.__custSelDelegated=1;
document.addEventListener('change',function(e){
var t=e.target;if(!t||!t.classList)return;
if(t.classList.contains('cust-sel'))custSelToggle(t.value,t.checked);
else if(t.id==='custSelAll')custSelAllToggle(t.checked);
});
}
function custSelCount(){var n=0;Object.keys(custBatchSel).forEach(function(k){if(custBatchSel[k])n++;});return n;}
function custSelRowOf(b){var n=b;while(n&&n.tagName!=='TR')n=n.parentNode;return (n&&n.tagName==='TR')?n:null;}
function custSelRowVisible(tr){try{if(!tr)return false;if(tr.hidden)return false;var d=tr.style?tr.style.display:'';if(d==='none')return false;return true;}catch(e){return true;}}
function custSelShownIds(){var a=[];document.querySelectorAll('#custTable input.cust-sel').forEach(function(b){if(custSelRowVisible(custSelRowOf(b)))a.push(b.value);});return a;}
/* ===== 第 516 轮:顶部布局重排配套 JS ===== */
function custMenuToggle(id,btn){var m=document.getElementById(id);if(!m)return;if(!m.hidden){m.hidden=true;return;}custMoreClose();custQuickClose();m.hidden=false;var r=(btn&&btn.getBoundingClientRect)?btn.getBoundingClientRect():null;if(!r)return;var w=m.offsetWidth||180,h=m.offsetHeight||0;var left=Math.min(Math.max(8,r.right-w),Math.max(8,window.innerWidth-w-8));var top=r.bottom+6;if(top+h>window.innerHeight-8)top=Math.max(8,r.top-h-6);m.style.left=left+'px';m.style.top=top+'px';}
function custMoreToggle(e){if(e&&e.stopPropagation)e.stopPropagation();custMenuToggle('custMoreMenu',e&&e.currentTarget);}
function custQuickToggle(e){if(e&&e.stopPropagation)e.stopPropagation();custMenuToggle('custQuickMenu',e&&e.currentTarget);}
function custMoreClose(){var m=document.getElementById('custMoreMenu');if(m)m.hidden=true;}
function custQuickClose(){var m=document.getElementById('custQuickMenu');if(m)m.hidden=true;}
function custAdvCount(){try{var f=(typeof custFilter!=='undefined'&&custFilter)||{};var k=['dealer','staff','teacher','stage','intent','reportStart','reportEnd','signStart','signEnd','paidMin','paidMax','contract','orderKind','followState','followStaff','followFrom','followTo','addrKw'];var n=0;k.forEach(function(x){if(String(f[x]==null?'':f[x]).trim()!=='')n++;});return n;}catch(e){return 0;}}
function custResSync(){var el=document.getElementById('custResCnt');if(!el)return;var tot=parseInt(el.getAttribute('data-total'),10);if(isNaN(tot))tot=0;var q=String((typeof custFilter!=='undefined'&&custFilter.kw)||'').trim();if(!q){el.textContent='共 '+tot+' 条';return;}var vis=0;document.querySelectorAll('#custTable tr[data-search]').forEach(function(tr){if(tr.style.display!=='none')vis++;});el.textContent='命中 '+vis+' / 共 '+tot+' 条';}
if(!window.__custMenuDoc){window.__custMenuDoc=1;document.addEventListener('click',function(e){var t=e.target;if(t&&t.closest&&t.closest('.cust-more-wrap'))return;custMoreClose();custQuickClose();});window.addEventListener('resize',function(){custMoreClose();custQuickClose();});}
function custSelBarHtml(){
var _sn=custSelCount();
return '<div id="custSelBar" class="cust-selbar"'+(_sn?'':' style="display:none"')+'>'
+'<b id="custSelCnt" style="font-size:13px">已选 0 个客户</b>'
+'<button class="btn ghost sm" type="button" onclick="custSelClear()">清空选择</button>'
+'<div class="cust-more-wrap"><button class="btn ghost sm" type="button" onclick="custQuickToggle(event)">快捷选择 ▾</button><div class="cust-more-menu" id="custQuickMenu" hidden>'
+'<button type="button" class="cust-more-item" onclick="custQuickClose();custSelQuick(\'all\')">全选筛选结果</button>'
+'<button type="button" class="cust-more-item" onclick="custQuickClose();custSelQuick(\'noservice\')">选无对接员工</button>'
+'<button type="button" class="cust-more-item" onclick="custQuickClose();custSelQuick(\'noteacher\')">选无服务老师</button>'
+'</div></div>'
+'<span style="flex:1"></span>'
+'<button class="btn sm" type="button" id="custSelStaffBtn" onclick="openCustStaffAssign()" disabled style="opacity:.5">👤 批量指定对接员工</button>'
+'<button class="btn sm" type="button" id="custSelTeacherBtn" onclick="openCustTeacherAssign()" disabled style="opacity:.5">👩🏫 批量指定服务老师</button>'
+'<button class="btn sm cust-sel-fill" type="button" onclick="openCustBatch()">🛠 批量补信息</button>'
+'</div>';
}
function custSelBarUpdate(){
var n=custSelCount();
var _sb=document.getElementById('custSelBar');if(_sb)_sb.style.display=n?'flex':'none';
var shown=custSelShownIds();var vis=0;
Object.keys(custBatchSel).forEach(function(k){if(custBatchSel[k]&&shown.indexOf(k)>=0)vis++;});
var el=document.getElementById('custSelCnt');
if(el)el.textContent='已选 '+n+' 个客户 / 当前列表 '+shown.length+' 个'+(n>vis?('(其中 '+(n-vis)+' 个不在当前搜索结果内,也会一并应用)'):'');
['custSelStaffBtn','custSelTeacherBtn'].forEach(function(id){
var b=document.getElementById(id);if(!b)return;
b.disabled=!n;b.style.opacity=n?'1':'.5';
});
}
function custSelAllSync(){
var all=document.getElementById('custSelAll');if(!all)return;
var boxes=document.querySelectorAll('#custTable input.cust-sel');var tot=0,on=0;
boxes.forEach(function(b){if(!custSelRowVisible(custSelRowOf(b)))return;tot++;if(b.checked)on++;});
all.checked=(tot>0&&on===tot);all.indeterminate=(on>0&&on<tot);
}
function custSelAfterRender(){
document.querySelectorAll('#custTable input.cust-sel').forEach(function(b){b.checked=!!custBatchSel[b.value];});
custSelAllSync();custSelBarUpdate();
}
function custSelToggle(id,on){if(on)custBatchSel[id]=true;else delete custBatchSel[id];custSelAllSync();custSelBarUpdate();}
function custSelAllToggle(on){
document.querySelectorAll('#custTable input.cust-sel').forEach(function(b){
if(!custSelRowVisible(custSelRowOf(b)))return;
b.checked=!!on;if(on)custBatchSel[b.value]=true;else delete custBatchSel[b.value];
});
custSelAllSync();custSelBarUpdate();
}
function custSelClear(){
custBatchSel={};
document.querySelectorAll('#custTable input.cust-sel').forEach(function(b){b.checked=false;});
custSelAllSync();custSelBarUpdate();
}
// 快捷选择:作用于「当前筛选结果」(= 表格里现有的行);每次为「替换式」选择,便于预期
function custSelQuick(kind){
if(kind==='none'){custSelClear();return;}
custBatchSel={};
document.querySelectorAll('#custTable input.cust-sel').forEach(function(b){
if(!custSelRowVisible(custSelRowOf(b))){b.checked=false;return;}
var c=DB.customers.find(function(x){return x.id===b.value;});if(!c){b.checked=false;return;}
var hit=(kind==='all')?true:(kind==='noservice'?(!c.serviceStaffId):(!(Array.isArray(c.serviceTeacherIds)&&c.serviceTeacherIds.length)));
b.checked=hit;if(hit)custBatchSel[c.id]=true;
});
custSelAllSync();custSelBarUpdate();
toast('已选 '+custSelCount()+' 个客户');
}
function custSelPickIds(){return Object.keys(custBatchSel).filter(function(k){return custBatchSel[k];});}
function custSelPickSummary(ids){
var names=[];ids.forEach(function(id){var c=DB.customers.find(function(x){return x.id===id;});if(c)names.push(c.name||'未命名');});
var head=names.slice(0,12).join('、');
return '本次将作用于 '+ids.length+' 个客户:'+head+(names.length>12?(' 等共 '+names.length+' 个'):'');
}
// ---- 批量指定对接员工(单值 serviceStaffId)----
let _csaStaff='';
function custStaffModeSync(){
var m=(document.getElementById('csa_mode')||{}).value||'replace';
var w=document.getElementById('csa_staff_wrap');if(w)w.style.opacity=(m==='clear'?'.45':'1');
var h=document.getElementById('csa_hint');
if(h)h.textContent=(m==='clear'?'将把所选客户的「对接员工」清空。':'将把所选客户的「对接员工」替换为下方所选员工。');
}
function openCustStaffAssign(){
if(!custSelVisible()){toast('无权限');return;}
var ids=custSelPickIds();
if(!ids.length){toast('请先在列表页勾选客户');return;}
var html='<div class="modal-head"><h3>👤 批量指定对接员工</h3><span class="x" onclick="closeModal()">×</span></div>';
html+='<p class="mtxt" style="margin-bottom:10px">'+esc(custSelPickSummary(ids))+'</p>';
html+='<div class="field" style="margin-bottom:10px"><label>对接员工</label>'+smartSelectHTML({id:'csa_staff',items:DB.dealers.filter(function(d){return d.type==='staff';}),selectedId:'',displayKey:'name',emptyLabel:'请选择员工',placeholder:'搜索员工…'})+'</div>';
html+='<div class="field" style="margin-bottom:10px"><label>应用方式</label><select id="csa_mode" onchange="custStaffModeSync()"><option value="replace">替换为所选对接员工</option><option value="clear">清空对接员工</option></select><div id="csa_hint" class="mtxt" style="margin-top:6px"></div></div>';
html+='<div style="display:flex;gap:10px;justify-content:flex-end;margin-top:12px"><button class="btn ghost" onclick="closeModal()">取消</button><button class="btn" onclick="custStaffAssignApply()">应用到已选客户</button></div>';
openModal(html,{onOpen:function(){_csaStaff='';try{bindSmartSelect('csa_staff_wrap',function(v){_csaStaff=v||'';});}catch(e){}custStaffModeSync();}});
}
function custStaffAssignApply(){
if(!custSelVisible()){toast('无权限');return;}
var ids=custSelPickIds();if(!ids.length){toast('请先在列表页勾选客户');return;}
var mode=(document.getElementById('csa_mode')||{}).value||'replace';
var staff=_csaStaff||'';
if(mode!=='clear'&&!staff){toast('请选择对接员工,或把应用方式改为「清空对接员工」');return;}
if(!window.confirm('确定对已选 '+ids.length+' 个客户'+(mode==='clear'?'清空对接员工':'指定对接员工')+'吗?将立即保存。'))return;
var n=0;
ids.forEach(function(id){
var c=DB.customers.find(function(x){return x.id===id;});if(!c)return;
c.serviceStaffId=(mode==='clear'?'':staff);n++;
});
custBatchSel={};
save().then(function(ok){if(ok)toast('已批量更新 '+n+' 个客户的对接员工');});closeModal();renderCustomers();
}
// ---- 批量指定服务老师(多值 serviceTeacherIds,一个客户可指定多名)----
function custTeacherModeSync(){
var m=(document.getElementById('cta_mode')||{}).value||'replace';
var w=document.getElementById('cta_teacher_wrap');if(w)w.style.opacity=(m==='clear'?'.45':'1');
var h=document.getElementById('cta_hint');
if(h)h.textContent=(m==='clear'?'将把所选客户的「服务老师」全部清空。':(m==='append'?'在原有服务老师基础上追加下方所选老师。':'用下方所选老师替换原有服务老师。'));
}
function openCustTeacherAssign(){
if(!custSelVisible()){toast('无权限');return;}
var ids=custSelPickIds();
if(!ids.length){toast('请先在列表页勾选客户');return;}
var html='<div class="modal-head"><h3>👩🏫 批量指定服务老师</h3><span class="x" onclick="closeModal()">×</span></div>';
html+='<p class="mtxt" style="margin-bottom:10px">'+esc(custSelPickSummary(ids))+'</p>';
html+='<div class="row2"><div class="field"><label>服务老师(可多选,一个客户可指定多名)</label>'+svcTeacherMultiHTML({id:'cta_teacher',items:svcTeacherCandidates(),selected:[]})+'</div>';
html+='<div class="field"><label>应用方式</label><select id="cta_mode" onchange="custTeacherModeSync()"><option value="replace">替换为所选(清掉原有)</option><option value="append">追加到已有(保留原有)</option><option value="clear">清空服务老师</option></select><div id="cta_hint" class="mtxt" style="margin-top:6px"></div></div></div>';
html+='<div style="display:flex;gap:10px;justify-content:flex-end;margin-top:12px"><button class="btn ghost" onclick="closeModal()">取消</button><button class="btn" onclick="custTeacherAssignApply()">应用到已选客户</button></div>';
openModal(html,{onOpen:function(){try{bindSvcTeacherMulti('cta_teacher_wrap');}catch(e){}custTeacherModeSync();}});
}
function custTeacherAssignApply(){
if(!custSelVisible()){toast('无权限');return;}
var ids=custSelPickIds();if(!ids.length){toast('请先在列表页勾选客户');return;}
var mode=(document.getElementById('cta_mode')||{}).value||'replace';
var sel=[];try{sel=svcTeacherSelValues('cta_teacher');}catch(e){}
if(mode!=='clear'&&!sel.length){toast('请选择服务老师,或把应用方式改为「清空服务老师」');return;}
if(!window.confirm('确定对已选 '+ids.length+' 个客户'+(mode==='clear'?'清空服务老师':(mode==='append'?('追加 '+sel.length+' 位服务老师'):('指定 '+sel.length+' 位服务老师')))+'吗?将立即保存。'))return;
var n=0;
ids.forEach(function(id){
var c=DB.customers.find(function(x){return x.id===id;});if(!c)return;
var cur=Array.isArray(c.serviceTeacherIds)?c.serviceTeacherIds.slice():[];
if(mode==='clear')c.serviceTeacherIds=[];
else if(mode==='append'){sel.forEach(function(v){if(cur.indexOf(v)<0)cur.push(v);});c.serviceTeacherIds=cur;}
else c.serviceTeacherIds=sel.slice();
n++;
});
custBatchSel={};
save().then(function(ok){if(ok)toast('已批量更新 '+n+' 个客户的服务老师');});closeModal();renderCustomers();
}
// ---- 批量补信息(只改「下次催款日期 / 金额」;对接员工与服务老师走上面两个专用按钮)----
function openCustBatch(){
if(!custSelVisible()){toast('无权限');return;}
var ids=custSelPickIds();
if(!ids.length){toast('请先在列表页勾选客户,再点「批量补信息」');return;}
var html='<div class="modal-head"><h3>🛠 客户批量补信息</h3><span class="x" onclick="closeModal()">×</span></div>';
html+='<p class="mtxt" style="margin-bottom:10px">'+esc(custSelPickSummary(ids))+'<br>只修改下方填写的字段,其他字段一律不动;点击「应用」后立即保存。批量指定对接员工 / 服务老师请用列表页上方的对应按钮。<br>提示:已绑定 VR 服务交付的客户,下次催款日期由系统按「VR 服务到期时间」自动同步,手动设置的日期会被自动覆盖(属正常规则)。</p>';
html+='<div class="row2" style="margin-top:6px"><div class="field"><label>批量设置 · 下次催款日期</label><input type="date" id="cb_nextpay"><div style="margin-top:6px"><label style="font-size:12.5px;color:var(--muted)"><input type="checkbox" id="cb_nextpay_clr"> 清空催款日期</label></div></div>';
if(fLevel('amount')==='full')html+='<div class="field"><label>批量补 · 金额(仅对「金额为空或0」的选中客户生效)</label><input type="number" min="0" id="cb_amount" placeholder="留空 = 不修改金额"></div>';
html+='</div>';
html+='<div style="display:flex;gap:10px;justify-content:flex-end;margin-top:12px"><button class="btn ghost" onclick="closeModal()">取消</button><button class="btn" onclick="custBatchApplySelected()">应用到已选客户</button></div>';
openModal(html,{onOpen:function(){}});
}
function custBatchApplySelected(){
if(!custSelVisible()){toast('无权限');return;}
var ids=custSelPickIds();if(!ids.length){toast('请先在列表页勾选客户');return;}
var np='';var npClr=false;var amt='';
try{np=document.getElementById('cb_nextpay').value||'';}catch(e){}
try{var nc=document.getElementById('cb_nextpay_clr');npClr=!!(nc&&nc.checked);}catch(e){}
try{var ae2=document.getElementById('cb_amount');amt=ae2?ae2.value:'';}catch(e){}
if(!np&&!npClr&&amt===''){toast('请至少填写一个要补的字段');return;}
if(!window.confirm('确定对已选 '+ids.length+' 个客户批量应用吗?将立即保存数据,可在客户详情中逐个核对。'))return;
var n=0;
ids.forEach(function(id){
var c=DB.customers.find(function(x){return x.id===id;});if(!c)return;
if(npClr)c.nextPayAt='';else if(np)c.nextPayAt=np;
if(amt!==''&&!(Number(c.amount)>0))c.amount=Number(amt);
n++;
});
custBatchSel={};
save().then(function(ok){if(ok)toast('已批量更新 '+n+' 个客户');});closeModal();renderCustomers();
}
let _finTipEl=null;let _finTipT=null;
function finCustTipHide(){clearTimeout(_finTipT);if(_finTipEl)_finTipEl.style.display='none';}
function _finTipLeave(){clearTimeout(_finTipT);_finTipT=setTimeout(finCustTipHide,350);}
function finCustTip(ev,el,cid){try{
clearTimeout(_finTipT);
var c=DB.customers.find(function(x){return x.id===cid;});if(!c)return;
if(!_finTipEl){_finTipEl=document.createElement('div');_finTipEl.id='finCustTipBox';document.body.appendChild(_finTipEl);}
// 弹卡可交互:鼠标进入弹卡取消关闭定时器,移出弹卡 250ms 后关闭(否则交款记录多时无法移入弹卡滚动)
_finTipEl.onmouseenter=function(){clearTimeout(_finTipT);};
_finTipEl.onmouseleave=function(){clearTimeout(_finTipT);_finTipT=setTimeout(finCustTipHide,250);};
var pays=(c.payments||[]).slice().map(function(p){return{date:p.date,amount:Number(p.amount||0),method:p.method||'',note:p.note||'',rf:false};}).concat((c.refunds||[]).slice().map(function(r){return{date:r.date,amount:Number(r.amount||0),method:'退费',note:r.note||'',rf:true};})).sort(function(a,b){return (b.date||'').localeCompare(a.date||'');});
var paid=paidOf(c),owed=owedOf(c),amt=Number(c.amount||0);
var h='<div style="font-weight:700;font-size:13px;margin-bottom:6px">'+esc(c.name||'未命名')+(c.school?' · '+esc(c.school):'')+'</div>';
h+='<div style="display:flex;gap:14px;font-size:12px;margin-bottom:8px;white-space:nowrap"><span>合同额 <b>'+money(amt)+'</b></span><span>已交 <b style="color:var(--ok)">'+money(paid)+'</b></span><span>欠款 <b style="color:'+(owed>0?'var(--danger)':'var(--muted)')+'">'+money(owed)+'</b></span></div>';
h+='<div style="max-height:220px;overflow:auto;overscroll-behavior:contain">';
if(!pays.length)h+='<div class="mtxt">暂无收退款记录</div>';
else{h+='<table style="width:100%;border-collapse:collapse;font-size:12px"><thead><tr>'+['日期','金额','方式','备注'].map(function(x){return '<th style="text-align:left;padding:3px 6px;border-bottom:1px solid var(--line);white-space:nowrap">'+x+'</th>';}).join('')+'</tr></thead><tbody>';
pays.forEach(function(p){h+='<tr><td style="padding:3px 6px;white-space:nowrap">'+esc(p.date||'—')+'</td><td style="padding:3px 6px;text-align:right;white-space:nowrap'+(p.rf?';color:var(--danger);font-weight:700':'')+'">'+(p.rf?'-'+money(Number(p.amount||0)):money(Number(p.amount||0)))+'</td><td style="padding:3px 6px;white-space:nowrap">'+(p.rf?'<span style="color:var(--danger)">退费</span>':esc(p.method||'—'))+'</td><td style="padding:3px 6px" class="mtxt">'+esc(p.note||'')+'</td></tr>';});
h+='</tbody></table>';}
h+='</div>';
_finTipEl.innerHTML=h;
_finTipEl.style.cssText='position:fixed;z-index:9999;background:#fff;border:1px solid var(--line);border-radius:12px;box-shadow:0 10px 30px rgba(15,23,42,.14);padding:12px 14px;width:340px;display:block;font-size:12.5px';
var r=el.getBoundingClientRect(),w=340,m=8;
var top=r.bottom+m,left=r.left;
if(top+160>window.innerHeight)top=Math.max(8,r.top-_finTipEl.offsetHeight-m);
if(left+w>window.innerWidth-8)left=Math.max(8,window.innerWidth-w-8);
_finTipEl.style.top=top+'px';_finTipEl.style.left=left+'px';
}catch(e){}}
/* ===== 交付矩阵 / 交付明细 导出(全字段 CSV) 2026-09-12 ===== */
function delivCanExport(){return mode==='dealer'?dealerCanExport():can('export_delivery');}
function delivExportAllowed(){if(!delivCanExport()){toast(mode==='dealer'?'无权限:经销商数据导出权限已关闭':'无权限:导出总部交付服务需「导出总部交付服务(矩阵 / 明细)」权限');return false;}return true;}
function delivDataList(scope){var isCust=delivSub==='customer';var listServices=(fLevel('dv_service')==='hidden')?[]:SERVICES.slice();var ownerList=isCust?DB.customers.slice():DB.dealers.filter(function(d){return d.type!=='staff';});ownerList=ownerList.filter(function(o){return isPushed(isCust?'customer':'dealer',o);});if(scope!=='all'){if(delivFilter.cat){listServices=listServices.filter(function(s){return s.cat===delivFilter.cat;});}if(delivFilter.qService){var sq=String(delivFilter.qService).toLowerCase();listServices=listServices.filter(function(s){return (s.name+' '+s.spec).toLowerCase().indexOf(sq)>=0;});}if(delivFilter.dealer){ownerList=isCust?ownerList.filter(function(o){return o.dealerId===delivFilter.dealer;}):ownerList.filter(function(o){return o.id===delivFilter.dealer;});}if(isCust){var cq=String(delivFilter.qCustomer||'').trim().toLowerCase();if(cq){var cex=ownerList.filter(function(c){return c.name&&c.name.trim().toLowerCase()===cq;});ownerList=cex.length?cex:ownerList.filter(function(c){return (c.name+' '+(c.school||'')).toLowerCase().indexOf(cq)>=0;});}}else{var dq=String(delivFilter.qDealer||'').trim().toLowerCase();if(dq){var dex=ownerList.filter(function(d){return d.name&&d.name.trim().toLowerCase()===dq;});ownerList=dex.length?dex:ownerList.filter(function(d){return d.name.toLowerCase().indexOf(dq)>=0;});}}if(isCust&&delivFilter.intent){ownerList=ownerList.filter(function(c){return intentArr(c).indexOf(delivFilter.intent)>=0;});}if(isCust&&delivFilter.coop&&delivFilter.coop.length){ownerList=ownerList.filter(function(c){return delivCoopMatch(c,delivFilter.coop);});}if(delivFilter.status){ownerList=ownerList.filter(function(o){return listServices.some(function(s){var dv=isCust?findCustomerDelivery(o.id,s.id):findDelivery(o.id,s.id);var st=dv?dv.status:'pending';return delivFilter.status==='pending'?(st==='pending'||st==='partial'):st===delivFilter.status;});});}}return {isCust:isCust,listServices:listServices,ownerList:delivSortOwners(ownerList,isCust)};}
function delivSortOwners(list,isCust){var MAIN=['vr_meta','ar_dual'];var COURSE=['vr_meta','ar_dual','online_res','online_aff','ai_review'];var dec=(list||[]).map(function(o,ix){var mu=0,cd=0,st='',i=0;for(i=0;i<MAIN.length;i++){st=delivStatusKey(isCust?findCustomerDelivery(o.id,MAIN[i]):findDelivery(o.id,MAIN[i]));if(st!=='done'&&st!=='n_a')mu++;}for(i=0;i<COURSE.length;i++){st=delivStatusKey(isCust?findCustomerDelivery(o.id,COURSE[i]):findDelivery(o.id,COURSE[i]));if(st==='done')cd++;}return{o:o,ix:ix,mu:mu,cd:cd,nm:String((o&&(o.school||o.name))||'')};});dec.sort(function(a,b){if(b.mu!==a.mu)return b.mu-a.mu;if(a.cd!==b.cd)return a.cd-b.cd;var c=a.nm.localeCompare(b.nm,'zh');return c!==0?c:(a.ix-b.ix);});return dec.map(function(x){return x.o;});}
function delivStatusLabel(dv){var st=dv?dv.status:'pending';return (DELIVERY_STATUS[st]||DELIVERY_STATUS.pending).label;}
function delivMetaText(dv,sid){if(!dv)return '';var mt=serviceMetaType(sid);var p=[];if(dv.operator)p.push(dv.operator);if(dv.date)p.push(dv.date);if(dv.qty)p.push('数量'+dv.qty);if(mt==='vr'&&dv.vr&&dv.vr.installs&&dv.vr.installs.length)p.push(dv.vr.installs.length+'次开通'+(dv.vr.devices?('·'+dv.vr.devices+'台'):''));if(mt==='online'&&dv.online&&dv.online.length)p.push(dv.online.length+'次开通');if(mt==='train'&&dv.trains&&dv.trains.length)p.push(dv.trains.length+'次师训');if(mt==='help'&&dv.helps&&dv.helps.length)p.push(dv.helps.length+'次帮扶');if(mt==='material'&&dv.materials&&dv.materials.length)p.push(dv.materials.length+'次领取');if(dv.expireAt)p.push('到期'+dv.expireAt);if(dv.notes)p.push('备注:'+dv.notes);return p.join(' ');}
function delivSubText(dv,mt){if(!dv)return '';var out=[];if(mt==='vr'&&dv.vr&&dv.vr.installs){dv.vr.installs.forEach(function(it,i){out.push('场景'+(i+1)+':'+(it.scene||'')+'@'+(it.installDate||'')+(it.note?('/备注:'+it.note):''));});}if(mt==='online'&&dv.online){dv.online.forEach(function(it,i){out.push('开通'+(i+1)+':'+(it.openDate||'')+(it.note?('/备注:'+it.note):''));});}if(mt==='train'&&dv.trains){dv.trains.forEach(function(it,i){out.push('师训'+(i+1)+':'+(it.trainDate||'')+'/'+(it.teacher||'')+(it.note?('/备注:'+it.note):''));});}if(mt==='help'&&dv.helps){dv.helps.forEach(function(it,i){out.push('帮扶'+(i+1)+':'+(it.start||'')+'~'+(it.end||'')+(it.attachments&&it.attachments.length?('(附件'+it.attachments.length+')'):'')+(it.note?('/备注:'+it.note):''));});}if(mt==='material'&&dv.materials){dv.materials.forEach(function(it,i){out.push('领取'+(i+1)+':'+(it.date||'')+'/'+(it.name||'')+(it.qty?('/'+it.qty):'')+(it.attachments&&it.attachments.length?('(附件'+it.attachments.length+')'):'')+(it.note?('/备注:'+it.note):''));});}return out.join(' ; ');}
function delivOwnerName(isCust,o){if(isCust){return o.name?fTxt('c_name',o.name):'';}return o.name?fTxt('d_name',o.name):'';}
function delivOwnerTypeText(isCust,o){return isCust?(dealerName(o.dealerId)||''):((DT(o.type)||{}).name||'');}
function delivOwnerStageText(isCust,o){if(isCust){return (STAGES[o.stage]||{}).label||o.stage||'';}var ds=dealerStage(o);return (ds&&ds.label&&ds.label!=='—')?ds.label:'';}
function csvCell(v){var s=String(v==null?'':v);if(s.indexOf('"')>=0||s.indexOf(',')>=0||s.indexOf('\n')>=0||s.indexOf('\r')>=0){return '"'+s.split('"').join('""')+'"';}return s;}
function csvRows(rows){return rows.map(function(r){return r.map(csvCell).join(',');}).join('\r\n');}
function commCsvDeliv(i){var c=(i&&i.cid)?commCustOf(i.cid):null;var o=c?commDelivOf(c):null;if(!o)return '';if(typeof fLevel==='function'&&fLevel('dv_status')!=='full')return '***';return o.done+'/'+o.total+' 项';}
function commCsvDelivDetail(i){var c=(i&&i.cid)?commCustOf(i.cid):null;if(!c)return '';if(typeof fLevel==='function'&&fLevel('dv_service')==='hidden')return '';var o=commDelivOf(c);if(!o)return '';if(typeof fLevel==='function'&&fLevel('dv_status')!=='full')return '***';return o.rows.map(function(r){var lb=r.status==='done'?'已交付':(r.status==='n_a'?'不适用':(r.status==='partial'?'部分交付':'待交付'));var dt=((r.status==='done'||r.status==='partial')&&r.date)?(' '+r.date):'';return r.name+' '+lb+dt;}).join(';');}
function commCsvHead(){return ['经销商','对接员工','类型','客户','课程交付(核心)','课程交付明细','来源','已交款','全款时间','分润比例','分润金额','已发分润','未发分润','发放时间','人工调整','说明'];}
function commCsvBlank(){return ['','','','','','','','','','','','','','','',''];}
/* ★ 第 605 轮(主人 2026-10-23 拍板「D」+「筛选也所见即所排」):导出明细按「该经销商在本次导出块内的客户家数」降序。
家数口径 = 块内明细行的客户去重(cid 去重,排除结转行 carry);不是经销商名下全部客户,也不是档案条数。
并列时保持原顺序(= 原「应发分润降序」)⇒ 每次导出顺序稳定可复现。带筛选导出 ⇒ 按筛选后的明细算家数。 */
function commHomesOf(items){var _s={};(items||[]).forEach(function(i){if(i&&i.cid&&!i.carry)_s[i.cid]=1;});return Object.keys(_s).length;}
function commSortByHomes(gs){var _a=(gs||[]).map(function(g,ix){var _n=0;try{commAlloc(g);_n=commHomesOf(commVisible(g));}catch(e){_n=0;}return{g:g,i:ix,n:_n};});_a.sort(function(a,b){return (b.n-a.n)||(a.i-b.i);});return _a.map(function(x){return x.g;});}
function commCsvRows(dealerName,cfgName,items,fa,fk,staffOf){const out=[];if(!items||!items.length){const e=commCsvBlank();e[0]=dealerName;e[2]=cfgName;e[15]='暂无分润来源';out.push(e);return out;}
items.forEach(i=>{out.push([dealerName,(staffOf?staffOf(i):''),cfgName,i.name,commCsvDeliv(i),commCsvDelivDetail(i),i.src,fa?i.paid:'***',i.fullDate||'',fk?(i.rate>0?(i.rate*100)+'%':'—'):'***',fk?i.comm:'***',fk?(i.paidOut||0):'***',fk?(i.unpaid||0):'***',i.paidAt?i.paidAt+(i.unpaid>0?'(部分)':''):'',fk?(i.adj?((i.adj>0?'+':'')+i.adj+(i.adjNote?' · '+i.adjNote:'')):''):'',perfOwnerJoin(i.note,i.ownerNote)]);});
const sm=f=>items.reduce((s,i)=>s+(f(i)||0),0);out.push([dealerName+' 小计','',cfgName,'共 '+commHomesOf(items)+' 家客户','','','',fa?sm(i=>i.paid):'***','','',fk?sm(i=>i.comm):'***',fk?sm(i=>i.paidOut):'***',fk?sm(i=>i.unpaid):'***','',fk?sm(i=>i.adj):'***','']);return out;}
function rowsToCsv(head,rows){return csvRows([head].concat((rows||[]).map(r=>{const a=Array.isArray(r)?r.slice(0,head.length):[];while(a.length < head.length)a.push('');return a;})));}
function rowsToCsvCheck(head,rows){const hc=head.length;let bad=0;const hist={};(rows||[]).forEach(r=>{const n=Array.isArray(r)?r.length:-1;if(n!==hc){bad++;hist[n]=(hist[n]||0)+1;}});return {head:hc,rows:(rows||[]).length,bad:bad,hist:hist};}
function delivDownloadCsv(name,rows){try{var csv='\uFEFF'+csvRows(rows);var blob=new Blob([csv],{type:'text/csv;charset=utf-8'});var a=document.createElement('a');a.href=URL.createObjectURL(blob);a.download=name;document.body.appendChild(a);a.click();setTimeout(function(){try{URL.revokeObjectURL(a.href);a.remove();}catch(e){}},1500);toast('已导出:'+name);return true;}catch(e){toast('导出失败:'+((e&&e.message)||e));return false;}}
function buildDelivMatrixCsv(scope){var d=delivDataList(scope);var isCust=d.isCust,listServices=d.listServices,ownerList=d.ownerList;var head=['序号',isCust?'客户':'经销商',isCust?'所属经销商':'经销商类型'];if(isCust){head=head.concat(['学校/校区','联系电话']);}else{head=head.concat(['联系电话']);}head=head.concat([isCust?'客户状态':'经销商状态','推送状态']);listServices.forEach(function(s){head.push(s.name);});head=head.concat(['已完成/服务项总数','部分交付','未交付项数','完成率','已交付价值','总价值']);var rows=[head];ownerList.forEach(function(o,idx){var stt=ownerDelivery(isCust?'customer':'dealer',o.id);var r=[idx+1,delivOwnerName(isCust,o),delivOwnerTypeText(isCust,o)];if(isCust){r.push(o.school?fTxt('c_school',o.school):'');}r.push(o.phone||'');r.push(delivOwnerStageText(isCust,o),isPushed(isCust?'customer':'dealer',o)?'已推送':'—');listServices.forEach(function(s){var dv=isCust?findCustomerDelivery(o.id,s.id):findDelivery(o.id,s.id);var lab=delivStatusLabel(dv);var ex=delivMetaText(dv,s.id);r.push(ex?(lab+'('+ex+')'):lab);});r.push(stt.done+'/'+stt.total+' 项',stt.partial,(stt.total-stt.done-stt.partial),Math.round(stt.completion)+'%',stt.deliveredValue,stt.totalValue);rows.push(r);});return rows;}
/* ★ 2026-10-05 第 321 轮(主人:「导出明细,交付日期:展示第一次开通时间」):
「首次开通时间」= 该服务项**最早一次开通**的日期 —— VR 取 installs[].installDate 最早一笔、
线上课取 online[].openDate 最早一笔;**无开通记录 ⇒ 保持交付记录日期 dv.date;无交付记录 ⇒ 空**。
(刻意**不**复用 openDateOf:它会回落客户签约日/报备日 ⇒ 会让 1086 行未交付的服务项凭空显示日期。) */
function firstOpenDateOf(dv){if(!dv)return '';var mt=serviceMetaType(dv.serviceId);if(mt==='vr'&&dv.vr&&Array.isArray(dv.vr.installs)&&dv.vr.installs.length){var _a=dv.vr.installs.map(function(i){return i&&i.installDate;}).filter(Boolean).sort();if(_a.length)return _a[0];}if(mt==='online'&&Array.isArray(dv.online)&&dv.online.length){var _b=dv.online.map(function(x){return x&&x.openDate;}).filter(Boolean).sort();if(_b.length)return _b[0];}if(mt==='material'&&Array.isArray(dv.materials)&&dv.materials.length){var _m=dv.materials.map(function(x){return x&&x.date;}).filter(Boolean).sort();if(_m.length)return _m[0];}return dv.date||'';}
function buildDelivDetailCsv(scope){var d=delivDataList(scope);var isCust=d.isCust,listServices=d.listServices,ownerList=d.ownerList;var head=['序号','交付对象类型','客户/经销商','所属经销商/类型','学校/校区',(isCust?'客户状态':'经销商状态'),'服务类别','服务项目','规格','交付状态','交付方式','操作人','首次开通时间','数量','到期时间','备注','子记录明细','市场价值','已交付价值'];var rows=[head];var n=0;ownerList.forEach(function(o){listServices.forEach(function(s){n++;var dv=isCust?findCustomerDelivery(o.id,s.id):findDelivery(o.id,s.id);var st=dv?dv.status:'pending';var mt=serviceMetaType(s.id);var factor=(st==='done'?1:(st==='partial'?0.5:0));rows.push([n,isCust?'客户':'经销商',delivOwnerName(isCust,o),delivOwnerTypeText(isCust,o),isCust?(o.school?fTxt('c_school',o.school):''):'',delivOwnerStageText(isCust,o),((SERVICE_CATEGORIES[s.cat]||{}).name||s.cat),s.name,s.spec||'',(DELIVERY_STATUS[st]||DELIVERY_STATUS.pending).label,(dv&&dv.operator)||'',dvRecorderText(dv),(dv?firstOpenDateOf(dv):''),(dv&&dv.qty)||'',(dv&&dv.expireAt)||'',(dv&&dv.notes)||'',delivSubText(dv,mt),s.value||0,Math.round((s.value||0)*factor)]);});});return rows;}
function openDelivExport(kind){if(!delivExportAllowed())return;var isCust=delivSub==='customer';var f=delivDataList('filter');var a=delivDataList('all');var unit=(kind==='matrix')?'行':'条';var fRows=(kind==='matrix')?f.ownerList.length:f.ownerList.length*f.listServices.length;var aRows=(kind==='matrix')?a.ownerList.length:a.ownerList.length*a.listServices.length;var kn=(kind==='matrix')?'交付矩阵(宽表)':'交付明细(全字段)';var hintSvc=f.listServices.length?'':'<div class="warn-box" style="margin-top:8px">当前角色未开放「服务项目」字段,导出的服务项列为空。</div>';hintSvc=hintSvc+exportDropHint(kind==='matrix'?'交付矩阵':'交付明细');var h='';h+='<div class="modal-head"><h3>📥 导出'+kn+'</h3><span class="x" onclick="closeModal()">×</span></div>';h+='<div class="modal-body">';h+='<div style="margin-bottom:14px"><div style="font-size:12.5px;font-weight:600;color:#334155;margin-bottom:6px;letter-spacing:.2px">导出范围</div>';h+='<div style="border:1px solid var(--line);border-radius:9px;padding:10px 12px;margin-bottom:8px"><label style="display:flex;align-items:center;gap:8px;cursor:pointer"><input type="radio" name="dx_scope" id="dx_scope_filter" value="filter" checked style="flex:none;width:auto;margin:0"> <span>当前筛选结果 <b>'+fRows+'</b> '+unit+'</span></label><div class="mtxt" style="margin-left:24px;margin-top:2px">共 '+f.ownerList.length+' 个'+(isCust?'客户':'经销商')+' × '+f.listServices.length+' 个服务项</div></div>';h+='<div style="border:1px solid var(--line);border-radius:9px;padding:10px 12px"><label style="display:flex;align-items:center;gap:8px;cursor:pointer"><input type="radio" name="dx_scope" id="dx_scope_all" value="all" style="flex:none;width:auto;margin:0"> <span>全部(忽略筛选) <b>'+aRows+'</b> '+unit+'</span></label><div class="mtxt" style="margin-left:24px;margin-top:2px">共 '+a.ownerList.length+' 个'+(isCust?'客户':'经销商')+' × '+a.listServices.length+' 个服务项</div></div>';h+='</div>';h+='<div class="mtxt" style="margin-top:4px">导出为 CSV(UTF-8 带 BOM,可直接用 Excel 打开)。'+((kind==='matrix')?'每行一个交付对象,每个服务项目一列,单元格含交付状态与方式/日期/次数/到期等全字段;末尾附完成度与价值汇总。':'一行 = 一个交付对象 × 一个服务项目,状态、交付方式、操作人(录入人)、首次开通时间、数量、到期时间、备注、子记录明细等字段全展开。')+'</div>';h+=hintSvc;h+='</div>';h+='<div class="modal-foot"><button class="btn ghost" onclick="closeModal()">取消</button><button class="btn" onclick="execDelivExport(\''+kind+'\')">📥 导出 CSV</button></div>';openModal(h);}
function execDelivExport(kind){var selF=document.getElementById('dx_scope_filter');var scope=(selF&&selF.checked)?'filter':'all';var isCust=delivSub==='customer';var rows=exportDropRows((kind==='matrix')?buildDelivMatrixCsv(scope):buildDelivDetailCsv(scope),kind==='matrix'?'交付矩阵':'交付明细');if(!rows||rows.length<=1){toast('当前范围无可导出的数据');return;}var d=new Date();var p2=function(n){return String(n).padStart(2,'0');};var ts=''+d.getFullYear()+p2(d.getMonth()+1)+p2(d.getDate())+p2(d.getHours())+p2(d.getMinutes());var nm='交付'+((kind==='matrix')?'矩阵':'明细')+'_'+(isCust?'客户':'经销商')+'_'+((scope==='all')?'全部':'筛选')+'_'+ts+'.csv';closeModal();delivDownloadCsv(nm,rows);}
/* ===== 分期·已签合同·交款≥2万 → 交付推送悬浮提醒(2026-09-14)=====
规则:stage='signed_installment'(分期未交清) ∧ 合同已签 ∧ 累计交款(账面,payments 合计)≥ 2 万
∧ 未退费/终止 ∧ 尚未推送交付(pushedDelivery 标志与交付记录皆无)
行为:只提醒、不自动推送 —— 与「全款 + 已签合同 → 自动推送」互补;点「推送交付」走既有
pushDelivery 校验链(退费/终止拒绝、未签合同提示),保存成功后 render() 收敛本浮窗
可见性:与客户列表「推送交付」按钮同口径(can('edit_delivery')),无该权限不显示浮窗;
金额列遵循 fLevel('amount'),无权限则隐去数额
关闭:仅本次会话(内存标记 _dvClosed)—— 刷新 / 重新登录后如仍满足条件会再次提醒 */
var DELIV_NOTIFY_MIN = 20000;
var _dvClosed = false;
function delivNotifyList() {
try {
if (!currentUser) return [];
if (!DB || !Array.isArray(DB.customers)) return [];
if (typeof can === 'function' && !can('edit_delivery')) return [];
return DB.customers.filter(function (c) {
if (!c || !c.id) return false;
if (c.stage !== 'signed_installment') return false;
if (!c.contractSigned) return false;
if (c.refunded || c.stage === 'refunded' || c.stage === 'terminated') return false;
if (isPushed('customer', c)) return false;
return paidOf(c) >= DELIV_NOTIFY_MIN;
});
} catch (e) { return []; }
}
function delivNotifyRowHTML(c) {
var showAmt = (typeof fLevel === 'function') && fLevel('amount') === 'full';
var sub = (c.school ? c.school : '未填校区') + (showAmt ? ' · 已交款 ' + money(paidOf(c)) + ' / 合同 ' + money(c.amount) : '');
return '<div class="dvf-row">'
+ '<div class="dvf-nm"><b>' + esc(c.name || '') + '</b><span>' + esc(sub) + '</span></div>'
+ '<button class="btn sm" data-dvid="' + esc(String(c.id)) + '" onclick="delivNotifyPush(this.getAttribute(\'data-dvid\'))">推送交付</button>'
+ '</div>';
}
function delivNotifySync() {
var el = document.getElementById('dvFloat');
if (!el) return;
var list = _dvClosed ? [] : delivNotifyList();
if (!list.length) { el.classList.add('hide'); el.innerHTML = ''; return; }
var showAmt = (typeof fLevel === 'function') && fLevel('amount') === 'full';
var head = '<div class="dvf-head"><b>交付推送提醒</b><span class="dvf-badge">' + list.length + '</span><span class="dvf-sp"></span>'
+ '<button class="dvf-x" onclick="delivNotifyClose()" title="关闭本提醒(刷新或重新登录后如仍未处理,会再次提醒)">✕</button></div>';
var sub = '<div class="dvf-sub">已签合同 · 分期未交清 · 累计交款' + (showAmt ? ' ≥ ' + money(DELIV_NOTIFY_MIN) : '已达标准') + ',可推送交付服务</div>';
el.innerHTML = head + sub + '<div class="dvf-list">' + list.map(delivNotifyRowHTML).join('') + '</div>';
el.classList.remove('hide');
}
function delivNotifyClose() { _dvClosed = true; delivNotifySync(); }
function delivNotifyPush(id) { if (!id) return; pushDelivery('customer', id); }
(function () {
var _prev = render;
render = function () {
var r = _prev.apply(this, arguments);
try { delivNotifySync(); } catch (e) {}
return r;
};
})();
/* ===== 项目目录(2 级项目维护:修改 / 添加 / 隐藏 / 有效期 / 分润建议)· 2026-10-14 第 445 轮 =====
数据:DB.items[](服务端种子幂等补齐:12 课包项 + 可添加项);DB.packages(课包登记,本页只读引用)。
纪律:本页只编辑 DB.items;保存走现有 save()(只发变化);隐藏=停售(hidden 布尔),历史订单不受影响。
本分片为 data-frag 实体区:代码中的小于号 / 大于号 / 和号一律写为 HTML 实体。 */
var ITEM_CATS=[['course','课程'],['product','产品'],['service','服务']];
function itemCatName(c){var m=null;ITEM_CATS.forEach(function(x){if(x[0]===c)m=x[1];});return m||c||'—';}
function itemById(id){return (DB.items||[]).find(function(x){return x&&x.id===id;});}
function itemCommText(it){
if(!it)return '—';
if(it.commMode==='fixed')return '固定 ¥'+(Number(it.commValue)||0)+'/单';
return '按现有规则'+(it.commValue?'(比例 '+Math.round((Number(it.commValue)||0)*100)+'%)':'');
}
/* 第 466 轮:项目级「业绩归属员工」标准(比例,0 = 未设置)。建单时自动带出到本单。 */
function itemCommTextStaff(it){
if(!it)return '按现有规则';
var v=Number(it.staffCommValue)||0;
if(!(v>0))return '按现有规则';
if(it.staffCommMode==='fixed')return '固定 ¥'+v+'/单';
return '比例 '+Math.round(v*1000)/10+'%';
}
var ITEM_TD='padding:9px 8px;border-bottom:1px solid var(--line)';
function renderItems(){
if(!can('view_items')){curView='dashboard';render();return;}
var canEditIt=can('manage_items');
var items=(DB.items||[]).slice();
var pkg=(DB.packages||[]).find(function(x){return x&&x.id==='pkg_annual';});
var pkgIds=(pkg&&pkg.itemIds)||[];
var g1=items.filter(function(x){return pkgIds.indexOf(x.id)>=0;});
var g2=items.filter(function(x){return pkgIds.indexOf(x.id)<0;});
function row(it){
var hid=it.hidden===true;
return '<tr'+(hid?' style="opacity:.55"':'')+'>'
+'<td style="'+ITEM_TD+'"><b>'+esc(it.name||'')+'</b>'
+(hid?' <span style="font-size:11px;color:#94a3b8;border:1px solid var(--line);border-radius:8px;padding:1px 6px;margin-left:4px">已隐藏 · 不卖</span>':'')
+'</td>'
+'<td style="'+ITEM_TD+'">'+esc(itemCatName(it.cat))+'</td>'
+'<td style="'+ITEM_TD+'">'+(Number(it.unitPrice)>0?'¥'+it.unitPrice+(it.unitName?' /'+esc(it.unitName):''):'<span style="color:#94a3b8">—</span>')+'</td>'
+'<td style="'+ITEM_TD+'">'+esc(itemCommText(it))+'</td>'
+'<td style="'+ITEM_TD+'">'+esc(itemCommTextStaff(it))+'</td>'
+'<td style="'+ITEM_TD+'">'+(it.countDeal===true?'✓':'—')+'</td>'
+'<td style="'+ITEM_TD+'">'+(it.trackQuota===true?('✓ '+esc(it.quotaUnit||'人')):'—')+'</td>'
+'<td style="'+ITEM_TD+'">'+((Number(it.validMonths)||0)>0?(it.validMonths+' 个月'):'<span style="color:#d97706">未设置有效期</span>')+'</td>'
+'<td style="'+ITEM_TD+';white-space:nowrap">'
+(canEditIt?('<button class="btn ghost sm" onclick="itemsEditOpen(\''+it.id+'\')">编辑</button> '
+'<button class="btn ghost sm" onclick="itemsToggleHidden(\''+it.id+'\')">'+(hid?'恢复售卖':'隐藏停售')+'</button>'
+'<button class="btn ghost sm" onclick="itemsTogglePkg(\''+it.id+'\')">'+(pkgIds.indexOf(it.id)>=0?'移出课包':'移入课包')+'</button>'):'<span style="color:#94a3b8">—</span>')
+'</td></tr>';
}
function group(title,tip,list){
if(!list.length)return '';
return '<div class="section-title">'+title+' <span style="font-weight:400;font-size:12px;color:var(--muted)">'+tip+'</span></div>'
+'<div class="card table-card" style="padding:0"><table id="itemsTbl" style="font-size:13px;width:100%">'
+'<thead><tr style="background:#f8fafc">'
+'<th style="padding:10px 8px;text-align:left;width:220px">名称</th>'
+'<th style="padding:10px 8px;text-align:left;width:70px">分类</th>'
+'<th style="padding:10px 8px;text-align:left;width:120px">单价</th>'
+'<th style="padding:10px 8px;text-align:left;width:112px">经销商分润</th>'
+'<th style="padding:10px 8px;text-align:left;width:112px">员工分润</th>'
+'<th style="padding:10px 8px;text-align:left;width:64px">计成交</th>'
+'<th style="padding:10px 8px;text-align:left;width:90px">按名额</th>'
+'<th style="padding:10px 8px;text-align:left;width:130px">有效期</th>'
+'<th style="padding:10px 8px;text-align:left">操作</th>'
+'</tr></thead><tbody>'+list.map(row).join('')+'</tbody></table></div>';
}
var on=items.filter(function(x){return x.hidden!==true;}).length;
view.innerHTML='<div class="card" style="margin-bottom:12px">'
+'<div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap">'
+'<div><b style="font-size:15px">项目目录</b> <span class="mtxt" style="font-size:12px">2 级项目维护:修改 / 添加 / 隐藏 / 有效期 / 分润建议</span></div>'
+(canEditIt?('<div style="margin-left:auto;display:flex;align-items:center;gap:6px"><span class="mtxt" style="font-size:12px">年课包价 ¥</span><input id="items_pkg_price" type="number" min="0" step="1" value="'+(pkg?Number(pkg.price)||0:0)+'" style="width:110px"><button class="btn ghost sm" onclick="itemsPkgPriceSave()">保存</button><button class="btn" onclick="itemsNewOpen()">+ 新增项目</button></div>'):'<div style="margin-left:auto"><span class="mtxt" style="font-size:12px">只读(无「管理项目目录」权限)</span></div>')
+'</div>'
+'<div class="mtxt" style="font-size:12px;margin-top:8px">隐藏 = 停售(历史订单不受影响,取消隐藏即恢复售卖)。有效期未设置(0)时,交付到期显示「未设置有效期」,不进到期提醒。课包组成可调整:操作列「移入 / 移出课包」;年课包价可在右上角编辑。</div>'
+'</div>'
+group('课包内项目','(年课包 '+g1.length+' 项 · 均可售卖)',g1)
+group('其它可售卖项','(从课包拆出 / 独立添加)',g2)
+'<div class="mtxt" style="font-size:12px;margin-top:10px">共 '+items.length+' 项 · 在售 '+on+' · 已隐藏 '+(items.length-on)+'</div>';
}
function itemsNewOpen(){itemsEditOpen('__new__');}
/* ★ 第 481 轮(主人「A」拍板):项目弹窗分润三选项联动 —— 选「按现有规则」隐藏值输入框;label 随模式切换。 */
function itCoMode(){
var m=(document.getElementById('it_cmode')||{}).value||'inherit';
var box=document.getElementById('it_cval_box'); if(box)box.style.display=(m==='inherit')?'none':'';
var lb=document.getElementById('it_cval_lbl'); if(lb)lb.innerHTML=(m==='fixed')?'分润值(固定金额 · 元/单)':'分润值(比例 % · 填 20 = 20%)';
}
function itScMode(){
var m=(document.getElementById('it_scmode')||{}).value||'inherit';
var box=document.getElementById('it_scval_box'); if(box)box.style.display=(m==='inherit')?'none':'';
var lb=document.getElementById('it_scval_lbl'); if(lb)lb.innerHTML=(m==='fixed')?'员工分润值(固定金额 · 元/单)':'员工分润值(比例 % · 填 4.5 = 4.5%)';
}
function itemsEditOpen(id){
if(!can('manage_items')){toast('无权限:需「管理项目目录」权限');return;}
var isNew=(id==='__new__'||!id);
var it=isNew?null:itemById(id);
if(!isNew&&!it){toast('项目不存在(可能已被移除)');return;}
var d=it||{name:'',cat:'service',unitPrice:0,unitName:'',commMode:'rate',commValue:0,countDeal:true,trackQuota:false,quotaUnit:'人',validMonths:0,hidden:false,enabled:true,note:''};
function sel(v,o){return v===o?' selected':'';}
function chk(v){return v?' checked':'';}
/* ★ 第 481 轮(主人「A」拍板):项目分润三选项 —— 与客户列表本单设定同款(按现有规则 / 按比例 / 固定金额)。
回显映射:值>0 ⇒ 按模式;值≤0(或缺失)⇒ inherit「按现有规则(默认)」。比例显示为百分数(存小数 ×100)。 */
var _dcv=Number(d.commValue)||0;
var dcMode=(_dcv>0)?((d.commMode==='fixed')?'fixed':'rate'):'inherit';
var _dsv=Number(d.staffCommValue)||0;
var dsMode=(_dsv>0)?((d.staffCommMode==='fixed')?'fixed':'rate'):'inherit';
if(!(_dcv>0))dcMode='';
if(!(_dsv>0))dsMode='';
var dcDisp=(dcMode==='rate')?(Math.round(_dcv*10000)/100):((dcMode==='fixed')?_dcv:0);
var dsDisp=(dsMode==='rate')?(Math.round(_dsv*10000)/100):((dsMode==='fixed')?_dsv:0);
/* 第 459 轮:弹窗「归入年课包」勾选 —— 默认 = 该项目当前是否已在年课包组成中;新增默认不勾(落课包外)。 */
var pkg0=(DB.packages||[]).find(function(x){return x&&x.id==='pkg_annual';});
var pkgIds0=(pkg0&&pkg0.itemIds)||[];
var inPkg0=(!isNew&&it)?(pkgIds0.indexOf(it.id)>=0):false;
var fld='margin-bottom:10px';
var lbl='display:block;font-size:12px;color:#475569;margin-bottom:4px';
var inp='width:100%;padding:7px 9px;border:1px solid var(--line);border-radius:8px;font-size:13px;box-sizing:border-box';
openModal(
'<div class="modal-head"><h3>'+(isNew?'新增项目':'编辑项目')+'</h3><span class="x" onclick="closeModal()">×</span></div>'
+'<div class="modal-body">'
+'<div style="'+fld+'"><label style="'+lbl+'">项目名称 *</label><input id="it_name" style="'+inp+'" value="'+esc(d.name||'')+'" placeholder="如:体验课(书法)"></div>'
+'<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px">'
+'<div style="'+fld+'"><label style="'+lbl+'">分类</label><select id="it_cat" style="'+inp+'">'
+ITEM_CATS.map(function(x){return '<option value="'+x[0]+'"'+sel(d.cat,x[0])+'>'+x[1]+'</option>';}).join('')
+'</select></div>'
+'<div style="'+fld+'"><label style="'+lbl+'">单价(元)</label><input id="it_price" type="number" min="0" step="1" style="'+inp+'" value="'+(Number(d.unitPrice)||0)+'"></div>'
+'<div style="'+fld+'"><label style="'+lbl+'">单位(选填)</label><input id="it_unit" style="'+inp+'" value="'+esc(d.unitName||'')+'" placeholder="如:元/单、元/月"></div>'
+'<div style="'+fld+'"><label style="'+lbl+'">有效期(月 · 0 = 未设置)</label><input id="it_months" type="number" min="0" step="1" style="'+inp+'" value="'+(Number(d.validMonths)||0)+'"></div>'
+'<div style="'+fld+'"><label style="'+lbl+'">分润方式 · 业绩归属经销商</label><select id="it_cmode" style="'+inp+'" onchange="itCoMode()">'
+'<option value=""'+sel(dcMode,'')+'>请选择(不设标准)</option>'
+'<option value="inherit"'+sel(dcMode,'inherit')+'>按现有规则</option>'
+'<option value="rate"'+sel(dcMode,'rate')+'>按比例(%)</option>'
+'<option value="fixed"'+sel(dcMode,'fixed')+'>固定金额(¥)</option>'
+'</select></div>'
+'<div id="it_cval_box" style="'+fld+'"><label style="'+lbl+'" id="it_cval_lbl">分润值(比例填 20 = 20%;固定填元)</label><input id="it_cval" type="number" min="0" step="0.01" style="'+inp+'" value="'+dcDisp+'"></div>'
+'<div style="'+fld+'"><label style="'+lbl+'">员工分润方式 · 业绩归属员工</label><select id="it_scmode" style="'+inp+'" onchange="itScMode()">'
+'<option value=""'+sel(dsMode,'')+'>请选择(不设标准)</option>'
+'<option value="inherit"'+sel(dsMode,'inherit')+'>按现有规则</option>'
+'<option value="rate"'+sel(dsMode,'rate')+'>按比例(%)</option>'
+'<option value="fixed"'+sel(dsMode,'fixed')+'>固定金额(¥)</option>'
+'</select></div>'
+'<div id="it_scval_box" style="'+fld+'"><label style="'+lbl+'" id="it_scval_lbl">员工分润值(比例填 4.5 = 4.5%;固定填元/单)</label><input id="it_scval" type="number" min="0" step="0.001" style="'+inp+'" value="'+dsDisp+'"></div>'
+'<div style="'+fld+'"><label style="'+lbl+'"> </label><div class="mtxt" style="font-size:12px;padding-top:6px">建单时按这些标准自动带出到本单(可改)。不选(请选择)/「按现有规则」= 不设标准、建单时不自动带出;比例一律填百分数(如 20 = 20%)。</div></div>'
+'</div>'
+'<div style="display:flex;gap:18px;flex-wrap:wrap;margin:4px 0 12px">'
+'<label style="display:flex;align-items:center;gap:6px;font-size:13px"><input type="checkbox" id="it_pkg"'+chk(inPkg0)+'> 归入年课包(随整包售卖)</label>'
+'<label style="display:flex;align-items:center;gap:6px;font-size:13px"><input type="checkbox" id="it_deal"'+chk(d.countDeal)+'> 计成交</label>'
+'<label style="display:flex;align-items:center;gap:6px;font-size:13px"><input type="checkbox" id="it_quota"'+chk(d.trackQuota)+'> 按名额登记消耗</label>'
+'<label style="display:flex;align-items:center;gap:6px;font-size:13px" title="勾选后:只由本项目组成的订单不进入「已成交未签合同」待签列表(不影响分润 / 交付)"><input type="checkbox" id="it_nosign"'+chk(d.noSign)+'> 本项目无需签订合同</label>'
+'<label style="display:flex;align-items:center;gap:6px;font-size:13px"><input type="checkbox" id="it_hidden"'+chk(d.hidden)+'> 隐藏(停售)</label>'
+'<label style="display:flex;align-items:center;gap:6px;font-size:13px"><input type="checkbox" id="it_enabled"'+chk(d.enabled)+'> 启用(总开关)</label>'
+'</div>'
+'<div style="'+fld+'"><label style="'+lbl+'">名额单位(按名额时用)</label><input id="it_qunit" style="'+inp+'" value="'+esc(d.quotaUnit||'人')+'"></div>'
+'<div style="'+fld+'"><label style="'+lbl+'">备注(选填)</label><textarea id="it_note" rows="2" style="'+inp+'">'+esc(d.note||'')+'</textarea></div>'
+'</div>'
+'<div class="modal-foot"><button class="btn ghost" onclick="closeModal()">取消</button>'
+'<button class="btn" onclick="itemsEditSave(\''+((it&&it.id)||'__new__')+'\')">💾 保存</button></div>'
);
itCoMode(); itScMode(); /* ★ 第 481 轮:初始化三选项联动(选「按现有规则」⇒ 隐藏值框) */
}
function itemsEditSave(id){
if(!can('manage_items')){toast('无权限:需「管理项目目录」权限');return;}
var gv=function(x){return (document.getElementById(x)||{}).value||'';};
var nm=String(gv('it_name')).trim();
if(!nm){toast('请填写项目名称');return;}
var price=Number(gv('it_price')||0);
if(!(price>=0)){toast('单价须 ≥ 0');return;}
var cv=Number(gv('it_cval')||0);
if(!(cv>=0)){toast('分润值须 ≥ 0');return;}
var scv=Number(gv('it_scval')||0);
if(!(scv>=0)){toast('员工分润比例须 ≥ 0');return;}
var vm=Number(gv('it_months')||0);
if(!(vm>=0)){toast('有效期须 ≥ 0(0 = 未设置)');return;}
var isNew=(id==='__new__'||!id);
var it=isNew?null:itemById(id);
if(!it){
it={id:'it_'+Date.now().toString(36)+Math.random().toString(36).slice(2,6),lv1:'common',order:999,enabled:true,note:''};
DB.items=DB.items||[];
DB.items.push(it);
}
it.name=nm;
it.cat=gv('it_cat')||'service';
it.unitPrice=price;
it.unitName=String(gv('it_unit')).trim();
/* ★ 第 481 轮(主人「A」拍板):三选项保存 ——「按现有规则」⇒ 经销商侧落 rate+0(保持字段形态,种子守卫要求);
比例 %(0~100)⇒ ÷100 存小数(clamp ≤1);固定金额 ⇒ 存元(clamp ≤1000000);值≤0 一律视为未设置。 */
var cmIn=gv('it_cmode')||'inherit';
var smIn=gv('it_scmode')||'inherit';
if(cmIn===''||cmIn==='inherit'||!(cv>0)){ it.commMode='rate'; it.commValue=0; }
else if(cmIn==='fixed'){ it.commMode='fixed'; it.commValue=Math.min(1000000,Math.round(cv)); }
else { it.commMode='rate'; it.commValue=Math.min(1,Math.round(cv/100*10000)/10000); }
/* 第 466/468 轮:项目级员工侧标准;第 481 轮并入三选项(「按现有规则」⇒ 删字段 = 未设置) */
it.staffCommMode=(smIn==='fixed')?'fixed':'rate';
if(smIn===''||smIn==='inherit'||!(scv>0)){ delete it.staffCommValue; delete it.staffCommMode; }
else { it.staffCommValue=(it.staffCommMode==='fixed')?Math.min(1000000,Math.round(scv)):Math.min(1,Math.round(scv/100*10000)/10000); }
it.countDeal=!!((document.getElementById('it_deal')||{}).checked);
it.trackQuota=!!((document.getElementById('it_quota')||{}).checked);
/* ★ 第 599 轮(主人 2026-10-23 A 方案):项目级「本项目无需签订合同」——
只影响「已成交未签合同」待签列表的展示口径,不影响分润 / 交付 / 阶段。 */
it.noSign=!!((document.getElementById('it_nosign')||{}).checked);
it.hidden=!!((document.getElementById('it_hidden')||{}).checked);
it.enabled=!!((document.getElementById('it_enabled')||{}).checked);
it.quotaUnit=String(gv('it_qunit')).trim()||'人';
it.validMonths=vm;
it.note=String(gv('it_note')).trim().slice(0,200);
/* 第 459 轮:弹窗「归入年课包」勾选 ⇒ 同步年课包组成(与 itemsTogglePkg 同规则:课包至少留 1 项)。
pkg_annual.itemIds 是「课包内 / 课包外」的唯一判据 —— 勾上即进课包,取消即移出。 */
var pkgP=(DB.packages||[]).find(function(x){return x&&x.id==='pkg_annual';});
if(pkgP){
if(!Array.isArray(pkgP.itemIds))pkgP.itemIds=[];
var wantPkg=!!((document.getElementById('it_pkg')||{}).checked);
var atP=pkgP.itemIds.indexOf(it.id);
if(wantPkg&&atP<0){ pkgP.itemIds.push(it.id); }
else if(!wantPkg&&atP>=0){
if(pkgP.itemIds.length<=1){ toast('年课包至少保留 1 项:已保持「归入年课包」'); }
else { pkgP.itemIds.splice(atP,1); }
}
}
save().then(function(ok){ if(ok)toast(isNew?'项目已添加':'项目已保存'); });
closeModal();
renderItems();
}
function itemsToggleHidden(id){
if(!can('manage_items')){toast('无权限:需「管理项目目录」权限');return;}
var it=itemById(id);
if(!it){toast('项目不存在');return;}
var to=!(it.hidden===true);
it.hidden=to;
save().then(function(ok){ if(ok)toast(to?'已隐藏(停售 · 可随时恢复)':'已恢复售卖'); });
renderItems();
}
function itemsTogglePkg(id){
if(!can('manage_items')){toast('无权限:需「项目目录」管理权限');return;}
var it=itemById(id);
if(!it){toast('项目不存在');return;}
var p=(DB.packages||[]).find(function(x){return x&&x.id==='pkg_annual';});
if(!p){toast('未找到年课包登记');return;}
if(!Array.isArray(p.itemIds))p.itemIds=[];
var at=p.itemIds.indexOf(id);
if(at>=0){
if(p.itemIds.length<=1){toast('课包至少保留 1 项,无法全部移出');return;}
p.itemIds.splice(at,1);
} else { p.itemIds.push(id); }
save().then(function(ok){ if(ok)toast(at>=0?'已移出年课包':'已移入年课包(随整包售卖)'); });
renderItems();
}
function itemsPkgPriceSave(){
if(!can('manage_items')){toast('无权限:需「项目目录」管理权限');return;}
var p=(DB.packages||[]).find(function(x){return x&&x.id==='pkg_annual';});
if(!p){toast('未找到年课包登记');return;}
var v=Number((document.getElementById('items_pkg_price')||{}).value)||0;
if(!(v>0)){toast('课包价须大于 0');return;}
p.price=Math.round(v*100)/100;
save().then(function(ok){ if(ok)toast('年课包价已保存:'+money(p.price)); });
renderItems();
}
/* ===== 第 451 轮(P2.5 · 体验课名额台账):工作台页 + 消耗登记弹窗 + 纯体验课档案豁免 =====
⚠ 本分片为 data-frag 实体区:小于号 / 大于号 / 和号一律用 < > & 书写。 */
var quotaKw='';var quotaOnlyLeft=false;var quotaRid='';
function quotaOnlyArchive(c,DB){
if(!c||!Array.isArray(c.itemIds)||!c.itemIds.length)return false;
var its=(DB&&DB.items)||[];
for(var i=0;i<c.itemIds.length;i++){
var it=null;
for(var j=0;j<its.length;j++){if(its[j]&&String(its[j].id)===String(c.itemIds[i])){it=its[j];break;}}
if(!(it&&it.trackQuota===true))return false;
}
return true;
}
function quotaRootOf(c){
if(!c)return null;
if(!c.linkedRootId)return c;
var by={};(DB.customers||[]).forEach(function(x){if(x&&x.id)by[x.id]=x;});
var g=c,gd=0;
while(g&&g.linkedRootId&&by[g.linkedRootId]&&gd++<5)g=by[g.linkedRootId];
return g||c;
}
function quotaOf(c){var r=quotaRootOf(c);return (r&&r.quota&&Number(r.quota.total)>0)?r.quota:null;}
function quotaUsedSumOf(c){var q=quotaOf(c);if(!q||!Array.isArray(q.used))return 0;var s=0;q.used.forEach(function(u){s+=Math.max(0,Math.floor(Number(u&&u.n)||0));});return s;}
/* ★ 第 496 轮(主人 2026-10-17「体验课是先交款,再参加体验课」):名额交款闸门 —— 按档案判定,不含关联档案。
判据:本档案已交款合计 ≥ 本档案订单金额 ⇒ 名额可用;否则未交款 ⇒ 可用名额按 0 算(台账/卡片显示「待交款」)。
订单金额未设置(≤0)⇒ 不做闸门,避免把未定价项目永久锁死。 */
function quotaPaidEnoughOf(c){if(!c)return true;var amt=Number(c.amount||0);if(!(amt>0))return true;return paidOf(c)+0.001>=amt;}
function quotaRemainOf(c){var q=quotaOf(c);if(!q)return 0;if(quotaTierPendingOf(c))return 0;if(!quotaPaidEnoughOf(c))return 0;return Number(q.total||0)-quotaUsedSumOf(c);}
/* ★ 第 502 轮(主人 2026-10-17「A + 两条都同意」):体验课「档位待确认」判据 —— 报备单没填哪一种体验课,
同步器按默认档建单并打标;未选档前名额剩余按 0(防按默认档多发),选档后本标记由 saveQuotaTier 清除。 */
function quotaTierPendingOf(c){return !!(c&&c.quotaTierPending===true);}
/* ★ 第 502 轮(主人 2026-10-17「A + 两条都同意」):体验课档位选择(补上「哪一种体验课」这一步)。
报备端不改 ⇒ 报备建单按默认档并打标,操作员在此选一次;选定即写 itemIds / itemQty / amount 并清标记。
为什么不只换项目:名额按「合同额 ÷ 单价」算,只换项目不改钱,名额必错 ⇒ 必须同步重算合同额(需字段级 amount 编辑权)。
⚠️ 本区是 HTML 转义区:`<` 写 <、`&&` 写 &&。 */
function quotaTierOpts(){var out=[];(DB.items||[]).forEach(function(x){if(x&&x.trackQuota===true&&x.hidden!==true)out.push(x);});return out;}
function quotaTierQtyOf(c){var cur=(Array.isArray(c.itemIds)&&c.itemIds.length)?String(c.itemIds[0]):'';var q=Math.floor(Number((c.itemQty||{})[cur])||1);return q>0?q:1;}
function openQuotaTierPick(cid){
var c=(DB.customers||[]).find(function(x){return x&&x.id===cid;});
if(!c){toast('未找到客户');return;}
if(!can('edit_customers')){toast('无权限:需「编辑客户」权限');return;}
if(canField('amount','e')!==true){toast('无权限:本角色不能修改「合同金额」,无法选档(选档会按新单价重算合同额)');return;}
var opts=expTierOptsOf(c);
if(opts.length<1){toast('项目目录里没有可售体验课档位,请先在「项目目录」添加一个体验课项目');return;}
var cur=(Array.isArray(c.itemIds)&&c.itemIds.length)?String(c.itemIds[0]):'';
var qty=quotaTierQtyOf(c);
var paid=(c.payments||[]).reduce(function(s,p){return s+(Number(p&&p.amount)||0);},0);
var rows=opts.map(function(x){var pr=Number(x.unitPrice)||0;var amt=Math.round(pr*qty*100)/100;
return '<label style="display:flex;align-items:center;gap:8px;padding:10px 8px;border-bottom:1px dashed var(--line);cursor:pointer">'
+'<input type="radio" name="qtier" value="'+esc(x.id)+'" data-price="'+pr+'" data-amt="'+amt+'" '+(String(x.id)===cur?'checked':'')+' onchange="quotaTierRecalc()">'
+'<span style="flex:1">'+esc(x.name||x.id)+'</span>'
+'<span class="mtxt">¥'+pr+' × '+qty+' 份 = <b>¥'+amt+'</b></span></label>';
}).join('');
openModal(`
<div class="modal-head"><h3>🎟 选择体验课档位 · ${esc(c.name)}</h3><span class="x" onclick="closeModal()">×</span></div>
<div class="modal-body">
<div class="warn-box">${quotaTierPendingOf(c)?'该客户的体验课档位<b>尚未确认</b>(报备单没有「哪一种体验课」这一项)。选定后:合同额按「单价 × 份数」重算,标记自动清除,名额按新金额生效。':'更换档位:合同额按「新单价 × 份数」重算,名额按新金额生效;交款记录与业绩归属不变。'}</div>
<div style="border:1px solid var(--line);border-radius:8px;margin:10px 0">${rows}</div>
<div class="mtxt" id="qtier_diff"></div>
<div class="hint">已交款 ¥${paid},不会自动改动交款记录;差额只体现在「欠款」上。</div>
</div>
<div class="modal-foot"><button class="btn" onclick="saveQuotaTier('${esc(c.id)}')">💾 确认档位</button><button class="btn ghost" onclick="closeModal()">取消</button></div>`);
quotaTierRecalc();
}
function quotaTierRecalc(){
var el=document.querySelector('input[name="qtier"]:checked');var box=document.getElementById('qtier_diff');
if(!el){if(box)box.innerHTML='';return;}
var amt=Number(el.getAttribute('data-amt'))||0;
if(box)box.innerHTML='选定后合同额 = <b>¥'+amt+'</b>';
}
function saveQuotaTier(cid){
var c=(DB.customers||[]).find(function(x){return x&&x.id===cid;});
if(!c){toast('未找到客户');return;}
if(!can('edit_customers')||canField('amount','e')!==true){toast('无权限:需「编辑客户」+「合同金额」编辑权');return;}
var el=document.querySelector('input[name="qtier"]:checked');
if(!el){toast('请先选择一个体验课档位');return;}
var iid=String(el.value||'');
var it=(DB.items||[]).find(function(x){return x&&String(x.id)===iid;});
if(!it){toast('项目不存在,请刷新后重试');return;}
var pr=Number(it.unitPrice)||0;
if(!(pr>0)){toast('该体验课档位未设置单价,无法按金额核算名额,请先在「项目目录」补单价');return;}
var qty=quotaTierQtyOf(c);
var amt=Math.round(pr*qty*100)/100;
var oldAmt=Number(c.amount)||0;
c.itemIds=[iid];c.itemQty={};c.itemQty[iid]=qty;c.amount=amt;
delete c.quotaTierPending;
save().then(function(ok){if(!ok)return;closeModal();render();toast('已确认体验课档位:'+String(it.name||iid)+' · 合同额 ¥'+oldAmt+' → ¥'+amt+'(名额 '+qty+' 个)');});
}
function quotaRowBtn(c){
if(!can('edit_cust_quota'))return '';
var r=quotaRootOf(c);if(!r||!r.quota||!(Number(r.quota.total)>0))return '';
return '<button class="btn ghost sm" onclick="openQuotaMeta(\''+esc(r.id)+'\')">🎟 名额</button>';
}
function quotaSearch(v){
var inp=document.getElementById('qtSearch');
var raw=String((v===undefined?(inp?inp.value:''):v)||'');
quotaKw=raw;
var q=raw.trim().toLowerCase();var shown=0;var total=0;
Array.prototype.forEach.call(document.querySelectorAll('#qtBody tr[data-search]'),function(tr){
total++;
var hit=!q||String(tr.getAttribute('data-search')||'').indexOf(q)>=0;
tr.style.display=hit?'':'none';
if(hit)shown++;
});
var tb=document.getElementById('qtBody');
var sum=tb?String(tb.getAttribute('data-sum')||'0'):'0';
var s=document.getElementById('qtSum');
if(s)s.textContent=(q?('当前显示 '+shown+' / '+total+' 家'):('共 '+total+' 家'))+' · 剩余合计 '+sum+' 个';
}
function renderQuotaPage(){
if(!view)return;
var all=[];
(DB.customers||[]).forEach(function(c){
if(!c)return;
if(quotaRootOf(c)!==c)return;
if(!(c.quota&&Number(c.quota.total)>0))return;
all.push(c);
});
var list=all.filter(function(c){ if(quotaOnlyLeft&"aRemainOf(c)<=0)return false; return true; });
list.sort(function(a,b){return quotaRemainOf(a)-quotaRemainOf(b)||String(a.name||'').localeCompare(String(b.name||''));});
var sumRemain=0;list.forEach(function(c){sumRemain+=quotaRemainOf(c);});
var trs=list.map(function(c){
var q=c.quota;var used=quotaUsedSumOf(c);var tierPend=quotaTierPendingOf(c);var paidOK=quotaPaidEnoughOf(c);var remain=(tierPend||!paidOK)?0:(Number(q.total||0)-used);
var last=(q.used&&q.used.length)?q.used[q.used.length-1]:null;
var intent=(c.intent&&c.intent[0])||'—';
var search=(String(c.name||'')+' '+String(c.school||'')+' '+String(c.custNo||'')).toLowerCase();
return '<tr data-search="'+esc(search)+'">'
+'<td><b>'+esc(c.name||'')+'</b>'+(c.school?('<div class="mtxt">'+esc(c.school)+'</div>'):'')+'</td>'
+'<td>'+esc(intent)+'</td>'
+'<td>'+q.total+'</td>'
+'<td>'+used+'</td>'
+'<td>'+(remain>0?('<b style="color:#15803d">'+remain+'</b> 个'):(tierPend?'<span class="mtxt" style="color:#b45309">0(待定档)</span>':(paidOK?'<span class="mtxt">0(已用完)</span>':'<span class="mtxt" style="color:#b45309">0(待交款)</span>')))+'</td>'
+'<td>'+(last?(esc(last.date||'')+' · '+last.n+' 个'+(last.student?(' · '+esc(last.student)):'')):'<span class="mtxt">—</span>')+'</td>'
+'<td>'+(can('edit_cust_quota')?('<button class="btn sm" onclick="openQuotaMeta(\''+esc(c.id)+'\')">+ 登记</button>'):'<span class="mtxt">—</span>')+'</td>'
+'</tr>';
}).join('');
view.innerHTML=`
<div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap;margin-bottom:12px">
<h2 style="margin:0;font-size:18px">🎟 体验课名额</h2>
<span class="mtxt" id="qtSum">共 ${list.length} 家 · 剩余合计 ${sumRemain} 个</span>
<label style="display:flex;align-items:center;gap:6px;font-size:12.5px;cursor:pointer;margin-left:auto"><input type="checkbox" id="qtOnlyLeft" ${quotaOnlyLeft?'checked':''} onchange="quotaOnlyLeft=this.checked;renderQuotaPage()">只看有余额</label>
</div>
<div class="search"><input id="qtSearch" class="list-search" value="${esc(quotaKw)}" placeholder="搜索 客户 / 校区 / 编号" oninput="quotaSearch(this.value)" oncompositionend="quotaSearch(this.value)"></div>
<div class="card table-card"><table><thead><tr><th>客户</th><th>1 级项目</th><th>共</th><th>已用</th><th>剩</th><th>最近消耗</th><th>操作</th></tr></thead><tbody id="qtBody" data-sum="${sumRemain}">${trs||'<tr><td colspan="7" class="empty">暂无体验课名额客户 —— 在「+ 新增订单」里选择「体验课(示范)」建立订单后,名额台账自动生成</td></tr>'}</tbody></table></div>`;
if(quotaKw)quotaSearch(quotaKw);
}
function openQuotaMeta(rid){
var c=(DB.customers||[]).find(function(x){return x&&x.id===rid;});
if(!c){toast('未找到客户');return;}
if(!can('edit_cust_quota')){toast('无权限:登记体验课名额消耗');return;}
if(!(c.quota&&Number(c.quota.total)>0)){toast('该客户暂无体验课名额');return;}
quotaRid=rid;
openModal(`
<div class="modal-head"><h3>🎟 体验课名额 · ${esc(c.name||'')}</h3><span class="x" onclick="closeModal()">×</span></div>
<div class="modal-body">
<div class="warn-box">📌 名额总数由「含体验课项目的订单」自动汇总(复购自动累加、改单自动跟随);剩余 = 总数 − 已用,自动计算。登记消耗即时生效(无需再保存)。消耗登记后客户状态自动为「已体验」(不计成交、不占校区前 5 家名额)。<br>⚠️ 体验课须<b>先交款再参加</b>:本档案订单未交齐时,剩余按 0 算、不能登记消耗(交款到账后自动恢复)。</div>
<div class="card" style="padding:12px;margin-bottom:12px"><div class="mtxt">进度</div><div id="qtProg"></div></div>
<div class="section-title" style="margin:14px 0 8px">消耗记录(新 → 旧)</div>
<div id="qtList"></div>
<div class="card" style="background:#f8fafc;margin-top:10px">
<div style="font-size:13px;font-weight:700;margin-bottom:10px">+ 登记消耗</div>
<div class="row2">
<div class="field"><label>日期</label><input type="date" id="qt_date" value="${today()}"></div>
<div class="field"><label>消耗个数</label><input type="number" id="qt_n" min="1" step="1" value="1"></div>
</div>
<div class="row2">
<div class="field"><label>学员(可选)</label><input id="qt_student" placeholder="如:张三 / 张三、李四"></div>
<div class="field"><label>备注(可选)</label><input id="qt_note" placeholder="如:已上体验课"></div>
</div>
<button class="btn sm" style="margin-top:6px" onclick="registerQuota()">登记消耗</button>
</div>
</div>
<div class="modal-foot"><button class="btn ghost" onclick="closeModal()">关闭</button></div>`);
renderQuotaMeta();
}
function renderQuotaMeta(){
var c=(DB.customers||[]).find(function(x){return x&&x.id===quotaRid;});
if(!c||!c.quota)return;
var used=quotaUsedSumOf(c);var paidOK=quotaPaidEnoughOf(c);var remain=paidOK?(Number(c.quota.total||0)-used):0;
var pe=document.getElementById('qtProg');
if(pe)pe.innerHTML='已用 <b>'+used+'</b> / 共 <b>'+Number(c.quota.total||0)+'</b> · '+(paidOK?('剩 <b style="color:'+(remain>0?'#15803d':'#b91c1c')+'">'+remain+'</b> 个'):'<b style="color:#b45309">待交款(名额暂不可用)</b>');
var le=document.getElementById('qtList');
if(!le)return;
var rows=(c.quota.used||[]).slice().reverse();
le.innerHTML=rows.length?rows.map(function(u){
return '<div class="card" style="padding:10px 12px;margin-bottom:8px;display:flex;align-items:center;gap:10px;flex-wrap:wrap">'
+'<b>'+esc(u.date||'')+'</b><span>消耗 '+Math.floor(Number(u.n)||0)+' 个</span>'
+(u.student?'<span class="mtxt">学员:'+esc(u.student)+'</span>':'')
+(u.note?'<span class="mtxt">'+esc(u.note)+'</span>':'')
+'<button class="btn ghost sm" style="margin-left:auto" onclick="quotaDelRow(\''+esc(u.id)+'\')">删除</button>'
+'</div>';
}).join(''):'<div class="empty">暂无消耗记录</div>';
}
function registerQuota(){
var c=(DB.customers||[]).find(function(x){return x&&x.id===quotaRid;});
if(!c||!c.quota){toast('客户或台账不存在');return;}
if(!can('edit_cust_quota')){toast('无权限:登记体验课名额消耗');return;}
var dEl=document.getElementById('qt_date');var nEl=document.getElementById('qt_n');
var sEl=document.getElementById('qt_student');var tEl=document.getElementById('qt_note');
var date=String((dEl&&dEl.value)||'').slice(0,10)||today();
var n=Math.floor(Number((nEl&&nEl.value)||0)||0);
if(!(n>0)){toast('消耗个数须大于 0');return;}
if(quotaTierPendingOf(c)){toast('该客户体验课档位尚未确认:请先点「🎟 选择体验课档位」,再登记名额消耗');return;}
if(!quotaPaidEnoughOf(c)){toast('该客户体验课尚未交款:请先在「财务收款」登记交款,再登记名额消耗');return;}
var remain=quotaRemainOf(c);
if(n>remain){toast('超出剩余名额:当前剩 '+remain+' 个');return;}
c.quota.used.push({id:uid('q_'),date:date,n:n,student:String((sEl&&sEl.value)||'').trim().slice(0,60),note:String((tEl&&tEl.value)||'').trim().slice(0,200)});
save().then(function(ok){
if(!ok)return;
if(nEl)nEl.value='1';if(sEl)sEl.value='';if(tEl)tEl.value='';
renderQuotaMeta();
if(curView==='custquota')renderQuotaPage();
toast('已登记消耗 '+n+' 个(剩 '+quotaRemainOf(c)+' 个)');
});
}
function quotaDelRow(qid){
if(!can('edit_cust_quota')){toast('无权限:登记体验课名额消耗');return;}
var c=(DB.customers||[]).find(function(x){return x&&x.id===quotaRid;});
if(!c||!c.quota)return;
if(!confirm('删除该条消耗记录?'))return;
c.quota.used=(c.quota.used||[]).filter(function(u){return !u||u.id!==qid;});
save().then(function(ok){
if(!ok)return;
renderQuotaMeta();
if(curView==='custquota')renderQuotaPage();
toast('已删除该条消耗记录');
});
}