feat: sync current progress (P0 hardening + P1 observability + deploy docs/systemd)
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
# Asset Tracker production env
|
||||
APP_ENV=production
|
||||
HTTP_ADDR=:9530
|
||||
DB_PATH=/root/.openclaw/workspace/asset-tracker/data/asset-tracker.db
|
||||
DEFAULT_TIMEZONE=Asia/Shanghai
|
||||
|
||||
# REQUIRED: set a strong secret (32+ chars suggested)
|
||||
JWT_SECRET=replace_with_strong_random_secret
|
||||
|
||||
# Optional auth TTLs
|
||||
ACCESS_TTL_MINUTES=30
|
||||
REFRESH_TTL_HOURS=168
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
# build/runtime artifacts
|
||||
bin/
|
||||
logs/
|
||||
data/
|
||||
*.pid
|
||||
|
||||
# local env secrets
|
||||
.env.production
|
||||
.env
|
||||
|
||||
# frontend deps/cache
|
||||
web/frontend/node_modules/
|
||||
web/frontend/dist/
|
||||
|
||||
# OS/editor
|
||||
.DS_Store
|
||||
.vscode/
|
||||
.idea/
|
||||
@@ -0,0 +1,85 @@
|
||||
# PRD - Asset Tracker (MVP)
|
||||
|
||||
## 1. 项目目标
|
||||
|
||||
构建单用户个人资产管理系统,统一记录现实资产和网络资产,提供资产统计与到期提醒能力。
|
||||
|
||||
## 2. MVP 功能边界
|
||||
|
||||
### 2.1 In Scope
|
||||
|
||||
1. 资产分类管理
|
||||
2. 资产新增、查询
|
||||
3. 仪表盘汇总:
|
||||
- 总资产(按记录金额直接累加)
|
||||
- 分类占比
|
||||
- 未来30天到期资产清单
|
||||
4. 定时提醒扫描:
|
||||
- 每小时扫描一次未来 30/7/1 天到期资产
|
||||
- 首版先日志输出提醒事件
|
||||
|
||||
### 2.2 Out of Scope
|
||||
|
||||
- 多用户协作
|
||||
- 自动估值源接入(股票/币价 API)
|
||||
- 复杂权限系统
|
||||
- 导入导出与财务报表
|
||||
|
||||
## 3. 用户故事
|
||||
|
||||
1. 作为用户,我希望快速记录资产,了解当前资产分布。
|
||||
2. 作为用户,我希望在关键资产到期前得到提醒,减少忘记续费风险。
|
||||
3. 作为用户,我希望看到未来30天到期资产列表,做提前规划。
|
||||
|
||||
## 4. 核心流程
|
||||
|
||||
### 4.1 新增资产
|
||||
- 选择/创建分类
|
||||
- 输入资产基础信息(名称、数量、单价、币种、到期时间)
|
||||
- 系统保存并可在列表中查看
|
||||
|
||||
### 4.2 资产列表
|
||||
- 查看所有有效资产
|
||||
- 支持按分类过滤(MVP 可选,先预留参数)
|
||||
|
||||
### 4.3 到期提醒扫描
|
||||
- cron 每小时触发
|
||||
- 扫描未来30天内到期资产
|
||||
- 输出提醒日志(含资产名、到期日期、剩余天数)
|
||||
|
||||
## 5. 数据模型(MVP)
|
||||
|
||||
### categories
|
||||
- id
|
||||
- name
|
||||
- type (`real` / `digital`)
|
||||
- color
|
||||
- created_at
|
||||
- updated_at
|
||||
|
||||
### assets
|
||||
- id
|
||||
- name
|
||||
- category_id
|
||||
- quantity
|
||||
- unit_price
|
||||
- total_value
|
||||
- currency
|
||||
- expiry_date(可空)
|
||||
- note
|
||||
- status (`active` / `inactive`)
|
||||
- created_at
|
||||
- updated_at
|
||||
|
||||
## 6. 非功能需求
|
||||
|
||||
1. 可部署性:单二进制 + SQLite
|
||||
2. 可维护性:分层结构(api/service简化为handler+repo)
|
||||
3. 可扩展性:预留 reminders 表与通知通道模块
|
||||
|
||||
## 7. 验收标准
|
||||
|
||||
- 能通过 API 创建分类和资产
|
||||
- 能通过 API 获取资产列表和仪表盘汇总
|
||||
- 服务启动后 cron 每小时运行提醒扫描并输出日志
|
||||
- OpenAPI 文档与实现字段一致
|
||||
@@ -0,0 +1,115 @@
|
||||
# asset-tracker
|
||||
|
||||
个人资产管理系统(MVP)
|
||||
|
||||
## MVP 范围
|
||||
|
||||
- 用户登录鉴权(JWT)
|
||||
- 资产分类管理(分类列表/新增)
|
||||
- 资产管理(新增/列表/更新/删除)
|
||||
- 仪表盘汇总(总资产、分类占比、未来30天到期)
|
||||
- 到期提醒(预生成 reminders + 定时扫描发送状态)
|
||||
|
||||
## 技术栈
|
||||
|
||||
- Go 1.22+
|
||||
- Gin
|
||||
- GORM + SQLite
|
||||
- robfig/cron
|
||||
|
||||
## 快速启动
|
||||
|
||||
```bash
|
||||
cd asset-tracker
|
||||
go mod tidy
|
||||
go run ./cmd/server
|
||||
```
|
||||
|
||||
服务默认监听:`http://127.0.0.1:9530`
|
||||
|
||||
默认账号(首次启动自动创建):
|
||||
- username: `admin`
|
||||
- password: `admin123`
|
||||
|
||||
> 强烈建议通过环境变量覆盖:`DEFAULT_USERNAME` `DEFAULT_PASSWORD` `JWT_SECRET`
|
||||
|
||||
## 鉴权流程
|
||||
|
||||
1. `POST /api/v1/auth/login` 获取 `access_token`(`refresh_token` 写入 HttpOnly Cookie)
|
||||
2. 后续请求添加 Header:`Authorization: Bearer <access_token>`
|
||||
3. access 过期后调用 `POST /api/v1/auth/refresh`(优先用 Cookie 刷新)
|
||||
|
||||
## 错误响应结构(统一)
|
||||
|
||||
```json
|
||||
{
|
||||
"code": "ASSET_INVALID_STATUS",
|
||||
"message": "status must be active or inactive",
|
||||
"details": null,
|
||||
"request_id": "d3f4a1b2c3d4e5f6"
|
||||
}
|
||||
```
|
||||
|
||||
所有响应都会带 `X-Request-Id`,便于日志排查。
|
||||
|
||||
## 关键接口
|
||||
|
||||
- `POST /api/v1/auth/login`
|
||||
- `POST /api/v1/auth/refresh`
|
||||
- `POST /api/v1/categories`
|
||||
- `GET /api/v1/categories`
|
||||
- `POST /api/v1/assets`
|
||||
- `GET /api/v1/assets?page=1&page_size=20&status=active`
|
||||
- `PUT /api/v1/assets/:id`
|
||||
- `DELETE /api/v1/assets/:id`
|
||||
- `GET /api/v1/dashboard/summary`
|
||||
- `GET /api/v1/reminders?status=failed&page=1&page_size=20`
|
||||
- `GET /healthz`
|
||||
- `GET /readyz`
|
||||
|
||||
详细 API 见:`openapi.yaml`
|
||||
|
||||
## 生产部署必备项清单
|
||||
|
||||
- [ ] 设置强 JWT 密钥(`JWT_SECRET`),禁止使用默认值
|
||||
- [ ] `APP_ENV=production`(生产环境会强校验 JWT_SECRET)
|
||||
- [ ] 配置 `ACCESS_TTL_MINUTES`、`REFRESH_TTL_HOURS`(按安全策略)
|
||||
- [ ] 启用服务守护(systemd 或 docker compose restart)
|
||||
- [ ] 开启健康检查(`/healthz`)与日志采集
|
||||
- [ ] 使用 HTTPS 反向代理(确保 Cookie `Secure` 生效)
|
||||
|
||||
### systemd 快速部署
|
||||
|
||||
```bash
|
||||
cd /root/.openclaw/workspace/asset-tracker
|
||||
cp .env.production.example .env.production
|
||||
# 编辑 .env.production,设置强 JWT_SECRET
|
||||
|
||||
bash deploy/systemd/install_systemd.sh
|
||||
systemctl enable --now asset-tracker
|
||||
systemctl status asset-tracker --no-pager
|
||||
```
|
||||
|
||||
### Docker Compose 生产部署
|
||||
|
||||
```bash
|
||||
cd /root/.openclaw/workspace/asset-tracker/deploy
|
||||
export JWT_SECRET='replace_with_strong_random_secret'
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
## 备份与恢复
|
||||
|
||||
```bash
|
||||
# 备份
|
||||
DB_PATH=./data/asset-tracker.db ./scripts/backup_db.sh
|
||||
|
||||
# 恢复
|
||||
./scripts/restore_db.sh ./backups/asset-tracker-YYYYmmdd_HHMMSS.db.gz ./data/asset-tracker.db
|
||||
```
|
||||
|
||||
## 后续建议
|
||||
|
||||
- 真实通知通道(Telegram/邮件)
|
||||
- 估值快照与趋势图
|
||||
- 多币种折算
|
||||
@@ -0,0 +1,77 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"asset-tracker/internal/api"
|
||||
"asset-tracker/internal/auth"
|
||||
"asset-tracker/internal/config"
|
||||
"asset-tracker/internal/model"
|
||||
"asset-tracker/internal/scheduler"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func main() {
|
||||
cfg := config.Load()
|
||||
if cfg.AppEnv == "production" && cfg.JWTSecret == "change_me_in_production" {
|
||||
log.Fatal("JWT_SECRET must be set in production")
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(cfg.DBPath), 0o755); err != nil {
|
||||
log.Fatalf("create db dir failed: %v", err)
|
||||
}
|
||||
|
||||
db, err := gorm.Open(sqlite.Open(cfg.DBPath), &gorm.Config{})
|
||||
if err != nil {
|
||||
log.Fatalf("open db failed: %v", err)
|
||||
}
|
||||
|
||||
if err := db.AutoMigrate(&model.User{}, &model.Category{}, &model.Asset{}, &model.Reminder{}, &model.AuditLog{}, &model.RefreshSession{}, &model.ReminderDeadLetter{}); err != nil {
|
||||
log.Fatalf("auto migrate failed: %v", err)
|
||||
}
|
||||
|
||||
if err := ensureDefaultUser(db, cfg.DefaultUsername, cfg.DefaultPassword, cfg.DefaultTimezone); err != nil {
|
||||
log.Fatalf("ensure default user failed: %v", err)
|
||||
}
|
||||
|
||||
scheduler.StartReminderScan(db)
|
||||
|
||||
tm := auth.NewTokenManager(cfg.JWTSecret, cfg.AccessTTLMinutes, cfg.RefreshTTLHours)
|
||||
h := api.NewHandler(db, tm)
|
||||
r := gin.New()
|
||||
r.Use(gin.Recovery())
|
||||
r.Use(api.RequestID())
|
||||
r.Use(api.AccessLog())
|
||||
api.RegisterRoutes(r, h, tm)
|
||||
|
||||
log.Printf("asset-tracker listening on %s", cfg.HTTPAddr)
|
||||
if err := r.Run(cfg.HTTPAddr); err != nil {
|
||||
log.Fatalf("run http server failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func ensureDefaultUser(db *gorm.DB, username, password, timezone string) error {
|
||||
var cnt int64
|
||||
if err := db.Model(&model.User{}).Where("username = ?", username).Count(&cnt).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if cnt > 0 {
|
||||
return nil
|
||||
}
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
u := model.User{
|
||||
Username: username,
|
||||
PasswordHash: string(hash),
|
||||
Timezone: timezone,
|
||||
}
|
||||
return db.Create(&u).Error
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
FROM golang:1.23-alpine AS builder
|
||||
WORKDIR /app
|
||||
COPY go.mod go.sum* ./
|
||||
RUN go mod download
|
||||
COPY . .
|
||||
RUN CGO_ENABLED=1 GOOS=linux go build -o asset-tracker ./cmd/server
|
||||
|
||||
FROM alpine:3.20
|
||||
WORKDIR /app
|
||||
RUN apk add --no-cache ca-certificates
|
||||
COPY --from=builder /app/asset-tracker /app/asset-tracker
|
||||
COPY --from=builder /app/openapi.yaml /app/openapi.yaml
|
||||
COPY --from=builder /app/web/dist /app/web/dist
|
||||
COPY --from=builder /app/web/legacy /app/web/legacy
|
||||
RUN mkdir -p /app/data
|
||||
EXPOSE 9530
|
||||
CMD ["/app/asset-tracker"]
|
||||
@@ -0,0 +1,24 @@
|
||||
version: '3.9'
|
||||
services:
|
||||
asset-tracker:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: deploy/Dockerfile
|
||||
container_name: asset-tracker
|
||||
ports:
|
||||
- "9530:9530"
|
||||
environment:
|
||||
- HTTP_ADDR=:9530
|
||||
- DB_PATH=/app/data/asset-tracker.db
|
||||
- APP_ENV=production
|
||||
- JWT_SECRET=${JWT_SECRET:?JWT_SECRET is required}
|
||||
- ACCESS_TTL_MINUTES=30
|
||||
- REFRESH_TTL_HOURS=168
|
||||
volumes:
|
||||
- ../data:/app/data
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:9530/healthz"]
|
||||
interval: 30s
|
||||
timeout: 3s
|
||||
retries: 5
|
||||
@@ -0,0 +1,23 @@
|
||||
[Unit]
|
||||
Description=Asset Tracker Service
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=root
|
||||
WorkingDirectory=/root/.openclaw/workspace/asset-tracker
|
||||
EnvironmentFile=-/root/.openclaw/workspace/asset-tracker/.env.production
|
||||
Environment=APP_ENV=production
|
||||
Environment=HTTP_ADDR=:9530
|
||||
Environment=DB_PATH=/root/.openclaw/workspace/asset-tracker/data/asset-tracker.db
|
||||
Environment=DEFAULT_TIMEZONE=Asia/Shanghai
|
||||
ExecStart=/root/.openclaw/workspace/asset-tracker/bin/asset-tracker
|
||||
Restart=always
|
||||
RestartSec=3
|
||||
LimitNOFILE=65535
|
||||
StandardOutput=append:/root/.openclaw/workspace/asset-tracker/logs/server.log
|
||||
StandardError=append:/root/.openclaw/workspace/asset-tracker/logs/server.log
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
Executable
+25
@@ -0,0 +1,25 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SERVICE_NAME=asset-tracker.service
|
||||
SRC_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
SRC_FILE="$SRC_DIR/$SERVICE_NAME"
|
||||
DST_FILE="/etc/systemd/system/$SERVICE_NAME"
|
||||
ENV_FILE="/root/.openclaw/workspace/asset-tracker/.env.production"
|
||||
|
||||
if [[ ! -f "$SRC_FILE" ]]; then
|
||||
echo "missing $SRC_FILE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
install -m 0644 "$SRC_FILE" "$DST_FILE"
|
||||
systemctl daemon-reload
|
||||
|
||||
if [[ ! -f "$ENV_FILE" ]]; then
|
||||
cp /root/.openclaw/workspace/asset-tracker/.env.production.example "$ENV_FILE"
|
||||
echo "Created $ENV_FILE from example. Please set JWT_SECRET before start."
|
||||
fi
|
||||
|
||||
echo "Installed $DST_FILE"
|
||||
echo "Next: edit $ENV_FILE and set strong JWT_SECRET"
|
||||
echo "Then run: systemctl enable --now asset-tracker"
|
||||
@@ -0,0 +1,54 @@
|
||||
module asset-tracker
|
||||
|
||||
go 1.23.0
|
||||
|
||||
toolchain go1.24.4
|
||||
|
||||
require (
|
||||
github.com/gin-gonic/gin v1.10.1
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1
|
||||
github.com/prometheus/client_golang v1.23.2
|
||||
github.com/robfig/cron/v3 v3.0.1
|
||||
golang.org/x/crypto v0.41.0
|
||||
gorm.io/driver/sqlite v1.5.7
|
||||
gorm.io/gorm v1.25.12
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/bytedance/sonic v1.11.6 // indirect
|
||||
github.com/bytedance/sonic/loader v0.1.1 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/cloudwego/base64x v0.1.4 // indirect
|
||||
github.com/cloudwego/iasm v0.2.0 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
|
||||
github.com/gin-contrib/sse v0.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.20.0 // indirect
|
||||
github.com/goccy/go-json v0.10.2 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
|
||||
github.com/kr/text v0.2.0 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-sqlite3 v1.14.22 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
|
||||
github.com/prometheus/client_model v0.6.2 // indirect
|
||||
github.com/prometheus/common v0.66.1 // indirect
|
||||
github.com/prometheus/procfs v0.16.1 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.2.12 // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.2 // indirect
|
||||
golang.org/x/arch v0.8.0 // indirect
|
||||
golang.org/x/net v0.43.0 // indirect
|
||||
golang.org/x/sys v0.35.0 // indirect
|
||||
golang.org/x/text v0.28.0 // indirect
|
||||
google.golang.org/protobuf v1.36.8 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
@@ -0,0 +1,132 @@
|
||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||
github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0=
|
||||
github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
|
||||
github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
|
||||
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
|
||||
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
|
||||
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
|
||||
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
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/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
|
||||
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
|
||||
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
|
||||
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
|
||||
github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ=
|
||||
github.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
|
||||
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.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8=
|
||||
github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
|
||||
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
||||
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
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/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||
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/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
||||
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
|
||||
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
|
||||
github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
|
||||
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||
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.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
|
||||
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
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/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
|
||||
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
|
||||
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/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
|
||||
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
|
||||
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
|
||||
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
|
||||
github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs=
|
||||
github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA=
|
||||
github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg=
|
||||
github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=
|
||||
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
|
||||
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
|
||||
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
|
||||
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
|
||||
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.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
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.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.9.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.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
|
||||
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
|
||||
go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
|
||||
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
||||
golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
|
||||
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
|
||||
golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4=
|
||||
golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc=
|
||||
golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE=
|
||||
golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
|
||||
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
|
||||
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
|
||||
google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc=
|
||||
google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
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=
|
||||
gorm.io/driver/sqlite v1.5.7 h1:8NvsrhP0ifM7LX9G4zPB97NwovUakUxc+2V2uuf3Z1I=
|
||||
gorm.io/driver/sqlite v1.5.7/go.mod h1:U+J8craQU6Fzkcvu8oLeAQmi50TkwPEhHDEjQZXDah4=
|
||||
gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8=
|
||||
gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ=
|
||||
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
|
||||
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
|
||||
@@ -0,0 +1,20 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"asset-tracker/internal/metrics"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func AccessLog() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
start := time.Now()
|
||||
c.Next()
|
||||
latency := time.Since(start)
|
||||
metrics.ObserveHTTP(c, start)
|
||||
log.Printf("[http] request_id=%s method=%s path=%s status=%d latency=%s ip=%s", requestID(c), c.Request.Method, c.Request.URL.Path, c.Writer.Status(), latency.String(), c.ClientIP())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"log"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func bizLog(c *gin.Context, level, module, action string, kv map[string]any) {
|
||||
log.Printf("[biz][%s] request_id=%s user_id=%d module=%s action=%s kv=%v", level, requestID(c), c.GetUint("user_id"), module, action, kv)
|
||||
}
|
||||
|
||||
func bizInfo(c *gin.Context, module, action string, kv map[string]any) {
|
||||
bizLog(c, "INFO", module, action, kv)
|
||||
}
|
||||
|
||||
func bizError(c *gin.Context, module, action, code string, err error, kv map[string]any) {
|
||||
if kv == nil {
|
||||
kv = map[string]any{}
|
||||
}
|
||||
kv["code"] = code
|
||||
if err != nil {
|
||||
kv["error"] = err.Error()
|
||||
}
|
||||
bizLog(c, "ERROR", module, action, kv)
|
||||
}
|
||||
@@ -0,0 +1,778 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"asset-tracker/internal/auth"
|
||||
"asset-tracker/internal/model"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
db *gorm.DB
|
||||
tm *auth.TokenManager
|
||||
}
|
||||
|
||||
func NewHandler(db *gorm.DB, tm *auth.TokenManager) *Handler {
|
||||
return &Handler{db: db, tm: tm}
|
||||
}
|
||||
|
||||
func toJSON(v any) string {
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return "{}"
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func (h *Handler) writeAudit(userID uint, entityType string, entityID uint, action string, before any, after any) {
|
||||
log := model.AuditLog{
|
||||
UserID: userID,
|
||||
EntityType: entityType,
|
||||
EntityID: entityID,
|
||||
Action: action,
|
||||
BeforeJSON: toJSON(before),
|
||||
AfterJSON: toJSON(after),
|
||||
}
|
||||
_ = h.db.Create(&log).Error
|
||||
}
|
||||
|
||||
var currencyPattern = regexp.MustCompile(`^[A-Z]{3,10}$`)
|
||||
|
||||
type loginRequest struct {
|
||||
Username string `json:"username" binding:"required"`
|
||||
Password string `json:"password" binding:"required"`
|
||||
}
|
||||
|
||||
func (h *Handler) Login(c *gin.Context) {
|
||||
var req loginRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
bizError(c, "auth", "login", "BAD_REQUEST", err, nil)
|
||||
JSONBadRequest(c, "BAD_REQUEST", "invalid request", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
var user model.User
|
||||
if err := h.db.Where("username = ?", strings.TrimSpace(req.Username)).First(&user).Error; err != nil {
|
||||
bizError(c, "auth", "login", "AUTH_INVALID_CREDENTIALS", err, map[string]any{"username": strings.TrimSpace(req.Username)})
|
||||
JSONUnauthorized(c, "AUTH_INVALID_CREDENTIALS", "invalid username or password")
|
||||
return
|
||||
}
|
||||
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.Password)); err != nil {
|
||||
bizError(c, "auth", "login", "AUTH_INVALID_CREDENTIALS", err, map[string]any{"username": user.Username, "uid": user.ID})
|
||||
JSONUnauthorized(c, "AUTH_INVALID_CREDENTIALS", "invalid username or password")
|
||||
return
|
||||
}
|
||||
|
||||
access, err := h.tm.GenerateAccessToken(user.ID, user.Username, user.Timezone)
|
||||
if err != nil {
|
||||
JSONInternal(c, "internal server error", err.Error())
|
||||
return
|
||||
}
|
||||
refresh, jti, exp, err := h.tm.GenerateRefreshToken(user.ID, user.Username, user.Timezone)
|
||||
if err != nil {
|
||||
JSONInternal(c, "internal server error", err.Error())
|
||||
return
|
||||
}
|
||||
if err := h.db.Create(&model.RefreshSession{UserID: user.ID, JTI: jti, ExpiresAt: exp}).Error; err != nil {
|
||||
bizError(c, "auth", "login", "REFRESH_SESSION_CREATE_FAILED", err, map[string]any{"uid": user.ID})
|
||||
JSONInternal(c, "internal server error", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
secure := strings.EqualFold(strings.TrimSpace(c.GetHeader("X-Forwarded-Proto")), "https") || c.Request.TLS != nil
|
||||
c.SetSameSite(http.SameSiteLaxMode)
|
||||
c.SetCookie("refresh_token", refresh, h.tm.RefreshMaxAgeSeconds(), "/api/v1/auth/refresh", "", secure, true)
|
||||
|
||||
bizInfo(c, "auth", "login", map[string]any{"uid": user.ID, "username": user.Username})
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"access_token": access,
|
||||
"token_type": "Bearer",
|
||||
})
|
||||
}
|
||||
|
||||
type refreshRequest struct {
|
||||
RefreshToken string `json:"refresh_token" binding:"required"`
|
||||
}
|
||||
|
||||
func (h *Handler) Refresh(c *gin.Context) {
|
||||
refreshToken := strings.TrimSpace(c.GetHeader("X-Refresh-Token"))
|
||||
if refreshToken == "" {
|
||||
if cookie, err := c.Cookie("refresh_token"); err == nil {
|
||||
refreshToken = strings.TrimSpace(cookie)
|
||||
}
|
||||
}
|
||||
if refreshToken == "" {
|
||||
var req refreshRequest
|
||||
if err := c.ShouldBindJSON(&req); err == nil {
|
||||
refreshToken = strings.TrimSpace(req.RefreshToken)
|
||||
}
|
||||
}
|
||||
if refreshToken == "" {
|
||||
bizError(c, "auth", "refresh", "AUTH_MISSING_REFRESH", nil, nil)
|
||||
JSONUnauthorized(c, "AUTH_MISSING_REFRESH", "missing refresh token")
|
||||
return
|
||||
}
|
||||
|
||||
claims, err := h.tm.ParseAndValidate(refreshToken, "refresh")
|
||||
if err != nil {
|
||||
JSONUnauthorized(c, "AUTH_INVALID_REFRESH", "invalid refresh token")
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(claims.ID) == "" {
|
||||
JSONUnauthorized(c, "AUTH_INVALID_REFRESH", "invalid refresh token")
|
||||
return
|
||||
}
|
||||
|
||||
var session model.RefreshSession
|
||||
if err := h.db.Where("jti = ? AND user_id = ?", claims.ID, claims.UserID).First(&session).Error; err != nil {
|
||||
bizError(c, "auth", "refresh", "AUTH_INVALID_REFRESH", err, map[string]any{"uid": claims.UserID, "jti": claims.ID})
|
||||
JSONUnauthorized(c, "AUTH_INVALID_REFRESH", "invalid refresh token")
|
||||
return
|
||||
}
|
||||
if session.RevokedAt != nil || session.ExpiresAt.Before(time.Now().UTC()) {
|
||||
bizError(c, "auth", "refresh", "AUTH_INVALID_REFRESH", nil, map[string]any{"uid": claims.UserID, "jti": claims.ID, "revoked": session.RevokedAt != nil})
|
||||
JSONUnauthorized(c, "AUTH_INVALID_REFRESH", "invalid refresh token")
|
||||
return
|
||||
}
|
||||
|
||||
access, err := h.tm.GenerateAccessToken(claims.UserID, claims.Username, claims.Timezone)
|
||||
if err != nil {
|
||||
JSONInternal(c, "internal server error", err.Error())
|
||||
return
|
||||
}
|
||||
newRefresh, newJTI, newExp, err := h.tm.GenerateRefreshToken(claims.UserID, claims.Username, claims.Timezone)
|
||||
if err != nil {
|
||||
JSONInternal(c, "internal server error", err.Error())
|
||||
return
|
||||
}
|
||||
if err := h.db.Transaction(func(tx *gorm.DB) error {
|
||||
now := time.Now().UTC()
|
||||
if err := tx.Model(&model.RefreshSession{}).Where("id = ?", session.ID).Updates(map[string]any{"revoked_at": &now, "replaced_by": newJTI}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Create(&model.RefreshSession{UserID: claims.UserID, JTI: newJTI, ExpiresAt: newExp}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
bizError(c, "auth", "refresh", "REFRESH_ROTATE_FAILED", err, map[string]any{"uid": claims.UserID, "old_jti": claims.ID, "new_jti": newJTI})
|
||||
JSONInternal(c, "internal server error", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
secure := strings.EqualFold(strings.TrimSpace(c.GetHeader("X-Forwarded-Proto")), "https") || c.Request.TLS != nil
|
||||
c.SetSameSite(http.SameSiteLaxMode)
|
||||
c.SetCookie("refresh_token", newRefresh, h.tm.RefreshMaxAgeSeconds(), "/api/v1/auth/refresh", "", secure, true)
|
||||
|
||||
bizInfo(c, "auth", "refresh", map[string]any{"uid": claims.UserID, "old_jti": claims.ID, "new_jti": newJTI})
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"access_token": access,
|
||||
"token_type": "Bearer",
|
||||
})
|
||||
}
|
||||
|
||||
type createCategoryRequest struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
Type string `json:"type" binding:"required,oneof=real digital"`
|
||||
Color string `json:"color"`
|
||||
}
|
||||
|
||||
func (h *Handler) CreateCategory(c *gin.Context) {
|
||||
userID := c.GetUint("user_id")
|
||||
var req createCategoryRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
bizError(c, "category", "create", "BAD_REQUEST", err, nil)
|
||||
JSONBadRequest(c, "BAD_REQUEST", "invalid request", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
cat := model.Category{
|
||||
UserID: userID,
|
||||
Name: strings.TrimSpace(req.Name),
|
||||
Type: req.Type,
|
||||
Color: strings.TrimSpace(req.Color),
|
||||
}
|
||||
if cat.Name == "" {
|
||||
bizError(c, "category", "create", "CATEGORY_NAME_REQUIRED", nil, nil)
|
||||
JSONBadRequest(c, "CATEGORY_NAME_REQUIRED", "name is required", nil)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.db.Create(&cat).Error; err != nil {
|
||||
if strings.Contains(strings.ToLower(err.Error()), "unique") {
|
||||
bizError(c, "category", "create", "CATEGORY_DUPLICATE", err, map[string]any{"name": cat.Name})
|
||||
JSONError(c, http.StatusConflict, "CATEGORY_DUPLICATE", "category already exists", nil)
|
||||
return
|
||||
}
|
||||
bizError(c, "category", "create", "INTERNAL_ERROR", err, nil)
|
||||
JSONInternal(c, "internal server error", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
bizInfo(c, "category", "create", map[string]any{"category_id": cat.ID, "name": cat.Name})
|
||||
c.JSON(http.StatusCreated, gin.H{"data": cat})
|
||||
}
|
||||
|
||||
func (h *Handler) ListCategories(c *gin.Context) {
|
||||
userID := c.GetUint("user_id")
|
||||
var categories []model.Category
|
||||
if err := h.db.Where("user_id = ?", userID).Order("id desc").Find(&categories).Error; err != nil {
|
||||
JSONInternal(c, "internal server error", err.Error())
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": categories})
|
||||
}
|
||||
|
||||
type createAssetRequest struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
CategoryID uint `json:"category_id" binding:"required"`
|
||||
Quantity float64 `json:"quantity" binding:"required"`
|
||||
UnitPrice float64 `json:"unit_price" binding:"required"`
|
||||
Currency string `json:"currency" binding:"required"`
|
||||
ExpiryDate string `json:"expiry_date"`
|
||||
Note string `json:"note"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
type updateAssetRequest struct {
|
||||
Name *string `json:"name"`
|
||||
CategoryID *uint `json:"category_id"`
|
||||
Quantity *float64 `json:"quantity"`
|
||||
UnitPrice *float64 `json:"unit_price"`
|
||||
Currency *string `json:"currency"`
|
||||
ExpiryDate *string `json:"expiry_date"`
|
||||
Note *string `json:"note"`
|
||||
Status *string `json:"status"`
|
||||
}
|
||||
|
||||
func parseExpiryToUTC(raw string) (*time.Time, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return nil, nil
|
||||
}
|
||||
parsed, err := time.Parse(time.RFC3339, raw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
u := parsed.UTC()
|
||||
return &u, nil
|
||||
}
|
||||
|
||||
func (h *Handler) CreateAsset(c *gin.Context) {
|
||||
userID := c.GetUint("user_id")
|
||||
var req createAssetRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
bizError(c, "asset", "create", "BAD_REQUEST", err, nil)
|
||||
JSONBadRequest(c, "BAD_REQUEST", "invalid request", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if req.Quantity < 0 || req.UnitPrice < 0 {
|
||||
JSONBadRequest(c, "ASSET_NEGATIVE_VALUE", "quantity and unit_price must be >= 0", nil)
|
||||
return
|
||||
}
|
||||
|
||||
currency := strings.ToUpper(strings.TrimSpace(req.Currency))
|
||||
if !currencyPattern.MatchString(currency) {
|
||||
JSONBadRequest(c, "ASSET_INVALID_CURRENCY", "currency must match [A-Z]{3,10}", nil)
|
||||
return
|
||||
}
|
||||
|
||||
status := strings.TrimSpace(req.Status)
|
||||
if status == "" {
|
||||
status = "active"
|
||||
}
|
||||
if status != "active" && status != "inactive" {
|
||||
JSONBadRequest(c, "ASSET_INVALID_STATUS", "status must be active or inactive", nil)
|
||||
return
|
||||
}
|
||||
|
||||
expiry, err := parseExpiryToUTC(req.ExpiryDate)
|
||||
if err != nil {
|
||||
JSONBadRequest(c, "ASSET_INVALID_EXPIRY", "expiry_date must be RFC3339", nil)
|
||||
return
|
||||
}
|
||||
|
||||
var count int64
|
||||
if err := h.db.Model(&model.Category{}).Where("id = ? AND user_id = ?", req.CategoryID, userID).Count(&count).Error; err != nil {
|
||||
JSONInternal(c, "internal server error", err.Error())
|
||||
return
|
||||
}
|
||||
if count == 0 {
|
||||
JSONBadRequest(c, "CATEGORY_NOT_FOUND", "category not found", nil)
|
||||
return
|
||||
}
|
||||
|
||||
asset := model.Asset{
|
||||
UserID: userID,
|
||||
Name: strings.TrimSpace(req.Name),
|
||||
CategoryID: req.CategoryID,
|
||||
Quantity: req.Quantity,
|
||||
UnitPrice: req.UnitPrice,
|
||||
TotalValue: req.Quantity * req.UnitPrice,
|
||||
Currency: currency,
|
||||
ExpiryDate: expiry,
|
||||
Note: strings.TrimSpace(req.Note),
|
||||
Status: status,
|
||||
}
|
||||
if asset.Name == "" {
|
||||
JSONBadRequest(c, "CATEGORY_NAME_REQUIRED", "name is required", nil)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.db.Create(&asset).Error; err != nil {
|
||||
bizError(c, "asset", "create", "INTERNAL_ERROR", err, nil)
|
||||
JSONInternal(c, "internal server error", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
h.ensureRemindersForAsset(asset)
|
||||
h.writeAudit(userID, "asset", asset.ID, "create", nil, asset)
|
||||
bizInfo(c, "asset", "create", map[string]any{"asset_id": asset.ID, "name": asset.Name})
|
||||
c.JSON(http.StatusCreated, gin.H{"data": formatAssetForTZ(asset, c.GetString("timezone"))})
|
||||
}
|
||||
|
||||
func (h *Handler) UpdateAsset(c *gin.Context) {
|
||||
userID := c.GetUint("user_id")
|
||||
assetID := c.Param("id")
|
||||
|
||||
var asset model.Asset
|
||||
if err := h.db.Where("id = ? AND user_id = ?", assetID, userID).First(&asset).Error; err != nil {
|
||||
bizError(c, "asset", "update", "ASSET_NOT_FOUND", err, map[string]any{"asset_id": assetID})
|
||||
JSONError(c, http.StatusNotFound, "ASSET_NOT_FOUND", "asset not found", nil)
|
||||
return
|
||||
}
|
||||
before := asset
|
||||
|
||||
var req updateAssetRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
bizError(c, "asset", "update", "BAD_REQUEST", err, map[string]any{"asset_id": asset.ID})
|
||||
JSONBadRequest(c, "BAD_REQUEST", "invalid request", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if req.Name != nil {
|
||||
asset.Name = strings.TrimSpace(*req.Name)
|
||||
if asset.Name == "" {
|
||||
JSONBadRequest(c, "ASSET_NAME_EMPTY", "name cannot be empty", nil)
|
||||
return
|
||||
}
|
||||
}
|
||||
if req.CategoryID != nil {
|
||||
var count int64
|
||||
if err := h.db.Model(&model.Category{}).Where("id = ? AND user_id = ?", *req.CategoryID, userID).Count(&count).Error; err != nil {
|
||||
JSONInternal(c, "internal server error", err.Error())
|
||||
return
|
||||
}
|
||||
if count == 0 {
|
||||
JSONBadRequest(c, "CATEGORY_NOT_FOUND", "category not found", nil)
|
||||
return
|
||||
}
|
||||
asset.CategoryID = *req.CategoryID
|
||||
}
|
||||
if req.Quantity != nil {
|
||||
if *req.Quantity < 0 {
|
||||
JSONBadRequest(c, "ASSET_QUANTITY_NEGATIVE", "quantity must be >= 0", nil)
|
||||
return
|
||||
}
|
||||
asset.Quantity = *req.Quantity
|
||||
}
|
||||
if req.UnitPrice != nil {
|
||||
if *req.UnitPrice < 0 {
|
||||
JSONBadRequest(c, "ASSET_UNIT_PRICE_NEGATIVE", "unit_price must be >= 0", nil)
|
||||
return
|
||||
}
|
||||
asset.UnitPrice = *req.UnitPrice
|
||||
}
|
||||
if req.Currency != nil {
|
||||
cur := strings.ToUpper(strings.TrimSpace(*req.Currency))
|
||||
if !currencyPattern.MatchString(cur) {
|
||||
JSONBadRequest(c, "ASSET_INVALID_CURRENCY", "currency must match [A-Z]{3,10}", nil)
|
||||
return
|
||||
}
|
||||
asset.Currency = cur
|
||||
}
|
||||
if req.Status != nil {
|
||||
status := strings.TrimSpace(*req.Status)
|
||||
if status != "active" && status != "inactive" {
|
||||
JSONBadRequest(c, "ASSET_INVALID_STATUS", "status must be active or inactive", nil)
|
||||
return
|
||||
}
|
||||
asset.Status = status
|
||||
}
|
||||
if req.Note != nil {
|
||||
asset.Note = strings.TrimSpace(*req.Note)
|
||||
}
|
||||
if req.ExpiryDate != nil {
|
||||
expiry, err := parseExpiryToUTC(*req.ExpiryDate)
|
||||
if err != nil {
|
||||
JSONBadRequest(c, "ASSET_INVALID_EXPIRY", "expiry_date must be RFC3339", nil)
|
||||
return
|
||||
}
|
||||
asset.ExpiryDate = expiry
|
||||
}
|
||||
|
||||
asset.TotalValue = asset.Quantity * asset.UnitPrice
|
||||
if err := h.db.Save(&asset).Error; err != nil {
|
||||
bizError(c, "asset", "update", "INTERNAL_ERROR", err, map[string]any{"asset_id": asset.ID})
|
||||
JSONInternal(c, "internal server error", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
h.ensureRemindersForAsset(asset)
|
||||
h.writeAudit(userID, "asset", asset.ID, "update", before, asset)
|
||||
bizInfo(c, "asset", "update", map[string]any{"asset_id": asset.ID, "status": asset.Status})
|
||||
c.JSON(http.StatusOK, gin.H{"data": formatAssetForTZ(asset, c.GetString("timezone"))})
|
||||
}
|
||||
|
||||
func (h *Handler) DeleteAsset(c *gin.Context) {
|
||||
userID := c.GetUint("user_id")
|
||||
assetID := c.Param("id")
|
||||
|
||||
var asset model.Asset
|
||||
if err := h.db.Where("id = ? AND user_id = ?", assetID, userID).First(&asset).Error; err != nil {
|
||||
bizError(c, "asset", "delete", "ASSET_NOT_FOUND", err, map[string]any{"asset_id": assetID})
|
||||
JSONError(c, http.StatusNotFound, "ASSET_NOT_FOUND", "asset not found", nil)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Where("asset_id = ? AND user_id = ?", asset.ID, userID).Delete(&model.Reminder{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Delete(&asset).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
log := model.AuditLog{
|
||||
UserID: userID,
|
||||
EntityType: "asset",
|
||||
EntityID: asset.ID,
|
||||
Action: "delete",
|
||||
BeforeJSON: toJSON(asset),
|
||||
AfterJSON: "null",
|
||||
}
|
||||
return tx.Create(&log).Error
|
||||
}); err != nil {
|
||||
bizError(c, "asset", "delete", "INTERNAL_ERROR", err, map[string]any{"asset_id": asset.ID})
|
||||
JSONInternal(c, "internal server error", err.Error())
|
||||
return
|
||||
}
|
||||
bizInfo(c, "asset", "delete", map[string]any{"asset_id": asset.ID})
|
||||
c.JSON(http.StatusOK, gin.H{"message": "deleted", "request_id": requestID(c)})
|
||||
}
|
||||
|
||||
func (h *Handler) ListAssets(c *gin.Context) {
|
||||
userID := c.GetUint("user_id")
|
||||
var assets []model.Asset
|
||||
query := h.db.Model(&model.Asset{}).Where("user_id = ?", userID).Order("id desc")
|
||||
|
||||
categoryID := strings.TrimSpace(c.Query("category_id"))
|
||||
if categoryID != "" {
|
||||
query = query.Where("category_id = ?", categoryID)
|
||||
}
|
||||
|
||||
status := strings.TrimSpace(c.Query("status"))
|
||||
if status != "" {
|
||||
if status != "active" && status != "inactive" {
|
||||
JSONBadRequest(c, "ASSET_INVALID_STATUS", "status must be active or inactive", nil)
|
||||
return
|
||||
}
|
||||
query = query.Where("status = ?", status)
|
||||
}
|
||||
|
||||
page := 1
|
||||
pageSize := 20
|
||||
if p := strings.TrimSpace(c.Query("page")); p != "" {
|
||||
fmt.Sscanf(p, "%d", &page)
|
||||
}
|
||||
if ps := strings.TrimSpace(c.Query("page_size")); ps != "" {
|
||||
fmt.Sscanf(ps, "%d", &pageSize)
|
||||
}
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if pageSize < 1 {
|
||||
pageSize = 20
|
||||
}
|
||||
if pageSize > 100 {
|
||||
pageSize = 100
|
||||
}
|
||||
offset := (page - 1) * pageSize
|
||||
|
||||
var total int64
|
||||
if err := query.Count(&total).Error; err != nil {
|
||||
JSONInternal(c, "internal server error", err.Error())
|
||||
return
|
||||
}
|
||||
if err := query.Offset(offset).Limit(pageSize).Find(&assets).Error; err != nil {
|
||||
JSONInternal(c, "internal server error", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
resp := make([]gin.H, 0, len(assets))
|
||||
for _, a := range assets {
|
||||
resp = append(resp, formatAssetForTZ(a, c.GetString("timezone")))
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"data": resp, "total": total, "page": page, "page_size": pageSize})
|
||||
}
|
||||
|
||||
func (h *Handler) PublicRecords(c *gin.Context) {
|
||||
tz := strings.TrimSpace(c.Query("timezone"))
|
||||
if tz == "" {
|
||||
tz = "Asia/Shanghai"
|
||||
}
|
||||
|
||||
var assets []model.Asset
|
||||
if err := h.db.Where("user_id = ?", 1).Order("updated_at desc").Limit(100).Find(&assets).Error; err != nil {
|
||||
JSONInternal(c, "internal server error", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
var categories []model.Category
|
||||
_ = h.db.Where("user_id = ?", 1).Find(&categories).Error
|
||||
catName := map[uint]string{}
|
||||
for _, x := range categories {
|
||||
catName[x.ID] = x.Name
|
||||
}
|
||||
|
||||
total := 0.0
|
||||
activeCount := 0
|
||||
byCat := map[string]float64{}
|
||||
resp := make([]gin.H, 0, len(assets))
|
||||
for _, a := range assets {
|
||||
if a.Status == "active" {
|
||||
total += a.TotalValue
|
||||
activeCount++
|
||||
byCat[catName[a.CategoryID]] += a.TotalValue
|
||||
}
|
||||
row := formatAssetForTZ(a, tz)
|
||||
row["category_name"] = catName[a.CategoryID]
|
||||
resp = append(resp, row)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"summary": gin.H{
|
||||
"user_id": 1,
|
||||
"active_asset_count": activeCount,
|
||||
"total_assets_value": total,
|
||||
"by_category": byCat,
|
||||
},
|
||||
"records": resp,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) ListReminders(c *gin.Context) {
|
||||
userID := c.GetUint("user_id")
|
||||
query := h.db.Model(&model.Reminder{}).Where("user_id = ?", userID).Order("status asc, remind_at asc, id desc")
|
||||
|
||||
status := strings.TrimSpace(c.Query("status"))
|
||||
if status != "" {
|
||||
allowed := map[string]bool{"pending": true, "sending": true, "sent": true, "failed": true}
|
||||
if !allowed[status] {
|
||||
JSONBadRequest(c, "REMINDER_INVALID_STATUS", "invalid status", nil)
|
||||
return
|
||||
}
|
||||
query = query.Where("status = ?", status)
|
||||
}
|
||||
|
||||
page := 1
|
||||
pageSize := 20
|
||||
if p := strings.TrimSpace(c.Query("page")); p != "" {
|
||||
fmt.Sscanf(p, "%d", &page)
|
||||
}
|
||||
if ps := strings.TrimSpace(c.Query("page_size")); ps != "" {
|
||||
fmt.Sscanf(ps, "%d", &pageSize)
|
||||
}
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if pageSize < 1 {
|
||||
pageSize = 20
|
||||
}
|
||||
if pageSize > 100 {
|
||||
pageSize = 100
|
||||
}
|
||||
offset := (page - 1) * pageSize
|
||||
|
||||
var total int64
|
||||
if err := query.Count(&total).Error; err != nil {
|
||||
JSONInternal(c, "internal server error", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
var rows []model.Reminder
|
||||
if err := query.Offset(offset).Limit(pageSize).Find(&rows).Error; err != nil {
|
||||
JSONInternal(c, "internal server error", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
assetIDs := make([]uint, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
assetIDs = append(assetIDs, r.AssetID)
|
||||
}
|
||||
nameMap := map[uint]string{}
|
||||
if len(assetIDs) > 0 {
|
||||
var assets []model.Asset
|
||||
_ = h.db.Where("id IN ? AND user_id = ?", assetIDs, userID).Find(&assets).Error
|
||||
for _, a := range assets {
|
||||
nameMap[a.ID] = a.Name
|
||||
}
|
||||
}
|
||||
|
||||
loc, err := time.LoadLocation(c.GetString("timezone"))
|
||||
if err != nil {
|
||||
loc = time.UTC
|
||||
}
|
||||
resp := make([]gin.H, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
item := gin.H{
|
||||
"id": r.ID,
|
||||
"asset_id": r.AssetID,
|
||||
"asset_name": nameMap[r.AssetID],
|
||||
"remind_at": r.RemindAt.In(loc).Format(time.RFC3339),
|
||||
"channel": r.Channel,
|
||||
"status": r.Status,
|
||||
"retry_count": r.RetryCount,
|
||||
"last_error": r.LastError,
|
||||
"created_at": r.CreatedAt.In(loc).Format(time.RFC3339),
|
||||
"updated_at": r.UpdatedAt.In(loc).Format(time.RFC3339),
|
||||
}
|
||||
if r.SentAt != nil {
|
||||
item["sent_at"] = r.SentAt.In(loc).Format(time.RFC3339)
|
||||
}
|
||||
if r.NextRetryAt != nil {
|
||||
item["next_retry_at"] = r.NextRetryAt.In(loc).Format(time.RFC3339)
|
||||
}
|
||||
resp = append(resp, item)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"data": resp, "total": total, "page": page, "page_size": pageSize})
|
||||
}
|
||||
|
||||
func (h *Handler) DashboardSummary(c *gin.Context) {
|
||||
userID := c.GetUint("user_id")
|
||||
var assets []model.Asset
|
||||
if err := h.db.Where("user_id = ? AND status = ?", userID, "active").Find(&assets).Error; err != nil {
|
||||
JSONInternal(c, "internal server error", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
total := 0.0
|
||||
for _, a := range assets {
|
||||
total += a.TotalValue
|
||||
}
|
||||
|
||||
type categoryAgg struct {
|
||||
CategoryID uint `json:"category_id"`
|
||||
CategoryName string `json:"category_name"`
|
||||
TotalValue float64 `json:"total_value"`
|
||||
Ratio float64 `json:"ratio"`
|
||||
}
|
||||
|
||||
nameMap := map[uint]string{}
|
||||
var categories []model.Category
|
||||
_ = h.db.Where("user_id = ?", userID).Find(&categories).Error
|
||||
for _, cat := range categories {
|
||||
nameMap[cat.ID] = cat.Name
|
||||
}
|
||||
|
||||
byCatMap := map[uint]float64{}
|
||||
for _, a := range assets {
|
||||
byCatMap[a.CategoryID] += a.TotalValue
|
||||
}
|
||||
|
||||
byCategory := make([]categoryAgg, 0, len(byCatMap))
|
||||
for categoryID, v := range byCatMap {
|
||||
ratio := 0.0
|
||||
if total > 0 {
|
||||
ratio = v / total
|
||||
}
|
||||
byCategory = append(byCategory, categoryAgg{
|
||||
CategoryID: categoryID,
|
||||
CategoryName: nameMap[categoryID],
|
||||
TotalValue: v,
|
||||
Ratio: ratio,
|
||||
})
|
||||
}
|
||||
|
||||
sort.Slice(byCategory, func(i, j int) bool { return byCategory[i].TotalValue > byCategory[j].TotalValue })
|
||||
|
||||
nowUTC := time.Now().UTC()
|
||||
endUTC := nowUTC.Add(30 * 24 * time.Hour)
|
||||
var expiring []model.Asset
|
||||
if err := h.db.Where("user_id = ? AND status = ? AND expiry_date IS NOT NULL AND expiry_date >= ? AND expiry_date <= ?", userID, "active", nowUTC, endUTC).Order("expiry_date asc").Find(&expiring).Error; err != nil {
|
||||
JSONInternal(c, "internal server error", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
expiringResp := make([]gin.H, 0, len(expiring))
|
||||
for _, a := range expiring {
|
||||
expiringResp = append(expiringResp, formatAssetForTZ(a, c.GetString("timezone")))
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"total_assets_value": total,
|
||||
"by_category": byCategory,
|
||||
"expiring_in_30_days": expiringResp,
|
||||
})
|
||||
}
|
||||
|
||||
func formatAssetForTZ(a model.Asset, tz string) gin.H {
|
||||
loc, err := time.LoadLocation(tz)
|
||||
if err != nil {
|
||||
loc = time.UTC
|
||||
}
|
||||
var expiry any
|
||||
if a.ExpiryDate != nil {
|
||||
expiry = a.ExpiryDate.In(loc).Format(time.RFC3339)
|
||||
}
|
||||
return gin.H{
|
||||
"id": a.ID,
|
||||
"name": a.Name,
|
||||
"category_id": a.CategoryID,
|
||||
"quantity": a.Quantity,
|
||||
"unit_price": a.UnitPrice,
|
||||
"total_value": a.TotalValue,
|
||||
"currency": a.Currency,
|
||||
"expiry_date": expiry,
|
||||
"note": a.Note,
|
||||
"status": a.Status,
|
||||
"created_at": a.CreatedAt.In(loc).Format(time.RFC3339),
|
||||
"updated_at": a.UpdatedAt.In(loc).Format(time.RFC3339),
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) ensureRemindersForAsset(asset model.Asset) {
|
||||
_ = h.db.Where("asset_id = ? AND user_id = ? AND status IN ?", asset.ID, asset.UserID, []string{"pending", "failed", "sending"}).Delete(&model.Reminder{}).Error
|
||||
if asset.ExpiryDate == nil || asset.Status != "active" {
|
||||
return
|
||||
}
|
||||
base := asset.ExpiryDate.UTC()
|
||||
days := []int{30, 7, 1}
|
||||
for _, d := range days {
|
||||
remindAt := base.Add(-time.Duration(d) * 24 * time.Hour)
|
||||
dedupe := fmt.Sprintf("asset:%d:at:%s:ch:in_app", asset.ID, remindAt.Format(time.RFC3339))
|
||||
r := model.Reminder{
|
||||
UserID: asset.UserID,
|
||||
AssetID: asset.ID,
|
||||
RemindAt: remindAt,
|
||||
Channel: "in_app",
|
||||
Status: "pending",
|
||||
DedupeKey: dedupe,
|
||||
}
|
||||
_ = h.db.Clauses(clause.OnConflict{Columns: []clause.Column{{Name: "dedupe_key"}}, DoNothing: true}).Create(&r).Error
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"asset-tracker/internal/auth"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func AuthRequired(tm *auth.TokenManager) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
authHeader := c.GetHeader("Authorization")
|
||||
if authHeader == "" || !strings.HasPrefix(authHeader, "Bearer ") {
|
||||
JSONUnauthorized(c, "AUTH_MISSING_BEARER", "missing bearer token")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
token := strings.TrimSpace(strings.TrimPrefix(authHeader, "Bearer "))
|
||||
claims, err := tm.ParseAndValidate(token, "access")
|
||||
if err != nil {
|
||||
JSONUnauthorized(c, "AUTH_INVALID_TOKEN", "invalid token")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
c.Set("user_id", claims.UserID)
|
||||
c.Set("username", claims.Username)
|
||||
c.Set("timezone", claims.Timezone)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func RequestID() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
id := c.GetHeader("X-Request-Id")
|
||||
if id == "" {
|
||||
b := make([]byte, 8)
|
||||
if _, err := rand.Read(b); err == nil {
|
||||
id = hex.EncodeToString(b)
|
||||
} else {
|
||||
id = "req-unknown"
|
||||
}
|
||||
}
|
||||
c.Set("request_id", id)
|
||||
c.Header("X-Request-Id", id)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type ErrorBody struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Details any `json:"details,omitempty"`
|
||||
RequestID string `json:"request_id,omitempty"`
|
||||
}
|
||||
|
||||
func requestID(c *gin.Context) string {
|
||||
if v, ok := c.Get("request_id"); ok {
|
||||
if s, ok := v.(string); ok {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func JSONError(c *gin.Context, status int, code, message string, details any) {
|
||||
c.JSON(status, ErrorBody{
|
||||
Code: code,
|
||||
Message: message,
|
||||
Details: details,
|
||||
RequestID: requestID(c),
|
||||
})
|
||||
}
|
||||
|
||||
func JSONBadRequest(c *gin.Context, code, message string, details any) {
|
||||
JSONError(c, http.StatusBadRequest, code, message, details)
|
||||
}
|
||||
|
||||
func JSONUnauthorized(c *gin.Context, code, message string) {
|
||||
JSONError(c, http.StatusUnauthorized, code, message, nil)
|
||||
}
|
||||
|
||||
func JSONInternal(c *gin.Context, message string, details any) {
|
||||
JSONError(c, http.StatusInternalServerError, "INTERNAL_ERROR", message, details)
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"asset-tracker/internal/auth"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||
)
|
||||
|
||||
func RegisterRoutes(r *gin.Engine, h *Handler, tm *auth.TokenManager) {
|
||||
r.Static("/legacy/static", "./web/legacy/static")
|
||||
r.GET("/legacy", func(c *gin.Context) {
|
||||
c.File("./web/legacy/index.html")
|
||||
})
|
||||
r.GET("/legacy/records", func(c *gin.Context) {
|
||||
c.File("./web/legacy/records.html")
|
||||
})
|
||||
|
||||
r.GET("/metrics", gin.WrapH(promhttp.Handler()))
|
||||
r.Static("/_assets", "./web/dist/_assets")
|
||||
r.GET("/", func(c *gin.Context) {
|
||||
c.File("./web/dist/index.html")
|
||||
})
|
||||
r.GET("/public/records", h.PublicRecords)
|
||||
// status endpoint moved here for diagnostics
|
||||
r.GET("/status", func(c *gin.Context) {
|
||||
c.JSON(200, gin.H{
|
||||
"name": "asset-tracker",
|
||||
"status": "running",
|
||||
"health": "/health",
|
||||
"api_base": "/api/v1",
|
||||
"web_app": "/app",
|
||||
})
|
||||
})
|
||||
|
||||
r.GET("/app", func(c *gin.Context) {
|
||||
c.File("./web/dist/index.html")
|
||||
})
|
||||
r.GET("/app/", func(c *gin.Context) {
|
||||
c.File("./web/dist/index.html")
|
||||
})
|
||||
r.NoRoute(func(c *gin.Context) {
|
||||
if c.Request.Method == http.MethodGet {
|
||||
path := c.Request.URL.Path
|
||||
if strings.HasPrefix(path, "/api/") || strings.HasPrefix(path, "/public/") || strings.HasPrefix(path, "/health") || strings.HasPrefix(path, "/status") || strings.HasPrefix(path, "/legacy") {
|
||||
JSONError(c, http.StatusNotFound, "NOT_FOUND", "not found", nil)
|
||||
return
|
||||
}
|
||||
c.File("./web/dist/index.html")
|
||||
return
|
||||
}
|
||||
JSONError(c, http.StatusNotFound, "NOT_FOUND", "not found", nil)
|
||||
})
|
||||
|
||||
r.GET("/health", func(c *gin.Context) {
|
||||
c.JSON(200, gin.H{"status": "ok"})
|
||||
})
|
||||
r.GET("/healthz", func(c *gin.Context) {
|
||||
c.JSON(200, gin.H{"status": "ok"})
|
||||
})
|
||||
r.GET("/readyz", func(c *gin.Context) {
|
||||
c.JSON(200, gin.H{"status": "ready"})
|
||||
})
|
||||
|
||||
v1 := r.Group("/api/v1")
|
||||
{
|
||||
v1.POST("/auth/login", h.Login)
|
||||
v1.POST("/auth/refresh", h.Refresh)
|
||||
|
||||
secured := v1.Group("")
|
||||
secured.Use(AuthRequired(tm))
|
||||
{
|
||||
secured.POST("/categories", h.CreateCategory)
|
||||
secured.GET("/categories", h.ListCategories)
|
||||
|
||||
secured.POST("/assets", h.CreateAsset)
|
||||
secured.GET("/assets", h.ListAssets)
|
||||
secured.PUT("/assets/:id", h.UpdateAsset)
|
||||
secured.DELETE("/assets/:id", h.DeleteAsset)
|
||||
|
||||
secured.GET("/dashboard/summary", h.DashboardSummary)
|
||||
secured.GET("/reminders", h.ListReminders)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
type Claims struct {
|
||||
UserID uint `json:"user_id"`
|
||||
Username string `json:"username"`
|
||||
Timezone string `json:"timezone"`
|
||||
Type string `json:"type"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
type TokenManager struct {
|
||||
secret []byte
|
||||
accessTTLMinutes int
|
||||
refreshTTLHours int
|
||||
}
|
||||
|
||||
func (tm *TokenManager) RefreshMaxAgeSeconds() int {
|
||||
return tm.refreshTTLHours * 3600
|
||||
}
|
||||
|
||||
func NewTokenManager(secret string, accessTTLMinutes, refreshTTLHours int) *TokenManager {
|
||||
return &TokenManager{
|
||||
secret: []byte(secret),
|
||||
accessTTLMinutes: accessTTLMinutes,
|
||||
refreshTTLHours: refreshTTLHours,
|
||||
}
|
||||
}
|
||||
|
||||
func (tm *TokenManager) GenerateAccessToken(userID uint, username, timezone string) (string, error) {
|
||||
now := time.Now().UTC()
|
||||
claims := Claims{
|
||||
UserID: userID,
|
||||
Username: username,
|
||||
Timezone: timezone,
|
||||
Type: "access",
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
IssuedAt: jwt.NewNumericDate(now),
|
||||
ExpiresAt: jwt.NewNumericDate(now.Add(time.Duration(tm.accessTTLMinutes) * time.Minute)),
|
||||
Subject: username,
|
||||
},
|
||||
}
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
return token.SignedString(tm.secret)
|
||||
}
|
||||
|
||||
func (tm *TokenManager) GenerateRefreshToken(userID uint, username, timezone string) (string, string, time.Time, error) {
|
||||
now := time.Now().UTC()
|
||||
expiresAt := now.Add(time.Duration(tm.refreshTTLHours) * time.Hour)
|
||||
jti, err := randomJTI()
|
||||
if err != nil {
|
||||
return "", "", time.Time{}, err
|
||||
}
|
||||
claims := Claims{
|
||||
UserID: userID,
|
||||
Username: username,
|
||||
Timezone: timezone,
|
||||
Type: "refresh",
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
IssuedAt: jwt.NewNumericDate(now),
|
||||
ExpiresAt: jwt.NewNumericDate(expiresAt),
|
||||
Subject: username,
|
||||
ID: jti,
|
||||
},
|
||||
}
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
tokenStr, err := token.SignedString(tm.secret)
|
||||
if err != nil {
|
||||
return "", "", time.Time{}, err
|
||||
}
|
||||
return tokenStr, jti, expiresAt, nil
|
||||
}
|
||||
|
||||
func (tm *TokenManager) ParseAndValidate(tokenStr string, expectedType string) (*Claims, error) {
|
||||
token, err := jwt.ParseWithClaims(tokenStr, &Claims{}, func(token *jwt.Token) (interface{}, error) {
|
||||
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, errors.New("unexpected signing method")
|
||||
}
|
||||
return tm.secret, nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
claims, ok := token.Claims.(*Claims)
|
||||
if !ok || !token.Valid {
|
||||
return nil, errors.New("invalid token")
|
||||
}
|
||||
if claims.Type != expectedType {
|
||||
return nil, errors.New("invalid token type")
|
||||
}
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
func randomJTI() (string, error) {
|
||||
b := make([]byte, 16)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(b), nil
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
HTTPAddr string
|
||||
DBPath string
|
||||
JWTSecret string
|
||||
AccessTTLMinutes int
|
||||
RefreshTTLHours int
|
||||
DefaultUsername string
|
||||
DefaultPassword string
|
||||
DefaultTimezone string
|
||||
AppEnv string
|
||||
}
|
||||
|
||||
func Load() Config {
|
||||
addr := getenv("HTTP_ADDR", ":9530")
|
||||
dbPath := getenv("DB_PATH", "./data/asset-tracker.db")
|
||||
jwtSecret := getenv("JWT_SECRET", "change_me_in_production")
|
||||
defaultUsername := getenv("DEFAULT_USERNAME", "admin")
|
||||
defaultPassword := getenv("DEFAULT_PASSWORD", "admin123")
|
||||
defaultTimezone := getenv("DEFAULT_TIMEZONE", "Asia/Shanghai")
|
||||
appEnv := strings.ToLower(strings.TrimSpace(getenv("APP_ENV", "dev")))
|
||||
|
||||
accessTTL := getenvInt("ACCESS_TTL_MINUTES", 30)
|
||||
if accessTTL < 5 {
|
||||
accessTTL = 5
|
||||
}
|
||||
refreshTTL := getenvInt("REFRESH_TTL_HOURS", 168)
|
||||
if refreshTTL < 1 {
|
||||
refreshTTL = 1
|
||||
}
|
||||
|
||||
return Config{
|
||||
HTTPAddr: addr,
|
||||
DBPath: dbPath,
|
||||
JWTSecret: jwtSecret,
|
||||
AccessTTLMinutes: accessTTL,
|
||||
RefreshTTLHours: refreshTTL,
|
||||
DefaultUsername: defaultUsername,
|
||||
DefaultPassword: defaultPassword,
|
||||
DefaultTimezone: defaultTimezone,
|
||||
AppEnv: appEnv,
|
||||
}
|
||||
}
|
||||
|
||||
func getenv(key, fallback string) string {
|
||||
v := strings.TrimSpace(os.Getenv(key))
|
||||
if v == "" {
|
||||
return fallback
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func getenvInt(key string, fallback int) int {
|
||||
v := strings.TrimSpace(os.Getenv(key))
|
||||
if v == "" {
|
||||
return fallback
|
||||
}
|
||||
n, err := strconv.Atoi(v)
|
||||
if err != nil {
|
||||
return fallback
|
||||
}
|
||||
return n
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package metrics
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/prometheus/client_golang/prometheus/promauto"
|
||||
)
|
||||
|
||||
var (
|
||||
HTTPRequestsTotal = promauto.NewCounterVec(
|
||||
prometheus.CounterOpts{Name: "http_requests_total", Help: "Total HTTP requests."},
|
||||
[]string{"method", "path", "status"},
|
||||
)
|
||||
HTTPRequestDuration = promauto.NewHistogramVec(
|
||||
prometheus.HistogramOpts{Name: "http_request_duration_seconds", Help: "HTTP request latency.", Buckets: prometheus.DefBuckets},
|
||||
[]string{"method", "path", "status"},
|
||||
)
|
||||
|
||||
ReminderSendTotal = promauto.NewCounterVec(
|
||||
prometheus.CounterOpts{Name: "reminder_send_total", Help: "Reminder delivery results."},
|
||||
[]string{"status"},
|
||||
)
|
||||
ReminderRetryTotal = promauto.NewCounter(
|
||||
prometheus.CounterOpts{Name: "reminder_retry_total", Help: "Reminder retry count."},
|
||||
)
|
||||
|
||||
DBQueryDuration = promauto.NewHistogramVec(
|
||||
prometheus.HistogramOpts{Name: "db_query_duration_seconds", Help: "DB query duration.", Buckets: prometheus.DefBuckets},
|
||||
[]string{"op", "table", "success"},
|
||||
)
|
||||
)
|
||||
|
||||
func init() {
|
||||
ReminderSendTotal.WithLabelValues("sent").Add(0)
|
||||
ReminderSendTotal.WithLabelValues("failed").Add(0)
|
||||
ReminderRetryTotal.Add(0)
|
||||
DBQueryDuration.WithLabelValues("scan_pending", "reminders", "true").Observe(0)
|
||||
}
|
||||
|
||||
func ObserveHTTP(c *gin.Context, start time.Time) {
|
||||
path := c.FullPath()
|
||||
if path == "" {
|
||||
path = c.Request.URL.Path
|
||||
}
|
||||
status := strconv.Itoa(c.Writer.Status())
|
||||
labels := []string{c.Request.Method, path, status}
|
||||
HTTPRequestsTotal.WithLabelValues(labels...).Inc()
|
||||
HTTPRequestDuration.WithLabelValues(labels...).Observe(time.Since(start).Seconds())
|
||||
}
|
||||
|
||||
func ObserveDB(op, table string, success bool, dur time.Duration) {
|
||||
if table == "" {
|
||||
table = "unknown"
|
||||
}
|
||||
s := "false"
|
||||
if success {
|
||||
s = "true"
|
||||
}
|
||||
DBQueryDuration.WithLabelValues(op, table, s).Observe(dur.Seconds())
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
type Asset struct {
|
||||
ID uint `json:"id" gorm:"primaryKey;index:idx_assets_user_status_id,priority:3"`
|
||||
UserID uint `json:"user_id" gorm:"not null;index:idx_assets_user_status_category,priority:1;index:idx_assets_user_status_id,priority:1"`
|
||||
Name string `json:"name" gorm:"size:128;not null"`
|
||||
CategoryID uint `json:"category_id" gorm:"not null;index:idx_assets_user_status_category,priority:3"`
|
||||
Category Category `json:"-"`
|
||||
Quantity float64 `json:"quantity" gorm:"not null"`
|
||||
UnitPrice float64 `json:"unit_price" gorm:"not null"`
|
||||
TotalValue float64 `json:"total_value" gorm:"not null;index"`
|
||||
Currency string `json:"currency" gorm:"size:16;not null"`
|
||||
ExpiryDate *time.Time `json:"expiry_date,omitempty" gorm:"index"`
|
||||
Note string `json:"note" gorm:"type:text"`
|
||||
Status string `json:"status" gorm:"size:16;not null;default:active;check:status IN ('active','inactive');index:idx_assets_user_status_category,priority:2;index:idx_assets_user_status_id,priority:2"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
type AuditLog struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
UserID uint `json:"user_id" gorm:"not null;index"`
|
||||
EntityType string `json:"entity_type" gorm:"size:32;not null;index"`
|
||||
EntityID uint `json:"entity_id" gorm:"not null;index"`
|
||||
Action string `json:"action" gorm:"size:16;not null;index"`
|
||||
BeforeJSON string `json:"before_json" gorm:"type:text"`
|
||||
AfterJSON string `json:"after_json" gorm:"type:text"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
type Category struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
UserID uint `json:"user_id" gorm:"not null;uniqueIndex:uidx_categories_user_name,priority:1;index"`
|
||||
Name string `json:"name" gorm:"size:128;not null;uniqueIndex:uidx_categories_user_name,priority:2"`
|
||||
Type string `json:"type" gorm:"size:16;not null"`
|
||||
Color string `json:"color" gorm:"size:32"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
type RefreshSession struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
UserID uint `json:"user_id" gorm:"not null;index"`
|
||||
JTI string `json:"jti" gorm:"size:64;not null;uniqueIndex"`
|
||||
ExpiresAt time.Time `json:"expires_at" gorm:"not null;index"`
|
||||
RevokedAt *time.Time `json:"revoked_at,omitempty" gorm:"index"`
|
||||
ReplacedBy string `json:"replaced_by" gorm:"size:64"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
type Reminder struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
UserID uint `json:"user_id" gorm:"not null;index;uniqueIndex:uq_reminder_identity,priority:1"`
|
||||
AssetID uint `json:"asset_id" gorm:"not null;index;uniqueIndex:uq_reminder_identity,priority:2"`
|
||||
RemindAt time.Time `json:"remind_at" gorm:"not null;index:idx_reminders_status_remind_at,priority:2;uniqueIndex:uq_reminder_identity,priority:3"`
|
||||
Channel string `json:"channel" gorm:"size:32;not null;default:in_app;uniqueIndex:uq_reminder_identity,priority:4"`
|
||||
Status string `json:"status" gorm:"size:16;not null;default:pending;check:status IN ('pending','sending','sent','failed');index:idx_reminders_status_remind_at,priority:1;index:idx_reminders_next_retry_status,priority:2;index:idx_reminders_status_next_retry,priority:1"`
|
||||
DedupeKey string `json:"dedupe_key" gorm:"size:128;not null;uniqueIndex"`
|
||||
RetryCount int `json:"retry_count" gorm:"not null;default:0"`
|
||||
NextRetryAt *time.Time `json:"next_retry_at,omitempty" gorm:"index:idx_reminders_next_retry_status,priority:1;index:idx_reminders_status_next_retry,priority:2"`
|
||||
LastError string `json:"last_error" gorm:"size:500"`
|
||||
SentAt *time.Time `json:"sent_at,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
type ReminderDeadLetter struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
ReminderID uint `json:"reminder_id" gorm:"not null;uniqueIndex"`
|
||||
UserID uint `json:"user_id" gorm:"not null;index"`
|
||||
AssetID uint `json:"asset_id" gorm:"not null;index"`
|
||||
RemindAt time.Time `json:"remind_at" gorm:"not null;index"`
|
||||
Channel string `json:"channel" gorm:"size:32;not null"`
|
||||
Status string `json:"status" gorm:"size:16;not null"`
|
||||
RetryCount int `json:"retry_count" gorm:"not null"`
|
||||
LastError string `json:"last_error" gorm:"size:500"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
type User struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
Username string `json:"username" gorm:"size:64;uniqueIndex;not null"`
|
||||
PasswordHash string `json:"-" gorm:"size:255;not null"`
|
||||
Timezone string `json:"timezone" gorm:"size:64;not null;default:UTC"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
package scheduler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"asset-tracker/internal/metrics"
|
||||
"asset-tracker/internal/model"
|
||||
|
||||
"github.com/robfig/cron/v3"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func StartReminderScan(db *gorm.DB) *cron.Cron {
|
||||
c := cron.New(cron.WithSeconds())
|
||||
|
||||
_, err := c.AddFunc("0 */5 * * * *", func() {
|
||||
runReminderScan(db)
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("[scheduler] add reminder scan job failed: %v", err)
|
||||
return c
|
||||
}
|
||||
|
||||
_, err = c.AddFunc("0 10 2 * * *", func() {
|
||||
runCompensationScan(db)
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("[scheduler] add compensation job failed: %v", err)
|
||||
return c
|
||||
}
|
||||
|
||||
c.Start()
|
||||
log.Println("[scheduler] reminder scan started: every 5 minutes, compensation daily 02:10")
|
||||
return c
|
||||
}
|
||||
|
||||
func runReminderScan(db *gorm.DB) {
|
||||
now := time.Now().UTC()
|
||||
windowEnd := now.Add(5 * time.Minute)
|
||||
|
||||
var pending []model.Reminder
|
||||
qStart := time.Now()
|
||||
err := db.Where("status = ? AND remind_at <= ?", "pending", windowEnd).Order("status asc, remind_at asc").Limit(200).Find(&pending).Error
|
||||
metrics.ObserveDB("scan_pending", "reminders", err == nil, time.Since(qStart))
|
||||
if err != nil {
|
||||
log.Printf("[scheduler] pending reminder query error: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
for _, r := range pending {
|
||||
processReminder(db, r, now)
|
||||
}
|
||||
|
||||
var failed []model.Reminder
|
||||
qStart = time.Now()
|
||||
err = db.Where("status = ? AND next_retry_at IS NOT NULL AND next_retry_at <= ?", "failed", now).Order("status asc, next_retry_at asc").Limit(200).Find(&failed).Error
|
||||
metrics.ObserveDB("scan_failed", "reminders", err == nil, time.Since(qStart))
|
||||
if err != nil {
|
||||
log.Printf("[scheduler] failed reminder query error: %v", err)
|
||||
return
|
||||
}
|
||||
for _, r := range failed {
|
||||
processReminder(db, r, now)
|
||||
}
|
||||
}
|
||||
|
||||
func processReminder(db *gorm.DB, r model.Reminder, now time.Time) {
|
||||
claim := db.Model(&model.Reminder{}).Where("id = ? AND status IN ?", r.ID, []string{"pending", "failed"}).Updates(map[string]interface{}{
|
||||
"status": "sending",
|
||||
"last_error": "",
|
||||
"next_retry_at": nil,
|
||||
})
|
||||
if claim.Error != nil {
|
||||
log.Printf("[scheduler] claim reminder id=%d failed: %v", r.ID, claim.Error)
|
||||
return
|
||||
}
|
||||
if claim.RowsAffected == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
if err := deliverReminder(r); err != nil {
|
||||
metrics.ReminderSendTotal.WithLabelValues("failed").Inc()
|
||||
retryCount := r.RetryCount + 1
|
||||
if retryCount >= maxRetryCount() {
|
||||
_ = db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Model(&model.Reminder{}).Where("id = ?", r.ID).Updates(map[string]interface{}{
|
||||
"status": "failed",
|
||||
"retry_count": retryCount,
|
||||
"last_error": "retry limit reached: " + err.Error(),
|
||||
"next_retry_at": nil,
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
dl := model.ReminderDeadLetter{
|
||||
ReminderID: r.ID,
|
||||
UserID: r.UserID,
|
||||
AssetID: r.AssetID,
|
||||
RemindAt: r.RemindAt,
|
||||
Channel: r.Channel,
|
||||
Status: "failed",
|
||||
RetryCount: retryCount,
|
||||
LastError: "retry limit reached: " + err.Error(),
|
||||
}
|
||||
return tx.Where("reminder_id = ?", r.ID).FirstOrCreate(&dl).Error
|
||||
})
|
||||
return
|
||||
}
|
||||
metrics.ReminderRetryTotal.Inc()
|
||||
next := now.Add(retryDelay(retryCount))
|
||||
_ = db.Model(&model.Reminder{}).Where("id = ?", r.ID).Updates(map[string]interface{}{
|
||||
"status": "failed",
|
||||
"retry_count": retryCount,
|
||||
"next_retry_at": &next,
|
||||
"last_error": err.Error(),
|
||||
}).Error
|
||||
return
|
||||
}
|
||||
|
||||
metrics.ReminderSendTotal.WithLabelValues("sent").Inc()
|
||||
sentAt := now
|
||||
_ = db.Model(&model.Reminder{}).Where("id = ?", r.ID).Updates(map[string]interface{}{
|
||||
"status": "sent",
|
||||
"sent_at": &sentAt,
|
||||
"last_error": "",
|
||||
"next_retry_at": nil,
|
||||
}).Error
|
||||
}
|
||||
|
||||
func runCompensationScan(db *gorm.DB) {
|
||||
now := time.Now().UTC()
|
||||
cutoff := now.Add(-10 * time.Minute)
|
||||
var missed []model.Reminder
|
||||
qStart := time.Now()
|
||||
err := db.Where("status = ? AND remind_at <= ?", "pending", cutoff).Order("remind_at asc").Limit(500).Find(&missed).Error
|
||||
metrics.ObserveDB("compensation_scan", "reminders", err == nil, time.Since(qStart))
|
||||
if err != nil {
|
||||
log.Printf("[scheduler] compensation query error: %v", err)
|
||||
return
|
||||
}
|
||||
if len(missed) > 0 {
|
||||
log.Printf("[scheduler] compensation scan found %d pending overdue reminders", len(missed))
|
||||
}
|
||||
for _, r := range missed {
|
||||
processReminder(db, r, now)
|
||||
}
|
||||
}
|
||||
|
||||
func deliverReminder(r model.Reminder) error {
|
||||
if r.Channel != "in_app" {
|
||||
return errors.New("unsupported channel")
|
||||
}
|
||||
log.Printf("[reminder] user=%d asset=%d channel=%s remind_at=%s dedupe=%s", r.UserID, r.AssetID, r.Channel, r.RemindAt.Format(time.RFC3339), r.DedupeKey)
|
||||
return nil
|
||||
}
|
||||
|
||||
func retryDelay(retryCount int) time.Duration {
|
||||
switch retryCount {
|
||||
case 1:
|
||||
return 5 * time.Minute
|
||||
case 2:
|
||||
return 30 * time.Minute
|
||||
default:
|
||||
return 2 * time.Hour
|
||||
}
|
||||
}
|
||||
|
||||
func maxRetryCount() int {
|
||||
return 8
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
-- SQLite init (reference)
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT NOT NULL,
|
||||
timezone TEXT NOT NULL DEFAULT 'UTC',
|
||||
created_at DATETIME,
|
||||
updated_at DATETIME
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS categories (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
color TEXT,
|
||||
created_at DATETIME,
|
||||
updated_at DATETIME,
|
||||
FOREIGN KEY(user_id) REFERENCES users(id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS assets (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
category_id INTEGER NOT NULL,
|
||||
quantity REAL NOT NULL,
|
||||
unit_price REAL NOT NULL,
|
||||
total_value REAL NOT NULL,
|
||||
currency TEXT NOT NULL,
|
||||
expiry_date DATETIME,
|
||||
note TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
created_at DATETIME,
|
||||
updated_at DATETIME,
|
||||
FOREIGN KEY(user_id) REFERENCES users(id),
|
||||
FOREIGN KEY(category_id) REFERENCES categories(id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS reminders (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
asset_id INTEGER NOT NULL,
|
||||
remind_at DATETIME NOT NULL,
|
||||
channel TEXT NOT NULL DEFAULT 'in_app',
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
dedupe_key TEXT NOT NULL UNIQUE,
|
||||
retry_count INTEGER NOT NULL DEFAULT 0,
|
||||
next_retry_at DATETIME,
|
||||
last_error TEXT,
|
||||
sent_at DATETIME,
|
||||
created_at DATETIME,
|
||||
updated_at DATETIME,
|
||||
FOREIGN KEY(user_id) REFERENCES users(id),
|
||||
FOREIGN KEY(asset_id) REFERENCES assets(id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS audit_logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
entity_type TEXT NOT NULL,
|
||||
entity_id INTEGER NOT NULL,
|
||||
action TEXT NOT NULL,
|
||||
before_json TEXT,
|
||||
after_json TEXT,
|
||||
created_at DATETIME,
|
||||
FOREIGN KEY(user_id) REFERENCES users(id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_categories_user_id ON categories(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_assets_user_status_category ON assets(user_id, status, category_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_assets_expiry_date ON assets(expiry_date);
|
||||
CREATE INDEX IF NOT EXISTS idx_reminders_status_remind_at ON reminders(status, remind_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_reminders_next_retry_status ON reminders(next_retry_at, status);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_user_entity_action ON audit_logs(user_id, entity_type, action);
|
||||
+231
@@ -0,0 +1,231 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: Asset Tracker API
|
||||
version: 0.2.0
|
||||
servers:
|
||||
- url: http://127.0.0.1:9530
|
||||
paths:
|
||||
/api/v1/auth/login:
|
||||
post:
|
||||
summary: Login
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [username, password]
|
||||
properties:
|
||||
username: { type: string }
|
||||
password: { type: string }
|
||||
responses:
|
||||
'200':
|
||||
description: OK
|
||||
headers:
|
||||
X-Request-Id:
|
||||
schema: { type: string }
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
access_token: { type: string }
|
||||
token_type: { type: string }
|
||||
'401':
|
||||
description: Unauthorized
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorBody'
|
||||
|
||||
/api/v1/auth/refresh:
|
||||
post:
|
||||
summary: Refresh access token
|
||||
description: Prefer refresh_token from HttpOnly cookie. Body/header is backward-compatible.
|
||||
requestBody:
|
||||
required: false
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
refresh_token: { type: string }
|
||||
responses:
|
||||
'200':
|
||||
description: OK
|
||||
headers:
|
||||
X-Request-Id:
|
||||
schema: { type: string }
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
access_token: { type: string }
|
||||
token_type: { type: string }
|
||||
'401':
|
||||
description: Unauthorized
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorBody'
|
||||
|
||||
/api/v1/categories:
|
||||
get:
|
||||
summary: List categories
|
||||
security:
|
||||
- bearerAuth: []
|
||||
responses:
|
||||
'200': { description: OK }
|
||||
post:
|
||||
summary: Create category
|
||||
security:
|
||||
- bearerAuth: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [name, type]
|
||||
properties:
|
||||
name: { type: string }
|
||||
type: { type: string, enum: [real, digital] }
|
||||
color: { type: string }
|
||||
responses:
|
||||
'201': { description: Created }
|
||||
|
||||
/api/v1/assets:
|
||||
get:
|
||||
summary: List assets
|
||||
security:
|
||||
- bearerAuth: []
|
||||
parameters:
|
||||
- in: query
|
||||
name: category_id
|
||||
schema: { type: integer }
|
||||
- in: query
|
||||
name: status
|
||||
schema: { type: string, enum: [active, inactive] }
|
||||
- in: query
|
||||
name: page
|
||||
schema: { type: integer, default: 1 }
|
||||
- in: query
|
||||
name: page_size
|
||||
schema: { type: integer, default: 20, maximum: 100 }
|
||||
responses:
|
||||
'200': { description: OK }
|
||||
'400':
|
||||
description: Bad Request
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorBody'
|
||||
post:
|
||||
summary: Create asset
|
||||
security:
|
||||
- bearerAuth: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [name, category_id, quantity, unit_price, currency]
|
||||
properties:
|
||||
name: { type: string }
|
||||
category_id: { type: integer }
|
||||
quantity: { type: number }
|
||||
unit_price: { type: number }
|
||||
currency: { type: string, example: USD }
|
||||
expiry_date: { type: string, format: date-time }
|
||||
note: { type: string }
|
||||
status: { type: string, enum: [active, inactive] }
|
||||
responses:
|
||||
'201': { description: Created }
|
||||
|
||||
/api/v1/assets/{id}:
|
||||
put:
|
||||
summary: Update asset
|
||||
security:
|
||||
- bearerAuth: []
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
required: true
|
||||
schema: { type: integer }
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
name: { type: string }
|
||||
category_id: { type: integer }
|
||||
quantity: { type: number }
|
||||
unit_price: { type: number }
|
||||
currency: { type: string }
|
||||
expiry_date: { type: string, format: date-time }
|
||||
note: { type: string }
|
||||
status: { type: string, enum: [active, inactive] }
|
||||
responses:
|
||||
'200': { description: OK }
|
||||
delete:
|
||||
summary: Delete asset
|
||||
security:
|
||||
- bearerAuth: []
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
required: true
|
||||
schema: { type: integer }
|
||||
responses:
|
||||
'200': { description: OK }
|
||||
|
||||
/api/v1/dashboard/summary:
|
||||
get:
|
||||
summary: Dashboard summary
|
||||
security:
|
||||
- bearerAuth: []
|
||||
responses:
|
||||
'200': { description: OK }
|
||||
|
||||
/api/v1/reminders:
|
||||
get:
|
||||
summary: List reminders
|
||||
security:
|
||||
- bearerAuth: []
|
||||
parameters:
|
||||
- in: query
|
||||
name: status
|
||||
schema: { type: string, enum: [pending, sending, sent, failed] }
|
||||
- in: query
|
||||
name: page
|
||||
schema: { type: integer, default: 1 }
|
||||
- in: query
|
||||
name: page_size
|
||||
schema: { type: integer, default: 20, maximum: 100 }
|
||||
responses:
|
||||
'200': { description: OK }
|
||||
'400':
|
||||
description: Bad Request
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorBody'
|
||||
|
||||
components:
|
||||
schemas:
|
||||
ErrorBody:
|
||||
type: object
|
||||
properties:
|
||||
code: { type: string }
|
||||
message: { type: string }
|
||||
details: {}
|
||||
request_id: { type: string }
|
||||
securitySchemes:
|
||||
bearerAuth:
|
||||
type: http
|
||||
scheme: bearer
|
||||
bearerFormat: JWT
|
||||
Executable
+18
@@ -0,0 +1,18 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
DB_PATH=${DB_PATH:-/root/.openclaw/workspace/asset-tracker/data/asset-tracker.db}
|
||||
BACKUP_DIR=${BACKUP_DIR:-/root/.openclaw/workspace/asset-tracker/backups}
|
||||
TS=$(date +%Y%m%d_%H%M%S)
|
||||
|
||||
mkdir -p "$BACKUP_DIR"
|
||||
if [ ! -f "$DB_PATH" ]; then
|
||||
echo "db not found: $DB_PATH" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
OUT="$BACKUP_DIR/asset-tracker-$TS.db"
|
||||
cp "$DB_PATH" "$OUT"
|
||||
gzip -f "$OUT"
|
||||
|
||||
echo "backup created: $OUT.gz"
|
||||
Executable
+23
@@ -0,0 +1,23 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
if [ $# -lt 1 ]; then
|
||||
echo "usage: $0 <backup.db.gz|backup.db> [target_db_path]" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
SRC=$1
|
||||
TARGET=${2:-/root/.openclaw/workspace/asset-tracker/data/asset-tracker.db}
|
||||
TMP=$(mktemp -d)
|
||||
trap 'rm -rf "$TMP"' EXIT
|
||||
|
||||
mkdir -p "$(dirname "$TARGET")"
|
||||
|
||||
if [[ "$SRC" == *.gz ]]; then
|
||||
gunzip -c "$SRC" > "$TMP/restore.db"
|
||||
else
|
||||
cp "$SRC" "$TMP/restore.db"
|
||||
fi
|
||||
|
||||
cp "$TMP/restore.db" "$TARGET"
|
||||
echo "restored to: $TARGET"
|
||||
@@ -0,0 +1 @@
|
||||
import{d as v,i as $,z as x,u as w,r as u,o as i,l as d,w as e,a as s,m as r,f as n}from"./index-CnyV6Gd0.js";const A=v({__name:"AppNav",setup(b){const f=$(),l=x(),m=w();function p(y){f.push(y)}function k(){m.logout(),f.push("/login")}return(y,t)=>{const o=u("el-button"),C=u("el-space"),g=u("el-card");return i(),d(g,{class:"card",shadow:"never",style:{"margin-bottom":"12px"}},{default:e(()=>[s(C,{wrap:""},{default:e(()=>[s(o,{type:r(l).path==="/"?"primary":"default",onClick:t[0]||(t[0]=a=>p("/"))},{default:e(()=>[...t[6]||(t[6]=[n("公开记录",-1)])]),_:1},8,["type"]),s(o,{type:r(l).path==="/app"?"primary":"default",onClick:t[1]||(t[1]=a=>p("/app"))},{default:e(()=>[...t[7]||(t[7]=[n("仪表盘",-1)])]),_:1},8,["type"]),s(o,{type:r(l).path==="/assets"?"primary":"default",onClick:t[2]||(t[2]=a=>p("/assets"))},{default:e(()=>[...t[8]||(t[8]=[n("资产",-1)])]),_:1},8,["type"]),s(o,{type:r(l).path==="/categories"?"primary":"default",onClick:t[3]||(t[3]=a=>p("/categories"))},{default:e(()=>[...t[9]||(t[9]=[n("分类",-1)])]),_:1},8,["type"]),s(o,{type:r(l).path==="/reminders"?"primary":"default",onClick:t[4]||(t[4]=a=>p("/reminders"))},{default:e(()=>[...t[10]||(t[10]=[n("提醒",-1)])]),_:1},8,["type"]),r(m).token?(i(),d(o,{key:0,type:"danger",plain:"",onClick:k},{default:e(()=>[...t[11]||(t[11]=[n("退出",-1)])]),_:1})):(i(),d(o,{key:1,onClick:t[5]||(t[5]=a=>p("/login"))},{default:e(()=>[...t[12]||(t[12]=[n("登录",-1)])]),_:1}))]),_:1})]),_:1})}}});export{A as _};
|
||||
Vendored
+1
File diff suppressed because one or more lines are too long
+1
@@ -0,0 +1 @@
|
||||
import{_ as k}from"./AppNav.vue_vue_type_script_setup_true_lang-RQbRtBk5.js";import{_ as E}from"./PageState.vue_vue_type_script_setup_true_lang-DEQuT0jG.js";import{l as M,c as B}from"./categories-Bz8nm2Da.js";import{g as v}from"./errors-E9D9vVes.js";import{d as N,j as U,c as $,a as e,w as t,r as a,h as p,f as b,g as j,E as _,o as D}from"./index-CnyV6Gd0.js";const I={class:"page"},H=N({__name:"Categories",setup(T){const c=p([]),l=j({name:"",type:"digital"}),r=p(!1),s=p("");async function d(){r.value=!0,s.value="";try{const n=await M();c.value=n.data||[]}catch(n){s.value=v(n,"分类数据加载失败")}finally{r.value=!1}}async function w(){if(!l.name.trim())return _.error("分类名必填");try{await B({name:l.name.trim(),type:l.type}),l.name="",await d(),_.success("新增成功")}catch(n){_.error(v(n,"新增失败"))}}return U(d),(n,o)=>{const x=a("el-input"),i=a("el-col"),f=a("el-option"),C=a("el-select"),y=a("el-button"),V=a("el-row"),g=a("el-card"),m=a("el-table-column"),h=a("el-table");return D(),$("div",I,[e(k),e(g,{class:"card"},{default:t(()=>[e(V,{gutter:12},{default:t(()=>[e(i,{xs:24,sm:10},{default:t(()=>[e(x,{modelValue:l.name,"onUpdate:modelValue":o[0]||(o[0]=u=>l.name=u),placeholder:"分类名称"},null,8,["modelValue"])]),_:1}),e(i,{xs:24,sm:8},{default:t(()=>[e(C,{modelValue:l.type,"onUpdate:modelValue":o[1]||(o[1]=u=>l.type=u),style:{width:"100%"}},{default:t(()=>[e(f,{label:"digital",value:"digital"}),e(f,{label:"real",value:"real"})]),_:1},8,["modelValue"])]),_:1}),e(i,{xs:24,sm:6},{default:t(()=>[e(y,{type:"primary",onClick:w},{default:t(()=>[...o[2]||(o[2]=[b("新增分类",-1)])]),_:1})]),_:1})]),_:1})]),_:1}),e(g,{class:"card"},{default:t(()=>[e(E,{loading:r.value,error:s.value,empty:!r.value&&!s.value&&c.value.length===0,"empty-text":"当前没有分类记录"},{retry:t(()=>[e(y,{onClick:d},{default:t(()=>[...o[3]||(o[3]=[b("重试",-1)])]),_:1})]),default:t(()=>[e(h,{data:c.value},{default:t(()=>[e(m,{prop:"id",label:"ID",width:"80"}),e(m,{prop:"name",label:"分类名","min-width":"140"}),e(m,{prop:"type",label:"类型",width:"120"}),e(m,{prop:"created_at",label:"创建时间","min-width":"180"})]),_:1},8,["data"])]),_:1},8,["loading","error","empty"])]),_:1})])}}});export{H as default};
|
||||
+39
File diff suppressed because one or more lines are too long
Vendored
+1
@@ -0,0 +1 @@
|
||||
import{d as g,u as V,c as b,a as o,w as a,r as s,b as x,e as y,f as v,g as h,h as C,E as p,i as E,o as k}from"./index-CnyV6Gd0.js";import{g as B}from"./errors-E9D9vVes.js";const M={class:"page",style:{"max-width":"420px","margin-top":"80px"}},A=g({__name:"Login",setup(N){const t=h({username:"",password:""}),i=V(),c=E(),l=C(!1);async function u(){l.value=!0;try{await i.login(t.username,t.password),p.success("登录成功"),c.push("/app")}catch(d){p.error(B(d,"登录失败,请检查账号密码"))}finally{l.value=!1}}return(d,e)=>{const m=s("el-input"),n=s("el-form-item"),_=s("el-button"),f=s("el-form"),w=s("el-card");return k(),b("div",M,[o(w,{class:"card"},{default:a(()=>[e[3]||(e[3]=x("h2",null,"登录",-1)),o(f,{onSubmit:y(u,["prevent"]),"label-width":"80px"},{default:a(()=>[o(n,{label:"用户名"},{default:a(()=>[o(m,{modelValue:t.username,"onUpdate:modelValue":e[0]||(e[0]=r=>t.username=r)},null,8,["modelValue"])]),_:1}),o(n,{label:"密码"},{default:a(()=>[o(m,{modelValue:t.password,"onUpdate:modelValue":e[1]||(e[1]=r=>t.password=r),"show-password":"",type:"password"},null,8,["modelValue"])]),_:1}),o(n,null,{default:a(()=>[o(_,{type:"primary",loading:l.value,onClick:u,style:{width:"100%"}},{default:a(()=>[...e[2]||(e[2]=[v("登录",-1)])]),_:1},8,["loading"])]),_:1})]),_:1})]),_:1})])}}});export{A as default};
|
||||
@@ -0,0 +1 @@
|
||||
import{d,c as i,a as m,l as n,w as p,y as a,r as t,o}from"./index-CnyV6Gd0.js";const y={key:0,style:{padding:"12px"}},k=d({__name:"PageState",props:{loading:{type:Boolean,default:!1},error:{default:""},empty:{type:Boolean,default:!1},emptyText:{default:"暂无数据"}},setup(e){return(l,u)=>{const r=t("el-skeleton"),s=t("el-result"),c=t("el-empty");return e.loading?(o(),i("div",y,[m(r,{rows:5,animated:""})])):e.error?(o(),n(s,{key:1,icon:"error",title:"加载失败","sub-title":e.error},{extra:p(()=>[a(l.$slots,"retry")]),_:3},8,["sub-title"])):e.empty?(o(),n(c,{key:2,description:e.emptyText},null,8,["description"])):a(l.$slots,"default",{key:3})}}});export{k as _};
|
||||
+1
@@ -0,0 +1 @@
|
||||
import{_ as x}from"./AppNav.vue_vue_type_script_setup_true_lang-RQbRtBk5.js";import{_ as k}from"./PageState.vue_vue_type_script_setup_true_lang-DEQuT0jG.js";import{d as C,j,c as B,a as e,w as t,r as l,h as u,o as N,f as p,t as w,b as y}from"./index-CnyV6Gd0.js";const V={class:"page"},D={style:{display:"flex","justify-content":"space-between","align-items":"center"}},P=C({__name:"PublicRecords",setup(E){const n=u(!1),s=u(""),c=u({}),r=u([]);async function i(){n.value=!0,s.value="";try{const _=await fetch("/public/records");if(!_.ok)throw new Error("请求失败");const a=await _.json();c.value=a.summary||{},r.value=a.records||[]}catch{s.value="公开记录加载失败"}finally{n.value=!1}}return j(i),(_,a)=>{const f=l("el-button"),d=l("el-statistic"),m=l("el-col"),b=l("el-row"),o=l("el-table-column"),h=l("el-table"),g=l("el-card");return N(),B("div",V,[e(x),e(g,{class:"card"},{header:t(()=>[y("div",D,[a[1]||(a[1]=y("span",null,"已记录内容(公开只读)",-1)),e(f,{onClick:i},{default:t(()=>[...a[0]||(a[0]=[p("刷新",-1)])]),_:1})])]),default:t(()=>[e(k,{loading:n.value,error:s.value,empty:!n.value&&!s.value&&r.value.length===0,"empty-text":"当前 0 条记录。请先到 /app 添加资产"},{retry:t(()=>[e(f,{onClick:i},{default:t(()=>[...a[2]||(a[2]=[p("重试",-1)])]),_:1})]),default:t(()=>[e(b,{gutter:12,style:{"margin-bottom":"12px"}},{default:t(()=>[e(m,{xs:24,sm:8},{default:t(()=>[e(d,{title:"活跃资产",value:c.value.active_asset_count||0},null,8,["value"])]),_:1}),e(m,{xs:24,sm:8},{default:t(()=>[e(d,{title:"总资产值",value:c.value.total_assets_value||0},null,8,["value"])]),_:1}),e(m,{xs:24,sm:8},{default:t(()=>[e(d,{title:"记录数",value:r.value.length},null,8,["value"])]),_:1})]),_:1}),e(h,{data:r.value,style:{width:"100%"}},{default:t(()=>[e(o,{prop:"id",label:"ID",width:"70"}),e(o,{prop:"name",label:"名称","min-width":"140"}),e(o,{prop:"category_name",label:"分类","min-width":"120"}),e(o,{label:"金额","min-width":"120"},{default:t(v=>[p(w(v.row.total_value)+" "+w(v.row.currency),1)]),_:1}),e(o,{prop:"status",label:"状态",width:"100"}),e(o,{prop:"expiry_date",label:"到期日","min-width":"180"})]),_:1},8,["data"])]),_:1},8,["loading","error","empty"])]),_:1})])}}});export{P as default};
|
||||
+1
@@ -0,0 +1 @@
|
||||
import{_ as N}from"./AppNav.vue_vue_type_script_setup_true_lang-RQbRtBk5.js";import{_ as j}from"./PageState.vue_vue_type_script_setup_true_lang-DEQuT0jG.js";import{x as D,d as E,j as M,c as y,a as e,w as a,r as t,h as n,l as R,f as w,t as S,b as T,o as g}from"./index-CnyV6Gd0.js";import{g as $}from"./errors-E9D9vVes.js";async function I(f){const{data:r}=await D.get("/reminders",{params:f});return r}const U={class:"page"},q={key:1},A={style:{"margin-top":"12px",display:"flex","justify-content":"flex-end"}},K=E({__name:"Reminders",setup(f){const r=n("pending"),m=n([]),v=n(0),p=n(1),b=n(20),u=n(!1),d=n("");function h(){p.value=1,_()}async function _(){u.value=!0,d.value="";try{const i=await I({status:r.value||void 0,page:p.value,page_size:b.value});m.value=i.data||[],v.value=i.total||0}catch(i){d.value=$(i,"提醒数据加载失败")}finally{u.value=!1}}return M(_),(i,o)=>{const c=t("el-tab-pane"),x=t("el-tabs"),k=t("el-button"),l=t("el-table-column"),C=t("el-tag"),V=t("el-table"),z=t("el-pagination"),B=t("el-card");return g(),y("div",U,[e(N),e(B,{class:"card"},{default:a(()=>[e(x,{modelValue:r.value,"onUpdate:modelValue":o[0]||(o[0]=s=>r.value=s),onTabChange:h},{default:a(()=>[e(c,{label:"待处理",name:"pending"}),e(c,{label:"发送中",name:"sending"}),e(c,{label:"已发送",name:"sent"}),e(c,{label:"失败",name:"failed"})]),_:1},8,["modelValue"]),e(j,{loading:u.value,error:d.value,empty:!u.value&&!d.value&&m.value.length===0,"empty-text":"当前没有提醒记录"},{retry:a(()=>[e(k,{onClick:_},{default:a(()=>[...o[2]||(o[2]=[w("重试",-1)])]),_:1})]),default:a(()=>[e(V,{data:m.value},{default:a(()=>[e(l,{prop:"id",label:"ID",width:"70"}),e(l,{prop:"asset_name",label:"资产","min-width":"140"}),e(l,{prop:"status",label:"状态",width:"100"}),e(l,{prop:"remind_at",label:"提醒时间","min-width":"180"}),e(l,{prop:"next_retry_at",label:"下次重试","min-width":"180"}),e(l,{prop:"retry_count",label:"重试",width:"80"}),e(l,{label:"错误信息","min-width":"240"},{default:a(s=>[s.row.last_error?(g(),R(C,{key:0,type:"danger",size:"small"},{default:a(()=>[w(S(s.row.last_error),1)]),_:2},1024)):(g(),y("span",q,"-"))]),_:1})]),_:1},8,["data"]),T("div",A,[e(z,{background:"",layout:"prev, pager, next, total",total:v.value,"page-size":b.value,"current-page":p.value,onCurrentChange:o[1]||(o[1]=s=>{p.value=s,_()})},null,8,["total","page-size","current-page"])])]),_:1},8,["loading","error","empty"])]),_:1})])}}});export{K as default};
|
||||
+1
@@ -0,0 +1 @@
|
||||
import{_ as p,c as d,a as t,w as o,b as n,f as i,r as s,o as u}from"./index-CnyV6Gd0.js";const c={},m={class:"page",style:{"max-width":"520px","margin-top":"80px"}};function _(r,e){const a=s("el-button"),l=s("el-card");return u(),d("div",m,[t(l,{class:"card"},{default:o(()=>[e[2]||(e[2]=n("h2",null,"会话已过期",-1)),e[3]||(e[3]=n("p",null,"登录状态失效,请重新登录后继续操作。",-1)),t(a,{type:"primary",onClick:e[0]||(e[0]=f=>r.$router.push("/login"))},{default:o(()=>[...e[1]||(e[1]=[i("去登录",-1)])]),_:1})]),_:1})])}const b=p(c,[["render",_]]);export{b as default};
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
import{x as s}from"./index-CnyV6Gd0.js";async function c(t){const{data:a}=await s.get("/assets",{params:t});return a}async function r(t){const{data:a}=await s.post("/assets",t);return a}async function o(t,a){const{data:e}=await s.put(`/assets/${t}`,a);return e}async function u(t){const{data:a}=await s.delete(`/assets/${t}`);return a}async function d(){const{data:t}=await s.get("/dashboard/summary");return t}export{u as a,r as c,d,c as l,o as u};
|
||||
+1
@@ -0,0 +1 @@
|
||||
import{x as a}from"./index-CnyV6Gd0.js";async function r(){const{data:t}=await a.get("/categories");return t}async function s(t){const{data:e}=await a.post("/categories",t);return e}export{s as c,r as l};
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
function a(e,o="请求失败,请稍后重试"){const t=e?.response?.status,n=e?.response?.data?.message;if(n&&typeof n=="string"){const i=e?.response?.data?.request_id;return i?`${n}(请求编号: ${i})`:n}let s=o;t===400?s="请求参数有误,请检查后重试":t===401?s="登录状态已失效,请重新登录":t===403?s="你没有权限执行该操作":t===404?s="请求资源不存在":t>=500&&(s="服务暂时不可用,请稍后重试");const r=e?.response?.data?.request_id||e?.response?.headers?.["x-request-id"];return r?`${s}(请求编号: ${r})`:s}export{a as g};
|
||||
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
+30
File diff suppressed because one or more lines are too long
Vendored
+14
@@ -0,0 +1,14 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>frontend</title>
|
||||
<script type="module" crossorigin src="/_assets/index-CnyV6Gd0.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/_assets/index-Bys16WK2.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
</body>
|
||||
</html>
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
@@ -0,0 +1,5 @@
|
||||
# Vue 3 + TypeScript + Vite
|
||||
|
||||
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
|
||||
|
||||
Learn more about the recommended Project Setup and IDE Support in the [Vue Docs TypeScript Guide](https://vuejs.org/guide/typescript/overview.html#project-setup).
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>frontend</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+2178
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "asset-tracker-frontend",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc -b && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.11.0",
|
||||
"echarts": "^5.6.0",
|
||||
"element-plus": "^2.11.4",
|
||||
"pinia": "^3.0.3",
|
||||
"vue": "^3.5.25",
|
||||
"vue-router": "^4.5.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.10.1",
|
||||
"@vitejs/plugin-vue": "^6.0.2",
|
||||
"@vue/tsconfig": "^0.8.1",
|
||||
"typescript": "~5.9.3",
|
||||
"vite": "^7.3.1",
|
||||
"vue-tsc": "^3.1.5"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,3 @@
|
||||
<template>
|
||||
<router-view />
|
||||
</template>
|
||||
@@ -0,0 +1,28 @@
|
||||
import client from './client'
|
||||
|
||||
export type AssetQuery = { page?: number; page_size?: number; status?: string; category_id?: number }
|
||||
|
||||
export async function listAssets(params: AssetQuery) {
|
||||
const { data } = await client.get('/assets', { params })
|
||||
return data
|
||||
}
|
||||
|
||||
export async function createAsset(payload: any) {
|
||||
const { data } = await client.post('/assets', payload)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function updateAsset(id: number, payload: any) {
|
||||
const { data } = await client.put(`/assets/${id}`, payload)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function deleteAsset(id: number) {
|
||||
const { data } = await client.delete(`/assets/${id}`)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function dashboardSummary() {
|
||||
const { data } = await client.get('/dashboard/summary')
|
||||
return data
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import client from './client'
|
||||
|
||||
export async function loginApi(username: string, password: string) {
|
||||
const { data } = await client.post('/auth/login', { username, password })
|
||||
return data
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import client from './client'
|
||||
|
||||
export async function listCategories() {
|
||||
const { data } = await client.get('/categories')
|
||||
return data
|
||||
}
|
||||
|
||||
export async function createCategory(payload: { name: string; type: 'real' | 'digital' }) {
|
||||
const { data } = await client.post('/categories', payload)
|
||||
return data
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import axios, { AxiosError, type InternalAxiosRequestConfig } from 'axios'
|
||||
import router from '../router'
|
||||
|
||||
type RetryableConfig = InternalAxiosRequestConfig & { _retry?: boolean }
|
||||
|
||||
let isRefreshing = false
|
||||
let pendingQueue: Array<(token: string | null) => void> = []
|
||||
|
||||
function processQueue(token: string | null) {
|
||||
pendingQueue.forEach((cb) => cb(token))
|
||||
pendingQueue = []
|
||||
}
|
||||
|
||||
const client = axios.create({
|
||||
baseURL: '/api/v1',
|
||||
timeout: 10000,
|
||||
withCredentials: true,
|
||||
})
|
||||
|
||||
client.interceptors.request.use((config) => {
|
||||
const token = localStorage.getItem('asset_tracker_token')
|
||||
if (token) {
|
||||
config.headers = config.headers || {}
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
return config
|
||||
})
|
||||
|
||||
async function refreshAccessToken(): Promise<string> {
|
||||
const resp = await axios.post('/api/v1/auth/refresh', {}, { withCredentials: true, timeout: 10000 })
|
||||
const token = resp?.data?.access_token || ''
|
||||
if (!token) throw new Error('refresh failed')
|
||||
localStorage.setItem('asset_tracker_token', token)
|
||||
return token
|
||||
}
|
||||
|
||||
function redirectSessionExpired() {
|
||||
localStorage.removeItem('asset_tracker_token')
|
||||
if (router.currentRoute.value.path !== '/session-expired') {
|
||||
router.push('/session-expired')
|
||||
}
|
||||
}
|
||||
|
||||
client.interceptors.response.use(
|
||||
(resp) => resp,
|
||||
async (err: AxiosError) => {
|
||||
const status = err?.response?.status
|
||||
const original = (err.config || {}) as RetryableConfig
|
||||
|
||||
if (status !== 401 || original._retry) {
|
||||
return Promise.reject(err)
|
||||
}
|
||||
|
||||
original._retry = true
|
||||
|
||||
if (isRefreshing) {
|
||||
return new Promise((resolve, reject) => {
|
||||
pendingQueue.push((token) => {
|
||||
if (!token) return reject(err)
|
||||
original.headers = original.headers || {}
|
||||
original.headers.Authorization = `Bearer ${token}`
|
||||
resolve(client(original))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
isRefreshing = true
|
||||
try {
|
||||
const token = await refreshAccessToken()
|
||||
processQueue(token)
|
||||
original.headers = original.headers || {}
|
||||
original.headers.Authorization = `Bearer ${token}`
|
||||
return client(original)
|
||||
} catch (e) {
|
||||
processQueue(null)
|
||||
redirectSessionExpired()
|
||||
return Promise.reject(e)
|
||||
} finally {
|
||||
isRefreshing = false
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
export default client
|
||||
@@ -0,0 +1,6 @@
|
||||
import client from './client'
|
||||
|
||||
export async function listReminders(params: { status?: string; page?: number; page_size?: number }) {
|
||||
const { data } = await client.get('/reminders', { params })
|
||||
return data
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 496 B |
@@ -0,0 +1,25 @@
|
||||
<script setup lang="ts">
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const auth = useAuthStore()
|
||||
|
||||
function go(path: string) { router.push(path) }
|
||||
function logout() { auth.logout(); router.push('/login') }
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-card class="card" shadow="never" style="margin-bottom: 12px;">
|
||||
<el-space wrap>
|
||||
<el-button :type="route.path==='/' ? 'primary':'default'" @click="go('/')">公开记录</el-button>
|
||||
<el-button :type="route.path==='/app' ? 'primary':'default'" @click="go('/app')">仪表盘</el-button>
|
||||
<el-button :type="route.path==='/assets' ? 'primary':'default'" @click="go('/assets')">资产</el-button>
|
||||
<el-button :type="route.path==='/categories' ? 'primary':'default'" @click="go('/categories')">分类</el-button>
|
||||
<el-button :type="route.path==='/reminders' ? 'primary':'default'" @click="go('/reminders')">提醒</el-button>
|
||||
<el-button v-if="auth.token" type="danger" plain @click="logout">退出</el-button>
|
||||
<el-button v-else @click="go('/login')">登录</el-button>
|
||||
</el-space>
|
||||
</el-card>
|
||||
</template>
|
||||
@@ -0,0 +1,43 @@
|
||||
<script setup lang="ts">
|
||||
import { reactive, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
const props = withDefaults(defineProps<{ modelValue: boolean; editing?: any; categories: any[] }>(), { editing: null })
|
||||
const emit = defineEmits(['update:modelValue', 'submit'])
|
||||
|
||||
const form = reactive<any>({ name: '', category_id: undefined, quantity: 1, unit_price: 0, currency: 'USD', expiry_date: '' })
|
||||
watch(() => props.editing, (v) => {
|
||||
if (v) Object.assign(form, { ...v, expiry_date: v.expiry_date ? v.expiry_date.slice(0,16) : '' })
|
||||
else Object.assign(form, { name: '', category_id: undefined, quantity: 1, unit_price: 0, currency: 'USD', expiry_date: '' })
|
||||
}, { immediate: true })
|
||||
|
||||
function save() {
|
||||
if (!form.name?.trim()) return ElMessage.error('资产名必填')
|
||||
if (Number(form.quantity) < 0 || Number(form.unit_price) < 0) return ElMessage.error('数量/单价必须 >= 0')
|
||||
emit('submit', {
|
||||
name: form.name.trim(),
|
||||
category_id: Number(form.category_id),
|
||||
quantity: Number(form.quantity),
|
||||
unit_price: Number(form.unit_price),
|
||||
currency: String(form.currency || '').toUpperCase(),
|
||||
expiry_date: form.expiry_date ? new Date(form.expiry_date).toISOString() : '',
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-dialog :model-value="modelValue" :title="editing ? '编辑资产' : '新增资产'" width="560px" @close="emit('update:modelValue', false)">
|
||||
<el-form label-width="90px">
|
||||
<el-form-item label="名称"><el-input v-model="form.name" /></el-form-item>
|
||||
<el-form-item label="分类"><el-select v-model="form.category_id" style="width:100%"><el-option v-for="c in categories" :key="c.id" :label="c.name" :value="c.id" /></el-select></el-form-item>
|
||||
<el-form-item label="数量"><el-input-number v-model="form.quantity" :min="0" style="width:100%" /></el-form-item>
|
||||
<el-form-item label="单价"><el-input-number v-model="form.unit_price" :min="0" style="width:100%" /></el-form-item>
|
||||
<el-form-item label="币种"><el-input v-model="form.currency" /></el-form-item>
|
||||
<el-form-item label="到期日"><el-date-picker v-model="form.expiry_date" type="datetime" value-format="YYYY-MM-DDTHH:mm" style="width:100%" /></el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="emit('update:modelValue', false)">取消</el-button>
|
||||
<el-button type="primary" @click="save">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
@@ -0,0 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{ expiryDate?: string }>()
|
||||
function text(v?: string) {
|
||||
if (!v) return '无到期日'
|
||||
const ms = new Date(v).getTime() - Date.now()
|
||||
const d = Math.ceil(ms / (24 * 3600 * 1000))
|
||||
return d >= 0 ? `剩余 ${d} 天` : `已过期 ${Math.abs(d)} 天`
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-tag size="small" :type="!expiryDate ? 'info' : 'warning'">{{ text(props.expiryDate) }}</el-tag>
|
||||
</template>
|
||||
@@ -0,0 +1,41 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
defineProps<{ msg: string }>()
|
||||
|
||||
const count = ref(0)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<h1>{{ msg }}</h1>
|
||||
|
||||
<div class="card">
|
||||
<button type="button" @click="count++">count is {{ count }}</button>
|
||||
<p>
|
||||
Edit
|
||||
<code>components/HelloWorld.vue</code> to test HMR
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p>
|
||||
Check out
|
||||
<a href="https://vuejs.org/guide/quick-start.html#local" target="_blank"
|
||||
>create-vue</a
|
||||
>, the official Vue + Vite starter
|
||||
</p>
|
||||
<p>
|
||||
Learn more about IDE Support for Vue in the
|
||||
<a
|
||||
href="https://vuejs.org/guide/scaling-up/tooling.html#ide-support"
|
||||
target="_blank"
|
||||
>Vue Docs Scaling up Guide</a
|
||||
>.
|
||||
</p>
|
||||
<p class="read-the-docs">Click on the Vite and Vue logos to learn more</p>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.read-the-docs {
|
||||
color: #888;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
withDefaults(defineProps<{ loading?: boolean; error?: string; empty?: boolean; emptyText?: string }>(), {
|
||||
loading: false,
|
||||
error: '',
|
||||
empty: false,
|
||||
emptyText: '暂无数据',
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="loading" style="padding: 12px;"><el-skeleton :rows="5" animated /></div>
|
||||
<el-result v-else-if="error" icon="error" title="加载失败" :sub-title="error">
|
||||
<template #extra><slot name="retry" /></template>
|
||||
</el-result>
|
||||
<el-empty v-else-if="empty" :description="emptyText" />
|
||||
<slot v-else />
|
||||
</template>
|
||||
@@ -0,0 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{ total: number; assetsCount: number; expiringCount: number }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-row :gutter="12">
|
||||
<el-col :xs="24" :sm="8"><el-card><div>总资产</div><h2>{{ total }}</h2></el-card></el-col>
|
||||
<el-col :xs="24" :sm="8"><el-card><div>资产数量</div><h2>{{ assetsCount }}</h2></el-card></el-col>
|
||||
<el-col :xs="24" :sm="8"><el-card><div>30天到期</div><h2>{{ expiringCount }}</h2></el-card></el-col>
|
||||
</el-row>
|
||||
</template>
|
||||
@@ -0,0 +1,13 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import ElementPlus from 'element-plus'
|
||||
import 'element-plus/dist/index.css'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import './style.css'
|
||||
|
||||
const app = createApp(App)
|
||||
app.use(createPinia())
|
||||
app.use(router)
|
||||
app.use(ElementPlus)
|
||||
app.mount('#app')
|
||||
@@ -0,0 +1,31 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
const Login = () => import('../views/Login.vue')
|
||||
const PublicRecords = () => import('../views/PublicRecords.vue')
|
||||
const Dashboard = () => import('../views/Dashboard.vue')
|
||||
const Assets = () => import('../views/Assets.vue')
|
||||
const Categories = () => import('../views/Categories.vue')
|
||||
const Reminders = () => import('../views/Reminders.vue')
|
||||
const SessionExpired = () => import('../views/SessionExpired.vue')
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
{ path: '/', component: PublicRecords },
|
||||
{ path: '/login', component: Login },
|
||||
{ path: '/session-expired', component: SessionExpired },
|
||||
{ path: '/app', component: Dashboard, meta: { requiresAuth: true } },
|
||||
{ path: '/assets', component: Assets, meta: { requiresAuth: true } },
|
||||
{ path: '/categories', component: Categories, meta: { requiresAuth: true } },
|
||||
{ path: '/reminders', component: Reminders, meta: { requiresAuth: true } },
|
||||
],
|
||||
})
|
||||
|
||||
router.beforeEach((to) => {
|
||||
const auth = useAuthStore()
|
||||
if (to.meta.requiresAuth && !auth.token) return '/login'
|
||||
if (to.path === '/login' && auth.token) return '/app'
|
||||
return true
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,22 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { createAsset, deleteAsset, listAssets, updateAsset } from '../api/assets'
|
||||
|
||||
export const useAssetsStore = defineStore('assets', {
|
||||
state: () => ({
|
||||
list: [] as any[],
|
||||
total: 0,
|
||||
page: 1,
|
||||
page_size: 10,
|
||||
status: '' as '' | 'active' | 'inactive',
|
||||
}),
|
||||
actions: {
|
||||
async fetch() {
|
||||
const data = await listAssets({ page: this.page, page_size: this.page_size, status: this.status || undefined })
|
||||
this.list = data.data || []
|
||||
this.total = data.total || 0
|
||||
},
|
||||
async create(payload: any) { await createAsset(payload); await this.fetch() },
|
||||
async update(id: number, payload: any) { await updateAsset(id, payload); await this.fetch() },
|
||||
async remove(id: number) { await deleteAsset(id); await this.fetch() },
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,19 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { loginApi } from '../api/auth'
|
||||
|
||||
export const useAuthStore = defineStore('auth', {
|
||||
state: () => ({
|
||||
token: localStorage.getItem('asset_tracker_token') || '',
|
||||
}),
|
||||
actions: {
|
||||
async login(username: string, password: string) {
|
||||
const data = await loginApi(username, password)
|
||||
this.token = data.access_token
|
||||
localStorage.setItem('asset_tracker_token', this.token)
|
||||
},
|
||||
logout() {
|
||||
this.token = ''
|
||||
localStorage.removeItem('asset_tracker_token')
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,18 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { dashboardSummary } from '../api/assets'
|
||||
|
||||
export const useDashboardStore = defineStore('dashboard', {
|
||||
state: () => ({
|
||||
total_assets_value: 0,
|
||||
by_category: [] as any[],
|
||||
expiring_in_30_days: [] as any[],
|
||||
}),
|
||||
actions: {
|
||||
async fetch() {
|
||||
const data = await dashboardSummary()
|
||||
this.total_assets_value = data.total_assets_value || 0
|
||||
this.by_category = data.by_category || []
|
||||
this.expiring_in_30_days = data.expiring_in_30_days || []
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,3 @@
|
||||
body { margin: 0; background: #f5f7fb; font-family: Inter, system-ui, -apple-system, Segoe UI, Roboto, sans-serif; }
|
||||
.page { max-width: 1200px; margin: 16px auto; padding: 0 12px; }
|
||||
.card { background: white; border-radius: 12px; padding: 12px; box-shadow: 0 1px 6px rgba(0,0,0,.06); margin-bottom: 12px; }
|
||||
@@ -0,0 +1,18 @@
|
||||
export function getErrorMessage(err: any, fallback = '请求失败,请稍后重试') {
|
||||
const status = err?.response?.status
|
||||
const serverMsg = err?.response?.data?.message
|
||||
if (serverMsg && typeof serverMsg === 'string') {
|
||||
const rid = err?.response?.data?.request_id
|
||||
return rid ? `${serverMsg}(请求编号: ${rid})` : serverMsg
|
||||
}
|
||||
|
||||
let msg = fallback
|
||||
if (status === 400) msg = '请求参数有误,请检查后重试'
|
||||
else if (status === 401) msg = '登录状态已失效,请重新登录'
|
||||
else if (status === 403) msg = '你没有权限执行该操作'
|
||||
else if (status === 404) msg = '请求资源不存在'
|
||||
else if (status >= 500) msg = '服务暂时不可用,请稍后重试'
|
||||
|
||||
const rid = err?.response?.data?.request_id || err?.response?.headers?.['x-request-id']
|
||||
return rid ? `${msg}(请求编号: ${rid})` : msg
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import AppNav from '../components/AppNav.vue'
|
||||
import AssetFormDialog from '../components/AssetFormDialog.vue'
|
||||
import ExpiryBadge from '../components/ExpiryBadge.vue'
|
||||
import PageState from '../components/PageState.vue'
|
||||
import { useAssetsStore } from '../stores/assets'
|
||||
import { listCategories } from '../api/categories'
|
||||
import { getErrorMessage } from '../utils/errors'
|
||||
|
||||
const assets = useAssetsStore()
|
||||
const categories = ref<any[]>([])
|
||||
const dialogVisible = ref(false)
|
||||
const editing = ref<any>(null)
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const keyword = ref('')
|
||||
let kwTimer: any = null
|
||||
|
||||
async function loadCategories() {
|
||||
const data = await listCategories()
|
||||
categories.value = data.data || []
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
await Promise.all([assets.fetch(), loadCategories()])
|
||||
} catch (e: any) {
|
||||
error.value = getErrorMessage(e, '资产数据加载失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
editing.value = null
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
function openEdit(row: any) {
|
||||
editing.value = row
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
async function save(payload: any) {
|
||||
try {
|
||||
if (editing.value) await assets.update(editing.value.id, payload)
|
||||
else await assets.create(payload)
|
||||
dialogVisible.value = false
|
||||
ElMessage.success('保存成功')
|
||||
} catch (e: any) {
|
||||
ElMessage.error(getErrorMessage(e, '保存失败'))
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(id: number) {
|
||||
await ElMessageBox.confirm('确认删除该资产?', '提示', { type: 'warning' })
|
||||
await assets.remove(id)
|
||||
ElMessage.success('删除成功')
|
||||
}
|
||||
|
||||
const categoryMap = computed(() => Object.fromEntries(categories.value.map((x) => [x.id, x.name])))
|
||||
const filteredList = computed(() => {
|
||||
const kw = keyword.value.trim().toLowerCase()
|
||||
if (!kw) return assets.list
|
||||
return (assets.list || []).filter((x: any) => {
|
||||
return String(x.name || '').toLowerCase().includes(kw) || String(categoryMap.value[x.category_id] || '').toLowerCase().includes(kw)
|
||||
})
|
||||
})
|
||||
|
||||
function onKeywordInput() {
|
||||
if (kwTimer) clearTimeout(kwTimer)
|
||||
kwTimer = setTimeout(() => {
|
||||
// local filter only; reserved for server-side keyword later
|
||||
}, 300)
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<AppNav />
|
||||
<el-card class="card">
|
||||
<el-row :gutter="12">
|
||||
<el-col :xs="24" :sm="8">
|
||||
<el-select v-model="assets.status" placeholder="状态筛选" clearable @change="assets.fetch" style="width:100%">
|
||||
<el-option label="active" value="active" />
|
||||
<el-option label="inactive" value="inactive" />
|
||||
</el-select>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="8">
|
||||
<el-input v-model="keyword" placeholder="按资产名/分类搜索" clearable @input="onKeywordInput" />
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="8">
|
||||
<el-button type="primary" @click="openCreate">新增资产</el-button>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-card>
|
||||
|
||||
<el-card class="card">
|
||||
<PageState :loading="loading" :error="error" :empty="!loading && !error && filteredList.length===0" empty-text="当前没有资产记录">
|
||||
<template #retry><el-button @click="load">重试</el-button></template>
|
||||
<el-table :data="filteredList">
|
||||
<el-table-column prop="name" label="名称" min-width="120" />
|
||||
<el-table-column label="分类" min-width="120">
|
||||
<template #default="scope">{{ categoryMap[scope.row.category_id] || scope.row.category_id }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="估值" min-width="120">
|
||||
<template #default="scope">{{ scope.row.total_value }} {{ scope.row.currency }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="到期" min-width="180">
|
||||
<template #default="scope">
|
||||
<div>{{ scope.row.expiry_date || '-' }}</div>
|
||||
<ExpiryBadge :expiry-date="scope.row.expiry_date" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="status" label="状态" width="100" />
|
||||
<el-table-column label="操作" width="160">
|
||||
<template #default="scope">
|
||||
<el-button link type="primary" @click="openEdit(scope.row)">编辑</el-button>
|
||||
<el-button link type="danger" @click="remove(scope.row.id)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div style="margin-top:12px;display:flex;justify-content:flex-end;">
|
||||
<el-pagination
|
||||
background
|
||||
layout="prev, pager, next, total"
|
||||
:total="assets.total"
|
||||
:page-size="assets.page_size"
|
||||
:current-page="assets.page"
|
||||
@current-change="(p:number)=>{assets.page=p;assets.fetch()}"
|
||||
/>
|
||||
</div>
|
||||
</PageState>
|
||||
</el-card>
|
||||
|
||||
<AssetFormDialog v-model="dialogVisible" :editing="editing" :categories="categories" @submit="save" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,65 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import AppNav from '../components/AppNav.vue'
|
||||
import PageState from '../components/PageState.vue'
|
||||
import { createCategory, listCategories } from '../api/categories'
|
||||
import { getErrorMessage } from '../utils/errors'
|
||||
|
||||
const rows = ref<any[]>([])
|
||||
const form = reactive<{ name: string; type: 'real' | 'digital' }>({ name: '', type: 'digital' })
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const data = await listCategories()
|
||||
rows.value = data.data || []
|
||||
} catch (e: any) {
|
||||
error.value = getErrorMessage(e, '分类数据加载失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (!form.name.trim()) return ElMessage.error('分类名必填')
|
||||
try {
|
||||
await createCategory({ name: form.name.trim(), type: form.type })
|
||||
form.name = ''
|
||||
await load()
|
||||
ElMessage.success('新增成功')
|
||||
} catch (e: any) {
|
||||
ElMessage.error(getErrorMessage(e, '新增失败'))
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<AppNav />
|
||||
<el-card class="card">
|
||||
<el-row :gutter="12">
|
||||
<el-col :xs="24" :sm="10"><el-input v-model="form.name" placeholder="分类名称" /></el-col>
|
||||
<el-col :xs="24" :sm="8"><el-select v-model="form.type" style="width:100%"><el-option label="digital" value="digital" /><el-option label="real" value="real" /></el-select></el-col>
|
||||
<el-col :xs="24" :sm="6"><el-button type="primary" @click="submit">新增分类</el-button></el-col>
|
||||
</el-row>
|
||||
</el-card>
|
||||
|
||||
<el-card class="card">
|
||||
<PageState :loading="loading" :error="error" :empty="!loading && !error && rows.length===0" empty-text="当前没有分类记录">
|
||||
<template #retry><el-button @click="load">重试</el-button></template>
|
||||
<el-table :data="rows">
|
||||
<el-table-column prop="id" label="ID" width="80" />
|
||||
<el-table-column prop="name" label="分类名" min-width="140" />
|
||||
<el-table-column prop="type" label="类型" width="120" />
|
||||
<el-table-column prop="created_at" label="创建时间" min-width="180" />
|
||||
</el-table>
|
||||
</PageState>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,64 @@
|
||||
<script setup lang="ts">
|
||||
import { nextTick, onMounted, ref } from 'vue'
|
||||
import * as echarts from 'echarts'
|
||||
import { useDashboardStore } from '../stores/dashboard'
|
||||
import SummaryCards from '../components/SummaryCards.vue'
|
||||
import AppNav from '../components/AppNav.vue'
|
||||
|
||||
const dashboard = useDashboardStore()
|
||||
const chartEl = ref<HTMLDivElement>()
|
||||
let chart: echarts.ECharts | null = null
|
||||
|
||||
function renderChart() {
|
||||
if (!chartEl.value) return
|
||||
if (!chart) chart = echarts.init(chartEl.value)
|
||||
chart.setOption({
|
||||
tooltip: { trigger: 'item' },
|
||||
series: [{
|
||||
type: 'pie',
|
||||
radius: ['40%', '70%'],
|
||||
data: (dashboard.by_category || []).map((x: any) => ({ name: x.category_name || '未分类', value: x.total_value })),
|
||||
}],
|
||||
})
|
||||
}
|
||||
|
||||
async function load() {
|
||||
await dashboard.fetch()
|
||||
await nextTick()
|
||||
renderChart()
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<AppNav />
|
||||
<SummaryCards
|
||||
:total="dashboard.total_assets_value"
|
||||
:assets-count="dashboard.by_category.length"
|
||||
:expiring-count="dashboard.expiring_in_30_days.length"
|
||||
/>
|
||||
|
||||
<el-row :gutter="12" style="margin-top: 12px;">
|
||||
<el-col :xs="24" :md="12">
|
||||
<el-card class="card">
|
||||
<template #header>分类占比</template>
|
||||
<div ref="chartEl" style="height:320px"></div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :xs="24" :md="12">
|
||||
<el-card class="card">
|
||||
<template #header>30天到期 Top10</template>
|
||||
<el-table :data="dashboard.expiring_in_30_days.slice(0,10)" size="small">
|
||||
<el-table-column prop="name" label="名称" min-width="120" />
|
||||
<el-table-column prop="expiry_date" label="到期日" min-width="180" />
|
||||
<el-table-column label="金额" min-width="110">
|
||||
<template #default="scope">{{ scope.row.total_value }} {{ scope.row.currency }}</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,38 @@
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { getErrorMessage } from '../utils/errors'
|
||||
|
||||
const form = reactive({ username: '', password: '' })
|
||||
const auth = useAuthStore()
|
||||
const router = useRouter()
|
||||
const loading = ref(false)
|
||||
|
||||
async function submit() {
|
||||
loading.value = true
|
||||
try {
|
||||
await auth.login(form.username, form.password)
|
||||
ElMessage.success('登录成功')
|
||||
router.push('/app')
|
||||
} catch (e: any) {
|
||||
ElMessage.error(getErrorMessage(e, '登录失败,请检查账号密码'))
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page" style="max-width: 420px; margin-top: 80px;">
|
||||
<el-card class="card">
|
||||
<h2>登录</h2>
|
||||
<el-form @submit.prevent="submit" label-width="80px">
|
||||
<el-form-item label="用户名"><el-input v-model="form.username" /></el-form-item>
|
||||
<el-form-item label="密码"><el-input v-model="form.password" show-password type="password" /></el-form-item>
|
||||
<el-form-item><el-button type="primary" :loading="loading" @click="submit" style="width:100%">登录</el-button></el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,62 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import AppNav from '../components/AppNav.vue'
|
||||
import PageState from '../components/PageState.vue'
|
||||
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const summary = ref<any>({})
|
||||
const records = ref<any[]>([])
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const res = await fetch('/public/records')
|
||||
if (!res.ok) throw new Error('请求失败')
|
||||
const data = await res.json()
|
||||
summary.value = data.summary || {}
|
||||
records.value = data.records || []
|
||||
} catch {
|
||||
error.value = '公开记录加载失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<AppNav />
|
||||
<el-card class="card">
|
||||
<template #header>
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;">
|
||||
<span>已记录内容(公开只读)</span>
|
||||
<el-button @click="load">刷新</el-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<PageState :loading="loading" :error="error" :empty="!loading && !error && records.length===0" empty-text="当前 0 条记录。请先到 /app 添加资产">
|
||||
<template #retry><el-button @click="load">重试</el-button></template>
|
||||
<el-row :gutter="12" style="margin-bottom:12px;">
|
||||
<el-col :xs="24" :sm="8"><el-statistic title="活跃资产" :value="summary.active_asset_count || 0" /></el-col>
|
||||
<el-col :xs="24" :sm="8"><el-statistic title="总资产值" :value="summary.total_assets_value || 0" /></el-col>
|
||||
<el-col :xs="24" :sm="8"><el-statistic title="记录数" :value="records.length" /></el-col>
|
||||
</el-row>
|
||||
|
||||
<el-table :data="records" style="width:100%">
|
||||
<el-table-column prop="id" label="ID" width="70" />
|
||||
<el-table-column prop="name" label="名称" min-width="140" />
|
||||
<el-table-column prop="category_name" label="分类" min-width="120" />
|
||||
<el-table-column label="金额" min-width="120">
|
||||
<template #default="scope">{{ scope.row.total_value }} {{ scope.row.currency }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="status" label="状态" width="100" />
|
||||
<el-table-column prop="expiry_date" label="到期日" min-width="180" />
|
||||
</el-table>
|
||||
</PageState>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,79 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import AppNav from '../components/AppNav.vue'
|
||||
import PageState from '../components/PageState.vue'
|
||||
import { listReminders } from '../api/reminders'
|
||||
import { getErrorMessage } from '../utils/errors'
|
||||
|
||||
const status = ref('pending')
|
||||
const rows = ref<any[]>([])
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
const pageSize = ref(20)
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
function onTabChange() {
|
||||
page.value = 1
|
||||
load()
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const data = await listReminders({ status: status.value || undefined, page: page.value, page_size: pageSize.value })
|
||||
rows.value = data.data || []
|
||||
total.value = data.total || 0
|
||||
} catch (e: any) {
|
||||
error.value = getErrorMessage(e, '提醒数据加载失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<AppNav />
|
||||
<el-card class="card">
|
||||
<el-tabs v-model="status" @tab-change="onTabChange">
|
||||
<el-tab-pane label="待处理" name="pending" />
|
||||
<el-tab-pane label="发送中" name="sending" />
|
||||
<el-tab-pane label="已发送" name="sent" />
|
||||
<el-tab-pane label="失败" name="failed" />
|
||||
</el-tabs>
|
||||
|
||||
<PageState :loading="loading" :error="error" :empty="!loading && !error && rows.length===0" empty-text="当前没有提醒记录">
|
||||
<template #retry><el-button @click="load">重试</el-button></template>
|
||||
<el-table :data="rows">
|
||||
<el-table-column prop="id" label="ID" width="70" />
|
||||
<el-table-column prop="asset_name" label="资产" min-width="140" />
|
||||
<el-table-column prop="status" label="状态" width="100" />
|
||||
<el-table-column prop="remind_at" label="提醒时间" min-width="180" />
|
||||
<el-table-column prop="next_retry_at" label="下次重试" min-width="180" />
|
||||
<el-table-column prop="retry_count" label="重试" width="80" />
|
||||
<el-table-column label="错误信息" min-width="240">
|
||||
<template #default="scope">
|
||||
<el-tag v-if="scope.row.last_error" type="danger" size="small">{{ scope.row.last_error }}</el-tag>
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div style="margin-top:12px;display:flex;justify-content:flex-end;">
|
||||
<el-pagination
|
||||
background
|
||||
layout="prev, pager, next, total"
|
||||
:total="total"
|
||||
:page-size="pageSize"
|
||||
:current-page="page"
|
||||
@current-change="(p:number)=>{page=p;load()}"
|
||||
/>
|
||||
</div>
|
||||
</PageState>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,9 @@
|
||||
<template>
|
||||
<div class="page" style="max-width: 520px; margin-top: 80px;">
|
||||
<el-card class="card">
|
||||
<h2>会话已过期</h2>
|
||||
<p>登录状态失效,请重新登录后继续操作。</p>
|
||||
<el-button type="primary" @click="$router.push('/login')">去登录</el-button>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"types": ["vite/client"],
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": 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,26 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "ES2023",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"types": ["node"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
base: '/',
|
||||
build: {
|
||||
assetsDir: '_assets',
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,72 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Asset Tracker</title>
|
||||
<link rel="stylesheet" href="/app/static/style.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>Asset Tracker</h1>
|
||||
|
||||
<section id="login-section" class="card">
|
||||
<h2>登录</h2>
|
||||
<div class="row">
|
||||
<input id="username" placeholder="用户名" value="admin" />
|
||||
<input id="password" type="password" placeholder="密码" value="admin123" />
|
||||
<button id="login-btn">登录</button>
|
||||
</div>
|
||||
<p class="hint">默认账号:admin / admin123</p>
|
||||
</section>
|
||||
|
||||
<section id="app-section" class="hidden">
|
||||
<div class="card">
|
||||
<div class="row between">
|
||||
<h2>仪表盘</h2>
|
||||
<button id="refresh-dashboard">刷新</button>
|
||||
</div>
|
||||
<div id="dashboard" class="grid"></div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>分类管理</h2>
|
||||
<div class="row">
|
||||
<input id="cat-name" placeholder="分类名,如:服务器" />
|
||||
<select id="cat-type">
|
||||
<option value="digital">digital</option>
|
||||
<option value="real">real</option>
|
||||
</select>
|
||||
<button id="add-cat">新增分类</button>
|
||||
</div>
|
||||
<ul id="category-list"></ul>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>资产管理</h2>
|
||||
<div class="row wrap">
|
||||
<input id="asset-name" placeholder="资产名称" />
|
||||
<select id="asset-category"></select>
|
||||
<input id="asset-quantity" type="number" step="0.01" placeholder="数量" value="1" />
|
||||
<input id="asset-price" type="number" step="0.01" placeholder="单价" value="100" />
|
||||
<input id="asset-currency" placeholder="币种,如 USD" value="USD" />
|
||||
<input id="asset-expiry" type="datetime-local" />
|
||||
<button id="add-asset">新增资产</button>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>ID</th><th>名称</th><th>分类</th><th>金额</th><th>状态</th><th>到期</th><th>操作</th></tr>
|
||||
</thead>
|
||||
<tbody id="asset-tbody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div id="asset-cards" class="asset-cards"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<pre id="msg"></pre>
|
||||
</div>
|
||||
<script src="/app/static/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,72 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Asset Tracker</title>
|
||||
<link rel="stylesheet" href="/app/static/style.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>Asset Tracker</h1>
|
||||
|
||||
<section id="login-section" class="card">
|
||||
<h2>登录</h2>
|
||||
<div class="row">
|
||||
<input id="username" placeholder="用户名" value="admin" />
|
||||
<input id="password" type="password" placeholder="密码" value="admin123" />
|
||||
<button id="login-btn">登录</button>
|
||||
</div>
|
||||
<p class="hint">默认账号:admin / admin123</p>
|
||||
</section>
|
||||
|
||||
<section id="app-section" class="hidden">
|
||||
<div class="card">
|
||||
<div class="row between">
|
||||
<h2>仪表盘</h2>
|
||||
<button id="refresh-dashboard">刷新</button>
|
||||
</div>
|
||||
<div id="dashboard" class="grid"></div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>分类管理</h2>
|
||||
<div class="row">
|
||||
<input id="cat-name" placeholder="分类名,如:服务器" />
|
||||
<select id="cat-type">
|
||||
<option value="digital">digital</option>
|
||||
<option value="real">real</option>
|
||||
</select>
|
||||
<button id="add-cat">新增分类</button>
|
||||
</div>
|
||||
<ul id="category-list"></ul>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>资产管理</h2>
|
||||
<div class="row wrap">
|
||||
<input id="asset-name" placeholder="资产名称" />
|
||||
<select id="asset-category"></select>
|
||||
<input id="asset-quantity" type="number" step="0.01" placeholder="数量" value="1" />
|
||||
<input id="asset-price" type="number" step="0.01" placeholder="单价" value="100" />
|
||||
<input id="asset-currency" placeholder="币种,如 USD" value="USD" />
|
||||
<input id="asset-expiry" type="datetime-local" />
|
||||
<button id="add-asset">新增资产</button>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>ID</th><th>名称</th><th>分类</th><th>金额</th><th>状态</th><th>到期</th><th>操作</th></tr>
|
||||
</thead>
|
||||
<tbody id="asset-tbody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div id="asset-cards" class="asset-cards"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<pre id="msg"></pre>
|
||||
</div>
|
||||
<script src="/app/static/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,38 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Asset Records</title>
|
||||
<link rel="stylesheet" href="/app/static/style.css" />
|
||||
<style>
|
||||
.top{display:flex;justify-content:space-between;align-items:center;gap:8px}
|
||||
.muted{color:#6b7280;font-size:13px}
|
||||
.record-card{border:1px solid #e5e7eb;border-radius:10px;padding:10px;margin-bottom:8px;background:#fff}
|
||||
.record-card .line{display:flex;justify-content:space-between;gap:10px;font-size:13px;margin:4px 0}
|
||||
.empty{padding:18px;border:1px dashed #cbd5e1;border-radius:10px;background:#f8fafc;color:#64748b}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="card top">
|
||||
<div>
|
||||
<h1 style="margin:0">已记录内容</h1>
|
||||
<div class="muted">只读展示页(管理入口:/app)</div>
|
||||
</div>
|
||||
<button id="refresh-btn">刷新</button>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>汇总</h2>
|
||||
<div id="summary" class="grid"></div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>记录列表</h2>
|
||||
<div id="records"></div>
|
||||
</div>
|
||||
</div>
|
||||
<script src="/app/static/public.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,134 @@
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const msg = (t) => $("msg").textContent = typeof t === 'string' ? t : JSON.stringify(t, null, 2);
|
||||
const tokenKey = 'asset_tracker_token';
|
||||
let categories = [];
|
||||
|
||||
function token(){ return localStorage.getItem(tokenKey) || ''; }
|
||||
|
||||
async function api(path, method='GET', body){
|
||||
const headers = {};
|
||||
if(token()) headers['Authorization'] = `Bearer ${token()}`;
|
||||
if(body) headers['Content-Type'] = 'application/json';
|
||||
const res = await fetch(path, { method, headers, body: body ? JSON.stringify(body) : undefined });
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if(!res.ok) throw new Error(data.error || `${res.status}`);
|
||||
return data;
|
||||
}
|
||||
|
||||
async function doLogin(){
|
||||
try{
|
||||
const data = await api('/api/v1/auth/login', 'POST', {
|
||||
username: $('username').value.trim(),
|
||||
password: $('password').value
|
||||
});
|
||||
localStorage.setItem(tokenKey, data.access_token);
|
||||
$('login-section').classList.add('hidden');
|
||||
$('app-section').classList.remove('hidden');
|
||||
await reloadAll();
|
||||
msg('登录成功');
|
||||
}catch(e){ msg(`登录失败: ${e.message}`); }
|
||||
}
|
||||
|
||||
async function loadCategories(){
|
||||
const data = await api('/api/v1/categories');
|
||||
categories = data.data || [];
|
||||
$('category-list').innerHTML = categories.map(c => `<li>#${c.id} ${c.name} (${c.type})</li>`).join('') || '<li>暂无分类</li>';
|
||||
$('asset-category').innerHTML = categories.map(c => `<option value="${c.id}">${c.name}</option>`).join('');
|
||||
}
|
||||
|
||||
async function addCategory(){
|
||||
try{
|
||||
await api('/api/v1/categories', 'POST', {
|
||||
name: $('cat-name').value.trim(),
|
||||
type: $('cat-type').value
|
||||
});
|
||||
$('cat-name').value='';
|
||||
await loadCategories();
|
||||
msg('分类创建成功');
|
||||
}catch(e){ msg(`分类创建失败: ${e.message}`); }
|
||||
}
|
||||
|
||||
function toRFC3339(localVal){
|
||||
if(!localVal) return '';
|
||||
const d = new Date(localVal);
|
||||
return d.toISOString();
|
||||
}
|
||||
|
||||
async function addAsset(){
|
||||
try{
|
||||
await api('/api/v1/assets', 'POST', {
|
||||
name: $('asset-name').value.trim(),
|
||||
category_id: Number($('asset-category').value),
|
||||
quantity: Number($('asset-quantity').value || 0),
|
||||
unit_price: Number($('asset-price').value || 0),
|
||||
currency: $('asset-currency').value.trim().toUpperCase(),
|
||||
expiry_date: toRFC3339($('asset-expiry').value)
|
||||
});
|
||||
$('asset-name').value='';
|
||||
await loadAssets();
|
||||
await loadDashboard();
|
||||
msg('资产创建成功');
|
||||
}catch(e){ msg(`资产创建失败: ${e.message}`); }
|
||||
}
|
||||
|
||||
async function loadAssets(){
|
||||
const data = await api('/api/v1/assets?page=1&page_size=100');
|
||||
const rows = data.data || [];
|
||||
const map = new Map(categories.map(c => [c.id, c.name]));
|
||||
$('asset-tbody').innerHTML = rows.map(a => `<tr>
|
||||
<td>${a.id}</td>
|
||||
<td>${a.name}</td>
|
||||
<td>${map.get(a.category_id) || a.category_id}</td>
|
||||
<td>${a.total_value} ${a.currency}</td>
|
||||
<td>${a.status}</td>
|
||||
<td>${a.expiry_date || '-'}</td>
|
||||
<td><button onclick="delAsset(${a.id})">删除</button></td>
|
||||
</tr>`).join('') || '<tr><td colspan="7">暂无资产</td></tr>';
|
||||
|
||||
$('asset-cards').innerHTML = rows.map(a => `<div class="asset-card">
|
||||
<div class="line"><b>#${a.id} ${a.name}</b><span>${a.status}</span></div>
|
||||
<div class="line"><span>分类</span><span>${map.get(a.category_id) || a.category_id}</span></div>
|
||||
<div class="line"><span>金额</span><span>${a.total_value} ${a.currency}</span></div>
|
||||
<div class="line"><span>到期</span><span>${a.expiry_date || '-'}</span></div>
|
||||
<div class="line"><button onclick="delAsset(${a.id})">删除</button></div>
|
||||
</div>`).join('') || '<div class="asset-card">暂无资产</div>';
|
||||
}
|
||||
|
||||
async function delAsset(id){
|
||||
try{
|
||||
await api(`/api/v1/assets/${id}`, 'DELETE');
|
||||
await loadAssets();
|
||||
await loadDashboard();
|
||||
msg(`已删除资产 #${id}`);
|
||||
}catch(e){ msg(`删除失败: ${e.message}`); }
|
||||
}
|
||||
window.delAsset = delAsset;
|
||||
|
||||
async function loadDashboard(){
|
||||
const d = await api('/api/v1/dashboard/summary');
|
||||
const byCat = (d.by_category || []).map(x => `${x.category_name}: ${x.total_value}`).join('<br>') || '无';
|
||||
$('dashboard').innerHTML = `
|
||||
<div class="kpi"><b>总资产</b><div>${d.total_assets_value}</div></div>
|
||||
<div class="kpi"><b>分类占比</b><div>${byCat}</div></div>
|
||||
<div class="kpi"><b>30天到期</b><div>${(d.expiring_in_30_days || []).length} 条</div></div>
|
||||
`;
|
||||
}
|
||||
|
||||
async function reloadAll(){
|
||||
await loadCategories();
|
||||
await loadAssets();
|
||||
await loadDashboard();
|
||||
}
|
||||
|
||||
$('login-btn').addEventListener('click', doLogin);
|
||||
$('add-cat').addEventListener('click', addCategory);
|
||||
$('add-asset').addEventListener('click', addAsset);
|
||||
$('refresh-dashboard').addEventListener('click', loadDashboard);
|
||||
|
||||
(async function init(){
|
||||
if(token()){
|
||||
$('login-section').classList.add('hidden');
|
||||
$('app-section').classList.remove('hidden');
|
||||
try{ await reloadAll(); }catch(e){ msg(`自动加载失败: ${e.message}`); }
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,33 @@
|
||||
async function loadPublicRecords(){
|
||||
const res = await fetch('/public/records');
|
||||
const data = await res.json();
|
||||
|
||||
const s = data.summary || {};
|
||||
const by = s.by_category || {};
|
||||
document.getElementById('summary').innerHTML = `
|
||||
<div class="kpi"><b>用户ID</b><div>${s.user_id ?? '-'}</div></div>
|
||||
<div class="kpi"><b>活跃资产数</b><div>${s.active_asset_count ?? 0}</div></div>
|
||||
<div class="kpi"><b>总资产值</b><div>${s.total_assets_value ?? 0}</div></div>
|
||||
<div class="kpi" style="grid-column:1/-1"><b>分类汇总</b><div>${Object.keys(by).length ? Object.entries(by).map(([k,v])=>`${k||'未分类'}: ${v}`).join('<br>') : '暂无'}</div></div>
|
||||
`;
|
||||
|
||||
const rows = data.records || [];
|
||||
const box = document.getElementById('records');
|
||||
if(!rows.length){
|
||||
box.innerHTML = '<div class="empty">当前 0 条记录。可前往 <a href="/app">/app</a> 添加资产后回来查看。</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
box.innerHTML = rows.map(r => `
|
||||
<div class="record-card">
|
||||
<div class="line"><b>#${r.id} ${r.name || ''}</b><span>${r.status || '-'}</span></div>
|
||||
<div class="line"><span>分类</span><span>${r.category_name || '-'}</span></div>
|
||||
<div class="line"><span>金额</span><span>${r.total_value ?? 0} ${r.currency || ''}</span></div>
|
||||
<div class="line"><span>到期</span><span>${r.expiry_date || '-'}</span></div>
|
||||
<div class="line"><span>更新时间</span><span>${r.updated_at || '-'}</span></div>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
document.getElementById('refresh-btn').addEventListener('click', loadPublicRecords);
|
||||
loadPublicRecords();
|
||||
@@ -0,0 +1,33 @@
|
||||
body{font-family:system-ui,-apple-system,Segoe UI,Roboto,sans-serif;background:#f5f7fb;margin:0;color:#1f2937}
|
||||
.container{max-width:1100px;margin:20px auto;padding:0 12px}
|
||||
h1{margin:0 0 12px;font-size:26px}
|
||||
h2{margin:0 0 10px;font-size:18px}
|
||||
.card{background:#fff;border-radius:12px;padding:14px;margin-bottom:12px;box-shadow:0 1px 6px rgba(0,0,0,.06)}
|
||||
.row{display:flex;gap:8px;align-items:center}
|
||||
.row.wrap{flex-wrap:wrap}
|
||||
.row.between{justify-content:space-between}
|
||||
input,select,button{padding:10px;border:1px solid #d0d7e2;border-radius:10px;font-size:14px}
|
||||
button{background:#2b7cff;color:#fff;border:none;cursor:pointer}
|
||||
button:hover{opacity:.92}
|
||||
.hidden{display:none}
|
||||
.grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:8px}
|
||||
.kpi{background:#f0f5ff;padding:10px;border-radius:10px;line-height:1.5}
|
||||
.table-wrap{overflow:auto;border:1px solid #edf1f7;border-radius:10px}
|
||||
table{width:100%;border-collapse:collapse;min-width:760px;background:#fff}
|
||||
th,td{border-bottom:1px solid #eceff5;padding:8px;text-align:left;white-space:nowrap}
|
||||
.asset-cards{display:none}
|
||||
.asset-card{border:1px solid #edf1f7;border-radius:10px;padding:10px;margin-bottom:8px;background:#fff}
|
||||
.asset-card .line{display:flex;justify-content:space-between;margin:4px 0;font-size:13px}
|
||||
pre{white-space:pre-wrap;background:#0d1117;color:#9ecbff;padding:10px;border-radius:8px;min-height:28px}
|
||||
.hint{color:#666;font-size:12px}
|
||||
|
||||
@media (max-width: 768px){
|
||||
.container{padding:0 10px;margin:12px auto}
|
||||
h1{font-size:22px}
|
||||
.grid{grid-template-columns:1fr}
|
||||
.row{flex-wrap:wrap}
|
||||
.row > *{flex:1 1 calc(50% - 8px);min-width:120px}
|
||||
.row > button{flex:1 1 100%}
|
||||
.table-wrap{display:none}
|
||||
.asset-cards{display:block}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Asset Records</title>
|
||||
<link rel="stylesheet" href="/app/static/style.css" />
|
||||
<style>
|
||||
.top{display:flex;justify-content:space-between;align-items:center;gap:8px}
|
||||
.muted{color:#6b7280;font-size:13px}
|
||||
.record-card{border:1px solid #e5e7eb;border-radius:10px;padding:10px;margin-bottom:8px;background:#fff}
|
||||
.record-card .line{display:flex;justify-content:space-between;gap:10px;font-size:13px;margin:4px 0}
|
||||
.empty{padding:18px;border:1px dashed #cbd5e1;border-radius:10px;background:#f8fafc;color:#64748b}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="card top">
|
||||
<div>
|
||||
<h1 style="margin:0">已记录内容</h1>
|
||||
<div class="muted">只读展示页(管理入口:/app)</div>
|
||||
</div>
|
||||
<button id="refresh-btn">刷新</button>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>汇总</h2>
|
||||
<div id="summary" class="grid"></div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>记录列表</h2>
|
||||
<div id="records"></div>
|
||||
</div>
|
||||
</div>
|
||||
<script src="/app/static/public.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,134 @@
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const msg = (t) => $("msg").textContent = typeof t === 'string' ? t : JSON.stringify(t, null, 2);
|
||||
const tokenKey = 'asset_tracker_token';
|
||||
let categories = [];
|
||||
|
||||
function token(){ return localStorage.getItem(tokenKey) || ''; }
|
||||
|
||||
async function api(path, method='GET', body){
|
||||
const headers = {};
|
||||
if(token()) headers['Authorization'] = `Bearer ${token()}`;
|
||||
if(body) headers['Content-Type'] = 'application/json';
|
||||
const res = await fetch(path, { method, headers, body: body ? JSON.stringify(body) : undefined });
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if(!res.ok) throw new Error(data.error || `${res.status}`);
|
||||
return data;
|
||||
}
|
||||
|
||||
async function doLogin(){
|
||||
try{
|
||||
const data = await api('/api/v1/auth/login', 'POST', {
|
||||
username: $('username').value.trim(),
|
||||
password: $('password').value
|
||||
});
|
||||
localStorage.setItem(tokenKey, data.access_token);
|
||||
$('login-section').classList.add('hidden');
|
||||
$('app-section').classList.remove('hidden');
|
||||
await reloadAll();
|
||||
msg('登录成功');
|
||||
}catch(e){ msg(`登录失败: ${e.message}`); }
|
||||
}
|
||||
|
||||
async function loadCategories(){
|
||||
const data = await api('/api/v1/categories');
|
||||
categories = data.data || [];
|
||||
$('category-list').innerHTML = categories.map(c => `<li>#${c.id} ${c.name} (${c.type})</li>`).join('') || '<li>暂无分类</li>';
|
||||
$('asset-category').innerHTML = categories.map(c => `<option value="${c.id}">${c.name}</option>`).join('');
|
||||
}
|
||||
|
||||
async function addCategory(){
|
||||
try{
|
||||
await api('/api/v1/categories', 'POST', {
|
||||
name: $('cat-name').value.trim(),
|
||||
type: $('cat-type').value
|
||||
});
|
||||
$('cat-name').value='';
|
||||
await loadCategories();
|
||||
msg('分类创建成功');
|
||||
}catch(e){ msg(`分类创建失败: ${e.message}`); }
|
||||
}
|
||||
|
||||
function toRFC3339(localVal){
|
||||
if(!localVal) return '';
|
||||
const d = new Date(localVal);
|
||||
return d.toISOString();
|
||||
}
|
||||
|
||||
async function addAsset(){
|
||||
try{
|
||||
await api('/api/v1/assets', 'POST', {
|
||||
name: $('asset-name').value.trim(),
|
||||
category_id: Number($('asset-category').value),
|
||||
quantity: Number($('asset-quantity').value || 0),
|
||||
unit_price: Number($('asset-price').value || 0),
|
||||
currency: $('asset-currency').value.trim().toUpperCase(),
|
||||
expiry_date: toRFC3339($('asset-expiry').value)
|
||||
});
|
||||
$('asset-name').value='';
|
||||
await loadAssets();
|
||||
await loadDashboard();
|
||||
msg('资产创建成功');
|
||||
}catch(e){ msg(`资产创建失败: ${e.message}`); }
|
||||
}
|
||||
|
||||
async function loadAssets(){
|
||||
const data = await api('/api/v1/assets?page=1&page_size=100');
|
||||
const rows = data.data || [];
|
||||
const map = new Map(categories.map(c => [c.id, c.name]));
|
||||
$('asset-tbody').innerHTML = rows.map(a => `<tr>
|
||||
<td>${a.id}</td>
|
||||
<td>${a.name}</td>
|
||||
<td>${map.get(a.category_id) || a.category_id}</td>
|
||||
<td>${a.total_value} ${a.currency}</td>
|
||||
<td>${a.status}</td>
|
||||
<td>${a.expiry_date || '-'}</td>
|
||||
<td><button onclick="delAsset(${a.id})">删除</button></td>
|
||||
</tr>`).join('') || '<tr><td colspan="7">暂无资产</td></tr>';
|
||||
|
||||
$('asset-cards').innerHTML = rows.map(a => `<div class="asset-card">
|
||||
<div class="line"><b>#${a.id} ${a.name}</b><span>${a.status}</span></div>
|
||||
<div class="line"><span>分类</span><span>${map.get(a.category_id) || a.category_id}</span></div>
|
||||
<div class="line"><span>金额</span><span>${a.total_value} ${a.currency}</span></div>
|
||||
<div class="line"><span>到期</span><span>${a.expiry_date || '-'}</span></div>
|
||||
<div class="line"><button onclick="delAsset(${a.id})">删除</button></div>
|
||||
</div>`).join('') || '<div class="asset-card">暂无资产</div>';
|
||||
}
|
||||
|
||||
async function delAsset(id){
|
||||
try{
|
||||
await api(`/api/v1/assets/${id}`, 'DELETE');
|
||||
await loadAssets();
|
||||
await loadDashboard();
|
||||
msg(`已删除资产 #${id}`);
|
||||
}catch(e){ msg(`删除失败: ${e.message}`); }
|
||||
}
|
||||
window.delAsset = delAsset;
|
||||
|
||||
async function loadDashboard(){
|
||||
const d = await api('/api/v1/dashboard/summary');
|
||||
const byCat = (d.by_category || []).map(x => `${x.category_name}: ${x.total_value}`).join('<br>') || '无';
|
||||
$('dashboard').innerHTML = `
|
||||
<div class="kpi"><b>总资产</b><div>${d.total_assets_value}</div></div>
|
||||
<div class="kpi"><b>分类占比</b><div>${byCat}</div></div>
|
||||
<div class="kpi"><b>30天到期</b><div>${(d.expiring_in_30_days || []).length} 条</div></div>
|
||||
`;
|
||||
}
|
||||
|
||||
async function reloadAll(){
|
||||
await loadCategories();
|
||||
await loadAssets();
|
||||
await loadDashboard();
|
||||
}
|
||||
|
||||
$('login-btn').addEventListener('click', doLogin);
|
||||
$('add-cat').addEventListener('click', addCategory);
|
||||
$('add-asset').addEventListener('click', addAsset);
|
||||
$('refresh-dashboard').addEventListener('click', loadDashboard);
|
||||
|
||||
(async function init(){
|
||||
if(token()){
|
||||
$('login-section').classList.add('hidden');
|
||||
$('app-section').classList.remove('hidden');
|
||||
try{ await reloadAll(); }catch(e){ msg(`自动加载失败: ${e.message}`); }
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,33 @@
|
||||
async function loadPublicRecords(){
|
||||
const res = await fetch('/public/records');
|
||||
const data = await res.json();
|
||||
|
||||
const s = data.summary || {};
|
||||
const by = s.by_category || {};
|
||||
document.getElementById('summary').innerHTML = `
|
||||
<div class="kpi"><b>用户ID</b><div>${s.user_id ?? '-'}</div></div>
|
||||
<div class="kpi"><b>活跃资产数</b><div>${s.active_asset_count ?? 0}</div></div>
|
||||
<div class="kpi"><b>总资产值</b><div>${s.total_assets_value ?? 0}</div></div>
|
||||
<div class="kpi" style="grid-column:1/-1"><b>分类汇总</b><div>${Object.keys(by).length ? Object.entries(by).map(([k,v])=>`${k||'未分类'}: ${v}`).join('<br>') : '暂无'}</div></div>
|
||||
`;
|
||||
|
||||
const rows = data.records || [];
|
||||
const box = document.getElementById('records');
|
||||
if(!rows.length){
|
||||
box.innerHTML = '<div class="empty">当前 0 条记录。可前往 <a href="/app">/app</a> 添加资产后回来查看。</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
box.innerHTML = rows.map(r => `
|
||||
<div class="record-card">
|
||||
<div class="line"><b>#${r.id} ${r.name || ''}</b><span>${r.status || '-'}</span></div>
|
||||
<div class="line"><span>分类</span><span>${r.category_name || '-'}</span></div>
|
||||
<div class="line"><span>金额</span><span>${r.total_value ?? 0} ${r.currency || ''}</span></div>
|
||||
<div class="line"><span>到期</span><span>${r.expiry_date || '-'}</span></div>
|
||||
<div class="line"><span>更新时间</span><span>${r.updated_at || '-'}</span></div>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
document.getElementById('refresh-btn').addEventListener('click', loadPublicRecords);
|
||||
loadPublicRecords();
|
||||
@@ -0,0 +1,33 @@
|
||||
body{font-family:system-ui,-apple-system,Segoe UI,Roboto,sans-serif;background:#f5f7fb;margin:0;color:#1f2937}
|
||||
.container{max-width:1100px;margin:20px auto;padding:0 12px}
|
||||
h1{margin:0 0 12px;font-size:26px}
|
||||
h2{margin:0 0 10px;font-size:18px}
|
||||
.card{background:#fff;border-radius:12px;padding:14px;margin-bottom:12px;box-shadow:0 1px 6px rgba(0,0,0,.06)}
|
||||
.row{display:flex;gap:8px;align-items:center}
|
||||
.row.wrap{flex-wrap:wrap}
|
||||
.row.between{justify-content:space-between}
|
||||
input,select,button{padding:10px;border:1px solid #d0d7e2;border-radius:10px;font-size:14px}
|
||||
button{background:#2b7cff;color:#fff;border:none;cursor:pointer}
|
||||
button:hover{opacity:.92}
|
||||
.hidden{display:none}
|
||||
.grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:8px}
|
||||
.kpi{background:#f0f5ff;padding:10px;border-radius:10px;line-height:1.5}
|
||||
.table-wrap{overflow:auto;border:1px solid #edf1f7;border-radius:10px}
|
||||
table{width:100%;border-collapse:collapse;min-width:760px;background:#fff}
|
||||
th,td{border-bottom:1px solid #eceff5;padding:8px;text-align:left;white-space:nowrap}
|
||||
.asset-cards{display:none}
|
||||
.asset-card{border:1px solid #edf1f7;border-radius:10px;padding:10px;margin-bottom:8px;background:#fff}
|
||||
.asset-card .line{display:flex;justify-content:space-between;margin:4px 0;font-size:13px}
|
||||
pre{white-space:pre-wrap;background:#0d1117;color:#9ecbff;padding:10px;border-radius:8px;min-height:28px}
|
||||
.hint{color:#666;font-size:12px}
|
||||
|
||||
@media (max-width: 768px){
|
||||
.container{padding:0 10px;margin:12px auto}
|
||||
h1{font-size:22px}
|
||||
.grid{grid-template-columns:1fr}
|
||||
.row{flex-wrap:wrap}
|
||||
.row > *{flex:1 1 calc(50% - 8px);min-width:120px}
|
||||
.row > button{flex:1 1 100%}
|
||||
.table-wrap{display:none}
|
||||
.asset-cards{display:block}
|
||||
}
|
||||
Reference in New Issue
Block a user