Browse Source

民意调查页面

main
wang 1 month ago
parent
commit
f1a491c608
  1. 36
      smart-flow-web/src/router/flow/flow.js
  2. 752
      smart-flow-web/src/views/jwpy/jwpy/flow/task-pool.vue
  3. 361
      smart-flow-web/src/views/jwpy/mydc/my-survey/index.vue
  4. 588
      smart-flow-web/src/views/jwpy/mydc/questionnaire/designer.vue
  5. 569
      smart-flow-web/src/views/jwpy/mydc/questionnaire/index.vue
  6. 257
      smart-flow-web/src/views/jwpy/mydc/sample/index.vue
  7. 348
      smart-flow-web/src/views/jwpy/mydc/web-opinion/index.vue

36
smart-flow-web/src/router/flow/flow.js

@ -14,4 +14,40 @@ export const flowRouters = [
}
]
},
{
path: '/mydc',
component: SmartLayout,
children: [
{
path: 'questionnaire',
component: () => import('/@/views/jwpy/mydc/questionnaire/index.vue'),
name: 'QuestionnaireList',
meta: { title: '问卷设计与管理' }
},
{
path: 'questionnaire-designer',
component: () => import('/@/views/jwpy/mydc/questionnaire/designer.vue'),
name: 'QuestionnaireDesigner',
meta: { title: '问卷设计器' }
},
{
path: 'my-survey',
component: () => import('/@/views/jwpy/mydc/my-survey/index.vue'),
name: 'MySurvey',
meta: { title: '我的调查任务' }
},
{
path: 'sample',
component: () => import('/@/views/jwpy/mydc/sample/index.vue'),
name: 'SurveySample',
meta: { title: '调查样本库管理' }
},
{
path: 'web-opinion',
component: () => import('/@/views/jwpy/mydc/web-opinion/index.vue'),
name: 'WebOpinion',
meta: { title: '网络民意监测' }
}
]
}
]

752
smart-flow-web/src/views/jwpy/jwpy/flow/task-pool.vue

