This commit is contained in:
2026-03-19 14:21:14 +08:00
parent 7bfba04199
commit 2614af7808
23 changed files with 4447 additions and 489 deletions

12
client/index.html Normal file
View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>公告抓取与分析工具</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

10
client/src/App.vue Normal file
View File

@@ -0,0 +1,10 @@
<script setup>
import zhCn from 'element-plus/es/locale/lang/zh-cn';
import AppShell from './components/AppShell.vue';
</script>
<template>
<el-config-provider :locale="zhCn">
<AppShell />
</el-config-provider>
</template>

24
client/src/api/http.js Normal file
View File

@@ -0,0 +1,24 @@
import axios from 'axios';
const http = axios.create({
baseURL: '/api',
timeout: 30000,
});
http.interceptors.response.use(
(response) => {
const payload = response.data;
if (payload && payload.success === false) {
return Promise.reject(new Error(payload.error || '请求失败'));
}
return payload;
},
(error) => {
const message = error.response?.data?.error || error.message || '请求失败';
return Promise.reject(new Error(message));
},
);
export default http;

25
client/src/api/results.js Normal file
View File

@@ -0,0 +1,25 @@
import http from './http';
export function fetchResults(params = {}) {
return http.get('/results', { params }).then((payload) => payload);
}
export function fetchResultFilters(params = {}) {
return http.get('/results/filters', { params }).then((payload) => payload.data || {});
}
export function deleteResult(id) {
return http.delete(`/results/${id}`);
}
export function clearResults() {
return http.delete('/results');
}
export function fetchProjects(params = {}) {
return http.get('/projects', { params }).then((payload) => payload);
}
export function fetchProjectFilters() {
return http.get('/projects/filters').then((payload) => payload.data || {});
}

View File

@@ -0,0 +1,17 @@
import http from './http';
export function fetchConfig() {
return http.get('/config').then((payload) => payload.data || {});
}
export function saveConfig(payload) {
return http.post('/config', payload);
}
export function fetchSchedulerStatus() {
return http.get('/scheduler/status').then((payload) => payload.data || {});
}
export function triggerScheduledTask() {
return http.post('/run-scheduled-task');
}

34
client/src/api/tasks.js Normal file
View File

@@ -0,0 +1,34 @@
import http from './http';
export function fetchTasks() {
return http.get('/tasks').then((payload) => payload.data || []);
}
export function createTask(payload) {
return http.post('/tasks', payload).then((response) => response.data);
}
export function updateTask(id, payload) {
return http.put(`/tasks/${id}`, payload).then((response) => response.data);
}
export function deleteTask(id) {
return http.delete(`/tasks/${id}`);
}
export function runTask(id) {
return http.post(`/tasks/${id}/run`);
}
export function runAllTasks(ids = []) {
const body = ids.length ? { ids } : {};
return http.post('/tasks/run', body);
}
export function toggleTask(id, enabled) {
return http.put(`/tasks/${id}`, { enabled }).then((response) => response.data);
}
export function fetchTaskStatus() {
return http.get('/tasks/status').then((payload) => payload.data || { isRunning: false });
}

View File

@@ -0,0 +1,41 @@
<script setup>
import { computed } from 'vue';
import { useRoute } from 'vue-router';
const route = useRoute();
const subtitle = computed(() => route.meta?.subtitle || '把抓取任务、分析结果和调度配置收在同一套前端工作台里。');
const menuItems = [
{ index: '/tasks', label: '任务配置' },
{ index: '/results', label: '抓取结果' },
{ index: '/projects', label: '项目管理' },
{ index: '/settings', label: '系统设置' },
];
</script>
<template>
<div class="app-shell">
<div class="app-frame">
<header class="app-topbar">
<div class="app-brand">
<div class="app-brand__mark"></div>
<div class="app-brand__text">
<h1>公告抓取与分析工具</h1>
<p>{{ subtitle }}</p>
</div>
</div>
</header>
<el-menu :default-active="route.path" class="app-menu" mode="horizontal" router>
<el-menu-item v-for="item in menuItems" :key="item.index" :index="item.index">
{{ item.label }}
</el-menu-item>
</el-menu>
<main class="app-main">
<RouterView />
</main>
</div>
</div>
</template>

View File

@@ -0,0 +1,55 @@
<script setup>
import { computed } from 'vue';
import { formatDateTime, formatYuan, pickResultLink, pickResultName } from '@/utils/format';
const props = defineProps({
record: {
type: Object,
required: true,
},
});
const emit = defineEmits(['delete']);
const items = computed(() => props.record.data?.results || []);
</script>
<template>
<el-card shadow="hover" class="result-card-plus">
<template #header>
<div class="result-card-plus__header">
<div>
<div class="result-card-plus__title">{{ record.city || '未命名城市' }}</div>
<div class="result-card-plus__meta">
<el-tag size="small" type="primary">{{ items.length }} 条结果</el-tag>
<el-tag v-if="record.error" size="small" type="danger">执行失败</el-tag>
</div>
</div>
<div class="result-card-plus__time">{{ formatDateTime(record.scrapedAt) }}</div>
</div>
</template>
<el-alert v-if="record.error" :title="`错误信息:${record.error}`" type="error" :closable="false" show-icon />
<div v-else-if="items.length" class="result-card-plus__list">
<div v-for="(item, index) in items" :key="`${record.id}-${index}`" class="result-card-plus__item">
<div class="result-card-plus__type">{{ item.type || '-' }}</div>
<div class="result-card-plus__name">{{ pickResultName(item) }}</div>
<div class="result-card-plus__amount">{{ formatYuan(item.amount_yuan) }}</div>
<div class="result-card-plus__date">{{ item.date || '-' }}</div>
<div class="result-card-plus__action">
<el-link v-if="pickResultLink(item)" :href="pickResultLink(item)" target="_blank" type="primary">查看详情</el-link>
<span v-else>-</span>
</div>
</div>
</div>
<el-empty v-else description="当前记录里没有可展示的数据" :image-size="88" />
<template #footer>
<div class="result-card-plus__footer">
<el-button type="danger" plain @click="emit('delete', record)">删除记录</el-button>
</div>
</template>
</el-card>
</template>

