{{ site.footer || t('footer.copyright') }}
++ {{ t('footer.versions', { frontend: version.frontend, backend: version.backendLabel }) + }} | {{ t('footer.build', { build: version.build }) }} +
diff --git a/frontend/src/i18n/locales/en-US.ts b/frontend/src/i18n/locales/en-US.ts index 78aca0e..4ade3cc 100644 --- a/frontend/src/i18n/locales/en-US.ts +++ b/frontend/src/i18n/locales/en-US.ts @@ -48,6 +48,8 @@ const enUS: MessageSchema = { }, footer: { copyright: 'Rill streaming platform · Page frame placeholder · All images are blank placeholders', + versions: 'Frontend {frontend} | Backend {backend}', + build: 'Build {build}', }, language: { title: 'Language', diff --git a/frontend/src/i18n/locales/ja-JP.ts b/frontend/src/i18n/locales/ja-JP.ts index 8f1cd7a..38fb22f 100644 --- a/frontend/src/i18n/locales/ja-JP.ts +++ b/frontend/src/i18n/locales/ja-JP.ts @@ -48,6 +48,8 @@ const jaJP: MessageSchema = { }, footer: { copyright: 'Rill 動画配信サービス · ページフレームのプレースホルダー · 画像はすべて空のプレースホルダーです', + versions: 'フロントエンド {frontend} | バックエンド {backend}', + build: 'ビルド {build}', }, language: { title: '言語', diff --git a/frontend/src/i18n/locales/zh-CN.ts b/frontend/src/i18n/locales/zh-CN.ts index 1c0f5bf..ffaff0a 100644 --- a/frontend/src/i18n/locales/zh-CN.ts +++ b/frontend/src/i18n/locales/zh-CN.ts @@ -46,6 +46,8 @@ const zhCN = { }, footer: { copyright: 'Rill 流媒体服务平台 · 页面框架占位 · 所有图片均为空白占位框', + versions: '前端 {frontend} | 后端 {backend}', + build: '构建 {build}', }, language: { title: '语言', diff --git a/frontend/src/main.ts b/frontend/src/main.ts index 0de6bee..0510503 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -10,6 +10,7 @@ import { setUnauthorizedHandler } from './api/http' import { useAuthStore } from './stores/auth' import { useNavStore } from './stores/nav' import { useSiteStore } from './stores/site' +import { useVersionStore } from './stores/version' const app = createApp(App) const pinia = createPinia() @@ -27,6 +28,9 @@ void site.load() const nav = useNavStore() void nav.load() +const version = useVersionStore() +void version.load() + setUnauthorizedHandler(() => { const wasAuthenticated = auth.isAuthenticated auth.logout() diff --git a/frontend/src/stores/version.ts b/frontend/src/stores/version.ts new file mode 100644 index 0000000..0c389cb --- /dev/null +++ b/frontend/src/stores/version.ts @@ -0,0 +1,33 @@ +import { computed, ref } from 'vue' +import { defineStore } from 'pinia' +import { getHealth } from '@/api/version' + +function buildLabel(): string { + const parts: string[] = [] + if (__GIT_HASH__) { + parts.push(`+${__GIT_HASH__}`) + } + if (__BUILD_TIME__) { + parts.push(__BUILD_TIME__) + } + return parts.join(' · ') +} + +export const useVersionStore = defineStore('version', () => { + const frontend = ref(`v${__APP_VERSION__}`) + const build = ref(buildLabel()) + const backend = ref('') + + const backendLabel = computed(() => (backend.value ? `v${backend.value}` : '—')) + + async function load() { + try { + const info = await getHealth() + backend.value = info.version + } catch { + // 加载失败时显示占位符 + } + } + + return { frontend, build, backend, backendLabel, load } +}) diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 34a236e..d7f6ebe 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -1,3 +1,5 @@ +import { execSync } from 'node:child_process' +import { readFileSync } from 'node:fs' import { fileURLToPath, URL } from 'node:url' import { defineConfig } from 'vite' @@ -5,8 +7,33 @@ import vue from '@vitejs/plugin-vue' import vueDevTools from 'vite-plugin-vue-devtools' import tailwindcss from '@tailwindcss/vite' +function gitHash(): string { + try { + return execSync('git rev-parse --short HEAD', { + cwd: fileURLToPath(new URL('.', import.meta.url)), + stdio: ['ignore', 'pipe', 'ignore'], + }) + .toString() + .trim() + } catch { + return '' + } +} + +const pkg = JSON.parse(readFileSync(new URL('./package.json', import.meta.url), 'utf-8')) as { + version: string +} + +// 构建时间(本地时区 YYYY-MM-DD HH:mm) +const buildTime = new Date().toLocaleString('sv-SE').slice(0, 16) + // https://vite.dev/config/ export default defineConfig({ + define: { + __APP_VERSION__: JSON.stringify(pkg.version), + __GIT_HASH__: JSON.stringify(gitHash()), + __BUILD_TIME__: JSON.stringify(buildTime), + }, plugins: [ vue(), vueDevTools(), diff --git a/internal/api/api.go b/internal/api/api.go index 67e7bfc..ea2fed9 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -20,6 +20,7 @@ import ( "rill/internal/site" "rill/internal/user" "rill/internal/usergroup" + "rill/internal/version" "rill/internal/videocategory" ) @@ -107,12 +108,13 @@ func RegisterRoutes(rg *gin.RouterGroup, db *gorm.DB, cfg *config.Config) { // HealthResponse 健康检查响应。 type HealthResponse struct { - Status string `json:"status" example:"ok"` - Error string `json:"error,omitempty" example:"database unavailable"` + Status string `json:"status" example:"ok"` + Version string `json:"version" example:"0.1.0"` + Error string `json:"error,omitempty" example:"database unavailable"` } // @Summary Health check -// @Description Check service and database connectivity; returns 503 when the database is unavailable. +// @Description Check service and database connectivity; returns 503 when the database is unavailable. The version field reports the current server version. // @Tags public // @Produce json // @Success 200 {object} api.HealthResponse @@ -121,9 +123,9 @@ type HealthResponse struct { func health(db *gorm.DB) gin.HandlerFunc { return func(c *gin.Context) { if err := database.Ping(c.Request.Context(), db); err != nil { - c.JSON(http.StatusServiceUnavailable, HealthResponse{Status: "error", Error: "database unavailable"}) + c.JSON(http.StatusServiceUnavailable, HealthResponse{Status: "error", Version: version.Version, Error: "database unavailable"}) return } - c.JSON(http.StatusOK, HealthResponse{Status: "ok"}) + c.JSON(http.StatusOK, HealthResponse{Status: "ok", Version: version.Version}) } } diff --git a/internal/api/api_test.go b/internal/api/api_test.go index d46566c..8057797 100644 --- a/internal/api/api_test.go +++ b/internal/api/api_test.go @@ -6,6 +6,7 @@ import ( "testing" "rill/internal/testutil" + "rill/internal/version" ) func TestHealth(t *testing.T) { @@ -23,4 +24,7 @@ func TestHealth(t *testing.T) { if resp["status"] != "ok" { t.Errorf("status = %q, 期望 ok", resp["status"]) } + if resp["version"] != version.Version { + t.Errorf("version = %q, 期望 %q", resp["version"], version.Version) + } } diff --git a/internal/version/version.go b/internal/version/version.go new file mode 100644 index 0000000..c7d5ccf --- /dev/null +++ b/internal/version/version.go @@ -0,0 +1,6 @@ +// Package version 保存服务端版本号,构建时可通过 +// -ldflags "-X rill/internal/version.Version=x.y.z" 覆盖。 +package version + +// Version 当前服务端版本。 +var Version = "0.1.0" diff --git a/main.go b/main.go index 0ae01ad..4dca91f 100644 --- a/main.go +++ b/main.go @@ -30,7 +30,7 @@ import ( //go:generate go tool swag init -g main.go -o docs --parseInternal // @title Rill API -// @version 1.0 +// @version 0.1.0 // @description Rill server HTTP API documentation. All endpoints are prefixed with api.prefix (default /api); requests and responses are JSON. // @description Swagger UI: {prefix}/swagger/index.html; OpenAPI JSON: {prefix}/swagger/doc.json. // @description Endpoints are grouped by required permission: public needs no authentication, user needs a Bearer JWT obtained from /auth/login, admin needs an admin user.