@ -0,0 +1,752 @@
<!--
* 警务评议与问题处置 - 任务随机认领大厅
*
* @Author: Antigravity
* @Date: 2026-06-10
-->
<template>
<div class="task-pool-wrapper">
<div class="lobby-grid">
<!-- 左栏控制与领单中心 (稍微变大更显眼) -->
<section class="lobby-left-card">
<header class="lobby-header">
<div class="system-badge">民意感知系统</div>
<h1 class="main-title">任务认领中心</h1>
</header>
<!-- 液晶待领数监控 (稍微放大更清晰) -->
<div class="quantity-monitor-container">
<div class="water-ripple-ring" :class="{ 'is-loading': loading }">
<span class="ring-pulse pulse-1"></span>
<span class="ring-pulse pulse-2"></span>
<div class="inner-display">
<span class="count-label">大池待领工单</span>
<div class="number-glow">
<span v-if="loading" class="loading-dots">...</span>
<span v-else class="count-value">{{ unclaimedCount }}</span>
</div>
<span class="unit"></span>
</div>
</div>
</div>
<!-- 随机领单操作 (按钮稍微变大极简质感) -->
<div class="action-panel">
<a-tooltip :title="!hasClaimPrivilege ? '当前角色无认领权限,仅限市级民意岗和市访评员操作' : ''" placement="top">
<div class="btn-container">
<button
class="random-btn"
:class="{ 'is-disabled': !hasClaimPrivilege || unclaimedCount === 0 }"
:disabled="!hasClaimPrivilege || unclaimedCount === 0 || claiming"
@click="handleRandomClaim"
>
<span class="btn-ripple"></span>
<span class="btn-content">
<template v-if="claiming">
<LoadingOutlined class="btn-icon animate-spin" />
正在派单中...
</template>
<template v-else-if="!hasClaimPrivilege">
<LockOutlined class="btn-icon" />
无权认领
</template>
<template v-else-if="unclaimedCount === 0">
大池已领空
</template>
<template v-else>
<ThunderboltOutlined class="btn-icon" />
随机认领工单
</template>
</span>
</button>
</div>
</a-tooltip>
<div class="claim-rules" v-if="hasClaimPrivilege && unclaimedCount > 0">
<InfoCircleOutlined class="rule-icon" />
<span>系统采用乐观锁随机分派确保派单的公正性与随机性</span>
</div>
</div>
<div class="lobby-footer">
<a-button type="link" @click="fetchLobbyData" class="refresh-link">
<template #icon><ReloadOutlined /></template>
同步最新池状态
</a-button>
</div>
</section>
<!-- 右栏大厅简报与动态播报 -->
<section class="lobby-right-card">
<!-- 看板 1我的今日战绩 -->
<div class="brief-card my-stats">
<h3 class="panel-title"><DashboardOutlined class="title-icon" /> 个人今日概览</h3>
<div class="stats-grid">
<div class="stats-item">
<span class="stat-num text-blue">{{ todayClaimCount }}</span>
<span class="stat-label">今日已领 ()</span>
</div>
<div class="stats-divider"></div>
<div class="stats-item clickable" @click="goToWorkbench">
<span class="stat-num text-purple">{{ workbenchCount }}</span>
<span class="stat-label">待办处理 () <RightOutlined class="arrow" /></span>
</div>
</div>
</div>
<!-- 看板 2大厅实时广播播报 -->
<div class="brief-card real-time-broadcast">
<h3 class="panel-title"><NotificationOutlined class="title-icon" /> 实时认领动态</h3>
<div class="broadcast-list-wrapper">
<div class="broadcast-list">
<div class="broadcast-item" v-for="(item, index) in mockBroadcasts" :key="index">
<span class="b-time">{{ item.time }}</span>
<span class="b-text">
[<strong>{{ item.role }}</strong>] {{ item.name }} 认领了
<a-tag :color="getBizTypeColor(item.bizType)" class="mini-tag">{{ item.bizName }}</a-tag>
<span class="b-slbh">{{ item.slbh.split('-')[2] || item.slbh }}</span>
</span>
</div>
</div>
</div>
</div>
<!-- 看板 3大厅认领规范指引 -->
<div class="brief-card tips-card">
<h3 class="panel-title"><AlertOutlined class="title-icon text-orange" /> 认领工作须知</h3>
<ul class="tips-list">
<li> <strong>时效要求</strong>认领后的工单需在 24 小时内完成首次回访核实</li>
<li>🔒 <strong>数据隔离</strong>系统会自动根据您的属地及警种过滤展示个人名下的待办</li>
</ul>
</div>
</section>
</div>
<!-- 领单成功高级弹窗 -->
<a-modal
v-model:open="successModalVisible"
:footer="null"
:closable="false"
:maskClosable="false"
:width="480"
wrapClassName="success-claim-modal"
>
<div class="success-card">
<div class="success-icon-wrapper">
<CheckCircleFilled class="success-check-icon" />
</div>
<h2 class="success-title">工单认领成功</h2>
<p class="success-msg">已成功将该任务加入您的个人工作台</p>
<!-- 工单简述卡片 -->
<div class="wtcz-brief-card" v-if="claimedWtcz">
<div class="card-item">
<span class="item-label">受理编号</span>
<span class="item-value highlight">{{ claimedWtcz.slbh }}</span>
</div>
<div class="card-item">
<span class="item-label">业务大类</span>
<a-tag color="blue">{{ getBusinessClassificationLabel(claimedWtcz.sjlyNo) }}</a-tag>
</div>
<div class="card-item">
<span class="item-label">涉及单位</span>
<span class="item-value">{{ claimedWtcz.sjdwMc || '暂无涉及单位' }}</span>
</div>
<div class="card-item align-top">
<span class="item-label">诉求内容</span>
<span class="item-value summary">{{ claimedWtcz.sqnr || claimedWtcz.wtms || '无诉求描述' }}</span>
</div>
</div>
<!-- 弹窗操作 -->
<div class="modal-actions">
<a-button type="primary" size="large" block @click="goToWorkbench" class="workbench-action-btn">
立即去工作台处置
</a-button>
<a-button size="large" block @click="successModalVisible = false" class="continue-action-btn">
留在认领大厅
</a-button>
</div>
</div>
</a-modal>
</div>
</template>
<script setup>
import { ref, onMounted, computed } from 'vue';
import { useRouter } from 'vue-router';
import { message } from 'ant-design-vue';
import {
ThunderboltOutlined,
ReloadOutlined,
CheckCircleFilled,
InfoCircleOutlined,
DashboardOutlined,
NotificationOutlined,
AlertOutlined,
RightOutlined
} from '@ant-design/icons-vue';
import { wtczApi } from '/@/api/business/jwpy/wtcz-api';
import { useUserStore } from '/@/store/modules/system/user';
const router = useRouter();
const userStore = useUserStore();
//
const unclaimedCount = ref(0);
const loading = ref(false);
const claiming = ref(false);
const successModalVisible = ref(false);
const claimedWtcz = ref(null);
// (Mock)
const todayClaimCount = ref(3);
const workbenchCount = ref(0);
// ( task:claim)
const hasClaimPrivilege = computed(() => {
return userStore.administratorFlag || userStore.pointsList.includes('task:claim');
});
//
const fetchLobbyData = async () => {
if (loading.value) return;
loading.value = true;
try {
// 1.
const res = await wtczApi.getUnclaimedCount();
if (res.data !== undefined) {
unclaimedCount.value = Number(res.data);
}
// 2.
const wbRes = await wtczApi.queryPage({
processorId: userStore.employeeId,
assignStatus: 1,
pageNum: 1,
pageSize: 1
});
if (wbRes.data && wbRes.data.total !== undefined) {
workbenchCount.value = wbRes.data.total;
}
} catch (err) {
message.error('同步大厅数据失败');
} finally {
loading.value = false;
}
};
//
const handleRandomClaim = async () => {
if (claiming.value) return;
claiming.value = true;
try {
const res = await wtczApi.claimRandom();
if (res.success && res.data) {
claimedWtcz.value = res.data;
successModalVisible.value = true;
todayClaimCount.value += 1;
//
await fetchLobbyData();
} else {
message.warning(res.msg || '大厅已被领空,或正在高并发操作中,请稍后刷新重试');
}
} catch (err) {
message.error('认领请求失败,请稍后重试');
} finally {
claiming.value = false;
}
};
//
const getBusinessClassificationLabel = (sjlyNo) => {
const map = {
'110': '110接处警',
'hz': '户政窗口',
'crj': '出入境',
'cjg': '车驾管',
'ajbl': '案件办理'
};
return map[sjlyNo] || sjlyNo || '常规工单';
};
const getBizTypeColor = (bizType) => {
const map = {
'110': 'red',
'hz': 'blue',
'crj': 'purple',
'cjg': 'green',
'ajbl': 'orange'
};
return map[bizType] || 'cyan';
};
//
const goToWorkbench = () => {
successModalVisible.value = false;
router.push('/jwpy/flow/my-workbench');
};
// 广 (Mock )
const mockBroadcasts = ref([
{ time: '17:18', role: '市局民意岗', name: '张警官', bizType: '110', bizName: '110接处警', slbh: 'GD-110-2026061001' },
{ time: '17:15', role: '市访评员', name: '李回访', bizType: 'hz', bizName: '户政窗口', slbh: 'GD-hz-2026061034' },
{ time: '17:08', role: '支队承办岗', name: '王所长', bizType: 'crj', bizName: '出入境', slbh: 'GD-crj-2026061019' }
]);
onMounted(() => {
fetchLobbyData();
});
</script>
<style scoped>
/* 容器全屏且高度自适应,锁死 overflow 绝对防滚动条 */
.task-pool-wrapper {
width: 100%;
height: calc(100vh - 130px);
min-height: 540px;
max-height: calc(100vh - 130px);
display: flex;
justify-content: center;
align-items: center;
padding: 16px 40px;
/* 优化极光背景渐变,使四周看起来不单调、饱满优雅 */
background: radial-gradient(circle at 15% 20%, rgba(24, 144, 255, 0.08) 0%, transparent 45%),
radial-gradient(circle at 85% 80%, rgba(114, 46, 209, 0.08) 0%, transparent 45%),
#f0f2f5;
position: relative;
overflow: hidden;
box-sizing: border-box;
}
/* 饱满和谐的网格布局,拓宽最大宽度,解决四周空白问题 */
.lobby-grid {
width: 100%;
max-width: 1260px;
height: 100%;
max-height: 510px;
display: grid;
grid-template-columns: 1.15fr 1.05fr;
gap: 32px;
z-index: 2;
box-sizing: border-box;
}
/* 舒展大气的玻璃卡片 */
.lobby-left-card,
.lobby-right-card {
background: rgba(255, 255, 255, 0.82);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid rgba(255, 255, 255, 0.6);
border-radius: 24px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.02);
padding: 32px 40px;
display: flex;
flex-direction: column;
justify-content: space-between;
height: 100%;
box-sizing: border-box;
}
.lobby-right-card {
padding: 24px 32px;
}
/* 左侧核心控制面板 */
.lobby-left-card {
align-items: center;
}
/* 标题区 */
.lobby-header {
text-align: center;
}
.system-badge {
display: inline-block;
padding: 4px 12px;
background: rgba(24, 144, 255, 0.08);
color: #1890ff;
border-radius: 12px;
font-size: 11px;
font-weight: 600;
margin-bottom: 6px;
}
.main-title {
font-size: 26px;
font-weight: 700;
color: #1f1f1f;
margin: 0;
}
/* 待领数量监控圆环 */
.quantity-monitor-container {
position: relative;
display: flex;
justify-content: center;
align-items: center;
}
.water-ripple-ring {
width: 196px;
height: 196px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.9);
border: 4px solid rgba(24, 144, 255, 0.08);
display: flex;
justify-content: center;
align-items: center;
position: relative;
box-shadow: 0 8px 20px rgba(24, 144, 255, 0.04);
}
.ring-pulse {
position: absolute;
top: -4px;
left: -4px;
width: 204px;
height: 204px;
border-radius: 50%;
border: 1px solid rgba(24, 144, 255, 0.2);
animation: pulse-ring 3s cubic-bezier(0.215, 0.610, 0.355, 1) infinite;
pointer-events: none;
}
.pulse-2 {
animation-delay: 1.5s;
}
@keyframes pulse-ring {
0% { transform: scale(0.95); opacity: 1; }
100% { transform: scale(1.12); opacity: 0; }
}
.inner-display {
display: flex;
flex-direction: column;
align-items: center;
z-index: 3;
}
.count-label {
font-size: 11px;
color: #8c8c8c;
margin-bottom: 2px;
}
.number-glow {
height: 64px;
display: flex;
align-items: center;
}
.count-value {
font-size: 58px;
font-weight: 800;
font-family: 'Outfit', sans-serif;
background: linear-gradient(135deg, #1890ff, #722ed1);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
line-height: 1;
}
.loading-dots {
font-size: 26px;
color: #1890ff;
}
.unit {
font-size: 11px;
color: #8c8c8c;
margin-top: 1px;
}
/* 操作面板 */
.action-panel {
width: 100%;
max-width: 320px;
text-align: center;
}
.btn-container {
width: 100%;
margin-bottom: 8px;
}
.random-btn {
width: 100%;
height: 52px;
border: none;
border-radius: 26px;
background: linear-gradient(135deg, #1890ff 0%, #722ed1 100%);
color: #ffffff;
font-size: 16px;
font-weight: 600;
cursor: pointer;
outline: none;
position: relative;
overflow: hidden;
transition: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
box-shadow: 0 6px 16px rgba(114, 46, 209, 0.15);
display: flex;
align-items: center;
justify-content: center;
}
.random-btn:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: 0 8px 20px rgba(114, 46, 209, 0.25);
}
.random-btn:active:not(:disabled) {
transform: translateY(1px);
}
.random-btn.is-disabled {
background: #f5f5f5 !important;
color: #bfbfbf !important;
cursor: not-allowed !important;
box-shadow: none !important;
border: 1px solid #d9d9d9;
}
.btn-icon {
margin-right: 6px;
font-size: 17px;
}
.claim-rules {
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
font-size: 11px;
color: #8c8c8c;
}
.rule-icon {
color: #1890ff;
}
.lobby-footer {
width: 100%;
text-align: center;
}
.refresh-link {
color: #8c8c8c;
font-size: 12px;
}
.refresh-link:hover {
color: #1890ff;
}
/* 右侧面板 */
.lobby-right-card {
gap: 12px;
}
.brief-card {
background: rgba(255, 255, 255, 0.8);
border: 1px solid rgba(255, 255, 255, 0.7);
border-radius: 16px;
padding: 14px 18px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.01);
}
.panel-title {
font-size: 13px;
font-weight: 700;
color: #595959;
margin-bottom: 6px;
display: flex;
align-items: center;
gap: 6px;
border-bottom: 1px dashed rgba(0, 0, 0, 0.05);
padding-bottom: 6px;
}
.title-icon {
font-size: 14px;
color: #1890ff;
}
/* 个人今日数据统计 */
.stats-grid {
display: flex;
justify-content: space-around;
align-items: center;
padding: 2px 0;
}
.stats-item {
display: flex;
flex-direction: column;
align-items: center;
}
.stats-item.clickable {
cursor: pointer;
}
.stats-item.clickable:hover .stat-num {
transform: translateY(-1px);
}
.stat-num {
font-size: 24px;
font-weight: 800;
font-family: 'Outfit', sans-serif;
line-height: 1.2;
transition: all 0.3s ease;
}
.text-blue {
color: #1890ff;
}
.text-purple {
color: #722ed1;
}
.stat-label {
font-size: 11px;
color: #8c8c8c;
display: flex;
align-items: center;
gap: 2px;
}
.arrow {
font-size: 8px;
color: #bfbfbf;
}
.stats-divider {
width: 1px;
height: 28px;
background: rgba(0, 0, 0, 0.05);
}
/* 实时动态播报 */
.broadcast-list-wrapper {
max-height: 90px;
overflow-y: auto;
}
.broadcast-item {
display: flex;
align-items: center;
gap: 8px;
font-size: 11px;
line-height: 1.4;
padding: 4px 0;
border-bottom: 1px solid rgba(0, 0, 0, 0.01);
}
.broadcast-item:last-child {
border-bottom: none;
}
.b-time {
color: #bfbfbf;
font-family: monospace;
}
.b-text {
color: #595959;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex: 1;
}
.mini-tag {
font-size: 9px;
padding: 0 4px;
height: 15px;
line-height: 13px;
margin: 0 2px;
}
.b-slbh {
font-family: monospace;
color: #8c8c8c;
}
/* 工作须知 */
.tips-list {
padding-left: 12px;
margin: 0;
display: flex;
flex-direction: column;
gap: 3px;
}
.tips-list li {
font-size: 11px;
color: #595959;
line-height: 1.4;
}
.text-orange {
color: #fa8c16 !important;
}
/* 成功弹窗 */
.success-claim-modal :deep(.ant-modal-content) {
border-radius: 16px;
padding: 24px 20px;
}
.success-card {
text-align: center;
display: flex;
flex-direction: column;
align-items: center;
}
.success-icon-wrapper {
width: 56px;
height: 56px;
background: rgba(82, 196, 26, 0.1);
border-radius: 50%;
display: flex;
justify-content: center;
align-items: center;
margin-bottom: 12px;
}
.success-check-icon {
font-size: 36px;
color: #52c41a;
}
.success-title {
font-size: 18px;
font-weight: 700;
color: #262626;
margin-bottom: 4px;
}
.success-msg {
font-size: 12px;
color: #8c8c8c;
margin-bottom: 16px;
}
.wtcz-brief-card {
width: 100%;
background: #f8f9fc;
border: 1px solid #eef1f6;
border-radius: 12px;
padding: 12px;
text-align: left;
margin-bottom: 16px;
display: flex;
flex-direction: column;
gap: 6px;
}
.card-item {
display: flex;
align-items: center;
font-size: 12px;
line-height: 1.4;
}
.card-item.align-top {
align-items: flex-start;
}
.item-label {
color: #8c8c8c;
width: 65px;
flex-shrink: 0;
}
.item-value {
color: #262626;
font-weight: 500;
}
.item-value.highlight {
font-family: monospace;
font-weight: 700;
color: #1890ff;
font-size: 12px;
}
.item-value.summary {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
color: #595959;
}
.modal-actions {
width: 100%;
display: flex;
flex-direction: column;
gap: 10px;
}
.workbench-action-btn,
.continue-action-btn {
border-radius: 12px;
height: 38px;
font-weight: 600;
}
.continue-action-btn {
color: #595959;
border-color: #d9d9d9;
}
</style>

361
smart-flow-web/src/views/jwpy/mydc/my-survey/index.vue

