feat: add in-app release update notices

- add a public version updates endpoint that reads GitHub releases and filters tags above the current app version

- show a brand-area update dot and release modal in the web app with shared DTOs and query helpers

- cover the endpoint and sidebar indicator flow with regression tests
This commit is contained in:
SmileQWQ
2026-05-10 18:40:06 +08:00
parent c36cfec236
commit 1ed1e01f8d
11 changed files with 376 additions and 14 deletions

View File

@@ -15,6 +15,7 @@ import { notificationRoutes } from './routes/notifications'
import { aiRoutes } from './routes/ai'
import { importRoutes } from './routes/imports'
import { tagRoutes } from './routes/tags'
import { versionRoutes } from './routes/version'
import { verifyToken } from './services/auth.service'
export async function buildApp() {
@@ -75,7 +76,8 @@ export async function buildApp() {
url === '/api/v1/auth/login' ||
url === '/api/v1/auth/login-options' ||
url === '/api/v1/auth/forgot-password/request' ||
url === '/api/v1/auth/forgot-password/reset'
url === '/api/v1/auth/forgot-password/reset' ||
url === '/api/v1/version/updates'
) {
return
}
@@ -103,6 +105,7 @@ export async function buildApp() {
await notificationRoutes(router)
await aiRoutes(router)
await importRoutes(router)
await versionRoutes(router)
},
{ prefix: '/api/v1' }
)

View File

@@ -0,0 +1,25 @@
import { FastifyInstance } from 'fastify'
import { z } from 'zod'
import { sendError, sendOk } from '../http'
import { getVersionUpdateSummary } from '../services/version-update.service'
export async function versionRoutes(app: FastifyInstance) {
app.get('/version/updates', async (request, reply) => {
const parsed = z
.object({
currentVersion: z.string().min(1).max(50)
})
.safeParse(request.query)
if (!parsed.success) {
return sendError(reply, 422, 'validation_error', 'Invalid currentVersion query', parsed.error.flatten())
}
try {
const summary = await getVersionUpdateSummary(parsed.data.currentVersion)
return sendOk(reply, summary)
} catch (error) {
return sendError(reply, 502, 'version_update_fetch_failed', error instanceof Error ? error.message : 'Failed to fetch releases')
}
})
}

View File

@@ -0,0 +1,88 @@
import type { ReleaseUpdateDto, VersionUpdateSummaryDto } from '@subtracker/shared'
const REPO_RELEASES_API = 'https://api.github.com/repos/Smile-QWQ/SubTracker/releases'
type GitHubRelease = {
tag_name: string
name: string | null
body: string | null
html_url: string
published_at: string | null
prerelease: boolean
draft: boolean
}
type ParsedVersion = [number, number, number]
function normalizeVersionTag(input: string) {
return String(input ?? '')
.trim()
.replace(/^v/i, '')
}
function parseSemverLike(input: string): ParsedVersion | null {
const normalized = normalizeVersionTag(input)
const match = normalized.match(/^(\d+)\.(\d+)\.(\d+)$/)
if (!match) return null
return [Number(match[1]), Number(match[2]), Number(match[3])]
}
function compareParsedVersions(a: ParsedVersion, b: ParsedVersion) {
for (let index = 0; index < 3; index += 1) {
if (a[index] !== b[index]) {
return a[index] - b[index]
}
}
return 0
}
export function isVersionGreaterThan(currentVersion: string, candidateVersion: string) {
const current = parseSemverLike(currentVersion)
const candidate = parseSemverLike(candidateVersion)
if (!current || !candidate) return false
return compareParsedVersions(candidate, current) > 0
}
export async function getVersionUpdateSummary(currentVersion: string): Promise<VersionUpdateSummaryDto> {
const normalizedCurrentVersion = normalizeVersionTag(currentVersion) || '0.0.0'
const response = await fetch(REPO_RELEASES_API, {
headers: {
Accept: 'application/vnd.github+json',
'User-Agent': 'SubTracker-Version-Checker'
}
})
if (!response.ok) {
throw new Error(`获取版本更新失败HTTP ${response.status}`)
}
const releases = (await response.json()) as GitHubRelease[]
const mappedReleases: ReleaseUpdateDto[] = releases
.filter((item) => !item.draft)
.map((item) => {
const normalizedTag = normalizeVersionTag(item.tag_name)
return {
tagName: item.tag_name,
version: normalizedTag,
name: item.name?.trim() || item.tag_name,
body: item.body?.trim() || '',
htmlUrl: item.html_url,
publishedAt: item.published_at || '',
isPrerelease: item.prerelease
}
})
.filter((item) => isVersionGreaterThan(normalizedCurrentVersion, item.version))
.sort((a, b) => {
const parsedA = parseSemverLike(a.version)
const parsedB = parseSemverLike(b.version)
if (!parsedA || !parsedB) return 0
return compareParsedVersions(parsedB, parsedA)
})
return {
currentVersion: normalizedCurrentVersion,
latestVersion: mappedReleases[0]?.version ?? null,
hasUpdate: mappedReleases.length > 0,
releases: mappedReleases
}
}

