Files
tiantian-system/app/pages/developer/requests.vue
2026-04-08 17:10:58 +08:00

565 lines
15 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.

<template>
<div class="dev-page">
<div class="page-header">
<div>
<h2 class="page-title">📋 权限申请记录</h2>
<p class="page-desc">查看你提交的 Git 仓库加组申请及审核状态</p>
</div>
<a-button type="primary" @click="showApplyModal = true">+ 提交新申请</a-button>
</div>
<div class="page-body">
<!-- 状态卡片 -->
<a-row :gutter="[14, 14]" class="mb-5">
<a-col :xs="12" :md="6" v-for="s in statusSummary" :key="s.label">
<div class="status-card" :class="s.color">
<div class="status-num">
<template v-if="loading.stats">-</template>
<template v-else>{{ s.num }}</template>
</div>
<div class="status-label">{{ s.label }}</div>
</div>
</a-col>
</a-row>
<!-- 申请记录列表 -->
<div class="panel">
<div class="panel-header">
<span class="panel-title">申请记录</span>
<a-radio-group v-model:value="filterStatus" size="small" button-style="solid">
<a-radio-button value="">全部</a-radio-button>
<a-radio-button value="pending">待审核</a-radio-button>
<a-radio-button value="approved">已通过</a-radio-button>
<a-radio-button value="rejected">已拒绝</a-radio-button>
</a-radio-group>
</div>
<!-- 加载状态 -->
<div v-if="loading.page" class="loading-state">
<div class="loading-icon">
<span class="loading-dot"></span>
<span class="loading-dot"></span>
<span class="loading-dot"></span>
</div>
<div class="loading-text">正在加载申请记录...</div>
</div>
<!-- 空状态 -->
<div v-else-if="filteredRequests.length === 0" class="empty-state">
<div class="empty-icon">📋</div>
<div class="empty-title">暂无申请记录</div>
<div class="empty-desc">绑定 Git 账号后提交仓库访问申请</div>
<a-space class="mt-4">
<a-button type="primary" @click="showApplyModal = true">提交申请</a-button>
<a-button @click="navigateTo('/developer/git')">绑定 Git 账号</a-button>
</a-space>
</div>
<!-- 申请列表 -->
<div v-else class="request-list">
<div
v-for="req in filteredRequests"
:key="req.id"
class="request-item"
>
<div class="request-status-dot" :class="req.status" />
<div class="request-content">
<div class="request-title-row">
<span class="request-title">{{ req.repoName || req.repo }}</span>
<a-tag :color="statusColor(req.status)">{{ statusText(req.status) }}</a-tag>
</div>
<div class="request-desc">{{ req.reason }}</div>
<div class="request-meta">
<span>🐙 {{ req.gitUsername || '未指定' }}</span>
<span class="meta-dot" />
<span>📅 {{ req.createdAt }}</span>
<span v-if="req.reviewedAt" class="meta-dot" />
<span v-if="req.reviewedAt"> {{ req.reviewedAt }} 审核</span>
<span v-if="req.reviewerName" class="meta-dot" />
<span v-if="req.reviewerName">👤 {{ req.reviewerName }}</span>
</div>
<div v-if="req.status === 'rejected' && req.rejectReason" class="reject-reason">
拒绝原因{{ req.rejectReason }}
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 提交申请弹窗 -->
<a-modal
v-model:open="showApplyModal"
title="📋 提交仓库访问申请"
ok-text="提交申请"
cancel-text="取消"
:ok-button-props="{ loading: loading.submit }"
:cancel-button-props="{ disabled: loading.submit }"
:mask-closable="!loading.submit"
@ok="handleApply"
>
<div v-if="loading.repos" class="modal-loading">
<a-spin size="small" />
<span class="ml-2">正在加载仓库列表...</span>
</div>
<a-form layout="vertical" class="mt-2" v-else>
<a-form-item label="申请仓库" required>
<a-select
v-model:value="applyForm.repo"
placeholder="请选择需要访问的仓库"
:loading="loading.repos"
:disabled="loading.repos"
allow-clear
>
<a-select-option v-for="r in repoOptions" :key="r.value" :value="r.value" :disabled="r.disabled">
{{ r.label }}
</a-select-option>
</a-select>
<div class="form-hint">
灰色选项表示你已有该仓库的访问权限
</div>
</a-form-item>
<a-form-item label="Git 用户名">
<a-input v-model:value="applyForm.gitUsername" placeholder="你绑定的 Gitea 用户名" />
<div class="form-hint">
未绑定<a @click="navigateTo('/developer/git')">前往绑定</a>
</div>
</a-form-item>
<a-form-item label="申请理由" required>
<a-textarea
v-model:value="applyForm.reason"
:rows="3"
placeholder="简述你申请该仓库的用途和背景..."
:maxlength="300"
show-count
:disabled="loading.submit"
/>
</a-form-item>
</a-form>
</a-modal>
</div>
</template>
<script setup lang="ts">
import { message } from 'ant-design-vue'
import type { SelectProps } from 'ant-design-vue'
import {
getPermissionRequests,
createPermissionRequest,
getPermissionRequestStats,
getAvailableRepositories
} from '@/api/developer'
definePageMeta({ layout: 'developer' })
useHead({ title: '权限申请记录 - 开发者中心' })
const showApplyModal = ref(false)
const filterStatus = ref('')
const loading = ref({
page: true,
stats: true,
repos: true,
submit: false,
})
const statsData = ref({
pending: 0,
approved: 0,
rejected: 0,
total: 0,
})
const applyForm = reactive({
repo: undefined as string | undefined,
gitUsername: '',
reason: '',
})
const statusSummary = computed(() => [
{ num: statsData.value.total, label: '全部申请', color: 'gray' },
{ num: statsData.value.pending, label: '待审核', color: 'orange' },
{ num: statsData.value.approved, label: '已通过', color: 'green' },
{ num: statsData.value.rejected, label: '已拒绝', color: 'red' },
])
// 申请记录数据
const requests = ref<any[]>([])
// 仓库选项
const repoOptions = ref<SelectProps['options']>([])
const filteredRequests = computed(() => {
if (!filterStatus.value) return requests.value
return requests.value.filter(r => r.status === filterStatus.value)
})
function statusText(status: string) {
const map: Record<string, string> = {
pending: '待审核',
approved: '已通过',
rejected: '已拒绝',
}
return map[status] || status
}
function statusColor(status: string) {
const map: Record<string, string> = {
pending: 'orange',
approved: 'green',
rejected: 'red',
}
return map[status] || 'default'
}
// 格式化日期
function formatDate(dateStr: string) {
const date = new Date(dateStr)
const now = new Date()
const diffMs = now.getTime() - date.getTime()
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24))
if (diffDays === 0) {
// 今天
return `今天 ${date.getHours().toString().padStart(2, '0')}:${date.getMinutes().toString().padStart(2, '0')}`
} else if (diffDays === 1) {
// 昨天
return `昨天 ${date.getHours().toString().padStart(2, '0')}:${date.getMinutes().toString().padStart(2, '0')}`
} else if (diffDays < 7) {
// 本周内
return `${diffDays}天前`
} else {
// 超过一周
return `${date.getMonth() + 1}/${date.getDate()}`
}
}
// 加载数据
async function loadData() {
try {
loading.value.page = true
loading.value.stats = true
// 并行加载申请列表和统计数据
const [requestsRes, statsRes] = await Promise.all([
getPermissionRequests(),
getPermissionRequestStats()
])
if (requestsRes.data.code === 200 || requestsRes.data.code === 0) {
requests.value = requestsRes.data.data.records.map((item: any) => ({
...item,
createdAt: formatDate(item.createdAt),
reviewedAt: item.reviewedAt ? formatDate(item.reviewedAt) : null
}))
}
if (statsRes.data.code === 200 || statsRes.data.code === 0) {
statsData.value = statsRes.data.data
}
} catch (error) {
console.error('加载申请数据失败:', error)
message.error('加载申请数据失败,请稍后重试')
} finally {
loading.value.page = false
loading.value.stats = false
}
}
// 加载可申请仓库列表
async function loadAvailableRepos() {
try {
loading.value.repos = true
const res = await getAvailableRepositories()
if (res.data.code === 200 || res.data.code === 0) {
repoOptions.value = res.data.data.map((repo: any) => ({
value: repo.value,
label: repo.label,
disabled: repo.isAccessible // 已可访问的仓库禁用
}))
}
} catch (error) {
console.error('加载仓库列表失败:', error)
message.error('加载仓库列表失败,请稍后重试')
} finally {
loading.value.repos = false
}
}
async function handleApply() {
if (!applyForm.repo || !applyForm.reason.trim()) {
message.error('请填写完整申请信息')
return
}
try {
loading.value.submit = true
const res = await createPermissionRequest({
repo: applyForm.repo,
reason: applyForm.reason.trim(),
gitUsername: applyForm.gitUsername.trim() || undefined
})
if (res.data.code === 200 || res.data.code === 0) {
message.success('申请提交成功,请等待审核')
showApplyModal.value = false
// 重置表单
Object.assign(applyForm, { repo: undefined, gitUsername: '', reason: '' })
// 重新加载数据
await loadData()
} else {
message.error(res.data.message || '申请提交失败,请稍后重试')
}
} catch (error: any) {
console.error('提交申请失败:', error)
if (error.response?.status === 400) {
message.error('申请参数错误,请检查填写内容')
} else if (error.response?.status === 409) {
message.error('该仓库已存在待审核的申请')
} else {
message.error('申请提交失败,请稍后重试')
}
} finally {
loading.value.submit = false
}
}
// 页面初始化
onMounted(() => {
loadData()
loadAvailableRepos()
})
// 监听过滤状态变化
watch(filterStatus, () => {
// 可以在这里添加额外的逻辑,比如重新加载筛选后的数据
})
</script>
<style scoped>
.dev-page { min-height: 100%; }
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 24px 28px 16px;
border-bottom: 1px solid #f0f0f0;
gap: 16px;
}
.page-title {
font-size: 20px;
font-weight: 700;
color: rgba(0, 0, 0, 0.88);
margin: 0 0 4px;
}
.page-desc {
font-size: 14px;
color: rgba(0, 0, 0, 0.45);
margin: 0;
}
.page-body {
padding: 20px 24px 28px;
}
/* 状态卡片 */
.status-card {
padding: 16px;
border-radius: 12px;
border: 1px solid transparent;
text-align: center;
}
.status-card.gray { background: #f9fafb; border-color: #f0f0f0; }
.status-card.orange { background: #fff7ed; border-color: #fed7aa; }
.status-card.green { background: #f0fdf4; border-color: #bbf7d0; }
.status-card.red { background: #fef2f2; border-color: #fecaca; }
.status-num {
font-size: 26px;
font-weight: 700;
color: rgba(0, 0, 0, 0.85);
line-height: 1.2;
margin-bottom: 4px;
}
.status-label {
font-size: 12px;
color: rgba(0, 0, 0, 0.45);
}
.panel {
background: #fff;
border: 1px solid #f0f0f0;
border-radius: 12px;
overflow: hidden;
}
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 18px;
border-bottom: 1px solid #f5f5f5;
}
.panel-title {
font-size: 14px;
font-weight: 600;
color: rgba(0, 0, 0, 0.85);
}
/* 空状态 */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
padding: 52px 24px;
text-align: center;
}
.empty-icon { font-size: 48px; margin-bottom: 12px; }
.empty-title { font-size: 16px; font-weight: 600; color: rgba(0, 0, 0, 0.7); }
.empty-desc { font-size: 13px; color: rgba(0, 0, 0, 0.4); margin-top: 6px; }
/* 申请列表 */
.request-list {
padding: 0;
}
.request-item {
display: flex;
gap: 14px;
padding: 16px 20px;
border-bottom: 1px solid #f9f9f9;
transition: background 0.15s;
}
.request-item:hover { background: #fafafa; }
.request-item:last-child { border-bottom: none; }
.request-status-dot {
width: 10px;
height: 10px;
border-radius: 50%;
flex-shrink: 0;
margin-top: 5px;
}
.request-status-dot.pending { background: #f59e0b; }
.request-status-dot.approved { background: #16a34a; }
.request-status-dot.rejected { background: #dc2626; }
.request-content { flex: 1; min-width: 0; }
.request-title-row {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 5px;
}
.request-title {
font-size: 14px;
font-weight: 500;
color: rgba(0, 0, 0, 0.85);
}
.request-desc {
font-size: 13px;
color: rgba(0, 0, 0, 0.5);
margin-bottom: 6px;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.request-meta {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
font-size: 12px;
color: rgba(0, 0, 0, 0.38);
}
.meta-dot {
width: 3px;
height: 3px;
border-radius: 50%;
background: rgba(0, 0, 0, 0.2);
}
.reject-reason {
font-size: 12px;
color: #dc2626;
background: #fef2f2;
padding: 6px 10px;
border-radius: 6px;
margin-top: 8px;
}
.form-hint {
font-size: 12px;
color: rgba(0, 0, 0, 0.38);
margin-top: 4px;
}
.form-hint a {
color: #4f46e5;
cursor: pointer;
}
/* 加载状态 */
.loading-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 80px 24px;
text-align: center;
}
.loading-icon {
display: flex;
gap: 6px;
margin-bottom: 16px;
}
.loading-dot {
width: 10px;
height: 10px;
border-radius: 50%;
background: #4f46e5;
animation: pulse 1.4s ease-in-out infinite;
}
.loading-dot:nth-child(2) { animation-delay: 0.2s; }
.loading-dot:nth-child(3) { animation-delay: 0.4s; }
@keyframes pulse {
0%, 100% { opacity: 0.3; transform: scale(0.8); }
50% { opacity: 1; transform: scale(1); }
}
.loading-text {
font-size: 14px;
color: rgba(0, 0, 0, 0.45);
}
/* 弹窗加载状态 */
.modal-loading {
display: flex;
align-items: center;
justify-content: center;
padding: 40px 0;
color: rgba(0, 0, 0, 0.45);
}
.ml-2 { margin-left: 8px; }
</style>