feat(web): 桌面登录页记住密码/自动登录与版本更新,首页版本更新入口与背景修复
- 登录页(仅桌面端)新增"记住密码/自动登录"勾选与"更新版本"按钮:登录成功持久化凭据 (base64 存桌面 localStorage),打开登录页恢复勾选并静默登录;显式登出/切号后关闭自动登录 - 新增 shared/composables/useVersionUpdate 统一版本检测:/api/version/latest 与本机版本比较, 通过 pywebview 桥 get_app_version 读本地 exe 版本,缺桥时降级为可手动更新 - 首页保留 ↻ 图标并加显式"更新版本"按钮,接入新检测逻辑 - 首页背景改画到 .home-root,避免被 SPA 全局样式时序覆盖导致图不显示
This commit is contained in:
@@ -10,18 +10,24 @@
|
|||||||
title="检测更新"
|
title="检测更新"
|
||||||
@click="toggleUpdatePanel"
|
@click="toggleUpdatePanel"
|
||||||
>↻</button>
|
>↻</button>
|
||||||
|
<button
|
||||||
|
v-if="isDesktopRuntime"
|
||||||
|
type="button"
|
||||||
|
class="header-update-text"
|
||||||
|
@click="toggleUpdatePanel"
|
||||||
|
>更新版本</button>
|
||||||
<div class="header-update-panel" v-show="updatePanel">
|
<div class="header-update-panel" v-show="updatePanel">
|
||||||
<div class="header-update-title"><span>↻</span> 软件更新</div>
|
<div class="header-update-title"><span>↻</span> 软件更新</div>
|
||||||
<div class="update-block">
|
<div class="update-block">
|
||||||
<span class="update-version-text">当前版本: {{ versionText }}</span>
|
<span class="update-version-text">当前版本: {{ currentVersion || '未知' }}</span>
|
||||||
<button type="button" class="btn-check-update" @click="checkUpdate">
|
<button type="button" class="btn-check-update" :disabled="checking" @click="runCheck">
|
||||||
<span>↻</span> 检测
|
<span>↻</span> {{ checking ? '检测中...' : '检测' }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="update-hint">{{ updateHint }}</div>
|
<div class="update-hint">{{ hint }}</div>
|
||||||
<div style="margin-top: 4px;">
|
<div v-if="hasUpdate || canDownload" style="margin-top: 4px;">
|
||||||
<button type="button" class="btn-download-update" v-if="updateReady" @click="doUpdate">
|
<button type="button" class="btn-download-update" :disabled="updating" @click="doUpdate">
|
||||||
立即更新
|
{{ updating ? '更新中...' : '立即更新' }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -64,16 +70,15 @@
|
|||||||
import { computed, onMounted, ref, watch } from 'vue'
|
import { computed, onMounted, ref, watch } from 'vue'
|
||||||
import { restoreLoginUser } from '@/shared/auth/ensure-auth'
|
import { restoreLoginUser } from '@/shared/auth/ensure-auth'
|
||||||
import { getCurrentUserAppColumnRaw, type PermissionMenuItem } from '@/shared/api/permission'
|
import { getCurrentUserAppColumnRaw, type PermissionMenuItem } from '@/shared/api/permission'
|
||||||
import { getPywebviewApi, isDesktopRuntime } from '@/shared/bridges/pywebview'
|
import { isDesktopRuntime } from '@/shared/bridges/pywebview'
|
||||||
|
import { useVersionUpdate } from '@/shared/composables/useVersionUpdate'
|
||||||
import { resolvePageHref } from '@/shared/page-prefix'
|
import { resolvePageHref } from '@/shared/page-prefix'
|
||||||
|
|
||||||
const username = ref('')
|
const username = ref('')
|
||||||
const versionText = ref('—')
|
|
||||||
const updatePanel = ref(false)
|
const updatePanel = ref(false)
|
||||||
const updateHint = ref('')
|
|
||||||
const updateReady = ref(false)
|
|
||||||
const updateFileUrl = ref('')
|
|
||||||
const toastText = ref('')
|
const toastText = ref('')
|
||||||
|
const { checking, updating, currentVersion, hasUpdate, canDownload, hint, checked, runCheck, doUpdate } =
|
||||||
|
useVersionUpdate()
|
||||||
|
|
||||||
// 桌面端原 Flask /logout 已随瘦身下线:统一跳登录页并清本地 token(/login?logout=1)
|
// 桌面端原 Flask /logout 已随瘦身下线:统一跳登录页并清本地 token(/login?logout=1)
|
||||||
const logoutHref = computed(() => `${resolvePageHref('/new_web_source/login.html')}?logout=1`)
|
const logoutHref = computed(() => `${resolvePageHref('/new_web_source/login.html')}?logout=1`)
|
||||||
@@ -162,69 +167,21 @@ async function loadCurrentUser() {
|
|||||||
|
|
||||||
function toggleUpdatePanel() {
|
function toggleUpdatePanel() {
|
||||||
updatePanel.value = !updatePanel.value
|
updatePanel.value = !updatePanel.value
|
||||||
}
|
// 首次展开面板即触发一次检测(版本逻辑在 shared/composables/useVersionUpdate)
|
||||||
|
if (updatePanel.value && !checked.value && !checking.value) {
|
||||||
async function fetchVersion() {
|
void runCheck()
|
||||||
try {
|
|
||||||
const resp = await window.fetch('/api/version', { credentials: 'same-origin' })
|
|
||||||
const data = await resp.json().catch(() => ({}))
|
|
||||||
const current = String((data && data.version) || '').replace(/^v/i, '')
|
|
||||||
if (current) versionText.value = current || '—'
|
|
||||||
return data || {}
|
|
||||||
} catch {
|
|
||||||
return {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function checkUpdate() {
|
|
||||||
updateHint.value = '正在检测更新...'
|
|
||||||
updateReady.value = false
|
|
||||||
try {
|
|
||||||
const data = await fetchVersion()
|
|
||||||
if (data.has_update && data.latest_version) {
|
|
||||||
updateHint.value =
|
|
||||||
`发现新版本 v${String(data.latest_version).replace(/^v/i, '')}` +
|
|
||||||
(data.desc ? `:${data.desc}` : '')
|
|
||||||
if (data.file_url) {
|
|
||||||
updateReady.value = true
|
|
||||||
updateFileUrl.value = data.file_url
|
|
||||||
} else {
|
|
||||||
updateHint.value = '暂无下载地址,请关注官方渠道。'
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
updateHint.value = '已是最新版本'
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
updateHint.value = '检测更新失败'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function doUpdate() {
|
|
||||||
if (!updateFileUrl.value) return
|
|
||||||
if (!window.confirm('有更新,是否现在更新?\n更新将下载安装包并重启程序。')) return
|
|
||||||
updateHint.value = '正在下载并准备更新,程序将自动退出...'
|
|
||||||
updateReady.value = false
|
|
||||||
try {
|
|
||||||
// 版本更新是桌面客户端专属动作:经浏览器桥调用本地 update 下载/启动(服务端化后无 /api/update/do)
|
|
||||||
const bridge = getPywebviewApi()
|
|
||||||
const result = bridge?.do_update_app ? await bridge.do_update_app(updateFileUrl.value) : undefined
|
|
||||||
updateHint.value = result?.success
|
|
||||||
? '更新已启动,程序即将退出...'
|
|
||||||
: (result?.error || '当前环境不支持自动更新')
|
|
||||||
} catch {
|
|
||||||
updateHint.value = '请求更新失败,请重试'
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
void loadCurrentUser()
|
void loadCurrentUser()
|
||||||
void loadPermissions()
|
void loadPermissions()
|
||||||
// 版本检测/更新仅桌面客户端(本地 Flask image 蓝图)提供;桌面桥注入后立即拉取
|
// 版本检测/更新仅桌面客户端:桌面桥注入后立即检测一次(逻辑见 useVersionUpdate)
|
||||||
watch(
|
watch(
|
||||||
isDesktopRuntime,
|
isDesktopRuntime,
|
||||||
(runtime) => {
|
(runtime) => {
|
||||||
if (runtime) {
|
if (runtime) {
|
||||||
void fetchVersion().catch(() => undefined)
|
void runCheck()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{ immediate: true },
|
{ immediate: true },
|
||||||
@@ -247,7 +204,10 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.home-root {
|
.home-root {
|
||||||
|
/* 背景直接落到根容器,避免依赖 body 被 SPA 全局样式覆盖导致图不显示 */
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
|
background: linear-gradient(rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.5)),
|
||||||
|
url("/bg.jpg") center/cover no-repeat;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -316,6 +276,21 @@ body {
|
|||||||
background: rgba(102, 126, 234, 0.2);
|
background: rgba(102, 126, 234, 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.header-update-text {
|
||||||
|
padding: 5px 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #4c5bd4;
|
||||||
|
background: rgba(102, 126, 234, 0.12);
|
||||||
|
border: 1px solid rgba(102, 126, 234, 0.35);
|
||||||
|
border-radius: 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-update-text:hover {
|
||||||
|
background: rgba(102, 126, 234, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
.header-update-panel {
|
.header-update-panel {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 44px;
|
top: 44px;
|
||||||
|
|||||||
@@ -43,29 +43,140 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- 记住密码 / 自动登录:仅桌面客户端提供(网页浏览器访问不存密码,见需求) -->
|
||||||
|
<div v-if="isDesktopRuntime" class="login-options">
|
||||||
|
<label class="check-label">
|
||||||
|
<input type="checkbox" class="check-input" v-model="rememberPassword" />
|
||||||
|
<span>记住密码</span>
|
||||||
|
</label>
|
||||||
|
<label class="check-label">
|
||||||
|
<input type="checkbox" class="check-input" v-model="autoLogin" />
|
||||||
|
<span>自动登录</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
<button type="submit" class="btn-login" id="btnLogin" :disabled="loggingIn">
|
<button type="submit" class="btn-login" id="btnLogin" :disabled="loggingIn">
|
||||||
{{ loggingIn ? '登录中...' : '登录' }}
|
{{ loggingIn ? '登录中...' : '登录' }}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
<!-- 版本更新(桌面端检测线上版本并下载更新包,对齐旧版客户端入口) -->
|
||||||
|
<div v-if="isDesktopRuntime" class="login-update">
|
||||||
|
<button type="button" class="link-update" @click="toggleUpdate">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
|
<path d="M21 12a9 9 0 1 1-2.64-6.36"></path>
|
||||||
|
<path d="M21 3v6h-6"></path>
|
||||||
|
</svg>
|
||||||
|
更新版本
|
||||||
|
</button>
|
||||||
|
<div v-if="updateOpen" class="update-panel">
|
||||||
|
<div class="update-panel-title">软件更新</div>
|
||||||
|
<div class="update-row"><span>当前版本</span><b>{{ currentVersion || '—' }}</b></div>
|
||||||
|
<div class="update-row"><span>最新版本</span><b>{{ latestVersion || '—' }}</b></div>
|
||||||
|
<div class="update-actions">
|
||||||
|
<button type="button" class="btn-mini" :disabled="checking" @click="runCheck">
|
||||||
|
{{ checking ? '检测中...' : '检测' }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="hasUpdate || canDownload"
|
||||||
|
type="button"
|
||||||
|
class="btn-mini btn-mini-green"
|
||||||
|
:disabled="updating"
|
||||||
|
@click="doUpdate"
|
||||||
|
>
|
||||||
|
{{ updating ? '更新中...' : '立即更新' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="update-hint">{{ hint }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, ref } from 'vue'
|
import { onMounted, ref, watch } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { loginWithDevice } from '@/shared/api/user'
|
import { loginWithDevice } from '@/shared/api/user'
|
||||||
|
import { isDesktopRuntime } from '@/shared/bridges/pywebview'
|
||||||
|
import { useVersionUpdate } from '@/shared/composables/useVersionUpdate'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
const AUTH_TOKEN_KEY = 'aiimage_auth_token'
|
const AUTH_TOKEN_KEY = 'aiimage_auth_token'
|
||||||
const DEVICE_ID_KEY = 'aiimage_device_id'
|
const DEVICE_ID_KEY = 'aiimage_device_id'
|
||||||
|
|
||||||
|
const REMEMBER_USER_KEY = 'aiimage_remember_user'
|
||||||
|
const REMEMBER_PWD_KEY = 'aiimage_remember_pwd'
|
||||||
|
const AUTO_LOGIN_KEY = 'aiimage_auto_login'
|
||||||
|
|
||||||
const username = ref('')
|
const username = ref('')
|
||||||
const password = ref('')
|
const password = ref('')
|
||||||
const passwordVisible = ref(false)
|
const passwordVisible = ref(false)
|
||||||
const loggingIn = ref(false)
|
const loggingIn = ref(false)
|
||||||
const errorMessage = ref('')
|
const errorMessage = ref('')
|
||||||
|
const rememberPassword = ref(false)
|
||||||
|
const autoLogin = ref(false)
|
||||||
|
const updateOpen = ref(false)
|
||||||
|
|
||||||
|
// 版本检测 / 更新:登录页与首页共用逻辑
|
||||||
|
const {
|
||||||
|
checking,
|
||||||
|
updating,
|
||||||
|
currentVersion,
|
||||||
|
latestVersion,
|
||||||
|
hasUpdate,
|
||||||
|
canDownload,
|
||||||
|
hint,
|
||||||
|
checked,
|
||||||
|
runCheck,
|
||||||
|
doUpdate,
|
||||||
|
} = useVersionUpdate()
|
||||||
|
|
||||||
|
function b64Encode(value: string): string {
|
||||||
|
try {
|
||||||
|
return btoa(unescape(encodeURIComponent(value)))
|
||||||
|
} catch {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function b64Decode(value: string): string {
|
||||||
|
if (!value) return ''
|
||||||
|
try {
|
||||||
|
return decodeURIComponent(escape(atob(value)))
|
||||||
|
} catch {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function lsGet(key: string): string {
|
||||||
|
try {
|
||||||
|
return window.localStorage.getItem(key) || ''
|
||||||
|
} catch {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function lsSet(key: string, value: string) {
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(key, value)
|
||||||
|
} catch {
|
||||||
|
/* 忽略 localStorage 异常 */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function lsRemove(key: string) {
|
||||||
|
try {
|
||||||
|
window.localStorage.removeItem(key)
|
||||||
|
} catch {
|
||||||
|
/* 忽略 */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasLogoutParam(): boolean {
|
||||||
|
const params = new URLSearchParams(window.location.search || '')
|
||||||
|
return params.get('logout') === '1' || params.get('switch') === '1'
|
||||||
|
}
|
||||||
|
|
||||||
function togglePassword() {
|
function togglePassword() {
|
||||||
passwordVisible.value = !passwordVisible.value
|
passwordVisible.value = !passwordVisible.value
|
||||||
@@ -121,6 +232,45 @@ async function fetchDeviceId(): Promise<string> {
|
|||||||
return makeBrowserDeviceId()
|
return makeBrowserDeviceId()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 登录成功后按勾选状态持久化账号凭据(仅桌面端登录页可勾选,密码 base64 简单编码) */
|
||||||
|
function saveCredentials(account: string) {
|
||||||
|
if (!rememberPassword.value) {
|
||||||
|
lsRemove(REMEMBER_USER_KEY)
|
||||||
|
lsRemove(REMEMBER_PWD_KEY)
|
||||||
|
lsRemove(AUTO_LOGIN_KEY)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
lsSet(REMEMBER_USER_KEY, account)
|
||||||
|
lsSet(REMEMBER_PWD_KEY, b64Encode(password.value))
|
||||||
|
lsSet(AUTO_LOGIN_KEY, autoLogin.value ? '1' : '0')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 打开登录页时恢复记住的账号/勾选状态 */
|
||||||
|
function loadCredentials() {
|
||||||
|
const account = lsGet(REMEMBER_USER_KEY)
|
||||||
|
const pwd = b64Decode(lsGet(REMEMBER_PWD_KEY))
|
||||||
|
if (account) username.value = account
|
||||||
|
if (pwd) password.value = pwd
|
||||||
|
const auto = lsGet(AUTO_LOGIN_KEY) === '1'
|
||||||
|
autoLogin.value = auto
|
||||||
|
rememberPassword.value = auto || Boolean(pwd)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 打开登录页即用记住的凭据静默登录(登出/切号导航除外) */
|
||||||
|
function tryAutoLogin() {
|
||||||
|
if (hasLogoutParam()) return
|
||||||
|
if (!autoLogin.value) return
|
||||||
|
if (!username.value || !lsGet(REMEMBER_PWD_KEY)) return
|
||||||
|
void submitLogin()
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleUpdate() {
|
||||||
|
updateOpen.value = !updateOpen.value
|
||||||
|
if (updateOpen.value && !checked.value && !checking.value) {
|
||||||
|
void runCheck()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function submitLogin() {
|
async function submitLogin() {
|
||||||
if (loggingIn.value) return
|
if (loggingIn.value) return
|
||||||
const account = username.value.trim()
|
const account = username.value.trim()
|
||||||
@@ -164,6 +314,10 @@ async function submitLogin() {
|
|||||||
/* 忽略 */
|
/* 忽略 */
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// 登录成功即持久化记住/自动登录凭据(桌面端专属勾选)
|
||||||
|
if (isDesktopRuntime.value) {
|
||||||
|
saveCredentials(account)
|
||||||
|
}
|
||||||
// 桌面端 Flask cookie 同步已随瘦身下线:登录态只存 localStorage(JWT 走 Bearer),无 cookie 依赖
|
// 桌面端 Flask cookie 同步已随瘦身下线:登录态只存 localStorage(JWT 走 Bearer),无 cookie 依赖
|
||||||
clearAppPermissionCaches()
|
clearAppPermissionCaches()
|
||||||
// SPA:登录成功后经路由回首页(URL 无 .html 后缀,见 src/router)
|
// SPA:登录成功后经路由回首页(URL 无 .html 后缀,见 src/router)
|
||||||
@@ -186,7 +340,29 @@ onMounted(() => {
|
|||||||
} catch {
|
} catch {
|
||||||
/* 忽略 */
|
/* 忽略 */
|
||||||
}
|
}
|
||||||
|
// 显式登出/切号后不再自动登录(保留记住的账号,仅关闭自动登录)
|
||||||
|
autoLogin.value = false
|
||||||
|
lsSet(AUTO_LOGIN_KEY, '0')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 桌面桥注入完成后再恢复凭据/自动登录(isDesktopRuntime 在 pywebviewready 后置 true)
|
||||||
|
watch(
|
||||||
|
isDesktopRuntime,
|
||||||
|
(runtime) => {
|
||||||
|
if (!runtime) return
|
||||||
|
loadCredentials()
|
||||||
|
tryAutoLogin()
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 勾选自动登录时隐含记住密码(自动登录依赖已存密码);反之取消记住密码则取消自动登录
|
||||||
|
watch(autoLogin, (checkedValue) => {
|
||||||
|
if (checkedValue) rememberPassword.value = true
|
||||||
|
})
|
||||||
|
watch(rememberPassword, (checkedValue) => {
|
||||||
|
if (!checkedValue) autoLogin.value = false
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -233,6 +409,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.login-box {
|
.login-box {
|
||||||
|
position: relative;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
border: 1px solid #b8d4e3;
|
border: 1px solid #b8d4e3;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
@@ -332,6 +509,32 @@ body {
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 记住密码 / 自动登录 */
|
||||||
|
.login-options {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
margin: -4px 0 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.check-label {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #555;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.check-input {
|
||||||
|
width: 15px;
|
||||||
|
height: 15px;
|
||||||
|
accent-color: #3498db;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
.btn-login {
|
.btn-login {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 14px;
|
padding: 14px;
|
||||||
@@ -353,4 +556,104 @@ body {
|
|||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 版本更新入口与面板 */
|
||||||
|
.login-update {
|
||||||
|
margin-top: 14px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-update {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #71808d;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-update svg {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-update:hover {
|
||||||
|
color: #3498db;
|
||||||
|
background: #f1f7fb;
|
||||||
|
border-color: #d5e6f2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-panel {
|
||||||
|
margin-top: 10px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
background: #f6fafd;
|
||||||
|
border: 1px solid #d5e6f2;
|
||||||
|
border-radius: 8px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-panel-title {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #555;
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-row b {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-mini {
|
||||||
|
padding: 5px 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #fff;
|
||||||
|
background: #3498db;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-mini:hover:not(:disabled) {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-mini:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-mini-green {
|
||||||
|
background: #28a745;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-mini-green:hover:not(:disabled) {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-hint {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #666;
|
||||||
|
margin-top: 8px;
|
||||||
|
min-height: 16px;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -135,6 +135,8 @@ export interface PywebviewApi {
|
|||||||
open_external_url?: (url: string) => Promise<{ success: boolean; error?: string }>;
|
open_external_url?: (url: string) => Promise<{ success: boolean; error?: string }>;
|
||||||
/** 版本更新(桌面专属):下载更新包并启动 update.exe 后退出主程序(原 Flask /api/update/do 桥化) */
|
/** 版本更新(桌面专属):下载更新包并启动 update.exe 后退出主程序(原 Flask /api/update/do 桥化) */
|
||||||
do_update_app?: (fileUrl: string) => Promise<{ success: boolean; error?: string }>;
|
do_update_app?: (fileUrl: string) => Promise<{ success: boolean; error?: string }>;
|
||||||
|
/** 桌面客户端本地版本号(与 /api/version/latest 比较检测更新;新版客户端提供) */
|
||||||
|
get_app_version?: () => Promise<{ success: boolean; version?: string; error?: string }>;
|
||||||
launch_yaoayanui?: () => Promise<{
|
launch_yaoayanui?: () => Promise<{
|
||||||
success: boolean;
|
success: boolean;
|
||||||
path?: string;
|
path?: string;
|
||||||
|
|||||||
@@ -0,0 +1,144 @@
|
|||||||
|
/**
|
||||||
|
* 版本检测 / 更新共享逻辑(登录页、首页共用)
|
||||||
|
*
|
||||||
|
* 服务端化后桌面页面直载线上 Web:检测"是否有新版"需把线上 /api/version/latest
|
||||||
|
* 与"本机 exe 版本"比较。本机版本经 pywebview 桥 get_app_version() 获取(新版客户端
|
||||||
|
* 提供);旧客户端无此桥时降级:本机版本为空 → 只要线上有包就允许手动更新。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { getPywebviewApi } from '@/shared/bridges/pywebview'
|
||||||
|
|
||||||
|
function normVersion(value: unknown): string {
|
||||||
|
return String(value ?? '').trim().replace(/^[vV]/, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchJson(url: string): Promise<Record<string, unknown>> {
|
||||||
|
const resp = await window.fetch(url, { credentials: 'same-origin' })
|
||||||
|
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
|
||||||
|
const text = await resp.text()
|
||||||
|
if (!text) return {}
|
||||||
|
try {
|
||||||
|
return JSON.parse(text) as Record<string, unknown>
|
||||||
|
} catch {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 读取本机客户端版本号:优先桌面桥;无桥(旧版 exe / 浏览器环境)返回空串。
|
||||||
|
*/
|
||||||
|
export async function resolveLocalVersion(): Promise<string> {
|
||||||
|
const bridge = getPywebviewApi()
|
||||||
|
if (bridge?.get_app_version) {
|
||||||
|
try {
|
||||||
|
const res = await bridge.get_app_version()
|
||||||
|
if (res?.success && res.version) {
|
||||||
|
return normVersion(res.version)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* 忽略,走空版本降级 */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 拉取线上最新客户端版本(公开接口,无需登录;web_config 无记录时 version 为空)。 */
|
||||||
|
export async function fetchLatestVersion(): Promise<{ version: string; fileUrl: string }> {
|
||||||
|
const data = await fetchJson('/api/version/latest')
|
||||||
|
return { version: normVersion(data.version), fileUrl: String(data.file_url ?? '') }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useVersionUpdate() {
|
||||||
|
const checking = ref(false)
|
||||||
|
const updating = ref(false)
|
||||||
|
const currentVersion = ref('')
|
||||||
|
const latestVersion = ref('')
|
||||||
|
const fileUrl = ref('')
|
||||||
|
const hasUpdate = ref(false)
|
||||||
|
/** 本地版本未知但线上有包时也允许"立即更新",此标志标记可下载 */
|
||||||
|
const canDownload = ref(false)
|
||||||
|
const hint = ref('')
|
||||||
|
const checked = ref(false)
|
||||||
|
|
||||||
|
async function loadCurrentVersion() {
|
||||||
|
if (currentVersion.value) return
|
||||||
|
currentVersion.value = await resolveLocalVersion()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runCheck() {
|
||||||
|
if (checking.value) return
|
||||||
|
checking.value = true
|
||||||
|
hint.value = '正在检测更新...'
|
||||||
|
try {
|
||||||
|
const local = await resolveLocalVersion()
|
||||||
|
currentVersion.value = local
|
||||||
|
const latest = await fetchLatestVersion()
|
||||||
|
latestVersion.value = latest.version
|
||||||
|
fileUrl.value = latest.fileUrl
|
||||||
|
checked.value = true
|
||||||
|
|
||||||
|
if (!latest.version) {
|
||||||
|
hasUpdate.value = false
|
||||||
|
canDownload.value = false
|
||||||
|
hint.value = '暂无版本更新信息'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!local) {
|
||||||
|
// 旧客户端无本机版本桥:无法精确比较,线上有包即可手动更新
|
||||||
|
hasUpdate.value = false
|
||||||
|
canDownload.value = Boolean(latest.fileUrl)
|
||||||
|
hint.value = latest.fileUrl
|
||||||
|
? `线上最新版本 v${latest.version},可点击下方按钮手动更新`
|
||||||
|
: `线上最新版本 v${latest.version},暂无下载地址`
|
||||||
|
return
|
||||||
|
}
|
||||||
|
hasUpdate.value = local !== latest.version
|
||||||
|
canDownload.value = hasUpdate.value && Boolean(latest.fileUrl)
|
||||||
|
hint.value = hasUpdate.value
|
||||||
|
? `发现新版本 v${latest.version}${latest.fileUrl ? '' : ',暂无下载地址'}`
|
||||||
|
: `已是最新版本 v${local}`
|
||||||
|
} catch {
|
||||||
|
hasUpdate.value = false
|
||||||
|
canDownload.value = false
|
||||||
|
hint.value = '检测更新失败,请稍后重试'
|
||||||
|
} finally {
|
||||||
|
checking.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doUpdate() {
|
||||||
|
if (updating.value || !fileUrl.value) return
|
||||||
|
if (!window.confirm('有更新,是否现在更新?\n更新将下载安装包并重启程序。')) return
|
||||||
|
updating.value = true
|
||||||
|
hint.value = '正在下载并准备更新,程序将自动退出...'
|
||||||
|
try {
|
||||||
|
const bridge = getPywebviewApi()
|
||||||
|
const result = bridge?.do_update_app
|
||||||
|
? await bridge.do_update_app(fileUrl.value)
|
||||||
|
: undefined
|
||||||
|
hint.value = result?.success
|
||||||
|
? '更新已启动,程序即将退出...'
|
||||||
|
: (result?.error || '当前环境不支持自动更新')
|
||||||
|
} catch {
|
||||||
|
hint.value = '请求更新失败,请重试'
|
||||||
|
} finally {
|
||||||
|
updating.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
checking,
|
||||||
|
updating,
|
||||||
|
currentVersion,
|
||||||
|
latestVersion,
|
||||||
|
fileUrl,
|
||||||
|
hasUpdate,
|
||||||
|
canDownload,
|
||||||
|
hint,
|
||||||
|
checked,
|
||||||
|
loadCurrentVersion,
|
||||||
|
runCheck,
|
||||||
|
doUpdate,
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user