View File

@@ -0,0 +1,105 @@
<script setup>
import { computed, reactive, watch } from 'vue';
import { TASK_MODE_OPTIONS } from '@/constants/taskModes';
const props = defineProps({
modelValue: {
type: Boolean,
default: false,
},
task: {
type: Object,
default: null,
},
});
const emit = defineEmits(['update:modelValue', 'submit']);
const form = reactive({
city: '',
plateName: '',
prompt: '',
mode: TASK_MODE_OPTIONS[0],
enabled: true,
});
const dialogTitle = computed(() => (props.task?.id ? '编辑任务' : '新增任务'));
const modeOptions = computed(() => {
const set = new Set(TASK_MODE_OPTIONS);
if (props.task?.mode) set.add(props.task.mode);
return [...set];
});
watch(
() => [props.modelValue, props.task],
() => {
form.city = props.task?.city || '';
form.plateName = props.task?.plateName || '';
form.prompt = props.task?.prompt || '';
form.mode = props.task?.mode || TASK_MODE_OPTIONS[0];
form.enabled = props.task?.enabled ?? true;
},
{ immediate: true },
);
function close() {
emit('update:modelValue', false);
}
function submit() {
emit('submit', {
city: form.city.trim(),
plateName: form.plateName.trim(),
prompt: form.prompt.trim(),
mode: form.mode.trim() || TASK_MODE_OPTIONS[0],
enabled: form.enabled,
});
}
</script>
<template>
<el-dialog :model-value="modelValue" :title="dialogTitle" width="760px" destroy-on-close @close="close">
<el-form label-position="top">
<el-form-item label="城市名称">
<el-input v-model="form.city" placeholder="如:南京市" />
</el-form-item>
<el-form-item label="板块名称">
<el-input v-model="form.plateName" placeholder="如:工程" />
</el-form-item>
<el-form-item label="提示词">
<el-input
v-model="form.prompt"
type="textarea"
:autosize="{ minRows: 6, maxRows: 12 }"
placeholder="请访问目标网站,抓取今天的招标公告和中标公告信息……"
/>
</el-form-item>
<el-row :gutter="16">
<el-col :xs="24" :sm="12">
<el-form-item label="模型">
<el-select v-model="form.mode" placeholder="选择模型" style="width: 100%;">
<el-option v-for="option in modeOptions" :key="option" :label="option" :value="option" />
</el-select>
</el-form-item>
</el-col>
<el-col :xs="24" :sm="12">
<el-form-item label="状态">
<el-switch v-model="form.enabled" inline-prompt active-text="启用" inactive-text="禁用" />
</el-form-item>
</el-col>
</el-row>
</el-form>
<el-alert title="建议把目标网址、筛选规则和字段要求都写进提示词里,后续维护会更稳定。" type="info" :closable="false" show-icon />
<template #footer>
<div class="dialog-footer">
<el-button @click="close">取消</el-button>
<el-button type="primary" @click="submit">保存任务</el-button>
</div>
</template>
</el-dialog>
</template>

View File

@@ -0,0 +1,10 @@
export const TASK_MODE_OPTIONS = [
'qwen3.5-plus',
'qwen3-max-2026-01-23',
'qwen3-coder-next',
'qwen3-coder-plus',
'glm-5',
'glm-4.7',
'kimi-k2.5',
'MiniMax-M2.5',
];

6
client/src/main.js Normal file
View File

@@ -0,0 +1,6 @@
import { createApp } from 'vue';
import App from './App.vue';
import router from './router';
import './styles.css';
createApp(App).use(router).mount('#app');

View File

