初始版本
This commit is contained in:
653
app/pages/developer/pipeline.vue
Normal file
653
app/pages/developer/pipeline.vue
Normal file
@@ -0,0 +1,653 @@
|
||||
<template>
|
||||
<div class="dev-page">
|
||||
<!-- 页面头部 -->
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2 class="page-title">⚙️ 流水线管理</h2>
|
||||
<p class="page-desc">配置 CI/CD 流水线,对接 Gitea CI / Jenkins / GitHub Actions</p>
|
||||
</div>
|
||||
<a-space>
|
||||
<a-button @click="loadPipelines">
|
||||
<template #icon><ReloadOutlined /></template>
|
||||
刷新
|
||||
</a-button>
|
||||
<a-button type="primary" @click="showPipelineModal = true">
|
||||
<template #icon><PlusOutlined /></template>
|
||||
新建流水线
|
||||
</a-button>
|
||||
</a-space>
|
||||
</div>
|
||||
|
||||
<!-- 选择应用 -->
|
||||
<div class="panel mb-4">
|
||||
<div class="panel-header">
|
||||
<span class="panel-title">📦 选择应用</span>
|
||||
</div>
|
||||
<div class="p-4">
|
||||
<a-select
|
||||
v-model:value="selectedAppId"
|
||||
style="width: 300px"
|
||||
placeholder="选择要管理流水线的应用"
|
||||
:loading="loadingApps"
|
||||
allow-clear
|
||||
@change="handleAppChange"
|
||||
>
|
||||
<a-select-option v-for="app in apps" :key="app.productId" :value="app.productId">
|
||||
{{ app.productName }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 流水线列表 -->
|
||||
<div v-if="selectedAppId" class="panel">
|
||||
<div class="panel-header">
|
||||
<span class="panel-title">⚡ 流水线列表</span>
|
||||
<a-tag v-if="pipelines.length > 0">{{ pipelines.length }} 条流水线</a-tag>
|
||||
</div>
|
||||
|
||||
<a-spin :spinning="loading">
|
||||
<div v-if="pipelines.length > 0" class="pipeline-list">
|
||||
<div v-for="pipeline in pipelines" :key="pipeline.id" class="pipeline-item">
|
||||
<div class="pipeline-header">
|
||||
<div class="pipeline-info">
|
||||
<span class="pipeline-name">{{ pipeline.name }}</span>
|
||||
<a-tag :color="pipeline.enabled ? 'green' : 'default'" size="small">
|
||||
{{ pipeline.enabled ? '启用' : '禁用' }}
|
||||
</a-tag>
|
||||
<a-tag size="small">{{ ciTypeText(pipeline.ciType) }}</a-tag>
|
||||
<a-tag v-if="pipeline.autoDeploy" color="blue" size="small">自动部署</a-tag>
|
||||
</div>
|
||||
<div class="pipeline-actions">
|
||||
<a-switch
|
||||
:checked="pipeline.enabled"
|
||||
size="small"
|
||||
@change="(checked: boolean) => handleToggle(pipeline, checked)"
|
||||
/>
|
||||
<a-button type="link" size="small" @click="handleEdit(pipeline)">编辑</a-button>
|
||||
<a-popconfirm title="确定要删除此流水线?" @confirm="handleDelete(pipeline)">
|
||||
<a-button danger type="link" size="small">删除</a-button>
|
||||
</a-popconfirm>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pipeline-body">
|
||||
<div class="pipeline-desc">{{ pipeline.description || '暂无描述' }}</div>
|
||||
|
||||
<div class="pipeline-meta">
|
||||
<span v-if="pipeline.repoFullName" class="meta-item">
|
||||
<GithubOutlined /> {{ pipeline.repoFullName }}
|
||||
</span>
|
||||
<span v-if="pipeline.workflowFile" class="meta-item">
|
||||
<FileOutlined /> {{ pipeline.workflowFile }}
|
||||
</span>
|
||||
<span class="meta-item">
|
||||
<BranchesOutlined /> {{ pipeline.defaultBranch || 'main' }}
|
||||
</span>
|
||||
<span class="meta-item">
|
||||
<NodeIndexOutlined /> {{ stagesText(pipeline.stages) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="pipeline-stats">
|
||||
<span class="stat-item success">
|
||||
<CheckCircleOutlined /> {{ pipeline.successCount || 0 }} 成功
|
||||
</span>
|
||||
<span class="stat-item failed">
|
||||
<CloseCircleOutlined /> {{ pipeline.failureCount || 0 }} 失败
|
||||
</span>
|
||||
<span v-if="pipeline.lastBuildTime" class="stat-item">
|
||||
<ClockCircleOutlined /> {{ formatTime(pipeline.lastBuildTime) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="pipeline.lastBuildStatus" class="pipeline-last-build" :class="pipeline.lastBuildStatus">
|
||||
最近构建: <span>{{ statusText(pipeline.lastBuildStatus) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a-empty v-else description="暂无流水线配置" class="py-8">
|
||||
<template #image>
|
||||
<div class="empty-icon">⚙️</div>
|
||||
</template>
|
||||
<a-button type="primary" @click="showPipelineModal = true">创建第一条流水线</a-button>
|
||||
</a-empty>
|
||||
</a-spin>
|
||||
</div>
|
||||
|
||||
<!-- 未选择应用提示 -->
|
||||
<div v-else class="panel">
|
||||
<div class="empty-state">
|
||||
<div class="empty-icon">📦</div>
|
||||
<div class="empty-title">请先选择应用</div>
|
||||
<div class="empty-desc">选择应用后,可以查看和管理该应用的 CI/CD 流水线</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 新建/编辑流水线弹窗 -->
|
||||
<a-modal
|
||||
v-model:open="showPipelineModal"
|
||||
:title="editingPipeline ? '编辑流水线' : '新建流水线'"
|
||||
width="600px"
|
||||
:confirm-loading="submitting"
|
||||
@ok="handleSubmit"
|
||||
@cancel="resetForm"
|
||||
>
|
||||
<a-form :model="form" layout="vertical">
|
||||
<a-form-item label="流水线名称" required>
|
||||
<a-input v-model:value="form.name" placeholder="如:生产环境构建" />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="描述">
|
||||
<a-textarea v-model:value="form.description" :rows="2" placeholder="描述此流水线的用途" />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="CI 系统" required>
|
||||
<a-radio-group v-model:value="form.ciType">
|
||||
<a-radio value="gitea">Gitea CI</a-radio>
|
||||
<a-radio value="jenkins">Jenkins</a-radio>
|
||||
<a-radio value="github">GitHub Actions</a-radio>
|
||||
</a-radio-group>
|
||||
</a-form-item>
|
||||
|
||||
<template v-if="form.ciType === 'gitea'">
|
||||
<a-form-item label="仓库全称" required>
|
||||
<a-input v-model:value="form.repoFullName" placeholder="如:gxwebsoft/my-app" />
|
||||
<div class="form-hint">Gitea 上的仓库完整名称(用户名/仓库名)</div>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="工作流文件">
|
||||
<a-input v-model:value="form.workflowFile" placeholder="build.yml" />
|
||||
<div class="form-hint">.gitea/workflows/ 目录下的工作流文件名</div>
|
||||
</a-form-item>
|
||||
</template>
|
||||
|
||||
<template v-if="form.ciType === 'jenkins'">
|
||||
<a-form-item label="Jenkins Job 名称" required>
|
||||
<a-input v-model:value="form.repoFullName" placeholder="如:my-app-build" />
|
||||
</a-form-item>
|
||||
</template>
|
||||
|
||||
<template v-if="form.ciType === 'github'">
|
||||
<a-form-item label="仓库全称" required>
|
||||
<a-input v-model:value="form.repoFullName" placeholder="如:owner/repo" />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="工作流文件">
|
||||
<a-input v-model:value="form.workflowFile" placeholder=".github/workflows/build.yml" />
|
||||
</a-form-item>
|
||||
</template>
|
||||
|
||||
<a-form-item label="环境" required>
|
||||
<a-radio-group v-model:value="form.env">
|
||||
<a-radio value="development">开发环境</a-radio>
|
||||
<a-radio value="staging">测试环境</a-radio>
|
||||
<a-radio value="production">生产环境</a-radio>
|
||||
</a-radio-group>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="默认分支">
|
||||
<a-input v-model:value="form.defaultBranch" placeholder="main" />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="流水线阶段">
|
||||
<a-checkbox-group v-model:value="form.selectedStages">
|
||||
<a-checkbox value="build">构建</a-checkbox>
|
||||
<a-checkbox value="test">测试</a-checkbox>
|
||||
<a-checkbox value="deploy">部署</a-checkbox>
|
||||
</a-checkbox-group>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="超时时间(秒)">
|
||||
<a-input-number v-model:value="form.timeout" :min="60" :max="7200" style="width: 200px" />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item>
|
||||
<a-checkbox v-model:checked="form.autoDeploy">自动部署</a-checkbox>
|
||||
<div class="form-hint">构建成功后自动触发部署流程</div>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
ReloadOutlined,
|
||||
PlusOutlined,
|
||||
GithubOutlined,
|
||||
FileOutlined,
|
||||
BranchesOutlined,
|
||||
NodeIndexOutlined,
|
||||
CheckCircleOutlined,
|
||||
CloseCircleOutlined,
|
||||
ClockCircleOutlined,
|
||||
} from '@ant-design/icons-vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import {
|
||||
pagePipeline,
|
||||
listPipelineByApp,
|
||||
createPipeline,
|
||||
updatePipeline,
|
||||
deletePipeline,
|
||||
togglePipeline,
|
||||
} from '@/api/app/cicd'
|
||||
import type { AppPipeline } from '@/api/app/cicd'
|
||||
import { getDeveloperApps } from '@/api/app/appProduct'
|
||||
import type { AppProduct } from '@/api/app/appProduct/model'
|
||||
|
||||
definePageMeta({ layout: 'developer' })
|
||||
useHead({ title: '流水线管理 - 开发者中心' })
|
||||
|
||||
const userId = import.meta.client ? localStorage.getItem('UserId') : null
|
||||
|
||||
// 状态
|
||||
const loading = ref(false)
|
||||
const loadingApps = ref(false)
|
||||
const submitting = ref(false)
|
||||
const apps = ref<AppProduct[]>([])
|
||||
const pipelines = ref<AppPipeline[]>([])
|
||||
const selectedAppId = ref<number | undefined>()
|
||||
|
||||
// 弹窗
|
||||
const showPipelineModal = ref(false)
|
||||
const editingPipeline = ref<AppPipeline | null>(null)
|
||||
|
||||
// 表单
|
||||
const form = reactive({
|
||||
name: '',
|
||||
description: '',
|
||||
ciType: 'gitea' as 'gitea' | 'jenkins' | 'github',
|
||||
repoFullName: '',
|
||||
workflowFile: '',
|
||||
env: 'production' as 'development' | 'staging' | 'production',
|
||||
defaultBranch: 'main',
|
||||
stages: '',
|
||||
selectedStages: ['build', 'test', 'deploy'] as string[],
|
||||
timeout: 3600,
|
||||
autoDeploy: false,
|
||||
})
|
||||
|
||||
// ========== 加载数据 ==========
|
||||
|
||||
async function loadApps() {
|
||||
loadingApps.value = true
|
||||
try {
|
||||
const res = await getDeveloperApps({ page: 1, limit: 100, userId: userId ? Number(userId) : undefined })
|
||||
apps.value = (res as any)?.data?.records || res?.list || []
|
||||
} catch (e) {
|
||||
console.error('加载应用列表失败:', e)
|
||||
} finally {
|
||||
loadingApps.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadPipelines() {
|
||||
if (!selectedAppId.value) {
|
||||
pipelines.value = []
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await listPipelineByApp(selectedAppId.value)
|
||||
if (res?.data?.code === 200) {
|
||||
pipelines.value = res.data.data || []
|
||||
} else {
|
||||
pipelines.value = []
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('加载流水线列表失败:', e)
|
||||
pipelines.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleAppChange(appId: number | undefined) {
|
||||
selectedAppId.value = appId
|
||||
if (appId) {
|
||||
loadPipelines()
|
||||
} else {
|
||||
pipelines.value = []
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 操作 ==========
|
||||
|
||||
function handleEdit(pipeline: AppPipeline) {
|
||||
editingPipeline.value = pipeline
|
||||
form.name = pipeline.name || ''
|
||||
form.description = pipeline.description || ''
|
||||
form.ciType = (pipeline.ciType as any) || 'gitea'
|
||||
form.repoFullName = pipeline.repoFullName || ''
|
||||
form.workflowFile = pipeline.workflowFile || ''
|
||||
form.env = (pipeline.env as any) || 'production'
|
||||
form.defaultBranch = pipeline.defaultBranch || 'main'
|
||||
form.selectedStages = pipeline.stages ? pipeline.stages.split(',') : ['build', 'test', 'deploy']
|
||||
form.timeout = pipeline.timeout || 3600
|
||||
form.autoDeploy = pipeline.autoDeploy || false
|
||||
showPipelineModal.value = true
|
||||
}
|
||||
|
||||
async function handleToggle(pipeline: AppPipeline, enabled: boolean) {
|
||||
try {
|
||||
const res = await togglePipeline(pipeline.id!, enabled)
|
||||
if (res?.data?.code === 200) {
|
||||
message.success(enabled ? '流水线已启用' : '流水线已禁用')
|
||||
loadPipelines()
|
||||
} else {
|
||||
message.error(res?.data?.message || '操作失败')
|
||||
}
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(pipeline: AppPipeline) {
|
||||
try {
|
||||
const res = await deletePipeline(pipeline.id!)
|
||||
if (res?.data?.code === 200) {
|
||||
message.success('流水线已删除')
|
||||
loadPipelines()
|
||||
} else {
|
||||
message.error(res?.data?.message || '删除失败')
|
||||
}
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!form.name.trim()) {
|
||||
message.error('请填写流水线名称')
|
||||
return
|
||||
}
|
||||
|
||||
if (!selectedAppId.value) {
|
||||
message.error('请先选择应用')
|
||||
return
|
||||
}
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
const data: Partial<AppPipeline> = {
|
||||
appId: selectedAppId.value,
|
||||
name: form.name,
|
||||
description: form.description,
|
||||
ciType: form.ciType,
|
||||
repoFullName: form.repoFullName,
|
||||
workflowFile: form.workflowFile,
|
||||
env: form.env,
|
||||
defaultBranch: form.defaultBranch,
|
||||
stages: form.selectedStages.join(','),
|
||||
timeout: form.timeout,
|
||||
autoDeploy: form.autoDeploy,
|
||||
}
|
||||
|
||||
let res
|
||||
if (editingPipeline.value) {
|
||||
data.id = editingPipeline.value.id
|
||||
res = await updatePipeline(data)
|
||||
} else {
|
||||
res = await createPipeline(data)
|
||||
}
|
||||
|
||||
if (res?.data?.code === 200) {
|
||||
message.success(editingPipeline.value ? '流水线已更新' : '流水线已创建')
|
||||
showPipelineModal.value = false
|
||||
resetForm()
|
||||
loadPipelines()
|
||||
} else {
|
||||
message.error(res?.data?.message || '操作失败')
|
||||
}
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '操作失败')
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
form.name = ''
|
||||
form.description = ''
|
||||
form.ciType = 'gitea'
|
||||
form.repoFullName = ''
|
||||
form.workflowFile = ''
|
||||
form.env = 'production'
|
||||
form.defaultBranch = 'main'
|
||||
form.selectedStages = ['build', 'test', 'deploy']
|
||||
form.timeout = 3600
|
||||
form.autoDeploy = false
|
||||
editingPipeline.value = null
|
||||
}
|
||||
|
||||
// ========== 辅助函数 ==========
|
||||
|
||||
function ciTypeText(type?: string) {
|
||||
const map: Record<string, string> = {
|
||||
gitea: 'Gitea CI',
|
||||
jenkins: 'Jenkins',
|
||||
github: 'GitHub Actions',
|
||||
}
|
||||
return map[type || ''] || type || 'CI'
|
||||
}
|
||||
|
||||
function stagesText(stages?: string) {
|
||||
if (!stages) return '未知'
|
||||
const map: Record<string, string> = {
|
||||
build: '构建',
|
||||
test: '测试',
|
||||
deploy: '部署',
|
||||
}
|
||||
return stages.split(',').map(s => map[s] || s).join(' → ')
|
||||
}
|
||||
|
||||
function statusText(status?: string) {
|
||||
const map: Record<string, string> = {
|
||||
pending: '排队中',
|
||||
running: '构建中',
|
||||
success: '成功',
|
||||
failed: '失败',
|
||||
cancelled: '已取消',
|
||||
}
|
||||
return map[status || ''] || status || '未知'
|
||||
}
|
||||
|
||||
function formatTime(time?: string) {
|
||||
if (!time) return ''
|
||||
return new Date(time).toLocaleString('zh-CN')
|
||||
}
|
||||
|
||||
// 初始化
|
||||
onMounted(() => {
|
||||
loadApps()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.dev-page {
|
||||
min-height: 100%;
|
||||
padding: 20px 24px 28px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: #1f2937;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.page-desc {
|
||||
font-size: 13px;
|
||||
color: #9ca3af;
|
||||
margin: 4px 0 0;
|
||||
}
|
||||
|
||||
/* 面板 */
|
||||
.panel {
|
||||
background: #fff;
|
||||
border: 1px solid #f0f0f0;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 18px;
|
||||
border-bottom: 1px solid #f5f5f5;
|
||||
}
|
||||
|
||||
.panel-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: rgba(0,0,0,0.85);
|
||||
}
|
||||
|
||||
/* 流水线列表 */
|
||||
.pipeline-list {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.pipeline-item {
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.pipeline-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.pipeline-item:hover {
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.pipeline-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 18px;
|
||||
border-bottom: 1px solid #f5f5f5;
|
||||
}
|
||||
|
||||
.pipeline-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.pipeline-name {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: rgba(0,0,0,0.85);
|
||||
}
|
||||
|
||||
.pipeline-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.pipeline-body {
|
||||
padding: 14px 18px;
|
||||
}
|
||||
|
||||
.pipeline-desc {
|
||||
font-size: 13px;
|
||||
color: rgba(0,0,0,0.65);
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.pipeline-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
font-size: 12px;
|
||||
color: rgba(0,0,0,0.45);
|
||||
}
|
||||
|
||||
.meta-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.pipeline-stats {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
margin-top: 10px;
|
||||
padding-top: 10px;
|
||||
border-top: 1px dashed #f0f0f0;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
font-size: 12px;
|
||||
color: rgba(0,0,0,0.45);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.stat-item.success { color: #52c41a; }
|
||||
.stat-item.failed { color: #ff4d4f; }
|
||||
|
||||
.pipeline-last-build {
|
||||
padding: 8px 18px;
|
||||
background: #f9f9f9;
|
||||
font-size: 12px;
|
||||
color: rgba(0,0,0,0.45);
|
||||
}
|
||||
|
||||
.pipeline-last-build span {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.pipeline-last-build.success span { color: #52c41a; }
|
||||
.pipeline-last-build.failed span { color: #ff4d4f; }
|
||||
.pipeline-last-build.running span { color: #1890ff; }
|
||||
.pipeline-last-build.pending span { color: #faad14; }
|
||||
|
||||
/* 空状态 */
|
||||
.empty-state {
|
||||
padding: 60px 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
font-size: 48px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.empty-title {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: rgba(0,0,0,0.85);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.empty-desc {
|
||||
font-size: 14px;
|
||||
color: rgba(0,0,0,0.45);
|
||||
}
|
||||
|
||||
.py-8 { padding: 40px 0; }
|
||||
.mb-4 { margin-bottom: 16px; }
|
||||
|
||||
.form-hint {
|
||||
font-size: 12px;
|
||||
color: rgba(0,0,0,0.38);
|
||||
margin-top: 4px;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user