This commit is contained in:
wood911010 committed 2026-03-13 19:20:27 +08:00
commit 42a5803c06
20 files changed
+2043 -809

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/
+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 设备列表查询请求结构(客户用户接口)
+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
@@ -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>
```
+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/core@10.11.1(vue@3.5.27(typescript@5.9.3)))
unplugin-vue-components:
specifier: ^31.0.0
version: 31.0.0(vue@3.5.27(typescript@5.9.3))
vite:
specifier: ^5.0.0
version: 5.4.21(@types/node@22.19.9)(sass@1.97.3)(terser@5.46.0)
@@ -318,6 +324,9 @@ packages:
'@jridgewell/gen-mapping@0.3.13':
resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
'@jridgewell/remapping@2.3.5':
resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==}
'@jridgewell/resolve-uri@3.1.2':
resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==}
engines: {node: '>=6.0.0'}
@@ -816,6 +825,11 @@ packages:
engines: {node: '>=0.4.0'}
hasBin: true
acorn@8.16.0:
resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==}
engines: {node: '>=0.4.0'}
hasBin: true
ajv@6.12.6:
resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==}
@@ -897,6 +911,10 @@ packages:
resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==}
engines: {node: '>= 14.16.0'}
chokidar@5.0.0:
resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==}
engines: {node: '>= 20.19.0'}
cliui@8.0.1:
resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==}
engines: {node: '>=12'}
@@ -918,6 +936,12 @@ packages:
concat-map@0.0.1:
resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==}
confbox@0.1.8:
resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==}
confbox@0.2.4:
resolution: {integrity: sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==}
cross-spawn@7.0.6:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'}
@@ -1023,6 +1047,10 @@ packages:
resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==}
engines: {node: '>=10'}
escape-string-regexp@5.0.0:
resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==}
engines: {node: '>=12'}
eslint-config-prettier@9.1.2:
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'}
exsolve@1.0.8:
resolution: {integrity: sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==}
fast-deep-equal@3.1.3:
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
@@ -1300,6 +1331,9 @@ packages:
isexe@2.0.0:
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
js-tokens@9.0.1:
resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==}
js-yaml@4.1.1:
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'}
local-pkg@1.1.2:
resolution: {integrity: sha512-arhlxbFRmoQHl33a0Zkle/YWlmNwoyt6QNZEIJcqNbdrsix5Lvc4HyyI3EnwxTYlZYc32EbYrQ8SzEZ7dqgg9A==}
engines: {node: '>=14'}
locate-path@6.0.0:
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'}
mlly@1.8.1:
resolution: {integrity: sha512-SnL6sNutTwRWWR/vcmCYHSADjiEesp5TGQQ0pXyLhW5IoeibRlF/CbSLailbB3CNqJUk9cVJ9dUDnbD7GrcHBQ==}
ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
@@ -1402,6 +1443,9 @@ packages:
nth-check@2.1.1:
resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==}
obug@2.1.1:
resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==}
once@1.4.0:
resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
@@ -1447,6 +1491,9 @@ packages:
pathe@1.1.2:
resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==}
pathe@2.0.3:
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
pathval@2.0.1:
resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==}
engines: {node: '>= 14.16'}
@@ -1471,6 +1518,12 @@ packages:
typescript:
optional: true
pkg-types@1.3.1:
resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==}
pkg-types@2.3.0:
resolution: {integrity: sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==}
postcss-selector-parser@6.1.2:
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'}
quansync@0.2.11:
resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==}
queue-microtask@1.2.3:
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
@@ -1506,6 +1562,10 @@ packages:
resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==}
engines: {node: '>= 14.18.0'}
readdirp@5.0.0:
resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==}
engines: {node: '>= 20.19.0'}
require-directory@2.1.1:
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
scule@1.3.0:
resolution: {integrity: sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==}
semver@7.7.4:
resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==}
engines: {node: '>=10'}
@@ -1602,6 +1665,9 @@ packages:
resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==}
engines: {node: '>=8'}
strip-literal@3.1.0:
resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==}
supports-color@7.2.0:
resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==}
engines: {node: '>=8'}
@@ -1672,13 +1738,50 @@ packages:
engines: {node: '>=14.17'}
hasBin: true
ufo@1.6.3:
resolution: {integrity: sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==}
undici-types@6.21.0:
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
unimport@5.7.0:
resolution: {integrity: sha512-njnL6sp8lEA8QQbZrt+52p/g4X0rw3bnGGmUcJnt1jeG8+iiqO779aGz0PirCtydAIVcuTBRlJ52F0u46z309Q==}
engines: {node: '>=18.12.0'}
universalify@2.0.1:
resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==}
engines: {node: '>= 10.0.0'}
unplugin-auto-import@21.0.0:
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
unplugin-utils@0.3.1:
resolution: {integrity: sha512-5lWVjgi6vuHhJ526bI4nlCOmkCIF3nnfXkCMDeMJrtdvxTs6ZFCM8oNufGTsDbKv/tJ/xj8RpvXjRuPBZJuJog==}
engines: {node: '>=20.19.0'}
unplugin-vue-components@31.0.0:
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
unplugin@2.3.11:
resolution: {integrity: sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww==}
engines: {node: '>=18.12.0'}
uri-js@4.4.1:
resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
@@ -1824,6 +1927,9 @@ packages:
typescript:
optional: true
webpack-virtual-modules@0.6.2:
resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==}
which@2.0.2:
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/remapping@2.3.5':
dependencies:
'@jridgewell/gen-mapping': 0.3.13
'@jridgewell/trace-mapping': 0.3.31
'@jridgewell/resolve-uri@3.1.2': {}
'@jridgewell/source-map@0.3.11':
@@ -2554,6 +2665,8 @@ snapshots:
acorn@8.15.0: {}
acorn@8.16.0: {}
ajv@6.12.6:
dependencies:
fast-deep-equal: 3.1.3
@@ -2634,6 +2747,10 @@ snapshots:
dependencies:
readdirp: 4.1.2
chokidar@5.0.0:
dependencies:
readdirp: 5.0.0
cliui@8.0.1:
dependencies:
string-width: 4.2.3
@@ -2654,6 +2771,10 @@ snapshots:
concat-map@0.0.1: {}
confbox@0.1.8: {}
confbox@0.2.4: {}
cross-spawn@7.0.6:
dependencies:
path-key: 3.1.1
@@ -2773,6 +2894,8 @@ snapshots:
escape-string-regexp@4.0.0: {}
escape-string-regexp@5.0.0: {}
eslint-config-prettier@9.1.2(eslint@8.57.1):
dependencies:
eslint: 8.57.1
@@ -2889,6 +3012,8 @@ snapshots:
expect-type@1.3.0: {}
exsolve@1.0.8: {}
fast-deep-equal@3.1.3: {}
fast-diff@1.3.0: {}
@@ -3067,6 +3192,8 @@ snapshots:
isexe@2.0.0: {}
js-tokens@9.0.1: {}
js-yaml@4.1.1:
dependencies:
argparse: 2.0.1
@@ -3092,6 +3219,12 @@ snapshots:
prelude-ls: 1.2.1
type-check: 0.4.0
local-pkg@1.1.2:
dependencies:
mlly: 1.8.1
pkg-types: 2.3.0
quansync: 0.2.11
locate-path@6.0.0:
dependencies:
p-locate: 5.0.0
@@ -3139,6 +3272,13 @@ snapshots:
dependencies:
brace-expansion: 2.0.2
mlly@1.8.1:
dependencies:
acorn: 8.16.0
pathe: 2.0.3
pkg-types: 1.3.1
ufo: 1.6.3
ms@2.1.3: {}
muggle-string@0.4.1: {}
@@ -3156,6 +3296,8 @@ snapshots:
dependencies:
boolbase: 1.0.0
obug@2.1.1: {}
once@1.4.0:
dependencies:
wrappy: 1.0.2
@@ -3199,6 +3341,8 @@ snapshots:
pathe@1.1.2: {}
pathe@2.0.3: {}
pathval@2.0.1: {}
picocolors@1.1.1: {}
@@ -3217,6 +3361,18 @@ snapshots:
transitivePeerDependencies:
- '@vue/composition-api'
pkg-types@1.3.1:
dependencies:
confbox: 0.1.8
mlly: 1.8.1
pathe: 2.0.3
pkg-types@2.3.0:
dependencies:
confbox: 0.2.4
exsolve: 1.0.8
pathe: 2.0.3
postcss-selector-parser@6.1.2:
dependencies:
cssesc: 3.0.0
@@ -3240,10 +3396,14 @@ snapshots:
punycode@2.3.1: {}
quansync@0.2.11: {}
queue-microtask@1.2.3: {}
readdirp@4.1.2: {}
readdirp@5.0.0: {}
require-directory@2.1.1: {}
resolve-from@4.0.0: {}
@@ -3306,6 +3466,8 @@ snapshots:
optionalDependencies:
'@parcel/watcher': 2.5.6
scule@1.3.0: {}
semver@7.7.4: {}
shebang-command@2.0.0:
@@ -3345,6 +3507,10 @@ snapshots:
strip-json-comments@3.1.1: {}
strip-literal@3.1.0:
dependencies:
js-tokens: 9.0.1
supports-color@7.2.0:
dependencies:
has-flag: 4.0.0
@@ -3399,10 +3565,65 @@ snapshots:
typescript@5.9.3: {}
ufo@1.6.3: {}
undici-types@6.21.0: {}
unimport@5.7.0:
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
universalify@2.0.1: {}
unplugin-auto-import@21.0.0(@vueuse/core@10.11.1(vue@3.5.27(typescript@5.9.3))):
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(vue@3.5.27(typescript@5.9.3))
unplugin-utils@0.3.1:
dependencies:
pathe: 2.0.3
picomatch: 4.0.3
unplugin-vue-components@31.0.0(vue@3.5.27(typescript@5.9.3)):
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(typescript@5.9.3)
unplugin@2.3.11:
dependencies:
'@jridgewell/remapping': 2.3.5
acorn: 8.15.0
picomatch: 4.0.3
webpack-virtual-modules: 0.6.2
uri-js@4.4.1:
dependencies:
punycode: 2.3.1
@@ -3555,6 +3776,8 @@ snapshots:
optionalDependencies:
typescript: 5.9.3
webpack-virtual-modules@0.6.2: {}
which@2.0.2:
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')
}
+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 */
}
}
+33 -13
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",
@@ -295,9 +317,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 +335,8 @@
"endDate": "End Date",
"separator": "to",
"quickOptions": {
"today": "Today",
"week": "This Week",
"month": "This Month"
"week": "Last 7 Days",
"month": "Last 30 Days"
}
}
},
+33 -13
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": "最近のライセンス",
@@ -294,9 +316,8 @@
"licenseTrend": {
"title": "ライセンストレンド",
"quickOptions": {
"today": "今日",
"week": "今週",
"month": "今月"
"week": "直7日間",
"month": "直30日間"
},
"datePicker": {
"rangeSeparator": "から",
@@ -313,9 +334,8 @@
"endDate": "終了日付",
"separator": "から",
"quickOptions": {
"today": "今日",
"week": "今週",
"month": "今月"
"week": "直7日間",
"month": "直30日間"
}
}
},
+34 -14
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": "最近授权",
@@ -328,9 +350,8 @@
"licenseTrend": {
"title": "授权趋势",
"quickOptions": {
"today": "本日",
"week": "本周",
"month": "本月"
"week": "最近7天",
"month": "最近30天"
},
"datePicker": {
"rangeSeparator": "至",
@@ -347,9 +368,8 @@
"endDate": "结束日期",
"separator": "至",
"quickOptions": {
"today": "本日",
"week": "本周",
"month": "本月"
"week": "最近7天",
"month": "最近30天"
}
}
},
+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)
+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 {
@@ -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
File diff suppressed because it is too large. Load diff
+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']
},
// 输出文件命名规则(优化缓存策略)