@@ -0,0 +1,233 @@
<script setup>
import { onMounted, reactive, ref } from 'vue';
import { ElMessage } from 'element-plus';
import { fetchProjectFilters, fetchProjects } from '@/api/results';
import { formatYuan } from '@/utils/format';
const form = reactive({
city: '',
section: '',
projectName: '',
minAmount: '',
maxAmount: '',
startDate: '',
endDate: '',
});
const activeFilters = reactive({
city: '',
section: '',
projectName: '',
minAmount: '',
maxAmount: '',
startDate: '',
endDate: '',
});
const options = reactive({
cities: [],
sections: [],
});
const state = reactive({
records: [],
page: 1,
pageSize: 10,
total: 0,
loading: true,
});
const summaryText = ref('加载中…');
onMounted(async () => {
await Promise.all([loadFilters(), loadProjects()]);
});
async function loadFilters() {
try {
const data = await fetchProjectFilters();
options.cities = data.cities || [];
options.sections = data.sections || [];
} catch (error) {
ElMessage.error(`项目筛选项加载失败:${error.message}`);
}
}
function validateForm() {
if (form.minAmount && form.maxAmount && Number(form.minAmount) > Number(form.maxAmount)) {
throw new Error('最小金额不能大于最大金额');
}
if (form.startDate && form.endDate && form.startDate > form.endDate) {
throw new Error('开始日期不能晚于结束日期');
}
}
function syncActiveFilters() {
Object.assign(activeFilters, form);
}
async function loadProjects() {
state.loading = true;
try {
const payload = await fetchProjects({
...activeFilters,
page: state.page,
pageSize: state.pageSize,
});
state.records = payload.data || [];
state.total = payload.total || 0;
state.page = payload.page || 1;
if (!state.records.length) {
summaryText.value = '当前筛选条件下没有项目';
return;
}
const startIndex = (state.page - 1) * state.pageSize + 1;
const endIndex = Math.min(state.page * state.pageSize, state.total);
summaryText.value = `显示第 ${startIndex}${endIndex} 条记录,共 ${state.total}`;
} catch (error) {
state.records = [];
state.total = 0;
summaryText.value = `项目加载失败:${error.message}`;
ElMessage.error(`项目加载失败:${error.message}`);
} finally {
state.loading = false;
}
}
async function search() {
try {
validateForm();
syncActiveFilters();
state.page = 1;
await loadProjects();
} catch (error) {
ElMessage.error(error.message);
}
}
async function reset() {
Object.keys(form).forEach((key) => {
form[key] = '';
activeFilters[key] = '';
});
state.page = 1;
await loadProjects();
}
async function changePage(page) {
state.page = page;
await loadProjects();
}
</script>
<template>
<section class="page-grid">
<header class="page-head">
<div>
<h2>项目管理</h2>
<p> Element Plus 表单和表格组织项目去重查询适合继续扩展导出排序和列配置</p>
</div>
</header>
<el-card shadow="never">
<el-form label-position="top">
<el-row :gutter="16">
<el-col :xs="24" :sm="12" :lg="6">
<el-form-item label="城市">
<el-select v-model="form.city" placeholder="全部城市" clearable style="width: 100%;">
<el-option v-for="item in options.cities" :key="item" :label="item" :value="item" />
</el-select>
</el-form-item>
</el-col>
<el-col :xs="24" :sm="12" :lg="6">
<el-form-item label="板块">
<el-select v-model="form.section" placeholder="全部板块" clearable style="width: 100%;">
<el-option v-for="item in options.sections" :key="item" :label="item" :value="item" />
</el-select>
</el-form-item>
</el-col>
<el-col :xs="24" :sm="12" :lg="6">
<el-form-item label="项目名称">
<el-input v-model="form.projectName" placeholder="输入项目名关键字" clearable />
</el-form-item>
</el-col>
<el-col :xs="24" :sm="12" :lg="6">
<el-form-item label="最小金额">
<el-input-number v-model="form.minAmount" :min="0" :precision="2" :controls="false" style="width: 100%;" />
</el-form-item>
</el-col>
<el-col :xs="24" :sm="12" :lg="6">
<el-form-item label="最大金额">
<el-input-number v-model="form.maxAmount" :min="0" :precision="2" :controls="false" style="width: 100%;" />
</el-form-item>
</el-col>
<el-col :xs="24" :sm="12" :lg="6">
<el-form-item label="开始日期">
<el-date-picker v-model="form.startDate" type="date" value-format="YYYY-MM-DD" placeholder="选择日期" style="width: 100%;" />
</el-form-item>
</el-col>
<el-col :xs="24" :sm="12" :lg="6">
<el-form-item label="结束日期">
<el-date-picker v-model="form.endDate" type="date" value-format="YYYY-MM-DD" placeholder="选择日期" style="width: 100%;" />
</el-form-item>
</el-col>
</el-row>
</el-form>
<div class="toolbar">
<div class="summary-text">按项目名称去重后展示方便快速判断有哪些有效项目</div>
<div class="toolbar__group">
<el-button @click="reset">重置</el-button>
<el-button type="primary" @click="search">查询项目</el-button>
</div>
</div>
</el-card>
<el-card shadow="never">
<template #header>
<div class="toolbar">
<span>{{ summaryText }}</span>
</div>
</template>
<el-table v-loading="state.loading" :data="state.records" style="width: 100%;" empty-text="暂无项目数据">
<el-table-column prop="city" label="城市" min-width="120" />
<el-table-column label="板块" min-width="140">
<template #default="{ row }">
{{ row.section || row.type || '-' }}
</template>
</el-table-column>
<el-table-column prop="projectName" label="项目名称" min-width="280" show-overflow-tooltip />
<el-table-column label="金额" min-width="150">
<template #default="{ row }">
{{ formatYuan(row.amountYuan) }}
</template>
</el-table-column>
<el-table-column prop="date" label="发布日期" min-width="140" />
<el-table-column label="详情" min-width="120" fixed="right">
<template #default="{ row }">
<el-link v-if="row.detailLink" :href="row.detailLink" type="primary" target="_blank">查看详情</el-link>
<span v-else>-</span>
</template>
</el-table-column>
</el-table>
<div class="pagination-wrap">
<el-pagination
v-if="state.total"
background
layout="total, prev, pager, next"
:current-page="state.page"
:page-size="state.pageSize"
:total="state.total"
@current-change="changePage"
/>
</div>
</el-card>
</section>
</template>

View File

