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

@@ -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 });
}