2e2c161770
- backed/: Go Fiber backend with cpdaily integration - frondend/: Vue 3 + Vite frontend - Dockerfile: multi-stage build (Node → Go → Alpine) - deploy/docker-compose.yml: production compose file - .woodpecker/build.yml: CI/CD with blue-green zero-downtime deploy - /health endpoint for deployment health checks - Port 8884/8885 blue-green swap via 1Panel OpenResty
265 lines
9.7 KiB
Vue
265 lines
9.7 KiB
Vue
<script setup lang="ts">
|
||
import { ref, onMounted } from 'vue'
|
||
import { Shield, RefreshCw, CheckCircle, Search, Eye, EyeOff, Copy } from 'lucide-vue-next'
|
||
import { Button } from '@/components/ui/button'
|
||
import { Input } from '@/components/ui/input'
|
||
import { useMessage } from '@/composables/useMessage'
|
||
import api from '@/api/index'
|
||
|
||
interface PendingSchoolItem {
|
||
id: number
|
||
schoolId: string
|
||
schoolName: string
|
||
username: string
|
||
password: string
|
||
ip: string
|
||
status: string
|
||
createdAt: string
|
||
}
|
||
|
||
interface SchoolGroup {
|
||
schoolId: string
|
||
schoolName: string
|
||
records: PendingSchoolItem[]
|
||
count: number
|
||
}
|
||
|
||
const message = useMessage()
|
||
const records = ref<PendingSchoolItem[]>([])
|
||
const groups = ref<SchoolGroup[]>([])
|
||
const loading = ref(true)
|
||
const statusFilter = ref('pending')
|
||
const searchKeyword = ref('')
|
||
const showPasswordMap = ref<Record<number, boolean>>({})
|
||
|
||
onMounted(() => {
|
||
loadData()
|
||
})
|
||
|
||
async function loadData() {
|
||
loading.value = true
|
||
try {
|
||
const res = await api.get<{ data: PendingSchoolItem[] }>('/admin/pending-schools', {
|
||
params: { status: statusFilter.value },
|
||
})
|
||
records.value = res.data?.data || []
|
||
buildGroups()
|
||
} catch {
|
||
records.value = []
|
||
groups.value = []
|
||
message.error('加载失败')
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
function buildGroups() {
|
||
const map = new Map<string, SchoolGroup>()
|
||
for (const r of records.value) {
|
||
if (searchKeyword.value && !r.schoolName.includes(searchKeyword.value) && !r.schoolId.includes(searchKeyword.value)) {
|
||
continue
|
||
}
|
||
if (!map.has(r.schoolId)) {
|
||
map.set(r.schoolId, { schoolId: r.schoolId, schoolName: r.schoolName, records: [], count: 0 })
|
||
}
|
||
const g = map.get(r.schoolId)!
|
||
g.records.push(r)
|
||
g.count++
|
||
}
|
||
groups.value = Array.from(map.values()).sort((a, b) => b.count - a.count)
|
||
}
|
||
|
||
function togglePassword(id: number) {
|
||
showPasswordMap.value[id] = !showPasswordMap.value[id]
|
||
}
|
||
|
||
async function handleMarkDone(record: PendingSchoolItem) {
|
||
try {
|
||
await api.post(`/admin/pending-schools/${record.id}/mark-done`)
|
||
message.success(`已标记 ${record.schoolName} 为适配完成`)
|
||
await loadData()
|
||
} catch (err: any) {
|
||
message.error(err?.response?.data?.message || '操作失败')
|
||
}
|
||
}
|
||
|
||
function copyText(text: string) {
|
||
navigator.clipboard.writeText(text).then(() => {
|
||
message.success('已复制')
|
||
})
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<div class="p-6">
|
||
<!-- 标题栏 -->
|
||
<div class="flex items-center justify-between mb-6">
|
||
<div class="flex items-center gap-3">
|
||
<div class="w-10 h-10 rounded-xl bg-amber-100 flex items-center justify-center">
|
||
<Shield class="w-5 h-5 text-amber-600" />
|
||
</div>
|
||
<div>
|
||
<h1 class="text-lg font-bold text-gray-900">待适配学校</h1>
|
||
<p class="text-sm text-gray-400">用户提交的登录失败测试账号,用于适配新学校</p>
|
||
</div>
|
||
</div>
|
||
<div class="flex items-center gap-2">
|
||
<!-- 筛选 -->
|
||
<div class="flex gap-1 bg-gray-100 rounded-lg p-0.5 mr-3">
|
||
<button
|
||
class="px-3 py-1.5 text-xs font-medium rounded-md transition-colors"
|
||
:class="statusFilter === 'pending' ? 'bg-white text-gray-900 shadow-sm' : 'text-gray-500 hover:text-gray-700'"
|
||
@click="statusFilter = 'pending'; loadData()"
|
||
>待处理</button>
|
||
<button
|
||
class="px-3 py-1.5 text-xs font-medium rounded-md transition-colors"
|
||
:class="statusFilter === 'completed' ? 'bg-white text-gray-900 shadow-sm' : 'text-gray-500 hover:text-gray-700'"
|
||
@click="statusFilter = 'completed'; loadData()"
|
||
>已完成</button>
|
||
<button
|
||
class="px-3 py-1.5 text-xs font-medium rounded-md transition-colors"
|
||
:class="statusFilter === '' ? 'bg-white text-gray-900 shadow-sm' : 'text-gray-500 hover:text-gray-700'"
|
||
@click="statusFilter = ''; loadData()"
|
||
>全部</button>
|
||
</div>
|
||
<Button variant="outline" size="sm" class="h-9 text-xs" @click="loadData">
|
||
<RefreshCw class="w-3.5 h-3.5 mr-1" />
|
||
刷新
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 搜索 -->
|
||
<div class="relative max-w-xs mb-6">
|
||
<Search class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||
<Input
|
||
v-model="searchKeyword"
|
||
placeholder="搜索学校名称..."
|
||
class="pl-9 h-9 text-sm"
|
||
@input="buildGroups"
|
||
/>
|
||
</div>
|
||
|
||
<!-- 主卡片 -->
|
||
<div class="bg-white rounded-xl border border-gray-200 overflow-hidden">
|
||
<!-- 加载骨架 -->
|
||
<div v-if="loading" class="p-6 space-y-4">
|
||
<div v-for="i in 4" :key="i" class="h-10 bg-gray-50 rounded animate-pulse" />
|
||
</div>
|
||
|
||
<!-- 空状态 -->
|
||
<div v-else-if="groups.length === 0" class="text-center py-20 text-gray-400">
|
||
<Shield class="w-12 h-12 mx-auto mb-3 opacity-30" />
|
||
<p class="text-sm">暂无待适配学校</p>
|
||
<p class="text-xs text-gray-300 mt-1">当用户提交登录失败的测试账号后,将在这里显示</p>
|
||
</div>
|
||
|
||
<!-- 学校列表 -->
|
||
<div v-else class="divide-y divide-gray-100">
|
||
<div
|
||
v-for="group in groups"
|
||
:key="group.schoolId"
|
||
>
|
||
<!-- 学校标题栏 -->
|
||
<div class="px-5 py-3 bg-gray-50/80 flex items-center justify-between border-b border-gray-100">
|
||
<div class="flex items-center gap-2.5">
|
||
<Shield class="w-4 h-4 text-amber-500" />
|
||
<span class="text-sm font-semibold text-gray-900">{{ group.schoolName }}</span>
|
||
<span class="text-[10px] px-1.5 py-0.5 rounded-full bg-amber-50 text-amber-600 font-medium border border-amber-100">
|
||
{{ group.count }} 条
|
||
</span>
|
||
<span class="text-[10px] text-gray-400 font-mono">{{ group.schoolId }}</span>
|
||
</div>
|
||
<Button
|
||
v-if="statusFilter !== 'completed'"
|
||
size="sm"
|
||
class="h-7 text-xs"
|
||
@click="handleMarkDone(group.records[0])"
|
||
>
|
||
<CheckCircle class="w-3 h-3 mr-1" />
|
||
标记完成
|
||
</Button>
|
||
</div>
|
||
|
||
<!-- 账号列表 -->
|
||
<table class="w-full text-sm">
|
||
<thead>
|
||
<tr class="text-left text-xs text-gray-400 border-b border-gray-50">
|
||
<th class="px-5 py-2.5 font-medium w-[20%]">账号</th>
|
||
<th class="px-5 py-2.5 font-medium w-[25%]">密码</th>
|
||
<th class="px-5 py-2.5 font-medium w-[12%]">IP</th>
|
||
<th class="px-5 py-2.5 font-medium w-[15%]">状态</th>
|
||
<th class="px-5 py-2.5 font-medium w-[18%]">提交时间</th>
|
||
<th class="px-5 py-2.5 font-medium w-[10%]">操作</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr
|
||
v-for="r in group.records"
|
||
:key="r.id"
|
||
class="border-t border-gray-50 hover:bg-gray-50/50 transition-colors"
|
||
>
|
||
<!-- 账号 -->
|
||
<td class="px-5 py-3">
|
||
<span class="text-gray-900 font-medium">{{ r.username }}</span>
|
||
</td>
|
||
<!-- 密码 -->
|
||
<td class="px-5 py-3">
|
||
<div class="flex items-center gap-2">
|
||
<span
|
||
class="text-gray-600 font-mono text-xs cursor-pointer select-all"
|
||
:class="showPasswordMap[r.id] ? '' : 'text-transparent bg-gray-100 rounded px-1'"
|
||
@click="togglePassword(r.id)"
|
||
>
|
||
{{ showPasswordMap[r.id] ? r.password : r.password }}
|
||
</span>
|
||
<button
|
||
class="inline-flex items-center gap-0.5 text-gray-400 hover:text-blue-500 transition-colors"
|
||
@click="togglePassword(r.id)"
|
||
>
|
||
<Eye v-if="!showPasswordMap[r.id]" class="w-3.5 h-3.5" />
|
||
<EyeOff v-else class="w-3.5 h-3.5" />
|
||
</button>
|
||
<button
|
||
class="inline-flex items-center gap-0.5 text-gray-400 hover:text-blue-500 transition-colors"
|
||
@click="copyText(r.password)"
|
||
>
|
||
<Copy class="w-3.5 h-3.5" />
|
||
</button>
|
||
</div>
|
||
</td>
|
||
<!-- IP -->
|
||
<td class="px-5 py-3">
|
||
<span class="text-gray-500 text-xs">{{ r.ip }}</span>
|
||
</td>
|
||
<!-- 状态 -->
|
||
<td class="px-5 py-3">
|
||
<span
|
||
class="inline-flex items-center px-1.5 py-0.5 rounded-full text-[10px] font-medium"
|
||
:class="r.status === 'pending' ? 'bg-amber-50 text-amber-600' : 'bg-green-50 text-green-600'"
|
||
>
|
||
{{ r.status === 'pending' ? '待处理' : '已完成' }}
|
||
</span>
|
||
</td>
|
||
<!-- 提交时间 -->
|
||
<td class="px-5 py-3 text-gray-500 text-xs">{{ r.createdAt }}</td>
|
||
<!-- 操作 -->
|
||
<td class="px-5 py-3">
|
||
<button
|
||
v-if="r.status === 'pending'"
|
||
class="inline-flex items-center gap-1 px-2 py-1 rounded-lg text-xs text-blue-500 hover:bg-blue-50 transition-colors"
|
||
@click="handleMarkDone(r)"
|
||
>
|
||
<CheckCircle class="w-3 h-3" />
|
||
完成
|
||
</button>
|
||
</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|