@@ -0,0 +1,200 @@
<script setup>
import { computed, onMounted, reactive } from 'vue';
import { ElMessage, ElMessageBox } from 'element-plus';
import ResultRecordCard from '@/components/ResultRecordCard.vue';
import { deleteResult, fetchResultFilters, fetchResults } from '@/api/results';
const filters = reactive({
city: '',
section: '',
type: '',
});
const options = reactive({
cities: [],
sections: [],
types: [],
});
const stats = reactive({
total: 0,
success: 0,
failed: 0,
cities: 0,
});
const state = reactive({
records: [],
page: 1,
pageSize: 10,
total: 0,
loading: true,
});
const hasRecords = computed(() => state.records.length > 0);
onMounted(async () => {
await refreshAll();
});
async function loadFilters() {
const data = await fetchResultFilters();
options.cities = data.cities || [];
options.sections = data.sections || [];
options.types = data.types || [];
}
async function loadStats() {
const payload = await fetchResults({ page: 1, pageSize: 500 });
const items = payload.data || [];
stats.total = payload.total || 0;
stats.success = items.filter((item) => !item.error).length;
stats.failed = items.filter((item) => item.error).length;
stats.cities = new Set(items.map((item) => item.city).filter(Boolean)).size;
}
async function loadRecords() {
state.loading = true;
try {
const payload = await fetchResults({
page: state.page,
pageSize: state.pageSize,
city: filters.city || undefined,
section: filters.section || undefined,
type: filters.type || undefined,
});
state.records = payload.data || [];
state.total = payload.total || 0;
state.page = payload.page || 1;
} catch (error) {
ElMessage.error(`结果加载失败:${error.message}`);
state.records = [];
state.total = 0;
} finally {
state.loading = false;
}
}
async function refreshAll() {
try {
await Promise.all([loadFilters(), loadStats(), loadRecords()]);
} catch (error) {
ElMessage.error(`页面刷新失败:${error.message}`);
}
}
async function applyFilters() {
state.page = 1;
await loadRecords();
}
async function changePage(page) {
state.page = page;
await loadRecords();
}
async function handleDelete(record) {
try {
await ElMessageBox.confirm('确定要删除这条抓取记录吗?', '删除确认', {
type: 'warning',
});
await deleteResult(record.id);
ElMessage.success('抓取记录已删除');
await refreshAll();
} catch (error) {
if (error !== 'cancel' && error !== 'close') {
ElMessage.error(`删除失败:${error.message}`);
}
}
}
function resetFilters() {
filters.city = '';
filters.section = '';
filters.type = '';
applyFilters();
}
</script>
<template>
<section class="page-grid">
<header class="page-head">
<div>
<h2>抓取结果</h2>
<p>结果页已经切到 Element Plus 组件风格适合继续加更复杂的筛选导出和批量操作</p>
</div>
</header>
<el-row :gutter="16">
<el-col :xs="24" :sm="12" :lg="6">
<el-statistic title="总记录数" :value="stats.total" />
</el-col>
<el-col :xs="24" :sm="12" :lg="6">
<el-statistic title="成功条数" :value="stats.success" />
</el-col>
<el-col :xs="24" :sm="12" :lg="6">
<el-statistic title="失败条数" :value="stats.failed" />
</el-col>
<el-col :xs="24" :sm="12" :lg="6">
<el-statistic title="来源城市" :value="stats.cities" />
</el-col>
</el-row>
<el-card shadow="never">
<el-form label-position="top">
<el-row :gutter="16">
<el-col :xs="24" :sm="8">
<el-form-item label="城市">
<el-select v-model="filters.city" placeholder="全部城市" clearable style="width: 100%;" @change="applyFilters">
<el-option v-for="item in options.cities" :key="item" :label="item" :value="item" />
</el-select>
</el-form-item>
</el-col>
<el-col :xs="24" :sm="8">
<el-form-item label="板块">
<el-select v-model="filters.section" placeholder="全部板块" clearable style="width: 100%;" @change="applyFilters">
<el-option v-for="item in options.sections" :key="item" :label="item" :value="item" />
</el-select>
</el-form-item>
</el-col>
<el-col :xs="24" :sm="8">
<el-form-item label="类型">
<el-select v-model="filters.type" placeholder="全部类型" clearable style="width: 100%;" @change="applyFilters">
<el-option v-for="item in options.types" :key="item" :label="item" :value="item" />
</el-select>
</el-form-item>
</el-col>
</el-row>
</el-form>
<div class="toolbar">
<div class="summary-text">当前页展示 {{ state.records.length }} 条记录</div>
<div class="toolbar__group">
<el-button @click="resetFilters">重置筛选</el-button>
<el-button type="primary" @click="refreshAll">刷新数据</el-button>
</div>
</div>
</el-card>
<div v-loading="state.loading" class="page-grid">
<template v-if="hasRecords">
<ResultRecordCard v-for="item in state.records" :key="item.id" :record="item" @delete="handleDelete" />
</template>
<el-empty v-else description="暂无抓取结果" :image-size="96" />
</div>
<div class="pagination-wrap">
<el-pagination
v-if="state.total"
background
layout="total, prev, pager, next"
:current-page="state.page"
:page-size="state.pageSize"
:total="state.total"
@current-change="changePage"
/>
</div>
</section>
</template>

View File

