Initial commit
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
web/dist
|
||||
data/
|
||||
dist/
|
||||
*.db
|
||||
.env
|
||||
@@ -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` |
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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:
|
||||
@@ -0,0 +1,236 @@
|
||||
# 通用 Go + Vue 项目 CI(Gitea Actions)
|
||||
#
|
||||
# 单 job:可选 go test + Docker 多架构构建/推送
|
||||
# 约定:go.mod、Dockerfile(详见 .gitea/README.md「Dockerfile 要求」);前端(可选)在 web/
|
||||
#
|
||||
# 前置:act_runner(ubuntu-latest + docker.sock)、Secret REGISTRY_TOKEN
|
||||
# Variables:REGISTRY、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
|
||||
@@ -0,0 +1,7 @@
|
||||
node_modules/
|
||||
dist/
|
||||
web/dist/
|
||||
data/
|
||||
*.db
|
||||
.env
|
||||
.DS_Store
|
||||
+46
@@ -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"]
|
||||
@@ -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.
|
||||
@@ -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) \
|
||||
.
|
||||
@@ -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 Token,401 全局跳转 |
|
||||
| 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/app(embed 前端)
|
||||
```
|
||||
|
||||
### 常用命令
|
||||
|
||||
| 命令 | 作用 |
|
||||
|------|------|
|
||||
| `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)
|
||||
@@ -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
|
||||
}
|
||||
@@ -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:
|
||||
@@ -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
|
||||
)
|
||||
@@ -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=
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
@@ -0,0 +1,8 @@
|
||||
package service
|
||||
|
||||
import "github.com/rose_cat707/go-vue-template/internal/store"
|
||||
|
||||
type App struct {
|
||||
Store *store.Store
|
||||
DataDir string
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
registry=https://registry.npmmirror.com
|
||||
@@ -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>
|
||||
Generated
+2492
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
<template>
|
||||
<div class="app-card">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
@@ -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>
|
||||
@@ -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',
|
||||
},
|
||||
}
|
||||
@@ -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
|
||||
@@ -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: '设置已保存',
|
||||
},
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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')
|
||||
@@ -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
|
||||
@@ -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 }
|
||||
})
|
||||
@@ -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 }
|
||||
})
|
||||
@@ -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%; }
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user