View File

@@ -0,0 +1,54 @@
import Fastify, { type FastifyInstance } from 'fastify'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const versionRouteMocks = vi.hoisted(() => ({
getVersionUpdateSummaryMock: vi.fn()
}))
vi.mock('../../src/services/version-update.service', () => ({
getVersionUpdateSummary: versionRouteMocks.getVersionUpdateSummaryMock
}))
import { versionRoutes } from '../../src/routes/version'
describe('version routes', () => {
let app: FastifyInstance
beforeEach(async () => {
app = Fastify()
await versionRoutes(app)
versionRouteMocks.getVersionUpdateSummaryMock.mockReset()
})
afterEach(async () => {
await app.close()
})
it('returns release updates above current version', async () => {
versionRouteMocks.getVersionUpdateSummaryMock.mockResolvedValue({
currentVersion: '0.7.6',
latestVersion: '0.8.0',
hasUpdate: true,
releases: [
{
tagName: 'v0.8.0',
version: '0.8.0',
name: 'v0.8.0',
body: '## 更新内容',
htmlUrl: 'https://github.com/Smile-QWQ/SubTracker/releases/tag/v0.8.0',
publishedAt: '2026-05-10T00:00:00.000Z',
isPrerelease: false
}
]
})
const res = await app.inject({
method: 'GET',
url: '/version/updates?currentVersion=0.7.6'
})
expect(res.statusCode).toBe(200)
expect(res.json().data.hasUpdate).toBe(true)
expect(res.json().data.latestVersion).toBe('0.8.0')
})
})

View File

