7 changed files with 2837 additions and 74 deletions
@ -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> |
|||
@ -1,33 +1,358 @@ |
|||
<!-- |
|||
* 警务评议与问题处置 - 我的调查任务 |
|||
* |
|||
* @Author: Antigravity |
|||
* @Date: 2026-06-10 |
|||
--> |
|||
<template> |
|||
<div class="page-container"> |
|||
<!-- 今日任务概览卡片 --> |
|||
<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="header"> |
|||
<a-space size="middle"> |
|||
<div class="icon-wrapper"> |
|||
<SolutionOutlined class="icon" /> |
|||
<div class="content-box"> |
|||
<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> |
|||
<div> |
|||
<h2 class="title">我的调查任务</h2> |
|||
<p class="subtitle">基层民警或专职调查员在此认领、填报以及提交派发给自身的问卷调查任务。</p> |
|||
</template> |
|||
</template> |
|||
</a-table> |
|||
</div> |
|||
</a-space> |
|||
</div> |
|||
|
|||
<div class="content-box"> |
|||
<div class="table-placeholder"> |
|||
<a-empty description="我的问卷调查填报表单及任务列表加载中" /> |
|||
<!-- 登记答卷弹窗 --> |
|||
<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> |
|||
@ -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> |
|||
@ -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 class="content-box"> |
|||
<!-- 头部查询过滤区域 (通过字典组件代替硬编码) --> |
|||
<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> |
|||
<h2 class="title">问卷设计与管理</h2> |
|||
<p class="subtitle">用于民意调查问卷的在线可视化设计、大区抽样方案及问卷库管理。</p> |
|||
</div> |
|||
</a-space> |
|||
</div> |
|||
|
|||
<div class="content-box"> |
|||
<div class="table-placeholder"> |
|||
<a-empty description="问卷设计可视化组件及表单设计器初始化中" /> |
|||
<!-- 创建/修改基础信息 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> |
|||
@ -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> |
|||
<div class="content-box"> |
|||
<!-- 头部查询 --> |
|||
<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> |
|||
|
|||
<div class="content-box"> |
|||
<div class="table-placeholder"> |
|||
<a-empty description="调查抽样样本库物理管理页面导入中" /> |
|||
<!-- 数据表格 --> |
|||
<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> |
|||
@ -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 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> |
|||
<h2 class="title">网络民意监测</h2> |
|||
<p class="subtitle">采集贴吧、微博、论坛等网络平台的警务负面舆情,并生成督察监测警报。</p> |
|||
<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> |
|||
</a-space> |
|||
</div> |
|||
</div> |
|||
</a-card> |
|||
</a-col> |
|||
|
|||
<div class="content-box"> |
|||
<div class="table-placeholder"> |
|||
<a-empty description="网络民意监测中心/涉警舆情预警系统对接中" /> |
|||
<!-- 右侧:热点词云与警种涉舆情况 --> |
|||
<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…
Reference in new issue