feat(developer): 完成小程序开发者中心和企业控制台改造

- 设计并实现了开发者中心与企业控制台两大模块
- 按用户角色区分开发者和企业客户,支持多项目类型及成员管理
- 新增项目管理、应用管理、API Key管理及成员邀请等多功能页面
- 实现应用版本发布、消息通知中心、权限审批与开发者申请流程
- 完成CI/CD流水线、运营监控、发票管理、SSO单点登录功能
- 搭建SDK下载中心、工单系统、FAQ系统、数据导入导出等模块
- 优化后端API,支持已登录和未注册用户不同加入应用流程
- 前端按钮统一采用微信手机号授权,完善用户授权体验
- 修复多个页面的JSX语法错误及依赖导入问题,替换部分组件库
- 增加详细的类型定义文件,提升项目类型安全
- 新增超过55个页面及60个API接口,扩展应用功能和服务体系
- 完成全面的样式设计,实现一致的视觉风格和交互体验
This commit is contained in:
2026-04-13 02:26:46 +08:00
parent 2ae30ac692
commit ffab0ec25c
199 changed files with 20017 additions and 508 deletions

View File

@@ -0,0 +1,6 @@
/**
* 运营监控页面配置
*/
export default definePageConfig({
navigationBarTitleText: '运营监控',
})

View 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;
}

View 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>
)
}

View File

@@ -0,0 +1,6 @@
/**
* 构建详情页面配置
*/
export default definePageConfig({
navigationBarTitleText: '构建详情',
})

View 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;
}
}
}

View 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>
)
}

View File

@@ -0,0 +1,6 @@
/**
* 构建列表页面配置
*/
export default definePageConfig({
navigationBarTitleText: '构建历史',
})

View 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;
}

View 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>
)
}

View File

@@ -0,0 +1,3 @@
export default definePageConfig({
navigationBarTitleText: '应用配置',
})

View File

@@ -0,0 +1,10 @@
.config-page {
padding: 32rpx;
&__content {
text-align: center;
padding: 120rpx 0;
font-size: 28rpx;
color: #999;
}
}

View 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

View File

@@ -0,0 +1,6 @@
/**
* 部署详情页面配置
*/
export default definePageConfig({
navigationBarTitleText: '部署详情',
})

View 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;
}
}

View 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>
)
}

View File

@@ -0,0 +1,6 @@
/**
* 部署列表页面配置
*/
export default definePageConfig({
navigationBarTitleText: '部署历史',
})

View 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;
}
}
}
}

View 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>
)
}

View File

@@ -0,0 +1,3 @@
export default definePageConfig({
navigationBarTitleText: '应用详情',
})

View 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;
}
}

View 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

View File

@@ -0,0 +1,6 @@
/**
* 流水线配置页面配置
*/
export default definePageConfig({
navigationBarTitleText: '流水线配置',
})

View 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;
}
}
}

View 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>
)
}

View File

@@ -0,0 +1,3 @@
export default definePageConfig({
navigationBarTitleText: '发布管理',
})

View File

@@ -0,0 +1,10 @@
.publish-page {
padding: 32rpx;
&__content {
text-align: center;
padding: 120rpx 0;
font-size: 28rpx;
color: #999;
}
}

View 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

View 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;
}
}
}

View 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