@@ -0,0 +1,228 @@
<script setup>
import { onMounted, reactive, ref } from 'vue';
import { ElMessage, ElMessageBox } from 'element-plus';
import { fetchConfig, fetchSchedulerStatus, saveConfig, triggerScheduledTask } from '@/api/settings';
const loading = ref(true);
const saveStatus = ref('');
const schedulerText = ref('未获取');
const form = reactive({
agent: {
baseUrl: '',
pollInterval: 3000,
timeout: 300000,
},
scheduler: {
enabled: false,
cronTime: '0 9 * * *',
description: '',
},
email: {
smtpHost: '',
smtpPort: 587,
smtpUser: '',
smtpPass: '',
recipients: '',
},
});
onMounted(async () => {
await refresh();
});
async function refresh() {
loading.value = true;
try {
const [config, schedulerStatus] = await Promise.all([fetchConfig(), fetchSchedulerStatus()]);
form.agent.baseUrl = config.agent?.baseUrl || '';
form.agent.pollInterval = config.agent?.pollInterval || 3000;
form.agent.timeout = config.agent?.timeout || 300000;
form.scheduler.enabled = config.scheduler?.enabled ?? false;
form.scheduler.cronTime = config.scheduler?.cronTime || '0 9 * * *';
form.scheduler.description = config.scheduler?.description || '';
form.email.smtpHost = config.email?.smtpHost || '';
form.email.smtpPort = config.email?.smtpPort || 587;
form.email.smtpUser = config.email?.smtpUser || '';
form.email.smtpPass = config.email?.smtpPass || '';
form.email.recipients = config.email?.recipients || '';
schedulerText.value = schedulerStatus.isRunning
? `运行中(启用任务 ${schedulerStatus.enabledTasks || 0} 个)`
: `未运行(启用任务 ${schedulerStatus.enabledTasks || 0} 个)`;
} catch (error) {
ElMessage.error(`设置加载失败:${error.message}`);
} finally {
loading.value = false;
}
}
async function handleSave() {
try {
await saveConfig({
agent: {
baseUrl: form.agent.baseUrl.trim(),
pollInterval: Number(form.agent.pollInterval) || 3000,
timeout: Number(form.agent.timeout) || 300000,
},
scheduler: {
enabled: Boolean(form.scheduler.enabled),
cronTime: form.scheduler.cronTime.trim() || '0 9 * * *',
description: form.scheduler.description.trim(),
},
email: {
smtpHost: form.email.smtpHost.trim(),
smtpPort: Number(form.email.smtpPort) || 587,
smtpUser: form.email.smtpUser.trim(),
smtpPass: form.email.smtpPass.trim(),
recipients: form.email.recipients.trim(),
},
});
saveStatus.value = '设置已保存';
ElMessage.success('系统设置已保存');
await refresh();
setTimeout(() => {
saveStatus.value = '';
}, 3000);
} catch (error) {
saveStatus.value = '';
ElMessage.error(`保存失败:${error.message}`);
}
}
async function handleTriggerScheduler() {
try {
await ElMessageBox.confirm('确定要立即执行一次定时任务吗?', '执行确认', {
type: 'warning',
});
const payload = await triggerScheduledTask();
ElMessage.success(payload.message || '定时任务已在后台触发');
await refresh();
} catch (error) {
if (error !== 'cancel' && error !== 'close') {
ElMessage.error(`触发失败:${error.message}`);
}
}
}
</script>
<template>
<section class="page-grid">
<header class="page-head">
<div>
<h2>系统设置</h2>
<p>这一页已经改用 Element Plus 表单和卡片来管理 Agent定时任务和邮件配置</p>
</div>
</header>
<div v-loading="loading" class="page-grid">
<el-card shadow="never">
<template #header>
<span>Agent 服务配置</span>
</template>
<el-form label-position="top">
<el-row :gutter="16">
<el-col :xs="24" :sm="24">
<el-form-item label="Agent 服务地址">
<el-input v-model="form.agent.baseUrl" placeholder="http://127.0.0.1:18625" />
</el-form-item>
</el-col>
<el-col :xs="24" :sm="12">
<el-form-item label="轮询间隔(毫秒)">
<el-input-number v-model="form.agent.pollInterval" :min="1000" :step="100" style="width: 100%;" />
</el-form-item>
</el-col>
<el-col :xs="24" :sm="12">
<el-form-item label="超时时间(毫秒)">
<el-input-number v-model="form.agent.timeout" :min="1000" :step="1000" style="width: 100%;" />
</el-form-item>
</el-col>
</el-row>
</el-form>
</el-card>
<el-card shadow="never">
<template #header>
<div class="toolbar">
<span>定时任务</span>
<el-tag type="info">{{ schedulerText }}</el-tag>
</div>
</template>
<el-form label-position="top">
<el-row :gutter="16">
<el-col :xs="24" :sm="8">
<el-form-item label="是否启用">
<el-switch v-model="form.scheduler.enabled" inline-prompt active-text="启用" inactive-text="禁用" />
</el-form-item>
</el-col>
<el-col :xs="24" :sm="8">
<el-form-item label="Cron 表达式">
<el-input v-model="form.scheduler.cronTime" placeholder="0 9 * * *" />
</el-form-item>
</el-col>
<el-col :xs="24" :sm="8">
<el-form-item label="描述">
<el-input v-model="form.scheduler.description" placeholder="每天 9 点执行" />
</el-form-item>
</el-col>
</el-row>
</el-form>
<el-button @click="handleTriggerScheduler">立即执行一次</el-button>
</el-card>
<el-card shadow="never">
<template #header>
<span>邮件配置</span>
</template>
<el-form label-position="top">
<el-row :gutter="16">
<el-col :xs="24" :sm="12">
<el-form-item label="SMTP 服务地址">
<el-input v-model="form.email.smtpHost" placeholder="smtp.qq.com" />
</el-form-item>
</el-col>
<el-col :xs="24" :sm="12">
<el-form-item label="端口">
<el-input-number v-model="form.email.smtpPort" :min="1" style="width: 100%;" />
</el-form-item>
</el-col>
<el-col :xs="24" :sm="12">
<el-form-item label="用户名">
<el-input v-model="form.email.smtpUser" placeholder="your-email@example.com" />
</el-form-item>
</el-col>
<el-col :xs="24" :sm="12">
<el-form-item label="密码或授权码">
<el-input v-model="form.email.smtpPass" type="password" show-password placeholder="留空会沿用已保存的掩码值" />
</el-form-item>
</el-col>
<el-col :xs="24" :sm="24">
<el-form-item label="收件人">
<el-input v-model="form.email.recipients" placeholder="a@example.com, b@example.com" />
</el-form-item>
</el-col>
</el-row>
</el-form>
</el-card>
<el-card shadow="never">
<div class="toolbar">
<div class="summary-text">保存后会自动刷新定时任务配置</div>
<div class="toolbar__group">
<span v-if="saveStatus" class="summary-text" style="color: var(--el-color-success);">{{ saveStatus }}</span>
<el-button type="primary" @click="handleSave">保存全部设置</el-button>
</div>
</div>
</el-card>
</div>
</section>
</template>

View File

