From 8410a2cac48fad21aabf202c9298ab6d5015f399 Mon Sep 17 00:00:00 2001 From: jie Date: Fri, 13 Mar 2026 00:42:22 +0800 Subject: [PATCH] feat: add dashboard stats redesign spec and login i18n completion --- .../specs/dashboard-stats-redesign-spec.md | 163 +++ frontend/.ai/specs/login-i18n-spec.md | 135 ++ .../components/charts/LicenseTrendChart.vue | 24 +- frontend/src/i18n/locales/en.json | 23 +- frontend/src/i18n/locales/ja.json | 23 +- frontend/src/i18n/locales/zh.json | 23 +- frontend/src/views/Login.vue | 1129 +++++++---------- 7 files changed, 805 insertions(+), 715 deletions(-) create mode 100644 frontend/.ai/specs/dashboard-stats-redesign-spec.md create mode 100644 frontend/.ai/specs/login-i18n-spec.md diff --git a/frontend/.ai/specs/dashboard-stats-redesign-spec.md b/frontend/.ai/specs/dashboard-stats-redesign-spec.md new file mode 100644 index 0000000..2def06b --- /dev/null +++ b/frontend/.ai/specs/dashboard-stats-redesign-spec.md @@ -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(下迭代)**: +- 卡片支持点击跳转到过滤后的列表页 diff --git a/frontend/.ai/specs/login-i18n-spec.md b/frontend/.ai/specs/login-i18n-spec.md new file mode 100644 index 0000000..36242c2 --- /dev/null +++ b/frontend/.ai/specs/login-i18n-spec.md @@ -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
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 + +

Secure License
Management

+ + +

{{ t('login.heroTitleLine1') }}
{{ t('login.heroTitleLine2') }}

+``` + +```html + +

Streamline your software licensing workflow with our enterprise-grade platform.

+ + +

{{ t('login.heroSubtitle') }}

+``` + +```html + +Active Licenses +Uptime +Support + + +{{ t('login.stats.activeLicenses') }} +{{ t('login.stats.uptime') }} +{{ t('login.stats.support') }} +``` + +```html + +

Welcome back

+

Sign in to your account to continue

+ + +

{{ t('login.welcomeTitle') }}

+

{{ t('login.welcomeSubtitle') }}

+``` + +```html + +

© 2025 Cedar-V. All rights reserved.

+ + +

{{ t('login.copyright') }}

