ci: 添加 Epic UI 构建和部署工作流

- 新增 CI/CD 工作流文件,实现前端项目的自动构建和部署
- 支持 main、master 和 develop 分支的自动构建- 包含代码检出、环境安装、依赖管理、项目构建等步骤
- 实现构建产物的自动部署和 Docker 容器重启
This commit is contained in:
hu xiaotong
2025-07-09 11:27:16 +08:00
parent 2a898c9bcc
commit b391ee88d4
4 changed files with 86 additions and 438 deletions

View File

@@ -2,9 +2,9 @@ kind: pipeline
type: docker type: docker
name: default name: default
#trigger: trigger:
# event: event:
# - manual - manual
steps: steps:
- name: build - name: build

View File

@@ -1,314 +0,0 @@
name: Epic UI Build & Deploy
run-name: ${{ gitea.actor }} 正在构建 Epic UI 前端项目 🚀
on:
push:
branches: [ main, master, develop ]
pull_request:
branches: [ main, master ]
jobs:
build:
runs-on: gitea_labels
container:
image: gitea-ci-bash:latest
env:
# 指定容器将工具缓存路径存放到 /opt/hostedtoolcache该目录是Gitea Runner的标准工具缓存目录
RUNNER_TOOL_CACHE: /opt/hostedtoolcache
volumes:
# 直接挂载到指定的宿主机路径
# 挂载生产环境目录
- /opt/1panel/apps/openresty/openresty/www/sites/epic7/index:/opt/1panel/apps/openresty/openresty/www/sites/epic7/index
steps:
- name: 检出代码
shell: bash
run: |
echo "📥 检出代码到工作目录..."
# 解析分支名
BRANCH_NAME=$(echo "${{ gitea.ref }}" | sed 's#refs/heads/##')
# 拉取代码使用token鉴权
git clone --depth=1 -b "$BRANCH_NAME" "http://1c18ee1ab9a9cb291506d0c5c016a33be7d59e8c:x-oauth-basic@gitea.htoop.cn/${{ gitea.repository }}.git" .
echo "✅ 代码检出成功"
- name: 安装Node.js环境
shell: bash
run: |
echo "🔧 安装Node.js环境..."
if command -v node &> /dev/null; then
echo "✅ Node.js已安装: $(node --version)"
else
echo "📥 下载并安装Node.js..."
NODE_VERSION="18.19.0"
NODE_ARCH="linux-x64"
MIRRORS=(
"https://mirrors.aliyun.com/nodejs-release/v${NODE_VERSION}/node-v${NODE_VERSION}-${NODE_ARCH}.tar.xz"
"https://nodejs.org/dist/v${NODE_VERSION}/node-v${NODE_VERSION}-${NODE_ARCH}.tar.xz"
)
DOWNLOAD_SUCCESS=false
for mirror in "${MIRRORS[@]}"; do
echo "尝试从镜像下载: $mirror"
if wget -q --timeout=30 --tries=3 "$mirror" -O node.tar.xz; then
echo "✅ 下载成功: $mirror"
DOWNLOAD_SUCCESS=true
break
else
echo "❌ 下载失败: $mirror"
continue
fi
done
if [ "$DOWNLOAD_SUCCESS" = false ]; then
echo "❌ 所有镜像源下载失败"
exit 1
fi
echo "解压Node.js到/usr/local..."
tar -C /usr/local -xJf node.tar.xz --strip-components=1
export PATH=$PATH:/usr/local/bin
rm node.tar.xz
echo "✅ Node.js安装完成"
fi
- name: 安装pnpm
shell: bash
run: |
echo "📦 安装pnpm包管理器..."
export PATH=$PATH:/usr/local/bin
if command -v pnpm &> /dev/null; then
echo "✅ pnpm已安装: $(pnpm --version)"
else
echo "📥 安装pnpm..."
npm install -g pnpm
echo "✅ pnpm安装完成: $(pnpm --version)"
fi
- name: 配置pnpm缓存
shell: bash
run: |
echo "📦 配置pnpm缓存..."
export PATH=$PATH:/usr/local/bin
# 配置pnpm缓存目录
PNPM_STORE_DIR="/opt/hostedtoolcache/pnpm-store"
mkdir -p "$PNPM_STORE_DIR"
# 设置pnpm使用缓存目录
pnpm config set store-dir "$PNPM_STORE_DIR"
pnpm config set cache-dir "/opt/hostedtoolcache/pnpm-cache"
echo "✅ pnpm缓存配置完成"
- name: 恢复依赖缓存
shell: bash
run: |
echo "📦 检查并恢复依赖缓存..."
export PATH=$PATH:/usr/local/bin
# 生成缓存键
CACHE_KEY=$(md5sum pnpm-lock.yaml | cut -d' ' -f1)
echo "缓存键: $CACHE_KEY"
# 使用 /opt/hostedtoolcache 目录
CACHE_FILE="/opt/hostedtoolcache/node_modules_${CACHE_KEY}.tar.gz"
echo "📁 检查缓存文件: $CACHE_FILE"
if [ -f "$CACHE_FILE" ]; then
echo "✅ 找到缓存文件: $CACHE_FILE"
echo "📦 缓存文件大小: $(du -sh "$CACHE_FILE" | cut -f1)"
echo "正在恢复缓存..."
if tar -xzf "$CACHE_FILE"; then
echo "✅ 缓存恢复成功"
echo "📦 恢复的 node_modules 大小:"
du -sh node_modules 2>/dev/null || echo "node_modules 目录不存在"
else
echo "❌ 缓存恢复失败"
echo "📥 将重新安装依赖"
fi
else
echo "📥 未找到缓存文件,将重新安装依赖"
fi
- name: 安装项目依赖
shell: bash
run: |
echo "📦 安装项目依赖..."
export PATH=$PATH:/usr/local/bin
# 检查是否已有node_modules
if [ -d "node_modules" ] && [ -f "node_modules/.pnpm-debug.log" ]; then
echo "✅ 检测到已存在的依赖,跳过安装"
else
echo "📥 安装项目依赖..."
pnpm install --frozen-lockfile --prefer-offline
echo "✅ 依赖安装完成"
fi
- name: 保存依赖缓存
shell: bash
run: |
echo "💾 保存依赖缓存..."
export PATH=$PATH:/usr/local/bin
# 生成缓存键
CACHE_KEY=$(md5sum pnpm-lock.yaml | cut -d' ' -f1)
echo "缓存键: $CACHE_KEY"
# 检查node_modules是否存在
if [ ! -d "node_modules" ]; then
echo "❌ node_modules 目录不存在,跳过缓存保存"
exit 0
fi
# 检查缓存文件是否已存在且是最新的
CACHE_FILE="/opt/hostedtoolcache/node_modules_${CACHE_KEY}.tar.gz"
if [ -f "$CACHE_FILE" ]; then
echo "✅ 缓存文件已存在且是最新的,跳过创建: $CACHE_FILE"
echo "📦 现有缓存文件大小: $(du -sh "$CACHE_FILE" | cut -f1)"
exit 0
fi
# 缓存文件不存在,需要创建
echo "📦 正在创建缓存文件: $CACHE_FILE"
if tar -czf "$CACHE_FILE" node_modules; then
echo "✅ 缓存已保存到: $CACHE_FILE"
echo "📦 缓存文件大小: $(du -sh "$CACHE_FILE" | cut -f1)"
else
echo "❌ 缓存保存失败"
exit 1
fi
- name: 构建项目
shell: bash
run: |
echo "🔨 构建 Epic UI 前端项目..."
export PATH=$PATH:/usr/local/bin
# 执行构建
pnpm build
echo "✅ 构建完成"
- name: 检查构建产物
shell: bash
run: |
echo "📦 构建产物信息:"
if [ -d "dist" ]; then
echo "✅ 找到dist目录"
ls -la dist/
echo "dist目录大小: $(du -sh dist | cut -f1)"
else
echo "❌ 未找到dist目录"
exit 1
fi
- name: 显示项目信息
shell: bash
run: |
echo "📋 项目信息:"
export PATH=$PATH:/usr/local/bin
echo "Node.js 版本: $(node --version)"
echo "npm 版本: $(npm --version)"
echo "pnpm 版本: $(pnpm --version)"
echo "构建时间: $(date)"
echo "分支: ${{ gitea.ref }}"
echo "提交: ${{ gitea.sha }}"
- name: 部署到生产环境
shell: bash
run: |
echo "🚀 部署到生产环境..."
# 生产环境目录容器内路径直接映射到nginx静态文件目录
PROD_DIR="/opt/1panel/apps/openresty/openresty/www/sites/epic7/index"
# 检查构建产物
if [ ! -d "dist" ]; then
echo "❌ 构建产物不存在"
exit 1
fi
echo "📦 构建产物内容:"
ls -la dist/
# 检查生产目录挂载
echo "📁 检查生产目录挂载状态..."
if [ -d "$PROD_DIR" ]; then
echo "✅ 生产目录已存在: $PROD_DIR"
echo "📁 当前生产目录内容:"
ls -la "$PROD_DIR" 2>/dev/null || echo "目录为空或无法访问"
else
echo "📁 生产目录不存在,将创建: $PROD_DIR"
fi
# 备份当前生产环境
if [ -d "$PROD_DIR" ] && [ "$(ls -A "$PROD_DIR" 2>/dev/null)" ]; then
BACKUP_DIR="/opt/prod_backup_$(date +%Y%m%d_%H%M%S)"
echo "📦 备份当前生产环境到: $BACKUP_DIR"
cp -r "$PROD_DIR" "$BACKUP_DIR"
fi
# 确保生产目录存在并清空
echo "📤 部署构建产物到nginx静态文件目录..."
mkdir -p "$PROD_DIR"
rm -rf "$PROD_DIR"/*
# 复制构建产物到生产环境
echo "📦 复制构建产物..."
if cp -r dist/* "$PROD_DIR/"; then
echo "✅ 构建产物复制成功"
else
echo "❌ 构建产物复制失败"
echo "📁 检查目标目录权限和空间..."
df -h "$PROD_DIR" 2>/dev/null || echo "无法检查磁盘空间"
ls -ld "$PROD_DIR" 2>/dev/null || echo "无法检查目录权限"
exit 1
fi
# 设置权限
chmod -R 755 "$PROD_DIR"
# 强制同步文件系统 - 确保文件写入到宿主机
echo "🔄 同步文件系统..."
sync
# 等待文件系统同步完成
echo "⏳ 等待文件系统同步..."
sleep 3
# 再次强制同步
sync
# 验证文件是否真的写入到宿主机
echo "🔍 验证文件同步状态..."
if [ -f "$PROD_DIR/index.html" ]; then
echo "✅ 确认index.html已同步到宿主机"
echo "📋 文件大小: $(ls -lh "$PROD_DIR/index.html" | awk '{print $5}')"
echo "📋 文件时间: $(ls -l "$PROD_DIR/index.html" | awk '{print $6, $7, $8}')"
else
echo "❌ index.html未同步到宿主机"
exit 1
fi
echo "✅ 部署完成"
echo "📁 生产环境目录: $PROD_DIR (直接映射到宿主机)"
echo "📦 部署的文件:"
ls -la "$PROD_DIR"
# 验证部署结果
echo "🔍 验证部署结果..."
echo "📋 文件数量: $(find "$PROD_DIR" -type f | wc -l)"
echo "📋 目录数量: $(find "$PROD_DIR" -type d | wc -l)"
echo "📋 总大小: $(du -sh "$PROD_DIR" | cut -f1)"
# 检查关键文件
if [ -f "$PROD_DIR/index.html" ]; then
echo "✅ index.html 存在"
echo "📋 index.html 大小: $(ls -lh "$PROD_DIR/index.html" | awk '{print $5}')"
else
echo "❌ index.html 不存在"
fi

5
.npmrc
View File

@@ -1,5 +0,0 @@
# pnpm 性能优化配置
registry=https://registry.npmmirror.com/
prefer-offline=true
auto-install-peers=true
shamefully-hoist=true

View File

@@ -2,7 +2,7 @@
import React, {useEffect, useState} from "react"; import React, {useEffect, useState} from "react";
import {useNavigate} from 'react-router-dom'; import {useNavigate} from 'react-router-dom';
import {Select} from 'antd'; import {Input} from 'antd';
import type {Hero} from '@/api/index'; import type {Hero} from '@/api/index';
import * as EpicApi from '@/api/index'; import * as EpicApi from '@/api/index';
@@ -14,60 +14,31 @@ type ExtendedHero = Hero & {
}; };
// 自定义样式覆盖 Ant Design 默认样式 // 自定义样式覆盖 Ant Design 默认样式
const selectStyles = ` const inputStyles = `
.ant-select { .ant-input, .ant-input-affix-wrapper, .ant-input-affix-wrapper input {
background-color: #1A1412 !important; background-color: #1A1412 !important;
border-color: #C17F59 !important; border-color: rgba(193, 127, 89, 0.3) !important;
color: #E6B17E !important; color: #E6B17E !important;
} border-radius: 0.5rem !important;
box-shadow: none !important;
.ant-select .ant-select-selector { }
background-color: #1A1412 !important; .ant-input:focus, .ant-input-affix-wrapper:focus, .ant-input-affix-wrapper-focused, .ant-input:active {
border-color: #C17F59 !important; background-color: #1A1412 !important;
color: #E6B17E !important; border-color: #C17F59 !important;
} color: #E6B17E !important;
box-shadow: 0 0 0 2px rgba(193, 127, 89, 0.2) !important;
.ant-select-focused .ant-select-selector { }
border-color: #C17F59 !important; .ant-input::placeholder, .ant-input-affix-wrapper input::placeholder {
box-shadow: 0 0 0 2px rgba(193, 127, 89, 0.2) !important; color: #9B8579 !important;
} opacity: 1 !important;
}
.ant-select-dropdown { .ant-input-clear-icon {
background-color: #1A1412 !important; color: #E6B17E !important;
border-color: #C17F59 !important; background: transparent !important;
} }
.ant-input-clear-icon:hover {
.ant-select-item { color: #C17F59 !important;
background-color: #1A1412 !important; }
color: #E6B17E !important;
}
.ant-select-item-option-selected {
background-color: #2A211E !important;
color: #E6B17E !important;
}
.ant-select-item-option-active {
background-color: #2A211E !important;
color: #E6B17E !important;
}
.ant-select-selection-placeholder {
color: #9B8579 !important;
}
.ant-select-selection-item {
color: #E6B17E !important;
}
.ant-select-arrow {
color: #E6B17E !important;
}
.ant-select-clear {
background-color: #1A1412 !important;
color: #E6B17E !important;
}
`; `;
const ELEMENTS = [ const ELEMENTS = [
@@ -157,15 +128,15 @@ const Characters: React.FC = () => {
return <img src={`/pic/role/${role}.png`} alt={role} className="w-7 h-7" />; return <img src={`/pic/role/${role}.png`} alt={role} className="w-7 h-7" />;
}; };
// 处理英雄选择 // 处理搜索输入
const handleHeroSelect = (value: string) => { const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setSearchTerm(value); setSearchTerm(e.target.value);
}; };
return ( return (
<div className="min-h-screen bg-[#1A1412] text-white font-sans relative"> <div className="min-h-screen bg-[#1A1412] text-white font-sans relative">
{/* 注入自定义样式 */} {/* 注入自定义样式 */}
<style dangerouslySetInnerHTML={{ __html: selectStyles }} /> <style dangerouslySetInnerHTML={{ __html: inputStyles }} />
{/* 背景装饰 */} {/* 背景装饰 */}
<div className="absolute inset-0 bg-gradient-to-b from-[#1A1412] via-[#2A211E] to-[#1A1412]"></div> <div className="absolute inset-0 bg-gradient-to-b from-[#1A1412] via-[#2A211E] to-[#1A1412]"></div>
@@ -179,26 +150,11 @@ const Characters: React.FC = () => {
{/* 搜索组件 */} {/* 搜索组件 */}
<div className="bg-gradient-to-br from-[#2A211E] to-[#1A1412] p-4 rounded-lg mb-8 border border-[#C17F59]/30 backdrop-blur-sm"> <div className="bg-gradient-to-br from-[#2A211E] to-[#1A1412] p-4 rounded-lg mb-8 border border-[#C17F59]/30 backdrop-blur-sm">
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-4"> <div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-4">
<Select <Input
showSearch placeholder="搜索英雄名称或昵称"
placeholder="选择英雄名称/昵称" value={searchTerm}
value={searchTerm || undefined} onChange={handleSearchChange}
onChange={handleHeroSelect} allowClear
onSearch={setSearchTerm}
filterOption={(input, option) => {
const heroName = option?.value as string;
return heroName.toLowerCase().includes(input.toLowerCase());
}}
options={allHeroes.map(hero => ({
value: hero.heroName,
label: (
<div className="flex items-center gap-2">
<img src={hero.headImgUrl} alt={hero.heroName} className="w-6 h-6 rounded-full" />
<span>{hero.heroName}</span>
{hero.nickName && <span className="text-[#9B8579] text-sm">({hero.nickName})</span>}
</div>
)
}))}
className="w-full" className="w-full"
/> />
</div> </div>
@@ -246,46 +202,57 @@ const Characters: React.FC = () => {
</div> </div>
</div> </div>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-2 lg:grid-cols-4 gap-6"> {filteredHeroes.length > 0 ? (
{filteredHeroes.map((hero) => ( <div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-2 lg:grid-cols-4 gap-6">
<div {filteredHeroes.map((hero) => (
key={hero.heroCode} <div
className="flex items-center bg-[#23201B] rounded-xl border border-[#C17F59]/30 hover:border-[#C17F59] transition-all duration-300 shadow-md px-4 py-3 min-h-[96px] max-h-[96px] cursor-pointer backdrop-blur-sm" key={hero.heroCode}
onClick={() => navigate(`/character/${hero.heroCode}`)} className="flex items-center bg-[#23201B] rounded-xl border border-[#C17F59]/30 hover:border-[#C17F59] transition-all duration-300 shadow-md px-4 py-3 min-h-[96px] max-h-[96px] cursor-pointer backdrop-blur-sm"
> onClick={() => navigate(`/character/${hero.heroCode}`)}
{/* 头像+attr */} >
<div className="relative mr-4 flex-shrink-0"> {/* 头像+attr */}
<img <div className="relative mr-4 flex-shrink-0">
src={hero.headImgUrl} <img
alt={hero.heroName} src={hero.headImgUrl}
className="w-16 h-16 rounded-full border-2 border-[#C17F59] object-cover object-center" alt={hero.heroName}
/> className="w-16 h-16 rounded-full border-2 border-[#C17F59] object-cover object-center"
<div className="absolute -top-2 -right-2"> />
{getElementIcon(hero.attribute)} <div className="absolute -top-2 -right-2">
</div> {getElementIcon(hero.attribute)}
</div> </div>
{/* 右侧信息 */} </div>
<div className="flex flex-col justify-center flex-1 min-w-0"> {/* 右侧信息 */}
<div className="flex items-center mb-1"> <div className="flex flex-col justify-center flex-1 min-w-0">
<span className="text-[16px] font-bold text-[#E6B17E]">{hero.heroName}</span> <div className="flex items-center mb-1">
</div> <span className="text-[16px] font-bold text-[#E6B17E]">{hero.heroName}</span>
<div className="flex items-center mb-1"> </div>
{renderStars(hero.stars)} <div className="flex items-center mb-1">
</div> {renderStars(hero.stars)}
<div className="flex items-center text-[#C17F59] text-base font-medium"> </div>
{hero.role && ( <div className="flex items-center text-[#C17F59] text-base font-medium">
<> {hero.role && (
<span className="inline-flex items-center justify-center w-8 h-8 rounded-full bg-[#E6B17E] mr-2"> <>
<img src={`/pic/role/${hero.role}.png`} alt={hero.role} className="w-6 h-6" /> <span className="inline-flex items-center justify-center w-8 h-8 rounded-full bg-[#E6B17E] mr-2">
</span> <img src={`/pic/role/${hero.role}.png`} alt={hero.role} className="w-6 h-6" />
<span className="align-middle">{ROLE_LABELS[hero.role] || hero.role}</span> </span>
</> <span className="align-middle">{ROLE_LABELS[hero.role] || hero.role}</span>
)} </>
)}
</div>
</div> </div>
</div> </div>
))}
</div>
) : (
<div className="flex flex-col items-center justify-center py-16">
<div className="text-[#9B8579] text-xl font-medium mb-4">
</div> </div>
))} <div className="text-[#7A6B5F] text-sm">
</div>
</div>
</div>
)}
</div> </div>
</div> </div>
); );