```
feat: 添加项目管理功能 添加了项目管理标签页,支持按城市、板块、项目名称、金额范围、日期等条件进行过滤查询, 包含完整的前端界面和后端API接口,实现数据去重和分页功能 ```
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user