6 Commits

Author SHA1 Message Date
052f3a137b ```
fix(public): 修复项目列表中section和type字段显示逻辑

修复了项目列表表格中section和type字段的显示顺序问题,
确保优先显示type字段内容,当type为空时才显示section字段内容
```
2026-03-30 18:35:40 +08:00
0d74cfe754 ```
feat(scheduler): 添加高级调度配置界面

- 实现调度模式选择(每天、每周、每月、自定义Cron)
- 添加时间选择器和日期相关配置选项
- 提供可视化调度预览功能
- 支持Cron表达式验证和实时更新
- 修改默认调度时间为早上8点40分
- 更新邮件接收者地址
```
2026-03-20 16:28:27 +08:00
89d0abd44c ```
feat: 添加Windows PowerShell重启脚本

添加restart.ps1脚本来管理服务器进程的启动、停止和重启功能,
包括PID文件管理和端口检测。同时创建了logs/server.pid文件来
存储服务器进程ID。
```
2026-03-20 11:54:13 +08:00
f8374f5e0d ```
feat: 添加服务器重启脚本

添加restart.sh脚本用于管理服务器进程,包含以下功能:
- 自动查找并停止现有服务器进程
- 启动新的服务器实例
- 管理进程ID文件和日志输出
- 支持端口配置和状态检查
```
2026-03-19 15:37:29 +08:00
f8dfad26a4 ```
feat(resultStore): 支持数据库文件迁移和兼容性处理

- 添加对旧版 results.sqlite 到新版 results.db 的自动迁移支持
- 实现数据库连接时的错误处理和日志记录
- 修改数据库事务模式为 DELETE 模式以提高稳定性
- 增加对临时 WAL 和 SHM 文件的清理处理
- 提供环境变量配置的数据库路径优先级支持
```
2026-03-19 15:12:55 +08:00
2a5fd99319 ```
feat(project): 添加项目数据导出功能

- 在项目查询界面添加导出按钮,支持将项目结果导出为CSV文件
- 实现exportProjectResults函数处理导出逻辑,包括参数验证和文件下载
- 新增validateProjectFilters函数用于验证项目筛选条件
- 新增buildProjectQueryParams函数构建查询参数
- 在服务端添加/api/projects/export接口,返回CSV格式数据
- 实现CSV文件生成和下载功能,包含中文文件名处理
- 重构项目查询逻辑,提取getProjects函数复用代码
```
2026-03-19 15:03:43 +08:00
9 changed files with 704 additions and 50 deletions

View File

@@ -6,7 +6,7 @@
}, },
"scheduler": { "scheduler": {
"enabled": true, "enabled": true,
"cronTime": "0 9 * * *", "cronTime": "40 08 * * *",
"threshold": 100000, "threshold": 100000,
"description": "每天9点采集当日项目", "description": "每天9点采集当日项目",
"timeRange": "thisMonth" "timeRange": "thisMonth"
@@ -16,6 +16,6 @@
"smtpPort": 587, "smtpPort": 587,
"smtpUser": "1076597680@qq.com", "smtpUser": "1076597680@qq.com",
"smtpPass": "nfrjdiraqddsjeeh", "smtpPass": "nfrjdiraqddsjeeh",
"recipients": "1650243281@qq.com" "recipients": "5482498@qq.com"
} }
} }

13
disable-all-tasks.bat Normal file
View File

@@ -0,0 +1,13 @@
@echo off
chcp 65001 >nul
cd /d "%~dp0"
echo ========================================
echo 批量禁用所有任务
echo ========================================
echo.
node disable-all-tasks.js
echo.
pause

65
disable-all-tasks.js Normal file
View File

