Compare commits

..
2 Commits
Author SHA1 Message Date
Blizzard e27adfe64c Merge pull request 'Serve admin console at root path' (#1) from feat/dev into main
ci-cd / verify (push) Successful in 25s
ci-cd / deploy (push) Successful in 2m46s
Reviewed-on: #1
2026-10-01 06:14:43 +00:00
Blizzard 8de48cb44d Serve admin console at root path
ci-cd / verify (push) Successful in 1m48s
ci-cd / deploy (push) Skipped
2026-10-01 14:13:04 +08:00
12 changed files with 75 additions and 99 deletions
+2 -2
View File
@@ -1,6 +1,6 @@
# 肉垫计划 # 肉垫计划
核心功能:多宠物档案、日常照顾任务、多宠物遛狗与主动位置分享、约遛。后端用 Gin + GORM + PostgreSQL,管理端用 React + Vite + shadcn/ui + Tailwind,构建后通过 `go:embed` 随服务提供 `/admin/`。 核心功能:多宠物档案、日常照顾任务、多宠物遛狗与主动位置分享、约遛。后端用 Gin + GORM + PostgreSQL,管理端用 React + Vite + shadcn/ui + Tailwind,构建后通过 `go:embed` 随服务提供 `/`。
## 目录 ## 目录
@@ -20,7 +20,7 @@
3. 若数据库还不存在,在 `pets-be` 目录执行一次 `go run ./cmd/dbinit`。 3. 若数据库还不存在,在 `pets-be` 目录执行一次 `go run ./cmd/dbinit`。
4. 执行 `go run ./cmd/server`。 4. 执行 `go run ./cmd/server`。
默认业务接口 `http://localhost:9090/api`,管理端 `http://localhost:9090/admin/`。小程序生产 API 地址是 `https://pet.sundynix.cn/api`。 默认业务接口 `http://localhost:9090/api`,管理端 `http://localhost:9090/`。小程序生产 API 地址是 `https://pet.sundynix.cn/api`。
配置可用 `PETS_` 前缀的环境变量覆盖,例如 `PETS_POSTGRES_PASSWORD`。生产模式必须设置 `PETS_ADMIN_PASSWORD`,微信登录需要 App ID 和 App Secret。`auth.dev_login` 只能在 debug 模式开启。 配置可用 `PETS_` 前缀的环境变量覆盖,例如 `PETS_POSTGRES_PASSWORD`。生产模式必须设置 `PETS_ADMIN_PASSWORD`,微信登录需要 App ID 和 App Secret。`auth.dev_login` 只能在 debug 模式开启。
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+5 -5
View File
@@ -1,12 +1,12 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang="zh-CN">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/admin/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>admin</title> <title>肉垫计划 · 管理端</title>
<script type="module" crossorigin src="/admin/assets/index-DmUsbLpt.js"></script> <script type="module" crossorigin src="/assets/index-COwxdvZQ.js"></script>
<link rel="stylesheet" crossorigin href="/admin/assets/index-BQA5NViF.css"> <link rel="stylesheet" crossorigin href="/assets/index-BQA5NViF.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+10 -6
View File
@@ -12,7 +12,7 @@ import (
//go:embed all:dist //go:embed all:dist
var distFS embed.FS var distFS embed.FS
// Register 把内嵌的后台 SPA 挂到 /admin 下,index.html 做 SPA fallback // Register 把内嵌的后台 SPA 挂到根路径,保留 /api 给业务接口。
func Register(r *gin.Engine) { func Register(r *gin.Engine) {
sub, err := fs.Sub(distFS, "dist") sub, err := fs.Sub(distFS, "dist")
if err != nil { if err != nil {
@@ -21,9 +21,7 @@ func Register(r *gin.Engine) {
fileServer := http.FileServer(http.FS(sub)) fileServer := http.FileServer(http.FS(sub))
handler := func(c *gin.Context) { handler := func(c *gin.Context) {
// 去掉前缀 /admin,交给内嵌文件服务器 reqPath := strings.TrimPrefix(c.Request.URL.Path, "/")
reqPath := strings.TrimPrefix(c.Request.URL.Path, "/admin")
reqPath = strings.TrimPrefix(reqPath, "/")
// 若请求的是真实存在的静态资源,直出;否则回退 index.html(前端路由) // 若请求的是真实存在的静态资源,直出;否则回退 index.html(前端路由)
if reqPath != "" { if reqPath != "" {
@@ -43,6 +41,12 @@ func Register(r *gin.Engine) {
c.Data(http.StatusOK, "text/html; charset=utf-8", data) c.Data(http.StatusOK, "text/html; charset=utf-8", data)
} }
r.GET("/admin", func(c *gin.Context) { c.Redirect(http.StatusFound, "/admin/") }) r.GET("/", handler)
r.GET("/admin/*filepath", handler) r.NoRoute(func(c *gin.Context) {
if c.Request.Method != http.MethodGet || strings.HasPrefix(c.Request.URL.Path, "/api/") || c.Request.URL.Path == "/api" || strings.HasPrefix(c.Request.URL.Path, "/assets/") {
c.Status(http.StatusNotFound)
return
}
handler(c)
})
} }
+36
View File
@@ -0,0 +1,36 @@
package admin
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
"github.com/sundynix/pets-be/internal/config"
"github.com/sundynix/pets-be/internal/httpapi"
)
func TestAdminAtRootAndAPIRoutes(t *testing.T) {
gin.SetMode(gin.TestMode)
r := gin.New()
httpapi.New(nil, nil, &config.Config{}).Register(r)
Register(r)
for _, tc := range []struct {
path string
status int
body string
}{
{path: "/", status: http.StatusOK, body: "<div id=\"root\"></div>"},
{path: "/api/ping", status: http.StatusOK, body: "pong"},
{path: "/api/missing", status: http.StatusNotFound},
{path: "/assets/missing.js", status: http.StatusNotFound},
} {
recorder := httptest.NewRecorder()
r.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, tc.path, nil))
if recorder.Code != tc.status || (tc.body != "" && !strings.Contains(recorder.Body.String(), tc.body)) {
t.Errorf("GET %s: got %d %q, want %d containing %q", tc.path, recorder.Code, recorder.Body.String(), tc.status, tc.body)
}
}
}
+1 -1
View File
@@ -1,5 +1,5 @@
# 管理端 # 管理端
React + Vite + Tailwind + shadcn/ui。`npm ci && npm run build` 会把产物输出到 `../../internal/admin/dist`,由 Go 服务嵌入并提供 `/admin/`。 React + Vite + Tailwind + shadcn/ui。`npm ci && npm run build` 会把产物输出到 `../../internal/admin/dist`,由 Go 服务嵌入并提供 `/`。
开发时执行 `npm run dev`,Vite 会把 `/api` 代理到本地 9090 端口。 开发时执行 `npm run dev`,Vite 会把 `/api` 代理到本地 9090 端口。
+2 -2
View File
@@ -1,10 +1,10 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang="zh-CN">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>admin</title> <title>肉垫计划 · 管理端</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
-58
View File
@@ -17,7 +17,6 @@
"lucide-react": "^1.23.0", "lucide-react": "^1.23.0",
"react": "^19.2.7", "react": "^19.2.7",
"react-dom": "^19.2.7", "react-dom": "^19.2.7",
"react-router-dom": "^7.18.1",
"recharts": "^3.10.1", "recharts": "^3.10.1",
"tailwind-merge": "^3.6.0" "tailwind-merge": "^3.6.0"
}, },
@@ -1601,19 +1600,6 @@
"node": ">= 6" "node": ">= 6"
} }
}, },
"node_modules/cookie": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
"integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/cssesc": { "node_modules/cssesc": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
@@ -3090,44 +3076,6 @@
} }
} }
}, },
"node_modules/react-router": {
"version": "7.18.1",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.1.tgz",
"integrity": "sha512-GDLgg3i3uM0aeJO3Fm+TCS+sDQ7gu12T6x0qdTEzcwqEfleci7JwugVNIF3U//0FWKnJT7ptG+20B2jfDqnZAg==",
"license": "MIT",
"dependencies": {
"cookie": "^1.0.1",
"set-cookie-parser": "^2.6.0"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
}
}
},
"node_modules/react-router-dom": {
"version": "7.18.1",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.1.tgz",
"integrity": "sha512-KaZh+X/6UtEp28x51AUYZDMg9NGoz2ja3dNHa+ta/tk40vCzKhQ/RypCWBMLbmDr6//E24Vv5uPsrqXFozdkAg==",
"license": "MIT",
"dependencies": {
"react-router": "7.18.1"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
}
},
"node_modules/react-style-singleton": { "node_modules/react-style-singleton": {
"version": "2.2.3", "version": "2.2.3",
"resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz",
@@ -3334,12 +3282,6 @@
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/set-cookie-parser": {
"version": "2.7.2",
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
"license": "MIT"
},
"node_modules/source-map-js": { "node_modules/source-map-js": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
-1
View File
@@ -19,7 +19,6 @@
"lucide-react": "^1.23.0", "lucide-react": "^1.23.0",
"react": "^19.2.7", "react": "^19.2.7",
"react-dom": "^19.2.7", "react-dom": "^19.2.7",
"react-router-dom": "^7.18.1",
"recharts": "^3.10.1", "recharts": "^3.10.1",
"tailwind-merge": "^3.6.0" "tailwind-merge": "^3.6.0"
}, },
+1 -4
View File
@@ -1,13 +1,10 @@
import { StrictMode } from 'react' import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client' import { createRoot } from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import './index.css' import './index.css'
import App from './App.tsx' import App from './App.tsx'
createRoot(document.getElementById('root')!).render( createRoot(document.getElementById('root')!).render(
<StrictMode> <StrictMode>
<BrowserRouter basename="/admin"> <App />
<App />
</BrowserRouter>
</StrictMode>, </StrictMode>,
) )
+1 -1
View File
@@ -4,7 +4,7 @@ import path from 'node:path'
// https://vite.dev/config/ // https://vite.dev/config/
export default defineConfig({ export default defineConfig({
base: '/admin/', base: '/',
plugins: [react()], plugins: [react()],
resolve: { resolve: {
alias: { alias: {