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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user