```
feat(project): 添加项目数据导出功能 - 在项目查询界面添加导出按钮,支持将项目结果导出为CSV文件 - 实现exportProjectResults函数处理导出逻辑,包括参数验证和文件下载 - 新增validateProjectFilters函数用于验证项目筛选条件 - 新增buildProjectQueryParams函数构建查询参数 - 在服务端添加/api/projects/export接口,返回CSV格式数据 - 实现CSV文件生成和下载功能,包含中文文件名处理 - 重构项目查询逻辑,提取getProjects函数复用代码 ```
This commit is contained in:
@@ -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>
|
||||||
@@ -1130,21 +1131,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 +1188,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 +1235,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) {
|
||||||
|
|||||||
@@ -690,12 +690,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 +712,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))];
|
||||||
|
|
||||||
|
|||||||
@@ -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 }) });
|
||||||
|
|||||||
Reference in New Issue
Block a user