chore: sync local latest state and repository cleanup
This commit is contained in:
@@ -43,6 +43,42 @@
|
||||
<label class="text-xs font-semibold text-mosquito-ink/70">预算/限额</label>
|
||||
<input class="mos-input mt-2 w-full" v-model="form.budget" />
|
||||
</div>
|
||||
<!-- 富文本内容配置 -->
|
||||
<div>
|
||||
<label class="text-xs font-semibold text-mosquito-ink/70">活动详情(富文本)</label>
|
||||
<div class="mt-2 border border-gray-300 rounded-md overflow-hidden">
|
||||
<!-- 简单工具栏 -->
|
||||
<div class="flex gap-1 p-2 bg-gray-50 border-b border-gray-300">
|
||||
<button type="button" class="px-2 py-1 text-xs bg-white border rounded hover:bg-gray-100" @click="formatText('bold')" title="粗体">B</button>
|
||||
<button type="button" class="px-2 py-1 text-xs bg-white border rounded hover:bg-gray-100 italic" @click="formatText('italic')" title="斜体">I</button>
|
||||
<button type="button" class="px-2 py-1 text-xs bg-white border rounded hover:bg-gray-100 underline" @click="formatText('underline')" title="下划线">U</button>
|
||||
<span class="border-r mx-1"></span>
|
||||
<label class="px-2 py-1 text-xs bg-white border rounded hover:bg-gray-100 cursor-pointer">
|
||||
<span>📷 上传图片</span>
|
||||
<input type="file" accept="image/png,image/jpeg,image/gif" class="hidden" @change="handleImageUpload" />
|
||||
</label>
|
||||
</div>
|
||||
<!-- 富文本编辑区 -->
|
||||
<div
|
||||
ref="editorRef"
|
||||
class="min-h-[120px] p-3 bg-white outline-none"
|
||||
contenteditable="true"
|
||||
@input="handleEditorInput"
|
||||
v-html="form.richContent"
|
||||
></div>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 mt-1">支持富文本编辑,可上传PNG/JPG/GIF图片(最大10MB)</p>
|
||||
</div>
|
||||
<!-- 已上传图片预览 -->
|
||||
<div v-if="uploadedImages.length > 0" class="space-y-2">
|
||||
<label class="text-xs font-semibold text-mosquito-ink/70">已上传图片</label>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<div v-for="(img, idx) in uploadedImages" :key="idx" class="relative group">
|
||||
<img :src="img.url" class="w-20 h-20 object-cover border rounded" />
|
||||
<button type="button" class="absolute -top-2 -right-2 w-5 h-5 bg-red-500 text-white rounded-full text-xs opacity-0 group-hover:opacity-100" @click="removeImage(idx)">×</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="currentStep === 3" class="space-y-4">
|
||||
@@ -54,7 +90,9 @@
|
||||
<label class="text-xs font-semibold text-mosquito-ink/70">结束时间</label>
|
||||
<input class="mos-input mt-2 w-full" type="date" v-model="form.endDate" />
|
||||
</div>
|
||||
<button class="mos-btn mos-btn-accent w-full" @click="saveConfig">保存配置(演示)</button>
|
||||
<button class="mos-btn mos-btn-accent w-full" @click="saveConfig" :disabled="saving">
|
||||
{{ saving ? '保存中...' : '保存配置' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
@@ -66,10 +104,24 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { activityService } from '../services/activity'
|
||||
import type { ActivityStatus } from '../types/activity'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const steps = ['基础信息', '受众与转化', '奖励与预算', '发布设置']
|
||||
const currentStep = ref(0)
|
||||
const saving = ref(false)
|
||||
const uploading = ref(false)
|
||||
const loading = ref(false)
|
||||
const editorRef = ref<HTMLElement | null>(null)
|
||||
const uploadedImages = ref<{ url: string; filename: string }[]>([])
|
||||
|
||||
// 从路由参数获取活动 ID
|
||||
const activityId = ref<number | null>(null)
|
||||
|
||||
const form = ref({
|
||||
name: '裂变增长计划',
|
||||
description: '邀请好友注册,获取双倍奖励。',
|
||||
@@ -77,10 +129,186 @@ const form = ref({
|
||||
conversion: '完成注册并绑定手机号',
|
||||
reward: '每邀请 1 人奖励 20 积分',
|
||||
budget: '总预算 50,000 积分',
|
||||
richContent: '<p>活动详情描述...</p>',
|
||||
startDate: '',
|
||||
endDate: ''
|
||||
})
|
||||
|
||||
// 组件挂载时加载活动数据(如果有 ID 参数)
|
||||
onMounted(async () => {
|
||||
const id = route.params.id
|
||||
if (id) {
|
||||
activityId.value = Number(id)
|
||||
await loadActivity(activityId.value)
|
||||
}
|
||||
})
|
||||
|
||||
// 加载活动数据
|
||||
const loadActivity = async (id: number) => {
|
||||
loading.value = true
|
||||
try {
|
||||
const activity = await activityService.getActivityById(id)
|
||||
if (activity) {
|
||||
// 填充表单数据
|
||||
form.value.name = activity.name || ''
|
||||
form.value.description = activity.description || ''
|
||||
|
||||
// 解析 JSON 配置字段
|
||||
if (activity.targetUsersConfig) {
|
||||
try {
|
||||
const targetConfig = typeof activity.targetUsersConfig === 'string'
|
||||
? JSON.parse(activity.targetUsersConfig)
|
||||
: activity.targetUsersConfig
|
||||
form.value.audience = targetConfig.audience || ''
|
||||
form.value.conversion = targetConfig.conversion || ''
|
||||
} catch (e) {
|
||||
console.warn('解析 targetUsersConfig 失败:', e)
|
||||
}
|
||||
}
|
||||
|
||||
if (activity.pageContentConfig) {
|
||||
try {
|
||||
const pageConfig = typeof activity.pageContentConfig === 'string'
|
||||
? JSON.parse(activity.pageContentConfig)
|
||||
: activity.pageContentConfig
|
||||
form.value.reward = pageConfig.rewardDesc || ''
|
||||
form.value.budget = pageConfig.budget || ''
|
||||
form.value.richContent = pageConfig.richContent || '<p>活动详情...</p>'
|
||||
if (pageConfig.images) {
|
||||
uploadedImages.value = pageConfig.images
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('解析 pageContentConfig 失败:', e)
|
||||
}
|
||||
}
|
||||
|
||||
if (activity.rewardTiersConfig) {
|
||||
try {
|
||||
const rewardConfig = typeof activity.rewardTiersConfig === 'string'
|
||||
? JSON.parse(activity.rewardTiersConfig)
|
||||
: activity.rewardTiersConfig
|
||||
form.value.reward = rewardConfig.reward || form.value.reward
|
||||
form.value.budget = rewardConfig.budget || form.value.budget
|
||||
} catch (e) {
|
||||
console.warn('解析 rewardTiersConfig 失败:', e)
|
||||
}
|
||||
}
|
||||
|
||||
// 解析时间
|
||||
if (activity.startTime) {
|
||||
form.value.startDate = activity.startTime.split('T')[0]
|
||||
}
|
||||
if (activity.endTime) {
|
||||
form.value.endDate = activity.endTime.split('T')[0]
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载活动数据失败:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 富文本格式化
|
||||
const formatText = (command: string) => {
|
||||
document.execCommand(command, false)
|
||||
editorRef.value?.focus()
|
||||
}
|
||||
|
||||
// 处理编辑器输入
|
||||
const handleEditorInput = () => {
|
||||
if (editorRef.value) {
|
||||
form.value.richContent = editorRef.value.innerHTML
|
||||
}
|
||||
}
|
||||
|
||||
// 处理图片上传
|
||||
const handleImageUpload = async (event: Event) => {
|
||||
const target = event.target as HTMLInputElement
|
||||
const file = target.files?.[0]
|
||||
if (!file) return
|
||||
|
||||
// 验证文件大小(最大10MB,与后端保持一致)
|
||||
if (file.size > 10 * 1024 * 1024) {
|
||||
alert('图片大小不能超过10MB')
|
||||
return
|
||||
}
|
||||
|
||||
// 验证文件类型
|
||||
const allowedTypes = ['image/png', 'image/jpeg', 'image/gif']
|
||||
if (!allowedTypes.includes(file.type)) {
|
||||
alert('仅支持PNG、JPG、GIF格式')
|
||||
return
|
||||
}
|
||||
|
||||
uploading.value = true
|
||||
try {
|
||||
let currentActivityId = activityId.value
|
||||
|
||||
// 如果是新活动(没有活动ID),需要先创建活动
|
||||
if (!currentActivityId) {
|
||||
// 先保存基础信息创建活动
|
||||
const activityData = {
|
||||
name: form.value.name || '未命名活动',
|
||||
description: form.value.description,
|
||||
startTime: form.value.startDate ? new Date(form.value.startDate).toISOString() : undefined,
|
||||
endTime: form.value.endDate ? new Date(form.value.endDate).toISOString() : undefined,
|
||||
status: 'DRAFT' as ActivityStatus,
|
||||
targetUsersConfig: JSON.stringify({
|
||||
audience: form.value.audience,
|
||||
conversion: form.value.conversion
|
||||
}),
|
||||
pageContentConfig: JSON.stringify({
|
||||
description: form.value.description,
|
||||
rewardDesc: form.value.reward,
|
||||
budget: form.value.budget
|
||||
}),
|
||||
rewardTiersConfig: JSON.stringify({
|
||||
reward: form.value.reward,
|
||||
budget: form.value.budget
|
||||
}),
|
||||
multiLevelRewardConfig: JSON.stringify({})
|
||||
}
|
||||
|
||||
const created = await activityService.createActivity(activityData)
|
||||
if (created && created.id) {
|
||||
currentActivityId = created.id
|
||||
activityId.value = currentActivityId
|
||||
// 更新路由(可选,让用户可以刷新页面)
|
||||
router.replace(`/activity-config/edit/${currentActivityId}`)
|
||||
} else {
|
||||
alert('请先保存活动基本信息后再上传图片')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 调用后端上传接口
|
||||
const uploadResult = await activityService.uploadActivityImage(currentActivityId, file)
|
||||
// 使用后端返回的URL(这才是真实可访问的路径)
|
||||
uploadedImages.value.push({ url: uploadResult.url, filename: uploadResult.filename })
|
||||
|
||||
// 在光标位置插入图片(使用后端URL)
|
||||
if (editorRef.value) {
|
||||
const img = document.createElement('img')
|
||||
img.src = uploadResult.url
|
||||
img.className = 'max-w-full h-auto'
|
||||
editorRef.value.appendChild(img)
|
||||
form.value.richContent = editorRef.value.innerHTML
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('图片上传失败:', error)
|
||||
alert('图片上传失败,请重试')
|
||||
} finally {
|
||||
uploading.value = false
|
||||
target.value = '' // 清除文件选择
|
||||
}
|
||||
}
|
||||
|
||||
// 移除已上传图片
|
||||
const removeImage = (index: number) => {
|
||||
uploadedImages.value.splice(index, 1)
|
||||
}
|
||||
|
||||
const prevStep = () => {
|
||||
if (currentStep.value > 0) currentStep.value--
|
||||
}
|
||||
@@ -89,7 +317,62 @@ const nextStep = () => {
|
||||
if (currentStep.value < steps.length - 1) currentStep.value++
|
||||
}
|
||||
|
||||
const saveConfig = () => {
|
||||
// demo placeholder
|
||||
const saveConfig = async () => {
|
||||
if (!form.value.name || !form.value.startDate || !form.value.endDate) {
|
||||
alert('请填写必填字段')
|
||||
return
|
||||
}
|
||||
|
||||
saving.value = true
|
||||
try {
|
||||
// 统一前后端契约:四大配置字段
|
||||
const activityData = {
|
||||
name: form.value.name,
|
||||
description: form.value.description,
|
||||
startTime: form.value.startDate ? new Date(form.value.startDate).toISOString() : undefined,
|
||||
endTime: form.value.endDate ? new Date(form.value.endDate).toISOString() : undefined,
|
||||
status: 'DRAFT' as ActivityStatus,
|
||||
// 目标用户配置 JSON
|
||||
targetUsersConfig: JSON.stringify({
|
||||
audience: form.value.audience,
|
||||
conversion: form.value.conversion
|
||||
}),
|
||||
// 页面内容配置 JSON(包含富文本内容)
|
||||
pageContentConfig: JSON.stringify({
|
||||
description: form.value.description,
|
||||
rewardDesc: form.value.reward,
|
||||
budget: form.value.budget,
|
||||
richContent: form.value.richContent,
|
||||
images: uploadedImages.value
|
||||
}),
|
||||
// 阶梯奖励配置 JSON
|
||||
rewardTiersConfig: JSON.stringify({
|
||||
reward: form.value.reward,
|
||||
budget: form.value.budget
|
||||
}),
|
||||
// 多级奖励配置(暂时为空,后续可扩展)
|
||||
multiLevelRewardConfig: JSON.stringify({})
|
||||
}
|
||||
|
||||
let resultId: number
|
||||
if (activityId.value) {
|
||||
// 更新已有活动
|
||||
await activityService.updateActivity(activityId.value, activityData)
|
||||
resultId = activityId.value
|
||||
alert('活动更新成功')
|
||||
} else {
|
||||
// 创建新活动
|
||||
const created = await activityService.createActivity(activityData)
|
||||
resultId = created.id!
|
||||
alert('活动创建成功')
|
||||
}
|
||||
// 跳转到活动详情页
|
||||
router.push(`/activity/${resultId}`)
|
||||
} catch (error) {
|
||||
console.error('保存配置失败:', error)
|
||||
alert('保存失败,请重试')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -43,11 +43,11 @@ const form = ref({
|
||||
audience: ''
|
||||
})
|
||||
|
||||
const createActivity = () => {
|
||||
const created = store.create({
|
||||
const createActivity = async () => {
|
||||
const created = await store.create({
|
||||
name: form.value.name || '未命名活动',
|
||||
description: '请在配置向导中补充活动描述。',
|
||||
status: 'draft',
|
||||
status: 'DRAFT',
|
||||
startTime: form.value.startDate ? new Date(form.value.startDate).toISOString() : new Date().toISOString(),
|
||||
endTime: form.value.endDate ? new Date(form.value.endDate).toISOString() : new Date().toISOString(),
|
||||
participants: 0,
|
||||
@@ -64,6 +64,7 @@ const createActivity = () => {
|
||||
budgetUsed: 0
|
||||
}
|
||||
})
|
||||
router.push(`/activities/${created.id}`)
|
||||
// 创建成功后跳转到配置向导页面
|
||||
router.push(`/activity/config/${created.id}`)
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -7,10 +7,34 @@
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="mos-pill">{{ statusLabel }}</span>
|
||||
<button class="mos-btn mos-btn-secondary" @click="toggleStatus">
|
||||
<button
|
||||
v-if="hasPermission('activity.index.pause.ALL') || hasPermission('activity.index.resume.ALL')"
|
||||
class="mos-btn mos-btn-secondary"
|
||||
@click="toggleStatus"
|
||||
>
|
||||
{{ toggleLabel }}
|
||||
</button>
|
||||
<button class="mos-btn mos-btn-accent" @click="endActivity">下线</button>
|
||||
<button
|
||||
v-if="hasPermission('activity.index.end.ALL')"
|
||||
class="mos-btn mos-btn-accent"
|
||||
@click="endActivity"
|
||||
>
|
||||
下线
|
||||
</button>
|
||||
<button
|
||||
v-if="activity?.status === 'ENDED' && hasPermission('activity.index.update.ALL')"
|
||||
class="mos-btn mos-btn-secondary"
|
||||
@click="handleArchive"
|
||||
>
|
||||
归档
|
||||
</button>
|
||||
<button
|
||||
v-if="activity?.status === 'DRAFT' && hasPermission('activity.index.delete.ALL')"
|
||||
class="mos-btn !border-red-500 !text-red-500 hover:!bg-red-50"
|
||||
@click="handleDelete"
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -28,22 +52,22 @@
|
||||
<div class="grid gap-4 md:grid-cols-2 text-sm">
|
||||
<div>
|
||||
<div class="text-xs text-mosquito-ink/70">目标人群</div>
|
||||
<div class="font-semibold">{{ activity?.config.audience }}</div>
|
||||
<div class="font-semibold">{{ activityConfig.audience }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xs text-mosquito-ink/70">转化条件</div>
|
||||
<div class="font-semibold">{{ activity?.config.conversion }}</div>
|
||||
<div class="font-semibold">{{ activityConfig.conversion }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xs text-mosquito-ink/70">奖励规则</div>
|
||||
<div class="font-semibold">{{ activity?.config.reward }}</div>
|
||||
<div class="font-semibold">{{ activityConfig.reward }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xs text-mosquito-ink/70">预算/限额</div>
|
||||
<div class="font-semibold">{{ activity?.config.budget }}</div>
|
||||
<div class="font-semibold">{{ activityConfig.budget }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<RouterLink to="/activities/config" class="text-sm font-semibold text-mosquito-accent">
|
||||
<RouterLink to="/activity/config" class="text-sm font-semibold text-mosquito-accent">
|
||||
进入配置向导
|
||||
</RouterLink>
|
||||
</div>
|
||||
@@ -71,67 +95,221 @@
|
||||
<div class="text-sm font-semibold text-mosquito-ink">排行榜预览</div>
|
||||
<MosquitoLeaderboard v-if="activity" :activity-id="activity.id" :top-n="5" />
|
||||
</div>
|
||||
|
||||
<!-- 裂变关系图 -->
|
||||
<div class="mos-card p-5 space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="text-sm font-semibold text-mosquito-ink">裂变关系图</div>
|
||||
<button class="mos-btn mos-btn-secondary !py-1 !px-2 !text-xs" @click="loadGraph" :disabled="graphLoading">
|
||||
{{ graphLoading ? '加载中...' : '刷新关系图' }}
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="graphError" class="text-red-500 text-sm">{{ graphError }}</div>
|
||||
<div v-else-if="!graphData || graphData.nodes?.length === 0" class="text-mosquito-ink/50 text-sm text-center py-8">
|
||||
暂无关系图数据
|
||||
</div>
|
||||
<div v-else class="graph-container overflow-auto" style="max-height: 400px;">
|
||||
<div class="flex flex-wrap gap-2 mb-4">
|
||||
<span class="text-xs">节点: {{ graphData.nodes?.length || 0 }}</span>
|
||||
<span class="text-xs">关系: {{ graphData.edges?.length || 0 }}</span>
|
||||
</div>
|
||||
<!-- 简化关系图展示:节点列表 + 连接关系 -->
|
||||
<div class="grid gap-2 md:grid-cols-2 lg:grid-cols-3">
|
||||
<div v-for="node in (graphData.nodes || []).slice(0, 20)" :key="node.id"
|
||||
class="border rounded p-2 text-xs hover:bg-mosquito-bg/50 cursor-pointer"
|
||||
@click="showNodeDetail(node)">
|
||||
<div class="font-semibold truncate">{{ node.label || node.id }}</div>
|
||||
<div class="text-mosquito-ink/70">
|
||||
直接邀请: {{ node.directInvites || 0 }} | 间接邀请: {{ node.indirectInvites || 0 }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="(graphData.nodes?.length || 0) > 20" class="text-center text-xs text-mosquito-ink/50 mt-2">
|
||||
... 还有 {{ (graphData.nodes?.length || 0) - 20 }} 个节点
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRoute, RouterLink } from 'vue-router'
|
||||
import { useRoute, useRouter, RouterLink } from 'vue-router'
|
||||
import MosquitoLeaderboard from '../../../components/MosquitoLeaderboard.vue'
|
||||
import { useActivityStore } from '../stores/activities'
|
||||
import { useAuditStore } from '../stores/audit'
|
||||
import { activityService } from '../services/activity'
|
||||
import type { ActivityItem } from '../stores/activities'
|
||||
import { downloadCsv } from '../utils/export'
|
||||
import ExportFieldPanel, { type ExportField } from '../components/ExportFieldPanel.vue'
|
||||
import { useExportFields } from '../composables/useExportFields'
|
||||
import { useDataService } from '../services'
|
||||
import { usePermission } from '../composables/usePermission'
|
||||
import type { Permission } from '../auth/roles'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const store = useActivityStore()
|
||||
const auditStore = useAuditStore()
|
||||
const activity = ref<ActivityItem | null>(null)
|
||||
const service = useDataService()
|
||||
const activity = ref<any>(null)
|
||||
const stats = ref<any>(null)
|
||||
const { hasPermission } = usePermission()
|
||||
|
||||
const loadActivity = () => {
|
||||
// 裂变关系图相关
|
||||
const graphData = ref<any>(null)
|
||||
const graphLoading = ref(false)
|
||||
const graphError = ref<string | null>(null)
|
||||
|
||||
const loadGraph = async () => {
|
||||
if (!activity.value) return
|
||||
graphLoading.value = true
|
||||
graphError.value = null
|
||||
try {
|
||||
graphData.value = await activityService.getActivityGraph(activity.value.id)
|
||||
} catch (e: any) {
|
||||
graphError.value = e.message || '加载关系图失败'
|
||||
console.error('加载关系图失败:', e)
|
||||
} finally {
|
||||
graphLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const showNodeDetail = (node: any) => {
|
||||
alert(`用户: ${node.label || node.id}\n直接邀请: ${node.directInvites || 0}人\n间接邀请: ${node.indirectInvites || 0}人`)
|
||||
}
|
||||
|
||||
// 解析活动配置
|
||||
const activityConfig = computed(() => {
|
||||
if (!activity.value) return { audience: '-', conversion: '-', reward: '-', budget: '-' }
|
||||
try {
|
||||
const targetUsers = activity.value.targetUsersConfig ? JSON.parse(activity.value.targetUsersConfig) : {}
|
||||
const pageContent = activity.value.pageContentConfig ? JSON.parse(activity.value.pageContentConfig) : {}
|
||||
const rewardTiers = activity.value.rewardTiersConfig ? JSON.parse(activity.value.rewardTiersConfig) : {}
|
||||
return {
|
||||
audience: targetUsers.description || targetUsers.targetType || '全量用户',
|
||||
conversion: pageContent.conversionGoal || pageContent.condition || '完成邀请',
|
||||
reward: rewardTiers.tiers?.map((t: any) => `${t.level}级:${t.reward}`).join(', ') || '按阶梯奖励',
|
||||
budget: activity.value.budget || activity.value.maxBudget || '-'
|
||||
}
|
||||
} catch (e) {
|
||||
return { audience: '-', conversion: '-', reward: '-', budget: '-' }
|
||||
}
|
||||
})
|
||||
|
||||
const loadActivity = async () => {
|
||||
const id = Number(route.params.id)
|
||||
activity.value = store.byId(id)
|
||||
activity.value = await activityService.getActivityById(id)
|
||||
// 获取活动统计数据
|
||||
try {
|
||||
stats.value = await activityService.getActivityStats(id)
|
||||
} catch (e) {
|
||||
console.warn('获取统计数据失败:', e)
|
||||
stats.value = { pv: 0, uv: 0, participants: 0, shares: 0, newUsers: 0, kFactor: 0, cac: 0 }
|
||||
}
|
||||
// 加载裂变关系图
|
||||
loadGraph()
|
||||
}
|
||||
|
||||
const statusLabel = computed(() => {
|
||||
if (!activity.value) return '未知'
|
||||
const map: Record<string, string> = {
|
||||
draft: '草稿',
|
||||
scheduled: '待上线',
|
||||
active: '进行中',
|
||||
paused: '已暂停',
|
||||
ended: '已结束'
|
||||
DRAFT: '草稿',
|
||||
PENDING: '待审批',
|
||||
IN_APPROVAL: '审批中',
|
||||
APPROVED: '已审批',
|
||||
REJECTED: '已拒绝',
|
||||
WAITING_PUBLISH: '待发布',
|
||||
RUNNING: '进行中',
|
||||
PAUSED: '已暂停',
|
||||
ENDED: '已结束',
|
||||
ARCHIVED: '已归档',
|
||||
DELETED: '已删除'
|
||||
}
|
||||
return map[activity.value.status] ?? '未知'
|
||||
})
|
||||
|
||||
const toggleLabel = computed(() => {
|
||||
if (!activity.value) return '切换状态'
|
||||
return activity.value.status === 'active' ? '暂停' : '上线'
|
||||
return activity.value.status === 'RUNNING' ? '暂停' : '上线'
|
||||
})
|
||||
|
||||
const toggleStatus = () => {
|
||||
const toggleStatus = async () => {
|
||||
if (!activity.value) return
|
||||
const next = activity.value.status === 'active' ? 'paused' : 'active'
|
||||
activity.value = store.updateStatus(activity.value.id, next)
|
||||
auditStore.addLog(next === 'active' ? '上线活动' : '暂停活动', activity.value?.name ?? '活动')
|
||||
const next = activity.value.status === 'RUNNING' ? 'PAUSED' : 'RUNNING'
|
||||
try {
|
||||
if (next === 'PAUSED') {
|
||||
await activityService.pauseActivity(activity.value.id)
|
||||
} else {
|
||||
await activityService.resumeActivity(activity.value.id)
|
||||
}
|
||||
// 重新获取活动详情以确保状态同步
|
||||
const updated = await activityService.getActivityById(activity.value.id)
|
||||
if (updated) {
|
||||
activity.value = updated
|
||||
}
|
||||
auditStore.addLog(next === 'RUNNING' ? '上线活动' : '暂停活动', activity.value?.name ?? '活动')
|
||||
} catch (error) {
|
||||
console.error('切换状态失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const endActivity = () => {
|
||||
// 删除活动
|
||||
const handleDelete = async () => {
|
||||
if (!activity.value) return
|
||||
activity.value = store.updateStatus(activity.value.id, 'ended')
|
||||
auditStore.addLog('下线活动', activity.value?.name ?? '活动')
|
||||
if (!confirm('确定要删除这个活动吗?只有草稿状态的活动才能删除。')) return
|
||||
try {
|
||||
await activityService.deleteActivity(activity.value.id)
|
||||
alert('活动删除成功')
|
||||
router.push('/activities')
|
||||
auditStore.addLog('删除活动', activity.value?.name ?? '活动')
|
||||
} catch (error) {
|
||||
console.error('删除活动失败:', error)
|
||||
alert(error instanceof Error ? error.message : '删除活动失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 归档活动
|
||||
const handleArchive = async () => {
|
||||
if (!activity.value) return
|
||||
if (!confirm('确定要归档这个活动吗?归档后活动将不再显示在列表中。')) return
|
||||
try {
|
||||
await activityService.archiveActivity(activity.value.id)
|
||||
// 重新获取活动详情以确保状态同步
|
||||
const updated = await activityService.getActivityById(activity.value.id)
|
||||
if (updated) {
|
||||
activity.value = updated
|
||||
}
|
||||
alert('活动归档成功')
|
||||
auditStore.addLog('归档活动', activity.value?.name ?? '活动')
|
||||
} catch (error) {
|
||||
console.error('归档活动失败:', error)
|
||||
alert(error instanceof Error ? error.message : '归档活动失败')
|
||||
}
|
||||
}
|
||||
|
||||
const endActivity = async () => {
|
||||
if (!activity.value) return
|
||||
try {
|
||||
await activityService.endActivity(activity.value.id)
|
||||
// 重新获取活动详情以确保状态同步
|
||||
const updated = await activityService.getActivityById(activity.value.id)
|
||||
if (updated) {
|
||||
activity.value = updated
|
||||
}
|
||||
auditStore.addLog('下线活动', activity.value?.name ?? '活动')
|
||||
} catch (error) {
|
||||
console.error('结束活动失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const metricsCards = computed(() => {
|
||||
if (!activity.value) return []
|
||||
if (!stats.value) return []
|
||||
return [
|
||||
{ label: '访问', value: activity.value.metrics.visits, hint: '近 7 天访问次数' },
|
||||
{ label: '分享', value: activity.value.metrics.shares, hint: '累计分享次数' },
|
||||
{ label: '转化', value: activity.value.metrics.conversions, hint: '累计转化人数' },
|
||||
{ label: '预算消耗', value: activity.value.metrics.budgetUsed, hint: '已消耗积分' }
|
||||
{ label: '访问(PV)', value: stats.value.pv || 0, hint: '页面访问次数' },
|
||||
{ label: '独立访客(UV)', value: stats.value.uv || 0, hint: '独立访客数' },
|
||||
{ label: '分享', value: stats.value.shares || 0, hint: '累计分享次数' },
|
||||
{ label: '转化(新用户)', value: stats.value.newUsers || 0, hint: '累计新用户数(新注册/转化)' }
|
||||
]
|
||||
})
|
||||
|
||||
@@ -186,7 +364,7 @@ const setCurrentSelected = (next: string[]) => {
|
||||
exportStates[exportType.value].setSelected(next)
|
||||
}
|
||||
|
||||
const exportData = () => {
|
||||
const exportData = async () => {
|
||||
const fields = currentFields.value
|
||||
const selectedKeys = currentSelected.value
|
||||
const filename = `${activity.value?.name ?? 'activity'}-${exportType.value}.csv`
|
||||
@@ -197,36 +375,58 @@ const exportData = () => {
|
||||
status: statusLabel.value,
|
||||
startTime: activity.value?.startTime ?? '',
|
||||
endTime: activity.value?.endTime ?? '',
|
||||
visits: String(activity.value?.metrics.visits ?? 0),
|
||||
shares: String(activity.value?.metrics.shares ?? 0),
|
||||
conversions: String(activity.value?.metrics.conversions ?? 0),
|
||||
budgetUsed: String(activity.value?.metrics.budgetUsed ?? 0)
|
||||
visits: String(stats.value?.pv ?? 0),
|
||||
shares: String(stats.value?.shares ?? 0),
|
||||
conversions: String(stats.value?.newUsers ?? 0),
|
||||
budgetUsed: '0'
|
||||
}
|
||||
const rows = fields
|
||||
.filter((field) => selectedKeys.includes(field.key))
|
||||
.map((field) => [field.label, values[field.key] ?? ''])
|
||||
downloadCsv(filename, ['字段', '值'], rows)
|
||||
auditStore.addLog('导出活动摘要', activity.value?.name ?? '')
|
||||
return
|
||||
}
|
||||
|
||||
const sample = exportType.value === 'conversions'
|
||||
? {
|
||||
user: '示例用户',
|
||||
channel: '分享链接',
|
||||
convertedAt: new Date().toLocaleString('zh-CN'),
|
||||
reward: '20 积分'
|
||||
}
|
||||
: {
|
||||
user: '示例用户',
|
||||
points: '20',
|
||||
status: '已发放',
|
||||
issuedAt: new Date().toLocaleString('zh-CN')
|
||||
}
|
||||
// 导出转化明细或奖励明细 - 使用真实API数据
|
||||
try {
|
||||
let exportData: { headers: string[]; rows: string[][] } | null = null
|
||||
|
||||
const rows = fields
|
||||
.filter((field) => selectedKeys.includes(field.key))
|
||||
.map((field) => [field.label, String(sample[field.key as keyof typeof sample] ?? '')])
|
||||
downloadCsv(filename, ['字段', '值'], rows)
|
||||
if (exportType.value === 'conversions') {
|
||||
// 调用真实API获取参与者数据
|
||||
const activityId = Number(route.params.id)
|
||||
exportData = await service.exportActivityParticipants(activityId)
|
||||
} else if (exportType.value === 'rewards') {
|
||||
// 调用真实API获取奖励数据
|
||||
const activityId = Number(route.params.id)
|
||||
exportData = await service.exportActivityRewards(activityId)
|
||||
}
|
||||
|
||||
if (exportData && exportData.rows.length > 0) {
|
||||
// 过滤选中的字段
|
||||
const fieldMap = new Map(fields.map(f => [f.key, f.label]))
|
||||
const filteredHeaders = exportData.headers.filter((_, i) => {
|
||||
const key = Object.keys(exportData!.rows[0] || {})[i]
|
||||
return selectedKeys.includes(key)
|
||||
})
|
||||
const filteredRows = exportData.rows.map(row =>
|
||||
exportData!.headers.map((h, i) => row[i])
|
||||
).map(row =>
|
||||
row.filter((_, i) => selectedKeys.includes(Object.keys(exportData!.rows[0] || {})[i]))
|
||||
)
|
||||
|
||||
downloadCsv(filename, filteredHeaders, filteredRows)
|
||||
auditStore.addLog(
|
||||
exportType.value === 'conversions' ? '导出转化明细' : '导出奖励明细',
|
||||
activity.value?.name ?? ''
|
||||
)
|
||||
} else {
|
||||
alert('暂无数据可导出')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('导出失败:', error)
|
||||
alert('导出失败: ' + (error as Error).message)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadActivity)
|
||||
|
||||
@@ -15,43 +15,108 @@
|
||||
{{ loadError }}
|
||||
</div>
|
||||
|
||||
<ListSection :page="page" :total-pages="totalPages" @prev="page--" @next="page++">
|
||||
<ListSection :page="page + 1" :total-pages="totalPages" @prev="prevPage" @next="nextPage">
|
||||
<template #title>活动运营看板</template>
|
||||
<template #subtitle>查看活动列表并管理分享推广设置。</template>
|
||||
<template #filters>
|
||||
<input class="mos-input !py-1 !px-2 !text-xs w-48" v-model="query" placeholder="搜索活动名称" />
|
||||
<select class="mos-input !py-1 !px-2 !text-xs" v-model="statusFilter">
|
||||
<option value="">全部状态</option>
|
||||
<option value="进行中">进行中</option>
|
||||
<option value="未开始">未开始</option>
|
||||
<option value="已结束">已结束</option>
|
||||
<option value="待配置">待配置</option>
|
||||
<!-- 值为英文状态码,显示为中文 -->
|
||||
<option value="RUNNING">进行中</option>
|
||||
<option value="WAITING_PUBLISH">待发布</option>
|
||||
<option value="ENDED">已结束</option>
|
||||
<option value="PAUSED">已暂停</option>
|
||||
<option value="DRAFT">草稿</option>
|
||||
</select>
|
||||
<input class="mos-input !py-1 !px-2 !text-xs" type="date" v-model="startDate" />
|
||||
<input class="mos-input !py-1 !px-2 !text-xs" type="date" v-model="endDate" />
|
||||
</template>
|
||||
<template #actions>
|
||||
<RouterLink to="/activities/new" class="rounded-xl bg-mosquito-accent px-3 py-2 text-sm font-semibold text-white shadow-soft">
|
||||
新建活动
|
||||
</RouterLink>
|
||||
<PermissionButton permission="activity.index.create.ALL" :hide-when-no-permission="true">
|
||||
<RouterLink to="/activity/new" class="rounded-xl bg-mosquito-accent px-3 py-2 text-sm font-semibold text-white shadow-soft">
|
||||
新建活动
|
||||
</RouterLink>
|
||||
</PermissionButton>
|
||||
</template>
|
||||
<template #default>
|
||||
<!-- 批量操作工具栏 -->
|
||||
<div v-if="pagedActivities.length" class="mb-3 flex items-center justify-between rounded-lg bg-mosquito-bg/50 px-3 py-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<label class="flex cursor-pointer items-center gap-1 text-xs">
|
||||
<input type="checkbox" v-model="selectAll" @change="toggleSelectAll" class="h-3.5 w-3.5" />
|
||||
<span class="text-mosquito-ink/70">全选</span>
|
||||
</label>
|
||||
<span class="text-xs text-mosquito-ink/50">|</span>
|
||||
<span class="text-xs text-mosquito-ink/70">已选 {{ selectedIds.length }} 项</span>
|
||||
</div>
|
||||
<div v-if="selectedIds.length > 0" class="flex gap-2">
|
||||
<PermissionButton permission="activity.index.publish.ALL" variant="secondary" @click="handleBatchPublish" :disabled="batchLoading">
|
||||
<span class="rounded px-2 py-1 text-xs font-semibold text-mosquito-brand hover:bg-mosquito-accent/10">批量发布</span>
|
||||
</PermissionButton>
|
||||
<PermissionButton permission="activity.index.pause.ALL" variant="secondary" @click="handleBatchPause" :disabled="batchLoading">
|
||||
<span class="rounded px-2 py-1 text-xs font-semibold text-amber-600 hover:bg-amber-50">批量暂停</span>
|
||||
</PermissionButton>
|
||||
<PermissionButton permission="activity.index.end.ALL" variant="secondary" @click="handleBatchEnd" :disabled="batchLoading">
|
||||
<span class="rounded px-2 py-1 text-xs font-semibold text-blue-600 hover:bg-blue-50">批量结束</span>
|
||||
</PermissionButton>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="pagedActivities.length" class="space-y-3">
|
||||
<RouterLink
|
||||
<div
|
||||
v-for="item in pagedActivities"
|
||||
:key="item.name"
|
||||
:to="`/activities/${item.id}`"
|
||||
:key="item.id"
|
||||
class="flex items-center justify-between rounded-xl border border-mosquito-line px-4 py-3 transition hover:bg-mosquito-bg/60"
|
||||
>
|
||||
<div>
|
||||
<div class="text-sm font-semibold text-mosquito-ink">{{ item.name }}</div>
|
||||
<div class="mos-muted text-xs">{{ item.period }}</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<input type="checkbox" :value="item.id" v-model="selectedIds" class="h-4 w-4" @click.stop />
|
||||
<RouterLink :to="`/activity/${item.id}`" class="flex-1">
|
||||
<div class="text-sm font-semibold text-mosquito-ink">{{ item.name }}</div>
|
||||
<div class="mos-muted text-xs">{{ item.period }}</div>
|
||||
</RouterLink>
|
||||
</div>
|
||||
<div class="flex items-center gap-4 text-xs text-mosquito-ink/70">
|
||||
<span>{{ item.participants }} 人参与</span>
|
||||
<span class="rounded-full bg-mosquito-accent/10 px-2 py-1 text-[10px] font-semibold text-mosquito-brand">{{ item.status }}</span>
|
||||
<div class="flex items-center gap-1" @click.stop>
|
||||
<PermissionButton
|
||||
v-if="getAvailableActions(item).some(a => a.key === 'publish')"
|
||||
permission="activity.index.publish.ALL"
|
||||
:disabled="isOperating(item.id)"
|
||||
variant="primary"
|
||||
@click="handlePublish(item.id)"
|
||||
>发布</PermissionButton>
|
||||
<PermissionButton
|
||||
v-if="getAvailableActions(item).some(a => a.key === 'pause')"
|
||||
permission="activity.index.pause.ALL"
|
||||
:disabled="isOperating(item.id)"
|
||||
variant="secondary"
|
||||
@click="handlePause(item.id)"
|
||||
>暂停</PermissionButton>
|
||||
<PermissionButton
|
||||
v-if="getAvailableActions(item).some(a => a.key === 'resume')"
|
||||
permission="activity.index.resume.ALL"
|
||||
:disabled="isOperating(item.id)"
|
||||
variant="secondary"
|
||||
@click="handleResume(item.id)"
|
||||
>恢复</PermissionButton>
|
||||
<PermissionButton
|
||||
v-if="getAvailableActions(item).some(a => a.key === 'end')"
|
||||
permission="activity.index.end.ALL"
|
||||
:disabled="isOperating(item.id)"
|
||||
variant="secondary"
|
||||
@click="handleEnd(item.id)"
|
||||
>结束</PermissionButton>
|
||||
<PermissionButton
|
||||
v-if="getAvailableActions(item).some(a => a.key === 'delete')"
|
||||
permission="activity.index.delete.ALL"
|
||||
:disabled="isOperating(item.id)"
|
||||
variant="danger"
|
||||
@click="handleDelete(item.id)"
|
||||
>删除</PermissionButton>
|
||||
</div>
|
||||
</div>
|
||||
</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #empty>
|
||||
@@ -113,20 +178,28 @@ import { useRoute, RouterLink } from 'vue-router'
|
||||
import MosquitoLeaderboard from '../../../components/MosquitoLeaderboard.vue'
|
||||
import { getUserIdFromToken, parseUserId } from '../../../shared/auth'
|
||||
import { useDataService } from '../services'
|
||||
import activityService from '../services/activity'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import ListSection from '../components/ListSection.vue'
|
||||
import PermissionButton from '../components/PermissionButton.vue'
|
||||
|
||||
type ActivitySummary = {
|
||||
id: number
|
||||
name: string
|
||||
startTime?: string
|
||||
endTime?: string
|
||||
status?: string
|
||||
participants?: number
|
||||
period?: string
|
||||
}
|
||||
|
||||
const activityId = 1
|
||||
const service = useDataService()
|
||||
const route = useRoute()
|
||||
const auth = useAuthStore()
|
||||
const userToken = import.meta.env.VITE_MOSQUITO_USER_TOKEN
|
||||
const hasAuth = computed(() => true)
|
||||
// 基于认证状态计算权限(真实鉴权)
|
||||
const hasAuth = computed(() => auth.isAuthenticated)
|
||||
const routeUserId = computed(() => parseUserId(route.query.userId ?? route.params.userId))
|
||||
const currentUserId = computed(() => getUserIdFromToken(userToken) ?? routeUserId.value ?? undefined)
|
||||
const activities = ref<ActivitySummary[]>([])
|
||||
@@ -139,6 +212,92 @@ const endDate = ref('')
|
||||
const page = ref(0)
|
||||
const pageSize = 6
|
||||
|
||||
// 批量选择相关
|
||||
const selectedIds = ref<number[]>([])
|
||||
const selectAll = ref(false)
|
||||
const batchLoading = ref(false)
|
||||
|
||||
// 分页方法
|
||||
const prevPage = () => {
|
||||
if (page.value > 0) {
|
||||
page.value--
|
||||
}
|
||||
}
|
||||
|
||||
const nextPage = () => {
|
||||
if (page.value < totalPages.value - 1) {
|
||||
page.value++
|
||||
}
|
||||
}
|
||||
|
||||
// 全选/取消全选
|
||||
const toggleSelectAll = () => {
|
||||
if (selectAll.value) {
|
||||
selectedIds.value = pagedActivities.value.map((item: any) => item.id)
|
||||
} else {
|
||||
selectedIds.value = []
|
||||
}
|
||||
}
|
||||
|
||||
// 批量发布
|
||||
const handleBatchPublish = async () => {
|
||||
if (selectedIds.value.length === 0) return
|
||||
if (!confirm(`确定发布选中的 ${selectedIds.value.length} 个活动吗?`)) return
|
||||
batchLoading.value = true
|
||||
try {
|
||||
const result = await activityService.batchPublish(selectedIds.value)
|
||||
showMessage(`批量发布成功: ${result.successCount || selectedIds.value.length} 个`)
|
||||
selectedIds.value = []
|
||||
selectAll.value = false
|
||||
await loadActivities()
|
||||
} catch (e: any) {
|
||||
showMessage(e.message || '批量发布失败', true)
|
||||
} finally {
|
||||
batchLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 批量暂停
|
||||
const handleBatchPause = async () => {
|
||||
if (selectedIds.value.length === 0) return
|
||||
if (!confirm(`确定暂停选中的 ${selectedIds.value.length} 个活动吗?`)) return
|
||||
batchLoading.value = true
|
||||
try {
|
||||
const result = await activityService.batchPause(selectedIds.value)
|
||||
showMessage(`批量暂停成功: ${result.successCount || selectedIds.value.length} 个`)
|
||||
selectedIds.value = []
|
||||
selectAll.value = false
|
||||
await loadActivities()
|
||||
} catch (e: any) {
|
||||
showMessage(e.message || '批量暂停失败', true)
|
||||
} finally {
|
||||
batchLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 批量结束
|
||||
const handleBatchEnd = async () => {
|
||||
if (selectedIds.value.length === 0) return
|
||||
if (!confirm(`确定结束选中的 ${selectedIds.value.length} 个活动吗?`)) return
|
||||
batchLoading.value = true
|
||||
try {
|
||||
const result = await activityService.batchEnd(selectedIds.value)
|
||||
showMessage(`批量结束成功: ${result.successCount || selectedIds.value.length} 个`)
|
||||
selectedIds.value = []
|
||||
selectAll.value = false
|
||||
await loadActivities()
|
||||
} catch (e: any) {
|
||||
showMessage(e.message || '批量结束失败', true)
|
||||
} finally {
|
||||
batchLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 监听分页变化,重新加载数据
|
||||
watch(page, () => {
|
||||
loadActivities()
|
||||
})
|
||||
|
||||
const formatPeriod = (activity: ActivitySummary) => {
|
||||
if (!activity.startTime || !activity.endTime) {
|
||||
return '活动时间待配置'
|
||||
@@ -152,6 +311,11 @@ const formatPeriod = (activity: ActivitySummary) => {
|
||||
}
|
||||
|
||||
const resolveStatus = (activity: ActivitySummary) => {
|
||||
// 优先使用后端返回的状态,不再使用时间推导
|
||||
if (activity.status) {
|
||||
return mapBackendStatusToChinese(activity.status)
|
||||
}
|
||||
// 兜底:如果后端没有返回状态,才使用时间推导(兼容旧数据)
|
||||
if (!activity.startTime || !activity.endTime) {
|
||||
return '待配置'
|
||||
}
|
||||
@@ -170,6 +334,29 @@ const resolveStatus = (activity: ActivitySummary) => {
|
||||
return '进行中'
|
||||
}
|
||||
|
||||
// 后端状态到中文的映射
|
||||
const mapBackendStatusToChinese = (backendStatus: string): string => {
|
||||
const statusMap: Record<string, string> = {
|
||||
'DRAFT': '草稿',
|
||||
'PENDING': '待审批',
|
||||
'IN_APPROVAL': '审批中',
|
||||
'APPROVED': '已审批',
|
||||
'REJECTED': '已拒绝',
|
||||
'WAITING_PUBLISH': '待发布',
|
||||
'RUNNING': '进行中',
|
||||
'PAUSED': '已暂停',
|
||||
'ENDED': '已结束',
|
||||
'ARCHIVED': '已归档',
|
||||
'DELETED': '已删除'
|
||||
}
|
||||
return statusMap[backendStatus] || backendStatus
|
||||
}
|
||||
|
||||
// 获取原始后端状态(用于按钮逻辑)
|
||||
const getBackendStatus = (activity: ActivitySummary): string => {
|
||||
return activity.status || ''
|
||||
}
|
||||
|
||||
const activitiesWithMeta = computed(() =>
|
||||
activities.value.map((item) => ({
|
||||
id: item.id,
|
||||
@@ -192,27 +379,192 @@ const filteredActivities = computed(() => {
|
||||
})
|
||||
})
|
||||
|
||||
const totalPages = computed(() => Math.max(1, Math.ceil(filteredActivities.value.length / pageSize)))
|
||||
|
||||
// 活动列表(使用过滤后的数据 + 后端分页)
|
||||
const pagedActivities = computed(() => {
|
||||
const start = page.value * pageSize
|
||||
return filteredActivities.value.slice(start, start + pageSize)
|
||||
// 后端已返回分页数据,直接使用
|
||||
const data = activities.value
|
||||
// 前端再做一次筛选确保一致性(搜索/状态/日期)
|
||||
return data.filter((item: any) => {
|
||||
const matchesQuery = (item.name ?? `活动 #${item.id}`).includes(query.value.trim())
|
||||
// 统一使用后端状态码进行筛选,支持中英文状态值
|
||||
const matchesStatus = statusFilter.value
|
||||
? item.status === statusFilter.value || item.status === mapChineseStatusToBackend(statusFilter.value)
|
||||
: true
|
||||
const startOk = startDate.value ? new Date(item.startTime).getTime() >= new Date(startDate.value).getTime() : true
|
||||
const endOk = endDate.value ? new Date(item.endTime).getTime() <= new Date(endDate.value).getTime() : true
|
||||
return matchesQuery && matchesStatus && startOk && endOk
|
||||
})
|
||||
})
|
||||
|
||||
watch([query, statusFilter, startDate, endDate], () => {
|
||||
page.value = 0
|
||||
// 中文状态到后端状态的映射(用于筛选)
|
||||
const mapChineseStatusToBackend = (chineseStatus: string): string => {
|
||||
const reverseStatusMap: Record<string, string> = {
|
||||
'草稿': 'DRAFT',
|
||||
'待审批': 'PENDING',
|
||||
'审批中': 'IN_APPROVAL',
|
||||
'已审批': 'APPROVED',
|
||||
'已拒绝': 'REJECTED',
|
||||
'待发布': 'WAITING_PUBLISH',
|
||||
'进行中': 'RUNNING',
|
||||
'已暂停': 'PAUSED',
|
||||
'已结束': 'ENDED',
|
||||
'已归档': 'ARCHIVED',
|
||||
'已删除': 'DELETED',
|
||||
'待配置': 'DRAFT'
|
||||
}
|
||||
return reverseStatusMap[chineseStatus] || chineseStatus
|
||||
}
|
||||
|
||||
// 监听筛选条件变化,重置页码并重新加载数据
|
||||
watch([query, statusFilter, startDate, endDate], (newVals, oldVals) => {
|
||||
// 如果当前页不是第一页,先回到第一页(这会自动触发loadActivities)
|
||||
if (page.value !== 0) {
|
||||
page.value = 0
|
||||
} else {
|
||||
// 如果已经在第一页,直接重新加载
|
||||
loadActivities()
|
||||
}
|
||||
})
|
||||
|
||||
// 总记录数(来自后端分页)
|
||||
const total = ref(0)
|
||||
const totalPages = computed(() => Math.ceil(total.value / pageSize))
|
||||
|
||||
const loadActivities = async () => {
|
||||
loadError.value = ''
|
||||
try {
|
||||
const list = await service.getActivities()
|
||||
activities.value = list
|
||||
// 使用后端分页API(带筛选参数)
|
||||
const result = await service.getActivities({
|
||||
page: page.value,
|
||||
size: pageSize,
|
||||
status: statusFilter.value || undefined,
|
||||
keyword: query.value || undefined,
|
||||
startDate: startDate.value || undefined,
|
||||
endDate: endDate.value || undefined
|
||||
})
|
||||
|
||||
// 处理分页响应
|
||||
const pageResult = result as { items?: any[]; total?: number }
|
||||
if (pageResult && typeof pageResult === 'object' && 'items' in pageResult && Array.isArray(pageResult.items)) {
|
||||
// 分页对象格式
|
||||
activities.value = pageResult.items
|
||||
total.value = typeof pageResult.total === 'number' ? pageResult.total : pageResult.items.length
|
||||
} else if (Array.isArray(result)) {
|
||||
// 数组格式(演示模式兼容)
|
||||
activities.value = result
|
||||
total.value = result.length
|
||||
} else {
|
||||
activities.value = []
|
||||
total.value = 0
|
||||
}
|
||||
} catch (error) {
|
||||
loadError.value = '活动列表加载失败。'
|
||||
}
|
||||
}
|
||||
|
||||
// 活动操作函数
|
||||
const operationLoading = ref<Record<number, string>>({})
|
||||
|
||||
const showMessage = (message: string, isError = false) => {
|
||||
// 使用简单的alert或console展示消息
|
||||
if (isError) {
|
||||
console.error(message)
|
||||
alert('错误: ' + message)
|
||||
} else {
|
||||
console.log(message)
|
||||
alert(message)
|
||||
}
|
||||
}
|
||||
|
||||
const handlePublish = async (id: number) => {
|
||||
operationLoading.value[id] = 'publishing'
|
||||
try {
|
||||
await activityService.publishActivity(id)
|
||||
showMessage('发布成功')
|
||||
await loadActivities()
|
||||
} catch (e: any) {
|
||||
showMessage(e.message || '发布失败', true)
|
||||
} finally {
|
||||
delete operationLoading.value[id]
|
||||
}
|
||||
}
|
||||
|
||||
const handlePause = async (id: number) => {
|
||||
operationLoading.value[id] = 'pausing'
|
||||
try {
|
||||
await activityService.pauseActivity(id)
|
||||
showMessage('暂停成功')
|
||||
await loadActivities()
|
||||
} catch (e: any) {
|
||||
showMessage(e.message || '暂停失败', true)
|
||||
} finally {
|
||||
delete operationLoading.value[id]
|
||||
}
|
||||
}
|
||||
|
||||
const handleResume = async (id: number) => {
|
||||
operationLoading.value[id] = 'resuming'
|
||||
try {
|
||||
await activityService.resumeActivity(id)
|
||||
showMessage('恢复成功')
|
||||
await loadActivities()
|
||||
} catch (e: any) {
|
||||
showMessage(e.message || '恢复失败', true)
|
||||
} finally {
|
||||
delete operationLoading.value[id]
|
||||
}
|
||||
}
|
||||
|
||||
const handleEnd = async (id: number) => {
|
||||
operationLoading.value[id] = 'ending'
|
||||
try {
|
||||
await activityService.endActivity(id)
|
||||
showMessage('结束成功')
|
||||
await loadActivities()
|
||||
} catch (e: any) {
|
||||
showMessage(e.message || '结束失败', true)
|
||||
} finally {
|
||||
delete operationLoading.value[id]
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
if (!confirm('确定要删除该活动吗?此操作不可恢复。')) {
|
||||
return
|
||||
}
|
||||
operationLoading.value[id] = 'deleting'
|
||||
try {
|
||||
await activityService.deleteActivity(id)
|
||||
showMessage('删除成功')
|
||||
await loadActivities()
|
||||
} catch (e: any) {
|
||||
showMessage(e.message || '删除失败', true)
|
||||
} finally {
|
||||
delete operationLoading.value[id]
|
||||
}
|
||||
}
|
||||
|
||||
// 根据后端状态判断操作按钮显示(严格遵守状态机规则)
|
||||
const getAvailableActions = (item: ActivitySummary) => {
|
||||
// 使用原始后端状态,不再使用前端推导的中文状态
|
||||
const backendStatus = getBackendStatus(item)
|
||||
const actions: { key: string; label: string; type: string; show: boolean }[] = [
|
||||
// 发布按钮:DRAFT, REJECTED, WAITING_PUBLISH 可以发布
|
||||
{ key: 'publish', label: '发布', type: 'primary', show: ['DRAFT', 'REJECTED', 'WAITING_PUBLISH'].includes(backendStatus) },
|
||||
// 暂停按钮:RUNNING 状态可以暂停
|
||||
{ key: 'pause', label: '暂停', type: 'warning', show: backendStatus === 'RUNNING' },
|
||||
// 恢复按钮:PAUSED 状态可以恢复
|
||||
{ key: 'resume', label: '恢复', type: 'success', show: backendStatus === 'PAUSED' },
|
||||
// 结束按钮:RUNNING 或 PAUSED 可以结束
|
||||
{ key: 'end', label: '结束', type: 'info', show: ['RUNNING', 'PAUSED'].includes(backendStatus) },
|
||||
// 删除按钮:仅 DRAFT 状态可以删除
|
||||
{ key: 'delete', label: '删除', type: 'danger', show: backendStatus === 'DRAFT' }
|
||||
]
|
||||
return actions.filter(a => a.show)
|
||||
}
|
||||
|
||||
const isOperating = (id: number) => !!operationLoading.value[id]
|
||||
|
||||
onMounted(() => {
|
||||
loadActivities()
|
||||
})
|
||||
|
||||
142
frontend/admin/src/views/ActivityParticipantsView.vue
Normal file
142
frontend/admin/src/views/ActivityParticipantsView.vue
Normal file
@@ -0,0 +1,142 @@
|
||||
<template>
|
||||
<section class="space-y-6">
|
||||
<header class="space-y-2">
|
||||
<div class="flex items-center gap-3">
|
||||
<RouterLink to="/activities" class="text-mosquito-brand hover:underline">← 返回活动列表</RouterLink>
|
||||
<h1 class="mos-title text-2xl font-semibold">活动参与者</h1>
|
||||
</div>
|
||||
<p class="mos-muted text-sm">查看活动 "{{ currentActivityName }}" 的邀请参与者列表。</p>
|
||||
</header>
|
||||
|
||||
<div class="mos-card p-5 space-y-4">
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<label class="text-xs font-semibold text-mosquito-ink/70">选择活动</label>
|
||||
<select class="mos-input !py-1 !px-2 !text-xs" v-model.number="selectedActivityId" @change="loadParticipants">
|
||||
<option :value="0">请选择活动</option>
|
||||
<option v-for="activity in activities" :key="activity.id" :value="activity.id">
|
||||
{{ activity.name }}
|
||||
</option>
|
||||
</select>
|
||||
<input class="mos-input !py-1 !px-2 !text-xs" v-model="query" placeholder="搜索邮箱" />
|
||||
<button class="mos-btn mos-btn-secondary !py-1 !px-2 !text-xs" @click="loadParticipants">搜索</button>
|
||||
</div>
|
||||
|
||||
<div v-if="selectedActivityId && participants.length" class="space-y-3">
|
||||
<div
|
||||
v-for="participant in participants"
|
||||
:key="participant.id"
|
||||
class="flex items-center justify-between rounded-xl border border-mosquito-line px-4 py-3"
|
||||
>
|
||||
<div>
|
||||
<div class="text-sm font-semibold text-mosquito-ink">{{ participant.email || '未填写邮箱' }}</div>
|
||||
<div class="mos-muted text-xs">
|
||||
邀请人ID: {{ participant.inviterUserId }} |
|
||||
被邀请人ID: {{ participant.inviteeUserId }} |
|
||||
状态: {{ participant.status }} |
|
||||
邀请时间: {{ formatDate(participant.invitedAt) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="selectedActivityId && !loading" class="py-8 text-center text-mosquito-ink/60">
|
||||
暂无参与者数据
|
||||
</div>
|
||||
|
||||
<!-- 分页 -->
|
||||
<div v-if="totalPages > 1" class="flex items-center justify-center gap-2">
|
||||
<button
|
||||
class="mos-btn mos-btn-secondary !py-1 !px-2 !text-xs"
|
||||
:disabled="currentPage === 0"
|
||||
@click="prevPage"
|
||||
>
|
||||
上一页
|
||||
</button>
|
||||
<span class="text-xs text-mosquito-ink/70">
|
||||
{{ currentPage + 1 }} / {{ totalPages }}
|
||||
</span>
|
||||
<button
|
||||
class="mos-btn mos-btn-secondary !py-1 !px-2 !text-xs"
|
||||
:disabled="currentPage >= totalPages - 1"
|
||||
@click="nextPage"
|
||||
>
|
||||
下一页
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { activityService } from '../services/activity'
|
||||
|
||||
const route = useRoute()
|
||||
const activities = ref<Array<{ id: number; name: string }>>([])
|
||||
const selectedActivityId = ref<number>(0)
|
||||
const currentActivityName = ref<string>('')
|
||||
const participants = ref<any[]>([])
|
||||
const query = ref('')
|
||||
const loading = ref(false)
|
||||
const currentPage = ref(0)
|
||||
const totalElements = ref(0)
|
||||
const totalPages = ref(0)
|
||||
const pageSize = ref(20)
|
||||
|
||||
const formatDate = (dateStr: string | null) => {
|
||||
if (!dateStr) return '-'
|
||||
return new Date(dateStr).toLocaleString('zh-CN')
|
||||
}
|
||||
|
||||
const loadActivities = async () => {
|
||||
try {
|
||||
const list = await activityService.getActivities({ page: 0, size: 100 })
|
||||
activities.value = list.map((a: any) => ({ id: a.id, name: a.name }))
|
||||
// 如果有路由参数中的activityId,自动选中
|
||||
const activityId = route.query.activityId
|
||||
if (activityId) {
|
||||
selectedActivityId.value = Number(activityId)
|
||||
loadParticipants()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载活动列表失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const loadParticipants = async () => {
|
||||
if (!selectedActivityId.value) return
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
const activity = activities.value.find(a => a.id === selectedActivityId.value)
|
||||
currentActivityName.value = activity?.name || ''
|
||||
|
||||
const result = await activityService.getParticipants(selectedActivityId.value, currentPage.value, pageSize.value, query.value)
|
||||
participants.value = result.content || []
|
||||
totalElements.value = result.totalElements || 0
|
||||
totalPages.value = result.totalPages || 0
|
||||
} catch (error) {
|
||||
console.error('加载参与者失败:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const prevPage = () => {
|
||||
if (currentPage.value > 0) {
|
||||
currentPage.value--
|
||||
loadParticipants()
|
||||
}
|
||||
}
|
||||
|
||||
const nextPage = () => {
|
||||
if (currentPage.value < totalPages.value - 1) {
|
||||
currentPage.value++
|
||||
loadParticipants()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadActivities()
|
||||
})
|
||||
</script>
|
||||
@@ -5,8 +5,22 @@
|
||||
<p class="mos-muted text-sm">处理角色变更与邀请审批。</p>
|
||||
</header>
|
||||
|
||||
<ListSection :page="requestPage" :total-pages="requestTotalPages" @prev="requestPage--" @next="requestPage++">
|
||||
<template #title>角色变更申请</template>
|
||||
<!-- Tab切换 -->
|
||||
<div class="flex gap-2 border-b border-mosquito-line pb-2">
|
||||
<button
|
||||
v-for="tab in tabs"
|
||||
:key="tab.key"
|
||||
class="px-4 py-2 text-sm font-medium transition-colors"
|
||||
:class="activeTab === tab.key ? 'text-mosquito-brand border-b-2 border-mosquito-brand' : 'text-mosquito-ink/70 hover:text-mosquito-ink'"
|
||||
@click="switchTab(tab.key)"
|
||||
>
|
||||
{{ tab.label }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 待审批 -->
|
||||
<ListSection v-if="activeTab === 'pending'" :page="requestPage" :total-pages="requestTotalPages" @prev="requestPage--" @next="requestPage++">
|
||||
<template #title>待审批申请</template>
|
||||
<template #filters>
|
||||
<input class="mos-input !py-1 !px-2 !text-xs w-44" v-model="requestQuery" placeholder="搜索用户" />
|
||||
<input class="mos-input !py-1 !px-2 !text-xs" type="date" v-model="requestStart" />
|
||||
@@ -17,8 +31,12 @@
|
||||
<button class="mos-btn mos-btn-secondary !py-1 !px-2 !text-xs" @click="selectAllRequests">
|
||||
{{ allRequestsSelected ? '取消全选' : '全选' }}
|
||||
</button>
|
||||
<button class="mos-btn mos-btn-secondary !py-1 !px-2 !text-xs" @click="batchApprove">批量通过</button>
|
||||
<button class="mos-btn mos-btn-secondary !py-1 !px-2 !text-xs" @click="batchReject">批量拒绝</button>
|
||||
<PermissionButton permission="approval.index.batch.handle.ALL" variant="secondary" :hide-when-no-permission="true" @click="batchApprove">
|
||||
批量通过
|
||||
</PermissionButton>
|
||||
<PermissionButton permission="approval.index.batch.handle.ALL" variant="secondary" :hide-when-no-permission="true" @click="batchReject">
|
||||
批量拒绝
|
||||
</PermissionButton>
|
||||
</template>
|
||||
<template #default>
|
||||
<div v-if="pagedRequests.length" class="space-y-3">
|
||||
@@ -33,9 +51,18 @@
|
||||
@change.stop="toggleRequestSelect(request.id)"
|
||||
/>
|
||||
<div>
|
||||
<div class="text-sm font-semibold text-mosquito-ink">{{ getUserName(request.userId) }}</div>
|
||||
<div class="mos-muted text-xs">从 {{ roleLabel(request.currentRole) }} 变更为 {{ roleLabel(request.targetRole) }}</div>
|
||||
<div class="mos-muted text-xs">原因:{{ request.reason }}</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm font-semibold text-mosquito-ink">{{ request.title || '审批申请' }}</span>
|
||||
<span
|
||||
class="rounded-full px-2 py-0.5 text-[10px] font-semibold"
|
||||
:class="getBizTypeClass(request.bizType || 'ROLE_CHANGE')"
|
||||
>
|
||||
{{ getBizTypeLabel(request.bizType || 'ROLE_CHANGE') }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="mos-muted text-xs">{{ request.description }}</div>
|
||||
<div class="mos-muted text-xs">申请人:{{ getUserName(request.userId) }}</div>
|
||||
<div v-if="request.reason" class="mos-muted text-xs">原因:{{ request.reason }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
@@ -45,8 +72,18 @@
|
||||
>
|
||||
{{ getSlaBadge(request.requestedAt).label }}
|
||||
</span>
|
||||
<button class="mos-btn mos-btn-secondary !py-1 !px-2 !text-xs" @click="setRejecting(request.id)">拒绝</button>
|
||||
<button class="mos-btn mos-btn-accent !py-1 !px-2 !text-xs" @click="approve(request)">通过</button>
|
||||
<PermissionButton permission="approval.execute.reject.ALL" variant="secondary" :hide-when-no-permission="true" @click="setRejecting(request.id)">
|
||||
拒绝
|
||||
</PermissionButton>
|
||||
<PermissionButton permission="approval.execute.transfer.ALL" variant="secondary" :hide-when-no-permission="true" @click="showTransfer(request.id)">
|
||||
转交
|
||||
</PermissionButton>
|
||||
<PermissionButton permission="approval.index.delegate.ALL" variant="secondary" :hide-when-no-permission="true" @click="showDelegate(request.id)">
|
||||
委托
|
||||
</PermissionButton>
|
||||
<PermissionButton permission="approval.execute.approve.ALL" variant="primary" :hide-when-no-permission="true" @click="approve(request)">
|
||||
通过
|
||||
</PermissionButton>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="rejectingId === request.id" class="mt-3 flex flex-wrap items-center gap-2">
|
||||
@@ -73,8 +110,12 @@
|
||||
<button class="mos-btn mos-btn-secondary !py-1 !px-2 !text-xs" @click="selectAllInvites">
|
||||
{{ allInvitesSelected ? '取消全选' : '全选' }}
|
||||
</button>
|
||||
<button class="mos-btn mos-btn-secondary !py-1 !px-2 !text-xs" @click="batchAcceptInvites">批量通过</button>
|
||||
<button class="mos-btn mos-btn-secondary !py-1 !px-2 !text-xs" @click="batchRejectInvites">批量拒绝</button>
|
||||
<PermissionButton permission="approval.index.batch.handle.ALL" variant="secondary" :hide-when-no-permission="true" @click="batchAcceptInvites">
|
||||
批量通过
|
||||
</PermissionButton>
|
||||
<PermissionButton permission="approval.index.batch.handle.ALL" variant="secondary" :hide-when-no-permission="true" @click="batchRejectInvites">
|
||||
批量拒绝
|
||||
</PermissionButton>
|
||||
</template>
|
||||
<template #default>
|
||||
<div v-if="pagedInvites.length" class="space-y-3">
|
||||
@@ -93,8 +134,12 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button class="mos-btn mos-btn-secondary !py-1 !px-2 !text-xs" @click="rejectInvite(invite)">拒绝</button>
|
||||
<button class="mos-btn mos-btn-accent !py-1 !px-2 !text-xs" @click="acceptInvite(invite)">通过</button>
|
||||
<PermissionButton permission="approval.execute.reject.ALL" variant="secondary" :hide-when-no-permission="true" @click="rejectInvite(invite)">
|
||||
拒绝
|
||||
</PermissionButton>
|
||||
<PermissionButton permission="approval.execute.approve.ALL" variant="primary" :hide-when-no-permission="true" @click="acceptInvite(invite)">
|
||||
通过
|
||||
</PermissionButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -103,6 +148,119 @@
|
||||
<div v-if="!pagedInvites.length" class="mt-4 text-sm text-mosquito-ink/60">暂无待审批邀请</div>
|
||||
</template>
|
||||
</ListSection>
|
||||
|
||||
<!-- 已审批 -->
|
||||
<ListSection v-if="activeTab === 'processed'" :page="processedPage" :total-pages="processedTotalPages" @prev="processedPage--" @next="processedPage++">
|
||||
<template #title>已审批记录</template>
|
||||
<template #filters>
|
||||
<input class="mos-input !py-1 !px-2 !text-xs w-44" v-model="processedQuery" placeholder="搜索用户" />
|
||||
</template>
|
||||
<template #default>
|
||||
<div v-if="pagedProcessed.length" class="space-y-3">
|
||||
<div v-for="record in pagedProcessed" :key="record.id" class="rounded-xl border border-mosquito-line px-4 py-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<div class="text-sm font-semibold text-mosquito-ink">{{ getUserName(record.userId) }}</div>
|
||||
<div class="mos-muted text-xs">审批结果:{{ record.result === 'APPROVE' ? '通过' : '拒绝' }}</div>
|
||||
<div class="mos-muted text-xs">审批意见:{{ record.comment || '无' }}</div>
|
||||
<div class="mos-muted text-xs">审批时间:{{ record.approvedAt }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #empty>
|
||||
<div v-if="!pagedProcessed.length" class="mt-4 text-sm text-mosquito-ink/60">暂无已审批记录</div>
|
||||
</template>
|
||||
</ListSection>
|
||||
|
||||
<!-- 我提交的 -->
|
||||
<ListSection v-if="activeTab === 'my'" :page="myPage" :total-pages="myTotalPages" @prev="myPage--" @next="myPage++">
|
||||
<template #title>我提交的审批</template>
|
||||
<template #filters>
|
||||
<input class="mos-input !py-1 !px-2 !text-xs w-44" v-model="myQuery" placeholder="搜索" />
|
||||
</template>
|
||||
<template #default>
|
||||
<div v-if="pagedMy.length" class="space-y-3">
|
||||
<div v-for="record in pagedMy" :key="record.id" class="rounded-xl border border-mosquito-line px-4 py-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<div class="text-sm font-semibold text-mosquito-ink">{{ record.type }}</div>
|
||||
<div class="mos-muted text-xs">状态:{{ record.status }}</div>
|
||||
<div class="mos-muted text-xs">提交时间:{{ record.submittedAt }}</div>
|
||||
</div>
|
||||
<span
|
||||
class="rounded-full px-2 py-1 text-[10px] font-semibold"
|
||||
:class="record.status === '已通过' ? 'bg-green-100 text-green-600' : record.status === '已拒绝' ? 'bg-red-100 text-red-600' : 'bg-yellow-100 text-yellow-600'"
|
||||
>
|
||||
{{ record.status }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #empty>
|
||||
<div v-if="!pagedMy.length" class="mt-4 text-sm text-mosquito-ink/60">暂无我提交的记录</div>
|
||||
</template>
|
||||
</ListSection>
|
||||
|
||||
<!-- 转交弹窗 -->
|
||||
<div v-if="showTransferModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||
<div class="w-96 rounded-xl bg-white p-6 shadow-xl">
|
||||
<h3 class="mb-4 text-lg font-semibold">转交审批</h3>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="mb-1 block text-sm text-gray-600">目标用户ID</label>
|
||||
<input
|
||||
class="mos-input w-full"
|
||||
v-model="transferTargetId"
|
||||
placeholder="请输入目标用户ID"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-sm text-gray-600">转交原因(可选)</label>
|
||||
<input
|
||||
class="mos-input w-full"
|
||||
v-model="transferComment"
|
||||
placeholder="请输入转交原因"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-6 flex justify-end gap-3">
|
||||
<button class="mos-btn mos-btn-secondary" @click="showTransferModal = false">取消</button>
|
||||
<button class="mos-btn mos-btn-primary" @click="confirmTransfer">确认转交</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 委托弹窗 -->
|
||||
<div v-if="showDelegateModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||
<div class="w-96 rounded-xl bg-white p-6 shadow-xl">
|
||||
<h3 class="mb-4 text-lg font-semibold">委托审批</h3>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="mb-1 block text-sm text-gray-600">目标用户ID</label>
|
||||
<input
|
||||
class="mos-input w-full"
|
||||
v-model="delegateTargetId"
|
||||
placeholder="请输入目标用户ID"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-sm text-gray-600">委托原因(可选)</label>
|
||||
<input
|
||||
class="mos-input w-full"
|
||||
v-model="delegateReason"
|
||||
placeholder="请输入委托原因"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-6 flex justify-end gap-3">
|
||||
<button class="mos-btn mos-btn-secondary" @click="showDelegateModal = false">取消</button>
|
||||
<button class="mos-btn mos-btn-primary" @click="confirmDelegate">确认委托</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -111,12 +269,53 @@ import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useUserStore, type RoleChangeRequest, type InviteRequest } from '../stores/users'
|
||||
import { useDataService } from '../services'
|
||||
import { useAuditStore } from '../stores/audit'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import ListSection from '../components/ListSection.vue'
|
||||
import PermissionButton from '../components/PermissionButton.vue'
|
||||
import { getSlaBadge, normalizeRejectReason } from '../utils/approval'
|
||||
import { RoleLabels, type AdminRole } from '../auth/roles'
|
||||
|
||||
// 审批状态映射(与 ApiDataService.ts 保持一致)
|
||||
const mapApprovalStatus = (status: string): '待审批' | '已通过' | '已拒绝' => {
|
||||
switch (status) {
|
||||
case 'PENDING':
|
||||
case 'PROCESSING':
|
||||
return '待审批'
|
||||
case 'APPROVED':
|
||||
case 'COMPLETED':
|
||||
return '已通过'
|
||||
case 'REJECTED':
|
||||
case 'CANCELLED':
|
||||
return '已拒绝'
|
||||
default:
|
||||
return '待审批'
|
||||
}
|
||||
}
|
||||
|
||||
// Tab配置
|
||||
const tabs = [
|
||||
{ key: 'pending', label: '待审批' },
|
||||
{ key: 'processed', label: '已审批' },
|
||||
{ key: 'my', label: '我提交' }
|
||||
]
|
||||
const activeTab = ref('pending')
|
||||
|
||||
// 转交弹窗
|
||||
const showTransferModal = ref(false)
|
||||
const transferTargetId = ref('')
|
||||
const transferComment = ref('')
|
||||
const transferringRequestId = ref<string | null>(null)
|
||||
|
||||
// 委托弹窗
|
||||
const showDelegateModal = ref(false)
|
||||
const delegateTargetId = ref('')
|
||||
const delegateReason = ref('')
|
||||
const delegatingRequestId = ref<string | null>(null)
|
||||
|
||||
const store = useUserStore()
|
||||
const service = useDataService()
|
||||
const auditStore = useAuditStore()
|
||||
const authStore = useAuthStore()
|
||||
const rejectingId = ref<string | null>(null)
|
||||
const rejectReason = ref('')
|
||||
const batchRejectReason = ref('')
|
||||
@@ -128,6 +327,219 @@ const inviteStart = ref('')
|
||||
const inviteEnd = ref('')
|
||||
const requestPage = ref(0)
|
||||
const invitePage = ref(0)
|
||||
|
||||
// 已审批相关
|
||||
const processedPage = ref(0)
|
||||
const processedQuery = ref('')
|
||||
const processedRecords = ref<any[]>([])
|
||||
const processedTotal = ref(0)
|
||||
|
||||
// 我提交的相关
|
||||
const myPage = ref(0)
|
||||
const myQuery = ref('')
|
||||
const myRecords = ref<any[]>([])
|
||||
const myTotal = ref(0)
|
||||
|
||||
const switchTab = async (tab: string) => {
|
||||
activeTab.value = tab
|
||||
if (tab === 'processed') {
|
||||
// 加载已审批记录
|
||||
try {
|
||||
if (authStore.mode === 'real') {
|
||||
const result = await service.getProcessedApprovals({ page: processedPage.value, size: 6, keyword: processedQuery.value })
|
||||
processedRecords.value = (result.items || []).map((record: any) => ({
|
||||
id: String(record.id),
|
||||
userId: String(record.applicantId || ''),
|
||||
type: record.flowName || record.type || '角色变更',
|
||||
result: record.status === 'APPROVED' ? 'APPROVE' : 'REJECT',
|
||||
comment: record.comment || '',
|
||||
approvedAt: record.updatedAt || record.approvedAt || ''
|
||||
}))
|
||||
// 使用后端返回的总数
|
||||
processedTotal.value = typeof result.total === 'number' ? result.total
|
||||
: processedRecords.value.length
|
||||
} else {
|
||||
// 演示模式
|
||||
processedRecords.value = []
|
||||
processedTotal.value = 0
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load processed records:', e)
|
||||
service.addNotification({
|
||||
title: '加载失败',
|
||||
content: '获取已审批记录失败'
|
||||
})
|
||||
}
|
||||
} else if (tab === 'my') {
|
||||
// 加载我提交的记录
|
||||
try {
|
||||
if (authStore.mode === 'real') {
|
||||
const result = await service.getMyApprovals({ page: myPage.value, size: 6, keyword: myQuery.value })
|
||||
myRecords.value = (result.items || []).map((record: any) => ({
|
||||
id: String(record.id),
|
||||
type: record.flowName || record.type || '角色变更',
|
||||
status: mapApprovalStatus(record.status),
|
||||
submittedAt: record.createdAt || ''
|
||||
}))
|
||||
// 使用后端返回的总数
|
||||
myTotal.value = typeof result.total === 'number' ? result.total
|
||||
: myRecords.value.length
|
||||
} else {
|
||||
// 演示模式
|
||||
myRecords.value = []
|
||||
myTotal.value = 0
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load my records:', e)
|
||||
service.addNotification({
|
||||
title: '加载失败',
|
||||
content: '获取我提交的审批失败'
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const processedTotalPages = computed(() => Math.max(1, Math.ceil(processedTotal.value / 6)))
|
||||
const myTotalPages = computed(() => Math.max(1, Math.ceil(myTotal.value / 6)))
|
||||
|
||||
// 后端已返回当前页数据,直接使用
|
||||
const pagedProcessed = computed(() => processedRecords.value)
|
||||
const pagedMy = computed(() => myRecords.value)
|
||||
|
||||
// 加载已审批记录
|
||||
const loadProcessedRecords = async () => {
|
||||
try {
|
||||
if (authStore.mode === 'real') {
|
||||
const result = await service.getProcessedApprovals({ page: processedPage.value, size: 6, keyword: processedQuery.value })
|
||||
processedRecords.value = (result.items || []).map((record: any) => ({
|
||||
id: String(record.id),
|
||||
userId: String(record.applicantId || ''),
|
||||
type: record.flowName || record.type || '角色变更',
|
||||
result: record.status === 'APPROVED' ? 'APPROVE' : 'REJECT',
|
||||
comment: record.comment || '',
|
||||
approvedAt: record.updatedAt || record.approvedAt || ''
|
||||
}))
|
||||
processedTotal.value = typeof result.total === 'number' ? result.total : processedRecords.value.length
|
||||
} else {
|
||||
processedRecords.value = []
|
||||
processedTotal.value = 0
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load processed records:', e)
|
||||
}
|
||||
}
|
||||
|
||||
// 加载我提交的记录
|
||||
const loadMyRecords = async () => {
|
||||
try {
|
||||
if (authStore.mode === 'real') {
|
||||
const result = await service.getMyApprovals({ page: myPage.value, size: 6, keyword: myQuery.value })
|
||||
myRecords.value = (result.items || []).map((record: any) => ({
|
||||
id: String(record.id),
|
||||
type: record.flowName || record.type || '角色变更',
|
||||
status: mapApprovalStatus(record.status),
|
||||
submittedAt: record.createdAt || ''
|
||||
}))
|
||||
myTotal.value = typeof result.total === 'number' ? result.total : myRecords.value.length
|
||||
} else {
|
||||
myRecords.value = []
|
||||
myTotal.value = 0
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load my records:', e)
|
||||
}
|
||||
}
|
||||
|
||||
// 监听已审批页码变化
|
||||
watch(processedPage, () => {
|
||||
if (activeTab.value === 'processed') {
|
||||
loadProcessedRecords()
|
||||
}
|
||||
})
|
||||
|
||||
// 监听已审批筛选变化
|
||||
watch(processedQuery, () => {
|
||||
if (processedPage.value !== 0) {
|
||||
processedPage.value = 0
|
||||
} else {
|
||||
loadProcessedRecords()
|
||||
}
|
||||
})
|
||||
|
||||
// 监听我提交页码变化
|
||||
watch(myPage, () => {
|
||||
if (activeTab.value === 'my') {
|
||||
loadMyRecords()
|
||||
}
|
||||
})
|
||||
|
||||
// 监听我提交筛选变化
|
||||
watch(myQuery, () => {
|
||||
if (myPage.value !== 0) {
|
||||
myPage.value = 0
|
||||
} else {
|
||||
loadMyRecords()
|
||||
}
|
||||
})
|
||||
|
||||
// 转交功能
|
||||
const showTransfer = (requestId: string) => {
|
||||
transferringRequestId.value = requestId
|
||||
transferTargetId.value = ''
|
||||
showTransferModal.value = true
|
||||
}
|
||||
|
||||
const confirmTransfer = async () => {
|
||||
if (!transferringRequestId.value || !transferTargetId.value) return
|
||||
|
||||
try {
|
||||
// 调用后端转交API
|
||||
await service.transferApproval(transferringRequestId.value, transferTargetId.value, transferComment.value)
|
||||
auditStore.addLog('转交审批', `转交给用户: ${transferTargetId.value}`)
|
||||
service.addNotification({
|
||||
title: '转交成功',
|
||||
content: '审批已转交'
|
||||
})
|
||||
// 关闭弹窗
|
||||
showTransferModal.value = false
|
||||
} catch (error) {
|
||||
service.addNotification({
|
||||
title: '转交失败',
|
||||
content: error instanceof Error ? error.message : '转交失败'
|
||||
})
|
||||
}
|
||||
showTransferModal.value = false
|
||||
}
|
||||
|
||||
// 委托功能
|
||||
const showDelegate = (requestId: string) => {
|
||||
delegatingRequestId.value = requestId
|
||||
delegateTargetId.value = ''
|
||||
showDelegateModal.value = true
|
||||
}
|
||||
|
||||
const confirmDelegate = async () => {
|
||||
if (!delegatingRequestId.value || !delegateTargetId.value) return
|
||||
|
||||
try {
|
||||
// 调用后端委托API
|
||||
await service.delegateApproval(delegatingRequestId.value, delegateTargetId.value, delegateReason.value)
|
||||
auditStore.addLog('委托审批', `委托给用户: ${delegateTargetId.value}`)
|
||||
service.addNotification({
|
||||
title: '委托成功',
|
||||
content: '审批已委托'
|
||||
})
|
||||
// 关闭弹窗
|
||||
showDelegateModal.value = false
|
||||
} catch (error) {
|
||||
service.addNotification({
|
||||
title: '委托失败',
|
||||
content: error instanceof Error ? error.message : '委托失败'
|
||||
})
|
||||
}
|
||||
showDelegateModal.value = false
|
||||
}
|
||||
|
||||
const pageSize = 6
|
||||
const selectedRequestIds = ref<string[]>([])
|
||||
const selectedInviteIds = ref<string[]>([])
|
||||
@@ -145,26 +557,79 @@ const pendingRequests = computed(() => store.pendingRoleRequests)
|
||||
const pendingInvites = computed(() => store.invites.filter((item) => item.status === '待接受'))
|
||||
|
||||
const roleLabel = (role: string) => {
|
||||
if (role === 'admin') return '管理员'
|
||||
if (role === 'operator') return '运营'
|
||||
return '只读'
|
||||
// 使用15角色体系的显示名称
|
||||
return RoleLabels[role as AdminRole] || role
|
||||
}
|
||||
|
||||
const getUserName = (id: string) => store.byId(id)?.name ?? id
|
||||
|
||||
// 根据业务类型获取标签文字
|
||||
const getBizTypeLabel = (bizType: string) => {
|
||||
const labels: Record<string, string> = {
|
||||
ROLE_CHANGE: '角色变更',
|
||||
SENSITIVE_EXPORT: '敏感导出',
|
||||
USER_FREEZE: '用户冻结',
|
||||
USER_UNFREEZE: '用户解冻',
|
||||
SYSTEM_CONFIG: '系统配置',
|
||||
ACTIVITY_CREATE: '活动创建',
|
||||
ACTIVITY_UPDATE: '活动更新',
|
||||
ACTIVITY_DELETE: '活动删除',
|
||||
REWARD_GRANT: '奖励发放'
|
||||
}
|
||||
return labels[bizType] || bizType
|
||||
}
|
||||
|
||||
// 根据业务类型获取样式类
|
||||
const getBizTypeClass = (bizType: string) => {
|
||||
const classes: Record<string, string> = {
|
||||
ROLE_CHANGE: 'bg-blue-100 text-blue-600',
|
||||
SENSITIVE_EXPORT: 'bg-purple-100 text-purple-600',
|
||||
USER_FREEZE: 'bg-red-100 text-red-600',
|
||||
USER_UNFREEZE: 'bg-green-100 text-green-600',
|
||||
SYSTEM_CONFIG: 'bg-yellow-100 text-yellow-600',
|
||||
ACTIVITY_CREATE: 'bg-emerald-100 text-emerald-600',
|
||||
ACTIVITY_UPDATE: 'bg-orange-100 text-orange-600',
|
||||
ACTIVITY_DELETE: 'bg-rose-100 text-rose-600',
|
||||
REWARD_GRANT: 'bg-cyan-100 text-cyan-600'
|
||||
}
|
||||
return classes[bizType] || 'bg-gray-100 text-gray-600'
|
||||
}
|
||||
|
||||
const slaClass = (level: ReturnType<typeof getSlaBadge>['level']) => {
|
||||
if (level === 'danger') return 'bg-rose-100 text-rose-600'
|
||||
if (level === 'warning') return 'bg-amber-100 text-amber-600'
|
||||
return 'bg-mosquito-accent/10 text-mosquito-brand'
|
||||
}
|
||||
|
||||
const approve = (request: RoleChangeRequest) => {
|
||||
store.approveRoleChange(request.id, '演示管理员')
|
||||
auditStore.addLog('审批通过角色变更', getUserName(request.userId))
|
||||
service.addNotification({
|
||||
title: '角色变更审批通过',
|
||||
detail: `${getUserName(request.userId)} 角色变更已通过`
|
||||
})
|
||||
const approve = async (request: RoleChangeRequest) => {
|
||||
// 真实模式下调用后端API,演示模式下使用本地store
|
||||
if (authStore.mode === 'real') {
|
||||
try {
|
||||
await service.handleApproval(request.id, 'APPROVE', '审批通过')
|
||||
// 刷新数据
|
||||
const requests = await service.getRoleRequests()
|
||||
if (requests) {
|
||||
store.setRoleRequests(requests)
|
||||
}
|
||||
auditStore.addLog('审批通过角色变更', getUserName(request.userId))
|
||||
service.addNotification({
|
||||
title: '角色变更审批通过',
|
||||
content: `${getUserName(request.userId)} 角色变更已通过`
|
||||
})
|
||||
} catch (error) {
|
||||
service.addNotification({
|
||||
title: '审批失败',
|
||||
content: error instanceof Error ? error.message : '审批处理失败'
|
||||
})
|
||||
}
|
||||
} else {
|
||||
store.approveRoleChange(request.id, '演示管理员')
|
||||
auditStore.addLog('审批通过角色变更', getUserName(request.userId))
|
||||
service.addNotification({
|
||||
title: '角色变更审批通过',
|
||||
content: `${getUserName(request.userId)} 角色变更已通过`
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const setRejecting = (id: string) => {
|
||||
@@ -177,33 +642,93 @@ const cancelReject = () => {
|
||||
rejectReason.value = ''
|
||||
}
|
||||
|
||||
const confirmReject = (request: RoleChangeRequest) => {
|
||||
const confirmReject = async (request: RoleChangeRequest) => {
|
||||
const reason = normalizeRejectReason(rejectReason.value, '未填写原因')
|
||||
store.rejectRoleChange(request.id, '演示管理员', reason)
|
||||
auditStore.addLog('审批拒绝角色变更', `${getUserName(request.userId)}:${reason}`)
|
||||
service.addNotification({
|
||||
title: '角色变更审批拒绝',
|
||||
detail: `${getUserName(request.userId)}:${reason}`
|
||||
})
|
||||
if (authStore.mode === 'real') {
|
||||
try {
|
||||
await service.handleApproval(request.id, 'REJECT', reason)
|
||||
const requests = await service.getRoleRequests()
|
||||
if (requests) {
|
||||
store.setRoleRequests(requests)
|
||||
}
|
||||
auditStore.addLog('审批拒绝角色变更', `${getUserName(request.userId)}:${reason}`)
|
||||
service.addNotification({
|
||||
title: '角色变更审批拒绝',
|
||||
content: `${getUserName(request.userId)}:${reason}`
|
||||
})
|
||||
} catch (error) {
|
||||
service.addNotification({
|
||||
title: '审批失败',
|
||||
content: error instanceof Error ? error.message : '审批处理失败'
|
||||
})
|
||||
}
|
||||
} else {
|
||||
store.rejectRoleChange(request.id, '演示管理员', reason)
|
||||
auditStore.addLog('审批拒绝角色变更', `${getUserName(request.userId)}:${reason}`)
|
||||
service.addNotification({
|
||||
title: '角色变更审批拒绝',
|
||||
content: `${getUserName(request.userId)}:${reason}`
|
||||
})
|
||||
}
|
||||
cancelReject()
|
||||
}
|
||||
|
||||
const acceptInvite = (invite: InviteRequest) => {
|
||||
store.acceptInvite(invite.id)
|
||||
auditStore.addLog('审批通过邀请', invite.email)
|
||||
service.addNotification({
|
||||
title: '邀请审批通过',
|
||||
detail: `${invite.email} 已通过`
|
||||
})
|
||||
const acceptInvite = async (invite: InviteRequest) => {
|
||||
if (authStore.mode === 'real') {
|
||||
try {
|
||||
await service.handleApproval(invite.id, 'APPROVE', '邀请通过')
|
||||
const invites = await service.getInvites()
|
||||
if (invites) {
|
||||
store.setInvites(invites)
|
||||
}
|
||||
auditStore.addLog('审批通过邀请', invite.email)
|
||||
service.addNotification({
|
||||
title: '邀请审批通过',
|
||||
content: `${invite.email} 已通过`
|
||||
})
|
||||
} catch (error) {
|
||||
service.addNotification({
|
||||
title: '审批失败',
|
||||
content: error instanceof Error ? error.message : '审批处理失败'
|
||||
})
|
||||
}
|
||||
} else {
|
||||
store.acceptInvite(invite.id)
|
||||
auditStore.addLog('审批通过邀请', invite.email)
|
||||
service.addNotification({
|
||||
title: '邀请审批通过',
|
||||
content: `${invite.email} 已通过`
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const rejectInvite = (invite: InviteRequest) => {
|
||||
invite.status = '已拒绝'
|
||||
auditStore.addLog('审批拒绝邀请', invite.email)
|
||||
service.addNotification({
|
||||
title: '邀请审批拒绝',
|
||||
detail: `${invite.email} 已拒绝`
|
||||
})
|
||||
const rejectInvite = async (invite: InviteRequest) => {
|
||||
if (authStore.mode === 'real') {
|
||||
try {
|
||||
await service.handleApproval(invite.id, 'REJECT', '邀请拒绝')
|
||||
const invites = await service.getInvites()
|
||||
if (invites) {
|
||||
store.setInvites(invites)
|
||||
}
|
||||
auditStore.addLog('审批拒绝邀请', invite.email)
|
||||
service.addNotification({
|
||||
title: '邀请审批拒绝',
|
||||
content: `${invite.email} 已拒绝`
|
||||
})
|
||||
} catch (error) {
|
||||
service.addNotification({
|
||||
title: '审批失败',
|
||||
content: error instanceof Error ? error.message : '审批处理失败'
|
||||
})
|
||||
}
|
||||
} else {
|
||||
invite.status = '已拒绝'
|
||||
auditStore.addLog('审批拒绝邀请', invite.email)
|
||||
service.addNotification({
|
||||
title: '邀请审批拒绝',
|
||||
content: `${invite.email} 已拒绝`
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const filteredRequests = computed(() => {
|
||||
@@ -285,39 +810,140 @@ const selectAllInvites = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const batchApprove = () => {
|
||||
filteredRequests.value
|
||||
const batchApprove = async () => {
|
||||
const idsToApprove = filteredRequests.value
|
||||
.filter((req) => selectedRequestIds.value.includes(req.id))
|
||||
.forEach(approve)
|
||||
.map((req) => req.id)
|
||||
if (idsToApprove.length === 0) return
|
||||
|
||||
if (authStore.mode === 'real') {
|
||||
try {
|
||||
await service.batchHandleApproval(idsToApprove, 'APPROVE', '批量审批通过')
|
||||
const requests = await service.getRoleRequests()
|
||||
if (requests) {
|
||||
store.setRoleRequests(requests)
|
||||
}
|
||||
auditStore.addLog('批量审批通过', `${idsToApprove.length} 条角色变更申请`)
|
||||
service.addNotification({
|
||||
title: '批量审批通过',
|
||||
content: `已通过 ${idsToApprove.length} 条申请`
|
||||
})
|
||||
} catch (error) {
|
||||
service.addNotification({
|
||||
title: '批量审批失败',
|
||||
content: error instanceof Error ? error.message : '批量审批处理失败'
|
||||
})
|
||||
}
|
||||
} else {
|
||||
filteredRequests.value
|
||||
.filter((req) => selectedRequestIds.value.includes(req.id))
|
||||
.forEach(approve)
|
||||
}
|
||||
selectedRequestIds.value = []
|
||||
}
|
||||
|
||||
const batchReject = () => {
|
||||
const batchReject = async () => {
|
||||
const reason = normalizeRejectReason(batchRejectReason.value)
|
||||
filteredRequests.value
|
||||
const idsToReject = filteredRequests.value
|
||||
.filter((req) => selectedRequestIds.value.includes(req.id))
|
||||
.forEach((req) => {
|
||||
store.rejectRoleChange(req.id, '演示管理员', reason)
|
||||
auditStore.addLog('审批拒绝角色变更', `${getUserName(req.userId)}:${reason}`)
|
||||
.map((req) => req.id)
|
||||
if (idsToReject.length === 0) return
|
||||
|
||||
if (authStore.mode === 'real') {
|
||||
try {
|
||||
await service.batchHandleApproval(idsToReject, 'REJECT', reason)
|
||||
const requests = await service.getRoleRequests()
|
||||
if (requests) {
|
||||
store.setRoleRequests(requests)
|
||||
}
|
||||
auditStore.addLog('批量审批拒绝', `${idsToReject.length} 条角色变更申请:${reason}`)
|
||||
service.addNotification({
|
||||
title: '角色变更审批拒绝',
|
||||
detail: `${getUserName(req.userId)}:${reason}`
|
||||
title: '批量审批拒绝',
|
||||
content: `已拒绝 ${idsToReject.length} 条申请`
|
||||
})
|
||||
})
|
||||
} catch (error) {
|
||||
service.addNotification({
|
||||
title: '批量审批失败',
|
||||
content: error instanceof Error ? error.message : '批量审批处理失败'
|
||||
})
|
||||
}
|
||||
} else {
|
||||
filteredRequests.value
|
||||
.filter((req) => selectedRequestIds.value.includes(req.id))
|
||||
.forEach((req) => {
|
||||
store.rejectRoleChange(req.id, '演示管理员', reason)
|
||||
auditStore.addLog('审批拒绝角色变更', `${getUserName(req.userId)}:${reason}`)
|
||||
service.addNotification({
|
||||
title: '角色变更审批拒绝',
|
||||
content: `${getUserName(req.userId)}:${reason}`
|
||||
})
|
||||
})
|
||||
}
|
||||
selectedRequestIds.value = []
|
||||
batchRejectReason.value = ''
|
||||
}
|
||||
|
||||
const batchAcceptInvites = () => {
|
||||
filteredInvites.value
|
||||
const batchAcceptInvites = async () => {
|
||||
const idsToAccept = filteredInvites.value
|
||||
.filter((inv) => selectedInviteIds.value.includes(inv.id))
|
||||
.forEach(acceptInvite)
|
||||
.map((inv) => inv.id)
|
||||
if (idsToAccept.length === 0) return
|
||||
|
||||
if (authStore.mode === 'real') {
|
||||
try {
|
||||
await service.batchHandleApproval(idsToAccept, 'APPROVE', '批量邀请通过')
|
||||
const invites = await service.getInvites()
|
||||
if (invites) {
|
||||
store.setInvites(invites)
|
||||
}
|
||||
auditStore.addLog('批量审批通过', `${idsToAccept.length} 条邀请申请`)
|
||||
service.addNotification({
|
||||
title: '批量邀请审批通过',
|
||||
content: `已通过 ${idsToAccept.length} 条邀请`
|
||||
})
|
||||
} catch (error) {
|
||||
service.addNotification({
|
||||
title: '批量审批失败',
|
||||
content: error instanceof Error ? error.message : '批量审批处理失败'
|
||||
})
|
||||
}
|
||||
} else {
|
||||
filteredInvites.value
|
||||
.filter((inv) => selectedInviteIds.value.includes(inv.id))
|
||||
.forEach(acceptInvite)
|
||||
}
|
||||
selectedInviteIds.value = []
|
||||
}
|
||||
|
||||
const batchRejectInvites = () => {
|
||||
filteredInvites.value
|
||||
const batchRejectInvites = async () => {
|
||||
const idsToReject = filteredInvites.value
|
||||
.filter((inv) => selectedInviteIds.value.includes(inv.id))
|
||||
.forEach(rejectInvite)
|
||||
.map((inv) => inv.id)
|
||||
if (idsToReject.length === 0) return
|
||||
|
||||
if (authStore.mode === 'real') {
|
||||
try {
|
||||
await service.batchHandleApproval(idsToReject, 'REJECT', '批量邀请拒绝')
|
||||
const invites = await service.getInvites()
|
||||
if (invites) {
|
||||
store.setInvites(invites)
|
||||
}
|
||||
auditStore.addLog('批量审批拒绝', `${idsToReject.length} 条邀请申请`)
|
||||
service.addNotification({
|
||||
title: '批量邀请审批拒绝',
|
||||
content: `已拒绝 ${idsToReject.length} 条邀请`
|
||||
})
|
||||
} catch (error) {
|
||||
service.addNotification({
|
||||
title: '批量审批失败',
|
||||
content: error instanceof Error ? error.message : '批量审批处理失败'
|
||||
})
|
||||
}
|
||||
} else {
|
||||
filteredInvites.value
|
||||
.filter((inv) => selectedInviteIds.value.includes(inv.id))
|
||||
.forEach(rejectInvite)
|
||||
}
|
||||
selectedInviteIds.value = []
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -12,7 +12,9 @@
|
||||
<button class="mos-btn mos-btn-secondary !py-1 !px-2 !text-xs" @click="selectAll">
|
||||
{{ allSelected ? '取消全选' : '全选' }}
|
||||
</button>
|
||||
<button class="mos-btn mos-btn-secondary !py-1 !px-2 !text-xs" @click="batchExport">批量导出</button>
|
||||
<PermissionButton permission="audit.index.export.ALL" variant="secondary" class="!py-1 !px-2 !text-xs" @click="batchExport">
|
||||
批量导出
|
||||
</PermissionButton>
|
||||
</template>
|
||||
<template #default>
|
||||
<div class="space-y-3">
|
||||
@@ -58,6 +60,7 @@ import { useAuditStore } from '../stores/audit'
|
||||
import ListSection from '../components/ListSection.vue'
|
||||
import ExportFieldPanel, { type ExportField } from '../components/ExportFieldPanel.vue'
|
||||
import { useExportFields } from '../composables/useExportFields'
|
||||
import PermissionButton from '../components/PermissionButton.vue'
|
||||
|
||||
type AuditLog = {
|
||||
id: string
|
||||
@@ -68,6 +71,7 @@ type AuditLog = {
|
||||
}
|
||||
|
||||
const logs = ref<AuditLog[]>([])
|
||||
const totalCount = ref(0)
|
||||
const service = useDataService()
|
||||
const auditStore = useAuditStore()
|
||||
const query = ref('')
|
||||
@@ -79,10 +83,29 @@ const pageSize = 8
|
||||
|
||||
const formatDate = (value: string) => new Date(value).toLocaleString('zh-CN')
|
||||
|
||||
// 加载审计日志 - 使用真实后端接口并透传筛选参数
|
||||
const loadAuditLogs = async () => {
|
||||
try {
|
||||
const pageData = await service.getAuditLogsPage(page.value, pageSize, {
|
||||
keyword: query.value.trim() || undefined,
|
||||
startDate: startDate.value || undefined,
|
||||
endDate: endDate.value || undefined
|
||||
})
|
||||
logs.value = pageData.items.map((item: any) => ({
|
||||
id: String(item.id || item.logId),
|
||||
actor: item.operator || item.userName || item.username || '未知',
|
||||
action: item.operation || item.action || '未知操作',
|
||||
resource: item.resource || item.target || '',
|
||||
createdAt: item.createdAt || item.operationTime
|
||||
}))
|
||||
totalCount.value = pageData.total
|
||||
} catch (error) {
|
||||
console.error('Failed to load audit logs:', error)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
const initial = await service.getAuditLogs()
|
||||
auditStore.init(initial)
|
||||
logs.value = auditStore.items
|
||||
await loadAuditLogs()
|
||||
})
|
||||
|
||||
const exportFields: ExportField[] = [
|
||||
@@ -96,21 +119,42 @@ const { selected: exportSelected, setSelected: setExportSelected } = useExportFi
|
||||
exportFields.map((field) => field.key)
|
||||
)
|
||||
|
||||
const exportLogs = () => {
|
||||
const headers = exportFields
|
||||
.filter((field) => exportSelected.value.includes(field.key))
|
||||
.map((field) => field.label)
|
||||
const rows = logs.value.map((item) =>
|
||||
exportFields
|
||||
// 导出审计日志 - 使用后端导出接口
|
||||
const exportLogs = async () => {
|
||||
try {
|
||||
const blob = await service.exportAuditLogs(
|
||||
query.value.trim() || undefined,
|
||||
undefined,
|
||||
undefined
|
||||
)
|
||||
if (blob) {
|
||||
const url = window.URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `audit-logs-${new Date().toISOString().slice(0, 10)}.csv`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
window.URL.revokeObjectURL(url)
|
||||
document.body.removeChild(a)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to export audit logs:', error)
|
||||
// 降级到本地导出
|
||||
const headers = exportFields
|
||||
.filter((field) => exportSelected.value.includes(field.key))
|
||||
.map((field) => {
|
||||
if (field.key === 'actor') return item.actor
|
||||
if (field.key === 'action') return item.action
|
||||
if (field.key === 'resource') return item.resource
|
||||
return formatDate(item.createdAt)
|
||||
})
|
||||
)
|
||||
downloadCsv('audit-logs-demo.csv', headers, rows)
|
||||
.map((field) => field.label)
|
||||
const rows = logs.value.map((item) =>
|
||||
exportFields
|
||||
.filter((field) => exportSelected.value.includes(field.key))
|
||||
.map((field) => {
|
||||
if (field.key === 'actor') return item.actor
|
||||
if (field.key === 'action') return item.action
|
||||
if (field.key === 'resource') return item.resource
|
||||
return formatDate(item.createdAt)
|
||||
})
|
||||
)
|
||||
downloadCsv('audit-logs-demo.csv', headers, rows)
|
||||
}
|
||||
}
|
||||
|
||||
const filteredLogs = computed(() => {
|
||||
@@ -123,15 +167,25 @@ const filteredLogs = computed(() => {
|
||||
})
|
||||
})
|
||||
|
||||
const totalPages = computed(() => Math.max(1, Math.ceil(filteredLogs.value.length / pageSize)))
|
||||
const totalPages = computed(() => Math.max(1, Math.ceil(totalCount.value / pageSize)))
|
||||
|
||||
const pagedLogs = computed(() => {
|
||||
const start = page.value * pageSize
|
||||
return filteredLogs.value.slice(start, start + pageSize)
|
||||
// 后端已返回当前页数据,直接使用
|
||||
const pagedLogs = computed(() => logs.value)
|
||||
|
||||
// 监听分页变化,重新加载数据
|
||||
watch(page, () => {
|
||||
loadAuditLogs()
|
||||
})
|
||||
|
||||
// 监听筛选条件变化,重置页码并重新加载数据
|
||||
watch([query, startDate, endDate], () => {
|
||||
page.value = 0
|
||||
// 如果当前页不是第一页,先回到第一页(这会自动触发loadAuditLogs)
|
||||
if (page.value !== 0) {
|
||||
page.value = 0
|
||||
} else {
|
||||
// 如果已经在第一页,直接重新加载
|
||||
loadAuditLogs()
|
||||
}
|
||||
})
|
||||
|
||||
const allSelected = computed(() => {
|
||||
|
||||
143
frontend/admin/src/views/DashboardMonitorView.vue
Normal file
143
frontend/admin/src/views/DashboardMonitorView.vue
Normal file
@@ -0,0 +1,143 @@
|
||||
<template>
|
||||
<section class="space-y-6">
|
||||
<header>
|
||||
<h1 class="mos-title text-2xl font-semibold">实时监控</h1>
|
||||
<p class="mos-muted mt-2 text-sm">实时监控活动数据与系统运行状态。</p>
|
||||
</header>
|
||||
|
||||
<section class="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
<div v-for="card in realtimeKPIs" :key="card.label" class="mos-card p-5">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="text-xs font-semibold text-mosquito-ink/70">{{ card.label }}</div>
|
||||
<span class="rounded-full bg-green-100 px-2 py-1 text-[10px] font-semibold text-green-600">
|
||||
实时
|
||||
</span>
|
||||
</div>
|
||||
<div class="mos-kpi mt-4 text-2xl font-semibold text-mosquito-ink">{{ formatNumber(card.value) }}</div>
|
||||
<div class="mos-muted mt-2 text-xs">{{ card.hint }}</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="grid gap-6 lg:grid-cols-2">
|
||||
<div class="mos-card p-5">
|
||||
<h2 class="mos-title text-lg font-semibold">实时访问趋势</h2>
|
||||
<p class="mos-muted mt-1 text-xs">最近24小时访问量趋势</p>
|
||||
<div class="mt-4 h-48 flex items-center justify-center rounded-xl border border-dashed border-mosquito-line">
|
||||
<div v-if="hourlyTrend.length > 0" class="w-full h-full p-2">
|
||||
<div class="flex items-end justify-between h-full gap-1">
|
||||
<div v-for="point in hourlyTrend" :key="point.hour" class="flex-1 flex flex-col items-center">
|
||||
<div
|
||||
class="w-full bg-mosquito-primary rounded-t"
|
||||
:style="{ height: getBarHeight(point.visits) + '%', minHeight: '4px' }"
|
||||
></div>
|
||||
<span class="text-[8px] text-mosquito-ink/50 mt-1">{{ point.hour.split(':')[0] }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span v-else class="text-sm text-mosquito-ink/60">暂无数据</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mos-card p-5">
|
||||
<h2 class="mos-title text-lg font-semibold">系统健康状态</h2>
|
||||
<p class="mos-muted mt-1 text-xs">后端服务与数据库状态</p>
|
||||
<div class="mt-4 space-y-3">
|
||||
<div class="flex items-center justify-between rounded-lg px-4 py-3" :class="systemHealth.backend?.status === 'UP' ? 'bg-green-50' : 'bg-red-50'">
|
||||
<span class="text-sm font-medium" :class="systemHealth.backend?.status === 'UP' ? 'text-green-700' : 'text-red-700'">后端服务</span>
|
||||
<span class="rounded-full px-2 py-1 text-xs font-semibold" :class="systemHealth.backend?.status === 'UP' ? 'bg-green-200 text-green-700' : 'bg-red-200 text-red-700'">
|
||||
{{ systemHealth.backend?.status === 'UP' ? '正常' : '异常' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between rounded-lg px-4 py-3" :class="systemHealth.database?.status === 'UP' ? 'bg-green-50' : 'bg-red-50'">
|
||||
<span class="text-sm font-medium" :class="systemHealth.database?.status === 'UP' ? 'text-green-700' : 'text-red-700'">数据库连接</span>
|
||||
<span class="rounded-full px-2 py-1 text-xs font-semibold" :class="systemHealth.database?.status === 'UP' ? 'bg-green-200 text-green-700' : 'bg-red-200 text-red-700'">
|
||||
{{ systemHealth.database?.status === 'UP' ? '正常' : '异常' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between rounded-lg px-4 py-3" :class="systemHealth.redis?.status === 'UP' ? 'bg-green-50' : 'bg-red-50'">
|
||||
<span class="text-sm font-medium" :class="systemHealth.redis?.status === 'UP' ? 'text-green-700' : 'text-red-700'">Redis缓存</span>
|
||||
<span class="rounded-full px-2 py-1 text-xs font-semibold" :class="systemHealth.redis?.status === 'UP' ? 'bg-green-200 text-green-700' : 'bg-red-200 text-red-700'">
|
||||
{{ systemHealth.redis?.status === 'UP' ? '正常' : '异常' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="mos-card p-5">
|
||||
<h2 class="mos-title text-lg font-semibold">实时事件流</h2>
|
||||
<p class="mos-muted mt-1 text-xs">最新系统事件与用户行为</p>
|
||||
<div class="mt-4 space-y-2">
|
||||
<div v-for="event in recentEvents" :key="event.id" class="flex items-center justify-between rounded-lg border border-mosquito-line px-4 py-2 text-sm">
|
||||
<span class="text-mosquito-ink">{{ event.description }}</span>
|
||||
<span class="text-mosquito-ink/60">{{ event.time }}</span>
|
||||
</div>
|
||||
<div v-if="!recentEvents.length" class="text-center text-sm text-mosquito-ink/60 py-4">
|
||||
暂无实时事件
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { getRealtimeData, type RealtimeData } from '@/services/dashboard'
|
||||
|
||||
type RealtimeEvent = {
|
||||
id: string
|
||||
description: string
|
||||
time: string
|
||||
}
|
||||
|
||||
type HourlyPoint = {
|
||||
hour: string
|
||||
visits: number
|
||||
}
|
||||
|
||||
const formatNumber = (value: number | null) => {
|
||||
if (value === null || Number.isNaN(value)) {
|
||||
return '--'
|
||||
}
|
||||
return value.toLocaleString('zh-CN')
|
||||
}
|
||||
|
||||
const realtimeKPIs = ref([
|
||||
{ label: '当前在线', value: 0, hint: '当前活跃用户数' },
|
||||
{ label: '今日访问', value: 0, hint: '今日页面访问次数' },
|
||||
{ label: '实时转化', value: 0, hint: '当前转化率' },
|
||||
{ label: 'API请求', value: 0, hint: 'API调用次数' }
|
||||
])
|
||||
|
||||
const recentEvents = ref<RealtimeEvent[]>([])
|
||||
const hourlyTrend = ref<HourlyPoint[]>([])
|
||||
const systemHealth = ref<{
|
||||
backend?: { status: string; message: string }
|
||||
database?: { status: string; message: string }
|
||||
redis?: { status: string; message: string }
|
||||
}>({})
|
||||
|
||||
const getBarHeight = (visits: number) => {
|
||||
if (!hourlyTrend.value.length) return 0
|
||||
const maxVisits = Math.max(...hourlyTrend.value.map(p => p.visits))
|
||||
if (maxVisits === 0) return 10
|
||||
return Math.max((visits / maxVisits) * 100, 10)
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const data: RealtimeData = await getRealtimeData()
|
||||
realtimeKPIs.value = [
|
||||
{ label: '当前在线', value: data.currentOnline, hint: '当前活跃用户数' },
|
||||
{ label: '今日访问', value: data.todayVisits, hint: '今日页面访问次数' },
|
||||
{ label: '实时转化', value: data.realtimeConversion, hint: '当前转化率(%)' },
|
||||
{ label: 'API请求', value: data.apiRequests, hint: 'API调用次数' }
|
||||
]
|
||||
hourlyTrend.value = data.hourlyTrend || []
|
||||
systemHealth.value = data.systemHealth || {}
|
||||
recentEvents.value = data.recentEvents || []
|
||||
} catch (error) {
|
||||
console.error('Failed to load realtime data:', error)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -52,7 +52,7 @@
|
||||
<RouterLink
|
||||
v-for="item in activitiesWithMeta"
|
||||
:key="item.name"
|
||||
:to="`/activities/${item.id}`"
|
||||
:to="`/activity/${item.id}`"
|
||||
class="flex items-center justify-between rounded-xl border border-mosquito-line px-4 py-3 transition hover:bg-mosquito-bg/60"
|
||||
>
|
||||
<div>
|
||||
@@ -106,16 +106,20 @@
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import { useDataService } from '../services'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
|
||||
type ActivitySummary = {
|
||||
id: number
|
||||
name: string
|
||||
startTime?: string
|
||||
endTime?: string
|
||||
participants?: number
|
||||
}
|
||||
|
||||
const service = useDataService()
|
||||
const hasAuth = computed(() => true)
|
||||
const auth = useAuthStore()
|
||||
// 基于认证状态计算权限(真实鉴权)
|
||||
const hasAuth = computed(() => auth.isAuthenticated)
|
||||
|
||||
const updatedAt = ref('刚刚')
|
||||
const loadError = ref('')
|
||||
@@ -147,7 +151,30 @@ const formatPeriod = (activity: ActivitySummary) => {
|
||||
return `${start.toLocaleDateString('zh-CN')} - ${end.toLocaleDateString('zh-CN')}`
|
||||
}
|
||||
|
||||
const resolveStatus = (activity: ActivitySummary) => {
|
||||
// 后端状态到中文的映射
|
||||
const mapBackendStatusToChinese = (backendStatus: string): string => {
|
||||
const statusMap: Record<string, string> = {
|
||||
'DRAFT': '草稿',
|
||||
'PENDING': '待审批',
|
||||
'IN_APPROVAL': '审批中',
|
||||
'APPROVED': '已审批',
|
||||
'REJECTED': '已拒绝',
|
||||
'WAITING_PUBLISH': '待发布',
|
||||
'RUNNING': '进行中',
|
||||
'PAUSED': '已暂停',
|
||||
'ENDED': '已结束',
|
||||
'ARCHIVED': '已归档',
|
||||
'DELETED': '已删除'
|
||||
}
|
||||
return statusMap[backendStatus] || backendStatus
|
||||
}
|
||||
|
||||
const resolveStatus = (activity: ActivitySummary & { status?: string }) => {
|
||||
// 优先使用后端返回的状态,不再使用时间推导
|
||||
if (activity.status) {
|
||||
return mapBackendStatusToChinese(activity.status)
|
||||
}
|
||||
// 兜底:如果后端没有返回状态,才使用时间推导(兼容旧数据)
|
||||
if (!activity.startTime || !activity.endTime) {
|
||||
return '待配置'
|
||||
}
|
||||
@@ -184,7 +211,7 @@ const activitiesWithMeta = computed(() =>
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
period: formatPeriod(item),
|
||||
participants: 0,
|
||||
participants: item.participants ?? 0,
|
||||
status: resolveStatus(item)
|
||||
}))
|
||||
)
|
||||
|
||||
@@ -5,9 +5,9 @@
|
||||
<h1 class="mos-title text-2xl font-semibold">部门管理</h1>
|
||||
<p class="mos-muted text-sm">管理系统部门组织架构。</p>
|
||||
</div>
|
||||
<button class="mos-btn mos-btn-accent" @click="openCreateDialog(0 as number)">
|
||||
<PermissionButton permission="department.index.manage.ALL" @click="openCreateDialog(0 as number)">
|
||||
新建部门
|
||||
</button>
|
||||
</PermissionButton>
|
||||
</header>
|
||||
|
||||
<!-- 部门树形列表 -->
|
||||
@@ -31,15 +31,15 @@
|
||||
<span class="text-xs text-mosquito-ink/50">{{ dept.deptCode }}</span>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button class="mos-btn mos-btn-secondary !py-1 !px-2 !text-xs" @click="openCreateDialog(dept.id || 0)">
|
||||
<PermissionButton permission="department.index.manage.ALL" variant="secondary" class="!py-1 !px-2 !text-xs" @click="openCreateDialog(dept.id || 0)">
|
||||
添加子部门
|
||||
</button>
|
||||
<button class="mos-btn mos-btn-secondary !py-1 !px-2 !text-xs" @click="openEditDialog(dept)">
|
||||
</PermissionButton>
|
||||
<PermissionButton permission="department.index.manage.ALL" variant="secondary" class="!py-1 !px-2 !text-xs" @click="openEditDialog(dept)">
|
||||
编辑
|
||||
</button>
|
||||
<button class="mos-btn mos-btn-danger !py-1 !px-2 !text-xs" @click="handleDelete(dept)">
|
||||
</PermissionButton>
|
||||
<PermissionButton permission="department.index.manage.ALL" variant="danger" class="!py-1 !px-2 !text-xs" @click="handleDelete(dept)">
|
||||
删除
|
||||
</button>
|
||||
</PermissionButton>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 子部门 -->
|
||||
@@ -54,8 +54,8 @@
|
||||
<span class="text-xs text-mosquito-ink/50">{{ child.deptCode }}</span>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button class="mos-btn mos-btn-secondary !py-1 !px-2 !text-xs" @click="openEditDialog(child)">编辑</button>
|
||||
<button class="mos-btn mos-btn-danger !py-1 !px-2 !text-xs" @click="handleDelete(child)">删除</button>
|
||||
<PermissionButton permission="department.index.manage.ALL" variant="secondary" class="!py-1 !px-2 !text-xs" @click="openEditDialog(child)">编辑</PermissionButton>
|
||||
<PermissionButton permission="department.index.manage.ALL" variant="danger" class="!py-1 !px-2 !text-xs" @click="handleDelete(child)">删除</PermissionButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -104,6 +104,7 @@
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { type Department } from '../services/department'
|
||||
import departmentService from '../services/department'
|
||||
import PermissionButton from '../components/PermissionButton.vue'
|
||||
|
||||
interface DepartmentWithChildren extends Department {
|
||||
children?: DepartmentWithChildren[]
|
||||
|
||||
@@ -1,3 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from 'vue'
|
||||
|
||||
onMounted(() => {
|
||||
document.title = '无权限访问 - Mosquito Admin'
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="flex min-h-[60vh] flex-col items-center justify-center text-center">
|
||||
<div class="mos-card max-w-md space-y-4 p-8">
|
||||
|
||||
@@ -16,19 +16,22 @@
|
||||
<option value="super_admin">超级管理员</option>
|
||||
<option value="system_admin">系统管理员</option>
|
||||
<option value="operation_manager">运营经理</option>
|
||||
<option value="operation_member">运营成员</option>
|
||||
<option value="operation_specialist">运营专员</option>
|
||||
<option value="marketing_manager">市场经理</option>
|
||||
<option value="marketing_member">市场成员</option>
|
||||
<option value="marketing_specialist">市场专员</option>
|
||||
<option value="finance_manager">财务经理</option>
|
||||
<option value="finance_member">财务成员</option>
|
||||
<option value="finance_specialist">财务专员</option>
|
||||
<option value="risk_manager">风控经理</option>
|
||||
<option value="risk_member">风控成员</option>
|
||||
<option value="customer_service">客服</option>
|
||||
<option value="risk_specialist">风控专员</option>
|
||||
<option value="cs_agent">客服专员</option>
|
||||
<option value="cs_manager">客服主管</option>
|
||||
<option value="auditor">审计员</option>
|
||||
<option value="viewer">只读</option>
|
||||
</select>
|
||||
</div>
|
||||
<button class="mos-btn mos-btn-accent w-full" @click="sendInvite">发送邀请(演示)</button>
|
||||
<PermissionButton permission="user.index.create.ALL" variant="primary" class="w-full" :disabled="loading" @click="sendInvite">
|
||||
{{ loading ? '处理中...' : '发送邀请' }}
|
||||
</PermissionButton>
|
||||
</div>
|
||||
|
||||
<ListSection :page="page" :total-pages="totalPages" @prev="page--" @next="page++">
|
||||
@@ -54,20 +57,22 @@
|
||||
</div>
|
||||
<div class="flex items-center gap-2 text-xs text-mosquito-ink/70">
|
||||
<span class="rounded-full bg-mosquito-accent/10 px-2 py-1 text-[10px] font-semibold text-mosquito-brand">{{ invite.status }}</span>
|
||||
<button
|
||||
<PermissionButton
|
||||
v-if="invite.status !== '已接受'"
|
||||
class="mos-btn mos-btn-secondary !py-1 !px-2 !text-xs"
|
||||
permission="user.index.update.ALL"
|
||||
variant="secondary"
|
||||
@click="resendInvite(invite.id)"
|
||||
>
|
||||
重发
|
||||
</button>
|
||||
<button
|
||||
<span class="!py-1 !px-2 !text-xs">重发</span>
|
||||
</PermissionButton>
|
||||
<PermissionButton
|
||||
v-if="invite.status === '待接受'"
|
||||
class="mos-btn mos-btn-secondary !py-1 !px-2 !text-xs"
|
||||
permission="user.index.update.ALL"
|
||||
variant="secondary"
|
||||
@click="expireInvite(invite.id)"
|
||||
>
|
||||
设为过期
|
||||
</button>
|
||||
<span class="!py-1 !px-2 !text-xs">设为过期</span>
|
||||
</PermissionButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -85,6 +90,7 @@ import { useAuditStore } from '../stores/audit'
|
||||
import { useUserStore } from '../stores/users'
|
||||
import { useDataService } from '../services'
|
||||
import ListSection from '../components/ListSection.vue'
|
||||
import PermissionButton from '../components/PermissionButton.vue'
|
||||
import { RoleLabels, type AdminRole } from '../auth/roles'
|
||||
|
||||
const auditStore = useAuditStore()
|
||||
@@ -94,15 +100,19 @@ const query = ref('')
|
||||
const statusFilter = ref('')
|
||||
const page = ref(0)
|
||||
const pageSize = 6
|
||||
const loading = ref(false)
|
||||
const form = ref({
|
||||
email: '',
|
||||
role: 'operation_manager'
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
const invites = await service.getInvites()
|
||||
userStore.init([], invites, [])
|
||||
})
|
||||
// 状态映射:后端英文 -> 前端中文
|
||||
const statusMap: Record<string, string> = {
|
||||
'PENDING': '待接受',
|
||||
'ACCEPTED': '已接受',
|
||||
'REJECTED': '已拒绝',
|
||||
'EXPIRED': '已过期'
|
||||
}
|
||||
|
||||
const roleLabel = (role: string) => {
|
||||
return RoleLabels[role as AdminRole] || role
|
||||
@@ -110,23 +120,85 @@ const roleLabel = (role: string) => {
|
||||
|
||||
const formatDate = (value: string) => new Date(value).toLocaleString('zh-CN')
|
||||
|
||||
const sendInvite = () => {
|
||||
userStore.addInvite(form.value.email || '未填写邮箱', form.value.role as AdminRole)
|
||||
auditStore.addLog('发送用户邀请', form.value.email || '未填写邮箱')
|
||||
form.value.email = ''
|
||||
form.value.role = 'operation_manager'
|
||||
// 从后端数据转换为前端显示格式
|
||||
const convertBackendInvite = (invite: any) => ({
|
||||
id: invite.id,
|
||||
email: invite.email,
|
||||
role: invite.role,
|
||||
status: statusMap[invite.status] || invite.status,
|
||||
statusRaw: invite.status,
|
||||
invitedAt: invite.invitedAt,
|
||||
expiredAt: invite.expiredAt
|
||||
})
|
||||
|
||||
// 加载邀请列表
|
||||
const loadInvites = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const invites = await service.getInvites()
|
||||
const converted = Array.isArray(invites) ? invites.map(convertBackendInvite) : []
|
||||
userStore.init([], converted, [])
|
||||
} catch (error) {
|
||||
console.error('加载邀请列表失败:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const resendInvite = (id: string) => {
|
||||
userStore.resendInvite(id)
|
||||
const invite = userStore.invites.find((item) => item.id === id)
|
||||
auditStore.addLog('重发邀请', invite?.email ?? id)
|
||||
onMounted(async () => {
|
||||
await loadInvites()
|
||||
})
|
||||
|
||||
const sendInvite = async () => {
|
||||
if (!form.value.email) {
|
||||
alert('请输入邮箱地址')
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
try {
|
||||
await service.createInvite(form.value.email, form.value.role)
|
||||
auditStore.addLog('发送用户邀请', form.value.email)
|
||||
form.value.email = ''
|
||||
form.value.role = 'operation_manager'
|
||||
await loadInvites()
|
||||
alert('邀请发送成功')
|
||||
} catch (error: any) {
|
||||
alert(error.message || '发送邀请失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const expireInvite = (id: string) => {
|
||||
userStore.expireInvite(id)
|
||||
const invite = userStore.invites.find((item) => item.id === id)
|
||||
auditStore.addLog('设置邀请过期', invite?.email ?? id)
|
||||
const resendInvite = async (id: number | string) => {
|
||||
const numericId = typeof id === 'string' ? parseInt(id, 10) || 0 : id
|
||||
loading.value = true
|
||||
try {
|
||||
await service.resendInvite(numericId)
|
||||
const invite = userStore.invites.find((item) => String(item.id) === String(id))
|
||||
auditStore.addLog('重发邀请', invite?.email ?? String(id))
|
||||
await loadInvites()
|
||||
alert('邀请重发成功')
|
||||
} catch (error: any) {
|
||||
alert(error.message || '重发邀请失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const expireInvite = async (id: number | string) => {
|
||||
const numericId = typeof id === 'string' ? parseInt(id, 10) || 0 : id
|
||||
loading.value = true
|
||||
try {
|
||||
await service.expireInvite(numericId)
|
||||
const invite = userStore.invites.find((item) => String(item.id) === String(id))
|
||||
auditStore.addLog('设置邀请过期', invite?.email ?? String(id))
|
||||
await loadInvites()
|
||||
alert('邀请已设置为过期')
|
||||
} catch (error: any) {
|
||||
alert(error.message || '设置邀请过期失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const filteredInvites = computed(() => {
|
||||
|
||||
@@ -2,38 +2,113 @@
|
||||
<section class="mx-auto flex min-h-[70vh] max-w-lg flex-col justify-center gap-6">
|
||||
<div class="text-center">
|
||||
<h1 class="mos-title text-2xl font-semibold">管理员登录</h1>
|
||||
<p class="mos-muted mt-2 text-sm">使用演示账号快速预览功能</p>
|
||||
<p class="mos-muted mt-2 text-sm">{{ isDemoMode ? '使用演示账号快速预览功能' : '请输入管理员账号密码登录' }}</p>
|
||||
</div>
|
||||
|
||||
<div class="mos-card p-6 space-y-4">
|
||||
<div>
|
||||
<label class="text-xs font-semibold text-mosquito-ink/70">用户名</label>
|
||||
<input class="mos-input mt-2 w-full" placeholder="管理员账号" disabled />
|
||||
<!-- 真实登录表单 -->
|
||||
<div v-if="mode === 'real'">
|
||||
<div>
|
||||
<label class="text-xs font-semibold text-mosquito-ink/70">用户名</label>
|
||||
<input
|
||||
v-model="username"
|
||||
class="mos-input mt-2 w-full"
|
||||
placeholder="管理员账号"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs font-semibold text-mosquito-ink/70">密码</label>
|
||||
<input
|
||||
v-model="password"
|
||||
class="mos-input mt-2 w-full"
|
||||
type="password"
|
||||
placeholder="••••••••"
|
||||
@keyup.enter="login"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="errorMessage" class="text-red-500 text-sm mt-2">
|
||||
{{ errorMessage }}
|
||||
</div>
|
||||
<button
|
||||
class="mos-btn mos-btn-accent w-full mt-4"
|
||||
:disabled="loading"
|
||||
@click="login"
|
||||
>
|
||||
{{ loading ? '登录中...' : '登录' }}
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs font-semibold text-mosquito-ink/70">密码</label>
|
||||
<input class="mos-input mt-2 w-full" type="password" placeholder="••••••••" disabled />
|
||||
</div>
|
||||
<button class="mos-btn mos-btn-accent w-full opacity-50 cursor-not-allowed" disabled>
|
||||
登录(暂未接入)
|
||||
</button>
|
||||
|
||||
<!-- 演示登录入口(始终显示) -->
|
||||
<div class="border-t border-mosquito-line pt-4">
|
||||
<button class="mos-btn mos-btn-secondary w-full" @click="loginDemo">
|
||||
一键登录(演示管理员)
|
||||
</button>
|
||||
<p class="mos-muted mt-2 text-xs text-center">未登录默认进入演示管理员视图</p>
|
||||
<p class="mos-muted mt-2 text-xs text-center">在任何模式下均可使用演示账号快速体验</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { authApi } from '../services/api/AuthApi'
|
||||
|
||||
const auth = useAuthStore()
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
const username = ref('')
|
||||
const password = ref('')
|
||||
const loading = ref(false)
|
||||
const errorMessage = ref('')
|
||||
|
||||
const mode = computed(() => auth.mode)
|
||||
const isDemoMode = computed(() => auth.isDemoMode)
|
||||
|
||||
// auto模式下自动登录demo
|
||||
onMounted(() => {
|
||||
const envMode = import.meta.env.VITE_MOSQUITO_AUTH_MODE
|
||||
if (envMode === 'auto' && !auth.isAuthenticated) {
|
||||
loginDemo()
|
||||
}
|
||||
})
|
||||
|
||||
const login = async () => {
|
||||
if (!username.value || !password.value) {
|
||||
errorMessage.value = '请输入用户名和密码'
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
errorMessage.value = ''
|
||||
|
||||
try {
|
||||
const response = await authApi.login(username.value, password.value)
|
||||
|
||||
if (response) {
|
||||
// 真实登录成功
|
||||
const role = (response.roles && response.roles[0]) as any || 'viewer'
|
||||
await auth.login({
|
||||
id: response.userId,
|
||||
name: response.displayName,
|
||||
email: response.username,
|
||||
role: role
|
||||
}, response.token)
|
||||
|
||||
// 跳转到原始页面或首页
|
||||
const redirect = route.query.redirect as string
|
||||
await router.push(redirect || '/')
|
||||
} else {
|
||||
errorMessage.value = '用户名或密码错误'
|
||||
}
|
||||
} catch (error) {
|
||||
errorMessage.value = '登录失败,请稍后重试'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const loginDemo = async () => {
|
||||
await auth.loginDemo('super_admin')
|
||||
|
||||
@@ -15,8 +15,12 @@
|
||||
<button class="mos-btn mos-btn-secondary !py-1 !px-2 !text-xs" @click="selectAll">
|
||||
{{ allSelected ? '取消全选' : '全选' }}
|
||||
</button>
|
||||
<button class="mos-btn mos-btn-secondary !py-1 !px-2 !text-xs" @click="batchRead">批量标记已读</button>
|
||||
<button class="mos-btn mos-btn-secondary !py-1 !px-2 !text-xs" @click="markAllRead">全部标记已读</button>
|
||||
<PermissionButton permission="notification.index.manage.ALL" variant="secondary" :hide-when-no-permission="true" @click="batchRead">
|
||||
批量标记已读
|
||||
</PermissionButton>
|
||||
<PermissionButton permission="notification.index.manage.ALL" variant="secondary" :hide-when-no-permission="true" @click="markAllRead">
|
||||
全部标记已读
|
||||
</PermissionButton>
|
||||
</template>
|
||||
<template #default>
|
||||
<div class="space-y-3">
|
||||
@@ -50,6 +54,7 @@ import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useDataService } from '../services'
|
||||
import { useAuditStore } from '../stores/audit'
|
||||
import ListSection from '../components/ListSection.vue'
|
||||
import PermissionButton from '../components/PermissionButton.vue'
|
||||
|
||||
type NoticeItem = {
|
||||
id: string
|
||||
@@ -60,6 +65,7 @@ type NoticeItem = {
|
||||
}
|
||||
|
||||
const notifications = ref<NoticeItem[]>([])
|
||||
const totalCount = ref(0)
|
||||
const service = useDataService()
|
||||
const auditStore = useAuditStore()
|
||||
const query = ref('')
|
||||
@@ -70,37 +76,79 @@ const pageSize = 8
|
||||
|
||||
const formatDate = (value: string) => new Date(value).toLocaleString('zh-CN')
|
||||
|
||||
onMounted(async () => {
|
||||
notifications.value = await service.getNotifications()
|
||||
})
|
||||
// 加载通知列表 - 使用真实后端接口并透传筛选参数
|
||||
const loadNotifications = async () => {
|
||||
try {
|
||||
// 构建筛选参数 - 透传到后端
|
||||
const filters: { type?: string; isRead?: boolean; keyword?: string } = {}
|
||||
if (readFilter.value === 'read') {
|
||||
filters.isRead = true
|
||||
} else if (readFilter.value === 'unread') {
|
||||
filters.isRead = false
|
||||
}
|
||||
// 搜索关键词透传到后端
|
||||
if (query.value.trim()) {
|
||||
filters.keyword = query.value.trim()
|
||||
}
|
||||
|
||||
const markAllRead = () => {
|
||||
notifications.value = notifications.value.map((item) => ({
|
||||
...item,
|
||||
read: true
|
||||
}))
|
||||
auditStore.addLog('标记通知已读', '通知中心')
|
||||
const pageData = await service.getNotificationsPage(page.value, pageSize, filters)
|
||||
notifications.value = pageData.items.map((item: any) => ({
|
||||
id: String(item.id || item.notificationId),
|
||||
title: item.title,
|
||||
detail: item.content || item.message || '',
|
||||
read: item.isRead ?? item.read ?? false,
|
||||
createdAt: item.createdAt || item.createdTime
|
||||
}))
|
||||
totalCount.value = pageData.total
|
||||
} catch (error) {
|
||||
console.error('Failed to load notifications:', error)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadNotifications()
|
||||
})
|
||||
|
||||
// 全部标记已读 - 调用后端接口
|
||||
const markAllRead = async () => {
|
||||
try {
|
||||
await service.markAllNotificationsRead()
|
||||
await loadNotifications()
|
||||
auditStore.addLog('标记通知已读', '通知中心')
|
||||
} catch (error) {
|
||||
console.error('Failed to mark all read:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 搜索筛选(保留前端本地筛选以提高响应速度)
|
||||
const filteredNotifications = computed(() => {
|
||||
return notifications.value.filter((item) => {
|
||||
const matchesQuery = item.title.includes(query.value.trim())
|
||||
const matchesRead = readFilter.value
|
||||
? (readFilter.value === 'read' ? item.read : !item.read)
|
||||
: true
|
||||
return matchesQuery && matchesRead
|
||||
const matchesQuery = query.value.trim() === '' || item.title.includes(query.value.trim())
|
||||
// 读取状态筛选已由后端处理,这里只做搜索筛选
|
||||
return matchesQuery
|
||||
})
|
||||
})
|
||||
|
||||
const totalPages = computed(() => Math.max(1, Math.ceil(filteredNotifications.value.length / pageSize)))
|
||||
// 使用后端返回的 total 计算总页数
|
||||
const totalPages = computed(() => Math.max(1, Math.ceil(totalCount.value / pageSize)))
|
||||
|
||||
const pagedNotifications = computed(() => {
|
||||
const start = page.value * pageSize
|
||||
return filteredNotifications.value.slice(start, start + pageSize)
|
||||
// 后端已返回当前页数据,直接使用
|
||||
const pagedNotifications = computed(() => notifications.value)
|
||||
|
||||
// 监听分页变化,重新加载数据
|
||||
watch(page, () => {
|
||||
loadNotifications()
|
||||
})
|
||||
|
||||
watch([query, readFilter], () => {
|
||||
page.value = 0
|
||||
// 监听筛选条件变化,重置页码并重新加载数据
|
||||
watch([query, readFilter], (newVals, oldVals) => {
|
||||
// 如果当前页不是第一页,先回到第一页(这会自动触发loadNotifications)
|
||||
if (page.value !== 0) {
|
||||
page.value = 0
|
||||
} else {
|
||||
// 如果已经在第一页,直接重新加载
|
||||
loadNotifications()
|
||||
}
|
||||
})
|
||||
|
||||
const allSelected = computed(() => {
|
||||
@@ -126,13 +174,20 @@ const selectAll = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const batchRead = () => {
|
||||
filteredNotifications.value
|
||||
.filter((item) => selectedIds.value.includes(item.id))
|
||||
.forEach((item) => {
|
||||
item.read = true
|
||||
})
|
||||
auditStore.addLog('批量标记通知已读', '通知中心')
|
||||
selectedIds.value = []
|
||||
// 批量标记已读 - 调用后端接口
|
||||
const batchRead = async () => {
|
||||
const idsToMark = selectedIds.value
|
||||
.map(id => parseInt(id))
|
||||
.filter(id => !isNaN(id))
|
||||
if (idsToMark.length === 0) return
|
||||
|
||||
try {
|
||||
await service.batchMarkNotificationsRead(idsToMark)
|
||||
await loadNotifications()
|
||||
auditStore.addLog('批量标记通知已读', '通知中心')
|
||||
selectedIds.value = []
|
||||
} catch (error) {
|
||||
console.error('Failed to batch read:', error)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
297
frontend/admin/src/views/PermissionUsersView.vue
Normal file
297
frontend/admin/src/views/PermissionUsersView.vue
Normal file
@@ -0,0 +1,297 @@
|
||||
<template>
|
||||
<section class="space-y-6">
|
||||
<header>
|
||||
<h1 class="mos-title text-2xl font-semibold">用户权限管理</h1>
|
||||
<p class="mos-muted mt-2 text-sm">管理用户权限、数据范围和角色分配。</p>
|
||||
</header>
|
||||
|
||||
<div class="mos-card p-4">
|
||||
<div class="flex flex-wrap gap-4 mb-4">
|
||||
<input v-model="searchQuery" class="mos-input w-64" placeholder="搜索用户姓名或ID..." />
|
||||
<select v-model="filterRole" class="mos-input w-40">
|
||||
<option value="">全部角色</option>
|
||||
<option v-for="role in roles" :key="role.code" :value="role.code">
|
||||
{{ role.name }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full">
|
||||
<thead>
|
||||
<tr class="border-b border-mosquito-line text-left text-sm text-mosquito-ink/70">
|
||||
<th class="pb-3 font-medium">用户</th>
|
||||
<th class="pb-3 font-medium">角色</th>
|
||||
<th class="pb-3 font-medium">数据范围</th>
|
||||
<th class="pb-3 font-medium">部门</th>
|
||||
<th class="pb-3 font-medium">状态</th>
|
||||
<th class="pb-3 font-medium">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="user in filteredUsers" :key="user.id" class="border-b border-mosquito-line/50">
|
||||
<td class="py-3">
|
||||
<div class="text-sm font-medium text-mosquito-ink">{{ user.realName || user.username }}</div>
|
||||
<div class="text-xs text-mosquito-ink/60">{{ user.username }} (ID: {{ user.id }})</div>
|
||||
</td>
|
||||
<td class="py-3 text-sm text-mosquito-ink">{{ user.roleName }}</td>
|
||||
<td class="py-3 text-sm text-mosquito-ink">
|
||||
<span :class="dataScopeClass(user.dataScope)" class="rounded-full px-2 py-1 text-xs font-semibold">
|
||||
{{ user.dataScopeName }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="py-3 text-sm text-mosquito-ink">{{ user.departmentName || '-' }}</td>
|
||||
<td class="py-3">
|
||||
<span
|
||||
:class="user.status === 'active' ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-700'"
|
||||
class="rounded-full px-2 py-1 text-xs font-semibold"
|
||||
>
|
||||
{{ user.status === 'active' ? '正常' : '禁用' }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="py-3">
|
||||
<div class="flex gap-2">
|
||||
<PermissionButton permission="user.index.update.ALL" variant="secondary" @click="editUser(user)">
|
||||
<span class="text-sm text-mosquito-accent hover:underline">编辑</span>
|
||||
</PermissionButton>
|
||||
<PermissionButton permission="role.index.manage.ALL" variant="secondary" @click="assignRole(user)">
|
||||
<span class="text-sm text-mosquito-accent hover:underline">分配角色</span>
|
||||
</PermissionButton>
|
||||
<PermissionButton permission="user.index.delete.ALL" variant="danger" @click="removeUser(user)">
|
||||
<span class="text-sm text-rose-600 hover:underline">移除</span>
|
||||
</PermissionButton>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div v-if="!filteredUsers.length" class="py-8 text-center text-mosquito-ink/60">
|
||||
暂无用户数据
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 分配角色弹窗 -->
|
||||
<div v-if="showRoleModal" class="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div class="mos-card p-6 w-full max-w-md">
|
||||
<h3 class="text-lg font-semibold mb-4">分配角色 - {{ selectedUser?.username }}</h3>
|
||||
<div class="space-y-3">
|
||||
<label v-for="role in roles" :key="role.code" class="flex items-center gap-3 p-3 rounded-lg border border-mosquito-line cursor-pointer hover:bg-mosquito-bg/50">
|
||||
<input type="radio" v-model="selectedRoleId" :value="role.id" class="h-4 w-4" />
|
||||
<div>
|
||||
<div class="text-sm font-medium text-mosquito-ink">{{ role.name }}</div>
|
||||
<div class="text-xs text-mosquito-ink/60">{{ role.description }}</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
<div class="flex gap-3 mt-6">
|
||||
<button class="mos-btn mos-btn-primary flex-1" @click="confirmAssignRole">确认</button>
|
||||
<button class="mos-btn mos-btn-secondary flex-1" @click="showRoleModal = false">取消</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { userService } from '@/services/userManage'
|
||||
import { permissionService } from '@/services/permission'
|
||||
import PermissionButton from '../components/PermissionButton.vue'
|
||||
|
||||
const showMessage = (msg: string, type: 'success' | 'error' = 'success') => {
|
||||
if (type === 'error') {
|
||||
alert(msg)
|
||||
} else {
|
||||
console.log(msg)
|
||||
}
|
||||
}
|
||||
|
||||
interface User {
|
||||
id: number
|
||||
username: string
|
||||
realName?: string
|
||||
roleCode: string
|
||||
roleName: string
|
||||
dataScope: string
|
||||
dataScopeName: string
|
||||
departmentId?: number
|
||||
departmentName?: string
|
||||
status: string
|
||||
}
|
||||
|
||||
interface Role {
|
||||
id: number
|
||||
code: string
|
||||
name: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
const searchQuery = ref('')
|
||||
const filterRole = ref('')
|
||||
const showRoleModal = ref(false)
|
||||
const selectedUser = ref<User | null>(null)
|
||||
const selectedRoleId = ref<number | null>(null)
|
||||
const loading = ref(false)
|
||||
|
||||
const roles = ref<Role[]>([])
|
||||
const users = ref<User[]>([])
|
||||
const totalUsers = ref(0)
|
||||
const currentPage = ref(1)
|
||||
const pageSize = ref(10)
|
||||
|
||||
// 角色code到ID的映射
|
||||
const roleCodeToId = ref<Record<string, number>>({})
|
||||
|
||||
// 加载角色列表
|
||||
const loadRoles = async () => {
|
||||
try {
|
||||
const roleList = await permissionService.getRoles()
|
||||
roles.value = roleList.map((r: any) => ({
|
||||
id: r.id,
|
||||
code: r.roleCode,
|
||||
name: r.roleName,
|
||||
description: r.description
|
||||
}))
|
||||
// 建立映射
|
||||
roles.value.forEach(role => {
|
||||
roleCodeToId.value[role.code] = role.id
|
||||
})
|
||||
} catch (error: any) {
|
||||
console.error('加载角色列表失败:', error)
|
||||
showMessage(error.message || '加载角色列表失败', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
// 加载用户列表
|
||||
const loadUsers = async () => {
|
||||
try {
|
||||
loading.value = true
|
||||
const result = await userService.getUsers({
|
||||
page: currentPage.value,
|
||||
size: pageSize.value,
|
||||
keyword: searchQuery.value || undefined
|
||||
})
|
||||
// 获取每个用户的角色
|
||||
const userList: User[] = []
|
||||
for (const user of result.items) {
|
||||
try {
|
||||
const roleCodes = await userService.getUserRoles(user.id)
|
||||
const roleCode = roleCodes[0] || ''
|
||||
const roleInfo = roles.value.find(r => r.code === roleCode)
|
||||
userList.push({
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
realName: user.realName,
|
||||
roleCode: roleCode,
|
||||
roleName: roleInfo?.name || roleCode || '未分配',
|
||||
dataScope: 'ALL', // 默认值
|
||||
dataScopeName: '全部数据',
|
||||
departmentId: user.departmentId,
|
||||
departmentName: user.departmentName,
|
||||
status: user.status?.toLowerCase() || 'active'
|
||||
})
|
||||
} catch (e) {
|
||||
// 如果获取用户角色失败,使用默认信息
|
||||
userList.push({
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
realName: user.realName,
|
||||
roleCode: '',
|
||||
roleName: '未分配',
|
||||
dataScope: 'ALL',
|
||||
dataScopeName: '全部数据',
|
||||
departmentId: user.departmentId,
|
||||
departmentName: user.departmentName,
|
||||
status: user.status?.toLowerCase() || 'active'
|
||||
})
|
||||
}
|
||||
}
|
||||
users.value = userList
|
||||
totalUsers.value = result.total
|
||||
} catch (error: any) {
|
||||
console.error('加载用户列表失败:', error)
|
||||
showMessage(error.message || '加载用户列表失败', 'error')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const filteredUsers = computed(() => {
|
||||
return users.value.filter((user) => {
|
||||
const matchesSearch = !searchQuery.value ||
|
||||
user.username.includes(searchQuery.value) ||
|
||||
user.realName?.includes(searchQuery.value) ||
|
||||
String(user.id).includes(searchQuery.value)
|
||||
const matchesRole = !filterRole.value || user.roleCode === filterRole.value
|
||||
return matchesSearch && matchesRole
|
||||
})
|
||||
})
|
||||
|
||||
const dataScopeClass = (scope: string) => {
|
||||
switch (scope) {
|
||||
case 'ALL':
|
||||
return 'bg-purple-100 text-purple-700'
|
||||
case 'DEPARTMENT':
|
||||
return 'bg-blue-100 text-blue-700'
|
||||
case 'OWN':
|
||||
return 'bg-gray-100 text-gray-700'
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-700'
|
||||
}
|
||||
}
|
||||
|
||||
const editUser = (user: User) => {
|
||||
alert(`编辑用户: ${user.username}`)
|
||||
}
|
||||
|
||||
const assignRole = (user: User) => {
|
||||
selectedUser.value = user
|
||||
selectedRoleId.value = roleCodeToId.value[user.roleCode] || null
|
||||
showRoleModal.value = true
|
||||
}
|
||||
|
||||
const confirmAssignRole = async () => {
|
||||
if (selectedUser.value && selectedRoleId.value) {
|
||||
try {
|
||||
loading.value = true
|
||||
await userService.assignRoles(selectedUser.value.id, [selectedRoleId.value], '管理员分配角色')
|
||||
showMessage('角色分配成功,请等待审批')
|
||||
await loadUsers()
|
||||
} catch (error: any) {
|
||||
showMessage(error.message || '分配角色失败', 'error')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
showRoleModal.value = false
|
||||
}
|
||||
|
||||
const removeUser = async (user: User) => {
|
||||
if (confirm(`确定移除用户"${user.username}"吗?`)) {
|
||||
try {
|
||||
loading.value = true
|
||||
// 使用紧急模式删除
|
||||
await userService.deleteUser(user.id)
|
||||
showMessage('用户删除成功')
|
||||
await loadUsers()
|
||||
} catch (error: any) {
|
||||
showMessage(error.message || '删除用户失败', 'error')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 搜索用户
|
||||
const handleSearch = () => {
|
||||
currentPage.value = 1
|
||||
loadUsers()
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadRoles()
|
||||
await loadUsers()
|
||||
})
|
||||
</script>
|
||||
@@ -46,9 +46,25 @@ import { computed } from 'vue'
|
||||
import { RolePermissions, type AdminRole, type Permission } from '../auth/roles'
|
||||
|
||||
const roles: { key: AdminRole; label: string }[] = [
|
||||
// 系统层
|
||||
{ key: 'super_admin', label: '超级管理员' },
|
||||
{ key: 'system_admin', label: '系统管理员' },
|
||||
// 管理层
|
||||
{ key: 'operation_director', label: '运营总监' },
|
||||
{ key: 'operation_manager', label: '运营经理' },
|
||||
{ key: 'operation_specialist', label: '运营专员' },
|
||||
{ key: 'marketing_director', label: '市场总监' },
|
||||
{ key: 'marketing_manager', label: '市场经理' },
|
||||
{ key: 'marketing_specialist', label: '市场专员' },
|
||||
{ key: 'finance_manager', label: '财务经理' },
|
||||
{ key: 'finance_specialist', label: '财务专员' },
|
||||
{ key: 'risk_manager', label: '风控经理' },
|
||||
{ key: 'risk_specialist', label: '风控专员' },
|
||||
{ key: 'cs_manager', label: '客服主管' },
|
||||
{ key: 'cs_agent', label: '客服专员' },
|
||||
// 审计层
|
||||
{ key: 'auditor', label: '审计员' },
|
||||
// 兼容
|
||||
{ key: 'viewer', label: '只读' }
|
||||
]
|
||||
|
||||
@@ -56,20 +72,20 @@ const permissionSections: { group: string; items: { key: Permission; label: stri
|
||||
{
|
||||
group: '可视化与运营查看',
|
||||
items: [
|
||||
{ key: 'dashboard:view', label: '看板查看', description: '访问运营概览与关键指标' },
|
||||
{ key: 'activity:view', label: '活动查看', description: '查看活动列表与详情信息' },
|
||||
{ key: 'dashboard:export', label: '导出数据', description: '导出看板数据' },
|
||||
{ key: 'risk:view', label: '告警查看', description: '查看风控与系统告警信息' }
|
||||
{ key: 'dashboard.index.view.ALL', label: '看板查看', description: '访问运营概览与关键指标' },
|
||||
{ key: 'activity.index.view.ALL', label: '活动查看', description: '查看活动列表与详情信息' },
|
||||
{ key: 'dashboard.index.export.ALL', label: '导出数据', description: '导出看板数据' },
|
||||
{ key: 'risk.index.view.ALL', label: '告警查看', description: '查看风控与系统告警信息' }
|
||||
]
|
||||
},
|
||||
{
|
||||
group: '运营与风控管理',
|
||||
items: [
|
||||
{ key: 'user:view', label: '用户管理', description: '管理运营成员、审批与角色' },
|
||||
{ key: 'reward:view', label: '奖励管理', description: '配置与执行奖励发放' },
|
||||
{ key: 'risk:rule', label: '风控管理', description: '维护风控规则与黑名单' },
|
||||
{ key: 'system:config', label: '配置管理', description: '管理系统配置与策略' },
|
||||
{ key: 'audit:view', label: '审计查看', description: '查看关键操作审计日志' }
|
||||
{ key: 'user.index.view.ALL', label: '用户管理', description: '管理运营成员、审批与角色' },
|
||||
{ key: 'reward.index.view.ALL', label: '奖励管理', description: '配置与执行奖励发放' },
|
||||
{ key: 'risk.rule.manage.ALL', label: '风控管理', description: '维护风控规则与黑名单' },
|
||||
{ key: 'system.config.manage.ALL', label: '配置管理', description: '管理系统配置与策略' },
|
||||
{ key: 'audit.index.view.ALL', label: '审计查看', description: '查看关键操作审计日志' }
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
124
frontend/admin/src/views/RewardApplyView.vue
Normal file
124
frontend/admin/src/views/RewardApplyView.vue
Normal file
@@ -0,0 +1,124 @@
|
||||
<template>
|
||||
<section class="space-y-6">
|
||||
<header>
|
||||
<h1 class="mos-title text-2xl font-semibold">申请奖励</h1>
|
||||
<p class="mos-muted mt-2 text-sm">提交奖励申请,进入审批流程。</p>
|
||||
</header>
|
||||
|
||||
<div class="mos-card p-6 max-w-2xl">
|
||||
<form @submit.prevent="submitApplication" class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-mosquito-ink mb-1">奖励类型</label>
|
||||
<select v-model="form.rewardType" class="mos-input w-full" required>
|
||||
<option value="">请选择奖励类型</option>
|
||||
<option value="coupon">优惠券</option>
|
||||
<option value="points">积分</option>
|
||||
<option value="cash">现金</option>
|
||||
<option value="gift">实物礼品</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-mosquito-ink mb-1">关联活动</label>
|
||||
<select v-model="form.activityId" class="mos-input w-full" required>
|
||||
<option value="">请选择活动</option>
|
||||
<option v-for="activity in activities" :key="activity.id" :value="activity.id">
|
||||
{{ activity.name }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-mosquito-ink mb-1">奖励数量/金额</label>
|
||||
<input v-model="form.amount" type="number" class="mos-input w-full" placeholder="请输入数量或金额" required />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-mosquito-ink mb-1">目标用户</label>
|
||||
<input v-model="form.targetUser" type="text" class="mos-input w-full" placeholder="请输入用户ID或手机号" required />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-mosquito-ink mb-1">申请原因</label>
|
||||
<textarea v-model="form.reason" class="mos-input w-full h-24" placeholder="请说明申请原因" required></textarea>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3 pt-2">
|
||||
<button type="submit" class="mos-btn mos-btn-primary" :disabled="submitting">
|
||||
{{ submitting ? '提交中...' : '提交申请' }}
|
||||
</button>
|
||||
<RouterLink to="/rewards" class="mos-btn mos-btn-secondary">取消</RouterLink>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div v-if="submitSuccess" class="mos-card border-green-200 bg-green-50 p-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-green-600">✓</span>
|
||||
<div>
|
||||
<div class="font-semibold text-green-700">申请提交成功</div>
|
||||
<div class="text-sm text-green-600">您的申请已提交,等待审批。</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { rewardService } from '../services/reward'
|
||||
|
||||
const router = useRouter()
|
||||
const submitting = ref(false)
|
||||
const submitSuccess = ref(false)
|
||||
|
||||
const form = ref({
|
||||
rewardType: '',
|
||||
activityId: '',
|
||||
amount: '',
|
||||
targetUser: '',
|
||||
reason: ''
|
||||
})
|
||||
|
||||
const activities = ref<{ id: number; name: string }[]>([])
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
// 加载活动列表
|
||||
const rewards = await rewardService.getRewards({ page: 1, size: 100 })
|
||||
// 由于getRewards返回的是奖励列表,我们用活动ID过滤
|
||||
// 实际应调用专门的活动列表接口,这里暂时用空
|
||||
activities.value = []
|
||||
} catch (error) {
|
||||
console.error('Failed to load activities:', error)
|
||||
}
|
||||
})
|
||||
|
||||
const submitApplication = async () => {
|
||||
submitting.value = true
|
||||
try {
|
||||
// 调用后端API申请奖励
|
||||
// 解析targetUser为userId(这里假设targetUser格式为用户ID)
|
||||
const userId = Number(form.value.targetUser) || 0
|
||||
if (!userId) {
|
||||
throw new Error('请输入有效的用户ID')
|
||||
}
|
||||
await rewardService.applyReward({
|
||||
userId: userId,
|
||||
activityId: Number(form.value.activityId),
|
||||
rewardType: form.value.rewardType.toUpperCase() as any,
|
||||
rewardAmount: Number(form.value.amount),
|
||||
applyReason: form.value.reason
|
||||
})
|
||||
submitSuccess.value = true
|
||||
setTimeout(() => {
|
||||
router.push('/rewards')
|
||||
}, 2000)
|
||||
} catch (error) {
|
||||
alert('申请提交失败:' + (error instanceof Error ? error.message : '未知错误'))
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -12,8 +12,12 @@
|
||||
<button class="mos-btn mos-btn-secondary !py-1 !px-2 !text-xs" @click="selectAll">
|
||||
{{ allSelected ? '取消全选' : '全选' }}
|
||||
</button>
|
||||
<button class="mos-btn mos-btn-secondary !py-1 !px-2 !text-xs" @click="batchIssue">批量发放</button>
|
||||
<button class="mos-btn mos-btn-secondary !py-1 !px-2 !text-xs" @click="batchRollback">批量回滚</button>
|
||||
<PermissionButton permission="reward.index.batch.ALL" variant="secondary" :hide-when-no-permission="true" @click="batchIssue">
|
||||
批量发放
|
||||
</PermissionButton>
|
||||
<PermissionButton permission="reward.index.cancel.ALL" variant="secondary" :hide-when-no-permission="true" @click="batchRollback">
|
||||
批量回滚
|
||||
</PermissionButton>
|
||||
<input class="mos-input !py-1 !px-2 !text-xs w-44" v-model="batchReason" placeholder="批量回滚原因" />
|
||||
</template>
|
||||
<template #default>
|
||||
@@ -38,12 +42,33 @@
|
||||
<div class="flex items-center gap-3 text-xs text-mosquito-ink/70">
|
||||
<span>{{ reward.points }} 积分</span>
|
||||
<span class="rounded-full bg-mosquito-accent/10 px-2 py-1 text-[10px] font-semibold text-mosquito-brand">{{ reward.status }}</span>
|
||||
<button
|
||||
class="mos-btn mos-btn-secondary !py-1 !px-2 !text-xs"
|
||||
<PermissionButton
|
||||
v-if="reward.status === '已发放'"
|
||||
permission="reward.index.cancel.ALL"
|
||||
variant="secondary"
|
||||
:hide-when-no-permission="true"
|
||||
@click="handleActionClick(reward)"
|
||||
>
|
||||
{{ actionLabel(reward) }}
|
||||
</button>
|
||||
回滚
|
||||
</PermissionButton>
|
||||
<PermissionButton
|
||||
v-else-if="reward.status === '发放失败'"
|
||||
permission="reward.index.grant.ALL"
|
||||
variant="secondary"
|
||||
:hide-when-no-permission="true"
|
||||
@click="handleActionClick(reward)"
|
||||
>
|
||||
重试
|
||||
</PermissionButton>
|
||||
<PermissionButton
|
||||
v-else
|
||||
permission="reward.index.grant.ALL"
|
||||
variant="secondary"
|
||||
:hide-when-no-permission="true"
|
||||
@click="handleActionClick(reward)"
|
||||
>
|
||||
发放
|
||||
</PermissionButton>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
@@ -75,7 +100,9 @@ import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useDataService } from '../services'
|
||||
import { downloadCsv } from '../utils/export'
|
||||
import { useAuditStore } from '../stores/audit'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import ListSection from '../components/ListSection.vue'
|
||||
import PermissionButton from '../components/PermissionButton.vue'
|
||||
import ExportFieldPanel, { type ExportField } from '../components/ExportFieldPanel.vue'
|
||||
import { useExportFields } from '../composables/useExportFields'
|
||||
import { normalizeRewardReason } from '../utils/reward'
|
||||
@@ -94,6 +121,7 @@ type RewardItem = {
|
||||
const rewards = ref<RewardItem[]>([])
|
||||
const service = useDataService()
|
||||
const auditStore = useAuditStore()
|
||||
const authStore = useAuthStore()
|
||||
const query = ref('')
|
||||
const selectedIds = ref<string[]>([])
|
||||
const startDate = ref('')
|
||||
@@ -121,24 +149,46 @@ const { selected: exportSelected, setSelected: setExportSelected } = useExportFi
|
||||
exportFields.map((field) => field.key)
|
||||
)
|
||||
|
||||
const exportRewards = () => {
|
||||
const headers = exportFields
|
||||
.filter((field) => exportSelected.value.includes(field.key))
|
||||
.map((field) => field.label)
|
||||
const rows = rewards.value.map((item) =>
|
||||
exportFields
|
||||
.filter((field) => exportSelected.value.includes(field.key))
|
||||
.map((field) => {
|
||||
if (field.key === 'userName') return item.userName
|
||||
if (field.key === 'points') return String(item.points)
|
||||
if (field.key === 'status') return item.status
|
||||
if (field.key === 'issuedAt') return formatDate(item.issuedAt)
|
||||
if (field.key === 'batchId') return item.batchId
|
||||
if (field.key === 'batchStatus') return item.batchStatus
|
||||
return item.note ?? ''
|
||||
const exportRewards = async () => {
|
||||
if (authStore.mode === 'real') {
|
||||
// 真实模式:调用后端导出接口
|
||||
try {
|
||||
const blob = await service.exportRewards({
|
||||
startDate: startDate.value || undefined,
|
||||
endDate: endDate.value || undefined
|
||||
})
|
||||
)
|
||||
downloadCsv('rewards-demo.csv', headers, rows)
|
||||
// 创建下载链接
|
||||
const url = window.URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = `rewards_${new Date().toISOString().slice(0, 10)}.csv`
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
window.URL.revokeObjectURL(url)
|
||||
} catch (error) {
|
||||
console.error('导出失败:', error)
|
||||
}
|
||||
} else {
|
||||
// 演示模式:本地CSV导出
|
||||
const headers = exportFields
|
||||
.filter((field) => exportSelected.value.includes(field.key))
|
||||
.map((field) => field.label)
|
||||
const rows = rewards.value.map((item) =>
|
||||
exportFields
|
||||
.filter((field) => exportSelected.value.includes(field.key))
|
||||
.map((field) => {
|
||||
if (field.key === 'userName') return item.userName
|
||||
if (field.key === 'points') return String(item.points)
|
||||
if (field.key === 'status') return item.status
|
||||
if (field.key === 'issuedAt') return formatDate(item.issuedAt)
|
||||
if (field.key === 'batchId') return item.batchId
|
||||
if (field.key === 'batchStatus') return item.batchStatus
|
||||
return item.note ?? ''
|
||||
})
|
||||
)
|
||||
downloadCsv('rewards-demo.csv', headers, rows)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -146,22 +196,65 @@ onMounted(async () => {
|
||||
rewards.value = await service.getRewards()
|
||||
})
|
||||
|
||||
const applyIssue = (reward: RewardItem) => {
|
||||
reward.status = '已发放'
|
||||
reward.note = undefined
|
||||
auditStore.addLog('发放奖励', reward.userName)
|
||||
const applyIssue = async (reward: RewardItem) => {
|
||||
if (authStore.mode === 'real') {
|
||||
// 真实模式:调用后端API
|
||||
try {
|
||||
await service.grantReward(Number(reward.id))
|
||||
// 刷新数据
|
||||
rewards.value = await service.getRewards()
|
||||
auditStore.addLog('发放奖励', reward.userName)
|
||||
} catch (error) {
|
||||
console.error('发放奖励失败:', error)
|
||||
alert(error instanceof Error ? error.message : '发放奖励失败')
|
||||
}
|
||||
} else {
|
||||
// 演示模式:本地状态变更
|
||||
reward.status = '已发放'
|
||||
reward.note = undefined
|
||||
auditStore.addLog('发放奖励', reward.userName)
|
||||
}
|
||||
}
|
||||
|
||||
const rollbackIssue = (reward: RewardItem, reason: string) => {
|
||||
reward.status = '待发放'
|
||||
reward.note = `回滚原因:${reason}`
|
||||
auditStore.addLog('回滚奖励', `${reward.userName}:${reason}`)
|
||||
const rollbackIssue = async (reward: RewardItem, reason: string) => {
|
||||
if (authStore.mode === 'real') {
|
||||
// 真实模式:调用后端API
|
||||
try {
|
||||
await service.cancelReward(Number(reward.id), reason)
|
||||
// 刷新数据
|
||||
rewards.value = await service.getRewards()
|
||||
auditStore.addLog('回滚奖励', `${reward.userName}:${reason}`)
|
||||
} catch (error) {
|
||||
console.error('回滚奖励失败:', error)
|
||||
alert(error instanceof Error ? error.message : '回滚奖励失败')
|
||||
}
|
||||
} else {
|
||||
// 演示模式:本地状态变更
|
||||
reward.status = '待发放'
|
||||
reward.note = `回滚原因:${reason}`
|
||||
auditStore.addLog('回滚奖励', `${reward.userName}:${reason}`)
|
||||
}
|
||||
}
|
||||
|
||||
const retryIssue = (reward: RewardItem, reason: string) => {
|
||||
reward.status = '已发放'
|
||||
reward.note = `重试原因:${reason}`
|
||||
auditStore.addLog('重试发放奖励', `${reward.userName}:${reason}`)
|
||||
const retryIssue = async (reward: RewardItem, reason: string) => {
|
||||
if (authStore.mode === 'real') {
|
||||
// 真实模式:调用后端API(先取消再发放)
|
||||
try {
|
||||
await service.cancelReward(Number(reward.id), reason)
|
||||
await service.grantReward(Number(reward.id))
|
||||
// 刷新数据
|
||||
rewards.value = await service.getRewards()
|
||||
auditStore.addLog('重试发放奖励', `${reward.userName}:${reason}`)
|
||||
} catch (error) {
|
||||
console.error('重试发放失败:', error)
|
||||
alert(error instanceof Error ? error.message : '重试发放失败')
|
||||
}
|
||||
} else {
|
||||
// 演示模式:本地状态变更
|
||||
reward.status = '已发放'
|
||||
reward.note = `重试原因:${reason}`
|
||||
auditStore.addLog('重试发放奖励', `${reward.userName}:${reason}`)
|
||||
}
|
||||
}
|
||||
|
||||
const actionLabel = (reward: RewardItem) => {
|
||||
@@ -231,18 +324,50 @@ const selectAll = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const batchIssue = () => {
|
||||
filteredRewards.value
|
||||
.filter((item) => selectedIds.value.includes(item.id))
|
||||
.forEach(applyIssue)
|
||||
const batchIssue = async () => {
|
||||
if (authStore.mode === 'real') {
|
||||
// 真实模式:调用批量API
|
||||
const ids = filteredRewards.value
|
||||
.filter((item) => selectedIds.value.includes(item.id))
|
||||
.map((item) => Number(item.id))
|
||||
try {
|
||||
await service.batchGrantRewards(ids)
|
||||
rewards.value = await service.getRewards()
|
||||
auditStore.addLog('批量发放奖励', `${ids.length} 条记录`)
|
||||
} catch (error) {
|
||||
console.error('批量发放失败:', error)
|
||||
alert(error instanceof Error ? error.message : '批量发放失败')
|
||||
}
|
||||
} else {
|
||||
// 演示模式:本地状态变更
|
||||
filteredRewards.value
|
||||
.filter((item) => selectedIds.value.includes(item.id))
|
||||
.forEach(applyIssue)
|
||||
}
|
||||
selectedIds.value = []
|
||||
}
|
||||
|
||||
const batchRollback = () => {
|
||||
const batchRollback = async () => {
|
||||
const reason = normalizeRewardReason(batchReason.value, '批量回滚')
|
||||
filteredRewards.value
|
||||
.filter((item) => selectedIds.value.includes(item.id))
|
||||
.forEach((item) => rollbackIssue(item, reason))
|
||||
if (authStore.mode === 'real') {
|
||||
// 真实模式:逐个调用API
|
||||
const items = filteredRewards.value.filter((item) => selectedIds.value.includes(item.id))
|
||||
try {
|
||||
for (const item of items) {
|
||||
await service.cancelReward(Number(item.id), reason)
|
||||
}
|
||||
rewards.value = await service.getRewards()
|
||||
auditStore.addLog('批量回滚奖励', `${items.length} 条记录:${reason}`)
|
||||
} catch (error) {
|
||||
console.error('批量回滚失败:', error)
|
||||
alert(error instanceof Error ? error.message : '批量回滚失败')
|
||||
}
|
||||
} else {
|
||||
// 演示模式:本地状态变更
|
||||
filteredRewards.value
|
||||
.filter((item) => selectedIds.value.includes(item.id))
|
||||
.forEach((item) => rollbackIssue(item, reason))
|
||||
}
|
||||
selectedIds.value = []
|
||||
batchReason.value = ''
|
||||
}
|
||||
|
||||
169
frontend/admin/src/views/RiskRuleFormView.vue
Normal file
169
frontend/admin/src/views/RiskRuleFormView.vue
Normal file
@@ -0,0 +1,169 @@
|
||||
<template>
|
||||
<section class="space-y-6">
|
||||
<header class="flex flex-wrap items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 class="mos-title text-2xl font-semibold">{{ isEdit ? '编辑规则' : '新建规则' }}</h1>
|
||||
<p class="mos-muted mt-2 text-sm">配置风控规则,包括规则类型、触发条件、处理动作等。</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="mos-card p-6">
|
||||
<form @submit.prevent="handleSubmit" class="space-y-4">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-mosquito-ink mb-1">规则名称 *</label>
|
||||
<input
|
||||
v-model="formData.name"
|
||||
type="text"
|
||||
class="mos-input w-full"
|
||||
placeholder="请输入规则名称"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-mosquito-ink mb-1">规则类型 *</label>
|
||||
<select v-model="formData.riskType" class="mos-input w-full" required>
|
||||
<option value="">请选择规则类型</option>
|
||||
<option value="CHEAT">欺诈</option>
|
||||
<option value="ABNORMAL">异常</option>
|
||||
<option value="VIOLATION">违规</option>
|
||||
<option value="SYSTEM">系统</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-mosquito-ink mb-1">处理动作 *</label>
|
||||
<select v-model="formData.action" class="mos-input w-full" required>
|
||||
<option value="">请选择处理动作</option>
|
||||
<option value="BLOCK">拦截</option>
|
||||
<option value="WARN">警告</option>
|
||||
<option value="LOG">记录</option>
|
||||
<option value="CAPTCHA">验证码</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-mosquito-ink mb-1">优先级</label>
|
||||
<input
|
||||
v-model.number="formData.priority"
|
||||
type="number"
|
||||
class="mos-input w-full"
|
||||
placeholder="数值越大优先级越高"
|
||||
min="0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-mosquito-ink mb-1">触发条件 *</label>
|
||||
<textarea
|
||||
v-model="formData.condition"
|
||||
class="mos-input w-full"
|
||||
rows="3"
|
||||
placeholder="请输入触发条件表达式,如: user.invite_count > 10"
|
||||
required
|
||||
></textarea>
|
||||
<p class="text-xs text-mosquito-ink/60 mt-1">支持条件表达式,用于判断是否触发此规则</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-mosquito-ink mb-1">描述</label>
|
||||
<textarea
|
||||
v-model="formData.description"
|
||||
class="mos-input w-full"
|
||||
rows="2"
|
||||
placeholder="请输入规则描述"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2 pt-4">
|
||||
<button type="submit" class="mos-btn mos-btn-primary" :disabled="submitting">
|
||||
{{ submitting ? '提交中...' : '提交审批' }}
|
||||
</button>
|
||||
<button type="button" class="mos-btn mos-btn-secondary" @click="handleCancel">
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import riskService, { type RiskRule, type RiskType, type RiskAction } from '@/services/risk'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
const isEdit = computed(() => !!route.params.id)
|
||||
const ruleId = computed(() => route.params.id as string | undefined)
|
||||
const submitting = ref(false)
|
||||
|
||||
const formData = ref({
|
||||
name: '',
|
||||
riskType: '',
|
||||
condition: '',
|
||||
action: '',
|
||||
priority: 0,
|
||||
description: ''
|
||||
})
|
||||
|
||||
const loadRule = async () => {
|
||||
if (!ruleId.value) return
|
||||
try {
|
||||
const rules = await riskService.getRules()
|
||||
const rule = rules.data.find(r => r.id === Number(ruleId.value))
|
||||
if (rule) {
|
||||
formData.value = {
|
||||
name: rule.name,
|
||||
riskType: rule.riskType || '',
|
||||
condition: rule.condition || '',
|
||||
action: rule.action || '',
|
||||
priority: rule.priority || 0,
|
||||
description: rule.description || ''
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
alert('加载规则失败: ' + (error.message || '未知错误'))
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (submitting.value) return
|
||||
submitting.value = true
|
||||
|
||||
try {
|
||||
const ruleData: Partial<RiskRule> = {
|
||||
name: formData.value.name,
|
||||
riskType: formData.value.riskType as RiskType,
|
||||
condition: formData.value.condition,
|
||||
action: formData.value.action as RiskAction,
|
||||
priority: formData.value.priority,
|
||||
description: formData.value.description
|
||||
}
|
||||
if (isEdit.value && ruleId.value) {
|
||||
await riskService.updateRule(Number(ruleId.value), ruleData)
|
||||
} else {
|
||||
await riskService.createRule(ruleData)
|
||||
}
|
||||
router.push('/risks/rules')
|
||||
} catch (error: any) {
|
||||
alert('提交失败: ' + (error.message || '未知错误'))
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleCancel = () => {
|
||||
router.push('/risks/rules')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (isEdit.value) {
|
||||
loadRule()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
278
frontend/admin/src/views/RiskRulesView.vue
Normal file
278
frontend/admin/src/views/RiskRulesView.vue
Normal file
@@ -0,0 +1,278 @@
|
||||
<template>
|
||||
<section class="space-y-6">
|
||||
<header class="flex flex-wrap items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 class="mos-title text-2xl font-semibold">风控规则</h1>
|
||||
<p class="mos-muted mt-2 text-sm">配置和管理风控规则,包括导入导出、拦截与解除拦截。</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<PermissionButton permission="risk.rule.create.ALL" variant="secondary" @click="handleImport">
|
||||
导入
|
||||
</PermissionButton>
|
||||
<PermissionButton permission="risk.rule.manage.ALL" variant="secondary" @click="handleExport">
|
||||
导出
|
||||
</PermissionButton>
|
||||
<PermissionButton permission="risk.rule.create.ALL" variant="primary" @click="router.push('/risks/new')">
|
||||
新建规则
|
||||
</PermissionButton>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="mos-card p-4">
|
||||
<div class="flex flex-wrap gap-4 mb-4">
|
||||
<input v-model="searchQuery" class="mos-input w-64" placeholder="搜索规则名称..." />
|
||||
<select v-model="filterStatus" class="mos-input w-40">
|
||||
<option value="">全部状态</option>
|
||||
<option value="enabled">已启用</option>
|
||||
<option value="disabled">已禁用</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full">
|
||||
<thead>
|
||||
<tr class="border-b border-mosquito-line text-left text-sm text-mosquito-ink/70">
|
||||
<th class="pb-3 font-medium">规则名称</th>
|
||||
<th class="pb-3 font-medium">规则类型</th>
|
||||
<th class="pb-3 font-medium">阈值</th>
|
||||
<th class="pb-3 font-medium">状态</th>
|
||||
<th class="pb-3 font-medium">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="rule in filteredRules" :key="rule.id" class="border-b border-mosquito-line/50">
|
||||
<td class="py-3 text-sm text-mosquito-ink">{{ rule.name }}</td>
|
||||
<td class="py-3 text-sm text-mosquito-ink">{{ rule.type }}</td>
|
||||
<td class="py-3 text-sm text-mosquito-ink">{{ rule.threshold }}</td>
|
||||
<td class="py-3">
|
||||
<span
|
||||
:class="rule.enabled ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-700'"
|
||||
class="rounded-full px-2 py-1 text-xs font-semibold"
|
||||
>
|
||||
{{ rule.enabled ? '已启用' : '已禁用' }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="py-3">
|
||||
<div class="flex gap-2">
|
||||
<PermissionButton permission="risk.rule.edit.ALL" variant="secondary" @click="editRule(rule)">
|
||||
<span class="text-sm text-mosquito-accent hover:underline">编辑</span>
|
||||
</PermissionButton>
|
||||
<PermissionButton
|
||||
permission="risk.rule.enable.ALL"
|
||||
variant="secondary"
|
||||
@click="toggleRule(rule)"
|
||||
>
|
||||
<span :class="rule.enabled ? 'text-amber-600 hover:underline' : 'text-green-600 hover:underline'">
|
||||
{{ rule.enabled ? '禁用' : '启用' }}
|
||||
</span>
|
||||
</PermissionButton>
|
||||
<PermissionButton permission="risk.rule.delete.ALL" variant="danger" @click="deleteRule(rule)">
|
||||
<span class="text-sm text-rose-600 hover:underline">删除</span>
|
||||
</PermissionButton>
|
||||
<PermissionButton
|
||||
v-if="rule.blocked"
|
||||
permission="risk.block.execute.ALL"
|
||||
variant="secondary"
|
||||
@click="unblockRule(rule)"
|
||||
>
|
||||
<span class="text-sm text-blue-600 hover:underline">解除拦截</span>
|
||||
</PermissionButton>
|
||||
<PermissionButton
|
||||
v-else
|
||||
permission="risk.block.execute.ALL"
|
||||
variant="secondary"
|
||||
@click="blockRule(rule)"
|
||||
>
|
||||
<span class="text-sm text-orange-600 hover:underline">拦截</span>
|
||||
</PermissionButton>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div v-if="!filteredRules.length" class="py-8 text-center text-mosquito-ink/60">
|
||||
暂无风控规则
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import riskService from '@/services/risk'
|
||||
import PermissionButton from '../components/PermissionButton.vue'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const showMessage = (msg: string, type: 'success' | 'error' = 'success') => {
|
||||
if (type === 'error') {
|
||||
alert(msg)
|
||||
} else {
|
||||
console.log(msg)
|
||||
}
|
||||
}
|
||||
|
||||
interface RiskRule {
|
||||
id: number
|
||||
name: string
|
||||
type: string
|
||||
code: string
|
||||
condition: string
|
||||
action: string
|
||||
threshold: string
|
||||
enabled: boolean
|
||||
blocked: boolean
|
||||
}
|
||||
|
||||
const searchQuery = ref('')
|
||||
const filterStatus = ref('')
|
||||
const loading = ref(false)
|
||||
|
||||
const rules = ref<RiskRule[]>([])
|
||||
|
||||
// 加载规则列表
|
||||
const loadRules = async () => {
|
||||
try {
|
||||
loading.value = true
|
||||
const result = await riskService.getRules({
|
||||
status: filterStatus.value || undefined
|
||||
})
|
||||
rules.value = result.data.map((r: any) => ({
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
type: r.type || r.riskType || '',
|
||||
code: r.code || '',
|
||||
condition: r.condition || '',
|
||||
action: r.action || '',
|
||||
threshold: r.condition || '',
|
||||
enabled: r.status === 'ENABLED',
|
||||
blocked: false
|
||||
}))
|
||||
} catch (error: any) {
|
||||
console.error('加载规则列表失败:', error)
|
||||
showMessage(error.message || '加载规则列表失败', 'error')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const filteredRules = computed(() => {
|
||||
return rules.value.filter((rule) => {
|
||||
const matchesSearch = !searchQuery.value || rule.name.includes(searchQuery.value)
|
||||
const matchesStatus = !filterStatus.value ||
|
||||
(filterStatus.value === 'enabled' && rule.enabled) ||
|
||||
(filterStatus.value === 'disabled' && !rule.enabled)
|
||||
return matchesSearch && matchesStatus
|
||||
})
|
||||
})
|
||||
|
||||
const handleImport = () => {
|
||||
// 创建文件输入元素
|
||||
const input = document.createElement('input')
|
||||
input.type = 'file'
|
||||
input.accept = '.json,.csv'
|
||||
input.onchange = async (e: Event) => {
|
||||
const file = (e.target as HTMLInputElement).files?.[0]
|
||||
if (!file) return
|
||||
|
||||
try {
|
||||
const text = await file.text()
|
||||
// 尝试解析JSON格式
|
||||
const imported = JSON.parse(text)
|
||||
if (Array.isArray(imported)) {
|
||||
// 批量导入规则
|
||||
for (const rule of imported) {
|
||||
if (rule.name && rule.code && rule.riskType) {
|
||||
await riskService.createRule(rule)
|
||||
}
|
||||
}
|
||||
showMessage(`成功导入 ${imported.length} 条规则`)
|
||||
loadRules()
|
||||
} else {
|
||||
showMessage('导入格式不正确,应为规则数组', 'error')
|
||||
}
|
||||
} catch (error: any) {
|
||||
showMessage('导入失败: ' + (error.message || '文件格式错误'), 'error')
|
||||
}
|
||||
}
|
||||
input.click()
|
||||
}
|
||||
|
||||
const handleExport = async () => {
|
||||
// 调用后端CSV导出接口
|
||||
try {
|
||||
loading.value = true
|
||||
const blob = await riskService.exportRules()
|
||||
const url = URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = `risk-rules-${new Date().toISOString().split('T')[0]}.csv`
|
||||
link.click()
|
||||
URL.revokeObjectURL(url)
|
||||
showMessage('规则导出成功')
|
||||
} catch (error: any) {
|
||||
showMessage('导出失败: ' + (error.message || '未知错误'), 'error')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const editRule = (rule: RiskRule) => {
|
||||
router.push(`/risks/edit/${rule.id}`)
|
||||
}
|
||||
|
||||
const toggleRule = async (rule: RiskRule) => {
|
||||
try {
|
||||
loading.value = true
|
||||
await riskService.toggleRule(rule.id, !rule.enabled)
|
||||
rule.enabled = !rule.enabled
|
||||
showMessage(rule.enabled ? '规则已启用' : '规则已禁用')
|
||||
} catch (error: any) {
|
||||
showMessage(error.message || '操作失败', 'error')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const deleteRule = async (rule: RiskRule) => {
|
||||
if (confirm(`确定删除规则"${rule.name}"吗?`)) {
|
||||
try {
|
||||
loading.value = true
|
||||
await riskService.deleteRule(rule.id)
|
||||
rules.value = rules.value.filter((r) => r.id !== rule.id)
|
||||
showMessage('规则删除成功')
|
||||
} catch (error: any) {
|
||||
showMessage(error.message || '删除规则失败', 'error')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const blockRule = async (rule: RiskRule) => {
|
||||
// 拦截操作应前往风险告警页面进行
|
||||
// 这里提示用户去告警页面执行拦截操作
|
||||
if (confirm(`规则"${rule.name}"的拦截操作需要在风险告警页面执行。\n\n是否前往风险监控页面?`)) {
|
||||
router.push('/risks')
|
||||
}
|
||||
}
|
||||
|
||||
const unblockRule = async (rule: RiskRule) => {
|
||||
// 解除拦截操作应前往风险告警页面进行
|
||||
if (confirm(`规则"${rule.name}"的解除拦截操作需要在风险告警页面执行。\n\n是否前往风险监控页面?`)) {
|
||||
router.push('/risks')
|
||||
}
|
||||
}
|
||||
|
||||
// 监听筛选状态变化
|
||||
const handleFilterChange = () => {
|
||||
loadRules()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadRules()
|
||||
})
|
||||
</script>
|
||||
@@ -19,20 +19,30 @@
|
||||
<div class="flex flex-col items-end gap-2 text-xs text-mosquito-ink/70">
|
||||
<span class="rounded-full bg-mosquito-accent/10 px-2 py-1 text-[10px] font-semibold text-mosquito-brand">{{ alert.status }}</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
class="mos-btn mos-btn-secondary !py-1 !px-2 !text-xs"
|
||||
<PermissionButton
|
||||
permission="risk.alert.handle.ALL"
|
||||
variant="secondary"
|
||||
:disabled="alert.status !== '未处理'"
|
||||
@click="updateAlert(alert, 'process')"
|
||||
>
|
||||
处理
|
||||
</button>
|
||||
<button
|
||||
class="mos-btn mos-btn-secondary !py-1 !px-2 !text-xs"
|
||||
<span class="!py-1 !px-2 !text-xs">处理</span>
|
||||
</PermissionButton>
|
||||
<PermissionButton
|
||||
permission="risk.alert.handle.ALL"
|
||||
variant="secondary"
|
||||
:disabled="alert.status === '已关闭'"
|
||||
@click="updateAlert(alert, 'close')"
|
||||
>
|
||||
关闭
|
||||
</button>
|
||||
<span class="!py-1 !px-2 !text-xs">关闭</span>
|
||||
</PermissionButton>
|
||||
<PermissionButton
|
||||
permission="risk.index.audit.ALL"
|
||||
variant="primary"
|
||||
:disabled="alert.status !== '待审核'"
|
||||
@click="auditAlert(alert)"
|
||||
>
|
||||
<span class="!py-1 !px-2 !text-xs">审核</span>
|
||||
</PermissionButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -54,9 +64,15 @@
|
||||
<button class="mos-btn mos-btn-secondary !py-1 !px-2 !text-xs" @click="selectAll">
|
||||
{{ allSelected ? '取消全选' : '全选' }}
|
||||
</button>
|
||||
<button class="mos-btn mos-btn-secondary !py-1 !px-2 !text-xs" @click="batchEnable">批量启用</button>
|
||||
<button class="mos-btn mos-btn-secondary !py-1 !px-2 !text-xs" @click="batchPause">批量暂停</button>
|
||||
<button class="mos-btn mos-btn-secondary !py-1 !px-2 !text-xs" @click="addRule">新增规则</button>
|
||||
<PermissionButton permission="risk.rule.manage.ALL" variant="secondary" :hide-when-no-permission="true" @click="batchEnable">
|
||||
批量启用
|
||||
</PermissionButton>
|
||||
<PermissionButton permission="risk.rule.manage.ALL" variant="secondary" :hide-when-no-permission="true" @click="batchPause">
|
||||
批量暂停
|
||||
</PermissionButton>
|
||||
<PermissionButton permission="risk.rule.manage.ALL" variant="secondary" :hide-when-no-permission="true" @click="addRule">
|
||||
新增规则
|
||||
</PermissionButton>
|
||||
</template>
|
||||
<template #default>
|
||||
<div class="space-y-3">
|
||||
@@ -93,6 +109,7 @@ import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useDataService } from '../services'
|
||||
import { useAuditStore } from '../stores/audit'
|
||||
import ListSection from '../components/ListSection.vue'
|
||||
import PermissionButton from '../components/PermissionButton.vue'
|
||||
import { transitionAlertStatus, type AlertAction } from '../utils/risk'
|
||||
|
||||
type RiskItem = {
|
||||
@@ -121,37 +138,100 @@ const endDate = ref('')
|
||||
const selectedIds = ref<string[]>([])
|
||||
const page = ref(0)
|
||||
const pageSize = 6
|
||||
const total = ref(0)
|
||||
|
||||
const formatDate = (value: string) => new Date(value).toLocaleDateString('zh-CN')
|
||||
|
||||
const loadRisks = async () => {
|
||||
try {
|
||||
const result = await service.getRiskItems({ page: page.value, size: pageSize })
|
||||
// 支持多种返回格式
|
||||
if (result && typeof result === 'object' && 'items' in result) {
|
||||
risks.value = result.items as RiskItem[]
|
||||
total.value = (result as any).total || 0
|
||||
} else if (Array.isArray(result)) {
|
||||
// 兼容旧的数组返回格式
|
||||
risks.value = result as RiskItem[]
|
||||
total.value = risks.value.length
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载风控规则失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
risks.value = await service.getRiskItems()
|
||||
await loadRisks()
|
||||
alerts.value = await service.getRiskAlerts()
|
||||
})
|
||||
|
||||
const addRule = () => {
|
||||
risks.value.unshift({
|
||||
id: `risk-${Date.now()}`,
|
||||
type: '新增规则',
|
||||
target: '待配置',
|
||||
status: '待核查',
|
||||
updatedAt: new Date().toISOString()
|
||||
})
|
||||
auditStore.addLog('新增风控规则', '风控规则')
|
||||
const addRule = async () => {
|
||||
try {
|
||||
const newRule = await service.createRiskRule({
|
||||
type: '新增规则',
|
||||
target: '待配置',
|
||||
status: '待核查'
|
||||
})
|
||||
risks.value.unshift({
|
||||
id: newRule.id || `risk-${Date.now()}`,
|
||||
type: '新增规则',
|
||||
target: '待配置',
|
||||
status: '待核查',
|
||||
updatedAt: new Date().toISOString()
|
||||
})
|
||||
auditStore.addLog('新增风控规则', '风控规则')
|
||||
} catch (error) {
|
||||
console.error('创建风控规则失败:', error)
|
||||
alert('创建失败: ' + (error as Error).message)
|
||||
}
|
||||
}
|
||||
|
||||
const toggleRisk = (item: RiskItem) => {
|
||||
item.status = item.status === '生效' ? '暂停' : '生效'
|
||||
item.updatedAt = new Date().toISOString()
|
||||
auditStore.addLog(item.status === '生效' ? '启用风控规则' : '暂停风控规则', item.type)
|
||||
const toggleRisk = async (item: RiskItem) => {
|
||||
try {
|
||||
// 根据当前状态计算目标状态:生效->暂停(enabled=false), 暂停->生效(enabled=true)
|
||||
const targetEnabled = item.status !== '生效'
|
||||
await service.toggleRiskRule(item.id, targetEnabled)
|
||||
item.status = targetEnabled ? '生效' : '暂停'
|
||||
item.updatedAt = new Date().toISOString()
|
||||
auditStore.addLog(targetEnabled ? '启用风控规则' : '暂停风控规则', item.type)
|
||||
} catch (error) {
|
||||
console.error('切换风控规则状态失败:', error)
|
||||
alert('操作失败: ' + (error as Error).message)
|
||||
}
|
||||
}
|
||||
|
||||
const updateAlert = (alert: RiskAlert, action: AlertAction) => {
|
||||
const nextStatus = transitionAlertStatus(alert.status, action)
|
||||
if (nextStatus === alert.status) return
|
||||
alert.status = nextStatus
|
||||
alert.updatedAt = new Date().toISOString()
|
||||
auditStore.addLog(nextStatus === '已关闭' ? '关闭风险告警' : '处理风险告警', alert.title)
|
||||
const updateAlert = async (alertItem: RiskAlert, action: AlertAction) => {
|
||||
try {
|
||||
// 转换 action: 'process' -> 'handle'
|
||||
const apiAction: 'handle' | 'close' = action === 'process' ? 'handle' : action
|
||||
await service.handleRiskAlert(alertItem.id, apiAction)
|
||||
const nextStatus = transitionAlertStatus(alertItem.status, action)
|
||||
if (nextStatus === alertItem.status) return
|
||||
alertItem.status = nextStatus
|
||||
alertItem.updatedAt = new Date().toISOString()
|
||||
auditStore.addLog(nextStatus === '已关闭' ? '关闭风险告警' : '处理风险告警', alertItem.title)
|
||||
} catch (error) {
|
||||
console.error('处理风险告警失败:', error)
|
||||
window.alert('操作失败: ' + (error as Error).message)
|
||||
}
|
||||
}
|
||||
|
||||
const auditAlert = async (alertItem: RiskAlert) => {
|
||||
try {
|
||||
const result = window.confirm(`确认审核告警"${alertItem.title}"?`)
|
||||
if (!result) return
|
||||
|
||||
await riskService.auditAlert(Number(alertItem.id), {
|
||||
result: 'APPROVED',
|
||||
comment: '审核通过'
|
||||
})
|
||||
|
||||
alertItem.status = '已审核'
|
||||
alertItem.updatedAt = new Date().toISOString()
|
||||
auditStore.addLog('审核风控告警', alertItem.title)
|
||||
} catch (error) {
|
||||
console.error('审核风控告警失败:', error)
|
||||
window.alert('操作失败: ' + (error as Error).message)
|
||||
}
|
||||
}
|
||||
|
||||
const filteredRisks = computed(() => {
|
||||
@@ -184,30 +264,44 @@ const selectAll = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const batchEnable = () => {
|
||||
filteredRisks.value
|
||||
.filter((item) => selectedIds.value.includes(item.id))
|
||||
.forEach((item) => {
|
||||
if (item.status !== '生效') toggleRisk(item)
|
||||
})
|
||||
const batchEnable = async () => {
|
||||
const itemsToEnable = filteredRisks.value.filter(
|
||||
(item) => selectedIds.value.includes(item.id) && item.status !== '生效'
|
||||
)
|
||||
for (const item of itemsToEnable) {
|
||||
try {
|
||||
await toggleRisk(item)
|
||||
} catch (error) {
|
||||
console.error(`启用风控规则 ${item.type} 失败:`, error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const batchPause = () => {
|
||||
filteredRisks.value
|
||||
.filter((item) => selectedIds.value.includes(item.id))
|
||||
.forEach((item) => {
|
||||
if (item.status === '生效') toggleRisk(item)
|
||||
})
|
||||
const batchPause = async () => {
|
||||
const itemsToPause = filteredRisks.value.filter(
|
||||
(item) => selectedIds.value.includes(item.id) && item.status === '生效'
|
||||
)
|
||||
for (const item of itemsToPause) {
|
||||
try {
|
||||
await toggleRisk(item)
|
||||
} catch (error) {
|
||||
console.error(`暂停风控规则 ${item.type} 失败:`, error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const totalPages = computed(() => Math.max(1, Math.ceil(filteredRisks.value.length / pageSize)))
|
||||
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize)))
|
||||
|
||||
const pagedRisks = computed(() => {
|
||||
const start = page.value * pageSize
|
||||
return filteredRisks.value.slice(start, start + pageSize)
|
||||
// 后端分页模式下,risks 已经是当前页数据,无需前端再分页
|
||||
const pagedRisks = computed(() => risks.value)
|
||||
|
||||
// 翻页时重新加载数据
|
||||
watch(page, () => {
|
||||
loadRisks()
|
||||
})
|
||||
|
||||
watch([query, startDate, endDate], () => {
|
||||
page.value = 0
|
||||
loadRisks()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -5,9 +5,9 @@
|
||||
<h1 class="mos-title text-2xl font-semibold">角色管理</h1>
|
||||
<p class="mos-muted text-sm">管理系统角色及其权限配置。</p>
|
||||
</div>
|
||||
<button class="mos-btn mos-btn-accent" @click="openCreateDialog">
|
||||
<PermissionButton permission="role.index.manage.ALL" @click="openCreateDialog">
|
||||
新建角色
|
||||
</button>
|
||||
</PermissionButton>
|
||||
</header>
|
||||
|
||||
<!-- 角色列表 -->
|
||||
@@ -46,18 +46,20 @@
|
||||
{{ formatDate(role.createdAt) }}
|
||||
</td>
|
||||
<td class="py-3 text-right">
|
||||
<button class="mos-btn mos-btn-secondary !py-1 !px-2 !text-xs" @click="openEditDialog(role)">
|
||||
<PermissionButton permission="role.index.manage.ALL" variant="secondary" class="!py-1 !px-2 !text-xs" @click="openEditDialog(role)">
|
||||
编辑
|
||||
</button>
|
||||
<button class="mos-btn mos-btn-secondary !py-1 !px-2 !text-xs ml-2" @click="openPermissionDialog(role)">
|
||||
</PermissionButton>
|
||||
<PermissionButton permission="role.index.manage.ALL" variant="secondary" class="!py-1 !px-2 !text-xs ml-2" @click="openPermissionDialog(role)">
|
||||
权限
|
||||
</button>
|
||||
<button
|
||||
class="mos-btn mos-btn-danger !py-1 !px-2 !text-xs ml-2"
|
||||
</PermissionButton>
|
||||
<PermissionButton
|
||||
permission="role.index.manage.ALL"
|
||||
variant="danger"
|
||||
class="!py-1 !px-2 !text-xs ml-2"
|
||||
@click="handleDelete(role)"
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
</PermissionButton>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
@@ -132,8 +134,8 @@
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="selectedPermissions.includes(perm.permissionCode)"
|
||||
@change="togglePermission(perm.permissionCode)"
|
||||
:checked="!!perm.id && selectedPermissions.includes(perm.id)"
|
||||
@change="togglePermission(perm.id!)"
|
||||
/>
|
||||
{{ perm.permissionName }}
|
||||
</label>
|
||||
@@ -154,6 +156,16 @@
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { RoleLabels, type AdminRole, type DataScope, type RoleInfo, type PermissionInfo } from '../auth/roles'
|
||||
import roleService from '../services/role'
|
||||
import PermissionButton from '../components/PermissionButton.vue'
|
||||
|
||||
const showMessage = (msg: string, type: 'success' | 'error' = 'success') => {
|
||||
// 简单消息提示
|
||||
if (type === 'error') {
|
||||
alert(msg)
|
||||
} else {
|
||||
console.log(msg)
|
||||
}
|
||||
}
|
||||
|
||||
const roles = ref<RoleInfo[]>([])
|
||||
const allPermissions = ref<PermissionInfo[]>([])
|
||||
@@ -161,7 +173,7 @@ const dialogVisible = ref(false)
|
||||
const permissionDialogVisible = ref(false)
|
||||
const isEdit = ref(false)
|
||||
const currentRole = ref<RoleInfo | null>(null)
|
||||
const selectedPermissions = ref<string[]>([])
|
||||
const selectedPermissions = ref<number[]>([])
|
||||
|
||||
const form = ref({
|
||||
roleCode: '',
|
||||
@@ -246,9 +258,10 @@ const openEditDialog = (role: RoleInfo) => {
|
||||
const openPermissionDialog = async (role: RoleInfo) => {
|
||||
currentRole.value = role
|
||||
try {
|
||||
const perms = await roleService.getRolePermissions(role.id || 0)
|
||||
// 简化:直接使用权限代码
|
||||
selectedPermissions.value = []
|
||||
// 后端返回的是permission ID数组
|
||||
const permIds = await roleService.getRolePermissions(role.id || 0)
|
||||
// 直接使用返回的ID
|
||||
selectedPermissions.value = permIds
|
||||
permissionDialogVisible.value = true
|
||||
} catch (error) {
|
||||
console.error('加载权限失败:', error)
|
||||
@@ -293,18 +306,31 @@ const handleDelete = async (role: RoleInfo) => {
|
||||
}
|
||||
}
|
||||
|
||||
const togglePermission = (permCode: string) => {
|
||||
const index = selectedPermissions.value.indexOf(permCode)
|
||||
const togglePermission = (permId: number) => {
|
||||
const index = selectedPermissions.value.indexOf(permId)
|
||||
if (index > -1) {
|
||||
selectedPermissions.value.splice(index, 1)
|
||||
} else {
|
||||
selectedPermissions.value.push(permCode)
|
||||
selectedPermissions.value.push(permId)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSavePermissions = async () => {
|
||||
alert('权限保存成功(演示模式)')
|
||||
permissionDialogVisible.value = false
|
||||
try {
|
||||
if (!currentRole.value?.id) {
|
||||
throw new Error('请先选择一个角色')
|
||||
}
|
||||
await roleService.assignPermissions({
|
||||
roleId: currentRole.value.id,
|
||||
permissionIds: selectedPermissions.value
|
||||
})
|
||||
showMessage('权限保存成功')
|
||||
permissionDialogVisible.value = false
|
||||
// 刷新角色权限
|
||||
loadRoles()
|
||||
} catch (error) {
|
||||
showMessage('权限保存失败', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
|
||||
310
frontend/admin/src/views/SystemApiKeysView.vue
Normal file
310
frontend/admin/src/views/SystemApiKeysView.vue
Normal file
@@ -0,0 +1,310 @@
|
||||
<template>
|
||||
<section class="space-y-6">
|
||||
<header class="flex flex-wrap items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 class="mos-title text-2xl font-semibold">API Key 管理</h1>
|
||||
<p class="mos-muted mt-2 text-sm">管理API访问密钥,包括创建、启用、禁用、重置和删除操作。</p>
|
||||
</div>
|
||||
<PermissionButton permission="system.api-key.create.ALL" @click="showCreateModal = true">
|
||||
创建 API Key
|
||||
</PermissionButton>
|
||||
</header>
|
||||
|
||||
<div class="mos-card p-4">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full">
|
||||
<thead>
|
||||
<tr class="border-b border-mosquito-line text-left text-sm text-mosquito-ink/70">
|
||||
<th class="pb-3 font-medium">名称</th>
|
||||
<th class="pb-3 font-medium">Key前缀</th>
|
||||
<th class="pb-3 font-medium">绑定IP</th>
|
||||
<th class="pb-3 font-medium">权限</th>
|
||||
<th class="pb-3 font-medium">状态</th>
|
||||
<th class="pb-3 font-medium">创建时间</th>
|
||||
<th class="pb-3 font-medium">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="key in apiKeys" :key="key.id" class="border-b border-mosquito-line/50">
|
||||
<td class="py-3 text-sm font-medium text-mosquito-ink">{{ key.name }}</td>
|
||||
<td class="py-3 text-sm text-mosquito-ink font-mono">
|
||||
<button
|
||||
type="button"
|
||||
class="hover:text-blue-600 underline"
|
||||
@click="handleToggleShowKey(key.id)"
|
||||
:title="showKeyId === key.id ? '点击隐藏' : '点击查看'"
|
||||
>
|
||||
{{ showKeyId === key.id ? key.key : '••••••••••••' }}
|
||||
</button>
|
||||
</td>
|
||||
<td class="py-3 text-sm text-mosquito-ink">{{ key.ipWhitelist || '未限制' }}</td>
|
||||
<td class="py-3 text-sm text-mosquito-ink">{{ key.permissions }}</td>
|
||||
<td class="py-3">
|
||||
<span
|
||||
:class="key.status === 1 ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-700'"
|
||||
class="rounded-full px-2 py-1 text-xs font-semibold"
|
||||
>
|
||||
{{ key.status === 1 ? '启用' : '禁用' }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="py-3 text-sm text-mosquito-ink">{{ key.createdAt }}</td>
|
||||
<td class="py-3">
|
||||
<div class="flex gap-2">
|
||||
<PermissionButton
|
||||
v-if="key.status === 1 ? hasPermission('system.api-key.disable.ALL') : hasPermission('system.api-key.enable.ALL')"
|
||||
tag="button"
|
||||
:as-button="false"
|
||||
class="text-sm"
|
||||
:class="key.status === 1 ? 'text-amber-600 hover:underline' : 'text-green-600 hover:underline'"
|
||||
:permission="key.status === 1 ? 'system.api-key.disable.ALL' : 'system.api-key.enable.ALL'"
|
||||
@click="toggleKey(key)"
|
||||
>
|
||||
{{ key.status === 1 ? '禁用' : '启用' }}
|
||||
</PermissionButton>
|
||||
<PermissionButton
|
||||
v-if="hasPermission('system.api-key.reset.ALL')"
|
||||
tag="button"
|
||||
:as-button="false"
|
||||
class="text-sm text-blue-600 hover:underline"
|
||||
permission="system.api-key.reset.ALL"
|
||||
@click="resetKey(key)"
|
||||
>
|
||||
重置
|
||||
</PermissionButton>
|
||||
<PermissionButton
|
||||
v-if="hasPermission('system.api-key.delete.ALL')"
|
||||
tag="button"
|
||||
:as-button="false"
|
||||
class="text-sm text-rose-600 hover:underline"
|
||||
permission="system.api-key.delete.ALL"
|
||||
@click="deleteKey(key)"
|
||||
>
|
||||
删除
|
||||
</PermissionButton>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div v-if="!apiKeys.length" class="py-8 text-center text-mosquito-ink/60">
|
||||
暂无API Key
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 创建弹窗 -->
|
||||
<div v-if="showCreateModal" class="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div class="mos-card p-6 w-full max-w-md">
|
||||
<h3 class="text-lg font-semibold mb-4">创建 API Key</h3>
|
||||
<form @submit.prevent="createKey" class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-mosquito-ink mb-1">名称</label>
|
||||
<input v-model="newKey.name" class="mos-input w-full" placeholder="请输入名称" required />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-mosquito-ink mb-1">绑定活动(可选)</label>
|
||||
<select v-model="newKey.activityId" class="mos-input w-full">
|
||||
<option :value="undefined">不绑定活动</option>
|
||||
<option v-for="activity in activities" :key="activity.id" :value="activity.id">
|
||||
{{ activity.name }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex gap-3">
|
||||
<button type="submit" class="mos-btn mos-btn-primary flex-1">创建</button>
|
||||
<button type="button" class="mos-btn mos-btn-secondary flex-1" @click="showCreateModal = false">取消</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 重置确认弹窗 -->
|
||||
<div v-if="showResetModal" class="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div class="mos-card p-6 w-full max-w-md">
|
||||
<h3 class="text-lg font-semibold mb-4">重置 API Key</h3>
|
||||
<p class="text-sm text-mosquito-ink mb-4">确定要重置 "{{ selectedKey?.name }}" 吗?重置后旧Key将立即失效。</p>
|
||||
<div class="flex gap-3">
|
||||
<button class="mos-btn mos-btn-primary flex-1" @click="confirmReset">确认重置</button>
|
||||
<button class="mos-btn mos-btn-secondary flex-1" @click="showResetModal = false">取消</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { systemConfigService } from '@/services/systemConfig'
|
||||
import activityService from '@/services/activity'
|
||||
import PermissionButton from '@/components/PermissionButton.vue'
|
||||
import { usePermission } from '@/composables/usePermission'
|
||||
|
||||
const { hasPermission } = usePermission()
|
||||
|
||||
const showMessage = (msg: string, type: 'success' | 'error' = 'success') => {
|
||||
if (type === 'error') {
|
||||
alert(msg)
|
||||
} else {
|
||||
console.log(msg)
|
||||
}
|
||||
}
|
||||
|
||||
interface ApiKey {
|
||||
id: number
|
||||
name: string
|
||||
key: string
|
||||
ipWhitelist?: string
|
||||
permissions: string
|
||||
enabled: boolean
|
||||
createdAt: string
|
||||
status: number
|
||||
}
|
||||
|
||||
const showCreateModal = ref(false)
|
||||
const showResetModal = ref(false)
|
||||
const selectedKey = ref<ApiKey | null>(null)
|
||||
const loading = ref(false)
|
||||
|
||||
const newKey = ref({
|
||||
name: '',
|
||||
activityId: undefined as number | undefined,
|
||||
permissions: 'read'
|
||||
})
|
||||
|
||||
// 活动列表(用于API Key绑定)
|
||||
const activities = ref<{ id: number; name: string }[]>([])
|
||||
|
||||
const loadActivities = async () => {
|
||||
try {
|
||||
const list = await activityService.getActivities({ size: 100 })
|
||||
activities.value = list.map((a: any) => ({ id: a.id, name: a.name || `活动 #${a.id}` }))
|
||||
} catch (error) {
|
||||
console.error('加载活动列表失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const apiKeys = ref<ApiKey[]>([])
|
||||
const showKeyId = ref<number | null>(null)
|
||||
|
||||
// 加载API密钥列表
|
||||
const loadApiKeys = async () => {
|
||||
try {
|
||||
loading.value = true
|
||||
const keys = await systemConfigService.getApiKeys()
|
||||
apiKeys.value = keys.map((k: any) => ({
|
||||
id: k.id,
|
||||
name: k.name,
|
||||
key: k.prefix || k.apiKeyPrefix || '',
|
||||
ipWhitelist: k.ipWhitelist,
|
||||
permissions: k.permissions || 'read',
|
||||
enabled: k.enabled !== false,
|
||||
status: k.enabled !== false ? 1 : 0,
|
||||
createdAt: k.createdAt || k.createdTime || new Date().toISOString().split('T')[0]
|
||||
}))
|
||||
} catch (error: any) {
|
||||
console.error('加载API密钥列表失败:', error)
|
||||
showMessage(error.message || '加载API密钥列表失败', 'error')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const toggleKey = async (key: ApiKey) => {
|
||||
try {
|
||||
loading.value = true
|
||||
if (key.status === 1) {
|
||||
await systemConfigService.disableApiKey(key.id)
|
||||
showMessage('API密钥已禁用')
|
||||
} else {
|
||||
await systemConfigService.enableApiKey(key.id)
|
||||
showMessage('API密钥已启用')
|
||||
}
|
||||
await loadApiKeys()
|
||||
} catch (error: any) {
|
||||
showMessage(error.message || '操作失败', 'error')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const resetKey = (key: ApiKey) => {
|
||||
selectedKey.value = key
|
||||
showResetModal.value = true
|
||||
}
|
||||
|
||||
const confirmReset = async () => {
|
||||
if (selectedKey.value) {
|
||||
try {
|
||||
loading.value = true
|
||||
const newKeyValue = await systemConfigService.resetApiKey(selectedKey.value.id)
|
||||
showMessage(`API密钥已重置,新密钥: ${newKeyValue}`, 'success')
|
||||
showKeyId.value = null
|
||||
await loadApiKeys()
|
||||
} catch (error: any) {
|
||||
showMessage(error.message || '重置失败', 'error')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
showResetModal.value = false
|
||||
}
|
||||
|
||||
const deleteKey = async (key: ApiKey) => {
|
||||
if (confirm(`确定删除API Key "${key.name}"吗?`)) {
|
||||
try {
|
||||
loading.value = true
|
||||
await systemConfigService.deleteApiKey(key.id)
|
||||
showMessage('API密钥删除成功')
|
||||
await loadApiKeys()
|
||||
} catch (error: any) {
|
||||
showMessage(error.message || '删除API密钥失败', 'error')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const createKey = async () => {
|
||||
// 先加载活动列表
|
||||
if (activities.value.length === 0) {
|
||||
await loadActivities()
|
||||
}
|
||||
|
||||
try {
|
||||
loading.value = true
|
||||
const result = await systemConfigService.createApiKey(newKey.value.name, newKey.value.activityId)
|
||||
// 显示审批结果提示,不展示明文key
|
||||
showMessage(`${result.message} (审批记录ID: ${result.recordId})`, 'success')
|
||||
await loadApiKeys()
|
||||
} catch (error: any) {
|
||||
showMessage(error.message || '创建API密钥失败', 'error')
|
||||
} finally {
|
||||
loading.value = false
|
||||
showCreateModal.value = false
|
||||
newKey.value = { name: '', activityId: undefined, permissions: 'read' }
|
||||
}
|
||||
}
|
||||
|
||||
// 切换显示/隐藏密钥
|
||||
const handleToggleShowKey = async (id: number) => {
|
||||
if (showKeyId.value === id) {
|
||||
showKeyId.value = null
|
||||
return
|
||||
}
|
||||
try {
|
||||
const key = await systemConfigService.revealApiKey(id)
|
||||
const idx = apiKeys.value.findIndex((k: ApiKey) => k.id === id)
|
||||
if (idx >= 0) {
|
||||
apiKeys.value[idx].key = key
|
||||
}
|
||||
showKeyId.value = id
|
||||
} catch (error: any) {
|
||||
showMessage(error.message || '获取密钥失败', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadApiKeys()
|
||||
})
|
||||
</script>
|
||||
@@ -22,7 +22,10 @@
|
||||
|
||||
<!-- 系统参数 -->
|
||||
<div v-if="activeTab === 'params'" class="mos-card p-5">
|
||||
<div class="space-y-4">
|
||||
<div v-if="loading" class="py-8 text-center text-mosquito-ink/60">
|
||||
加载中...
|
||||
</div>
|
||||
<div v-else class="space-y-4">
|
||||
<div v-for="config in systemParams" :key="config.key" class="flex items-center justify-between py-3 border-b border-mosquito-line">
|
||||
<div>
|
||||
<div class="font-semibold">{{ config.label }}</div>
|
||||
@@ -51,8 +54,11 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-6 flex justify-end">
|
||||
<button class="mos-btn mos-btn-accent" @click="saveParams">保存配置</button>
|
||||
<div class="mt-6 flex justify-end gap-2">
|
||||
<button class="mos-btn mos-btn-secondary" @click="loadConfigs">刷新</button>
|
||||
<PermissionButton permission="system.config.manage.ALL" :hide-when-no-permission="true" @click="saveParams">
|
||||
{{ saving ? '保存中...' : '保存配置' }}
|
||||
</PermissionButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -65,7 +71,9 @@
|
||||
<div class="text-xs text-mosquito-ink/70">缓存活动列表和统计数据</div>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button class="mos-btn mos-btn-secondary" @click="clearCache('activity')">清除缓存</button>
|
||||
<PermissionButton permission="system.cache.manage.ALL" variant="secondary" :hide-when-no-permission="true" @click="clearCache('activity')">
|
||||
清除缓存
|
||||
</PermissionButton>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-between py-3 border-b border-mosquito-line">
|
||||
@@ -74,7 +82,9 @@
|
||||
<div class="text-xs text-mosquito-ink/70">缓存用户信息和权限数据</div>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button class="mos-btn mos-btn-secondary" @click="clearCache('user')">清除缓存</button>
|
||||
<PermissionButton permission="system.cache.manage.ALL" variant="secondary" :hide-when-no-permission="true" @click="clearCache('user')">
|
||||
清除缓存
|
||||
</PermissionButton>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-between py-3 border-b border-mosquito-line">
|
||||
@@ -83,7 +93,9 @@
|
||||
<div class="text-xs text-mosquito-ink/70">缓存奖励配置和发放记录</div>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button class="mos-btn mos-btn-secondary" @click="clearCache('reward')">清除缓存</button>
|
||||
<PermissionButton permission="system.cache.manage.ALL" variant="secondary" :hide-when-no-permission="true" @click="clearCache('reward')">
|
||||
清除缓存
|
||||
</PermissionButton>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-between py-3">
|
||||
@@ -92,7 +104,9 @@
|
||||
<div class="text-xs text-mosquito-ink/70">清除所有系统缓存</div>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button class="mos-btn mos-btn-danger" @click="clearCache('all')">清除全部缓存</button>
|
||||
<PermissionButton permission="system.cache.manage.ALL" variant="danger" :hide-when-no-permission="true" @click="clearCache('all')">
|
||||
清除全部缓存
|
||||
</PermissionButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -102,7 +116,9 @@
|
||||
<div v-if="activeTab === 'apiKey'" class="mos-card p-5">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<div class="font-semibold">API密钥管理</div>
|
||||
<button class="mos-btn mos-btn-accent" @click="createApiKey">创建新密钥</button>
|
||||
<PermissionButton permission="system.api-key.create.ALL" :hide-when-no-permission="true" @click="createApiKey">
|
||||
创建新密钥
|
||||
</PermissionButton>
|
||||
</div>
|
||||
<table class="w-full">
|
||||
<thead>
|
||||
@@ -125,12 +141,18 @@
|
||||
</td>
|
||||
<td class="py-3 text-sm">{{ key.createdAt }}</td>
|
||||
<td class="py-3">
|
||||
<button class="mos-btn mos-btn-secondary !py-1 !px-2 !text-xs" @click="toggleShowKey(key.id)">
|
||||
{{ showKeyId === key.id ? '隐藏' : '显示' }}
|
||||
</button>
|
||||
<button class="mos-btn mos-btn-danger !py-1 !px-2 !text-xs ml-2" @click="deleteApiKey(key.id)">
|
||||
删除
|
||||
</button>
|
||||
<PermissionButton permission="system.api-key.view.ALL" variant="secondary" @click="handleToggleShowKey(key.id)">
|
||||
<span class="!py-1 !px-2 !text-xs">{{ showKeyId === key.id ? '隐藏' : '显示' }}</span>
|
||||
</PermissionButton>
|
||||
<PermissionButton :permission="key.status === 1 ? 'system.api-key.disable.ALL' : 'system.api-key.enable.ALL'" variant="secondary" @click="toggleApiKeyStatus(key.id, key.status)">
|
||||
<span class="!py-1 !px-2 !text-xs ml-2">{{ key.status === 1 ? '禁用' : '启用' }}</span>
|
||||
</PermissionButton>
|
||||
<PermissionButton permission="system.api-key.reset.ALL" variant="secondary" @click="handleResetApiKey(key.id)">
|
||||
<span class="!py-1 !px-2 !text-xs ml-2">重置</span>
|
||||
</PermissionButton>
|
||||
<PermissionButton permission="system.api-key.delete.ALL" variant="danger" @click="deleteApiKey(key.id)">
|
||||
<span class="!py-1 !px-2 !text-xs ml-2">删除</span>
|
||||
</PermissionButton>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
@@ -143,10 +165,35 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { systemConfigService } from '@/services/systemConfig'
|
||||
import activityService from '@/services/activity'
|
||||
import PermissionButton from '../components/PermissionButton.vue'
|
||||
|
||||
const showMessage = (msg: string, type: 'success' | 'error' = 'success') => {
|
||||
if (type === 'error') {
|
||||
alert(msg)
|
||||
} else {
|
||||
console.log(msg)
|
||||
}
|
||||
}
|
||||
|
||||
const activeTab = ref('params')
|
||||
const showKeyId = ref<number | null>(null)
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const apiKeyLoading = ref(false)
|
||||
|
||||
// 活动列表(用于API Key绑定)
|
||||
const activities = ref<{ id: number; name: string }[]>([])
|
||||
const loadActivities = async () => {
|
||||
try {
|
||||
const list = await activityService.getActivities({ size: 100 })
|
||||
activities.value = list.map((a: any) => ({ id: a.id, name: a.name || `活动 #${a.id}` }))
|
||||
} catch (error) {
|
||||
console.error('加载活动列表失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const tabs = [
|
||||
{ key: 'params', label: '系统参数' },
|
||||
@@ -154,7 +201,15 @@ const tabs = [
|
||||
{ key: 'apiKey', label: 'API密钥' }
|
||||
]
|
||||
|
||||
const systemParams = ref([
|
||||
interface ConfigItem {
|
||||
key: string
|
||||
label: string
|
||||
description: string
|
||||
value: string | number | boolean
|
||||
type: 'string' | 'number' | 'boolean'
|
||||
}
|
||||
|
||||
const systemParams = ref<ConfigItem[]>([
|
||||
{ key: 'reward.max.points', label: '单次奖励上限', description: '单次奖励发放的最大积分值', value: 10000, type: 'number' },
|
||||
{ key: 'activity.max.count', label: '最大活动数', description: '系统允许创建的最大活动数量', value: 100, type: 'number' },
|
||||
{ key: 'risk.callback.threshold', label: '回调失败阈值', description: '触发告警的回调失败率(%)', value: 5, type: 'number' },
|
||||
@@ -163,25 +218,95 @@ const systemParams = ref([
|
||||
{ key: 'reward.batch.size', label: '批量发放大小', description: '奖励批量发放的每批数量', value: 200, type: 'number' }
|
||||
])
|
||||
|
||||
const apiKeys = ref([
|
||||
{ id: 1, name: '生产环境密钥', key: 'mk_prod_xxxxxxxxxxxxx', status: 1, createdAt: '2026-01-15' },
|
||||
{ id: 2, name: '测试环境密钥', key: 'mk_test_xxxxxxxxxxxxx', status: 1, createdAt: '2026-02-01' }
|
||||
])
|
||||
const apiKeys = ref<any[]>([])
|
||||
|
||||
const saveParams = () => {
|
||||
alert('配置保存成功(演示)')
|
||||
}
|
||||
|
||||
const clearCache = (type: string) => {
|
||||
if (confirm(`确定要清除${type === 'all' ? '全部' : type}缓存吗?`)) {
|
||||
alert('缓存清除成功(演示)')
|
||||
// 从后端加载配置
|
||||
const loadConfigs = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await systemConfigService.getConfigs()
|
||||
if (result && result.length > 0) {
|
||||
// 将后端配置合并到本地
|
||||
result.forEach((config: any) => {
|
||||
const param = systemParams.value.find(p => p.key === config.key)
|
||||
if (param) {
|
||||
if (param.type === 'number') {
|
||||
param.value = Number(config.value) || 0
|
||||
} else if (param.type === 'boolean') {
|
||||
param.value = config.value === 'true'
|
||||
} else {
|
||||
param.value = config.value
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('加载配置失败:', error)
|
||||
showMessage(error.message || '加载配置失败', 'error')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const createApiKey = () => {
|
||||
// 保存配置到后端
|
||||
const saveParams = async () => {
|
||||
saving.value = true
|
||||
try {
|
||||
for (const param of systemParams.value) {
|
||||
await systemConfigService.updateConfig(param.key, String(param.value))
|
||||
}
|
||||
showMessage('配置保存成功')
|
||||
await loadConfigs()
|
||||
} catch (error: any) {
|
||||
console.error('保存配置失败:', error)
|
||||
showMessage(error.message || '保存配置失败', 'error')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const clearCache = async (type: string) => {
|
||||
if (confirm(`确定要清除${type === 'all' ? '全部' : type}缓存吗?`)) {
|
||||
try {
|
||||
await systemConfigService.clearCache(type === 'all' ? undefined : type)
|
||||
showMessage('缓存清除成功')
|
||||
} catch (error: any) {
|
||||
showMessage(error.message || '清除缓存失败', 'error')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const createApiKey = async () => {
|
||||
// 先加载活动列表
|
||||
if (activities.value.length === 0) {
|
||||
await loadActivities()
|
||||
}
|
||||
|
||||
const name = prompt('请输入密钥名称:')
|
||||
if (name) {
|
||||
alert('API密钥创建成功(演示)')
|
||||
if (!name) return
|
||||
|
||||
// 让用户选择活动
|
||||
const activityOptions = activities.value.map((a, i) => `${i + 1}. ${a.name} (ID: ${a.id})`).join('\n')
|
||||
const activityIndexStr = prompt(`请选择绑定的活动(输入编号):\n${activityOptions}`)
|
||||
if (!activityIndexStr) return
|
||||
|
||||
const activityIndex = parseInt(activityIndexStr, 10) - 1
|
||||
if (isNaN(activityIndex) || activityIndex < 0 || activityIndex >= activities.value.length) {
|
||||
showMessage('请选择有效的活动编号', 'error')
|
||||
return
|
||||
}
|
||||
|
||||
const selectedActivity = activities.value[activityIndex]
|
||||
|
||||
try {
|
||||
apiKeyLoading.value = true
|
||||
const newKey = await systemConfigService.createApiKey(name, selectedActivity.id)
|
||||
showMessage(`API密钥创建成功: ${newKey}`, 'success')
|
||||
await loadApiKeys()
|
||||
} catch (error: any) {
|
||||
showMessage(error.message || '创建API密钥失败', 'error')
|
||||
} finally {
|
||||
apiKeyLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -189,9 +314,97 @@ const toggleShowKey = (id: number) => {
|
||||
showKeyId.value = showKeyId.value === id ? null : id
|
||||
}
|
||||
|
||||
const deleteApiKey = (id: number) => {
|
||||
const deleteApiKey = async (id: number) => {
|
||||
if (confirm('确定要删除这个API密钥吗?')) {
|
||||
alert('API密钥删除成功(演示)')
|
||||
try {
|
||||
apiKeyLoading.value = true
|
||||
await systemConfigService.deleteApiKey(id)
|
||||
showMessage('API密钥删除成功')
|
||||
await loadApiKeys()
|
||||
} catch (error: any) {
|
||||
showMessage(error.message || '删除API密钥失败', 'error')
|
||||
} finally {
|
||||
apiKeyLoading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 加载API密钥列表
|
||||
const loadApiKeys = async () => {
|
||||
try {
|
||||
apiKeyLoading.value = true
|
||||
const keys = await systemConfigService.getApiKeys()
|
||||
apiKeys.value = keys.map((k: any) => ({
|
||||
id: k.id,
|
||||
name: k.name,
|
||||
key: k.prefix || k.apiKeyPrefix || '',
|
||||
status: k.enabled !== false ? 1 : 0,
|
||||
createdAt: k.createdAt || k.createdTime || new Date().toISOString().split('T')[0]
|
||||
}))
|
||||
} catch (error: any) {
|
||||
console.error('加载API密钥列表失败:', error)
|
||||
showMessage(error.message || '加载API密钥列表失败', 'error')
|
||||
} finally {
|
||||
apiKeyLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 切换显示/隐藏密钥
|
||||
const handleToggleShowKey = async (id: number) => {
|
||||
if (showKeyId.value === id) {
|
||||
showKeyId.value = null
|
||||
return
|
||||
}
|
||||
try {
|
||||
const key = await systemConfigService.revealApiKey(id)
|
||||
const idx = apiKeys.value.findIndex((k: any) => k.id === id)
|
||||
if (idx >= 0) {
|
||||
apiKeys.value[idx].key = key
|
||||
}
|
||||
showKeyId.value = id
|
||||
} catch (error: any) {
|
||||
showMessage(error.message || '获取密钥失败', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
// 启用/禁用API密钥
|
||||
const toggleApiKeyStatus = async (id: number, currentStatus: number) => {
|
||||
try {
|
||||
apiKeyLoading.value = true
|
||||
if (currentStatus === 1) {
|
||||
await systemConfigService.disableApiKey(id)
|
||||
showMessage('API密钥已禁用')
|
||||
} else {
|
||||
await systemConfigService.enableApiKey(id)
|
||||
showMessage('API密钥已启用')
|
||||
}
|
||||
await loadApiKeys()
|
||||
} catch (error: any) {
|
||||
showMessage(error.message || '操作失败', 'error')
|
||||
} finally {
|
||||
apiKeyLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 重置API密钥
|
||||
const handleResetApiKey = async (id: number) => {
|
||||
if (confirm('确定要重置这个API密钥吗?重置后旧密钥将失效。')) {
|
||||
try {
|
||||
apiKeyLoading.value = true
|
||||
const newKey = await systemConfigService.resetApiKey(id)
|
||||
showMessage(`API密钥已重置,新密钥: ${newKey}`, 'success')
|
||||
showKeyId.value = null
|
||||
await loadApiKeys()
|
||||
} catch (error: any) {
|
||||
showMessage(error.message || '重置失败', 'error')
|
||||
} finally {
|
||||
apiKeyLoading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadConfigs()
|
||||
loadApiKeys()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -11,6 +11,52 @@
|
||||
<div class="text-xs text-mosquito-ink/70">角色:{{ roleLabel(user?.role) }}</div>
|
||||
<div class="text-xs text-mosquito-ink/70">状态:{{ user?.status }}</div>
|
||||
<div class="text-xs text-mosquito-ink/70">直属上级:{{ user?.managerName }}</div>
|
||||
|
||||
<!-- PRD要求:用户详情操作按钮 -->
|
||||
<div class="border-t border-mosquito-line pt-4 space-y-2">
|
||||
<div class="text-sm font-semibold text-mosquito-ink">用户操作</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<!-- 冻结/解冻按钮 - 需要 freeze 或 unfreeze 权限 -->
|
||||
<button
|
||||
v-if="user?.status === '冻结' ? hasPermission('user.index.unfreeze.ALL') : hasPermission('user.index.freeze.ALL')"
|
||||
class="mos-btn !py-1 !px-2 !text-xs"
|
||||
:class="user?.status === '冻结' ? 'mos-btn-secondary' : 'mos-btn-secondary'"
|
||||
@click="toggleFreeze"
|
||||
>
|
||||
{{ user?.status === '冻结' ? '解冻' : '冻结' }}
|
||||
</button>
|
||||
<!-- 白名单按钮 - 需要 whitelist.add 或 whitelist.remove 权限 -->
|
||||
<button
|
||||
v-if="isInWhitelist ? hasPermission('user.whitelist.remove.ALL') : hasPermission('user.whitelist.add.ALL')"
|
||||
class="mos-btn !py-1 !px-2 !text-xs"
|
||||
:class="isInWhitelist ? 'mos-btn-primary' : 'mos-btn-secondary'"
|
||||
@click="toggleWhitelist"
|
||||
>
|
||||
{{ isInWhitelist ? '取消白名单' : '加入白名单' }}
|
||||
</button>
|
||||
<!-- 黑名单按钮 - 需要 user.index.update.ALL 权限 -->
|
||||
<button
|
||||
v-if="hasPermission('user.index.update.ALL')"
|
||||
class="mos-btn !py-1 !px-2 !text-xs"
|
||||
:class="isInBlacklist ? 'mos-btn-accent' : 'mos-btn-secondary'"
|
||||
@click="toggleBlacklist"
|
||||
>
|
||||
{{ isInBlacklist ? '取消黑名单' : '加入黑名单' }}
|
||||
</button>
|
||||
<!-- 积分调整按钮 - 需要 user.points.adjust.ALL 权限 -->
|
||||
<button
|
||||
v-if="hasPermission('user.points.adjust.ALL')"
|
||||
class="mos-btn mos-btn-secondary !py-1 !px-2 !text-xs"
|
||||
@click="showPointsModal = true"
|
||||
>
|
||||
积分调整
|
||||
</button>
|
||||
<!-- 投诉记录按钮 -->
|
||||
<button class="mos-btn mos-btn-secondary !py-1 !px-2 !text-xs" @click="openComplaintModal">
|
||||
投诉记录
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mos-card lg:col-span-2 p-5 space-y-4">
|
||||
@@ -39,9 +85,7 @@
|
||||
<div class="text-sm font-semibold text-mosquito-ink">发起角色变更申请</div>
|
||||
<div class="mt-3 flex flex-wrap items-center gap-2">
|
||||
<select class="mos-input !py-1 !px-2 !text-xs" v-model="targetRole">
|
||||
<option value="admin">管理员</option>
|
||||
<option value="operator">运营</option>
|
||||
<option value="viewer">只读</option>
|
||||
<option v-for="opt in roleOptions" :key="opt.value" :value="opt.value">{{ opt.label }}</option>
|
||||
</select>
|
||||
<input class="mos-input !py-1 !px-2 !text-xs w-56" v-model="reason" placeholder="填写申请原因" />
|
||||
<button class="mos-btn mos-btn-accent !py-1 !px-2 !text-xs" @click="submitRequest">提交申请</button>
|
||||
@@ -49,6 +93,53 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 积分调整弹窗 -->
|
||||
<div v-if="showPointsModal" class="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div class="mos-card p-6 w-full max-w-md">
|
||||
<h3 class="text-lg font-semibold mb-4">积分调整</h3>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-mosquito-ink mb-1">调整类型</label>
|
||||
<select v-model="pointsAction" class="mos-input w-full">
|
||||
<option value="add">增加积分</option>
|
||||
<option value="subtract">扣减积分</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-mosquito-ink mb-1">积分数量</label>
|
||||
<input v-model.number="pointsAmount" type="number" class="mos-input w-full" placeholder="请输入积分数量" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-mosquito-ink mb-1">调整原因</label>
|
||||
<textarea v-model="pointsReason" class="mos-input w-full h-20" placeholder="请说明调整原因"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-3 mt-6">
|
||||
<button class="mos-btn mos-btn-primary flex-1" @click="confirmPointsAdjust">确认</button>
|
||||
<button class="mos-btn mos-btn-secondary flex-1" @click="showPointsModal = false">取消</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 投诉记录弹窗 -->
|
||||
<div v-if="showComplaintModal" class="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div class="mos-card p-6 w-full max-w-md">
|
||||
<h3 class="text-lg font-semibold mb-4">投诉记录</h3>
|
||||
<div class="space-y-3 max-h-64 overflow-y-auto">
|
||||
<div v-for="complaint in complaints" :key="complaint.id" class="rounded-lg border border-mosquito-line p-3 text-sm">
|
||||
<div class="font-medium">{{ complaint.title }}</div>
|
||||
<div class="text-xs text-mosquito-ink/70 mt-1">{{ complaint.content }}</div>
|
||||
<div class="text-xs text-mosquito-ink/50 mt-1">{{ complaint.time }}</div>
|
||||
</div>
|
||||
<div v-if="!complaints.length" class="text-center text-mosquito-ink/60 py-4">暂无投诉记录</div>
|
||||
</div>
|
||||
<div class="flex gap-3 mt-4">
|
||||
<button class="mos-btn mos-btn-primary flex-1" @click="addComplaint">添加投诉</button>
|
||||
<button class="mos-btn mos-btn-secondary flex-1" @click="showComplaintModal = false">关闭</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -57,24 +148,174 @@ import { computed, ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useUserStore } from '../stores/users'
|
||||
import { useAuditStore } from '../stores/audit'
|
||||
import { usePermission } from '../composables/usePermission'
|
||||
import { userService } from '../services/user'
|
||||
import { RoleLabels, type AdminRole, type Permission } from '../auth/roles'
|
||||
|
||||
const route = useRoute()
|
||||
const store = useUserStore()
|
||||
const auditStore = useAuditStore()
|
||||
const { hasPermission } = usePermission()
|
||||
const userId = computed(() => String(route.params.id))
|
||||
|
||||
// 白名单/黑名单状态
|
||||
const isInWhitelist = ref(false)
|
||||
const isInBlacklist = ref(false)
|
||||
|
||||
// 积分调整弹窗
|
||||
const showPointsModal = ref(false)
|
||||
const pointsAction = ref('add')
|
||||
const pointsAmount = ref(0)
|
||||
const pointsReason = ref('')
|
||||
|
||||
// 投诉记录弹窗
|
||||
const showComplaintModal = ref(false)
|
||||
const complaints = ref<{ id: string; title: string; content: string; time: string }[]>([])
|
||||
|
||||
const toggleFreeze = async () => {
|
||||
if (!user.value) return
|
||||
try {
|
||||
const action = user.value.status === '冻结' ? '解冻' : '冻结'
|
||||
if (action === '冻结') {
|
||||
await userService.freezeUser(Number(user.value.id))
|
||||
} else {
|
||||
await userService.unfreezeUser(Number(user.value.id))
|
||||
}
|
||||
auditStore.addLog(`${action}用户`, user.value.name)
|
||||
// 刷新用户数据
|
||||
await store.fetchUsers()
|
||||
} catch (error) {
|
||||
console.error('Failed to toggle freeze:', error)
|
||||
alert((error instanceof Error ? error.message : '操作失败'))
|
||||
}
|
||||
}
|
||||
|
||||
const toggleWhitelist = async () => {
|
||||
const adding = !isInWhitelist.value
|
||||
const previousState = isInWhitelist.value
|
||||
isInWhitelist.value = adding
|
||||
auditStore.addLog(adding ? '加入白名单' : '取消白名单', user.value?.name || '')
|
||||
try {
|
||||
if (adding) {
|
||||
await userService.addToWhitelist(Number(user.value!.id))
|
||||
} else {
|
||||
await userService.removeFromWhitelist(Number(user.value!.id))
|
||||
}
|
||||
} catch (error) {
|
||||
// 失败时回滚UI状态
|
||||
isInWhitelist.value = previousState
|
||||
console.error('白名单操作失败:', error)
|
||||
alert(error instanceof Error ? error.message : '白名单操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
const toggleBlacklist = async () => {
|
||||
const adding = !isInBlacklist.value
|
||||
const previousState = isInBlacklist.value
|
||||
isInBlacklist.value = adding
|
||||
auditStore.addLog(adding ? '加入黑名单' : '取消黑名单', user.value?.name || '')
|
||||
try {
|
||||
if (adding) {
|
||||
await userService.addToBlacklist(Number(user.value!.id))
|
||||
} else {
|
||||
await userService.removeFromBlacklist(Number(user.value!.id))
|
||||
}
|
||||
} catch (error) {
|
||||
// 失败时回滚UI状态
|
||||
isInBlacklist.value = previousState
|
||||
console.error('黑名单操作失败:', error)
|
||||
alert(error instanceof Error ? error.message : '黑名单操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
const confirmPointsAdjust = async () => {
|
||||
if (!user.value || pointsAmount.value <= 0) return
|
||||
try {
|
||||
// 转换动作:add为正数,reduce为负数
|
||||
const amount = pointsAction.value === 'add' ? pointsAmount.value : -pointsAmount.value
|
||||
const newPoints = await userService.adjustPoints(Number(user.value.id), amount, pointsReason.value)
|
||||
auditStore.addLog(
|
||||
`${pointsAction.value === 'add' ? '增加' : '扣减'}积分`,
|
||||
`${user.value.name}: ${pointsAmount.value}分 - ${pointsReason.value},新积分: ${newPoints}`
|
||||
)
|
||||
showPointsModal.value = false
|
||||
pointsAmount.value = 0
|
||||
pointsReason.value = ''
|
||||
} catch (error) {
|
||||
console.error('Failed to adjust points:', error)
|
||||
alert((error instanceof Error ? error.message : '积分调整失败'))
|
||||
}
|
||||
}
|
||||
|
||||
const addComplaint = async () => {
|
||||
const title = prompt('请输入投诉标题')
|
||||
if (!title) return
|
||||
const content = prompt('请输入投诉内容')
|
||||
if (!content) return
|
||||
|
||||
try {
|
||||
await userService.addComplaint(Number(user.value!.id), { title, content })
|
||||
// 刷新投诉列表
|
||||
await loadComplaints()
|
||||
auditStore.addLog('添加投诉记录', `${user.value?.name}: ${title}`)
|
||||
} catch (error) {
|
||||
console.error('添加投诉记录失败:', error)
|
||||
alert(error instanceof Error ? error.message : '添加投诉记录失败')
|
||||
}
|
||||
}
|
||||
|
||||
const loadComplaints = async () => {
|
||||
if (!user.value) return
|
||||
try {
|
||||
const data = await userService.getComplaints(Number(user.value.id))
|
||||
complaints.value = data.map((c: any) => ({
|
||||
id: c.id?.toString() || '',
|
||||
title: c.title || '',
|
||||
content: c.content || '',
|
||||
time: c.createdAt ? new Date(c.createdAt).toLocaleString('zh-CN') : ''
|
||||
}))
|
||||
} catch (error) {
|
||||
console.error('加载投诉记录失败:', error)
|
||||
complaints.value = []
|
||||
}
|
||||
}
|
||||
|
||||
const openComplaintModal = async () => {
|
||||
await loadComplaints()
|
||||
showComplaintModal.value = true
|
||||
}
|
||||
|
||||
const user = computed(() => store.byId(userId.value))
|
||||
const history = computed(() => store.roleRequests.filter((item) => item.userId === userId.value))
|
||||
const targetRole = ref('operator')
|
||||
|
||||
// 角色选项(15个角色)
|
||||
const roleOptions: { value: AdminRole; label: string }[] = [
|
||||
{ value: 'super_admin', label: '超级管理员' },
|
||||
{ value: 'system_admin', label: '系统管理员' },
|
||||
{ value: 'operation_director', label: '运营总监' },
|
||||
{ value: 'operation_manager', label: '运营经理' },
|
||||
{ value: 'operation_specialist', label: '运营专员' },
|
||||
{ value: 'marketing_director', label: '市场总监' },
|
||||
{ value: 'marketing_manager', label: '市场经理' },
|
||||
{ value: 'marketing_specialist', label: '市场专员' },
|
||||
{ value: 'finance_manager', label: '财务经理' },
|
||||
{ value: 'finance_specialist', label: '财务专员' },
|
||||
{ value: 'risk_manager', label: '风控经理' },
|
||||
{ value: 'risk_specialist', label: '风控专员' },
|
||||
{ value: 'cs_manager', label: '客服主管' },
|
||||
{ value: 'cs_agent', label: '客服专员' },
|
||||
{ value: 'auditor', label: '审计员' },
|
||||
{ value: 'viewer', label: '只读' }
|
||||
]
|
||||
|
||||
const targetRole = ref<AdminRole>('operation_manager')
|
||||
const reason = ref('')
|
||||
const statusFilter = ref('')
|
||||
const startDate = ref('')
|
||||
const endDate = ref('')
|
||||
|
||||
const roleLabel = (role?: string) => {
|
||||
if (role === 'admin') return '管理员'
|
||||
if (role === 'operator') return '运营'
|
||||
return '只读'
|
||||
return RoleLabels[role as AdminRole] || role || '未知'
|
||||
}
|
||||
|
||||
const formatDate = (value?: string) => (value ? new Date(value).toLocaleString('zh-CN') : '--')
|
||||
|
||||
@@ -32,9 +32,7 @@
|
||||
<input class="mos-input !py-1 !px-2 !text-xs w-full md:w-56" v-model="staffQuery" placeholder="搜索姓名/邮箱" />
|
||||
<select class="mos-input !py-1 !px-2 !text-xs" v-model="roleFilter">
|
||||
<option value="">全部角色</option>
|
||||
<option value="admin">管理员</option>
|
||||
<option value="operator">运营</option>
|
||||
<option value="viewer">只读</option>
|
||||
<option v-for="role in roleOptions" :key="role.value" :value="role.value">{{ role.label }}</option>
|
||||
</select>
|
||||
<select class="mos-input !py-1 !px-2 !text-xs" v-model="statusFilter">
|
||||
<option value="">全部状态</option>
|
||||
@@ -44,8 +42,12 @@
|
||||
<button class="mos-btn mos-btn-secondary !py-1 !px-2 !text-xs" @click="selectAllStaff">
|
||||
{{ allStaffSelected ? '取消全选' : '全选' }}
|
||||
</button>
|
||||
<button class="mos-btn mos-btn-secondary !py-1 !px-2 !text-xs" @click="batchEnable">批量启用</button>
|
||||
<button class="mos-btn mos-btn-secondary !py-1 !px-2 !text-xs" @click="batchDisable">批量禁用</button>
|
||||
<PermissionButton permission="user.index.unfreeze.ALL" variant="secondary" :hide-when-no-permission="true" @click="batchEnable">
|
||||
批量启用
|
||||
</PermissionButton>
|
||||
<PermissionButton permission="user.index.freeze.ALL" variant="secondary" :hide-when-no-permission="true" @click="batchDisable">
|
||||
批量禁用
|
||||
</PermissionButton>
|
||||
</div>
|
||||
<div v-else class="flex flex-wrap items-center gap-3">
|
||||
<label class="text-xs text-mosquito-ink/70">活动</label>
|
||||
@@ -65,6 +67,9 @@
|
||||
</div>
|
||||
</template>
|
||||
<template #actions>
|
||||
<PermissionButton permission="user.index.export.ALL" variant="secondary" :hide-when-no-permission="true" @click="handleExportUsers">
|
||||
导出用户
|
||||
</PermissionButton>
|
||||
<RouterLink v-if="tab === 'staff'" to="/users/invite" class="mos-btn mos-btn-accent">邀请用户</RouterLink>
|
||||
</template>
|
||||
<template #default>
|
||||
@@ -93,9 +98,24 @@
|
||||
<button class="mos-btn mos-btn-secondary !py-1 !px-2 !text-xs" @click.stop="requestRole(user)">
|
||||
申请变更
|
||||
</button>
|
||||
<button class="mos-btn mos-btn-secondary !py-1 !px-2 !text-xs" @click.stop="toggleUser(user)">
|
||||
{{ user.status === '冻结' ? '启用' : '禁用' }}
|
||||
</button>
|
||||
<PermissionButton
|
||||
v-if="user.status === '冻结'"
|
||||
permission="user.index.unfreeze.ALL"
|
||||
variant="secondary"
|
||||
:hide-when-no-permission="true"
|
||||
@click.stop="toggleUser(user)"
|
||||
>
|
||||
启用
|
||||
</PermissionButton>
|
||||
<PermissionButton
|
||||
v-else
|
||||
permission="user.index.freeze.ALL"
|
||||
variant="secondary"
|
||||
:hide-when-no-permission="true"
|
||||
@click.stop="toggleUser(user)"
|
||||
>
|
||||
禁用
|
||||
</PermissionButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -136,14 +156,25 @@ import { useUserStore } from '../stores/users'
|
||||
import { useActivityStore } from '../stores/activities'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import ListSection from '../components/ListSection.vue'
|
||||
import PermissionButton from '../components/PermissionButton.vue'
|
||||
import { RoleLabels, type AdminRole } from '../auth/roles'
|
||||
|
||||
// 15角色体系选项
|
||||
const roleOptions = computed(() => {
|
||||
const roles: { value: string; label: string }[] = []
|
||||
for (const [key, label] of Object.entries(RoleLabels)) {
|
||||
roles.push({ value: key, label })
|
||||
}
|
||||
return roles
|
||||
})
|
||||
|
||||
type UserItem = {
|
||||
id: string
|
||||
name: string
|
||||
email: string
|
||||
role: string
|
||||
status: string
|
||||
managerName?: string
|
||||
}
|
||||
|
||||
type ActivityUser = {
|
||||
@@ -152,7 +183,9 @@ type ActivityUser = {
|
||||
status: string
|
||||
}
|
||||
|
||||
const users = ref<UserItem[]>([])
|
||||
import type { UserAccount } from '../stores/users'
|
||||
|
||||
const users = ref<UserAccount[]>([])
|
||||
const service = useDataService()
|
||||
const auditStore = useAuditStore()
|
||||
const userStore = useUserStore()
|
||||
@@ -170,24 +203,59 @@ const activityQuery = ref('')
|
||||
const activityStatusFilter = ref('')
|
||||
const staffPage = ref(0)
|
||||
const staffPageSize = 6
|
||||
const staffTotal = ref(0)
|
||||
const activityPage = ref(0)
|
||||
const activityPageSize = 6
|
||||
|
||||
onMounted(async () => {
|
||||
const [userList, invites, requests] = await Promise.all([
|
||||
service.getUsers(),
|
||||
await loadStaffUsers()
|
||||
const [invites, requests] = await Promise.all([
|
||||
service.getInvites(),
|
||||
service.getRoleRequests()
|
||||
])
|
||||
users.value = userList
|
||||
userStore.init(userList, invites, requests)
|
||||
userStore.init(users.value, invites, requests)
|
||||
activities.value = activityStore.items.map((item) => ({ id: item.id, name: item.name }))
|
||||
selectedActivityId.value = activities.value[0]?.id ?? 0
|
||||
})
|
||||
|
||||
const toggleUser = (user: UserItem) => {
|
||||
user.status = user.status === '冻结' ? '正常' : '冻结'
|
||||
auditStore.addLog(user.status === '冻结' ? '禁用用户' : '启用用户', user.name)
|
||||
// 加载员工用户(后端分页)
|
||||
const loadStaffUsers = async () => {
|
||||
try {
|
||||
const result = await service.getUsersPage({
|
||||
page: staffPage.value,
|
||||
size: staffPageSize,
|
||||
keyword: staffQuery.value || undefined,
|
||||
status: statusFilter.value || undefined,
|
||||
role: roleFilter.value || undefined
|
||||
})
|
||||
// 处理分页响应
|
||||
const pageResult = result as { items?: any[]; total?: number }
|
||||
if (pageResult && typeof pageResult === 'object' && 'items' in pageResult && Array.isArray(pageResult.items)) {
|
||||
users.value = pageResult.items
|
||||
staffTotal.value = typeof pageResult.total === 'number' ? pageResult.total : pageResult.items.length
|
||||
} else if (Array.isArray(result)) {
|
||||
users.value = result
|
||||
staffTotal.value = result.length
|
||||
} else {
|
||||
users.value = []
|
||||
staffTotal.value = 0
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load staff users:', error)
|
||||
users.value = []
|
||||
staffTotal.value = 0
|
||||
}
|
||||
}
|
||||
|
||||
const toggleUser = async (user: UserItem) => {
|
||||
const isFreezing = user.status !== '冻结'
|
||||
try {
|
||||
await userStore.toggleUserStatus(user.id)
|
||||
auditStore.addLog(isFreezing ? '冻结用户' : '解冻用户', user.name)
|
||||
} catch (error) {
|
||||
console.error('用户状态变更失败:', error)
|
||||
alert('操作失败: ' + (error as Error).message)
|
||||
}
|
||||
}
|
||||
|
||||
const requestRole = (user: UserItem) => {
|
||||
@@ -244,17 +312,27 @@ const filteredUsers = computed(() => {
|
||||
})
|
||||
})
|
||||
|
||||
// 监听筛选条件变化,重置页码并重新加载
|
||||
watch([staffQuery, roleFilter, statusFilter], () => {
|
||||
staffPage.value = 0
|
||||
// 如果当前页不是第一页,先回到第一页(这会自动触发loadStaffUsers)
|
||||
if (staffPage.value !== 0) {
|
||||
staffPage.value = 0
|
||||
} else {
|
||||
// 如果已经在第一页,直接重新加载
|
||||
loadStaffUsers()
|
||||
}
|
||||
})
|
||||
|
||||
const staffTotalPages = computed(() => Math.max(1, Math.ceil(filteredUsers.value.length / staffPageSize)))
|
||||
|
||||
const pagedUsers = computed(() => {
|
||||
const start = staffPage.value * staffPageSize
|
||||
return filteredUsers.value.slice(start, start + staffPageSize)
|
||||
// 监听页码变化,重新加载数据
|
||||
watch(staffPage, () => {
|
||||
loadStaffUsers()
|
||||
})
|
||||
|
||||
const staffTotalPages = computed(() => Math.max(1, Math.ceil(staffTotal.value / staffPageSize)))
|
||||
|
||||
// 后端已返回当前页数据,直接使用
|
||||
const pagedUsers = computed(() => users.value)
|
||||
|
||||
const allStaffSelected = computed(() => {
|
||||
return filteredUsers.value.length > 0 && filteredUsers.value.every((user) => selectedStaffIds.value.includes(user.id))
|
||||
})
|
||||
@@ -275,22 +353,85 @@ const selectAllStaff = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const batchEnable = () => {
|
||||
selectedStaffIds.value.forEach((id) => {
|
||||
const user = users.value.find((item) => item.id === id)
|
||||
if (user && user.status === '冻结') {
|
||||
toggleUser(user)
|
||||
const batchEnable = async () => {
|
||||
const frozenUsers = users.value.filter(
|
||||
(user) => selectedStaffIds.value.includes(user.id) && user.status === '冻结'
|
||||
)
|
||||
for (const user of frozenUsers) {
|
||||
try {
|
||||
await userStore.toggleUserStatus(user.id)
|
||||
auditStore.addLog('批量解冻用户', user.name)
|
||||
} catch (error) {
|
||||
console.error(`解冻用户 ${user.name} 失败:`, error)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const batchDisable = () => {
|
||||
selectedStaffIds.value.forEach((id) => {
|
||||
const user = users.value.find((item) => item.id === id)
|
||||
if (user && user.status === '正常') {
|
||||
toggleUser(user)
|
||||
const batchDisable = async () => {
|
||||
const activeUsers = users.value.filter(
|
||||
(user) => selectedStaffIds.value.includes(user.id) && user.status === '正常'
|
||||
)
|
||||
for (const user of activeUsers) {
|
||||
try {
|
||||
await userStore.toggleUserStatus(user.id)
|
||||
auditStore.addLog('批量冻结用户', user.name)
|
||||
} catch (error) {
|
||||
console.error(`冻结用户 ${user.name} 失败:`, error)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 敏感用户导出处理
|
||||
const handleExportUsers = async () => {
|
||||
const apiService = service as any
|
||||
try {
|
||||
// 先检查是否有已批准的敏感导出审批
|
||||
const hasApproval = await apiService.hasApprovedSensitiveExport()
|
||||
|
||||
if (hasApproval) {
|
||||
// 有审批,直接导出
|
||||
const blob = await apiService.exportUsersWithSensitive()
|
||||
downloadBlob(blob, 'users_export.csv')
|
||||
auditStore.addLog('导出用户', '敏感数据导出')
|
||||
} else {
|
||||
// 无审批,弹窗询问是否提交审批申请
|
||||
const reason = prompt('导出敏感数据需要审批,请输入导出原因:')
|
||||
if (reason) {
|
||||
await apiService.submitSensitiveExportApproval(reason)
|
||||
alert('审批申请已提交,请等待审批通过后再导出')
|
||||
auditStore.addLog('提交导出审批', reason)
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (error.message === 'NEED_APPROVAL') {
|
||||
const reason = prompt('导出敏感数据需要审批,请输入导出原因:')
|
||||
if (reason) {
|
||||
try {
|
||||
await apiService.submitSensitiveExportApproval(reason)
|
||||
alert('审批申请已提交,请等待审批通过后再导出')
|
||||
auditStore.addLog('提交导出审批', reason)
|
||||
} catch (submitError) {
|
||||
console.error('提交审批失败:', submitError)
|
||||
alert('提交审批失败: ' + (submitError as Error).message)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.error('导出失败:', error)
|
||||
alert('导出失败: ' + error.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 下载blob为文件
|
||||
const downloadBlob = (blob: Blob, filename: string) => {
|
||||
const url = window.URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = filename
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
window.URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
const filteredActivityUsers = computed(() => {
|
||||
|
||||
Reference in New Issue
Block a user