345 lines
11 KiB
Vue
345 lines
11 KiB
Vue
<template>
|
||
<div class="suggestions-page">
|
||
<div class="page-header">
|
||
<div>
|
||
<h2 class="page-title">💬 建言献策管理</h2>
|
||
<p class="page-desc">管理用户提交的建言献策,支持审核与状态跟踪</p>
|
||
</div>
|
||
<a-space>
|
||
<a-button @click="loadSuggestions" :loading="loading">
|
||
<template #icon><ReloadOutlined /></template>
|
||
刷新
|
||
</a-button>
|
||
</a-space>
|
||
</div>
|
||
|
||
<!-- 统计卡片 -->
|
||
<a-row :gutter="[16, 16]" class="mb-6">
|
||
<a-col :xs="12" :sm="6" v-for="stat in statCards" :key="stat.key">
|
||
<div
|
||
class="stat-card"
|
||
:class="[stat.color, { active: filterStatus === stat.key }]"
|
||
@click="handleStatFilter(stat.key)"
|
||
>
|
||
<div class="stat-icon">{{ stat.icon }}</div>
|
||
<div class="stat-info">
|
||
<div class="stat-value">{{ stat.value }}</div>
|
||
<div class="stat-label">{{ stat.label }}</div>
|
||
</div>
|
||
</div>
|
||
</a-col>
|
||
</a-row>
|
||
|
||
<div class="panel">
|
||
<div class="panel-header">
|
||
<span class="panel-title">📋 建言列表</span>
|
||
<a-space wrap>
|
||
<a-select v-model:value="filterStatus" style="width: 120px" @change="handleSearch">
|
||
<a-select-option :value="undefined">全部状态</a-select-option>
|
||
<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-input-search
|
||
v-model:value="searchKeyword"
|
||
placeholder="搜索标题 / 内容"
|
||
style="width: 240px"
|
||
@search="handleSearch"
|
||
/>
|
||
</a-space>
|
||
</div>
|
||
|
||
<a-table
|
||
:columns="columns"
|
||
:data-source="pagedSuggestions"
|
||
:loading="loading"
|
||
:pagination="tablePagination"
|
||
row-key="id"
|
||
@change="handleTableChange"
|
||
size="middle"
|
||
>
|
||
<template #bodyCell="{ column, record }">
|
||
<template v-if="column.key === 'info'">
|
||
<div class="suggestion-info-cell">
|
||
<div class="suggestion-title">{{ record.title }}</div>
|
||
<div class="suggestion-meta">
|
||
<span>👤 {{ record.authorName || '匿名' }}</span>
|
||
<span class="meta-item">📅 {{ record.createTime?.substring(0, 10) || '-' }}</span>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<template v-if="column.key === 'content'">
|
||
<div class="content-preview">{{ record.content?.substring(0, 50) }}...</div>
|
||
</template>
|
||
|
||
<template v-if="column.key === 'status'">
|
||
<a-tag :color="statusColor(record.status)">{{ statusText(record.status) }}</a-tag>
|
||
</template>
|
||
|
||
<template v-if="column.key === 'action'">
|
||
<a-space>
|
||
<a-button type="link" size="small" @click="handleView(record)">查看</a-button>
|
||
<a-button type="link" size="small" @click="handleProcess(record)" v-if="record.status === 0">处理</a-button>
|
||
</a-space>
|
||
</template>
|
||
</template>
|
||
</a-table>
|
||
</div>
|
||
|
||
<!-- 查看详情弹窗 -->
|
||
<a-modal
|
||
v-model:open="showDetailModal"
|
||
title="建言详情"
|
||
width="700px"
|
||
:footer="null"
|
||
>
|
||
<template v-if="currentSuggestion">
|
||
<a-descriptions :column="2" bordered size="small">
|
||
<a-descriptions-item label="标题" :span="2">{{ currentSuggestion.title }}</a-descriptions-item>
|
||
<a-descriptions-item label="提交人">{{ currentSuggestion.authorName || '匿名' }}</a-descriptions-item>
|
||
<a-descriptions-item label="联系方式">{{ currentSuggestion.contact || '-' }}</a-descriptions-item>
|
||
<a-descriptions-item label="提交时间">{{ currentSuggestion.createTime?.substring(0, 16) || '-' }}</a-descriptions-item>
|
||
<a-descriptions-item label="当前状态">
|
||
<a-tag :color="statusColor(currentSuggestion.status)">{{ statusText(currentSuggestion.status) }}</a-tag>
|
||
</a-descriptions-item>
|
||
<a-descriptions-item label="建言内容" :span="2">
|
||
<div class="full-content">{{ currentSuggestion.content }}</div>
|
||
</a-descriptions-item>
|
||
<a-descriptions-item label="处理备注" :span="2" v-if="currentSuggestion.reply">
|
||
{{ currentSuggestion.reply }}
|
||
</a-descriptions-item>
|
||
</a-descriptions>
|
||
|
||
<div v-if="currentSuggestion.status === 0" class="process-actions">
|
||
<a-divider />
|
||
<a-form :model="replyForm" layout="vertical">
|
||
<a-form-item label="处理备注">
|
||
<a-textarea v-model:value="replyForm.reply" :rows="3" placeholder="请输入处理备注..." />
|
||
</a-form-item>
|
||
<a-form-item label="处理结果">
|
||
<a-select v-model:value="replyForm.status" placeholder="请选择处理结果">
|
||
<a-select-option :value="1">已处理</a-select-option>
|
||
<a-select-option :value="2">已采纳</a-select-option>
|
||
</a-select>
|
||
</a-form-item>
|
||
<a-space>
|
||
<a-button type="primary" @click="handleSubmitReply">提交</a-button>
|
||
</a-space>
|
||
</a-form>
|
||
</div>
|
||
</template>
|
||
</a-modal>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { ReloadOutlined } from '@ant-design/icons-vue'
|
||
import { message } from 'ant-design-vue'
|
||
|
||
definePageMeta({ layout: 'admin' })
|
||
useHead({ title: '建言管理 - 后台管理' })
|
||
|
||
interface Suggestion {
|
||
id?: number
|
||
title?: string
|
||
content?: string
|
||
authorName?: string
|
||
contact?: string
|
||
status?: number
|
||
reply?: string
|
||
createTime?: string
|
||
}
|
||
|
||
const loading = ref(false)
|
||
const suggestions = ref<Suggestion[]>([])
|
||
const filterStatus = ref<number | undefined>(undefined)
|
||
const searchKeyword = ref('')
|
||
|
||
const pagination = reactive({
|
||
current: 1,
|
||
pageSize: 20,
|
||
showSizeChanger: true,
|
||
showQuickJumper: true,
|
||
})
|
||
|
||
const statCards = reactive([
|
||
{ key: 0, icon: '⏳', label: '待处理', value: 0, color: 'orange' },
|
||
{ key: 1, icon: '✅', label: '已处理', value: 0, color: 'blue' },
|
||
{ key: 2, icon: '🎯', label: '已采纳', value: 0, color: 'green' },
|
||
{ key: -1, icon: '📝', label: '全部建言', value: 0, color: 'purple' },
|
||
])
|
||
|
||
const columns = [
|
||
{ title: '建言信息', key: 'info', width: 280 },
|
||
{ title: '内容预览', key: 'content', width: 200 },
|
||
{ title: '状态', key: 'status', width: 100 },
|
||
{ title: '操作', key: 'action', width: 120 },
|
||
]
|
||
|
||
const showDetailModal = ref(false)
|
||
const currentSuggestion = ref<Suggestion | null>(null)
|
||
const replyForm = reactive({
|
||
reply: '',
|
||
status: 1,
|
||
})
|
||
|
||
const filteredSuggestions = computed(() => {
|
||
const keyword = searchKeyword.value.trim().toLowerCase()
|
||
return suggestions.value
|
||
.filter(item => filterStatus.value === undefined || item.status === filterStatus.value)
|
||
.filter(item => {
|
||
if (!keyword) return true
|
||
return [item.title, item.content]
|
||
.some(val => String(val || '').toLowerCase().includes(keyword))
|
||
})
|
||
.sort((a, b) => (b.id || 0) - (a.id || 0))
|
||
})
|
||
|
||
const pagedSuggestions = computed(() => {
|
||
const start = (pagination.current - 1) * pagination.pageSize
|
||
return filteredSuggestions.value.slice(start, start + pagination.pageSize)
|
||
})
|
||
|
||
const tablePagination = computed(() => ({
|
||
current: pagination.current,
|
||
pageSize: pagination.pageSize,
|
||
total: filteredSuggestions.value.length,
|
||
showSizeChanger: pagination.showSizeChanger,
|
||
showQuickJumper: pagination.showQuickJumper,
|
||
}))
|
||
|
||
function updateStats() {
|
||
statCards[0].value = suggestions.value.filter(i => i.status === 0).length
|
||
statCards[1].value = suggestions.value.filter(i => i.status === 1).length
|
||
statCards[2].value = suggestions.value.filter(i => i.status === 2).length
|
||
statCards[3].value = suggestions.value.length
|
||
}
|
||
|
||
async function loadSuggestions() {
|
||
loading.value = true
|
||
try {
|
||
// TODO: 接入实际API
|
||
updateStats()
|
||
} catch (e: any) {
|
||
message.error(e?.message || '加载建言列表失败')
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
function handleStatFilter(key: number) {
|
||
filterStatus.value = key === -1 ? undefined : key
|
||
pagination.current = 1
|
||
}
|
||
|
||
function handleSearch() {
|
||
pagination.current = 1
|
||
}
|
||
|
||
function handleTableChange(pag: { current: number; pageSize: number }) {
|
||
pagination.current = pag.current
|
||
pagination.pageSize = pag.pageSize
|
||
}
|
||
|
||
function handleView(record: Suggestion) {
|
||
currentSuggestion.value = record
|
||
replyForm.reply = ''
|
||
replyForm.status = 1
|
||
showDetailModal.value = true
|
||
}
|
||
|
||
function handleProcess(record: Suggestion) {
|
||
handleView(record)
|
||
}
|
||
|
||
async function handleSubmitReply() {
|
||
if (!currentSuggestion.value?.id) return
|
||
try {
|
||
// TODO: 接入实际API
|
||
// await processSuggestion(currentSuggestion.value.id, replyForm)
|
||
message.success('处理成功')
|
||
showDetailModal.value = false
|
||
await loadSuggestions()
|
||
} catch (e: any) {
|
||
message.error(e?.message || '处理失败')
|
||
}
|
||
}
|
||
|
||
function statusText(status?: number) {
|
||
const map: Record<number, string> = { 0: '待处理', 1: '已处理', 2: '已采纳' }
|
||
return map[status ?? -1] || '-'
|
||
}
|
||
|
||
function statusColor(status?: number) {
|
||
const map: Record<number, string> = { 0: 'orange', 1: 'blue', 2: 'success' }
|
||
return map[status ?? -1] || 'default'
|
||
}
|
||
|
||
onMounted(() => {
|
||
loadSuggestions()
|
||
})
|
||
</script>
|
||
|
||
<style scoped>
|
||
.suggestions-page { min-height: 100%; }
|
||
|
||
.page-header {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
margin-bottom: 20px;
|
||
}
|
||
.page-title { font-size: 18px; font-weight: 700; color: #1f2937; margin: 0; }
|
||
.page-desc { font-size: 13px; color: #9ca3af; margin: 2px 0 0; }
|
||
|
||
.stat-card {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
padding: 16px;
|
||
border-radius: 12px;
|
||
border: 2px solid transparent;
|
||
cursor: pointer;
|
||
transition: all 0.2s;
|
||
}
|
||
.stat-card:hover { transform: translateY(-2px); box-shadow: 0 4px 12px rgba(0,0,0,0.08); }
|
||
.stat-card.active { box-shadow: 0 4px 12px rgba(0,0,0,0.12); }
|
||
.stat-card.blue { background: #eff6ff; border-color: #dbeafe; }
|
||
.stat-card.green { background: #f0fdf4; border-color: #bbf7d0; }
|
||
.stat-card.orange { background: #fff7ed; border-color: #fed7aa; }
|
||
.stat-card.purple { background: #faf5ff; border-color: #e9d5ff; }
|
||
.stat-icon { font-size: 28px; flex-shrink: 0; }
|
||
.stat-value { font-size: 22px; font-weight: 700; color: rgba(0,0,0,0.85); line-height: 1.2; }
|
||
.stat-label { font-size: 12px; color: rgba(0,0,0,0.45); margin-top: 2px; }
|
||
|
||
.panel { background: #fff; border: 1px solid #f0f0f0; border-radius: 12px; overflow: hidden; }
|
||
.panel-header {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding: 14px 18px;
|
||
border-bottom: 1px solid #f5f5f5;
|
||
flex-wrap: wrap;
|
||
gap: 10px;
|
||
}
|
||
.panel-title { font-size: 14px; font-weight: 600; color: rgba(0,0,0,0.85); }
|
||
|
||
.suggestion-info-cell { display: flex; flex-direction: column; gap: 4px; }
|
||
.suggestion-title { font-size: 14px; font-weight: 500; color: rgba(0,0,0,0.85); }
|
||
.suggestion-meta { font-size: 12px; color: rgba(0,0,0,0.45); }
|
||
.meta-item { margin-left: 12px; }
|
||
.content-preview { font-size: 12px; color: rgba(0,0,0,0.65); }
|
||
|
||
.full-content {
|
||
max-height: 200px;
|
||
overflow-y: auto;
|
||
white-space: pre-wrap;
|
||
line-height: 1.6;
|
||
}
|
||
|
||
.process-actions { margin-top: 16px; }
|
||
|
||
.mb-6 { margin-bottom: 24px; }
|
||
</style>
|