29 Commits
Author SHA1 Message Date
leeexin eb1f68b423 feat: add public purchase portal and admin tools
Deploy Frontend via Git / build-and-deploy (push) Failing after 1m0s
Deploy Backend via Git / deploy (push) Failing after 1m0s
2026-03-28 17:55:03 +08:00
wood911010 c0a6ee3cb0 refactor: improve JSON editor functionality and styling, enhance input handling, and update button visibility 2026-03-21 20:53:33 +08:00
wood911010 1ceff0ffa3 chore: update image assets for customer, home, license, and login screens 2026-03-21 17:04:18 +08:00
wood911010 f16bd5567b chore: update README.md to reflect the new demo URL and remove port number for simplified access 2026-03-21 15:44:09 +08:00
wood911010 60f49b3b27 chore: remove unused type import from LicenseForm.vue to clean up code 2026-03-15 13:20:07 +08:00
wood911010 2f9a401381 feat: implement JSON editor component for enhanced JSON data management in forms 2026-03-13 23:57:14 +08:00
wood911010 7355edf999 chore: remove sync_to_enterprise.yml as it is no longer needed for the workflow 2026-03-13 20:57:40 +08:00
wood911010 f8173ace88 chore: update sync_to_enterprise.yml to enhance branch management and streamline push process to enterprise repository 2026-03-13 20:55:46 +08:00
wood911010 a4198743a4 chore: simplify sync_to_enterprise.yml by removing redundant Git setup and explicitly setting remote for pushing changes 2026-03-13 20:44:57 +08:00
wood911010 224d36dcf1 chore: refine sync_to_enterprise.yml by improving error handling during repo cloning and enhancing commit configuration 2026-03-13 20:41:43 +08:00
wood911010 1fb4940625 chore: enhance sync_to_enterprise.yml with debug information and streamline cloning process for better error handling 2026-03-13 20:36:38 +08:00
wood911010 182f57480a chore: streamline sync_to_enterprise.yml by replacing Git setup with gh CLI for improved efficiency 2026-03-13 20:28:42 +08:00
wood911010 9680da7762 chore: improve Git setup and remote verification in sync_to_enterprise.yml for better reliability 2026-03-13 20:17:12 +08:00
wood911010 13e33777bd chore: add remote connection test to sync_to_enterprise.yml for improved debugging 2026-03-13 20:00:56 +08:00
wood911010 4dab5563c5 chore: add debug echo for enterprise repo token loading in sync_to_enterprise.yml 2026-03-13 19:43:03 +08:00
wood911010 1dba45d417 feat: add workflow_dispatch trigger to sync_to_enterprise.yml for manual workflow execution 2026-03-13 19:33:20 +08:00
wood911010 42a5803c06 Merge branch 'main' of https://github.com/cedar-v/license-manager 2026-03-13 19:20:27 +08:00
wood911010 ed00a63434 feat: Add GitHub Actions workflow for syncing to enterprise repository and update documentation for remote configuration 2026-03-13 19:20:20 +08:00
jie c2204adf0c feat: update login page localization and enhance UI elements for better user experience 2026-03-13 13:46:07 +08:00
jie ce410666d9 feat: fix chart overflow issues by adjusting CSS height strategies and ECharts grid configuration 2026-03-13 01:40:13 +08:00
jie bc5d654a59 feat: enhance dashboard stats API and UI with new metrics and improved localization 2026-03-13 01:17:24 +08:00
jie 8410a2cac4 feat: add dashboard stats redesign spec and login i18n completion 2026-03-13 00:42:22 +08:00
jie b0539c0014 Implement feature X to enhance user experience and optimize performance 2026-03-12 23:49:50 +08:00
jie ccbe6cf37b feat: remove zrender from echarts dependencies for improved bundle size 2026-03-12 22:45:03 +08:00
jie a0ff21ad3f feat: enhance LicenseInfo component by adding MessageBoxInputData type for better type safety 2026-03-12 22:37:06 +08:00
jie 10030f2fc7 Merge branch 'main' of https://github.com/cedar-v/license-manager 2026-03-12 22:33:20 +08:00
jie 9eb237513e feat: migrate from npm to pnpm for dependency management in frontend deployment 2026-03-12 10:43:02 +08:00
Coldroads 4e619c30be Merge pull request #9 from mosshello/main
feat: optimize frontend performance and update dependencies
2026-03-11 19:06:20 +08:00
jie 09be964d53 feat: optimize frontend performance and update dependencies 2026-03-11 19:02:36 +08:00
59 changed files with 6657 additions and 1839 deletions

No files matched your search

+228
View File
@@ -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 的实现输入。实现时严格遵循上述契约,禁止超出本文档定义的修改范围。*
+12 -6
View File
@@ -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/
+2 -1
View File
@@ -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"]
+2 -2
View File
@@ -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`
+4 -4
View File
@@ -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
}
+5
View File
@@ -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)
+20 -9
View File
@@ -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 设备列表查询请求结构(客户用户接口)
+1
View File
@@ -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 支付响应结构
+45 -22
View File
@@ -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
}
+40
View File
@@ -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
+35
View File
@@ -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`,支付成功后可通过授权码查询和续期结果页面查看状态。
Binary file not shown.

Before

Width:  |  Height:  |  Size: 133 KiB

After

Width:  |  Height:  |  Size: 164 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 364 KiB

After

Width:  |  Height:  |  Size: 485 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 474 KiB

After

Width:  |  Height:  |  Size: 283 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 1.7 MiB

+7 -7
View File
@@ -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` 按上述文件清单实施。*
+135
View File
@@ -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>
```
+473 -6
View File
@@ -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",
+2
View File
@@ -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",
+223
View File
@@ -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
+22 -1
View File
@@ -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')
}
+24 -1
View File
@@ -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 }
})
}
+107
View File
@@ -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;
}
}
+1 -1
View File
@@ -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>
+284
View File
@@ -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>
+90 -17
View File
@@ -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"
}
}
}
}
+90 -17
View File
@@ -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": "配列"
}
}
}
}
+91 -18
View File
@@ -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": "数组"
}
}
}
}
+1 -11
View File
@@ -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)
+35 -1
View File
@@ -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;
+346
View File
@@ -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) || []
}))
}
+36
View File
@@ -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>
+88 -39
View File
@@ -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 {
+146
View File
@@ -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>
+34 -621
View File
@@ -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 {
File diff suppressed because it is too large. Load diff
+73
View File
@@ -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) => {
+151 -23
View File
@@ -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;
}
+192
View File
@@ -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>
+59
View File
@@ -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>
+558
View File
@@ -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>
+136 -48
View File
@@ -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>
+146 -47
View File
@@ -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>
+31 -8
View File
@@ -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']
},
// 输出文件命名规则(优化缓存策略)
+285 -51
View File
@@ -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"
+31
View File
@@ -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