@@ -48,14 +48,15 @@
<div class="logo" :class="{ 'logo--collapsed': siderCollapsed }">
<template v-if="!siderCollapsed">
<div class="logo__brand">
<div class="logo__icon">
<n-icon :size="18">
<wallet-outline />
</n-icon>
</div>
<div class="logo__stack">
<span class="logo__text">SubTracker</span>
<span class="logo__version">{{ appVersion }}</span>
<div class="logo__brand-trigger" @click="openVersionUpdatePanel">
<div class="logo__icon">
<img :src="brandLogoUrl" alt="SubTracker logo" class="logo__image" />
<span v-if="hasVersionUpdate" class="logo__update-dot" />
</div>
<div class="logo__stack">
<span class="logo__text">SubTracker</span>
<span class="logo__version">{{ appVersion }}</span>
</div>
</div>
</div>
<n-button quaternary circle class="logo__toggle" @click="siderCollapsed = !siderCollapsed">
@@ -154,6 +155,44 @@
</n-space>
</n-space>
</n-modal>
<n-modal
:show="versionUpdateModalVisible"
preset="card"
title="版本更新"
style="width: min(860px, calc(100vw - 24px))"
@update:show="versionUpdateModalVisible = $event"
>
<n-space vertical :size="16">
<n-alert v-if="versionUpdateState?.hasUpdate" type="warning" :show-icon="false">
检测到新版本当前版本 {{ versionUpdateState.currentVersion }}最新版本 {{ versionUpdateState.latestVersion }}
</n-alert>
<n-alert v-else type="success" :show-icon="false">
当前已经是最新版本{{ versionUpdateState?.currentVersion || appVersion }}
</n-alert>
<n-space vertical v-if="versionUpdateState?.releases?.length" :size="12">
<n-card v-for="release in versionUpdateState.releases" :key="release.tagName" size="small" embedded>
<n-space vertical :size="8">
<n-space justify="space-between" align="center">
<div>
<div class="release-item__title">{{ release.name }}</div>
<div class="release-item__meta">
{{ release.tagName }} · {{ release.publishedAt ? formatReleaseDate(release.publishedAt) : '发布时间未知' }}
</div>
</div>
<n-button tag="a" :href="release.htmlUrl" target="_blank" rel="noreferrer" secondary>
查看 Release
</n-button>
</n-space>
<div class="release-item__markdown" v-html="renderReleaseBody(release.body)" />
</n-space>
</n-card>
</n-space>
<div v-else class="card-muted">暂无比当前版本更高的 release</div>
</n-space>
</n-modal>
</template>
</n-message-provider>
</n-config-provider>
@@ -166,6 +205,7 @@ import { useWindowSize } from '@vueuse/core'
import {
NAlert,
NButton,
NCard,
NConfigProvider,
NDrawer,
NDrawerContent,
@@ -203,11 +243,14 @@ import {
SunnyOutline,
WalletOutline
} from '@vicons/ionicons5'
import brandLogoUrl from '@/assets/brand-logo.png'
import { api } from '@/composables/api'
import { useSettingsQuery } from '@/composables/settings-query'
import { useVersionUpdateQuery } from '@/composables/version-update-query'
import { useThemePreference, type ThemePreference } from '@/composables/theme-preference'
import { useAuthStore } from '@/stores/auth'
import { isRememberedSession } from '@/utils/auth-storage'
import { renderMarkdownToHtml } from '@/utils/simple-markdown'
const route = useRoute()
const router = useRouter()
@@ -217,6 +260,7 @@ const { message } = createDiscreteApi(['message'])
const appVersion = __APP_VERSION__
const mobileMenuVisible = ref(false)
const siderCollapsed = ref(false)
const versionUpdateModalVisible = ref(false)
const { width } = useWindowSize()
const changingDefaultPassword = ref(false)
const defaultPasswordForm = reactive({
@@ -225,6 +269,7 @@ const defaultPasswordForm = reactive({
})
const { data: settings } = useSettingsQuery()
const { data: versionUpdateState } = useVersionUpdateQuery(appVersion)
function renderMenuIcon(icon: typeof GridOutline) {
return () => h(NIcon, null, { default: () => h(icon) })
@@ -258,6 +303,7 @@ const themeToggleTooltip = computed(() => {
const nextLabel = currentLabel === '深色' ? '浅色' : '深色'
return `当前主题:${currentLabel},点击切换到${nextLabel}`
})
const hasVersionUpdate = computed(() => Boolean(versionUpdateState.value?.hasUpdate))
watchEffect(() => {
if (typeof document === 'undefined') return
@@ -334,6 +380,22 @@ async function submitDefaultPasswordChange() {
changingDefaultPassword.value = false
}
}
function openVersionUpdatePanel() {
versionUpdateModalVisible.value = true
}
function renderReleaseBody(body: string) {
return renderMarkdownToHtml(body)
}
function formatReleaseDate(value: string) {
try {
return new Date(value).toLocaleString('zh-CN')
} catch {
return value
}
}
</script>
<style scoped>
@@ -420,6 +482,14 @@ async function submitDefaultPasswordChange() {
min-width: 0;
}
.logo__brand-trigger {
display: inline-flex;
align-items: center;
gap: 10px;
min-width: 0;
cursor: pointer;
}
.logo__stack {
display: flex;
flex-direction: column;
@@ -433,15 +503,31 @@ async function submitDefaultPasswordChange() {
}
.logo__icon {
width: 30px;
height: 30px;
border-radius: 10px;
background: linear-gradient(135deg, #2563eb 0%, #4f46e5 100%);
color: #fff;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
position: relative;
}
.logo__image {
width: 100%;
height: 100%;
display: block;
object-fit: contain;
}
.logo__update-dot {
position: absolute;
top: 0;
right: 0;
width: 8px;
height: 8px;
border-radius: 999px;
background: #ef4444;
box-shadow: 0 0 0 2px var(--app-surface);
}
.logo__text {
@@ -465,6 +551,38 @@ async function submitDefaultPasswordChange() {
margin: 0 auto;
}
.release-item__title {
font-weight: 700;
color: var(--app-text-strong);
}
.release-item__meta {
margin-top: 4px;
font-size: 12px;
color: var(--app-text-secondary);
}
.release-item__markdown {
color: var(--app-text-primary);
line-height: 1.7;
}
.release-item__markdown :deep(h1),
.release-item__markdown :deep(h2),
.release-item__markdown :deep(h3) {
margin: 0 0 8px;
font-size: 15px;
}
.release-item__markdown :deep(p) {
margin: 0 0 8px;
}
.release-item__markdown :deep(ul) {
margin: 0;
padding-left: 18px;
}
.header {
min-height: 64px;
display: flex;

Binary file not shown.

After

Width:  |  Height:  |  Size: 204 KiB

View File

@@ -30,6 +30,7 @@ import type {
SubscriptionStatus,
Tag,
TelegramConfig,
VersionUpdateSummary,
WallosImportCommitResult,
WallosImportInspectResult
} from '@/types/api'
@@ -144,6 +145,12 @@ export const api = {
return unwrap<LoginOptions>((await client.get('/auth/login-options')) as { data: Envelope<LoginOptions> })
},
async getVersionUpdates(currentVersion: string) {
return unwrap<VersionUpdateSummary>((await client.get('/version/updates', { params: { currentVersion } })) as {
data: Envelope<VersionUpdateSummary>
})
},
async requestForgotPasswordCode(payload: ForgotPasswordRequestPayload) {
return postOnce<{ accepted: boolean }>('/auth/forgot-password/request', payload)
},

View File

@@ -0,0 +1,15 @@
import { useQuery } from '@tanstack/vue-query'
import { api } from '@/composables/api'
export const VERSION_UPDATE_QUERY_KEY = ['version-updates'] as const
export function useVersionUpdateQuery(currentVersion: string) {
return useQuery({
queryKey: [...VERSION_UPDATE_QUERY_KEY, currentVersion],
queryFn: () => api.getVersionUpdates(currentVersion),
staleTime: 10 * 60_000,
gcTime: 30 * 60_000,
refetchOnWindowFocus: false,
enabled: Boolean(currentVersion)
})
}

View File

@@ -307,6 +307,23 @@ export interface ExchangeRateSnapshot {
isStale: boolean
}
export interface ReleaseUpdate {
tagName: string
version: string
name: string
body: string
htmlUrl: string
publishedAt: string
isPrerelease: boolean
}
export interface VersionUpdateSummary {
currentVersion: string
latestVersion: string | null
hasUpdate: boolean
releases: ReleaseUpdate[]
}
export interface LogoSearchResult {
label: string
logoUrl: string

View File

@@ -0,0 +1,18 @@
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
describe('app layout version update brand indicator', () => {
it('shows update dot and modal entry around the logo brand', () => {
const source = readFileSync('src/App.vue', 'utf8')
expect(source).toContain('useVersionUpdateQuery(appVersion)')
expect(source).toContain('logo__brand-trigger')
expect(source).toContain('logo__update-dot')
expect(source).toContain('brandLogoUrl')
expect(source).toContain('logo__image')
expect(source).toContain('openVersionUpdatePanel')
expect(source).toContain('title="版本更新"')
expect(source).toContain('renderReleaseBody')
expect(source).toContain('查看 Release')
})
})

View File

@@ -531,6 +531,23 @@ export interface ExchangeRateSnapshotDto {
isStale: boolean
}
export interface ReleaseUpdateDto {
tagName: string
version: string
name: string
body: string
htmlUrl: string
publishedAt: string
isPrerelease: boolean
}
export interface VersionUpdateSummaryDto {
currentVersion: string
latestVersion: string | null
hasUpdate: boolean
releases: ReleaseUpdateDto[]
}
export interface LogoSearchResultDto {
label: string
logoUrl: string