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

623 lines
18 KiB
Vue
Raw Permalink 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="review-page">
<!-- 页面头部 -->
<div class="page-header">
<div>
<h2 class="page-title">🔍 应用审核管理</h2>
<p class="page-desc">审核开发者提交的应用上架申请</p>
</div>
<a-space>
<a-button @click="loadApps" :loading="loading">
<template #icon><ReloadOutlined /></template>
刷新
</a-button>
</a-space>
</div>
<!-- 状态统计 -->
<a-row :gutter="[16, 16]" class="mb-6">
<a-col :xs="12" :md="6" v-for="stat in reviewStats" :key="stat.key">
<div
class="stat-card"
:class="[stat.color, { active: filterStatus === stat.key }]"
@click="handleStatFilter(stat.key)"
>
<div class="stat-icon">{{ stat.icon }}</div>
<div class="stat-info">
<div class="stat-value">{{ stat.value }}</div>
<div class="stat-label">{{ stat.label }}</div>
</div>
</div>
</a-col>
</a-row>
<!-- 筛选栏 -->
<div class="panel">
<div class="panel-header">
<span class="panel-title">📋 审核列表</span>
<a-space>
<a-select
v-model:value="filterStatus"
style="width: 140px"
@change="loadApps"
>
<a-select-option value="">全部状态</a-select-option>
<a-select-option value="pending_review">待审核</a-select-option>
<a-select-option value="published">已上架</a-select-option>
<a-select-option value="rejected">已拒绝</a-select-option>
<a-select-option value="deprecated">已下架</a-select-option>
</a-select>
<a-input-search
v-model:value="searchKeyword"
placeholder="搜索应用名称"
style="width: 200px"
@search="loadApps"
/>
</a-space>
</div>
<a-table
:columns="columns"
:data-source="apps"
:loading="loading"
:pagination="pagination"
row-key="productId"
@change="handleTableChange"
>
<template #bodyCell="{ column, record }">
<!-- 应用信息 -->
<template v-if="column.key === 'appInfo'">
<div class="app-info-cell">
<img v-if="record.icon" :src="record.icon" class="app-icon" />
<div v-else class="app-icon-placeholder" :style="{ background: iconBgColor(record.productName) }">
{{ (record.productName || 'A').charAt(0).toUpperCase() }}
</div>
<div class="app-info-text">
<div class="app-name">{{ record.productName }}</div>
<div class="app-code">{{ record.productCode }}</div>
<div class="app-developer" v-if="record.developer">
<UserOutlined style="font-size:11px;margin-right:3px" />{{ record.developer }}
</div>
</div>
</div>
</template>
<!-- 发布状态 -->
<template v-if="column.key === 'publishStatus'">
<a-tag :color="statusColor(record.publishStatus)">
{{ statusText(record.publishStatus) }}
</a-tag>
</template>
<!-- 定价 -->
<template v-if="column.key === 'price'">
<span v-if="record.priceType === 'free' || !record.priceType" class="price-free">免费</span>
<span v-else class="price-paid">¥{{ ((record.price || 0) / 100).toFixed(2) }}
<span class="price-period">{{ subscriptionText(record.subscriptionPeriod) }}</span>
</span>
</template>
<!-- 申请时间 -->
<template v-if="column.key === 'applyTime'">
<span class="text-sm text-gray-500">{{ record.publishTime || record.updateTime || '-' }}</span>
</template>
<!-- 操作 -->
<template v-if="column.key === 'action'">
<a-space>
<a-button type="link" size="small" @click="handleViewDetail(record)">详情</a-button>
<!-- 待审核通过/拒绝 -->
<template v-if="record.publishStatus === 'pending_review'">
<a-popconfirm title="确认通过此应用上架申请?" @confirm="handleApprove(record)">
<a-button type="primary" size="small">通过</a-button>
</a-popconfirm>
<a-button danger size="small" @click="handleReject(record)">拒绝</a-button>
</template>
<!-- 已上架下架 -->
<a-popconfirm
v-if="record.publishStatus === 'published'"
title="确认下架此应用?"
@confirm="handleAdminUnpublish(record)"
>
<a-button danger size="small">下架</a-button>
</a-popconfirm>
</a-space>
</template>
</template>
</a-table>
</div>
<!-- 审核详情弹窗 -->
<a-modal
v-model:open="showDetailModal"
:title="`应用详情:${currentApp?.productName || ''}`"
width="700px"
:footer="null"
>
<template v-if="currentApp">
<a-descriptions :column="2" bordered size="small">
<a-descriptions-item label="应用名称">{{ currentApp.productName }}</a-descriptions-item>
<a-descriptions-item label="应用标识">{{ currentApp.productCode }}</a-descriptions-item>
<a-descriptions-item label="开发者">{{ currentApp.developer || '-' }}</a-descriptions-item>
<a-descriptions-item label="发布状态">
<a-tag :color="statusColor(currentApp.publishStatus)">{{ statusText(currentApp.publishStatus) }}</a-tag>
</a-descriptions-item>
<a-descriptions-item label="定价模式">{{ priceTypeText(currentApp.priceType) }}</a-descriptions-item>
<a-descriptions-item label="价格">
<span v-if="currentApp.priceType === 'free' || !currentApp.priceType">免费</span>
<span v-else>¥{{ ((currentApp.price || 0) / 100).toFixed(2) }}</span>
</a-descriptions-item>
<a-descriptions-item label="提交时间" :span="2">{{ currentApp.publishTime || '-' }}</a-descriptions-item>
<a-descriptions-item label="应用简介" :span="2">
{{ currentApp.description || '-' }}
</a-descriptions-item>
<a-descriptions-item label="详细说明" :span="2">
<div class="detail-desc">{{ currentApp.content || '-' }}</div>
</a-descriptions-item>
<a-descriptions-item v-if="currentApp.rejectReason" label="拒绝原因" :span="2">
<a-alert type="error" :message="currentApp.rejectReason" show-icon />
</a-descriptions-item>
</a-descriptions>
<!-- 待审核时的操作区 -->
<div v-if="currentApp.publishStatus === 'pending_review'" class="detail-actions">
<a-popconfirm title="确认通过此应用上架申请?" @confirm="handleApprove(currentApp)">
<a-button type="primary" :loading="approving"> 审核通过</a-button>
</a-popconfirm>
<a-button danger @click="handleReject(currentApp)"> 拒绝上架</a-button>
</div>
</template>
</a-modal>
<!-- 拒绝原因弹窗 -->
<a-modal
v-model:open="showRejectModal"
title="填写拒绝原因"
:confirm-loading="rejecting"
@ok="confirmReject"
@cancel="showRejectModal = false"
>
<a-form layout="vertical">
<a-form-item label="拒绝原因" required>
<a-textarea
v-model:value="rejectReasonInput"
:rows="4"
placeholder="请填写具体的拒绝原因,以便开发者修改后重新提交"
:maxlength="500"
show-count
/>
</a-form-item>
<div class="reject-tips">
<p>💡 常见拒绝原因</p>
<a-space wrap>
<a-tag
v-for="tip in rejectTips"
:key="tip"
class="reject-tip-tag"
@click="rejectReasonInput = tip"
>{{ tip }}</a-tag>
</a-space>
</div>
</a-form>
</a-modal>
</div>
</template>
<script setup lang="ts">
import { ReloadOutlined, UserOutlined } from '@ant-design/icons-vue'
import { message } from 'ant-design-vue'
import {
pagePublishReviews,
approvePublishReview,
rejectPublishReview,
unpublishAppProduct,
} from '@/api/app/appProduct'
import type { AppProduct } from '@/api/app/appProduct/model'
definePageMeta({ layout: 'console' })
useHead({ title: '应用审核管理 - 控制台' })
// 加载状态
const loading = ref(false)
const apps = ref<AppProduct[]>([])
// 筛选
const filterStatus = ref('pending_review')
const searchKeyword = ref('')
// 分页
const pagination = reactive({
current: 1,
pageSize: 20,
total: 0,
showSizeChanger: true,
showQuickJumper: true,
})
// 统计
const reviewStats = reactive([
{ key: 'pending_review', icon: '⏳', label: '待审核', value: 0, color: 'orange' },
{ key: 'published', icon: '✅', label: '已上架', value: 0, color: 'green' },
{ key: 'rejected', icon: '❌', label: '已拒绝', value: 0, color: 'red' },
{ key: '', icon: '📦', label: '全部应用', value: 0, color: 'blue' },
])
// 表格列
const columns = [
{ title: '应用信息', key: 'appInfo', width: 260 },
{ title: '审核状态', key: 'publishStatus', width: 110 },
{ title: '定价', key: 'price', width: 130 },
{ title: '提交时间', key: 'applyTime', width: 160 },
{ title: '操作', key: 'action', width: 200 },
]
// 详情弹窗
const showDetailModal = ref(false)
const currentApp = ref<AppProduct | null>(null)
const approving = ref(false)
// 拒绝弹窗
const showRejectModal = ref(false)
const rejectReasonInput = ref('')
const rejecting = ref(false)
const rejectTargetApp = ref<AppProduct | null>(null)
const rejectTips = [
'功能描述不完整,缺少使用说明文档',
'应用简介过于简单,请补充详细功能介绍',
'应用名称与实际功能不符',
'价格设置不合理,请重新评估',
'存在违规内容,请修改后重新提交',
'截图不清晰或与功能描述不符',
]
// 加载审核列表
async function loadApps() {
loading.value = true
try {
const res = await pagePublishReviews({
page: pagination.current,
limit: pagination.pageSize,
publishStatus: filterStatus.value || undefined,
keywords: searchKeyword.value || undefined,
})
apps.value = res?.list || []
pagination.total = res?.count || 0
updateStats()
} catch {
message.error('加载审核列表失败')
} finally {
loading.value = false
}
}
// 更新统计(单独加载全量统计)
async function updateStats() {
try {
const [pendingRes, publishedRes, rejectedRes, allRes] = await Promise.allSettled([
pagePublishReviews({ page: 1, limit: 1, publishStatus: 'pending_review' }),
pagePublishReviews({ page: 1, limit: 1, publishStatus: 'published' }),
pagePublishReviews({ page: 1, limit: 1, publishStatus: 'rejected' }),
pagePublishReviews({ page: 1, limit: 1 }),
])
if (pendingRes.status === 'fulfilled') reviewStats[0].value = pendingRes.value?.count || 0
if (publishedRes.status === 'fulfilled') reviewStats[1].value = publishedRes.value?.count || 0
if (rejectedRes.status === 'fulfilled') reviewStats[2].value = rejectedRes.value?.count || 0
if (allRes.status === 'fulfilled') reviewStats[3].value = allRes.value?.count || 0
} catch { /* ignore */ }
}
// 统计卡片点击筛选
function handleStatFilter(key: string) {
filterStatus.value = key
pagination.current = 1
loadApps()
}
// 分页变化
function handleTableChange(pag: any) {
pagination.current = pag.current
pagination.pageSize = pag.pageSize
loadApps()
}
// 查看详情
function handleViewDetail(record: AppProduct) {
currentApp.value = record
showDetailModal.value = true
}
// 审核通过
async function handleApprove(record: AppProduct) {
approving.value = true
try {
await approvePublishReview(record.productId!)
message.success(`${record.productName}」已通过审核并上架`)
showDetailModal.value = false
loadApps()
} catch (e: any) {
message.error(e?.message || '操作失败')
} finally {
approving.value = false
}
}
// 打开拒绝弹窗
function handleReject(record: AppProduct) {
rejectTargetApp.value = record
rejectReasonInput.value = ''
showRejectModal.value = true
}
// 确认拒绝
async function confirmReject() {
if (!rejectReasonInput.value.trim()) {
message.warning('请填写拒绝原因')
return
}
if (!rejectTargetApp.value) return
rejecting.value = true
try {
await rejectPublishReview({
productId: rejectTargetApp.value.productId!,
rejectReason: rejectReasonInput.value,
})
message.success('已拒绝并通知开发者')
showRejectModal.value = false
showDetailModal.value = false
loadApps()
} catch (e: any) {
message.error(e?.message || '操作失败')
} finally {
rejecting.value = false
}
}
// 管理员下架
async function handleAdminUnpublish(record: AppProduct) {
try {
await unpublishAppProduct(record.productId!)
message.success(`${record.productName}」已下架`)
loadApps()
} catch (e: any) {
message.error(e?.message || '操作失败')
}
}
// 状态相关
function statusText(status?: string) {
const map: Record<string, string> = {
developing: '开发中',
pending_review: '待审核',
published: '已上架',
rejected: '已拒绝',
deprecated: '已下架',
}
return map[status || ''] || '开发中'
}
function statusColor(status?: string) {
const map: Record<string, string> = {
developing: 'default',
pending_review: 'orange',
published: 'success',
rejected: 'error',
deprecated: 'default',
}
return map[status || ''] || 'default'
}
function priceTypeText(type?: string) {
const map: Record<string, string> = {
free: '免费',
one_time: '一次性付费',
subscription: '订阅制',
}
return map[type || ''] || '免费'
}
function subscriptionText(period?: string) {
if (period === 'month') return '/月'
if (period === 'year') return '/年'
return ''
}
const PALETTE = ['#4e6ef2', '#f4a261', '#e76f51', '#2a9d8f', '#e9c46a', '#457b9d']
function iconBgColor(name?: string) {
if (!name) return PALETTE[0]
let h = 0
for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) & 0xffffffff
return PALETTE[Math.abs(h) % PALETTE.length]
}
onMounted(() => {
loadApps()
})
</script>
<style scoped>
.review-page {
min-height: 100%;
}
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20px;
}
.page-title {
font-size: 18px;
font-weight: 700;
color: #1f2937;
margin: 0;
line-height: 1.4;
}
.page-desc {
font-size: 13px;
color: #9ca3af;
margin: 2px 0 0;
}
/* 统计卡片 */
.stat-card {
display: flex;
align-items: center;
gap: 12px;
padding: 16px;
border-radius: 12px;
border: 2px solid transparent;
cursor: pointer;
transition: all 0.2s;
}
.stat-card:hover { transform: translateY(-2px); box-shadow: 0 4px 12px rgba(0,0,0,0.08); }
.stat-card.active { border-color: currentColor; box-shadow: 0 4px 12px rgba(0,0,0,0.12); }
.stat-card.blue { background: #eff6ff; border-color: #dbeafe; }
.stat-card.orange { background: #fff7ed; border-color: #fed7aa; }
.stat-card.green { background: #f0fdf4; border-color: #bbf7d0; }
.stat-card.red { background: #fff1f2; border-color: #fecdd3; }
.stat-card.active.blue { border-color: #3b82f6; }
.stat-card.active.orange { border-color: #f97316; }
.stat-card.active.green { border-color: #22c55e; }
.stat-card.active.red { border-color: #ef4444; }
.stat-icon { font-size: 28px; flex-shrink: 0; }
.stat-value {
font-size: 22px;
font-weight: 700;
color: rgba(0, 0, 0, 0.85);
line-height: 1.2;
}
.stat-label {
font-size: 12px;
color: rgba(0, 0, 0, 0.45);
margin-top: 2px;
}
/* 面板 */
.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);
}
/* 应用信息格 */
.app-info-cell {
display: flex;
align-items: center;
gap: 12px;
}
.app-icon {
width: 44px;
height: 44px;
border-radius: 8px;
object-fit: cover;
flex-shrink: 0;
}
.app-icon-placeholder {
width: 44px;
height: 44px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
font-weight: 600;
color: #fff;
flex-shrink: 0;
}
.app-info-text { flex: 1; min-width: 0; }
.app-name {
font-size: 14px;
font-weight: 500;
color: rgba(0, 0, 0, 0.85);
}
.app-code {
font-size: 12px;
color: rgba(0, 0, 0, 0.45);
}
.app-developer {
font-size: 12px;
color: rgba(0, 0, 0, 0.45);
margin-top: 2px;
}
/* 价格 */
.price-free { color: #22c55e; font-weight: 500; font-size: 13px; }
.price-paid { color: #f59e0b; font-weight: 600; font-size: 14px; }
.price-period { font-size: 11px; color: rgba(0,0,0,0.45); font-weight: 400; margin-left: 2px; }
/* 详情弹窗 */
.detail-actions {
display: flex;
gap: 12px;
justify-content: flex-end;
margin-top: 20px;
padding-top: 16px;
border-top: 1px solid #f0f0f0;
}
.detail-desc {
max-height: 200px;
overflow-y: auto;
white-space: pre-wrap;
word-break: break-word;
}
/* 拒绝原因提示 */
.reject-tips {
margin-top: 12px;
padding: 12px;
background: #fafafa;
border-radius: 8px;
}
.reject-tips p {
font-size: 12px;
color: rgba(0,0,0,0.45);
margin: 0 0 8px;
}
.reject-tip-tag {
cursor: pointer;
transition: all 0.15s;
}
.reject-tip-tag:hover {
color: #4f46e5;
border-color: #4f46e5;
}
.mb-6 { margin-bottom: 24px; }
.text-sm { font-size: 12px; }
.text-gray-500 { color: rgba(0,0,0,0.45); }
</style>