<!doctype html>
|
<html lang="zh-CN">
|
<head>
|
<meta charset="utf-8">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<title>销售客户管理系统完整体验版</title>
|
<style>
|
* { box-sizing: border-box; }
|
body { margin: 0; font-family: "Microsoft YaHei", Arial, sans-serif; background: #f5f7fa; color: #172033; font-size: 14px; overflow: hidden; }
|
.app { display: grid; grid-template-columns: 220px 1fr; height: 100vh; min-height: 0; }
|
aside { background: #101827; color: #d9e2ef; padding: 18px 12px; height: 100vh; overflow-y: auto; position: sticky; top: 0; }
|
.brand { padding: 8px 10px 18px; border-bottom: 1px solid rgba(255,255,255,.12); margin-bottom: 12px; }
|
.brand strong { display: block; font-size: 16px; color: white; }
|
.brand span { color: #9aa7ba; font-size: 12px; }
|
.nav button { width: 100%; border: 0; background: transparent; color: #cbd5e1; padding: 11px 10px; text-align: left; border-radius: 8px; cursor: pointer; font: inherit; margin: 2px 0; }
|
.nav button.active, .nav button:hover { background: rgba(255,255,255,.1); color: #fff; }
|
.menu-module { margin: 10px 0 4px; color: #fff; font-weight: 700; font-size: 13px; padding: 8px 10px 6px; border-top: 1px solid rgba(255,255,255,.1); cursor: pointer; display: flex; align-items: center; justify-content: space-between; }
|
.menu-module::after { content: "收起"; color: #7f8ea3; font-size: 12px; font-weight: 400; }
|
.menu-module.collapsed::after { content: "展开"; }
|
.menu-group { color: #7f8ea3; font-size: 12px; padding: 8px 10px 2px; }
|
.nav button.child { padding: 8px 10px 8px 22px; font-size: 13px; border-radius: 6px; }
|
.nav button.disabled { opacity: .45; cursor: default; }
|
.nav button.disabled:hover { background: transparent; color: #cbd5e1; }
|
.workspace-tabs { display: flex; gap: 6px; align-items: center; padding: 8px 22px 0; background: #fff; border-bottom: 1px solid #dde3ec; overflow-x: auto; }
|
.workspace-tab { border: 1px solid #d9e0ea; background: #f8fafc; color: #475569; padding: 7px 10px; border-radius: 6px 6px 0 0; white-space: nowrap; font-size: 13px; cursor: pointer; }
|
.workspace-tab.active { background: #fff; color: #172033; border-bottom-color: #fff; font-weight: 700; }
|
.tab-close { border: 0; background: transparent; color: #7f8ea3; margin-left: 6px; cursor: pointer; font-weight: 800; }
|
.workspace-tab.active .tab-close { color: #334155; }
|
.erp-query { display: grid; grid-template-columns: repeat(4, minmax(160px, 1fr)); gap: 10px; padding: 12px 16px; border-bottom: 1px solid #e5eaf1; background: #fbfcfe; }
|
.erp-toolrow { padding: 10px 16px; display: flex; justify-content: space-between; gap: 12px; align-items: center; border-bottom: 1px solid #e5eaf1; }
|
.erp-action { color: #2563eb; cursor: pointer; font-weight: 700; }
|
header { height: 66px; background: #fff; border-bottom: 1px solid #dde3ec; padding: 0 22px; display: flex; align-items: center; justify-content: space-between; position: sticky; top: 0; z-index: 2; }
|
h1 { margin: 0; font-size: 20px; }
|
.sub { color: #66758b; font-size: 12px; margin-top: 4px; }
|
.role { display: flex; gap: 4px; padding: 3px; background: #edf1f6; border: 1px solid #dde3ec; border-radius: 8px; }
|
.role button { border: 0; background: transparent; padding: 8px 13px; border-radius: 6px; cursor: pointer; font: inherit; color: #66758b; }
|
.role button.active { background: #fff; color: #172033; box-shadow: 0 1px 4px rgba(15, 23, 42, .12); }
|
main { min-width: 0; height: 100vh; overflow-y: auto; }
|
.content { padding: 20px 22px 34px; }
|
.view { display: none; }
|
.view.active { display: block; }
|
.stats { display: grid; grid-template-columns: repeat(4, minmax(150px, 1fr)); gap: 14px; margin-bottom: 16px; }
|
.stat, .panel { background: #fff; border: 1px solid #dde3ec; border-radius: 8px; box-shadow: 0 8px 22px rgba(15, 23, 42, .07); }
|
.stat { padding: 15px; }
|
.stat .label { color: #66758b; font-size: 12px; }
|
.stat .num { font-size: 28px; font-weight: 800; margin-top: 7px; }
|
.grid { display: grid; grid-template-columns: 1.1fr .9fr; gap: 16px; }
|
.panel { overflow: hidden; margin-bottom: 16px; }
|
.panel-head { padding: 14px 16px; border-bottom: 1px solid #dde3ec; display: flex; align-items: center; justify-content: space-between; gap: 10px; }
|
.panel-head strong { font-size: 15px; }
|
.toolbar { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
|
table { width: 100%; border-collapse: collapse; }
|
th, td { padding: 11px 13px; border-bottom: 1px solid #e5eaf1; text-align: left; vertical-align: middle; }
|
th { color: #66758b; font-size: 12px; background: #fafbfc; font-weight: 600; }
|
tr:hover td { background: #f8fafc; }
|
input, select, textarea { border: 1px solid #d9e0ea; border-radius: 8px; padding: 9px 10px; font: inherit; background: #fff; min-width: 0; width: 100%; }
|
textarea { min-height: 82px; resize: vertical; }
|
.form { display: grid; grid-template-columns: repeat(3, minmax(180px, 1fr)); gap: 12px; padding: 16px; }
|
.field label { display: block; color: #66758b; font-size: 12px; margin-bottom: 6px; }
|
.wide { grid-column: span 3; }
|
.btn { border: 1px solid #d9e0ea; background: #fff; border-radius: 8px; padding: 9px 12px; cursor: pointer; font: inherit; color: #172033; }
|
.btn.primary { background: #2563eb; border-color: #2563eb; color: #fff; }
|
.btn.green { background: #16845b; border-color: #16845b; color: #fff; }
|
.btn.red { background: #c24136; border-color: #c24136; color: #fff; }
|
.tag { display: inline-flex; border-radius: 999px; padding: 4px 8px; font-size: 12px; border: 1px solid transparent; }
|
.green-tag { color: #166534; background: #eaf8f0; border-color: #ccebd8; }
|
.blue-tag { color: #1d4ed8; background: #eaf1ff; border-color: #cfe0ff; }
|
.amber-tag { color: #92400e; background: #fff7e6; border-color: #fde4ad; }
|
.red-tag { color: #991b1b; background: #feecec; border-color: #fecaca; }
|
.gray-tag { color: #475569; background: #eef2f7; border-color: #dce3ed; }
|
.muted { color: #66758b; }
|
.list { padding: 8px 0; }
|
.item { padding: 12px 16px; border-bottom: 1px solid #e5eaf1; display: flex; justify-content: space-between; gap: 14px; }
|
.item:last-child { border-bottom: 0; }
|
.drawer { display: none; position: fixed; inset: 0; z-index: 5; }
|
.drawer.active { display: block; }
|
.scrim { position: absolute; inset: 0; background: rgba(15, 23, 42, .38); }
|
.drawer-card { position: absolute; right: 0; top: 0; width: min(720px, 100%); height: 100%; background: #fff; display: flex; flex-direction: column; box-shadow: -14px 0 30px rgba(15,23,42,.18); }
|
.drawer-head { padding: 16px; border-bottom: 1px solid #dde3ec; display: flex; justify-content: space-between; align-items: center; }
|
.drawer-body { padding: 16px; overflow: auto; }
|
.detail-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
|
.box { border: 1px solid #dde3ec; border-radius: 8px; padding: 12px; margin-top: 12px; }
|
.box h3 { margin: 0 0 10px; font-size: 15px; }
|
.sheet-modal { display: none; position: fixed; inset: 0; z-index: 7; background: rgba(15, 23, 42, .36); }
|
.sheet-modal.active { display: block; }
|
.sheet-card { position: absolute; inset: 34px 26px; background: #fff; border: 1px solid #d9e0ea; box-shadow: 0 18px 45px rgba(15, 23, 42, .18); display: flex; flex-direction: column; min-width: 920px; }
|
.sheet-head { height: 44px; display: flex; align-items: center; justify-content: space-between; padding: 0 16px; border-bottom: 1px solid #e5eaf1; background: #f8fafc; }
|
.sheet-body { padding: 12px 20px; overflow: auto; }
|
.sheet-title { text-align: center; font-size: 22px; font-weight: 800; margin: 4px 0 14px; }
|
.sheet-top { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 12px 34px; align-items: end; margin-bottom: 12px; }
|
.sheet-top .field { display: grid; grid-template-columns: 92px 1fr; align-items: center; gap: 8px; }
|
.sheet-top label, .settle-grid label { color: #334155; font-size: 13px; margin: 0; }
|
.line-table { min-width: 1180px; }
|
.line-table input, .line-table select { border-radius: 0; padding: 5px 7px; background: #fffef2; }
|
.line-table th, .line-table td { padding: 6px 8px; }
|
.sheet-lower { display: grid; grid-template-columns: 1.1fr .95fr; gap: 16px; margin-top: 10px; border-top: 1px solid #e5eaf1; padding-top: 10px; }
|
.settle-grid { display: grid; grid-template-columns: 120px minmax(180px, 1fr) 120px minmax(160px, 1fr); gap: 10px 12px; align-items: center; }
|
.amount-box { display: grid; grid-template-columns: 110px 28px 1fr; gap: 6px; align-items: center; margin-bottom: 8px; }
|
.amount-box input[readonly] { background: #eef0f2; color: #dc2626; font-weight: 800; }
|
.sheet-actions { display: flex; justify-content: flex-end; gap: 10px; padding: 12px 18px; border-top: 1px solid #e5eaf1; background: #fff; }
|
.toast { display: none; position: fixed; left: 50%; bottom: 24px; transform: translateX(-50%); background: #101827; color: #fff; padding: 10px 14px; border-radius: 8px; z-index: 8; }
|
.toast.show { display: block; }
|
.alert { display: none; margin: 16px 16px 0; border: 1px solid #fecaca; background: #fff1f1; color: #991b1b; padding: 10px 12px; border-radius: 8px; }
|
.alert.show { display: block; }
|
.bar { height: 8px; background: #e8edf5; border-radius: 999px; overflow: hidden; min-width: 90px; }
|
.bar span { display: block; height: 100%; background: #2563eb; border-radius: inherit; }
|
.pager { display: flex; gap: 8px; align-items: center; justify-content: flex-end; padding: 12px 16px; border-top: 1px solid #e5eaf1; }
|
.login-screen { position: fixed; inset: 0; z-index: 20; display: none; align-items: center; justify-content: center; background: #eef3f8; padding: 18px; }
|
.login-screen.active { display: flex; }
|
.login-card { width: min(420px, 100%); background: #fff; border: 1px solid #dde3ec; border-radius: 8px; box-shadow: 0 18px 45px rgba(15, 23, 42, .14); padding: 22px; }
|
.login-card h2 { margin: 0 0 6px; font-size: 22px; }
|
.login-card .field { margin-top: 14px; }
|
.account-chip { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; justify-content: flex-end; }
|
.password-modal { display: none; position: fixed; inset: 0; z-index: 30; align-items: center; justify-content: center; padding: 16px; background: rgba(15, 23, 42, .48); }
|
.password-modal.active { display: flex; }
|
.password-card { width: min(460px, 100%); max-height: calc(100vh - 32px); overflow-y: auto; background: #fff; border: 1px solid #d9e0ea; border-radius: 10px; box-shadow: 0 20px 50px rgba(15, 23, 42, .24); }
|
.password-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; padding: 18px 18px 12px; border-bottom: 1px solid #e5eaf1; }
|
.password-head h2 { margin: 0; font-size: 20px; }
|
.password-form { display: grid; gap: 14px; padding: 18px; }
|
.password-form .field label { color: #334155; font-size: 13px; font-weight: 700; }
|
.password-form input:focus, .password-form button:focus-visible, #changePasswordButton:focus-visible { outline: 3px solid rgba(37, 99, 235, .3); outline-offset: 2px; }
|
.password-form input[aria-invalid="true"] { border-color: #c24136; }
|
.password-hint { color: #66758b; font-size: 12px; margin-top: 5px; }
|
.password-status { display: none; border: 1px solid #fecaca; background: #fff1f1; color: #991b1b; padding: 10px 12px; border-radius: 8px; }
|
.password-status.show { display: block; }
|
.password-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 2px; }
|
.password-actions .btn { min-height: 44px; }
|
.alert.notice { border-color: #cfe0ff; background: #eef5ff; color: #1d4ed8; }
|
.permission-grid { display: grid; grid-template-columns: repeat(3, minmax(160px, 1fr)); gap: 8px 12px; padding: 10px; border: 1px solid #dde3ec; border-radius: 8px; background: #fbfcfe; }
|
.permission-grid label { display: flex; gap: 7px; align-items: center; color: #334155; font-size: 13px; margin: 0; }
|
.permission-grid input { width: auto; }
|
@media (max-width: 960px) { body { overflow: auto; } .app { grid-template-columns: 1fr; height: auto; } header { height: auto; padding: 14px; flex-direction: column; align-items: flex-start; } .account-chip { justify-content: flex-start; } .stats, .grid, .form, .permission-grid, .sheet-top, .sheet-lower, .settle-grid { grid-template-columns: 1fr; } .wide { grid-column: span 1; } aside { position: static; height: auto; max-height: 46vh; } main { height: auto; overflow: visible; } .panel { overflow-x: auto; } table { min-width: 860px; } .sheet-card { inset: 10px; min-width: 0; } }
|
@media (max-width: 480px) { .password-modal { align-items: flex-start; padding: 16px; overflow-y: auto; } .password-card { max-height: none; } .password-head, .password-form { padding-left: 16px; padding-right: 16px; } .password-actions { flex-direction: column-reverse; } .password-actions .btn { width: 100%; } }
|
</style>
|
</head>
|
<body>
|
<div class="login-screen" id="loginScreen">
|
<form class="login-card" id="loginForm">
|
<h2>销售 CRM 登录</h2>
|
<div class="sub">请输入主管或销售账号后进入系统。</div>
|
<div class="field"><label>账号</label><input name="username" autocomplete="username" value="admin" required></div>
|
<div class="field"><label>密码</label><input name="password" type="password" autocomplete="current-password" value="123456" required></div>
|
<div class="alert" id="loginAlert" style="margin:14px 0 0;"></div>
|
<button class="btn primary" type="submit" style="width:100%; margin-top:14px;">登录</button>
|
<div class="sub" style="margin-top:12px;">测试账号:admin / ops1 / sales1 / sales2 / sales3,密码都是 123456。</div>
|
</form>
|
</div>
|
<div class="app">
|
<aside>
|
<div class="brand"><strong>销售 CRM 完整体验版</strong><span>本地保存 + 权限 + 公海 + 成交回访</span></div>
|
<nav class="nav">
|
<button class="active" data-view="dashboard">工作台</button>
|
<div class="menu-module">销售</div>
|
<div class="menu-group">客户业务</div>
|
<button class="child" data-view="customers">客户信息</button>
|
<button class="child" data-view="new">新增客户/线索</button>
|
<button class="child" data-view="deals">成交客户回访</button>
|
<button class="child" data-view="pool">公共客户池</button>
|
<div class="menu-group">批发业务</div>
|
<button class="child" data-view="salesOrders" data-feature="quote">报价单</button>
|
<button class="child" data-view="salesOrders" data-feature="salesOrder">销售订单</button>
|
<button class="child" data-view="salesOrders" data-feature="salesBill">销售单</button>
|
<button class="child" data-view="salesOrders" data-feature="salesReturn">销售退货单</button>
|
<button class="child" data-view="salesOrders" data-feature="invoice">销售发票登记</button>
|
<div class="menu-group">销售报表</div>
|
<button class="child" data-view="reports" data-feature="salesSummary">销售汇总</button>
|
<button class="child" data-view="reports" data-feature="salesTrend">销售趋势分析</button>
|
<button class="child" data-view="reports" data-feature="salesRank">销售业绩分析</button>
|
<div class="menu-module">生产</div>
|
<div class="menu-group">自制业务</div>
|
<button class="child" data-view="production" data-feature="productionTask">生产任务</button>
|
<div class="menu-module">库管</div>
|
<div class="menu-group">库存查询</div>
|
<button class="child" data-view="stock" data-feature="stockQuery">商品库存查询</button>
|
<button class="child" data-view="stock" data-feature="serialTrack">序列号跟踪</button>
|
<button class="child" data-view="stock" data-feature="transitQuery">在途商品查询</button>
|
<div class="menu-module">物流</div>
|
<div class="menu-group">物流发货</div>
|
<button class="child" data-view="logistics" data-feature="shipBySales">按销售单</button>
|
<button class="child" data-view="logistics" data-feature="shipByOutbound">按出库单</button>
|
<div class="menu-group">信息管理</div>
|
<button class="child" data-view="logistics" data-feature="logisticsCompany">物流公司</button>
|
<button class="child" data-view="logistics" data-feature="logisticsQuery">物流查询</button>
|
<div class="menu-module">财务</div>
|
<div class="menu-group">往来账务</div>
|
<button class="child" data-view="finance" data-feature="receivable">应收账款</button>
|
<button class="child" data-view="finance" data-feature="receipt">收款单</button>
|
<button class="child" data-view="finance" data-feature="otherReceivable">其他应收单</button>
|
<div class="menu-group">费用报销</div>
|
<button class="child" data-view="finance" data-feature="expense">报销申请</button>
|
<div class="menu-group">财务报表</div>
|
<button class="child" data-view="finance" data-feature="statement">客户对账明细</button>
|
<button class="child" data-view="finance" data-feature="unpaid">未收账款统计</button>
|
<div class="menu-module">系统</div>
|
<button class="child" data-view="reminders">提醒任务</button>
|
<button class="child" data-view="tools">数据工具</button>
|
<button data-view="accounts" id="accountsNav">账号权限</button>
|
</nav>
|
</aside>
|
<main>
|
<header>
|
<div><h1 id="title">工作台</h1><div class="sub" id="subtitle">请先登录账号。</div></div>
|
<div class="account-chip"><span class="tag blue-tag" id="accountBadge">未登录</span><button class="btn" id="changePasswordButton" type="button" onclick="openPasswordChangeModal()" hidden>修改密码</button><button class="btn" type="button" onclick="logout()">退出</button></div>
|
</header>
|
<div class="workspace-tabs" id="workspaceTabs"></div>
|
<section class="content">
|
<div id="dashboard" class="view active">
|
<div class="stats" id="stats"></div>
|
<div class="grid">
|
<div class="panel"><div class="panel-head"><strong>最近客户</strong><button class="btn primary" onclick="setView('new')">新增客户</button></div><table><thead><tr><th>客户</th><th>负责人</th><th>阶段</th><th>成交</th><th>下次跟进</th><th>操作</th></tr></thead><tbody id="recentRows"></tbody></table></div>
|
<div class="panel"><div class="panel-head"><strong>今日提醒</strong></div><div class="list" id="dashReminders"></div></div>
|
</div>
|
</div>
|
|
<div id="customers" class="view">
|
<div class="panel">
|
<div class="panel-head">
|
<strong>客户列表</strong>
|
<div class="toolbar">
|
<input id="search" placeholder="搜索客户/公司/电话/微信">
|
<button class="btn" onclick="resetFilters()">清空筛选</button>
|
<button class="btn" onclick="exportCustomers()">导出Excel</button>
|
<button class="btn" onclick="runRules()">自动公海检查</button>
|
<button class="btn" onclick="loadState()">刷新</button>
|
</div>
|
</div>
|
<div class="form" style="padding-bottom:10px;">
|
<div class="field"><label>负责人</label><select id="ownerFilter"><option value="">全部</option><option>销售一</option><option>销售二</option><option>销售三</option></select></div>
|
<div class="field"><label>来源平台</label><select id="sourceFilter"><option value="">全部</option><option>百度</option><option>爱采购</option><option>淘宝</option><option>京东</option><option>抖音</option><option>官网</option><option>转介绍</option><option>视频号</option><option>小红书</option><option>公众号</option><option>豆包</option><option>DeepSeek</option><option>元宝</option><option>快手</option><option>阿里</option><option>天猫</option><option>京东自营店</option></select></div>
|
<div class="field"><label>客户类别</label><select id="categoryFilter"><option value="">全部</option><option>公众号</option><option>快手</option><option>抖音</option><option>淘宝店铺</option><option>天猫</option><option>京东POP</option><option>京东自营</option><option>百度</option><option>视频号</option><option>360</option><option>阿里店铺</option><option>阿里新店</option><option>爱采购</option><option>微信小店</option><option>小红书</option><option>1688商家</option><option>公司自营</option><option>渠道经销商</option><option>线上经销商</option><option>其他</option></select></div>
|
<div class="field"><label>阶段漏斗</label><select id="funnelFilter"><option value="">全部</option><option>S1 新线索</option><option>S2 已联系</option><option>S3 需求确认</option><option>S4 选型方案</option><option>S5 已报价</option><option>S6 比价/审批</option><option>S7 成交/执行</option><option>S8 暂缓/失败</option></select></div>
|
<div class="field"><label>成交状态</label><select id="dealFilter"><option value="">全部</option><option>未成交</option><option>已成交</option><option>待发货/执行</option><option>已交付</option><option>售后维护中</option><option>复购跟进中</option><option>已流失</option></select></div>
|
<div class="field"><label>公海状态</label><select id="poolFilter"><option value="">全部</option><option>正常</option><option>公共客户池</option></select></div>
|
<div class="field"><label>意向等级</label><select id="intentionFilter"><option value="">全部</option><option>A</option><option>B</option><option>C</option><option>D</option></select></div>
|
<div class="field"><label>首次录入开始</label><input id="inputStartFilter" type="date"></div>
|
<div class="field"><label>首次录入结束</label><input id="inputEndFilter" type="date"></div>
|
<div class="field"><label>统计周期</label><select id="periodFilter"><option value="">全部</option><option value="week">本周</option><option value="month">本月</option><option value="year">今年</option></select></div>
|
</div>
|
<div class="sub" id="customerSummary" style="padding:0 16px 12px;"></div>
|
<div class="toolbar" style="padding:0 16px 12px;">
|
<select id="batchFunnel" style="width:160px;"><option value="">批量修改阶段</option><option>S1 新线索</option><option>S2 已联系</option><option>S3 需求确认</option><option>S4 选型方案</option><option>S5 已报价</option><option>S6 比价/审批</option><option>S7 成交/执行</option><option>S8 暂缓/失败</option></select>
|
<button class="btn" onclick="batchUpdateFunnel()">应用到勾选客户</button>
|
</div>
|
<table><thead><tr><th><input type="checkbox" id="selectAllCustomers" style="width:auto;" onchange="toggleAllCustomers(this.checked)"></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="customerRows"></tbody></table>
|
<div class="pager"><button class="btn" onclick="changePage(-1)">上一页</button><span id="pageInfo" class="muted"></span><button class="btn" onclick="changePage(1)">下一页</button><select id="pageSize" style="width:90px;" onchange="changePageSize()"><option>10</option><option>20</option><option>50</option></select></div>
|
</div>
|
</div>
|
|
<div id="new" class="view">
|
<div class="panel">
|
<div class="panel-head"><strong id="newFormTitle">新增客户</strong><span class="muted" id="newFormHint">强重复字段:公司、电话、微信、平台账号、线索ID</span></div>
|
<div class="alert" id="formAlert"></div>
|
<div class="alert" id="duplicateAlert" style="background:#fff7e6;color:#92400e;border-color:#fde4ad;"></div>
|
<form class="form" id="customerForm">
|
<div class="wide" id="entryModeTitle" style="font-weight:700; padding-top:8px;">录入信息</div>
|
<div class="field common-entry"><label>客户姓名/称呼 *</label><input name="name" required></div>
|
<div class="field common-entry"><label>手机号</label><input name="phone"></div>
|
<div class="field common-entry"><label>微信</label><input name="wechat"></div>
|
<div class="field common-entry"><label>平台账号/ID</label><input name="platformAccount"></div>
|
<div class="field common-entry"><label>公司名称 *</label><input name="company" required></div>
|
<div class="field common-entry"><label>来源平台</label><select name="source"><option>百度</option><option>爱采购</option><option>淘宝</option><option>京东</option><option>抖音</option><option>官网</option><option>转介绍</option><option>视频号</option><option>小红书</option><option>公众号</option><option>豆包</option><option>DeepSeek</option><option>元宝</option><option>快手</option><option>阿里</option><option>天猫</option><option>京东自营店</option></select></div>
|
<div class="field common-entry"><label>客户类别</label><select name="customerCategory"><option>公众号</option><option>快手</option><option>抖音</option><option>淘宝店铺</option><option>天猫</option><option>京东POP</option><option>京东自营</option><option>百度</option><option>视频号</option><option>360</option><option>阿里店铺</option><option>阿里新店</option><option>爱采购</option><option>微信小店</option><option>小红书</option><option>1688商家</option><option>公司自营</option><option>渠道经销商</option><option>线上经销商</option><option>其他</option></select></div>
|
<div class="wide ops-entry" style="font-weight:700; padding-top:8px;">运营来源信息</div>
|
<div class="field ops-entry"><label>免费/付费</label><select name="trafficCostType"><option>免费</option><option>付费</option></select></div>
|
<div class="field ops-entry"><label>搜索词</label><input name="searchTerm" placeholder="客户搜索词"></div>
|
<div class="field ops-entry"><label>关键词</label><input name="keyword" placeholder="投放/内容关键词"></div>
|
<div class="field ops-entry"><label>时段</label><input name="trafficTimeSlot" placeholder="如 10:00-12:00"></div>
|
<div class="field ops-entry"><label>经销商/终端</label><select name="customerType"><option value="">未判断</option><option>终端</option><option>经销商</option></select></div>
|
<div class="field ops-entry"><label>客户地区</label><input name="opRegion" placeholder="如 湖南-长沙"></div>
|
<div class="field ops-entry"><label>流量类型</label><input name="trafficType" placeholder="自然流量/营销流量"></div>
|
<div class="field ops-entry"><label>营销类型</label><input name="marketingType" placeholder="标准投放/私信留资/表单"></div>
|
<div class="field ops-entry"><label>互动场景</label><input name="interactionScene" placeholder="短视频/直播/搜索"></div>
|
<div class="field ops-entry"><label>内容/广告名称</label><input name="contentName" placeholder="单元名称或内容标题"></div>
|
<div class="field ops-entry"><label>投放账号</label><input name="sourceAccount" placeholder="账号/店铺/抖音号"></div>
|
<div class="field ops-entry"><label>内容链接</label><input name="contentLink" placeholder="视频或落地页链接"></div>
|
<div class="field ops-entry"><label>线索ID</label><input name="leadId"></div>
|
<div class="field ops-entry"><label>转化状态</label><input name="conversionStatus" placeholder="合法转化/待确认"></div>
|
<div class="field ops-entry"><label>运营负责人</label><input name="opsOwner" placeholder="如 运营一"></div>
|
<div class="wide sales-entry" style="font-weight:700; padding-top:8px;">销售信息</div>
|
<div class="field sales-entry"><label>首次咨询时间</label><input name="firstConsultAt" type="datetime-local"></div>
|
<div class="field sales-entry"><label>首次接待人</label><input name="firstReceiver" value="销售一"></div>
|
<div class="field sales-entry"><label>客户地区</label><input name="region"></div>
|
<div class="field sales-entry"><label>使用场景</label><input name="scene" placeholder="食品厂、冷库、水处理等"></div>
|
<div class="field sales-entry"><label>需求类型</label><input name="demand" placeholder="空间消毒、水处理、除味等"></div>
|
<div class="field sales-entry"><label>关键参数</label><input name="params" placeholder="面积、水量、型号、数量、预算"></div>
|
<div class="field sales-entry"><label>意向等级</label><select name="intention"><option>A</option><option>B</option><option>C</option><option>D</option></select></div>
|
<div class="field sales-entry"><label>阶段漏斗</label><select name="funnel"><option>S1 新线索</option><option>S2 已联系</option><option>S3 需求确认</option><option>S4 选型方案</option><option>S5 已报价</option><option>S6 比价/审批</option><option>S7 成交/执行</option><option>S8 暂缓/失败</option></select></div>
|
<div class="field sales-entry"><label>成交状态</label><select name="dealStatus"><option>未成交</option><option>已成交</option><option>待发货/执行</option><option>已交付</option><option>售后维护中</option><option>复购跟进中</option><option>已流失</option></select></div>
|
<div class="field sales-entry"><label>下次跟进时间 *</label><input name="nextFollowupAt" type="date" required></div>
|
<div class="field wide common-entry"><label>备注</label><textarea name="remark"></textarea></div>
|
<div class="wide"><button class="btn primary" id="saveCustomerBtn" type="submit">保存客户</button></div>
|
</form>
|
</div>
|
</div>
|
|
<div id="deals" class="view">
|
<div class="panel"><div class="panel-head"><strong>成交客户</strong><div class="toolbar"><input id="dealSearch" placeholder="搜索成交客户" oninput="renderRows()"><select id="dealStatusFilter" onchange="renderRows()"><option value="">全部成交状态</option><option>已成交</option><option>待发货/执行</option><option>已交付</option><option>售后维护中</option><option>复购跟进中</option></select><select id="dealStarFilter" onchange="renderRows()"><option value="">全部星级</option><option>金</option><option>银</option><option>铜</option><option>铁</option></select></div></div><table><thead><tr><th>客户</th><th>成交状态</th><th>星级</th><th>成交信息</th><th>负责人</th><th>首次录入人</th><th>下次成交回访</th><th>操作</th></tr></thead><tbody id="dealRows"></tbody></table></div>
|
</div>
|
|
<div id="salesOrders" class="view">
|
<div class="panel">
|
<div class="panel-head"><strong id="businessTitle">销售订单</strong><span class="muted" id="businessHint">销售成交后建立订单,后续给生产、库管、物流、财务引用。</span></div>
|
<div class="erp-query">
|
<div class="field"><label>订单日期</label><input id="orderDateStart" type="date"></div>
|
<div class="field"><label>至</label><input id="orderDateEnd" type="date"></div>
|
<div class="field"><label>客户</label><input id="orderCustomerFilter" placeholder="客户名称/公司"></div>
|
<div class="field"><label>审核状态</label><select id="orderAuditFilter"><option value="">全部</option><option>未审核</option><option>已审核</option></select></div>
|
<div class="field"><label>商品</label><input id="orderProductFilter" placeholder="商品/型号"></div>
|
<div class="field"><label>销售员</label><input id="orderOwnerFilter" placeholder="销售员"></div>
|
<div class="field"><label>出库状态</label><select id="orderOutboundFilter"><option value="">全部</option><option>未出库</option><option>部分出库</option><option>已出库</option></select></div>
|
<div class="field"><label> </label><button class="btn primary" type="button" onclick="renderBusinessModules()">查询</button></div>
|
</div>
|
<div class="erp-toolrow">
|
<div class="toolbar">
|
<button class="btn primary" type="button" onclick="focusBusinessForm('salesOrderForm')">新增</button>
|
<button class="btn" type="button" onclick="copyLatestOrder()">复制</button>
|
<button class="btn" type="button" onclick="openSalesBillFromLatest()">生成销售单</button>
|
<button class="btn" type="button" onclick="printCurrentTable()">批量打印</button>
|
<button class="btn" type="button" onclick="auditLatestOrder()">更多操作</button>
|
</div>
|
<button class="btn" type="button" onclick="exportCustomers()">导出</button>
|
</div>
|
<form class="form" id="salesOrderForm">
|
<div class="field"><label>客户/公司 *</label><input name="customer" required placeholder="例如 河北某某公司"></div>
|
<div class="field"><label>销售负责人</label><input name="owner" placeholder="例如 销售一"></div>
|
<div class="field"><label>客户订单号</label><input name="customerOrderNo" placeholder="平台订单号/客户订单号"></div>
|
<div class="field"><label>产品信息 *</label><input name="product" required placeholder="产品/型号/配置"></div>
|
<div class="field"><label>数量</label><input name="quantity" type="number" min="0" step="1"></div>
|
<div class="field"><label>单价</label><input name="unitPrice" type="number" min="0" step="0.01"></div>
|
<div class="field"><label>总价</label><input name="totalPrice" type="number" min="0" step="0.01"></div>
|
<div class="field"><label>订金</label><input name="depositAmount" type="number" min="0" step="0.01"></div>
|
<div class="field"><label>交货日期</label><input name="deliveryDate" type="date"></div>
|
<div class="field"><label>审核状态</label><select name="auditStatus"><option>未审核</option><option>已审核</option></select></div>
|
<div class="field"><label>订单状态</label><select name="status"><option>待确认</option><option>待生产</option><option>待发货</option><option>已发货</option><option>已完成</option><option>已取消</option></select></div>
|
<div class="field wide"><label>备注</label><textarea name="remark"></textarea></div>
|
<div class="wide"><button class="btn primary" type="submit">保存销售订单</button></div>
|
</form>
|
<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><th>未收款(¥)</th><th>销售单</th><th>出库状态</th><th>生产任务</th><th>备注</th><th>制单人</th><th>制单日期</th></tr></thead><tbody id="salesOrderRows"></tbody></table>
|
</div>
|
</div>
|
|
<div id="production" class="view">
|
<div class="panel">
|
<div class="panel-head"><strong>生产任务</strong><span class="muted">用于排产、生产中、完成、异常记录。</span></div>
|
<div class="erp-query">
|
<div class="field"><label>任务日期</label><input type="date"></div>
|
<div class="field"><label>订单号</label><input placeholder="销售订单号"></div>
|
<div class="field"><label>商品</label><input placeholder="产品/型号"></div>
|
<div class="field"><label>任务状态</label><select><option>全部</option><option>待排产</option><option>生产中</option><option>已完成</option><option>异常</option></select></div>
|
</div>
|
<div class="erp-toolrow"><div class="toolbar"><button class="btn primary" type="button" onclick="focusBusinessForm('productionForm')">新增</button><button class="btn" type="button" onclick="generateFromLatestOrder('production')">从销售订单生成</button><button class="btn" type="button" onclick="printCurrentTable()">打印</button></div><span class="muted">自制业务 / 生产任务</span></div>
|
<form class="form" id="productionForm">
|
<div class="field"><label>关联订单号</label><input name="orderId" placeholder="销售订单ID"></div>
|
<div class="field"><label>产品/型号 *</label><input name="product" required></div>
|
<div class="field"><label>数量</label><input name="quantity" type="number" min="0" step="1"></div>
|
<div class="field"><label>生产负责人</label><input name="owner"></div>
|
<div class="field"><label>预计完成日期</label><input name="dueDate" type="date"></div>
|
<div class="field"><label>生产状态</label><select name="status"><option>待排产</option><option>生产中</option><option>已完成</option><option>异常</option></select></div>
|
<div class="field wide"><label>备注</label><textarea name="remark"></textarea></div>
|
<div class="wide"><button class="btn primary" type="submit">保存生产任务</button></div>
|
</form>
|
<table><thead><tr><th>任务号</th><th>订单</th><th>产品</th><th>数量</th><th>预计完成</th><th>状态</th><th>负责人</th><th>操作</th></tr></thead><tbody id="productionRows"></tbody></table>
|
</div>
|
</div>
|
|
<div id="stock" class="view">
|
<div class="panel">
|
<div class="panel-head"><strong>库管库存</strong><span class="muted">记录商品库存、序列号、在途和预警。</span></div>
|
<div class="erp-query">
|
<div class="field"><label>商品</label><input placeholder="商品名称/型号"></div>
|
<div class="field"><label>序列号</label><input placeholder="序列号"></div>
|
<div class="field"><label>库存状态</label><select><option>全部</option><option>正常</option><option>预警</option><option>缺货</option></select></div>
|
<div class="field"><label> </label><button class="btn primary" type="button" onclick="renderBusinessModules()">查询</button></div>
|
</div>
|
<div class="erp-toolrow"><div class="toolbar"><button class="btn primary" type="button" onclick="focusBusinessForm('stockForm')">新增库存</button><button class="btn" type="button" onclick="renderBusinessHeader()">切换查询</button><button class="btn" type="button" onclick="exportBusinessCsv('stockItems')">导出</button></div><span class="muted">库存查询</span></div>
|
<form class="form" id="stockForm">
|
<div class="field"><label>产品/型号 *</label><input name="product" required></div>
|
<div class="field"><label>库存数量</label><input name="quantity" type="number" min="0" step="1"></div>
|
<div class="field"><label>安全库存</label><input name="safeQuantity" type="number" min="0" step="1"></div>
|
<div class="field"><label>序列号</label><input name="serialNo"></div>
|
<div class="field"><label>在途数量</label><input name="transitQuantity" type="number" min="0" step="1"></div>
|
<div class="field"><label>库存状态</label><select name="status"><option>正常</option><option>预警</option><option>缺货</option></select></div>
|
<div class="field wide"><label>备注</label><textarea name="remark"></textarea></div>
|
<div class="wide"><button class="btn primary" type="submit">保存库存记录</button></div>
|
</form>
|
<table><thead><tr><th>库存号</th><th>产品</th><th>库存</th><th>安全库存</th><th>在途</th><th>序列号</th><th>状态</th><th>操作</th></tr></thead><tbody id="stockRows"></tbody></table>
|
</div>
|
</div>
|
|
<div id="logistics" class="view">
|
<div class="panel">
|
<div class="panel-head"><strong>物流发货</strong><span class="muted">按销售订单记录发货、物流单号和签收状态。</span></div>
|
<div class="erp-query">
|
<div class="field"><label>发货日期</label><input type="date"></div>
|
<div class="field"><label>销售订单号</label><input placeholder="销售订单号"></div>
|
<div class="field"><label>物流公司</label><input placeholder="物流公司"></div>
|
<div class="field"><label>物流状态</label><select><option>全部</option><option>待发货</option><option>已发货</option><option>已签收</option><option>异常</option></select></div>
|
</div>
|
<div class="erp-toolrow"><div class="toolbar"><button class="btn primary" type="button" onclick="focusBusinessForm('logisticsForm')">新增发货</button><button class="btn" type="button" onclick="generateFromLatestOrder('logistics')">按销售单</button><button class="btn" type="button" onclick="focusBusinessForm('logisticsForm')">物流公司</button></div><span class="muted">物流发货 / 信息管理</span></div>
|
<form class="form" id="logisticsForm">
|
<div class="field"><label>关联订单号</label><input name="orderId" placeholder="销售订单ID"></div>
|
<div class="field"><label>物流公司</label><input name="company"></div>
|
<div class="field"><label>物流单号</label><input name="trackingNo"></div>
|
<div class="field"><label>发货日期</label><input name="shipDate" type="date"></div>
|
<div class="field"><label>收货地址</label><input name="address"></div>
|
<div class="field"><label>物流状态</label><select name="status"><option>待发货</option><option>已发货</option><option>已签收</option><option>异常</option></select></div>
|
<div class="field wide"><label>备注</label><textarea name="remark"></textarea></div>
|
<div class="wide"><button class="btn primary" type="submit">保存物流记录</button></div>
|
</form>
|
<table><thead><tr><th>物流号</th><th>订单</th><th>物流公司</th><th>单号</th><th>发货日期</th><th>状态</th><th>地址</th><th>操作</th></tr></thead><tbody id="logisticsRows"></tbody></table>
|
</div>
|
</div>
|
|
<div id="finance" class="view">
|
<div class="panel">
|
<div class="panel-head"><strong>财务收款</strong><span class="muted">记录应收、已收、未收、开票和收款提醒。</span></div>
|
<div class="erp-query">
|
<div class="field"><label>单据日期</label><input type="date"></div>
|
<div class="field"><label>客户/订单</label><input placeholder="客户名称/订单号"></div>
|
<div class="field"><label>收款状态</label><select><option>全部</option><option>待收款</option><option>部分收款</option><option>已收款</option></select></div>
|
<div class="field"><label>开票状态</label><select><option>全部</option><option>未开票</option><option>已开票</option><option>无需开票</option></select></div>
|
</div>
|
<div class="erp-toolrow"><div class="toolbar"><button class="btn primary" type="button" onclick="focusBusinessForm('financeForm')">新增收款</button><button class="btn" type="button" onclick="generateFromLatestOrder('finance')">从订单生成应收</button><button class="btn" type="button" onclick="exportBusinessCsv('receivables')">财务报表</button></div><span class="muted">往来账务 / 财务报表</span></div>
|
<form class="form" id="financeForm">
|
<div class="field"><label>关联订单/客户 *</label><input name="orderId" required placeholder="订单号或客户名"></div>
|
<div class="field"><label>应收金额</label><input name="receivableAmount" type="number" min="0" step="0.01"></div>
|
<div class="field"><label>已收金额</label><input name="receivedAmount" type="number" min="0" step="0.01"></div>
|
<div class="field"><label>未收金额</label><input name="unpaidAmount" type="number" min="0" step="0.01"></div>
|
<div class="field"><label>开票状态</label><select name="invoiceStatus"><option>未开票</option><option>已开票</option><option>无需开票</option></select></div>
|
<div class="field"><label>收款状态</label><select name="status"><option>待收款</option><option>部分收款</option><option>已收款</option></select></div>
|
<div class="field"><label>下次收款提醒</label><input name="nextReminderAt" type="date"></div>
|
<div class="field wide"><label>备注</label><textarea name="remark"></textarea></div>
|
<div class="wide"><button class="btn primary" type="submit">保存财务记录</button></div>
|
</form>
|
<table><thead><tr><th>应收号</th><th>订单/客户</th><th>应收</th><th>已收</th><th>未收</th><th>开票</th><th>状态</th><th>操作</th></tr></thead><tbody id="financeRows"></tbody></table>
|
</div>
|
</div>
|
|
<div id="pool" class="view">
|
<div class="panel"><div class="panel-head"><strong>公共客户池</strong><span class="muted">员工不可私自抢客户,主管可分配</span></div><table><thead><tr><th>客户</th><th>首次录入人</th><th>原负责人</th><th>释放原因</th><th>释放时间</th><th>操作</th></tr></thead><tbody id="poolRows"></tbody></table></div>
|
</div>
|
|
<div id="reminders" class="view">
|
<div class="panel">
|
<div class="panel-head"><strong>提醒任务</strong></div>
|
<div class="form" style="padding-bottom:10px;">
|
<div class="field"><label>提醒对象/客户</label><input id="manualReminderCustomer" placeholder="可填客户名"></div>
|
<div class="field"><label>提醒日期</label><input id="manualReminderDate" type="date"></div>
|
<div class="field"><label>提醒内容</label><input id="manualReminderContent" placeholder="例如:明天上午联系客户确认报价"></div>
|
<div class="field"><label> </label><button class="btn primary" onclick="createReminder()">创建提醒</button></div>
|
</div>
|
<div class="list" id="reminderList"></div>
|
</div>
|
</div>
|
|
<div id="reports" class="view">
|
<div class="grid">
|
<div class="panel"><div class="panel-head"><strong>转化漏斗</strong></div><table><thead><tr><th>阶段</th><th>客户数</th><th>占比</th></tr></thead><tbody id="funnelReportRows"></tbody></table></div>
|
<div class="panel"><div class="panel-head"><strong>销售排行</strong></div><table><thead><tr><th>销售</th><th>客户数</th><th>成交数</th><th>公海数</th></tr></thead><tbody id="salesReportRows"></tbody></table></div>
|
</div>
|
<div class="grid">
|
<div class="panel"><div class="panel-head"><strong>来源渠道分析</strong></div><table><thead><tr><th>来源</th><th>客户数</th><th>免费/付费</th><th>成交数</th><th>成交率</th></tr></thead><tbody id="sourceReportRows"></tbody></table></div>
|
<div class="panel"><div class="panel-head"><strong>月度趋势</strong></div><table><thead><tr><th>月份</th><th>新增客户</th><th>成交客户</th></tr></thead><tbody id="monthReportRows"></tbody></table></div>
|
</div>
|
<div class="panel"><div class="panel-head"><strong>运营关键词/客户地区汇总</strong></div><table><thead><tr><th>维度</th><th>客户数</th><th>成交数</th><th>成交率</th></tr></thead><tbody id="opsReportRows"></tbody></table></div>
|
</div>
|
|
<div id="tools" class="view">
|
<div class="panel">
|
<div class="panel-head"><strong>批量导入</strong><span class="muted">每行:客户,公司,电话,微信,来源,下次跟进日期,备注</span></div>
|
<div style="padding:16px;">
|
<input id="importFile" type="file" accept=".csv,.txt" onchange="loadImportFile(event)">
|
<textarea id="importText" placeholder="张三,某某公司,13800000000,wx001,百度,2026-07-10,展会客户"></textarea>
|
<button class="btn primary" style="margin-top:10px;" onclick="importCustomers()">批量导入</button>
|
</div>
|
</div>
|
<div class="panel">
|
<div class="panel-head"><strong>合并重复客户</strong><span class="muted">把被合并客户的跟进和执行记录转移到主客户,然后删除被合并客户</span></div>
|
<div style="padding:0 16px 10px;"><button class="btn" onclick="renderDuplicateSuggestions()">扫描重复建议</button></div>
|
<table><thead><tr><th>疑似重复</th><th>依据</th><th>操作</th></tr></thead><tbody id="duplicateRows"></tbody></table>
|
<div class="form">
|
<div class="field"><label>主客户ID</label><input id="mergeMainId" placeholder="保留这个客户"></div>
|
<div class="field"><label>被合并客户ID</label><input id="mergeDupId" placeholder="删除这个客户"></div>
|
<div class="field"><label> </label><button class="btn red" onclick="mergeCustomers()">合并客户</button></div>
|
</div>
|
</div>
|
</div>
|
|
<div id="accounts" class="view">
|
<div class="panel">
|
<div class="panel-head"><strong>账号权限</strong><span class="muted">由主管后台创建账号,手机号可作为登录账号</span></div>
|
<form class="form" id="accountForm">
|
<div class="wide" id="accountEditHint" style="font-weight:700;">新增账号</div>
|
<div class="field"><label>手机号/登录账号 *</label><input name="username" placeholder="例如 13800000000" required></div>
|
<div class="field"><label>员工姓名 *</label><input name="name" placeholder="例如 销售四" required></div>
|
<div class="field"><label>岗位 *</label><select name="role" required><option value="sales">销售</option><option value="ops">运营</option><option value="production">生产</option><option value="warehouse">库管</option><option value="logistics">物流</option><option value="finance">财务</option><option value="supervisor">主管</option></select></div>
|
<div class="field"><label>初始/新密码</label><input name="password" type="text" placeholder="新增必填,修改可留空"></div>
|
<div class="field"><label>账号状态</label><select name="status"><option value="active">启用</option><option value="disabled">停用</option></select></div>
|
<div class="field"><label> </label><div class="toolbar"><button class="btn primary" type="submit">保存账号</button><button class="btn" type="button" onclick="resetAccountForm()">新增模式</button></div></div>
|
<div class="wide">
|
<div class="field"><label>开通权限</label><div class="permission-grid" id="permissionChecks"></div></div>
|
</div>
|
<div class="wide muted">主管可按岗位给员工勾选或取消权限。岗位只作为默认模板,保存时以这里勾选的权限为准。</div>
|
</form>
|
<table><thead><tr><th>账号</th><th>姓名</th><th>岗位</th><th>状态</th><th>权限</th><th>操作</th></tr></thead><tbody id="accountRows"></tbody></table>
|
</div>
|
<div class="panel">
|
<div class="panel-head"><strong>客户转接</strong><span class="muted">员工离职或岗位调整时,把名下客户批量转给其他销售</span></div>
|
<div class="form">
|
<div class="field"><label>原负责人</label><select id="transferFrom"></select></div>
|
<div class="field"><label>新负责人</label><select id="transferTo"></select></div>
|
<div class="field"><label> </label><button class="btn red" onclick="transferCustomers()">确认转接客户</button></div>
|
<div class="wide muted" id="transferHint"></div>
|
</div>
|
</div>
|
</div>
|
</section>
|
</main>
|
</div>
|
|
<div class="drawer" id="drawer"><div class="scrim" onclick="closeDrawer()"></div><div class="drawer-card"><div class="drawer-head"><div><h1 id="drawerTitle">客户详情</h1><div class="sub" id="drawerSub"></div></div><button class="btn" onclick="closeDrawer()">关闭</button></div><div class="drawer-body" id="drawerBody"></div></div></div>
|
<div class="sheet-modal" id="salesBillModal">
|
<div class="sheet-card">
|
<div class="sheet-head"><strong>生成销售单</strong><div class="toolbar"><button class="btn" onclick="closeSalesBillModal()">关闭</button></div></div>
|
<div class="sheet-body">
|
<div class="sheet-title">销售单</div>
|
<input type="hidden" id="billOrderId">
|
<div class="sheet-top">
|
<div class="field"><label>出库仓库 *</label><select id="billWarehouse"><option>原材料库</option><option selected>产成品库</option><option>半成品库</option><option>废品库</option><option>办公用品库</option><option>故障配件</option></select></div>
|
<div class="field"><label>销售日期 *</label><input id="billDate" type="date"></div>
|
<div class="field"><label>客户 *</label><input id="billCustomer"></div>
|
<div class="field"><label>送货方式</label><select id="billShipMethod"><option>自提</option><option>物流</option><option>快递</option><option>送货上门</option></select></div>
|
<div class="field"><label>客户订单号</label><input id="billCustomerOrderNo"></div>
|
<div class="field"><label>归属项目</label><input id="billProject"></div>
|
<div class="field"><label>单据编号</label><input id="billNo" readonly></div>
|
</div>
|
<div class="toolbar" style="margin-bottom:8px;">
|
<button class="btn primary" type="button" onclick="toast('商品选择会在产品库完善后接入')">选择商品</button>
|
<button class="btn" type="button" onclick="toast('套餐功能后续接入')">选择套餐</button>
|
<button class="btn" type="button" onclick="toast('扫码录入后续接入')">扫码条码</button>
|
<input id="billBarcode" placeholder="请扫描条形码" style="max-width:180px;">
|
<button class="btn" type="button" onclick="toast('当前先按订单商品带入')">过滤订单中商品</button>
|
<button class="btn" type="button" onclick="toast('数量填充后续接入')">数量填充</button>
|
</div>
|
<table class="line-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><th>折后金额(¥)</th><th>赠品</th><th>品牌</th><th>仓位/货架号</th><th>备注</th></tr></thead>
|
<tbody>
|
<tr>
|
<td>1</td>
|
<td><span class="erp-action" onclick="toast('当前体验版保留一行订单商品')">删</span></td>
|
<td><input id="billProductCode"></td>
|
<td><input id="billProduct"></td>
|
<td><input id="billModel"></td>
|
<td><input id="billOrderQty" type="number" readonly></td>
|
<td><input id="billSoldQty" type="number" readonly value="0"></td>
|
<td><input id="billQty" type="number" min="0" step="1" oninput="calcSalesBill()"></td>
|
<td><input id="billUnit" value="台"></td>
|
<td><input id="billUnitPrice" type="number" min="0" step="0.01" oninput="calcSalesBill()"></td>
|
<td><input id="billAmount" type="number" readonly></td>
|
<td><input id="billDiscountRate" type="number" min="0" step="0.01" value="100" oninput="calcSalesBill()"></td>
|
<td><input id="billDiscountPrice" type="number" readonly></td>
|
<td><input id="billDiscountAmount" type="number" readonly></td>
|
<td><select id="billGift"><option>否</option><option>是</option></select></td>
|
<td><input id="billBrand"></td>
|
<td><input id="billShelf"></td>
|
<td><input id="billItemRemark"></td>
|
</tr>
|
</tbody>
|
</table>
|
<div class="sheet-lower">
|
<div>
|
<textarea id="billRemark" placeholder="备注,例如:220V改380V"></textarea>
|
<div class="settle-grid" style="margin-top:10px;">
|
<label>结算账户:</label><select id="billSettleAccount"><option>京东-pop店</option><option>京东-自营</option><option>南连对公账户-中国银行</option><option>天猫</option><option>对公账户-秦皇岛银行</option><option>微信</option><option>支付宝</option><option>淘宝</option><option>现金卡</option><option>碧栗对公账户-建设银行</option><option>阿里巴巴</option><option>阿里巴巴新店</option></select>
|
<label>现结款:</label><div style="display:flex;gap:6px;"><span>¥</span><input id="billCashReceived" type="number" min="0" step="0.01" oninput="calcSalesBill()"></div>
|
<label>制单人:</label><input id="billMaker" readonly>
|
<label>销售员:</label><div style="display:flex;gap:6px;"><input id="billSalesperson"><button class="btn primary" type="button" onclick="fillBillSalesperson()">...</button></div>
|
</div>
|
</div>
|
<div>
|
<div class="toolbar" style="margin-bottom:12px;"><strong>上传附件:</strong><button class="btn" type="button" onclick="toast('附件上传后续接入云端存储')">+</button></div>
|
<div class="amount-box"><span>合计金额:</span><span>¥</span><input id="billSumAmount" readonly></div>
|
<div class="amount-box"><span>其它费用:</span><span>¥</span><input id="billOtherFee" type="number" min="0" step="0.01" value="0" oninput="calcSalesBill()"></div>
|
<div class="amount-box"><span>优惠金额:</span><span>¥</span><input id="billDiscountMoney" type="number" min="0" step="0.01" value="0" oninput="calcSalesBill()"></div>
|
<div class="amount-box"><span>现结后欠款:</span><span>¥</span><input id="billUnpaid" readonly></div>
|
<div class="amount-box"><span>总计金额:</span><span>¥</span><input id="billGrandTotal" readonly></div>
|
</div>
|
</div>
|
</div>
|
<div class="sheet-actions">
|
<button class="btn primary" onclick="saveSalesBill(true)">保存并审核</button>
|
<button class="btn" onclick="saveSalesBill(false)">保存</button>
|
<button class="btn" onclick="closeSalesBillModal()">取消</button>
|
</div>
|
</div>
|
</div>
|
|
<div class="password-modal" id="passwordChangeModal" aria-hidden="true">
|
<div class="password-card" role="dialog" aria-modal="true" aria-labelledby="passwordChangeTitle" aria-describedby="passwordChangeDescription">
|
<div class="password-head">
|
<div><h2 id="passwordChangeTitle">修改密码</h2><div class="sub" id="passwordChangeDescription">修改成功后将退出当前登录,请使用新密码重新登录。</div></div>
|
</div>
|
<form class="password-form" id="passwordChangeForm" novalidate>
|
<div class="field">
|
<label for="currentPassword">当前密码</label>
|
<input id="currentPassword" name="currentPassword" type="password" autocomplete="current-password" aria-describedby="passwordChangeAlert" required>
|
</div>
|
<div class="field">
|
<label for="newPassword">新密码</label>
|
<input id="newPassword" name="newPassword" type="password" autocomplete="new-password" aria-describedby="newPasswordHint passwordChangeAlert" required>
|
<div class="password-hint" id="newPasswordHint">8至64个Unicode码点,输入内容不会被裁剪或规范化。</div>
|
</div>
|
<div class="field">
|
<label for="confirmPassword">确认新密码</label>
|
<input id="confirmPassword" name="confirmPassword" type="password" autocomplete="new-password" aria-describedby="passwordChangeAlert" required>
|
</div>
|
<div class="password-status" id="passwordChangeAlert" role="alert" aria-live="assertive" tabindex="-1"></div>
|
<div class="password-actions">
|
<button class="btn" id="passwordChangeCancel" type="button" onclick="closePasswordChangeModal()">取消</button>
|
<button class="btn primary" id="passwordChangeSubmit" type="submit">确认修改</button>
|
</div>
|
</form>
|
</div>
|
</div>
|
<div class="toast" id="toast"></div>
|
|
<script>
|
let role = "sales";
|
let currentUser = "销售一";
|
let activeView = "dashboard";
|
let activeFeature = "dashboard";
|
const featureTitles = {
|
dashboard: "工作台",
|
customers: "客户信息",
|
new: "新增客户/线索",
|
deals: "成交客户回访",
|
quote: "报价单",
|
salesOrder: "销售订单",
|
salesBill: "销售单",
|
salesReturn: "销售退货单",
|
invoice: "销售发票登记",
|
productionTask: "生产任务",
|
stockQuery: "商品库存查询",
|
serialTrack: "序列号跟踪",
|
transitQuery: "在途商品查询",
|
shipBySales: "按销售单发货",
|
shipByOutbound: "按出库单发货",
|
logisticsCompany: "物流公司",
|
logisticsQuery: "物流查询",
|
receivable: "应收账款",
|
receipt: "收款单",
|
otherReceivable: "其他应收单",
|
expense: "报销申请",
|
statement: "客户对账明细",
|
unpaid: "未收账款统计",
|
pool: "公共客户池",
|
reminders: "提醒任务",
|
reports: "统计报表",
|
tools: "数据工具",
|
accounts: "账号权限"
|
};
|
let openedTabs = [{ view: "dashboard", feature: "dashboard", title: "工作台" }];
|
let state = { customers: [], reminders: [] };
|
let customerPage = 1;
|
let customerPageSize = 10;
|
const apiBase = location.protocol === "file:" ? "http://127.0.0.1:8090" : "";
|
const localStoreKey = "sales_crm_full_demo_state_v1";
|
const accountStoreKey = "sales_crm_account_v1";
|
let auth = null;
|
let passwordChangeSubmitting = false;
|
let passwordChangeReturnFocus = null;
|
localStorage.removeItem(accountStoreKey);
|
const dealStatuses = ["已成交", "待发货/执行", "已交付", "售后维护中", "复购跟进中"];
|
const customerCategoryOptions = ["公众号", "快手", "抖音", "淘宝店铺", "天猫", "京东POP", "京东自营", "百度", "视频号", "360", "阿里店铺", "阿里新店", "爱采购", "微信小店", "小红书", "1688商家", "公司自营", "渠道经销商", "线上经销商", "其他"];
|
const permissionCatalog = [
|
["customers:own", "客户-本人客户"],
|
["customers:all", "客户-全部客户"],
|
["customers:delete", "客户-删除客户"],
|
["customers:assign", "客户-分配公海"],
|
["customers:export", "客户-导出数据"],
|
["sales:edit", "销售-跟进报价成交"],
|
["ops:edit", "运营-来源录入"],
|
["orders:view", "订单-查看"],
|
["orders:edit", "订单-编辑"],
|
["production:edit", "生产-任务管理"],
|
["stock:edit", "库管-库存出库"],
|
["logistics:edit", "物流-发货签收"],
|
["finance:edit", "财务-收款账务"],
|
["reports:view", "报表-查看"],
|
["accounts:manage", "系统-账号权限"]
|
];
|
const permissionLabels = Object.fromEntries(permissionCatalog);
|
|
function categoryOptions(selected = "") {
|
const value = selected || "其他";
|
return customerCategoryOptions.map(option => `<option ${option === value ? "selected" : ""}>${option}</option>`).join("");
|
}
|
|
function defaultPermissionSet(nextRole) {
|
const defaults = {
|
supervisor: ["customers:all", "customers:delete", "customers:assign", "customers:export", "orders:view", "orders:edit", "production:edit", "stock:edit", "logistics:edit", "finance:edit", "reports:view", "accounts:manage"],
|
ops: ["customers:all", "ops:edit", "reports:view"],
|
production: ["orders:view", "production:edit"],
|
warehouse: ["orders:view", "stock:edit"],
|
logistics: ["orders:view", "logistics:edit"],
|
finance: ["orders:view", "finance:edit", "reports:view"],
|
sales: ["customers:own", "sales:edit", "orders:view", "orders:edit"]
|
};
|
return defaults[nextRole] || defaults.sales;
|
}
|
|
function hasPerm(code) {
|
const permissions = auth?.user?.permissions || [];
|
return role === "supervisor" || permissions.includes(code);
|
}
|
|
function api(path, options = {}) {
|
const headers = { "Content-Type": "application/json", ...(options.headers || {}) };
|
if (auth?.token) headers.Authorization = `Bearer ${auth.token}`;
|
return fetch(apiBase + path, { ...options, headers })
|
.then(async (res) => {
|
const data = await res.json();
|
if (!res.ok) throw new Error(data.error || "请求失败");
|
if (path.startsWith("/api/state")) saveLocalState(data);
|
return data;
|
})
|
.catch((err) => {
|
if (err.message === "Failed to fetch") {
|
if (path === "/api/login") {
|
throw new Error("后端服务未连接,请用 http://192.168.1.191:8090/ 打开,不要直接打开 HTML 文件。");
|
}
|
toast("后端未连接,已切换到浏览器本地体验模式");
|
return localApi(path, options);
|
}
|
throw err;
|
});
|
}
|
|
const passwordChangeErrorMessages = Object.freeze({
|
INVALID_REQUEST: "请求无效,请检查输入后重试。",
|
AUTHENTICATION_REQUIRED: "登录状态已失效,请重新登录。",
|
ACCOUNT_DISABLED: "账号不可用,请联系主管。",
|
CURRENT_PASSWORD_INCORRECT: "当前密码不正确。",
|
PASSWORD_LENGTH_INVALID: "新密码必须为8至64个Unicode码点。",
|
NEW_PASSWORD_SAME_AS_CURRENT: "新密码不能与当前密码相同。",
|
PASSWORD_UPDATE_CONFLICT: "账号状态已变化,请重新登录后再试。",
|
PASSWORD_CHANGE_UNAVAILABLE: "修改密码服务暂时不可用,请稍后手动重试。",
|
INTERNAL_ERROR: "修改失败,请稍后重试。"
|
});
|
|
async function requestPasswordChange(currentPassword, newPassword) {
|
if (!auth?.token) {
|
const error = new Error("Password change authentication required");
|
error.code = "AUTHENTICATION_REQUIRED";
|
throw error;
|
}
|
const controller = new AbortController();
|
const timeout = setTimeout(() => controller.abort(), 15000);
|
try {
|
const response = await fetch(apiBase + "/api/v1/me/password/change", {
|
method: "POST",
|
headers: {
|
"Content-Type": "application/json",
|
Authorization: `Bearer ${auth.token}`
|
},
|
body: JSON.stringify({ currentPassword, newPassword }),
|
cache: "no-store",
|
signal: controller.signal
|
});
|
if (response.status === 204) return;
|
|
let code = "";
|
try {
|
const data = await response.json();
|
if (typeof data?.error?.code === "string") code = data.error.code;
|
} catch {
|
code = "";
|
}
|
const error = new Error("Password change failed");
|
error.code = Object.prototype.hasOwnProperty.call(passwordChangeErrorMessages, code) ? code : "UNKNOWN_RESPONSE";
|
throw error;
|
} catch (error) {
|
if (error.name === "AbortError" || error instanceof TypeError) {
|
const networkError = new Error("Password change unavailable");
|
networkError.code = "PASSWORD_CHANGE_UNAVAILABLE";
|
throw networkError;
|
}
|
throw error;
|
} finally {
|
clearTimeout(timeout);
|
}
|
}
|
|
function clearPasswordChangeFeedback() {
|
const alert = document.getElementById("passwordChangeAlert");
|
alert.textContent = "";
|
alert.classList.remove("show");
|
document.querySelectorAll("#passwordChangeForm input").forEach(input => input.removeAttribute("aria-invalid"));
|
}
|
|
function clearPasswordChangeForm() {
|
document.getElementById("passwordChangeForm").reset();
|
clearPasswordChangeFeedback();
|
}
|
|
function setPasswordChangeError(message, fieldName = "") {
|
clearPasswordChangeFeedback();
|
const alert = document.getElementById("passwordChangeAlert");
|
alert.textContent = message;
|
alert.classList.add("show");
|
const field = fieldName ? document.querySelector(`#passwordChangeForm [name="${fieldName}"]`) : null;
|
if (field) {
|
field.setAttribute("aria-invalid", "true");
|
field.focus();
|
} else {
|
alert.focus?.();
|
}
|
}
|
|
function setPasswordChangeBusy(busy) {
|
passwordChangeSubmitting = busy;
|
document.querySelectorAll("#passwordChangeForm input, #passwordChangeForm button").forEach(control => { control.disabled = busy; });
|
const submit = document.getElementById("passwordChangeSubmit");
|
submit.textContent = busy ? "正在修改…" : "确认修改";
|
document.getElementById("passwordChangeForm").setAttribute("aria-busy", busy ? "true" : "false");
|
}
|
|
function openPasswordChangeModal() {
|
if (!auth?.token || passwordChangeSubmitting) return;
|
passwordChangeReturnFocus = document.activeElement;
|
clearPasswordChangeForm();
|
const modal = document.getElementById("passwordChangeModal");
|
modal.classList.add("active");
|
modal.setAttribute("aria-hidden", "false");
|
const app = document.querySelector(".app");
|
app.inert = true;
|
app.setAttribute("aria-hidden", "true");
|
document.body.style.overflow = "hidden";
|
document.getElementById("currentPassword").focus();
|
}
|
|
function closePasswordChangeModal(options = {}) {
|
if (passwordChangeSubmitting && !options.force) return;
|
clearPasswordChangeForm();
|
const modal = document.getElementById("passwordChangeModal");
|
modal.classList.remove("active");
|
modal.setAttribute("aria-hidden", "true");
|
const app = document.querySelector(".app");
|
app.inert = false;
|
app.removeAttribute("aria-hidden");
|
document.body.style.overflow = "";
|
if (options.restoreFocus !== false && passwordChangeReturnFocus?.focus) passwordChangeReturnFocus.focus();
|
passwordChangeReturnFocus = null;
|
}
|
|
function validatePasswordChange(form) {
|
clearPasswordChangeFeedback();
|
const currentPassword = form.elements.currentPassword.value;
|
const newPassword = form.elements.newPassword.value;
|
const confirmPassword = form.elements.confirmPassword.value;
|
if (!currentPassword) return { message: "请输入当前密码。", field: "currentPassword" };
|
if (!newPassword) return { message: "请输入新密码。", field: "newPassword" };
|
if (!confirmPassword) return { message: "请再次输入新密码。", field: "confirmPassword" };
|
const codePointLength = Array.from(newPassword).length;
|
if (codePointLength < 8 || codePointLength > 64) return { message: passwordChangeErrorMessages.PASSWORD_LENGTH_INVALID, field: "newPassword" };
|
if (newPassword === currentPassword) return { message: passwordChangeErrorMessages.NEW_PASSWORD_SAME_AS_CURRENT, field: "newPassword" };
|
if (confirmPassword !== newPassword) return { message: "两次输入的新密码不一致。", field: "confirmPassword" };
|
return { currentPassword, newPassword };
|
}
|
|
function handlePasswordChangeError(error) {
|
const code = error?.code || "UNKNOWN_RESPONSE";
|
if (code === "AUTHENTICATION_REQUIRED") {
|
logout(passwordChangeErrorMessages[code]);
|
return;
|
}
|
if (code === "ACCOUNT_DISABLED") {
|
logout(passwordChangeErrorMessages[code]);
|
return;
|
}
|
if (code === "PASSWORD_UPDATE_CONFLICT") {
|
logout(passwordChangeErrorMessages[code]);
|
return;
|
}
|
if (code === "CURRENT_PASSWORD_INCORRECT") {
|
document.getElementById("currentPassword").value = "";
|
setPasswordChangeError(passwordChangeErrorMessages[code], "currentPassword");
|
return;
|
}
|
if (code === "PASSWORD_LENGTH_INVALID" || code === "NEW_PASSWORD_SAME_AS_CURRENT") {
|
setPasswordChangeError(passwordChangeErrorMessages[code], "newPassword");
|
return;
|
}
|
const message = passwordChangeErrorMessages[code] || "修改失败,未确认任何更改,请稍后重试。";
|
setPasswordChangeError(message);
|
}
|
|
function loadLocalState() {
|
const raw = localStorage.getItem(localStoreKey);
|
if (raw) {
|
const data = JSON.parse(raw);
|
(data.customers || []).forEach(c => {
|
if (!Object.prototype.hasOwnProperty.call(c, "customerCategory")) c.customerCategory = c.source || "";
|
});
|
return data;
|
}
|
return { nextCustomerId: 1, nextActionId: 1, customers: [], actions: [], reminders: [] };
|
}
|
|
function saveLocalState(nextState = state) {
|
const copy = { ...nextState, reminders: [] };
|
localStorage.setItem(localStoreKey, JSON.stringify(copy));
|
}
|
|
async function localApi(path, options = {}) {
|
const method = (options.method || "GET").toUpperCase();
|
const body = options.body ? JSON.parse(options.body) : {};
|
const data = loadLocalState();
|
if (method === "GET" && path.startsWith("/api/state")) {
|
return { ...data, reminders: remindersFor(data), today: new Date().toISOString().slice(0, 10), localMode: true };
|
}
|
const customer = data.customers.find(c => c.id === Number(body.customerId));
|
const user = body.user || currentUser;
|
if (path === "/api/customers") {
|
const duplicate = data.customers.find(c => (body.phone && c.phone === body.phone) || (body.wechat && c.wechat === body.wechat) || (body.platformAccount && c.platformAccount === body.platformAccount) || (body.leadId && (c.leadId === body.leadId || c.douyinCustomerId === body.leadId)) || (body.company && c.company === body.company));
|
if (duplicate) throw new Error(`发现强重复客户:${duplicate.name} / ${duplicate.company},不能重复录入`);
|
const row = { id: data.nextCustomerId++, name: body.name || "", company: body.company || "", phone: body.phone || "", wechat: body.wechat || "", platformAccount: body.platformAccount || "", source: body.source || "", customerCategory: body.customerCategory || body.source || "", trafficCostType: body.trafficCostType || "免费", searchTerm: body.searchTerm || "", keyword: body.keyword || "", trafficTimeSlot: body.trafficTimeSlot || "", customerType: body.customerType || "", opRegion: body.opRegion || body.region || "", trafficType: body.trafficType || "", marketingType: body.marketingType || "", interactionScene: body.interactionScene || "", conversionStatus: body.conversionStatus || "", leadId: body.leadId || "", sourceAccount: body.sourceAccount || "", contentName: body.contentName || "", contentLink: body.contentLink || "", opsOwner: body.opsOwner || "", owner: role === "sales" ? user : "销售一", firstReceiver: body.firstReceiver || user, firstInputAt: nowText(), firstConsultAt: body.firstConsultAt || nowText(), region: body.region || "", scene: body.scene || "", demand: body.demand || "", params: body.params || "", intention: body.intention || "C", level: body.intention || "C", funnel: body.funnel || "S1 新线索", stage: body.stage || "初询", dealStatus: body.dealStatus || "未成交", dealAt: "", dealAmount: "", dealProduct: "", dealQuantity: "", dealUnitPrice: "", dealTotalPrice: "", revisitLevel: body.revisitLevel || "铜", nextDealRevisit: "", protect: "保护中", protectEnd: "", pool: "正常", poolReason: "", releasedAt: "", previousOwner: "", nextFollowupAt: body.nextFollowupAt || "", lastEffectiveFollowupAt: "", remark: body.remark || "", followups: [], quotes: [], dealRevisits: [], actions: [] };
|
addLocalAction(data, row, user, "新增客户", "浏览器本地保存");
|
data.customers.unshift(row);
|
saveLocalState(data);
|
return { customer: row };
|
}
|
if (path === "/api/reminders") {
|
data.manualReminders = data.manualReminders || [];
|
const reminder = { id: Date.now(), customerId: Number(body.customerId) || null, customer: body.customer || "", user, dueDate: body.dueDate || new Date().toISOString().slice(0, 10), content: body.content || "", status: "open", createdAt: nowText() };
|
data.manualReminders.unshift(reminder);
|
saveLocalState(data);
|
return { reminder };
|
}
|
if (path === "/api/reminders/complete") {
|
data.manualReminders = data.manualReminders || [];
|
const reminder = data.manualReminders.find(r => r.id === Number(body.id));
|
if (reminder) reminder.status = "done";
|
saveLocalState(data);
|
return { reminder };
|
}
|
if (path === "/api/run-rules") {
|
const now = new Date();
|
data.customers.forEach(c => {
|
if (c.pool === "公共客户池" || dealStatuses.includes(c.dealStatus)) return;
|
const base = c.lastEffectiveFollowupAt || String(c.firstInputAt || "").slice(0, 10);
|
if (!base) return;
|
const days = Math.floor((now - new Date(base + "T00:00:00")) / 86400000);
|
if (days >= 30) {
|
c.previousOwner = c.owner;
|
c.pool = "公共客户池";
|
c.poolReason = `自动公海:${days}天无有效跟进`;
|
c.releasedAt = nowText();
|
addLocalAction(data, c, "系统", "自动进入公海", c.poolReason);
|
}
|
});
|
saveLocalState(data);
|
return { ok: true };
|
}
|
if (!customer) throw new Error("客户不存在");
|
if (path === "/api/customers/update") {
|
["name", "company", "phone", "wechat", "platformAccount", "source", "customerCategory", "trafficCostType", "searchTerm", "keyword", "trafficTimeSlot", "customerType", "opRegion", "trafficType", "marketingType", "interactionScene", "conversionStatus", "leadId", "sourceAccount", "contentName", "contentLink", "opsOwner", "owner", "firstReceiver", "firstInputAt", "region", "scene", "demand", "params", "intention", "funnel", "stage", "dealStatus", "pool", "poolReason", "nextFollowupAt", "remark"].forEach(field => {
|
if (Object.prototype.hasOwnProperty.call(body, field)) customer[field] = body[field] || "";
|
});
|
addLocalAction(data, customer, user, "编辑客户信息", "修改客户基础资料");
|
}
|
if (path === "/api/customers/delete") {
|
if (role !== "supervisor") throw new Error("只有主管可以删除客户");
|
data.customers = data.customers.filter(c => c.id !== customer.id);
|
saveLocalState(data);
|
return { ok: true };
|
}
|
if (path === "/api/followups") {
|
customer.followups = customer.followups || [];
|
customer.followups.unshift({ at: new Date().toISOString().slice(0, 10), user, channel: body.channel || "电话", content: body.content || "", next: body.nextFollowupAt || "", effective: !!body.effective });
|
customer.nextFollowupAt = body.nextFollowupAt || customer.nextFollowupAt;
|
if (body.funnel && customer.funnel !== body.funnel) {
|
addLocalAction(data, customer, user, "调整客户阶段", `${customer.funnel || "未设置"} -> ${body.funnel}`);
|
customer.funnel = body.funnel;
|
customer.stage = body.funnel.replace(/^S\d+\s*/, "");
|
}
|
addLocalAction(data, customer, user, "新增跟进记录", body.content || "");
|
}
|
if (path === "/api/followups/update") {
|
const index = Number(body.followupIndex);
|
if (!customer.followups || !customer.followups[index]) throw new Error("跟进记录不存在");
|
customer.followups[index] = { ...customer.followups[index], channel: body.channel || customer.followups[index].channel, content: body.content || "", next: body.nextFollowupAt || "", effective: !!body.effective };
|
addLocalAction(data, customer, user, "编辑跟进记录", customer.followups[index].content);
|
}
|
if (path === "/api/followups/delete") {
|
const index = Number(body.followupIndex);
|
if (!customer.followups || !customer.followups[index]) throw new Error("跟进记录不存在");
|
const removed = customer.followups.splice(index, 1)[0];
|
addLocalAction(data, customer, user, "删除跟进记录", removed.content || "");
|
}
|
if (path === "/api/quotes") {
|
customer.quotes = customer.quotes || [];
|
const quantity = Number(body.quantity || 0);
|
const unitPrice = Number(body.unitPrice || 0);
|
const amount = body.totalPrice || (quantity && unitPrice ? String(quantity * unitPrice) : "");
|
const row = { at: body.at || new Date().toISOString().slice(0, 10), user, model: body.model || "", quantity: body.quantity || "", unitPrice: body.unitPrice || "", amount, priceType: body.priceType || "标准价", approval: body.approval || "无需审批", quoteNo: body.quoteNo || "", remark: body.remark || "" };
|
customer.quotes.unshift(row);
|
addLocalAction(data, customer, user, "新增报价记录", `${row.model} ${row.amount}`);
|
}
|
if (path === "/api/deal") updateDealCustomer(data, customer, body, user);
|
if (path === "/api/deal-revisits") {
|
customer.dealRevisits = customer.dealRevisits || [];
|
customer.dealRevisits.unshift({ at: body.at || new Date().toISOString().slice(0, 10), user, result: body.result || "", next: body.nextDealRevisit || "" });
|
customer.nextDealRevisit = body.nextDealRevisit || customer.nextDealRevisit;
|
addLocalAction(data, customer, user, "新增成交回访", body.result || "");
|
}
|
if (path === "/api/release") {
|
customer.previousOwner = customer.owner;
|
customer.pool = "公共客户池";
|
customer.poolReason = body.reason || "主管释放";
|
customer.releasedAt = nowText();
|
addLocalAction(data, customer, user, "释放到公海", customer.poolReason);
|
}
|
if (path === "/api/assign") {
|
customer.previousOwner = customer.owner;
|
customer.owner = body.owner || currentUser;
|
customer.pool = "正常";
|
customer.poolReason = "";
|
addLocalAction(data, customer, user, "公海重新分配", `分配给 ${customer.owner}`);
|
}
|
saveLocalState(data);
|
return { customer };
|
}
|
|
function nowText() {
|
const d = new Date();
|
const pad = n => String(n).padStart(2, "0");
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
}
|
|
function addDays(dateText, days) {
|
const d = new Date(`${dateText || new Date().toISOString().slice(0, 10)}T00:00:00`);
|
d.setDate(d.getDate() + days);
|
return d.toISOString().slice(0, 10);
|
}
|
|
function addLocalAction(data, customer, user, title, content) {
|
const row = { id: data.nextActionId++, customerId: customer.id, user, title, content, at: nowText() };
|
data.actions.unshift(row);
|
customer.actions = customer.actions || [];
|
customer.actions.unshift(row);
|
}
|
|
function updateDealCustomer(data, customer, body, user) {
|
customer.dealStatus = body.dealStatus || "未成交";
|
if (customer.dealStatus === "未成交") {
|
customer.stage = "初询";
|
customer.funnel = "S2 已联系";
|
customer.dealAt = "";
|
customer.dealAmount = "";
|
customer.dealProduct = "";
|
customer.dealQuantity = "";
|
customer.dealUnitPrice = "";
|
customer.dealTotalPrice = "";
|
customer.nextDealRevisit = "";
|
addLocalAction(data, customer, user, "修改成交状态", "已改为未成交,并清空成交信息");
|
} else {
|
customer.stage = "成交";
|
customer.funnel = "S7 成交/执行";
|
customer.dealAt = body.dealAt || new Date().toISOString().slice(0, 10);
|
customer.dealAmount = body.dealAmount || "";
|
customer.dealProduct = body.dealProduct || "";
|
customer.dealQuantity = body.dealQuantity || "";
|
customer.dealUnitPrice = body.dealUnitPrice || "";
|
customer.dealTotalPrice = body.dealTotalPrice || body.dealAmount || "";
|
customer.revisitLevel = body.revisitLevel || customer.revisitLevel || "铜";
|
customer.nextDealRevisit = body.nextDealRevisit || addDays(customer.dealAt, 7);
|
addLocalAction(data, customer, user, "修改成交状态", customer.dealStatus);
|
}
|
}
|
|
function remindersFor(data) {
|
const today = new Date().toISOString().slice(0, 10);
|
return data.customers.filter(c => role === "supervisor" || role === "ops" || c.owner === currentUser).flatMap(c => {
|
const rows = [];
|
const isDeal = dealStatuses.includes(c.dealStatus);
|
if (!isDeal && c.nextFollowupAt && c.nextFollowupAt <= today) {
|
const overdueDays = Math.max(0, Math.floor((new Date(today + "T00:00:00") - new Date(c.nextFollowupAt + "T00:00:00")) / 86400000));
|
const type = overdueDays >= 7 ? "逾期7天主管升级" : overdueDays >= 3 ? "逾期3天提醒" : c.nextFollowupAt === today ? "今日待跟进" : "超期未跟进";
|
rows.push({ customerId: c.id, customer: c.name, type, text: `${c.company},下次跟进时间 ${c.nextFollowupAt},已逾期${overdueDays}天` });
|
}
|
if (c.pool === "预公海") rows.push({ customerId: c.id, customer: c.name, type: "预公海提醒", text: c.poolReason || "需要补充有效跟进或回访" });
|
if (isDeal) {
|
if ((!c.dealRevisits || !c.dealRevisits.length) && !c.nextDealRevisit) {
|
rows.push({ customerId: c.id, customer: c.name, type: "成交回访待登记", text: `${c.company} 已成交,但还没有成交回访记录,请补充首次回访。` });
|
} else if (c.nextDealRevisit && c.nextDealRevisit <= today) {
|
rows.push({ customerId: c.id, customer: c.name, type: "成交回访", text: `成交客户需要回访,计划时间 ${c.nextDealRevisit}` });
|
}
|
}
|
return rows;
|
});
|
}
|
|
function toast(text) {
|
const el = document.getElementById("toast");
|
el.textContent = text;
|
el.classList.add("show");
|
setTimeout(() => el.classList.remove("show"), 3000);
|
}
|
|
function showFormAlert(text) {
|
const el = document.getElementById("formAlert");
|
el.textContent = text;
|
el.classList.add("show");
|
}
|
|
function clearFormAlert() {
|
const el = document.getElementById("formAlert");
|
el.textContent = "";
|
el.classList.remove("show");
|
}
|
|
function normalizeText(value) {
|
return String(value || "").trim().toLowerCase().replace(/\s+/g, "");
|
}
|
|
function findDuplicateCandidates(formData) {
|
const phone = normalizeText(formData.phone);
|
const wechat = normalizeText(formData.wechat);
|
const platform = normalizeText(formData.platformAccount);
|
const leadId = normalizeText(formData.leadId);
|
const company = normalizeText(formData.company);
|
const name = normalizeText(formData.name);
|
const rows = [];
|
for (const c of state.customers || []) {
|
const reasons = [];
|
let strong = false;
|
if (phone && normalizeText(c.phone) === phone) { reasons.push("电话相同"); strong = true; }
|
if (wechat && normalizeText(c.wechat) === wechat) { reasons.push("微信相同"); strong = true; }
|
if (platform && normalizeText(c.platformAccount) === platform) { reasons.push("平台ID相同"); strong = true; }
|
if (leadId && (normalizeText(c.leadId) === leadId || normalizeText(c.douyinCustomerId) === leadId)) { reasons.push("线索ID相同"); strong = true; }
|
if (company && normalizeText(c.company) === company) reasons.push("公司名称相同");
|
if (name && normalizeText(c.name) === name) reasons.push("客户名称相同");
|
if (!reasons.length) continue;
|
rows.push({ customer: c, reasons, strong });
|
}
|
return rows.sort((a, b) => Number(b.strong) - Number(a.strong)).slice(0, 5);
|
}
|
|
async function getDuplicateCandidates(formData) {
|
if (auth?.token) {
|
try {
|
const result = await api("/api/duplicates", { method: "POST", body: JSON.stringify(formData) });
|
return (result.duplicates || []).map(row => ({ customer: row, reasons: row.reasons || [], strong: !!row.strong }));
|
} catch {
|
return findDuplicateCandidates(formData);
|
}
|
}
|
return findDuplicateCandidates(formData);
|
}
|
|
async function renderDuplicateAlert() {
|
const form = document.getElementById("customerForm");
|
const el = document.getElementById("duplicateAlert");
|
if (!form || !el) return [];
|
const body = Object.fromEntries(new FormData(form).entries());
|
const rows = await getDuplicateCandidates(body);
|
if (!rows.length) {
|
el.innerHTML = "";
|
el.classList.remove("show");
|
return [];
|
}
|
el.innerHTML = `<strong>${rows.some(r => r.strong) ? "发现强重复客户,建议打开已有客户补充" : "发现疑似重复客户"}</strong>` +
|
rows.map(({ customer, reasons, strong }) => `
|
<div style="margin-top:8px;">
|
<div>${strong ? "强重复" : "疑似"}:${customer.name || "-"} / ${customer.company || "-"} | ${reasons.join("、")}</div>
|
<div class="muted">负责人:${customer.owner || "-"} | 首次录入:${customer.firstReceiver || "-"} | 来源:${customer.source || "-"} | 阶段:${customer.funnel || "-"} | 最近跟进:${customer.lastFollowupAt || "-"} | 成交:${customer.dealStatus || "-"} | 公海:${customer.pool || "-"}</div>
|
<button class="btn" type="button" style="margin-top:6px;" onclick="openExistingCustomer(${customer.id})">打开已有客户补充</button>
|
</div>
|
`).join("");
|
el.classList.add("show");
|
return rows;
|
}
|
|
function openExistingCustomer(id) {
|
if (!state.customers.some(c => c.id === id)) {
|
toast("该客户已存在,但不在当前账号可查看范围内,请联系负责人或主管补充。");
|
return;
|
}
|
setView("customers");
|
openCustomer(id);
|
}
|
|
function tag(text, cls) { return `<span class="tag ${cls}">${text || "-"}</span>`; }
|
function isDealCustomer(c) { return dealStatuses.includes(c.dealStatus); }
|
function dealTag(text) { return tag(text, text === "未成交" || text === "已流失" ? "gray-tag" : "green-tag"); }
|
function poolTag(c) { return tag(c.pool, c.pool === "公共客户池" ? "red-tag" : c.pool === "预公海" ? "amber-tag" : "green-tag"); }
|
function dealOptions(current) {
|
return ["未成交", "已成交", "待发货/执行", "已交付", "售后维护中", "复购跟进中", "已流失"]
|
.map(v => `<option ${v === current ? "selected" : ""}>${v}</option>`)
|
.join("");
|
}
|
function funnelOptions(current) {
|
return ["S1 新线索", "S2 已联系", "S3 需求确认", "S4 选型方案", "S5 已报价", "S6 比价/审批", "S7 成交/执行", "S8 暂缓/失败"]
|
.map(v => `<option ${v === current ? "selected" : ""}>${v}</option>`)
|
.join("");
|
}
|
|
function visibleCustomers() {
|
const rows = (role === "supervisor" || role === "ops") ? state.customers : state.customers.filter(c => c.owner === currentUser);
|
const keyword = (document.getElementById("search")?.value || "").trim();
|
const owner = document.getElementById("ownerFilter")?.value || "";
|
const source = document.getElementById("sourceFilter")?.value || "";
|
const category = document.getElementById("categoryFilter")?.value || "";
|
const funnel = document.getElementById("funnelFilter")?.value || "";
|
const deal = document.getElementById("dealFilter")?.value || "";
|
const pool = document.getElementById("poolFilter")?.value || "";
|
const intention = document.getElementById("intentionFilter")?.value || "";
|
const inputStart = document.getElementById("inputStartFilter")?.value || "";
|
const inputEnd = document.getElementById("inputEndFilter")?.value || "";
|
const period = document.getElementById("periodFilter")?.value || "";
|
const range = periodRange(period);
|
return rows.filter(c => {
|
const inputDate = String(c.firstInputAt || "").slice(0, 10);
|
if (keyword && !`${c.name}${c.company}${c.phone}${c.wechat}${c.platformAccount}`.includes(keyword)) return false;
|
if (owner && c.owner !== owner) return false;
|
if (source && c.source !== source) return false;
|
if (category && (c.customerCategory || c.source) !== category) return false;
|
if (funnel && c.funnel !== funnel) return false;
|
if (deal && c.dealStatus !== deal) return false;
|
if (pool && c.pool !== pool) return false;
|
if (intention && c.intention !== intention) return false;
|
if (inputStart && inputDate < inputStart) return false;
|
if (inputEnd && inputDate > inputEnd) return false;
|
if (range && (inputDate < range.start || inputDate > range.end)) return false;
|
return true;
|
});
|
}
|
|
function periodRange(period) {
|
if (!period) return null;
|
const d = new Date();
|
const pad = n => String(n).padStart(2, "0");
|
const fmt = x => `${x.getFullYear()}-${pad(x.getMonth() + 1)}-${pad(x.getDate())}`;
|
if (period === "year") return { start: `${d.getFullYear()}-01-01`, end: `${d.getFullYear()}-12-31` };
|
if (period === "month") return { start: `${d.getFullYear()}-${pad(d.getMonth() + 1)}-01`, end: fmt(new Date(d.getFullYear(), d.getMonth() + 1, 0)) };
|
const day = d.getDay() || 7;
|
const start = new Date(d);
|
start.setDate(d.getDate() - day + 1);
|
const end = new Date(start);
|
end.setDate(start.getDate() + 6);
|
return { start: fmt(start), end: fmt(end) };
|
}
|
|
function resetFilters() {
|
["search", "ownerFilter", "sourceFilter", "categoryFilter", "funnelFilter", "dealFilter", "poolFilter", "intentionFilter", "inputStartFilter", "inputEndFilter", "periodFilter"].forEach(id => {
|
const el = document.getElementById(id);
|
if (el) el.value = "";
|
});
|
renderRows();
|
}
|
|
async function loadState() {
|
if (!auth?.token) {
|
showLogin();
|
return;
|
}
|
state = await api(`/api/state?token=${encodeURIComponent(auth.token)}`);
|
if (state.currentAccount) applyAccount(state.currentAccount);
|
render();
|
}
|
|
function setView(view, feature) {
|
activeView = view;
|
activeFeature = feature || view;
|
document.querySelectorAll(".view").forEach(v => v.classList.toggle("active", v.id === view));
|
document.querySelectorAll(".nav button").forEach(b => b.classList.toggle("active", b.dataset.view === view && (b.dataset.feature || b.dataset.view) === activeFeature));
|
const titles = { dashboard: "工作台", customers: "客户列表", new: "新增客户", deals: "成交客户", salesOrders: "销售订单", production: "生产任务", stock: "库管库存", logistics: "物流发货", finance: "财务收款", pool: "公共客户池", reminders: "提醒任务", reports: "统计报表", tools: "数据工具", accounts: "账号权限" };
|
const title = featureTitles[activeFeature] || titles[view] || view;
|
document.getElementById("title").textContent = title;
|
if (!openedTabs.some(tab => tab.view === view && tab.feature === activeFeature)) {
|
openedTabs.push({ view, feature: activeFeature, title });
|
}
|
renderWorkspaceTabs();
|
renderBusinessHeader();
|
}
|
|
function renderWorkspaceTabs() {
|
const el = document.getElementById("workspaceTabs");
|
if (!el) return;
|
el.innerHTML = openedTabs.map(tab => {
|
const active = tab.view === activeView && tab.feature === activeFeature;
|
const close = tab.feature === "dashboard" ? "" : `<span class="tab-close" title="关闭" onclick="closeWorkspaceTab(event, '${tab.feature}')">×</span>`;
|
return `<button class="workspace-tab ${active ? "active" : ""}" onclick="setView('${tab.view}', '${tab.feature}')">${tab.title}${close}</button>`;
|
}).join("");
|
}
|
|
function closeWorkspaceTab(event, feature) {
|
event.stopPropagation();
|
const index = openedTabs.findIndex(tab => tab.feature === feature);
|
if (index <= 0) return;
|
const closingActive = openedTabs[index].view === activeView && openedTabs[index].feature === activeFeature;
|
openedTabs.splice(index, 1);
|
if (closingActive) {
|
const next = openedTabs[Math.max(0, index - 1)] || openedTabs[0];
|
setView(next.view, next.feature);
|
} else {
|
renderWorkspaceTabs();
|
}
|
}
|
|
function renderBusinessHeader() {
|
const title = document.getElementById("businessTitle");
|
const hint = document.getElementById("businessHint");
|
if (!title || !hint) return;
|
const featureTitle = featureTitles[activeFeature] || "销售订单";
|
title.textContent = featureTitle;
|
const hints = {
|
quote: "销售报价入口,后续可生成销售订单。",
|
salesOrder: "按智能云销售订单方式处理:审核、收款、出库、生产任务都围绕订单流转。",
|
salesBill: "销售单用于已成交发货后的正式销售记录。",
|
salesReturn: "销售退货单用于售后退货、退款和库存回流。",
|
invoice: "销售发票登记用于财务开票记录和客户对账。"
|
};
|
hint.textContent = hints[activeFeature] || "销售成交后建立订单,后续给生产、库管、物流、财务引用。";
|
}
|
|
function applyAccount(account) {
|
role = account.role;
|
currentUser = account.name;
|
const roleText = roleName(role);
|
const permissionCount = account.permissions?.length || 0;
|
document.getElementById("subtitle").textContent = role === "supervisor"
|
? `当前账号:${currentUser}。可管理账号权限和全部业务。`
|
: `当前账号:${currentUser}。已开通 ${permissionCount} 项权限,按权限显示对应功能。`;
|
document.getElementById("accountBadge").textContent = `${currentUser}|${roleText}`;
|
document.getElementById("changePasswordButton").hidden = false;
|
const accountsNav = document.getElementById("accountsNav");
|
if (accountsNav) accountsNav.style.display = hasPerm("accounts:manage") ? "" : "none";
|
if (!hasPerm("accounts:manage") && activeView === "accounts") setView("dashboard");
|
updateBusinessNav();
|
updateOwnerControls();
|
configureEntryForm();
|
}
|
|
function updateBusinessNav() {
|
const allowed = {
|
dashboard: true,
|
customers: hasPerm("customers:own") || hasPerm("customers:all"),
|
new: hasPerm("customers:own") || hasPerm("customers:all") || hasPerm("ops:edit"),
|
deals: hasPerm("sales:edit") || hasPerm("finance:edit"),
|
salesOrders: hasPerm("orders:view") || hasPerm("orders:edit"),
|
production: hasPerm("production:edit"),
|
stock: hasPerm("stock:edit"),
|
logistics: hasPerm("logistics:edit"),
|
finance: hasPerm("finance:edit"),
|
pool: hasPerm("customers:assign") || hasPerm("customers:own") || hasPerm("customers:all"),
|
reminders: true,
|
reports: hasPerm("reports:view"),
|
tools: hasPerm("customers:export") || hasPerm("customers:delete"),
|
accounts: hasPerm("accounts:manage")
|
};
|
document.querySelectorAll(".nav button").forEach(button => {
|
const canShow = allowed[button.dataset.view] !== false;
|
button.dataset.allowedHidden = canShow ? "0" : "1";
|
button.style.display = canShow ? "" : "none";
|
});
|
if (allowed[activeView] === false) setView("dashboard");
|
syncCollapsedNav();
|
}
|
|
function initNavCollapse() {
|
const modules = [...document.querySelectorAll(".menu-module")];
|
modules.forEach((module, index) => {
|
module.dataset.section = String(index);
|
module.setAttribute("role", "button");
|
module.setAttribute("tabindex", "0");
|
let node = module.nextElementSibling;
|
while (node && !node.classList.contains("menu-module")) {
|
node.dataset.section = String(index);
|
node = node.nextElementSibling;
|
}
|
if (index > 0) module.classList.add("collapsed");
|
const toggle = () => {
|
module.classList.toggle("collapsed");
|
syncCollapsedNav();
|
};
|
module.addEventListener("click", toggle);
|
module.addEventListener("keydown", (event) => {
|
if (event.key === "Enter" || event.key === " ") {
|
event.preventDefault();
|
toggle();
|
}
|
});
|
});
|
syncCollapsedNav();
|
}
|
|
function syncCollapsedNav() {
|
document.querySelectorAll(".menu-module").forEach(module => {
|
const hidden = module.classList.contains("collapsed");
|
const section = module.dataset.section;
|
document.querySelectorAll(`.nav [data-section="${section}"]`).forEach(item => {
|
if (item !== module) item.style.display = hidden ? "none" : (item.dataset.allowedHidden === "1" ? "none" : "");
|
});
|
});
|
}
|
|
function configureEntryForm() {
|
const showOps = role === "ops" || role === "supervisor";
|
const showSales = role === "sales" || role === "supervisor";
|
document.querySelectorAll(".ops-entry").forEach(el => { el.style.display = showOps ? "" : "none"; });
|
document.querySelectorAll(".sales-entry").forEach(el => { el.style.display = showSales ? "" : "none"; });
|
const title = document.getElementById("entryModeTitle");
|
if (title) title.textContent = role === "ops" ? "运营录入线索" : role === "sales" ? "销售录入客户" : "主管录入客户/线索";
|
const formTitle = document.getElementById("newFormTitle");
|
if (formTitle) formTitle.textContent = role === "ops" ? "新增运营线索" : role === "sales" ? "新增销售客户" : "新增客户/线索";
|
const formHint = document.getElementById("newFormHint");
|
if (formHint) formHint.textContent = role === "ops" ? "运营只录来源归因,客户资料后续由销售补充" : role === "sales" ? "销售只录客户资料和跟进信息,运营来源可后续补充" : "主管可同时录入销售资料和运营来源";
|
const saveBtn = document.getElementById("saveCustomerBtn");
|
if (saveBtn) saveBtn.textContent = role === "ops" ? "保存运营线索" : "保存客户";
|
const nameInput = document.querySelector('#customerForm [name="name"]');
|
const nextInput = document.querySelector('#customerForm [name="nextFollowupAt"]');
|
if (nameInput) nameInput.required = role !== "ops";
|
if (nextInput) nextInput.required = role !== "ops";
|
const opsOwner = document.querySelector('#customerForm [name="opsOwner"]');
|
if (opsOwner && role === "ops" && !opsOwner.value) opsOwner.value = currentUser;
|
}
|
|
function showLogin() {
|
document.getElementById("loginScreen").classList.add("active");
|
}
|
|
function hideLogin() {
|
document.getElementById("loginScreen").classList.remove("active");
|
}
|
|
function logout(message = "", notice = false) {
|
closePasswordChangeModal({ force: true, restoreFocus: false });
|
auth = null;
|
localStorage.removeItem(accountStoreKey);
|
state = { customers: [], reminders: [] };
|
role = "sales";
|
currentUser = "";
|
activeView = "dashboard";
|
activeFeature = "dashboard";
|
openedTabs = [{ view: "dashboard", feature: "dashboard", title: "工作台" }];
|
closeDrawer();
|
document.getElementById("accountBadge").textContent = "未登录";
|
document.getElementById("changePasswordButton").hidden = true;
|
document.getElementById("subtitle").textContent = "请先登录账号。";
|
document.querySelectorAll(".view").forEach(view => view.classList.toggle("active", view.id === "dashboard"));
|
document.querySelectorAll(".nav button").forEach(button => button.classList.toggle("active", button.dataset.view === "dashboard"));
|
document.getElementById("title").textContent = "工作台";
|
render();
|
const loginForm = document.getElementById("loginForm");
|
loginForm.elements.password.value = "";
|
const loginAlert = document.getElementById("loginAlert");
|
loginAlert.textContent = message;
|
loginAlert.classList.toggle("show", !!message);
|
loginAlert.classList.toggle("notice", !!message && notice);
|
showLogin();
|
setTimeout(() => loginForm.elements.username.focus(), 0);
|
}
|
|
function updateOwnerControls() {
|
const users = state.allUsers || [
|
{ name: "销售一", role: "sales" },
|
{ name: "销售二", role: "sales" },
|
{ name: "销售三", role: "sales" }
|
];
|
const sales = users.filter(user => user.role === "sales" && user.status !== "disabled").map(user => user.name);
|
const ownerFilter = document.getElementById("ownerFilter");
|
if (ownerFilter) {
|
ownerFilter.innerHTML = (role === "supervisor" || role === "ops")
|
? `<option value="">全部</option>${sales.map(name => `<option>${name}</option>`).join("")}`
|
: `<option>${currentUser}</option>`;
|
ownerFilter.disabled = !(role === "supervisor" || role === "ops");
|
}
|
const firstReceiver = document.querySelector('[name="firstReceiver"]');
|
if (firstReceiver && !firstReceiver.value) firstReceiver.value = currentUser;
|
}
|
|
function renderStats() {
|
const rows = visibleCustomers();
|
const stats = (role === "supervisor" || role === "ops")
|
? [["全部客户", state.customers.length], ["成交客户", state.customers.filter(isDealCustomer).length], ["公海客户", state.customers.filter(c => c.pool === "公共客户池").length], ["提醒任务", state.reminders.length]]
|
: [["我的客户", rows.length], ["今日提醒", state.reminders.length], ["成交客户", rows.filter(isDealCustomer).length], ["公海客户", rows.filter(c => c.pool === "公共客户池").length]];
|
document.getElementById("stats").innerHTML = stats.map(s => `<div class="stat"><div class="label">${s[0]}</div><div class="num">${s[1]}</div></div>`).join("");
|
}
|
|
function renderRows() {
|
const rows = visibleCustomers();
|
document.getElementById("recentRows").innerHTML = rows.slice(0, 6).map(c => `<tr><td><strong>${c.name}</strong><div class="muted">${c.company}</div></td><td>${c.owner}</td><td>${tag(c.funnel, "blue-tag")}</td><td>${dealTag(c.dealStatus)}</td><td>${c.nextFollowupAt || "-"}</td><td><button class="btn" onclick="openCustomer(${c.id})">详情</button></td></tr>`).join("");
|
const summary = document.getElementById("customerSummary");
|
if (summary) {
|
const deals = rows.filter(isDealCustomer).length;
|
const pools = rows.filter(c => c.pool === "公共客户池").length;
|
summary.textContent = `当前筛选结果:${rows.length} 个客户,成交 ${deals} 个,公海 ${pools} 个。序号按当前筛选结果自动排列,便于周/月/年统计。`;
|
}
|
const totalPages = Math.max(1, Math.ceil(rows.length / customerPageSize));
|
customerPage = Math.min(customerPage, totalPages);
|
const start = (customerPage - 1) * customerPageSize;
|
const pageRows = rows.slice(start, start + customerPageSize);
|
document.getElementById("customerRows").innerHTML = pageRows.map((c, index) => `<tr><td><input type="checkbox" class="customerCheck" value="${c.id}" style="width:auto;"></td><td>${start + index + 1}</td><td><strong>${c.name}</strong><div class="muted">ID:${c.id} | ${c.company}</div></td><td>${c.phone || "-"}<div class="muted">${c.wechat || c.platformAccount || ""}</div></td><td>${c.source || "-"}</td><td>${c.customerCategory || c.source || "-"}</td><td>${c.owner}</td><td>${c.firstReceiver}<div class="muted">${c.firstInputAt}</div></td><td>${tag(c.funnel, "blue-tag")}</td><td>${dealTag(c.dealStatus)}</td><td>${poolTag(c)}<div class="muted">${c.protect}</div></td><td><button class="btn" onclick="openCustomer(${c.id})">详情</button></td></tr>`).join("");
|
const pageInfo = document.getElementById("pageInfo");
|
if (pageInfo) pageInfo.textContent = `第 ${customerPage} / ${totalPages} 页,共 ${rows.length} 条`;
|
const dealKeyword = (document.getElementById("dealSearch")?.value || "").trim();
|
const dealStatus = document.getElementById("dealStatusFilter")?.value || "";
|
const dealStar = document.getElementById("dealStarFilter")?.value || "";
|
const deals = rows.filter(isDealCustomer).filter(c => (!dealKeyword || `${c.name}${c.company}${c.phone}${c.wechat}`.includes(dealKeyword)) && (!dealStatus || c.dealStatus === dealStatus) && (!dealStar || (c.revisitLevel || "铜") === dealStar));
|
document.getElementById("dealRows").innerHTML = deals.length ? deals.map(c => `<tr><td><strong>${c.name}</strong><div class="muted">${c.company}</div></td><td>${dealTag(c.dealStatus)}</td><td>${tag(c.revisitLevel || "铜", (c.revisitLevel || "铜") === "金" ? "amber-tag" : (c.revisitLevel || "铜") === "银" ? "blue-tag" : (c.revisitLevel || "铜") === "铁" ? "gray-tag" : "green-tag")}</td><td>${c.dealProduct || "-"}<div class="muted">${c.dealQuantity || "-"} × ${c.dealUnitPrice || "-"} = ${c.dealTotalPrice || c.dealAmount || "-"}</div><div class="muted">${c.dealAt || "-"}</div></td><td>${c.owner}</td><td>${c.firstReceiver}<div class="muted">${c.firstInputAt}</div></td><td>${c.nextDealRevisit || ((c.dealRevisits || []).length ? "-" : "待首次回访")}</td><td><button class="btn" onclick="openCustomer(${c.id})">回访</button></td></tr>`).join("") : `<tr><td colspan="8" class="muted">暂无成交客户</td></tr>`;
|
const pools = state.customers.filter(c => c.pool === "公共客户池");
|
const salesOptions = (state.allUsers || []).filter(user => user.role === "sales" && user.status !== "disabled").map(user => `<option>${user.name}</option>`).join("") || `<option>销售一</option>`;
|
document.getElementById("poolRows").innerHTML = pools.length ? pools.map(c => `<tr><td><strong>${c.name}</strong><div class="muted">${c.company}</div></td><td>${c.firstReceiver}<div class="muted">${c.firstInputAt}</div></td><td>${c.previousOwner || c.owner}</td><td>${c.poolReason || "-"}</td><td>${c.releasedAt || "-"}</td><td>${role === "supervisor" ? `<select id="assignOwner_${c.id}" style="width:94px;margin-right:6px;">${salesOptions}</select><button class="btn primary" onclick="assignCustomer(${c.id})">分配</button>` : `<span class="muted">等待主管分配</span>`}</td></tr>`).join("") : `<tr><td colspan="6" class="muted">暂无公海客户</td></tr>`;
|
}
|
|
function renderReminders() {
|
const html = state.reminders.length ? state.reminders.map(r => `<div class="item"><div><strong>${r.type}:${r.customer}</strong><div class="muted">${r.text}</div></div><div class="toolbar">${r.customerId ? `<button class="btn" onclick="openCustomer(${r.customerId})">处理</button>` : ""}${r.manual ? `<button class="btn green" onclick="completeReminder(${r.id})">完成</button>` : ""}</div></div>`).join("") : `<div class="item"><div><strong>暂无提醒</strong><div class="muted">今天没有需要处理的任务</div></div></div>`;
|
document.getElementById("dashReminders").innerHTML = html;
|
document.getElementById("reminderList").innerHTML = html;
|
}
|
|
function renderReports() {
|
const customers = state.customers || [];
|
const total = customers.length || 1;
|
const funnels = ["S1 新线索", "S2 已联系", "S3 需求确认", "S4 选型方案", "S5 已报价", "S6 比价/审批", "S7 成交/执行", "S8 暂缓/失败"];
|
const funnelEl = document.getElementById("funnelReportRows");
|
if (funnelEl) funnelEl.innerHTML = funnels.map(f => {
|
const count = customers.filter(c => c.funnel === f).length;
|
const pct = Math.round(count / total * 100);
|
return `<tr><td>${f}</td><td>${count}</td><td><div class="bar"><span style="width:${pct}%"></span></div><div class="muted">${pct}%</div></td></tr>`;
|
}).join("");
|
const owners = [...new Set(customers.map(c => c.owner || "未分配"))];
|
const salesEl = document.getElementById("salesReportRows");
|
if (salesEl) salesEl.innerHTML = owners.map(owner => {
|
const rows = customers.filter(c => (c.owner || "未分配") === owner);
|
const deals = rows.filter(isDealCustomer).length;
|
return `<tr><td>${owner}</td><td>${rows.length}</td><td>${deals}<div class="bar"><span style="width:${rows.length ? Math.round(deals / rows.length * 100) : 0}%"></span></div></td><td>${rows.filter(c => c.pool === "公共客户池").length}</td></tr>`;
|
}).join("");
|
const sources = [...new Set(customers.map(c => c.source || "未填写"))];
|
const sourceEl = document.getElementById("sourceReportRows");
|
if (sourceEl) sourceEl.innerHTML = sources.map(source => {
|
const rows = customers.filter(c => (c.source || "未填写") === source);
|
const deals = rows.filter(isDealCustomer).length;
|
const pct = rows.length ? Math.round(deals / rows.length * 100) : 0;
|
const free = rows.filter(c => (c.trafficCostType || "免费") === "免费").length;
|
const paid = rows.filter(c => c.trafficCostType === "付费").length;
|
return `<tr><td>${source}</td><td>${rows.length}</td><td>免费 ${free} / 付费 ${paid}</td><td>${deals}</td><td><div class="bar"><span style="width:${pct}%"></span></div><div class="muted">${pct}%</div></td></tr>`;
|
}).join("");
|
const opsEl = document.getElementById("opsReportRows");
|
if (opsEl) {
|
const keys = [];
|
customers.forEach(c => {
|
if (c.keyword) keys.push(`关键词:${c.keyword}`);
|
if (c.searchTerm) keys.push(`搜索词:${c.searchTerm}`);
|
if (c.opRegion || c.region) keys.push(`客户地区:${c.opRegion || c.region}`);
|
if (c.customerType) keys.push(`类型:${c.customerType}`);
|
});
|
const uniq = [...new Set(keys)].slice(0, 30);
|
opsEl.innerHTML = uniq.length ? uniq.map(key => {
|
const [kind, value] = key.split(":");
|
const rows = customers.filter(c =>
|
(kind === "关键词" && c.keyword === value) ||
|
(kind === "搜索词" && c.searchTerm === value) ||
|
(kind === "客户地区" && (c.opRegion || c.region) === value) ||
|
(kind === "类型" && c.customerType === value)
|
);
|
const deals = rows.filter(isDealCustomer).length;
|
const pct = rows.length ? Math.round(deals / rows.length * 100) : 0;
|
return `<tr><td>${key}</td><td>${rows.length}</td><td>${deals}</td><td><div class="bar"><span style="width:${pct}%"></span></div><div class="muted">${pct}%</div></td></tr>`;
|
}).join("") : `<tr><td colspan="4" class="muted">暂无运营来源数据</td></tr>`;
|
}
|
const months = [...new Set(customers.map(c => String(c.firstInputAt || "").slice(0, 7)).filter(Boolean))].sort();
|
const monthEl = document.getElementById("monthReportRows");
|
if (monthEl) monthEl.innerHTML = months.map(month => {
|
const rows = customers.filter(c => String(c.firstInputAt || "").startsWith(month));
|
return `<tr><td>${month}</td><td>${rows.length}</td><td>${rows.filter(isDealCustomer).length}</td></tr>`;
|
}).join("");
|
}
|
|
function roleName(roleValue) {
|
const names = { supervisor: "主管", ops: "运营", sales: "销售", production: "生产", warehouse: "库管", logistics: "物流", finance: "财务" };
|
return names[roleValue] || roleValue || "未知";
|
}
|
|
function statusName(status) {
|
return status === "disabled" ? "停用" : "启用";
|
}
|
|
function permissionText(account) {
|
const permissions = account.permissions?.length ? account.permissions : defaultPermissionSet(account.role);
|
return permissions.map(code => permissionLabels[code] || code).join(" / ");
|
}
|
|
function renderPermissionChecks(selected = []) {
|
const el = document.getElementById("permissionChecks");
|
if (!el) return;
|
const selectedSet = new Set(selected);
|
el.innerHTML = permissionCatalog.map(([code, label]) => `
|
<label><input type="checkbox" name="permissions" value="${code}" ${selectedSet.has(code) ? "checked" : ""}>${label}</label>
|
`).join("");
|
}
|
|
function setPermissionChecks(selected = []) {
|
const selectedSet = new Set(selected);
|
document.querySelectorAll('#permissionChecks input[name="permissions"]').forEach(input => {
|
input.checked = selectedSet.has(input.value);
|
});
|
}
|
|
function checkedPermissions() {
|
return [...document.querySelectorAll('#permissionChecks input[name="permissions"]:checked')].map(input => input.value);
|
}
|
|
function renderAccounts() {
|
const el = document.getElementById("accountRows");
|
if (!el) return;
|
const accounts = state.allUsers || [];
|
el.innerHTML = accounts.length ? accounts.map(account => `
|
<tr>
|
<td><strong>${account.username}</strong><div class="muted">${account.phone || ""}</div></td>
|
<td>${account.name}</td>
|
<td>${tag(roleName(account.role), account.role === "supervisor" ? "amber-tag" : account.role === "ops" ? "blue-tag" : "green-tag")}</td>
|
<td>${tag(statusName(account.status), account.status === "disabled" ? "red-tag" : "green-tag")}</td>
|
<td>${permissionText(account)}</td>
|
<td class="toolbar">
|
<button class="btn" onclick="editAccount('${account.username}')">编辑</button>
|
${account.username === auth?.user?.username ? `<span class="muted">当前账号</span>` : `<button class="btn ${account.status === "disabled" ? "green" : "red"}" onclick="toggleAccountStatus('${account.username}', '${account.status === "disabled" ? "active" : "disabled"}')">${account.status === "disabled" ? "启用" : "停用"}</button>`}
|
</td>
|
</tr>
|
`).join("") : `<tr><td colspan="6" class="muted">暂无账号数据</td></tr>`;
|
renderTransferControls();
|
}
|
|
function renderTransferControls() {
|
const fromEl = document.getElementById("transferFrom");
|
const toEl = document.getElementById("transferTo");
|
if (!fromEl || !toEl) return;
|
const accounts = state.allUsers || [];
|
const allSales = accounts.filter(account => account.role === "sales");
|
const activeSales = accounts.filter(account => account.role === "sales" && account.status !== "disabled");
|
const customerCounts = {};
|
(state.customers || []).forEach(customer => {
|
customerCounts[customer.owner || ""] = (customerCounts[customer.owner || ""] || 0) + 1;
|
});
|
fromEl.innerHTML = allSales.map(account => `<option value="${account.name}">${account.name}(${customerCounts[account.name] || 0}个客户${account.status === "disabled" ? ",已停用" : ""})</option>`).join("");
|
toEl.innerHTML = activeSales.map(account => `<option value="${account.name}">${account.name}</option>`).join("");
|
const hint = document.getElementById("transferHint");
|
if (hint) hint.textContent = "建议流程:先把离职员工名下客户转接给新负责人,再停用该员工账号。";
|
}
|
|
function editAccount(username) {
|
const account = (state.allUsers || []).find(row => row.username === username);
|
const form = document.getElementById("accountForm");
|
if (!account || !form) return;
|
form.elements.username.value = account.username;
|
form.elements.name.value = account.name;
|
form.elements.role.value = account.role;
|
form.elements.status.value = account.status || "active";
|
form.elements.password.value = "";
|
form.elements.username.readOnly = true;
|
setPermissionChecks(account.permissions?.length ? account.permissions : defaultPermissionSet(account.role));
|
const hint = document.getElementById("accountEditHint");
|
if (hint) hint.textContent = `正在编辑:${account.name}(${account.username})`;
|
form.scrollIntoView({ behavior: "smooth", block: "start" });
|
form.elements.name.focus();
|
toast("已进入编辑模式,修改后点击保存账号");
|
}
|
|
function resetAccountForm() {
|
const form = document.getElementById("accountForm");
|
if (!form) return;
|
form.reset();
|
form.elements.username.readOnly = false;
|
form.elements.status.value = "active";
|
form.elements.role.value = "sales";
|
setPermissionChecks(defaultPermissionSet("sales"));
|
const hint = document.getElementById("accountEditHint");
|
if (hint) hint.textContent = "新增账号";
|
}
|
|
async function toggleAccountStatus(username, status) {
|
await api("/api/accounts/status", { method: "POST", body: JSON.stringify({ username, status }) });
|
toast(status === "disabled" ? "账号已停用" : "账号已启用");
|
loadState();
|
}
|
|
async function transferCustomers() {
|
if (role !== "supervisor") {
|
toast("只有主管可以转接客户");
|
return;
|
}
|
const from = document.getElementById("transferFrom")?.value || "";
|
const to = document.getElementById("transferTo")?.value || "";
|
if (!from || !to || from === to) {
|
toast("请选择不同的原负责人和新负责人");
|
return;
|
}
|
if (!confirm(`确认把 ${from} 名下全部客户转接给 ${to}?`)) return;
|
const result = await api("/api/accounts/transfer", { method: "POST", body: JSON.stringify({ from, to }) });
|
toast(`已转接 ${result.count || 0} 个客户`);
|
await loadState();
|
}
|
|
function businessRows(kind) {
|
return Array.isArray(state[kind]) ? state[kind] : [];
|
}
|
|
function moneyText(value) {
|
const num = Number(value || 0);
|
return num ? `¥${num.toFixed(2)}` : "-";
|
}
|
|
function statusSelect(kind, id, current, options) {
|
return `<select onchange="updateBusinessStatus('${kind}', ${id}, this.value)" style="min-width:110px;">${options.map(option => `<option ${option === current ? "selected" : ""}>${option}</option>`).join("")}</select>`;
|
}
|
|
function renderBusinessModules() {
|
const salesOrderRows = document.getElementById("salesOrderRows");
|
if (salesOrderRows) {
|
const statuses = ["待确认", "待生产", "待发货", "已发货", "已完成", "已取消"];
|
const customerKeyword = (document.getElementById("orderCustomerFilter")?.value || "").trim();
|
const productKeyword = (document.getElementById("orderProductFilter")?.value || "").trim();
|
const ownerKeyword = (document.getElementById("orderOwnerFilter")?.value || "").trim();
|
const audit = document.getElementById("orderAuditFilter")?.value || "";
|
const outbound = document.getElementById("orderOutboundFilter")?.value || "";
|
const startDate = document.getElementById("orderDateStart")?.value || "";
|
const endDate = document.getElementById("orderDateEnd")?.value || "";
|
const rows = businessRows("salesOrders").filter(row => {
|
const createdDate = String(row.createdAt || "").slice(0, 10);
|
return (!customerKeyword || `${row.customer || ""}${row.customerOrderNo || ""}`.includes(customerKeyword))
|
&& (!productKeyword || `${row.product || ""}`.includes(productKeyword))
|
&& (!ownerKeyword || `${row.owner || ""}${row.createdBy || ""}`.includes(ownerKeyword))
|
&& (!audit || (row.auditStatus || "未审核") === audit)
|
&& (!outbound || (row.outboundStatus || "未出库") === outbound)
|
&& (!startDate || createdDate >= startDate)
|
&& (!endDate || createdDate <= endDate);
|
});
|
salesOrderRows.innerHTML = rows.length ? rows.map(row => {
|
const total = row.totalPrice || (Number(row.quantity || 0) * Number(row.unitPrice || 0));
|
const received = row.receivedAmount || row.depositAmount || "";
|
const unpaid = total ? Math.max(0, Number(total || 0) - Number(received || 0)) : "";
|
return `<tr><td><div class="toolbar"><span class="erp-action" onclick="copyOrderId(${row.id})">查看</span><span class="erp-action" onclick="openSalesBillFromOrder(${row.id})">销售单</span><span class="erp-action" onclick="generateFromOrder(${row.id}, 'production')">生产</span><span class="erp-action" onclick="generateFromOrder(${row.id}, 'logistics')">物流</span><span class="erp-action" onclick="generateFromOrder(${row.id}, 'finance')">应收</span><span class="erp-action" onclick="deleteBusiness('salesOrders', ${row.id})">删除</span></div></td><td>XSDD${String(row.id).padStart(6, "0")}</td><td>${String(row.createdAt || "").slice(0, 10) || "-"}</td><td><strong>${row.customer || "-"}</strong><div class="muted">${row.product || ""}</div></td><td>${row.customerOrderNo || "-"}</td><td>${row.quantity || "-"}</td><td>${moneyText(total)}</td><td>${moneyText(row.depositAmount)}</td><td>${row.deliveryDate || "-"}</td><td>${row.owner || row.createdBy || "-"}</td><td>${statusSelect("salesOrders", row.id, row.status || "待确认", statuses)}</td><td>${tag(row.auditStatus || "未审核", (row.auditStatus || "未审核") === "已审核" ? "green-tag" : "amber-tag")}</td><td>${moneyText(received)}</td><td>${moneyText(unpaid)}</td><td>${row.salesBillStatus || "未生成"}${row.salesBillNo ? `<div class="muted">${row.salesBillNo}</div>` : ""}</td><td>${row.outboundStatus || "未出库"}</td><td>${row.productionStatus || "未生成"}</td><td>${row.remark || ""}</td><td>${row.createdBy || "-"}</td><td>${row.createdAt || "-"}</td></tr>`;
|
}).join("") : `<tr><td colspan="20" class="muted">暂无销售订单</td></tr>`;
|
}
|
const productionRows = document.getElementById("productionRows");
|
if (productionRows) {
|
const statuses = ["待排产", "生产中", "已完成", "异常"];
|
const rows = businessRows("productionTasks");
|
productionRows.innerHTML = rows.length ? rows.map(row => `<tr><td>#${row.id}</td><td>${row.orderId || "-"}</td><td>${row.product || "-"}</td><td>${row.quantity || "-"}</td><td>${row.dueDate || "-"}</td><td>${statusSelect("productionTasks", row.id, row.status || "待排产", statuses)}</td><td>${row.owner || row.createdBy || "-"}</td><td><span class="muted">${row.remark || ""}</span> <span class="erp-action" onclick="deleteBusiness('productionTasks', ${row.id})">删除</span></td></tr>`).join("") : `<tr><td colspan="8" class="muted">暂无生产任务</td></tr>`;
|
}
|
const stockRows = document.getElementById("stockRows");
|
if (stockRows) {
|
const statuses = ["正常", "预警", "缺货"];
|
const rows = businessRows("stockItems");
|
stockRows.innerHTML = rows.length ? rows.map(row => `<tr><td>#${row.id}</td><td>${row.product || "-"}</td><td>${row.quantity || "0"}</td><td>${row.safeQuantity || "0"}</td><td>${row.transitQuantity || "0"}</td><td>${row.serialNo || "-"}</td><td>${statusSelect("stockItems", row.id, row.status || "正常", statuses)}</td><td><span class="muted">${row.remark || ""}</span> <span class="erp-action" onclick="deleteBusiness('stockItems', ${row.id})">删除</span></td></tr>`).join("") : `<tr><td colspan="8" class="muted">暂无库存记录</td></tr>`;
|
}
|
const logisticsRows = document.getElementById("logisticsRows");
|
if (logisticsRows) {
|
const statuses = ["待发货", "已发货", "已签收", "异常"];
|
const rows = businessRows("shipments");
|
logisticsRows.innerHTML = rows.length ? rows.map(row => `<tr><td>#${row.id}</td><td>${row.orderId || "-"}</td><td>${row.company || "-"}</td><td>${row.trackingNo || "-"}</td><td>${row.shipDate || "-"}</td><td>${statusSelect("shipments", row.id, row.status || "待发货", statuses)}</td><td>${row.address || "-"}</td><td><span class="muted">${row.remark || ""}</span> <span class="erp-action" onclick="deleteBusiness('shipments', ${row.id})">删除</span></td></tr>`).join("") : `<tr><td colspan="8" class="muted">暂无物流记录</td></tr>`;
|
}
|
const financeRows = document.getElementById("financeRows");
|
if (financeRows) {
|
const statuses = ["待收款", "部分收款", "已收款"];
|
const rows = businessRows("receivables");
|
financeRows.innerHTML = rows.length ? rows.map(row => `<tr><td>#${row.id}</td><td>${row.orderId || "-"}</td><td>${moneyText(row.receivableAmount)}</td><td>${moneyText(row.receivedAmount)}</td><td>${moneyText(row.unpaidAmount)}</td><td>${row.invoiceStatus || "-"}</td><td>${statusSelect("receivables", row.id, row.status || "待收款", statuses)}</td><td><span class="muted">${row.nextReminderAt || ""}</span> <span class="erp-action" onclick="deleteBusiness('receivables', ${row.id})">删除</span></td></tr>`).join("") : `<tr><td colspan="8" class="muted">暂无财务记录</td></tr>`;
|
}
|
}
|
|
function copyOrderId(id) {
|
navigator.clipboard?.writeText(String(id));
|
toast(`订单号 #${id} 已复制`);
|
}
|
|
function focusBusinessForm(formId) {
|
const form = document.getElementById(formId);
|
if (!form) return;
|
form.scrollIntoView({ behavior: "smooth", block: "start" });
|
const firstInput = form.querySelector("input, select, textarea");
|
if (firstInput) firstInput.focus();
|
}
|
|
function latestOrder() {
|
return businessRows("salesOrders")[0] || null;
|
}
|
|
function orderNo(row) {
|
return `XSDD${String(row.id).padStart(6, "0")}`;
|
}
|
|
function billNo(row) {
|
return `XSD${new Date().getFullYear().toString().slice(2)}${String(row.id).padStart(8, "0")}`;
|
}
|
|
function todayText() {
|
return new Date().toISOString().slice(0, 10);
|
}
|
|
function openSalesBillFromLatest() {
|
const source = latestOrder();
|
if (!source) {
|
toast("暂无可生成销售单的销售订单");
|
return;
|
}
|
openSalesBillFromOrder(source.id);
|
}
|
|
function openSalesBillFromOrder(id) {
|
const order = businessRows("salesOrders").find(row => row.id === Number(id));
|
if (!order) {
|
toast("销售订单不存在");
|
return;
|
}
|
const total = Number(order.totalPrice || (Number(order.quantity || 0) * Number(order.unitPrice || 0)) || 0);
|
const received = Number(order.receivedAmount || order.depositAmount || 0);
|
const qty = Number(order.quantity || 1) || 1;
|
const unitPrice = Number(order.unitPrice || (qty ? total / qty : 0) || 0);
|
document.getElementById("billOrderId").value = order.id;
|
document.getElementById("billDate").value = todayText();
|
document.getElementById("billCustomer").value = order.customer || "";
|
document.getElementById("billCustomerOrderNo").value = order.customerOrderNo || "";
|
document.getElementById("billProject").value = order.project || "";
|
document.getElementById("billNo").value = order.salesBillNo || `XSD${new Date().getFullYear().toString().slice(2)}${String(order.id).padStart(8, "0")}`;
|
document.getElementById("billProductCode").value = order.productCode || `G${String(order.id).padStart(4, "0")}`;
|
document.getElementById("billProduct").value = order.product || "";
|
document.getElementById("billModel").value = order.model || order.product || "";
|
document.getElementById("billOrderQty").value = qty;
|
document.getElementById("billSoldQty").value = order.salesBillStatus === "全部生成" ? qty : 0;
|
document.getElementById("billQty").value = qty;
|
document.getElementById("billUnitPrice").value = unitPrice ? unitPrice.toFixed(2) : "";
|
document.getElementById("billCashReceived").value = received ? received.toFixed(2) : "";
|
document.getElementById("billSalesperson").value = order.owner || order.createdBy || currentUser;
|
document.getElementById("billMaker").value = currentUser;
|
document.getElementById("billRemark").value = order.remark || "";
|
calcSalesBill();
|
document.getElementById("salesBillModal").classList.add("active");
|
}
|
|
function closeSalesBillModal() {
|
document.getElementById("salesBillModal").classList.remove("active");
|
}
|
|
function fillBillSalesperson() {
|
document.getElementById("billSalesperson").value = currentUser;
|
}
|
|
function calcSalesBill() {
|
const qty = Number(document.getElementById("billQty")?.value || 0);
|
const unitPrice = Number(document.getElementById("billUnitPrice")?.value || 0);
|
const rate = Number(document.getElementById("billDiscountRate")?.value || 100);
|
const otherFee = Number(document.getElementById("billOtherFee")?.value || 0);
|
const discountMoney = Number(document.getElementById("billDiscountMoney")?.value || 0);
|
const cash = Number(document.getElementById("billCashReceived")?.value || 0);
|
const amount = qty * unitPrice;
|
const discountPrice = unitPrice * rate / 100;
|
const discountAmount = qty * discountPrice;
|
const grandTotal = Math.max(0, discountAmount + otherFee - discountMoney);
|
const unpaid = Math.max(0, grandTotal - cash);
|
const setValue = (id, value) => {
|
const el = document.getElementById(id);
|
if (el) el.value = value ? value.toFixed(2) : "0.00";
|
};
|
setValue("billAmount", amount);
|
setValue("billDiscountPrice", discountPrice);
|
setValue("billDiscountAmount", discountAmount);
|
setValue("billSumAmount", discountAmount);
|
setValue("billGrandTotal", grandTotal);
|
setValue("billUnpaid", unpaid);
|
}
|
|
async function saveSalesBill(audit) {
|
const orderId = Number(document.getElementById("billOrderId").value);
|
const order = businessRows("salesOrders").find(row => row.id === orderId);
|
if (!order) {
|
toast("销售订单不存在,不能保存销售单");
|
return;
|
}
|
calcSalesBill();
|
const bill = {
|
orderId: orderNo(order),
|
sourceOrderId: order.id,
|
billNo: document.getElementById("billNo").value,
|
billDate: document.getElementById("billDate").value,
|
warehouse: document.getElementById("billWarehouse").value,
|
shipMethod: document.getElementById("billShipMethod").value,
|
customer: document.getElementById("billCustomer").value,
|
customerOrderNo: document.getElementById("billCustomerOrderNo").value,
|
project: document.getElementById("billProject").value,
|
productCode: document.getElementById("billProductCode").value,
|
product: document.getElementById("billProduct").value,
|
model: document.getElementById("billModel").value,
|
orderQuantity: document.getElementById("billOrderQty").value,
|
quantity: document.getElementById("billQty").value,
|
unit: document.getElementById("billUnit").value,
|
unitPrice: document.getElementById("billUnitPrice").value,
|
amount: document.getElementById("billAmount").value,
|
discountRate: document.getElementById("billDiscountRate").value,
|
discountPrice: document.getElementById("billDiscountPrice").value,
|
discountAmount: document.getElementById("billDiscountAmount").value,
|
gift: document.getElementById("billGift").value,
|
brand: document.getElementById("billBrand").value,
|
shelf: document.getElementById("billShelf").value,
|
itemRemark: document.getElementById("billItemRemark").value,
|
remark: document.getElementById("billRemark").value,
|
settleAccount: document.getElementById("billSettleAccount").value,
|
cashReceived: document.getElementById("billCashReceived").value,
|
otherFee: document.getElementById("billOtherFee").value,
|
discountMoney: document.getElementById("billDiscountMoney").value,
|
unpaidAmount: document.getElementById("billUnpaid").value,
|
totalAmount: document.getElementById("billGrandTotal").value,
|
salesperson: document.getElementById("billSalesperson").value,
|
maker: document.getElementById("billMaker").value,
|
auditStatus: audit ? "已审核" : "未审核",
|
status: audit ? "已审核" : "草稿"
|
};
|
await api("/api/business/create", { method: "POST", body: JSON.stringify({ kind: "salesBills", row: bill }) });
|
await api("/api/business/update", {
|
method: "POST",
|
body: JSON.stringify({
|
kind: "salesOrders",
|
id: order.id,
|
patch: {
|
salesBillStatus: "全部生成",
|
salesBillNo: bill.billNo,
|
auditStatus: audit ? "已审核" : (order.auditStatus || "未审核"),
|
status: audit ? "待发货" : (order.status || "待确认"),
|
receivedAmount: bill.cashReceived,
|
unpaidAmount: bill.unpaidAmount,
|
settlementAccount: bill.settleAccount,
|
outboundWarehouse: bill.warehouse,
|
salesperson: bill.salesperson
|
}
|
})
|
});
|
toast(audit ? "销售单已保存并审核" : "销售单已保存");
|
closeSalesBillModal();
|
await loadState();
|
}
|
|
async function copyLatestOrder() {
|
const source = latestOrder();
|
if (!source) {
|
toast("暂无可复制的销售订单");
|
return;
|
}
|
const row = { ...source, customerOrderNo: `${source.customerOrderNo || ""}-复制`, auditStatus: "未审核", status: "待确认", salesBillStatus: "未生成", outboundStatus: "未出库", productionStatus: "未生成" };
|
delete row.id;
|
delete row.createdAt;
|
delete row.createdBy;
|
delete row.updatedAt;
|
await api("/api/business/create", { method: "POST", body: JSON.stringify({ kind: "salesOrders", row }) });
|
toast("已复制最近一张销售订单");
|
await loadState();
|
}
|
|
async function generateLatestOrderDocs() {
|
const source = latestOrder();
|
if (!source) {
|
toast("暂无可生成单据的销售订单");
|
return;
|
}
|
await generateFromOrder(source.id, "production", false);
|
await generateFromOrder(source.id, "logistics", false);
|
await generateFromOrder(source.id, "finance", false);
|
toast("已生成生产任务、物流记录和应收账款");
|
await loadState();
|
}
|
|
async function generateFromLatestOrder(target) {
|
const source = latestOrder();
|
if (!source) {
|
toast("暂无销售订单,不能生成");
|
return;
|
}
|
await generateFromOrder(source.id, target);
|
}
|
|
async function auditLatestOrder() {
|
const source = latestOrder();
|
if (!source) {
|
toast("暂无可审核的销售订单");
|
return;
|
}
|
await api("/api/business/update", { method: "POST", body: JSON.stringify({ kind: "salesOrders", id: source.id, patch: { auditStatus: "已审核", status: source.status || "待生产" } }) });
|
toast("最近一张销售订单已审核");
|
await loadState();
|
}
|
|
function printCurrentTable() {
|
window.print();
|
}
|
|
function exportBusinessCsv(kind) {
|
const rows = businessRows(kind);
|
if (!rows.length) {
|
toast("暂无可导出的业务记录");
|
return;
|
}
|
const headers = [...new Set(rows.flatMap(row => Object.keys(row)))];
|
const csv = [headers, ...rows.map(row => headers.map(key => row[key] || ""))]
|
.map(row => row.map(cell => `"${String(cell || "").replace(/"/g, '""')}"`).join(","))
|
.join("\r\n");
|
const blob = new Blob(["\ufeff" + csv], { type: "text/csv;charset=utf-8" });
|
const a = document.createElement("a");
|
a.href = URL.createObjectURL(blob);
|
a.download = `${kind}.csv`;
|
a.click();
|
URL.revokeObjectURL(a.href);
|
toast("业务记录已导出");
|
}
|
|
async function generateFromOrder(id, target, reload = true) {
|
const order = businessRows("salesOrders").find(row => row.id === Number(id));
|
if (!order) {
|
toast("销售订单不存在");
|
return;
|
}
|
const orderNo = `XSDD${String(order.id).padStart(6, "0")}`;
|
if (target === "production") {
|
await api("/api/business/create", { method: "POST", body: JSON.stringify({ kind: "productionTasks", row: { orderId: orderNo, product: order.product || "", quantity: order.quantity || "", owner: "生产部", dueDate: order.deliveryDate || "", status: "待排产", remark: `由${orderNo}生成` } }) });
|
await api("/api/business/update", { method: "POST", body: JSON.stringify({ kind: "salesOrders", id: order.id, patch: { productionStatus: "已生成", status: "待生产" } }) });
|
toast("已生成生产任务");
|
}
|
if (target === "logistics") {
|
await api("/api/business/create", { method: "POST", body: JSON.stringify({ kind: "shipments", row: { orderId: orderNo, company: "", trackingNo: "", shipDate: "", address: order.address || "", status: "待发货", remark: `由${orderNo}生成` } }) });
|
await api("/api/business/update", { method: "POST", body: JSON.stringify({ kind: "salesOrders", id: order.id, patch: { outboundStatus: "未出库" } }) });
|
toast("已生成物流记录");
|
}
|
if (target === "finance") {
|
const total = order.totalPrice || (Number(order.quantity || 0) * Number(order.unitPrice || 0));
|
const received = order.depositAmount || "";
|
await api("/api/business/create", { method: "POST", body: JSON.stringify({ kind: "receivables", row: { orderId: orderNo, receivableAmount: total || "", receivedAmount: received, unpaidAmount: total ? Math.max(0, Number(total) - Number(received || 0)) : "", invoiceStatus: "未开票", status: Number(received || 0) > 0 ? "部分收款" : "待收款", remark: `由${orderNo}生成` } }) });
|
toast("已生成应收账款");
|
}
|
if (reload) await loadState();
|
}
|
|
async function deleteBusiness(kind, id) {
|
if (!confirm("确认删除这条业务记录?")) return;
|
await api("/api/business/delete", { method: "POST", body: JSON.stringify({ kind, id }) });
|
toast("业务记录已删除");
|
await loadState();
|
}
|
|
async function createBusiness(kind, form) {
|
const row = Object.fromEntries(new FormData(form).entries());
|
if (row.quantity && row.unitPrice && !row.totalPrice) row.totalPrice = String(Number(row.quantity) * Number(row.unitPrice));
|
if (row.receivableAmount && row.receivedAmount && !row.unpaidAmount) row.unpaidAmount = String(Math.max(0, Number(row.receivableAmount) - Number(row.receivedAmount)));
|
await api("/api/business/create", { method: "POST", body: JSON.stringify({ kind, row }) });
|
form.reset();
|
toast("记录已保存");
|
await loadState();
|
}
|
|
async function updateBusinessStatus(kind, id, status) {
|
await api("/api/business/update", { method: "POST", body: JSON.stringify({ kind, id, patch: { status } }) });
|
toast("状态已更新");
|
await loadState();
|
}
|
|
function render() {
|
renderWorkspaceTabs();
|
renderBusinessHeader();
|
renderStats();
|
renderRows();
|
renderReminders();
|
renderReports();
|
renderAccounts();
|
renderBusinessModules();
|
}
|
|
function openCustomer(id) {
|
const c = state.customers.find(x => x.id === id);
|
document.getElementById("drawerTitle").textContent = `${c.name} · ${c.company}`;
|
document.getElementById("drawerSub").textContent = `负责人:${c.owner} | 首次录入:${c.firstReceiver} ${c.firstInputAt}`;
|
document.getElementById("drawerBody").innerHTML = `
|
<div class="detail-grid">
|
<div class="field"><label>电话</label><input id="topPhone" value="${c.phone || ""}"></div>
|
<div class="field"><label>微信 / 平台账号</label><input id="topWechat" value="${c.wechat || ""}" placeholder="微信"><input id="topPlatform" style="margin-top:6px;" value="${c.platformAccount || ""}" placeholder="平台账号"></div>
|
<div class="field"><label>阶段</label><select id="topFunnel">${funnelOptions(c.funnel)}</select></div>
|
<div class="field"><label>成交状态</label><select id="topDealStatus">${dealOptions(c.dealStatus)}</select></div>
|
<div class="field"><label>下次跟进</label><input id="topNext" type="date" value="${c.nextFollowupAt || ""}"></div>
|
<div class="field"><label>公海状态</label><select id="topPool"><option ${c.pool === "正常" ? "selected" : ""}>正常</option><option ${c.pool === "公共客户池" ? "selected" : ""}>公共客户池</option></select></div>
|
<div class="field"><label>场景</label><input id="topScene" value="${c.scene || ""}"></div>
|
<div class="field"><label>关键参数</label><input id="topParams" value="${c.params || ""}"></div>
|
</div>
|
<div class="toolbar" style="margin-top:10px;"><button class="btn primary" onclick="updateTopCustomer(${c.id})">保存顶部信息</button></div>
|
<div class="box"><h3>备注</h3><div class="muted">${c.remark || "无"}</div></div>
|
<div class="box"><h3>编辑客户信息</h3><div class="detail-grid">
|
<div class="field"><label>客户姓名</label><input id="editName" value="${c.name || ""}"></div>
|
<div class="field"><label>公司名称</label><input id="editCompany" value="${c.company || ""}"></div>
|
<div class="field"><label>手机号</label><input id="editPhone" value="${c.phone || ""}"></div>
|
<div class="field"><label>微信</label><input id="editWechat" value="${c.wechat || ""}"></div>
|
<div class="field"><label>平台账号</label><input id="editPlatform" value="${c.platformAccount || ""}"></div>
|
<div class="field"><label>来源</label><input id="editSource" value="${c.source || ""}"></div>
|
<div class="field"><label>客户类别</label><select id="editCustomerCategory">${categoryOptions(c.customerCategory || c.source)}</select></div>
|
<div class="field"><label>负责人</label><select id="editOwner"><option ${c.owner === "销售一" ? "selected" : ""}>销售一</option><option ${c.owner === "销售二" ? "selected" : ""}>销售二</option><option ${c.owner === "销售三" ? "selected" : ""}>销售三</option></select></div>
|
<div class="field"><label>录入人/首次接待人</label><select id="editFirstReceiver"><option ${c.firstReceiver === "销售一" ? "selected" : ""}>销售一</option><option ${c.firstReceiver === "销售二" ? "selected" : ""}>销售二</option><option ${c.firstReceiver === "销售三" ? "selected" : ""}>销售三</option><option ${c.firstReceiver === "主管" ? "selected" : ""}>主管</option></select></div>
|
<div class="field"><label>首次录入时间</label><input id="editFirstInputAt" value="${c.firstInputAt || ""}"></div>
|
<div class="field"><label>下次跟进</label><input id="editNext" type="date" value="${c.nextFollowupAt || ""}"></div>
|
<div class="field"><label>意向等级</label><select id="editIntention"><option ${c.intention === "A" ? "selected" : ""}>A</option><option ${c.intention === "B" ? "selected" : ""}>B</option><option ${c.intention === "C" ? "selected" : ""}>C</option><option ${c.intention === "D" ? "selected" : ""}>D</option></select></div>
|
<div class="field"><label>阶段</label><select id="editFunnel">${funnelOptions(c.funnel)}</select></div>
|
</div><div class="field" style="margin-top:10px;"><label>备注</label><textarea id="editRemark">${c.remark || ""}</textarea></div><div class="toolbar" style="margin-top:10px;"><button class="btn primary" onclick="updateCustomer(${c.id})">保存客户信息</button>${role === "supervisor" ? `<button class="btn red" onclick="deleteCustomer(${c.id})">删除客户</button>` : ""}</div></div>
|
<div class="box"><h3>运营来源信息</h3><div class="detail-grid">
|
<div class="field"><label>免费/付费</label><select id="opTrafficCostType"><option ${(!c.trafficCostType || c.trafficCostType === "免费") ? "selected" : ""}>免费</option><option ${c.trafficCostType === "付费" ? "selected" : ""}>付费</option></select></div>
|
<div class="field"><label>来源平台</label><input id="opSource" value="${c.source || ""}"></div>
|
<div class="field"><label>客户类别</label><select id="opCustomerCategory">${categoryOptions(c.customerCategory || c.source)}</select></div>
|
<div class="field"><label>搜索词</label><input id="opSearchTerm" value="${c.searchTerm || ""}"></div>
|
<div class="field"><label>关键词</label><input id="opKeyword" value="${c.keyword || ""}"></div>
|
<div class="field"><label>时段</label><input id="opTrafficTimeSlot" value="${c.trafficTimeSlot || ""}"></div>
|
<div class="field"><label>经销商/终端</label><select id="opCustomerType"><option value="" ${!c.customerType ? "selected" : ""}>未判断</option><option ${c.customerType === "终端" ? "selected" : ""}>终端</option><option ${c.customerType === "经销商" ? "selected" : ""}>经销商</option></select></div>
|
<div class="field"><label>客户地区</label><input id="opRegion" value="${c.opRegion || c.region || ""}"></div>
|
<div class="field"><label>流量类型</label><input id="opTrafficType" value="${c.trafficType || ""}" placeholder="自然流量/营销流量"></div>
|
<div class="field"><label>营销类型</label><input id="opMarketingType" value="${c.marketingType || ""}" placeholder="标准投放/私信留资/表单"></div>
|
<div class="field"><label>互动场景</label><input id="opInteractionScene" value="${c.interactionScene || ""}" placeholder="短视频/直播/搜索"></div>
|
<div class="field"><label>内容/广告名称</label><input id="opContentName" value="${c.contentName || ""}"></div>
|
<div class="field"><label>投放账号</label><input id="opSourceAccount" value="${c.sourceAccount || ""}"></div>
|
<div class="field"><label>内容链接</label><input id="opContentLink" value="${c.contentLink || ""}"></div>
|
<div class="field"><label>线索ID</label><input id="opLeadId" value="${c.leadId || c.douyinCustomerId || ""}"></div>
|
<div class="field"><label>转化状态</label><input id="opConversionStatus" value="${c.conversionStatus || ""}"></div>
|
<div class="field"><label>运营负责人</label><input id="opOpsOwner" value="${c.opsOwner || ""}"></div>
|
</div><div class="toolbar" style="margin-top:10px;"><button class="btn primary" onclick="updateCustomer(${c.id})">保存运营来源</button></div></div>
|
<div class="box"><h3>新增跟进记录</h3><div class="detail-grid"><div class="field"><label>跟进方式</label><select id="fuChannel"><option>电话</option><option>微信</option><option>平台</option><option>面访</option></select></div><div class="field"><label>跟进后阶段</label><select id="fuFunnel">${funnelOptions(c.funnel)}</select></div><div class="field"><label>下次跟进时间</label><input id="fuNext" type="date"></div></div><div class="toolbar" style="margin-top:10px;"><button class="btn" onclick="insertTemplate('已联系,客户在考虑,约定下次继续跟进。')">考虑中</button><button class="btn" onclick="insertTemplate('已报价,等待客户回复。')">已报价</button><button class="btn" onclick="insertTemplate('客户暂缓采购,后续按计划回访。')">暂缓</button><button class="btn" onclick="insertTemplate('客户未接通,稍后再次联系。')">未接通</button></div><div class="field" style="margin-top:10px;"><label>跟进内容</label><textarea id="fuContent"></textarea></div><label style="display:block;margin:8px 0;"><input id="fuEffective" type="checkbox" checked style="width:auto;"> 有效跟进</label><button class="btn primary" onclick="addFollowup(${c.id})">保存跟进</button></div>
|
<div class="box"><h3>销售报价 / 报价单</h3><div class="detail-grid"><div class="field"><label>报价单号</label><input id="quoteNo" placeholder="如 BJ-20260707-001"></div><div class="field"><label>产品型号</label><input id="quoteModel"></div><div class="field"><label>数量</label><input id="quoteQty" type="number" oninput="calcQuoteTotal()"></div><div class="field"><label>单价</label><input id="quoteUnit" type="number" oninput="calcQuoteTotal()"></div><div class="field"><label>总价</label><input id="quoteTotal" type="number"></div><div class="field"><label>价格类型</label><select id="quotePriceType"><option>标准价</option><option>优惠价</option><option>特批价</option><option>投标价</option></select></div><div class="field"><label>审批状态</label><select id="quoteApproval"><option>无需审批</option><option>待审批</option><option>已通过</option><option>已驳回</option></select></div></div><div class="field" style="margin-top:10px;"><label>报价备注</label><textarea id="quoteRemark"></textarea></div><button class="btn primary" style="margin-top:10px;" onclick="addQuote(${c.id})">保存报价</button></div>
|
<div class="box"><h3>成交订单信息</h3><div class="detail-grid"><div class="field"><label>成交状态</label><select id="dealStatus">${dealOptions(c.dealStatus)}</select></div><div class="field"><label>成交客户星级</label><select id="revisitLevel"><option ${c.revisitLevel === "金" ? "selected" : ""}>金</option><option ${c.revisitLevel === "银" ? "selected" : ""}>银</option><option ${!c.revisitLevel || c.revisitLevel === "铜" ? "selected" : ""}>铜</option><option ${c.revisitLevel === "铁" ? "selected" : ""}>铁</option></select></div><div class="field"><label>成交时间</label><input id="dealAt" type="date" value="${c.dealAt || ""}"></div><div class="field"><label>成交产品/型号</label><input id="dealProduct" value="${c.dealProduct || ""}"></div><div class="field"><label>数量</label><input id="dealQuantity" type="number" value="${c.dealQuantity || ""}" oninput="calcDealTotal()"></div><div class="field"><label>单价</label><input id="dealUnitPrice" type="number" value="${c.dealUnitPrice || ""}" oninput="calcDealTotal()"></div><div class="field"><label>总价格</label><input id="dealTotalPrice" type="number" value="${c.dealTotalPrice || c.dealAmount || ""}"></div><div class="field"><label>下次成交回访</label><input id="nextDealRevisit" type="date" value="${c.nextDealRevisit || ""}"></div></div><div class="muted" style="margin-top:8px;">选择“未成交”后,会自动清空成交订单信息并移出成交客户列表。星级用于区分金、银、铜、铁客户回访优先级。</div><button class="btn green" style="margin-top:10px;" onclick="markDeal(${c.id})">保存成交状态</button></div>
|
<div class="box"><h3>成交回访</h3><div class="detail-grid"><div class="field"><label>回访日期</label><input id="rvAt" type="date"></div><div class="field"><label>下次回访</label><input id="rvNext" type="date"></div></div><div class="field" style="margin-top:10px;"><label>回访结果</label><textarea id="rvResult"></textarea></div><button class="btn primary" onclick="addDealRevisit(${c.id})">保存回访</button></div>
|
<div class="box"><h3>报价记录</h3>${(c.quotes || []).map(q => `<div class="item"><div><strong>${q.at} ${q.quoteNo || ""} ${q.model || ""}</strong><div class="muted">数量 ${q.quantity || "-"} | 单价 ${q.unitPrice || "-"} | 总价 ${q.amount || "-"} | ${q.priceType || "-"} | ${q.approval || "-"}</div><div class="muted">${q.remark || ""}</div></div><span>${q.user || ""}</span></div>`).join("") || '<div class="muted">暂无报价</div>'}</div>
|
<div class="box"><h3>客户跟进记录表</h3>${(c.followups || []).map((f, index) => `<div class="item"><div style="flex:1;"><strong>本次记录:${f.at} ${f.channel}</strong><textarea id="followupContent_${index}" style="margin-top:6px;">${f.content}</textarea><div class="detail-grid" style="margin-top:6px;"><div class="field"><label>本次沟通方式</label><select id="followupChannel_${index}"><option ${f.channel === "电话" ? "selected" : ""}>电话</option><option ${f.channel === "微信" ? "selected" : ""}>微信</option><option ${f.channel === "平台" ? "selected" : ""}>平台</option><option ${f.channel === "面访" ? "selected" : ""}>面访</option></select></div><div class="field"><label>下一次跟进计划</label><input id="followupNext_${index}" type="date" value="${f.next || ""}"></div></div></div><div><div>${f.effective ? "有效" : "尝试"}</div><button class="btn" style="margin-top:6px;" onclick="updateFollowup(${c.id}, ${index})">保存</button><button class="btn red" style="margin-top:6px;" onclick="deleteFollowup(${c.id}, ${index})">删除</button></div></div>`).join("") || '<div class="muted">暂无跟进</div>'}</div>
|
<div class="box"><h3>成交回访记录</h3>${(c.dealRevisits || []).map(r => `<div class="item"><div><strong>${r.at}</strong><div class="muted">${r.result}</div></div><span>下次 ${r.next || "-"}</span></div>`).join("") || '<div class="muted">暂无成交回访</div>'}</div>
|
<div class="box"><h3>客户执行记录</h3>${(c.actions || []).map(a => `<div class="item"><div><strong>${a.at} ${a.title}</strong><div class="muted">${a.content}</div></div><span>${a.user}</span></div>`).join("") || '<div class="muted">暂无执行记录</div>'}</div>
|
${role === "supervisor" && c.pool !== "公共客户池" ? `<div class="box"><h3>主管操作</h3><button class="btn red" onclick="releaseCustomer(${c.id})">释放到公共客户池</button></div>` : ""}
|
`;
|
document.getElementById("drawer").classList.add("active");
|
}
|
|
function closeDrawer() { document.getElementById("drawer").classList.remove("active"); }
|
|
async function updateCustomer(id) {
|
const body = {
|
customerId: id,
|
user: currentUser,
|
name: document.getElementById("editName").value,
|
company: document.getElementById("editCompany").value,
|
phone: document.getElementById("editPhone").value,
|
wechat: document.getElementById("editWechat").value,
|
platformAccount: document.getElementById("editPlatform").value,
|
source: document.getElementById("opSource")?.value || document.getElementById("editSource").value,
|
customerCategory: document.getElementById("opCustomerCategory")?.value || document.getElementById("editCustomerCategory")?.value || "",
|
trafficCostType: document.getElementById("opTrafficCostType")?.value || "",
|
searchTerm: document.getElementById("opSearchTerm")?.value || "",
|
keyword: document.getElementById("opKeyword")?.value || "",
|
trafficTimeSlot: document.getElementById("opTrafficTimeSlot")?.value || "",
|
customerType: document.getElementById("opCustomerType")?.value || "",
|
opRegion: document.getElementById("opRegion")?.value || "",
|
trafficType: document.getElementById("opTrafficType")?.value || "",
|
marketingType: document.getElementById("opMarketingType")?.value || "",
|
interactionScene: document.getElementById("opInteractionScene")?.value || "",
|
conversionStatus: document.getElementById("opConversionStatus")?.value || "",
|
leadId: document.getElementById("opLeadId")?.value || "",
|
douyinCustomerId: document.getElementById("opLeadId")?.value || "",
|
sourceAccount: document.getElementById("opSourceAccount")?.value || "",
|
contentName: document.getElementById("opContentName")?.value || "",
|
contentLink: document.getElementById("opContentLink")?.value || "",
|
opsOwner: document.getElementById("opOpsOwner")?.value || "",
|
owner: document.getElementById("editOwner").value,
|
firstReceiver: document.getElementById("editFirstReceiver").value,
|
firstInputAt: document.getElementById("editFirstInputAt").value,
|
nextFollowupAt: document.getElementById("editNext").value,
|
intention: document.getElementById("editIntention").value,
|
funnel: document.getElementById("editFunnel").value,
|
remark: document.getElementById("editRemark").value
|
};
|
await api("/api/customers/update", { method: "POST", body: JSON.stringify(body) });
|
toast("客户信息已保存");
|
closeDrawer();
|
loadState();
|
}
|
|
async function updateTopCustomer(id) {
|
const c = state.customers.find(row => row.id === id);
|
if (!c) return;
|
const body = {
|
customerId: id,
|
user: currentUser,
|
name: c.name,
|
company: c.company,
|
phone: document.getElementById("topPhone").value,
|
wechat: document.getElementById("topWechat").value,
|
platformAccount: document.getElementById("topPlatform").value,
|
source: c.source,
|
owner: c.owner,
|
firstReceiver: c.firstReceiver,
|
firstInputAt: c.firstInputAt,
|
scene: document.getElementById("topScene").value,
|
params: document.getElementById("topParams").value,
|
intention: c.intention,
|
funnel: document.getElementById("topFunnel").value,
|
stage: document.getElementById("topFunnel").value.replace(/^S\d+\s*/, ""),
|
dealStatus: document.getElementById("topDealStatus").value,
|
pool: document.getElementById("topPool").value,
|
nextFollowupAt: document.getElementById("topNext").value,
|
remark: c.remark
|
};
|
await api("/api/customers/update", { method: "POST", body: JSON.stringify(body) });
|
toast("顶部信息已保存");
|
closeDrawer();
|
loadState();
|
}
|
|
async function deleteCustomer(id) {
|
if (role !== "supervisor") {
|
toast("只有主管可以删除客户");
|
return;
|
}
|
if (!confirm("确认删除这个客户?测试数据删除后不可恢复。")) return;
|
await api("/api/customers/delete", { method: "POST", body: JSON.stringify({ customerId: id, user: currentUser }) });
|
toast("客户已删除");
|
closeDrawer();
|
loadState();
|
}
|
|
async function addFollowup(id) {
|
const channel = document.getElementById("fuChannel").value;
|
const funnel = document.getElementById("fuFunnel").value;
|
const content = document.getElementById("fuContent").value;
|
const nextFollowupAt = document.getElementById("fuNext").value;
|
const effective = document.getElementById("fuEffective").checked;
|
await api("/api/followups", { method: "POST", body: JSON.stringify({ customerId: id, user: currentUser, channel, funnel, content, nextFollowupAt, effective }) });
|
toast("跟进已保存");
|
closeDrawer();
|
loadState();
|
}
|
|
function insertTemplate(text) {
|
const el = document.getElementById("fuContent");
|
if (!el) return;
|
el.value = el.value ? `${el.value}\n${text}` : text;
|
el.focus();
|
}
|
|
function calcQuoteTotal() {
|
const qty = Number(document.getElementById("quoteQty")?.value || 0);
|
const unit = Number(document.getElementById("quoteUnit")?.value || 0);
|
const total = document.getElementById("quoteTotal");
|
if (total && qty && unit) total.value = qty * unit;
|
}
|
|
function calcDealTotal() {
|
const qty = Number(document.getElementById("dealQuantity")?.value || 0);
|
const unit = Number(document.getElementById("dealUnitPrice")?.value || 0);
|
const total = document.getElementById("dealTotalPrice");
|
if (total && qty && unit) total.value = qty * unit;
|
}
|
|
async function addQuote(id) {
|
await api("/api/quotes", { method: "POST", body: JSON.stringify({ customerId: id, user: currentUser, quoteNo: document.getElementById("quoteNo").value, model: document.getElementById("quoteModel").value, quantity: document.getElementById("quoteQty").value, unitPrice: document.getElementById("quoteUnit").value, totalPrice: document.getElementById("quoteTotal").value, priceType: document.getElementById("quotePriceType").value, approval: document.getElementById("quoteApproval").value, remark: document.getElementById("quoteRemark").value }) });
|
toast("报价已保存");
|
closeDrawer();
|
loadState();
|
}
|
|
async function updateFollowup(id, index) {
|
await api("/api/followups/update", { method: "POST", body: JSON.stringify({ customerId: id, user: currentUser, followupIndex: index, channel: document.getElementById(`followupChannel_${index}`).value, content: document.getElementById(`followupContent_${index}`).value, nextFollowupAt: document.getElementById(`followupNext_${index}`).value, effective: true }) });
|
toast("跟进记录已更新");
|
closeDrawer();
|
loadState();
|
}
|
|
async function deleteFollowup(id, index) {
|
if (!confirm("确认删除这条跟进记录?")) return;
|
await api("/api/followups/delete", { method: "POST", body: JSON.stringify({ customerId: id, user: currentUser, followupIndex: index }) });
|
toast("跟进记录已删除");
|
closeDrawer();
|
loadState();
|
}
|
|
async function markDeal(id) {
|
const status = document.getElementById("dealStatus").value;
|
const payload = { customerId: id, user: currentUser, dealStatus: status };
|
if (status !== "未成交") {
|
payload.dealAt = document.getElementById("dealAt").value;
|
payload.dealProduct = document.getElementById("dealProduct").value;
|
payload.dealQuantity = document.getElementById("dealQuantity").value;
|
payload.dealUnitPrice = document.getElementById("dealUnitPrice").value;
|
payload.dealTotalPrice = document.getElementById("dealTotalPrice").value;
|
payload.dealAmount = payload.dealTotalPrice;
|
payload.revisitLevel = document.getElementById("revisitLevel").value;
|
payload.nextDealRevisit = document.getElementById("nextDealRevisit").value;
|
}
|
await api("/api/deal", { method: "POST", body: JSON.stringify(payload) });
|
toast("成交状态已保存");
|
closeDrawer();
|
loadState();
|
}
|
|
async function addDealRevisit(id) {
|
const at = document.getElementById("rvAt").value;
|
const result = document.getElementById("rvResult").value;
|
const nextDealRevisit = document.getElementById("rvNext").value;
|
await api("/api/deal-revisits", { method: "POST", body: JSON.stringify({ customerId: id, user: currentUser, at, result, nextDealRevisit }) });
|
toast("成交回访已保存");
|
closeDrawer();
|
loadState();
|
}
|
|
async function releaseCustomer(id) {
|
await api("/api/release", { method: "POST", body: JSON.stringify({ customerId: id, user: currentUser, reason: "主管手动释放体验" }) });
|
toast("已释放到公海");
|
closeDrawer();
|
loadState();
|
}
|
|
async function assignCustomer(id) {
|
const owner = document.getElementById(`assignOwner_${id}`)?.value || "销售一";
|
await api("/api/assign", { method: "POST", body: JSON.stringify({ customerId: id, user: currentUser, owner }) });
|
toast(`已分配给${owner}`);
|
loadState();
|
}
|
|
function toggleAllCustomers(checked) {
|
document.querySelectorAll(".customerCheck").forEach(el => { el.checked = checked; });
|
}
|
|
function changePage(delta) {
|
customerPage = Math.max(1, customerPage + delta);
|
renderRows();
|
}
|
|
function changePageSize() {
|
customerPageSize = Number(document.getElementById("pageSize").value) || 10;
|
customerPage = 1;
|
renderRows();
|
}
|
|
async function batchUpdateFunnel() {
|
const funnel = document.getElementById("batchFunnel").value;
|
const ids = [...document.querySelectorAll(".customerCheck:checked")].map(el => Number(el.value));
|
if (!funnel || !ids.length) {
|
toast("请选择客户和目标阶段");
|
return;
|
}
|
for (const id of ids) {
|
const c = state.customers.find(row => row.id === id);
|
if (!c) continue;
|
await api("/api/customers/update", { method: "POST", body: JSON.stringify({ customerId: id, user: currentUser, funnel, stage: funnel.replace(/^S\d+\s*/, ""), name: c.name, company: c.company, phone: c.phone, wechat: c.wechat, platformAccount: c.platformAccount, source: c.source, owner: c.owner, nextFollowupAt: c.nextFollowupAt, intention: c.intention, remark: c.remark }) });
|
}
|
toast(`已批量修改 ${ids.length} 个客户阶段`);
|
loadState();
|
}
|
|
async function importCustomers() {
|
const text = document.getElementById("importText").value.trim();
|
if (!text) {
|
toast("请粘贴要导入的数据");
|
return;
|
}
|
let ok = 0;
|
let fail = 0;
|
for (const line of text.split(/\r?\n/)) {
|
const [name, company, phone, wechat, source, nextFollowupAt, remark] = line.split(",").map(v => (v || "").trim());
|
if (!name || !company || (!phone && !wechat)) { fail++; continue; }
|
try {
|
await api("/api/customers", { method: "POST", body: JSON.stringify({ user: currentUser === "主管" ? "销售一" : currentUser, name, company, phone, wechat, source: source || "批量导入", nextFollowupAt, remark, firstReceiver: currentUser === "主管" ? "销售一" : currentUser, intention: "C", funnel: "S1 新线索", dealStatus: "未成交" }) });
|
ok++;
|
} catch {
|
fail++;
|
}
|
}
|
toast(`导入完成:成功 ${ok} 条,失败 ${fail} 条`);
|
loadState();
|
}
|
|
function loadImportFile(event) {
|
const file = event.target.files && event.target.files[0];
|
if (!file) return;
|
const reader = new FileReader();
|
reader.onload = () => {
|
document.getElementById("importText").value = String(reader.result || "");
|
toast("文件已读取,请确认内容后点击批量导入");
|
};
|
reader.readAsText(file, "utf-8");
|
}
|
|
function duplicatePairs() {
|
const pairs = [];
|
const seen = new Set();
|
for (let i = 0; i < state.customers.length; i++) {
|
for (let j = i + 1; j < state.customers.length; j++) {
|
const a = state.customers[i];
|
const b = state.customers[j];
|
const reasons = [];
|
if (a.company && a.company === b.company) reasons.push("公司相同");
|
if (a.phone && a.phone === b.phone) reasons.push("电话相同");
|
if (a.wechat && a.wechat === b.wechat) reasons.push("微信相同");
|
if (a.platformAccount && a.platformAccount === b.platformAccount) reasons.push("平台账号相同");
|
if (!reasons.length) continue;
|
const key = [a.id, b.id].sort().join("-");
|
if (!seen.has(key)) {
|
seen.add(key);
|
pairs.push({ a, b, reasons });
|
}
|
}
|
}
|
return pairs;
|
}
|
|
function renderDuplicateSuggestions() {
|
const rows = duplicatePairs();
|
const el = document.getElementById("duplicateRows");
|
el.innerHTML = rows.length ? rows.map(({ a, b, reasons }) => `<tr><td>ID:${a.id} ${a.name} / ID:${b.id} ${b.name}<div class="muted">${a.company || b.company}</div></td><td>${reasons.join("、")}</td><td><button class="btn" onclick="fillMerge(${a.id}, ${b.id})">填入合并</button></td></tr>`).join("") : `<tr><td colspan="3" class="muted">暂无疑似重复客户</td></tr>`;
|
}
|
|
function fillMerge(mainId, dupId) {
|
document.getElementById("mergeMainId").value = mainId;
|
document.getElementById("mergeDupId").value = dupId;
|
toast("已填入合并客户ID,请确认后点击合并");
|
}
|
|
async function mergeCustomers() {
|
const mainId = Number(document.getElementById("mergeMainId").value);
|
const dupId = Number(document.getElementById("mergeDupId").value);
|
const main = state.customers.find(c => c.id === mainId);
|
const dup = state.customers.find(c => c.id === dupId);
|
if (!main || !dup || mainId === dupId) {
|
toast("请填写正确的两个客户ID");
|
return;
|
}
|
const mergedRemark = `${main.remark || ""}\n合并客户:${dup.name}/${dup.company} ${dup.remark || ""}`.trim();
|
await api("/api/customers/update", { method: "POST", body: JSON.stringify({ customerId: mainId, user: currentUser, name: main.name, company: main.company, phone: main.phone || dup.phone, wechat: main.wechat || dup.wechat, platformAccount: main.platformAccount || dup.platformAccount, source: main.source, owner: main.owner, nextFollowupAt: main.nextFollowupAt, intention: main.intention, funnel: main.funnel, remark: mergedRemark }) });
|
await api("/api/customers/delete", { method: "POST", body: JSON.stringify({ customerId: dupId, user: currentUser }) });
|
toast("重复客户已合并");
|
loadState();
|
}
|
|
async function createReminder() {
|
const customer = document.getElementById("manualReminderCustomer").value;
|
const dueDate = document.getElementById("manualReminderDate").value;
|
const content = document.getElementById("manualReminderContent").value;
|
if (!content) {
|
toast("请填写提醒内容");
|
return;
|
}
|
await api("/api/reminders", { method: "POST", body: JSON.stringify({ user: currentUser, customer, dueDate, content }) });
|
document.getElementById("manualReminderContent").value = "";
|
toast("提醒已创建");
|
loadState();
|
}
|
|
async function completeReminder(id) {
|
await api("/api/reminders/complete", { method: "POST", body: JSON.stringify({ id, user: currentUser }) });
|
toast("提醒已完成");
|
loadState();
|
}
|
|
async function runRules() {
|
await api("/api/run-rules", { method: "POST", body: JSON.stringify({ user: currentUser }) });
|
toast("已执行自动公海检查");
|
loadState();
|
}
|
|
function exportCustomers() {
|
if (apiBase || location.protocol !== "file:") {
|
window.open(`${apiBase}/api/export.csv?token=${encodeURIComponent(auth?.token || "")}`, "_blank");
|
return;
|
}
|
const headers = ["序号", "客户", "公司", "电话", "微信", "平台账号", "来源", "客户类别", "免费/付费", "搜索词", "关键词", "时段", "经销商/终端", "客户地区", "内容/广告", "投放账号", "内容链接", "运营负责人", "线索ID", "负责人", "首次录入人", "首次录入时间", "阶段", "成交状态", "成交客户星级", "成交产品", "数量", "单价", "总价", "公海状态", "下次跟进", "备注"];
|
const rows = visibleCustomers().map((c, index) => [index + 1, c.name, c.company, c.phone, c.wechat, c.platformAccount, c.source, c.customerCategory || c.source, c.trafficCostType, c.searchTerm, c.keyword, c.trafficTimeSlot, c.customerType, c.opRegion || c.region, c.contentName, c.sourceAccount, c.contentLink, c.opsOwner, c.leadId, c.owner, c.firstReceiver, c.firstInputAt, c.funnel, c.dealStatus, c.revisitLevel, c.dealProduct, c.dealQuantity, c.dealUnitPrice, c.dealTotalPrice || c.dealAmount, c.pool, c.nextFollowupAt, c.remark]);
|
const csv = [headers, ...rows].map(row => row.map(cell => `"${String(cell || "").replace(/"/g, '""')}"`).join(",")).join("\r\n");
|
const blob = new Blob(["\ufeff" + csv], { type: "text/csv;charset=utf-8" });
|
const a = document.createElement("a");
|
a.href = URL.createObjectURL(blob);
|
a.download = "sales-crm-customers.csv";
|
a.click();
|
URL.revokeObjectURL(a.href);
|
}
|
|
initNavCollapse();
|
document.querySelectorAll(".nav button").forEach(b => b.addEventListener("click", () => setView(b.dataset.view, b.dataset.feature || b.dataset.view)));
|
["search", "ownerFilter", "sourceFilter", "categoryFilter", "funnelFilter", "dealFilter", "poolFilter", "intentionFilter", "inputStartFilter", "inputEndFilter", "periodFilter"].forEach(id => {
|
const el = document.getElementById(id);
|
if (el) el.addEventListener("input", renderRows);
|
if (el) el.addEventListener("change", renderRows);
|
});
|
document.getElementById("customerForm").addEventListener("submit", async (event) => {
|
event.preventDefault();
|
clearFormAlert();
|
const form = event.target;
|
const body = Object.fromEntries(new FormData(form).entries());
|
body.customerCategory = body.customerCategory || body.source || "其他";
|
if (role === "ops" && !body.name) body.name = "未命名";
|
if (role === "ops" && body.opRegion && !body.region) body.region = body.opRegion;
|
const hasContact = [body.phone, body.wechat, body.platformAccount, body.leadId].some(v => (v || "").trim());
|
if (!hasContact) {
|
showFormAlert("手机号、微信、平台账号、线索ID至少填写一项,否则无法识别重复客户和后续联系。");
|
return;
|
}
|
const duplicateRows = await renderDuplicateAlert();
|
const duplicate = duplicateRows.find(row => row.strong) || duplicateRows.find(row => row.reasons.includes("公司名称相同"));
|
if (duplicate) {
|
showFormAlert(`发现重复客户:${duplicate.customer.name} / ${duplicate.customer.company},请打开已有客户补充,不要重复新建。`);
|
return;
|
}
|
body.user = currentUser;
|
body.firstReceiver = body.firstReceiver || body.user;
|
try {
|
await api("/api/customers", { method: "POST", body: JSON.stringify(body) });
|
form.reset();
|
form.elements.firstReceiver.value = currentUser;
|
configureEntryForm();
|
toast("客户已保存");
|
setView("customers");
|
loadState();
|
} catch (err) {
|
showFormAlert(err.message);
|
toast(err.message);
|
}
|
});
|
["name", "company", "phone", "wechat", "platformAccount", "leadId"].forEach(name => {
|
const el = document.querySelector(`#customerForm [name="${name}"]`);
|
if (el) el.addEventListener("input", renderDuplicateAlert);
|
});
|
[
|
["salesOrderForm", "salesOrders"],
|
["productionForm", "productionTasks"],
|
["stockForm", "stockItems"],
|
["logisticsForm", "shipments"],
|
["financeForm", "receivables"]
|
].forEach(([formId, kind]) => {
|
const form = document.getElementById(formId);
|
if (form) {
|
form.addEventListener("submit", async (event) => {
|
event.preventDefault();
|
try {
|
await createBusiness(kind, form);
|
} catch (err) {
|
toast(err.message);
|
}
|
});
|
}
|
});
|
document.getElementById("accountForm").addEventListener("submit", async (event) => {
|
event.preventDefault();
|
if (role !== "supervisor") {
|
toast("只有主管可以管理账号");
|
return;
|
}
|
const form = event.target;
|
const body = Object.fromEntries(new FormData(form).entries());
|
body.phone = body.username;
|
body.permissions = checkedPermissions();
|
try {
|
await api("/api/accounts/save", { method: "POST", body: JSON.stringify(body) });
|
toast("账号已保存");
|
resetAccountForm();
|
await loadState();
|
} catch (err) {
|
toast(err.message);
|
}
|
});
|
document.querySelector('#accountForm [name="role"]')?.addEventListener("change", (event) => {
|
setPermissionChecks(defaultPermissionSet(event.target.value));
|
});
|
renderPermissionChecks(defaultPermissionSet("sales"));
|
document.getElementById("passwordChangeForm").addEventListener("submit", async (event) => {
|
event.preventDefault();
|
if (passwordChangeSubmitting) return;
|
const form = event.currentTarget;
|
const values = validatePasswordChange(form);
|
if (values.message) {
|
setPasswordChangeError(values.message, values.field);
|
return;
|
}
|
setPasswordChangeBusy(true);
|
try {
|
await requestPasswordChange(values.currentPassword, values.newPassword);
|
setPasswordChangeBusy(false);
|
logout("密码已修改,请使用新密码重新登录。", true);
|
} catch (error) {
|
setPasswordChangeBusy(false);
|
handlePasswordChangeError(error);
|
}
|
});
|
document.getElementById("passwordChangeModal").addEventListener("keydown", (event) => {
|
if (!event.currentTarget.classList.contains("active")) return;
|
if (event.key === "Escape") {
|
if (!passwordChangeSubmitting) {
|
event.preventDefault();
|
closePasswordChangeModal();
|
}
|
return;
|
}
|
if (event.key !== "Tab" || passwordChangeSubmitting) return;
|
const focusable = [...event.currentTarget.querySelectorAll("input:not([disabled]), button:not([disabled]), [tabindex]:not([tabindex='-1'])")];
|
if (!focusable.length) return;
|
const first = focusable[0];
|
const last = focusable[focusable.length - 1];
|
if (event.shiftKey && document.activeElement === first) {
|
event.preventDefault();
|
last.focus();
|
} else if (!event.shiftKey && document.activeElement === last) {
|
event.preventDefault();
|
first.focus();
|
}
|
});
|
document.getElementById("loginForm").addEventListener("submit", async (event) => {
|
event.preventDefault();
|
const form = event.target;
|
const loginAlert = document.getElementById("loginAlert");
|
loginAlert.classList.remove("show", "notice");
|
try {
|
const body = Object.fromEntries(new FormData(form).entries());
|
const result = await api("/api/login", { method: "POST", body: JSON.stringify(body) });
|
auth = result;
|
applyAccount(auth.user);
|
hideLogin();
|
await loadState();
|
} catch (err) {
|
loginAlert.textContent = err.message;
|
loginAlert.classList.add("show");
|
}
|
});
|
showLogin();
|
</script>
|
</body>
|
</html>
|