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
+
+
+
+
+
+
+
🔐
+
需要管理员权限
+
+ 请关闭 Cursor,右键点击图标
+ 选择 以管理员身份运行
+
+
+
+
+
+
+
+
+
+
+
🔐
+
需要管理员权限
+
+ 重置机器码需要管理员权限才能完整执行。
+ 请按以下步骤操作:
+ 1. 完全关闭 Cursor
+ 2. 右键点击 Cursor 图标
+ 3. 选择 以管理员身份运行
+ 4. 再次点击重置机器码
+
+
+
+
+
+
+
+
+
+
+
✓
+
操作成功
+
+ 需要重启 Cursor 才能生效
+
+
+
+
+
+
+
+
+
+
+
+
⏰
+
激活码已过期
+
+ 您的激活码已过期,请续费后继续使用
+
+
+
+
+
+
+
+
+
+
+
⚠️
+
清理 Cursor 环境
+
+ 此操作会删除所有配置和登录信息
确定要继续吗?
+
+
+
+
+
+
+
+
+
+
+
+
💰
+
账号未使用完
+
+ 当前账号
+ 已用额度: $0.00 (不足 $10)
+ 确定要换号吗?
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 🔐
+ 软件授权
+ 未授权
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
🌿 AUTO 密钥
+
未激活
+
+
+
+
+
⚡ PRO 密钥
+
未激活
+
+
+
+
+
+
+
+ 激活码
+ 尚未激活
+
+
+ 到期时间
+ 尚未激活
+
+
+
+
+
+
+ 👤
+ 账号数据
+ 未激活
+
+
+
+ CI积分余额
+ 0
+
+
+
+
+
+
+
+
+
+
+
+
+ ⚡
+ 无感换号
+ 未启用
+
+
+
+ 积分
+ 0
+
+
+
+ 当前账号
+ 未分配
+
+
+
+ 免魔法模式
+ PRO
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 📊
+ 账号用量
+
+
+
+
+
+ 会员类型
+ -
+
+
+ 试用剩余
+ -
+
+
+
+
+ 请求次数
+ -
+
+
+ 已用额度
+ -
+
+
+
-
+
+
+
+
+
+ 📢
+ 公告
+ info
+
+
+
+
+
+
+
+
+
+ 📦
+ 版本信息
+ 有更新
+
+
+ 当前版本
+ -
+
+
+ 最新版本
+ -
+
+
+ ⚠️ 发现新版本,请更新插件以获取最新功能
+
+
+
+
+
+
+
+
+
+
+
+
\ 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
+
+
+
+
+
+
+
🔐
+
需要管理员权限
+
+ 请关闭 Cursor,右键点击图标
+ 选择 以管理员身份运行
+
+
+
+
+
+
+
+
+
+
+
🔐
+
需要管理员权限
+
+ 重置机器码需要管理员权限才能完整执行。
+ 请按以下步骤操作:
+ 1. 完全关闭 Cursor
+ 2. 右键点击 Cursor 图标
+ 3. 选择 以管理员身份运行
+ 4. 再次点击重置机器码
+
+
+
+
+
+
+
+
+
+
+
✓
+
操作成功
+
+ 需要重启 Cursor 才能生效
+
+
+
+
+
+
+
+
+
+
+
+
⏰
+
激活码已过期
+
+ 您的激活码已过期,请续费后继续使用
+
+
+
+
+
+
+
+
+
+
+
⚠️
+
清理 Cursor 环境
+
+ 此操作会删除所有配置和登录信息
确定要继续吗?
+
+
+
+
+
+
+
+
+
+
+
+
💰
+
账号未使用完
+
+ 当前账号
+ 已用额度: $0.00 (不足 $10)
+ 确定要换号吗?
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 🔐
+ 软件授权
+ 未授权
+
+
+
+
+
+
+
+
+ 激活码
+ 尚未激活
+
+
+ 到期时间
+ 尚未激活
+
+
+
+
+
+
+ 👤
+ 账号数据
+ 未激活
+
+
+
+ CI积分余额
+ 0
+
+
+
+
+
+
+
+
+
+
+
+
+ ⚡
+ 无感换号
+ 未启用
+
+
+
+ 积分
+ 0
+
+
+
+ 当前账号
+ 未分配
+
+
+
+ 免魔法模式
+ PRO
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 📊
+ 账号用量
+
+
+
+
+
+ 会员类型
+ -
+
+
+ 试用剩余
+ -
+
+
+
+
+ 请求次数
+ -
+
+
+ 已用额度
+ -
+
+
+
-
+
+
+
+
+
+ 📢
+ 公告
+ info
+
+
+
+
+
+
+
+
+
+ 📦
+ 版本信息
+ 有更新
+
+
+ 当前版本
+ -
+
+
+ 最新版本
+ -
+
+
+ ⚠️ 发现新版本,请更新插件以获取最新功能
+
+
+
+
+
+
+
+
+
+
+
+
\ 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 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 \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)
+
+
+
+
+
+
+
+
+
+**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`, `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