mirror of
https://github.com/Smile-QWQ/SubTracker.git
synced 2026-09-03 06:23:58 +08:00
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:
@@ -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' }
|
||||
)
|
||||
|
||||
25
apps/api/src/routes/version.ts
Normal file
25
apps/api/src/routes/version.ts
Normal 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')
|
||||
}
|
||||
})
|
||||
}
|
||||
88
apps/api/src/services/version-update.service.ts
Normal file
88
apps/api/src/services/version-update.service.ts
Normal 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
|
||||
}
|
||||
}
|
||||
54
apps/api/tests/integration/version-routes.test.ts
Normal file
54
apps/api/tests/integration/version-routes.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
@@ -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;
|
||||
|
||||
BIN
apps/web/src/assets/brand-logo.png
Normal file
BIN
apps/web/src/assets/brand-logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 204 KiB |
@@ -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)
|
||||
},
|
||||
|
||||
15
apps/web/src/composables/version-update-query.ts
Normal file
15
apps/web/src/composables/version-update-query.ts
Normal 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)
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
|
||||
18
apps/web/tests/unit/components/version-update-brand.test.ts
Normal file
18
apps/web/tests/unit/components/version-update-brand.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user