chore: establish V1.1 recovery baseline

This commit is contained in:
2026-09-09 22:05:00 +08:00
commit b46b48eb33
947 changed files with 155471 additions and 0 deletions
+13
View File
@@ -0,0 +1,13 @@
<template>
<router-view />
</template>
<script setup lang="ts">
</script>
<style>
#app {
width: 100%;
height: 100%;
}
</style>
+46
View File
@@ -0,0 +1,46 @@
import axios from 'axios'
import { ElMessage } from 'element-plus'
import router from '@/router'
const client = axios.create({
baseURL: '/api/v1',
timeout: 10000,
})
client.interceptors.request.use((config) => {
const token = localStorage.getItem('accessToken')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
})
client.interceptors.response.use(
(res) => res.data,
(err) => {
if (err.response?.status === 401) {
localStorage.removeItem('accessToken')
router.push('/login')
ElMessage.error('登录已过期,请重新登录')
} else if (err.response?.data?.message) {
ElMessage.error(err.response.data.message)
} else {
ElMessage.error('请求失败')
}
return Promise.reject(err)
}
)
export const get = (url: string, params?: Record<string, any>, headers?: Record<string, string>) =>
client.get(url, { params, headers })
export const post = (url: string, data?: any, headers?: Record<string, string>) =>
client.post(url, data, { headers })
export const put = (url: string, data?: any, headers?: Record<string, string>) =>
client.put(url, data, { headers })
export const del = (url: string, headers?: Record<string, string>) =>
client.delete(url, { headers })
export default client
+137
View File
@@ -0,0 +1,137 @@
<template>
<el-container class="main-layout">
<el-aside :width="isCollapse ? '64px' : '220px'" class="sidebar">
<div class="logo">
<span v-if="!isCollapse">丹阳公益婚恋</span>
<span v-else>丹阳</span>
</div>
<el-menu :default-active="activeMenu" :collapse="isCollapse" :router="true"
background-color="#304156" text-color="#bfcbd9" active-text-color="#E96B6B" class="sidebar-menu">
<el-menu-item index="/dashboard" :disabled="false">
<el-icon><DataBoard /></el-icon><template #title>工作台</template>
</el-menu-item>
<el-menu-item v-if="hasPerm('review.view')" index="/review">
<el-icon><DocumentChecked /></el-icon><template #title>资料审核</template>
</el-menu-item>
<el-menu-item v-if="hasPerm('user.view')" index="/users">
<el-icon><User /></el-icon><template #title>用户管理</template>
</el-menu-item>
<el-menu-item v-if="hasPerm('organization.manage')" index="/organizations">
<el-icon><OfficeBuilding /></el-icon><template #title>机构管理</template>
</el-menu-item>
<el-menu-item v-if="hasPerm('permission.manage')" index="/operators">
<el-icon><Setting /></el-icon><template #title>人员管理</template>
</el-menu-item>
<el-menu-item v-if="hasPerm('activity.review_registration')" index="/activities/interests">
<el-icon><Calendar /></el-icon><template #title>活动互选</template>
</el-menu-item>
<el-menu-item v-if="hasPerm('risk.report.view')" index="/risk/reports">
<el-icon><Warning /></el-icon><template #title>举报中心</template>
</el-menu-item>
<el-menu-item v-if="hasPerm('risk.report.process')" index="/appeals">
<el-icon><ChatDotRound /></el-icon><template #title>申诉管理</template>
</el-menu-item>
<el-menu-item v-if="hasPerm('analytics.view')" index="/analytics">
<el-icon><TrendCharts /></el-icon><template #title>数据统计</template>
</el-menu-item>
<el-menu-item v-if="hasPerm('user.view')" index="/audit">
<el-icon><Document /></el-icon><template #title>审计日志</template>
</el-menu-item>
<!-- V1.1 新增菜单 -->
<el-menu-item v-if="hasPerm('banner.manage')" index="/banner">
<el-icon><Promotion /></el-icon><template #title>Banner管理</template>
</el-menu-item>
<el-menu-item v-if="hasPerm('content.manage')" index="/content">
<el-icon><FolderOpened /></el-icon><template #title>内容管理</template>
</el-menu-item>
<el-menu-item v-if="hasPerm('service_location.manage')" index="/service-station">
<el-icon><Location /></el-icon><template #title>善缘服务站</template>
</el-menu-item>
<el-menu-item v-if="hasPerm('service_appointment.manage')" index="/service-appointment">
<el-icon><Memo /></el-icon><template #title>服务预约</template>
</el-menu-item>
<el-menu-item v-if="hasPerm('service_program.manage')" index="/service-program">
<el-icon><Service /></el-icon><template #title>专项服务项目</template>
</el-menu-item>
<el-menu-item v-if="hasPerm('counseling.manage')" index="/counseling">
<el-icon><ChatDotRound /></el-icon><template #title>婚恋咨询</template>
</el-menu-item>
<el-menu-item v-if="hasPerm('referral.manage')" index="/referral">
<el-icon><Bell /></el-icon><template #title>转介管理</template>
</el-menu-item>
</el-menu>
</el-aside>
<el-container>
<el-header class="header">
<div class="header-left">
<el-icon class="collapse-btn" @click="isCollapse = !isCollapse">
<Fold v-if="!isCollapse" /><Expand v-else />
</el-icon>
<el-breadcrumb separator="/">
<el-breadcrumb-item :to="{ path: '/' }">首页</el-breadcrumb-item>
<el-breadcrumb-item>{{ currentTitle }}</el-breadcrumb-item>
</el-breadcrumb>
</div>
<div class="header-right">
<el-dropdown>
<span class="user-info">
<el-avatar :size="32" style="background:#E96B6B">{{ (operator?.name || '管')[0] }}</el-avatar>
<span class="user-name">{{ operator?.name || '工作人员' }}</span>
</span>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item @click="handleLogout">退出登录</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</el-header>
<el-main class="main-content">
<router-view v-slot="{ Component }">
<keep-alive><component :is="Component" /></keep-alive>
</router-view>
</el-main>
</el-container>
</el-container>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { Fold, Expand, DataBoard, DocumentChecked, User, OfficeBuilding, Setting, Calendar, Warning, ChatDotRound, TrendCharts, Document, Service, Location, Memo, Promotion, FolderOpened, Bell } from '@element-plus/icons-vue'
import { useAuthStore } from '@/stores/auth'
const route = useRoute()
const router = useRouter()
const authStore = useAuthStore()
const isCollapse = ref(false)
const activeMenu = computed(() => route.path)
const currentTitle = computed(() => (route.meta?.title as string) || '页面')
const operator = computed(() => authStore.operator)
function hasPerm(permission: string): boolean {
return (authStore.permissions || []).includes(permission)
}
function handleLogout() {
authStore.logout()
router.push('/login')
}
</script>
<style scoped>
.main-layout { height: 100vh; width: 100%; }
.sidebar { background-color: #304156; transition: width 0.3s; overflow: hidden; }
.logo { height: 60px; display: flex; align-items: center; justify-content: center; color: #fff; font-size: 16px; font-weight: bold; border-bottom: 1px solid #3a4a5e; }
.sidebar-menu { border-right: none; }
.header { background: #fff; border-bottom: 1px solid #e4e7ed; display: flex; align-items: center; justify-content: space-between; padding: 0 20px; }
.header-left { display: flex; align-items: center; gap: 16px; }
.collapse-btn { cursor: pointer; font-size: 20px; color: #666; }
.collapse-btn:hover { color: #E96B6B; }
.header-right { display: flex; align-items: center; gap: 16px; }
.user-info { display: flex; align-items: center; gap: 8px; cursor: pointer; }
.user-name { font-size: 14px; color: #333; }
.main-content { background: #f0f2f5; padding: 20px; overflow: auto; }
</style>
+22
View File
@@ -0,0 +1,22 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
import App from './App.vue'
import router from './router'
import './theme/index.css'
const app = createApp(App)
const pinia = createPinia()
// 注册所有 Element Plus 图标
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.component(key, component)
}
app.use(pinia)
app.use(router)
app.use(ElementPlus, { size: 'default', zIndex: 3000 })
app.mount('#app')
@@ -0,0 +1,89 @@
<template>
<div class="page-container">
<el-card shadow="never">
<template #header>
<div class="card-header">
<span>活动互选管理</span>
<el-input v-model="searchActivityId" placeholder="活动ID搜索" clearable style="width:260px" />
<el-button type="primary" @click="loadData" :loading="loading">查询</el-button>
</div>
</template>
<el-table :data="tableData" v-loading="loading" border stripe>
<el-table-column prop="activityId" label="活动ID" width="160" />
<el-table-column prop="activityTitle" label="活动名称" min-width="180" />
<el-table-column label="双向互选对数" width="140">
<template #default="{row}">{{ row.mutualPairs }} 对</template>
</el-table-column>
<el-table-column label="反馈提交数" width="120">
<template #default="{row}">{{ row.feedbackCount }}</template>
</el-table-column>
<el-table-column label="操作" width="120">
<template #default="{row}">
<el-button size="small" @click="viewMutuals(row.activityId)">查看互选</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
<!-- 互选详情抽屉 -->
<el-drawer v-model="drawerVisible" title="活动互选详情" size="480px">
<div v-if="currentActivity">
<h4>{{ currentActivity.title }} — 双向互选列表</h4>
<el-table :data="mutualList" border size="small" style="margin-top:16px">
<el-table-column prop="sourceName" label="表达者" width="120" />
<el-table-column prop="targetName" label="被表达者" width="120" />
<el-table-column prop="confirmedAt" label="确认时间" width="180" />
</el-table>
<el-empty v-if="!mutualList.length" description="暂无双向互选记录" />
</div>
</el-drawer>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { get } from '@/api/client'
const searchActivityId = ref('')
const loading = ref(false)
const tableData = ref<any[]>([])
const drawerVisible = ref(false)
const currentActivity = ref<any>(null)
const mutualList = ref<any[]>([])
async function loadData() {
loading.value = true
try {
const token = localStorage.getItem('accessToken')
const res: any = await get('/activities/operator/activity-interest-summary', {}, { Authorization: `Bearer ${token}` })
tableData.value = res || []
} catch (e) {
console.error(e)
} finally {
loading.value = false
}
}
async function viewMutuals(activityId: string) {
drawerVisible.value = true
try {
const token = localStorage.getItem('accessToken')
const [actRes, mutualRes]: any = await Promise.all([
get(`/activities/${activityId}`, {}, { Authorization: `Bearer ${token}` }),
get(`/activities/${activityId}/interests/mutual`, {}, { Authorization: `Bearer ${token}` })
])
currentActivity.value = actRes
mutualList.value = mutualRes || []
} catch (e) {
console.error(e)
}
}
onMounted(loadData)
</script>
<style scoped>
.page-container { padding: 24px; }
.card-header { display: flex; align-items: center; gap: 12px; }
</style>
@@ -0,0 +1,188 @@
<template>
<div class="page-container" v-loading="loading">
<!-- 日期选择 -->
<el-card shadow="never" style="margin-bottom:16px">
<el-form :inline="true">
<el-form-item label="时间范围">
<el-date-picker v-model="dateRange" type="daterange" range-separator="至"
start-placeholder="开始日期" end-placeholder="结束日期" value-format="YYYY-MM-DD" />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="loadData">查询</el-button>
<el-button @click="loadDailySummary">每日汇总</el-button>
</el-form-item>
</el-form>
</el-card>
<!-- 用户漏斗 -->
<el-card shadow="never" style="margin-bottom:16px">
<template #header><span>用户转化漏斗</span></template>
<el-row :gutter="16">
<el-col :span="6" v-for="(stage) in funnelStages" :key="stage.key">
<div class="funnel-stage" :style="{ backgroundColor: stage.color + '15', borderColor: stage.color }">
<div class="funnel-label">{{ stage.label }}</div>
<div class="funnel-value">{{ stage.value }}</div>
<div class="funnel-rate" v-if="stage.rate !== null">{{ stage.rate }}</div>
</div>
</el-col>
</el-row>
<el-descriptions :column="4" border size="small" style="margin-top:16px">
<el-descriptions-item label="累计注册">{{ funnelStats.totalRegistered }}</el-descriptions-item>
<el-descriptions-item label="实名用户">{{ funnelStats.verifiedCount }}</el-descriptions-item>
<el-descriptions-item label="审核通过">{{ funnelStats.profileApproved }}</el-descriptions-item>
<el-descriptions-item label="7日活跃">{{ funnelStats.activeUsers }}</el-descriptions-item>
</el-descriptions>
</el-card>
<!-- Match漏斗 -->
<el-card shadow="never" style="margin-bottom:16px">
<template #header><span>Match转化漏斗</span></template>
<el-row :gutter="16">
<el-col :span="4" v-for="stage in matchStages" :key="stage.key">
<div class="funnel-stage" :style="{ backgroundColor: stage.color + '15', borderColor: stage.color }">
<div class="funnel-label">{{ stage.label }}</div>
<div class="funnel-value">{{ stage.value }}</div>
<div class="funnel-rate">{{ stage.rate }}</div>
</div>
</el-col>
</el-row>
</el-card>
<!-- 安全统计 -->
<el-row :gutter="16" style="margin-bottom:16px">
<el-col :span="6" v-for="card in safetyCards" :key="card.key">
<el-card shadow="hover" class="stat-card">
<div class="stat-value" :style="{ color: card.color }">{{ card.value }}</div>
<div class="stat-label">{{ card.label }}</div>
</el-card>
</el-col>
</el-row>
<!-- 每日汇总表 -->
<el-card shadow="never" v-if="dailySummary.length > 0">
<template #header><span>每日汇总(最近{{ dailySummary.length }}天)</span></template>
<el-table :data="dailySummary" border stripe size="small">
<el-table-column prop="date" label="日期" width="120" />
<el-table-column label="新增用户" width="100"><template #default="{row}">{{ row.newUsers }}</template></el-table-column>
<el-table-column label="新增Match" width="100"><template #default="{row}">{{ row.newMatches }}</template></el-table-column>
<el-table-column label="新增聊天" width="100"><template #default="{row}">{{ row.newConversations }}</template></el-table-column>
<el-table-column label="新增举报" width="100"><template #default="{row}">{{ row.newReports }}</template></el-table-column>
</el-table>
</el-card>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { get } from '@/api/client'
const loading = ref(false)
const dateRange = ref<[string, string]>([
new Date(Date.now() - 30 * 86400000).toISOString().slice(0, 10),
new Date().toISOString().slice(0, 10)
])
const funnelStats = ref<any>({})
const matchFunnelStats = ref<any>({})
const safetyStats = ref<any>({})
const dailySummary = ref<any[]>([])
const funnelStages = [
{ key: 'registered', label: '累计注册', value: 0, rate: null, color: '#409EFF' },
{ key: 'verified', label: '已实名', value: 0, rate: null, color: '#67C23A' },
{ key: 'approved', label: '审核通过', value: 0, rate: null, color: '#E59A32' },
{ key: 'active', label: '7日活跃', value: 0, rate: null, color: '#E96B6B' }
]
const matchStages = [
{ key: 'recommended', label: '推荐', value: 0, rate: '-', color: '#409EFF' },
{ key: 'liked', label: '喜欢', value: 0, rate: '-', color: '#67C23A' },
{ key: 'matches', label: 'Match', value: 0, rate: '-', color: '#E59A32' },
{ key: 'chats', label: '聊天', value: 0, rate: '-', color: '#909399' },
{ key: 'knowing', label: '了解中', value: 0, rate: '-', color: '#E96B6B' },
{ key: 'success', label: '成功牵手', value: 0, rate: '-', color: '#F56C6C' }
]
const safetyCards = [
{ key: 'pending', label: '待处理举报', value: 0, color: '#E96B6B' },
{ key: 'resolved', label: '已处理举报', value: 0, color: '#67C23A' },
{ key: 'highRisk', label: '高风险用户', value: 0, color: '#E59A32' },
{ key: 'banned', label: '封禁用户', value: 0, color: '#F56C6C' }
]
async function loadFunnel() {
try {
const token = localStorage.getItem('accessToken')
if (dateRange.value[0] && dateRange.value[1]) {
const [userRes, matchRes, safetyRes]: any[] = await Promise.all([
get('/operator/analytics/user-funnel', {
since: dateRange.value[0], until: dateRange.value[1]
}, { Authorization: `Bearer ${token}` }),
get('/operator/analytics/match-funnel', {
since: dateRange.value[0], until: dateRange.value[1]
}, { Authorization: `Bearer ${token}` }),
get('/operator/analytics/safety', {}, { Authorization: `Bearer ${token}` })
])
funnelStats.value = userRes
matchFunnelStats.value = matchRes
safetyStats.value = safetyRes
// 更新漏斗显示
funnelStages[0].value = userRes.totalRegistered || 0
funnelStages[1].value = userRes.verifiedCount || 0
funnelStages[1].rate = userRes.verifyRate || '-'
funnelStages[2].value = userRes.profileApproved || 0
funnelStages[2].rate = userRes.approvalRate || '-'
funnelStages[3].value = userRes.activeUsers || 0
funnelStages[3].rate = userRes.activeRate || '-'
matchStages[0].value = matchRes.totalRecommended || 0
matchStages[1].value = matchRes.totalLiked || 0
matchStages[1].rate = matchRes.likeRate || '-'
matchStages[2].value = matchRes.totalMatches || 0
matchStages[2].rate = matchRes.matchRate || '-'
matchStages[3].value = matchRes.chatsStarted || 0
matchStages[3].rate = matchRes.chatRate || '-'
matchStages[4].value = matchRes.gettingToKnow || 0
matchStages[5].value = matchRes.successPairs || 0
matchStages[5].rate = matchRes.successRate || '-'
safetyCards[0].value = safetyRes.pendingReports || 0
safetyCards[1].value = safetyRes.resolvedReports || 0
safetyCards[2].value = safetyRes.highRiskUsers || 0
safetyCards[3].value = safetyRes.bannedUsers || 0
}
} catch (e) { console.error(e) }
}
async function loadDailySummary() {
try {
const token = localStorage.getItem('accessToken')
const res: any = await get('/operator/analytics/daily-summary', { days: 30 }, { Authorization: `Bearer ${token}` })
dailySummary.value = res || []
} catch (e) { console.error(e) }
}
async function loadData() {
loading.value = true
try { await Promise.all([loadFunnel(), loadDailySummary()]) }
finally { loading.value = false }
}
onMounted(loadData)
</script>
<style scoped>
.page-container { padding: 24px; }
.funnel-stage {
border: 2px solid;
border-radius: 12px;
padding: 20px;
text-align: center;
}
.funnel-label { font-size: 14px; color: #666; margin-bottom: 8px; }
.funnel-value { font-size: 28px; font-weight: bold; color: #333; }
.funnel-rate { font-size: 12px; color: #999; margin-top: 4px; }
.stat-card { text-align: center; }
.stat-value { font-size: 32px; font-weight: bold; }
.stat-label { font-size: 13px; color: #666; margin-top: 6px; }
</style>
@@ -0,0 +1,174 @@
<template>
<div class="page-container">
<el-row :gutter="16">
<el-col :span="4" v-for="s in stats" :key="s.key">
<el-card shadow="hover" class="stat-card">
<div class="stat-value">{{ s.value }}</div>
<div class="stat-label">{{ s.label }}</div>
</el-card>
</el-col>
</el-row>
<el-card shadow="never" style="margin-top:16px">
<template #header>
<div class="card-header">
<span>申诉管理</span>
<el-tag type="warning" v-if="stats.find(s=>s.key==='pending')?.value">待处理 {{ stats.find(s=>s.key==='pending')?.value }}</el-tag>
</div>
</template>
<el-table :data="tableData" v-loading="loading" border stripe>
<el-table-column prop="userName" label="用户" width="120" />
<el-table-column prop="appealTypeText" label="申诉类型" width="120" />
<el-table-column prop="reason" label="申诉原因" min-width="220" show-overflow-tooltip />
<el-table-column prop="contactMethod" label="联系方式" width="140" />
<el-table-column prop="status" label="状态" width="100">
<template #default="{row}">
<el-tag :type="statusTagType(row.status)">{{ statusText(row.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="createdAt" label="提交时间" width="180">
<template #default="{row}">{{ formatTime(row.createdAt) }}</template>
</el-table-column>
<el-table-column label="操作" width="120">
<template #default="{row}">
<el-button v-if="row.status==='pending'" type="primary" size="small" @click="openDetail(row.id)">处理</el-button>
<el-button size="small" @click="openDetail(row.id)">查看</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
<!-- 申诉详情抽屉 -->
<el-drawer v-model="drawerVisible" title="申诉详情" size="520px">
<div v-if="detail">
<el-descriptions :column="2" border>
<el-descriptions-item label="用户">{{ detail.userName }}</el-descriptions-item>
<el-descriptions-item label="申诉类型">{{ detail.appealTypeText }}</el-descriptions-item>
<el-descriptions-item label="状态">
<el-tag :type="statusTagType(detail.status)">{{ statusText(detail.status) }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="联系方式">{{ detail.contactMethod || '无' }}</el-descriptions-item>
<el-descriptions-item label="申诉原因" :span="2">{{ detail.reason }}</el-descriptions-item>
<el-descriptions-item label="证据" :span="2">
<span v-if="detail.evidence">见原始证据文件</span>
<span v-else class="text-muted">未上传</span>
</el-descriptions-item>
<el-descriptions-item label="处置备注" :span="2" v-if="detail.decisionNote">
{{ detail.decisionNote }}
</el-descriptions-item>
</el-descriptions>
<template v-if="detail.status==='pending'||detail.status==='reviewing'">
<h4 style="margin:20px 0 12px;color:#E96B6B">审核决策</h4>
<el-form label-width="100px">
<el-form-item label="处理结果">
<el-select v-model="decision" style="width:100%">
<el-option label="通过申诉,解除限制" value="approve" />
<el-option label="驳回申诉,维持原处置" value="reject" />
<el-option label="升级人工进一步调查" value="escalate" />
</el-select>
</el-form-item>
<el-form-item label="审核说明">
<el-input v-model="decisionNote" type="textarea" :rows="3" placeholder="请输入审核意见" />
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="processing" @click="handleProcess">确认提交</el-button>
<el-button @click="drawerVisible=false">取消</el-button>
</el-form-item>
</el-form>
</template>
</div>
</el-drawer>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { get, post } from '@/api/client'
const loading = ref(false)
const processing = ref(false)
const tableData = ref<any[]>([])
const stats = ref<any[]>([])
const drawerVisible = ref(false)
const currentId = ref('')
const detail = ref<any>(null)
const decision = ref('approve')
const decisionNote = ref('')
const STATUS_MAP: Record<string, { text: string; type: string }> = {
pending: { text: '待审核', type: 'warning' },
reviewing: { text: '审核中', type: 'info' },
approved: { text: '已通过', type: 'success' },
rejected: { text: '已驳回', type: 'danger' },
escalated: { text: '已升级', type: 'warning' }
}
async function loadData() {
loading.value = true
try {
const token = localStorage.getItem('accessToken')
const res: any = await get('/operator/appeals/pending', {}, { Authorization: `Bearer ${token}` })
tableData.value = res || []
loadStats()
} catch (e) { console.error(e) }
finally { loading.value = false }
}
async function loadStats() {
try {
const token = localStorage.getItem('accessToken')
const res: any = await get('/operator/appeals/stats', {}, { Authorization: `Bearer ${token}` })
stats.value = [
{ key: 'pending', label: '待审核', value: res.pendingCount || 0, color: '#E96B6B' },
{ key: 'reviewing', label: '审核中', value: res.reviewingCount || 0, color: '#E59A32' },
{ key: 'approved', label: '已通过', value: res.approvedCount || 0, color: '#67C23A' },
{ key: 'rejected', label: '已驳回', value: res.rejectedCount || 0, color: '#909399' },
{ key: 'escalated', label: '已升级', value: res.escalatedCount || 0, color: '#F56C6C' }
]
} catch {}
}
async function openDetail(id: string) {
currentId.value = id
drawerVisible.value = true
decision.value = 'approve'
decisionNote.value = ''
try {
const token = localStorage.getItem('accessToken')
const res: any = await get(`/operator/appeals/${id}`, {}, { Authorization: `Bearer ${token}` })
detail.value = res
} catch (e) { console.error(e) }
}
async function handleProcess() {
if (!decision.value) return
processing.value = true
try {
const token = localStorage.getItem('accessToken')
await post(`/operator/appeals/${currentId.value}/process`,
{ decision: decision.value, decisionNote: decisionNote.value },
{ Authorization: `Bearer ${token}` })
drawerVisible.value = false
detail.value = null
loadData()
} catch (e) { console.error(e) }
finally { processing.value = false }
}
function statusTagType(s: string) { return STATUS_MAP[s]?.type || 'info' }
function statusText(s: string) { return STATUS_MAP[s]?.text || s }
function formatTime(t?: string) { return t ? new Date(t).toLocaleString('zh-CN') : '-' }
onMounted(loadData)
</script>
<style scoped>
.page-container { padding: 24px; }
.stat-card { text-align: center; }
.stat-value { font-size: 32px; font-weight: bold; color: #409EFF; }
.stat-label { font-size: 13px; color: #666; margin-top: 6px; }
.card-header { display: flex; justify-content: space-between; align-items: center; }
.text-muted { color: #BBB; font-size: 13px; }
</style>
@@ -0,0 +1,95 @@
<template>
<div class="page-container">
<el-card shadow="never">
<template #header>
<div class="card-header">
<span>审计日志</span>
<el-radio-group v-model="resourceFilter" size="small" @change="loadData">
<el-radio-button label="">全部</el-radio-button>
<el-radio-button label="user">用户</el-radio-button>
<el-radio-button label="operator">Operator</el-radio-button>
<el-radio-button label="report">举报</el-radio-button>
<el-radio-button label="appeal">申诉</el-radio-button>
<el-radio-button label="activity">活动</el-radio-button>
</el-radio-group>
</div>
</template>
<el-table :data="tableData" v-loading="loading" border stripe>
<el-table-column prop="operatorId" label="操作人ID" width="140" />
<el-table-column prop="action" label="操作" width="160">
<template #default="{row}">{{ actionText(row.action) }}</template>
</el-table-column>
<el-table-column prop="resourceType" label="资源类型" width="120">
<template #default="{row}">{{ row.resourceType }}</template>
</el-table-column>
<el-table-column prop="resourceId" label="资源ID" width="140" show-overflow-tooltip />
<el-table-column prop="ip" label="IP地址" width="140" />
<el-table-column prop="createdAt" label="时间" width="180">
<template #default="{row}">{{ formatTime(row.createdAt) }}</template>
</el-table-column>
</el-table>
<el-pagination v-if="total > 0" style="margin-top:16px;text-align:right"
layout="total, prev, pager, next" :total="total" :page-size="pageSize"
:current-page="page" @current-change="handlePageChange" />
</el-card>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { get } from '@/api/client'
const loading = ref(false)
const tableData = ref<any[]>([])
const total = ref(0)
const page = ref(0)
const pageSize = ref(20)
const resourceFilter = ref('')
const ACTION_TEXT: Record<string, string> = {
CREATE_OPERATOR: '创建操作员',
DISABLE_OPERATOR: '停用操作员',
ENABLE_OPERATOR: '启用操作员',
RESET_PASSWORD: '重置密码',
VIEW_USER_DETAIL: '查看用户详情',
UPDATE_USER_STATUS: '修改用户状态',
SUBMIT_REPORT: '提交举报',
PROCESS_REPORT: '处理举报',
SUBMIT_APPEAL: '提交申诉',
PROCESS_APPEAL: '处理申诉',
CREATE_ORGANIZATION: '创建机构',
DISABLE_ORGANIZATION: '停用机构',
ENABLE_ORGANIZATION: '启用机构',
CREATE_ACTIVITY: '创建活动',
APPROVE_REGISTRATION: '审核报名',
REJECT_REGISTRATION: '拒绝报名'
}
async function loadData() {
loading.value = true
try {
const token = localStorage.getItem('accessToken')
const res: any = await get('/operator/audit/logs', {
resourceType: resourceFilter.value || undefined,
page: page.value,
pageSize: pageSize.value
}, { Authorization: `Bearer ${token}` })
tableData.value = res.list || []
total.value = res.total || 0
} catch (e) { console.error(e) }
finally { loading.value = false }
}
function handlePageChange(p: number) { page.value = p - 1; loadData() }
function actionText(a: string) { return ACTION_TEXT[a] || a }
function formatTime(t?: string) { return t ? new Date(t).toLocaleString('zh-CN') : '-' }
onMounted(loadData)
</script>
<style scoped>
.page-container { padding: 24px; }
.card-header { display: flex; justify-content: space-between; align-items: center; }
</style>
@@ -0,0 +1,144 @@
<template>
<div class="banner-management">
<el-card>
<template #header>
<div class="card-header">
<span>首页 Banner 管理</span>
<el-button type="primary" @click="openCreate">新建 Banner</el-button>
</div>
</template>
<el-table :data="banners" v-loading="loading" stripe>
<el-table-column prop="title" label="标题" width="150" />
<el-table-column prop="target_type" label="跳转类型" width="120">
<template #default="{ row }">
<el-tag size="small">{{ bannerTargetTypeLabel(row.target_type) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="audience_scope" label="展示范围" width="120">
<template #default="{ row }">
<el-tag size="small" :type="audienceScopeTag(row.audience_scope)">{{ audienceScopeLabel(row.audience_scope) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="100">
<template #default="{ row }">
<el-tag :type="statusTag(row.status)">{{ statusLabel(row.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="sort_order" label="排序" width="80" />
<el-table-column label="时间范围" width="200">
<template #default="{ row }">
<span>{{ formatDateTime(row.start_at) }} ~ {{ formatDateTime(row.end_at) }}</span>
</template>
</el-table-column>
<el-table-column label="操作" width="200">
<template #default="{ row }">
<el-button size="small" @click="openEdit(row)">编辑</el-button>
<el-button size="small" type="warning" @click="toggleStatus(row)">
{{ row.status === 'active' ? '下架' : '上架' }}
</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
<!-- 新建/编辑 Dialog -->
<el-dialog v-model="dialogVisible" :title="editingId ? '编辑 Banner' : '新建 Banner'" width="600px">
<el-form :model="form" label-width="100px">
<el-form-item label="标题"><el-input v-model="form.title" /></el-form-item>
<el-form-item label="副标题"><el-input v-model="form.subtitle" /></el-form-item>
<el-form-item label="跳转类型">
<el-select v-model="form.target_type">
<el-option label="公益活动" value="activity" />
<el-option label="公益红娘" value="matchmaker" />
<el-option label="婚恋咨询" value="counseling" />
<el-option label="婚前辅导" value="premarital" />
<el-option label="善缘服务站" value="service_location" />
<el-option label="文章内容" value="article" />
<el-option label="内部页面" value="internal_page" />
<el-option label="无" value="none" />
</el-select>
</el-form-item>
<el-form-item label="目标值"><el-input v-model="form.target_value" placeholder="活动ID/文章ID等" /></el-form-item>
<el-form-item label="展示范围">
<el-select v-model="form.audience_scope">
<el-option label="全部用户" value="all" />
<el-option label="未注册" value="onboarding" />
<el-option label="正在寻找" value="searching" />
<el-option label="初步了解" value="getting_to_know" />
<el-option label="成功牵手" value="successful" />
</el-select>
</el-form-item>
<el-form-item label="排序"><el-input-number v-model="form.sort_order" :min="0" /></el-form-item>
<el-form-item label="展示开始"><el-date-picker v-model="form.startAt" type="datetime" /></el-form-item>
<el-form-item label="展示结束"><el-date-picker v-model="form.endAt" type="datetime" /></el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="saveBanner">保存</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { get, post, put } from '@/api/client'
import { ElMessage } from 'element-plus'
const loading = ref(false)
const banners = ref<any[]>([])
const dialogVisible = ref(false)
const editingId = ref<string | null>(null)
const form = ref({ title: '', subtitle: '', target_type: 'none', target_value: '', audience_scope: 'all', sort_order: 0, start_at: '', end_at: '' })
const TARGET_TYPE_LABEL: Record<string, string> = { activity: '活动', matchmaker: '红娘', counseling: '咨询', premarital: '婚前辅导', service_location: '服务站', article: '文章', internal_page: '内部页', none: '无' }
const AUDIENCE_SCOPE_LABEL: Record<string, string> = { all: '全部', onboarding: '未注册', searching: '寻找中', getting_to_know: '初步了解', successful: '成功牵手' }
const STATUS_LABEL: Record<string, string> = { draft: '草稿', scheduled: '定时', active: '生效中', inactive: '已下架', expired: '已过期' }
const STATUS_TAG: Record<string, string> = { draft: 'info', scheduled: 'warning', active: 'success', inactive: '', expired: 'danger' }
function bannerTargetTypeLabel(t: string) { return TARGET_TYPE_LABEL[t] || t }
function audienceScopeLabel(s: string) { return AUDIENCE_SCOPE_LABEL[s] || s }
function audienceScopeTag(s: string) { return s === 'all' ? 'success' : 'info' }
function statusLabel(s: string) { return STATUS_LABEL[s] || s }
function statusTag(s: string) { return STATUS_TAG[s] || 'info' }
function formatDateTime(d: string) { return d ? new Date(d).toLocaleString('zh-CN') : '-' }
async function loadBanners() {
loading.value = true
try {
const res: any = await get('/home/banners')
banners.value = res.data || []
} catch (e) { console.error(e) } finally { loading.value = false }
}
function openCreate() { editingId.value = null; Object.assign(form.value, { title: '', subtitle: '', target_type: 'none', target_value: '', audience_scope: 'all', sort_order: 0, start_at: '', end_at: '' }); dialogVisible.value = true }
function openEdit(row: any) { editingId.value = row.id; Object.assign(form.value, { title: row.title, subtitle: row.subtitle, target_type: row.target_type, target_value: row.target_value, audience_scope: row.audience_scope, sort_order: row.sort_order, start_at: row.start_at, end_at: row.end_at }); dialogVisible.value = true }
async function saveBanner() {
try {
if (editingId.value) {
await put(`/home/banners/${editingId.value}`, form.value)
ElMessage.success('更新成功')
} else {
await post('/home/banners', form.value)
ElMessage.success('创建成功')
}
dialogVisible.value = false
loadBanners()
} catch (e) { ElMessage.error('操作失败') }
}
async function toggleStatus(row: any) {
try {
const newStatus = row.status === 'active' ? 'inactive' : 'active'
await put(`/home/banners/${row.id}`, { ...row, status: newStatus })
await loadBanners()
} catch (e) { ElMessage.error('操作失败') }
}
onMounted(loadBanners)
</script>
<style scoped>
.card-header { display: flex; justify-content: space-between; align-items: center; }
</style>
@@ -0,0 +1,154 @@
<template>
<div class="content-management">
<el-tabs v-model="activeTab">
<el-tab-pane label="内容分类" name="categories">
<el-card>
<template #header><div class="card-header"><span>内容分类</span><el-button type="primary" size="small" @click="openCategoryDialog">新建分类</el-button></div></template>
<el-table :data="categories" stripe>
<el-table-column prop="name" label="分类名称" />
<el-table-column prop="code" label="编码" width="150" />
<el-table-column prop="sort_order" label="排序" width="80" />
<el-table-column prop="status" label="状态" width="100">
<template #default="{ row }"><el-tag :type="row.status==='active'?'success':''">{{ row.status }}</el-tag></template>
</el-table-column>
<el-table-column label="操作" width="150">
<template #default="{ row }">
<el-button size="small" @click="editCategory(row)">编辑</el-button>
<el-button size="small" type="danger" @click="deleteCategory(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
</el-tab-pane>
<el-tab-pane label="文章内容" name="articles">
<el-card>
<template #header><div class="card-header"><span>文章内容</span><el-button type="primary" size="small" @click="openArticleDialog">新建文章</el-button></div></template>
<el-table :data="articles" stripe v-loading="loading">
<el-table-column prop="title" label="标题" />
<el-table-column label="分类" width="120">
<template #default="{ row }">{{ categoryLabel(row.category_id) }}</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="100">
<template #default="{ row }"><el-tag :type="statusTag(row.status)">{{ statusLabel(row.status) }}</el-tag></template>
</el-table-column>
<el-table-column prop="view_count" label="阅读量" width="80" />
<el-table-column label="操作" width="200">
<template #default="{ row }">
<el-button size="small" @click="editArticle(row)">编辑</el-button>
<el-button v-if="row.status==='draft'" size="small" type="success" @click="publishArticle(row)">发布</el-button>
<el-button v-if="row.status==='published'" size="small" type="warning" @click="offlineArticle(row)">下架</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
</el-tab-pane>
</el-tabs>
<!-- 分类Dialog -->
<el-dialog v-model="categoryDialogVisible" :title="editingCategoryId ? '编辑分类' : '新建分类'" width="500px">
<el-form :model="categoryForm" label-width="80px">
<el-form-item label="名称"><el-input v-model="categoryForm.name" /></el-form-item>
<el-form-item label="编码"><el-input v-model="categoryForm.code" /></el-form-item>
<el-form-item label="排序"><el-input-number v-model="categoryForm.sortOrder" :min="0" /></el-form-item>
</el-form>
<template #footer>
<el-button @click="categoryDialogVisible=false">取消</el-button>
<el-button type="primary" @click="saveCategory">保存</el-button>
</template>
</el-dialog>
<!-- 文章Dialog -->
<el-dialog v-model="articleDialogVisible" :title="editingArticleId ? '编辑文章' : '新建文章'" width="700px">
<el-form :model="articleForm" label-width="80px">
<el-form-item label="标题"><el-input v-model="articleForm.title" /></el-form-item>
<el-form-item label="分类">
<el-select v-model="articleForm.categoryId">
<el-option v-for="c in categories" :key="c.id" :label="c.name" :value="c.id" />
</el-select>
</el-form-item>
<el-form-item label="摘要"><el-input v-model="articleForm.summary" type="textarea" :rows="2" /></el-form-item>
<el-form-item label="内容"><el-input v-model="articleForm.content" type="textarea" :rows="8" /></el-form-item>
</el-form>
<template #footer>
<el-button @click="articleDialogVisible=false">取消</el-button>
<el-button type="primary" @click="saveArticle">保存</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { get, post, put, del } from '@/api/client'
import { ElMessage } from 'element-plus'
const activeTab = ref('categories')
const loading = ref(false)
const categories = ref<any[]>([])
const articles = ref<any[]>([])
const categoryDialogVisible = ref(false)
const editingCategoryId = ref<string | null>(null)
const categoryForm = ref({ name: '', code: '', sortOrder: 0 })
const articleDialogVisible = ref(false)
const editingArticleId = ref<string | null>(null)
const articleForm = ref({ title: '', categoryId: '', summary: '', content: '' })
const STATUS_LABEL: Record<string, string> = { draft: '草稿', published: '已发布', offline: '已下架' }
const STATUS_TAG: Record<string, string> = { draft: 'info', published: 'success', offline: 'danger' }
function statusLabel(s: string) { return STATUS_LABEL[s] || s }
function statusTag(s: string) { return STATUS_TAG[s] || 'info' }
function categoryLabel(id: string) { const c = categories.value.find(x => x.id === id); return c ? c.name : '-' }
async function loadCategories() {
const res: any = await get('/content/categories')
categories.value = res.data || []
}
async function loadArticles() {
loading.value = true
try {
const res: any = await get('/content/articles')
articles.value = res.data || []
} finally { loading.value = false }
}
async function saveCategory() {
try {
if (editingCategoryId.value) await put(`/content/operator/categories/${editingCategoryId.value}`, categoryForm.value)
else await post('/content/operator/categories', categoryForm.value)
ElMessage.success('保存成功')
categoryDialogVisible.value = false
loadCategories()
} catch (e: any) { ElMessage.error(e?.response?.data?.message || '保存失败') }
}
async function editCategory(row: any) { editingCategoryId.value = row.id; Object.assign(categoryForm.value, row); categoryDialogVisible.value = true }
async function deleteCategory(row: any) {
try { await del(`/content/operator/categories/${row.id}`); ElMessage.success('删除成功'); loadCategories() }
catch (e) { ElMessage.error('删除失败') }
}
async function saveArticle() {
try {
if (editingArticleId.value) await put(`/content/operator/articles/${editingArticleId.value}`, articleForm.value)
else await post('/content/operator/articles', articleForm.value)
ElMessage.success('保存成功')
articleDialogVisible.value = false
loadArticles()
} catch (e: any) { ElMessage.error(e?.response?.data?.message || '保存失败') }
}
async function editArticle(row: any) { editingArticleId.value = row.id; Object.assign(articleForm.value, { title: row.title, categoryId: row.category_id, summary: row.summary, content: row.content }); articleDialogVisible.value = true }
async function publishArticle(row: any) {
try { await post(`/content/operator/articles/${row.id}/publish`, {}); ElMessage.success('发布成功'); loadArticles() }
catch (e) { ElMessage.error('发布失败') }
}
async function offlineArticle(row: any) {
try { await post(`/content/operator/articles/${row.id}/offline`, {}); ElMessage.success('下架成功'); loadArticles() }
catch (e) { ElMessage.error('下架失败') }
}
function openCategoryDialog() { editingCategoryId.value = null; Object.assign(categoryForm.value, { name: '', code: '', sortOrder: 0 }); categoryDialogVisible.value = true }
function openArticleDialog() { editingArticleId.value = null; Object.assign(articleForm.value, { title: '', categoryId: '', summary: '', content: '' }); articleDialogVisible.value = true }
onMounted(() => { loadCategories(); loadArticles() })
</script>
<style
scoped></style>
@@ -0,0 +1,155 @@
<template>
<div class="counseling-management">
<el-card>
<template #header><div class="card-header"><span>公益婚恋咨询管理</span></div></template>
<el-tabs v-model="activeTab">
<el-tab-pane label="待分配队列" name="queue">
<el-table :data="pendingQueue" stripe v-loading="loading">
<el-table-column prop="userId" label="用户ID" width="120" />
<el-table-column prop="serviceType" label="服务类型" width="150">
<template #default="{ row }">{{ serviceTypeLabel(row.serviceType) }}</template>
</el-table-column>
<el-table-column prop="requestSource" label="来源" width="140">
<template #default="{ row }"><el-tag size="small">{{ requestSourceLabel(row.requestSource) }}</el-tag></template>
</el-table-column>
<el-table-column prop="userRequestSummary" label="问题描述" show-overflow-tooltip />
<el-table-column prop="createdAt" label="申请时间" width="160">
<template #default="{ row }">{{ formatDateTime(row.createdAt) }}</template>
</el-table-column>
<el-table-column label="操作" width="150">
<template #default="{ row }">
<el-button size="small" type="primary" @click="openAssign(row)">分配咨询师</el-button>
</template>
</el-table-column>
</el-table>
</el-tab-pane>
<el-tab-pane label="我的咨询(Counselor)" name="assigned">
<el-table :data="assignedServices" stripe v-loading="loading">
<el-table-column prop="serviceType" label="类型" width="140">
<template #default="{ row }">{{ serviceTypeLabel(row.serviceType) }}</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="100">
<template #default="{ row }"><el-tag :type="statusTag(row.status)">{{ statusLabel(row.status) }}</el-tag></template>
</el-table-column>
<el-table-column prop="scheduledStartAt" label="预约时间" width="160">
<template #default="{ row }">{{ formatDateTime(row.scheduledStartAt) }}</template>
</el-table-column>
<el-table-column label="操作" width="200">
<template #default="{ row }">
<el-button v-if="row.status==='assigned'" size="small" @click="scheduleCounseling(row)">排期</el-button>
<el-button v-if="row.status==='scheduled'" size="small" type="success" @click="startCounseling(row)">开始</el-button>
<el-button v-if="row.status==='in_progress'" size="small" type="primary" @click="showFollowup(row)">跟进</el-button>
<el-button v-if="row.status==='in_progress'" size="small" type="warning" @click="completeCounseling(row)">完成</el-button>
</template>
</el-table-column>
</el-table>
</el-tab-pane>
</el-tabs>
</el-card>
<el-dialog v-model="assignDialogVisible" title="分配咨询师" width="400px">
<el-form label-width="80px">
<el-form-item label="咨询师"><el-select v-model="assignForm.counselorId" placeholder="选择咨询师">
<el-option label="张咨询师" value="counselor01" />
<el-option label="李咨询师" value="counselor02" />
</el-select></el-form-item>
</el-form>
<template #footer>
<el-button @click="assignDialogVisible=false">取消</el-button>
<el-button type="primary" @click="doAssign">确认分配</el-button>
</template>
</el-dialog>
<el-dialog v-model="followupDialogVisible" title="添加跟进记录" width="500px">
<el-form label-width="80px">
<el-form-item label="跟进类型"><el-select v-model="followupForm.type">
<el-option label="电话回访" value="phone_call" />
<el-option label="面谈" value="face_to_face" />
<el-option label="线上沟通" value="online_chat" />
</el-select></el-form-item>
<el-form-item label="跟进内容"><el-input v-model="followupForm.content" type="textarea" :rows="3" /></el-form-item>
</el-form>
<template #footer>
<el-button @click="followupDialogVisible=false">取消</el-button>
<el-button type="primary" @click="doFollowup">保存</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { get, post } from '@/api/client'
import { ElMessage } from 'element-plus'
const activeTab = ref('queue')
const loading = ref(false)
const pendingQueue = ref<any[]>([])
const assignedServices = ref<any[]>([])
const assignDialogVisible = ref(false)
const followupDialogVisible = ref(false)
const currentId = ref('')
const assignForm = ref({ counselorId: '' })
const followupForm = ref({ type: 'phone_call', content: '' })
const SERVICE_TYPE_LABEL: Record<string, string> = {
relationship_guidance: '恋爱困惑指导', emotional_support: '情感支持',
premarital_guidance: '婚前辅导', anti_fraud_guidance: '婚恋反诈指导'
}
const REQUEST_SOURCE_LABEL: Record<string, string> = {
user_self: '用户自申请', matchmaker_referral: '红娘转介', operator_assisted: '工作人员协助'
}
const STATUS_LABEL: Record<string, string> = {
pending: '待分配', assigned: '已分配', scheduled: '已排期',
in_progress: '咨询中', completed: '已完成', cancelled: '已取消', closed: '已关闭'
}
const STATUS_TAG: Record<string, string> = {
pending: 'warning', assigned: 'info', scheduled: 'primary',
in_progress: 'success', completed: '', cancelled: 'danger', closed: 'info'
}
function serviceTypeLabel(t: string) { return SERVICE_TYPE_LABEL[t] || t }
function requestSourceLabel(s: string) { return REQUEST_SOURCE_LABEL[s] || s }
function statusLabel(s: string) { return STATUS_LABEL[s] || s }
function statusTag(s: string) { return STATUS_TAG[s] || 'info' }
function formatDateTime(d: string) { return d ? new Date(d).toLocaleString('zh-CN') : '-' }
async function loadQueue() {
loading.value = true
try { const res: any = await get('/operator/counseling/services/queue'); pendingQueue.value = res?.data || [] } finally { loading.value = false }
}
async function loadAssigned() {
loading.value = true
try { const res: any = await get('/operator/counseling/services/operator/assigned'); assignedServices.value = res?.data || [] } finally { loading.value = false }
}
async function openAssign(row: any) { currentId.value = row.id; assignForm.value.counselorId = ''; assignDialogVisible.value = true }
async function doAssign() {
try { await post(`/operator/counseling/services/${currentId.value}/assign`, { counselorId: assignForm.value.counselorId }); ElMessage.success('分配成功'); assignDialogVisible.value = false; loadQueue() }
catch (e: any) { ElMessage.error(e?.response?.data?.message || '操作失败') }
}
async function scheduleCounseling(row: any) {
const now = new Date(); now.setHours(now.getHours() + 2)
const end = new Date(now); end.setHours(end.getHours() + 1)
try { await post(`/operator/counseling/services/${row.id}/schedule`, { startAt: now.toISOString(), endAt: end.toISOString() }); ElMessage.success('排期成功'); loadAssigned() }
catch (e: any) { ElMessage.error(e?.response?.data?.message || '操作失败') }
}
async function startCounseling(row: any) {
try { await post(`/operator/counseling/services/${row.id}/start`, {}); ElMessage.success('已开始'); loadAssigned() }
catch (e: any) { ElMessage.error(e?.response?.data?.message || '操作失败') }
}
function showFollowup(row: any) { currentId.value = row.id; followupForm.value = { type: 'phone_call', content: '' }; followupDialogVisible.value = true }
async function doFollowup() {
try { await post(`/operator/counseling/services/${currentId.value}/followup`, { followupType: followupForm.value.type, content: followupForm.value.content }); ElMessage.success('跟进记录已保存'); followupDialogVisible.value = false; loadAssigned() }
}
}
async function completeCounseling(row: any) {
try { await post(, {}); ElMessage.success('已完成'); loadAssigned() }
catch (e: any) { ElMessage.error(e?.response?.data?.message || '操作失败') }
}
onMounted(() => { loadQueue(); loadAssigned() })
</script>
<style scoped>
.card-header { display: flex; align-items: center; justify-content: space-between; }
</style>
@@ -0,0 +1,143 @@
<template>
<div class="page-container">
<el-card shadow="never">
<template #header>
<div class="card-header">
<span>Operator 管理</span>
<el-button type="primary" @click="openCreate">新增人员</el-button>
</div>
</template>
<el-table :data="tableData" v-loading="loading" border stripe>
<el-table-column prop="username" label="用户名" width="140" />
<el-table-column prop="name" label="姓名" width="120" />
<el-table-column prop="organizationId" label="所属机构" width="160">
<template #default="{row}">{{ orgMap[row.organizationId] || '-' }}</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="100">
<template #default="{row}">
<el-tag :type="row.status==='normal'?'success':'danger'">{{ row.status==='normal'?'正常':'停用' }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="lastLoginAt" label="最近登录" width="180">
<template #default="{row}">{{ formatTime(row.lastLoginAt) }}</template>
</el-table-column>
<el-table-column prop="createdAt" label="创建时间" width="180">
<template #default="{row}">{{ formatTime(row.createdAt) }}</template>
</el-table-column>
<el-table-column label="操作" width="200">
<template #default="{row}">
<el-button size="small" @click="resetPwd(row.id)">重置密码</el-button>
<el-button size="small" :type="row.status==='normal'?'warning':'success'"
@click="toggleStatus(row)">{{ row.status==='normal'?'停用':'启用' }}</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination v-if="total > 0" style="margin-top:16px;text-align:right"
layout="total, prev, pager, next" :total="total" :page-size="pageSize"
:current-page="page" @current-change="handlePageChange" />
</el-card>
<!-- 新增对话框 -->
<el-dialog v-model="formVisible" title="新增Operator" width="480px">
<el-form :model="form" label-width="100px">
<el-form-item label="用户名"><el-input v-model="form.username" /></el-form-item>
<el-form-item label="姓名"><el-input v-model="form.name" /></el-form-item>
<el-form-item label="所属机构">
<el-select v-model="form.organizationId" style="width:100%">
<el-option v-for="o in orgList" :key="o.id" :label="o.name" :value="o.id" />
</el-select>
</el-form-item>
<el-form-item label="密码">
<el-input v-model="form.password" type="password" show-password />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="formVisible=false">取消</el-button>
<el-button type="primary" :loading="saving" @click="save">保存</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { get, post, put } from '@/api/client'
const loading = ref(false)
const tableData = ref<any[]>([])
const total = ref(0)
const page = ref(0)
const pageSize = ref(20)
const formVisible = ref(false)
const saving = ref(false)
const form = ref({ username: '', name: '', organizationId: '', password: '' })
const orgList = ref<any[]>([])
const orgMap = ref<Record<string, string>>({})
async function loadData() {
loading.value = true
try {
const token = localStorage.getItem('accessToken')
const res: any = await get('/operator/operators', { page: page.value, pageSize: pageSize.value }, { Authorization: `Bearer ${token}` })
tableData.value = res.list || []
total.value = res.total || 0
} catch (e) { console.error(e) }
finally { loading.value = false }
}
async function loadOrgs() {
try {
const token = localStorage.getItem('accessToken')
const res: any = await get('/operator/operators/organizations', {}, { Authorization: `Bearer ${token}` })
orgList.value = res || []
orgList.value.forEach(o => { orgMap.value[o.id] = o.name })
} catch {}
}
function openCreate() {
form.value = { username: '', name: '', organizationId: '', password: '' }
formVisible.value = true
}
async function save() {
if (!form.value.username || !form.value.password) return
saving.value = true
try {
const token = localStorage.getItem('accessToken')
await post('/operator/operators', form.value, { Authorization: `Bearer ${token}` })
formVisible.value = false
loadData()
} catch (e) { console.error(e) }
finally { saving.value = false }
}
async function toggleStatus(row: any) {
try {
const token = localStorage.getItem('accessToken')
const action = row.status === 'normal' ? 'disable' : 'enable'
await put(`/operator/operators/${row.id}/${action}`, {}, { Authorization: `Bearer ${token}` })
loadData()
} catch (e) { console.error(e) }
}
async function resetPwd(id: string) {
const pwd = prompt('请输入新密码(至少8位)')
if (!pwd || pwd.length < 8) return
try {
const token = localStorage.getItem('accessToken')
await put(`/operator/operators/${id}/reset-password`, { newPassword: pwd }, { Authorization: `Bearer ${token}` })
} catch (e) { console.error(e) }
}
function handlePageChange(p: number) { page.value = p - 1; loadData() }
function formatTime(t?: string) { return t ? new Date(t).toLocaleString('zh-CN') : '-' }
onMounted(() => { loadData(); loadOrgs() })
</script>
<style scoped>
.page-container { padding: 24px; }
.card-header { display: flex; justify-content: space-between; align-items: center; }
</style>
@@ -0,0 +1,140 @@
<template>
<div class="page-container">
<el-card shadow="never">
<template #header>
<div class="card-header">
<span>机构管理</span>
<el-button type="primary" @click="openCreate">新增机构</el-button>
</div>
</template>
<el-table :data="tableData" v-loading="loading" border stripe>
<el-table-column prop="name" label="机构名称" width="200" />
<el-table-column prop="type" label="机构类型" width="140">
<template #default="{row}">{{ orgTypeText(row.type) }}</template>
</el-table-column>
<el-table-column prop="contactName" label="联系人" width="120" />
<el-table-column prop="status" label="状态" width="100">
<template #default="{row}">
<el-tag :type="row.status==='normal'?'success':'danger'">{{ row.status==='normal'?'正常':'已停用' }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="createdAt" label="创建时间" width="180">
<template #default="{row}">{{ formatTime(row.createdAt) }}</template>
</el-table-column>
<el-table-column label="操作" width="180">
<template #default="{row}">
<el-button size="small" @click="openEdit(row)">编辑</el-button>
<el-button size="small" :type="row.status==='normal'?'warning':'success'"
@click="toggleStatus(row)">{{ row.status==='normal'?'停用':'启用' }}</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
<!-- 新增/编辑对话框 -->
<el-dialog v-model="formVisible" :title="editId ? '编辑机构' : '新增机构'" width="480px">
<el-form :model="form" label-width="100px">
<el-form-item label="机构名称">
<el-input v-model="form.name" />
</el-form-item>
<el-form-item label="机构类型">
<el-select v-model="form.type" style="width:100%">
<el-option label="妇联" value="fulian" />
<el-option label="工会" value="gonghui" />
<el-option label="公益服务中心" value="public_service" />
<el-option label="志愿者团队" value="volunteer" />
<el-option label="其他合作机构" value="other" />
</el-select>
</el-form-item>
<el-form-item label="联系人">
<el-input v-model="form.contactName" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="formVisible=false">取消</el-button>
<el-button type="primary" :loading="saving" @click="save">保存</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { get, post, put } from '@/api/client'
const loading = ref(false)
const tableData = ref<any[]>([])
const formVisible = ref(false)
const editId = ref('')
const form = ref({ name: '', type: '', contactName: '' })
const saving = ref(false)
const ORG_TYPE_TEXT: Record<string, string> = {
fulian: '妇联', gonghui: '工会', public_service: '公益服务中心',
volunteer: '志愿者团队', other: '其他合作机构'
}
async function loadData() {
loading.value = true
try {
const token = localStorage.getItem('accessToken')
const res: any = await get('/operator/organizations', {}, { Authorization: `Bearer ${token}` })
tableData.value = res || []
} catch (e) { console.error(e) }
finally { loading.value = false }
}
function openCreate() {
editId.value = ''
form.value = { name: '', type: 'fulian', contactName: '' }
formVisible.value = true
}
function openEdit(row: any) {
editId.value = row.id
form.value = { name: row.name, type: row.type, contactName: row.contactName || '' }
formVisible.value = true
}
async function save() {
if (!form.value.name) return
saving.value = true
try {
const token = localStorage.getItem('accessToken')
if (editId.value) {
await put(`/operator/organizations/${editId.value}`, form.value, {
Authorization: `Bearer ${token}`
})
} else {
await post('/operator/organizations', form.value, {
Authorization: `Bearer ${token}`
})
}
formVisible.value = false
loadData()
} catch (e) { console.error(e) }
finally { saving.value = false }
}
async function toggleStatus(row: any) {
try {
const token = localStorage.getItem('accessToken')
const action = row.status === 'normal' ? 'disable' : 'enable'
await put(`/operator/organizations/${row.id}/${action}`, {}, {
Authorization: `Bearer ${token}`
})
loadData()
} catch (e) { console.error(e) }
}
function orgTypeText(t?: string) { return t && ORG_TYPE_TEXT[t] ? ORG_TYPE_TEXT[t] : (t || '-') }
function formatTime(t?: string) { return t ? new Date(t).toLocaleString('zh-CN') : '-' }
onMounted(loadData)
</script>
<style scoped>
.page-container { padding: 24px; }
.card-header { display: flex; justify-content: space-between; align-items: center; }
</style>
@@ -0,0 +1,37 @@
<template>
<div class="referral-management">
<el-card>
<template #header><div class="card-header"><span>红娘转介记录</span></div></template>
<el-table :data="referrals" stripe v-loading="loading">
<el-table-column prop="type" label="类型" width="120">
<template #default="{ row }"><el-tag>{{ typeLabel(row.type) }}</el-tag></template>
</el-table-column>
<el-table-column prop="id" label="关联ID" width="120" />
<el-table-column prop="status" label="状态" width="100">
<template #default="{ row }"><el-tag :type="row.status==='confirmed'?'success':'warning'">{{ row.status }}</el-tag></template>
</el-table-column>
<el-table-column prop="userConfirmedAt" label="用户确认时间" width="160">
<template #default="{ row }">{{ row.userConfirmedAt ? formatDateTime(row.userConfirmedAt) : '未确认' }}</template>
</el-table-column>
<el-table-column prop="createdAt" label="创建时间" width="160">
<template #default="{ row }">{{ formatDateTime(row.createdAt) }}</template>
</el-table-column>
</el-table>
</el-card>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { get } from '@/api/client'
const loading = ref(false)
const referrals = ref<any[]>([])
function typeLabel(t: string) { return t === 'service_station' ? '服务站' : t === 'activity' ? '活动' : t }
function formatDateTime(d: string) { return d ? new Date(d).toLocaleString('zh-CN') : '-' }
async function load() {
loading.value = true
try { const res: any = await get('/referrals/my-history'); referrals.value = res.data || [] } finally { loading.value = false }
}
onMounted(load)
</script>
<style scoped>.card-header{display:flex;justify-content:space-between;align-items:center}</style>
+230
View File
@@ -0,0 +1,230 @@
<template>
<el-container class="review-page">
<el-header class="header">
<h2>资料审核</h2>
<el-tag type="warning" v-if="stats.pending > 0">待审核 {{ stats.pending }}</el-tag>
<el-tag type="info" v-if="stats.processing > 0">审核中 {{ stats.processing }}</el-tag>
</el-header>
<el-main>
<el-tabs v-model="activeTab">
<el-tab-pane label="待审核队列" name="pending">
<el-table :data="pendingTasks" v-loading="loading">
<el-table-column prop="userId" label="用户ID" width="120" />
<el-table-column prop="reviewType" label="审核类型" width="120" />
<el-table-column prop="submittedAt" label="提交时间" width="180">
<template #default="{ row }">
{{ formatTime(row.submittedAt) }}
</template>
</el-table-column>
<el-table-column label="操作" width="120">
<template #default="{ row }">
<el-button type="primary" size="small" @click="handleClaim(row)">领取</el-button>
</template>
</el-table-column>
</el-table>
<el-empty v-if="!loading && pendingTasks.length === 0" description="暂无待审核任务" />
</el-tab-pane>
<el-tab-pane label="我的审核" name="my">
<el-table :data="myTasks" v-loading="loading">
<el-table-column prop="userId" label="用户ID" width="120" />
<el-table-column prop="status" label="状态" width="100">
<template #default="{ row }">
<el-tag :type="statusType(row.status)">{{ statusText(row.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="completedAt" label="完成时间" width="180">
<template #default="{ row }">
{{ row.completedAt ? formatTime(row.completedAt) : '-' }}
</template>
</el-table-column>
<el-table-column label="操作" width="120">
<template #default="{ row }">
<el-button size="small" @click="handleView(row)">查看</el-button>
</template>
</el-table-column>
</el-table>
</el-tab-pane>
</el-tabs>
</el-main>
<!-- 审核详情抽屉 -->
<el-drawer v-model="detailVisible" title="审核详情" size="500px">
<template v-if="currentTask">
<el-descriptions :column="1" border>
<el-descriptions-item label="用户ID">{{ currentTask.userId }}</el-descriptions-item>
<el-descriptions-item label="审核类型">{{ currentTask.reviewType }}</el-descriptions-item>
<el-descriptions-item label="提交时间">{{ formatTime(currentTask.submittedAt) }}</el-descriptions-item>
<el-descriptions-item label="当前状态">
<el-tag :type="statusType(currentTask.status)">{{ statusText(currentTask.status) }}</el-tag>
</el-descriptions-item>
</el-descriptions>
<el-divider>用户资料</el-divider>
<el-descriptions :column="2" border v-if="currentTask.userProfile">
<el-descriptions-item label="姓名">{{ currentTask.userProfile.displayName }}</el-descriptions-item>
<el-descriptions-item label="性别">{{ currentTask.userProfile.gender }}</el-descriptions-item>
<el-descriptions-item label="年龄">{{ currentTask.userProfile.age }}岁</el-descriptions-item>
<el-descriptions-item label="身高">{{ currentTask.userProfile.heightCm }}cm</el-descriptions-item>
<el-descriptions-item label="婚姻状况">{{ currentTask.userProfile.maritalStatus }}</el-descriptions-item>
<el-descriptions-item label="学历">{{ currentTask.userProfile.educationCode }}</el-descriptions-item>
<el-descriptions-item label="自我介绍" :span="2">
{{ currentTask.userProfile.selfIntro || '暂未填写' }}
</el-descriptions-item>
</el-descriptions>
<el-divider>审核操作</el-divider>
<el-form :model="reviewForm" label-width="80px">
<el-form-item label="审核意见">
<el-input v-model="reviewForm.comment" type="textarea" :rows="3" placeholder="请输入审核意见" />
</el-form-item>
<el-form-item label="审核原因">
<el-select v-model="reviewForm.reasonCodes" multiple placeholder="可选" style="width:100%">
<el-option
v-for="reason in reasonOptions"
:key="reason.code"
:label="reason.name"
:value="reason.code"
/>
</el-select>
</el-form-item>
<el-form-item>
<el-button type="success" @click="handleApprove" :loading="submitting">通过</el-button>
<el-button type="warning" @click="handleNeedEdit" :loading="submitting">需修改</el-button>
<el-button type="danger" @click="handleReject" :loading="submitting">拒绝</el-button>
</el-form-item>
</el-form>
</template>
</el-drawer>
</el-container>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
const activeTab = ref('pending')
const loading = ref(false)
const submitting = ref(false)
const detailVisible = ref(false)
const stats = ref({ pending: 0, processing: 0 })
const pendingTasks = ref<any[]>([])
const myTasks = ref<any[]>([])
const currentTask = ref<any>(null)
const reasonOptions = ref<any[]>([])
const reviewForm = ref({
comment: '',
reasonCodes: [] as string[]
})
async function loadStats() {
// TODO: Phase 05 实现真实API调用
stats.value = { pending: 12, processing: 3 }
}
function formatTime(ts?: string) {
if (!ts) return '-'
return new Date(ts).toLocaleString('zh-CN')
}
function statusType(status: string) {
const map: Record<string, string> = {
pending: 'warning',
processing: 'info',
approved: 'success',
need_edit: 'warning',
rejected: 'danger'
}
return map[status] || ''
}
function statusText(status: string) {
const map: Record<string, string> = {
pending: '待审核',
processing: '审核中',
approved: '已通过',
need_edit: '需修改',
rejected: '已拒绝'
}
return map[status] || status
}
function handleClaim(_row: any) {
// TODO: 调用 API 领取
ElMessage.success('领取成功')
}
function handleView(row: any) {
currentTask.value = row
detailVisible.value = true
}
async function handleApprove() {
submitting.value = true
try {
// TODO: 调用 API
ElMessage.success('审核通过')
detailVisible.value = false
await loadStats()
} finally {
submitting.value = false
}
}
async function handleNeedEdit() {
submitting.value = true
try {
ElMessage.success('已标记需修改')
detailVisible.value = false
await loadStats()
} finally {
submitting.value = false
}
}
async function handleReject() {
submitting.value = true
try {
ElMessage.success('已拒绝')
detailVisible.value = false
await loadStats()
} finally {
submitting.value = false
}
}
onMounted(() => {
loadStats()
})
</script>
<style scoped>
.review-page {
height: 100vh;
display: flex;
flex-direction: column;
}
.header {
background: #fff;
padding: 16px 24px;
display: flex;
align-items: center;
gap: 16px;
border-bottom: 1px solid #e4e7ed;
}
.header h2 {
margin: 0;
font-size: 18px;
color: #303133;
}
.el-main {
flex: 1;
padding: 20px;
overflow: auto;
}
</style>
@@ -0,0 +1,193 @@
<template>
<div class="page-container">
<el-row :gutter="16">
<!-- 统计卡片 -->
<el-col :span="6" v-for="s in stats" :key="s.key">
<el-card shadow="hover" class="stat-card">
<div class="stat-value" :style="{ color: s.color }">{{ s.value }}</div>
<div class="stat-label">{{ s.label }}</div>
</el-card>
</el-col>
</el-row>
<el-card shadow="never" style="margin-top:16px">
<template #header>
<div class="card-header">
<span>举报列表</span>
<el-radio-group v-model="statusFilter" size="small" @change="loadData">
<el-radio-button label="pending">待处理</el-radio-button>
<el-radio-button label="processing">处理中</el-radio-button>
<el-radio-button label="resolved">已处理</el-radio-button>
<el-radio-button label="dismissed">已驳回</el-radio-button>
</el-radio-group>
</div>
</template>
<el-table :data="tableData" v-loading="loading" border stripe>
<el-table-column prop="reasonText" label="举报原因" width="140" />
<el-table-column prop="reportedUserId" label="被举报人ID" width="140" />
<el-table-column prop="description" label="说明" min-width="200" show-overflow-tooltip />
<el-table-column prop="status" label="状态" width="100">
<template #default="{row}">
<el-tag :type="statusTagType(row.status)">{{ statusText(row.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="createdAt" label="提交时间" width="180">
<template #default="{row}">{{ formatTime(row.createdAt) }}</template>
</el-table-column>
<el-table-column label="操作" width="120">
<template #default="{row}">
<el-button
v-if="row.status==='pending'||row.status==='processing'"
type="primary" size="small"
@click="openDetail(row.id)">处理</el-button>
<el-button size="small" @click="openDetail(row.id)">查看</el-button>
</template>
</el-table-column>
</el-table>
<el-empty v-if="!loading && tableData.length===0" description="暂无举报记录" />
</el-card>
<!-- 举报详情抽屉 -->
<el-drawer v-model="drawerVisible" :title="`举报详情 #${currentReportId?.slice(0,8)||''}`" size="560px">
<div v-if="detail">
<el-descriptions :column="2" border>
<el-descriptions-item label="举报原因">{{ detail.report?.reasonText }}</el-descriptions-item>
<el-descriptions-item label="状态">
<el-tag :type="statusTagType(detail.report?.status)">{{ statusText(detail.report?.status) }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="补充说明" :span="2">{{ detail.report?.description || '无' }}</el-descriptions-item>
</el-descriptions>
<h4 style="margin:20px 0 12px;color:#E96B6B">被举报人风险画像</h4>
<el-descriptions :column="2" border v-if="detail.riskProfile">
<el-descriptions-item label="用户">{{ detail.riskProfile.displayName }}</el-descriptions-item>
<el-descriptions-item label="风险分数">{{ detail.riskProfile.totalRiskScore }}</el-descriptions-item>
<el-descriptions-item label="风险等级">
<el-tag :type="riskLevelTag(detail.riskProfile.riskLevel)">
{{ riskLevelText(detail.riskProfile.riskLevel) }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="历史举报数">{{ detail.riskProfile.totalReportsCount }}</el-descriptions-item>
</el-descriptions>
<h4 style="margin:20px 0 12px;color:#E96B6B">处置操作</h4>
<el-form label-width="100px">
<el-form-item label="处理结果">
<el-select v-model="processResult" style="width:100%">
<el-option label="举报不成立(无处置)" value="no_action" />
<el-option label="警告" value="warning" />
<el-option label="限制功能(7天)" value="feature_limit" />
<el-option label="暂停账号(30天)" value="temporary_suspend" />
<el-option label="封禁账号" value="ban" />
<el-option label="转人工进一步调查" value="manual_review" />
</el-select>
</el-form-item>
<el-form-item label="处理备注">
<el-input v-model="processNote" type="textarea" :rows="3" placeholder="请输入处理说明" />
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="processing" @click="handleProcess">确认提交</el-button>
<el-button @click="drawerVisible=false">取消</el-button>
</el-form-item>
</el-form>
</div>
</el-drawer>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { get, post } from '@/api/client'
const loading = ref(false)
const processing = ref(false)
const statusFilter = ref('pending')
const tableData = ref<any[]>([])
const stats = ref<any[]>([])
const drawerVisible = ref(false)
const currentReportId = ref('')
const detail = ref<any>(null)
const processResult = ref('no_action')
const processNote = ref('')
const STATUS_MAP: Record<string, { text: string; type: string }> = {
pending: { text: '待处理', type: 'warning' },
processing: { text: '处理中', type: 'warning' },
resolved: { text: '已处理', type: 'success' },
rejected: { text: '未受理', type: 'info' },
dismissed: { text: '已驳回', type: 'default' }
}
async function loadData() {
loading.value = true
try {
const token = localStorage.getItem('accessToken')
const res: any = await get(`/operator/risk/reports/${statusFilter.value}`, {}, { Authorization: `Bearer ${token}` })
tableData.value = res || []
loadStats()
} catch (e) { console.error(e) }
finally { loading.value = false }
}
async function loadStats() {
try {
const token = localStorage.getItem('accessToken')
const res: any = await get('/operator/risk/reports/stats', {}, { Authorization: `Bearer ${token}` })
stats.value = [
{ key: 'pending', label: '待处理', value: res.pendingCount || 0, color: '#E96B6B' },
{ key: 'processing', label: '处理中', value: res.processingCount || 0, color: '#E59A32' },
{ key: 'resolved', label: '已处理', value: res.resolvedCount || 0, color: '#67C23A' },
{ key: 'dismissed', label: '已驳回', value: res.dismissedCount || 0, color: '#909399' }
]
} catch {}
}
async function openDetail(reportId: string) {
currentReportId.value = reportId
drawerVisible.value = true
processResult.value = 'no_action'
processNote.value = ''
try {
const token = localStorage.getItem('accessToken')
const res: any = await get(`/operator/risk/reports/${reportId}`, {}, { Authorization: `Bearer ${token}` })
detail.value = res
} catch (e) { console.error(e) }
}
async function handleProcess() {
if (!processResult.value) return
processing.value = true
try {
const token = localStorage.getItem('accessToken')
await post(`/operator/risk/reports/${currentReportId.value}/process`,
{ processResult: processResult.value, processNote: processNote.value },
{ Authorization: `Bearer ${token}` })
drawerVisible.value = false
detail.value = null
loadData()
} catch (e) { console.error(e) }
finally { processing.value = false }
}
function statusTagType(s: string) { return STATUS_MAP[s]?.type || 'info' }
function statusText(s: string) { return STATUS_MAP[s]?.text || s }
function riskLevelTag(l: string) {
return { normal: 'info', watch: '', high: 'warning', critical: 'danger' }[l] || 'info'
}
function riskLevelText(l: string) {
return { normal: '正常', watch: '关注', high: '高风险', critical: '极高风险' }[l] || l
}
function formatTime(t?: string) { return t ? new Date(t).toLocaleString('zh-CN') : '-' }
onMounted(loadData)
</script>
<style scoped>
.page-container { padding: 24px; }
.stat-card { text-align: center; }
.stat-value { font-size: 36px; font-weight: bold; }
.stat-label { font-size: 14px; color: #666; margin-top: 8px; }
.card-header { display: flex; justify-content: space-between; align-items: center; }
</style>
@@ -0,0 +1,80 @@
<template>
<div class="appointment-management">
<el-card>
<template #header><div class="card-header"><span>服务预约管理</span>
<el-radio-group v-model="filterStatus" size="small" @change="loadAppointments">
<el-radio-button label="all">全部</el-radio-button>
<el-radio-button label="submitted">待确认</el-radio-button>
<el-radio-button label="confirmed">已确认</el-radio-button>
<el-radio-button label="completed">已完成</el-radio-button>
<el-radio-button label="cancelled">已取消</el-radio-button>
</el-radio-group>
</div></template>
<el-table :data="appointments" stripe v-loading="loading">
<el-table-column prop="id" label="ID" width="100" />
<el-table-column prop="userId" label="用户ID" width="120" />
<el-table-column prop="serviceType" label="服务类型" width="150" />
<el-table-column prop="source" label="来源" width="140">
<template #default="{ row }"><el-tag size="small">{{ sourceLabel(row.source) }}</el-tag></template>
</el-table-column>
<el-table-column prop="status" label="状态" width="100">
<template #default="{ row }"><el-tag :type="statusTag(row.status)">{{ statusLabel(row.status) }}</el-tag></template>
</el-table-column>
<el-table-column prop="preferredDate" label="预约日期" width="110" />
<el-table-column prop="assignedOperatorId" label="分配人员" width="100" />
<el-table-column prop="createdAt" label="创建时间" width="160">
<template #default="{ row }">{{ formatDateTime(row.createdAt) }}</template>
</el-table-column>
<el-table-column label="操作" width="180">
<template #default="{ row }">
<el-button v-if="row.status==='submitted'||row.status==='pending_confirmation'" size="small" type="success" @click="confirmAppointment(row)">确认</el-button>
<el-button v-if="row.status==='confirmed'" size="small" type="primary" @click="completeAppointment(row)">完成</el-button>
<el-button v-if="row.status==='submitted'||row.status==='pending_confirmation'" size="small" type="danger" @click="cancelAppointment(row)">取消</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { get, post } from '@/api/client'
import { ElMessage } from 'element-plus'
const loading = ref(false)
const appointments = ref<any[]>([])
const filterStatus = ref('all')
const SOURCE_LABEL: Record<string, string> = { user_self: '用户自申请', matchmaker_referral: '红娘转介', operator_assisted: '工作人员协助', activity_followup: '活动跟进', service_station: '服务站推荐' }
const STATUS_LABEL: Record<string, string> = { submitted: '已提交', pending_confirmation: '待确认', confirmed: '已确认', completed: '已完成', cancelled: '已取消' }
const STATUS_TAG: Record<string, string> = { submitted: 'warning', pending_confirmation: 'warning', confirmed: 'success', completed: 'info', cancelled: 'danger' }
function sourceLabel(s: string) { return SOURCE_LABEL[s] || s }
function statusLabel(s: string) { return STATUS_LABEL[s] || s }
function statusTag(s: string) { return STATUS_TAG[s] || '' }
function formatDateTime(d: string) { return d ? new Date(d).toLocaleString('zh-CN') : '-' }
async function loadAppointments() {
loading.value = true
try {
const res: any = await get('/operator/service-appointments', { status: filterStatus.value === 'all' ? undefined : filterStatus.value })
appointments.value = res.data || []
} finally { loading.value = false }
}
async function confirmAppointment(row: any) {
try { await post(`/service-appointments/operator/${row.id}/confirm`, {}); ElMessage.success('确认成功'); loadAppointments() }
catch (e) { ElMessage.error('操作失败') }
}
async function completeAppointment(row: any) {
try { await post(`/service-appointments/operator/${row.id}/complete`, {}); ElMessage.success('完成成功'); loadAppointments() }
catch (e) { ElMessage.error('操作失败') }
}
async function cancelAppointment(row: any) {
try { await post(`/service-appointments/operator/${row.id}/cancel`, { reason: '运营取消' }); ElMessage.success('取消成功'); loadAppointments() }
catch (e) { ElMessage.error('操作失败') }
}
onMounted(loadAppointments)
</script>
<style scoped>.card-header{display:flex;justify-content:space-between;align-items:center}</style>
@@ -0,0 +1,93 @@
<template>
<div class="service-program">
<el-card>
<template #header><div class="card-header"><span>专项公益服务项目</span><el-button type="primary" size="small" @click="openCreate">新建项目</el-button></div></template>
<el-table :data="programs" stripe v-loading="loading">
<el-table-column prop="name" label="项目名称" />
<el-table-column prop="status" label="状态" width="100">
<template #default="{ row }"><el-tag :type="row.status==='active'?'success':row.status==='draft'?'info':'danger'">{{ row.status }}</el-tag></template>
</el-table-column>
<el-table-column label="时间范围" width="220">
<template #default="{ row }">{{ formatDateTime(row.start_at) }} ~ {{ formatDateTime(row.end_at) }}</template>
</el-table-column>
<el-table-column label="成员数" width="80">
<template #default="{ row }">{{ row.memberCount || 0 }}</template>
</el-table-column>
<el-table-column label="操作" width="180">
<template #default="{ row }">
<el-button v-if="row.status==='draft'" size="small" type="success" @click="activateProgram(row)">激活</el-button>
<el-button size="small" @click="viewMembers(row)">查看成员</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
<!-- 成员Dialog -->
<el-dialog v-model="memberDialogVisible" title="项目成员" width="700px">
<el-table :data="members" stripe v-loading="memberLoading">
<el-table-column prop="userId" label="用户ID" width="120" />
<el-table-column prop="status" label="状态" width="100">
<template #default="{ row }"><el-tag :type="row.status==='approved'?'success':row.status==='pending'?'warning':'info'">{{ row.status }}</el-tag></template>
</el-table-column>
<el-table-column prop="appliedAt" label="申请时间" width="160">
<template #default="{ row }">{{ formatDateTime(row.appliedAt) }}</template>
</el-table-column>
<el-table-column label="操作" width="150">
<template #default="{ row }">
<el-button v-if="row.status==='pending'" size="small" type="success" @click="approveMember(row.id)">通过</el-button>
<el-button v-if="row.status==='pending'" size="small" type="danger" @click="rejectMember(row.id)">拒绝</el-button>
</template>
</el-table-column>
</el-table>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { get, post } from '@/api/client'
import { ElMessage } from 'element-plus'
const loading = ref(false)
const programs = ref<any[]>([])
const memberDialogVisible = ref(false)
const currentProgramId = ref('')
const members = ref<any[]>([])
const memberLoading = ref(false)
async function loadPrograms() {
loading.value = true
try {
const res: any = await get('/service-programs')
programs.value = res.data || []
} finally { loading.value = false }
}
async function viewMembers(row: any) {
currentProgramId.value = row.id
memberDialogVisible.value = true
memberLoading.value = true
try {
const res: any = await get(`/service-programs/operator/${row.id}/members`)
members.value = res.data || []
} finally { memberLoading.value = false }
}
async function activateProgram(row: any) {
try { await post(`/service-programs/operator/${row.id}/activate`, {}); ElMessage.success('激活成功'); loadPrograms() }
catch (e) { ElMessage.error('操作失败') }
}
async function approveMember(memberId: string) {
try { await post(`/service-programs/operator/members/${memberId}/approve`, {}); ElMessage.success('已通过'); viewMembers({ id: currentProgramId.value }); }
catch (e) { ElMessage.error('操作失败') }
}
async function rejectMember(memberId: string) {
try { await post(`/service-programs/operator/members/${memberId}/reject`, { reason: '不符合条件' }); ElMessage.success('已拒绝'); viewMembers({ id: currentProgramId.value }); }
catch (e) { ElMessage.error('操作失败') }
}
function formatDateTime(d: string) { return d ? new Date(d).toLocaleString('zh-CN') : '-' }
onMounted(loadPrograms)
</script>
<style scoped>.card-header{display:flex;justify-content:space-between;align-items:center}</style>
@@ -0,0 +1,80 @@
<template>
<div class="service-station">
<el-card>
<template #header><div class="card-header"><span>善缘服务站管理</span><el-button type="primary" size="small" @click="openCreate">新建服务站</el-button></div></template>
<el-table :data="stations" stripe v-loading="loading">
<el-table-column prop="name" label="名称" />
<el-table-column prop="short_name" label="简称" width="120" />
<el-table-column prop="address" label="地址" show-overflow-tooltip />
<el-table-column prop="contact_phone_display" label="联系电话" width="130" />
<el-table-column prop="opening_hours_text" label="营业时间" width="180">
<template #default="{ row }">{{ row.opening_hours_text || '-' }}</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="100">
<template #default="{ row }"><el-tag :type="row.status==='active'?'success':row.status==='closed'?'danger':'info'">{{ row.status }}</el-tag></template>
</el-table-column>
<el-table-column label="操作" width="150">
<template #default="{ row }">
<el-button size="small" @click="openEdit(row)">编辑</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
<el-dialog v-model="dialogVisible" title="编辑服务站" width="600px">
<el-form :model="form" label-width="100px">
<el-form-item label="名称"><el-input v-model="form.name" /></el-form-item>
<el-form-item label="简称"><el-input v-model="form.short_name" /></el-form-item>
<el-form-item label="地址"><el-input v-model="form.address" /></el-form-item>
<el-form-item label="电话显示"><el-input v-model="form.contact_phone_display" /></el-form-item>
<el-form-item label="营业时间"><el-input v-model="form.opening_hours_text" type="textarea" :rows="3" /></el-form-item>
<el-form-item label="状态">
<el-select v-model="form.status">
<el-option label="营业中" value="active" />
<el-option label="休息中" value="inactive" />
<el-option label="关闭" value="closed" />
</el-select>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible=false">取消</el-button>
<el-button type="primary" @click="saveStation">保存</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { get, put } from '@/api/client'
import { ElMessage } from 'element-plus'
const loading = ref(false)
const stations = ref<any[]>([])
const dialogVisible = ref(false)
const form = ref<any>({})
async function loadStations() {
loading.value = true
try {
const res: any = await get('/operator/service-locations')
stations.value = res.data || []
} finally { loading.value = false }
}
function openEdit(row: any) { Object.assign(form.value, row); dialogVisible.value = true }
function openCreate() { Object.assign(form.value, { name: '', short_name: '', address: '', contact_phone_display: '', opening_hours_text: '', status: 'active' }); dialogVisible.value = true }
async function saveStation() {
try {
if (form.value.id) await put(`/operator/service-locations/${form.value.id}`, form.value)
else await post('/operator/service-locations', form.value)
ElMessage.success('保存成功')
dialogVisible.value = false
loadStations()
} catch (e: any) { ElMessage.error(e?.response?.data?.message || '保存失败') }
}
onMounted(loadStations)
</script>
<style scoped>.card-header{display:flex;justify-content:space-between;align-items:center}</style>
@@ -0,0 +1,176 @@
<template>
<div class="page-container" v-loading="loading">
<el-card shadow="never" v-if="user">
<template #header>
<div class="card-header">
<span>用户详情</span>
<el-tag :type="accountStatusType(user.accountStatus)">{{ accountStatusText(user.accountStatus) }}</el-tag>
<el-tag :type="riskLevelType(user.riskLevel)">{{ riskLevelText(user.riskLevel) }}</el-tag>
</div>
</template>
<el-tabs v-model="activeTab">
<!-- 基础信息 -->
<el-tab-pane label="基础信息" name="basic">
<el-descriptions :column="2" border>
<el-descriptions-item label="用户ID">{{ user.id }}</el-descriptions-item>
<el-descriptions-item label="显示名称">{{ user.displayName }}</el-descriptions-item>
<el-descriptions-item label="性别">{{ genderText(user.gender) }}</el-descriptions-item>
<el-descriptions-item label="年龄">{{ user.age || '-' }}</el-descriptions-item>
<el-descriptions-item label="手机">
<template v-if="user.sensitiveMasked">
<span class="masked">{{ user.phoneMasked }}</span>
<el-button size="small" link @click="requestSensitive">需要查看</el-button>
</template>
<template v-else>{{ user.phone }}</template>
</el-descriptions-item>
<el-descriptions-item label="区域">{{ user.regionName || '-' }}</el-descriptions-item>
<el-descriptions-item label="注册时间">{{ formatTime(user.registeredAt) }}</el-descriptions-item>
<el-descriptions-item label="最近登录">{{ formatTime(user.lastLoginAt) }}</el-descriptions-item>
</el-descriptions>
</el-tab-pane>
<!-- 婚恋资料 -->
<el-tab-pane label="婚恋资料" name="profile">
<el-descriptions :column="2" border>
<el-descriptions-item label="婚姻状态">{{ maritalText(user.maritalStatus) }}</el-descriptions-item>
<el-descriptions-item label="子女情况">{{ childrenText(user.childrenStatus) }}</el-descriptions-item>
<el-descriptions-item label="学历">{{ eduText(user.educationCode) }}</el-descriptions-item>
<el-descriptions-item label="职业">{{ occText(user.occupationCode) }}</el-descriptions-item>
<el-descriptions-item label="自我介绍" :span="2">{{ user.selfIntro || '未填写' }}</el-descriptions-item>
<el-descriptions-item label="资料完整度">{{ user.profileCompletion }}%</el-descriptions-item>
<el-descriptions-item label="资料状态">{{ profileStatusText(user.profileStatus) }}</el-descriptions-item>
</el-descriptions>
</el-tab-pane>
<!-- 风险记录 -->
<el-tab-pane label="风险记录" name="risk">
<el-descriptions :column="2" border>
<el-descriptions-item label="风险分数">{{ user.totalRiskScore ?? 0 }}</el-descriptions-item>
<el-descriptions-item label="风险等级">
<el-tag :type="riskLevelType(user.riskLevel)">{{ riskLevelText(user.riskLevel) }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="历史举报数">{{ user.totalReportsCount ?? 0 }}</el-descriptions-item>
<el-descriptions-item label="当前处置数">{{ user.activeActionsCount ?? 0 }}</el-descriptions-item>
</el-descriptions>
<div style="margin-top:16px">
<el-button type="primary" size="small" @click="showStatusDialog = true">修改账号状态</el-button>
</div>
</el-tab-pane>
<!-- 状态记录 -->
<el-tab-pane label="状态记录" name="status">
<el-descriptions :column="2" border>
<el-descriptions-item label="账号状态">
<el-tag :type="accountStatusType(user.accountStatus)">{{ accountStatusText(user.accountStatus) }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="婚恋状态">{{ relationshipText(user.relationshipStatus) }}</el-descriptions-item>
</el-descriptions>
</el-tab-pane>
</el-tabs>
</el-card>
<!-- 修改状态对话框 -->
<el-dialog v-model="showStatusDialog" title="修改账号状态" width="400px">
<el-form label-width="100px">
<el-form-item label="新状态">
<el-select v-model="newStatus" style="width:100%">
<el-option label="正常" value="normal" />
<el-option label="风险审核中" value="reviewing_risk" />
<el-option label="功能受限" value="limited" />
<el-option label="临时暂停" value="suspended" />
<el-option label="封禁" value="banned" />
<el-option label="已注销" value="closed" />
</el-select>
</el-form-item>
<el-form-item label="原因">
<el-input v-model="statusNote" type="textarea" :rows="3" placeholder="请输入修改原因" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showStatusDialog = false">取消</el-button>
<el-button type="primary" :loading="updating" @click="updateStatus">确认</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRoute } from 'vue-router' // eslint-disable-next-line
import { get, put } from '@/api/client'
const route = useRoute()
const loading = ref(true)
const user = ref<any>(null)
const activeTab = ref('basic')
const showStatusDialog = ref(false)
const newStatus = ref('normal')
const statusNote = ref('')
const updating = ref(false)
const accountId = route.query.id as string
const ACCOUNT_MAP: Record<string, { text: string; type: string }> = {
normal: { text: '正常', type: 'success' },
banned: { text: '封禁', type: 'danger' },
suspended: { text: '暂停', type: 'warning' },
reviewing_risk: { text: '风险审核', type: 'danger' },
limited: { text: '受限', type: 'info' }
}
const RISK_MAP: Record<string, { text: string; type: string }> = {
normal: { text: '正常', type: 'info' },
watch: { text: '关注', type: '' },
high: { text: '高风险', type: 'warning' },
critical: { text: '极高风险', type: 'danger' }
}
async function loadDetail() {
if (!accountId) return
loading.value = true
try {
const token = localStorage.getItem('accessToken')
const res: any = await get(`/operator/users/${accountId}`, {}, { Authorization: `Bearer ${token}` })
user.value = res
} catch (e) { console.error(e) }
finally { loading.value = false }
}
async function updateStatus() {
if (!newStatus.value) return
updating.value = true
try {
const token = localStorage.getItem('accessToken')
await put(`/operator/users/${accountId}/status`, { accountStatus: newStatus.value }, {
Authorization: `Bearer ${token}`
})
showStatusDialog.value = false
loadDetail()
} catch (e) { console.error(e) }
finally { updating.value = false }
}
function requestSensitive() { /* TODO: 需要敏感权限的申请流程 */ }
function genderText(g?: string) { return g === 'male' ? '男' : g === 'female' ? '女' : '-' }
function maritalText(m?: string) { const m2 = m as string | undefined; return m2 && { single: '未婚', divorced: '离异', widowed: '丧偶' }[m2] || '-' }
function childrenText(c?: string) { const c2 = c as string | undefined; return c2 && { none: '无子女', with_children: '有子女' }[c2] || '-' }
function eduText(e?: string) { return e || '-' }
function occText(o?: string) { return o || '-' }
function profileStatusText(s?: string) { const s2 = s as string | undefined; return s2 && { approved: '已审核', pending: '待审核', need_edit: '需修改' }[s2] || s }
function relationshipText(r?: string) { const r2 = r as string | undefined; return r2 && { searching: '寻找中', getting_to_know: '正在了解', matched_successfully: '已牵手', paused: '暂停' }[r2] || r || '-' }
function accountStatusType(s: string) { return ACCOUNT_MAP[s]?.type || 'info' }
function accountStatusText(s: string) { return ACCOUNT_MAP[s]?.text || s }
function riskLevelType(l: string) { return RISK_MAP[l]?.type || 'info' }
function riskLevelText(l: string) { return RISK_MAP[l]?.text || l }
function formatTime(t?: string) { return t ? new Date(t).toLocaleString('zh-CN') : '-' }
onMounted(loadDetail)
</script>
<style scoped>
.page-container { padding: 24px; }
.card-header { display: flex; align-items: center; gap: 12px; }
.masked { color: #999; font-style: italic; }
</style>
@@ -0,0 +1,120 @@
<template>
<div class="page-container">
<el-card shadow="never">
<template #header>
<div class="card-header">
<span>用户管理</span>
<el-input v-model="keyword" placeholder="搜索用户ID" clearable style="width:200px" />
<el-select v-model="accountStatus" placeholder="账号状态" clearable style="width:140px">
<el-option label="正常" value="normal" />
<el-option label="封禁" value="banned" />
<el-option label="暂停" value="suspended" />
<el-option label="风险审核中" value="reviewing_risk" />
</el-select>
<el-button type="primary" @click="loadData" :loading="loading">查询</el-button>
</div>
</template>
<el-table :data="tableData" v-loading="loading" border stripe>
<el-table-column prop="id" label="用户ID" width="140" show-overflow-tooltip />
<el-table-column prop="displayName" label="姓名" width="120" />
<el-table-column prop="gender" label="性别" width="60">
<template #default="{row}">{{ row.gender === 'male' ? '男' : row.gender === 'female' ? '女' : '-' }}</template>
</el-table-column>
<el-table-column prop="age" label="年龄" width="60" />
<el-table-column prop="phoneMasked" label="手机尾号" width="120" />
<el-table-column prop="regionName" label="区域" width="100" />
<el-table-column prop="accountStatus" label="账号状态" width="100">
<template #default="{row}">
<el-tag :type="accountStatusType(row.accountStatus)">{{ accountStatusText(row.accountStatus) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="riskLevel" label="风险等级" width="100">
<template #default="{row}">
<el-tag :type="riskLevelType(row.riskLevel)">{{ riskLevelText(row.riskLevel) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="registeredAt" label="注册时间" width="180">
<template #default="{row}">{{ formatTime(row.registeredAt) }}</template>
</el-table-column>
<el-table-column label="操作" width="80">
<template #default="{row}">
<el-button size="small" @click="goDetail(row.id)">查看</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination v-if="total > 0" style="margin-top:16px;text-align:right"
layout="total, prev, pager, next" :total="total" :page-size="pageSize"
:current-page="page" @current-change="handlePageChange" />
</el-card>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { get } from '@/api/client'
const router = useRouter()
const loading = ref(false)
const keyword = ref('')
const accountStatus = ref('')
const tableData = ref<any[]>([])
const total = ref(0)
const page = ref(0)
const pageSize = ref(20)
const ACCOUNT_MAP: Record<string, { text: string; type: string }> = {
normal: { text: '正常', type: 'success' },
banned: { text: '封禁', type: 'danger' },
suspended: { text: '暂停', type: 'warning' },
reviewing_risk: { text: '风险审核', type: 'danger' },
limited: { text: '受限', type: 'info' }
}
const RISK_MAP: Record<string, { text: string; type: string }> = {
normal: { text: '正常', type: 'info' },
watch: { text: '关注', type: '' },
high: { text: '高风险', type: 'warning' },
critical: { text: '极高风险', type: 'danger' }
}
async function loadData() {
loading.value = true
try {
const token = localStorage.getItem('accessToken')
const res: any = await get('/operator/users', {
keyword: keyword.value || undefined,
accountStatus: accountStatus.value || undefined,
page: page.value,
pageSize: pageSize.value
}, { Authorization: `Bearer ${token}` })
tableData.value = res.list || []
total.value = res.total || 0
} catch (e) { console.error(e) }
finally { loading.value = false }
}
function handlePageChange(p: number) {
page.value = p - 1
loadData()
}
function goDetail(id: string) {
router.push({ path: '/users/detail', query: { id } })
}
function accountStatusType(s: string) { return ACCOUNT_MAP[s]?.type || 'info' }
function accountStatusText(s: string) { return ACCOUNT_MAP[s]?.text || s }
function riskLevelType(l: string) { return RISK_MAP[l]?.type || 'info' }
function riskLevelText(l: string) { return RISK_MAP[l]?.text || l }
function formatTime(t?: string) { return t ? new Date(t).toLocaleString('zh-CN') : '-' }
onMounted(loadData)
</script>
<style scoped>
.page-container { padding: 24px; }
.card-header { display: flex; align-items: center; gap: 12px; }
</style>
+81
View File
@@ -0,0 +1,81 @@
import { createRouter, createWebHistory } from 'vue-router'
import type { RouteRecordRaw } from 'vue-router'
import { ElMessage } from 'element-plus'
import { useAuthStore } from '@/stores/auth'
// 路由权限映射:path prefix → 需要的最小权限码
const ROUTE_PERMISSIONS: Record<string, string[]> = {
'/users/detail': ['user.sensitive.view'],
'/operators': ['permission.manage'],
'/organizations': ['organization.manage'],
'/analytics': ['analytics.view'],
'/audit': ['user.view'],
'/review': ['review.view'],
'/risk/reports': ['risk.report.view'],
'/appeals': ['risk.report.process'],
'/activities/interests': ['activity.review_registration'],
'/banner': ['banner.manage'],
'/content': ['content.manage'],
'/service-station': ['service_location.manage'],
'/service-appointment': ['service_appointment.manage'],
'/service-program': ['service_program.manage'],
'/counseling': ['counseling.manage'],
'/referral': ['referral.manage'],
}
function hasPermission(requiredPerms: string[]): boolean {
if (!requiredPerms.length) return true
const userPerms = useAuthStore().permissions || []
return requiredPerms.every((perm) => userPerms.includes(perm))
}
const routes: RouteRecordRaw[] = [
{ path: '/login', name: 'Login', component: () => import('@/views/LoginView.vue'), meta: { title: '登录' } },
{
path: '/',
component: () => import('@/layouts/MainLayout.vue'),
redirect: '/dashboard',
children: [
{ path: 'dashboard', name: 'Dashboard', component: () => import('@/views/DashboardView.vue'), meta: { title: '工作台' } },
{ path: 'review', name: 'Review', component: () => import('@/modules/review/ReviewView.vue'), meta: { title: '资料审核', permissions: ['review.view'] } },
{ path: 'activities/interests', name: 'ActivityInterests', component: () => import('@/modules/activity/ActivityInterestView.vue'), meta: { title: '活动互选管理', permissions: ['activity.review_registration'] } },
{ path: 'risk/reports', name: 'ReportCenter', component: () => import('@/modules/risk/ReportCenterView.vue'), meta: { title: '举报中心', permissions: ['risk.report.view'] } },
{ path: 'appeals', name: 'AppealCenter', component: () => import('@/modules/appeal/AppealCenterView.vue'), meta: { title: '申诉管理', permissions: ['risk.report.process'] } },
{ path: 'users', name: 'UserManagement', component: () => import('@/modules/user/UserManagementView.vue'), meta: { title: '用户管理', permissions: ['user.view'] } },
{ path: 'users/detail', name: 'UserDetail', component: () => import('@/modules/user/UserDetailView.vue'), meta: { title: '用户详情', permissions: ['user.view', 'user.sensitive.view'] } },
{ path: 'organizations', name: 'OrganizationManagement', component: () => import('@/modules/organization/OrganizationView.vue'), meta: { title: '组织管理', permissions: ['organization.manage'] } },
{ path: 'operators', name: 'OperatorManagement', component: () => import('@/modules/operator/OperatorManagementView.vue'), meta: { title: '工作人员管理', permissions: ['permission.manage'] } },
{ path: 'analytics', name: 'Analytics', component: () => import('@/modules/analytics/AnalyticsView.vue'), meta: { title: '数据统计', permissions: ['analytics.view'] } },
{ path: 'audit', name: 'AuditLog', component: () => import('@/modules/audit/AuditLogView.vue'), meta: { title: '操作日志', permissions: ['user.view'] } },
// V1.1 新增模块
{ path: 'banner', name: 'BannerManagement', component: () => import('@/modules/banner/BannerManagementView.vue'), meta: { title: 'Banner管理', permissions: ['banner.manage'] } },
{ path: 'content', name: 'ContentManagement', component: () => import('@/modules/content/ContentManagementView.vue'), meta: { title: '内容管理', permissions: ['content.manage'] } },
{ path: 'service-station', name: 'ServiceStation', component: () => import('@/modules/service-station/ServiceStationView.vue'), meta: { title: '善缘服务站', permissions: ['service_location.manage'] } },
{ path: 'service-appointment', name: 'ServiceAppointment', component: () => import('@/modules/service-appointment/ServiceAppointmentView.vue'), meta: { title: '服务预约', permissions: ['service_appointment.manage'] } },
{ path: 'service-program', name: 'ServiceProgram', component: () => import('@/modules/service-program/ServiceProgramView.vue'), meta: { title: '专项服务项目', permissions: ['service_program.manage'] } },
{ path: 'referral', name: 'ReferralManagement', component: () => import('@/modules/referral/ReferralManagementView.vue'), meta: { title: '转介管理', permissions: ['referral.manage'] } },
],
},
]
const router = createRouter({ history: createWebHistory(), routes })
router.beforeEach((to) => {
const authStore = useAuthStore()
const requiredPerms = (to.meta.permissions as string[]) || []
// 1. 登录检查
if (to.path !== '/login' && !authStore.isLoggedIn) {
return { path: '/login', query: { redirect: to.fullPath } }
}
// 2. 权限检查
if (requiredPerms.length > 0 && !hasPermission(requiredPerms)) {
ElMessage.warning('您没有访问该页面的权限')
return { path: '/dashboard' }
}
document.title = to.meta.title ? `${to.meta.title} - 丹阳公益婚恋` : '丹阳公益婚恋'
})
export default router
+48
View File
@@ -0,0 +1,48 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { post } from '@/api/client'
interface OperatorInfo {
operatorId: string
name: string
username: string
permissions: string[]
organizationId: string | null
}
export const useAuthStore = defineStore('auth', () => {
const token = ref<string>(localStorage.getItem('accessToken') || '')
const operator = ref<OperatorInfo | null>(
JSON.parse(localStorage.getItem('operator') || 'null')
)
const isLoggedIn = computed(() => !!token.value)
const permissions = computed(() => operator.value?.permissions || [])
async function login(username: string, password: string) {
const res: any = await post('/auth/operator/login', { username, password })
if (res.success) {
token.value = res.data.token
operator.value = {
operatorId: res.data.operatorId,
name: res.data.name,
username: res.data.username,
permissions: res.data.permissions || [],
organizationId: null,
}
localStorage.setItem('accessToken', res.data.token)
localStorage.setItem('operator', JSON.stringify(operator.value))
return res.data
}
throw new Error(res.message || '登录失败')
}
function logout() {
token.value = ''
operator.value = null
localStorage.removeItem('accessToken')
localStorage.removeItem('operator')
}
return { token, operator, isLoggedIn, permissions, login, logout }
})
+80
View File
@@ -0,0 +1,80 @@
:root {
/* Brand Colors */
--color-primary: #E96B6B;
--color-primary-light: #FFF3F2;
--color-success: #2E9B68;
--color-warning: #E59A32;
--color-danger: #DC3545;
--color-info: #17A2B8;
/* Text Colors */
--color-text-primary: #1A1A1A;
--color-text-secondary: #666666;
--color-text-tertiary: #999999;
/* Background */
--color-bg-primary: #FFFFFF;
--color-bg-secondary: #F5F7FA;
--color-bg-page: #F0F2F5;
/* Border */
--color-border: #E4E7ED;
--color-border-light: #EBEEF5;
/* Layout */
--sidebar-width: 220px;
--header-height: 60px;
--border-radius: 6px;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html, body, #app {
width: 100%;
height: 100%;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB',
'Microsoft YaHei', sans-serif;
font-size: 14px;
color: var(--color-text-primary);
background-color: var(--color-bg-page);
}
/* Override Element Plus primary color */
.el-button--primary {
--el-button-bg-color: #E96B6B;
--el-button-border-color: #E96B6B;
--el-button-hover-bg-color: #d45a5a;
--el-button-hover-border-color: #d45a5a;
}
.el-radio-group .el-radio-button__inner:checked {
background-color: #E96B6B;
border-color: #E96B6B;
}
/* Common utility */
.page-container {
padding: 20px;
}
.text-primary { color: var(--color-text-primary); }
.text-secondary { color: var(--color-text-secondary); }
.text-tertiary { color: var(--color-text-tertiary); }
.mt-sm { margin-top: 8px; }
.mt-md { margin-top: 16px; }
.mt-lg { margin-top: 24px; }
.mb-sm { margin-bottom: 8px; }
.mb-md { margin-bottom: 16px; }
.mb-lg { margin-bottom: 24px; }
.flex-center {
display: flex;
align-items: center;
justify-content: center;
}
+95
View File
@@ -0,0 +1,95 @@
<template>
<div class="page-container">
<el-row :gutter="16">
<el-col :span="6" v-for="card in statCards" :key="card.key">
<el-card shadow="hover" class="stat-card">
<div class="stat-value" :style="{ color: card.color }">{{ card.value }}</div>
<div class="stat-label">{{ card.label }}</div>
</el-card>
</el-col>
</el-row>
<el-row :gutter="16" style="margin-top:16px">
<el-col :span="12">
<el-card shadow="never">
<template #header><span>用户统计</span></template>
<el-descriptions :column="2" border size="small">
<el-descriptions-item label="累计注册">{{ stats.totalUsers }}</el-descriptions-item>
<el-descriptions-item label="已实名">{{ stats.verifiedUsers }}</el-descriptions-item>
<el-descriptions-item label="活跃用户(7天)">{{ stats.activeUsers }}</el-descriptions-item>
<el-descriptions-item label="待审核举报">{{ stats.pendingReports }}</el-descriptions-item>
</el-descriptions>
</el-card>
</el-col>
<el-col :span="12">
<el-card shadow="never">
<template #header><span>婚恋服务</span></template>
<el-descriptions :column="2" border size="small">
<el-descriptions-item label="总喜欢数">{{ stats.totalLikes }}</el-descriptions-item>
<el-descriptions-item label="双向Match">{{ stats.totalMatches }}</el-descriptions-item>
<el-descriptions-item label="正在了解">{{ stats.gettingToKnow }}</el-descriptions-item>
<el-descriptions-item label="成功牵手">{{ stats.successPairs }}</el-descriptions-item>
</el-descriptions>
</el-card>
</el-col>
</el-row>
<el-row :gutter="16" style="margin-top:16px">
<el-col :span="12">
<el-card shadow="never">
<template #header><span>安全风险</span></template>
<el-descriptions :column="2" border size="small">
<el-descriptions-item label="封禁账号">{{ stats.bannedUsers }}</el-descriptions-item>
<el-descriptions-item label="处理中举报">{{ stats.processingReports }}</el-descriptions-item>
</el-descriptions>
</el-card>
</el-col>
<el-col :span="12">
<el-card shadow="never">
<template #header><span>公益红娘</span></template>
<el-descriptions :column="2" border size="small">
<el-descriptions-item label="红娘服务申请">{{ stats.matchmakerServices }}</el-descriptions-item>
</el-descriptions>
</el-card>
</el-col>
</el-row>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { get } from '@/api/client'
const stats = ref<any>({})
const loading = ref(false)
const statCards = [
{ key: 'totalUsers', label: '累计注册', value: 0, color: '#409EFF' },
{ key: 'verifiedUsers', label: '已实名', value: 0, color: '#67C23A' },
{ key: 'activeUsers', label: '近7天活跃', value: 0, color: '#E59A32' },
{ key: 'successPairs', label: '成功牵手', value: 0, color: '#E96B6B' }
]
async function loadStats() {
loading.value = true
try {
const token = localStorage.getItem('accessToken')
const res: any = await get('/operator/analytics/dashboard', {}, { Authorization: `Bearer ${token}` })
stats.value = res || {}
statCards[0].value = res.totalUsers || 0
statCards[1].value = res.verifiedUsers || 0
statCards[2].value = res.activeUsers || 0
statCards[3].value = res.successPairs || 0
} catch (e) { console.error(e) }
finally { loading.value = false }
}
onMounted(loadStats)
</script>
<style scoped>
.page-container { padding: 24px; }
.stat-card { text-align: center; }
.stat-value { font-size: 36px; font-weight: bold; }
.stat-label { font-size: 14px; color: #666; margin-top: 8px; }
</style>
+73
View File
@@ -0,0 +1,73 @@
<template>
<div class="login-container">
<div class="login-card">
<div class="login-header">
<h1>丹阳公益婚恋服务平台</h1>
<p>工作人员登录</p>
</div>
<el-form ref="formRef" :model="form" :rules="rules" class="login-form" @submit.prevent="handleLogin">
<el-form-item prop="username">
<el-input v-model="form.username" placeholder="用户名" prefix-icon="User" size="large" />
</el-form-item>
<el-form-item prop="password">
<el-input v-model="form.password" type="password" placeholder="密码" prefix-icon="Lock"
size="large" show-password @keyup.enter="handleLogin" />
</el-form-item>
<el-form-item>
<el-button type="primary" size="large" :loading="loading" style="width:100%;background:#E96B6B;border-color:#E96B6B"
@click="handleLogin">登录</el-button>
</el-form-item>
</el-form>
<div class="login-footer">
<p>丹阳公益婚恋服务平台 · 运营管理后台</p>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import type { FormInstance } from 'element-plus'
import { useAuthStore } from '@/stores/auth'
const router = useRouter()
const formRef = ref<FormInstance>()
const loading = ref(false)
const authStore = useAuthStore()
const form = reactive({ username: '', password: '' })
const rules = {
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
password: [{ required: true, message: '请输入密码', trigger: 'blur' }],
}
async function handleLogin() {
if (!formRef.value) return
await formRef.value.validate(async (valid) => {
if (!valid) return
loading.value = true
try {
await authStore.login(form.username, form.password)
ElMessage.success('登录成功')
router.push('/dashboard')
} catch (err: any) {
ElMessage.error(err.message || '登录失败,请检查用户名和密码')
} finally {
loading.value = false
}
})
}
</script>
<style scoped>
.login-container { width: 100%; height: 100vh; display: flex; align-items: center; justify-content: center;
background: linear-gradient(135deg, #FFF3F2 0%, #FFF8F7 100%); }
.login-card { width: 400px; padding: 40px; background: #fff; border-radius: 12px; box-shadow: 0 4px 24px rgba(233,107,107,0.1); }
.login-header { text-align: center; margin-bottom: 32px; }
.login-header h1 { font-size: 24px; color: #E96B6B; margin-bottom: 8px; }
.login-header p { font-size: 14px; color: #999; }
.login-form { margin-top: 24px; }
.login-footer { text-align: center; margin-top: 24px; font-size: 12px; color: #bbb; }
</style>
+7
View File
@@ -0,0 +1,7 @@
/// <reference types="vite/client" />
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, any>
export default component
}