test(cache): 修复CacheConfigTest边界值测试
- 修改 shouldVerifyCacheManager_withMaximumIntegerTtl 为 shouldVerifyCacheManager_withMaximumAllowedTtl - 使用正确的最大TTL值(10080分钟,7天)而不是 Integer.MAX_VALUE - 新增 shouldThrowException_whenTtlExceedsMaximum 测试验证边界检查 - 所有1266个测试用例通过 - 覆盖率: 指令81.89%, 行88.48%, 分支51.55% docs: 添加项目状态报告 - 生成 PROJECT_STATUS_REPORT.md 详细记录项目当前状态 - 包含质量指标、已完成功能、待办事项和技术债务
This commit is contained in:
12
frontend/admin/index.html
Normal file
12
frontend/admin/index.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Mosquito Admin</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
4724
frontend/admin/package-lock.json
generated
Normal file
4724
frontend/admin/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
34
frontend/admin/package.json
Normal file
34
frontend/admin/package.json
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"name": "@mosquito/admin",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc && vite build",
|
||||
"preview": "vite preview",
|
||||
"type-check": "vue-tsc --noEmit",
|
||||
"test": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"pinia": "^2.1.7",
|
||||
"vue": "^3.3.0",
|
||||
"vue-router": "^4.2.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.10.0",
|
||||
"@vitejs/plugin-vue": "^5.0.0",
|
||||
"@vue/test-utils": "^2.4.6",
|
||||
"autoprefixer": "^10.4.17",
|
||||
"jsdom": "^28.0.0",
|
||||
"postcss": "^8.4.33",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"typescript": "~5.3.0",
|
||||
"vite": "^5.0.0",
|
||||
"vitest": "^4.0.18",
|
||||
"vue-tsc": "^1.8.25"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
}
|
||||
6
frontend/admin/postcss.config.cjs
Normal file
6
frontend/admin/postcss.config.cjs
Normal file
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {}
|
||||
}
|
||||
}
|
||||
194
frontend/admin/src/App.vue
Normal file
194
frontend/admin/src/App.vue
Normal file
@@ -0,0 +1,194 @@
|
||||
<template>
|
||||
<div class="mosquito-app">
|
||||
<header class="sticky top-0 z-40 border-b border-mosquito-line bg-white/90 backdrop-blur">
|
||||
<div class="mx-auto flex max-w-6xl items-center justify-between px-6 py-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex h-10 w-10 items-center justify-center rounded-2xl bg-mosquito-accent/15 text-sm font-bold text-mosquito-brand">
|
||||
M
|
||||
</div>
|
||||
<div>
|
||||
<div class="mos-title text-sm font-semibold">Mosquito Admin</div>
|
||||
<div class="mos-muted text-xs">运营与数据控制台</div>
|
||||
</div>
|
||||
</div>
|
||||
<nav class="flex items-center gap-2 text-sm font-semibold">
|
||||
<RouterLink
|
||||
to="/"
|
||||
class="rounded-full px-3 py-1.5 text-mosquito-ink/70 transition"
|
||||
:class="{ 'bg-mosquito-accent/10 text-mosquito-ink': route.path === '/' }"
|
||||
>
|
||||
概览
|
||||
</RouterLink>
|
||||
<RouterLink
|
||||
to="/activities"
|
||||
class="rounded-full px-3 py-1.5 text-mosquito-ink/70 transition"
|
||||
:class="{ 'bg-mosquito-accent/10 text-mosquito-ink': route.path.startsWith('/activities') }"
|
||||
>
|
||||
活动
|
||||
</RouterLink>
|
||||
<RouterLink
|
||||
v-if="auth.hasPermission('manage:users')"
|
||||
to="/users"
|
||||
class="rounded-full px-3 py-1.5 text-mosquito-ink/70 transition"
|
||||
:class="{ 'bg-mosquito-accent/10 text-mosquito-ink': route.path.startsWith('/users') }"
|
||||
>
|
||||
用户
|
||||
</RouterLink>
|
||||
<RouterLink
|
||||
v-if="auth.hasPermission('manage:rewards')"
|
||||
to="/rewards"
|
||||
class="rounded-full px-3 py-1.5 text-mosquito-ink/70 transition"
|
||||
:class="{ 'bg-mosquito-accent/10 text-mosquito-ink': route.path.startsWith('/rewards') }"
|
||||
>
|
||||
奖励
|
||||
</RouterLink>
|
||||
<RouterLink
|
||||
v-if="auth.hasPermission('manage:risk')"
|
||||
to="/risk"
|
||||
class="rounded-full px-3 py-1.5 text-mosquito-ink/70 transition"
|
||||
:class="{ 'bg-mosquito-accent/10 text-mosquito-ink': route.path.startsWith('/risk') }"
|
||||
>
|
||||
风控
|
||||
</RouterLink>
|
||||
<RouterLink
|
||||
v-if="auth.hasPermission('view:audit')"
|
||||
to="/audit"
|
||||
class="rounded-full px-3 py-1.5 text-mosquito-ink/70 transition"
|
||||
:class="{ 'bg-mosquito-accent/10 text-mosquito-ink': route.path.startsWith('/audit') }"
|
||||
>
|
||||
审计
|
||||
</RouterLink>
|
||||
<RouterLink
|
||||
v-if="auth.hasPermission('manage:users')"
|
||||
to="/approvals"
|
||||
class="rounded-full px-3 py-1.5 text-mosquito-ink/70 transition"
|
||||
:class="{ 'bg-mosquito-accent/10 text-mosquito-ink': route.path.startsWith('/approvals') }"
|
||||
>
|
||||
审批
|
||||
</RouterLink>
|
||||
<RouterLink
|
||||
v-if="auth.hasPermission('manage:users')"
|
||||
to="/permissions"
|
||||
class="rounded-full px-3 py-1.5 text-mosquito-ink/70 transition"
|
||||
:class="{ 'bg-mosquito-accent/10 text-mosquito-ink': route.path.startsWith('/permissions') }"
|
||||
>
|
||||
权限
|
||||
</RouterLink>
|
||||
<RouterLink
|
||||
v-if="auth.hasPermission('view:notifications')"
|
||||
to="/notifications"
|
||||
class="rounded-full px-3 py-1.5 text-mosquito-ink/70 transition"
|
||||
:class="{ 'bg-mosquito-accent/10 text-mosquito-ink': route.path.startsWith('/notifications') }"
|
||||
>
|
||||
通知
|
||||
</RouterLink>
|
||||
</nav>
|
||||
<div class="flex items-center gap-2">
|
||||
<span v-if="auth.mode === 'demo'" class="mos-pill">演示模式</span>
|
||||
<span class="mos-pill">{{ roleLabel }}</span>
|
||||
<label v-if="auth.mode === 'demo'" class="flex items-center gap-2 text-xs text-mosquito-ink/70">
|
||||
角色
|
||||
<select class="mos-input !py-1 !px-2 !text-xs" v-model="selectedRole" @change="onRoleChange">
|
||||
<option value="admin">管理员</option>
|
||||
<option value="operator">运营</option>
|
||||
<option value="viewer">只读</option>
|
||||
</select>
|
||||
</label>
|
||||
<RouterLink
|
||||
to="/login"
|
||||
class="rounded-xl border border-mosquito-line px-3 py-2 text-sm font-semibold text-mosquito-ink/70"
|
||||
>
|
||||
登录页
|
||||
</RouterLink>
|
||||
<button
|
||||
class="rounded-xl border border-mosquito-line px-3 py-2 text-sm font-semibold text-mosquito-ink/70"
|
||||
@click="toggleReportPanel"
|
||||
>
|
||||
导出报表
|
||||
</button>
|
||||
<RouterLink
|
||||
to="/activities/new"
|
||||
class="rounded-xl bg-mosquito-accent px-3 py-2 text-sm font-semibold text-white shadow-soft"
|
||||
>
|
||||
新建活动
|
||||
</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<main class="mx-auto max-w-6xl px-6 py-8 pb-16">
|
||||
<div v-if="auth.mode === 'demo'" class="mb-6 rounded-2xl border border-mosquito-line bg-white/80 px-4 py-3 text-xs text-mosquito-ink/70">
|
||||
当前为演示数据预览,未接入真实鉴权与生产数据源。
|
||||
</div>
|
||||
<div v-if="showReportPanel" class="mb-6 rounded-2xl border border-mosquito-line bg-white/90 p-4">
|
||||
<ExportFieldPanel
|
||||
title="导出运营报表"
|
||||
:fields="reportFields"
|
||||
:selected="reportSelected"
|
||||
@update:selected="setReportSelected"
|
||||
@export="exportReport"
|
||||
/>
|
||||
</div>
|
||||
<router-view />
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useRoute } from 'vue-router'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useAuthStore } from './stores/auth'
|
||||
import { downloadCsv } from './utils/export'
|
||||
import ExportFieldPanel, { type ExportField } from './components/ExportFieldPanel.vue'
|
||||
import { useExportFields } from './composables/useExportFields'
|
||||
|
||||
const route = useRoute()
|
||||
const auth = useAuthStore()
|
||||
const selectedRole = ref(auth.role)
|
||||
|
||||
const roleLabel = computed(() => {
|
||||
if (auth.role === 'admin') return '管理员'
|
||||
if (auth.role === 'operator') return '运营'
|
||||
return '只读'
|
||||
})
|
||||
|
||||
const onRoleChange = () => {
|
||||
auth.setRole(selectedRole.value)
|
||||
}
|
||||
|
||||
watch(
|
||||
() => auth.role,
|
||||
(value) => {
|
||||
selectedRole.value = value
|
||||
}
|
||||
)
|
||||
|
||||
const showReportPanel = ref(false)
|
||||
const reportFields: ExportField[] = [
|
||||
{ key: 'visits', label: '访问', required: true },
|
||||
{ key: 'shares', label: '分享' },
|
||||
{ key: 'conversions', label: '转化' },
|
||||
{ key: 'newUsers', label: '新增' }
|
||||
]
|
||||
const reportData: Record<string, string> = {
|
||||
visits: '48210',
|
||||
shares: '12800',
|
||||
conversions: '3840',
|
||||
newUsers: '920'
|
||||
}
|
||||
const { selected: reportSelected, setSelected: setReportSelected } = useExportFields(
|
||||
reportFields,
|
||||
reportFields.map((field) => field.key)
|
||||
)
|
||||
|
||||
const toggleReportPanel = () => {
|
||||
showReportPanel.value = !showReportPanel.value
|
||||
}
|
||||
|
||||
const exportReport = () => {
|
||||
const rows = reportFields
|
||||
.filter((field) => reportSelected.value.includes(field.key))
|
||||
.map((field) => [field.label, reportData[field.key] ?? ''])
|
||||
downloadCsv('admin-report-demo.csv', ['指标', '值'], rows)
|
||||
showReportPanel.value = false
|
||||
}
|
||||
</script>
|
||||
3
frontend/admin/src/auth/adapters/AuthAdapter.ts
Normal file
3
frontend/admin/src/auth/adapters/AuthAdapter.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import type { AuthAdapter } from '../types'
|
||||
|
||||
export type { AuthAdapter }
|
||||
40
frontend/admin/src/auth/adapters/DemoAuthAdapter.ts
Normal file
40
frontend/admin/src/auth/adapters/DemoAuthAdapter.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import type { AdminRole, Permission } from '../roles'
|
||||
import { RolePermissions } from '../roles'
|
||||
import type { AuthAdapter, AuthUser, LoginResult } from '../types'
|
||||
|
||||
const demoUser = (role: AdminRole): AuthUser => ({
|
||||
id: `demo-${role}`,
|
||||
name: role === 'admin' ? '演示管理员' : role === 'operator' ? '演示运营' : '演示访客',
|
||||
email: 'demo@mosquito.local',
|
||||
role
|
||||
})
|
||||
|
||||
export class DemoAuthAdapter implements AuthAdapter {
|
||||
private currentUser: AuthUser | null = demoUser('admin')
|
||||
|
||||
async loginWithPassword(_username: string, _password: string): Promise<LoginResult> {
|
||||
return { user: demoUser('admin') }
|
||||
}
|
||||
|
||||
async loginDemo(role: AdminRole = 'admin'): Promise<LoginResult> {
|
||||
this.currentUser = demoUser(role)
|
||||
return { user: this.currentUser }
|
||||
}
|
||||
|
||||
async logout(): Promise<void> {
|
||||
this.currentUser = null
|
||||
}
|
||||
|
||||
async switchRole(role: AdminRole): Promise<AuthUser> {
|
||||
this.currentUser = demoUser(role)
|
||||
return this.currentUser
|
||||
}
|
||||
|
||||
async getCurrentUser(): Promise<AuthUser | null> {
|
||||
return this.currentUser
|
||||
}
|
||||
|
||||
hasPermission(role: AdminRole, permission: Permission): boolean {
|
||||
return RolePermissions[role].includes(permission)
|
||||
}
|
||||
}
|
||||
44
frontend/admin/src/auth/roles.ts
Normal file
44
frontend/admin/src/auth/roles.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
export type AdminRole = 'admin' | 'operator' | 'viewer'
|
||||
|
||||
export type Permission =
|
||||
| 'view:dashboard'
|
||||
| 'view:activities'
|
||||
| 'view:leaderboard'
|
||||
| 'view:alerts'
|
||||
| 'view:notifications'
|
||||
| 'manage:users'
|
||||
| 'manage:rewards'
|
||||
| 'manage:risk'
|
||||
| 'manage:config'
|
||||
| 'view:audit'
|
||||
|
||||
export const RolePermissions: Record<AdminRole, Permission[]> = {
|
||||
admin: [
|
||||
'view:dashboard',
|
||||
'view:activities',
|
||||
'view:leaderboard',
|
||||
'view:alerts',
|
||||
'view:notifications',
|
||||
'manage:users',
|
||||
'manage:rewards',
|
||||
'manage:risk',
|
||||
'manage:config',
|
||||
'view:audit'
|
||||
],
|
||||
operator: [
|
||||
'view:dashboard',
|
||||
'view:activities',
|
||||
'view:leaderboard',
|
||||
'view:alerts',
|
||||
'view:notifications',
|
||||
'manage:rewards',
|
||||
'manage:risk'
|
||||
],
|
||||
viewer: [
|
||||
'view:dashboard',
|
||||
'view:activities',
|
||||
'view:leaderboard',
|
||||
'view:alerts',
|
||||
'view:notifications'
|
||||
]
|
||||
}
|
||||
27
frontend/admin/src/auth/types.ts
Normal file
27
frontend/admin/src/auth/types.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import type { AdminRole, Permission } from './roles'
|
||||
|
||||
export type AuthUser = {
|
||||
id: string
|
||||
name: string
|
||||
email?: string
|
||||
role: AdminRole
|
||||
}
|
||||
|
||||
export type AuthState = {
|
||||
user: AuthUser | null
|
||||
mode: 'demo' | 'real'
|
||||
}
|
||||
|
||||
export type LoginResult = {
|
||||
user: AuthUser
|
||||
token?: string
|
||||
}
|
||||
|
||||
export type AuthAdapter = {
|
||||
loginWithPassword(username: string, password: string): Promise<LoginResult>
|
||||
loginDemo(role?: AdminRole): Promise<LoginResult>
|
||||
logout(): Promise<void>
|
||||
switchRole(role: AdminRole): Promise<AuthUser>
|
||||
getCurrentUser(): Promise<AuthUser | null>
|
||||
hasPermission(role: AdminRole, permission: Permission): boolean
|
||||
}
|
||||
84
frontend/admin/src/components/ExportFieldPanel.vue
Normal file
84
frontend/admin/src/components/ExportFieldPanel.vue
Normal file
@@ -0,0 +1,84 @@
|
||||
<template>
|
||||
<div class="space-y-3">
|
||||
<div class="text-sm font-semibold text-mosquito-ink">{{ title }}</div>
|
||||
<div class="space-y-2">
|
||||
<label
|
||||
v-for="field in fields"
|
||||
:key="field.key"
|
||||
class="flex items-center gap-2 text-xs text-mosquito-ink/80"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="h-4 w-4"
|
||||
:checked="isChecked(field.key)"
|
||||
:disabled="field.required"
|
||||
@change="onToggle(field.key, ($event.target as HTMLInputElement).checked)"
|
||||
/>
|
||||
<span>{{ field.label }}</span>
|
||||
<span v-if="field.required" class="rounded-full bg-mosquito-accent/10 px-2 py-0.5 text-[10px] font-semibold text-mosquito-brand">
|
||||
必选
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="flex items-center justify-between text-xs text-mosquito-ink/70">
|
||||
<button class="mos-btn mos-btn-secondary !py-1 !px-2 !text-xs" @click="selectAll">全选</button>
|
||||
<button class="mos-btn mos-btn-secondary !py-1 !px-2 !text-xs" @click="clearOptional">仅保留必选</button>
|
||||
<button
|
||||
data-test="export-button"
|
||||
class="mos-btn mos-btn-accent !py-1 !px-3 !text-xs"
|
||||
@click="emit('export')"
|
||||
>
|
||||
导出
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
export type ExportField = {
|
||||
key: string
|
||||
label: string
|
||||
required?: boolean
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
title: string
|
||||
fields: ExportField[]
|
||||
selected: string[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'update:selected', value: string[]): void
|
||||
(event: 'export'): void
|
||||
}>()
|
||||
|
||||
const requiredKeys = computed(() => props.fields.filter((field) => field.required).map((field) => field.key))
|
||||
|
||||
const normalizeSelection = (next: string[]) => {
|
||||
const merged = new Set([...requiredKeys.value, ...next])
|
||||
return props.fields.map((field) => field.key).filter((key) => merged.has(key))
|
||||
}
|
||||
|
||||
const isChecked = (key: string) => normalizeSelection(props.selected).includes(key)
|
||||
|
||||
const onToggle = (key: string, checked: boolean) => {
|
||||
if (requiredKeys.value.includes(key)) {
|
||||
emit('update:selected', normalizeSelection(props.selected))
|
||||
return
|
||||
}
|
||||
const next = checked
|
||||
? [...props.selected, key]
|
||||
: props.selected.filter((item) => item !== key)
|
||||
emit('update:selected', normalizeSelection(next))
|
||||
}
|
||||
|
||||
const selectAll = () => {
|
||||
emit('update:selected', normalizeSelection(props.fields.map((field) => field.key)))
|
||||
}
|
||||
|
||||
const clearOptional = () => {
|
||||
emit('update:selected', normalizeSelection([]))
|
||||
}
|
||||
</script>
|
||||
48
frontend/admin/src/components/FilterPaginationBar.vue
Normal file
48
frontend/admin/src/components/FilterPaginationBar.vue
Normal file
@@ -0,0 +1,48 @@
|
||||
<template>
|
||||
<div class="space-y-3">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div class="flex flex-wrap items-center gap-2 text-xs text-mosquito-ink/70">
|
||||
<slot name="filters" />
|
||||
</div>
|
||||
<div class="flex items-center gap-2 text-xs text-mosquito-ink/70">
|
||||
<slot name="actions" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<slot />
|
||||
|
||||
<div v-if="showPagination" class="mt-2 flex items-center justify-between text-xs text-mosquito-ink/70">
|
||||
<button class="mos-btn mos-btn-secondary !py-1 !px-2 !text-xs" :disabled="page <= 0" @click="$emit('prev')">
|
||||
上一页
|
||||
</button>
|
||||
<div>第 {{ page + 1 }} / {{ totalPages }} 页</div>
|
||||
<button
|
||||
class="mos-btn mos-btn-secondary !py-1 !px-2 !text-xs"
|
||||
:disabled="page >= totalPages - 1"
|
||||
@click="$emit('next')"
|
||||
>
|
||||
下一页
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
page?: number
|
||||
totalPages?: number
|
||||
}>(),
|
||||
{
|
||||
page: 0,
|
||||
totalPages: 0
|
||||
}
|
||||
)
|
||||
|
||||
const showPagination = props.totalPages > 1
|
||||
|
||||
defineEmits<{
|
||||
prev: []
|
||||
next: []
|
||||
}>()
|
||||
</script>
|
||||
56
frontend/admin/src/components/ListSection.vue
Normal file
56
frontend/admin/src/components/ListSection.vue
Normal file
@@ -0,0 +1,56 @@
|
||||
<template>
|
||||
<section class="space-y-6">
|
||||
<header v-if="$slots.title || $slots.subtitle" class="space-y-2">
|
||||
<h1 v-if="$slots.title" class="mos-title text-2xl font-semibold">
|
||||
<slot name="title" />
|
||||
</h1>
|
||||
<p v-if="$slots.subtitle" class="mos-muted text-sm">
|
||||
<slot name="subtitle" />
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div class="mos-card p-5">
|
||||
<FilterPaginationBar
|
||||
v-if="page !== undefined && totalPages !== undefined"
|
||||
:page="page"
|
||||
:total-pages="totalPages"
|
||||
@prev="emit('prev')"
|
||||
@next="emit('next')"
|
||||
>
|
||||
<template #filters>
|
||||
<slot name="filters" />
|
||||
</template>
|
||||
<template #actions>
|
||||
<slot name="actions" />
|
||||
</template>
|
||||
<slot />
|
||||
<slot name="empty" />
|
||||
</FilterPaginationBar>
|
||||
<template v-else>
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<slot name="filters" />
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<slot name="actions" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4 space-y-3">
|
||||
<slot />
|
||||
<slot name="empty" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-if="$slots.footer" class="mt-4">
|
||||
<slot name="footer" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import FilterPaginationBar from './FilterPaginationBar.vue'
|
||||
|
||||
defineProps<{ page?: number; totalPages?: number }>()
|
||||
const emit = defineEmits<{ (event: 'prev'): void; (event: 'next'): void }>()
|
||||
</script>
|
||||
@@ -0,0 +1,40 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import ExportFieldPanel from '../ExportFieldPanel.vue'
|
||||
|
||||
describe('ExportFieldPanel', () => {
|
||||
it('emits updated selection when toggling optional field', async () => {
|
||||
const wrapper = mount(ExportFieldPanel, {
|
||||
props: {
|
||||
title: 'Fields',
|
||||
fields: [
|
||||
{ key: 'name', label: 'Name', required: true },
|
||||
{ key: 'status', label: 'Status' }
|
||||
],
|
||||
selected: ['name']
|
||||
}
|
||||
})
|
||||
|
||||
const inputs = wrapper.findAll('input[type="checkbox"]')
|
||||
expect(inputs).toHaveLength(2)
|
||||
expect((inputs[0].element as HTMLInputElement).checked).toBe(true)
|
||||
expect((inputs[0].element as HTMLInputElement).disabled).toBe(true)
|
||||
|
||||
await inputs[1].setValue(true)
|
||||
const emitted = wrapper.emitted('update:selected')
|
||||
expect(emitted).toBeTruthy()
|
||||
expect(emitted?.[0][0]).toEqual(['name', 'status'])
|
||||
})
|
||||
|
||||
it('emits export event when clicking export button', async () => {
|
||||
const wrapper = mount(ExportFieldPanel, {
|
||||
props: {
|
||||
title: 'Fields',
|
||||
fields: [{ key: 'name', label: 'Name' }],
|
||||
selected: ['name']
|
||||
}
|
||||
})
|
||||
|
||||
await wrapper.get('[data-test="export-button"]').trigger('click')
|
||||
expect(wrapper.emitted('export')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
26
frontend/admin/src/components/__tests__/ListSection.test.ts
Normal file
26
frontend/admin/src/components/__tests__/ListSection.test.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import ListSection from '../ListSection.vue'
|
||||
|
||||
describe('ListSection', () => {
|
||||
it('renders provided slots', () => {
|
||||
const wrapper = mount(ListSection, {
|
||||
slots: {
|
||||
title: '<div data-test="title">Title</div>',
|
||||
subtitle: '<div data-test="subtitle">Subtitle</div>',
|
||||
filters: '<div data-test="filters">Filters</div>',
|
||||
actions: '<div data-test="actions">Actions</div>',
|
||||
default: '<div data-test="content">Content</div>',
|
||||
empty: '<div data-test="empty">Empty</div>',
|
||||
footer: '<div data-test="footer">Footer</div>'
|
||||
}
|
||||
})
|
||||
|
||||
expect(wrapper.find('[data-test="title"]').text()).toBe('Title')
|
||||
expect(wrapper.find('[data-test="subtitle"]').text()).toBe('Subtitle')
|
||||
expect(wrapper.find('[data-test="filters"]').text()).toBe('Filters')
|
||||
expect(wrapper.find('[data-test="actions"]').text()).toBe('Actions')
|
||||
expect(wrapper.find('[data-test="content"]').text()).toBe('Content')
|
||||
expect(wrapper.find('[data-test="empty"]').text()).toBe('Empty')
|
||||
expect(wrapper.find('[data-test="footer"]').text()).toBe('Footer')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,32 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { useExportFields } from '../useExportFields'
|
||||
|
||||
describe('useExportFields', () => {
|
||||
it('keeps required fields selected', () => {
|
||||
const { selected, toggle } = useExportFields(
|
||||
[
|
||||
{ key: 'name', label: 'Name', required: true },
|
||||
{ key: 'status', label: 'Status' }
|
||||
],
|
||||
['status']
|
||||
)
|
||||
|
||||
expect(selected.value).toEqual(['name', 'status'])
|
||||
|
||||
toggle('name', false)
|
||||
expect(selected.value).toEqual(['name', 'status'])
|
||||
})
|
||||
|
||||
it('can clear optional fields', () => {
|
||||
const { selected, clearOptional } = useExportFields(
|
||||
[
|
||||
{ key: 'name', label: 'Name', required: true },
|
||||
{ key: 'status', label: 'Status' }
|
||||
],
|
||||
['name', 'status']
|
||||
)
|
||||
|
||||
clearOptional()
|
||||
expect(selected.value).toEqual(['name'])
|
||||
})
|
||||
})
|
||||
40
frontend/admin/src/composables/useExportFields.ts
Normal file
40
frontend/admin/src/composables/useExportFields.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import type { ExportField } from '../components/ExportFieldPanel.vue'
|
||||
|
||||
const normalizeSelection = (fields: ExportField[], selected: string[]) => {
|
||||
const requiredKeys = fields.filter((field) => field.required).map((field) => field.key)
|
||||
const merged = new Set([...requiredKeys, ...selected])
|
||||
return fields.map((field) => field.key).filter((key) => merged.has(key))
|
||||
}
|
||||
|
||||
export const useExportFields = (fields: ExportField[], initialSelected: string[] = []) => {
|
||||
const selected = ref<string[]>(normalizeSelection(fields, initialSelected))
|
||||
const requiredKeys = computed(() => fields.filter((field) => field.required).map((field) => field.key))
|
||||
|
||||
const setSelected = (next: string[]) => {
|
||||
selected.value = normalizeSelection(fields, next)
|
||||
}
|
||||
|
||||
const toggle = (key: string, checked: boolean) => {
|
||||
if (requiredKeys.value.includes(key)) {
|
||||
setSelected(selected.value)
|
||||
return
|
||||
}
|
||||
const next = checked
|
||||
? [...selected.value, key]
|
||||
: selected.value.filter((item) => item !== key)
|
||||
setSelected(next)
|
||||
}
|
||||
|
||||
const selectAll = () => setSelected(fields.map((field) => field.key))
|
||||
const clearOptional = () => setSelected([])
|
||||
|
||||
return {
|
||||
selected,
|
||||
requiredKeys,
|
||||
setSelected,
|
||||
toggle,
|
||||
selectAll,
|
||||
clearOptional
|
||||
}
|
||||
}
|
||||
18
frontend/admin/src/main.ts
Normal file
18
frontend/admin/src/main.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import router from './router'
|
||||
import App from './App.vue'
|
||||
import './styles/index.css'
|
||||
import MosquitoEnhancedPlugin from '../../index'
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
app.use(createPinia())
|
||||
app.use(router)
|
||||
app.use(MosquitoEnhancedPlugin, {
|
||||
baseUrl: import.meta.env.VITE_MOSQUITO_API_BASE_URL ?? '',
|
||||
apiKey: import.meta.env.VITE_MOSQUITO_API_KEY ?? '',
|
||||
userToken: import.meta.env.VITE_MOSQUITO_USER_TOKEN ?? ''
|
||||
})
|
||||
|
||||
app.mount('#app')
|
||||
138
frontend/admin/src/router/index.ts
Normal file
138
frontend/admin/src/router/index.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import DashboardView from '../views/DashboardView.vue'
|
||||
import ActivityListView from '../views/ActivityListView.vue'
|
||||
import ForbiddenView from '../views/ForbiddenView.vue'
|
||||
import LoginView from '../views/LoginView.vue'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import UsersView from '../views/UsersView.vue'
|
||||
import RewardsView from '../views/RewardsView.vue'
|
||||
import RiskView from '../views/RiskView.vue'
|
||||
import AuditLogView from '../views/AuditLogView.vue'
|
||||
import NotificationsView from '../views/NotificationsView.vue'
|
||||
import ActivityCreateView from '../views/ActivityCreateView.vue'
|
||||
import InviteUserView from '../views/InviteUserView.vue'
|
||||
import ActivityDetailView from '../views/ActivityDetailView.vue'
|
||||
import ActivityConfigWizardView from '../views/ActivityConfigWizardView.vue'
|
||||
import ApprovalCenterView from '../views/ApprovalCenterView.vue'
|
||||
import UserDetailView from '../views/UserDetailView.vue'
|
||||
import PermissionsView from '../views/PermissionsView.vue'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
{
|
||||
path: '/login',
|
||||
name: 'login',
|
||||
component: LoginView
|
||||
},
|
||||
{
|
||||
path: '/',
|
||||
name: 'dashboard',
|
||||
component: DashboardView,
|
||||
meta: { roles: ['admin', 'operator', 'viewer'] }
|
||||
},
|
||||
{
|
||||
path: '/activities',
|
||||
name: 'activities',
|
||||
component: ActivityListView,
|
||||
meta: { roles: ['admin', 'operator', 'viewer'] }
|
||||
},
|
||||
{
|
||||
path: '/activities/new',
|
||||
name: 'activity-create',
|
||||
component: ActivityCreateView,
|
||||
meta: { roles: ['admin', 'operator'] }
|
||||
},
|
||||
{
|
||||
path: '/activities/:id',
|
||||
name: 'activity-detail',
|
||||
component: ActivityDetailView,
|
||||
meta: { roles: ['admin', 'operator', 'viewer'] }
|
||||
},
|
||||
{
|
||||
path: '/activities/config',
|
||||
name: 'activity-config',
|
||||
component: ActivityConfigWizardView,
|
||||
meta: { roles: ['admin', 'operator'] }
|
||||
},
|
||||
{
|
||||
path: '/activities/:id',
|
||||
name: 'activity-detail',
|
||||
component: ActivityDetailView,
|
||||
meta: { roles: ['admin', 'operator', 'viewer'] }
|
||||
},
|
||||
{
|
||||
path: '/users',
|
||||
name: 'users',
|
||||
component: UsersView,
|
||||
meta: { roles: ['admin'] }
|
||||
},
|
||||
{
|
||||
path: '/users/:id',
|
||||
name: 'user-detail',
|
||||
component: UserDetailView,
|
||||
meta: { roles: ['admin'] }
|
||||
},
|
||||
{
|
||||
path: '/users/invite',
|
||||
name: 'user-invite',
|
||||
component: InviteUserView,
|
||||
meta: { roles: ['admin'] }
|
||||
},
|
||||
{
|
||||
path: '/rewards',
|
||||
name: 'rewards',
|
||||
component: RewardsView,
|
||||
meta: { roles: ['admin', 'operator'] }
|
||||
},
|
||||
{
|
||||
path: '/risk',
|
||||
name: 'risk',
|
||||
component: RiskView,
|
||||
meta: { roles: ['admin', 'operator'] }
|
||||
},
|
||||
{
|
||||
path: '/audit',
|
||||
name: 'audit',
|
||||
component: AuditLogView,
|
||||
meta: { roles: ['admin'] }
|
||||
},
|
||||
{
|
||||
path: '/approvals',
|
||||
name: 'approvals',
|
||||
component: ApprovalCenterView,
|
||||
meta: { roles: ['admin'] }
|
||||
},
|
||||
{
|
||||
path: '/permissions',
|
||||
name: 'permissions',
|
||||
component: PermissionsView,
|
||||
meta: { roles: ['admin'] }
|
||||
},
|
||||
{
|
||||
path: '/notifications',
|
||||
name: 'notifications',
|
||||
component: NotificationsView,
|
||||
meta: { roles: ['admin', 'operator', 'viewer'] }
|
||||
},
|
||||
{
|
||||
path: '/403',
|
||||
name: 'forbidden',
|
||||
component: ForbiddenView
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
router.beforeEach(async (to) => {
|
||||
const auth = useAuthStore()
|
||||
if (!auth.isAuthenticated && to.name !== 'login') {
|
||||
await auth.loginDemo('admin')
|
||||
}
|
||||
const roles = (to.meta?.roles as string[] | undefined) ?? null
|
||||
if (roles && !roles.includes(auth.role)) {
|
||||
return { name: 'forbidden' }
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
export default router
|
||||
74
frontend/admin/src/services/api/ApiDataService.ts
Normal file
74
frontend/admin/src/services/api/ApiDataService.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
const baseUrl = import.meta.env.VITE_MOSQUITO_API_BASE_URL ?? ''
|
||||
const apiKey = import.meta.env.VITE_MOSQUITO_API_KEY ?? ''
|
||||
const userToken = import.meta.env.VITE_MOSQUITO_USER_TOKEN ?? ''
|
||||
|
||||
const requestJson = async (url: string) => {
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-API-Key': apiKey,
|
||||
...(userToken ? { Authorization: `Bearer ${userToken}` } : {})
|
||||
}
|
||||
})
|
||||
const payload = await response.json().catch(() => ({}))
|
||||
if (!response.ok) {
|
||||
throw new Error(payload?.message || '请求失败')
|
||||
}
|
||||
return payload?.data ?? []
|
||||
}
|
||||
|
||||
export const apiDataService = {
|
||||
async getDashboard() {
|
||||
return {
|
||||
updatedAt: '刚刚',
|
||||
kpis: [],
|
||||
activities: [],
|
||||
alerts: []
|
||||
}
|
||||
},
|
||||
async getActivities() {
|
||||
return []
|
||||
},
|
||||
async getActivityById(_id: number) {
|
||||
return null
|
||||
},
|
||||
async getUsers() {
|
||||
return []
|
||||
},
|
||||
async getInvites() {
|
||||
return []
|
||||
},
|
||||
async getRoleRequests() {
|
||||
return []
|
||||
},
|
||||
async getRewards() {
|
||||
return []
|
||||
},
|
||||
async getRiskItems() {
|
||||
return []
|
||||
},
|
||||
async getRiskAlerts() {
|
||||
return []
|
||||
},
|
||||
async getAuditLogs() {
|
||||
return []
|
||||
},
|
||||
async getNotifications() {
|
||||
return []
|
||||
},
|
||||
async addNotification(_payload: { title: string; detail: string }) {
|
||||
return null
|
||||
},
|
||||
async getConfig() {
|
||||
return []
|
||||
},
|
||||
async getInvitedFriends(activityId: number, userId: number, page: number, size: number) {
|
||||
const params = new URLSearchParams({
|
||||
activityId: String(activityId),
|
||||
userId: String(userId),
|
||||
page: String(page),
|
||||
size: String(size)
|
||||
})
|
||||
return requestJson(`${baseUrl}/api/v1/me/invited-friends?${params}`)
|
||||
}
|
||||
}
|
||||
309
frontend/admin/src/services/demo/DemoDataService.ts
Normal file
309
frontend/admin/src/services/demo/DemoDataService.ts
Normal file
@@ -0,0 +1,309 @@
|
||||
import type { AdminRole } from '../../auth/roles'
|
||||
|
||||
export type DemoKpi = {
|
||||
label: string
|
||||
value: number
|
||||
status: string
|
||||
hint: string
|
||||
}
|
||||
|
||||
export type DemoActivity = {
|
||||
id: number
|
||||
name: string
|
||||
description: string
|
||||
startTime: string
|
||||
endTime: string
|
||||
participants: number
|
||||
status: string
|
||||
config: {
|
||||
audience: string
|
||||
conversion: string
|
||||
reward: string
|
||||
budget: string
|
||||
}
|
||||
metrics: {
|
||||
visits: number
|
||||
shares: number
|
||||
conversions: number
|
||||
budgetUsed: number
|
||||
}
|
||||
}
|
||||
|
||||
export type DemoAlert = {
|
||||
title: string
|
||||
detail: string
|
||||
}
|
||||
|
||||
export type DemoUser = {
|
||||
id: string
|
||||
name: string
|
||||
email: string
|
||||
role: AdminRole
|
||||
status: '正常' | '冻结'
|
||||
managerName: string
|
||||
}
|
||||
|
||||
export type DemoInvite = {
|
||||
id: string
|
||||
email: string
|
||||
role: AdminRole
|
||||
status: '待接受' | '已接受' | '已拒绝' | '已过期'
|
||||
invitedAt: string
|
||||
acceptedAt?: string
|
||||
expiredAt?: string
|
||||
}
|
||||
|
||||
export type DemoReward = {
|
||||
id: string
|
||||
userName: string
|
||||
points: number
|
||||
status: string
|
||||
issuedAt: string
|
||||
batchId: string
|
||||
batchStatus: string
|
||||
note?: string
|
||||
}
|
||||
|
||||
export type DemoRiskItem = {
|
||||
id: string
|
||||
type: string
|
||||
target: string
|
||||
status: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export type DemoRiskAlert = {
|
||||
id: string
|
||||
title: string
|
||||
detail: string
|
||||
status: '未处理' | '处理中' | '已关闭'
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export type DemoAuditLog = {
|
||||
id: string
|
||||
actor: string
|
||||
action: string
|
||||
resource: string
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export type DemoNotification = {
|
||||
id: string
|
||||
title: string
|
||||
detail: string
|
||||
read: boolean
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export type DemoNotificationInput = {
|
||||
title: string
|
||||
detail: string
|
||||
}
|
||||
|
||||
export type DemoRoleRequest = {
|
||||
id: string
|
||||
userId: string
|
||||
currentRole: AdminRole
|
||||
targetRole: AdminRole
|
||||
reason: string
|
||||
status: '待审批' | '已通过' | '已拒绝'
|
||||
requestedAt: string
|
||||
}
|
||||
|
||||
export type DemoConfig = {
|
||||
key: string
|
||||
value: string
|
||||
description: string
|
||||
}
|
||||
|
||||
const now = new Date()
|
||||
const isoDays = (offset: number) => new Date(now.getTime() + offset * 86400000).toISOString()
|
||||
|
||||
const demoActivities: DemoActivity[] = [
|
||||
{
|
||||
id: 1,
|
||||
name: '裂变增长计划',
|
||||
description: '邀请好友注册,获取双倍奖励。',
|
||||
startTime: isoDays(-7),
|
||||
endTime: isoDays(21),
|
||||
participants: 1280,
|
||||
status: '进行中',
|
||||
config: {
|
||||
audience: '新注册用户与邀请达人',
|
||||
conversion: '完成注册并绑定手机号',
|
||||
reward: '每邀请 1 人奖励 20 积分',
|
||||
budget: '总预算 50,000 积分'
|
||||
},
|
||||
metrics: {
|
||||
visits: 48210,
|
||||
shares: 12800,
|
||||
conversions: 3840,
|
||||
budgetUsed: 32000
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: '新用户召回活动',
|
||||
description: '召回沉默用户,提升活跃度。',
|
||||
startTime: isoDays(-21),
|
||||
endTime: isoDays(-2),
|
||||
participants: 640,
|
||||
status: '已结束',
|
||||
config: {
|
||||
audience: '30 天未登录用户',
|
||||
conversion: '完成首次分享',
|
||||
reward: '每邀请 1 人奖励 10 积分',
|
||||
budget: '总预算 20,000 积分'
|
||||
},
|
||||
metrics: {
|
||||
visits: 18200,
|
||||
shares: 6200,
|
||||
conversions: 1200,
|
||||
budgetUsed: 15000
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
const demoKpis: DemoKpi[] = [
|
||||
{ label: '访问', value: 48210, status: '已同步', hint: '近 7 天访问次数' },
|
||||
{ label: '分享', value: 12800, status: '已同步', hint: '累计分享次数' },
|
||||
{ label: '转化', value: 3840, status: '已同步', hint: '累计转化人数' },
|
||||
{ label: '新增', value: 920, status: '已同步', hint: '新增访问用户' }
|
||||
]
|
||||
|
||||
const demoAlerts: DemoAlert[] = [
|
||||
{ title: '回调失败率升高', detail: '最近 1 小时失败率 3.2%,建议检查回调服务。' }
|
||||
]
|
||||
|
||||
const demoUsers: DemoUser[] = [
|
||||
{ id: 'u-1001', name: '王晨', email: 'wangchen@demo.com', role: 'operator', status: '正常', managerName: '演示管理员' },
|
||||
{ id: 'u-1002', name: '李雪', email: 'lixue@demo.com', role: 'operator', status: '正常', managerName: '演示管理员' },
|
||||
{ id: 'u-1003', name: '周宁', email: 'zhouning@demo.com', role: 'viewer', status: '冻结', managerName: '王晨' }
|
||||
]
|
||||
|
||||
const demoRewards: DemoReward[] = [
|
||||
{ id: 'r-2001', userName: '王晨', points: 120, status: '已发放', issuedAt: isoDays(-1), batchId: 'batch-01', batchStatus: '已完成' },
|
||||
{ id: 'r-2002', userName: '李雪', points: 80, status: '待发放', issuedAt: isoDays(0), batchId: 'batch-02', batchStatus: '排队中' },
|
||||
{ id: 'r-2003', userName: '周宁', points: 50, status: '发放失败', issuedAt: isoDays(-2), batchId: 'batch-02', batchStatus: '异常' }
|
||||
]
|
||||
|
||||
const demoRiskItems: DemoRiskItem[] = [
|
||||
{ id: 'risk-1', type: '黑名单', target: '138****1234', status: '生效', updatedAt: isoDays(-2) },
|
||||
{ id: 'risk-2', type: '异常转化', target: 'IP: 10.10.2.24', status: '待核查', updatedAt: isoDays(-1) }
|
||||
]
|
||||
|
||||
const demoRiskAlerts: DemoRiskAlert[] = [
|
||||
{ id: 'alert-1', title: '回调失败率升高', detail: '最近 1 小时失败率 3.2%,建议检查回调服务。', status: '未处理', updatedAt: isoDays(-1) },
|
||||
{ id: 'alert-2', title: '异常积分发放', detail: '检测到单日发放异常增长,需复核。', status: '处理中', updatedAt: isoDays(-2) }
|
||||
]
|
||||
|
||||
const demoAuditLogs: DemoAuditLog[] = [
|
||||
{ id: 'audit-1', actor: '演示管理员', action: '更新活动', resource: '活动 #1', createdAt: isoDays(-1) },
|
||||
{ id: 'audit-2', actor: '演示管理员', action: '调整奖励规则', resource: '奖励方案 A', createdAt: isoDays(-3) }
|
||||
]
|
||||
|
||||
const demoNotifications: DemoNotification[] = [
|
||||
{ id: 'notice-1', title: '活动即将结束', detail: '裂变增长计划 3 天后结束', read: false, createdAt: isoDays(-1) },
|
||||
{ id: 'notice-2', title: '回调异常提醒', detail: '请检查回调配置与重试策略', read: true, createdAt: isoDays(-4) }
|
||||
]
|
||||
|
||||
const demoConfig: DemoConfig[] = [
|
||||
{ key: 'callback.retry.max', value: '3', description: '回调最大重试次数' },
|
||||
{ key: 'reward.batch.size', value: '200', description: '奖励批量发放大小' }
|
||||
]
|
||||
|
||||
export const demoDataService = {
|
||||
async getDashboard() {
|
||||
return {
|
||||
updatedAt: now.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }),
|
||||
kpis: demoKpis,
|
||||
activities: demoActivities,
|
||||
alerts: demoAlerts
|
||||
}
|
||||
},
|
||||
async getActivities() {
|
||||
return demoActivities
|
||||
},
|
||||
async getActivityById(id: number) {
|
||||
return demoActivities.find((item) => item.id === id) ?? null
|
||||
},
|
||||
async getUsers() {
|
||||
return demoUsers
|
||||
},
|
||||
async getInvites(): Promise<DemoInvite[]> {
|
||||
return [
|
||||
{
|
||||
id: 'invite-1',
|
||||
email: 'newuser@demo.com',
|
||||
role: 'operator',
|
||||
status: '待接受',
|
||||
invitedAt: isoDays(-1)
|
||||
},
|
||||
{
|
||||
id: 'invite-2',
|
||||
email: 'expired@demo.com',
|
||||
role: 'viewer',
|
||||
status: '已过期',
|
||||
invitedAt: isoDays(-5),
|
||||
expiredAt: isoDays(-2)
|
||||
},
|
||||
{
|
||||
id: 'invite-3',
|
||||
email: 'accepted@demo.com',
|
||||
role: 'admin',
|
||||
status: '已接受',
|
||||
invitedAt: isoDays(-6),
|
||||
acceptedAt: isoDays(-4)
|
||||
}
|
||||
]
|
||||
},
|
||||
async getRoleRequests(): Promise<DemoRoleRequest[]> {
|
||||
return [
|
||||
{
|
||||
id: 'role-1',
|
||||
userId: 'u-1002',
|
||||
currentRole: 'operator',
|
||||
targetRole: 'admin',
|
||||
reason: '需要管理活动权限',
|
||||
status: '待审批',
|
||||
requestedAt: isoDays(-2)
|
||||
}
|
||||
]
|
||||
},
|
||||
async getRewards() {
|
||||
return demoRewards
|
||||
},
|
||||
async getRiskItems() {
|
||||
return demoRiskItems
|
||||
},
|
||||
async getRiskAlerts() {
|
||||
return demoRiskAlerts
|
||||
},
|
||||
async getAuditLogs() {
|
||||
return demoAuditLogs
|
||||
},
|
||||
async getNotifications() {
|
||||
return demoNotifications
|
||||
},
|
||||
async addNotification(payload: DemoNotificationInput) {
|
||||
const item: DemoNotification = {
|
||||
id: `notice-${Date.now()}`,
|
||||
title: payload.title,
|
||||
detail: payload.detail,
|
||||
read: false,
|
||||
createdAt: new Date().toISOString()
|
||||
}
|
||||
demoNotifications.unshift(item)
|
||||
return item
|
||||
},
|
||||
async getConfig() {
|
||||
return demoConfig
|
||||
},
|
||||
async getInvitedFriends(_activityId: number, _userId: number, _page: number, _size: number) {
|
||||
return [
|
||||
{ nickname: '邀请用户 A', maskedPhone: '138****1024', status: '已注册' },
|
||||
{ nickname: '邀请用户 B', maskedPhone: '139****2048', status: '未注册' }
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { demoDataService } from '../DemoDataService'
|
||||
|
||||
describe('demoDataService', () => {
|
||||
it('adds notification entries', async () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date('2026-02-10T00:00:00Z'))
|
||||
|
||||
const originalLength = (await demoDataService.getNotifications()).length
|
||||
const created = await demoDataService.addNotification({
|
||||
title: '审批通过',
|
||||
detail: '王晨 角色变更已通过'
|
||||
})
|
||||
const nextLength = (await demoDataService.getNotifications()).length
|
||||
|
||||
expect(nextLength).toBe(originalLength + 1)
|
||||
expect(created.title).toBe('审批通过')
|
||||
expect(created.read).toBe(false)
|
||||
|
||||
vi.useRealTimers()
|
||||
})
|
||||
})
|
||||
8
frontend/admin/src/services/index.ts
Normal file
8
frontend/admin/src/services/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { demoDataService } from './demo/DemoDataService'
|
||||
import { apiDataService } from './api/ApiDataService'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
|
||||
export const useDataService = () => {
|
||||
const auth = useAuthStore()
|
||||
return auth.mode === 'demo' ? demoDataService : apiDataService
|
||||
}
|
||||
35
frontend/admin/src/stores/__tests__/users.test.ts
Normal file
35
frontend/admin/src/stores/__tests__/users.test.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { useUserStore } from '../users'
|
||||
|
||||
describe('useUserStore invites', () => {
|
||||
it('expires invite', () => {
|
||||
setActivePinia(createPinia())
|
||||
const store = useUserStore()
|
||||
store.init([], [{ id: 'invite-1', email: 'a@demo.com', role: 'operator', status: '待接受', invitedAt: '2026-02-01T00:00:00Z' }], [])
|
||||
|
||||
store.expireInvite('invite-1')
|
||||
|
||||
const invite = store.invites[0]
|
||||
expect(invite.status).toBe('已过期')
|
||||
expect(invite.expiredAt).toBeTruthy()
|
||||
})
|
||||
|
||||
it('resends invite by resetting status and invitedAt', () => {
|
||||
setActivePinia(createPinia())
|
||||
const store = useUserStore()
|
||||
store.init([], [{ id: 'invite-2', email: 'b@demo.com', role: 'viewer', status: '已过期', invitedAt: '2026-02-01T00:00:00Z', expiredAt: '2026-02-02T00:00:00Z' }], [])
|
||||
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date('2026-02-10T00:00:00Z'))
|
||||
|
||||
store.resendInvite('invite-2')
|
||||
|
||||
const invite = store.invites[0]
|
||||
expect(invite.status).toBe('待接受')
|
||||
expect(invite.invitedAt).toBe('2026-02-10T00:00:00.000Z')
|
||||
expect(invite.expiredAt).toBeUndefined()
|
||||
|
||||
vi.useRealTimers()
|
||||
})
|
||||
})
|
||||
145
frontend/admin/src/stores/activities.ts
Normal file
145
frontend/admin/src/stores/activities.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
export type ActivityStatus = 'draft' | 'scheduled' | 'active' | 'paused' | 'ended'
|
||||
|
||||
export type ActivityConfig = {
|
||||
audience: string
|
||||
conversion: string
|
||||
reward: string
|
||||
budget: string
|
||||
}
|
||||
|
||||
export type ActivityMetrics = {
|
||||
visits: number
|
||||
shares: number
|
||||
conversions: number
|
||||
budgetUsed: number
|
||||
}
|
||||
|
||||
export type ActivityItem = {
|
||||
id: number
|
||||
name: string
|
||||
description: string
|
||||
status: ActivityStatus
|
||||
startTime: string
|
||||
endTime: string
|
||||
participants: number
|
||||
config: ActivityConfig
|
||||
metrics: ActivityMetrics
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
const storageKey = 'mosquito-admin-activities-v1'
|
||||
|
||||
const safeRead = (): ActivityItem[] | null => {
|
||||
try {
|
||||
const raw = localStorage.getItem(storageKey)
|
||||
return raw ? (JSON.parse(raw) as ActivityItem[]) : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const safeWrite = (items: ActivityItem[]) => {
|
||||
try {
|
||||
localStorage.setItem(storageKey, JSON.stringify(items))
|
||||
} catch {
|
||||
// ignore storage errors in restricted contexts
|
||||
}
|
||||
}
|
||||
|
||||
const seedActivities = (): ActivityItem[] => {
|
||||
const now = Date.now()
|
||||
const iso = (offsetDays: number) => new Date(now + offsetDays * 86400000).toISOString()
|
||||
return [
|
||||
{
|
||||
id: 1,
|
||||
name: '裂变增长计划',
|
||||
description: '邀请好友注册,获取双倍奖励。',
|
||||
status: 'active',
|
||||
startTime: iso(-7),
|
||||
endTime: iso(21),
|
||||
participants: 1280,
|
||||
config: {
|
||||
audience: '新注册用户与邀请达人',
|
||||
conversion: '完成注册并绑定手机号',
|
||||
reward: '每邀请 1 人奖励 20 积分',
|
||||
budget: '总预算 50,000 积分'
|
||||
},
|
||||
metrics: {
|
||||
visits: 48210,
|
||||
shares: 12800,
|
||||
conversions: 3840,
|
||||
budgetUsed: 32000
|
||||
},
|
||||
createdAt: iso(-10),
|
||||
updatedAt: iso(-1)
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: '新用户召回活动',
|
||||
description: '召回沉默用户,提升活跃度。',
|
||||
status: 'ended',
|
||||
startTime: iso(-21),
|
||||
endTime: iso(-2),
|
||||
participants: 640,
|
||||
config: {
|
||||
audience: '30 天未登录用户',
|
||||
conversion: '完成首次分享',
|
||||
reward: '每邀请 1 人奖励 10 积分',
|
||||
budget: '总预算 20,000 积分'
|
||||
},
|
||||
metrics: {
|
||||
visits: 18200,
|
||||
shares: 6200,
|
||||
conversions: 1200,
|
||||
budgetUsed: 15000
|
||||
},
|
||||
createdAt: iso(-25),
|
||||
updatedAt: iso(-2)
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
export const useActivityStore = defineStore('activities', {
|
||||
state: () => ({
|
||||
items: safeRead() ?? seedActivities()
|
||||
}),
|
||||
getters: {
|
||||
byId: (state) => (id: number) => state.items.find((item) => item.id === id) ?? null
|
||||
},
|
||||
actions: {
|
||||
persist() {
|
||||
safeWrite(this.items)
|
||||
},
|
||||
create(item: Omit<ActivityItem, 'id' | 'createdAt' | 'updatedAt'>) {
|
||||
const now = new Date().toISOString()
|
||||
const nextId = this.items.length ? Math.max(...this.items.map((i) => i.id)) + 1 : 1
|
||||
const created: ActivityItem = {
|
||||
...item,
|
||||
id: nextId,
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
}
|
||||
this.items = [created, ...this.items]
|
||||
this.persist()
|
||||
return created
|
||||
},
|
||||
update(id: number, updates: Partial<ActivityItem>) {
|
||||
const index = this.items.findIndex((item) => item.id === id)
|
||||
if (index < 0) return null
|
||||
const updated = {
|
||||
...this.items[index],
|
||||
...updates,
|
||||
updatedAt: new Date().toISOString()
|
||||
}
|
||||
this.items.splice(index, 1, updated)
|
||||
this.persist()
|
||||
return updated
|
||||
},
|
||||
updateStatus(id: number, status: ActivityStatus) {
|
||||
return this.update(id, { status })
|
||||
}
|
||||
}
|
||||
})
|
||||
12
frontend/admin/src/stores/app.ts
Normal file
12
frontend/admin/src/stores/app.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
export const useAppStore = defineStore('app', {
|
||||
state: () => ({
|
||||
ready: false
|
||||
}),
|
||||
actions: {
|
||||
setReady(value: boolean) {
|
||||
this.ready = value
|
||||
}
|
||||
}
|
||||
})
|
||||
30
frontend/admin/src/stores/audit.ts
Normal file
30
frontend/admin/src/stores/audit.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
export type AuditLogItem = {
|
||||
id: string
|
||||
actor: string
|
||||
action: string
|
||||
resource: string
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export const useAuditStore = defineStore('audit', {
|
||||
state: () => ({
|
||||
items: [] as AuditLogItem[]
|
||||
}),
|
||||
actions: {
|
||||
init(items: AuditLogItem[]) {
|
||||
if (this.items.length) return
|
||||
this.items = items
|
||||
},
|
||||
addLog(action: string, resource: string, actor: string = '演示管理员') {
|
||||
this.items.unshift({
|
||||
id: `audit-${Date.now()}`,
|
||||
actor,
|
||||
action,
|
||||
resource,
|
||||
createdAt: new Date().toISOString()
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
43
frontend/admin/src/stores/auth.ts
Normal file
43
frontend/admin/src/stores/auth.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import type { AdminRole, Permission } from '../auth/roles'
|
||||
import { RolePermissions } from '../auth/roles'
|
||||
import { DemoAuthAdapter } from '../auth/adapters/DemoAuthAdapter'
|
||||
import type { AuthState } from '../auth/types'
|
||||
|
||||
const demoAdapter = new DemoAuthAdapter()
|
||||
|
||||
export const useAuthStore = defineStore('auth', {
|
||||
state: (): AuthState => ({
|
||||
user: {
|
||||
id: 'demo-admin',
|
||||
name: '演示管理员',
|
||||
email: 'demo@mosquito.local',
|
||||
role: 'admin'
|
||||
},
|
||||
mode: (import.meta.env.VITE_MOSQUITO_AUTH_MODE as AuthState['mode']) || 'demo'
|
||||
}),
|
||||
getters: {
|
||||
isAuthenticated: (state) => Boolean(state.user),
|
||||
role: (state): AdminRole => state.user?.role ?? 'viewer',
|
||||
hasPermission: (state) => (permission: Permission) => {
|
||||
const role = state.user?.role ?? 'viewer'
|
||||
return RolePermissions[role].includes(permission)
|
||||
}
|
||||
},
|
||||
actions: {
|
||||
async loginDemo(role: AdminRole = 'admin') {
|
||||
const result = await demoAdapter.loginDemo(role)
|
||||
this.user = result.user
|
||||
this.mode = 'demo'
|
||||
},
|
||||
async logout() {
|
||||
await demoAdapter.logout()
|
||||
this.user = null
|
||||
this.mode = 'demo'
|
||||
},
|
||||
async setRole(role: AdminRole) {
|
||||
this.user = await demoAdapter.switchRole(role)
|
||||
this.mode = 'demo'
|
||||
}
|
||||
}
|
||||
})
|
||||
125
frontend/admin/src/stores/users.ts
Normal file
125
frontend/admin/src/stores/users.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import type { AdminRole } from '../auth/roles'
|
||||
|
||||
export type UserAccount = {
|
||||
id: string
|
||||
name: string
|
||||
email: string
|
||||
role: AdminRole
|
||||
status: '正常' | '冻结'
|
||||
managerName: string
|
||||
}
|
||||
|
||||
export type InviteRequest = {
|
||||
id: string
|
||||
email: string
|
||||
role: AdminRole
|
||||
status: '待接受' | '已接受' | '已拒绝' | '已过期'
|
||||
invitedAt: string
|
||||
acceptedAt?: string
|
||||
expiredAt?: string
|
||||
}
|
||||
|
||||
export type RoleChangeRequest = {
|
||||
id: string
|
||||
userId: string
|
||||
currentRole: AdminRole
|
||||
targetRole: AdminRole
|
||||
reason: string
|
||||
status: '待审批' | '已通过' | '已拒绝'
|
||||
requestedAt: string
|
||||
approvedBy?: string
|
||||
decisionAt?: string
|
||||
rejectReason?: string
|
||||
}
|
||||
|
||||
const nowIso = () => new Date().toISOString()
|
||||
|
||||
export const useUserStore = defineStore('users', {
|
||||
state: () => ({
|
||||
users: [] as UserAccount[],
|
||||
invites: [] as InviteRequest[],
|
||||
roleRequests: [] as RoleChangeRequest[]
|
||||
}),
|
||||
getters: {
|
||||
byId: (state) => (id: string) => state.users.find((u) => u.id === id) ?? null,
|
||||
pendingRoleRequests: (state) => state.roleRequests.filter((req) => req.status === '待审批')
|
||||
},
|
||||
actions: {
|
||||
init(users: UserAccount[], invites: InviteRequest[], requests: RoleChangeRequest[]) {
|
||||
if (this.users.length) return
|
||||
this.users = users
|
||||
this.invites = invites
|
||||
this.roleRequests = requests
|
||||
},
|
||||
toggleUserStatus(id: string) {
|
||||
const user = this.byId(id)
|
||||
if (!user) return
|
||||
user.status = user.status === '冻结' ? '正常' : '冻结'
|
||||
},
|
||||
addInvite(email: string, role: AdminRole) {
|
||||
const invite: InviteRequest = {
|
||||
id: `invite-${Date.now()}`,
|
||||
email,
|
||||
role,
|
||||
status: '待接受',
|
||||
invitedAt: nowIso()
|
||||
}
|
||||
this.invites.unshift(invite)
|
||||
return invite
|
||||
},
|
||||
acceptInvite(id: string) {
|
||||
const invite = this.invites.find((item) => item.id === id)
|
||||
if (!invite || invite.status !== '待接受') return
|
||||
invite.status = '已接受'
|
||||
invite.acceptedAt = nowIso()
|
||||
},
|
||||
resendInvite(id: string) {
|
||||
const invite = this.invites.find((item) => item.id === id)
|
||||
if (!invite) return
|
||||
invite.status = '待接受'
|
||||
invite.invitedAt = nowIso()
|
||||
invite.expiredAt = undefined
|
||||
},
|
||||
expireInvite(id: string) {
|
||||
const invite = this.invites.find((item) => item.id === id)
|
||||
if (!invite || invite.status === '已过期') return
|
||||
invite.status = '已过期'
|
||||
invite.expiredAt = nowIso()
|
||||
},
|
||||
requestRoleChange(userId: string, targetRole: AdminRole, reason: string) {
|
||||
const user = this.byId(userId)
|
||||
if (!user) return null
|
||||
const request: RoleChangeRequest = {
|
||||
id: `role-${Date.now()}`,
|
||||
userId,
|
||||
currentRole: user.role,
|
||||
targetRole,
|
||||
reason,
|
||||
status: '待审批',
|
||||
requestedAt: nowIso()
|
||||
}
|
||||
this.roleRequests.unshift(request)
|
||||
return request
|
||||
},
|
||||
approveRoleChange(id: string, approver: string) {
|
||||
const request = this.roleRequests.find((item) => item.id === id)
|
||||
if (!request || request.status !== '待审批') return
|
||||
request.status = '已通过'
|
||||
request.approvedBy = approver
|
||||
request.decisionAt = nowIso()
|
||||
const user = this.byId(request.userId)
|
||||
if (user) {
|
||||
user.role = request.targetRole
|
||||
}
|
||||
},
|
||||
rejectRoleChange(id: string, approver: string, rejectReason: string) {
|
||||
const request = this.roleRequests.find((item) => item.id === id)
|
||||
if (!request || request.status !== '待审批') return
|
||||
request.status = '已拒绝'
|
||||
request.approvedBy = approver
|
||||
request.decisionAt = nowIso()
|
||||
request.rejectReason = rejectReason
|
||||
}
|
||||
}
|
||||
})
|
||||
104
frontend/admin/src/styles/index.css
Normal file
104
frontend/admin/src/styles/index.css
Normal file
@@ -0,0 +1,104 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600;700&family=IBM+Plex+Mono:wght@500;600&family=Source+Sans+3:wght@400;500;600&display=swap');
|
||||
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--mosquito-bg: #F3F6F9;
|
||||
--mosquito-surface: #FFFFFF;
|
||||
--mosquito-ink: #0B1C2C;
|
||||
--mosquito-muted: #5F6C7B;
|
||||
--mosquito-brand: #0B3A63;
|
||||
--mosquito-accent: #16B9A5;
|
||||
--mosquito-accent-2: #6AA7FF;
|
||||
--mosquito-line: #E0E6ED;
|
||||
--mosquito-shadow: 0 20px 50px rgba(11, 28, 44, 0.12);
|
||||
--mosquito-card-shadow: 0 12px 24px rgba(11, 28, 44, 0.08);
|
||||
--mosquito-font-display: 'IBM Plex Sans', 'Noto Sans SC', sans-serif;
|
||||
--mosquito-font-body: 'Source Sans 3', 'Noto Sans SC', sans-serif;
|
||||
--mosquito-font-mono: 'IBM Plex Mono', ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--mosquito-font-body);
|
||||
background: var(--mosquito-bg);
|
||||
color: var(--mosquito-ink);
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.mosquito-app {
|
||||
min-height: 100vh;
|
||||
background:
|
||||
radial-gradient(circle at top right, rgba(106, 167, 255, 0.18), transparent 45%),
|
||||
radial-gradient(circle at 20% 20%, rgba(22, 185, 165, 0.12), transparent 42%),
|
||||
var(--mosquito-bg);
|
||||
color: var(--mosquito-ink);
|
||||
}
|
||||
|
||||
.mos-card {
|
||||
border-radius: 16px;
|
||||
border: 1px solid var(--mosquito-line);
|
||||
background: var(--mosquito-surface);
|
||||
box-shadow: var(--mosquito-card-shadow);
|
||||
}
|
||||
|
||||
.mos-muted {
|
||||
color: var(--mosquito-muted);
|
||||
}
|
||||
|
||||
.mos-title {
|
||||
font-family: var(--mosquito-font-display);
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.mos-kpi {
|
||||
font-family: var(--mosquito-font-mono);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.mos-pill {
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(22, 185, 165, 0.4);
|
||||
background: rgba(22, 185, 165, 0.12);
|
||||
color: #0B3A63;
|
||||
padding: 4px 10px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.mos-input {
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--mosquito-line);
|
||||
background: var(--mosquito-surface);
|
||||
padding: 10px 12px;
|
||||
font-size: 14px;
|
||||
color: var(--mosquito-ink);
|
||||
}
|
||||
|
||||
.mos-input::placeholder {
|
||||
color: rgba(95, 108, 123, 0.6);
|
||||
}
|
||||
|
||||
.mos-btn {
|
||||
border-radius: 12px;
|
||||
padding: 10px 14px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.mos-btn-accent {
|
||||
background: var(--mosquito-accent);
|
||||
color: white;
|
||||
box-shadow: var(--mosquito-card-shadow);
|
||||
}
|
||||
|
||||
.mos-btn-secondary {
|
||||
border: 1px solid var(--mosquito-line);
|
||||
background: var(--mosquito-surface);
|
||||
color: var(--mosquito-ink);
|
||||
}
|
||||
}
|
||||
18
frontend/admin/src/utils/__tests__/approval.test.ts
Normal file
18
frontend/admin/src/utils/__tests__/approval.test.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { getSlaBadge, normalizeRejectReason } from '../approval'
|
||||
|
||||
describe('approval utils', () => {
|
||||
it('returns danger badge when overtime', () => {
|
||||
const now = new Date('2026-02-10T12:00:00Z')
|
||||
const requestedAt = '2026-02-08T00:00:00Z'
|
||||
const badge = getSlaBadge(requestedAt, now)
|
||||
|
||||
expect(badge.level).toBe('danger')
|
||||
expect(badge.label).toContain('超时')
|
||||
})
|
||||
|
||||
it('normalizes empty reject reason', () => {
|
||||
expect(normalizeRejectReason('')).toBe('批量拒绝')
|
||||
expect(normalizeRejectReason(' 自定义原因 ')).toBe('自定义原因')
|
||||
})
|
||||
})
|
||||
12
frontend/admin/src/utils/__tests__/reward.test.ts
Normal file
12
frontend/admin/src/utils/__tests__/reward.test.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { normalizeRewardReason } from '../reward'
|
||||
|
||||
describe('normalizeRewardReason', () => {
|
||||
it('falls back when input is empty', () => {
|
||||
expect(normalizeRewardReason('')).toBe('未填写原因')
|
||||
})
|
||||
|
||||
it('trims input', () => {
|
||||
expect(normalizeRewardReason(' 重试补发 ')).toBe('重试补发')
|
||||
})
|
||||
})
|
||||
16
frontend/admin/src/utils/__tests__/risk.test.ts
Normal file
16
frontend/admin/src/utils/__tests__/risk.test.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { transitionAlertStatus } from '../risk'
|
||||
|
||||
describe('transitionAlertStatus', () => {
|
||||
it('moves from 未处理 to 处理中 when processing', () => {
|
||||
expect(transitionAlertStatus('未处理', 'process')).toBe('处理中')
|
||||
})
|
||||
|
||||
it('moves to 已关闭 when closing', () => {
|
||||
expect(transitionAlertStatus('处理中', 'close')).toBe('已关闭')
|
||||
})
|
||||
|
||||
it('keeps 已关闭 status', () => {
|
||||
expect(transitionAlertStatus('已关闭', 'process')).toBe('已关闭')
|
||||
})
|
||||
})
|
||||
22
frontend/admin/src/utils/approval.ts
Normal file
22
frontend/admin/src/utils/approval.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
export type SlaLevel = 'normal' | 'warning' | 'danger'
|
||||
|
||||
const SLA_HOURS = 48
|
||||
const WARNING_RATIO = 0.75
|
||||
|
||||
export const getSlaBadge = (requestedAt: string, now: Date = new Date()) => {
|
||||
const requestedTime = new Date(requestedAt).getTime()
|
||||
const hours = Math.max(0, Math.round((now.getTime() - requestedTime) / 3600000))
|
||||
|
||||
if (hours >= SLA_HOURS) {
|
||||
return { label: `已超时 ${hours}h`, level: 'danger', hours }
|
||||
}
|
||||
if (hours >= SLA_HOURS * WARNING_RATIO) {
|
||||
return { label: `临近超时 ${hours}h`, level: 'warning', hours }
|
||||
}
|
||||
return { label: `待审批 ${hours}h`, level: 'normal', hours }
|
||||
}
|
||||
|
||||
export const normalizeRejectReason = (input: string, fallback = '批量拒绝') => {
|
||||
const trimmed = input.trim()
|
||||
return trimmed || fallback
|
||||
}
|
||||
9
frontend/admin/src/utils/export.ts
Normal file
9
frontend/admin/src/utils/export.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export const downloadCsv = (filename: string, headers: string[], rows: (string | number)[][]) => {
|
||||
const lines = [headers.join(','), ...rows.map((row) => row.join(','))]
|
||||
const blob = new Blob([lines.join('\n')], { type: 'text/csv;charset=utf-8;' })
|
||||
const link = document.createElement('a')
|
||||
link.href = URL.createObjectURL(blob)
|
||||
link.download = filename
|
||||
link.click()
|
||||
URL.revokeObjectURL(link.href)
|
||||
}
|
||||
4
frontend/admin/src/utils/reward.ts
Normal file
4
frontend/admin/src/utils/reward.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export const normalizeRewardReason = (input: string, fallback = '未填写原因') => {
|
||||
const trimmed = input.trim()
|
||||
return trimmed || fallback
|
||||
}
|
||||
9
frontend/admin/src/utils/risk.ts
Normal file
9
frontend/admin/src/utils/risk.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export type AlertStatus = '未处理' | '处理中' | '已关闭'
|
||||
export type AlertAction = 'process' | 'close'
|
||||
|
||||
export const transitionAlertStatus = (status: AlertStatus, action: AlertAction): AlertStatus => {
|
||||
if (status === '已关闭') return status
|
||||
if (action === 'close') return '已关闭'
|
||||
if (status === '未处理') return '处理中'
|
||||
return status
|
||||
}
|
||||
95
frontend/admin/src/views/ActivityConfigWizardView.vue
Normal file
95
frontend/admin/src/views/ActivityConfigWizardView.vue
Normal file
@@ -0,0 +1,95 @@
|
||||
<template>
|
||||
<section class="space-y-6">
|
||||
<header class="space-y-2">
|
||||
<h1 class="mos-title text-2xl font-semibold">活动配置向导</h1>
|
||||
<p class="mos-muted text-sm">分步完成活动配置与发布设置。</p>
|
||||
</header>
|
||||
|
||||
<div class="mos-card p-5 space-y-6">
|
||||
<div class="flex items-center gap-2 text-xs text-mosquito-ink/70">
|
||||
<span v-for="(step, index) in steps" :key="step" class="mos-pill" :class="{ 'opacity-40': index !== currentStep }">
|
||||
{{ index + 1 }}. {{ step }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="currentStep === 0" class="space-y-4">
|
||||
<div>
|
||||
<label class="text-xs font-semibold text-mosquito-ink/70">活动名称</label>
|
||||
<input class="mos-input mt-2 w-full" v-model="form.name" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs font-semibold text-mosquito-ink/70">描述</label>
|
||||
<textarea class="mos-input mt-2 w-full" rows="3" v-model="form.description"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="currentStep === 1" class="space-y-4">
|
||||
<div>
|
||||
<label class="text-xs font-semibold text-mosquito-ink/70">目标人群</label>
|
||||
<input class="mos-input mt-2 w-full" v-model="form.audience" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs font-semibold text-mosquito-ink/70">转化条件</label>
|
||||
<input class="mos-input mt-2 w-full" v-model="form.conversion" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="currentStep === 2" class="space-y-4">
|
||||
<div>
|
||||
<label class="text-xs font-semibold text-mosquito-ink/70">奖励规则</label>
|
||||
<input class="mos-input mt-2 w-full" v-model="form.reward" />
|
||||
</div>
|
||||
<div>
|
||||
<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>
|
||||
|
||||
<div v-if="currentStep === 3" class="space-y-4">
|
||||
<div>
|
||||
<label class="text-xs font-semibold text-mosquito-ink/70">开始时间</label>
|
||||
<input class="mos-input mt-2 w-full" type="date" v-model="form.startDate" />
|
||||
</div>
|
||||
<div>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<button class="mos-btn mos-btn-secondary" :disabled="currentStep === 0" @click="prevStep">上一步</button>
|
||||
<button class="mos-btn mos-btn-accent" :disabled="currentStep === steps.length - 1" @click="nextStep">下一步</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
const steps = ['基础信息', '受众与转化', '奖励与预算', '发布设置']
|
||||
const currentStep = ref(0)
|
||||
const form = ref({
|
||||
name: '裂变增长计划',
|
||||
description: '邀请好友注册,获取双倍奖励。',
|
||||
audience: '新注册用户与邀请达人',
|
||||
conversion: '完成注册并绑定手机号',
|
||||
reward: '每邀请 1 人奖励 20 积分',
|
||||
budget: '总预算 50,000 积分',
|
||||
startDate: '',
|
||||
endDate: ''
|
||||
})
|
||||
|
||||
const prevStep = () => {
|
||||
if (currentStep.value > 0) currentStep.value--
|
||||
}
|
||||
|
||||
const nextStep = () => {
|
||||
if (currentStep.value < steps.length - 1) currentStep.value++
|
||||
}
|
||||
|
||||
const saveConfig = () => {
|
||||
// demo placeholder
|
||||
}
|
||||
</script>
|
||||
69
frontend/admin/src/views/ActivityCreateView.vue
Normal file
69
frontend/admin/src/views/ActivityCreateView.vue
Normal file
@@ -0,0 +1,69 @@
|
||||
<template>
|
||||
<section class="space-y-6">
|
||||
<header class="space-y-2">
|
||||
<h1 class="mos-title text-2xl font-semibold">新建活动</h1>
|
||||
<p class="mos-muted text-sm">配置活动时间、目标用户与奖励规则。</p>
|
||||
</header>
|
||||
|
||||
<div class="mos-card p-5 space-y-4">
|
||||
<div>
|
||||
<label class="text-xs font-semibold text-mosquito-ink/70">活动名称</label>
|
||||
<input class="mos-input mt-2 w-full" v-model="form.name" placeholder="例如:裂变增长计划" />
|
||||
</div>
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<label class="text-xs font-semibold text-mosquito-ink/70">开始时间</label>
|
||||
<input class="mos-input mt-2 w-full" type="date" v-model="form.startDate" />
|
||||
</div>
|
||||
<div>
|
||||
<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>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs font-semibold text-mosquito-ink/70">目标用户</label>
|
||||
<input class="mos-input mt-2 w-full" v-model="form.audience" placeholder="示例:VIP 用户 / 新注册用户" />
|
||||
</div>
|
||||
<button class="mos-btn mos-btn-accent w-full" @click="createActivity">保存并进入配置</button>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useActivityStore } from '../stores/activities'
|
||||
|
||||
const router = useRouter()
|
||||
const store = useActivityStore()
|
||||
const form = ref({
|
||||
name: '',
|
||||
startDate: '',
|
||||
endDate: '',
|
||||
audience: ''
|
||||
})
|
||||
|
||||
const createActivity = () => {
|
||||
const created = store.create({
|
||||
name: form.value.name || '未命名活动',
|
||||
description: '请在配置向导中补充活动描述。',
|
||||
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,
|
||||
config: {
|
||||
audience: form.value.audience || '待配置',
|
||||
conversion: '待配置',
|
||||
reward: '待配置',
|
||||
budget: '待配置'
|
||||
},
|
||||
metrics: {
|
||||
visits: 0,
|
||||
shares: 0,
|
||||
conversions: 0,
|
||||
budgetUsed: 0
|
||||
}
|
||||
})
|
||||
router.push(`/activities/${created.id}`)
|
||||
}
|
||||
</script>
|
||||
233
frontend/admin/src/views/ActivityDetailView.vue
Normal file
233
frontend/admin/src/views/ActivityDetailView.vue
Normal file
@@ -0,0 +1,233 @@
|
||||
<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">{{ activity?.name || '活动详情' }}</h1>
|
||||
<p class="mos-muted mt-2 text-sm">{{ activity?.description }}</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="mos-pill">{{ statusLabel }}</span>
|
||||
<button class="mos-btn mos-btn-secondary" @click="toggleStatus">
|
||||
{{ toggleLabel }}
|
||||
</button>
|
||||
<button class="mos-btn mos-btn-accent" @click="endActivity">下线</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="grid gap-4 md:grid-cols-4">
|
||||
<div class="mos-card p-4" v-for="metric in metricsCards" :key="metric.label">
|
||||
<div class="text-xs font-semibold text-mosquito-ink/70">{{ metric.label }}</div>
|
||||
<div class="mos-kpi mt-3 text-xl font-semibold">{{ metric.value }}</div>
|
||||
<div class="mos-muted mt-1 text-xs">{{ metric.hint }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-6 lg:grid-cols-3">
|
||||
<div class="mos-card lg:col-span-2 p-5 space-y-4">
|
||||
<div class="text-sm font-semibold text-mosquito-ink">活动配置</div>
|
||||
<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>
|
||||
<div>
|
||||
<div class="text-xs text-mosquito-ink/70">转化条件</div>
|
||||
<div class="font-semibold">{{ activity?.config.conversion }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xs text-mosquito-ink/70">奖励规则</div>
|
||||
<div class="font-semibold">{{ activity?.config.reward }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xs text-mosquito-ink/70">预算/限额</div>
|
||||
<div class="font-semibold">{{ activity?.config.budget }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<RouterLink to="/activities/config" class="text-sm font-semibold text-mosquito-accent">
|
||||
进入配置向导
|
||||
</RouterLink>
|
||||
</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>
|
||||
<select class="mos-input !py-1 !px-2 !text-xs" v-model="exportType">
|
||||
<option value="summary">活动摘要</option>
|
||||
<option value="conversions">转化明细</option>
|
||||
<option value="rewards">奖励明细</option>
|
||||
</select>
|
||||
</div>
|
||||
<ExportFieldPanel
|
||||
:title="exportTitle"
|
||||
:fields="currentFields"
|
||||
:selected="currentSelected"
|
||||
@update:selected="setCurrentSelected"
|
||||
@export="exportData"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mos-card p-5 space-y-4">
|
||||
<div class="text-sm font-semibold text-mosquito-ink">排行榜预览</div>
|
||||
<MosquitoLeaderboard v-if="activity" :activity-id="activity.id" :top-n="5" />
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRoute, RouterLink } from 'vue-router'
|
||||
import MosquitoLeaderboard from '../../../components/MosquitoLeaderboard.vue'
|
||||
import { useActivityStore } from '../stores/activities'
|
||||
import { useAuditStore } from '../stores/audit'
|
||||
import type { ActivityItem } from '../stores/activities'
|
||||
import { downloadCsv } from '../utils/export'
|
||||
import ExportFieldPanel, { type ExportField } from '../components/ExportFieldPanel.vue'
|
||||
import { useExportFields } from '../composables/useExportFields'
|
||||
|
||||
const route = useRoute()
|
||||
const store = useActivityStore()
|
||||
const auditStore = useAuditStore()
|
||||
const activity = ref<ActivityItem | null>(null)
|
||||
|
||||
const loadActivity = () => {
|
||||
const id = Number(route.params.id)
|
||||
activity.value = store.byId(id)
|
||||
}
|
||||
|
||||
const statusLabel = computed(() => {
|
||||
if (!activity.value) return '未知'
|
||||
const map: Record<string, string> = {
|
||||
draft: '草稿',
|
||||
scheduled: '待上线',
|
||||
active: '进行中',
|
||||
paused: '已暂停',
|
||||
ended: '已结束'
|
||||
}
|
||||
return map[activity.value.status] ?? '未知'
|
||||
})
|
||||
|
||||
const toggleLabel = computed(() => {
|
||||
if (!activity.value) return '切换状态'
|
||||
return activity.value.status === 'active' ? '暂停' : '上线'
|
||||
})
|
||||
|
||||
const toggleStatus = () => {
|
||||
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 endActivity = () => {
|
||||
if (!activity.value) return
|
||||
activity.value = store.updateStatus(activity.value.id, 'ended')
|
||||
auditStore.addLog('下线活动', activity.value?.name ?? '活动')
|
||||
}
|
||||
|
||||
const metricsCards = computed(() => {
|
||||
if (!activity.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: '已消耗积分' }
|
||||
]
|
||||
})
|
||||
|
||||
type ExportType = 'summary' | 'conversions' | 'rewards'
|
||||
|
||||
const exportType = ref<ExportType>('summary')
|
||||
const summaryFields: ExportField[] = [
|
||||
{ key: 'name', label: '活动名称', required: true },
|
||||
{ key: 'status', label: '状态' },
|
||||
{ key: 'startTime', label: '开始时间' },
|
||||
{ key: 'endTime', label: '结束时间' },
|
||||
{ key: 'visits', label: '访问' },
|
||||
{ key: 'shares', label: '分享' },
|
||||
{ key: 'conversions', label: '转化' },
|
||||
{ key: 'budgetUsed', label: '预算消耗' }
|
||||
]
|
||||
const conversionFields: ExportField[] = [
|
||||
{ key: 'user', label: '用户', required: true },
|
||||
{ key: 'channel', label: '来源' },
|
||||
{ key: 'convertedAt', label: '转化时间' },
|
||||
{ key: 'reward', label: '奖励' }
|
||||
]
|
||||
const rewardFields: ExportField[] = [
|
||||
{ key: 'user', label: '用户', required: true },
|
||||
{ key: 'points', label: '积分' },
|
||||
{ key: 'status', label: '状态' },
|
||||
{ key: 'issuedAt', label: '发放时间' }
|
||||
]
|
||||
|
||||
const exportFieldsMap: Record<ExportType, ExportField[]> = {
|
||||
summary: summaryFields,
|
||||
conversions: conversionFields,
|
||||
rewards: rewardFields
|
||||
}
|
||||
|
||||
const exportStates = {
|
||||
summary: useExportFields(summaryFields, summaryFields.map((field) => field.key)),
|
||||
conversions: useExportFields(conversionFields, conversionFields.map((field) => field.key)),
|
||||
rewards: useExportFields(rewardFields, rewardFields.map((field) => field.key))
|
||||
}
|
||||
|
||||
const exportTitle = computed(() => {
|
||||
if (exportType.value === 'summary') return '导出活动摘要字段'
|
||||
if (exportType.value === 'conversions') return '导出转化明细字段'
|
||||
return '导出奖励明细字段'
|
||||
})
|
||||
|
||||
const currentFields = computed(() => exportFieldsMap[exportType.value])
|
||||
const currentSelected = computed(() => exportStates[exportType.value].selected.value)
|
||||
|
||||
const setCurrentSelected = (next: string[]) => {
|
||||
exportStates[exportType.value].setSelected(next)
|
||||
}
|
||||
|
||||
const exportData = () => {
|
||||
const fields = currentFields.value
|
||||
const selectedKeys = currentSelected.value
|
||||
const filename = `${activity.value?.name ?? 'activity'}-${exportType.value}.csv`
|
||||
|
||||
if (exportType.value === 'summary') {
|
||||
const values: Record<string, string> = {
|
||||
name: activity.value?.name ?? '',
|
||||
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)
|
||||
}
|
||||
const rows = fields
|
||||
.filter((field) => selectedKeys.includes(field.key))
|
||||
.map((field) => [field.label, values[field.key] ?? ''])
|
||||
downloadCsv(filename, ['字段', '值'], rows)
|
||||
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')
|
||||
}
|
||||
|
||||
const rows = fields
|
||||
.filter((field) => selectedKeys.includes(field.key))
|
||||
.map((field) => [field.label, String(sample[field.key as keyof typeof sample] ?? '')])
|
||||
downloadCsv(filename, ['字段', '值'], rows)
|
||||
}
|
||||
|
||||
onMounted(loadActivity)
|
||||
</script>
|
||||
219
frontend/admin/src/views/ActivityListView.vue
Normal file
219
frontend/admin/src/views/ActivityListView.vue
Normal file
@@ -0,0 +1,219 @@
|
||||
<template>
|
||||
<section class="space-y-6">
|
||||
|
||||
<div v-if="!hasAuth" class="mos-card border-dashed p-4 text-sm text-mosquito-ink/70">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="flex h-10 w-10 items-center justify-center rounded-2xl bg-mosquito-accent/10 text-mosquito-accent">!</div>
|
||||
<div>
|
||||
<div class="font-semibold text-mosquito-ink">未配置鉴权信息</div>
|
||||
<div class="mos-muted mt-1 text-xs">请设置 API Key 与用户令牌后查看活动列表。</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loadError" class="mos-card border-dashed p-4 text-sm text-rose-600">
|
||||
{{ loadError }}
|
||||
</div>
|
||||
|
||||
<ListSection :page="page" :total-pages="totalPages" @prev="page--" @next="page++">
|
||||
<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>
|
||||
</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>
|
||||
</template>
|
||||
<template #default>
|
||||
<div v-if="pagedActivities.length" class="space-y-3">
|
||||
<RouterLink
|
||||
v-for="item in pagedActivities"
|
||||
:key="item.name"
|
||||
:to="`/activities/${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>
|
||||
<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>
|
||||
</RouterLink>
|
||||
</div>
|
||||
</template>
|
||||
<template #empty>
|
||||
<div v-if="!pagedActivities.length" class="mt-6 rounded-xl border border-dashed border-mosquito-line p-6 text-sm text-mosquito-ink/70">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="flex h-10 w-10 items-center justify-center rounded-2xl bg-mosquito-accent/10 text-mosquito-accent">+</div>
|
||||
<div>
|
||||
<div class="font-semibold text-mosquito-ink">暂无活动数据</div>
|
||||
<div class="mos-muted mt-1 text-xs">点击“新建活动”开始配置分享任务。</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</ListSection>
|
||||
|
||||
<div class="mos-card p-5">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<div class="text-xs font-semibold text-mosquito-ink/70">排行榜预览</div>
|
||||
<div class="mos-muted mt-1 text-xs">数据来自 /api/v1/activities/{id}/leaderboard</div>
|
||||
</div>
|
||||
<span class="mos-pill">示例</span>
|
||||
</div>
|
||||
|
||||
<div v-if="hasAuth && activitiesWithMeta.length" class="mt-4">
|
||||
<MosquitoLeaderboard
|
||||
:activity-id="activeActivityId"
|
||||
:top-n="3"
|
||||
:current-user-id="currentUserId"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-else-if="hasAuth" class="mt-6 rounded-xl border border-dashed border-mosquito-line p-6 text-sm text-mosquito-ink/70">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="flex h-10 w-10 items-center justify-center rounded-2xl bg-mosquito-accent/10 text-mosquito-accent">!</div>
|
||||
<div>
|
||||
<div class="font-semibold text-mosquito-ink">暂无活动榜单</div>
|
||||
<div class="mos-muted mt-1 text-xs">请先创建活动后再查看排行。</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="mt-6 rounded-xl border border-dashed border-mosquito-line p-6 text-sm text-mosquito-ink/70">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="flex h-10 w-10 items-center justify-center rounded-2xl bg-mosquito-accent/10 text-mosquito-accent">!</div>
|
||||
<div>
|
||||
<div class="font-semibold text-mosquito-ink">配置鉴权后可查看榜单</div>
|
||||
<div class="mos-muted mt-1 text-xs">请设置 API Key 与用户令牌。</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useRoute, RouterLink } from 'vue-router'
|
||||
import MosquitoLeaderboard from '../../../components/MosquitoLeaderboard.vue'
|
||||
import { getUserIdFromToken, parseUserId } from '../../../shared/auth'
|
||||
import { useDataService } from '../services'
|
||||
import ListSection from '../components/ListSection.vue'
|
||||
|
||||
type ActivitySummary = {
|
||||
id: number
|
||||
name: string
|
||||
startTime?: string
|
||||
endTime?: string
|
||||
}
|
||||
|
||||
const activityId = 1
|
||||
const service = useDataService()
|
||||
const route = useRoute()
|
||||
const userToken = import.meta.env.VITE_MOSQUITO_USER_TOKEN
|
||||
const hasAuth = computed(() => true)
|
||||
const routeUserId = computed(() => parseUserId(route.query.userId ?? route.params.userId))
|
||||
const currentUserId = computed(() => getUserIdFromToken(userToken) ?? routeUserId.value ?? undefined)
|
||||
const activities = ref<ActivitySummary[]>([])
|
||||
const loadError = ref('')
|
||||
const activeActivityId = computed(() => activities.value[0]?.id ?? activityId)
|
||||
const query = ref('')
|
||||
const statusFilter = ref('')
|
||||
const startDate = ref('')
|
||||
const endDate = ref('')
|
||||
const page = ref(0)
|
||||
const pageSize = 6
|
||||
|
||||
const formatPeriod = (activity: ActivitySummary) => {
|
||||
if (!activity.startTime || !activity.endTime) {
|
||||
return '活动时间待配置'
|
||||
}
|
||||
const start = new Date(activity.startTime)
|
||||
const end = new Date(activity.endTime)
|
||||
if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) {
|
||||
return '活动时间待配置'
|
||||
}
|
||||
return `${start.toLocaleDateString('zh-CN')} - ${end.toLocaleDateString('zh-CN')}`
|
||||
}
|
||||
|
||||
const resolveStatus = (activity: ActivitySummary) => {
|
||||
if (!activity.startTime || !activity.endTime) {
|
||||
return '待配置'
|
||||
}
|
||||
const now = Date.now()
|
||||
const start = new Date(activity.startTime).getTime()
|
||||
const end = new Date(activity.endTime).getTime()
|
||||
if (Number.isNaN(start) || Number.isNaN(end)) {
|
||||
return '待配置'
|
||||
}
|
||||
if (now < start) {
|
||||
return '未开始'
|
||||
}
|
||||
if (now > end) {
|
||||
return '已结束'
|
||||
}
|
||||
return '进行中'
|
||||
}
|
||||
|
||||
const activitiesWithMeta = computed(() =>
|
||||
activities.value.map((item) => ({
|
||||
id: item.id,
|
||||
name: item.name ?? `活动 #${item.id}`,
|
||||
period: formatPeriod(item),
|
||||
participants: 0,
|
||||
status: resolveStatus(item),
|
||||
startTime: item.startTime ?? '',
|
||||
endTime: item.endTime ?? ''
|
||||
}))
|
||||
)
|
||||
|
||||
const filteredActivities = computed(() => {
|
||||
return activitiesWithMeta.value.filter((item) => {
|
||||
const matchesQuery = item.name.includes(query.value.trim())
|
||||
const matchesStatus = statusFilter.value ? item.status === 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
|
||||
})
|
||||
})
|
||||
|
||||
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)
|
||||
})
|
||||
|
||||
watch([query, statusFilter, startDate, endDate], () => {
|
||||
page.value = 0
|
||||
})
|
||||
|
||||
const loadActivities = async () => {
|
||||
loadError.value = ''
|
||||
try {
|
||||
const list = await service.getActivities()
|
||||
activities.value = list
|
||||
} catch (error) {
|
||||
loadError.value = '活动列表加载失败。'
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadActivities()
|
||||
})
|
||||
</script>
|
||||
323
frontend/admin/src/views/ApprovalCenterView.vue
Normal file
323
frontend/admin/src/views/ApprovalCenterView.vue
Normal file
@@ -0,0 +1,323 @@
|
||||
<template>
|
||||
<section class="space-y-6">
|
||||
<header class="space-y-2">
|
||||
<h1 class="mos-title text-2xl font-semibold">审批中心</h1>
|
||||
<p class="mos-muted text-sm">处理角色变更与邀请审批。</p>
|
||||
</header>
|
||||
|
||||
<ListSection :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" />
|
||||
<input class="mos-input !py-1 !px-2 !text-xs" type="date" v-model="requestEnd" />
|
||||
<input class="mos-input !py-1 !px-2 !text-xs w-48" v-model="batchRejectReason" placeholder="批量拒绝原因" />
|
||||
</template>
|
||||
<template #actions>
|
||||
<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>
|
||||
</template>
|
||||
<template #default>
|
||||
<div v-if="pagedRequests.length" class="space-y-3">
|
||||
<div v-for="request in pagedRequests" :key="request.id" class="rounded-xl border border-mosquito-line px-4 py-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="h-4 w-4"
|
||||
:checked="selectedRequestIds.includes(request.id)"
|
||||
@click.stop
|
||||
@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>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
class="rounded-full px-2 py-1 text-[10px] font-semibold"
|
||||
:class="slaClass(getSlaBadge(request.requestedAt).level)"
|
||||
>
|
||||
{{ 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>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="rejectingId === request.id" class="mt-3 flex flex-wrap items-center gap-2">
|
||||
<input class="mos-input !py-1 !px-2 !text-xs flex-1" v-model="rejectReason" placeholder="请输入拒绝原因" />
|
||||
<button class="mos-btn mos-btn-secondary !py-1 !px-2 !text-xs" @click="cancelReject">取消</button>
|
||||
<button class="mos-btn mos-btn-accent !py-1 !px-2 !text-xs" @click="confirmReject(request)">确认拒绝</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #empty>
|
||||
<div v-if="!pagedRequests.length" class="mt-4 text-sm text-mosquito-ink/60">暂无待审批申请</div>
|
||||
</template>
|
||||
</ListSection>
|
||||
|
||||
<ListSection :page="invitePage" :total-pages="inviteTotalPages" @prev="invitePage--" @next="invitePage++">
|
||||
<template #title>邀请申请</template>
|
||||
<template #filters>
|
||||
<input class="mos-input !py-1 !px-2 !text-xs w-44" v-model="inviteQuery" placeholder="搜索邮箱" />
|
||||
<input class="mos-input !py-1 !px-2 !text-xs" type="date" v-model="inviteStart" />
|
||||
<input class="mos-input !py-1 !px-2 !text-xs" type="date" v-model="inviteEnd" />
|
||||
</template>
|
||||
<template #actions>
|
||||
<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>
|
||||
</template>
|
||||
<template #default>
|
||||
<div v-if="pagedInvites.length" class="space-y-3">
|
||||
<div v-for="invite in pagedInvites" :key="invite.id" class="flex items-center justify-between rounded-xl border border-mosquito-line px-4 py-3">
|
||||
<div class="flex items-center gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="h-4 w-4"
|
||||
:checked="selectedInviteIds.includes(invite.id)"
|
||||
@click.stop
|
||||
@change.stop="toggleInviteSelect(invite.id)"
|
||||
/>
|
||||
<div>
|
||||
<div class="text-sm font-semibold text-mosquito-ink">{{ invite.email }}</div>
|
||||
<div class="mos-muted text-xs">角色:{{ roleLabel(invite.role) }}</div>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #empty>
|
||||
<div v-if="!pagedInvites.length" class="mt-4 text-sm text-mosquito-ink/60">暂无待审批邀请</div>
|
||||
</template>
|
||||
</ListSection>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
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 ListSection from '../components/ListSection.vue'
|
||||
import { getSlaBadge, normalizeRejectReason } from '../utils/approval'
|
||||
|
||||
const store = useUserStore()
|
||||
const service = useDataService()
|
||||
const auditStore = useAuditStore()
|
||||
const rejectingId = ref<string | null>(null)
|
||||
const rejectReason = ref('')
|
||||
const batchRejectReason = ref('')
|
||||
const requestQuery = ref('')
|
||||
const inviteQuery = ref('')
|
||||
const requestStart = ref('')
|
||||
const requestEnd = ref('')
|
||||
const inviteStart = ref('')
|
||||
const inviteEnd = ref('')
|
||||
const requestPage = ref(0)
|
||||
const invitePage = ref(0)
|
||||
const pageSize = 6
|
||||
const selectedRequestIds = ref<string[]>([])
|
||||
const selectedInviteIds = ref<string[]>([])
|
||||
|
||||
onMounted(async () => {
|
||||
const [users, invites, requests] = await Promise.all([
|
||||
service.getUsers(),
|
||||
service.getInvites(),
|
||||
service.getRoleRequests()
|
||||
])
|
||||
store.init(users, invites, requests)
|
||||
})
|
||||
|
||||
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 '只读'
|
||||
}
|
||||
|
||||
const getUserName = (id: string) => store.byId(id)?.name ?? id
|
||||
|
||||
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 setRejecting = (id: string) => {
|
||||
rejectingId.value = id
|
||||
rejectReason.value = ''
|
||||
}
|
||||
|
||||
const cancelReject = () => {
|
||||
rejectingId.value = null
|
||||
rejectReason.value = ''
|
||||
}
|
||||
|
||||
const confirmReject = (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}`
|
||||
})
|
||||
cancelReject()
|
||||
}
|
||||
|
||||
const acceptInvite = (invite: InviteRequest) => {
|
||||
store.acceptInvite(invite.id)
|
||||
auditStore.addLog('审批通过邀请', invite.email)
|
||||
service.addNotification({
|
||||
title: '邀请审批通过',
|
||||
detail: `${invite.email} 已通过`
|
||||
})
|
||||
}
|
||||
|
||||
const rejectInvite = (invite: InviteRequest) => {
|
||||
invite.status = '已拒绝'
|
||||
auditStore.addLog('审批拒绝邀请', invite.email)
|
||||
service.addNotification({
|
||||
title: '邀请审批拒绝',
|
||||
detail: `${invite.email} 已拒绝`
|
||||
})
|
||||
}
|
||||
|
||||
const filteredRequests = computed(() => {
|
||||
return pendingRequests.value.filter((request) => {
|
||||
const matchesQuery = getUserName(request.userId).includes(requestQuery.value)
|
||||
const startOk = requestStart.value ? new Date(request.requestedAt).getTime() >= new Date(requestStart.value).getTime() : true
|
||||
const endOk = requestEnd.value ? new Date(request.requestedAt).getTime() <= new Date(requestEnd.value).getTime() : true
|
||||
return matchesQuery && startOk && endOk
|
||||
})
|
||||
})
|
||||
|
||||
const filteredInvites = computed(() => {
|
||||
return pendingInvites.value.filter((invite) => {
|
||||
const matchesQuery = invite.email.includes(inviteQuery.value)
|
||||
const startOk = inviteStart.value ? new Date(invite.invitedAt).getTime() >= new Date(inviteStart.value).getTime() : true
|
||||
const endOk = inviteEnd.value ? new Date(invite.invitedAt).getTime() <= new Date(inviteEnd.value).getTime() : true
|
||||
return matchesQuery && startOk && endOk
|
||||
})
|
||||
})
|
||||
|
||||
const requestTotalPages = computed(() => Math.max(1, Math.ceil(filteredRequests.value.length / pageSize)))
|
||||
const inviteTotalPages = computed(() => Math.max(1, Math.ceil(filteredInvites.value.length / pageSize)))
|
||||
|
||||
const pagedRequests = computed(() => {
|
||||
const start = requestPage.value * pageSize
|
||||
return filteredRequests.value.slice(start, start + pageSize)
|
||||
})
|
||||
|
||||
const pagedInvites = computed(() => {
|
||||
const start = invitePage.value * pageSize
|
||||
return filteredInvites.value.slice(start, start + pageSize)
|
||||
})
|
||||
|
||||
watch([requestQuery, requestStart, requestEnd], () => {
|
||||
requestPage.value = 0
|
||||
})
|
||||
|
||||
watch([inviteQuery, inviteStart, inviteEnd], () => {
|
||||
invitePage.value = 0
|
||||
})
|
||||
|
||||
const allRequestsSelected = computed(() => {
|
||||
return filteredRequests.value.length > 0 && filteredRequests.value.every((req) => selectedRequestIds.value.includes(req.id))
|
||||
})
|
||||
|
||||
const allInvitesSelected = computed(() => {
|
||||
return filteredInvites.value.length > 0 && filteredInvites.value.every((invite) => selectedInviteIds.value.includes(invite.id))
|
||||
})
|
||||
|
||||
const toggleRequestSelect = (id: string) => {
|
||||
if (selectedRequestIds.value.includes(id)) {
|
||||
selectedRequestIds.value = selectedRequestIds.value.filter((item) => item !== id)
|
||||
} else {
|
||||
selectedRequestIds.value = [...selectedRequestIds.value, id]
|
||||
}
|
||||
}
|
||||
|
||||
const toggleInviteSelect = (id: string) => {
|
||||
if (selectedInviteIds.value.includes(id)) {
|
||||
selectedInviteIds.value = selectedInviteIds.value.filter((item) => item !== id)
|
||||
} else {
|
||||
selectedInviteIds.value = [...selectedInviteIds.value, id]
|
||||
}
|
||||
}
|
||||
|
||||
const selectAllRequests = () => {
|
||||
if (allRequestsSelected.value) {
|
||||
selectedRequestIds.value = []
|
||||
} else {
|
||||
selectedRequestIds.value = filteredRequests.value.map((req) => req.id)
|
||||
}
|
||||
}
|
||||
|
||||
const selectAllInvites = () => {
|
||||
if (allInvitesSelected.value) {
|
||||
selectedInviteIds.value = []
|
||||
} else {
|
||||
selectedInviteIds.value = filteredInvites.value.map((inv) => inv.id)
|
||||
}
|
||||
}
|
||||
|
||||
const batchApprove = () => {
|
||||
filteredRequests.value
|
||||
.filter((req) => selectedRequestIds.value.includes(req.id))
|
||||
.forEach(approve)
|
||||
}
|
||||
|
||||
const batchReject = () => {
|
||||
const reason = normalizeRejectReason(batchRejectReason.value)
|
||||
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: '角色变更审批拒绝',
|
||||
detail: `${getUserName(req.userId)}:${reason}`
|
||||
})
|
||||
})
|
||||
selectedRequestIds.value = []
|
||||
batchRejectReason.value = ''
|
||||
}
|
||||
|
||||
const batchAcceptInvites = () => {
|
||||
filteredInvites.value
|
||||
.filter((inv) => selectedInviteIds.value.includes(inv.id))
|
||||
.forEach(acceptInvite)
|
||||
selectedInviteIds.value = []
|
||||
}
|
||||
|
||||
const batchRejectInvites = () => {
|
||||
filteredInvites.value
|
||||
.filter((inv) => selectedInviteIds.value.includes(inv.id))
|
||||
.forEach(rejectInvite)
|
||||
selectedInviteIds.value = []
|
||||
}
|
||||
</script>
|
||||
163
frontend/admin/src/views/AuditLogView.vue
Normal file
163
frontend/admin/src/views/AuditLogView.vue
Normal file
@@ -0,0 +1,163 @@
|
||||
<template>
|
||||
<section class="space-y-6">
|
||||
<ListSection :page="page" :total-pages="totalPages" @prev="page--" @next="page++">
|
||||
<template #title>审计日志</template>
|
||||
<template #subtitle>记录关键操作与配置变更。</template>
|
||||
<template #filters>
|
||||
<input class="mos-input !py-1 !px-2 !text-xs w-56" v-model="query" placeholder="搜索操作人/资源" />
|
||||
<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>
|
||||
<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>
|
||||
</template>
|
||||
<template #default>
|
||||
<div class="space-y-3">
|
||||
<div v-for="log in pagedLogs" :key="log.id" class="flex items-center justify-between rounded-xl border border-mosquito-line px-4 py-3">
|
||||
<div class="flex items-center gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="h-4 w-4"
|
||||
:checked="selectedIds.includes(log.id)"
|
||||
@click.stop
|
||||
@change.stop="toggleSelect(log.id)"
|
||||
/>
|
||||
<div>
|
||||
<div class="text-sm font-semibold text-mosquito-ink">{{ log.action }}</div>
|
||||
<div class="mos-muted text-xs">{{ log.resource }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-xs text-mosquito-ink/70">
|
||||
<div>{{ log.actor }}</div>
|
||||
<div class="mos-muted">{{ formatDate(log.createdAt) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #footer>
|
||||
<ExportFieldPanel
|
||||
title="导出字段"
|
||||
:fields="exportFields"
|
||||
:selected="exportSelected"
|
||||
@update:selected="setExportSelected"
|
||||
@export="exportLogs"
|
||||
/>
|
||||
</template>
|
||||
</ListSection>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useDataService } from '../services'
|
||||
import { downloadCsv } from '../utils/export'
|
||||
import { useAuditStore } from '../stores/audit'
|
||||
import ListSection from '../components/ListSection.vue'
|
||||
import ExportFieldPanel, { type ExportField } from '../components/ExportFieldPanel.vue'
|
||||
import { useExportFields } from '../composables/useExportFields'
|
||||
|
||||
type AuditLog = {
|
||||
id: string
|
||||
actor: string
|
||||
action: string
|
||||
resource: string
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
const logs = ref<AuditLog[]>([])
|
||||
const service = useDataService()
|
||||
const auditStore = useAuditStore()
|
||||
const query = ref('')
|
||||
const startDate = ref('')
|
||||
const endDate = ref('')
|
||||
const selectedIds = ref<string[]>([])
|
||||
const page = ref(0)
|
||||
const pageSize = 8
|
||||
|
||||
const formatDate = (value: string) => new Date(value).toLocaleString('zh-CN')
|
||||
|
||||
onMounted(async () => {
|
||||
const initial = await service.getAuditLogs()
|
||||
auditStore.init(initial)
|
||||
logs.value = auditStore.items
|
||||
})
|
||||
|
||||
const exportFields: ExportField[] = [
|
||||
{ key: 'actor', label: '操作人', required: true },
|
||||
{ key: 'action', label: '动作' },
|
||||
{ key: 'resource', label: '资源' },
|
||||
{ key: 'createdAt', label: '时间' }
|
||||
]
|
||||
const { selected: exportSelected, setSelected: setExportSelected } = useExportFields(
|
||||
exportFields,
|
||||
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
|
||||
.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(() => {
|
||||
return logs.value.filter((item) => {
|
||||
const keyword = query.value.trim()
|
||||
const matchesKeyword = keyword ? item.actor.includes(keyword) || item.resource.includes(keyword) : true
|
||||
const startOk = startDate.value ? new Date(item.createdAt).getTime() >= new Date(startDate.value).getTime() : true
|
||||
const endOk = endDate.value ? new Date(item.createdAt).getTime() <= new Date(endDate.value).getTime() : true
|
||||
return matchesKeyword && startOk && endOk
|
||||
})
|
||||
})
|
||||
|
||||
const totalPages = computed(() => Math.max(1, Math.ceil(filteredLogs.value.length / pageSize)))
|
||||
|
||||
const pagedLogs = computed(() => {
|
||||
const start = page.value * pageSize
|
||||
return filteredLogs.value.slice(start, start + pageSize)
|
||||
})
|
||||
|
||||
watch([query, startDate, endDate], () => {
|
||||
page.value = 0
|
||||
})
|
||||
|
||||
const allSelected = computed(() => {
|
||||
return filteredLogs.value.length > 0 && filteredLogs.value.every((item) => selectedIds.value.includes(item.id))
|
||||
})
|
||||
|
||||
const toggleSelect = (id: string) => {
|
||||
if (selectedIds.value.includes(id)) {
|
||||
selectedIds.value = selectedIds.value.filter((item) => item !== id)
|
||||
} else {
|
||||
selectedIds.value = [...selectedIds.value, id]
|
||||
}
|
||||
}
|
||||
|
||||
const selectAll = () => {
|
||||
if (allSelected.value) {
|
||||
selectedIds.value = []
|
||||
} else {
|
||||
selectedIds.value = filteredLogs.value.map((item) => item.id)
|
||||
}
|
||||
}
|
||||
|
||||
const batchExport = () => {
|
||||
const rows = filteredLogs.value
|
||||
.filter((item) => selectedIds.value.includes(item.id))
|
||||
.map((item) => [item.actor, item.action, item.resource, formatDate(item.createdAt)])
|
||||
downloadCsv('audit-logs-selected.csv', ['操作人', '动作', '资源', '时间'], rows)
|
||||
}
|
||||
</script>
|
||||
195
frontend/admin/src/views/DashboardView.vue
Normal file
195
frontend/admin/src/views/DashboardView.vue
Normal file
@@ -0,0 +1,195 @@
|
||||
<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 items-center gap-2 text-xs text-mosquito-ink/60">
|
||||
<span class="rounded-full bg-mosquito-accent/10 px-3 py-1 font-semibold text-mosquito-brand">Production</span>
|
||||
最近更新:{{ updatedAt }}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div v-if="!hasAuth" class="mos-card border-dashed p-4 text-sm text-mosquito-ink/70">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="flex h-10 w-10 items-center justify-center rounded-2xl bg-mosquito-accent/10 text-mosquito-accent">!</div>
|
||||
<div>
|
||||
<div class="font-semibold text-mosquito-ink">未配置鉴权信息</div>
|
||||
<div class="mos-muted mt-1 text-xs">请设置 API Key 与用户令牌后加载实时数据。</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loadError" class="mos-card border-dashed p-4 text-sm text-rose-600">
|
||||
{{ loadError }}
|
||||
</div>
|
||||
|
||||
<section class="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
<div v-for="card in kpis" :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-mosquito-accent/10 px-2 py-1 text-[10px] font-semibold text-mosquito-brand">
|
||||
{{ card.status }}
|
||||
</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-3">
|
||||
<div class="mos-card lg:col-span-2 p-5">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="mos-title text-lg font-semibold">活动列表</h2>
|
||||
<p class="mos-muted mt-1 text-xs">跟踪当前活动的进展与参与度。</p>
|
||||
</div>
|
||||
<RouterLink to="/activities" class="text-sm font-semibold text-mosquito-accent">查看全部</RouterLink>
|
||||
</div>
|
||||
|
||||
<div v-if="activitiesWithMeta.length" class="mt-4 space-y-3">
|
||||
<RouterLink
|
||||
v-for="item in activitiesWithMeta"
|
||||
:key="item.name"
|
||||
:to="`/activities/${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>
|
||||
<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>
|
||||
</RouterLink>
|
||||
</div>
|
||||
|
||||
<div v-else class="mt-6 rounded-xl border border-dashed border-mosquito-line p-6 text-sm text-mosquito-ink/70">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="flex h-10 w-10 items-center justify-center rounded-2xl bg-mosquito-accent/10 text-mosquito-accent">+</div>
|
||||
<div>
|
||||
<div class="font-semibold text-mosquito-ink">暂无活动</div>
|
||||
<div class="mos-muted mt-1 text-xs">创建第一个活动,开始追踪分享转化。</div>
|
||||
</div>
|
||||
</div>
|
||||
</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 v-if="alerts.length" class="mt-4 space-y-3">
|
||||
<div v-for="alert in alerts" :key="alert.title" class="rounded-xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700">
|
||||
<div class="font-semibold">{{ alert.title }}</div>
|
||||
<div class="text-xs text-rose-600">{{ alert.detail }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="mt-6 rounded-xl border border-dashed border-mosquito-line p-6 text-sm text-mosquito-ink/70">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="flex h-10 w-10 items-center justify-center rounded-2xl bg-mosquito-accent/10 text-mosquito-accent">✓</div>
|
||||
<div>
|
||||
<div class="font-semibold text-mosquito-ink">暂无异常</div>
|
||||
<div class="mos-muted mt-1 text-xs">系统运行正常,保持监控即可。</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import { useDataService } from '../services'
|
||||
|
||||
type ActivitySummary = {
|
||||
id: number
|
||||
name: string
|
||||
startTime?: string
|
||||
endTime?: string
|
||||
}
|
||||
|
||||
const service = useDataService()
|
||||
const hasAuth = computed(() => true)
|
||||
|
||||
const updatedAt = ref('刚刚')
|
||||
const loadError = ref('')
|
||||
const activities = ref<ActivitySummary[]>([])
|
||||
const alerts: { title: string; detail: string }[] = []
|
||||
const kpis = ref([
|
||||
{ label: '访问', value: null as number | null, status: '待同步', hint: '接入埋点后显示实时数据' },
|
||||
{ label: '分享', value: null as number | null, status: '待同步', hint: '活动开启后统计分享次数' },
|
||||
{ label: '转化', value: null as number | null, status: '待同步', hint: '用户注册转化将在此展示' },
|
||||
{ label: '新增', value: null as number | null, status: '待同步', hint: '当前暂无新增用户' }
|
||||
])
|
||||
|
||||
const formatNumber = (value: number | null) => {
|
||||
if (value === null || Number.isNaN(value)) {
|
||||
return '--'
|
||||
}
|
||||
return value.toLocaleString('zh-CN')
|
||||
}
|
||||
|
||||
const formatPeriod = (activity: ActivitySummary) => {
|
||||
if (!activity.startTime || !activity.endTime) {
|
||||
return '活动时间待配置'
|
||||
}
|
||||
const start = new Date(activity.startTime)
|
||||
const end = new Date(activity.endTime)
|
||||
if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) {
|
||||
return '活动时间待配置'
|
||||
}
|
||||
return `${start.toLocaleDateString('zh-CN')} - ${end.toLocaleDateString('zh-CN')}`
|
||||
}
|
||||
|
||||
const resolveStatus = (activity: ActivitySummary) => {
|
||||
if (!activity.startTime || !activity.endTime) {
|
||||
return '待配置'
|
||||
}
|
||||
const now = Date.now()
|
||||
const start = new Date(activity.startTime).getTime()
|
||||
const end = new Date(activity.endTime).getTime()
|
||||
if (Number.isNaN(start) || Number.isNaN(end)) {
|
||||
return '待配置'
|
||||
}
|
||||
if (now < start) {
|
||||
return '未开始'
|
||||
}
|
||||
if (now > end) {
|
||||
return '已结束'
|
||||
}
|
||||
return '进行中'
|
||||
}
|
||||
|
||||
const loadData = async () => {
|
||||
loadError.value = ''
|
||||
try {
|
||||
const data = await service.getDashboard()
|
||||
updatedAt.value = data.updatedAt
|
||||
kpis.value = data.kpis
|
||||
activities.value = data.activities
|
||||
alerts.splice(0, alerts.length, ...data.alerts)
|
||||
} catch (error) {
|
||||
loadError.value = '数据加载失败,请稍后重试。'
|
||||
}
|
||||
}
|
||||
|
||||
const activitiesWithMeta = computed(() =>
|
||||
activities.value.map((item) => ({
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
period: formatPeriod(item),
|
||||
participants: 0,
|
||||
status: resolveStatus(item)
|
||||
}))
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
loadData()
|
||||
})
|
||||
</script>
|
||||
15
frontend/admin/src/views/ForbiddenView.vue
Normal file
15
frontend/admin/src/views/ForbiddenView.vue
Normal file
@@ -0,0 +1,15 @@
|
||||
<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">
|
||||
<div class="text-3xl font-semibold text-mosquito-ink">403</div>
|
||||
<div class="text-sm text-mosquito-muted">当前账号无权限访问该页面</div>
|
||||
<div class="text-xs text-mosquito-muted">可在演示模式切换角色后再尝试</div>
|
||||
<RouterLink
|
||||
to="/"
|
||||
class="mt-2 inline-flex items-center justify-center rounded-xl bg-mosquito-accent px-4 py-2 text-sm font-semibold text-white shadow-soft"
|
||||
>
|
||||
返回首页
|
||||
</RouterLink>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
140
frontend/admin/src/views/InviteUserView.vue
Normal file
140
frontend/admin/src/views/InviteUserView.vue
Normal file
@@ -0,0 +1,140 @@
|
||||
<template>
|
||||
<section class="space-y-6">
|
||||
<header class="space-y-2">
|
||||
<h1 class="mos-title text-2xl font-semibold">邀请用户</h1>
|
||||
<p class="mos-muted text-sm">发送邀请链接给新成员。</p>
|
||||
</header>
|
||||
|
||||
<div class="mos-card p-5 space-y-4">
|
||||
<div>
|
||||
<label class="text-xs font-semibold text-mosquito-ink/70">邮箱</label>
|
||||
<input class="mos-input mt-2 w-full" v-model="form.email" placeholder="name@company.com" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs font-semibold text-mosquito-ink/70">角色</label>
|
||||
<select class="mos-input mt-2 w-full" v-model="form.role">
|
||||
<option value="管理员">管理员</option>
|
||||
<option value="运营">运营</option>
|
||||
<option value="只读">只读</option>
|
||||
</select>
|
||||
</div>
|
||||
<button class="mos-btn mos-btn-accent w-full" @click="sendInvite">发送邀请(演示)</button>
|
||||
</div>
|
||||
|
||||
<ListSection :page="page" :total-pages="totalPages" @prev="page--" @next="page++">
|
||||
<template #title>邀请记录</template>
|
||||
<template #filters>
|
||||
<input class="mos-input !py-1 !px-2 !text-xs w-56" 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>
|
||||
</select>
|
||||
</template>
|
||||
<template #default>
|
||||
<div v-if="pagedInvites.length" class="space-y-3">
|
||||
<div v-for="invite in pagedInvites" :key="invite.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">{{ invite.email }}</div>
|
||||
<div class="mos-muted text-xs">角色:{{ roleLabel(invite.role) }}</div>
|
||||
<div class="mos-muted text-xs">邀请时间:{{ formatDate(invite.invitedAt) }}</div>
|
||||
<div v-if="invite.expiredAt" class="mos-muted text-xs">过期时间:{{ formatDate(invite.expiredAt) }}</div>
|
||||
</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
|
||||
v-if="invite.status !== '已接受'"
|
||||
class="mos-btn mos-btn-secondary !py-1 !px-2 !text-xs"
|
||||
@click="resendInvite(invite.id)"
|
||||
>
|
||||
重发
|
||||
</button>
|
||||
<button
|
||||
v-if="invite.status === '待接受'"
|
||||
class="mos-btn mos-btn-secondary !py-1 !px-2 !text-xs"
|
||||
@click="expireInvite(invite.id)"
|
||||
>
|
||||
设为过期
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #empty>
|
||||
<div v-if="!pagedInvites.length" class="text-sm text-mosquito-ink/60">暂无邀请记录</div>
|
||||
</template>
|
||||
</ListSection>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useAuditStore } from '../stores/audit'
|
||||
import { useUserStore } from '../stores/users'
|
||||
import { useDataService } from '../services'
|
||||
import ListSection from '../components/ListSection.vue'
|
||||
|
||||
const auditStore = useAuditStore()
|
||||
const userStore = useUserStore()
|
||||
const service = useDataService()
|
||||
const query = ref('')
|
||||
const statusFilter = ref('')
|
||||
const page = ref(0)
|
||||
const pageSize = 6
|
||||
const form = ref({
|
||||
email: '',
|
||||
role: '运营'
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
const invites = await service.getInvites()
|
||||
userStore.init([], invites, [])
|
||||
})
|
||||
|
||||
const roleLabel = (role: string) => {
|
||||
if (role === 'admin') return '管理员'
|
||||
if (role === 'operator') return '运营'
|
||||
return '只读'
|
||||
}
|
||||
|
||||
const formatDate = (value: string) => new Date(value).toLocaleString('zh-CN')
|
||||
|
||||
const sendInvite = () => {
|
||||
userStore.addInvite(form.value.email || '未填写邮箱', form.value.role === '管理员' ? 'admin' : form.value.role === '运营' ? 'operator' : 'viewer')
|
||||
auditStore.addLog('发送用户邀请', form.value.email || '未填写邮箱')
|
||||
form.value.email = ''
|
||||
form.value.role = '运营'
|
||||
}
|
||||
|
||||
const resendInvite = (id: string) => {
|
||||
userStore.resendInvite(id)
|
||||
const invite = userStore.invites.find((item) => item.id === id)
|
||||
auditStore.addLog('重发邀请', invite?.email ?? id)
|
||||
}
|
||||
|
||||
const expireInvite = (id: string) => {
|
||||
userStore.expireInvite(id)
|
||||
const invite = userStore.invites.find((item) => item.id === id)
|
||||
auditStore.addLog('设置邀请过期', invite?.email ?? id)
|
||||
}
|
||||
|
||||
const filteredInvites = computed(() => {
|
||||
return userStore.invites.filter((invite) => {
|
||||
const matchesQuery = invite.email.includes(query.value.trim())
|
||||
const matchesStatus = statusFilter.value ? invite.status === statusFilter.value : true
|
||||
return matchesQuery && matchesStatus
|
||||
})
|
||||
})
|
||||
|
||||
const totalPages = computed(() => Math.max(1, Math.ceil(filteredInvites.value.length / pageSize)))
|
||||
const pagedInvites = computed(() => {
|
||||
const start = page.value * pageSize
|
||||
return filteredInvites.value.slice(start, start + pageSize)
|
||||
})
|
||||
|
||||
watch([query, statusFilter], () => {
|
||||
page.value = 0
|
||||
})
|
||||
</script>
|
||||
42
frontend/admin/src/views/LoginView.vue
Normal file
42
frontend/admin/src/views/LoginView.vue
Normal file
@@ -0,0 +1,42 @@
|
||||
<template>
|
||||
<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>
|
||||
</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>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
|
||||
const auth = useAuthStore()
|
||||
const router = useRouter()
|
||||
|
||||
const loginDemo = async () => {
|
||||
await auth.loginDemo('admin')
|
||||
await router.push('/')
|
||||
}
|
||||
</script>
|
||||
138
frontend/admin/src/views/NotificationsView.vue
Normal file
138
frontend/admin/src/views/NotificationsView.vue
Normal file
@@ -0,0 +1,138 @@
|
||||
<template>
|
||||
<section class="space-y-6">
|
||||
<ListSection :page="page" :total-pages="totalPages" @prev="page--" @next="page++">
|
||||
<template #title>通知中心</template>
|
||||
<template #subtitle>查看系统告警与运营提醒。</template>
|
||||
<template #filters>
|
||||
<input class="mos-input !py-1 !px-2 !text-xs w-56" v-model="query" placeholder="搜索通知标题" />
|
||||
<select class="mos-input !py-1 !px-2 !text-xs" v-model="readFilter">
|
||||
<option value="">全部状态</option>
|
||||
<option value="unread">未读</option>
|
||||
<option value="read">已读</option>
|
||||
</select>
|
||||
</template>
|
||||
<template #actions>
|
||||
<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>
|
||||
</template>
|
||||
<template #default>
|
||||
<div class="space-y-3">
|
||||
<div v-for="notice in pagedNotifications" :key="notice.id" class="flex items-center justify-between rounded-xl border border-mosquito-line px-4 py-3">
|
||||
<div class="flex items-center gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="h-4 w-4"
|
||||
:checked="selectedIds.includes(notice.id)"
|
||||
@click.stop
|
||||
@change.stop="toggleSelect(notice.id)"
|
||||
/>
|
||||
<div>
|
||||
<div class="text-sm font-semibold text-mosquito-ink">{{ notice.title }}</div>
|
||||
<div class="mos-muted text-xs">{{ notice.detail }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-xs text-mosquito-ink/70">
|
||||
<div>{{ notice.read ? '已读' : '未读' }}</div>
|
||||
<div class="mos-muted">{{ formatDate(notice.createdAt) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</ListSection>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useDataService } from '../services'
|
||||
import { useAuditStore } from '../stores/audit'
|
||||
import ListSection from '../components/ListSection.vue'
|
||||
|
||||
type NoticeItem = {
|
||||
id: string
|
||||
title: string
|
||||
detail: string
|
||||
read: boolean
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
const notifications = ref<NoticeItem[]>([])
|
||||
const service = useDataService()
|
||||
const auditStore = useAuditStore()
|
||||
const query = ref('')
|
||||
const readFilter = ref('')
|
||||
const selectedIds = ref<string[]>([])
|
||||
const page = ref(0)
|
||||
const pageSize = 8
|
||||
|
||||
const formatDate = (value: string) => new Date(value).toLocaleString('zh-CN')
|
||||
|
||||
onMounted(async () => {
|
||||
notifications.value = await service.getNotifications()
|
||||
})
|
||||
|
||||
const markAllRead = () => {
|
||||
notifications.value = notifications.value.map((item) => ({
|
||||
...item,
|
||||
read: true
|
||||
}))
|
||||
auditStore.addLog('标记通知已读', '通知中心')
|
||||
}
|
||||
|
||||
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 totalPages = computed(() => Math.max(1, Math.ceil(filteredNotifications.value.length / pageSize)))
|
||||
|
||||
const pagedNotifications = computed(() => {
|
||||
const start = page.value * pageSize
|
||||
return filteredNotifications.value.slice(start, start + pageSize)
|
||||
})
|
||||
|
||||
watch([query, readFilter], () => {
|
||||
page.value = 0
|
||||
})
|
||||
|
||||
const allSelected = computed(() => {
|
||||
return (
|
||||
filteredNotifications.value.length > 0 &&
|
||||
filteredNotifications.value.every((item) => selectedIds.value.includes(item.id))
|
||||
)
|
||||
})
|
||||
|
||||
const toggleSelect = (id: string) => {
|
||||
if (selectedIds.value.includes(id)) {
|
||||
selectedIds.value = selectedIds.value.filter((item) => item !== id)
|
||||
} else {
|
||||
selectedIds.value = [...selectedIds.value, id]
|
||||
}
|
||||
}
|
||||
|
||||
const selectAll = () => {
|
||||
if (allSelected.value) {
|
||||
selectedIds.value = []
|
||||
} else {
|
||||
selectedIds.value = filteredNotifications.value.map((item) => item.id)
|
||||
}
|
||||
}
|
||||
|
||||
const batchRead = () => {
|
||||
filteredNotifications.value
|
||||
.filter((item) => selectedIds.value.includes(item.id))
|
||||
.forEach((item) => {
|
||||
item.read = true
|
||||
})
|
||||
auditStore.addLog('批量标记通知已读', '通知中心')
|
||||
selectedIds.value = []
|
||||
}
|
||||
</script>
|
||||
84
frontend/admin/src/views/PermissionsView.vue
Normal file
84
frontend/admin/src/views/PermissionsView.vue
Normal file
@@ -0,0 +1,84 @@
|
||||
<template>
|
||||
<section class="space-y-6">
|
||||
<header class="space-y-2">
|
||||
<h1 class="mos-title text-2xl font-semibold">权限矩阵</h1>
|
||||
<p class="mos-muted text-sm">查看不同角色的功能权限范围。</p>
|
||||
</header>
|
||||
|
||||
<div class="mos-card p-5 space-y-4">
|
||||
<div class="grid gap-3 rounded-xl border border-mosquito-line px-4 py-3 text-xs text-mosquito-ink/70" :style="gridStyle">
|
||||
<div></div>
|
||||
<div v-for="role in roles" :key="role.key" class="text-center font-semibold text-mosquito-ink">
|
||||
{{ role.label }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-for="section in permissionSections" :key="section.group" class="space-y-2">
|
||||
<div class="text-xs font-semibold text-mosquito-ink/70">{{ section.group }}</div>
|
||||
<div
|
||||
v-for="permission in section.items"
|
||||
:key="permission.key"
|
||||
class="grid gap-3 rounded-xl border border-mosquito-line px-4 py-3 text-sm"
|
||||
:style="gridStyle"
|
||||
>
|
||||
<div>
|
||||
<div class="text-sm font-semibold text-mosquito-ink">{{ permission.label }}</div>
|
||||
<div class="mos-muted text-xs">{{ permission.description }}</div>
|
||||
</div>
|
||||
<div v-for="role in roles" :key="role.key" class="flex items-center justify-center">
|
||||
<span
|
||||
class="rounded-full px-2 py-1 text-[10px] font-semibold"
|
||||
:class="hasPermission(role.key, permission.key)
|
||||
? 'bg-mosquito-accent/10 text-mosquito-brand'
|
||||
: 'bg-mosquito-bg text-mosquito-ink/50'"
|
||||
>
|
||||
{{ hasPermission(role.key, permission.key) ? '允许' : '无' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { RolePermissions, type AdminRole, type Permission } from '../auth/roles'
|
||||
|
||||
const roles: { key: AdminRole; label: string }[] = [
|
||||
{ key: 'admin', label: '管理员' },
|
||||
{ key: 'operator', label: '运营' },
|
||||
{ key: 'viewer', label: '只读' }
|
||||
]
|
||||
|
||||
const permissionSections: { group: string; items: { key: Permission; label: string; description: string }[] }[] = [
|
||||
{
|
||||
group: '可视化与运营查看',
|
||||
items: [
|
||||
{ key: 'view:dashboard', label: '看板查看', description: '访问运营概览与关键指标' },
|
||||
{ key: 'view:activities', label: '活动查看', description: '查看活动列表与详情信息' },
|
||||
{ key: 'view:leaderboard', label: '排行榜查看', description: '查看活动排行榜与排名' },
|
||||
{ key: 'view:alerts', label: '告警查看', description: '查看风控与系统告警信息' },
|
||||
{ key: 'view:notifications', label: '通知查看', description: '查看审批与系统通知' }
|
||||
]
|
||||
},
|
||||
{
|
||||
group: '运营与风控管理',
|
||||
items: [
|
||||
{ key: 'manage:users', label: '用户管理', description: '管理运营成员、审批与角色' },
|
||||
{ key: 'manage:rewards', label: '奖励管理', description: '配置与执行奖励发放' },
|
||||
{ key: 'manage:risk', label: '风控管理', description: '维护风控规则与黑名单' },
|
||||
{ key: 'manage:config', label: '配置管理', description: '管理系统配置与策略' },
|
||||
{ key: 'view:audit', label: '审计查看', description: '查看关键操作审计日志' }
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
const hasPermission = (role: AdminRole, permission: Permission) => {
|
||||
return RolePermissions[role]?.includes(permission) ?? false
|
||||
}
|
||||
|
||||
const gridStyle = computed(() => ({
|
||||
gridTemplateColumns: `minmax(200px, 1.8fr) repeat(${roles.length}, minmax(80px, 1fr))`
|
||||
}))
|
||||
</script>
|
||||
260
frontend/admin/src/views/RewardsView.vue
Normal file
260
frontend/admin/src/views/RewardsView.vue
Normal file
@@ -0,0 +1,260 @@
|
||||
<template>
|
||||
<section class="space-y-6">
|
||||
<ListSection :page="page" :total-pages="totalPages" @prev="page--" @next="page++">
|
||||
<template #title>奖励发放</template>
|
||||
<template #subtitle>查看奖励发放状态与明细。</template>
|
||||
<template #filters>
|
||||
<input class="mos-input !py-1 !px-2 !text-xs w-48" v-model="query" placeholder="搜索用户" />
|
||||
<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>
|
||||
<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>
|
||||
<input class="mos-input !py-1 !px-2 !text-xs w-44" v-model="batchReason" placeholder="批量回滚原因" />
|
||||
</template>
|
||||
<template #default>
|
||||
<div class="space-y-3">
|
||||
<div v-for="reward in pagedRewards" :key="reward.id" class="space-y-2 rounded-xl border border-mosquito-line px-4 py-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="h-4 w-4"
|
||||
:checked="selectedIds.includes(reward.id)"
|
||||
@click.stop
|
||||
@change.stop="toggleSelect(reward.id)"
|
||||
/>
|
||||
<div>
|
||||
<div class="text-sm font-semibold text-mosquito-ink">{{ reward.userName }}</div>
|
||||
<div class="mos-muted text-xs">批次:{{ reward.batchId }} · {{ reward.batchStatus }}</div>
|
||||
<div class="mos-muted text-xs">发放时间:{{ formatDate(reward.issuedAt) }}</div>
|
||||
<div v-if="reward.note" class="mos-muted text-xs">备注:{{ reward.note }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<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"
|
||||
@click="handleActionClick(reward)"
|
||||
>
|
||||
{{ actionLabel(reward) }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="actioningId === reward.id"
|
||||
class="flex flex-wrap items-center gap-2 rounded-xl border border-dashed border-mosquito-line px-4 py-3 text-xs"
|
||||
>
|
||||
<input class="mos-input !py-1 !px-2 !text-xs flex-1" v-model="actionReason" placeholder="请输入原因" />
|
||||
<button class="mos-btn mos-btn-secondary !py-1 !px-2 !text-xs" @click="cancelAction">取消</button>
|
||||
<button class="mos-btn mos-btn-accent !py-1 !px-2 !text-xs" @click="confirmAction(reward)">确认</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #footer>
|
||||
<ExportFieldPanel
|
||||
title="导出字段"
|
||||
:fields="exportFields"
|
||||
:selected="exportSelected"
|
||||
@update:selected="setExportSelected"
|
||||
@export="exportRewards"
|
||||
/>
|
||||
</template>
|
||||
</ListSection>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useDataService } from '../services'
|
||||
import { downloadCsv } from '../utils/export'
|
||||
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 { normalizeRewardReason } from '../utils/reward'
|
||||
|
||||
type RewardItem = {
|
||||
id: string
|
||||
userName: string
|
||||
points: number
|
||||
status: string
|
||||
issuedAt: string
|
||||
batchId: string
|
||||
batchStatus: string
|
||||
note?: string
|
||||
}
|
||||
|
||||
const rewards = ref<RewardItem[]>([])
|
||||
const service = useDataService()
|
||||
const auditStore = useAuditStore()
|
||||
const query = ref('')
|
||||
const selectedIds = ref<string[]>([])
|
||||
const startDate = ref('')
|
||||
const endDate = ref('')
|
||||
const batchReason = ref('')
|
||||
const actioningId = ref<string | null>(null)
|
||||
const actionType = ref<'rollback' | 'retry' | null>(null)
|
||||
const actionReason = ref('')
|
||||
const page = ref(0)
|
||||
const pageSize = 6
|
||||
|
||||
const formatDate = (value: string) => new Date(value).toLocaleDateString('zh-CN')
|
||||
|
||||
const exportFields: ExportField[] = [
|
||||
{ key: 'userName', label: '用户', required: true },
|
||||
{ key: 'points', label: '积分' },
|
||||
{ key: 'status', label: '状态' },
|
||||
{ key: 'issuedAt', label: '发放时间' },
|
||||
{ key: 'batchId', label: '批次编号' },
|
||||
{ key: 'batchStatus', label: '批次状态' },
|
||||
{ key: 'note', label: '备注' }
|
||||
]
|
||||
const { selected: exportSelected, setSelected: setExportSelected } = useExportFields(
|
||||
exportFields,
|
||||
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 ?? ''
|
||||
})
|
||||
)
|
||||
downloadCsv('rewards-demo.csv', headers, rows)
|
||||
}
|
||||
|
||||
|
||||
onMounted(async () => {
|
||||
rewards.value = await service.getRewards()
|
||||
})
|
||||
|
||||
const applyIssue = (reward: RewardItem) => {
|
||||
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 retryIssue = (reward: RewardItem, reason: string) => {
|
||||
reward.status = '已发放'
|
||||
reward.note = `重试原因:${reason}`
|
||||
auditStore.addLog('重试发放奖励', `${reward.userName}:${reason}`)
|
||||
}
|
||||
|
||||
const actionLabel = (reward: RewardItem) => {
|
||||
if (reward.status === '已发放') return '回滚'
|
||||
if (reward.status === '发放失败') return '重试'
|
||||
return '发放'
|
||||
}
|
||||
|
||||
const handleActionClick = (reward: RewardItem) => {
|
||||
if (reward.status === '已发放') {
|
||||
actioningId.value = reward.id
|
||||
actionType.value = 'rollback'
|
||||
actionReason.value = ''
|
||||
return
|
||||
}
|
||||
if (reward.status === '发放失败') {
|
||||
actioningId.value = reward.id
|
||||
actionType.value = 'retry'
|
||||
actionReason.value = ''
|
||||
return
|
||||
}
|
||||
applyIssue(reward)
|
||||
}
|
||||
|
||||
const cancelAction = () => {
|
||||
actioningId.value = null
|
||||
actionType.value = null
|
||||
actionReason.value = ''
|
||||
}
|
||||
|
||||
const confirmAction = (reward: RewardItem) => {
|
||||
const reason = normalizeRewardReason(actionReason.value)
|
||||
if (actionType.value === 'rollback') {
|
||||
rollbackIssue(reward, reason)
|
||||
} else if (actionType.value === 'retry') {
|
||||
retryIssue(reward, reason)
|
||||
}
|
||||
cancelAction()
|
||||
}
|
||||
|
||||
const filteredRewards = computed(() => {
|
||||
return rewards.value.filter((item) => {
|
||||
const matchesQuery = item.userName.includes(query.value.trim())
|
||||
const startOk = startDate.value ? new Date(item.issuedAt).getTime() >= new Date(startDate.value).getTime() : true
|
||||
const endOk = endDate.value ? new Date(item.issuedAt).getTime() <= new Date(endDate.value).getTime() : true
|
||||
return matchesQuery && startOk && endOk
|
||||
})
|
||||
})
|
||||
|
||||
const allSelected = computed(() => {
|
||||
return filteredRewards.value.length > 0 && filteredRewards.value.every((item) => selectedIds.value.includes(item.id))
|
||||
})
|
||||
|
||||
const toggleSelect = (id: string) => {
|
||||
if (selectedIds.value.includes(id)) {
|
||||
selectedIds.value = selectedIds.value.filter((item) => item !== id)
|
||||
} else {
|
||||
selectedIds.value = [...selectedIds.value, id]
|
||||
}
|
||||
}
|
||||
|
||||
const selectAll = () => {
|
||||
if (allSelected.value) {
|
||||
selectedIds.value = []
|
||||
} else {
|
||||
selectedIds.value = filteredRewards.value.map((item) => item.id)
|
||||
}
|
||||
}
|
||||
|
||||
const batchIssue = () => {
|
||||
filteredRewards.value
|
||||
.filter((item) => selectedIds.value.includes(item.id))
|
||||
.forEach(applyIssue)
|
||||
selectedIds.value = []
|
||||
}
|
||||
|
||||
const batchRollback = () => {
|
||||
const reason = normalizeRewardReason(batchReason.value, '批量回滚')
|
||||
filteredRewards.value
|
||||
.filter((item) => selectedIds.value.includes(item.id))
|
||||
.forEach((item) => rollbackIssue(item, reason))
|
||||
selectedIds.value = []
|
||||
batchReason.value = ''
|
||||
}
|
||||
|
||||
const totalPages = computed(() => Math.max(1, Math.ceil(filteredRewards.value.length / pageSize)))
|
||||
|
||||
const pagedRewards = computed(() => {
|
||||
const start = page.value * pageSize
|
||||
return filteredRewards.value.slice(start, start + pageSize)
|
||||
})
|
||||
|
||||
watch([query, startDate, endDate], () => {
|
||||
page.value = 0
|
||||
})
|
||||
</script>
|
||||
213
frontend/admin/src/views/RiskView.vue
Normal file
213
frontend/admin/src/views/RiskView.vue
Normal file
@@ -0,0 +1,213 @@
|
||||
<template>
|
||||
<section class="space-y-6">
|
||||
<header class="space-y-2">
|
||||
<h1 class="mos-title text-2xl font-semibold">风控与黑名单</h1>
|
||||
<p class="mos-muted text-sm">识别异常行为并管理黑名单。</p>
|
||||
</header>
|
||||
|
||||
<ListSection>
|
||||
<template #title>风险告警处置</template>
|
||||
<template #subtitle>跟踪告警处理进度与闭环结果。</template>
|
||||
<template #default>
|
||||
<div v-if="alerts.length" class="space-y-3">
|
||||
<div v-for="alert in alerts" :key="alert.id" class="flex items-start justify-between rounded-xl border border-mosquito-line px-4 py-3">
|
||||
<div>
|
||||
<div class="text-sm font-semibold text-mosquito-ink">{{ alert.title }}</div>
|
||||
<div class="mos-muted text-xs">{{ alert.detail }}</div>
|
||||
<div class="mos-muted text-xs">更新时间:{{ formatDate(alert.updatedAt) }}</div>
|
||||
</div>
|
||||
<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"
|
||||
:disabled="alert.status !== '未处理'"
|
||||
@click="updateAlert(alert, 'process')"
|
||||
>
|
||||
处理
|
||||
</button>
|
||||
<button
|
||||
class="mos-btn mos-btn-secondary !py-1 !px-2 !text-xs"
|
||||
:disabled="alert.status === '已关闭'"
|
||||
@click="updateAlert(alert, 'close')"
|
||||
>
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #empty>
|
||||
<div v-if="!alerts.length" class="mt-4 text-sm text-mosquito-ink/60">暂无告警</div>
|
||||
</template>
|
||||
</ListSection>
|
||||
|
||||
<ListSection :page="page" :total-pages="totalPages" @prev="page--" @next="page++">
|
||||
<template #title>风控规则与黑名单</template>
|
||||
<template #filters>
|
||||
<input class="mos-input !py-1 !px-2 !text-xs w-48" v-model="query" placeholder="搜索类型/目标" />
|
||||
<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>
|
||||
<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>
|
||||
</template>
|
||||
<template #default>
|
||||
<div class="space-y-3">
|
||||
<div v-for="item in pagedRisks" :key="item.id" class="flex items-center justify-between rounded-xl border border-mosquito-line px-4 py-3">
|
||||
<div class="flex items-center gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="h-4 w-4"
|
||||
:checked="selectedIds.includes(item.id)"
|
||||
@click.stop
|
||||
@change.stop="toggleSelect(item.id)"
|
||||
/>
|
||||
<div>
|
||||
<div class="text-sm font-semibold text-mosquito-ink">{{ item.type }}</div>
|
||||
<div class="mos-muted text-xs">{{ item.target }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 text-xs text-mosquito-ink/70">
|
||||
<span>{{ item.status }}</span>
|
||||
<span class="mos-muted">{{ formatDate(item.updatedAt) }}</span>
|
||||
<button class="mos-btn mos-btn-secondary !py-1 !px-2 !text-xs" @click="toggleRisk(item)">
|
||||
{{ item.status === '生效' ? '暂停' : '启用' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</ListSection>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useDataService } from '../services'
|
||||
import { useAuditStore } from '../stores/audit'
|
||||
import ListSection from '../components/ListSection.vue'
|
||||
import { transitionAlertStatus, type AlertAction } from '../utils/risk'
|
||||
|
||||
type RiskItem = {
|
||||
id: string
|
||||
type: string
|
||||
target: string
|
||||
status: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
type RiskAlert = {
|
||||
id: string
|
||||
title: string
|
||||
detail: string
|
||||
status: '未处理' | '处理中' | '已关闭'
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
const risks = ref<RiskItem[]>([])
|
||||
const alerts = ref<RiskAlert[]>([])
|
||||
const service = useDataService()
|
||||
const auditStore = useAuditStore()
|
||||
const query = ref('')
|
||||
const startDate = ref('')
|
||||
const endDate = ref('')
|
||||
const selectedIds = ref<string[]>([])
|
||||
const page = ref(0)
|
||||
const pageSize = 6
|
||||
|
||||
const formatDate = (value: string) => new Date(value).toLocaleDateString('zh-CN')
|
||||
|
||||
onMounted(async () => {
|
||||
risks.value = await service.getRiskItems()
|
||||
alerts.value = await service.getRiskAlerts()
|
||||
})
|
||||
|
||||
const addRule = () => {
|
||||
risks.value.unshift({
|
||||
id: `risk-${Date.now()}`,
|
||||
type: '新增规则',
|
||||
target: '待配置',
|
||||
status: '待核查',
|
||||
updatedAt: new Date().toISOString()
|
||||
})
|
||||
auditStore.addLog('新增风控规则', '风控规则')
|
||||
}
|
||||
|
||||
const toggleRisk = (item: RiskItem) => {
|
||||
item.status = item.status === '生效' ? '暂停' : '生效'
|
||||
item.updatedAt = new Date().toISOString()
|
||||
auditStore.addLog(item.status === '生效' ? '启用风控规则' : '暂停风控规则', item.type)
|
||||
}
|
||||
|
||||
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 filteredRisks = computed(() => {
|
||||
return risks.value.filter((item) => {
|
||||
const keyword = query.value.trim()
|
||||
const matchesKeyword = keyword ? item.type.includes(keyword) || item.target.includes(keyword) : true
|
||||
const startOk = startDate.value ? new Date(item.updatedAt).getTime() >= new Date(startDate.value).getTime() : true
|
||||
const endOk = endDate.value ? new Date(item.updatedAt).getTime() <= new Date(endDate.value).getTime() : true
|
||||
return matchesKeyword && startOk && endOk
|
||||
})
|
||||
})
|
||||
|
||||
const allSelected = computed(() => {
|
||||
return filteredRisks.value.length > 0 && filteredRisks.value.every((item) => selectedIds.value.includes(item.id))
|
||||
})
|
||||
|
||||
const toggleSelect = (id: string) => {
|
||||
if (selectedIds.value.includes(id)) {
|
||||
selectedIds.value = selectedIds.value.filter((item) => item !== id)
|
||||
} else {
|
||||
selectedIds.value = [...selectedIds.value, id]
|
||||
}
|
||||
}
|
||||
|
||||
const selectAll = () => {
|
||||
if (allSelected.value) {
|
||||
selectedIds.value = []
|
||||
} else {
|
||||
selectedIds.value = filteredRisks.value.map((item) => item.id)
|
||||
}
|
||||
}
|
||||
|
||||
const batchEnable = () => {
|
||||
filteredRisks.value
|
||||
.filter((item) => selectedIds.value.includes(item.id))
|
||||
.forEach((item) => {
|
||||
if (item.status !== '生效') toggleRisk(item)
|
||||
})
|
||||
}
|
||||
|
||||
const batchPause = () => {
|
||||
filteredRisks.value
|
||||
.filter((item) => selectedIds.value.includes(item.id))
|
||||
.forEach((item) => {
|
||||
if (item.status === '生效') toggleRisk(item)
|
||||
})
|
||||
}
|
||||
|
||||
const totalPages = computed(() => Math.max(1, Math.ceil(filteredRisks.value.length / pageSize)))
|
||||
|
||||
const pagedRisks = computed(() => {
|
||||
const start = page.value * pageSize
|
||||
return filteredRisks.value.slice(start, start + pageSize)
|
||||
})
|
||||
|
||||
watch([query, startDate, endDate], () => {
|
||||
page.value = 0
|
||||
})
|
||||
</script>
|
||||
97
frontend/admin/src/views/UserDetailView.vue
Normal file
97
frontend/admin/src/views/UserDetailView.vue
Normal file
@@ -0,0 +1,97 @@
|
||||
<template>
|
||||
<section class="space-y-6">
|
||||
<header class="space-y-2">
|
||||
<h1 class="mos-title text-2xl font-semibold">用户详情</h1>
|
||||
<p class="mos-muted text-sm">{{ user?.name }} · {{ user?.email }}</p>
|
||||
</header>
|
||||
|
||||
<div class="grid gap-6 lg:grid-cols-3">
|
||||
<div class="mos-card p-5 space-y-3">
|
||||
<div class="text-sm font-semibold text-mosquito-ink">基本信息</div>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div class="mos-card lg:col-span-2 p-5 space-y-4">
|
||||
<div class="text-sm font-semibold text-mosquito-ink">角色变更历史</div>
|
||||
<div class="flex flex-wrap items-center gap-2 text-xs text-mosquito-ink/70">
|
||||
<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>
|
||||
</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" />
|
||||
</div>
|
||||
<div v-if="filteredHistory.length" class="mt-4 space-y-3">
|
||||
<div v-for="item in filteredHistory" :key="item.id" class="rounded-xl border border-mosquito-line px-4 py-3 text-xs">
|
||||
<div class="font-semibold">{{ roleLabel(item.currentRole) }} → {{ roleLabel(item.targetRole) }}</div>
|
||||
<div class="mos-muted">状态:{{ item.status }} · 申请时间:{{ formatDate(item.requestedAt) }}</div>
|
||||
<div v-if="item.status !== '待审批'" class="mos-muted">审批人:{{ item.approvedBy }} · {{ formatDate(item.decisionAt) }}</div>
|
||||
<div v-if="item.rejectReason" class="mos-muted">拒绝原因:{{ item.rejectReason }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="mt-4 text-sm text-mosquito-ink/60">暂无变更记录</div>
|
||||
|
||||
<div class="border-t border-mosquito-line pt-4">
|
||||
<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>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useUserStore } from '../stores/users'
|
||||
import { useAuditStore } from '../stores/audit'
|
||||
|
||||
const route = useRoute()
|
||||
const store = useUserStore()
|
||||
const auditStore = useAuditStore()
|
||||
const userId = computed(() => String(route.params.id))
|
||||
|
||||
const user = computed(() => store.byId(userId.value))
|
||||
const history = computed(() => store.roleRequests.filter((item) => item.userId === userId.value))
|
||||
const targetRole = ref('operator')
|
||||
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 '只读'
|
||||
}
|
||||
|
||||
const formatDate = (value?: string) => (value ? new Date(value).toLocaleString('zh-CN') : '--')
|
||||
|
||||
const filteredHistory = computed(() => {
|
||||
return history.value.filter((item) => {
|
||||
const matchesStatus = statusFilter.value ? item.status === statusFilter.value : true
|
||||
const startOk = startDate.value ? new Date(item.requestedAt).getTime() >= new Date(startDate.value).getTime() : true
|
||||
const endOk = endDate.value ? new Date(item.requestedAt).getTime() <= new Date(endDate.value).getTime() : true
|
||||
return matchesStatus && startOk && endOk
|
||||
})
|
||||
})
|
||||
|
||||
const submitRequest = () => {
|
||||
if (!user.value) return
|
||||
store.requestRoleChange(user.value.id, targetRole.value as any, reason.value || '未填写原因')
|
||||
auditStore.addLog('提交角色变更申请', user.value.name)
|
||||
reason.value = ''
|
||||
}
|
||||
</script>
|
||||
316
frontend/admin/src/views/UsersView.vue
Normal file
316
frontend/admin/src/views/UsersView.vue
Normal file
@@ -0,0 +1,316 @@
|
||||
<template>
|
||||
<section class="space-y-6">
|
||||
<ListSection
|
||||
:page="tab === 'staff' ? staffPage : activityPage"
|
||||
:total-pages="tab === 'staff' ? staffTotalPages : activityTotalPages"
|
||||
@prev="handlePrev"
|
||||
@next="handleNext"
|
||||
>
|
||||
<template #title>用户管理</template>
|
||||
<template #subtitle>管理运营成员与权限角色。</template>
|
||||
<template #filters>
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<div class="flex items-center gap-2 text-sm font-semibold">
|
||||
<button
|
||||
class="rounded-full px-3 py-1.5"
|
||||
:class="tab === 'staff' ? 'bg-mosquito-accent/10 text-mosquito-ink' : 'text-mosquito-ink/60'"
|
||||
data-test="tab-staff"
|
||||
@click="tab = 'staff'"
|
||||
>
|
||||
运营用户
|
||||
</button>
|
||||
<button
|
||||
class="rounded-full px-3 py-1.5"
|
||||
:class="tab === 'activity' ? 'bg-mosquito-accent/10 text-mosquito-ink' : 'text-mosquito-ink/60'"
|
||||
data-test="tab-activity"
|
||||
@click="switchToActivity()"
|
||||
>
|
||||
活动用户
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="tab === 'staff'" class="flex flex-wrap items-center gap-2 text-xs text-mosquito-ink/70">
|
||||
<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>
|
||||
</select>
|
||||
<select class="mos-input !py-1 !px-2 !text-xs" v-model="statusFilter">
|
||||
<option value="">全部状态</option>
|
||||
<option value="正常">正常</option>
|
||||
<option value="冻结">冻结</option>
|
||||
</select>
|
||||
<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>
|
||||
</div>
|
||||
<div v-else class="flex flex-wrap items-center gap-3">
|
||||
<label class="text-xs text-mosquito-ink/70">活动</label>
|
||||
<select class="mos-input !py-1 !px-2 !text-xs" v-model.number="selectedActivityId" @change="loadActivityUsers">
|
||||
<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 w-full md:w-56" v-model="activityQuery" placeholder="搜索昵称/手机号" />
|
||||
<select class="mos-input !py-1 !px-2 !text-xs" v-model="activityStatusFilter">
|
||||
<option value="">全部状态</option>
|
||||
<option value="已注册">已注册</option>
|
||||
<option value="未注册">未注册</option>
|
||||
</select>
|
||||
<button class="mos-btn mos-btn-secondary !py-1 !px-2 !text-xs" @click="loadActivityUsers">刷新</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #actions>
|
||||
<RouterLink v-if="tab === 'staff'" to="/users/invite" class="mos-btn mos-btn-accent">邀请用户</RouterLink>
|
||||
</template>
|
||||
<template #default>
|
||||
<div v-if="tab === 'staff'" class="space-y-3">
|
||||
<div
|
||||
v-for="user in pagedUsers"
|
||||
:key="user.id"
|
||||
class="flex items-center justify-between rounded-xl border border-mosquito-line px-4 py-3 transition hover:bg-mosquito-bg/60"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="h-4 w-4"
|
||||
:checked="selectedStaffIds.includes(user.id)"
|
||||
@click.stop
|
||||
@change.stop="toggleSelect(user.id)"
|
||||
/>
|
||||
<RouterLink :to="`/users/${user.id}`" class="flex flex-col">
|
||||
<span class="text-sm font-semibold text-mosquito-ink">{{ user.name }}</span>
|
||||
<span class="mos-muted text-xs">{{ user.email }}</span>
|
||||
</RouterLink>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 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">{{ roleLabel(user.role) }}</span>
|
||||
<span>{{ user.status }}</span>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="pagedActivityUsers.length" class="space-y-3" data-test="activity-users-list">
|
||||
<div
|
||||
v-for="friend in pagedActivityUsers"
|
||||
:key="friend.nickname + friend.maskedPhone"
|
||||
data-test="activity-user-row"
|
||||
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">{{ friend.nickname }}</div>
|
||||
<div class="mos-muted text-xs">{{ friend.maskedPhone }}</div>
|
||||
</div>
|
||||
<div class="text-xs text-mosquito-ink/70">{{ friend.status }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #empty>
|
||||
<div
|
||||
v-if="tab === 'activity' && !pagedActivityUsers.length"
|
||||
data-test="activity-users-empty"
|
||||
class="rounded-xl border border-dashed border-mosquito-line p-4 text-sm text-mosquito-ink/60"
|
||||
>
|
||||
暂无活动用户数据
|
||||
</div>
|
||||
</template>
|
||||
</ListSection>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useDataService } from '../services'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import { useAuditStore } from '../stores/audit'
|
||||
import { useUserStore } from '../stores/users'
|
||||
import { useActivityStore } from '../stores/activities'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import ListSection from '../components/ListSection.vue'
|
||||
|
||||
type UserItem = {
|
||||
id: string
|
||||
name: string
|
||||
email: string
|
||||
role: string
|
||||
status: string
|
||||
}
|
||||
|
||||
type ActivityUser = {
|
||||
nickname: string
|
||||
maskedPhone: string
|
||||
status: string
|
||||
}
|
||||
|
||||
const users = ref<UserItem[]>([])
|
||||
const service = useDataService()
|
||||
const auditStore = useAuditStore()
|
||||
const userStore = useUserStore()
|
||||
const activityStore = useActivityStore()
|
||||
const authStore = useAuthStore()
|
||||
const tab = ref<'staff' | 'activity'>('staff')
|
||||
const activityUsers = ref<ActivityUser[]>([])
|
||||
const activities = ref<{ id: number; name: string }[]>([])
|
||||
const selectedActivityId = ref<number>(0)
|
||||
const staffQuery = ref('')
|
||||
const roleFilter = ref('')
|
||||
const statusFilter = ref('')
|
||||
const selectedStaffIds = ref<string[]>([])
|
||||
const activityQuery = ref('')
|
||||
const activityStatusFilter = ref('')
|
||||
const staffPage = ref(0)
|
||||
const staffPageSize = 6
|
||||
const activityPage = ref(0)
|
||||
const activityPageSize = 6
|
||||
|
||||
onMounted(async () => {
|
||||
const [userList, invites, requests] = await Promise.all([
|
||||
service.getUsers(),
|
||||
service.getInvites(),
|
||||
service.getRoleRequests()
|
||||
])
|
||||
users.value = userList
|
||||
userStore.init(userList, 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 requestRole = (user: UserItem) => {
|
||||
userStore.requestRoleChange(user.id, 'admin', '需要更高权限')
|
||||
auditStore.addLog('提交角色变更申请', user.name)
|
||||
}
|
||||
|
||||
const roleLabel = (role: string) => {
|
||||
if (role === 'admin') return '管理员'
|
||||
if (role === 'operator') return '运营'
|
||||
return '只读'
|
||||
}
|
||||
|
||||
const resolveUserId = () => {
|
||||
const raw = authStore.user?.id ?? ''
|
||||
const parsed = Number(raw)
|
||||
return Number.isNaN(parsed) || parsed <= 0 ? 1 : parsed
|
||||
}
|
||||
|
||||
const loadActivityUsers = async () => {
|
||||
if (!selectedActivityId.value) {
|
||||
activityUsers.value = []
|
||||
return
|
||||
}
|
||||
activityUsers.value = await service.getInvitedFriends(selectedActivityId.value, resolveUserId(), 0, 20)
|
||||
}
|
||||
|
||||
const switchToActivity = async () => {
|
||||
tab.value = 'activity'
|
||||
await loadActivityUsers()
|
||||
}
|
||||
|
||||
const handlePrev = () => {
|
||||
if (tab.value === 'staff') {
|
||||
staffPage.value -= 1
|
||||
} else {
|
||||
activityPage.value -= 1
|
||||
}
|
||||
}
|
||||
|
||||
const handleNext = () => {
|
||||
if (tab.value === 'staff') {
|
||||
staffPage.value += 1
|
||||
} else {
|
||||
activityPage.value += 1
|
||||
}
|
||||
}
|
||||
|
||||
const filteredUsers = computed(() => {
|
||||
return users.value.filter((user) => {
|
||||
const matchesQuery =
|
||||
user.name.includes(staffQuery.value) || user.email.includes(staffQuery.value)
|
||||
const matchesRole = roleFilter.value ? user.role === roleFilter.value : true
|
||||
const matchesStatus = statusFilter.value ? user.status === statusFilter.value : true
|
||||
return matchesQuery && matchesRole && matchesStatus
|
||||
})
|
||||
})
|
||||
|
||||
watch([staffQuery, roleFilter, statusFilter], () => {
|
||||
staffPage.value = 0
|
||||
})
|
||||
|
||||
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)
|
||||
})
|
||||
|
||||
const allStaffSelected = computed(() => {
|
||||
return filteredUsers.value.length > 0 && filteredUsers.value.every((user) => selectedStaffIds.value.includes(user.id))
|
||||
})
|
||||
|
||||
const toggleSelect = (id: string) => {
|
||||
if (selectedStaffIds.value.includes(id)) {
|
||||
selectedStaffIds.value = selectedStaffIds.value.filter((item) => item !== id)
|
||||
} else {
|
||||
selectedStaffIds.value = [...selectedStaffIds.value, id]
|
||||
}
|
||||
}
|
||||
|
||||
const selectAllStaff = () => {
|
||||
if (allStaffSelected.value) {
|
||||
selectedStaffIds.value = []
|
||||
} else {
|
||||
selectedStaffIds.value = filteredUsers.value.map((user) => user.id)
|
||||
}
|
||||
}
|
||||
|
||||
const batchEnable = () => {
|
||||
selectedStaffIds.value.forEach((id) => {
|
||||
const user = users.value.find((item) => item.id === id)
|
||||
if (user && user.status === '冻结') {
|
||||
toggleUser(user)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const batchDisable = () => {
|
||||
selectedStaffIds.value.forEach((id) => {
|
||||
const user = users.value.find((item) => item.id === id)
|
||||
if (user && user.status === '正常') {
|
||||
toggleUser(user)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const filteredActivityUsers = computed(() => {
|
||||
return activityUsers.value.filter((item) => {
|
||||
const matchesQuery =
|
||||
item.nickname.includes(activityQuery.value) || item.maskedPhone.includes(activityQuery.value)
|
||||
const matchesStatus = activityStatusFilter.value ? item.status === activityStatusFilter.value : true
|
||||
return matchesQuery && matchesStatus
|
||||
})
|
||||
})
|
||||
|
||||
watch([activityQuery, activityStatusFilter], () => {
|
||||
activityPage.value = 0
|
||||
})
|
||||
|
||||
const activityTotalPages = computed(() => Math.max(1, Math.ceil(filteredActivityUsers.value.length / activityPageSize)))
|
||||
|
||||
const pagedActivityUsers = computed(() => {
|
||||
const start = activityPage.value * activityPageSize
|
||||
return filteredActivityUsers.value.slice(start, start + activityPageSize)
|
||||
})
|
||||
</script>
|
||||
14
frontend/admin/src/views/__tests__/PermissionsView.test.ts
Normal file
14
frontend/admin/src/views/__tests__/PermissionsView.test.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import PermissionsView from '../PermissionsView.vue'
|
||||
|
||||
describe('PermissionsView', () => {
|
||||
it('renders role headers and permission labels', () => {
|
||||
const wrapper = mount(PermissionsView)
|
||||
|
||||
expect(wrapper.text()).toContain('权限矩阵')
|
||||
expect(wrapper.text()).toContain('管理员')
|
||||
expect(wrapper.text()).toContain('运营')
|
||||
expect(wrapper.text()).toContain('只读')
|
||||
expect(wrapper.text()).toContain('活动查看')
|
||||
})
|
||||
})
|
||||
1
frontend/admin/src/vite-env.d.ts
vendored
Normal file
1
frontend/admin/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
28
frontend/admin/tailwind.config.cjs
Normal file
28
frontend/admin/tailwind.config.cjs
Normal file
@@ -0,0 +1,28 @@
|
||||
module.exports = {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{vue,ts,tsx}",
|
||||
"../components/**/*.{vue,ts,tsx}",
|
||||
"../index.ts"
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
mosquito: {
|
||||
ink: '#0B1C2C',
|
||||
brand: '#0B3A63',
|
||||
accent: '#16B9A5',
|
||||
accent2: '#6AA7FF',
|
||||
surface: '#FFFFFF',
|
||||
bg: '#F3F6F9',
|
||||
line: '#E0E6ED'
|
||||
}
|
||||
},
|
||||
boxShadow: {
|
||||
soft: '0 12px 24px rgba(11, 28, 44, 0.08)',
|
||||
glow: '0 20px 50px rgba(11, 28, 44, 0.12)'
|
||||
}
|
||||
}
|
||||
},
|
||||
plugins: []
|
||||
}
|
||||
17
frontend/admin/tsconfig.json
Normal file
17
frontend/admin/tsconfig.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"jsx": "preserve",
|
||||
"useDefineForClassFields": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"types": ["vite/client", "vitest/globals"]
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.vue"]
|
||||
}
|
||||
31
frontend/admin/vite.config.ts
Normal file
31
frontend/admin/vite.config.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import path from 'node:path'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, 'src')
|
||||
}
|
||||
},
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
globals: true
|
||||
},
|
||||
server: {
|
||||
fs: {
|
||||
allow: ['..']
|
||||
},
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://127.0.0.1:8080',
|
||||
changeOrigin: true
|
||||
},
|
||||
'/r': {
|
||||
target: 'http://127.0.0.1:8080',
|
||||
changeOrigin: true
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user