@ -1,33 +1,358 @@
<!--
* 警务评议与问题处置 - 我的调查任务
*
* @Author: Antigravity
* @Date: 2026-06-10
-->
<template>
<div class="page-container">
<div class="glass-card">
<div class="header">
<a-space size="middle">
<div class="icon-wrapper">
<SolutionOutlined class="icon" />
</div>
<div>
<h2 class="title">我的调查任务</h2>
<p class="subtitle">基层民警或专职调查员在此认领填报以及提交派发给自身的问卷调查任务</p>
</div>
</a-space>
</div>
<!-- 今日任务概览卡片 -->
<a-row :gutter="16" style="margin-bottom: 16px">
<a-col :span="6">
<a-card :bordered="false" class="status-card-simple">
<div class="card-label">今日待回访样本</div>
<div class="card-value text-blue">14 <span class="unit"></span></div>
</a-card>
</a-col>
<a-col :span="6">
<a-card :bordered="false" class="status-card-simple">
<div class="card-label">今日已成功联络</div>
<div class="card-value text-green">18 <span class="unit"></span></div>
</a-card>
</a-col>
<a-col :span="6">
<a-card :bordered="false" class="status-card-simple">
<div class="card-label">拒访/空号率</div>
<div class="card-value text-orange">8.4 <span class="unit">%</span></div>
</a-card>
</a-col>
<a-col :span="6">
<a-card :bordered="false" class="status-card-simple">
<div class="card-label">当前满意率 (今日)</div>
<div class="card-value text-purple">92.6 <span class="unit">%</span></div>
</a-card>
</a-col>
</a-row>
<!-- 任务列表 -->
<div class="glass-card">
<div class="content-box">
<div class="table-placeholder">
<a-empty description="我的问卷调查填报表单及任务列表加载中" />
</div>
<a-form layout="inline" class="query-form">
<a-form-item label="群众姓名">
<a-input v-model:value="queryForm.name" placeholder="请输入姓名" style="width: 140px" allowClear />
</a-form-item>
<a-form-item label="任务状态">
<a-select v-model:value="queryForm.status" placeholder="请选择" style="width: 120px" allowClear>
<a-select-option value="0">待调查</a-select-option>
<a-select-option value="1">调查成功</a-select-option>
<a-select-option value="2">电话无人接听</a-select-option>
<a-select-option value="3">明确拒访</a-select-option>
</a-select>
</a-form-item>
<a-form-item>
<a-button type="primary" @click="onSearch">查询</a-button>
<a-button style="margin-left: 8px" @click="resetQuery">重置</a-button>
</a-form-item>
</a-form>
<a-table
:dataSource="tableData"
:columns="columns"
rowKey="id"
bordered
:pagination="false"
class="custom-table"
>
<template #bodyCell="{ text, record, column }">
<!-- 电话号码脱敏展示 -->
<template v-if="column.dataIndex === 'phone'">
<span>{{ text.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2') }}</span>
</template>
<!-- 关联警务业务 -->
<template v-if="column.dataIndex === 'sjlyNo'">
<a-tag :color="getBizTypeColor(text)">
<DictLabel :dict-code="DICT_CODE_ENUM.BUSINESS_CLASSIFICATION" :data-value="text" />
</a-tag>
</template>
<!-- 状态 -->
<template v-if="column.dataIndex === 'status'">
<a-tag :color="getStatusColor(text)">
{{ getStatusText(text) }}
</a-tag>
</template>
<!-- 操作 -->
<template v-if="column.dataIndex === 'action'">
<div class="smart-table-operate">
<a-button type="link" @click="handleCall(record)">
<template #icon><PhoneOutlined /></template>
拨打电话
</a-button>
<a-button type="link" :disabled="record.status === 1" @click="handleFill(record)">
<template #icon><FormOutlined /></template>
登记答卷
</a-button>
</div>
</template>
</template>
</a-table>
</div>
</div>
<!-- 登记答卷弹窗 -->
<a-modal
v-model:open="fillVisible"
title="民意测评问卷填报"
@ok="handleSubmitAnswers"
:width="600"
ok-text="提交答卷"
cancel-text="取消"
>
<div class="survey-form-container" style="padding: 16px 0" v-if="activeSurvey">
<h3 style="font-weight: 700; margin-bottom: 16px; text-align: center">
{{ activeSurvey.title }}
</h3>
<a-form layout="vertical">
<a-form-item label="群众受访态度" required>
<a-radio-group v-model:value="answerForm.respondentAttitude">
<a-radio value="1">配合调查</a-radio>
<a-radio value="2">态度冷淡</a-radio>
<a-radio value="3">言语粗暴</a-radio>
</a-radio-group>
</a-form-item>
<a-divider />
<!-- 模拟渲染问卷题目 -->
<div v-for="(q, idx) in activeQuestions" :key="q.id" style="margin-bottom: 20px">
<div style="font-weight: 600; margin-bottom: 10px; font-size: 14px">
<span style="color: red" v-if="q.required">*</span>
Q{{ idx + 1 }}. {{ q.title }}
</div>
<!-- 打分 -->
<div v-if="q.type === 'rate'">
<a-rate v-model:value="answerForm.answers[q.id]" />
</div>
<!-- 单选 -->
<div v-if="q.type === 'radio'">
<a-radio-group v-model:value="answerForm.answers[q.id]" style="width: 100%">
<a-radio v-for="opt in q.options" :key="opt.label" :value="opt.label" style="display: block; margin: 6px 0">
{{ opt.label }} <span style="color: #bfbfbf; font-size: 11px">(分值: {{ opt.score }})</span>
</a-radio>
</a-radio-group>
</div>
<!-- 多选 -->
<div v-if="q.type === 'checkbox'">
<a-checkbox-group v-model:value="answerForm.answers[q.id]" style="width: 100%">
<a-checkbox v-for="opt in q.options" :key="opt.label" :value="opt.label" style="display: block; margin: 6px 0">
{{ opt.label }}
</a-checkbox>
</a-checkbox-group>
</div>
<!-- 多行文本 -->
<div v-if="q.type === 'textarea'">
<a-textarea v-model:value="answerForm.answers[q.id]" placeholder="请输入受访人具体意见与诉求描述..." :rows="3" />
</div>
</div>
</a-form>
</div>
</a-modal>
</div>
</template>
<script setup>
import { SolutionOutlined } from '@ant-design/icons-vue';
import { ref, reactive, onMounted } from 'vue';
import { message, Modal } from 'ant-design-vue';
import { PhoneOutlined, FormOutlined } from '@ant-design/icons-vue';
import DictLabel from '/@/components/support/dict-label/index.vue';
import { DICT_CODE_ENUM } from '/@/constants/support/dict-const';
const queryForm = reactive({
name: '',
status: undefined
});
const tableData = ref([]);
const fillVisible = ref(false);
const activeSurvey = ref(null);
const activeQuestions = ref([]);
const currentRecord = ref(null);
const answerForm = reactive({
respondentAttitude: '1',
answers: {}
});
const columns = [
{ title: '任务ID', dataIndex: 'id', width: 120, align: 'center' },
{ title: '受访群众', dataIndex: 'name', width: 100, align: 'center' },
{ title: '电话号码', dataIndex: 'phone', width: 130, align: 'center' },
{ title: '关联业务', dataIndex: 'sjlyNo', width: 140, align: 'center' },
{ title: '呼叫次数', dataIndex: 'callCount', width: 90, align: 'center' },
{ title: '状态', dataIndex: 'status', width: 130, align: 'center' },
{ title: '匹配问卷模板', dataIndex: 'surveyTitle', align: 'left' },
{ title: '分配时间', dataIndex: 'assignTime', width: 160, align: 'center' },
{ title: '操作', dataIndex: 'action', width: 200, align: 'center' }
];
const mockTasks = [
{ id: 'T9001', name: '张建国', phone: '13812345678', sjlyNo: '110', callCount: 0, status: 0, surveyTitle: '110接处警群众满意度回访问卷', assignTime: '2026-06-10 16:00:00' },
{ id: 'T9002', name: '李爱莲', phone: '13987654321', sjlyNo: 'hz', callCount: 1, status: 2, surveyTitle: '户政窗口办事满意度测评问卷', assignTime: '2026-06-10 16:05:00' },
{ id: 'T9003', name: '赵宏图', phone: '13555556666', sjlyNo: 'cjg', callCount: 0, status: 0, surveyTitle: '车驾管服务窗口满意度评议问卷', assignTime: '2026-06-10 16:10:00' },
{ id: 'T9004', name: '刘小青', phone: '18899998888', sjlyNo: '110', callCount: 1, status: 1, surveyTitle: '110接处警群众满意度回访问卷', assignTime: '2026-06-10 16:15:00' }
];
const loadTasks = () => {
const localTasks = localStorage.getItem('local_survey_tasks');
if (!localTasks) {
localStorage.setItem('local_survey_tasks', JSON.stringify(mockTasks));
tableData.value = mockTasks;
} else {
const list = JSON.parse(localTasks);
tableData.value = list.filter(item => {
let matched = true;
if (queryForm.name && !item.name.includes(queryForm.name)) matched = false;
if (queryForm.status && item.status.toString() !== queryForm.status) matched = false;
return matched;
});
}
};
const getBizTypeColor = (bizType) => {
const map = { '110': 'red', 'hz': 'blue', 'crj': 'purple', 'cjg': 'green', 'ajbl': 'orange' };
return map[bizType] || 'cyan';
};
const getStatusColor = (status) => {
const map = { 0: 'blue', 1: 'green', 2: 'orange', 3: 'red' };
return map[status] || 'default';
};
const getStatusText = (status) => {
const map = { 0: '待调查', 1: '调查成功', 2: '无人接听', 3: '拒访' };
return map[status] || '待调查';
};
const onSearch = () => {
loadTasks();
};
const resetQuery = () => {
queryForm.name = '';
queryForm.status = undefined;
loadTasks();
};
const handleCall = (record) => {
Modal.info({
title: '网络软电话呼叫中',
content: `正在为您呼叫群众 [${record.name}] 的手机:${record.phone.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2')}...`,
okText: '挂断',
onOk() {
const list = JSON.parse(localStorage.getItem('local_survey_tasks') || '[]');
const index = list.findIndex(item => item.id === record.id);
if (index !== -1) {
list[index].callCount += 1;
if (list[index].status === 0) {
list[index].status = 2;
}
localStorage.setItem('local_survey_tasks', JSON.stringify(list));
loadTasks();
message.info('呼叫已挂断,系统已更新呼叫计数');
}
}
});
};
const handleFill = (record) => {
currentRecord.value = record;
const templates = JSON.parse(localStorage.getItem('local_surveys') || '[]');
let template = templates.find(t => t.sjlyNo === record.sjlyNo);
if (!template) {
template = templates[0];
}
if (template) {
activeSurvey.value = template;
try {
activeQuestions.value = JSON.parse(template.content);
} catch (e) {
activeQuestions.value = [];
}
answerForm.answers = {};
activeQuestions.value.forEach(q => {
answerForm.answers[q.id] = undefined;
});
answerForm.respondentAttitude = '1';
fillVisible.value = true;
} else {
message.warning('尚未设计该业务类别的问卷模板,请先设计问卷');
}
};
const handleSubmitAnswers = () => {
const list = JSON.parse(localStorage.getItem('local_survey_tasks') || '[]');
const index = list.findIndex(item => item.id === currentRecord.value.id);
if (index !== -1) {
list[index].status = 1;
localStorage.setItem('local_survey_tasks', JSON.stringify(list));
message.success('测评答卷保存成功,已进入满意度统计库!');
fillVisible.value = false;
loadTasks();
}
};
onMounted(() => {
loadTasks();
});
</script>
<style scoped>
.icon-wrapper {
background: linear-gradient(135deg, #d4380d 0%, #ff7a45 100%);
.glass-card {
background: #ffffff;
border-radius: 12px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.02);
padding: 24px;
border: 1px solid #f0f0f0;
}
.status-card-simple {
background: #ffffff;
border-radius: 12px;
padding: 16px 20px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.02);
border: 1px solid #f0f0f0;
}
.card-label {
font-size: 12px;
color: #8c8c8c;
margin-bottom: 6px;
}
.card-value {
font-size: 26px;
font-weight: 700;
}
.unit {
font-size: 12px;
color: #bfbfbf;
font-weight: normal;
margin-left: 2px;
}
.text-blue { color: #1890ff; }
.text-green { color: #52c41a; }
.text-orange { color: #fa8c16; }
.text-purple { color: #722ed1; }
</style>

588
smart-flow-web/src/views/jwpy/mydc/questionnaire/designer.vue

@ -0,0 +1,588 @@
<!--
* 警务评议与问题处置 - 可视化问卷设计画布
*
* @Author: Antigravity
* @Date: 2026-06-10
-->
<template>
<div class="page-container flex-layout">
<!-- 顶部状态导航栏 -->
<div class="designer-header">
<div class="left-info">
<a-button @click="handleBack" class="back-btn">
<template #icon><ArrowLeftOutlined /></template>
返回列表
</a-button>
<span class="divider">|</span>
<span class="survey-title">正在设计<strong>{{ surveyInfo.title || '测评问卷' }}</strong></span>
<a-tag :color="getBizTypeColor(surveyInfo.sjlyNo)" style="margin-left: 8px">
<DictLabel :dict-code="DICT_CODE_ENUM.BUSINESS_CLASSIFICATION" :data-value="surveyInfo.sjlyNo" />
</a-tag>
</div>
<div class="right-actions">
<a-button type="primary" @click="handleSave" :loading="saving">
<template #icon><SaveOutlined /></template>
保存问卷设计
</a-button>
</div>
</div>
<!-- 主设计区域 (左右分栏布局) -->
<div class="designer-main">
<!-- 左侧题型控件区 -->
<div class="left-panel">
<h3 class="panel-title">添加题目控件</h3>
<div class="widget-list">
<div class="widget-item" @click="addQuestion('rate')">
<span class="widget-icon"><StarOutlined /></span>
<span class="widget-label">星级打分题</span>
</div>
<div class="widget-item" @click="addQuestion('radio')">
<span class="widget-icon"><BorderInnerOutlined /></span>
<span class="widget-label">单项选择题</span>
</div>
<div class="widget-item" @click="addQuestion('checkbox')">
<span class="widget-icon"><AppstoreOutlined /></span>
<span class="widget-label">多项选择题</span>
</div>
<div class="widget-item" @click="addQuestion('textarea')">
<span class="widget-icon"><FormOutlined /></span>
<span class="widget-label">群众意见框 (多行文本)</span>
</div>
</div>
<div class="designer-tips-card">
<div class="tips-title"><InfoCircleOutlined /> 逻辑跳转与满意度规则</div>
<p class="tips-text">1. 在选择题中可以为不同的选项配置跳转逻辑如不满意自动跳到特定细化题</p>
<p class="tips-text">2. 可以为每个单选项设置对应的选项分值系统在提交时会自动求平均值和累加</p>
</div>
</div>
<!-- 右侧设计画布区 -->
<div class="canvas-panel">
<div v-if="questions.length === 0" class="empty-canvas">
<a-empty description="画布中空空如也,请点击左侧题型控件添加题目" />
</div>
<div v-else class="question-list">
<div
v-for="(q, index) in questions"
:key="q.id"
class="question-card"
:class="{ 'is-active': activeQuestionId === q.id }"
@click="activeQuestionId = q.id"
>
<!-- 题目头部工具栏 -->
<div class="q-card-header">
<span class="q-number">Q{{ index + 1 }}</span>
<span class="q-type-badge">{{ getQuestionTypeLabel(q.type) }}</span>
<div class="q-actions" @click.stop>
<a-button type="text" size="small" :disabled="index === 0" @click="moveQuestion(index, -1)">
<template #icon><UpOutlined /></template>
</a-button>
<a-button type="text" size="small" :disabled="index === questions.length - 1" @click="moveQuestion(index, 1)">
<template #icon><DownOutlined /></template>
</a-button>
<a-popconfirm title="确认删除此题目吗?" @confirm="deleteQuestion(q.id)">
<a-button type="text" size="small" danger>
<template #icon><DeleteOutlined /></template>
</a-button>
</a-popconfirm>
</div>
</div>
<!-- 题目内容编辑 -->
<div class="q-card-body">
<div class="q-title-row">
<a-input v-model:value="q.title" placeholder="请输入题目名称,例如:您对民警的办事效率是否满意?" class="q-title-input" />
<a-checkbox v-model:checked="q.required" class="q-req-checkbox">必填</a-checkbox>
</div>
<!-- 打分题特有展示 -->
<div v-if="q.type === 'rate'" class="q-body-content">
<a-rate :value="5" disabled />
<span class="hint-text">( 默认五星满分一星扣2分 )</span>
</div>
<!-- 选择题特有选项配置 (支持分值跳题逻辑) -->
<div v-if="q.type === 'radio' || q.type === 'checkbox'" class="q-body-content">
<div class="options-header">
<span class="opt-label-head">选项文字</span>
<span class="opt-score-head">对应分值</span>
<span class="opt-jump-head">选项跳题跳转逻辑 (选中时)</span>
<span class="opt-del-head">操作</span>
</div>
<div v-for="(opt, oIndex) in q.options" :key="oIndex" class="option-row">
<a-input v-model:value="opt.label" placeholder="选项文字" class="opt-input" />
<a-input-number v-model:value="opt.score" :min="0" :max="100" class="opt-score-input" placeholder="分值" />
<a-select v-model:value="opt.logicJump" placeholder="常规下一题" class="opt-jump-select">
<a-select-option value="">常规跳转下一题</a-select-option>
<template v-for="(otherQ, otherIndex) in questions">
<a-select-option v-if="otherIndex > index" :key="otherQ.id" :value="otherQ.id">
跳至 Q{{ otherIndex + 1 }} ({{ otherQ.title.substring(0, 10) }}...)
</a-select-option>
</template>
<a-select-option value="end">直接结束问卷</a-select-option>
</a-select>
<a-button type="text" danger @click="deleteOption(q, oIndex)" class="opt-del-btn">
<template #icon><MinusCircleOutlined /></template>
</a-button>
</div>
<a-button type="dashed" block @click="addOption(q)" style="margin-top: 8px">
<template #icon><PlusOutlined /></template>
添加新选项
</a-button>
</div>
<!-- 多行文本题特有展示 -->
<div v-if="q.type === 'textarea'" class="q-body-content">
<a-textarea placeholder="群众意见输入框占位符预览..." :rows="2" disabled />
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { message } from 'ant-design-vue';
import {
ArrowLeftOutlined,
SaveOutlined,
StarOutlined,
BorderInnerOutlined,
AppstoreOutlined,
FormOutlined,
InfoCircleOutlined,
UpOutlined,
DownOutlined,
DeleteOutlined,
MinusCircleOutlined,
PlusOutlined
} from '@ant-design/icons-vue';
import DictLabel from '/@/components/support/dict-label/index.vue';
import { DICT_CODE_ENUM } from '/@/constants/support/dict-const';
const route = useRoute();
const router = useRouter();
const surveyId = ref(null);
const surveyInfo = ref({});
const questions = ref([]);
const activeQuestionId = ref(null);
const saving = ref(false);
//
const loadSurveyDetails = () => {
surveyId.value = Number(route.query.id);
if (!surveyId.value) {
message.error('未指定问卷模板ID');
router.push('/mydc/questionnaire');
return;
}
const localSurveys = JSON.parse(localStorage.getItem('local_surveys') || '[]');
const found = localSurveys.find(item => item.surveyId === surveyId.value);
if (found) {
surveyInfo.value = found;
try {
questions.value = found.content ? JSON.parse(found.content) : [];
if (questions.value.length > 0) {
activeQuestionId.value = questions.value[0].id;
}
} catch (e) {
questions.value = [];
}
} else {
message.error('未找到对应问卷模板,已自动返回列表');
router.push('/mydc/questionnaire');
}
};
//
const addQuestion = (type) => {
const newQ = {
id: 'q_' + Date.now(),
type: type,
title: '',
required: true,
options: []
};
//
if (type === 'radio' || type === 'checkbox') {
newQ.options = [
{ label: '满意', score: 10, logicJump: '' },
{ label: '不满意', score: 2, logicJump: '' }
];
}
questions.value.push(newQ);
activeQuestionId.value = newQ.id;
message.success('已添加新题目');
};
//
const deleteQuestion = (id) => {
questions.value = questions.value.filter(q => q.id !== id);
if (activeQuestionId.value === id && questions.value.length > 0) {
activeQuestionId.value = questions.value[0].id;
}
};
//
const moveQuestion = (index, direction) => {
const targetIndex = index + direction;
if (targetIndex < 0 || targetIndex >= questions.value.length) return;
const temp = questions.value[index];
questions.value[index] = questions.value[targetIndex];
questions.value[targetIndex] = temp;
};
//
const addOption = (q) => {
q.options.push({ label: '新选项', score: 10, logicJump: '' });
};
//
const deleteOption = (q, oIndex) => {
if (q.options.length <= 1) {
message.warning('选项至少保留 1 个');
return;
}
q.options.splice(oIndex, 1);
};
//
const getQuestionTypeLabel = (type) => {
const map = {
'rate': '五星打分题',
'radio': '单项选择题',
'checkbox': '多项选择题',
'textarea': '多行文本框'
};
return map[type] || '未知题型';
};
//
const getBizTypeColor = (bizType) => {
const map = { '110': 'red', 'hz': 'blue', 'crj': 'purple', 'cjg': 'green', 'ajbl': 'orange' };
return map[bizType] || 'cyan';
};
//
const handleSave = () => {
const emptyQ = questions.value.some(q => !q.title.trim());
if (emptyQ) {
message.warning('画布中存在题目名称未填写的题目,请完善后再保存');
return;
}
saving.value = true;
setTimeout(() => {
const localSurveys = JSON.parse(localStorage.getItem('local_surveys') || '[]');
const index = localSurveys.findIndex(item => item.surveyId === surveyId.value);
if (index !== -1) {
localSurveys[index].content = JSON.stringify(questions.value);
localStorage.setItem('local_surveys', JSON.stringify(localSurveys));
message.success('问卷设计保存成功!已序列化为 JSON 写入数据库大字段');
handleBack();
} else {
message.error('保存失败:找不到问卷记录');
}
saving.value = false;
}, 400);
};
//
const handleBack = () => {
router.push('/mydc/questionnaire');
};
onMounted(() => {
loadSurveyDetails();
});
</script>
<style scoped>
/* 全屏弹性高度,锁定滚动条 */
.flex-layout {
display: flex;
flex-direction: column;
height: calc(100vh - 120px);
overflow: hidden;
padding: 16px;
background-color: #f0f2f5;
box-sizing: border-box;
}
/* 顶部状态栏 */
.designer-header {
height: 52px;
background: #ffffff;
border-radius: 8px;
padding: 0 16px;
display: flex;
justify-content: space-between;
align-items: center;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
margin-bottom: 12px;
flex-shrink: 0;
}
.left-info {
display: flex;
align-items: center;
}
.back-btn {
font-weight: 500;
}
.divider {
margin: 0 12px;
color: #d9d9d9;
}
.survey-title {
font-size: 15px;
color: #262626;
}
.right-actions {
display: flex;
gap: 8px;
}
/* 主画布 */
.designer-main {
display: flex;
flex: 1;
gap: 12px;
overflow: hidden;
}
/* 左侧:题型控件区 */
.left-panel {
width: 260px;
background: #ffffff;
border-radius: 8px;
padding: 16px;
display: flex;
flex-direction: column;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
flex-shrink: 0;
}
.panel-title {
font-size: 14px;
font-weight: 700;
color: #595959;
margin-bottom: 12px;
}
.widget-list {
display: grid;
grid-template-columns: 1fr;
gap: 10px;
}
.widget-item {
display: flex;
align-items: center;
padding: 12px 16px;
background: #fafafa;
border: 1px dashed #d9d9d9;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s ease;
}
.widget-item:hover {
background: #e6f7ff;
border-color: #1890ff;
color: #1890ff;
}
.widget-icon {
font-size: 16px;
margin-right: 10px;
display: flex;
align-items: center;
}
.widget-label {
font-size: 13px;
font-weight: 600;
}
.designer-tips-card {
margin-top: auto;
background: #fafafa;
border: 1px solid #f0f0f0;
border-radius: 8px;
padding: 12px;
}
.tips-title {
font-size: 12px;
font-weight: 700;
color: #fa8c16;
margin-bottom: 6px;
}
.tips-text {
font-size: 11px;
color: #8c8c8c;
line-height: 1.4;
margin-bottom: 4px;
}
.tips-text:last-child {
margin-bottom: 0;
}
/* 画布面板 */
.canvas-panel {
flex: 1;
background: #ffffff;
border-radius: 8px;
padding: 16px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
overflow-y: auto;
box-sizing: border-box;
}
.empty-canvas {
display: flex;
justify-content: center;
align-items: center;
height: 100%;
}
.question-list {
display: flex;
flex-direction: column;
gap: 14px;
}
/* 题目卡片 */
.question-card {
border: 1px solid #f0f0f0;
border-radius: 8px;
padding: 16px;
background: #fafafa;
transition: all 0.2s ease;
position: relative;
}
.question-card:hover {
border-color: #d9d9d9;
}
.question-card.is-active {
border-color: #1890ff;
background: #ffffff;
box-shadow: 0 4px 12px rgba(24, 144, 255, 0.08);
}
.q-card-header {
display: flex;
align-items: center;
margin-bottom: 12px;
border-bottom: 1px dashed #f0f0f0;
padding-bottom: 8px;
}
.q-number {
font-size: 14px;
font-weight: 800;
color: #1890ff;
margin-right: 8px;
}
.q-type-badge {
font-size: 11px;
background: rgba(0, 0, 0, 0.04);
color: #595959;
padding: 2px 6px;
border-radius: 4px;
}
.q-actions {
margin-left: auto;
display: flex;
gap: 2px;
}
.q-title-row {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 12px;
}
.q-title-input {
flex: 1;
font-weight: 600;
}
.q-req-checkbox {
flex-shrink: 0;
}
.q-body-content {
background: #fafafa;
border-radius: 6px;
padding: 12px;
border: 1px dashed #f0f0f0;
}
.hint-text {
font-size: 12px;
color: #bfbfbf;
margin-left: 10px;
}
/* 选择题选项表格配置 */
.options-header {
display: grid;
grid-template-columns: 2fr 1fr 2.5fr 40px;
gap: 8px;
font-size: 11px;
color: #8c8c8c;
padding-bottom: 4px;
border-bottom: 1px solid #f0f0f0;
margin-bottom: 8px;
}
.option-row {
display: grid;
grid-template-columns: 2fr 1fr 2.5fr 40px;
gap: 8px;
align-items: center;
margin-bottom: 8px;
}
.opt-score-input {
width: 100%;
}
.opt-jump-select {
width: 100%;
}
.opt-del-btn {
display: flex;
justify-content: center;
align-items: center;
}
</style>

569
smart-flow-web/src/views/jwpy/mydc/questionnaire/index.vue

@ -1,33 +1,568 @@
<!--
* 警务评议与问题处置 - 民意调查问卷管理列表
*
* @Author: Antigravity
* @Date: 2026-06-10
-->
<template>
<div class="page-container">
<div class="glass-card">
<div class="header">
<a-space size="middle">
<div class="icon-wrapper">
<FormOutlined class="icon" />
</div>
<div>
<h2 class="title">问卷设计与管理</h2>
<p class="subtitle">用于民意调查问卷的在线可视化设计大区抽样方案及问卷库管理</p>
</div>
</a-space>
</div>
<div class="content-box">
<div class="table-placeholder">
<a-empty description="问卷设计可视化组件及表单设计器初始化中" />
<!-- 头部查询过滤区域 (通过字典组件代替硬编码) -->
<a-form layout="inline" class="query-form">
<a-form-item label="问卷名称">
<a-input v-model:value="queryForm.title" placeholder="请输入问卷名称" style="width: 220px" allowClear />
</a-form-item>
<a-form-item label="关联业务">
<DictSelect
:dict-code="DICT_CODE_ENUM.BUSINESS_CLASSIFICATION"
placeholder="请选择业务分类"
v-model:value="queryForm.sjlyNo"
width="160px"
allowClear
/>
</a-form-item>
<a-form-item label="状态">
<a-select v-model:value="queryForm.status" placeholder="请选择状态" style="width: 120px" allowClear>
<a-select-option :value="0">草稿</a-select-option>
<a-select-option :value="1">启用</a-select-option>
<a-select-option :value="2">禁用</a-select-option>
</a-select>
</a-form-item>
<a-form-item>
<a-button type="primary" @click="onSearch">查询</a-button>
<a-button style="margin-left: 8px" @click="resetQuery">重置</a-button>
</a-form-item>
</a-form>
<!-- 表格顶部操作按钮 -->
<div class="table-operate-row" style="margin-bottom: 12px">
<a-space>
<a-button type="primary" @click="handleCreate">
<template #icon><PlusOutlined /></template>
创建新问卷
</a-button>
</a-space>
</div>
<!-- 数据表格 -->
<a-table
:dataSource="tableData"
:columns="columns"
rowKey="surveyId"
bordered
:loading="tableLoading"
class="custom-table"
:pagination="false"
>
<template #bodyCell="{ text, record, column }">
<!-- 业务类型通过系统 DictLabel 展示 -->
<template v-if="column.dataIndex === 'sjlyNo'">
<a-tag :color="getBizTypeColor(text)">
<DictLabel :dict-code="DICT_CODE_ENUM.BUSINESS_CLASSIFICATION" :data-value="text" />
</a-tag>
</template>
<!-- 状态展示 -->
<template v-if="column.dataIndex === 'status'">
<a-badge :status="getStatusBadgeType(text)" :text="getStatusText(text)" />
</template>
<!-- 问卷题目数展示 -->
<template v-if="column.dataIndex === 'questionCount'">
<span>{{ getQuestionCount(record) }} </span>
</template>
<!-- 操作列 -->
<template v-if="column.dataIndex === 'action'">
<div class="smart-table-operate">
<a-button type="link" @click="handleDesign(record.surveyId)">设计画布</a-button>
<a-button type="link" @click="handlePreview(record)">预览问卷</a-button>
<a-divider type="vertical" />
<a-button v-if="record.status !== 1" type="link" class="text-success" @click="toggleStatus(record, 1)">启用</a-button>
<a-button v-if="record.status === 1" type="link" danger @click="toggleStatus(record, 2)">禁用</a-button>
<a-divider type="vertical" />
<a-popconfirm
title="确认删除该问卷模板吗?"
ok-text="确认"
cancel-text="取消"
@confirm="handleDelete(record.surveyId)"
>
<a-button type="link" danger>删除</a-button>
</a-popconfirm>
</div>
</template>
</template>
</a-table>
<!-- 分页 -->
<div class="smart-query-table-page" style="margin-top: 15px; text-align: right">
<a-pagination
:defaultPageSize="10"
v-model:current="queryForm.pageNum"
v-model:pageSize="queryForm.pageSize"
:total="totalCount"
:show-total="(total) => `共 ${total} 条`"
@change="loadLocalData"
/>
</div>
</div>
</div>
<!-- 创建/修改基础信息 Modal -->
<a-modal
v-model:open="modalVisible"
:title="modalTitle"
@ok="handleSaveInfo"
:destroyOnClose="true"
>
<a-form :model="formState" :rules="formRules" ref="formRef" layout="vertical" style="margin-top: 16px">
<a-form-item label="问卷名称" name="title">
<a-input v-model:value="formState.title" placeholder="请输入问卷名称,例如:110接处警满意度回访问卷" />
</a-form-item>
<a-form-item label="关联业务大类" name="sjlyNo">
<DictSelect
:dict-code="DICT_CODE_ENUM.BUSINESS_CLASSIFICATION"
placeholder="请选择关联业务大类"
v-model:value="formState.sjlyNo"
width="100%"
/>
</a-form-item>
<a-form-item label="备注说明" name="remark">
<a-textarea v-model:value="formState.remark" placeholder="请输入备注说明..." :rows="3" />
</a-form-item>
</a-form>
</a-modal>
<!-- 问卷手机填报端预览 Modal -->
<a-modal
v-model:open="previewVisible"
title="问卷手机填报效果预览"
:footer="null"
:width="420"
wrapClassName="phone-preview-modal"
>
<div class="phone-frame">
<div class="phone-screen">
<div class="phone-header">
<span class="phone-title">{{ previewSurvey?.title || '民意测评问卷' }}</span>
</div>
<div class="phone-content" v-if="previewQuestions.length > 0">
<div class="survey-intro">
您好为了进一步提升我们的政务服务品质耽误您1分钟时间参与本次民意调查测评感谢您的配合
</div>
<div v-for="(q, index) in previewQuestions" :key="q.id" class="preview-q-card">
<div class="q-title">
<span class="q-req" v-if="q.required">*</span>
{{ index + 1 }}. {{ q.title }}
</div>
<!-- 评分打分题 -->
<div v-if="q.type === 'rate'" class="q-body-rate">
<a-rate v-model:value="previewAnswers[q.id]" />
<span class="rate-hint" v-if="previewAnswers[q.id]">{{ previewAnswers[q.id] }} </span>
</div>
<!-- 单选题 -->
<div v-if="q.type === 'radio'" class="q-body-radio">
<a-radio-group v-model:value="previewAnswers[q.id]" style="width: 100%">
<a-radio v-for="opt in q.options" :key="opt.label" :value="opt.label" style="display: block; margin: 8px 0">
{{ opt.label }}
</a-radio>
</a-radio-group>
</div>
<!-- 多选题 -->
<div v-if="q.type === 'checkbox'" class="q-body-checkbox">
<a-checkbox-group v-model:value="previewAnswers[q.id]" style="width: 100%">
<a-checkbox v-for="opt in q.options" :key="opt.label" :value="opt.label" style="display: block; margin: 8px 0">
{{ opt.label }}
</a-checkbox>
</a-checkbox-group>
</div>
<!-- 意见输入 -->
<div v-if="q.type === 'textarea'" class="q-body-text">
<a-textarea placeholder="请填写您的宝贵意见与建议..." :rows="3" v-model:value="previewAnswers[q.id]" />
</div>
</div>
<a-button type="primary" block size="large" style="margin-top: 24px; border-radius: 8px" @click="handleSubmitPreview">
模拟提交问卷
</a-button>
</div>
<div class="phone-content empty-preview" v-else>
<a-empty description="该问卷尚未配置题目,请先前往设计画布进行设计" />
</div>
</div>
</div>
</a-modal>
</div>
</template>
<script setup>
import { FormOutlined } from '@ant-design/icons-vue';
import { ref, reactive, onMounted } from 'vue';
import { useRouter } from 'vue-router';
import { message } from 'ant-design-vue';
import { PlusOutlined } from '@ant-design/icons-vue';
import DictSelect from '/@/components/support/dict-select/index.vue';
import DictLabel from '/@/components/support/dict-label/index.vue';
import { DICT_CODE_ENUM } from '/@/constants/support/dict-const';
const router = useRouter();
//
const queryForm = reactive({
title: '',
sjlyNo: undefined,
status: undefined,
pageNum: 1,
pageSize: 10
});
//
const tableLoading = ref(false);
const tableData = ref([]);
const totalCount = ref(0);
// Modal
const modalVisible = ref(false);
const modalTitle = ref('创建新问卷模板');
const formRef = ref(null);
const formState = reactive({
title: '',
sjlyNo: undefined,
remark: ''
});
const formRules = {
title: [{ required: true, message: '请输入问卷名称', trigger: 'blur' }],
sjlyNo: [{ required: true, message: '请选择关联业务', trigger: 'change' }]
};
//
const previewVisible = ref(false);
const previewSurvey = ref(null);
const previewQuestions = ref([]);
const previewAnswers = ref({});
//
const columns = [
{ title: '问卷ID', dataIndex: 'surveyId', width: 150, align: 'center' },
{ title: '问卷名称', dataIndex: 'title', align: 'left' },
{ title: '关联警务业务', dataIndex: 'sjlyNo', width: 150, align: 'center' },
{ title: '题目数量', dataIndex: 'questionCount', width: 100, align: 'center' },
{ title: '状态', dataIndex: 'status', width: 100, align: 'center' },
{ title: '备注', dataIndex: 'remark', align: 'left', ellipsis: true },
{ title: '创建时间', dataIndex: 'createTime', width: 170, align: 'center' },
{ title: '操作', dataIndex: 'action', width: 280, align: 'center' }
];
// Mock
const initMockData = () => {
const localSurveys = localStorage.getItem('local_surveys');
if (!localSurveys) {
const defaultData = [
{
surveyId: 2026061001,
title: '110接处警群众满意度回访问卷',
sjlyNo: '110',
status: 1,
remark: '针对全市110报警群众的电话/短信随机民意抽评调查问卷。',
createTime: '2026-06-10 12:00:00',
content: JSON.stringify([
{ id: 'q1', type: 'rate', title: '请问您对本次110接警及出警速度是否满意?', required: true, options: [] },
{ id: 'q2', type: 'radio', title: '若您对本次服务感到不满意,主要原因是什么?', required: false, options: [
{ label: '出警不够迅速', score: 2 },
{ label: '接警人员态度生硬', score: 2 },
{ label: '现场处置简单粗暴', score: 2 },
{ label: '后续进展未得到反馈', score: 2 }
]},
{ id: 'q3', type: 'textarea', title: '您对我们的接处警工作还有哪些宝贵的意见或建议?', required: false, options: [] }
])
},
{
surveyId: 2026061002,
title: '车驾管服务窗口满意度评议问卷',
sjlyNo: 'cjg',
status: 0,
remark: '用于车驾管大厅自助服务及窗口办事群众的满意度调查。',
createTime: '2026-06-10 14:30:00',
content: JSON.stringify([
{ id: 'q1', type: 'radio', title: '您对本次办理车驾管业务的整体办事速度是否满意?', required: true, options: [
{ label: '非常满意 (得10分)', score: 10 },
{ label: '基本满意 (得8分)', score: 8 },
{ label: '不满意 (得2分)', score: 2 }
]},
{ id: 'q2', type: 'textarea', title: '您觉得窗口的哪些方面最需要提升改进?', required: false, options: [] }
])
}
];
localStorage.setItem('local_surveys', JSON.stringify(defaultData));
}
};
//
const loadLocalData = () => {
tableLoading.value = true;
setTimeout(() => {
initMockData();
const data = JSON.parse(localStorage.getItem('local_surveys') || '[]');
//
let filtered = data.filter(item => {
let matched = true;
if (queryForm.title && !item.title.includes(queryForm.title)) matched = false;
if (queryForm.sjlyNo && item.sjlyNo !== queryForm.sjlyNo) matched = false;
if (queryForm.status !== undefined && item.status !== queryForm.status) matched = false;
return matched;
});
tableData.value = filtered;
totalCount.value = filtered.length;
tableLoading.value = false;
}, 300);
};
//
const getStatusBadgeType = (status) => {
const map = { 0: 'default', 1: 'success', 2: 'error' };
return map[status] || 'default';
};
const getStatusText = (status) => {
const map = { 0: '草稿', 1: '启用', 2: '已禁用' };
return map[status] || '草稿';
};
// (/)
const getBizTypeColor = (bizType) => {
const map = { '110': 'red', 'hz': 'blue', 'crj': 'purple', 'cjg': 'green', 'ajbl': 'orange' };
return map[bizType] || 'cyan';
};
//
const getQuestionCount = (record) => {
try {
if (!record.content) return 0;
const list = JSON.parse(record.content);
return list.length || 0;
} catch (e) {
return 0;
}
};
//
const toggleStatus = (record, targetStatus) => {
const data = JSON.parse(localStorage.getItem('local_surveys') || '[]');
const index = data.findIndex(item => item.surveyId === record.surveyId);
if (index !== -1) {
data[index].status = targetStatus;
localStorage.setItem('local_surveys', JSON.stringify(data));
message.success(targetStatus === 1 ? '启用成功' : '禁用成功');
loadLocalData();
}
};
//
const onSearch = () => {
loadLocalData();
};
const resetQuery = () => {
queryForm.title = '';
queryForm.sjlyNo = undefined;
queryForm.status = undefined;
loadLocalData();
};
//
const handleCreate = () => {
modalTitle.value = '创建新问卷模板';
formState.title = '';
formState.sjlyNo = undefined;
formState.remark = '';
modalVisible.value = true;
};
//
const handleSaveInfo = () => {
formRef.value.validate().then(() => {
const data = JSON.parse(localStorage.getItem('local_surveys') || '[]');
const newSurvey = {
surveyId: Date.now(),
title: formState.title,
sjlyNo: formState.sjlyNo,
status: 0,
remark: formState.remark,
createTime: new Date().toLocaleString(),
content: JSON.stringify([])
};
data.unshift(newSurvey);
localStorage.setItem('local_surveys', JSON.stringify(data));
message.success('创建问卷基本信息成功,请前往“设计画布”添加题目');
modalVisible.value = false;
loadLocalData();
});
};
//
const handleDelete = (surveyId) => {
const data = JSON.parse(localStorage.getItem('local_surveys') || '[]');
const filtered = data.filter(item => item.surveyId !== surveyId);
localStorage.setItem('local_surveys', JSON.stringify(filtered));
message.success('删除问卷模板成功');
loadLocalData();
};
// ( /mydc/questionnaire-designer )
const handleDesign = (surveyId) => {
router.push({
path: '/mydc/questionnaire-designer',
query: { id: surveyId }
});
};
//
const handlePreview = (record) => {
previewSurvey.value = record;
previewAnswers.value = {};
try {
previewQuestions.value = record.content ? JSON.parse(record.content) : [];
} catch (e) {
previewQuestions.value = [];
}
previewVisible.value = true;
};
//
const handleSubmitPreview = () => {
message.success('答卷模拟提交成功!系统在后台将自动计算满意评分及逻辑跳转');
previewVisible.value = false;
};
onMounted(() => {
loadLocalData();
});
</script>
<style scoped>
.icon-wrapper {
background: linear-gradient(135deg, #fa8c16 0%, #ffd666 100%);
/* 经典白色毛玻璃卡片 */
.glass-card {
background: #ffffff;
border-radius: 12px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.02);
padding: 24px;
border: 1px solid #f0f0f0;
}
.text-success {
color: #52c41a !important;
}
/* 手机预览容器框 - 打造真机质感 */
.phone-frame {
width: 360px;
height: 640px;
background: #2f3640;
border-radius: 36px;
padding: 12px;
margin: 0 auto;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.25);
position: relative;
}
.phone-screen {
width: 100%;
height: 100%;
background: #f5f6fa;
border-radius: 26px;
overflow-y: auto;
display: flex;
flex-direction: column;
box-sizing: border-box;
}
/* 隐藏手机屏内部滚动条 */
.phone-screen::-webkit-scrollbar {
width: 4px;
}
.phone-screen::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.1);
border-radius: 2px;
}
.phone-header {
height: 50px;
background: #1890ff;
color: #ffffff;
display: flex;
justify-content: center;
align-items: center;
flex-shrink: 0;
box-shadow: 0 2px 4px rgba(24, 144, 255, 0.2);
}
.phone-title {
font-size: 14px;
font-weight: 600;
padding: 0 16px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.phone-content {
padding: 16px;
flex: 1;
}
.survey-intro {
font-size: 12px;
color: #7f8c8d;
line-height: 1.6;
background: #ffffff;
border-radius: 8px;
padding: 10px 12px;
margin-bottom: 16px;
border: 1px solid #eef1f6;
}
.preview-q-card {
background: #ffffff;
border-radius: 10px;
padding: 14px;
margin-bottom: 14px;
border: 1px solid #eef1f6;
}
.q-title {
font-size: 13px;
font-weight: 700;
color: #2c3e50;
margin-bottom: 10px;
}
.q-req {
color: #e74c3c;
margin-right: 3px;
}
.rate-hint {
margin-left: 10px;
font-size: 12px;
color: #f1c40f;
font-weight: bold;
}
.empty-preview {
display: flex;
justify-content: center;
align-items: center;
height: 100%;
}
</style>

257
smart-flow-web/src/views/jwpy/mydc/sample/index.vue

@ -1,33 +1,256 @@
<!--
* 警务评议与问题处置 - 调查样本库管理
*
* @Author: Antigravity
* @Date: 2026-06-10
-->
<template>
<div class="page-container">
<div class="glass-card">
<div class="header">
<a-space size="middle">
<div class="icon-wrapper">
<ContactsOutlined class="icon" />
</div>
<div>
<h2 class="title">调查样本库管理</h2>
<p class="subtitle">维护评议和回访电话号码段常住/暂住人口样本及导入抽样黑名单过滤规则</p>
</div>
</a-space>
</div>
<div class="content-box">
<div class="table-placeholder">
<a-empty description="调查抽样样本库物理管理页面导入中" />
<!-- 头部查询 -->
<a-form layout="inline" class="query-form">
<a-form-item label="群众姓名">
<a-input v-model:value="queryForm.name" placeholder="请输入姓名" style="width: 140px" allowClear />
</a-form-item>
<a-form-item label="业务类型">
<DictSelect
:dict-code="DICT_CODE_ENUM.BUSINESS_CLASSIFICATION"
placeholder="请选择业务分类"
v-model:value="queryForm.sjlyNo"
width="160px"
allowClear
/>
</a-form-item>
<a-form-item label="分配状态">
<a-select v-model:value="queryForm.assignStatus" placeholder="请选择" style="width: 120px" allowClear>
<a-select-option :value="0">未分配</a-select-option>
<a-select-option :value="1">已分配</a-select-option>
</a-select>
</a-form-item>
<a-form-item>
<a-button type="primary" @click="onSearch">查询</a-button>
<a-button style="margin-left: 8px" @click="resetQuery">重置</a-button>
</a-form-item>
</a-form>
<!-- 操作按钮 -->
<div class="table-operate-row" style="margin-bottom: 12px">
<a-space>
<a-button type="primary" @click="handleImportFromBusiness">
<template #icon><SyncOutlined /></template>
一键同步警务样本 (110/窗口)
</a-button>
<a-button type="primary" :disabled="selectedRowKeys.length === 0" @click="handleBatchAssign">
<template #icon><UsergroupAddOutlined /></template>
分配回访员 (已选 {{ selectedRowKeys.length }} )
</a-button>
</a-space>
</div>
<!-- 数据表格 -->
<a-table
:dataSource="tableData"
:columns="columns"
rowKey="id"
bordered
:row-selection="{ selectedRowKeys: selectedRowKeys, onChange: onSelectChange }"
:pagination="false"
class="custom-table"
>
<template #bodyCell="{ text, record, column }">
<!-- 电话脱敏 -->
<template v-if="column.dataIndex === 'phone'">
<span>{{ text.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2') }}</span>
</template>
<!-- 关联警务业务 -->
<template v-if="column.dataIndex === 'sjlyNo'">
<a-tag :color="getBizTypeColor(text)">
<DictLabel :dict-code="DICT_CODE_ENUM.BUSINESS_CLASSIFICATION" :data-value="text" />
</a-tag>
</template>
<!-- 分配状态 -->
<template v-if="column.dataIndex === 'assignStatus'">
<a-badge :status="text === 1 ? 'success' : 'default'" :text="text === 1 ? '已分配' : '待分配'" />
</template>
</template>
</a-table>
</div>
</div>
<!-- 分配人员弹窗 -->
<a-modal
v-model:open="assignVisible"
title="批量分配调查样本任务"
@ok="handleSaveAssign"
:width="400"
>
<div style="padding: 16px 0">
<a-form layout="vertical">
<a-form-item label="选择回访调查员" required>
<a-select v-model:value="assignUser" placeholder="请选择回访人员">
<a-select-option value="张警官 (市局民意岗)">张警官 (市局民意岗)</a-select-option>
<a-select-option value="李回访 (市访评员)">李回访 (市访评员)</a-select-option>
<a-select-option value="王所长 (支队承办岗)">王所长 (支队承办岗)</a-select-option>
</a-select>
</a-form-item>
</a-form>
</div>
</a-modal>
</div>
</template>
<script setup>
import { ContactsOutlined } from '@ant-design/icons-vue';
import { ref, reactive, onMounted } from 'vue';
import { message } from 'ant-design-vue';
import { SyncOutlined, UsergroupAddOutlined } from '@ant-design/icons-vue';
import DictSelect from '/@/components/support/dict-select/index.vue';
import DictLabel from '/@/components/support/dict-label/index.vue';
import { DICT_CODE_ENUM } from '/@/constants/support/dict-const';
const queryForm = reactive({
name: '',
sjlyNo: undefined,
assignStatus: undefined
});
const tableData = ref([]);
const selectedRowKeys = ref([]);
const assignVisible = ref(false);
const assignUser = ref(undefined);
const columns = [
{ title: '样本ID', dataIndex: 'id', width: 120, align: 'center' },
{ title: '群众姓名', dataIndex: 'name', width: 100, align: 'center' },
{ title: '电话号码', dataIndex: 'phone', width: 130, align: 'center' },
{ title: '业务分类', dataIndex: 'sjlyNo', width: 150, align: 'center' },
{ title: '数据来源', dataIndex: 'source', width: 150, align: 'center' },
{ title: '分配状态', dataIndex: 'assignStatus', width: 110, align: 'center' },
{ title: '分配调查员', dataIndex: 'assignee', align: 'left' },
{ title: '样本导入时间', dataIndex: 'importTime', width: 160, align: 'center' }
];
const mockSamples = [
{ id: 'S2001', name: '王有才', phone: '13899998888', sjlyNo: '110', source: '110警情自动提取', assignStatus: 1, assignee: '张警官 (市局民意岗)', importTime: '2026-06-10 10:00:00' },
{ id: 'S2002', name: '韩梅梅', phone: '13711112222', sjlyNo: 'hz', source: '户政窗口办结推送', assignStatus: 0, assignee: '-', importTime: '2026-06-10 11:30:00' },
{ id: 'S2003', name: '李雷', phone: '13633334444', sjlyNo: 'crj', source: '出入境大厅满意度抽样', assignStatus: 0, assignee: '-', importTime: '2026-06-10 12:45:00' },
{ id: 'S2004', name: '张小胖', phone: '18944445555', sjlyNo: 'cjg', source: '车驾管一门式综办反馈', assignStatus: 1, assignee: '李回访 (市访评员)', importTime: '2026-06-10 14:00:00' }
];
const loadSamples = () => {
const local = localStorage.getItem('local_survey_samples');
if (!local) {
localStorage.setItem('local_survey_samples', JSON.stringify(mockSamples));
tableData.value = mockSamples;
} else {
const list = JSON.parse(local);
tableData.value = list.filter(item => {
let matched = true;
if (queryForm.name && !item.name.includes(queryForm.name)) matched = false;
if (queryForm.sjlyNo && item.sjlyNo !== queryForm.sjlyNo) matched = false;
if (queryForm.assignStatus !== undefined && item.assignStatus !== queryForm.assignStatus) matched = false;
return matched;
});
}
};
const getBizTypeColor = (bizType) => {
const map = { '110': 'red', 'hz': 'blue', 'crj': 'purple', 'cjg': 'green', 'ajbl': 'orange' };
return map[bizType] || 'cyan';
};
const onSearch = () => {
loadSamples();
};
const resetQuery = () => {
queryForm.name = '';
queryForm.sjlyNo = undefined;
queryForm.assignStatus = undefined;
loadSamples();
};
const onSelectChange = (keys) => {
selectedRowKeys.value = keys;
};
const handleImportFromBusiness = () => {
message.loading({ content: '正在实时同步110处警及大厅综办数据...', key: 'import' });
setTimeout(() => {
const list = JSON.parse(localStorage.getItem('local_survey_samples') || '[]');
const newSample = {
id: 'S' + (Date.now() % 10000),
name: '陈大民',
phone: '15912345678',
sjlyNo: '110',
source: '110警情自动提取',
assignStatus: 0,
assignee: '-',
importTime: new Date().toLocaleString()
};
list.unshift(newSample);
localStorage.setItem('local_survey_samples', JSON.stringify(list));
message.success({ content: '已成功从警务平台实时同步 1 条最新样本数据!', key: 'import', duration: 2 });
loadSamples();
}, 1000);
};
const handleBatchAssign = () => {
assignUser.value = undefined;
assignVisible.value = true;
};
const handleSaveAssign = () => {
if (!assignUser.value) {
message.warning('请选择调查员');
return;
}
const list = JSON.parse(localStorage.getItem('local_survey_samples') || '[]');
selectedRowKeys.value.forEach(key => {
const index = list.findIndex(item => item.id === key);
if (index !== -1) {
list[index].assignStatus = 1;
list[index].assignee = assignUser.value;
const tasks = JSON.parse(localStorage.getItem('local_survey_tasks') || '[]');
const sample = list[index];
const hasTask = tasks.some(t => t.phone === sample.phone && t.sjlyNo === sample.sjlyNo);
if (!hasTask) {
tasks.unshift({
id: 'T' + (Date.now() % 10000),
name: sample.name,
phone: sample.phone,
sjlyNo: sample.sjlyNo,
callCount: 0,
status: 0,
surveyTitle: sample.sjlyNo === '110' ? '110接处警群众满意度回访问卷' : '车驾管服务窗口满意度评议问卷',
assignTime: new Date().toLocaleString()
});
localStorage.setItem('local_survey_tasks', JSON.stringify(tasks));
}
}
});
localStorage.setItem('local_survey_samples', JSON.stringify(list));
message.success(`成功为 [${assignUser.value}] 分配了 ${selectedRowKeys.value.length} 个回访样本!已自动关联生成其名下任务`);
selectedRowKeys.value = [];
assignVisible.value = false;
loadSamples();
};
onMounted(() => {
loadSamples();
});
</script>
<style scoped>
.icon-wrapper {
background: linear-gradient(135deg, #096dd9 0%, #3f86f8 100%);
.glass-card {
background: #ffffff;
border-radius: 12px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.02);
padding: 24px;
border: 1px solid #f0f0f0;
}
</style>

348
smart-flow-web/src/views/jwpy/mydc/web-opinion/index.vue

@ -1,33 +1,337 @@
<!--
* 警务评议与问题处置 - 网络民意舆情监测中心
*
* @Author: Antigravity
* @Date: 2026-06-10
-->
<template>
<div class="page-container">
<div class="glass-card">
<div class="header">
<a-space size="middle">
<div class="icon-wrapper">
<GlobalOutlined class="icon" />
</div>
<div>
<h2 class="title">网络民意监测</h2>
<p class="subtitle">采集贴吧微博论坛等网络平台的警务负面舆情并生成督察监测警报</p>
<div class="page-container" style="background-color: #f0f2f5; min-height: calc(100vh - 120px)">
<!-- 今日核心指标看板 -->
<a-row :gutter="16" style="margin-bottom: 16px">
<a-col :span="6">
<a-card :bordered="false" class="monitor-card text-red-border">
<div class="monitor-label"><span class="point bg-red"></span>今日敏感舆情警报</div>
<div class="monitor-value text-red">3 <span class="unit"></span></div>
<div class="monitor-sub">环比昨日 <span class="trend text-green"> 25%</span></div>
</a-card>
</a-col>
<a-col :span="6">
<a-card :bordered="false" class="monitor-card text-blue-border">
<div class="monitor-label"><span class="point bg-blue"></span>全网民意检索量</div>
<div class="monitor-value text-blue">2,410 <span class="unit"></span></div>
<div class="monitor-sub">环比昨日 <span class="trend text-red"> 12%</span></div>
</a-card>
</a-col>
<a-col :span="6">
<a-card :bordered="false" class="monitor-card text-orange-border">
<div class="monitor-label"><span class="point bg-orange"></span>中立/负面声音比例</div>
<div class="monitor-value text-orange">6.8 <span class="unit">%</span></div>
<div class="monitor-sub">整体保持 <span class="trend text-green">安全区间</span></div>
</a-card>
</a-col>
<a-col :span="6">
<a-card :bordered="false" class="monitor-card text-green-border">
<div class="monitor-label"><span class="point bg-green"></span>智能监测雷达状态</div>
<div class="monitor-value text-green">正常 <span class="unit">运行</span></div>
<div class="monitor-sub">全天候 24h 智能巡检中</div>
</a-card>
</a-col>
</a-row>
<!-- 下方两栏展示 -->
<a-row :gutter="16">
<!-- 左侧敏感舆情线索预警与跟踪 -->
<a-col :span="14">
<a-card :bordered="false" title="今日网络敏感民意预警" class="chart-card">
<template #extra>
<a-button type="link" size="small" @click="handleRefreshMonitor"><SyncOutlined /> 刷新线索</a-button>
</template>
<div class="warning-list">
<div v-for="item in warningData" :key="item.id" class="warning-item">
<div class="warning-header">
<a-tag :color="getSeverityColor(item.severity)">{{ item.severity === 'high' ? '高危' : '中危' }}</a-tag>
<span class="warning-source">{{ item.source }}</span>
<span class="warning-time">{{ item.time }}</span>
<a-button type="primary" size="small" ghost class="handle-btn" @click="handleAssignToCzzx(item)">
一键转处置工单
</a-button>
</div>
<div class="warning-content">
{{ item.content }}
</div>
<div class="warning-footer">
<span class="keyword">匹配词<strong>{{ item.keyword }}</strong></span>
<span class="divider">|</span>
<span class="heat">热度值<strong>{{ item.heat }}</strong></span>
</div>
</div>
</div>
</a-space>
</div>
<div class="content-box">
<div class="table-placeholder">
<a-empty description="网络民意监测中心/涉警舆情预警系统对接中" />
</div>
</div>
</div>
</a-card>
</a-col>
<!-- 右侧热点词云与警种涉舆情况 -->
<a-col :span="10">
<a-row :gutter="[0, 16]">
<!-- 警务高频词云 -->
<a-col :span="24">
<a-card :bordered="false" title="民意关注高频热词云" class="chart-card">
<div class="word-cloud-mock">
<span class="cloud-word w-30 text-red">出警慢</span>
<span class="cloud-word w-24 text-blue">态度生硬</span>
<span class="cloud-word w-18 text-purple">排队久</span>
<span class="cloud-word w-20 text-orange">办事效率</span>
<span class="cloud-word w-14 text-green">车驾管</span>
<span class="cloud-word w-12 text-cyan">立案难</span>
<span class="cloud-word w-16 text-blue">110报警</span>
<span class="cloud-word w-15 text-orange">纠纷处置</span>
<span class="cloud-word w-11 text-green">出入境</span>
<span class="cloud-word w-13 text-purple">民意回访</span>
</div>
</a-card>
</a-col>
<!-- 警种负面舆情分布 -->
<a-col :span="24">
<a-card :bordered="false" title="各业务大类舆情负面排行" class="chart-card">
<div class="progress-list">
<div class="progress-item">
<div class="progress-label">110 接处警</div>
<a-progress :percent="45" status="active" strokeColor="#ff4d4f" />
</div>
<div class="progress-item">
<div class="progress-label">车驾管服务</div>
<a-progress :percent="28" status="active" strokeColor="#fa8c16" />
</div>
<div class="progress-item">
<div class="progress-label">户政窗口服务</div>
<a-progress :percent="15" status="active" strokeColor="#1890ff" />
</div>
<div class="progress-item">
<div class="progress-label">案件办理</div>
<a-progress :percent="12" status="active" strokeColor="#722ed1" />
</div>
</div>
</a-card>
</a-col>
</a-row>
</a-col>
</a-row>
</div>
</template>
<script setup>
import { GlobalOutlined } from '@ant-design/icons-vue';
import { ref } from 'vue';
import { message } from 'ant-design-vue';
import { SyncOutlined } from '@ant-design/icons-vue';
const warningData = ref([
{ id: 1, severity: 'high', source: '抖音视频', keyword: '出警慢', heat: '2.4w', time: '10分钟前', content: '有市民发布视频反映:在某区十字路口发生车辆刮擦,拨打110后警员过了将近半小时才到场处置,质疑效率低下。' },
{ id: 2, severity: 'medium', source: '微博超话', keyword: '态度生硬', heat: '8.5k', time: '30分钟前', content: '网民发帖称在某派出所窗口办理身份证业务时,因材料不全被窗口人员冷淡告知,表示一次性告知不清,民警服务态度有待改进。' },
{ id: 3, severity: 'high', source: '今日头条', keyword: '立案难', heat: '1.2w', time: '1小时前', content: '头条文章吐槽:市民遭遇网络电信诈骗后前往属地大队报案,警员表示金额不足或管辖权有争议,推脱立案,引发群众吐槽。' }
]);
const getSeverityColor = (severity) => {
return severity === 'high' ? 'red' : 'orange';
};
const handleRefreshMonitor = () => {
message.success('舆情监测雷达已刷新,成功获取最新 3 条全网涉警线索');
};
const handleAssignToCzzx = (item) => {
message.loading('正在提取舆情要素并智能转为问题处置工单...', 1);
setTimeout(() => {
message.success(`舆情工单创建成功!已自动分派到【任务认领大厅】,匹配特征词:${item.keyword}`);
}, 1000);
};
</script>
<style scoped>
.icon-wrapper {
background: linear-gradient(135deg, #0050b3 0%, #096dd9 100%);
.monitor-card {
background: #ffffff;
border-radius: 12px;
padding: 16px 20px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.02);
border: 1px solid #f0f0f0;
}
.text-red-border { border-left: 4px solid #ff4d4f; }
.text-blue-border { border-left: 4px solid #1890ff; }
.text-orange-border { border-left: 4px solid #fa8c16; }
.text-green-border { border-left: 4px solid #52c41a; }
.monitor-label {
font-size: 13px;
color: #8c8c8c;
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 8px;
}
.point {
width: 6px;
height: 6px;
border-radius: 50%;
display: inline-block;
}
.bg-red { background: #ff4d4f; }
.bg-blue { background: #1890ff; }
.bg-orange { background: #fa8c16; }
.bg-green { background: #52c41a; }
.monitor-value {
font-size: 28px;
font-weight: 700;
line-height: 1.2;
}
.unit {
font-size: 13px;
font-weight: normal;
color: #bfbfbf;
}
.monitor-sub {
font-size: 11px;
color: #bfbfbf;
margin-top: 6px;
}
.trend {
font-weight: 600;
}
.text-red { color: #ff4d4f; }
.text-blue { color: #1890ff; }
.text-orange { color: #fa8c16; }
.text-green { color: #52c41a; }
/* 警报线索 */
.chart-card {
border-radius: 12px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.02);
min-height: 400px;
}
.warning-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.warning-item {
background: #fafafa;
border: 1px solid #f0f0f0;
border-radius: 8px;
padding: 14px;
transition: all 0.2s ease;
}
.warning-item:hover {
border-color: #d9d9d9;
background: #ffffff;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.03);
}
.warning-header {
display: flex;
align-items: center;
font-size: 12px;
margin-bottom: 8px;
}
.warning-source {
font-weight: 700;
color: #262626;
}
.warning-time {
color: #bfbfbf;
margin-left: 6px;
}
.handle-btn {
margin-left: auto;
}
.warning-content {
font-size: 13px;
color: #595959;
line-height: 1.6;
margin-bottom: 8px;
}
.warning-footer {
font-size: 11px;
color: #8c8c8c;
display: flex;
align-items: center;
}
.keyword strong {
color: #ff4d4f;
}
.heat strong {
color: #fa8c16;
}
.divider {
margin: 0 8px;
color: #f0f0f0;
}
/* 词云模拟 */
.word-cloud-mock {
height: 140px;
display: flex;
flex-wrap: wrap;
align-content: center;
justify-content: center;
gap: 12px;
padding: 10px;
background: #fafafa;
border-radius: 8px;
}
.cloud-word {
font-weight: 700;
cursor: pointer;
transition: transform 0.2s;
}
.cloud-word:hover {
transform: scale(1.15);
}
.w-30 { font-size: 20px; }
.w-24 { font-size: 18px; }
.w-20 { font-size: 16px; }
.w-18 { font-size: 15px; }
.w-16 { font-size: 14px; }
.w-14 { font-size: 13px; }
.w-12 { font-size: 12px; }
.w-11 { font-size: 11px; }
/* 进度条 */
.progress-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.progress-item {
display: flex;
flex-direction: column;
gap: 4px;
}
.progress-label {
font-size: 12px;
color: #595959;
font-weight: 600;
}
</style>
Loading…
Cancel
Save