Files
template-nuxt4/app/pages/admin/suggestions/index.vue
2026-04-29 01:33:33 +08:00

345 lines
11 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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 :loading="loading" @click="loadSuggestions">
<template #icon><ReloadOutlined /></template>
刷新
</a-button>
</a-space>
</div>
<!-- 统计卡片 -->
<a-row :gutter="[16, 16]" class="mb-6">
<a-col v-for="stat in statCards" :key="stat.key" :sm="6" :xs="12">
<div
:class="[stat.color, { active: filterStatus === stat.key }]"
class="stat-card"
@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"
size="middle"
@change="handleTableChange"
>
<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 size="small" type="link" @click="handleView(record)">查看</a-button>
<a-button v-if="record.status === 0" size="small" type="link" @click="handleProcess(record)">处理</a-button>
</a-space>
</template>
</template>
</a-table>
</div>
<!-- 查看详情弹窗 -->
<a-modal
v-model:open="showDetailModal"
:footer="null"
title="建言详情"
width="700px"
>
<template v-if="currentSuggestion">
<a-descriptions :column="2" bordered size="small">
<a-descriptions-item :span="2" label="标题">{{ 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 :span="2" label="建言内容">
<div class="full-content">{{ currentSuggestion.content }}</div>
</a-descriptions-item>
<a-descriptions-item v-if="currentSuggestion.reply" :span="2" label="处理备注">
{{ 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 lang="ts" setup>
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>