Skip to content

Commit 9e6a308

Browse files
committed
feat: 应用更新检查与 Changelog 框架
侧栏版本徽标红点提示新版本,关于弹窗显示新版本与更新日志入口; 启动按版本自动弹一次更新日志。COS 端点留占位待运维部署
1 parent 13e9e08 commit 9e6a308

9 files changed

Lines changed: 242 additions & 1 deletion

File tree

ChuChartManager/Front/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
"preview": "vite preview"
99
},
1010
"dependencies": {
11+
"@f3ve/vue-markdown-it": "^0.2.3",
1112
"@fontsource/noto-sans-sc": "^5.2.9",
1213
"@fontsource/quicksand": "^5.2.10",
1314
"@munet/ui": "workspace:*",

ChuChartManager/Front/src/App.vue

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { modalShowing, GlobalElementsContainer } from '@munet/ui'
44
import Sidebar from '@/components/Sidebar.vue'
55
import StatusBar from '@/components/StatusBar.vue'
66
import StartupErrorDialog from '@/components/StartupErrorDialog'
7+
import ChangelogModal from '@/components/ChangelogModal'
78
import MusicList from '@/views/MusicList.vue'
89
import Course from '@/views/Course/index'
910
import ResourceManager from '@/views/ResourceManager/index'
@@ -19,6 +20,7 @@ import Oobe from '@/views/Oobe/index'
1920
import { ensureBackendUrl } from '@/api'
2021
import { loadLocaleFromBackend } from '@/locales'
2122
import { updateOptionDirs, sidebarActive, updateAppVersion } from '@/store/refs'
23+
import { checkAppUpdate } from '@/store/appUpdate'
2224
2325
const hash = window.location.hash.replace('#', '')
2426
const isOobeWindow = hash === 'oobe' || hash === 'mode-select'
@@ -31,7 +33,8 @@ onMounted(async () => {
3133
await ensureBackendUrl()
3234
await loadLocaleFromBackend()
3335
updateOptionDirs()
34-
updateAppVersion()
36+
await updateAppVersion()
37+
checkAppUpdate()
3538
ready.value = true
3639
})
3740
@@ -46,6 +49,7 @@ const handleRefresh = () => {
4649
<div v-else class="content-root" :class="{ 'modal-open': modalShowing }">
4750
<GlobalElementsContainer />
4851
<StartupErrorDialog />
52+
<ChangelogModal :ready="ready" />
4953
<div class="main-layout">
5054
<Sidebar v-model:active="sidebarActive" @refresh="handleRefresh" />
5155
<div class="main-content">
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { defineComponent, watch } from 'vue'
2+
import { Modal } from '@munet/ui'
3+
import { useI18n } from 'vue-i18n'
4+
import { VueMarkdownIt } from '@f3ve/vue-markdown-it'
5+
import { appVersion } from '@/store/refs'
6+
import {
7+
changelogAutoPopupDone,
8+
changelogContent,
9+
changelogTargetVersion,
10+
getCleanVersion,
11+
lastShownChangelogVersion,
12+
openChangelog,
13+
showChangelogModal,
14+
} from '@/store/appUpdate'
15+
16+
export default defineComponent({
17+
props: {
18+
ready: { type: Boolean, required: true },
19+
},
20+
setup(props) {
21+
const { t } = useI18n()
22+
23+
watch(
24+
() => [props.ready, appVersion.value?.version] as const,
25+
async ([ready, ver]) => {
26+
if (!ready || !ver) return
27+
if (changelogAutoPopupDone.value) return
28+
changelogAutoPopupDone.value = true
29+
30+
const cleanVer = getCleanVersion(ver)
31+
if (cleanVer === lastShownChangelogVersion.value) return
32+
33+
await new Promise(resolve => setTimeout(resolve, 200))
34+
const shown = await openChangelog(ver, { showAfterLoaded: true, skipIfEmpty: true })
35+
if (shown) lastShownChangelogVersion.value = cleanVer
36+
},
37+
{ immediate: true },
38+
)
39+
40+
return () => (
41+
<Modal
42+
width="min(85vw,50em)"
43+
title={`${t('about.changelogTitle')} - v${changelogTargetVersion.value}`}
44+
v-model:show={showChangelogModal.value}
45+
>
46+
<div class="changelog-md cst of-y-auto of-x-hidden max-h-[80vh]">
47+
{changelogContent.value
48+
? <VueMarkdownIt source={changelogContent.value} />
49+
: <div class="text-center py-4 op-60">{t('common.loading')}</div>}
50+
</div>
51+
</Modal>
52+
)
53+
},
54+
})

ChuChartManager/Front/src/components/VersionInfo.tsx

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { computed, defineComponent, ref } from 'vue'
22
import { Modal } from '@munet/ui'
33
import { useI18n } from 'vue-i18n'
44
import { appVersion } from '@/store/refs'
5+
import { appUpdateInfo, hasUpdate, openChangelog } from '@/store/appUpdate'
56

67
export default defineComponent({
78
setup() {
@@ -15,6 +16,9 @@ export default defineComponent({
1516
onClick={() => show.value = true}
1617
>
1718
v{displayVersion.value}
19+
{hasUpdate.value && (
20+
<div class="absolute -top-1 -right-1 w-2.5 h-2.5 rounded-full bg-#f64861 border-2 border-white" />
21+
)}
1822

1923
<Modal
2024
width="min(85vw,30em)"
@@ -30,6 +34,20 @@ export default defineComponent({
3034
<div class="text-sm op-60">{t('about.gameVersion')}</div>
3135
<div>{appVersion.value.gameVersionStr}</div>
3236
</div>
37+
{hasUpdate.value && (
38+
<div class="flex items-center justify-between gap-2 bg-#f6486118 rd p-2.5">
39+
<div>
40+
<div class="text-sm c-#f64861">{t('about.updateAvailable')}</div>
41+
<div class="font-medium">v{appUpdateInfo.value?.version}</div>
42+
</div>
43+
<div
44+
class="px-3 py-1 rounded-md cursor-pointer bg-avatarMenuButton text-sm"
45+
onClick={() => { if (appUpdateInfo.value) openChangelog(appUpdateInfo.value.version) }}
46+
>
47+
{t('about.viewChangelog')}
48+
</div>
49+
</div>
50+
)}
3351
<div class="op-60 text-center text-xs mt-4">
3452
© 2026 MuNET Team
3553
<br />

ChuChartManager/Front/src/global.sass

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,3 +76,36 @@ body
7676
.fade-leave-to
7777
opacity: 0
7878
transform: translateY(-6px)
79+
80+
.changelog-md
81+
line-height: 1.6
82+
83+
h1, h2, h3
84+
margin: 0.8em 0 0.4em
85+
font-weight: 600
86+
87+
h1
88+
font-size: 1.4em
89+
h2
90+
font-size: 1.2em
91+
h3
92+
font-size: 1.05em
93+
94+
ul, ol
95+
padding-left: 1.4em
96+
margin: 0.4em 0
97+
98+
li
99+
margin: 0.2em 0
100+
101+
a
102+
color: oklch(0.6 0.15 var(--hue))
103+
104+
code
105+
background: rgba(0, 0, 0, 0.06)
106+
padding: 0.1em 0.35em
107+
border-radius: 0.3em
108+
font-size: 0.9em
109+
110+
p
111+
margin: 0.5em 0

ChuChartManager/Front/src/locales/en.yaml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,9 @@ about:
5858
title: About
5959
version: App Version
6060
gameVersion: Game Version
61+
changelogTitle: Changelog
62+
updateAvailable: A new version is available
63+
viewChangelog: View changelog
6164
startup:
6265
errorTitle: Errors occurred during startup
6366
fixPrompt: Please review and fix the issues above. Edit or remove the affected files manually if necessary.

ChuChartManager/Front/src/locales/ja.yaml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,9 @@ about:
5858
title: について
5959
version: アプリバージョン
6060
gameVersion: ゲームバージョン
61+
changelogTitle: 更新履歴
62+
updateAvailable: 新しいバージョンが利用可能です
63+
viewChangelog: 更新履歴を見る
6164
startup:
6265
errorTitle: 起動時にエラーが発生しました
6366
fixPrompt: 上記の問題を確認して修正してください。必要に応じて該当ファイルを手動で編集または削除してください。

ChuChartManager/Front/src/locales/zh.yaml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,9 @@ about:
5858
title: 关于
5959
version: 应用版本
6060
gameVersion: 游戏版本
61+
changelogTitle: 更新日志
62+
updateAvailable: 有新版本可用
63+
viewChangelog: 查看更新日志
6164
startup:
6265
errorTitle: 启动时发生错误
6366
fixPrompt: 请检查并修复以上问题,必要时手动修改或删除对应文件。
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
import { computed, ref } from 'vue'
2+
import { useStorage } from '@vueuse/core'
3+
import { locale } from '@/locales'
4+
import { appVersion } from '@/store/refs'
5+
6+
// TODO(部署): 由运维部署 COS bucket 后填入实际地址
7+
// 约定文件:
8+
// {COS_BASE}/ccm.json → { "version": "26.1" }
9+
// {COS_BASE}/ccm-changelog/{ver}.{loc}.md → 对应版本/语言的更新日志
10+
// 未部署时 fetch 失败,静默降级为「无更新」
11+
const COS_BASE = ''
12+
13+
export interface AppUpdateInfo {
14+
version: string
15+
}
16+
17+
export const appUpdateInfo = ref<AppUpdateInfo | null>(null)
18+
19+
export const getCleanVersion = (v: string) => v.split('+')[0]
20+
21+
function compareVersion(a: string, b: string): number {
22+
const pa = getCleanVersion(a).split('.').map(Number)
23+
const pb = getCleanVersion(b).split('.').map(Number)
24+
const len = Math.max(pa.length, pb.length)
25+
for (let i = 0; i < len; i++) {
26+
const da = pa[i] || 0
27+
const db = pb[i] || 0
28+
if (da !== db) return da - db
29+
}
30+
return 0
31+
}
32+
33+
export const hasUpdate = computed(() => {
34+
const remote = appUpdateInfo.value?.version
35+
const local = appVersion.value?.version
36+
if (!remote || !local) return false
37+
return compareVersion(remote, local) > 0
38+
})
39+
40+
export async function checkAppUpdate() {
41+
if (!COS_BASE) return
42+
try {
43+
const res = await fetch(`${COS_BASE}/ccm.json`, { cache: 'no-cache' })
44+
if (!res.ok) return
45+
appUpdateInfo.value = await res.json()
46+
if (appUpdateInfo.value?.version) eagerFetchChangelog(appUpdateInfo.value.version)
47+
} catch (e) {
48+
console.error('Failed to get app update info:', e)
49+
}
50+
}
51+
52+
export const showChangelogModal = ref(false)
53+
export const changelogContent = ref('')
54+
export const changelogTargetVersion = ref('')
55+
export const changelogAutoPopupDone = ref(false)
56+
export const lastShownChangelogVersion = useStorage('ccm-last-shown-changelog', '')
57+
58+
function getLocaleFallbackChain(): string[] {
59+
const current = locale.value
60+
const chain = [current]
61+
if (current.includes('-')) chain.push(current.split('-')[0])
62+
if (!chain.includes('en')) chain.push('en')
63+
return chain
64+
}
65+
66+
async function fetchChangelog(ver: string): Promise<string> {
67+
if (!COS_BASE) return ''
68+
const cleanVer = getCleanVersion(ver)
69+
for (const loc of getLocaleFallbackChain()) {
70+
try {
71+
const res = await fetch(`${COS_BASE}/ccm-changelog/${cleanVer}.${loc}.md`, { cache: 'no-cache' })
72+
if (res.ok) return await res.text()
73+
} catch {
74+
// 网络错误,尝试下一个 locale
75+
}
76+
}
77+
return ''
78+
}
79+
80+
const changelogCache = new Map<string, Promise<string>>()
81+
let openChangelogRequestId = 0
82+
83+
function getChangelogCacheKey(ver: string) {
84+
return `${getCleanVersion(ver)}|${getLocaleFallbackChain().join('>')}`
85+
}
86+
87+
export function eagerFetchChangelog(ver: string) {
88+
const key = getChangelogCacheKey(ver)
89+
if (!changelogCache.has(key)) changelogCache.set(key, fetchChangelog(ver))
90+
}
91+
92+
async function getChangelogCached(ver: string): Promise<string> {
93+
const key = getChangelogCacheKey(ver)
94+
const cached = changelogCache.get(key)
95+
if (cached) return cached
96+
const promise = fetchChangelog(ver)
97+
changelogCache.set(key, promise)
98+
return promise
99+
}
100+
101+
export async function openChangelog(ver: string, options?: { showAfterLoaded?: boolean; skipIfEmpty?: boolean }) {
102+
const requestId = ++openChangelogRequestId
103+
const cleanVer = getCleanVersion(ver)
104+
const showAfterLoaded = !!options?.showAfterLoaded
105+
const skipIfEmpty = !!options?.skipIfEmpty
106+
107+
changelogTargetVersion.value = cleanVer
108+
changelogContent.value = ''
109+
110+
if (!showAfterLoaded) showChangelogModal.value = true
111+
112+
const content = await getChangelogCached(ver)
113+
if (requestId !== openChangelogRequestId) return false
114+
if (skipIfEmpty && !content) {
115+
showChangelogModal.value = false
116+
return false
117+
}
118+
119+
changelogContent.value = content
120+
if (showAfterLoaded) showChangelogModal.value = true
121+
return true
122+
}

0 commit comments

Comments
 (0)