You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 

718 lines
40 KiB

<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>A5 项目管理 Demo</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font: 14px/1.6 "Microsoft YaHei", system-ui, sans-serif; color: #333; background: #f5f6fa; }
header { display: flex; align-items: center; gap: 16px; padding: 10px 20px; background: #1f2d3d; color: #fff; flex-wrap: wrap; }
header h1 { font-size: 17px; font-weight: 600; }
header select, header button, header input { padding: 4px 10px; border-radius: 4px; border: 1px solid #45566b; background: #2c3e50; color: #fff; }
header button.primary { background: #409eff; border-color: #409eff; cursor: pointer; }
header .who { color: #9fb3c8; }
nav.tabs { display: flex; gap: 4px; padding: 0 20px; background: #fff; border-bottom: 1px solid #e4e7ed; }
nav.tabs button { padding: 10px 22px; border: none; background: none; font-size: 14px; cursor: pointer; color: #666; border-bottom: 2px solid transparent; }
nav.tabs button.on { color: #409eff; border-bottom-color: #409eff; font-weight: 600; }
main { padding: 16px 20px; }
.panel { background: #fff; border: 1px solid #e4e7ed; border-radius: 6px; padding: 14px; margin-bottom: 14px; }
.viewbar { display: flex; gap: 6px; flex-wrap: wrap; align-items: center; margin-bottom: 10px; }
.viewbar button { padding: 5px 12px; border: 1px solid #dcdfe6; background: #fff; border-radius: 4px; cursor: pointer; }
.viewbar button.on { background: #409eff; color: #fff; border-color: #409eff; }
.viewbar .sp { flex: 1; }
input, select, textarea { padding: 5px 8px; border: 1px solid #dcdfe6; border-radius: 4px; font: inherit; }
table { width: 100%; border-collapse: collapse; }
th, td { text-align: left; padding: 8px 10px; border-bottom: 1px solid #ebeef5; white-space: nowrap; }
th { color: #909399; font-weight: 500; font-size: 13px; background: #fafafa; }
tr.row { cursor: pointer; } tr.row:hover td { background: #f0f7ff; }
.badge { display: inline-block; padding: 1px 8px; border-radius: 10px; font-size: 12px; }
.b1 { background: #ecf0f5; color: #606266; } .b2 { background: #e8f7e8; color: #2fa52f; }
.b3 { background: #fdf0e0; color: #d9820c; } .b4 { background: #fdeaea; color: #d84a4a; }
.b5 { background: #efe7fb; color: #8a5bd6; }
.badge.stage0 { background: #fdeaea; color: #d84a4a; }
.badge.stage5win { background: #e8f7e8; color: #2fa52f; }
.badge.stage5lose, .badge.stage6 { background: #f0f0f0; color: #909399; }
.toolbar { display: flex; gap: 6px; flex-wrap: wrap; margin: 10px 0; }
.toolbar button { padding: 5px 12px; border: 1px solid #dcdfe6; background: #fff; border-radius: 4px; cursor: pointer; }
.toolbar button.warn { color: #d9820c; border-color: #f0c78a; }
.toolbar button.danger { color: #d84a4a; border-color: #f3c1c1; }
.toolbar button.win { color: #2fa52f; border-color: #b3e3b3; }
.toolbar button:disabled { color: #bbb; cursor: not-allowed; }
.board { display: flex; gap: 10px; align-items: flex-start; overflow-x: auto; padding-bottom: 8px; }
.bcol { min-width: 230px; flex: 1; background: #eff2f5; border-radius: 6px; padding: 8px; }
.bcol .colhead { display: flex; justify-content: space-between; align-items: center; padding: 2px 4px 8px; font-weight: 600; font-size: 13px; }
.bcol .colhead .cnt { color: #909399; font-weight: 400; }
.bcard { background: #fff; border: 1px solid #e4e7ed; border-radius: 6px; padding: 8px 10px; margin-bottom: 8px; cursor: pointer; }
.bcard:hover { border-color: #409eff; box-shadow: 0 1px 6px rgba(64,158,255,.18); }
.bcard .bname { font-weight: 600; margin-bottom: 4px; }
.bcard .bmeta { color: #909399; font-size: 12px; display: flex; gap: 8px; flex-wrap: wrap; }
.bcard .bops { margin-top: 6px; }
.bcard .bops button { padding: 2px 8px; font-size: 12px; border: 1px solid #dcdfe6; background: #fff; border-radius: 4px; cursor: pointer; color: #d9820c; border-color: #f0c78a; }
.subtabs { display: flex; gap: 2px; border-bottom: 1px solid #e4e7ed; margin-bottom: 10px; flex-wrap: wrap; }
.subtabs button { padding: 7px 14px; border: none; background: none; cursor: pointer; border-bottom: 2px solid transparent; }
.subtabs button.on { color: #409eff; border-bottom-color: #409eff; font-weight: 600; }
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 6px 18px; }
.grid .kv { display: flex; gap: 8px; } .grid .kv b { color: #909399; font-weight: 400; min-width: 96px; }
.empty { color: #999; text-align: center; padding: 24px 0; }
.pager { display: flex; gap: 8px; align-items: center; justify-content: flex-end; padding: 10px 0 0; }
.pager button { padding: 3px 10px; border: 1px solid #dcdfe6; background: #fff; border-radius: 4px; cursor: pointer; }
#toast { position: fixed; top: 14px; right: 14px; z-index: 99; display: flex; flex-direction: column; gap: 8px; max-width: 480px; }
.toast-item { padding: 10px 14px; border-radius: 6px; color: #fff; font-size: 13px; word-break: break-all; }
.toast-ok { background: #52c41a; } .toast-err { background: #d84a4a; } .toast-info { background: #3b7dbd; }
dialog { border: 1px solid #dcdfe6; border-radius: 8px; padding: 0; min-width: 460px; max-width: 680px; }
dialog::backdrop { background: rgba(0,0,0,.35); }
dialog .dhead { padding: 12px 16px; background: #f5f7fa; border-bottom: 1px solid #e4e7ed; font-weight: 600; }
dialog .dbody { padding: 14px 16px; display: flex; flex-direction: column; gap: 10px; }
dialog .dbody label { display: flex; align-items: center; gap: 10px; }
dialog .dbody label b { width: 110px; color: #606266; font-weight: 400; text-align: right; flex-shrink: 0; }
dialog .dbody input, dialog .dbody select, dialog .dbody textarea { flex: 1; }
dialog .dfoot { padding: 10px 16px; text-align: right; border-top: 1px solid #e4e7ed; display: flex; gap: 8px; justify-content: flex-end; }
dialog .dfoot button { padding: 6px 18px; border-radius: 4px; border: 1px solid #dcdfe6; background: #fff; cursor: pointer; }
dialog .dfoot button.ok { background: #409eff; color: #fff; border-color: #409eff; }
.hint { color: #999; font-size: 12px; }
.sumcards { display: flex; gap: 12px; flex-wrap: wrap; }
.sumcards .card { border: 1px solid #e4e7ed; border-radius: 6px; padding: 8px 16px; min-width: 130px; cursor: pointer; }
.sumcards .card.hot { border-color: #f3c1c1; background: #fff7f7; }
.sumcards .card b { display: block; font-size: 20px; color: #409eff; }
.sumcards .card.hot b { color: #d84a4a; }
.frm { display: flex; flex-direction: column; gap: 8px; max-width: 560px; }
.frm label { display: flex; gap: 10px; align-items: center; }
.frm label b { width: 90px; color: #606266; font-weight: 400; text-align: right; flex-shrink: 0; }
</style>
</head>
<body>
<header>
<h1>A5 项目管理 Demo</h1>
<span class="hint" id="apiBox"></span>
<span>当前用户:</span>
<select id="userSel" onchange="switchUser(this.value)"></select>
<input id="uidInput" placeholder="测试环境 userId 直登" style="width:250px">
<button onclick="loginByUid()">uid 登录</button>
<span class="who" id="whoBox">未登录</span>
<span style="flex:1"></span>
<button class="primary" onclick="dlgCreate()">+ 新建项目</button>
</header>
<nav class="tabs">
<button data-tab="board" class="on" onclick="switchTab('board');loadBoard()">看板</button>
<button data-tab="list" onclick="switchTab('list');loadList()">列表</button>
<button data-tab="detail" onclick="openDetailTab()">详情</button>
</nav>
<main>
<!-- ========== 看板(票 14) ========== -->
<section id="tab-board">
<div class="panel">
<div class="viewbar">
<button data-scope="manage" class="on" onclick="switchScope(this)">项目管理</button>
<button data-scope="mine" onclick="switchScope(this)">我的项目</button>
<span class="sp"></span>
<span class="sumcards">
<span class="card hot" onclick="gotoStage0()" title="点击筛选列表 stage=0">
<b id="conflictCnt">-</b>冲突待处理
</span>
</span>
</div>
<div class="board" id="boardBox"><div class="empty">加载中…</div></div>
<div class="hint">固定 7 列(stage 0~6 全画);列头阶段名 = 项目规则别名 → 字典名 → 枚举名;stage0 卡片带「冲突解除」入口;赢单停 stage5、stage6 仅输单。</div>
</div>
</section>
<!-- ========== 列表(票 14) ========== -->
<section id="tab-list" style="display:none">
<div class="panel">
<div class="viewbar">
<button data-scope="manage" class="on" onclick="switchScopeL(this)">项目管理</button>
<button data-scope="mine" onclick="switchScopeL(this)">我的项目</button>
<span class="sp"></span>
<select id="fStage" onchange="S.page=1;loadList()">
<option value="">阶段:全部</option>
<option value="0">冲突处理</option><option value="1">方案设计</option>
<option value="2">商务报价</option><option value="3">招投标准备</option>
<option value="4">商务跟进</option><option value="5">赢单/输单</option>
<option value="6">结项</option>
</select>
<select id="fFiling" onchange="S.page=1;loadList()">
<option value="">报备:全部</option>
<option value="1">审核中</option><option value="2">通过</option>
<option value="3">驳回</option>
</select>
<input id="fKeyword" placeholder="项目名称" onkeydown="if(event.key==='Enter'){S.page=1;loadList()}">
<button onclick="S.page=1;loadList()">查询</button>
</div>
<table>
<thead><tr>
<th>项目名称</th><th>阶段</th><th>状态</th><th>报备</th><th>赢/输</th>
<th>客户</th><th>负责人</th><th>项目金额</th><th>创建时间</th>
</tr></thead>
<tbody id="listBox"></tbody>
</table>
<div class="pager">
<span class="hint" id="listTotal"></span>
<button onclick="pageTo(S.page-1)">上一页</button>
<span id="pageNo">1</span>
<button onclick="pageTo(S.page+1)">下一页</button>
</div>
<div class="hint">阶段筛枚举就 7 个值(原型「报备中」是混轴笔误不照做);报备状态为独立筛选项。</div>
</div>
</section>
<!-- ========== 详情(票 15) ========== -->
<section id="tab-detail" style="display:none">
<div class="panel" id="detailHead">请从看板/列表点击一个项目</div>
<div class="panel" id="detailActions" style="display:none"></div>
<div class="panel" id="detailTabs" style="display:none">
<div class="subtabs" id="detailSubtabs">
<button data-sub="follow" class="on" onclick="switchSub('follow')">跟进</button>
<button data-sub="attachment" onclick="switchSub('attachment')">附件</button>
<button data-sub="customer" onclick="switchSub('customer')">客户</button>
<button data-sub="timeline" onclick="switchSub('timeline')">动态</button>
<button data-sub="scheme" onclick="switchSub('scheme')">方案设计</button>
<button data-sub="quote" onclick="switchSub('quote')">商务报价</button>
<button data-sub="bidding" onclick="switchSub('bidding')">招投标</button>
<button data-sub="bidopen" onclick="switchSub('bidopen')">开标</button>
</div>
<div id="subBox"></div>
</div>
</section>
</main>
<div id="toast"></div>
<dialog id="dlgCreate">
<div class="dhead">新建项目(独立建项目 · 本期唯一可达建项目入口)</div>
<div class="dbody">
<div class="hint">客户必填(客户目录选,不支持现场新建);方案卡必填且卡客户须等于项目客户(一项目一客户一卡)。落库即:stage=0 冲突处理 / 报备审核中。</div>
<label><b>项目名称*</b><input id="cName" placeholder="如:智慧园区一期"></label>
<label><b>客户ID*</b><input id="cCustomer" placeholder="crm-customer 客户 id(目录选)"></label>
<label><b>方案卡ID*</b><input id="cCard" placeholder="该客户名下商机归属方案卡 id"></label>
<label><b>来源商机</b><input id="cSource" placeholder="可空"></label>
<label><b>项目金额</b><input id="cAmount" placeholder="可空,如 1200000"></label>
<label><b>地区</b><input id="cRegion" placeholder="可空,字典code"></label>
<label><b>品牌</b><input id="cBrand" placeholder="可空"></label>
</div>
<div class="dfoot">
<button onclick="dlgClose('dlgCreate')">取消</button>
<button class="ok" onclick="doCreate()">创建</button>
</div>
</dialog>
<dialog id="dlgFiling">
<div class="dhead">冲突解除 = 报备审核回填(一次动作双写)</div>
<div class="dbody">
<div class="hint" id="filingCtx"></div>
<label><b>审核结论</b>
<select id="filingApproved">
<option value="true">通过(filing=2 + stage 0→1,=冲突解除)</option>
<option value="false">驳回(filing=3 停 stage0,可再审)</option>
</select>
</label>
<label><b>审核意见</b><input id="filingRemark" placeholder="落项目动态,可空"></label>
</div>
<div class="dfoot">
<button onclick="dlgClose('dlgFiling')">取消</button>
<button class="ok" onclick="doFiling()">提交</button>
</div>
</dialog>
<dialog id="dlgRollback">
<div class="dhead">阶段回退(多步选前序节点 · 仅管理员+销售总监)</div>
<div class="dbody">
<div class="hint">可选「前面任意已经过的节点」(下限方案设计 stage1;3→5 连跳跳过的节点不在列)。三终态(结项/已关闭/赢单)不可回退。</div>
<label><b>回退到</b><select id="rbTargets"></select></label>
</div>
<div class="dfoot">
<button onclick="dlgClose('dlgRollback')">取消</button>
<button class="ok" onclick="doRollback()">确认回退</button>
</div>
</dialog>
<dialog id="dlgBid">
<div class="dhead">标记赢单 / 输单(stage5 分叉 · 一次动作)</div>
<div class="dbody">
<label><b>标记*</b><select id="bidResultSel">
<option value="1">赢单(stage5 闭环锁死,不可回退)</option>
<option value="2">输单(进 stage6 结项终态)</option>
</select></label>
<div class="hint">赢单快照(可落库部分,选填;bid_time 由服务端盖章):</div>
<label><b>开标时间</b><input id="bidOpenTime" placeholder="yyyy-MM-dd HH:mm:ss"></label>
<label><b>开标地点</b><input id="bidOpenPlace"></label>
<label><b>中标方式</b><input id="bidAwardMethod"></label>
<label><b>中标金额</b><input id="bidAwardAmount"></label>
<div class="hint">输单复盘字段(输单原因/竞品/失败分析)为后续字段(spin-out),本期不采集。</div>
</div>
<div class="dfoot">
<button onclick="dlgClose('dlgBid')">取消</button>
<button class="ok" onclick="doBidSubmit()">确认标记</button>
</div>
</dialog>
<dialog id="dlgCloseProj">
<div class="dhead">关闭项目(任意 stage → 已关闭 · 记因四要素)</div>
<div class="dbody">
<label><b>关闭类型*</b>
<select id="closeType">
<option value="MANUAL">主动关闭</option>
<option value="CUSTOMER_CANCEL">客户取消</option>
<option value="LOST_CONTACT">失去联系</option>
<option value="OTHER">其他</option>
</select>
</label>
<label><b>关闭原因</b><input id="closeReason" placeholder="可空"></label>
<label><b>关闭备注</b><input id="closeRemark" placeholder="可空"></label>
<div class="hint">关闭后项目只读,不可阶段回退。</div>
</div>
<div class="dfoot">
<button onclick="dlgClose('dlgCloseProj')">取消</button>
<button class="ok" onclick="doCloseProj()">确认关闭</button>
</div>
</dialog>
<script>
/* ================= 基础设施(沿 customer demo 范式) ================= */
const API_BASE = (() => {
const q = new URLSearchParams(location.search).get('api');
if (q) localStorage.setItem('a5_api', q);
return localStorage.getItem('a5_api') || (location.protocol.startsWith('http') ? '' : 'http://localhost:8080');
})();
const $ = id => document.getElementById(id);
$('apiBox').textContent = 'API: ' + (API_BASE || '同源');
const esc = s => s == null ? '' : String(s).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
const TOKEN = () => localStorage.getItem('a5_token');
const ME = () => { try { return JSON.parse(localStorage.getItem('a5_me') || 'null'); } catch(e) { return null; } };
function toast(msg, type) {
const d = document.createElement('div');
d.className = 'toast-item toast-' + (type === 'ok' ? 'ok' : type === 'err' ? 'err' : 'info');
d.textContent = msg;
$('toast').appendChild(d);
setTimeout(() => d.remove(), 4500);
}
async function api(path, opt = {}) {
const headers = opt.headers || (opt.headers = {});
if (!(opt.body instanceof FormData) && opt.body) headers['Content-Type'] = headers['Content-Type'] || 'application/x-www-form-urlencoded;charset=UTF-8';
if (TOKEN()) headers['Authorization'] = 'Bearer ' + TOKEN();
const r = await fetch(API_BASE + path, opt);
let j = null;
try { j = await r.json(); } catch (e) {}
if (r.status === 401) { toast('未登录或 token 失效,请先 uid 登录', 'err'); throw new Error('401'); }
if (j && j.code !== 0 && j.code !== undefined) {
const e = new Error(j.message || ('HTTP ' + r.status)); e.code = j.code; throw e;
}
if (!r.ok) throw new Error('HTTP ' + r.status);
return j ? j.data : null;
}
const form = o => Object.entries(o).filter(([,v]) => v !== undefined && v !== null && v !== '')
.map(([k,v]) => encodeURIComponent(k) + '=' + encodeURIComponent(v)).join('&');
function dlgOpen(id) { $(id).showModal(); }
function dlgClose(id) { $(id).close(); }
/* ================= 登录(uid debug-token,角色切换模拟) ================= */
const USERS_KEY = 'a5_users';
function loadUsers() {
let users = [{id:'', name:'—— 手输 userId ——'}];
try { users = users.concat(JSON.parse(localStorage.getItem(USERS_KEY) || '[]')); } catch(e) {}
$('userSel').innerHTML = users.map(u => `<option value="${esc(u.id)}">${esc(u.name)}</option>`).join('');
}
function rememberUser(uid, name) {
if (!uid) return;
let users = [];
try { users = JSON.parse(localStorage.getItem(USERS_KEY) || '[]'); } catch(e) {}
if (!users.some(u => u.id === uid)) {
users.push({id: uid, name: (name || uid) + ' · ' + uid.slice(-6)});
localStorage.setItem(USERS_KEY, JSON.stringify(users.slice(-8)));
}
loadUsers();
$('userSel').value = uid;
}
async function loginByUid() {
const uid = ($('uidInput').value || '').trim() || $('userSel').value;
if (!uid) return toast('请输入 userId', 'err');
try {
const r = await fetch(API_BASE + '/api/auth/debug/token?userId=' + encodeURIComponent(uid));
const j = await r.json();
const d = j && j.data;
const tok = typeof d === 'string' ? d : (d && d.token);
if (!tok) return toast('debug/token 失败: ' + ((j && j.message) || r.status), 'err');
localStorage.setItem('a5_token', tok);
let me = null;
try {
const r2 = await fetch(API_BASE + '/api/auth/me', { headers: { Authorization: 'Bearer ' + tok } });
const j2 = await r2.json();
if (j2 && j2.code === 0) me = j2.data;
} catch (e) {}
if (!me) me = { userId: uid };
localStorage.setItem('a5_me', JSON.stringify(me));
rememberUser(uid, me.userName || me.nickName);
paintWho();
toast('登录成功: ' + (me.userName || me.nickName || uid), 'ok');
loadBoard(); loadList();
} catch (e) { toast('登录异常: ' + e.message, 'err'); }
}
function switchUser(uid) { $('uidInput').value = uid; if (uid) loginByUid(); }
function paintWho() {
const me = ME();
$('whoBox').textContent = me ? '已登录:' + (me.userName || me.nickName || '') + ' (' + (me.userId || '') + ')' : '未登录';
}
/* ================= Tab / scope ================= */
function switchTab(t) {
document.querySelectorAll('nav.tabs button').forEach(b => b.classList.toggle('on', b.dataset.tab === t));
document.querySelectorAll('main > section').forEach(s => s.style.display = s.id === 'tab-' + t ? '' : 'none');
}
const STAGE_LABEL = {0:'冲突处理',1:'方案设计',2:'商务报价',3:'招投标准备',4:'商务跟进',5:'赢单/输单',6:'结项'};
const FILING_LABEL = {0:'未报备',1:'审核中',2:'通过',3:'驳回'};
const STATUS_LABEL = {1:'进行中',2:'已关闭',3:'已结项'};
const BID_LABEL = {0:'未定',1:'赢单',2:'输单'};
function stageBadge(row) {
const st = row.projectStage;
let cls = 'b1';
if (st === 0) cls = 'stage0';
if (st === 5 && row.bidResult === 1) cls = 'stage5win';
if (st === 5 && row.bidResult === 2) cls = 'stage5lose';
if (st === 6) cls = 'stage6';
return `<span class="badge ${cls}">${esc(row.stageName || STAGE_LABEL[st] || st)}</span>`;
}
function filingBadge(v) {
const c = v === 1 ? 'b3' : v === 2 ? 'b2' : v === 3 ? 'b4' : 'b1';
return `<span class="badge ${c}">${FILING_LABEL[v] ?? v}</span>`;
}
function statusBadge(v) {
const c = v === 1 ? 'b2' : v === 2 ? 'b3' : 'b1';
return `<span class="badge ${c}">${STATUS_LABEL[v] ?? v}</span>`;
}
function openProject(id) {
S.detailId = id;
switchTab('detail');
loadDetail();
}
function openDetailTab() {
if (S.detailId) loadDetail();
}
/* ================= 看板(票 14) ================= */
const S = { boardScope: 'manage', listScope: 'manage', page: 1, size: 10, detailId: null };
function switchScope(btn) {
S.boardScope = btn.dataset.scope;
document.querySelectorAll('#tab-board [data-scope]').forEach(b => b.classList.toggle('on', b === btn));
loadBoard();
}
async function loadBoard() {
if (!TOKEN()) { $('boardBox').innerHTML = '<div class="empty">请先 uid 登录</div>'; return; }
try {
const b = await api('/api/project/board?scope=' + S.boardScope);
$('conflictCnt').textContent = b.conflictPendingCount;
$('boardBox').innerHTML = b.columns.map(col => `
<div class="bcol">
<div class="colhead"><span>${esc(col.stageName)}</span><span class="cnt">共 ${col.count} 条</span></div>
${col.cards.map(card => `
<div class="bcard" onclick="openProject('${card.id}')">
<div class="bname">${esc(card.projectName)}</div>
<div class="bmeta">
<span>${esc(card.customerName || '-')}</span>
<span>${esc(card.ownerNameSnapshot || '-')}</span>
<span>${card.projectAmount != null ? '¥' + Number(card.projectAmount).toLocaleString() : ''}</span>
<span>${card.bidResult && card.bidResult !== 0 ? BID_LABEL[card.bidResult] : ''}</span>
</div>
${card.conflictActionAvailable ? `<div class="bops"><button onclick="event.stopPropagation();dlgFilingOpen('${card.id}', '${esc(card.filingStatusName)}')">冲突解除</button></div>` : ''}
</div>`).join('') || '<div class="hint" style="padding:4px">无项目</div>'}
</div>`).join('');
} catch (e) { $('boardBox').innerHTML = '<div class="empty">加载失败: ' + esc(e.message) + '</div>'; }
}
function gotoStage0() {
switchTab('list');
$('fStage').value = '0';
S.page = 1; loadList();
}
/* ================= 列表(票 14) ================= */
function switchScopeL(btn) {
S.listScope = btn.dataset.scope;
document.querySelectorAll('#tab-list [data-scope]').forEach(b => b.classList.toggle('on', b === btn));
S.page = 1; loadList();
}
async function loadList() {
if (!TOKEN()) { $('listBox').innerHTML = '<tr><td colspan="9" class="empty">请先 uid 登录</td></tr>'; return; }
try {
const q = form({scope: S.listScope, stage: $('fStage').value, filingStatus: $('fFiling').value,
keyword: $('fKeyword').value, current: S.page, size: S.size});
const page = await api('/api/project/list?' + q);
$('listTotal').textContent = '共 ' + page.total + ' 条';
$('pageNo').textContent = S.page;
$('listBox').innerHTML = page.content.map(row => `
<tr class="row" onclick="openProject('${row.id}')">
<td>${esc(row.projectName)}</td>
<td>${stageBadge(row)}</td>
<td>${statusBadge(row.projectStatus)}</td>
<td>${filingBadge(row.filingStatus)}</td>
<td>${row.bidResult && row.bidResult !== 0 ? BID_LABEL[row.bidResult] : '-'}</td>
<td>${esc(row.customerName || '-')}</td>
<td>${esc(row.ownerNameSnapshot || '-')}</td>
<td>${row.projectAmount != null ? Number(row.projectAmount).toLocaleString() : '-'}</td>
<td>${esc((row.createTime || '').slice(0, 19).replace('T', ' '))}</td>
</tr>`).join('') || '<tr><td colspan="9" class="empty">无数据</td></tr>';
} catch (e) { $('listBox').innerHTML = '<tr><td colspan="9" class="empty">加载失败: ' + esc(e.message) + '</td></tr>'; }
}
function pageTo(p) {
if (p < 1) return;
S.page = p; loadList();
}
/* ================= 新建项目(票 14) ================= */
function dlgCreate() { dlgOpen('dlgCreate'); }
async function doCreate() {
try {
const id = await api('/api/project/create', {method: 'POST', body: form({
projectName: $('cName').value.trim(),
customerId: $('cCustomer').value.trim(),
schemeCardId: $('cCard').value.trim(),
sourceOppId: $('cSource').value.trim(),
projectAmount: $('cAmount').value.trim(),
regionCode: $('cRegion').value.trim(),
brand: $('cBrand').value.trim(),
})});
dlgClose('dlgCreate');
toast('建项目成功 id=' + id + '(stage=0 报备审核中)', 'ok');
loadBoard(); loadList();
openProject(id);
} catch (e) { toast('建项目失败: ' + e.message + (e.code ? ' (code=' + e.code + ')' : ''), 'err'); }
}
/* ================= 详情(票 15) ================= */
let D = null;
let curSub = 'follow';
async function loadDetail() {
if (!S.detailId) return;
try {
D = await api('/api/project/detail?id=' + S.detailId);
paintHead(); paintActions(); switchSub(curSub);
} catch (e) { toast('详情加载失败: ' + e.message, 'err'); }
}
function paintHead() {
$('detailHead').innerHTML = `
<div style="display:flex;gap:12px;align-items:center;flex-wrap:wrap">
<b style="font-size:16px">${esc(D.projectName)}</b>
${stageBadge(D)} ${statusBadge(D.projectStatus)} ${filingBadge(D.filingStatus)}
${D.bidResult && D.bidResult !== 0 ? `<span class="badge ${D.bidResult === 1 ? 'stage5win' : 'stage5lose'}">${BID_LABEL[D.bidResult]}</span>` : ''}
<span class="hint">id=${D.id}</span>
</div>
<div class="grid" style="margin-top:10px">
<div class="kv"><b>客户</b><span>${esc(D.customerName || D.customerId || '-')}</span></div>
<div class="kv"><b>负责人</b><span>${esc(D.ownerNameSnapshot || '-')}</span></div>
<div class="kv"><b>项目金额</b><span>${D.projectAmount != null ? Number(D.projectAmount).toLocaleString() : '-'}</span></div>
<div class="kv"><b>方案卡</b><span>${esc(D.schemeCardId || '-')}</span></div>
<div class="kv"><b>品牌</b><span>${esc(D.brand || '-')}</span></div>
<div class="kv"><b>地区</b><span>${esc(D.regionCode || '-')}</span></div>
<div class="kv"><b>关闭</b><span>${D.closeTime ? esc(D.closeType) + ' ' + esc(dt(D.closeTime)) : '-'}</span></div>
</div>`;
$('detailTabs').style.display = '';
}
function paintActions() {
const box = $('detailActions');
const active = D.projectStatus === 1;
const stage = D.projectStage;
const btns = [];
if (active && stage >= 1 && stage <= 3) btns.push(`<button onclick="doAdvance(false)">推进(${esc(STAGE_LABEL[stage])}${esc(STAGE_LABEL[stage+1])})</button>`);
if (active && stage === 3) btns.push(`<button class="warn" onclick="doAdvance(true)">无需招投标(3 → 5 连跳)</button>`);
if (active && stage === 4) btns.push(`<button onclick="doAdvance(false)">推进(商务跟进 → 赢单/输单)</button>`);
if (active && stage >= 2) btns.push(`<button onclick="dlgRollbackOpen()">回退…</button>`);
if (active && stage === 0 && (D.filingStatus === 1 || D.filingStatus === 3))
btns.push(`<button class="warn" onclick="dlgFilingOpen('${D.id}', '${FILING_LABEL[D.filingStatus]}')">冲突解除(报备${FILING_LABEL[D.filingStatus]})</button>`);
if (active && stage === 5 && D.bidResult === 0) {
btns.push(`<button class="win" onclick="dlgBidOpen(1)">标记赢单(stage5 闭环锁死)</button>`);
btns.push(`<button class="danger" onclick="dlgBidOpen(2)">标记输单(进结项)</button>`);
}
if (active) btns.push(`<button class="danger" onclick="dlgOpen('dlgCloseProj')">关闭…</button>`);
box.style.display = btns.length ? '' : 'none';
box.innerHTML = '<div class="viewbar"><b style="margin-right:6px">状态动作:</b>' + btns.join('') +
(active ? '' : '<span class="hint">终态(已关闭/已结项)锁死,无可用动作</span>') + '</div>';
}
function switchSub(sub) {
curSub = sub;
document.querySelectorAll('#detailSubtabs button').forEach(b => b.classList.toggle('on', b.dataset.sub === sub));
const box = $('subBox');
box.innerHTML = '<div class="loading">加载中…</div>';
const loaders = {follow: loadFollow, attachment: loadAttachment, customer: loadCustomer,
timeline: loadTimeline, scheme: loadScheme, quote: loadQuote, bidding: loadBidding, bidopen: loadBidOpen};
(loaders[sub] || (() => {}))();
}
/* ---- 状态动作 ---- */
async function doAdvance(skipBidding) {
try {
await api('/api/project/stage/advance', {method: 'POST', body: form({id: D.id, skipBidding: skipBidding || ''})});
toast('推进成功', 'ok'); loadDetail(); loadBoard();
} catch (e) { toast('推进失败: ' + e.message + (e.code ? ' (code=' + e.code + ')' : ''), 'err'); }
}
async function dlgRollbackOpen() {
try {
const targets = await api('/api/project/stage/rollback-targets?id=' + D.id);
if (!targets.length) return toast('无可回退节点(stage0 冲突页回退置灰)', 'info');
$('rbTargets').innerHTML = targets.map(t =>
`<option value="${t.stage}">${esc(t.stageName)}(stage${t.stage})</option>`).join('');
dlgOpen('dlgRollback');
} catch (e) { toast('可选节点查询失败: ' + e.message, 'err'); }
}
async function doRollback() {
try {
await api('/api/project/stage/rollback', {method: 'POST', body: form({id: D.id, targetStage: $('rbTargets').value})});
dlgClose('dlgRollback');
toast('回退成功', 'ok'); loadDetail(); loadBoard();
} catch (e) { toast('回退失败: ' + e.message + (e.code ? ' (code=' + e.code + ')' : ''), 'err'); }
}
function dlgFilingOpen(id, filingName) {
$('filingCtx').textContent = `项目 id=${id},当前报备状态:${filingName}。通过=冲突解除(stage 0→1);驳回停 stage0 可再审。`;
$('dlgFiling').dataset.pid = id;
dlgOpen('dlgFiling');
}
async function doFiling() {
try {
await api('/api/project/filing/review', {method: 'POST', body: form({
id: $('dlgFiling').dataset.pid, approved: $('filingApproved').value, remark: $('filingRemark').value})});
dlgClose('dlgFiling');
toast('报备审核回填成功', 'ok'); loadDetail(); loadBoard();
} catch (e) { toast('冲突解除失败: ' + e.message + (e.code ? ' (code=' + e.code + ')' : ''), 'err'); }
}
function dlgBidOpen(result) {
$('bidResultSel').value = String(result);
dlgOpen('dlgBid');
}
async function doBidSubmit() {
try {
await api('/api/project/bid-result/mark', {method: 'POST', body: form({
id: D.id, bidResult: $('bidResultSel').value,
bidOpenTime: $('bidOpenTime').value, bidOpenPlace: $('bidOpenPlace').value,
bidAwardMethod: $('bidAwardMethod').value, bidAwardAmount: $('bidAwardAmount').value})});
dlgClose('dlgBid');
toast('标记成功(快照已随标记落列)', 'ok'); loadDetail(); loadBoard();
} catch (e) { toast('标记失败: ' + e.message, 'err'); }
}
async function doCloseProj() {
try {
await api('/api/project/close', {method: 'POST', body: form({
id: D.id, closeType: $('closeType').value, closeReason: $('closeReason').value, closeRemark: $('closeRemark').value})});
dlgClose('dlgCloseProj');
toast('项目已关闭(记因四要素落库)', 'ok'); loadDetail(); loadBoard();
} catch (e) { toast('关闭失败: ' + e.message, 'err'); }
}
/* ---- Tab: 跟进 ---- */
async function loadFollow() {
try {
const page = await api(`/api/project/follow/list?projectId=${D.id}&current=1&size=20`);
$('subBox').innerHTML = `
<div class="frm">
<label><b>跟进内容*</b><input id="flContent"></label>
<label><b>跟进时间*</b><input id="flTime" placeholder="yyyy-MM-dd HH:mm:ss" value="${new Date().toISOString().slice(0,19).replace('T',' ')}"></label>
<label><b>跟进方式</b><input id="flWay" placeholder="字典code,可空"></label>
<div><button style="background:#409eff;color:#fff;border-color:#409eff;border:1px solid #409eff;padding:5px 14px;border-radius:4px;cursor:pointer" onclick="addFollow()">新增跟进</button></div>
</div>
<table style="margin-top:10px"><thead><tr><th>时间</th><th>内容</th><th>方式</th></tr></thead>
<tbody>${page.content.map(f => `<tr><td>${esc(dt(f.followTime))}</td><td>${esc(f.followContent)}</td><td>${esc(f.followWay||'-')}</td></tr>`).join('') || '<tr><td colspan="3" class="empty">无跟进记录</td></tr>'}</tbody></table>`;
} catch (e) { $('subBox').innerHTML = '<div class="empty">加载失败: ' + esc(e.message) + '</div>'; }
}
async function addFollow() {
try {
await api('/api/project/follow/add', {method: 'POST', body: form({
projectId: D.id, followContent: $('flContent').value, followTime: $('flTime').value, followWay: $('flWay').value})});
toast('跟进已新增(动态留痕)', 'ok'); loadFollow();
} catch (e) { toast('新增跟进失败: ' + e.message, 'err'); }
}
/* ---- Tab: 附件 ---- */
async function loadAttachment() {
try {
const list = await api(`/api/project/attachment/list?projectId=${D.id}`);
$('subBox').innerHTML = `
<div class="frm">
<label><b>文件ID*</b><input id="attFile" placeholder="先经 crm-file 上传拿 fileId"></label>
<label><b>归属</b><select id="attBiz"><option value="PROJECT">项目主体</option><option value="FOLLOW_UP">跟进附件</option></select></label>
<label><b>业务id</b><input id="attBizId" placeholder="FOLLOW_UP 时=跟进行id,可空"></label>
<div><button style="background:#409eff;color:#fff;border-color:#409eff;border:1px solid #409eff;padding:5px 14px;border-radius:4px;cursor:pointer" onclick="addAttachment()">新增附件</button></div>
</div>
<table style="margin-top:10px"><thead><tr><th>id</th><th>归属</th><th>文件id</th><th>备注</th><th>操作</th></tr></thead>
<tbody>${list.map(a => `<tr><td>${a.id}</td><td>${esc(a.bizType)}</td><td>${esc(a.fileId)}</td><td>${esc(a.remark||'-')}</td>
<td><button onclick="delAttachment('${a.id}')">删除</button></td></tr>`).join('') || '<tr><td colspan="5" class="empty">无附件</td></tr>'}</tbody></table>
<div class="hint">附件本体走 crm-file,子表仅存 biz 定位(biz_type+biz_id);设备/报价清单明细行不落 CRM。</div>`;
} catch (e) { $('subBox').innerHTML = '<div class="empty">加载失败: ' + esc(e.message) + '</div>'; }
}
async function addAttachment() {
try {
await api('/api/project/attachment/add', {method: 'POST', body: form({
projectId: D.id, fileId: $('attFile').value, bizType: $('attBiz').value, bizId: $('attBizId').value})});
toast('附件已挂载', 'ok'); loadAttachment();
} catch (e) { toast('新增附件失败: ' + e.message, 'err'); }
}
async function delAttachment(id) {
try { await api('/api/project/attachment/delete', {method: 'POST', body: form({id})});
toast('已删除(软删)', 'ok'); loadAttachment();
} catch (e) { toast('删除失败: ' + e.message, 'err'); }
}
/* ---- Tab: 客户 ---- */
async function loadCustomer() {
try {
const list = await api(`/api/project/customer/list?projectId=${D.id}`);
$('subBox').innerHTML = !list.length ? '<div class="empty">客户不可用或未关联</div>' : `
<div class="grid">
<div class="kv"><b>客户名称</b><span>${esc(list[0].customerName)}</span></div>
<div class="kv"><b>客户编号</b><span>${esc(list[0].customerNo)}</span></div>
<div class="kv"><b>客户类型</b><span>${esc(list[0].customerType)}</span></div>
<div class="kv"><b>销售负责人</b><span>${esc(list[0].ownerName)}</span></div>
</div>
<div class="hint">一项目一客户(主表 customer_id 单列,无 project_customer 子表);详情走 CustomerCatalogPort。</div>`;
} catch (e) { $('subBox').innerHTML = '<div class="empty">加载失败: ' + esc(e.message) + '</div>'; }
}
/* ---- Tab: 动态 ---- */
async function loadTimeline() {
try {
const page = await api(`/api/project/timeline/list?projectId=${D.id}&current=1&size=50`);
$('subBox').innerHTML = `
<table><thead><tr><th>时间</th><th>动作</th><th>描述</th><th>操作人</th></tr></thead>
<tbody>${page.content.map(t => `<tr>
<td>${esc(dt(t.opTime))}</td>
<td><span class="badge b5">${esc(t.actionName || t.action)}</span>
${t.fromStage != null && t.toStage != null ? `<span class="hint"> ${STAGE_LABEL[t.fromStage]}${STAGE_LABEL[t.toStage]}</span>` : ''}</td>
<td>${esc(t.opDesc)}</td>
<td>${esc(t.operatorName || '-')}</td></tr>`).join('') || '<tr><td colspan="4" class="empty">无动态</td></tr>'}</tbody></table>`;
} catch (e) { $('subBox').innerHTML = '<div class="empty">加载失败: ' + esc(e.message) + '</div>'; }
}
/* ---- Tab: 方案/报价/招投标/开标 ---- */
async function loadScheme() {
try {
const d = await api(`/api/project/scheme?projectId=${D.id}`);
$('subBox').innerHTML = kvGrid([['概算金额', d.schemeBudget], ['方案版本', d.schemeVersion],
['设计任务状态', d.schemeTaskStatus]]) + '<div class="hint">主表扩展列(零阶段子表)。</div>';
} catch (e) { $('subBox').innerHTML = '<div class="empty">加载失败: ' + esc(e.message) + '</div>'; }
}
async function loadQuote() {
try {
const d = await api(`/api/project/quote?projectId=${D.id}`);
$('subBox').innerHTML = kvGrid([['报价总价', d.quoteTotal], ['报价状态', d.quoteStatus]])
+ '<div class="hint">报价清单明细行不落 CRM(硬边界),仅回显汇总标量。</div>';
} catch (e) { $('subBox').innerHTML = '<div class="empty">加载失败: ' + esc(e.message) + '</div>'; }
}
async function loadBidding() {
try {
const d = await api(`/api/project/bidding?projectId=${D.id}`);
$('subBox').innerHTML = kvGrid([['投标截止', dt(d.bidDeadline)], ['投标预算', d.bidBudget],
['品牌控制', d.brandControl], ['标底类型', d.bidBaseType], ['标底等级', d.bidBaseTypeName],
['分配比例', d.allocRatio], ['投标保证金', d.bidBond], ['投标文件', d.bidFileId], ['投标主体', d.bidderName]])
+ '<div class="hint">投标主体一对一本期定案;标底等级名走 ProjectRule 等级判定(票 13)。</div>';
} catch (e) { $('subBox').innerHTML = '<div class="empty">加载失败: ' + esc(e.message) + '</div>'; }
}
async function loadBidOpen() {
try {
const d = await api(`/api/project/bid-open?projectId=${D.id}`);
$('subBox').innerHTML = kvGrid([['开标时间', dt(d.bidOpenTime)], ['开标地点', d.bidOpenPlace],
['中标方式', d.bidAwardMethod], ['中标金额', d.bidAwardAmount],
['赢/输', d.bidResultName], ['中标时间', dt(d.bidTime)]])
+ '<div class="hint">标记赢单/输单走详情头动作区(stage5 专用分叉)。</div>';
} catch (e) { $('subBox').innerHTML = '<div class="empty">加载失败: ' + esc(e.message) + '</div>'; }
}
function kvGrid(pairs) {
return '<div class="grid">' + pairs.map(([k, v]) =>
`<div class="kv"><b>${k}</b><span>${v == null || v === '' ? '-' : esc(v)}</span></div>`).join('') + '</div>';
}
function dt(s) { return s ? String(s).slice(0, 19).replace('T', ' ') : null; }
/* ================= init ================= */
loadUsers(); paintWho();
if (TOKEN()) { loadBoard(); loadList(); }
</script>
</body>
</html>