From 1467ae8a89450a2dca8078c3d12b06b1228fcfcc Mon Sep 17 00:00:00 2001 From: Roe-xin Date: Wed, 25 Feb 2026 10:14:00 +0800 Subject: [PATCH] =?UTF-8?q?feat:=E8=B5=84=E6=BA=90=E7=82=B9=E4=BD=BF?= =?UTF-8?q?=E7=94=A8=E5=AE=9E=E6=97=B6=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/插件试用用户功能实现方案.md | 783 +++++++++++++++++++++++++++++++ src/panels/ICHelperPanel.ts | 21 + src/services/creditsService.ts | 14 + src/views/planCard.ts | 2 +- 4 files changed, 819 insertions(+), 1 deletion(-) create mode 100644 docs/插件试用用户功能实现方案.md diff --git a/docs/插件试用用户功能实现方案.md b/docs/插件试用用户功能实现方案.md new file mode 100644 index 0000000..b585b2a --- /dev/null +++ b/docs/插件试用用户功能实现方案.md @@ -0,0 +1,783 @@ +# 插件试用用户功能实现方案 + +## 1. 方案概述 + +**核心思路:** +- Web 登录成功后只返回 token(保持现状) +- 插件调用 `getUserInfo(token)` 时,后端返回的数据里包含标识字段 +- 前端根据该字段判断是否是插件试用用户 +- 插件试用用户:显示欢迎弹窗,不显示邀请码弹窗 +- 正式用户:显示邀请码弹窗(现有逻辑) + +--- + +## 2. 后端需要做什么 + +### 2.1 在用户信息接口中添加字段 + +**接口:** `GET /system/user/getInfo` + +**现有响应:** +```json +{ + "userId": "xxx", + "username": "testuser", + "nickname": "测试用户", + "email": "test@example.com" +} +``` + +**新增字段:** + +**方案 :添加 isPluginTrial 字段** + +```json +{ + "userId": "xxx", + "username": "testuser", + "nickname": "测试用户", + "email": "test@example.com", + "isPluginTrial": true, // ← 新增:是否是插件试用用户 + "pluginTrialExpiresAt": 1709654400000 // ← 新增:试用到期时间(毫秒时间戳) +} +``` + +### 2.2 后端逻辑说明 + +**判断逻辑:** +```javascript +// 伪代码 +function getUserInfo(userId) { + const user = db.users.findById(userId); + + // 判断是否是插件试用用户(后端自己的逻辑) + const isPluginTrial = checkIfPluginTrialUser(user); + + return { + userId: user.id, + username: user.username, + nickname: user.nickname, + email: user.email, + isPluginTrial: isPluginTrial, + pluginTrialExpiresAt: isPluginTrial ? user.trial_expires_at : null + }; +} +``` + +**Token 过期时间:** +- 插件试用用户:JWT Token 设置 15 天过期 +- 正式用户:JWT Token 设置 30 天过期(或现有逻辑) + +--- + +## 3. 前端需要修改的地方 + +### 3.1 修改 UserInfo 接口 + +**文件:** `src/services/userService.ts` + +**现有接口:** +```typescript +interface UserInfo { + userId: string; + username: string; + nickname: string; + email?: string; + phonenumber?: string; + avatar?: string; + roles?: string[]; + permissions?: string[]; + createTime?: string; + loginDate?: string; + membership?: { + tierCode: string; + tierName: string; + tierLevel: number; + remainingDays?: number; + monthlyCredits?: number; + }; + credits?: number; +} +``` + +**新增字段:** +```typescript +interface UserInfo { + // ... 现有字段 + isPluginTrial?: boolean; // ← 新增:是否是插件试用用户 + pluginTrialExpiresAt?: number; // ← 新增:试用到期时间(毫秒时间戳) + membership?: { + tierCode: string; + tierName: string; + tierLevel: number; + remainingDays?: number; + monthlyCredits?: number; + }; + credits?: number; +} +``` + + +### 3.2 修改 onTokenReceived() 方法 + +**文件:** `src/services/userService.ts` + +**现有代码位置:** 约 200 行左右 + +**修改内容:** +```typescript +async onTokenReceived(token: string) { + // 现有逻辑:并行获取三类信息 + const [userInfo, membershipInfo, credits] = await Promise.all([ + getUserInfo(token), + getMembershipInfo(token), + fetchBalanceWithToken(token) + ]); + + // 合并数据 + const fullUserInfo = { + ...userInfo, + membership: membershipInfo, + credits: credits + }; + + // 保存到 globalState + await this.context.globalState.update('icCoderUserInfo', fullUserInfo); + + // ========== 新增逻辑 ========== + // 判断是否是插件试用用户 + if (fullUserInfo.isPluginTrial === true) { + // 插件试用用户:显示欢迎弹窗,不显示邀请码弹窗 + await this.showWelcomePanel(); + // 标记为已显示欢迎弹窗(避免重复显示) + await this.context.globalState.update('pluginTrialWelcomed', true); + } else { + // 正式用户:显示邀请码弹窗(现有逻辑) + await this.checkAndShowInvitationModal(); + } + // ========== 新增逻辑结束 ========== + + return fullUserInfo; +} +``` + + +### 3.3 新增欢迎弹窗面板 + +**新建文件:** `src/panels/WelcomePanel.ts` + +```typescript +/** + * 欢迎引导面板 + * 功能:插件试用用户首次登录显示使用教程 + */ + +import * as vscode from 'vscode'; + +export class WelcomePanel { + public static currentPanel: WelcomePanel | undefined; + private readonly _panel: vscode.WebviewPanel; + private _disposables: vscode.Disposable[] = []; + + private constructor(panel: vscode.WebviewPanel) { + this._panel = panel; + this._panel.webview.html = this.getHtmlContent(); + + // 监听关闭事件 + this._panel.onDidDispose(() => this.dispose(), null, this._disposables); + } + + public static render(context: vscode.ExtensionContext) { + // 避免重复显示 + if (WelcomePanel.currentPanel) { + WelcomePanel.currentPanel._panel.reveal(vscode.ViewColumn.One); + return; + } + + const panel = vscode.window.createWebviewPanel( + 'icCoderWelcome', + '欢迎使用 IC Coder', + vscode.ViewColumn.One, + { + enableScripts: true, + retainContextWhenHidden: true + } + ); + + WelcomePanel.currentPanel = new WelcomePanel(panel); + } + + private getHtmlContent(): string { + return ` + + + + + + 欢迎使用 IC Coder + + + +

