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

700 lines
20 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">管理应用的上架审核状态和版本发布</p>
</div>
<a-button type="primary" @click="showPublishModal = true">
<template #icon><PlusOutlined /></template>
提交上架申请
</a-button>
</div>
<!-- 状态统计卡片 -->
<a-row :gutter="[16, 16]" class="mb-6">
<a-col :xs="12" :md="6" v-for="stat in publishStats" :key="stat.key">
<div class="stat-card" :class="stat.color">
<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" placeholder="全部状态" @change="loadApps">
<a-select-option value="">全部状态</a-select-option>
<a-select-option value="developing">开发中</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="filteredApps"
: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>
</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'">
<div class="price-cell">
<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>
<span class="price-type">{{ priceTypeText(record.priceType) }}</span>
</div>
</template>
<template v-if="column.key === 'stats'">
<div class="stats-cell">
<span>安装 {{ record.installs || 0 }}</span>
<span>评分 {{ record.rating || '-' }}</span>
</div>
</template>
<template v-if="column.key === 'action'">
<a-space>
<!-- 开发中提交审核 -->
<a-button
v-if="!record.publishStatus || record.publishStatus === 'developing'"
type="primary"
size="small"
@click="handleSubmitReview(record)"
>
提交审核
</a-button>
<!-- 待审核撤回申请 -->
<a-popconfirm
v-if="record.publishStatus === 'pending_review'"
title="确认撤回审核申请?"
@confirm="handleWithdraw(record)"
>
<a-button size="small">撤回申请</a-button>
</a-popconfirm>
<!-- 已上架下架 -->
<a-popconfirm
v-if="record.publishStatus === 'published'"
title="确认下架此应用?"
ok-text="下架"
ok-type="danger"
@confirm="handleUnpublish(record)"
>
<a-button danger size="small">下架</a-button>
</a-popconfirm>
<!-- 审核未通过查看原因 + 重新提交 -->
<template v-if="record.publishStatus === 'rejected'">
<a-button type="link" size="small" @click="handleViewReason(record)">查看原因</a-button>
<a-button type="primary" size="small" @click="handleSubmitReview(record)">重新提交</a-button>
</template>
</a-space>
</template>
</template>
</a-table>
</div>
<!-- 审核记录 -->
<div class="panel mt-4">
<div class="panel-header">
<span class="panel-title">📋 审核记录</span>
<a-button type="link" size="small" @click="loadReviewLogs" :loading="logsLoading">刷新</a-button>
</div>
<a-spin :spinning="logsLoading">
<a-timeline class="timeline-content" v-if="reviewLogs.length > 0">
<a-timeline-item v-for="record in reviewLogs" :key="record.productId" :color="record.color">
<div class="timeline-item-content">
<div class="timeline-title">{{ record.title }}</div>
<div class="timeline-desc">{{ record.desc }}</div>
<div class="timeline-time">{{ record.time }}</div>
</div>
</a-timeline-item>
</a-timeline>
<a-empty v-else description="暂无审核记录" class="py-8" />
</a-spin>
</div>
<!-- 提交上架申请弹窗 -->
<a-modal
v-model:open="showPublishModal"
title="提交上架申请"
width="600px"
:confirm-loading="submitLoading"
@ok="handlePublishSubmit"
@cancel="resetPublishForm"
>
<a-form :model="publishForm" layout="vertical">
<a-form-item label="选择应用" required>
<a-select v-model:value="publishForm.productId" placeholder="选择要上架的应用">
<a-select-option v-for="app in developingApps" :key="app.productId" :value="app.productId">
{{ app.productName }}
</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="定价模式" required>
<a-radio-group v-model:value="publishForm.priceType">
<a-radio value="free">免费</a-radio>
<a-radio value="one_time">一次性付费</a-radio>
<a-radio value="subscription">订阅制</a-radio>
</a-radio-group>
</a-form-item>
<a-form-item v-if="publishForm.priceType !== 'free'" label="价格(元)" required>
<a-input-number v-model:value="publishForm.price" :min="0" :precision="2" style="width: 200px" />
</a-form-item>
<a-form-item v-if="publishForm.priceType === 'subscription'" label="订阅周期" required>
<a-radio-group v-model:value="publishForm.subscriptionPeriod">
<a-radio value="month">按月</a-radio>
<a-radio value="year">按年</a-radio>
</a-radio-group>
</a-form-item>
<a-form-item label="应用简介" required>
<a-textarea v-model:value="publishForm.description" :rows="3" placeholder="简要描述应用功能和特点" />
</a-form-item>
<a-form-item label="详细说明">
<a-textarea v-model:value="publishForm.content" :rows="5" placeholder="详细介绍应用功能、使用场景、技术架构等" />
</a-form-item>
</a-form>
</a-modal>
<!-- 查看审核原因弹窗 -->
<a-modal v-model:open="showReasonModal" title="审核未通过原因" :footer="null">
<a-alert type="error" :message="rejectReason" show-icon />
<div class="mt-4">
<p class="text-gray-600">请根据以上原因修改后重新提交审核</p>
</div>
</a-modal>
</div>
</template>
<script setup lang="ts">
import { PlusOutlined } from '@ant-design/icons-vue'
import { message } from 'ant-design-vue'
import {
pageAppProduct,
updateAppProduct,
submitReview,
withdrawPublishReview,
unpublishAppProduct,
} from '@/api/app/appProduct'
import type { AppProduct } from '@/api/app/appProduct/model'
definePageMeta({ layout: 'developer' })
useHead({ title: '发布管理 - 开发者中心' })
const userId = import.meta.client ? localStorage.getItem('UserId') : null
// 加载状态
const loading = ref(false)
const logsLoading = ref(false)
const apps = ref<AppProduct[]>([])
// 筛选
const filterStatus = ref('')
const searchKeyword = ref('')
// 分页
const pagination = reactive({
current: 1,
pageSize: 10,
total: 0,
showSizeChanger: true,
showQuickJumper: true,
})
// 发布统计
const publishStats = reactive([
{ key: 'developing', icon: '🔧', label: '开发中', value: 0, color: 'blue' },
{ 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' },
])
// 审核记录(从已处理的应用中聚合生成)
interface ReviewLog {
productId?: number
title: string
desc: string
time: string
color: string
}
const reviewLogs = ref<ReviewLog[]>([])
// 表格列
const columns = [
{ title: '应用信息', key: 'appInfo', width: 280 },
{ title: '发布状态', key: 'publishStatus', width: 120 },
{ title: '定价', key: 'price', width: 150 },
{ title: '数据统计', key: 'stats', width: 150 },
{ title: '操作', key: 'action', width: 220 },
]
// 发布弹窗
const showPublishModal = ref(false)
const submitLoading = ref(false)
const publishForm = reactive({
productId: undefined as number | undefined,
priceType: 'free' as 'free' | 'one_time' | 'subscription',
price: 0,
subscriptionPeriod: 'month' as 'month' | 'year',
description: '',
content: '',
})
// 查看原因弹窗
const showReasonModal = ref(false)
const rejectReason = ref('')
// 开发中的应用列表(可提交上架)
const developingApps = computed(() => {
return apps.value.filter(
app => !app.publishStatus || app.publishStatus === 'developing' || app.publishStatus === 'rejected'
)
})
// 前端筛选(关键词过滤)
const filteredApps = computed(() => {
if (!searchKeyword.value) return apps.value
const kw = searchKeyword.value.toLowerCase()
return apps.value.filter(
app =>
app.productName?.toLowerCase().includes(kw) ||
app.productCode?.toLowerCase().includes(kw)
)
})
// 加载应用列表
async function loadApps() {
loading.value = true
try {
const queryUserId = userId ? Number(userId) : undefined
const res = await pageAppProduct({
page: pagination.current,
limit: pagination.pageSize,
userId: queryUserId,
publishStatus: filterStatus.value || undefined,
})
apps.value = res?.list || []
pagination.total = res?.count || 0
updateStats()
} catch {
message.error('加载应用列表失败')
} finally {
loading.value = false
}
}
// 加载审核记录(从应用列表聚合)
function loadReviewLogs() {
logsLoading.value = true
const logs: ReviewLog[] = []
for (const app of apps.value) {
const name = app.productName || '未命名应用'
if (app.publishStatus === 'published' && app.publishTime) {
logs.push({
productId: app.productId,
title: `应用「${name}」审核通过并上架`,
desc: '恭喜!您的应用已通过审核并上架到市场',
time: app.publishTime,
color: 'green',
})
}
if (app.publishStatus === 'rejected' && app.reviewTime) {
logs.push({
productId: app.productId,
title: `应用「${name}」审核未通过`,
desc: app.rejectReason || '请查看具体原因后修改重新提交',
time: app.reviewTime,
color: 'red',
})
}
if (app.publishStatus === 'pending_review' && app.publishTime) {
logs.push({
productId: app.productId,
title: `应用「${name}」已提交审核`,
desc: '等待平台审核人员审核,通常 1-3 个工作日',
time: app.publishTime,
color: 'blue',
})
}
}
// 按时间降序
logs.sort((a, b) => (a.time < b.time ? 1 : -1))
reviewLogs.value = logs
logsLoading.value = false
}
// 更新统计数据
function updateStats() {
publishStats[0].value = apps.value.filter(a => !a.publishStatus || a.publishStatus === 'developing').length
publishStats[1].value = apps.value.filter(a => a.publishStatus === 'pending_review').length
publishStats[2].value = apps.value.filter(a => a.publishStatus === 'published').length
publishStats[3].value = apps.value.filter(a => a.publishStatus === 'rejected').length
loadReviewLogs()
}
// 分页变化
function handleTableChange(pag: any) {
pagination.current = pag.current
pagination.pageSize = pag.pageSize
loadApps()
}
// 状态文本
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 || ''] ?? ''
}
// 图标背景色
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]
}
// 操作:提交审核(打开弹窗)
function handleSubmitReview(record: AppProduct) {
publishForm.productId = record.productId
publishForm.priceType = record.priceType || 'free'
publishForm.price = record.price ? record.price / 100 : 0
publishForm.description = record.description || ''
publishForm.content = record.content || ''
showPublishModal.value = true
}
// 操作:撤回审核
async function handleWithdraw(record: AppProduct) {
try {
await withdrawPublishReview(record.productId!)
message.success('已撤回审核申请')
loadApps()
} catch (e: any) {
message.error(e?.message || '撤回失败')
}
}
// 操作:下架
async function handleUnpublish(record: AppProduct) {
try {
await unpublishAppProduct(record.productId!)
message.success('已下架')
loadApps()
} catch (e: any) {
message.error(e?.message || '下架失败')
}
}
// 操作:查看拒绝原因
function handleViewReason(record: AppProduct) {
rejectReason.value = record.rejectReason || '审核未通过,请联系平台客服了解详情。'
showReasonModal.value = true
}
// 提交上架申请
async function handlePublishSubmit() {
if (!publishForm.productId) {
message.error('请选择应用')
return
}
if (!publishForm.description.trim()) {
message.error('请填写应用简介')
return
}
submitLoading.value = true
try {
// 先更新产品信息(简介、定价等)
await updateAppProduct({
productId: publishForm.productId,
description: publishForm.description,
content: publishForm.content,
priceType: publishForm.priceType,
price: publishForm.priceType !== 'free' ? Math.round(publishForm.price * 100) : 0,
subscriptionPeriod: publishForm.priceType === 'subscription' ? publishForm.subscriptionPeriod : undefined,
} as Partial<AppProduct>)
// 再提交审核
await submitReview(publishForm.productId)
message.success('上架申请提交成功,等待审核(通常 1-3 个工作日)')
showPublishModal.value = false
resetPublishForm()
loadApps()
} catch (e: any) {
message.error(e?.message || '提交失败,请稍后重试')
} finally {
submitLoading.value = false
}
}
function resetPublishForm() {
publishForm.productId = undefined
publishForm.priceType = 'free'
publishForm.price = 0
publishForm.subscriptionPeriod = 'month'
publishForm.description = ''
publishForm.content = ''
}
onMounted(() => {
loadApps()
})
</script>
<style scoped>
.dev-page {
min-height: 100%;
padding: 20px 24px 28px;
}
.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: 1px solid transparent;
transition: all 0.2s;
}
.stat-card:hover { transform: translateY(-1px); }
.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-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: 40px;
height: 40px;
border-radius: 8px;
object-fit: cover;
}
.app-icon-placeholder {
width: 40px;
height: 40px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
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);
}
.price-cell {
display: flex;
flex-direction: column;
}
.price-free {
color: #22c55e;
font-weight: 500;
}
.price-paid {
color: #f59e0b;
font-weight: 600;
font-size: 16px;
}
.price-type {
font-size: 12px;
color: rgba(0, 0, 0, 0.45);
}
.stats-cell {
display: flex;
flex-direction: column;
gap: 4px;
font-size: 12px;
color: rgba(0, 0, 0, 0.65);
}
/* 时间线 */
.timeline-content {
padding: 20px;
}
.timeline-item-content {
display: flex;
flex-direction: column;
gap: 4px;
}
.timeline-title {
font-size: 14px;
font-weight: 500;
color: rgba(0, 0, 0, 0.85);
}
.timeline-desc {
font-size: 13px;
color: rgba(0, 0, 0, 0.65);
}
.timeline-time {
font-size: 12px;
color: rgba(0, 0, 0, 0.45);
}
.mb-6 { margin-bottom: 24px; }
.mt-4 { margin-top: 16px; }
.py-8 { padding: 32px 0; }
</style>