@@ -0,0 +1,347 @@
<script setup>
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue';
import { ElMessage, ElMessageBox } from 'element-plus';
import TaskDialog from '@/components/TaskDialog.vue';
import {
createTask,
deleteTask,
fetchTasks,
fetchTaskStatus,
runAllTasks,
runTask,
toggleTask,
updateTask,
} from '@/api/tasks';
import { formatElapsed } from '@/utils/format';
const tasks = ref([]);
const isLoading = ref(true);
const dialogOpen = ref(false);
const editingTask = ref(null);
const filters = reactive({
city: '',
plate: '',
});
const pagination = reactive({
page: 1,
pageSize: 10,
});
const runner = reactive({
timer: null,
polling: false,
});
const status = reactive({
visible: false,
type: 'info',
message: '',
});
const filteredTasks = computed(() => {
const cityKeyword = filters.city.trim().toLowerCase();
const plateKeyword = filters.plate.trim().toLowerCase();
return tasks.value.filter((task) => {
const city = (task.city || '').toLowerCase();
const plate = (task.plateName || '').toLowerCase();
return (!cityKeyword || city.includes(cityKeyword)) && (!plateKeyword || plate.includes(plateKeyword));
});
});
const enabledCount = computed(() => tasks.value.filter((item) => item.enabled).length);
const pagedTasks = computed(() => {
const start = (pagination.page - 1) * pagination.pageSize;
return filteredTasks.value.slice(start, start + pagination.pageSize);
});
const summaryText = computed(
() => `${tasks.value.length} 个任务,已启用 ${enabledCount.value} 个,筛选后 ${filteredTasks.value.length}`,
);
watch(
() => [filters.city, filters.plate],
() => {
pagination.page = 1;
},
);
onMounted(async () => {
await loadTasks();
await restoreRunningStatus();
});
onBeforeUnmount(() => {
stopPolling();
});
async function loadTasks() {
isLoading.value = true;
try {
tasks.value = await fetchTasks();
} catch (error) {
ElMessage.error(`任务加载失败:${error.message}`);
} finally {
isLoading.value = false;
}
}
function openCreateDialog() {
editingTask.value = null;
dialogOpen.value = true;
}
function openEditDialog(task) {
editingTask.value = task;
dialogOpen.value = true;
}
function updateStatus(type, message) {
status.visible = true;
status.type = type;
status.message = message;
}
function stopPolling() {
if (runner.timer) {
clearInterval(runner.timer);
runner.timer = null;
}
runner.polling = false;
}
function applyRunStatus(data) {
if (!data?.isRunning && !data?.finished) {
stopPolling();
return;
}
if (data.finished) {
stopPolling();
if (data.error) {
updateStatus('error', `运行失败:${data.error}`);
ElMessage.error(`任务执行失败:${data.error}`);
return;
}
const results = data.results || [];
if (results.length <= 1) {
const total = results[0]?.data?.total ?? 0;
updateStatus('success', `运行完成,抓取到 ${total} 条结果。`);
ElMessage.success(`任务完成,抓取到 ${total} 条结果`);
return;
}
const successCount = results.filter((item) => !item.error).length;
const failCount = results.filter((item) => item.error).length;
updateStatus('success', `批量运行完成,成功 ${successCount} 个,失败 ${failCount} 个。`);
ElMessage.success(`批量运行完成,成功 ${successCount}`);
return;
}
updateStatus(
'info',
`正在执行:${data.city || '任务'}${data.current || 0}/${data.total || 0}),已用时 ${formatElapsed(data.elapsed)}`,
);
}
async function pollStatus() {
try {
const data = await fetchTaskStatus();
applyRunStatus(data);
if (data.finished) {
await loadTasks();
}
} catch (error) {
stopPolling();
updateStatus('error', `状态轮询失败:${error.message}`);
}
}
function startPolling() {
stopPolling();
runner.polling = true;
runner.timer = setInterval(pollStatus, 2000);
}
async function restoreRunningStatus() {
try {
const data = await fetchTaskStatus();
applyRunStatus(data);
if (data.isRunning) {
startPolling();
}
} catch (error) {
ElMessage.error(`任务状态恢复失败:${error.message}`);
}
}
async function submitTask(payload) {
try {
if (editingTask.value?.id) {
await updateTask(editingTask.value.id, payload);
ElMessage.success('任务已更新');
} else {
await createTask(payload);
ElMessage.success('任务已创建');
}
dialogOpen.value = false;
await loadTasks();
} catch (error) {
ElMessage.error(`保存失败:${error.message}`);
}
}
async function handleDelete(task) {
try {
await ElMessageBox.confirm(`确定要删除“${task.city || '未命名任务'}”吗?`, '删除确认', {
type: 'warning',
});
await deleteTask(task.id);
ElMessage.success('任务已删除');
await loadTasks();
} catch (error) {
if (error !== 'cancel' && error !== 'close') {
ElMessage.error(`删除失败:${error.message}`);
}
}
}
async function handleToggle(task, enabled) {
try {
await toggleTask(task.id, enabled);
ElMessage.success(enabled ? '任务已启用' : '任务已禁用');
await loadTasks();
} catch (error) {
ElMessage.error(`状态更新失败:${error.message}`);
}
}
async function handleRun(task) {
if (runner.polling) {
ElMessage.info('当前已有任务在运行,请稍后再试');
return;
}
try {
await runTask(task.id);
updateStatus('info', `已开始运行任务:${task.city}`);
ElMessage.success(`已开始运行:${task.city}`);
startPolling();
} catch (error) {
updateStatus('error', `启动失败:${error.message}`);
ElMessage.error(`启动失败:${error.message}`);
}
}
async function handleRunAll() {
if (!enabledCount.value) {
ElMessage.info('没有已启用的任务可以运行');
return;
}
try {
await ElMessageBox.confirm(`确定要运行全部 ${enabledCount.value} 个已启用任务吗?`, '批量执行确认', {
type: 'warning',
});
await runAllTasks();
updateStatus('info', '已开始运行全部启用任务,请稍候…');
ElMessage.success('已开始运行全部启用任务');
startPolling();
} catch (error) {
if (error !== 'cancel' && error !== 'close') {
updateStatus('error', `批量启动失败:${error.message}`);
ElMessage.error(`批量启动失败:${error.message}`);
}
}
}
</script>
<template>
<section class="page-grid">
<header class="page-head">
<div>
<h2>任务配置</h2>
</div>
</header>
<el-card shadow="never">
<div class="toolbar">
<div class="toolbar__group">
<el-button type="primary" @click="openCreateDialog">新增任务</el-button>
<el-button type="success" :disabled="runner.polling" @click="handleRunAll">运行全部启用</el-button>
</div>
<div class="summary-text">{{ summaryText }}</div>
</div>
<el-form label-position="top" class="filter-form">
<el-row :gutter="16">
<el-col :xs="24" :sm="12">
<el-form-item label="搜索城市">
<el-input v-model="filters.city" placeholder="输入城市关键字" clearable />
</el-form-item>
</el-col>
<el-col :xs="24" :sm="12">
<el-form-item label="搜索板块">
<el-input v-model="filters.plate" placeholder="输入板块关键字" clearable />
</el-form-item>
</el-col>
</el-row>
</el-form>
<el-alert v-if="status.visible" :title="status.message" :type="status.type" :closable="false" show-icon />
<el-table v-loading="isLoading" :data="pagedTasks" style="width: 100%; margin-top: 18px;" empty-text="没有匹配的任务">
<el-table-column prop="city" label="城市" min-width="120" />
<el-table-column prop="plateName" label="板块" min-width="120" />
<el-table-column label="提示词" min-width="280">
<template #default="{ row }">
<div class="table-prompt">{{ row.prompt || '-' }}</div>
</template>
</el-table-column>
<el-table-column label="模型" min-width="160">
<template #default="{ row }">
<el-tag type="info">{{ row.mode || 'qwen3.5-plus' }}</el-tag>
</template>
</el-table-column>
<el-table-column label="状态" width="100">
<template #default="{ row }">
<el-tag :type="row.enabled ? 'success' : 'danger'">{{ row.enabled ? '启用' : '禁用' }}</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" min-width="260" fixed="right">
<template #default="{ row }">
<div class="table-actions">
<el-button size="small" @click="openEditDialog(row)">编辑</el-button>
<el-button size="small" type="primary" :disabled="runner.polling" @click="handleRun(row)">运行</el-button>
<el-button size="small" plain @click="handleToggle(row, !row.enabled)">{{ row.enabled ? '禁用' : '启用' }}</el-button>
<el-button size="small" type="danger" plain @click="handleDelete(row)">删除</el-button>
</div>
</template>
</el-table-column>
</el-table>
<div class="pagination-wrap">
<el-pagination
v-if="filteredTasks.length"
background
layout="total, prev, pager, next"
:current-page="pagination.page"
:page-size="pagination.pageSize"
:total="filteredTasks.length"
@current-change="pagination.page = $event"
/>
</div>
</el-card>
<TaskDialog v-model="dialogOpen" :task="editingTask" @submit="submitTask" />
</section>
</template>

