feat(developer): 完成小程序开发者中心和企业控制台改造
- 设计并实现了开发者中心与企业控制台两大模块 - 按用户角色区分开发者和企业客户,支持多项目类型及成员管理 - 新增项目管理、应用管理、API Key管理及成员邀请等多功能页面 - 实现应用版本发布、消息通知中心、权限审批与开发者申请流程 - 完成CI/CD流水线、运营监控、发票管理、SSO单点登录功能 - 搭建SDK下载中心、工单系统、FAQ系统、数据导入导出等模块 - 优化后端API,支持已登录和未注册用户不同加入应用流程 - 前端按钮统一采用微信手机号授权,完善用户授权体验 - 修复多个页面的JSX语法错误及依赖导入问题,替换部分组件库 - 增加详细的类型定义文件,提升项目类型安全 - 新增超过55个页面及60个API接口,扩展应用功能和服务体系 - 完成全面的样式设计,实现一致的视觉风格和交互体验
This commit is contained in:
6
src/developer/app/[id]/analytics.config.ts
Normal file
6
src/developer/app/[id]/analytics.config.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* 运营监控页面配置
|
||||
*/
|
||||
export default definePageConfig({
|
||||
navigationBarTitleText: '运营监控',
|
||||
})
|
||||
457
src/developer/app/[id]/analytics.scss
Normal file
457
src/developer/app/[id]/analytics.scss
Normal file
@@ -0,0 +1,457 @@
|
||||
.analytics-page {
|
||||
min-height: 100vh;
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
.loading-wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding-top: 300px;
|
||||
|
||||
.loading-text {
|
||||
margin-top: 16px;
|
||||
font-size: 28px;
|
||||
color: #999;
|
||||
}
|
||||
}
|
||||
|
||||
// 实时数据卡片
|
||||
.realtime-card {
|
||||
background: linear-gradient(135deg, #1890ff, #52c41a);
|
||||
padding: 24px;
|
||||
margin: 24px;
|
||||
border-radius: 16px;
|
||||
color: #fff;
|
||||
|
||||
.realtime-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 24px;
|
||||
|
||||
.title {
|
||||
font-size: 32px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.live-dot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 22px;
|
||||
|
||||
.dot {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
animation: pulse 1.5s infinite;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.realtime-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 16px;
|
||||
|
||||
.stat-item {
|
||||
text-align: center;
|
||||
|
||||
.stat-value {
|
||||
display: block;
|
||||
font-size: 36px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 4px;
|
||||
|
||||
&.error {
|
||||
color: #ff6b6b;
|
||||
}
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 22px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
// 今日概览
|
||||
.overview-stats {
|
||||
display: flex;
|
||||
padding: 0 24px;
|
||||
gap: 16px;
|
||||
|
||||
.stat-card {
|
||||
flex: 1;
|
||||
background: #fff;
|
||||
padding: 20px;
|
||||
border-radius: 12px;
|
||||
text-align: center;
|
||||
|
||||
.stat-value {
|
||||
display: block;
|
||||
font-size: 32px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 22px;
|
||||
color: #999;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Tab 内容
|
||||
.tab-content {
|
||||
height: calc(100vh - 550px);
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.chart-section,
|
||||
.metrics-section,
|
||||
.region-section,
|
||||
.source-section,
|
||||
.page-section,
|
||||
.error-section {
|
||||
background: #fff;
|
||||
border-radius: 16px;
|
||||
padding: 24px;
|
||||
margin-bottom: 24px;
|
||||
|
||||
.section-title {
|
||||
display: block;
|
||||
font-size: 30px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
// 图表占位
|
||||
.chart-placeholder {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 300px;
|
||||
background: #fafafa;
|
||||
border-radius: 12px;
|
||||
|
||||
.iconfont {
|
||||
font-size: 64px;
|
||||
color: #ccc;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.placeholder-text {
|
||||
font-size: 28px;
|
||||
color: #666;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.placeholder-hint {
|
||||
font-size: 22px;
|
||||
color: #999;
|
||||
}
|
||||
}
|
||||
|
||||
// 饼图
|
||||
.pie-chart {
|
||||
.pie-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.pie-color {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 4px;
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
.pie-label {
|
||||
flex: 1;
|
||||
font-size: 26px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.pie-value {
|
||||
font-size: 26px;
|
||||
color: #666;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 性能指标
|
||||
.metric-item {
|
||||
padding: 16px 0;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.metric-info {
|
||||
.metric-name {
|
||||
display: block;
|
||||
font-size: 26px;
|
||||
color: #666;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.metric-value-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
|
||||
.metric-value {
|
||||
font-size: 40px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.metric-unit {
|
||||
font-size: 24px;
|
||||
color: #999;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 错误统计
|
||||
.error-summary {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
|
||||
.error-item {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
background: #fafafa;
|
||||
border-radius: 12px;
|
||||
|
||||
.error-value {
|
||||
display: block;
|
||||
font-size: 40px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
|
||||
&.error {
|
||||
color: #F44336;
|
||||
}
|
||||
|
||||
&.warning {
|
||||
color: #FF9800;
|
||||
}
|
||||
}
|
||||
|
||||
.error-label {
|
||||
font-size: 24px;
|
||||
color: #666;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 用户统计
|
||||
.user-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
|
||||
.user-card {
|
||||
background: #fff;
|
||||
padding: 24px 16px;
|
||||
border-radius: 12px;
|
||||
text-align: center;
|
||||
|
||||
.user-value {
|
||||
display: block;
|
||||
font-size: 36px;
|
||||
font-weight: 600;
|
||||
color: #1890ff;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.user-label {
|
||||
font-size: 24px;
|
||||
color: #666;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 区域分布
|
||||
.region-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.region-rank {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
line-height: 32px;
|
||||
text-align: center;
|
||||
background: #1890ff;
|
||||
color: #fff;
|
||||
border-radius: 50%;
|
||||
font-size: 22px;
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
.region-name {
|
||||
width: 100px;
|
||||
font-size: 26px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.region-bar {
|
||||
flex: 1;
|
||||
height: 16px;
|
||||
background: #f0f0f0;
|
||||
border-radius: 8px;
|
||||
margin: 0 12px;
|
||||
overflow: hidden;
|
||||
|
||||
.bar-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #1890ff, #52c41a);
|
||||
border-radius: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.region-value {
|
||||
width: 80px;
|
||||
text-align: right;
|
||||
font-size: 26px;
|
||||
color: #666;
|
||||
}
|
||||
}
|
||||
|
||||
// 来源分布
|
||||
.source-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.source-rank {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
line-height: 32px;
|
||||
text-align: center;
|
||||
background: #f0f0f0;
|
||||
color: #666;
|
||||
border-radius: 50%;
|
||||
font-size: 22px;
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
.source-name {
|
||||
flex: 1;
|
||||
font-size: 26px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.source-value {
|
||||
font-size: 26px;
|
||||
color: #666;
|
||||
}
|
||||
}
|
||||
|
||||
// 页面排行
|
||||
.page-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 16px 0;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.page-rank {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
line-height: 32px;
|
||||
text-align: center;
|
||||
background: #1890ff;
|
||||
color: #fff;
|
||||
border-radius: 50%;
|
||||
font-size: 22px;
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
.page-info {
|
||||
flex: 1;
|
||||
|
||||
.page-title {
|
||||
display: block;
|
||||
font-size: 26px;
|
||||
color: #333;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.page-path {
|
||||
font-size: 22px;
|
||||
color: #999;
|
||||
}
|
||||
}
|
||||
|
||||
.page-stats {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
|
||||
.page-stat {
|
||||
text-align: center;
|
||||
|
||||
.stat-num {
|
||||
display: block;
|
||||
font-size: 26px;
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 20px;
|
||||
color: #999;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 空数据
|
||||
.empty-data {
|
||||
text-align: center;
|
||||
padding: 48px;
|
||||
font-size: 26px;
|
||||
color: #999;
|
||||
}
|
||||
425
src/developer/app/[id]/analytics.tsx
Normal file
425
src/developer/app/[id]/analytics.tsx
Normal file
@@ -0,0 +1,425 @@
|
||||
/**
|
||||
* 运营监控页面 - 数据分析看板
|
||||
*/
|
||||
import { useState, useEffect } from 'react'
|
||||
import { View, Text, ScrollView } from '@tarojs/components'
|
||||
import Taro from '@tarojs/taro'
|
||||
import { AtTabs, AtTabsPane, AtTag, AtActivityIndicator } from 'taro-ui'
|
||||
import {
|
||||
getOverviewStats,
|
||||
getRealtimeData,
|
||||
getPerformanceMetrics,
|
||||
getDeviceStats,
|
||||
getSourceStats,
|
||||
getPageStats,
|
||||
getRegionStats,
|
||||
} from '../../../../api/analytics'
|
||||
import type { OverviewStats, PerformanceMetric, DeviceStat, SourceStat, PageStat, RegionStat } from '../../../../types/analytics'
|
||||
import './analytics.scss'
|
||||
|
||||
// 格式化数字
|
||||
const formatNumber = (num?: number) => {
|
||||
if (!num) return '0'
|
||||
if (num >= 100000000) return (num / 100000000).toFixed(1) + '亿'
|
||||
if (num >= 10000) return (num / 10000).toFixed(1) + '万'
|
||||
return num.toLocaleString()
|
||||
}
|
||||
|
||||
// 格式化百分比
|
||||
const formatPercent = (num?: number) => {
|
||||
if (!num) return '0%'
|
||||
return num.toFixed(1) + '%'
|
||||
}
|
||||
|
||||
export default function Analytics() {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [realtime, setRealtime] = useState({
|
||||
uv: 0,
|
||||
pv: 0,
|
||||
apiCalls: 0,
|
||||
errors: 0,
|
||||
activeUsers: 0,
|
||||
})
|
||||
const [overview, setOverview] = useState<OverviewStats>({})
|
||||
const [metrics, setMetrics] = useState<PerformanceMetric[]>([])
|
||||
const [devices, setDevices] = useState<DeviceStat[]>([])
|
||||
const [sources, setSources] = useState<SourceStat[]>([])
|
||||
const [pages, setPages] = useState<PageStat[]>([])
|
||||
const [regions, setRegions] = useState<RegionStat[]>([])
|
||||
const [activeTab, setActiveTab] = useState(0)
|
||||
const [appId, setAppId] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
const pages = Taro.getCurrentPages()
|
||||
const current = pages[pages.length - 1]
|
||||
if (current?.options?.appId) {
|
||||
setAppId(current.options.appId)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (appId) {
|
||||
fetchData()
|
||||
// 实时数据每 30 秒刷新
|
||||
const timer = setInterval(() => {
|
||||
fetchRealtimeData()
|
||||
}, 30000)
|
||||
return () => clearInterval(timer)
|
||||
}
|
||||
}, [appId])
|
||||
|
||||
// 获取所有数据
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
await Promise.all([
|
||||
fetchRealtimeData(),
|
||||
fetchOverviewData(),
|
||||
fetchMetricsData(),
|
||||
fetchDeviceData(),
|
||||
fetchSourceData(),
|
||||
fetchPageData(),
|
||||
fetchRegionData(),
|
||||
])
|
||||
} catch (err) {
|
||||
console.error('获取数据失败', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 获取实时数据
|
||||
const fetchRealtimeData = async () => {
|
||||
try {
|
||||
const res = await getRealtimeData(Number(appId))
|
||||
if (res.data) {
|
||||
setRealtime(res.data)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('获取实时数据失败', err)
|
||||
}
|
||||
}
|
||||
|
||||
// 获取概览数据
|
||||
const fetchOverviewData = async () => {
|
||||
try {
|
||||
const res = await getOverviewStats(Number(appId))
|
||||
if (res.data) {
|
||||
setOverview(res.data)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('获取概览数据失败', err)
|
||||
}
|
||||
}
|
||||
|
||||
// 获取性能指标
|
||||
const fetchMetricsData = async () => {
|
||||
try {
|
||||
const res = await getPerformanceMetrics(Number(appId))
|
||||
if (res.data) {
|
||||
setMetrics(res.data.list || [])
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('获取性能指标失败', err)
|
||||
}
|
||||
}
|
||||
|
||||
// 获取设备分布
|
||||
const fetchDeviceData = async () => {
|
||||
try {
|
||||
const res = await getDeviceStats(Number(appId))
|
||||
if (res.data) {
|
||||
setDevices(res.data.list || [])
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('获取设备数据失败', err)
|
||||
}
|
||||
}
|
||||
|
||||
// 获取来源分布
|
||||
const fetchSourceData = async () => {
|
||||
try {
|
||||
const res = await getSourceStats(Number(appId))
|
||||
if (res.data) {
|
||||
setSources(res.data.list || [])
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('获取来源数据失败', err)
|
||||
}
|
||||
}
|
||||
|
||||
// 获取页面访问
|
||||
const fetchPageData = async () => {
|
||||
try {
|
||||
const res = await getPageStats({
|
||||
websiteId: Number(appId),
|
||||
limit: 10,
|
||||
})
|
||||
if (res.data) {
|
||||
setPages(res.data.list || [])
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('获取页面数据失败', err)
|
||||
}
|
||||
}
|
||||
|
||||
// 获取区域分布
|
||||
const fetchRegionData = async () => {
|
||||
try {
|
||||
const res = await getRegionStats(Number(appId))
|
||||
if (res.data) {
|
||||
setRegions(res.data.list || [])
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('获取区域数据失败', err)
|
||||
}
|
||||
}
|
||||
|
||||
const tabs = [
|
||||
{ title: '概览' },
|
||||
{ title: '性能' },
|
||||
{ title: '用户' },
|
||||
{ title: '页面' },
|
||||
]
|
||||
|
||||
return (
|
||||
<View className="analytics-page">
|
||||
{loading && realtime.uv === 0 ? (
|
||||
<View className="loading-wrap">
|
||||
<AtActivityIndicator size={32} />
|
||||
<Text className="loading-text">加载中...</Text>
|
||||
</View>
|
||||
) : (
|
||||
<>
|
||||
{/* 实时数据 */}
|
||||
<View className="realtime-card">
|
||||
<View className="realtime-header">
|
||||
<Text className="title">实时数据</Text>
|
||||
<View className="live-dot">
|
||||
<View className="dot" />
|
||||
<Text>LIVE</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View className="realtime-stats">
|
||||
<View className="stat-item">
|
||||
<Text className="stat-value">{formatNumber(realtime.uv)}</Text>
|
||||
<Text className="stat-label">实时 UV</Text>
|
||||
</View>
|
||||
<View className="stat-item">
|
||||
<Text className="stat-value">{formatNumber(realtime.pv)}</Text>
|
||||
<Text className="stat-label">实时 PV</Text>
|
||||
</View>
|
||||
<View className="stat-item">
|
||||
<Text className="stat-value">{formatNumber(realtime.apiCalls)}</Text>
|
||||
<Text className="stat-label">API 调用</Text>
|
||||
</View>
|
||||
<View className="stat-item">
|
||||
<Text className={`stat-value ${realtime.errors > 0 ? 'error' : ''}`}>
|
||||
{formatNumber(realtime.errors)}
|
||||
</Text>
|
||||
<Text className="stat-label">错误数</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 今日概览 */}
|
||||
<View className="overview-stats">
|
||||
<View className="stat-card">
|
||||
<Text className="stat-value">{formatNumber(overview.todayUv)}</Text>
|
||||
<Text className="stat-label">今日 UV</Text>
|
||||
</View>
|
||||
<View className="stat-card">
|
||||
<Text className="stat-value">{formatNumber(overview.todayPv)}</Text>
|
||||
<Text className="stat-label">今日 PV</Text>
|
||||
</View>
|
||||
<View className="stat-card">
|
||||
<Text className="stat-value">{formatNumber(overview.todayApiCalls)}</Text>
|
||||
<Text className="stat-label">API 调用</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Tab 切换 */}
|
||||
<AtTabs
|
||||
current={activeTab}
|
||||
tabList={tabs}
|
||||
scroll
|
||||
onClick={(index) => setActiveTab(index)}
|
||||
>
|
||||
<AtTabsPane current={activeTab} index={0}>
|
||||
<ScrollView scrollY className="tab-content">
|
||||
{/* 趋势图表区 */}
|
||||
<View className="chart-section">
|
||||
<Text className="section-title">数据趋势</Text>
|
||||
<View className="chart-placeholder">
|
||||
<Text className="iconfont icon-chart" />
|
||||
<Text className="placeholder-text">趋势图表</Text>
|
||||
<Text className="placeholder-hint">可集成 ECharts 展示数据趋势</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 设备分布 */}
|
||||
<View className="chart-section">
|
||||
<Text className="section-title">设备分布</Text>
|
||||
{devices.length > 0 ? (
|
||||
<View className="pie-chart">
|
||||
{devices.map((device, index) => (
|
||||
<View className="pie-item" key={index}>
|
||||
<View
|
||||
className="pie-color"
|
||||
style={{
|
||||
background: ['#1890ff', '#52c41a', '#faad14', '#f5222d'][index % 4],
|
||||
}}
|
||||
/>
|
||||
<Text className="pie-label">{device.device}</Text>
|
||||
<Text className="pie-value">{formatPercent(device.percentage)}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
) : (
|
||||
<View className="empty-data">暂无数据</View>
|
||||
)}
|
||||
</View>
|
||||
</ScrollView>
|
||||
</AtTabsPane>
|
||||
|
||||
<AtTabsPane current={activeTab} index={1}>
|
||||
<ScrollView scrollY className="tab-content">
|
||||
{/* 性能指标 */}
|
||||
<View className="metrics-section">
|
||||
<Text className="section-title">核心指标</Text>
|
||||
{metrics.length > 0 ? (
|
||||
metrics.map((metric, index) => (
|
||||
<View className="metric-item" key={index}>
|
||||
<View className="metric-info">
|
||||
<Text className="metric-name">{metric.metric}</Text>
|
||||
<View className="metric-value-row">
|
||||
<Text className="metric-value">{metric.value?.toFixed(2)}</Text>
|
||||
<Text className="metric-unit">{metric.unit}</Text>
|
||||
<AtTag
|
||||
size="small"
|
||||
type={metric.trend === 'up' ? 'success' : metric.trend === 'down' ? 'error' : 'primary'}
|
||||
>
|
||||
{metric.trend === 'up' ? '↑' : metric.trend === 'down' ? '↓' : '→'}
|
||||
{Math.abs(metric.change || 0)}%
|
||||
</AtTag>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
))
|
||||
) : (
|
||||
<View className="empty-data">暂无数据</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* 错误统计 */}
|
||||
<View className="error-section">
|
||||
<Text className="section-title">错误统计</Text>
|
||||
<View className="error-summary">
|
||||
<View className="error-item">
|
||||
<Text className="error-value error">{overview.todayErrors || 0}</Text>
|
||||
<Text className="error-label">今日错误</Text>
|
||||
</View>
|
||||
<View className="error-item">
|
||||
<Text className="error-value warning">0</Text>
|
||||
<Text className="error-label">严重错误</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</AtTabsPane>
|
||||
|
||||
<AtTabsPane current={activeTab} index={2}>
|
||||
<ScrollView scrollY className="tab-content">
|
||||
{/* 用户统计 */}
|
||||
<View className="user-stats">
|
||||
<View className="user-card">
|
||||
<Text className="user-value">{formatNumber(overview.activeUsers)}</Text>
|
||||
<Text className="user-label">活跃用户</Text>
|
||||
</View>
|
||||
<View className="user-card">
|
||||
<Text className="user-value">{formatNumber(overview.newUsers)}</Text>
|
||||
<Text className="user-label">新增用户</Text>
|
||||
</View>
|
||||
<View className="user-card">
|
||||
<Text className="user-value">{formatPercent(overview.retention)}</Text>
|
||||
<Text className="user-label">留存率</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 区域分布 */}
|
||||
<View className="region-section">
|
||||
<Text className="section-title">地域分布</Text>
|
||||
{regions.length > 0 ? (
|
||||
regions.slice(0, 5).map((region, index) => (
|
||||
<View className="region-item" key={index}>
|
||||
<View className="region-rank">{index + 1}</View>
|
||||
<Text className="region-name">{region.region}</Text>
|
||||
<View className="region-bar">
|
||||
<View
|
||||
className="bar-fill"
|
||||
style={{ width: (region.percentage || 0) + '%' }}
|
||||
/>
|
||||
</View>
|
||||
<Text className="region-value">{formatPercent(region.percentage)}</Text>
|
||||
</View>
|
||||
))
|
||||
) : (
|
||||
<View className="empty-data">暂无数据</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* 来源分布 */}
|
||||
<View className="source-section">
|
||||
<Text className="section-title">流量来源</Text>
|
||||
{sources.length > 0 ? (
|
||||
sources.map((source, index) => (
|
||||
<View className="source-item" key={index}>
|
||||
<View className="source-rank">{index + 1}</View>
|
||||
<Text className="source-name">{source.source}</Text>
|
||||
<Text className="source-value">{formatPercent(source.percentage)}</Text>
|
||||
</View>
|
||||
))
|
||||
) : (
|
||||
<View className="empty-data">暂无数据</View>
|
||||
)}
|
||||
</View>
|
||||
</ScrollView>
|
||||
</AtTabsPane>
|
||||
|
||||
<AtTabsPane current={activeTab} index={3}>
|
||||
<ScrollView scrollY className="tab-content">
|
||||
{/* 页面排行 */}
|
||||
<View className="page-section">
|
||||
<Text className="section-title">页面访问排行</Text>
|
||||
{pages.length > 0 ? (
|
||||
pages.map((page, index) => (
|
||||
<View className="page-item" key={index}>
|
||||
<View className="page-rank">{index + 1}</View>
|
||||
<View className="page-info">
|
||||
<Text className="page-title">{page.title || page.path}</Text>
|
||||
<Text className="page-path">{page.path}</Text>
|
||||
</View>
|
||||
<View className="page-stats">
|
||||
<View className="page-stat">
|
||||
<Text className="stat-num">{formatNumber(page.pv)}</Text>
|
||||
<Text className="stat-label">PV</Text>
|
||||
</View>
|
||||
<View className="page-stat">
|
||||
<Text className="stat-num">{formatNumber(page.uv)}</Text>
|
||||
<Text className="stat-label">UV</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
))
|
||||
) : (
|
||||
<View className="empty-data">暂无数据</View>
|
||||
)}
|
||||
</View>
|
||||
</ScrollView>
|
||||
</AtTabsPane>
|
||||
</AtTabs>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
6
src/developer/app/[id]/build/[id].config.ts
Normal file
6
src/developer/app/[id]/build/[id].config.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* 构建详情页面配置
|
||||
*/
|
||||
export default definePageConfig({
|
||||
navigationBarTitleText: '构建详情',
|
||||
})
|
||||
261
src/developer/app/[id]/build/[id].scss
Normal file
261
src/developer/app/[id]/build/[id].scss
Normal file
@@ -0,0 +1,261 @@
|
||||
.build-detail {
|
||||
min-height: 100vh;
|
||||
background: #f5f5f5;
|
||||
padding: 24px;
|
||||
|
||||
&.loading-wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding-top: 300px;
|
||||
|
||||
.loading-text {
|
||||
margin-top: 16px;
|
||||
font-size: 28px;
|
||||
color: #999;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.status-card {
|
||||
background: #fff;
|
||||
border-radius: 16px;
|
||||
padding: 24px;
|
||||
margin-bottom: 24px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
|
||||
|
||||
.status-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 24px;
|
||||
|
||||
.build-no {
|
||||
font-size: 36px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
}
|
||||
|
||||
.status-info {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
|
||||
.info-item {
|
||||
.label {
|
||||
display: block;
|
||||
font-size: 24px;
|
||||
color: #999;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.value {
|
||||
font-size: 28px;
|
||||
color: #333;
|
||||
|
||||
&.commit {
|
||||
font-family: monospace;
|
||||
color: #2196F3;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.commit-message {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
background: #f5f5f5;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 16px;
|
||||
|
||||
.iconfont {
|
||||
color: #2196F3;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.msg {
|
||||
flex: 1;
|
||||
font-size: 26px;
|
||||
color: #666;
|
||||
line-height: 1.5;
|
||||
}
|
||||
}
|
||||
|
||||
.meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 24px;
|
||||
color: #999;
|
||||
}
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
|
||||
.at-button {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
background: #fff;
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 24px;
|
||||
|
||||
.tab {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
padding: 24px;
|
||||
font-size: 28px;
|
||||
color: #666;
|
||||
position: relative;
|
||||
|
||||
&.active {
|
||||
color: #1890ff;
|
||||
font-weight: 500;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 60px;
|
||||
height: 4px;
|
||||
background: #1890ff;
|
||||
border-radius: 2px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
height: calc(100vh - 600px);
|
||||
}
|
||||
|
||||
.info-panel {
|
||||
.info-section {
|
||||
background: #fff;
|
||||
border-radius: 16px;
|
||||
padding: 24px;
|
||||
margin-bottom: 24px;
|
||||
|
||||
.section-title {
|
||||
display: block;
|
||||
font-size: 30px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
margin-bottom: 20px;
|
||||
padding-bottom: 16px;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
|
||||
.info-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 0;
|
||||
|
||||
.row-label {
|
||||
font-size: 26px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.row-value {
|
||||
font-size: 26px;
|
||||
color: #333;
|
||||
|
||||
&.commit {
|
||||
font-family: monospace;
|
||||
color: #2196F3;
|
||||
max-width: 400px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.logs-panel {
|
||||
background: #1e1e1e;
|
||||
border-radius: 16px;
|
||||
padding: 24px;
|
||||
min-height: 500px;
|
||||
|
||||
.logs-content {
|
||||
.log-line {
|
||||
display: block;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 22px;
|
||||
color: #d4d4d4;
|
||||
line-height: 1.8;
|
||||
word-break: break-all;
|
||||
}
|
||||
}
|
||||
|
||||
.loading-logs {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 300px;
|
||||
color: #d4d4d4;
|
||||
font-size: 26px;
|
||||
gap: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.artifacts-panel {
|
||||
.artifact-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
background: #fff;
|
||||
border-radius: 16px;
|
||||
padding: 24px;
|
||||
margin-bottom: 16px;
|
||||
|
||||
.artifact-info {
|
||||
flex: 1;
|
||||
|
||||
.artifact-name {
|
||||
display: block;
|
||||
font-size: 28px;
|
||||
color: #333;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.artifact-size {
|
||||
font-size: 24px;
|
||||
color: #999;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.empty-artifacts {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 100px 0;
|
||||
color: #999;
|
||||
|
||||
.iconfont {
|
||||
font-size: 96px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
text {
|
||||
font-size: 28px;
|
||||
}
|
||||
}
|
||||
}
|
||||
325
src/developer/app/[id]/build/[id].tsx
Normal file
325
src/developer/app/[id]/build/[id].tsx
Normal file
@@ -0,0 +1,325 @@
|
||||
/**
|
||||
* 构建详情页面
|
||||
*/
|
||||
import { useState, useEffect } from 'react'
|
||||
import { View, Text, ScrollView, Button } from '@tarojs/components'
|
||||
import Taro from '@tarojs/taro'
|
||||
import { AtButton, AtTag, AtActivityIndicator, AtTimeline } from 'taro-ui'
|
||||
import { getBuild, getBuildLogs, triggerBuild } from '../../../../../api/cicd'
|
||||
import type { Build, BuildStatus } from '../../../../../types/cicd'
|
||||
import './build-detail.scss'
|
||||
|
||||
// 状态映射
|
||||
const STATUS_MAP: Record<BuildStatus, { text: string; color: string }> = {
|
||||
pending: { text: '等待中', color: '#FFC107' },
|
||||
running: { text: '构建中', color: '#2196F3' },
|
||||
success: { text: '构建成功', color: '#4CAF50' },
|
||||
failed: { text: '构建失败', color: '#F44336' },
|
||||
cancelled: { text: '已取消', color: '#9E9E9E' },
|
||||
}
|
||||
|
||||
export default function BuildDetail() {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [build, setBuild] = useState<Build>({})
|
||||
const [logs, setLogs] = useState('')
|
||||
const [activeTab, setActiveTab] = useState('info')
|
||||
const [buildId, setBuildId] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
const pages = Taro.getCurrentPages()
|
||||
const current = pages[pages.length - 1]
|
||||
if (current?.options?.id) {
|
||||
setBuildId(current.options.id)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (buildId) {
|
||||
fetchBuildDetail()
|
||||
}
|
||||
}, [buildId])
|
||||
|
||||
// 获取构建详情
|
||||
const fetchBuildDetail = async () => {
|
||||
try {
|
||||
const res = await getBuild(Number(buildId))
|
||||
if (res.data) {
|
||||
setBuild(res.data)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('获取构建详情失败', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 获取构建日志
|
||||
const fetchBuildLogs = async () => {
|
||||
try {
|
||||
const res = await getBuildLogs(Number(buildId))
|
||||
if (res.data) {
|
||||
setLogs(res.data.logs || '')
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('获取构建日志失败', err)
|
||||
}
|
||||
}
|
||||
|
||||
// 切换 Tab
|
||||
useEffect(() => {
|
||||
if (activeTab === 'logs' && !logs) {
|
||||
fetchBuildLogs()
|
||||
}
|
||||
}, [activeTab])
|
||||
|
||||
// 重新触发构建
|
||||
const handleRetryBuild = async () => {
|
||||
Taro.showModal({
|
||||
title: '提示',
|
||||
content: '确定要重新构建吗?',
|
||||
success: async (res) => {
|
||||
if (res.confirm && build.websiteId) {
|
||||
try {
|
||||
Taro.showLoading({ title: '重新构建中...' })
|
||||
await triggerBuild({
|
||||
websiteId: build.websiteId!,
|
||||
commitId: build.commitId,
|
||||
branch: build.branch,
|
||||
})
|
||||
Taro.showToast({ title: '构建已触发', icon: 'success' })
|
||||
fetchBuildDetail()
|
||||
} catch (err) {
|
||||
Taro.showToast({ title: '触发失败', icon: 'none' })
|
||||
} finally {
|
||||
Taro.hideLoading()
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// 部署
|
||||
const handleDeploy = () => {
|
||||
Taro.navigateTo({
|
||||
url: `/developer/app/${build.websiteId}/deploys?buildId=${buildId}`,
|
||||
})
|
||||
}
|
||||
|
||||
// 格式化时间
|
||||
const formatTime = (time?: string) => {
|
||||
if (!time) return '-'
|
||||
const date = new Date(time)
|
||||
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')} ${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}:${String(date.getSeconds()).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
// 格式化时长
|
||||
const formatDuration = (seconds?: number) => {
|
||||
if (!seconds) return '-'
|
||||
if (seconds < 60) return `${seconds} 秒`
|
||||
const mins = Math.floor(seconds / 60)
|
||||
const secs = seconds % 60
|
||||
return `${mins} 分 ${secs} 秒`
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<View className="build-detail loading-wrap">
|
||||
<AtActivityIndicator size={32} />
|
||||
<Text className="loading-text">加载中...</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const statusInfo = STATUS_MAP[build.status || 'pending']
|
||||
|
||||
return (
|
||||
<View className="build-detail">
|
||||
{/* 构建状态 */}
|
||||
<View className="status-card">
|
||||
<View className="status-header">
|
||||
<Text className="build-no">#{build.buildNo || build.id}</Text>
|
||||
<AtTag type={statusInfo.color.includes('F44336') ? 'error' : statusInfo.color.includes('4CAF50') ? 'success' : 'primary'}>
|
||||
{statusInfo.text}
|
||||
</AtTag>
|
||||
</View>
|
||||
|
||||
<View className="status-info">
|
||||
<View className="info-item">
|
||||
<Text className="label">分支</Text>
|
||||
<Text className="value">{build.branch || 'main'}</Text>
|
||||
</View>
|
||||
<View className="info-item">
|
||||
<Text className="label">提交</Text>
|
||||
<Text className="value commit">{build.commitId?.slice(0, 7)}</Text>
|
||||
</View>
|
||||
<View className="info-item">
|
||||
<Text className="label">触发方式</Text>
|
||||
<Text className="value">{build.trigger || 'manual'}</Text>
|
||||
</View>
|
||||
<View className="info-item">
|
||||
<Text className="label">耗时</Text>
|
||||
<Text className="value">{formatDuration(build.duration)}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className="commit-message">
|
||||
<Text className="iconfont icon-commit" />
|
||||
<Text className="msg">{build.commitMessage}</Text>
|
||||
</View>
|
||||
|
||||
<View className="meta">
|
||||
<Text className="author">{build.author}</Text>
|
||||
<Text className="time">{formatTime(build.startTime)}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<View className="actions">
|
||||
{build.status === 'failed' && (
|
||||
<AtButton type="primary" onClick={handleRetryBuild}>
|
||||
重新构建
|
||||
</AtButton>
|
||||
)}
|
||||
{build.status === 'success' && (
|
||||
<AtButton type="primary" onClick={handleDeploy}>
|
||||
部署
|
||||
</AtButton>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Tab 切换 */}
|
||||
<View className="tabs">
|
||||
<View
|
||||
className={`tab ${activeTab === 'info' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('info')}
|
||||
>
|
||||
构建信息
|
||||
</View>
|
||||
<View
|
||||
className={`tab ${activeTab === 'logs' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('logs')}
|
||||
>
|
||||
构建日志
|
||||
</View>
|
||||
<View
|
||||
className={`tab ${activeTab === 'artifacts' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('artifacts')}
|
||||
>
|
||||
构建产物
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Tab 内容 */}
|
||||
<ScrollView scrollY className="tab-content">
|
||||
{activeTab === 'info' && (
|
||||
<View className="info-panel">
|
||||
<View className="info-section">
|
||||
<Text className="section-title">基本信息</Text>
|
||||
<View className="info-row">
|
||||
<Text className="row-label">构建编号</Text>
|
||||
<Text className="row-value">{build.buildNo}</Text>
|
||||
</View>
|
||||
<View className="info-row">
|
||||
<Text className="row-label">构建 ID</Text>
|
||||
<Text className="row-value">{build.id}</Text>
|
||||
</View>
|
||||
<View className="info-row">
|
||||
<Text className="row-label">项目 ID</Text>
|
||||
<Text className="row-value">{build.websiteId}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className="info-section">
|
||||
<Text className="section-title">Git 信息</Text>
|
||||
<View className="info-row">
|
||||
<Text className="row-label">分支</Text>
|
||||
<Text className="row-value">{build.branch}</Text>
|
||||
</View>
|
||||
<View className="info-row">
|
||||
<Text className="row-label">Commit ID</Text>
|
||||
<Text className="row-value commit">{build.commitId}</Text>
|
||||
</View>
|
||||
<View className="info-row">
|
||||
<Text className="row-label">提交者</Text>
|
||||
<Text className="row-value">{build.author}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className="info-section">
|
||||
<Text className="section-title">时间信息</Text>
|
||||
<View className="info-row">
|
||||
<Text className="row-label">开始时间</Text>
|
||||
<Text className="row-value">{formatTime(build.startTime)}</Text>
|
||||
</View>
|
||||
<View className="info-row">
|
||||
<Text className="row-label">结束时间</Text>
|
||||
<Text className="row-value">{formatTime(build.endTime)}</Text>
|
||||
</View>
|
||||
<View className="info-row">
|
||||
<Text className="row-label">总耗时</Text>
|
||||
<Text className="row-value">{formatDuration(build.duration)}</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{activeTab === 'logs' && (
|
||||
<View className="logs-panel">
|
||||
{logs ? (
|
||||
<View className="logs-content">
|
||||
{logs.split('\n').map((line, index) => (
|
||||
<Text key={index} className="log-line">
|
||||
{line}
|
||||
</Text>
|
||||
))}
|
||||
</View>
|
||||
) : (
|
||||
<View className="loading-logs">
|
||||
<AtActivityIndicator size={24} />
|
||||
<Text>加载日志中...</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{activeTab === 'artifacts' && (
|
||||
<View className="artifacts-panel">
|
||||
{build.artifacts && build.artifacts.length > 0 ? (
|
||||
build.artifacts.map((artifact, index) => (
|
||||
<View className="artifact-item" key={index}>
|
||||
<View className="artifact-info">
|
||||
<Text className="artifact-name">{artifact.name}</Text>
|
||||
<Text className="artifact-size">
|
||||
{(artifact.size || 0) > 1024 * 1024
|
||||
? `${((artifact.size || 0) / 1024 / 1024).toFixed(2)} MB`
|
||||
: `${((artifact.size || 0) / 1024).toFixed(2)} KB`}
|
||||
</Text>
|
||||
</View>
|
||||
{artifact.downloadUrl && (
|
||||
<AtButton
|
||||
size="small"
|
||||
type="secondary"
|
||||
onClick={() => {
|
||||
if (artifact.downloadUrl) {
|
||||
Taro.setClipboardData({ data: artifact.downloadUrl })
|
||||
Taro.showToast({ title: '链接已复制', icon: 'success' })
|
||||
}
|
||||
}}
|
||||
>
|
||||
复制链接
|
||||
</AtButton>
|
||||
)}
|
||||
</View>
|
||||
))
|
||||
) : (
|
||||
<View className="empty-artifacts">
|
||||
<Text className="iconfont icon-empty" />
|
||||
<Text>暂无构建产物</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</ScrollView>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
6
src/developer/app/[id]/builds.config.ts
Normal file
6
src/developer/app/[id]/builds.config.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* 构建列表页面配置
|
||||
*/
|
||||
export default definePageConfig({
|
||||
navigationBarTitleText: '构建历史',
|
||||
})
|
||||
154
src/developer/app/[id]/builds.scss
Normal file
154
src/developer/app/[id]/builds.scss
Normal file
@@ -0,0 +1,154 @@
|
||||
.builds-page {
|
||||
min-height: 100vh;
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
.search-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 24px;
|
||||
gap: 16px;
|
||||
background: #fff;
|
||||
border-bottom: 1px solid #eee;
|
||||
|
||||
.search-input-wrap {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 64px;
|
||||
padding: 0 24px;
|
||||
background: #f5f5f5;
|
||||
border-radius: 32px;
|
||||
|
||||
.iconfont {
|
||||
margin-right: 12px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
flex: 1;
|
||||
font-size: 28px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.build-list {
|
||||
height: calc(100vh - 280px);
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.build-item {
|
||||
background: #fff;
|
||||
border-radius: 16px;
|
||||
padding: 24px;
|
||||
margin-bottom: 24px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
|
||||
|
||||
.build-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
|
||||
.build-no {
|
||||
font-size: 32px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
}
|
||||
|
||||
.build-info {
|
||||
margin-bottom: 16px;
|
||||
|
||||
.build-branch {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
font-size: 26px;
|
||||
color: #666;
|
||||
|
||||
.iconfont {
|
||||
color: #4CAF50;
|
||||
}
|
||||
}
|
||||
|
||||
.build-commit {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 24px;
|
||||
color: #999;
|
||||
|
||||
.iconfont {
|
||||
color: #2196F3;
|
||||
}
|
||||
|
||||
.commit-id {
|
||||
font-family: monospace;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.commit-msg {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.build-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid #eee;
|
||||
|
||||
.build-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
font-size: 24px;
|
||||
color: #999;
|
||||
|
||||
.author {
|
||||
color: #666;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.loading-wrap,
|
||||
.empty-wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 100px 0;
|
||||
|
||||
.iconfont {
|
||||
font-size: 96px;
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
.loading-text,
|
||||
.empty-text {
|
||||
margin-top: 24px;
|
||||
font-size: 28px;
|
||||
color: #999;
|
||||
}
|
||||
}
|
||||
|
||||
.loading-more {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.no-more {
|
||||
text-align: center;
|
||||
padding: 24px;
|
||||
font-size: 24px;
|
||||
color: #999;
|
||||
}
|
||||
269
src/developer/app/[id]/builds.tsx
Normal file
269
src/developer/app/[id]/builds.tsx
Normal file
@@ -0,0 +1,269 @@
|
||||
/**
|
||||
* 构建列表页面
|
||||
*/
|
||||
import { useState, useEffect } from 'react'
|
||||
import { View, Text, ScrollView, Input } from '@tarojs/components'
|
||||
import Taro from '@tarojs/taro'
|
||||
import { AtButton, AtTabs, AtTabsPane, AtTag, AtActivityIndicator } from 'taro-ui'
|
||||
import { pageBuild, triggerBuild, cancelBuild } from '../../../../api/cicd'
|
||||
import type { Build, BuildStatus } from '../../../../types/cicd'
|
||||
import './builds.scss'
|
||||
|
||||
// 状态映射
|
||||
const STATUS_MAP: Record<BuildStatus, { text: string; color: string }> = {
|
||||
pending: { text: '等待中', color: '#FFC107' },
|
||||
running: { text: '构建中', color: '#2196F3' },
|
||||
success: { text: '构建成功', color: '#4CAF50' },
|
||||
failed: { text: '构建失败', color: '#F44336' },
|
||||
cancelled: { text: '已取消', color: '#9E9E9E' },
|
||||
}
|
||||
|
||||
export default function Builds() {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [builds, setBuilds] = useState<Build[]>([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const [page, setPage] = useState(1)
|
||||
const [hasMore, setHasMore] = useState(true)
|
||||
const [status, setStatus] = useState<BuildStatus | ''>('')
|
||||
const [keyword, setKeyword] = useState('')
|
||||
const [appId, setAppId] = useState('')
|
||||
|
||||
// 获取构建列表
|
||||
const fetchBuilds = async (pageNum = 1, reset = false) => {
|
||||
try {
|
||||
const res = await pageBuild({
|
||||
page: pageNum,
|
||||
limit: 20,
|
||||
websiteId: Number(appId),
|
||||
status: status || undefined,
|
||||
})
|
||||
|
||||
if (res.data) {
|
||||
if (reset) {
|
||||
setBuilds(res.data.list || [])
|
||||
} else {
|
||||
setBuilds(prev => [...prev, ...(res.data?.list || [])])
|
||||
}
|
||||
setTotal(res.data.total || 0)
|
||||
setHasMore((res.data.list || []).length === 20)
|
||||
setPage(pageNum)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('获取构建列表失败', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const pages = Taro.getCurrentPages()
|
||||
const current = pages[pages.length - 1]
|
||||
if (current?.options?.appId) {
|
||||
setAppId(current.options.appId)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (appId) {
|
||||
fetchBuilds(1, true)
|
||||
}
|
||||
}, [appId, status])
|
||||
|
||||
// 触发构建
|
||||
const handleTriggerBuild = async () => {
|
||||
if (!appId) return
|
||||
|
||||
Taro.showModal({
|
||||
title: '提示',
|
||||
content: '确定要触发新的构建吗?',
|
||||
success: async (res) => {
|
||||
if (res.confirm) {
|
||||
try {
|
||||
Taro.showLoading({ title: '触发中...' })
|
||||
await triggerBuild({ websiteId: Number(appId) })
|
||||
Taro.showToast({ title: '构建已触发', icon: 'success' })
|
||||
fetchBuilds(1, true)
|
||||
} catch (err) {
|
||||
Taro.showToast({ title: '触发失败', icon: 'none' })
|
||||
} finally {
|
||||
Taro.hideLoading()
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// 取消构建
|
||||
const handleCancelBuild = async (buildId: number) => {
|
||||
Taro.showModal({
|
||||
title: '提示',
|
||||
content: '确定要取消该构建吗?',
|
||||
success: async (res) => {
|
||||
if (res.confirm) {
|
||||
try {
|
||||
Taro.showLoading({ title: '取消中...' })
|
||||
await cancelBuild(buildId)
|
||||
Taro.showToast({ title: '已取消', icon: 'success' })
|
||||
fetchBuilds(page, true)
|
||||
} catch (err) {
|
||||
Taro.showToast({ title: '取消失败', icon: 'none' })
|
||||
} finally {
|
||||
Taro.hideLoading()
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// 查看构建详情
|
||||
const goToBuildDetail = (buildId: number) => {
|
||||
Taro.navigateTo({
|
||||
url: `/developer/app/${appId}/build/${buildId}`,
|
||||
})
|
||||
}
|
||||
|
||||
// 加载更多
|
||||
const handleLoadMore = () => {
|
||||
if (hasMore && !loading) {
|
||||
fetchBuilds(page + 1)
|
||||
}
|
||||
}
|
||||
|
||||
// 格式化时间
|
||||
const formatTime = (time?: string) => {
|
||||
if (!time) return '-'
|
||||
const date = new Date(time)
|
||||
return `${date.getMonth() + 1}/${date.getDate()} ${date.getHours()}:${String(date.getMinutes()).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
// 格式化时长
|
||||
const formatDuration = (seconds?: number) => {
|
||||
if (!seconds) return '-'
|
||||
if (seconds < 60) return `${seconds}s`
|
||||
const mins = Math.floor(seconds / 60)
|
||||
const secs = seconds % 60
|
||||
return `${mins}m ${secs}s`
|
||||
}
|
||||
|
||||
const tabs = [
|
||||
{ title: '全部' },
|
||||
{ title: '等待中' },
|
||||
{ title: '构建中' },
|
||||
{ title: '成功' },
|
||||
{ title: '失败' },
|
||||
]
|
||||
|
||||
return (
|
||||
<View className="builds-page">
|
||||
{/* 搜索栏 */}
|
||||
<View className="search-bar">
|
||||
<View className="search-input-wrap">
|
||||
<Text className="iconfont icon-search" />
|
||||
<Input
|
||||
className="search-input"
|
||||
placeholder="搜索构建号/分支/提交信息"
|
||||
value={keyword}
|
||||
onInput={(e) => setKeyword(e.detail.value)}
|
||||
/>
|
||||
</View>
|
||||
<AtButton size="small" type="primary" onClick={handleTriggerBuild}>
|
||||
触发构建
|
||||
</AtButton>
|
||||
</View>
|
||||
|
||||
{/* 状态筛选 */}
|
||||
<AtTabs
|
||||
current={tabs.findIndex(t => {
|
||||
if (status === '') return t.title === '全部'
|
||||
return STATUS_MAP[status]?.text === t.title
|
||||
})}
|
||||
tabList={tabs}
|
||||
scroll
|
||||
onClick={(index) => {
|
||||
const statusMap: (BuildStatus | '')[] = ['', 'pending', 'running', 'success', 'failed']
|
||||
setStatus(statusMap[index])
|
||||
}}
|
||||
>
|
||||
<AtTabsPane current={0} index={0}>
|
||||
<ScrollView
|
||||
scrollY
|
||||
className="build-list"
|
||||
onScrollToLower={handleLoadMore}
|
||||
>
|
||||
{loading && builds.length === 0 ? (
|
||||
<View className="loading-wrap">
|
||||
<AtActivityIndicator size={32} />
|
||||
<Text className="loading-text">加载中...</Text>
|
||||
</View>
|
||||
) : builds.length === 0 ? (
|
||||
<View className="empty-wrap">
|
||||
<Text className="iconfont icon-empty" />
|
||||
<Text className="empty-text">暂无构建记录</Text>
|
||||
</View>
|
||||
) : (
|
||||
builds.map((build) => (
|
||||
<View
|
||||
className="build-item"
|
||||
key={build.id}
|
||||
onClick={() => build.id && goToBuildDetail(build.id)}
|
||||
>
|
||||
<View className="build-header">
|
||||
<Text className="build-no">#{build.buildNo || build.id}</Text>
|
||||
<AtTag
|
||||
type={STATUS_MAP[build.status || 'pending'].color.includes('F44336') ? 'error' : 'primary'}
|
||||
size="small"
|
||||
>
|
||||
{STATUS_MAP[build.status || 'pending'].text}
|
||||
</AtTag>
|
||||
</View>
|
||||
|
||||
<View className="build-info">
|
||||
<View className="build-branch">
|
||||
<Text className="iconfont icon-git-branch" />
|
||||
<Text>{build.branch || 'main'}</Text>
|
||||
</View>
|
||||
<View className="build-commit">
|
||||
<Text className="iconfont icon-commit" />
|
||||
<Text className="commit-id">{build.commitId?.slice(0, 7)}</Text>
|
||||
<Text className="commit-msg">{build.commitMessage}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className="build-footer">
|
||||
<View className="build-meta">
|
||||
<Text className="author">{build.author}</Text>
|
||||
<Text className="time">{formatTime(build.createTime)}</Text>
|
||||
<Text className="duration">{formatDuration(build.duration)}</Text>
|
||||
</View>
|
||||
{build.status === 'pending' || build.status === 'running' ? (
|
||||
<AtButton
|
||||
size="small"
|
||||
type="secondary"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
build.id && handleCancelBuild(build.id)
|
||||
}}
|
||||
>
|
||||
取消
|
||||
</AtButton>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
))
|
||||
)}
|
||||
|
||||
{loading && builds.length > 0 && (
|
||||
<View className="loading-more">
|
||||
<AtActivityIndicator size={24} />
|
||||
</View>
|
||||
)}
|
||||
|
||||
{!hasMore && builds.length > 0 && (
|
||||
<View className="no-more">没有更多了</View>
|
||||
)}
|
||||
</ScrollView>
|
||||
</AtTabsPane>
|
||||
</AtTabs>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
3
src/developer/app/[id]/config.config.ts
Normal file
3
src/developer/app/[id]/config.config.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export default definePageConfig({
|
||||
navigationBarTitleText: '应用配置',
|
||||
})
|
||||
10
src/developer/app/[id]/config.scss
Normal file
10
src/developer/app/[id]/config.scss
Normal file
@@ -0,0 +1,10 @@
|
||||
.config-page {
|
||||
padding: 32rpx;
|
||||
|
||||
&__content {
|
||||
text-align: center;
|
||||
padding: 120rpx 0;
|
||||
font-size: 28rpx;
|
||||
color: #999;
|
||||
}
|
||||
}
|
||||
15
src/developer/app/[id]/config.tsx
Normal file
15
src/developer/app/[id]/config.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import React from 'react'
|
||||
import { View, Text } from '@tarojs/components'
|
||||
import './config.scss'
|
||||
|
||||
const ConfigPage: React.FC = () => {
|
||||
return (
|
||||
<View className="config-page">
|
||||
<View className="config-page__content">
|
||||
<Text>应用配置功能开发中...</Text>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export default ConfigPage
|
||||
6
src/developer/app/[id]/deploy/[id].config.ts
Normal file
6
src/developer/app/[id]/deploy/[id].config.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* 部署详情页面配置
|
||||
*/
|
||||
export default definePageConfig({
|
||||
navigationBarTitleText: '部署详情',
|
||||
})
|
||||
237
src/developer/app/[id]/deploy/[id].scss
Normal file
237
src/developer/app/[id]/deploy/[id].scss
Normal file
@@ -0,0 +1,237 @@
|
||||
.deploy-detail {
|
||||
min-height: 100vh;
|
||||
background: #f5f5f5;
|
||||
padding: 24px;
|
||||
|
||||
&.loading-wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding-top: 300px;
|
||||
|
||||
.loading-text {
|
||||
margin-top: 16px;
|
||||
font-size: 28px;
|
||||
color: #999;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.status-card {
|
||||
background: #fff;
|
||||
border-radius: 16px;
|
||||
padding: 24px;
|
||||
margin-bottom: 24px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
|
||||
|
||||
.status-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 32px;
|
||||
|
||||
.version-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
|
||||
.version {
|
||||
font-size: 40px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.status-progress {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 32px;
|
||||
|
||||
.progress-step {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
|
||||
.step-dot {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
background: #e0e0e0;
|
||||
margin-bottom: 8px;
|
||||
|
||||
&.active {
|
||||
background: #4CAF50;
|
||||
}
|
||||
}
|
||||
|
||||
.step-label {
|
||||
font-size: 24px;
|
||||
color: #999;
|
||||
}
|
||||
}
|
||||
|
||||
.progress-line {
|
||||
width: 100px;
|
||||
height: 4px;
|
||||
background: #e0e0e0;
|
||||
margin: 0 16px;
|
||||
margin-bottom: 32px;
|
||||
|
||||
&.active {
|
||||
background: #4CAF50;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.deploy-meta {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 16px;
|
||||
|
||||
.meta-item {
|
||||
.label {
|
||||
display: block;
|
||||
font-size: 24px;
|
||||
color: #999;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.value {
|
||||
font-size: 28px;
|
||||
color: #333;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.rollback-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 24px;
|
||||
padding: 16px;
|
||||
background: #FFF3E0;
|
||||
border-radius: 8px;
|
||||
font-size: 26px;
|
||||
color: #FF9800;
|
||||
|
||||
.iconfont {
|
||||
font-size: 28px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
|
||||
.at-button {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
background: #fff;
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 24px;
|
||||
|
||||
.tab {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
padding: 24px;
|
||||
font-size: 28px;
|
||||
color: #666;
|
||||
position: relative;
|
||||
|
||||
&.active {
|
||||
color: #1890ff;
|
||||
font-weight: 500;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 60px;
|
||||
height: 4px;
|
||||
background: #1890ff;
|
||||
border-radius: 2px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
height: calc(100vh - 600px);
|
||||
}
|
||||
|
||||
.info-panel {
|
||||
.info-section {
|
||||
background: #fff;
|
||||
border-radius: 16px;
|
||||
padding: 24px;
|
||||
margin-bottom: 24px;
|
||||
|
||||
.section-title {
|
||||
display: block;
|
||||
font-size: 30px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
margin-bottom: 20px;
|
||||
padding-bottom: 16px;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
|
||||
.info-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 0;
|
||||
|
||||
.row-label {
|
||||
font-size: 26px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.row-value {
|
||||
font-size: 26px;
|
||||
color: #333;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.logs-panel {
|
||||
background: #1e1e1e;
|
||||
border-radius: 16px;
|
||||
padding: 24px;
|
||||
min-height: 500px;
|
||||
|
||||
.logs-content {
|
||||
.log-line {
|
||||
display: block;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 22px;
|
||||
color: #d4d4d4;
|
||||
line-height: 1.8;
|
||||
word-break: break-all;
|
||||
}
|
||||
}
|
||||
|
||||
.loading-logs {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 300px;
|
||||
color: #d4d4d4;
|
||||
font-size: 26px;
|
||||
gap: 16px;
|
||||
}
|
||||
}
|
||||
304
src/developer/app/[id]/deploy/[id].tsx
Normal file
304
src/developer/app/[id]/deploy/[id].tsx
Normal file
@@ -0,0 +1,304 @@
|
||||
/**
|
||||
* 部署详情页面
|
||||
*/
|
||||
import { useState, useEffect } from 'react'
|
||||
import { View, Text, ScrollView } from '@tarojs/components'
|
||||
import Taro from '@tarojs/taro'
|
||||
import { AtButton, AtTag, AtActivityIndicator } from 'taro-ui'
|
||||
import { getDeploy, getDeployLogs, rollbackDeploy } from '../../../../../api/cicd'
|
||||
import type { Deploy, DeployStatus, DeployEnv } from '../../../../../types/cicd'
|
||||
import './deploy-detail.scss'
|
||||
|
||||
// 状态映射
|
||||
const STATUS_MAP: Record<DeployStatus, { text: string; color: string }> = {
|
||||
pending: { text: '等待中', color: '#FFC107' },
|
||||
deploying: { text: '部署中', color: '#2196F3' },
|
||||
success: { text: '部署成功', color: '#4CAF50' },
|
||||
failed: { text: '部署失败', color: '#F44336' },
|
||||
rollback: { text: '回滚中', color: '#FF9800' },
|
||||
}
|
||||
|
||||
// 环境映射
|
||||
const ENV_MAP: Record<DeployEnv, { text: string; color: string }> = {
|
||||
development: { text: '开发环境', color: '#9E9E9E' },
|
||||
staging: { text: '预发布环境', color: '#FF9800' },
|
||||
production: { text: '生产环境', color: '#4CAF50' },
|
||||
}
|
||||
|
||||
export default function DeployDetail() {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [deploy, setDeploy] = useState<Deploy>({})
|
||||
const [logs, setLogs] = useState('')
|
||||
const [activeTab, setActiveTab] = useState('info')
|
||||
const [deployId, setDeployId] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
const pages = Taro.getCurrentPages()
|
||||
const current = pages[pages.length - 1]
|
||||
if (current?.options?.id) {
|
||||
setDeployId(current.options.id)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (deployId) {
|
||||
fetchDeployDetail()
|
||||
}
|
||||
}, [deployId])
|
||||
|
||||
// 获取部署详情
|
||||
const fetchDeployDetail = async () => {
|
||||
try {
|
||||
const res = await getDeploy(Number(deployId))
|
||||
if (res.data) {
|
||||
setDeploy(res.data)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('获取部署详情失败', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 获取部署日志
|
||||
const fetchDeployLogs = async () => {
|
||||
try {
|
||||
const res = await getDeployLogs(Number(deployId))
|
||||
if (res.data) {
|
||||
setLogs(res.data.logs || '')
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('获取部署日志失败', err)
|
||||
}
|
||||
}
|
||||
|
||||
// 切换 Tab
|
||||
useEffect(() => {
|
||||
if (activeTab === 'logs' && !logs) {
|
||||
fetchDeployLogs()
|
||||
}
|
||||
}, [activeTab])
|
||||
|
||||
// 回滚部署
|
||||
const handleRollback = async () => {
|
||||
Taro.showModal({
|
||||
title: '确认回滚',
|
||||
content: `确定要回滚到版本 ${deploy.previousVersion || '上一个版本'} 吗?`,
|
||||
success: async (res) => {
|
||||
if (res.confirm) {
|
||||
try {
|
||||
Taro.showLoading({ title: '回滚中...' })
|
||||
await rollbackDeploy(Number(deployId))
|
||||
Taro.showToast({ title: '回滚成功', icon: 'success' })
|
||||
fetchDeployDetail()
|
||||
} catch (err) {
|
||||
Taro.showToast({ title: '回滚失败', icon: 'none' })
|
||||
} finally {
|
||||
Taro.hideLoading()
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// 格式化时间
|
||||
const formatTime = (time?: string) => {
|
||||
if (!time) return '-'
|
||||
const date = new Date(time)
|
||||
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')} ${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}:${String(date.getSeconds()).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
// 格式化时长
|
||||
const formatDuration = (seconds?: number) => {
|
||||
if (!seconds) return '-'
|
||||
if (seconds < 60) return `${seconds} 秒`
|
||||
const mins = Math.floor(seconds / 60)
|
||||
const secs = seconds % 60
|
||||
return `${mins} 分 ${secs} 秒`
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<View className="deploy-detail loading-wrap">
|
||||
<AtActivityIndicator size={32} />
|
||||
<Text className="loading-text">加载中...</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const statusInfo = STATUS_MAP[deploy.status || 'pending']
|
||||
const envInfo = ENV_MAP[deploy.env || 'staging']
|
||||
|
||||
return (
|
||||
<View className="deploy-detail">
|
||||
{/* 部署状态卡片 */}
|
||||
<View className="status-card">
|
||||
<View className="status-header">
|
||||
<View className="version-info">
|
||||
<Text className="version">v{deploy.version}</Text>
|
||||
<AtTag type={envInfo.color.includes('4CAF50') ? 'success' : 'primary'}>
|
||||
{envInfo.text}
|
||||
</AtTag>
|
||||
</View>
|
||||
<AtTag
|
||||
type={statusInfo.color.includes('F44336') ? 'error' : statusInfo.color.includes('4CAF50') ? 'success' : 'primary'}
|
||||
>
|
||||
{statusInfo.text}
|
||||
</AtTag>
|
||||
</View>
|
||||
|
||||
<View className="status-progress">
|
||||
<View className="progress-step">
|
||||
<View className={`step-dot ${deploy.status !== 'pending' ? 'active' : ''}`} />
|
||||
<Text className="step-label">等待</Text>
|
||||
</View>
|
||||
<View className={`progress-line ${deploy.status === 'deploying' || deploy.status === 'success' ? 'active' : ''}`} />
|
||||
<View className="progress-step">
|
||||
<View className={`step-dot ${deploy.status === 'deploying' || deploy.status === 'success' ? 'active' : ''}`} />
|
||||
<Text className="step-label">部署</Text>
|
||||
</View>
|
||||
<View className={`progress-line ${deploy.status === 'success' ? 'active' : ''}`} />
|
||||
<View className="progress-step">
|
||||
<View className={`step-dot ${deploy.status === 'success' ? 'active' : ''}`} />
|
||||
<Text className="step-label">完成</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className="deploy-meta">
|
||||
<View className="meta-item">
|
||||
<Text className="label">构建版本</Text>
|
||||
<Text className="value">#{deploy.buildNo}</Text>
|
||||
</View>
|
||||
<View className="meta-item">
|
||||
<Text className="label">部署人</Text>
|
||||
<Text className="value">{deploy.deployer}</Text>
|
||||
</View>
|
||||
<View className="meta-item">
|
||||
<Text className="label">开始时间</Text>
|
||||
<Text className="value">{formatTime(deploy.startTime)}</Text>
|
||||
</View>
|
||||
<View className="meta-item">
|
||||
<Text className="label">总耗时</Text>
|
||||
<Text className="value">{formatDuration(deploy.duration)}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{deploy.previousVersion && (
|
||||
<View className="rollback-info">
|
||||
<Text className="iconfont icon-rollback" />
|
||||
<Text>从 v{deploy.previousVersion} 回滚</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<View className="actions">
|
||||
{deploy.status === 'success' && (
|
||||
<AtButton type="primary" onClick={handleRollback}>
|
||||
回滚到此版本
|
||||
</AtButton>
|
||||
)}
|
||||
<AtButton
|
||||
type="secondary"
|
||||
onClick={() => {
|
||||
Taro.navigateBack()
|
||||
}}
|
||||
>
|
||||
返回列表
|
||||
</AtButton>
|
||||
</View>
|
||||
|
||||
{/* Tab 切换 */}
|
||||
<View className="tabs">
|
||||
<View
|
||||
className={`tab ${activeTab === 'info' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('info')}
|
||||
>
|
||||
部署信息
|
||||
</View>
|
||||
<View
|
||||
className={`tab ${activeTab === 'logs' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('logs')}
|
||||
>
|
||||
部署日志
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Tab 内容 */}
|
||||
<ScrollView scrollY className="tab-content">
|
||||
{activeTab === 'info' && (
|
||||
<View className="info-panel">
|
||||
<View className="info-section">
|
||||
<Text className="section-title">基本信息</Text>
|
||||
<View className="info-row">
|
||||
<Text className="row-label">部署 ID</Text>
|
||||
<Text className="row-value">{deploy.id}</Text>
|
||||
</View>
|
||||
<View className="info-row">
|
||||
<Text className="row-label">项目 ID</Text>
|
||||
<Text className="row-value">{deploy.websiteId}</Text>
|
||||
</View>
|
||||
<View className="info-row">
|
||||
<Text className="row-label">构建 ID</Text>
|
||||
<Text className="row-value">{deploy.buildId}</Text>
|
||||
</View>
|
||||
<View className="info-row">
|
||||
<Text className="row-label">版本号</Text>
|
||||
<Text className="row-value">v{deploy.version}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className="info-section">
|
||||
<Text className="section-title">时间信息</Text>
|
||||
<View className="info-row">
|
||||
<Text className="row-label">开始时间</Text>
|
||||
<Text className="row-value">{formatTime(deploy.startTime)}</Text>
|
||||
</View>
|
||||
<View className="info-row">
|
||||
<Text className="row-label">结束时间</Text>
|
||||
<Text className="row-value">{formatTime(deploy.endTime)}</Text>
|
||||
</View>
|
||||
<View className="info-row">
|
||||
<Text className="row-label">总耗时</Text>
|
||||
<Text className="row-value">{formatDuration(deploy.duration)}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{deploy.previousVersion && (
|
||||
<View className="info-section">
|
||||
<Text className="section-title">回滚信息</Text>
|
||||
<View className="info-row">
|
||||
<Text className="row-label">回滚源版本</Text>
|
||||
<Text className="row-value">v{deploy.previousVersion}</Text>
|
||||
</View>
|
||||
<View className="info-row">
|
||||
<Text className="row-label">回滚至版本</Text>
|
||||
<Text className="row-value">v{deploy.version}</Text>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{activeTab === 'logs' && (
|
||||
<View className="logs-panel">
|
||||
{logs ? (
|
||||
<View className="logs-content">
|
||||
{logs.split('\n').map((line, index) => (
|
||||
<Text key={index} className="log-line">
|
||||
{line}
|
||||
</Text>
|
||||
))}
|
||||
</View>
|
||||
) : (
|
||||
<View className="loading-logs">
|
||||
<AtActivityIndicator size={24} />
|
||||
<Text>加载日志中...</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</ScrollView>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
6
src/developer/app/[id]/deploys.config.ts
Normal file
6
src/developer/app/[id]/deploys.config.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* 部署列表页面配置
|
||||
*/
|
||||
export default definePageConfig({
|
||||
navigationBarTitleText: '部署历史',
|
||||
})
|
||||
229
src/developer/app/[id]/deploys.scss
Normal file
229
src/developer/app/[id]/deploys.scss
Normal file
@@ -0,0 +1,229 @@
|
||||
.deploys-page {
|
||||
min-height: 100vh;
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
.stats-bar {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
padding: 24px;
|
||||
background: #fff;
|
||||
border-bottom: 1px solid #eee;
|
||||
|
||||
.stat-item {
|
||||
text-align: center;
|
||||
|
||||
.stat-num {
|
||||
display: block;
|
||||
font-size: 40px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
|
||||
&.success {
|
||||
color: #4CAF50;
|
||||
}
|
||||
|
||||
&.failed {
|
||||
color: #F44336;
|
||||
}
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 24px;
|
||||
color: #999;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.deploy-list {
|
||||
height: calc(100vh - 300px);
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.deploy-item {
|
||||
background: #fff;
|
||||
border-radius: 16px;
|
||||
padding: 24px;
|
||||
margin-bottom: 24px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
|
||||
|
||||
.deploy-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
|
||||
.deploy-version {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
|
||||
.version {
|
||||
font-size: 32px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.deploy-info {
|
||||
margin-bottom: 16px;
|
||||
|
||||
.deploy-build,
|
||||
.rollback-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 26px;
|
||||
color: #666;
|
||||
margin-bottom: 8px;
|
||||
|
||||
.iconfont {
|
||||
color: #2196F3;
|
||||
}
|
||||
}
|
||||
|
||||
.rollback-info .iconfont {
|
||||
color: #FF9800;
|
||||
}
|
||||
}
|
||||
|
||||
.deploy-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid #eee;
|
||||
|
||||
.deploy-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
font-size: 24px;
|
||||
color: #999;
|
||||
|
||||
.deployer {
|
||||
color: #666;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.loading-wrap,
|
||||
.empty-wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 100px 0;
|
||||
|
||||
.iconfont {
|
||||
font-size: 96px;
|
||||
color: #ccc;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.loading-text,
|
||||
.empty-text {
|
||||
font-size: 28px;
|
||||
color: #999;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
.loading-more {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.no-more {
|
||||
text-align: center;
|
||||
padding: 24px;
|
||||
font-size: 24px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
// 部署弹窗样式
|
||||
.deploy-modal-content {
|
||||
.select-label {
|
||||
display: block;
|
||||
font-size: 28px;
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.env-select {
|
||||
margin-bottom: 24px;
|
||||
|
||||
.env-options {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
|
||||
.env-option {
|
||||
flex: 1;
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
background: #f5f5f5;
|
||||
border-radius: 8px;
|
||||
font-size: 26px;
|
||||
color: #666;
|
||||
|
||||
&.active {
|
||||
background: #1890ff;
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.build-select {
|
||||
.build-list {
|
||||
max-height: 400px;
|
||||
}
|
||||
|
||||
.build-option {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px;
|
||||
background: #f5f5f5;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 12px;
|
||||
|
||||
&.active {
|
||||
background: rgba(24, 144, 255, 0.1);
|
||||
border: 1px solid #1890ff;
|
||||
}
|
||||
|
||||
.build-info {
|
||||
flex: 1;
|
||||
|
||||
.build-no {
|
||||
font-size: 28px;
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
.build-branch {
|
||||
font-size: 24px;
|
||||
color: #4CAF50;
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
.build-commit {
|
||||
font-size: 24px;
|
||||
color: #2196F3;
|
||||
font-family: monospace;
|
||||
}
|
||||
}
|
||||
|
||||
.build-time {
|
||||
font-size: 24px;
|
||||
color: #999;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
371
src/developer/app/[id]/deploys.tsx
Normal file
371
src/developer/app/[id]/deploys.tsx
Normal file
@@ -0,0 +1,371 @@
|
||||
/**
|
||||
* 部署列表页面
|
||||
*/
|
||||
import { useState, useEffect } from 'react'
|
||||
import { View, Text, ScrollView } from '@tarojs/components'
|
||||
import Taro from '@tarojs/taro'
|
||||
import { AtButton, AtTabs, AtTabsPane, AtTag, AtActivityIndicator, AtModal } from 'taro-ui'
|
||||
import { pageDeploy, triggerDeploy, rollbackDeploy } from '../../../../api/cicd'
|
||||
import { pageBuild } from '../../../../api/cicd'
|
||||
import type { Deploy, DeployStatus, DeployEnv, Build } from '../../../../types/cicd'
|
||||
import './deploys.scss'
|
||||
|
||||
// 状态映射
|
||||
const STATUS_MAP: Record<DeployStatus, { text: string; color: string }> = {
|
||||
pending: { text: '等待中', color: '#FFC107' },
|
||||
deploying: { text: '部署中', color: '#2196F3' },
|
||||
success: { text: '部署成功', color: '#4CAF50' },
|
||||
failed: { text: '部署失败', color: '#F44336' },
|
||||
rollback: { text: '回滚中', color: '#FF9800' },
|
||||
}
|
||||
|
||||
// 环境映射
|
||||
const ENV_MAP: Record<DeployEnv, { text: string; color: string }> = {
|
||||
development: { text: '开发', color: '#9E9E9E' },
|
||||
staging: { text: '预发布', color: '#FF9800' },
|
||||
production: { text: '生产', color: '#4CAF50' },
|
||||
}
|
||||
|
||||
export default function Deploys() {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [deploys, setDeploys] = useState<Deploy[]>([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const [page, setPage] = useState(1)
|
||||
const [hasMore, setHasMore] = useState(true)
|
||||
const [env, setEnv] = useState<DeployEnv | ''>('')
|
||||
const [appId, setAppId] = useState('')
|
||||
const [buildId, setBuildId] = useState('')
|
||||
const [showDeployModal, setShowDeployModal] = useState(false)
|
||||
const [selectBuild, setSelectBuild] = useState<Build | null>(null)
|
||||
const [selectEnv, setSelectEnv] = useState<DeployEnv>('staging')
|
||||
const [builds, setBuilds] = useState<Build[]>([])
|
||||
|
||||
// 获取部署列表
|
||||
const fetchDeploys = async (pageNum = 1, reset = false) => {
|
||||
try {
|
||||
const res = await pageDeploy({
|
||||
page: pageNum,
|
||||
limit: 20,
|
||||
websiteId: Number(appId),
|
||||
env: env || undefined,
|
||||
})
|
||||
|
||||
if (res.data) {
|
||||
if (reset) {
|
||||
setDeploys(res.data.list || [])
|
||||
} else {
|
||||
setDeploys(prev => [...prev, ...(res.data?.list || [])])
|
||||
}
|
||||
setTotal(res.data.total || 0)
|
||||
setHasMore((res.data.list || []).length === 20)
|
||||
setPage(pageNum)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('获取部署列表失败', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 获取成功构建列表(用于部署选择)
|
||||
const fetchBuilds = async () => {
|
||||
try {
|
||||
const res = await pageBuild({
|
||||
page: 1,
|
||||
limit: 20,
|
||||
websiteId: Number(appId),
|
||||
status: 'success',
|
||||
})
|
||||
if (res.data) {
|
||||
setBuilds(res.data.list || [])
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('获取构建列表失败', err)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const pages = Taro.getCurrentPages()
|
||||
const current = pages[pages.length - 1]
|
||||
if (current?.options?.appId) {
|
||||
setAppId(current.options.appId)
|
||||
}
|
||||
if (current?.options?.buildId) {
|
||||
setBuildId(current.options.buildId)
|
||||
// 如果有 buildId,自动打开部署弹窗
|
||||
fetchBuilds().then(() => {
|
||||
if (current.options?.buildId && builds.length > 0) {
|
||||
const build = builds.find(b => b.id === Number(current.options.buildId))
|
||||
if (build) {
|
||||
setSelectBuild(build)
|
||||
setShowDeployModal(true)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (appId) {
|
||||
fetchDeploys(1, true)
|
||||
}
|
||||
}, [appId, env])
|
||||
|
||||
// 触发部署
|
||||
const handleTriggerDeploy = async () => {
|
||||
if (!selectBuild || !appId) return
|
||||
|
||||
try {
|
||||
Taro.showLoading({ title: '部署中...' })
|
||||
await triggerDeploy({
|
||||
websiteId: Number(appId),
|
||||
buildId: selectBuild.id!,
|
||||
env: selectEnv,
|
||||
})
|
||||
Taro.showToast({ title: '部署已触发', icon: 'success' })
|
||||
setShowDeployModal(false)
|
||||
fetchDeploys(1, true)
|
||||
} catch (err) {
|
||||
Taro.showToast({ title: '部署失败', icon: 'none' })
|
||||
} finally {
|
||||
Taro.hideLoading()
|
||||
}
|
||||
}
|
||||
|
||||
// 回滚部署
|
||||
const handleRollback = async (deployId: number) => {
|
||||
Taro.showModal({
|
||||
title: '确认回滚',
|
||||
content: '确定要回滚到上一个版本吗?',
|
||||
success: async (res) => {
|
||||
if (res.confirm) {
|
||||
try {
|
||||
Taro.showLoading({ title: '回滚中...' })
|
||||
await rollbackDeploy(deployId)
|
||||
Taro.showToast({ title: '回滚成功', icon: 'success' })
|
||||
fetchDeploys(page, true)
|
||||
} catch (err) {
|
||||
Taro.showToast({ title: '回滚失败', icon: 'none' })
|
||||
} finally {
|
||||
Taro.hideLoading()
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// 查看部署详情
|
||||
const goToDeployDetail = (deployId: number) => {
|
||||
Taro.navigateTo({
|
||||
url: `/developer/app/${appId}/deploy/${deployId}`,
|
||||
})
|
||||
}
|
||||
|
||||
// 加载更多
|
||||
const handleLoadMore = () => {
|
||||
if (hasMore && !loading) {
|
||||
fetchDeploys(page + 1)
|
||||
}
|
||||
}
|
||||
|
||||
// 格式化时间
|
||||
const formatTime = (time?: string) => {
|
||||
if (!time) return '-'
|
||||
const date = new Date(time)
|
||||
return `${date.getMonth() + 1}/${date.getDate()} ${date.getHours()}:${String(date.getMinutes()).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
// 格式化时长
|
||||
const formatDuration = (seconds?: number) => {
|
||||
if (!seconds) return '-'
|
||||
if (seconds < 60) return `${seconds}s`
|
||||
const mins = Math.floor(seconds / 60)
|
||||
const secs = seconds % 60
|
||||
return `${mins}m ${secs}s`
|
||||
}
|
||||
|
||||
const tabs = [
|
||||
{ title: '全部' },
|
||||
{ title: '开发' },
|
||||
{ title: '预发布' },
|
||||
{ title: '生产' },
|
||||
]
|
||||
|
||||
const envList: (DeployEnv | '')[] = ['', 'development', 'staging', 'production']
|
||||
|
||||
return (
|
||||
<View className="deploys-page">
|
||||
{/* 顶部统计 */}
|
||||
<View className="stats-bar">
|
||||
<View className="stat-item">
|
||||
<Text className="stat-num">{total}</Text>
|
||||
<Text className="stat-label">总部署</Text>
|
||||
</View>
|
||||
<View className="stat-item">
|
||||
<Text className="stat-num success">{deploys.filter(d => d.status === 'success').length}</Text>
|
||||
<Text className="stat-label">成功</Text>
|
||||
</View>
|
||||
<View className="stat-item">
|
||||
<Text className="stat-num failed">{deploys.filter(d => d.status === 'failed').length}</Text>
|
||||
<Text className="stat-label">失败</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 环境筛选 */}
|
||||
<AtTabs
|
||||
current={envList.indexOf(env)}
|
||||
tabList={tabs}
|
||||
scroll
|
||||
onClick={(index) => setEnv(envList[index])}
|
||||
>
|
||||
<AtTabsPane current={0} index={0}>
|
||||
<ScrollView
|
||||
scrollY
|
||||
className="deploy-list"
|
||||
onScrollToLower={handleLoadMore}
|
||||
>
|
||||
{loading && deploys.length === 0 ? (
|
||||
<View className="loading-wrap">
|
||||
<AtActivityIndicator size={32} />
|
||||
<Text className="loading-text">加载中...</Text>
|
||||
</View>
|
||||
) : deploys.length === 0 ? (
|
||||
<View className="empty-wrap">
|
||||
<Text className="iconfont icon-empty" />
|
||||
<Text className="empty-text">暂无部署记录</Text>
|
||||
<AtButton size="small" type="primary" onClick={() => {
|
||||
fetchBuilds()
|
||||
setShowDeployModal(true)
|
||||
}}>
|
||||
发起部署
|
||||
</AtButton>
|
||||
</View>
|
||||
) : (
|
||||
deploys.map((deploy) => (
|
||||
<View
|
||||
className="deploy-item"
|
||||
key={deploy.id}
|
||||
onClick={() => deploy.id && goToDeployDetail(deploy.id)}
|
||||
>
|
||||
<View className="deploy-header">
|
||||
<View className="deploy-version">
|
||||
<Text className="version">v{deploy.version}</Text>
|
||||
<AtTag
|
||||
size="small"
|
||||
type={ENV_MAP[deploy.env || 'staging'].color.includes('4CAF50') ? 'success' : 'primary'}
|
||||
>
|
||||
{ENV_MAP[deploy.env || 'staging'].text}
|
||||
</AtTag>
|
||||
</View>
|
||||
<AtTag
|
||||
type={STATUS_MAP[deploy.status || 'pending'].color.includes('F44336') ? 'error' : STATUS_MAP[deploy.status || 'pending'].color.includes('4CAF50') ? 'success' : 'primary'}
|
||||
size="small"
|
||||
>
|
||||
{STATUS_MAP[deploy.status || 'pending'].text}
|
||||
</AtTag>
|
||||
</View>
|
||||
|
||||
<View className="deploy-info">
|
||||
<View className="deploy-build">
|
||||
<Text className="iconfont icon-build" />
|
||||
<Text>#{deploy.buildNo}</Text>
|
||||
</View>
|
||||
{deploy.previousVersion && (
|
||||
<View className="rollback-info">
|
||||
<Text className="iconfont icon-rollback" />
|
||||
<Text>从 v{deploy.previousVersion} 回滚</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View className="deploy-footer">
|
||||
<View className="deploy-meta">
|
||||
<Text className="deployer">{deploy.deployer}</Text>
|
||||
<Text className="time">{formatTime(deploy.startTime)}</Text>
|
||||
<Text className="duration">{formatDuration(deploy.duration)}</Text>
|
||||
</View>
|
||||
{deploy.status === 'success' && (
|
||||
<AtButton
|
||||
size="small"
|
||||
type="secondary"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
deploy.id && handleRollback(deploy.id)
|
||||
}}
|
||||
>
|
||||
回滚
|
||||
</AtButton>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
))
|
||||
)}
|
||||
|
||||
{loading && deploys.length > 0 && (
|
||||
<View className="loading-more">
|
||||
<AtActivityIndicator size={24} />
|
||||
</View>
|
||||
)}
|
||||
|
||||
{!hasMore && deploys.length > 0 && (
|
||||
<View className="no-more">没有更多了</View>
|
||||
)}
|
||||
</ScrollView>
|
||||
</AtTabsPane>
|
||||
</AtTabs>
|
||||
|
||||
{/* 部署弹窗 */}
|
||||
<AtModal
|
||||
isOpened={showDeployModal}
|
||||
title="选择构建版本"
|
||||
cancelText="取消"
|
||||
confirmText="部署"
|
||||
onClose={() => setShowDeployModal(false)}
|
||||
onCancel={() => setShowDeployModal(false)}
|
||||
onConfirm={handleTriggerDeploy}
|
||||
content={
|
||||
<View className="deploy-modal-content">
|
||||
{/* 环境选择 */}
|
||||
<View className="env-select">
|
||||
<Text className="select-label">部署环境</Text>
|
||||
<View className="env-options">
|
||||
{(['development', 'staging', 'production'] as DeployEnv[]).map((e) => (
|
||||
<View
|
||||
key={e}
|
||||
className={`env-option ${selectEnv === e ? 'active' : ''}`}
|
||||
onClick={() => setSelectEnv(e)}
|
||||
>
|
||||
{ENV_MAP[e].text}
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 构建选择 */}
|
||||
<View className="build-select">
|
||||
<Text className="select-label">选择构建版本</Text>
|
||||
<ScrollView scrollY className="build-list">
|
||||
{builds.map((build) => (
|
||||
<View
|
||||
key={build.id}
|
||||
className={`build-option ${selectBuild?.id === build.id ? 'active' : ''}`}
|
||||
onClick={() => setSelectBuild(build)}
|
||||
>
|
||||
<View className="build-info">
|
||||
<Text className="build-no">#{build.buildNo}</Text>
|
||||
<Text className="build-branch">{build.branch}</Text>
|
||||
<Text className="build-commit">{build.commitId?.slice(0, 7)}</Text>
|
||||
</View>
|
||||
<View className="build-time">
|
||||
{formatTime(build.endTime)}
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
</ScrollView>
|
||||
</View>
|
||||
</View>
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
3
src/developer/app/[id]/index.config.ts
Normal file
3
src/developer/app/[id]/index.config.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export default definePageConfig({
|
||||
navigationBarTitleText: '应用详情',
|
||||
})
|
||||
28
src/developer/app/[id]/index.scss
Normal file
28
src/developer/app/[id]/index.scss
Normal file
@@ -0,0 +1,28 @@
|
||||
page {
|
||||
background: #f5f6f7;
|
||||
}
|
||||
|
||||
.app-detail-page {
|
||||
min-height: 100vh;
|
||||
background: #f5f6f7;
|
||||
padding: 24rpx;
|
||||
|
||||
&__header {
|
||||
margin-bottom: 24rpx;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-size: 36rpx;
|
||||
font-weight: 800;
|
||||
color: #111111;
|
||||
}
|
||||
|
||||
&__content {
|
||||
background: #ffffff;
|
||||
border-radius: 20rpx;
|
||||
padding: 48rpx;
|
||||
text-align: center;
|
||||
color: #666666;
|
||||
font-size: 28rpx;
|
||||
}
|
||||
}
|
||||
22
src/developer/app/[id]/index.tsx
Normal file
22
src/developer/app/[id]/index.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import React from 'react'
|
||||
import { View, Text } from '@tarojs/components'
|
||||
import Taro from '@tarojs/taro'
|
||||
import './index.scss'
|
||||
|
||||
const AppDetail: React.FC = () => {
|
||||
const id = Taro.getCurrentInstance()?.router?.params?.id
|
||||
|
||||
return (
|
||||
<View className="app-detail-page">
|
||||
<View className="app-detail-page__header">
|
||||
<Text className="app-detail-page__title">📱 应用详情</Text>
|
||||
</View>
|
||||
<View className="app-detail-page__content">
|
||||
<Text>应用 ID: {id}</Text>
|
||||
<Text style={{ marginTop: '20rpx' }}>应用详情功能开发中...</Text>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export default AppDetail
|
||||
6
src/developer/app/[id]/pipeline.config.ts
Normal file
6
src/developer/app/[id]/pipeline.config.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* 流水线配置页面配置
|
||||
*/
|
||||
export default definePageConfig({
|
||||
navigationBarTitleText: '流水线配置',
|
||||
})
|
||||
254
src/developer/app/[id]/pipeline.scss
Normal file
254
src/developer/app/[id]/pipeline.scss
Normal file
@@ -0,0 +1,254 @@
|
||||
.pipeline-page {
|
||||
min-height: 100vh;
|
||||
background: #f5f5f5;
|
||||
padding-bottom: 120px;
|
||||
|
||||
&.loading-wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding-top: 300px;
|
||||
|
||||
.loading-text {
|
||||
margin-top: 16px;
|
||||
font-size: 28px;
|
||||
color: #999;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.pipeline-content {
|
||||
height: calc(100vh - 120px);
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.config-section {
|
||||
background: #fff;
|
||||
border-radius: 16px;
|
||||
padding: 24px;
|
||||
margin-bottom: 24px;
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
display: block;
|
||||
font-size: 32px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
.config-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 20px 0;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.item-info {
|
||||
flex: 1;
|
||||
|
||||
.item-label {
|
||||
display: block;
|
||||
font-size: 28px;
|
||||
color: #333;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.item-desc {
|
||||
font-size: 24px;
|
||||
color: #999;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 分支规则
|
||||
.rule-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px;
|
||||
background: #f5f5f5;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 12px;
|
||||
|
||||
.rule-info {
|
||||
flex: 1;
|
||||
|
||||
.rule-branch {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
font-size: 28px;
|
||||
color: #333;
|
||||
|
||||
.iconfont {
|
||||
color: #4CAF50;
|
||||
}
|
||||
}
|
||||
|
||||
.rule-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.rule-env {
|
||||
font-size: 24px;
|
||||
color: #666;
|
||||
}
|
||||
}
|
||||
|
||||
// 环境变量
|
||||
.env-list {
|
||||
.env-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px;
|
||||
background: #f5f5f5;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 12px;
|
||||
|
||||
.env-info {
|
||||
flex: 1;
|
||||
|
||||
.env-key {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
|
||||
.key {
|
||||
font-size: 28px;
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
font-family: monospace;
|
||||
}
|
||||
}
|
||||
|
||||
.env-value {
|
||||
font-size: 24px;
|
||||
color: #666;
|
||||
font-family: monospace;
|
||||
}
|
||||
}
|
||||
|
||||
.env-delete {
|
||||
font-size: 32px;
|
||||
color: #F44336;
|
||||
padding: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Webhook
|
||||
.webhook-list {
|
||||
.webhook-item {
|
||||
padding: 16px;
|
||||
background: #f5f5f5;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 12px;
|
||||
|
||||
.webhook-info {
|
||||
.webhook-url {
|
||||
display: block;
|
||||
font-size: 26px;
|
||||
color: #2196F3;
|
||||
margin-bottom: 8px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.webhook-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.empty-rules,
|
||||
.empty-envs,
|
||||
.empty-webhooks {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 48px 0;
|
||||
color: #999;
|
||||
|
||||
.iconfont {
|
||||
font-size: 64px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
text {
|
||||
font-size: 26px;
|
||||
}
|
||||
}
|
||||
|
||||
// 保存按钮
|
||||
.save-bar {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
padding: 24px;
|
||||
background: #fff;
|
||||
box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.05);
|
||||
|
||||
.at-button {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
// 环境变量弹窗
|
||||
.env-modal-content {
|
||||
.modal-item {
|
||||
margin-bottom: 24px;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.modal-label {
|
||||
display: block;
|
||||
font-size: 26px;
|
||||
color: #666;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.modal-input {
|
||||
width: 100%;
|
||||
padding: 16px;
|
||||
background: #f5f5f5;
|
||||
border-radius: 8px;
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.modal-switch {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.modal-hint {
|
||||
font-size: 22px;
|
||||
color: #999;
|
||||
margin-top: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
346
src/developer/app/[id]/pipeline.tsx
Normal file
346
src/developer/app/[id]/pipeline.tsx
Normal file
@@ -0,0 +1,346 @@
|
||||
/**
|
||||
* 流水线配置页面
|
||||
*/
|
||||
import { useState, useEffect } from 'react'
|
||||
import { View, Text, ScrollView, Input, Switch } from '@tarojs/components'
|
||||
import Taro from '@tarojs/taro'
|
||||
import { AtButton, AtSwitch, AtTag, AtActivityIndicator, AtInput, AtModal } from 'taro-ui'
|
||||
import {
|
||||
getPipelineConfig,
|
||||
updatePipelineConfig,
|
||||
addEnvVar,
|
||||
deleteEnvVar,
|
||||
getBranches,
|
||||
} from '../../../../api/cicd'
|
||||
import type { PipelineConfig, BranchRule, EnvVar } from '../../../../types/cicd'
|
||||
import './pipeline.scss'
|
||||
|
||||
export default function Pipeline() {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [config, setConfig] = useState<PipelineConfig>({})
|
||||
const [branches, setBranches] = useState<string[]>([])
|
||||
const [showEnvModal, setShowEnvModal] = useState(false)
|
||||
const [newEnvKey, setNewEnvKey] = useState('')
|
||||
const [newEnvValue, setNewEnvValue] = useState('')
|
||||
const [newEnvSecret, setNewEnvSecret] = useState(false)
|
||||
const [appId, setAppId] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
const pages = Taro.getCurrentPages()
|
||||
const current = pages[pages.length - 1]
|
||||
if (current?.options?.appId) {
|
||||
setAppId(current.options.appId)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (appId) {
|
||||
fetchConfig()
|
||||
fetchBranches()
|
||||
}
|
||||
}, [appId])
|
||||
|
||||
// 获取流水线配置
|
||||
const fetchConfig = async () => {
|
||||
try {
|
||||
const res = await getPipelineConfig(Number(appId))
|
||||
if (res.data) {
|
||||
setConfig(res.data)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('获取流水线配置失败', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 获取分支列表
|
||||
const fetchBranches = async () => {
|
||||
try {
|
||||
const res = await getBranches(Number(appId))
|
||||
if (res.data) {
|
||||
setBranches(res.data.branches || [])
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('获取分支列表失败', err)
|
||||
}
|
||||
}
|
||||
|
||||
// 更新配置
|
||||
const handleUpdateConfig = async () => {
|
||||
try {
|
||||
setSaving(true)
|
||||
await updatePipelineConfig({
|
||||
...config,
|
||||
websiteId: Number(appId),
|
||||
})
|
||||
Taro.showToast({ title: '保存成功', icon: 'success' })
|
||||
} catch (err) {
|
||||
Taro.showToast({ title: '保存失败', icon: 'none' })
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 添加环境变量
|
||||
const handleAddEnvVar = async () => {
|
||||
if (!newEnvKey.trim()) {
|
||||
Taro.showToast({ title: '请输入变量名', icon: 'none' })
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await addEnvVar(Number(appId), {
|
||||
key: newEnvKey,
|
||||
value: newEnvValue,
|
||||
isSecret: newEnvSecret,
|
||||
})
|
||||
Taro.showToast({ title: '添加成功', icon: 'success' })
|
||||
setShowEnvModal(false)
|
||||
setNewEnvKey('')
|
||||
setNewEnvValue('')
|
||||
setNewEnvSecret(false)
|
||||
fetchConfig()
|
||||
} catch (err) {
|
||||
Taro.showToast({ title: '添加失败', icon: 'none' })
|
||||
}
|
||||
}
|
||||
|
||||
// 删除环境变量
|
||||
const handleDeleteEnvVar = (key: string) => {
|
||||
Taro.showModal({
|
||||
title: '确认删除',
|
||||
content: `确定要删除环境变量 ${key} 吗?`,
|
||||
success: async (res) => {
|
||||
if (res.confirm) {
|
||||
try {
|
||||
await deleteEnvVar(Number(appId), key)
|
||||
Taro.showToast({ title: '删除成功', icon: 'success' })
|
||||
fetchConfig()
|
||||
} catch (err) {
|
||||
Taro.showToast({ title: '删除失败', icon: 'none' })
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// 复制环境变量值
|
||||
const handleCopyEnvValue = (value: string) => {
|
||||
Taro.setClipboardData({ data: value })
|
||||
Taro.showToast({ title: '已复制', icon: 'success' })
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<View className="pipeline-page loading-wrap">
|
||||
<AtActivityIndicator size={32} />
|
||||
<Text className="loading-text">加载中...</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<View className="pipeline-page">
|
||||
<ScrollView scrollY className="pipeline-content">
|
||||
{/* 基础配置 */}
|
||||
<View className="config-section">
|
||||
<Text className="section-title">基础配置</Text>
|
||||
|
||||
<View className="config-item">
|
||||
<View className="item-info">
|
||||
<Text className="item-label">启用流水线</Text>
|
||||
<Text className="item-desc">开启后支持自动构建和部署</Text>
|
||||
</View>
|
||||
<Switch
|
||||
checked={config.enabled}
|
||||
onChange={(e) => setConfig({ ...config, enabled: e.detail.value })}
|
||||
color="#1890ff"
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View className="config-item">
|
||||
<View className="item-info">
|
||||
<Text className="item-label">自动部署</Text>
|
||||
<Text className="item-desc">构建成功后自动部署到对应环境</Text>
|
||||
</View>
|
||||
<Switch
|
||||
checked={config.autoDeploy}
|
||||
onChange={(e) => setConfig({ ...config, autoDeploy: e.detail.value })}
|
||||
color="#1890ff"
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 分支规则 */}
|
||||
<View className="config-section">
|
||||
<View className="section-header">
|
||||
<Text className="section-title">分支规则</Text>
|
||||
<AtButton size="small" type="primary">
|
||||
添加规则
|
||||
</AtButton>
|
||||
</View>
|
||||
|
||||
{config.branchRules && config.branchRules.length > 0 ? (
|
||||
config.branchRules.map((rule, index) => (
|
||||
<View className="rule-item" key={index}>
|
||||
<View className="rule-info">
|
||||
<View className="rule-branch">
|
||||
<Text className="iconfont icon-git-branch" />
|
||||
<Text>{rule.branch || 'main'}</Text>
|
||||
</View>
|
||||
<View className="rule-tags">
|
||||
{rule.autoBuild && <AtTag size="small">自动构建</AtTag>}
|
||||
{rule.autoDeploy && <AtTag size="small" type="success">自动部署</AtTag>}
|
||||
{rule.requireApproval && <AtTag size="small" type="warning">需要审批</AtTag>}
|
||||
</View>
|
||||
</View>
|
||||
<Text className="rule-env">
|
||||
{rule.deployEnv === 'development' ? '开发' : rule.deployEnv === 'staging' ? '预发布' : '生产'}
|
||||
</Text>
|
||||
</View>
|
||||
))
|
||||
) : (
|
||||
<View className="empty-rules">
|
||||
<Text className="iconfont icon-empty" />
|
||||
<Text>暂无分支规则</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* 环境变量 */}
|
||||
<View className="config-section">
|
||||
<View className="section-header">
|
||||
<Text className="section-title">环境变量</Text>
|
||||
<AtButton size="small" type="primary" onClick={() => setShowEnvModal(true)}>
|
||||
添加变量
|
||||
</AtButton>
|
||||
</View>
|
||||
|
||||
{config.environmentVars && config.environmentVars.length > 0 ? (
|
||||
<View className="env-list">
|
||||
{config.environmentVars.map((env, index) => (
|
||||
<View className="env-item" key={index}>
|
||||
<View className="env-info">
|
||||
<View className="env-key">
|
||||
<Text className="key">{env.key}</Text>
|
||||
{env.isSecret && <AtTag size="small" type="warning">加密</AtTag>}
|
||||
</View>
|
||||
<Text
|
||||
className="env-value"
|
||||
onClick={() => !env.isSecret && env.value && handleCopyEnvValue(env.value)}
|
||||
>
|
||||
{env.isSecret ? '******' : env.value}
|
||||
</Text>
|
||||
</View>
|
||||
<Text
|
||||
className="env-delete iconfont icon-delete"
|
||||
onClick={() => env.key && handleDeleteEnvVar(env.key)}
|
||||
/>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
) : (
|
||||
<View className="empty-envs">
|
||||
<Text className="iconfont icon-empty" />
|
||||
<Text>暂无环境变量</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Webhook 配置 */}
|
||||
<View className="config-section">
|
||||
<View className="section-header">
|
||||
<Text className="section-title">Webhook 配置</Text>
|
||||
<AtButton size="small" type="primary">
|
||||
添加 Webhook
|
||||
</AtButton>
|
||||
</View>
|
||||
|
||||
{config.webhooks && config.webhooks.length > 0 ? (
|
||||
<View className="webhook-list">
|
||||
{config.webhooks.map((webhook, index) => (
|
||||
<View className="webhook-item" key={index}>
|
||||
<View className="webhook-info">
|
||||
<Text className="webhook-url">{webhook.url}</Text>
|
||||
<View className="webhook-tags">
|
||||
{webhook.enabled ? (
|
||||
<AtTag size="small" type="success">启用</AtTag>
|
||||
) : (
|
||||
<AtTag size="small">禁用</AtTag>
|
||||
)}
|
||||
{webhook.events?.map((event, i) => (
|
||||
<AtTag key={i} size="small" type="primary">{event}</AtTag>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
) : (
|
||||
<View className="empty-webhooks">
|
||||
<Text className="iconfont icon-empty" />
|
||||
<Text>暂无 Webhook</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</ScrollView>
|
||||
|
||||
{/* 保存按钮 */}
|
||||
<View className="save-bar">
|
||||
<AtButton
|
||||
type="primary"
|
||||
loading={saving}
|
||||
onClick={handleUpdateConfig}
|
||||
>
|
||||
保存配置
|
||||
</AtButton>
|
||||
</View>
|
||||
|
||||
{/* 添加环境变量弹窗 */}
|
||||
<AtModal
|
||||
isOpened={showEnvModal}
|
||||
title="添加环境变量"
|
||||
cancelText="取消"
|
||||
confirmText="添加"
|
||||
onClose={() => setShowEnvModal(false)}
|
||||
onCancel={() => setShowEnvModal(false)}
|
||||
onConfirm={handleAddEnvVar}
|
||||
content={
|
||||
<View className="env-modal-content">
|
||||
<View className="modal-item">
|
||||
<Text className="modal-label">变量名 *</Text>
|
||||
<Input
|
||||
className="modal-input"
|
||||
placeholder="如: API_KEY"
|
||||
value={newEnvKey}
|
||||
onInput={(e) => setNewEnvKey(e.detail.value)}
|
||||
/>
|
||||
</View>
|
||||
<View className="modal-item">
|
||||
<Text className="modal-label">变量值</Text>
|
||||
<Input
|
||||
className="modal-input"
|
||||
placeholder="变量对应的值"
|
||||
value={newEnvValue}
|
||||
onInput={(e) => setNewEnvValue(e.detail.value)}
|
||||
/>
|
||||
</View>
|
||||
<View className="modal-item">
|
||||
<View className="modal-switch">
|
||||
<Text className="modal-label">加密存储</Text>
|
||||
<AtSwitch
|
||||
checked={newEnvSecret}
|
||||
onChange={(e) => setNewEnvSecret(e.detail.value)}
|
||||
size="small"
|
||||
/>
|
||||
</View>
|
||||
<Text className="modal-hint">加密后值将不可见</Text>
|
||||
</View>
|
||||
</View>
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
3
src/developer/app/[id]/publish.config.ts
Normal file
3
src/developer/app/[id]/publish.config.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export default definePageConfig({
|
||||
navigationBarTitleText: '发布管理',
|
||||
})
|
||||
10
src/developer/app/[id]/publish.scss
Normal file
10
src/developer/app/[id]/publish.scss
Normal file
@@ -0,0 +1,10 @@
|
||||
.publish-page {
|
||||
padding: 32rpx;
|
||||
|
||||
&__content {
|
||||
text-align: center;
|
||||
padding: 120rpx 0;
|
||||
font-size: 28rpx;
|
||||
color: #999;
|
||||
}
|
||||
}
|
||||
15
src/developer/app/[id]/publish.tsx
Normal file
15
src/developer/app/[id]/publish.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import React from 'react'
|
||||
import { View, Text } from '@tarojs/components'
|
||||
import './publish.scss'
|
||||
|
||||
const PublishPage: React.FC = () => {
|
||||
return (
|
||||
<View className="publish-page">
|
||||
<View className="publish-page__content">
|
||||
<Text>发布管理功能开发中...</Text>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export default PublishPage
|
||||
335
src/developer/app/[id]/version.scss
Normal file
335
src/developer/app/[id]/version.scss
Normal file
@@ -0,0 +1,335 @@
|
||||
.version-page {
|
||||
min-height: 100vh;
|
||||
background: #f5f5f5;
|
||||
padding: 24rpx;
|
||||
padding-bottom: 120rpx;
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 24rpx;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-size: 36rpx;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
&__publish-btn {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 32rpx;
|
||||
font-size: 26rpx;
|
||||
padding: 0 28rpx;
|
||||
height: 60rpx;
|
||||
line-height: 60rpx;
|
||||
|
||||
&::after {
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
|
||||
// 环境 Tab
|
||||
&__tabs {
|
||||
display: flex;
|
||||
background: #fff;
|
||||
border-radius: 12rpx;
|
||||
padding: 8rpx;
|
||||
margin-bottom: 24rpx;
|
||||
}
|
||||
|
||||
&__tab {
|
||||
flex: 1;
|
||||
height: 72rpx;
|
||||
line-height: 72rpx;
|
||||
text-align: center;
|
||||
font-size: 28rpx;
|
||||
color: #666;
|
||||
border-radius: 8rpx;
|
||||
|
||||
&.active {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
// 列表
|
||||
&__list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24rpx;
|
||||
}
|
||||
|
||||
&__empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 120rpx 0;
|
||||
|
||||
text {
|
||||
font-size: 30rpx;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
&-hint {
|
||||
margin-top: 16rpx;
|
||||
font-size: 26rpx !important;
|
||||
color: #ccc !important;
|
||||
}
|
||||
}
|
||||
|
||||
&__loading,
|
||||
&__no-more {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 32rpx 0;
|
||||
|
||||
text {
|
||||
font-size: 26rpx;
|
||||
color: #999;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 版本卡片
|
||||
.version-card {
|
||||
background: #fff;
|
||||
border-radius: 16rpx;
|
||||
padding: 32rpx;
|
||||
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.05);
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
|
||||
&__info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16rpx;
|
||||
}
|
||||
|
||||
&__name {
|
||||
font-size: 32rpx;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
&__status {
|
||||
font-size: 24rpx;
|
||||
padding: 4rpx 12rpx;
|
||||
border-radius: 8rpx;
|
||||
}
|
||||
|
||||
&__env {
|
||||
font-size: 24rpx;
|
||||
padding: 4rpx 16rpx;
|
||||
border-radius: 8rpx;
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
&__current {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: #fff;
|
||||
font-size: 24rpx;
|
||||
padding: 4rpx 16rpx;
|
||||
border-radius: 8rpx;
|
||||
margin-bottom: 16rpx;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
&__body {
|
||||
background: #fafafa;
|
||||
border-radius: 12rpx;
|
||||
padding: 24rpx;
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
|
||||
&__row {
|
||||
display: flex;
|
||||
margin-bottom: 12rpx;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&__label {
|
||||
font-size: 26rpx;
|
||||
color: #666;
|
||||
width: 140rpx;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__value {
|
||||
font-size: 26rpx;
|
||||
color: #333;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
&__changelog {
|
||||
background: #f0f7ff;
|
||||
border-radius: 12rpx;
|
||||
padding: 24rpx;
|
||||
margin-bottom: 16rpx;
|
||||
|
||||
&-title {
|
||||
display: block;
|
||||
font-size: 26rpx;
|
||||
color: #1890ff;
|
||||
margin-bottom: 8rpx;
|
||||
}
|
||||
|
||||
&-content {
|
||||
display: block;
|
||||
font-size: 26rpx;
|
||||
color: #333;
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
}
|
||||
|
||||
&__footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
&__time {
|
||||
font-size: 24rpx;
|
||||
color: #999;
|
||||
}
|
||||
}
|
||||
|
||||
// 发布弹窗
|
||||
.version-page__modal {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
|
||||
&-mask {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
&-content {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-height: 85vh;
|
||||
background: #fff;
|
||||
border-radius: 32rpx 32rpx 0 0;
|
||||
padding: 48rpx 32rpx;
|
||||
padding-bottom: calc(48rpx + env(safe-area-inset-bottom));
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
&-title {
|
||||
display: block;
|
||||
font-size: 36rpx;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
text-align: center;
|
||||
margin-bottom: 48rpx;
|
||||
}
|
||||
}
|
||||
|
||||
.version-page__form {
|
||||
&-item {
|
||||
margin-bottom: 32rpx;
|
||||
}
|
||||
|
||||
&-label {
|
||||
display: block;
|
||||
font-size: 28rpx;
|
||||
color: #333;
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
|
||||
&-input {
|
||||
width: 100%;
|
||||
height: 88rpx;
|
||||
background: #f5f5f5;
|
||||
border-radius: 12rpx;
|
||||
padding: 0 24rpx;
|
||||
font-size: 30rpx;
|
||||
}
|
||||
|
||||
&-radio {
|
||||
display: flex;
|
||||
gap: 24rpx;
|
||||
|
||||
&-item {
|
||||
flex: 1;
|
||||
height: 72rpx;
|
||||
line-height: 72rpx;
|
||||
text-align: center;
|
||||
background: #f5f5f5;
|
||||
border-radius: 12rpx;
|
||||
font-size: 28rpx;
|
||||
color: #666;
|
||||
|
||||
&.active {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&-textarea {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.version-page__textarea {
|
||||
width: 100%;
|
||||
min-height: 180rpx;
|
||||
background: #f5f5f5;
|
||||
border-radius: 12rpx;
|
||||
padding: 24rpx;
|
||||
font-size: 30rpx;
|
||||
line-height: 1.6;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.version-page__modal-actions {
|
||||
display: flex;
|
||||
gap: 24rpx;
|
||||
margin-top: 48rpx;
|
||||
}
|
||||
|
||||
.version-page__modal-btn {
|
||||
flex: 1;
|
||||
height: 88rpx;
|
||||
line-height: 88rpx;
|
||||
border-radius: 44rpx;
|
||||
font-size: 32rpx;
|
||||
|
||||
&--cancel {
|
||||
background: #f5f5f5;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
&--confirm {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: #fff;
|
||||
|
||||
&::after {
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
364
src/developer/app/[id]/version.tsx
Normal file
364
src/developer/app/[id]/version.tsx
Normal file
@@ -0,0 +1,364 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { View, Text, Button, Input } from '@tarojs/components'
|
||||
import Taro, { useRouter } from '@tarojs/taro'
|
||||
import { usePullDownRefresh } from '@tarojs/taro'
|
||||
import { pageVersion, createVersion } from '@/api/developer/developer'
|
||||
import type { Version, VersionParam, VersionStatus, PublishEnv } from '@/types/developer'
|
||||
import './version.scss'
|
||||
|
||||
// 状态配置
|
||||
const STATUS_CONFIG: Record<VersionStatus, { label: string; color: string; bgColor: string }> = {
|
||||
0: { label: '构建中', color: '#faad14', bgColor: '#fffbe6' },
|
||||
1: { label: '已发布', color: '#52c41a', bgColor: '#f6ffed' },
|
||||
2: { label: '已回滚', color: '#ff4d4f', bgColor: '#fff1f0' },
|
||||
3: { label: '构建失败', color: '#f5222d', bgColor: '#fff1f0' },
|
||||
}
|
||||
|
||||
// 环境配置
|
||||
const ENV_CONFIG: Record<PublishEnv, { label: string; color: string }> = {
|
||||
development: { label: '开发环境', color: '#722ed1' },
|
||||
staging: { label: '预发布环境', color: '#1890ff' },
|
||||
production: { label: '生产环境', color: '#52c41a' },
|
||||
}
|
||||
|
||||
const VersionPage: React.FC = () => {
|
||||
const router = useRouter()
|
||||
const appId = Number(router.params.id)
|
||||
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
const [list, setList] = useState<Version[]>([])
|
||||
const [page, setPage] = useState(1)
|
||||
const [hasMore, setHasMore] = useState(true)
|
||||
const [showCreate, setShowCreate] = useState(false)
|
||||
const [createForm, setCreateForm] = useState({
|
||||
versionName: '',
|
||||
versionNo: '',
|
||||
changelog: '',
|
||||
env: 'staging' as PublishEnv,
|
||||
})
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [currentTab, setCurrentTab] = useState<PublishEnv | 'all'>('all')
|
||||
|
||||
// 加载数据
|
||||
const loadData = async (pageNum: number = 1, isRefresh = false) => {
|
||||
if (loading) return
|
||||
setLoading(true)
|
||||
if (isRefresh) setRefreshing(true)
|
||||
|
||||
try {
|
||||
const params: VersionParam = {
|
||||
page: pageNum,
|
||||
limit: 20,
|
||||
websiteId: appId,
|
||||
env: currentTab === 'all' ? undefined : currentTab,
|
||||
}
|
||||
const data = await pageVersion(params)
|
||||
|
||||
if (pageNum === 1) {
|
||||
setList(data?.records || [])
|
||||
} else {
|
||||
setList(prev => [...prev, ...(data?.records || [])])
|
||||
}
|
||||
|
||||
const total = data?.total || 0
|
||||
const records = data?.records || []
|
||||
setHasMore(records.length > 0 && (pageNum * 20) < total)
|
||||
setPage(pageNum)
|
||||
} catch (err) {
|
||||
console.error('加载失败', err)
|
||||
Taro.showToast({ title: '加载失败', icon: 'none' })
|
||||
} finally {
|
||||
setLoading(false)
|
||||
setRefreshing(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadData(1)
|
||||
}, [appId, currentTab])
|
||||
|
||||
// 下拉刷新
|
||||
usePullDownRefresh(() => {
|
||||
loadData(1, true)
|
||||
})
|
||||
|
||||
// 加载更多
|
||||
const onReachBottom = () => {
|
||||
if (hasMore && !loading) {
|
||||
loadData(page + 1)
|
||||
}
|
||||
}
|
||||
|
||||
// 创建版本
|
||||
const handleCreate = async () => {
|
||||
if (!createForm.versionName.trim()) {
|
||||
Taro.showToast({ title: '请输入版本名称', icon: 'none' })
|
||||
return
|
||||
}
|
||||
if (!createForm.versionNo.trim()) {
|
||||
Taro.showToast({ title: '请输入版本号', icon: 'none' })
|
||||
return
|
||||
}
|
||||
|
||||
setCreating(true)
|
||||
try {
|
||||
await createVersion({
|
||||
websiteId: appId,
|
||||
versionName: createForm.versionName,
|
||||
versionNo: createForm.versionNo,
|
||||
changelog: createForm.changelog,
|
||||
env: createForm.env,
|
||||
} as Partial<Version>)
|
||||
Taro.showToast({ title: '发布成功', icon: 'success' })
|
||||
setShowCreate(false)
|
||||
setCreateForm({ versionName: '', versionNo: '', changelog: '', env: 'staging' })
|
||||
loadData(1)
|
||||
} catch (err) {
|
||||
console.error('发布失败', err)
|
||||
Taro.showToast({ title: '发布失败', icon: 'none' })
|
||||
} finally {
|
||||
setCreating(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 发布新版本
|
||||
const handlePublish = () => {
|
||||
if (list.some(v => v.status === 0)) {
|
||||
Taro.showToast({ title: '有版本正在构建中', icon: 'none' })
|
||||
return
|
||||
}
|
||||
setShowCreate(true)
|
||||
}
|
||||
|
||||
// 格式化日期
|
||||
const formatDate = (dateStr?: string) => {
|
||||
if (!dateStr) return '-'
|
||||
const date = new Date(dateStr)
|
||||
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')} ${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
// 格式化文件大小
|
||||
const formatSize = (size?: string) => {
|
||||
if (!size) return '-'
|
||||
if (size.length < 4) return size
|
||||
const num = parseFloat(size)
|
||||
if (num >= 1024 * 1024) {
|
||||
return (num / (1024 * 1024)).toFixed(2) + ' MB'
|
||||
} else if (num >= 1024) {
|
||||
return (num / 1024).toFixed(2) + ' KB'
|
||||
}
|
||||
return size + ' B'
|
||||
}
|
||||
|
||||
// Tab 配置
|
||||
const tabs: { key: PublishEnv | 'all'; label: string }[] = [
|
||||
{ key: 'all', label: '全部' },
|
||||
{ key: 'development', label: '开发' },
|
||||
{ key: 'staging', label: '预发布' },
|
||||
{ key: 'production', label: '生产' },
|
||||
]
|
||||
|
||||
return (
|
||||
<View className="version-page">
|
||||
{/* 头部 */}
|
||||
<View className="version-page__header">
|
||||
<Text className="version-page__title">📦 版本管理</Text>
|
||||
<Button
|
||||
className="version-page__publish-btn"
|
||||
size="mini"
|
||||
onClick={handlePublish}
|
||||
>
|
||||
+ 发布版本
|
||||
</Button>
|
||||
</View>
|
||||
|
||||
{/* 环境 Tab */}
|
||||
<View className="version-page__tabs">
|
||||
{tabs.map((tab) => (
|
||||
<View
|
||||
key={tab.key}
|
||||
className={`version-page__tab ${currentTab === tab.key ? 'active' : ''}`}
|
||||
onClick={() => setCurrentTab(tab.key)}
|
||||
>
|
||||
{tab.label}
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
|
||||
{/* 列表 */}
|
||||
<View className="version-page__list">
|
||||
{list.length === 0 && !loading ? (
|
||||
<View className="version-page__empty">
|
||||
<Text>暂无版本记录</Text>
|
||||
<Text className="version-page__empty-hint">点击「发布版本」创建新版本</Text>
|
||||
</View>
|
||||
) : (
|
||||
list.map((item) => (
|
||||
<View key={item.id} className="version-card">
|
||||
<View className="version-card__header">
|
||||
<View className="version-card__info">
|
||||
<Text className="version-card__name">
|
||||
{item.versionName || `v${item.versionNo}`}
|
||||
</Text>
|
||||
<View
|
||||
className="version-card__status"
|
||||
style={{
|
||||
color: STATUS_CONFIG[item.status as VersionStatus]?.color,
|
||||
background: STATUS_CONFIG[item.status as VersionStatus]?.bgColor,
|
||||
}}
|
||||
>
|
||||
{STATUS_CONFIG[item.status as VersionStatus]?.label}
|
||||
</View>
|
||||
</View>
|
||||
<View
|
||||
className="version-card__env"
|
||||
style={{ color: ENV_CONFIG[item.env as PublishEnv]?.color }}
|
||||
>
|
||||
{ENV_CONFIG[item.env as PublishEnv]?.label}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{item.isCurrent && (
|
||||
<View className="version-card__current">
|
||||
<Text>当前版本</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View className="version-card__body">
|
||||
{item.versionNo && (
|
||||
<View className="version-card__row">
|
||||
<Text className="version-card__label">版本号:</Text>
|
||||
<Text className="version-card__value">{item.versionNo}</Text>
|
||||
</View>
|
||||
)}
|
||||
{item.packageSize && (
|
||||
<View className="version-card__row">
|
||||
<Text className="version-card__label">包大小:</Text>
|
||||
<Text className="version-card__value">{formatSize(item.packageSize)}</Text>
|
||||
</View>
|
||||
)}
|
||||
{item.publishBy && (
|
||||
<View className="version-card__row">
|
||||
<Text className="version-card__label">发布人:</Text>
|
||||
<Text className="version-card__value">{item.publishBy}</Text>
|
||||
</View>
|
||||
)}
|
||||
{item.publishTime && (
|
||||
<View className="version-card__row">
|
||||
<Text className="version-card__label">发布时间:</Text>
|
||||
<Text className="version-card__value">{formatDate(item.publishTime)}</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{item.changelog && (
|
||||
<View className="version-card__changelog">
|
||||
<Text className="version-card__changelog-title">更新日志:</Text>
|
||||
<Text className="version-card__changelog-content">{item.changelog}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View className="version-card__footer">
|
||||
<Text className="version-card__time">
|
||||
创建于 {formatDate(item.createTime)}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
))
|
||||
)}
|
||||
|
||||
{/* 加载状态 */}
|
||||
{loading && list.length > 0 && (
|
||||
<View className="version-page__loading">
|
||||
<Text>加载中...</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{!hasMore && list.length > 0 && (
|
||||
<View className="version-page__no-more">
|
||||
<Text>没有更多了</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* 发布弹窗 */}
|
||||
{showCreate && (
|
||||
<View className="version-page__modal">
|
||||
<View className="version-page__modal-mask" onClick={() => setShowCreate(false)} />
|
||||
<View className="version-page__modal-content">
|
||||
<Text className="version-page__modal-title">发布新版本</Text>
|
||||
|
||||
<View className="version-page__form">
|
||||
<View className="version-page__form-item">
|
||||
<Text className="version-page__form-label">版本名称 *</Text>
|
||||
<Input
|
||||
className="version-page__form-input"
|
||||
placeholder="如:正式版 v1.0.0"
|
||||
value={createForm.versionName}
|
||||
onInput={(e) => setCreateForm(prev => ({ ...prev, versionName: e.detail.value }))}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View className="version-page__form-item">
|
||||
<Text className="version-page__form-label">版本号 *</Text>
|
||||
<Input
|
||||
className="version-page__form-input"
|
||||
placeholder="如:1.0.0"
|
||||
value={createForm.versionNo}
|
||||
onInput={(e) => setCreateForm(prev => ({ ...prev, versionNo: e.detail.value }))}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View className="version-page__form-item">
|
||||
<Text className="version-page__form-label">发布环境</Text>
|
||||
<View className="version-page__form-radio">
|
||||
{(['staging', 'production'] as const).map((env) => (
|
||||
<View
|
||||
key={env}
|
||||
className={`version-page__form-radio-item ${createForm.env === env ? 'active' : ''}`}
|
||||
onClick={() => setCreateForm(prev => ({ ...prev, env }))}
|
||||
>
|
||||
{ENV_CONFIG[env].label}
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className="version-page__form-item">
|
||||
<Text className="version-page__form-label">更新日志</Text>
|
||||
<View className="version-page__form-textarea">
|
||||
<textarea
|
||||
className="version-page__textarea"
|
||||
placeholder="请输入版本更新内容..."
|
||||
value={createForm.changelog}
|
||||
onInput={(e: any) => setCreateForm(prev => ({ ...prev, changelog: e.detail.value }))}
|
||||
rows={4}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className="version-page__modal-actions">
|
||||
<Button
|
||||
className="version-page__modal-btn version-page__modal-btn--cancel"
|
||||
onClick={() => setShowCreate(false)}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
className="version-page__modal-btn version-page__modal-btn--confirm"
|
||||
loading={creating}
|
||||
onClick={handleCreate}
|
||||
>
|
||||
发布
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export default VersionPage
|
||||
Reference in New Issue
Block a user