| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618 |
- <template>
- <div class="p-2 public-opinion-page">
- <!-- 标题区 -->
- <div class="public-opinion-header">
- <h2 class="page-title">舆情推送</h2>
- <div class="page-subtitle">实时监测舆情信息,及时推送预警</div>
- </div>
- <!-- ① 数据卡片 -->
- <el-row :gutter="20" class="mb-2">
- <el-col :span="6">
- <el-card shadow="hover" class="stat-card">
- <div class="stat-card-content">
- <div class="stat-info">
- <div class="stat-title">今日舆情总数</div>
- <div class="stat-value">{{ stats.todayTotal || 0 }}</div>
- <div class="stat-extra stat-extra-up" v-if="stats.todayGrowth !== undefined">
- <el-icon v-if="stats.todayGrowth >= 0"><CaretTop /></el-icon>
- <el-icon v-else><CaretBottom /></el-icon>
- <span>{{ Math.abs(stats.todayGrowth) }}% 较昨日</span>
- </div>
- </div>
- <div class="stat-icon stat-icon-blue">
- <el-icon><ChatLineSquare /></el-icon>
- </div>
- </div>
- </el-card>
- </el-col>
- <el-col :span="6">
- <el-card shadow="hover" class="stat-card">
- <div class="stat-card-content">
- <div class="stat-info">
- <div class="stat-title">重大舆情</div>
- <div class="stat-value">{{ stats.majorTotal || 0 }}</div>
- <div class="stat-extra" :class="(stats.majorChange || 0) >= 0 ? 'stat-extra-up' : 'stat-extra-down'">
- <el-icon v-if="(stats.majorChange || 0) >= 0"><CaretTop /></el-icon>
- <el-icon v-else><CaretBottom /></el-icon>
- <span>{{ Math.abs(stats.majorChange || 0) }} 较昨日</span>
- </div>
- </div>
- <div class="stat-icon stat-icon-red">
- <el-icon><Warning /></el-icon>
- </div>
- </div>
- </el-card>
- </el-col>
- <el-col :span="6">
- <el-card shadow="hover" class="stat-card">
- <div class="stat-card-content">
- <div class="stat-info">
- <div class="stat-title">已处理舆情</div>
- <div class="stat-value">{{ stats.handledTotal || 0 }}</div>
- <div class="stat-extra stat-extra-up">
- <span>{{ stats.handleRate || 0 }}% 处理率</span>
- </div>
- </div>
- <div class="stat-icon stat-icon-green">
- <el-icon><CircleCheck /></el-icon>
- </div>
- </div>
- </el-card>
- </el-col>
- <el-col :span="6">
- <el-card shadow="hover" class="stat-card">
- <div class="stat-card-content">
- <div class="stat-info">
- <div class="stat-title">待处理舆情</div>
- <div class="stat-value">{{ stats.pendingTotal || 0 }}</div>
- <div class="stat-extra stat-extra-warning">
- <span>需要紧急处理</span>
- </div>
- </div>
- <div class="stat-icon stat-icon-orange">
- <el-icon><Bell /></el-icon>
- </div>
- </div>
- </el-card>
- </el-col>
- </el-row>
- <!-- ② 近七天舆情趋势 -->
- <el-card shadow="hover" class="mb-2">
- <template #header>
- <span class="section-title">近7天舆情趋势</span>
- </template>
- <div ref="trendChartRef" class="trend-chart"></div>
- </el-card>
- <!-- ③ 重大舆情明细 -->
- <el-card shadow="hover">
- <template #header>
- <div class="section-header">
- <span class="section-title">重大舆情明细</span>
- <el-button type="primary" :icon="Plus" @click="handleAdd">新增舆情</el-button>
- </div>
- </template>
- <el-table v-loading="loading" :data="majorList" border stripe>
- <el-table-column label="舆情标题" align="center" prop="opinionTitle" :show-overflow-tooltip="true" min-width="220" />
- <el-table-column label="来源" align="center" prop="source" width="160" :show-overflow-tooltip="true" />
- <el-table-column label="发布时间" align="center" prop="publishTime" width="180" />
- <el-table-column label="风险等级" align="center" prop="riskLevel" width="100">
- <template #default="scope">
- <el-tag :type="getRiskLevelTag(scope.row.riskLevel)">{{ scope.row.riskLevel }}</el-tag>
- </template>
- </el-table-column>
- <el-table-column label="状态" align="center" prop="status" width="100">
- <template #default="scope">
- <el-tag :type="getStatusTag(scope.row.status)">{{ scope.row.status || '待处理' }}</el-tag>
- </template>
- </el-table-column>
- <el-table-column label="操作" fixed="right" align="center" width="120" class-name="small-padding fixed-width">
- <template #default="scope">
- <el-button link type="primary" @click="handleProcess(scope.row)">处理</el-button>
- </template>
- </el-table-column>
- </el-table>
- </el-card>
- <!-- ④ 新增舆情对话框 -->
- <el-dialog v-model="dialog.visible" title="新增舆情信息" width="640px" append-to-body @close="closeAddDialog">
- <el-form ref="formRef" :model="form" :rules="rules" label-width="100px">
- <el-form-item label="舆情标题" prop="opinionTitle">
- <el-input v-model="form.opinionTitle" placeholder="请输入舆情内容标题" maxlength="200" show-word-limit />
- </el-form-item>
- <el-row :gutter="20">
- <el-col :span="12">
- <el-form-item label="舆情来源" prop="source">
- <el-select v-model="form.source" placeholder="请选择舆情来源" style="width: 100%">
- <el-option
- v-for="dict in opinion_source_dict"
- :key="dict.value"
- :label="dict.label"
- :value="dict.value"
- />
- </el-select>
- </el-form-item>
- </el-col>
- <el-col :span="12">
- <el-form-item label="发布时间" prop="publishTime">
- <el-date-picker
- v-model="form.publishTime"
- type="datetime"
- placeholder="请选择发布时间"
- value-format="YYYY-MM-DD HH:mm:ss"
- style="width: 100%"
- />
- </el-form-item>
- </el-col>
- </el-row>
- <el-row :gutter="20">
- <el-col :span="12">
- <el-form-item label="风险等级" prop="riskLevel">
- <el-select v-model="form.riskLevel" placeholder="请选择风险等级" style="width: 100%">
- <el-option
- v-for="dict in risk_opinion_level_dict"
- :key="dict.value"
- :label="dict.label"
- :value="dict.value"
- />
- </el-select>
- </el-form-item>
- </el-col>
- <el-col :span="12">
- <el-form-item label="负责人" prop="responsiblePerson">
- <el-input v-model="form.responsiblePerson" placeholder="请输入负责人" maxlength="50" />
- </el-form-item>
- </el-col>
- </el-row>
- <el-form-item label="舆情链接" prop="opinionLink">
- <el-input v-model="form.opinionLink" placeholder="请输入舆情外部链接(URL)" maxlength="500" />
- </el-form-item>
- <el-form-item label="舆情内容" prop="opinionContent">
- <el-input
- v-model="form.opinionContent"
- type="textarea"
- :rows="4"
- placeholder="请输入舆情内容"
- maxlength="2000"
- show-word-limit
- />
- </el-form-item>
- <el-form-item label="处置建议" prop="disposalSuggestion">
- <el-input
- v-model="form.disposalSuggestion"
- type="textarea"
- :rows="3"
- placeholder="请输入舆情处置建议"
- maxlength="1000"
- show-word-limit
- />
- </el-form-item>
- </el-form>
- <template #footer>
- <div class="dialog-footer">
- <el-button @click="cancelAdd">取 消</el-button>
- <el-button type="primary" @click="submitForm">提 交</el-button>
- </div>
- </template>
- </el-dialog>
- <!-- 处理舆情弹窗 -->
- <el-dialog v-model="processDialog.visible" title="处理舆情" width="520px" append-to-body>
- <el-form ref="processFormRef" :model="processForm" :rules="processRules" label-width="100px">
- <el-form-item label="舆情标题">
- <span class="readonly-text">{{ processForm.opinionTitle }}</span>
- </el-form-item>
- <el-form-item label="来源">
- <span class="readonly-text">{{ processForm.source }}</span>
- </el-form-item>
- <el-form-item label="风险等级">
- <el-tag :type="getRiskLevelTag(processForm.riskLevel)">{{ processForm.riskLevel }}</el-tag>
- </el-form-item>
- <el-form-item label="状态" prop="status">
- <el-select v-model="processForm.status" placeholder="请选择状态" style="width: 100%">
- <el-option
- v-for="dict in risk_opinion_status_dict"
- :key="dict.value"
- :label="dict.label"
- :value="dict.value"
- />
- </el-select>
- </el-form-item>
- <el-form-item label="处理结果" prop="handleResult">
- <el-input
- v-model="processForm.handleResult"
- type="textarea"
- :rows="4"
- placeholder="请录入处理结果"
- maxlength="1000"
- show-word-limit
- />
- </el-form-item>
- </el-form>
- <template #footer>
- <div class="dialog-footer">
- <el-button @click="processDialog.visible = false">取 消</el-button>
- <el-button type="primary" @click="submitProcessForm">提 交</el-button>
- </div>
- </template>
- </el-dialog>
- </div>
- </template>
- <script setup name="PublicOpinion" lang="ts">
- import { ref, reactive, toRefs, onMounted, onBeforeUnmount, getCurrentInstance, nextTick } from 'vue';
- import * as echarts from 'echarts';
- import {
- addPublicOpinion,
- handlePublicOpinion,
- getPublicOpinionStats,
- getPublicOpinionTrend,
- getMajorOpinionList
- } from '@/api/risk/publicOpinion';
- import type { RiskPublicOpinionBo, RiskPublicOpinionVo, PublicOpinionStatsVo, PublicOpinionTrendVo } from '@/api/risk/types';
- import { FormInstance } from 'element-plus';
- import type { ComponentInternalInstance } from 'vue';
- import { CaretTop, CaretBottom, ChatLineSquare, Warning, CircleCheck, Bell, Plus } from '@element-plus/icons-vue';
- const { proxy } = getCurrentInstance() as ComponentInternalInstance;
- // ============ 状态 ============
- const majorList = ref<RiskPublicOpinionVo[]>([]);
- const loading = ref(false);
- const stats = ref<PublicOpinionStatsVo>({
- todayTotal: 0,
- yesterdayTotal: 0,
- todayGrowth: 0,
- majorTotal: 0,
- majorYesterday: 0,
- majorChange: 0,
- handledTotal: 0,
- handleBase: 0,
- handleRate: 0,
- pendingTotal: 0
- });
- const trendList = ref<PublicOpinionTrendVo[]>([]);
- // 字典
- const { opinion_source: opinion_source_dict, risk_opinion_level: risk_opinion_level_dict, risk_opinion_status: risk_opinion_status_dict } = toRefs<any>(
- proxy?.useDict('opinion_source', 'risk_opinion_level', 'risk_opinion_status')
- );
- // 图表
- const trendChartRef = ref<HTMLElement>();
- let trendChart: echarts.ECharts | null = null;
- // 表单
- const formRef = ref<FormInstance>();
- const processFormRef = ref<FormInstance>();
- const data = reactive<any>({
- form: {
- id: undefined,
- opinionTitle: '',
- source: '',
- publishTime: '',
- riskLevel: '',
- responsiblePerson: '',
- opinionLink: '',
- opinionContent: '',
- disposalSuggestion: '',
- status: '待处理'
- } as RiskPublicOpinionBo,
- rules: {
- opinionTitle: [{ required: true, message: '舆情标题不能为空', trigger: 'blur' }],
- source: [{ required: true, message: '请选择舆情来源', trigger: 'change' }],
- publishTime: [{ required: true, message: '请选择发布时间', trigger: 'change' }],
- riskLevel: [{ required: true, message: '请选择风险等级', trigger: 'change' }],
- opinionContent: [{ required: true, message: '请输入舆情内容', trigger: 'blur' }]
- }
- });
- const { form, rules } = toRefs(data);
- const dialog = reactive({ visible: false });
- const processDialog = reactive({ visible: false });
- const processForm = reactive<any>({
- id: undefined,
- opinionTitle: '',
- source: '',
- riskLevel: '',
- status: '处理中',
- handleResult: ''
- });
- const processRules = {
- status: [{ required: true, message: '请选择状态', trigger: 'change' }],
- handleResult: [{ required: true, message: '请录入处理结果', trigger: 'blur' }]
- };
- // ============ 数据加载 ============
- const loadStats = async () => {
- const res = await getPublicOpinionStats();
- if (res.data) {
- stats.value = res.data;
- }
- };
- const loadTrend = async () => {
- const res = await getPublicOpinionTrend();
- trendList.value = res.data || [];
- await nextTick();
- renderTrendChart();
- };
- const loadMajorList = async () => {
- loading.value = true;
- try {
- const res = await getMajorOpinionList();
- majorList.value = res.data || [];
- } finally {
- loading.value = false;
- }
- };
- const loadAll = async () => {
- await Promise.all([loadStats(), loadTrend(), loadMajorList()]);
- };
- // ============ 图表渲染 ============
- const renderTrendChart = () => {
- if (!trendChartRef.value) return;
- if (!trendChart) {
- trendChart = echarts.init(trendChartRef.value);
- }
- const dates = trendList.value.map((it) => it.date?.slice(5) || '');
- const totalArr = trendList.value.map((it) => it.totalCount || 0);
- const majorArr = trendList.value.map((it) => it.majorCount || 0);
- const handledArr = trendList.value.map((it) => it.handledCount || 0);
- trendChart.setOption({
- tooltip: { trigger: 'axis' },
- legend: {
- data: ['总舆情', '重大舆情', '已处理'],
- top: 0,
- icon: 'circle'
- },
- grid: { left: 40, right: 30, top: 40, bottom: 30 },
- xAxis: {
- type: 'category',
- data: dates,
- boundaryGap: false,
- axisLine: { lineStyle: { color: '#e0e0e0' } },
- axisLabel: { color: '#666' }
- },
- yAxis: {
- type: 'value',
- minInterval: 1,
- splitLine: { lineStyle: { type: 'dashed', color: '#eee' } },
- axisLabel: { color: '#666' }
- },
- series: [
- {
- name: '总舆情',
- type: 'line',
- smooth: true,
- symbol: 'circle',
- symbolSize: 6,
- data: totalArr,
- itemStyle: { color: '#409EFF' },
- lineStyle: { width: 2, color: '#409EFF' }
- },
- {
- name: '重大舆情',
- type: 'line',
- smooth: true,
- symbol: 'circle',
- symbolSize: 6,
- data: majorArr,
- itemStyle: { color: '#F56C6C' },
- lineStyle: { width: 2, color: '#F56C6C' }
- },
- {
- name: '已处理',
- type: 'line',
- smooth: true,
- symbol: 'circle',
- symbolSize: 6,
- data: handledArr,
- itemStyle: { color: '#67C23A' },
- lineStyle: { width: 2, color: '#67C23A' }
- }
- ]
- });
- };
- const handleResize = () => {
- trendChart?.resize();
- };
- // ============ 操作 ============
- const handleAdd = () => {
- resetForm();
- dialog.visible = true;
- };
- const submitForm = () => {
- formRef.value?.validate(async (valid: boolean) => {
- if (!valid) return;
- await addPublicOpinion(form.value);
- proxy?.$modal.msgSuccess('新增成功');
- dialog.visible = false;
- await loadAll();
- });
- };
- const cancelAdd = () => {
- dialog.visible = false;
- resetForm();
- };
- const closeAddDialog = () => {
- dialog.visible = false;
- resetForm();
- };
- const resetForm = () => {
- form.value = {
- id: undefined,
- opinionTitle: '',
- source: '',
- publishTime: '',
- riskLevel: '',
- responsiblePerson: '',
- opinionLink: '',
- opinionContent: '',
- disposalSuggestion: '',
- status: '待处理'
- } as RiskPublicOpinionBo;
- formRef.value?.resetFields();
- };
- const handleProcess = (row: RiskPublicOpinionVo) => {
- processForm.id = row.id;
- processForm.opinionTitle = row.opinionTitle;
- processForm.source = row.source;
- processForm.riskLevel = row.riskLevel;
- processForm.status = row.status && row.status !== '已处理' ? row.status : '处理中';
- processForm.handleResult = row.handleResult || '';
- processDialog.visible = true;
- };
- const submitProcessForm = () => {
- processFormRef.value?.validate(async (valid: boolean) => {
- if (!valid) return;
- await handlePublicOpinion(processForm.id, { ...processForm });
- proxy?.$modal.msgSuccess('处理成功');
- processDialog.visible = false;
- await loadAll();
- });
- };
- // ============ 工具 ============
- const getRiskLevelTag = (level: string): 'primary' | 'success' | 'warning' | 'info' | 'danger' => {
- const map: Record<string, 'primary' | 'success' | 'warning' | 'info' | 'danger'> = {
- 重大: 'danger',
- 较大: 'warning',
- 一般: 'success',
- 高: 'danger',
- 中: 'warning',
- 低: 'success'
- };
- return map[level] || 'info';
- };
- const getStatusTag = (status: string): 'primary' | 'success' | 'warning' | 'info' | 'danger' => {
- const map: Record<string, 'primary' | 'success' | 'warning' | 'info' | 'danger'> = {
- 待处理: 'danger',
- 处理中: 'warning',
- 已处理: 'success'
- };
- return map[status] || 'info';
- };
- // ============ 生命周期 ============
- onMounted(async () => {
- await loadAll();
- window.addEventListener('resize', handleResize);
- });
- onBeforeUnmount(() => {
- window.removeEventListener('resize', handleResize);
- trendChart?.dispose();
- trendChart = null;
- });
- </script>
- <style scoped lang="scss">
- .public-opinion-page {
- padding: 16px;
- height: 100%;
- overflow-y: auto;
- box-sizing: border-box;
- }
- .public-opinion-header {
- margin-bottom: 16px;
- .page-title {
- font-size: 20px;
- font-weight: 600;
- color: #303133;
- margin: 0 0 4px 0;
- }
- .page-subtitle {
- font-size: 13px;
- color: #909399;
- }
- }
- .mb-2 { margin-bottom: 16px; }
- .stat-card {
- border-radius: 8px;
- :deep(.el-card__body) { padding: 20px; }
- }
- .stat-card-content {
- display: flex;
- align-items: center;
- justify-content: space-between;
- }
- .stat-info { flex: 1; }
- .stat-title {
- font-size: 13px;
- color: #909399;
- margin-bottom: 8px;
- white-space: nowrap;
- }
- .stat-value {
- font-size: 28px;
- font-weight: 700;
- color: #303133;
- line-height: 1.2;
- margin-bottom: 8px;
- }
- .stat-extra {
- font-size: 12px;
- color: #909399;
- display: flex;
- align-items: center;
- gap: 2px;
- }
- .stat-extra-up { color: #f56c6c; }
- .stat-extra-down { color: #67c23a; }
- .stat-extra-warning {
- color: #e6a23c;
- font-weight: 600;
- }
- .stat-icon {
- width: 48px;
- height: 48px;
- border-radius: 8px;
- display: flex;
- align-items: center;
- justify-content: center;
- font-size: 24px;
- color: #fff;
- }
- .stat-icon-blue { background: #ecf5ff; color: #409EFF; }
- .stat-icon-red { background: #fef0f0; color: #F56C6C; }
- .stat-icon-green { background: #f0f9eb; color: #67C23A; }
- .stat-icon-orange { background: #fdf6ec; color: #E6A23C; }
- .section-title { font-size: 16px; font-weight: 600; color: #303133; }
- .section-header {
- display: flex;
- align-items: center;
- justify-content: space-between;
- }
- .trend-chart { width: 100%; height: 360px; }
- .readonly-text {
- color: #606266;
- font-size: 14px;
- }
- </style>
|