View File

@@ -0,0 +1,56 @@
import { createRouter, createWebHistory } from 'vue-router';
const routes = [
{
path: '/',
redirect: '/tasks',
},
{
path: '/tasks',
name: 'tasks',
component: () => import('@/pages/TasksPage.vue'),
meta: {
title: '任务配置',
subtitle: '管理城市任务、切换模型,并手动触发抓取流程。',
},
},
{
path: '/results',
name: 'results',
component: () => import('@/pages/ResultsPage.vue'),
meta: {
title: '抓取结果',
subtitle: '查看抓取记录、按条件筛选,并快速定位失败任务。',
},
},
{
path: '/projects',
name: 'projects',
component: () => import('@/pages/ProjectsPage.vue'),
meta: {
title: '项目管理',
subtitle: '对抓取出的项目去重、筛选与金额范围查询。',
},
},
{
path: '/settings',
name: 'settings',
component: () => import('@/pages/SettingsPage.vue'),
meta: {
title: '系统设置',
subtitle: '维护 Agent、定时任务与邮件推送配置。',
},
},
];
const router = createRouter({
history: createWebHistory(),
routes,
});
router.afterEach((to) => {
const title = to.meta?.title ? `${to.meta.title} | 公告抓取与分析工具` : '公告抓取与分析工具';
document.title = title;
});
export default router;

383
client/src/styles.css Normal file
View File