@@ -0,0 +1,65 @@
// 批量禁用所有任务脚本
// 用法: node disable-all-tasks.js
import Database from 'better-sqlite3';
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
import { existsSync, mkdirSync } from 'fs';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const DEFAULT_DB_DIR = join(__dirname, 'data');
const DEFAULT_DB_PATH = join(DEFAULT_DB_DIR, 'results.db');
const LEGACY_DB_PATH = join(DEFAULT_DB_DIR, 'results.sqlite');
// 确定数据库路径
let DB_PATH = process.env.APP_DB_PATH || process.env.RESULTS_DB_PATH;
if (!DB_PATH) {
if (existsSync(DEFAULT_DB_PATH)) {
DB_PATH = DEFAULT_DB_PATH;
} else if (existsSync(LEGACY_DB_PATH)) {
DB_PATH = LEGACY_DB_PATH;
} else {
DB_PATH = DEFAULT_DB_PATH;
}
}
if (!existsSync(DB_PATH)) {
console.error('数据库文件不存在:', DB_PATH);
console.log('请确保项目已初始化并运行过至少一次');
process.exit(1);
}
try {
const db = new Database(DB_PATH);
// 先查询当前启用的任务数量
const enabledCount = db.prepare('SELECT COUNT(*) as count FROM tasks WHERE enabled = 1').get();
console.log(`当前启用的任务数量: ${enabledCount.count}`);
// 查询总任务数量
const totalCount = db.prepare('SELECT COUNT(*) as count FROM tasks').get();
console.log(`总任务数量: ${totalCount.count}`);
if (enabledCount.count === 0) {
console.log('没有需要禁用的任务');
db.close();
process.exit(0);
}
// 执行批量禁用
const result = db.prepare('UPDATE tasks SET enabled = 0').run();
console.log(`已禁用 ${result.changes} 个任务`);
// 验证结果
const newEnabledCount = db.prepare('SELECT COUNT(*) as count FROM tasks WHERE enabled = 1').get();
console.log(`禁用后启用的任务数量: ${newEnabledCount.count}`);
db.close();
console.log('操作完成');
} catch (error) {
console.error('操作失败:', error.message);
process.exit(1);
}

1
logs/server.pid Normal file
View File

@@ -0,0 +1 @@
15556

View File

