fix security and import validation
This commit is contained in:
@@ -1,8 +1,30 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
function loadEnvFile(filePath) {
|
||||||
|
if (!fs.existsSync(filePath)) return {};
|
||||||
|
return fs.readFileSync(filePath, 'utf8')
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.reduce((env, line) => {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (!trimmed || trimmed.startsWith('#')) return env;
|
||||||
|
const idx = trimmed.indexOf('=');
|
||||||
|
if (idx === -1) return env;
|
||||||
|
const key = trimmed.slice(0, idx).trim();
|
||||||
|
const value = trimmed.slice(idx + 1).trim();
|
||||||
|
env[key] = value.replace(/^['"]|['"]$/g, '');
|
||||||
|
return env;
|
||||||
|
}, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
const runtimeEnv = loadEnvFile(path.join(__dirname, '.env'));
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
apps: [{
|
apps: [{
|
||||||
name: 'gamer',
|
name: 'gamer',
|
||||||
script: 'server/app.js',
|
script: 'server/app.js',
|
||||||
env: {
|
env: {
|
||||||
|
...runtimeEnv,
|
||||||
NODE_ENV: 'production',
|
NODE_ENV: 'production',
|
||||||
PORT: 3000
|
PORT: 3000
|
||||||
},
|
},
|
||||||
|
|||||||
Generated
+23
-22
@@ -427,9 +427,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/es-object-atoms": {
|
"node_modules/es-object-atoms": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
|
||||||
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
|
"integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"es-errors": "^1.3.0"
|
"es-errors": "^1.3.0"
|
||||||
@@ -633,9 +633,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/hasown": {
|
"node_modules/hasown": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
|
||||||
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
|
"integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"function-bind": "^1.1.2"
|
"function-bind": "^1.1.2"
|
||||||
@@ -988,9 +988,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/path-to-regexp": {
|
"node_modules/path-to-regexp": {
|
||||||
"version": "8.3.0",
|
"version": "8.4.2",
|
||||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz",
|
||||||
"integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==",
|
"integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"funding": {
|
"funding": {
|
||||||
"type": "opencollective",
|
"type": "opencollective",
|
||||||
@@ -1047,12 +1047,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/qs": {
|
"node_modules/qs": {
|
||||||
"version": "6.14.1",
|
"version": "6.15.3",
|
||||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz",
|
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz",
|
||||||
"integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==",
|
"integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==",
|
||||||
"license": "BSD-3-Clause",
|
"license": "BSD-3-Clause",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"side-channel": "^1.1.0"
|
"es-define-property": "^1.0.1",
|
||||||
|
"side-channel": "^1.1.1"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.6"
|
"node": ">=0.6"
|
||||||
@@ -1220,14 +1221,14 @@
|
|||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/side-channel": {
|
"node_modules/side-channel": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
|
||||||
"integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
|
"integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"es-errors": "^1.3.0",
|
"es-errors": "^1.3.0",
|
||||||
"object-inspect": "^1.13.3",
|
"object-inspect": "^1.13.4",
|
||||||
"side-channel-list": "^1.0.0",
|
"side-channel-list": "^1.0.1",
|
||||||
"side-channel-map": "^1.0.1",
|
"side-channel-map": "^1.0.1",
|
||||||
"side-channel-weakmap": "^1.0.2"
|
"side-channel-weakmap": "^1.0.2"
|
||||||
},
|
},
|
||||||
@@ -1239,13 +1240,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/side-channel-list": {
|
"node_modules/side-channel-list": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
|
||||||
"integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==",
|
"integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"es-errors": "^1.3.0",
|
"es-errors": "^1.3.0",
|
||||||
"object-inspect": "^1.13.3"
|
"object-inspect": "^1.13.4"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
|
|||||||
+42
-7
@@ -62,6 +62,39 @@ function headers() {
|
|||||||
return { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token };
|
return { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function escapeHtml(value) {
|
||||||
|
return String(value ?? '').replace(/[&<>"']/g, (ch) => ({
|
||||||
|
'&': '&',
|
||||||
|
'<': '<',
|
||||||
|
'>': '>',
|
||||||
|
'"': '"',
|
||||||
|
"'": '''
|
||||||
|
}[ch]));
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeAttr(value) {
|
||||||
|
return escapeHtml(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function jsString(value) {
|
||||||
|
return escapeAttr(JSON.stringify(String(value ?? '')));
|
||||||
|
}
|
||||||
|
|
||||||
|
function recordArg(record) {
|
||||||
|
return escapeAttr(JSON.stringify(record));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 统一的 API 请求函数,自动处理 401 跳转到登录页
|
||||||
|
async function apiFetch(url, options = {}) {
|
||||||
|
const res = await fetch(url, { ...options, headers: { ...headers(), ...(options.headers || {}) } });
|
||||||
|
if (res.status === 401) {
|
||||||
|
AppState.clearAuth();
|
||||||
|
showPage('loginPage');
|
||||||
|
throw new Error('Unauthorized');
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
function formatLocalTime(isoStr) {
|
function formatLocalTime(isoStr) {
|
||||||
if (!isoStr) return '';
|
if (!isoStr) return '';
|
||||||
const d = new Date(isoStr);
|
const d = new Date(isoStr);
|
||||||
@@ -81,7 +114,7 @@ function formatLocalDate(input) {
|
|||||||
|
|
||||||
async function loadTotalStats() {
|
async function loadTotalStats() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(API + '/api/stats/total', { headers: headers() });
|
const res = await apiFetch(API + '/api/stats/total');
|
||||||
if (!res.ok) return;
|
if (!res.ok) return;
|
||||||
const s = await res.json();
|
const s = await res.json();
|
||||||
document.getElementById('totalTakeCount').textContent = s.take_count;
|
document.getElementById('totalTakeCount').textContent = s.take_count;
|
||||||
@@ -99,10 +132,11 @@ function renderHorizontalChart(containerId, rows, clickHandler, fromPage) {
|
|||||||
const max = Math.max(...rows.map(r => r.count), 1);
|
const max = Math.max(...rows.map(r => r.count), 1);
|
||||||
container.innerHTML = rows.map((r, i) => {
|
container.innerHTML = rows.map((r, i) => {
|
||||||
const pct = Math.max((r.count / max) * 100, 2);
|
const pct = Math.max((r.count / max) * 100, 2);
|
||||||
const onclick = clickHandler ? ` onclick="${clickHandler}('${r.name.replace(/'/g, "\\'")}', '${fromPage}')"` : '';
|
const onclick = clickHandler ? ` onclick="${clickHandler}(${jsString(r.name)}, ${jsString(fromPage)})"` : '';
|
||||||
|
const name = escapeHtml(r.name);
|
||||||
return `<div class="hbar-row"${onclick}>
|
return `<div class="hbar-row"${onclick}>
|
||||||
<div class="hbar-index ${i < 3 ? 'top' : ''}">${i + 1}</div>
|
<div class="hbar-index ${i < 3 ? 'top' : ''}">${i + 1}</div>
|
||||||
<div class="hbar-label" title="${r.name}">${r.name}</div>
|
<div class="hbar-label" title="${escapeAttr(r.name)}">${name}</div>
|
||||||
<div class="hbar-track"><div class="hbar-fill" style="width:${pct}%"></div></div>
|
<div class="hbar-track"><div class="hbar-fill" style="width:${pct}%"></div></div>
|
||||||
<div class="hbar-value">${r.count}单</div>
|
<div class="hbar-value">${r.count}单</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
@@ -112,7 +146,7 @@ function renderHorizontalChart(containerId, rows, clickHandler, fromPage) {
|
|||||||
async function openTakeRanking() {
|
async function openTakeRanking() {
|
||||||
showPage('takeRankingPage');
|
showPage('takeRankingPage');
|
||||||
try {
|
try {
|
||||||
const res = await fetch(API + '/api/stats/take-ranking', { headers: headers() });
|
const res = await apiFetch(API + '/api/stats/take-ranking');
|
||||||
if (!res.ok) return;
|
if (!res.ok) return;
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
const rows = data.rows || [];
|
const rows = data.rows || [];
|
||||||
@@ -141,7 +175,7 @@ async function openTakeRanking() {
|
|||||||
async function openDispatchRanking() {
|
async function openDispatchRanking() {
|
||||||
showPage('dispatchRankingPage');
|
showPage('dispatchRankingPage');
|
||||||
try {
|
try {
|
||||||
const res = await fetch(API + '/api/stats/dispatch-ranking', { headers: headers() });
|
const res = await apiFetch(API + '/api/stats/dispatch-ranking');
|
||||||
if (!res.ok) return;
|
if (!res.ok) return;
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
const rows = data.rows || [];
|
const rows = data.rows || [];
|
||||||
@@ -178,9 +212,10 @@ async function openDispatchRanking() {
|
|||||||
const container = document.getElementById('dispatchRankingChart');
|
const container = document.getElementById('dispatchRankingChart');
|
||||||
container.innerHTML = rows.map((r, i) => {
|
container.innerHTML = rows.map((r, i) => {
|
||||||
const pct = Math.max(((r.dispatch_count || 0) / max) * 100, 2);
|
const pct = Math.max(((r.dispatch_count || 0) / max) * 100, 2);
|
||||||
return `<div class="hbar-row" onclick="openPartnerDetail('${r.name.replace(/'/g, "\\'")}', 'dispatchRankingPage')">
|
const name = escapeHtml(r.name);
|
||||||
|
return `<div class="hbar-row" onclick="openPartnerDetail(${jsString(r.name)}, 'dispatchRankingPage')">
|
||||||
<div class="hbar-index ${i < 3 ? 'top' : ''}">${i + 1}</div>
|
<div class="hbar-index ${i < 3 ? 'top' : ''}">${i + 1}</div>
|
||||||
<div class="hbar-label" title="${r.name}">${r.name}</div>
|
<div class="hbar-label" title="${escapeAttr(r.name)}">${name}</div>
|
||||||
<div class="hbar-track"><div class="hbar-fill" style="width:${pct}%"></div></div>
|
<div class="hbar-track"><div class="hbar-fill" style="width:${pct}%"></div></div>
|
||||||
<div class="hbar-value">${r.dispatch_count}单</div>
|
<div class="hbar-value">${r.dispatch_count}单</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ const _acCache = {};
|
|||||||
async function acFetchNames(field) {
|
async function acFetchNames(field) {
|
||||||
if (_acCache[field] && Date.now() - _acCache[field].ts < 60000) return _acCache[field].data;
|
if (_acCache[field] && Date.now() - _acCache[field].ts < 60000) return _acCache[field].data;
|
||||||
try {
|
try {
|
||||||
const res = await fetch(API + '/api/contacts?field=' + field, { headers: headers() });
|
const res = await apiFetch(API + '/api/contacts?field=' + field);
|
||||||
if (!res.ok) return [];
|
if (!res.ok) return [];
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
_acCache[field] = { data, ts: Date.now() };
|
_acCache[field] = { data, ts: Date.now() };
|
||||||
@@ -45,14 +45,16 @@ function acSetup(inputId, field) {
|
|||||||
if (filtered.length === 0) { list.classList.remove('show'); return; }
|
if (filtered.length === 0) { list.classList.remove('show'); return; }
|
||||||
activeIdx = -1;
|
activeIdx = -1;
|
||||||
list.innerHTML = filtered.slice(0, 10).map((n, i) => {
|
list.innerHTML = filtered.slice(0, 10).map((n, i) => {
|
||||||
let display = n;
|
let display = escapeHtml(n);
|
||||||
if (q) {
|
if (q) {
|
||||||
const idx = n.toLowerCase().indexOf(q);
|
const idx = n.toLowerCase().indexOf(q);
|
||||||
if (idx >= 0) {
|
if (idx >= 0) {
|
||||||
display = n.slice(0, idx) + '<span class="ac-highlight">' + n.slice(idx, idx + q.length) + '</span>' + n.slice(idx + q.length);
|
display = escapeHtml(n.slice(0, idx)) +
|
||||||
|
'<span class="ac-highlight">' + escapeHtml(n.slice(idx, idx + q.length)) + '</span>' +
|
||||||
|
escapeHtml(n.slice(idx + q.length));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return `<div class="ac-item" data-idx="${i}" data-value="${n.replace(/"/g, '"')}">${display}</div>`;
|
return `<div class="ac-item" data-idx="${i}" data-value="${escapeAttr(n)}">${display}</div>`;
|
||||||
}).join('');
|
}).join('');
|
||||||
list.classList.add('show');
|
list.classList.add('show');
|
||||||
}
|
}
|
||||||
|
|||||||
+15
-16
@@ -38,9 +38,8 @@ async function loadListData(listId) {
|
|||||||
showLoading(listId, true);
|
showLoading(listId, true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(
|
const res = await apiFetch(
|
||||||
API + cfg.endpoint + '?page=' + state.page + '&limit=' + PAGE_SIZE + '&year=' + currentYear + '&month=' + currentMonth,
|
API + cfg.endpoint + '?page=' + state.page + '&limit=' + PAGE_SIZE + '&year=' + currentYear + '&month=' + currentMonth
|
||||||
{ headers: headers() }
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
@@ -173,37 +172,37 @@ async function loadMilkTea() { return loadListData('milkTeaList'); }
|
|||||||
|
|
||||||
function renderOrderItem(r) {
|
function renderOrderItem(r) {
|
||||||
const time = formatLocalTime(r.created_at);
|
const time = formatLocalTime(r.created_at);
|
||||||
return '<div class="order-item" onclick=\'openEditModal(' + JSON.stringify(r).replace(/'/g, "'") + ')\'>' +
|
return '<div class="order-item" onclick="openEditModal(JSON.parse(this.dataset.record))" data-record="' + recordArg(r) + '">' +
|
||||||
'<div class="order-item-top"><span class="order-item-boss">' + (r.boss || '-') + '</span><span class="order-item-amount">' + (r.amount || 0).toFixed(2) + '</span></div>' +
|
'<div class="order-item-top"><span class="order-item-boss">' + escapeHtml(r.boss || '-') + '</span><span class="order-item-amount">' + (r.amount || 0).toFixed(2) + '</span></div>' +
|
||||||
'<div class="order-item-bottom"><span>' + (r.quantity || 0) + '单 × ' + (r.unit_price || 0).toFixed(2) + '</span><span>' + time + (r.partner ? ' · ' + r.partner : '') + '</span></div></div>';
|
'<div class="order-item-bottom"><span>' + (r.quantity || 0) + '单 × ' + (r.unit_price || 0).toFixed(2) + '</span><span>' + escapeHtml(time + (r.partner ? ' · ' + r.partner : '')) + '</span></div></div>';
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderOtherIncomeItem(r) {
|
function renderOtherIncomeItem(r) {
|
||||||
const time = formatLocalTime(r.created_at);
|
const time = formatLocalTime(r.created_at);
|
||||||
const displayCategory = r.category === '红包收入' ? '红包' : (r.category || '-');
|
const displayCategory = r.category === '红包收入' ? '红包' : (r.category || '-');
|
||||||
return '<div class="order-item" onclick=\'openEditModal(' + JSON.stringify(r).replace(/'/g, "'") + ')\'>' +
|
return '<div class="order-item" onclick="openEditModal(JSON.parse(this.dataset.record))" data-record="' + recordArg(r) + '">' +
|
||||||
'<div class="order-item-top"><span class="order-item-boss">' + displayCategory + (r.boss ? ' · ' + r.boss : '') + '</span><span class="order-item-amount">' + (r.amount || 0).toFixed(2) + '</span></div>' +
|
'<div class="order-item-top"><span class="order-item-boss">' + escapeHtml(displayCategory + (r.boss ? ' · ' + r.boss : '')) + '</span><span class="order-item-amount">' + (r.amount || 0).toFixed(2) + '</span></div>' +
|
||||||
'<div class="order-item-bottom"><span>' + (r.source || '') + '</span><span>' + time + '</span></div></div>';
|
'<div class="order-item-bottom"><span>' + escapeHtml(r.source || '') + '</span><span>' + escapeHtml(time) + '</span></div></div>';
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderDispatchItem(r) {
|
function renderDispatchItem(r) {
|
||||||
const time = formatLocalTime(r.created_at);
|
const time = formatLocalTime(r.created_at);
|
||||||
const unitPrice = r.unit_price || (r.quantity ? r.amount / r.quantity : 0);
|
const unitPrice = r.unit_price || (r.quantity ? r.amount / r.quantity : 0);
|
||||||
return '<div class="order-item" onclick=\'openEditModal(' + JSON.stringify(r).replace(/'/g, "'") + ')\'>' +
|
return '<div class="order-item" onclick="openEditModal(JSON.parse(this.dataset.record))" data-record="' + recordArg(r) + '">' +
|
||||||
'<div class="order-item-top"><span class="order-item-boss">' + (r.boss || '-') + '</span><span class="order-item-amount">' + (r.amount || 0).toFixed(2) + '</span></div>' +
|
'<div class="order-item-top"><span class="order-item-boss">' + escapeHtml(r.boss || '-') + '</span><span class="order-item-amount">' + (r.amount || 0).toFixed(2) + '</span></div>' +
|
||||||
'<div class="order-item-bottom"><span>' + (r.quantity || 0) + '单 × ' + (r.rebate || 0).toFixed(2) + '(单价' + unitPrice.toFixed(2) + ')</span><span>' + time + (r.partner ? ' · ' + r.partner : '') + '</span></div></div>';
|
'<div class="order-item-bottom"><span>' + (r.quantity || 0) + '单 × ' + (r.rebate || 0).toFixed(2) + '(单价' + unitPrice.toFixed(2) + ')</span><span>' + escapeHtml(time + (r.partner ? ' · ' + r.partner : '')) + '</span></div></div>';
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderRedEnvelopeItem(r) {
|
function renderRedEnvelopeItem(r) {
|
||||||
const time = formatLocalTime(r.created_at);
|
const time = formatLocalTime(r.created_at);
|
||||||
return '<div class="order-item" onclick=\'openEditModal(' + JSON.stringify(r).replace(/'/g, "'") + ')\'>' +
|
return '<div class="order-item" onclick="openEditModal(JSON.parse(this.dataset.record))" data-record="' + recordArg(r) + '">' +
|
||||||
'<div class="order-item-top"><span class="order-item-boss">红包</span><span class="order-item-amount">' + (r.amount || 0).toFixed(2) + '</span></div>' +
|
'<div class="order-item-top"><span class="order-item-boss">红包</span><span class="order-item-amount">' + (r.amount || 0).toFixed(2) + '</span></div>' +
|
||||||
'<div class="order-item-bottom"><span>' + (r.source || '') + '</span><span>' + time + '</span></div></div>';
|
'<div class="order-item-bottom"><span>' + escapeHtml(r.source || '') + '</span><span>' + escapeHtml(time) + '</span></div></div>';
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderMilkTeaItem(r) {
|
function renderMilkTeaItem(r) {
|
||||||
const time = formatLocalTime(r.created_at);
|
const time = formatLocalTime(r.created_at);
|
||||||
return '<div class="order-item" onclick=\'openEditModal(' + JSON.stringify(r).replace(/'/g, "'") + ')\'>' +
|
return '<div class="order-item" onclick="openEditModal(JSON.parse(this.dataset.record))" data-record="' + recordArg(r) + '">' +
|
||||||
'<div class="order-item-top"><span class="order-item-boss">奶茶</span><span class="order-item-amount">' + (r.amount || 0).toFixed(2) + '</span></div>' +
|
'<div class="order-item-top"><span class="order-item-boss">奶茶</span><span class="order-item-amount">' + (r.amount || 0).toFixed(2) + '</span></div>' +
|
||||||
'<div class="order-item-bottom"><span>' + (r.source || '') + '</span><span>' + time + '</span></div></div>';
|
'<div class="order-item-bottom"><span>' + escapeHtml(r.source || '') + '</span><span>' + escapeHtml(time) + '</span></div></div>';
|
||||||
}
|
}
|
||||||
+10
-7
@@ -17,7 +17,7 @@ async function loadRankingDetail() {
|
|||||||
url += '&endDate=' + formatLocalDate(d);
|
url += '&endDate=' + formatLocalDate(d);
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const res = await fetch(url, { headers: headers() });
|
const res = await apiFetch(url);
|
||||||
if (!res.ok) return;
|
if (!res.ok) return;
|
||||||
const rows = await res.json();
|
const rows = await res.json();
|
||||||
const container = document.getElementById('rankingDetailList');
|
const container = document.getElementById('rankingDetailList');
|
||||||
@@ -29,9 +29,10 @@ async function loadRankingDetail() {
|
|||||||
const max = Math.max(...rows.map(r => r.dispatch_count), 1);
|
const max = Math.max(...rows.map(r => r.dispatch_count), 1);
|
||||||
container.innerHTML = rows.map((r, i) => {
|
container.innerHTML = rows.map((r, i) => {
|
||||||
const pct = Math.max((r.dispatch_count / max) * 100, 2);
|
const pct = Math.max((r.dispatch_count / max) * 100, 2);
|
||||||
return `<div class="hbar-row" onclick="openBossDetail('${r.boss.replace(/'/g, "\\'")}', 'rankingPage')">
|
const boss = escapeHtml(r.boss);
|
||||||
|
return `<div class="hbar-row" onclick="openBossDetail(${jsString(r.boss)}, 'rankingPage')">
|
||||||
<div class="hbar-index ${i < 3 ? 'top' : ''}">${i + 1}</div>
|
<div class="hbar-index ${i < 3 ? 'top' : ''}">${i + 1}</div>
|
||||||
<div class="hbar-label" title="${r.boss}">${r.boss}</div>
|
<div class="hbar-label" title="${escapeAttr(r.boss)}">${boss}</div>
|
||||||
<div class="hbar-track"><div class="hbar-fill" style="width:${pct}%"></div></div>
|
<div class="hbar-track"><div class="hbar-fill" style="width:${pct}%"></div></div>
|
||||||
<div class="hbar-value">${r.dispatch_count}单</div>
|
<div class="hbar-value">${r.dispatch_count}单</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
@@ -40,9 +41,10 @@ async function loadRankingDetail() {
|
|||||||
const max = Math.max(...rows.map(r => r.take_count), 1);
|
const max = Math.max(...rows.map(r => r.take_count), 1);
|
||||||
container.innerHTML = rows.map((r, i) => {
|
container.innerHTML = rows.map((r, i) => {
|
||||||
const pct = Math.max((r.take_count / max) * 100, 2);
|
const pct = Math.max((r.take_count / max) * 100, 2);
|
||||||
return `<div class="hbar-row" onclick="openBossDetail('${r.boss.replace(/'/g, "\\'")}', 'rankingPage')">
|
const boss = escapeHtml(r.boss);
|
||||||
|
return `<div class="hbar-row" onclick="openBossDetail(${jsString(r.boss)}, 'rankingPage')">
|
||||||
<div class="hbar-index ${i < 3 ? 'top' : ''}">${i + 1}</div>
|
<div class="hbar-index ${i < 3 ? 'top' : ''}">${i + 1}</div>
|
||||||
<div class="hbar-label" title="${r.boss}">${r.boss}</div>
|
<div class="hbar-label" title="${escapeAttr(r.boss)}">${boss}</div>
|
||||||
<div class="hbar-track"><div class="hbar-fill" style="width:${pct}%"></div></div>
|
<div class="hbar-track"><div class="hbar-fill" style="width:${pct}%"></div></div>
|
||||||
<div class="hbar-value">${r.take_count}单</div>
|
<div class="hbar-value">${r.take_count}单</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
@@ -51,9 +53,10 @@ async function loadRankingDetail() {
|
|||||||
const max = Math.max(...rows.map(r => r.total_income), 1);
|
const max = Math.max(...rows.map(r => r.total_income), 1);
|
||||||
container.innerHTML = rows.map((r, i) => {
|
container.innerHTML = rows.map((r, i) => {
|
||||||
const pct = Math.max((r.total_income / max) * 100, 2);
|
const pct = Math.max((r.total_income / max) * 100, 2);
|
||||||
return `<div class="hbar-row" onclick="openBossDetail('${r.boss.replace(/'/g, "\\'")}', 'rankingPage')">
|
const boss = escapeHtml(r.boss);
|
||||||
|
return `<div class="hbar-row" onclick="openBossDetail(${jsString(r.boss)}, 'rankingPage')">
|
||||||
<div class="hbar-index ${i < 3 ? 'top' : ''}">${i + 1}</div>
|
<div class="hbar-index ${i < 3 ? 'top' : ''}">${i + 1}</div>
|
||||||
<div class="hbar-label" title="${r.boss}">${r.boss}</div>
|
<div class="hbar-label" title="${escapeAttr(r.boss)}">${boss}</div>
|
||||||
<div class="hbar-track"><div class="hbar-fill" style="width:${pct}%"></div></div>
|
<div class="hbar-track"><div class="hbar-fill" style="width:${pct}%"></div></div>
|
||||||
<div class="hbar-value">${r.total_income.toFixed(2)}</div>
|
<div class="hbar-value">${r.total_income.toFixed(2)}</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
|
|||||||
+73
-63
@@ -2,10 +2,10 @@ async function loadStats() {
|
|||||||
try {
|
try {
|
||||||
// 并行加载所有数据
|
// 并行加载所有数据
|
||||||
const [monthlyRes, bossRankRes, partnerRankRes, weeklyRes] = await Promise.all([
|
const [monthlyRes, bossRankRes, partnerRankRes, weeklyRes] = await Promise.all([
|
||||||
fetch(API + '/api/stats/monthly?year=' + currentYear + '&month=' + currentMonth, { headers: headers() }),
|
apiFetch(API + '/api/stats/monthly?year=' + currentYear + '&month=' + currentMonth),
|
||||||
fetch(API + '/api/stats/boss-ranking/preview?year=' + currentYear + '&month=' + currentMonth, { headers: headers() }),
|
apiFetch(API + '/api/stats/boss-ranking/preview?year=' + currentYear + '&month=' + currentMonth),
|
||||||
fetch(API + '/api/stats/partner-ranking/preview?year=' + currentYear + '&month=' + currentMonth, { headers: headers() }),
|
apiFetch(API + '/api/stats/partner-ranking/preview?year=' + currentYear + '&month=' + currentMonth),
|
||||||
fetch(API + '/api/stats/daily-income', { headers: headers() })
|
apiFetch(API + '/api/stats/daily-income')
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (monthlyRes.ok) {
|
if (monthlyRes.ok) {
|
||||||
@@ -40,9 +40,38 @@ async function loadStats() {
|
|||||||
} catch (e) { console.error(e); }
|
} catch (e) { console.error(e); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function recordLineText(r, fallbackField) {
|
||||||
|
if (r.quantity) {
|
||||||
|
if (r.category === '派单') {
|
||||||
|
const rebate = (r.rebate || r.amount / r.quantity).toFixed(2);
|
||||||
|
return r.quantity + '单 × ' + rebate + '(单价' + (r.unit_price || 0).toFixed(2) + ')';
|
||||||
|
}
|
||||||
|
return r.quantity + '单 × ' + (r.unit_price || 0).toFixed(2);
|
||||||
|
}
|
||||||
|
return r[fallbackField] || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderRecordItem(r, opts = {}) {
|
||||||
|
const time = opts.time || formatLocalTime(r.created_at);
|
||||||
|
const displayCategory = r.category === '红包收入' ? '红包' : (r.category || '-');
|
||||||
|
const suffix = opts.suffix || '';
|
||||||
|
const line = opts.line !== undefined ? opts.line : recordLineText(r, 'note');
|
||||||
|
const amountPrefix = opts.amountPrefix || '';
|
||||||
|
return `<div class="order-item" onclick="openEditModal(JSON.parse(this.dataset.record))" data-record="${recordArg(r)}">
|
||||||
|
<div class="order-item-top">
|
||||||
|
<span class="order-item-boss">${escapeHtml(displayCategory + suffix)}</span>
|
||||||
|
<span class="order-item-amount">${amountPrefix}${(r.amount || 0).toFixed(2)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="order-item-bottom">
|
||||||
|
<span>${escapeHtml(line)}</span>
|
||||||
|
<span>${escapeHtml(time + (opts.partnerTime && r.partner ? ' · ' + r.partner : ''))}</span>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
async function loadWeeklyChart() {
|
async function loadWeeklyChart() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(API + '/api/stats/daily-income', { headers: headers() });
|
const res = await apiFetch(API + '/api/stats/daily-income');
|
||||||
if (!res.ok) return;
|
if (!res.ok) return;
|
||||||
const days = await res.json();
|
const days = await res.json();
|
||||||
renderWeeklyChart(days);
|
renderWeeklyChart(days);
|
||||||
@@ -61,7 +90,7 @@ function renderWeeklyChart(days) {
|
|||||||
const height = Math.max((d.income / max) * barMaxHeight, 4);
|
const height = Math.max((d.income / max) * barMaxHeight, 4);
|
||||||
const label = d.date.slice(5).replace('-', '/');
|
const label = d.date.slice(5).replace('-', '/');
|
||||||
const amount = d.income > 0 ? '' + d.income.toFixed(0) : '0';
|
const amount = d.income > 0 ? '' + d.income.toFixed(0) : '0';
|
||||||
return `<div class="weekly-bar-col" onclick="openDailyDetail('${d.date}')">
|
return `<div class="weekly-bar-col" onclick="openDailyDetail(${jsString(d.date)})">
|
||||||
<span class="weekly-bar-amount">${amount}</span>
|
<span class="weekly-bar-amount">${amount}</span>
|
||||||
<div class="weekly-bar" style="height:${height}px"></div>
|
<div class="weekly-bar" style="height:${height}px"></div>
|
||||||
<span class="weekly-bar-date">${label}</span>
|
<span class="weekly-bar-date">${label}</span>
|
||||||
@@ -81,7 +110,7 @@ async function openDailyDetail(date) {
|
|||||||
showPage('dailyDetailPage');
|
showPage('dailyDetailPage');
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const res = await fetch(API + '/api/stats/daily-detail?date=' + date, { headers: headers() });
|
const res = await apiFetch(API + '/api/stats/daily-detail?date=' + date);
|
||||||
if (!res.ok) return;
|
if (!res.ok) return;
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
document.getElementById('dailySummary').innerHTML = `
|
document.getElementById('dailySummary').innerHTML = `
|
||||||
@@ -95,17 +124,12 @@ async function openDailyDetail(date) {
|
|||||||
}
|
}
|
||||||
container.innerHTML = data.records.map(r => {
|
container.innerHTML = data.records.map(r => {
|
||||||
const time = formatLocalTime(r.created_at);
|
const time = formatLocalTime(r.created_at);
|
||||||
const displayCategory = r.category === '红包收入' ? '红包' : (r.category || '-');
|
return renderRecordItem(r, {
|
||||||
return `<div class="order-item" onclick='openEditModal(${JSON.stringify(r).replace(/'/g, "'")})'>
|
time,
|
||||||
<div class="order-item-top">
|
suffix: r.boss ? ' · ' + r.boss : '',
|
||||||
<span class="order-item-boss">${displayCategory}${r.boss ? ' · ' + r.boss : ''}</span>
|
line: recordLineText(r, 'source'),
|
||||||
<span class="order-item-amount">${(r.amount || 0).toFixed(2)}</span>
|
partnerTime: true
|
||||||
</div>
|
});
|
||||||
<div class="order-item-bottom">
|
|
||||||
<span>${r.quantity ? r.quantity + '单 × ' + (r.category === '派单' ? (r.rebate || r.amount / r.quantity).toFixed(2) + '(单价' + (r.unit_price || 0).toFixed(2) + ')' : (r.unit_price || 0).toFixed(2)) : (r.source || '')}</span>
|
|
||||||
<span>${time}${r.partner ? ' · ' + r.partner : ''}</span>
|
|
||||||
</div>
|
|
||||||
</div>`;
|
|
||||||
}).join('');
|
}).join('');
|
||||||
} catch (e) { console.error(e); }
|
} catch (e) { console.error(e); }
|
||||||
}
|
}
|
||||||
@@ -144,7 +168,7 @@ async function loadYearlyNetIncome() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(API + '/api/stats/yearly-net-income', { headers: headers() });
|
const res = await apiFetch(API + '/api/stats/yearly-net-income');
|
||||||
if (!res.ok) return;
|
if (!res.ok) return;
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
|
|
||||||
@@ -214,7 +238,7 @@ async function loadMonthlyDetail(type) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(API + '/api/stats/monthly-records?type=' + type + '&year=' + monthlyDetailYear + '&month=' + monthlyDetailMonth + '&limit=1000', { headers: headers() });
|
const res = await apiFetch(API + '/api/stats/monthly-records?type=' + type + '&year=' + monthlyDetailYear + '&month=' + monthlyDetailMonth + '&limit=1000');
|
||||||
if (!res.ok) return;
|
if (!res.ok) return;
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
const color = type === 'income' ? '#27ae60' : '#e74c3c';
|
const color = type === 'income' ? '#27ae60' : '#e74c3c';
|
||||||
@@ -238,17 +262,13 @@ async function loadMonthlyDetail(type) {
|
|||||||
html += `<div class="order-date-group"><div class="order-date-label">${date}</div>`;
|
html += `<div class="order-date-group"><div class="order-date-label">${date}</div>`;
|
||||||
records.forEach(r => {
|
records.forEach(r => {
|
||||||
const time = formatLocalTime(r.created_at);
|
const time = formatLocalTime(r.created_at);
|
||||||
const displayCategory = r.category === '红包收入' ? '红包' : (r.category || '-');
|
html += renderRecordItem(r, {
|
||||||
html += `<div class="order-item" onclick='openEditModal(${JSON.stringify(r).replace(/'/g, "'")})'>
|
time,
|
||||||
<div class="order-item-top">
|
suffix: r.boss ? ' · ' + r.boss : r.source ? ' · ' + r.source : r.destination ? ' · ' + r.destination : '',
|
||||||
<span class="order-item-boss">${displayCategory}${r.boss ? ' · ' + r.boss : r.source ? ' · ' + r.source : r.destination ? ' · ' + r.destination : ''}</span>
|
line: recordLineText(r, 'note'),
|
||||||
<span class="order-item-amount">${type === 'expense' ? '-' : ''}${(r.amount || 0).toFixed(2)}</span>
|
amountPrefix: type === 'expense' ? '-' : '',
|
||||||
</div>
|
partnerTime: true
|
||||||
<div class="order-item-bottom">
|
});
|
||||||
<span>${r.quantity ? r.quantity + '单 × ' + (r.category === '派单' ? (r.rebate || r.amount / r.quantity).toFixed(2) + '(单价' + (r.unit_price || 0).toFixed(2) + ')' : (r.unit_price || 0).toFixed(2)) : (r.note || '')}</span>
|
|
||||||
<span>${time}${r.partner ? ' · ' + r.partner : ''}</span>
|
|
||||||
</div>
|
|
||||||
</div>`;
|
|
||||||
});
|
});
|
||||||
html += '</div>';
|
html += '</div>';
|
||||||
}
|
}
|
||||||
@@ -258,7 +278,7 @@ async function loadMonthlyDetail(type) {
|
|||||||
|
|
||||||
async function loadBossRankingPreview() {
|
async function loadBossRankingPreview() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(API + '/api/stats/boss-ranking/preview?year=' + currentYear + '&month=' + currentMonth, { headers: headers() });
|
const res = await apiFetch(API + '/api/stats/boss-ranking/preview?year=' + currentYear + '&month=' + currentMonth);
|
||||||
if (!res.ok) return;
|
if (!res.ok) return;
|
||||||
const rows = await res.json();
|
const rows = await res.json();
|
||||||
renderBossRankingPreview(rows);
|
renderBossRankingPreview(rows);
|
||||||
@@ -275,11 +295,12 @@ function renderHBarRanking(rows, { containerId, getValue, getLabel, onClick, for
|
|||||||
const max = Math.max(...rows.map(r => getValue(r)), 1);
|
const max = Math.max(...rows.map(r => getValue(r)), 1);
|
||||||
container.innerHTML = rows.map((r, i) => {
|
container.innerHTML = rows.map((r, i) => {
|
||||||
const pct = Math.max((getValue(r) / max) * 100, 2);
|
const pct = Math.max((getValue(r) / max) * 100, 2);
|
||||||
const label = getLabel(r).replace(/'/g, "\\'");
|
const rawLabel = getLabel(r);
|
||||||
|
const label = escapeHtml(rawLabel);
|
||||||
const value = formatValue ? formatValue(getValue(r)) : (typeof getValue(r) === 'number' ? getValue(r).toFixed(2) : getValue(r));
|
const value = formatValue ? formatValue(getValue(r)) : (typeof getValue(r) === 'number' ? getValue(r).toFixed(2) : getValue(r));
|
||||||
return `<div class="hbar-row" onclick="${onClick}('${label}', 'homePage')">
|
return `<div class="hbar-row" onclick="${onClick}(${jsString(rawLabel)}, 'homePage')">
|
||||||
<div class="hbar-index ${i < 3 ? 'top' : ''}">${i + 1}</div>
|
<div class="hbar-index ${i < 3 ? 'top' : ''}">${i + 1}</div>
|
||||||
<div class="hbar-label" title="${label}">${label}</div>
|
<div class="hbar-label" title="${escapeAttr(rawLabel)}">${label}</div>
|
||||||
<div class="hbar-track"><div class="hbar-fill" style="width:${pct}%"></div></div>
|
<div class="hbar-track"><div class="hbar-fill" style="width:${pct}%"></div></div>
|
||||||
<div class="hbar-value">${value}</div>
|
<div class="hbar-value">${value}</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
@@ -392,7 +413,7 @@ async function loadBossDetail(append) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(url, { headers: headers() });
|
const res = await apiFetch(url);
|
||||||
if (!res.ok) { bossDetailLoading = false; return; }
|
if (!res.ok) { bossDetailLoading = false; return; }
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
|
|
||||||
@@ -447,17 +468,11 @@ async function loadBossDetail(append) {
|
|||||||
html += `<div class="order-date-group"><div class="order-date-label">${month} 收入 ${group.total.toFixed(2)}</div>`;
|
html += `<div class="order-date-group"><div class="order-date-label">${month} 收入 ${group.total.toFixed(2)}</div>`;
|
||||||
group.records.forEach(r => {
|
group.records.forEach(r => {
|
||||||
const time = formatLocalDate(r.created_at) + ' ' + formatLocalTime(r.created_at);
|
const time = formatLocalDate(r.created_at) + ' ' + formatLocalTime(r.created_at);
|
||||||
const displayCategory = r.category === '红包收入' ? '红包' : r.category;
|
html += renderRecordItem(r, {
|
||||||
html += `<div class="order-item" onclick='openEditModal(${JSON.stringify(r).replace(/'/g, "'")})'>
|
time,
|
||||||
<div class="order-item-top">
|
suffix: r.partner ? ' · ' + r.partner : '',
|
||||||
<span class="order-item-boss">${displayCategory}${r.partner ? ' · ' + r.partner : ''}</span>
|
line: recordLineText(r, 'note')
|
||||||
<span class="order-item-amount">${(r.amount || 0).toFixed(2)}</span>
|
});
|
||||||
</div>
|
|
||||||
<div class="order-item-bottom">
|
|
||||||
<span>${r.quantity ? r.quantity + '单 × ' + (r.category === '派单' ? (r.rebate || r.amount / r.quantity).toFixed(2) + '(单价' + (r.unit_price || 0).toFixed(2) + ')' : (r.unit_price || 0).toFixed(2)) : (r.note || '')}</span>
|
|
||||||
<span>${time}</span>
|
|
||||||
</div>
|
|
||||||
</div>`;
|
|
||||||
});
|
});
|
||||||
html += '</div>';
|
html += '</div>';
|
||||||
}
|
}
|
||||||
@@ -502,7 +517,7 @@ function setupBossDetailScroll() {
|
|||||||
|
|
||||||
async function loadPartnerRankingPreview() {
|
async function loadPartnerRankingPreview() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(API + '/api/stats/partner-ranking/preview?year=' + currentYear + '&month=' + currentMonth, { headers: headers() });
|
const res = await apiFetch(API + '/api/stats/partner-ranking/preview?year=' + currentYear + '&month=' + currentMonth);
|
||||||
if (!res.ok) return;
|
if (!res.ok) return;
|
||||||
const rows = await res.json();
|
const rows = await res.json();
|
||||||
renderPartnerRankingPreview(rows);
|
renderPartnerRankingPreview(rows);
|
||||||
@@ -542,7 +557,7 @@ async function loadPartnerRankingDetail() {
|
|||||||
}
|
}
|
||||||
if (params.length) url += '?' + params.join('&');
|
if (params.length) url += '?' + params.join('&');
|
||||||
try {
|
try {
|
||||||
const res = await fetch(url, { headers: headers() });
|
const res = await apiFetch(url);
|
||||||
if (!res.ok) return;
|
if (!res.ok) return;
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
const rows = data.rows || [];
|
const rows = data.rows || [];
|
||||||
@@ -565,9 +580,10 @@ async function loadPartnerRankingDetail() {
|
|||||||
const max = Math.max(...rows.map(r => r.dispatch_count), 1);
|
const max = Math.max(...rows.map(r => r.dispatch_count), 1);
|
||||||
container.innerHTML = rows.map((r, i) => {
|
container.innerHTML = rows.map((r, i) => {
|
||||||
const pct = Math.max((r.dispatch_count / max) * 100, 2);
|
const pct = Math.max((r.dispatch_count / max) * 100, 2);
|
||||||
return `<div class="hbar-row" onclick="openPartnerDetail('${r.partner.replace(/'/g, "\\'")}', 'partnerRankingPage')">
|
const partner = escapeHtml(r.partner);
|
||||||
|
return `<div class="hbar-row" onclick="openPartnerDetail(${jsString(r.partner)}, 'partnerRankingPage')">
|
||||||
<div class="hbar-index ${i < 3 ? 'top' : ''}">${i + 1}</div>
|
<div class="hbar-index ${i < 3 ? 'top' : ''}">${i + 1}</div>
|
||||||
<div class="hbar-label" title="${r.partner}">${r.partner}</div>
|
<div class="hbar-label" title="${escapeAttr(r.partner)}">${partner}</div>
|
||||||
<div class="hbar-track"><div class="hbar-fill" style="width:${pct}%"></div></div>
|
<div class="hbar-track"><div class="hbar-fill" style="width:${pct}%"></div></div>
|
||||||
<div class="hbar-value">${r.dispatch_count}单</div>
|
<div class="hbar-value">${r.dispatch_count}单</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
@@ -667,7 +683,7 @@ async function loadPartnerDetail(append) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(url, { headers: headers() });
|
const res = await apiFetch(url);
|
||||||
if (!res.ok) { partnerDetailLoading = false; return; }
|
if (!res.ok) { partnerDetailLoading = false; return; }
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
|
|
||||||
@@ -722,17 +738,11 @@ async function loadPartnerDetail(append) {
|
|||||||
html += `<div class="order-date-group"><div class="order-date-label">${month} 收入 ${group.total.toFixed(2)}</div>`;
|
html += `<div class="order-date-group"><div class="order-date-label">${month} 收入 ${group.total.toFixed(2)}</div>`;
|
||||||
group.records.forEach(r => {
|
group.records.forEach(r => {
|
||||||
const time = formatLocalDate(r.created_at) + ' ' + formatLocalTime(r.created_at);
|
const time = formatLocalDate(r.created_at) + ' ' + formatLocalTime(r.created_at);
|
||||||
const displayCategory = r.category === '红包收入' ? '红包' : r.category;
|
html += renderRecordItem(r, {
|
||||||
html += `<div class="order-item" onclick='openEditModal(${JSON.stringify(r).replace(/'/g, "'")})'>
|
time,
|
||||||
<div class="order-item-top">
|
suffix: r.boss ? ' · ' + r.boss : '',
|
||||||
<span class="order-item-boss">${displayCategory}${r.boss ? ' · ' + r.boss : ''}</span>
|
line: recordLineText(r, 'source')
|
||||||
<span class="order-item-amount">${(r.amount || 0).toFixed(2)}</span>
|
});
|
||||||
</div>
|
|
||||||
<div class="order-item-bottom">
|
|
||||||
<span>${r.quantity ? r.quantity + '单 × ' + (r.category === '派单' ? (r.rebate || r.amount / r.quantity).toFixed(2) + '(单价' + (r.unit_price || 0).toFixed(2) + ')' : (r.unit_price || 0).toFixed(2)) : (r.source || '')}</span>
|
|
||||||
<span>${time}</span>
|
|
||||||
</div>
|
|
||||||
</div>`;
|
|
||||||
});
|
});
|
||||||
html += '</div>';
|
html += '</div>';
|
||||||
}
|
}
|
||||||
|
|||||||
+8
-1
@@ -17,7 +17,14 @@ const app = express();
|
|||||||
// 请求日志中间件
|
// 请求日志中间件
|
||||||
app.use(logger.requestLogger);
|
app.use(logger.requestLogger);
|
||||||
|
|
||||||
app.use(cors());
|
app.use(cors({
|
||||||
|
origin(origin, callback) {
|
||||||
|
if (!origin || config.corsOrigins.includes(origin)) {
|
||||||
|
return callback(null, true);
|
||||||
|
}
|
||||||
|
return callback(null, false);
|
||||||
|
}
|
||||||
|
}));
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
app.use(compression());
|
app.use(compression());
|
||||||
app.use(express.static(path.join(__dirname, '..', 'public'), {
|
app.use(express.static(path.join(__dirname, '..', 'public'), {
|
||||||
|
|||||||
+21
-1
@@ -1,15 +1,31 @@
|
|||||||
/**
|
/**
|
||||||
* 应用配置
|
* 应用配置
|
||||||
*/
|
*/
|
||||||
|
const isProduction = process.env.NODE_ENV === 'production';
|
||||||
|
const jwtSecret = process.env.JWT_SECRET || '';
|
||||||
|
|
||||||
|
if (isProduction && !jwtSecret) {
|
||||||
|
throw new Error('JWT_SECRET must be set in production');
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
// 服务器配置
|
// 服务器配置
|
||||||
port: process.env.PORT || 3000,
|
port: process.env.PORT || 3000,
|
||||||
host: process.env.HOST || '0.0.0.0',
|
host: process.env.HOST || '0.0.0.0',
|
||||||
|
|
||||||
// JWT 配置
|
// JWT 配置
|
||||||
jwtSecret: process.env.JWT_SECRET || 'gamer-order-secret-2024',
|
jwtSecret: jwtSecret || 'dev-only-gamer-order-secret',
|
||||||
jwtExpiresIn: '7d',
|
jwtExpiresIn: '7d',
|
||||||
|
|
||||||
|
// 注册默认关闭,生产环境可通过 ENABLE_REGISTRATION=true 显式开启
|
||||||
|
enableRegistration: process.env.ENABLE_REGISTRATION === 'true',
|
||||||
|
|
||||||
|
// CORS 配置:默认同源;如需跨域,用逗号分隔配置 CORS_ORIGINS
|
||||||
|
corsOrigins: (process.env.CORS_ORIGINS || '')
|
||||||
|
.split(',')
|
||||||
|
.map(s => s.trim())
|
||||||
|
.filter(Boolean),
|
||||||
|
|
||||||
// 缓存配置
|
// 缓存配置
|
||||||
cache: {
|
cache: {
|
||||||
ttl: 2 * 60 * 1000, // 缓存有效期 2 分钟
|
ttl: 2 * 60 * 1000, // 缓存有效期 2 分钟
|
||||||
@@ -22,6 +38,10 @@ module.exports = {
|
|||||||
maxLimit: 100 // 最大每页条数
|
maxLimit: 100 // 最大每页条数
|
||||||
},
|
},
|
||||||
|
|
||||||
|
import: {
|
||||||
|
maxRecords: 5000
|
||||||
|
},
|
||||||
|
|
||||||
// 日志配置
|
// 日志配置
|
||||||
log: {
|
log: {
|
||||||
level: process.env.LOG_LEVEL || 'info', // debug, info, warn, error
|
level: process.env.LOG_LEVEL || 'info', // debug, info, warn, error
|
||||||
|
|||||||
+7
-2
@@ -1,5 +1,4 @@
|
|||||||
const Database = require('better-sqlite3');
|
const Database = require('better-sqlite3');
|
||||||
const bcrypt = require('bcryptjs');
|
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const logger = require('./utils/logger');
|
const logger = require('./utils/logger');
|
||||||
|
|
||||||
@@ -108,11 +107,17 @@ function migrateAddRebateColumn() {
|
|||||||
* 初始化默认用户
|
* 初始化默认用户
|
||||||
*/
|
*/
|
||||||
function initDefaultUser() {
|
function initDefaultUser() {
|
||||||
|
if (process.env.NODE_ENV === 'production') {
|
||||||
|
logger.info('生产环境跳过默认用户初始化');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const existingUser = db.prepare('SELECT id FROM users WHERE username = ?').get('lizhilun');
|
const existingUser = db.prepare('SELECT id FROM users WHERE username = ?').get('lizhilun');
|
||||||
if (!existingUser) {
|
if (!existingUser) {
|
||||||
|
const bcrypt = require('bcryptjs');
|
||||||
const hash = bcrypt.hashSync('lizhilun', 10);
|
const hash = bcrypt.hashSync('lizhilun', 10);
|
||||||
db.prepare('INSERT INTO users (username, password) VALUES (?, ?)').run('lizhilun', hash);
|
db.prepare('INSERT INTO users (username, password) VALUES (?, ?)').run('lizhilun', hash);
|
||||||
logger.info('默认用户 lizhilun 已创建');
|
logger.info('开发默认用户 lizhilun 已创建');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,43 @@ const logger = require('../utils/logger');
|
|||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
|
const loginAttempts = new Map();
|
||||||
|
const LOGIN_WINDOW_MS = 15 * 60 * 1000;
|
||||||
|
const LOGIN_MAX_FAILURES = 5;
|
||||||
|
|
||||||
|
function normalizeLoginKey(req, username) {
|
||||||
|
return `${req.ip || req.socket.remoteAddress || 'unknown'}:${String(username || '').trim().toLowerCase()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLoginAttempt(key) {
|
||||||
|
const now = Date.now();
|
||||||
|
const attempt = loginAttempts.get(key);
|
||||||
|
if (!attempt || attempt.resetAt <= now) {
|
||||||
|
const fresh = { failures: 0, resetAt: now + LOGIN_WINDOW_MS };
|
||||||
|
loginAttempts.set(key, fresh);
|
||||||
|
return fresh;
|
||||||
|
}
|
||||||
|
return attempt;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearExpiredLoginAttempts() {
|
||||||
|
const now = Date.now();
|
||||||
|
for (const [key, attempt] of loginAttempts) {
|
||||||
|
if (attempt.resetAt <= now) loginAttempts.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function recordFailedLogin(key) {
|
||||||
|
const attempt = getLoginAttempt(key);
|
||||||
|
attempt.failures += 1;
|
||||||
|
if (loginAttempts.size > 1000) clearExpiredLoginAttempts();
|
||||||
|
return attempt;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetLoginAttempts(key) {
|
||||||
|
loginAttempts.delete(key);
|
||||||
|
}
|
||||||
|
|
||||||
// JWT 中间件
|
// JWT 中间件
|
||||||
function auth(req, res, next) {
|
function auth(req, res, next) {
|
||||||
const token = req.headers.authorization?.split(' ')[1];
|
const token = req.headers.authorization?.split(' ')[1];
|
||||||
@@ -27,15 +64,58 @@ router.post('/login', (req, res) => {
|
|||||||
return res.status(400).json({ error: '用户名和密码不能为空' });
|
return res.status(400).json({ error: '用户名和密码不能为空' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const loginKey = normalizeLoginKey(req, username);
|
||||||
|
const attempt = getLoginAttempt(loginKey);
|
||||||
|
if (attempt.failures >= LOGIN_MAX_FAILURES) {
|
||||||
|
const retryAfter = Math.ceil((attempt.resetAt - Date.now()) / 1000);
|
||||||
|
res.setHeader('Retry-After', String(Math.max(retryAfter, 1)));
|
||||||
|
return res.status(429).json({ error: '登录失败次数过多,请稍后再试' });
|
||||||
|
}
|
||||||
|
|
||||||
const user = db.prepare('SELECT * FROM users WHERE username = ?').get(username);
|
const user = db.prepare('SELECT * FROM users WHERE username = ?').get(username);
|
||||||
if (!user || !bcrypt.compareSync(password, user.password)) {
|
if (!user || !bcrypt.compareSync(password, user.password)) {
|
||||||
|
recordFailedLogin(loginKey);
|
||||||
logger.warn(`登录失败: ${username}`);
|
logger.warn(`登录失败: ${username}`);
|
||||||
return res.status(401).json({ error: '用户名或密码错误' });
|
return res.status(401).json({ error: '用户名或密码错误' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
resetLoginAttempts(loginKey);
|
||||||
const token = jwt.sign({ id: user.id, username: user.username }, config.jwtSecret, { expiresIn: config.jwtExpiresIn });
|
const token = jwt.sign({ id: user.id, username: user.username }, config.jwtSecret, { expiresIn: config.jwtExpiresIn });
|
||||||
logger.info(`用户登录: ${username}`);
|
logger.info(`用户登录: ${username}`);
|
||||||
res.json({ token, username: user.username });
|
res.json({ token, username: user.username });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 注册
|
||||||
|
router.post('/register', (req, res) => {
|
||||||
|
if (!config.enableRegistration) {
|
||||||
|
return res.status(403).json({ error: '注册已关闭' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { username, password } = req.body;
|
||||||
|
|
||||||
|
if (!username || !password) {
|
||||||
|
return res.status(400).json({ error: '用户名和密码不能为空' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (username.length < 2 || username.length > 20) {
|
||||||
|
return res.status(400).json({ error: '用户名长度需在2-20个字符之间' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (password.length < 6) {
|
||||||
|
return res.status(400).json({ error: '密码长度不能少于6个字符' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = db.prepare('SELECT id FROM users WHERE username = ?').get(username);
|
||||||
|
if (existing) {
|
||||||
|
return res.status(409).json({ error: '用户名已存在' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const hash = bcrypt.hashSync(password, 10);
|
||||||
|
const result = db.prepare('INSERT INTO users (username, password) VALUES (?, ?)').run(username, hash);
|
||||||
|
logger.info(`新用户注册: ${username}`);
|
||||||
|
|
||||||
|
const token = jwt.sign({ id: result.lastInsertRowid, username }, config.jwtSecret, { expiresIn: config.jwtExpiresIn });
|
||||||
|
res.json({ token, username });
|
||||||
|
});
|
||||||
|
|
||||||
module.exports = { router, auth };
|
module.exports = { router, auth };
|
||||||
|
|||||||
+90
-25
@@ -1,10 +1,26 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const db = require('../db');
|
const db = require('../db');
|
||||||
|
const config = require('../config');
|
||||||
const logger = require('../utils/logger');
|
const logger = require('../utils/logger');
|
||||||
const { parseCSVLine, AppError } = require('../utils/helpers');
|
const {
|
||||||
|
parseCSVLine,
|
||||||
|
AppError,
|
||||||
|
validateNumber,
|
||||||
|
validateInList,
|
||||||
|
validateIsoDate,
|
||||||
|
optionalString
|
||||||
|
} = require('../utils/helpers');
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
|
function emptyToNull(value) {
|
||||||
|
return value === undefined || value === null || value === '' ? null : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function nullableDbValue(value) {
|
||||||
|
return value === undefined || value === null || value === '' ? null : value;
|
||||||
|
}
|
||||||
|
|
||||||
// 导出数据
|
// 导出数据
|
||||||
router.get('/export', (req, res, next) => {
|
router.get('/export', (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
@@ -89,29 +105,63 @@ router.post('/import', express.text({ type: '*/*', limit: '10mb' }), (req, res,
|
|||||||
if (!lines[i].trim()) continue;
|
if (!lines[i].trim()) continue;
|
||||||
const values = parseCSVLine(lines[i]);
|
const values = parseCSVLine(lines[i]);
|
||||||
const record = {};
|
const record = {};
|
||||||
csvHeaders.forEach((h, idx) => { record[h] = values[idx] || null; });
|
csvHeaders.forEach((h, idx) => { record[h] = emptyToNull(values[idx]); });
|
||||||
if (record.amount) record.amount = parseFloat(record.amount);
|
if (record.amount !== null) record.amount = parseFloat(record.amount);
|
||||||
if (record.quantity) record.quantity = parseInt(record.quantity) || null;
|
if (record.quantity !== null) record.quantity = parseInt(record.quantity, 10);
|
||||||
if (record.unit_price) record.unit_price = parseFloat(record.unit_price) || null;
|
if (record.unit_price !== null) record.unit_price = parseFloat(record.unit_price);
|
||||||
if (record.rebate) record.rebate = parseFloat(record.rebate) || null;
|
if (record.rebate !== null) record.rebate = parseFloat(record.rebate);
|
||||||
records.push(record);
|
records.push(record);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const r of records) {
|
if (records.length > config.import.maxRecords) {
|
||||||
for (const f of requiredFields) {
|
throw new AppError(`单次最多导入 ${config.import.maxRecords} 条记录`, 400);
|
||||||
if (!r[f] && r[f] !== 0) {
|
|
||||||
throw new AppError(`记录缺少必填字段: ${f}`, 400);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!['income', 'expense'].includes(r.type)) {
|
|
||||||
throw new AppError(`无效的类型: ${r.type}`, 400);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const existingCheck = db.prepare(
|
const allCategories = [...config.categories.income, ...config.categories.expense];
|
||||||
'SELECT id FROM records WHERE user_id = ? AND created_at = ? AND category = ? AND amount = ?'
|
const normalizedRecords = records.map((r, index) => {
|
||||||
);
|
for (const f of requiredFields) {
|
||||||
|
if (!r[f] && r[f] !== 0) {
|
||||||
|
throw new AppError(`第 ${index + 1} 条记录缺少必填字段: ${f}`, 400);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
validateInList(r.type, 'type', ['income', 'expense']);
|
||||||
|
validateInList(r.category, 'category', allCategories);
|
||||||
|
const amount = r.category === '派单'
|
||||||
|
? validateNumber(r.amount, '金额')
|
||||||
|
: validateNumber(r.amount, '金额', 0);
|
||||||
|
return {
|
||||||
|
type: r.type,
|
||||||
|
category: r.category,
|
||||||
|
amount,
|
||||||
|
quantity: r.quantity === undefined || r.quantity === null || r.quantity === '' ? null : validateNumber(r.quantity, '数量', 0),
|
||||||
|
unit_price: r.unit_price === undefined || r.unit_price === null || r.unit_price === '' ? null : validateNumber(r.unit_price, '单价', 0),
|
||||||
|
rebate: r.rebate === undefined || r.rebate === null || r.rebate === '' ? null : validateNumber(r.rebate, '返点'),
|
||||||
|
boss: optionalString(r.boss, '老板', 100),
|
||||||
|
partner: optionalString(r.partner, '陪玩', 100),
|
||||||
|
source: optionalString(r.source, '来源', 100),
|
||||||
|
destination: optionalString(r.destination, '去向', 100),
|
||||||
|
note: optionalString(r.note, '备注', 500),
|
||||||
|
created_at: validateIsoDate(r.created_at, '创建时间')
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const existingCheck = db.prepare(`
|
||||||
|
SELECT id FROM records
|
||||||
|
WHERE user_id = ?
|
||||||
|
AND type = ?
|
||||||
|
AND category = ?
|
||||||
|
AND amount = ?
|
||||||
|
AND COALESCE(quantity, '') = COALESCE(?, '')
|
||||||
|
AND COALESCE(unit_price, '') = COALESCE(?, '')
|
||||||
|
AND COALESCE(rebate, '') = COALESCE(?, '')
|
||||||
|
AND COALESCE(boss, '') = COALESCE(?, '')
|
||||||
|
AND COALESCE(partner, '') = COALESCE(?, '')
|
||||||
|
AND COALESCE(source, '') = COALESCE(?, '')
|
||||||
|
AND COALESCE(destination, '') = COALESCE(?, '')
|
||||||
|
AND COALESCE(note, '') = COALESCE(?, '')
|
||||||
|
AND created_at = ?
|
||||||
|
`);
|
||||||
const insertStmt = db.prepare(`
|
const insertStmt = db.prepare(`
|
||||||
INSERT INTO records (user_id, type, category, amount, quantity, unit_price, rebate, boss, partner, source, destination, note, created_at)
|
INSERT INTO records (user_id, type, category, amount, quantity, unit_price, rebate, boss, partner, source, destination, note, created_at)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
@@ -120,21 +170,36 @@ router.post('/import', express.text({ type: '*/*', limit: '10mb' }), (req, res,
|
|||||||
let imported = 0, skipped = 0;
|
let imported = 0, skipped = 0;
|
||||||
const insertMany = db.transaction((recs) => {
|
const insertMany = db.transaction((recs) => {
|
||||||
for (const r of recs) {
|
for (const r of recs) {
|
||||||
if (existingCheck.get(req.user.id, r.created_at, r.category, r.amount)) {
|
const values = [
|
||||||
|
req.user.id,
|
||||||
|
r.type,
|
||||||
|
r.category,
|
||||||
|
r.amount,
|
||||||
|
r.quantity,
|
||||||
|
r.unit_price,
|
||||||
|
r.rebate,
|
||||||
|
r.boss,
|
||||||
|
r.partner,
|
||||||
|
r.source,
|
||||||
|
r.destination,
|
||||||
|
r.note,
|
||||||
|
r.created_at
|
||||||
|
];
|
||||||
|
if (existingCheck.get(...values)) {
|
||||||
skipped++; continue;
|
skipped++; continue;
|
||||||
}
|
}
|
||||||
insertStmt.run(req.user.id, r.type, r.category, r.amount,
|
insertStmt.run(req.user.id, r.type, r.category, r.amount,
|
||||||
r.quantity || null, r.unit_price || null, r.rebate || null,
|
nullableDbValue(r.quantity), nullableDbValue(r.unit_price), nullableDbValue(r.rebate),
|
||||||
r.boss || null, r.partner || null,
|
nullableDbValue(r.boss), nullableDbValue(r.partner),
|
||||||
r.source || null, r.destination || null,
|
nullableDbValue(r.source), nullableDbValue(r.destination),
|
||||||
r.note || null, r.created_at);
|
nullableDbValue(r.note), r.created_at);
|
||||||
imported++;
|
imported++;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
insertMany(records);
|
insertMany(normalizedRecords);
|
||||||
|
|
||||||
logger.info(`用户 ${req.user.username} 导入数据: ${imported} 条, 跳过 ${skipped} 条`);
|
logger.info(`用户 ${req.user.username} 导入数据: ${imported} 条, 跳过 ${skipped} 条`);
|
||||||
res.json({ imported, skipped, total: records.length });
|
res.json({ imported, skipped, total: normalizedRecords.length });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof SyntaxError) {
|
if (err instanceof SyntaxError) {
|
||||||
next(new AppError('文件解析失败: ' + err.message, 400));
|
next(new AppError('文件解析失败: ' + err.message, 400));
|
||||||
|
|||||||
+70
-11
@@ -2,7 +2,17 @@ const express = require('express');
|
|||||||
const db = require('../db');
|
const db = require('../db');
|
||||||
const config = require('../config');
|
const config = require('../config');
|
||||||
const logger = require('../utils/logger');
|
const logger = require('../utils/logger');
|
||||||
const { getMonthRange, AppError, validateRequired, validateNumber, validateInList } = require('../utils/helpers');
|
const {
|
||||||
|
getMonthRange,
|
||||||
|
AppError,
|
||||||
|
validateRequired,
|
||||||
|
validateNumber,
|
||||||
|
validateInList,
|
||||||
|
validatePage,
|
||||||
|
validateLimit,
|
||||||
|
validateIsoDate,
|
||||||
|
optionalString
|
||||||
|
} = require('../utils/helpers');
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
@@ -18,13 +28,34 @@ router.post('/', (req, res, next) => {
|
|||||||
const allCategories = [...config.categories.income, ...config.categories.expense];
|
const allCategories = [...config.categories.income, ...config.categories.expense];
|
||||||
validateInList(category, 'category', allCategories);
|
validateInList(category, 'category', allCategories);
|
||||||
|
|
||||||
const validatedAmount = validateNumber(amount, '金额', 0);
|
// 派单的返点可为负数,因此金额也允许为负数
|
||||||
|
const validatedAmount = category === '派单'
|
||||||
|
? validateNumber(amount, '金额')
|
||||||
|
: validateNumber(amount, '金额', 0);
|
||||||
|
const validatedQuantity = quantity === undefined || quantity === null || quantity === '' ? null : validateNumber(quantity, '数量', 0);
|
||||||
|
const validatedUnitPrice = unit_price === undefined || unit_price === null || unit_price === '' ? null : validateNumber(unit_price, '单价', 0);
|
||||||
|
const validatedRebate = rebate === undefined || rebate === null || rebate === '' ? null : validateNumber(rebate, '返点');
|
||||||
|
const validatedCreatedAt = validateIsoDate(created_at, '创建时间');
|
||||||
|
|
||||||
const stmt = db.prepare(`
|
const stmt = db.prepare(`
|
||||||
INSERT INTO records (user_id, type, category, amount, quantity, unit_price, rebate, boss, partner, source, destination, note, created_at)
|
INSERT INTO records (user_id, type, category, amount, quantity, unit_price, rebate, boss, partner, source, destination, note, created_at)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
`);
|
`);
|
||||||
const result = stmt.run(req.user.id, type, category, validatedAmount, quantity || null, unit_price || null, rebate || null, boss || null, partner || null, source || null, destination || null, note || null, created_at || new Date().toISOString());
|
const result = stmt.run(
|
||||||
|
req.user.id,
|
||||||
|
type,
|
||||||
|
category,
|
||||||
|
validatedAmount,
|
||||||
|
validatedQuantity,
|
||||||
|
validatedUnitPrice,
|
||||||
|
validatedRebate,
|
||||||
|
optionalString(boss, '老板', 100),
|
||||||
|
optionalString(partner, '陪玩', 100),
|
||||||
|
optionalString(source, '来源', 100),
|
||||||
|
optionalString(destination, '去向', 100),
|
||||||
|
optionalString(note, '备注', 500),
|
||||||
|
validatedCreatedAt
|
||||||
|
);
|
||||||
|
|
||||||
logger.info(`用户 ${req.user.username} 新增记录: ${category} ${validatedAmount}`);
|
logger.info(`用户 ${req.user.username} 新增记录: ${category} ${validatedAmount}`);
|
||||||
res.json({ id: result.lastInsertRowid });
|
res.json({ id: result.lastInsertRowid });
|
||||||
@@ -35,8 +66,9 @@ router.post('/', (req, res, next) => {
|
|||||||
|
|
||||||
// 通用分页查询函数
|
// 通用分页查询函数
|
||||||
function queryPagedRecords(userId, year, month, options) {
|
function queryPagedRecords(userId, year, month, options) {
|
||||||
const { page = 1, limit = 20 } = options;
|
const page = validatePage(options.page, 1);
|
||||||
const offset = (Number(page) - 1) * Number(limit);
|
const limit = validateLimit(options.limit, config.pagination.defaultLimit, config.pagination.maxLimit);
|
||||||
|
const offset = (page - 1) * limit;
|
||||||
const { startDate, endDate } = getMonthRange(year, month);
|
const { startDate, endDate } = getMonthRange(year, month);
|
||||||
|
|
||||||
let whereClause = `user_id = ? AND DATE(created_at, 'localtime') >= ? AND DATE(created_at, 'localtime') < ?`;
|
let whereClause = `user_id = ? AND DATE(created_at, 'localtime') >= ? AND DATE(created_at, 'localtime') < ?`;
|
||||||
@@ -61,9 +93,9 @@ function queryPagedRecords(userId, year, month, options) {
|
|||||||
|
|
||||||
const records = db.prepare(
|
const records = db.prepare(
|
||||||
`SELECT * FROM records WHERE ${whereClause} ORDER BY created_at DESC LIMIT ? OFFSET ?`
|
`SELECT * FROM records WHERE ${whereClause} ORDER BY created_at DESC LIMIT ? OFFSET ?`
|
||||||
).all(...params, Number(limit), offset);
|
).all(...params, limit, offset);
|
||||||
|
|
||||||
return { records, total, page: Number(page), limit: Number(limit) };
|
return { records, total, page, limit };
|
||||||
}
|
}
|
||||||
|
|
||||||
// 接单列表
|
// 接单列表
|
||||||
@@ -122,7 +154,9 @@ router.get('/dispatches', (req, res) => {
|
|||||||
// 获取记录列表
|
// 获取记录列表
|
||||||
router.get('/', (req, res) => {
|
router.get('/', (req, res) => {
|
||||||
const { type, page = 1, limit = 20 } = req.query;
|
const { type, page = 1, limit = 20 } = req.query;
|
||||||
const offset = (page - 1) * limit;
|
const validatedPage = validatePage(page, 1);
|
||||||
|
const validatedLimit = validateLimit(limit, config.pagination.defaultLimit, config.pagination.maxLimit);
|
||||||
|
const offset = (validatedPage - 1) * validatedLimit;
|
||||||
let sql = 'SELECT * FROM records WHERE user_id = ?';
|
let sql = 'SELECT * FROM records WHERE user_id = ?';
|
||||||
const params = [req.user.id];
|
const params = [req.user.id];
|
||||||
if (type) {
|
if (type) {
|
||||||
@@ -130,7 +164,7 @@ router.get('/', (req, res) => {
|
|||||||
params.push(type);
|
params.push(type);
|
||||||
}
|
}
|
||||||
sql += ' ORDER BY created_at DESC LIMIT ? OFFSET ?';
|
sql += ' ORDER BY created_at DESC LIMIT ? OFFSET ?';
|
||||||
params.push(Number(limit), Number(offset));
|
params.push(validatedLimit, offset);
|
||||||
res.json(db.prepare(sql).all(...params));
|
res.json(db.prepare(sql).all(...params));
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -144,12 +178,37 @@ router.put('/:id', (req, res, next) => {
|
|||||||
throw new AppError('无效的记录ID', 400);
|
throw new AppError('无效的记录ID', 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
const validatedAmount = validateNumber(amount, '金额', 0);
|
// 获取原有记录以判断类别(派单允许负数金额)
|
||||||
|
const existing = db.prepare('SELECT category FROM records WHERE id = ? AND user_id = ?').get(id, req.user.id);
|
||||||
|
if (!existing) {
|
||||||
|
throw new AppError('记录不存在', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const validatedAmount = existing.category === '派单'
|
||||||
|
? validateNumber(amount, '金额')
|
||||||
|
: validateNumber(amount, '金额', 0);
|
||||||
|
const validatedQuantity = quantity === undefined || quantity === null || quantity === '' ? null : validateNumber(quantity, '数量', 0);
|
||||||
|
const validatedUnitPrice = unit_price === undefined || unit_price === null || unit_price === '' ? null : validateNumber(unit_price, '单价', 0);
|
||||||
|
const validatedRebate = rebate === undefined || rebate === null || rebate === '' ? null : validateNumber(rebate, '返点');
|
||||||
|
const validatedCreatedAt = validateIsoDate(created_at, '创建时间');
|
||||||
|
|
||||||
db.prepare(`
|
db.prepare(`
|
||||||
UPDATE records SET quantity = ?, unit_price = ?, rebate = ?, amount = ?, boss = ?, partner = ?, source = ?, destination = ?, note = ?, created_at = ?
|
UPDATE records SET quantity = ?, unit_price = ?, rebate = ?, amount = ?, boss = ?, partner = ?, source = ?, destination = ?, note = ?, created_at = ?
|
||||||
WHERE id = ? AND user_id = ?
|
WHERE id = ? AND user_id = ?
|
||||||
`).run(quantity || null, unit_price || null, rebate || null, validatedAmount, boss || null, partner || null, source || null, destination || null, note || null, created_at || null, id, req.user.id);
|
`).run(
|
||||||
|
validatedQuantity,
|
||||||
|
validatedUnitPrice,
|
||||||
|
validatedRebate,
|
||||||
|
validatedAmount,
|
||||||
|
optionalString(boss, '老板', 100),
|
||||||
|
optionalString(partner, '陪玩', 100),
|
||||||
|
optionalString(source, '来源', 100),
|
||||||
|
optionalString(destination, '去向', 100),
|
||||||
|
optionalString(note, '备注', 500),
|
||||||
|
validatedCreatedAt,
|
||||||
|
id,
|
||||||
|
req.user.id
|
||||||
|
);
|
||||||
|
|
||||||
logger.debug(`用户 ${req.user.username} 更新记录: ${id}`);
|
logger.debug(`用户 ${req.user.username} 更新记录: ${id}`);
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
|
|||||||
+29
-2
@@ -58,7 +58,7 @@ function validateRequired(obj, fields) {
|
|||||||
|
|
||||||
function validateNumber(value, field, min, max) {
|
function validateNumber(value, field, min, max) {
|
||||||
const num = Number(value);
|
const num = Number(value);
|
||||||
if (isNaN(num)) {
|
if (!Number.isFinite(num)) {
|
||||||
throw new AppError(`${field} 必须是数字`, 400);
|
throw new AppError(`${field} 必须是数字`, 400);
|
||||||
}
|
}
|
||||||
if (min !== undefined && num < min) {
|
if (min !== undefined && num < min) {
|
||||||
@@ -80,6 +80,11 @@ function validateString(value, field, maxLength) {
|
|||||||
return value.trim();
|
return value.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function optionalString(value, field, maxLength) {
|
||||||
|
if (value === undefined || value === null || value === '') return null;
|
||||||
|
return validateString(String(value), field, maxLength);
|
||||||
|
}
|
||||||
|
|
||||||
function validateInList(value, field, list) {
|
function validateInList(value, field, list) {
|
||||||
if (!list.includes(value)) {
|
if (!list.includes(value)) {
|
||||||
throw new AppError(`${field} 值无效`, 400);
|
throw new AppError(`${field} 值无效`, 400);
|
||||||
@@ -87,6 +92,24 @@ function validateInList(value, field, list) {
|
|||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function validatePage(value, defaultValue = 1) {
|
||||||
|
return Math.max(1, Number.parseInt(value, 10) || defaultValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateLimit(value, defaultValue, maxLimit) {
|
||||||
|
const limit = Number.parseInt(value, 10) || defaultValue;
|
||||||
|
return Math.min(Math.max(1, limit), maxLimit);
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateIsoDate(value, field) {
|
||||||
|
if (value === undefined || value === null || value === '') return new Date().toISOString();
|
||||||
|
const date = new Date(value);
|
||||||
|
if (Number.isNaN(date.getTime())) {
|
||||||
|
throw new AppError(`${field} 格式无效`, 400);
|
||||||
|
}
|
||||||
|
return date.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
AppError,
|
AppError,
|
||||||
getMonthRange,
|
getMonthRange,
|
||||||
@@ -94,5 +117,9 @@ module.exports = {
|
|||||||
validateRequired,
|
validateRequired,
|
||||||
validateNumber,
|
validateNumber,
|
||||||
validateString,
|
validateString,
|
||||||
validateInList
|
optionalString,
|
||||||
|
validateInList,
|
||||||
|
validatePage,
|
||||||
|
validateLimit,
|
||||||
|
validateIsoDate
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user