v0.0.2: Java应用模板、任务中心占位符修复、端口映射修复、WFJ Panel品牌统一

This commit is contained in:
2026-09-08 12:00:49 +08:00
parent 90bc4bd398
commit c98716dd7b
8 changed files with 948 additions and 13 deletions
+27 -5
View File
@@ -260,9 +260,14 @@ function execDeploy(cmd, timeoutMs = 30000) {
let settled = false
let stdout = ''
let stderr = ''
let streamRef = null
const timer = setTimeout(() => {
if (!settled) {
settled = true
// 销毁 stream,防止 channel 泄漏和远程进程继续运行
if (streamRef) {
try { streamRef.destroy() } catch (_) {}
}
resolve({ ok: false, message: '命令执行超时' })
}
}, timeoutMs)
@@ -276,6 +281,12 @@ function execDeploy(cmd, timeoutMs = 30000) {
}
return
}
streamRef = stream
// 如果已超时,立即销毁新拿到的 stream
if (settled) {
try { stream.destroy() } catch (_) {}
return
}
stream.on('data', (chunk) => (stdout += chunk.toString()))
stream.stderr.on('data', (chunk) => (stderr += chunk.toString()))
stream.on('close', (code) => {
@@ -895,11 +906,21 @@ ipcMain.handle('docker:exec', (event, { cmd, timeout }) => execDocker(cmd, timeo
// 部署步骤:1.拉取镜像 2.创建目录 3.写入配置 4.创建容器
ipcMain.handle('docker:deployApp', async (event, { deploy, paramValues }) => {
// 参数占位符替换函数(提前定义,用于 appName 等字段)
function tplEarly(str) {
if (!str || typeof str !== 'string') return str
let result = str
for (const [key, val] of Object.entries(paramValues || {})) {
result = result.replace(new RegExp(`\\{\\{${key}\\}\\}`, 'g'), val)
}
return result
}
// 创建任务对象
const taskId = ++taskCounter
const task = {
id: taskId,
appName: deploy.containerName || 'unknown',
appName: tplEarly(deploy.containerName) || deploy.appName || 'unknown',
status: 'pending',
steps: [],
logs: [],
@@ -964,8 +985,8 @@ async function runDeployTask(task, deploy, paramValues) {
const imageName = tpl(deploy.image)
setTaskStep(task, 'pull', `正在拉取镜像 ${imageName}...`)
// 拉取镜像可能耗时较长,超时设为 5 分钟
const pullResult = await execDeploy(`docker pull ${imageName}`, 300000)
// 拉取镜像可能耗时较长(国内镜像加速也常超 5 分钟),超时设为 10 分钟
const pullResult = await execDeploy(`docker pull ${imageName}`, 600000)
if (!pullResult.ok || (pullResult.code !== 0 && !pullResult.stdout.includes('Status: Image is up to date') && !pullResult.stdout.includes('Already exists'))) {
const errMsg = pullResult.stderr || pullResult.message || '未知错误'
setTaskStep(task, 'pull-error', `镜像拉取失败: ${errMsg}`)
@@ -1027,9 +1048,10 @@ async function runDeployTask(task, deploy, paramValues) {
// 端口映射
for (const port of (deploy.ports || [])) {
const hostPort = tpl(port.host)
if (!port.container) continue
const containerPort = tpl(port.container)
if (!containerPort) continue
let p = hostPort ? `${hostPort}:` : ''
p += port.container
p += containerPort
if (port.protocol && port.protocol !== 'tcp') p += '/' + port.protocol
parts.push('-p', p)
}
+1 -1
View File
@@ -2,7 +2,7 @@
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<title>SSH Panel · 服务器管理面板</title>
<title>WFJ Panel · 服务器管理面板</title>
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self' ws:"
@@ -80,6 +80,11 @@
color: #6366f1;
}
.app-icon-java {
background: rgba(237, 117, 23, 0.08);
color: #ed7517;
}
/* ---- 应用卡片信息 ---- */
.app-card-info {
flex: 1;
@@ -853,3 +858,160 @@
color: var(--status-critical);
background: rgba(239, 68, 68, 0.06);
}
/* ============================================================
远程目录选择器
============================================================ */
.path-input-wrapper {
display: flex;
gap: 6px;
flex: 1;
}
.path-input-field {
flex: 1;
}
.path-browse-btn {
height: 36px;
padding: 0 10px;
font-size: 12px;
display: inline-flex;
align-items: center;
gap: 4px;
flex-shrink: 0;
}
.path-picker-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1400;
}
.path-picker-dialog {
background: var(--card-bg);
border-radius: var(--radius-card);
box-shadow: var(--shadow-overlay);
width: 90%;
max-width: 480px;
max-height: 70vh;
display: flex;
flex-direction: column;
overflow: hidden;
}
.path-picker-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 20px;
border-bottom: 1px solid var(--hairline);
flex-shrink: 0;
}
.path-picker-header h3 {
font-size: 15px;
font-weight: 600;
color: var(--ink-strong);
margin: 0;
}
.path-picker-close {
border: none;
background: none;
font-size: 22px;
color: var(--ink-muted);
cursor: pointer;
padding: 0 4px;
line-height: 1;
}
.path-picker-close:hover {
color: var(--ink-strong);
}
.path-picker-bar {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 16px;
border-bottom: 1px solid var(--hairline);
flex-shrink: 0;
}
.path-picker-cwd {
font-size: 12px;
font-family: 'Consolas', 'Monaco', monospace;
color: var(--ink-secondary);
background: var(--surface-dim);
padding: 4px 10px;
border-radius: var(--radius-sm);
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.path-picker-body {
flex: 1;
overflow-y: auto;
padding: 8px 0;
min-height: 200px;
max-height: 320px;
}
.path-picker-loading,
.path-picker-empty,
.path-picker-error {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
font-size: 13px;
color: var(--ink-muted);
}
.path-picker-error {
color: var(--status-critical);
}
.path-picker-item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 20px;
cursor: pointer;
font-size: 13px;
color: var(--ink-strong);
transition: background 0.1s ease;
}
.path-picker-item:hover {
background: var(--brand-soft);
color: var(--brand);
}
.path-picker-item svg {
flex-shrink: 0;
color: var(--ink-muted);
}
.path-picker-item:hover svg {
color: var(--brand);
}
.path-picker-footer {
display: flex;
justify-content: flex-end;
gap: 10px;
padding: 12px 20px;
border-top: 1px solid var(--hairline);
flex-shrink: 0;
}
+184 -5
View File
@@ -32,10 +32,148 @@ function AppIcon({ type }) {
<path d="M3 9h18M9 21V9" />
</svg>
),
java: (
<svg viewBox="0 0 24 24" width="40" height="40" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M8 20c2-1 3-3 3-5-1 1-2 3-2 5" />
<path d="M11 20c2-1 3-3 3-5-1 1-2 3-2 5" />
<path d="M14 20c2-1 3-3 3-5-1 1-2 3-2 5" />
<path d="M6 16h12M6 16v-4a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v4" />
<path d="M9 11V7a2 2 0 0 1 2-2h2a2 2 0 0 1 2 2v4" />
</svg>
),
}
return icons[type] || icons.default
}
// 远程目录选择器
function PathPicker({ value, onChange, placeholder }) {
const [open, setOpen] = useState(false)
const [currentPath, setCurrentPath] = useState('/')
const [entries, setEntries] = useState([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const loadDir = useCallback(async (dir) => {
setLoading(true)
setError('')
try {
const r = await window.panel.sftpList(dir)
if (r.ok) {
setEntries(r.list.filter((e) => e.isDirectory))
setCurrentPath(dir)
} else {
setError(r.message || '读取目录失败')
setEntries([])
}
} catch (err) {
setError(err.message || '连接异常')
setEntries([])
}
setLoading(false)
}, [])
const openPicker = () => {
// 如果已有值,打开其父目录;否则从根目录开始
if (value) {
const parts = value.split('/').filter(Boolean)
parts.pop()
const parent = '/' + parts.join('/')
setCurrentPath(parent || '/')
} else {
setCurrentPath('/')
}
setOpen(true)
loadDir(value ? value.replace(/\/[^/]*$/, '') || '/' : '/')
}
const goToDir = (sub) => {
const next = currentPath === '/' ? '/' + sub : currentPath + '/' + sub
loadDir(next)
}
const goUp = () => {
if (currentPath === '/') return
const parts = currentPath.split('/').filter(Boolean)
parts.pop()
loadDir('/' + parts.join('/'))
}
const selectCurrent = () => {
onChange(currentPath)
setOpen(false)
}
if (open) {
return (
<div className="path-picker-overlay" onClick={() => setOpen(false)}>
<div className="path-picker-dialog" onClick={(e) => e.stopPropagation()}>
<div className="path-picker-header">
<h3>选择目录</h3>
<button className="path-picker-close" onClick={() => setOpen(false)}>×</button>
</div>
<div className="path-picker-bar">
<button className="btn btn-ghost btn-sm" onClick={goUp} disabled={currentPath === '/'}>
↑ 上级
</button>
<code className="path-picker-cwd">{currentPath}</code>
</div>
<div className="path-picker-body">
{loading ? (
<div className="path-picker-loading">加载中...</div>
) : error ? (
<div className="path-picker-error">{error}</div>
) : entries.length === 0 ? (
<div className="path-picker-empty">没有子目录</div>
) : (
entries.map((e) => (
<div
key={e.filename}
className="path-picker-item"
onClick={() => goToDir(e.filename)}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M3 7a1 1 0 0 1 1-1h4l2 2h8a1 1 0 0 1 1 1v9a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V7z" />
</svg>
<span>{e.filename}</span>
</div>
))
)}
</div>
<div className="path-picker-footer">
<button className="btn btn-ghost" onClick={() => setOpen(false)}>取消</button>
<button className="btn btn-primary" onClick={selectCurrent}>
选择此目录
</button>
</div>
</div>
</div>
)
}
return (
<div className="path-input-wrapper">
<input
type="text"
className="param-input path-input-field"
placeholder={placeholder}
value={value || ''}
onChange={(e) => onChange(e.target.value)}
/>
<button
type="button"
className="btn btn-ghost path-browse-btn"
onClick={openPicker}
title="浏览远程目录"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M3 7a1 1 0 0 1 1-1h4l2 2h8a1 1 0 0 1 1 1v9a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V7z" />
</svg>
浏览
</button>
</div>
)
}
// SSH 连接数警告弹窗
function ConnectionWarning({ count, onConfirm, onCancel }) {
return (
@@ -185,11 +323,32 @@ export default function AppStore({ onDeployed, onViewTasks }) {
// 判断某个模板是否已安装
const isInstalled = useCallback((template) => {
// 运行时类模板允许重复部署,不显示"已安装"状态
if (template.category === '运行时') return false
const cn = template.deploy?.containerName
if (!cn) return false
// containerName 含 {{}} 占位符的也允许重复部署
if (cn.includes('{{')) return false
return installedApps.includes(cn)
}, [installedApps])
// 为运行时类模板自动生成不重复的容器名
const autoContainerName = useCallback((template) => {
const base = template.deploy?.containerName || ''
// 如果 containerName 不含占位符,直接返回原值
if (!base.includes('{{')) return base
// 提取占位符外的静态前缀(如 {{CONTAINER_NAME}} → 无前缀)
// 用模板 app_key 作为基础名
const prefix = template.id || template.deploy?.containerName.replace(/{{.*?}}/g, '') || 'app'
// 找一个不冲突的名称:prefix, prefix-2, prefix-3 ...
let name = prefix
let suffix = 2
while (installedApps.includes(name)) {
name = `${prefix}-${suffix++}`
}
return name
}, [installedApps])
// 同步在线商店
const handleSync = useCallback(async () => {
setSyncing(true)
@@ -221,7 +380,12 @@ export default function AppStore({ onDeployed, onViewTasks }) {
if (!selectedApp) return
const defaults = {}
for (const param of selectedApp.deploy.params || []) {
defaults[param.key] = param.default || ''
if (param.type === 'select' && !param.default && param.options && param.options.length > 0) {
// select 无默认值时自动选中第一个选项,避免状态为空导致按钮置灰
defaults[param.key] = param.options[0].value
} else {
defaults[param.key] = param.default || ''
}
}
// Halo + MySQL 联动:如果 MySQL 容器已安装,自动填充默认值
if (selectedApp.id === 'halo' && installedApps.includes('mysql')) {
@@ -231,8 +395,17 @@ export default function AppStore({ onDeployed, onViewTasks }) {
defaults['DB_NAME'] = 'halodb'
defaults['DB_USER'] = 'root'
}
// 运行时类模板:自动填充不重复的容器名
if (selectedApp.category === '运行时') {
const autoName = autoContainerName(selectedApp)
// 找到 CONTAINER_NAME 参数并覆盖默认值
const hasContainerNameParam = (selectedApp.deploy.params || []).some((p) => p.key === 'CONTAINER_NAME')
if (hasContainerNameParam) {
defaults['CONTAINER_NAME'] = autoName
}
}
setParamValues(defaults)
}, [selectedApp, installedApps])
}, [selectedApp, installedApps, autoContainerName])
// 执行部署(先检查连接数)
const handleDeploy = useCallback(async () => {
@@ -441,7 +614,7 @@ export default function AppStore({ onDeployed, onViewTasks }) {
</div>
{/* 参数输入表单 */}
{installed ? (
{(installed && app.category !== '运行时') ? (
<div className="app-installed-notice">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" />
@@ -474,6 +647,12 @@ export default function AppStore({ onDeployed, onViewTasks }) {
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
) : param.type === 'path' ? (
<PathPicker
value={paramValues[param.key] || ''}
onChange={(val) => setParamValues((prev) => ({ ...prev, [param.key]: val }))}
placeholder={param.placeholder}
/>
) : (
<input
type={param.type === 'password' ? 'password' : 'text'}
@@ -507,13 +686,13 @@ export default function AppStore({ onDeployed, onViewTasks }) {
<button
className="btn btn-primary"
onClick={handleDeploy}
disabled={installed || deploying || (d.params || []).some((p) => {
disabled={(installed && app.category !== '运行时') || deploying || (d.params || []).some((p) => {
if (!p.required) return false
if (p.showWhen && paramValues[p.showWhen.param] !== p.showWhen.equals) return false
return !paramValues[p.key]
})}
>
{installed ? '已安装' : deploying ? '提交中...' : '一键部署'}
{(installed && app.category !== '运行时') ? '已安装' : deploying ? '提交中...' : '一键部署'}
</button>
</div>
</div>