@@ -622,6 +622,7 @@
<div style="display:flex;gap:10px;align-items:center;"> <div style="display:flex;gap:10px;align-items:center;">
<button class="btn btn-primary btn-sm" onclick="searchProjectResults()">查询</button> <button class="btn btn-primary btn-sm" onclick="searchProjectResults()">查询</button>
<button class="btn btn-secondary btn-sm" onclick="resetProjectFilters()">重置</button> <button class="btn btn-secondary btn-sm" onclick="resetProjectFilters()">重置</button>
<button class="btn btn-success btn-sm" id="btnExportProjects" onclick="exportProjectResults(this)">导出</button>
<!-- <button class="btn btn-danger btn-sm" onclick="clearResults()">清空全部</button> --> <!-- <button class="btn btn-danger btn-sm" onclick="clearResults()">清空全部</button> -->
</div> </div>
</div> </div>
@@ -687,14 +688,51 @@
</select> </select>
</div> </div>
<div class="form-group"> <div class="form-group">
<label>Cron 表达</label> <label>执行方</label>
<input type="text" id="cfgCronTime" placeholder="0 9 * * *"> <select id="cfgScheduleMode" onchange="handleSchedulerModeChange()">
<option value="daily">每天</option>
<option value="weekly">每周</option>
<option value="monthly">每月</option>
<option value="custom">高级 Cron</option>
</select>
</div> </div>
<div class="form-group"> <div class="form-group">
<label>描述</label> <label>描述</label>
<input type="text" id="cfgDescription" placeholder="每天9点采集"> <input type="text" id="cfgDescription" placeholder="每天9点采集">
</div> </div>
</div> </div>
<div class="form-row">
<div class="form-group">
<label>执行时间</label>
<input type="time" id="cfgScheduleTime" value="09:00" onchange="updateSchedulerCronPreview()">
</div>
<div class="form-group" id="cfgWeekdayGroup" style="display:none;">
<label>星期</label>
<select id="cfgWeekday" onchange="updateSchedulerCronPreview()">
<option value="1">周一</option>
<option value="2">周二</option>
<option value="3">周三</option>
<option value="4">周四</option>
<option value="5">周五</option>
<option value="6">周六</option>
<option value="0">周日</option>
</select>
</div>
<div class="form-group" id="cfgMonthDayGroup" style="display:none;">
<label>每月几号</label>
<input type="number" id="cfgMonthDay" min="1" max="31" value="1" onchange="updateSchedulerCronPreview()">
</div>
</div>
<div class="form-row">
<div class="form-group" id="cfgCustomCronGroup" style="display:none;">
<label>高级 Cron 表达式</label>
<input type="text" id="cfgCronTime" placeholder="0 9 * * *" oninput="updateSchedulerCronPreview()">
</div>
<div class="form-group">
<label>计划预览</label>
<input type="text" id="cfgCronPreview" readonly placeholder="每天 09:00">
</div>
</div>
<button class="btn btn-secondary btn-sm" onclick="triggerScheduledTask()">立即执行定时任务</button> <button class="btn btn-secondary btn-sm" onclick="triggerScheduledTask()">立即执行定时任务</button>
<span id="schedulerStatus" style="margin-left:12px;font-size:13px;color:#888;"></span> <span id="schedulerStatus" style="margin-left:12px;font-size:13px;color:#888;"></span>
</div> </div>
@@ -1130,21 +1168,10 @@
async function loadProjectResults() { async function loadProjectResults() {
try { try {
await loadProjectFilters();
const params = new URLSearchParams({ page: currentProjectsPage, pageSize: 10 });
const projectFilters = getProjectFilterValues(); const projectFilters = getProjectFilterValues();
validateProjectFilters(projectFilters);
if (projectFilters.minAmount && projectFilters.maxAmount && Number(projectFilters.minAmount) > Number(projectFilters.maxAmount)) { await loadProjectFilters();
throw new Error('最小金额不能大于最大金额'); const params = buildProjectQueryParams({ projectFilters, includePagination: true });
}
if (projectFilters.startDate && projectFilters.endDate && projectFilters.startDate > projectFilters.endDate) {
throw new Error('开始日期不能晚于结束日期');
}
Object.entries(projectFilters).forEach(([key, value]) => {
if (value !== '') params.set(key, value);
});
const res = await fetch('/api/projects?' + params); const res = await fetch('/api/projects?' + params);
const json = await res.json(); const json = await res.json();
@@ -1198,6 +1225,31 @@
}; };
} }
function validateProjectFilters(projectFilters) {
if (projectFilters.minAmount && projectFilters.maxAmount && Number(projectFilters.minAmount) > Number(projectFilters.maxAmount)) {
throw new Error('最小金额不能大于最大金额');
}
if (projectFilters.startDate && projectFilters.endDate && projectFilters.startDate > projectFilters.endDate) {
throw new Error('开始日期不能晚于结束日期');
}
}
function buildProjectQueryParams({ projectFilters = getProjectFilterValues(), includePagination = true } = {}) {
const params = new URLSearchParams();
if (includePagination) {
params.set('page', currentProjectsPage);
params.set('pageSize', 10);
}
Object.entries(projectFilters).forEach(([key, value]) => {
if (value !== '') params.set(key, value);
});
return params;
}
function searchProjectResults() { function searchProjectResults() {
currentProjectsPage = 1; currentProjectsPage = 1;
loadProjectResults(); loadProjectResults();
@@ -1220,6 +1272,73 @@
loadProjectResults(); loadProjectResults();
} }
async function exportProjectResults(buttonEl) {
const btn = buttonEl || document.getElementById('btnExportProjects');
const originalText = btn ? btn.textContent : '导出';
try {
const projectFilters = getProjectFilterValues();
validateProjectFilters(projectFilters);
if (btn) {
btn.disabled = true;
btn.textContent = '导出中...';
}
const res = await fetch('/api/projects/export?' + buildProjectQueryParams({
projectFilters,
includePagination: false,
}));
if (!res.ok) {
let message = '导出失败';
try {
const json = await res.json();
message = json.error || message;
} catch (_error) {
// Ignore JSON parse failure and use the fallback message.
}
throw new Error(message);
}
const blob = await res.blob();
const downloadUrl = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = downloadUrl;
link.download = getDownloadFilename(
res.headers.get('content-disposition'),
`projects-${new Date().toISOString().slice(0, 19).replace(/[-:]/g, '').replace('T', '-')}.csv`
);
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(downloadUrl);
} catch (e) {
alert('导出失败: ' + e.message);
} finally {
if (btn) {
btn.disabled = false;
btn.textContent = originalText;
}
}
}
function getDownloadFilename(contentDisposition, fallbackName) {
if (!contentDisposition) return fallbackName;
const utf8Match = contentDisposition.match(/filename\*=UTF-8''([^;]+)/i);
if (utf8Match && utf8Match[1]) {
return decodeURIComponent(utf8Match[1]);
}
const asciiMatch = contentDisposition.match(/filename="([^"]+)"/i) || contentDisposition.match(/filename=([^;]+)/i);
if (asciiMatch && asciiMatch[1]) {
return asciiMatch[1].trim();
}
return fallbackName;
}
function renderResults({ data, total, page, pageSize, listId, paginationId }) { function renderResults({ data, total, page, pageSize, listId, paginationId }) {
const container = document.getElementById(listId); const container = document.getElementById(listId);
if (!data || data.length === 0) { if (!data || data.length === 0) {
@@ -1278,7 +1397,7 @@
tbody.innerHTML = data.map(project => ` tbody.innerHTML = data.map(project => `
<tr> <tr>
<td>${escHtml(project.city || '-')}</td> <td>${escHtml(project.city || '-')}</td>
<td>${escHtml(project.section || project.type || '-')}</td> <td>${escHtml( project.type ||project.section || '-')}</td>
<td>${escHtml(project.projectName || '-')}</td> <td>${escHtml(project.projectName || '-')}</td>
<td>${formatAmount(project.amountYuan)}</td> <td>${formatAmount(project.amountYuan)}</td>
<td>${escHtml(project.date || '-')}</td> <td>${escHtml(project.date || '-')}</td>
@@ -1351,6 +1470,123 @@
.replace(/'/g, '&#39;'); .replace(/'/g, '&#39;');
} }
function padSchedulerNumber(value) {
return String(value).padStart(2, '0');
}
function normalizeSchedulerTime(value) {
const match = /^(\d{1,2}):(\d{1,2})$/.exec(String(value || '').trim());
if (!match) return '09:00';
const hour = Math.min(23, Math.max(0, parseInt(match[1], 10)));
const minute = Math.min(59, Math.max(0, parseInt(match[2], 10)));
return `${padSchedulerNumber(hour)}:${padSchedulerNumber(minute)}`;
}
function splitSchedulerTime(value) {
const [hour, minute] = normalizeSchedulerTime(value).split(':');
return { hour, minute };
}
function describeScheduler(mode, time, weekday, monthDay, cronTime) {
const normalizedTime = normalizeSchedulerTime(time);
const weekdayMap = {
'0': '周日',
'1': '周一',
'2': '周二',
'3': '周三',
'4': '周四',
'5': '周五',
'6': '周六',
};
if (mode === 'daily') return `每天 ${normalizedTime}`;
if (mode === 'weekly') return `每周${weekdayMap[String(weekday)] || '周一'} ${normalizedTime}`;
if (mode === 'monthly') return `每月 ${monthDay}${normalizedTime}`;
return cronTime || '0 9 * * *';
}
function parseSchedulerCron(cronTime) {
const normalizedCron = String(cronTime || '0 9 * * *').trim().replace(/\s+/g, ' ');
const parts = normalizedCron.split(' ');
const fallback = {
mode: 'custom',
time: '09:00',
weekday: '1',
monthDay: '1',
cronTime: normalizedCron || '0 9 * * *',
};
if (parts.length !== 5) return fallback;
const [minute, hour, dayOfMonth, month, dayOfWeek] = parts;
if (!/^\d+$/.test(minute) || !/^\d+$/.test(hour)) return fallback;
const time = `${padSchedulerNumber(parseInt(hour, 10))}:${padSchedulerNumber(parseInt(minute, 10))}`;
if (dayOfMonth === '*' && month === '*' && dayOfWeek === '*') {
return { mode: 'daily', time, weekday: '1', monthDay: '1', cronTime: normalizedCron };
}
if (dayOfMonth === '*' && month === '*' && /^(0|1|2|3|4|5|6|7)$/.test(dayOfWeek)) {
return {
mode: 'weekly',
time,
weekday: dayOfWeek === '7' ? '0' : dayOfWeek,
monthDay: '1',
cronTime: normalizedCron,
};
}
if (/^\d+$/.test(dayOfMonth) && month === '*' && dayOfWeek === '*') {
const normalizedMonthDay = Math.min(31, Math.max(1, parseInt(dayOfMonth, 10)));
return {
mode: 'monthly',
time,
weekday: '1',
monthDay: String(normalizedMonthDay),
cronTime: normalizedCron,
};
}
return fallback;
}
function buildSchedulerCronFromInputs() {
const mode = document.getElementById('cfgScheduleMode').value;
const time = normalizeSchedulerTime(document.getElementById('cfgScheduleTime').value);
const { hour, minute } = splitSchedulerTime(time);
const weekday = document.getElementById('cfgWeekday').value || '1';
const monthDayValue = parseInt(document.getElementById('cfgMonthDay').value, 10);
const monthDay = Number.isFinite(monthDayValue) ? Math.min(31, Math.max(1, monthDayValue)) : 1;
if (mode === 'daily') return `${minute} ${hour} * * *`;
if (mode === 'weekly') return `${minute} ${hour} * * ${weekday}`;
if (mode === 'monthly') return `${minute} ${hour} ${monthDay} * *`;
return document.getElementById('cfgCronTime').value.trim() || '0 9 * * *';
}
function updateSchedulerCronPreview() {
const cronTime = buildSchedulerCronFromInputs();
document.getElementById('cfgCronTime').value = cronTime;
document.getElementById('cfgCronPreview').value = describeScheduler(
document.getElementById('cfgScheduleMode').value,
document.getElementById('cfgScheduleTime').value,
document.getElementById('cfgWeekday').value,
document.getElementById('cfgMonthDay').value,
cronTime
);
}
function handleSchedulerModeChange() {
const mode = document.getElementById('cfgScheduleMode').value;
document.getElementById('cfgWeekdayGroup').style.display = mode === 'weekly' ? '' : 'none';
document.getElementById('cfgMonthDayGroup').style.display = mode === 'monthly' ? '' : 'none';
document.getElementById('cfgCustomCronGroup').style.display = mode === 'custom' ? '' : 'none';
updateSchedulerCronPreview();
}
// ===== 设置 ===== // ===== 设置 =====
async function loadSettings() { async function loadSettings() {
try { try {
@@ -1366,8 +1602,14 @@
// Scheduler // Scheduler
document.getElementById('cfgSchedulerEnabled').value = String(cfg.scheduler?.enabled ?? false); document.getElementById('cfgSchedulerEnabled').value = String(cfg.scheduler?.enabled ?? false);
document.getElementById('cfgCronTime').value = cfg.scheduler?.cronTime || '0 9 * * *'; const schedulerUi = parseSchedulerCron(cfg.scheduler?.cronTime || '0 9 * * *');
document.getElementById('cfgScheduleMode').value = schedulerUi.mode;
document.getElementById('cfgScheduleTime').value = schedulerUi.time;
document.getElementById('cfgWeekday').value = schedulerUi.weekday;
document.getElementById('cfgMonthDay').value = schedulerUi.monthDay;
document.getElementById('cfgCronTime').value = schedulerUi.cronTime;
document.getElementById('cfgDescription').value = cfg.scheduler?.description || ''; document.getElementById('cfgDescription').value = cfg.scheduler?.description || '';
handleSchedulerModeChange();
// Email // Email
document.getElementById('cfgSmtpHost').value = cfg.email?.smtpHost || ''; document.getElementById('cfgSmtpHost').value = cfg.email?.smtpHost || '';
@@ -1391,6 +1633,14 @@
async function saveSettings() { async function saveSettings() {
try { try {
const cronTime = buildSchedulerCronFromInputs();
if (document.getElementById('cfgScheduleMode').value === 'custom') {
const cronParts = cronTime.trim().split(/\s+/);
if (cronParts.length !== 5) {
throw new Error('Cron 表达式格式错误,请填写 5 段');
}
}
const cfg = { const cfg = {
agent: { agent: {
baseUrl: document.getElementById('cfgBaseUrl').value.trim(), baseUrl: document.getElementById('cfgBaseUrl').value.trim(),
@@ -1399,7 +1649,7 @@
}, },
scheduler: { scheduler: {
enabled: document.getElementById('cfgSchedulerEnabled').value === 'true', enabled: document.getElementById('cfgSchedulerEnabled').value === 'true',
cronTime: document.getElementById('cfgCronTime').value.trim() || '0 9 * * *', cronTime,
description: document.getElementById('cfgDescription').value.trim(), description: document.getElementById('cfgDescription').value.trim(),
}, },
email: { email: {
@@ -1463,6 +1713,7 @@
// ===== 初始化 ===== // ===== 初始化 =====
initProjectSearchInputs(); initProjectSearchInputs();
loadTasks(); loadTasks();
handleSchedulerModeChange();
loadSettings(); loadSettings();
</script> </script>
</body> </body>

100
restart.ps1 Normal file
View File

@@ -0,0 +1,100 @@
$ErrorActionPreference = 'Stop'
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$LogDir = Join-Path $ScriptDir 'logs'
$PidFile = Join-Path $LogDir 'server.pid'
$LogFile = Join-Path $LogDir 'server.log'
New-Item -ItemType Directory -Force -Path $LogDir | Out-Null
function Get-PortFromEnvFile {
$envFile = Join-Path $ScriptDir '.env'
if (-not (Test-Path $envFile)) {
return $null
}
$line = Get-Content $envFile | Where-Object { $_ -match '^PORT=' } | Select-Object -Last 1
if (-not $line) {
return $null
}
return ($line -replace '^PORT=', '').Trim()
}
function Get-ProjectServerProcesses {
Get-CimInstance Win32_Process | Where-Object {
$_.Name -eq 'node.exe' -and
$_.CommandLine -like '*src/server.js*' -and
$_.CommandLine -like "*$ScriptDir*"
}
}
function Stop-ExistingServer {
$processes = @(Get-ProjectServerProcesses)
if ($processes.Count -eq 0) {
Write-Host "No existing server process found for $ScriptDir"
return
}
$ids = $processes | ForEach-Object { $_.ProcessId }
Write-Host ("Stopping existing server process(es): " + ($ids -join ', '))
foreach ($process in $processes) {
Stop-Process -Id $process.ProcessId -Force -ErrorAction SilentlyContinue
}
Start-Sleep -Seconds 2
}
function Start-Server {
$port = if ($env:PORT) { $env:PORT } else { Get-PortFromEnvFile }
if (-not $port) { $port = '5000' }
Write-Host "Starting server from $ScriptDir on port $port"
$command = "Set-Location -LiteralPath '$ScriptDir'; node src/server.js *>> '$LogFile'"
$process = Start-Process -FilePath 'powershell.exe' `
-ArgumentList '-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', $command `
-WindowStyle Hidden `
-PassThru
Set-Content -Path $PidFile -Value $process.Id
Write-Host "Started PID: $($process.Id)"
return $port
}
function Show-Status {
param(
[string]$Port
)
Start-Sleep -Seconds 2
Write-Host ''
Write-Host 'Active project server process(es):'
$processes = @(Get-ProjectServerProcesses)
if ($processes.Count -eq 0) {
Write-Host 'None'
} else {
$processes | Select-Object ProcessId, Name, CommandLine | Format-Table -AutoSize
}
Write-Host ''
Write-Host 'Port check:'
Get-NetTCPConnection -LocalPort ([int]$Port) -State Listen -ErrorAction SilentlyContinue |
Select-Object LocalAddress, LocalPort, OwningProcess | Format-Table -AutoSize
Write-Host ''
Write-Host 'Recent log output:'
if (Test-Path $LogFile) {
Get-Content $LogFile -Tail 30
} else {
Write-Host 'No log file yet.'
}
}
Stop-ExistingServer
$port = Start-Server
Show-Status -Port $port

83
restart.sh Normal file
View File

@@ -0,0 +1,83 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
LOG_DIR="$SCRIPT_DIR/logs"
PID_FILE="$LOG_DIR/server.pid"
LOG_FILE="$LOG_DIR/server.log"
mkdir -p "$LOG_DIR"
PORT="${PORT:-}"
if [[ -z "$PORT" && -f "$SCRIPT_DIR/.env" ]]; then
PORT="$(grep -E '^PORT=' "$SCRIPT_DIR/.env" | tail -n 1 | cut -d '=' -f 2- | tr -d '\r' || true)"
fi
PORT="${PORT:-5000}"
find_project_server_pids() {
pgrep -f "node src/server.js" | while read -r pid; do
[[ -n "$pid" ]] || continue
[[ -d "/proc/$pid" ]] || continue
local cwd
cwd="$(readlink -f "/proc/$pid/cwd" 2>/dev/null || true)"
if [[ "$cwd" == "$SCRIPT_DIR" ]]; then
echo "$pid"
fi
done
}
stop_existing_server() {
local pids
pids="$(find_project_server_pids || true)"
if [[ -z "$pids" ]]; then
echo "No existing server process found for $SCRIPT_DIR"
return
fi
echo "Stopping existing server process(es): $pids"
while read -r pid; do
[[ -n "$pid" ]] || continue
kill "$pid" 2>/dev/null || true
done <<< "$pids"
sleep 2
local remaining
remaining="$(find_project_server_pids || true)"
if [[ -n "$remaining" ]]; then
echo "Force killing remaining process(es): $remaining"
while read -r pid; do
[[ -n "$pid" ]] || continue
kill -9 "$pid" 2>/dev/null || true
done <<< "$remaining"
fi
}
start_server() {
cd "$SCRIPT_DIR"
echo "Starting server from $SCRIPT_DIR on port $PORT"
nohup node src/server.js >> "$LOG_FILE" 2>&1 &
local pid=$!
echo "$pid" > "$PID_FILE"
echo "Started PID: $pid"
}
show_status() {
sleep 2
echo
echo "Active project server process(es):"
find_project_server_pids || true
echo
echo "Port check:"
ss -lntp 2>/dev/null | grep ":$PORT" || true
echo
echo "Recent log output:"
tail -n 30 "$LOG_FILE" 2>/dev/null || true
}
stop_existing_server
start_server
show_status

View File

@@ -1,15 +1,23 @@
import Database from 'better-sqlite3'; import Database from 'better-sqlite3';
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'; import {
copyFileSync,
existsSync,
mkdirSync,
readFileSync,
renameSync,
unlinkSync,
writeFileSync,
} from 'fs';
import { fileURLToPath } from 'url'; import { fileURLToPath } from 'url';
import { dirname, join } from 'path'; import { dirname, join } from 'path';
const __filename = fileURLToPath(import.meta.url); const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename); const __dirname = dirname(__filename);
const DB_PATH = const DEFAULT_DB_DIR = join(__dirname, '..', 'data');
process.env.APP_DB_PATH || const DEFAULT_DB_PATH = join(DEFAULT_DB_DIR, 'results.db');
process.env.RESULTS_DB_PATH || const LEGACY_DB_PATH = join(DEFAULT_DB_DIR, 'results.sqlite');
join(__dirname, '..', 'data', 'results.sqlite'); const DB_PATH = resolveDbPath();
const CONFIG_PATH = join(__dirname, '..', 'config.json'); const CONFIG_PATH = join(__dirname, '..', 'config.json');
const MAX_RESULT_RECORDS = 500; const MAX_RESULT_RECORDS = 500;
const DEFAULT_TASK_MODE = 'qwen3.5-plus'; const DEFAULT_TASK_MODE = 'qwen3.5-plus';
@@ -22,6 +30,55 @@ function clone(value) {
return JSON.parse(JSON.stringify(value)); return JSON.parse(JSON.stringify(value));
} }
function removeFileIfExists(filePath) {
if (!existsSync(filePath)) return;
unlinkSync(filePath);
}
function migrateLegacyDbIfNeeded(nextPath, legacyPath) {
if (existsSync(nextPath) || !existsSync(legacyPath)) return nextPath;
const legacyDb = new Database(legacyPath);
try {
legacyDb.pragma('wal_checkpoint(TRUNCATE)');
} catch (_error) {
// Ignore checkpoint failures and still attempt to switch to single-file mode.
}
legacyDb.pragma('journal_mode = DELETE');
legacyDb.close();
try {
renameSync(legacyPath, nextPath);
} catch (_error) {
copyFileSync(legacyPath, nextPath);
}
removeFileIfExists(`${legacyPath}-shm`);
removeFileIfExists(`${legacyPath}-wal`);
return nextPath;
}
function resolveDbPath() {
const explicitPath = process.env.APP_DB_PATH || process.env.RESULTS_DB_PATH;
if (explicitPath) return explicitPath;
mkdirSync(DEFAULT_DB_DIR, { recursive: true });
try {
return migrateLegacyDbIfNeeded(DEFAULT_DB_PATH, LEGACY_DB_PATH);
} catch (error) {
if (existsSync(LEGACY_DB_PATH)) {
console.warn(`[resultStore] Legacy database migration skipped: ${error.message}`);
return LEGACY_DB_PATH;
}
throw error;
}
}
function generateResultId() { function generateResultId() {
return `result-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`; return `result-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
} }
@@ -141,7 +198,13 @@ function getDb() {
mkdirSync(dirname(DB_PATH), { recursive: true }); mkdirSync(dirname(DB_PATH), { recursive: true });
db = new Database(DB_PATH); db = new Database(DB_PATH);
db.pragma('journal_mode = WAL');
try {
db.pragma('journal_mode = DELETE');
} catch (error) {
console.warn(`[resultStore] Database journal mode unchanged: ${error.message}`);
}
return db; return db;
} }
@@ -690,12 +753,11 @@ export function listProjects({
} = {}) { } = {}) {
initResultsStore(); initResultsStore();
const rows = queryBaseRows({}); const projects = getProjects({
const projects = buildProjectList(rows, {
dedupeByName, dedupeByName,
city, city,
section, section,
projectNameKeyword: projectName, projectName,
minAmount, minAmount,
maxAmount, maxAmount,
startDate, startDate,
@@ -713,10 +775,34 @@ export function listProjects({
}; };
} }
export function getProjects({
city,
section,
projectName,
minAmount,
maxAmount,
startDate,
endDate,
dedupeByName = true,
} = {}) {
initResultsStore();
return buildProjectList(queryBaseRows({}), {
dedupeByName,
city,
section,
projectNameKeyword: projectName,
minAmount,
maxAmount,
startDate,
endDate,
});
}
export function getProjectFilters({ dedupeByName = true } = {}) { export function getProjectFilters({ dedupeByName = true } = {}) {
initResultsStore(); initResultsStore();
const projects = buildProjectList(queryBaseRows({}), { dedupeByName }); const projects = getProjects({ dedupeByName });
const cities = [...new Set(projects.map((project) => project.city).filter(Boolean))]; const cities = [...new Set(projects.map((project) => project.city).filter(Boolean))];
const sections = [...new Set(projects.map((project) => project.section).filter(Boolean))]; const sections = [...new Set(projects.map((project) => project.section).filter(Boolean))];

View File

@@ -12,6 +12,7 @@ import {
deleteTaskById, deleteTaskById,
listResults, listResults,
listProjects, listProjects,
getProjects,
deleteResultById, deleteResultById,
clearResults, clearResults,
getResultFilters, getResultFilters,
@@ -90,6 +91,58 @@ function mergeConfigWithExistingSecrets(incoming = {}) {
return next; return next;
} }
function getProjectQueryFilters(query = {}) {
return {
city: query.city,
section: query.section,
projectName: query.projectName,
minAmount: query.minAmount,
maxAmount: query.maxAmount,
startDate: query.startDate,
endDate: query.endDate,
};
}
function formatExportTimestamp(date = new Date()) {
const pad = (value) => String(value).padStart(2, '0');
return [
date.getFullYear(),
pad(date.getMonth() + 1),
pad(date.getDate()),
'-',
pad(date.getHours()),
pad(date.getMinutes()),
pad(date.getSeconds()),
].join('');
}
function escapeCsvCell(value) {
if (value === null || value === undefined) return '';
const stringValue = String(value);
if (/[",\r\n]/.test(stringValue)) {
return `"${stringValue.replace(/"/g, '""')}"`;
}
return stringValue;
}
function buildProjectsCsv(projects = []) {
const columns = [
['城市', (project) => project.city || ''],
['板块', (project) => project.section || project.type || ''],
['项目名称', (project) => project.projectName || ''],
['金额(元)', (project) => Number.isFinite(project.amountYuan) ? project.amountYuan : ''],
['发布日期', (project) => project.date || ''],
['详情链接', (project) => project.detailLink || ''],
];
const lines = [
columns.map(([header]) => escapeCsvCell(header)).join(','),
...projects.map((project) => columns.map(([, getter]) => escapeCsvCell(getter(project))).join(',')),
];
return `\uFEFF${lines.join('\r\n')}`;
}
let isRunning = false; let isRunning = false;
let runningStatus = null; let runningStatus = null;
@@ -227,25 +280,10 @@ app.get('/api/results/filters', (_req, res) => {
app.get('/api/projects', (req, res) => { app.get('/api/projects', (req, res) => {
try { try {
const { const { page = 1, pageSize = 20 } = req.query;
city, const filters = getProjectQueryFilters(req.query);
section,
projectName,
minAmount,
maxAmount,
startDate,
endDate,
page = 1,
pageSize = 20,
} = req.query;
const result = listProjects({ const result = listProjects({
city, ...filters,
section,
projectName,
minAmount,
maxAmount,
startDate,
endDate,
page, page,
pageSize, pageSize,
dedupeByName: true, dedupeByName: true,
@@ -256,6 +294,23 @@ app.get('/api/projects', (req, res) => {
} }
}); });
app.get('/api/projects/export', (req, res) => {
try {
const projects = getProjects({
...getProjectQueryFilters(req.query),
dedupeByName: true,
});
const csv = buildProjectsCsv(projects);
const filename = `projects-${formatExportTimestamp()}.csv`;
res.setHeader('Content-Type', 'text/csv; charset=utf-8');
res.setHeader('Content-Disposition', `attachment; filename="${filename}"; filename*=UTF-8''${encodeURIComponent(filename)}`);
res.send(csv);
} catch (error) {
res.status(500).json({ success: false, error: error.message });
}
});
app.get('/api/projects/filters', (_req, res) => { app.get('/api/projects/filters', (_req, res) => {
try { try {
res.json({ success: true, data: getProjectFilters({ dedupeByName: true }) }); res.json({ success: true, data: getProjectFilters({ dedupeByName: true }) });