Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
89810942cd | ||
|
|
6b3198d36f | ||
|
|
8e688efb62 | ||
|
|
7c9ee77992 |
@@ -38,6 +38,28 @@ jobs:
|
||||
--exclude='.gitea' \
|
||||
"${CLONE_DIR}/" "${RELEASE_DIR}/"
|
||||
|
||||
echo "=== 2.5 Ensure .env secrets ==="
|
||||
# .env 不随 git 走,线上凭据由服务器本地 shared/.env 提供。
|
||||
# 若缺失则从 CI secrets 自动补齐;已存在的变量保留,避免覆盖线上配置/导致 session 失效。
|
||||
mkdir -p "${DEPLOY_BASE}/shared"
|
||||
SHARED_ENV="${DEPLOY_BASE}/shared/.env"
|
||||
touch "${SHARED_ENV}"
|
||||
ensure_env_var() {
|
||||
key="$1"; val="$2"
|
||||
if [ -z "$val" ]; then
|
||||
echo " ! ${key} 未提供(CI secret 为空),跳过写入"
|
||||
return
|
||||
fi
|
||||
if ! grep -q "^${key}=" "${SHARED_ENV}"; then
|
||||
echo "${key}=${val}" >> "${SHARED_ENV}"
|
||||
echo " + 已写入 ${key}"
|
||||
else
|
||||
echo " = ${key} 已存在,保留原值"
|
||||
fi
|
||||
}
|
||||
ensure_env_var "DB_PASSWORD" "${{ secrets.OPC_DB_PASSWORD }}"
|
||||
ensure_env_var "SECRET_KEY" "${{ secrets.OPC_SECRET_KEY }}"
|
||||
|
||||
echo "=== 3. Link shared resources ==="
|
||||
mkdir -p "${RELEASE_DIR}/data"
|
||||
# .env from shared dir (not in git)
|
||||
|
||||
@@ -39,12 +39,22 @@ UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
# ---------- 数据库连接 ----------
|
||||
def _require_env(name):
|
||||
"""强制读取必需环境变量,缺失即抛出清晰错误(禁止弱默认降级)"""
|
||||
val = os.environ.get(name)
|
||||
if not val:
|
||||
raise RuntimeError(
|
||||
f"缺少必需的环境变量 {name},请在 .env 中显式配置(关键凭据禁止硬编码兜底)"
|
||||
)
|
||||
return val
|
||||
|
||||
|
||||
def db():
|
||||
return mysql.connector.connect(
|
||||
host=os.environ.get("DB_HOST", "127.0.0.1"),
|
||||
port=int(os.environ.get("DB_PORT", "3306")),
|
||||
user=os.environ.get("DB_USER", "opc"),
|
||||
password=os.environ.get("DB_PASSWORD", "opc123456"),
|
||||
password=_require_env("DB_PASSWORD"),
|
||||
database=os.environ.get("DB_NAME", "opc"),
|
||||
charset="utf8mb4",
|
||||
collation="utf8mb4_unicode_ci",
|
||||
@@ -55,10 +65,44 @@ def now():
|
||||
return datetime.utcnow().isoformat()
|
||||
|
||||
|
||||
def _convert_placeholders(sql):
|
||||
"""将 ? 占位符转为 MySQL 的 %s,仅替换字符串字面量之外的 ?。
|
||||
|
||||
避免 `sql.replace('?', '%s')` 的全局替换缺陷:当 SQL 字面量或 LIKE 模式
|
||||
中恰好含 `?` 时会误替换、导致参数错位。这里用状态机跳过单/双引号内的内容。
|
||||
"""
|
||||
out = []
|
||||
in_single = in_double = False
|
||||
i, n = 0, len(sql)
|
||||
while i < n:
|
||||
ch = sql[i]
|
||||
if ch == "\\" and i + 1 < n: # 跳过转义序列
|
||||
out.append(ch)
|
||||
out.append(sql[i + 1])
|
||||
i += 2
|
||||
continue
|
||||
if ch == "'" and not in_double:
|
||||
in_single = not in_single
|
||||
elif ch == '"' and not in_single:
|
||||
in_double = not in_double
|
||||
out.append("%s" if (ch == "?" and not in_single and not in_double) else ch)
|
||||
i += 1
|
||||
return "".join(out)
|
||||
|
||||
|
||||
def _exec(conn, sql, args=()):
|
||||
"""执行 SQL,自动将 ? 转为 MySQL 的 %s"""
|
||||
"""执行 SQL,将 ? 占位符安全地转为 MySQL 的 %s。
|
||||
|
||||
占位符数量与参数数量必须一致,否则立即报错,防止静默错位。
|
||||
"""
|
||||
converted = _convert_placeholders(sql)
|
||||
n_ph = converted.count("%s")
|
||||
if n_ph != len(args):
|
||||
raise ValueError(
|
||||
f"SQL 占位符数量({n_ph})与参数数量({len(args)})不匹配,请检查 SQL 与传参: {sql}"
|
||||
)
|
||||
cur = conn.cursor(dictionary=True)
|
||||
cur.execute(sql.replace("?", "%s"), args)
|
||||
cur.execute(converted, args)
|
||||
return cur
|
||||
|
||||
|
||||
|
||||
@@ -19,7 +19,14 @@ app = Flask(
|
||||
template_folder=str(ROOT / "templates"),
|
||||
static_folder=str(ROOT / "static"),
|
||||
)
|
||||
app.secret_key = os.environ.get("SECRET_KEY", "opc-dev-secret-2026")
|
||||
# secret_key 必须从环境变量注入,禁止硬编码兜底——否则 session 可被伪造。
|
||||
_secret_key = os.environ.get("SECRET_KEY")
|
||||
if not _secret_key:
|
||||
raise RuntimeError(
|
||||
"缺少必需的环境变量 SECRET_KEY,请在 .env 中配置(建议: "
|
||||
"python3 -c 'import secrets;print(secrets.token_hex(32))')"
|
||||
)
|
||||
app.secret_key = _secret_key
|
||||
app.config["TEMPLATES_AUTO_RELOAD"] = True
|
||||
app.register_blueprint(bp)
|
||||
|
||||
|
||||
@@ -98,7 +98,7 @@ function renderFinance() {
|
||||
})();
|
||||
const sm = pf.sign_month || "";
|
||||
const signMonthCell = `<td class="px-2 py-px text-center align-middle text-sm"><span class="pf-sm-text cursor-pointer hover:text-blue-600" id="pf-sm-${pf.id}" onclick="event.stopPropagation(); editPfSignMonth(event, ${pf.id})">${sm || '—'}</span></td>`;
|
||||
return `<tr class="border-b border-slate-100 hover:bg-slate-50 cursor-pointer" onclick="openPfEditModal(${pf.id})"><td class="px-2 py-px text-center align-middle text-sm truncate" style="max-width:100px" title="${esc(pf.client_name || '')}">${esc(pf.client_name || "")}</td><td class="px-2 py-px text-sm font-medium text-center align-middle">${esc(pf.customer_name)}</td>${signMonthCell}<td class="px-2 py-px text-center align-middle text-sm">${money(pf.sign_amount)}</td>${mCols}${totalCol}</tr>`;
|
||||
return `<tr class="border-b ${state._fmMarks.red[pf.id] ? 'bg-red-50' : 'border-slate-100'} hover:bg-slate-50 cursor-pointer" oncontextmenu="finShowCtx(event, ${pf.id})" onclick="openPfEditModal(${pf.id})"><td class="px-2 py-px text-sm font-medium text-center align-middle">${esc(pf.customer_name)}</td>${signMonthCell}<td class="px-2 py-px text-center align-middle text-sm">${money(pf.sign_amount)}</td>${mCols}${totalCol}</tr>`;
|
||||
};
|
||||
|
||||
const now2 = new Date();
|
||||
@@ -285,16 +285,15 @@ function renderFinance() {
|
||||
};
|
||||
const SIGNED_COLS = [
|
||||
{ key: '_expand', label: '' },
|
||||
{ key: 'client_name', label: '客户' }, { key: 'customer_name', label: '项目名称' }, { key: 'sign_amount', label: '签约金额' },
|
||||
{ key: 'customer_name', label: '项目名称' }, { key: 'sign_amount', label: '签约金额' },
|
||||
{ key: 'rev', label: '已确收' }, { key: 'gross', label: '毛利' }, { key: 'gross_rate', label: '毛利率' },
|
||||
{ key: 'payment', label: '回款' }, { key: 'pending', label: '应收' }, { key: 'pay_rate', label: '回款率' },
|
||||
{ key: 'cost', label: '成本' }, { key: 'paid', label: '支出' }, { key: 'payable', label: '应付' }, { key: 'paid_rate', label: '付款率' },
|
||||
{ key: 'cashflow', label: '现金流' },
|
||||
];
|
||||
const UNSIGNED_COLS = [
|
||||
{ key: 'client_name', label: '客户' }, { key: 'customer_name', label: '项目名称' }, { key: 'sign_amount', label: '签约金额' },
|
||||
{ key: 'customer_name', label: '项目名称' }, { key: 'sign_amount', label: '签约金额' },
|
||||
{ key: 'sign_month', label: '签约月份' }, { key: 'gross', label: '毛利' }, { key: 'cost', label: '成本' },
|
||||
,
|
||||
];
|
||||
|
||||
const renderView = (rows, sumRev, sumPay, sumCost, sumPaid, sumGross, signTotal, signCnt, emptyText) => {
|
||||
@@ -317,7 +316,7 @@ function renderFinance() {
|
||||
const cardHtml = (r, i) => '<div class="card px-2 py-1.5 text-center flex-1" style="background:' + (['#eff6ff','#eff6ff','#eff6ff','#fef3c7','#fef3c7','#fef3c7','#fef9c3','#fef9c3','#fef9c3'][i] || '#fff') + '"><div class="text-xs text-slate-600">' + r[0] + '</div><div class="text-sm font-semibold text-slate-800">' + r[1] + '</div></div>';
|
||||
const fs = '<div class="flex gap-1 items-stretch py-2">' + cardHtml(rows_data[0],0) + cardHtml(rows_data[1],1) + cardHtml(rows_data[2],2) + '<span class="w-px bg-slate-200 mx-1 self-stretch"></span>' + cardHtml(rows_data[3],3) + cardHtml(rows_data[4],4) + cardHtml(rows_data[5],5) + '<span class="w-px bg-slate-200 mx-1 self-stretch"></span>' + cardHtml(rows_data[6],6) + cardHtml(rows_data[7],7) + cardHtml(rows_data[8],8) + '</div>';
|
||||
const thead = isUnsigned ? buildThead(UNSIGNED_COLS, 'uns') : buildThead(SIGNED_COLS, 'sig');
|
||||
const theadCols = isUnsigned ? 6 : 15;
|
||||
const theadCols = isUnsigned ? 5 : 14;
|
||||
const tbody = rows.length ? `<tbody style="line-height:1.37">${rows.join("")}</tbody>` : `<tr><td colspan="${theadCols}" class="p-6 text-center text-slate-700">${emptyText}</td></tr>`;
|
||||
const subtitle = isUnsigned
|
||||
? '合同 → 毛利 → 成本 → 项目利润'
|
||||
@@ -366,11 +365,11 @@ function renderFinance() {
|
||||
var bCf = bPay - ePaid;
|
||||
return '<tr class="bg-slate-50"><td class="px-2 py-px"></td><td class="px-2 py-px"></td><td class="px-2 py-px text-center text-xs text-slate-600">' + m + '</td><td class="px-2 py-px text-center text-xs text-blue-600">' + (bRev ? money(bRev) : '—') + '</td><td class="px-2 py-px text-center text-xs text-green-600">' + (bGross ? money(bGross) : '—') + '</td><td class="px-2 py-px"></td><td class="px-2 py-px text-center text-xs text-amber-600">' + (bPay ? money(bPay) : '—') + '</td><td class="px-2 py-px"></td><td class="px-2 py-px text-center text-xs text-rose-600">' + (eCost ? money(eCost) : '—') + '</td><td class="px-2 py-px text-center text-xs text-purple-600">' + (ePaid ? money(ePaid) : '—') + '</td><td class="px-2 py-px"></td><td class="px-2 py-px text-center text-xs ' + (bCf >= 0 ? 'text-green-600' : 'text-red-600') + '">' + (bCf ? money(bCf) : '—') + '</td><td class="px-2 py-px text-center text-xs ' + (bRev - bPay > 0 ? 'text-red-600' : bRev - bPay < 0 ? 'text-green-600' : 'text-slate-300') + '">' + (bRev - bPay ? money(bRev - bPay) : '—') + '</td></tr>';
|
||||
}).join('');
|
||||
return `<tr class="border-b border-slate-100 hover:bg-slate-50 cursor-pointer" onclick="openPfEditModal(${pf.id})"><td class="px-2 py-px text-center align-middle text-sm" onclick="event.stopPropagation();finToggleExpand(${pf.id})"><i data-lucide="chevron-right" style="width:14px;height:14px;color:#94a3b8" id="fin_expand_icon_${pf.id}"></i></td><td class="px-2 py-px text-center align-middle text-sm truncate" style="max-width:100px" title="${esc(pf.client_name || '')}">${esc(pf.client_name || "")}</td><td class="px-2 py-px text-sm font-medium text-center align-middle truncate" style="min-width:300px;max-width:300px;width:300px" title="${esc(pf.customer_name)}">${esc(pf.customer_name)}</td><td class="px-2 py-px text-center align-middle text-sm font-medium">${money(pf.sign_amount)}</td><td class="px-2 py-px text-center text-blue-700 align-middle">${fmtNoUnit(rev)}</td><td class="px-2 py-px text-center align-middle text-sm font-medium">${fmtNoUnit(gross)}</td><td class="px-2 py-px text-center align-middle text-sm">${grossR}</td><td class="px-2 py-px text-center text-amber-700 align-middle">${fmtNoUnit(payment)}</td><td class="px-2 py-px text-center align-middle text-sm font-semibold ${pendingCls}">${pendingVal}</td><td class="px-2 py-px text-center align-middle text-sm">${payR}</td><td class="px-2 py-px text-center text-rose-700 align-middle">${fmtNoUnit(cost)}</td><td class="px-2 py-px text-center text-purple-700 align-middle">${fmtNoUnit(paid)}</td><td class="px-2 py-px text-center align-middle text-sm font-semibold ${payableCls}">${payableVal}</td><td class="px-2 py-px text-center align-middle text-sm">${paidR}</td><td class="px-2 py-px text-center font-semibold align-middle ${cfCls}">${cf}</td></tr><tr class="hidden" id="fin_expand_${pf.id}"><td colspan="15"><table class="w-full"><thead><tr class="border-b border-slate-200"><th class="px-2 py-1"></th><th class="px-2 py-1"></th><th class="px-2 py-1 text-center text-xs text-slate-600">月份</th><th class="px-2 py-1 text-center text-xs text-slate-600">确收</th><th class="px-2 py-1 text-center text-xs text-slate-600">毛利</th><th class="px-2 py-1"></th><th class="px-2 py-1 text-center text-xs text-slate-600">回款</th><th class="px-2 py-1"></th><th class="px-2 py-1 text-center text-xs text-slate-600">成本</th><th class="px-2 py-1 text-center text-xs text-slate-600">支出</th><th class="px-2 py-1"></th><th class="px-2 py-1 text-center text-xs text-slate-600">现金流</th><th class="px-2 py-1 text-center text-xs text-slate-600">应收</th></tr></thead><tbody>${detailRows}</tbody></table></td></tr>`;
|
||||
return `<tr class="border-b ${state._fmMarks.red[pf.id] ? 'bg-red-50' : 'border-slate-100'} hover:bg-slate-50 cursor-pointer" oncontextmenu="finShowCtx(event, ${pf.id})" onclick="openPfEditModal(${pf.id})"><td class="px-2 py-px text-center align-middle text-sm" onclick="event.stopPropagation();finToggleExpand(${pf.id})"><i data-lucide="chevron-right" style="width:14px;height:14px;color:#94a3b8" id="fin_expand_icon_${pf.id}"></i></td><td class="px-2 py-px text-sm font-medium text-center align-middle truncate" style="min-width:300px;max-width:300px;width:300px" title="${esc(pf.customer_name)}">${esc(pf.customer_name)}</td><td class="px-2 py-px text-center align-middle text-sm font-medium">${money(pf.sign_amount)}</td><td class="px-2 py-px text-center text-blue-700 align-middle">${fmtNoUnit(rev)}</td><td class="px-2 py-px text-center align-middle text-sm font-medium">${fmtNoUnit(gross)}</td><td class="px-2 py-px text-center align-middle text-sm">${grossR}</td><td class="px-2 py-px text-center text-amber-700 align-middle">${fmtNoUnit(payment)}</td><td class="px-2 py-px text-center align-middle text-sm font-semibold ${pendingCls}">${pendingVal}</td><td class="px-2 py-px text-center align-middle text-sm">${payR}</td><td class="px-2 py-px text-center text-rose-700 align-middle">${fmtNoUnit(cost)}</td><td class="px-2 py-px text-center text-purple-700 align-middle">${fmtNoUnit(paid)}</td><td class="px-2 py-px text-center align-middle text-sm font-semibold ${payableCls}">${payableVal}</td><td class="px-2 py-px text-center align-middle text-sm">${paidR}</td><td class="px-2 py-px text-center font-semibold align-middle ${cfCls}">${cf}</td></tr><tr class="hidden" id="fin_expand_${pf.id}"><td colspan="14"><table class="w-full"><thead><tr class="border-b border-slate-200"><th class="px-2 py-1"></th><th class="px-2 py-1"></th><th class="px-2 py-1 text-center text-xs text-slate-600">月份</th><th class="px-2 py-1 text-center text-xs text-slate-600">确收</th><th class="px-2 py-1 text-center text-xs text-slate-600">毛利</th><th class="px-2 py-1"></th><th class="px-2 py-1 text-center text-xs text-slate-600">回款</th><th class="px-2 py-1"></th><th class="px-2 py-1 text-center text-xs text-slate-600">成本</th><th class="px-2 py-1 text-center text-xs text-slate-600">支出</th><th class="px-2 py-1"></th><th class="px-2 py-1 text-center text-xs text-slate-600">现金流</th><th class="px-2 py-1 text-center text-xs text-slate-600">应收</th></tr></thead><tbody>${detailRows}</tbody></table></td></tr>`;
|
||||
};
|
||||
// 待签约简版行
|
||||
const tdRowUnsigned = (pf, cost, gross) => {
|
||||
return `<tr class="border-b border-slate-100 hover:bg-slate-50 cursor-pointer" onclick="openPfEditModal(${pf.id})"><td class="px-2 py-px text-sm font-medium text-center align-middle truncate" style="min-width:300px;max-width:300px;width:300px" title="${esc(pf.customer_name)}">${esc(pf.customer_name)}</td><td class="px-2 py-px text-center align-middle text-sm font-medium">${money(pf.sign_amount)}</td><td class="px-2 py-px text-center align-middle text-sm">${pf.sign_month || '—'}</td><td class="px-2 py-px text-center align-middle text-sm font-medium">${fmtNoUnit(gross)}</td><td class="px-2 py-px text-center text-rose-700 align-middle">${fmtNoUnit(cost)}</td></tr>`;
|
||||
return `<tr class="border-b ${state._fmMarks.red[pf.id] ? 'bg-red-50' : 'border-slate-100'} hover:bg-slate-50 cursor-pointer" oncontextmenu="finShowCtx(event, ${pf.id})" onclick="openPfEditModal(${pf.id})"><td class="px-2 py-px text-sm font-medium text-center align-middle truncate" style="min-width:300px;max-width:300px;width:300px" title="${esc(pf.customer_name)}">${esc(pf.customer_name)}</td><td class="px-2 py-px text-center align-middle text-sm font-medium">${money(pf.sign_amount)}</td><td class="px-2 py-px text-center align-middle text-sm">${pf.sign_month || '—'}</td><td class="px-2 py-px text-center align-middle text-sm font-medium">${fmtNoUnit(gross)}</td><td class="px-2 py-px text-center text-rose-700 align-middle">${fmtNoUnit(cost)}</td></tr>`;
|
||||
};
|
||||
|
||||
if (state.finView === 'monthly') {
|
||||
@@ -958,7 +957,8 @@ window.createFinance = async (event) => {
|
||||
document.querySelector("#financeModalTitle").textContent = "新增项目财务";
|
||||
closeFinanceModal();
|
||||
state.data.projectFinances = await api("/api/projectFinances/list?tenant=" + encodeURIComponent(state.tenant));
|
||||
renderFinance();
|
||||
localStorage.setItem('opc_fm_marks', JSON.stringify(state._fmMarks));
|
||||
renderFinance();
|
||||
} catch (error) {
|
||||
toast("保存失败:" + error.message, "error");
|
||||
}
|
||||
@@ -974,7 +974,8 @@ window.deleteFinanceItem = async () => {
|
||||
await api(`/api/projectFinances/${pfId}`, { method: "DELETE" });
|
||||
closeFinanceModal();
|
||||
state.data.projectFinances = await api("/api/projectFinances/list?tenant=" + encodeURIComponent(state.tenant));
|
||||
renderFinance();
|
||||
localStorage.setItem('opc_fm_marks', JSON.stringify(state._fmMarks));
|
||||
renderFinance();
|
||||
toast("已删除", "success");
|
||||
} catch (error) {
|
||||
toast("删除失败:" + error.message, "error");
|
||||
@@ -1254,8 +1255,51 @@ function renderOverviewCharts(data) {
|
||||
};
|
||||
|
||||
// ====== 筛选器控制函数 ======
|
||||
window.switchFinFilter = (f) => { state.finFilter = f; renderFinance(); };
|
||||
window.setFinView = (v) => { state.finView = v; renderFinance(); };
|
||||
window.setFinYear = (y) => { state.finYear = parseInt(y); renderFinance(); };
|
||||
window.setFinQuarter = (q) => { state.finView = 'quarterly'; state.finQuarter = q; renderFinance(); };
|
||||
window.setFinMonth = (m) => { state.finView = 'monthly'; state.finMonth = m; renderFinance(); };
|
||||
window.switchFinFilter = (f) => { state.finFilter = f; localStorage.setItem('opc_fm_marks', JSON.stringify(state._fmMarks));
|
||||
renderFinance(); };
|
||||
window.setFinView = (v) => { state.finView = v; localStorage.setItem('opc_fm_marks', JSON.stringify(state._fmMarks));
|
||||
renderFinance(); };
|
||||
window.setFinYear = (y) => { state.finYear = parseInt(y); localStorage.setItem('opc_fm_marks', JSON.stringify(state._fmMarks));
|
||||
renderFinance(); };
|
||||
window.setFinQuarter = (q) => { state.finView = 'quarterly'; state.finQuarter = q; localStorage.setItem('opc_fm_marks', JSON.stringify(state._fmMarks));
|
||||
renderFinance(); };
|
||||
window.setFinMonth = (m) => { state.finView = 'monthly'; state.finMonth = m; localStorage.setItem('opc_fm_marks', JSON.stringify(state._fmMarks));
|
||||
renderFinance(); };
|
||||
|
||||
|
||||
// ---- 项目标记上下文菜单 ----
|
||||
if (!state._fmMarks) state._fmMarks = (function() { try { return JSON.parse(localStorage.getItem('opc_fm_marks')) || { red: {} }; } catch(e) { return { red: {} }; } })();
|
||||
window.finShowCtx = function(e, pfId) {
|
||||
e.preventDefault();
|
||||
var menu = document.getElementById('finCtxMenu');
|
||||
if (!menu) {
|
||||
menu = document.createElement('div');
|
||||
menu.id = 'finCtxMenu';
|
||||
menu.style.cssText = 'display:none;position:fixed;z-index:9999;background:#fff;border-radius:8px;box-shadow:0 10px 40px rgba(0,0,0,.15);border:1px solid #e2e8f0;padding:4px 0;min-width:130px';
|
||||
menu.innerHTML = '<div style="padding:4px 12px;font-size:11px;font-weight:600;color:#94a3b8">项目标记</div>' +
|
||||
'<div data-act="red" style="padding:6px 12px;font-size:13px;cursor:pointer;color:#334155"><span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:#ef4444;margin-right:8px"></span>标红</div>';
|
||||
menu.addEventListener('click', function(ev) {
|
||||
ev.stopPropagation();
|
||||
var id = menu.getAttribute('data-pfid');
|
||||
menu.style.display = 'none';
|
||||
if (state._fmMarks.red[id]) { delete state._fmMarks.red[id]; } else { state._fmMarks.red[id] = true; }
|
||||
localStorage.setItem('opc_fm_marks', JSON.stringify(state._fmMarks));
|
||||
renderFinance();
|
||||
});
|
||||
document.body.appendChild(menu);
|
||||
}
|
||||
menu.setAttribute('data-pfid', pfId);
|
||||
menu.style.display = 'block';
|
||||
menu.style.left = e.pageX + 'px';
|
||||
menu.style.top = Math.min(e.pageY, window.innerHeight - 200) + 'px';
|
||||
};
|
||||
document.addEventListener('click', function() { var m = document.getElementById('finCtxMenu'); if (m) m.style.display = 'none'; });
|
||||
|
||||
window.finCtxMenu_act = function(e, type) {
|
||||
e.stopPropagation();
|
||||
var pfId = document.getElementById("finCtxMenu").getAttribute("data-pfid");
|
||||
if (type === "clear") { delete state._fmMarks.red[pfId]; delete state._fmMarks.pin[pfId]; }
|
||||
else { if (state._fmMarks[type][pfId]) { delete state._fmMarks[type][pfId]; } else { state._fmMarks[type][pfId] = true; } }
|
||||
localStorage.setItem('opc_fm_marks', JSON.stringify(state._fmMarks));
|
||||
renderFinance();
|
||||
};
|
||||
|
||||
@@ -96,7 +96,7 @@ function renderPlan() {
|
||||
})();
|
||||
const sm = pf.sign_month || "";
|
||||
const signMonthCell = `<td class="px-2 py-px text-center align-middle text-sm"><span class="pf-sm-text cursor-pointer hover:text-blue-600" id="pf-sm-${pf.id}" onclick="event.stopPropagation(); planEditSignMonth(event, ${pf.id})">${sm || '—'}</span></td>`;
|
||||
return `<tr class="border-b border-slate-100 hover:bg-slate-50 cursor-pointer" onclick="planOpenPfEditModal(${pf.id})"><td class="px-2 py-px text-center align-middle text-sm truncate" style="max-width:100px" title="${esc(pf.client_name || '')}">${esc(pf.client_name || "")}</td><td class="px-2 py-px text-sm font-medium text-center align-middle">${esc(pf.customer_name)}</td><td class="px-2 py-px text-center align-middle text-sm">${esc(pf.weight || "20%")}</td><td class="px-2 py-px text-center align-middle text-sm">${esc(pf.project_type || "待签约")}</td>${signMonthCell}<td class="px-2 py-px text-center align-middle text-sm">${money(pf.sign_amount)}</td>${mCols}${totalCol}</tr>`;
|
||||
return `<tr class="border-b ${state._pmMarks.red[pf.id] ? 'bg-red-50' : 'border-slate-100'} hover:bg-slate-50 cursor-pointer" oncontextmenu="planShowCtx(event, ${pf.id})" onclick="planOpenPfEditModal(${pf.id})"><td class="px-2 py-px text-sm font-medium text-center align-middle">${esc(pf.customer_name)}</td><td class="px-2 py-px text-center align-middle text-sm">${esc(pf.weight || "20%")}</td><td class="px-2 py-px text-center align-middle text-sm">${esc(pf.project_type || "待签约")}</td>${signMonthCell}<td class="px-2 py-px text-center align-middle text-sm">${money(pf.sign_amount)}</td>${mCols}${totalCol}</tr>`;
|
||||
};
|
||||
|
||||
const now2 = new Date();
|
||||
@@ -288,7 +288,6 @@ function renderPlan() {
|
||||
};
|
||||
const SIGNED_COLS = [
|
||||
{ key: '_expand', label: '' },
|
||||
{ key: 'client_name', label: '客户' },
|
||||
{ key: 'customer_name', label: '项目名称' },
|
||||
{ key: 'weight', label: '加权' },
|
||||
{ key: 'project_type', label: '项目类型' },
|
||||
@@ -299,20 +298,19 @@ function renderPlan() {
|
||||
{ key: 'payment', label: '回款' },
|
||||
{ key: 'pending', label: '应收' },
|
||||
{ key: 'pay_rate', label: '回款率' },
|
||||
{ key: 'cost', label: '成本' },
|
||||
{ key: 'paid', label: '支出' },
|
||||
{ key: 'payable', label: '应付' },
|
||||
{ key: 'cost', label: '当期成本' },
|
||||
{ key: 'paid', label: '当期流出' },
|
||||
{ key: 'cashflow', label: '现金流' },
|
||||
];
|
||||
const UNSIGNED_COLS = [
|
||||
{ key: 'client_name', label: '客户' },
|
||||
{ key: 'customer_name', label: '项目名称' },
|
||||
{ key: 'weight', label: '加权' },
|
||||
{ key: 'project_type', label: '项目类型' },
|
||||
{ key: 'sign_amount', label: '签约金额' },
|
||||
{ key: 'sign_month', label: '签约月份' },
|
||||
{ key: 'gross', label: '毛利' },
|
||||
{ key: 'cost', label: '当期成本' },
|
||||
{ key: 'cost', label: '成本' },
|
||||
{ key: 'profit', label: '项目利润' },
|
||||
];
|
||||
|
||||
@@ -336,7 +334,7 @@ function renderPlan() {
|
||||
const cardHtml = (r, i) => '<div class="card px-2 py-1.5 text-center flex-1" style="background:' + (['#eff6ff','#eff6ff','#eff6ff','#fef3c7','#fef3c7','#fef3c7','#fef9c3','#fef9c3','#fef9c3'][i] || '#fff') + '"><div class="text-xs text-slate-600">' + r[0] + '</div><div class="text-sm font-semibold text-slate-800">' + r[1] + '</div></div>';
|
||||
const fs = '<div class="flex gap-1 items-stretch py-2">' + cardHtml(rows_data[0],0) + cardHtml(rows_data[1],1) + cardHtml(rows_data[2],2) + '<span class="w-px bg-slate-200 mx-1 self-stretch"></span>' + cardHtml(rows_data[3],3) + cardHtml(rows_data[4],4) + cardHtml(rows_data[5],5) + '<span class="w-px bg-slate-200 mx-1 self-stretch"></span>' + cardHtml(rows_data[6],6) + cardHtml(rows_data[7],7) + cardHtml(rows_data[8],8) + '</div>';
|
||||
const thead = isUnsigned ? buildThead(UNSIGNED_COLS, 'uns') : buildThead(SIGNED_COLS, 'sig');
|
||||
const theadCols = isUnsigned ? 9 : 15;
|
||||
const theadCols = isUnsigned ? 8 : 15;
|
||||
const tbody = rows.length ? `<tbody style="line-height:1.37">${rows.join("")}</tbody>` : `<tr><td colspan="${theadCols}" class="p-6 text-center text-slate-700">${emptyText}</td></tr>`;
|
||||
const subtitle = isUnsigned
|
||||
? '合同 → 毛利 → 成本 → 项目利润'
|
||||
@@ -398,14 +396,14 @@ function renderPlan() {
|
||||
var bCf = bPay - ePaid;
|
||||
return '<tr class="bg-slate-50"><td class="px-2 py-px"></td><td class="px-2 py-px text-center text-xs text-slate-600">' + m + '</td><td class="px-2 py-px text-center text-xs text-blue-600">' + (bRev ? money(bRev) : '—') + '</td><td class="px-2 py-px text-center text-xs text-green-600">' + (bGross ? money(bGross) : '—') + '</td><td class="px-2 py-px"></td><td class="px-2 py-px text-center text-xs text-amber-600">' + (bPay ? money(bPay) : '—') + '</td><td class="px-2 py-px"></td><td class="px-2 py-px text-center text-xs text-rose-600">' + (eCost ? money(eCost) : '—') + '</td><td class="px-2 py-px text-center text-xs text-purple-600">' + (ePaid ? money(ePaid) : '—') + '</td><td class="px-2 py-px"></td><td class="px-2 py-px text-center text-xs ' + (bCf >= 0 ? 'text-green-600' : 'text-red-600') + '">' + (bCf ? money(bCf) : '—') + '</td><td class="px-2 py-px text-center text-xs ' + (bRev - bPay > 0 ? 'text-red-600' : bRev - bPay < 0 ? 'text-green-600' : 'text-slate-300') + '">' + (bRev - bPay ? money(bRev - bPay) : '—') + '</td></tr>';
|
||||
}).join('');
|
||||
return `<tr class="border-b border-slate-100 hover:bg-slate-50 cursor-pointer" onclick="planOpenPfEditModal(${pf.id})"><td class="px-2 py-px text-center align-middle text-sm" onclick="event.stopPropagation();planToggleExpand(${pf.id})"><i data-lucide="chevron-right" style="width:14px;height:14px;color:#94a3b8" id="plan_expand_icon_${pf.id}"></i></td><td class="px-2 py-px text-center align-middle text-sm truncate" style="max-width:100px" title="${esc(pf.client_name || '')}">${esc(pf.client_name || "")}</td><td class="px-2 py-px text-sm font-medium text-center align-middle truncate" style="min-width:300px;max-width:300px;width:300px" title="${esc(pf.customer_name)}">${esc(pf.customer_name)}</td><td class="px-2 py-px text-center align-middle text-sm" onclick="event.stopPropagation()">${planInlineSelect('weight', pf.id, 'weight', pf.weight || '20%', ['20%','40%','60%','80%','100%'])}</td><td class="px-2 py-px text-center align-middle text-sm" onclick="event.stopPropagation()">${planInlineSelect('ptype', pf.id, 'project_type', pf.project_type || '待签约', ['待签约','已签约'])}</td><td class="px-2 py-px text-center align-middle text-sm font-medium">${money(pf.sign_amount)}</td><td class="px-2 py-px text-center text-blue-700 align-middle">${fmtNoUnit(rev)}</td><td class="px-2 py-px text-center align-middle text-sm font-medium">${fmtNoUnit(gross)}</td><td class="px-2 py-px text-center align-middle text-sm">${grossR}</td><td class="px-2 py-px text-center text-amber-700 align-middle">${fmtNoUnit(payment)}</td><td class="px-2 py-px text-center align-middle text-sm font-semibold ${pendingCls}">${pendingVal}</td><td class="px-2 py-px text-center align-middle text-sm">${payR}</td><td class="px-2 py-px text-center text-rose-700 align-middle">${fmtNoUnit(cost)}</td><td class="px-2 py-px text-center text-purple-700 align-middle">${fmtNoUnit(paid)}</td><td class="px-2 py-px text-center align-middle text-sm font-semibold ${payableCls}">${payableVal}</td><td class="px-2 py-px text-center font-semibold align-middle ${cfCls}">${cf}</td></tr><tr class="hidden" id="plan_expand_${pf.id}"><td colspan="16"><table class="w-full"><thead><tr class="border-b border-slate-200"><th class="px-2 py-1"></th><th class="px-2 py-1 text-center text-xs text-slate-600">月份</th><th class="px-2 py-1 text-center text-xs text-slate-600">确收</th><th class="px-2 py-1 text-center text-xs text-slate-600">毛利</th><th class="px-2 py-1"></th><th class="px-2 py-1 text-center text-xs text-slate-600">回款</th><th class="px-2 py-1"></th><th class="px-2 py-1 text-center text-xs text-slate-600">当期成本</th><th class="px-2 py-1 text-center text-xs text-slate-600">当期流出</th><th class="px-2 py-1"></th><th class="px-2 py-1 text-center text-xs text-slate-600">现金流</th><th class="px-2 py-1 text-center text-xs text-slate-600">应收</th></tr></thead><tbody>${detailRows}</tbody></table></td></tr>`;
|
||||
return `<tr class="border-b ${state._pmMarks.red[pf.id] ? 'bg-red-50' : 'border-slate-100'} hover:bg-slate-50 cursor-pointer" oncontextmenu="planShowCtx(event, ${pf.id})" onclick="planOpenPfEditModal(${pf.id})"><td class="px-2 py-px text-center align-middle text-sm" onclick="event.stopPropagation();planToggleExpand(${pf.id})"><i data-lucide="chevron-right" style="width:14px;height:14px;color:#94a3b8" id="plan_expand_icon_${pf.id}"></i></td><td class="px-2 py-px text-sm font-medium text-center align-middle truncate" style="min-width:300px;max-width:300px;width:300px" title="${esc(pf.customer_name)}">${esc(pf.customer_name)}</td><td class="px-2 py-px text-center align-middle text-sm" onclick="event.stopPropagation()">${planInlineSelect('weight', pf.id, 'weight', pf.weight || '20%', ['20%','40%','60%','80%','100%'])}</td><td class="px-2 py-px text-center align-middle text-sm" onclick="event.stopPropagation()">${planInlineSelect('ptype', pf.id, 'project_type', pf.project_type || '待签约', ['待签约','已签约'])}</td><td class="px-2 py-px text-center align-middle text-sm font-medium">${money(pf.sign_amount)}</td><td class="px-2 py-px text-center text-blue-700 align-middle">${fmtNoUnit(rev)}</td><td class="px-2 py-px text-center align-middle text-sm font-medium">${fmtNoUnit(gross)}</td><td class="px-2 py-px text-center align-middle text-sm">${grossR}</td><td class="px-2 py-px text-center text-amber-700 align-middle">${fmtNoUnit(payment)}</td><td class="px-2 py-px text-center align-middle text-sm font-semibold ${pendingCls}">${pendingVal}</td><td class="px-2 py-px text-center align-middle text-sm">${payR}</td><td class="px-2 py-px text-center align-middle text-sm font-semibold ${payableCls}">${fmtNoUnit(cost)}</td><td class="px-2 py-px text-center text-purple-700 align-middle">${fmtNoUnit(paid)}</td><td class="px-2 py-px text-center align-middle text-sm font-semibold ${payableCls}">${payableVal}</td><td class="px-2 py-px text-center font-semibold align-middle ${cfCls}">${cf}</td></tr><tr class="hidden" id="plan_expand_${pf.id}"><td colspan="15"><table class="w-full"><thead><tr class="border-b border-slate-200"><th class="px-2 py-1"></th><th class="px-2 py-1 text-center text-xs text-slate-600">月份</th><th class="px-2 py-1 text-center text-xs text-slate-600">确收</th><th class="px-2 py-1 text-center text-xs text-slate-600">毛利</th><th class="px-2 py-1"></th><th class="px-2 py-1 text-center text-xs text-slate-600">回款</th><th class="px-2 py-1"></th><th class="px-2 py-1 text-center text-xs text-slate-600">成本</th><th class="px-2 py-1 text-center text-xs text-slate-600">支出</th><th class="px-2 py-1"></th><th class="px-2 py-1 text-center text-xs text-slate-600">现金流</th><th class="px-2 py-1 text-center text-xs text-slate-600">应收</th></tr></thead><tbody>${detailRows}</tbody></table></td></tr>`;
|
||||
};
|
||||
// 待签约简版行
|
||||
const tdRowUnsigned = (pf, cost, gross) => {
|
||||
const profit = gross;
|
||||
const pfCls = profit >= 0 ? 'text-green-600' : 'text-red-600';
|
||||
const pfVal = profit ? money(profit) : '<span class="text-slate-300">—</span>';
|
||||
return `<tr class="border-b border-slate-100 hover:bg-slate-50 cursor-pointer" onclick="planOpenPfEditModal(${pf.id})"><td class="px-2 py-px text-center align-middle text-sm truncate" style="max-width:100px" title="${esc(pf.client_name || '')}">${esc(pf.client_name || "")}</td><td class="px-2 py-px text-sm font-medium text-center align-middle truncate" style="min-width:300px;max-width:300px;width:300px" title="${esc(pf.customer_name)}">${esc(pf.customer_name)}</td><td class="px-2 py-px text-center align-middle text-sm" onclick="event.stopPropagation()">${planInlineSelect('weight', pf.id, 'weight', pf.weight || '20%', ['20%','40%','60%','80%','100%'])}</td><td class="px-2 py-px text-center align-middle text-sm" onclick="event.stopPropagation()">${planInlineSelect('ptype', pf.id, 'project_type', pf.project_type || '待签约', ['待签约','已签约'])}</td><td class="px-2 py-px text-center align-middle text-sm font-medium">${money(pf.sign_amount)}</td><td class="px-2 py-px text-center align-middle text-sm">${pf.sign_month || '—'}</td><td class="px-2 py-px text-center align-middle text-sm font-medium">${fmtNoUnit(gross)}</td><td class="px-2 py-px text-center text-rose-700 align-middle">${fmtNoUnit(cost)}</td><td class="px-2 py-px text-center font-semibold align-middle ${pfCls}">${pfVal}</td></tr>`;
|
||||
return `<tr class="border-b ${state._pmMarks.red[pf.id] ? 'bg-red-50' : 'border-slate-100'} hover:bg-slate-50 cursor-pointer" oncontextmenu="planShowCtx(event, ${pf.id})" onclick="planOpenPfEditModal(${pf.id})"><td class="px-2 py-px text-sm font-medium text-center align-middle truncate" style="min-width:300px;max-width:300px;width:300px" title="${esc(pf.customer_name)}">${esc(pf.customer_name)}</td><td class="px-2 py-px text-center align-middle text-sm" onclick="event.stopPropagation()">${planInlineSelect('weight', pf.id, 'weight', pf.weight || '20%', ['20%','40%','60%','80%','100%'])}</td><td class="px-2 py-px text-center align-middle text-sm" onclick="event.stopPropagation()">${planInlineSelect('ptype', pf.id, 'project_type', pf.project_type || '待签约', ['待签约','已签约'])}</td><td class="px-2 py-px text-center align-middle text-sm font-medium">${money(pf.sign_amount)}</td><td class="px-2 py-px text-center align-middle text-sm">${pf.sign_month || '—'}</td><td class="px-2 py-px text-center align-middle text-sm font-medium">${fmtNoUnit(gross)}</td><td class="px-2 py-px text-center text-rose-700 align-middle">${fmtNoUnit(cost)}</td><td class="px-2 py-px text-center font-semibold align-middle ${pfCls}">${pfVal}</td></tr>`;
|
||||
};
|
||||
|
||||
if (state.planView === 'monthly') {
|
||||
@@ -1012,7 +1010,8 @@ window.createPlanFinance = async (event) => {
|
||||
document.querySelector("#planModalTitle").textContent = "新增项目财务";
|
||||
closePlanModal();
|
||||
state.data.planFinances = await api("/api/planFinances/list?tenant=" + encodeURIComponent(state.tenant));
|
||||
renderPlan();
|
||||
localStorage.setItem('opc_pm_marks', JSON.stringify(state._pmMarks));
|
||||
renderPlan();
|
||||
} catch (error) {
|
||||
toast("刷新失败:" + error.message, "error");
|
||||
}
|
||||
@@ -1028,7 +1027,8 @@ window.deletePlanItem = async () => {
|
||||
await api(`/api/planFinances/${pfId}`, { method: "DELETE" });
|
||||
closePlanModal();
|
||||
state.data.planFinances = await api("/api/planFinances/list?tenant=" + encodeURIComponent(state.tenant));
|
||||
renderPlan();
|
||||
localStorage.setItem('opc_pm_marks', JSON.stringify(state._pmMarks));
|
||||
renderPlan();
|
||||
toast("已删除", "success");
|
||||
} catch (error) {
|
||||
toast("删除失败:" + error.message, "error");
|
||||
@@ -1179,7 +1179,7 @@ function renderPlanOverview() {
|
||||
document.querySelector("#planOverview").innerHTML = '<div class="grid gap-2.5">' +
|
||||
deptCard +
|
||||
cfCard +
|
||||
card('<h3 class="text-base font-bold text-slate-700 mb-3">项目经营详情 <span class="text-sm text-slate-500 font-normal ml-3">所有项目产生的签单,确收,毛利,回款,当期成本,当期流出,现金流</span></h3><div class="overflow-x-auto"><table class="w-full text-sm"><thead>' + thead + '</thead><tbody>' + tbody + '</tbody></table></div>', 'p-4') +
|
||||
card('<h3 class="text-base font-bold text-slate-700 mb-3">项目经营详情 <span class="text-sm text-slate-500 font-normal ml-3">所有项目产生的签单,确收,毛利,回款,成本,支出,现金流</span></h3><div class="overflow-x-auto"><table class="w-full text-sm"><thead>' + thead + '</thead><tbody>' + tbody + '</tbody></table></div>', 'p-4') +
|
||||
'<div class="grid grid-cols-3 gap-2.5">' +
|
||||
card('<div class="mb-3 flex items-center justify-between"><h2 class="text-sm font-bold text-slate-600">月度签约趋势</h2><span class="text-xs text-slate-600">2026</span></div><div style="position:relative;height:200px"><canvas id="planChartSign"></canvas></div>', 'p-4') +
|
||||
card('<div class="mb-3 flex items-center justify-between"><h2 class="text-sm font-bold text-slate-600">月度确收与毛利</h2><span class="text-xs text-slate-600">2026</span></div><div style="position:relative;height:200px"><canvas id="planChartRev"></canvas></div>', 'p-4') +
|
||||
@@ -1316,7 +1316,7 @@ function renderPlanQuarterlyOverview() {
|
||||
document.querySelector("#planQuarterlyOverview").innerHTML = '<div class="grid gap-2.5">' +
|
||||
card('<h3 class="text-base font-bold text-slate-700 mb-3">部门损益情况 <span class="text-sm text-slate-500 font-normal ml-3">部门利润 = 所有项目产生的毛利 - 部门 1 级产生的人力成本,研发成本,差旅和其他支出</span></h3><div class="overflow-x-auto"><table class="w-full text-sm"><thead>' + deptThead + '</thead><tbody>' + deptTbody + '</tbody></table></div>', 'p-4') +
|
||||
card('<h3 class="text-base font-bold text-slate-700 mb-3">部门现金流 <span class="text-sm text-slate-500 font-normal ml-3">部门现金流=所有项目产生的现金流 - 部门一级因费用产生的现金流出或流入</span></h3><div class="overflow-x-auto"><table class="w-full text-sm"><thead>' + deptThead + '</thead><tbody>' + cfTbody + '</tbody></table></div>', 'p-4') +
|
||||
card('<h3 class="text-base font-bold text-slate-700 mb-3">项目经营详情 <span class="text-sm text-slate-500 font-normal ml-3">所有项目产生的签单,确收,毛利,回款,当期成本,当期流出,现金流</span></h3><div class="overflow-x-auto"><table class="w-full text-sm"><thead>' + thead + '</thead><tbody>' + tbody + '</tbody></table></div>', 'p-4') +
|
||||
card('<h3 class="text-base font-bold text-slate-700 mb-3">项目经营详情 <span class="text-sm text-slate-500 font-normal ml-3">所有项目产生的签单,确收,毛利,回款,成本,支出,现金流</span></h3><div class="overflow-x-auto"><table class="w-full text-sm"><thead>' + thead + '</thead><tbody>' + tbody + '</tbody></table></div>', 'p-4') +
|
||||
'</div>';
|
||||
if (window.lucide) window.lucide.createIcons();
|
||||
}
|
||||
@@ -1356,7 +1356,8 @@ window.planInlineUpdate = async (pfId, field, value) => {
|
||||
try {
|
||||
await api("/api/planFinances/" + pfId, { method: "PUT", body: JSON.stringify({ data: { [field]: value } }) });
|
||||
state.data.planFinances = await api("/api/planFinances/list?tenant=" + encodeURIComponent(state.tenant));
|
||||
renderPlan();
|
||||
localStorage.setItem('opc_pm_marks', JSON.stringify(state._pmMarks));
|
||||
renderPlan();
|
||||
toast("已更新", "success");
|
||||
} catch (error) {
|
||||
toast("更新失败:" + error.message, "error");
|
||||
@@ -1364,9 +1365,53 @@ window.planInlineUpdate = async (pfId, field, value) => {
|
||||
};
|
||||
|
||||
// ====== 筛选器控制函数 ======
|
||||
window.switchPlanFilter = (f) => { state.planFilter = f; renderPlan(); };
|
||||
window.setPlanView = (v) => { state.planView = v; renderPlan(); };
|
||||
window.setPlanYear = (y) => { state.planYear = parseInt(y); renderPlan(); };
|
||||
window.setPlanQuarter = (q) => { state.planView = 'quarterly'; state.planQuarter = q; renderPlan(); };
|
||||
window.setPlanMonth = (m) => { state.planView = 'monthly'; state.planMonth = m; renderPlan(); };
|
||||
window.setPlanStatusFilter = (f) => { state.planStatusFilter = f; renderPlan(); };
|
||||
window.switchPlanFilter = (f) => { state.planFilter = f; localStorage.setItem('opc_pm_marks', JSON.stringify(state._pmMarks));
|
||||
renderPlan(); };
|
||||
window.setPlanView = (v) => { state.planView = v; localStorage.setItem('opc_pm_marks', JSON.stringify(state._pmMarks));
|
||||
renderPlan(); };
|
||||
window.setPlanYear = (y) => { state.planYear = parseInt(y); localStorage.setItem('opc_pm_marks', JSON.stringify(state._pmMarks));
|
||||
renderPlan(); };
|
||||
window.setPlanQuarter = (q) => { state.planView = 'quarterly'; state.planQuarter = q; localStorage.setItem('opc_pm_marks', JSON.stringify(state._pmMarks));
|
||||
renderPlan(); };
|
||||
window.setPlanMonth = (m) => { state.planView = 'monthly'; state.planMonth = m; localStorage.setItem('opc_pm_marks', JSON.stringify(state._pmMarks));
|
||||
renderPlan(); };
|
||||
window.setPlanStatusFilter = (f) => { state.planStatusFilter = f; localStorage.setItem('opc_pm_marks', JSON.stringify(state._pmMarks));
|
||||
renderPlan(); };
|
||||
|
||||
|
||||
// ---- 项目标记上下文菜单 ----
|
||||
if (!state._pmMarks) state._pmMarks = (function() { try { return JSON.parse(localStorage.getItem('opc_pm_marks')) || { red: {} }; } catch(e) { return { red: {} }; } })();
|
||||
window.planShowCtx = function(e, pfId) {
|
||||
e.preventDefault();
|
||||
var menu = document.getElementById('planCtxMenu');
|
||||
if (!menu) {
|
||||
menu = document.createElement('div');
|
||||
menu.id = 'planCtxMenu';
|
||||
menu.style.cssText = 'display:none;position:fixed;z-index:9999;background:#fff;border-radius:8px;box-shadow:0 10px 40px rgba(0,0,0,.15);border:1px solid #e2e8f0;padding:4px 0;min-width:130px';
|
||||
menu.innerHTML = '<div style="padding:4px 12px;font-size:11px;font-weight:600;color:#94a3b8">项目标记</div>' +
|
||||
'<div data-act="red" style="padding:6px 12px;font-size:13px;cursor:pointer;color:#334155"><span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:#ef4444;margin-right:8px"></span>标红</div>';
|
||||
menu.addEventListener('click', function(ev) {
|
||||
ev.stopPropagation();
|
||||
var id = menu.getAttribute('data-pfid');
|
||||
menu.style.display = 'none';
|
||||
if (state._pmMarks.red[id]) { delete state._pmMarks.red[id]; } else { state._pmMarks.red[id] = true; }
|
||||
localStorage.setItem('opc_pm_marks', JSON.stringify(state._pmMarks));
|
||||
renderPlan();
|
||||
});
|
||||
document.body.appendChild(menu);
|
||||
}
|
||||
menu.setAttribute('data-pfid', pfId);
|
||||
menu.style.display = 'block';
|
||||
menu.style.left = e.pageX + 'px';
|
||||
menu.style.top = Math.min(e.pageY, window.innerHeight - 200) + 'px';
|
||||
};
|
||||
document.addEventListener('click', function() { var m = document.getElementById('planCtxMenu'); if (m) m.style.display = 'none'; });
|
||||
|
||||
window.planCtxMenu_act = function(e, type) {
|
||||
e.stopPropagation();
|
||||
var pfId = document.getElementById("planCtxMenu").getAttribute("data-pfid");
|
||||
if (type === "clear") { delete state._pmMarks.red[pfId]; delete state._pmMarks.pin[pfId]; }
|
||||
else { if (state._pmMarks[type][pfId]) { delete state._pmMarks[type][pfId]; } else { state._pmMarks[type][pfId] = true; } }
|
||||
localStorage.setItem('opc_pm_marks', JSON.stringify(state._pmMarks));
|
||||
renderPlan();
|
||||
};
|
||||
|
||||
@@ -84,7 +84,7 @@
|
||||
<header class="topbar border-b border-slate-200 bg-white px-8 py-5">
|
||||
<div class="flex items-center gap-3 w-full">
|
||||
<div class="flex-1">
|
||||
<p class="eyebrow text-xs font-semibold uppercase tracking-[0.18em] text-blue-700">OPC Manager <span class="ml-2 text-xs font-normal text-slate-400 tracking-normal">v1.2.0.38</span></p>
|
||||
<p class="eyebrow text-xs font-semibold uppercase tracking-[0.18em] text-blue-700">OPC Manager <span class="ml-2 text-xs font-normal text-slate-400 tracking-normal">v1.2.0.41</span></p>
|
||||
<div class="flex items-center gap-4 mt-1">
|
||||
<h1 class="text-2xl font-semibold" id="workspaceTitle">科普 OPC 工作台</h1>
|
||||
<div class="hidden sm:flex items-center gap-1.5 bg-gradient-to-r from-blue-50 to-indigo-50 border border-blue-100 rounded-full px-4 py-1.5">
|
||||
|
||||
Reference in New Issue
Block a user