feat: 添加项目管理功能

添加了项目管理标签页,支持按城市、板块、项目名称、金额范围、日期等条件进行过滤查询,
包含完整的前端界面和后端API接口,实现数据去重和分页功能
```
This commit is contained in:
2026-03-19 11:40:14 +08:00
parent d78dc655ee
commit 7bfba04199
4 changed files with 582 additions and 33 deletions

View File

@@ -520,6 +520,7 @@
<div class="tabs">
<button class="tab active" onclick="switchTab('tasks')">任务配置</button>
<button class="tab" onclick="switchTab('results')">抓取结果</button>
<button class="tab" onclick="switchTab('projects')">项目管理</button>
<button class="tab" onclick="switchTab('settings')">系统设置</button>
</div>
@@ -573,13 +574,84 @@
</select>
<button class="btn btn-secondary btn-sm" onclick="loadResults()">刷新</button>
</div>
<button class="btn btn-danger btn-sm" onclick="clearResults()">清空全部</button>
<!-- <button class="btn btn-danger btn-sm" onclick="clearResults()">清空全部</button> -->
</div>
<div id="resultsList"></div>
<div id="resultsPagination" style="text-align:center;margin-top:16px;"></div>
</div>
<!-- ===== 项目管理 Tab ===== -->
<div id="tab-projects" class="tab-content">
<div class="config-section" style="padding-bottom:16px;">
<div class="form-row">
<div class="form-group">
<label>城市</label>
<select id="projectFilterCity">
<option value="">全部城市</option>
</select> </div>
<div class="form-group">
<label>板块</label>
<input type="text" id="projectFilterSection" placeholder="请输入板块名称">
</div>
<div class="form-group">
<label>项目名称</label>
<input type="text" id="projectFilterProjectName" placeholder="请输入项目名称">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>最小金额</label>
<input type="number" id="projectFilterMinAmount" placeholder="最小金额" min="0" step="0.01">
</div>
<div class="form-group">
<label>最大金额</label>
<input type="number" id="projectFilterMaxAmount" placeholder="最大金额" min="0" step="0.01">
</div>
<div class="form-group">
<label>开始日期</label>
<input type="date" id="projectFilterStartDate">
</div>
<div class="form-group">
<label>结束日期</label>
<input type="date" id="projectFilterEndDate">
</div>
</div>
<div style="display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap;">
<span style="font-size:13px;color:#888;">按项目名称拆分并去重后展示</span>
<div style="display:flex;gap:10px;align-items:center;">
<button class="btn btn-primary btn-sm" onclick="searchProjectResults()">查询</button>
<button class="btn btn-secondary btn-sm" onclick="resetProjectFilters()">重置</button>
<!-- <button class="btn btn-danger btn-sm" onclick="clearResults()">清空全部</button> -->
</div>
</div>
</div>
<div class="config-section" style="padding:0;overflow:hidden;">
<div style="padding:16px 20px;border-bottom:1px solid rgba(15, 35, 58, 0.08);font-size:13px;color:#66768a;" id="projectsSummary">加载中...</div>
<div style="overflow-x:auto;">
<table>
<thead>
<tr>
<th style="min-width:120px;">城市</th>
<th style="min-width:140px;">板块</th>
<th>项目名称</th>
<th style="min-width:150px;">金额</th>
<th style="min-width:120px;">发布日期</th>
<th style="min-width:120px;">详情链接</th>
</tr>
</thead>
<tbody id="projectsTbody">
<tr>
<td colspan="6" class="empty-state">加载中...</td>
</tr>
</tbody>
</table>
</div>
</div>
<div id="projectsPagination" style="text-align:center;margin-top:16px;"></div>
</div>
<!-- ===== 系统设置 Tab ===== -->
<div id="tab-settings" class="tab-content">
<!-- Agent 配置 -->
@@ -712,16 +784,18 @@
let currentTasksPage = 1;
const tasksPageSize = 10;
let currentResultsPage = 1;
let currentProjectsPage = 1;
// ===== Tab 切换 =====
function switchTab(name) {
document.querySelectorAll('.tab').forEach((t, i) => {
t.classList.toggle('active', ['tasks', 'results', 'settings'][i] === name);
t.classList.toggle('active', ['tasks', 'results', 'projects', 'settings'][i] === name);
});
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
document.getElementById('tab-' + name).classList.add('active');
if (name === 'results') loadResults();
if (name === 'projects') loadProjectResults();
if (name === 'settings') loadSettings();
}
@@ -1030,36 +1104,127 @@
// ===== 结果展示 =====
async function loadResults() {
const city = document.getElementById('filterCity').value;
try {
// 加载筛选选项
const filtersRes = await fetch('/api/results/filters');
const filtersJson = await filtersRes.json();
if (filtersJson.success) {
const sel = document.getElementById('filterCity');
const curVal = sel.value;
sel.innerHTML = '<option value="">全部城市</option>' +
(filtersJson.data.cities || []).map(c => `<option value="${c}" ${c === curVal ? 'selected' : ''}>${c}</option>`).join('');
}
// 加载结果
await loadResultCityOptions('filterCity');
const city = document.getElementById('filterCity').value;
const params = new URLSearchParams({ page: currentResultsPage, pageSize: 10 });
if (city) params.set('city', city);
const res = await fetch('/api/results?' + params);
const json = await res.json();
if (!json.success) throw new Error(json.error);
renderResults(json.data, json.total, json.page, json.pageSize);
renderResults({
data: json.data,
total: json.total,
page: json.page,
pageSize: json.pageSize,
listId: 'resultsList',
paginationId: 'resultsPagination',
});
} catch (e) {
document.getElementById('resultsList').innerHTML = `<div class="status-box status-error">加载失败: ${e.message}</div>`;
document.getElementById('resultsPagination').innerHTML = '';
}
}
function renderResults(data, total, page, pageSize) {
const container = document.getElementById('resultsList');
async function loadProjectResults() {
try {
await loadProjectFilters();
const params = new URLSearchParams({ page: currentProjectsPage, pageSize: 10 });
const projectFilters = getProjectFilterValues();
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('开始日期不能晚于结束日期');
}
Object.entries(projectFilters).forEach(([key, value]) => {
if (value !== '') params.set(key, value);
});
const res = await fetch('/api/projects?' + params);
const json = await res.json();
if (!json.success) throw new Error(json.error);
renderProjects({
data: json.data,
total: json.total,
page: json.page,
pageSize: json.pageSize,
});
} catch (e) {
document.getElementById('projectsTbody').innerHTML = `<tr><td colspan="6" class="empty-state">加载失败: ${e.message}</td></tr>`;
document.getElementById('projectsSummary').textContent = '项目列表加载失败';
document.getElementById('projectsPagination').innerHTML = '';
}
}
async function loadProjectFilters() {
const filtersRes = await fetch('/api/projects/filters');
const filtersJson = await filtersRes.json();
if (!filtersJson.success) return;
populateSelectWithPlaceholder('projectFilterCity', '全部城市', filtersJson.data.cities || []);
}
async function loadResultCityOptions(selectId) {
const filtersRes = await fetch('/api/results/filters');
const filtersJson = await filtersRes.json();
if (!filtersJson.success) return;
populateSelectWithPlaceholder(selectId, '全部城市', filtersJson.data.cities || []);
}
function populateSelectWithPlaceholder(selectId, placeholder, items) {
const sel = document.getElementById(selectId);
const curVal = sel.value;
sel.innerHTML = `<option value="">${placeholder}</option>` +
items.map(item => `<option value="${item}" ${item === curVal ? 'selected' : ''}>${item}</option>`).join('');
}
function getProjectFilterValues() {
return {
city: document.getElementById('projectFilterCity').value.trim(),
section: document.getElementById('projectFilterSection').value.trim(),
projectName: document.getElementById('projectFilterProjectName').value.trim(),
minAmount: document.getElementById('projectFilterMinAmount').value.trim(),
maxAmount: document.getElementById('projectFilterMaxAmount').value.trim(),
startDate: document.getElementById('projectFilterStartDate').value.trim(),
endDate: document.getElementById('projectFilterEndDate').value.trim(),
};
}
function searchProjectResults() {
currentProjectsPage = 1;
loadProjectResults();
}
function resetProjectFilters() {
[
'projectFilterCity',
'projectFilterSection',
'projectFilterProjectName',
'projectFilterMinAmount',
'projectFilterMaxAmount',
'projectFilterStartDate',
'projectFilterEndDate',
].forEach((id) => {
document.getElementById(id).value = '';
});
currentProjectsPage = 1;
loadProjectResults();
}
function renderResults({ data, total, page, pageSize, listId, paginationId }) {
const container = document.getElementById(listId);
if (!data || data.length === 0) {
container.innerHTML = '<div class="empty-state">暂无结果</div>';
document.getElementById('resultsPagination').innerHTML = '';
document.getElementById(paginationId).innerHTML = '';
return;
}
@@ -1070,7 +1235,7 @@
const itemsHtml = results.length > 0 ? results.map(item => `
<div class="result-item">
<span class="type">${item.type || '-'}</span>
<span class="name">${item.project_name || '-'}</span>
<span class="name">${item.project_name || item.projectName || item.title || '-'}</span>
<span class="amount">${item.amount_yuan ? item.amount_yuan.toLocaleString() + ' 元' : '-'}</span>
<span class="date">${item.date || '-'}</span>
${(item.detail_link || item.target_link) ? `<a href="${item.detail_link || item.target_link}" target="_blank">查看</a>` : ''}
@@ -1092,26 +1257,69 @@
`;
}).join('');
// 分页
renderPagination({ total, page, pageSize, paginationId, view: 'results' });
}
function renderProjects({ data, total, page, pageSize }) {
const tbody = document.getElementById('projectsTbody');
const summary = document.getElementById('projectsSummary');
if (!data || data.length === 0) {
tbody.innerHTML = '<tr><td colspan="6" class="empty-state">暂无去重后的项目</td></tr>';
summary.textContent = '当前筛选条件下没有项目';
document.getElementById('projectsPagination').innerHTML = '';
return;
}
const startIndex = (page - 1) * pageSize + 1;
const endIndex = Math.min(page * pageSize, total);
summary.textContent = `显示第 ${startIndex}${endIndex} 条记录,共 ${total} 条记录`;
tbody.innerHTML = data.map(project => `
<tr>
<td>${escHtml(project.city || '-')}</td>
<td>${escHtml(project.section || project.type || '-')}</td>
<td>${escHtml(project.projectName || '-')}</td>
<td>${formatAmount(project.amountYuan)}</td>
<td>${escHtml(project.date || '-')}</td>
<td>${project.detailLink ? `<a href="${project.detailLink}" target="_blank">查看详情</a>` : '-'}</td>
</tr>
`).join('');
renderPagination({ total, page, pageSize, paginationId: 'projectsPagination', view: 'projects' });
}
function renderPagination({ total, page, pageSize, paginationId, view }) {
const totalPages = Math.ceil(total / pageSize);
const pagination = document.getElementById(paginationId);
if (totalPages > 1) {
let pHtml = '';
for (let i = 1; i <= totalPages; i++) {
pHtml += `<button class="btn btn-sm ${i === page ? 'btn-primary' : 'btn-secondary'}" onclick="goPage(${i})" style="margin:0 3px;">${i}</button>`;
pHtml += `<button class="btn btn-sm ${i === page ? 'btn-primary' : 'btn-secondary'}" onclick="goPage('${view}', ${i})" style="margin:0 3px;">${i}</button>`;
}
document.getElementById('resultsPagination').innerHTML = `<div>第 ${page}/${totalPages} 页,共 ${total} 条</div>${pHtml}`;
pagination.innerHTML = `<div>第 ${page}/${totalPages} 页,共 ${total} 条</div>${pHtml}`;
} else {
document.getElementById('resultsPagination').innerHTML = total > 0 ? `${total}` : '';
pagination.innerHTML = total > 0 ? `${total}` : '';
}
}
function goPage(p) { currentResultsPage = p; loadResults(); }
function goPage(view, p) {
if (view === 'projects') {
currentProjectsPage = p;
loadProjectResults();
return;
}
currentResultsPage = p;
loadResults();
}
async function deleteResult(id) {
if (!confirm('确定删除这条结果?')) return;
try {
await fetch(`/api/results/${id}`, { method: 'DELETE' });
loadResults();
await Promise.all([loadResults(), loadProjectResults()]);
} catch (e) {
alert('删除失败: ' + e.message);
}
@@ -1121,12 +1329,28 @@
if (!confirm('确定要清空全部结果吗?此操作不可恢复。')) return;
try {
await fetch('/api/results', { method: 'DELETE' });
loadResults();
currentResultsPage = 1;
currentProjectsPage = 1;
await Promise.all([loadResults(), loadProjectResults()]);
} catch (e) {
alert('清空失败: ' + e.message);
}
}
function formatAmount(amount) {
if (typeof amount !== 'number' || !Number.isFinite(amount)) return '-';
return `¥ ${amount.toLocaleString('zh-CN', { maximumFractionDigits: 2 })}`;
}
function escHtml(value) {
return String(value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
// ===== 设置 =====
async function loadSettings() {
try {
@@ -1215,7 +1439,29 @@
}
}
function initProjectSearchInputs() {
[
'projectFilterCity',
'projectFilterSection',
'projectFilterProjectName',
'projectFilterMinAmount',
'projectFilterMaxAmount',
'projectFilterStartDate',
'projectFilterEndDate',
].forEach((id) => {
const element = document.getElementById(id);
if (!element) return;
element.addEventListener('keydown', (event) => {
if (event.key === 'Enter') {
event.preventDefault();
searchProjectResults();
}
});
});
}
// ===== 初始化 =====
initProjectSearchInputs();
loadTasks();
loadSettings();
</script>

View File

@@ -1166,7 +1166,7 @@
<div class="toolbar-actions">
<button class="btn btn-ghost" onclick="resetFilters()">↺ 重置</button>
<button class="btn btn-primary" onclick="loadResults()"> 刷新</button>
<button class="btn btn-danger" onclick="confirmClearAll()"> 清空全部</button>
<!-- <button class="btn btn-danger" onclick="confirmClearAll()"> 清空全部</button> -->
</div>
</div>

View File

@@ -287,6 +287,215 @@ function matchType(record, type) {
return items.some((item) => item.type === type);
}
function normalizeProjectName(item) {
if (!item || typeof item !== 'object') return '';
const candidates = [
item.project_name,
item.projectName,
item.title,
item.name,
item.bidName,
];
for (const candidate of candidates) {
if (typeof candidate === 'string' && candidate.trim()) {
return candidate.replace(/\s+/g, ' ').trim();
}
}
return '';
}
function dedupeRowsByProjectName(rows) {
const seenProjectNames = new Set();
const dedupedRows = [];
for (const row of rows) {
const items = Array.isArray(row.data?.results) ? row.data.results : [];
if (items.length === 0) continue;
const uniqueItems = [];
for (const item of items) {
const projectName = normalizeProjectName(item);
if (!projectName) {
uniqueItems.push(item);
continue;
}
if (seenProjectNames.has(projectName)) continue;
seenProjectNames.add(projectName);
uniqueItems.push(item);
}
if (uniqueItems.length === 0) continue;
dedupedRows.push({
...row,
data: {
...(row.data || {}),
results: uniqueItems,
total: uniqueItems.length,
},
});
}
return dedupedRows;
}
function pickProjectLink(item) {
const candidates = [item?.detail_link, item?.target_link, item?.url, item?.href];
for (const candidate of candidates) {
if (typeof candidate === 'string' && candidate.trim()) return candidate.trim();
}
return '';
}
function parseProjectSection(item) {
if (typeof item?.section === 'string' && item.section.trim()) return item.section.trim();
if (typeof item?.type === 'string' && item.type.trim()) {
return item.type.split(/[-/]/)[0].trim();
}
return '';
}
function parseProjectAmount(item) {
if (typeof item?.amount_yuan === 'number' && Number.isFinite(item.amount_yuan)) {
return item.amount_yuan;
}
if (typeof item?.amount === 'number' && Number.isFinite(item.amount)) {
return item.amount;
}
return null;
}
function projectSortValue(project) {
const dateValue = Date.parse(project.date || '');
const scrapedAtValue = Date.parse(project.scrapedAt || '');
return {
dateValue: Number.isFinite(dateValue) ? dateValue : 0,
scrapedAtValue: Number.isFinite(scrapedAtValue) ? scrapedAtValue : 0,
};
}
function compareProjectsDesc(a, b) {
const aValue = projectSortValue(a);
const bValue = projectSortValue(b);
if (bValue.dateValue !== aValue.dateValue) return bValue.dateValue - aValue.dateValue;
if (bValue.scrapedAtValue !== aValue.scrapedAtValue) return bValue.scrapedAtValue - aValue.scrapedAtValue;
return (a.projectName || '').localeCompare(b.projectName || '', 'zh-CN');
}
function normalizeSearchText(value) {
if (typeof value !== 'string') return '';
return value.trim().toLowerCase();
}
function includesSearchText(source, keyword) {
if (!keyword) return true;
return normalizeSearchText(source).includes(keyword);
}
function parseNumberFilter(value) {
if (value === null || value === undefined || value === '') return null;
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : null;
}
function parseDateFilter(value) {
if (typeof value !== 'string' || !value.trim()) return null;
const timestamp = Date.parse(value);
return Number.isFinite(timestamp) ? timestamp : null;
}
function matchProjectFilters(project, filters = {}) {
const cityKeyword = normalizeSearchText(filters.city);
const sectionKeyword = normalizeSearchText(filters.section);
const projectNameKeyword = normalizeSearchText(filters.projectName);
const minAmount = parseNumberFilter(filters.minAmount);
const maxAmount = parseNumberFilter(filters.maxAmount);
const startDate = parseDateFilter(filters.startDate);
const endDate = parseDateFilter(filters.endDate);
const projectDate = parseDateFilter(project.date);
if (!includesSearchText(project.city, cityKeyword)) return false;
if (!includesSearchText(project.section || project.type, sectionKeyword)) return false;
if (!includesSearchText(project.projectName, projectNameKeyword)) return false;
if (minAmount !== null && (project.amountYuan === null || project.amountYuan < minAmount)) return false;
if (maxAmount !== null && (project.amountYuan === null || project.amountYuan > maxAmount)) return false;
if (startDate !== null && (projectDate === null || projectDate < startDate)) return false;
if (endDate !== null && (projectDate === null || projectDate > endDate)) return false;
return true;
}
function buildProjectList(rows, {
dedupeByName = false,
city,
section,
projectNameKeyword,
minAmount,
maxAmount,
startDate,
endDate,
} = {}) {
const seenProjectNames = new Set();
const projects = [];
for (const row of rows) {
const items = Array.isArray(row.data?.results) ? row.data.results : [];
for (let index = 0; index < items.length; index += 1) {
const item = items[index];
const projectName = normalizeProjectName(item);
if (!projectName) continue;
const projectSection = parseProjectSection(item);
if (city && row.city !== city) continue;
if (section && projectSection !== section) continue;
if (dedupeByName) {
if (seenProjectNames.has(projectName)) continue;
seenProjectNames.add(projectName);
}
projects.push({
id: `${row.id}:${index}`,
resultId: row.id,
taskId: row.taskId,
city: row.city || '',
section: projectSection,
type: typeof item?.type === 'string' ? item.type : '',
projectName,
amountYuan: parseProjectAmount(item),
date: typeof item?.date === 'string' ? item.date : '',
detailLink: pickProjectLink(item),
scrapedAt: row.scrapedAt,
raw: item,
});
}
}
return projects
.filter((project) => matchProjectFilters(project, {
city,
section,
projectName: projectNameKeyword,
minAmount,
maxAmount,
startDate,
endDate,
}))
.sort(compareProjectsDesc);
}
export function initResultsStore() {
if (initialized) return;
ensureSchema();
@@ -409,12 +618,13 @@ export function appendResult(result) {
return record;
}
export function listResults({ city, section, type, taskId, page = 1, pageSize = 20 } = {}) {
export function listResults({ city, section, type, taskId, page = 1, pageSize = 20, projectMode = false } = {}) {
initResultsStore();
let results = queryBaseRows({ city, taskId });
if (section) results = results.filter((record) => matchSection(record, section));
if (type) results = results.filter((record) => matchType(record, type));
if (projectMode) results = dedupeRowsByProjectName(results);
const normalizedPage = Math.max(1, parseInt(page, 10) || 1);
const normalizedPageSize = Math.max(1, parseInt(pageSize, 10) || 20);
@@ -439,10 +649,10 @@ export function clearResults() {
getDb().prepare('DELETE FROM results').run();
}
export function getResultFilters() {
export function getResultFilters({ projectMode = false } = {}) {
initResultsStore();
const rows = queryBaseRows({});
const rows = projectMode ? dedupeRowsByProjectName(queryBaseRows({})) : queryBaseRows({});
const cities = [...new Set(rows.map((row) => row.city).filter(Boolean))];
const sections = new Set();
const types = new Set();
@@ -466,6 +676,56 @@ export function getResultFilters() {
};
}
export function listProjects({
city,
section,
projectName,
minAmount,
maxAmount,
startDate,
endDate,
page = 1,
pageSize = 20,
dedupeByName = true,
} = {}) {
initResultsStore();
const rows = queryBaseRows({});
const projects = buildProjectList(rows, {
dedupeByName,
city,
section,
projectNameKeyword: projectName,
minAmount,
maxAmount,
startDate,
endDate,
});
const normalizedPage = Math.max(1, parseInt(page, 10) || 1);
const normalizedPageSize = Math.max(1, parseInt(pageSize, 10) || 20);
const start = (normalizedPage - 1) * normalizedPageSize;
return {
total: projects.length,
page: normalizedPage,
pageSize: normalizedPageSize,
data: projects.slice(start, start + normalizedPageSize),
};
}
export function getProjectFilters({ dedupeByName = true } = {}) {
initResultsStore();
const projects = buildProjectList(queryBaseRows({}), { dedupeByName });
const cities = [...new Set(projects.map((project) => project.city).filter(Boolean))];
const sections = [...new Set(projects.map((project) => project.section).filter(Boolean))];
return {
cities,
sections,
};
}
export function getResultsDbPath() {
return DB_PATH;
}

View File

@@ -11,9 +11,11 @@ import {
updateTask,
deleteTaskById,
listResults,
listProjects,
deleteResultById,
clearResults,
getResultFilters,
getProjectFilters,
appendResult,
} from './resultStore.js';
import { initScheduler, runTaskNow, reloadScheduler, getSchedulerStatus } from './scheduler.js';
@@ -184,8 +186,9 @@ function runTasksInBackground(tasks) {
app.get('/api/results', (req, res) => {
try {
const { city, section, type, page = 1, pageSize = 20, taskId } = req.query;
const result = listResults({ city, section, type, page, pageSize, taskId });
const { city, section, type, page = 1, pageSize = 20, taskId, view } = req.query;
const projectMode = view === 'projects';
const result = listResults({ city, section, type, page, pageSize, taskId, projectMode });
res.json({ success: true, ...result });
} catch (error) {
res.status(500).json({ success: false, error: error.message });
@@ -215,7 +218,47 @@ app.delete('/api/results', (_req, res) => {
app.get('/api/results/filters', (_req, res) => {
try {
res.json({ success: true, data: getResultFilters() });
const projectMode = _req.query.view === 'projects';
res.json({ success: true, data: getResultFilters({ projectMode }) });
} catch (error) {
res.status(500).json({ success: false, error: error.message });
}
});
app.get('/api/projects', (req, res) => {
try {
const {
city,
section,
projectName,
minAmount,
maxAmount,
startDate,
endDate,
page = 1,
pageSize = 20,
} = req.query;
const result = listProjects({
city,
section,
projectName,
minAmount,
maxAmount,
startDate,
endDate,
page,
pageSize,
dedupeByName: true,
});
res.json({ success: true, ...result });
} catch (error) {
res.status(500).json({ success: false, error: error.message });
}
});
app.get('/api/projects/filters', (_req, res) => {
try {
res.json({ success: true, data: getProjectFilters({ dedupeByName: true }) });
} catch (error) {
res.status(500).json({ success: false, error: error.message });
}