DCFronted/src/components/backend/AffairManagement.vue
2025-06-29 22:02:55 +08:00

964 lines
23 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="affair-management">
<div class="page-header">
<h1>事项管理</h1>
<div class="header-actions">
<button class="btn-common btn-gradient" @click="openAddModal">添加需求</button>
<button class="btn-common btn-light" @click="onRefresh">刷新</button>
<button class="btn-common btn-gradient" @click="showDeletedItems = !showDeletedItems">
{{ showDeletedItems ? '隐藏已删除' : '显示已删除' }}
</button>
</div>
</div>
<div v-if="error" class="error-message">
{{ error }}
</div>
<div class="table-container">
<table class="maps-table">
<thead>
<tr>
<th>ID</th>
<th>请求者</th>
<th>QQ号</th>
<th>请求内容</th>
<th>悬赏金额</th>
<th>需求创建时间</th>
<th>回复数量</th>
<th>状态</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="(demand, index) in displayDemands" :key="demand.id" class="table-row">
<td class="id" data-label="ID">{{ index + 1 }}</td>
<td class="requester" data-label="请求者">
<span v-if="!demand.requester" class="tag no-reward">匿名</span>
<span v-else>{{ demand.requester }}</span>
</td>
<td class="name" data-label="QQ号">
<span v-if="!demand.qq_code" class="tag no-reward">匿名</span>
<span v-else>{{ demand.qq_code }}</span>
</td>
<td class="content" data-label="请求内容">{{ getDisplayContent(demand.content) }}</td>
<td class="reward" data-label="悬赏金额">
<span :class="['tag', isNoReward(demand.reward) ? 'no-reward' : 'has-reward']">
{{ isNoReward(demand.reward) ? '无赏金' : demand.reward }}
</span>
</td>
<td class="date" data-label="创建时间">{{ formatDate(demand.date) }}</td>
<td class="reply-count" data-label="回复数量">{{ getReplyCount(demand.sendcontent) }}</td>
<td class="status" data-label="状态">
<span :class="['tag', isDeleted(demand.content) ? 'deleted' : 'active']">
{{ isDeleted(demand.content) ? '已删除' : '正常' }}
</span>
</td>
<td class="actions" data-label="操作">
<button class="btn-common btn-small btn-gradient" @click="showDetail(demand)">查看</button>
<button v-if="!isDeleted(demand.content)" class="btn-common btn-small btn-warning" @click="handleHide(demand)">隐藏</button>
<button v-else class="btn-common btn-small btn-restore" @click="handleRestore(demand)">恢复</button>
<button v-if="showDeletedItems" class="btn-common btn-small btn-danger" @click="handleDelete(demand)">删除</button>
</td>
</tr>
</tbody>
</table>
</div>
<!-- 详情弹窗 -->
<div v-if="showModal" class="modal-overlay" @click="closeModal">
<div class="modal-content" @click.stop>
<div class="modal-header">
<h2>需求详情</h2>
<button class="close-btn" @click="closeModal">&times;</button>
</div>
<div class="modal-body">
<div class="detail-item">
<span class="label">QQ号</span>
<span v-if="!selectedDemand?.qq_code" class="tag no-reward">匿名</span>
<span v-else>{{ selectedDemand?.qq_code }}</span>
</div>
<div class="detail-item">
<span class="label">请求者:</span>
<span v-if="!selectedDemand?.requester" class="tag no-reward">匿名</span>
<span v-else>{{ selectedDemand?.requester }}</span>
</div>
<div class="detail-item">
<span class="label">需求内容:</span>
<span class="value">{{ getDisplayContent(selectedDemand?.content) }}</span>
</div>
<div class="detail-item">
<span class="label">赏金:</span>
<span :class="['tag', isNoReward(selectedDemand?.reward) ? 'no-reward' : 'has-reward']">
{{ isNoReward(selectedDemand?.reward) ? '无赏金' : selectedDemand?.reward }}
</span>
</div>
<div class="detail-item">
<span class="label">发布时间:</span>
<span class="value">{{ formatDate(selectedDemand?.date) }}</span>
</div>
<div class="detail-item">
<span class="label">状态:</span>
<span :class="['tag', isDeleted(selectedDemand?.content) ? 'deleted' : 'active']">
{{ isDeleted(selectedDemand?.content) ? '已删除' : '正常' }}
</span>
</div>
<div class="reply-section">
<h3>回复内容</h3>
<div class="reply-list">
<div v-if="selectedDemand?.sendcontent">
<div v-for="(reply, index) in selectedDemand.sendcontent.split('|')" :key="index" class="reply-content">
<div class="reply-with-avatar">
<img :src="`https://q1.qlogo.cn/g?b=qq&nk=${extractQQ(reply)}&s=40`" alt="User Avatar" class="reply-avatar" />
<div class="reply-text">
<b>{{ parseReplyText(reply).user }}</b> {{ parseReplyText(reply).content }}
</div>
</div>
</div>
</div>
<div v-else class="no-reply">
暂无回复
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 删除确认弹窗 -->
<div v-if="showDeleteConfirm" class="modal-overlay" style="z-index:2000;">
<div class="modal-content" style="max-width:350px;text-align:center;">
<div class="modal-header">
<h2 style="color:#F56C6C;">{{ currentOperation === 'hide' ? '隐藏确认' : '删除确认' }}</h2>
</div>
<div class="modal-body" style="font-size:16px;">
{{ currentOperation === 'hide' ? '确定要隐藏该需求吗?' : '确定要删除该需求吗?此操作不可恢复。' }}
</div>
<div class="delete-dialog-footer" style="display:flex;justify-content:center;gap:18px;margin:18px 0 8px 0;">
<button class="confirm-button" @click="confirmOperation">
{{ currentOperation === 'hide' ? '确认隐藏' : '确认删除' }}
</button>
<button class="cancel-button" @click="cancelDelete">取消</button>
</div>
</div>
</div>
<!-- 恢复确认弹窗 -->
<div v-if="showRestoreConfirm" class="modal-overlay" style="z-index:2000;">
<div class="modal-content" style="max-width:350px;text-align:center;">
<div class="modal-header">
<h2 style="color:#10B981;">恢复确认</h2>
</div>
<div class="modal-body" style="font-size:16px;">确定要恢复该需求吗?</div>
<div class="delete-dialog-footer" style="display:flex;justify-content:center;gap:18px;margin:18px 0 8px 0;">
<button class="confirm-button restore" @click="confirmRestore">确认恢复</button>
<button class="cancel-button" @click="cancelRestore">取消</button>
</div>
</div>
</div>
<!-- 添加需求弹窗 -->
<div v-if="showAddModal" class="modal-overlay" @click="closeAddModal">
<div class="modal-content" @click.stop>
<div class="modal-header">
<h2>添加需求</h2>
<button class="close-btn" @click="closeAddModal">&times;</button>
</div>
<div class="modal-body">
<form class="add-modal-form" @submit.prevent="submitAddForm">
<div class="form-row">
<span class="label">请求者:</span>
<input v-model="addForm.requester" class="input" placeholder="可选" />
</div>
<div class="form-row">
<span class="label">QQ号</span>
<input v-model="addForm.qq_code" class="input" placeholder="可选" />
</div>
<div class="form-row">
<span class="label">需求内容:</span>
<textarea
v-model="addForm.content"
class="input"
placeholder="请输入需求内容"
rows="3"
required
></textarea>
</div>
<div class="form-row">
<span class="label">赏金:</span>
<input v-model="addForm.reward" class="input" placeholder="可选" />
</div>
<div v-if="addError" class="error">{{ addError }}</div>
<button class="btn-common btn-gradient submit-btn" :disabled="addLoading">
{{ addLoading ? '提交中...' : '提交' }}
</button>
</form>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, computed } from 'vue'
import { getDemandsList, updateDemand, deleteDemand, addDemand } from '../../api/demands'
// 响应式数据
const demands = ref([])
const loading = ref(false)
const error = ref(null)
const showModal = ref(false)
const selectedDemand = ref(null)
const showDeleteConfirm = ref(false)
const showRestoreConfirm = ref(false)
const pendingDeleteId = ref(null)
const pendingRestoreId = ref(null)
const showDeletedItems = ref(false)
const currentOperation = ref('') // 'hide' 或 'delete'
const showAddModal = ref(false)
const addForm = ref({
requester: '',
content: '',
reward: '',
qq_code: ''
})
const addError = ref('')
const addLoading = ref(false)
// 计算属性:根据显示设置过滤需求
const displayDemands = computed(() => {
if (showDeletedItems.value) {
return demands.value
} else {
return demands.value.filter(demand => !isDeleted(demand.content))
}
})
// 判断是否为无赏金
const isNoReward = (reward) => {
return !reward || reward === '无赏金'
}
// 判断是否已删除
const isDeleted = (content) => {
return content?.startsWith('&DEL')
}
// 获取显示内容(去掉删除标记)
const getDisplayContent = (content) => {
if (!content) return ''
return content.replace(/^&DEL/, '')
}
// 获取回复数量
const getReplyCount = (sendcontent) => {
if (!sendcontent) return 0
return sendcontent.split('|').length
}
// 格式化日期
const formatDate = (dateString) => {
if (!dateString || dateString === 'Test_date') return '日期未提供'
try {
const date = new Date(dateString)
if (isNaN(date.getTime())) {
return dateString
}
const pad = num => num.toString().padStart(2, '0')
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
} catch (e) {
return dateString
}
}
// 获取需求列表
const fetchDemands = async () => {
loading.value = true
error.value = null
try {
const data = await getDemandsList()
demands.value = data
} catch (err) {
console.error('加载需求列表失败:', err)
if (err.response?.status === 403) {
error.value = '权限不足,请确认您有管理员权限'
} else if (err.response?.status === 401) {
error.value = '登录已过期,请重新登录'
} else {
error.value = `加载失败: ${err.response?.data?.detail || err.message}`
}
} finally {
loading.value = false
}
}
// 显示详情弹窗
const showDetail = (demand) => {
selectedDemand.value = demand
showModal.value = true
}
// 关闭弹窗
const closeModal = () => {
showModal.value = false
selectedDemand.value = null
}
// 处理隐藏
const handleHide = (demand) => {
pendingDeleteId.value = demand.id
currentOperation.value = 'hide'
showDeleteConfirm.value = true
}
// 确认操作
const confirmOperation = async () => {
try {
if (currentOperation.value === 'hide') {
const demand = demands.value.find(d => d.id === pendingDeleteId.value)
if (!demand) return
const updatedContent = `&DEL${demand.content}`
await updateDemand(pendingDeleteId.value, {
requester: demand.requester,
qq_code: demand.qq_code,
content: updatedContent,
reward: demand.reward,
date: demand.date,
sendcontent: demand.sendcontent
})
} else if (currentOperation.value === 'delete') {
await deleteDemand(pendingDeleteId.value)
}
fetchDemands()
} catch (e) {
console.error('操作失败:', e)
} finally {
showDeleteConfirm.value = false
pendingDeleteId.value = null
currentOperation.value = ''
}
}
// 处理删除
const handleDelete = (demand) => {
pendingDeleteId.value = demand.id
currentOperation.value = 'delete'
showDeleteConfirm.value = true
}
// 取消删除
const cancelDelete = () => {
showDeleteConfirm.value = false
pendingDeleteId.value = null
}
// 处理恢复
const handleRestore = (demand) => {
pendingRestoreId.value = demand.id
showRestoreConfirm.value = true
}
// 确认恢复
const confirmRestore = async () => {
try {
const demand = demands.value.find(d => d.id === pendingRestoreId.value)
if (!demand) return
const restoredContent = demand.content.replace(/^&DEL/, '')
await updateDemand(pendingRestoreId.value, {
requester: demand.requester,
qq_code: demand.qq_code,
content: restoredContent,
reward: demand.reward,
date: demand.date,
sendcontent: demand.sendcontent
})
fetchDemands()
} catch (e) {
console.error('恢复失败:', e)
} finally {
showRestoreConfirm.value = false
pendingRestoreId.value = null
}
}
// 取消恢复
const cancelRestore = () => {
showRestoreConfirm.value = false
pendingRestoreId.value = null
}
// 刷新
const onRefresh = () => {
fetchDemands()
}
// 打开添加需求弹窗
const openAddModal = () => {
addForm.value = {
requester: '',
content: '',
reward: '',
qq_code: ''
}
addError.value = ''
showAddModal.value = true
}
// 关闭添加需求弹窗
const closeAddModal = () => {
showAddModal.value = false
addError.value = ''
}
// 提交添加需求表单
const submitAddForm = async () => {
if (!addForm.value.content?.trim()) {
addError.value = '需求内容不能为空'
return
}
addLoading.value = true
addError.value = ''
try {
const now = new Date()
const dateStr = `${now.getFullYear()}-${(now.getMonth()+1).toString().padStart(2,'0')}-${now.getDate().toString().padStart(2,'0')} ${now.getHours().toString().padStart(2,'0')}:${now.getMinutes().toString().padStart(2,'0')}:${now.getSeconds().toString().padStart(2,'0')}`
const payload = {
requester: addForm.value.requester || '',
sendcontent: '',
content: addForm.value.content,
reward: addForm.value.reward || '',
date: dateStr,
qq_code: addForm.value.qq_code || ''
}
console.log('提交的数据:', payload)
await addDemand(payload)
showAddModal.value = false
fetchDemands() // 刷新列表
} catch (e) {
console.error('提交失败:', e)
addError.value = e.response?.data?.detail || '提交失败,请稍后重试'
} finally {
addLoading.value = false
}
}
// 解析回复文本
function parseReplyText(str) {
// 首先尝试解析 qq:内容 格式
const qqMatch = str.match(/^(\d+):(.+)$/)
if (qqMatch) {
return {
user: qqMatch[1],
content: qqMatch[2].trim()
}
}
// 兼容原有的昵称QQ号格式允许有空格
const match = str.match(/^(.+?[(][1-9][0-9]{4,}[)])(.*)$/)
if (match) {
return {
user: match[1].trim(),
content: match[2].replace(/^|^:/, '').trim()
}
}
// fallback
return {
user: '',
content: str
}
}
// 提取QQ号
function extractQQ(str) {
// 首先尝试解析 qq:内容 格式
const qqMatch = str.match(/^(\d+):(.+)$/)
if (qqMatch) {
return qqMatch[1]
}
// 兼容原有的昵称QQ号格式
const match = str.match(/[(]([1-9][0-9]{4,})[)]/)
return match ? match[1] : ''
}
// 组件挂载时自动加载数据
onMounted(() => {
fetchDemands()
})
</script>
<style scoped>
.affair-management {
padding: 20px;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.page-header h1 {
color: #1a237e;
margin: 0;
}
.header-actions {
display: flex;
gap: 10px;
}
.btn-common {
display: inline-block;
padding: 8px 22px;
font-size: 15px;
font-weight: 500;
border-radius: 6px;
border: 1px solid #b6d2ff;
cursor: pointer;
transition: background 0.2s, color 0.2s, border 0.2s;
outline: none;
box-shadow: none;
}
.btn-gradient {
background: linear-gradient(90deg, #71eaeb 0%, #416bdf 100%);
color: #fff;
border: 1px solid #71eaeb;
}
.btn-gradient:hover {
background: linear-gradient(90deg, #416bdf 0%, #71eaeb 100%);
color: #fff;
border: 1.5px solid #416bdf;
}
.btn-light {
background: linear-gradient(90deg, #e3f0ff 0%, #f7fbff 100%);
color: #2563eb;
border: 1px solid #b6d2ff;
}
.btn-light:hover {
background: linear-gradient(90deg, #d0e7ff 0%, #eaf4ff 100%);
color: #174ea6;
border: 1.5px solid #2563eb;
}
.btn-small {
padding: 5px 15px;
font-size: 14px;
}
.btn-danger {
background: #ef4444;
color: #fff;
border: 1px solid #ef4444;
}
.btn-danger:hover {
background: #dc2626;
border: 1.5px solid #dc2626;
}
.btn-warning {
background: #f59e0b;
color: #fff;
border: 1px solid #f59e0b;
}
.btn-warning:hover {
background: #d97706;
border: 1.5px solid #d97706;
}
.btn-restore {
background: #10b981;
color: #fff;
border: 1px solid #10b981;
}
.btn-restore:hover {
background: #059669;
border: 1.5px solid #059669;
}
.table-container {
background: white;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.maps-table {
width: 100%;
border-collapse: collapse;
}
.maps-table th,
.maps-table td {
text-align: center;
vertical-align: middle;
padding: 12px 8px;
border-bottom: 1px solid #f0f0f0;
}
.maps-table th {
background: #f8fafc;
font-weight: 600;
color: #1a237e;
}
.maps-table tr:hover {
background: #f8fafc;
}
.content {
max-width: 300px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.tag {
display: inline-block;
padding: 2px 10px;
border-radius: 12px;
font-size: 13px;
font-weight: 500;
line-height: 1.2;
background: none;
box-shadow: none;
border: none;
margin: 0;
vertical-align: middle;
}
.has-reward {
background: #fff7e6;
color: #d46b08;
border: 1px solid #ffd591;
}
.no-reward {
background: #f5f5f5;
color: #8c8c8c;
border: 1px solid #d9d9d9;
}
.active {
background: #f0f9ff;
color: #0369a1;
border: 1px solid #7dd3fc;
}
.deleted {
background: #fef2f2;
color: #dc2626;
border: 1px solid #fca5a5;
}
.actions {
display: flex;
gap: 5px;
justify-content: center;
}
/* 弹窗样式 */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: flex-start;
padding-top: 80px;
z-index: 1000;
}
.modal-content {
background: white;
border-radius: 8px;
width: 90%;
max-width: 800px;
max-height: 80vh;
overflow-y: auto;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.modal-header {
padding: 20px;
border-bottom: 1px solid #eee;
display: flex;
justify-content: space-between;
align-items: center;
}
.modal-header h2 {
margin: 0;
color: #1a237e;
font-size: 1.5rem;
}
.close-btn {
background: none;
border: none;
font-size: 24px;
color: #666;
cursor: pointer;
padding: 0;
line-height: 1;
}
.close-btn:hover {
color: #1a237e;
}
.modal-body {
padding: 20px;
}
.detail-item {
margin-bottom: 15px;
}
.detail-item:last-child {
margin-bottom: 0;
}
.label {
font-weight: 600;
color: #1a237e;
margin-right: 10px;
}
.value {
color: #333;
}
.reply-section {
margin-top: 20px;
padding-top: 20px;
border-top: 1px solid #eee;
}
.reply-section h3 {
color: #1a237e;
margin-bottom: 15px;
font-size: 1.2rem;
}
.reply-list {
background: none;
border-radius: 0;
padding: 0;
}
.reply-content {
text-align: left;
padding: 10px 0;
color: #333;
border-left: 3px solid #2563eb;
padding-left: 15px;
margin: 10px 0;
font-size: 14px;
line-height: 1.6;
background: transparent;
box-shadow: none;
}
.no-reply {
text-align: center;
color: #666;
padding: 20px;
background: #f7faff;
border-radius: 8px;
}
.reply-with-avatar {
display: flex;
align-items: flex-start;
gap: 8px;
margin-bottom: 16px;
}
.reply-with-avatar:last-child {
margin-bottom: 0;
}
.reply-avatar {
width: 40px;
height: 40px;
border-radius: 50%;
border: none;
object-fit: cover;
flex-shrink: 0;
background: #f3f6fa;
}
.reply-text {
flex: 1;
display: flex;
align-items: center;
min-height: 40px;
font-size: 15px;
color: #222;
line-height: 1.7;
padding-left: 14px;
background: none;
border-radius: 0;
box-shadow: none;
}
.reply-text b {
font-weight: 600;
margin-right: 6px;
color: #2563eb;
font-size: 15px;
}
.confirm-button {
background-color: #f56c6c;
color: white;
border: none;
padding: 8px 24px;
border-radius: 4px;
font-size: 14px;
cursor: pointer;
transition: background-color 0.3s;
}
.confirm-button:hover {
background-color: #f78989;
}
.confirm-button.restore {
background-color: #10b981;
}
.confirm-button.restore:hover {
background-color: #059669;
}
.cancel-button {
background-color: #f0f0f0;
color: #333;
border: none;
padding: 8px 24px;
border-radius: 4px;
font-size: 14px;
cursor: pointer;
}
.cancel-button:hover {
background-color: #e0e0e0;
}
.add-modal-form {
display: flex;
flex-direction: column;
gap: 15px;
}
.form-row {
display: flex;
align-items: center;
gap: 10px;
}
.label {
font-weight: 600;
color: #1a237e;
width: 100px;
}
.input {
flex: 1;
padding: 8px;
border: 1px solid #b6d2ff;
border-radius: 4px;
}
.error {
color: #f56c6c;
font-size: 14px;
margin-top: 5px;
}
.submit-btn {
background-color: #416bdf;
color: white;
border: none;
padding: 8px 24px;
border-radius: 4px;
font-size: 14px;
cursor: pointer;
transition: background-color 0.3s;
}
.submit-btn:hover {
background-color: #71eaeb;
}
.submit-btn:disabled {
background-color: #b6d2ff;
cursor: not-allowed;
}
.error-message {
color: #f56c6c;
font-size: 14px;
margin-bottom: 10px;
}
/* --- Mobile Responsiveness: Horizontal Scroll Table --- */
@media (max-width: 768px) {
/* Header actions */
.page-header {
flex-direction: column;
align-items: flex-start;
gap: 15px;
}
.header-actions {
display: flex;
flex-wrap: wrap;
gap: 10px;
width: 100%;
}
/* Make the table container scrollable */
.table-container {
overflow-x: auto;
-webkit-overflow-scrolling: touch; /* for smooth scrolling on iOS */
border: 1px solid #e2e8f0;
border-radius: 8px;
background: #fff;
}
/* Give the table a minimum width to ensure scrolling is needed */
.maps-table {
min-width: 900px;
width: 100%;
}
/* Modal Windows */
.modal-content {
width: calc(100% - 2rem);
margin: 1rem;
}
.modal-body {
max-height: 70vh;
}
}
</style>