diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 52635a1..0d1a08b 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -82,7 +82,11 @@ "Bash(ls -la \"D:\\temp\\破解\\cursorpro-0.4.5\\deobfuscated_full\\extension\\out\\webview\"\" 2>/dev/null || dir \"D:temp破解cursorpro-0.4.5deobfuscated_fullextensionoutwebview \")", "Bash(npx vsce package:*)", "Bash(git commit:*)", - "Bash(git branch:*)" + "Bash(git branch:*)", + "Bash(node format_html.js:*)", + "Bash(move:*)", + "Bash(node test_cursor_api.js:*)", + "Bash(python test_cursor_service.py:*)" ] } } diff --git a/backend/app/api/admin.py b/backend/app/api/admin.py index cc53c3a..31be6a6 100644 --- a/backend/app/api/admin.py +++ b/backend/app/api/admin.py @@ -587,6 +587,101 @@ async def delete_key( return {"message": "删除成功"} +@router.post("/keys/{key_id}/revoke") +async def revoke_key( + key_id: int, + db: Session = Depends(get_db), + current_user: dict = Depends(get_current_user) +): + """撤销激活码(从主密钥扣除资源)""" + success, message = KeyService.revoke_key(db, key_id) + if not success: + raise HTTPException(status_code=400, detail=message) + return {"success": True, "message": message} + + +@router.get("/keys/by-device/{device_id}") +async def get_keys_by_device( + device_id: str, + db: Session = Depends(get_db), + current_user: dict = Depends(get_current_user) +): + """获取设备的所有密钥(管理后台用)""" + keys_info = KeyService.get_device_keys(db, device_id) + + result = { + "device_id": device_id, + "auto": None, + "pro": None + } + + # Auto 密钥组 + if keys_info["auto"]: + auto_data = keys_info["auto"] + master = auto_data["master"] + merged_keys = auto_data["merged_keys"] + + all_keys = [{ + "id": master.id, + "key": master.key, + "is_master": True, + "status": master.status.value, + "duration_days": master.duration_days, + "activated_at": master.first_activated_at.strftime("%Y-%m-%d %H:%M:%S") if master.first_activated_at else None + }] + for k in merged_keys: + all_keys.append({ + "id": k.id, + "key": k.key, + "is_master": False, + "status": k.status.value, + "duration_days": k.duration_days, + "merged_at": k.merged_at.strftime("%Y-%m-%d %H:%M:%S") if k.merged_at else None + }) + + result["auto"] = { + "total_keys": len(all_keys), + "expire_at": master.expire_at.strftime("%Y-%m-%d %H:%M:%S") if master.expire_at else None, + "current_account": master.current_account.email if master.current_account else None, + "keys": all_keys + } + + # Pro 密钥组 + if keys_info["pro"]: + pro_data = keys_info["pro"] + master = pro_data["master"] + merged_keys = pro_data["merged_keys"] + + all_keys = [{ + "id": master.id, + "key": master.key, + "is_master": True, + "status": master.status.value, + "quota_contribution": master.quota_contribution, + "activated_at": master.first_activated_at.strftime("%Y-%m-%d %H:%M:%S") if master.first_activated_at else None + }] + for k in merged_keys: + all_keys.append({ + "id": k.id, + "key": k.key, + "is_master": False, + "status": k.status.value, + "quota_contribution": k.quota_contribution, + "merged_at": k.merged_at.strftime("%Y-%m-%d %H:%M:%S") if k.merged_at else None + }) + + result["pro"] = { + "total_keys": len(all_keys), + "quota": pro_data["quota"], + "quota_used": pro_data["quota_used"], + "quota_remaining": pro_data["quota_remaining"], + "current_account": master.current_account.email if master.current_account else None, + "keys": all_keys + } + + return result + + @router.get("/keys/{key_id}/usage-info") async def get_key_usage_info( key_id: int, diff --git a/backend/app/api/client.py b/backend/app/api/client.py index 51d08ef..30f8451 100644 --- a/backend/app/api/client.py +++ b/backend/app/api/client.py @@ -79,55 +79,63 @@ async def verify_key(request: VerifyKeyRequest, req: Request, db: Session = Depe async def verify_key_impl(request: VerifyKeyRequest, req: Request, db: Session): - """验证激活码实现""" + """验证激活码实现 - 支持密钥合并""" key = KeyService.get_by_key(db, request.key) if not key: return {"success": False, "valid": False, "error": "激活码不存在"} - # 首次激活:设置激活时间和过期时间 - KeyService.activate(db, key) + # 激活密钥(支持合并) + activate_ok, activate_msg, master_key = KeyService.activate(db, key, request.device_id) + if not activate_ok: + LogService.log(db, key.id, "verify", ip_address=req.client.host, success=False, message=activate_msg) + return {"success": False, "valid": False, "error": activate_msg} - # 检查设备限制 - if request.device_id: - device_ok, device_msg = KeyService.check_device(db, key, request.device_id) - if not device_ok: - LogService.log(db, key.id, "verify", ip_address=req.client.host, success=False, message=device_msg) - return {"success": False, "valid": False, "error": device_msg} + # 使用主密钥进行后续操作 + active_key = master_key if master_key else key - # 检查激活码是否有效 - is_valid, message = KeyService.is_valid(key, db) + # 检查主密钥是否有效 + is_valid, message = KeyService.is_valid(active_key, db) if not is_valid: - LogService.log(db, key.id, "verify", ip_address=req.client.host, success=False, message=message) + LogService.log(db, active_key.id, "verify", ip_address=req.client.host, success=False, message=message) return {"success": False, "valid": False, "error": message} # 获取当前绑定的账号,或分配新账号 account = None - if key.current_account_id: - account = AccountService.get_by_id(db, key.current_account_id) + if active_key.current_account_id: + account = AccountService.get_by_id(db, active_key.current_account_id) - # 只有账号不存在或被禁用/过期才分配新的(IN_USE 状态的账号继续使用) + # 只有账号不存在或被禁用/过期才分配新的 if not account or account.status in (AccountStatus.DISABLED, AccountStatus.EXPIRED): - # 分配新账号 - account = AccountService.get_available(db, key.membership_type) + account = AccountService.get_available(db, active_key.membership_type) if not account: - LogService.log(db, key.id, "verify", ip_address=req.client.host, success=False, message="无可用账号") + LogService.log(db, active_key.id, "verify", ip_address=req.client.host, success=False, message="无可用账号") return {"success": False, "valid": False, "error": "暂无可用账号,请稍后重试"} - KeyService.bind_account(db, key, account) - AccountService.mark_used(db, account, key.id) + KeyService.bind_account(db, active_key, account) + AccountService.mark_used(db, account, active_key.id) - LogService.log(db, key.id, "verify", account.id, ip_address=req.client.host, success=True) + # 只记录首次激活,不记录每次验证(减少日志量) + if "激活成功" in activate_msg or "合并" in activate_msg: + LogService.log(db, active_key.id, "activate", account.id, ip_address=req.client.host, success=True, message=activate_msg) + + # 返回格式 + expire_date = active_key.expire_at.strftime("%Y-%m-%d %H:%M:%S") if active_key.expire_at else None + is_pro = active_key.membership_type == MembershipType.PRO - # 返回格式匹配原版插件期望 - expire_date = key.expire_at.strftime("%Y-%m-%d %H:%M:%S") if key.expire_at else None return { "success": True, "valid": True, + "message": activate_msg, + "membership_type": active_key.membership_type.value, "expire_date": expire_date, - "switch_remaining": key.quota - key.quota_used, - "switch_limit": key.quota, - "data": build_account_data(account, key) + "switch_remaining": active_key.quota - active_key.quota_used if is_pro else 999, + "switch_limit": active_key.quota if is_pro else 999, + "quota": active_key.quota if is_pro else None, + "quota_used": active_key.quota_used if is_pro else None, + "merged_count": active_key.merged_count, + "master_key": active_key.key[:8] + "****", # 隐藏部分密钥 + "data": build_account_data(account, active_key) } @@ -195,6 +203,115 @@ async def switch_account_impl(request: SwitchAccountRequest, req: Request, db: S ) +# ========== 设备密钥信息 API ========== + +@router.get("/device-keys") +async def get_device_keys(device_id: str = None, db: Session = Depends(get_db)): + """获取设备的所有密钥信息(Auto和Pro)""" + if not device_id: + return {"success": False, "error": "缺少设备ID"} + + keys_info = KeyService.get_device_keys(db, device_id) + + result = { + "success": True, + "device_id": device_id, + "auto": None, + "pro": None + } + + # Auto 密钥信息 + if keys_info["auto"]: + auto_data = keys_info["auto"] + master = auto_data["master"] + result["auto"] = { + "has_key": True, + "master_key": master.key[:8] + "****", + "expire_at": master.expire_at.strftime("%Y/%m/%d %H:%M:%S") if master.expire_at else None, + "merged_count": auto_data["total_keys"], + "current_account": master.current_account.email if master.current_account else None, + "status": master.status.value + } + else: + result["auto"] = {"has_key": False} + + # Pro 密钥信息 + if keys_info["pro"]: + pro_data = keys_info["pro"] + master = pro_data["master"] + result["pro"] = { + "has_key": True, + "master_key": master.key[:8] + "****", + "quota": pro_data["quota"], + "quota_used": pro_data["quota_used"], + "quota_remaining": pro_data["quota_remaining"], + "merged_count": pro_data["total_keys"], + "expire_at": master.expire_at.strftime("%Y/%m/%d %H:%M:%S") if master.expire_at else None, + "current_account": master.current_account.email if master.current_account else None, + "status": master.status.value + } + else: + result["pro"] = {"has_key": False} + + return result + + +@router.get("/device-keys/detail") +async def get_device_keys_detail(device_id: str = None, membership_type: str = None, db: Session = Depends(get_db)): + """获取设备某类型密钥的详细信息(包括所有合并的密钥)""" + if not device_id: + return {"success": False, "error": "缺少设备ID"} + + if membership_type not in ["auto", "pro", "free"]: + return {"success": False, "error": "无效的密钥类型"} + + # 映射类型 + mem_type = MembershipType.FREE if membership_type in ["auto", "free"] else MembershipType.PRO + + # 获取主密钥 + master = KeyService.get_master_key(db, device_id, mem_type) + if not master: + return {"success": True, "has_key": False, "keys": []} + + # 获取所有合并的密钥 + from app.models import ActivationKey + merged_keys = db.query(ActivationKey).filter( + ActivationKey.master_key_id == master.id + ).order_by(ActivationKey.merged_at.desc()).all() + + keys_list = [] + # 主密钥 + keys_list.append({ + "id": master.id, + "key": master.key[:8] + "****", + "is_master": True, + "status": master.status.value, + "contribution": master.quota_contribution if mem_type == MembershipType.PRO else master.duration_days, + "contribution_type": "积分" if mem_type == MembershipType.PRO else "天", + "activated_at": master.first_activated_at.strftime("%Y/%m/%d %H:%M") if master.first_activated_at else None + }) + + # 合并的密钥 + for k in merged_keys: + keys_list.append({ + "id": k.id, + "key": k.key[:8] + "****", + "is_master": False, + "status": k.status.value, + "contribution": k.quota_contribution if mem_type == MembershipType.PRO else k.duration_days, + "contribution_type": "积分" if mem_type == MembershipType.PRO else "天", + "merged_at": k.merged_at.strftime("%Y/%m/%d %H:%M") if k.merged_at else None + }) + + return { + "success": True, + "has_key": True, + "membership_type": membership_type, + "total_keys": len(keys_list), + "keys": keys_list + } + + # ========== 版本 API ========== @router.get("/version") @@ -479,9 +596,9 @@ async def get_seamless_token_v2(userKey: str = None, key: str = None, req: Reque KeyService.use_switch(db, activation_key) is_new = True - # 记录日志 - if req: - LogService.log(db, activation_key.id, "seamless_get_token", account.id, ip_address=req.client.host, success=True) + # 只记录获取新账号的情况,不记录每次token验证 + if req and is_new: + LogService.log(db, activation_key.id, "seamless_get_token", account.id, ip_address=req.client.host, success=True, message="分配新账号") # 返回格式需要直接包含字段,供注入代码使用 # 注入代码检查: if(d && d.accessToken) { ... } diff --git a/backend/app/config.py b/backend/app/config.py index e891820..786a8e4 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -4,11 +4,11 @@ from typing import Optional class Settings(BaseSettings): # 数据库配置 - USE_SQLITE: bool = True # 设为 False 使用 MySQL - DB_HOST: str = "localhost" + USE_SQLITE: bool = False # 设为 False 使用 MySQL + DB_HOST: str = "127.0.0.1" DB_PORT: int = 3306 - DB_USER: str = "root" - DB_PASSWORD: str = "" + DB_USER: str = "cursorpro" + DB_PASSWORD: str = "jf6BntYBPz6KH6Pw" DB_NAME: str = "cursorpro" # JWT配置 diff --git a/backend/app/models/models.py b/backend/app/models/models.py index 7960ee2..109a118 100644 --- a/backend/app/models/models.py +++ b/backend/app/models/models.py @@ -15,9 +15,12 @@ class AccountStatus(str, enum.Enum): EXPIRED = "expired" # 过期 class KeyStatus(str, enum.Enum): - ACTIVE = "active" - DISABLED = "disabled" - EXPIRED = "expired" + UNUSED = "unused" # 未使用 + ACTIVE = "active" # 已激活(主密钥) + MERGED = "merged" # 已合并到主密钥 + REVOKED = "revoked" # 已撤销 + DISABLED = "disabled" # 禁用 + EXPIRED = "expired" # 过期 class CursorAccount(Base): @@ -50,34 +53,41 @@ class ActivationKey(Base): id = Column(Integer, primary_key=True, autoincrement=True) key = Column(String(64), unique=True, nullable=False, index=True, comment="激活码") - status = Column(Enum(KeyStatus), default=KeyStatus.ACTIVE, comment="状态") + status = Column(Enum(KeyStatus), default=KeyStatus.UNUSED, comment="状态") # 套餐类型 - membership_type = Column(Enum(MembershipType), default=MembershipType.PRO, comment="套餐类型: free=无限auto, pro=高级模型") + membership_type = Column(Enum(MembershipType), default=MembershipType.PRO, comment="套餐类型: free=Auto池, pro=Pro池") - # 额度系统 - quota = Column(Integer, default=500, comment="总额度") - quota_used = Column(Integer, default=0, comment="已用额度") + # 密钥合并关系 + master_key_id = Column(Integer, ForeignKey("activation_keys.id"), nullable=True, comment="主密钥ID(如果已合并)") + device_id = Column(String(255), nullable=True, index=True, comment="绑定的设备ID") - # 有效期设置 - valid_days = Column(Integer, default=30, comment="有效天数(0表示永久)") + # 该密钥贡献的资源 (创建时设置,不变) + duration_days = Column(Integer, default=30, comment="Auto: 该密钥贡献的天数") + quota_contribution = Column(Integer, default=500, comment="Pro: 该密钥贡献的积分") + + # 额度系统 (仅主密钥使用,累计值) + quota = Column(Integer, default=500, comment="Pro主密钥: 总额度(累加)") + quota_used = Column(Integer, default=0, comment="Pro主密钥: 已用额度") + + # 有效期 (仅主密钥使用) + expire_at = Column(DateTime, nullable=True, comment="Auto主密钥: 到期时间(累加)") + + # 激活信息 first_activated_at = Column(DateTime, nullable=True, comment="首次激活时间") - expire_at = Column(DateTime, nullable=True, comment="过期时间(首次激活时计算)") + merged_at = Column(DateTime, nullable=True, comment="合并时间") - # 设备限制 - max_devices = Column(Integer, default=2, comment="最大设备数") + # 设备限制 (可换设备,此字段保留但不强制) + max_devices = Column(Integer, default=3, comment="最大设备数(可换设备)") - # 换号频率限制(已废弃,现由全局设置控制) - switch_interval_minutes = Column(Integer, default=30, comment="[已废弃]换号间隔(分钟)") - switch_limit_per_interval = Column(Integer, default=2, comment="[已废弃]间隔内最大换号次数") - - # 当前绑定的账号 + # 当前绑定的账号 (仅主密钥使用) current_account_id = Column(Integer, ForeignKey("cursor_accounts.id"), nullable=True) current_account = relationship("CursorAccount", foreign_keys=[current_account_id]) - # 统计 + # 统计 (仅主密钥使用) switch_count = Column(Integer, default=0, comment="总换号次数") last_switch_at = Column(DateTime, nullable=True, comment="最后换号时间") + merged_count = Column(Integer, default=0, comment="已合并的密钥数量") # 备注 remark = Column(String(500), nullable=True, comment="备注") @@ -85,6 +95,14 @@ class ActivationKey(Base): created_at = Column(DateTime, server_default=func.now()) updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now()) + # 关系 + master_key = relationship("ActivationKey", remote_side=[id], foreign_keys=[master_key_id]) + + @property + def valid_days(self): + """兼容旧API: duration_days的别名""" + return self.duration_days or 0 + class KeyDevice(Base): """激活码绑定的设备""" diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py index 12122f2..71c4be4 100644 --- a/backend/app/services/__init__.py +++ b/backend/app/services/__init__.py @@ -1,2 +1,11 @@ from app.services.account_service import AccountService, KeyService, LogService, GlobalSettingsService, BatchService from app.services.auth_service import authenticate_admin, create_access_token, get_current_user +from app.services.cursor_usage_service import ( + CursorUsageService, + CursorUsageInfo, + cursor_usage_service, + check_account_valid, + get_account_usage, + batch_check_accounts, + check_and_classify_account +) diff --git a/backend/app/services/account_service.py b/backend/app/services/account_service.py index 680e61f..89c5f53 100644 --- a/backend/app/services/account_service.py +++ b/backend/app/services/account_service.py @@ -121,11 +121,17 @@ class KeyService: if retry == max_retries - 1: raise ValueError(f"无法生成唯一激活码,请重试") + # 根据类型设置默认值 + is_pro = key_data.membership_type == MembershipType.PRO db_key = ActivationKey( key=key_str, + status=KeyStatus.UNUSED, # 新密钥默认未使用 membership_type=key_data.membership_type, - quota=key_data.quota if key_data.membership_type == MembershipType.PRO else 0, # Free不需要额度 - valid_days=key_data.valid_days, + # 该密钥贡献的资源 + duration_days=key_data.valid_days if not is_pro else 0, # Auto贡献天数 + quota_contribution=key_data.quota if is_pro else 0, # Pro贡献积分 + # 主密钥初始值(激活时使用) + quota=key_data.quota if is_pro else 0, max_devices=key_data.max_devices, remark=key_data.remark ) @@ -171,22 +177,139 @@ class KeyService: return False @staticmethod - def activate(db: Session, key: ActivationKey): - """首次激活:设置激活时间和过期时间""" - if key.first_activated_at is None: - key.first_activated_at = datetime.now() - if key.valid_days > 0: - key.expire_at = key.first_activated_at + timedelta(days=key.valid_days) + def activate(db: Session, key: ActivationKey, device_id: str = None) -> Tuple[bool, str, Optional[ActivationKey]]: + """ + 激活密钥 + - 如果设备已有同类型主密钥,则合并(叠加时长/积分) + - 否则,该密钥成为主密钥 + 返回: (成功, 消息, 主密钥) + """ + now = datetime.now() + + # 检查密钥状态 + if key.status == KeyStatus.MERGED: + return False, "该密钥已被合并使用", None + if key.status == KeyStatus.REVOKED: + return False, "该密钥已被撤销", None + if key.status == KeyStatus.DISABLED: + return False, "该密钥已被禁用", None + if key.status == KeyStatus.ACTIVE: + # 已激活的密钥,检查是否是同设备 + if device_id and key.device_id and key.device_id != device_id: + # 换设备激活,更新设备ID + key.device_id = device_id + db.commit() + return True, "密钥已激活", key + + # 查找该设备同类型的主密钥 + master_key = None + if device_id: + master_key = db.query(ActivationKey).filter( + ActivationKey.device_id == device_id, + ActivationKey.membership_type == key.membership_type, + ActivationKey.status == KeyStatus.ACTIVE, + ActivationKey.master_key_id == None # 是主密钥 + ).first() + + if master_key: + # 合并到现有主密钥 + key.status = KeyStatus.MERGED + key.master_key_id = master_key.id + key.merged_at = now + key.device_id = device_id + + # 叠加资源到主密钥 + if key.membership_type == MembershipType.PRO: + # Pro: 叠加积分 + master_key.quota += key.quota_contribution + else: + # Auto: 叠加时长 + if master_key.expire_at: + master_key.expire_at += timedelta(days=key.duration_days) + else: + master_key.expire_at = now + timedelta(days=key.duration_days) + + master_key.merged_count += 1 db.commit() + return True, f"密钥已合并,{'积分' if key.membership_type == MembershipType.PRO else '时长'}已叠加", master_key + else: + # 该密钥成为主密钥 + key.status = KeyStatus.ACTIVE + key.device_id = device_id + key.first_activated_at = now + + # 设置初始到期时间(Auto) + if key.membership_type == MembershipType.FREE and key.duration_days > 0: + key.expire_at = now + timedelta(days=key.duration_days) + + db.commit() + return True, "激活成功", key + + @staticmethod + def get_master_key(db: Session, device_id: str, membership_type: MembershipType) -> Optional[ActivationKey]: + """获取设备的主密钥""" + return db.query(ActivationKey).filter( + ActivationKey.device_id == device_id, + ActivationKey.membership_type == membership_type, + ActivationKey.status == KeyStatus.ACTIVE, + ActivationKey.master_key_id == None + ).first() + + @staticmethod + def get_device_keys(db: Session, device_id: str) -> dict: + """获取设备的所有密钥信息""" + result = {"auto": None, "pro": None} + + # 获取Auto主密钥 + auto_master = KeyService.get_master_key(db, device_id, MembershipType.FREE) + if auto_master: + # 获取合并的密钥 + merged_keys = db.query(ActivationKey).filter( + ActivationKey.master_key_id == auto_master.id + ).all() + result["auto"] = { + "master": auto_master, + "merged_keys": merged_keys, + "total_keys": 1 + len(merged_keys), + "expire_at": auto_master.expire_at + } + + # 获取Pro主密钥 + pro_master = KeyService.get_master_key(db, device_id, MembershipType.PRO) + if pro_master: + merged_keys = db.query(ActivationKey).filter( + ActivationKey.master_key_id == pro_master.id + ).all() + result["pro"] = { + "master": pro_master, + "merged_keys": merged_keys, + "total_keys": 1 + len(merged_keys), + "quota": pro_master.quota, + "quota_used": pro_master.quota_used, + "quota_remaining": pro_master.quota - pro_master.quota_used + } + + return result @staticmethod def is_valid(key: ActivationKey, db: Session) -> Tuple[bool, str]: - """检查激活码是否有效""" - if key.status != KeyStatus.ACTIVE: + """检查激活码是否有效(仅检查主密钥)""" + # 状态检查 + if key.status == KeyStatus.UNUSED: + return False, "激活码未激活" + if key.status == KeyStatus.MERGED: + return False, "该密钥已合并,请使用主密钥" + if key.status == KeyStatus.REVOKED: + return False, "激活码已被撤销" + if key.status == KeyStatus.DISABLED: return False, "激活码已禁用" + if key.status == KeyStatus.EXPIRED: + return False, "激活码已过期" + if key.status != KeyStatus.ACTIVE: + return False, "激活码状态异常" # 检查是否已过期(只有激活后才检查) - if key.first_activated_at and key.expire_at and key.expire_at < datetime.now(): + if key.expire_at and key.expire_at < datetime.now(): return False, "激活码已过期" # Pro套餐检查额度 @@ -292,6 +415,66 @@ class KeyService: key.current_account_id = account.id db.commit() + @staticmethod + def revoke_key(db: Session, key_id: int) -> Tuple[bool, str]: + """ + 撤销密钥 + - 如果是主密钥:不允许直接撤销(需要先撤销所有合并的密钥) + - 如果是合并的密钥:从主密钥扣除贡献的资源 + """ + key = db.query(ActivationKey).filter(ActivationKey.id == key_id).first() + if not key: + return False, "密钥不存在" + + if key.status == KeyStatus.REVOKED: + return False, "密钥已被撤销" + + if key.status == KeyStatus.ACTIVE and key.master_key_id is None: + # 是主密钥,检查是否有合并的密钥 + merged_count = db.query(ActivationKey).filter( + ActivationKey.master_key_id == key.id, + ActivationKey.status == KeyStatus.MERGED + ).count() + if merged_count > 0: + return False, f"该密钥有{merged_count}个合并密钥,请先撤销合并的密钥" + + # 主密钥没有合并密钥,可以直接撤销 + key.status = KeyStatus.REVOKED + db.commit() + return True, "主密钥已撤销" + + elif key.status == KeyStatus.MERGED: + # 是合并的密钥,从主密钥扣除资源 + master = db.query(ActivationKey).filter(ActivationKey.id == key.master_key_id).first() + if not master: + return False, "找不到主密钥" + + if key.membership_type == MembershipType.PRO: + # Pro: 检查扣除后是否会导致已用超额 + new_quota = master.quota - key.quota_contribution + if master.quota_used > new_quota: + return False, f"无法撤销:撤销后剩余额度({new_quota})小于已用额度({master.quota_used})" + master.quota = new_quota + else: + # Auto: 扣除时长 + if master.expire_at: + master.expire_at -= timedelta(days=key.duration_days) + # 检查扣除后是否已过期 + if master.expire_at < datetime.now(): + return False, "无法撤销:撤销后密钥将立即过期" + + master.merged_count -= 1 + key.status = KeyStatus.REVOKED + key.master_key_id = None # 解除关联 + db.commit() + return True, "合并密钥已撤销,资源已扣除" + + else: + # 其他状态(UNUSED, DISABLED 等) + key.status = KeyStatus.REVOKED + db.commit() + return True, "密钥已撤销" + @staticmethod def count(db: Session) -> dict: """统计激活码数量""" diff --git a/backend/app/services/cursor_usage_service.py b/backend/app/services/cursor_usage_service.py new file mode 100644 index 0000000..f1716fb --- /dev/null +++ b/backend/app/services/cursor_usage_service.py @@ -0,0 +1,337 @@ +""" +Cursor 官方用量 API 服务 +用于验证账号有效性和查询用量信息 +""" +import httpx +import asyncio +from typing import Optional, Dict, Any, Tuple, List +from dataclasses import dataclass +from datetime import datetime + + +@dataclass +class CursorUsageInfo: + """Cursor 用量信息""" + is_valid: bool = False # 账号是否有效 + error_message: Optional[str] = None # 错误信息 + + # 用户信息 + user_id: Optional[int] = None + email: Optional[str] = None + team_id: Optional[int] = None + is_enterprise: bool = False + + # 会员信息 + membership_type: str = "free" # free, free_trial, pro, business + billing_cycle_start: Optional[str] = None + billing_cycle_end: Optional[str] = None + days_remaining_on_trial: Optional[int] = None # 试用剩余天数 (free_trial) + + # 套餐用量 + plan_used: int = 0 + plan_limit: int = 0 + plan_remaining: int = 0 + + # Token 用量 + total_input_tokens: int = 0 + total_output_tokens: int = 0 + total_cache_read_tokens: int = 0 + total_cost_cents: float = 0.0 + + # 请求次数 + total_requests: int = 0 # totalUsageEventsCount + + @property + def pool_type(self) -> str: + """ + 判断账号应归入哪个号池 + - 'pro': Pro池 (free_trial, pro, business) + - 'auto': Auto池 (free) + """ + if self.membership_type in ('free_trial', 'pro', 'business'): + return 'pro' + return 'auto' + + @property + def is_pro_trial(self) -> bool: + """是否为 Pro 试用账号""" + return self.membership_type == 'free_trial' + + @property + def is_usable(self) -> bool: + """账号是否可用 (有效且有剩余额度)""" + if not self.is_valid: + return False + # Pro试用和Pro需要检查剩余额度 + if self.pool_type == 'pro': + return self.plan_remaining > 0 + # Auto池 free账号始终可用 + return True + + +class CursorUsageService: + """Cursor 用量查询服务""" + + BASE_URL = "https://cursor.com" + TIMEOUT = 15.0 + + def __init__(self): + self.headers = { + "accept": "*/*", + "content-type": "application/json", + "origin": "https://cursor.com", + "referer": "https://cursor.com/dashboard", + "user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36" + } + + def _get_cookie_header(self, token: str) -> str: + """构造 Cookie Header""" + # 支持直接传 token 或完整 cookie + if token.startswith("WorkosCursorSessionToken="): + return token + return f"WorkosCursorSessionToken={token}" + + async def _request( + self, + method: str, + path: str, + token: str, + json_data: Optional[Dict] = None + ) -> Dict[str, Any]: + """发送请求""" + headers = {**self.headers, "Cookie": self._get_cookie_header(token)} + + async with httpx.AsyncClient(timeout=self.TIMEOUT) as client: + if method == "GET": + resp = await client.get(f"{self.BASE_URL}{path}", headers=headers) + else: + resp = await client.post( + f"{self.BASE_URL}{path}", + headers=headers, + json=json_data or {} + ) + + if resp.status_code == 200: + return {"success": True, "data": resp.json()} + elif resp.status_code in [401, 403]: + return {"success": False, "error": "认证失败,Token 无效或已过期"} + else: + return {"success": False, "error": f"请求失败: {resp.status_code}"} + + async def get_usage_summary(self, token: str) -> Dict[str, Any]: + """获取用量摘要""" + return await self._request("GET", "/api/usage-summary", token) + + async def get_billing_cycle(self, token: str) -> Dict[str, Any]: + """获取当前计费周期""" + return await self._request("POST", "/api/dashboard/get-current-billing-cycle", token, {}) + + async def get_filtered_usage( + self, + token: str, + start_date_ms: str, + end_date_ms: str, + page: int = 1, + page_size: int = 100 + ) -> Dict[str, Any]: + """获取过滤后的使用事件""" + return await self._request( + "POST", + "/api/dashboard/get-filtered-usage-events", + token, + { + "startDate": start_date_ms, + "endDate": end_date_ms, + "page": page, + "pageSize": page_size + } + ) + + async def get_aggregated_usage( + self, + token: str, + start_date_ms: int, + team_id: Optional[int] = None + ) -> Dict[str, Any]: + """获取聚合使用事件""" + data = {"startDate": start_date_ms} + if team_id: + data["teamId"] = team_id + return await self._request( + "POST", + "/api/dashboard/get-aggregated-usage-events", + token, + data + ) + + async def validate_and_get_usage(self, token: str) -> CursorUsageInfo: + """ + 验证账号并获取完整用量信息 + 这是主要的对外接口 + """ + result = CursorUsageInfo() + + try: + # 1. 获取用量摘要 (验证 token 有效性) + summary_resp = await self.get_usage_summary(token) + if not summary_resp["success"]: + result.error_message = summary_resp["error"] + return result + + summary = summary_resp["data"] + result.is_valid = True + result.membership_type = summary.get("membershipType", "free") + result.billing_cycle_start = summary.get("billingCycleStart") + result.billing_cycle_end = summary.get("billingCycleEnd") + result.days_remaining_on_trial = summary.get("daysRemainingOnTrial") # 试用剩余天数 + + # 套餐用量 + individual = summary.get("individualUsage", {}) + plan = individual.get("plan", {}) + result.plan_used = plan.get("used", 0) + result.plan_limit = plan.get("limit", 0) + result.plan_remaining = plan.get("remaining", 0) + + # 2. 获取计费周期 + billing_resp = await self.get_billing_cycle(token) + if billing_resp["success"]: + billing = billing_resp["data"] + start_ms = billing.get("startDateEpochMillis", "0") + end_ms = billing.get("endDateEpochMillis", "0") + + # 3. 获取请求次数 (totalUsageEventsCount) + filtered_resp = await self.get_filtered_usage(token, start_ms, end_ms, 1, 1) + if filtered_resp["success"]: + filtered = filtered_resp["data"] + result.total_requests = filtered.get("totalUsageEventsCount", 0) + + # 4. 获取 Token 用量 + aggregated_resp = await self.get_aggregated_usage(token, int(start_ms)) + if aggregated_resp["success"]: + agg = aggregated_resp["data"] + result.total_input_tokens = int(agg.get("totalInputTokens", "0")) + result.total_output_tokens = int(agg.get("totalOutputTokens", "0")) + result.total_cache_read_tokens = int(agg.get("totalCacheReadTokens", "0")) + result.total_cost_cents = agg.get("totalCostCents", 0.0) + + return result + + except httpx.TimeoutException: + result.error_message = "请求超时" + return result + except Exception as e: + result.error_message = f"请求异常: {str(e)}" + return result + + def validate_and_get_usage_sync(self, token: str) -> CursorUsageInfo: + """同步版本的验证和获取用量""" + return asyncio.run(self.validate_and_get_usage(token)) + + +# 单例 +cursor_usage_service = CursorUsageService() + + +# ============ 便捷函数 ============ + +async def check_account_valid(token: str) -> Tuple[bool, Optional[str]]: + """ + 检查账号是否有效 + 返回: (是否有效, 错误信息) + """ + try: + resp = await cursor_usage_service.get_usage_summary(token) + if resp["success"]: + return True, None + return False, resp["error"] + except Exception as e: + return False, str(e) + + +async def get_account_usage(token: str) -> Dict[str, Any]: + """ + 获取账号用量信息 + 返回格式化的用量数据 + """ + info = await cursor_usage_service.validate_and_get_usage(token) + + if not info.is_valid: + return { + "success": False, + "error": info.error_message + } + + return { + "success": True, + "data": { + "membership_type": info.membership_type, + "pool_type": info.pool_type, # 号池类型: pro/auto + "is_pro_trial": info.is_pro_trial, # 是否Pro试用 + "is_usable": info.is_usable, # 是否可用 + "days_remaining_on_trial": info.days_remaining_on_trial, + "billing_cycle": { + "start": info.billing_cycle_start, + "end": info.billing_cycle_end + }, + "plan_usage": { + "used": info.plan_used, + "limit": info.plan_limit, + "remaining": info.plan_remaining + }, + "token_usage": { + "input_tokens": info.total_input_tokens, + "output_tokens": info.total_output_tokens, + "cache_read_tokens": info.total_cache_read_tokens, + "total_cost_usd": round(info.total_cost_cents / 100, 4) + }, + "total_requests": info.total_requests + } + } + + +async def batch_check_accounts(tokens: List[str]) -> List[Dict[str, Any]]: + """ + 批量检查多个账号 + """ + results = [] + for token in tokens: + info = await cursor_usage_service.validate_and_get_usage(token) + results.append({ + "token": token[:20] + "...", # 脱敏 + "is_valid": info.is_valid, + "is_usable": info.is_usable, + "pool_type": info.pool_type, # pro/auto + "membership_type": info.membership_type if info.is_valid else None, + "days_remaining_on_trial": info.days_remaining_on_trial, + "plan_used": info.plan_used if info.is_valid else 0, + "plan_limit": info.plan_limit if info.is_valid else 0, + "plan_remaining": info.plan_remaining if info.is_valid else 0, + "total_requests": info.total_requests if info.is_valid else 0, + "error": info.error_message + }) + return results + + +async def check_and_classify_account(token: str) -> Dict[str, Any]: + """ + 检查账号并分类到对应号池 + 返回账号信息和推荐的号池 + """ + info = await cursor_usage_service.validate_and_get_usage(token) + + if not info.is_valid: + return { + "success": False, + "error": info.error_message + } + + return { + "success": True, + "pool_type": info.pool_type, # 'pro' 或 'auto' + "is_usable": info.is_usable, # 是否可用 + "membership_type": info.membership_type, + "is_pro_trial": info.is_pro_trial, + "plan_remaining": info.plan_remaining, + "total_requests": info.total_requests, + "recommendation": f"建议放入 {'Pro' if info.pool_type == 'pro' else 'Auto'} 号池" + } diff --git a/backend/test_cursor_service.py b/backend/test_cursor_service.py new file mode 100644 index 0000000..c30cc07 --- /dev/null +++ b/backend/test_cursor_service.py @@ -0,0 +1,104 @@ +""" +测试 CursorUsageService +""" +import asyncio +import sys +sys.path.insert(0, '.') + +from app.services.cursor_usage_service import ( + cursor_usage_service, + get_account_usage, + check_account_valid, + check_and_classify_account +) + +# 测试 Token (free_trial) +TEST_TOKEN = "user_01KCG2G9K4Q37C1PKTNR7EVNGW::eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJhdXRoMHx1c2VyXzAxS0NHMkc5SzRRMzdDMVBLVE5SN0VWTkdXIiwidGltZSI6IjE3NjU3ODc5NjYiLCJyYW5kb21uZXNzIjoiOTA1NTU4NjktYTlmMC00M2NhIiwiZXhwIjoxNzcwOTcxOTY2LCJpc3MiOiJodHRwczovL2F1dGhlbnRpY2F0aW9uLmN1cnNvci5zaCIsInNjb3BlIjoib3BlbmlkIHByb2ZpbGUgZW1haWwgb2ZmbGluZV9hY2Nlc3MiLCJhdWQiOiJodHRwczovL2N1cnNvci5jb20iLCJ0eXBlIjoic2Vzc2lvbiJ9.vreEnprZ7q9pU7b6TTVGQ0HUIQTJrxLXcnkz4Ne4Dng" + + +async def test_check_valid(): + """测试账号有效性检查""" + print("\n========== 1. 检查账号有效性 ==========") + is_valid, error = await check_account_valid(TEST_TOKEN) + print(f"账号有效: {is_valid}") + if error: + print(f"错误: {error}") + + +async def test_get_usage(): + """测试获取用量信息""" + print("\n========== 2. 获取用量信息 ==========") + result = await get_account_usage(TEST_TOKEN) + + if result["success"]: + data = result["data"] + print(f"会员类型: {data['membership_type']}") + if data.get('days_remaining_on_trial'): + print(f"试用剩余天数: {data['days_remaining_on_trial']}") + print(f"计费周期: {data['billing_cycle']['start']} ~ {data['billing_cycle']['end']}") + print(f"套餐用量: {data['plan_usage']['used']}/{data['plan_usage']['limit']} (剩余 {data['plan_usage']['remaining']})") + print(f"总请求次数: {data['total_requests']}") + print(f"Token 用量:") + print(f" - 输入: {data['token_usage']['input_tokens']}") + print(f" - 输出: {data['token_usage']['output_tokens']}") + print(f" - 缓存读取: {data['token_usage']['cache_read_tokens']}") + print(f" - 总费用: ${data['token_usage']['total_cost_usd']}") + else: + print(f"获取失败: {result['error']}") + + +async def test_full_info(): + """测试完整信息""" + print("\n========== 3. 完整验证结果 ==========") + info = await cursor_usage_service.validate_and_get_usage(TEST_TOKEN) + + print(f"账号有效: {info.is_valid}") + print(f"会员类型: {info.membership_type}") + print(f"号池类型: {info.pool_type}") + print(f"是否Pro试用: {info.is_pro_trial}") + print(f"是否可用: {info.is_usable}") + if info.days_remaining_on_trial: + print(f"试用剩余天数: {info.days_remaining_on_trial}") + print(f"套餐用量: {info.plan_used}/{info.plan_limit} (剩余 {info.plan_remaining})") + print(f"总请求次数: {info.total_requests}") + print(f"总输入Token: {info.total_input_tokens}") + print(f"总输出Token: {info.total_output_tokens}") + print(f"总费用: ${info.total_cost_cents / 100:.4f}") + + if info.error_message: + print(f"错误: {info.error_message}") + + +async def test_classify(): + """测试号池分类""" + print("\n========== 4. 号池分类 ==========") + result = await check_and_classify_account(TEST_TOKEN) + + if result["success"]: + print(f"号池类型: {result['pool_type']}") + print(f"是否可用: {result['is_usable']}") + print(f"会员类型: {result['membership_type']}") + print(f"是否Pro试用: {result['is_pro_trial']}") + print(f"剩余额度: {result['plan_remaining']}") + print(f">>> {result['recommendation']}") + else: + print(f"分类失败: {result['error']}") + + +async def main(): + print("=" * 50) + print(" CursorUsageService 测试") + print("=" * 50) + + await test_check_valid() + await test_get_usage() + await test_full_info() + await test_classify() + + print("\n" + "=" * 50) + print(" 测试完成") + print("=" * 50) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/cursor 官方用量接口.md b/cursor 官方用量接口.md new file mode 100644 index 0000000..d497bf0 --- /dev/null +++ b/cursor 官方用量接口.md @@ -0,0 +1,458 @@ +# Cursor 官方用量接口文档 + +> 来源:Vibeviewer 项目逆向分析 + +## 基础配置 + +| 配置项 | 值 | +|--------|-----| +| Base URL | `https://cursor.com` | +| 认证方式 | Cookie Header | + +### 通用 Headers + +```http +accept: */* +content-type: application/json +origin: https://cursor.com +referer: https://cursor.com/dashboard +Cookie: <用户登录后的Cookie> +``` + +--- + +## API 接口 + +### 1. 获取用户信息 + +``` +GET /api/dashboard/get-me +``` + +**请求参数**: 无 (仅需 Cookie) + +**响应示例**: +```json +{ + "authId": "auth_xxxxx", + "userId": 12345, + "email": "user@example.com", + "workosId": "workos_xxxxx", + "teamId": 67890, + "isEnterpriseUser": false +} +``` + +**字段说明**: +| 字段 | 类型 | 说明 | +|------|------|------| +| authId | String | 认证 ID | +| userId | Int | 用户 ID | +| email | String | 用户邮箱 | +| workosId | String | WorkOS ID | +| teamId | Int? | 团队 ID (个人用户为 null) | +| isEnterpriseUser | Bool | 是否企业用户 | + +--- + +### 2. 获取用量摘要 + +``` +GET /api/usage-summary +``` + +**请求参数**: 无 (仅需 Cookie) + +**响应示例**: +```json +{ + "billingCycleStart": "2024-01-01T00:00:00.000Z", + "billingCycleEnd": "2024-02-01T00:00:00.000Z", + "membershipType": "pro", + "limitType": "usage_based", + "individualUsage": { + "plan": { + "used": 150, + "limit": 500, + "remaining": 350, + "breakdown": { + "included": 500, + "bonus": 0, + "total": 500 + } + }, + "onDemand": { + "used": 0, + "limit": null, + "remaining": null, + "enabled": false + } + }, + "teamUsage": { + "onDemand": { + "used": 0, + "limit": 10000, + "remaining": 10000, + "enabled": true + } + } +} +``` + +**会员类型 (membershipType)**: +| 值 | 说明 | 订阅名称 | 模型 | 套餐额度 | +|----|------|----------|------|---------| +| `free` | 免费版 | `free` | `default` | ~0 | +| `free_trial` | **Pro 试用** | `pro-free-trial` | `gpt-5.2-high` | 1000 | +| `pro` | Pro 会员 | `pro` | 高级模型 | 更高 | +| `business` | 商业版 | `business` | 企业级 | 无限 | + +**重要**: `free_trial` 是 Pro 试用账号,拥有完整 Pro 功能,只是有时间限制! + +**free_trial 特点**: +- `customSubscriptionName`: `pro-free-trial` +- 可用模型: `gpt-5.2-high` 等高级模型 +- 套餐额度: 1000 (与 Pro 相同) +- 计费周期: 7天试用期 +- `daysRemainingOnTrial`: 试用剩余天数 + +--- + +### 3. 获取当前计费周期 + +``` +POST /api/dashboard/get-current-billing-cycle +``` + +**请求体**: +```json +{} +``` + +**响应示例**: +```json +{ + "startDateEpochMillis": "1704067200000", + "endDateEpochMillis": "1706745600000" +} +``` + +**字段说明**: +| 字段 | 类型 | 说明 | +|------|------|------| +| startDateEpochMillis | String | 计费周期开始时间 (毫秒时间戳) | +| endDateEpochMillis | String | 计费周期结束时间 (毫秒时间戳) | + +--- + +### 4. 获取过滤后的使用事件 + +``` +POST /api/dashboard/get-filtered-usage-events +``` + +**请求体**: +```json +{ + "startDate": "1704067200000", + "endDate": "1706745600000", + "userId": 12345, + "page": 1, + "pageSize": 100 +} +``` + +**请求参数说明**: +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| startDate | String | 是 | 开始时间 (毫秒时间戳字符串) | +| endDate | String | 是 | 结束时间 (毫秒时间戳字符串) | +| userId | Int | 是 | 用户 ID | +| page | Int | 是 | 页码 (从 1 开始) | +| pageSize | Int | 是 | 每页数量 (建议 100) | + +**响应示例**: +```json +{ + "totalUsageEventsCount": 256, + "usageEventsDisplay": [ + { + "timestamp": "1704500000000", + "model": "gpt-4", + "kind": "chat", + "requestsCosts": 0.05, + "usageBasedCosts": "$0.05", + "isTokenBasedCall": true, + "owningUser": "user@example.com", + "cursorTokenFee": 0.0, + "tokenUsage": { + "inputTokens": 1500, + "outputTokens": 800, + "totalCents": 5.0, + "cacheWriteTokens": 0, + "cacheReadTokens": 200 + } + } + ] +} +``` + +**事件字段说明**: +| 字段 | 类型 | 说明 | +|------|------|------| +| timestamp | String | 事件时间 (毫秒时间戳) | +| model | String | 使用的模型名称 | +| kind | String | 请求类型 (chat/completion 等) | +| requestsCosts | Double? | 请求费用 | +| usageBasedCosts | String | 费用显示字符串 (如 "$0.05") | +| isTokenBasedCall | Bool | 是否按 Token 计费 | +| owningUser | String | 用户邮箱 | +| cursorTokenFee | Double | Cursor Token 费用 | +| tokenUsage | Object | Token 使用详情 | + +**tokenUsage 字段**: +| 字段 | 类型 | 说明 | +|------|------|------| +| inputTokens | Int? | 输入 Token 数 | +| outputTokens | Int? | 输出 Token 数 | +| totalCents | Double? | 总费用 (美分) | +| cacheWriteTokens | Int? | 缓存写入 Token 数 | +| cacheReadTokens | Int? | 缓存读取 Token 数 | + +--- + +### 5. 获取聚合使用事件 + +``` +POST /api/dashboard/get-aggregated-usage-events +``` + +**请求体**: +```json +{ + "teamId": 67890, + "startDate": 1704067200000 +} +``` + +**请求参数说明**: +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| teamId | Int? | 否 | 团队 ID (Pro 个人账号传 null) | +| startDate | Int64 | 是 | 开始时间 (毫秒时间戳,数字类型) | + +**响应示例**: +```json +{ + "aggregations": [ + { + "modelIntent": "gpt-4", + "inputTokens": "150000", + "outputTokens": "75000", + "cacheWriteTokens": "0", + "cacheReadTokens": "5000", + "totalCents": 250.5 + }, + { + "modelIntent": "claude-3-sonnet", + "inputTokens": "80000", + "outputTokens": "40000", + "cacheWriteTokens": "0", + "cacheReadTokens": "2000", + "totalCents": 120.0 + } + ], + "totalInputTokens": "230000", + "totalOutputTokens": "115000", + "totalCacheWriteTokens": "0", + "totalCacheReadTokens": "7000", + "totalCostCents": 370.5 +} +``` + +**聚合字段说明**: +| 字段 | 类型 | 说明 | +|------|------|------| +| modelIntent | String | 模型名称 | +| inputTokens | String | 输入 Token 总数 | +| outputTokens | String | 输出 Token 总数 | +| cacheWriteTokens | String | 缓存写入 Token 总数 | +| cacheReadTokens | String | 缓存读取 Token 总数 | +| totalCents | Double | 该模型总费用 (美分) | + +--- + +## 重要字段说明 + +### totalUsageEventsCount (总请求次数) + +这个字段在 `get-filtered-usage-events` 接口返回,表示计费周期内的**总请求/对话次数**。 + +```json +{ + "totalUsageEventsCount": 6, // 总请求次数 + "usageEventsDisplay": [...] +} +``` + +**用途**: +- 统计用户使用频率 +- 计费系统中的请求次数限制 +- 账号活跃度判断 + +--- + +### 6. 获取团队成员消费 (Team Plan) + +``` +POST /api/dashboard/get-team-spend +``` + +**请求体**: +```json +{ + "teamId": 67890, + "page": 1, + "pageSize": 100, + "sortBy": "name", + "sortDirection": "asc" +} +``` + +**用途**: 获取团队各成员的消费情况,用于计算免费额度 + +--- + +### 7. 获取团队模型分析 (Team Plan) + +``` +POST /api/dashboard/get-team-models-analytics +``` + +**请求体**: +```json +{ + "startDate": "2024-01-01", + "endDate": "2024-01-07", + "c": "team_id" +} +``` + +**用途**: 获取团队模型使用分析数据 + +--- + +## 使用示例 + +### JavaScript/Node.js + +```javascript +const axios = require('axios'); + +const cookie = 'your_cookie_here'; + +// 获取用户信息 +async function getMe() { + const res = await axios.get('https://cursor.com/api/dashboard/get-me', { + headers: { + 'Cookie': cookie, + 'accept': '*/*', + 'referer': 'https://cursor.com/dashboard' + } + }); + return res.data; +} + +// 获取用量摘要 +async function getUsageSummary() { + const res = await axios.get('https://cursor.com/api/usage-summary', { + headers: { + 'Cookie': cookie, + 'accept': '*/*', + 'referer': 'https://cursor.com/dashboard?tab=usage' + } + }); + return res.data; +} + +// 获取使用事件 +async function getFilteredUsageEvents(userId, startDate, endDate, page = 1) { + const res = await axios.post( + 'https://cursor.com/api/dashboard/get-filtered-usage-events', + { + startDate: startDate, + endDate: endDate, + userId: userId, + page: page, + pageSize: 100 + }, + { + headers: { + 'Cookie': cookie, + 'Content-Type': 'application/json', + 'accept': '*/*', + 'origin': 'https://cursor.com', + 'referer': 'https://cursor.com/dashboard' + } + } + ); + return res.data; +} +``` + +### Python + +```python +import requests + +cookie = 'your_cookie_here' +headers = { + 'Cookie': cookie, + 'accept': '*/*', + 'content-type': 'application/json', + 'origin': 'https://cursor.com', + 'referer': 'https://cursor.com/dashboard' +} + +# 获取用户信息 +def get_me(): + res = requests.get('https://cursor.com/api/dashboard/get-me', headers=headers) + return res.json() + +# 获取用量摘要 +def get_usage_summary(): + res = requests.get('https://cursor.com/api/usage-summary', headers=headers) + return res.json() + +# 获取使用事件 +def get_filtered_usage_events(user_id, start_date, end_date, page=1): + data = { + 'startDate': start_date, + 'endDate': end_date, + 'userId': user_id, + 'page': page, + 'pageSize': 100 + } + res = requests.post( + 'https://cursor.com/api/dashboard/get-filtered-usage-events', + json=data, + headers=headers + ) + return res.json() +``` + +--- + +## 注意事项 + +1. **Cookie 获取**: 需要从浏览器登录 Cursor Dashboard 后获取 Cookie +2. **时间戳格式**: 大部分接口使用毫秒时间戳,注意区分字符串和数字类型 +3. **分页**: `get-filtered-usage-events` 支持分页,每页最多 100 条 +4. **账号类型**: 部分接口 (如 team-spend) 仅适用于团队账号 +5. **费用单位**: `totalCents` 字段单位为美分,需除以 100 得到美元 + +--- + +## 更新日志 + +- 2024-12: 初始版本,来源 Vibeviewer 项目 diff --git a/docs/系统设计文档.md b/docs/系统设计文档.md new file mode 100644 index 0000000..bb269b9 --- /dev/null +++ b/docs/系统设计文档.md @@ -0,0 +1,911 @@ +# 蜂鸟Pro 系统设计文档 v2.0 + +## 一、系统概述 + +蜂鸟Pro 是一个 Cursor 账号管理与智能换号工具,支持双账号池(Auto/Pro)、无感换号、用量监控等功能。 + +### 1.1 核心功能 + +| 功能 | 描述 | +|------|------| +| 双账号池 | Auto池(按时间计费)+ Pro池(按积分计费) | +| 无感换号 | 注入代码实现不重启切换账号 | +| 用量监控 | 实时获取 Cursor 官方用量数据 | +| 智能换号 | 根据用量自动触发换号 | +| 密钥合并 | 多个密钥合并到主密钥,累加资源 | + +--- + +## 二、数据模型设计 + +### 2.1 账号表 (cursor_accounts) + +```sql +CREATE TABLE cursor_accounts ( + id INT PRIMARY KEY AUTO_INCREMENT, + email VARCHAR(255) NOT NULL COMMENT '账号邮箱', + token TEXT NOT NULL COMMENT '认证Token (user_id::jwt)', + password VARCHAR(255) COMMENT '账号密码(可选)', + + -- 状态管理 + status ENUM('pending', 'analyzing', 'available', 'in_use', 'exhausted', 'invalid', 'disabled') + DEFAULT 'pending' COMMENT '账号状态', + + -- 账号类型 (从Cursor API自动分析得出) + account_type ENUM('free_trial', 'pro', 'free', 'business', 'unknown') + DEFAULT 'unknown' COMMENT '账号类型', + + -- 用量信息 (从Cursor API获取) + membership_type VARCHAR(50) COMMENT '会员类型原始值', + billing_cycle_start DATETIME COMMENT '计费周期开始', + billing_cycle_end DATETIME COMMENT '计费周期结束', + trial_days_remaining INT DEFAULT 0 COMMENT '试用剩余天数', + + -- 用量统计 + usage_limit INT DEFAULT 0 COMMENT '用量上限', + usage_used INT DEFAULT 0 COMMENT '已用用量', + usage_remaining INT DEFAULT 0 COMMENT '剩余用量', + usage_percent DECIMAL(5,2) DEFAULT 0 COMMENT '用量百分比', + + -- 详细用量 (从聚合API获取) + total_requests INT DEFAULT 0 COMMENT '总请求次数', + total_input_tokens BIGINT DEFAULT 0 COMMENT '总输入Token', + total_output_tokens BIGINT DEFAULT 0 COMMENT '总输出Token', + total_cost_cents DECIMAL(10,2) DEFAULT 0 COMMENT '总花费(美分)', + + -- 锁定信息 + locked_by_key_id INT COMMENT '被哪个激活码锁定', + locked_at DATETIME COMMENT '锁定时间', + + -- 分析信息 + last_analyzed_at DATETIME COMMENT '最后分析时间', + analyze_error VARCHAR(500) COMMENT '分析错误信息', + + -- 元数据 + remark VARCHAR(500) COMMENT '备注', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + + INDEX idx_status (status), + INDEX idx_account_type (account_type), + INDEX idx_locked_by (locked_by_key_id) +); +``` + +### 2.2 账号状态流转 + +``` +pending (待分析) + ↓ 后台分析任务 +analyzing (分析中) + ↓ 分析成功 +available (可用) + ↓ 被激活码锁定 +in_use (使用中) + ↓ 用量耗尽 / 手动释放 +exhausted (已耗尽) / available (可用) + +异常状态: +- invalid: Token无效/过期 +- disabled: 管理员禁用 +``` + +### 2.3 激活码表 (activation_keys) + +```sql +CREATE TABLE activation_keys ( + id INT PRIMARY KEY AUTO_INCREMENT, + `key` VARCHAR(64) NOT NULL UNIQUE COMMENT '激活码', + + -- 状态 + status ENUM('unused', 'active', 'expired', 'disabled') DEFAULT 'unused' COMMENT '状态', + + -- 套餐类型 + membership_type ENUM('auto', 'pro') DEFAULT 'pro' COMMENT '套餐类型', + + -- 密钥合并 (支持多密钥合并到主密钥) + master_key_id INT COMMENT '主密钥ID (如果已合并到其他密钥)', + merged_count INT DEFAULT 0 COMMENT '已合并的子密钥数量', + merged_at DATETIME COMMENT '合并时间', + + -- 设备绑定 + device_id VARCHAR(255) COMMENT '绑定的设备ID', + + -- ===== Auto密钥专属字段 ===== + duration_days INT DEFAULT 30 COMMENT '该密钥贡献的天数', + expire_at DATETIME COMMENT '到期时间 (首次激活时计算)', + + -- ===== Pro密钥专属字段 ===== + quota_contribution INT DEFAULT 500 COMMENT '该密钥贡献的积分', + quota INT DEFAULT 500 COMMENT '总积分 (主密钥累加值)', + quota_used INT DEFAULT 0 COMMENT '已用积分', + + -- ===== 无感换号 ===== + seamless_enabled TINYINT(1) DEFAULT 0 COMMENT '是否启用无感换号', + current_account_id INT COMMENT '当前使用的账号ID', + + -- ===== 统计 ===== + switch_count INT DEFAULT 0 COMMENT '总换号次数', + last_switch_at DATETIME COMMENT '最后换号时间', + + -- ===== 设备限制 ===== + max_devices INT DEFAULT 2 COMMENT '最大设备数', + + -- 激活信息 + first_activated_at DATETIME COMMENT '首次激活时间', + last_active_at DATETIME COMMENT '最后活跃时间', + + -- 备注 + remark VARCHAR(500) COMMENT '备注', + + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + + INDEX idx_status (status), + INDEX idx_membership_type (membership_type), + INDEX idx_device_id (device_id), + INDEX idx_master_key_id (master_key_id), + FOREIGN KEY (master_key_id) REFERENCES activation_keys(id), + FOREIGN KEY (current_account_id) REFERENCES cursor_accounts(id) +); +``` + +### 2.4 设备绑定表 (key_devices) + +```sql +CREATE TABLE key_devices ( + id INT PRIMARY KEY AUTO_INCREMENT, + key_id INT NOT NULL COMMENT '激活码ID', + device_id VARCHAR(255) NOT NULL COMMENT '设备标识', + device_name VARCHAR(255) COMMENT '设备名称', + platform VARCHAR(50) COMMENT '平台: windows/macos/linux', + last_active_at DATETIME COMMENT '最后活跃时间', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + + UNIQUE KEY uk_key_device (key_id, device_id), + FOREIGN KEY (key_id) REFERENCES activation_keys(id) +); +``` + +### 2.5 使用日志表 (usage_logs) + +```sql +CREATE TABLE usage_logs ( + id INT PRIMARY KEY AUTO_INCREMENT, + key_id INT NOT NULL COMMENT '激活码ID', + account_id INT COMMENT '账号ID', + + action ENUM('activate', 'verify', 'enable_seamless', 'disable_seamless', + 'switch', 'auto_switch', 'release', 'merge') NOT NULL COMMENT '操作类型', + + success TINYINT(1) DEFAULT 1 COMMENT '是否成功', + message VARCHAR(500) COMMENT '消息', + + -- 请求信息 + ip_address VARCHAR(50), + user_agent VARCHAR(500), + device_id VARCHAR(255), + + -- 用量快照 (换号时记录) + usage_snapshot JSON COMMENT '用量快照', + + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + + INDEX idx_key_id (key_id), + INDEX idx_action (action), + INDEX idx_created_at (created_at) +); +``` + +### 2.6 全局设置表 (global_settings) + +```sql +CREATE TABLE global_settings ( + id INT PRIMARY KEY AUTO_INCREMENT, + `key` VARCHAR(100) NOT NULL UNIQUE COMMENT '设置键', + value VARCHAR(500) NOT NULL COMMENT '设置值', + value_type ENUM('string', 'int', 'float', 'bool', 'json') DEFAULT 'string', + description VARCHAR(500) COMMENT '描述', + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP +); + +-- 默认设置 +INSERT INTO global_settings (`key`, value, value_type, description) VALUES +('auto_switch_threshold', '98', 'int', 'Auto池自动换号阈值(用量百分比)'), +('pro_switch_threshold', '98', 'int', 'Pro池自动换号阈值(用量百分比)'), +('account_analyze_interval', '300', 'int', '账号分析间隔(秒)'), +('max_switch_per_day', '50', 'int', '每日最大换号次数'), +('auto_daily_switches', '999', 'int', 'Auto密钥每日换号次数限制'), +('pro_quota_per_switch', '1', 'int', 'Pro密钥每次换号消耗积分'); +``` + +--- + +## 三、业务流程设计 + +### 3.1 账号添加与分析流程 + +``` +┌─────────────────────────────────────────────────────────────┐ +│ 管理员添加账号 │ +└─────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ 1. 保存账号,状态 = pending │ +└─────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ 2. 后台定时任务 (每5分钟扫描 pending/available 状态账号) │ +└─────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ 3. 调用 Cursor API 分析账号 │ +│ ├─ GET /api/usage-summary │ +│ │ → membershipType, usageLimit, usageUsed │ +│ │ → daysRemainingOnTrial, billingCycle │ +│ │ │ +│ └─ POST /api/dashboard/get-aggregated-usage-events │ +│ → totalRequests, totalCostCents │ +└─────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ 4. 更新账号信息 │ +│ ├─ account_type = 根据 membershipType 判断 │ +│ ├─ 更新所有用量字段 │ +│ ├─ last_analyzed_at = NOW() │ +│ └─ status = available (如果用量未耗尽) │ +│ = exhausted (如果用量已耗尽) │ +│ = invalid (如果Token无效) │ +└─────────────────────────────────────────────────────────────┘ +``` + +### 3.2 密钥激活流程 + +``` +┌─────────────────────────────────────────────────────────────┐ +│ 用户输入激活码 │ +└─────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ 1. 验证激活码 │ +│ ├─ 检查激活码是否存在 │ +│ ├─ 检查状态是否为 unused 或 active │ +│ └─ 检查是否过期 (Auto: expire_at, Pro: quota剩余) │ +└─────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ 2. 处理设备绑定 │ +│ ├─ 检查当前设备数是否超过 max_devices │ +│ ├─ 如果是新设备,添加到 key_devices │ +│ └─ 更新 last_active_at │ +└─────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ 3. 首次激活处理 │ +│ ├─ 如果 first_activated_at 为空: │ +│ │ ├─ Auto: 计算 expire_at = NOW() + duration_days │ +│ │ └─ 设置 first_activated_at = NOW() │ +│ └─ 更新 status = active │ +└─────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ 4. 返回激活结果 (不分配账号!) │ +│ { │ +│ success: true, │ +│ membership_type: "auto" / "pro", │ +│ expire_at: "2025-12-25 18:00:00", // Auto │ +│ quota: 500, quota_used: 0, // Pro │ +│ seamless_enabled: false │ +│ } │ +└─────────────────────────────────────────────────────────────┘ +``` + +### 3.3 启用无感换号流程 + +``` +┌─────────────────────────────────────────────────────────────┐ +│ 用户点击"启用无感换号" │ +└─────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ 1. 前端: 注入代码到 Cursor workbench.js │ +│ ├─ 检查是否有写入权限 │ +│ ├─ 备份原文件 │ +│ └─ 注入换号代码 │ +└─────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ 2. 前端: 调用后端 API 启用无感 │ +│ POST /api/client/enable-seamless │ +│ { key, device_id } │ +└─────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ 3. 后端: 分配账号 │ +│ ├─ 根据密钥类型选择账号池: │ +│ │ ├─ Auto密钥 → 优先 free_trial 类型账号 │ +│ │ └─ Pro密钥 → 优先 pro 类型账号 │ +│ ├─ 从 available 状态账号中选择用量最低的 │ +│ ├─ 锁定账号: locked_by_key_id = key.id, status = in_use │ +│ └─ 更新密钥: current_account_id, seamless_enabled = 1 │ +└─────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ 4. 返回账号信息 │ +│ { │ +│ success: true, │ +│ account: { │ +│ email: "xxx@gmail.com", │ +│ token: "user_id::jwt_token", │ +│ membership_type: "free_trial", │ +│ trial_days_remaining: 6, │ +│ usage_percent: 20, │ +│ total_requests: 201, │ +│ total_cost_usd: 12.63 │ +│ } │ +│ } │ +└─────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ 5. 前端: 显示账号用量模块,提示重启 Cursor │ +└─────────────────────────────────────────────────────────────┘ +``` + +### 3.4 换号流程 (手动/自动) + +``` +┌─────────────────────────────────────────────────────────────┐ +│ 手动换号 / 自动换号 (用量超阈值) │ +└─────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ 1. 检查换号条件 │ +│ ├─ 密钥是否有效 │ +│ ├─ 是否启用了无感换号 │ +│ ├─ Pro密钥: 检查剩余积分是否足够 │ +│ └─ 检查今日换号次数是否超限 │ +└─────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ 2. 释放当前账号 │ +│ ├─ 获取当前账号用量快照 (用于日志) │ +│ ├─ 判断账号状态: │ +│ │ ├─ 用量 < 90% → status = available │ +│ │ └─ 用量 >= 90% → status = exhausted │ +│ └─ 清除锁定: locked_by_key_id = NULL │ +└─────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ 3. 分配新账号 (同启用无感流程) │ +└─────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ 4. 更新统计 │ +│ ├─ Pro密钥: quota_used += 1 │ +│ ├─ switch_count += 1 │ +│ ├─ last_switch_at = NOW() │ +│ └─ 记录日志到 usage_logs │ +└─────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ 5. 返回新账号信息 │ +└─────────────────────────────────────────────────────────────┘ +``` + +### 3.5 密钥合并流程 + +``` +┌─────────────────────────────────────────────────────────────┐ +│ 用户输入新密钥到已激活的设备 │ +└─────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ 1. 检查合并条件 │ +│ ├─ 新密钥状态必须是 unused │ +│ ├─ 新密钥类型必须与主密钥相同 (Auto+Auto / Pro+Pro) │ +│ └─ 检查主密钥是否已被合并过 (已合并的不能再当主密钥) │ +└─────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ 2. 执行合并 │ +│ ├─ 新密钥: master_key_id = 主密钥ID, merged_at = NOW() │ +│ ├─ 新密钥: status = active │ +│ │ │ +│ ├─ 主密钥 (Auto): expire_at += 新密钥.duration_days │ +│ └─ 主密钥 (Pro): quota += 新密钥.quota_contribution │ +│ │ +│ └─ 主密钥: merged_count += 1 │ +└─────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ 3. 返回合并结果 │ +│ { │ +│ success: true, │ +│ message: "密钥已合并", │ +│ new_expire_at / new_quota: ... │ +│ } │ +└─────────────────────────────────────────────────────────────┘ +``` + +--- + +## 四、API 接口设计 + +### 4.1 客户端 API (/api/client/*) + +#### 4.1.1 验证/激活密钥 +``` +POST /api/client/activate +Request: +{ + "key": "XXXX-XXXX-XXXX", + "device_id": "machine_id_hash" +} + +Response (成功): +{ + "success": true, + "data": { + "key": "XXXX-XXXX-XXXX", + "membership_type": "auto", // auto | pro + "status": "active", + + // Auto密钥 + "expire_at": "2025-12-25T18:00:00Z", + "days_remaining": 7, + + // Pro密钥 + "quota": 500, + "quota_used": 50, + "quota_remaining": 450, + + // 无感状态 + "seamless_enabled": false, + "current_account": null + } +} + +Response (失败): +{ + "success": false, + "error": "激活码无效", + "code": "INVALID_KEY" +} +``` + +#### 4.1.2 启用无感换号 +``` +POST /api/client/enable-seamless +Request: +{ + "key": "XXXX-XXXX-XXXX", + "device_id": "machine_id_hash" +} + +Response: +{ + "success": true, + "data": { + "account": { + "email": "user@gmail.com", + "token": "user_id::jwt_token", + "account_type": "free_trial", + "membership_type": "free_trial", + "trial_days_remaining": 6, + "usage_percent": 20.5, + "total_requests": 201, + "total_cost_usd": 12.63 + } + } +} +``` + +#### 4.1.3 禁用无感换号 +``` +POST /api/client/disable-seamless +Request: +{ + "key": "XXXX-XXXX-XXXX" +} + +Response: +{ + "success": true, + "message": "无感换号已禁用" +} +``` + +#### 4.1.4 手动换号 +``` +POST /api/client/switch +Request: +{ + "key": "XXXX-XXXX-XXXX" +} + +Response: +{ + "success": true, + "data": { + "old_account": "old@gmail.com", + "new_account": { + "email": "new@gmail.com", + "token": "user_id::jwt_token", + ... + }, + "switch_count": 5, + "quota_remaining": 495 // Pro密钥 + } +} +``` + +#### 4.1.5 获取状态 +``` +GET /api/client/status?key=XXXX-XXXX-XXXX + +Response: +{ + "success": true, + "data": { + "key_info": { + "membership_type": "auto", + "status": "active", + "expire_at": "2025-12-25T18:00:00Z", + "days_remaining": 7, + "seamless_enabled": true, + "switch_count": 3 + }, + "account_info": { // 仅当 seamless_enabled=true + "email": "user@gmail.com", + "account_type": "free_trial", + "trial_days_remaining": 6, + "usage_percent": 20.5, + "total_requests": 201, + "total_cost_usd": 12.63, + "last_analyzed_at": "2025-12-18T14:12:35Z" + } + } +} +``` + +#### 4.1.6 获取账号用量 (实时) +``` +GET /api/client/account-usage?key=XXXX-XXXX-XXXX&refresh=true + +Response: +{ + "success": true, + "data": { + "email": "user@gmail.com", + "membership_type": "free_trial", + "trial_days_remaining": 6, + "billing_cycle": { + "start": "2025-12-15T00:00:00Z", + "end": "2026-01-15T00:00:00Z" + }, + "usage": { + "limit": 1000, + "used": 201, + "remaining": 799, + "percent": 20.1 + }, + "cost": { + "total_requests": 201, + "total_input_tokens": 346883, + "total_output_tokens": 45356, + "total_cost_usd": 12.63 + }, + "updated_at": "2025-12-18T14:12:35Z" + } +} +``` + +### 4.2 管理后台 API (/api/admin/*) + +#### 4.2.1 账号管理 +``` +GET /api/admin/accounts # 账号列表 +POST /api/admin/accounts # 添加账号 (只需token) +GET /api/admin/accounts/{id} # 账号详情 +PUT /api/admin/accounts/{id} # 更新账号 +DELETE /api/admin/accounts/{id} # 删除账号 +POST /api/admin/accounts/{id}/analyze # 手动分析账号 +POST /api/admin/accounts/batch # 批量添加账号 +``` + +#### 4.2.2 激活码管理 +``` +GET /api/admin/keys # 激活码列表 +POST /api/admin/keys/generate # 生成激活码 +GET /api/admin/keys/{id} # 激活码详情 +PUT /api/admin/keys/{id} # 更新激活码 +DELETE /api/admin/keys/{id} # 删除激活码 +POST /api/admin/keys/{id}/extend # 延期/加积分 +``` + +--- + +## 五、前端界面设计 + +### 5.1 控制面板布局 + +``` +┌─────────────────────────────────────────────────────────────┐ +│ 🔐 软件授权 已授权 │ +├─────────────────────────────────────────────────────────────┤ +│ │ +│ ┌───────────────────┐ ┌───────────────────┐ │ +│ │ 🌿 Auto │ │ ⚡ Pro │ │ +│ │ 基础模型·无限换号 │ │ 高级模型·积分制 │ │ +│ │ 已激活 │ │ 已激活 │ │ +│ └───────────────────┘ └───────────────────┘ │ +│ │ +│ [请输入CDK激活码 ] [激活] │ +│ │ +│ ┌─ AUTO 密钥 ──────────┐ ┌─ PRO 密钥 ──────────┐ │ +│ │ HIOR03M0GT8VDTL**** │ │ LAXFY1EY7QZJ9C3L****│ │ +│ │ 到期: 2025/12/19 │ │ 积分: 450/500 │ │ +│ │ 18:49:36 │ │ │ │ +│ │ [清除] │ │ [清除] │ │ +│ └──────────────────────┘ └─────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────┘ + +┌─────────────────────────────────────────────────────────────┐ +│ ⚡ 无感换号 已启用 │ +├─────────────────────────────────────────────────────────────┤ +│ │ +│ 当前账号 user123@gmail.com │ +│ │ +│ 使用池 ● Auto池 ○ Pro池 │ +│ │ +│ 免魔法模式 PRO [====○ ] │ +│ │ +│ [ 一键换号 (Auto无限/Pro扣1积分) ] │ +│ │ +│ [重置机器码] [禁用无感换号] │ +│ │ +└─────────────────────────────────────────────────────────────┘ + +┌─────────────────────────────────────────────────────────────┐ +│ 📊 账号用量 [🔄] │ +├─────────────────────────────────────────────────────────────┤ +│ │ +│ ┌─────────────────┬─────────────────┐ │ +│ │ 会员类型 │ 免费试用 │ │ +│ ├─────────────────┼─────────────────┤ │ +│ │ 试用剩余 │ 6 天 │ │ +│ ├─────────────────┼─────────────────┤ │ +│ │ 请求次数 │ 201 次 │ │ +│ ├─────────────────┼─────────────────┤ │ +│ │ 已用额度 │ $12.63 │ │ +│ ├─────────────────┼─────────────────┤ │ +│ │ 用量百分比 │ ████░░░░ 20% │ │ +│ └─────────────────┴─────────────────┘ │ +│ │ +│ 更新于 14:12:35 │ +│ │ +└─────────────────────────────────────────────────────────────┘ + +┌─────────────────────────────────────────────────────────────┐ +│ 📢 公告 通知 │ +├─────────────────────────────────────────────────────────────┤ +│ 欢迎使用蜂鸟Pro │ +│ 感谢使用蜂鸟Pro! │ +│ │ +│ 如有问题请联系客服。 │ +│ 2024/12/17 00:00 │ +└─────────────────────────────────────────────────────────────┘ +``` + +### 5.2 状态显示规则 + +| 条件 | 显示内容 | +|------|----------| +| 未激活任何密钥 | "请先激活授权码", 启用无感按钮禁用 | +| 已激活Auto | 显示Auto密钥卡片, 到期时间 | +| 已激活Pro | 显示Pro密钥卡片, 积分 xxx/xxx | +| 已激活但未启用无感 | "账号用量"模块隐藏 | +| 已启用无感 | 显示当前账号邮箱, 显示"账号用量"模块 | + +--- + +## 六、注入代码设计 + +### 6.1 注入位置 +``` +Cursor安装目录/resources/app/out/vs/workbench/workbench.desktop.main.js +``` + +### 6.2 注入代码功能 +```javascript +// 注入的代码 (伪代码) +(function() { + const API_BASE = 'https://api.aicode.edu.pl'; + const CHECK_INTERVAL = 60000; // 每60秒检查一次 + + // 从 localStorage 读取配置 + const config = JSON.parse(localStorage.getItem('hummingbird_config') || '{}'); + + // 定时检查用量 + setInterval(async () => { + if (!config.enabled) return; + + try { + // 获取用量 + const usage = await fetch(`${API_BASE}/api/client/account-usage?key=${config.key}`); + const data = await usage.json(); + + // 检查是否需要换号 + if (data.usage.percent >= config.threshold) { + // 自动换号 + const switchRes = await fetch(`${API_BASE}/api/client/switch`, { + method: 'POST', + body: JSON.stringify({ key: config.key }) + }); + const newAccount = await switchRes.json(); + + // 更新本地 Token + if (newAccount.success) { + updateLocalToken(newAccount.data.new_account.token); + } + } + } catch (e) { + console.error('[HummingbirdPro] Check failed:', e); + } + }, CHECK_INTERVAL); + + function updateLocalToken(token) { + // 更新 Cursor 的认证存储 + // ... + } +})(); +``` + +--- + +## 七、后台定时任务 + +### 7.1 账号分析任务 +```python +# 每5分钟执行一次 +async def analyze_accounts_task(): + """分析待处理和可用状态的账号""" + + # 获取需要分析的账号 + accounts = db.query(CursorAccount).filter( + CursorAccount.status.in_(['pending', 'available']), + or_( + CursorAccount.last_analyzed_at == None, + CursorAccount.last_analyzed_at < datetime.now() - timedelta(minutes=30) + ) + ).limit(10).all() + + for account in accounts: + try: + # 调用 Cursor API + usage_data = await cursor_api.get_usage_summary(account.token) + aggregated = await cursor_api.get_aggregated_usage(account.token) + + # 更新账号信息 + account.account_type = map_membership_type(usage_data['membershipType']) + account.membership_type = usage_data['membershipType'] + account.trial_days_remaining = usage_data.get('daysRemainingOnTrial', 0) + account.usage_limit = usage_data['individualUsage']['plan']['limit'] + account.usage_used = usage_data['individualUsage']['plan']['used'] + account.usage_percent = (account.usage_used / account.usage_limit * 100) if account.usage_limit > 0 else 0 + account.total_requests = aggregated.get('totalRequests', 0) + account.total_cost_cents = aggregated.get('totalCostCents', 0) + account.last_analyzed_at = datetime.now() + + # 更新状态 + if account.usage_percent >= 95: + account.status = AccountStatus.EXHAUSTED + elif account.status == AccountStatus.PENDING: + account.status = AccountStatus.AVAILABLE + + except TokenInvalidError: + account.status = AccountStatus.INVALID + account.analyze_error = "Token无效或已过期" + except Exception as e: + account.analyze_error = str(e) + + db.commit() +``` + +--- + +## 八、错误码定义 + +| 错误码 | 描述 | +|--------|------| +| INVALID_KEY | 激活码无效 | +| KEY_EXPIRED | 激活码已过期 | +| KEY_DISABLED | 激活码已禁用 | +| QUOTA_EXCEEDED | 积分不足 | +| DEVICE_LIMIT | 设备数超限 | +| NO_AVAILABLE_ACCOUNT | 无可用账号 | +| SEAMLESS_NOT_ENABLED | 未启用无感换号 | +| SWITCH_LIMIT_EXCEEDED | 换号次数超限 | +| ACCOUNT_LOCKED | 账号已被锁定 | +| TOKEN_INVALID | Token无效 | + +--- + +## 九、部署配置 + +### 9.1 环境变量 +```bash +# 数据库 +USE_SQLITE=false +DB_HOST=127.0.0.1 +DB_PORT=3306 +DB_USER=cursorpro +DB_PASSWORD=xxx +DB_NAME=cursorpro + +# JWT +SECRET_KEY=your-secret-key +ACCESS_TOKEN_EXPIRE_MINUTES=10080 + +# 管理员 +ADMIN_USERNAME=admin +ADMIN_PASSWORD=your-password + +# API +API_TOKEN=your-api-token +``` + +### 9.2 依赖 +``` +fastapi +uvicorn +sqlalchemy +pymysql +pydantic +httpx +apscheduler +``` + +--- + +## 十、开发计划 + +### Phase 1: 后端重构 +1. [ ] 更新数据模型 (models.py) +2. [ ] 实现 Cursor API 服务 (cursor_usage_service.py) +3. [ ] 重写账号服务 (account_service.py) +4. [ ] 重写客户端 API (client.py) +5. [ ] 添加定时任务 (tasks.py) + +### Phase 2: 前端优化 +1. [ ] 重构 panel.html 界面 +2. [ ] 修复状态显示逻辑 +3. [ ] 添加账号用量模块 +4. [ ] 优化错误提示 + +### Phase 3: 测试与部署 +1. [ ] 单元测试 +2. [ ] 集成测试 +3. [ ] 部署到生产环境 diff --git a/extension_clean/hummingbird-cursorpro-2.0.0.vsix b/extension_clean/hummingbird-cursorpro-2.0.0.vsix index 8a32f9f..086fae2 100644 Binary files a/extension_clean/hummingbird-cursorpro-2.0.0.vsix and b/extension_clean/hummingbird-cursorpro-2.0.0.vsix differ diff --git a/extension_clean/hummingbird-pro-2.0.1.vsix b/extension_clean/hummingbird-pro-2.0.1.vsix new file mode 100644 index 0000000..f0ca4c2 Binary files /dev/null and b/extension_clean/hummingbird-pro-2.0.1.vsix differ diff --git a/extension_clean/out/api/client.js b/extension_clean/out/api/client.js index 8947003..2b7c691 100644 --- a/extension_clean/out/api/client.js +++ b/extension_clean/out/api/client.js @@ -1,7 +1,7 @@ 'use strict'; // ============================================ -// CursorPro API Client - 反混淆版本 +// 蜂鸟Pro API Client // ============================================ Object.defineProperty(exports, "__esModule", { value: true }); @@ -19,7 +19,7 @@ let onlineStatusCallbacks = []; * 获取 API URL (从配置或使用默认值) */ function getApiUrl() { - const config = vscode.workspace.getConfiguration('cursorpro'); + const config = vscode.workspace.getConfiguration('hummingbird'); return config.get('apiUrl') || DEFAULT_API_URL; } exports.getApiUrl = getApiUrl; diff --git a/extension_clean/out/extension.js b/extension_clean/out/extension.js index ab05132..f9ad5c5 100644 --- a/extension_clean/out/extension.js +++ b/extension_clean/out/extension.js @@ -1,7 +1,7 @@ 'use strict'; // ============================================ -// CursorPro Extension - 反混淆版本 +// 蜂鸟Pro Extension // ============================================ Object.defineProperty(exports, "__esModule", { value: true }); @@ -14,7 +14,7 @@ const path = require('path'); let usageStatusBarItem; // 创建输出通道 -exports.outputChannel = vscode.window.createOutputChannel('CursorPro'); +exports.outputChannel = vscode.window.createOutputChannel('蜂鸟Pro'); /** * 日志函数 @@ -22,7 +22,7 @@ exports.outputChannel = vscode.window.createOutputChannel('CursorPro'); function log(message) { const timestamp = new Date().toLocaleTimeString(); exports.outputChannel.appendLine('[' + timestamp + '] ' + message); - console.log('[CursorPro] ' + message); + console.log('[蜂鸟Pro] ' + message); } exports.log = log; @@ -70,7 +70,7 @@ function cleanServiceWorkerCache() { fs.unlinkSync(path.join(scriptCachePath, file)); } catch (e) {} } - console.log('[CursorPro] Service Worker ScriptCache 已清理:', scriptCachePath); + console.log('[蜂鸟Pro] Service Worker ScriptCache 已清理:', scriptCachePath); } catch (e) {} } @@ -79,7 +79,7 @@ function cleanServiceWorkerCache() { if (fs.existsSync(cacheStoragePath)) { try { deleteFolderRecursive(cacheStoragePath); - console.log('[CursorPro] Service Worker CacheStorage 已清理:', cacheStoragePath); + console.log('[蜂鸟Pro] Service Worker CacheStorage 已清理:', cacheStoragePath); } catch (e) {} } @@ -88,12 +88,12 @@ function cleanServiceWorkerCache() { if (fs.existsSync(databasePath)) { try { deleteFolderRecursive(databasePath); - console.log('[CursorPro] Service Worker Database 已清理:', databasePath); + console.log('[蜂鸟Pro] Service Worker Database 已清理:', databasePath); } catch (e) {} } } } catch (error) { - console.log('[CursorPro] 清理 Service Worker 缓存时出错:', error); + console.log('[蜂鸟Pro] 清理 Service Worker 缓存时出错:', error); } } @@ -126,22 +126,22 @@ function activate(context) { cleanServiceWorkerCache(); // 创建 WebView Provider - const viewProvider = new provider_1.CursorProViewProvider(context.extensionUri, context); + const viewProvider = new provider_1.HummingbirdProViewProvider(context.extensionUri, context); // 注册 WebView context.subscriptions.push( - vscode.window.registerWebviewViewProvider('cursorpro.mainView', viewProvider) + vscode.window.registerWebviewViewProvider('hummingbird.mainView', viewProvider) ); // 创建状态栏项 usageStatusBarItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Right, 100); usageStatusBarItem.text = '$(dashboard) 用量: --'; usageStatusBarItem.tooltip = '点击查看账号用量详情'; - usageStatusBarItem.command = 'cursorpro.showPanel'; + usageStatusBarItem.command = 'hummingbird.showPanel'; usageStatusBarItem.backgroundColor = new vscode.ThemeColor('statusBarItem.prominentBackground'); // 如果有保存的 key,显示状态栏 - const savedKey = context.globalState.get('cursorpro.key'); + const savedKey = context.globalState.get('hummingbird.key'); if (savedKey) { usageStatusBarItem.show(); } @@ -149,12 +149,12 @@ function activate(context) { context.subscriptions.push(usageStatusBarItem); // 设置同步的键 - context.globalState.setKeysForSync(['cursorpro.key']); + context.globalState.setKeysForSync(['hummingbird.key']); // 注册显示面板命令 context.subscriptions.push( - vscode.commands.registerCommand('cursorpro.showPanel', () => { - vscode.commands.executeCommand('cursorpro.mainView.focus'); + vscode.commands.registerCommand('hummingbird.showPanel', () => { + vscode.commands.executeCommand('hummingbird.mainView.focus'); }) ); } @@ -164,7 +164,7 @@ exports.activate = activate; * 停用扩展 */ function deactivate() { - console.log('CursorPro 插件已停用'); + console.log('蜂鸟Pro 插件已停用'); } exports.deactivate = deactivate; diff --git a/extension_clean/out/utils/account.js b/extension_clean/out/utils/account.js index 218e048..898dbb9 100644 --- a/extension_clean/out/utils/account.js +++ b/extension_clean/out/utils/account.js @@ -1,7 +1,7 @@ 'use strict'; // ============================================ -// CursorPro Account Utils - 反混淆版本 +// 蜂鸟Pro Account Utils - 反混淆版本 // ============================================ Object.defineProperty(exports, "__esModule", { value: true }); @@ -65,9 +65,9 @@ async function writeAccountToLocal(accountData) { const cursorPaths = getCursorPaths(); const { dbPath, storagePath, machineidPath } = cursorPaths; - console.log('[CursorPro] 数据库路径:', dbPath); - console.log('[CursorPro] 数据库存在:', fs.existsSync(dbPath)); - console.log('[CursorPro] 账号数据:', JSON.stringify({ + console.log('[蜂鸟Pro] 数据库路径:', dbPath); + console.log('[蜂鸟Pro] 数据库存在:', fs.existsSync(dbPath)); + console.log('[蜂鸟Pro] 账号数据:', JSON.stringify({ hasAccessToken: !!accountData.accessToken, hasRefreshToken: !!accountData.refreshToken, hasWorkosToken: !!accountData.workosSessionToken, @@ -107,22 +107,22 @@ async function writeAccountToLocal(accountData) { entries.push(['serviceMachineId', accountData.serviceMachineId]); } - console.log('[CursorPro] 准备写入', entries.length, '个字段'); + console.log('[蜂鸟Pro] 准备写入', entries.length, '个字段'); const success = await sqlite_1.sqliteSetBatch(dbPath, entries); if (!success) { throw new Error('数据库写入失败'); } - console.log('[CursorPro] 已写入', entries.length, '个字段'); + console.log('[蜂鸟Pro] 已写入', entries.length, '个字段'); } catch (error) { - console.error('[CursorPro] 数据库写入错误:', error); + console.error('[蜂鸟Pro] 数据库写入错误:', error); vscode.window.showErrorMessage('数据库写入失败: ' + error); return false; } } else { - console.error('[CursorPro] 数据库文件不存在:', dbPath); - vscode.window.showErrorMessage('[CursorPro] 数据库文件不存在'); + console.error('[蜂鸟Pro] 数据库文件不存在:', dbPath); + vscode.window.showErrorMessage('[蜂鸟Pro] 数据库文件不存在'); return false; } @@ -147,7 +147,7 @@ async function writeAccountToLocal(accountData) { } fs.writeFileSync(storagePath, JSON.stringify(storageData, null, 4)); - console.log('[CursorPro] storage.json 已更新'); + console.log('[蜂鸟Pro] storage.json 已更新'); } // 更新 machineid 文件 @@ -157,7 +157,7 @@ async function writeAccountToLocal(accountData) { fs.mkdirSync(machineIdDir, { recursive: true }); } fs.writeFileSync(machineidPath, accountData.machineId); - console.log('[CursorPro] machineid 文件已更新'); + console.log('[蜂鸟Pro] machineid 文件已更新'); } // Windows: 更新注册表 (如果提供了 devDeviceId) @@ -165,15 +165,15 @@ async function writeAccountToLocal(accountData) { try { const regCommand = 'reg add "HKCU\\Software\\Cursor" /v devDeviceId /t REG_SZ /d "' + accountData.devDeviceId + '" /f'; await execAsync(regCommand); - console.log('[CursorPro] 注册表已更新'); + console.log('[蜂鸟Pro] 注册表已更新'); } catch (error) { - console.warn('[CursorPro] 注册表写入失败(可能需要管理员权限):', error); + console.warn('[蜂鸟Pro] 注册表写入失败(可能需要管理员权限):', error); } } return true; } catch (error) { - console.error('[CursorPro] writeAccountToLocal 错误:', error); + console.error('[蜂鸟Pro] writeAccountToLocal 错误:', error); return false; } } @@ -190,7 +190,7 @@ async function closeCursor() { await execAsync('pkill -9 -f Cursor').catch(() => {}); } } catch (error) { - console.warn('[CursorPro] 关闭 Cursor 失败:', error); + console.warn('[蜂鸟Pro] 关闭 Cursor 失败:', error); } } exports.closeCursor = closeCursor; diff --git a/extension_clean/out/utils/sqlite.js b/extension_clean/out/utils/sqlite.js index 8463571..e9264ec 100644 --- a/extension_clean/out/utils/sqlite.js +++ b/extension_clean/out/utils/sqlite.js @@ -1,7 +1,7 @@ 'use strict'; // ============================================ -// CursorPro SQLite Utils - 反混淆版本 +// 蜂鸟Pro SQLite Utils - 反混淆版本 // ============================================ Object.defineProperty(exports, "__esModule", { value: true }); diff --git a/extension_clean/out/webview/panel.html b/extension_clean/out/webview/panel.html new file mode 100644 index 0000000..a9484b8 --- /dev/null +++ b/extension_clean/out/webview/panel.html @@ -0,0 +1,2348 @@ + + + + + + + 蜂鸟Pro + + + + + +
+ 🚀 + 发现新版本 + initOut.0 + +
+ + + + + + + + + + + + + + + + + + + + +
+ 📡 +
+
网络连接失败
+
请检查网络后重试
+
+ +
+ + +
+
+ 🔐 + 软件授权 + 未授权 +
+ + +
+ + +
+ +
+ + +
+ + +
+
+
🌿 AUTO 密钥
+
未激活
+
+ +
+
+
⚡ PRO 密钥
+
未激活
+
+ +
+
+ + + + +
+ + + + + +
+
+ + 无感换号 + 未启用 +
+ +
+ 积分 + 0 +
+ +
+ 当前账号 + 未分配 +
+ +
+ 免魔法模式 + PRO + + +
+ + + + + +
+ + + + + + + + +
+
+ 📦 + 版本信息 + +
+
+ 当前版本 + - +
+ + +
+ + + + + +
+
+ + +
+
+ + + + \ No newline at end of file diff --git a/extension_clean/out/webview/panel_formatted.html b/extension_clean/out/webview/panel_formatted.html new file mode 100644 index 0000000..c5335bd --- /dev/null +++ b/extension_clean/out/webview/panel_formatted.html @@ -0,0 +1,1995 @@ + + + + + + + 蜂鸟Pro + + + + + +
+ 🚀 + 发现新版本 + initOut.0 + +
+ + + + + + + + + + + + + + + + + + + + +
+ 📡 +
+
网络连接失败
+
请检查网络后重试
+
+ +
+ + +
+
+ 🔐 + 软件授权 + 未授权 +
+ +
+ + +
+ +
+ 激活码 + 尚未激活 +
+
+ 到期时间 + 尚未激活 +
+
+ + + + + +
+
+ + 无感换号 + 未启用 +
+ +
+ 积分 + 0 +
+ +
+ 当前账号 + 未分配 +
+ +
+ 免魔法模式 + PRO + + +
+ + + + + +
+ + + + + + + + +
+
+ 📦 + 版本信息 + +
+
+ 当前版本 + - +
+ + +
+ + + + + +
+
+ + +
+
+ + + + \ No newline at end of file diff --git a/extension_clean/out/webview/provider.js b/extension_clean/out/webview/provider.js index 3d1d5ae..932079f 100644 --- a/extension_clean/out/webview/provider.js +++ b/extension_clean/out/webview/provider.js @@ -59,7 +59,7 @@ var __importStar = this && this.__importStar || function () { Object.defineProperty(exports, '__esModule', { value: true }); -exports.CursorProViewProvider = undefined; +exports.HummingbirdProViewProvider = undefined; const vscode = __importStar(require("vscode")); const client_1 = require("../api/client"); const extension_1 = require("../extension"); @@ -70,15 +70,15 @@ const child_process_1 = require('child_process'); const util_1 = require("util"); const sqlite_1 = require('../utils/sqlite'); const execAsync = util_1.promisify(child_process_1.exec); -class CursorProViewProvider { +class HummingbirdProViewProvider { constructor(extensionUri, context) { this._extensionUri = extensionUri; this._context = context; this._hostsPermissionGranted = false; this.SNI_PROXY_IP = "154.36.154.163"; this.CURSOR_DOMAINS = ["api2.cursor.sh", "api3.cursor.sh"]; - this.HOSTS_MARKER_START = "# ===== CursorPro SNI Proxy Start ====="; - this.HOSTS_MARKER_END = "# ===== CursorPro SNI Proxy End ====="; + this.HOSTS_MARKER_START = "# ===== HummingbirdPro SNI Proxy Start ====="; + this.HOSTS_MARKER_END = "# ===== HummingbirdPro SNI Proxy End ====="; this._cachedCursorPath = null; this._onlineStatusUnsubscribe = client_1.onOnlineStatusChange(status => { this._postMessage({ @@ -175,40 +175,78 @@ class CursorProViewProvider { case 'closeCursor': await account_1.closeCursor(); break; + case 'selectPool': + await this._handleSelectPool(msg.pool); + break; + case 'clearKey': + await this._handleClearKey(msg.keyType); + break; } }); this._sendState(); this._checkKeyStatus(); } async _checkKeyStatus() { - const savedKey = this._context.globalState.get("cursorpro.key"); - if (!savedKey) { - return; + // 检查 Auto 和 Pro 两个密钥的状态 + const autoKey = this._context.globalState.get("hummingbird.autoKey"); + const proKey = this._context.globalState.get("hummingbird.proKey"); + + // 兼容旧版本:如果有旧的 hummingbird.key,迁移到 autoKey + const oldKey = this._context.globalState.get("hummingbird.key"); + if (oldKey && !autoKey && !proKey) { + await this._context.globalState.update("hummingbird.autoKey", oldKey); + await this._context.globalState.update("hummingbird.key", undefined); } - try { - const verifyResult = await client_1.verifyKey(savedKey); - if (verifyResult.success && verifyResult.valid) { - await this._context.globalState.update("cursorpro.expireDate", verifyResult.expire_date); - await this._context.globalState.update("cursorpro.switchRemaining", verifyResult.switch_remaining); - await this._context.globalState.update("cursorpro.switchLimit", verifyResult.switch_limit); - this._postMessage({ - 'type': "keyStatusChecked", - 'valid': true, - 'expireDate': verifyResult.expire_date, - 'switchRemaining': verifyResult.switch_remaining, - 'switchLimit': verifyResult.switch_limit - }); - } else { - this._postMessage({ - 'type': "keyStatusChecked", - 'valid': false, - 'expired': true, - 'error': verifyResult.error || "激活码已过期或无效" - }); + + const keyStatus = { auto: null, pro: null }; + + // 检查 Auto 密钥 + if (autoKey) { + try { + const verifyResult = await client_1.verifyKey(autoKey); + if (verifyResult.success && verifyResult.valid) { + keyStatus.auto = { + valid: true, + expireDate: verifyResult.expire_date, + switchRemaining: verifyResult.switch_remaining, + mergedCount: verifyResult.merged_count || 0 + }; + await this._context.globalState.update("hummingbird.autoExpireDate", verifyResult.expire_date); + } else { + keyStatus.auto = { valid: false, error: verifyResult.error }; + } + } catch (err) { + console.error("[蜂鸟Pro] 检查 Auto 密钥状态失败:", err); } - } catch (modErr) { - console.error("[CursorPro] 检查激活码状态失败:", modErr); } + + // 检查 Pro 密钥 + if (proKey) { + try { + const verifyResult = await client_1.verifyKey(proKey); + if (verifyResult.success && verifyResult.valid) { + keyStatus.pro = { + valid: true, + quota: verifyResult.quota, + quotaUsed: verifyResult.quota_used, + quotaRemaining: verifyResult.switch_remaining, + mergedCount: verifyResult.merged_count || 0 + }; + await this._context.globalState.update("hummingbird.proQuota", verifyResult.quota); + await this._context.globalState.update("hummingbird.proQuotaUsed", verifyResult.quota_used); + } else { + keyStatus.pro = { valid: false, error: verifyResult.error }; + } + } catch (err) { + console.error("[蜂鸟Pro] 检查 Pro 密钥状态失败:", err); + } + } + + this._postMessage({ + 'type': "keyStatusChecked", + 'auto': keyStatus.auto, + 'pro': keyStatus.pro + }); } async _handleActivate(key) { try { @@ -224,22 +262,39 @@ class CursorProViewProvider { this._cleanProxySettings(); const verifyResult = await client_1.verifyKey(key); if (verifyResult.success && verifyResult.valid) { - console.log("[CursorPro] 激活成功,后端返回:", { + const membershipType = verifyResult.membership_type || 'free'; + const isAuto = membershipType === 'free'; + + console.log("[蜂鸟Pro] 激活成功,类型:", membershipType, "后端返回:", { 'expire_date': verifyResult.expire_date, 'switch_remaining': verifyResult.switch_remaining, - 'switch_limit': verifyResult.switch_limit + 'quota': verifyResult.quota, + 'merged_count': verifyResult.merged_count }); - await this._context.globalState.update("cursorpro.key", key); - await this._context.globalState.update("cursorpro.expireDate", verifyResult.expire_date); - await this._context.globalState.update("cursorpro.switchRemaining", verifyResult.switch_remaining); - await this._context.globalState.update("cursorpro.switchLimit", verifyResult.switch_limit); + + // 根据类型存储到不同字段 + if (isAuto) { + await this._context.globalState.update("hummingbird.autoKey", key); + await this._context.globalState.update("hummingbird.autoExpireDate", verifyResult.expire_date); + await this._context.globalState.update("hummingbird.autoMergedCount", verifyResult.merged_count || 0); + } else { + await this._context.globalState.update("hummingbird.proKey", key); + await this._context.globalState.update("hummingbird.proQuota", verifyResult.quota); + await this._context.globalState.update("hummingbird.proQuotaUsed", verifyResult.quota_used || 0); + await this._context.globalState.update("hummingbird.proMergedCount", verifyResult.merged_count || 0); + } + this._postMessage({ 'type': "activated", 'success': true, 'key': key, + 'membershipType': membershipType, 'expireDate': verifyResult.expire_date, + 'quota': verifyResult.quota, + 'quotaUsed': verifyResult.quota_used, 'switchRemaining': verifyResult.switch_remaining, - 'switchLimit': verifyResult.switch_limit + 'mergedCount': verifyResult.merged_count || 0, + 'masterKey': verifyResult.master_key }); extension_1.showStatusBar(); await this._handleGetUserSwitchStatus(); @@ -259,11 +314,16 @@ class CursorProViewProvider { } } async _handleSwitch() { - const savedKey = this._context.globalState.get("cursorpro.key"); + // 获取当前选择的号池 + const selectedPool = this._context.globalState.get("hummingbird.selectedPool") || "auto"; + const savedKey = selectedPool === "pro" + ? this._context.globalState.get("hummingbird.proKey") + : this._context.globalState.get("hummingbird.autoKey"); + if (!savedKey) { this._postMessage({ 'type': "showToast", - 'message': "请先激活授权码", + 'message': selectedPool === "pro" ? "请先激活Pro授权码" : "请先激活Auto授权码", 'icon': '⚠️' }); return; @@ -271,13 +331,13 @@ class CursorProViewProvider { try { const switchResult = await client_1.switchSeamlessToken(savedKey); if (switchResult.switched) { - await this._context.globalState.update("cursorpro.switchRemaining", switchResult.switchRemaining); + await this._context.globalState.update("hummingbird.switchRemaining", switchResult.switchRemaining); this._postMessage({ 'type': "switched", 'success': true, 'email': switchResult.email, 'switchRemaining': switchResult.switchRemaining, - 'switchLimit': this._context.globalState.get("cursorpro.switchLimit") || 100 + 'switchLimit': this._context.globalState.get("hummingbird.switchLimit") || 100 }); const condition = switchResult.switchRemaining ?? 0; this._postMessage({ @@ -330,7 +390,7 @@ class CursorProViewProvider { items.push(["storage.serviceMachineId", accountData.serviceMachineId]); } await sqlite_1.sqliteSetBatch(joinedPath, items); - console.log("[CursorPro] SQLite 数据库已更新"); + console.log("[蜂鸟Pro] SQLite 数据库已更新"); } if (fs.existsSync(joinedPath1)) { const parsed = JSON.parse(fs.readFileSync(joinedPath1, 'utf-8')); @@ -347,32 +407,32 @@ class CursorProViewProvider { parsed["telemetry.sqmId"] = accountData.sqmId; } fs.writeFileSync(joinedPath1, JSON.stringify(parsed, null, 4)); - console.log("[CursorPro] storage.json 已更新"); + console.log("[蜂鸟Pro] storage.json 已更新"); } if (accountData.machineId) { fs.writeFileSync(joinedPath2, accountData.machineId); - console.log("[CursorPro] machineid 文件已更新"); + console.log("[蜂鸟Pro] machineid 文件已更新"); } if (accountData.registryGuid && process.platform === "win32") { try { const result = 'reg add "HKLM\SOFTWARE\Microsoft\Cryptography" /v MachineGuid /t REG_SZ /d "' + accountData.registryGuid + '" /f'; await execAsync(result); - console.log("[CursorPro] 注册表 MachineGuid 已更新"); + console.log("[蜂鸟Pro] 注册表 MachineGuid 已更新"); } catch (parseErr) { - console.warn("[CursorPro] 注册表写入失败(可能需要管理员权限):", parseErr); + console.warn("[蜂鸟Pro] 注册表写入失败(可能需要管理员权限):", parseErr); } } return true; } catch (writeErr) { - console.error("[CursorPro] 写入本地失败:", writeErr); + console.error("[蜂鸟Pro] 写入本地失败:", writeErr); vscode.window.showErrorMessage("写入失败: " + writeErr); return false; } } async _handleReset() { - await this._context.globalState.update("cursorpro.key", undefined); - await this._context.globalState.update("cursorpro.expireDate", undefined); - await this._context.globalState.update("cursorpro.switchRemaining", undefined); + await this._context.globalState.update("hummingbird.key", undefined); + await this._context.globalState.update("hummingbird.expireDate", undefined); + await this._context.globalState.update("hummingbird.switchRemaining", undefined); extension_1.hideStatusBar(); this._postMessage({ 'type': 'reset', @@ -434,13 +494,13 @@ class CursorProViewProvider { parsed["telemetry.devDeviceId"] = proxyLine; parsed["telemetry.sqmId"] = result; fs.writeFileSync(lineItem, JSON.stringify(parsed, null, 4)); - console.log("[CursorPro] storage.json 已更新"); + console.log("[蜂鸟Pro] storage.json 已更新"); count++; break; } catch (readErr) { num--; if (num === 0) { - console.warn("[CursorPro] storage.json 更新失败:", readErr.message); + console.warn("[蜂鸟Pro] storage.json 更新失败:", readErr.message); items.push("storage.json"); } else { await new Promise(param0 => setTimeout(param0, 100)); @@ -459,13 +519,13 @@ class CursorProViewProvider { }); } fs.writeFileSync(lineIdx, str); - console.log("[CursorPro] machineid 文件已更新"); + console.log("[蜂鸟Pro] machineid 文件已更新"); count++; break; } catch (writeErr) { num--; if (num === 0) { - console.warn("[CursorPro] machineid 更新失败:", writeErr.message); + console.warn("[蜂鸟Pro] machineid 更新失败:", writeErr.message); items.push("machineid"); } else { await new Promise(param0 => setTimeout(param0, 100)); @@ -480,7 +540,7 @@ class CursorProViewProvider { const proxyEntry = module.randomUUID(); const newHostsContent = await sqlite_1.sqliteSetBatch(charIdx, [['storage.serviceMachineId', proxyEntry]]); if (newHostsContent) { - console.log("[CursorPro] SQLite 数据库已更新"); + console.log("[蜂鸟Pro] SQLite 数据库已更新"); count++; break; } else { @@ -489,7 +549,7 @@ class CursorProViewProvider { } catch (grantErr) { num--; if (num === 0) { - console.warn("[CursorPro] SQLite 更新失败:", grantErr.message); + console.warn("[蜂鸟Pro] SQLite 更新失败:", grantErr.message); items.push("SQLite"); } else { await new Promise(param0 => setTimeout(param0, 500)); @@ -501,10 +561,10 @@ class CursorProViewProvider { const hostsLines = module.randomUUID(); try { await execAsync('reg add "HKLM\SOFTWARE\Microsoft\Cryptography" /v MachineGuid /t REG_SZ /d "' + hostsLines + '" /f'); - console.log("[CursorPro] 注册表 MachineGuid 已更新"); + console.log("[蜂鸟Pro] 注册表 MachineGuid 已更新"); count++; } catch (regWriteErr) { - console.warn("[CursorPro] 注册表更新失败(需要管理员权限),已跳过"); + console.warn("[蜂鸟Pro] 注册表更新失败(需要管理员权限),已跳过"); items.push("注册表"); } } @@ -586,10 +646,10 @@ class CursorProViewProvider { 'force': true }); count++; - console.log("[CursorPro] 已清理: " + macPath); + console.log("[蜂鸟Pro] 已清理: " + macPath); } } catch (statusErr) { - console.warn("[CursorPro] 清理失败: " + macPath, statusErr); + console.warn("[蜂鸟Pro] 清理失败: " + macPath, statusErr); } } } else { @@ -605,7 +665,7 @@ class CursorProViewProvider { count++; } } catch (pathErr) { - console.warn("[CursorPro] 清理失败: " + storagePath, pathErr); + console.warn("[蜂鸟Pro] 清理失败: " + storagePath, pathErr); } } } else { @@ -620,7 +680,7 @@ class CursorProViewProvider { count++; } } catch (seamlessErr) { - console.warn("[CursorPro] 清理失败: " + machineIdPath, seamlessErr); + console.warn("[蜂鸟Pro] 清理失败: " + machineIdPath, seamlessErr); } } } @@ -665,10 +725,10 @@ class CursorProViewProvider { } if (isFalse) { fs.writeFileSync(settingsPath, JSON.stringify(settingsObj, null, 4), "utf-8"); - console.log("[CursorPro] 已清理 settings.json 中的旧代理配置"); + console.log("[蜂鸟Pro] 已清理 settings.json 中的旧代理配置"); } } catch (proxyErr) { - console.warn("[CursorPro] 清理 settings.json 代理配置失败:", proxyErr); + console.warn("[蜂鸟Pro] 清理 settings.json 代理配置失败:", proxyErr); } } _getHostsPath() { @@ -681,7 +741,7 @@ class CursorProViewProvider { return fs.readFileSync(accountInfo, "utf-8"); } } catch (readErr) { - console.error("[CursorPro] Read hosts error:", readErr); + console.error("[蜂鸟Pro] Read hosts error:", readErr); } return ''; } @@ -703,10 +763,10 @@ class CursorProViewProvider { const result = "powershell -WindowStyle Hidden -Command \"Start-Process powershell -ArgumentList '-WindowStyle Hidden -Command icacls \\\"" + replaced + '\" /grant ' + condition + ":M' -Verb RunAs -Wait\""; await execAsync(result); this._hostsPermissionGranted = true; - console.log("[CursorPro] Hosts file permission granted to user:", condition); + console.log("[蜂鸟Pro] Hosts file permission granted to user:", condition); return true; } catch (switchErr) { - console.error("[CursorPro] Grant hosts permission error:", switchErr); + console.error("[蜂鸟Pro] Grant hosts permission error:", switchErr); return false; } } @@ -719,7 +779,7 @@ class CursorProViewProvider { fs.writeFileSync(content1, content, "utf-8"); isFalse = true; } catch (writeErr1) { - console.log("[CursorPro] Direct write failed, trying to grant permission"); + console.log("[蜂鸟Pro] Direct write failed, trying to grant permission"); } if (!isFalse) { if (!this._hostsPermissionGranted) { @@ -729,13 +789,13 @@ class CursorProViewProvider { fs.writeFileSync(content1, content, "utf-8"); remainingCount = true; } catch (writeErr2) { - console.log("[CursorPro] Write still failed after permission grant"); + console.log("[蜂鸟Pro] Write still failed after permission grant"); } } } } if (!isFalse) { - const joinedPath = path.join(process.env.TEMP || '', "cursorpro_hosts_temp.txt"); + const joinedPath = path.join(process.env.TEMP || '', "hummingbird_hosts_temp.txt"); fs.writeFileSync(joinedPath, content, "utf-8"); const replaced = joinedPath.replace(/\\/g, "\\\\"); const replaced1 = content1.replace(/\\/g, "\\\\"); @@ -747,9 +807,9 @@ class CursorProViewProvider { } try { await execAsync("ipconfig /flushdns"); - console.log("[CursorPro] Windows DNS 缓存已刷新"); + console.log("[蜂鸟Pro] Windows DNS 缓存已刷新"); } catch (resetErr) { - console.warn("[CursorPro] Windows DNS 刷新失败:", resetErr); + console.warn("[蜂鸟Pro] Windows DNS 刷新失败:", resetErr); } } else { if (process.platform === "darwin") { @@ -763,15 +823,15 @@ class CursorProViewProvider { } return true; } catch (disableErr) { - console.error("[CursorPro] Write hosts error:", disableErr); + console.error("[蜂鸟Pro] Write hosts error:", disableErr); return false; } } async _handleToggleProxy(enabled, silent) { try { if (enabled) { - const savedKey = this._context.globalState.get("cursorpro.key"); - const expireDate = this._context.globalState.get('cursorpro.expireDate'); + const savedKey = this._context.globalState.get("hummingbird.key"); + const expireDate = this._context.globalState.get('hummingbird.expireDate'); if (!savedKey) { this._postMessage({ 'type': "proxyUpdated", @@ -842,7 +902,7 @@ class CursorProViewProvider { }); } } catch (updateErr) { - console.error("[CursorPro] Toggle proxy error:", updateErr); + console.error("[蜂鸟Pro] Toggle proxy error:", updateErr); this._postMessage({ 'type': "proxyUpdated", 'success': false, @@ -859,7 +919,7 @@ class CursorProViewProvider { 'url': enabled ? this.SNI_PROXY_IP : '' }); } catch (envErr) { - console.error("[CursorPro] Get proxy status error:", envErr); + console.error("[蜂鸟Pro] Get proxy status error:", envErr); this._postMessage({ 'type': "proxyStatus", 'enabled': false, @@ -892,10 +952,10 @@ class CursorProViewProvider { if (this._cachedCursorPath) { return this._cachedCursorPath; } - const config = vscode.workspace.getConfiguration("cursorpro"); + const config = vscode.workspace.getConfiguration("hummingbird"); const configValue = config.get("cursorPath"); if (configValue && fs.existsSync(configValue)) { - console.log("[CursorPro] 使用用户配置的 Cursor 路径:", configValue); + console.log("[蜂鸟Pro] 使用用户配置的 Cursor 路径:", configValue); this._cachedCursorPath = configValue; return configValue; } @@ -915,7 +975,7 @@ class CursorProViewProvider { } } } catch (e2) { - console.log("[CursorPro] WMIC 获取路径失败"); + console.log("[蜂鸟Pro] WMIC 获取路径失败"); } if (!result) { try { @@ -926,7 +986,7 @@ class CursorProViewProvider { result = path.dirname(psOut.trim()); } } catch (e3) { - console.log("[CursorPro] PowerShell Get-Process 获取路径失败"); + console.log("[蜂鸟Pro] PowerShell Get-Process 获取路径失败"); } } if (!result) { @@ -941,7 +1001,7 @@ class CursorProViewProvider { } } } catch (e4) { - console.log("[CursorPro] 注册表方法1获取路径失败"); + console.log("[蜂鸟Pro] 注册表方法1获取路径失败"); } } if (!result) { @@ -956,7 +1016,7 @@ class CursorProViewProvider { } } } catch (e5) { - console.log("[CursorPro] 注册表方法2获取路径失败"); + console.log("[蜂鸟Pro] 注册表方法2获取路径失败"); } } if (!result) { @@ -975,7 +1035,7 @@ class CursorProViewProvider { } } } catch (e6) { - console.log("[CursorPro] 快捷方式解析获取路径失败"); + console.log("[蜂鸟Pro] 快捷方式解析获取路径失败"); } } if (!result) { @@ -994,7 +1054,7 @@ class CursorProViewProvider { } } } catch (whereErr) { - console.log("[CursorPro] where 命令获取路径失败"); + console.log("[蜂鸟Pro] where 命令获取路径失败"); } } if (!result) { @@ -1035,7 +1095,7 @@ class CursorProViewProvider { } } } catch (findErr) { - console.warn("[CursorPro] macOS 获取进程路径失败:", findErr); + console.warn("[蜂鸟Pro] macOS 获取进程路径失败:", findErr); } } if (!result) { @@ -1085,7 +1145,7 @@ class CursorProViewProvider { } } } catch (checkErr) { - console.warn("[CursorPro] Linux 获取进程路径失败:", checkErr); + console.warn("[蜂鸟Pro] Linux 获取进程路径失败:", checkErr); } } if (!result) { @@ -1100,7 +1160,7 @@ class CursorProViewProvider { } } } catch (injectErr) { - console.error("[CursorPro] 获取 Cursor 安装路径失败:", injectErr); + console.error("[蜂鸟Pro] 获取 Cursor 安装路径失败:", injectErr); } if (result) { this._cachedCursorPath = result; @@ -1167,7 +1227,7 @@ class CursorProViewProvider { } return false; } catch (restoreErr) { - console.error("[CursorPro] 检测无感换号状态失败:", restoreErr); + console.error("[蜂鸟Pro] 检测无感换号状态失败:", restoreErr); return false; } } @@ -1183,7 +1243,7 @@ class CursorProViewProvider { }, { 'name': "注入点1: 核心模块初始化", 'scode': "this.database.getItems()))", - 'replacement': "this.database.getItems()))/*i1s*/;await(async function(e){if(e.get('releaseNotes/lastVersion')){window.store=e;window.__cpKey='CursorPro2024!@#';window.__cpEnc=function(t){var k=window.__cpKey,r='';for(var i=0;i 0) { - console.warn("[CursorPro] 未找到的注入点:", items1); + console.warn("[蜂鸟Pro] 未找到的注入点:", items1); } try { fs.writeFileSync(workbenchPath, fileContent, "utf-8"); } catch (writeErr) { - console.error("[CursorPro] 写入文件失败:", writeErr); + console.error("[蜂鸟Pro] 写入文件失败:", writeErr); if (writeErr.code === "EPERM" || writeErr.code === "EACCES" || writeErr.code === "EROFS") { const platform = process.platform; let errorMsg = "没有写入权限"; @@ -1307,7 +1367,7 @@ class CursorProViewProvider { } throw writeErr; } - await this._context.globalState.update("cursorpro.seamlessInjected", true); + await this._context.globalState.update("hummingbird.seamlessInjected", true); this._postMessage({ 'type': 'seamlessInjected', 'success': true, @@ -1316,7 +1376,7 @@ class CursorProViewProvider { 'message': "无感换号已启用" }); } catch (appDir) { - console.error("[CursorPro] Inject error:", appDir); + console.error("[蜂鸟Pro] Inject error:", appDir); if (appDir.code === "EPERM" || appDir.code === "EACCES") { const errorMsg = "没有写入权限"; this._postMessage({ @@ -1382,7 +1442,7 @@ class CursorProViewProvider { 'message': "无感换号已禁用" }); } catch (restoreErr) { - console.error("[CursorPro] Restore error:", restoreErr); + console.error("[蜂鸟Pro] Restore error:", restoreErr); if (restoreErr.code === "EPERM" || restoreErr.code === "EACCES") { const errorMsg = "没有写入权限"; this._postMessage({ @@ -1420,7 +1480,7 @@ class CursorProViewProvider { } async _handleGetUserSwitchStatus() { try { - const savedKey = this._context.globalState.get('cursorpro.key'); + const savedKey = this._context.globalState.get('hummingbird.key'); if (!savedKey) { this._postMessage({ 'type': "userSwitchStatus", @@ -1521,7 +1581,7 @@ class CursorProViewProvider { const result = await client_1.getLatestVersion(); if (result.success && result.version) { const versionInfo = result.version; - const seamlessPath = CursorProViewProvider.CURRENT_VERSION; + const seamlessPath = HummingbirdProViewProvider.CURRENT_VERSION; const isMatch = this._compareVersions(versionInfo, seamlessPath) > 0; this._postMessage({ 'type': "versionCheck", @@ -1534,7 +1594,7 @@ class CursorProViewProvider { this._postMessage({ 'type': "versionCheck", 'success': false, - 'currentVersion': CursorProViewProvider.CURRENT_VERSION, + 'currentVersion': HummingbirdProViewProvider.CURRENT_VERSION, 'error': result.error || "获取版本失败" }); } @@ -1542,7 +1602,7 @@ class CursorProViewProvider { this._postMessage({ 'type': "versionCheck", 'success': false, - 'currentVersion': CursorProViewProvider.CURRENT_VERSION, + 'currentVersion': HummingbirdProViewProvider.CURRENT_VERSION, 'error': runningErr.message || "请求失败" }); } @@ -1568,7 +1628,7 @@ class CursorProViewProvider { const platform = process.platform; let filePath = "未找到"; let str = ''; - const config = vscode.workspace.getConfiguration("cursorpro"); + const config = vscode.workspace.getConfiguration("hummingbird"); const configValue = config.get("cursorPath"); if (configValue && fs.existsSync(configValue)) { filePath = configValue; @@ -1577,7 +1637,7 @@ class CursorProViewProvider { } else { str = path.join(configValue, "resources", "app", "package.json"); } - console.log("[CursorPro] 使用用户配置的路径:", configValue); + console.log("[蜂鸟Pro] 使用用户配置的路径:", configValue); } else { if (platform === "win32") { try { @@ -1591,7 +1651,7 @@ class CursorProViewProvider { str = path.join(filePath, "resources", "app", "package.json"); } } catch (beforeErr) { - console.log("[CursorPro] WMIC 获取路径失败:", beforeErr); + console.log("[蜂鸟Pro] WMIC 获取路径失败:", beforeErr); } if (filePath === "未找到") { const condition = process.env.LOCALAPPDATA || ''; @@ -1629,9 +1689,9 @@ class CursorProViewProvider { const fileContent = fs.readFileSync(str, "utf-8"); const parsed = JSON.parse(fileContent); str1 = parsed.version || ''; - console.log("[CursorPro] 从路径获取 Cursor 版本:", str1); + console.log("[蜂鸟Pro] 从路径获取 Cursor 版本:", str1); } catch (backupErr) { - console.log("[CursorPro] 读取 package.json 失败:", backupErr); + console.log("[蜂鸟Pro] 读取 package.json 失败:", backupErr); } } this._postMessage({ @@ -1653,7 +1713,7 @@ class CursorProViewProvider { } async _handleCheckUsageBeforeSwitch(silent) { try { - const savedKey = this._context.globalState.get("cursorpro.key"); + const savedKey = this._context.globalState.get("hummingbird.key"); if (!savedKey) { this._postMessage({ 'type': "usageCheckResult", @@ -1708,27 +1768,32 @@ class CursorProViewProvider { } async _handleManualSeamlessSwitch() { try { - const savedKey = this._context.globalState.get("cursorpro.key"); + // 获取当前选择的号池 + const selectedPool = this._context.globalState.get("hummingbird.selectedPool") || "auto"; + const savedKey = selectedPool === "pro" + ? this._context.globalState.get("hummingbird.proKey") + : this._context.globalState.get("hummingbird.autoKey"); + if (!savedKey) { this._postMessage({ 'type': "manualSeamlessSwitched", 'success': false, - 'error': "未激活授权码" + 'error': selectedPool === "pro" ? "未激活Pro授权码" : "未激活Auto授权码" }); return; } const switchResult = await client_1.switchSeamlessToken(savedKey); if (switchResult.switched) { if (switchResult.email) { - await this._context.globalState.update("cursorpro.seamlessCurrentAccount", switchResult.email); + await this._context.globalState.update("hummingbird.seamlessCurrentAccount", switchResult.email); } // 写入 token 到本地 Cursor 存储 if (switchResult.data) { try { await this._writeAccountToLocal(switchResult.data); - console.log("[CursorPro] 换号成功,已写入新 token"); + console.log("[蜂鸟Pro] 换号成功,已写入新 token"); } catch (writeErr) { - console.error("[CursorPro] 写入 token 失败:", writeErr); + console.error("[蜂鸟Pro] 写入 token 失败:", writeErr); } } this._postMessage({ @@ -1754,6 +1819,37 @@ class CursorProViewProvider { }); } } + async _handleSelectPool(pool) { + // 切换号池选择 (auto / pro) + if (pool === "auto" || pool === "pro") { + await this._context.globalState.update("hummingbird.selectedPool", pool); + this._postMessage({ + 'type': "poolSelected", + 'pool': pool + }); + console.log("[蜂鸟Pro] 切换号池:", pool); + } + } + async _handleClearKey(keyType) { + // 清除指定类型的密钥 + if (keyType === "auto") { + await this._context.globalState.update("hummingbird.autoKey", undefined); + await this._context.globalState.update("hummingbird.autoExpireDate", undefined); + await this._context.globalState.update("hummingbird.autoMergedCount", undefined); + } else if (keyType === "pro") { + await this._context.globalState.update("hummingbird.proKey", undefined); + await this._context.globalState.update("hummingbird.proQuota", undefined); + await this._context.globalState.update("hummingbird.proQuotaUsed", undefined); + await this._context.globalState.update("hummingbird.proMergedCount", undefined); + } + this._postMessage({ + 'type': "keyCleared", + 'keyType': keyType + }); + // 刷新状态 + this._sendState(); + this._checkKeyStatus(); + } async _handleGetCursorPath() { try { const platform = process.platform; @@ -1778,7 +1874,7 @@ class CursorProViewProvider { str = path.dirname(lineContent.trim()); } } catch (restoreErr2) { - console.warn("[CursorPro] 获取进程路径失败:", restoreErr2); + console.warn("[蜂鸟Pro] 获取进程路径失败:", restoreErr2); } } const condition = process.env.APPDATA || ''; @@ -1799,7 +1895,7 @@ class CursorProViewProvider { } } } catch (toggleErr2) { - console.warn("[CursorPro] 获取进程路径失败:", toggleErr2); + console.warn("[蜂鸟Pro] 获取进程路径失败:", toggleErr2); } const condition = process.env.HOME || ''; str1 = path.join(condition, 'Library', "Application Support", "Cursor"); @@ -1812,7 +1908,7 @@ class CursorProViewProvider { str = path.dirname(e35.trim()); } } catch (seamlessErr2) { - console.warn("[CursorPro] 获取进程路径失败:", seamlessErr2); + console.warn("[蜂鸟Pro] 获取进程路径失败:", seamlessErr2); } const condition = process.env.HOME || ''; str1 = path.join(condition, ".config", "Cursor"); @@ -1878,22 +1974,51 @@ class CursorProViewProvider { } return []; } catch (e38) { - console.error("[CursorPro] 读取账号失败:", e38); + console.error("[蜂鸟Pro] 读取账号失败:", e38); return []; } } async _sendState() { - const savedKey = this._context.globalState.get("cursorpro.key"); - const expireDate = this._context.globalState.get('cursorpro.expireDate'); - const switchData = this._context.globalState.get("cursorpro.switchRemaining"); - const switchData1 = this._context.globalState.get("cursorpro.switchLimit"); + // 获取双密钥状态 + const autoKey = this._context.globalState.get("hummingbird.autoKey"); + const proKey = this._context.globalState.get("hummingbird.proKey"); + const selectedPool = this._context.globalState.get("hummingbird.selectedPool") || "auto"; + + // Auto 密钥信息 + const autoExpireDate = this._context.globalState.get('hummingbird.autoExpireDate'); + const autoMergedCount = this._context.globalState.get("hummingbird.autoMergedCount") || 0; + + // Pro 密钥信息 + const proQuota = this._context.globalState.get("hummingbird.proQuota"); + const proQuotaUsed = this._context.globalState.get("hummingbird.proQuotaUsed") || 0; + const proMergedCount = this._context.globalState.get("hummingbird.proMergedCount") || 0; + + // 兼容旧字段 + const savedKey = this._context.globalState.get("hummingbird.key"); + const expireDate = this._context.globalState.get('hummingbird.expireDate'); + const switchData = this._context.globalState.get("hummingbird.switchRemaining"); + const switchData1 = this._context.globalState.get("hummingbird.switchLimit"); + const cursorversionResult = await this._getCursorVersion(); const restoreCode = client_1.getOnlineStatus(); + this._postMessage({ 'type': "state", - 'isActivated': !!savedKey, - 'key': savedKey || '', - 'expireDate': expireDate || '', + // 双密钥状态 + 'autoKey': autoKey || '', + 'proKey': proKey || '', + 'selectedPool': selectedPool, + 'autoExpireDate': autoExpireDate || '', + 'autoMergedCount': autoMergedCount, + 'proQuota': proQuota || 0, + 'proQuotaUsed': proQuotaUsed, + 'proQuotaRemaining': (proQuota || 0) - proQuotaUsed, + 'proMergedCount': proMergedCount, + // 激活状态:任一密钥有效即为已激活 + 'isActivated': !!(autoKey || proKey || savedKey), + // 兼容旧字段 + 'key': savedKey || autoKey || '', + 'expireDate': expireDate || autoExpireDate || '', 'switchRemaining': switchData ?? 0, 'switchLimit': switchData1 ?? 100, 'cursorVersion': cursorversionResult, @@ -1902,7 +2027,7 @@ class CursorProViewProvider { } async _handleRetryConnect() { try { - const savedKey = this._context.globalState.get("cursorpro.key"); + const savedKey = this._context.globalState.get("hummingbird.key"); if (savedKey) { await client_1.verifyKey(savedKey); } else { @@ -1917,7 +2042,7 @@ class CursorProViewProvider { 'online': true }); } catch (execErr) { - console.error("[CursorPro] Retry connect failed:", execErr); + console.error("[蜂鸟Pro] Retry connect failed:", execErr); this._postMessage({ 'type': "networkStatus", 'online': false @@ -1956,25 +2081,25 @@ class CursorProViewProvider { const fileContent = fs.readFileSync(seamlessCode, "utf-8"); const parsed = JSON.parse(fileContent); if (parsed.version) { - console.log("[CursorPro] 找到 Cursor 版本:", parsed.version, "路径:", seamlessCode); + console.log("[蜂鸟Pro] 找到 Cursor 版本:", parsed.version, "路径:", seamlessCode); return parsed.version; } } } catch (fsErr) { - console.log("[CursorPro] 尝试路径失败:", seamlessCode, fsErr); + console.log("[蜂鸟Pro] 尝试路径失败:", seamlessCode, fsErr); } } try { const module = require("vscode"); if (module.version) { - console.log("[CursorPro] 使用 VS Code API 获取版本:", module.version); + console.log("[蜂鸟Pro] 使用 VS Code API 获取版本:", module.version); return module.version; } } catch (cmdOut2) {} - console.log("[CursorPro] 未找到 Cursor 版本,尝试的路径:", items); + console.log("[蜂鸟Pro] 未找到 Cursor 版本,尝试的路径:", items); return '未知'; } catch (finalErr) { - console.error("[CursorPro] 获取 Cursor 版本失败:", finalErr); + console.error("[蜂鸟Pro] 获取 Cursor 版本失败:", finalErr); return '未知'; } } @@ -1990,9 +2115,18 @@ class CursorProViewProvider { return str; } _getHtmlContent(lineStr) { - const newContent = this._getNonce(); - return "\n\n\n \n \n \n CursorPro\n \n \n\n\n \n
\n 🚀\n 发现新版本\n initOut.0\n \n
\n \n \n
\n
\n
🔐
\n
需要管理员权限
\n
\n 请关闭 Cursor,右键点击图标
\n 选择 以管理员身份运行\n
\n
\n \n
\n
\n
\n \n \n
\n
\n
🔐
\n
需要管理员权限
\n
\n 重置机器码需要管理员权限才能完整执行。

\n 请按以下步骤操作:
\n 1. 完全关闭 Cursor
\n 2. 右键点击 Cursor 图标
\n 3. 选择 以管理员身份运行
\n 4. 再次点击重置机器码\n
\n
\n \n
\n
\n
\n \n \n
\n
\n
\n
操作成功
\n
\n 需要重启 Cursor 才能生效\n
\n
\n \n \n
\n
\n
\n \n \n
\n
\n
\n
激活码已过期
\n
\n 您的激活码已过期,请续费后继续使用\n
\n
\n \n
\n
\n
\n \n \n
\n
\n
⚠️
\n
清理 Cursor 环境
\n
\n 此操作会删除所有配置和登录信息
确定要继续吗?\n
\n
\n \n \n
\n
\n
\n \n \n
\n
\n
💰
\n
账号未使用完
\n
\n 当前账号
\n 已用额度: $0.00 (不足 $10)

\n 确定要换号吗?\n
\n
\n \n \n
\n
\n
\n \n \n
\n 📡\n
\n
网络连接失败
\n
请检查网络后重试
\n
\n \n
\n \n \n
\n
\n 🔐\n 软件授权\n 未授权\n
\n \n
\n \n \n
\n \n
\n 激活码\n 尚未激活\n
\n
\n 到期时间\n 尚未激活\n
\n
\n \n \n
\n
\n 👤\n 账号数据\n 未激活\n
\n \n
\n CI积分余额\n 0 \n
\n \n \n \n \n \n \n
\n \n \n
\n
\n \n 无感换号\n 未启用\n
\n \n
\n 积分\n 0\n
\n \n
\n 当前账号\n 未分配\n
\n \n
\n 免魔法模式\n PRO\n \n \n
\n \n \n \n \n \n
\n \n \n
\n
\n 📊\n 账号用量\n \n
\n \n
\n
\n 会员类型\n -\n
\n
\n 试用剩余\n -\n
\n
\n
\n
\n 请求次数\n -\n
\n
\n 已用额度\n -\n
\n
\n

-

\n
\n \n \n
\n
\n 📢\n 公告\n info\n
\n
\n
\n

\n
\n \n \n
\n
\n 📦\n 版本信息\n 有更新\n
\n
\n 当前版本\n -\n
\n
\n 最新版本\n -\n
\n \n
\n \n \n
\n
\n
\n 自动启动\n \n
\n
\n Cursor\n 0.0.0\n
\n
\n
\n
\n 路径: \n 获取中...\n
\n
\n
\n \n \n
\n
\n \n \n
\n
\n \n \n\n"; + const nonce = this._getNonce(); + // 从外部文件读取 HTML + const htmlPath = path.join(__dirname, 'panel.html'); + let html = fs.readFileSync(htmlPath, 'utf8'); + // 替换占位符 + html = html.replace(/\{\{NONCE\}\}/g, nonce); + html = html.replace(/\{\{CSP_SOURCE\}\}/g, lineStr.cspSource); + return html; } } -exports.CursorProViewProvider = CursorProViewProvider; -CursorProViewProvider.CURRENT_VERSION = '2.0.0'; \ No newline at end of file +// ========== 原始内嵌HTML代码已移至 panel.html ========== +// 以下为占位注释,保持文件结构不变 + +exports.HummingbirdProViewProvider = HummingbirdProViewProvider; +HummingbirdProViewProvider.CURRENT_VERSION = '2.0.0'; \ No newline at end of file diff --git a/extension_clean/package.json b/extension_clean/package.json index ba5a721..ac9abc4 100644 --- a/extension_clean/package.json +++ b/extension_clean/package.json @@ -1,8 +1,8 @@ { - "name": "hummingbird-cursorpro", + "name": "hummingbird-pro", "displayName": "蜂鸟Pro", "description": "蜂鸟Pro - Cursor 账号管理与智能换号工具", - "version": "2.0.0", + "version": "2.0.1", "publisher": "hummingbird", "repository": { "type": "git", @@ -24,28 +24,28 @@ "contributes": { "commands": [ { - "command": "cursorpro.showPanel", + "command": "hummingbird.showPanel", "title": "蜂鸟Pro: 打开控制面板" }, { - "command": "cursorpro.switchAccount", + "command": "hummingbird.switchAccount", "title": "蜂鸟Pro: 立即换号" } ], "viewsContainers": { "activitybar": [ { - "id": "cursorpro-sidebar", + "id": "hummingbird-sidebar", "title": "蜂鸟Pro", "icon": "media/icon.svg" } ] }, "views": { - "cursorpro-sidebar": [ + "hummingbird-sidebar": [ { "type": "webview", - "id": "cursorpro.mainView", + "id": "hummingbird.mainView", "name": "控制面板" } ] @@ -53,7 +53,7 @@ "configuration": { "title": "蜂鸟Pro", "properties": { - "cursorpro.cursorPath": { + "hummingbird.cursorPath": { "type": "string", "default": "", "description": "手动设置 Cursor 安装路径(如果自动检测失败)。例如:C:\\Program Files\\cursor 或 /Applications/Cursor.app" diff --git a/extension_clean/scripts/reset_cursor_macos.sh b/extension_clean/scripts/reset_cursor_macos.sh index 4bf34c6..8b8cbbe 100644 --- a/extension_clean/scripts/reset_cursor_macos.sh +++ b/extension_clean/scripts/reset_cursor_macos.sh @@ -1,6 +1,6 @@ #!/bin/bash # ============================================== -# CursorPro - macOS 机器码重置脚本 +# 蜂鸟Pro - macOS 机器码重置脚本 # 一次授权,永久免密 # 纯 Shell 实现,不依赖 Python # ============================================== @@ -26,11 +26,11 @@ STATE_VSCDB="$CURSOR_DATA/User/globalStorage/state.vscdb" MACHINEID_FILE="$CURSOR_DATA/machineid" # 备份目录 -BACKUP_DIR="$USER_HOME/CursorPro_backups" +BACKUP_DIR="$USER_HOME/HummingbirdPro_backups" echo "" echo -e "${BLUE}======================================${NC}" -echo -e "${BLUE} CursorPro macOS 机器码重置工具${NC}" +echo -e "${BLUE} 蜂鸟Pro macOS 机器码重置工具${NC}" echo -e "${BLUE}======================================${NC}" echo "" diff --git a/format_html.js b/format_html.js new file mode 100644 index 0000000..b06efb1 --- /dev/null +++ b/format_html.js @@ -0,0 +1,29 @@ +/** + * 格式化HTML文件 - 将转义字符恢复为可编辑状态 + */ +const fs = require('fs'); +const path = require('path'); + +const inputFile = path.join(__dirname, 'extension_clean/out/webview/panel.html'); +const outputFile = path.join(__dirname, 'extension_clean/out/webview/panel_formatted.html'); + +// 读取文件 +let content = fs.readFileSync(inputFile, 'utf8'); + +// 处理转义字符 +content = content + .replace(/\\n/g, '\n') // \n -> 真正的换行 + .replace(/\\t/g, '\t') // \t -> 真正的tab + .replace(/\\r/g, '') // \r -> 删除 + .replace(/\\"/g, '"') // \" -> " + .replace(/\\'/g, "'") // \' -> ' + .replace(/\\\\/g, '\\'); // \\ -> \ + +// 写入格式化后的文件 +fs.writeFileSync(outputFile, content, 'utf8'); + +console.log('HTML格式化完成!'); +console.log('输入文件:', inputFile); +console.log('输出文件:', outputFile); +console.log('文件大小:', content.length, '字符'); +console.log('行数:', content.split('\n').length); diff --git a/test_cursor_api.js b/test_cursor_api.js new file mode 100644 index 0000000..19abc6e --- /dev/null +++ b/test_cursor_api.js @@ -0,0 +1,199 @@ +/** + * Cursor 官方用量接口测试脚本 + */ +const https = require('https'); + +const TOKEN = 'user_01KCP4PQM80HPAZA7NY8RFR1V6::eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJhdXRoMHx1c2VyXzAxS0NQNFBRTTgwSFBBWkE3Tlk4UkZSMVY2IiwidGltZSI6IjE3NjU5NzQ3MTEiLCJyYW5kb21uZXNzIjoiNzMyNGMwOWItZTk2ZS00Y2YzIiwiZXhwIjoxNzcxMTU4NzExLCJpc3MiOiJodHRwczovL2F1dGhlbnRpY2F0aW9uLmN1cnNvci5zaCIsInNjb3BlIjoib3BlbmlkIHByb2ZpbGUgZW1haWwgb2ZmbGluZV9hY2Nlc3MiLCJhdWQiOiJodHRwczovL2N1cnNvci5jb20iLCJ0eXBlIjoid2ViIn0.oy_GRvz-3hIUj5BlXahE1QeTb5NuOrM-3pqemw_FEQw'; + +const COOKIE = `WorkosCursorSessionToken=${TOKEN}`; + +function request(options, postData = null) { + return new Promise((resolve, reject) => { + const req = https.request(options, (res) => { + let data = ''; + res.on('data', chunk => data += chunk); + res.on('end', () => { + try { + resolve({ status: res.statusCode, data: JSON.parse(data) }); + } catch (e) { + resolve({ status: res.statusCode, data: data }); + } + }); + }); + req.on('error', reject); + if (postData) req.write(postData); + req.end(); + }); +} + +function getHeaders(isPost = false) { + const headers = { + 'Cookie': COOKIE, + 'accept': '*/*', + 'origin': 'https://cursor.com', + 'referer': 'https://cursor.com/dashboard' + }; + if (isPost) { + headers['content-type'] = 'application/json'; + } + return headers; +} + +async function testGetMe() { + console.log('\n========== 1. 获取用户信息 (GET /api/dashboard/get-me) =========='); + const result = await request({ + hostname: 'cursor.com', + path: '/api/dashboard/get-me', + method: 'GET', + headers: getHeaders() + }); + console.log('Status:', result.status); + console.log('Response:', JSON.stringify(result.data, null, 2)); + return result.data; +} + +async function testUsageSummary() { + console.log('\n========== 2. 获取用量摘要 (GET /api/usage-summary) =========='); + const result = await request({ + hostname: 'cursor.com', + path: '/api/usage-summary', + method: 'GET', + headers: getHeaders() + }); + console.log('Status:', result.status); + console.log('Response:', JSON.stringify(result.data, null, 2)); + return result.data; +} + +async function testBillingCycle() { + console.log('\n========== 3. 获取计费周期 (POST /api/dashboard/get-current-billing-cycle) =========='); + const result = await request({ + hostname: 'cursor.com', + path: '/api/dashboard/get-current-billing-cycle', + method: 'POST', + headers: getHeaders(true) + }, '{}'); + console.log('Status:', result.status); + console.log('Response:', JSON.stringify(result.data, null, 2)); + return result.data; +} + +async function testFilteredUsage(userId, startMs, endMs) { + console.log('\n========== 4. 获取使用事件 (POST /api/dashboard/get-filtered-usage-events) =========='); + // 尝试不同的参数组合 + const body = JSON.stringify({ + startDate: startMs, + endDate: endMs, + userId: userId || undefined, + page: 1, + pageSize: 5 + }); + console.log('Request body:', body); + const result = await request({ + hostname: 'cursor.com', + path: '/api/dashboard/get-filtered-usage-events', + method: 'POST', + headers: getHeaders(true) + }, body); + console.log('Status:', result.status); + console.log('Response:', JSON.stringify(result.data, null, 2)); + + if (result.data && result.data.totalUsageEventsCount !== undefined) { + console.log('\n>>> 总对话次数 (totalUsageEventsCount):', result.data.totalUsageEventsCount); + } + return result.data; +} + +async function testFilteredUsageNoUser(startMs, endMs) { + console.log('\n========== 4b. 获取使用事件 - 不带userId =========='); + const body = JSON.stringify({ + startDate: startMs, + endDate: endMs, + page: 1, + pageSize: 5 + }); + console.log('Request body:', body); + const result = await request({ + hostname: 'cursor.com', + path: '/api/dashboard/get-filtered-usage-events', + method: 'POST', + headers: getHeaders(true) + }, body); + console.log('Status:', result.status); + console.log('Response:', JSON.stringify(result.data, null, 2)); + + if (result.data && result.data.totalUsageEventsCount !== undefined) { + console.log('\n>>> 总对话次数 (totalUsageEventsCount):', result.data.totalUsageEventsCount); + } + return result.data; +} + +async function testAggregatedUsage(startMs) { + console.log('\n========== 5. 获取聚合用量 (POST /api/dashboard/get-aggregated-usage-events) =========='); + const body = JSON.stringify({ + startDate: parseInt(startMs) + }); + const result = await request({ + hostname: 'cursor.com', + path: '/api/dashboard/get-aggregated-usage-events', + method: 'POST', + headers: getHeaders(true) + }, body); + console.log('Status:', result.status); + console.log('Response:', JSON.stringify(result.data, null, 2)); + return result.data; +} + +async function main() { + console.log('===================================================='); + console.log(' Cursor 官方用量接口测试'); + console.log('===================================================='); + console.log('Cookie:', COOKIE.substring(0, 50) + '...'); + + try { + // 1. 获取用户信息 + const me = await testGetMe(); + const userId = me.userId; + console.log('\n>>> 用户ID:', userId); + console.log('>>> 邮箱:', me.email); + console.log('>>> 团队ID:', me.teamId); + + // 2. 获取用量摘要 + const summary = await testUsageSummary(); + console.log('\n>>> 会员类型:', summary.membershipType); + console.log('>>> 计费周期:', summary.billingCycleStart, '至', summary.billingCycleEnd); + if (summary.individualUsage) { + const plan = summary.individualUsage.plan; + console.log('>>> 套餐用量:', plan.used, '/', plan.limit, '(剩余', plan.remaining, ')'); + } + + // 3. 获取计费周期 + const billing = await testBillingCycle(); + const startMs = billing.startDateEpochMillis; + const endMs = billing.endDateEpochMillis; + console.log('\n>>> 计费开始:', new Date(parseInt(startMs)).toISOString()); + console.log('>>> 计费结束:', new Date(parseInt(endMs)).toISOString()); + + // 4. 获取使用事件 - 不带 userId + await testFilteredUsageNoUser(startMs, endMs); + + // 4b. 如果有 userId,也试试带 userId 的 + if (userId) { + await testFilteredUsage(userId, startMs, endMs); + } + + // 5. 获取聚合用量 + if (startMs) { + await testAggregatedUsage(startMs); + } + + console.log('\n===================================================='); + console.log(' 测试完成'); + console.log('===================================================='); + + } catch (error) { + console.error('测试出错:', error.message); + } +} + +main(); diff --git a/参考计费/.CLAUDE.md b/参考计费/.CLAUDE.md new file mode 100644 index 0000000..b39773e --- /dev/null +++ b/参考计费/.CLAUDE.md @@ -0,0 +1,736 @@ +# Project Overview + +> 参见 Tuist/模块化细节与常见问题排查:`.cursor/rules/tuist.mdc` +> 参见 项目模块化架构设计及新增代码规范: `.cursor/rules/architecture.mdc` + +This is a native **MacOS MenuBar application** built with **Swift 6.1+** and **SwiftUI**. The codebase targets **iOS 18.0 and later**, allowing full use of modern Swift and iOS APIs. All concurrency is handled with **Swift Concurrency** (async/await, actors, @MainActor isolation) ensuring thread-safe code. + +- **Frameworks & Tech:** SwiftUI for UI, Swift Concurrency with strict mode, Swift Package Manager for modular architecture +- **Architecture:** Model-View (MV) pattern using pure SwiftUI state management. We avoid MVVM and instead leverage SwiftUI's built-in state mechanisms (@State, @Observable, @Environment, @Binding) +- **Testing:** Swift Testing framework with modern @Test macros and #expect/#require assertions +- **Platform:** iOS (Simulator and Device) +- **Accessibility:** Full accessibility support using SwiftUI's accessibility modifiers + +## Project Structure + +The project follows a **workspace + SPM package** architecture: + +``` +YourApp/ +├── Config/ # XCConfig build settings +│ ├── Debug.xcconfig +│ ├── Release.xcconfig +│ ├── Shared.xcconfig +│ └── Tests.xcconfig +├── YourApp.xcworkspace/ # Workspace container +├── YourApp.xcodeproj/ # App shell (minimal wrapper) +├── YourApp/ # App target - just the entry point +│ ├── Assets.xcassets/ +│ ├── YourAppApp.swift # @main entry point only +│ └── YourApp.xctestplan +├── YourAppPackage/ # All features and business logic +│ ├── Package.swift +│ ├── Sources/ +│ │ └── YourAppFeature/ # Feature modules +│ └── Tests/ +│ └── YourAppFeatureTests/ # Swift Testing tests +└── YourAppUITests/ # UI automation tests +``` + +**Important:** All development work should be done in the **YourAppPackage** Swift Package, not in the app project. The app project is merely a thin wrapper that imports and launches the package features. + +# Code Quality & Style Guidelines + +## Swift Style & Conventions + +- **Naming:** Use `UpperCamelCase` for types, `lowerCamelCase` for properties/functions. Choose descriptive names (e.g., `calculateMonthlyRevenue()` not `calcRev`) +- **Value Types:** Prefer `struct` for models and data, use `class` only when reference semantics are required +- **Enums:** Leverage Swift's powerful enums with associated values for state representation +- **Early Returns:** Prefer early return pattern over nested conditionals to avoid pyramid of doom + +## Optionals & Error Handling + +- Use optionals with `if let`/`guard let` for nil handling +- Never force-unwrap (`!`) without absolute certainty - prefer `guard` with failure path +- Use `do/try/catch` for error handling with meaningful error types +- Handle or propagate all errors - no empty catch blocks + +# Modern SwiftUI Architecture Guidelines (2025) + +### No ViewModels - Use Native SwiftUI Data Flow +**New features MUST follow these patterns:** + +1. **Views as Pure State Expressions** + ```swift + struct MyView: View { + @Environment(MyService.self) private var service + @State private var viewState: ViewState = .loading + + enum ViewState { + case loading + case loaded(data: [Item]) + case error(String) + } + + var body: some View { + // View is just a representation of its state + } + } + ``` + +2. **Use Environment Appropriately** + - **App-wide services**: Router, Theme, CurrentAccount, Client, etc. - use `@Environment` + - **Feature-specific services**: Timeline services, single-view logic - use `let` properties with `@Observable` + - Rule: Environment for cross-app/cross-feature dependencies, let properties for single-feature services + - Access app-wide via `@Environment(ServiceType.self)` + - Feature services: `private let myService = MyObservableService()` + +3. **Local State Management** + - Use `@State` for view-specific state + - Use `enum` for view states (loading, loaded, error) + - Use `.task(id:)` and `.onChange(of:)` for side effects + - Pass state between views using `@Binding` + +4. **No ViewModels Required** + - Views should be lightweight and disposable + - Business logic belongs in services/clients + - Test services independently, not views + - Use SwiftUI previews for visual testing + +5. **When Views Get Complex** + - Split into smaller subviews + - Use compound views that compose smaller views + - Pass state via bindings between views + - Never reach for a ViewModel as the solution + +# iOS 26 Features (Optional) + +**Note**: If your app targets iOS 26+, you can take advantage of these cutting-edge SwiftUI APIs introduced in June 2025. These features are optional and should only be used when your deployment target supports iOS 26. + +## Available iOS 26 SwiftUI APIs + +When targeting iOS 26+, consider using these new APIs: + +#### Liquid Glass Effects +- `glassEffect(_:in:isEnabled:)` - Apply Liquid Glass effects to views +- `buttonStyle(.glass)` - Apply Liquid Glass styling to buttons +- `ToolbarSpacer` - Create visual breaks in toolbars with Liquid Glass + +#### Enhanced Scrolling +- `scrollEdgeEffectStyle(_:for:)` - Configure scroll edge effects +- `backgroundExtensionEffect()` - Duplicate, mirror, and blur views around edges + +#### Tab Bar Enhancements +- `tabBarMinimizeBehavior(_:)` - Control tab bar minimization behavior +- Search role for tabs with search field replacing tab bar +- `TabViewBottomAccessoryPlacement` - Adjust accessory view content based on placement + +#### Web Integration +- `WebView` and `WebPage` - Full control over browsing experience + +#### Drag and Drop +- `draggable(_:_:)` - Drag multiple items +- `dragContainer(for:id:in:selection:_:)` - Container for draggable views + +#### Animation +- `@Animatable` macro - SwiftUI synthesizes custom animatable data properties + +#### UI Components +- `Slider` with automatic tick marks when using step parameter +- `windowResizeAnchor(_:)` - Set window anchor point for resizing + +#### Text Enhancements +- `TextEditor` now supports `AttributedString` +- `AttributedTextSelection` - Handle text selection with attributed text +- `AttributedTextFormattingDefinition` - Define text styling in specific contexts +- `FindContext` - Create find navigator in text editing views + +#### Accessibility +- `AssistiveAccess` - Support Assistive Access in iOS scenes + +#### HDR Support +- `Color.ResolvedHDR` - RGBA values with HDR headroom information + +#### UIKit Integration +- `UIHostingSceneDelegate` - Host and present SwiftUI scenes in UIKit +- `NSGestureRecognizerRepresentable` - Incorporate gesture recognizers from AppKit + +#### Immersive Spaces (if applicable) +- `manipulable(coordinateSpace:operations:inertia:isEnabled:onChanged:)` - Hand gesture manipulation +- `SurfaceSnappingInfo` - Snap volumes and windows to surfaces +- `RemoteImmersiveSpace` - Render stereo content from Mac to Apple Vision Pro +- `SpatialContainer` - 3D layout container +- Depth-based modifiers: `aspectRatio3D(_:contentMode:)`, `rotation3DLayout(_:)`, `depthAlignment(_:)` + +## iOS 26 Usage Guidelines +- **Only use when targeting iOS 26+**: Ensure your deployment target supports these APIs +- **Progressive enhancement**: Use availability checks if supporting multiple iOS versions +- **Feature detection**: Test on older simulators to ensure graceful fallbacks +- **Modern aesthetics**: Leverage Liquid Glass effects for cutting-edge UI design + +```swift +// Example: Using iOS 26 features with availability checks +struct ModernButton: View { + var body: some View { + Button("Tap me") { + // Action + } + .buttonStyle({ + if #available(iOS 26.0, *) { + .glass + } else { + .bordered + } + }()) + } +} +``` + +## SwiftUI State Management (MV Pattern) + +- **@State:** For all state management, including observable model objects +- **@Observable:** Modern macro for making model classes observable (replaces ObservableObject) +- **@Environment:** For dependency injection and shared app state +- **@Binding:** For two-way data flow between parent and child views +- **@Bindable:** For creating bindings to @Observable objects +- Avoid ViewModels - put view logic directly in SwiftUI views using these state mechanisms +- Keep views focused and extract reusable components + +Example with @Observable: +```swift +@Observable +class UserSettings { + var theme: Theme = .light + var fontSize: Double = 16.0 +} + +@MainActor +struct SettingsView: View { + @State private var settings = UserSettings() + + var body: some View { + VStack { + // Direct property access, no $ prefix needed + Text("Font Size: \(settings.fontSize)") + + // For bindings, use @Bindable + @Bindable var settings = settings + Slider(value: $settings.fontSize, in: 10...30) + } + } +} + +// Sharing state across views +@MainActor +struct ContentView: View { + @State private var userSettings = UserSettings() + + var body: some View { + NavigationStack { + MainView() + .environment(userSettings) + } + } +} + +@MainActor +struct MainView: View { + @Environment(UserSettings.self) private var settings + + var body: some View { + Text("Current theme: \(settings.theme)") + } +} +``` + +Example with .task modifier for async operations: +```swift +@Observable +class DataModel { + var items: [Item] = [] + var isLoading = false + + func loadData() async throws { + isLoading = true + defer { isLoading = false } + + // Simulated network call + try await Task.sleep(for: .seconds(1)) + items = try await fetchItems() + } +} + +@MainActor +struct ItemListView: View { + @State private var model = DataModel() + + var body: some View { + List(model.items) { item in + Text(item.name) + } + .overlay { + if model.isLoading { + ProgressView() + } + } + .task { + // This task automatically cancels when view disappears + do { + try await model.loadData() + } catch { + // Handle error + } + } + .refreshable { + // Pull to refresh also uses async/await + try? await model.loadData() + } + } +} +``` + +## Concurrency + +- **@MainActor:** All UI updates must use @MainActor isolation +- **Actors:** Use actors for expensive operations like disk I/O, network calls, or heavy computation +- **async/await:** Always prefer async functions over completion handlers +- **Task:** Use structured concurrency with proper task cancellation +- **.task modifier:** Always use .task { } on views for async operations tied to view lifecycle - it automatically handles cancellation +- **Avoid Task { } in onAppear:** This doesn't cancel automatically and can cause memory leaks or crashes +- No GCD usage - Swift Concurrency only + +### Sendable Conformance + +Swift 6 enforces strict concurrency checking. All types that cross concurrency boundaries must be Sendable: + +- **Value types (struct, enum):** Usually Sendable if all properties are Sendable +- **Classes:** Must be marked `final` and have immutable or Sendable properties, or use `@unchecked Sendable` with thread-safe implementation +- **@Observable classes:** Automatically Sendable when all properties are Sendable +- **Closures:** Mark as `@Sendable` when captured by concurrent contexts + +```swift +// Sendable struct - automatic conformance +struct UserData: Sendable { + let id: UUID + let name: String +} + +// Sendable class - must be final with immutable properties +final class Configuration: Sendable { + let apiKey: String + let endpoint: URL + + init(apiKey: String, endpoint: URL) { + self.apiKey = apiKey + self.endpoint = endpoint + } +} + +// @Observable with Sendable +@Observable +final class UserModel: Sendable { + var name: String = "" + var age: Int = 0 + // Automatically Sendable if all stored properties are Sendable +} + +// Using @unchecked Sendable for thread-safe types +final class Cache: @unchecked Sendable { + private let lock = NSLock() + private var storage: [String: Any] = [:] + + func get(_ key: String) -> Any? { + lock.withLock { storage[key] } + } +} + +// @Sendable closures +func processInBackground(completion: @Sendable @escaping (Result) -> Void) { + Task { + // Processing... + completion(.success(data)) + } +} +``` + +## Code Organization + +- Keep functions focused on a single responsibility +- Break large functions (>50 lines) into smaller, testable units +- Use extensions to organize code by feature or protocol conformance +- Prefer `let` over `var` - use immutability by default +- Use `[weak self]` in closures to prevent retain cycles +- Always include `self.` when referring to instance properties in closures + +# Testing Guidelines + +We use **Swift Testing** framework (not XCTest) for all tests. Tests live in the package test target. + +## Swift Testing Basics + +```swift +import Testing + +@Test func userCanLogin() async throws { + let service = AuthService() + let result = try await service.login(username: "test", password: "pass") + #expect(result.isSuccess) + #expect(result.user.name == "Test User") +} + +@Test("User sees error with invalid credentials") +func invalidLogin() async throws { + let service = AuthService() + await #expect(throws: AuthError.self) { + try await service.login(username: "", password: "") + } +} +``` + +## Key Swift Testing Features + +- **@Test:** Marks a test function (replaces XCTest's test prefix) +- **@Suite:** Groups related tests together +- **#expect:** Validates conditions (replaces XCTAssert) +- **#require:** Like #expect but stops test execution on failure +- **Parameterized Tests:** Use @Test with arguments for data-driven tests +- **async/await:** Full support for testing async code +- **Traits:** Add metadata like `.bug()`, `.feature()`, or custom tags + +## Test Organization + +- Write tests in the package's Tests/ directory +- One test file per source file when possible +- Name tests descriptively explaining what they verify +- Test both happy paths and edge cases +- Add tests for bug fixes to prevent regression + +# Entitlements Management + +This template includes a **declarative entitlements system** that AI agents can safely modify without touching Xcode project files. + +## How It Works + +- **Entitlements File**: `Config/MyProject.entitlements` contains all app capabilities +- **XCConfig Integration**: `CODE_SIGN_ENTITLEMENTS` setting in `Config/Shared.xcconfig` points to the entitlements file +- **AI-Friendly**: Agents can edit the XML file directly to add/remove capabilities + +## Adding Entitlements + +To add capabilities to your app, edit `Config/MyProject.entitlements`: + +## Common Entitlements + +| Capability | Entitlement Key | Value | +|------------|-----------------|-------| +| HealthKit | `com.apple.developer.healthkit` | `` | +| CloudKit | `com.apple.developer.icloud-services` | `CloudKit` | +| Push Notifications | `aps-environment` | `development` or `production` | +| App Groups | `com.apple.security.application-groups` | `group.id` | +| Keychain Sharing | `keychain-access-groups` | `$(AppIdentifierPrefix)bundle.id` | +| Background Modes | `com.apple.developer.background-modes` | `mode-name` | +| Contacts | `com.apple.developer.contacts.notes` | `` | +| Camera | `com.apple.developer.avfoundation.audio` | `` | + +# XcodeBuildMCP Tool Usage + +To work with this project, build, test, and development commands should use XcodeBuildMCP tools instead of raw command-line calls. + +## Project Discovery & Setup + +```javascript +// Discover Xcode projects in the workspace +discover_projs({ + workspaceRoot: "/path/to/YourApp" +}) + +// List available schemes +list_schems_ws({ + workspacePath: "/path/to/YourApp.xcworkspace" +}) +``` + +## Building for Simulator + +```javascript +// Build for iPhone simulator by name +build_sim_name_ws({ + workspacePath: "/path/to/YourApp.xcworkspace", + scheme: "YourApp", + simulatorName: "iPhone 16", + configuration: "Debug" +}) + +// Build and run in one step +build_run_sim_name_ws({ + workspacePath: "/path/to/YourApp.xcworkspace", + scheme: "YourApp", + simulatorName: "iPhone 16" +}) +``` + +## Building for Device + +```javascript +// List connected devices first +list_devices() + +// Build for physical device +build_dev_ws({ + workspacePath: "/path/to/YourApp.xcworkspace", + scheme: "YourApp", + configuration: "Debug" +}) +``` + +## Testing + +```javascript +// Run tests on simulator +test_sim_name_ws({ + workspacePath: "/path/to/YourApp.xcworkspace", + scheme: "YourApp", + simulatorName: "iPhone 16" +}) + +// Run tests on device +test_device_ws({ + workspacePath: "/path/to/YourApp.xcworkspace", + scheme: "YourApp", + deviceId: "DEVICE_UUID_HERE" +}) + +// Test Swift Package +swift_package_test({ + packagePath: "/path/to/YourAppPackage" +}) +``` + +## Simulator Management + +```javascript +// List available simulators +list_sims({ + enabled: true +}) + +// Boot simulator +boot_sim({ + simulatorUuid: "SIMULATOR_UUID" +}) + +// Install app +install_app_sim({ + simulatorUuid: "SIMULATOR_UUID", + appPath: "/path/to/YourApp.app" +}) + +// Launch app +launch_app_sim({ + simulatorUuid: "SIMULATOR_UUID", + bundleId: "com.example.YourApp" +}) +``` + +## Device Management + +```javascript +// Install on device +install_app_device({ + deviceId: "DEVICE_UUID", + appPath: "/path/to/YourApp.app" +}) + +// Launch on device +launch_app_device({ + deviceId: "DEVICE_UUID", + bundleId: "com.example.YourApp" +}) +``` + +## UI Automation + +```javascript +// Get UI hierarchy +describe_ui({ + simulatorUuid: "SIMULATOR_UUID" +}) + +// Tap element +tap({ + simulatorUuid: "SIMULATOR_UUID", + x: 100, + y: 200 +}) + +// Type text +type_text({ + simulatorUuid: "SIMULATOR_UUID", + text: "Hello World" +}) + +// Take screenshot +screenshot({ + simulatorUuid: "SIMULATOR_UUID" +}) +``` + +## Log Capture + +```javascript +// Start capturing simulator logs +start_sim_log_cap({ + simulatorUuid: "SIMULATOR_UUID", + bundleId: "com.example.YourApp" +}) + +// Stop and retrieve logs +stop_sim_log_cap({ + logSessionId: "SESSION_ID" +}) + +// Device logs +start_device_log_cap({ + deviceId: "DEVICE_UUID", + bundleId: "com.example.YourApp" +}) +``` + +## Utility Functions + +```javascript +// Get bundle ID from app +get_app_bundle_id({ + appPath: "/path/to/YourApp.app" +}) + +// Clean build artifacts +clean_ws({ + workspacePath: "/path/to/YourApp.xcworkspace" +}) + +// Get app path for simulator +get_sim_app_path_name_ws({ + workspacePath: "/path/to/YourApp.xcworkspace", + scheme: "YourApp", + platform: "iOS Simulator", + simulatorName: "iPhone 16" +}) +``` + +# Development Workflow + +1. **Make changes in the Package**: All feature development happens in YourAppPackage/Sources/ +2. **Write tests**: Add Swift Testing tests in YourAppPackage/Tests/ +3. **Build and test**: Use XcodeBuildMCP tools to build and run tests +4. **Run on simulator**: Deploy to simulator for manual testing +5. **UI automation**: Use describe_ui and automation tools for UI testing +6. **Device testing**: Deploy to physical device when needed + +# Best Practices + +## SwiftUI & State Management + +- Keep views small and focused +- Extract reusable components into their own files +- Use @ViewBuilder for conditional view composition +- Leverage SwiftUI's built-in animations and transitions +- Avoid massive body computations - break them down +- **Always use .task modifier** for async work tied to view lifecycle - it automatically cancels when the view disappears +- Never use Task { } in onAppear - use .task instead for proper lifecycle management + +## Performance + +- Use .id() modifier sparingly as it forces view recreation +- Implement Equatable on models to optimize SwiftUI diffing +- Use LazyVStack/LazyHStack for large lists +- Profile with Instruments when needed +- @Observable tracks only accessed properties, improving performance over @Published + +## Accessibility + +- Always provide accessibilityLabel for interactive elements +- Use accessibilityIdentifier for UI testing +- Implement accessibilityHint where actions aren't obvious +- Test with VoiceOver enabled +- Support Dynamic Type + +## Security & Privacy + +- Never log sensitive information +- Use Keychain for credential storage +- All network calls must use HTTPS +- Request minimal permissions +- Follow App Store privacy guidelines + +## Data Persistence + +When data persistence is required, always prefer **SwiftData** over CoreData. However, carefully consider whether persistence is truly necessary - many apps can function well with in-memory state that loads on launch. + +### When to Use SwiftData + +- You have complex relational data that needs to persist across app launches +- You need advanced querying capabilities with predicates and sorting +- You're building a data-heavy app (note-taking, inventory, task management) +- You need CloudKit sync with minimal configuration + +### When NOT to Use Data Persistence + +- Simple user preferences (use UserDefaults) +- Temporary state that can be reloaded from network +- Small configuration data (consider JSON files or plist) +- Apps that primarily display remote data + +### SwiftData Best Practices + +```swift +import SwiftData + +@Model +final class Task { + var title: String + var isCompleted: Bool + var createdAt: Date + + init(title: String) { + self.title = title + self.isCompleted = false + self.createdAt = Date() + } +} + +// In your app +@main +struct MyProjectApp: App { + var body: some Scene { + WindowGroup { + ContentView() + .modelContainer(for: Task.self) + } + } +} + +// In your views +struct TaskListView: View { + @Query private var tasks: [Task] + @Environment(\.modelContext) private var context + + var body: some View { + List(tasks) { task in + Text(task.title) + } + .toolbar { + Button("Add") { + let newTask = Task(title: "New Task") + context.insert(newTask) + } + } + } +} +``` + +**Important:** Never use CoreData for new projects. SwiftData provides a modern, type-safe API that's easier to work with and integrates seamlessly with SwiftUI. + +--- + +Remember: This project prioritizes clean, simple SwiftUI code using the platform's native state management. Keep the app shell minimal and implement all features in the Swift Package. \ No newline at end of file diff --git a/参考计费/.cursor/commands/release_version.md b/参考计费/.cursor/commands/release_version.md new file mode 100644 index 0000000..35a0218 --- /dev/null +++ b/参考计费/.cursor/commands/release_version.md @@ -0,0 +1,47 @@ +# Release Version Command + +## Description +Automatically bump version number, build DMG package, create GitHub PR and release with English descriptions. + +## Usage +``` +@release_version [version_type] +``` + +## Parameters +- `version_type` (optional): Type of version bump + - `patch` (default): 1.1.1 → 1.1.2 + - `minor`: 1.1.1 → 1.2.0 + - `major`: 1.1.1 → 2.0.0 + +## Examples +``` +@release_version +@release_version patch +@release_version minor +@release_version major +``` + +## What it does +1. **Version Bump**: Updates version in `Scripts/create_dmg.sh` and `Derived/InfoPlists/Vibeviewer-Info.plist` +2. **Build DMG**: Runs `make dmg` to create installation package +3. **Git Operations**: Commits changes and pushes to current branch +4. **Create PR**: Creates GitHub PR with English description +5. **Create Release**: Creates GitHub release with DMG attachment and English release notes + +## Prerequisites +- GitHub CLI (`gh`) installed and authenticated +- Current branch pushed to remote +- Make sure you're in the project root directory + +## Output +- Updated version files +- Built DMG package +- GitHub PR link +- GitHub Release link + +## Notes +- The command will automatically detect the current version and increment accordingly +- All descriptions will be in English +- The DMG file will be automatically attached to the release +- Make sure you have write permissions to the repository diff --git a/参考计费/.cursor/mcp.json b/参考计费/.cursor/mcp.json new file mode 100644 index 0000000..c69bfea --- /dev/null +++ b/参考计费/.cursor/mcp.json @@ -0,0 +1,18 @@ +{ + "mcpServers": { + "tuist": { + "command": "/opt/homebrew/bin/tuist", + "args": [ + "mcp", + "start" + ] + }, + "XcodeBuildMCP": { + "command": "npx", + "args": [ + "-y", + "xcodebuildmcp@latest" + ] + } + } +} \ No newline at end of file diff --git a/参考计费/.cursor/rules/api_guideline.mdc b/参考计费/.cursor/rules/api_guideline.mdc new file mode 100644 index 0000000..294e0e6 --- /dev/null +++ b/参考计费/.cursor/rules/api_guideline.mdc @@ -0,0 +1,149 @@ +--- +alwaysApply: false +--- +# API Authoring Guidelines (VibeviewerAPI) + +## Goals +- Unify API naming, directories, abstractions, dependency injection, and decoding patterns +- Keep all APIs in a single module `VibeviewerAPI` to enforce isolation and modularity +- Standardize `DecodableTargetType` and the `HttpClient.decodableRequest(_:)` usage (async/await only) on top of Moya/Alamofire + +## Hard rules +- API targets must be declared with `struct` (no `enum`/case-style targets) +- Use async/await-only decoding; callback-based styles are forbidden +- Separate API declarations from model declarations: + - API Targets/Services → `VibeviewerAPI` + - Data models/aggregations → `VibeviewerModel` +- Views/upper layers must use `Service` protocols via dependency injection, and must not call API targets or `HttpClient` directly +- The API module only exposes `Service` protocols and default implementations; API targets, networking details, and common header configuration remain internal + +## Dependencies & imports +- API module imports only: + - `Foundation` + - `Moya` + - `Alamofire` (used via `HttpClient`) + - `VibeviewerModel` +- Never import UI frameworks in the API module (`SwiftUI`/`AppKit`/`UIKit`) + +## Naming conventions +- Targets: Feature name + `API`, e.g., `YourFeatureAPI` +- Protocols: `YourFeatureService` +- Default implementations: `DefaultYourFeatureService` +- Models: `YourFeatureResponse`, `YourFeatureDetail`, etc. + +## Directory structure (VibeviewerAPI) +```text +VibeviewerAPI/ + Sources/VibeviewerAPI/ + Mapping/ + ... DTOs & Mappers + Plugins/ + RequestHeaderConfigurationPlugin.swift + RequestErrorHandlingPlugin.swift + SimpleNetworkLoggerPlugin.swift + Service/ + MoyaProvider+DecodableRequest.swift + HttpClient.swift # Unified Moya provider & session wrapper + HttpClientError.swift + Targets/ + CursorGetMeAPI.swift # internal target + CursorUsageAPI.swift # internal target + CursorTeamSpendAPI.swift # internal target + CursorService.swift # public protocol + default implementation (service only) +``` + +## Target and decoding conventions +- Targets conform to `DecodableTargetType`: + - `associatedtype ResultType: Decodable` + - `var decodeAtKeyPath: String? { get }` (default `nil`) + - Implement `baseURL`, `path`, `method`, `task`, `headers`, `sampleData` + - Avoid overriding `validationType` unless necessary + +Example: +```swift +import Foundation +import Moya +import VibeviewerModel + +struct UserProfileDetailAPI: DecodableTargetType { + typealias ResultType = UserProfileResponse + + let userId: String + + var baseURL: URL { APIConfig.baseURL } + var path: String { "/users/\(userId)" } + var method: Moya.Method { .get } + var task: Task { .requestPlain } + var headers: [String: String]? { APIHeadersBuilder.basicHeaders(cookieHeader: nil) } + var sampleData: Data { Data("{\"id\":\"1\",\"name\":\"foo\"}".utf8) } +} +``` + +## Service abstraction & dependency injection +- Expose protocol + default implementation (expose services only; hide networking details) +- The default `public init(decoding:)` must not leak internal protocol types; provide `internal init(network:decoding:)` for test injection + +```swift +import Foundation +import Moya +import VibeviewerModel + +public protocol UserProfileService { + func fetchDetail(userId: String) async throws -> UserProfileResponse +} + +public struct DefaultUserProfileService: UserProfileService { + private let network: NetworkClient + private let decoding: JSONDecoder.KeyDecodingStrategy + + // Business-facing: do not expose internal NetworkClient abstraction + public init(decoding: JSONDecoder.KeyDecodingStrategy = .useDefaultKeys) { + self.network = DefaultNetworkClient() + self.decoding = decoding + } + + // Test injection: available within the API module (same package or @testable) + init(network: any NetworkClient, decoding: JSONDecoder.KeyDecodingStrategy = .useDefaultKeys) { + self.network = network + self.decoding = decoding + } + + public func fetchDetail(userId: String) async throws -> UserProfileResponse { + try await network.decodableRequest( + UserProfileDetailAPI(userId: userId), + decodingStrategy: decoding + ) + } +} +``` + +> Note: `DefaultNetworkClient`, the `NetworkClient` protocol, and the concrete `HttpClient` implementation details remain `internal` and are not exposed. + +## View usage (dependency injection) +Views must not call API targets or `HttpClient` directly. Use injected services instead: +```swift +import VibeviewerAPI +import VibeviewerModel + +let service: UserProfileService = DefaultUserProfileService() +let model = try await service.fetchDetail(userId: "1") +``` + +## Error handling & logging +- Enable `SimpleNetworkLoggerPlugin` by default to log requests/responses +- Enable `RequestErrorHandlingPlugin` by default: + - Timeouts/offline → unified handling + - Customizable via strategy protocols + +## Testing & mock conventions +- Within the `VibeviewerAPI` module, inject a `FakeNetworkClient` via `internal init(network:decoding:)` to replace real networking +- Provide `sampleData` for each target; prefer minimal realistic JSON to ensure robust decoding +- Use `@testable import VibeviewerAPI` to access internal symbols when external tests are required + +## Alignment with modular architecture (architecture.mdc) +- Do not import UI frameworks in the API module +- Expose only `Service` protocols and default implementations; hide targets and networking details +- Dependency direction: `VibeviewerModel` ← `VibeviewerAPI` ← `VibeviewerFeature` +- Strict “one file, one type/responsibility”; clear feature aggregation; one-way dependencies + + diff --git a/参考计费/.cursor/rules/architecture.mdc b/参考计费/.cursor/rules/architecture.mdc new file mode 100644 index 0000000..21a4502 --- /dev/null +++ b/参考计费/.cursor/rules/architecture.mdc @@ -0,0 +1,108 @@ +--- +alwaysApply: true +title: Vibeviewer Architecture Guidelines +--- + +## Background + +- The project uses a layered, modular Swift Package architecture with goals: minimal public surface, one-way dependencies, single responsibility, testability, and replaceability. +- Layers and dependency direction (top-down only): + - Core/Shared → common utilities and extensions (no business-layer dependencies) + - Model → pure data/DTO/domain entities (may depend on Core) + - API/Service → networking/IO/3rd-party orchestration and DTO→domain mapping (depends on Model + 3rd-party) + - Feature/UI → SwiftUI views and interactions (depends on API-exposed service protocols and domain models; must not depend on networking libraries) +- Architectural style: Native SwiftUI MV (not MVVM). State via @State/@Observable; dependency injection via @Environment; concurrency with async/await and @MainActor. + +## Do (Recommended) + +- Module placement & responsibilities + - Before adding code, decide whether it belongs to UI/Service/Model/Core and place it in the corresponding package/directory; one type/responsibility per file. + - The API layer exposes only “service protocol + default implementation”; networking library/targets/plugins are encapsulated internally. + - Service functions return domain models (Model-layer entities) or clear error types; avoid leaking DTOs to the UI. + +- Domain models & mapping + - Abstract API response DTOs into domain entities (e.g., UserProfile / UsageOverview / TeamSpendOverview / UsageEvent / FilteredUsageHistory). + - Perform DTO→domain mapping in the API layer; UI consumes domain-only. + +- Dependencies & visibility + - One-way: Core ← Model ← API ← Feature. + - Default to internal; use public only for cross-package use; prefer protocols over concrete types. + +- SwiftUI & concurrency + - Inject services via @Environment; place side effects in .task / .onChange so they automatically cancel with the view lifecycle. + - UI updates occur on @MainActor; networking/IO on background using async/await; cross-isolation types must be Sendable. + +- Testing & replaceability + - Provide an injectable network client interface for services; separate default implementation from testable construction paths. + - Put utilities/algorithms into Core; prefer pure functions for unit testing and reuse. + +- Troble Shooting + - if you facing an lint error by "can't not found xxx in scope" when you edit/new/delete some interface on Package, that means you need to call XCodeBuildMCP to rebuild that package, so that other package can update the codebase to fix that error + +## Don't (Avoid) + +- UI directly depending on networking libraries, triggering requests, or being exposed to backend error details. +- Feature depending on API internals (e.g., Targets/Plugins/concrete networking implementations). +- Exposing API DTOs directly to the UI (causes global coupling and fragility). +- Reverse dependencies (e.g., Model depends on Feature; API depends on UI). +- Introducing MVVM/ViewModel as the default; or using Task { } in onAppear (use .task instead). +- Overusing public types/initializers; placing multiple unrelated types in one file. + +## Review checklist + +1) Quadrant self-check (placement) + - UI/interaction/rendering → Feature/UI + - Networking/disk/auth/3rd-party → API/Service + - Pure data/DTO/state aggregation → Model + - Utilities/extensions/algorithms → Core + +2) Surface area & replaceability + - Can it be exposed via protocol to hide details? Is internal sufficient by default? + - Do services return only domain models/error enums? Is it easy to replace/mock? + +3) Dependency direction & coupling + - Any violation of Core ← Model ← API ← Feature one-way dependency? + - Does the UI still reference DTOs or networking implementations? If yes, move mapping/abstraction to the API layer. + +4) Concurrency & thread safety + - Are UI updates on @MainActor? Are cross-isolation types Sendable? Are we using async/await? + - Should serialization-required persistence/cache be placed within an Actor boundary? + +5) File organization & naming + - Clear directories (Feature/Views, API/Service, API/Targets, API/Plugins, Model/Entities, Core/Extensions). + - One type per file; names reflect layer and responsibility (e.g., FeatureXView, FeatureXService, GetYAPI, ZResponse). + - Package directory structure: Sources// organized by feature subfolders; avoid dumping all source at one level. + - Suggested subfolders: + - API: Service / Targets / Plugins / Mapping (DTO→Domain mapping) + - Feature: Views / Components / Scenes / Modifiers + - Model: Entities + - Core: Extensions / Utils + - Consistent naming: use a shared prefix/suffix for similar features for discoverability. + - Suffix examples: …Service, …API, …Response, …Request, …View, …Section, …Window, …Plugin, …Mapper. + - Use a consistent domain/vendor prefix where needed (e.g., Cursor…). + - File name equals type name: each file contains only one primary type; exact case-sensitive match. + - Protocol/implementation convention: protocol uses FooService; default implementation uses DefaultFooService (or LiveFooService). Expose only protocols and inject implementations. + + - Model-layer naming (Entities vs DTOs): + - Entities (exposed to business/UI): + - Use domain-oriented neutral nouns; avoid vendor prefixes by default (e.g., UserProfile, UsageOverview, TeamSpendOverview, UsageEvent, FilteredUsageHistory, AppSettings, Credentials, DashboardSnapshot). + - If source domain must be shown (e.g., “Cursor”), use a consistent prefix within that domain (e.g., CursorCredentials, CursorDashboardSnapshot) for consistency and discoverability. + - Suggested suffixes: …Overview, …Snapshot, …History, …Event, …Member, …RoleCount. + - Prefer struct, value semantics, and Sendable; expose public types/members only when needed cross-package. + - File name equals type name; single-type files. + - DTOs (API layer only, under API/Mapping/DTOs): + - Use vendor/source prefix + semantic suffix: e.g., Cursor…Request, Cursor…Response, Cursor…Event. + - Default visibility is internal; do not expose to Feature/UI; map to domain in the API layer only. + - File name equals type name; single-type files; field names mirror backend responses (literal), adapted to domain naming via mapping. + - Mapping lives in the API layer (Service/Mapping); UI/Feature must never depend on DTOs. + +## Pre-PR checks + - Remove unnecessary public modifiers; check for reverse dependencies across layers. + - Ensure UI injects services via @Environment and contains no networking details. + - Ensure DTO→domain mapping is complete, robust, and testable. + +Note: When using iOS 26 features, follow availability checks and progressive enhancement; ensure reasonable fallbacks for older OS versions. + +## FAQ + - After adding/removing module code, if lint reports a missing class but you are sure it exists, rebuild the package with XcodeBuild MCP and try again. + diff --git a/参考计费/.cursor/rules/project.mdc b/参考计费/.cursor/rules/project.mdc new file mode 100644 index 0000000..7447776 --- /dev/null +++ b/参考计费/.cursor/rules/project.mdc @@ -0,0 +1,738 @@ +--- +alwaysApply: true +--- +# Project Overview + +> 参见 Tuist/模块化细节与常见问题排查:`.cursor/rules/tuist.mdc` + +This is a native **MacOS MenuBar application** built with **Swift 6.1+** and **SwiftUI**. The codebase targets **iOS 18.0 and later**, allowing full use of modern Swift and iOS APIs. All concurrency is handled with **Swift Concurrency** (async/await, actors, @MainActor isolation) ensuring thread-safe code. + +- **Frameworks & Tech:** SwiftUI for UI, Swift Concurrency with strict mode, Swift Package Manager for modular architecture +- **Architecture:** Model-View (MV) pattern using pure SwiftUI state management. We avoid MVVM and instead leverage SwiftUI's built-in state mechanisms (@State, @Observable, @Environment, @Binding) +- **Testing:** Swift Testing framework with modern @Test macros and #expect/#require assertions +- **Platform:** iOS (Simulator and Device) +- **Accessibility:** Full accessibility support using SwiftUI's accessibility modifiers + +## Project Structure + +The project follows a **workspace + SPM package** architecture: + +``` +YourApp/ +├── Config/ # XCConfig build settings +│ ├── Debug.xcconfig +│ ├── Release.xcconfig +│ ├── Shared.xcconfig +│ └── Tests.xcconfig +├── YourApp.xcworkspace/ # Workspace container +├── YourApp.xcodeproj/ # App shell (minimal wrapper) +├── YourApp/ # App target - just the entry point +│ ├── Assets.xcassets/ +│ ├── YourAppApp.swift # @main entry point only +│ └── YourApp.xctestplan +├── YourAppPackage/ # All features and business logic +│ ├── Package.swift +│ ├── Sources/ +│ │ └── YourAppFeature/ # Feature modules +│ └── Tests/ +│ └── YourAppFeatureTests/ # Swift Testing tests +└── YourAppUITests/ # UI automation tests +``` + +**Important:** All development work should be done in the **YourAppPackage** Swift Package, not in the app project. The app project is merely a thin wrapper that imports and launches the package features. + +# Code Quality & Style Guidelines + +## Swift Style & Conventions + +- **Naming:** Use `UpperCamelCase` for types, `lowerCamelCase` for properties/functions. Choose descriptive names (e.g., `calculateMonthlyRevenue()` not `calcRev`) +- **Value Types:** Prefer `struct` for models and data, use `class` only when reference semantics are required +- **Enums:** Leverage Swift's powerful enums with associated values for state representation +- **Early Returns:** Prefer early return pattern over nested conditionals to avoid pyramid of doom + +## Optionals & Error Handling + +- Use optionals with `if let`/`guard let` for nil handling +- Never force-unwrap (`!`) without absolute certainty - prefer `guard` with failure path +- Use `do/try/catch` for error handling with meaningful error types +- Handle or propagate all errors - no empty catch blocks + +# Modern SwiftUI Architecture Guidelines (2025) + +### No ViewModels - Use Native SwiftUI Data Flow +**New features MUST follow these patterns:** + +1. **Views as Pure State Expressions** + ```swift + struct MyView: View { + @Environment(MyService.self) private var service + @State private var viewState: ViewState = .loading + + enum ViewState { + case loading + case loaded(data: [Item]) + case error(String) + } + + var body: some View { + // View is just a representation of its state + } + } + ``` + +2. **Use Environment Appropriately** + - **App-wide services**: Router, Theme, CurrentAccount, Client, etc. - use `@Environment` + - **Feature-specific services**: Timeline services, single-view logic - use `let` properties with `@Observable` + - Rule: Environment for cross-app/cross-feature dependencies, let properties for single-feature services + - Access app-wide via `@Environment(ServiceType.self)` + - Feature services: `private let myService = MyObservableService()` + +3. **Local State Management** + - Use `@State` for view-specific state + - Use `enum` for view states (loading, loaded, error) + - Use `.task(id:)` and `.onChange(of:)` for side effects + - Pass state between views using `@Binding` + +4. **No ViewModels Required** + - Views should be lightweight and disposable + - Business logic belongs in services/clients + - Test services independently, not views + - Use SwiftUI previews for visual testing + +5. **When Views Get Complex** + - Split into smaller subviews + - Use compound views that compose smaller views + - Pass state via bindings between views + - Never reach for a ViewModel as the solution + +# iOS 26 Features (Optional) + +**Note**: If your app targets iOS 26+, you can take advantage of these cutting-edge SwiftUI APIs introduced in June 2025. These features are optional and should only be used when your deployment target supports iOS 26. + +## Available iOS 26 SwiftUI APIs + +When targeting iOS 26+, consider using these new APIs: + +#### Liquid Glass Effects +- `glassEffect(_:in:isEnabled:)` - Apply Liquid Glass effects to views +- `buttonStyle(.glass)` - Apply Liquid Glass styling to buttons +- `ToolbarSpacer` - Create visual breaks in toolbars with Liquid Glass + +#### Enhanced Scrolling +- `scrollEdgeEffectStyle(_:for:)` - Configure scroll edge effects +- `backgroundExtensionEffect()` - Duplicate, mirror, and blur views around edges + +#### Tab Bar Enhancements +- `tabBarMinimizeBehavior(_:)` - Control tab bar minimization behavior +- Search role for tabs with search field replacing tab bar +- `TabViewBottomAccessoryPlacement` - Adjust accessory view content based on placement + +#### Web Integration +- `WebView` and `WebPage` - Full control over browsing experience + +#### Drag and Drop +- `draggable(_:_:)` - Drag multiple items +- `dragContainer(for:id:in:selection:_:)` - Container for draggable views + +#### Animation +- `@Animatable` macro - SwiftUI synthesizes custom animatable data properties + +#### UI Components +- `Slider` with automatic tick marks when using step parameter +- `windowResizeAnchor(_:)` - Set window anchor point for resizing + +#### Text Enhancements +- `TextEditor` now supports `AttributedString` +- `AttributedTextSelection` - Handle text selection with attributed text +- `AttributedTextFormattingDefinition` - Define text styling in specific contexts +- `FindContext` - Create find navigator in text editing views + +#### Accessibility +- `AssistiveAccess` - Support Assistive Access in iOS scenes + +#### HDR Support +- `Color.ResolvedHDR` - RGBA values with HDR headroom information + +#### UIKit Integration +- `UIHostingSceneDelegate` - Host and present SwiftUI scenes in UIKit +- `NSGestureRecognizerRepresentable` - Incorporate gesture recognizers from AppKit + +#### Immersive Spaces (if applicable) +- `manipulable(coordinateSpace:operations:inertia:isEnabled:onChanged:)` - Hand gesture manipulation +- `SurfaceSnappingInfo` - Snap volumes and windows to surfaces +- `RemoteImmersiveSpace` - Render stereo content from Mac to Apple Vision Pro +- `SpatialContainer` - 3D layout container +- Depth-based modifiers: `aspectRatio3D(_:contentMode:)`, `rotation3DLayout(_:)`, `depthAlignment(_:)` + +## iOS 26 Usage Guidelines +- **Only use when targeting iOS 26+**: Ensure your deployment target supports these APIs +- **Progressive enhancement**: Use availability checks if supporting multiple iOS versions +- **Feature detection**: Test on older simulators to ensure graceful fallbacks +- **Modern aesthetics**: Leverage Liquid Glass effects for cutting-edge UI design + +```swift +// Example: Using iOS 26 features with availability checks +struct ModernButton: View { + var body: some View { + Button("Tap me") { + // Action + } + .buttonStyle({ + if #available(iOS 26.0, *) { + .glass + } else { + .bordered + } + }()) + } +} +``` + +## SwiftUI State Management (MV Pattern) + +- **@State:** For all state management, including observable model objects +- **@Observable:** Modern macro for making model classes observable (replaces ObservableObject) +- **@Environment:** For dependency injection and shared app state +- **@Binding:** For two-way data flow between parent and child views +- **@Bindable:** For creating bindings to @Observable objects +- Avoid ViewModels - put view logic directly in SwiftUI views using these state mechanisms +- Keep views focused and extract reusable components + +Example with @Observable: +```swift +@Observable +class UserSettings { + var theme: Theme = .light + var fontSize: Double = 16.0 +} + +@MainActor +struct SettingsView: View { + @State private var settings = UserSettings() + + var body: some View { + VStack { + // Direct property access, no $ prefix needed + Text("Font Size: \(settings.fontSize)") + + // For bindings, use @Bindable + @Bindable var settings = settings + Slider(value: $settings.fontSize, in: 10...30) + } + } +} + +// Sharing state across views +@MainActor +struct ContentView: View { + @State private var userSettings = UserSettings() + + var body: some View { + NavigationStack { + MainView() + .environment(userSettings) + } + } +} + +@MainActor +struct MainView: View { + @Environment(UserSettings.self) private var settings + + var body: some View { + Text("Current theme: \(settings.theme)") + } +} +``` + +Example with .task modifier for async operations: +```swift +@Observable +class DataModel { + var items: [Item] = [] + var isLoading = false + + func loadData() async throws { + isLoading = true + defer { isLoading = false } + + // Simulated network call + try await Task.sleep(for: .seconds(1)) + items = try await fetchItems() + } +} + +@MainActor +struct ItemListView: View { + @State private var model = DataModel() + + var body: some View { + List(model.items) { item in + Text(item.name) + } + .overlay { + if model.isLoading { + ProgressView() + } + } + .task { + // This task automatically cancels when view disappears + do { + try await model.loadData() + } catch { + // Handle error + } + } + .refreshable { + // Pull to refresh also uses async/await + try? await model.loadData() + } + } +} +``` + +## Concurrency + +- **@MainActor:** All UI updates must use @MainActor isolation +- **Actors:** Use actors for expensive operations like disk I/O, network calls, or heavy computation +- **async/await:** Always prefer async functions over completion handlers +- **Task:** Use structured concurrency with proper task cancellation +- **.task modifier:** Always use .task { } on views for async operations tied to view lifecycle - it automatically handles cancellation +- **Avoid Task { } in onAppear:** This doesn't cancel automatically and can cause memory leaks or crashes +- No GCD usage - Swift Concurrency only + +### Sendable Conformance + +Swift 6 enforces strict concurrency checking. All types that cross concurrency boundaries must be Sendable: + +- **Value types (struct, enum):** Usually Sendable if all properties are Sendable +- **Classes:** Must be marked `final` and have immutable or Sendable properties, or use `@unchecked Sendable` with thread-safe implementation +- **@Observable classes:** Automatically Sendable when all properties are Sendable +- **Closures:** Mark as `@Sendable` when captured by concurrent contexts + +```swift +// Sendable struct - automatic conformance +struct UserData: Sendable { + let id: UUID + let name: String +} + +// Sendable class - must be final with immutable properties +final class Configuration: Sendable { + let apiKey: String + let endpoint: URL + + init(apiKey: String, endpoint: URL) { + self.apiKey = apiKey + self.endpoint = endpoint + } +} + +// @Observable with Sendable +@Observable +final class UserModel: Sendable { + var name: String = "" + var age: Int = 0 + // Automatically Sendable if all stored properties are Sendable +} + +// Using @unchecked Sendable for thread-safe types +final class Cache: @unchecked Sendable { + private let lock = NSLock() + private var storage: [String: Any] = [:] + + func get(_ key: String) -> Any? { + lock.withLock { storage[key] } + } +} + +// @Sendable closures +func processInBackground(completion: @Sendable @escaping (Result) -> Void) { + Task { + // Processing... + completion(.success(data)) + } +} +``` + +## Code Organization + +- Keep functions focused on a single responsibility +- Break large functions (>50 lines) into smaller, testable units +- Use extensions to organize code by feature or protocol conformance +- Prefer `let` over `var` - use immutability by default +- Use `[weak self]` in closures to prevent retain cycles +- Always include `self.` when referring to instance properties in closures + +# Testing Guidelines + +We use **Swift Testing** framework (not XCTest) for all tests. Tests live in the package test target. + +## Swift Testing Basics + +```swift +import Testing + +@Test func userCanLogin() async throws { + let service = AuthService() + let result = try await service.login(username: "test", password: "pass") + #expect(result.isSuccess) + #expect(result.user.name == "Test User") +} + +@Test("User sees error with invalid credentials") +func invalidLogin() async throws { + let service = AuthService() + await #expect(throws: AuthError.self) { + try await service.login(username: "", password: "") + } +} +``` + +## Key Swift Testing Features + +- **@Test:** Marks a test function (replaces XCTest's test prefix) +- **@Suite:** Groups related tests together +- **#expect:** Validates conditions (replaces XCTAssert) +- **#require:** Like #expect but stops test execution on failure +- **Parameterized Tests:** Use @Test with arguments for data-driven tests +- **async/await:** Full support for testing async code +- **Traits:** Add metadata like `.bug()`, `.feature()`, or custom tags + +## Test Organization + +- Write tests in the package's Tests/ directory +- One test file per source file when possible +- Name tests descriptively explaining what they verify +- Test both happy paths and edge cases +- Add tests for bug fixes to prevent regression + +# Entitlements Management + +This template includes a **declarative entitlements system** that AI agents can safely modify without touching Xcode project files. + +## How It Works + +- **Entitlements File**: `Config/MyProject.entitlements` contains all app capabilities +- **XCConfig Integration**: `CODE_SIGN_ENTITLEMENTS` setting in `Config/Shared.xcconfig` points to the entitlements file +- **AI-Friendly**: Agents can edit the XML file directly to add/remove capabilities + +## Adding Entitlements + +To add capabilities to your app, edit `Config/MyProject.entitlements`: + +## Common Entitlements + +| Capability | Entitlement Key | Value | +|------------|-----------------|-------| +| HealthKit | `com.apple.developer.healthkit` | `` | +| CloudKit | `com.apple.developer.icloud-services` | `CloudKit` | +| Push Notifications | `aps-environment` | `development` or `production` | +| App Groups | `com.apple.security.application-groups` | `group.id` | +| Keychain Sharing | `keychain-access-groups` | `$(AppIdentifierPrefix)bundle.id` | +| Background Modes | `com.apple.developer.background-modes` | `mode-name` | +| Contacts | `com.apple.developer.contacts.notes` | `` | +| Camera | `com.apple.developer.avfoundation.audio` | `` | + +# XcodeBuildMCP Tool Usage + +To work with this project, build, test, and development commands should use XcodeBuildMCP tools instead of raw command-line calls. + +## Project Discovery & Setup + +```javascript +// Discover Xcode projects in the workspace +discover_projs({ + workspaceRoot: "/path/to/YourApp" +}) + +// List available schemes +list_schems_ws({ + workspacePath: "/path/to/YourApp.xcworkspace" +}) +``` + +## Building for Simulator + +```javascript +// Build for iPhone simulator by name +build_sim_name_ws({ + workspacePath: "/path/to/YourApp.xcworkspace", + scheme: "YourApp", + simulatorName: "iPhone 16", + configuration: "Debug" +}) + +// Build and run in one step +build_run_sim_name_ws({ + workspacePath: "/path/to/YourApp.xcworkspace", + scheme: "YourApp", + simulatorName: "iPhone 16" +}) +``` + +## Building for Device + +```javascript +// List connected devices first +list_devices() + +// Build for physical device +build_dev_ws({ + workspacePath: "/path/to/YourApp.xcworkspace", + scheme: "YourApp", + configuration: "Debug" +}) +``` + +## Testing + +```javascript +// Run tests on simulator +test_sim_name_ws({ + workspacePath: "/path/to/YourApp.xcworkspace", + scheme: "YourApp", + simulatorName: "iPhone 16" +}) + +// Run tests on device +test_device_ws({ + workspacePath: "/path/to/YourApp.xcworkspace", + scheme: "YourApp", + deviceId: "DEVICE_UUID_HERE" +}) + +// Test Swift Package +swift_package_test({ + packagePath: "/path/to/YourAppPackage" +}) +``` + +## Simulator Management + +```javascript +// List available simulators +list_sims({ + enabled: true +}) + +// Boot simulator +boot_sim({ + simulatorUuid: "SIMULATOR_UUID" +}) + +// Install app +install_app_sim({ + simulatorUuid: "SIMULATOR_UUID", + appPath: "/path/to/YourApp.app" +}) + +// Launch app +launch_app_sim({ + simulatorUuid: "SIMULATOR_UUID", + bundleId: "com.example.YourApp" +}) +``` + +## Device Management + +```javascript +// Install on device +install_app_device({ + deviceId: "DEVICE_UUID", + appPath: "/path/to/YourApp.app" +}) + +// Launch on device +launch_app_device({ + deviceId: "DEVICE_UUID", + bundleId: "com.example.YourApp" +}) +``` + +## UI Automation + +```javascript +// Get UI hierarchy +describe_ui({ + simulatorUuid: "SIMULATOR_UUID" +}) + +// Tap element +tap({ + simulatorUuid: "SIMULATOR_UUID", + x: 100, + y: 200 +}) + +// Type text +type_text({ + simulatorUuid: "SIMULATOR_UUID", + text: "Hello World" +}) + +// Take screenshot +screenshot({ + simulatorUuid: "SIMULATOR_UUID" +}) +``` + +## Log Capture + +```javascript +// Start capturing simulator logs +start_sim_log_cap({ + simulatorUuid: "SIMULATOR_UUID", + bundleId: "com.example.YourApp" +}) + +// Stop and retrieve logs +stop_sim_log_cap({ + logSessionId: "SESSION_ID" +}) + +// Device logs +start_device_log_cap({ + deviceId: "DEVICE_UUID", + bundleId: "com.example.YourApp" +}) +``` + +## Utility Functions + +```javascript +// Get bundle ID from app +get_app_bundle_id({ + appPath: "/path/to/YourApp.app" +}) + +// Clean build artifacts +clean_ws({ + workspacePath: "/path/to/YourApp.xcworkspace" +}) + +// Get app path for simulator +get_sim_app_path_name_ws({ + workspacePath: "/path/to/YourApp.xcworkspace", + scheme: "YourApp", + platform: "iOS Simulator", + simulatorName: "iPhone 16" +}) +``` + +# Development Workflow + +1. **Make changes in the Package**: All feature development happens in YourAppPackage/Sources/ +2. **Write tests**: Add Swift Testing tests in YourAppPackage/Tests/ +3. **Build and test**: Use XcodeBuildMCP tools to build and run tests +4. **Run on simulator**: Deploy to simulator for manual testing +5. **UI automation**: Use describe_ui and automation tools for UI testing +6. **Device testing**: Deploy to physical device when needed + +# Best Practices + +## SwiftUI & State Management + +- Keep views small and focused +- Extract reusable components into their own files +- Use @ViewBuilder for conditional view composition +- Leverage SwiftUI's built-in animations and transitions +- Avoid massive body computations - break them down +- **Always use .task modifier** for async work tied to view lifecycle - it automatically cancels when the view disappears +- Never use Task { } in onAppear - use .task instead for proper lifecycle management + +## Performance + +- Use .id() modifier sparingly as it forces view recreation +- Implement Equatable on models to optimize SwiftUI diffing +- Use LazyVStack/LazyHStack for large lists +- Profile with Instruments when needed +- @Observable tracks only accessed properties, improving performance over @Published + +## Accessibility + +- Always provide accessibilityLabel for interactive elements +- Use accessibilityIdentifier for UI testing +- Implement accessibilityHint where actions aren't obvious +- Test with VoiceOver enabled +- Support Dynamic Type + +## Security & Privacy + +- Never log sensitive information +- Use Keychain for credential storage +- All network calls must use HTTPS +- Request minimal permissions +- Follow App Store privacy guidelines + +## Data Persistence + +When data persistence is required, always prefer **SwiftData** over CoreData. However, carefully consider whether persistence is truly necessary - many apps can function well with in-memory state that loads on launch. + +### When to Use SwiftData + +- You have complex relational data that needs to persist across app launches +- You need advanced querying capabilities with predicates and sorting +- You're building a data-heavy app (note-taking, inventory, task management) +- You need CloudKit sync with minimal configuration + +### When NOT to Use Data Persistence + +- Simple user preferences (use UserDefaults) +- Temporary state that can be reloaded from network +- Small configuration data (consider JSON files or plist) +- Apps that primarily display remote data + +### SwiftData Best Practices + +```swift +import SwiftData + +@Model +final class Task { + var title: String + var isCompleted: Bool + var createdAt: Date + + init(title: String) { + self.title = title + self.isCompleted = false + self.createdAt = Date() + } +} + +// In your app +@main +struct MyProjectApp: App { + var body: some Scene { + WindowGroup { + ContentView() + .modelContainer(for: Task.self) + } + } +} + +// In your views +struct TaskListView: View { + @Query private var tasks: [Task] + @Environment(\.modelContext) private var context + + var body: some View { + List(tasks) { task in + Text(task.title) + } + .toolbar { + Button("Add") { + let newTask = Task(title: "New Task") + context.insert(newTask) + } + } + } +} +``` + +**Important:** Never use CoreData for new projects. SwiftData provides a modern, type-safe API that's easier to work with and integrates seamlessly with SwiftUI. + +--- + +Remember: This project prioritizes clean, simple SwiftUI code using the platform's native state management. Keep the app shell minimal and implement all features in the Swift Package. \ No newline at end of file diff --git a/参考计费/.cursor/rules/tuist.mdc b/参考计费/.cursor/rules/tuist.mdc new file mode 100644 index 0000000..984983c --- /dev/null +++ b/参考计费/.cursor/rules/tuist.mdc @@ -0,0 +1,198 @@ +--- +alwaysApply: false +--- + +# Tuist 集成与模块化拆分(Vibeviewer) + +本规则记录项目接入 Tuist、按 Feature 拆分为独立 SPM 包、UI 层依赖注入,以及常见问题排查与修复。 + +## 标准方案(Single Source of Truth) +- 仅在 `Project.swift` 的 `packages` 节点声明本地包,保持“单一来源”。 +- 不使用 `Tuist/Dependencies.swift` 声明本地包,避免与 `Project.swift` 重复导致解析冲突。 +- App 目标依赖统一使用 `.package(product:)`。 +- 生成工程:`make generate`;清理:`make clear`(仅清当前项目 DerivedData 与项目级 Tuist 缓存)。 + +示例(节选,自 `Project.swift`) +```swift +packages: [ + .local(path: "Packages/VibeviewerCore"), + .local(path: "Packages/VibeviewerModel"), + .local(path: "Packages/VibeviewerAPI"), + .local(path: "Packages/VibeviewerLoginUI"), + .local(path: "Packages/VibeviewerMenuUI"), + .local(path: "Packages/VibeviewerSettingsUI") +], +``` + +## UI 层依赖注入(遵循 project.mdc) +- 不使用 MVVM;视图内部用 `@State` 管理轻量状态。 +- 使用 Environment 注入跨模块依赖: + - 在 `VibeviewerModel` 暴露 `EnvironmentValues.cursorStorage`。 + - 在 `VibeviewerMenuUI` 暴露 `EnvironmentValues.cursorService`、`loginWindowManager`、`settingsWindowManager`。 +- App 注入: +```swift +MenuPopoverView() + .environment(\.cursorService, DefaultCursorService()) + .environment(\.cursorStorage, CursorStorage.shared) + .environment(\.loginWindowManager, LoginWindowManager.shared) + .environment(\.settingsWindowManager, SettingsWindowManager.shared) +``` +- 视图使用: +```swift +@Environment(\.cursorService) private var service +@Environment(\.cursorStorage) private var storage +@Environment(\.loginWindowManager) private var loginWindow +@Environment(\.settingsWindowManager) private var settingsWindow +``` + +## Feature 拆包规范 +- 单一职责: + - `VibeviewerLoginUI`:登录视图与窗口 + - `VibeviewerMenuUI`:菜单视图与业务触发 + - `VibeviewerSettingsUI`:设置视图与窗口 +- 每个包必须包含测试目录 `Tests/Tests/`(即便是占位),否则会出现测试路径报错。 + +## 常见问题与排查 +- 包在 Xcode 里显示为“文件夹 + ?”,不是 SPM 包: + - 原因:`Project.swift` 与 `Tuist/Dependencies.swift` 同时声明了本地包(重复来源),或 SwiftPM/Xcode 缓存脏。 + - 处理:删除 `Tuist/Dependencies.swift` 的本地包声明(本项目直接删除该文件);删除各包 `.swiftpm/`;`make clear` 后再 `make generate`。 + +### 修复步骤(示例:VibeviewerAppEnvironment 未作为包加载/显示为文件夹) +1. 确认 Single Source of Truth:仅在 `Project.swift` 的 `packages` 节点保留本地包声明。 + - 保持如下形式(节选): + ```swift + packages: [ + .local(path: "Packages/VibeviewerCore"), + .local(path: "Packages/VibeviewerModel"), + .local(path: "Packages/VibeviewerAPI"), + .local(path: "Packages/VibeviewerLoginUI"), + .local(path: "Packages/VibeviewerMenuUI"), + .local(path: "Packages/VibeviewerSettingsUI"), + .local(path: "Packages/VibeviewerAppEnvironment"), + ] + ``` +2. 清空 `Tuist/Dependencies.swift` 的本地包声明,避免与 `Project.swift` 重复: + ```swift + let dependencies = Dependencies( + swiftPackageManager: .init( + packages: [ /* 留空,统一由 Project.swift 管理 */ ], + baseSettings: .settings(base: [:], configurations: [/* 省略 */]) + ), + platforms: [.macOS] + ) + ``` + - 注:也可直接删除该文件;两者目标一致——移除重复来源。 +3. 可选清理缓存(若仍显示为文件夹或解析异常): + - 删除各包下残留的 `.swiftpm/` 目录(若存在)。 +4. 重新生成工程: + ```bash + make clear && make generate + ``` +5. 验证: + - Xcode 的 Project Navigator 中,`VibeviewerAppEnvironment` 以 Swift Package 方式展示(非普通文件夹)。 + - App 目标依赖通过 `.package(product: "VibeviewerAppEnvironment")` 引入。 +- “Couldn't load project at …/.swiftpm/xcode”: + - 原因:加载了过期的 `.swiftpm/xcode` 子工程缓存。 + - 处理:删除对应包 `.swiftpm/` 后重新生成。 +- `no such module 'X'`: + - 原因:缺少包/目标依赖或未在 `packages` 声明路径。 + - 处理:在包的 `Package.swift` 增加依赖;在 `Project.swift` 的 `packages` 增加 `.local(path:)`;再生成。 +- 捕获列表语法错误(如 `[weak _ = service]`): + - Swift 不允许匿名弱引用捕获。移除该语法,使用受控任务生命周期(持有 `Task` 并适时取消)。 + +## Make 命令 +- 生成: +```bash +make generate +``` +- 清理(当前项目): +```bash +make clear +``` + +## 新增 Feature 包 Checklist +1. 在 `Packages/YourFeature/` 创建 `Package.swift`、`Sources/YourFeature/`、`Tests/YourFeatureTests/`。 +2. 在 `Package.swift` 写入 `.package(path: ...)` 与 `targets.target.dependencies`。 +3. 在 `Project.swift` 的 `packages` 增加 `.local(path: ...)`,并在 App 目标依赖加 `.package(product: ...)`。 +4. `make generate` 重新生成。 + +> 经验:保持“单一来源”(只在 `Project.swift` 声明本地包)显著降低 Tuist/SwiftPM 解析歧义与缓存问题。# Tuist 集成与模块化拆分(Vibeviewer) + +本规则记录项目接入 Tuist、按 Feature 拆分为独立 SPM 包、UI 层依赖注入,以及常见问题排查与修复。 + +## 标准方案(Single Source of Truth) +- 仅在 `Project.swift` 的 `packages` 节点声明本地包,保持“单一来源”。 +- 不使用 `Tuist/Dependencies.swift` 声明本地包,避免与 `Project.swift` 重复导致解析冲突。 +- App 目标依赖统一使用 `.package(product:)`。 +- 生成工程:`make generate`;清理:`make clear`(仅清当前项目 DerivedData 与项目级 Tuist 缓存)。 + +示例(节选,自 `Project.swift`) +```swift +packages: [ + .local(path: "Packages/VibeviewerCore"), + .local(path: "Packages/VibeviewerModel"), + .local(path: "Packages/VibeviewerAPI"), + .local(path: "Packages/VibeviewerLoginUI"), + .local(path: "Packages/VibeviewerMenuUI"), + .local(path: "Packages/VibeviewerSettingsUI") +], +``` + +## UI 层依赖注入(遵循 project.mdc) +- 不使用 MVVM;视图内部用 `@State` 管理轻量状态。 +- 使用 Environment 注入跨模块依赖: + - 在 `VibeviewerModel` 暴露 `EnvironmentValues.cursorStorage`。 + - 在 `VibeviewerMenuUI` 暴露 `EnvironmentValues.cursorService`、`loginWindowManager`、`settingsWindowManager`。 +- App 注入: +```swift +MenuPopoverView() + .environment(\.cursorService, DefaultCursorService()) + .environment(\.cursorStorage, CursorStorage.shared) + .environment(\.loginWindowManager, LoginWindowManager.shared) + .environment(\.settingsWindowManager, SettingsWindowManager.shared) +``` +- 视图使用: +```swift +@Environment(\.cursorService) private var service +@Environment(\.cursorStorage) private var storage +@Environment(\.loginWindowManager) private var loginWindow +@Environment(\.settingsWindowManager) private var settingsWindow +``` + +## Feature 拆包规范 +- 单一职责: + - `VibeviewerLoginUI`:登录视图与窗口 + - `VibeviewerMenuUI`:菜单视图与业务触发 + - `VibeviewerSettingsUI`:设置视图与窗口 +- 每个包必须包含测试目录 `Tests/Tests/`(即便是占位),否则会出现测试路径报错。 + +## 常见问题与排查 +- 包在 Xcode 里显示为“文件夹 + ?”,不是 SPM 包: + - 原因:`Project.swift` 与 `Tuist/Dependencies.swift` 同时声明了本地包(重复来源),或 SwiftPM/Xcode 缓存脏。 + - 处理:删除 `Tuist/Dependencies.swift` 的本地包声明(本项目直接删除该文件);删除各包 `.swiftpm/`;`make clear` 后再 `make generate`。 +- “Couldn't load project at …/.swiftpm/xcode”: + - 原因:加载了过期的 `.swiftpm/xcode` 子工程缓存。 + - 处理:删除对应包 `.swiftpm/` 后重新生成。 +- `no such module 'X'`: + - 原因:缺少包/目标依赖或未在 `packages` 声明路径。 + - 处理:在包的 `Package.swift` 增加依赖;在 `Project.swift` 的 `packages` 增加 `.local(path:)`;再生成。 +- 捕获列表语法错误(如 `[weak _ = service]`): + - Swift 不允许匿名弱引用捕获。移除该语法,使用受控任务生命周期(持有 `Task` 并适时取消)。 + +## Make 命令 +- 生成: +```bash +make generate +``` +- 清理(当前项目): +```bash +make clear +``` + +## 新增 Feature 包 Checklist +1. 在 `Packages/YourFeature/` 创建 `Package.swift`、`Sources/YourFeature/`、`Tests/YourFeatureTests/`。 +2. 在 `Package.swift` 写入 `.package(path: ...)` 与 `targets.target.dependencies`。 +3. 在 `Project.swift` 的 `packages` 增加 `.local(path: ...)`,并在 App 目标依赖加 `.package(product: ...)`。 +4. `make generate` 重新生成。 + +> 经验:保持“单一来源”(只在 `Project.swift` 声明本地包)显著降低 Tuist/SwiftPM 解析歧义与缓存问题。 \ No newline at end of file diff --git a/参考计费/.gitignore b/参考计费/.gitignore new file mode 100644 index 0000000..9ce0d4c --- /dev/null +++ b/参考计费/.gitignore @@ -0,0 +1,117 @@ +# Xcode +# +# gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore + +## User settings +xcuserdata/ + +## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) +*.xcscmblueprint +*.xccheckout +*.xcodeproj +*.xcworkspace + +## macos +*.dmg +*.app +*.app.zip +*.app.tar.gz +*.app.tar.bz2 +*.app.tar.xz + +## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4) +build/ +DerivedData/ +*.moved-aside +*.pbxuser +!default.pbxuser +*.mode1v3 +!default.mode1v3 +*.mode2v3 +!default.mode2v3 +*.perspectivev3 +!default.perspectivev3 + +## Obj-C/Swift specific +*.hmap + +## App packaging +*.ipa +*.dSYM.zip +*.dSYM +.wrangler/ + +## Playgrounds +timeline.xctimeline +playground.xcworkspace + +# Swift Package Manager +# +# Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. +# Packages/ +# Package.pins +# Package.resolved +# *.xcodeproj +# +# Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata +# hence it is not needed unless you have added a package configuration file to your project +# .swiftpm + +.build/ + +# CocoaPods +# +# We recommend against adding the Pods directory to your .gitignore. However +# you should judge for yourself, the pros and cons are mentioned at: +# https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control +# +Pods/ +# +# Add this line if you want to avoid checking in source code from the Xcode workspace +*.xcworkspace + +# Carthage +# +# Add this line if you want to avoid checking in source code from Carthage dependencies. +# Carthage/Checkouts + +Carthage/Build/ + +# Accio dependency management +Dependencies/ +.accio/ + +# fastlane +# +# It is recommended to not store the screenshots in the git repo. +# Instead, use fastlane to re-generate the screenshots whenever they are needed. +# For more information about the recommended setup visit: +# https://docs.fastlane.tools/best-practices/source-control/#source-control +**/fastlane/report.xml +**/fastlane/Preview.html +**/fastlane/screenshots +**/fastlane/test_output + + +.DS_Store + +**/.build/ +# Info.plist + +# Tuist (generated artifacts not necessary to track) +# Project/workspace are already ignored above via *.xcodeproj / *.xcworkspace +# Ignore project-local Derived directory that may appear at repo root +Derived/ +# Potential Tuist local directories (safe to ignore if present) +Tuist/Derived/ +Tuist/Cache/ +Tuist/Graph/ +buildServer.json + +# Sparkle 更新相关文件 +Scripts/sparkle_keys/eddsa_private_key.pem +Scripts/sparkle_keys/eddsa_private_key_base64.txt +Scripts/sparkle_keys/signature_*.txt +Scripts/sparkle/ +*.tar.xz +temp_dmg/ \ No newline at end of file diff --git a/参考计费/.package.resolved b/参考计费/.package.resolved new file mode 100644 index 0000000..46bcde4 --- /dev/null +++ b/参考计费/.package.resolved @@ -0,0 +1,42 @@ +{ + "originHash" : "dd4976b5f6a35b41f285c4d19c0e521031fb5d395df8adc8ed7a8e14ad1db176", + "pins" : [ + { + "identity" : "alamofire", + "kind" : "remoteSourceControl", + "location" : "https://github.com/Alamofire/Alamofire.git", + "state" : { + "revision" : "513364f870f6bfc468f9d2ff0a95caccc10044c5", + "version" : "5.10.2" + } + }, + { + "identity" : "moya", + "kind" : "remoteSourceControl", + "location" : "https://github.com/Moya/Moya.git", + "state" : { + "revision" : "c263811c1f3dbf002be9bd83107f7cdc38992b26", + "version" : "15.0.3" + } + }, + { + "identity" : "reactiveswift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/ReactiveCocoa/ReactiveSwift.git", + "state" : { + "revision" : "c43bae3dac73fdd3cb906bd5a1914686ca71ed3c", + "version" : "6.7.0" + } + }, + { + "identity" : "rxswift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/ReactiveX/RxSwift.git", + "state" : { + "revision" : "5dd1907d64f0d36f158f61a466bab75067224893", + "version" : "6.9.0" + } + } + ], + "version" : 3 +} diff --git a/参考计费/.swiftformat b/参考计费/.swiftformat new file mode 100644 index 0000000..5f9c8ed --- /dev/null +++ b/参考计费/.swiftformat @@ -0,0 +1,28 @@ +--swiftversion 5.10 + +--indent 4 +--tabwidth 4 +--allman false +--wraparguments before-first +--wrapcollections before-first +--maxwidth 160 +--wrapreturntype preserve +--wrapparameters before-first +--stripunusedargs closure-only +--header ignore +--enable enumNamespaces +--self insert + +# Enabled rules (opt-in) +--enable isEmpty +--enable redundantType +--enable redundantReturn +--enable extensionAccessControl + +# Disabled rules (avoid risky auto-fixes by default) +--disable strongOutlets +--disable trailingCommas + +# File options +--exclude Derived,.build,**/Package.swift + diff --git a/参考计费/Images/image.png b/参考计费/Images/image.png new file mode 100644 index 0000000..5037ae0 Binary files /dev/null and b/参考计费/Images/image.png differ diff --git a/参考计费/LICENSE b/参考计费/LICENSE new file mode 100644 index 0000000..bd2171a --- /dev/null +++ b/参考计费/LICENSE @@ -0,0 +1,23 @@ +MIT License + +Copyright (c) 2025 Groot chen + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + + diff --git a/参考计费/Makefile b/参考计费/Makefile new file mode 100644 index 0000000..b0ebf26 --- /dev/null +++ b/参考计费/Makefile @@ -0,0 +1,32 @@ +.PHONY: generate clear build dmg dmg-release release + +generate: + @Scripts/generate.sh + +clear: + @Scripts/clear.sh + +build: + @echo "🔨 Building Vibeviewer..." + @xcodebuild -workspace Vibeviewer.xcworkspace -scheme Vibeviewer -configuration Release -destination "platform=macOS" -skipMacroValidation build + +dmg: + @echo "💽 Creating DMG package..." + @Scripts/create_dmg.sh + +dmg-release: + @echo "💽 Creating DMG package..." + @Scripts/create_dmg.sh + +release: clear generate build dmg-release + @echo "🚀 Release build completed! DMG is ready for distribution." + @echo "📋 Next steps:" + @echo " 1. Create GitHub Release (tag: v)" + @echo " 2. Upload DMG file" + @echo "" + @echo "💡 提示: 使用 ./Scripts/release.sh 可以自动化整个流程" + +release-full: + @Scripts/release.sh + + diff --git a/参考计费/Packages/VibeviewerAPI/Package.resolved b/参考计费/Packages/VibeviewerAPI/Package.resolved new file mode 100644 index 0000000..476d260 --- /dev/null +++ b/参考计费/Packages/VibeviewerAPI/Package.resolved @@ -0,0 +1,42 @@ +{ + "originHash" : "1c8e9c91c686aa90c1a15c428e52c1d8c1ad02100fe3069d87feb1d4fafef7d1", + "pins" : [ + { + "identity" : "alamofire", + "kind" : "remoteSourceControl", + "location" : "https://github.com/Alamofire/Alamofire.git", + "state" : { + "revision" : "513364f870f6bfc468f9d2ff0a95caccc10044c5", + "version" : "5.10.2" + } + }, + { + "identity" : "moya", + "kind" : "remoteSourceControl", + "location" : "https://github.com/Moya/Moya.git", + "state" : { + "revision" : "c263811c1f3dbf002be9bd83107f7cdc38992b26", + "version" : "15.0.3" + } + }, + { + "identity" : "reactiveswift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/ReactiveCocoa/ReactiveSwift.git", + "state" : { + "revision" : "c43bae3dac73fdd3cb906bd5a1914686ca71ed3c", + "version" : "6.7.0" + } + }, + { + "identity" : "rxswift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/ReactiveX/RxSwift.git", + "state" : { + "revision" : "5dd1907d64f0d36f158f61a466bab75067224893", + "version" : "6.9.0" + } + } + ], + "version" : 3 +} diff --git a/参考计费/Packages/VibeviewerAPI/Package.swift b/参考计费/Packages/VibeviewerAPI/Package.swift new file mode 100644 index 0000000..4fe54b1 --- /dev/null +++ b/参考计费/Packages/VibeviewerAPI/Package.swift @@ -0,0 +1,34 @@ +// swift-tools-version:5.10 +import PackageDescription + +let package = Package( + name: "VibeviewerAPI", + platforms: [ + .macOS(.v14) + ], + products: [ + .library(name: "VibeviewerAPI", targets: ["VibeviewerAPI"]) + ], + dependencies: [ + .package(path: "../VibeviewerCore"), + .package(path: "../VibeviewerModel"), + .package(url: "https://github.com/Moya/Moya.git", .upToNextMajor(from: "15.0.0")), + .package(url: "https://github.com/Alamofire/Alamofire.git", .upToNextMajor(from: "5.8.0")), + + ], + targets: [ + .target( + name: "VibeviewerAPI", + dependencies: [ + "VibeviewerCore", + "VibeviewerModel", + .product(name: "Moya", package: "Moya"), + .product(name: "Alamofire", package: "Alamofire"), + ] + ), + .testTarget( + name: "VibeviewerAPITests", + dependencies: ["VibeviewerAPI"] + ), + ] +) diff --git a/参考计费/Packages/VibeviewerAPI/Sources/VibeviewerAPI/Mapping/DTOs/CursorAggregatedUsageEventsResponse.swift b/参考计费/Packages/VibeviewerAPI/Sources/VibeviewerAPI/Mapping/DTOs/CursorAggregatedUsageEventsResponse.swift new file mode 100644 index 0000000..dd4cae2 --- /dev/null +++ b/参考计费/Packages/VibeviewerAPI/Sources/VibeviewerAPI/Mapping/DTOs/CursorAggregatedUsageEventsResponse.swift @@ -0,0 +1,54 @@ +import Foundation + +/// Cursor API 返回的聚合使用事件响应 DTO +struct CursorAggregatedUsageEventsResponse: Decodable, Sendable, Equatable { + let aggregations: [CursorModelAggregation] + let totalInputTokens: String + let totalOutputTokens: String + let totalCacheWriteTokens: String + let totalCacheReadTokens: String + let totalCostCents: Double + + init( + aggregations: [CursorModelAggregation], + totalInputTokens: String, + totalOutputTokens: String, + totalCacheWriteTokens: String, + totalCacheReadTokens: String, + totalCostCents: Double + ) { + self.aggregations = aggregations + self.totalInputTokens = totalInputTokens + self.totalOutputTokens = totalOutputTokens + self.totalCacheWriteTokens = totalCacheWriteTokens + self.totalCacheReadTokens = totalCacheReadTokens + self.totalCostCents = totalCostCents + } +} + +/// 单个模型的聚合数据 DTO +struct CursorModelAggregation: Decodable, Sendable, Equatable { + let modelIntent: String + let inputTokens: String? + let outputTokens: String? + let cacheWriteTokens: String? + let cacheReadTokens: String? + let totalCents: Double + + init( + modelIntent: String, + inputTokens: String?, + outputTokens: String?, + cacheWriteTokens: String?, + cacheReadTokens: String?, + totalCents: Double + ) { + self.modelIntent = modelIntent + self.inputTokens = inputTokens + self.outputTokens = outputTokens + self.cacheWriteTokens = cacheWriteTokens + self.cacheReadTokens = cacheReadTokens + self.totalCents = totalCents + } +} + diff --git a/参考计费/Packages/VibeviewerAPI/Sources/VibeviewerAPI/Mapping/DTOs/CursorCurrentBillingCycleResponse.swift b/参考计费/Packages/VibeviewerAPI/Sources/VibeviewerAPI/Mapping/DTOs/CursorCurrentBillingCycleResponse.swift new file mode 100644 index 0000000..4446f0a --- /dev/null +++ b/参考计费/Packages/VibeviewerAPI/Sources/VibeviewerAPI/Mapping/DTOs/CursorCurrentBillingCycleResponse.swift @@ -0,0 +1,16 @@ +import Foundation + +/// Cursor API 返回的当前计费周期响应 DTO +struct CursorCurrentBillingCycleResponse: Decodable, Sendable, Equatable { + let startDateEpochMillis: String + let endDateEpochMillis: String + + init( + startDateEpochMillis: String, + endDateEpochMillis: String + ) { + self.startDateEpochMillis = startDateEpochMillis + self.endDateEpochMillis = endDateEpochMillis + } +} + diff --git a/参考计费/Packages/VibeviewerAPI/Sources/VibeviewerAPI/Mapping/DTOs/CursorFilteredUsageEvent.swift b/参考计费/Packages/VibeviewerAPI/Sources/VibeviewerAPI/Mapping/DTOs/CursorFilteredUsageEvent.swift new file mode 100644 index 0000000..68c682e --- /dev/null +++ b/参考计费/Packages/VibeviewerAPI/Sources/VibeviewerAPI/Mapping/DTOs/CursorFilteredUsageEvent.swift @@ -0,0 +1,57 @@ +import Foundation + +struct CursorTokenUsage: Decodable, Sendable, Equatable { + let outputTokens: Int? + let inputTokens: Int? + let totalCents: Double? + let cacheWriteTokens: Int? + let cacheReadTokens: Int? + + init( + outputTokens: Int?, + inputTokens: Int?, + totalCents: Double?, + cacheWriteTokens: Int?, + cacheReadTokens: Int? + ) { + self.outputTokens = outputTokens + self.inputTokens = inputTokens + self.totalCents = totalCents + self.cacheWriteTokens = cacheWriteTokens + self.cacheReadTokens = cacheReadTokens + } +} + +struct CursorFilteredUsageEvent: Decodable, Sendable, Equatable { + let timestamp: String + let model: String + let kind: String + let requestsCosts: Double? + let usageBasedCosts: String + let isTokenBasedCall: Bool + let owningUser: String + let cursorTokenFee: Double + let tokenUsage: CursorTokenUsage + + init( + timestamp: String, + model: String, + kind: String, + requestsCosts: Double?, + usageBasedCosts: String, + isTokenBasedCall: Bool, + owningUser: String, + cursorTokenFee: Double, + tokenUsage: CursorTokenUsage + ) { + self.timestamp = timestamp + self.model = model + self.kind = kind + self.requestsCosts = requestsCosts + self.usageBasedCosts = usageBasedCosts + self.isTokenBasedCall = isTokenBasedCall + self.owningUser = owningUser + self.cursorTokenFee = cursorTokenFee + self.tokenUsage = tokenUsage + } +} diff --git a/参考计费/Packages/VibeviewerAPI/Sources/VibeviewerAPI/Mapping/DTOs/CursorFilteredUsageResponse.swift b/参考计费/Packages/VibeviewerAPI/Sources/VibeviewerAPI/Mapping/DTOs/CursorFilteredUsageResponse.swift new file mode 100644 index 0000000..d6623a5 --- /dev/null +++ b/参考计费/Packages/VibeviewerAPI/Sources/VibeviewerAPI/Mapping/DTOs/CursorFilteredUsageResponse.swift @@ -0,0 +1,11 @@ +import Foundation + +struct CursorFilteredUsageResponse: Decodable, Sendable, Equatable { + let totalUsageEventsCount: Int? + let usageEventsDisplay: [CursorFilteredUsageEvent]? + + init(totalUsageEventsCount: Int? = nil, usageEventsDisplay: [CursorFilteredUsageEvent]? = nil) { + self.totalUsageEventsCount = totalUsageEventsCount + self.usageEventsDisplay = usageEventsDisplay + } +} diff --git a/参考计费/Packages/VibeviewerAPI/Sources/VibeviewerAPI/Mapping/DTOs/CursorMeResponse.swift b/参考计费/Packages/VibeviewerAPI/Sources/VibeviewerAPI/Mapping/DTOs/CursorMeResponse.swift new file mode 100644 index 0000000..a6d3e7e --- /dev/null +++ b/参考计费/Packages/VibeviewerAPI/Sources/VibeviewerAPI/Mapping/DTOs/CursorMeResponse.swift @@ -0,0 +1,19 @@ +import Foundation + +struct CursorMeResponse: Decodable, Sendable { + let authId: String + let userId: Int + let email: String + let workosId: String + let teamId: Int? + let isEnterpriseUser: Bool + + init(authId: String, userId: Int, email: String, workosId: String, teamId: Int?, isEnterpriseUser: Bool) { + self.authId = authId + self.userId = userId + self.email = email + self.workosId = workosId + self.teamId = teamId + self.isEnterpriseUser = isEnterpriseUser + } +} diff --git a/参考计费/Packages/VibeviewerAPI/Sources/VibeviewerAPI/Mapping/DTOs/CursorModelUsage.swift b/参考计费/Packages/VibeviewerAPI/Sources/VibeviewerAPI/Mapping/DTOs/CursorModelUsage.swift new file mode 100644 index 0000000..7c0b295 --- /dev/null +++ b/参考计费/Packages/VibeviewerAPI/Sources/VibeviewerAPI/Mapping/DTOs/CursorModelUsage.swift @@ -0,0 +1,11 @@ +import Foundation + +struct CursorModelUsage: Decodable, Sendable { + let numTokens: Int + let maxTokenUsage: Int? + + init(numTokens: Int, maxTokenUsage: Int?) { + self.numTokens = numTokens + self.maxTokenUsage = maxTokenUsage + } +} diff --git a/参考计费/Packages/VibeviewerAPI/Sources/VibeviewerAPI/Mapping/DTOs/CursorTeamModelsAnalyticsResponse.swift b/参考计费/Packages/VibeviewerAPI/Sources/VibeviewerAPI/Mapping/DTOs/CursorTeamModelsAnalyticsResponse.swift new file mode 100644 index 0000000..c96d5e3 --- /dev/null +++ b/参考计费/Packages/VibeviewerAPI/Sources/VibeviewerAPI/Mapping/DTOs/CursorTeamModelsAnalyticsResponse.swift @@ -0,0 +1,51 @@ +import Foundation + +/// Cursor API 团队模型分析响应 DTO +public struct CursorTeamModelsAnalyticsResponse: Codable, Sendable, Equatable { + public let meta: [Meta] + public let data: [DataItem] + + public init(meta: [Meta], data: [DataItem]) { + self.meta = meta + self.data = data + } +} + +/// 元数据信息 +public struct Meta: Codable, Sendable, Equatable { + public let name: String + public let type: String + + public init(name: String, type: String) { + self.name = name + self.type = type + } +} + +/// 数据项 +public struct DataItem: Codable, Sendable, Equatable { + public let date: String + public let modelBreakdown: [String: ModelStats] + + enum CodingKeys: String, CodingKey { + case date + case modelBreakdown = "model_breakdown" + } + + public init(date: String, modelBreakdown: [String: ModelStats]) { + self.date = date + self.modelBreakdown = modelBreakdown + } +} + +/// 模型统计信息 +public struct ModelStats: Codable, Sendable, Equatable { + public let requests: UInt64 + public let users: UInt64? + + public init(requests: UInt64, users: UInt64) { + self.requests = requests + self.users = users + } +} + diff --git a/参考计费/Packages/VibeviewerAPI/Sources/VibeviewerAPI/Mapping/DTOs/CursorTeamSpendResponse.swift b/参考计费/Packages/VibeviewerAPI/Sources/VibeviewerAPI/Mapping/DTOs/CursorTeamSpendResponse.swift new file mode 100644 index 0000000..66f8bfb --- /dev/null +++ b/参考计费/Packages/VibeviewerAPI/Sources/VibeviewerAPI/Mapping/DTOs/CursorTeamSpendResponse.swift @@ -0,0 +1,30 @@ +import Foundation + +struct CursorTeamSpendResponse: Decodable, Sendable, Equatable { + let teamMemberSpend: [CursorTeamMemberSpend] + let subscriptionCycleStart: String + let totalMembers: Int + let totalPages: Int + let totalByRole: [CursorRoleCount] + let nextCycleStart: String + let limitedUserCount: Int + let maxUserSpendCents: Int? + let subscriptionLimitedUsers: Int +} + +struct CursorTeamMemberSpend: Decodable, Sendable, Equatable { + let userId: Int + let email: String + let role: String + let hardLimitOverrideDollars: Int? + let includedSpendCents: Int? + let spendCents: Int? + let fastPremiumRequests: Int? +} + +struct CursorRoleCount: Decodable, Sendable, Equatable { + let role: String + let count: Int +} + + diff --git a/参考计费/Packages/VibeviewerAPI/Sources/VibeviewerAPI/Mapping/DTOs/CursorUsageSummaryResponse.swift b/参考计费/Packages/VibeviewerAPI/Sources/VibeviewerAPI/Mapping/DTOs/CursorUsageSummaryResponse.swift new file mode 100644 index 0000000..48bbeb1 --- /dev/null +++ b/参考计费/Packages/VibeviewerAPI/Sources/VibeviewerAPI/Mapping/DTOs/CursorUsageSummaryResponse.swift @@ -0,0 +1,84 @@ +import Foundation + +struct CursorUsageSummaryResponse: Decodable, Sendable, Equatable { + let billingCycleStart: String + let billingCycleEnd: String + let membershipType: String + let limitType: String + let individualUsage: CursorIndividualUsage + let teamUsage: CursorTeamUsage? + + init( + billingCycleStart: String, + billingCycleEnd: String, + membershipType: String, + limitType: String, + individualUsage: CursorIndividualUsage, + teamUsage: CursorTeamUsage? = nil + ) { + self.billingCycleStart = billingCycleStart + self.billingCycleEnd = billingCycleEnd + self.membershipType = membershipType + self.limitType = limitType + self.individualUsage = individualUsage + self.teamUsage = teamUsage + } +} + +struct CursorIndividualUsage: Decodable, Sendable, Equatable { + let plan: CursorPlanUsage + let onDemand: CursorOnDemandUsage? + + init(plan: CursorPlanUsage, onDemand: CursorOnDemandUsage? = nil) { + self.plan = plan + self.onDemand = onDemand + } +} + +struct CursorPlanUsage: Decodable, Sendable, Equatable { + let used: Int + let limit: Int + let remaining: Int + let breakdown: CursorPlanBreakdown + + init(used: Int, limit: Int, remaining: Int, breakdown: CursorPlanBreakdown) { + self.used = used + self.limit = limit + self.remaining = remaining + self.breakdown = breakdown + } +} + +struct CursorPlanBreakdown: Decodable, Sendable, Equatable { + let included: Int + let bonus: Int + let total: Int + + init(included: Int, bonus: Int, total: Int) { + self.included = included + self.bonus = bonus + self.total = total + } +} + +struct CursorOnDemandUsage: Decodable, Sendable, Equatable { + let used: Int + let limit: Int? + let remaining: Int? + let enabled: Bool + + init(used: Int, limit: Int?, remaining: Int?, enabled: Bool) { + self.used = used + self.limit = limit + self.remaining = remaining + self.enabled = enabled + } +} + +struct CursorTeamUsage: Decodable, Sendable, Equatable { + let onDemand: CursorOnDemandUsage? + + init(onDemand: CursorOnDemandUsage? = nil) { + self.onDemand = onDemand + } +} diff --git a/参考计费/Packages/VibeviewerAPI/Sources/VibeviewerAPI/Plugins/RequestErrorHandlingPlugin.swift b/参考计费/Packages/VibeviewerAPI/Sources/VibeviewerAPI/Plugins/RequestErrorHandlingPlugin.swift new file mode 100644 index 0000000..dfe3635 --- /dev/null +++ b/参考计费/Packages/VibeviewerAPI/Sources/VibeviewerAPI/Plugins/RequestErrorHandlingPlugin.swift @@ -0,0 +1,111 @@ +import Alamofire +import Foundation +import Moya + +struct RequestErrorWrapper { + let moyaError: MoyaError + + var afError: AFError? { + if case let .underlying(error as AFError, _) = moyaError { + return error + } + return nil + } + + var nsError: NSError? { + if case let .underlying(error as NSError, _) = moyaError { + return error + } else if let afError { + return afError.underlyingError as? NSError + } + return nil + } + + var isRequestCancelled: Bool { + if case .explicitlyCancelled = self.afError { + return true + } + return false + } + + var defaultErrorMessage: String? { + if self.nsError?.code == NSURLErrorTimedOut { + "加载数据失败,请稍后重试" + } else if self.nsError?.code == NSURLErrorNotConnectedToInternet { + "无网络连接,请检查网络" + } else { + "加载数据失败,请稍后重试" + } + } +} + +protocol RequestErrorHandlable { + var errorHandlingType: RequestErrorHandlingPlugin.RequestErrorHandlingType { get } +} + +extension RequestErrorHandlable { + var errorHandlingType: RequestErrorHandlingPlugin.RequestErrorHandlingType { + .all + } +} + +class RequestErrorHandlingPlugin { + enum RequestErrorHandlingType { + enum FilterResult { + case handledByPlugin(message: String?) + case shouldNotHandledByPlugin + } + + case connectionError // 现在包括超时和断网错误 + case all + case allWithFilter(filter: (RequestErrorWrapper) -> FilterResult) + + func handleError(_ error: RequestErrorWrapper, handler: (_ shouldHandle: Bool, _ message: String?) -> Void) { + switch self { + case .connectionError: + if error.nsError?.code == NSURLErrorTimedOut { + handler(true, error.defaultErrorMessage) + } else if error.nsError?.code == NSURLErrorNotConnectedToInternet { + handler(true, error.defaultErrorMessage) + } + case .all: + handler(true, error.defaultErrorMessage) + case let .allWithFilter(filter): + switch filter(error) { + case let .handledByPlugin(messsage): + handler(true, messsage ?? error.defaultErrorMessage) + case .shouldNotHandledByPlugin: + handler(false, nil) + } + } + handler(false, nil) + } + } +} + +extension RequestErrorHandlingPlugin: PluginType { + func prepare(_ request: URLRequest, target: TargetType) -> URLRequest { + var request = request + request.timeoutInterval = 30 + return request + } + + func didReceive(_ result: Result, target: TargetType) { + let requestErrorHandleSubject: RequestErrorHandlable? = + ((target as? MultiTarget)?.target as? RequestErrorHandlable) + ?? (target as? RequestErrorHandlable) + + guard let requestErrorHandleSubject, case let .failure(moyaError) = result else { return } + + let errorWrapper = RequestErrorWrapper(moyaError: moyaError) + if errorWrapper.isRequestCancelled { + return + } + + requestErrorHandleSubject.errorHandlingType.handleError(errorWrapper) { shouldHandle, message in + if shouldHandle, let message, !message.isEmpty { + // show error + } + } + } +} diff --git a/参考计费/Packages/VibeviewerAPI/Sources/VibeviewerAPI/Plugins/RequestHeaderConfigurationPlugin.swift b/参考计费/Packages/VibeviewerAPI/Sources/VibeviewerAPI/Plugins/RequestHeaderConfigurationPlugin.swift new file mode 100644 index 0000000..d66bbb4 --- /dev/null +++ b/参考计费/Packages/VibeviewerAPI/Sources/VibeviewerAPI/Plugins/RequestHeaderConfigurationPlugin.swift @@ -0,0 +1,30 @@ +import Foundation +import Moya + +final class RequestHeaderConfigurationPlugin: PluginType { + static let shared: RequestHeaderConfigurationPlugin = .init() + + var header: [String: String] = [:] + + // MARK: Plugin + + func prepare(_ request: URLRequest, target: TargetType) -> URLRequest { + var request = request + request.allHTTPHeaderFields?.merge(self.header) { _, new in new } + return request + } + + func setAuthorization(_ token: String) { + self.header["Authorization"] = "Bearer " + } + + func clearAuthorization() { + self.header["Authorization"] = "" + } + + init() { + self.header = [ + "Authorization": "Bearer " + ] + } +} diff --git a/参考计费/Packages/VibeviewerAPI/Sources/VibeviewerAPI/Plugins/SimpleNetworkLoggerPlugin.swift b/参考计费/Packages/VibeviewerAPI/Sources/VibeviewerAPI/Plugins/SimpleNetworkLoggerPlugin.swift new file mode 100644 index 0000000..93ad1d8 --- /dev/null +++ b/参考计费/Packages/VibeviewerAPI/Sources/VibeviewerAPI/Plugins/SimpleNetworkLoggerPlugin.swift @@ -0,0 +1,65 @@ +import Foundation +import Moya +import VibeviewerCore + +final class SimpleNetworkLoggerPlugin {} + +// MARK: - PluginType + +extension SimpleNetworkLoggerPlugin: PluginType { + func didReceive(_ result: Result, target: TargetType) { + var loggings: [String] = [] + + let targetType: TargetType.Type = if let multiTarget = target as? MultiTarget { + type(of: multiTarget.target) + } else { + type(of: target) + } + + loggings.append("Request: \(targetType) [\(Date())]") + + switch result { + case let .success(success): + loggings + .append("URL: \(success.request?.url?.absoluteString ?? target.baseURL.absoluteString + target.path)") + loggings.append("Method: \(target.method.rawValue)") + if let output = success.request?.httpBody?.toPrettyPrintedJSONString() { + loggings.append("Request body: \n\(output)") + } + loggings.append("Status Code: \(success.statusCode)") + + if let output = success.data.toPrettyPrintedJSONString() { + loggings.append("Response: \n\(output)") + } else if let string = String(data: success.data, encoding: .utf8) { + loggings.append("Response: \(string)") + } else { + loggings.append("Response: \(success.data)") + } + + case let .failure(failure): + loggings + .append("URL: \(failure.response?.request?.url?.absoluteString ?? target.baseURL.absoluteString + target.path)") + loggings.append("Method: \(target.method.rawValue)") + if let output = failure.response?.request?.httpBody?.toPrettyPrintedJSONString() { + loggings.append("Request body: \n\(output)") + } + if let errorResponseCode = failure.response?.statusCode { + loggings.append("Error Code: \(errorResponseCode)") + } else { + loggings.append("Error Code: \(failure.errorCode)") + } + + if let errorOutput = failure.response?.data.toPrettyPrintedJSONString() { + loggings.append("Error Response: \n\(errorOutput)") + } + + loggings.append("Error detail: \(failure.localizedDescription)") + } + + loggings = loggings.map { "🔵 " + $0 } + let seperator = "===================================================================" + loggings.insert(seperator, at: 0) + loggings.append(seperator) + loggings.forEach { print($0) } + } +} diff --git a/参考计费/Packages/VibeviewerAPI/Sources/VibeviewerAPI/Service/APICommon.swift b/参考计费/Packages/VibeviewerAPI/Sources/VibeviewerAPI/Service/APICommon.swift new file mode 100644 index 0000000..d0bb9a3 --- /dev/null +++ b/参考计费/Packages/VibeviewerAPI/Sources/VibeviewerAPI/Service/APICommon.swift @@ -0,0 +1,28 @@ +import Foundation + +enum APIConfig { + static let baseURL = URL(string: "https://cursor.com")! + static let dashboardReferer = "https://cursor.com/dashboard" +} + +enum APIHeadersBuilder { + static func jsonHeaders(cookieHeader: String?) -> [String: String] { + var h: [String: String] = [ + "accept": "*/*", + "content-type": "application/json", + "origin": "https://cursor.com", + "referer": APIConfig.dashboardReferer + ] + if let cookieHeader, !cookieHeader.isEmpty { h["Cookie"] = cookieHeader } + return h + } + + static func basicHeaders(cookieHeader: String?) -> [String: String] { + var h: [String: String] = [ + "accept": "*/*", + "referer": APIConfig.dashboardReferer + ] + if let cookieHeader, !cookieHeader.isEmpty { h["Cookie"] = cookieHeader } + return h + } +} diff --git a/参考计费/Packages/VibeviewerAPI/Sources/VibeviewerAPI/Service/CursorService.swift b/参考计费/Packages/VibeviewerAPI/Sources/VibeviewerAPI/Service/CursorService.swift new file mode 100644 index 0000000..ff6e814 --- /dev/null +++ b/参考计费/Packages/VibeviewerAPI/Sources/VibeviewerAPI/Service/CursorService.swift @@ -0,0 +1,582 @@ +import Foundation +import Moya +import VibeviewerModel +import VibeviewerCore + +public enum CursorServiceError: Error { + case sessionExpired +} + +protocol CursorNetworkClient { + func decodableRequest( + _ target: T, + decodingStrategy: JSONDecoder.KeyDecodingStrategy + ) async throws -> T + .ResultType +} + +struct DefaultCursorNetworkClient: CursorNetworkClient { + init() {} + + func decodableRequest(_ target: T, decodingStrategy: JSONDecoder.KeyDecodingStrategy) async throws -> T + .ResultType where T: DecodableTargetType + { + try await HttpClient.decodableRequest(target, decodingStrategy: decodingStrategy) + } +} + +public protocol CursorService { + func fetchMe(cookieHeader: String) async throws -> Credentials + func fetchUsageSummary(cookieHeader: String) async throws -> VibeviewerModel.UsageSummary + /// 仅 Team Plan 使用:返回当前用户的 free usage(以分计)。计算方式:includedSpendCents - hardLimitOverrideDollars*100,若小于0则为0 + func fetchTeamFreeUsageCents(teamId: Int, userId: Int, cookieHeader: String) async throws -> Int + func fetchFilteredUsageEvents( + startDateMs: String, + endDateMs: String, + userId: Int, + page: Int, + cookieHeader: String + ) async throws -> VibeviewerModel.FilteredUsageHistory + func fetchModelsAnalytics( + startDate: String, + endDate: String, + c: String, + cookieHeader: String + ) async throws -> VibeviewerModel.ModelsUsageChartData + /// 获取聚合使用事件(仅限 Pro 账号,非 Team 账号) + /// - Parameters: + /// - teamId: 团队 ID,Pro 账号传 nil + /// - startDate: 开始日期(毫秒时间戳) + /// - cookieHeader: Cookie 头 + func fetchAggregatedUsageEvents( + teamId: Int?, + startDate: Int64, + cookieHeader: String + ) async throws -> VibeviewerModel.AggregatedUsageEvents + /// 获取当前计费周期 + /// - Parameter cookieHeader: Cookie 头 + func fetchCurrentBillingCycle(cookieHeader: String) async throws -> VibeviewerModel.BillingCycle + /// 获取当前计费周期(返回原始毫秒时间戳字符串) + /// - Parameter cookieHeader: Cookie 头 + /// - Returns: (startDateMs: String, endDateMs: String) 毫秒时间戳字符串 + func fetchCurrentBillingCycleMs(cookieHeader: String) async throws -> (startDateMs: String, endDateMs: String) + /// 通过 Filtered Usage Events 获取模型使用量图表数据(Pro 用户替代方案) + /// - Parameters: + /// - startDateMs: 开始日期(毫秒时间戳) + /// - endDateMs: 结束日期(毫秒时间戳) + /// - userId: 用户 ID + /// - cookieHeader: Cookie 头 + /// - Returns: 模型使用量图表数据 + func fetchModelsUsageChartFromEvents( + startDateMs: String, + endDateMs: String, + userId: Int, + cookieHeader: String + ) async throws -> VibeviewerModel.ModelsUsageChartData +} + +public struct DefaultCursorService: CursorService { + private let network: CursorNetworkClient + private let decoding: JSONDecoder.KeyDecodingStrategy + + // Public initializer that does not expose internal protocol types + public init(decoding: JSONDecoder.KeyDecodingStrategy = .useDefaultKeys) { + self.network = DefaultCursorNetworkClient() + self.decoding = decoding + } + + // Internal injectable initializer for tests + init(network: any CursorNetworkClient, decoding: JSONDecoder.KeyDecodingStrategy = .useDefaultKeys) { + self.network = network + self.decoding = decoding + } + + private func performRequest(_ target: T) async throws -> T.ResultType { + do { + return try await self.network.decodableRequest(target, decodingStrategy: self.decoding) + } catch { + if let moyaError = error as? MoyaError, + case let .statusCode(response) = moyaError, + [401, 403].contains(response.statusCode) + { + throw CursorServiceError.sessionExpired + } + throw error + } + } + + public func fetchMe(cookieHeader: String) async throws -> Credentials { + let dto: CursorMeResponse = try await self.performRequest(CursorGetMeAPI(cookieHeader: cookieHeader)) + return Credentials( + userId: dto.userId, + workosId: dto.workosId, + email: dto.email, + teamId: dto.teamId ?? 0, + cookieHeader: cookieHeader, + isEnterpriseUser: dto.isEnterpriseUser + ) + } + + public func fetchUsageSummary(cookieHeader: String) async throws -> VibeviewerModel.UsageSummary { + let dto: CursorUsageSummaryResponse = try await self.performRequest(CursorUsageSummaryAPI(cookieHeader: cookieHeader)) + + // 解析日期 + let dateFormatter = ISO8601DateFormatter() + let billingCycleStart = dateFormatter.date(from: dto.billingCycleStart) ?? Date() + let billingCycleEnd = dateFormatter.date(from: dto.billingCycleEnd) ?? Date() + + // 映射计划使用情况 + let planUsage = VibeviewerModel.PlanUsage( + used: dto.individualUsage.plan.used, + limit: dto.individualUsage.plan.limit, + remaining: dto.individualUsage.plan.remaining, + breakdown: VibeviewerModel.PlanBreakdown( + included: dto.individualUsage.plan.breakdown.included, + bonus: dto.individualUsage.plan.breakdown.bonus, + total: dto.individualUsage.plan.breakdown.total + ) + ) + + // 映射按需使用情况(如果存在) + let onDemandUsage: VibeviewerModel.OnDemandUsage? = { + guard let individualOnDemand = dto.individualUsage.onDemand else { return nil } + if individualOnDemand.used > 0 || (individualOnDemand.limit ?? 0) > 0 { + return VibeviewerModel.OnDemandUsage( + used: individualOnDemand.used, + limit: individualOnDemand.limit, + remaining: individualOnDemand.remaining, + enabled: individualOnDemand.enabled + ) + } + return nil + }() + + // 映射个人使用情况 + let individualUsage = VibeviewerModel.IndividualUsage( + plan: planUsage, + onDemand: onDemandUsage + ) + + // 映射团队使用情况(如果存在) + let teamUsage: VibeviewerModel.TeamUsage? = { + guard let teamUsageData = dto.teamUsage, + let teamOnDemand = teamUsageData.onDemand else { return nil } + if teamOnDemand.used > 0 || (teamOnDemand.limit ?? 0) > 0 { + return VibeviewerModel.TeamUsage( + onDemand: VibeviewerModel.OnDemandUsage( + used: teamOnDemand.used, + limit: teamOnDemand.limit, + remaining: teamOnDemand.remaining, + enabled: teamOnDemand.enabled + ) + ) + } + return nil + }() + + // 映射会员类型 + let membershipType = VibeviewerModel.MembershipType(rawValue: dto.membershipType) ?? .free + + return VibeviewerModel.UsageSummary( + billingCycleStart: billingCycleStart, + billingCycleEnd: billingCycleEnd, + membershipType: membershipType, + limitType: dto.limitType, + individualUsage: individualUsage, + teamUsage: teamUsage + ) + } + + public func fetchFilteredUsageEvents( + startDateMs: String, + endDateMs: String, + userId: Int, + page: Int, + cookieHeader: String + ) async throws -> VibeviewerModel.FilteredUsageHistory { + let dto: CursorFilteredUsageResponse = try await self.performRequest( + CursorFilteredUsageAPI( + startDateMs: startDateMs, + endDateMs: endDateMs, + userId: userId, + page: page, + cookieHeader: cookieHeader + ) + ) + let events: [VibeviewerModel.UsageEvent] = (dto.usageEventsDisplay ?? []).map { e in + let tokenUsage = VibeviewerModel.TokenUsage( + outputTokens: e.tokenUsage.outputTokens, + inputTokens: e.tokenUsage.inputTokens, + totalCents: e.tokenUsage.totalCents ?? 0.0, + cacheWriteTokens: e.tokenUsage.cacheWriteTokens, + cacheReadTokens: e.tokenUsage.cacheReadTokens + ) + + // 计算请求次数:基于 token 使用情况,如果没有 token 信息则默认为 1 + let requestCount = Self.calculateRequestCount(from: e.tokenUsage) + + return VibeviewerModel.UsageEvent( + occurredAtMs: e.timestamp, + modelName: e.model, + kind: e.kind, + requestCostCount: requestCount, + usageCostDisplay: e.usageBasedCosts, + usageCostCents: Self.parseCents(fromDollarString: e.usageBasedCosts), + isTokenBased: e.isTokenBasedCall, + userDisplayName: e.owningUser, + cursorTokenFee: e.cursorTokenFee, + tokenUsage: tokenUsage + ) + } + return VibeviewerModel.FilteredUsageHistory(totalCount: dto.totalUsageEventsCount ?? 0, events: events) + } + + public func fetchTeamFreeUsageCents(teamId: Int, userId: Int, cookieHeader: String) async throws -> Int { + let dto: CursorTeamSpendResponse = try await self.performRequest( + CursorGetTeamSpendAPI( + teamId: teamId, + page: 1, + // pageSize is hardcoded to 100 + sortBy: "name", + sortDirection: "asc", + cookieHeader: cookieHeader + ) + ) + + guard let me = dto.teamMemberSpend.first(where: { $0.userId == userId }) else { + return 0 + } + + let included = me.includedSpendCents ?? 0 + let overrideDollars = me.hardLimitOverrideDollars ?? 0 + let freeCents = max(included - overrideDollars * 100, 0) + return freeCents + } + + public func fetchModelsAnalytics( + startDate: String, + endDate: String, + c: String, + cookieHeader: String + ) async throws -> VibeviewerModel.ModelsUsageChartData { + let dto: CursorTeamModelsAnalyticsResponse = try await self.performRequest( + CursorTeamModelsAnalyticsAPI( + startDate: startDate, + endDate: endDate, + c: c, + cookieHeader: cookieHeader + ) + ) + return mapToModelsUsageChartData(dto) + } + + public func fetchAggregatedUsageEvents( + teamId: Int?, + startDate: Int64, + cookieHeader: String + ) async throws -> VibeviewerModel.AggregatedUsageEvents { + let dto: CursorAggregatedUsageEventsResponse = try await self.performRequest( + CursorAggregatedUsageEventsAPI( + teamId: teamId, + startDate: startDate, + cookieHeader: cookieHeader + ) + ) + return mapToAggregatedUsageEvents(dto) + } + + public func fetchCurrentBillingCycle(cookieHeader: String) async throws -> VibeviewerModel.BillingCycle { + let dto: CursorCurrentBillingCycleResponse = try await self.performRequest( + CursorCurrentBillingCycleAPI(cookieHeader: cookieHeader) + ) + return mapToBillingCycle(dto) + } + + public func fetchCurrentBillingCycleMs(cookieHeader: String) async throws -> (startDateMs: String, endDateMs: String) { + let dto: CursorCurrentBillingCycleResponse = try await self.performRequest( + CursorCurrentBillingCycleAPI(cookieHeader: cookieHeader) + ) + return (startDateMs: dto.startDateEpochMillis, endDateMs: dto.endDateEpochMillis) + } + + public func fetchModelsUsageChartFromEvents( + startDateMs: String, + endDateMs: String, + userId: Int, + cookieHeader: String + ) async throws -> VibeviewerModel.ModelsUsageChartData { + // 一次性获取 700 条数据(7 页,每页 100 条) + var allEvents: [VibeviewerModel.UsageEvent] = [] + let maxPages = 7 + + // 并发获取所有页面的数据 + try await withThrowingTaskGroup(of: (page: Int, history: VibeviewerModel.FilteredUsageHistory).self) { group in + for page in 1...maxPages { + group.addTask { + let history = try await self.fetchFilteredUsageEvents( + startDateMs: startDateMs, + endDateMs: endDateMs, + userId: userId, + page: page, + cookieHeader: cookieHeader + ) + return (page: page, history: history) + } + } + + // 收集所有结果并按页码排序 + var results: [(page: Int, history: VibeviewerModel.FilteredUsageHistory)] = [] + for try await result in group { + results.append(result) + } + results.sort { $0.page < $1.page } + + // 合并所有事件 + for result in results { + allEvents.append(contentsOf: result.history.events) + } + } + + // 转换为 ModelsUsageChartData + return convertEventsToModelsUsageChart(events: allEvents, startDateMs: startDateMs, endDateMs: endDateMs) + } + + /// 映射当前计费周期 DTO 到领域模型 + private func mapToBillingCycle(_ dto: CursorCurrentBillingCycleResponse) -> VibeviewerModel.BillingCycle { + let startDate = Date.fromMillisecondsString(dto.startDateEpochMillis) ?? Date() + let endDate = Date.fromMillisecondsString(dto.endDateEpochMillis) ?? Date() + return VibeviewerModel.BillingCycle( + startDate: startDate, + endDate: endDate + ) + } + + /// 映射聚合使用事件 DTO 到领域模型 + private func mapToAggregatedUsageEvents(_ dto: CursorAggregatedUsageEventsResponse) -> VibeviewerModel.AggregatedUsageEvents { + let aggregations = dto.aggregations.map { agg in + VibeviewerModel.ModelAggregation( + modelIntent: agg.modelIntent, + inputTokens: Int(agg.inputTokens ?? "0") ?? 0, + outputTokens: Int(agg.outputTokens ?? "0") ?? 0, + cacheWriteTokens: Int(agg.cacheWriteTokens ?? "0") ?? 0, + cacheReadTokens: Int(agg.cacheReadTokens ?? "0") ?? 0, + totalCents: agg.totalCents + ) + } + + return VibeviewerModel.AggregatedUsageEvents( + aggregations: aggregations, + totalInputTokens: Int(dto.totalInputTokens) ?? 0, + totalOutputTokens: Int(dto.totalOutputTokens) ?? 0, + totalCacheWriteTokens: Int(dto.totalCacheWriteTokens) ?? 0, + totalCacheReadTokens: Int(dto.totalCacheReadTokens) ?? 0, + totalCostCents: dto.totalCostCents + ) + } + + /// 映射模型分析 DTO 到业务层柱状图数据 + private func mapToModelsUsageChartData(_ dto: CursorTeamModelsAnalyticsResponse) -> VibeviewerModel.ModelsUsageChartData { + let formatter = DateFormatter() + formatter.locale = .current + formatter.timeZone = TimeZone(secondsFromGMT: 0) + formatter.dateFormat = "yyyy-MM-dd" + + // 将 DTO 数据转换为字典,方便查找 + var dataDict: [String: VibeviewerModel.ModelsUsageChartData.DataPoint] = [:] + for item in dto.data { + let dateLabel = formatDateLabelForChart(from: item.date) + let modelUsages = item.modelBreakdown + .map { (modelName, stats) in + VibeviewerModel.ModelsUsageChartData.ModelUsage( + modelName: modelName, + requests: Int(stats.requests) + ) + } + .sorted { $0.requests > $1.requests } + + dataDict[item.date] = VibeviewerModel.ModelsUsageChartData.DataPoint( + date: item.date, + dateLabel: dateLabel, + modelUsages: modelUsages + ) + } + + // 生成最近7天的日期范围 + let calendar = Calendar.current + let today = calendar.startOfDay(for: Date()) + var allDates: [Date] = [] + + for i in (0..<7).reversed() { + if let date = calendar.date(byAdding: .day, value: -i, to: today) { + allDates.append(date) + } + } + + // 补足缺失的日期 + let dataPoints = allDates.map { date -> VibeviewerModel.ModelsUsageChartData.DataPoint in + let dateString = formatter.string(from: date) + + // 如果该日期有数据,使用现有数据;否则创建空数据点 + if let existingData = dataDict[dateString] { + return existingData + } else { + let dateLabel = formatDateLabelForChart(from: dateString) + return VibeviewerModel.ModelsUsageChartData.DataPoint( + date: dateString, + dateLabel: dateLabel, + modelUsages: [] + ) + } + } + + return VibeviewerModel.ModelsUsageChartData(dataPoints: dataPoints) + } + + /// 将 YYYY-MM-DD 格式的日期字符串转换为 MM/dd 格式的图表标签 + private func formatDateLabelForChart(from dateString: String) -> String { + let formatter = DateFormatter() + formatter.locale = .current + formatter.timeZone = TimeZone(secondsFromGMT: 0) + formatter.dateFormat = "yyyy-MM-dd" + + guard let date = formatter.date(from: dateString) else { + return dateString + } + + let labelFormatter = DateFormatter() + labelFormatter.locale = .current + labelFormatter.timeZone = TimeZone(secondsFromGMT: 0) + labelFormatter.dateFormat = "MM/dd" + return labelFormatter.string(from: date) + } + + /// 将使用事件列表转换为模型使用量图表数据 + /// - Parameters: + /// - events: 使用事件列表 + /// - startDateMs: 开始日期(毫秒时间戳) + /// - endDateMs: 结束日期(毫秒时间戳) + /// - Returns: 模型使用量图表数据(确保至少7天) + private func convertEventsToModelsUsageChart( + events: [VibeviewerModel.UsageEvent], + startDateMs: String, + endDateMs: String + ) -> VibeviewerModel.ModelsUsageChartData { + let formatter = DateFormatter() + formatter.locale = .current + // 使用本地时区按“自然日”分组,避免凌晨时段被算到前一天(UTC)里 + formatter.timeZone = .current + formatter.dateFormat = "yyyy-MM-dd" + + // 解析开始和结束日期 + guard let startMs = Int64(startDateMs), + let endMs = Int64(endDateMs) else { + return VibeviewerModel.ModelsUsageChartData(dataPoints: []) + } + + let startDate = Date(timeIntervalSince1970: TimeInterval(startMs) / 1000.0) + let originalEndDate = Date(timeIntervalSince1970: TimeInterval(endMs) / 1000.0) + let calendar = Calendar.current + + // 为了避免 X 轴出现“未来一天”的空数据(例如今天是 24 号却出现 25 号), + // 这里将用于生成日期刻度的结束日期截断到“今天 00:00”, + // 但事件本身的时间范围仍然由后端返回的数据决定。 + let startOfToday = calendar.startOfDay(for: Date()) + let endDate: Date = originalEndDate > startOfToday ? startOfToday : originalEndDate + + // 生成日期范围内的所有日期(从 startDate 到 endDate,均为自然日) + var allDates: [Date] = [] + var currentDate = startDate + + while currentDate <= endDate { + allDates.append(currentDate) + guard let nextDate = calendar.date(byAdding: .day, value: 1, to: currentDate) else { break } + currentDate = nextDate + } + + // 如果数据不足7天,从今天往前补足7天 + if allDates.count < 7 { + let today = calendar.startOfDay(for: Date()) + allDates = [] + for i in (0..<7).reversed() { + if let date = calendar.date(byAdding: .day, value: -i, to: today) { + allDates.append(date) + } + } + } + + // 按日期分组统计每个模型的请求次数 + // dateString -> modelName -> requestCount + var dateModelStats: [String: [String: Int]] = [:] + + // 初始化所有日期 + for date in allDates { + let dateString = formatter.string(from: date) + dateModelStats[dateString] = [:] + } + + // 统计事件 + for event in events { + guard let eventMs = Int64(event.occurredAtMs) else { continue } + let eventDate = Date(timeIntervalSince1970: TimeInterval(eventMs) / 1000.0) + let dateString = formatter.string(from: eventDate) + + // 如果日期在范围内,统计 + if dateModelStats[dateString] != nil { + let modelName = event.modelName + let currentCount = dateModelStats[dateString]?[modelName] ?? 0 + dateModelStats[dateString]?[modelName] = currentCount + event.requestCostCount + } + } + + // 转换为 DataPoint 数组 + let dataPoints = allDates.map { date -> VibeviewerModel.ModelsUsageChartData.DataPoint in + let dateString = formatter.string(from: date) + let dateLabel = formatDateLabelForChart(from: dateString) + + let modelStats = dateModelStats[dateString] ?? [:] + let modelUsages = modelStats + .map { (modelName, requests) in + VibeviewerModel.ModelsUsageChartData.ModelUsage( + modelName: modelName, + requests: requests + ) + } + .sorted { $0.requests > $1.requests } // 按请求数降序排序 + + return VibeviewerModel.ModelsUsageChartData.DataPoint( + date: dateString, + dateLabel: dateLabel, + modelUsages: modelUsages + ) + } + + return VibeviewerModel.ModelsUsageChartData(dataPoints: dataPoints) + } + +} + +private extension DefaultCursorService { + static func parseCents(fromDollarString s: String) -> Int { + // "$0.04" -> 4 + let trimmed = s.trimmingCharacters(in: .whitespacesAndNewlines) + guard let idx = trimmed.firstIndex(where: { ($0 >= "0" && $0 <= "9") || $0 == "." }) else { return 0 } + let numberPart = trimmed[idx...] + guard let value = Double(numberPart) else { return 0 } + return Int((value * 100.0).rounded()) + } + + static func calculateRequestCount(from tokenUsage: CursorTokenUsage) -> Int { + // 基于 token 使用情况计算请求次数 + // 如果有 output tokens 或 input tokens,说明有实际的请求 + let hasOutputTokens = (tokenUsage.outputTokens ?? 0) > 0 + let hasInputTokens = (tokenUsage.inputTokens ?? 0) > 0 + + if hasOutputTokens || hasInputTokens { + // 如果有 token 使用,至少算作 1 次请求 + return 1 + } else { + // 如果没有 token 使用,可能是缓存读取或其他类型的请求 + return 1 + } + } +} diff --git a/参考计费/Packages/VibeviewerAPI/Sources/VibeviewerAPI/Service/HttpClient.swift b/参考计费/Packages/VibeviewerAPI/Sources/VibeviewerAPI/Service/HttpClient.swift new file mode 100644 index 0000000..8b39265 --- /dev/null +++ b/参考计费/Packages/VibeviewerAPI/Sources/VibeviewerAPI/Service/HttpClient.swift @@ -0,0 +1,209 @@ +import Alamofire +import Foundation +import Moya + +@available(iOS 13, macOS 10.15, tvOS 13, *) +enum HttpClient { + private static var _provider: MoyaProvider? + + static var provider: MoyaProvider { + if _provider == nil { + _provider = createProvider() + } + return _provider! + } + + private static func createProvider() -> MoyaProvider { + var plugins: [PluginType] = [] + plugins.append(SimpleNetworkLoggerPlugin()) + plugins.append(RequestErrorHandlingPlugin()) + + // 创建完全不验证 SSL 的配置 + let configuration = URLSessionConfiguration.af.default + let session = Session( + configuration: configuration, + serverTrustManager: nil + ) + + return MoyaProvider(session: session, plugins: plugins) + } + + // 用来防止mockprovider释放 + private static var _mockProvider: MoyaProvider! + + static func mockProvider(_ reponseType: MockResponseType) -> MoyaProvider { + let plugins = [NetworkLoggerPlugin(configuration: .init(logOptions: .successResponseBody))] + let endpointClosure: (MultiTarget) -> Endpoint = + switch reponseType { + case let .success(data): + { (target: MultiTarget) -> Endpoint in + Endpoint( + url: URL(target: target).absoluteString, + sampleResponseClosure: { .networkResponse(200, data ?? target.sampleData) }, + method: target.method, + task: target.task, + httpHeaderFields: target.headers + ) + } + case let .failure(error): + { (target: MultiTarget) -> Endpoint in + Endpoint( + url: URL(target: target).absoluteString, + sampleResponseClosure: { + .networkError(error ?? NSError(domain: "mock error", code: -1)) + }, + method: target.method, + task: target.task, + httpHeaderFields: target.headers + ) + } + } + let provider = MoyaProvider( + endpointClosure: endpointClosure, + stubClosure: MoyaProvider.delayedStub(2), + plugins: plugins + ) + self._mockProvider = provider + return provider + } + + enum MockResponseType { + case success(Data?) + case failure(NSError?) + } + + enum ProviderType { + case normal + case mockSuccess(Data?) + case mockFailure(NSError?) + } + + @discardableResult + static func decodableRequest( + providerType: ProviderType = .normal, + decodingStrategy: JSONDecoder + .KeyDecodingStrategy = .useDefaultKeys, + _ target: T, + callbackQueue: DispatchQueue? = nil, + completion: @escaping (_ result: Result) + -> Void + ) -> Moya.Cancellable { + let provider: MoyaProvider = + switch providerType { + case .normal: + self.provider + case let .mockSuccess(data): + self.mockProvider(.success(data)) + case let .mockFailure(error): + self.mockProvider(.failure(error)) + } + return provider.decodableRequest( + target, + decodingStrategy: decodingStrategy, + callbackQueue: callbackQueue, + completion: completion + ) + } + + @discardableResult + static func request( + providerType: ProviderType = .normal, + _ target: some TargetType, + callbackQueue: DispatchQueue? = nil, + progressHandler: ProgressBlock? = nil, + completion: @escaping (_ result: Result) -> Void + ) -> Moya.Cancellable { + let provider: MoyaProvider = + switch providerType { + case .normal: + self.provider + case let .mockSuccess(data): + self.mockProvider(.success(data)) + case let .mockFailure(error): + self.mockProvider(.failure(error)) + } + return + provider + .request(MultiTarget(target), callbackQueue: callbackQueue, progress: progressHandler) { + result in + switch result { + case let .success(rsp): + completion(.success(rsp.data)) + case let .failure(error): + completion(.failure(error)) + } + } + } + + @discardableResult + static func request( + providerType: ProviderType = .normal, + _ target: some TargetType, + callbackQueue: DispatchQueue? = nil, + progressHandler: ProgressBlock? = nil, + completion: @escaping (_ result: Result) -> Void + ) -> Moya.Cancellable { + let provider: MoyaProvider = + switch providerType { + case .normal: + self.provider + case let .mockSuccess(data): + self.mockProvider(.success(data)) + case let .mockFailure(error): + self.mockProvider(.failure(error)) + } + return + provider + .request(MultiTarget(target), callbackQueue: callbackQueue, progress: progressHandler) { + result in + switch result { + case let .success(rsp): + completion(.success(rsp)) + case let .failure(error): + completion(.failure(error)) + } + } + } + + // Async + + static func decodableRequest( + _ target: T, + decodingStrategy: JSONDecoder + .KeyDecodingStrategy = .useDefaultKeys + ) async throws -> T + .ResultType + { + try await withCheckedThrowingContinuation { continuation in + HttpClient.decodableRequest(decodingStrategy: decodingStrategy, target, callbackQueue: nil) { + result in + switch result { + case let .success(response): + continuation.resume(returning: response) + case let .failure(error): + continuation.resume(throwing: error) + } + } + } + } + + @discardableResult + static func request(_ target: some TargetType, progressHandler: ProgressBlock? = nil) + async throws -> Data? + { + try await withCheckedThrowingContinuation { continuation in + HttpClient.request(target, callbackQueue: nil, progressHandler: progressHandler) { + result in + switch result { + case let .success(response): + continuation.resume(returning: response) + case let .failure(error): + continuation.resume(throwing: error) + } + } + } + } +} + + + diff --git a/参考计费/Packages/VibeviewerAPI/Sources/VibeviewerAPI/Service/HttpClientError.swift b/参考计费/Packages/VibeviewerAPI/Sources/VibeviewerAPI/Service/HttpClientError.swift new file mode 100644 index 0000000..cf80e58 --- /dev/null +++ b/参考计费/Packages/VibeviewerAPI/Sources/VibeviewerAPI/Service/HttpClientError.swift @@ -0,0 +1,9 @@ +import Foundation + +enum HttpClientError: Error { + case missingParams + case invalidateParams +} + + + diff --git a/参考计费/Packages/VibeviewerAPI/Sources/VibeviewerAPI/Service/MoyaProvider+DecodableRequest.swift b/参考计费/Packages/VibeviewerAPI/Sources/VibeviewerAPI/Service/MoyaProvider+DecodableRequest.swift new file mode 100644 index 0000000..732b58e --- /dev/null +++ b/参考计费/Packages/VibeviewerAPI/Sources/VibeviewerAPI/Service/MoyaProvider+DecodableRequest.swift @@ -0,0 +1,39 @@ +import Foundation +import Moya + +@available(OSX 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *) +extension MoyaProvider where Target == MultiTarget { + func decodableRequest( + _ target: T, + decodingStrategy: JSONDecoder.KeyDecodingStrategy = .useDefaultKeys, + callbackQueue: DispatchQueue? = nil, + completion: @escaping (_ result: Result) -> Void + ) -> Moya.Cancellable { + request(MultiTarget(target), callbackQueue: callbackQueue) { [weak self] result in + switch result { + case let .success(response): + do { + let JSONDecoder = JSONDecoder() + JSONDecoder.keyDecodingStrategy = decodingStrategy + let responseObject = try response.map( + T.ResultType.self, + atKeyPath: target.decodeAtKeyPath, + using: JSONDecoder + ) + completion(.success(responseObject)) + } catch { + completion(.failure(error)) + self?.logDecodeError(error) + } + case let .failure(error): + completion(.failure(error)) + } + } + } + + private func logDecodeError(_ error: Error) { + print("===================================================================") + print("🔴 Decode Error: \(error)") + print("===================================================================") + } +} diff --git a/参考计费/Packages/VibeviewerAPI/Sources/VibeviewerAPI/Targets/CursorAggregatedUsageEventsAPI.swift b/参考计费/Packages/VibeviewerAPI/Sources/VibeviewerAPI/Targets/CursorAggregatedUsageEventsAPI.swift new file mode 100644 index 0000000..a8a3021 --- /dev/null +++ b/参考计费/Packages/VibeviewerAPI/Sources/VibeviewerAPI/Targets/CursorAggregatedUsageEventsAPI.swift @@ -0,0 +1,44 @@ +import Foundation +import Moya + +struct CursorAggregatedUsageEventsAPI: DecodableTargetType { + typealias ResultType = CursorAggregatedUsageEventsResponse + + let teamId: Int? + let startDate: Int64 + private let cookieHeader: String? + + var baseURL: URL { APIConfig.baseURL } + var path: String { "/api/dashboard/get-aggregated-usage-events" } + var method: Moya.Method { .post } + var task: Task { + var params: [String: Any] = [ + "startDate": self.startDate + ] + if let teamId = self.teamId { + params["teamId"] = teamId + } + return .requestParameters(parameters: params, encoding: JSONEncoding.default) + } + + var headers: [String: String]? { APIHeadersBuilder.jsonHeaders(cookieHeader: self.cookieHeader) } + var sampleData: Data { + Data(""" + { + "aggregations": [], + "totalInputTokens": "0", + "totalOutputTokens": "0", + "totalCacheWriteTokens": "0", + "totalCacheReadTokens": "0", + "totalCostCents": 0.0 + } + """.utf8) + } + + init(teamId: Int?, startDate: Int64, cookieHeader: String?) { + self.teamId = teamId + self.startDate = startDate + self.cookieHeader = cookieHeader + } +} + diff --git a/参考计费/Packages/VibeviewerAPI/Sources/VibeviewerAPI/Targets/CursorCurrentBillingCycleAPI.swift b/参考计费/Packages/VibeviewerAPI/Sources/VibeviewerAPI/Targets/CursorCurrentBillingCycleAPI.swift new file mode 100644 index 0000000..b6cf747 --- /dev/null +++ b/参考计费/Packages/VibeviewerAPI/Sources/VibeviewerAPI/Targets/CursorCurrentBillingCycleAPI.swift @@ -0,0 +1,30 @@ +import Foundation +import Moya + +struct CursorCurrentBillingCycleAPI: DecodableTargetType { + typealias ResultType = CursorCurrentBillingCycleResponse + + private let cookieHeader: String? + + var baseURL: URL { APIConfig.baseURL } + var path: String { "/api/dashboard/get-current-billing-cycle" } + var method: Moya.Method { .post } + var task: Task { + .requestParameters(parameters: [:], encoding: JSONEncoding.default) + } + + var headers: [String: String]? { APIHeadersBuilder.jsonHeaders(cookieHeader: self.cookieHeader) } + var sampleData: Data { + Data(""" + { + "startDateEpochMillis": "1763891472000", + "endDateEpochMillis": "1764496272000" + } + """.utf8) + } + + init(cookieHeader: String?) { + self.cookieHeader = cookieHeader + } +} + diff --git a/参考计费/Packages/VibeviewerAPI/Sources/VibeviewerAPI/Targets/CursorFilteredUsageAPI.swift b/参考计费/Packages/VibeviewerAPI/Sources/VibeviewerAPI/Targets/CursorFilteredUsageAPI.swift new file mode 100644 index 0000000..8c964c2 --- /dev/null +++ b/参考计费/Packages/VibeviewerAPI/Sources/VibeviewerAPI/Targets/CursorFilteredUsageAPI.swift @@ -0,0 +1,40 @@ +import Foundation +import Moya +import VibeviewerModel + +struct CursorFilteredUsageAPI: DecodableTargetType { + typealias ResultType = CursorFilteredUsageResponse + + let startDateMs: String + let endDateMs: String + let userId: Int + let page: Int + private let cookieHeader: String? + + var baseURL: URL { APIConfig.baseURL } + var path: String { "/api/dashboard/get-filtered-usage-events" } + var method: Moya.Method { .post } + var task: Task { + let params: [String: Any] = [ + "startDate": self.startDateMs, + "endDate": self.endDateMs, + "userId": self.userId, + "page": self.page, + "pageSize": 100 + ] + return .requestParameters(parameters: params, encoding: JSONEncoding.default) + } + + var headers: [String: String]? { APIHeadersBuilder.jsonHeaders(cookieHeader: self.cookieHeader) } + var sampleData: Data { + Data("{\"totalUsageEventsCount\":1,\"usageEventsDisplay\":[]}".utf8) + } + + init(startDateMs: String, endDateMs: String, userId: Int, page: Int, cookieHeader: String?) { + self.startDateMs = startDateMs + self.endDateMs = endDateMs + self.userId = userId + self.page = page + self.cookieHeader = cookieHeader + } +} diff --git a/参考计费/Packages/VibeviewerAPI/Sources/VibeviewerAPI/Targets/CursorGetMeAPI.swift b/参考计费/Packages/VibeviewerAPI/Sources/VibeviewerAPI/Targets/CursorGetMeAPI.swift new file mode 100644 index 0000000..637a61c --- /dev/null +++ b/参考计费/Packages/VibeviewerAPI/Sources/VibeviewerAPI/Targets/CursorGetMeAPI.swift @@ -0,0 +1,19 @@ +import Foundation +import Moya +import VibeviewerModel + +struct CursorGetMeAPI: DecodableTargetType { + typealias ResultType = CursorMeResponse + + var baseURL: URL { APIConfig.baseURL } + var path: String { "/api/dashboard/get-me" } + var method: Moya.Method { .get } + var task: Task { .requestPlain } + var headers: [String: String]? { APIHeadersBuilder.jsonHeaders(cookieHeader: self.cookieHeader) } + var sampleData: Data { + Data("{\"authId\":\"\",\"userId\":0,\"email\":\"\",\"workosId\":\"\",\"teamId\":0,\"isEnterpriseUser\":false}".utf8) + } + + private let cookieHeader: String? + init(cookieHeader: String?) { self.cookieHeader = cookieHeader } +} diff --git a/参考计费/Packages/VibeviewerAPI/Sources/VibeviewerAPI/Targets/CursorGetTeamSpendAPI.swift b/参考计费/Packages/VibeviewerAPI/Sources/VibeviewerAPI/Targets/CursorGetTeamSpendAPI.swift new file mode 100644 index 0000000..65de3e1 --- /dev/null +++ b/参考计费/Packages/VibeviewerAPI/Sources/VibeviewerAPI/Targets/CursorGetTeamSpendAPI.swift @@ -0,0 +1,42 @@ +import Foundation +import Moya + +struct CursorGetTeamSpendAPI: DecodableTargetType { + typealias ResultType = CursorTeamSpendResponse + + let teamId: Int + let page: Int + let pageSize: Int + let sortBy: String + let sortDirection: String + private let cookieHeader: String? + + var baseURL: URL { APIConfig.baseURL } + var path: String { "/api/dashboard/get-team-spend" } + var method: Moya.Method { .post } + var task: Task { + let params: [String: Any] = [ + "teamId": self.teamId, + "page": self.page, + "pageSize": self.pageSize, + "sortBy": self.sortBy, + "sortDirection": self.sortDirection + ] + return .requestParameters(parameters: params, encoding: JSONEncoding.default) + } + var headers: [String: String]? { APIHeadersBuilder.jsonHeaders(cookieHeader: self.cookieHeader) } + var sampleData: Data { + Data("{\n \"teamMemberSpend\": [],\n \"subscriptionCycleStart\": \"0\",\n \"totalMembers\": 0,\n \"totalPages\": 0,\n \"totalByRole\": [],\n \"nextCycleStart\": \"0\",\n \"limitedUserCount\": 0,\n \"maxUserSpendCents\": 0,\n \"subscriptionLimitedUsers\": 0\n}".utf8) + } + + init(teamId: Int, page: Int = 1, pageSize: Int = 50, sortBy: String = "name", sortDirection: String = "asc", cookieHeader: String?) { + self.teamId = teamId + self.page = page + self.pageSize = pageSize + self.sortBy = sortBy + self.sortDirection = sortDirection + self.cookieHeader = cookieHeader + } +} + + diff --git a/参考计费/Packages/VibeviewerAPI/Sources/VibeviewerAPI/Targets/CursorTeamModelsAnalyticsAPI.swift b/参考计费/Packages/VibeviewerAPI/Sources/VibeviewerAPI/Targets/CursorTeamModelsAnalyticsAPI.swift new file mode 100644 index 0000000..3a3b939 --- /dev/null +++ b/参考计费/Packages/VibeviewerAPI/Sources/VibeviewerAPI/Targets/CursorTeamModelsAnalyticsAPI.swift @@ -0,0 +1,36 @@ +import Foundation +import Moya + +struct CursorTeamModelsAnalyticsAPI: DecodableTargetType { + typealias ResultType = CursorTeamModelsAnalyticsResponse + + let startDate: String + let endDate: String + let c: String + private let cookieHeader: String? + + var baseURL: URL { APIConfig.baseURL } + var path: String { "/api/v2/analytics/team/models" } + var method: Moya.Method { .get } + var task: Task { + let params: [String: Any] = [ + "startDate": self.startDate, + "endDate": self.endDate, + "c": self.c + ] + return .requestParameters(parameters: params, encoding: URLEncoding.default) + } + + var headers: [String: String]? { APIHeadersBuilder.basicHeaders(cookieHeader: self.cookieHeader) } + var sampleData: Data { + Data("{}".utf8) + } + + init(startDate: String, endDate: String, c: String, cookieHeader: String?) { + self.startDate = startDate + self.endDate = endDate + self.c = c + self.cookieHeader = cookieHeader + } +} + diff --git a/参考计费/Packages/VibeviewerAPI/Sources/VibeviewerAPI/Targets/CursorUsageSummaryAPI.swift b/参考计费/Packages/VibeviewerAPI/Sources/VibeviewerAPI/Targets/CursorUsageSummaryAPI.swift new file mode 100644 index 0000000..a9bf0b4 --- /dev/null +++ b/参考计费/Packages/VibeviewerAPI/Sources/VibeviewerAPI/Targets/CursorUsageSummaryAPI.swift @@ -0,0 +1,47 @@ +import Foundation +import Moya + +struct CursorUsageSummaryAPI: DecodableTargetType { + typealias ResultType = CursorUsageSummaryResponse + + let cookieHeader: String + + var baseURL: URL { + URL(string: "https://cursor.com")! + } + + var path: String { + "/api/usage-summary" + } + + var method: Moya.Method { + .get + } + + var task: Moya.Task { + .requestPlain + } + + var headers: [String: String]? { + [ + "accept": "*/*", + "accept-language": "zh-CN,zh;q=0.9", + "cache-control": "no-cache", + "dnt": "1", + "pragma": "no-cache", + "priority": "u=1, i", + "referer": "https://cursor.com/dashboard?tab=usage", + "sec-ch-ua": "\"Not=A?Brand\";v=\"24\", \"Chromium\";v=\"140\"", + "sec-ch-ua-arch": "\"arm\"", + "sec-ch-ua-bitness": "\"64\"", + "sec-ch-ua-mobile": "?0", + "sec-ch-ua-platform": "\"macOS\"", + "sec-ch-ua-platform-version": "\"15.3.1\"", + "sec-fetch-dest": "empty", + "sec-fetch-mode": "cors", + "sec-fetch-site": "same-origin", + "user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36", + "Cookie": cookieHeader + ] + } +} diff --git a/参考计费/Packages/VibeviewerAPI/Sources/VibeviewerAPI/Targets/MoyaNetworkTypes.swift b/参考计费/Packages/VibeviewerAPI/Sources/VibeviewerAPI/Targets/MoyaNetworkTypes.swift new file mode 100644 index 0000000..9dc006b --- /dev/null +++ b/参考计费/Packages/VibeviewerAPI/Sources/VibeviewerAPI/Targets/MoyaNetworkTypes.swift @@ -0,0 +1,16 @@ +import Foundation +import Moya + +protocol DecodableTargetType: TargetType { + associatedtype ResultType: Decodable + + var decodeAtKeyPath: String? { get } +} + +extension DecodableTargetType { + var decodeAtKeyPath: String? { nil } + + var validationType: ValidationType { + .successCodes + } +} diff --git a/参考计费/Packages/VibeviewerAPI/Tests/VibeviewerAPITests/PlaceholderTests.swift b/参考计费/Packages/VibeviewerAPI/Tests/VibeviewerAPITests/PlaceholderTests.swift new file mode 100644 index 0000000..a1bbe8c --- /dev/null +++ b/参考计费/Packages/VibeviewerAPI/Tests/VibeviewerAPITests/PlaceholderTests.swift @@ -0,0 +1,6 @@ +import Testing + +@Test func placeholderTest() async throws { + // Placeholder test to ensure test target builds correctly + #expect(true) +} diff --git a/参考计费/Packages/VibeviewerAppEnvironment/Package.resolved b/参考计费/Packages/VibeviewerAppEnvironment/Package.resolved new file mode 100644 index 0000000..01a6f94 --- /dev/null +++ b/参考计费/Packages/VibeviewerAppEnvironment/Package.resolved @@ -0,0 +1,42 @@ +{ + "originHash" : "96a5b396a796a589b3f9c8f01a168bba37961921fe4ecfafe1b8e1f5c5a26ef8", + "pins" : [ + { + "identity" : "alamofire", + "kind" : "remoteSourceControl", + "location" : "https://github.com/Alamofire/Alamofire.git", + "state" : { + "revision" : "513364f870f6bfc468f9d2ff0a95caccc10044c5", + "version" : "5.10.2" + } + }, + { + "identity" : "moya", + "kind" : "remoteSourceControl", + "location" : "https://github.com/Moya/Moya.git", + "state" : { + "revision" : "c263811c1f3dbf002be9bd83107f7cdc38992b26", + "version" : "15.0.3" + } + }, + { + "identity" : "reactiveswift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/ReactiveCocoa/ReactiveSwift.git", + "state" : { + "revision" : "c43bae3dac73fdd3cb906bd5a1914686ca71ed3c", + "version" : "6.7.0" + } + }, + { + "identity" : "rxswift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/ReactiveX/RxSwift.git", + "state" : { + "revision" : "5dd1907d64f0d36f158f61a466bab75067224893", + "version" : "6.9.0" + } + } + ], + "version" : 3 +} diff --git a/参考计费/Packages/VibeviewerAppEnvironment/Package.swift b/参考计费/Packages/VibeviewerAppEnvironment/Package.swift new file mode 100644 index 0000000..236bacc --- /dev/null +++ b/参考计费/Packages/VibeviewerAppEnvironment/Package.swift @@ -0,0 +1,42 @@ +// swift-tools-version: 5.10 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +let package = Package( + name: "VibeviewerAppEnvironment", + platforms: [ + .macOS(.v14) + ], + products: [ + // Products define the executables and libraries a package produces, making them visible to other packages. + .library( + name: "VibeviewerAppEnvironment", + targets: ["VibeviewerAppEnvironment"] + ) + ], + dependencies: [ + .package(path: "../VibeviewerAPI"), + .package(path: "../VibeviewerModel"), + .package(path: "../VibeviewerStorage"), + .package(path: "../VibeviewerCore"), + ], + targets: [ + // Targets are the basic building blocks of a package, defining a module or a test suite. + // Targets can depend on other targets in this package and products from dependencies. + .target( + name: "VibeviewerAppEnvironment", + dependencies: [ + "VibeviewerAPI", + "VibeviewerModel", + "VibeviewerStorage", + "VibeviewerCore", + ] + ), + .testTarget( + name: "VibeviewerAppEnvironmentTests", + dependencies: ["VibeviewerAppEnvironment"], + path: "Tests/VibeviewerAppEnvironmentTests" + ), + ] +) diff --git a/参考计费/Packages/VibeviewerAppEnvironment/Sources/VibeviewerAppEnvironment/Environment/CursorServiceEnvironment.swift b/参考计费/Packages/VibeviewerAppEnvironment/Sources/VibeviewerAppEnvironment/Environment/CursorServiceEnvironment.swift new file mode 100644 index 0000000..e444cb6 --- /dev/null +++ b/参考计费/Packages/VibeviewerAppEnvironment/Sources/VibeviewerAppEnvironment/Environment/CursorServiceEnvironment.swift @@ -0,0 +1,13 @@ +import SwiftUI +import VibeviewerAPI + +private struct CursorServiceKey: EnvironmentKey { + static let defaultValue: CursorService = DefaultCursorService() +} + +public extension EnvironmentValues { + var cursorService: CursorService { + get { self[CursorServiceKey.self] } + set { self[CursorServiceKey.self] = newValue } + } +} diff --git a/参考计费/Packages/VibeviewerAppEnvironment/Sources/VibeviewerAppEnvironment/Environment/CursorStorageEnvironment.swift b/参考计费/Packages/VibeviewerAppEnvironment/Sources/VibeviewerAppEnvironment/Environment/CursorStorageEnvironment.swift new file mode 100644 index 0000000..df50e13 --- /dev/null +++ b/参考计费/Packages/VibeviewerAppEnvironment/Sources/VibeviewerAppEnvironment/Environment/CursorStorageEnvironment.swift @@ -0,0 +1,13 @@ +import SwiftUI +import VibeviewerStorage + +private struct CursorStorageKey: EnvironmentKey { + static let defaultValue: any CursorStorageService = DefaultCursorStorageService() +} + +public extension EnvironmentValues { + var cursorStorage: any CursorStorageService { + get { self[CursorStorageKey.self] } + set { self[CursorStorageKey.self] = newValue } + } +} diff --git a/参考计费/Packages/VibeviewerAppEnvironment/Sources/VibeviewerAppEnvironment/Environment/DashboardRefreshEnvironment.swift b/参考计费/Packages/VibeviewerAppEnvironment/Sources/VibeviewerAppEnvironment/Environment/DashboardRefreshEnvironment.swift new file mode 100644 index 0000000..269c925 --- /dev/null +++ b/参考计费/Packages/VibeviewerAppEnvironment/Sources/VibeviewerAppEnvironment/Environment/DashboardRefreshEnvironment.swift @@ -0,0 +1,23 @@ +import SwiftUI + +private struct DashboardRefreshServiceKey: EnvironmentKey { + static let defaultValue: any DashboardRefreshService = NoopDashboardRefreshService() +} + +private struct ScreenPowerStateServiceKey: EnvironmentKey { + static let defaultValue: any ScreenPowerStateService = NoopScreenPowerStateService() +} + +public extension EnvironmentValues { + var dashboardRefreshService: any DashboardRefreshService { + get { self[DashboardRefreshServiceKey.self] } + set { self[DashboardRefreshServiceKey.self] = newValue } + } + + var screenPowerStateService: any ScreenPowerStateService { + get { self[ScreenPowerStateServiceKey.self] } + set { self[ScreenPowerStateServiceKey.self] = newValue } + } +} + + diff --git a/参考计费/Packages/VibeviewerAppEnvironment/Sources/VibeviewerAppEnvironment/Environment/LaunchAtLoginEnvironment.swift b/参考计费/Packages/VibeviewerAppEnvironment/Sources/VibeviewerAppEnvironment/Environment/LaunchAtLoginEnvironment.swift new file mode 100644 index 0000000..2df9962 --- /dev/null +++ b/参考计费/Packages/VibeviewerAppEnvironment/Sources/VibeviewerAppEnvironment/Environment/LaunchAtLoginEnvironment.swift @@ -0,0 +1,13 @@ +import SwiftUI +import VibeviewerCore + +private struct LaunchAtLoginServiceKey: EnvironmentKey { + static let defaultValue: any LaunchAtLoginService = DefaultLaunchAtLoginService() +} + +public extension EnvironmentValues { + var launchAtLoginService: any LaunchAtLoginService { + get { self[LaunchAtLoginServiceKey.self] } + set { self[LaunchAtLoginServiceKey.self] = newValue } + } +} \ No newline at end of file diff --git a/参考计费/Packages/VibeviewerAppEnvironment/Sources/VibeviewerAppEnvironment/Environment/LoginServiceEnvironment.swift b/参考计费/Packages/VibeviewerAppEnvironment/Sources/VibeviewerAppEnvironment/Environment/LoginServiceEnvironment.swift new file mode 100644 index 0000000..8dd6603 --- /dev/null +++ b/参考计费/Packages/VibeviewerAppEnvironment/Sources/VibeviewerAppEnvironment/Environment/LoginServiceEnvironment.swift @@ -0,0 +1,14 @@ +import SwiftUI + +private struct LoginServiceKey: EnvironmentKey { + static let defaultValue: any LoginService = NoopLoginService() +} + +public extension EnvironmentValues { + var loginService: any LoginService { + get { self[LoginServiceKey.self] } + set { self[LoginServiceKey.self] = newValue } + } +} + + diff --git a/参考计费/Packages/VibeviewerAppEnvironment/Sources/VibeviewerAppEnvironment/Environment/UpdateServiceEnvironment.swift b/参考计费/Packages/VibeviewerAppEnvironment/Sources/VibeviewerAppEnvironment/Environment/UpdateServiceEnvironment.swift new file mode 100644 index 0000000..7252643 --- /dev/null +++ b/参考计费/Packages/VibeviewerAppEnvironment/Sources/VibeviewerAppEnvironment/Environment/UpdateServiceEnvironment.swift @@ -0,0 +1,13 @@ +import SwiftUI + +private struct UpdateServiceKey: EnvironmentKey { + static let defaultValue: any UpdateService = NoopUpdateService() +} + +public extension EnvironmentValues { + var updateService: any UpdateService { + get { self[UpdateServiceKey.self] } + set { self[UpdateServiceKey.self] = newValue } + } +} + diff --git a/参考计费/Packages/VibeviewerAppEnvironment/Sources/VibeviewerAppEnvironment/Service/DashboardRefreshService.swift b/参考计费/Packages/VibeviewerAppEnvironment/Sources/VibeviewerAppEnvironment/Service/DashboardRefreshService.swift new file mode 100644 index 0000000..83eaf99 --- /dev/null +++ b/参考计费/Packages/VibeviewerAppEnvironment/Sources/VibeviewerAppEnvironment/Service/DashboardRefreshService.swift @@ -0,0 +1,289 @@ +import Foundation +import Observation +import VibeviewerAPI +import VibeviewerModel +import VibeviewerStorage +import VibeviewerCore + +/// 后台刷新服务协议 +public protocol DashboardRefreshService: Sendable { + @MainActor var isRefreshing: Bool { get } + @MainActor var isPaused: Bool { get } + @MainActor func start() async + @MainActor func stop() + @MainActor func pause() + @MainActor func resume() async + @MainActor func refreshNow() async +} + +/// 无操作默认实现,便于提供 Environment 默认值 +public struct NoopDashboardRefreshService: DashboardRefreshService { + public init() {} + public var isRefreshing: Bool { false } + public var isPaused: Bool { false } + @MainActor public func start() async {} + @MainActor public func stop() {} + @MainActor public func pause() {} + @MainActor public func resume() async {} + @MainActor public func refreshNow() async {} +} + +@MainActor +@Observable +public final class DefaultDashboardRefreshService: DashboardRefreshService { + private let api: CursorService + private let storage: any CursorStorageService + private let settings: AppSettings + private let session: AppSession + private var loopTask: Task? + public private(set) var isRefreshing: Bool = false + public private(set) var isPaused: Bool = false + + public init( + api: CursorService, + storage: any CursorStorageService, + settings: AppSettings, + session: AppSession + ) { + self.api = api + self.storage = storage + self.settings = settings + self.session = session + } + + public func start() async { + await self.bootstrapIfNeeded() + await self.refreshNow() + + self.loopTask?.cancel() + self.loopTask = Task { [weak self] in + guard let self else { return } + while !Task.isCancelled { + // 如果暂停,则等待一段时间后再检查 + if self.isPaused { + try? await Task.sleep(for: .seconds(30)) // 暂停时每30秒检查一次状态 + continue + } + await self.refreshNow() + // 固定 5 分钟刷新一次 + try? await Task.sleep(for: .seconds(5 * 60)) + } + } + } + + public func stop() { + self.loopTask?.cancel() + self.loopTask = nil + } + + public func pause() { + self.isPaused = true + } + + public func resume() async { + self.isPaused = false + // 立即刷新一次 + await self.refreshNow() + } + + public func refreshNow() async { + if self.isRefreshing || self.isPaused { return } + self.isRefreshing = true + defer { self.isRefreshing = false } + await self.bootstrapIfNeeded() + guard let creds = self.session.credentials else { return } + + do { + // 计算时间范围 + let (analyticsStartMs, analyticsEndMs) = self.analyticsDateRangeMs() + + // 使用 async let 并发发起所有独立的 API 请求 + async let usageSummary = try await self.api.fetchUsageSummary( + cookieHeader: creds.cookieHeader + ) + async let history = try await self.api.fetchFilteredUsageEvents( + startDateMs: analyticsStartMs, + endDateMs: analyticsEndMs, + userId: creds.userId, + page: 1, + cookieHeader: creds.cookieHeader + ) + async let billingCycleMs = try? await self.api.fetchCurrentBillingCycleMs( + cookieHeader: creds.cookieHeader + ) + + // 等待 usageSummary,用于判断账号类型 + let usageSummaryValue = try await usageSummary + + // Pro 用户使用 filtered usage events 获取图表数据(700 条) + // Team/Enterprise 用户使用 models analytics API + let modelsUsageChart = try? await self.fetchModelsUsageChartForUser( + usageSummary: usageSummaryValue, + creds: creds, + analyticsStartMs: analyticsStartMs, + analyticsEndMs: analyticsEndMs + ) + + // 获取计费周期(毫秒时间戳格式) + let billingCycleValue = await billingCycleMs + + // totalRequestsAllModels 将基于使用事件计算,而非API返回的请求数据 + let totalAll = 0 // 暂时设为0,后续通过使用事件更新 + + let current = self.session.snapshot + + // Team Plan free usage(依赖 usageSummary 判定) + func computeFreeCents() async -> Int { + if usageSummaryValue.membershipType == .enterprise && creds.isEnterpriseUser == false { + return (try? await self.api.fetchTeamFreeUsageCents( + teamId: creds.teamId, + userId: creds.userId, + cookieHeader: creds.cookieHeader + )) ?? 0 + } + return 0 + } + let freeCents = await computeFreeCents() + + // 获取聚合使用事件(仅限 Pro 系列账号,非 Team) + func fetchModelsUsageSummaryIfNeeded(billingCycleStartMs: String) async -> VibeviewerModel.ModelsUsageSummary? { + // 仅 Pro 系列账号才获取(Pro / Pro+ / Ultra,非 Team / Enterprise) + let isProAccount = usageSummaryValue.membershipType.isProSeries + guard isProAccount else { return nil } + + // 使用账单周期的开始时间(毫秒时间戳) + let startDateMs = Int64(billingCycleStartMs) ?? 0 + + let aggregated = try? await self.api.fetchAggregatedUsageEvents( + teamId: -1, + startDate: startDateMs, + cookieHeader: creds.cookieHeader + ) + + return aggregated.map { VibeviewerModel.ModelsUsageSummary(from: $0) } + } + var modelsUsageSummary: VibeviewerModel.ModelsUsageSummary? = nil + if let billingCycleStartMs = billingCycleValue?.startDateMs { + modelsUsageSummary = await fetchModelsUsageSummaryIfNeeded(billingCycleStartMs: billingCycleStartMs) + } + + // 先更新一次概览(使用旧历史事件),提升 UI 及时性 + let overview = DashboardSnapshot( + email: creds.email, + totalRequestsAllModels: totalAll, + spendingCents: usageSummaryValue.individualUsage.plan.used, + hardLimitDollars: usageSummaryValue.individualUsage.plan.limit / 100, + usageEvents: current?.usageEvents ?? [], + requestToday: current?.requestToday ?? 0, + requestYestoday: current?.requestYestoday ?? 0, + usageSummary: usageSummaryValue, + freeUsageCents: freeCents, + modelsUsageChart: current?.modelsUsageChart, + modelsUsageSummary: modelsUsageSummary, + billingCycleStartMs: billingCycleValue?.startDateMs, + billingCycleEndMs: billingCycleValue?.endDateMs + ) + self.session.snapshot = overview + try? await self.storage.saveDashboardSnapshot(overview) + + // 等待并合并历史事件数据 + let historyValue = try await history + let (reqToday, reqYesterday) = self.splitTodayAndYesterdayCounts(from: historyValue.events) + let merged = DashboardSnapshot( + email: overview.email, + totalRequestsAllModels: overview.totalRequestsAllModels, + spendingCents: overview.spendingCents, + hardLimitDollars: overview.hardLimitDollars, + usageEvents: historyValue.events, + requestToday: reqToday, + requestYestoday: reqYesterday, + usageSummary: usageSummaryValue, + freeUsageCents: overview.freeUsageCents, + modelsUsageChart: modelsUsageChart, + modelsUsageSummary: modelsUsageSummary, + billingCycleStartMs: billingCycleValue?.startDateMs, + billingCycleEndMs: billingCycleValue?.endDateMs + ) + self.session.snapshot = merged + try? await self.storage.saveDashboardSnapshot(merged) + } catch { + // 静默失败 + } + } + + private func bootstrapIfNeeded() async { + if self.session.snapshot == nil, let cached = await self.storage.loadDashboardSnapshot() { + self.session.snapshot = cached + } + if self.session.credentials == nil { + self.session.credentials = await self.storage.loadCredentials() + } + } + + private func yesterdayToNowRangeMs() -> (String, String) { + let (start, end) = VibeviewerCore.DateUtils.yesterdayToNowRange() + return (VibeviewerCore.DateUtils.millisecondsString(from: start), VibeviewerCore.DateUtils.millisecondsString(from: end)) + } + + private func analyticsDateRangeMs() -> (String, String) { + let days = self.settings.analyticsDataDays + let (start, end) = VibeviewerCore.DateUtils.daysAgoToNowRange(days: days) + return (VibeviewerCore.DateUtils.millisecondsString(from: start), VibeviewerCore.DateUtils.millisecondsString(from: end)) + } + + private func splitTodayAndYesterdayCounts(from events: [UsageEvent]) -> (Int, Int) { + let calendar = Calendar.current + var today = 0 + var yesterday = 0 + for e in events { + guard let date = VibeviewerCore.DateUtils.date(fromMillisecondsString: e.occurredAtMs) else { continue } + if calendar.isDateInToday(date) { + today += e.requestCostCount + } else if calendar.isDateInYesterday(date) { + yesterday += e.requestCostCount + } + } + return (today, yesterday) + } + + /// 计算模型分析的时间范围:使用设置中的分析数据范围天数 + private func modelsAnalyticsDateRange() -> (start: String, end: String) { + let days = self.settings.analyticsDataDays + return VibeviewerCore.DateUtils.daysAgoToTodayRange(days: days) + } + + /// 根据账号类型获取模型使用量图表数据 + /// - 非 Team 账号(Pro / Pro+ / Ultra / Free 等):使用 filtered usage events(700 条) + /// - Team Plan 账号:使用 models analytics API(/api/v2/analytics/team/models) + private func fetchModelsUsageChartForUser( + usageSummary: VibeviewerModel.UsageSummary, + creds: Credentials, + analyticsStartMs: String, + analyticsEndMs: String + ) async throws -> VibeviewerModel.ModelsUsageChartData { + // 仅 Team Plan 账号调用 team analytics 接口: + // - 后端使用 membershipType = .enterprise + isEnterpriseUser = false 表示 Team Plan + let isTeamPlanAccount = (usageSummary.membershipType == .enterprise && creds.isEnterpriseUser == false) + + // 非 Team 账号一律使用 filtered usage events,避免误调 /api/v2/analytics/team/ 系列接口 + guard isTeamPlanAccount else { + return try await self.api.fetchModelsUsageChartFromEvents( + startDateMs: analyticsStartMs, + endDateMs: analyticsEndMs, + userId: creds.userId, + cookieHeader: creds.cookieHeader + ) + } + + // Team Plan 用户使用 models analytics API + let dateRange = self.modelsAnalyticsDateRange() + return try await self.api.fetchModelsAnalytics( + startDate: dateRange.start, + endDate: dateRange.end, + c: creds.workosId, + cookieHeader: creds.cookieHeader + ) + } +} + + diff --git a/参考计费/Packages/VibeviewerAppEnvironment/Sources/VibeviewerAppEnvironment/Service/LoginService.swift b/参考计费/Packages/VibeviewerAppEnvironment/Sources/VibeviewerAppEnvironment/Service/LoginService.swift new file mode 100644 index 0000000..7c3d0cd --- /dev/null +++ b/参考计费/Packages/VibeviewerAppEnvironment/Sources/VibeviewerAppEnvironment/Service/LoginService.swift @@ -0,0 +1,79 @@ +import Foundation +import VibeviewerAPI +import VibeviewerModel +import VibeviewerStorage + +public enum LoginServiceError: Error, Equatable { + case fetchAccountFailed + case saveCredentialsFailed + case initialRefreshFailed +} + +public protocol LoginService: Sendable { + /// 执行完整的登录流程:根据 Cookie 获取账号信息、保存凭据并触发 Dashboard 刷新 + @MainActor + func login(with cookieHeader: String) async throws +} + +/// 无操作实现,作为 Environment 的默认值 +public struct NoopLoginService: LoginService { + public init() {} + @MainActor + public func login(with cookieHeader: String) async throws {} +} + +@MainActor +public final class DefaultLoginService: LoginService { + private let api: CursorService + private let storage: any CursorStorageService + private let refresher: any DashboardRefreshService + private let session: AppSession + + public init( + api: CursorService, + storage: any CursorStorageService, + refresher: any DashboardRefreshService, + session: AppSession + ) { + self.api = api + self.storage = storage + self.refresher = refresher + self.session = session + } + + public func login(with cookieHeader: String) async throws { + // 记录登录前状态,用于首次登录失败时回滚 + let previousCredentials = self.session.credentials + let previousSnapshot = self.session.snapshot + + // 1. 使用 Cookie 获取账号信息 + let me: Credentials + do { + me = try await self.api.fetchMe(cookieHeader: cookieHeader) + } catch { + throw LoginServiceError.fetchAccountFailed + } + + // 2. 保存凭据并更新会话 + do { + try await self.storage.saveCredentials(me) + self.session.credentials = me + } catch { + throw LoginServiceError.saveCredentialsFailed + } + + // 3. 启动后台刷新服务,让其负责拉取和写入 Dashboard 数据 + await self.refresher.start() + + // 4. 如果是首次登录且依然没有 snapshot,视为登录失败并回滚 + if previousCredentials == nil, previousSnapshot == nil, self.session.snapshot == nil { + await self.storage.clearCredentials() + await self.storage.clearDashboardSnapshot() + self.session.credentials = nil + self.session.snapshot = nil + throw LoginServiceError.initialRefreshFailed + } + } +} + + diff --git a/参考计费/Packages/VibeviewerAppEnvironment/Sources/VibeviewerAppEnvironment/Service/PowerAwareDashboardRefreshService.swift b/参考计费/Packages/VibeviewerAppEnvironment/Sources/VibeviewerAppEnvironment/Service/PowerAwareDashboardRefreshService.swift new file mode 100644 index 0000000..a1cbed3 --- /dev/null +++ b/参考计费/Packages/VibeviewerAppEnvironment/Sources/VibeviewerAppEnvironment/Service/PowerAwareDashboardRefreshService.swift @@ -0,0 +1,59 @@ +import Foundation +import Observation + +/// 集成刷新服务和屏幕电源状态的协调器 +@MainActor +@Observable +public final class PowerAwareDashboardRefreshService: DashboardRefreshService { + private let refreshService: DefaultDashboardRefreshService + private let screenPowerService: DefaultScreenPowerStateService + + public var isRefreshing: Bool { refreshService.isRefreshing } + public var isPaused: Bool { refreshService.isPaused } + + public init( + refreshService: DefaultDashboardRefreshService, + screenPowerService: DefaultScreenPowerStateService + ) { + self.refreshService = refreshService + self.screenPowerService = screenPowerService + + // 设置屏幕睡眠和唤醒回调 + screenPowerService.setOnScreenSleep { [weak self] in + Task { @MainActor in + self?.refreshService.pause() + } + } + + screenPowerService.setOnScreenWake { [weak self] in + Task { @MainActor in + await self?.refreshService.resume() + } + } + } + + public func start() async { + // 启动屏幕电源状态监控 + screenPowerService.startMonitoring() + + // 启动刷新服务 + await refreshService.start() + } + + public func stop() { + refreshService.stop() + screenPowerService.stopMonitoring() + } + + public func pause() { + refreshService.pause() + } + + public func resume() async { + await refreshService.resume() + } + + public func refreshNow() async { + await refreshService.refreshNow() + } +} \ No newline at end of file diff --git a/参考计费/Packages/VibeviewerAppEnvironment/Sources/VibeviewerAppEnvironment/Service/ScreenPowerStateService.swift b/参考计费/Packages/VibeviewerAppEnvironment/Sources/VibeviewerAppEnvironment/Service/ScreenPowerStateService.swift new file mode 100644 index 0000000..1292bab --- /dev/null +++ b/参考计费/Packages/VibeviewerAppEnvironment/Sources/VibeviewerAppEnvironment/Service/ScreenPowerStateService.swift @@ -0,0 +1,77 @@ +import Foundation +import Cocoa + +/// 屏幕电源状态服务协议 +public protocol ScreenPowerStateService: Sendable { + @MainActor var isScreenAwake: Bool { get } + @MainActor func startMonitoring() + @MainActor func stopMonitoring() + @MainActor func setOnScreenSleep(_ handler: @escaping @Sendable () -> Void) + @MainActor func setOnScreenWake(_ handler: @escaping @Sendable () -> Void) +} + +/// 默认屏幕电源状态服务实现 +@MainActor +public final class DefaultScreenPowerStateService: ScreenPowerStateService, ObservableObject { + public private(set) var isScreenAwake: Bool = true + + private var onScreenSleep: (@Sendable () -> Void)? + private var onScreenWake: (@Sendable () -> Void)? + + public init() {} + + public func setOnScreenSleep(_ handler: @escaping @Sendable () -> Void) { + self.onScreenSleep = handler + } + + public func setOnScreenWake(_ handler: @escaping @Sendable () -> Void) { + self.onScreenWake = handler + } + + public func startMonitoring() { + NotificationCenter.default.addObserver( + forName: NSWorkspace.willSleepNotification, + object: nil, + queue: .main + ) { [weak self] _ in + Task { @MainActor in + self?.handleScreenSleep() + } + } + + NotificationCenter.default.addObserver( + forName: NSWorkspace.didWakeNotification, + object: nil, + queue: .main + ) { [weak self] _ in + Task { @MainActor in + self?.handleScreenWake() + } + } + } + + public func stopMonitoring() { + NotificationCenter.default.removeObserver(self, name: NSWorkspace.willSleepNotification, object: nil) + NotificationCenter.default.removeObserver(self, name: NSWorkspace.didWakeNotification, object: nil) + } + + private func handleScreenSleep() { + isScreenAwake = false + onScreenSleep?() + } + + private func handleScreenWake() { + isScreenAwake = true + onScreenWake?() + } +} + +/// 无操作默认实现,便于提供 Environment 默认值 +public struct NoopScreenPowerStateService: ScreenPowerStateService { + public init() {} + public var isScreenAwake: Bool { true } + public func startMonitoring() {} + public func stopMonitoring() {} + public func setOnScreenSleep(_ handler: @escaping @Sendable () -> Void) {} + public func setOnScreenWake(_ handler: @escaping @Sendable () -> Void) {} +} \ No newline at end of file diff --git a/参考计费/Packages/VibeviewerAppEnvironment/Sources/VibeviewerAppEnvironment/Service/UpdateService.swift b/参考计费/Packages/VibeviewerAppEnvironment/Sources/VibeviewerAppEnvironment/Service/UpdateService.swift new file mode 100644 index 0000000..3ea0416 --- /dev/null +++ b/参考计费/Packages/VibeviewerAppEnvironment/Sources/VibeviewerAppEnvironment/Service/UpdateService.swift @@ -0,0 +1,54 @@ +import Foundation + +/// 应用更新服务协议 +public protocol UpdateService: Sendable { + /// 检查更新(手动触发) + @MainActor func checkForUpdates() + + /// 自动检查更新(在应用启动时调用) + @MainActor func checkForUpdatesInBackground() + + /// 是否正在检查更新 + @MainActor var isCheckingForUpdates: Bool { get } + + /// 是否有可用更新 + @MainActor var updateAvailable: Bool { get } + + /// 当前版本信息 + var currentVersion: String { get } + + /// 最新可用版本号(如果有更新) + @MainActor var latestVersion: String? { get } + + /// 上次检查更新的时间 + @MainActor var lastUpdateCheckDate: Date? { get } + + /// 更新状态描述 + @MainActor var updateStatusDescription: String { get } +} + +/// 无操作默认实现,便于提供 Environment 默认值 +public struct NoopUpdateService: UpdateService { + public init() {} + + @MainActor public func checkForUpdates() {} + @MainActor public func checkForUpdatesInBackground() {} + @MainActor public var isCheckingForUpdates: Bool { false } + @MainActor public var updateAvailable: Bool { false } + public var currentVersion: String { + // 使用 Bundle.main 读取版本号 + if let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String, !version.isEmpty { + return version + } + // Fallback: 尝试从 CFBundleVersion 读取 + if let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String, !version.isEmpty { + return version + } + // 默认版本号 + return "1.1.9" + } + @MainActor public var latestVersion: String? { nil } + @MainActor public var lastUpdateCheckDate: Date? { nil } + @MainActor public var updateStatusDescription: String { "更新服务不可用" } +} + diff --git a/参考计费/Packages/VibeviewerAppEnvironment/Tests/VibeviewerAppEnvironmentTests/PlaceholderTests.swift b/参考计费/Packages/VibeviewerAppEnvironment/Tests/VibeviewerAppEnvironmentTests/PlaceholderTests.swift new file mode 100644 index 0000000..c70069d --- /dev/null +++ b/参考计费/Packages/VibeviewerAppEnvironment/Tests/VibeviewerAppEnvironmentTests/PlaceholderTests.swift @@ -0,0 +1,8 @@ +@testable import VibeviewerCore +import XCTest + +final class VibeviewerAppEnvironmentTests: XCTestCase { + func testExample() { + XCTAssertTrue(true) + } +} diff --git a/参考计费/Packages/VibeviewerCore/Package.swift b/参考计费/Packages/VibeviewerCore/Package.swift new file mode 100644 index 0000000..545f69e --- /dev/null +++ b/参考计费/Packages/VibeviewerCore/Package.swift @@ -0,0 +1,17 @@ +// swift-tools-version:5.10 +import PackageDescription + +let package = Package( + name: "VibeviewerCore", + platforms: [ + .macOS(.v14) + ], + products: [ + .library(name: "VibeviewerCore", targets: ["VibeviewerCore"]), + ], + dependencies: [], + targets: [ + .target(name: "VibeviewerCore", dependencies: []), + .testTarget(name: "VibeviewerCoreTests", dependencies: ["VibeviewerCore"]) + ] +) diff --git a/参考计费/Packages/VibeviewerCore/Sources/VibeviewerCore/Extensions/Data+E.swift b/参考计费/Packages/VibeviewerCore/Sources/VibeviewerCore/Extensions/Data+E.swift new file mode 100644 index 0000000..26247b5 --- /dev/null +++ b/参考计费/Packages/VibeviewerCore/Sources/VibeviewerCore/Extensions/Data+E.swift @@ -0,0 +1,22 @@ +// +// Data+E.swift +// HttpClient +// +// Created by Groot chen on 2024/9/6. +// + +import Foundation + +public extension Data { + func toPrettyPrintedJSONString() -> String? { + if let json = try? JSONSerialization.jsonObject(with: self), + let data = try? JSONSerialization.data( + withJSONObject: json, + options: [.prettyPrinted, .withoutEscapingSlashes] + ) + { + return String(data: data, encoding: .utf8) + } + return nil + } +} diff --git a/参考计费/Packages/VibeviewerCore/Sources/VibeviewerCore/Extensions/Date+E.swift b/参考计费/Packages/VibeviewerCore/Sources/VibeviewerCore/Extensions/Date+E.swift new file mode 100644 index 0000000..9232193 --- /dev/null +++ b/参考计费/Packages/VibeviewerCore/Sources/VibeviewerCore/Extensions/Date+E.swift @@ -0,0 +1,33 @@ +import Foundation + +public extension Date { + /// 毫秒时间戳(字符串) + public var millisecondsSince1970String: String { + String(Int(self.timeIntervalSince1970 * 1000)) + } + + /// 由毫秒时间戳字符串构造 Date + public static func fromMillisecondsString(_ msString: String) -> Date? { + guard let ms = Double(msString) else { return nil } + return Date(timeIntervalSince1970: ms / 1000.0) + } +} + +public extension Calendar { + /// 给定日期所在天的起止 [start, end] + public func dayRange(for date: Date) -> (start: Date, end: Date) { + let startOfDay = self.startOfDay(for: date) + let nextDay = self.date(byAdding: .day, value: 1, to: startOfDay) ?? date + let endOfDay = Date(timeInterval: -0.001, since: nextDay) + return (startOfDay, endOfDay) + } + + /// 昨天 00:00 到当前时刻的区间 [yesterdayStart, now] + public func yesterdayToNowRange(from now: Date = Date()) -> (start: Date, end: Date) { + let startOfToday = self.startOfDay(for: now) + let startOfYesterday = self.date(byAdding: .day, value: -1, to: startOfToday) ?? now + return (startOfYesterday, now) + } +} + + diff --git a/参考计费/Packages/VibeviewerCore/Sources/VibeviewerCore/Extensions/DateUtils.swift b/参考计费/Packages/VibeviewerCore/Sources/VibeviewerCore/Extensions/DateUtils.swift new file mode 100644 index 0000000..59ff369 --- /dev/null +++ b/参考计费/Packages/VibeviewerCore/Sources/VibeviewerCore/Extensions/DateUtils.swift @@ -0,0 +1,121 @@ +import Foundation + +public enum DateUtils { + public enum TimeFormat { + case hm // HH:mm + case hms // HH:mm:ss + + fileprivate var dateFormat: String { + switch self { + case .hm: return "HH:mm" + case .hms: return "HH:mm:ss" + } + } + } + + /// 给定日期所在天的起止 [start, end] + public static func dayRange(for date: Date, calendar: Calendar = .current) -> (start: Date, end: Date) { + let startOfDay = calendar.startOfDay(for: date) + let nextDay = calendar.date(byAdding: .day, value: 1, to: startOfDay) ?? date + let endOfDay = Date(timeInterval: -0.001, since: nextDay) + return (startOfDay, endOfDay) + } + + /// 昨天 00:00 到当前时刻的区间 [yesterdayStart, now] + public static func yesterdayToNowRange(from now: Date = Date(), calendar: Calendar = .current) -> (start: Date, end: Date) { + let startOfToday = calendar.startOfDay(for: now) + let startOfYesterday = calendar.date(byAdding: .day, value: -1, to: startOfToday) ?? now + return (startOfYesterday, now) + } + + /// 7 天前的 00:00 到明天 00:00 的区间 [sevenDaysAgoStart, tomorrowStart] + public static func sevenDaysAgoToNowRange(from now: Date = Date(), calendar: Calendar = .current) -> (start: Date, end: Date) { + let startOfToday = calendar.startOfDay(for: now) + let startOfSevenDaysAgo = calendar.date(byAdding: .day, value: -7, to: startOfToday) ?? now + let startOfTomorrow = calendar.date(byAdding: .day, value: 1, to: startOfToday) ?? now + return (startOfSevenDaysAgo, startOfTomorrow) + } + + /// 指定天数前的 00:00 到明天 00:00 的区间 [nDaysAgoStart, tomorrowStart] + public static func daysAgoToNowRange(days: Int, from now: Date = Date(), calendar: Calendar = .current) -> (start: Date, end: Date) { + let startOfToday = calendar.startOfDay(for: now) + let startOfNDaysAgo = calendar.date(byAdding: .day, value: -days, to: startOfToday) ?? now + let startOfTomorrow = calendar.date(byAdding: .day, value: 1, to: startOfToday) ?? now + return (startOfNDaysAgo, startOfTomorrow) + } + + /// 将 Date 转为毫秒字符串 + public static func millisecondsString(from date: Date) -> String { + String(Int(date.timeIntervalSince1970 * 1000)) + } + + /// 由毫秒字符串转 Date + public static func date(fromMillisecondsString msString: String) -> Date? { + guard let ms = Double(msString) else { return nil } + return Date(timeIntervalSince1970: ms / 1000.0) + } + + /// 将 Date 按指定格式转为时间字符串(默认 HH:mm:ss) + public static func timeString(from date: Date, + format: TimeFormat = .hms, + timeZone: TimeZone = .current, + locale: Locale = Locale(identifier: "en_US_POSIX")) -> String { + let formatter = DateFormatter() + formatter.locale = locale + formatter.timeZone = timeZone + formatter.dateFormat = format.dateFormat + return formatter.string(from: date) + } + + /// 由毫秒级时间戳转为时间字符串 + public static func timeString(fromMilliseconds ms: Int64, + format: TimeFormat = .hms, + timeZone: TimeZone = .current, + locale: Locale = .current) -> String { + let date = Date(timeIntervalSince1970: TimeInterval(ms) / 1000.0) + return timeString(from: date, format: format, timeZone: timeZone, locale: locale) + } + + /// 由秒级时间戳转为时间字符串 + public static func timeString(fromSeconds s: Int64, + format: TimeFormat = .hms, + timeZone: TimeZone = .current, + locale: Locale = .current) -> String { + let date = Date(timeIntervalSince1970: TimeInterval(s)) + return timeString(from: date, format: format, timeZone: timeZone, locale: locale) + } + + /// 由毫秒级时间戳(字符串)转为时间字符串;非法输入返回空字符串 + public static func timeString(fromMillisecondsString msString: String, + format: TimeFormat = .hms, + timeZone: TimeZone = .current, + locale: Locale = .current) -> String { + guard let ms = Int64(msString) else { return "" } + return timeString(fromMilliseconds: ms, format: format, timeZone: timeZone, locale: locale) + } + + /// 将 Date 转为 YYYY-MM-DD 格式的日期字符串 + public static func dateString(from date: Date, calendar: Calendar = .current) -> String { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = TimeZone(secondsFromGMT: 0) + formatter.dateFormat = "yyyy-MM-dd" + return formatter.string(from: date) + } + + /// 计算从指定天数前到今天的时间范围(用于 API 日期参数) + /// 使用 UTC 时区确保日期一致性 + /// 返回从 n 天前到今天(包括今天)的日期范围 + public static func daysAgoToTodayRange(days: Int, from now: Date = Date(), calendar: Calendar = .current) -> (start: String, end: String) { + // 使用 UTC 时区来计算日期,确保与 dateString 方法一致 + var utcCalendar = Calendar(identifier: .gregorian) + utcCalendar.timeZone = TimeZone(secondsFromGMT: 0)! + + let startOfToday = utcCalendar.startOfDay(for: now) + // 从 (days-1) 天前开始,这样包括今天一共是 days 天 + let startOfNDaysAgo = utcCalendar.date(byAdding: .day, value: -(days - 1), to: startOfToday) ?? now + return (dateString(from: startOfNDaysAgo, calendar: utcCalendar), dateString(from: startOfToday, calendar: utcCalendar)) + } +} + + diff --git a/参考计费/Packages/VibeviewerCore/Sources/VibeviewerCore/Extensions/Int+E.swift b/参考计费/Packages/VibeviewerCore/Sources/VibeviewerCore/Extensions/Int+E.swift new file mode 100644 index 0000000..196a3cb --- /dev/null +++ b/参考计费/Packages/VibeviewerCore/Sources/VibeviewerCore/Extensions/Int+E.swift @@ -0,0 +1,7 @@ +import Foundation + +public extension Int { + var dollarStringFromCents: String { + "$" + String(format: "%.2f", Double(self) / 100.0) + } +} diff --git a/参考计费/Packages/VibeviewerCore/Sources/VibeviewerCore/Services/LaunchAtLoginService.swift b/参考计费/Packages/VibeviewerCore/Sources/VibeviewerCore/Services/LaunchAtLoginService.swift new file mode 100644 index 0000000..00078f0 --- /dev/null +++ b/参考计费/Packages/VibeviewerCore/Sources/VibeviewerCore/Services/LaunchAtLoginService.swift @@ -0,0 +1,36 @@ +import Foundation +import ServiceManagement + +public protocol LaunchAtLoginService { + var isEnabled: Bool { get } + func setEnabled(_ enabled: Bool) -> Bool +} + +public final class DefaultLaunchAtLoginService: LaunchAtLoginService { + public init() {} + + public var isEnabled: Bool { + SMAppService.mainApp.status == .enabled + } + + public func setEnabled(_ enabled: Bool) -> Bool { + do { + if enabled { + if SMAppService.mainApp.status == .enabled { + return true + } + try SMAppService.mainApp.register() + return true + } else { + if SMAppService.mainApp.status != .enabled { + return true + } + try SMAppService.mainApp.unregister() + return true + } + } catch { + print("Failed to \(enabled ? "enable" : "disable") launch at login: \(error)") + return false + } + } +} \ No newline at end of file diff --git a/参考计费/Packages/VibeviewerCore/Tests/VibeviewerCoreTests/PlaceholderTests.swift b/参考计费/Packages/VibeviewerCore/Tests/VibeviewerCoreTests/PlaceholderTests.swift new file mode 100644 index 0000000..0a2afd6 --- /dev/null +++ b/参考计费/Packages/VibeviewerCore/Tests/VibeviewerCoreTests/PlaceholderTests.swift @@ -0,0 +1,8 @@ +@testable import VibeviewerCore +import XCTest + +final class VibeviewerCoreTests: XCTestCase { + func testExample() { + XCTAssertTrue(true) + } +} diff --git a/参考计费/Packages/VibeviewerLoginUI/Package.swift b/参考计费/Packages/VibeviewerLoginUI/Package.swift new file mode 100644 index 0000000..21b20c0 --- /dev/null +++ b/参考计费/Packages/VibeviewerLoginUI/Package.swift @@ -0,0 +1,26 @@ +// swift-tools-version:5.10 +import PackageDescription + +let package = Package( + name: "VibeviewerLoginUI", + platforms: [ + .macOS(.v14) + ], + products: [ + .library(name: "VibeviewerLoginUI", targets: ["VibeviewerLoginUI"]), + ], + dependencies: [ + .package(path: "../VibeviewerShareUI") + ], + targets: [ + .target( + name: "VibeviewerLoginUI", + dependencies: [ + "VibeviewerShareUI" + ] + ), + .testTarget(name: "VibeviewerLoginUITests", dependencies: ["VibeviewerLoginUI"]) + ] +) + + diff --git a/参考计费/Packages/VibeviewerLoginUI/Sources/VibeviewerLoginUI/Components/CookieWebView.swift b/参考计费/Packages/VibeviewerLoginUI/Sources/VibeviewerLoginUI/Components/CookieWebView.swift new file mode 100644 index 0000000..f4d2bf0 --- /dev/null +++ b/参考计费/Packages/VibeviewerLoginUI/Sources/VibeviewerLoginUI/Components/CookieWebView.swift @@ -0,0 +1,52 @@ +import SwiftUI +import WebKit + +struct CookieWebView: NSViewRepresentable { + let onCookieCaptured: (String) -> Void + + func makeNSView(context: Context) -> WKWebView { + let config = WKWebViewConfiguration() + let webView = WKWebView(frame: .zero, configuration: config) + webView.navigationDelegate = context.coordinator + if let url = + URL( + string: "https://authenticator.cursor.sh/" + ) + { + webView.load(URLRequest(url: url)) + } + return webView + } + + func updateNSView(_ nsView: WKWebView, context: Context) {} + + func makeCoordinator() -> Coordinator { + Coordinator(onCookieCaptured: self.onCookieCaptured) + } + + final class Coordinator: NSObject, WKNavigationDelegate { + let onCookieCaptured: (String) -> Void + + init(onCookieCaptured: @escaping (String) -> Void) { + self.onCookieCaptured = onCookieCaptured + } + + func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { + if webView.url?.absoluteString.hasSuffix("/dashboard") == true { + self.captureCursorCookies(from: webView) + } + } + + private func captureCursorCookies(from webView: WKWebView) { + webView.configuration.websiteDataStore.httpCookieStore.getAllCookies { cookies in + let relevant = cookies.filter { cookie in + let domain = cookie.domain.lowercased() + return domain.contains("cursor.com") + } + guard !relevant.isEmpty else { return } + let headerString = relevant.map { "\($0.name)=\($0.value)" }.joined(separator: "; ") + self.onCookieCaptured(headerString) + } + } + } +} diff --git a/参考计费/Packages/VibeviewerLoginUI/Sources/VibeviewerLoginUI/Environment/EnvironmentKeys.swift b/参考计费/Packages/VibeviewerLoginUI/Sources/VibeviewerLoginUI/Environment/EnvironmentKeys.swift new file mode 100644 index 0000000..273ac21 --- /dev/null +++ b/参考计费/Packages/VibeviewerLoginUI/Sources/VibeviewerLoginUI/Environment/EnvironmentKeys.swift @@ -0,0 +1,12 @@ +import SwiftUI + +private struct LoginWindowManagerKey: EnvironmentKey { + static let defaultValue: LoginWindowManager = .shared +} + +public extension EnvironmentValues { + var loginWindowManager: LoginWindowManager { + get { self[LoginWindowManagerKey.self] } + set { self[LoginWindowManagerKey.self] = newValue } + } +} diff --git a/参考计费/Packages/VibeviewerLoginUI/Sources/VibeviewerLoginUI/Scenes/LoginWindow.swift b/参考计费/Packages/VibeviewerLoginUI/Sources/VibeviewerLoginUI/Scenes/LoginWindow.swift new file mode 100644 index 0000000..0ba7c4a --- /dev/null +++ b/参考计费/Packages/VibeviewerLoginUI/Sources/VibeviewerLoginUI/Scenes/LoginWindow.swift @@ -0,0 +1,38 @@ +import AppKit +import SwiftUI + +public final class LoginWindowManager { + public static let shared = LoginWindowManager() + private var controller: LoginWindowController? + + public func show(onCookieCaptured: @escaping (String) -> Void) { + if let controller { + controller.showWindow(nil) + controller.window?.makeKeyAndOrderFront(nil) + NSApp.activate(ignoringOtherApps: true) + return + } + let controller = LoginWindowController(onCookieCaptured: { [weak self] cookie in + onCookieCaptured(cookie) + self?.close() + }) + self.controller = controller + controller.window?.center() + controller.showWindow(nil) + controller.window?.makeKeyAndOrderFront(nil) + NSApp.activate(ignoringOtherApps: true) + if let hosting = controller.contentViewController as? NSHostingController { + hosting.rootView = CursorLoginView(onCookieCaptured: { cookie in + onCookieCaptured(cookie) + self.close() + }, onClose: { [weak self] in + self?.close() + }) + } + } + + public func close() { + self.controller?.close() + self.controller = nil + } +} diff --git a/参考计费/Packages/VibeviewerLoginUI/Sources/VibeviewerLoginUI/Scenes/LoginWindowController.swift b/参考计费/Packages/VibeviewerLoginUI/Sources/VibeviewerLoginUI/Scenes/LoginWindowController.swift new file mode 100644 index 0000000..a0210ee --- /dev/null +++ b/参考计费/Packages/VibeviewerLoginUI/Sources/VibeviewerLoginUI/Scenes/LoginWindowController.swift @@ -0,0 +1,20 @@ +import AppKit +import SwiftUI + +final class LoginWindowController: NSWindowController, NSWindowDelegate { + private var onCookieCaptured: ((String) -> Void)? + + convenience init(onCookieCaptured: @escaping (String) -> Void) { + let vc = NSHostingController(rootView: CursorLoginView(onCookieCaptured: { cookie in + onCookieCaptured(cookie) + }, onClose: {})) + let window = NSWindow(contentViewController: vc) + window.title = "Cursor 登录" + window.setContentSize(NSSize(width: 900, height: 680)) + window.styleMask = [.titled, .closable, .miniaturizable, .resizable] + window.isReleasedWhenClosed = false + self.init(window: window) + self.onCookieCaptured = onCookieCaptured + self.window?.delegate = self + } +} diff --git a/参考计费/Packages/VibeviewerLoginUI/Sources/VibeviewerLoginUI/Views/CursorLoginView.swift b/参考计费/Packages/VibeviewerLoginUI/Sources/VibeviewerLoginUI/Views/CursorLoginView.swift new file mode 100644 index 0000000..a283f67 --- /dev/null +++ b/参考计费/Packages/VibeviewerLoginUI/Sources/VibeviewerLoginUI/Views/CursorLoginView.swift @@ -0,0 +1,16 @@ +import SwiftUI + +@MainActor +struct CursorLoginView: View { + let onCookieCaptured: (String) -> Void + let onClose: () -> Void + + var body: some View { + VStack(spacing: 0) { + CookieWebView(onCookieCaptured: { cookie in + self.onCookieCaptured(cookie) + self.onClose() + }) + } + } +} \ No newline at end of file diff --git a/参考计费/Packages/VibeviewerLoginUI/Tests/VibeviewerLoginUITests/PlaceholderTests.swift b/参考计费/Packages/VibeviewerLoginUI/Tests/VibeviewerLoginUITests/PlaceholderTests.swift new file mode 100644 index 0000000..705d84f --- /dev/null +++ b/参考计费/Packages/VibeviewerLoginUI/Tests/VibeviewerLoginUITests/PlaceholderTests.swift @@ -0,0 +1,8 @@ +@testable import VibeviewerLoginUI +import XCTest + +final class VibeviewerLoginUITests: XCTestCase { + func testExample() { + XCTAssertTrue(true) + } +} diff --git a/参考计费/Packages/VibeviewerMenuUI/Package.resolved b/参考计费/Packages/VibeviewerMenuUI/Package.resolved new file mode 100644 index 0000000..6d9b004 --- /dev/null +++ b/参考计费/Packages/VibeviewerMenuUI/Package.resolved @@ -0,0 +1,42 @@ +{ + "originHash" : "9306278cf3775247b97d318b7dce25c7fee6729b83694f52dd8be9b737c35483", + "pins" : [ + { + "identity" : "alamofire", + "kind" : "remoteSourceControl", + "location" : "https://github.com/Alamofire/Alamofire.git", + "state" : { + "revision" : "513364f870f6bfc468f9d2ff0a95caccc10044c5", + "version" : "5.10.2" + } + }, + { + "identity" : "moya", + "kind" : "remoteSourceControl", + "location" : "https://github.com/Moya/Moya.git", + "state" : { + "revision" : "c263811c1f3dbf002be9bd83107f7cdc38992b26", + "version" : "15.0.3" + } + }, + { + "identity" : "reactiveswift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/ReactiveCocoa/ReactiveSwift.git", + "state" : { + "revision" : "c43bae3dac73fdd3cb906bd5a1914686ca71ed3c", + "version" : "6.7.0" + } + }, + { + "identity" : "rxswift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/ReactiveX/RxSwift.git", + "state" : { + "revision" : "5dd1907d64f0d36f158f61a466bab75067224893", + "version" : "6.9.0" + } + } + ], + "version" : 3 +} diff --git a/参考计费/Packages/VibeviewerMenuUI/Package.swift b/参考计费/Packages/VibeviewerMenuUI/Package.swift new file mode 100644 index 0000000..4a81f08 --- /dev/null +++ b/参考计费/Packages/VibeviewerMenuUI/Package.swift @@ -0,0 +1,36 @@ +// swift-tools-version:5.10 +import PackageDescription + +let package = Package( + name: "VibeviewerMenuUI", + platforms: [ + .macOS(.v14) + ], + products: [ + .library(name: "VibeviewerMenuUI", targets: ["VibeviewerMenuUI"]) + ], + dependencies: [ + .package(path: "../VibeviewerCore"), + .package(path: "../VibeviewerModel"), + .package(path: "../VibeviewerAppEnvironment"), + .package(path: "../VibeviewerAPI"), + .package(path: "../VibeviewerLoginUI"), + .package(path: "../VibeviewerSettingsUI"), + .package(path: "../VibeviewerShareUI"), + ], + targets: [ + .target( + name: "VibeviewerMenuUI", + dependencies: [ + "VibeviewerCore", + "VibeviewerModel", + "VibeviewerAppEnvironment", + "VibeviewerAPI", + "VibeviewerLoginUI", + "VibeviewerSettingsUI", + "VibeviewerShareUI" + ] + ), + .testTarget(name: "VibeviewerMenuUITests", dependencies: ["VibeviewerMenuUI"]), + ] +) diff --git a/参考计费/Packages/VibeviewerMenuUI/Sources/VibeviewerMenuUI/Components/ActionButtonsView.swift b/参考计费/Packages/VibeviewerMenuUI/Sources/VibeviewerMenuUI/Components/ActionButtonsView.swift new file mode 100644 index 0000000..73c18f1 --- /dev/null +++ b/参考计费/Packages/VibeviewerMenuUI/Sources/VibeviewerMenuUI/Components/ActionButtonsView.swift @@ -0,0 +1,29 @@ +import SwiftUI +import VibeviewerLoginUI + +@MainActor +struct ActionButtonsView: View { + let isLoading: Bool + let isLoggedIn: Bool + let onRefresh: () -> Void + let onLogin: () -> Void + let onLogout: () -> Void + let onSettings: () -> Void + + var body: some View { + HStack(spacing: 10) { + if self.isLoading { + ProgressView() + } else { + Button("刷新") { self.onRefresh() } + } + + if !self.isLoggedIn { + Button("登录") { self.onLogin() } + } else { + Button("退出登录") { self.onLogout() } + } + Button("设置") { self.onSettings() } + } + } +} diff --git a/参考计费/Packages/VibeviewerMenuUI/Sources/VibeviewerMenuUI/Components/DashboardErrorView.swift b/参考计费/Packages/VibeviewerMenuUI/Sources/VibeviewerMenuUI/Components/DashboardErrorView.swift new file mode 100644 index 0000000..0a1c445 --- /dev/null +++ b/参考计费/Packages/VibeviewerMenuUI/Sources/VibeviewerMenuUI/Components/DashboardErrorView.swift @@ -0,0 +1,45 @@ +import SwiftUI +import VibeviewerShareUI + +@MainActor +struct DashboardErrorView: View { + let message: String + let onRetry: (() -> Void)? + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 8) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(Color.red.opacity(0.9)) + Text("Failed to Refresh Data") + .font(.app(.satoshiBold, size: 12)) + } + + Text(message) + .font(.app(.satoshiMedium, size: 11)) + .foregroundStyle(.secondary) + + if let onRetry { + Button { + onRetry() + } label: { + Text("Retry") + } + .buttonStyle(.vibe(.primary)) + .controlSize(.small) + } + } + .padding(10) + .maxFrame(true, false, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 8) + .fill(Color.red.opacity(0.08)) + ) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(Color.red.opacity(0.25), lineWidth: 1) + ) + } +} + + diff --git a/参考计费/Packages/VibeviewerMenuUI/Sources/VibeviewerMenuUI/Components/ErrorBannerView.swift b/参考计费/Packages/VibeviewerMenuUI/Sources/VibeviewerMenuUI/Components/ErrorBannerView.swift new file mode 100644 index 0000000..2083cf4 --- /dev/null +++ b/参考计费/Packages/VibeviewerMenuUI/Sources/VibeviewerMenuUI/Components/ErrorBannerView.swift @@ -0,0 +1,14 @@ +import SwiftUI + +@MainActor +struct ErrorBannerView: View { + let message: String? + + var body: some View { + if let msg = message, !msg.isEmpty { + Text(msg) + .foregroundStyle(.red) + .font(.caption) + } + } +} diff --git a/参考计费/Packages/VibeviewerMenuUI/Sources/VibeviewerMenuUI/Components/MembershipBadge.swift b/参考计费/Packages/VibeviewerMenuUI/Sources/VibeviewerMenuUI/Components/MembershipBadge.swift new file mode 100644 index 0000000..c85ce1a --- /dev/null +++ b/参考计费/Packages/VibeviewerMenuUI/Sources/VibeviewerMenuUI/Components/MembershipBadge.swift @@ -0,0 +1,29 @@ +import SwiftUI +import VibeviewerModel +import VibeviewerShareUI + +/// 会员类型徽章组件 +struct MembershipBadge: View { + let membershipType: MembershipType + let isEnterpriseUser: Bool + + var body: some View { + Text(membershipType.displayName(isEnterprise: isEnterpriseUser)) + .font(.app(.satoshiMedium, size: 12)) + .foregroundStyle(.secondary) + } +} + +#Preview { + VStack(spacing: 12) { + MembershipBadge(membershipType: .free, isEnterpriseUser: false) + MembershipBadge(membershipType: .freeTrial, isEnterpriseUser: false) + MembershipBadge(membershipType: .pro, isEnterpriseUser: false) + MembershipBadge(membershipType: .proPlus, isEnterpriseUser: false) + MembershipBadge(membershipType: .ultra, isEnterpriseUser: false) + MembershipBadge(membershipType: .enterprise, isEnterpriseUser: false) + MembershipBadge(membershipType: .enterprise, isEnterpriseUser: true) + } + .padding() +} + diff --git a/参考计费/Packages/VibeviewerMenuUI/Sources/VibeviewerMenuUI/Components/MenuFooterView.swift b/参考计费/Packages/VibeviewerMenuUI/Sources/VibeviewerMenuUI/Components/MenuFooterView.swift new file mode 100644 index 0000000..772ca94 --- /dev/null +++ b/参考计费/Packages/VibeviewerMenuUI/Sources/VibeviewerMenuUI/Components/MenuFooterView.swift @@ -0,0 +1,62 @@ +import SwiftUI +import VibeviewerShareUI +import VibeviewerAppEnvironment +import VibeviewerModel +import VibeviewerSettingsUI + +struct MenuFooterView: View { + @Environment(\.dashboardRefreshService) private var refresher + @Environment(\.settingsWindowManager) private var settingsWindow + @Environment(AppSession.self) private var session + + let onRefresh: () -> Void + + var body: some View { + HStack(alignment: .center, spacing: 12) { + Button { + settingsWindow.show() + } label: { + Image(systemName: "gear") + .font(.system(size: 14)) + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + + // 显示会员类型徽章 + if let membershipType = session.snapshot?.usageSummary?.membershipType { + MembershipBadge( + membershipType: membershipType, + isEnterpriseUser: session.credentials?.isEnterpriseUser ?? false + ) + } + + Spacer() + + Button { + onRefresh() + } label: { + HStack(spacing: 4) { + if refresher.isRefreshing { + ProgressView() + .controlSize(.mini) + .progressViewStyle(.circular) + .tint(.white) + .frame(width: 16, height: 16) + } + Text("Refresh") + .font(.app(.satoshiMedium, size: 12)) + } + } + .buttonStyle(.vibe(Color(hex: "5B67E2").opacity(0.8))) + .animation(.easeInOut(duration: 0.2), value: refresher.isRefreshing) + + Button { + NSApplication.shared.terminate(nil) + } label: { + Text("Quit") + .font(.app(.satoshiMedium, size: 12)) + } + .buttonStyle(.vibe(Color(hex: "F58283").opacity(0.8))) + } + } +} diff --git a/参考计费/Packages/VibeviewerMenuUI/Sources/VibeviewerMenuUI/Components/MetricsView.swift b/参考计费/Packages/VibeviewerMenuUI/Sources/VibeviewerMenuUI/Components/MetricsView.swift new file mode 100644 index 0000000..a9df5c6 --- /dev/null +++ b/参考计费/Packages/VibeviewerMenuUI/Sources/VibeviewerMenuUI/Components/MetricsView.swift @@ -0,0 +1,250 @@ +import SwiftUI +import VibeviewerModel +import VibeviewerCore +import VibeviewerShareUI +import Foundation + +struct MetricsViewDataSource: Equatable { + var icon: String + var title: String + var description: String? + var currentValue: String + var targetValue: String? + var progress: Double + var tint: Color +} + +struct MetricsView: View { + enum MetricType { + case billing(MetricsViewDataSource) + case onDemand(MetricsViewDataSource) + case free(MetricsViewDataSource) + } + + var metric: MetricType + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + switch metric { + case .billing(let dataSource): + MetricContentView(dataSource: dataSource) + case .onDemand(let dataSource): + MetricContentView(dataSource: dataSource) + case .free(let dataSource): + MetricContentView(dataSource: dataSource) + } + } + } + + struct MetricContentView: View { + let dataSource: MetricsViewDataSource + + @State var isHovering: Bool = false + + @Environment(\.colorScheme) private var colorScheme + + var tintColor: Color { + if isHovering { + return dataSource.tint + } else { + return dataSource.tint.opacity(colorScheme == .dark ? 0.5 : 0.8) + } + } + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HStack(alignment: .center, spacing: 12) { + HStack(alignment: .center, spacing: 4) { + Image(systemName: dataSource.icon) + .font(.system(size: 16)) + .foregroundStyle(tintColor) + Text(dataSource.title) + .font(.app(.satoshiBold, size: 12)) + .foregroundStyle(tintColor) + } + + Spacer() + + HStack(alignment: .lastTextBaseline, spacing: 0) { + if let target = dataSource.targetValue, !target.isEmpty { + Text(target) + .font(.app(.satoshiRegular, size: 12)) + .foregroundStyle(.secondary) + + Text(" / ") + .font(.app(.satoshiRegular, size: 12)) + .foregroundStyle(.secondary) + + Text(dataSource.currentValue) + .font(.app(.satoshiBold, size: 16)) + .foregroundStyle(.primary) + .contentTransition(.numericText()) + } else { + Text(dataSource.currentValue) + .font(.app(.satoshiBold, size: 16)) + .foregroundStyle(.primary) + .contentTransition(.numericText()) + } + } + } + + progressBar(color: tintColor) + + if let description = dataSource.description { + Text(description) + .font(.app(.satoshiRegular, size: 10)) + .foregroundStyle(.secondary) + } + } + .animation(.easeInOut(duration: 0.2), value: isHovering) + .onHover { isHovering = $0 } + } + + @ViewBuilder + func progressBar(color: Color) -> some View { + ZStack(alignment: .leading) { + RoundedRectangle(cornerRadius: 100) + .fill(Color(hex: "686868").opacity(0.5)) + .frame(height: 4) + + GeometryReader { proxy in + RoundedRectangle(cornerRadius: 100) + .fill(color) + .frame(width: proxy.size.width * dataSource.progress, height: 4) + } + .frame(height: 4) + } + } + } +} + +extension DashboardSnapshot { + // MARK: - Subscription Expiry Configuration + + /// Configuration for subscription expiry date calculation + /// Modify this enum to change expiry date behavior with minimal code changes + private enum SubscriptionExpiryRule { + case endOfCurrentMonth + case specificDaysFromNow(Int) + case endOfNextMonth + // Add more cases as needed + } + + /// Current expiry rule - change this to modify expiry date calculation + private var currentExpiryRule: SubscriptionExpiryRule { + .endOfCurrentMonth // Can be easily changed to any other rule + } + + // MARK: - Helper Properties for Expiry Date Calculation + + /// Current subscription expiry date based on configured rule + private var subscriptionExpiryDate: Date { + let calendar = Calendar.current + let now = Date() + + switch currentExpiryRule { + case .endOfCurrentMonth: + let endOfMonth = calendar.dateInterval(of: .month, for: now)?.end ?? now + return calendar.date(byAdding: .day, value: -1, to: endOfMonth) ?? now + + case .specificDaysFromNow(let days): + return calendar.date(byAdding: .day, value: days, to: now) ?? now + + case .endOfNextMonth: + let nextMonth = calendar.date(byAdding: .month, value: 1, to: now) ?? now + let endOfNextMonth = calendar.dateInterval(of: .month, for: nextMonth)?.end ?? now + return calendar.date(byAdding: .day, value: -1, to: endOfNextMonth) ?? now + } + } + + /// Formatted expiry date string in yy:mm:dd format + private var expiryDateString: String { + let formatter = DateFormatter() + formatter.dateFormat = "yy:MM:dd" + return formatter.string(from: subscriptionExpiryDate) + } + + /// Remaining days until subscription expiry + private var remainingDays: Int { + let calendar = Calendar.current + let days = calendar.dateComponents([.day], from: Date(), to: subscriptionExpiryDate).day ?? 0 + return max(days, 1) // At least 1 day to avoid division by zero + } + + /// Remaining balance in cents + private var remainingBalanceCents: Int { + return max((hardLimitDollars * 100) - spendingCents, 0) + } + + /// Average daily spending allowance from remaining balance + private var averageDailyAllowance: String { + let dailyAllowanceCents = remainingBalanceCents / remainingDays + return dailyAllowanceCents.dollarStringFromCents + } + + var billingMetrics: MetricsViewDataSource { + // 如果有新的usageSummary数据,优先使用 + if let usageSummary = usageSummary { + let description = "Expires \(expiryDateString)" + + // UsageSummary 的 used/limit 已经是美分,直接转换为美元显示 + return MetricsViewDataSource( + icon: "dollarsign.circle.fill", + title: "Plan Usage", + description: description, + currentValue: usageSummary.individualUsage.plan.used.dollarStringFromCents, + targetValue: usageSummary.individualUsage.plan.limit.dollarStringFromCents, + progress: min(Double(usageSummary.individualUsage.plan.used) / Double(usageSummary.individualUsage.plan.limit), 1), + tint: Color(hex: "55E07A") + ) + } else { + // 回退到旧的数据源 + let description = "Expires \(expiryDateString), \(averageDailyAllowance)/day remaining" + + return MetricsViewDataSource( + icon: "dollarsign.circle.fill", + title: "Usage Spending", + description: description, + currentValue: spendingCents.dollarStringFromCents, + targetValue: (hardLimitDollars * 100).dollarStringFromCents, + progress: min(Double(spendingCents) / Double(hardLimitDollars * 100), 1), + tint: Color(hex: "55E07A") + ) + } + } + + var onDemandMetrics: MetricsViewDataSource? { + guard let usageSummary = usageSummary, + let onDemand = usageSummary.individualUsage.onDemand, + let limit = onDemand.limit else { + return nil + } + + let description = "Expires \(expiryDateString)" + + // UsageSummary 的 used/limit 已经是美分,直接转换为美元显示 + return MetricsViewDataSource( + icon: "bolt.circle.fill", + title: "On-Demand Usage", + description: description, + currentValue: onDemand.used.dollarStringFromCents, + targetValue: limit.dollarStringFromCents, + progress: min(Double(onDemand.used) / Double(limit), 1), + tint: Color(hex: "FF6B6B") + ) + } + + var freeUsageMetrics: MetricsViewDataSource? { + guard freeUsageCents > 0 else { return nil } + let description = "Free credits (team plan)" + return MetricsViewDataSource( + icon: "gift.circle.fill", + title: "Free Usage", + description: description, + currentValue: freeUsageCents.dollarStringFromCents, + targetValue: nil, + progress: 1.0, + tint: Color(hex: "4DA3FF") + ) + } +} \ No newline at end of file diff --git a/参考计费/Packages/VibeviewerMenuUI/Sources/VibeviewerMenuUI/Components/ModelsUsageBarChartView.swift b/参考计费/Packages/VibeviewerMenuUI/Sources/VibeviewerMenuUI/Components/ModelsUsageBarChartView.swift new file mode 100644 index 0000000..b4783ed --- /dev/null +++ b/参考计费/Packages/VibeviewerMenuUI/Sources/VibeviewerMenuUI/Components/ModelsUsageBarChartView.swift @@ -0,0 +1,332 @@ +import SwiftUI +import VibeviewerModel +import VibeviewerCore +import Charts + +struct ModelsUsageBarChartView: View { + let data: ModelsUsageChartData + + @State private var selectedDate: String? + + // 基于“模型前缀 → 基础色”的分组映射,整体采用墨绿色系的相近色 + // 这里的颜色是几种不同明度/偏色的墨绿色,方便同一前缀下做细微区分 + private let mossGreenPalette: [Color] = [ + Color(red: 0/255, green: 92/255, blue: 66/255), // 深墨绿 + Color(red: 24/255, green: 120/255, blue: 88/255), // 偏亮墨绿 + Color(red: 16/255, green: 104/255, blue: 80/255), // 略偏蓝的墨绿 + Color(red: 40/255, green: 132/255, blue: 96/255), // 柔和一点的墨绿 + Color(red: 6/255, green: 76/255, blue: 60/255) // 更深一点的墨绿 + ] + + /// 不同模型前缀对应的基础 palette 偏移量(同一前缀颜色更接近) + private let modelPrefixOffsets: [String: Int] = [ + "gpt-": 0, + "claude-": 1, + "composer-": 2, + "grok-": 3, + "Other": 4 + ] + + /// 实际用于展示的数据点(最多 7 天,优先展示最近的数据) + private var displayedDataPoints: [ModelsUsageChartData.DataPoint] { + guard data.dataPoints.count > 7 else { + return data.dataPoints + } + return Array(data.dataPoints.suffix(7)) + } + + var body: some View { + if displayedDataPoints.isEmpty { + emptyView + } else { + VStack(alignment: .leading, spacing: 12) { + chartView + legendView + summaryView + } + } + } + + private var emptyView: some View { + Text("暂无数据") + .font(.app(.satoshiRegular, size: 12)) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .center) + .padding(.vertical, 40) + } + + private var chartView: some View { + Chart { + ForEach(displayedDataPoints, id: \.date) { item in + let stackedData = calculateStackedData(for: item) + + ForEach(Array(stackedData.enumerated()), id: \.offset) { index, stackedItem in + BarMark( + x: .value("Date", item.dateLabel), + yStart: .value("Start", stackedItem.start), + yEnd: .value("End", stackedItem.end) + ) + .foregroundStyle(barColor(for: stackedItem.modelName, dateLabel: item.dateLabel)) + .cornerRadius(4) + .opacity(shouldDimBar(for: item.dateLabel) ? 0.4 : 1.0) + } + } + + if let selectedDate = selectedDate, + let selectedItem = displayedDataPoints.first(where: { $0.dateLabel == selectedDate }) { + RuleMark(x: .value("Selected", selectedDate)) + .lineStyle(StrokeStyle(lineWidth: 2, dash: [4])) + .foregroundStyle(Color.gray.opacity(0.3)) + .annotation( + position: annotationPosition(for: selectedDate), + alignment: .center, + spacing: 8, + overflowResolution: AnnotationOverflowResolution(x: .disabled, y: .disabled) + ) { + annotationView(for: selectedItem) + } + } + } + // 确保 X 轴始终展示所有日期标签(即使某些日期没有数据) + .chartXScale(domain: displayedDataPoints.map { $0.dateLabel }) + .chartXSelection(value: $selectedDate) + .chartYAxis { + AxisMarks(position: .leading) { value in + AxisValueLabel { + if let intValue = value.as(Int.self) { + Text("\(intValue)") + .font(.app(.satoshiRegular, size: 10)) + .foregroundStyle(.secondary) + } + } + AxisGridLine(stroke: StrokeStyle(lineWidth: 0.5)) + .foregroundStyle(.secondary.opacity(0.2)) + } + } + .chartXAxis { + AxisMarks { value in + AxisValueLabel { + if let stringValue = value.as(String.self) { + Text(stringValue) + .font(.app(.satoshiRegular, size: 9)) + .foregroundStyle(.secondary) + } + } + } + } + .frame(height: 180) + .animation(.easeInOut(duration: 0.2), value: selectedDate) + } + + private func barColor(for modelName: String, dateLabel: String) -> AnyShapeStyle { + let color = colorForModel(modelName) + if selectedDate == dateLabel { + return AnyShapeStyle(color.opacity(0.9)) + } else { + return AnyShapeStyle(color.gradient) + } + } + + private func colorForModel(_ modelName: String) -> Color { + // 1. 根据模型名前缀找到对应的基础偏移量 + let prefixOffset: Int = { + for (prefix, offset) in modelPrefixOffsets { + if modelName.hasPrefix(prefix) { + return offset + } + } + // 没有匹配到已知前缀时,统一归为 "Other" 分组 + return modelPrefixOffsets["Other"] ?? 0 + }() + + // 2. 使用模型名的哈希生成一个稳定的索引,叠加前缀偏移,让同一前缀的颜色彼此相近 + let hash = abs(modelName.hashValue) + let index = (prefixOffset + hash) % mossGreenPalette.count + + return mossGreenPalette[index] + } + + private func shouldDimBar(for dateLabel: String) -> Bool { + guard selectedDate != nil else { return false } + return selectedDate != dateLabel + } + + /// 根据选中项的位置动态计算 annotation 位置 + /// 左侧使用 topTrailing,右侧使用 topLeading,中间使用 top + private func annotationPosition(for dateLabel: String) -> AnnotationPosition { + guard let selectedIndex = displayedDataPoints.firstIndex(where: { $0.dateLabel == dateLabel }) else { + return .top + } + + let totalCount = displayedDataPoints.count + let middleIndex = totalCount / 2 + + if selectedIndex < middleIndex { + // 左侧:使用 topTrailing,annotation 显示在右侧 + return .topTrailing + } else if selectedIndex > middleIndex { + // 右侧:使用 topLeading,annotation 显示在左侧 + return .topLeading + } else { + // 中间:使用 top + return .top + } + } + + /// 计算堆叠数据:为每个模型计算起始和结束位置 + private func calculateStackedData(for item: ModelsUsageChartData.DataPoint) -> [(modelName: String, start: Int, end: Int)] { + var cumulativeY: Int = 0 + var result: [(modelName: String, start: Int, end: Int)] = [] + + for modelUsage in item.modelUsages { + if modelUsage.requests > 0 { + result.append(( + modelName: modelUsage.modelName, + start: cumulativeY, + end: cumulativeY + modelUsage.requests + )) + cumulativeY += modelUsage.requests + } + } + + return result + } + + private var legendView: some View { + // 获取所有唯一的模型名称 + let uniqueModels = Set(displayedDataPoints.flatMap { $0.modelUsages.map { $0.modelName } }) + .sorted() + + // 限制显示的模型数量(最多显示前8个) + let displayedModels = Array(uniqueModels.prefix(8)) + + return ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 16) { + ForEach(displayedModels, id: \.self) { modelName in + HStack(spacing: 6) { + RoundedRectangle(cornerRadius: 2) + .fill(colorForModel(modelName).gradient) + .frame(width: 12, height: 12) + Text(modelName) + .font(.app(.satoshiRegular, size: 10)) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + + if uniqueModels.count > 8 { + Text("+\(uniqueModels.count - 8) more") + .font(.app(.satoshiRegular, size: 10)) + .foregroundStyle(.secondary) + } + } + } + } + + private func annotationView(for item: ModelsUsageChartData.DataPoint) -> some View { + VStack(alignment: .leading, spacing: 6) { + Text(item.dateLabel) + .font(.app(.satoshiMedium, size: 11)) + .foregroundStyle(.secondary) + + VStack(alignment: .leading, spacing: 3) { + ForEach(item.modelUsages.prefix(5), id: \.modelName) { modelUsage in + if modelUsage.requests > 0 { + HStack(spacing: 6) { + Circle() + .fill(colorForModel(modelUsage.modelName)) + .frame(width: 6, height: 6) + Text("\(modelUsage.modelName): \(modelUsage.requests)") + .font(.app(.satoshiRegular, size: 11)) + .foregroundStyle(.primary) + } + } + } + + if item.modelUsages.count > 5 { + Text("... and \(item.modelUsages.count - 5) more") + .font(.app(.satoshiRegular, size: 10)) + .foregroundStyle(.secondary) + .padding(.leading, 12) + } + + if item.modelUsages.count > 1 { + Divider() + .padding(.vertical, 2) + + Text("Total: \(item.totalValue)") + .font(.app(.satoshiBold, size: 13)) + .foregroundStyle(.primary) + } else if let firstModel = item.modelUsages.first { + Text("\(firstModel.requests) requests") + .font(.app(.satoshiBold, size: 13)) + .foregroundStyle(.primary) + .padding(.top, 2) + } + } + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .fixedSize(horizontal: true, vertical: false) + .background { + RoundedRectangle(cornerRadius: 8) + .fill(.background) + .shadow(color: .black.opacity(0.1), radius: 4, x: 0, y: 2) + } + } + + private var summaryView: some View { + HStack(spacing: 16) { + if let total = totalValue { + VStack(alignment: .leading, spacing: 2) { + Text("Total") + .font(.app(.satoshiRegular, size: 10)) + .foregroundStyle(.secondary) + Text("\(total)") + .font(.app(.satoshiBold, size: 14)) + .foregroundStyle(.primary) + } + } + + if let avg = averageValue { + VStack(alignment: .leading, spacing: 2) { + Text("Average") + .font(.app(.satoshiRegular, size: 10)) + .foregroundStyle(.secondary) + Text(String(format: "%.1f", avg)) + .font(.app(.satoshiBold, size: 14)) + .foregroundStyle(.primary) + } + } + + if let max = maxValue { + VStack(alignment: .leading, spacing: 2) { + Text("Peak") + .font(.app(.satoshiRegular, size: 10)) + .foregroundStyle(.secondary) + Text("\(max)") + .font(.app(.satoshiBold, size: 14)) + .foregroundStyle(.primary) + } + } + + Spacer() + } + .padding(.top, 8) + } + + private var totalValue: Int? { + guard !displayedDataPoints.isEmpty else { return nil } + return displayedDataPoints.reduce(0) { $0 + $1.totalValue } + } + + private var averageValue: Double? { + guard let total = totalValue, !displayedDataPoints.isEmpty else { return nil } + return Double(total) / Double(displayedDataPoints.count) + } + + private var maxValue: Int? { + displayedDataPoints.map { $0.totalValue }.max() + } +} + diff --git a/参考计费/Packages/VibeviewerMenuUI/Sources/VibeviewerMenuUI/Components/TotalCreditsUsageView.swift b/参考计费/Packages/VibeviewerMenuUI/Sources/VibeviewerMenuUI/Components/TotalCreditsUsageView.swift new file mode 100644 index 0000000..f6e8894 --- /dev/null +++ b/参考计费/Packages/VibeviewerMenuUI/Sources/VibeviewerMenuUI/Components/TotalCreditsUsageView.swift @@ -0,0 +1,115 @@ +import SwiftUI +import VibeviewerModel +import VibeviewerCore +import VibeviewerShareUI + +struct TotalCreditsUsageView: View { + let snapshot: DashboardSnapshot? + + @State private var isModelsUsageExpanded: Bool = false + + var body: some View { + VStack(alignment: .trailing, spacing: 6) { + if let billingCycleText { + Text(billingCycleText) + .font(.app(.satoshiRegular, size: 10)) + .foregroundStyle(.secondary) + } + + headerView + + if isModelsUsageExpanded, let modelsUsageSummary = snapshot?.modelsUsageSummary { + modelsUsageDetailView(modelsUsageSummary) + } + + Text(snapshot?.displayTotalUsageCents.dollarStringFromCents ?? "0") + .font(.app(.satoshiBold, size: 16)) + .foregroundStyle(.primary) + .contentTransition(.numericText()) + } + .maxFrame(true, false, alignment: .trailing) + } + + private var headerView: some View { + HStack(alignment: .center, spacing: 4) { + Text("Total Credits Usage") + .font(.app(.satoshiRegular, size: 12)) + .foregroundStyle(.secondary) + + // 如果有模型用量数据,显示展开/折叠箭头 + if snapshot?.modelsUsageSummary != nil { + Image(systemName: "chevron.down") + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(.secondary) + .rotationEffect(.degrees(isModelsUsageExpanded ? 180 : 0)) + } + } + .onTapGesture { + if snapshot?.modelsUsageSummary != nil { + isModelsUsageExpanded.toggle() + } + } + .maxFrame(true, false, alignment: .trailing) + } + + private func modelsUsageDetailView(_ summary: ModelsUsageSummary) -> some View { + VStack(alignment: .leading, spacing: 12) { + ForEach(summary.modelsSortedByCost.prefix(5), id: \.modelName) { model in + UsageEventView.EventItemView(event: makeAggregateEvent(from: model)) + } + } + + } + + /// 将模型聚合数据映射为一个“虚构”的 UsageEvent,供 UsageEventView.EventItemView 复用 UI + private func makeAggregateEvent(from model: ModelUsageInfo) -> UsageEvent { + let tokenUsage = TokenUsage( + outputTokens: model.outputTokens, + inputTokens: model.inputTokens, + totalCents: model.costCents, + cacheWriteTokens: model.cacheWriteTokens, + cacheReadTokens: model.cacheReadTokens + ) + + // occurredAtMs 使用 "0" 即可,这里不会参与分组和排序,仅用于展示 + return UsageEvent( + occurredAtMs: "0", + modelName: model.modelName, + kind: "aggregate", + requestCostCount: 0, + usageCostDisplay: model.formattedCost, + usageCostCents: Int(model.costCents.rounded()), + isTokenBased: true, + userDisplayName: "", + cursorTokenFee: 0, + tokenUsage: tokenUsage + ) + } + + /// 当前计费周期展示文案(如 "Billing cycle: Oct 1 – Oct 31") + private var billingCycleText: String? { + guard + let startMs = snapshot?.billingCycleStartMs, + let endMs = snapshot?.billingCycleEndMs, + let startDate = Date.fromMillisecondsString(startMs), + let endDate = Date.fromMillisecondsString(endMs) + else { + return nil + } + + let formatter = DateFormatter() + formatter.dateFormat = "MMM d" + let start = formatter.string(from: startDate) + let end = formatter.string(from: endDate) + + return "\(start) – \(end)" + } + + private func formatNumber(_ number: Int) -> String { + let formatter = NumberFormatter() + formatter.numberStyle = .decimal + formatter.groupingSeparator = "," + return formatter.string(from: NSNumber(value: number)) ?? "\(number)" + } +} + diff --git a/参考计费/Packages/VibeviewerMenuUI/Sources/VibeviewerMenuUI/Components/UnloginView.swift b/参考计费/Packages/VibeviewerMenuUI/Sources/VibeviewerMenuUI/Components/UnloginView.swift new file mode 100644 index 0000000..1a39140 --- /dev/null +++ b/参考计费/Packages/VibeviewerMenuUI/Sources/VibeviewerMenuUI/Components/UnloginView.swift @@ -0,0 +1,149 @@ +import SwiftUI +import VibeviewerShareUI + +@MainActor +struct UnloginView: View { + enum LoginMethod: String, CaseIterable, Identifiable { + case web + case cookie + + var id: String { rawValue } + + var title: String { + switch self { + case .web: + return "Web Login" + case .cookie: + return "Cookie Login" + } + } + + var description: String { + switch self { + case .web: + return "Open Cursor login page and automatically capture your cookies after login." + case .cookie: + return "Paste your Cursor cookie header (from browser Developer Tools) to log in directly." + } + } + } + + let onWebLogin: () -> Void + let onCookieLogin: (String) -> Void + + @State private var selectedLoginMethod: LoginMethod = .web + @State private var manualCookie: String = "" + @State private var manualCookieError: String? + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + Text("Login to Cursor") + .font(.app(.satoshiBold, size: 16)) + + Text("Choose a login method that works best for you.") + .font(.app(.satoshiMedium, size: 11)) + .foregroundStyle(.secondary) + + Picker("Login Method", selection: $selectedLoginMethod) { + ForEach(LoginMethod.allCases) { method in + Text(method.title).tag(method) + } + } + .pickerStyle(.segmented) + .labelsHidden() + + Text(selectedLoginMethod.description) + .font(.app(.satoshiMedium, size: 11)) + .foregroundStyle(.secondary) + + Group { + switch selectedLoginMethod { + case .web: + Button { + onWebLogin() + } label: { + Text("Login via Web") + } + .buttonStyle(.vibe(.primary)) + + case .cookie: + manualCookieLoginView + } + } + } + .maxFrame(true, false, alignment: .leading) + } + + @ViewBuilder + private var manualCookieLoginView: some View { + VStack(alignment: .leading, spacing: 8) { + Text("Cursor Cookie Header") + .font(.app(.satoshiMedium, size: 12)) + + TextEditor(text: $manualCookie) + .font(.system(.body, design: .monospaced)) + .frame(minHeight: 80, maxHeight: 120) + .overlay( + RoundedRectangle(cornerRadius: 6) + .stroke(Color.secondary.opacity(0.3), lineWidth: 1) + ) + .overlay { + if manualCookie.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + Text("Example:\nCookie: cursor_session=...; other_key=...") + .foregroundStyle(Color.secondary.opacity(0.7)) + .font(.app(.satoshiMedium, size: 10)) + .padding(6) + .frame( + maxWidth: .infinity, + maxHeight: .infinity, + alignment: .topLeading + ) + } + } + + if let error = manualCookieError { + Text(error) + .font(.app(.satoshiMedium, size: 10)) + .foregroundStyle(.red) + } + + HStack { + Spacer() + Button("Login with Cookie") { + submitManualCookie() + } + .buttonStyle(.vibe(.primary)) + .disabled(normalizedCookieHeader(from: manualCookie).isEmpty) + } + } + } + + private func submitManualCookie() { + let normalized = normalizedCookieHeader(from: manualCookie) + guard !normalized.isEmpty else { + manualCookieError = "Cookie header cannot be empty." + return + } + manualCookieError = nil + onCookieLogin(normalized) + } + + /// 归一化用户输入的 Cookie 字符串: + /// - 去除首尾空白 + /// - 支持用户直接粘贴包含 `Cookie:` 或 `cookie:` 前缀的完整请求头 + private func normalizedCookieHeader(from input: String) -> String { + var value = input.trimmingCharacters(in: .whitespacesAndNewlines) + guard !value.isEmpty else { return "" } + + let lowercased = value.lowercased() + if lowercased.hasPrefix("cookie:") { + if let range = value.range(of: ":", options: .caseInsensitive) { + let afterColon = value[range.upperBound...] + value = String(afterColon).trimmingCharacters(in: .whitespacesAndNewlines) + } + } + return value + } +} + + diff --git a/参考计费/Packages/VibeviewerMenuUI/Sources/VibeviewerMenuUI/Components/UsageEnventView.swift b/参考计费/Packages/VibeviewerMenuUI/Sources/VibeviewerMenuUI/Components/UsageEnventView.swift new file mode 100644 index 0000000..e35af3b --- /dev/null +++ b/参考计费/Packages/VibeviewerMenuUI/Sources/VibeviewerMenuUI/Components/UsageEnventView.swift @@ -0,0 +1,211 @@ +import SwiftUI +import VibeviewerModel +import VibeviewerShareUI +import VibeviewerCore + +struct UsageEventView: View { + var events: [UsageEvent] + @Environment(AppSettings.self) private var appSettings + + var body: some View { + UsageEventViewBody(events: events, limit: appSettings.usageHistory.limit) + } + + struct EventItemView: View { + let event: UsageEvent + @State private var isExpanded = false + + // MARK: - Body + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + mainRowView + + if isExpanded { + expandedDetailsView + } + } + .animation(.easeInOut(duration: 0.2), value: isExpanded) + } + // MARK: - Computed Properties + + private var totalTokensDisplay: String { + let totalTokens = event.tokenUsage?.totalTokens ?? 0 + let value = Double(totalTokens) + + switch totalTokens { + case 0..<1_000: + return "\(totalTokens)" + case 1_000..<1_000_000: + return String(format: "%.1fK", value / 1_000.0) + case 1_000_000..<1_000_000_000: + return String(format: "%.2fM", value / 1_000_000.0) + default: + return String(format: "%.2fB", value / 1_000_000_000.0) + } + } + + private var costDisplay: String { + let totalCents = (event.tokenUsage?.totalCents ?? 0.0) + event.cursorTokenFee + let dollars = totalCents / 100.0 + return String(format: "$%.2f", dollars) + } + + private var tokenDetails: [(label: String, value: Int)] { + let rawDetails: [(String, Int)] = [ + ("Input", event.tokenUsage?.inputTokens ?? 0), + ("Output", event.tokenUsage?.outputTokens ?? 0), + ("Cache Write", event.tokenUsage?.cacheWriteTokens ?? 0), + ("Cache Read", event.tokenUsage?.cacheReadTokens ?? 0), + ("Total Tokens", event.tokenUsage?.totalTokens ?? 0), + ] + return rawDetails + } + + // MARK: - Subviews + + private var brandLogoView: some View { + event.brand.logo + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 18, height: 18) + .padding(6) + .background(.thinMaterial, in: .circle) + } + + private var modelNameView: some View { + Text(event.modelName) + .font(.app(.satoshiBold, size: 14)) + .lineLimit(1) + // .foregroundStyle(event.kind.isError ? AnyShapeStyle(Color.red.secondary) : AnyShapeStyle(.primary)) + } + + private var tokenCostView: some View { + HStack(spacing: 12) { + Text(totalTokensDisplay) + .font(.app(.satoshiMedium, size: 12)) + .foregroundStyle(.secondary) + .monospacedDigit() + + Text(costDisplay) + .font(.app(.satoshiMedium, size: 12)) + .foregroundStyle(.secondary) + .monospacedDigit() + } + .layoutPriority(1) + } + + private var mainRowView: some View { + HStack(spacing: 12) { + brandLogoView + modelNameView + Spacer() + tokenCostView + } + .contentShape(Rectangle()) + .onTapGesture { + isExpanded.toggle() + } + } + + private func tokenDetailRowView(for detail: (String, Int)) -> some View { + HStack { + Text(detail.0) + .font(.app(.satoshiRegular, size: 12)) + .foregroundStyle(.secondary) + .frame(width: 70, alignment: .leading) + + Spacer() + + Text("\(detail.1)") + .font(.app(.satoshiMedium, size: 12)) + .foregroundStyle(.primary) + .monospacedDigit() + + } + .padding(.horizontal, 12) + } + + private var expandedDetailsView: some View { + VStack(alignment: .leading, spacing: 4) { + ForEach(tokenDetails, id: \.0) { detail in + tokenDetailRowView(for: detail) + } + } + .padding(.vertical, 4) + .transition(.opacity) + .fixedSize(horizontal: false, vertical: true) + } + + } +} + +struct UsageEventViewBody: View { + let events: [UsageEvent] + let limit: Int + + private var groups: [UsageEventHourGroup] { + Array(events.prefix(limit)).groupedByHour() + } + + var body: some View { + UsageEventGroupsView(groups: groups) + } +} + +struct UsageEventGroupsView: View { + let groups: [UsageEventHourGroup] + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + ForEach(groups) { group in + HourGroupSectionView(group: group) + } + } + } +} + +struct HourGroupSectionView: View { + let group: UsageEventHourGroup + + var body: some View { + let totalRequestsText: String = String(group.totalRequests) + let totalCostText: String = { + let totalCents = group.events.reduce(0.0) { sum, event in + sum + (event.tokenUsage?.totalCents ?? 0.0) + event.cursorTokenFee + } + let dollars = totalCents / 100.0 + return String(format: "$%.2f", dollars) + }() + return VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 8) { + Text(group.title) + .font(.app(.satoshiBold, size: 12)) + .foregroundStyle(.secondary) + Spacer() + HStack(spacing: 6) { + HStack(alignment: .center, spacing: 2) { + Image(systemName: "arrow.triangle.2.circlepath") + .font(.app(.satoshiMedium, size: 10)) + .foregroundStyle(.primary) + Text(totalRequestsText) + .font(.app(.satoshiMedium, size: 12)) + .foregroundStyle(.secondary) + } + HStack(alignment: .center, spacing: 2) { + Image(systemName: "dollarsign.circle") + .font(.app(.satoshiMedium, size: 10)) + .foregroundStyle(.primary) + Text(totalCostText) + .font(.app(.satoshiMedium, size: 12)) + .foregroundStyle(.secondary) + } + } + } + + ForEach(group.events, id: \.occurredAtMs) { event in + UsageEventView.EventItemView(event: event) + } + } + } +} diff --git a/参考计费/Packages/VibeviewerMenuUI/Sources/VibeviewerMenuUI/Components/UsageHeaderView.swift b/参考计费/Packages/VibeviewerMenuUI/Sources/VibeviewerMenuUI/Components/UsageHeaderView.swift new file mode 100644 index 0000000..f0d267d --- /dev/null +++ b/参考计费/Packages/VibeviewerMenuUI/Sources/VibeviewerMenuUI/Components/UsageHeaderView.swift @@ -0,0 +1,26 @@ +import SwiftUI +import Observation +import VibeviewerModel +import VibeviewerShareUI + +struct UsageHeaderView: View { + enum Action { + case dashboard + } + + var action: (Action) -> Void + + var body: some View { + HStack { + Text("VibeViewer") + .font(.app(.satoshiMedium, size: 16)) + .foregroundStyle(.primary) + Spacer() + + Button("Dashboard") { + action(.dashboard) + } + .buttonStyle(.vibe(.secondary)) + } + } +} \ No newline at end of file diff --git a/参考计费/Packages/VibeviewerMenuUI/Sources/VibeviewerMenuUI/Components/UsageHistorySection.swift b/参考计费/Packages/VibeviewerMenuUI/Sources/VibeviewerMenuUI/Components/UsageHistorySection.swift new file mode 100644 index 0000000..da0c22d --- /dev/null +++ b/参考计费/Packages/VibeviewerMenuUI/Sources/VibeviewerMenuUI/Components/UsageHistorySection.swift @@ -0,0 +1,64 @@ +import SwiftUI +import VibeviewerModel + +@MainActor +struct UsageHistorySection: View { + let isLoading: Bool + @Bindable var settings: AppSettings + let events: [UsageEvent] + let onReload: () -> Void + let onToday: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Divider() + HStack { + Spacer() + Stepper("条数: \(self.settings.usageHistory.limit)", value: self.$settings.usageHistory.limit, in: 1 ... 100) + .frame(minWidth: 120) + } + .font(.callout) + + HStack(spacing: 10) { + if self.isLoading { + ProgressView() + } else { + Button("加载用量历史") { self.onReload() } + } + Button("今天") { self.onToday() } + } + + if !self.events.isEmpty { + VStack(alignment: .leading, spacing: 6) { + ForEach(Array(self.events.prefix(self.settings.usageHistory.limit).enumerated()), id: \.offset) { _, e in + HStack(alignment: .top, spacing: 8) { + Text(self.formatTimestamp(e.occurredAtMs)) + .font(.caption) + .foregroundStyle(.secondary) + .frame(width: 120, alignment: .leading) + Text(e.modelName) + .font(.callout) + .frame(minWidth: 90, alignment: .leading) + Spacer(minLength: 6) + Text("req: \(e.requestCostCount)") + .font(.caption) + Text(e.usageCostDisplay) + .font(.caption) + } + } + } + .padding(.top, 4) + } else { + Text("暂无用量历史").font(.caption).foregroundStyle(.secondary) + } + } + } + + private func formatTimestamp(_ msString: String) -> String { + guard let ms = Double(msString) else { return msString } + let date = Date(timeIntervalSince1970: ms / 1000.0) + let formatter = DateFormatter() + formatter.dateFormat = "HH:mm:ss" + return formatter.string(from: date) + } +} diff --git a/参考计费/Packages/VibeviewerMenuUI/Sources/VibeviewerMenuUI/Views/DashboardSummaryView.swift b/参考计费/Packages/VibeviewerMenuUI/Sources/VibeviewerMenuUI/Views/DashboardSummaryView.swift new file mode 100644 index 0000000..4cab292 --- /dev/null +++ b/参考计费/Packages/VibeviewerMenuUI/Sources/VibeviewerMenuUI/Views/DashboardSummaryView.swift @@ -0,0 +1,31 @@ +import SwiftUI +import VibeviewerCore +import VibeviewerModel + +@MainActor +struct DashboardSummaryView: View { + let snapshot: DashboardSnapshot? + + var body: some View { + Group { + if let snapshot { + VStack(alignment: .leading, spacing: 4) { + Text("邮箱: \(snapshot.email)") + Text("所有模型总请求: \(snapshot.totalRequestsAllModels)") + Text("Usage Spending ($): \(snapshot.spendingCents.dollarStringFromCents)") + Text("预算上限 ($): \(snapshot.hardLimitDollars)") + + if let usageSummary = snapshot.usageSummary { + Text("Plan Usage: \(usageSummary.individualUsage.plan.used)/\(usageSummary.individualUsage.plan.limit)") + if let onDemand = usageSummary.individualUsage.onDemand, + let limit = onDemand.limit { + Text("On-Demand Usage: \(onDemand.used)/\(limit)") + } + } + } + } else { + Text("未登录,请先登录 Cursor") + } + } + } +} diff --git a/参考计费/Packages/VibeviewerMenuUI/Sources/VibeviewerMenuUI/Views/MenuPopoverView.swift b/参考计费/Packages/VibeviewerMenuUI/Sources/VibeviewerMenuUI/Views/MenuPopoverView.swift new file mode 100644 index 0000000..8e1aa9e --- /dev/null +++ b/参考计费/Packages/VibeviewerMenuUI/Sources/VibeviewerMenuUI/Views/MenuPopoverView.swift @@ -0,0 +1,189 @@ +import Observation +import SwiftUI +import VibeviewerAPI +import VibeviewerAppEnvironment +import VibeviewerLoginUI +import VibeviewerModel +import VibeviewerSettingsUI +import VibeviewerCore +import VibeviewerShareUI + +@MainActor +public struct MenuPopoverView: View { + @Environment(\.loginService) private var loginService + @Environment(\.cursorStorage) private var storage + @Environment(\.loginWindowManager) private var loginWindow + @Environment(\.settingsWindowManager) private var settingsWindow + @Environment(\.dashboardRefreshService) private var refresher + @Environment(AppSettings.self) private var appSettings + @Environment(AppSession.self) private var session + + @Environment(\.colorScheme) private var colorScheme + + enum ViewState: Equatable { + case loading + case loaded + case error(String) + } + + public init() {} + + @State private var state: ViewState = .loading + @State private var isLoggingIn: Bool = false + @State private var loginError: String? + + public var body: some View { + @Bindable var appSettings = appSettings + + VStack(alignment: .leading, spacing: 16) { + UsageHeaderView { action in + switch action { + case .dashboard: + self.openDashboard() + } + } + + if isLoggingIn { + loginLoadingView + } else if let snapshot = self.session.snapshot { + if let loginError { + // 出错时只展示错误视图,不展示旧的 snapshot 内容 + DashboardErrorView( + message: loginError, + onRetry: { manualRefresh() } + ) + } else { + let isProSeriesUser = snapshot.usageSummary?.membershipType.isProSeries == true + + if !isProSeriesUser { + MetricsView(metric: .billing(snapshot.billingMetrics)) + + if let free = snapshot.freeUsageMetrics { + MetricsView(metric: .free(free)) + } + + if let onDemandMetrics = snapshot.onDemandMetrics { + MetricsView(metric: .onDemand(onDemandMetrics)) + } + + Divider().opacity(0.5) + } + + UsageEventView(events: self.session.snapshot?.usageEvents ?? []) + + if let modelsUsageChart = self.session.snapshot?.modelsUsageChart { + Divider().opacity(0.5) + + ModelsUsageBarChartView(data: modelsUsageChart) + } + + Divider().opacity(0.5) + + TotalCreditsUsageView(snapshot: snapshot) + + Divider().opacity(0.5) + + MenuFooterView(onRefresh: { + manualRefresh() + }) + } + } else { + VStack(alignment: .leading, spacing: 8) { + loginButtonView + + if let loginError { + DashboardErrorView( + message: loginError, + onRetry: nil + ) + } + } + } + } + .padding(.horizontal, 16) + .padding(.vertical, 24) + .background { + ZStack { + Color(hex: colorScheme == .dark ? "1F1E1E" : "F9F9F9") + Circle() + .fill(Color(hex: colorScheme == .dark ? "354E48" : "F2A48B")) + .padding(80) + .blur(radius: 100) + } + .cornerRadiusWithCorners(32 - 4) + } + .padding(session.credentials != nil ? 4 : 0) + } + + private var loginButtonView: some View { + UnloginView( + onWebLogin: { + loginWindow.show(onCookieCaptured: { cookie in + self.performLogin(with: cookie) + }) + }, + onCookieLogin: { cookie in + self.performLogin(with: cookie) + } + ) + } + + private func openDashboard() { + NSWorkspace.shared.open(URL(string: "https://cursor.com/dashboard?tab=usage")!) + } + + private var loginLoadingView: some View { + VStack(alignment: .leading, spacing: 12) { + Text("Logging in…") + .font(.app(.satoshiBold, size: 16)) + + HStack(spacing: 8) { + ProgressView() + .controlSize(.small) + Text("Fetching your latest usage data, this may take a few seconds.") + .font(.app(.satoshiMedium, size: 11)) + .foregroundStyle(.secondary) + } + } + .maxFrame(true, false, alignment: .leading) + } + + private func performLogin(with cookieHeader: String) { + Task { @MainActor in + self.loginError = nil + self.isLoggingIn = true + defer { self.isLoggingIn = false } + + do { + try await self.loginService.login(with: cookieHeader) + } catch LoginServiceError.fetchAccountFailed { + self.loginError = "Failed to fetch account info. Please check your cookie and try again." + } catch LoginServiceError.saveCredentialsFailed { + self.loginError = "Failed to save credentials locally. Please try again." + } catch LoginServiceError.initialRefreshFailed { + self.loginError = "Failed to load dashboard data. Please try again later." + } catch { + self.loginError = "Login failed. Please try again." + } + } + } + + private func manualRefresh() { + Task { @MainActor in + guard self.session.credentials != nil else { + self.loginError = "You need to login before refreshing dashboard data." + return + } + + self.loginError = nil + + // 使用后台刷新服务的公共方法进行刷新 + await self.refresher.refreshNow() + + // 如果刷新后完全没有 snapshot,则认为刷新失败并展示错误 + if self.session.snapshot == nil { + self.loginError = "Failed to refresh dashboard data. Please try again later." + } + } + } +} diff --git a/参考计费/Packages/VibeviewerMenuUI/Sources/VibeviewerMenuUI/Views/SettingsView.swift b/参考计费/Packages/VibeviewerMenuUI/Sources/VibeviewerMenuUI/Views/SettingsView.swift new file mode 100644 index 0000000..1ffccd4 --- /dev/null +++ b/参考计费/Packages/VibeviewerMenuUI/Sources/VibeviewerMenuUI/Views/SettingsView.swift @@ -0,0 +1,94 @@ +import SwiftUI +import VibeviewerShareUI +import VibeviewerAppEnvironment +import VibeviewerModel + +struct SettingsView: View { + @Environment(\.dismiss) private var dismiss + @Environment(AppSettings.self) private var appSettings + + @State private var refreshFrequency: String = "" + @State private var usageHistoryLimit: String = "" + @State private var pauseOnScreenSleep: Bool = false + + var body: some View { + VStack(alignment: .leading, spacing: 20) { + HStack { + Text("Settings") + .font(.app(.satoshiBold, size: 18)) + + Spacer() + + Button { + dismiss() + } label: { + Image(systemName: "xmark") + .font(.system(size: 12)) + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + } + + VStack(alignment: .leading, spacing: 16) { + VStack(alignment: .leading, spacing: 8) { + Text("Refresh Frequency (minutes)") + .font(.app(.satoshiMedium, size: 12)) + + TextField("5", text: $refreshFrequency) + .textFieldStyle(.roundedBorder) + .frame(width: 80) + } + + VStack(alignment: .leading, spacing: 8) { + Text("Usage History Limit") + .font(.app(.satoshiMedium, size: 12)) + + TextField("5", text: $usageHistoryLimit) + .textFieldStyle(.roundedBorder) + .frame(width: 80) + } + + Toggle("Pause refresh when screen sleeps", isOn: $pauseOnScreenSleep) + .font(.app(.satoshiMedium, size: 12)) + } + + HStack { + Spacer() + + Button("Cancel") { + dismiss() + } + .buttonStyle(.vibe(Color(hex: "F58283").opacity(0.8))) + + Button("Save") { + saveSettings() + dismiss() + } + .buttonStyle(.vibe(Color(hex: "5B67E2").opacity(0.8))) + } + } + .padding(20) + .frame(width: 320, height: 240) + .onAppear { + loadSettings() + } + } + + private func loadSettings() { + refreshFrequency = String(appSettings.overview.refreshInterval) + usageHistoryLimit = String(appSettings.usageHistory.limit) + pauseOnScreenSleep = appSettings.pauseOnScreenSleep + } + + private func saveSettings() { + if let refreshValue = Int(refreshFrequency) { + appSettings.overview.refreshInterval = refreshValue + } + + if let limitValue = Int(usageHistoryLimit) { + appSettings.usageHistory.limit = limitValue + } + + appSettings.pauseOnScreenSleep = pauseOnScreenSleep + } +} \ No newline at end of file diff --git a/参考计费/Packages/VibeviewerMenuUI/Tests/VibeviewerMenuUITests/PlaceholderTests.swift b/参考计费/Packages/VibeviewerMenuUI/Tests/VibeviewerMenuUITests/PlaceholderTests.swift new file mode 100644 index 0000000..7d8d8f2 --- /dev/null +++ b/参考计费/Packages/VibeviewerMenuUI/Tests/VibeviewerMenuUITests/PlaceholderTests.swift @@ -0,0 +1,8 @@ +@testable import VibeviewerMenuUI +import XCTest + +final class VibeviewerMenuUITests: XCTestCase { + func testExample() { + XCTAssertTrue(true) + } +} diff --git a/参考计费/Packages/VibeviewerModel/Package.resolved b/参考计费/Packages/VibeviewerModel/Package.resolved new file mode 100644 index 0000000..e0d096d --- /dev/null +++ b/参考计费/Packages/VibeviewerModel/Package.resolved @@ -0,0 +1,42 @@ +{ + "originHash" : "92fad12ce0ee54ec200016721b4c688ff3af7c525ef00f048094fd209751300c", + "pins" : [ + { + "identity" : "alamofire", + "kind" : "remoteSourceControl", + "location" : "https://github.com/Alamofire/Alamofire.git", + "state" : { + "revision" : "513364f870f6bfc468f9d2ff0a95caccc10044c5", + "version" : "5.10.2" + } + }, + { + "identity" : "moya", + "kind" : "remoteSourceControl", + "location" : "https://github.com/Moya/Moya.git", + "state" : { + "revision" : "c263811c1f3dbf002be9bd83107f7cdc38992b26", + "version" : "15.0.3" + } + }, + { + "identity" : "reactiveswift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/ReactiveCocoa/ReactiveSwift.git", + "state" : { + "revision" : "c43bae3dac73fdd3cb906bd5a1914686ca71ed3c", + "version" : "6.7.0" + } + }, + { + "identity" : "rxswift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/ReactiveX/RxSwift.git", + "state" : { + "revision" : "5dd1907d64f0d36f158f61a466bab75067224893", + "version" : "6.9.0" + } + } + ], + "version" : 3 +} diff --git a/参考计费/Packages/VibeviewerModel/Package.swift b/参考计费/Packages/VibeviewerModel/Package.swift new file mode 100644 index 0000000..effc003 --- /dev/null +++ b/参考计费/Packages/VibeviewerModel/Package.swift @@ -0,0 +1,19 @@ +// swift-tools-version:5.10 +import PackageDescription + +let package = Package( + name: "VibeviewerModel", + platforms: [ + .macOS(.v14) + ], + products: [ + .library(name: "VibeviewerModel", targets: ["VibeviewerModel"]), + ], + dependencies: [ + .package(path: "../VibeviewerCore") + ], + targets: [ + .target(name: "VibeviewerModel", dependencies: ["VibeviewerCore"]), + .testTarget(name: "VibeviewerModelTests", dependencies: ["VibeviewerModel"]) + ] +) diff --git a/参考计费/Packages/VibeviewerModel/Sources/VibeviewerModel/Entities/AIModelBrands.swift b/参考计费/Packages/VibeviewerModel/Sources/VibeviewerModel/Entities/AIModelBrands.swift new file mode 100644 index 0000000..cc21d89 --- /dev/null +++ b/参考计费/Packages/VibeviewerModel/Sources/VibeviewerModel/Entities/AIModelBrands.swift @@ -0,0 +1,20 @@ +import Foundation + +public enum AIModelBrands: String, CaseIterable { + case gpt + case claude + case deepseek + case gemini + case grok + case kimi + case `default` + + public static func brand(for modelName: String) -> AIModelBrands { + for brand in AIModelBrands.allCases { + if modelName.hasPrefix(brand.rawValue) { + return brand + } + } + return .default + } +} \ No newline at end of file diff --git a/参考计费/Packages/VibeviewerModel/Sources/VibeviewerModel/Entities/AggregatedUsageEvents.swift b/参考计费/Packages/VibeviewerModel/Sources/VibeviewerModel/Entities/AggregatedUsageEvents.swift new file mode 100644 index 0000000..101ef57 --- /dev/null +++ b/参考计费/Packages/VibeviewerModel/Sources/VibeviewerModel/Entities/AggregatedUsageEvents.swift @@ -0,0 +1,66 @@ +import Foundation + +/// 聚合使用事件的领域实体 +public struct AggregatedUsageEvents: Sendable, Equatable, Codable { + /// 按模型分组的使用聚合数据 + public let aggregations: [ModelAggregation] + /// 总输入 token 数 + public let totalInputTokens: Int + /// 总输出 token 数 + public let totalOutputTokens: Int + /// 总缓存写入 token 数 + public let totalCacheWriteTokens: Int + /// 总缓存读取 token 数 + public let totalCacheReadTokens: Int + /// 总成本(美分) + public let totalCostCents: Double + + public init( + aggregations: [ModelAggregation], + totalInputTokens: Int, + totalOutputTokens: Int, + totalCacheWriteTokens: Int, + totalCacheReadTokens: Int, + totalCostCents: Double + ) { + self.aggregations = aggregations + self.totalInputTokens = totalInputTokens + self.totalOutputTokens = totalOutputTokens + self.totalCacheWriteTokens = totalCacheWriteTokens + self.totalCacheReadTokens = totalCacheReadTokens + self.totalCostCents = totalCostCents + } +} + +/// 单个模型的使用聚合数据 +public struct ModelAggregation: Sendable, Equatable, Codable { + /// 模型意图/名称(如 "claude-4.5-sonnet-thinking") + public let modelIntent: String + /// 输入 token 数 + public let inputTokens: Int + /// 输出 token 数 + public let outputTokens: Int + /// 缓存写入 token 数 + public let cacheWriteTokens: Int + /// 缓存读取 token 数 + public let cacheReadTokens: Int + /// 该模型的总成本(美分) + public let totalCents: Double + + public init( + modelIntent: String, + inputTokens: Int, + outputTokens: Int, + cacheWriteTokens: Int, + cacheReadTokens: Int, + totalCents: Double + ) { + self.modelIntent = modelIntent + self.inputTokens = inputTokens + self.outputTokens = outputTokens + self.cacheWriteTokens = cacheWriteTokens + self.cacheReadTokens = cacheReadTokens + self.totalCents = totalCents + } +} + diff --git a/参考计费/Packages/VibeviewerModel/Sources/VibeviewerModel/Entities/AppAppearance.swift b/参考计费/Packages/VibeviewerModel/Sources/VibeviewerModel/Entities/AppAppearance.swift new file mode 100644 index 0000000..9c6bf7f --- /dev/null +++ b/参考计费/Packages/VibeviewerModel/Sources/VibeviewerModel/Entities/AppAppearance.swift @@ -0,0 +1,9 @@ +import Foundation + +public enum AppAppearance: String, Codable, Sendable, Equatable, CaseIterable, Hashable { + case system + case light + case dark +} + + diff --git a/参考计费/Packages/VibeviewerModel/Sources/VibeviewerModel/Entities/AppSession.swift b/参考计费/Packages/VibeviewerModel/Sources/VibeviewerModel/Entities/AppSession.swift new file mode 100644 index 0000000..d429575 --- /dev/null +++ b/参考计费/Packages/VibeviewerModel/Sources/VibeviewerModel/Entities/AppSession.swift @@ -0,0 +1,14 @@ +import Foundation +import Observation + +@MainActor +@Observable +public final class AppSession { + public var credentials: Credentials? + public var snapshot: DashboardSnapshot? + + public init(credentials: Credentials? = nil, snapshot: DashboardSnapshot? = nil) { + self.credentials = credentials + self.snapshot = snapshot + } +} diff --git a/参考计费/Packages/VibeviewerModel/Sources/VibeviewerModel/Entities/AppSettings.swift b/参考计费/Packages/VibeviewerModel/Sources/VibeviewerModel/Entities/AppSettings.swift new file mode 100644 index 0000000..23eb3cc --- /dev/null +++ b/参考计费/Packages/VibeviewerModel/Sources/VibeviewerModel/Entities/AppSettings.swift @@ -0,0 +1,98 @@ +import Foundation +import Observation + +@Observable +public final class AppSettings: Codable, Sendable, Equatable { + public var launchAtLogin: Bool + public var usageHistory: AppSettings.UsageHistory + public var overview: AppSettings.Overview + public var pauseOnScreenSleep: Bool + public var appearance: AppAppearance + public var analyticsDataDays: Int + + public init( + launchAtLogin: Bool = false, + usageHistory: AppSettings.UsageHistory = AppSettings.UsageHistory(limit: 5), + overview: AppSettings.Overview = AppSettings.Overview(refreshInterval: 5), + pauseOnScreenSleep: Bool = false, + appearance: AppAppearance = .system, + analyticsDataDays: Int = 7 + ) { + self.launchAtLogin = launchAtLogin + self.usageHistory = usageHistory + self.overview = overview + self.pauseOnScreenSleep = pauseOnScreenSleep + self.appearance = appearance + self.analyticsDataDays = analyticsDataDays + } + + public static func == (lhs: AppSettings, rhs: AppSettings) -> Bool { + lhs.launchAtLogin == rhs.launchAtLogin && + lhs.usageHistory == rhs.usageHistory && + lhs.overview == rhs.overview && + lhs.pauseOnScreenSleep == rhs.pauseOnScreenSleep && + lhs.appearance == rhs.appearance && + lhs.analyticsDataDays == rhs.analyticsDataDays + } + + // MARK: - Codable (backward compatible) + + private enum CodingKeys: String, CodingKey { + case launchAtLogin + case usageHistory + case overview + case pauseOnScreenSleep + case appearance + case analyticsDataDays + } + + public required convenience init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let launchAtLogin = try container.decodeIfPresent(Bool.self, forKey: .launchAtLogin) ?? false + let usageHistory = try container.decodeIfPresent(AppSettings.UsageHistory.self, forKey: .usageHistory) ?? AppSettings.UsageHistory(limit: 5) + let overview = try container.decodeIfPresent(AppSettings.Overview.self, forKey: .overview) ?? AppSettings.Overview(refreshInterval: 5) + let pauseOnScreenSleep = try container.decodeIfPresent(Bool.self, forKey: .pauseOnScreenSleep) ?? false + let appearance = try container.decodeIfPresent(AppAppearance.self, forKey: .appearance) ?? .system + let analyticsDataDays = try container.decodeIfPresent(Int.self, forKey: .analyticsDataDays) ?? 7 + self.init( + launchAtLogin: launchAtLogin, + usageHistory: usageHistory, + overview: overview, + pauseOnScreenSleep: pauseOnScreenSleep, + appearance: appearance, + analyticsDataDays: analyticsDataDays + ) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(self.launchAtLogin, forKey: .launchAtLogin) + try container.encode(self.usageHistory, forKey: .usageHistory) + try container.encode(self.overview, forKey: .overview) + try container.encode(self.pauseOnScreenSleep, forKey: .pauseOnScreenSleep) + try container.encode(self.appearance, forKey: .appearance) + try container.encode(self.analyticsDataDays, forKey: .analyticsDataDays) + } + + public struct Overview: Codable, Sendable, Equatable { + public var refreshInterval: Int + + public init( + refreshInterval: Int = 5 + ) { + self.refreshInterval = refreshInterval + } + } + + public struct UsageHistory: Codable, Sendable, Equatable { + public var limit: Int + + public init( + limit: Int = 5 + ) { + self.limit = limit + } + } + + // moved to its own file: AppAppearance +} diff --git a/参考计费/Packages/VibeviewerModel/Sources/VibeviewerModel/Entities/BillingCycle.swift b/参考计费/Packages/VibeviewerModel/Sources/VibeviewerModel/Entities/BillingCycle.swift new file mode 100644 index 0000000..2729f04 --- /dev/null +++ b/参考计费/Packages/VibeviewerModel/Sources/VibeviewerModel/Entities/BillingCycle.swift @@ -0,0 +1,18 @@ +import Foundation + +/// 计费周期领域实体 +public struct BillingCycle: Sendable, Equatable, Codable { + /// 计费周期开始日期 + public let startDate: Date + /// 计费周期结束日期 + public let endDate: Date + + public init( + startDate: Date, + endDate: Date + ) { + self.startDate = startDate + self.endDate = endDate + } +} + diff --git a/参考计费/Packages/VibeviewerModel/Sources/VibeviewerModel/Entities/Credentials.swift b/参考计费/Packages/VibeviewerModel/Sources/VibeviewerModel/Entities/Credentials.swift new file mode 100644 index 0000000..6c33ca4 --- /dev/null +++ b/参考计费/Packages/VibeviewerModel/Sources/VibeviewerModel/Entities/Credentials.swift @@ -0,0 +1,29 @@ +import Foundation + +@Observable +public class Credentials: Codable, Equatable { + public let userId: Int + public let workosId: String + public let email: String + public let teamId: Int + public let cookieHeader: String + public let isEnterpriseUser: Bool + + public init(userId: Int, workosId: String, email: String, teamId: Int, cookieHeader: String, isEnterpriseUser: Bool) { + self.userId = userId + self.workosId = workosId + self.email = email + self.teamId = teamId + self.cookieHeader = cookieHeader + self.isEnterpriseUser = isEnterpriseUser + } + + public static func == (lhs: Credentials, rhs: Credentials) -> Bool { + lhs.userId == rhs.userId && + lhs.workosId == rhs.workosId && + lhs.email == rhs.email && + lhs.teamId == rhs.teamId && + lhs.cookieHeader == rhs.cookieHeader && + lhs.isEnterpriseUser == rhs.isEnterpriseUser + } +} diff --git a/参考计费/Packages/VibeviewerModel/Sources/VibeviewerModel/Entities/DashboardSnapshot.swift b/参考计费/Packages/VibeviewerModel/Sources/VibeviewerModel/Entities/DashboardSnapshot.swift new file mode 100644 index 0000000..b4ffd26 --- /dev/null +++ b/参考计费/Packages/VibeviewerModel/Sources/VibeviewerModel/Entities/DashboardSnapshot.swift @@ -0,0 +1,165 @@ +import Foundation + +@Observable +public class DashboardSnapshot: Codable, Equatable { + // 用户邮箱 + public let email: String + /// 当前月总请求数(包含计划内请求 + 计划外请求(Billing)) + public let totalRequestsAllModels: Int + /// 当前月已用花费 + public let spendingCents: Int + /// 当前月预算上限 + public let hardLimitDollars: Int + /// 当前用量历史 + public let usageEvents: [UsageEvent] + /// 今日请求次数(由外部在获取 usageEvents 后计算并注入) + public let requestToday: Int + /// 昨日请求次数(由外部在获取 usageEvents 后计算并注入) + public let requestYestoday: Int + /// 使用情况摘要 + public let usageSummary: UsageSummary? + /// 团队计划下个人可用的免费额度(分)。仅 Team Plan 生效 + public let freeUsageCents: Int + /// 模型使用量柱状图数据 + public let modelsUsageChart: ModelsUsageChartData? + /// 模型用量汇总信息(仅 Pro 账号,非 Team 账号) + public let modelsUsageSummary: ModelsUsageSummary? + /// 当前计费周期开始时间(毫秒时间戳字符串) + public let billingCycleStartMs: String? + /// 当前计费周期结束时间(毫秒时间戳字符串) + public let billingCycleEndMs: String? + + public init( + email: String, + totalRequestsAllModels: Int, + spendingCents: Int, + hardLimitDollars: Int, + usageEvents: [UsageEvent] = [], + requestToday: Int = 0, + requestYestoday: Int = 0, + usageSummary: UsageSummary? = nil, + freeUsageCents: Int = 0, + modelsUsageChart: ModelsUsageChartData? = nil, + modelsUsageSummary: ModelsUsageSummary? = nil, + billingCycleStartMs: String? = nil, + billingCycleEndMs: String? = nil + ) { + self.email = email + self.totalRequestsAllModels = totalRequestsAllModels + self.spendingCents = spendingCents + self.hardLimitDollars = hardLimitDollars + self.usageEvents = usageEvents + self.requestToday = requestToday + self.requestYestoday = requestYestoday + self.usageSummary = usageSummary + self.freeUsageCents = freeUsageCents + self.modelsUsageChart = modelsUsageChart + self.modelsUsageSummary = modelsUsageSummary + self.billingCycleStartMs = billingCycleStartMs + self.billingCycleEndMs = billingCycleEndMs + } + + private enum CodingKeys: String, CodingKey { + case email + case totalRequestsAllModels + case spendingCents + case hardLimitDollars + case usageEvents + case requestToday + case requestYestoday + case usageSummary + case freeUsageCents + case modelsUsageChart + case modelsUsageSummary + case billingCycleStartMs + case billingCycleEndMs + } + + public required init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.email = try container.decode(String.self, forKey: .email) + self.totalRequestsAllModels = try container.decode(Int.self, forKey: .totalRequestsAllModels) + self.spendingCents = try container.decode(Int.self, forKey: .spendingCents) + self.hardLimitDollars = try container.decode(Int.self, forKey: .hardLimitDollars) + self.requestToday = try container.decode(Int.self, forKey: .requestToday) + self.requestYestoday = try container.decode(Int.self, forKey: .requestYestoday) + self.usageEvents = try container.decode([UsageEvent].self, forKey: .usageEvents) + self.usageSummary = try? container.decode(UsageSummary.self, forKey: .usageSummary) + self.freeUsageCents = (try? container.decode(Int.self, forKey: .freeUsageCents)) ?? 0 + self.modelsUsageChart = try? container.decode(ModelsUsageChartData.self, forKey: .modelsUsageChart) + self.modelsUsageSummary = try? container.decode(ModelsUsageSummary.self, forKey: .modelsUsageSummary) + self.billingCycleStartMs = try? container.decode(String.self, forKey: .billingCycleStartMs) + self.billingCycleEndMs = try? container.decode(String.self, forKey: .billingCycleEndMs) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(self.email, forKey: .email) + try container.encode(self.totalRequestsAllModels, forKey: .totalRequestsAllModels) + try container.encode(self.spendingCents, forKey: .spendingCents) + try container.encode(self.hardLimitDollars, forKey: .hardLimitDollars) + try container.encode(self.usageEvents, forKey: .usageEvents) + try container.encode(self.requestToday, forKey: .requestToday) + try container.encode(self.requestYestoday, forKey: .requestYestoday) + if let usageSummary = self.usageSummary { + try container.encode(usageSummary, forKey: .usageSummary) + } + if self.freeUsageCents > 0 { + try container.encode(self.freeUsageCents, forKey: .freeUsageCents) + } + if let modelsUsageChart = self.modelsUsageChart { + try container.encode(modelsUsageChart, forKey: .modelsUsageChart) + } + if let modelsUsageSummary = self.modelsUsageSummary { + try container.encode(modelsUsageSummary, forKey: .modelsUsageSummary) + } + if let billingCycleStartMs = self.billingCycleStartMs { + try container.encode(billingCycleStartMs, forKey: .billingCycleStartMs) + } + if let billingCycleEndMs = self.billingCycleEndMs { + try container.encode(billingCycleEndMs, forKey: .billingCycleEndMs) + } + } + + /// 计算 plan + onDemand 的总消耗金额(以分为单位) + public var totalUsageCents: Int { + guard let usageSummary = usageSummary else { + return spendingCents + } + + let planUsed = usageSummary.individualUsage.plan.used + let onDemandUsed = usageSummary.individualUsage.onDemand?.used ?? 0 + let freeUsage = freeUsageCents + + return planUsed + onDemandUsed + freeUsage + } + + /// UI 展示用的总消耗金额(以分为单位) + /// - 对于 Pro 系列账号(pro / proPlus / ultra),如果存在 `modelsUsageSummary`, + /// 优先使用模型聚合总成本(基于 `ModelUsageInfo` 汇总) + /// - 其它情况则回退到 `totalUsageCents` + public var displayTotalUsageCents: Int { + if + let usageSummary, + let modelsUsageSummary, + usageSummary.membershipType.isProSeries + { + return Int(modelsUsageSummary.totalCostCents.rounded()) + } + + return totalUsageCents + } + + public static func == (lhs: DashboardSnapshot, rhs: DashboardSnapshot) -> Bool { + lhs.email == rhs.email && + lhs.totalRequestsAllModels == rhs.totalRequestsAllModels && + lhs.spendingCents == rhs.spendingCents && + lhs.hardLimitDollars == rhs.hardLimitDollars && + lhs.usageSummary == rhs.usageSummary && + lhs.freeUsageCents == rhs.freeUsageCents && + lhs.modelsUsageChart == rhs.modelsUsageChart && + lhs.modelsUsageSummary == rhs.modelsUsageSummary && + lhs.billingCycleStartMs == rhs.billingCycleStartMs && + lhs.billingCycleEndMs == rhs.billingCycleEndMs + } +} diff --git a/参考计费/Packages/VibeviewerModel/Sources/VibeviewerModel/Entities/FilteredUsageHistory.swift b/参考计费/Packages/VibeviewerModel/Sources/VibeviewerModel/Entities/FilteredUsageHistory.swift new file mode 100644 index 0000000..594a262 --- /dev/null +++ b/参考计费/Packages/VibeviewerModel/Sources/VibeviewerModel/Entities/FilteredUsageHistory.swift @@ -0,0 +1,11 @@ +import Foundation + +public struct FilteredUsageHistory: Sendable, Equatable { + public let totalCount: Int + public let events: [UsageEvent] + + public init(totalCount: Int, events: [UsageEvent]) { + self.totalCount = totalCount + self.events = events + } +} diff --git a/参考计费/Packages/VibeviewerModel/Sources/VibeviewerModel/Entities/MembershipType.swift b/参考计费/Packages/VibeviewerModel/Sources/VibeviewerModel/Entities/MembershipType.swift new file mode 100644 index 0000000..edd57f3 --- /dev/null +++ b/参考计费/Packages/VibeviewerModel/Sources/VibeviewerModel/Entities/MembershipType.swift @@ -0,0 +1,56 @@ +import Foundation + +/// 会员类型 +public enum MembershipType: String, Sendable, Equatable, Codable { + case enterprise = "enterprise" + case freeTrial = "free_trial" + case pro = "pro" + case proPlus = "pro_plus" + case ultra = "ultra" + case free = "free" + + /// 是否为 Pro 系列账号(Pro / Pro+ / Ultra) + public var isProSeries: Bool { + switch self { + case .pro, .proPlus, .ultra: + return true + default: + return false + } + } + + /// 获取会员类型的显示名称 + /// - Parameters: + /// - subscriptionStatus: 订阅状态 + /// - isEnterprise: 是否为企业版(用于区分 Enterprise 和 Team Plan) + /// - Returns: 显示名称 + public func displayName( + subscriptionStatus: SubscriptionStatus? = nil, + isEnterprise: Bool = false + ) -> String { + switch self { + case .enterprise: + return isEnterprise ? "Enterprise" : "Team Plan" + case .freeTrial: + return "Pro Trial" + case .pro: + return subscriptionStatus == .trialing ? "Pro Trial" : "Pro Plan" + case .proPlus: + return subscriptionStatus == .trialing ? "Pro+ Trial" : "Pro+ Plan" + case .ultra: + return "Ultra Plan" + case .free: + return "Free Plan" + } + } +} + +/// 订阅状态 +public enum SubscriptionStatus: String, Sendable, Equatable, Codable { + case trialing = "trialing" + case active = "active" + case canceled = "canceled" + case pastDue = "past_due" + case unpaid = "unpaid" +} + diff --git a/参考计费/Packages/VibeviewerModel/Sources/VibeviewerModel/Entities/ModelUsageInfo.swift b/参考计费/Packages/VibeviewerModel/Sources/VibeviewerModel/Entities/ModelUsageInfo.swift new file mode 100644 index 0000000..882dc13 --- /dev/null +++ b/参考计费/Packages/VibeviewerModel/Sources/VibeviewerModel/Entities/ModelUsageInfo.swift @@ -0,0 +1,126 @@ +import Foundation + +/// 模型用量信息 - 用于仪表板展示各个模型的详细使用情况 +public struct ModelUsageInfo: Sendable, Equatable, Codable { + /// 模型名称 + public let modelName: String + /// 输入 token 数 + public let inputTokens: Int + /// 输出 token 数 + public let outputTokens: Int + /// 缓存写入 token 数 + public let cacheWriteTokens: Int + /// 缓存读取 token 数 + public let cacheReadTokens: Int + /// 该模型的总成本(美分) + public let costCents: Double + + public init( + modelName: String, + inputTokens: Int, + outputTokens: Int, + cacheWriteTokens: Int, + cacheReadTokens: Int, + costCents: Double + ) { + self.modelName = modelName + self.inputTokens = inputTokens + self.outputTokens = outputTokens + self.cacheWriteTokens = cacheWriteTokens + self.cacheReadTokens = cacheReadTokens + self.costCents = costCents + } + + /// 从 ModelAggregation 转换 + public init(from aggregation: ModelAggregation) { + self.modelName = aggregation.modelIntent + self.inputTokens = aggregation.inputTokens + self.outputTokens = aggregation.outputTokens + self.cacheWriteTokens = aggregation.cacheWriteTokens + self.cacheReadTokens = aggregation.cacheReadTokens + self.costCents = aggregation.totalCents + } + + /// 总 token 数(不含缓存) + public var totalTokens: Int { + inputTokens + outputTokens + } + + /// 总 token 数(含缓存) + public var totalTokensWithCache: Int { + inputTokens + outputTokens + cacheWriteTokens + cacheReadTokens + } + + /// 格式化成本显示(如 "$1.23") + public var formattedCost: String { + String(format: "$%.2f", costCents / 100.0) + } +} + +/// 模型用量汇总 - 用于仪表板展示所有模型的用量概览 +public struct ModelsUsageSummary: Sendable, Equatable, Codable { + /// 各个模型的用量信息 + public let models: [ModelUsageInfo] + /// 总输入 token 数 + public let totalInputTokens: Int + /// 总输出 token 数 + public let totalOutputTokens: Int + /// 总缓存写入 token 数 + public let totalCacheWriteTokens: Int + /// 总缓存读取 token 数 + public let totalCacheReadTokens: Int + /// 总成本(美分) + public let totalCostCents: Double + + public init( + models: [ModelUsageInfo], + totalInputTokens: Int, + totalOutputTokens: Int, + totalCacheWriteTokens: Int, + totalCacheReadTokens: Int, + totalCostCents: Double + ) { + self.models = models + self.totalInputTokens = totalInputTokens + self.totalOutputTokens = totalOutputTokens + self.totalCacheWriteTokens = totalCacheWriteTokens + self.totalCacheReadTokens = totalCacheReadTokens + self.totalCostCents = totalCostCents + } + + /// 从 AggregatedUsageEvents 转换 + public init(from aggregated: AggregatedUsageEvents) { + self.models = aggregated.aggregations.map { ModelUsageInfo(from: $0) } + self.totalInputTokens = aggregated.totalInputTokens + self.totalOutputTokens = aggregated.totalOutputTokens + self.totalCacheWriteTokens = aggregated.totalCacheWriteTokens + self.totalCacheReadTokens = aggregated.totalCacheReadTokens + self.totalCostCents = aggregated.totalCostCents + } + + /// 总 token 数(不含缓存) + public var totalTokens: Int { + totalInputTokens + totalOutputTokens + } + + /// 总 token 数(含缓存) + public var totalTokensWithCache: Int { + totalInputTokens + totalOutputTokens + totalCacheWriteTokens + totalCacheReadTokens + } + + /// 格式化总成本显示(如 "$1.23") + public var formattedTotalCost: String { + String(format: "$%.2f", totalCostCents / 100.0) + } + + /// 按成本降序排序的模型列表 + public var modelsSortedByCost: [ModelUsageInfo] { + models.sorted { $0.costCents > $1.costCents } + } + + /// 按 token 使用量降序排序的模型列表 + public var modelsSortedByTokens: [ModelUsageInfo] { + models.sorted { $0.totalTokens > $1.totalTokens } + } +} + diff --git a/参考计费/Packages/VibeviewerModel/Sources/VibeviewerModel/Entities/ModelsUsageChartData.swift b/参考计费/Packages/VibeviewerModel/Sources/VibeviewerModel/Entities/ModelsUsageChartData.swift new file mode 100644 index 0000000..bba3bcd --- /dev/null +++ b/参考计费/Packages/VibeviewerModel/Sources/VibeviewerModel/Entities/ModelsUsageChartData.swift @@ -0,0 +1,45 @@ +import Foundation + +/// 模型使用量柱状图数据 +public struct ModelsUsageChartData: Codable, Sendable, Equatable { + /// 数据点列表 + public let dataPoints: [DataPoint] + + public init(dataPoints: [DataPoint]) { + self.dataPoints = dataPoints + } + + /// 单个数据点 + public struct DataPoint: Codable, Sendable, Equatable { + /// 原始日期(YYYY-MM-DD 格式) + public let date: String + /// 格式化后的日期标签(MM/dd) + public let dateLabel: String + /// 各模型的使用量列表 + public let modelUsages: [ModelUsage] + /// 总使用次数(所有模型的总和) + public var totalValue: Int { + modelUsages.reduce(0) { $0 + $1.requests } + } + + public init(date: String, dateLabel: String, modelUsages: [ModelUsage]) { + self.date = date + self.dateLabel = dateLabel + self.modelUsages = modelUsages + } + } + + /// 单个模型的使用量 + public struct ModelUsage: Codable, Sendable, Equatable { + /// 模型名称 + public let modelName: String + /// 请求数 + public let requests: Int + + public init(modelName: String, requests: Int) { + self.modelName = modelName + self.requests = requests + } + } +} + diff --git a/参考计费/Packages/VibeviewerModel/Sources/VibeviewerModel/Entities/TokenUsage.swift b/参考计费/Packages/VibeviewerModel/Sources/VibeviewerModel/Entities/TokenUsage.swift new file mode 100644 index 0000000..1e5edc8 --- /dev/null +++ b/参考计费/Packages/VibeviewerModel/Sources/VibeviewerModel/Entities/TokenUsage.swift @@ -0,0 +1,27 @@ +import Foundation + +public struct TokenUsage: Codable, Sendable, Equatable { + public let outputTokens: Int? + public let inputTokens: Int? + public let totalCents: Double + public let cacheWriteTokens: Int? + public let cacheReadTokens: Int? + + public var totalTokens: Int { + return (outputTokens ?? 0) + (inputTokens ?? 0) + (cacheWriteTokens ?? 0) + (cacheReadTokens ?? 0) + } + + public init( + outputTokens: Int?, + inputTokens: Int?, + totalCents: Double, + cacheWriteTokens: Int?, + cacheReadTokens: Int? + ) { + self.outputTokens = outputTokens + self.inputTokens = inputTokens + self.totalCents = totalCents + self.cacheWriteTokens = cacheWriteTokens + self.cacheReadTokens = cacheReadTokens + } +} diff --git a/参考计费/Packages/VibeviewerModel/Sources/VibeviewerModel/Entities/UsageEvent.swift b/参考计费/Packages/VibeviewerModel/Sources/VibeviewerModel/Entities/UsageEvent.swift new file mode 100644 index 0000000..70f71d4 --- /dev/null +++ b/参考计费/Packages/VibeviewerModel/Sources/VibeviewerModel/Entities/UsageEvent.swift @@ -0,0 +1,92 @@ +import Foundation + +public struct UsageEvent: Codable, Sendable, Equatable { + public let occurredAtMs: String + public let modelName: String + public let kind: String + public let requestCostCount: Int + public let usageCostDisplay: String + /// 花费(分)——用于数值计算与累加 + public let usageCostCents: Int + public let isTokenBased: Bool + public let userDisplayName: String + public let cursorTokenFee: Double + public let tokenUsage: TokenUsage? + + public var brand: AIModelBrands { + AIModelBrands.brand(for: self.modelName) + } + + /// 计算实际费用显示(美元格式) + public var calculatedCostDisplay: String { + let totalCents = (tokenUsage?.totalCents ?? 0.0) + cursorTokenFee + let dollars = totalCents / 100.0 + return String(format: "$%.2f", dollars) + } + + public init( + occurredAtMs: String, + modelName: String, + kind: String, + requestCostCount: Int, + usageCostDisplay: String, + usageCostCents: Int = 0, + isTokenBased: Bool, + userDisplayName: String, + cursorTokenFee: Double = 0.0, + tokenUsage: TokenUsage? = nil + ) { + self.occurredAtMs = occurredAtMs + self.modelName = modelName + self.kind = kind + self.requestCostCount = requestCostCount + self.usageCostDisplay = usageCostDisplay + self.usageCostCents = usageCostCents + self.isTokenBased = isTokenBased + self.userDisplayName = userDisplayName + self.cursorTokenFee = cursorTokenFee + self.tokenUsage = tokenUsage + } + + private enum CodingKeys: String, CodingKey { + case occurredAtMs + case modelName + case kind + case requestCostCount + case usageCostDisplay + case usageCostCents + case isTokenBased + case userDisplayName + case teamDisplayName + case cursorTokenFee + case tokenUsage + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.occurredAtMs = try container.decode(String.self, forKey: .occurredAtMs) + self.modelName = try container.decode(String.self, forKey: .modelName) + self.kind = try container.decode(String.self, forKey: .kind) + self.requestCostCount = try container.decode(Int.self, forKey: .requestCostCount) + self.usageCostDisplay = try container.decode(String.self, forKey: .usageCostDisplay) + self.usageCostCents = (try? container.decode(Int.self, forKey: .usageCostCents)) ?? 0 + self.isTokenBased = try container.decode(Bool.self, forKey: .isTokenBased) + self.userDisplayName = try container.decode(String.self, forKey: .userDisplayName) + self.cursorTokenFee = (try? container.decode(Double.self, forKey: .cursorTokenFee)) ?? 0.0 + self.tokenUsage = try container.decodeIfPresent(TokenUsage.self, forKey: .tokenUsage) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(self.occurredAtMs, forKey: .occurredAtMs) + try container.encode(self.modelName, forKey: .modelName) + try container.encode(self.kind, forKey: .kind) + try container.encode(self.requestCostCount, forKey: .requestCostCount) + try container.encode(self.usageCostDisplay, forKey: .usageCostDisplay) + try container.encode(self.usageCostCents, forKey: .usageCostCents) + try container.encode(self.isTokenBased, forKey: .isTokenBased) + try container.encode(self.userDisplayName, forKey: .userDisplayName) + try container.encode(self.cursorTokenFee, forKey: .cursorTokenFee) + try container.encodeIfPresent(self.tokenUsage, forKey: .tokenUsage) + } +} diff --git a/参考计费/Packages/VibeviewerModel/Sources/VibeviewerModel/Entities/UsageEventHourGroup.swift b/参考计费/Packages/VibeviewerModel/Sources/VibeviewerModel/Entities/UsageEventHourGroup.swift new file mode 100644 index 0000000..e5633c5 --- /dev/null +++ b/参考计费/Packages/VibeviewerModel/Sources/VibeviewerModel/Entities/UsageEventHourGroup.swift @@ -0,0 +1,68 @@ +import Foundation +import VibeviewerCore + +public struct UsageEventHourGroup: Identifiable, Sendable, Equatable { + public let id: Date + public let hourStart: Date + public let title: String + public let events: [UsageEvent] + + public var totalRequests: Int { events.map(\.requestCostCount).reduce(0, +) } + public var totalCostCents: Int { events.map(\.usageCostCents).reduce(0, +) } + + /// 计算实际总费用显示(美元格式) + public var calculatedTotalCostDisplay: String { + let totalCents = events.reduce(0.0) { sum, event in + sum + (event.tokenUsage?.totalCents ?? 0.0) + event.cursorTokenFee + } + let dollars = totalCents / 100.0 + return String(format: "$%.2f", dollars) + } + + public init(id: Date, hourStart: Date, title: String, events: [UsageEvent]) { + self.id = id + self.hourStart = hourStart + self.title = title + self.events = events + } +} + +public extension Array where Element == UsageEvent { + func groupedByHour(calendar: Calendar = .current) -> [UsageEventHourGroup] { + var buckets: [Date: [UsageEvent]] = [:] + for event in self { + guard let date = DateUtils.date(fromMillisecondsString: event.occurredAtMs), + let hourStart = calendar.dateInterval(of: .hour, for: date)?.start else { continue } + buckets[hourStart, default: []].append(event) + } + + let sortedStarts = buckets.keys.sorted(by: >) + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = .current + formatter.dateFormat = "yyyy-MM-dd HH:00" + + return sortedStarts.map { start in + UsageEventHourGroup( + id: start, + hourStart: start, + title: formatter.string(from: start), + events: buckets[start] ?? [] + ) + } + } +} + +public enum UsageEventHourGrouper { + public static func groupByHour(_ events: [UsageEvent], calendar: Calendar = .current) -> [UsageEventHourGroup] { + events.groupedByHour(calendar: calendar) + } +} + +public extension UsageEventHourGroup { + static func group(_ events: [UsageEvent], calendar: Calendar = .current) -> [UsageEventHourGroup] { + events.groupedByHour(calendar: calendar) + } +} + + diff --git a/参考计费/Packages/VibeviewerModel/Sources/VibeviewerModel/Entities/UsageOverview.swift b/参考计费/Packages/VibeviewerModel/Sources/VibeviewerModel/Entities/UsageOverview.swift new file mode 100644 index 0000000..891eadb --- /dev/null +++ b/参考计费/Packages/VibeviewerModel/Sources/VibeviewerModel/Entities/UsageOverview.swift @@ -0,0 +1,22 @@ +import Foundation + +public struct UsageOverview: Sendable, Equatable { + public struct ModelUsage: Sendable, Equatable { + public let modelName: String + /// 当前月已用 token 数 + public let tokensUsed: Int? + + public init(modelName: String, tokensUsed: Int? = nil) { + self.modelName = modelName + self.tokensUsed = tokensUsed + } + } + + public let startOfMonthMs: Date + public let models: [ModelUsage] + + public init(startOfMonthMs: Date, models: [ModelUsage]) { + self.startOfMonthMs = startOfMonthMs + self.models = models + } +} diff --git a/参考计费/Packages/VibeviewerModel/Sources/VibeviewerModel/Entities/UsageSummary.swift b/参考计费/Packages/VibeviewerModel/Sources/VibeviewerModel/Entities/UsageSummary.swift new file mode 100644 index 0000000..5c196c0 --- /dev/null +++ b/参考计费/Packages/VibeviewerModel/Sources/VibeviewerModel/Entities/UsageSummary.swift @@ -0,0 +1,84 @@ +import Foundation + +public struct UsageSummary: Sendable, Equatable, Codable { + public let billingCycleStart: Date + public let billingCycleEnd: Date + public let membershipType: MembershipType + public let limitType: String + public let individualUsage: IndividualUsage + public let teamUsage: TeamUsage? + + public init( + billingCycleStart: Date, + billingCycleEnd: Date, + membershipType: MembershipType, + limitType: String, + individualUsage: IndividualUsage, + teamUsage: TeamUsage? = nil + ) { + self.billingCycleStart = billingCycleStart + self.billingCycleEnd = billingCycleEnd + self.membershipType = membershipType + self.limitType = limitType + self.individualUsage = individualUsage + self.teamUsage = teamUsage + } +} + +public struct IndividualUsage: Sendable, Equatable, Codable { + public let plan: PlanUsage + public let onDemand: OnDemandUsage? + + public init(plan: PlanUsage, onDemand: OnDemandUsage? = nil) { + self.plan = plan + self.onDemand = onDemand + } +} + +public struct PlanUsage: Sendable, Equatable, Codable { + public let used: Int + public let limit: Int + public let remaining: Int + public let breakdown: PlanBreakdown + + public init(used: Int, limit: Int, remaining: Int, breakdown: PlanBreakdown) { + self.used = used + self.limit = limit + self.remaining = remaining + self.breakdown = breakdown + } +} + +public struct PlanBreakdown: Sendable, Equatable, Codable { + public let included: Int + public let bonus: Int + public let total: Int + + public init(included: Int, bonus: Int, total: Int) { + self.included = included + self.bonus = bonus + self.total = total + } +} + +public struct OnDemandUsage: Sendable, Equatable, Codable { + public let used: Int + public let limit: Int? + public let remaining: Int? + public let enabled: Bool + + public init(used: Int, limit: Int?, remaining: Int?, enabled: Bool) { + self.used = used + self.limit = limit + self.remaining = remaining + self.enabled = enabled + } +} + +public struct TeamUsage: Sendable, Equatable, Codable { + public let onDemand: OnDemandUsage + + public init(onDemand: OnDemandUsage) { + self.onDemand = onDemand + } +} diff --git a/参考计费/Packages/VibeviewerModel/Tests/VibeviewerModelTests/PlaceholderTests.swift b/参考计费/Packages/VibeviewerModel/Tests/VibeviewerModelTests/PlaceholderTests.swift new file mode 100644 index 0000000..1421a24 --- /dev/null +++ b/参考计费/Packages/VibeviewerModel/Tests/VibeviewerModelTests/PlaceholderTests.swift @@ -0,0 +1,8 @@ +@testable import VibeviewerModel +import XCTest + +final class VibeviewerModelTests: XCTestCase { + func testExample() { + XCTAssertTrue(true) + } +} diff --git a/参考计费/Packages/VibeviewerSettingsUI/Package.resolved b/参考计费/Packages/VibeviewerSettingsUI/Package.resolved new file mode 100644 index 0000000..30af558 --- /dev/null +++ b/参考计费/Packages/VibeviewerSettingsUI/Package.resolved @@ -0,0 +1,51 @@ +{ + "originHash" : "87b7891a178f9f79751f334c663dfe85e6310bca1bb0aa6f3cce8da4fe4fb426", + "pins" : [ + { + "identity" : "alamofire", + "kind" : "remoteSourceControl", + "location" : "https://github.com/Alamofire/Alamofire.git", + "state" : { + "revision" : "513364f870f6bfc468f9d2ff0a95caccc10044c5", + "version" : "5.10.2" + } + }, + { + "identity" : "moya", + "kind" : "remoteSourceControl", + "location" : "https://github.com/Moya/Moya.git", + "state" : { + "revision" : "c263811c1f3dbf002be9bd83107f7cdc38992b26", + "version" : "15.0.3" + } + }, + { + "identity" : "reactiveswift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/ReactiveCocoa/ReactiveSwift.git", + "state" : { + "revision" : "c43bae3dac73fdd3cb906bd5a1914686ca71ed3c", + "version" : "6.7.0" + } + }, + { + "identity" : "rxswift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/ReactiveX/RxSwift.git", + "state" : { + "revision" : "5dd1907d64f0d36f158f61a466bab75067224893", + "version" : "6.9.0" + } + }, + { + "identity" : "sparkle", + "kind" : "remoteSourceControl", + "location" : "https://github.com/sparkle-project/Sparkle", + "state" : { + "revision" : "9a1d2a19d3595fcf8d9c447173f9a1687b3dcadb", + "version" : "2.8.0" + } + } + ], + "version" : 3 +} diff --git a/参考计费/Packages/VibeviewerSettingsUI/Package.swift b/参考计费/Packages/VibeviewerSettingsUI/Package.swift new file mode 100644 index 0000000..6dd9d23 --- /dev/null +++ b/参考计费/Packages/VibeviewerSettingsUI/Package.swift @@ -0,0 +1,28 @@ +// swift-tools-version:5.10 +import PackageDescription + +let package = Package( + name: "VibeviewerSettingsUI", + platforms: [ + .macOS(.v14) + ], + products: [ + .library(name: "VibeviewerSettingsUI", targets: ["VibeviewerSettingsUI"]) + ], + dependencies: [ + .package(path: "../VibeviewerModel"), + .package(path: "../VibeviewerAppEnvironment"), + .package(path: "../VibeviewerShareUI"), + ], + targets: [ + .target( + name: "VibeviewerSettingsUI", + dependencies: [ + "VibeviewerModel", + "VibeviewerAppEnvironment", + "VibeviewerShareUI", + ] + ), + .testTarget(name: "VibeviewerSettingsUITests", dependencies: ["VibeviewerSettingsUI"]), + ] +) diff --git a/参考计费/Packages/VibeviewerSettingsUI/Sources/VibeviewerSettingsUI/Environment/EnvironmentKeys.swift b/参考计费/Packages/VibeviewerSettingsUI/Sources/VibeviewerSettingsUI/Environment/EnvironmentKeys.swift new file mode 100644 index 0000000..29457d7 --- /dev/null +++ b/参考计费/Packages/VibeviewerSettingsUI/Sources/VibeviewerSettingsUI/Environment/EnvironmentKeys.swift @@ -0,0 +1,15 @@ +import SwiftUI + +private struct SettingsWindowManagerKey: EnvironmentKey { + @MainActor + static var defaultValue: SettingsWindowManager { + SettingsWindowManager.shared + } +} + +public extension EnvironmentValues { + var settingsWindowManager: SettingsWindowManager { + get { self[SettingsWindowManagerKey.self] } + set { self[SettingsWindowManagerKey.self] = newValue } + } +} diff --git a/参考计费/Packages/VibeviewerSettingsUI/Sources/VibeviewerSettingsUI/Scenes/SettingsWindow.swift b/参考计费/Packages/VibeviewerSettingsUI/Sources/VibeviewerSettingsUI/Scenes/SettingsWindow.swift new file mode 100644 index 0000000..c2329b6 --- /dev/null +++ b/参考计费/Packages/VibeviewerSettingsUI/Sources/VibeviewerSettingsUI/Scenes/SettingsWindow.swift @@ -0,0 +1,66 @@ +import AppKit +import SwiftUI +import VibeviewerAppEnvironment +import VibeviewerCore +import VibeviewerModel +import VibeviewerStorage + +@MainActor +public final class SettingsWindowManager { + public static let shared = SettingsWindowManager() + private var controller: NSWindowController? + public var appSettings: AppSettings = DefaultCursorStorageService.loadSettingsSync() + public var appSession: AppSession = AppSession( + credentials: DefaultCursorStorageService.loadCredentialsSync(), + snapshot: DefaultCursorStorageService.loadDashboardSnapshotSync() + ) + public var dashboardRefreshService: any DashboardRefreshService = NoopDashboardRefreshService() + public var updateService: any UpdateService = NoopUpdateService() + + public func show() { + // Close MenuBarExtra popover window if it's open + closeMenuBarExtraWindow() + + if let controller { + controller.close() + self.controller = nil + } + let vc = NSHostingController(rootView: SettingsView() + .environment(self.appSettings) + .environment(self.appSession) + .environment(\.dashboardRefreshService, self.dashboardRefreshService) + .environment(\.updateService, self.updateService) + .environment(\.cursorStorage, DefaultCursorStorageService()) + .environment(\.launchAtLoginService, DefaultLaunchAtLoginService())) + let window = NSWindow(contentViewController: vc) + window.title = "Settings" + window.setContentSize(NSSize(width: 560, height: 500)) + window.styleMask = [.titled, .closable] + window.isReleasedWhenClosed = false + window.titlebarAppearsTransparent = false + window.toolbarStyle = .unified + let ctrl = NSWindowController(window: window) + self.controller = ctrl + ctrl.window?.center() + ctrl.showWindow(nil) + ctrl.window?.makeKeyAndOrderFront(nil) + NSApp.activate(ignoringOtherApps: true) + } + + private func closeMenuBarExtraWindow() { + // Close MenuBarExtra popover windows + // MenuBarExtra windows are typically non-activating NSPanel instances + for window in NSApp.windows { + if let panel = window as? NSPanel, + panel.styleMask.contains(.nonactivatingPanel), + window != self.controller?.window { + window.close() + } + } + } + + public func close() { + self.controller?.close() + self.controller = nil + } +} diff --git a/参考计费/Packages/VibeviewerSettingsUI/Sources/VibeviewerSettingsUI/Views/SettingsView.swift b/参考计费/Packages/VibeviewerSettingsUI/Sources/VibeviewerSettingsUI/Views/SettingsView.swift new file mode 100644 index 0000000..c0947f8 --- /dev/null +++ b/参考计费/Packages/VibeviewerSettingsUI/Sources/VibeviewerSettingsUI/Views/SettingsView.swift @@ -0,0 +1,251 @@ +import Observation +import SwiftUI +import VibeviewerAppEnvironment +import VibeviewerModel +import VibeviewerShareUI + +public struct SettingsView: View { + @Environment(AppSettings.self) private var appSettings + @Environment(\.cursorStorage) private var storage + @Environment(\.launchAtLoginService) private var launchAtLoginService + @Environment(\.dashboardRefreshService) private var refresher + @Environment(\.updateService) private var updateService + @Environment(AppSession.self) private var session + + @State private var refreshFrequency: Int = 5 + @State private var usageHistoryLimit: Int = 5 + @State private var pauseOnScreenSleep: Bool = false + @State private var launchAtLogin: Bool = false + @State private var appearanceSelection: VibeviewerModel.AppAppearance = .system + @State private var showingClearSessionAlert: Bool = false + @State private var showingLogoutAlert: Bool = false + @State private var analyticsDataDays: Int = 7 + + // 预定义选项 + private let refreshFrequencyOptions: [Int] = [1, 2, 3, 5, 10, 15, 30] + private let usageHistoryLimitOptions: [Int] = [5, 10, 20, 50, 100] + private let analyticsDataDaysOptions: [Int] = [3, 7, 14, 30, 60, 90] + + public init() {} + + public var body: some View { + Form { + Section { + Picker("Appearance", selection: $appearanceSelection) { + Text("System").tag(VibeviewerModel.AppAppearance.system) + Text("Light").tag(VibeviewerModel.AppAppearance.light) + Text("Dark").tag(VibeviewerModel.AppAppearance.dark) + } + .onChange(of: appearanceSelection) { oldValue, newValue in + appSettings.appearance = newValue + Task { @MainActor in + try? await appSettings.save(using: storage) + } + } + + // 版本信息 + HStack { + Text("Current Version") + Spacer() + Text(updateService.currentVersion) + .foregroundColor(.secondary) + } + } header: { + Text("General") + } + + Section { + Picker("Refresh Frequency", selection: $refreshFrequency) { + ForEach(refreshFrequencyOptions, id: \.self) { value in + Text("\(value) minutes").tag(value) + } + } + .pickerStyle(.menu) + .onChange(of: refreshFrequency) { oldValue, newValue in + appSettings.overview.refreshInterval = newValue + Task { @MainActor in + try? await appSettings.save(using: storage) + } + } + + Picker("Usage History Limit", selection: $usageHistoryLimit) { + ForEach(usageHistoryLimitOptions, id: \.self) { value in + Text("\(value) items").tag(value) + } + } + .pickerStyle(.menu) + .onChange(of: usageHistoryLimit) { oldValue, newValue in + appSettings.usageHistory.limit = newValue + Task { @MainActor in + try? await appSettings.save(using: storage) + } + } + + Picker("Analytics Data Range", selection: $analyticsDataDays) { + ForEach(analyticsDataDaysOptions, id: \.self) { value in + Text("\(value) days").tag(value) + } + } + .pickerStyle(.menu) + .onChange(of: analyticsDataDays) { oldValue, newValue in + appSettings.analyticsDataDays = newValue + Task { @MainActor in + try? await appSettings.save(using: storage) + } + } + } header: { + Text("Data") + } footer: { + Text("Refresh Frequency: Controls the automatic refresh interval for dashboard data.\nUsage History Limit: Limits the number of usage history items displayed.\nAnalytics Data Range: Controls the number of days of data shown in analytics charts.") + } + + Section { + Toggle("Pause refresh when screen sleeps", isOn: $pauseOnScreenSleep) + .onChange(of: pauseOnScreenSleep) { oldValue, newValue in + appSettings.pauseOnScreenSleep = newValue + Task { @MainActor in + try? await appSettings.save(using: storage) + } + } + + Toggle("Launch at login", isOn: $launchAtLogin) + .onChange(of: launchAtLogin) { oldValue, newValue in + _ = launchAtLoginService.setEnabled(newValue) + appSettings.launchAtLogin = newValue + Task { @MainActor in + try? await appSettings.save(using: storage) + } + } + } header: { + Text("Behavior") + } + + if session.credentials != nil { + Section { + Button(role: .destructive) { + showingLogoutAlert = true + } label: { + Text("Log Out") + } + } header: { + Text("Account") + } footer: { + Text("Clear login credentials and stop data refresh. You will need to log in again to continue using the app.") + } + } + + Section { + Button(role: .destructive) { + showingClearSessionAlert = true + } label: { + Text("Clear App Cache") + } + } header: { + Text("Advanced") + } footer: { + Text("Clear all stored credentials and dashboard data. You will need to log in again.") + } + } + .formStyle(.grouped) + .frame(width: 560, height: 500) + .onAppear { + loadSettings() + } + .alert("Log Out", isPresented: $showingLogoutAlert) { + Button("Cancel", role: .cancel) { } + Button("Log Out", role: .destructive) { + Task { @MainActor in + await logout() + } + } + } message: { + Text("This will clear your login credentials and stop data refresh. You will need to log in again to continue using the app.") + } + .alert("Clear App Cache", isPresented: $showingClearSessionAlert) { + Button("Cancel", role: .cancel) { } + Button("Clear", role: .destructive) { + Task { @MainActor in + await clearAppSession() + } + } + } message: { + Text("This will clear all stored credentials and dashboard data. You will need to log in again.") + } + } + + private func loadSettings() { + // 加载设置值 + let currentRefreshFrequency = appSettings.overview.refreshInterval + let currentUsageHistoryLimit = appSettings.usageHistory.limit + let currentAnalyticsDataDays = appSettings.analyticsDataDays + + // 如果当前值不在选项中,使用最接近的值并更新设置 + if refreshFrequencyOptions.contains(currentRefreshFrequency) { + refreshFrequency = currentRefreshFrequency + } else { + let closest = refreshFrequencyOptions.min(by: { abs($0 - currentRefreshFrequency) < abs($1 - currentRefreshFrequency) }) ?? 5 + refreshFrequency = closest + appSettings.overview.refreshInterval = closest + } + + if usageHistoryLimitOptions.contains(currentUsageHistoryLimit) { + usageHistoryLimit = currentUsageHistoryLimit + } else { + let closest = usageHistoryLimitOptions.min(by: { abs($0 - currentUsageHistoryLimit) < abs($1 - currentUsageHistoryLimit) }) ?? 5 + usageHistoryLimit = closest + appSettings.usageHistory.limit = closest + } + + if analyticsDataDaysOptions.contains(currentAnalyticsDataDays) { + analyticsDataDays = currentAnalyticsDataDays + } else { + let closest = analyticsDataDaysOptions.min(by: { abs($0 - currentAnalyticsDataDays) < abs($1 - currentAnalyticsDataDays) }) ?? 7 + analyticsDataDays = closest + appSettings.analyticsDataDays = closest + } + + pauseOnScreenSleep = appSettings.pauseOnScreenSleep + launchAtLogin = launchAtLoginService.isEnabled + appearanceSelection = appSettings.appearance + + // 如果值被调整了,保存设置 + if !refreshFrequencyOptions.contains(currentRefreshFrequency) || + !usageHistoryLimitOptions.contains(currentUsageHistoryLimit) || + !analyticsDataDaysOptions.contains(currentAnalyticsDataDays) { + Task { @MainActor in + try? await appSettings.save(using: storage) + } + } + } + + private func logout() async { + // 停止刷新服务 + refresher.stop() + + // 清空存储的凭据与当前仪表盘快照,使应用回到需要登录的状态 + await storage.clearCredentials() + await storage.clearDashboardSnapshot() + + // 重置内存中的会话数据 + session.credentials = nil + session.snapshot = nil + + // 关闭设置窗口 + NSApplication.shared.keyWindow?.close() + } + + private func clearAppSession() async { + // 停止刷新服务 + refresher.stop() + + // 清空存储的 AppSession 数据 + await storage.clearAppSession() + + // 重置内存中的 AppSession + session.credentials = nil + session.snapshot = nil + + // 关闭设置窗口 + NSApplication.shared.keyWindow?.close() + } +} diff --git a/参考计费/Packages/VibeviewerSettingsUI/Tests/VibeviewerSettingsUITests/PlaceholderTests.swift b/参考计费/Packages/VibeviewerSettingsUI/Tests/VibeviewerSettingsUITests/PlaceholderTests.swift new file mode 100644 index 0000000..d0d13d2 --- /dev/null +++ b/参考计费/Packages/VibeviewerSettingsUI/Tests/VibeviewerSettingsUITests/PlaceholderTests.swift @@ -0,0 +1,8 @@ +@testable import VibeviewerSettingsUI +import XCTest + +final class VibeviewerSettingsUITests: XCTestCase { + func testExample() { + XCTAssertTrue(true) + } +} diff --git a/参考计费/Packages/VibeviewerShareUI/Package.swift b/参考计费/Packages/VibeviewerShareUI/Package.swift new file mode 100644 index 0000000..6f6736a --- /dev/null +++ b/参考计费/Packages/VibeviewerShareUI/Package.swift @@ -0,0 +1,29 @@ +// swift-tools-version:5.10 +import PackageDescription + +let package = Package( + name: "VibeviewerShareUI", + platforms: [ + .macOS(.v14) + ], + products: [ + .library(name: "VibeviewerShareUI", targets: ["VibeviewerShareUI"]) + ], + dependencies: [ + .package(path: "../VibeviewerModel") + ], + targets: [ + .target( + name: "VibeviewerShareUI", + dependencies: ["VibeviewerModel"], + resources: [ + // 将自定义字体放入 Sources/VibeviewerShareUI/Fonts/ 下 + // 例如:Satoshi-Regular.otf、Satoshi-Medium.otf、Satoshi-Bold.otf、Satoshi-Italic.otf + .process("Fonts"), + .process("Images"), + .process("Shaders") + ] + ), + .testTarget(name: "VibeviewerShareUITests", dependencies: ["VibeviewerShareUI"]), + ] +) diff --git a/参考计费/Packages/VibeviewerShareUI/Sources/VibeviewerShareUI/Components/VibeButtonStyle.swift b/参考计费/Packages/VibeviewerShareUI/Sources/VibeviewerShareUI/Components/VibeButtonStyle.swift new file mode 100644 index 0000000..c361ccd --- /dev/null +++ b/参考计费/Packages/VibeviewerShareUI/Sources/VibeviewerShareUI/Components/VibeButtonStyle.swift @@ -0,0 +1,34 @@ +import SwiftUI + +public struct VibeButtonStyle: ButtonStyle { + var tintColor: Color + + @GestureState private var isPressing = false + private let pressScale: CGFloat = 0.94 + + @State private var isHovering: Bool = false + + public init(_ tint: Color) { + self.tintColor = tint + } + + public func makeBody(configuration: Configuration) -> some View { + configuration.label + .foregroundStyle(tintColor) + .font(.app(.satoshiMedium, size: 12)) + .padding(.horizontal, 12) + .padding(.vertical, 8) + .overlayBorder(color: tintColor.opacity(isHovering ? 1 : 0.4), lineWidth: 1, cornerRadius: 100) + .scaleEffect(configuration.isPressed || isPressing ? pressScale : 1.0) + .animation(.snappy(duration: 0.2), value: configuration.isPressed || isPressing) + .scaleEffect(isHovering ? 1.05 : 1.0) + .onHover { isHovering = $0 } + .animation(.easeInOut(duration: 0.2), value: isHovering) + } +} + +extension ButtonStyle where Self == VibeButtonStyle { + public static func vibe(_ tint: Color) -> Self { + VibeButtonStyle(tint) + } +} diff --git a/参考计费/Packages/VibeviewerShareUI/Sources/VibeviewerShareUI/Font+App.swift b/参考计费/Packages/VibeviewerShareUI/Sources/VibeviewerShareUI/Font+App.swift new file mode 100644 index 0000000..3271303 --- /dev/null +++ b/参考计费/Packages/VibeviewerShareUI/Sources/VibeviewerShareUI/Font+App.swift @@ -0,0 +1,26 @@ +import SwiftUI + +public enum AppFont: String, CaseIterable { + case satoshiRegular = "Satoshi-Regular" + case satoshiMedium = "Satoshi-Medium" + case satoshiBold = "Satoshi-Bold" + case satoshiItalic = "Satoshi-Italic" +} + +public extension Font { + /// Create a Font from AppFont with given size and optional relative weight. + static func app(_ font: AppFont, size: CGFloat, weight: Weight? = nil) -> Font { + FontsRegistrar.registerAllFonts() + let f = Font.custom(font.rawValue, size: size) + if let weight { + return f.weight(weight) + } + return f + } + + /// Convenience semantic fonts + static func appTitle(_ size: CGFloat = 20) -> Font { .app(.satoshiBold, size: size) } + static func appBody(_ size: CGFloat = 15) -> Font { .app(.satoshiRegular, size: size) } + static func appEmphasis(_ size: CGFloat = 15) -> Font { .app(.satoshiMedium, size: size) } + static func appCaption(_ size: CGFloat = 12) -> Font { .app(.satoshiRegular, size: size) } +} diff --git a/参考计费/Packages/VibeviewerShareUI/Sources/VibeviewerShareUI/Fonts/Satoshi-Bold.otf b/参考计费/Packages/VibeviewerShareUI/Sources/VibeviewerShareUI/Fonts/Satoshi-Bold.otf new file mode 100644 index 0000000..677ab5f Binary files /dev/null and b/参考计费/Packages/VibeviewerShareUI/Sources/VibeviewerShareUI/Fonts/Satoshi-Bold.otf differ diff --git a/参考计费/Packages/VibeviewerShareUI/Sources/VibeviewerShareUI/Fonts/Satoshi-Italic.otf b/参考计费/Packages/VibeviewerShareUI/Sources/VibeviewerShareUI/Fonts/Satoshi-Italic.otf new file mode 100644 index 0000000..d8652b3 Binary files /dev/null and b/参考计费/Packages/VibeviewerShareUI/Sources/VibeviewerShareUI/Fonts/Satoshi-Italic.otf differ diff --git a/参考计费/Packages/VibeviewerShareUI/Sources/VibeviewerShareUI/Fonts/Satoshi-Medium.otf b/参考计费/Packages/VibeviewerShareUI/Sources/VibeviewerShareUI/Fonts/Satoshi-Medium.otf new file mode 100644 index 0000000..3513a83 Binary files /dev/null and b/参考计费/Packages/VibeviewerShareUI/Sources/VibeviewerShareUI/Fonts/Satoshi-Medium.otf differ diff --git a/参考计费/Packages/VibeviewerShareUI/Sources/VibeviewerShareUI/Fonts/Satoshi-Regular.otf b/参考计费/Packages/VibeviewerShareUI/Sources/VibeviewerShareUI/Fonts/Satoshi-Regular.otf new file mode 100644 index 0000000..ddaadc0 Binary files /dev/null and b/参考计费/Packages/VibeviewerShareUI/Sources/VibeviewerShareUI/Fonts/Satoshi-Regular.otf differ diff --git a/参考计费/Packages/VibeviewerShareUI/Sources/VibeviewerShareUI/FontsRegistrar.swift b/参考计费/Packages/VibeviewerShareUI/Sources/VibeviewerShareUI/FontsRegistrar.swift new file mode 100644 index 0000000..edb6dbc --- /dev/null +++ b/参考计费/Packages/VibeviewerShareUI/Sources/VibeviewerShareUI/FontsRegistrar.swift @@ -0,0 +1,34 @@ +import CoreText +import Foundation + +public enum FontsRegistrar { + /// Registers all font files shipped in the module bundle under `Fonts/`. + /// Safe to call multiple times; duplicates are ignored by CoreText. + public static func registerAllFonts() { + let bundle = Bundle.module + let subdir = "Fonts" + + let otfURLs = bundle.urls(forResourcesWithExtension: "otf", subdirectory: subdir) ?? [] + let ttfURLs = bundle.urls(forResourcesWithExtension: "ttf", subdirectory: subdir) ?? [] + let urls = otfURLs + ttfURLs + + for url in urls { + var error: Unmanaged? + // Use process scope so registration lives for the app lifecycle + let ok = CTFontManagerRegisterFontsForURL(url as CFURL, .process, &error) + if !ok { + // Ignore already-registered errors; other errors can be logged in debug + #if DEBUG + if let err = error?.takeRetainedValue() { + let cfError = err as Error + // CFError domain kCTFontManagerErrorDomain code 305 means already registered + // We'll just print in debug builds + print( + "[FontsRegistrar] Font registration error for \(url.lastPathComponent): \(cfError)" + ) + } + #endif + } + } + } +} diff --git a/参考计费/Packages/VibeviewerShareUI/Sources/VibeviewerShareUI/Images/claude.pdf b/参考计费/Packages/VibeviewerShareUI/Sources/VibeviewerShareUI/Images/claude.pdf new file mode 100644 index 0000000..d5f25ff Binary files /dev/null and b/参考计费/Packages/VibeviewerShareUI/Sources/VibeviewerShareUI/Images/claude.pdf differ diff --git a/参考计费/Packages/VibeviewerShareUI/Sources/VibeviewerShareUI/Images/cursor.pdf b/参考计费/Packages/VibeviewerShareUI/Sources/VibeviewerShareUI/Images/cursor.pdf new file mode 100644 index 0000000..3a76d68 Binary files /dev/null and b/参考计费/Packages/VibeviewerShareUI/Sources/VibeviewerShareUI/Images/cursor.pdf differ diff --git a/参考计费/Packages/VibeviewerShareUI/Sources/VibeviewerShareUI/Images/deepseek.pdf b/参考计费/Packages/VibeviewerShareUI/Sources/VibeviewerShareUI/Images/deepseek.pdf new file mode 100644 index 0000000..210edd0 Binary files /dev/null and b/参考计费/Packages/VibeviewerShareUI/Sources/VibeviewerShareUI/Images/deepseek.pdf differ diff --git a/参考计费/Packages/VibeviewerShareUI/Sources/VibeviewerShareUI/Images/gemini.pdf b/参考计费/Packages/VibeviewerShareUI/Sources/VibeviewerShareUI/Images/gemini.pdf new file mode 100644 index 0000000..82cdb66 Binary files /dev/null and b/参考计费/Packages/VibeviewerShareUI/Sources/VibeviewerShareUI/Images/gemini.pdf differ diff --git a/参考计费/Packages/VibeviewerShareUI/Sources/VibeviewerShareUI/Images/gpt.pdf b/参考计费/Packages/VibeviewerShareUI/Sources/VibeviewerShareUI/Images/gpt.pdf new file mode 100644 index 0000000..999faef Binary files /dev/null and b/参考计费/Packages/VibeviewerShareUI/Sources/VibeviewerShareUI/Images/gpt.pdf differ diff --git a/参考计费/Packages/VibeviewerShareUI/Sources/VibeviewerShareUI/Images/grok.pdf b/参考计费/Packages/VibeviewerShareUI/Sources/VibeviewerShareUI/Images/grok.pdf new file mode 100644 index 0000000..b669ab2 Binary files /dev/null and b/参考计费/Packages/VibeviewerShareUI/Sources/VibeviewerShareUI/Images/grok.pdf differ diff --git a/参考计费/Packages/VibeviewerShareUI/Sources/VibeviewerShareUI/Images/kimi.pdf b/参考计费/Packages/VibeviewerShareUI/Sources/VibeviewerShareUI/Images/kimi.pdf new file mode 100644 index 0000000..21c129a Binary files /dev/null and b/参考计费/Packages/VibeviewerShareUI/Sources/VibeviewerShareUI/Images/kimi.pdf differ diff --git a/参考计费/Packages/VibeviewerShareUI/Sources/VibeviewerShareUI/Model+Logo.swift b/参考计费/Packages/VibeviewerShareUI/Sources/VibeviewerShareUI/Model+Logo.swift new file mode 100644 index 0000000..5805aa6 --- /dev/null +++ b/参考计费/Packages/VibeviewerShareUI/Sources/VibeviewerShareUI/Model+Logo.swift @@ -0,0 +1,53 @@ +import Foundation +import SwiftUI +import VibeviewerModel + +public extension AIModelBrands { + /// 从 SPM 模块资源中加载图片;macOS 直接以文件 URL 读取 PDF/PNG,避免命名查找失败 + private func moduleImage(_ name: String) -> Image { + #if canImport(AppKit) + if let url = Bundle.module.url(forResource: name, withExtension: "pdf"), + let nsImage = NSImage(contentsOf: url) { + return Image(nsImage: nsImage) + } + if let url = Bundle.module.url(forResource: name, withExtension: "png"), + let nsImage = NSImage(contentsOf: url) { + return Image(nsImage: nsImage) + } + // 回退占位(确保界面不会空白) + return Image(systemName: "app") + #else + if let url = Bundle.module.url(forResource: name, withExtension: "pdf"), + let data = try? Data(contentsOf: url), + let uiImage = UIImage(data: data) { + return Image(uiImage: uiImage) + } + if let url = Bundle.module.url(forResource: name, withExtension: "png"), + let data = try? Data(contentsOf: url), + let uiImage = UIImage(data: data) { + return Image(uiImage: uiImage) + } + // 回退占位(确保界面不会空白) + return Image(systemName: "app") + #endif + } + + var logo: Image { + switch self { + case .gpt: + return moduleImage("gpt") + case .claude: + return moduleImage("claude") + case .deepseek: + return moduleImage("deepseek") + case .gemini: + return moduleImage("gemini") + case .grok: + return moduleImage("grok").renderingMode(.template) + case .kimi: + return moduleImage("kimi") + case .default: + return moduleImage("cursor") + } + } +} \ No newline at end of file diff --git a/参考计费/Packages/VibeviewerShareUI/Sources/VibeviewerShareUI/Shaders/noise.metal b/参考计费/Packages/VibeviewerShareUI/Sources/VibeviewerShareUI/Shaders/noise.metal new file mode 100644 index 0000000..da96516 --- /dev/null +++ b/参考计费/Packages/VibeviewerShareUI/Sources/VibeviewerShareUI/Shaders/noise.metal @@ -0,0 +1,21 @@ +// +// noise.metal +// Goby +// +// Created by Groot on 2025/8/14. +// + +#include +#include +using namespace metal; + +[[ stitchable ]] +half4 parameterizedNoise(float2 position, half4 color, float intensity, float frequency, float opacity) { + float value = fract(cos(dot(position * frequency, float2(12.9898, 78.233))) * 43758.5453); + + float r = color.r * mix(1.0, value, intensity); + float g = color.g * mix(1.0, value, intensity); + float b = color.b * mix(1.0, value, intensity); + + return half4(r, g, b, opacity); +} diff --git a/参考计费/Packages/VibeviewerShareUI/Sources/VibeviewerShareUI/Shaders/ripple.metal b/参考计费/Packages/VibeviewerShareUI/Sources/VibeviewerShareUI/Shaders/ripple.metal new file mode 100644 index 0000000..d821186 --- /dev/null +++ b/参考计费/Packages/VibeviewerShareUI/Sources/VibeviewerShareUI/Shaders/ripple.metal @@ -0,0 +1,47 @@ +#include +#include +using namespace metal; + +[[ stitchable ]] +half4 Ripple( + float2 position, + SwiftUI::Layer layer, + float2 origin, + float time, + float amplitude, + float frequency, + float decay, + float speed +) { + // The distance of the current pixel position from `origin`. + float distance = length(position - origin); + // The amount of time it takes for the ripple to arrive at the current pixel position. + float delay = distance / speed; + + // Adjust for delay, clamp to 0. + time -= delay; + time = max(0.0, time); + + // The ripple is a sine wave that Metal scales by an exponential decay + // function. + float rippleAmount = amplitude * sin(frequency * time) * exp(-decay * time); + + // A vector of length `amplitude` that points away from position. + float2 n = normalize(position - origin); + + // Scale `n` by the ripple amount at the current pixel position and add it + // to the current pixel position. + // + // This new position moves toward or away from `origin` based on the + // sign and magnitude of `rippleAmount`. + float2 newPosition = position + rippleAmount * n; + + // Sample the layer at the new position. + half4 color = layer.sample(newPosition); + + // Lighten or darken the color based on the ripple amount and its alpha + // component. + color.rgb += 0.3 * (rippleAmount / amplitude) * color.a; + + return color; +} diff --git a/参考计费/Packages/VibeviewerShareUI/Sources/VibeviewerShareUI/ViewExtension/Appearance.swift b/参考计费/Packages/VibeviewerShareUI/Sources/VibeviewerShareUI/ViewExtension/Appearance.swift new file mode 100644 index 0000000..4ccc6a4 --- /dev/null +++ b/参考计费/Packages/VibeviewerShareUI/Sources/VibeviewerShareUI/ViewExtension/Appearance.swift @@ -0,0 +1,16 @@ +import VibeviewerModel +import SwiftUI + +public extension View { + @ViewBuilder + func applyPreferredColorScheme(_ appearance: VibeviewerModel.AppAppearance) -> some View { + switch appearance { + case .system: + self + case .light: + self.environment(\.colorScheme, .light) + case .dark: + self.environment(\.colorScheme, .dark) + } + } +} diff --git a/参考计费/Packages/VibeviewerShareUI/Sources/VibeviewerShareUI/ViewExtension/Color+E.swift b/参考计费/Packages/VibeviewerShareUI/Sources/VibeviewerShareUI/ViewExtension/Color+E.swift new file mode 100644 index 0000000..6174e49 --- /dev/null +++ b/参考计费/Packages/VibeviewerShareUI/Sources/VibeviewerShareUI/ViewExtension/Color+E.swift @@ -0,0 +1,28 @@ +import SwiftUI + +public extension Color { + init(hex: String) { + let hex = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted) + var int: UInt64 = 0 + Scanner(string: hex).scanHexInt64(&int) + let a, r, g, b: UInt64 + switch hex.count { + case 3: // RGB (12-bit) + (a, r, g, b) = (255, (int >> 8) * 17, (int >> 4 & 0xF) * 17, (int & 0xF) * 17) + case 6: // RGB (24-bit) + (a, r, g, b) = (255, int >> 16, int >> 8 & 0xFF, int & 0xFF) + case 8: // ARGB (32-bit) + (a, r, g, b) = (int >> 24, int >> 16 & 0xFF, int >> 8 & 0xFF, int & 0xFF) + default: + (a, r, g, b) = (1, 1, 1, 0) + } + + self.init( + .sRGB, + red: Double(r) / 255, + green: Double(g) / 255, + blue: Double(b) / 255, + opacity: Double(a) / 255 + ) + } +} diff --git a/参考计费/Packages/VibeviewerShareUI/Sources/VibeviewerShareUI/ViewExtension/MenubarTransparentBackground.swift b/参考计费/Packages/VibeviewerShareUI/Sources/VibeviewerShareUI/ViewExtension/MenubarTransparentBackground.swift new file mode 100644 index 0000000..d0f7c1e --- /dev/null +++ b/参考计费/Packages/VibeviewerShareUI/Sources/VibeviewerShareUI/ViewExtension/MenubarTransparentBackground.swift @@ -0,0 +1,105 @@ +import SwiftUI +import AppKit + +public struct MenuBarExtraTransparencyHelperView: NSViewRepresentable { + public init() {} + + public class WindowConfiguratorView: NSView { + public override func viewWillDraw() { + super.viewWillDraw() + self.configure(window: self.window) + } + public override func viewWillMove(toWindow newWindow: NSWindow?) { + super.viewWillMove(toWindow: newWindow) + self.configure(window: newWindow) + } + + public override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + self.configure(window: self.window) + } + + private func configure(window: NSWindow?) { + guard let window else { return } + + // Make the underlying Menu Bar Extra panel/window transparent + window.styleMask.insert(.fullSizeContentView) + window.isOpaque = false + window.backgroundColor = .clear + window.titleVisibility = .hidden + window.titlebarAppearsTransparent = true + window.hasShadow = true + + guard let contentView = window.contentView else { return } + // Ensure content view is fully transparent + contentView.wantsLayer = true + contentView.layer?.backgroundColor = NSColor.clear.cgColor + contentView.layer?.isOpaque = false + + // Clear any default backgrounds across the entire ancestor chain + self.clearBackgroundUpwards(from: contentView) + + // If you want translucent blur instead of fully transparent, uncomment the block below + // addBlur(in: contentView) + } + + private func clearBackgroundRecursively(in view: NSView?) { + guard let view else { return } + view.wantsLayer = true + view.layer?.backgroundColor = NSColor.clear.cgColor + view.layer?.isOpaque = false + + if let eff = view as? NSVisualEffectView, eff.identifier?.rawValue != "vv_transparent_blur" { + // 移除系统默认的模糊/材质背景视图,确保完全透明 + eff.removeFromSuperview() + return + } + + for sub in view.subviews { clearBackgroundRecursively(in: sub) } + } + + private func clearBackgroundUpwards(from view: NSView) { + var current: NSView? = view + while let node = current { + clearBackgroundRecursively(in: node) + current = node.superview + } + } + + private func addBlur(in contentView: NSView) { + let identifier = NSUserInterfaceItemIdentifier("vv_transparent_blur") + if contentView.subviews.contains(where: { $0.identifier == identifier }) { return } + + let blurView = NSVisualEffectView() + blurView.identifier = identifier + blurView.blendingMode = .withinWindow + blurView.state = .active + blurView.material = .hudWindow + blurView.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(blurView, positioned: .below, relativeTo: nil) + + NSLayoutConstraint.activate([ + blurView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), + blurView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), + blurView.topAnchor.constraint(equalTo: contentView.topAnchor), + blurView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor) + ]) + } + } + + public func makeNSView(context: Context) -> WindowConfiguratorView { + WindowConfiguratorView() + } + + public func updateNSView(_ nsView: WindowConfiguratorView, context: Context) { } +} + +public extension View { + /// 为 MenuBarExtra 的窗口启用透明背景(并添加系统模糊)。 + /// 使用方式:将其作为菜单根视图的一个背景层即可。 + func menuBarExtraTransparentBackground() -> some View { + self.background(MenuBarExtraTransparencyHelperView()) + } +} + + diff --git a/参考计费/Packages/VibeviewerShareUI/Sources/VibeviewerShareUI/ViewExtension/MenubarWindowCorner.swift b/参考计费/Packages/VibeviewerShareUI/Sources/VibeviewerShareUI/ViewExtension/MenubarWindowCorner.swift new file mode 100644 index 0000000..66f3725 --- /dev/null +++ b/参考计费/Packages/VibeviewerShareUI/Sources/VibeviewerShareUI/ViewExtension/MenubarWindowCorner.swift @@ -0,0 +1,104 @@ +import SwiftUI + +extension NSObject { + + /// Swap the given named instance method of the given named class with the given + /// named instance method of this class. + /// - Parameters: + /// - method: The name of the instance method whose implementation will be exchanged. + /// - className: The name of the class whose instance method implementation will be exchanged. + /// - newMethod: The name of the instance method on this class which will replace the first given method. + static func exchange(method: String, in className: String, for newMethod: String) { + guard let classRef = objc_getClass(className) as? AnyClass, + let original = class_getInstanceMethod(classRef, Selector((method))), + let replacement = class_getInstanceMethod(self, Selector((newMethod))) + else { + fatalError("Could not exchange method \(method) on class \(className)."); + } + + method_exchangeImplementations(original, replacement); + } + +} + +// MARK: - Custom Window Corner Mask Implementation + +/// Exchange Flag +/// +var __SwiftUIMenuBarExtraPanel___cornerMask__didExchange = false; + +/// Custom Corner Radius +/// +fileprivate let kWindowCornerRadius: CGFloat = 32; + +extension NSObject { + + @objc func __SwiftUIMenuBarExtraPanel___cornerMask() -> NSImage? { + let width = kWindowCornerRadius * 2; + let height = kWindowCornerRadius * 2; + + let image = NSImage(size: CGSizeMake(width, height)); + + image.lockFocus(); + + /// Draw a rounded-rectangle corner mask. + /// + NSColor.black.setFill(); + NSBezierPath( + roundedRect: CGRectMake(0, 0, width, height), + xRadius: kWindowCornerRadius, + yRadius: kWindowCornerRadius).fill(); + + image.unlockFocus(); + + image.capInsets = .init( + top: kWindowCornerRadius, + left: kWindowCornerRadius, + bottom: kWindowCornerRadius, + right: kWindowCornerRadius); + + return image; + } + +} + +// MARK: - Context Window Accessor + +public struct MenuBarExtraWindowHelperView: NSViewRepresentable { + + public init() {} + + public class WindowHelper: NSView { + + public override func viewWillDraw() { + if __SwiftUIMenuBarExtraPanel___cornerMask__didExchange { return } + + guard + let window: AnyObject = self.window, + let windowClass = window.className + else { return } + + + NSObject.exchange( + method: "_cornerMask", + in: windowClass, + for: "__SwiftUIMenuBarExtraPanel___cornerMask"); + + let _ = window.perform(Selector(("_cornerMaskChanged"))); + + __SwiftUIMenuBarExtraPanel___cornerMask__didExchange = true; + + } + + } + + public func updateNSView(_ nsView: WindowHelper, context: Context) { } + + public func makeNSView(context: Context) -> WindowHelper { WindowHelper() } +} + +public extension View { + func menuBarExtraWindowCorner() -> some View { + self.background(MenuBarExtraWindowHelperView()) + } +} \ No newline at end of file diff --git a/参考计费/Packages/VibeviewerShareUI/Sources/VibeviewerShareUI/ViewExtension/NoiseEffect.swift b/参考计费/Packages/VibeviewerShareUI/Sources/VibeviewerShareUI/ViewExtension/NoiseEffect.swift new file mode 100644 index 0000000..5f5d7f6 --- /dev/null +++ b/参考计费/Packages/VibeviewerShareUI/Sources/VibeviewerShareUI/ViewExtension/NoiseEffect.swift @@ -0,0 +1,25 @@ +import SwiftUI +import Foundation + +public extension View { + func noiseEffect(seed: Float, frequency: Float, amplitude: Float) -> some View { + self.modifier(NoiseEffectModifier(seed: seed, frequency: frequency, amplitude: amplitude)) + } +} + +public struct NoiseEffectModifier: ViewModifier { + var seed: Float + var frequency: Float + var amplitude: Float + + public init(seed: Float, frequency: Float, amplitude: Float) { + self.seed = seed + self.frequency = frequency + self.amplitude = amplitude + } + + public func body(content: Content) -> some View { + content + .colorEffect(ShaderLibrary.parameterizedNoise(.float(seed), .float(frequency), .float(amplitude))) + } +} \ No newline at end of file diff --git a/参考计费/Packages/VibeviewerShareUI/Sources/VibeviewerShareUI/ViewExtension/RippleEffect.swift b/参考计费/Packages/VibeviewerShareUI/Sources/VibeviewerShareUI/ViewExtension/RippleEffect.swift new file mode 100644 index 0000000..4476156 --- /dev/null +++ b/参考计费/Packages/VibeviewerShareUI/Sources/VibeviewerShareUI/ViewExtension/RippleEffect.swift @@ -0,0 +1,100 @@ +import SwiftUI + +public extension View { + func rippleEffect(at origin: CGPoint, isRunning: Bool, interval: TimeInterval) -> some View { + self.modifier(RippleEffect(at: origin, isRunning: isRunning, interval: interval)) + } +} + +@MainActor +struct RippleEffect: ViewModifier { + var origin: CGPoint + + var isRunning: Bool + var interval: TimeInterval + + @State private var tick: Int = 0 + + init(at origin: CGPoint, isRunning: Bool, interval: TimeInterval) { + self.origin = origin + self.isRunning = isRunning + self.interval = interval + } + + func body(content: Content) -> some View { + let origin = origin + let animationDuration = animationDuration + + return content + .keyframeAnimator( + initialValue: 0, + trigger: tick + ) { view, elapsedTime in + view.modifier(RippleModifier( + origin: origin, + elapsedTime: elapsedTime, + duration: animationDuration + )) + } keyframes: { _ in + MoveKeyframe(0) + CubicKeyframe(animationDuration, duration: animationDuration) + } + .task(id: isRunning ? interval : -1.0) { + guard isRunning else { return } + while !Task.isCancelled && isRunning { + try? await Task.sleep(nanoseconds: UInt64(interval * 1_000_000_000)) + if isRunning { + tick &+= 1 + } + } + } + } + + var animationDuration: TimeInterval { 3 } +} + +struct RippleModifier: ViewModifier { + var origin: CGPoint + + var elapsedTime: TimeInterval + + var duration: TimeInterval + + var amplitude: Double = 12 + var frequency: Double = 15 + var decay: Double = 8 + var speed: Double = 1200 + + func body(content: Content) -> some View { + let shader = ShaderLibrary.Ripple( + .float2(origin), + .float(elapsedTime), + .float(amplitude), + .float(frequency), + .float(decay), + .float(speed) + ) + + let maxSampleOffset = maxSampleOffset + let elapsedTime = elapsedTime + let duration = duration + + content.visualEffect { view, _ in + view.layerEffect( + shader, + maxSampleOffset: maxSampleOffset, + isEnabled: 0 < elapsedTime && elapsedTime < duration + ) + } + } + + var maxSampleOffset: CGSize { + CGSize(width: amplitude, height: amplitude) + } +} + +struct NoiseEffect: ViewModifier { + func body(content: Content) -> some View { + content + } +} \ No newline at end of file diff --git a/参考计费/Packages/VibeviewerShareUI/Sources/VibeviewerShareUI/ViewExtension/View+E.swift b/参考计费/Packages/VibeviewerShareUI/Sources/VibeviewerShareUI/ViewExtension/View+E.swift new file mode 100644 index 0000000..a7e7803 --- /dev/null +++ b/参考计费/Packages/VibeviewerShareUI/Sources/VibeviewerShareUI/ViewExtension/View+E.swift @@ -0,0 +1,135 @@ +import SwiftUI +import Foundation + +public extension View { + func maxFrame( + _ width: Bool = true, _ height: Bool = true, alignment: SwiftUI.Alignment = .center + ) -> some View { + Group { + if width, height { + frame(maxWidth: .infinity, maxHeight: .infinity, alignment: alignment) + } else if width { + frame(maxWidth: .infinity, alignment: alignment) + } else if height { + frame(maxHeight: .infinity, alignment: alignment) + } else { + self + } + } + } + + func cornerRadiusWithCorners( + _ radius: CGFloat, corners: RectCorner = .allCorners + ) -> some View { + clipShape(RoundedCorner(radius: radius, corners: corners)) + } + + func linearBorder( + color: Color, cornerRadius: CGFloat, lineWidth: CGFloat = 1, from: UnitPoint = .top, + to: UnitPoint = .center + ) -> some View { + overlay( + RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) + .inset(by: lineWidth) + .stroke( + LinearGradient( + stops: [ + .init(color: color.opacity(0.1), location: 0), + .init(color: color.opacity(0.02), location: 0.5), + .init(color: color.opacity(0.06), location: 1), + ], startPoint: from, endPoint: to), + lineWidth: lineWidth + ) + + ) + } + + func linearBorder( + stops: [Gradient.Stop], cornerRadius: CGFloat, lineWidth: CGFloat = 1, + from: UnitPoint = .top, to: UnitPoint = .center + ) -> some View { + overlay( + RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) + .inset(by: lineWidth) + .stroke( + LinearGradient(stops: stops, startPoint: from, endPoint: to), + lineWidth: lineWidth + ) + + ) + } + + func overlayBorder( + color: Color, + lineWidth: CGFloat = 1, + insets: CGFloat = 0, + cornerRadius: CGFloat = 0, + hidden: Bool = false + ) -> some View { + overlay( + RoundedCorner(radius: cornerRadius, corners: .allCorners) + .fill(color) + .mask( + RoundedCorner(radius: cornerRadius, corners: .allCorners) + .stroke(style: .init(lineWidth: lineWidth)) + ) + .allowsHitTesting(false) + .padding(insets) + ) + } + + func extendTapGesture(_ value: CGFloat = 8, _ action: @escaping () -> Void) -> some View { + self + .padding(value) + .contentShape(Rectangle()) + .onTapGesture { + action() + } + .padding(-value) + } +} + +public struct RectCorner: OptionSet, Sendable { + public let rawValue: Int + public init(rawValue: Int) { self.rawValue = rawValue } + + public static let topLeft: RectCorner = RectCorner(rawValue: 1 << 0) + public static let topRight: RectCorner = RectCorner(rawValue: 1 << 1) + public static let bottomLeft: RectCorner = RectCorner(rawValue: 1 << 2) + public static let bottomRight: RectCorner = RectCorner(rawValue: 1 << 3) + public static let allCorners: RectCorner = [.topLeft, .topRight, .bottomLeft, .bottomRight] +} + +struct RoundedCorner: Shape, InsettableShape { + var radius: CGFloat + var corners: RectCorner = .allCorners + + func path(in rect: CGRect) -> Path { + let topLeft = corners.contains(.topLeft) ? radius : 0 + let topRight = corners.contains(.topRight) ? radius : 0 + let bottomLeft = corners.contains(.bottomLeft) ? radius : 0 + let bottomRight = corners.contains(.bottomRight) ? radius : 0 + + if #available(iOS 17.0, macOS 14.0, *) { + return UnevenRoundedRectangle( + topLeadingRadius: topLeft, + bottomLeadingRadius: bottomLeft, + bottomTrailingRadius: bottomRight, + topTrailingRadius: topRight, + style: .continuous + ).path(in: rect) + } else { + if corners == .allCorners { + return RoundedRectangle(cornerRadius: radius, style: .continuous).path(in: rect) + } else { + return Path(rect) + } + } + } + + nonisolated func inset(by amount: CGFloat) -> some InsettableShape { + var shape = self + shape.radius -= amount + return shape + } +} \ No newline at end of file diff --git a/参考计费/Packages/VibeviewerShareUI/Tests/VibeviewerShareUITests/PlaceholderTests.swift b/参考计费/Packages/VibeviewerShareUI/Tests/VibeviewerShareUITests/PlaceholderTests.swift new file mode 100644 index 0000000..ff2d7e3 --- /dev/null +++ b/参考计费/Packages/VibeviewerShareUI/Tests/VibeviewerShareUITests/PlaceholderTests.swift @@ -0,0 +1,5 @@ +import Testing + +@Test func placeholderCompiles() { + #expect(true) +} diff --git a/参考计费/Packages/VibeviewerStorage/.swiftpm/xcode/xcshareddata/xcschemes/VibeviewerStorageTests.xcscheme b/参考计费/Packages/VibeviewerStorage/.swiftpm/xcode/xcshareddata/xcschemes/VibeviewerStorageTests.xcscheme new file mode 100644 index 0000000..ace92c2 --- /dev/null +++ b/参考计费/Packages/VibeviewerStorage/.swiftpm/xcode/xcshareddata/xcschemes/VibeviewerStorageTests.xcscheme @@ -0,0 +1,73 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/参考计费/Packages/VibeviewerStorage/Package.swift b/参考计费/Packages/VibeviewerStorage/Package.swift new file mode 100644 index 0000000..fd7b0e5 --- /dev/null +++ b/参考计费/Packages/VibeviewerStorage/Package.swift @@ -0,0 +1,29 @@ +// swift-tools-version: 5.10 +import PackageDescription + +let package = Package( + name: "VibeviewerStorage", + platforms: [ + .macOS(.v14) + ], + products: [ + .library(name: "VibeviewerStorage", targets: ["VibeviewerStorage"]) + ], + dependencies: [ + .package(path: "../VibeviewerModel") + ], + targets: [ + .target( + name: "VibeviewerStorage", + dependencies: [ + .product(name: "VibeviewerModel", package: "VibeviewerModel") + ] + ), + .testTarget( + name: "VibeviewerStorageTests", + dependencies: ["VibeviewerStorage"] + ) + ] +) + + diff --git a/参考计费/Packages/VibeviewerStorage/Sources/VibeviewerStorage/AppSettings+Save.swift b/参考计费/Packages/VibeviewerStorage/Sources/VibeviewerStorage/AppSettings+Save.swift new file mode 100644 index 0000000..b5cafd4 --- /dev/null +++ b/参考计费/Packages/VibeviewerStorage/Sources/VibeviewerStorage/AppSettings+Save.swift @@ -0,0 +1,8 @@ +import Foundation +import VibeviewerModel + +public extension AppSettings { + func save(using storage: any CursorStorageService) async throws { + try await storage.saveSettings(self) + } +} diff --git a/参考计费/Packages/VibeviewerStorage/Sources/VibeviewerStorage/CursorStorageService.swift b/参考计费/Packages/VibeviewerStorage/Sources/VibeviewerStorage/CursorStorageService.swift new file mode 100644 index 0000000..d687feb --- /dev/null +++ b/参考计费/Packages/VibeviewerStorage/Sources/VibeviewerStorage/CursorStorageService.swift @@ -0,0 +1,34 @@ +import Foundation +import VibeviewerModel + +// Service Protocol (exposed) +public protocol CursorStorageService: Sendable { + // Credentials + func saveCredentials(_ creds: Credentials) async throws + func loadCredentials() async -> Credentials? + func clearCredentials() async + + // Dashboard Snapshot + func saveDashboardSnapshot(_ snapshot: DashboardSnapshot) async throws + func loadDashboardSnapshot() async -> DashboardSnapshot? + func clearDashboardSnapshot() async + + // App Settings + func saveSettings(_ settings: AppSettings) async throws + func loadSettings() async -> AppSettings + + // Billing Cycle + func saveBillingCycle(startDateMs: String, endDateMs: String) async throws + func loadBillingCycle() async -> (startDateMs: String, endDateMs: String)? + func clearBillingCycle() async + + // AppSession Management + func clearAppSession() async +} + +// Synchronous preload helpers for app launch use-cases +public protocol CursorStorageSyncHelpers { + static func loadCredentialsSync() -> Credentials? + static func loadDashboardSnapshotSync() -> DashboardSnapshot? + static func loadSettingsSync() -> AppSettings +} diff --git a/参考计费/Packages/VibeviewerStorage/Sources/VibeviewerStorage/DefaultCursorStorageService.swift b/参考计费/Packages/VibeviewerStorage/Sources/VibeviewerStorage/DefaultCursorStorageService.swift new file mode 100644 index 0000000..4f2cf13 --- /dev/null +++ b/参考计费/Packages/VibeviewerStorage/Sources/VibeviewerStorage/DefaultCursorStorageService.swift @@ -0,0 +1,117 @@ +import Foundation +import VibeviewerModel + +public enum CursorStorageKeys { + public static let credentials = "cursor.credentials.v1" + public static let settings = "app.settings.v1" + public static let dashboardSnapshot = "cursor.dashboard.snapshot.v1" + public static let billingCycle = "cursor.billing.cycle.v1" +} + +public struct DefaultCursorStorageService: CursorStorageService, CursorStorageSyncHelpers { + private let defaults: UserDefaults + + public init(userDefaults: UserDefaults = .standard) { + self.defaults = userDefaults + } + + // MARK: - Credentials + + public func saveCredentials(_ me: Credentials) async throws { + let data = try JSONEncoder().encode(me) + self.defaults.set(data, forKey: CursorStorageKeys.credentials) + } + + public func loadCredentials() async -> Credentials? { + guard let data = self.defaults.data(forKey: CursorStorageKeys.credentials) else { return nil } + return try? JSONDecoder().decode(Credentials.self, from: data) + } + + public func clearCredentials() async { + self.defaults.removeObject(forKey: CursorStorageKeys.credentials) + } + + // MARK: - Dashboard Snapshot + + public func saveDashboardSnapshot(_ snapshot: DashboardSnapshot) async throws { + let data = try JSONEncoder().encode(snapshot) + self.defaults.set(data, forKey: CursorStorageKeys.dashboardSnapshot) + } + + public func loadDashboardSnapshot() async -> DashboardSnapshot? { + guard let data = self.defaults.data(forKey: CursorStorageKeys.dashboardSnapshot) else { return nil } + return try? JSONDecoder().decode(DashboardSnapshot.self, from: data) + } + + public func clearDashboardSnapshot() async { + self.defaults.removeObject(forKey: CursorStorageKeys.dashboardSnapshot) + } + + // MARK: - App Settings + + public func saveSettings(_ settings: AppSettings) async throws { + let data = try JSONEncoder().encode(settings) + self.defaults.set(data, forKey: CursorStorageKeys.settings) + } + + public func loadSettings() async -> AppSettings { + if let data = self.defaults.data(forKey: CursorStorageKeys.settings), + let decoded = try? JSONDecoder().decode(AppSettings.self, from: data) + { + return decoded + } + return AppSettings() + } + + // MARK: - Billing Cycle + + public func saveBillingCycle(startDateMs: String, endDateMs: String) async throws { + let data: [String: String] = [ + "startDateMs": startDateMs, + "endDateMs": endDateMs + ] + let jsonData = try JSONEncoder().encode(data) + self.defaults.set(jsonData, forKey: CursorStorageKeys.billingCycle) + } + + public func loadBillingCycle() async -> (startDateMs: String, endDateMs: String)? { + guard let data = self.defaults.data(forKey: CursorStorageKeys.billingCycle), + let dict = try? JSONDecoder().decode([String: String].self, from: data), + let startDateMs = dict["startDateMs"], + let endDateMs = dict["endDateMs"] else { + return nil + } + return (startDateMs: startDateMs, endDateMs: endDateMs) + } + + public func clearBillingCycle() async { + self.defaults.removeObject(forKey: CursorStorageKeys.billingCycle) + } + + // MARK: - AppSession Management + + public func clearAppSession() async { + await clearCredentials() + await clearDashboardSnapshot() + } + + // MARK: - Sync Helpers + + public static func loadCredentialsSync() -> Credentials? { + let defaults = UserDefaults.standard + guard let data = defaults.data(forKey: CursorStorageKeys.credentials) else { return nil } + return try? JSONDecoder().decode(Credentials.self, from: data) + } + + public static func loadDashboardSnapshotSync() -> DashboardSnapshot? { + let defaults = UserDefaults.standard + guard let data = defaults.data(forKey: CursorStorageKeys.dashboardSnapshot) else { return nil } + return try? JSONDecoder().decode(DashboardSnapshot.self, from: data) + } + + public static func loadSettingsSync() -> AppSettings { + let defaults = UserDefaults.standard + guard let data = defaults.data(forKey: CursorStorageKeys.settings) else { return AppSettings() } + return (try? JSONDecoder().decode(AppSettings.self, from: data)) ?? AppSettings() + } +} diff --git a/参考计费/Packages/VibeviewerStorage/Tests/VibeviewerStorageTests/StorageServiceTests.swift b/参考计费/Packages/VibeviewerStorage/Tests/VibeviewerStorageTests/StorageServiceTests.swift new file mode 100644 index 0000000..6c7ec32 --- /dev/null +++ b/参考计费/Packages/VibeviewerStorage/Tests/VibeviewerStorageTests/StorageServiceTests.swift @@ -0,0 +1,37 @@ +import Foundation +import Testing +import VibeviewerModel +@testable import VibeviewerStorage + +@Suite("StorageService basic") +struct StorageServiceTests { + @Test("Credentials save/load/clear") + func credentialsCRUD() async throws { + let suite = UserDefaults(suiteName: "test.credentials.")! + suite.removePersistentDomain(forName: "test.credentials.") + let storage = DefaultCursorStorageService(userDefaults: suite) + + let creds = Credentials(userId: 123_456, workosId: "w1", email: "e@x.com", teamId: 1, cookieHeader: "c", isEnterpriseUser: false) + try await storage.saveCredentials(creds) + let loaded = await storage.loadCredentials() + #expect(loaded == creds) + await storage.clearCredentials() + let cleared = await storage.loadCredentials() + #expect(cleared == nil) + } + + @Test("Snapshot save/load/clear") + func snapshotCRUD() async throws { + let suite = UserDefaults(suiteName: "test.snapshot.")! + suite.removePersistentDomain(forName: "test.snapshot.") + let storage = DefaultCursorStorageService(userDefaults: suite) + + let snap = DashboardSnapshot(email: "e@x.com", totalRequestsAllModels: 2, spendingCents: 3, hardLimitDollars: 4) + try await storage.saveDashboardSnapshot(snap) + let loaded = await storage.loadDashboardSnapshot() + #expect(loaded == snap) + await storage.clearDashboardSnapshot() + let cleared = await storage.loadDashboardSnapshot() + #expect(cleared == nil) + } +} diff --git a/参考计费/Project.swift b/参考计费/Project.swift new file mode 100644 index 0000000..743c5d2 --- /dev/null +++ b/参考计费/Project.swift @@ -0,0 +1,72 @@ +import ProjectDescription + +let workspaceName = "Vibeviewer" + +// 版本号统一配置 - 只在这里修改版本号 +let appVersion = "1.1.11" + +let project = Project( + name: workspaceName, + organizationName: "Vibeviewer", + options: .options( + developmentRegion: "en", + disableBundleAccessors: false, + disableSynthesizedResourceAccessors: false + ), + packages: [ + .local(path: "Packages/VibeviewerCore"), + .local(path: "Packages/VibeviewerModel"), + .local(path: "Packages/VibeviewerAPI"), + .local(path: "Packages/VibeviewerLoginUI"), + .local(path: "Packages/VibeviewerMenuUI"), + .local(path: "Packages/VibeviewerSettingsUI"), + .local(path: "Packages/VibeviewerAppEnvironment"), + .local(path: "Packages/VibeviewerStorage"), + .local(path: "Packages/VibeviewerShareUI"), + ], + settings: .settings(base: [ + "SWIFT_VERSION": .string("5.10"), + "MACOSX_DEPLOYMENT_TARGET": .string("14.0"), + // 代码签名配置 - 确保 Release 构建使用相同的签名 + "CODE_SIGN_IDENTITY": .string("$(CODE_SIGN_IDENTITY)"), + "CODE_SIGN_STYLE": .string("Automatic"), + "DEVELOPMENT_TEAM": .string("$(DEVELOPMENT_TEAM)"), + // 版本信息 - 使用统一的版本号常量 + "MARKETING_VERSION": .string(appVersion), + "CURRENT_PROJECT_VERSION": .string(appVersion), + ]), + targets: [ + .target( + name: workspaceName, + destinations: .macOS, + product: .app, + bundleId: "com.magicgroot.vibeviewer", + deploymentTargets: .macOS("14.0"), + infoPlist: .extendingDefault(with: [ + "LSUIElement": .boolean(true), + "LSMinimumSystemVersion": .string("14.0"), + "LSApplicationCategoryType": .string("public.app-category.productivity"), + "UIAppFonts": .array([.string("Satoshi-Regular.ttf"), .string("Satoshi-Medium.ttf"), .string("Satoshi-Bold.ttf"), .string("Satoshi-Italic.ttf")]), + // 版本信息 - 使用统一的版本号常量 + "CFBundleShortVersionString": .string(appVersion), + "CFBundleVersion": .string(appVersion), + ]), + sources: ["Vibeviewer/**"], + resources: [ + "Vibeviewer/Assets.xcassets", + "Vibeviewer/Preview Content/**", + ], + dependencies: [ + .package(product: "VibeviewerAPI"), + .package(product: "VibeviewerModel"), + .package(product: "VibeviewerCore"), + .package(product: "VibeviewerLoginUI"), + .package(product: "VibeviewerMenuUI"), + .package(product: "VibeviewerSettingsUI"), + .package(product: "VibeviewerAppEnvironment"), + .package(product: "VibeviewerStorage"), + .package(product: "VibeviewerShareUI"), + ] + ) + ] +) diff --git a/参考计费/README.md b/参考计费/README.md new file mode 100644 index 0000000..fa9265c --- /dev/null +++ b/参考计费/README.md @@ -0,0 +1,203 @@ +## Vibeviewer + +English | [简体中文](README.zh-CN.md) + +![Swift](https://img.shields.io/badge/Swift-5.10-orange?logo=swift) +![Xcode](https://img.shields.io/badge/Xcode-15.4%2B-blue?logo=xcode) +![macOS](https://img.shields.io/badge/macOS-14%2B-black?logo=apple) +![License](https://img.shields.io/badge/License-MIT-green) +![Release](https://img.shields.io/badge/Release-DMG-purple) + +![Preview](Images/image.png) + +**Tags**: `swift`, `swiftui`, `xcode`, `tuist`, `macos`, `menu-bar`, `release` + +An open-source macOS menu bar app that surfaces workspace/team usage and spend at a glance, with sign-in, settings, auto-refresh, and sharing capabilities. The project follows a modular Swift Package architecture and a pure SwiftUI MV (not MVVM) approach, emphasizing clear boundaries and replaceability. + +### Features +- **Menu bar summary**: Popover shows key metrics and recent activity: + - **Billing overview**, **Free usage** (when available), **On‑demand** (when available) + - **Total credits usage** with smooth numeric transitions + - **Requests compare (Today vs Yesterday)** and **Usage events** + - Top actions: **Open Dashboard** and **Log out** +- **Sign-in & Settings**: Dedicated windows with persisted credentials and preferences. +- **Power-aware refresh**: Smart refresh strategy reacting to screen power/activity state. +- **Modular architecture**: One-way dependencies Core ← Model ← API ← Feature; DTO→Domain mapping lives in API only. +- **Sharing components**: Built-in fonts and assets to generate shareable views. + +### Notes +- Currently developed and tested against **team accounts** only. Individual/free accounts are not yet verified — contributions for compatibility are welcome. +- Thanks to the modular layered design, although Cursor is the present data source, other similar apps can be integrated by implementing the corresponding data-layer interfaces — PRs are welcome. +- The app currently has no logo — designers are welcome to contribute one. + +> Brand and data sources are for demonstration. The UI never sees concrete networking implementations — only service protocols and default implementations are exposed. + +--- + +## Architecture & Structure + +Workspace with multiple Swift Packages (one-way dependency: Core ← Model ← API ← Feature): + +``` +Vibeviewer/ +├─ Vibeviewer.xcworkspace # Open this workspace +├─ Vibeviewer/ # Thin app shell (entry only) +├─ Packages/ +│ ├─ VibeviewerCore/ # Core: utilities/extensions/shared services +│ ├─ VibeviewerModel/ # Model: pure domain entities (value types/Sendable) +│ ├─ VibeviewerAPI/ # API: networking/IO + DTO→Domain mapping (protocols exposed) +│ ├─ VibeviewerAppEnvironment/ # Environment injection & cross-feature services +│ ├─ VibeviewerStorage/ # Storage (settings, credentials, etc.) +│ ├─ VibeviewerLoginUI/ # Feature: login UI +│ ├─ VibeviewerMenuUI/ # Feature: menu popover UI (main) +│ ├─ VibeviewerSettingsUI/ # Feature: settings UI +│ └─ VibeviewerShareUI/ # Feature: sharing components & assets +└─ Scripts/ & Makefile # Tuist generation, clean, DMG packaging +``` + +Key rules (see also `./.cursor/rules/architecture.mdc`): +- **Placement & responsibility** + - Core/Shared → utilities & extensions + - Model → pure domain data + - API/Service → networking/IO/3rd-party orchestration and DTO→Domain mapping + - Feature/UI → SwiftUI views & interactions consuming service protocols and domain models only +- **Dependency direction**: Core ← Model ← API ← Feature (no reverse dependencies) +- **Replaceability**: API exposes service protocols + default impl; UI injects via `@Environment`, never references networking libs directly +- **SwiftUI MV**: + - Use `@State`/`@Observable`/`@Environment`/`@Binding` for state + - Side effects in `.task`/`.onChange` (lifecycle-aware cancellation) + - Avoid default ViewModel layer (no MVVM by default) + +--- + +## Requirements + +- macOS 14.0+ +- Xcode 15.4+ (`SWIFT_VERSION = 5.10`) +- Tuist + +Install Tuist if needed: + +```bash +brew tap tuist/tuist && brew install tuist +``` + +--- + +## Getting Started + +1) Generate the Xcode workspace: + +```bash +make generate +# or +Scripts/generate.sh +``` + +2) Open and run: + +```bash +open Vibeviewer.xcworkspace +# In Xcode: scheme = Vibeviewer, destination = My Mac (macOS), then Run +``` + +3) Build/package via CLI (optional): + +```bash +make build # Release build (macOS) +make dmg # Create DMG package +make release # Clean → Generate → Build → Package +make release-full # Full automated release (build + tag + GitHub release) +``` + +4) Release process: + +See [Scripts/RELEASE_GUIDE.md](Scripts/RELEASE_GUIDE.md) for detailed release instructions. + +Quick release: +```bash +./Scripts/release.sh [VERSION] # Automated release workflow +``` + +--- + +## Run & Debug + +- The menu bar shows the icon and key metrics; click to open the popover. +- Sign-in and Settings windows are provided via environment-injected window managers (see `.environment(...)` in `VibeviewerApp.swift`). +- Auto-refresh starts on app launch and reacts to screen power/activity changes. + +--- + +## Testing + +Each package ships its own tests. Run from Xcode or via CLI per package: + +```bash +swift test --package-path Packages/VibeviewerCore +swift test --package-path Packages/VibeviewerModel +swift test --package-path Packages/VibeviewerAPI +swift test --package-path Packages/VibeviewerAppEnvironment +swift test --package-path Packages/VibeviewerStorage +swift test --package-path Packages/VibeviewerLoginUI +swift test --package-path Packages/VibeviewerMenuUI +swift test --package-path Packages/VibeviewerSettingsUI +swift test --package-path Packages/VibeviewerShareUI +``` + +> Tip: after adding/removing packages, run `make generate` first. + +--- + +## Contributing + +Issues and PRs are welcome. To keep the codebase consistent and maintainable: + +1) Branch & commits +- Use branches like `feat/...` or `fix/...`. +- Prefer Conventional Commits (e.g., `feat: add dashboard refresh service`). + +2) Architecture agreements +- Read `./.cursor/rules/architecture.mdc` and this README before changes. +- Place new code in the proper layer (UI/Service/Model/Core). One primary type per file. +- API layer exposes service protocols + default impl only; DTOs stay internal; UI uses domain models only. + +3) Self-check +- `make generate` works and the workspace opens +- `make build` succeeds (or Release build in Xcode) +- `swift test` passes for related packages +- No reverse dependencies; UI never imports networking implementations + +4) PR +- Describe motivation, touched modules, and impacts +- Include screenshots/clips for UI changes +- Prefer small, focused PRs + +--- + +## FAQ + +- Q: Missing targets or workspace won’t open? + - A: Run `make generate` (or `Scripts/generate.sh`). + +- Q: Tuist command not found? + - A: Install via Homebrew as above. + +- Q: Swift version mismatch during build? + - A: Use Xcode 15.4+ (Swift 5.10). If issues persist, run `Scripts/clear.sh` then `make generate`. + +--- + +## License + +This project is open-sourced under the MIT License. See `LICENSE` for details. + +--- + +## Acknowledgements + +Thanks to the community for contributions to modular Swift packages, SwiftUI, and developer tooling — and thanks for helping improve Vibeviewer! + +UI inspiration from X user @hi_caicai — see [Minto: Vibe Coding Tracker](https://apps.apple.com/ca/app/minto-vibe-coding-tracker/id6749605275?mt=12). + + diff --git a/参考计费/README.zh-CN.md b/参考计费/README.zh-CN.md new file mode 100644 index 0000000..5f939a0 --- /dev/null +++ b/参考计费/README.zh-CN.md @@ -0,0 +1,203 @@ +## Vibeviewer + +[English](README.md) | 简体中文 + +![Swift](https://img.shields.io/badge/Swift-5.10-orange?logo=swift) +![Xcode](https://img.shields.io/badge/Xcode-15.4%2B-blue?logo=xcode) +![macOS](https://img.shields.io/badge/macOS-14%2B-black?logo=apple) +![License](https://img.shields.io/badge/License-MIT-green) +![Release](https://img.shields.io/badge/Release-DMG-purple) + +![Preview](Images/image.png) + +**标签**: `swift`, `swiftui`, `xcode`, `tuist`, `macos`, `menu-bar`, `release` + +一个开源的 macOS 菜单栏应用,用来在系统状态栏中快速查看与分享「工作区/团队」的使用与支出概览,并提供登录、设置、自动刷新与分享能力。项目采用模块化的 Swift Package 架构与纯 SwiftUI MV(非 MVVM)范式,专注于清晰的边界与可替换性。 + +### 亮点特性 +- **菜单栏概览**:点击弹窗查看关键指标与最近活动: + - **计费总览**、**免费额度**(如存在)与 **按需额度**(如存在) + - **总消耗金额(Total Credits Usage)**,支持数值过渡动画 + - **请求对比(今日 vs 昨日)** 与 **使用事件流水** + - 顶部快捷操作:**打开 Dashboard**、**退出登录** +- **登录与设置**:独立的登录与设置窗口,支持持久化凭据与应用偏好。 +- **自动刷新**:基于屏幕电源/活跃状态的智能刷新策略,节能且及时。 +- **模块化架构**:Core ← Model ← API ← Feature 单向依赖,DTO→Domain 映射仅在 API 层完成。 +- **分享组件**:内置字体与图形资源,便捷生成分享视图。 + +### 注意 +- 项目目前仅基于**团队账号**进行开发测试,对于个人会员账号以及 free 版本账号还未经测试,欢迎提交适配与优化。 +- 项目基于模块化分层原则,虽然目前支持 Cursor 作为数据源,对其他相同类型 App 来说,理论上只要实现了数据层的对应方法,即可无缝适配,欢迎 PR。 +- 应用目前没有 Logo,欢迎设计大佬贡献一个 Logo。 + +> 品牌与数据来源仅用于演示。项目不会向 UI 层暴露具体的网络实现,遵循「服务协议 + 默认实现」的封装原则。 + +--- + +## 架构与目录结构 + +项目采用 Workspace + 多个 Swift Packages 的分层设计(单向依赖:Core ← Model ← API ← Feature): + +``` +Vibeviewer/ +├─ Vibeviewer.xcworkspace # 打开此工作区进行开发 +├─ Vibeviewer/ # App 外壳(很薄,仅入口) +├─ Packages/ +│ ├─ VibeviewerCore/ # Core:工具/扩展/通用服务(不依赖业务层) +│ ├─ VibeviewerModel/ # Model:纯领域实体(值类型/Sendable) +│ ├─ VibeviewerAPI/ # API:网络/IO + DTO→Domain 映射(对外仅暴露服务协议) +│ ├─ VibeviewerAppEnvironment/ # 环境注入与跨特性服务入口 +│ ├─ VibeviewerStorage/ # 存储(设置、凭据等) +│ ├─ VibeviewerLoginUI/ # Feature:登录相关 UI +│ ├─ VibeviewerMenuUI/ # Feature:菜单栏弹窗 UI(主界面) +│ ├─ VibeviewerSettingsUI/ # Feature:设置界面 UI +│ └─ VibeviewerShareUI/ # Feature:分享组件与资源 +└─ Scripts/ & Makefile # Tuist 生成、清理、打包 DMG 的脚本 +``` + +关键原则与约束(强烈建议在提交前阅读 `./.cursor/rules/architecture.mdc`): +- **分层与职责**: + - Core/Shared → 工具与扩展 + - Model → 纯数据/领域实体 + - API/Service → 网络/IO/三方编排以及 DTO→Domain 映射 + - Feature/UI → SwiftUI 视图与交互,仅依赖服务协议和领域模型 +- **依赖方向**:仅允许自上而下(Core ← Model ← API ← Feature),严禁反向依赖。 +- **可替换性**:API 层仅暴露服务协议与默认实现;UI 通过 `@Environment` 注入,不直接引用网络库。 +- **SwiftUI MV 模式**: + - 使用 `@State`/`@Observable`/`@Environment`/`@Binding` 管理状态 + - 异步副作用使用 `.task`/`.onChange`,自动随视图生命周期取消 + - 不引入默认的 ViewModel 层(避免 MVVM 依赖) + +--- + +## 开发环境 + +- macOS 14.0+ +- Xcode 15.4+(`SWIFT_VERSION = 5.10`) +- Tuist(项目生成与管理) + +安装 Tuist(若未安装): + +```bash +brew tap tuist/tuist && brew install tuist +``` + +--- + +## 快速开始 + +1) 生成 Xcode 工作区: + +```bash +make generate +# 或者 +Scripts/generate.sh +``` + +2) 打开工作区并运行: + +```bash +open Vibeviewer.xcworkspace +# 在 Xcode 中选择 scheme:Vibeviewer,目标:My Mac(macOS),直接 Run +``` + +3) 命令行构建/打包(可选): + +```bash +make build # Release 构建(macOS 平台) +make dmg # 生成 DMG 安装包 +make release # 清理 → 生成 → 构建 → 打包全流程 +make release-full # 完整自动化发版流程(构建 + Tag + GitHub Release) +``` + +4) 发版流程: + +详细说明请参考 [Scripts/RELEASE_GUIDE.md](Scripts/RELEASE_GUIDE.md)。 + +快速发版: +```bash +./Scripts/release.sh [版本号] # 自动化发版流程 +``` + +--- + +## 运行与调试 + +- 初次运行会在菜单栏显示图标与关键指标,点击打开弹窗查看详细信息。 +- 登录与设置窗口通过依赖注入的窗口管理服务打开(参见 `VibeviewerApp.swift` 中的 `.environment(...)`)。 +- 自动刷新服务将在应用启动与屏幕状态变化时调度执行。 + +--- + +## 测试 + +各模块包含独立的 Swift Package 测试目标。可在 Xcode 的 Test navigator 直接运行,或使用命令行分别在各包下执行: + +```bash +swift test --package-path Packages/VibeviewerCore +swift test --package-path Packages/VibeviewerModel +swift test --package-path Packages/VibeviewerAPI +swift test --package-path Packages/VibeviewerAppEnvironment +swift test --package-path Packages/VibeviewerStorage +swift test --package-path Packages/VibeviewerLoginUI +swift test --package-path Packages/VibeviewerMenuUI +swift test --package-path Packages/VibeviewerSettingsUI +swift test --package-path Packages/VibeviewerShareUI +``` + +> 提示:若新增/调整包结构,请先执行 `make generate` 以确保工作区最新。 + +--- + +## 贡献指南 + +欢迎 Issue 与 PR!为保持一致性与可维护性,请遵循以下约定: + +1) 分支与提交 +- 建议使用形如 `feat/short-topic`、`fix/short-topic` 的分支命名。 +- 提交信息尽量遵循 Conventional Commits(如 `feat: add dashboard refresh service`)。 + +2) 架构约定 +- 变更前阅读 `./.cursor/rules/architecture.mdc` 与本 README 的分层说明。 +- 新增类型放置到对应层中:UI/Service/Model/Core,每个文件仅承载一个主要类型。 +- API 层仅暴露「服务协议 + 默认实现」,DTO 仅在 API 内部,UI 层只能看到领域实体。 + +3) 开发自检清单 +- `make generate` 能通过且工作区可成功打开 +- `make build` 通过(或 Xcode Release 构建成功) +- 相关包的 `swift test` 通过 +- 没有反向依赖或 UI 直接依赖网络实现 + +4) 提交 PR +- 在 PR 描述中简述变更动机、涉及模块与影响面 +- 如涉及 UI,建议附带截图/录屏 +- 小而聚焦的 PR 更容易被审阅与合并 + +--- + +## 常见问题(FAQ) + +- Q: Xcode 打不开或 Targets 缺失? + - A: 先运行 `make generate`(或 `Scripts/generate.sh`)重新生成工作区。 + +- Q: 提示找不到 Tuist 命令? + - A: 参考上文用 Homebrew 安装 Tuist 后重试。 + +- Q: 构建失败提示 Swift 版本不匹配? + - A: 请使用 Xcode 15.4+(Swift 5.10)。若升级后仍失败,执行清理脚本 `Scripts/clear.sh` 再 `make generate`。 + +--- + +## 许可证 + +本项目采用 MIT License 开源。详情参见 `LICENSE` 文件。 + +--- + +## 致谢 + +感谢所有为模块化 Swift 包架构、SwiftUI 生态与开发者工具做出贡献的社区成员。也感谢你对 Vibeviewer 的关注与改进! + +UI 灵感来自 X 用户 @hi_caicai 的作品:[Minto: Vibe Coding Tracker](https://apps.apple.com/ca/app/minto-vibe-coding-tracker/id6749605275?mt=12)。 + + diff --git a/参考计费/Scripts/RELEASE_GUIDE.md b/参考计费/Scripts/RELEASE_GUIDE.md new file mode 100644 index 0000000..d7e2e26 --- /dev/null +++ b/参考计费/Scripts/RELEASE_GUIDE.md @@ -0,0 +1,136 @@ +# Release 流程指南 + +本文档描述了 Vibeviewer 项目的完整 Release 流程,包括构建、签名和上传。 + +## 前置要求 + +1. **GitHub CLI** + ```bash + brew install gh + gh auth login + ``` + +## 快速开始 + +### 方法 1: 使用自动化脚本(推荐) + +```bash +# 自动检测版本并执行完整流程 +./Scripts/release.sh + +# 指定版本号 +./Scripts/release.sh 1.1.9 + +# 跳过构建(使用已有 DMG) +./Scripts/release.sh --skip-build 1.1.9 + +# 跳过上传(仅本地操作) +./Scripts/release.sh --skip-upload 1.1.9 +``` + +### 方法 2: 使用 Makefile + +```bash +# 构建 DMG +make release + +# 然后手动执行后续步骤 +``` + +### 方法 3: 手动步骤 + +1. **更新版本号** + - 编辑 `Project.swift`,更新 `appVersion` 常量 + +2. **构建和创建 DMG** + ```bash + make release + # 或 + Scripts/create_dmg.sh --version 1.1.9 + ``` + +3. **创建 Git Tag** + ```bash + git tag -a v1.1.9 -m "Release version 1.1.9" + git push origin v1.1.9 + ``` + +4. **创建 GitHub Release** + ```bash + gh release create v1.1.9 \ + --title "Version 1.1.9" \ + --notes "Release notes here" \ + Vibeviewer-1.1.9.dmg + ``` + +## 详细流程说明 + +### 1. 版本号管理 + +版本号在 `Project.swift` 中统一管理: +- `appVersion`: 统一版本号配置(如 "1.1.9") +- `MARKETING_VERSION`: 显示版本号(从 appVersion 读取) +- `CURRENT_PROJECT_VERSION`: 构建版本号(从 appVersion 读取) +- `CFBundleShortVersionString`: Info.plist 中的版本号(从 appVersion 读取) +- `CFBundleVersion`: Info.plist 中的构建号(从 appVersion 读取) + +### 2. 构建流程 + +`Scripts/create_dmg.sh` 脚本会: +1. 清理之前的构建产物 +2. 构建 Release 版本应用 +3. 验证应用版本信息和代码签名 +4. 创建 DMG 安装包 + +### 3. GitHub Release + +使用 GitHub CLI 创建 Release: +- 自动生成变更日志(基于 Git commits) +- 上传 DMG 文件 +- 创建 Release 页面 + +## 故障排查 + +### 问题 1: GitHub Release 创建失败 + +**可能原因**: +- GitHub CLI 未认证 +- Tag 已存在 +- 网络问题 + +**解决方案**: +```bash +# 重新认证 +gh auth login + +# 删除现有 Tag/Release +git tag -d v1.1.9 +git push origin :refs/tags/v1.1.9 +gh release delete v1.1.9 +``` + +### 问题 2: 构建失败 + +**解决方案**: +1. 检查 Xcode 是否正确安装 +2. 运行 `make clear` 清理构建缓存 +3. 检查 `Project.swift` 中的版本号配置 + +## 最佳实践 + +1. **版本号**: 遵循语义化版本(Semantic Versioning) +2. **变更日志**: 在 Release Notes 中清晰描述更新内容 +3. **测试**: 发布前在本地测试 DMG 安装 +4. **文档**: 重大更新时更新 README 和文档 + +## 相关文件 + +- `Scripts/release.sh` - 完整的自动化 Release 脚本 +- `Scripts/create_dmg.sh` - DMG 创建脚本 +- `Project.swift` - 版本号配置 +- `Makefile` - 构建命令 + +## 参考链接 + +- [GitHub CLI 文档](https://cli.github.com/manual/) +- [语义化版本](https://semver.org/) diff --git a/参考计费/Scripts/clear.sh b/参考计费/Scripts/clear.sh new file mode 100644 index 0000000..83f8700 --- /dev/null +++ b/参考计费/Scripts/clear.sh @@ -0,0 +1,42 @@ +#!/bin/zsh +set -euo pipefail + +echo "[clean] Cleaning Xcode DerivedData for current project only..." + +# Project name prefix inferred from repo root folder +SCRIPT_DIR=$(cd -- "$(dirname -- "$0")" && pwd) +REPO_ROOT=$(cd -- "${SCRIPT_DIR}/.." && pwd) +PROJECT_PREFIX=$(basename "$REPO_ROOT") + +# Xcode DerivedData (current user) +DERIVED_DATA_DIR=~/Library/Developer/Xcode/DerivedData + +if [ -d "$DERIVED_DATA_DIR" ]; then + # In zsh, enable null_glob so non-matching globs expand to empty + setopt null_glob 2>/dev/null || true + matches=("$DERIVED_DATA_DIR"/${PROJECT_PREFIX}-*) + if [ ${#matches[@]} -gt 0 ]; then + echo "[clean] Removing:" + for p in "${matches[@]}"; do + echo " - $p" + done + rm -rf "${matches[@]}" + else + echo "[clean] No DerivedData entries found for prefix: ${PROJECT_PREFIX}-*" + fi +else + echo "[clean] Not found: $DERIVED_DATA_DIR" +fi + +# Clean Tuist caches for current project only +# We pass --path to ensure tuist cleans caches scoped to this project +if command -v tuist >/dev/null 2>&1; then + echo "[clean] Cleaning Tuist caches for this project..." + tuist clean --path "$REPO_ROOT" || true +else + echo "[clean] tuist not found, skipping tuist cache clean" +fi + +echo "[clean] Done." + + diff --git a/参考计费/Scripts/create_dmg.sh b/参考计费/Scripts/create_dmg.sh new file mode 100644 index 0000000..c84e5a2 --- /dev/null +++ b/参考计费/Scripts/create_dmg.sh @@ -0,0 +1,190 @@ +#!/bin/bash +set -e + +# Configuration +APP_NAME="Vibeviewer" +CONFIGURATION="Release" +SCHEME="Vibeviewer" +WORKSPACE="Vibeviewer.xcworkspace" +BUILD_DIR="build" +TEMP_DIR="temp_dmg" +BACKGROUND_IMAGE_NAME="dmg_background.png" + +# Script directory +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" + +# Parse command line arguments +VERSION="" + +while [[ $# -gt 0 ]]; do + case $1 in + --version|-v) + VERSION="$2" + shift 2 + ;; + --help|-h) + echo "用法: $0 [选项]" + echo "" + echo "选项:" + echo " --version, -v <版本> 指定版本号(默认从应用 Info.plist 读取)" + echo " --help, -h 显示此帮助信息" + echo "" + echo "示例:" + echo " $0 # 仅创建 DMG" + echo " $0 -v 1.1.9 # 指定版本创建 DMG" + exit 0 + ;; + *) + echo "未知选项: $1" + echo "使用 --help 查看帮助信息" + exit 1 + ;; + esac +done + +# Colors +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[0;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +echo -e "${BLUE}🚀 Starting DMG creation process for ${APP_NAME}...${NC}" + +# Clean up previous builds +echo -e "${YELLOW}📦 Cleaning up previous builds...${NC}" +rm -rf "${BUILD_DIR}" +rm -rf "${TEMP_DIR}" +# Note: DMG_NAME will be set after version detection, so we clean up old DMGs separately +rm -f "${APP_NAME}"-*.dmg + +# Build the app +echo -e "${BLUE}🔨 Building ${APP_NAME} in ${CONFIGURATION} configuration...${NC}" +xcodebuild -workspace "${WORKSPACE}" \ + -scheme "${SCHEME}" \ + -configuration "${CONFIGURATION}" \ + -derivedDataPath "${BUILD_DIR}" \ + -destination "platform=macOS" \ + -skipMacroValidation \ + clean build + +# Find the built app +APP_PATH=$(find "${BUILD_DIR}" -name "${APP_NAME}.app" -type d | head -1) +if [ -z "$APP_PATH" ]; then + echo -e "${RED}❌ Error: Could not find ${APP_NAME}.app in build output${NC}" + exit 1 +fi + +echo -e "${GREEN}✅ Found app at: ${APP_PATH}${NC}" + +# 验证 app 的版本信息和代码签名 +echo -e "${BLUE}🔍 验证 app 信息...${NC}" +INFO_PLIST="${APP_PATH}/Contents/Info.plist" +if [ -f "$INFO_PLIST" ]; then + APP_VERSION=$(/usr/libexec/PlistBuddy -c "Print :CFBundleShortVersionString" "$INFO_PLIST" 2>/dev/null || echo "") + APP_BUILD=$(/usr/libexec/PlistBuddy -c "Print :CFBundleVersion" "$INFO_PLIST" 2>/dev/null || echo "") + echo -e " 版本: ${APP_VERSION}" + echo -e " Build: ${APP_BUILD}" + + # 检查代码签名 + if codesign -dv "${APP_PATH}" 2>&1 | grep -q "code object is not signed"; then + echo -e "${YELLOW}⚠️ 警告: App 未签名或签名无效${NC}" + else + SIGNING_IDENTITY=$(codesign -dv "${APP_PATH}" 2>&1 | grep "Authority=" | head -1 | sed 's/.*Authority=\(.*\)/\1/' || echo "未知") + echo -e " 签名: ${SIGNING_IDENTITY}" + + # 检查签名是否有效 + if ! codesign --verify --verbose "${APP_PATH}" 2>&1 | grep -q "valid on disk"; then + echo -e "${YELLOW}⚠️ 警告: 代码签名验证失败${NC}" + fi + fi +else + echo -e "${YELLOW}⚠️ 警告: 找不到 Info.plist${NC}" +fi + +# Get version from Project.swift first (single source of truth), then from built app +if [ -z "$VERSION" ]; then + # 优先从 Project.swift 读取版本号(统一版本号配置) + if [ -f "${PROJECT_ROOT}/Project.swift" ]; then + VERSION=$(grep -E '^let appVersion\s*=' "${PROJECT_ROOT}/Project.swift" | sed -E 's/.*"([0-9]+\.[0-9]+\.[0-9]+)".*/\1/' | head -1) + fi + + # Fallback: 从构建后的应用读取版本号 + if [ -z "$VERSION" ]; then + INFO_PLIST="${APP_PATH}/Contents/Info.plist" + if [ -f "$INFO_PLIST" ]; then + VERSION=$(/usr/libexec/PlistBuddy -c "Print :CFBundleShortVersionString" "$INFO_PLIST" 2>/dev/null || echo "") + fi + fi + + # Final fallback + if [ -z "$VERSION" ]; then + echo -e "${YELLOW}⚠️ 无法自动获取版本号,使用默认值 1.1.8${NC}" + echo -e "${YELLOW} 提示: 使用 --version 参数指定版本号${NC}" + VERSION="1.1.8" + fi +fi + +DMG_NAME="${APP_NAME}-${VERSION}.dmg" +echo -e "${BLUE}📦 版本: ${VERSION}${NC}" +echo -e "${BLUE}📦 DMG 文件名: ${DMG_NAME}${NC}" + +# Create temporary directory for DMG contents +echo -e "${YELLOW}📁 Creating DMG contents...${NC}" +mkdir -p "${TEMP_DIR}" +cp -R "${APP_PATH}" "${TEMP_DIR}/" + +# Create Applications symlink +ln -s /Applications "${TEMP_DIR}/Applications" + +# Create a simple background image if it doesn't exist +if [ ! -f "${BACKGROUND_IMAGE_NAME}" ]; then + echo -e "${YELLOW}🎨 Creating background image...${NC}" + # Create a simple background using ImageMagick if available, otherwise skip + if command -v convert >/dev/null 2>&1; then + convert -size 600x400 xc:white \ + -fill '#f0f0f0' -draw 'rectangle 0,0 600,400' \ + -fill black -pointsize 20 -gravity center \ + -annotate +0-100 "Drag ${APP_NAME} to Applications" \ + "${BACKGROUND_IMAGE_NAME}" + fi +fi + +# Copy background image if it exists +if [ -f "${BACKGROUND_IMAGE_NAME}" ]; then + cp "${BACKGROUND_IMAGE_NAME}" "${TEMP_DIR}/.background.png" +fi + +# Create DMG +echo -e "${BLUE}💽 Creating DMG file...${NC}" +hdiutil create -volname "${APP_NAME}" \ + -srcfolder "${TEMP_DIR}" \ + -ov \ + -format UDZO \ + -imagekey zlib-level=9 \ + "${DMG_NAME}" + +# Clean up temporary files +echo -e "${YELLOW}🧹 Cleaning up temporary files...${NC}" +rm -rf "${TEMP_DIR}" +rm -rf "${BUILD_DIR}" + +# Get DMG size +DMG_SIZE=$(du -h "${DMG_NAME}" | cut -f1) + +echo -e "${GREEN}🎉 DMG creation completed successfully!${NC}" +echo -e "${GREEN}📦 Output: ${DMG_NAME} (${DMG_SIZE})${NC}" +echo -e "${GREEN}📍 Location: $(pwd)/${DMG_NAME}${NC}" +echo "" +echo -e "${BLUE}📋 下一步:${NC}" +echo -e "1. 在 GitHub 上创建 Release (tag: v${VERSION})" +echo -e "2. 上传 DMG 文件: ${DMG_NAME}" +echo -e "3. 填写 Release Notes" + +# Optional: Open the directory containing the DMG +if command -v open >/dev/null 2>&1; then + echo "" + echo -e "${BLUE}📂 Opening directory...${NC}" + open . +fi \ No newline at end of file diff --git a/参考计费/Scripts/generate.sh b/参考计费/Scripts/generate.sh new file mode 100644 index 0000000..afbd638 --- /dev/null +++ b/参考计费/Scripts/generate.sh @@ -0,0 +1,21 @@ +#!/bin/zsh +set -euo pipefail + +SCRIPT_DIR=$(cd -- "$(dirname -- "$0")" && pwd) +REPO_ROOT=$(cd -- "${SCRIPT_DIR}/.." && pwd) + +cd "${REPO_ROOT}" + +echo "[tuist] Generating project..." +tuist install || { + echo "[tuist] install failed" >&2 + exit 1 +} +tuist generate || { + echo "[tuist] generate failed" >&2 + exit 1 +} + +echo "[tuist] Done. Open with: open Vibeviewer.xcworkspace" + + diff --git a/参考计费/Scripts/release.sh b/参考计费/Scripts/release.sh new file mode 100644 index 0000000..a578ebf --- /dev/null +++ b/参考计费/Scripts/release.sh @@ -0,0 +1,226 @@ +#!/bin/bash +set -e + +# 完整的 Release 流程脚本 +# 用法: ./Scripts/release.sh [VERSION] [--skip-build] [--skip-upload] [--skip-commit] + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +APP_NAME="Vibeviewer" + +# Colors +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[0;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +# 解析参数 +SKIP_BUILD=false +SKIP_UPLOAD=false +VERSION="" + +while [[ $# -gt 0 ]]; do + case $1 in + --skip-build) + SKIP_BUILD=true + shift + ;; + --skip-upload) + SKIP_UPLOAD=true + shift + ;; + --version|-v) + VERSION="$2" + shift 2 + ;; + --help|-h) + echo "用法: $0 [选项] [VERSION]" + echo "" + echo "选项:" + echo " --version, -v <版本> 指定版本号(默认从 Project.swift 读取)" + echo " --skip-build 跳过构建步骤" + echo " --skip-upload 跳过上传到 GitHub Release" + echo " --help, -h 显示此帮助信息" + echo "" + echo "示例:" + echo " $0 # 自动检测版本并完整流程" + echo " $0 1.1.7 # 指定版本号" + echo " $0 --skip-build 1.1.7 # 跳过构建(使用已有 DMG)" + exit 0 + ;; + *) + if [ -z "$VERSION" ] && [[ "$1" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + VERSION="$1" + else + echo -e "${RED}❌ 未知选项: $1${NC}" + echo "使用 --help 查看帮助信息" + exit 1 + fi + shift + ;; + esac +done + +echo -e "${BLUE}🚀 开始 Release 流程...${NC}" +echo "" + +# 1. 获取版本号(从 Project.swift 的统一版本号配置读取) +if [ -z "$VERSION" ]; then + echo -e "${BLUE}📋 检测版本号...${NC}" + # 优先从 appVersion 常量读取(统一版本号配置) + VERSION=$(grep -E '^let appVersion\s*=' "$PROJECT_ROOT/Project.swift" | sed -E 's/.*"([0-9]+\.[0-9]+\.[0-9]+)".*/\1/' | head -1) + + # Fallback: 从 MARKETING_VERSION 读取 + if [ -z "$VERSION" ]; then + VERSION=$(grep -E 'MARKETING_VERSION' "$PROJECT_ROOT/Project.swift" | head -1 | sed -E 's/.*"([0-9]+\.[0-9]+\.[0-9]+)".*/\1/') + fi + + if [ -z "$VERSION" ]; then + echo -e "${RED}❌ 无法自动检测版本号${NC}" + echo -e "${YELLOW} 请使用 --version 参数指定版本号${NC}" + exit 1 + fi +fi + +echo -e "${GREEN}✅ 版本号: ${VERSION}${NC}" +echo "" + +# 2. 检查 GitHub CLI +if ! command -v gh >/dev/null 2>&1; then + echo -e "${RED}❌ 错误: 需要 GitHub CLI (gh)${NC}" + echo -e "${YELLOW} 安装: brew install gh${NC}" + exit 1 +fi + +# 4. 构建和创建 DMG +DMG_NAME="${APP_NAME}-${VERSION}.dmg" +if [ "$SKIP_BUILD" = false ]; then + echo -e "${BLUE}🔨 构建 Release 版本并创建 DMG...${NC}" + "$SCRIPT_DIR/create_dmg.sh" --version "$VERSION" || { + echo -e "${RED}❌ 构建失败${NC}" + exit 1 + } + echo "" +else + echo -e "${YELLOW}⏭️ 跳过构建步骤${NC}" + if [ ! -f "$DMG_NAME" ]; then + echo -e "${RED}❌ 错误: DMG 文件不存在: $DMG_NAME${NC}" + exit 1 + fi + echo "" +fi + +# 5. 检查 Git 状态 +echo -e "${BLUE}📋 检查 Git 状态...${NC}" +if [ -n "$(git status --porcelain)" ]; then + echo -e "${YELLOW}⚠️ 有未提交的更改${NC}" + git status --short + echo "" + read -p "是否继续?(y/N): " -n 1 -r + echo + if [[ ! $REPLY =~ ^[Yy]$ ]]; then + exit 1 + fi +fi + +# 6. 创建 Git Tag +echo -e "${BLUE}🏷️ 创建 Git Tag...${NC}" +if git rev-parse "v${VERSION}" >/dev/null 2>&1; then + echo -e "${YELLOW}⚠️ Tag v${VERSION} 已存在${NC}" + read -p "是否删除并重新创建?(y/N): " -n 1 -r + echo + if [[ $REPLY =~ ^[Yy]$ ]]; then + git tag -d "v${VERSION}" 2>/dev/null || true + git push origin ":refs/tags/v${VERSION}" 2>/dev/null || true + else + echo -e "${YELLOW}⏭️ 跳过 Tag 创建${NC}" + fi +fi + +if ! git rev-parse "v${VERSION}" >/dev/null 2>&1; then + git tag -a "v${VERSION}" -m "Release version ${VERSION}" + echo -e "${GREEN}✅ Tag v${VERSION} 已创建${NC}" +else + echo -e "${YELLOW}⏭️ 使用现有 Tag${NC}" +fi +echo "" + +# 7. 创建 GitHub Release +if [ "$SKIP_UPLOAD" = false ]; then + echo -e "${BLUE}📤 创建 GitHub Release...${NC}" + + # 检查 Release 是否已存在 + if gh release view "v${VERSION}" >/dev/null 2>&1; then + echo -e "${YELLOW}⚠️ Release v${VERSION} 已存在${NC}" + read -p "是否删除并重新创建?(y/N): " -n 1 -r + echo + if [[ $REPLY =~ ^[Yy]$ ]]; then + gh release delete "v${VERSION}" --yes 2>/dev/null || true + else + echo -e "${YELLOW}⏭️ 跳过 Release 创建,直接上传 DMG${NC}" + gh release upload "v${VERSION}" "$DMG_NAME" --clobber || { + echo -e "${RED}❌ 上传失败${NC}" + exit 1 + } + echo -e "${GREEN}✅ DMG 已上传${NC}" + echo "" + SKIP_RELEASE_CREATE=true + fi + fi + + if [ "$SKIP_RELEASE_CREATE" != true ]; then + # 获取变更日志 + PREV_TAG=$(git describe --tags --abbrev=0 HEAD~1 2>/dev/null || echo "") + if [ -n "$PREV_TAG" ]; then + CHANGELOG=$(git log "${PREV_TAG}..HEAD" --pretty=format:"- %s" | head -20) + else + CHANGELOG=$(git log --oneline -10 --pretty=format:"- %s") + fi + + RELEASE_NOTES=$(cat < + + + + com.apple.security.app-sandbox + + com.apple.security.network.client + + com.apple.security.files.user-selected.read-write + + com.apple.security.files.downloads.read-write + + + diff --git a/参考计费/Vibeviewer/VibeviewerApp.swift b/参考计费/Vibeviewer/VibeviewerApp.swift new file mode 100644 index 0000000..83b3615 --- /dev/null +++ b/参考计费/Vibeviewer/VibeviewerApp.swift @@ -0,0 +1,109 @@ +// +// VibeviewerApp.swift +// Vibeviewer +// +// Created by Groot chen on 2025/8/24. +// + +import Observation +import SwiftUI +import VibeviewerAPI +import VibeviewerAppEnvironment +import VibeviewerCore +import VibeviewerLoginUI +import VibeviewerMenuUI +import VibeviewerModel +import VibeviewerSettingsUI +import VibeviewerStorage +import VibeviewerShareUI + +@main +struct VibeviewerApp: App { + @State private var settings: AppSettings = DefaultCursorStorageService.loadSettingsSync() + + @State private var session: VibeviewerModel.AppSession = .init( + credentials: DefaultCursorStorageService.loadCredentialsSync(), + snapshot: DefaultCursorStorageService.loadDashboardSnapshotSync() + ) + @State private var refresher: any DashboardRefreshService = NoopDashboardRefreshService() + @State private var loginService: any LoginService = NoopLoginService() + @State private var updateService: any UpdateService = NoopUpdateService() + + var body: some Scene { + MenuBarExtra { + MenuPopoverView() + .environment(\.cursorService, DefaultCursorService()) + .environment(\.cursorStorage, DefaultCursorStorageService()) + .environment(\.loginWindowManager, LoginWindowManager.shared) + .environment(\.settingsWindowManager, SettingsWindowManager.shared) + .environment(\.dashboardRefreshService, self.refresher) + .environment(\.loginService, self.loginService) + .environment(\.launchAtLoginService, DefaultLaunchAtLoginService()) + .environment(\.updateService, self.updateService) + .environment(self.settings) + .environment(self.session) + .menuBarExtraWindowCorner() + .onAppear { + SettingsWindowManager.shared.appSettings = self.settings + SettingsWindowManager.shared.appSession = self.session + SettingsWindowManager.shared.dashboardRefreshService = self.refresher + SettingsWindowManager.shared.updateService = self.updateService + } + .id(self.settings.appearance) + .applyPreferredColorScheme(self.settings.appearance) + } label: { + menuBarLabel() + } + .menuBarExtraStyle(.window) + .windowResizability(.contentSize) + } + + private func menuBarLabel() -> some View { + HStack(spacing: 4) { + Image(.menuBarIcon) + .renderingMode(.template) + .resizable() + .frame(width: 16, height: 16) + .padding(.trailing, 4) + .foregroundStyle(.primary) + Text({ + guard let snapshot = self.session.snapshot else { return "" } + return snapshot.displayTotalUsageCents.dollarStringFromCents + }()) + .font(.app(.satoshiBold, size: 15)) + .foregroundColor(.primary) + } + .task { + await self.setupDashboardRefreshService() + } + } + + private func setupDashboardRefreshService() async { + let api = DefaultCursorService() + let storage = DefaultCursorStorageService() + + let dashboardRefreshSvc = DefaultDashboardRefreshService( + api: api, + storage: storage, + settings: self.settings, + session: self.session + ) + let screenPowerSvc = DefaultScreenPowerStateService() + let powerAwareSvc = PowerAwareDashboardRefreshService( + refreshService: dashboardRefreshSvc, + screenPowerService: screenPowerSvc + ) + self.refresher = powerAwareSvc + + // 创建登录服务,依赖刷新服务 + self.loginService = DefaultLoginService( + api: api, + storage: storage, + refresher: self.refresher, + session: self.session + ) + + await self.refresher.start() + } +} + diff --git a/参考计费/Vibeviewer/cursor_web_api_curl/get_filter_usage.txt b/参考计费/Vibeviewer/cursor_web_api_curl/get_filter_usage.txt new file mode 100644 index 0000000..0801da1 --- /dev/null +++ b/参考计费/Vibeviewer/cursor_web_api_curl/get_filter_usage.txt @@ -0,0 +1,1020 @@ +// request 获取用户按天filter的用量记录, + +curl 'https://cursor.com/api/dashboard/get-filtered-usage-events' \ + -H 'accept: */*' \ + -H 'accept-language: zh-CN,zh;q=0.9' \ + -H 'content-type: application/json' \ + -b 'IndrX2ZuSmZramJSX0NIYUZoRzRzUGZ0cENIVHpHNXk0VE0ya2ZiUkVzQU14X2Fub255bW91c1VzZXJJZCI%3D=IjI1NTg2NzdiLWYxNzEtNGEwNS04MGUwLWI5YmZjMGM2ZmE5YiI=; NEXT_LOCALE=en; htjs_anonymous_id=cb747e5e-9cc2-40a0-9fee-ef20e6451a9b; htjs_sesh={%22id%22:1755137078127%2C%22expiresAt%22:1755138890946%2C%22timeout%22:1800000%2C%22sessionStart%22:false%2C%22autoTrack%22:true}; ph_phc_TXdpocbGVeZVm5VJmAsHTMrCofBQu3e0kN8HGMNGTVW_posthog=%7B%22distinct_id%22%3A%220196aeab-9658-758a-98ad-891c3ed366d8%22%2C%22%24sesid%22%3A%5B1755137090946%2C%220198a652-6bb5-7e3f-8ba7-bad9d82b78cb%22%2C1755137076149%5D%7D; WorkosCursorSessionToken=user_01JRXG9J1YYXYF39Y4NAR5AMCE%3A%3AeyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJhdXRoMHx1c2VyXzAxSlJYRzlKMVlZWFlGMzlZNE5BUjVBTUNFIiwidGltZSI6IjE3NTYwOTI3OTEiLCJyYW5kb21uZXNzIjoiMjg0NTI0NTQtMjQ4Yi00ZjczIiwiZXhwIjoxNzYxMjc2NzkxLCJpc3MiOiJodHRwczovL2F1dGhlbnRpY2F0aW9uLmN1cnNvci5zaCIsInNjb3BlIjoib3BlbmlkIHByb2ZpbGUgZW1haWwgb2ZmbGluZV9hY2Nlc3MiLCJhdWQiOiJodHRwczovL2N1cnNvci5jb20iLCJ0eXBlIjoid2ViIn0.UKbY6ERiNzPGTrS7KZlsLyQ5P6Zk2yc-bi2xM6FtrKo' \ + -H 'dnt: 1' \ + -H 'origin: https://cursor.com' \ + -H 'priority: u=1, i' \ + -H 'referer: https://cursor.com/dashboard?tab=usage' \ + -H 'sec-ch-ua: "Not)A;Brand";v="8", "Chromium";v="138"' \ + -H 'sec-ch-ua-arch: "arm"' \ + -H 'sec-ch-ua-bitness: "64"' \ + -H 'sec-ch-ua-mobile: ?0' \ + -H 'sec-ch-ua-platform: "macOS"' \ + -H 'sec-ch-ua-platform-version: "15.3.1"' \ + -H 'sec-fetch-dest: empty' \ + -H 'sec-fetch-mode: cors' \ + -H 'sec-fetch-site: same-origin' \ + -H 'user-agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36' \ + --data-raw '{"teamId":15113845,"startDate":"1755446400000","endDate":"1756137599999","userId":190525724,"page":1,"pageSize":100}' + +// response +{ + "totalUsageEventsCount": 301, + "usageEventsDisplay": [ + { + "timestamp": "1756092480751", + "model": "gpt-5", + "kind": "USAGE_EVENT_KIND_USAGE_BASED", + "requestsCosts": 1, + "usageBasedCosts": "$0.04", + "isTokenBasedCall": false, + "owningUser": "190525724", + "owningTeam": "15113845" + }, + { + "timestamp": "1756092139474", + "model": "gpt-5", + "kind": "USAGE_EVENT_KIND_USAGE_BASED", + "requestsCosts": 1, + "usageBasedCosts": "$0.04", + "isTokenBasedCall": false, + "owningUser": "190525724", + "owningTeam": "15113845" + }, + { + "timestamp": "1756091504183", + "model": "gpt-5", + "kind": "USAGE_EVENT_KIND_USAGE_BASED", + "requestsCosts": 1, + "usageBasedCosts": "$0.04", + "isTokenBasedCall": false, + "owningUser": "190525724", + "owningTeam": "15113845" + }, + { + "timestamp": "1756091210229", + "model": "gpt-5", + "kind": "USAGE_EVENT_KIND_ERRORED_NOT_CHARGED", + "usageBasedCosts": "$0.00", + "isTokenBasedCall": false, + "owningUser": "190525724", + "owningTeam": "15113845" + }, + { + "timestamp": "1756090878293", + "model": "gpt-5", + "kind": "USAGE_EVENT_KIND_USAGE_BASED", + "requestsCosts": 1, + "usageBasedCosts": "$0.04", + "isTokenBasedCall": false, + "owningUser": "190525724", + "owningTeam": "15113845" + }, + { + "timestamp": "1756090512166", + "model": "gpt-5", + "kind": "USAGE_EVENT_KIND_USAGE_BASED", + "requestsCosts": 1, + "usageBasedCosts": "$0.04", + "isTokenBasedCall": false, + "owningUser": "190525724", + "owningTeam": "15113845" + }, + { + "timestamp": "1756090335293", + "model": "gpt-5", + "kind": "USAGE_EVENT_KIND_USAGE_BASED", + "requestsCosts": 1, + "usageBasedCosts": "$0.04", + "isTokenBasedCall": false, + "owningUser": "190525724", + "owningTeam": "15113845" + }, + { + "timestamp": "1756090280795", + "model": "gpt-5", + "kind": "USAGE_EVENT_KIND_USAGE_BASED", + "requestsCosts": 1, + "usageBasedCosts": "$0.04", + "isTokenBasedCall": false, + "owningUser": "190525724", + "owningTeam": "15113845" + }, + { + "timestamp": "1756089968588", + "model": "gpt-5", + "kind": "USAGE_EVENT_KIND_USAGE_BASED", + "requestsCosts": 1, + "usageBasedCosts": "$0.04", + "isTokenBasedCall": false, + "owningUser": "190525724", + "owningTeam": "15113845" + }, + { + "timestamp": "1756089890357", + "model": "gpt-5", + "kind": "USAGE_EVENT_KIND_USAGE_BASED", + "requestsCosts": 1, + "usageBasedCosts": "$0.04", + "isTokenBasedCall": false, + "owningUser": "190525724", + "owningTeam": "15113845" + }, + { + "timestamp": "1756089803184", + "model": "gpt-5", + "kind": "USAGE_EVENT_KIND_USAGE_BASED", + "requestsCosts": 1, + "usageBasedCosts": "$0.04", + "isTokenBasedCall": false, + "owningUser": "190525724", + "owningTeam": "15113845" + }, + { + "timestamp": "1756089742204", + "model": "gpt-5", + "kind": "USAGE_EVENT_KIND_USAGE_BASED", + "requestsCosts": 1, + "usageBasedCosts": "$0.04", + "isTokenBasedCall": false, + "owningUser": "190525724", + "owningTeam": "15113845" + }, + { + "timestamp": "1756089526214", + "model": "gpt-5", + "kind": "USAGE_EVENT_KIND_USAGE_BASED", + "requestsCosts": 1, + "usageBasedCosts": "$0.04", + "isTokenBasedCall": false, + "owningUser": "190525724", + "owningTeam": "15113845" + }, + { + "timestamp": "1756088873845", + "model": "gpt-5", + "kind": "USAGE_EVENT_KIND_USAGE_BASED", + "requestsCosts": 1, + "usageBasedCosts": "$0.04", + "isTokenBasedCall": false, + "owningUser": "190525724", + "owningTeam": "15113845" + }, + { + "timestamp": "1756088727930", + "model": "gpt-5", + "kind": "USAGE_EVENT_KIND_ERRORED_NOT_CHARGED", + "usageBasedCosts": "$0.00", + "isTokenBasedCall": false, + "owningUser": "190525724", + "owningTeam": "15113845" + }, + { + "timestamp": "1756055462005", + "model": "gpt-5", + "kind": "USAGE_EVENT_KIND_USAGE_BASED", + "requestsCosts": 1, + "usageBasedCosts": "$0.04", + "isTokenBasedCall": false, + "owningUser": "190525724", + "owningTeam": "15113845" + }, + { + "timestamp": "1756055346214", + "model": "default", + "kind": "USAGE_EVENT_KIND_USAGE_BASED", + "requestsCosts": 1, + "usageBasedCosts": "$0.04", + "isTokenBasedCall": false, + "owningUser": "190525724", + "owningTeam": "15113845" + }, + { + "timestamp": "1756055291889", + "model": "default", + "kind": "USAGE_EVENT_KIND_USAGE_BASED", + "requestsCosts": 1, + "usageBasedCosts": "$0.04", + "isTokenBasedCall": false, + "owningUser": "190525724", + "owningTeam": "15113845" + }, + { + "timestamp": "1756054699654", + "model": "gpt-5", + "kind": "USAGE_EVENT_KIND_USAGE_BASED", + "requestsCosts": 1, + "usageBasedCosts": "$0.04", + "isTokenBasedCall": false, + "owningUser": "190525724", + "owningTeam": "15113845" + }, + { + "timestamp": "1756054462973", + "model": "gpt-5", + "kind": "USAGE_EVENT_KIND_USAGE_BASED", + "requestsCosts": 1, + "usageBasedCosts": "$0.04", + "isTokenBasedCall": false, + "owningUser": "190525724", + "owningTeam": "15113845" + }, + { + "timestamp": "1756054008630", + "model": "gpt-5", + "kind": "USAGE_EVENT_KIND_USAGE_BASED", + "requestsCosts": 1, + "usageBasedCosts": "$0.04", + "isTokenBasedCall": false, + "owningUser": "190525724", + "owningTeam": "15113845" + }, + { + "timestamp": "1756053670082", + "model": "gpt-5", + "kind": "USAGE_EVENT_KIND_USAGE_BASED", + "requestsCosts": 1, + "usageBasedCosts": "$0.04", + "isTokenBasedCall": false, + "owningUser": "190525724", + "owningTeam": "15113845" + }, + { + "timestamp": "1756053527961", + "model": "gemini-2.5-pro", + "kind": "USAGE_EVENT_KIND_USAGE_BASED", + "requestsCosts": 1, + "usageBasedCosts": "$0.04", + "isTokenBasedCall": false, + "owningUser": "190525724", + "owningTeam": "15113845" + }, + { + "timestamp": "1756053391516", + "model": "gemini-2.5-pro", + "kind": "USAGE_EVENT_KIND_USAGE_BASED", + "requestsCosts": 1, + "usageBasedCosts": "$0.04", + "isTokenBasedCall": false, + "owningUser": "190525724", + "owningTeam": "15113845" + }, + { + "timestamp": "1756053172919", + "model": "gemini-2.5-pro", + "kind": "USAGE_EVENT_KIND_USAGE_BASED", + "requestsCosts": 1, + "usageBasedCosts": "$0.04", + "isTokenBasedCall": false, + "owningUser": "190525724", + "owningTeam": "15113845" + }, + { + "timestamp": "1756052764854", + "model": "gpt-5", + "kind": "USAGE_EVENT_KIND_USAGE_BASED", + "requestsCosts": 1, + "usageBasedCosts": "$0.04", + "isTokenBasedCall": false, + "owningUser": "190525724", + "owningTeam": "15113845" + }, + { + "timestamp": "1756052659689", + "model": "gpt-5", + "kind": "USAGE_EVENT_KIND_USAGE_BASED", + "requestsCosts": 1, + "usageBasedCosts": "$0.04", + "isTokenBasedCall": false, + "owningUser": "190525724", + "owningTeam": "15113845" + }, + { + "timestamp": "1756052493895", + "model": "gpt-5", + "kind": "USAGE_EVENT_KIND_USAGE_BASED", + "requestsCosts": 1, + "usageBasedCosts": "$0.04", + "isTokenBasedCall": false, + "owningUser": "190525724", + "owningTeam": "15113845" + }, + { + "timestamp": "1756052260462", + "model": "gpt-5", + "kind": "USAGE_EVENT_KIND_USAGE_BASED", + "requestsCosts": 1, + "usageBasedCosts": "$0.04", + "isTokenBasedCall": false, + "owningUser": "190525724", + "owningTeam": "15113845" + }, + { + "timestamp": "1756052190940", + "model": "gpt-5", + "kind": "USAGE_EVENT_KIND_USAGE_BASED", + "requestsCosts": 1, + "usageBasedCosts": "$0.04", + "isTokenBasedCall": false, + "owningUser": "190525724", + "owningTeam": "15113845" + }, + { + "timestamp": "1756051744340", + "model": "gpt-5", + "kind": "USAGE_EVENT_KIND_USAGE_BASED", + "requestsCosts": 1, + "usageBasedCosts": "$0.04", + "isTokenBasedCall": false, + "owningUser": "190525724", + "owningTeam": "15113845" + }, + { + "timestamp": "1756051598948", + "model": "gpt-5", + "kind": "USAGE_EVENT_KIND_USAGE_BASED", + "requestsCosts": 1, + "usageBasedCosts": "$0.04", + "isTokenBasedCall": false, + "owningUser": "190525724", + "owningTeam": "15113845" + }, + { + "timestamp": "1756051349670", + "model": "gpt-5", + "kind": "USAGE_EVENT_KIND_USAGE_BASED", + "requestsCosts": 1, + "usageBasedCosts": "$0.04", + "isTokenBasedCall": false, + "owningUser": "190525724", + "owningTeam": "15113845" + }, + { + "timestamp": "1756050915179", + "model": "gpt-5", + "kind": "USAGE_EVENT_KIND_USAGE_BASED", + "requestsCosts": 1, + "usageBasedCosts": "$0.04", + "isTokenBasedCall": false, + "owningUser": "190525724", + "owningTeam": "15113845" + }, + { + "timestamp": "1756050850405", + "model": "gpt-5", + "kind": "USAGE_EVENT_KIND_USAGE_BASED", + "requestsCosts": 1, + "usageBasedCosts": "$0.04", + "isTokenBasedCall": false, + "owningUser": "190525724", + "owningTeam": "15113845" + }, + { + "timestamp": "1756050700639", + "model": "gpt-5", + "kind": "USAGE_EVENT_KIND_USAGE_BASED", + "requestsCosts": 1, + "usageBasedCosts": "$0.04", + "isTokenBasedCall": false, + "owningUser": "190525724", + "owningTeam": "15113845" + }, + { + "timestamp": "1756050448269", + "model": "gpt-5", + "kind": "USAGE_EVENT_KIND_USAGE_BASED", + "requestsCosts": 1, + "usageBasedCosts": "$0.04", + "isTokenBasedCall": false, + "owningUser": "190525724", + "owningTeam": "15113845" + }, + { + "timestamp": "1756050335159", + "model": "gpt-5", + "kind": "USAGE_EVENT_KIND_USAGE_BASED", + "requestsCosts": 1, + "usageBasedCosts": "$0.04", + "isTokenBasedCall": false, + "owningUser": "190525724", + "owningTeam": "15113845" + }, + { + "timestamp": "1756050210402", + "model": "gpt-5", + "kind": "USAGE_EVENT_KIND_USAGE_BASED", + "requestsCosts": 1, + "usageBasedCosts": "$0.04", + "isTokenBasedCall": false, + "owningUser": "190525724", + "owningTeam": "15113845" + }, + { + "timestamp": "1756050183437", + "model": "gpt-5", + "kind": "USAGE_EVENT_KIND_USAGE_BASED", + "requestsCosts": 1, + "usageBasedCosts": "$0.04", + "isTokenBasedCall": false, + "owningUser": "190525724", + "owningTeam": "15113845" + }, + { + "timestamp": "1756050051802", + "model": "default", + "kind": "USAGE_EVENT_KIND_USAGE_BASED", + "requestsCosts": 1, + "usageBasedCosts": "$0.04", + "isTokenBasedCall": false, + "owningUser": "190525724", + "owningTeam": "15113845" + }, + { + "timestamp": "1756050010838", + "model": "gpt-5", + "kind": "USAGE_EVENT_KIND_USAGE_BASED", + "requestsCosts": 1, + "usageBasedCosts": "$0.04", + "isTokenBasedCall": false, + "owningUser": "190525724", + "owningTeam": "15113845" + }, + { + "timestamp": "1756049433497", + "model": "gpt-5", + "kind": "USAGE_EVENT_KIND_USAGE_BASED", + "requestsCosts": 1, + "usageBasedCosts": "$0.04", + "isTokenBasedCall": false, + "owningUser": "190525724", + "owningTeam": "15113845" + }, + { + "timestamp": "1756049391669", + "model": "gpt-5", + "kind": "USAGE_EVENT_KIND_USAGE_BASED", + "requestsCosts": 1, + "usageBasedCosts": "$0.04", + "isTokenBasedCall": false, + "owningUser": "190525724", + "owningTeam": "15113845" + }, + { + "timestamp": "1756049314138", + "model": "gpt-5", + "kind": "USAGE_EVENT_KIND_USAGE_BASED", + "requestsCosts": 1, + "usageBasedCosts": "$0.04", + "isTokenBasedCall": false, + "owningUser": "190525724", + "owningTeam": "15113845" + }, + { + "timestamp": "1756049140111", + "model": "gpt-5", + "kind": "USAGE_EVENT_KIND_USAGE_BASED", + "requestsCosts": 1, + "usageBasedCosts": "$0.04", + "isTokenBasedCall": false, + "owningUser": "190525724", + "owningTeam": "15113845" + }, + { + "timestamp": "1756048747301", + "model": "gpt-5", + "kind": "USAGE_EVENT_KIND_USAGE_BASED", + "requestsCosts": 1, + "usageBasedCosts": "$0.04", + "isTokenBasedCall": false, + "owningUser": "190525724", + "owningTeam": "15113845" + }, + { + "timestamp": "1756048491643", + "model": "gpt-5", + "kind": "USAGE_EVENT_KIND_USAGE_BASED", + "requestsCosts": 1, + "usageBasedCosts": "$0.04", + "isTokenBasedCall": false, + "owningUser": "190525724", + "owningTeam": "15113845" + }, + { + "timestamp": "1756048437844", + "model": "gpt-5", + "kind": "USAGE_EVENT_KIND_USAGE_BASED", + "requestsCosts": 1, + "usageBasedCosts": "$0.04", + "isTokenBasedCall": false, + "owningUser": "190525724", + "owningTeam": "15113845" + }, + { + "timestamp": "1756048247301", + "model": "gpt-5", + "kind": "USAGE_EVENT_KIND_USAGE_BASED", + "requestsCosts": 1, + "usageBasedCosts": "$0.04", + "isTokenBasedCall": false, + "owningUser": "190525724", + "owningTeam": "15113845" + }, + { + "timestamp": "1756043995436", + "model": "gpt-5", + "kind": "USAGE_EVENT_KIND_USAGE_BASED", + "requestsCosts": 1, + "usageBasedCosts": "$0.04", + "isTokenBasedCall": false, + "owningUser": "190525724", + "owningTeam": "15113845" + }, + { + "timestamp": "1756043736663", + "model": "gpt-5", + "kind": "USAGE_EVENT_KIND_USAGE_BASED", + "requestsCosts": 1, + "usageBasedCosts": "$0.04", + "isTokenBasedCall": false, + "owningUser": "190525724", + "owningTeam": "15113845" + }, + { + "timestamp": "1756043070519", + "model": "gpt-5", + "kind": "USAGE_EVENT_KIND_USAGE_BASED", + "requestsCosts": 1, + "usageBasedCosts": "$0.04", + "isTokenBasedCall": false, + "owningUser": "190525724", + "owningTeam": "15113845" + }, + { + "timestamp": "1756042152568", + "model": "gpt-5", + "kind": "USAGE_EVENT_KIND_USAGE_BASED", + "requestsCosts": 1, + "usageBasedCosts": "$0.04", + "isTokenBasedCall": false, + "owningUser": "190525724", + "owningTeam": "15113845" + }, + { + "timestamp": "1756041993311", + "model": "gemini-2.5-pro", + "kind": "USAGE_EVENT_KIND_USAGE_BASED", + "requestsCosts": 1, + "usageBasedCosts": "$0.04", + "isTokenBasedCall": false, + "owningUser": "190525724", + "owningTeam": "15113845" + }, + { + "timestamp": "1756041940213", + "model": "gemini-2.5-pro", + "kind": "USAGE_EVENT_KIND_USAGE_BASED", + "requestsCosts": 1, + "usageBasedCosts": "$0.04", + "isTokenBasedCall": false, + "owningUser": "190525724", + "owningTeam": "15113845" + }, + { + "timestamp": "1756041898229", + "model": "gemini-2.5-pro", + "kind": "USAGE_EVENT_KIND_ERRORED_NOT_CHARGED", + "usageBasedCosts": "$0.00", + "isTokenBasedCall": false, + "owningUser": "190525724", + "owningTeam": "15113845" + }, + { + "timestamp": "1756041574862", + "model": "gemini-2.5-pro", + "kind": "USAGE_EVENT_KIND_USAGE_BASED", + "requestsCosts": 1, + "usageBasedCosts": "$0.04", + "isTokenBasedCall": false, + "owningUser": "190525724", + "owningTeam": "15113845" + }, + { + "timestamp": "1756041488071", + "model": "gemini-2.5-pro", + "kind": "USAGE_EVENT_KIND_USAGE_BASED", + "requestsCosts": 1, + "usageBasedCosts": "$0.04", + "isTokenBasedCall": false, + "owningUser": "190525724", + "owningTeam": "15113845" + }, + { + "timestamp": "1756041301595", + "model": "gemini-2.5-pro", + "kind": "USAGE_EVENT_KIND_USAGE_BASED", + "requestsCosts": 1, + "usageBasedCosts": "$0.04", + "isTokenBasedCall": false, + "owningUser": "190525724", + "owningTeam": "15113845" + }, + { + "timestamp": "1756041052120", + "model": "gemini-2.5-pro", + "kind": "USAGE_EVENT_KIND_USAGE_BASED", + "requestsCosts": 1, + "usageBasedCosts": "$0.04", + "isTokenBasedCall": false, + "owningUser": "190525724", + "owningTeam": "15113845" + }, + { + "timestamp": "1756040859390", + "model": "gemini-2.5-pro", + "kind": "USAGE_EVENT_KIND_USAGE_BASED", + "requestsCosts": 1, + "usageBasedCosts": "$0.04", + "isTokenBasedCall": false, + "owningUser": "190525724", + "owningTeam": "15113845" + }, + { + "timestamp": "1756040773874", + "model": "gemini-2.5-pro", + "kind": "USAGE_EVENT_KIND_ERRORED_NOT_CHARGED", + "usageBasedCosts": "$0.00", + "isTokenBasedCall": false, + "owningUser": "190525724", + "owningTeam": "15113845" + }, + { + "timestamp": "1756040594601", + "model": "gpt-5", + "kind": "USAGE_EVENT_KIND_USAGE_BASED", + "requestsCosts": 1, + "usageBasedCosts": "$0.04", + "isTokenBasedCall": false, + "owningUser": "190525724", + "owningTeam": "15113845" + }, + { + "timestamp": "1756040515035", + "model": "gpt-5", + "kind": "USAGE_EVENT_KIND_USAGE_BASED", + "requestsCosts": 1, + "usageBasedCosts": "$0.04", + "isTokenBasedCall": false, + "owningUser": "190525724", + "owningTeam": "15113845" + }, + { + "timestamp": "1756040405076", + "model": "gpt-5", + "kind": "USAGE_EVENT_KIND_USAGE_BASED", + "requestsCosts": 1, + "usageBasedCosts": "$0.04", + "isTokenBasedCall": false, + "owningUser": "190525724", + "owningTeam": "15113845" + }, + { + "timestamp": "1756040251644", + "model": "gpt-5", + "kind": "USAGE_EVENT_KIND_USAGE_BASED", + "requestsCosts": 1, + "usageBasedCosts": "$0.04", + "isTokenBasedCall": false, + "owningUser": "190525724", + "owningTeam": "15113845" + }, + { + "timestamp": "1756039898112", + "model": "gpt-5", + "kind": "USAGE_EVENT_KIND_USAGE_BASED", + "requestsCosts": 1, + "usageBasedCosts": "$0.04", + "isTokenBasedCall": false, + "owningUser": "190525724", + "owningTeam": "15113845" + }, + { + "timestamp": "1756037804990", + "model": "gpt-5", + "kind": "USAGE_EVENT_KIND_USAGE_BASED", + "requestsCosts": 1, + "usageBasedCosts": "$0.04", + "isTokenBasedCall": false, + "owningUser": "190525724", + "owningTeam": "15113845" + }, + { + "timestamp": "1756037371784", + "model": "gpt-5", + "kind": "USAGE_EVENT_KIND_USAGE_BASED", + "requestsCosts": 1, + "usageBasedCosts": "$0.04", + "isTokenBasedCall": false, + "owningUser": "190525724", + "owningTeam": "15113845" + }, + { + "timestamp": "1756037132458", + "model": "claude-4-sonnet-thinking", + "kind": "USAGE_EVENT_KIND_ABORTED_NOT_CHARGED", + "usageBasedCosts": "$0.00", + "isTokenBasedCall": false, + "owningUser": "190525724", + "owningTeam": "15113845" + }, + { + "timestamp": "1756036800093", + "model": "gpt-5", + "kind": "USAGE_EVENT_KIND_USAGE_BASED", + "requestsCosts": 1, + "usageBasedCosts": "$0.04", + "isTokenBasedCall": false, + "owningUser": "190525724", + "owningTeam": "15113845" + }, + { + "timestamp": "1756036687610", + "model": "gpt-5", + "kind": "USAGE_EVENT_KIND_USAGE_BASED", + "requestsCosts": 1, + "usageBasedCosts": "$0.04", + "isTokenBasedCall": false, + "owningUser": "190525724", + "owningTeam": "15113845" + }, + { + "timestamp": "1756036223465", + "model": "gpt-5", + "kind": "USAGE_EVENT_KIND_USAGE_BASED", + "requestsCosts": 1, + "usageBasedCosts": "$0.04", + "isTokenBasedCall": false, + "owningUser": "190525724", + "owningTeam": "15113845" + }, + { + "timestamp": "1756035968618", + "model": "gpt-5", + "kind": "USAGE_EVENT_KIND_USAGE_BASED", + "requestsCosts": 1, + "usageBasedCosts": "$0.04", + "isTokenBasedCall": false, + "owningUser": "190525724", + "owningTeam": "15113845" + }, + { + "timestamp": "1755960848223", + "model": "gpt-5", + "kind": "USAGE_EVENT_KIND_USAGE_BASED", + "requestsCosts": 1, + "usageBasedCosts": "$0.04", + "isTokenBasedCall": false, + "owningUser": "190525724", + "owningTeam": "15113845" + }, + { + "timestamp": "1755960451719", + "model": "gpt-5", + "kind": "USAGE_EVENT_KIND_USAGE_BASED", + "requestsCosts": 1, + "usageBasedCosts": "$0.04", + "isTokenBasedCall": false, + "owningUser": "190525724", + "owningTeam": "15113845" + }, + { + "timestamp": "1755959571316", + "model": "gemini-2.5-pro", + "kind": "USAGE_EVENT_KIND_USAGE_BASED", + "requestsCosts": 1, + "usageBasedCosts": "$0.04", + "isTokenBasedCall": false, + "owningUser": "190525724", + "owningTeam": "15113845" + }, + { + "timestamp": "1755959507713", + "model": "gemini-2.5-pro", + "kind": "USAGE_EVENT_KIND_USAGE_BASED", + "requestsCosts": 1, + "usageBasedCosts": "$0.04", + "isTokenBasedCall": false, + "owningUser": "190525724", + "owningTeam": "15113845" + }, + { + "timestamp": "1755959345878", + "model": "gemini-2.5-pro", + "kind": "USAGE_EVENT_KIND_USAGE_BASED", + "requestsCosts": 1, + "usageBasedCosts": "$0.04", + "isTokenBasedCall": false, + "owningUser": "190525724", + "owningTeam": "15113845" + }, + { + "timestamp": "1755959185761", + "model": "gemini-2.5-pro", + "kind": "USAGE_EVENT_KIND_USAGE_BASED", + "requestsCosts": 1, + "usageBasedCosts": "$0.04", + "isTokenBasedCall": false, + "owningUser": "190525724", + "owningTeam": "15113845" + }, + { + "timestamp": "1755959104622", + "model": "gemini-2.5-pro", + "kind": "USAGE_EVENT_KIND_ERRORED_NOT_CHARGED", + "usageBasedCosts": "$0.00", + "isTokenBasedCall": false, + "owningUser": "190525724", + "owningTeam": "15113845" + }, + { + "timestamp": "1755959058276", + "model": "gemini-2.5-pro", + "kind": "USAGE_EVENT_KIND_USAGE_BASED", + "requestsCosts": 1, + "usageBasedCosts": "$0.04", + "isTokenBasedCall": false, + "owningUser": "190525724", + "owningTeam": "15113845" + }, + { + "timestamp": "1755958875478", + "model": "gpt-5", + "kind": "USAGE_EVENT_KIND_USAGE_BASED", + "requestsCosts": 1, + "usageBasedCosts": "$0.04", + "isTokenBasedCall": false, + "owningUser": "190525724", + "owningTeam": "15113845" + }, + { + "timestamp": "1755958797537", + "model": "gpt-5", + "kind": "USAGE_EVENT_KIND_USAGE_BASED", + "requestsCosts": 1, + "usageBasedCosts": "$0.04", + "isTokenBasedCall": false, + "owningUser": "190525724", + "owningTeam": "15113845" + }, + { + "timestamp": "1755958448507", + "model": "gpt-5", + "kind": "USAGE_EVENT_KIND_USAGE_BASED", + "requestsCosts": 1, + "usageBasedCosts": "$0.04", + "isTokenBasedCall": false, + "owningUser": "190525724", + "owningTeam": "15113845" + }, + { + "timestamp": "1755958357202", + "model": "gemini-2.5-pro", + "kind": "USAGE_EVENT_KIND_ERRORED_NOT_CHARGED", + "usageBasedCosts": "$0.00", + "isTokenBasedCall": false, + "owningUser": "190525724", + "owningTeam": "15113845" + }, + { + "timestamp": "1755958195881", + "model": "gemini-2.5-pro", + "kind": "USAGE_EVENT_KIND_ERRORED_NOT_CHARGED", + "usageBasedCosts": "$0.00", + "isTokenBasedCall": false, + "owningUser": "190525724", + "owningTeam": "15113845" + }, + { + "timestamp": "1755957286553", + "model": "claude-4-sonnet-thinking", + "kind": "USAGE_EVENT_KIND_USAGE_BASED", + "requestsCosts": 2, + "usageBasedCosts": "$0.08", + "isTokenBasedCall": false, + "owningUser": "190525724", + "owningTeam": "15113845" + }, + { + "timestamp": "1755956137227", + "model": "gemini-2.5-pro", + "kind": "USAGE_EVENT_KIND_USAGE_BASED", + "requestsCosts": 1, + "usageBasedCosts": "$0.04", + "isTokenBasedCall": false, + "owningUser": "190525724", + "owningTeam": "15113845" + }, + { + "timestamp": "1755956043892", + "model": "gemini-2.5-pro", + "kind": "USAGE_EVENT_KIND_USAGE_BASED", + "requestsCosts": 1, + "usageBasedCosts": "$0.04", + "isTokenBasedCall": false, + "owningUser": "190525724", + "owningTeam": "15113845" + }, + { + "timestamp": "1755955912774", + "model": "gemini-2.5-pro", + "kind": "USAGE_EVENT_KIND_USAGE_BASED", + "requestsCosts": 1, + "usageBasedCosts": "$0.04", + "isTokenBasedCall": false, + "owningUser": "190525724", + "owningTeam": "15113845" + }, + { + "timestamp": "1755954287497", + "model": "gemini-2.5-pro", + "kind": "USAGE_EVENT_KIND_USAGE_BASED", + "requestsCosts": 1, + "usageBasedCosts": "$0.04", + "isTokenBasedCall": false, + "owningUser": "190525724", + "owningTeam": "15113845" + }, + { + "timestamp": "1755954067654", + "model": "gemini-2.5-pro", + "kind": "USAGE_EVENT_KIND_USAGE_BASED", + "requestsCosts": 1, + "usageBasedCosts": "$0.04", + "isTokenBasedCall": false, + "owningUser": "190525724", + "owningTeam": "15113845" + }, + { + "timestamp": "1755953785644", + "model": "gpt-5", + "kind": "USAGE_EVENT_KIND_USAGE_BASED", + "requestsCosts": 1, + "usageBasedCosts": "$0.04", + "isTokenBasedCall": false, + "owningUser": "190525724", + "owningTeam": "15113845" + }, + { + "timestamp": "1755953670452", + "model": "gpt-5", + "kind": "USAGE_EVENT_KIND_ERRORED_NOT_CHARGED", + "usageBasedCosts": "$0.00", + "isTokenBasedCall": false, + "owningUser": "190525724", + "owningTeam": "15113845" + }, + { + "timestamp": "1755953035113", + "model": "gpt-5", + "kind": "USAGE_EVENT_KIND_USAGE_BASED", + "requestsCosts": 1, + "usageBasedCosts": "$0.04", + "isTokenBasedCall": false, + "owningUser": "190525724", + "owningTeam": "15113845" + }, + { + "timestamp": "1755929085971", + "model": "gpt-5", + "kind": "USAGE_EVENT_KIND_USAGE_BASED", + "requestsCosts": 1, + "usageBasedCosts": "$0.04", + "isTokenBasedCall": false, + "owningUser": "190525724", + "owningTeam": "15113845" + }, + { + "timestamp": "1755928797667", + "model": "gpt-5", + "kind": "USAGE_EVENT_KIND_USAGE_BASED", + "requestsCosts": 1, + "usageBasedCosts": "$0.04", + "isTokenBasedCall": false, + "owningUser": "190525724", + "owningTeam": "15113845" + }, + { + "timestamp": "1755928637560", + "model": "gpt-5", + "kind": "USAGE_EVENT_KIND_USAGE_BASED", + "requestsCosts": 1, + "usageBasedCosts": "$0.04", + "isTokenBasedCall": false, + "owningUser": "190525724", + "owningTeam": "15113845" + } + ] +} \ No newline at end of file diff --git a/参考计费/Vibeviewer/cursor_web_api_curl/get_me.txt b/参考计费/Vibeviewer/cursor_web_api_curl/get_me.txt new file mode 100644 index 0000000..9dc6a67 --- /dev/null +++ b/参考计费/Vibeviewer/cursor_web_api_curl/get_me.txt @@ -0,0 +1,34 @@ +// request 获取我账号的信息,id,邮箱等 + +curl 'https://cursor.com/api/dashboard/get-me' \ + -H 'accept: */*' \ + -H 'accept-language: zh-CN,zh;q=0.9' \ + -H 'cache-control: no-cache' \ + -H 'content-type: application/json' \ + -b 'ph_phc_Rbh7SDD9nCGKRBOqjoiijTSAW8WMAJpVQEs6hGEPqZp_posthog=%7B%22distinct_id%22%3A%2219173c4ad7e1493-06705278063831-18525637-384000-19173c4ad7f32e6%22%2C%22%24device_id%22%3A%2219173c4ad7e1493-06705278063831-18525637-384000-19173c4ad7f32e6%22%2C%22%24user_state%22%3A%22anonymous%22%2C%22%24sesid%22%3A%5B1724985313038%2C%22191a112dc194d6-00f44b74fff70c-18525637-384000-191a112dc1a2457%22%2C1724984253465%5D%2C%22%24session_recording_enabled_server_side%22%3Afalse%2C%22%24autocapture_disabled_server_side%22%3Afalse%2C%22%24active_feature_flags%22%3A%5B%5D%2C%22%24enabled_feature_flags%22%3A%7B%7D%2C%22%24feature_flag_payloads%22%3A%7B%7D%7D; ph_phc_OrLbTmMnw0Ou1C4xuVIWJJaijIcp4J9Cm4JsAVRLtJo_posthog=%7B%22distinct_id%22%3A%22191754b04a119a8-0ed566da8c313e-18525637-384000-191754b04a23a15%22%2C%22%24device_id%22%3A%22191754b04a119a8-0ed566da8c313e-18525637-384000-191754b04a23a15%22%2C%22%24user_state%22%3A%22anonymous%22%2C%22%24sesid%22%3A%5B1728977816053%2C%221928f155ee2417b-0fc04a964da2e1-16525637-384000-1928f155ee32fa4%22%2C1728977395426%5D%2C%22%24session_recording_enabled_server_side%22%3Afalse%2C%22%24autocapture_disabled_server_side%22%3Afalse%2C%22%24active_feature_flags%22%3A%5B%5D%2C%22%24enabled_feature_flags%22%3A%7B%7D%2C%22%24feature_flag_payloads%22%3A%7B%7D%7D; IndrX2ZuSmZramJSX0NIYUZoRzRzUGZ0cENIVHpHNXk0VE0ya2ZiUkVzQU14X2Fub255bW91c1VzZXJJZCI%3D=Ijg4NGNiZDkwLWNiN2MtNDU2OC05NTJiLWYzMDM1MzI2MmI2OSI=; NEXT_LOCALE=en; WorkosCursorSessionToken=user_01JRXG9J1YYXYF39Y4NAR5AMCE%3A%3AeyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJhdXRoMHx1c2VyXzAxSlJYRzlKMVlZWFlGMzlZNE5BUjVBTUNFIiwidGltZSI6IjE3NTQ5MjkwMzciLCJyYW5kb21uZXNzIjoiOTI1OTg5M2MtZGM1YS00YmFiIiwiZXhwIjoxNzYwMTEzMDM3LCJpc3MiOiJodHRwczovL2F1dGhlbnRpY2F0aW9uLmN1cnNvci5zaCIsInNjb3BlIjoib3BlbmlkIHByb2ZpbGUgZW1haWwgb2ZmbGluZV9hY2Nlc3MiLCJhdWQiOiJodHRwczovL2N1cnNvci5jb20iLCJ0eXBlIjoid2ViIn0.6xFI-QuOQ1mEs9NbZ2Q-b9_3j3X6U1_Jy_pVfUtGPlg; ph_phc_TXdpocbGVeZVm5VJmAsHTMrCofBQu3e0kN8HGMNGTVW_posthog=%7B%22distinct_id%22%3A%22019173c4-44fa-7c89-850b-6ade7fe8eed6%22%2C%22%24sesid%22%3A%5B1756035992474%2C%220198dbe6-cb44-7288-badf-61d3ca2c8d5c%22%2C1756035992388%5D%7D; htjs_anonymous_id=e1fbd20a-84b9-4a9b-990d-15fc360fc973; htjs_sesh={%22id%22:1756035992760%2C%22expiresAt%22:1756037792760%2C%22timeout%22:1800000%2C%22sessionStart%22:true%2C%22autoTrack%22:true}' \ + -H 'dnt: 1' \ + -H 'origin: https://cursor.com' \ + -H 'pragma: no-cache' \ + -H 'priority: u=1, i' \ + -H 'referer: https://cursor.com/dashboard' \ + -H 'sec-ch-ua: "Chromium";v="139", "Not;A=Brand";v="99"' \ + -H 'sec-ch-ua-arch: "arm"' \ + -H 'sec-ch-ua-bitness: "64"' \ + -H 'sec-ch-ua-mobile: ?0' \ + -H 'sec-ch-ua-platform: "macOS"' \ + -H 'sec-ch-ua-platform-version: "15.6.0"' \ + -H 'sec-fetch-dest: empty' \ + -H 'sec-fetch-mode: cors' \ + -H 'sec-fetch-site: same-origin' \ + -H 'user-agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36' \ + --data-raw '{}' + +// Response + +{ + "authId": "auth0|user_01JRXG9J1YYXYF39Y4NAR5AMCE", + "userId": 190525724, + "email": "svendaviskl@outlook.pt", + "workosId": "user_01JRXG9J1YYXYF39Y4NAR5AMCE", + "teamId": 15113845 +} \ No newline at end of file diff --git a/参考计费/Vibeviewer/cursor_web_api_curl/get_team_spend.txt b/参考计费/Vibeviewer/cursor_web_api_curl/get_team_spend.txt new file mode 100644 index 0000000..8124544 --- /dev/null +++ b/参考计费/Vibeviewer/cursor_web_api_curl/get_team_spend.txt @@ -0,0 +1,83 @@ +// request, 获取我team中的使用量情况,使用这里与我id相同的spendCents和fastPremiumRequests,还有hardLimitOverrideDollars作为我的用量数据源 + +curl 'https://cursor.com/api/dashboard/get-team-spend' \ + -H 'accept: */*' \ + -H 'accept-language: zh-CN,zh;q=0.9' \ + -H 'cache-control: no-cache' \ + -H 'content-type: application/json' \ + -b 'ph_phc_Rbh7SDD9nCGKRBOqjoiijTSAW8WMAJpVQEs6hGEPqZp_posthog=%7B%22distinct_id%22%3A%2219173c4ad7e1493-06705278063831-18525637-384000-19173c4ad7f32e6%22%2C%22%24device_id%22%3A%2219173c4ad7e1493-06705278063831-18525637-384000-19173c4ad7f32e6%22%2C%22%24user_state%22%3A%22anonymous%22%2C%22%24sesid%22%3A%5B1724985313038%2C%22191a112dc194d6-00f44b74fff70c-18525637-384000-191a112dc1a2457%22%2C1724984253465%5D%2C%22%24session_recording_enabled_server_side%22%3Afalse%2C%22%24autocapture_disabled_server_side%22%3Afalse%2C%22%24active_feature_flags%22%3A%5B%5D%2C%22%24enabled_feature_flags%22%3A%7B%7D%2C%22%24feature_flag_payloads%22%3A%7B%7D%7D; ph_phc_OrLbTmMnw0Ou1C4xuVIWJJaijIcp4J9Cm4JsAVRLtJo_posthog=%7B%22distinct_id%22%3A%22191754b04a119a8-0ed566da8c313e-18525637-384000-191754b04a23a15%22%2C%22%24device_id%22%3A%22191754b04a119a8-0ed566da8c313e-18525637-384000-191754b04a23a15%22%2C%22%24user_state%22%3A%22anonymous%22%2C%22%24sesid%22%3A%5B1728977816053%2C%221928f155ee2417b-0fc04a964da2e1-16525637-384000-1928f155ee32fa4%22%2C1728977395426%5D%2C%22%24session_recording_enabled_server_side%22%3Afalse%2C%22%24autocapture_disabled_server_side%22%3Afalse%2C%22%24active_feature_flags%22%3A%5B%5D%2C%22%24enabled_feature_flags%22%3A%7B%7D%2C%22%24feature_flag_payloads%22%3A%7B%7D%7D; IndrX2ZuSmZramJSX0NIYUZoRzRzUGZ0cENIVHpHNXk0VE0ya2ZiUkVzQU14X2Fub255bW91c1VzZXJJZCI%3D=Ijg4NGNiZDkwLWNiN2MtNDU2OC05NTJiLWYzMDM1MzI2MmI2OSI=; NEXT_LOCALE=en; WorkosCursorSessionToken=user_01JRXG9J1YYXYF39Y4NAR5AMCE%3A%3AeyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJhdXRoMHx1c2VyXzAxSlJYRzlKMVlZWFlGMzlZNE5BUjVBTUNFIiwidGltZSI6IjE3NTQ5MjkwMzciLCJyYW5kb21uZXNzIjoiOTI1OTg5M2MtZGM1YS00YmFiIiwiZXhwIjoxNzYwMTEzMDM3LCJpc3MiOiJodHRwczovL2F1dGhlbnRpY2F0aW9uLmN1cnNvci5zaCIsInNjb3BlIjoib3BlbmlkIHByb2ZpbGUgZW1haWwgb2ZmbGluZV9hY2Nlc3MiLCJhdWQiOiJodHRwczovL2N1cnNvci5jb20iLCJ0eXBlIjoid2ViIn0.6xFI-QuOQ1mEs9NbZ2Q-b9_3j3X6U1_Jy_pVfUtGPlg; ph_phc_TXdpocbGVeZVm5VJmAsHTMrCofBQu3e0kN8HGMNGTVW_posthog=%7B%22distinct_id%22%3A%22019173c4-44fa-7c89-850b-6ade7fe8eed6%22%2C%22%24sesid%22%3A%5B1756035992474%2C%220198dbe6-cb44-7288-badf-61d3ca2c8d5c%22%2C1756035992388%5D%7D; htjs_anonymous_id=e1fbd20a-84b9-4a9b-990d-15fc360fc973; htjs_sesh={%22id%22:1756035992760%2C%22expiresAt%22:1756037792760%2C%22timeout%22:1800000%2C%22sessionStart%22:true%2C%22autoTrack%22:true}' \ + -H 'dnt: 1' \ + -H 'origin: https://cursor.com' \ + -H 'pragma: no-cache' \ + -H 'priority: u=1, i' \ + -H 'referer: https://cursor.com/dashboard' \ + -H 'sec-ch-ua: "Chromium";v="139", "Not;A=Brand";v="99"' \ + -H 'sec-ch-ua-arch: "arm"' \ + -H 'sec-ch-ua-bitness: "64"' \ + -H 'sec-ch-ua-mobile: ?0' \ + -H 'sec-ch-ua-platform: "macOS"' \ + -H 'sec-ch-ua-platform-version: "15.6.0"' \ + -H 'sec-fetch-dest: empty' \ + -H 'sec-fetch-mode: cors' \ + -H 'sec-fetch-site: same-origin' \ + -H 'user-agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36' \ + --data-raw '{"teamId":15113845}' + + +// Response + +{ + "teamMemberSpend": [ + { + "userId": 190534072, + "email": "deltamurazikyt@outlook.sa", + "role": "TEAM_ROLE_MEMBER", + "hardLimitOverrideDollars": 20 + }, + { + "userId": 190555359, + "email": "jackiestammhtg@outlook.pt", + "role": "TEAM_ROLE_MEMBER", + "hardLimitOverrideDollars": 40 + }, + { + "userId": 190553762, + "email": "lizziequigleywv@outlook.kr", + "role": "TEAM_ROLE_OWNER", + "hardLimitOverrideDollars": 1 + }, + { + "userId": 190552176, + "email": "mayamoenqky@outlook.dk", + "role": "TEAM_ROLE_MEMBER", + "hardLimitOverrideDollars": 40 + }, + { + "userId": 190549879, + "email": "nicklaussanfordtux@outlook.pt", + "role": "TEAM_ROLE_MEMBER", + "hardLimitOverrideDollars": 20 + }, + { + "userId": 190525724, + "spendCents": 875, + "fastPremiumRequests": 500, + "email": "svendaviskl@outlook.pt", + "role": "TEAM_ROLE_MEMBER", + "hardLimitOverrideDollars": 20 + } + ], + "subscriptionCycleStart": "1754879994000", + "totalMembers": 6, + "totalPages": 1, + "totalByRole": [ + { + "role": "TEAM_ROLE_OWNER", + "count": 1 + }, + { + "role": "TEAM_ROLE_MEMBER", + "count": 5 + } + ] +} \ No newline at end of file diff --git a/参考计费/Vibeviewer/cursor_web_api_curl/get_usage.txt b/参考计费/Vibeviewer/cursor_web_api_curl/get_usage.txt new file mode 100644 index 0000000..03db922 --- /dev/null +++ b/参考计费/Vibeviewer/cursor_web_api_curl/get_usage.txt @@ -0,0 +1,48 @@ +// request 获取我Plan中已使用的requst数量,numRequests表示套餐中已发送的request,numRequestsTotal作为套餐中和按量付费情况下总的发送request情况 + +curl 'https://cursor.com/api/usage?user=user_01JRXG9J1YYXYF39Y4NAR5AMCE' \ + -H 'accept: */*' \ + -H 'accept-language: zh-CN,zh;q=0.9' \ + -H 'cache-control: no-cache' \ + -b 'ph_phc_Rbh7SDD9nCGKRBOqjoiijTSAW8WMAJpVQEs6hGEPqZp_posthog=%7B%22distinct_id%22%3A%2219173c4ad7e1493-06705278063831-18525637-384000-19173c4ad7f32e6%22%2C%22%24device_id%22%3A%2219173c4ad7e1493-06705278063831-18525637-384000-19173c4ad7f32e6%22%2C%22%24user_state%22%3A%22anonymous%22%2C%22%24sesid%22%3A%5B1724985313038%2C%22191a112dc194d6-00f44b74fff70c-18525637-384000-191a112dc1a2457%22%2C1724984253465%5D%2C%22%24session_recording_enabled_server_side%22%3Afalse%2C%22%24autocapture_disabled_server_side%22%3Afalse%2C%22%24active_feature_flags%22%3A%5B%5D%2C%22%24enabled_feature_flags%22%3A%7B%7D%2C%22%24feature_flag_payloads%22%3A%7B%7D%7D; ph_phc_OrLbTmMnw0Ou1C4xuVIWJJaijIcp4J9Cm4JsAVRLtJo_posthog=%7B%22distinct_id%22%3A%22191754b04a119a8-0ed566da8c313e-18525637-384000-191754b04a23a15%22%2C%22%24device_id%22%3A%22191754b04a119a8-0ed566da8c313e-18525637-384000-191754b04a23a15%22%2C%22%24user_state%22%3A%22anonymous%22%2C%22%24sesid%22%3A%5B1728977816053%2C%221928f155ee2417b-0fc04a964da2e1-16525637-384000-1928f155ee32fa4%22%2C1728977395426%5D%2C%22%24session_recording_enabled_server_side%22%3Afalse%2C%22%24autocapture_disabled_server_side%22%3Afalse%2C%22%24active_feature_flags%22%3A%5B%5D%2C%22%24enabled_feature_flags%22%3A%7B%7D%2C%22%24feature_flag_payloads%22%3A%7B%7D%7D; IndrX2ZuSmZramJSX0NIYUZoRzRzUGZ0cENIVHpHNXk0VE0ya2ZiUkVzQU14X2Fub255bW91c1VzZXJJZCI%3D=Ijg4NGNiZDkwLWNiN2MtNDU2OC05NTJiLWYzMDM1MzI2MmI2OSI=; NEXT_LOCALE=en; WorkosCursorSessionToken=user_01JRXG9J1YYXYF39Y4NAR5AMCE%3A%3AeyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJhdXRoMHx1c2VyXzAxSlJYRzlKMVlZWFlGMzlZNE5BUjVBTUNFIiwidGltZSI6IjE3NTQ5MjkwMzciLCJyYW5kb21uZXNzIjoiOTI1OTg5M2MtZGM1YS00YmFiIiwiZXhwIjoxNzYwMTEzMDM3LCJpc3MiOiJodHRwczovL2F1dGhlbnRpY2F0aW9uLmN1cnNvci5zaCIsInNjb3BlIjoib3BlbmlkIHByb2ZpbGUgZW1haWwgb2ZmbGluZV9hY2Nlc3MiLCJhdWQiOiJodHRwczovL2N1cnNvci5jb20iLCJ0eXBlIjoid2ViIn0.6xFI-QuOQ1mEs9NbZ2Q-b9_3j3X6U1_Jy_pVfUtGPlg; ph_phc_TXdpocbGVeZVm5VJmAsHTMrCofBQu3e0kN8HGMNGTVW_posthog=%7B%22distinct_id%22%3A%22019173c4-44fa-7c89-850b-6ade7fe8eed6%22%2C%22%24sesid%22%3A%5B1756035992474%2C%220198dbe6-cb44-7288-badf-61d3ca2c8d5c%22%2C1756035992388%5D%7D; htjs_anonymous_id=e1fbd20a-84b9-4a9b-990d-15fc360fc973; htjs_sesh={%22id%22:1756035992760%2C%22expiresAt%22:1756037792760%2C%22timeout%22:1800000%2C%22sessionStart%22:true%2C%22autoTrack%22:true}' \ + -H 'dnt: 1' \ + -H 'pragma: no-cache' \ + -H 'priority: u=1, i' \ + -H 'referer: https://cursor.com/dashboard' \ + -H 'sec-ch-ua: "Chromium";v="139", "Not;A=Brand";v="99"' \ + -H 'sec-ch-ua-arch: "arm"' \ + -H 'sec-ch-ua-bitness: "64"' \ + -H 'sec-ch-ua-mobile: ?0' \ + -H 'sec-ch-ua-platform: "macOS"' \ + -H 'sec-ch-ua-platform-version: "15.6.0"' \ + -H 'sec-fetch-dest: empty' \ + -H 'sec-fetch-mode: cors' \ + -H 'sec-fetch-site: same-origin' \ + -H 'user-agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36' + +// Response + +{ + "gpt-4": { + "numRequests": 501, + "numRequestsTotal": 654, + "numTokens": 8351834, + "maxRequestUsage": 500, + "maxTokenUsage": null + }, + "gpt-3.5-turbo": { + "numRequests": 6, + "numRequestsTotal": 6, + "numTokens": 131640, + "maxRequestUsage": null, + "maxTokenUsage": null + }, + "gpt-4-32k": { + "numRequests": 0, + "numRequestsTotal": 0, + "numTokens": 0, + "maxRequestUsage": 50, + "maxTokenUsage": null + }, + "startOfMonth": "2025-08-11T02:39:54.000Z" +} \ No newline at end of file diff --git a/参考计费/dmg_background.png b/参考计费/dmg_background.png new file mode 100644 index 0000000..18daaef Binary files /dev/null and b/参考计费/dmg_background.png differ