feat: 桌面启动器、可执行 jar 瘦身与数据库配置实时进度

本次提交为「汇账」补充桌面 GUI 启动器与 jpackage 免安装打包,并对可执行 jar 做两项依赖瘦身(移除 OpenAI SDK、裁剪 Knife4j/Swagger UI),修复 Windows 构建兼容问题,最后将数据库「测试连接 / 保存并应用」改为 SSE 实时进度并新增进度弹框。

变更内容:
- 桌面启动器:新增 cn.rosercode.ledgerly.launcher(启停服务、日志实时查看、系统托盘、单实例锁、端口配置),普通 jar 经 exclude 排除该包以保持无 JavaFX 依赖。
- 打包:bootJar/bootGuiJar 文件名携带目标 JDK 版本;新增 jpackageAppImage(聚合)与 jpackageServerAppImage/jpackageGuiAppImage,基于 jpackage --type app-image 产出免安装应用,GUI 内嵌 win/linux/mac/mac-aarch64 四平台 JavaFX;应用图标不入库,打包时从 OSS 地址(launcher/icon.url)下载源图并由 tools/IconGen.java 生成平台图标(ico/icns/png)。
- 启动器图标:图标二进制不随源码分发,地址记录在 launcher/icon.url(阿里云 OSS 长期签名地址);运行时由 LauncherIconProvider 下载并缓存到 <基准目录>/cache/launcher-icon.png,缓存缺失时先直接用远端地址,下载失败静默降级为无图标。
- 运行目录:AppHome 新增 configDir/logsDir/setHome,config/logs/data 统一以基准目录(ledgerly.home 系统属性 / LEDGERLY_HOME 环境变量)解析,logback 同步支持。
- 依赖瘦身:移除 com.openai:openai-java,DeepSeekClient 改用 Spring RestClient 直连 Chat Completions(对外签名与 DeepSeekResponse 不变);Knife4j/Swagger UI 改为 developmentOnly,生产仅保留 /v3/api-docs JSON,Knife4jConfig 增加 @ConditionalOnClass 兜底。
- Windows 兼容:npm 解析兼容 npm.cmd;Spotless 统一 LF 并新增 .gitattributes。
- 数据库配置:新增 DatabaseConfigStreamService 与 POST /api/database/{test,config}/stream,SchemaInitializer/SchemaMigrator/DatabaseService 增加进度回调,前端弹框实时展示日志;数据库接口超时放宽为测试 60s、保存 120s。
- 缺陷修复:LauncherHome 改用 java.class.path 定位 jar,避免 Spring Boot 可执行 jar 下嵌套路径导致启动器崩溃;JwtInterceptor 跳过 SSE 完成后的 ASYNC 分派并加固响应已提交判断,避免首次无 Token 配置数据库时重复鉴权写入 401 触发 getOutputStream() 异常。
- 文档:README 精简为功能特性与快速开始/构建打包,细节说明迁移至本地 AGENTS.md。
This commit is contained in:
2026-09-24 14:43:01 +08:00
parent feb56c8a8d
commit ce71fd9b7a
35 changed files with 2745 additions and 315 deletions
+177 -31
View File
@@ -1,17 +1,20 @@
<script setup lang="ts">
// 数据库配置面板:可复用于「系统设置」页与首次配置引导页。
// setup 为 true 时表示首次引导,保存成功后跳转登录页。
import { ref, onMounted } from 'vue'
// 「测试连接」与「保存并应用」均通过 SSE 实时推送后台进度,并以弹框展示,避免长时间无反馈。
import { ref, onMounted, onBeforeUnmount } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import {
useMessage, NCard, NForm, NFormItem, NInput, NInputNumber, NButton, NSpace,
NAlert, NTag, NRadioGroup, NRadioButton, NIcon, NText, NDescriptions, NDescriptionsItem,
NModal, NSpin,
} from 'naive-ui'
import { ServerOutline, SaveOutline, FlashOutline, FolderOpenOutline } from '@vicons/ionicons5'
import { getDatabaseStatus, testDatabaseConnection, saveDatabaseConfig } from '../api/database'
import { getDatabaseStatus } from '../api/database'
import type { DatabaseConfigPayload, DatabaseType } from '../api/database'
import { useDatabaseStore } from '../store/database'
import { openSse } from '../utils/sse'
const props = defineProps<{ setup?: boolean }>()
@@ -25,6 +28,15 @@ const saving = ref(false)
const configured = ref(false)
const hasPassword = ref(false)
// SSE 进度弹框状态
const streamVisible = ref(false)
const streamRunning = ref(false)
const streamTitle = ref('')
const streamLogs = ref<{ time: string; text: string }[]>([])
let streamController: AbortController | null = null
// 弹框关闭后执行的动作(如保存成功后的跳转/刷新),由用户手动关闭时触发
let afterClose: (() => void) | null = null
// 表单数据
const form = ref({
type: 'SQLITE' as DatabaseType,
@@ -92,42 +104,128 @@ function validate(): boolean {
return true
}
/** 测试数据库连接 */
async function handleTest() {
if (!validate()) return
testing.value = true
try {
await testDatabaseConnection(buildPayload())
message.success(t('database.testSuccess'))
} catch (e: any) {
message.error(e.message || t('database.testFailed'))
} finally {
testing.value = false
}
/** 追加一行进度日志 */
function pushLog(text: string) {
const now = new Date()
const pad = (n: number) => String(n).padStart(2, '0')
const time = `${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`
streamLogs.value.push({ time, text })
}
/** 保存配置并切换到新数据库 */
async function handleSave() {
/**
* 发起一次带进度弹框的 SSE 数据库操作。
*
* @param url SSE 接口地址
* @param title 弹框标题
* @param onDone 成功回调(参数为 done 事件携带的数据)
* @param onFail 失败回调(用于复位按钮状态)
*/
function runStream(url: string, title: string, onDone: (data: string) => void, onFail: () => void) {
streamTitle.value = title
streamLogs.value = []
streamVisible.value = true
streamRunning.value = true
streamController = openSse(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(buildPayload()),
onEvent: (event, data) => {
if (event === 'progress') {
pushLog(data)
} else if (event === 'done') {
pushLog(t('database.streamDone'))
streamRunning.value = false
onDone(data)
} else if (event === 'failed') {
let reason = data
try {
reason = JSON.parse(data)?.message || data
} catch {
// 非 JSON 时直接使用原文
}
pushLog(`${t('database.streamFailed')}${reason ? ':' + reason : ''}`)
streamRunning.value = false
onFail()
message.error(reason || t('database.saveFailed'))
}
},
onError: (e: any) => {
streamRunning.value = false
onFail()
message.error(e?.message || t('database.saveFailed'))
},
onClose: () => {
streamRunning.value = false
streamController = null
},
})
}
/** 关闭进度弹框(运行中不可关闭;关闭时执行挂起的后续动作) */
function closeStream() {
if (streamRunning.value) return
streamVisible.value = false
const action = afterClose
afterClose = null
action?.()
}
/** 测试数据库连接(SSE 进度) */
function handleTest() {
if (!validate()) return
testing.value = true
afterClose = null
runStream(
'/api/database/test/stream',
t('database.testingTitle'),
() => {
testing.value = false
message.success(t('database.testSuccess'))
},
() => {
testing.value = false
},
)
}
/** 保存配置并切换到新数据库(SSE 进度) */
function handleSave() {
if (!validate()) return
saving.value = true
try {
const res: any = await saveDatabaseConfig(buildPayload())
databaseStore.setStatus(res.data)
message.success(t('database.saveSuccess'))
if (props.setup) {
// 首次配置完成后引导用户登录
router.push('/login')
} else {
await loadStatus()
}
} catch (e: any) {
message.error(e.message || t('database.saveFailed'))
} finally {
saving.value = false
}
afterClose = null
runStream(
'/api/database/config/stream',
t('database.applyingTitle'),
(data) => {
saving.value = false
try {
databaseStore.setStatus(JSON.parse(data))
} catch {
// 忽略解析失败
}
message.success(t('database.saveSuccess'))
// 由用户手动关闭弹框后再跳转/刷新,避免弹框被自动关闭
afterClose = () => {
if (props.setup) {
// 首次配置完成后引导用户登录
router.push('/login')
} else {
loadStatus()
}
}
},
() => {
saving.value = false
},
)
}
onMounted(loadStatus)
onBeforeUnmount(() => {
// 页面卸载时中断 SSE 连接(后台任务仍会执行至结束)
streamController?.abort()
})
</script>
<template>
@@ -224,6 +322,36 @@ onMounted(loadStatus)
</NSpace>
</NFormItem>
</NForm>
<!-- SSE 进度弹框:让用户了解后台正在运行 -->
<NModal
v-model:show="streamVisible"
preset="card"
:title="streamTitle"
style="width: 560px; max-width: 92vw;"
:closable="false"
:mask-closable="false"
:close-on-esc="false"
>
<NSpin :show="streamRunning">
<NText depth="3" style="font-size: 12px; display: block; margin-bottom: 8px;">
{{ t('database.streamHint') }}
</NText>
<div class="db-stream-logs">
<div v-for="(log, i) in streamLogs" :key="i" class="db-stream-item">
<span class="db-stream-time">{{ log.time }}</span>
<span class="db-stream-text">{{ log.text }}</span>
</div>
</div>
</NSpin>
<template #footer>
<NSpace justify="end">
<NButton :disabled="streamRunning" @click="closeStream">
{{ t('database.streamClose') }}
</NButton>
</NSpace>
</template>
</NModal>
</NCard>
</template>
@@ -234,4 +362,22 @@ onMounted(loadStatus)
border-radius: 8px;
background: rgba(0, 0, 0, 0.02);
}
.db-stream-logs {
max-height: 320px;
overflow-y: auto;
font-family: 'Consolas', 'Menlo', monospace;
font-size: 12px;
line-height: 1.7;
}
.db-stream-item {
display: flex;
gap: 8px;
}
.db-stream-time {
flex: none;
color: rgba(128, 128, 128, 0.9);
}
.db-stream-text {
word-break: break-all;
}
</style>