@@ -0,0 +1,383 @@
@import url('https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700;800&family=Noto+Sans+SC:wght@400;500;700&family=IBM+Plex+Mono:wght@500&display=swap');
:root {
--bg-top: #edf7ff;
--bg-mid: #f8fcf8;
--bg-bottom: #fff8ef;
--line: rgba(15, 34, 52, 0.1);
--text: #11263a;
--muted: #60758a;
--shadow-lg: 0 24px 54px rgba(11, 42, 68, 0.14);
--shadow-md: 0 12px 28px rgba(11, 42, 68, 0.08);
--radius-lg: 18px;
}
html,
body,
#app {
min-height: 100%;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: 'Plus Jakarta Sans', 'Noto Sans SC', 'PingFang SC', 'Microsoft YaHei', sans-serif;
color: var(--text);
background:
radial-gradient(880px 520px at -8% -12%, rgba(13, 108, 184, 0.2), transparent 58%),
radial-gradient(760px 440px at 110% -10%, rgba(15, 157, 140, 0.18), transparent 52%),
radial-gradient(900px 520px at 60% 118%, rgba(215, 139, 30, 0.12), transparent 56%),
linear-gradient(160deg, var(--bg-top) 0%, var(--bg-mid) 52%, var(--bg-bottom) 100%);
--el-color-primary: #0d6cb8;
--el-border-radius-base: 12px;
--el-font-family: 'Plus Jakarta Sans', 'Noto Sans SC', 'PingFang SC', 'Microsoft YaHei', sans-serif;
}
a {
text-decoration: none;
}
.app-shell {
min-height: 100vh;
padding: 24px;
}
.app-frame {
max-width: 1480px;
margin: 0 auto;
border: 1px solid var(--line);
border-radius: 28px;
overflow: hidden;
background: rgba(255, 255, 255, 0.68);
box-shadow: var(--shadow-lg);
backdrop-filter: blur(22px);
}
.app-topbar {
padding: 24px 28px;
color: #eff8ff;
background:
radial-gradient(circle at top right, rgba(255, 255, 255, 0.2), transparent 26%),
linear-gradient(125deg, #0c639f 0%, #0f85a2 55%, #17a86b 100%);
}
.app-brand {
display: flex;
gap: 16px;
align-items: center;
}
.app-brand__mark {
width: 54px;
height: 54px;
border-radius: 16px;
display: grid;
place-items: center;
color: #0d4c6e;
font-size: 24px;
font-weight: 800;
background: linear-gradient(145deg, rgba(255, 255, 255, 0.96), rgba(232, 248, 255, 0.84));
}
.app-brand__text h1 {
margin: 0;
font-size: 26px;
}
.app-brand__text p {
margin: 6px 0 0;
font-size: 13px;
opacity: 0.92;
}
.app-menu.el-menu {
padding: 10px 10px 0;
border-bottom: 1px solid var(--line);
background: rgba(255, 255, 255, 0.26);
}
.app-menu.el-menu--horizontal > .el-menu-item {
height: 48px;
line-height: 48px;
border-bottom: 0;
margin: 0 8px 10px 0;
border-radius: 14px;
color: var(--muted);
font-weight: 700;
}
.app-menu.el-menu--horizontal > .el-menu-item.is-active {
color: var(--el-color-primary);
background: rgba(255, 255, 255, 0.9);
}
.app-main {
padding: 28px;
}
.page-grid {
display: grid;
gap: 18px;
}
.page-head {
display: flex;
justify-content: space-between;
gap: 20px;
align-items: flex-end;
}
.page-head h2 {
margin: 0;
font-size: 28px;
}
.page-head p {
margin: 8px 0 0;
color: var(--muted);
font-size: 14px;
}
.toolbar {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.toolbar__group {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.summary-text {
color: var(--muted);
font-size: 14px;
}
.hero-panel.el-card {
border: 0;
color: #f7fcff;
background: linear-gradient(140deg, rgba(13, 108, 184, 0.95), rgba(15, 157, 140, 0.92));
box-shadow: var(--shadow-md);
}
.hero-panel .el-card__body {
position: relative;
overflow: hidden;
}
.hero-panel .el-card__body::after {
content: '';
position: absolute;
right: -70px;
top: -80px;
width: 240px;
height: 240px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.14);
}
.hero-panel h3,
.hero-panel p {
position: relative;
z-index: 1;
}
.hero-panel h3 {
margin: 0;
font-size: 18px;
}
.hero-panel p {
margin: 8px 0 0;
max-width: 760px;
opacity: 0.92;
}
.filter-form {
margin-top: 18px;
}
.table-prompt {
display: -webkit-box;
overflow: hidden;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
line-height: 1.6;
}
.table-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.pagination-wrap {
display: flex;
justify-content: flex-end;
margin-top: 18px;
}
.dialog-footer {
display: flex;
justify-content: flex-end;
gap: 10px;
}
.result-card-plus {
border-radius: var(--radius-lg);
box-shadow: var(--shadow-md);
}
.result-card-plus__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.result-card-plus__title {
font-size: 18px;
font-weight: 700;
}
.result-card-plus__meta {
display: flex;
gap: 8px;
margin-top: 8px;
flex-wrap: wrap;
}
.result-card-plus__time {
color: var(--muted);
font-size: 13px;
white-space: nowrap;
}
.result-card-plus__list {
display: grid;
gap: 12px;
}
.result-card-plus__item {
display: grid;
grid-template-columns: 110px 1.6fr 150px 120px auto;
gap: 12px;
align-items: center;
padding-bottom: 12px;
border-bottom: 1px solid rgba(15, 34, 52, 0.08);
}
.result-card-plus__item:last-child {
border-bottom: 0;
padding-bottom: 0;
}
.result-card-plus__type {
color: var(--el-color-primary);
font-size: 13px;
font-weight: 800;
}
.result-card-plus__name {
min-width: 0;
word-break: break-word;
}
.result-card-plus__amount {
color: #99591a;
font-weight: 700;
text-align: right;
}
.result-card-plus__date {
color: var(--muted);
font-size: 13px;
text-align: center;
}
.result-card-plus__action {
text-align: right;
}
.result-card-plus__footer {
display: flex;
justify-content: flex-end;
}
.el-card {
--el-card-border-color: rgba(15, 34, 52, 0.08);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-md);
}
.el-statistic {
padding: 18px;
border-radius: var(--radius-lg);
background: rgba(255, 255, 255, 0.76);
border: 1px solid rgba(15, 34, 52, 0.08);
box-shadow: var(--shadow-md);
}
.el-statistic__head {
color: var(--muted);
}
.el-table {
margin-top: 8px;
}
.el-table,
.el-table tr,
.el-table th.el-table__cell,
.el-table td.el-table__cell {
background: transparent;
}
.el-empty {
padding: 36px 0;
}
@media (max-width: 1120px) {
.result-card-plus__item {
grid-template-columns: 90px 1fr;
}
.result-card-plus__amount,
.result-card-plus__date,
.result-card-plus__action {
text-align: left;
}
}
@media (max-width: 840px) {
.app-shell {
padding: 12px;
}
.app-main,
.app-topbar {
padding: 18px;
}
.page-head {
align-items: flex-start;
flex-direction: column;
}
.app-menu.el-menu--horizontal {
display: block;
height: auto;
}
.app-menu.el-menu--horizontal > .el-menu-item {
display: block;
}
}

View File

@@ -0,0 +1,26 @@
export function formatDateTime(value) {
if (!value) return '-';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return date.toLocaleString('zh-CN');
}
export function formatYuan(value) {
if (typeof value !== 'number' || !Number.isFinite(value)) return '-';
return `${value.toLocaleString('zh-CN', { maximumFractionDigits: 2 })} 元`;
}
export function formatElapsed(totalSeconds) {
const seconds = Number(totalSeconds) || 0;
const minutes = Math.floor(seconds / 60);
const remainder = seconds % 60;
return minutes > 0 ? `${minutes}${remainder}` : `${remainder}`;
}
export function pickResultName(item = {}) {
return item.project_name || item.projectName || item.title || item.name || item.bidName || '-';
}
export function pickResultLink(item = {}) {
return item.detail_link || item.target_link || item.url || item.href || '';
}