深圳新房踩盘工作台
漏斗式选盘:数据为底 · 实地为证
本周踩盘计划 0
多维看板 0
楼盘评价卡 0
板块网格 0
只读浏览:这个链接只能查看。需要编辑请向管理员索取「可编辑」链接。 管理员登录
今天要处理
0
逾期 + 今日待踩 + 待复盘,一条都不许漏
粤ICP备2026062194号-1
计划
看板
评价卡
板块
新增
硬指标照实填、软配套自己走一遍再写。「一句话风险」必须敢写,写不出来说明还没踩明白。
分享管理
生成「仅查看」或「可编辑」链接发给别人,随时可以作废。链接本身就是钥匙,不要公开张贴。
' + esc(p) + '';
}
function qCount(s) {
if (!s) { return 0; }
var n = 0;
String(s).split('\n').forEach(function (l) { if (l.trim()) { n++; } });
return n;
}
/* ================= 同步状态 ================= */
function setSync(mode) {
var conf = { on: ['已同步', 'on'], busy: ['同步中…', 'busy'], off: ['离线模式', 'off'] };
var m = conf[mode] || conf.off;
['syncBadge', 'syncBadge2'].forEach(function (id) {
var el = $(id);
if (!el) { return; }
el.className = 'sync ' + m[1];
var sp = el.querySelector('span:last-child');
if (sp) { sp.textContent = m[0]; }
});
}
var toastTimer = null;
function toast(msg, isErr) {
var el = $('toast');
if (!el) { return; }
el.textContent = msg;
el.className = 'toast' + (isErr ? ' err' : '');
if (toastTimer) { window.clearTimeout(toastTimer); }
toastTimer = window.setTimeout(function () { el.className = 'toast hidden'; }, 2600);
}
/* ================= 数据层 ================= */
function collectOptions(schema) {
var map = {};
((schema && schema.properties) || []).forEach(function (f) {
if ((f.type === 'select' || f.type === 'multi_select') && f.config && f.config.options) {
map[f.name] = f.config.options;
}
});
return map;
}
function loadAll(startCursor, acc, guard) {
acc = acc || [];
guard = guard || 0;
if (guard > 100) { return Promise.resolve(acc); }
return db.query({ databaseId: DATABASE_ID, pageSize: 200, startCursor: startCursor })
.then(function (r) {
acc = acc.concat((r && r.results) ? r.results : []);
var next = r ? r.nextCursor : null;
if (r && r.hasMore && next && next !== startCursor && (r.results || []).length) {
return loadAll(next, acc, guard + 1);
}
return acc;
})
.catch(function (err) {
console.error('[database] 分页加载失败:', err);
throw err;
});
}
function loadLocal() {
state.offline = true;
try {
var raw = window.localStorage.getItem(STORE_KEY);
state.rows = raw ? JSON.parse(raw) : [];
} catch (e) { state.rows = []; }
}
function saveLocal() {
if (!state.offline) { return; }
try { window.localStorage.setItem(STORE_KEY, JSON.stringify(state.rows)); } catch (e) { /* 忽略 */ }
}
function reload() {
if (state.offline) { refreshAll(); return Promise.resolve(); }
setSync('busy');
return loadAll().then(function (rows) {
state.rows = rows;
setSync('on');
refreshAll();
}).catch(function (err) {
console.error('[database] 重新加载失败:', err);
setSync('off');
toast('数据刷新失败,稍后重试', true);
});
}
/* ================= 统一刷新入口(铁律:渲染函数之间互不调用) ================= */
function refreshAll() {
renderToday();
renderStats();
renderNavCounts();
renderPlan();
renderBoard();
renderCards();
renderGrid();
}
/* ================= 视图切换 ================= */
function showView(v) {
if (v === 'new') { openForm(null); return; }
if (VIEWS.indexOf(v) === -1) { return; }
state.view = v;
VIEWS.forEach(function (x) {
var el = $('view-' + x);
if (el) { el.className = 'view' + (x === v ? '' : ' hidden'); }
});
markNav(v);
}
function markNav(v) {
var items = document.querySelectorAll('.nav-item, .tabbar button');
Array.prototype.forEach.call(items, function (b) {
var isOn = b.getAttribute('data-view') === v;
if (isOn) { b.classList.add('active'); } else { b.classList.remove('active'); }
});
}
/* ================= 表单 ================= */
function optionList(map, name) {
var opts = (map && map[name]) || [];
var list = [];
opts.forEach(function (o) { list.push({ id: o.id || o.text, text: o.text }); });
/* 云端 schema 拿不到时(离线模式)用本地兜底,保证下拉仍可用 */
if (!list.length && DEFAULT_OPTIONS[name]) {
DEFAULT_OPTIONS[name].forEach(function (t) { list.push({ id: t, text: t }); });
}
return list;
}
function renderSelects(map) {
FIELDS.forEach(function (f) {
if (f.t !== 'select') { return; }
var el = $(f.k);
if (!el) { return; }
var html = '请选择 ';
optionList(map, f.n).forEach(function (o) {
html += '' + esc(o.text) + ' ';
});
setHTML(el, html);
});
restoreForm();
}
function fillSelect(k, fieldName, valueText) {
var el = $(k);
if (!el) { return; }
el.value = '';
if (!valueText) { return; }
var opts = optionList(state.options, fieldName);
for (var i = 0; i < opts.length; i++) {
if (opts[i].text === valueText) { el.value = opts[i].id; return; }
}
el.value = valueText;
}
/* 下拉的 value 是选项 id,取回阶段等文本时必须反查 —— 否则门禁会全部判错 */
function selectText(k, fieldName) {
var el = $(k);
if (!el || !el.value) { return ''; }
var opts = optionList(state.options, fieldName);
for (var i = 0; i < opts.length; i++) {
if (opts[i].id === el.value) { return opts[i].text; }
}
return el.value;
}
/* 行政区自动带出:只在没选过的时候填,绝不覆盖用户已选的值 */
function autoDist() {
var src = $('f3');
var sel = $('f27');
if (!src || !sel || sel.value) { return; }
var s = src.value || '';
for (var i = 0; i < DISTS.length; i++) {
if (s.indexOf(DISTS[i]) > -1) {
var opts = optionList(state.options, '行政区');
for (var j = 0; j < opts.length; j++) {
if (opts[j].text === DISTS[i]) { sel.value = opts[j].id; return; }
}
return;
}
}
}
function clearForm() {
var form = $('houseForm');
if (!form) { return; }
FIELDS.forEach(function (f) {
var el = form.querySelector('[name="' + f.k + '"]');
if (el) { el.value = ''; }
});
}
/* ---- 表单草稿本地缓存(断网/误关页面不丢已填内容) ---- */
var FORM_CACHE_KEY = 'wb_scaikan_form';
var cacheTimer = null;
var NO_CACHE_RE = /密码|身份证|secret|token|key/i;
function cacheableFields(form) {
var out = [];
FIELDS.forEach(function (f) {
if (NO_CACHE_RE.test(f.n) || NO_CACHE_RE.test(f.k)) { return; }
var el = form.querySelector('[name="' + f.k + '"]');
if (el && !el.hasAttribute('data-no-cache')) { out.push({ f: f, el: el }); }
});
return out;
}
function cacheForm() {
var form = $('houseForm');
if (!form) { return; }
try {
if (!window.localStorage) { return; }
var data = {};
cacheableFields(form).forEach(function (it) { data[it.f.k] = it.el.value; });
window.localStorage.setItem(FORM_CACHE_KEY, JSON.stringify(data));
} catch (e) { /* 存储不可用则静默降级 */ }
}
function cacheFormSoon() {
if (cacheTimer) { window.clearTimeout(cacheTimer); }
cacheTimer = window.setTimeout(cacheForm, 300);
}
function restoreForm() {
var form = $('houseForm');
if (!form) { return; }
try {
if (!window.localStorage) { return; }
var raw = window.localStorage.getItem(FORM_CACHE_KEY);
if (!raw) { return; }
var data = JSON.parse(raw) || {};
cacheableFields(form).forEach(function (it) {
if (data[it.f.k] == null) { return; }
it.el.value = data[it.f.k];
});
} catch (e) { /* 忽略 */ }
}
function clearFormCache() {
try { window.localStorage.removeItem(FORM_CACHE_KEY); } catch (e) { /* 忽略 */ }
}
function openForm(row) {
if (!state.canEdit) { denyWrite(); return; }
clearForm();
if (!row) { restoreForm(); }
state.editingId = row ? rowId(row) : null;
txt($('formTitle'), row ? '编辑楼盘:' + (row['楼盘名称'] || '') : '新增楼盘');
txt($('formSub'), row
? '按阶段补:计划阶段先补齐「选盘理由」和「待验证问题」;去过现场,再补「一句话卖点」和「一句话风险」。'
: '计划先行:先把「为什么选它」和「现场要问什么」写清楚。卖点、风险这些结论踩完再补,现在不用编。');
if (row) {
FIELDS.forEach(function (f) {
var v = row[f.n];
if (f.t === 'select') { fillSelect(f.k, f.n, v == null ? '' : String(v)); }
else if (f.k === 'f5') { var el = $(f.k); if (el) { el.value = dayOf(v); } }
else { var el2 = $(f.k); if (el2) { el2.value = v == null ? '' : String(v); } }
});
}
renderReqHint();
$('modal').className = 'modal';
var first = $('f1');
if (first) { first.focus(); }
}
function closeForm() {
state.editingId = null;
$('modal').className = 'modal hidden';
}
function buildProps(form) {
var props = {};
FIELDS.forEach(function (f) {
var el = form.querySelector('[name="' + f.k + '"]');
if (!el) { return; }
var v = el.value;
if (v === '' || v == null) { return; }
if (f.t === 'number') { props[f.n] = { number: Number(v) }; }
else if (f.t === 'date') { props[f.n] = { date: String(v) }; }
/* 下拉的 value 是选项 id,写回云端要翻译成选项文本 */
else if (f.t === 'select') { props[f.n] = { select: selectText(f.k, f.n) }; }
else { props[f.n] = { text: String(v) }; }
});
return props;
}
function onSave(e) {
if (e) { e.preventDefault(); }
if (!state.canEdit) { denyWrite(); return; }
var form = $('houseForm');
if (!form || state.busy) { return; }
var stageVal = selectText('f4', '阶段') || '待盘点';
var miss = missInForm(form, stageVal);
if (miss) {
toast('还差一项:' + miss.n + '——' + miss.why, true);
var mEl = form.querySelector('[name="' + miss.k + '"]');
if (mEl) { mEl.focus(); }
return;
}
var props = buildProps(form);
if (!props['阶段']) { props['阶段'] = { select: '待盘点' }; }
var btn = $('btnSave');
state.busy = true;
if (btn) { btn.disabled = true; btn.textContent = '保存中…'; }
var done = function () {
state.busy = false;
if (btn) { btn.disabled = false; btn.textContent = '保存'; }
};
if (state.offline) {
var local = { _id: 'L' + Date.now() };
FIELDS.forEach(function (f) {
if (props[f.n]) {
var key = Object.keys(props[f.n])[0];
local[f.n] = props[f.n][key];
}
});
if (state.editingId) {
var old = findRow(state.editingId);
if (old) { FIELDS.forEach(function (f) { if (props[f.n]) { old[f.n] = local[f.n]; } }); }
} else {
state.rows.push(local);
}
saveLocal();
clearFormCache();
closeForm();
refreshAll();
done();
toast('已保存(离线模式,仅存本机)');
return;
}
setSync('busy');
var p;
if (state.editingId) {
p = db.updateRecord({ databaseId: DATABASE_ID, recordId: state.editingId, properties: props });
} else {
p = db.addRecord({ databaseId: DATABASE_ID, properties: props });
}
p.then(function () {
setSync('on');
clearFormCache();
closeForm();
toast('已保存');
return reload();
}).catch(function (err) {
console.error('[database] 保存失败:', err);
setSync('off');
toast('保存失败,请重试(已填写内容保留)', true);
}).then(done, done);
}
/* ================= 阶段流转 ================= */
function updateStage(rowIdv, stage) {
if (!state.canEdit) { denyWrite(); return; }
var row = findRow(rowIdv);
if (state.offline) {
if (row) { row['阶段'] = stage; }
saveLocal(); refreshAll();
toast('已更新为「' + stage + '」(离线模式)');
return;
}
setSync('busy');
var patch = {};
patch['阶段'] = { select: stage };
db.updateRecord({ databaseId: DATABASE_ID, recordId: rowIdv, properties: patch })
.then(function () {
if (row) { row['阶段'] = stage; }
setSync('on');
refreshAll();
toast('已更新为「' + stage + '」');
})
.catch(function (err) {
console.error('[database] 阶段更新失败:', err);
setSync('off');
toast('阶段更新失败,请重试', true);
});
}
function advance(rowIdv) {
if (!state.canEdit) { denyWrite(); return; }
var row = findRow(rowIdv);
if (!row) { return; }
var cur = row['阶段'] || '待盘点';
var nx = nextStage(cur);
if (nx === cur) { toast('已经是「已完成」,不用再推了'); return; }
var miss = missInRow(row, nx);
if (miss) {
toast('还进不了「' + nx + '」:先补上「' + miss.n + '」——' + miss.why, true);
openForm(row);
return;
}
updateStage(rowIdv, nx);
}
function removeRow(rowIdv) {
if (!state.canEdit) { denyWrite(); return; }
var row = findRow(rowIdv);
if (!row) { return; }
if (!window.confirm('删除「' + (row['楼盘名称'] || '') + '」这条记录?删除后不可恢复。')) { return; }
if (state.offline) {
state.rows = state.rows.filter(function (r) { return rowId(r) !== rowIdv; });
saveLocal(); refreshAll(); toast('已删除(离线模式)');
return;
}
setSync('busy');
db.deleteRecord({ databaseId: DATABASE_ID, recordId: rowIdv })
.then(function () {
state.rows = state.rows.filter(function (r) { return rowId(r) !== rowIdv; });
setSync('on');
refreshAll();
toast('已删除');
})
.catch(function (err) {
console.error('[database] 删除失败:', err);
setSync('off');
toast('删除失败,请重试', true);
});
}
/* ================= 备份 / 恢复 / 清空 ================= */
function exportJSON() {
var payload = { app: '深圳新房踩盘工作台', exportedAt: new Date().toISOString(), count: state.rows.length, rows: state.rows };
var blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json;charset=utf-8' });
var url = URL.createObjectURL(blob);
var a = document.createElement('a');
a.href = url;
a.download = '深圳新房踩盘库_' + todayStr() + '.json';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
window.setTimeout(function () { URL.revokeObjectURL(url); }, 2000);
toast('已导出 ' + state.rows.length + ' 条记录');
}
function importJSON(file) {
if (!state.canEdit) { denyWrite(); return; }
var reader = new FileReader();
reader.onload = function () {
var rows = null;
try {
var obj = JSON.parse(String(reader.result));
if (obj && obj.rows && obj.rows.length !== undefined) { rows = obj.rows; }
else if (Object.prototype.toString.call(obj) === '[object Array]') { rows = obj; }
} catch (e) { rows = null; }
if (!rows) { toast('导入失败:文件格式无法识别', true); return; }
if (state.offline) {
state.rows = rows;
saveLocal(); refreshAll();
toast('已从备份恢复 ' + rows.length + ' 条(离线模式)');
return;
}
var exist = {};
state.rows.forEach(function (r) { exist[String(r['楼盘名称'] || '')] = true; });
var pending = rows.filter(function (r) { return r && r['楼盘名称'] && !exist[String(r['楼盘名称'])]; });
if (!pending.length) { toast('备份里没有新增记录,无需恢复'); return; }
if (!window.confirm('将向云端补录 ' + pending.length + ' 条备份记录(按楼盘名称去重,不覆盖已有)。继续?')) { return; }
setSync('busy');
var i = 0;
var step = function () {
if (i >= pending.length) {
setSync('on');
toast('已补录 ' + pending.length + ' 条');
return reload();
}
var src = pending[i];
var props = {};
FIELDS.forEach(function (f) {
var v = src[f.n];
if (v === '' || v == null) { return; }
if (f.t === 'number') { props[f.n] = { number: Number(v) }; }
else if (f.t === 'date') { props[f.n] = { date: dayOf(v) }; }
else if (f.t === 'select') { props[f.n] = { select: String(v) }; }
else { props[f.n] = { text: String(v) }; }
});
i++;
return db.addRecord({ databaseId: DATABASE_ID, properties: props })
.then(step)
.catch(function (err) {
console.error('[database] 恢复中断:', err);
setSync('off');
toast('恢复在第 ' + i + ' 条中断,已成功 ' + (i - 1) + ' 条', true);
});
};
return step();
};
reader.readAsText(file);
}
function onClear() {
if (!state.canEdit) { denyWrite(); return; }
if (!state.rows.length) { toast('当前没有任何记录'); return; }
if (!window.confirm('确定清空全部 ' + state.rows.length + ' 条踩盘记录吗?此操作不可撤销,建议先「导出 JSON 备份」。')) { return; }
if (state.offline) {
state.rows = []; saveLocal(); refreshAll(); toast('已清空(离线模式)');
return;
}
var ids = state.rows.map(rowId).filter(Boolean);
if (!ids.length) { return; }
setSync('busy');
var i = 0;
var step = function () {
if (i >= ids.length) { setSync('on'); refreshAll(); toast('已清空全部记录'); return; }
var id = ids[i]; i++;
return db.deleteRecord({ databaseId: DATABASE_ID, recordId: id })
.then(step)
.catch(function (err) {
console.error('[database] 清空中断:', err);
setSync('off');
toast('清空在第 ' + i + ' 条中断', true);
});
};
return step();
}
/* ================= 变更订阅 ================= */
var subTimer = null;
function subscribe() {
if (!db || typeof db.onUpdated !== 'function') { return; }
db.onUpdated(function (payload) {
var ids = (payload && payload.databaseIds) ? payload.databaseIds : [];
if (ids.indexOf(DATABASE_ID) === -1) { return; }
if (subTimer) { window.clearTimeout(subTimer); }
subTimer = window.setTimeout(function () {
loadAll().then(function (rows) { state.rows = rows; refreshAll(); })
.catch(function (err) { console.error('[database] 订阅刷新失败:', err); });
}, 300);
});
}
/* ================= 访问控制 ================= */
var WRITE_METHODS = ['addRecord', 'updateRecord', 'deleteRecord'];
/* 把平台 database 通道包一层:只读态下三个写方法直接拒绝(兜底,防漏掉的 UI 入口) */
function guardDb(raw) {
if (!raw || typeof Proxy !== 'function') { return raw; }
return new Proxy(raw, {
get: function (target, prop) {
if (WRITE_METHODS.indexOf(prop) !== -1 && !state.canEdit) {
return function () { return Promise.reject(new Error('只读模式:当前账号没有编辑权限')); };
}
var v = target[prop];
return typeof v === 'function' ? function () { return v.apply(target, arguments); } : v;
}
});
}
function roleText(info) {
var r = info && info.role;
if (r === 'admin') { return '管理员:可编辑,可管理分享链接'; }
if (r === 'edit') { return '可编辑链接' + (info.label ? ':' + info.label : ''); }
if (r === 'view') { return '仅查看链接' + (info.label ? ':' + info.label : ''); }
return (info && info.note) || '没有有效的分享链接';
}
/* 返回 {ok, admin, role, note};绝不 reject */
function checkAccess() {
return apiDb.getUserInfo()
.then(function (info) {
return { ok: !!(info && info.canEdit), admin: !!(info && info.isAdmin), role: (info && info.role) || 'none', note: roleText(info) };
})
.catch(function (err) {
console.error('[acl] 身份校验失败:', err);
return { ok: false, admin: false, role: 'error', note: '无法连接服务器:' + errText(err) };
});
}
function applyAccess(r) {
state.canEdit = !!(r && r.ok);
state.isAdmin = !!(r && r.admin);
state.role = (r && r.role) || 'none';
state.aclNote = (r && r.note) || '';
if (document.body && document.body.classList) {
document.body.classList.toggle('readonly', !state.canEdit);
document.body.classList.toggle('is-admin', state.isAdmin);
document.body.classList.toggle('no-link', state.role === 'none' || state.role === 'error');
}
txt($('roWho'), state.aclNote);
if (!state.canEdit) { closeForm(); }
refreshAll();
}
function denyWrite() {
toast('当前是只读模式,没有编辑权限', true);
return false;
}
function copyText(text, tip) {
var legacy = function () {
try {
var ta = document.createElement('textarea');
ta.value = text; ta.setAttribute('readonly', '');
ta.style.position = 'fixed'; ta.style.top = '0'; ta.style.left = '0'; ta.style.opacity = '0';
document.body.appendChild(ta); ta.select(); ta.setSelectionRange(0, text.length);
var ok = document.execCommand('copy');
document.body.removeChild(ta);
if (ok) { toast(tip); return; }
} catch (e) { /* 继续走 prompt */ }
window.prompt('请手动复制:', text);
};
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).then(function () { toast(tip); }).catch(legacy);
} else { legacy(); }
}
/* ================= 管理员 / 分享管理 ================= */
function openAdmin() {
$('adminModal').className = 'modal';
if (state.isAdmin) {
$('adminLogin').className = 'hidden'; $('adminPanel').className = '';
loadShares();
} else {
$('adminLogin').className = ''; $('adminPanel').className = 'hidden';
var pw = $('adminPw'); if (pw) { pw.value = ''; pw.focus(); }
}
}
function closeAdmin() { $('adminModal').className = 'modal hidden'; }
function adminLogin() {
var pw = $('adminPw').value;
if (!pw) { toast('请输入口令', true); return; }
apiFetch('POST', '/admin/login', { password: pw })
.then(function (r) {
setStore(ADMIN_KEY, r.token);
$('adminPw').value = '';
toast('管理员已登录');
return connect().then(openAdmin);
})
.catch(function (err) { toast('登录失败:' + errText(err), true); });
}
function adminLogout() {
apiFetch('POST', '/admin/logout').catch(function () { /* 会话可能已失效 */ });
setStore(ADMIN_KEY, '');
closeAdmin();
toast('已退出管理员');
connect();
}
function shareUrl(token) {
return window.location.origin + window.location.pathname + '?t=' + encodeURIComponent(token);
}
function two(n) { return (n < 10 ? '0' : '') + n; }
function fmtTs(t) {
if (!t) { return ''; }
var d = new Date(t);
return d.getFullYear() + '-' + two(d.getMonth() + 1) + '-' + two(d.getDate()) + ' ' + two(d.getHours()) + ':' + two(d.getMinutes());
}
var showHiddenShares = false;
function loadShares() {
apiFetch('GET', '/shares' + (showHiddenShares ? '?all=1' : ''))
.then(function (r) { renderShares((r && r.results) || [], (r && r.hiddenCount) || 0); })
.catch(function (err) { toast('读取分享列表失败:' + errText(err), true); });
}
function renderShares(list, hiddenCount) {
var head = hiddenCount > 0 || showHiddenShares
? ''
+ (showHiddenShares ? '收起已隐藏的链接' : '显示已隐藏的链接(' + hiddenCount + ')') + '
'
: '';
if (!list.length) {
setHTML($('shList'), head + '还没有分享链接。选好权限、填上备注,点「生成链接」。
');
return;
}
var parts = list.map(function (s) {
var url = shareUrl(s.token);
var isEdit = s.role === 'edit';
var meta = fmtTs(s.created_at) + (s.last_used ? ' · 最近使用 ' + fmtTs(s.last_used) : ' · 还没人打开过')
+ (s.revoked ? ' · 已作废' : '') + (s.hidden ? ' · 已隐藏' : '');
var ops;
if (!s.revoked) {
ops = '复制链接 '
+ '作废 ';
} else if (s.hidden) {
ops = '恢复显示 ';
} else {
ops = '不再显示 ';
}
return ''
+ '' + (isEdit ? '可编辑' : '仅查看') + ' '
+ '' + esc(s.label || '(未备注)') + ' '
+ '' + esc(meta) + ' '
+ '' + esc(url) + ' '
+ ops
+ '
';
});
setHTML($('shList'), head + parts.join(''));
}
function setShareHidden(token, hide) {
apiFetch('POST', '/shares/' + encodeURIComponent(token) + (hide ? '/hide' : '/unhide'))
.then(function () { toast(hide ? '已从列表隐藏' : '已恢复显示'); loadShares(); })
.catch(function (err) { toast('操作失败:' + errText(err), true); });
}
function createShare() {
var role = $('shRole').value === 'edit' ? 'edit' : 'view';
var label = String($('shLabel').value || '').trim();
apiFetch('POST', '/shares', { role: role, label: label })
.then(function (s) {
$('shLabel').value = '';
copyText(shareUrl(s.token), (role === 'edit' ? '可编辑' : '仅查看') + '链接已生成并复制');
loadShares();
})
.catch(function (err) { toast('生成失败:' + errText(err), true); });
}
function revokeShare(token) {
if (!window.confirm('作废这条链接?拿到它的人将立刻无法访问。')) { return; }
apiFetch('DELETE', '/shares/' + encodeURIComponent(token))
.then(function () { toast('已作废'); loadShares(); })
.catch(function (err) { toast('作废失败:' + errText(err), true); });
}
/* ================= 初始化 ================= */ /* ================= 初始化 ================= */
var bootTries = 0;
function showDiag(html) {
var el = $('connDiag');
if (!el) { return; }
setHTML(el, html);
el.className = 'conn-diag';
}
function hideDiag() {
var el = $('connDiag');
if (el) { el.className = 'conn-diag hidden'; }
}
function errText(err) {
return String((err && err.message) || err || '未知错误').slice(0, 160);
}
function envInfo() {
return { href: String(window.location.href || '').slice(0, 130), online: navigator.onLine !== false };
}
function diagDetail(reason) {
var e = envInfo();
return '' + esc(reason) + ' '
+ '当前地址:' + esc(e.href)
+ ' | 网络:' + (e.online ? '在线' : '离线')
+ ' | 身份:' + esc(state.aclNote || '未识别') + (state.canEdit ? '(可编辑)' : '(只读)') + '
'
+ '请确认网络后点「重试连接」,或刷新页面。
'
+ '重试连接 ';
}
function bootConnect() {
var schemaP = db.getSchema({ databaseId: DATABASE_ID })
.then(function (schema) {
state.options = collectOptions(schema);
renderSelects(state.options);
})
.catch(function (err) {
console.error('[database] 获取表结构失败(不阻塞数据加载):', err);
});
var rowsP = loadAll();
Promise.all([schemaP, rowsP])
.then(function (arr) {
state.rows = arr[1];
setSync('on');
hideDiag();
refreshAll();
subscribe();
})
.catch(function (err) { bootFail(err); });
}
function bootFail(err) {
console.error('[database] 初始化失败:', err);
bootTries++;
if (bootTries <= 2) {
setSync('busy');
showDiag('连接第 ' + bootTries + ' 次失败(' + esc(errText(err)) + '),正在重试… ');
window.setTimeout(bootConnect, 2500 * bootTries);
return;
}
setSync('off');
loadLocal();
refreshAll();
showDiag(diagDetail('连接失败:' + errText(err) + '。当前显示本地离线缓存。'));
toast('服务器暂时连不上,已切到本地离线模式', true);
}
/* 身份校验 → 数据加载;没有有效链接就停在提示,不再反复重试 */
function connect() {
setSync('busy');
return checkAccess().then(function (r) {
applyAccess(r);
if (state.role === 'none') {
setSync('off');
showDiag('' + esc(state.aclNote) + '。请向管理员索取分享链接。 '
+ '管理员登录 ');
return;
}
if (state.role === 'error') {
setSync('off');
loadLocal();
refreshAll();
showDiag(diagDetail(state.aclNote + '。当前显示本地离线缓存。'));
return;
}
bootTries = 0;
bootConnect();
});
}
function boot() {
bindEvents();
renderSelects({});
connect();
}
function bindEvents() {
var navs = document.querySelectorAll('.nav-item, .tabbar button');
Array.prototype.forEach.call(navs, function (b) {
b.addEventListener('click', function () { showView(b.getAttribute('data-view')); });
});
if ($('btnNew')) { $('btnNew').addEventListener('click', function () { openForm(null); }); }
document.addEventListener('click', function (ev) {
var t = ev.target;
if (t && t.id === 'btnRetry') {
showDiag('正在重新连接… ');
connect();
}
if (t && (t.id === 'btnAdminEntry' || t.id === 'btnAdminEntry2' || t.id === 'btnAdminEntry3')) { openAdmin(); }
});
if ($('btnCancel')) { $('btnCancel').addEventListener('click', closeForm); }
if ($('houseForm')) {
$('houseForm').addEventListener('submit', onSave);
$('houseForm').addEventListener('input', cacheFormSoon);
$('houseForm').addEventListener('change', cacheFormSoon);
}
if ($('f4')) { $('f4').addEventListener('change', renderReqHint); }
/* 填完「所属板块」自动带出行政区(如 南山·后海 → 南山),省一步手选 */
if ($('f3')) {
$('f3').addEventListener('blur', autoDist);
$('f3').addEventListener('change', autoDist);
}
if ($('btnExport')) { $('btnExport').addEventListener('click', exportJSON); }
if ($('btnShare')) { $('btnShare').addEventListener('click', openAdmin); }
if ($('btnAdminCancel')) { $('btnAdminCancel').addEventListener('click', closeAdmin); }
if ($('btnAdminClose')) { $('btnAdminClose').addEventListener('click', closeAdmin); }
if ($('btnAdminLogin')) { $('btnAdminLogin').addEventListener('click', adminLogin); }
if ($('adminPw')) { $('adminPw').addEventListener('keydown', function (ev) { if (ev.key === 'Enter') { ev.preventDefault(); adminLogin(); } }); }
if ($('btnAdminLogout')) { $('btnAdminLogout').addEventListener('click', adminLogout); }
if ($('btnShCreate')) { $('btnShCreate').addEventListener('click', createShare); }
if ($('btnImport')) { $('btnImport').addEventListener('click', function () { $('fileImport').click(); }); }
if ($('fileImport')) {
$('fileImport').addEventListener('change', function (ev) {
var f = ev.target.files && ev.target.files[0];
if (f) { importJSON(f); }
ev.target.value = '';
});
}
if ($('btnClear')) { $('btnClear').addEventListener('click', onClear); }
document.addEventListener('click', function (ev) {
var t = ev.target;
var goBtn = closestAttr(t, 'data-go');
if (goBtn) { advance(goBtn.getAttribute('data-go')); return; }
var nextBtn = closestAttr(t, 'data-next');
if (nextBtn) { advance(nextBtn.getAttribute('data-next')); return; }
var editBtn = closestAttr(t, 'data-edit');
if (editBtn) { openForm(findRow(editBtn.getAttribute('data-edit'))); return; }
var delBtn = closestAttr(t, 'data-del');
if (delBtn) { removeRow(delBtn.getAttribute('data-del')); return; }
var cp = closestAttr(t, 'data-copy');
if (cp) { copyText(cp.getAttribute('data-copy'), '链接已复制'); return; }
var rv = closestAttr(t, 'data-revoke');
if (rv) { revokeShare(rv.getAttribute('data-revoke')); return; }
var hd = closestAttr(t, 'data-hide');
if (hd) { setShareHidden(hd.getAttribute('data-hide'), true); return; }
var uh = closestAttr(t, 'data-unhide');
if (uh) { setShareHidden(uh.getAttribute('data-unhide'), false); return; }
if (t && t.id === 'btnShToggleHidden') { showHiddenShares = !showHiddenShares; loadShares(); return; }
var foldBtn = closestAttr(t, 'data-fold');
if (foldBtn) { toggleFold(foldBtn); return; }
/* ---- 多维看板 ---- */
if (t && t.id === 'btnBclear') { bdClear(); return; }
var unf = closestAttr(t, 'data-unf');
if (unf) {
var uv = String(unf.getAttribute('data-unf')).split('::');
if (uv.length === 2) { bdToggle(uv[0], uv[1]); }
return;
}
var mxc = closestAttr(t, 'data-mxc');
if (mxc) { mxCol = mxc.getAttribute('data-mxc'); renderBoard(); return; }
var mcell = closestAttr(t, 'data-mxr');
if (mcell) {
bdSetPair('板块分层', mcell.getAttribute('data-mxr'),
mcell.getAttribute('data-mxk'), mcell.getAttribute('data-mxv'));
return;
}
var cell = closestAttr(t, 'data-bd');
if (cell) { bdToggle(cell.getAttribute('data-bd'), cell.getAttribute('data-bk')); return; }
});
}
function closestAttr(node, attr) {
var el = node;
while (el && el.getAttribute) {
if (el.hasAttribute(attr)) { return el; }
el = el.parentNode;
}
return null;
}
function toggleFold(btn) {
var card = closestAttr(btn, 'data-card');
var body = card ? card.querySelector('[data-more]') : null;
if (!body) { return; }
var open = body.getAttribute('data-more') === '1';
body.setAttribute('data-more', open ? '0' : '1');
body.className = open ? 'hidden' : '';
var labOpen = btn.getAttribute('data-label-open') || '展开细节';
var labClose = btn.getAttribute('data-label-close') || '收起';
btn.textContent = open ? labOpen : labClose;
}
/* ================= 计算层:今天要处理 / 统计 ================= */
var TASK_META = {
over: { label: '逾期', cls: 't-ov' },
today: { label: '今天要踩', cls: 't-today' },
rev: { label: '待复盘', cls: 't-rev' }
};
function pendingTasks() {
var t = todayStr();
var out = [];
state.rows.forEach(function (r) {
var st = r['阶段'] || '待盘点';
if (st === '已完成') { return; }
var d = dayOf(r['计划踩盘日']);
var type = null;
if (d && d < t) { type = 'over'; }
else if (d && d === t) { type = 'today'; }
else if (st === '待复盘') { type = 'rev'; }
if (type) { out.push({ row: r, type: type, d: d }); }
});
var order = { over: 0, today: 1, rev: 2 };
out.sort(function (a, b) {
if (order[a.type] !== order[b.type]) { return order[a.type] - order[b.type]; }
return String(a.d || '9999').localeCompare(String(b.d || '9999'));
});
return out;
}
function calcStats() {
var s = { total: state.rows.length, done: 0, wc: 0, sum: 0, n: 0, risk: 0 };
state.rows.forEach(function (r) {
var st = r['阶段'] || '待盘点';
if (st === '已完成') { s.done++; }
if (st === '待盘点' || st === '待踩盘') { s.wc++; }
var p = r['参考均价'];
if (p != null && p !== '' && !isNaN(Number(p))) { s.sum += Number(p); s.n++; }
if (r['一句话风险']) { s.risk++; }
});
s.avg = s.n ? Math.round(s.sum / s.n) : null;
return s;
}
function bind(inner) {
return '' + inner + ' ';
}
function tierCls(tier) {
if (tier === '核心区') { return 'chip tier1'; }
if (tier === '潜力区') { return 'chip tier2'; }
if (tier === '刚需区') { return 'chip tier3'; }
return 'chip';
}
function rateCls(rate) {
if (rate === '强烈推荐' || rate === '推荐') { return 'chip tier3'; }
if (rate === '放弃') { return 'chip tier1'; }
return 'chip rate';
}
function tierChip(tier) {
if (!tier) { return ''; }
return '' + esc(tier) + ' ';
}
function metaLine(r) {
var parts = [];
parts.push(r['所属板块'] || '板块待填');
parts.push(dayOf(r['计划踩盘日']) ? '计划 ' + dayOf(r['计划踩盘日']) : '未排期');
parts.push(r['参考均价'] ? fmtMoney(r['参考均价']) + ' 元/㎡' : '均价待补');
if (r['主力户型']) { parts.push(String(r['主力户型'])); }
return parts.join(' · ');
}
/* ================= 多维看板:维度定义 · 交叉筛选 · 矩阵 ================= */
var BDIMS = [
{ key: '阶段', short: '阶段', label: '阶段', note: '这周该干什么,一眼分堆', keys: STAGES },
{ key: '板块分层', short: '分层', label: '板块分层', note: '核心看稀缺 · 潜力看兑现 · 刚需看通勤', keys: TIERS.concat(['未填']) },
{ key: '行政区', short: '行政区', label: '行政区', note: '空着的行政区,就是资源库还没铺到的地方', keys: DISTS.concat(['未填']) },
{ key: '单价', short: '单价', label: '单价区间(元/㎡)', note: '按参考均价自动归档,不用手填', keys: PRICE_BANDS.map(function (b) { return b.k; }).concat(['待补']) },
{ key: '总价', short: '总价', label: '总价区间', note: '按「总价区间」字段归档;没填的按主力建面中位估算', keys: TOTAL_BANDS.concat(['待补']) }
];
var mxCol = '行政区';
var bf = {};
BDIMS.forEach(function (d) { bf[d.key] = []; });
function numOf(v) {
if (v == null || v === '') { return null; }
var n = Number(v);
return isNaN(n) ? null : n;
}
/* 行政区:优先读字段;字段空则从「所属板块」前缀推导(如 南山·后海 → 南山) */
function distOf(r) {
var v = r['行政区'];
if (v) { return v; }
var s = String(r['所属板块'] || '') + ' ' + String(r['备注'] || '');
for (var i = 0; i < DISTS.length; i++) {
if (s.indexOf(DISTS[i]) > -1) { return DISTS[i]; }
}
return '未填';
}
function priceBandOf(r) {
var p = numOf(r['参考均价']);
if (p == null) { return '待补'; }
for (var i = 0; i < PRICE_BANDS.length; i++) {
if (p >= PRICE_BANDS[i].lo && p < PRICE_BANDS[i].hi) { return PRICE_BANDS[i].k; }
}
return '待补';
}
/* 从「主力户型」里抠出所有㎡前的建面数字(含区间两端:216-305㎡ → 216/305),
取最小/最大当建面区间;只抓区间末端会让总价档整体偏高。 */
function areaRangeOf(r) {
var s = String(r['主力户型'] || '');
var re = /(\d+(?:\.\d+)?)\s*(?:-\s*(\d+(?:\.\d+)?))?\s*㎡/g;
var nums = [];
var m = re.exec(s);
while (m !== null) {
nums.push(Number(m[1]));
if (m[2]) { nums.push(Number(m[2])); }
m = re.exec(s);
}
if (!nums.length) { return null; }
return { lo: Math.min.apply(null, nums), hi: Math.max.apply(null, nums) };
}
function totalBandOf(r) {
var v = r['总价区间'];
if (TOTAL_BANDS.indexOf(v) > -1) { return v; }
var p = numOf(r['参考均价']);
var rg = areaRangeOf(r);
if (p == null || !rg) { return '待补'; }
var wan = p * ((rg.lo + rg.hi) / 2) / 10000;
for (var i = 0; i < TOTAL_BUCKETS.length; i++) {
if (wan < TOTAL_BUCKETS[i].hi) { return TOTAL_BUCKETS[i].k; }
}
return '待补';
}
function boardVal(r, key) {
if (key === '阶段') { return r['阶段'] || '待盘点'; }
if (key === '板块分层') { return r['板块分层'] || '未填'; }
if (key === '行政区') { return distOf(r); }
if (key === '单价') { return priceBandOf(r); }
return totalBandOf(r);
}
/* 交叉筛选规则:同维度内取并集,维度之间取交集。
统计某个维度各项数量时,剔除该维度自身的筛选,否则点一下别的格子就全归零。 */
function bdHit(r, skip1, skip2) {
for (var i = 0; i < BDIMS.length; i++) {
var k = BDIMS[i].key;
if (k === skip1 || k === skip2) { continue; }
if (!bf[k].length) { continue; }
if (bf[k].indexOf(boardVal(r, k)) === -1) { return false; }
}
return true;
}
function bdToggle(key, val) {
var i = bf[key].indexOf(val);
if (i > -1) { bf[key].splice(i, 1); } else { bf[key].push(val); }
renderBoard();
}
function bdSetPair(k1, v1, k2, v2) {
var both = bf[k1].indexOf(v1) > -1 && bf[k2].indexOf(v2) > -1;
if (both) {
bf[k1] = bf[k1].filter(function (x) { return x !== v1; });
bf[k2] = bf[k2].filter(function (x) { return x !== v2; });
} else {
if (bf[k1].indexOf(v1) === -1) { bf[k1].push(v1); }
if (bf[k2].indexOf(v2) === -1) { bf[k2].push(v2); }
}
renderBoard();
}
function bdClear() {
BDIMS.forEach(function (d) { bf[d.key] = []; });
renderBoard();
}
function bdActive() {
var n = 0;
BDIMS.forEach(function (d) { n += bf[d.key].length; });
return n;
}
function hitMeta(r) {
var p = [];
p.push(r['所属板块'] || '板块待填');
p.push(r['参考均价'] ? fmtMoney(r['参考均价']) + ' 元/㎡' : '均价待补');
p.push('总价 ' + totalBandOf(r));
if (r['主力户型']) { p.push(String(r['主力户型'])); }
return p.join(' · ');
}
function fade(a) { return 'background:rgba(37,99,235,' + a.toFixed(2) + ');color:#fff;font-weight:700'; }
function mxCell(n, mx, on, rk, ck) {
if (!n) { return '· '; }
var a = 0.16 + 0.66 * (n / mx);
return '' + esc(String(n)) + ' ';
}
function renderMatrix() {
var colDef = null;
BDIMS.forEach(function (d) { if (d.key === mxCol) { colDef = d; } });
if (!colDef) { mxCol = '行政区'; colDef = BDIMS[2]; }
var cols = colDef.keys;
var pool = state.rows.filter(function (r) { return bdHit(r, '板块分层', mxCol); });
var grid = {}, rowSum = {}, colSum = {}, mx = 1;
TIERS.forEach(function (t) { grid[t] = {}; rowSum[t] = 0; });
cols.forEach(function (c) { colSum[c] = 0; });
pool.forEach(function (r) {
var t = r['板块分层'] || '未填';
if (!grid[t]) { grid[t] = {}; rowSum[t] = 0; }
var c = boardVal(r, mxCol);
grid[t][c] = (grid[t][c] || 0) + 1;
rowSum[t]++;
colSum[c] = (colSum[c] || 0) + 1;
if (grid[t][c] > mx) { mx = grid[t][c]; }
});
var tabs = '';
['行政区', '阶段', '单价', '总价'].forEach(function (k) {
var lb = k;
BDIMS.forEach(function (d) { if (d.key === k) { lb = d.short; } });
tabs += '列=' + esc(lb) + ' ';
});
tabs += '
';
var t = ' ';
cols.forEach(function (c) { t += '' + esc(c) + ' '; });
t += '合计 ';
var rk = TIERS.slice();
if (grid['未填']) { rk.push('未填'); }
rk.forEach(function (tw) {
t += '' + esc(tw) + ' ';
cols.forEach(function (c) {
var n = (grid[tw] && grid[tw][c]) || 0;
var on = bf['板块分层'].indexOf(tw) > -1 && bf[mxCol].indexOf(c) > -1;
t += '' + mxCell(n, mx, on, tw, c) + ' ';
});
t += '' + esc(String(rowSum[tw] || 0)) + ' ';
});
t += '合计 ';
cols.forEach(function (c) { t += '' + esc(String(colSum[c] || 0)) + ' '; });
t += '' + esc(String(pool.length)) + '
';
return '
交叉矩阵 · 板块分层 × ' + esc(colDef.short) + ' '
+ '格子里是盘数;点格子=同时按这一层和这个' + esc(colDef.short) + '筛选,再点一次取消 '
+ tabs + '
' + t + '
';
}
function renderBoard() {
var top = $('boardTop');
var box = $('boardBody');
if (!box) { return; }
if (!state.rows.length) {
setHTML(top, '');
setHTML(box, '还没有楼盘。先录几个盘,看板才会活起来。
');
return;
}
var act = bdActive();
var hit = state.rows.filter(function (r) { return bdHit(r); });
/* 筛选条 */
var fh = '';
BDIMS.forEach(function (d) {
bf[d.key].forEach(function (k) {
fh += '' + esc(d.label)
+ ':' + esc(k) + ' × ';
});
});
if (!act) {
fh = '未筛选 · 点下面任意格子做交叉筛选,可以叠加多个条件 ';
}
var bar = '' + fh + '
';
setHTML(top, bar + (act ? '清空筛选(' + esc(String(act)) + ') ' : ''));
var html = '';
/* 命中的盘(只在筛选后出现) */
if (act) {
html += '命中 ' + esc(String(hit.length)) + ' 个盘'
+ ' · 共 '
+ esc(String(state.rows.length)) + ' 个
';
if (!hit.length) {
html += '
这个组合下没有盘——换个条件,或者先去把盘录进来。
';
}
hit.forEach(function (r) {
var id = rowId(r);
html += '
' + bind(esc(r['楼盘名称'] || '(未命名)')) + ' '
+ tierChip(r['板块分层'])
+ '' + esc(distOf(r)) + ' '
+ '' + esc(r['阶段'] || '待盘点') + ' '
+ '编辑 '
+ '' + bind(esc(hitMeta(r))) + '
';
});
html += '
';
}
/* 五个维度(每个维度都是独立一行可点的格子) */
BDIMS.forEach(function (d) {
var pool = state.rows.filter(function (r) { return bdHit(r, d.key); });
var cnt = {};
pool.forEach(function (r) { var v = boardVal(r, d.key); cnt[v] = (cnt[v] || 0) + 1; });
html += '
' + esc(d.label) + ' '
+ '' + esc(d.note) + ' ';
d.keys.forEach(function (k) {
var n = cnt[k] || 0;
var on = bf[d.key].indexOf(k) > -1;
html += '' + esc(k)
+ '' + esc(String(n)) + ' ';
});
html += '
';
});
html += renderMatrix();
setHTML(box, html);
}
/* ================= 渲染层(彼此不互相调用,由 refreshAll 统一调度) ================= */
function renderToday() {
var tasks = pendingTasks();
var list = $('todayList');
var badge = document.querySelector('[data-stat="today_n"]');
txt(badge, tasks.length);
if (badge) {
}
txt($('todayFlag'), tasks.length ? '逾期 → 今日 → 待复盘,按顺序清' : '今天没有卡住的活,节奏很好');
if (!tasks.length) {
setHTML(list, '今天没有待处理项。昨天没做完的会自动滚进来,不会凭空消失。
');
return;
}
var html = '';
tasks.forEach(function (t) {
var r = t.row;
var meta = TASK_META[t.type];
var st = r['阶段'] || '待盘点';
var id = rowId(r);
var line = (r['所属板块'] || '板块待填') + ' · ' + st + ' · 计划 ' + (t.d || '未排期');
html += ''
+ '' + esc(meta.label) + ' '
+ '' + bind(esc(r['楼盘名称'] || '(未命名)')) + ' '
+ prioChip(r['优先级'])
+ '' + bind(esc(line)) + ' '
+ '推进到「' + esc(nextStage(st)) + '」 '
+ '
';
});
setHTML(list, html);
}
function renderStats() {
var s = calcStats();
var box = $('statsRow');
if (!box) { return; }
var cards = [
{ k: '在库楼盘', v: String(s.total), u: '个' },
{ k: '已踩完', v: String(s.done), u: '个' },
{ k: '未踩完', v: String(s.wc), u: '个' },
{ k: '参考均价均值', v: s.avg == null ? '—' : fmtMoney(s.avg), u: s.avg == null ? '' : '元/㎡' }
];
var html = '';
cards.forEach(function (c) {
var uv = c.u ? (' ' + esc(c.u) + ' ') : '';
html += '' + esc(c.k) + '
'
+ '
' + bind(esc(c.v) + uv) + '
';
});
if (s.total >= 30) {
html += '备份提醒
'
+ '
数据已积累 ' + bind(esc(String(s.total)))
+ ' 条,建议点「导出 JSON 备份」存一份本地快照。
';
}
setHTML(box, html);
}
function renderNavCounts() {
var s = calcStats();
var map = { c_plan: s.total, c_board: s.total, c_card: s.total, c_grid: TIERS.length };
Object.keys(map).forEach(function (key) {
var el = document.querySelector('[data-stat="' + key + '"]');
txt(el, map[key]);
if (el) {
}
});
txt($('topSub'), s.total
? '在库 ' + s.total + ' 个盘 · 已踩完 ' + s.done + ' 个 · 未完成 ' + s.wc + ' 个'
: '先排计划再踩现场:计划阶段只要求写清「为什么选它」,卖点和风险踩完再补');
}
/* 一周节奏带:把「盘点 → 踩盘 → 复盘」钉在页面最上面 */
function renderRhythm() {
var h = '';
RHYTHM.forEach(function (x) {
h += '
' + esc(x.d) + '
'
+ '
' + esc(x.t) + '
'
+ '
' + esc(x.p) + '
';
});
h += '
';
return h;
}
var STAGE_TIP = {
'待盘点': '本周要跑的候选。先把「为什么选它」和「现场要问什么」写清楚,再去排路线。',
'待踩盘': '问题清单已就位——到现场照着问,一条一条划掉。',
'待复盘': '去过了。现在把结论写下来:一句话卖点 + 一句话风险,写不出就别定级。',
'已完成': '本周产出。这两句话可以直接拿去跟客户讲。'
};
function renderPlan() {
var box = $('planBody');
if (!box) { return; }
if (!state.rows.length) {
setHTML(box, renderRhythm()
+ '还没有楼盘。点右上角「新增楼盘」,先把本周重点关注的几个盘录进来(周一至周二盘点)。
');
return;
}
var html = renderRhythm();
STAGES.forEach(function (st) {
var group = state.rows.filter(function (r) { return (r['阶段'] || '待盘点') === st; });
if (!group.length) { return; }
group.sort(function (a, b) {
var pa = prioRank(a['优先级']);
var pb = prioRank(b['优先级']);
if (pa !== pb) { return pa - pb; }
return String(dayOf(a['计划踩盘日']) || '9999').localeCompare(String(dayOf(b['计划踩盘日']) || '9999'));
});
var undone = group.filter(function (r) { return missInRow(r, st); }).length;
html += '' + esc(st) + ' '
+ '' + bind(esc(group.length + ' 个盘' + (undone ? ' · ' + undone + ' 个待补必填' : ''))) + '
'
+ '
' + esc(STAGE_TIP[st] || '') + '
';
group.forEach(function (r) {
var id = rowId(r);
var qn = qCount(r['待验证问题']);
var missRow = missInRow(r, st);
html += '
'
+ '
'
+ '' + bind(esc(r['楼盘名称'] || '(未命名)')) + ' '
+ prioChip(r['优先级'])
+ tierChip(r['板块分层'])
+ (r['综合评级'] ? '' + esc(r['综合评级']) + ' ' : '')
+ (missRow ? '缺 ' + esc(missRow.n) + ' ' : '')
+ '' + bind(esc(metaLine(r))) + ' '
+ ''
+ (st === '已完成' ? '' : '推进到「' + esc(nextStage(st)) + '」 ')
+ '编辑 '
+ '删除 '
+ '
';
if (r['选盘理由']) {
html += '
' + bind(esc(r['选盘理由'])) + '
';
}
if (qn) {
html += '
展开待验证问题清单(' + esc(String(qn)) + ' 条) '
+ '
'
+ '
到现场要问清楚的
'
+ '
' + bind(esc(r['待验证问题'])) + ' ';
}
html += '
';
});
html += '
';
});
setHTML(box, html);
}
function kv(label, value) {
var v = (value === 0) ? '0' : (value || '—');
return '' + esc(label) + ' '
+ bind(esc(v)) + '
';
}
function renderCards() {
var box = $('cardsBody');
if (!box) { return; }
if (!state.rows.length) {
setHTML(box, '还没有评价卡。录一个盘,先用一句话把它说清楚。
');
return;
}
var rows = state.rows.slice().sort(function (a, b) {
return String(a['板块分层'] || 'zz').localeCompare(String(b['板块分层'] || 'zz'));
});
var html = '';
rows.forEach(function (r) {
var id = rowId(r);
var st = r['阶段'] || '待盘点';
var detail = ''
+ (r['选盘理由']
? '选盘理由: '
+ bind(esc(r['选盘理由'])) + '
'
: '')
+ (r['待验证问题']
? '到现场要问清楚的(' + bind(esc(String(qCount(r['待验证问题']))))
+ ' 条)
' + bind(esc(r['待验证问题'])) + ' '
: '')
+ '硬指标
'
+ kv('容积率', r['容积率']) + kv('梯户比', r['梯户比'])
+ kv('车位比', r['车位比']) + kv('得房率', r['得房率']) + '
'
+ '软配套(实地走一遍)
'
+ kv('地铁步行', r['地铁步行']) + kv('学校步行', r['学校步行'])
+ kv('商业步行', r['商业步行']) + kv('主力户型', r['主力户型']) + '
'
+ '案场 / 竞品
'
+ kv('案场折扣政策', r['案场折扣政策']) + kv('去化最快户型', r['去化最快户型']) + '
'
+ '竞品与二手对比: '
+ bind(esc(r['竞品与二手对比'] || '待补'))
+ '
'
+ (r['备注']
? '备注: '
+ bind(esc(r['备注'])) + '
'
: '');
html += ''
+ '
' + bind(esc(r['楼盘名称'] || '(未命名)')) + ' '
+ prioChip(r['优先级'])
+ tierChip(r['板块分层'])
+ '' + esc(st) + ' '
+ (r['综合评级'] ? '' + esc(r['综合评级']) + ' ' : '')
+ '
'
+ '
'
+ kv('所属板块', r['所属板块']) + kv('参考均价', r['参考均价'] ? fmtMoney(r['参考均价']) + ' 元/㎡' : '')
+ kv('计划踩盘日', dayOf(r['计划踩盘日'])) + kv('优先级', r['优先级'])
+ '
'
+ '
核心卖点(结论): '
+ bind(esc(r['一句话卖点'] || (r['预设卖点']
? '待补 · 出发前的假设:' + r['预设卖点'] : '待补——没写出来说明还没想清楚')))
+ '
'
+ '
核心风险(结论): '
+ bind(esc(r['一句话风险'] || (r['预设风险']
? '待补 · 出发前的假设:' + r['预设风险'] : '待补——没写出来说明还没踩明白')))
+ '
'
+ '
' + detail + '
'
+ '
展开选盘理由 · 问题清单 · 软配套 · 竞品 '
+ '
'
+ (st === '已完成' ? '' : '推进到「' + esc(nextStage(st)) + '」 ')
+ '编辑 '
+ '删除 '
+ '
';
});
setHTML(box, html);
}
function renderGrid() {
var box = $('gridBody');
if (!box) { return; }
if (!state.rows.length) {
setHTML(box, '还没有数据。按「板块 + 产品力」把盘铺进网格,你就有了自己的坐标系。
');
return;
}
var html = '';
TIERS.forEach(function (tier, idx) {
var group = state.rows.filter(function (r) { return r['板块分层'] === tier; });
html += '' + esc(tier)
+ ' ' + bind(esc(String(group.length))) + ' 个盘 '
+ '
' + esc(TIER_DESC[tier] || '') + '
';
if (!group.length) {
html += '
这一层还没铺盘。
';
return;
}
var byBlock = {};
group.forEach(function (r) {
var b = r['所属板块'] || '未填板块';
if (!byBlock[b]) { byBlock[b] = { n: 0, done: 0, prices: [] }; }
byBlock[b].n++;
if ((r['阶段'] || '') === '已完成') { byBlock[b].done++; }
var p = r['参考均价'];
if (p != null && p !== '' && !isNaN(Number(p))) { byBlock[b].prices.push(Number(p)); }
});
var keys = Object.keys(byBlock).sort(function (a, b) { return byBlock[b].n - byBlock[a].n; });
var max = 1;
keys.forEach(function (k) { if (byBlock[k].n > max) { max = byBlock[k].n; } });
html += '';
keys.forEach(function (k) {
var g = byBlock[k];
var pct = Math.round(g.n / max * 100);
html += '
' + bind(esc(k)) + ' '
+ ' '
+ '' + bind(esc(g.n + ' 个·已踩 ' + g.done)) + '
';
});
html += '
';
var all = [];
var doneAll = 0;
keys.forEach(function (k) {
all = all.concat(byBlock[k].prices);
doneAll += byBlock[k].done;
});
var rangeTxt = all.length
? '参考均价区间 ' + fmtMoney(Math.min.apply(null, all)) + ' ~ ' + fmtMoney(Math.max.apply(null, all)) + ' 元/㎡'
: '参考均价待补';
html += '' + bind(esc('共 ' + group.length + ' 个盘 · 已踩完 ' + doneAll + ' 个 · ' + rangeTxt)) + '
';
html += '';
});
setHTML(box, html);
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', boot);
} else {
boot();
}
})();