feat(admin): migrate admin UI to Vue3

This commit is contained in:
2025-12-13 20:51:44 +08:00
parent 3c31f30ee4
commit 235ba28cc8
46 changed files with 9355 additions and 3513 deletions

View File

@@ -0,0 +1,256 @@
<script setup>
import { onMounted, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { closeFeedback, deleteFeedback, fetchFeedbacks, replyFeedback } from '../api/feedbacks'
const loading = ref(false)
const statusFilter = ref('')
const stats = ref({ total: 0, pending: 0, replied: 0, closed: 0 })
const list = ref([])
const statusOptions = [
{ label: '全部状态', value: '' },
{ label: '待处理', value: 'pending' },
{ label: '已回复', value: 'replied' },
{ label: '已关闭', value: 'closed' },
]
function statusMeta(status) {
if (status === 'pending') return { label: '待处理', type: 'warning' }
if (status === 'replied') return { label: '已回复', type: 'success' }
if (status === 'closed') return { label: '已关闭', type: 'info' }
return { label: status || '-', type: 'info' }
}
async function load() {
loading.value = true
try {
const data = await fetchFeedbacks(statusFilter.value)
list.value = data?.feedbacks || []
stats.value = data?.stats || { total: 0, pending: 0, replied: 0, closed: 0 }
} catch {
list.value = []
stats.value = { total: 0, pending: 0, replied: 0, closed: 0 }
} finally {
loading.value = false
}
}
async function onReply(row) {
let text
try {
const res = await ElMessageBox.prompt('请输入回复内容', '回复反馈', {
inputType: 'textarea',
inputPlaceholder: '回复内容',
confirmButtonText: '提交',
cancelButtonText: '取消',
inputValidator: (v) => Boolean(String(v || '').trim()),
inputErrorMessage: '回复内容不能为空',
})
text = res.value
} catch {
return
}
try {
const res = await replyFeedback(row.id, String(text || '').trim())
ElMessage.success(res?.message || '回复成功')
await load()
} catch {
// handled by interceptor
}
}
async function onClose(row) {
try {
await ElMessageBox.confirm('确定要关闭这个反馈吗?', '关闭反馈', {
confirmButtonText: '关闭',
cancelButtonText: '取消',
type: 'warning',
})
} catch {
return
}
try {
const res = await closeFeedback(row.id)
ElMessage.success(res?.message || '反馈已关闭')
await load()
} catch {
// handled by interceptor
}
}
async function onDelete(row) {
try {
await ElMessageBox.confirm('确定要删除这个反馈吗?此操作不可恢复!', '删除反馈', {
confirmButtonText: '删除',
cancelButtonText: '取消',
type: 'error',
})
} catch {
return
}
try {
const res = await deleteFeedback(row.id)
ElMessage.success(res?.message || '反馈已删除')
await load()
} catch {
// handled by interceptor
}
}
onMounted(load)
</script>
<template>
<div class="page-stack">
<div class="app-page-title">
<h2>反馈管理</h2>
<div class="toolbar">
<el-select v-model="statusFilter" style="width: 160px" @change="load">
<el-option v-for="o in statusOptions" :key="o.value" :label="o.label" :value="o.value" />
</el-select>
<el-button @click="load">刷新</el-button>
</div>
</div>
<el-row :gutter="12">
<el-col :xs="12" :sm="6">
<el-card shadow="never" class="stat-card" :body-style="{ padding: '14px' }">
<div class="stat-value">{{ stats.total || 0 }}</div>
<div class="stat-label">总计</div>
</el-card>
</el-col>
<el-col :xs="12" :sm="6">
<el-card shadow="never" class="stat-card" :body-style="{ padding: '14px' }">
<div class="stat-value warn">{{ stats.pending || 0 }}</div>
<div class="stat-label">待处理</div>
</el-card>
</el-col>
<el-col :xs="12" :sm="6">
<el-card shadow="never" class="stat-card" :body-style="{ padding: '14px' }">
<div class="stat-value ok">{{ stats.replied || 0 }}</div>
<div class="stat-label">已回复</div>
</el-card>
</el-col>
<el-col :xs="12" :sm="6">
<el-card shadow="never" class="stat-card" :body-style="{ padding: '14px' }">
<div class="stat-value">{{ stats.closed || 0 }}</div>
<div class="stat-label">已关闭</div>
</el-card>
</el-col>
</el-row>
<el-card shadow="never" :body-style="{ padding: '16px' }" class="card">
<div class="table-wrap">
<el-table :data="list" v-loading="loading" style="width: 100%">
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="username" label="用户" width="140" />
<el-table-column label="标题" min-width="180">
<template #default="{ row }">
<el-tooltip :content="row.title" placement="top" :show-after="300">
<span class="ellipsis">{{ row.title }}</span>
</el-tooltip>
</template>
</el-table-column>
<el-table-column label="描述" min-width="220">
<template #default="{ row }">
<el-tooltip :content="row.description" placement="top" :show-after="300">
<span class="ellipsis">{{ row.description }}</span>
</el-tooltip>
</template>
</el-table-column>
<el-table-column prop="contact" label="联系方式" min-width="160">
<template #default="{ row }">{{ row.contact || '-' }}</template>
</el-table-column>
<el-table-column label="状态" width="110">
<template #default="{ row }">
<el-tag :type="statusMeta(row.status).type" effect="light">{{ statusMeta(row.status).label }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="created_at" label="提交时间" width="180" />
<el-table-column label="回复" min-width="180">
<template #default="{ row }">
<el-tooltip :content="row.admin_reply || ''" placement="top" :show-after="300">
<span class="ellipsis">{{ row.admin_reply || '-' }}</span>
</el-tooltip>
</template>
</el-table-column>
<el-table-column label="操作" width="220" fixed="right">
<template #default="{ row }">
<div class="actions">
<template v-if="row.status !== 'closed'">
<el-button type="primary" size="small" @click="onReply(row)">回复</el-button>
<el-button size="small" @click="onClose(row)">关闭</el-button>
</template>
<el-button type="danger" size="small" @click="onDelete(row)">删除</el-button>
</div>
</template>
</el-table-column>
</el-table>
</div>
</el-card>
</div>
</template>
<style scoped>
.page-stack {
display: flex;
flex-direction: column;
gap: 12px;
}
.toolbar {
display: flex;
gap: 10px;
align-items: center;
}
.card,
.stat-card {
border-radius: var(--app-radius);
border: 1px solid var(--app-border);
}
.stat-value {
font-size: 20px;
font-weight: 800;
line-height: 1.1;
}
.stat-label {
margin-top: 6px;
font-size: 12px;
color: var(--app-muted);
}
.warn {
color: #b45309;
}
.ok {
color: #047857;
}
.table-wrap {
overflow-x: auto;
}
.ellipsis {
display: inline-block;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
</style>