🎉 欢迎使用 IC Coder!

+

+ 您已成功激活 15 天试用期,让我们开始探索 IC Coder 的强大功能吧! +

+ +
+

📝 步骤 1:打开聊天面板

+

点击侧边栏的 IC Coder 图标,或使用命令面板搜索 "IC Coder: Open Chat"

+
+ +
+

💬 步骤 2:输入您的需求

+

描述您想要生成的 Verilog 代码或需要帮助的问题,AI 将为您提供专业的解决方案

+
+ +
+

🔬 步骤 3:运行仿真

+

使用 "生成 VCD" 命令运行 iverilog 仿真,并通过波形查看器查看仿真结果

+
+ + + + + + + `; + } + + public dispose() { + WelcomePanel.currentPanel = undefined; + this._panel.dispose(); + while (this._disposables.length) { + const disposable = this._disposables.pop(); + if (disposable) { + disposable.dispose(); + } + } + } +} +``` + + +### 3.4 新增过期提醒面板 + +**新建文件:** `src/panels/ExpiredPanel.ts` + +```typescript +/** + * 试用期到期提醒面板 + * 功能:试用期到期时显示续费提示 + */ + +import * as vscode from 'vscode'; + +export class ExpiredPanel { + public static render() { + const panel = vscode.window.createWebviewPanel( + 'icCoderExpired', + '试用期已到期', + vscode.ViewColumn.One, + { enableScripts: true } + ); + + panel.webview.html = this.getHtmlContent(); + } + + private static getHtmlContent(): string { + return ` + + + + + + + +

⏰ 您的试用期已到期

+

感谢您使用 IC Coder!您的 15 天试用期已结束。

+

如需继续使用,请联系我们获取正式版本。

