Initial commit

This commit is contained in:
2026-06-29 11:16:11 +00:00
commit 6073cbd885
55 changed files with 5132 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
web/dist
data/
dist/
*.db
.env
+180
View File
@@ -0,0 +1,180 @@
# Gitea Actions CI 模板(Go + Vue
本目录提供 **Gitea Actions** 持续集成配置:**可选 go test** + **Docker 多架构构建/推送**Go 编译与 Vue 构建在 Dockerfile 内完成)。
## 项目约定
| 路径 | 说明 |
|------|------|
| `go.mod` | 仓库根目录 |
| `Dockerfile` | 仓库根目录,多阶段构建(含前端 `web/` |
| `web/` | 前端源码;由 Dockerfile 内 `npm install` / `npm run build` 处理,CI 不再单独构建 |
## Dockerfile 要求
工作流的 **Build image** 步骤在仓库根目录(或 Variable `DOCKERFILE` 指定路径)执行 `docker buildx build`**不会**在 CI 里单独装 Node / 跑 `npm`。因此 Dockerfile 必须能独立完成构建与(运行时)启动。
### 基本要求
| 项 | 要求 |
|----|------|
| 位置 | 默认仓库根目录 `Dockerfile`;其他路径用 Variable `DOCKERFILE` |
| 构建上下文 | 仓库根目录 `.`(整个仓库会被 `COPY` 进镜像) |
| Go 版本 | `go.mod``go` 行与 `golang` 基础镜像大版本一致 |
| 多架构 | `go build` 须使用 `GOARCH="$TARGETARCH"`buildx 注入);推荐 `CGO_ENABLED=0` |
| Go 模块代理 | **必须**在镜像内显式设置 `GOPROXY` / `GOSUMDB`(见下);容器内不会自动读取 `go.env` |
| 基础镜像加速 | 支持构建参数 `IMAGE_PREFIX`CI 默认 `docker.1panel.live/library/` |
| 前端(可选) | 存在 `web/` 时在 Dockerfile 内完成 `npm install` + `npm run build` |
### CI 自动传入的构建参数
| 参数 | 默认 | 说明 |
|------|------|------|
| `IMAGE_PREFIX` | `docker.1panel.live/library/` | 基础镜像前缀;`hub` 表示 Docker Hub |
| `GOPROXY` | `https://goproxy.cn,direct` | 与 workflow / Variable 一致 |
| `GOSUMDB` | `sum.golang.google.cn` | checksum 数据库 |
buildx 还会注入 `TARGETARCH``BUILDPLATFORM` 等,无需在 workflow 里写。
### 推荐:Go + Vue 多阶段模板
与本仓库根目录 `Dockerfile` 一致,可按项目调整入口路径与静态资源目录:
```dockerfile
# syntax=docker/dockerfile:1
ARG IMAGE_PREFIX=
FROM --platform=$BUILDPLATFORM ${IMAGE_PREFIX}node:20-alpine AS web-builder
WORKDIR /src/web
COPY web/package.json web/package-lock.json web/.npmrc ./
RUN npm install
COPY web/ ./
RUN npm run build
FROM --platform=$BUILDPLATFORM ${IMAGE_PREFIX}golang:1.25-alpine AS go-builder
ARG TARGETARCH
ARG GOPROXY=https://goproxy.cn,direct
ARG GOSUMDB=sum.golang.google.cn
ENV CGO_ENABLED=0 GOPROXY=${GOPROXY} GOSUMDB=${GOSUMDB}
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY cmd/ ./cmd/
COPY internal/ ./internal/
COPY --from=web-builder /src/web/dist ./internal/api/static/
RUN GOOS=linux GOARCH="$TARGETARCH" \
go build -trimpath -ldflags="-w -s" -o /app ./cmd/app
FROM ${IMAGE_PREFIX}alpine:3.20
RUN apk add --no-cache ca-certificates tzdata tini
COPY --from=go-builder /app /usr/local/bin/app
VOLUME ["/data"]
EXPOSE 8080
ENTRYPOINT ["/sbin/tini", "--"]
CMD ["/usr/local/bin/app", "-data", "/data", "-addr", ":8080"]
```
### 仅 Go(无前端)
删除 `web-builder` stage`go-builder` 中不要 `COPY` 前端产物;其余 `GOPROXY` / `TARGETARCH` / `IMAGE_PREFIX` 要求相同。
### 前端 npm 源(国内)
`web/.npmrc` 配置 registry,并在 Dockerfile 里与 `package.json` 一并 `COPY`
```ini
registry=https://registry.npmmirror.com
```
### 本地验证(与 CI 一致)
```bash
docker buildx build --platform linux/amd64 \
--build-arg IMAGE_PREFIX=docker.1panel.live/library/ \
--build-arg GOPROXY=https://goproxy.cn,direct \
-f Dockerfile -t myapp:test .
```
### 常见 Dockerfile 构建错误
| 报错 | 原因 | 处理 |
|------|------|------|
| `proxy.golang.org` timeout | 镜像内未设 `ENV GOPROXY` | go-builder 阶段加 `ARG`/`ENV GOPROXY` |
| `node` / Vite 版本不符 | 基础镜像 Node 过旧 | 使用 `node:20-alpine` 及以上 |
| 某架构 build 失败 | 未使用 `TARGETARCH` | `GOARCH="$TARGETARCH"` |
| 拉基础镜像 timeout | Hub 不可达 | `--build-arg IMAGE_PREFIX=docker.1panel.live/library/` 或 1Panel 配镜像加速 |
## 一次性配置
1. **Runner**:部署 act_runner,标签含 `ubuntu-latest`,并挂载 `docker.sock`(见 [act-runner/README.md](act-runner/README.md))。
2. **Secret**:仓库 Settings → Actions → Secrets,添加 `REGISTRY_TOKEN`PAT`write:package` 权限)。
3. **Variable(推荐)**:若 runner 与 Gitea 同机、或 `gitea.server_url` 为内网地址,必须设置 `REGISTRY=你的公网域名`(如 `git.example.com`**不要**填 `172.17.0.1:13827`)。
4. **Gitea Registry**:服务端 `[packages] ENABLED = true``ROOT_URL` 正确;穿透场景建议 `PUBLIC_URL_DETECTION = never`Gitea 1.26+)。
`REGISTRY` 未设置时,会从 `GITEA_ROOT_URL``gitea.server_url` 推断;均为内网地址时 workflow 会提前失败并提示。
## 可选 Variables
仓库 Settings → Actions → Variables(留空则用默认值):
| 变量 | 默认 | 说明 |
|------|------|------|
| `REGISTRY` | 见下方推断顺序 | **公网** Registry 主机名,如 `git.example.com`(勿用内网 IP:13827 |
| `GITEA_ROOT_URL` | (空) | 当 `gitea.server_url` 为内网时,可设 `https://git.example.com/` |
| `IMAGE_NAME` | 仓库名小写 | 镜像名,非 owner/repo 全路径 |
| `DOCKERFILE` | `Dockerfile` | Dockerfile 路径 |
| `DOCKER_PLATFORMS` | `linux/amd64,linux/arm64` | push 时 buildx 平台 |
| `GO_TEST_SCOPE` | `./...` | `go test` 包路径 |
| `RUN_GO_TEST` | (空,即运行) | 设 `false` 跳过 go test,仅 Docker 构建 |
| `DOCKER_IMAGE_PREFIX` | `1panel` | 基础镜像前缀;可选 `hub` / `daocloud` |
| `GOPROXY` | `https://goproxy.cn,direct` | Go 模块代理 |
| `GOSUMDB` | `sum.golang.google.cn` | Go checksum 数据库 |
## 触发与镜像 tag
- **pull_request**go test(可关)+ 单架构 `docker build` 验证(不推送)
- **push main/master**go test + 多架构构建并推送 `:latest``:sha-xxxxxxx`
- **push tag v\***:额外推送 `:v1.2.3`
示例(仓库 `owner/go-vue-template`Gitea 在 `git.example.com`):
```text
git.example.com/owner/go-vue-template:latest
git.example.com/owner/go-vue-template:sha-35b3b48
git.example.com/owner/go-vue-template:v1.0.0
```
## 目录结构
```text
.gitea/
├── README.md # 本文件
├── workflows/
│ └── ci.yml # 主工作流
└── act-runner/ # runner 部署参考(可选)
├── README.md
├── config.yaml
├── docker-compose.yml
└── .env.example
```
## 本地验证 Registry
```bash
host=$(echo "https://你的-gitea-地址/" | sed -e 's|^https://||' -e 's|/.*||')
curl -s -D - "https://${host}/v2/" -o /dev/null | grep -i www-authenticate
docker pull "${host}/owner/image:latest" # 公开 Registry 无需 login
```
推送镜像(CI)仍需仓库 Secret `REGISTRY_TOKEN``write:package`)。仅拉取公开包不需要登录。
`realm` 应指向公网 Gitea 域名,而非 `127.0.0.1`
## 常见 Registry 错误
| 报错 | 原因 | 处理 |
|------|------|------|
| `Get "https://172.17.0.1:13827/v2/"` HTTP/HTTPS | Variable `REGISTRY``gitea.server_url` 为内网地址 | 设 `REGISTRY=git.example.com` |
| token 指向 `127.0.0.1` | Gitea `realm` 配置错误 | `PUBLIC_URL_DETECTION=never` + 正确 `ROOT_URL` |
+3
View File
@@ -0,0 +1,3 @@
GITEA_INSTANCE_URL=https://git.example.com
GITEA_RUNNER_REGISTRATION_TOKEN=从仓库_Settings_Actions_Runners_复制
GITEA_RUNNER_NAME=go-vue-template-ci-runner
+98
View File
@@ -0,0 +1,98 @@
# act_runner 配置参考
工作流 `runs-on: ubuntu-latest``docker` job 额外使用 `catthehacker/ubuntu:act-22.04` 容器(含 Docker CLI)。
## 构建镜像必须:挂载 Docker Socket
`docker` job 需要访问宿主机 Docker 引擎。在 **runner 所在机器**`config.yaml` 中配置:
```yaml
container:
options: -v /var/run/docker.sock:/var/run/docker.sock
valid_volumes:
- /var/run/docker.sock
```
推荐 job 镜像(含 Node + Docker CLI):
```yaml
runner:
labels:
- "ubuntu-latest:docker://catthehacker/ubuntu:act-22.04"
- "ubuntu-22.04:docker://catthehacker/ubuntu:act-22.04"
```
修改后 **重启 runner**(例如 `docker restart <runner容器>` 或重启 Gitea Runner 服务)。
## 1Panel 宿主机:配置 Docker 镜像加速(推荐)
Runner 通过 `docker.sock` 使用宿主机 Docker。在 **1Panel** 中配置加速器后,普通 `docker pull` 会走加速;**buildx 多架构构建**仍建议配合 workflow 内的 `IMAGE_PREFIX`(默认 `docker.1panel.live`)。
1. 登录 1Panel → **容器****配置**
2. **镜像加速地址** 填入:
```text
https://docker.1panel.live
```
3. 保存并 **重启 Docker**
CI 工作流默认 `IMAGE_PREFIX=docker.1panel.live/library/`buildkit 拉基础镜像)。若 1Panel 加速不可用,可在仓库 Variables 设 `DOCKER_IMAGE_PREFIX=daocloud` 或 `hub`。
## 验证
在 runner 宿主机执行:
```bash
docker info
ls -l /var/run/docker.sock
```
## 从零部署 runner(可选)
```bash
cd .gitea/act-runner
cp .env.example .env # 填入 Registration Token
docker compose up -d
```
## GitHub Actions 镜像
本模板 `ci.yml` 已使用 `https://gitea.com/actions/checkout@v4`,不依赖 runner 的 `github_mirror` 配置。
若其他 workflow 仍写 `uses: actions/checkout@v4`,可在 runner `config.yaml` 中设置:
```yaml
runner:
github_mirror: 'https://gitea.com'
```
## 常见错误
| 报错 | 原因 | 处理 |
|------|------|------|
| `registry-1.docker.io` i/o timeout | Docker Hub 不可达 | 1Panel 配 `docker.1panel.live`Variable `DOCKER_IMAGE_PREFIX=1panel` |
| `docker: command not found` | job 容器无 Docker CLI | 工作流已指定 act 镜像;或 runner 改用 catthehacker/ubuntu |
| `Cannot connect to Docker daemon` | 未挂载 docker.sock | 按上文修改 config.yaml 并重启 runner |
| `http: server gave HTTP response to HTTPS client` 且 token 指向 `127.0.0.1` | Gitea Registry 配置/反代错误 | 见 `.gitea/README.md` |
## Registry 登录失败(127.0.0.1 / HTTP vs HTTPS
若 `docker login git.example.com` 报错类似:
```text
Get "https://127.0.0.1:xxxxx/v2/token?...": http: server gave HTTP response to HTTPS client
```
说明 Gitea 把 **Docker 认证 token 地址** 配成了本机内网地址,CI runner 访问不到。需在 **Gitea 服务器** 修复:
```ini
[server]
ROOT_URL = https://git.example.com/
LOCAL_ROOT_URL = http://127.0.0.1:3000/
[packages]
ENABLED = true
```
`ROOT_URL` 必须与浏览器访问 Gitea 的 **HTTPS 外网地址** 完全一致(含末尾 `/`)。
参考:[Gitea 反向代理文档](https://docs.gitea.com/administration/reverse-proxies)
+32
View File
@@ -0,0 +1,32 @@
# act_runner 配置(可选参考部署)
# 工作流 runs-on: ubuntu-latest
log:
level: info
runner:
file: .runner
capacity: 2
timeout: 3h
insecure: false
fetch_timeout: 5s
fetch_interval: 2s
# 拉取 uses: actions/checkout@v4 等 GitHub Action 时的镜像(替换 https://github.com
# 需 Gitea app.ini 中 [actions] DEFAULT_ACTIONS_URL = github
# 留空则直连 github.com;本模板 workflow 已使用 gitea.com 绝对 URL,可不配置
#
# github_mirror: ''
# github_mirror: 'https://gitea.com'
labels:
- "ubuntu-latest:docker://catthehacker/ubuntu:act-22.04"
- "ubuntu-22.04:docker://catthehacker/ubuntu:act-22.04"
cache:
enabled: false
container:
network: bridge
privileged: false
options: -v /var/run/docker.sock:/var/run/docker.sock
valid_volumes:
- /var/run/docker.sock
+22
View File
@@ -0,0 +1,22 @@
# Gitea act_runner — 可选参考部署(标签 ubuntu-latest,与工作流一致)
#
# 若已在 Gitea 注册个人/仓库 runner 且标签为 ubuntu-latest,无需使用本目录。
services:
act-runner:
image: docker.io/gitea/act_runner:0.2.12
container_name: go-vue-template-act-runner
restart: unless-stopped
environment:
CONFIG_FILE: /config.yaml
GITEA_INSTANCE_URL: ${GITEA_INSTANCE_URL:-https://git.example.com}
GITEA_RUNNER_REGISTRATION_TOKEN: ${GITEA_RUNNER_REGISTRATION_TOKEN}
GITEA_RUNNER_NAME: ${GITEA_RUNNER_NAME:-go-vue-template-ci-runner}
volumes:
- ./config.yaml:/config.yaml:ro
- act-runner-data:/data
- /var/run/docker.sock:/var/run/docker.sock
working_dir: /data
volumes:
act-runner-data:
+236
View File
@@ -0,0 +1,236 @@
# 通用 Go + Vue 项目 CIGitea Actions
#
# 单 job:可选 go test + Docker 多架构构建/推送
# 约定:go.mod、Dockerfile(详见 .gitea/README.md「Dockerfile 要求」);前端(可选)在 web/
#
# 前置:act_runnerubuntu-latest + docker.sock)、Secret REGISTRY_TOKEN
# VariablesREGISTRY、IMAGE_NAME、DOCKER_IMAGE_PREFIX、RUN_GO_TEST 等
name: CI
on:
push:
branches: [main, master]
tags: ['v*']
pull_request:
env:
REGISTRY: ${{ vars.REGISTRY }}
GITEA_ROOT_URL: ${{ vars.GITEA_ROOT_URL }}
IMAGE_NAME: ${{ vars.IMAGE_NAME }}
DOCKERFILE: ${{ vars.DOCKERFILE }}
DOCKER_PLATFORMS: ${{ vars.DOCKER_PLATFORMS }}
GO_TEST_SCOPE: ${{ vars.GO_TEST_SCOPE }}
RUN_GO_TEST: ${{ vars.RUN_GO_TEST }}
DOCKER_IMAGE_PREFIX: ${{ vars.DOCKER_IMAGE_PREFIX }}
GOPROXY: ${{ vars.GOPROXY }}
GOSUMDB: ${{ vars.GOSUMDB }}
jobs:
docker:
runs-on: ubuntu-latest
container:
image: catthehacker/ubuntu:act-22.04
steps:
- name: Checkout
uses: https://gitea.com/actions/checkout@v4
- name: Run Go tests
if: vars.RUN_GO_TEST != 'false'
shell: bash
run: |
set -euo pipefail
export GOPROXY="${GOPROXY:-https://goproxy.cn,direct}"
export GOSUMDB="${GOSUMDB:-sum.golang.google.cn}"
GO_VERSION=$(grep '^go ' go.mod | awk '{print $2}')
ARCH=$(uname -m | sed 's/x86_64/amd64/; s/aarch64/arm64/')
TAR="go${GO_VERSION}.linux-${ARCH}.tar.gz"
if ! command -v go >/dev/null 2>&1 || ! go version | grep -q "go${GO_VERSION} "; then
downloaded=0
for url in \
"https://mirrors.aliyun.com/golang/${TAR}" \
"https://golang.google.cn/dl/${TAR}" \
"https://go.dev/dl/${TAR}"; do
echo "trying ${url}"
if curl -fsSL --connect-timeout 20 --retry 3 --retry-delay 5 --max-time 600 \
"$url" -o /tmp/go.tgz; then
downloaded=1
break
fi
done
[ "$downloaded" -eq 1 ] || { echo "failed to download Go ${GO_VERSION}" >&2; exit 1; }
rm -rf /usr/local/go
tar -C /usr/local -xzf /tmp/go.tgz
export PATH="/usr/local/go/bin:${PATH}"
fi
go version
go test "${GO_TEST_SCOPE:-./...}"
- name: Setup Docker
run: |
set -eux
if ! command -v docker >/dev/null 2>&1; then
apt-get update
DEBIAN_FRONTEND=noninteractive apt-get install -y docker.io
fi
if [ ! -S /var/run/docker.sock ]; then
echo "ERROR: /var/run/docker.sock 未挂载到 job 容器。" >&2
echo "请在 act_runner config.yaml 中配置:" >&2
echo " container.options: -v /var/run/docker.sock:/var/run/docker.sock" >&2
echo " container.valid_volumes: [/var/run/docker.sock]" >&2
exit 1
fi
docker info
- name: Resolve registry
id: reg
if: gitea.event_name == 'push'
shell: bash
env:
GITEA_SERVER_URL: ${{ gitea.server_url }}
run: |
set -euo pipefail
url_to_host() {
echo "$1" | sed -e 's|^https://||' -e 's|^http://||' -e 's|/.*||'
}
is_internal_host() {
local host="${1%%:*}"
case "$host" in
localhost|127.*|10.*|192.168.*) return 0 ;;
172.*)
local second
second=$(echo "$host" | cut -d. -f2)
[ "$second" -ge 16 ] && [ "$second" -le 31 ]
return
;;
*) return 1 ;;
esac
}
registry="${REGISTRY:-}"
if [ -z "$registry" ] && [ -n "${GITEA_ROOT_URL:-}" ]; then
registry=$(url_to_host "${GITEA_ROOT_URL}")
fi
if [ -z "$registry" ]; then
registry=$(url_to_host "${GITEA_SERVER_URL}")
fi
if is_internal_host "$registry"; then
echo "ERROR: Registry 主机 '${registry}' 是内网地址。" >&2
echo "请设置 Variable REGISTRY=公网 Gitea 域名(如 git.example.com)。" >&2
exit 1
fi
echo "registry host: ${registry}"
auth_header=""
if auth_header=$(curl -fsS -D - "https://${registry}/v2/" -o /dev/null 2>&1 | grep -i '^www-authenticate:'); then
if echo "$auth_header" | grep -qiE '127\.0\.0\.1|172\.(1[6-9]|2[0-9]|3[01])\.|localhost|:13827'; then
echo "ERROR: Registry token realm 指向内网地址:${auth_header}" >&2
exit 1
fi
fi
echo "host=${registry}" >> "${GITHUB_OUTPUT}"
- name: Log in to Container Registry
if: gitea.event_name == 'push'
shell: bash
run: |
set -euo pipefail
echo "${{ secrets.REGISTRY_TOKEN }}" | \
docker login "${{ steps.reg.outputs.host }}" -u "${{ gitea.actor }}" --password-stdin
- name: Build image
shell: bash
env:
GITEA_SHA: ${{ gitea.sha }}
GITEA_REF: ${{ gitea.ref }}
GITEA_REPOSITORY: ${{ gitea.repository }}
GITEA_REPOSITORY_OWNER: ${{ gitea.repository_owner }}
GITEA_EVENT_NAME: ${{ gitea.event_name }}
REGISTRY_HOST: ${{ steps.reg.outputs.host }}
run: |
set -euo pipefail
dockerfile="${DOCKERFILE:-Dockerfile}"
if [ "${DOCKER_IMAGE_PREFIX:-}" = "hub" ] || [ "${DOCKER_IMAGE_PREFIX:-}" = "docker.io" ]; then
image_prefix=""
elif [ -z "${DOCKER_IMAGE_PREFIX:-}" ] || [ "${DOCKER_IMAGE_PREFIX}" = "1panel" ]; then
image_prefix="docker.1panel.live/library/"
elif [ "${DOCKER_IMAGE_PREFIX}" = "daocloud" ]; then
image_prefix="docker.m.daocloud.io/library/"
else
image_prefix="${DOCKER_IMAGE_PREFIX}"
[[ "${image_prefix}" == */ ]] || image_prefix="${image_prefix}/"
fi
echo "using IMAGE_PREFIX=${image_prefix:-<docker.io>}"
build_args=(
--build-arg "IMAGE_PREFIX=${image_prefix}"
--build-arg "GOPROXY=${GOPROXY:-https://goproxy.cn,direct}"
--build-arg "GOSUMDB=${GOSUMDB:-sum.golang.google.cn}"
)
builder_id="gitea-buildx-${GITEA_REPOSITORY//\//-}"
if [ "${GITEA_EVENT_NAME}" = "push" ]; then
install_binfmt() {
for img in \
"docker.1panel.live/tonistiigi/binfmt:latest" \
"docker.m.daocloud.io/tonistiigi/binfmt:latest" \
"tonistiigi/binfmt:latest"; do
echo "trying binfmt image: ${img}"
if docker run --privileged --rm "${img}" --install all; then
return 0
fi
done
return 1
}
install_binfmt || true
registry="${REGISTRY_HOST}"
image_name="${IMAGE_NAME:-$(echo "${GITEA_REPOSITORY##*/}" | tr '[:upper:]' '[:lower:]')}"
platforms="${DOCKER_PLATFORMS:-linux/amd64,linux/arm64}"
image="${registry}/${GITEA_REPOSITORY_OWNER}/${image_name}"
tags="${image}:sha-${GITEA_SHA:0:7}"
case "${GITEA_REF}" in
refs/heads/main|refs/heads/master)
tags="${tags},${image}:latest"
;;
refs/tags/v*)
tags="${tags},${image}:${GITEA_REF#refs/tags/}"
;;
esac
docker buildx create --name "${builder_id}" --use 2>/dev/null || docker buildx use "${builder_id}"
docker buildx inspect --bootstrap
tag_args=()
IFS=',' read -r -a tag_list <<< "$tags"
for t in "${tag_list[@]}"; do
tag_args+=(-t "$t")
done
docker buildx build \
--platform "${platforms}" \
-f "${dockerfile}" \
"${build_args[@]}" \
"${tag_args[@]}" \
--push \
.
else
# PR:仅单架构构建验证 Dockerfile,不推送
docker buildx create --name "${builder_id}" --use 2>/dev/null || docker buildx use "${builder_id}"
docker buildx inspect --bootstrap
docker buildx build \
--platform linux/amd64 \
-f "${dockerfile}" \
"${build_args[@]}" \
--load \
.
fi
+7
View File
@@ -0,0 +1,7 @@
node_modules/
dist/
web/dist/
data/
*.db
.env
.DS_Store
+46
View File
@@ -0,0 +1,46 @@
# syntax=docker/dockerfile:1
ARG IMAGE_PREFIX=
FROM ${IMAGE_PREFIX}node:20-alpine AS web-builder
WORKDIR /src/web
COPY web/package.json web/package-lock.json web/.npmrc ./
RUN npm install
COPY web/ ./
RUN npm run build
FROM ${IMAGE_PREFIX}golang:1.25-alpine AS go-builder
ARG TARGETARCH=amd64
ARG GOPROXY=https://goproxy.cn,direct
ARG GOSUMDB=sum.golang.google.cn
ENV CGO_ENABLED=0 GOPROXY=${GOPROXY} GOSUMDB=${GOSUMDB}
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY cmd/ ./cmd/
COPY internal/ ./internal/
COPY --from=web-builder /src/web/dist ./internal/api/static/
RUN GOOS=linux GOARCH="$TARGETARCH" \
go build -trimpath -ldflags="-w -s" -o /app ./cmd/app
FROM ${IMAGE_PREFIX}alpine:3.20
RUN apk add --no-cache ca-certificates tzdata tini
ENV APP_DATA=/data \
APP_ADDR=:8080
COPY --from=go-builder /app /usr/local/bin/app
RUN adduser -D -H app
USER app
VOLUME ["/data"]
EXPOSE 8080
ENTRYPOINT ["/sbin/tini", "--"]
CMD ["/usr/local/bin/app", "-data", "/data", "-addr", ":8080"]
+18
View File
@@ -0,0 +1,18 @@
MIT License
Copyright (c) 2026 rose_cat707
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
associated documentation files (the "Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the
following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial
portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT
LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO
EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
USE OR OTHER DEALINGS IN THE SOFTWARE.
+43
View File
@@ -0,0 +1,43 @@
.PHONY: dev dev-web build build-web run test tidy clean docker-build
export GOPROXY ?= https://goproxy.cn,direct
export GOSUMDB ?= sum.golang.google.cn
export IMAGE_PREFIX ?= docker.1panel.live/library/
export APP_IMAGE ?= go-vue-template:latest
dev:
@echo "Run backend and frontend in separate terminals:"
@echo " make run"
@echo " make dev-web"
dev-web:
cd web && npm install && npm run dev
build-web:
cd web && npm install && npm run build
rm -rf internal/api/static/*
cp -r web/dist/* internal/api/static/
build: build-web
go build -o dist/app ./cmd/app
run:
go run ./cmd/app
tidy:
go mod tidy
test:
go test ./...
clean:
rm -rf dist web/dist internal/api/static/*
@echo '<!DOCTYPE html><html><body>App</body></html>' > internal/api/static/index.html
docker-build:
docker build \
-t $(APP_IMAGE) \
--build-arg IMAGE_PREFIX=$(IMAGE_PREFIX) \
--build-arg GOPROXY=$(GOPROXY) \
--build-arg GOSUMDB=$(GOSUMDB) \
.
+309
View File
@@ -0,0 +1,309 @@
# go-vue-template
Go + Vue 单体应用脚手架。一个二进制同时提供 REST API 与管理台 UI,前端通过 `go:embed` 嵌入,适合内部工具、管理后台、网关控制台等场景。
## 特性
- **单体部署**:API 与 SPA 同进程、同端口,生产环境无需 Node.js
- **分层清晰**`cmd``api``service``store`,职责边界明确
- **开箱即用**:登录鉴权、设置页、审计日志、i18n、Docker 多阶段构建
- **纯 Go 编译**SQLite 使用 `modernc.org/sqlite``CGO_ENABLED=0` 可交叉编译
- **可演进**goose SQL 迁移、settings KV 配置、Pinia 状态管理
## 快速开始
```bash
# 开发模式(两个终端)
make run # 后端 :8080
make dev-web # 前端 :5173/api 代理到 8080
# 生产构建
make build
./dist/app
# Docker
docker compose up -d
```
默认管理员密码:`admin`(可在设置页修改)。
---
## 使用本模板
1. **Fork 或 clone** 本仓库作为新项目起点
2. **修改 Go module 名**:全局替换 `github.com/rose_cat707/go-vue-template` 为你的模块路径
3. **修改应用标识**:更新 `settings` 默认值、`web/` 中的品牌文案、镜像名等
4. **按需扩展**:在 `internal/` 增加业务包,在 `web/src/views/` 增加页面
```bash
# 示例:批量替换 module 名
find . -type f \( -name '*.go' -o -name 'go.mod' \) \
-exec sed -i '' 's|github.com/rose_cat707/go-vue-template|github.com/you/your-app|g' {} +
```
---
## 架构
```
┌─────────────────────────────────────────────────────────┐
│ 单一 Go 进程 │
│ ┌──────────────┐ ┌──────────────┐ ┌───────────────┐ │
│ │ /api/v1/* │ │ /health │ │ /* (SPA) │ │
│ │ REST API │ │ 健康检查 │ │ embed 静态资源 │ │
│ └──────┬───────┘ └──────────────┘ └───────────────┘ │
│ │ │
│ ┌──────▼──────────────────────────────────────────┐ │
│ │ api (HTTP) → service (编排) → store (持久化) │ │
│ └─────────────────────────────────────────────────┘ │
│ │ │
│ ┌──────▼──────┐ │
│ │ SQLite WAL │ {data}/app.db │
│ └─────────────┘ │
└─────────────────────────────────────────────────────────┘
▲ ▲
│ 生产:同一端口 │ 开发:Vite :5173 代理 /api
└──────────────────────────┘
```
### 分层约定
| 层 | 路径 | 职责 |
|----|------|------|
| 入口 | `cmd/app/` | 解析参数、初始化依赖、启动 HTTP、优雅关闭 |
| HTTP | `internal/api/` | 路由注册、中间件、请求/响应、SPA 静态服务 |
| 业务 | `internal/service/` | 跨模块编排,注入 Store 与领域服务 |
| 持久化 | `internal/store/` | 数据库连接、迁移、仓储方法 |
| 配置 | `internal/settings/` | 配置键常量、默认值、展示脱敏、提交校验 |
| 前端 | `web/` | Vue SPA 源码,构建产物复制到 `internal/api/static/` |
**原则:**
- Handler 只做 HTTP 适配,不写复杂业务逻辑
- Store 封装 SQL,不暴露 `*sql.DB` 给 Handler
- 前端构建产物不提交 dist,仅保留 `static/index.html` 占位以保证 `go test` / CI 可通过
---
## 目录结构
```
.
├── cmd/app/ # 进程入口
├── internal/
│ ├── api/
│ │ ├── handlers.go # 路由与 Handler
│ │ ├── auth_token.go # Token 签发与校验
│ │ ├── static.go # go:embed SPA
│ │ └── static/ # 前端构建产物(占位 index.html 已提交)
│ ├── service/ # 业务编排
│ ├── store/
│ │ ├── store.go
│ │ └── migrations/ # goose SQL 迁移
│ └── settings/ # 配置键与规范化
├── web/
│ ├── src/
│ │ ├── api/ # axios 客户端与类型
│ │ ├── components/ # 通用 UI 组件
│ │ ├── i18n/ # 多语言
│ │ ├── layouts/ # 布局
│ │ ├── router/ # 路由与鉴权守卫
│ │ ├── stores/ # Pinia
│ │ ├── styles/ # 主题变量
│ │ └── views/ # 页面
│ ├── vite.config.ts
│ └── package.json
├── Makefile
├── Dockerfile
├── docker-compose.yml
├── go.mod
└── .gitea/ # Gitea Actions CI
├── README.md
├── workflows/ci.yml
└── act-runner/ # runner 部署参考(可选)
```
---
## 技术栈
| 层级 | 选型 | 说明 |
|------|------|------|
| 后端 | Go 1.25 + Gin | HTTP 路由与中间件 |
| 数据库 | SQLite (modernc.org/sqlite) | 纯 Go 驱动,无 CGO |
| 迁移 | goose v3 (embed SQL) | 版本化、可回滚 |
| 前端 | Vue 3 + Vite + TypeScript | Composition API + `<script setup>` |
| UI | Element Plus | 组件库与图标 |
| 状态 | Pinia | 全局状态(认证、语言等) |
| 路由 | vue-router (History) | 路由守卫 + `/auth/status` |
| HTTP | axios | Bearer Token401 全局跳转 |
| i18n | vue-i18n | 与 Element Plus locale 联动 |
---
## API 规范
**前缀:** `/api/v1`
### 鉴权
| 接口 | 说明 |
|------|------|
| `POST /api/v1/auth/login` | 登录,返回 `{ "token": "..." }` |
| `GET /api/v1/auth/status` | 查询 `{ "enabled": true \| false }` |
| 受保护路由 | Header: `Authorization: Bearer <token>` |
`auth_enabled` 不为 `"true"` 时,受保护路由跳过鉴权(便于本地调试)。
### 响应格式
```json
// 成功 — 按场景选用
{ "ok": true }
{ "items": [...], "total": 42 }
{ "app_name": "App", "data_dir": "./data" }
// 错误 — 统一字段
{ "error": "human readable message" }
```
### HTTP 状态码
| 码 | 含义 |
|----|------|
| 400 | 请求参数错误 |
| 401 | 未授权 |
| 404 | 资源不存在 |
| 500 | 服务器内部错误 |
### 分页
列表接口支持 `?limit=`(默认 50,最大 500)与 `?offset=`,响应含 `items``total`
---
## 配置
### 启动参数与环境变量
| 来源 | 名称 | 默认 | 说明 |
|------|------|------|------|
| CLI | `-data` | `./data` | 数据目录(SQLite 等) |
| CLI | `-addr` | `:8080` | HTTP 监听地址 |
| Env | `APP_DATA` | — | 覆盖 `-data` |
| Env | `APP_ADDR` | — | 覆盖 `-addr` |
### 运行时配置(SQLite KV
业务配置存储在 `settings` 表,可通过 Web 设置页修改,无需重启:
| 键 | 说明 |
|----|------|
| `app_name` | 应用名称 |
| `admin_password` | 管理员密码(GET 时脱敏) |
| `auth_enabled` | 是否启用认证(`true` / `false` |
| `ui_locale` | 界面语言(`zh-CN` / `en` |
新增配置项:在 `internal/settings/keys.go` 定义键与默认值,并编写 goose 迁移插入初始值。
---
## 开发工作流
### 双进程开发(推荐)
```bash
# 终端 1 — 后端
make run
# 终端 2 — 前端热更新
make dev-web
# 浏览器访问 http://localhost:5173
```
Vite 将 `/api` 代理到 `http://127.0.0.1:8080`
### 单体构建
```bash
make build
# 1. web: npm run build → web/dist
# 2. cp web/dist/* → internal/api/static/
# 3. go build → dist/appembed 前端)
```
### 常用命令
| 命令 | 作用 |
|------|------|
| `make run` | 运行后端(不 embed 最新前端) |
| `make dev-web` | 启动 Vite 开发服务器 |
| `make build` | 构建含 embed 前端的完整二进制 |
| `make test` | 运行 Go 测试 |
| `make tidy` | `go mod tidy` |
| `make clean` | 清理构建产物 |
---
## Docker 部署
```bash
make docker-build
docker run -d -p 8080:8080 -v app-data:/data go-vue-template:latest
```
或使用:
```bash
docker compose up -d
```
镜像采用多阶段构建(Node 编译前端 → Go 编译 → Alpine 运行),以非 root 用户运行,数据持久化至 `/data` 卷。
---
## Gitea Actions CI
仓库已包含 `.gitea/workflows/ci.yml`push 到 `main`/`master` 或打 `v*` tag 时自动:
1. 运行 `go test ./...`
2. 多架构 Docker 构建并推送到 Gitea Container Registry
**一次性配置:**
| 项 | 说明 |
|----|------|
| Runner | 标签 `ubuntu-latest`,挂载 `/var/run/docker.sock` |
| Secret | `REGISTRY_TOKEN`PAT`write:package` |
| Variable | `REGISTRY=你的公网 Gitea 域名`(内网部署时必填) |
PR 仅验证 Dockerfile 构建,不推送镜像。详细说明见 [.gitea/README.md](.gitea/README.md)。
---
## 扩展指南
### 新增 REST 接口
1. `internal/store/migrations/00N_*.sql` — 建表或改表
2. `internal/store/store.go` — 仓储方法
3. `internal/api/handlers.go` — 注册路由与 Handler
4. `web/src/api/client.ts` — 类型定义与 API 调用
5. `web/src/views/` + `web/src/router/index.ts` — 页面与路由
### 新增业务模块
`internal/` 下新建包(如 `internal/worker/`),在 `service.App` 中注入,由 `api` 层调用。保持 Handler 薄、领域逻辑内聚。
### 前端规范
- 页面使用 `PageHeader` + `AppCard` 布局
- 样式变量定义在 `web/src/styles/theme.css`
- 文案走 `vue-i18n`,避免硬编码
- API 调用统一经 `web/src/api/client.ts`
---
## License
MIT — 见 [LICENSE](./LICENSE)
+65
View File
@@ -0,0 +1,65 @@
package main
import (
"context"
"flag"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/rose_cat707/go-vue-template/internal/api"
"github.com/rose_cat707/go-vue-template/internal/service"
"github.com/rose_cat707/go-vue-template/internal/store"
)
func main() {
dataDir := flag.String("data", envOr("APP_DATA", "./data"), "data directory")
addr := flag.String("addr", envOr("APP_ADDR", ":8080"), "HTTP listen address")
flag.Parse()
if err := os.MkdirAll(*dataDir, 0o755); err != nil {
log.Fatalf("data dir: %v", err)
}
st, err := store.Open(*dataDir)
if err != nil {
log.Fatalf("database: %v", err)
}
defer st.Close()
app := &service.App{Store: st, DataDir: *dataDir}
srv := api.NewServer(app)
httpServer := &http.Server{
Addr: *addr,
Handler: srv.Router(),
}
go func() {
log.Printf("app listening on %s (data=%s)", *addr, *dataDir)
if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("server: %v", err)
}
}()
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
log.Println("shutting down...")
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := httpServer.Shutdown(ctx); err != nil {
log.Printf("shutdown: %v", err)
}
}
func envOr(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}
+15
View File
@@ -0,0 +1,15 @@
services:
app:
build: .
image: go-vue-template:latest
ports:
- "8080:8080"
volumes:
- app-data:/data
environment:
APP_DATA: /data
APP_ADDR: ":8080"
restart: unless-stopped
volumes:
app-data:
+2
View File
@@ -0,0 +1,2 @@
GOPROXY=https://goproxy.cn,direct
GOSUMDB=sum.golang.google.cn
+52
View File
@@ -0,0 +1,52 @@
module github.com/rose_cat707/go-vue-template
go 1.25.7
require (
github.com/gin-gonic/gin v1.12.0
github.com/pressly/goose/v3 v3.27.1
golang.org/x/crypto v0.53.0
modernc.org/sqlite v1.52.0
)
require (
github.com/bytedance/gopkg v0.1.3 // indirect
github.com/bytedance/sonic v1.15.0 // indirect
github.com/bytedance/sonic/loader v0.5.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.30.1 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.21 // indirect
github.com/mfridman/interpolate v0.0.2 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.59.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/sethvargo/go-retry v0.3.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.1 // indirect
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
golang.org/x/arch v0.22.0 // indirect
golang.org/x/net v0.55.0 // indirect
golang.org/x/sync v0.21.0 // indirect
golang.org/x/sys v0.46.0 // indirect
golang.org/x/text v0.38.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
modernc.org/libc v1.72.3 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
)
+142
View File
@@ -0,0 +1,142 @@
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/mattn/go-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs=
github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY=
github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pressly/goose/v3 v3.27.1 h1:6uEvcprBybDmW4hcz3gYujhARhye+GoWKhEWyzD5sh4=
github.com/pressly/goose/v3 v3.27.1/go.mod h1:maruOxsPnIG2yHHyo8UqKWXYKFcH7Q76csUV7+7KYoM=
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE=
github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI=
golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.28.2 h1:3tQ0lf2ADtoby2EtSP+J7IE2SHwEJdP8ioR59wx7XpY=
modernc.org/cc/v4 v4.28.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.34.0 h1:yRLPFZieg532OT4rp4JFNIVcquwalMX26G95WQDqwCQ=
modernc.org/ccgo/v4 v4.34.0/go.mod h1:AS5WYMyBakQ+fhsHhtP8mWB82KTGPkNNJDGfGQCe0/A=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.72.3 h1:ZnDF4tXn4NBXFutMMQC4vtbTFSXhhKzR73fv0beZEAU=
modernc.org/libc v1.72.3/go.mod h1:dn0dZNnnn1clLyvRxLxYExxiKRZIRENOfqQ8XEeg4Qs=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.52.0 h1:p4dhYh2tXZCiyaqHwRVJDjIGKWyXayiQpThxgDzJaxo=
modernc.org/sqlite v1.52.0/go.mod h1:tcNzv5p84E0skkmJn038y+hWJbLQXQqEnQfeh5r2JLM=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
+35
View File
@@ -0,0 +1,35 @@
package api
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"fmt"
"strconv"
"strings"
"time"
)
func issueToken(secret string, ttl time.Duration) string {
exp := time.Now().Add(ttl).Unix()
return fmt.Sprintf("%d.%s", exp, signToken(secret, exp))
}
func verifyToken(secret, token string) bool {
parts := strings.SplitN(token, ".", 2)
if len(parts) != 2 {
return false
}
exp, err := strconv.ParseInt(parts[0], 10, 64)
if err != nil || time.Now().Unix() > exp {
return false
}
expected := signToken(secret, exp)
return hmac.Equal([]byte(parts[1]), []byte(expected))
}
func signToken(secret string, exp int64) string {
mac := hmac.New(sha256.New, []byte(secret))
_, _ = mac.Write([]byte(strconv.FormatInt(exp, 10)))
return hex.EncodeToString(mac.Sum(nil))
}
+206
View File
@@ -0,0 +1,206 @@
package api
import (
"net/http"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
"golang.org/x/crypto/bcrypt"
"github.com/rose_cat707/go-vue-template/internal/service"
"github.com/rose_cat707/go-vue-template/internal/settings"
)
type Server struct {
app *service.App
}
func NewServer(app *service.App) *Server {
return &Server{app: app}
}
func (s *Server) Router() *gin.Engine {
gin.SetMode(gin.ReleaseMode)
r := gin.New()
r.Use(gin.Recovery(), s.corsMiddleware())
r.GET("/health", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"status": "ok"})
})
api := r.Group("/api/v1")
api.POST("/auth/login", s.login)
api.GET("/auth/status", s.authStatus)
protected := api.Group("")
protected.Use(s.authMiddleware())
{
protected.GET("/dashboard", s.dashboard)
protected.GET("/settings", s.getSettings)
protected.PUT("/settings", s.putSettings)
protected.GET("/audit-logs", s.listAuditLogs)
}
r.NoRoute(s.serveSPA)
return r
}
func (s *Server) corsMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
c.Header("Access-Control-Allow-Origin", "*")
c.Header("Access-Control-Allow-Headers", "Authorization, Content-Type")
c.Header("Access-Control-Allow-Methods", "GET,POST,PUT,DELETE,OPTIONS")
if c.Request.Method == http.MethodOptions {
c.AbortWithStatus(204)
return
}
c.Next()
}
}
func (s *Server) authMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
cfg, err := s.app.Store.GetSettings(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
c.Abort()
return
}
if cfg[settings.KeyAuthEnabled] != "true" {
c.Next()
return
}
token := bearerToken(c)
if token == "" || !verifyToken(cfg[settings.KeyAdminPassword], token) {
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
c.Abort()
return
}
c.Next()
}
}
type loginReq struct {
Password string `json:"password"`
}
func (s *Server) authStatus(c *gin.Context) {
cfg, err := s.app.Store.GetSettings(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"enabled": cfg[settings.KeyAuthEnabled] == "true"})
}
func (s *Server) login(c *gin.Context) {
var req loginReq
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
ctx := c.Request.Context()
cfg, err := s.app.Store.GetSettings(ctx)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
hash := cfg[settings.KeyAdminPassword]
if !checkPassword(req.Password, hash) {
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid password"})
return
}
token := issueToken(hash, 24*time.Hour)
c.JSON(http.StatusOK, gin.H{"token": token})
}
func (s *Server) dashboard(c *gin.Context) {
cfg, err := s.app.Store.GetSettings(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"app_name": cfg[settings.KeyAppName],
"data_dir": s.app.DataDir,
})
}
func (s *Server) getSettings(c *gin.Context) {
cfg, err := s.app.Store.GetSettings(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, settings.Display(cfg))
}
func (s *Server) putSettings(c *gin.Context) {
var in map[string]string
if err := c.ShouldBindJSON(&in); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
normalized, err := settings.NormalizeSubmit(in)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if len(normalized) == 0 {
c.JSON(http.StatusOK, gin.H{"ok": true})
return
}
ctx := c.Request.Context()
if err := s.app.Store.SetSettings(ctx, normalized); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
_ = s.app.Store.InsertAuditLog(ctx, "update", "settings", "settings updated")
c.JSON(http.StatusOK, gin.H{"ok": true})
}
func (s *Server) listAuditLogs(c *gin.Context) {
limit, offset := parseLimitOffset(c, 50, 500)
items, total, err := s.app.Store.ListAuditLogs(c.Request.Context(), limit, offset)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"items": items, "total": total})
}
func bearerToken(c *gin.Context) string {
h := c.GetHeader("Authorization")
if strings.HasPrefix(h, "Bearer ") {
return strings.TrimPrefix(h, "Bearer ")
}
return ""
}
func checkPassword(password, stored string) bool {
if strings.HasPrefix(stored, "$2a$") || strings.HasPrefix(stored, "$2b$") {
return bcrypt.CompareHashAndPassword([]byte(stored), []byte(password)) == nil
}
return password == stored
}
func parseLimitOffset(c *gin.Context, defaultLimit, maxLimit int) (int, int) {
limit := defaultLimit
offset := 0
if v := c.Query("limit"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
limit = n
}
}
if limit > maxLimit {
limit = maxLimit
}
if v := c.Query("offset"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n >= 0 {
offset = n
}
}
return limit, offset
}
+51
View File
@@ -0,0 +1,51 @@
package api
import (
"embed"
"io/fs"
"mime"
"net/http"
"path/filepath"
"strings"
"github.com/gin-gonic/gin"
)
//go:embed static/*
var staticFS embed.FS
func (s *Server) serveSPA(c *gin.Context) {
if strings.HasPrefix(c.Request.URL.Path, "/api/") {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
sub, err := fs.Sub(staticFS, "static")
if err != nil {
c.String(http.StatusInternalServerError, "static fs error")
return
}
path := strings.TrimPrefix(c.Request.URL.Path, "/")
if path == "" {
path = "index.html"
}
data, err := fs.ReadFile(sub, path)
if err != nil {
data, err = fs.ReadFile(sub, "index.html")
if err != nil {
c.String(http.StatusNotFound, "UI not built. Run: make build-web")
return
}
path = "index.html"
}
ext := filepath.Ext(path)
ct := mime.TypeByExtension(ext)
if ct == "" {
ct = "text/html; charset=utf-8"
}
if strings.HasPrefix(path, "assets/") {
c.Header("Cache-Control", "public, max-age=31536000, immutable")
} else {
c.Header("Cache-Control", "no-cache")
}
c.Data(http.StatusOK, ct, data)
}
@@ -0,0 +1 @@
import{T as e,_ as t,c as n,d as r,g as i,m as a,u as o}from"./index-CYwbDAGF.js";var s={class:`page-header`},c={class:`page-header__title`},l={key:0,class:`page-header__subtitle`},u={class:`page-header__actions`},d=a({__name:`PageHeader`,props:{title:{},subtitle:{}},setup(a){return(d,f)=>(i(),r(`div`,s,[n(`div`,null,[n(`h2`,c,e(a.title),1),a.subtitle?(i(),r(`p`,l,e(a.subtitle),1)):o(``,!0)]),n(`div`,u,[t(d.$slots,`actions`)])]))}}),f=(e,t)=>{let n=e.__vccOpts||e;for(let[e,r]of t)n[e]=r;return n},p={},m={class:`app-card`};function h(e,n){return i(),r(`div`,m,[t(e.$slots,`default`)])}var g=f(p,[[`render`,h]]);export{d as n,g as t};
@@ -0,0 +1 @@
import{C as e,T as t,a as n,b as r,d as i,f as a,g as o,h as s,l as c,m as l,p as u,r as d,u as f,v as p,w as m,x as h,y as g}from"./index-CYwbDAGF.js";import{n as _,t as v}from"./AppCard-BLS7p9Sn.js";var y=l({__name:`DashboardView`,setup(l){let{t:y}=n(),b=e(!0),x=e(null);return s(async()=>{try{x.value=(await d.get(`/dashboard`)).data}finally{b.value=!1}}),(e,n)=>{let s=p(`el-descriptions-item`),l=p(`el-descriptions`),d=g(`loading`);return o(),i(`div`,null,[u(_,{title:m(y)(`dashboard.title`)},null,8,[`title`]),h((o(),c(v,null,{default:r(()=>[x.value?(o(),c(l,{key:0,column:1,border:``},{default:r(()=>[u(s,{label:m(y)(`dashboard.appName`)},{default:r(()=>[a(t(x.value.app_name),1)]),_:1},8,[`label`]),u(s,{label:m(y)(`dashboard.dataDir`)},{default:r(()=>[a(t(x.value.data_dir),1)]),_:1},8,[`label`])]),_:1})):f(``,!0)]),_:1})),[[d,b.value]])])}}});export{y as default};
@@ -0,0 +1 @@
import{C as e,T as t,a as n,b as r,c as i,d as a,f as o,g as s,i as c,m as l,n as u,o as d,p as f,s as p,v as m,w as h}from"./index-CYwbDAGF.js";var g={class:`login-page`},_={class:`login-wrap`},v={class:`page-desc`},y={class:`login-card app-card`},b={class:`app-card__title`},x={class:`form-hint`},S=l({__name:`LoginView`,setup(l){let{t:S}=n(),C=c(),w=u(),T=e(``),E=e(!1);async function D(){E.value=!0;try{await w.login(T.value),C.push(`/`)}catch{d.error(S(`login.wrongPassword`))}finally{E.value=!1}}return(e,n)=>{let c=m(`el-input`),l=m(`el-form-item`),u=m(`el-button`),d=m(`el-form`);return s(),a(`div`,g,[i(`div`,_,[n[1]||=i(`div`,{class:`login-brand-icon`},`A`,-1),n[2]||=i(`h1`,{class:`page-title`},`App`,-1),i(`p`,v,t(h(S)(`login.subtitle`)),1),i(`div`,y,[i(`h3`,b,t(h(S)(`login.title`)),1),f(d,{size:`large`,onSubmit:p(D,[`prevent`])},{default:r(()=>[f(l,{label:h(S)(`login.password`)},{default:r(()=>[f(c,{modelValue:T.value,"onUpdate:modelValue":n[0]||=e=>T.value=e,type:`password`,"show-password":``,placeholder:h(S)(`login.passwordPlaceholder`)},null,8,[`modelValue`,`placeholder`])]),_:1},8,[`label`]),f(u,{type:`primary`,"native-type":`submit`,loading:E.value,class:`w-full`},{default:r(()=>[o(t(h(S)(`login.submit`)),1)]),_:1},8,[`loading`])]),_:1}),i(`p`,x,t(h(S)(`login.hint`)),1)])])])}}});export{S as default};
@@ -0,0 +1 @@
import{C as e,S as t,T as n,a as r,b as i,d as a,f as o,g as s,h as c,l,m as u,o as d,p as f,r as p,t as m,v as h,w as g,x as _,y as v}from"./index-CYwbDAGF.js";import{n as y,t as b}from"./AppCard-BLS7p9Sn.js";var x=u({__name:`SettingsView`,setup(u){let{t:x}=r(),S=m(),C=e(!0),w=e(!1),T=t({app_name:`App`,api_port:`8080`,admin_password:``,auth_enabled:`true`,ui_locale:`zh-CN`});c(async()=>{try{let e=await p.get(`/settings`);Object.assign(T,e.data)}finally{C.value=!1}});async function E(){w.value=!0;try{await p.put(`/settings`,T),(T.ui_locale===`zh-CN`||T.ui_locale===`en`)&&S.setLocale(T.ui_locale),d.success(x(`settings.saved`))}finally{w.value=!1}}return(e,t)=>{let r=h(`el-input`),c=h(`el-form-item`),u=h(`el-switch`),d=h(`el-option`),p=h(`el-select`),m=h(`el-button`),S=h(`el-form`),D=v(`loading`);return s(),a(`div`,null,[f(y,{title:g(x)(`settings.title`)},null,8,[`title`]),_((s(),l(b,null,{default:i(()=>[f(S,{"label-width":`120px`},{default:i(()=>[f(c,{label:g(x)(`settings.appName`)},{default:i(()=>[f(r,{modelValue:T.app_name,"onUpdate:modelValue":t[0]||=e=>T.app_name=e},null,8,[`modelValue`])]),_:1},8,[`label`]),f(c,{label:g(x)(`settings.authEnabled`)},{default:i(()=>[f(u,{modelValue:T.auth_enabled,"onUpdate:modelValue":t[1]||=e=>T.auth_enabled=e,"active-value":`true`,"inactive-value":`false`},null,8,[`modelValue`])]),_:1},8,[`label`]),f(c,{label:g(x)(`settings.adminPassword`)},{default:i(()=>[f(r,{modelValue:T.admin_password,"onUpdate:modelValue":t[2]||=e=>T.admin_password=e,type:`password`,"show-password":``,placeholder:`******`},null,8,[`modelValue`])]),_:1},8,[`label`]),f(c,{label:g(x)(`settings.uiLocale`)},{default:i(()=>[f(p,{modelValue:T.ui_locale,"onUpdate:modelValue":t[3]||=e=>T.ui_locale=e,style:{width:`200px`}},{default:i(()=>[f(d,{label:`简体中文`,value:`zh-CN`}),f(d,{label:`English`,value:`en`})]),_:1},8,[`modelValue`])]),_:1},8,[`label`]),f(c,null,{default:i(()=>[f(m,{type:`primary`,loading:w.value,onClick:E},{default:i(()=>[o(n(g(x)(`common.save`)),1)]),_:1},8,[`loading`])]),_:1})]),_:1})]),_:1})),[[D,C.value]])])}}});export{x as default};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>App</title>
<script type="module" crossorigin src="/assets/index-CYwbDAGF.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DvvrUfw6.css">
</head>
<body>
<div id="app"></div>
</body>
</html>
+8
View File
@@ -0,0 +1,8 @@
package service
import "github.com/rose_cat707/go-vue-template/internal/store"
type App struct {
Store *store.Store
DataDir string
}
+45
View File
@@ -0,0 +1,45 @@
package settings
const (
KeyAppName = "app_name"
KeyAPIPort = "api_port"
KeyAdminPassword = "admin_password"
KeyAuthEnabled = "auth_enabled"
KeyUILocale = "ui_locale"
)
var Defaults = map[string]string{
KeyAppName: "App",
KeyAPIPort: "8080",
KeyAdminPassword: "admin",
KeyAuthEnabled: "true",
KeyUILocale: "zh-CN",
}
// Display returns settings safe for GET responses (masks secrets).
func Display(raw map[string]string) map[string]string {
out := make(map[string]string, len(raw))
for k, v := range raw {
out[k] = v
}
if v, ok := out[KeyAdminPassword]; ok && v != "" {
out[KeyAdminPassword] = "******"
}
return out
}
// NormalizeSubmit validates and normalizes settings from PUT requests.
func NormalizeSubmit(in map[string]string) (map[string]string, error) {
out := make(map[string]string)
for k, v := range in {
switch k {
case KeyAppName, KeyAPIPort, KeyAuthEnabled, KeyUILocale:
out[k] = v
case KeyAdminPassword:
if v != "" && v != "******" {
out[k] = v
}
}
}
return out, nil
}
+24
View File
@@ -0,0 +1,24 @@
-- +goose Up
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS audit_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
action TEXT NOT NULL,
resource TEXT NOT NULL,
detail TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
INSERT OR IGNORE INTO settings (key, value) VALUES
('app_name', 'App'),
('api_port', '8080'),
('admin_password', 'admin'),
('auth_enabled', 'true'),
('ui_locale', 'zh-CN');
-- +goose Down
DROP TABLE IF EXISTS audit_logs;
DROP TABLE IF EXISTS settings;
+151
View File
@@ -0,0 +1,151 @@
package store
import (
"context"
"database/sql"
"embed"
"fmt"
"os"
"path/filepath"
"github.com/pressly/goose/v3"
_ "modernc.org/sqlite"
)
//go:embed migrations/*.sql
var migrationsFS embed.FS
type Store struct {
db *sql.DB
}
func Open(dataDir string) (*Store, error) {
if err := os.MkdirAll(dataDir, 0o755); err != nil {
return nil, err
}
dbPath := filepath.Join(dataDir, "app.db")
db, err := sql.Open("sqlite", dbPath+"?_pragma=foreign_keys(1)&_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)")
if err != nil {
return nil, err
}
db.SetMaxOpenConns(1)
s := &Store{db: db}
if err := s.migrate(); err != nil {
db.Close()
return nil, err
}
return s, nil
}
func (s *Store) DB() *sql.DB { return s.db }
func (s *Store) Close() error { return s.db.Close() }
func (s *Store) migrate() error {
goose.SetBaseFS(migrationsFS)
if err := goose.SetDialect("sqlite3"); err != nil {
return err
}
return goose.Up(s.db, "migrations")
}
func (s *Store) GetSetting(ctx context.Context, key string) (string, error) {
var value string
err := s.db.QueryRowContext(ctx, `SELECT value FROM settings WHERE key = ?`, key).Scan(&value)
return value, err
}
func (s *Store) GetSettings(ctx context.Context) (map[string]string, error) {
rows, err := s.db.QueryContext(ctx, `SELECT key, value FROM settings`)
if err != nil {
return nil, err
}
defer rows.Close()
out := make(map[string]string)
for rows.Next() {
var k, v string
if err := rows.Scan(&k, &v); err != nil {
return nil, err
}
out[k] = v
}
return out, rows.Err()
}
func (s *Store) SetSettings(ctx context.Context, settings map[string]string) error {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
stmt, err := tx.PrepareContext(ctx, `INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value`)
if err != nil {
return err
}
defer stmt.Close()
for k, v := range settings {
if _, err := stmt.ExecContext(ctx, k, v); err != nil {
return err
}
}
return tx.Commit()
}
func (s *Store) ListAuditLogs(ctx context.Context, limit, offset int) ([]AuditLog, int, error) {
var total int
if err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM audit_logs`).Scan(&total); err != nil {
return nil, 0, err
}
rows, err := s.db.QueryContext(ctx,
`SELECT id, action, resource, detail, created_at FROM audit_logs ORDER BY id DESC LIMIT ? OFFSET ?`,
limit, offset,
)
if err != nil {
return nil, 0, err
}
defer rows.Close()
var items []AuditLog
for rows.Next() {
var item AuditLog
if err := rows.Scan(&item.ID, &item.Action, &item.Resource, &item.Detail, &item.CreatedAt); err != nil {
return nil, 0, err
}
items = append(items, item)
}
return items, total, rows.Err()
}
func (s *Store) InsertAuditLog(ctx context.Context, action, resource, detail string) error {
_, err := s.db.ExecContext(ctx,
`INSERT INTO audit_logs (action, resource, detail) VALUES (?, ?, ?)`,
action, resource, detail,
)
return err
}
type AuditLog struct {
ID int64 `json:"id"`
Action string `json:"action"`
Resource string `json:"resource"`
Detail string `json:"detail"`
CreatedAt string `json:"created_at"`
}
func MaskSecret(value string) string {
if value == "" {
return ""
}
if len(value) <= 4 {
return "****"
}
return value[:2] + "****" + value[len(value)-2:]
}
func ValidateSettingKey(key string) error {
switch key {
case "app_name", "api_port", "admin_password", "auth_enabled", "ui_locale":
return nil
default:
return fmt.Errorf("unknown setting: %s", key)
}
}
+1
View File
@@ -0,0 +1 @@
registry=https://registry.npmmirror.com
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>App</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+2492
View File
File diff suppressed because it is too large Load Diff
+28
View File
@@ -0,0 +1,28 @@
{
"name": "web",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"@element-plus/icons-vue": "^2.3.2",
"axios": "^1.18.0",
"element-plus": "^2.14.2",
"pinia": "^3.0.4",
"vue": "^3.5.34",
"vue-i18n": "^10.0.7",
"vue-router": "^5.1.0"
},
"devDependencies": {
"@types/node": "^24.12.3",
"@vitejs/plugin-vue": "^6.0.6",
"@vue/tsconfig": "^0.9.1",
"typescript": "~6.0.2",
"vite": "^8.0.12",
"vue-tsc": "^3.2.8"
}
}
+15
View File
@@ -0,0 +1,15 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import zhCn from 'element-plus/es/locale/lang/zh-cn'
import en from 'element-plus/es/locale/lang/en'
const { locale } = useI18n()
const elementLocale = computed(() => (locale.value === 'en' ? en : zhCn))
</script>
<template>
<el-config-provider :locale="elementLocale">
<router-view />
</el-config-provider>
</template>
+51
View File
@@ -0,0 +1,51 @@
import axios from 'axios'
import router from '@/router'
const api = axios.create({
baseURL: '/api/v1',
timeout: 30000,
})
api.interceptors.request.use((config) => {
const token = localStorage.getItem('app_token')
if (token && token !== 'disabled') {
config.headers.Authorization = `Bearer ${token}`
}
return config
})
api.interceptors.response.use(
(res) => res,
(err) => {
if (err.response?.status === 401 && err.config?.url !== '/auth/login') {
localStorage.removeItem('app_token')
if (router.currentRoute.value.name !== 'login') {
router.push('/login')
}
}
return Promise.reject(err)
},
)
export default api
export interface DashboardData {
app_name: string
data_dir: string
}
export interface SettingsData {
app_name: string
api_port: string
admin_password: string
auth_enabled: string
ui_locale: string
}
export interface AuditLog {
id: number
action: string
resource: string
detail: string
created_at: string
}
+5
View File
@@ -0,0 +1,5 @@
<template>
<div class="app-card">
<slot />
</div>
</template>
+15
View File
@@ -0,0 +1,15 @@
<script setup lang="ts">
defineProps<{ title: string; subtitle?: string }>()
</script>
<template>
<div class="page-header">
<div>
<h2 class="page-header__title">{{ title }}</h2>
<p v-if="subtitle" class="page-header__subtitle">{{ subtitle }}</p>
</div>
<div class="page-header__actions">
<slot name="actions" />
</div>
</div>
</template>
+35
View File
@@ -0,0 +1,35 @@
export default {
common: {
management: 'Management',
logout: 'Logout',
save: 'Save',
loading: 'Loading…',
},
nav: {
dashboard: 'Dashboard',
settings: 'Settings',
},
login: {
title: 'Sign in',
subtitle: 'Admin Console',
password: 'Password',
passwordPlaceholder: 'Enter admin password',
submit: 'Sign in',
hint: 'Default password: admin',
failed: 'Login failed',
wrongPassword: 'Invalid password',
},
dashboard: {
title: 'Dashboard',
appName: 'App name',
dataDir: 'Data directory',
},
settings: {
title: 'Settings',
appName: 'App name',
authEnabled: 'Enable auth',
adminPassword: 'Admin password',
uiLocale: 'UI locale',
saved: 'Settings saved',
},
}
+17
View File
@@ -0,0 +1,17 @@
import { createI18n } from 'vue-i18n'
import zhCN from './zh-CN'
import en from './en'
import type { AppLocale } from '@/stores/locale'
const i18n = createI18n({
legacy: false,
locale: (localStorage.getItem('app_locale') as AppLocale) || 'zh-CN',
fallbackLocale: 'zh-CN',
messages: { 'zh-CN': zhCN, en },
})
export function applyDocumentLocale(locale: AppLocale) {
document.documentElement.lang = locale === 'en' ? 'en' : 'zh-CN'
}
export default i18n
+35
View File
@@ -0,0 +1,35 @@
export default {
common: {
management: '管理',
logout: '退出登录',
save: '保存',
loading: '加载中…',
},
nav: {
dashboard: '概览',
settings: '设置',
},
login: {
title: '登录',
subtitle: '管理控制台',
password: '密码',
passwordPlaceholder: '请输入管理员密码',
submit: '登录',
hint: '默认密码:admin',
failed: '登录失败',
wrongPassword: '密码错误',
},
dashboard: {
title: '概览',
appName: '应用名称',
dataDir: '数据目录',
},
settings: {
title: '设置',
appName: '应用名称',
authEnabled: '启用认证',
adminPassword: '管理员密码',
uiLocale: '界面语言',
saved: '设置已保存',
},
}
+58
View File
@@ -0,0 +1,58 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute, useRouter } from 'vue-router'
import { Odometer, Setting } from '@element-plus/icons-vue'
import { useAuthStore } from '@/stores/auth'
const { t } = useI18n()
const route = useRoute()
const router = useRouter()
const auth = useAuthStore()
const navItems = [
{ path: '/', key: 'nav.dashboard', icon: Odometer },
{ path: '/settings', key: 'nav.settings', icon: Setting },
] as const
const pageTitle = computed(() => {
const hit = navItems.find((item) => item.path === route.path)
return hit ? t(hit.key) : 'App'
})
function logout() {
auth.logout()
router.push('/login')
}
</script>
<template>
<div class="layout">
<aside class="sidebar">
<div class="sidebar-brand">
<div class="brand-icon">A</div>
<span class="brand-name">App</span>
</div>
<div class="nav-group-label">{{ t('common.management') }}</div>
<router-link
v-for="item in navItems"
:key="item.path"
:to="item.path"
class="nav-item"
:class="{ active: route.path === item.path }"
>
<el-icon :size="16"><component :is="item.icon" /></el-icon>
{{ t(item.key) }}
</router-link>
</aside>
<div class="main-area">
<header class="topbar">
<span class="topbar-title">{{ pageTitle }}</span>
<el-button text @click="logout">{{ t('common.logout') }}</el-button>
</header>
<main class="main-content">
<router-view />
</main>
</div>
</div>
</template>
+23
View File
@@ -0,0 +1,23 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import '@/styles/theme.css'
import App from './App.vue'
import router from './router'
import i18n, { applyDocumentLocale } from '@/i18n'
import { useLocaleStore } from '@/stores/locale'
const app = createApp(App)
const pinia = createPinia()
app.use(pinia)
app.use(router)
app.use(i18n)
app.use(ElementPlus)
const localeStore = useLocaleStore(pinia)
applyDocumentLocale(localeStore.locale)
app.mount('#app')
+40
View File
@@ -0,0 +1,40 @@
import { createRouter, createWebHistory } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import MainLayout from '@/layouts/MainLayout.vue'
const router = createRouter({
history: createWebHistory(),
routes: [
{
path: '/login',
name: 'login',
component: () => import('@/views/LoginView.vue'),
meta: { public: true },
},
{
path: '/',
component: MainLayout,
children: [
{ path: '', name: 'dashboard', component: () => import('@/views/DashboardView.vue') },
{ path: 'settings', name: 'settings', component: () => import('@/views/SettingsView.vue') },
],
},
],
})
router.beforeEach(async (to) => {
const auth = useAuthStore()
if (to.meta.public) return true
if (auth.isLoggedIn()) return true
try {
const res = await fetch('/api/v1/auth/status')
const data = await res.json()
if (!data.enabled) {
auth.token = 'disabled'
return true
}
} catch { /* ignore */ }
return '/login'
})
export default router
+22
View File
@@ -0,0 +1,22 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import api from '@/api/client'
export const useAuthStore = defineStore('auth', () => {
const token = ref(localStorage.getItem('app_token') || '')
async function login(password: string) {
const { data } = await api.post<{ token: string }>('/auth/login', { password })
token.value = data.token
localStorage.setItem('app_token', data.token)
}
function logout() {
token.value = ''
localStorage.removeItem('app_token')
}
const isLoggedIn = () => !!token.value
return { token, login, logout, isLoggedIn }
})
+21
View File
@@ -0,0 +1,21 @@
import { defineStore } from 'pinia'
import { ref, watch } from 'vue'
import i18n, { applyDocumentLocale } from '@/i18n'
export type AppLocale = 'zh-CN' | 'en'
export const useLocaleStore = defineStore('locale', () => {
const locale = ref<AppLocale>((localStorage.getItem('app_locale') as AppLocale) || 'zh-CN')
watch(locale, (value) => {
localStorage.setItem('app_locale', value)
i18n.global.locale.value = value
applyDocumentLocale(value)
})
function setLocale(value: AppLocale) {
locale.value = value
}
return { locale, setLocale }
})
+165
View File
@@ -0,0 +1,165 @@
:root {
--background: #f4f4f5;
--foreground: #18181b;
--card: #ffffff;
--primary: #2563eb;
--primary-foreground: #fafafa;
--muted-foreground: #71717a;
--border: #e4e4e7;
--sidebar-width: 15rem;
--radius: 0.5rem;
--space-3: 12px;
--space-4: 16px;
--space-5: 20px;
--space-6: 24px;
--el-color-primary: #2563eb;
--el-border-radius-base: 0.5rem;
--el-bg-color-page: #f4f4f5;
}
* { box-sizing: border-box; }
html, body, #app {
margin: 0;
padding: 0;
height: 100%;
font-size: 14px;
background: var(--background);
color: var(--foreground);
}
.layout { display: flex; height: 100vh; }
.sidebar {
width: var(--sidebar-width);
flex-shrink: 0;
background: #fafafa;
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
}
.sidebar-brand {
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-5) var(--space-4);
}
.brand-icon {
width: 32px;
height: 32px;
border-radius: var(--radius);
background: var(--primary);
color: var(--primary-foreground);
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
}
.brand-name { font-size: 1.125rem; font-weight: 600; }
.nav-group-label {
padding: 0 var(--space-4);
margin-bottom: var(--space-3);
font-size: 12px;
color: var(--muted-foreground);
}
.nav-item {
display: flex;
align-items: center;
gap: var(--space-3);
padding: 10px var(--space-4);
color: inherit;
text-decoration: none;
}
.nav-item.active { background: #eff6ff; color: var(--primary); }
.main-area { flex: 1; display: flex; flex-direction: column; min-width: 0; }
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-4) var(--space-6);
border-bottom: 1px solid var(--border);
background: var(--card);
}
.topbar-title { font-size: 1.125rem; font-weight: 600; }
.main-content {
flex: 1;
overflow: auto;
padding: var(--space-6);
}
.page-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
margin-bottom: var(--space-4);
}
.page-header__title {
margin: 0;
font-size: 1.25rem;
font-weight: 600;
}
.page-header__subtitle {
margin: 4px 0 0;
color: var(--muted-foreground);
}
.app-card {
background: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: var(--space-6);
}
.app-card__title { margin: 0 0 var(--space-4); font-size: 1rem; }
.login-page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: var(--background);
}
.login-wrap { width: 100%; max-width: 400px; padding: var(--space-6); text-align: center; }
.login-brand-icon {
width: 48px;
height: 48px;
margin: 0 auto var(--space-4);
border-radius: var(--radius);
background: var(--primary);
color: var(--primary-foreground);
display: flex;
align-items: center;
justify-content: center;
font-size: 1.25rem;
font-weight: 700;
}
.page-title { margin: 0 0 8px; font-size: 1.5rem; }
.page-desc { margin: 0 0 var(--space-6); color: var(--muted-foreground); }
.login-card { text-align: left; }
.form-hint {
margin-top: var(--space-4);
text-align: center;
color: var(--muted-foreground);
font-size: 12px;
}
.w-full { width: 100%; }
+32
View File
@@ -0,0 +1,32 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import api, { type DashboardData } from '@/api/client'
import PageHeader from '@/components/PageHeader.vue'
import AppCard from '@/components/AppCard.vue'
const { t } = useI18n()
const loading = ref(true)
const data = ref<DashboardData | null>(null)
onMounted(async () => {
try {
const res = await api.get<DashboardData>('/dashboard')
data.value = res.data
} finally {
loading.value = false
}
})
</script>
<template>
<div>
<PageHeader :title="t('dashboard.title')" />
<AppCard v-loading="loading">
<el-descriptions v-if="data" :column="1" border>
<el-descriptions-item :label="t('dashboard.appName')">{{ data.app_name }}</el-descriptions-item>
<el-descriptions-item :label="t('dashboard.dataDir')">{{ data.data_dir }}</el-descriptions-item>
</el-descriptions>
</AppCard>
</div>
</template>
+52
View File
@@ -0,0 +1,52 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { useAuthStore } from '@/stores/auth'
const { t } = useI18n()
const router = useRouter()
const auth = useAuthStore()
const password = ref('')
const loading = ref(false)
async function submit() {
loading.value = true
try {
await auth.login(password.value)
router.push('/')
} catch {
ElMessage.error(t('login.wrongPassword'))
} finally {
loading.value = false
}
}
</script>
<template>
<div class="login-page">
<div class="login-wrap">
<div class="login-brand-icon">A</div>
<h1 class="page-title">App</h1>
<p class="page-desc">{{ t('login.subtitle') }}</p>
<div class="login-card app-card">
<h3 class="app-card__title">{{ t('login.title') }}</h3>
<el-form size="large" @submit.prevent="submit">
<el-form-item :label="t('login.password')">
<el-input
v-model="password"
type="password"
show-password
:placeholder="t('login.passwordPlaceholder')"
/>
</el-form-item>
<el-button type="primary" native-type="submit" :loading="loading" class="w-full">
{{ t('login.submit') }}
</el-button>
</el-form>
<p class="form-hint">{{ t('login.hint') }}</p>
</div>
</div>
</div>
</template>
+71
View File
@@ -0,0 +1,71 @@
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { ElMessage } from 'element-plus'
import api, { type SettingsData } from '@/api/client'
import PageHeader from '@/components/PageHeader.vue'
import AppCard from '@/components/AppCard.vue'
import { useLocaleStore } from '@/stores/locale'
const { t } = useI18n()
const localeStore = useLocaleStore()
const loading = ref(true)
const saving = ref(false)
const form = reactive<SettingsData>({
app_name: 'App',
api_port: '8080',
admin_password: '',
auth_enabled: 'true',
ui_locale: 'zh-CN',
})
onMounted(async () => {
try {
const res = await api.get<SettingsData>('/settings')
Object.assign(form, res.data)
} finally {
loading.value = false
}
})
async function save() {
saving.value = true
try {
await api.put('/settings', form)
if (form.ui_locale === 'zh-CN' || form.ui_locale === 'en') {
localeStore.setLocale(form.ui_locale)
}
ElMessage.success(t('settings.saved'))
} finally {
saving.value = false
}
}
</script>
<template>
<div>
<PageHeader :title="t('settings.title')" />
<AppCard v-loading="loading">
<el-form label-width="120px">
<el-form-item :label="t('settings.appName')">
<el-input v-model="form.app_name" />
</el-form-item>
<el-form-item :label="t('settings.authEnabled')">
<el-switch v-model="form.auth_enabled" active-value="true" inactive-value="false" />
</el-form-item>
<el-form-item :label="t('settings.adminPassword')">
<el-input v-model="form.admin_password" type="password" show-password placeholder="******" />
</el-form-item>
<el-form-item :label="t('settings.uiLocale')">
<el-select v-model="form.ui_locale" style="width: 200px">
<el-option label="简体中文" value="zh-CN" />
<el-option label="English" value="en" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="saving" @click="save">{{ t('common.save') }}</el-button>
</el-form-item>
</el-form>
</AppCard>
</div>
</template>
+17
View File
@@ -0,0 +1,17 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"types": ["vite/client"],
"ignoreDeprecations": "6.0",
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
},
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+19
View File
@@ -0,0 +1,19 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
}
+25
View File
@@ -0,0 +1,25 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { fileURLToPath, URL } from 'node:url'
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
},
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://127.0.0.1:8080',
changeOrigin: true,
},
},
},
build: {
outDir: 'dist',
emptyOutDir: true,
},
})