+``` diff --git a/frontend/src/components/charts/LicenseTrendChart.vue b/frontend/src/components/charts/LicenseTrendChart.vue index 60a7f78..7c427fe 100644 --- a/frontend/src/components/charts/LicenseTrendChart.vue +++ b/frontend/src/components/charts/LicenseTrendChart.vue @@ -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' } ]) @@ -161,7 +160,7 @@ const chartOption = computed(() => { return { grid: { left: 60, - right: 30, + right: 50, top: 30, bottom: 60, containLabel: false @@ -277,24 +276,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 diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 6c62443..1bf8125 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -17,7 +17,18 @@ "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": { + "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." }, "sidebar": { "expand": "Expand Sidebar", @@ -295,9 +306,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 +324,8 @@ "endDate": "End Date", "separator": "to", "quickOptions": { - "today": "Today", - "week": "This Week", - "month": "This Month" + "week": "Last 7 Days", + "month": "Last 30 Days" } } }, diff --git a/frontend/src/i18n/locales/ja.json b/frontend/src/i18n/locales/ja.json index d682592..2c87a90 100644 --- a/frontend/src/i18n/locales/ja.json +++ b/frontend/src/i18n/locales/ja.json @@ -17,7 +17,18 @@ "usernameMinLength": "ユーザー名は3文字以上である必要があります", "passwordRequired": "パスワードを入力してください", "passwordMinLength": "パスワードは6文字以上である必要があります" - } + }, + "heroTitleLine1": "セキュアな", + "heroTitleLine2": "ライセンス管理", + "heroSubtitle": "エンタープライズグレードのプラットフォームで、ソフトウェアライセンス管理を効率化。", + "stats": { + "activeLicenses": "アクティブライセンス", + "uptime": "稼働率", + "support": "サポート" + }, + "welcomeTitle": "おかえりなさい", + "welcomeSubtitle": "アカウントにサインインして続けてください", + "copyright": "© 2025 Cedar-V. All rights reserved." }, "sidebar": { "expand": "サイドバーを展開", @@ -294,9 +305,8 @@ "licenseTrend": { "title": "ライセンストレンド", "quickOptions": { - "today": "今日", - "week": "今週", - "month": "今月" + "week": "直7日間", + "month": "直30日間" }, "datePicker": { "rangeSeparator": "から", @@ -313,9 +323,8 @@ "endDate": "終了日付", "separator": "から", "quickOptions": { - "today": "今日", - "week": "今週", - "month": "今月" + "week": "直7日間", + "month": "直30日間" } } }, diff --git a/frontend/src/i18n/locales/zh.json b/frontend/src/i18n/locales/zh.json index ded4df6..0f2a152 100644 --- a/frontend/src/i18n/locales/zh.json +++ b/frontend/src/i18n/locales/zh.json @@ -17,7 +17,18 @@ "usernameMinLength": "用户名至少需要3个字符", "passwordRequired": "请输入密码", "passwordMinLength": "密码至少需要6个字符" - } + }, + "heroTitleLine1": "软件授权", + "heroTitleLine2": "管理系统", + "heroSubtitle": "赋能软件商业化,构建 B2B 与 B2C 全场景的软件授权体系。", + "stats": { + "activeLicenses": "活跃许可证", + "uptime": "可用率", + "support": "全天支持" + }, + "welcomeTitle": "欢迎回来", + "welcomeSubtitle": "登录您的账户以继续", + "copyright": "© 2025 Cedar-V. 保留所有权利。" }, "sidebar": { "expand": "展开侧边栏", @@ -328,9 +339,8 @@ "licenseTrend": { "title": "授权趋势", "quickOptions": { - "today": "本日", - "week": "本周", - "month": "本月" + "week": "最近7天", + "month": "最近30天" }, "datePicker": { "rangeSeparator": "至", @@ -347,9 +357,8 @@ "endDate": "结束日期", "separator": "至", "quickOptions": { - "today": "本日", - "week": "本周", - "month": "本月" + "week": "最近7天", + "month": "最近30天" } } }, diff --git a/frontend/src/views/Login.vue b/frontend/src/views/Login.vue index aa86e05..d95cc66 100644 --- a/frontend/src/views/Login.vue +++ b/frontend/src/views/Login.vue @@ -1,140 +1,138 @@ @@ -158,11 +156,9 @@ const { t, locale } = useI18n() const loginFormRef = ref() const canvasRef = ref() -// 当前语言 const currentLanguage = ref(locale.value) const loading = ref(false) -// 登录表单数据 const loginForm = reactive({ username: '', password: '' @@ -170,7 +166,6 @@ const loginForm = reactive({ const rememberMe = ref(false) -// 表单验证规则 const loginRules: FormRules = { username: [ { required: true, message: () => t('login.error.usernameRequired'), trigger: 'blur' }, @@ -182,8 +177,7 @@ const loginRules: FormRules = { ] } -// 生成粒子样式 -function getParticleStyle(index: number) { +function getParticleStyle(_index: number) { const size = Math.random() * 8 + 4 const left = Math.random() * 100 const delay = Math.random() * 15 @@ -200,15 +194,8 @@ function getParticleStyle(index: number) { } } -// Canvas 动画 let animationId: number -let particles: Array<{ - x: number - y: number - vx: number - vy: number - radius: number -}> = [] +let particles: Array<{ x: number; y: number; vx: number; vy: number; radius: number }> = [] function initCanvas() { const canvas = canvasRef.value @@ -218,13 +205,15 @@ function initCanvas() { if (!ctx) return const resizeCanvas = () => { - canvas.width = window.innerWidth - canvas.height = window.innerHeight + const parent = canvas.parentElement + if (parent) { + canvas.width = parent.offsetWidth + canvas.height = parent.offsetHeight + } } resizeCanvas() window.addEventListener('resize', resizeCanvas) - // 初始化粒子 const particleCount = 25 particles = [] for (let i = 0; i < particleCount; i++) { @@ -239,35 +228,26 @@ function initCanvas() { const isDark = document.documentElement.getAttribute('data-theme') === 'dark' const primaryColor = isDark ? '0, 200, 150' : '1, 156, 124' - const secondaryColor = isDark ? '0, 150, 200' : '20, 107, 89' function animate() { if (!ctx || !canvas) return - ctx.clearRect(0, 0, canvas.width, canvas.height) - // 更新和绘制粒子 particles.forEach((particle, i) => { - // 更新位置 particle.x += particle.vx particle.y += particle.vy - - // 边界检测 if (particle.x < 0 || particle.x > canvas.width) particle.vx *= -1 if (particle.y < 0 || particle.y > canvas.height) particle.vy *= -1 - // 绘制粒子 ctx.beginPath() ctx.arc(particle.x, particle.y, particle.radius, 0, Math.PI * 2) ctx.fillStyle = `rgba(${primaryColor}, 0.6)` ctx.fill() - // 绘制连接线 for (let j = i + 1; j < particles.length; j++) { const dx = particles[j].x - particle.x const dy = particles[j].y - particle.y const distance = Math.sqrt(dx * dx + dy * dy) - if (distance < 150) { ctx.beginPath() ctx.moveTo(particle.x, particle.y) @@ -291,18 +271,14 @@ function initCanvas() { } } -// 切换语言 function handleLanguageChange(lang: string) { - // 使用统一的语言管理器切换语言 changeLanguage(lang as SupportedLocale) currentLanguage.value = lang } onMounted(() => { - // 确保主题正确初始化 appStore.initTheme() - // 记住密码功能 const saved = localStorage.getItem('loginInfo') if (saved) { const info = JSON.parse(saved) @@ -311,21 +287,16 @@ onMounted(() => { rememberMe.value = true } - // 同步当前语言状态 currentLanguage.value = locale.value - // 初始化 Canvas 动画 const cleanup = initCanvas() - onUnmounted(() => { cleanup?.() }) }) onUnmounted(() => { - if (animationId) { - cancelAnimationFrame(animationId) - } + if (animationId) cancelAnimationFrame(animationId) }) function handleForgotPassword() { @@ -334,26 +305,19 @@ function handleForgotPassword() { async function handleLogin() { if (!loginFormRef.value) return - try { const valid = await loginFormRef.value.validate() if (!valid) return loading.value = true - const loginData: LoginRequest = { username: loginForm.username, password: loginForm.password } const response = await Login(loginData) - console.log('登录响应:', response) - - // 检查响应是否成功 (支持 code 000000) if (response.code === '000000') { - // 登录成功,存储用户信息到store - if (response.data && response.data.token && response.data.user_info) { - // 使用store的方法存储登录数据 + if (response.data?.token && response.data?.user_info) { userStore.setLoginData(response.data.token, { username: response.data.user_info.username, role: response.data.user_info.role @@ -361,13 +325,10 @@ async function handleLogin() { } if (rememberMe.value) { - localStorage.setItem( - 'loginInfo', - JSON.stringify({ - username: loginForm.username, - password: loginForm.password - }) - ) + localStorage.setItem('loginInfo', JSON.stringify({ + username: loginForm.username, + password: loginForm.password + })) } else { localStorage.removeItem('loginInfo') } @@ -378,11 +339,8 @@ async function handleLogin() { ElMessage.error(response.message) } } catch (error: any) { - // 使用后端返回的错误信息 - let errorMessage = error.backendMessage || error.response?.data?.message || error.message - if (errorMessage) { - ElMessage.error(errorMessage) - } + const errorMessage = error.backendMessage || error.response?.data?.message || error.message + if (errorMessage) ElMessage.error(errorMessage) } finally { loading.value = false } @@ -392,36 +350,50 @@ async function handleLogin() {