Commit 545623b1 by lijiabin

Merge branch '配置测试环境' into feature/21402-【YAYA文化岛】优化点整理

parents b02715a9 98efd42c
import cp from 'node:child_process'
import ssh from 'ssh2'
import archiver from 'archiver'
import fs from 'node:fs'
import path from 'node:path'
import cp from "node:child_process";
import { createHash } from "node:crypto";
import ssh from "ssh2";
import archiver from "archiver";
import fs from "node:fs";
import path from "node:path";
import { getCurrentBranchName, getDeploySlug } from "./util.js";
// 当前分支名
const branchName = getCurrentBranchName();
// 服务器路径名
const deploySlug = getDeploySlug(branchName);
// 最多部署的分支个数
const MAX_DEPLOY_BRANCH_COUNT = 5;
const connectInfo = {
host: "47.119.149.50",
port: "22",
username: "root",
password: "Qdt20250205",
};
const remoteRootPath = "/usr/local/nginx/www/culture";
const remoteBranchPath = `${remoteRootPath}/${deploySlug}`;
const remoteTarPath = `${remoteBranchPath}/dist.tar.gz`;
const remoteMetaPath = `${remoteBranchPath}/.deploy-meta.json`;
const unzipDirMode = {
spawn: 'npm run build:test',
// 解压文件名
unzipDir: 'culture/',
// 终端定位位置 cd
servicePath: '/usr/local/nginx/',
// 压缩包存放位置
serviceFilePath: '/usr/local/nginx/dist.tar.gz',
spawn: "npm run build:test",
servicePath: remoteRootPath,
unzipDir: deploySlug,
serviceFilePath: remoteTarPath,
};
const __dirname = path.resolve();
const distPath = path.resolve(__dirname, "dist");
const zipPath = path.resolve(__dirname, "dist.tar.gz");
const { spawn, servicePath, unzipDir, serviceFilePath } = unzipDirMode;
let conn = null;
function connectServer() {
return new Promise((resolve, reject) => {
conn = new ssh.Client();
conn
.on("ready", () => {
console.log("SSH 连接成功");
resolve(conn);
})
.on("error", (err) => {
reject(err);
})
.connect(connectInfo);
});
}
const __dirname = path.resolve()
// 文件所在地
const distPath = path.resolve(__dirname, 'dist')
// 打包后位置
const zipPath = path.resolve(__dirname, 'dist.tar.gz')
// 远程服务器存放位置
const { spawn, servicePath, serviceFilePath, unzipDir } = unzipDirMode
// 服务器连接信息
const connectInfo = {
host: '47.119.149.50',
port: '22',
username: 'root',
password: 'Qdt20250205',
function execCommand(command) {
return new Promise((resolve, reject) => {
conn.exec(command, { encoding: "utf-8" }, (err, stream) => {
if (err) {
reject(err);
return;
}
let stdout = "";
let stderr = "";
stream.on("data", (data) => {
stdout += data.toString();
});
stream.stderr.on("data", (data) => {
stderr += data.toString();
});
stream.on("close", (code) => {
if (code === 0) {
resolve(stdout);
return;
}
reject(new Error(stderr.trim() || `命令执行失败: ${command}`));
});
});
});
}
// 获取远程服务器已部署的分支文件夹列表
async function getFolderList() {
const result = await execCommand(
`find ${remoteRootPath} -mindepth 1 -maxdepth 1 -type d -printf "%f\\n"`,
);
return result
.split("\n")
.map((item) => item.trim())
.filter(Boolean);
}
// 获取已部署的分支的元信息 中文名 服务器路径名 部署时间
async function getDeployEntries(folderList) {
const entries = await Promise.all(
folderList.map(async (slug) => {
const metaFilePath = `${remoteRootPath}/${slug}/.deploy-meta.json`;
try {
const rawMeta = await execCommand(`cat '${metaFilePath}'`);
const meta = JSON.parse(rawMeta);
return {
slug,
branchName: meta.branchName || slug,
deployedAt: meta.deployedAt || "",
};
} catch {
return {
slug,
branchName: slug,
deployedAt: "",
};
}
}),
);
return entries.sort((a, b) => a.branchName.localeCompare(b.branchName, "zh-CN"));
}
// 写入index.html文件
async function writeIndexHtml(entries) {
// 剔除掉已经部署的
const dedupedEntries = [];
const seenSlugs = new Set();
for (const entry of [{ slug: deploySlug, branchName }, ...entries]) {
if (seenSlugs.has(entry.slug)) {
continue;
}
seenSlugs.add(entry.slug);
dedupedEntries.push(entry);
}
const indexHtml = `<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>culture</title>
<style>
:root {
--bg: linear-gradient(135deg, #f4fbf8 0%, #e9f6ff 100%);
--card-bg: rgba(255, 255, 255, 0.88);
--card-border: rgba(137, 196, 181, 0.24);
--text-main: #16332d;
--text-sub: #5e7b74;
--button-bg: linear-gradient(135deg, #8be2c8 0%, #63d4c5 100%);
--button-shadow: 0 10px 24px rgba(99, 212, 197, 0.22);
--button-shadow-hover: 0 14px 28px rgba(99, 212, 197, 0.3);
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
font-family: "PingFang SC", "Microsoft YaHei", sans-serif;
background: var(--bg);
color: var(--text-main);
}
.page {
width: min(960px, calc(100vw - 32px));
margin: 0 auto;
padding: 40px 0 56px;
}
.hero {
margin-bottom: 24px;
padding: 28px 28px 24px;
border: 1px solid var(--card-border);
border-radius: 24px;
background: var(--card-bg);
backdrop-filter: blur(10px);
box-shadow: 0 20px 50px rgba(87, 143, 130, 0.12);
}
.hero h1 {
margin: 0 0 10px;
font-size: 28px;
line-height: 1.2;
}
.hero p {
margin: 0;
color: var(--text-sub);
font-size: 14px;
line-height: 1.7;
}
.account-panel {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px;
margin-top: 18px;
padding-top: 18px;
border-top: 1px solid rgba(137, 196, 181, 0.18);
}
.account-label {
font-size: 14px;
font-weight: 600;
color: var(--text-main);
}
.account-input {
flex: 1 1 260px;
min-width: 220px;
height: 44px;
padding: 0 14px;
border: 1px solid rgba(108, 180, 163, 0.28);
border-radius: 14px;
background: rgba(255, 255, 255, 0.92);
color: var(--text-main);
font-size: 14px;
outline: none;
transition: border-color 0.2s ease, box-shadow 0.2s ease;
}
.account-input:focus {
border-color: rgba(74, 180, 152, 0.8);
box-shadow: 0 0 0 4px rgba(116, 211, 187, 0.14);
}
.account-tip {
width: 100%;
margin: 0;
color: var(--text-sub);
font-size: 12px;
line-height: 1.6;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 16px;
}
.branch-card {
padding: 18px;
border: 1px solid rgba(137, 196, 181, 0.2);
border-radius: 20px;
background: rgba(255, 255, 255, 0.82);
box-shadow: 0 14px 30px rgba(126, 178, 167, 0.1);
}
.branch-name {
margin: 0 0 12px;
font-size: 16px;
font-weight: 600;
line-height: 1.5;
word-break: break-word;
}
.branch-slug {
margin: 0 0 16px;
color: var(--text-sub);
font-size: 12px;
line-height: 1.6;
word-break: break-all;
}
.branch-button {
width: 100%;
border: 0;
border-radius: 14px;
padding: 12px 16px;
background: var(--button-bg);
color: #13312a;
font-size: 14px;
font-weight: 600;
cursor: pointer;
box-shadow: var(--button-shadow);
transition: transform 0.2s ease, box-shadow 0.2s ease, filter 0.2s ease;
}
.branch-button:hover {
transform: translateY(-2px);
box-shadow: var(--button-shadow-hover);
filter: saturate(1.05);
}
.branch-button:active {
transform: translateY(0);
}
@media (max-width: 640px) {
.page {
width: min(100vw - 20px, 960px);
padding-top: 20px;
}
.hero {
padding: 22px 18px 18px;
border-radius: 18px;
}
.hero h1 {
font-size: 24px;
}
}
</style>
</head>
<body>
<div class="page">
<section class="hero">
<h1>Culture 分支导航</h1>
<p>这里展示当前可访问的测试分支,点击卡片按钮即可进入对应环境。</p>
<div class="account-panel">
<label class="account-label" for="accountInput">当前账号</label>
<input
id="accountInput"
class="account-input"
type="text"
placeholder="请输入账号"
/>
<p class="account-tip">账号会自动保存在浏览器本地,后续点击任意分支都会带上当前输入的 code 参数。</p>
</div>
</section>
<div class="grid">
${dedupedEntries
.map(
(item) => `
<section class="branch-card">
<h2 class="branch-name">分支「${item.branchName}」</h2>
<p class="branch-slug">访问路径:/${item.slug}/</p>
<button class="branch-button" onclick="goToBranch('${item.slug}')">
进入当前分支
</button>
</section>`,
)
.join("\n")}
</div>
</div>
<script>
const STORAGE_KEY = 'culture_account_code'
const DEFAULT_ACCOUNT = 'lijiabin'
const accountInput = document.getElementById('accountInput')
function getAccountCode() {
const storedValue = window.localStorage.getItem(STORAGE_KEY)
return storedValue && storedValue.trim() ? storedValue.trim() : DEFAULT_ACCOUNT
}
function setAccountCode(value) {
const finalValue = value && value.trim() ? value.trim() : DEFAULT_ACCOUNT
window.localStorage.setItem(STORAGE_KEY, finalValue)
if (accountInput && accountInput.value !== finalValue) {
accountInput.value = finalValue
}
return finalValue
}
function goToBranch(slug) {
const code = encodeURIComponent(getAccountCode())
window.location.href = '/' + slug + '/?code=' + code
}
if (accountInput) {
accountInput.value = getAccountCode()
accountInput.addEventListener('input', (event) => {
setAccountCode(event.target.value)
})
accountInput.addEventListener('blur', (event) => {
setAccountCode(event.target.value)
})
}
setAccountCode(getAccountCode())
</script>
</body>
</html>
`;
return new Promise((resolve, reject) => {
conn.sftp((err, sftp) => {
if (err) {
reject(new Error(`SFTP Error: ${err.message}`));
return;
}
const writeStream = sftp.createWriteStream(`${remoteRootPath}/index.html`, {
encoding: "utf8",
});
writeStream.on("error", (error) => {
sftp.end();
reject(new Error(`写入失败: ${error.message}`));
});
writeStream.on("close", () => {
console.log("index.html 写入成功");
sftp.end();
resolve();
});
writeStream.end(indexHtml);
});
});
}
// 写入部署元信息
async function writeDeployMeta() {
const meta = {
branchName,
deploySlug,
deployedAt: new Date().toLocaleString("zh-CN", {
hour12: false,
timeZone: "Asia/Shanghai",
}),
};
const metaContent = JSON.stringify(meta, null, 2);
await execCommand(`cat <<'EOF' > '${remoteMetaPath}'
${metaContent}
EOF`);
}
//链接服务器
let conn = new ssh.Client()
async function start() {
try {
// 1. 打包
await build()
// 2. 压缩zip
await startZip()
// 3. 将zip文件传输至远程服务器
await connect()
// 4. 部署解压
await shellCmd(conn)
console.log('部署完成')
await connectServer();
const folderList = await getFolderList();
const deployEntries = await getDeployEntries(folderList);
const alreadyDeployed = deployEntries.some((item) => item.slug === deploySlug);
if (!alreadyDeployed && deployEntries.length >= MAX_DEPLOY_BRANCH_COUNT) {
console.log(
`当前已部署分支数量已达到上限 ${MAX_DEPLOY_BRANCH_COUNT},请先手动删除以下分支中的一个或多个:`,
);
deployEntries.forEach((item, index) => {
console.log(`${index + 1}. ${item.branchName} (${item.slug})`);
});
return;
}
await writeIndexHtml(deployEntries);
await build();
await startZip();
await uploadTar();
await shellCmd();
await writeDeployMeta();
console.log(`部署完成,当前分支「${branchName}」对应目录:${deploySlug}`);
} catch (error) {
console.error('Error:', error.message)
console.error("Error:", error.message);
} finally {
// 5. 断开ssh,并删除本地压缩包
conn.end()
delZip()
if (conn) {
conn.end();
}
delZip();
}
}
start()
/**
* 1. 本地构建项目
*/
start();
function build() {
return new Promise((resolve, reject) => {
//对项目进行打包,然后生成压缩文件
let pro = cp.spawn(spawn, {
const pro = cp.spawn(spawn, {
shell: true,
stdio: 'inherit',
})
pro.on('exit', (code) => {
//打包完成后 开始链接目标服务器,并自动部署
stdio: "inherit",
});
pro.on("exit", (code) => {
if (code === 0) {
console.log('---构建成功---')
resolve()
} else {
reject(new Error('构建失败'))
console.log("---构建成功---");
resolve();
return;
}
})
})
reject(new Error("构建失败"));
});
});
}
/**
* 2. 将打包后文件压缩zip
* @returns
*/
function startZip() {
return new Promise((resolve, reject) => {
console.log('开始打包tar')
//定义打包格式和相关配置
const archive = archiver('tar', {
gzip: true, // 如果需要压缩,可以使用 gzip
gzipOptions: { level: 9 }, // gzip 压缩级别
}).on('error', (err) => reject(err))
console.log('zipPath', zipPath)
const output = fs.createWriteStream(zipPath)
//监听流的打包
output.on('close', (err) => {
console.log('err', err)
console.log('目标打包完成')
resolve(true)
})
//开始压缩
archive.pipe(output)
// 文件夹压缩
archive.directory(distPath, false)
archive.finalize()
})
}
console.log("开始打包 tar");
/**
* 3. 将zip文件传输至远程服务器
*/
function connect() {
return new Promise((resolve, reject) => {
conn
.on('ready', () => {
conn.sftp((err, sftp) => {
if (err) {
return reject(err)
}
sftp.fastPut(zipPath, serviceFilePath, {}, (err) => {
if (err) {
return reject(err)
}
//开始上传
console.log('文件上传成功')
resolve()
})
})
})
.on('error', (err) => reject(err))
.connect(connectInfo)
})
const archive = archiver("tar", {
gzip: true,
gzipOptions: { level: 9 },
});
archive.on("error", (err) => {
reject(err);
});
console.log("zipPath", zipPath);
const output = fs.createWriteStream(zipPath);
output.on("close", () => {
console.log("目标打包完成");
resolve(true);
});
output.on("error", (err) => {
reject(err);
});
archive.pipe(output);
archive.directory(distPath, false);
archive.finalize();
});
}
/**
* 4. 解压部署操作
* @param {*} conn
*/
async function shellCmd(conn) {
async function uploadTar() {
await execCommand(`mkdir -p ${servicePath}/${unzipDir}`);
return new Promise((resolve, reject) => {
conn.shell((err, stream) => {
conn.sftp((err, sftp) => {
if (err) {
return reject(err)
reject(err);
return;
}
//进入服务器暂存地址
//解压上传的压缩包
//移动解压后的文件到发布目录
//删除压缩包
//退出
const commands = `
cd ${servicePath} &&
mkdir -p ${unzipDir} &&
tar -xzf dist.tar.gz -C ${unzipDir} &&
rm -rf dist.tar.gz &&
exit
`
console.log('终端执行命令:', commands)
stream
.on('close', () => resolve())
.on('data', (data) => console.log(data.toString()))
.stderr.on('data', (data) => console.error('Error:', data.toString()))
stream.end(commands)
})
})
sftp.fastPut(zipPath, serviceFilePath, {}, (putErr) => {
sftp.end();
if (putErr) {
reject(putErr);
return;
}
console.log("文件上传成功");
resolve();
});
});
});
}
async function shellCmd() {
const command = `cd ${remoteBranchPath} && tar -xzf dist.tar.gz && rm -f dist.tar.gz`;
console.log("终端执行命令:", command);
await execCommand(command);
}
/**
* 5. 删除本地的dist.zip
*/
function delZip() {
fs.unlink(zipPath, function (err) {
if (!fs.existsSync(zipPath)) {
return;
}
fs.unlink(zipPath, (err) => {
if (err) {
console.error('删除文件失败:', err.message)
console.error("删除文件失败:", err.message);
return;
}
console.log('文件:' + zipPath + '删除成功!')
})
console.log(`文件: ${zipPath} 删除成功!`);
});
}
import { execSync } from 'node:child_process'
import { createHash } from 'node:crypto'
// 获取当前分支名 去掉空格 斜杠 换行 等特殊字符
export const getCurrentBranchName = () => {
const branchName = execSync('git branch --show-current', {
encoding: 'utf-8',
})
.trim()
.replaceAll(' ', '_')
.replaceAll('/', '_')
.replaceAll('\n', '_')
return branchName
}
// 根据分支名创建一个唯一的服务器文件夹名字
export const getDeploySlug = (branchName) => {
return `branch-${createHash('md5').update(branchName).digest('hex').slice(0, 8)}`
}
......@@ -17,6 +17,7 @@ export const app_config: { [key: string]: IConfig } = {
// 测试环境 暂时无测试环境部署
test: {
// baseUrl: 'http://culture.yswg.com.cn:8089', // 线上测试机
baseUrl: 'http://192.168.2.55:8089', // 首拥本地
loginType: 1,
wxRedirect: '',
......
......@@ -15,10 +15,12 @@ import Icons from 'unplugin-icons/vite'
import path from 'node:path'
// 获取到执行脚本的--参数
import pkg from './package.json'
// @ts-ignore
import { getCurrentBranchName, getDeploySlug } from './deploy/util.js'
// https://vite.dev/config/
export default defineConfig(({ mode }) => {
return {
base: mode === 'test' ? `/${getDeploySlug(getCurrentBranchName())}/` : '/',
define: {
__CORE_LIB_VERSION__: JSON.stringify(pkg.dependencies),
},
......@@ -62,7 +64,7 @@ export default defineConfig(({ mode }) => {
symbolId: 'icon-[dir]-[name]',
}),
mode === 'development' && visualizer(), // 开发环境打包才需要
pushUpdatePlugin(),
mode === 'production' && pushUpdatePlugin(), // 生产环境打包才更新推送
],
server: {
// 是否开启 https
......@@ -80,7 +82,7 @@ export default defineConfig(({ mode }) => {
// },
},
build: {
minify: 'esbuild',
minify: 'esbuild', // 'esbuild'
chunkSizeWarningLimit: 1000,
rollupOptions: {
output: {
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment