chore: establish V1.1 recovery baseline
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
<template>
|
||||
<router-view />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
</script>
|
||||
|
||||
<style>
|
||||
#app {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -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
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
|
||||
@@ -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 }
|
||||
})
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Vendored
+7
@@ -0,0 +1,7 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare module '*.vue' {
|
||||
import type { DefineComponent } from 'vue'
|
||||
const component: DefineComponent<{}, {}, any>
|
||||
export default component
|
||||
}
|
||||
Reference in New Issue
Block a user