+ + + + + + + `; + } +} +``` + + +### 3.5 新增过期检测服务 + +**新建文件:** `src/services/trialExpirationService.ts` + +```typescript +/** + * 试用期过期检测服务 + * 功能:检查插件试用用户是否过期 + */ + +import * as vscode from 'vscode'; +import { getUserInfo } from './userService'; +import { ExpiredPanel } from '../panels/ExpiredPanel'; + +export class TrialExpirationService { + private context: vscode.ExtensionContext; + + constructor(context: vscode.ExtensionContext) { + this.context = context; + } + + /** + * 检查是否过期 + * @returns true=已过期,false=未过期 + */ + public async checkExpiration(): Promise { + const userInfo = await getUserInfo(); + + // 不是插件试用用户,不需要检查 + if (!userInfo?.isPluginTrial) { + return false; + } + + // 没有过期时间,不检查 + if (!userInfo.pluginTrialExpiresAt) { + return false; + } + + // 检查是否过期 + const now = Date.now(); + if (now >= userInfo.pluginTrialExpiresAt) { + // 已过期 + await this.handleExpired(); + return true; + } + + return false; + } + + /** + * 处理过期逻辑 + */ + private async handleExpired(): Promise { + // 显示过期弹窗 + ExpiredPanel.render(); + + // 清除本地数据(可选) + // await this.context.globalState.update('icCoderUserInfo', undefined); + // await this.context.globalState.update('icCoderSessions', undefined); + } +} +``` + + +### 3.6 在消息发送前检查过期 + +**文件:** `src/utils/messageHandler.ts` + +**修改位置:** 在发送消息给后端之前添加过期检查 + +```typescript +import { TrialExpirationService } from '../services/trialExpirationService'; + +// 在 handleUserMessage 或类似的消息处理函数中添加 +async function handleUserMessage(message: string, context: vscode.ExtensionContext) { + // ========== 新增:检查试用期是否过期 ========== + const trialService = new TrialExpirationService(context); + const isExpired = await trialService.checkExpiration(); + + if (isExpired) { + // 已过期,禁止使用 + return { + success: false, + message: '您的试用期已到期,请联系我们获取正式版本' + }; + } + // ========== 新增结束 ========== + + // 现有的消息处理逻辑 + // ... +} +``` + + +--- + +## 4. 完整的实现流程 + +### 4.1 登录流程(带过期检查) + +``` +1. 用户点击登录 + ↓ +2. 打开浏览器,Web 端登录 + ↓ +3. 重定向回插件:http://localhost:{port}/callback?token={token} + ↓ +4. 插件调用 onTokenReceived(token) + ↓ +5. 并行获取:getUserInfo + getMembershipInfo + Credits + ↓ +6. 后端返回 userInfo(包含 isPluginTrial 和 pluginTrialExpiresAt) + ↓ +7. 判断 isPluginTrial === true? + ├─ 是:显示欢迎弹窗,不显示邀请码弹窗 + └─ 否:显示邀请码弹窗(现有逻辑) + ↓ +8. 保存用户信息到 globalState +``` + +### 4.2 使用功能时的过期检查 + +``` +1. 用户发送消息/使用功能 + ↓ +2. 调用 trialService.checkExpiration() + ↓ +3. 获取 userInfo,检查 isPluginTrial + ↓ +4. 如果是插件试用用户,检查 Date.now() >= pluginTrialExpiresAt? + ├─ 是:显示过期弹窗,禁止使用,返回 true + └─ 否:允许使用,返回 false + ↓ +5. 继续正常的消息处理流程 +``` + + +--- + +## 5. ⚠️ 潜在 Bug 和注意事项 + +### 5.1 时间同步问题 + +**问题:** 前端使用 `Date.now()` 判断过期,如果用户本地时间不准确会导致误判 + +**场景:** +- 用户本地时间快了 1 天 → 提前显示过期弹窗 +- 用户本地时间慢了 1 天 → 过期后仍可使用 + +**解决方案:** +```typescript +// 方案 1:每次使用前调用后端验证(推荐) +async checkExpiration(): Promise { + try { + // 调用后端接口验证 Token 是否过期 + const response = await fetch(`${API_BASE}/auth/verify`, { + headers: { 'Authorization': `Bearer ${token}` } + }); + + if (response.status === 401) { + // Token 过期 + await this.handleExpired(); + return true; + } + return false; + } catch (error) { + // 网络错误,使用本地时间判断 + const userInfo = await getUserInfo(); + return Date.now() >= (userInfo?.pluginTrialExpiresAt || 0); + } +} +``` + + +### 5.2 欢迎弹窗重复显示 + +**问题:** 每次登录都显示欢迎弹窗,用户体验不好 + +**场景:** +- 试用用户第一次登录显示欢迎弹窗 ✅ +- 用户关闭插件后重新打开,又显示欢迎弹窗 ❌ + +**解决方案:** +```typescript +async onTokenReceived(token: string) { + // ... 获取用户信息 + + if (fullUserInfo.isPluginTrial === true) { + // 检查是否已经显示过欢迎弹窗 + const hasWelcomed = this.context.globalState.get('pluginTrialWelcomed'); + + if (!hasWelcomed) { + await this.showWelcomePanel(); + await this.context.globalState.update('pluginTrialWelcomed', true); + } + } else { + await this.checkAndShowInvitationModal(); + } +} +``` + + +### 5.3 isPluginTrial 字段类型不一致 + +**问题:** 后端可能返回 `true`、`false`、`null`、`undefined`,前端判断时需要严格处理 + +**场景:** +```typescript +// ❌ 错误写法 +if (userInfo.isPluginTrial) { + // undefined 会被判断为 false +} + +// ✅ 正确写法 +if (userInfo.isPluginTrial === true) { + // 插件试用用户 +} +``` + +**建议:** +- 后端统一返回 `true` 或 `false`,不要返回 `null` +- 前端使用严格相等 `===` 判断 + + +### 5.4 Token 过期但前端未清除 + +**问题:** Token 在后端已过期,但前端仍保存着过期的 Token + +**场景:** +- 用户 15 天后打开插件 +- 前端尝试调用 API,后端返回 401 +- 前端没有处理 401,导致功能异常 + +**解决方案:** +```typescript +// 在 apiClient.ts 中统一处理 401 +async function apiCall(url: string, options: any) { + const response = await fetch(url, options); + + if (response.status === 401) { + // Token 过期,清除本地数据 + await clearAllData(); + ExpiredPanel.render(); + throw new Error('Token expired'); + } + + return response.json(); +} +``` + + +--- + +## 6. 前后端对接清单 + +### 6.1 后端需要提供 + +**1. 修改 `GET /system/user/getInfo` 接口** +- 新增字段:`isPluginTrial` (boolean) +- 新增字段:`pluginTrialExpiresAt` (number, 毫秒时间戳) + +**2. Token 过期时间设置** +- 插件试用用户:JWT Token 设置 15 天过期 +- 正式用户:保持现有逻辑 + +**3. 测试账号** +- 提供 1-2 个插件试用用户账号用于测试 + +### 6.2 前端需要修改 + +**1. 修改文件:** +- `src/services/userService.ts` - 修改 UserInfo 接口和 onTokenReceived() +- `src/utils/messageHandler.ts` - 添加过期检查 + +**2. 新增文件:** +- `src/panels/WelcomePanel.ts` - 欢迎弹窗 +- `src/panels/ExpiredPanel.ts` - 过期提醒弹窗 +- `src/services/trialExpirationService.ts` - 过期检测服务 + +**3. globalState 新增存储键:** +- `pluginTrialWelcomed` (boolean) - 是否已显示欢迎弹窗 + + +--- + +## 7. 测试计划 + +### 7.1 登录流程测试 + +**测试用例 1:插件试用用户登录** +- 使用插件试用用户账号登录 +- 验证是否显示欢迎弹窗 +- 验证是否不显示邀请码弹窗 +- 验证 userInfo 中 isPluginTrial === true + +**测试用例 2:正式用户登录** +- 使用正式用户账号登录 +- 验证是否显示邀请码弹窗 +- 验证是否不显示欢迎弹窗 +- 验证 userInfo 中 isPluginTrial !== true + +**测试用例 3:欢迎弹窗不重复显示** +- 插件试用用户登录后显示欢迎弹窗 +- 关闭插件重新打开 +- 验证不再显示欢迎弹窗 + + +### 7.2 过期检测测试 + +**测试用例 4:未过期用户正常使用** +- 插件试用用户登录(未过期) +- 发送消息使用功能 +- 验证功能正常使用 + +**测试用例 5:已过期用户禁止使用** +- 修改本地时间到 15 天后(或修改 pluginTrialExpiresAt) +- 尝试发送消息 +- 验证显示过期弹窗 +- 验证功能被禁用 + +**测试用例 6:Token 过期处理** +- 使用过期的 Token 调用 API +- 验证后端返回 401 +- 验证前端显示过期弹窗 + + +--- + +## 8. 总结 + +### 8.1 核心改动点 + +**后端(最小改动):** +1. `GET /system/user/getInfo` 接口新增 2 个字段 +2. JWT Token 根据用户类型设置不同过期时间 + +**前端(主要改动):** +1. 修改 UserInfo 接口定义 +2. 修改 onTokenReceived() 添加判断逻辑 +3. 新增 3 个文件(欢迎面板、过期面板、过期检测服务) +4. 在消息发送前添加过期检查 + +### 8.2 关键判断逻辑 + +```typescript +// 登录后判断 +if (userInfo.isPluginTrial === true) { + showWelcomePanel(); // 显示欢迎弹窗 +} else { + showInvitationModal(); // 显示邀请码弹窗 +} + +// 使用前判断 +if (Date.now() >= userInfo.pluginTrialExpiresAt) { + showExpiredPanel(); // 显示过期弹窗 + return; // 禁止使用 +} +``` + + +### 8.3 必须注意的问题 + +1. **时间同步问题** - 用户本地时间不准确导致误判,建议调用后端验证 +2. **isPluginTrial 严格判断** - 必须使用 `=== true` 判断 +3. **欢迎弹窗重复显示** - 使用 globalState 标记避免重复 +4. **Token 过期处理** - 在 apiClient 中统一处理 401 响应 + +### 8.4 实现优先级 + +**P0(必须实现):** +1. 后端接口新增字段 +2. 前端 UserInfo 接口修改 +3. 前端 onTokenReceived() 判断逻辑 +4. 过期检测逻辑 + +**P1(重要):** +1. 欢迎弹窗 +2. 过期提醒弹窗 + +**P2(优化):** +1. 后端验证过期(避免时间同步问题) +2. 更友好的过期提示 + +--- + +**文档完成!** 基于你现有的代码架构,这个方案改动最小,实现最简单。 + diff --git a/src/panels/ICHelperPanel.ts b/src/panels/ICHelperPanel.ts index 722b100..5d23714 100644 --- a/src/panels/ICHelperPanel.ts +++ b/src/panels/ICHelperPanel.ts @@ -20,6 +20,7 @@ import { ChatHistoryManager } from "../utils/chatHistoryManager"; import { MessageType } from "../types/chatHistory"; import { getCachedUserInfo } from "../services/userService"; import { isTokenExpired } from "../utils/jwtUtils"; +import { setBalanceUpdateCallback } from "../services/creditsService"; /** * 获取会员等级图标 URI @@ -222,6 +223,26 @@ export async function showICHelperPanel( console.error('[ICHelperPanel] 获取用户信息失败:', error); } + // 设置余额更新回调 + setBalanceUpdateCallback((balance: number) => { + const userInfo = getCachedUserInfo(); + if (userInfo) { + userInfo.credits = balance; + const tierIconUrl = getTierIconUri(panel.webview, context, userInfo.membership?.tierCode); + panel.webview.postMessage({ + command: 'updateUserInfo', + userInfo: { + userId: userInfo.userId, + nickname: userInfo.nickname, + username: userInfo.username, + credits: balance, + membership: userInfo.membership + }, + tierIconUrl: tierIconUrl + }); + } + }); + // 检查是否有待发送的消息 const pendingMessage = context.globalState.get('pendingMessage') as any; if (pendingMessage) { diff --git a/src/services/creditsService.ts b/src/services/creditsService.ts index ae5d341..ad0f8de 100644 --- a/src/services/creditsService.ts +++ b/src/services/creditsService.ts @@ -25,6 +25,9 @@ const CACHE_TTL_MS = 5 * 60 * 1000; /** ExtensionContext 用于持久化存储 */ let extensionContext: vscode.ExtensionContext | null = null; +/** 余额更新回调函数 */ +let onBalanceUpdateCallback: ((balance: number) => void) | null = null; + /** * 初始化 Credits 服务(设置 context) */ @@ -39,6 +42,13 @@ export function initCreditsService(context: vscode.ExtensionContext): void { } } +/** + * 设置余额更新回调 + */ +export function setBalanceUpdateCallback(callback: (balance: number) => void): void { + onBalanceUpdateCallback = callback; +} + /** * 保存余额到持久化存储 */ @@ -60,6 +70,10 @@ export function updateCachedBalance(balance: number): void { saveBalance(balance).catch(err => { console.error('[CreditsService] 保存余额失败:', err); }); + // 通知前端更新余额显示 + if (onBalanceUpdateCallback) { + onBalanceUpdateCallback(balance); + } } /** diff --git a/src/views/planCard.ts b/src/views/planCard.ts index 706ec37..d8dca73 100644 --- a/src/views/planCard.ts +++ b/src/views/planCard.ts @@ -720,7 +720,7 @@ export function getPlanCardScript(): string { segmentDiv.innerHTML = \`
- 📋 + \${segment.planTitle || '执行计划'}
\${progressHtml}