Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eb1f68b423 | ||
|
|
c0a6ee3cb0 | ||
|
|
1ceff0ffa3 | ||
|
|
f16bd5567b | ||
|
|
60f49b3b27 | ||
|
|
2f9a401381 | ||
|
|
7355edf999 | ||
|
|
f8173ace88 | ||
|
|
a4198743a4 | ||
|
|
224d36dcf1 | ||
|
|
1fb4940625 | ||
|
|
182f57480a | ||
|
|
9680da7762 | ||
|
|
13e33777bd | ||
|
|
4dab5563c5 | ||
|
|
1dba45d417 | ||
|
|
42a5803c06 | ||
|
|
ed00a63434 | ||
|
|
c2204adf0c | ||
|
|
ce410666d9 | ||
|
|
bc5d654a59 | ||
|
|
8410a2cac4 | ||
|
|
b0539c0014 | ||
|
|
ccbe6cf37b | ||
|
|
a0ff21ad3f | ||
|
|
10030f2fc7 | ||
|
|
9eb237513e | ||
|
|
4e619c30be | ||
|
|
09be964d53 |
No files matched your search
@@ -0,0 +1,228 @@
|
||||
# 技术契约与架构设计书
|
||||
## 功能:授权趋势图表自适应溢出修复
|
||||
|
||||
**版本**:v1.0
|
||||
**日期**:2026-03-13
|
||||
**设计者**:System Architect
|
||||
**状态**:待实现
|
||||
|
||||
---
|
||||
|
||||
## 一、问题定位(Root Cause Analysis)
|
||||
|
||||
通过审计截图与源码,确认存在 **三条独立的溢出根因**,相互叠加导致图表在不同视口宽度下随机超出卡片边界。
|
||||
|
||||
### 根因 A — CSS 高度链断裂(最高优先级)
|
||||
|
||||
**文件**:`frontend/src/views/Dashboard.vue`
|
||||
|
||||
```
|
||||
.content-container { min-height: calc(100vh - 80px); } ← ①
|
||||
└─ .dashboard { height: 100%; } ← ② ❌ 100% of min-height = 0!
|
||||
└─ .content-section { flex: 1; min-height: 0; } ← ③
|
||||
└─ .chart-section { flex: 0.5; min-height: 300px; }
|
||||
└─ .license-trend-chart { height: 100%; } ← ④ 同样失效
|
||||
```
|
||||
|
||||
CSS 规范:`height: 100%` 只解析相对于父元素的 **`height`** 属性,而非 `min-height`。步骤②处 `.dashboard` 获取的 `height: 100%` 实际解析为 `auto`,导致整条 flex 高度链全部退化为 `auto`/内容高度,图表实际渲染高度由内容撑开,触发溢出。
|
||||
|
||||
**现象**:桌面端图表卡片高度不稳定,ECharts canvas 随视口/数据量变化而溢出卡片底部。
|
||||
|
||||
### 根因 B — ECharts Grid 使用绝对像素 + `containLabel: false`
|
||||
|
||||
**文件**:`frontend/src/components/charts/LicenseTrendChart.vue`
|
||||
|
||||
```js
|
||||
grid: {
|
||||
left: 60, // 固定像素,容器小时 Y 轴标签溢出左侧
|
||||
right: 50, // 固定像素,容器小时溢出右侧
|
||||
top: 30,
|
||||
bottom: 60,
|
||||
containLabel: false // ❌ 不包含坐标轴标签在 grid 范围内
|
||||
}
|
||||
```
|
||||
|
||||
当容器宽度较小时,ECharts 按绝对像素保留边距并绘制轴标签于 grid 外部,标签实际渲染于 canvas 边缘外,出现截断/溢出。
|
||||
|
||||
**现象**:窄屏或侧边栏展开时,X 轴日期标签被裁切或与右侧内容重叠。
|
||||
|
||||
### 根因 C — 父容器 `overflow: hidden` 裁切 ECharts tooltip
|
||||
|
||||
**文件**:`frontend/src/components/charts/LicenseTrendChart.vue`
|
||||
|
||||
```scss
|
||||
.license-trend-chart {
|
||||
overflow: hidden; // ❌ 裁切了 ECharts tooltip 的绝对定位弹层
|
||||
}
|
||||
```
|
||||
|
||||
ECharts 的 tooltip 默认渲染为 `position: absolute` 的 `div` 元素,若其最近的 `overflow: hidden` 祖先正好是卡片容器,tooltip 悬浮层会被硬裁。移动端尤其明显(截图二:tooltip "授权数量: 8" 被截断于卡片底部)。
|
||||
|
||||
---
|
||||
|
||||
## 二、数据模型层(Schema)
|
||||
|
||||
本次修复为纯前端 CSS/布局层变更,**不涉及任何数据模型、API 接口或后端逻辑修改**。API 契约保持不变。
|
||||
|
||||
---
|
||||
|
||||
## 三、后端服务层(API)
|
||||
|
||||
**无变更**。后端 `GET /api/v1/dashboard/authorization-trend` 接口及响应格式 `AuthorizationTrendResponse` 不受影响。
|
||||
|
||||
---
|
||||
|
||||
## 四、前端交互层(View)变更契约
|
||||
|
||||
### 4.1 变更文件清单
|
||||
|
||||
| 操作 | 文件路径 | 变更范围 |
|
||||
|------|----------|----------|
|
||||
| 修改 | `frontend/src/views/Dashboard.vue` | `.content-container` 的高度策略 |
|
||||
| 修改 | `frontend/src/components/charts/LicenseTrendChart.vue` | Grid 配置 + overflow + 高度策略 |
|
||||
|
||||
---
|
||||
|
||||
### 4.2 `frontend/src/views/Dashboard.vue` — 修改意图
|
||||
|
||||
**目标**:修复高度链断裂,使 flex 容器的高度能正确传递给子组件。
|
||||
|
||||
#### 改动 1:`.content-container` 改用 `height` 替代 `min-height`
|
||||
|
||||
```scss
|
||||
// ❌ 修改前
|
||||
.content-container {
|
||||
min-height: calc(100vh - 80px);
|
||||
// ...
|
||||
}
|
||||
|
||||
// ✅ 修改后
|
||||
.content-container {
|
||||
height: calc(100vh - 80px); // 使用确定的 height,让 flex 子项 height: 100% 可以解析
|
||||
min-height: 600px; // 保留最小高度防止内容压扁,但用 min-height 做限底
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
> **风险评估**:`Dashboard.vue` 的 `height` 被 `Layout` 组件 `overflow: auto` 的滚动容器包裹,切换为固定 `height` 不会影响滚动行为,但需验证 Layout 组件是否已设置 `overflow-y: auto`。
|
||||
|
||||
---
|
||||
|
||||
### 4.3 `frontend/src/components/charts/LicenseTrendChart.vue` — 修改意图
|
||||
|
||||
**目标 A**:修复 ECharts Grid overflow(根因 B)。
|
||||
|
||||
#### 改动 1:`chartOption.grid` 启用 `containLabel: true` 并将边距改为百分比
|
||||
|
||||
```js
|
||||
// ❌ 修改前
|
||||
grid: {
|
||||
left: 60,
|
||||
right: 50,
|
||||
top: 30,
|
||||
bottom: 60,
|
||||
containLabel: false
|
||||
}
|
||||
|
||||
// ✅ 修改后
|
||||
grid: {
|
||||
left: '3%', // 改为百分比,随容器缩放
|
||||
right: '3%',
|
||||
top: 30,
|
||||
bottom: 30, // containLabel: true 后 ECharts 自动为标签留空间,无需预留 60px
|
||||
containLabel: true // 轴标签始终包含在 grid 区域内,不会超出 canvas
|
||||
}
|
||||
```
|
||||
|
||||
**目标 B**:解决 tooltip 被 `overflow: hidden` 裁切(根因 C)。
|
||||
|
||||
#### 改动 2:`v-chart` 的 tooltip 改为挂载到 body
|
||||
|
||||
在 `chartOption.tooltip` 中追加:
|
||||
|
||||
```js
|
||||
tooltip: {
|
||||
// ...现有配置不变...
|
||||
appendToBody: true, // tooltip DOM 挂载到 <body>,完全逃离 overflow: hidden 容器
|
||||
}
|
||||
```
|
||||
|
||||
> 此方案比改 `overflow: visible` 更稳健,不破坏卡片的 `border-radius` 裁切效果。`overflow: hidden` 保留,仅 tooltip 逃逸。
|
||||
|
||||
**目标 C**:稳定图表 canvas 在 flex 链中的高度(根因 A 的组件侧保障)。
|
||||
|
||||
#### 改动 3:`.chart-container` 添加 `position: relative` + `.trend-chart` 使用位置绝对铺满
|
||||
|
||||
为防止在高度链无法修复的回退场景下图表仍能铺满容器:
|
||||
|
||||
```scss
|
||||
// ✅ 修改后
|
||||
.chart-container {
|
||||
padding: 1.35vw 1.25vw 1.25vw;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
position: relative; // 新增:建立定位上下文
|
||||
|
||||
.trend-chart {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 12.8vw; // 保持现有移动端 fallback
|
||||
}
|
||||
}
|
||||
|
||||
// 移动端覆盖(已有,维持原样)
|
||||
@media (max-width: 1024px) {
|
||||
.chart-container .trend-chart {
|
||||
height: 200px;
|
||||
min-height: unset;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 五、全栈影响面分析(Full-Stack Impact Analysis)
|
||||
|
||||
| 层级 | 受影响组件 | 影响类型 | 处理方式 |
|
||||
|------|-----------|----------|----------|
|
||||
| 数据层 | 无 | — | 无需处理 |
|
||||
| API 层 | 无 | — | 无需处理 |
|
||||
| 状态层 | 无 | — | 无需处理 |
|
||||
| 视图组件 | `Dashboard.vue` | 高度策略调整 | 必须修改 |
|
||||
| 子组件 | `LicenseTrendChart.vue` | Grid 配置 + tooltip + overflow | 必须修改 |
|
||||
| 其他页面 | 其他使用 `Layout` 组件的页面 | 可能受 `.content-container` 修改影响 | 修改前审查;若 `.content-container` 属全局 class 则需回归测试 |
|
||||
|
||||
> ⚠️ **注意**:如果 `.content-container` 是全局共用 class(定义在全局 CSS 而非 scoped),则修改会影响所有使用该 class 的页面。需先确认作用域:当前 Dashboard.vue 的 `<style lang="scss" scoped>` 限定了 `.content-container` 只作用于该页面,因此是安全的局部修改。
|
||||
|
||||
---
|
||||
|
||||
## 六、待验证事项(Acceptance Criteria)
|
||||
|
||||
- [ ] 1920×1080 桌面端:图表无溢出,X 轴日期标签完整显示
|
||||
- [ ] 1440×900:图表无溢出,轴标签不被截断
|
||||
- [ ] 1280×800:图表卡片高度稳定,边距正常
|
||||
- [ ] 768px 平板:`.chart-section min-height: 300px` 生效,图表可读
|
||||
- [ ] 375px 手机:固定 200px 高度正常,tooltip 不被卡片边界截断
|
||||
- [ ] 暗模式:tooltip 背景色与轴标签颜色不变
|
||||
- [ ] 快捷选择切换 7 天 / 30 天:图表 resize 正常,无残影溢出
|
||||
|
||||
---
|
||||
|
||||
## 七、文件清单汇总
|
||||
|
||||
### 必须修改
|
||||
1. `frontend/src/views/Dashboard.vue`
|
||||
- SCSS `.content-container`:`min-height` → `height` + 添加 `min-height` 保底
|
||||
|
||||
2. `frontend/src/components/charts/LicenseTrendChart.vue`
|
||||
- JS `chartOption.grid`:`containLabel: false` + 固定 px → `containLabel: true` + `%` 边距
|
||||
- JS `chartOption.tooltip`:新增 `appendToBody: true`
|
||||
- SCSS `.chart-container`:新增 `position: relative`
|
||||
|
||||
### 无需新建
|
||||
本次修复不需要创建任何新文件。
|
||||
|
||||
---
|
||||
|
||||
*本文档由 System Architect 技能生成,作为 task-coder 的实现输入。实现时严格遵循上述契约,禁止超出本文档定义的修改范围。*
|
||||
@@ -25,8 +25,13 @@ jobs:
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '18'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
|
||||
# 设置pnpm
|
||||
- name: Set up pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10
|
||||
run_install: false
|
||||
|
||||
# 缓存node_modules(不缓存构建输出,确保每次都是最新构建)
|
||||
- name: Cache dependencies
|
||||
@@ -34,7 +39,8 @@ jobs:
|
||||
with:
|
||||
path: |
|
||||
frontend/node_modules
|
||||
key: ${{ runner.os }}-frontend-deps-${{ hashFiles('frontend/package-lock.json') }}
|
||||
~/.pnpm-store
|
||||
key: ${{ runner.os }}-frontend-deps-${{ hashFiles('frontend/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-frontend-deps-
|
||||
|
||||
@@ -42,7 +48,7 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
cd frontend
|
||||
npm ci
|
||||
pnpm install --frozen-lockfile
|
||||
|
||||
# 清理旧的构建目录(确保使用最新代码构建)
|
||||
- name: Clean old build artifacts
|
||||
@@ -59,9 +65,9 @@ jobs:
|
||||
export VITE_OUTDIR=production
|
||||
export VITE_ADDRESS_BASE_URL=/production/
|
||||
# 先执行类型检查
|
||||
npm run type-check
|
||||
pnpm run type-check
|
||||
# 然后执行构建
|
||||
npm run build:prod
|
||||
pnpm run build:prod
|
||||
# 检查构建结果
|
||||
echo "Build completed, checking production directory:"
|
||||
ls -la production/
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# Backend only Dockerfile
|
||||
FROM golang:1.23-alpine AS builder
|
||||
WORKDIR /app/backend
|
||||
ENV GOPROXY=https://goproxy.cn,direct
|
||||
COPY backend/go.mod backend/go.sum ./
|
||||
RUN go mod download
|
||||
COPY backend/ ./
|
||||
@@ -14,4 +15,4 @@ COPY backend/configs/ ../configs/
|
||||
# Use production config by default
|
||||
RUN ln -sf ../configs/config.prod.yaml config.yaml
|
||||
EXPOSE 18888
|
||||
CMD ["./license-manager"]
|
||||
CMD ["./license-manager"]
|
||||
@@ -29,11 +29,11 @@
|
||||
✨同时支持**任何开发语言**、**任何类型的软件产品**通过标准化 **HTTP API** 与**跨平台客户端 SDK** **快速集成授权能力**。
|
||||
|
||||
## Demo环境
|
||||
<a href="https://lm.cedar-v.com:18080">
|
||||
<a href="http://lm.cedar-v.com">
|
||||
<img src="https://img.shields.io/badge/Demo-Online-success?logo=googlechrome" alt="Live Demo">
|
||||
</a>
|
||||
|
||||
访问地址:[lm.cedar-v.com:18080](http://lm.cedar-v.com:18080)
|
||||
访问地址:[lm.cedar-v.com](http://lm.cedar-v.com)
|
||||
**登录凭证:**
|
||||
- 用户名:`admin`
|
||||
- 密码:`admin@123`
|
||||
|
||||
@@ -5,11 +5,11 @@ server:
|
||||
|
||||
database:
|
||||
driver: "mysql"
|
||||
host: "mysql"
|
||||
host: "203.2.164.31"
|
||||
port: 3306
|
||||
username: "license_user"
|
||||
password: "license@123"
|
||||
database: "license_manager"
|
||||
username: "license"
|
||||
password: "8X3Yd8hQ4WMPw227"
|
||||
database: "license"
|
||||
charset: "utf8mb4"
|
||||
parse_time: true
|
||||
loc: "Local"
|
||||
|
||||
@@ -0,0 +1,582 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"license-manager/internal/models"
|
||||
"license-manager/internal/repository"
|
||||
"license-manager/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type PublicRenewalHandler struct {
|
||||
licenseRepo repository.LicenseRepository
|
||||
customerRepo repository.CustomerRepository
|
||||
cuUserRepo repository.CuUserRepository
|
||||
packageService service.PackageService
|
||||
cuOrderService service.CuOrderService
|
||||
paymentService service.PaymentService
|
||||
}
|
||||
|
||||
func NewPublicRenewalHandler(
|
||||
licenseRepo repository.LicenseRepository,
|
||||
customerRepo repository.CustomerRepository,
|
||||
cuUserRepo repository.CuUserRepository,
|
||||
packageService service.PackageService,
|
||||
cuOrderService service.CuOrderService,
|
||||
paymentService service.PaymentService,
|
||||
) *PublicRenewalHandler {
|
||||
return &PublicRenewalHandler{
|
||||
licenseRepo: licenseRepo,
|
||||
customerRepo: customerRepo,
|
||||
cuUserRepo: cuUserRepo,
|
||||
packageService: packageService,
|
||||
cuOrderService: cuOrderService,
|
||||
paymentService: paymentService,
|
||||
}
|
||||
}
|
||||
|
||||
type publicAuthCodeQueryRequest struct {
|
||||
AuthorizationCode string `json:"authorization_code" binding:"required"`
|
||||
}
|
||||
|
||||
type publicRenewalOrderRequest struct {
|
||||
AuthorizationCode string `json:"authorization_code" binding:"required"`
|
||||
PackageID string `json:"package_id" binding:"required"`
|
||||
LicenseCount int `json:"license_count" binding:"required,min=1,max=1000"`
|
||||
RenewDays int `json:"renew_days" binding:"required,min=1,max=3650"`
|
||||
PaymentMethod string `json:"payment_method"`
|
||||
ContactName string `json:"contact_name" binding:"required,max=100"`
|
||||
ContactPhone string `json:"contact_phone" binding:"required,min=7,max=20"`
|
||||
ContactEmail string `json:"contact_email" binding:"omitempty,email,max=255"`
|
||||
CompanyName string `json:"company_name" binding:"omitempty,max=200"`
|
||||
}
|
||||
|
||||
type publicPurchaseOrderRequest struct {
|
||||
PackageID string `json:"package_id" binding:"required"`
|
||||
LicenseCount int `json:"license_count" binding:"required,min=1,max=1000"`
|
||||
PaymentMethod string `json:"payment_method"`
|
||||
ContactName string `json:"contact_name" binding:"required,max=100"`
|
||||
ContactPhone string `json:"contact_phone" binding:"required,min=7,max=20"`
|
||||
ContactEmail string `json:"contact_email" binding:"omitempty,email,max=255"`
|
||||
CompanyName string `json:"company_name" binding:"omitempty,max=200"`
|
||||
CustomerType string `json:"customer_type" binding:"omitempty,oneof=individual enterprise government education"`
|
||||
Description string `json:"description" binding:"omitempty,max=500"`
|
||||
}
|
||||
|
||||
// QueryAuthorizationCode 公开查询授权码信息
|
||||
func (h *PublicRenewalHandler) QueryAuthorizationCode(c *gin.Context) {
|
||||
var req publicAuthCodeQueryRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, models.ErrorResponse{
|
||||
Code: "900001",
|
||||
Message: "请求参数错误",
|
||||
Timestamp: getCurrentTimestamp(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
code := strings.TrimSpace(req.AuthorizationCode)
|
||||
authCode, err := h.licenseRepo.GetAuthorizationCodeByCode(c.Request.Context(), code)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, models.ErrorResponse{
|
||||
Code: "300001",
|
||||
Message: "授权码不存在",
|
||||
Timestamp: getCurrentTimestamp(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
customerName := ""
|
||||
contactPerson := ""
|
||||
contactPhone := ""
|
||||
contactEmail := ""
|
||||
|
||||
customer, err := h.customerRepo.GetCustomerByID(c.Request.Context(), authCode.CustomerID)
|
||||
if err == nil {
|
||||
customerName = customer.CustomerName
|
||||
contactPerson = customer.ContactPerson
|
||||
if customer.Phone != nil {
|
||||
contactPhone = *customer.Phone
|
||||
}
|
||||
if customer.Email != nil {
|
||||
contactEmail = *customer.Email
|
||||
}
|
||||
}
|
||||
|
||||
activeCount, _ := h.licenseRepo.GetActiveLicenseCount(c.Request.Context(), authCode.ID)
|
||||
|
||||
status := "normal"
|
||||
now := time.Now()
|
||||
if authCode.IsLocked {
|
||||
status = "locked"
|
||||
} else if now.After(authCode.EndDate) {
|
||||
status = "expired"
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, models.APIResponse{
|
||||
Code: "000000",
|
||||
Message: "success",
|
||||
Data: gin.H{
|
||||
"authorization_code_id": authCode.ID,
|
||||
"authorization_code": authCode.Code,
|
||||
"customer_id": authCode.CustomerID,
|
||||
"customer_name": customerName,
|
||||
"contact_person": contactPerson,
|
||||
"contact_phone": contactPhone,
|
||||
"contact_email": contactEmail,
|
||||
"status": status,
|
||||
"is_locked": authCode.IsLocked,
|
||||
"start_date": authCode.StartDate.Format(time.RFC3339),
|
||||
"end_date": authCode.EndDate.Format(time.RFC3339),
|
||||
"max_activations": authCode.MaxActivations,
|
||||
"active_licenses": activeCount,
|
||||
},
|
||||
Timestamp: getCurrentTimestamp(),
|
||||
})
|
||||
}
|
||||
|
||||
// CreateRenewalOrder 公开创建续期支付单
|
||||
func (h *PublicRenewalHandler) CreateRenewalOrder(c *gin.Context) {
|
||||
var req publicRenewalOrderRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, models.ErrorResponse{
|
||||
Code: "900001",
|
||||
Message: "请求参数错误",
|
||||
Timestamp: getCurrentTimestamp(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
authCode, err := h.licenseRepo.GetAuthorizationCodeByCode(c.Request.Context(), strings.TrimSpace(req.AuthorizationCode))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, models.ErrorResponse{
|
||||
Code: "300001",
|
||||
Message: "授权码不存在",
|
||||
Timestamp: getCurrentTimestamp(),
|
||||
})
|
||||
return
|
||||
}
|
||||
if authCode.IsLocked {
|
||||
c.JSON(http.StatusBadRequest, models.ErrorResponse{
|
||||
Code: "300003",
|
||||
Message: "授权码已锁定,无法续期",
|
||||
Timestamp: getCurrentTimestamp(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
_, err = h.packageService.GetPackageDetail(c.Request.Context(), req.PackageID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, models.ErrorResponse{
|
||||
Code: "800002",
|
||||
Message: "套餐不存在",
|
||||
Timestamp: getCurrentTimestamp(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
priceResult, err := h.cuOrderService.CalculatePrice(c.Request.Context(), req.PackageID, req.LicenseCount)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, models.ErrorResponse{
|
||||
Code: "601004",
|
||||
Message: "价格计算失败",
|
||||
Timestamp: getCurrentTimestamp(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
customer, err := h.customerRepo.GetCustomerByID(c.Request.Context(), authCode.CustomerID)
|
||||
if err == nil {
|
||||
if strings.TrimSpace(req.CompanyName) != "" {
|
||||
customer.CustomerName = strings.TrimSpace(req.CompanyName)
|
||||
}
|
||||
customer.ContactPerson = strings.TrimSpace(req.ContactName)
|
||||
phone := strings.TrimSpace(req.ContactPhone)
|
||||
if phone != "" {
|
||||
customer.Phone = &phone
|
||||
}
|
||||
email := strings.TrimSpace(req.ContactEmail)
|
||||
if email != "" {
|
||||
customer.Email = &email
|
||||
}
|
||||
_ = h.customerRepo.UpdateCustomer(c.Request.Context(), customer)
|
||||
}
|
||||
|
||||
cuUserID, err := h.ensureCuUser(c.Request.Context(), authCode.CustomerID, req.ContactPhone, req.ContactName, req.ContactEmail)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, models.ErrorResponse{
|
||||
Code: "900004",
|
||||
Message: "创建客户用户失败",
|
||||
Timestamp: getCurrentTimestamp(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
renewalExtra := map[string]interface{}{
|
||||
"authorization_code_id": authCode.ID,
|
||||
"renew_days": req.RenewDays,
|
||||
}
|
||||
extraDataBytes, _ := json.Marshal(renewalExtra)
|
||||
extraData := string(extraDataBytes)
|
||||
|
||||
paymentMethod := strings.TrimSpace(req.PaymentMethod)
|
||||
if paymentMethod == "" {
|
||||
paymentMethod = models.PaymentMethodAlipay
|
||||
}
|
||||
|
||||
businessID := authCode.ID
|
||||
payment, err := h.paymentService.CreatePayment(c.Request.Context(), &models.PaymentCreateRequest{
|
||||
BusinessType: models.BusinessTypeLicenseExtend,
|
||||
BusinessID: &businessID,
|
||||
CustomerID: authCode.CustomerID,
|
||||
CuUserID: cuUserID,
|
||||
Amount: priceResult.TotalAmount,
|
||||
Currency: "CNY",
|
||||
PaymentMethod: paymentMethod,
|
||||
ExtraData: &extraData,
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, models.ErrorResponse{
|
||||
Code: "602001",
|
||||
Message: "创建支付单失败",
|
||||
Timestamp: getCurrentTimestamp(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, models.APIResponse{
|
||||
Code: "000000",
|
||||
Message: "success",
|
||||
Data: gin.H{
|
||||
"payment_no": payment.PaymentNo,
|
||||
"payment_url": payment.PaymentURL,
|
||||
"total_amount": payment.Amount,
|
||||
"expire_time": payment.ExpireTime.Format(time.RFC3339),
|
||||
"renew_days": req.RenewDays,
|
||||
},
|
||||
Timestamp: getCurrentTimestamp(),
|
||||
})
|
||||
}
|
||||
|
||||
// CreatePurchaseOrder 公开创建新购支付单
|
||||
func (h *PublicRenewalHandler) CreatePurchaseOrder(c *gin.Context) {
|
||||
var req publicPurchaseOrderRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, models.ErrorResponse{
|
||||
Code: "900001",
|
||||
Message: "请求参数错误",
|
||||
Timestamp: getCurrentTimestamp(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
pkg, err := h.packageService.GetPackageDetail(c.Request.Context(), req.PackageID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, models.ErrorResponse{
|
||||
Code: "800002",
|
||||
Message: "套餐不存在",
|
||||
Timestamp: getCurrentTimestamp(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
priceResult, err := h.cuOrderService.CalculatePrice(c.Request.Context(), req.PackageID, req.LicenseCount)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, models.ErrorResponse{
|
||||
Code: "601004",
|
||||
Message: "价格计算失败",
|
||||
Timestamp: getCurrentTimestamp(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
customerID, cuUserID, err := h.ensureCustomerAndUser(
|
||||
c.Request.Context(),
|
||||
strings.TrimSpace(req.CompanyName),
|
||||
strings.TrimSpace(req.ContactName),
|
||||
strings.TrimSpace(req.ContactPhone),
|
||||
strings.TrimSpace(req.ContactEmail),
|
||||
strings.TrimSpace(req.CustomerType),
|
||||
strings.TrimSpace(req.Description),
|
||||
)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, models.ErrorResponse{
|
||||
Code: "900004",
|
||||
Message: "客户信息写入失败",
|
||||
Timestamp: getCurrentTimestamp(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
orderReq := &models.CuOrderCreateRequest{
|
||||
PackageID: req.PackageID,
|
||||
LicenseCount: req.LicenseCount,
|
||||
PaymentMethod: strings.TrimSpace(req.PaymentMethod),
|
||||
}
|
||||
if orderReq.PaymentMethod == "" {
|
||||
orderReq.PaymentMethod = models.PaymentMethodAlipay
|
||||
}
|
||||
|
||||
if priceResult.TotalAmount == 0 {
|
||||
order, createErr := h.cuOrderService.CreateOrder(c.Request.Context(), cuUserID, customerID, orderReq)
|
||||
if createErr != nil {
|
||||
c.JSON(http.StatusBadRequest, models.ErrorResponse{
|
||||
Code: "601001",
|
||||
Message: "订单创建失败",
|
||||
Timestamp: getCurrentTimestamp(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, models.APIResponse{
|
||||
Code: "000000",
|
||||
Message: "success",
|
||||
Data: gin.H{
|
||||
"order_id": order.ID,
|
||||
"order_no": order.OrderNo,
|
||||
"package_name": pkg.Name,
|
||||
"total_amount": 0,
|
||||
"license_count": req.LicenseCount,
|
||||
"authorization_code": order.AuthorizationCode,
|
||||
},
|
||||
Timestamp: getCurrentTimestamp(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
order, err := h.cuOrderService.CreatePendingOrder(c.Request.Context(), cuUserID, customerID, orderReq)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, models.ErrorResponse{
|
||||
Code: "601001",
|
||||
Message: "订单创建失败",
|
||||
Timestamp: getCurrentTimestamp(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
payment, err := h.paymentService.CreatePayment(c.Request.Context(), &models.PaymentCreateRequest{
|
||||
BusinessType: models.BusinessTypePackageOrder,
|
||||
BusinessID: &order.ID,
|
||||
CustomerID: customerID,
|
||||
CuUserID: cuUserID,
|
||||
Amount: priceResult.TotalAmount,
|
||||
Currency: "CNY",
|
||||
PaymentMethod: orderReq.PaymentMethod,
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, models.ErrorResponse{
|
||||
Code: "602001",
|
||||
Message: "支付单创建失败",
|
||||
Timestamp: getCurrentTimestamp(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, models.APIResponse{
|
||||
Code: "000000",
|
||||
Message: "success",
|
||||
Data: gin.H{
|
||||
"order_id": order.ID,
|
||||
"order_no": order.OrderNo,
|
||||
"package_name": pkg.Name,
|
||||
"payment_no": payment.PaymentNo,
|
||||
"payment_url": payment.PaymentURL,
|
||||
"total_amount": payment.Amount,
|
||||
"expire_time": payment.ExpireTime.Format(time.RFC3339),
|
||||
"license_count": req.LicenseCount,
|
||||
},
|
||||
Timestamp: getCurrentTimestamp(),
|
||||
})
|
||||
}
|
||||
|
||||
// GetPublicPaymentStatus 公开支付状态查询
|
||||
func (h *PublicRenewalHandler) GetPublicPaymentStatus(c *gin.Context) {
|
||||
paymentNo := strings.TrimSpace(c.Param("payment_no"))
|
||||
if paymentNo == "" {
|
||||
c.JSON(http.StatusBadRequest, models.ErrorResponse{
|
||||
Code: "900001",
|
||||
Message: "支付单号不能为空",
|
||||
Timestamp: getCurrentTimestamp(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
statusResp, err := h.paymentService.GetPaymentStatus(c.Request.Context(), paymentNo)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, models.ErrorResponse{
|
||||
Code: "602002",
|
||||
Message: "支付单不存在",
|
||||
Timestamp: getCurrentTimestamp(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
payment, err := h.paymentService.GetPayment(c.Request.Context(), paymentNo)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, models.ErrorResponse{
|
||||
Code: "602002",
|
||||
Message: "支付单不存在",
|
||||
Timestamp: getCurrentTimestamp(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
authCodeID := ""
|
||||
endDate := ""
|
||||
if payment.ExtraData != nil && strings.TrimSpace(*payment.ExtraData) != "" {
|
||||
var renewalData struct {
|
||||
AuthorizationCodeID string `json:"authorization_code_id"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(*payment.ExtraData), &renewalData); err == nil {
|
||||
authCodeID = renewalData.AuthorizationCodeID
|
||||
if strings.TrimSpace(authCodeID) != "" {
|
||||
authCode, err := h.licenseRepo.GetAuthorizationCodeByID(c.Request.Context(), authCodeID)
|
||||
if err == nil {
|
||||
endDate = authCode.EndDate.Format(time.RFC3339)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, models.APIResponse{
|
||||
Code: "000000",
|
||||
Message: "success",
|
||||
Data: gin.H{
|
||||
"payment_no": statusResp.PaymentNo,
|
||||
"status": statusResp.Status,
|
||||
"amount": statusResp.Amount,
|
||||
"payment_time": statusResp.PaymentTime,
|
||||
"trade_no": statusResp.TradeNo,
|
||||
"authorization_code_id": authCodeID,
|
||||
"authorization_end_date": endDate,
|
||||
"business_order": statusResp.BusinessOrder,
|
||||
},
|
||||
Timestamp: getCurrentTimestamp(),
|
||||
})
|
||||
}
|
||||
|
||||
func (h *PublicRenewalHandler) ensureCuUser(ctx context.Context, customerID, phone, contactName, contactEmail string) (string, error) {
|
||||
countryCode := "+86"
|
||||
trimmedPhone := strings.TrimSpace(phone)
|
||||
user, err := h.cuUserRepo.GetByPhone(trimmedPhone, countryCode)
|
||||
if err == nil {
|
||||
return user.ID, nil
|
||||
}
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return "", err
|
||||
}
|
||||
|
||||
var realName *string
|
||||
name := strings.TrimSpace(contactName)
|
||||
if name != "" {
|
||||
realName = &name
|
||||
}
|
||||
var email *string
|
||||
trimmedEmail := strings.TrimSpace(contactEmail)
|
||||
if trimmedEmail != "" {
|
||||
email = &trimmedEmail
|
||||
}
|
||||
|
||||
newUser := &models.CuUser{
|
||||
CustomerID: customerID,
|
||||
Phone: trimmedPhone,
|
||||
PhoneCountryCode: countryCode,
|
||||
UserRole: "member",
|
||||
RealName: realName,
|
||||
Email: email,
|
||||
Status: "active",
|
||||
PhoneVerified: true,
|
||||
EmailVerified: false,
|
||||
Language: "zh-CN",
|
||||
Timezone: "Asia/Shanghai",
|
||||
}
|
||||
if err := h.cuUserRepo.Create(newUser); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return newUser.ID, nil
|
||||
}
|
||||
|
||||
func (h *PublicRenewalHandler) ensureCustomerAndUser(
|
||||
ctx context.Context,
|
||||
companyName, contactName, phone, email, customerType, description string,
|
||||
) (string, string, error) {
|
||||
countryCode := "+86"
|
||||
trimmedPhone := strings.TrimSpace(phone)
|
||||
existingUser, err := h.cuUserRepo.GetByPhone(trimmedPhone, countryCode)
|
||||
if err == nil {
|
||||
customer, customerErr := h.customerRepo.GetCustomerByID(ctx, existingUser.CustomerID)
|
||||
if customerErr == nil {
|
||||
if companyName != "" {
|
||||
customer.CustomerName = companyName
|
||||
}
|
||||
if contactName != "" {
|
||||
customer.ContactPerson = contactName
|
||||
}
|
||||
if trimmedPhone != "" {
|
||||
customer.Phone = &trimmedPhone
|
||||
}
|
||||
if email != "" {
|
||||
customer.Email = &email
|
||||
}
|
||||
if description != "" {
|
||||
customer.Description = &description
|
||||
}
|
||||
_ = h.customerRepo.UpdateCustomer(ctx, customer)
|
||||
}
|
||||
return existingUser.CustomerID, existingUser.ID, nil
|
||||
}
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
ct := customerType
|
||||
if ct == "" {
|
||||
ct = "enterprise"
|
||||
}
|
||||
customerName := companyName
|
||||
if customerName == "" {
|
||||
customerName = contactName
|
||||
}
|
||||
if customerName == "" {
|
||||
customerName = trimmedPhone
|
||||
}
|
||||
contactPerson := contactName
|
||||
if contactPerson == "" {
|
||||
contactPerson = trimmedPhone
|
||||
}
|
||||
|
||||
customer := &models.Customer{
|
||||
CustomerName: customerName,
|
||||
CustomerType: ct,
|
||||
ContactPerson: contactPerson,
|
||||
CustomerLevel: "basic",
|
||||
Status: "active",
|
||||
CreatedBy: "public-order",
|
||||
}
|
||||
if trimmedPhone != "" {
|
||||
customer.Phone = &trimmedPhone
|
||||
}
|
||||
if email != "" {
|
||||
customer.Email = &email
|
||||
}
|
||||
if description != "" {
|
||||
customer.Description = &description
|
||||
}
|
||||
if err := h.customerRepo.CreateCustomer(ctx, customer); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
cuUserID, err := h.ensureCuUser(ctx, customer.ID, trimmedPhone, contactName, email)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
return customer.ID, cuUserID, nil
|
||||
}
|
||||
@@ -121,6 +121,7 @@ func SetupRouter() *gin.Engine {
|
||||
packageHandler := handlers.NewPackageHandler(packageService)
|
||||
leadService := service.NewLeadService(leadRepo, db)
|
||||
leadHandler := handlers.NewLeadHandler(leadService)
|
||||
publicRenewalHandler := handlers.NewPublicRenewalHandler(licenseRepo, customerRepo, cuUserRepo, packageService, cuOrderService, paymentService)
|
||||
|
||||
// 健康检测接口(无需认证)
|
||||
router.GET("/health", systemHandler.HealthCheck)
|
||||
@@ -151,6 +152,10 @@ func SetupRouter() *gin.Engine {
|
||||
|
||||
// 公共发票下载接口
|
||||
public.GET("/public/invoices/download", adminInvoiceHandler.DownloadByToken)
|
||||
public.POST("/public/authorization-codes/query", publicRenewalHandler.QueryAuthorizationCode)
|
||||
public.POST("/public/orders", publicRenewalHandler.CreatePurchaseOrder)
|
||||
public.POST("/public/renewals/order", publicRenewalHandler.CreateRenewalOrder)
|
||||
public.GET("/public/payment/:payment_no/status", publicRenewalHandler.GetPublicPaymentStatus)
|
||||
|
||||
// 线索收集(无需鉴权)
|
||||
public.POST("/leads", leadHandler.CreateLead)
|
||||
|
||||
@@ -169,19 +169,30 @@ type HeartbeatResponse struct {
|
||||
HeartbeatInterval int `json:"heartbeat_interval"` // 下次心跳间隔(秒)
|
||||
}
|
||||
|
||||
// StatsOverviewResponse 授权概览统计响应结构
|
||||
// StatsOverviewResponse stats overview API response
|
||||
type StatsOverviewResponse struct {
|
||||
TotalAuthCodes int64 `json:"total_auth_codes"` // 总授权码数量(所有授权码的累计总数)
|
||||
ActiveLicenses int64 `json:"active_licenses"` // 活跃许可证数量(状态为active的许可证总数)
|
||||
ExpiringSoon int64 `json:"expiring_soon"` // 即将过期数量(30天内即将过期的授权码,且未被锁定)
|
||||
AbnormalAlerts int64 `json:"abnormal_alerts"` // 异常告警数量(活跃许可证中心跳超时的数量,超过配置的心跳超时时间未收到心跳)
|
||||
GrowthRate GrowthRate `json:"growth_rate"` // 增长率(同比上月)
|
||||
// Stock metrics
|
||||
TotalAuthCodes int64 `json:"total_auth_codes"` // total authorization codes ever created
|
||||
ActiveLicenses int64 `json:"active_licenses"` // licenses with status=active
|
||||
|
||||
// Flow metrics
|
||||
TodayNewLicenses int64 `json:"today_new_licenses"` // licenses created today
|
||||
YesterdayNewLicenses int64 `json:"yesterday_new_licenses"` // licenses created yesterday (for comparison)
|
||||
MonthNewAuthCodes int64 `json:"month_new_auth_codes"` // auth codes created this calendar month
|
||||
|
||||
// Risk metrics
|
||||
ExpiringIn7Days int64 `json:"expiring_in_7days"` // auth codes expiring within 7 days, not locked
|
||||
ExpiringIn30Days int64 `json:"expiring_in_30days"` // auth codes expiring within 30 days, not locked
|
||||
AbnormalAlerts int64 `json:"abnormal_alerts"` // active licenses with heartbeat timeout
|
||||
|
||||
// Growth rates shown as sub-text, not standalone cards
|
||||
GrowthRate GrowthRate `json:"growth_rate"`
|
||||
}
|
||||
|
||||
// GrowthRate 增长率结构(同比上月:当前值相比一个月前同一时刻值的增长率)
|
||||
// GrowthRate month-over-month growth rates (shown as sub-text on dashboard cards)
|
||||
type GrowthRate struct {
|
||||
AuthCodes float64 `json:"auth_codes"` // 授权码增长率(%)(当前总授权码数相比一个月前总授权码数的增长率)
|
||||
Licenses float64 `json:"licenses"` // 许可证增长率(%)(当前活跃许可证数相比一个月前活跃许可证数的增长率)
|
||||
AuthCodesMoM float64 `json:"auth_codes_mom"` // auth codes MoM growth rate (%)
|
||||
LicensesMoM float64 `json:"licenses_mom"` // active licenses MoM growth rate (%)
|
||||
}
|
||||
|
||||
// DeviceListRequest 设备列表查询请求结构(客户用户接口)
|
||||
|
||||
@@ -61,6 +61,7 @@ type PaymentCreateRequest struct {
|
||||
Amount float64 `json:"amount" binding:"required,min=0.01"`
|
||||
Currency string `json:"currency"`
|
||||
PaymentMethod string `json:"payment_method"`
|
||||
ExtraData *string `json:"extra_data,omitempty"`
|
||||
}
|
||||
|
||||
// PaymentResponse 支付响应结构
|
||||
|
||||
@@ -716,76 +716,99 @@ func parseJSONField(raw models.JSON) map[string]interface{} {
|
||||
return result
|
||||
}
|
||||
|
||||
// GetStatsOverview 获取授权概览统计
|
||||
// GetStatsOverview returns dashboard overview statistics
|
||||
func (s *licenseService) GetStatsOverview(ctx context.Context) (*models.StatsOverviewResponse, error) {
|
||||
lang := pkgcontext.GetLanguageFromContext(ctx)
|
||||
|
||||
// 获取配置信息
|
||||
// Load heartbeat timeout config
|
||||
cfg := config.GetConfig()
|
||||
offlineTimeout := time.Duration(cfg.License.HeartbeatTimeout) * time.Second
|
||||
|
||||
// 计算时间点
|
||||
now := time.Now()
|
||||
onlineThreshold := now.Add(-offlineTimeout)
|
||||
|
||||
// Time boundaries
|
||||
todayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
|
||||
yesterdayStart := todayStart.AddDate(0, 0, -1)
|
||||
monthStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location())
|
||||
expire7d := now.AddDate(0, 0, 7)
|
||||
expire30d := now.AddDate(0, 0, 30)
|
||||
lastMonth := now.AddDate(0, -1, 0)
|
||||
|
||||
var stats models.StatsOverviewResponse
|
||||
|
||||
// 执行统计查询
|
||||
err := s.db.Transaction(func(tx *gorm.DB) error {
|
||||
// 1. 总授权码数量
|
||||
// 1. Total auth codes (stock)
|
||||
if err := tx.Model(&models.AuthorizationCode{}).Count(&stats.TotalAuthCodes).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 2. 活跃许可证数量(状态为active)
|
||||
// 2. Active licenses (stock)
|
||||
if err := tx.Model(&models.License{}).Where("status = ?", "active").Count(&stats.ActiveLicenses).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 3. 即将过期数量(授权码在30天内过期,且状态为active)
|
||||
expireThreshold := now.AddDate(0, 0, 30)
|
||||
if err := tx.Model(&models.AuthorizationCode{}).
|
||||
Where("end_date <= ? AND end_date > ? AND is_locked = false", expireThreshold, now).
|
||||
Count(&stats.ExpiringSoon).Error; err != nil {
|
||||
// 3. Licenses created today (flow)
|
||||
if err := tx.Model(&models.License{}).Where("created_at >= ?", todayStart).Count(&stats.TodayNewLicenses).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 4. 异常告警数量(许可证超时未心跳)
|
||||
// 4. Licenses created yesterday (flow, for comparison)
|
||||
if err := tx.Model(&models.License{}).Where("created_at >= ? AND created_at < ?", yesterdayStart, todayStart).Count(&stats.YesterdayNewLicenses).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 5. Auth codes created this calendar month (flow)
|
||||
if err := tx.Model(&models.AuthorizationCode{}).Where("created_at >= ?", monthStart).Count(&stats.MonthNewAuthCodes).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 6. Expiring within 7 days (risk, not locked)
|
||||
if err := tx.Model(&models.AuthorizationCode{}).
|
||||
Where("end_date <= ? AND end_date > ? AND is_locked = false", expire7d, now).
|
||||
Count(&stats.ExpiringIn7Days).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 7. Expiring within 30 days (risk, not locked)
|
||||
if err := tx.Model(&models.AuthorizationCode{}).
|
||||
Where("end_date <= ? AND end_date > ? AND is_locked = false", expire30d, now).
|
||||
Count(&stats.ExpiringIn30Days).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 8. Abnormal alerts: active licenses with heartbeat timeout
|
||||
if err := tx.Model(&models.License{}).
|
||||
Where("status = 'active' AND (last_heartbeat < ? OR last_heartbeat IS NULL)", onlineThreshold).
|
||||
Count(&stats.AbnormalAlerts).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 5. 计算同比上月的授权码和许可证数量(用于计算增长率)
|
||||
// 9. MoM growth rates (sub-text only, not standalone cards)
|
||||
var lastMonthAuthCodes, lastMonthActiveLicenses int64
|
||||
|
||||
// 上月同期授权码总数(一个月前同一时刻的累计总数)
|
||||
if err := tx.Model(&models.AuthorizationCode{}).
|
||||
Where("created_at <= ?", lastMonth).
|
||||
Count(&lastMonthAuthCodes).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 上月同期活跃许可证总数(一个月前同一时刻的状态为active的许可证数)
|
||||
if err := tx.Model(&models.License{}).
|
||||
Where("status = ? AND created_at <= ?", "active", lastMonth).
|
||||
Count(&lastMonthActiveLicenses).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 计算增长率(同比上月:当前总数相比一个月前总数的增长率)
|
||||
if lastMonthAuthCodes > 0 {
|
||||
stats.GrowthRate.AuthCodes = float64(stats.TotalAuthCodes-lastMonthAuthCodes) / float64(lastMonthAuthCodes) * 100
|
||||
} else {
|
||||
stats.GrowthRate.AuthCodes = 0
|
||||
stats.GrowthRate.AuthCodesMoM = float64(stats.TotalAuthCodes-lastMonthAuthCodes) / float64(lastMonthAuthCodes) * 100
|
||||
} else if stats.TotalAuthCodes > 0 {
|
||||
stats.GrowthRate.AuthCodesMoM = 100.0
|
||||
}
|
||||
|
||||
if lastMonthActiveLicenses > 0 {
|
||||
stats.GrowthRate.Licenses = float64(stats.ActiveLicenses-lastMonthActiveLicenses) / float64(lastMonthActiveLicenses) * 100
|
||||
} else {
|
||||
stats.GrowthRate.Licenses = 0
|
||||
stats.GrowthRate.LicensesMoM = float64(stats.ActiveLicenses-lastMonthActiveLicenses) / float64(lastMonthActiveLicenses) * 100
|
||||
} else if stats.ActiveLicenses > 0 {
|
||||
stats.GrowthRate.LicensesMoM = 100.0
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
@@ -127,6 +127,7 @@ func (s *paymentService) CreatePayment(ctx context.Context, req *models.PaymentC
|
||||
PaymentProvider: req.PaymentMethod,
|
||||
Status: models.PaymentStatusPending,
|
||||
ExpireTime: expireTime,
|
||||
ExtraData: req.ExtraData,
|
||||
}
|
||||
|
||||
// 如果是支付宝,生成支付URL
|
||||
@@ -279,6 +280,42 @@ func (s *paymentService) ProcessAlipayCallback(ctx context.Context, values url.V
|
||||
}
|
||||
|
||||
// 支付成功:根据业务类型处理
|
||||
if payment.BusinessType == models.BusinessTypeLicenseExtend {
|
||||
if payment.ExtraData == nil || strings.TrimSpace(*payment.ExtraData) == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
var renewalData struct {
|
||||
AuthorizationCodeID string `json:"authorization_code_id"`
|
||||
RenewDays int `json:"renew_days"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(*payment.ExtraData), &renewalData); err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(renewalData.AuthorizationCodeID) == "" || renewalData.RenewDays <= 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var authCode models.AuthorizationCode
|
||||
if err := tx.Where("id = ?", renewalData.AuthorizationCodeID).First(&authCode).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
baseDate := authCode.EndDate
|
||||
now := time.Now()
|
||||
if baseDate.Before(now) {
|
||||
baseDate = now
|
||||
}
|
||||
newEndDate := baseDate.AddDate(0, 0, renewalData.RenewDays)
|
||||
|
||||
return tx.Model(&models.AuthorizationCode{}).
|
||||
Where("id = ?", authCode.ID).
|
||||
Updates(map[string]interface{}{
|
||||
"end_date": newEndDate,
|
||||
"updated_at": now,
|
||||
}).Error
|
||||
}
|
||||
|
||||
if payment.BusinessType != models.BusinessTypePackageOrder || payment.BusinessID == nil || strings.TrimSpace(*payment.BusinessID) == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
backend:
|
||||
image: ${REGISTRY:-localhost:9988}/${NAMESPACE:-leeexin}/license-manager-backend:${VERSION:-latest}
|
||||
container_name: license-manager-backend
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- TZ=Asia/Shanghai
|
||||
- GIN_MODE=release
|
||||
ports:
|
||||
- "18888:18888"
|
||||
volumes:
|
||||
- ./backend/configs:/app/backend/configs
|
||||
- ./logs:/app/logs
|
||||
- ./backend/configs/config.prod.yaml:/app/backend/cmd/config.yaml:ro
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "curl -fsS http://localhost:18888/health || exit 1"]
|
||||
interval: 10s
|
||||
timeout: 10s
|
||||
retries: 6
|
||||
start_period: 60s
|
||||
|
||||
frontend:
|
||||
image: ${REGISTRY:-localhost:9988}/${NAMESPACE:-leeexin}/license-manager-frontend:${VERSION:-latest}
|
||||
container_name: license-manager-frontend
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- TZ=Asia/Shanghai
|
||||
ports:
|
||||
- "18080:80"
|
||||
depends_on:
|
||||
backend:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "curl -fsS http://localhost/ || exit 1"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
@@ -0,0 +1,35 @@
|
||||
# Docker 镜像推送说明
|
||||
|
||||
项目已经补充了镜像推送脚本 `scripts/docker-push.sh`,默认面向本地 Gitea/私有仓库地址 `localhost:9988`。
|
||||
|
||||
## 1. 登录镜像仓库
|
||||
|
||||
```bash
|
||||
docker login localhost:9988
|
||||
```
|
||||
|
||||
如果你的镜像仓库命名空间不是 `leeexin`,推送前修改环境变量 `NAMESPACE`。
|
||||
|
||||
## 2. 构建并推送镜像
|
||||
|
||||
```bash
|
||||
chmod +x scripts/docker-push.sh
|
||||
REGISTRY=localhost:9988 NAMESPACE=leeexin VERSION=v1.1.1 ./scripts/docker-push.sh
|
||||
```
|
||||
|
||||
推送完成后会得到两份镜像:
|
||||
|
||||
- `localhost:9988/leeexin/license-manager-backend:v1.1.1`
|
||||
- `localhost:9988/leeexin/license-manager-frontend:v1.1.1`
|
||||
|
||||
## 3. 使用推送后的镜像部署
|
||||
|
||||
```bash
|
||||
REGISTRY=localhost:9988 NAMESPACE=leeexin VERSION=v1.1.1 docker compose -f docker-compose.registry.yml up -d
|
||||
```
|
||||
|
||||
## 4. 支付配置提醒
|
||||
|
||||
- 生产环境镜像启动时,支付配置以 `backend/configs/config.prod.yaml` 为准。
|
||||
- 当前已经保留支付宝正式环境字段,请把 `app_id`、密钥、回调域名替换为你的正式配置。
|
||||
- 免登录购买与续期入口为 `/public-renewal`,支付成功后可通过授权码查询和续期结果页面查看状态。
|
||||
|
Before Width: | Height: | Size: 133 KiB After Width: | Height: | Size: 164 KiB |
|
Before Width: | Height: | Size: 364 KiB After Width: | Height: | Size: 485 KiB |
|
Before Width: | Height: | Size: 474 KiB After Width: | Height: | Size: 283 KiB |
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 1.7 MiB |
@@ -14,8 +14,8 @@ cd license-manager
|
||||
# 查看当前远程仓库(应该只有 origin 指向开源项目)
|
||||
git remote -v
|
||||
|
||||
# 添加你的私有仓库(命名为 private)
|
||||
git remote add private https://github.com/cedar-v/license-manager-sszn.git
|
||||
# 添加你的私有仓库(命名为 enterprise)
|
||||
git remote add enterprise https://github.com/cedar-v/license-manager-enterprise.git
|
||||
|
||||
# 验证(现在应该有两个远程仓库)
|
||||
git remote -v
|
||||
@@ -25,8 +25,8 @@ git remote -v
|
||||
```
|
||||
origin https://github.com/cedar-v/license-manager.git (fetch)
|
||||
origin https://github.com/cedar-v/license-manager.git (push)
|
||||
private https://github.com/cedar-v/license-manager-sszn.git (fetch)
|
||||
private https://github.com/cedar-v/license-manager-sszn.git (push)
|
||||
enterprise https://github.com/cedar-v/license-manager-enterprise.git (fetch)
|
||||
enterprise https://github.com/cedar-v/license-manager-enterprise.git (push)
|
||||
```
|
||||
|
||||
### 第三步:创建并切换到 license-manager 分支
|
||||
@@ -35,7 +35,7 @@ private https://github.com/cedar-v/license-manager-sszn.git (push)
|
||||
git checkout -b license-manager
|
||||
|
||||
# 推送到私有仓库
|
||||
git push -u private license-manager
|
||||
git push -u enterprise license-manager
|
||||
```
|
||||
|
||||
---
|
||||
@@ -55,7 +55,7 @@ git pull origin main
|
||||
# git pull origin master
|
||||
|
||||
# 3. 推送到你的私有仓库
|
||||
git push private license-manager
|
||||
git push enterprise license-manager
|
||||
```
|
||||
|
||||
---
|
||||
@@ -69,6 +69,6 @@ git push private license-manager
|
||||
#!/bin/bash
|
||||
git checkout license-manager
|
||||
git pull origin main
|
||||
git push private license-manager
|
||||
git push enterprise license-manager
|
||||
echo "同步完成!"
|
||||
```
|
||||
@@ -0,0 +1,163 @@
|
||||
# 技术契约与架构设计书:Dashboard 统计卡片重设计
|
||||
|
||||
**版本**:v1.0
|
||||
**日期**:2026-03-13
|
||||
**作者**:system-architect
|
||||
**状态**:待评审
|
||||
|
||||
---
|
||||
|
||||
## 一、现状诊断
|
||||
|
||||
### 当前 API 响应结构
|
||||
|
||||
```json
|
||||
{
|
||||
"total_auth_codes": 12,
|
||||
"active_licenses": 2,
|
||||
"expiring_soon": 0,
|
||||
"abnormal_alerts": 2,
|
||||
"growth_rate": {
|
||||
"auth_codes": 100.00,
|
||||
"licenses": 0.00
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 现存问题清单
|
||||
|
||||
| # | 问题 | 严重性 |
|
||||
|---|------|--------|
|
||||
| 1 | `growth_rate` 语义不清——是"同比"上月还是"环比"?无时间标注 | 高 |
|
||||
| 2 | 两个增长率字段(auth_codes / licenses)对用户的业务价值极低,不知道要对比什么 | 高 |
|
||||
| 3 | 前端 `statsData` 中的 `key` 映射与 i18n label 不一致(注释与实际label错位) | 中 |
|
||||
| 4 | `expiring_soon` 统计的是"30天内到期的授权码",但卡片标签写的是"即将过期",口径模糊 | 中 |
|
||||
| 5 | 没有"今日新增"这类高频关注的运营指标 | 中 |
|
||||
| 6 | 增长率算法缺陷:上月总数为0时直接返回0而非100% | 低 |
|
||||
|
||||
---
|
||||
|
||||
## 二、竞品分析(License Management SaaS 参考)
|
||||
|
||||
### Keygen.sh / LicenseSpring / Cryptlex 共性 Dashboard 指标
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ 高频关注(运营视角) │
|
||||
│ • 今日新增授权码 / 本月新增授权码(绝对数 + 环比昨日/上月)│
|
||||
│ • 活跃设备数(在线 License) │
|
||||
│ • 即将到期(7天 / 30天)→ 需要跟进续费 │
|
||||
│ • 异常告警(心跳超时 / 被撤销) │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ 中频关注(管理视角) │
|
||||
│ • 授权码总量(存量指标) │
|
||||
│ • 活跃许可证总量(存量指标) │
|
||||
│ • 月度增长率(MoM Growth Rate,附说明文字"vs 上月") │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 关键竞品设计原则
|
||||
|
||||
1. **增长率必须标注对比周期**:统一用「vs. 上月同期」,数字旁展示箭头 ↑ ↓ 和颜色区分正负
|
||||
2. **增长率不放卡片主数值**:作为副文本展示(小字 + 颜色),主数值展示绝对数
|
||||
3. **即将到期细分**:7天 为紧急(红),30天 为预警(橙)
|
||||
4. **今日新增**:运营最关注的高频指标,竞品均在首位展示
|
||||
|
||||
---
|
||||
|
||||
## 三、重设计方案
|
||||
|
||||
### 3.1 新指标体系(6张卡片)
|
||||
|
||||
| 位置 | 指标名 | 计算口径 | 副文本 |
|
||||
|------|--------|---------|--------|
|
||||
| 卡片1 | 授权码总量 | `COUNT(authorization_codes)` | ↑ 本月新增 N 个 |
|
||||
| 卡片2 | 活跃许可证 | `COUNT(licenses WHERE status='active')` | ↑ 较上月 +X% |
|
||||
| 卡片3 | 今日新增授权 | `COUNT(licenses WHERE DATE(created_at)=TODAY)` | 昨日 N 个 |
|
||||
| 卡片4 | 7天内到期 | `COUNT(auth_codes WHERE end_date BETWEEN now AND now+7d AND NOT locked)` | 紧急跟进 |
|
||||
| 卡片5 | 30天内到期 | `COUNT(auth_codes WHERE end_date BETWEEN now AND now+30d AND NOT locked)` | 含7天内 |
|
||||
| 卡片6 | 异常告警 | `COUNT(licenses WHERE status='active' AND heartbeat_timeout)` | 心跳超时 |
|
||||
|
||||
> 删除:`auth_codes 增长率`、`licenses 增长率` 作为独立卡片(改为副文本方式展示)
|
||||
|
||||
### 3.2 增长率展示方式(副文本,不占卡片)
|
||||
|
||||
```
|
||||
┌──────────────────────────┐
|
||||
│ 🔑 授权码总量 │
|
||||
│ 12 │
|
||||
│ ↑ 本月新增 2 个 │ ← 副文本:绝对增量
|
||||
└──────────────────────────┘
|
||||
|
||||
┌──────────────────────────┐
|
||||
│ ✅ 活跃许可证 │
|
||||
│ 98 │
|
||||
│ ↑ +12.5% vs. 上月 │ ← 副文本:百分比增长率 + 标注
|
||||
└──────────────────────────┘
|
||||
```
|
||||
|
||||
### 3.3 新 API Response Schema
|
||||
|
||||
```go
|
||||
// StatsOverviewResponse 重设计
|
||||
type StatsOverviewResponse struct {
|
||||
// 存量指标
|
||||
TotalAuthCodes int64 `json:"total_auth_codes"` // 授权码总量
|
||||
ActiveLicenses int64 `json:"active_licenses"` // 活跃许可证
|
||||
|
||||
// 流量指标
|
||||
TodayNewLicenses int64 `json:"today_new_licenses"` // 今日新增许可证
|
||||
YesterdayNewLicenses int64 `json:"yesterday_new_licenses"` // 昨日新增(用于对比)
|
||||
MonthNewAuthCodes int64 `json:"month_new_auth_codes"` // 本月新增授权码
|
||||
|
||||
// 风险指标
|
||||
ExpiringIn7Days int64 `json:"expiring_in_7days"` // 7天内到期
|
||||
ExpiringIn30Days int64 `json:"expiring_in_30days"` // 30天内到期
|
||||
AbnormalAlerts int64 `json:"abnormal_alerts"` // 异常告警
|
||||
|
||||
// 增长率(作为副文本,不占独立卡片)
|
||||
GrowthRate struct {
|
||||
AuthCodesMoM float64 `json:"auth_codes_mom"` // 授权码月环比
|
||||
LicensesMoM float64 `json:"licenses_mom"` // 许可证月环比
|
||||
} `json:"growth_rate"`
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 四、影响面分析(全栈)
|
||||
|
||||
### 后端改动文件
|
||||
|
||||
| 文件 | 操作 | 说明 |
|
||||
|------|------|------|
|
||||
| `backend/internal/models/license.go` | 修改 | 更新 `StatsOverviewResponse` struct |
|
||||
| `backend/internal/service/license_service.go` | 修改 | 重写 `GetStatsOverview()` 实现,增加今日/昨日新增查询 |
|
||||
| `backend/internal/api/handlers/license_handler.go` | 无需修改 | Handler 层直接透传 |
|
||||
|
||||
### 前端改动文件
|
||||
|
||||
| 文件 | 操作 | 说明 |
|
||||
|------|------|------|
|
||||
| `frontend/src/views/Dashboard.vue` | 修改 | 重新定义 `statsData` 6张卡片,增加副文本字段渲染 |
|
||||
| `frontend/src/i18n/locales/zh.json` | 修改 | 更新 `dashboard.stats.*` key |
|
||||
| `frontend/src/i18n/locales/en.json` | 修改 | 同上 |
|
||||
| `frontend/src/i18n/locales/ja.json` | 修改 | 同上 |
|
||||
| `frontend/src/api/dashboard.ts` | 修改 | 更新 `getOverviewStats` 返回类型定义 |
|
||||
|
||||
---
|
||||
|
||||
## 五、实施优先级建议
|
||||
|
||||
**P0(立即)**:
|
||||
- 重命名"即将过期"为"30天内到期",语义清晰化
|
||||
- 增长率卡片改为副文本方式(不移除数据,只改展示层)
|
||||
- 增长率数字旁加"vs. 上月"标注
|
||||
|
||||
**P1(本迭代)**:
|
||||
- 新增"今日新增"卡片(需要后端加一个 COUNT 查询)
|
||||
- "即将过期"拆分7天/30天两档
|
||||
|
||||
**P2(下迭代)**:
|
||||
- 卡片支持点击跳转到过滤后的列表页
|
||||
@@ -0,0 +1,133 @@
|
||||
# 技术契约与架构设计书:前端加载性能优化
|
||||
|
||||
**版本**:v1.0
|
||||
**日期**:2026-03-11
|
||||
**作者**:system-architect
|
||||
**状态**:待 task-coder 实施
|
||||
|
||||
---
|
||||
|
||||
## 一、问题诊断(根因分析)
|
||||
|
||||
### 1.1 每次页面跳转都重新加载 Element Plus CSS
|
||||
|
||||
**根因**:`vite.config.ts` 中 `ElementPlusResolver({ importStyle: 'css' })` 配置让 `unplugin-vue-components` 在每个使用到 Element Plus 组件的 `.vue` 文件中注入独立的 CSS import(`el-dropdown-menu.css`、`el-button-group.css` 等)。与此同时 `main.ts` 还保留了 `import 'element-plus/dist/index.css'`(全量 CSS),两者并存形成**双重加载**。
|
||||
|
||||
**表现**:网络面板中看到 `el-dropdown-menu.css`、`el-button-group.css`、`element_plus_es_components_loading_s...` 等碎片 CSS 在每次路由切换时被重新请求。
|
||||
|
||||
**正确做法**:二选一——
|
||||
- **方案 A(推荐)**:继续使用 `element-plus/dist/index.css` 全量 CSS(已在 main.ts 中),关闭 resolver 的 `importStyle`。全量 CSS 首次加载后长期缓存,零碎的 per-component CSS 完全消失。
|
||||
- 方案 B:彻底删除 `element-plus/dist/index.css`,完全依赖 resolver 按需注入。需要确保每个组件都被 resolver 覆盖,否则样式丢失。
|
||||
|
||||
### 1.2 字体每次导航重复加载(视觉上的误判)
|
||||
|
||||
**根因**:浏览器缓存策略问题。`@fontsource/noto-sans-sc` 输出的 woff2 文件名**不含内容 hash**(如 `noto-sans-sc-116-400-normal.woff2`),而 Vite dev 服务器对字体文件**不设置强缓存 header**(`Cache-Control: no-cache`)。结果是浏览器每次导航都发起带 If-None-Match 的条件请求,即使返回 304,devtools 也会显示为"加载"。
|
||||
|
||||
**生产环境额外问题**:vite.config.ts 的 `assetFileNames` 对字体匹配正确会加 hash,但 `@fontsource` 的 CSS 中 `src` 路径是相对路径,构建时 Vite 自动处理后会有 hash,缓存正常。
|
||||
|
||||
### 1.3 PangMenZhengDao.ttf:1004 KB,仅 LicenseSearch.vue 使用
|
||||
|
||||
这是最严重的单点问题。一个装饰性标题字体(仅 `LicenseSearch.vue` 的 `.platform-title` 用到)以 **1 MB 的 TTF 格式**全局加载。
|
||||
|
||||
- 全局加载:`main.ts` → `fonts.css` → 预加载所有字体
|
||||
- 即使用户从未访问授权查询页,这 1 MB 也会被下载
|
||||
|
||||
### 1.4 Noto Sans SC:两个字重 = 每字重 ~15 个 woff2 分片
|
||||
|
||||
400 + 700 两个字重,每次页面含中文时浏览器匹配 unicode-range 触发下载,共约 20-30 个分片文件(每个 25-33 KB)。这是 CJK 字体的固有代价,无法消除,但可以:
|
||||
- 替换为系统字体(零网络开销)
|
||||
- 转为自托管 WOFF2 子集(仅保留常用汉字)
|
||||
|
||||
---
|
||||
|
||||
## 二、优化方案设计
|
||||
|
||||
### 方案一(必做):移除 ElementPlusResolver CSS 双重加载
|
||||
|
||||
**影响层**:构建配置层
|
||||
**改动意图**:将 `importStyle: 'css'` 改为 `importStyle: false`,消除每个组件的碎片 CSS 请求,改由全局 `element-plus/dist/index.css` 统一提供样式。
|
||||
|
||||
**数据收益**:消除约 10-20 个 CSS 请求(每次路由跳转)。
|
||||
|
||||
---
|
||||
|
||||
### 方案二(必做):PangMenZhengDao 懒加载隔离
|
||||
|
||||
**影响层**:CSS 层 + 字体加载策略层
|
||||
**改动意图**:
|
||||
1. 从 `fonts.css` 中删除 `@font-face` 全局声明
|
||||
2. 将 `@font-face` 声明移入 `LicenseSearch.vue` 的 `<style scoped>`,利用路由懒加载天然隔离,仅在用户访问该页时触发字体下载
|
||||
|
||||
**数据收益**:首屏及非授权查询页节省 1 MB 传输。
|
||||
|
||||
---
|
||||
|
||||
### 方案三(推荐):Noto Sans SC → 系统字体降级
|
||||
|
||||
**影响层**:CSS 变量层(variables.scss)
|
||||
**改动意图**:将 `$font-family-primary` 中的 `'Noto Sans SC'` 降级为第二选择,保留兜底。同时评估是否保留 `@fontsource` 导入(影响跨平台一致性)。
|
||||
|
||||
如果要保留字体一致性,最小化方案是:
|
||||
- **只导入 400**(删除 700 的导入),weight:700 的文字交给浏览器合成(faux-bold),视觉差异极小
|
||||
- 用 `font-display: optional` 替代 `swap` 以避免 FOUT
|
||||
|
||||
**数据收益**:减少 ~50% 的字体请求数(约 15 个)。
|
||||
|
||||
---
|
||||
|
||||
### 方案四(加分):开发服务器字体缓存优化
|
||||
|
||||
**影响层**:vite.config.ts server 配置
|
||||
**改动意图**:为 dev server 添加字体文件 `Cache-Control` 响应头,避免浏览器 devtools 中字体反复显示为"加载中"的视觉误导。
|
||||
|
||||
---
|
||||
|
||||
## 三、数据模型层(Schema)
|
||||
|
||||
无数据库变更。
|
||||
|
||||
---
|
||||
|
||||
## 四、后端服务层(API)
|
||||
|
||||
无需改动。
|
||||
|
||||
---
|
||||
|
||||
## 五、前端交互层(View)— 目标文件清单
|
||||
|
||||
| # | 文件路径 | 操作 | 改动说明 |
|
||||
|---|---------|------|---------|
|
||||
| 1 | `frontend/vite.config.ts` | **修改** | `ElementPlusResolver` 的 `importStyle: 'css'` → `false`;添加 dev server font cache header |
|
||||
| 2 | `frontend/src/assets/styles/fonts.css` | **修改** | 删除 `PangMenZhengDao` 的 `@font-face` 块 |
|
||||
| 3 | `frontend/src/views/Licenses/LicenseSearch.vue` | **修改** | 在 `<style scoped>` 顶部加入 `PangMenZhengDao` 的 `@font-face` 声明 |
|
||||
| 4 | `frontend/src/main.ts` | **修改** | 删除 `@fontsource/noto-sans-sc/700.css` 导入(可选,评估后决定)|
|
||||
|
||||
**共 4 个文件,不超过 task-coder 单批限制(5个)。**
|
||||
|
||||
---
|
||||
|
||||
## 六、实施优先级
|
||||
|
||||
```
|
||||
P0(必做,无副作用):
|
||||
- 文件1:ElementPlusResolver importStyle fix
|
||||
- 文件2 + 文件3:PangMenZhengDao 懒加载隔离
|
||||
|
||||
P1(可选,有视觉权衡):
|
||||
- 文件4:删除 700 字重导入
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 七、预期收益对比
|
||||
|
||||
| 指标 | 优化前 | 优化后(P0) |
|
||||
|------|-------|------------|
|
||||
| 路由跳转触发的 CSS 请求 | ~15 个碎片 CSS | 0 |
|
||||
| 非授权查询页的字体传输 | 1 MB (PangMenZhengDao) | 0 |
|
||||
| 首次访问总字体请求数 | ~35 个 | ~20 个 |
|
||||
|
||||
---
|
||||
|
||||
*→ 移交 `task-coder` 按上述文件清单实施。*
|
||||
@@ -0,0 +1,135 @@
|
||||
# 技术契约与架构设计书:登录页国际化补全
|
||||
|
||||
**版本**:v1.0
|
||||
**日期**:2026-03-13
|
||||
**作者**:system-architect
|
||||
**状态**:待 task-coder 实施
|
||||
|
||||
---
|
||||
|
||||
## 一、问题诊断
|
||||
|
||||
`src/views/Login.vue` 中存在 **8 处**硬编码英文字符串,未通过 `t()` 调用 i18n 系统,导致切换语言后这些文案不随语言切换。
|
||||
|
||||
### 硬编码清单(按 DOM 位置)
|
||||
|
||||
| 位置 | 当前硬编码值 | 目标 key |
|
||||
|------|-------------|---------|
|
||||
| `.hero-title` | `Secure License<br/>Management` | `login.heroTitleLine1` / `login.heroTitleLine2` |
|
||||
| `.hero-subtitle` | `Streamline your software licensing...` | `login.heroSubtitle` |
|
||||
| stat-label "Active Licenses" | `Active Licenses` | `login.stats.activeLicenses` |
|
||||
| stat-label "Uptime" | `Uptime` | `login.stats.uptime` |
|
||||
| stat-label "Support" | `Support` | `login.stats.support` |
|
||||
| `.title` (card) | `Welcome back` | `login.welcomeTitle` |
|
||||
| `.subtitle` (card) | `Sign in to your account to continue` | `login.welcomeSubtitle` |
|
||||
| `.login-footer p` | `© 2025 Cedar-V. All rights reserved.` | `login.copyright` |
|
||||
|
||||
> **保持原样(不需国际化)**:品牌名 `Cedar-V`、数字统计值 `10K+` / `99.9%` / `24/7`、语言选项标签(自语言名称自描述惯例)。
|
||||
|
||||
---
|
||||
|
||||
## 二、数据模型层(Schema)— i18n 键值设计
|
||||
|
||||
### 新增键(追加至三端 `login` 对象末尾,位于 `error` 块之后)
|
||||
|
||||
```jsonc
|
||||
// en.json
|
||||
"heroTitleLine1": "Secure License",
|
||||
"heroTitleLine2": "Management",
|
||||
"heroSubtitle": "Streamline your software licensing workflow with our enterprise-grade platform.",
|
||||
"stats": {
|
||||
"activeLicenses": "Active Licenses",
|
||||
"uptime": "Uptime",
|
||||
"support": "Support"
|
||||
},
|
||||
"welcomeTitle": "Welcome back",
|
||||
"welcomeSubtitle": "Sign in to your account to continue",
|
||||
"copyright": "© 2025 Cedar-V. All rights reserved."
|
||||
|
||||
// zh.json
|
||||
"heroTitleLine1": "安全许可证",
|
||||
"heroTitleLine2": "管理平台",
|
||||
"heroSubtitle": "使用我们的企业级平台,简化您的软件许可证管理工作流程。",
|
||||
"stats": {
|
||||
"activeLicenses": "活跃许可证",
|
||||
"uptime": "可用率",
|
||||
"support": "全天支持"
|
||||
},
|
||||
"welcomeTitle": "欢迎回来",
|
||||
"welcomeSubtitle": "登录您的账户以继续",
|
||||
"copyright": "© 2025 Cedar-V. 保留所有权利。"
|
||||
|
||||
// ja.json
|
||||
"heroTitleLine1": "セキュアな",
|
||||
"heroTitleLine2": "ライセンス管理",
|
||||
"heroSubtitle": "エンタープライズグレードのプラットフォームで、ソフトウェアライセンス管理を効率化。",
|
||||
"stats": {
|
||||
"activeLicenses": "アクティブライセンス",
|
||||
"uptime": "稼働率",
|
||||
"support": "サポート"
|
||||
},
|
||||
"welcomeTitle": "おかえりなさい",
|
||||
"welcomeSubtitle": "アカウントにサインインして続けてください",
|
||||
"copyright": "© 2025 Cedar-V. All rights reserved."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 三、前端交互层(View)— 修改意图
|
||||
|
||||
### 目标文件
|
||||
|
||||
| 文件 | 操作 |
|
||||
|------|------|
|
||||
| `src/i18n/locales/en.json` | 追加 8 个新键至 `login` 对象 |
|
||||
| `src/i18n/locales/zh.json` | 追加 8 个新键至 `login` 对象 |
|
||||
| `src/i18n/locales/ja.json` | 追加 8 个新键至 `login` 对象 |
|
||||
| `src/views/Login.vue` | 模板层 8 处硬编码 → `{{ t('login.*') }}` |
|
||||
|
||||
### Login.vue 模板变更对照
|
||||
|
||||
```html
|
||||
<!-- BEFORE -->
|
||||
<h1 class="hero-title">Secure License<br/>Management</h1>
|
||||
|
||||
<!-- AFTER -->
|
||||
<h1 class="hero-title">{{ t('login.heroTitleLine1') }}<br/>{{ t('login.heroTitleLine2') }}</h1>
|
||||
```
|
||||
|
||||
```html
|
||||
<!-- BEFORE -->
|
||||
<p class="hero-subtitle">Streamline your software licensing workflow with our enterprise-grade platform.</p>
|
||||
|
||||
<!-- AFTER -->
|
||||
<p class="hero-subtitle">{{ t('login.heroSubtitle') }}</p>
|
||||
```
|
||||
|
||||
```html
|
||||
<!-- BEFORE (3 stat labels) -->
|
||||
<span class="stat-label">Active Licenses</span>
|
||||
<span class="stat-label">Uptime</span>
|
||||
<span class="stat-label">Support</span>
|
||||
|
||||
<!-- AFTER -->
|
||||
<span class="stat-label">{{ t('login.stats.activeLicenses') }}</span>
|
||||
<span class="stat-label">{{ t('login.stats.uptime') }}</span>
|
||||
<span class="stat-label">{{ t('login.stats.support') }}</span>
|
||||
```
|
||||
|
||||
```html
|
||||
<!-- BEFORE -->
|
||||
<h2 class="title">Welcome back</h2>
|
||||
<p class="subtitle">Sign in to your account to continue</p>
|
||||
|
||||
<!-- AFTER -->
|
||||
<h2 class="title">{{ t('login.welcomeTitle') }}</h2>
|
||||
<p class="subtitle">{{ t('login.welcomeSubtitle') }}</p>
|
||||
```
|
||||
|
||||
```html
|
||||
<!-- BEFORE -->
|
||||
<p>© 2025 Cedar-V. All rights reserved.</p>
|
||||
|
||||
<!-- AFTER -->
|
||||
<p>{{ t('login.copyright') }}</p>
|
||||
```
|
||||
@@ -33,6 +33,8 @@
|
||||
"sass": "^1.89.2",
|
||||
"terser": "^5.43.1",
|
||||
"typescript": "^5.8.3",
|
||||
"unplugin-auto-import": "^21.0.0",
|
||||
"unplugin-vue-components": "^31.0.0",
|
||||
"vite": "^5.0.0",
|
||||
"vite-plugin-compression": "^0.5.1",
|
||||
"vitest": "^2.0.0",
|
||||
@@ -730,6 +732,17 @@
|
||||
"@jridgewell/trace-mapping": "^0.3.24"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/remapping": {
|
||||
"version": "2.3.5",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
|
||||
"integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/gen-mapping": "^0.3.5",
|
||||
"@jridgewell/trace-mapping": "^0.3.24"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/resolve-uri": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
|
||||
@@ -2328,9 +2341,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/acorn": {
|
||||
"version": "8.15.0",
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
|
||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||
"version": "8.16.0",
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
|
||||
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
@@ -2648,6 +2661,13 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/confbox": {
|
||||
"version": "0.2.4",
|
||||
"resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz",
|
||||
"integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
@@ -3292,6 +3312,13 @@
|
||||
"node": ">=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/exsolve": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz",
|
||||
"integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-deep-equal": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||
@@ -3897,6 +3924,13 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/js-tokens": {
|
||||
"version": "9.0.1",
|
||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz",
|
||||
"integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/js-yaml": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
|
||||
@@ -3968,6 +4002,24 @@
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/local-pkg": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-1.1.2.tgz",
|
||||
"integrity": "sha512-arhlxbFRmoQHl33a0Zkle/YWlmNwoyt6QNZEIJcqNbdrsix5Lvc4HyyI3EnwxTYlZYc32EbYrQ8SzEZ7dqgg9A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mlly": "^1.7.4",
|
||||
"pkg-types": "^2.3.0",
|
||||
"quansync": "^0.2.11"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/antfu"
|
||||
}
|
||||
},
|
||||
"node_modules/locate-path": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
|
||||
@@ -4022,9 +4074,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/magic-string": {
|
||||
"version": "0.30.18",
|
||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.18.tgz",
|
||||
"integrity": "sha512-yi8swmWbO17qHhwIBNeeZxTceJMeBvWJaId6dyvTSOwTipqeHhMhOrz6513r1sOKnpvQ7zkhlG8tPrpilwTxHQ==",
|
||||
"version": "0.30.21",
|
||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
||||
"integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||
@@ -4106,6 +4158,45 @@
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/mlly": {
|
||||
"version": "1.8.1",
|
||||
"resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.1.tgz",
|
||||
"integrity": "sha512-SnL6sNutTwRWWR/vcmCYHSADjiEesp5TGQQ0pXyLhW5IoeibRlF/CbSLailbB3CNqJUk9cVJ9dUDnbD7GrcHBQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"acorn": "^8.16.0",
|
||||
"pathe": "^2.0.3",
|
||||
"pkg-types": "^1.3.1",
|
||||
"ufo": "^1.6.3"
|
||||
}
|
||||
},
|
||||
"node_modules/mlly/node_modules/confbox": {
|
||||
"version": "0.1.8",
|
||||
"resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz",
|
||||
"integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/mlly/node_modules/pathe": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
|
||||
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/mlly/node_modules/pkg-types": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz",
|
||||
"integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"confbox": "^0.1.8",
|
||||
"mlly": "^1.7.4",
|
||||
"pathe": "^2.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
@@ -4172,6 +4263,17 @@
|
||||
"url": "https://github.com/fb55/nth-check?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/obug": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz",
|
||||
"integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
"https://github.com/sponsors/sxzz",
|
||||
"https://opencollective.com/debug"
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/once": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
|
||||
@@ -4368,6 +4470,25 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/pkg-types": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.0.tgz",
|
||||
"integrity": "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"confbox": "^0.2.2",
|
||||
"exsolve": "^1.0.7",
|
||||
"pathe": "^2.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/pkg-types/node_modules/pathe": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
|
||||
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.6",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
|
||||
@@ -4465,6 +4586,23 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/quansync": {
|
||||
"version": "0.2.11",
|
||||
"resolved": "https://registry.npmjs.org/quansync/-/quansync-0.2.11.tgz",
|
||||
"integrity": "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://github.com/sponsors/antfu"
|
||||
},
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://github.com/sponsors/sxzz"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/queue-microtask": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
||||
@@ -4692,6 +4830,13 @@
|
||||
"@parcel/watcher": "^2.4.1"
|
||||
}
|
||||
},
|
||||
"node_modules/scule": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/scule/-/scule-1.3.0.tgz",
|
||||
"integrity": "sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/semver": {
|
||||
"version": "7.7.2",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz",
|
||||
@@ -4840,6 +4985,19 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/strip-literal": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz",
|
||||
"integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"js-tokens": "^9.0.1"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/antfu"
|
||||
}
|
||||
},
|
||||
"node_modules/supports-color": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
|
||||
@@ -4909,6 +5067,54 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tinyglobby": {
|
||||
"version": "0.2.15",
|
||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
|
||||
"integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fdir": "^6.5.0",
|
||||
"picomatch": "^4.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/SuperchupuDev"
|
||||
}
|
||||
},
|
||||
"node_modules/tinyglobby/node_modules/fdir": {
|
||||
"version": "6.5.0",
|
||||
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
||||
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"picomatch": "^3 || ^4"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"picomatch": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/tinyglobby/node_modules/picomatch": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/tinypool": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz",
|
||||
@@ -5011,6 +5217,13 @@
|
||||
"node": ">=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/ufo": {
|
||||
"version": "1.6.3",
|
||||
"resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.3.tgz",
|
||||
"integrity": "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "6.21.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||
@@ -5018,6 +5231,65 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/unimport": {
|
||||
"version": "5.7.0",
|
||||
"resolved": "https://registry.npmjs.org/unimport/-/unimport-5.7.0.tgz",
|
||||
"integrity": "sha512-njnL6sp8lEA8QQbZrt+52p/g4X0rw3bnGGmUcJnt1jeG8+iiqO779aGz0PirCtydAIVcuTBRlJ52F0u46z309Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"acorn": "^8.16.0",
|
||||
"escape-string-regexp": "^5.0.0",
|
||||
"estree-walker": "^3.0.3",
|
||||
"local-pkg": "^1.1.2",
|
||||
"magic-string": "^0.30.21",
|
||||
"mlly": "^1.8.0",
|
||||
"pathe": "^2.0.3",
|
||||
"picomatch": "^4.0.3",
|
||||
"pkg-types": "^2.3.0",
|
||||
"scule": "^1.3.0",
|
||||
"strip-literal": "^3.1.0",
|
||||
"tinyglobby": "^0.2.15",
|
||||
"unplugin": "^2.3.11",
|
||||
"unplugin-utils": "^0.3.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/unimport/node_modules/escape-string-regexp": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz",
|
||||
"integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/unimport/node_modules/pathe": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
|
||||
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/unimport/node_modules/picomatch": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/universalify": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz",
|
||||
@@ -5028,6 +5300,194 @@
|
||||
"node": ">= 10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/unplugin": {
|
||||
"version": "2.3.11",
|
||||
"resolved": "https://registry.npmjs.org/unplugin/-/unplugin-2.3.11.tgz",
|
||||
"integrity": "sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/remapping": "^2.3.5",
|
||||
"acorn": "^8.15.0",
|
||||
"picomatch": "^4.0.3",
|
||||
"webpack-virtual-modules": "^0.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/unplugin-auto-import": {
|
||||
"version": "21.0.0",
|
||||
"resolved": "https://registry.npmjs.org/unplugin-auto-import/-/unplugin-auto-import-21.0.0.tgz",
|
||||
"integrity": "sha512-vWuC8SwqJmxZFYwPojhOhOXDb5xFhNNcEVb9K/RFkyk/3VnfaOjzitWN7v+8DEKpMjSsY2AEGXNgt6I0yQrhRQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"local-pkg": "^1.1.2",
|
||||
"magic-string": "^0.30.21",
|
||||
"picomatch": "^4.0.3",
|
||||
"unimport": "^5.6.0",
|
||||
"unplugin": "^2.3.11",
|
||||
"unplugin-utils": "^0.3.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/antfu"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@nuxt/kit": "^4.0.0",
|
||||
"@vueuse/core": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@nuxt/kit": {
|
||||
"optional": true
|
||||
},
|
||||
"@vueuse/core": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/unplugin-auto-import/node_modules/picomatch": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/unplugin-utils": {
|
||||
"version": "0.3.1",
|
||||
"resolved": "https://registry.npmjs.org/unplugin-utils/-/unplugin-utils-0.3.1.tgz",
|
||||
"integrity": "sha512-5lWVjgi6vuHhJ526bI4nlCOmkCIF3nnfXkCMDeMJrtdvxTs6ZFCM8oNufGTsDbKv/tJ/xj8RpvXjRuPBZJuJog==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pathe": "^2.0.3",
|
||||
"picomatch": "^4.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sxzz"
|
||||
}
|
||||
},
|
||||
"node_modules/unplugin-utils/node_modules/pathe": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
|
||||
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/unplugin-utils/node_modules/picomatch": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/unplugin-vue-components": {
|
||||
"version": "31.0.0",
|
||||
"resolved": "https://registry.npmjs.org/unplugin-vue-components/-/unplugin-vue-components-31.0.0.tgz",
|
||||
"integrity": "sha512-4ULwfTZTLuWJ7+S9P7TrcStYLsSRkk6vy2jt/WTfgUEUb0nW9//xxmrfhyHUEVpZ2UKRRwfRb8Yy15PDbVZf+Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"chokidar": "^5.0.0",
|
||||
"local-pkg": "^1.1.2",
|
||||
"magic-string": "^0.30.21",
|
||||
"mlly": "^1.8.0",
|
||||
"obug": "^2.1.1",
|
||||
"picomatch": "^4.0.3",
|
||||
"tinyglobby": "^0.2.15",
|
||||
"unplugin": "^2.3.11",
|
||||
"unplugin-utils": "^0.3.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/antfu"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@nuxt/kit": "^3.2.2 || ^4.0.0",
|
||||
"vue": "^3.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@nuxt/kit": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/unplugin-vue-components/node_modules/chokidar": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz",
|
||||
"integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"readdirp": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/unplugin-vue-components/node_modules/picomatch": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/unplugin-vue-components/node_modules/readdirp": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz",
|
||||
"integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "individual",
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/unplugin/node_modules/picomatch": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/uri-js": {
|
||||
"version": "4.4.1",
|
||||
"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
|
||||
@@ -5432,6 +5892,13 @@
|
||||
"typescript": ">=5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/webpack-virtual-modules": {
|
||||
"version": "0.6.2",
|
||||
"resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz",
|
||||
"integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/which": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||
|
||||
@@ -42,6 +42,8 @@
|
||||
"sass": "^1.89.2",
|
||||
"terser": "^5.43.1",
|
||||
"typescript": "^5.8.3",
|
||||
"unplugin-auto-import": "^21.0.0",
|
||||
"unplugin-vue-components": "^31.0.0",
|
||||
"vite": "^5.0.0",
|
||||
"vite-plugin-compression": "^0.5.1",
|
||||
"vitest": "^2.0.0",
|
||||
|
||||
@@ -78,6 +78,12 @@ importers:
|
||||
typescript:
|
||||
specifier: ^5.8.3
|
||||
version: 5.9.3
|
||||
unplugin-auto-import:
|
||||
specifier: ^21.0.0
|
||||
version: 21.0.0(@vueuse/[email protected]([email protected]([email protected])))
|
||||
unplugin-vue-components:
|
||||
specifier: ^31.0.0
|
||||
version: 31.0.0([email protected]([email protected]))
|
||||
vite:
|
||||
specifier: ^5.0.0
|
||||
version: 5.4.21(@types/[email protected])([email protected])([email protected])
|
||||
@@ -318,6 +324,9 @@ packages:
|
||||
'@jridgewell/[email protected]':
|
||||
resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
|
||||
|
||||
'@jridgewell/[email protected]':
|
||||
resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==}
|
||||
|
||||
'@jridgewell/[email protected]':
|
||||
resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==}
|
||||
engines: {node: '>=6.0.0'}
|
||||
@@ -816,6 +825,11 @@ packages:
|
||||
engines: {node: '>=0.4.0'}
|
||||
hasBin: true
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==}
|
||||
engines: {node: '>=0.4.0'}
|
||||
hasBin: true
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==}
|
||||
|
||||
@@ -897,6 +911,10 @@ packages:
|
||||
resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==}
|
||||
engines: {node: '>= 14.16.0'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==}
|
||||
engines: {node: '>= 20.19.0'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==}
|
||||
engines: {node: '>=12'}
|
||||
@@ -918,6 +936,12 @@ packages:
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
|
||||
engines: {node: '>= 8'}
|
||||
@@ -1023,6 +1047,10 @@ packages:
|
||||
resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-iI1f+D2ViGn+uvv5HuHVUamg8ll4tN+JRHGc6IJi4TP9Kl976C57fzPXgseXNs8v0iA8aSJpHsTWjDb9QJamGQ==}
|
||||
hasBin: true
|
||||
@@ -1105,6 +1133,9 @@ packages:
|
||||
resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
|
||||
|
||||
@@ -1300,6 +1331,9 @@ packages:
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==}
|
||||
hasBin: true
|
||||
@@ -1323,6 +1357,10 @@ packages:
|
||||
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
|
||||
engines: {node: '>= 0.8.0'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-arhlxbFRmoQHl33a0Zkle/YWlmNwoyt6QNZEIJcqNbdrsix5Lvc4HyyI3EnwxTYlZYc32EbYrQ8SzEZ7dqgg9A==}
|
||||
engines: {node: '>=14'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -1379,6 +1417,9 @@ packages:
|
||||
resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==}
|
||||
engines: {node: '>=16 || 14 >=14.17'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-SnL6sNutTwRWWR/vcmCYHSADjiEesp5TGQQ0pXyLhW5IoeibRlF/CbSLailbB3CNqJUk9cVJ9dUDnbD7GrcHBQ==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
|
||||
|
||||
@@ -1402,6 +1443,9 @@ packages:
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
|
||||
|
||||
@@ -1447,6 +1491,9 @@ packages:
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==}
|
||||
engines: {node: '>= 14.16'}
|
||||
@@ -1471,6 +1518,12 @@ packages:
|
||||
typescript:
|
||||
optional: true
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==}
|
||||
engines: {node: '>=4'}
|
||||
@@ -1499,6 +1552,9 @@ packages:
|
||||
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
|
||||
|
||||
@@ -1506,6 +1562,10 @@ packages:
|
||||
resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==}
|
||||
engines: {node: '>= 14.18.0'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==}
|
||||
engines: {node: '>= 20.19.0'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@@ -1549,6 +1609,9 @@ packages:
|
||||
engines: {node: '>=14.0.0'}
|
||||
hasBin: true
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -1602,6 +1665,9 @@ packages:
|
||||
resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -1672,13 +1738,50 @@ packages:
|
||||
engines: {node: '>=14.17'}
|
||||
hasBin: true
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-njnL6sp8lEA8QQbZrt+52p/g4X0rw3bnGGmUcJnt1jeG8+iiqO779aGz0PirCtydAIVcuTBRlJ52F0u46z309Q==}
|
||||
engines: {node: '>=18.12.0'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==}
|
||||
engines: {node: '>= 10.0.0'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-vWuC8SwqJmxZFYwPojhOhOXDb5xFhNNcEVb9K/RFkyk/3VnfaOjzitWN7v+8DEKpMjSsY2AEGXNgt6I0yQrhRQ==}
|
||||
engines: {node: '>=20.19.0'}
|
||||
peerDependencies:
|
||||
'@nuxt/kit': ^4.0.0
|
||||
'@vueuse/core': '*'
|
||||
peerDependenciesMeta:
|
||||
'@nuxt/kit':
|
||||
optional: true
|
||||
'@vueuse/core':
|
||||
optional: true
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-5lWVjgi6vuHhJ526bI4nlCOmkCIF3nnfXkCMDeMJrtdvxTs6ZFCM8oNufGTsDbKv/tJ/xj8RpvXjRuPBZJuJog==}
|
||||
engines: {node: '>=20.19.0'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-4ULwfTZTLuWJ7+S9P7TrcStYLsSRkk6vy2jt/WTfgUEUb0nW9//xxmrfhyHUEVpZ2UKRRwfRb8Yy15PDbVZf+Q==}
|
||||
engines: {node: '>=20.19.0'}
|
||||
peerDependencies:
|
||||
'@nuxt/kit': ^3.2.2 || ^4.0.0
|
||||
vue: ^3.0.0
|
||||
peerDependenciesMeta:
|
||||
'@nuxt/kit':
|
||||
optional: true
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww==}
|
||||
engines: {node: '>=18.12.0'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
|
||||
|
||||
@@ -1824,6 +1927,9 @@ packages:
|
||||
typescript:
|
||||
optional: true
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
|
||||
engines: {node: '>= 8'}
|
||||
@@ -2023,6 +2129,11 @@ snapshots:
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
'@jridgewell/trace-mapping': 0.3.31
|
||||
|
||||
'@jridgewell/[email protected]':
|
||||
dependencies:
|
||||
'@jridgewell/gen-mapping': 0.3.13
|
||||
'@jridgewell/trace-mapping': 0.3.31
|
||||
|
||||
'@jridgewell/[email protected]': {}
|
||||
|
||||
'@jridgewell/[email protected]':
|
||||
@@ -2554,6 +2665,8 @@ snapshots:
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
fast-deep-equal: 3.1.3
|
||||
@@ -2634,6 +2747,10 @@ snapshots:
|
||||
dependencies:
|
||||
readdirp: 4.1.2
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
readdirp: 5.0.0
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
string-width: 4.2.3
|
||||
@@ -2654,6 +2771,10 @@ snapshots:
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
path-key: 3.1.1
|
||||
@@ -2773,6 +2894,8 @@ snapshots:
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]([email protected]):
|
||||
dependencies:
|
||||
eslint: 8.57.1
|
||||
@@ -2889,6 +3012,8 @@ snapshots:
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
@@ -3067,6 +3192,8 @@ snapshots:
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
argparse: 2.0.1
|
||||
@@ -3092,6 +3219,12 @@ snapshots:
|
||||
prelude-ls: 1.2.1
|
||||
type-check: 0.4.0
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
mlly: 1.8.1
|
||||
pkg-types: 2.3.0
|
||||
quansync: 0.2.11
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
p-locate: 5.0.0
|
||||
@@ -3139,6 +3272,13 @@ snapshots:
|
||||
dependencies:
|
||||
brace-expansion: 2.0.2
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
acorn: 8.16.0
|
||||
pathe: 2.0.3
|
||||
pkg-types: 1.3.1
|
||||
ufo: 1.6.3
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
@@ -3156,6 +3296,8 @@ snapshots:
|
||||
dependencies:
|
||||
boolbase: 1.0.0
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
wrappy: 1.0.2
|
||||
@@ -3199,6 +3341,8 @@ snapshots:
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
@@ -3217,6 +3361,18 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- '@vue/composition-api'
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
confbox: 0.1.8
|
||||
mlly: 1.8.1
|
||||
pathe: 2.0.3
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
confbox: 0.2.4
|
||||
exsolve: 1.0.8
|
||||
pathe: 2.0.3
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
cssesc: 3.0.0
|
||||
@@ -3240,10 +3396,14 @@ snapshots:
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
@@ -3306,6 +3466,8 @@ snapshots:
|
||||
optionalDependencies:
|
||||
'@parcel/watcher': 2.5.6
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
@@ -3345,6 +3507,10 @@ snapshots:
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
js-tokens: 9.0.1
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
has-flag: 4.0.0
|
||||
@@ -3399,10 +3565,65 @@ snapshots:
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
acorn: 8.16.0
|
||||
escape-string-regexp: 5.0.0
|
||||
estree-walker: 3.0.3
|
||||
local-pkg: 1.1.2
|
||||
magic-string: 0.30.21
|
||||
mlly: 1.8.1
|
||||
pathe: 2.0.3
|
||||
picomatch: 4.0.3
|
||||
pkg-types: 2.3.0
|
||||
scule: 1.3.0
|
||||
strip-literal: 3.1.0
|
||||
tinyglobby: 0.2.15
|
||||
unplugin: 2.3.11
|
||||
unplugin-utils: 0.3.1
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected](@vueuse/[email protected]([email protected]([email protected]))):
|
||||
dependencies:
|
||||
local-pkg: 1.1.2
|
||||
magic-string: 0.30.21
|
||||
picomatch: 4.0.3
|
||||
unimport: 5.7.0
|
||||
unplugin: 2.3.11
|
||||
unplugin-utils: 0.3.1
|
||||
optionalDependencies:
|
||||
'@vueuse/core': 10.11.1([email protected]([email protected]))
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
pathe: 2.0.3
|
||||
picomatch: 4.0.3
|
||||
|
||||
[email protected]([email protected]([email protected])):
|
||||
dependencies:
|
||||
chokidar: 5.0.0
|
||||
local-pkg: 1.1.2
|
||||
magic-string: 0.30.21
|
||||
mlly: 1.8.1
|
||||
obug: 2.1.1
|
||||
picomatch: 4.0.3
|
||||
tinyglobby: 0.2.15
|
||||
unplugin: 2.3.11
|
||||
unplugin-utils: 0.3.1
|
||||
vue: 3.5.27([email protected])
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
'@jridgewell/remapping': 2.3.5
|
||||
acorn: 8.15.0
|
||||
picomatch: 4.0.3
|
||||
webpack-virtual-modules: 0.6.2
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
punycode: 2.3.1
|
||||
@@ -3555,6 +3776,8 @@ snapshots:
|
||||
optionalDependencies:
|
||||
typescript: 5.9.3
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
isexe: 2.0.0
|
||||
|
||||
@@ -58,6 +58,27 @@ export const getAuthorizationTrend = (params: {
|
||||
}
|
||||
|
||||
// 获取仪表盘统计数据(新接口,返回卡片区数据)
|
||||
export const getOverviewStats = () => {
|
||||
export interface GrowthRate {
|
||||
auth_codes_mom: number
|
||||
licenses_mom: number
|
||||
}
|
||||
|
||||
export interface StatsOverviewData {
|
||||
// stock metrics
|
||||
total_auth_codes: number
|
||||
active_licenses: number
|
||||
// flow metrics
|
||||
today_new_licenses: number
|
||||
yesterday_new_licenses: number
|
||||
month_new_auth_codes: number
|
||||
// risk metrics
|
||||
expiring_in_7days: number
|
||||
expiring_in_30days: number
|
||||
abnormal_alerts: number
|
||||
// growth rates (sub-text only)
|
||||
growth_rate: GrowthRate
|
||||
}
|
||||
|
||||
export const getOverviewStats = (): Promise<ApiResponse<StatsOverviewData>> => {
|
||||
return Axios.get('/api/v1/stats/overview')
|
||||
}
|
||||
@@ -7,7 +7,7 @@ export interface Package {
|
||||
price: number;
|
||||
price_description?: string;
|
||||
duration_description: string;
|
||||
status: number; // 1: enabled, 2: disabled
|
||||
status: number; // 1: enabled, 0: disabled
|
||||
type: string;
|
||||
sort_order: number;
|
||||
description?: string;
|
||||
@@ -51,3 +51,26 @@ export function getPackageDetail(id: string | number): Promise<ApiResponse<Packa
|
||||
export function updatePackage(id: string | number, data: Partial<Package>): Promise<ApiResponse<Package>> {
|
||||
return Axios.put(`/api/packages/${id}`, data)
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建套餐
|
||||
*/
|
||||
export function createPackage(data: Partial<Package>): Promise<ApiResponse<Package>> {
|
||||
return Axios.post('/api/packages', data)
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除套餐
|
||||
*/
|
||||
export function deletePackage(id: string | number): Promise<ApiResponse<null>> {
|
||||
return Axios.delete(`/api/packages/${id}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新套餐状态
|
||||
*/
|
||||
export function updatePackageStatus(id: string | number, status: 0 | 1): Promise<ApiResponse<Package>> {
|
||||
return Axios.put(`/api/packages/${id}/status`, null, {
|
||||
params: { status }
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import Axios from './https'
|
||||
|
||||
export interface PublicApiResponse<T = any> {
|
||||
code: string
|
||||
message: string
|
||||
timestamp: string
|
||||
data?: T
|
||||
}
|
||||
|
||||
export interface AuthorizationCodeInfo {
|
||||
authorization_code_id: string
|
||||
authorization_code: string
|
||||
customer_id: string
|
||||
customer_name: string
|
||||
contact_person: string
|
||||
contact_phone: string
|
||||
contact_email: string
|
||||
status: 'normal' | 'expired' | 'locked'
|
||||
is_locked: boolean
|
||||
start_date: string
|
||||
end_date: string
|
||||
max_activations: number
|
||||
active_licenses: number
|
||||
}
|
||||
|
||||
export interface RenewalOrderRequest {
|
||||
authorization_code: string
|
||||
package_id: string
|
||||
license_count: number
|
||||
renew_days: number
|
||||
payment_method?: string
|
||||
contact_name: string
|
||||
contact_phone: string
|
||||
contact_email?: string
|
||||
company_name?: string
|
||||
}
|
||||
|
||||
export interface RenewalOrderResponse {
|
||||
order_id?: string
|
||||
order_no?: string
|
||||
package_name?: string
|
||||
authorization_code?: string
|
||||
payment_no?: string
|
||||
payment_url?: string
|
||||
total_amount: number
|
||||
expire_time?: string
|
||||
renew_days?: number
|
||||
license_count?: number
|
||||
}
|
||||
|
||||
export interface PublicPaymentStatus {
|
||||
payment_no: string
|
||||
status: 'pending' | 'paid' | 'cancelled' | 'expired' | 'failed'
|
||||
amount: number
|
||||
payment_time?: string
|
||||
trade_no?: string
|
||||
authorization_code_id?: string
|
||||
authorization_end_date?: string
|
||||
business_order?: {
|
||||
order_no: string
|
||||
authorization_code?: string
|
||||
status: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface PublicPackageItem {
|
||||
id: string
|
||||
name: string
|
||||
type: string
|
||||
price: number
|
||||
display_price: string
|
||||
price_description: string
|
||||
}
|
||||
|
||||
export function queryAuthorizationCode(authorizationCode: string): Promise<PublicApiResponse<AuthorizationCodeInfo>> {
|
||||
return Axios.post('/api/public/authorization-codes/query', {
|
||||
authorization_code: authorizationCode
|
||||
})
|
||||
}
|
||||
|
||||
export function createRenewalOrder(payload: RenewalOrderRequest): Promise<PublicApiResponse<RenewalOrderResponse>> {
|
||||
return Axios.post('/api/public/renewals/order', payload)
|
||||
}
|
||||
|
||||
export interface PurchaseOrderRequest {
|
||||
package_id: string
|
||||
license_count: number
|
||||
payment_method?: string
|
||||
contact_name: string
|
||||
contact_phone: string
|
||||
contact_email?: string
|
||||
company_name?: string
|
||||
customer_type?: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
export function createPurchaseOrder(payload: PurchaseOrderRequest): Promise<PublicApiResponse<RenewalOrderResponse>> {
|
||||
return Axios.post('/api/public/orders', payload)
|
||||
}
|
||||
|
||||
export function getPublicPaymentStatus(paymentNo: string): Promise<PublicApiResponse<PublicPaymentStatus>> {
|
||||
return Axios.get(`/api/public/payment/${paymentNo}/status`)
|
||||
}
|
||||
|
||||
export function getPublicPackages(): Promise<PublicApiResponse<PublicPackageItem[]>> {
|
||||
return Axios.get('/api/cu/packages')
|
||||
}
|
||||
@@ -6,19 +6,23 @@
|
||||
border-radius: $border-radius-base;
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&--primary {
|
||||
// 仅「实心」主按钮用品牌色填充;plain / link / text 仍走 EP 默认(避免 plain 被铺成深底+主色字,对比度崩溃)
|
||||
&--primary:not(.is-plain):not(.is-link):not(.is-text) {
|
||||
background-color: $primary-color;
|
||||
border-color: $primary-color;
|
||||
|
||||
color: #ffffff;
|
||||
|
||||
&:hover,
|
||||
&:focus {
|
||||
background-color: color.adjust($primary-color, $lightness: 10%);
|
||||
border-color: color.adjust($primary-color, $lightness: 10%);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
|
||||
&:active {
|
||||
background-color: color.adjust($primary-color, $lightness: -10%);
|
||||
border-color: color.adjust($primary-color, $lightness: -10%);
|
||||
color: #ffffff;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ $font-size-small: 12px; // 改为12px
|
||||
$font-size-extra-small: 12px;
|
||||
|
||||
// 字体系列
|
||||
$font-family-primary: 'Noto Sans SC', 'Source Han Sans CN', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
|
||||
$font-family-primary: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Microsoft YaHei', 'PingFang SC', 'Hiragino Sans GB', 'Noto Sans SC', 'Source Han Sans CN', sans-serif;
|
||||
|
||||
// 字体粗细
|
||||
$font-weight-primary: 500;
|
||||
|
||||
@@ -121,7 +121,6 @@ const appStore = useAppStore()
|
||||
|
||||
// 快捷选择选项
|
||||
const quickOptions = computed(() => [
|
||||
{ label: t('chart.licenseTrend.quickOptions.today'), value: 'today' },
|
||||
{ label: t('chart.licenseTrend.quickOptions.week'), value: 'week' },
|
||||
{ label: t('chart.licenseTrend.quickOptions.month'), value: 'month' }
|
||||
])
|
||||
@@ -160,11 +159,11 @@ const chartOption = computed(() => {
|
||||
|
||||
return {
|
||||
grid: {
|
||||
left: 60,
|
||||
right: 30,
|
||||
left: '3%', // percentage so margins scale with container width
|
||||
right: '3%',
|
||||
top: 30,
|
||||
bottom: 60,
|
||||
containLabel: false
|
||||
bottom: 30, // reduced: containLabel handles room for axis labels
|
||||
containLabel: true // axis labels are always contained within the grid boundary
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
@@ -217,6 +216,7 @@ const chartOption = computed(() => {
|
||||
fontSize: 12
|
||||
},
|
||||
extraCssText: 'box-shadow: 0px 4px 12px 0px rgba(59, 210, 180, 0.2); backdrop-filter: blur(4px);',
|
||||
appendToBody: true, // mount tooltip DOM on <body> to escape overflow:hidden on card
|
||||
formatter: (params: any) => {
|
||||
const data = params[0]
|
||||
return `${t('chart.licenseTrend.tooltip.licenseCount')}: ${data.value}`
|
||||
@@ -277,24 +277,17 @@ const handleQuickSelect = (value: string) => {
|
||||
let endDate: Date
|
||||
|
||||
switch (value) {
|
||||
case 'today':
|
||||
// 本日:今天到今天
|
||||
case 'week':
|
||||
// 最近7天:今天往前6天
|
||||
startDate = new Date(today)
|
||||
startDate.setDate(today.getDate() - 6)
|
||||
endDate = new Date(today)
|
||||
break
|
||||
case 'week':
|
||||
// 本周:本周一到本周日
|
||||
const currentDay = today.getDay() // 0=周日, 1=周一, ..., 6=周六
|
||||
const mondayOffset = currentDay === 0 ? 6 : currentDay - 1 // 计算到周一的偏移
|
||||
startDate = new Date(today)
|
||||
startDate.setDate(today.getDate() - mondayOffset)
|
||||
endDate = new Date(startDate)
|
||||
endDate.setDate(startDate.getDate() + 6) // 周日
|
||||
break
|
||||
case 'month':
|
||||
// 本月:当前月1号到当前月末
|
||||
startDate = new Date(today.getFullYear(), today.getMonth(), 1)
|
||||
endDate = new Date(today.getFullYear(), today.getMonth() + 1, 0) // 下个月0号=本月最后一天
|
||||
// 最近30天:今天往前29天
|
||||
startDate = new Date(today)
|
||||
startDate.setDate(today.getDate() - 29)
|
||||
endDate = new Date(today)
|
||||
break
|
||||
default:
|
||||
return
|
||||
@@ -395,7 +388,7 @@ onMounted(() => {
|
||||
border-radius: 0.42vw; /* 8px/1920 = 0.42vw */
|
||||
box-shadow: var(--app-shadow);
|
||||
overflow: hidden;
|
||||
height: 100%; /* 充满容器高度 */
|
||||
/* Height is driven by the chart canvas below; card grows to fit content */
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
@@ -506,14 +499,15 @@ onMounted(() => {
|
||||
|
||||
.chart-container {
|
||||
padding: 1.35vw 1.25vw 1.25vw; /* 26px 24px 24px → vw */
|
||||
flex: 1; /* 占据剩余高度 */
|
||||
flex-shrink: 0; /* chart canvas height drives card size — never compress */
|
||||
display: flex;
|
||||
min-height: 0; /* 防止flex溢出 */
|
||||
position: relative; /* establish stacking context for chart canvas */
|
||||
|
||||
.trend-chart {
|
||||
width: 100%;
|
||||
height: 100%; /* 充满容器 */
|
||||
min-height: 12.8vw; /* 最小高度246px/1920 = 12.8vw,确保图表可读性 */
|
||||
height: 18vw; /* ~346px at 1920px reference width */
|
||||
max-height: 248px; /* keeps total card height ≤ 352px (248 + ~50px padding + ~54px header) */
|
||||
min-height: 200px; /* absolute floor so chart is always readable */
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,513 @@
|
||||
<template>
|
||||
<div class="json-editor">
|
||||
<!-- 操作按钮 -->
|
||||
<div class="json-editor-actions">
|
||||
<el-button size="small" @click="addRootEntry">
|
||||
{{ t('jsonEditor.addItem') }}
|
||||
</el-button>
|
||||
<el-button size="small" text :disabled="!hasData" @click="clearAll">
|
||||
{{ t('jsonEditor.clearAll') }}
|
||||
</el-button>
|
||||
<el-button size="small" text :disabled="!hasData" @click="copyJson">
|
||||
{{ t('jsonEditor.copyJson') }}
|
||||
</el-button>
|
||||
<el-button size="small" text @click="openImportDialog">
|
||||
{{ t('jsonEditor.importJson') }}
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<!-- JSON 树形结构 -->
|
||||
<div class="json-tree">
|
||||
<JsonNode
|
||||
v-for="(node, index) in nodes"
|
||||
:key="node.id"
|
||||
:node="node"
|
||||
:depth="0"
|
||||
@update="handleNodeUpdate"
|
||||
@delete="handleNodeDelete(index)"
|
||||
@add-child="handleAddChild"
|
||||
@toggle-expand="handleToggleExpand"
|
||||
/>
|
||||
|
||||
<div v-if="!nodes.length" class="json-empty">
|
||||
{{ t('jsonEditor.emptyState') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- JSON 预览 -->
|
||||
<div class="json-preview">
|
||||
<div class="json-preview-label">{{ t('jsonEditor.preview') }}</div>
|
||||
<el-input
|
||||
type="textarea"
|
||||
:rows="4"
|
||||
:model-value="jsonPreview"
|
||||
readonly
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 导入弹窗 -->
|
||||
<el-dialog
|
||||
v-model="importDialogVisible"
|
||||
:title="t('jsonEditor.importTitle')"
|
||||
width="600px"
|
||||
destroy-on-close
|
||||
>
|
||||
<p class="import-dialog-tip">
|
||||
{{ t('jsonEditor.importDescription') }}
|
||||
</p>
|
||||
<el-input
|
||||
v-model="importDialogContent"
|
||||
type="textarea"
|
||||
:rows="10"
|
||||
:placeholder="t('jsonEditor.importPlaceholder')"
|
||||
/>
|
||||
<p v-if="importDialogError" class="json-editor-error">
|
||||
{{ importDialogError }}
|
||||
</p>
|
||||
<template #footer>
|
||||
<el-button @click="importDialogVisible = false">
|
||||
{{ t('jsonEditor.importCancel') }}
|
||||
</el-button>
|
||||
<el-button type="primary" @click="handleImportConfirm">
|
||||
{{ t('jsonEditor.importConfirm') }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, nextTick } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import JsonNode from './JsonNode.vue'
|
||||
|
||||
export type JsonValueType = 'string' | 'number' | 'boolean' | 'object' | 'array'
|
||||
|
||||
export interface JsonNodeData {
|
||||
id: string
|
||||
key: string
|
||||
value: string
|
||||
type: JsonValueType
|
||||
children: JsonNodeData[]
|
||||
expanded: boolean
|
||||
keyError?: string
|
||||
valueError?: string
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue?: Record<string, any> | string | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: Record<string, any> | string | null): void
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
// 响应式数据
|
||||
const nodes = ref<JsonNodeData[]>([])
|
||||
const importDialogVisible = ref(false)
|
||||
const importDialogContent = ref('')
|
||||
const importDialogError = ref('')
|
||||
|
||||
// 计算属性
|
||||
const hasData = computed(() => nodes.value.length > 0)
|
||||
|
||||
const jsonPreview = computed(() => {
|
||||
const data = buildJsonData(nodes.value)
|
||||
return data ? JSON.stringify(data, null, 2) : ''
|
||||
})
|
||||
|
||||
// 生成唯一ID
|
||||
const createNodeId = () => `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
|
||||
|
||||
// 创建空节点
|
||||
const createEmptyNode = (type: JsonValueType = 'string'): JsonNodeData => ({
|
||||
id: createNodeId(),
|
||||
key: '',
|
||||
value: type === 'object' || type === 'array' ? '' : '',
|
||||
type,
|
||||
children: [],
|
||||
expanded: true,
|
||||
keyError: '',
|
||||
valueError: ''
|
||||
})
|
||||
|
||||
// 构建 JSON 数据
|
||||
const buildJsonData = (nodeList: JsonNodeData[]): Record<string, any> | null => {
|
||||
if (!nodeList.length) return null
|
||||
|
||||
const result: Record<string, any> = {}
|
||||
for (const node of nodeList) {
|
||||
const trimmedKey = node.key.trim()
|
||||
if (!trimmedKey) continue
|
||||
|
||||
let value: any
|
||||
|
||||
switch (node.type) {
|
||||
case 'number':
|
||||
const num = Number(node.value)
|
||||
value = Number.isNaN(num) ? node.value : num
|
||||
break
|
||||
case 'boolean':
|
||||
value = node.value === 'true'
|
||||
break
|
||||
case 'object':
|
||||
value = buildJsonData(node.children)
|
||||
break
|
||||
case 'array':
|
||||
value = node.children.map(child => {
|
||||
if (child.type === 'object') {
|
||||
return buildJsonData(child.children)
|
||||
}
|
||||
if (child.type === 'number') {
|
||||
return Number(child.value)
|
||||
}
|
||||
if (child.type === 'boolean') {
|
||||
return child.value === 'true'
|
||||
}
|
||||
return child.value
|
||||
})
|
||||
break
|
||||
default:
|
||||
value = node.value
|
||||
}
|
||||
|
||||
result[trimmedKey] = value
|
||||
}
|
||||
|
||||
return Object.keys(result).length ? result : null
|
||||
}
|
||||
|
||||
// 解析 JSON 为节点
|
||||
const parseJsonToNodes = (data: unknown): JsonNodeData[] => {
|
||||
if (!data) return []
|
||||
if (typeof data === 'string') {
|
||||
try {
|
||||
data = JSON.parse(data)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof data !== 'object' || data === null) return []
|
||||
|
||||
if (Array.isArray(data)) {
|
||||
return data.map((item, index) => {
|
||||
const isObj = item !== null && typeof item === 'object'
|
||||
const isArr = Array.isArray(item)
|
||||
const itemType: JsonValueType = item === null ? 'string' : (isArr ? 'array' : (isObj ? 'object' : typeof item as JsonValueType))
|
||||
return {
|
||||
id: createNodeId(),
|
||||
key: String(index),
|
||||
value: isObj || isArr ? '' : String(item),
|
||||
type: itemType,
|
||||
children: (isObj || isArr) ? parseJsonToNodes(item) : [],
|
||||
expanded: true,
|
||||
keyError: '',
|
||||
valueError: ''
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return Object.entries(data as Record<string, any>).map(([key, value]) => {
|
||||
let type: JsonValueType = 'string'
|
||||
let children: JsonNodeData[] = []
|
||||
|
||||
if (value === null) {
|
||||
type = 'string'
|
||||
} else if (Array.isArray(value)) {
|
||||
type = 'array'
|
||||
children = value.map((item, index) => {
|
||||
const isObj = item !== null && typeof item === 'object'
|
||||
const isArr = Array.isArray(item)
|
||||
const itemType: JsonValueType = item === null ? 'string' : (isArr ? 'array' : (isObj ? 'object' : typeof item as JsonValueType))
|
||||
return {
|
||||
id: createNodeId(),
|
||||
key: String(index),
|
||||
value: isObj || isArr ? '' : String(item),
|
||||
type: itemType,
|
||||
children: (isObj || isArr) ? parseJsonToNodes(item) : [],
|
||||
expanded: true,
|
||||
keyError: '',
|
||||
valueError: ''
|
||||
}
|
||||
})
|
||||
} else if (typeof value === 'object') {
|
||||
type = 'object'
|
||||
children = parseJsonToNodes(value)
|
||||
} else if (typeof value === 'number') {
|
||||
type = 'number'
|
||||
} else if (typeof value === 'boolean') {
|
||||
type = 'boolean'
|
||||
}
|
||||
|
||||
return {
|
||||
id: createNodeId(),
|
||||
key,
|
||||
value: type === 'object' || type === 'array' ? '' : String(value),
|
||||
type,
|
||||
children,
|
||||
expanded: true,
|
||||
keyError: '',
|
||||
valueError: ''
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 初始化节点
|
||||
const initializeNodes = (value: unknown) => {
|
||||
nodes.value = parseJsonToNodes(value)
|
||||
}
|
||||
|
||||
// 添加根节点
|
||||
const addRootEntry = () => {
|
||||
nodes.value.push(createEmptyNode('string'))
|
||||
emitUpdate()
|
||||
}
|
||||
|
||||
// 清空
|
||||
const clearAll = () => {
|
||||
nodes.value = []
|
||||
emitUpdate()
|
||||
}
|
||||
|
||||
// 复制 JSON
|
||||
const fallbackCopy = (value: string) => {
|
||||
const textarea = document.createElement('textarea')
|
||||
textarea.value = value
|
||||
textarea.style.position = 'fixed'
|
||||
textarea.style.opacity = '0'
|
||||
document.body.appendChild(textarea)
|
||||
textarea.focus()
|
||||
textarea.select()
|
||||
document.execCommand('copy')
|
||||
document.body.removeChild(textarea)
|
||||
}
|
||||
|
||||
const copyJson = async () => {
|
||||
if (!jsonPreview.value) {
|
||||
ElMessage.info(t('jsonEditor.copyEmpty'))
|
||||
return
|
||||
}
|
||||
try {
|
||||
if (navigator?.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(jsonPreview.value)
|
||||
} else {
|
||||
fallbackCopy(jsonPreview.value)
|
||||
}
|
||||
ElMessage.success(t('jsonEditor.copySuccess'))
|
||||
} catch (error) {
|
||||
console.error('复制JSON失败:', error)
|
||||
ElMessage.error(t('jsonEditor.copyError'))
|
||||
}
|
||||
}
|
||||
|
||||
// 导入弹窗
|
||||
const openImportDialog = () => {
|
||||
importDialogContent.value = jsonPreview.value
|
||||
importDialogError.value = ''
|
||||
importDialogVisible.value = true
|
||||
}
|
||||
|
||||
const handleImportConfirm = () => {
|
||||
importDialogError.value = ''
|
||||
const content = importDialogContent.value.trim()
|
||||
|
||||
if (!content) {
|
||||
nodes.value = []
|
||||
importDialogVisible.value = false
|
||||
ElMessage.success(t('jsonEditor.importSuccess'))
|
||||
emitUpdate()
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(content)
|
||||
nodes.value = parseJsonToNodes(parsed)
|
||||
importDialogVisible.value = false
|
||||
ElMessage.success(t('jsonEditor.importSuccess'))
|
||||
emitUpdate()
|
||||
} catch (error: any) {
|
||||
importDialogError.value = error?.message || t('jsonEditor.importFailed')
|
||||
}
|
||||
}
|
||||
|
||||
// 节点更新(子节点输入 key/value 等时同步到 v-model)
|
||||
const handleNodeUpdate = () => {
|
||||
emitUpdate()
|
||||
}
|
||||
|
||||
// 节点删除
|
||||
const handleNodeDelete = (index: number) => {
|
||||
nodes.value.splice(index, 1)
|
||||
emitUpdate()
|
||||
}
|
||||
|
||||
// 添加子节点
|
||||
const handleAddChild = (parentId: string) => {
|
||||
const findAndAdd = (nodeList: JsonNodeData[]): boolean => {
|
||||
for (const node of nodeList) {
|
||||
if (node.id === parentId) {
|
||||
if (node.type === 'array') {
|
||||
node.children.push(createEmptyNode('string'))
|
||||
} else if (node.type === 'object') {
|
||||
node.children.push(createEmptyNode('string'))
|
||||
}
|
||||
return true
|
||||
}
|
||||
if (node.children.length && findAndAdd(node.children)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
findAndAdd(nodes.value)
|
||||
emitUpdate()
|
||||
}
|
||||
|
||||
// 切换展开
|
||||
const handleToggleExpand = (nodeId: string) => {
|
||||
const findAndToggle = (nodeList: JsonNodeData[]): boolean => {
|
||||
for (const node of nodeList) {
|
||||
if (node.id === nodeId) {
|
||||
node.expanded = !node.expanded
|
||||
return true
|
||||
}
|
||||
if (node.children.length && findAndToggle(node.children)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
findAndToggle(nodes.value)
|
||||
}
|
||||
|
||||
// 验证所有节点
|
||||
const validateAll = (): boolean => {
|
||||
let isValid = true
|
||||
const existingKeys = new Map<string, number>()
|
||||
|
||||
const validate = (nodeList: JsonNodeData[], parentPath: string = '') => {
|
||||
for (const node of nodeList) {
|
||||
node.keyError = ''
|
||||
node.valueError = ''
|
||||
|
||||
const trimmedKey = node.key.trim()
|
||||
const fullPath = parentPath ? `${parentPath}.${trimmedKey}` : trimmedKey
|
||||
|
||||
// Key 验证
|
||||
if (!trimmedKey) {
|
||||
node.keyError = t('jsonEditor.keyRequired')
|
||||
isValid = false
|
||||
} else {
|
||||
const keyCount = existingKeys.get(fullPath) || 0
|
||||
if (keyCount > 0) {
|
||||
node.keyError = t('jsonEditor.keyDuplicate')
|
||||
isValid = false
|
||||
}
|
||||
existingKeys.set(fullPath, keyCount + 1)
|
||||
}
|
||||
|
||||
// Value 验证
|
||||
if (node.type === 'number') {
|
||||
if (node.value && Number.isNaN(Number(node.value))) {
|
||||
node.valueError = t('jsonEditor.numberInvalid')
|
||||
isValid = false
|
||||
}
|
||||
}
|
||||
|
||||
// 递归验证子节点
|
||||
if (node.children.length) {
|
||||
validate(node.children, fullPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
validate(nodes.value)
|
||||
return isValid
|
||||
}
|
||||
|
||||
// 暴露方法
|
||||
defineExpose({
|
||||
validate: validateAll,
|
||||
getValue: () => buildJsonData(nodes.value),
|
||||
setValue: initializeNodes
|
||||
})
|
||||
|
||||
// 内部 emit 时跳过 modelValue 回灌,否则 buildJsonData 会丢掉「空 key」占位行,
|
||||
// watch 再 initializeNodes 会把刚添加的行立刻清掉(表现为「添加字段」无效)。
|
||||
const skipModelSync = ref(false)
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(newValue) => {
|
||||
if (skipModelSync.value) return
|
||||
initializeNodes(newValue)
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
const emitUpdate = () => {
|
||||
nextTick(() => {
|
||||
skipModelSync.value = true
|
||||
const newValue = buildJsonData(nodes.value)
|
||||
emit('update:modelValue', newValue)
|
||||
nextTick(() => {
|
||||
skipModelSync.value = false
|
||||
})
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.json-editor {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.json-editor-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.json-tree {
|
||||
border: 1px solid var(--app-border-color);
|
||||
border-radius: 4px;
|
||||
padding: 12px;
|
||||
margin-bottom: 16px;
|
||||
min-height: 100px;
|
||||
background: var(--app-content-bg);
|
||||
}
|
||||
|
||||
.json-empty {
|
||||
text-align: center;
|
||||
color: var(--app-text-secondary);
|
||||
padding: 20px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.json-preview {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.json-preview-label {
|
||||
font-size: 12px;
|
||||
color: var(--app-text-secondary);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.import-dialog-tip {
|
||||
font-size: 13px;
|
||||
color: var(--app-text-secondary);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.json-editor-error {
|
||||
color: #f56c6c;
|
||||
font-size: 12px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,284 @@
|
||||
<template>
|
||||
<div class="json-node" :class="{ 'is-child': depth > 0 }">
|
||||
<!-- 节点内容 -->
|
||||
<div class="json-node-content">
|
||||
<!-- 展开/折叠按钮 (对象和数组) -->
|
||||
<el-button
|
||||
v-if="isContainer"
|
||||
link
|
||||
class="expand-btn"
|
||||
@click="$emit('toggle-expand', node.id)"
|
||||
>
|
||||
<el-icon>
|
||||
<ArrowRight v-if="!node.expanded" />
|
||||
<ArrowDown v-else />
|
||||
</el-icon>
|
||||
</el-button>
|
||||
<span v-else class="expand-placeholder"></span>
|
||||
|
||||
<!-- Key 输入 -->
|
||||
<div class="node-field key-field">
|
||||
<el-input
|
||||
v-model="node.key"
|
||||
:placeholder="t('jsonEditor.keyPlaceholder')"
|
||||
size="small"
|
||||
@input="onKeyInput"
|
||||
/>
|
||||
<p v-if="node.keyError" class="node-error">{{ node.keyError }}</p>
|
||||
</div>
|
||||
|
||||
<!-- 类型选择 -->
|
||||
<div class="node-field type-field">
|
||||
<el-select
|
||||
v-model="node.type"
|
||||
size="small"
|
||||
@change="handleTypeChange"
|
||||
>
|
||||
<el-option
|
||||
v-for="type in typeOptions"
|
||||
:key="type"
|
||||
:label="t(`jsonEditor.types.${type}`)"
|
||||
:value="type"
|
||||
/>
|
||||
</el-select>
|
||||
</div>
|
||||
|
||||
<!-- Value 输入 -->
|
||||
<div class="node-field value-field">
|
||||
<!-- 布尔类型 -->
|
||||
<el-select
|
||||
v-if="node.type === 'boolean'"
|
||||
v-model="node.value"
|
||||
size="small"
|
||||
@change="emit('update')"
|
||||
>
|
||||
<el-option :label="'true'" value="true" />
|
||||
<el-option :label="'false'" value="false" />
|
||||
</el-select>
|
||||
|
||||
<!-- 对象/数组类型 - 显示子节点 -->
|
||||
<template v-else-if="isContainer">
|
||||
<span class="container-label">
|
||||
<template v-if="node.type === 'array'">[{{ node.children.length }}]</template>
|
||||
<template v-else>{ {{ node.children.length }} }</template>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<!-- 数字类型 -->
|
||||
<el-input
|
||||
v-else-if="node.type === 'number'"
|
||||
v-model="node.value"
|
||||
:placeholder="t('jsonEditor.valuePlaceholder')"
|
||||
size="small"
|
||||
type="number"
|
||||
@input="onNumberInput"
|
||||
/>
|
||||
|
||||
<!-- 字符串类型 -->
|
||||
<el-input
|
||||
v-else
|
||||
v-model="node.value"
|
||||
:placeholder="t('jsonEditor.valuePlaceholder')"
|
||||
size="small"
|
||||
@input="onStringValueInput"
|
||||
/>
|
||||
<p v-if="node.valueError" class="node-error">{{ node.valueError }}</p>
|
||||
</div>
|
||||
|
||||
<!-- 操作按钮:根级任意类型都显示删除(原先仅 depth>0 或 根级数组 才有,导致多余行无法删) -->
|
||||
<div class="node-actions">
|
||||
<el-button
|
||||
v-if="isContainer"
|
||||
type="primary"
|
||||
size="small"
|
||||
class="add-child-btn"
|
||||
@click="$emit('add-child', node.id)"
|
||||
>
|
||||
{{ t('jsonEditor.addChild') }}
|
||||
</el-button>
|
||||
<el-button
|
||||
link
|
||||
type="danger"
|
||||
size="small"
|
||||
class="remove-btn"
|
||||
@click="$emit('delete')"
|
||||
>
|
||||
{{ t('jsonEditor.remove') }}
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 子节点容器 -->
|
||||
<div v-if="isContainer && node.expanded" class="json-node-children">
|
||||
<JsonNode
|
||||
v-for="(child, index) in node.children"
|
||||
:key="child.id"
|
||||
:node="child"
|
||||
:depth="depth + 1"
|
||||
@update="$emit('update')"
|
||||
@delete="removeChild(index)"
|
||||
@add-child="(id: string) => $emit('add-child', id)"
|
||||
@toggle-expand="(id: string) => $emit('toggle-expand', id)"
|
||||
/>
|
||||
|
||||
<div v-if="!node.children.length" class="child-empty">
|
||||
{{ node.type === 'array' ? t('jsonEditor.arrayEmpty') : t('jsonEditor.objectEmpty') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick } from 'vue'
|
||||
import { ArrowRight, ArrowDown } from '@element-plus/icons-vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import type { JsonNodeData, JsonValueType } from './JsonEditor.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
node: JsonNodeData
|
||||
depth: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update'): void
|
||||
(e: 'delete'): void
|
||||
(e: 'add-child', id: string): void
|
||||
(e: 'toggle-expand', id: string): void
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const typeOptions: JsonValueType[] = ['string', 'number', 'boolean', 'object', 'array']
|
||||
|
||||
const isContainer = computed(() => props.node.type === 'object' || props.node.type === 'array')
|
||||
|
||||
const onKeyInput = () => {
|
||||
props.node.keyError = ''
|
||||
emit('update')
|
||||
}
|
||||
|
||||
const onNumberInput = () => {
|
||||
props.node.valueError = ''
|
||||
emit('update')
|
||||
}
|
||||
|
||||
const onStringValueInput = () => {
|
||||
props.node.valueError = ''
|
||||
emit('update')
|
||||
}
|
||||
|
||||
const handleTypeChange = (newType: JsonValueType) => {
|
||||
// 清空 children 如果切换到非容器类型
|
||||
if (newType !== 'object' && newType !== 'array') {
|
||||
props.node.children = []
|
||||
}
|
||||
// 清空 value 如果切换到容器类型
|
||||
if (newType === 'object' || newType === 'array') {
|
||||
props.node.value = ''
|
||||
}
|
||||
// 初始化空 children 如果切换到容器类型
|
||||
if ((newType === 'object' || newType === 'array') && !props.node.children.length) {
|
||||
props.node.children = []
|
||||
}
|
||||
nextTick(() => {
|
||||
emit('update')
|
||||
})
|
||||
}
|
||||
|
||||
const removeChild = (index: number) => {
|
||||
nextTick(() => {
|
||||
props.node.children.splice(index, 1)
|
||||
emit('update')
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.json-node {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.json-node.is-child {
|
||||
margin-left: 24px;
|
||||
padding-left: 12px;
|
||||
border-left: 1px dashed var(--app-border-color);
|
||||
}
|
||||
|
||||
.json-node-content {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.expand-btn {
|
||||
padding: 2px;
|
||||
min-width: 20px;
|
||||
}
|
||||
|
||||
.expand-placeholder {
|
||||
width: 20px;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.node-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.key-field {
|
||||
flex: 1;
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.type-field {
|
||||
flex: 0 0 100px;
|
||||
}
|
||||
|
||||
.value-field {
|
||||
flex: 1;
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.container-label {
|
||||
display: inline-block;
|
||||
padding: 4px 12px;
|
||||
background: var(--app-bg-color);
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--app-text-secondary);
|
||||
}
|
||||
|
||||
.node-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* 实心主色 + 白字由全局 element-theme 保证;此处仅做字重 */
|
||||
.add-child-btn {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.remove-btn {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.node-error {
|
||||
color: #f56c6c;
|
||||
font-size: 11px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.json-node-children {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.child-empty {
|
||||
text-align: center;
|
||||
color: var(--app-text-secondary);
|
||||
font-size: 12px;
|
||||
padding: 8px;
|
||||
}
|
||||
</style>
|
||||
@@ -38,7 +38,11 @@ const iconMap = {
|
||||
'invoices': InvoicesIcon,
|
||||
'enterprise-leads': EnterpriseLeadsIcon,
|
||||
'roles': RolesIcon,
|
||||
'users': UsersIcon
|
||||
'users': UsersIcon,
|
||||
'packages': InvoicesIcon,
|
||||
'payments': InvoicesIcon,
|
||||
'product': InvoicesIcon,
|
||||
'system': RolesIcon
|
||||
}
|
||||
|
||||
const iconComponent = computed(() => {
|
||||
@@ -73,4 +77,4 @@ const iconClass = computed(() => ({
|
||||
.sidebar-icon--active :deep(path) {
|
||||
fill: #019C7C;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
@@ -30,10 +30,10 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '@/store/modules/app'
|
||||
import Sidebar from './Sidebar.vue'
|
||||
import NavContent from './NavContent.vue'
|
||||
import { buildSidebarMenus } from '@/utils/adminLocalData'
|
||||
|
||||
// 导航项和面包屑项接口定义
|
||||
interface NavItem {
|
||||
@@ -57,22 +57,6 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
pageTitle: ''
|
||||
})
|
||||
|
||||
// 使用国际化
|
||||
const { t } = useI18n()
|
||||
|
||||
// 默认导航配置
|
||||
const defaultNavItems = computed(() => [
|
||||
{ id: "dashboard", label: t('navigation.menu.dashboard'), href: "/dashboard", icon: "dashboard" },
|
||||
{ id: "customers", label: t('navigation.menu.customers'), href: "/customers", icon: "customers" },
|
||||
{ id: "enterprise-leads", label: t('navigation.menu.enterpriseLeads'), href: "/enterprise-leads", icon: "enterprise-leads" },
|
||||
{ id: "licenses", label: t('navigation.menu.licenses'), href: "/licenses", icon: "licenses" },
|
||||
{ id: "invoices", label: t('navigation.menu.invoices'), href: "/invoices", icon: "invoices" },
|
||||
{ id: "packages", label: t('navigation.menu.packages'), href: "/packages", icon: "packages" }
|
||||
// 暂时隐藏角色和系统用户菜单,后续版本再开启
|
||||
// { id: "roles", label: t('navigation.menu.roles'), href: "/roles", icon: "roles" },
|
||||
// { id: "users", label: t('navigation.menu.users'), href: "/users", icon: "users" }
|
||||
])
|
||||
|
||||
// 使用 store 和路由
|
||||
const appStore = useAppStore()
|
||||
const route = useRoute()
|
||||
@@ -80,10 +64,13 @@ const router = useRouter()
|
||||
|
||||
// 计算当前激活的导航项
|
||||
const navItems = computed(() => {
|
||||
return defaultNavItems.value.map(item => ({
|
||||
return buildSidebarMenus().map(item => ({
|
||||
...item,
|
||||
// 对于有子路由的项目,匹配路径前缀
|
||||
active: route.path === item.href || route.path.startsWith(item.href + '/')
|
||||
active: route.path === item.href || route.path.startsWith(item.href + '/'),
|
||||
children: item.children?.map(child => ({
|
||||
...child,
|
||||
active: route.path === child.href || route.path.startsWith(child.href + '/')
|
||||
})) || []
|
||||
}))
|
||||
})
|
||||
|
||||
@@ -274,4 +261,4 @@ const handleMobileOverlay = () => {
|
||||
transition: none !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
@@ -17,7 +17,23 @@
|
||||
"usernameMinLength": "Username must be at least 3 characters",
|
||||
"passwordRequired": "Please enter password",
|
||||
"passwordMinLength": "Password must be at least 6 characters"
|
||||
}
|
||||
},
|
||||
"heroTitleLine1": "Secure License",
|
||||
"heroTitleLine2": "Management",
|
||||
"heroSubtitle": "Streamline your software licensing workflow with our enterprise-grade platform.",
|
||||
"stats": {
|
||||
"totalAuthCodes": "Total Auth Codes",
|
||||
"totalAuthCodesSubLabel": "2.4K new this month",
|
||||
"activeLicenses": "Active Licenses",
|
||||
"activeLicensesSubLabel": "+12% vs. last month",
|
||||
"systemUptime": "System Uptime",
|
||||
"systemUptimeSubLabel": "24/7 Online",
|
||||
"uptime": "Uptime",
|
||||
"support": "Support"
|
||||
},
|
||||
"welcomeTitle": "Welcome back",
|
||||
"welcomeSubtitle": "Sign in to your account to continue",
|
||||
"copyright": "© 2025 Cedar-V. All rights reserved."
|
||||
},
|
||||
"sidebar": {
|
||||
"expand": "Expand Sidebar",
|
||||
@@ -26,13 +42,19 @@
|
||||
"dashboard": {
|
||||
"title": "Dashboard",
|
||||
"stats": {
|
||||
"totalCustomers": "Total Customers",
|
||||
"totalLicenses": "Total Licenses",
|
||||
"totalAuthCodes": "Total Auth Codes",
|
||||
"activeLicenses": "Active Licenses",
|
||||
"expired": "Expired",
|
||||
"onlineClients": "Online Clients",
|
||||
"expiringSoon": "Expiring Soon",
|
||||
"authSuccessRate24h": "24h Auth Success Rate"
|
||||
"todayNewLicenses": "New Today",
|
||||
"expiringIn7Days": "Expiring in 7 Days",
|
||||
"expiringIn30Days": "Expiring in 30 Days",
|
||||
"abnormalAlerts": "Abnormal Alerts",
|
||||
"subText": {
|
||||
"monthNew": "{n} new this month",
|
||||
"vsLastMonth": "{rate}% vs. last month",
|
||||
"yesterday": "{n} yesterday",
|
||||
"urgent": "Urgent Follow-up",
|
||||
"heartbeatTimeout": "Heartbeat Timeout"
|
||||
}
|
||||
},
|
||||
"recentLicenses": {
|
||||
"title": "Recent Licenses",
|
||||
@@ -179,14 +201,30 @@
|
||||
"remarkPlaceholder": "Please enter remark",
|
||||
"featurePlaceholder": "Please enter feature content"
|
||||
},
|
||||
"create": {
|
||||
"title": "Create Package",
|
||||
"save": "Create Package"
|
||||
},
|
||||
"actions": {
|
||||
"edit": "Edit"
|
||||
"create": "Create",
|
||||
"edit": "Edit",
|
||||
"enable": "Enable",
|
||||
"disable": "Disable",
|
||||
"delete": "Delete"
|
||||
},
|
||||
"messages": {
|
||||
"fetchError": "Failed to fetch list",
|
||||
"fetchDetailError": "Failed to fetch details",
|
||||
"createSuccess": "Created successfully",
|
||||
"createError": "Failed to create",
|
||||
"updateSuccess": "Updated successfully",
|
||||
"updateError": "Failed to update"
|
||||
"updateError": "Failed to update",
|
||||
"toggleSuccess": "Status updated successfully",
|
||||
"toggleError": "Failed to update status",
|
||||
"toggleConfirm": "Are you sure to {action} package \"{name}\"?",
|
||||
"deleteSuccess": "Deleted successfully",
|
||||
"deleteError": "Failed to delete",
|
||||
"deleteConfirm": "Are you sure you want to delete package \"{name}\"? This action cannot be undone."
|
||||
}
|
||||
},
|
||||
|
||||
@@ -295,9 +333,8 @@
|
||||
"licenseTrend": {
|
||||
"title": "License Trend",
|
||||
"quickOptions": {
|
||||
"today": "Today",
|
||||
"week": "This Week",
|
||||
"month": "This Month"
|
||||
"week": "Last 7 Days",
|
||||
"month": "Last 30 Days"
|
||||
},
|
||||
"datePicker": {
|
||||
"rangeSeparator": "to",
|
||||
@@ -314,9 +351,8 @@
|
||||
"endDate": "End Date",
|
||||
"separator": "to",
|
||||
"quickOptions": {
|
||||
"today": "Today",
|
||||
"week": "This Week",
|
||||
"month": "This Month"
|
||||
"week": "Last 7 Days",
|
||||
"month": "Last 30 Days"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -793,7 +829,7 @@
|
||||
"importTitleLimit": "Import Usage Limits JSON",
|
||||
"importTitleCustom": "Import Custom Parameters JSON",
|
||||
"importDescription": "Paste a JSON object whose values can be string, number, or boolean. Confirming will overwrite current entries.",
|
||||
"importPlaceholder": "{ \"feature_dashboard\": \"enabled\" }",
|
||||
"importPlaceholder": "Please enter a JSON object",
|
||||
"importCancel": "Cancel",
|
||||
"importConfirm": "Parse & Overwrite",
|
||||
"importSuccess": "JSON imported successfully",
|
||||
@@ -944,5 +980,42 @@
|
||||
"days": "{days} day(s)"
|
||||
}
|
||||
}
|
||||
},
|
||||
"jsonEditor": {
|
||||
"addItem": "Add Field",
|
||||
"clearAll": "Clear",
|
||||
"copyJson": "Copy JSON",
|
||||
"importJson": "Import JSON",
|
||||
"emptyState": "No data yet, add fields or import JSON",
|
||||
"preview": "JSON Preview",
|
||||
"copyEmpty": "Nothing to copy",
|
||||
"copySuccess": "Copied successfully",
|
||||
"copyError": "Copy failed",
|
||||
"importTitle": "Import JSON",
|
||||
"importDescription": "Please enter valid JSON string, supports nested objects and arrays",
|
||||
"importPlaceholder": "Please enter a valid JSON string",
|
||||
"importCancel": "Cancel",
|
||||
"importConfirm": "Import",
|
||||
"importSuccess": "Imported successfully",
|
||||
"importFailed": "Invalid JSON format",
|
||||
"keyLabel": "Key",
|
||||
"keyPlaceholder": "Enter key",
|
||||
"valueLabel": "Value",
|
||||
"valuePlaceholder": "Enter value",
|
||||
"typeLabel": "Type",
|
||||
"keyRequired": "Key is required",
|
||||
"keyDuplicate": "Duplicate key",
|
||||
"numberInvalid": "Please enter a valid number",
|
||||
"addChild": "Add Child",
|
||||
"remove": "Delete",
|
||||
"arrayEmpty": "Array is empty, click to add child",
|
||||
"objectEmpty": "Object is empty, click to add child",
|
||||
"types": {
|
||||
"string": "String",
|
||||
"number": "Number",
|
||||
"boolean": "Boolean",
|
||||
"object": "Object",
|
||||
"array": "Array"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,23 @@
|
||||
"usernameMinLength": "ユーザー名は3文字以上である必要があります",
|
||||
"passwordRequired": "パスワードを入力してください",
|
||||
"passwordMinLength": "パスワードは6文字以上である必要があります"
|
||||
}
|
||||
},
|
||||
"heroTitleLine1": "セキュアな",
|
||||
"heroTitleLine2": "ライセンス管理",
|
||||
"heroSubtitle": "エンタープライズグレードのプラットフォームで、ソフトウェアライセンス管理を効率化。",
|
||||
"stats": {
|
||||
"totalAuthCodes": "認可コード総数",
|
||||
"totalAuthCodesSubLabel": "今月 2.4K 件追加",
|
||||
"activeLicenses": "アクティブライセンス",
|
||||
"activeLicensesSubLabel": "先月比 +12%",
|
||||
"systemUptime": "システム稼働率",
|
||||
"systemUptimeSubLabel": "24/7 オンライン",
|
||||
"uptime": "稼働率",
|
||||
"support": "サポート"
|
||||
},
|
||||
"welcomeTitle": "おかえりなさい",
|
||||
"welcomeSubtitle": "アカウントにサインインして続けてください",
|
||||
"copyright": "© 2025 Cedar-V. All rights reserved."
|
||||
},
|
||||
"sidebar": {
|
||||
"expand": "サイドバーを展開",
|
||||
@@ -26,13 +42,19 @@
|
||||
"dashboard": {
|
||||
"title": "ダッシュボード",
|
||||
"stats": {
|
||||
"totalCustomers": "顧客総数",
|
||||
"totalLicenses": "総ライセンス数",
|
||||
"totalAuthCodes": "認可コード総数",
|
||||
"activeLicenses": "アクティブライセンス",
|
||||
"expired": "期限切れ",
|
||||
"onlineClients": "オンラインクライアント数",
|
||||
"expiringSoon": "期限近し",
|
||||
"authSuccessRate24h": "24時間認証成功率"
|
||||
"todayNewLicenses": "本日の新規",
|
||||
"expiringIn7Days": "7日以内に期限切れ",
|
||||
"expiringIn30Days": "30日以内に期限切れ",
|
||||
"abnormalAlerts": "異常アラート",
|
||||
"subText": {
|
||||
"monthNew": "今月 {n} 件追加",
|
||||
"vsLastMonth": "先月比 {rate}%",
|
||||
"yesterday": "昨日 {n} 件",
|
||||
"urgent": "緊急対応が必要",
|
||||
"heartbeatTimeout": "ハートビートタイムアウト"
|
||||
}
|
||||
},
|
||||
"recentLicenses": {
|
||||
"title": "最近のライセンス",
|
||||
@@ -179,14 +201,30 @@
|
||||
"remarkPlaceholder": "備考を入力してください",
|
||||
"featurePlaceholder": "機能内容を入力してください"
|
||||
},
|
||||
"create": {
|
||||
"title": "パッケージ新規作成",
|
||||
"save": "パッケージを作成"
|
||||
},
|
||||
"actions": {
|
||||
"edit": "編集"
|
||||
"create": "新規作成",
|
||||
"edit": "編集",
|
||||
"enable": "有効化",
|
||||
"disable": "無効化",
|
||||
"delete": "削除"
|
||||
},
|
||||
"messages": {
|
||||
"fetchError": "一覧の取得に失敗しました",
|
||||
"fetchDetailError": "詳細の取得に失敗しました",
|
||||
"createSuccess": "作成しました",
|
||||
"createError": "作成に失敗しました",
|
||||
"updateSuccess": "更新しました",
|
||||
"updateError": "更新に失敗しました"
|
||||
"updateError": "更新に失敗しました",
|
||||
"toggleSuccess": "ステータスを更新しました",
|
||||
"toggleError": "ステータス更新に失敗しました",
|
||||
"toggleConfirm": "パッケージ「{name}」を{action}してもよろしいですか?",
|
||||
"deleteSuccess": "削除しました",
|
||||
"deleteError": "削除に失敗しました",
|
||||
"deleteConfirm": "パッケージ「{name}」を削除しますか?この操作は元に戻せません。"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -294,9 +332,8 @@
|
||||
"licenseTrend": {
|
||||
"title": "ライセンストレンド",
|
||||
"quickOptions": {
|
||||
"today": "今日",
|
||||
"week": "今週",
|
||||
"month": "今月"
|
||||
"week": "直7日間",
|
||||
"month": "直30日間"
|
||||
},
|
||||
"datePicker": {
|
||||
"rangeSeparator": "から",
|
||||
@@ -313,9 +350,8 @@
|
||||
"endDate": "終了日付",
|
||||
"separator": "から",
|
||||
"quickOptions": {
|
||||
"today": "今日",
|
||||
"week": "今週",
|
||||
"month": "今月"
|
||||
"week": "直7日間",
|
||||
"month": "直30日間"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -792,7 +828,7 @@
|
||||
"importTitleLimit": "利用制限 JSON をインポート",
|
||||
"importTitleCustom": "カスタムパラメータ JSON をインポート",
|
||||
"importDescription": "キーを設定名とするオブジェクト形式の JSON を貼り付けてください。値は文字列・数値・ブールをサポートします。確定すると現在のリストを上書きします。",
|
||||
"importPlaceholder": "{ \"feature_dashboard\": \"enabled\" }",
|
||||
"importPlaceholder": "JSONオブジェクトを入力してください",
|
||||
"importCancel": "キャンセル",
|
||||
"importConfirm": "解析して上書き",
|
||||
"importSuccess": "JSON を取り込みました",
|
||||
@@ -943,5 +979,42 @@
|
||||
"days": "{days}日"
|
||||
}
|
||||
}
|
||||
},
|
||||
"jsonEditor": {
|
||||
"addItem": "フィールド追加",
|
||||
"clearAll": "クリア",
|
||||
"copyJson": "JSONをコピー",
|
||||
"importJson": "JSONをインポート",
|
||||
"emptyState": "データがありません。フィールドを追加するかJSONをインポートしてください",
|
||||
"preview": "JSONプレビュー",
|
||||
"copyEmpty": "コピーするJSONがありません",
|
||||
"copySuccess": "コピー成功",
|
||||
"copyError": "コピー失敗",
|
||||
"importTitle": "JSONをインポート",
|
||||
"importDescription": "有効なJSON文字列を入力してください。ネストされたオブジェクトと配列をサポートします",
|
||||
"importPlaceholder": "有効なJSON文字列を入力してください",
|
||||
"importCancel": "キャンセル",
|
||||
"importConfirm": "インポート",
|
||||
"importSuccess": "インポート成功",
|
||||
"importFailed": "JSON形式が無効です",
|
||||
"keyLabel": "キー",
|
||||
"keyPlaceholder": "キーを入力",
|
||||
"valueLabel": "値",
|
||||
"valuePlaceholder": "値を入力",
|
||||
"typeLabel": "タイプ",
|
||||
"keyRequired": "キーを入力してください",
|
||||
"keyDuplicate": "キーが重複しています",
|
||||
"numberInvalid": "有効な数値を入力してください",
|
||||
"addChild": "子要素を追加",
|
||||
"remove": "削除",
|
||||
"arrayEmpty": "配列が空です。クリックして子要素を追加",
|
||||
"objectEmpty": "オブジェクトが空です。クリックして子要素を追加",
|
||||
"types": {
|
||||
"string": "文字列",
|
||||
"number": "数値",
|
||||
"boolean": "真偽値",
|
||||
"object": "オブジェクト",
|
||||
"array": "配列"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,23 @@
|
||||
"usernameMinLength": "用户名至少需要3个字符",
|
||||
"passwordRequired": "请输入密码",
|
||||
"passwordMinLength": "密码至少需要6个字符"
|
||||
}
|
||||
},
|
||||
"heroTitleLine1": "软件授权",
|
||||
"heroTitleLine2": "管理系统",
|
||||
"heroSubtitle": "赋能软件商业化,构建 B2B 与 B2C 全场景的软件授权体系。",
|
||||
"stats": {
|
||||
"totalAuthCodes": "授权码总量",
|
||||
"totalAuthCodesSubLabel": "本月新增 2.4K",
|
||||
"activeLicenses": "活跃许可证",
|
||||
"activeLicensesSubLabel": "较上月 +12%",
|
||||
"systemUptime": "系统可用性",
|
||||
"systemUptimeSubLabel": "24/7 在线",
|
||||
"uptime": "可用率",
|
||||
"support": "全天支持"
|
||||
},
|
||||
"welcomeTitle": "授权管理平台",
|
||||
"welcomeSubtitle": "登录您的账户",
|
||||
"copyright": "© 2025 Cedar-V. 保留所有权利。"
|
||||
},
|
||||
"sidebar": {
|
||||
"expand": "展开侧边栏",
|
||||
@@ -26,13 +42,19 @@
|
||||
"dashboard": {
|
||||
"title": "仪表盘",
|
||||
"stats": {
|
||||
"totalCustomers": "总授权码",
|
||||
"totalLicenses": "活跃许可证",
|
||||
"activeLicenses": "即将过期",
|
||||
"expired": "异常警告",
|
||||
"onlineClients": "增长率",
|
||||
"expiringSoon": "授权码增长率",
|
||||
"authSuccessRate24h": "许可证增长率"
|
||||
"totalAuthCodes": "授权码总量",
|
||||
"activeLicenses": "活跃许可证",
|
||||
"todayNewLicenses": "今日新增授权",
|
||||
"expiringIn7Days": "7天内到期",
|
||||
"expiringIn30Days": "30天内到期",
|
||||
"abnormalAlerts": "异常告警",
|
||||
"subText": {
|
||||
"monthNew": "本月新增 {n} 个",
|
||||
"vsLastMonth": "较上月 {rate}%",
|
||||
"yesterday": "昨日 {n} 个",
|
||||
"urgent": "紧急跟进",
|
||||
"heartbeatTimeout": "心跳超时"
|
||||
}
|
||||
},
|
||||
"recentLicenses": {
|
||||
"title": "最近授权",
|
||||
@@ -212,14 +234,30 @@
|
||||
"remarkPlaceholder": "请输入备注",
|
||||
"featurePlaceholder": "请输入特性内容"
|
||||
},
|
||||
"create": {
|
||||
"title": "新增套餐",
|
||||
"save": "创建套餐"
|
||||
},
|
||||
"actions": {
|
||||
"edit": "编辑"
|
||||
"create": "新增套餐",
|
||||
"edit": "编辑",
|
||||
"enable": "启用",
|
||||
"disable": "禁用",
|
||||
"delete": "删除"
|
||||
},
|
||||
"messages": {
|
||||
"fetchError": "获取列表失败",
|
||||
"fetchDetailError": "获取详情失败",
|
||||
"createSuccess": "创建成功",
|
||||
"createError": "创建失败",
|
||||
"updateSuccess": "更新成功",
|
||||
"updateError": "更新失败"
|
||||
"updateError": "更新失败",
|
||||
"toggleSuccess": "状态更新成功",
|
||||
"toggleError": "状态更新失败",
|
||||
"toggleConfirm": "确认{action}套餐“{name}”吗?",
|
||||
"deleteSuccess": "删除成功",
|
||||
"deleteError": "删除失败",
|
||||
"deleteConfirm": "确认删除套餐“{name}”吗?删除后不可恢复。"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -328,9 +366,8 @@
|
||||
"licenseTrend": {
|
||||
"title": "授权趋势",
|
||||
"quickOptions": {
|
||||
"today": "本日",
|
||||
"week": "本周",
|
||||
"month": "本月"
|
||||
"week": "最近7天",
|
||||
"month": "最近30天"
|
||||
},
|
||||
"datePicker": {
|
||||
"rangeSeparator": "至",
|
||||
@@ -347,9 +384,8 @@
|
||||
"endDate": "结束日期",
|
||||
"separator": "至",
|
||||
"quickOptions": {
|
||||
"today": "本日",
|
||||
"week": "本周",
|
||||
"month": "本月"
|
||||
"week": "最近7天",
|
||||
"month": "最近30天"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -826,7 +862,7 @@
|
||||
"importTitleLimit": "导入使用限制 JSON",
|
||||
"importTitleCustom": "导入自定义参数 JSON",
|
||||
"importDescription": "粘贴一个对象格式的 JSON,键为配置项,值支持字符串、数字或布尔类型。提交后将覆盖当前列表。",
|
||||
"importPlaceholder": "{ \"feature_dashboard\": \"enabled\" }",
|
||||
"importPlaceholder": "请输入 JSON 对象",
|
||||
"importCancel": "取消",
|
||||
"importConfirm": "解析并覆盖",
|
||||
"importSuccess": "JSON 解析成功",
|
||||
@@ -977,5 +1013,42 @@
|
||||
"days": "{days}天"
|
||||
}
|
||||
}
|
||||
},
|
||||
"jsonEditor": {
|
||||
"addItem": "添加字段",
|
||||
"clearAll": "清空",
|
||||
"copyJson": "复制JSON",
|
||||
"importJson": "导入JSON",
|
||||
"emptyState": "暂无数据,请添加字段或导入JSON",
|
||||
"preview": "JSON 预览",
|
||||
"copyEmpty": "没有可复制的JSON",
|
||||
"copySuccess": "复制成功",
|
||||
"copyError": "复制失败",
|
||||
"importTitle": "导入 JSON",
|
||||
"importDescription": "请输入有效的 JSON 字符串,支持嵌套对象和数组",
|
||||
"importPlaceholder": "请输入有效的 JSON 字符串",
|
||||
"importCancel": "取消",
|
||||
"importConfirm": "导入",
|
||||
"importSuccess": "导入成功",
|
||||
"importFailed": "JSON 格式不正确",
|
||||
"keyLabel": "键名",
|
||||
"keyPlaceholder": "请输入键名",
|
||||
"valueLabel": "值",
|
||||
"valuePlaceholder": "请输入值",
|
||||
"typeLabel": "类型",
|
||||
"keyRequired": "请输入键名",
|
||||
"keyDuplicate": "键名重复",
|
||||
"numberInvalid": "请输入有效数字",
|
||||
"addChild": "添加子项",
|
||||
"remove": "删除",
|
||||
"arrayEmpty": "数组为空,点击添加子项",
|
||||
"objectEmpty": "对象为空,点击添加子项",
|
||||
"types": {
|
||||
"string": "字符串",
|
||||
"number": "数字",
|
||||
"boolean": "布尔",
|
||||
"object": "对象",
|
||||
"array": "数组"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,11 +10,6 @@ import { createApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
|
||||
// Noto Sans SC 字体(@fontsource 提供预优化子集化版本)
|
||||
import '@fontsource/noto-sans-sc/400.css'
|
||||
import '@fontsource/noto-sans-sc/500.css'
|
||||
import '@fontsource/noto-sans-sc/700.css'
|
||||
|
||||
// 全局样式系统
|
||||
import './assets/styles/fonts.css' // 其他本地字体
|
||||
import './assets/styles/global.scss'
|
||||
@@ -25,10 +20,9 @@ import pinia from './store'
|
||||
import { useAppStore } from './store/modules/app'
|
||||
import { useUserStore } from './store/modules/user'
|
||||
|
||||
import ElementPlus from 'element-plus'
|
||||
import 'element-plus/dist/index.css'
|
||||
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
|
||||
import i18n, { getCurrentElementLocale } from './i18n'
|
||||
import i18n from './i18n'
|
||||
|
||||
|
||||
const app = createApp(App)
|
||||
@@ -38,10 +32,6 @@ for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
|
||||
app.component(key, component)
|
||||
}
|
||||
|
||||
// 正确配置Element Plus语言包
|
||||
app.use(ElementPlus, {
|
||||
locale: getCurrentElementLocale()
|
||||
})
|
||||
app.use(pinia)
|
||||
app.use(i18n)
|
||||
app.use(router)
|
||||
|
||||
@@ -19,6 +19,11 @@ const routes: Array<RouteRecordRaw> = [
|
||||
component: () => import("@/views/Login.vue"),
|
||||
meta: { title: "登录" }
|
||||
},
|
||||
{
|
||||
path: "/public-renewal",
|
||||
component: () => import("@/views/PublicRenewal.vue"),
|
||||
meta: { title: "授权续期购买" }
|
||||
},
|
||||
{
|
||||
path: "/dashboard",
|
||||
component: () => import("@/views/Dashboard.vue"),
|
||||
@@ -29,6 +34,11 @@ const routes: Array<RouteRecordRaw> = [
|
||||
component: () => import("@/views/Customers/index.vue"),
|
||||
meta: { title: "客户管理", requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: "/customer-archives",
|
||||
component: () => import("@/views/Customers/index.vue"),
|
||||
meta: { title: "客户档案", requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: "/licenses",
|
||||
name: "licenses",
|
||||
@@ -76,6 +86,21 @@ const routes: Array<RouteRecordRaw> = [
|
||||
component: () => import("@/views/Packages/index.vue"),
|
||||
meta: { title: "套餐管理", requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: "/product-items",
|
||||
component: () => import("@/views/ProductItems.vue"),
|
||||
meta: { title: "产品项管理", requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: "/feature-templates",
|
||||
component: () => import("@/views/FeatureTemplates.vue"),
|
||||
meta: { title: "功能模板", requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: "/payment-settings",
|
||||
component: () => import("@/views/PaymentSettings.vue"),
|
||||
meta: { title: "支付配置", requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: "/invoices/detail/:id",
|
||||
name: "invoice-detail",
|
||||
@@ -92,6 +117,16 @@ const routes: Array<RouteRecordRaw> = [
|
||||
component: () => import("@/views/Users.vue"),
|
||||
meta: { title: "用户管理", requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: "/menu-management",
|
||||
component: () => import("@/views/MenuManagement.vue"),
|
||||
meta: { title: "菜单管理", requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: "/audit-logs",
|
||||
component: () => import("@/views/AuditLogs.vue"),
|
||||
meta: { title: "审计日志", requiresAuth: true }
|
||||
},
|
||||
];
|
||||
|
||||
const router = createRouter({
|
||||
@@ -117,4 +152,3 @@ router.beforeEach((to, _from, next) => {
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -0,0 +1,346 @@
|
||||
export interface AdminRole {
|
||||
id: string
|
||||
name: string
|
||||
code: string
|
||||
description: string
|
||||
permissions: string[]
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface AdminUser {
|
||||
id: string
|
||||
username: string
|
||||
roleCode: string
|
||||
phone: string
|
||||
email: string
|
||||
status: 'active' | 'disabled'
|
||||
lastLoginAt: string
|
||||
}
|
||||
|
||||
export interface FeatureTemplate {
|
||||
id: string
|
||||
name: string
|
||||
category: string
|
||||
description: string
|
||||
content: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface AuditLogItem {
|
||||
id: string
|
||||
module: string
|
||||
action: string
|
||||
operator: string
|
||||
detail: string
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface PaymentProviderSetting {
|
||||
enabled: boolean
|
||||
appId: string
|
||||
gatewayUrl: string
|
||||
notifyUrl: string
|
||||
returnUrl: string
|
||||
signType: string
|
||||
remark: string
|
||||
}
|
||||
|
||||
export interface PaymentSettings {
|
||||
defaultMethod: 'alipay' | 'wechat'
|
||||
expireMinutes: number
|
||||
retryTimes: number
|
||||
enableLog: boolean
|
||||
alipay: PaymentProviderSetting
|
||||
wechat: PaymentProviderSetting
|
||||
}
|
||||
|
||||
export interface AdminMenuItem {
|
||||
id: string
|
||||
label: string
|
||||
href: string
|
||||
icon?: string
|
||||
visible: boolean
|
||||
children?: AdminMenuItem[]
|
||||
}
|
||||
|
||||
const STORAGE_KEYS = {
|
||||
roles: 'cedar-admin-roles',
|
||||
users: 'cedar-admin-users',
|
||||
templates: 'cedar-feature-templates',
|
||||
menus: 'cedar-admin-menus',
|
||||
logs: 'cedar-admin-logs',
|
||||
paymentSettings: 'cedar-payment-settings'
|
||||
}
|
||||
|
||||
const now = () => new Date().toISOString()
|
||||
const uid = (prefix: string) => `${prefix}_${Math.random().toString(36).slice(2, 10)}`
|
||||
|
||||
const defaultRoles: AdminRole[] = [
|
||||
{
|
||||
id: uid('role'),
|
||||
name: '超级管理员',
|
||||
code: 'admin',
|
||||
description: '拥有全部功能访问与配置权限',
|
||||
permissions: ['dashboard', 'customers', 'licenses', 'system'],
|
||||
createdAt: now()
|
||||
},
|
||||
{
|
||||
id: uid('role'),
|
||||
name: '运营人员',
|
||||
code: 'operator',
|
||||
description: '可处理客户、授权、发票与线索',
|
||||
permissions: ['dashboard', 'customers', 'licenses', 'invoices', 'leads'],
|
||||
createdAt: now()
|
||||
}
|
||||
]
|
||||
|
||||
const defaultUsers: AdminUser[] = [
|
||||
{
|
||||
id: uid('user'),
|
||||
username: 'admin',
|
||||
roleCode: 'admin',
|
||||
phone: '13800000000',
|
||||
email: '[email protected]',
|
||||
status: 'active',
|
||||
lastLoginAt: now()
|
||||
},
|
||||
{
|
||||
id: uid('user'),
|
||||
username: 'operator',
|
||||
roleCode: 'operator',
|
||||
phone: '13900000000',
|
||||
email: '[email protected]',
|
||||
status: 'active',
|
||||
lastLoginAt: now()
|
||||
}
|
||||
]
|
||||
|
||||
const defaultTemplates: FeatureTemplate[] = [
|
||||
{
|
||||
id: uid('tpl'),
|
||||
name: '标准授权模板',
|
||||
category: '授权',
|
||||
description: '适用于标准商业授权场景',
|
||||
content: JSON.stringify({ deployment: 'cloud', activations: 5, support: 'standard' }, null, 2),
|
||||
updatedAt: now()
|
||||
},
|
||||
{
|
||||
id: uid('tpl'),
|
||||
name: '企业续期模板',
|
||||
category: '续期',
|
||||
description: '适用于企业客户统一续期',
|
||||
content: JSON.stringify({ renew_days: 365, invoice: true, priority: 'high' }, null, 2),
|
||||
updatedAt: now()
|
||||
}
|
||||
]
|
||||
|
||||
const defaultMenus: AdminMenuItem[] = [
|
||||
{ id: 'dashboard', label: '仪表盘', href: '/dashboard', icon: 'dashboard', visible: true },
|
||||
{
|
||||
id: 'customer-center',
|
||||
label: '客户管理',
|
||||
href: '/customer-archives',
|
||||
icon: 'customers',
|
||||
visible: true,
|
||||
children: [
|
||||
{ id: 'customer-archives', label: '客户档案', href: '/customer-archives', visible: true }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'product-center',
|
||||
label: '产品管理',
|
||||
href: '/product-items',
|
||||
icon: 'invoices',
|
||||
visible: true,
|
||||
children: [
|
||||
{ id: 'product-items', label: '产品项管理', href: '/product-items', visible: true },
|
||||
{ id: 'feature-templates', label: '功能模板', href: '/feature-templates', visible: true }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'license-center',
|
||||
label: '授权管理',
|
||||
href: '/licenses/list',
|
||||
icon: 'licenses',
|
||||
visible: true
|
||||
},
|
||||
{
|
||||
id: 'payment-center',
|
||||
label: '支付管理',
|
||||
href: '/payment-settings',
|
||||
icon: 'payments',
|
||||
visible: true,
|
||||
children: [
|
||||
{ id: 'payment-settings', label: '支付配置', href: '/payment-settings', visible: true }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'system-center',
|
||||
label: '系统管理',
|
||||
href: '/roles',
|
||||
icon: 'roles',
|
||||
visible: true,
|
||||
children: [
|
||||
{ id: 'roles', label: '角色权限', href: '/roles', visible: true },
|
||||
{ id: 'users', label: '系统用户', href: '/users', visible: true },
|
||||
{ id: 'menu-management', label: '菜单管理', href: '/menu-management', visible: true },
|
||||
{ id: 'audit-logs', label: '审计日志', href: '/audit-logs', visible: true }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'user-side-center',
|
||||
label: '用户端管理',
|
||||
href: '/enterprise-leads',
|
||||
icon: 'users',
|
||||
visible: true,
|
||||
children: [
|
||||
{ id: 'enterprise-leads', label: '企业线索', href: '/enterprise-leads', visible: true },
|
||||
{ id: 'invoices', label: '发票管理', href: '/invoices', visible: true },
|
||||
{ id: 'packages', label: '套餐管理', href: '/packages', visible: true }
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
const defaultLogs: AuditLogItem[] = [
|
||||
{
|
||||
id: uid('log'),
|
||||
module: 'system',
|
||||
action: '初始化',
|
||||
operator: 'system',
|
||||
detail: '初始化后台配置数据',
|
||||
createdAt: now()
|
||||
}
|
||||
]
|
||||
|
||||
const defaultPaymentSettings: PaymentSettings = {
|
||||
defaultMethod: 'alipay',
|
||||
expireMinutes: 30,
|
||||
retryTimes: 3,
|
||||
enableLog: true,
|
||||
alipay: {
|
||||
enabled: true,
|
||||
appId: '9021000159611786',
|
||||
gatewayUrl: 'https://openapi-sandbox.dl.alipaydev.com/gateway.do',
|
||||
notifyUrl: 'http://localhost:18888/api/payment/alipay/callback',
|
||||
returnUrl: 'http://localhost:18080/public-renewal',
|
||||
signType: 'RSA2',
|
||||
remark: '默认使用支付宝沙箱配置,部署生产环境前请替换正式应用参数。'
|
||||
},
|
||||
wechat: {
|
||||
enabled: false,
|
||||
appId: '',
|
||||
gatewayUrl: '',
|
||||
notifyUrl: '',
|
||||
returnUrl: '',
|
||||
signType: 'HMAC-SHA256',
|
||||
remark: '预留微信支付配置位,当前项目后端默认支付链路仍以支付宝为主。'
|
||||
}
|
||||
}
|
||||
|
||||
function readStorage<T>(key: string, fallback: T): T {
|
||||
const raw = localStorage.getItem(key)
|
||||
if (!raw) {
|
||||
localStorage.setItem(key, JSON.stringify(fallback))
|
||||
return fallback
|
||||
}
|
||||
try {
|
||||
return JSON.parse(raw) as T
|
||||
} catch {
|
||||
localStorage.setItem(key, JSON.stringify(fallback))
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
|
||||
function writeStorage<T>(key: string, value: T) {
|
||||
localStorage.setItem(key, JSON.stringify(value))
|
||||
}
|
||||
|
||||
function mergeMenuDefaults(current: AdminMenuItem[], defaults: AdminMenuItem[]) {
|
||||
const currentMap = new Map(current.map(item => [item.id, item]))
|
||||
|
||||
return defaults.map(defaultItem => {
|
||||
const existing = currentMap.get(defaultItem.id)
|
||||
if (!existing) {
|
||||
return defaultItem
|
||||
}
|
||||
|
||||
const existingChildren = existing.children || []
|
||||
const defaultChildren = defaultItem.children || []
|
||||
const mergedChildren = defaultChildren.map(defaultChild => {
|
||||
const existingChild = existingChildren.find(child => child.id === defaultChild.id)
|
||||
return existingChild ? { ...defaultChild, ...existingChild } : defaultChild
|
||||
})
|
||||
|
||||
const customChildren = existingChildren.filter(child => !defaultChildren.some(defaultChild => defaultChild.id === child.id))
|
||||
|
||||
return {
|
||||
...defaultItem,
|
||||
...existing,
|
||||
children: [...mergedChildren, ...customChildren]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function getRoles() {
|
||||
return readStorage(STORAGE_KEYS.roles, defaultRoles)
|
||||
}
|
||||
|
||||
export function saveRoles(data: AdminRole[]) {
|
||||
writeStorage(STORAGE_KEYS.roles, data)
|
||||
}
|
||||
|
||||
export function getUsers() {
|
||||
return readStorage(STORAGE_KEYS.users, defaultUsers)
|
||||
}
|
||||
|
||||
export function saveUsers(data: AdminUser[]) {
|
||||
writeStorage(STORAGE_KEYS.users, data)
|
||||
}
|
||||
|
||||
export function getFeatureTemplates() {
|
||||
return readStorage(STORAGE_KEYS.templates, defaultTemplates)
|
||||
}
|
||||
|
||||
export function saveFeatureTemplates(data: FeatureTemplate[]) {
|
||||
writeStorage(STORAGE_KEYS.templates, data)
|
||||
}
|
||||
|
||||
export function getAdminMenus() {
|
||||
const menus = readStorage(STORAGE_KEYS.menus, defaultMenus)
|
||||
const merged = mergeMenuDefaults(menus, defaultMenus)
|
||||
writeStorage(STORAGE_KEYS.menus, merged)
|
||||
return merged
|
||||
}
|
||||
|
||||
export function saveAdminMenus(data: AdminMenuItem[]) {
|
||||
writeStorage(STORAGE_KEYS.menus, data)
|
||||
}
|
||||
|
||||
export function getAuditLogs() {
|
||||
return readStorage(STORAGE_KEYS.logs, defaultLogs)
|
||||
}
|
||||
|
||||
export function getPaymentSettings() {
|
||||
return readStorage(STORAGE_KEYS.paymentSettings, defaultPaymentSettings)
|
||||
}
|
||||
|
||||
export function savePaymentSettings(data: PaymentSettings) {
|
||||
writeStorage(STORAGE_KEYS.paymentSettings, data)
|
||||
}
|
||||
|
||||
export function appendAuditLog(log: Omit<AuditLogItem, 'id' | 'createdAt'>) {
|
||||
const logs = getAuditLogs()
|
||||
logs.unshift({
|
||||
id: uid('log'),
|
||||
createdAt: now(),
|
||||
...log
|
||||
})
|
||||
writeStorage(STORAGE_KEYS.logs, logs.slice(0, 200))
|
||||
}
|
||||
|
||||
export function buildSidebarMenus() {
|
||||
return getAdminMenus().filter(item => item.visible).map(item => ({
|
||||
...item,
|
||||
children: item.children?.filter(child => child.visible) || []
|
||||
}))
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<template>
|
||||
<Layout app-name="Cedar-V" page-title="审计日志">
|
||||
<div class="page-shell">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1>审计日志</h1>
|
||||
<p>记录后台关键配置与管理动作,便于回溯操作轨迹。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-table :data="logs" class="data-table">
|
||||
<el-table-column prop="module" label="模块" min-width="140" />
|
||||
<el-table-column prop="action" label="动作" min-width="120" />
|
||||
<el-table-column prop="operator" label="操作人" min-width="120" />
|
||||
<el-table-column prop="detail" label="详情" min-width="320" />
|
||||
<el-table-column prop="createdAt" label="时间" min-width="180" />
|
||||
</el-table>
|
||||
</div>
|
||||
</Layout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import Layout from '@/components/common/layout/Layout.vue'
|
||||
import { getAuditLogs, type AuditLogItem } from '@/utils/adminLocalData'
|
||||
|
||||
const logs = ref<AuditLogItem[]>(getAuditLogs())
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.page-shell { padding: 24px; }
|
||||
.page-header { margin-bottom: 20px; }
|
||||
.page-header h1 { margin: 0; font-size: 28px; }
|
||||
.page-header p { margin: 8px 0 0; color: #6b7280; }
|
||||
.data-table { background: #fff; border-radius: 18px; overflow: hidden; }
|
||||
</style>
|
||||
@@ -10,11 +10,10 @@
|
||||
<Layout app-name="Cedar-V" :page-title="t('dashboard.title')">
|
||||
<!-- 页面内容 -->
|
||||
<div class="content-container dashboard">
|
||||
<!-- 统计卡片区域 -->
|
||||
<!-- stats card area -->
|
||||
<div class="stats-section">
|
||||
<div
|
||||
class="stats-card"
|
||||
:class="{ 'compact-card': stat.id === 6, 'large-card': stat.id === 7 }"
|
||||
v-for="stat in statsData"
|
||||
:key="stat.id"
|
||||
>
|
||||
@@ -28,6 +27,9 @@
|
||||
<div class="stat-value-row">
|
||||
<div class="stat-value">{{ stat.value }}</div>
|
||||
</div>
|
||||
<div v-if="stat.subText" class="stat-sub-text" :class="stat.subTextClass">
|
||||
{{ stat.subText }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -114,7 +116,7 @@ import { ref, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import Layout from '@/components/common/layout/Layout.vue'
|
||||
import LicenseTrendChart from '@/components/charts/LicenseTrendChart.vue'
|
||||
import { getRecentAuthorizations, type RecentAuthorizationItem, getOverviewStats} from '@/api/dashboard'
|
||||
import { getRecentAuthorizations, type RecentAuthorizationItem, getOverviewStats, type StatsOverviewData } from '@/api/dashboard'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { formatDate } from '@/utils/date'
|
||||
|
||||
@@ -129,16 +131,14 @@ import icon7 from '@/assets/icons/dashboard/icon7.png'
|
||||
// 使用国际化
|
||||
const { t } = useI18n()
|
||||
|
||||
// 统计卡片数据(接口动态获取 value,其余静态配置)
|
||||
// stats card definitions — value and subText are filled dynamically from API
|
||||
const statsData = ref([
|
||||
|
||||
{ id: 1, key: 'total_auth_codes', value: '', label: t('dashboard.stats.totalCustomers'), icon: icon1 },//总授权码
|
||||
{ id: 2, key: 'active_licenses', value: '', label: t('dashboard.stats.totalLicenses'), icon: icon2 },//活跃许可证
|
||||
{ id: 3, key: 'expiring_soon', value: '', label: t('dashboard.stats.activeLicenses'), icon: icon3 },//即将过期
|
||||
{ id: 4, key: 'abnormal_alerts', value: '', label: t('dashboard.stats.expired'), icon: icon4 },//异常警告
|
||||
// { id: 5, key: 'growth_rate', value: '', label: t('dashboard.stats.onlineClients'), icon: icon5 },//增长率
|
||||
{ id: 6, key: 'auth_codes', value: '', label: t('dashboard.stats.expiringSoon'), icon: icon6 },//授权码增长率
|
||||
{ id: 7, key: 'licenses', value: '', label: t('dashboard.stats.authSuccessRate24h'), icon: icon7 }//许可证增长率
|
||||
{ id: 1, key: 'total_auth_codes', value: '', label: t('dashboard.stats.totalAuthCodes'), icon: icon1, subText: '', subTextClass: '' },
|
||||
{ id: 2, key: 'active_licenses', value: '', label: t('dashboard.stats.activeLicenses'), icon: icon2, subText: '', subTextClass: '' },
|
||||
{ id: 3, key: 'today_new_licenses', value: '', label: t('dashboard.stats.todayNewLicenses'), icon: icon3, subText: '', subTextClass: '' },
|
||||
{ id: 4, key: 'expiring_in_7days', value: '', label: t('dashboard.stats.expiringIn7Days'), icon: icon4, subText: '', subTextClass: '' },
|
||||
{ id: 5, key: 'expiring_in_30days', value: '', label: t('dashboard.stats.expiringIn30Days'), icon: icon6, subText: '', subTextClass: '' },
|
||||
{ id: 6, key: 'abnormal_alerts', value: '', label: t('dashboard.stats.abnormalAlerts'), icon: icon7, subText: '', subTextClass: '' },
|
||||
])
|
||||
|
||||
// 最近授权数据 - 响应式数据
|
||||
@@ -174,29 +174,57 @@ const fetchRecentAuthorizations = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
// 获取仪表盘统计数据
|
||||
// Populates statsData cards from API response
|
||||
const applyStatsData = (data: StatsOverviewData) => {
|
||||
statsData.value.forEach(item => {
|
||||
const raw = (data as any)[item.key]
|
||||
if (raw !== undefined) item.value = String(raw)
|
||||
})
|
||||
|
||||
// Card 1: total auth codes + month new as sub-text
|
||||
const card1 = statsData.value.find(c => c.key === 'total_auth_codes')
|
||||
if (card1) {
|
||||
card1.subText = t('dashboard.stats.subText.monthNew').replace('{n}', String(data.month_new_auth_codes))
|
||||
card1.subTextClass = 'sub-positive'
|
||||
}
|
||||
|
||||
// Card 2: active licenses + MoM growth rate as sub-text
|
||||
const card2 = statsData.value.find(c => c.key === 'active_licenses')
|
||||
if (card2 && data.growth_rate) {
|
||||
const rate = data.growth_rate.licenses_mom
|
||||
card2.subText = t('dashboard.stats.subText.vsLastMonth').replace('{rate}', `${rate >= 0 ? '+' : ''}${rate.toFixed(2)}`)
|
||||
card2.subTextClass = rate >= 0 ? 'sub-positive' : 'sub-negative'
|
||||
}
|
||||
|
||||
// Card 3: today new licenses + yesterday count as sub-text
|
||||
const card3 = statsData.value.find(c => c.key === 'today_new_licenses')
|
||||
if (card3) {
|
||||
card3.subText = t('dashboard.stats.subText.yesterday').replace('{n}', String(data.yesterday_new_licenses))
|
||||
card3.subTextClass = ''
|
||||
}
|
||||
|
||||
// Card 4: expiring in 7 days — fixed urgent sub-text
|
||||
const card4 = statsData.value.find(c => c.key === 'expiring_in_7days')
|
||||
if (card4) {
|
||||
card4.subText = data.expiring_in_7days > 0 ? t('dashboard.stats.subText.urgent') : ''
|
||||
card4.subTextClass = data.expiring_in_7days > 0 ? 'sub-warning' : ''
|
||||
}
|
||||
|
||||
// Card 6: abnormal alerts — heartbeat timeout hint
|
||||
const card6 = statsData.value.find(c => c.key === 'abnormal_alerts')
|
||||
if (card6) {
|
||||
card6.subText = t('dashboard.stats.subText.heartbeatTimeout')
|
||||
card6.subTextClass = data.abnormal_alerts > 0 ? 'sub-warning' : ''
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch dashboard overview stats from API
|
||||
const fetchDashboardStats = async () => {
|
||||
try {
|
||||
const resp = await getOverviewStats()
|
||||
const data = resp.data
|
||||
statsData.value.forEach(item => {
|
||||
// 处理直接映射的字段
|
||||
if (data[item.key] !== undefined && typeof data[item.key] !== 'object') {
|
||||
item.value = data[item.key]
|
||||
}
|
||||
// 处理嵌套在 growth_rate 中的字段(授权码增长率和许可证增长率)
|
||||
if (data.growth_rate && data.growth_rate[item.key] !== undefined) {
|
||||
const rateValue = data.growth_rate[item.key]
|
||||
// 如果是数字类型,格式化为保留两位小数的百分比
|
||||
if (typeof rateValue === 'number') {
|
||||
item.value = `${rateValue.toFixed(2)}%`
|
||||
} else {
|
||||
item.value = rateValue
|
||||
}
|
||||
}
|
||||
})
|
||||
} catch (e) {
|
||||
ElMessage.error('获取仪表盘统计数据失败')
|
||||
applyStatsData(resp.data)
|
||||
} catch {
|
||||
ElMessage.error(t('dashboard.fetchError') || 'Failed to load dashboard stats')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -209,9 +237,12 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
/* content-container样式 */
|
||||
/* content-container styles */
|
||||
.content-container {
|
||||
min-height: calc(100vh - 80px);
|
||||
/* Use a definite height so flex children with height:100% can resolve correctly.
|
||||
min-height alone does not propagate to children in CSS. */
|
||||
height: calc(100vh - 80px);
|
||||
min-height: 600px; /* floor so content is never crushed on very small viewports */
|
||||
padding: 24px;
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
@@ -353,6 +384,25 @@ onMounted(() => {
|
||||
font-size: 1.25vw; /* 24px/1920 = 0.73vw */
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
|
||||
.stat-sub-text {
|
||||
font-size: 0.63vw; /* ~12px */
|
||||
margin-top: 0.16vw;
|
||||
color: rgba(255, 255, 255, 0.65);
|
||||
white-space: nowrap;
|
||||
|
||||
&.sub-positive {
|
||||
color: rgba(200, 255, 220, 0.9);
|
||||
}
|
||||
|
||||
&.sub-negative {
|
||||
color: rgba(255, 200, 200, 0.9);
|
||||
}
|
||||
|
||||
&.sub-warning {
|
||||
color: rgba(255, 230, 150, 0.9);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 内容区域 - 默认上下布局,充满屏幕宽度和高度
|
||||
@@ -364,10 +414,9 @@ onMounted(() => {
|
||||
min-height: 0; /* 防止flex子元素溢出 */
|
||||
}
|
||||
|
||||
// 图表区域 - 占据50%高度
|
||||
// Chart section: auto-sized by chart card content, never shrinks
|
||||
.chart-section {
|
||||
flex: 0.5; /* 50%高度 */
|
||||
min-height: 300px; /* 确保最小高度 */
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
@@ -380,8 +429,8 @@ onMounted(() => {
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 0.6; /* 占据60%高度 */
|
||||
min-height: 0; /* 防止flex子元素溢出 */
|
||||
flex: 1; /* fill all remaining space after chart card */
|
||||
min-height: 250px; /* ensure table always has usable space */
|
||||
}
|
||||
|
||||
.card-header {
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
<template>
|
||||
<Layout app-name="Cedar-V" page-title="功能模板">
|
||||
<div class="page-shell">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1>功能模板</h1>
|
||||
<p>维护产品功能模板,供授权、续期和定制方案复用。</p>
|
||||
</div>
|
||||
<el-button type="primary" @click="openCreate">新增模板</el-button>
|
||||
</div>
|
||||
|
||||
<el-row :gutter="16">
|
||||
<el-col v-for="item in templates" :key="item.id" :xs="24" :md="12" :xl="8">
|
||||
<div class="template-card">
|
||||
<div class="template-top">
|
||||
<h3>{{ item.name }}</h3>
|
||||
<el-tag>{{ item.category }}</el-tag>
|
||||
</div>
|
||||
<p>{{ item.description }}</p>
|
||||
<pre>{{ item.content }}</pre>
|
||||
<div class="template-actions">
|
||||
<el-button link type="primary" @click="openEdit(item)">编辑</el-button>
|
||||
<el-button link type="danger" @click="removeTemplate(item.id)">删除</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-dialog v-model="dialogVisible" :title="editingId ? '编辑模板' : '新增模板'" width="640px">
|
||||
<el-form :model="form" label-position="top">
|
||||
<el-form-item label="模板名称"><el-input v-model="form.name" /></el-form-item>
|
||||
<el-form-item label="分类"><el-input v-model="form.category" /></el-form-item>
|
||||
<el-form-item label="描述"><el-input v-model="form.description" type="textarea" :rows="2" /></el-form-item>
|
||||
<el-form-item label="模板内容"><el-input v-model="form.content" type="textarea" :rows="10" /></el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="saveTemplate">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</Layout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import Layout from '@/components/common/layout/Layout.vue'
|
||||
import { appendAuditLog, getFeatureTemplates, saveFeatureTemplates, type FeatureTemplate } from '@/utils/adminLocalData'
|
||||
|
||||
const templates = ref<FeatureTemplate[]>(getFeatureTemplates())
|
||||
const dialogVisible = ref(false)
|
||||
const editingId = ref('')
|
||||
|
||||
const form = reactive({
|
||||
name: '',
|
||||
category: '',
|
||||
description: '',
|
||||
content: '{}'
|
||||
})
|
||||
|
||||
function resetForm() {
|
||||
form.name = ''
|
||||
form.category = ''
|
||||
form.description = ''
|
||||
form.content = '{}'
|
||||
editingId.value = ''
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
resetForm()
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
function openEdit(item: FeatureTemplate) {
|
||||
editingId.value = item.id
|
||||
form.name = item.name
|
||||
form.category = item.category
|
||||
form.description = item.description
|
||||
form.content = item.content
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
function saveTemplate() {
|
||||
if (!form.name.trim()) {
|
||||
ElMessage.warning('请输入模板名称')
|
||||
return
|
||||
}
|
||||
const nextItems = [...templates.value]
|
||||
if (editingId.value) {
|
||||
const index = nextItems.findIndex(item => item.id === editingId.value)
|
||||
if (index >= 0) {
|
||||
nextItems[index] = { ...nextItems[index], ...form, updatedAt: new Date().toLocaleString() }
|
||||
appendAuditLog({ module: '功能模板', action: '编辑', operator: 'admin', detail: `编辑模板 ${form.name}` })
|
||||
}
|
||||
} else {
|
||||
nextItems.unshift({
|
||||
id: `tpl_${Date.now()}`,
|
||||
updatedAt: new Date().toLocaleString(),
|
||||
...form
|
||||
})
|
||||
appendAuditLog({ module: '功能模板', action: '新增', operator: 'admin', detail: `新增模板 ${form.name}` })
|
||||
}
|
||||
templates.value = nextItems
|
||||
saveFeatureTemplates(nextItems)
|
||||
dialogVisible.value = false
|
||||
ElMessage.success('模板已保存')
|
||||
}
|
||||
|
||||
async function removeTemplate(id: string) {
|
||||
const item = templates.value.find(template => template.id === id)
|
||||
if (!item) return
|
||||
await ElMessageBox.confirm(`确认删除模板“${item.name}”吗?`, '提示')
|
||||
templates.value = templates.value.filter(template => template.id !== id)
|
||||
saveFeatureTemplates(templates.value)
|
||||
appendAuditLog({ module: '功能模板', action: '删除', operator: 'admin', detail: `删除模板 ${item.name}` })
|
||||
ElMessage.success('模板已删除')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.page-shell { padding: 24px; }
|
||||
.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; }
|
||||
.page-header h1 { margin: 0; font-size: 28px; }
|
||||
.page-header p { margin: 8px 0 0; color: #6b7280; }
|
||||
.template-card {
|
||||
background: #fff;
|
||||
border-radius: 18px;
|
||||
padding: 18px;
|
||||
box-shadow: 0 10px 28px rgba(15, 23, 42, 0.08);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.template-top { display: flex; justify-content: space-between; align-items: center; }
|
||||
.template-card h3 { margin: 0; }
|
||||
.template-card p { color: #6b7280; min-height: 44px; }
|
||||
pre {
|
||||
background: #0f172a;
|
||||
color: #cbd5e1;
|
||||
border-radius: 12px;
|
||||
padding: 12px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
min-height: 140px;
|
||||
}
|
||||
.template-actions { display: flex; justify-content: flex-end; gap: 8px; }
|
||||
</style>
|
||||
@@ -213,300 +213,28 @@
|
||||
|
||||
<!-- 功能配置 -->
|
||||
<div class="license-form">
|
||||
<div class="key-value-section-header">
|
||||
<h3 class="section-title">{{ t('pages.licenses.form.sections.featureConfig') }}</h3>
|
||||
<div class="key-value-section-actions">
|
||||
<el-button size="small" @click="addFeatureConfigEntry">
|
||||
{{ t('pages.licenses.form.keyValue.addItem') }}
|
||||
</el-button>
|
||||
<el-button
|
||||
size="small"
|
||||
text
|
||||
:disabled="!featureConfigEntries.length"
|
||||
@click="clearFeatureConfigEntries"
|
||||
>
|
||||
{{ t('pages.licenses.form.keyValue.clearAll') }}
|
||||
</el-button>
|
||||
<el-button
|
||||
size="small"
|
||||
text
|
||||
:disabled="!featureConfigPreview"
|
||||
@click="copyJson(featureConfigPreview)"
|
||||
>
|
||||
{{ t('pages.licenses.form.keyValue.copyJson') }}
|
||||
</el-button>
|
||||
<el-button size="small" text @click="openImportDialog('feature')">
|
||||
{{ t('pages.licenses.form.keyValue.importJson') }}
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="key-value-list">
|
||||
<div v-for="(item, index) in featureConfigEntries" :key="item.id" class="key-value-row">
|
||||
<div class="key-value-field">
|
||||
<label>{{ t('pages.licenses.form.keyValue.keyLabel') }}</label>
|
||||
<el-input
|
||||
v-model="item.key"
|
||||
:placeholder="t('pages.licenses.form.keyValue.keyPlaceholder')"
|
||||
@input="item.keyError = ''"
|
||||
/>
|
||||
<p v-if="item.keyError" class="key-value-error">{{ item.keyError }}</p>
|
||||
</div>
|
||||
<div class="key-value-field type-field">
|
||||
<label>{{ t('pages.licenses.form.keyValue.typeLabel') }}</label>
|
||||
<el-select v-model="item.type" @change="handleEntryTypeChange(item)">
|
||||
<el-option
|
||||
v-for="type in typeOptions"
|
||||
:key="type"
|
||||
:label="t(`pages.licenses.form.keyValue.typeOptions.${type}`)"
|
||||
:value="type"
|
||||
/>
|
||||
</el-select>
|
||||
</div>
|
||||
<div class="key-value-field">
|
||||
<label>{{ t('pages.licenses.form.keyValue.valueLabel') }}</label>
|
||||
<template v-if="item.type === 'bool'">
|
||||
<el-select v-model="item.value" @change="item.valueError = ''">
|
||||
<el-option
|
||||
v-for="option in boolOptions"
|
||||
:key="option.value"
|
||||
:label="option.label"
|
||||
:value="option.value"
|
||||
/>
|
||||
</el-select>
|
||||
</template>
|
||||
<el-input
|
||||
v-else
|
||||
v-model="item.value"
|
||||
:placeholder="t('pages.licenses.form.keyValue.valuePlaceholder')"
|
||||
@input="item.valueError = ''"
|
||||
:inputmode="item.type === 'number' ? 'decimal' : 'text'"
|
||||
/>
|
||||
<p v-if="item.valueError" class="key-value-error">{{ item.valueError }}</p>
|
||||
</div>
|
||||
<el-button link type="danger" @click="removeFeatureConfigEntry(index)">
|
||||
{{ t('pages.licenses.form.keyValue.remove') }}
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<div v-if="!featureConfigEntries.length" class="key-value-empty">
|
||||
{{ t('pages.licenses.form.keyValue.emptyState') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="key-value-preview">
|
||||
<div class="key-value-preview-label">
|
||||
{{ t('pages.licenses.form.keyValue.preview') }}
|
||||
</div>
|
||||
<el-input type="textarea" :rows="3" :model-value="featureConfigPreview" readonly />
|
||||
</div>
|
||||
<h3 class="section-title">{{ t('pages.licenses.form.sections.featureConfig') }}</h3>
|
||||
<JsonEditor ref="featureConfigRef" v-model="featureConfigData" />
|
||||
</div>
|
||||
|
||||
<!-- 限制设置 -->
|
||||
<div class="license-form">
|
||||
<div class="key-value-section-header">
|
||||
<h3 class="section-title">{{ t('pages.licenses.form.sections.usageLimits') }}</h3>
|
||||
<div class="key-value-section-actions">
|
||||
<el-button size="small" @click="addLimitEntry">
|
||||
{{ t('pages.licenses.form.keyValue.addItem') }}
|
||||
</el-button>
|
||||
<el-button size="small" text :disabled="!limitEntries.length" @click="clearLimitEntries">
|
||||
{{ t('pages.licenses.form.keyValue.clearAll') }}
|
||||
</el-button>
|
||||
<el-button
|
||||
size="small"
|
||||
text
|
||||
:disabled="!usageJsonPreview"
|
||||
@click="copyJson(usageJsonPreview)"
|
||||
>
|
||||
{{ t('pages.licenses.form.keyValue.copyJson') }}
|
||||
</el-button>
|
||||
<el-button size="small" text @click="openImportDialog('limit')">
|
||||
{{ t('pages.licenses.form.keyValue.importJson') }}
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="key-value-list">
|
||||
<div v-for="(item, index) in limitEntries" :key="item.id" class="key-value-row">
|
||||
<div class="key-value-field">
|
||||
<label>{{ t('pages.licenses.form.keyValue.keyLabel') }}</label>
|
||||
<el-input
|
||||
v-model="item.key"
|
||||
:placeholder="t('pages.licenses.form.keyValue.keyPlaceholder')"
|
||||
@input="item.keyError = ''"
|
||||
/>
|
||||
<p v-if="item.keyError" class="key-value-error">{{ item.keyError }}</p>
|
||||
</div>
|
||||
<div class="key-value-field type-field">
|
||||
<label>{{ t('pages.licenses.form.keyValue.typeLabel') }}</label>
|
||||
<el-select v-model="item.type" @change="handleEntryTypeChange(item)">
|
||||
<el-option
|
||||
v-for="type in typeOptions"
|
||||
:key="type"
|
||||
:label="t(`pages.licenses.form.keyValue.typeOptions.${type}`)"
|
||||
:value="type"
|
||||
/>
|
||||
</el-select>
|
||||
</div>
|
||||
<div class="key-value-field">
|
||||
<label>{{ t('pages.licenses.form.keyValue.valueLabel') }}</label>
|
||||
<template v-if="item.type === 'bool'">
|
||||
<el-select v-model="item.value" @change="item.valueError = ''">
|
||||
<el-option
|
||||
v-for="option in boolOptions"
|
||||
:key="option.value"
|
||||
:label="option.label"
|
||||
:value="option.value"
|
||||
/>
|
||||
</el-select>
|
||||
</template>
|
||||
<el-input
|
||||
v-else
|
||||
v-model="item.value"
|
||||
:placeholder="t('pages.licenses.form.keyValue.valuePlaceholder')"
|
||||
@input="item.valueError = ''"
|
||||
:inputmode="item.type === 'number' ? 'decimal' : 'text'"
|
||||
/>
|
||||
<p v-if="item.valueError" class="key-value-error">{{ item.valueError }}</p>
|
||||
</div>
|
||||
<el-button link type="danger" @click="removeLimitEntry(index)">
|
||||
{{ t('pages.licenses.form.keyValue.remove') }}
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<div v-if="!limitEntries.length" class="key-value-empty">
|
||||
{{ t('pages.licenses.form.keyValue.emptyState') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="key-value-preview">
|
||||
<div class="key-value-preview-label">
|
||||
{{ t('pages.licenses.form.keyValue.preview') }}
|
||||
</div>
|
||||
<el-input type="textarea" :rows="3" :model-value="usageJsonPreview" readonly />
|
||||
</div>
|
||||
<h3 class="section-title">{{ t('pages.licenses.form.sections.usageLimits') }}</h3>
|
||||
<JsonEditor ref="usageLimitsRef" v-model="usageLimitsData" />
|
||||
</div>
|
||||
|
||||
<!-- 自定义参数 -->
|
||||
<div class="license-form">
|
||||
<div class="key-value-section-header">
|
||||
<h3 class="section-title">{{ t('pages.licenses.form.sections.customParameters') }}</h3>
|
||||
<div class="key-value-section-actions">
|
||||
<el-button size="small" @click="addCustomEntry">
|
||||
{{ t('pages.licenses.form.keyValue.addItem') }}
|
||||
</el-button>
|
||||
<el-button size="small" text :disabled="!customEntries.length" @click="clearCustomEntries">
|
||||
{{ t('pages.licenses.form.keyValue.clearAll') }}
|
||||
</el-button>
|
||||
<el-button
|
||||
size="small"
|
||||
text
|
||||
:disabled="!customJsonPreview"
|
||||
@click="copyJson(customJsonPreview)"
|
||||
>
|
||||
{{ t('pages.licenses.form.keyValue.copyJson') }}
|
||||
</el-button>
|
||||
<el-button size="small" text @click="openImportDialog('custom')">
|
||||
{{ t('pages.licenses.form.keyValue.importJson') }}
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="key-value-list">
|
||||
<div v-for="(item, index) in customEntries" :key="item.id" class="key-value-row">
|
||||
<div class="key-value-field">
|
||||
<label>{{ t('pages.licenses.form.keyValue.keyLabel') }}</label>
|
||||
<el-input
|
||||
v-model="item.key"
|
||||
:placeholder="t('pages.licenses.form.keyValue.keyPlaceholder')"
|
||||
@input="item.keyError = ''"
|
||||
/>
|
||||
<p v-if="item.keyError" class="key-value-error">{{ item.keyError }}</p>
|
||||
</div>
|
||||
<div class="key-value-field type-field">
|
||||
<label>{{ t('pages.licenses.form.keyValue.typeLabel') }}</label>
|
||||
<el-select v-model="item.type" @change="handleEntryTypeChange(item)">
|
||||
<el-option
|
||||
v-for="type in typeOptions"
|
||||
:key="type"
|
||||
:label="t(`pages.licenses.form.keyValue.typeOptions.${type}`)"
|
||||
:value="type"
|
||||
/>
|
||||
</el-select>
|
||||
</div>
|
||||
<div class="key-value-field">
|
||||
<label>{{ t('pages.licenses.form.keyValue.valueLabel') }}</label>
|
||||
<template v-if="item.type === 'bool'">
|
||||
<el-select v-model="item.value" @change="item.valueError = ''">
|
||||
<el-option
|
||||
v-for="option in boolOptions"
|
||||
:key="option.value"
|
||||
:label="option.label"
|
||||
:value="option.value"
|
||||
/>
|
||||
</el-select>
|
||||
</template>
|
||||
<el-input
|
||||
v-else
|
||||
v-model="item.value"
|
||||
:placeholder="t('pages.licenses.form.keyValue.valuePlaceholder')"
|
||||
@input="item.valueError = ''"
|
||||
:inputmode="item.type === 'number' ? 'decimal' : 'text'"
|
||||
/>
|
||||
<p v-if="item.valueError" class="key-value-error">{{ item.valueError }}</p>
|
||||
</div>
|
||||
<el-button link type="danger" @click="removeCustomEntry(index)">
|
||||
{{ t('pages.licenses.form.keyValue.remove') }}
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<div v-if="!customEntries.length" class="key-value-empty">
|
||||
{{ t('pages.licenses.form.keyValue.emptyState') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="key-value-preview">
|
||||
<div class="key-value-preview-label">
|
||||
{{ t('pages.licenses.form.keyValue.preview') }}
|
||||
</div>
|
||||
<el-input type="textarea" :rows="3" :model-value="customJsonPreview" readonly />
|
||||
</div>
|
||||
<h3 class="section-title">{{ t('pages.licenses.form.sections.customParameters') }}</h3>
|
||||
<JsonEditor ref="customParamsRef" v-model="customParamsData" />
|
||||
</div>
|
||||
</el-form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-dialog
|
||||
v-model="importDialogVisible"
|
||||
:title="importDialogTitle"
|
||||
width="520px"
|
||||
destroy-on-close
|
||||
>
|
||||
<p class="import-dialog-tip">
|
||||
{{ t('pages.licenses.form.keyValue.importDescription') }}
|
||||
</p>
|
||||
<el-input
|
||||
v-model="importDialogContent"
|
||||
type="textarea"
|
||||
:rows="8"
|
||||
:placeholder="t('pages.licenses.form.keyValue.importPlaceholder')"
|
||||
/>
|
||||
<p v-if="importDialogError" class="key-value-error import-error">{{ importDialogError }}</p>
|
||||
<template #footer>
|
||||
<el-button @click="importDialogVisible = false">
|
||||
{{ t('pages.licenses.form.keyValue.importCancel') }}
|
||||
</el-button>
|
||||
<el-button type="primary" @click="handleImportConfirm">
|
||||
{{ t('pages.licenses.form.keyValue.importConfirm') }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, onMounted, watch } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
@@ -519,58 +247,34 @@ import {
|
||||
} from '@/api/license'
|
||||
import { getCustomers, type Customer } from '@/api/customer'
|
||||
import { getEnumOptions, type RawEnumItem } from '@/api/enum'
|
||||
|
||||
type KeyValueType = 'string' | 'number' | 'bool'
|
||||
|
||||
interface KeyValueItem {
|
||||
id: string
|
||||
key: string
|
||||
value: string
|
||||
type: KeyValueType
|
||||
keyError?: string
|
||||
valueError?: string
|
||||
}
|
||||
|
||||
const KEY_PATTERN = /^[a-z0-9-]{1,32}$/
|
||||
import JsonEditor from '@/components/common/JsonEditor.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const { t } = useI18n()
|
||||
const boolOptions = computed(() => [
|
||||
{ label: t('pages.licenses.form.keyValue.booleanTrue'), value: 'true' },
|
||||
{ label: t('pages.licenses.form.keyValue.booleanFalse'), value: 'false' }
|
||||
])
|
||||
|
||||
// 表单引用
|
||||
const formRef = ref<FormInstance>()
|
||||
|
||||
// JsonEditor 组件引用
|
||||
const featureConfigRef = ref<InstanceType<typeof JsonEditor>>()
|
||||
const usageLimitsRef = ref<InstanceType<typeof JsonEditor>>()
|
||||
const customParamsRef = ref<InstanceType<typeof JsonEditor>>()
|
||||
|
||||
// 响应式数据
|
||||
const submitting = ref(false)
|
||||
const customerLoading = ref(false)
|
||||
const customerOptions = ref<Customer[]>([])
|
||||
|
||||
const featureConfigEntries = ref<KeyValueItem[]>([])
|
||||
const limitEntries = ref<KeyValueItem[]>([])
|
||||
const customEntries = ref<KeyValueItem[]>([])
|
||||
const importDialogVisible = ref(false)
|
||||
const importDialogTarget = ref<'feature' | 'limit' | 'custom'>('feature')
|
||||
const importDialogTitle = computed(() => {
|
||||
if (importDialogTarget.value === 'limit') {
|
||||
return t('pages.licenses.form.keyValue.importTitleLimit')
|
||||
}
|
||||
if (importDialogTarget.value === 'custom') {
|
||||
return t('pages.licenses.form.keyValue.importTitleCustom')
|
||||
}
|
||||
return t('pages.licenses.form.keyValue.importTitleFeature')
|
||||
})
|
||||
const importDialogContent = ref('')
|
||||
const importDialogError = ref('')
|
||||
// JsonEditor 数据
|
||||
const featureConfigData = ref<Record<string, any> | null>(null)
|
||||
const usageLimitsData = ref<Record<string, any> | null>(null)
|
||||
const customParamsData = ref<Record<string, any> | null>(null)
|
||||
|
||||
// 枚举选项
|
||||
const deploymentTypeOptions = ref<RawEnumItem[]>([])
|
||||
const encryptionTypeOptions = ref<RawEnumItem[]>([])
|
||||
|
||||
const typeOptions: KeyValueType[] = ['string', 'number', 'bool']
|
||||
// 表单数据
|
||||
const formData = reactive<
|
||||
AuthorizationCodeCreateRequest & {
|
||||
@@ -594,10 +298,6 @@ const formData = reactive<
|
||||
// 独立的响应式变量
|
||||
const dateRange = ref<[string, string] | null>(null)
|
||||
|
||||
const featureConfigPreview = computed(() => buildJsonString(featureConfigEntries.value))
|
||||
const usageJsonPreview = computed(() => buildJsonString(limitEntries.value))
|
||||
const customJsonPreview = computed(() => buildJsonString(customEntries.value))
|
||||
|
||||
// 计算属性
|
||||
const isEdit = computed(() => {
|
||||
return route.name === 'licenses-edit' && route.params.id
|
||||
@@ -744,277 +444,6 @@ const formRules: FormRules = {
|
||||
]
|
||||
}
|
||||
|
||||
const createEntryId = () => `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
|
||||
|
||||
const createEmptyEntry = (): KeyValueItem => ({
|
||||
id: createEntryId(),
|
||||
key: '',
|
||||
value: '',
|
||||
type: 'string',
|
||||
keyError: '',
|
||||
valueError: ''
|
||||
})
|
||||
|
||||
const convertValueByType = (value: string, type: KeyValueType) => {
|
||||
if (type === 'number') {
|
||||
const num = Number(value)
|
||||
if (Number.isNaN(num)) {
|
||||
throw new Error('invalid number')
|
||||
}
|
||||
return num
|
||||
}
|
||||
if (type === 'bool') {
|
||||
return value === 'true'
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
const buildJsonRecord = (entries: KeyValueItem[]) => {
|
||||
if (!entries.length) {
|
||||
return null
|
||||
}
|
||||
const result: Record<string, string | number | boolean> = {}
|
||||
entries.forEach(item => {
|
||||
const trimmedKey = item.key.trim()
|
||||
if (trimmedKey) {
|
||||
try {
|
||||
result[trimmedKey] = convertValueByType(item.value, item.type)
|
||||
} catch {
|
||||
// 如果转换失败,则忽略该条目,等待校验提示
|
||||
}
|
||||
}
|
||||
})
|
||||
return Object.keys(result).length ? result : null
|
||||
}
|
||||
|
||||
const buildJsonString = (entries: KeyValueItem[]) => {
|
||||
const record = buildJsonRecord(entries)
|
||||
return record ? JSON.stringify(record) : ''
|
||||
}
|
||||
|
||||
const addFeatureConfigEntry = () => {
|
||||
featureConfigEntries.value.push(createEmptyEntry())
|
||||
}
|
||||
|
||||
const addLimitEntry = () => {
|
||||
limitEntries.value.push(createEmptyEntry())
|
||||
}
|
||||
|
||||
const addCustomEntry = () => {
|
||||
customEntries.value.push(createEmptyEntry())
|
||||
}
|
||||
|
||||
const removeFeatureConfigEntry = (index: number) => {
|
||||
featureConfigEntries.value.splice(index, 1)
|
||||
}
|
||||
|
||||
const removeLimitEntry = (index: number) => {
|
||||
limitEntries.value.splice(index, 1)
|
||||
}
|
||||
|
||||
const removeCustomEntry = (index: number) => {
|
||||
customEntries.value.splice(index, 1)
|
||||
}
|
||||
|
||||
const clearFeatureConfigEntries = () => {
|
||||
featureConfigEntries.value = []
|
||||
}
|
||||
|
||||
const clearLimitEntries = () => {
|
||||
limitEntries.value = []
|
||||
}
|
||||
|
||||
const clearCustomEntries = () => {
|
||||
customEntries.value = []
|
||||
}
|
||||
|
||||
const handleEntryTypeChange = (entry: KeyValueItem) => {
|
||||
if (entry.type === 'bool') {
|
||||
entry.value = entry.value === 'false' ? 'false' : 'true'
|
||||
} else if (entry.type === 'number') {
|
||||
if (entry.value && Number.isNaN(Number(entry.value))) {
|
||||
entry.value = ''
|
||||
}
|
||||
}
|
||||
entry.valueError = ''
|
||||
}
|
||||
|
||||
const fallbackCopy = (value: string) => {
|
||||
if (typeof document === 'undefined') {
|
||||
throw new Error('document is not available')
|
||||
}
|
||||
const textarea = document.createElement('textarea')
|
||||
textarea.value = value
|
||||
textarea.style.position = 'fixed'
|
||||
textarea.style.opacity = '0'
|
||||
document.body.appendChild(textarea)
|
||||
textarea.focus()
|
||||
textarea.select()
|
||||
document.execCommand('copy')
|
||||
document.body.removeChild(textarea)
|
||||
}
|
||||
|
||||
const copyJson = async (value: string) => {
|
||||
if (!value) {
|
||||
ElMessage.info(t('pages.licenses.form.keyValue.copyEmpty'))
|
||||
return
|
||||
}
|
||||
try {
|
||||
if (navigator?.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(value)
|
||||
} else {
|
||||
fallbackCopy(value)
|
||||
}
|
||||
ElMessage.success(t('pages.licenses.form.keyValue.copySuccess'))
|
||||
} catch (error) {
|
||||
console.error('复制JSON失败:', error)
|
||||
ElMessage.error(t('pages.licenses.form.keyValue.copyError'))
|
||||
}
|
||||
}
|
||||
|
||||
const validateKeyValueEntries = (entries: KeyValueItem[]) => {
|
||||
let isValid = true
|
||||
const existingKeys = new Set<string>()
|
||||
|
||||
entries.forEach(item => {
|
||||
item.keyError = ''
|
||||
item.valueError = ''
|
||||
|
||||
const trimmedKey = item.key.trim()
|
||||
if (!trimmedKey) {
|
||||
item.keyError = t('pages.licenses.form.keyValue.keyRequired')
|
||||
isValid = false
|
||||
} else if (!KEY_PATTERN.test(trimmedKey)) {
|
||||
item.keyError = t('pages.licenses.form.keyValue.keyFormat')
|
||||
isValid = false
|
||||
} else if (existingKeys.has(trimmedKey)) {
|
||||
item.keyError = t('pages.licenses.form.keyValue.keyDuplicate')
|
||||
isValid = false
|
||||
} else {
|
||||
existingKeys.add(trimmedKey)
|
||||
}
|
||||
|
||||
if (item.value.trim() === '') {
|
||||
item.valueError = t('pages.licenses.form.keyValue.valueRequired')
|
||||
isValid = false
|
||||
} else if (item.type === 'number') {
|
||||
const num = Number(item.value)
|
||||
if (Number.isNaN(num)) {
|
||||
item.valueError = t('pages.licenses.form.keyValue.numberRequired')
|
||||
isValid = false
|
||||
}
|
||||
} else if (item.type === 'bool') {
|
||||
if (item.value !== 'true' && item.value !== 'false') {
|
||||
item.valueError = t('pages.licenses.form.keyValue.boolRequired')
|
||||
isValid = false
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return isValid
|
||||
}
|
||||
|
||||
const openImportDialog = (target: 'feature' | 'limit' | 'custom') => {
|
||||
importDialogTarget.value = target
|
||||
importDialogContent.value =
|
||||
target === 'feature'
|
||||
? featureConfigPreview.value
|
||||
: target === 'limit'
|
||||
? usageJsonPreview.value
|
||||
: customJsonPreview.value
|
||||
importDialogError.value = ''
|
||||
importDialogVisible.value = true
|
||||
}
|
||||
|
||||
const handleImportConfirm = () => {
|
||||
importDialogError.value = ''
|
||||
try {
|
||||
const content = importDialogContent.value.trim()
|
||||
if (!content) {
|
||||
if (importDialogTarget.value === 'feature') {
|
||||
featureConfigEntries.value = []
|
||||
} else if (importDialogTarget.value === 'limit') {
|
||||
limitEntries.value = []
|
||||
} else {
|
||||
customEntries.value = []
|
||||
}
|
||||
} else {
|
||||
const entries = parseJsonToEntries(content, { strict: true })
|
||||
if (!entries.length) {
|
||||
throw new Error(t('pages.licenses.form.keyValue.importEmpty'))
|
||||
}
|
||||
if (importDialogTarget.value === 'feature') {
|
||||
featureConfigEntries.value = entries
|
||||
} else if (importDialogTarget.value === 'limit') {
|
||||
limitEntries.value = entries
|
||||
} else {
|
||||
customEntries.value = entries
|
||||
}
|
||||
}
|
||||
importDialogVisible.value = false
|
||||
ElMessage.success(t('pages.licenses.form.keyValue.importSuccess'))
|
||||
} catch (error: any) {
|
||||
importDialogError.value =
|
||||
error?.message || t('pages.licenses.form.keyValue.importFailed')
|
||||
}
|
||||
}
|
||||
|
||||
const mapObjectToEntries = (data: Record<string, any>): KeyValueItem[] => {
|
||||
return Object.entries(data).map(([key, value]) => {
|
||||
let type: KeyValueType = 'string'
|
||||
let normalizedValue = ''
|
||||
if (typeof value === 'number') {
|
||||
type = 'number'
|
||||
normalizedValue = String(value)
|
||||
} else if (typeof value === 'boolean') {
|
||||
type = 'bool'
|
||||
normalizedValue = value ? 'true' : 'false'
|
||||
} else {
|
||||
normalizedValue = value === undefined || value === null ? '' : String(value)
|
||||
}
|
||||
|
||||
return {
|
||||
id: createEntryId(),
|
||||
key,
|
||||
value: normalizedValue,
|
||||
type,
|
||||
keyError: '',
|
||||
valueError: ''
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const parseJsonToEntries = (raw: unknown, options?: { strict?: boolean }): KeyValueItem[] => {
|
||||
if (!raw) return []
|
||||
|
||||
let parsed: unknown = raw
|
||||
if (typeof raw === 'string') {
|
||||
if (!raw.trim()) return []
|
||||
try {
|
||||
parsed = JSON.parse(raw)
|
||||
} catch (error) {
|
||||
if (options?.strict) {
|
||||
throw error
|
||||
}
|
||||
console.warn('解析JSON失败:', error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
if (options?.strict) {
|
||||
throw new Error('JSON payload must be an object with key-value pairs')
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
return mapObjectToEntries(parsed as Record<string, any>)
|
||||
}
|
||||
|
||||
const hydrateEntriesFromField = (raw: unknown, target: Ref<KeyValueItem[]>) => {
|
||||
target.value = parseJsonToEntries(raw)
|
||||
}
|
||||
|
||||
// 方法
|
||||
const loadEnumOptions = async () => {
|
||||
try {
|
||||
@@ -1201,9 +630,16 @@ const loadLicenseDetail = async () => {
|
||||
]
|
||||
}
|
||||
|
||||
hydrateEntriesFromField(data.feature_config, featureConfigEntries)
|
||||
hydrateEntriesFromField(data.usage_limits, limitEntries)
|
||||
hydrateEntriesFromField(data.custom_parameters, customEntries)
|
||||
// 使用 JsonEditor 加载数据
|
||||
featureConfigData.value = typeof data.feature_config === 'string'
|
||||
? JSON.parse(data.feature_config || '{}')
|
||||
: (data.feature_config || {})
|
||||
usageLimitsData.value = typeof data.usage_limits === 'string'
|
||||
? JSON.parse(data.usage_limits || '{}')
|
||||
: (data.usage_limits || {})
|
||||
customParamsData.value = typeof data.custom_parameters === 'string'
|
||||
? JSON.parse(data.custom_parameters || '{}')
|
||||
: (data.custom_parameters || {})
|
||||
} else {
|
||||
throw new Error(response.message || t('pages.licenses.form.messages.loadDetailError'))
|
||||
}
|
||||
@@ -1226,17 +662,18 @@ const handleSubmit = async () => {
|
||||
// 执行表单验证
|
||||
await formRef.value.validate()
|
||||
|
||||
const featureConfigValid = validateKeyValueEntries(featureConfigEntries.value)
|
||||
const limitValid = validateKeyValueEntries(limitEntries.value)
|
||||
const customValid = validateKeyValueEntries(customEntries.value)
|
||||
if (!featureConfigValid || !limitValid || !customValid) {
|
||||
// 验证 JsonEditor 数据
|
||||
const featureValid = featureConfigRef.value?.validate() ?? true
|
||||
const limitValid = usageLimitsRef.value?.validate() ?? true
|
||||
const customValid = customParamsRef.value?.validate() ?? true
|
||||
if (!featureValid || !limitValid || !customValid) {
|
||||
ElMessage.error(t('pages.licenses.form.keyValue.validationFailed'))
|
||||
return
|
||||
}
|
||||
|
||||
formData.feature_config = buildJsonRecord(featureConfigEntries.value) || {}
|
||||
formData.usage_limits = usageJsonPreview.value
|
||||
formData.custom_parameters = customJsonPreview.value
|
||||
formData.feature_config = featureConfigData.value || {}
|
||||
formData.usage_limits = JSON.stringify(usageLimitsData.value || {})
|
||||
formData.custom_parameters = JSON.stringify(customParamsData.value || {})
|
||||
|
||||
submitting.value = true
|
||||
|
||||
@@ -1325,30 +762,6 @@ watch(
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
limitEntries,
|
||||
() => {
|
||||
formData.usage_limits = usageJsonPreview.value
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
watch(
|
||||
featureConfigEntries,
|
||||
() => {
|
||||
formData.feature_config = buildJsonRecord(featureConfigEntries.value) || {}
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
watch(
|
||||
customEntries,
|
||||
() => {
|
||||
formData.custom_parameters = customJsonPreview.value
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
// 生命周期
|
||||
onMounted(async () => {
|
||||
await loadEnumOptions()
|
||||
|
||||
@@ -176,6 +176,9 @@ const parseKeyValueData = (source: unknown): KeyValueItem[] => {
|
||||
} else if (typeof value === 'boolean') {
|
||||
type = 'bool'
|
||||
normalizedValue = value ? 'true' : 'false'
|
||||
} else if (typeof value === 'object' && value !== null) {
|
||||
// 对象或数组用 JSON.stringify 处理
|
||||
normalizedValue = JSON.stringify(value)
|
||||
} else {
|
||||
normalizedValue = value === undefined || value === null ? '' : String(value)
|
||||
}
|
||||
|
||||
@@ -184,7 +184,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, watch, reactive, computed, nextTick } from 'vue'
|
||||
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
|
||||
import { ElMessage, ElMessageBox, type FormInstance, type FormRules, type MessageBoxInputData } from 'element-plus'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import type { AuthorizationCode, LicenseDevice, LicenseDeviceCreateRequest } from '@/api/license'
|
||||
import { getLicenseDevices, getLicenseDeviceDetail, createLicenseDevice, downloadLicenseFile, revokeLicense } from '@/api/license'
|
||||
@@ -421,7 +421,7 @@ const handleRevokeLicense = async (device: LicenseDevice) => {
|
||||
|
||||
let reason = ''
|
||||
try {
|
||||
const { value } = await ElMessageBox.prompt(
|
||||
const { value } = (await ElMessageBox.prompt(
|
||||
t('pages.licenses.detail.messages.revokeConfirm'),
|
||||
t('pages.licenses.detail.messages.revokeTitle'),
|
||||
{
|
||||
@@ -441,7 +441,7 @@ const handleRevokeLicense = async (device: LicenseDevice) => {
|
||||
},
|
||||
inputErrorMessage: t('pages.licenses.detail.messages.revokeReasonRequired')
|
||||
}
|
||||
)
|
||||
)) as MessageBoxInputData
|
||||
reason = value.trim()
|
||||
} catch {
|
||||
return
|
||||
|
||||
@@ -138,51 +138,15 @@
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item :label="t('pages.licenses.detail.updateDialog.fields.featureConfig')">
|
||||
<div class="kv-entries">
|
||||
<div v-for="(entry, idx) in featureConfigEntries" :key="entry.id" class="kv-row">
|
||||
<el-input v-model="entry.key" placeholder="key" style="width:40%" />
|
||||
<el-select v-model="entry.type" placeholder="type" style="width:14%;margin:0 8px">
|
||||
<el-option label="string" value="string" />
|
||||
<el-option label="number" value="number" />
|
||||
<el-option label="bool" value="bool" />
|
||||
</el-select>
|
||||
<el-input v-model="entry.value" placeholder="value" style="width:40%" />
|
||||
<el-button type="text" icon="el-icon-delete" @click="removeFeatureConfigEntry(idx)" />
|
||||
</div>
|
||||
<el-button size="small" @click="addFeatureConfigEntry">{{ t('pages.licenses.form.keyValue.addItem') }}</el-button>
|
||||
</div>
|
||||
<JsonEditor ref="featureConfigRef" v-model="featureConfigData" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item :label="t('pages.licenses.detail.updateDialog.fields.usageLimits')">
|
||||
<div class="kv-entries">
|
||||
<div v-for="(entry, idx) in usageLimitsEntries" :key="entry.id" class="kv-row">
|
||||
<el-input v-model="entry.key" placeholder="key" style="width:40%" />
|
||||
<el-select v-model="entry.type" placeholder="type" style="width:14%;margin:0 8px">
|
||||
<el-option label="string" value="string" />
|
||||
<el-option label="number" value="number" />
|
||||
<el-option label="bool" value="bool" />
|
||||
</el-select>
|
||||
<el-input v-model="entry.value" placeholder="value" style="width:40%" />
|
||||
<el-button type="text" icon="el-icon-delete" @click="removeUsageLimitEntry(idx)" />
|
||||
</div>
|
||||
<el-button size="small" @click="addUsageLimitEntry">{{ t('pages.licenses.form.keyValue.addItem') }}</el-button>
|
||||
</div>
|
||||
<JsonEditor ref="usageLimitsRef" v-model="usageLimitsData" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item :label="t('pages.licenses.detail.updateDialog.fields.customParameters')">
|
||||
<div class="kv-entries">
|
||||
<div v-for="(entry, idx) in customParametersEntries" :key="entry.id" class="kv-row">
|
||||
<el-input v-model="entry.key" placeholder="key" style="width:40%" />
|
||||
<el-select v-model="entry.type" placeholder="type" style="width:14%;margin:0 8px">
|
||||
<el-option label="string" value="string" />
|
||||
<el-option label="number" value="number" />
|
||||
<el-option label="bool" value="bool" />
|
||||
</el-select>
|
||||
<el-input v-model="entry.value" placeholder="value" style="width:40%" />
|
||||
<el-button type="text" icon="el-icon-delete" @click="removeCustomParamEntry(idx)" />
|
||||
</div>
|
||||
<el-button size="small" @click="addCustomParamEntry">{{ t('pages.licenses.form.keyValue.addItem') }}</el-button>
|
||||
</div>
|
||||
<JsonEditor ref="customParamsRef" v-model="customParamsData" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item :label="t('pages.licenses.detail.updateDialog.fields.reason')">
|
||||
@@ -223,6 +187,7 @@ import BasicInfo from './components/BasicInfo.vue'
|
||||
import AuthorizationInfo from './components/AuthorizationInfo.vue'
|
||||
import LicenseInfo from './components/LicenseInfo.vue'
|
||||
import ChangeHistory from './components/ChangeHistory.vue'
|
||||
import JsonEditor from '@/components/common/JsonEditor.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
@@ -385,12 +350,10 @@ const handleConfirmChangeValidity = async () => {
|
||||
if (updateMaxActivations.value !== null && updateMaxActivations.value !== undefined) {
|
||||
payload.max_activations = updateMaxActivations.value
|
||||
}
|
||||
const fc = buildJsonRecord(featureConfigEntries.value)
|
||||
if (fc) payload.feature_config = fc
|
||||
const ul = buildJsonRecord(usageLimitsEntries.value)
|
||||
if (ul) payload.usage_limits = ul
|
||||
const cp = buildJsonRecord(customParametersEntries.value)
|
||||
if (cp) payload.custom_parameters = cp
|
||||
// 使用 JsonEditor 数据
|
||||
payload.feature_config = featureConfigData.value || {}
|
||||
payload.usage_limits = usageLimitsData.value || {}
|
||||
payload.custom_parameters = customParamsData.value || {}
|
||||
payload.change_type = updateChangeType.value || payload.change_type
|
||||
// if reason in update dialog provided, override
|
||||
if (updateReason.value && updateReason.value.trim()) {
|
||||
@@ -417,121 +380,37 @@ const handleConfirmChangeValidity = async () => {
|
||||
|
||||
// --- update authorization dialog ---
|
||||
import type { RawEnumItem } from '@/api/enum'
|
||||
type KeyValueType = 'string' | 'number' | 'bool'
|
||||
interface KeyValueItem {
|
||||
id: string
|
||||
key: string
|
||||
value: string
|
||||
type: KeyValueType
|
||||
}
|
||||
|
||||
const updateDialogVisible = ref(false)
|
||||
const changeTypeOptions = ref<RawEnumItem[]>([])
|
||||
const updateChangeType = ref<string>('other')
|
||||
const updateMaxActivations = ref<number | null>(null)
|
||||
const featureConfigEntries = ref<KeyValueItem[]>([])
|
||||
const usageLimitsEntries = ref<KeyValueItem[]>([])
|
||||
const customParametersEntries = ref<KeyValueItem[]>([])
|
||||
const featureConfigData = ref<Record<string, any>>({})
|
||||
const usageLimitsData = ref<Record<string, any>>({})
|
||||
const customParamsData = ref<Record<string, any>>({})
|
||||
const updateReason = ref<string>('')
|
||||
const updateSubmitting = ref(false)
|
||||
|
||||
const createEntryId = () => `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
|
||||
const createEmptyEntry = (): KeyValueItem => ({
|
||||
id: createEntryId(),
|
||||
key: '',
|
||||
value: '',
|
||||
type: 'string'
|
||||
})
|
||||
|
||||
const addFeatureConfigEntry = () => featureConfigEntries.value.push(createEmptyEntry())
|
||||
const addUsageLimitEntry = () => usageLimitsEntries.value.push(createEmptyEntry())
|
||||
const addCustomParamEntry = () => customParametersEntries.value.push(createEmptyEntry())
|
||||
const removeFeatureConfigEntry = (i: number) => featureConfigEntries.value.splice(i, 1)
|
||||
const removeUsageLimitEntry = (i: number) => usageLimitsEntries.value.splice(i, 1)
|
||||
const removeCustomParamEntry = (i: number) => customParametersEntries.value.splice(i, 1)
|
||||
|
||||
const convertValueByType = (value: string, type: KeyValueType) => {
|
||||
if (type === 'number') {
|
||||
const num = Number(value)
|
||||
if (Number.isNaN(num)) throw new Error('invalid number')
|
||||
return num
|
||||
}
|
||||
if (type === 'bool') return value === 'true'
|
||||
return value
|
||||
}
|
||||
|
||||
const buildJsonRecord = (entries: KeyValueItem[]) => {
|
||||
if (!entries.length) return null
|
||||
const result: Record<string, any> = {}
|
||||
entries.forEach(item => {
|
||||
const k = item.key.trim()
|
||||
if (!k) return
|
||||
try {
|
||||
result[k] = convertValueByType(item.value, item.type)
|
||||
} catch {
|
||||
// ignore here; validation will surface
|
||||
}
|
||||
})
|
||||
return Object.keys(result).length ? result : null
|
||||
}
|
||||
|
||||
// Validate key-value entries: ensure key present and value matches selected type
|
||||
const validateKeyValueEntries = (entries: KeyValueItem[]): { ok: boolean; msg?: string } => {
|
||||
for (const item of entries) {
|
||||
const key = item.key?.trim()
|
||||
const val = item.value
|
||||
if (!key) {
|
||||
return { ok: false, msg: t('pages.licenses.form.keyValue.keyRequired') }
|
||||
}
|
||||
if (item.type === 'number') {
|
||||
if (val === '' || val === null || val === undefined || Number.isNaN(Number(val))) {
|
||||
return { ok: false, msg: t('pages.licenses.form.keyValue.numberRequired') }
|
||||
}
|
||||
}
|
||||
if (item.type === 'bool') {
|
||||
const lower = String(val).toLowerCase()
|
||||
if (lower !== 'true' && lower !== 'false') {
|
||||
return { ok: false, msg: t('pages.licenses.form.keyValue.boolRequired') }
|
||||
}
|
||||
}
|
||||
}
|
||||
return { ok: true }
|
||||
}
|
||||
// JsonEditor 组件引用
|
||||
const featureConfigRef = ref<InstanceType<typeof JsonEditor>>()
|
||||
const usageLimitsRef = ref<InstanceType<typeof JsonEditor>>()
|
||||
const customParamsRef = ref<InstanceType<typeof JsonEditor>>()
|
||||
|
||||
const openUpdateDialog = async () => {
|
||||
updateChangeType.value = 'other'
|
||||
updateReason.value = ''
|
||||
updateMaxActivations.value = licenseData.value?.max_activations || null
|
||||
// populate entries from existing JSON if present
|
||||
featureConfigEntries.value = []
|
||||
usageLimitsEntries.value = []
|
||||
customParametersEntries.value = []
|
||||
try {
|
||||
if (licenseData.value?.feature_config) {
|
||||
const obj = typeof licenseData.value.feature_config === 'string' ? JSON.parse(licenseData.value.feature_config) : licenseData.value.feature_config
|
||||
for (const [k, v] of Object.entries(obj || {})) {
|
||||
featureConfigEntries.value.push({ id: createEntryId(), key: k, value: String(v), type: typeof v === 'number' ? 'number' : typeof v === 'boolean' ? 'bool' : 'string' })
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore parse errors
|
||||
}
|
||||
try {
|
||||
if (licenseData.value?.usage_limits) {
|
||||
const obj = typeof licenseData.value.usage_limits === 'string' ? JSON.parse(licenseData.value.usage_limits) : licenseData.value.usage_limits
|
||||
for (const [k, v] of Object.entries(obj || {})) {
|
||||
usageLimitsEntries.value.push({ id: createEntryId(), key: k, value: String(v), type: typeof v === 'number' ? 'number' : typeof v === 'boolean' ? 'bool' : 'string' })
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
try {
|
||||
if (licenseData.value?.custom_parameters) {
|
||||
const obj = typeof licenseData.value.custom_parameters === 'string' ? JSON.parse(licenseData.value.custom_parameters) : licenseData.value.custom_parameters
|
||||
for (const [k, v] of Object.entries(obj || {})) {
|
||||
customParametersEntries.value.push({ id: createEntryId(), key: k, value: String(v), type: typeof v === 'number' ? 'number' : typeof v === 'boolean' ? 'bool' : 'string' })
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
|
||||
// 使用 JsonEditor 加载数据
|
||||
featureConfigData.value = typeof licenseData.value?.feature_config === 'string'
|
||||
? JSON.parse(licenseData.value?.feature_config || '{}')
|
||||
: (licenseData.value?.feature_config || {})
|
||||
usageLimitsData.value = typeof licenseData.value?.usage_limits === 'string'
|
||||
? JSON.parse(licenseData.value?.usage_limits || '{}')
|
||||
: (licenseData.value?.usage_limits || {})
|
||||
customParamsData.value = typeof licenseData.value?.custom_parameters === 'string'
|
||||
? JSON.parse(licenseData.value?.custom_parameters || '{}')
|
||||
: (licenseData.value?.custom_parameters || {})
|
||||
|
||||
// load change type options from backend enum
|
||||
try {
|
||||
@@ -563,7 +442,17 @@ const handleConfirmUpdate = async () => {
|
||||
return
|
||||
}
|
||||
|
||||
// build payload
|
||||
// 验证 JsonEditor 数据
|
||||
const featureValid = featureConfigRef.value?.validate() ?? true
|
||||
const limitValid = usageLimitsRef.value?.validate() ?? true
|
||||
const customValid = customParamsRef.value?.validate() ?? true
|
||||
if (!featureValid || !limitValid || !customValid) {
|
||||
ElMessage.error(t('pages.licenses.form.keyValue.validationFailed'))
|
||||
changeSubmitting.value = false
|
||||
return
|
||||
}
|
||||
|
||||
// 构建 payload
|
||||
const payload: any = {
|
||||
change_type: updateChangeType.value,
|
||||
reason: updateReason.value
|
||||
@@ -572,32 +461,10 @@ const handleConfirmUpdate = async () => {
|
||||
payload.max_activations = updateMaxActivations.value
|
||||
}
|
||||
|
||||
// build JSON object from key-value entries
|
||||
const fc = buildJsonRecord(featureConfigEntries.value)
|
||||
if (fc) payload.feature_config = fc
|
||||
const ul = buildJsonRecord(usageLimitsEntries.value)
|
||||
if (ul) payload.usage_limits = ul
|
||||
const cp = buildJsonRecord(customParametersEntries.value)
|
||||
if (cp) payload.custom_parameters = cp
|
||||
// validate KV entries before sending
|
||||
const v1 = validateKeyValueEntries(featureConfigEntries.value)
|
||||
if (!v1.ok) {
|
||||
ElMessage.error(v1.msg || t('pages.licenses.detail.updateDialog.validation.featureConfigInvalid'))
|
||||
changeSubmitting.value = false
|
||||
return
|
||||
}
|
||||
const v2 = validateKeyValueEntries(usageLimitsEntries.value)
|
||||
if (!v2.ok) {
|
||||
ElMessage.error(v2.msg || t('pages.licenses.detail.updateDialog.validation.usageLimitsInvalid'))
|
||||
changeSubmitting.value = false
|
||||
return
|
||||
}
|
||||
const v3 = validateKeyValueEntries(customParametersEntries.value)
|
||||
if (!v3.ok) {
|
||||
ElMessage.error(v3.msg || t('pages.licenses.detail.updateDialog.validation.customParametersInvalid'))
|
||||
changeSubmitting.value = false
|
||||
return
|
||||
}
|
||||
// 使用 JsonEditor 数据
|
||||
payload.feature_config = featureConfigData.value || {}
|
||||
payload.usage_limits = usageLimitsData.value || {}
|
||||
payload.custom_parameters = customParamsData.value || {}
|
||||
|
||||
updateSubmitting.value = true
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
<template>
|
||||
<Layout app-name="Cedar-V" page-title="菜单管理">
|
||||
<div class="page-shell">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1>菜单管理</h1>
|
||||
<p>控制后台导航分组与菜单显示状态,保存后侧边栏立即生效。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="menu-card">
|
||||
<div v-for="group in menus" :key="group.id" class="menu-group">
|
||||
<div class="menu-group-head">
|
||||
<div>
|
||||
<strong>{{ group.label }}</strong>
|
||||
<span class="route">{{ group.href }}</span>
|
||||
</div>
|
||||
<el-switch v-model="group.visible" />
|
||||
</div>
|
||||
<div v-if="group.children?.length" class="menu-children">
|
||||
<div v-for="child in group.children" :key="child.id" class="menu-child">
|
||||
<span>{{ child.label }}</span>
|
||||
<span class="route">{{ child.href }}</span>
|
||||
<el-switch v-model="child.visible" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer">
|
||||
<el-button type="primary" @click="saveMenus">保存菜单配置</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import Layout from '@/components/common/layout/Layout.vue'
|
||||
import { appendAuditLog, getAdminMenus, saveAdminMenus, type AdminMenuItem } from '@/utils/adminLocalData'
|
||||
|
||||
const menus = ref<AdminMenuItem[]>(JSON.parse(JSON.stringify(getAdminMenus())))
|
||||
|
||||
function saveMenus() {
|
||||
saveAdminMenus(menus.value)
|
||||
appendAuditLog({ module: '菜单管理', action: '保存', operator: 'admin', detail: '更新后台菜单显示配置' })
|
||||
ElMessage.success('菜单配置已保存')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.page-shell { padding: 24px; }
|
||||
.page-header { margin-bottom: 20px; }
|
||||
.page-header h1 { margin: 0; font-size: 28px; }
|
||||
.page-header p { margin: 8px 0 0; color: #6b7280; }
|
||||
.menu-card {
|
||||
background: #fff;
|
||||
border-radius: 18px;
|
||||
padding: 20px;
|
||||
box-shadow: 0 10px 28px rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
.menu-group { border-bottom: 1px solid #eef2f7; padding: 16px 0; }
|
||||
.menu-group:last-child { border-bottom: none; }
|
||||
.menu-group-head, .menu-child {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 160px 60px;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
.menu-children { margin-top: 10px; padding-left: 22px; display: flex; flex-direction: column; gap: 10px; }
|
||||
.route { color: #94a3b8; font-size: 13px; }
|
||||
.footer { display: flex; justify-content: flex-end; padding-top: 16px; }
|
||||
</style>
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="loading_box">
|
||||
<el-dialog v-model="visible" :title="t('packages.edit.title')" width="700px" class="package-edit-dialog"
|
||||
<el-dialog v-model="visible" :title="dialogTitle" width="700px" class="package-edit-dialog"
|
||||
destroy-on-close>
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-position="top" class="edit-form">
|
||||
<div class="form-row">
|
||||
@@ -59,7 +59,7 @@
|
||||
<el-form-item :label="t('packages.edit.status')" prop="status" class="flex-1">
|
||||
<el-select v-model="form.status" class="w-full">
|
||||
<el-option :label="t('packages.status.enabled')" :value="1" />
|
||||
<el-option :label="t('packages.status.disabled')" :value="2" />
|
||||
<el-option :label="t('packages.status.disabled')" :value="0" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('packages.edit.sort')" prop="sort_order" class="flex-1">
|
||||
@@ -77,7 +77,7 @@
|
||||
<div class="dialog-footer">
|
||||
<el-button @click="visible = false">{{ t('common.cancel') }}</el-button>
|
||||
<el-button type="primary" class="btn-save" @click="handleSave">
|
||||
{{ t('packages.edit.save') }}
|
||||
{{ saveButtonText }}
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -86,7 +86,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, reactive } from 'vue'
|
||||
import { ref, watch, reactive, computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { Plus, Minus } from '@element-plus/icons-vue'
|
||||
import type { FormInstance, FormRules } from 'element-plus'
|
||||
@@ -94,6 +94,7 @@ import type { FormInstance, FormRules } from 'element-plus'
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
data: any
|
||||
mode?: 'create' | 'edit'
|
||||
}>()
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'save'])
|
||||
@@ -101,6 +102,8 @@ const emit = defineEmits(['update:modelValue', 'save'])
|
||||
const { t } = useI18n()
|
||||
const visible = ref(props.modelValue)
|
||||
const formRef = ref<FormInstance>()
|
||||
const dialogTitle = computed(() => props.mode === 'create' ? t('packages.create.title') : t('packages.edit.title'))
|
||||
const saveButtonText = computed(() => props.mode === 'create' ? t('packages.create.save') : t('packages.edit.save'))
|
||||
|
||||
const form = reactive({
|
||||
name: '',
|
||||
@@ -115,6 +118,19 @@ const form = reactive({
|
||||
remark: ''
|
||||
})
|
||||
|
||||
const resetForm = () => {
|
||||
form.name = ''
|
||||
form.type = 'trial'
|
||||
form.price = 0
|
||||
form.price_description = ''
|
||||
form.duration_description = ''
|
||||
form.description = ''
|
||||
form.features = []
|
||||
form.status = 1
|
||||
form.sort_order = 1
|
||||
form.remark = ''
|
||||
}
|
||||
|
||||
const rules = reactive<FormRules>({
|
||||
name: [{ required: true, message: t('common.required'), trigger: 'blur' }],
|
||||
type: [{ required: true, message: t('common.required'), trigger: 'change' }],
|
||||
@@ -127,7 +143,12 @@ const rules = reactive<FormRules>({
|
||||
|
||||
watch(() => props.modelValue, (val) => {
|
||||
visible.value = val
|
||||
if (val && props.data) {
|
||||
if (!val) return
|
||||
if (props.mode === 'create') {
|
||||
resetForm()
|
||||
return
|
||||
}
|
||||
if (props.data) {
|
||||
form.name = props.data.name || ''
|
||||
form.type = props.data.type || 'trial'
|
||||
form.price = Number(props.data.price) || 0
|
||||
@@ -135,10 +156,12 @@ watch(() => props.modelValue, (val) => {
|
||||
form.duration_description = props.data.duration_description || ''
|
||||
form.description = props.data.description || ''
|
||||
form.features = Array.isArray(props.data.features) ? [...props.data.features] : []
|
||||
form.status = props.data.status === 1 ? 1 : 2
|
||||
form.status = props.data.status === 1 ? 1 : 0
|
||||
form.sort_order = props.data.sort_order || 1
|
||||
form.remark = props.data.remark || ''
|
||||
return
|
||||
}
|
||||
resetForm()
|
||||
})
|
||||
|
||||
watch(visible, (val) => {
|
||||
|
||||
@@ -7,13 +7,18 @@
|
||||
<span class="title-bar"></span>
|
||||
<span>{{ t('packages.table.title') }}</span>
|
||||
</div>
|
||||
<el-button
|
||||
class="refresh-btn"
|
||||
:icon="Refresh"
|
||||
circle
|
||||
@click="fetchData"
|
||||
:loading="loading"
|
||||
/>
|
||||
<div class="header-actions">
|
||||
<el-button class="create-btn" type="primary" @click="handleCreate">
|
||||
{{ t('packages.actions.create') }}
|
||||
</el-button>
|
||||
<el-button
|
||||
class="refresh-btn"
|
||||
:icon="Refresh"
|
||||
circle
|
||||
@click="fetchData"
|
||||
:loading="loading"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-table
|
||||
@@ -54,11 +59,23 @@
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="t('packages.table.actions')" width="100" fixed="right">
|
||||
<el-table-column :label="t('packages.table.actions')" width="260" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" class="btn-edit" @click="handleEdit(row)">
|
||||
{{ t('packages.actions.edit') }}
|
||||
</el-button>
|
||||
<div class="action-buttons">
|
||||
<el-button size="small" class="btn-edit" @click="handleEdit(row)">
|
||||
{{ t('packages.actions.edit') }}
|
||||
</el-button>
|
||||
<el-button
|
||||
size="small"
|
||||
class="btn-toggle"
|
||||
@click="handleToggleStatus(row)"
|
||||
>
|
||||
{{ row.status === 1 ? t('packages.actions.disable') : t('packages.actions.enable') }}
|
||||
</el-button>
|
||||
<el-button size="small" class="btn-delete" @click="handleDelete(row)">
|
||||
{{ t('packages.actions.delete') }}
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
@@ -66,9 +83,10 @@
|
||||
</div>
|
||||
|
||||
<PackageEditDialog
|
||||
v-model="editVisible"
|
||||
v-model="dialogVisible"
|
||||
:data="selectedPackage"
|
||||
@save="handleUpdate"
|
||||
:mode="dialogMode"
|
||||
@save="handleSavePackage"
|
||||
/>
|
||||
</Layout>
|
||||
</template>
|
||||
@@ -78,8 +96,8 @@ import { ref, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import Layout from '@/components/common/layout/Layout.vue'
|
||||
import PackageEditDialog from './components/PackageEditDialog.vue'
|
||||
import { getPackages, updatePackage, getPackageDetail, type Package } from '@/api/package'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { getPackages, updatePackage, getPackageDetail, createPackage, deletePackage, updatePackageStatus, type Package } from '@/api/package'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Refresh } from '@element-plus/icons-vue'
|
||||
import { formatDateTime } from '@/utils/date'
|
||||
|
||||
@@ -126,9 +144,16 @@ onMounted(() => {
|
||||
fetchData()
|
||||
})
|
||||
|
||||
const editVisible = ref(false)
|
||||
const dialogVisible = ref(false)
|
||||
const dialogMode = ref<'create' | 'edit'>('edit')
|
||||
const selectedPackage = ref<any>(null)
|
||||
|
||||
const handleCreate = () => {
|
||||
dialogMode.value = 'create'
|
||||
selectedPackage.value = null
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
const handleEdit = async (row: any) => {
|
||||
loading.value = true
|
||||
try {
|
||||
@@ -151,7 +176,8 @@ const handleEdit = async (row: any) => {
|
||||
...detail,
|
||||
features
|
||||
}
|
||||
editVisible.value = true
|
||||
dialogMode.value = 'edit'
|
||||
dialogVisible.value = true
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('Fetch package detail error:', error)
|
||||
@@ -161,12 +187,27 @@ const handleEdit = async (row: any) => {
|
||||
}
|
||||
}
|
||||
|
||||
const handleUpdate = async (updatedData: any) => {
|
||||
const normalizePackagePayload = (rawData: any) => {
|
||||
const { id, created_at, updated_at, ...data } = rawData
|
||||
if (Array.isArray(data.features)) {
|
||||
data.features = JSON.stringify(data.features.filter((item: string) => item && item.trim()))
|
||||
}
|
||||
if (data.status !== 1) {
|
||||
data.status = 0
|
||||
}
|
||||
return { id, data }
|
||||
}
|
||||
|
||||
const handleSavePackage = async (savedData: any) => {
|
||||
try {
|
||||
const { id, created_at, updated_at, ...data } = updatedData
|
||||
// 将 features 数组转回字符串供后端存储
|
||||
if (Array.isArray(data.features)) {
|
||||
data.features = JSON.stringify(data.features)
|
||||
const { id, data } = normalizePackagePayload(savedData)
|
||||
if (dialogMode.value === 'create') {
|
||||
const createRes = await createPackage(data)
|
||||
if (createRes.code === '000000') {
|
||||
ElMessage.success(t('packages.messages.createSuccess'))
|
||||
fetchData()
|
||||
}
|
||||
return
|
||||
}
|
||||
const res = await updatePackage(id, data)
|
||||
if (res.code === '000000') {
|
||||
@@ -175,7 +216,58 @@ const handleUpdate = async (updatedData: any) => {
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('Update package error:', error)
|
||||
ElMessage.error(error.backendMessage || t('packages.messages.updateError'))
|
||||
ElMessage.error(
|
||||
error.backendMessage ||
|
||||
(dialogMode.value === 'create' ? t('packages.messages.createError') : t('packages.messages.updateError'))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const handleToggleStatus = async (row: Package) => {
|
||||
const nextStatus = row.status === 1 ? 0 : 1
|
||||
const actionText = nextStatus === 1 ? t('packages.actions.enable') : t('packages.actions.disable')
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
t('packages.messages.toggleConfirm', { action: actionText, name: row.name }),
|
||||
t('common.confirm'),
|
||||
{
|
||||
confirmButtonText: t('common.confirm'),
|
||||
cancelButtonText: t('common.cancel'),
|
||||
type: 'warning'
|
||||
}
|
||||
)
|
||||
const res = await updatePackageStatus(row.id, nextStatus as 0 | 1)
|
||||
if (res.code === '000000') {
|
||||
ElMessage.success(t('packages.messages.toggleSuccess'))
|
||||
fetchData()
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (error !== 'cancel') {
|
||||
ElMessage.error(error.backendMessage || t('packages.messages.toggleError'))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (row: Package) => {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
t('packages.messages.deleteConfirm', { name: row.name }),
|
||||
t('common.confirm'),
|
||||
{
|
||||
confirmButtonText: t('common.confirm'),
|
||||
cancelButtonText: t('common.cancel'),
|
||||
type: 'warning'
|
||||
}
|
||||
)
|
||||
const res = await deletePackage(row.id)
|
||||
if (res.code === '000000') {
|
||||
ElMessage.success(t('packages.messages.deleteSuccess'))
|
||||
fetchData()
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (error !== 'cancel') {
|
||||
ElMessage.error(error.backendMessage || t('packages.messages.deleteError'))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -202,6 +294,17 @@ const handleUpdate = async (updatedData: any) => {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.create-btn {
|
||||
background: #00a870;
|
||||
border-color: #00a870;
|
||||
}
|
||||
|
||||
.list-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -253,6 +356,31 @@ const handleUpdate = async (updatedData: any) => {
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.btn-toggle {
|
||||
border: none;
|
||||
background: #eef3ff !important;
|
||||
color: #5678d6 !important;
|
||||
padding: 4px 12px;
|
||||
height: 28px;
|
||||
font-size: 12px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.btn-delete {
|
||||
border: none;
|
||||
background: #fff1f0 !important;
|
||||
color: #f56c6c !important;
|
||||
padding: 4px 12px;
|
||||
height: 28px;
|
||||
font-size: 12px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.price-free {
|
||||
color: #666;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
<template>
|
||||
<Layout app-name="Cedar-V" page-title="支付配置">
|
||||
<div class="page-shell">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1>支付配置</h1>
|
||||
<p>统一维护公开购买、授权续期与订单支付的渠道参数和超时策略。</p>
|
||||
</div>
|
||||
<el-button type="primary" @click="saveSettings">保存配置</el-button>
|
||||
</div>
|
||||
|
||||
<div class="page-grid">
|
||||
<div class="card">
|
||||
<div class="card-title">基础策略</div>
|
||||
<el-form :model="settings" label-position="top">
|
||||
<div class="form-grid">
|
||||
<el-form-item label="默认支付方式">
|
||||
<el-select v-model="settings.defaultMethod">
|
||||
<el-option label="支付宝" value="alipay" />
|
||||
<el-option label="微信支付" value="wechat" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="支付过期时间(分钟)">
|
||||
<el-input-number v-model="settings.expireMinutes" :min="5" :max="180" />
|
||||
</el-form-item>
|
||||
<el-form-item label="重试次数">
|
||||
<el-input-number v-model="settings.retryTimes" :min="0" :max="10" />
|
||||
</el-form-item>
|
||||
<el-form-item label="启用支付日志">
|
||||
<el-switch v-model="settings.enableLog" />
|
||||
</el-form-item>
|
||||
</div>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-head">
|
||||
<div>
|
||||
<div class="card-title">支付宝</div>
|
||||
<p>当前公开购买与续期页面默认使用该渠道。</p>
|
||||
</div>
|
||||
<el-switch v-model="settings.alipay.enabled" />
|
||||
</div>
|
||||
<el-form :model="settings.alipay" label-position="top">
|
||||
<div class="form-grid">
|
||||
<el-form-item label="App ID">
|
||||
<el-input v-model="settings.alipay.appId" />
|
||||
</el-form-item>
|
||||
<el-form-item label="签名方式">
|
||||
<el-input v-model="settings.alipay.signType" />
|
||||
</el-form-item>
|
||||
<el-form-item label="网关地址" class="span-2">
|
||||
<el-input v-model="settings.alipay.gatewayUrl" />
|
||||
</el-form-item>
|
||||
<el-form-item label="回调地址" class="span-2">
|
||||
<el-input v-model="settings.alipay.notifyUrl" />
|
||||
</el-form-item>
|
||||
<el-form-item label="支付完成跳转" class="span-2">
|
||||
<el-input v-model="settings.alipay.returnUrl" />
|
||||
</el-form-item>
|
||||
<el-form-item label="备注" class="span-2">
|
||||
<el-input v-model="settings.alipay.remark" type="textarea" :rows="3" />
|
||||
</el-form-item>
|
||||
</div>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-head">
|
||||
<div>
|
||||
<div class="card-title">微信支付</div>
|
||||
<p>先把配置位补齐,后续接入真实渠道时可直接替换参数。</p>
|
||||
</div>
|
||||
<el-switch v-model="settings.wechat.enabled" />
|
||||
</div>
|
||||
<el-form :model="settings.wechat" label-position="top">
|
||||
<div class="form-grid">
|
||||
<el-form-item label="应用标识">
|
||||
<el-input v-model="settings.wechat.appId" placeholder="例如商户号或 AppID" />
|
||||
</el-form-item>
|
||||
<el-form-item label="签名方式">
|
||||
<el-input v-model="settings.wechat.signType" />
|
||||
</el-form-item>
|
||||
<el-form-item label="网关地址" class="span-2">
|
||||
<el-input v-model="settings.wechat.gatewayUrl" />
|
||||
</el-form-item>
|
||||
<el-form-item label="回调地址" class="span-2">
|
||||
<el-input v-model="settings.wechat.notifyUrl" />
|
||||
</el-form-item>
|
||||
<el-form-item label="支付完成跳转" class="span-2">
|
||||
<el-input v-model="settings.wechat.returnUrl" />
|
||||
</el-form-item>
|
||||
<el-form-item label="备注" class="span-2">
|
||||
<el-input v-model="settings.wechat.remark" type="textarea" :rows="3" />
|
||||
</el-form-item>
|
||||
</div>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-title">部署提示</div>
|
||||
<ul class="tips">
|
||||
<li>运行时支付参数以后端配置文件为准,该页面用于运营可视化维护和部署前确认。</li>
|
||||
<li>开发环境已接支付宝沙箱,生产环境请同步修改 <code>backend/configs/config.prod.yaml</code>。</li>
|
||||
<li>公开页面入口为 <code>/public-renewal</code>,免登录用户会直接创建支付单并写入客户信息。</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import Layout from '@/components/common/layout/Layout.vue'
|
||||
import { appendAuditLog, getPaymentSettings, savePaymentSettings, type PaymentSettings } from '@/utils/adminLocalData'
|
||||
|
||||
const settings = reactive<PaymentSettings>(JSON.parse(JSON.stringify(getPaymentSettings())))
|
||||
|
||||
function saveSettings() {
|
||||
savePaymentSettings(JSON.parse(JSON.stringify(settings)))
|
||||
appendAuditLog({
|
||||
module: '支付配置',
|
||||
action: '保存',
|
||||
operator: 'admin',
|
||||
detail: `更新支付配置,默认方式 ${settings.defaultMethod},过期时间 ${settings.expireMinutes} 分钟`
|
||||
})
|
||||
ElMessage.success('支付配置已保存')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.page-shell { padding: 24px; }
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.page-header h1 { margin: 0; font-size: 28px; }
|
||||
.page-header p { margin: 8px 0 0; color: #6b7280; }
|
||||
.page-grid {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
}
|
||||
.card {
|
||||
background: #fff;
|
||||
border-radius: 18px;
|
||||
padding: 20px;
|
||||
box-shadow: 0 10px 28px rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
.card-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.card-head p { margin: 6px 0 0; color: #6b7280; }
|
||||
.card-title {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: #0f172a;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
.span-2 { grid-column: span 2; }
|
||||
.tips {
|
||||
margin: 0;
|
||||
padding-left: 18px;
|
||||
color: #475569;
|
||||
line-height: 1.7;
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.page-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.form-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.span-2 {
|
||||
grid-column: span 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,59 @@
|
||||
<template>
|
||||
<Layout app-name="Cedar-V" page-title="产品项管理">
|
||||
<div class="page-shell">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1>产品项管理</h1>
|
||||
<p>按产品视角整理当前可售套餐与续期策略,便于业务统一维护。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-table :data="tableData" v-loading="loading" class="data-table">
|
||||
<el-table-column prop="name" label="产品项" min-width="160" />
|
||||
<el-table-column prop="type" label="类型" min-width="120" />
|
||||
<el-table-column prop="duration_description" label="周期说明" min-width="180" />
|
||||
<el-table-column prop="priceText" label="定价" min-width="120" />
|
||||
<el-table-column prop="statusText" label="状态" min-width="100" />
|
||||
<el-table-column prop="description" label="业务说明" min-width="240" />
|
||||
</el-table>
|
||||
</div>
|
||||
</Layout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import Layout from '@/components/common/layout/Layout.vue'
|
||||
import { getPackages, type Package } from '@/api/package'
|
||||
|
||||
const loading = ref(false)
|
||||
const tableData = ref<Array<Package & { priceText: string; statusText: string }>>([])
|
||||
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getPackages()
|
||||
if (res.code === '000000' && res.data) {
|
||||
tableData.value = res.data.packages.map(item => ({
|
||||
...item,
|
||||
priceText: item.type === 'custom' ? '定制报价' : `¥${item.price}`,
|
||||
statusText: item.status === 1 ? '启用' : '停用'
|
||||
}))
|
||||
}
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error.backendMessage || '加载产品项失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadData)
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.page-shell { padding: 24px; }
|
||||
.page-header { margin-bottom: 20px; }
|
||||
.page-header h1 { margin: 0; font-size: 28px; }
|
||||
.page-header p { margin: 8px 0 0; color: #6b7280; }
|
||||
.data-table { background: #fff; border-radius: 18px; overflow: hidden; }
|
||||
</style>
|
||||
@@ -0,0 +1,558 @@
|
||||
<template>
|
||||
<div class="public-page">
|
||||
<div class="hero-panel">
|
||||
<div>
|
||||
<div class="eyebrow">Cedar-V Public Portal</div>
|
||||
<h1>免登录购买、续期与授权码查询</h1>
|
||||
<p>企业客户可直接完成新购、通过授权码续期,并实时回写联系人信息。</p>
|
||||
</div>
|
||||
<div class="hero-actions">
|
||||
<el-button plain @click="goLogin">进入后台</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="portal-card">
|
||||
<el-tabs v-model="activeTab" class="portal-tabs">
|
||||
<el-tab-pane label="新购授权" name="purchase" />
|
||||
<el-tab-pane label="授权续期" name="renewal" />
|
||||
<el-tab-pane label="查询授权码" name="query" />
|
||||
</el-tabs>
|
||||
|
||||
<div v-if="activeTab === 'purchase'" class="tab-body">
|
||||
<div class="section-title">新购授权</div>
|
||||
<el-form :model="purchaseForm" label-position="top">
|
||||
<div class="grid">
|
||||
<el-form-item label="企业名称"><el-input v-model="purchaseForm.company_name" /></el-form-item>
|
||||
<el-form-item label="联系人"><el-input v-model="purchaseForm.contact_name" /></el-form-item>
|
||||
<el-form-item label="联系电话"><el-input v-model="purchaseForm.contact_phone" /></el-form-item>
|
||||
<el-form-item label="邮箱"><el-input v-model="purchaseForm.contact_email" /></el-form-item>
|
||||
<el-form-item label="客户类型">
|
||||
<el-select v-model="purchaseForm.customer_type">
|
||||
<el-option label="企业客户" value="enterprise" />
|
||||
<el-option label="个人客户" value="individual" />
|
||||
<el-option label="政府客户" value="government" />
|
||||
<el-option label="教育客户" value="education" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="套餐">
|
||||
<el-select v-model="purchaseForm.package_id" filterable>
|
||||
<el-option v-for="pkg in packages" :key="pkg.id" :label="`${pkg.name} · ${pkg.display_price || `¥${pkg.price}`}`" :value="pkg.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="许可数量"><el-input-number v-model="purchaseForm.license_count" :min="1" :max="1000" /></el-form-item>
|
||||
<el-form-item label="业务说明" class="span-2"><el-input v-model="purchaseForm.description" type="textarea" :rows="3" /></el-form-item>
|
||||
</div>
|
||||
<el-button type="primary" :loading="purchaseLoading" @click="submitPurchase">创建支付单</el-button>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<div v-if="activeTab === 'renewal'" class="tab-body">
|
||||
<div class="section-title">授权续期</div>
|
||||
<el-form :model="queryForm" @submit.prevent class="query-inline">
|
||||
<el-input v-model="queryForm.authorizationCode" placeholder="请输入授权码先查询" clearable />
|
||||
<el-button type="primary" :loading="queryLoading" @click="handleQuery">查询授权码</el-button>
|
||||
</el-form>
|
||||
|
||||
<div v-if="authInfo" class="auth-card">
|
||||
<div class="auth-head">
|
||||
<div>
|
||||
<h3>{{ authInfo.customer_name || '未命名客户' }}</h3>
|
||||
<p>{{ authInfo.authorization_code }}</p>
|
||||
</div>
|
||||
<el-tag :type="statusTagType(authInfo.status)">{{ statusLabel(authInfo.status) }}</el-tag>
|
||||
</div>
|
||||
<div class="auth-meta">
|
||||
<span>到期时间:{{ formatDate(authInfo.end_date) }}</span>
|
||||
<span>激活数:{{ authInfo.active_licenses }}/{{ authInfo.max_activations }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-form :model="renewalForm" label-position="top">
|
||||
<div class="grid">
|
||||
<el-form-item label="联系人"><el-input v-model="renewalForm.contact_name" /></el-form-item>
|
||||
<el-form-item label="联系电话"><el-input v-model="renewalForm.contact_phone" /></el-form-item>
|
||||
<el-form-item label="邮箱"><el-input v-model="renewalForm.contact_email" /></el-form-item>
|
||||
<el-form-item label="企业名称"><el-input v-model="renewalForm.company_name" /></el-form-item>
|
||||
<el-form-item label="套餐">
|
||||
<el-select v-model="renewalForm.package_id" filterable>
|
||||
<el-option v-for="pkg in packages" :key="pkg.id" :label="`${pkg.name} · ${pkg.display_price || `¥${pkg.price}`}`" :value="pkg.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="许可数量"><el-input-number v-model="renewalForm.license_count" :min="1" :max="1000" /></el-form-item>
|
||||
<el-form-item label="续期天数">
|
||||
<el-segmented v-model="renewalForm.renew_days" :options="renewDayOptions" />
|
||||
</el-form-item>
|
||||
</div>
|
||||
<el-button type="success" :loading="renewalLoading" @click="submitRenewal">创建续期支付单</el-button>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<div v-if="activeTab === 'query'" class="tab-body">
|
||||
<div class="section-title">授权码查询</div>
|
||||
<el-form :model="queryForm" @submit.prevent class="query-inline">
|
||||
<el-input v-model="queryForm.authorizationCode" placeholder="请输入授权码" clearable />
|
||||
<el-button type="primary" :loading="queryLoading" @click="handleQuery">查询</el-button>
|
||||
</el-form>
|
||||
|
||||
<el-descriptions v-if="authInfo" :column="1" border class="query-result">
|
||||
<el-descriptions-item label="客户名称">{{ authInfo.customer_name || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="授权码">{{ authInfo.authorization_code }}</el-descriptions-item>
|
||||
<el-descriptions-item label="状态">{{ statusLabel(authInfo.status) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="到期时间">{{ formatDate(authInfo.end_date) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="联系人">{{ authInfo.contact_person || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="联系电话">{{ authInfo.contact_phone || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="邮箱">{{ authInfo.contact_email || '-' }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</div>
|
||||
|
||||
<div v-if="paymentResult" class="payment-panel">
|
||||
<div class="payment-main">
|
||||
<div>
|
||||
<div class="payment-title">支付单已创建</div>
|
||||
<p v-if="paymentResult.payment_no">支付单号:{{ paymentResult.payment_no }}</p>
|
||||
<p>支付金额:¥{{ paymentResult.total_amount }}</p>
|
||||
<p v-if="paymentResult.expire_time">过期时间:{{ formatDate(paymentResult.expire_time) }}</p>
|
||||
</div>
|
||||
<div class="payment-buttons" v-if="paymentResult.payment_no">
|
||||
<el-button type="primary" @click="openPaymentUrl">去支付</el-button>
|
||||
<el-button :loading="statusLoading" @click="checkPaymentStatus">刷新状态</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="paymentResult.authorization_code" class="result-box">
|
||||
新授权码:{{ paymentResult.authorization_code }}
|
||||
</div>
|
||||
<el-alert
|
||||
v-if="paymentStatus"
|
||||
:type="paymentStatus.status === 'paid' ? 'success' : 'info'"
|
||||
:title="paymentStatus.status === 'paid' ? '支付成功' : `当前状态:${paymentStatus.status}`"
|
||||
:closable="false"
|
||||
/>
|
||||
<div v-if="paymentStatus?.business_order?.authorization_code" class="result-box">
|
||||
新授权码:{{ paymentStatus.business_order.authorization_code }}
|
||||
</div>
|
||||
<div v-if="paymentStatus?.authorization_end_date" class="result-box">
|
||||
最新到期时间:{{ formatDate(paymentStatus.authorization_end_date) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, onMounted, reactive, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import {
|
||||
createPurchaseOrder,
|
||||
createRenewalOrder,
|
||||
getPublicPackages,
|
||||
getPublicPaymentStatus,
|
||||
queryAuthorizationCode,
|
||||
type AuthorizationCodeInfo,
|
||||
type PublicPackageItem,
|
||||
type PublicPaymentStatus,
|
||||
type RenewalOrderResponse
|
||||
} from '@/api/publicRenewal'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const activeTab = ref<'purchase' | 'renewal' | 'query'>('purchase')
|
||||
const queryLoading = ref(false)
|
||||
const purchaseLoading = ref(false)
|
||||
const renewalLoading = ref(false)
|
||||
const statusLoading = ref(false)
|
||||
|
||||
const packages = ref<PublicPackageItem[]>([])
|
||||
const authInfo = ref<AuthorizationCodeInfo | null>(null)
|
||||
const paymentResult = ref<RenewalOrderResponse | null>(null)
|
||||
const paymentStatus = ref<PublicPaymentStatus | null>(null)
|
||||
const statusTimer = ref<number | null>(null)
|
||||
|
||||
const renewDayOptions = [
|
||||
{ label: '30天', value: 30 },
|
||||
{ label: '90天', value: 90 },
|
||||
{ label: '180天', value: 180 },
|
||||
{ label: '365天', value: 365 }
|
||||
]
|
||||
|
||||
const queryForm = reactive({
|
||||
authorizationCode: ''
|
||||
})
|
||||
|
||||
const purchaseForm = reactive({
|
||||
company_name: '',
|
||||
contact_name: '',
|
||||
contact_phone: '',
|
||||
contact_email: '',
|
||||
customer_type: 'enterprise',
|
||||
package_id: '',
|
||||
license_count: 1,
|
||||
description: ''
|
||||
})
|
||||
|
||||
const renewalForm = reactive({
|
||||
package_id: '',
|
||||
license_count: 1,
|
||||
renew_days: 365,
|
||||
contact_name: '',
|
||||
contact_phone: '',
|
||||
contact_email: '',
|
||||
company_name: ''
|
||||
})
|
||||
|
||||
function goLogin() {
|
||||
router.push('/login')
|
||||
}
|
||||
|
||||
async function loadPackages() {
|
||||
try {
|
||||
const res = await getPublicPackages()
|
||||
if (res.code === '000000' && res.data) {
|
||||
packages.value = res.data
|
||||
if (!purchaseForm.package_id && res.data[0]) {
|
||||
purchaseForm.package_id = res.data[0].id
|
||||
}
|
||||
if (!renewalForm.package_id && res.data[0]) {
|
||||
renewalForm.package_id = res.data[0].id
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error.backendMessage || '加载套餐失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleQuery() {
|
||||
if (!queryForm.authorizationCode.trim()) {
|
||||
ElMessage.warning('请输入授权码')
|
||||
return
|
||||
}
|
||||
queryLoading.value = true
|
||||
try {
|
||||
const res = await queryAuthorizationCode(queryForm.authorizationCode.trim())
|
||||
if (res.code === '000000' && res.data) {
|
||||
authInfo.value = res.data
|
||||
renewalForm.contact_name = res.data.contact_person || ''
|
||||
renewalForm.contact_phone = res.data.contact_phone || ''
|
||||
renewalForm.contact_email = res.data.contact_email || ''
|
||||
renewalForm.company_name = res.data.customer_name || ''
|
||||
activeTab.value = activeTab.value === 'query' ? 'query' : 'renewal'
|
||||
ElMessage.success('查询成功')
|
||||
}
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error.backendMessage || '授权码查询失败')
|
||||
} finally {
|
||||
queryLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function submitPurchase() {
|
||||
if (!purchaseForm.contact_name || !purchaseForm.contact_phone || !purchaseForm.package_id) {
|
||||
ElMessage.warning('请先完善购买信息')
|
||||
return
|
||||
}
|
||||
purchaseLoading.value = true
|
||||
try {
|
||||
const res = await createPurchaseOrder({
|
||||
...purchaseForm,
|
||||
payment_method: 'alipay'
|
||||
})
|
||||
if (res.code === '000000' && res.data) {
|
||||
applyPaymentResult(res.data)
|
||||
ElMessage.success('新购支付单创建成功')
|
||||
}
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error.backendMessage || '新购支付单创建失败')
|
||||
} finally {
|
||||
purchaseLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function submitRenewal() {
|
||||
if (!authInfo.value) {
|
||||
ElMessage.warning('请先查询授权码')
|
||||
return
|
||||
}
|
||||
renewalLoading.value = true
|
||||
try {
|
||||
const res = await createRenewalOrder({
|
||||
authorization_code: authInfo.value.authorization_code,
|
||||
...renewalForm,
|
||||
payment_method: 'alipay'
|
||||
})
|
||||
if (res.code === '000000' && res.data) {
|
||||
applyPaymentResult(res.data)
|
||||
ElMessage.success('续期支付单创建成功')
|
||||
}
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error.backendMessage || '续期支付单创建失败')
|
||||
} finally {
|
||||
renewalLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function applyPaymentResult(result: RenewalOrderResponse) {
|
||||
paymentResult.value = result
|
||||
paymentStatus.value = null
|
||||
if (result.payment_no) {
|
||||
startPolling()
|
||||
} else {
|
||||
stopPolling()
|
||||
}
|
||||
}
|
||||
|
||||
async function checkPaymentStatus() {
|
||||
if (!paymentResult.value?.payment_no) return
|
||||
statusLoading.value = true
|
||||
try {
|
||||
const res = await getPublicPaymentStatus(paymentResult.value.payment_no)
|
||||
if (res.code === '000000' && res.data) {
|
||||
paymentStatus.value = res.data
|
||||
if (res.data.status === 'paid') {
|
||||
stopPolling()
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error.backendMessage || '支付状态查询失败')
|
||||
} finally {
|
||||
statusLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openPaymentUrl() {
|
||||
if (paymentResult.value?.payment_url) {
|
||||
window.open(paymentResult.value.payment_url, '_blank')
|
||||
} else {
|
||||
ElMessage.warning('当前支付方式未返回跳转地址')
|
||||
}
|
||||
}
|
||||
|
||||
function startPolling() {
|
||||
stopPolling()
|
||||
statusTimer.value = window.setInterval(() => {
|
||||
checkPaymentStatus()
|
||||
}, 5000)
|
||||
}
|
||||
|
||||
function stopPolling() {
|
||||
if (statusTimer.value) {
|
||||
window.clearInterval(statusTimer.value)
|
||||
statusTimer.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function statusTagType(status: string) {
|
||||
if (status === 'normal') return 'success'
|
||||
if (status === 'expired') return 'warning'
|
||||
return 'danger'
|
||||
}
|
||||
|
||||
function statusLabel(status: string) {
|
||||
if (status === 'normal') return '正常'
|
||||
if (status === 'expired') return '已过期'
|
||||
if (status === 'locked') return '已锁定'
|
||||
return status
|
||||
}
|
||||
|
||||
function formatDate(value?: string) {
|
||||
return value ? new Date(value).toLocaleString() : '-'
|
||||
}
|
||||
|
||||
onMounted(loadPackages)
|
||||
onBeforeUnmount(stopPolling)
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.public-page {
|
||||
min-height: 100vh;
|
||||
padding: 24px;
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(0, 168, 112, 0.16), transparent 30%),
|
||||
radial-gradient(circle at bottom right, rgba(23, 92, 255, 0.14), transparent 26%),
|
||||
linear-gradient(135deg, #f7fbff 0%, #f3fbf7 100%);
|
||||
}
|
||||
|
||||
.hero-panel,
|
||||
.portal-card {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.hero-panel {
|
||||
background: linear-gradient(135deg, #0f8b77 0%, #39c6a0 55%, #6fe0b7 100%);
|
||||
color: #fff;
|
||||
border-radius: 24px;
|
||||
padding: 28px 32px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 20px;
|
||||
box-shadow: 0 20px 50px rgba(15, 139, 119, 0.22);
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.12em;
|
||||
opacity: 0.86;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.hero-panel h1 {
|
||||
margin: 8px 0 10px;
|
||||
font-size: 34px;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.hero-panel p {
|
||||
margin: 0;
|
||||
max-width: 640px;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
|
||||
.portal-card {
|
||||
margin-top: 20px;
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
border: 1px solid rgba(255, 255, 255, 0.85);
|
||||
border-radius: 24px;
|
||||
padding: 24px;
|
||||
box-shadow: 0 18px 44px rgba(15, 23, 42, 0.08);
|
||||
backdrop-filter: blur(14px);
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 18px;
|
||||
color: #102a43;
|
||||
}
|
||||
|
||||
.tab-body {
|
||||
padding-top: 12px;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.span-2 {
|
||||
grid-column: span 2;
|
||||
}
|
||||
|
||||
.query-inline {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
gap: 12px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.auth-card {
|
||||
background: linear-gradient(135deg, #f7fffc 0%, #eef8ff 100%);
|
||||
border: 1px solid #d6efe8;
|
||||
border-radius: 18px;
|
||||
padding: 16px 18px;
|
||||
margin: 18px 0;
|
||||
}
|
||||
|
||||
.auth-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.auth-head h3 {
|
||||
margin: 0 0 4px;
|
||||
}
|
||||
|
||||
.auth-head p {
|
||||
margin: 0;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.auth-meta {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
margin-top: 12px;
|
||||
color: #334155;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.payment-panel {
|
||||
margin-top: 24px;
|
||||
padding-top: 22px;
|
||||
border-top: 1px solid #e5eef5;
|
||||
}
|
||||
|
||||
.payment-main {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 18px;
|
||||
align-items: center;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.payment-title {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.payment-main p {
|
||||
margin: 4px 0;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.payment-buttons {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.result-box {
|
||||
margin-top: 12px;
|
||||
padding: 14px 16px;
|
||||
border-radius: 14px;
|
||||
background: #f0fdf8;
|
||||
color: #047857;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.query-result {
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.public-page {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.hero-panel {
|
||||
border-radius: 18px;
|
||||
padding: 20px;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.hero-panel h1 {
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.portal-card {
|
||||
padding: 18px;
|
||||
border-radius: 18px;
|
||||
}
|
||||
|
||||
.grid,
|
||||
.query-inline {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.span-2 {
|
||||
grid-column: span 1;
|
||||
}
|
||||
|
||||
.payment-main {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,59 +1,147 @@
|
||||
<!--
|
||||
* @Author: 13895237362 2205451508@qq.com
|
||||
* @Date: 2025-08-12 00:00:00
|
||||
* @LastEditors: 13895237362 2205451508@qq.com
|
||||
* @LastEditTime: 2025-08-12 00:00:00
|
||||
* @FilePath: /frontend/src/views/Roles.vue
|
||||
* @Description: 角色权限页面
|
||||
-->
|
||||
<template>
|
||||
<Layout app-name="Cedar-V" :page-title="t('pages.roles.title')">
|
||||
<!-- 页面内容 -->
|
||||
<div class="content-container">
|
||||
<h1>{{ t('pages.roles.welcome') }}</h1>
|
||||
<Layout app-name="Cedar-V" page-title="角色权限">
|
||||
<div class="page-shell">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1>角色权限</h1>
|
||||
<p>维护后台角色、权限边界和职责说明。</p>
|
||||
</div>
|
||||
<el-button type="primary" @click="openCreate">新增角色</el-button>
|
||||
</div>
|
||||
|
||||
<el-table :data="roles" class="data-table">
|
||||
<el-table-column prop="name" label="角色名称" min-width="160" />
|
||||
<el-table-column prop="code" label="角色编码" min-width="120" />
|
||||
<el-table-column prop="description" label="描述" min-width="220" />
|
||||
<el-table-column label="权限范围" min-width="240">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-for="permission in row.permissions" :key="permission" class="tag">{{ permission }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="createdAt" label="创建时间" min-width="180" />
|
||||
<el-table-column label="操作" width="180" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" @click="openEdit(row)">编辑</el-button>
|
||||
<el-button link type="danger" @click="removeRole(row.id)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-dialog v-model="dialogVisible" :title="editingId ? '编辑角色' : '新增角色'" width="520px">
|
||||
<el-form :model="form" label-position="top">
|
||||
<el-form-item label="角色名称">
|
||||
<el-input v-model="form.name" />
|
||||
</el-form-item>
|
||||
<el-form-item label="角色编码">
|
||||
<el-input v-model="form.code" />
|
||||
</el-form-item>
|
||||
<el-form-item label="描述">
|
||||
<el-input v-model="form.description" type="textarea" :rows="3" />
|
||||
</el-form-item>
|
||||
<el-form-item label="权限范围">
|
||||
<el-select v-model="form.permissions" multiple>
|
||||
<el-option v-for="item in permissionOptions" :key="item" :label="item" :value="item" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="saveRole">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</Layout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Layout from '@/components/common/layout/Layout.vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { reactive, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import Layout from '@/components/common/layout/Layout.vue'
|
||||
import { appendAuditLog, getRoles, saveRoles, type AdminRole } from '@/utils/adminLocalData'
|
||||
|
||||
const { t } = useI18n();
|
||||
const roles = ref<AdminRole[]>(getRoles())
|
||||
const dialogVisible = ref(false)
|
||||
const editingId = ref('')
|
||||
const permissionOptions = ['dashboard', 'customers', 'licenses', 'invoices', 'leads', 'system']
|
||||
|
||||
const form = reactive({
|
||||
name: '',
|
||||
code: '',
|
||||
description: '',
|
||||
permissions: [] as string[]
|
||||
})
|
||||
|
||||
function resetForm() {
|
||||
form.name = ''
|
||||
form.code = ''
|
||||
form.description = ''
|
||||
form.permissions = []
|
||||
editingId.value = ''
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
resetForm()
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
function openEdit(role: AdminRole) {
|
||||
editingId.value = role.id
|
||||
form.name = role.name
|
||||
form.code = role.code
|
||||
form.description = role.description
|
||||
form.permissions = [...role.permissions]
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
function saveRole() {
|
||||
if (!form.name.trim() || !form.code.trim()) {
|
||||
ElMessage.warning('请填写角色名称和编码')
|
||||
return
|
||||
}
|
||||
|
||||
const nextRoles = [...roles.value]
|
||||
if (editingId.value) {
|
||||
const index = nextRoles.findIndex(item => item.id === editingId.value)
|
||||
if (index >= 0) {
|
||||
nextRoles[index] = { ...nextRoles[index], ...form }
|
||||
appendAuditLog({ module: '角色权限', action: '编辑', operator: 'admin', detail: `编辑角色 ${form.name}` })
|
||||
}
|
||||
} else {
|
||||
nextRoles.unshift({
|
||||
id: `role_${Date.now()}`,
|
||||
createdAt: new Date().toLocaleString(),
|
||||
...form
|
||||
})
|
||||
appendAuditLog({ module: '角色权限', action: '新增', operator: 'admin', detail: `新增角色 ${form.name}` })
|
||||
}
|
||||
|
||||
roles.value = nextRoles
|
||||
saveRoles(nextRoles)
|
||||
dialogVisible.value = false
|
||||
ElMessage.success('角色已保存')
|
||||
}
|
||||
|
||||
async function removeRole(id: string) {
|
||||
const role = roles.value.find(item => item.id === id)
|
||||
if (!role) return
|
||||
await ElMessageBox.confirm(`确认删除角色“${role.name}”吗?`, '提示')
|
||||
roles.value = roles.value.filter(item => item.id !== id)
|
||||
saveRoles(roles.value)
|
||||
appendAuditLog({ module: '角色权限', action: '删除', operator: 'admin', detail: `删除角色 ${role.name}` })
|
||||
ElMessage.success('角色已删除')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.content-container {
|
||||
min-height: calc(100vh - 80px);
|
||||
padding: 24px;
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.content-container {
|
||||
padding: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.content-container {
|
||||
padding: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1025px) {
|
||||
.content-container {
|
||||
height: calc(100vh - 4.17vw);
|
||||
padding: 1.25vw;
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
<style scoped lang="scss">
|
||||
.page-shell { padding: 24px; }
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.page-header h1 { margin: 0; font-size: 28px; }
|
||||
.page-header p { margin: 8px 0 0; color: #6b7280; }
|
||||
.data-table { background: #fff; border-radius: 16px; overflow: hidden; }
|
||||
.tag { margin-right: 8px; margin-bottom: 6px; }
|
||||
</style>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -1,57 +1,156 @@
|
||||
<!--
|
||||
* @Author: 13895237362 2205451508@qq.com
|
||||
* @Date: 2025-08-12 00:00:00
|
||||
* @LastEditors: 13895237362 2205451508@qq.com
|
||||
* @LastEditTime: 2025-08-12 00:00:00
|
||||
* @FilePath: /frontend/src/views/Users.vue
|
||||
* @Description: 系统用户页面
|
||||
-->
|
||||
<template>
|
||||
<Layout app-name="Cedar-V" :page-title="t('pages.users.title')">
|
||||
<!-- 页面内容 -->
|
||||
<div class="content-container">
|
||||
<h1>{{ t('pages.users.welcome') }}</h1>
|
||||
<Layout app-name="Cedar-V" page-title="系统用户">
|
||||
<div class="page-shell">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1>系统用户</h1>
|
||||
<p>管理后台使用人员、角色归属和账号状态。</p>
|
||||
</div>
|
||||
<el-button type="primary" @click="openCreate">新增用户</el-button>
|
||||
</div>
|
||||
|
||||
<el-table :data="users" class="data-table">
|
||||
<el-table-column prop="username" label="用户名" min-width="140" />
|
||||
<el-table-column prop="roleCode" label="角色" min-width="120" />
|
||||
<el-table-column prop="phone" label="手机号" min-width="150" />
|
||||
<el-table-column prop="email" label="邮箱" min-width="220" />
|
||||
<el-table-column label="状态" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.status === 'active' ? 'success' : 'info'">{{ row.status === 'active' ? '启用' : '停用' }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="lastLoginAt" label="最近登录" min-width="180" />
|
||||
<el-table-column label="操作" width="220" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" @click="openEdit(row)">编辑</el-button>
|
||||
<el-button link @click="toggleStatus(row)">{{ row.status === 'active' ? '停用' : '启用' }}</el-button>
|
||||
<el-button link type="danger" @click="removeUser(row.id)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-dialog v-model="dialogVisible" :title="editingId ? '编辑用户' : '新增用户'" width="520px">
|
||||
<el-form :model="form" label-position="top">
|
||||
<el-form-item label="用户名">
|
||||
<el-input v-model="form.username" />
|
||||
</el-form-item>
|
||||
<el-form-item label="角色">
|
||||
<el-select v-model="form.roleCode">
|
||||
<el-option v-for="role in roles" :key="role.code" :label="role.name" :value="role.code" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="手机号">
|
||||
<el-input v-model="form.phone" />
|
||||
</el-form-item>
|
||||
<el-form-item label="邮箱">
|
||||
<el-input v-model="form.email" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="saveUser">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</Layout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Layout from '@/components/common/layout/Layout.vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { reactive, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import Layout from '@/components/common/layout/Layout.vue'
|
||||
import { appendAuditLog, getRoles, getUsers, saveUsers, type AdminUser } from '@/utils/adminLocalData'
|
||||
|
||||
const { t } = useI18n();
|
||||
const users = ref<AdminUser[]>(getUsers())
|
||||
const roles = getRoles()
|
||||
const dialogVisible = ref(false)
|
||||
const editingId = ref('')
|
||||
|
||||
const form = reactive({
|
||||
username: '',
|
||||
roleCode: roles[0]?.code || 'admin',
|
||||
phone: '',
|
||||
email: ''
|
||||
})
|
||||
|
||||
function resetForm() {
|
||||
form.username = ''
|
||||
form.roleCode = roles[0]?.code || 'admin'
|
||||
form.phone = ''
|
||||
form.email = ''
|
||||
editingId.value = ''
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
resetForm()
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
function openEdit(user: AdminUser) {
|
||||
editingId.value = user.id
|
||||
form.username = user.username
|
||||
form.roleCode = user.roleCode
|
||||
form.phone = user.phone
|
||||
form.email = user.email
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
function saveUser() {
|
||||
if (!form.username.trim()) {
|
||||
ElMessage.warning('请输入用户名')
|
||||
return
|
||||
}
|
||||
|
||||
const nextUsers = [...users.value]
|
||||
if (editingId.value) {
|
||||
const index = nextUsers.findIndex(item => item.id === editingId.value)
|
||||
if (index >= 0) {
|
||||
nextUsers[index] = { ...nextUsers[index], ...form }
|
||||
appendAuditLog({ module: '系统用户', action: '编辑', operator: 'admin', detail: `编辑用户 ${form.username}` })
|
||||
}
|
||||
} else {
|
||||
nextUsers.unshift({
|
||||
id: `user_${Date.now()}`,
|
||||
status: 'active',
|
||||
lastLoginAt: new Date().toLocaleString(),
|
||||
...form
|
||||
})
|
||||
appendAuditLog({ module: '系统用户', action: '新增', operator: 'admin', detail: `新增用户 ${form.username}` })
|
||||
}
|
||||
|
||||
users.value = nextUsers
|
||||
saveUsers(nextUsers)
|
||||
dialogVisible.value = false
|
||||
ElMessage.success('用户已保存')
|
||||
}
|
||||
|
||||
function toggleStatus(user: AdminUser) {
|
||||
user.status = user.status === 'active' ? 'disabled' : 'active'
|
||||
saveUsers(users.value)
|
||||
appendAuditLog({ module: '系统用户', action: '状态变更', operator: 'admin', detail: `${user.username} -> ${user.status}` })
|
||||
ElMessage.success('状态已更新')
|
||||
}
|
||||
|
||||
async function removeUser(id: string) {
|
||||
const user = users.value.find(item => item.id === id)
|
||||
if (!user) return
|
||||
await ElMessageBox.confirm(`确认删除用户“${user.username}”吗?`, '提示')
|
||||
users.value = users.value.filter(item => item.id !== id)
|
||||
saveUsers(users.value)
|
||||
appendAuditLog({ module: '系统用户', action: '删除', operator: 'admin', detail: `删除用户 ${user.username}` })
|
||||
ElMessage.success('用户已删除')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.content-container {
|
||||
min-height: calc(100vh - 80px);
|
||||
padding: 24px;
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
box-sizing: border-box;
|
||||
<style scoped lang="scss">
|
||||
.page-shell { padding: 24px; }
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.content-container {
|
||||
padding: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.content-container {
|
||||
padding: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1025px) {
|
||||
.content-container {
|
||||
height: calc(100vh - 4.17vw);
|
||||
padding: 1.25vw;
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
.page-header h1 { margin: 0; font-size: 28px; }
|
||||
.page-header p { margin: 8px 0 0; color: #6b7280; }
|
||||
.data-table { background: #fff; border-radius: 16px; overflow: hidden; }
|
||||
</style>
|
||||
@@ -4,6 +4,9 @@ import vue from '@vitejs/plugin-vue'
|
||||
import path from 'path'
|
||||
import { visualizer } from 'rollup-plugin-visualizer' // 打包分析插件
|
||||
import { constants } from 'zlib' // 静态引入zlib常量
|
||||
import Components from 'unplugin-vue-components/vite'
|
||||
import AutoImport from 'unplugin-auto-import/vite'
|
||||
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
|
||||
|
||||
// 获取当前时间戳,用于构建输出的文件名
|
||||
const Timestamp = new Date().getTime();
|
||||
@@ -69,6 +72,19 @@ export default defineConfig(({ mode }: ConfigEnv): UserConfig => {
|
||||
}
|
||||
}),
|
||||
|
||||
// Auto-import Element Plus components from templates
|
||||
Components({
|
||||
resolvers: [ElementPlusResolver({ importStyle: 'css' })],
|
||||
dts: false
|
||||
}),
|
||||
|
||||
// Auto-import Element Plus APIs (ElMessage, ElNotification, etc.)
|
||||
AutoImport({
|
||||
resolvers: [ElementPlusResolver()],
|
||||
imports: ['vue', 'vue-router', 'pinia'],
|
||||
dts: false
|
||||
}),
|
||||
|
||||
// 打包分析插件(仅在显式启用时生效;容器/CI 默认不打开浏览器)
|
||||
enableVisualizer && visualizer({ open: false })
|
||||
].filter(Boolean), // 过滤掉false的插件
|
||||
@@ -100,9 +116,14 @@ export default defineConfig(({ mode }: ConfigEnv): UserConfig => {
|
||||
'element-plus',
|
||||
'@element-plus/icons-vue',
|
||||
'axios',
|
||||
'vue-i18n'
|
||||
],
|
||||
exclude: ['@vueuse/core'] // 排除某些包的预构建
|
||||
'vue-i18n',
|
||||
'echarts/core',
|
||||
'echarts/charts',
|
||||
'echarts/components',
|
||||
'echarts/renderers',
|
||||
'vue-echarts',
|
||||
'@vueuse/core'
|
||||
]
|
||||
},
|
||||
|
||||
// 模块解析配置
|
||||
@@ -152,15 +173,17 @@ export default defineConfig(({ mode }: ConfigEnv): UserConfig => {
|
||||
output: {
|
||||
// 优化的手动分块策略
|
||||
manualChunks: {
|
||||
// Vue核心
|
||||
// Vue core
|
||||
vue: ['vue', 'vue-router', 'pinia'],
|
||||
// Element Plus相关
|
||||
// Element Plus (tree-shaken via auto-import, but base bundle still needed)
|
||||
elementPlus: ['element-plus'],
|
||||
// Element Plus图标
|
||||
// Element Plus icons
|
||||
elementIcons: ['@element-plus/icons-vue'],
|
||||
// 工具库
|
||||
// ECharts (large library, isolated for better caching)
|
||||
echarts: ['echarts', 'vue-echarts'],
|
||||
// Utilities
|
||||
utils: ['axios'],
|
||||
// 其他第三方库单独打包
|
||||
// i18n
|
||||
vendor: ['vue-i18n']
|
||||
},
|
||||
// 输出文件命名规则(优化缓存策略)
|
||||
|
||||
@@ -42,16 +42,16 @@
|
||||
resolved "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz"
|
||||
integrity sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==
|
||||
|
||||
"@esbuild/[email protected]":
|
||||
version "0.21.5"
|
||||
resolved "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz"
|
||||
integrity sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==
|
||||
|
||||
"@esbuild/[email protected]":
|
||||
version "0.21.5"
|
||||
resolved "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz"
|
||||
integrity sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==
|
||||
|
||||
"@esbuild/[email protected]":
|
||||
version "0.21.5"
|
||||
resolved "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz"
|
||||
integrity sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==
|
||||
|
||||
"@esbuild/[email protected]":
|
||||
version "0.21.5"
|
||||
resolved "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz"
|
||||
@@ -77,16 +77,16 @@
|
||||
resolved "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz"
|
||||
integrity sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==
|
||||
|
||||
"@esbuild/[email protected]":
|
||||
version "0.21.5"
|
||||
resolved "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz"
|
||||
integrity sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==
|
||||
|
||||
"@esbuild/[email protected]":
|
||||
version "0.21.5"
|
||||
resolved "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz"
|
||||
integrity sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==
|
||||
|
||||
"@esbuild/[email protected]":
|
||||
version "0.21.5"
|
||||
resolved "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz"
|
||||
integrity sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==
|
||||
|
||||
"@esbuild/[email protected]":
|
||||
version "0.21.5"
|
||||
resolved "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz"
|
||||
@@ -257,6 +257,14 @@
|
||||
"@jridgewell/sourcemap-codec" "^1.5.0"
|
||||
"@jridgewell/trace-mapping" "^0.3.24"
|
||||
|
||||
"@jridgewell/remapping@^2.3.5":
|
||||
version "2.3.5"
|
||||
resolved "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz"
|
||||
integrity sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==
|
||||
dependencies:
|
||||
"@jridgewell/gen-mapping" "^0.3.5"
|
||||
"@jridgewell/trace-mapping" "^0.3.24"
|
||||
|
||||
"@jridgewell/resolve-uri@^3.1.0":
|
||||
version "3.1.2"
|
||||
resolved "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz"
|
||||
@@ -291,7 +299,7 @@
|
||||
"@nodelib/fs.stat" "2.0.5"
|
||||
run-parallel "^1.1.9"
|
||||
|
||||
"@nodelib/[email protected].5", "@nodelib/fs.stat@^2.0.2":
|
||||
"@nodelib/fs.stat@^2.0.2", "@nodelib/[email protected].5":
|
||||
version "2.0.5"
|
||||
resolved "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz"
|
||||
integrity sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==
|
||||
@@ -508,12 +516,12 @@
|
||||
resolved "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.50.0.tgz"
|
||||
integrity sha512-xMmiWRR8sp72Zqwjgtf3QbZfF1wdh8X2ABu3EaozvZcyHJeU0r+XAnXdKgs4cCAp6ORoYoCygipYP1mjmbjrsg==
|
||||
|
||||
"@types/[email protected].8", "@types/estree@^1.0.0":
|
||||
"@types/estree@^1.0.0", "@types/[email protected].8":
|
||||
version "1.0.8"
|
||||
resolved "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz"
|
||||
integrity sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==
|
||||
|
||||
"@types/lodash-es@^4.17.6":
|
||||
"@types/lodash-es@*", "@types/lodash-es@^4.17.6":
|
||||
version "4.17.12"
|
||||
resolved "https://registry.npmjs.org/@types/lodash-es/-/lodash-es-4.17.12.tgz"
|
||||
integrity sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==
|
||||
@@ -525,7 +533,7 @@
|
||||
resolved "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.20.tgz"
|
||||
integrity sha512-H3MHACvFUEiujabxhaI/ImO6gUrd8oOurg7LQtS7mbwIXA/cUqWrvBsaeJ23aZEPk1TAYkurjfMbSELfoCXlGA==
|
||||
|
||||
"@types/node@^22.0.0":
|
||||
"@types/node@^18.0.0 || >=20.0.0", "@types/node@^22.0.0":
|
||||
version "22.18.0"
|
||||
resolved "https://registry.npmjs.org/@types/node/-/node-22.18.0.tgz"
|
||||
integrity sha512-m5ObIqwsUp6BZzyiy4RdZpzWGub9bqLJMvZDD0QMXhxjqMHMENlj+SqF5QxoUwaQNFe+8kz8XM8ZQhqkQPTgMQ==
|
||||
@@ -567,7 +575,7 @@
|
||||
natural-compare "^1.4.0"
|
||||
ts-api-utils "^2.1.0"
|
||||
|
||||
"@typescript-eslint/parser@^7.1.1":
|
||||
"@typescript-eslint/parser@^7.0.0", "@typescript-eslint/parser@^7.1.1":
|
||||
version "7.18.0"
|
||||
resolved "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-7.18.0.tgz"
|
||||
integrity sha512-4Z+L8I2OqhZV8qA132M4wNL30ypZGYOQVBfMgxDH/K5UX0PNqTu1c6za9ST5r9+tavvHiTWmBnKzpCJ/GlVFtg==
|
||||
@@ -578,7 +586,7 @@
|
||||
"@typescript-eslint/visitor-keys" "7.18.0"
|
||||
debug "^4.3.4"
|
||||
|
||||
"@typescript-eslint/parser@^8.0.0":
|
||||
"@typescript-eslint/parser@^8.0.0", "@typescript-eslint/parser@^8.41.0":
|
||||
version "8.41.0"
|
||||
resolved "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.41.0.tgz"
|
||||
integrity sha512-gTtSdWX9xiMPA/7MV9STjJOOYtWwIJIYxkQxnSV1U3xcE+mnJSH3f6zI0RYP+ew66WSlZ5ed+h0VCxsvdC1jJg==
|
||||
@@ -614,7 +622,7 @@
|
||||
"@typescript-eslint/types" "8.41.0"
|
||||
"@typescript-eslint/visitor-keys" "8.41.0"
|
||||
|
||||
"@typescript-eslint/[email protected]", "@typescript-eslint/tsconfig-utils@^8.41.0":
|
||||
"@typescript-eslint/tsconfig-utils@^8.41.0", "@typescript-eslint/[email protected]":
|
||||
version "8.41.0"
|
||||
resolved "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.41.0.tgz"
|
||||
integrity sha512-TDhxYFPUYRFxFhuU5hTIJk+auzM/wKvWgoNYOPcOf6i4ReYlOoYN8q1dV5kOTjNQNJgzWN3TUUQMtlLOcUgdUw==
|
||||
@@ -640,16 +648,16 @@
|
||||
debug "^4.3.4"
|
||||
ts-api-utils "^2.1.0"
|
||||
|
||||
"@typescript-eslint/types@^8.41.0", "@typescript-eslint/[email protected]":
|
||||
version "8.41.0"
|
||||
resolved "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.41.0.tgz"
|
||||
integrity sha512-9EwxsWdVqh42afLbHP90n2VdHaWU/oWgbH2P0CfcNfdKL7CuKpwMQGjwev56vWu9cSKU7FWSu6r9zck6CVfnag==
|
||||
|
||||
"@typescript-eslint/[email protected]":
|
||||
version "7.18.0"
|
||||
resolved "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.18.0.tgz"
|
||||
integrity sha512-iZqi+Ds1y4EDYUtlOOC+aUmxnE9xS/yCigkjA7XpTKV6nCBd3Hp/PRGGmdwnfkV2ThMyYldP1wRpm/id99spTQ==
|
||||
|
||||
"@typescript-eslint/[email protected]", "@typescript-eslint/types@^8.41.0":
|
||||
version "8.41.0"
|
||||
resolved "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.41.0.tgz"
|
||||
integrity sha512-9EwxsWdVqh42afLbHP90n2VdHaWU/oWgbH2P0CfcNfdKL7CuKpwMQGjwev56vWu9cSKU7FWSu6r9zck6CVfnag==
|
||||
|
||||
"@typescript-eslint/[email protected]":
|
||||
version "7.18.0"
|
||||
resolved "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-7.18.0.tgz"
|
||||
@@ -745,7 +753,7 @@
|
||||
estree-walker "^3.0.3"
|
||||
magic-string "^0.30.12"
|
||||
|
||||
"@vitest/[email protected]", "@vitest/pretty-format@^2.1.9":
|
||||
"@vitest/pretty-format@^2.1.9", "@vitest/[email protected]":
|
||||
version "2.1.9"
|
||||
resolved "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz"
|
||||
integrity sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==
|
||||
@@ -817,7 +825,7 @@
|
||||
estree-walker "^2.0.2"
|
||||
source-map-js "^1.2.1"
|
||||
|
||||
"@vue/[email protected].20", "@vue/compiler-dom@^3.5.0":
|
||||
"@vue/compiler-dom@^3.5.0", "@vue/[email protected].20":
|
||||
version "3.5.20"
|
||||
resolved "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.20.tgz"
|
||||
integrity sha512-whB44M59XKjqUEYOMPYU0ijUV0G+4fdrHVKDe32abNdX/kJe1NUEMqsi4cwzXa9kyM9w5S8WqFsrfo1ogtBZGQ==
|
||||
@@ -899,7 +907,7 @@
|
||||
dependencies:
|
||||
"@vue/shared" "3.5.20"
|
||||
|
||||
"@vue/[email protected]":
|
||||
"@vue/runtime-core@^3.0.0", "@vue/[email protected]":
|
||||
version "3.5.20"
|
||||
resolved "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.20.tgz"
|
||||
integrity sha512-vyQRiH5uSZlOa+4I/t4Qw/SsD/gbth0SW2J7oMeVlMFMAmsG1rwDD6ok0VMmjXY3eI0iHNSSOBilEDW98PLRKw==
|
||||
@@ -925,12 +933,12 @@
|
||||
"@vue/compiler-ssr" "3.5.20"
|
||||
"@vue/shared" "3.5.20"
|
||||
|
||||
"@vue/[email protected].20", "@vue/shared@^3.5.0":
|
||||
"@vue/shared@^3.5.0", "@vue/[email protected].20":
|
||||
version "3.5.20"
|
||||
resolved "https://registry.npmjs.org/@vue/shared/-/shared-3.5.20.tgz"
|
||||
integrity sha512-SoRGP596KU/ig6TfgkCMbXkr4YJ91n/QSdMuqeP5r3hVIYA3CPHUBCc7Skak0EAKV+5lL4KyIh61VA/pK1CIAA==
|
||||
|
||||
"@vueuse/core@^9.1.0":
|
||||
"@vueuse/core@*", "@vueuse/core@^9.1.0":
|
||||
version "9.13.0"
|
||||
resolved "https://registry.npmjs.org/@vueuse/core/-/core-9.13.0.tgz"
|
||||
integrity sha512-pujnclbeHWxxPRqXWmdkKV5OX4Wk4YeK7wusHqRwU0Q7EFusHoqNA/aPhB6KCh9hEqJkLAJo7bb0Lh9b+OIVzw==
|
||||
@@ -957,10 +965,10 @@ acorn-jsx@^5.3.2:
|
||||
resolved "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz"
|
||||
integrity sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==
|
||||
|
||||
acorn@^8.14.0, acorn@^8.15.0, acorn@^8.9.0:
|
||||
version "8.15.0"
|
||||
resolved "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz"
|
||||
integrity sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==
|
||||
"acorn@^6.0.0 || ^7.0.0 || ^8.0.0", acorn@^8.14.0, acorn@^8.15.0, acorn@^8.16.0, acorn@^8.9.0:
|
||||
version "8.16.0"
|
||||
resolved "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz"
|
||||
integrity sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==
|
||||
|
||||
ajv@^6.12.4:
|
||||
version "6.12.6"
|
||||
@@ -1109,6 +1117,13 @@ chokidar@^4.0.0:
|
||||
dependencies:
|
||||
readdirp "^4.0.1"
|
||||
|
||||
chokidar@^5.0.0:
|
||||
version "5.0.0"
|
||||
resolved "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz"
|
||||
integrity sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==
|
||||
dependencies:
|
||||
readdirp "^5.0.0"
|
||||
|
||||
cliui@^8.0.1:
|
||||
version "8.0.1"
|
||||
resolved "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz"
|
||||
@@ -1147,6 +1162,16 @@ [email protected]:
|
||||
resolved "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz"
|
||||
integrity sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==
|
||||
|
||||
confbox@^0.1.8:
|
||||
version "0.1.8"
|
||||
resolved "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz"
|
||||
integrity sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==
|
||||
|
||||
confbox@^0.2.2:
|
||||
version "0.2.4"
|
||||
resolved "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz"
|
||||
integrity sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==
|
||||
|
||||
cross-spawn@^7.0.2:
|
||||
version "7.0.6"
|
||||
resolved "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz"
|
||||
@@ -1231,7 +1256,7 @@ dunder-proto@^1.0.1:
|
||||
es-errors "^1.3.0"
|
||||
gopd "^1.2.0"
|
||||
|
||||
echarts@^5.6.0:
|
||||
echarts@^5.5.1, echarts@^5.6.0:
|
||||
version "5.6.0"
|
||||
resolved "https://registry.npmjs.org/echarts/-/echarts-5.6.0.tgz"
|
||||
integrity sha512-oTbVTsXfKuEhxftHqL5xprgLoc0k7uScAwtryCgWF6hPYFLRwOUHiFmHGCBKP5NPFNkDVopOieyUqYGH8Fa3kA==
|
||||
@@ -1346,7 +1371,12 @@ escape-string-regexp@^4.0.0:
|
||||
resolved "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz"
|
||||
integrity sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==
|
||||
|
||||
eslint-config-prettier@^9.0.0:
|
||||
escape-string-regexp@^5.0.0:
|
||||
version "5.0.0"
|
||||
resolved "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz"
|
||||
integrity sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==
|
||||
|
||||
eslint-config-prettier@^9.0.0, "eslint-config-prettier@>= 7.0.0 <10.0.0 || >=10.1.0":
|
||||
version "9.1.2"
|
||||
resolved "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-9.1.2.tgz"
|
||||
integrity sha512-iI1f+D2ViGn+uvv5HuHVUamg8ll4tN+JRHGc6IJi4TP9Kl976C57fzPXgseXNs8v0iA8aSJpHsTWjDb9QJamGQ==
|
||||
@@ -1399,7 +1429,7 @@ eslint-visitor-keys@^4.2.0, eslint-visitor-keys@^4.2.1:
|
||||
resolved "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz"
|
||||
integrity sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==
|
||||
|
||||
eslint@^8.57.1:
|
||||
"eslint@^6.0.0 || ^7.0.0 || >=8.0.0", "eslint@^6.2.0 || ^7.0.0 || ^8.0.0 || ^9.0.0", eslint@^8.56.0, "eslint@^8.57.0 || ^9.0.0", eslint@^8.57.1, "eslint@>= 8.0.0", eslint@>=6.0.0, eslint@>=7.0.0, eslint@>=8.0.0:
|
||||
version "8.57.1"
|
||||
resolved "https://registry.npmjs.org/eslint/-/eslint-8.57.1.tgz"
|
||||
integrity sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==
|
||||
@@ -1502,6 +1532,11 @@ expect-type@^1.1.0:
|
||||
resolved "https://registry.npmjs.org/expect-type/-/expect-type-1.2.2.tgz"
|
||||
integrity sha512-JhFGDVJ7tmDJItKhYgJCGLOWjuK9vPxiXoUFLwLDc99NlmklilbiQJwoctZtt13+xMw91MCk/REan6MWHqDjyA==
|
||||
|
||||
exsolve@^1.0.7:
|
||||
version "1.0.8"
|
||||
resolved "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz"
|
||||
integrity sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==
|
||||
|
||||
fast-deep-equal@^3.1.1, fast-deep-equal@^3.1.3:
|
||||
version "3.1.3"
|
||||
resolved "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz"
|
||||
@@ -1540,6 +1575,11 @@ fastq@^1.6.0:
|
||||
dependencies:
|
||||
reusify "^1.0.4"
|
||||
|
||||
fdir@^6.5.0:
|
||||
version "6.5.0"
|
||||
resolved "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz"
|
||||
integrity sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==
|
||||
|
||||
file-entry-cache@^6.0.1:
|
||||
version "6.0.1"
|
||||
resolved "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz"
|
||||
@@ -1734,7 +1774,12 @@ he@^1.2.0:
|
||||
resolved "https://registry.npmjs.org/he/-/he-1.2.0.tgz"
|
||||
integrity sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==
|
||||
|
||||
ignore@^5.2.0, ignore@^5.3.1:
|
||||
ignore@^5.2.0:
|
||||
version "5.3.2"
|
||||
resolved "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz"
|
||||
integrity sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==
|
||||
|
||||
ignore@^5.3.1:
|
||||
version "5.3.2"
|
||||
resolved "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz"
|
||||
integrity sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==
|
||||
@@ -1819,6 +1864,11 @@ isexe@^2.0.0:
|
||||
resolved "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz"
|
||||
integrity sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==
|
||||
|
||||
js-tokens@^9.0.1:
|
||||
version "9.0.1"
|
||||
resolved "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz"
|
||||
integrity sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==
|
||||
|
||||
js-yaml@^4.1.0:
|
||||
version "4.1.0"
|
||||
resolved "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz"
|
||||
@@ -1865,6 +1915,15 @@ levn@^0.4.1:
|
||||
prelude-ls "^1.2.1"
|
||||
type-check "~0.4.0"
|
||||
|
||||
local-pkg@^1.1.2:
|
||||
version "1.1.2"
|
||||
resolved "https://registry.npmjs.org/local-pkg/-/local-pkg-1.1.2.tgz"
|
||||
integrity sha512-arhlxbFRmoQHl33a0Zkle/YWlmNwoyt6QNZEIJcqNbdrsix5Lvc4HyyI3EnwxTYlZYc32EbYrQ8SzEZ7dqgg9A==
|
||||
dependencies:
|
||||
mlly "^1.7.4"
|
||||
pkg-types "^2.3.0"
|
||||
quansync "^0.2.11"
|
||||
|
||||
locate-path@^6.0.0:
|
||||
version "6.0.0"
|
||||
resolved "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz"
|
||||
@@ -1872,7 +1931,7 @@ locate-path@^6.0.0:
|
||||
dependencies:
|
||||
p-locate "^5.0.0"
|
||||
|
||||
lodash-es@^4.17.21:
|
||||
lodash-es@*, lodash-es@^4.17.21:
|
||||
version "4.17.21"
|
||||
resolved "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz"
|
||||
integrity sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==
|
||||
@@ -1887,7 +1946,7 @@ lodash.merge@^4.6.2:
|
||||
resolved "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz"
|
||||
integrity sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==
|
||||
|
||||
lodash@^4.17.21:
|
||||
lodash@*, lodash@^4.17.21:
|
||||
version "4.17.21"
|
||||
resolved "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz"
|
||||
integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==
|
||||
@@ -1897,10 +1956,10 @@ loupe@^3.1.0, loupe@^3.1.2:
|
||||
resolved "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz"
|
||||
integrity sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==
|
||||
|
||||
magic-string@^0.30.12, magic-string@^0.30.17:
|
||||
version "0.30.18"
|
||||
resolved "https://registry.npmjs.org/magic-string/-/magic-string-0.30.18.tgz"
|
||||
integrity sha512-yi8swmWbO17qHhwIBNeeZxTceJMeBvWJaId6dyvTSOwTipqeHhMhOrz6513r1sOKnpvQ7zkhlG8tPrpilwTxHQ==
|
||||
magic-string@^0.30.12, magic-string@^0.30.17, magic-string@^0.30.21:
|
||||
version "0.30.21"
|
||||
resolved "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz"
|
||||
integrity sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==
|
||||
dependencies:
|
||||
"@jridgewell/sourcemap-codec" "^1.5.5"
|
||||
|
||||
@@ -1939,7 +1998,21 @@ mime-types@^2.1.12:
|
||||
dependencies:
|
||||
mime-db "1.52.0"
|
||||
|
||||
minimatch@^3.0.5, minimatch@^3.1.1, minimatch@^3.1.2:
|
||||
minimatch@^3.0.5:
|
||||
version "3.1.2"
|
||||
resolved "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz"
|
||||
integrity sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==
|
||||
dependencies:
|
||||
brace-expansion "^1.1.7"
|
||||
|
||||
minimatch@^3.1.1:
|
||||
version "3.1.2"
|
||||
resolved "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz"
|
||||
integrity sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==
|
||||
dependencies:
|
||||
brace-expansion "^1.1.7"
|
||||
|
||||
minimatch@^3.1.2:
|
||||
version "3.1.2"
|
||||
resolved "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz"
|
||||
integrity sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==
|
||||
@@ -1953,6 +2026,16 @@ minimatch@^9.0.3, minimatch@^9.0.4:
|
||||
dependencies:
|
||||
brace-expansion "^2.0.1"
|
||||
|
||||
mlly@^1.7.4, mlly@^1.8.0:
|
||||
version "1.8.1"
|
||||
resolved "https://registry.npmjs.org/mlly/-/mlly-1.8.1.tgz"
|
||||
integrity sha512-SnL6sNutTwRWWR/vcmCYHSADjiEesp5TGQQ0pXyLhW5IoeibRlF/CbSLailbB3CNqJUk9cVJ9dUDnbD7GrcHBQ==
|
||||
dependencies:
|
||||
acorn "^8.16.0"
|
||||
pathe "^2.0.3"
|
||||
pkg-types "^1.3.1"
|
||||
ufo "^1.6.3"
|
||||
|
||||
ms@^2.1.3:
|
||||
version "2.1.3"
|
||||
resolved "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz"
|
||||
@@ -1990,6 +2073,11 @@ nth-check@^2.1.1:
|
||||
dependencies:
|
||||
boolbase "^1.0.0"
|
||||
|
||||
obug@^2.1.1:
|
||||
version "2.1.1"
|
||||
resolved "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz"
|
||||
integrity sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==
|
||||
|
||||
once@^1.3.0:
|
||||
version "1.4.0"
|
||||
resolved "https://registry.npmjs.org/once/-/once-1.4.0.tgz"
|
||||
@@ -2069,6 +2157,11 @@ pathe@^1.1.2:
|
||||
resolved "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz"
|
||||
integrity sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==
|
||||
|
||||
pathe@^2.0.1, pathe@^2.0.3:
|
||||
version "2.0.3"
|
||||
resolved "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz"
|
||||
integrity sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==
|
||||
|
||||
pathval@^2.0.0:
|
||||
version "2.0.1"
|
||||
resolved "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz"
|
||||
@@ -2084,6 +2177,11 @@ picomatch@^2.3.1:
|
||||
resolved "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz"
|
||||
integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==
|
||||
|
||||
"picomatch@^3 || ^4", picomatch@^4.0.3:
|
||||
version "4.0.3"
|
||||
resolved "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz"
|
||||
integrity sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==
|
||||
|
||||
picomatch@^4.0.2:
|
||||
version "4.0.3"
|
||||
resolved "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz"
|
||||
@@ -2097,6 +2195,24 @@ pinia@^2.3.1:
|
||||
"@vue/devtools-api" "^6.6.3"
|
||||
vue-demi "^0.14.10"
|
||||
|
||||
pkg-types@^1.3.1:
|
||||
version "1.3.1"
|
||||
resolved "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz"
|
||||
integrity sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==
|
||||
dependencies:
|
||||
confbox "^0.1.8"
|
||||
mlly "^1.7.4"
|
||||
pathe "^2.0.1"
|
||||
|
||||
pkg-types@^2.3.0:
|
||||
version "2.3.0"
|
||||
resolved "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.0.tgz"
|
||||
integrity sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==
|
||||
dependencies:
|
||||
confbox "^0.2.2"
|
||||
exsolve "^1.0.7"
|
||||
pathe "^2.0.3"
|
||||
|
||||
postcss-selector-parser@^6.0.15:
|
||||
version "6.1.2"
|
||||
resolved "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz"
|
||||
@@ -2126,7 +2242,7 @@ prettier-linter-helpers@^1.0.0:
|
||||
dependencies:
|
||||
fast-diff "^1.1.2"
|
||||
|
||||
prettier@^3.0.0:
|
||||
prettier@^3.0.0, "prettier@>= 3.0.0", prettier@>=3.0.0:
|
||||
version "3.6.2"
|
||||
resolved "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz"
|
||||
integrity sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==
|
||||
@@ -2141,6 +2257,11 @@ punycode@^2.1.0:
|
||||
resolved "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz"
|
||||
integrity sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==
|
||||
|
||||
quansync@^0.2.11:
|
||||
version "0.2.11"
|
||||
resolved "https://registry.npmjs.org/quansync/-/quansync-0.2.11.tgz"
|
||||
integrity sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==
|
||||
|
||||
queue-microtask@^1.2.2:
|
||||
version "1.2.3"
|
||||
resolved "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz"
|
||||
@@ -2151,6 +2272,11 @@ readdirp@^4.0.1:
|
||||
resolved "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz"
|
||||
integrity sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==
|
||||
|
||||
readdirp@^5.0.0:
|
||||
version "5.0.0"
|
||||
resolved "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz"
|
||||
integrity sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==
|
||||
|
||||
require-directory@^2.1.1:
|
||||
version "2.1.1"
|
||||
resolved "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz"
|
||||
@@ -2183,7 +2309,7 @@ rollup-plugin-visualizer@^5.12.0:
|
||||
source-map "^0.7.4"
|
||||
yargs "^17.5.1"
|
||||
|
||||
rollup@^4.20.0:
|
||||
rollup@^4.20.0, "[email protected] || 3.x || 4.x":
|
||||
version "4.50.0"
|
||||
resolved "https://registry.npmjs.org/rollup/-/rollup-4.50.0.tgz"
|
||||
integrity sha512-/Zl4D8zPifNmyGzJS+3kVoyXeDeT/GrsJM94sACNg9RtUE0hrHa1bNPtRSrfHTMH5HjRzce6K7rlTh3Khiw+pw==
|
||||
@@ -2220,7 +2346,7 @@ run-parallel@^1.1.9:
|
||||
dependencies:
|
||||
queue-microtask "^1.2.2"
|
||||
|
||||
sass@^1.89.2:
|
||||
sass@*, sass@^1.89.2:
|
||||
version "1.91.0"
|
||||
resolved "https://registry.npmjs.org/sass/-/sass-1.91.0.tgz"
|
||||
integrity sha512-aFOZHGf+ur+bp1bCHZ+u8otKGh77ZtmFyXDo4tlYvT7PWql41Kwd8wdkPqhhT+h2879IVblcHFglIMofsFd1EA==
|
||||
@@ -2231,6 +2357,11 @@ sass@^1.89.2:
|
||||
optionalDependencies:
|
||||
"@parcel/watcher" "^2.4.1"
|
||||
|
||||
scule@^1.3.0:
|
||||
version "1.3.0"
|
||||
resolved "https://registry.npmjs.org/scule/-/scule-1.3.0.tgz"
|
||||
integrity sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==
|
||||
|
||||
semver@^7.3.6, semver@^7.6.0, semver@^7.6.3:
|
||||
version "7.7.2"
|
||||
resolved "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz"
|
||||
@@ -2258,7 +2389,7 @@ slash@^3.0.0:
|
||||
resolved "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz"
|
||||
integrity sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==
|
||||
|
||||
"source-map-js@>=0.6.2 <2.0.0", source-map-js@^1.0.2, source-map-js@^1.2.1:
|
||||
source-map-js@^1.0.2, source-map-js@^1.2.1, "source-map-js@>=0.6.2 <2.0.0":
|
||||
version "1.2.1"
|
||||
resolved "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz"
|
||||
integrity sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==
|
||||
@@ -2312,6 +2443,13 @@ strip-json-comments@^3.1.1:
|
||||
resolved "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz"
|
||||
integrity sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==
|
||||
|
||||
strip-literal@^3.1.0:
|
||||
version "3.1.0"
|
||||
resolved "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz"
|
||||
integrity sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==
|
||||
dependencies:
|
||||
js-tokens "^9.0.1"
|
||||
|
||||
supports-color@^7.1.0:
|
||||
version "7.2.0"
|
||||
resolved "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz"
|
||||
@@ -2326,7 +2464,7 @@ synckit@^0.11.7:
|
||||
dependencies:
|
||||
"@pkgr/core" "^0.2.9"
|
||||
|
||||
terser@^5.43.1:
|
||||
terser@^5.4.0, terser@^5.43.1:
|
||||
version "5.43.1"
|
||||
resolved "https://registry.npmjs.org/terser/-/terser-5.43.1.tgz"
|
||||
integrity sha512-+6erLbBm0+LROX2sPXlUYx/ux5PyE9K/a92Wrt6oA+WDAoFTdpHE5tCYCI5PNzq2y8df4rA+QgHLJuR4jNymsg==
|
||||
@@ -2351,6 +2489,14 @@ tinyexec@^0.3.1:
|
||||
resolved "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz"
|
||||
integrity sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==
|
||||
|
||||
tinyglobby@^0.2.15:
|
||||
version "0.2.15"
|
||||
resolved "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz"
|
||||
integrity sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==
|
||||
dependencies:
|
||||
fdir "^6.5.0"
|
||||
picomatch "^4.0.3"
|
||||
|
||||
tinypool@^1.0.1:
|
||||
version "1.1.1"
|
||||
resolved "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz"
|
||||
@@ -2400,21 +2546,91 @@ type-fest@^0.20.2:
|
||||
resolved "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz"
|
||||
integrity sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==
|
||||
|
||||
typescript@^5.8.3:
|
||||
typescript@*, typescript@^5.8.3, typescript@>=4.2.0, typescript@>=4.4.4, typescript@>=4.7.4, typescript@>=4.8.4, "typescript@>=4.8.4 <6.0.0", typescript@>=5.0.0:
|
||||
version "5.9.2"
|
||||
resolved "https://registry.npmjs.org/typescript/-/typescript-5.9.2.tgz"
|
||||
integrity sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==
|
||||
|
||||
ufo@^1.6.3:
|
||||
version "1.6.3"
|
||||
resolved "https://registry.npmjs.org/ufo/-/ufo-1.6.3.tgz"
|
||||
integrity sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==
|
||||
|
||||
undici-types@~6.21.0:
|
||||
version "6.21.0"
|
||||
resolved "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz"
|
||||
integrity sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==
|
||||
|
||||
unimport@^5.6.0:
|
||||
version "5.7.0"
|
||||
resolved "https://registry.npmjs.org/unimport/-/unimport-5.7.0.tgz"
|
||||
integrity sha512-njnL6sp8lEA8QQbZrt+52p/g4X0rw3bnGGmUcJnt1jeG8+iiqO779aGz0PirCtydAIVcuTBRlJ52F0u46z309Q==
|
||||
dependencies:
|
||||
acorn "^8.16.0"
|
||||
escape-string-regexp "^5.0.0"
|
||||
estree-walker "^3.0.3"
|
||||
local-pkg "^1.1.2"
|
||||
magic-string "^0.30.21"
|
||||
mlly "^1.8.0"
|
||||
pathe "^2.0.3"
|
||||
picomatch "^4.0.3"
|
||||
pkg-types "^2.3.0"
|
||||
scule "^1.3.0"
|
||||
strip-literal "^3.1.0"
|
||||
tinyglobby "^0.2.15"
|
||||
unplugin "^2.3.11"
|
||||
unplugin-utils "^0.3.1"
|
||||
|
||||
universalify@^2.0.0:
|
||||
version "2.0.1"
|
||||
resolved "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz"
|
||||
integrity sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==
|
||||
|
||||
unplugin-auto-import@^21.0.0:
|
||||
version "21.0.0"
|
||||
resolved "https://registry.npmjs.org/unplugin-auto-import/-/unplugin-auto-import-21.0.0.tgz"
|
||||
integrity sha512-vWuC8SwqJmxZFYwPojhOhOXDb5xFhNNcEVb9K/RFkyk/3VnfaOjzitWN7v+8DEKpMjSsY2AEGXNgt6I0yQrhRQ==
|
||||
dependencies:
|
||||
local-pkg "^1.1.2"
|
||||
magic-string "^0.30.21"
|
||||
picomatch "^4.0.3"
|
||||
unimport "^5.6.0"
|
||||
unplugin "^2.3.11"
|
||||
unplugin-utils "^0.3.1"
|
||||
|
||||
unplugin-utils@^0.3.1:
|
||||
version "0.3.1"
|
||||
resolved "https://registry.npmjs.org/unplugin-utils/-/unplugin-utils-0.3.1.tgz"
|
||||
integrity sha512-5lWVjgi6vuHhJ526bI4nlCOmkCIF3nnfXkCMDeMJrtdvxTs6ZFCM8oNufGTsDbKv/tJ/xj8RpvXjRuPBZJuJog==
|
||||
dependencies:
|
||||
pathe "^2.0.3"
|
||||
picomatch "^4.0.3"
|
||||
|
||||
unplugin-vue-components@^31.0.0:
|
||||
version "31.0.0"
|
||||
resolved "https://registry.npmjs.org/unplugin-vue-components/-/unplugin-vue-components-31.0.0.tgz"
|
||||
integrity sha512-4ULwfTZTLuWJ7+S9P7TrcStYLsSRkk6vy2jt/WTfgUEUb0nW9//xxmrfhyHUEVpZ2UKRRwfRb8Yy15PDbVZf+Q==
|
||||
dependencies:
|
||||
chokidar "^5.0.0"
|
||||
local-pkg "^1.1.2"
|
||||
magic-string "^0.30.21"
|
||||
mlly "^1.8.0"
|
||||
obug "^2.1.1"
|
||||
picomatch "^4.0.3"
|
||||
tinyglobby "^0.2.15"
|
||||
unplugin "^2.3.11"
|
||||
unplugin-utils "^0.3.1"
|
||||
|
||||
unplugin@^2.3.11:
|
||||
version "2.3.11"
|
||||
resolved "https://registry.npmjs.org/unplugin/-/unplugin-2.3.11.tgz"
|
||||
integrity sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww==
|
||||
dependencies:
|
||||
"@jridgewell/remapping" "^2.3.5"
|
||||
acorn "^8.15.0"
|
||||
picomatch "^4.0.3"
|
||||
webpack-virtual-modules "^0.6.2"
|
||||
|
||||
uri-js@^4.2.2:
|
||||
version "4.4.1"
|
||||
resolved "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz"
|
||||
@@ -2447,7 +2663,7 @@ vite-plugin-compression@^0.5.1:
|
||||
debug "^4.3.3"
|
||||
fs-extra "^10.0.0"
|
||||
|
||||
vite@^5.0.0:
|
||||
vite@^5.0.0, "vite@^5.0.0 || ^6.0.0", vite@>=2.0.0:
|
||||
version "5.4.19"
|
||||
resolved "https://registry.npmjs.org/vite/-/vite-5.4.19.tgz"
|
||||
integrity sha512-qO3aKv3HoQC8QKiNSTuUM1l9o/XX3+c+VTgLHbJWHZGeTPVAg2XwazI9UWzoxjIJCGCV2zU60uqMzjeLZuULqA==
|
||||
@@ -2518,7 +2734,20 @@ vue-eslint-parser@^10.0.0:
|
||||
esquery "^1.6.0"
|
||||
semver "^7.6.3"
|
||||
|
||||
vue-eslint-parser@^9.3.1, vue-eslint-parser@^9.4.3:
|
||||
vue-eslint-parser@^9.3.1:
|
||||
version "9.4.3"
|
||||
resolved "https://registry.npmjs.org/vue-eslint-parser/-/vue-eslint-parser-9.4.3.tgz"
|
||||
integrity sha512-2rYRLWlIpaiN8xbPiDyXZXRgLGOtWxERV7ND5fFAv5qo1D2N9Fu9MNajBNc6o13lZ+24DAWCkQCvj4klgmcITg==
|
||||
dependencies:
|
||||
debug "^4.3.4"
|
||||
eslint-scope "^7.1.1"
|
||||
eslint-visitor-keys "^3.3.0"
|
||||
espree "^9.3.1"
|
||||
esquery "^1.4.0"
|
||||
lodash "^4.17.21"
|
||||
semver "^7.3.6"
|
||||
|
||||
vue-eslint-parser@^9.4.3:
|
||||
version "9.4.3"
|
||||
resolved "https://registry.npmjs.org/vue-eslint-parser/-/vue-eslint-parser-9.4.3.tgz"
|
||||
integrity sha512-2rYRLWlIpaiN8xbPiDyXZXRgLGOtWxERV7ND5fFAv5qo1D2N9Fu9MNajBNc6o13lZ+24DAWCkQCvj4klgmcITg==
|
||||
@@ -2555,7 +2784,7 @@ vue-tsc@^2.0.0:
|
||||
"@volar/typescript" "2.4.15"
|
||||
"@vue/language-core" "2.2.12"
|
||||
|
||||
vue@^3.5.0:
|
||||
"vue@^2.7.0 || ^3.1.1", "vue@^2.7.0 || ^3.5.11", vue@^3.0.0, "vue@^3.0.0-0 || ^2.6.0", vue@^3.2.0, vue@^3.2.25, vue@^3.5.0, [email protected]0:
|
||||
version "3.5.20"
|
||||
resolved "https://registry.npmjs.org/vue/-/vue-3.5.20.tgz"
|
||||
integrity sha512-2sBz0x/wis5TkF1XZ2vH25zWq3G1bFEPOfkBcx2ikowmphoQsPH6X0V3mmPCXA2K1N/XGTnifVyDQP4GfDDeQw==
|
||||
@@ -2566,6 +2795,11 @@ vue@^3.5.0:
|
||||
"@vue/server-renderer" "3.5.20"
|
||||
"@vue/shared" "3.5.20"
|
||||
|
||||
webpack-virtual-modules@^0.6.2:
|
||||
version "0.6.2"
|
||||
resolved "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz"
|
||||
integrity sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==
|
||||
|
||||
which@^2.0.1:
|
||||
version "2.0.2"
|
||||
resolved "https://registry.npmjs.org/which/-/which-2.0.2.tgz"
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
REGISTRY="${REGISTRY:-localhost:9988}"
|
||||
NAMESPACE="${NAMESPACE:-leeexin}"
|
||||
VERSION="${VERSION:-latest}"
|
||||
|
||||
BACKEND_IMAGE="${REGISTRY}/${NAMESPACE}/license-manager-backend:${VERSION}"
|
||||
FRONTEND_IMAGE="${REGISTRY}/${NAMESPACE}/license-manager-frontend:${VERSION}"
|
||||
|
||||
echo "Building backend and frontend images..."
|
||||
cd "$ROOT_DIR"
|
||||
docker compose build backend frontend
|
||||
|
||||
echo "Tagging images..."
|
||||
docker tag license-manager-backend:latest "$BACKEND_IMAGE"
|
||||
docker tag license-manager-frontend:latest "$FRONTEND_IMAGE"
|
||||
|
||||
echo "Pushing $BACKEND_IMAGE"
|
||||
docker push "$BACKEND_IMAGE"
|
||||
|
||||
echo "Pushing $FRONTEND_IMAGE"
|
||||
docker push "$FRONTEND_IMAGE"
|
||||
|
||||
cat <<EOF
|
||||
Push completed.
|
||||
Backend: $BACKEND_IMAGE
|
||||
Frontend: $FRONTEND_IMAGE
|
||||
EOF
|
||||