Files
cpdaily_xiaozhushou/frondend/src/views/admin/AdminPendingSchools.vue
T
becomer 2e2c161770 feat: init cpdaily-xiaozhushou with blue-green deploy
- 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
2026-07-03 15:37:55 +08:00

265 lines
9.7 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>