Compare commits
3 Commits
c07e8e9c19
...
1afee2de73
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1afee2de73 | ||
|
|
07b81ff7a2 | ||
|
|
4c3388ed36 |
@ -5,6 +5,8 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"dubbo.apache.org/dubbo-go/v3/client"
|
||||
"dubbo.apache.org/dubbo-go/v3/common/constant"
|
||||
@ -13,9 +15,12 @@ import (
|
||||
"github.com/topfans/backend/gateway/dto"
|
||||
"github.com/topfans/backend/gateway/pkg/ossutil"
|
||||
"github.com/topfans/backend/gateway/pkg/response"
|
||||
"github.com/topfans/backend/pkg/database"
|
||||
"github.com/topfans/backend/pkg/jwt"
|
||||
"github.com/topfans/backend/pkg/logger"
|
||||
pb "github.com/topfans/backend/pkg/proto/user"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// UserController 用户控制器
|
||||
@ -614,3 +619,146 @@ func (ctrl *UserController) GetMyFanIdentities(c *gin.Context) {
|
||||
data := dto.ToMyFanIdentitiesResponseDTO(resp.Items, resp.CurrentStarId)
|
||||
response.Success(c, data)
|
||||
}
|
||||
|
||||
// DeleteAccount 注销账号
|
||||
// @Summary 注销账号
|
||||
// @Description 软删除当前用户账号:停用账号和所有粉丝身份,释放手机号供重新注册
|
||||
// @Tags users
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Success 200 {object} response.Response
|
||||
// @Router /api/user/delete-account [post]
|
||||
func (ctrl *UserController) DeleteAccount(c *gin.Context) {
|
||||
// 从认证中间件获取用户信息
|
||||
userID, exists := c.Get("user_id")
|
||||
if !exists {
|
||||
response.Error(c, http.StatusUnauthorized, "未授权")
|
||||
return
|
||||
}
|
||||
|
||||
uid := userID.(int64)
|
||||
|
||||
logger.Logger.Info("DeleteAccount request",
|
||||
zap.Int64("user_id", uid),
|
||||
)
|
||||
|
||||
// 获取数据库连接
|
||||
db := database.GetDB()
|
||||
if db == nil {
|
||||
logger.Logger.Error("DeleteAccount: database not initialized")
|
||||
response.Error(c, http.StatusInternalServerError, "服务内部错误")
|
||||
return
|
||||
}
|
||||
|
||||
// 提前黑名单当前 token,防止事务成功但黑名单失败导致的漏洞
|
||||
// 解析 token 获取过期时间,TTL = 剩余有效时间
|
||||
tokenTTL := 7 * 24 * time.Hour // 默认 7 天
|
||||
authHeader := c.GetHeader("Authorization")
|
||||
rawToken := strings.TrimPrefix(authHeader, "Bearer ")
|
||||
if claims, parseErr := jwt.ParseToken(rawToken); parseErr == nil && claims.ExpiresAt != nil {
|
||||
if remaining := time.Until(claims.ExpiresAt.Time); remaining > 0 {
|
||||
tokenTTL = remaining
|
||||
}
|
||||
}
|
||||
// 黑名单写入(Redis 不可用时记录日志但不阻断注销流程)
|
||||
blacklistCtx := context.Background()
|
||||
if redisErr := database.AddToBlacklist(blacklistCtx, rawToken, uid, "account_deleted", tokenTTL); redisErr != nil {
|
||||
logger.Logger.Warn("DeleteAccount: failed to blacklist token",
|
||||
zap.Int64("user_id", uid),
|
||||
zap.Error(redisErr),
|
||||
)
|
||||
}
|
||||
|
||||
// 在事务中执行软删除
|
||||
err := db.Transaction(func(tx *gorm.DB) error {
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
// 1. 软删除用户:停用账号、释放手机号、清除token
|
||||
releasedMobile := fmt.Sprintf("D%010d", uid) // D + 用户ID补齐10位 = 11位,符合varchar(11)
|
||||
result := tx.Model(&struct {
|
||||
ID int64 `gorm:"column:id"`
|
||||
IsActive bool `gorm:"column:is_active"`
|
||||
Mobile string `gorm:"column:mobile"`
|
||||
AccessToken *string `gorm:"column:access_token"`
|
||||
DeletedAt *int64 `gorm:"column:deleted_at"`
|
||||
UpdatedAt int64 `gorm:"column:updated_at"`
|
||||
}{}).
|
||||
Table("users").
|
||||
Where("id = ?", uid).
|
||||
Updates(map[string]interface{}{
|
||||
"is_active": false,
|
||||
"mobile": releasedMobile,
|
||||
"access_token": nil,
|
||||
"deleted_at": now,
|
||||
"updated_at": now,
|
||||
})
|
||||
if result.Error != nil {
|
||||
logger.Logger.Error("DeleteAccount: failed to soft-delete user",
|
||||
zap.Int64("user_id", uid),
|
||||
zap.Error(result.Error),
|
||||
)
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
logger.Logger.Warn("DeleteAccount: user already deleted, still deactivating fan profiles",
|
||||
zap.Int64("user_id", uid),
|
||||
)
|
||||
}
|
||||
|
||||
// 2. 停用所有粉丝身份,并释放昵称(解除 uniqueIndex 占用)
|
||||
var profiles []struct {
|
||||
ID int64 `gorm:"column:id"`
|
||||
Nickname string `gorm:"column:nickname"`
|
||||
}
|
||||
if err := tx.Table("fan_profiles").
|
||||
Where("user_id = ? AND is_active = ?", uid, true).
|
||||
Find(&profiles).Error; err != nil {
|
||||
logger.Logger.Error("DeleteAccount: failed to query fan profiles",
|
||||
zap.Int64("user_id", uid),
|
||||
zap.Error(err),
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
for _, p := range profiles {
|
||||
releasedNickname := fmt.Sprintf("D%010d", p.ID) // D + profile ID 补齐 10 位
|
||||
if err := tx.Table("fan_profiles").
|
||||
Where("id = ?", p.ID).
|
||||
Updates(map[string]interface{}{
|
||||
"is_active": false,
|
||||
"nickname": releasedNickname,
|
||||
"updated_at": now,
|
||||
}).Error; err != nil {
|
||||
logger.Logger.Error("DeleteAccount: failed to deactivate fan profile",
|
||||
zap.Int64("user_id", uid),
|
||||
zap.Int64("profile_id", p.ID),
|
||||
zap.Error(err),
|
||||
)
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
logger.Logger.Info("DeleteAccount: fan profiles deactivated",
|
||||
zap.Int64("user_id", uid),
|
||||
zap.Int("count", len(profiles)),
|
||||
)
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
logger.Logger.Error("DeleteAccount transaction failed",
|
||||
zap.Int64("user_id", uid),
|
||||
zap.Error(err),
|
||||
)
|
||||
response.Error(c, http.StatusInternalServerError, "注销失败,请稍后重试")
|
||||
return
|
||||
}
|
||||
|
||||
logger.Logger.Info("DeleteAccount successful",
|
||||
zap.Int64("user_id", uid),
|
||||
)
|
||||
|
||||
response.SuccessWithMessage(c, "账号已注销", nil)
|
||||
}
|
||||
|
||||
@ -5130,6 +5130,10 @@ const docTemplate = `{
|
||||
"current_identity": {
|
||||
"$ref": "#/definitions/dto.CurrentIdentityDTO"
|
||||
},
|
||||
"mobile": {
|
||||
"description": "脱敏手机号,如 139****0001",
|
||||
"type": "string"
|
||||
},
|
||||
"nickname": {
|
||||
"type": "string"
|
||||
},
|
||||
|
||||
@ -60,6 +60,7 @@ type GetMeResponseDTO struct {
|
||||
Nickname string `json:"nickname"`
|
||||
AvatarURL string `json:"avatar_url,omitempty"`
|
||||
ChainAddress string `json:"chain_address,omitempty"`
|
||||
Mobile string `json:"mobile,omitempty"` // 脱敏手机号,如 139****0001
|
||||
CurrentIdentity CurrentIdentityDTO `json:"current_identity"`
|
||||
}
|
||||
|
||||
|
||||
@ -118,6 +118,11 @@ func ToGetMeResponseDTO(user *pb.User, profile *pb.FanProfile, star *pb.Star) Ge
|
||||
dto.ChainAddress = profile.ChainAddress
|
||||
}
|
||||
|
||||
// 脱敏手机号:139****0001
|
||||
if user.Mobile != "" && len(user.Mobile) == 11 {
|
||||
dto.Mobile = user.Mobile[:3] + "****" + user.Mobile[7:]
|
||||
}
|
||||
|
||||
return dto
|
||||
}
|
||||
|
||||
|
||||
@ -225,8 +225,12 @@ func main() {
|
||||
// 4.13 初始化 Activity Hub(WebSocket 实时推送)
|
||||
redisClient := database.GetRedis()
|
||||
activityHub := socket.NewActivityHub(redisClient, cfg.WebSocket.ActivityPath)
|
||||
go activityHub.Run(context.Background())
|
||||
defer activityHub.Close()
|
||||
hubCtx, hubCancel := context.WithCancel(context.Background())
|
||||
go activityHub.Run(hubCtx)
|
||||
defer func() {
|
||||
hubCancel()
|
||||
activityHub.Close()
|
||||
}()
|
||||
logger.Logger.Info("ActivityHub initialized",
|
||||
zap.String("path", cfg.WebSocket.ActivityPath),
|
||||
zap.Bool("redis_available", redisClient != nil),
|
||||
@ -261,5 +265,7 @@ func main() {
|
||||
<-quit
|
||||
|
||||
logger.Logger.Info("Shutting down gateway server...")
|
||||
hubCancel()
|
||||
activityHub.Close()
|
||||
logger.Logger.Info("Gateway server stopped")
|
||||
}
|
||||
|
||||
@ -210,6 +210,13 @@ func SetupRouter(userClient *client.Client, socialClient *client.Client, assetCl
|
||||
account.POST("/password", userCtrl.UpdatePassword) // 更新密码
|
||||
}
|
||||
|
||||
// 用户相关路由(需要认证)
|
||||
user := v1.Group("/user")
|
||||
user.Use(middleware.AuthMiddleware())
|
||||
{
|
||||
user.POST("/delete-account", userCtrl.DeleteAccount) // 注销账号
|
||||
}
|
||||
|
||||
// 我的粉丝身份管理路由(需要认证)
|
||||
myFanIdentities := v1.Group("/my/fan-identities")
|
||||
myFanIdentities.Use(middleware.AuthMiddleware())
|
||||
|
||||
@ -58,13 +58,41 @@ func (h *ActivityHub) ActivityPath() string {
|
||||
return h.activityPath
|
||||
}
|
||||
|
||||
// Run 启动 Redis PSubscribe,收到 publish 后 fanout 到本地连接
|
||||
// Run 启动 Redis PSubscribe 主循环,带自动重连。
|
||||
// 当 Redis 不可用时定时重试,不会永久阻塞;
|
||||
// ch 意外关闭后自动重新订阅,确保 fanout 链路始终存活。
|
||||
func (h *ActivityHub) Run(ctx context.Context) {
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
logger.Logger.Info("ActivityHub Run loop exiting due to context done")
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
h.runOnce(ctx)
|
||||
|
||||
// runOnce 退出说明 PSubscribe 断开或 Redis 不可用;等待后重试
|
||||
logger.Logger.Warn("ActivityHub runOnce exited, will retry after 5s")
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(5 * time.Second):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// runOnce 执行单次 PSubscribe → fanout 循环。
|
||||
func (h *ActivityHub) runOnce(ctx context.Context) {
|
||||
if h.redisClient == nil {
|
||||
logger.Logger.Warn("ActivityHub: redisClient is nil, Pub/Sub fanout disabled")
|
||||
<-ctx.Done()
|
||||
logger.Logger.Warn("ActivityHub: redisClient is nil, Pub/Sub fanout disabled, waiting...")
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
case <-time.After(5 * time.Second):
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
sub := h.redisClient.PSubscribe(ctx, "act:*:messages", "act:*:contributions")
|
||||
defer sub.Close()
|
||||
ch := sub.Channel()
|
||||
@ -72,11 +100,11 @@ func (h *ActivityHub) Run(ctx context.Context) {
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
logger.Logger.Info("ActivityHub Run loop exiting due to context done")
|
||||
logger.Logger.Info("ActivityHub runOnce exiting due to context done")
|
||||
return
|
||||
case msg, ok := <-ch:
|
||||
if !ok {
|
||||
logger.Logger.Warn("ActivityHub Redis Pub/Sub channel closed")
|
||||
logger.Logger.Warn("ActivityHub Redis Pub/Sub channel closed, will reconnect")
|
||||
return
|
||||
}
|
||||
var payload map[string]interface{}
|
||||
@ -89,7 +117,8 @@ func (h *ActivityHub) Run(ctx context.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// fanout 把 payload 推送到订阅该 channel 的所有本地连接
|
||||
// fanout 把 payload 推送到订阅该 channel 的所有本地连接。
|
||||
// 写失败时主动清理死连接,不等 Ping/Pong 超时(最长 ~90s)。
|
||||
func (h *ActivityHub) fanout(channel string, payload map[string]interface{}) {
|
||||
h.mu.RLock()
|
||||
conns := h.subscriptions[channel]
|
||||
@ -99,11 +128,22 @@ func (h *ActivityHub) fanout(channel string, payload map[string]interface{}) {
|
||||
}
|
||||
h.mu.RUnlock()
|
||||
|
||||
var dead []*ActivityConn
|
||||
for _, c := range targets {
|
||||
if err := c.writeJSON(payload); err != nil {
|
||||
logger.Logger.Error("ActivityHub writeJSON failed", zap.Int64("user_id", c.UserID), zap.Error(err))
|
||||
dead = append(dead, c)
|
||||
}
|
||||
}
|
||||
|
||||
// 批量清理死连接(需要写锁,与 RLock 分离以避免死锁)
|
||||
if len(dead) > 0 {
|
||||
h.mu.Lock()
|
||||
for _, c := range dead {
|
||||
h.removeConnLocked(c)
|
||||
}
|
||||
h.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
// HandleWebSocket 处理 /activity 握手
|
||||
@ -148,12 +188,17 @@ func (h *ActivityHub) HandleWebSocket(w http.ResponseWriter, r *http.Request) {
|
||||
zap.Int64("star_id", starID),
|
||||
)
|
||||
|
||||
// 立即推送 auth_response
|
||||
// 立即推送 auth_response,附带 pub/sub 状态
|
||||
pubsubStatus := "connected"
|
||||
if h.redisClient == nil {
|
||||
pubsubStatus = "pubsub_disabled"
|
||||
}
|
||||
_ = conn.WriteJSON(map[string]interface{}{
|
||||
"type": "auth_response",
|
||||
"success": true,
|
||||
"user_id": userID,
|
||||
"star_id": starID,
|
||||
"type": "auth_response",
|
||||
"success": true,
|
||||
"user_id": userID,
|
||||
"star_id": starID,
|
||||
"pubsub_status": pubsubStatus,
|
||||
})
|
||||
|
||||
go c.readPump()
|
||||
@ -309,7 +354,12 @@ func (h *ActivityHub) unsubscribe(c *ActivityConn, activityID int64, topics []st
|
||||
// unregister 断开时清理
|
||||
func (h *ActivityHub) unregister(c *ActivityConn) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
h.removeConnLocked(c)
|
||||
h.mu.Unlock()
|
||||
}
|
||||
|
||||
// removeConnLocked 从 clients 和所有 subscriptions 中移除连接(需持有 h.mu 写锁)
|
||||
func (h *ActivityHub) removeConnLocked(c *ActivityConn) {
|
||||
if conns, ok := h.clients[c.UserID]; ok {
|
||||
delete(conns, c)
|
||||
if len(conns) == 0 {
|
||||
|
||||
@ -31,8 +31,8 @@ var upgrader = websocket.Upgrader{
|
||||
|
||||
// Hub 管理所有 AI Chat WebSocket 连接
|
||||
type Hub struct {
|
||||
// 用户连接映射: userId -> *Connection
|
||||
clients map[int64]*Connection
|
||||
// 用户连接映射: userId -> set of *Connection(支持同一用户多设备)
|
||||
clients map[int64]map[*Connection]struct{}
|
||||
|
||||
// Dubbo 客户端
|
||||
aiChatClient *client.Client
|
||||
@ -69,7 +69,7 @@ func (c *Connection) sendError(code, message string) {
|
||||
// NewHub 创建 Hub 实例
|
||||
func NewHub(aiChatClient *client.Client, aiChatPath string) *Hub {
|
||||
return &Hub{
|
||||
clients: make(map[int64]*Connection),
|
||||
clients: make(map[int64]map[*Connection]struct{}),
|
||||
aiChatClient: aiChatClient,
|
||||
aiChatPath: aiChatPath,
|
||||
}
|
||||
@ -113,9 +113,12 @@ func (h *Hub) HandleWebSocket(w http.ResponseWriter, r *http.Request) {
|
||||
Hub: h,
|
||||
}
|
||||
|
||||
// 注册连接
|
||||
// 注册连接(同一用户多设备各自独立连接)
|
||||
h.mu.Lock()
|
||||
h.clients[userID] = connection
|
||||
if h.clients[userID] == nil {
|
||||
h.clients[userID] = make(map[*Connection]struct{})
|
||||
}
|
||||
h.clients[userID][connection] = struct{}{}
|
||||
h.mu.Unlock()
|
||||
|
||||
logger.Logger.Info("WebSocket connection established",
|
||||
@ -166,7 +169,12 @@ func (h *Hub) validateToken(token string) (int64, int64, error) {
|
||||
func (c *Connection) readPump() {
|
||||
defer func() {
|
||||
c.Hub.mu.Lock()
|
||||
delete(c.Hub.clients, c.UserID)
|
||||
if conns, ok := c.Hub.clients[c.UserID]; ok {
|
||||
delete(conns, c)
|
||||
if len(conns) == 0 {
|
||||
delete(c.Hub.clients, c.UserID)
|
||||
}
|
||||
}
|
||||
c.Hub.mu.Unlock()
|
||||
c.Conn.Close()
|
||||
}()
|
||||
@ -467,12 +475,14 @@ func (c *Connection) getPersonas() {
|
||||
|
||||
// sendError 发送错误消息 (使用 Send 通道)
|
||||
|
||||
// Close 关闭连接
|
||||
// Close 关闭所有连接
|
||||
func (h *Hub) Close() {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
|
||||
for _, conn := range h.clients {
|
||||
conn.Conn.Close()
|
||||
for _, conns := range h.clients {
|
||||
for c := range conns {
|
||||
c.Conn.Close()
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -178,6 +178,7 @@ func (s *authService) Register(ctx context.Context, req *pb.RegisterRequest) (*p
|
||||
UserID: user.ID,
|
||||
StarID: req.StarId,
|
||||
Nickname: req.Nickname,
|
||||
AvatarURL: user.AvatarURL,
|
||||
Level: 1,
|
||||
Times: 1,
|
||||
Social: 0,
|
||||
|
||||
@ -258,12 +258,20 @@ func (s *identityService) AddIdentity(req *pb.AddIdentityRequest, userID int64)
|
||||
return nil, fmt.Errorf("failed to check existing fan profile: %w", err)
|
||||
}
|
||||
|
||||
// 5. 创建新的粉丝档案
|
||||
// 5. 查询用户头像(用于新粉丝档案)
|
||||
var avatarURL *string
|
||||
user, err := s.userRepo.GetByID(userID)
|
||||
if err == nil && user != nil {
|
||||
avatarURL = user.AvatarURL
|
||||
}
|
||||
|
||||
// 6. 创建新的粉丝档案
|
||||
now := time.Now().UnixMilli()
|
||||
fanProfile := &models.FanProfile{
|
||||
UserID: userID,
|
||||
StarID: req.StarId,
|
||||
Nickname: req.Nickname,
|
||||
AvatarURL: avatarURL,
|
||||
Level: 1,
|
||||
Times: 1,
|
||||
Social: 0,
|
||||
|
||||
@ -13,9 +13,27 @@ export default {
|
||||
// 进入 square 后由 handleEnterTopfans 设为 true(本次会话内不再展示)。
|
||||
globalData: {
|
||||
welcomeShownThisSession: false,
|
||||
// 冷启动时是否需要跳转一键登录(onLaunch 设置,onShow 消费后立即清除)
|
||||
shouldShowQuickLogin: false,
|
||||
},
|
||||
onLaunch: function () {
|
||||
console.log("App Launch");
|
||||
|
||||
// 冷启动时检查本地 token,标记是否需要跳转到一键登录页
|
||||
// 仅 onLaunch 触发(冷启动/app 销毁后重新打开),后台切回前台不触发
|
||||
// 通过 HIDE_TIME_KEY 判断 App 是否短时间内被系统重启(卡顿/网络切换/内存回收):
|
||||
// 若切后台 5 分钟内又触发 onLaunch,说明是系统被动重启,跳过 quickLogin 保持原页面
|
||||
const token = uni.getStorageSync("access_token");
|
||||
const lastHideTime = uni.getStorageSync(HIDE_TIME_KEY) || 0;
|
||||
const RECENT_RESTART_THRESHOLD = 5 * 60 * 1000; // 5 分钟
|
||||
const isRecentRestart = Date.now() - lastHideTime < RECENT_RESTART_THRESHOLD;
|
||||
this.globalData.shouldShowQuickLogin = !!token && !isRecentRestart;
|
||||
|
||||
// 系统被动重启时,清除 needs_welcome,防止回到 square 时误触发 TopfansWelcome
|
||||
// (welcomeShownThisSession 会被重置为 false,若 needs_welcome 还在 storage 就会弹欢迎页)
|
||||
if (isRecentRestart) {
|
||||
uni.removeStorageSync("needs_welcome");
|
||||
}
|
||||
// 【TopfansWelcome 会话判定】
|
||||
// 触发场景(即用户期望展示欢迎页的入口):
|
||||
// 1) "后台关闭才会在打开":用户在后台杀掉进程后重新打开 → onLaunch 触发 → 新会话
|
||||
@ -34,12 +52,30 @@ export default {
|
||||
},
|
||||
onShow: function () {
|
||||
console.log("App Show");
|
||||
|
||||
// 冷启动时如果有本地 token,跳转到一键登录页(仅首次 onShow 执行一次)
|
||||
// 后台切回前台时 shouldShowQuickLogin 为 false,不跳转,直接回到挂载时的页面
|
||||
if (this.globalData.shouldShowQuickLogin) {
|
||||
this.globalData.shouldShowQuickLogin = false;
|
||||
// 清除 needs_welcome,避免 App 重启后 quickLogin → square 误触发 TopfansWelcome
|
||||
// 首次登录/注册的欢迎页由 login.vue / selectRole.vue 各自负责写入
|
||||
uni.removeStorageSync("needs_welcome");
|
||||
// 即使跳转到一键登录,仍需初始化推送服务和清除角标
|
||||
this.getAllNotice();
|
||||
this.clearBadgeAndNotifications();
|
||||
uni.reLaunch({ url: "/pages/login/quickLogin" });
|
||||
return;
|
||||
}
|
||||
|
||||
this.handleBackgroundReturn();
|
||||
|
||||
this.getAllNotice();
|
||||
// 从后台切回前台时重新初始化 WebSocket(onHide 中已关闭)
|
||||
this.initWebSocket();
|
||||
|
||||
// 打开 App 时清除图标角标和通知栏
|
||||
this.clearBadgeAndNotifications();
|
||||
this.getAllNotice();
|
||||
|
||||
// 打开 App 时清除图标角标和通知栏
|
||||
this.clearBadgeAndNotifications();
|
||||
},
|
||||
onHide: function () {
|
||||
console.log("App Hide");
|
||||
|
||||
@ -3,10 +3,6 @@
|
||||
<!-- 状态栏占位 -->
|
||||
<view class="status-bar"></view>
|
||||
|
||||
<view v-if="showBack" class="nav-back" @tap="goBack">
|
||||
<text class="nav-back-icon">←</text>
|
||||
</view>
|
||||
|
||||
<!-- 背景图片层 -->
|
||||
<image
|
||||
class="background-image"
|
||||
@ -48,15 +44,12 @@ defineProps({
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
showBack: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
// showBack: {
|
||||
// type: Boolean,
|
||||
// default: true,
|
||||
// },
|
||||
});
|
||||
|
||||
const goBack = () => {
|
||||
uni.navigateBack();
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@ -76,25 +69,6 @@ const goBack = () => {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.nav-back {
|
||||
width: 80rpx;
|
||||
height: 80rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: absolute;
|
||||
top: 96rpx;
|
||||
left: 32rpx;
|
||||
/* background: rgba(255,255,255,0.5);
|
||||
border-radius: 50%; */
|
||||
}
|
||||
|
||||
.nav-back-icon {
|
||||
font-size: 48rpx;
|
||||
font-weight: bold;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* 背景图:覆盖整个屏幕 */
|
||||
.background-image {
|
||||
position: absolute;
|
||||
|
||||
@ -172,8 +172,8 @@ export default {
|
||||
}
|
||||
this.isErrorProcessing = true
|
||||
this.isTyping = false
|
||||
uni.showToast({ title: data.message || data.error || '发生错误', icon: 'none' })
|
||||
this.aiMessage = '亲爱的你来辣 ~~'
|
||||
uni.showToast({ title: data.message || data.error || '发生错误', icon: 'none' })
|
||||
// 延迟重置标志
|
||||
setTimeout(() => {
|
||||
this.isErrorProcessing = false
|
||||
|
||||
@ -1,8 +1,10 @@
|
||||
<template>
|
||||
<view class="avatar-wrapper" :style="wrapperStyle">
|
||||
<view class="avatar-circle" :style="avatarStyle">
|
||||
<image class="avatar-image" :src="avatarImage" mode="aspectFill"></image>
|
||||
<image class="avatar-image-k" src="/static/square/gerentouxiangkuang.png" mode="aspectFill" />
|
||||
<view class="avatar-box" :style="avatarBoxStyle">
|
||||
<image class="avatar-frame" src="/static/login/portal/setnickname-avatar-box.png" mode="aspectFit"></image>
|
||||
<view class="avatar-inner" :style="avatarInnerStyle">
|
||||
<image class="avatar-img" :src="avatarImage" mode="aspectFill"></image>
|
||||
</view>
|
||||
</view>
|
||||
<view class="level-badge" v-if="showLevel && level" :style="badgeStyle">
|
||||
<text class="level-text" :style="badgeTextStyle">Lv {{ level }}</text>
|
||||
@ -138,15 +140,25 @@ const wrapperStyle = computed(() => ({
|
||||
flexShrink: 0
|
||||
}));
|
||||
|
||||
// 头像圆圈样式
|
||||
const avatarStyle = computed(() => {
|
||||
// 根据尺寸调整阴影大小
|
||||
const shadowSize = props.size >= 160 ? '0 8rpx 32rpx rgba(0, 0, 0, 0.2)' : '0 4rpx 16rpx rgba(0, 0, 0, 0.2)';
|
||||
// 参考设计稿:框体 270rpx 时,内圆 inset 为 18rpx(框体边框厚度)
|
||||
// 内圆比例 = 18 / 270 = 1/15 ≈ 0.0667
|
||||
const FRAME_INSET_RATIO = 18 / 270;
|
||||
|
||||
// 头像框外层样式
|
||||
const avatarBoxStyle = computed(() => ({
|
||||
position: 'relative',
|
||||
width: `${props.size}rpx`,
|
||||
height: `${props.size}rpx`
|
||||
}));
|
||||
|
||||
// 头像内圆样式(跟随 size 等比例缩放 inset)
|
||||
const avatarInnerStyle = computed(() => {
|
||||
const inset = props.size * FRAME_INSET_RATIO;
|
||||
return {
|
||||
width: `${props.size}rpx`,
|
||||
height: `${props.size}rpx`,
|
||||
borderWidth: `${props.borderWidth}rpx`,
|
||||
boxShadow: shadowSize
|
||||
top: `${inset}rpx`,
|
||||
left: `${inset}rpx`,
|
||||
right: `${inset}rpx`,
|
||||
bottom: `${inset}rpx`
|
||||
};
|
||||
});
|
||||
|
||||
@ -156,8 +168,6 @@ const badgeStyle = computed(() => {
|
||||
if (props.size >= 160) {
|
||||
// 大头像(profile页面)
|
||||
return {
|
||||
// top: '-10rpx',
|
||||
// right: '-10rpx',
|
||||
borderRadius: '20rpx',
|
||||
padding: '6rpx 16rpx',
|
||||
border: '3rpx solid rgba(255, 255, 255, 0.8)',
|
||||
@ -168,8 +178,6 @@ const badgeStyle = computed(() => {
|
||||
// 小头像(好友列表等)
|
||||
const offset = -props.size * 0.18; // 徽章偏移量
|
||||
return {
|
||||
// top: `${offset}rpx`,
|
||||
// right: `${offset}rpx`,
|
||||
borderRadius: '10rpx',
|
||||
padding: '0 8rpx',
|
||||
border: '2rpx solid rgba(255, 255, 255, 0.8)',
|
||||
@ -198,33 +206,44 @@ const badgeTextStyle = computed(() => {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.avatar-circle {
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-style: solid;
|
||||
border-color: rgba(255, 255, 255, 0.3);
|
||||
overflow: hidden;
|
||||
/* 头像框外层容器 */
|
||||
.avatar-box {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.avatar-image {
|
||||
/* 头像框装饰图(setnickname-avatar-box.png)*/
|
||||
.avatar-frame {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
filter: drop-shadow(0 0 23rpx rgba(135, 16, 16, 0.67));
|
||||
}
|
||||
|
||||
/* 头像内圆区域 */
|
||||
.avatar-inner {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
/* background: linear-gradient(
|
||||
147deg,
|
||||
rgba(223, 223, 146, 0.87) 17%,
|
||||
rgba(135, 197, 216, 0.87) 39%,
|
||||
rgba(194, 123, 209, 0.87) 99%
|
||||
); */
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 头像图片 */
|
||||
.avatar-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.avatar-image-k {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.level-badge {
|
||||
position: absolute;
|
||||
background: linear-gradient(165deg, #F0E4B1 0%, #F08399 50%, #B94E73 90%, #834B9E 100%);
|
||||
|
||||
@ -4,15 +4,16 @@
|
||||
<view class="login-content">
|
||||
<!-- 头像 -->
|
||||
<view class="avatar-wrapper">
|
||||
<image
|
||||
class="avatar-image"
|
||||
src="/static/login/portal/register-avatar.png"
|
||||
mode="aspectFit"
|
||||
></image>
|
||||
<image class="avatar-image" src="/static/login/portal/register-avatar.png" mode="aspectFit"></image>
|
||||
</view>
|
||||
|
||||
<!-- 登录卡片 -->
|
||||
<view class="login-card">
|
||||
|
||||
<view class="nav-back" @tap="goBack">
|
||||
<text class="nav-back-icon">←</text>
|
||||
</view>
|
||||
|
||||
<!-- 标题 -->
|
||||
<view class="card-title-wrapper">
|
||||
<text class="card-title">账号密码登录</text>
|
||||
@ -24,14 +25,8 @@
|
||||
<view class="input-group">
|
||||
<view class="input-outer">
|
||||
<view class="input-inner">
|
||||
<input
|
||||
class="input-field"
|
||||
type="number"
|
||||
v-model="form.phone"
|
||||
placeholder="输入您的手机号"
|
||||
placeholder-class="input-placeholder"
|
||||
maxlength="11"
|
||||
/>
|
||||
<input class="input-field" type="number" v-model="form.phone" placeholder="输入您的手机号"
|
||||
placeholder-class="input-placeholder" maxlength="11" />
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
@ -40,13 +35,8 @@
|
||||
<view class="input-group">
|
||||
<view class="input-outer">
|
||||
<view class="input-inner">
|
||||
<input
|
||||
class="input-field"
|
||||
:type="showPassword ? 'text' : 'password'"
|
||||
v-model="form.password"
|
||||
placeholder="输入您的密码"
|
||||
placeholder-class="input-placeholder"
|
||||
/>
|
||||
<input class="input-field" :type="showPassword ? 'text' : 'password'" v-model="form.password"
|
||||
placeholder="输入您的密码" placeholder-class="input-placeholder" />
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
@ -59,9 +49,7 @@
|
||||
|
||||
<!-- 忘记密码 -->
|
||||
<view class="forgot-password-wrapper">
|
||||
<text class="forgot-password" @tap="handleForgotPassword"
|
||||
>忘记密码</text
|
||||
>
|
||||
<text class="forgot-password" @tap="handleForgotPassword">忘记密码</text>
|
||||
</view>
|
||||
|
||||
<!-- 分割线 -->
|
||||
@ -74,6 +62,22 @@
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 用户不存在弹窗 -->
|
||||
<view v-if="showRegisterDialog" class="dialog-overlay" @tap="closeRegisterDialog">
|
||||
<view class="dialog-card" @tap.stop>
|
||||
<view class="dialog-title">账号不存在</view>
|
||||
<view class="dialog-message">该手机号尚未注册,是否前往注册页面创建账号?</view>
|
||||
<view class="dialog-actions">
|
||||
<view class="dialog-btn dialog-btn-cancel" @tap="closeRegisterDialog">
|
||||
<text class="dialog-btn-text">取消</text>
|
||||
</view>
|
||||
<view class="dialog-btn dialog-btn-confirm" @tap="goToRegister">
|
||||
<text class="dialog-btn-text">去注册</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</LoginBackground>
|
||||
</template>
|
||||
@ -95,6 +99,11 @@ const form = ref({
|
||||
});
|
||||
const showPassword = ref(false);
|
||||
const errorMessage = ref("");
|
||||
const showRegisterDialog = ref(false);
|
||||
|
||||
const goBack = () => {
|
||||
uni.reLaunch({ url: "/pages/login/portal" })
|
||||
};
|
||||
|
||||
// 获取页面参数(用于显示错误信息)
|
||||
const getPageParams = () => {
|
||||
@ -130,6 +139,11 @@ const handleForgotPassword = () => {
|
||||
uni.showToast({ title: "忘记密码功能开发中", icon: "none" });
|
||||
};
|
||||
|
||||
// 关闭注册引导弹窗
|
||||
const closeRegisterDialog = () => {
|
||||
showRegisterDialog.value = false;
|
||||
};
|
||||
|
||||
// 登录
|
||||
const handleLogin = async () => {
|
||||
const phoneValidation = validatePhone(form.value.phone);
|
||||
@ -161,6 +175,16 @@ const handleLogin = async () => {
|
||||
uni.reLaunch({ url: "/pages/square/square" });
|
||||
} catch (error) {
|
||||
uni.hideLoading();
|
||||
|
||||
// 用户不存在时弹窗引导前往注册
|
||||
if (
|
||||
error.code === 404 ||
|
||||
(error.message && error.message.includes("用户不存在"))
|
||||
) {
|
||||
showRegisterDialog.value = true;
|
||||
return;
|
||||
}
|
||||
|
||||
errorMessage.value = error.message || "登录失败,请重试";
|
||||
uni.showToast({
|
||||
title: errorMessage.value,
|
||||
@ -214,6 +238,23 @@ const handleLogin = async () => {
|
||||
z-index: 12;
|
||||
}
|
||||
|
||||
|
||||
.nav-back {
|
||||
width: 80rpx;
|
||||
height: 80rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: absolute;
|
||||
left: 32rpx;
|
||||
}
|
||||
|
||||
.nav-back-icon {
|
||||
font-size: 48rpx;
|
||||
font-weight: bold;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* 标题 */
|
||||
.card-title-wrapper {
|
||||
position: relative;
|
||||
@ -257,12 +298,10 @@ const handleLogin = async () => {
|
||||
width: 100%;
|
||||
height: 74rpx;
|
||||
border-radius: 58rpx;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
rgba(255, 222, 8, 0.06) 0%,
|
||||
rgba(252, 100, 102, 0.12) 64%,
|
||||
rgba(244, 88, 104, 0.12) 100%
|
||||
);
|
||||
background: linear-gradient(90deg,
|
||||
rgba(255, 222, 8, 0.06) 0%,
|
||||
rgba(252, 100, 102, 0.12) 64%,
|
||||
rgba(244, 88, 104, 0.12) 100%);
|
||||
box-shadow: 4rpx 4rpx 8rpx 0 rgba(242, 21, 21, 0.47);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@ -343,12 +382,10 @@ const handleLogin = async () => {
|
||||
width: 270rpx;
|
||||
height: 90rpx;
|
||||
border-radius: 58rpx;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
rgba(255, 222, 8, 0.28) 0%,
|
||||
rgba(252, 100, 102, 0.58) 64%,
|
||||
rgba(244, 88, 104, 0.58) 100%
|
||||
);
|
||||
background: linear-gradient(90deg,
|
||||
rgba(255, 222, 8, 0.28) 0%,
|
||||
rgba(252, 100, 102, 0.58) 64%,
|
||||
rgba(244, 88, 104, 0.58) 100%);
|
||||
box-shadow: 4rpx 4rpx 8rpx 0 rgba(242, 21, 21, 0.47);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@ -362,4 +399,80 @@ const handleLogin = async () => {
|
||||
text-shadow: -2rpx 2rpx 8rpx rgba(0, 0, 0, 0.84);
|
||||
font-family: "Abhaya Libre Medium", sans-serif;
|
||||
}
|
||||
|
||||
/* 注册引导弹窗 */
|
||||
.dialog-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.dialog-card {
|
||||
width: 560rpx;
|
||||
background: #fff;
|
||||
border-radius: 32rpx;
|
||||
padding: 48rpx 40rpx 36rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.dialog-title {
|
||||
font-size: 36rpx;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
|
||||
.dialog-message {
|
||||
font-size: 28rpx;
|
||||
color: #666;
|
||||
text-align: center;
|
||||
line-height: 1.6;
|
||||
margin-bottom: 40rpx;
|
||||
}
|
||||
|
||||
.dialog-actions {
|
||||
display: flex;
|
||||
gap: 24rpx;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.dialog-btn {
|
||||
flex: 1;
|
||||
height: 80rpx;
|
||||
border-radius: 58rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.dialog-btn-cancel {
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
.dialog-btn-confirm {
|
||||
background: linear-gradient(90deg,
|
||||
rgba(255, 222, 8, 0.28) 0%,
|
||||
rgba(252, 100, 102, 0.58) 64%,
|
||||
rgba(244, 88, 104, 0.58) 100%);
|
||||
box-shadow: 4rpx 4rpx 8rpx 0 rgba(242, 21, 21, 0.47);
|
||||
}
|
||||
|
||||
.dialog-btn-text {
|
||||
font-size: 30rpx;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.dialog-btn-confirm .dialog-btn-text {
|
||||
color: #fff9e7;
|
||||
text-shadow: -2rpx 2rpx 8rpx rgba(0, 0, 0, 0.84);
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -210,7 +210,7 @@ const openAgreementFromTip = () => {
|
||||
.portal-card {
|
||||
width: 606rpx;
|
||||
height: 388rpx;
|
||||
margin-top: 660rpx;
|
||||
margin-top: 532rpx;
|
||||
padding: 20rpx 50rpx;
|
||||
box-sizing: border-box;
|
||||
border-radius: 28px;
|
||||
@ -337,8 +337,8 @@ const openAgreementFromTip = () => {
|
||||
}
|
||||
|
||||
.checkbox-inner {
|
||||
width: 24rpx;
|
||||
height: 24rpx;
|
||||
width: 30rpx;
|
||||
height: 30rpx;
|
||||
border-radius: 50%;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
@ -54,7 +54,9 @@
|
||||
class="agreement-checkbox"
|
||||
:class="{ checked: agreedToTerms }"
|
||||
@tap="toggleAgreement"
|
||||
></view>
|
||||
>
|
||||
<view v-if="agreedToTerms" class="checkbox-inner"></view>
|
||||
</view>
|
||||
<view class="agreement-text">
|
||||
<text class="agreement-line"
|
||||
>登陆代表您已阅读并接受统一认证服务条款,以及</text
|
||||
@ -130,7 +132,7 @@ const maskPhone = (phone) => {
|
||||
};
|
||||
|
||||
const maskedPhone = ref(
|
||||
userInfo.mobile_masked || maskPhone(userInfo.mobile) || "174****2223",
|
||||
userInfo.mobile_masked|| userInfo.mobile || maskPhone(userInfo.mobile) || "174****2223",
|
||||
);
|
||||
const userAvatar = ref(
|
||||
userInfo.avatar_url || "/static/login/portal/quicklogin-avatar.png",
|
||||
@ -155,8 +157,11 @@ const handleQuickLogin = () => {
|
||||
uni.showLoading({ title: "登录中..." });
|
||||
setTimeout(() => {
|
||||
uni.hideLoading();
|
||||
// 标记从一键登录进入,触发 TopfansWelcome 显示
|
||||
uni.setStorageSync("needs_welcome", true);
|
||||
// 标记从一键登录进入,触发 TopfansWelcome 显示
|
||||
uni.setStorageSync("needs_welcome", true);
|
||||
// 原因:quickLogin 只在 App 冷启动 + 有 token 时由 App.vue 跳转进入,
|
||||
// 若写入 needs_welcome 则每次系统杀掉进程重启都会触发 TopfansWelcome,
|
||||
// 用户体验很差。needs_welcome 由 login.vue(密码登录)和 selectRole.vue(注册选角)负责写入。
|
||||
uni.reLaunch({ url: "/pages/square/square" });
|
||||
}, 800);
|
||||
};
|
||||
@ -408,8 +413,11 @@ const openAgreementFromTip = () => {
|
||||
opacity: 0.96;
|
||||
}
|
||||
|
||||
.agreement-checkbox.checked {
|
||||
background: #fff;
|
||||
.checkbox-inner {
|
||||
width: 30rpx;
|
||||
height: 30rpx;
|
||||
border-radius: 50%;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.agreement-text {
|
||||
|
||||
@ -1438,7 +1438,7 @@ const confirmDeleteAccount = async () => {
|
||||
// 延迟跳转到登录页
|
||||
setTimeout(() => {
|
||||
uni.reLaunch({
|
||||
url: "/pages/login/login",
|
||||
url: "/pages/login/portal",
|
||||
});
|
||||
}, 1500);
|
||||
} else {
|
||||
@ -1486,7 +1486,7 @@ const confirmLogout = () => {
|
||||
|
||||
// 跳转到登录页
|
||||
uni.reLaunch({
|
||||
url: "/pages/login/login",
|
||||
url: "/pages/login/portal",
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@ -13,6 +13,11 @@
|
||||
|
||||
<!-- 玻璃拟态卡片 -->
|
||||
<view class="glass-card">
|
||||
|
||||
<view class="nav-back" @tap="goBack">
|
||||
<text class="nav-back-icon">←</text>
|
||||
</view>
|
||||
|
||||
<!-- 标题:选择明星 -->
|
||||
<view class="card-title-wrapper">
|
||||
<text class="card-title">选择明星</text>
|
||||
@ -29,6 +34,7 @@
|
||||
placeholder="请输入TA的名称"
|
||||
placeholder-class="star-input-placeholder"
|
||||
:adjust-position="true"
|
||||
disabled
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
@ -106,7 +112,7 @@ const emit = defineEmits(["confirm"]);
|
||||
const store = useStore();
|
||||
|
||||
// 响应式数据
|
||||
const searchText = ref("");
|
||||
const searchText = ref("张艺兴");
|
||||
const selectedStar = ref(null);
|
||||
|
||||
// 通用确认弹窗状态
|
||||
@ -149,7 +155,9 @@ const showConfirmModal = (options) => {
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
const goBack = () => {
|
||||
uni.navigateBack();
|
||||
};
|
||||
|
||||
|
||||
|
||||
@ -162,7 +170,7 @@ const selectStar = (star) => {
|
||||
// 重新选择
|
||||
const resetSelection = () => {
|
||||
selectedStar.value = null;
|
||||
searchText.value = "";
|
||||
// searchText.value = "";
|
||||
};
|
||||
|
||||
// 确认选择明星(从输入框内容自动匹配)
|
||||
@ -175,7 +183,7 @@ const handleConfirm = () => {
|
||||
// 简单匹配:把输入内容包装成一个临时 selectedStar
|
||||
selectedStar.value = {
|
||||
id: Date.now(),
|
||||
star_id: 0,
|
||||
star_id: 87,
|
||||
nameCn: query,
|
||||
nameEn: "",
|
||||
image: "/static/login/portal/selectrole-photo1.png",
|
||||
@ -211,13 +219,17 @@ const handleNext = async () => {
|
||||
const mobile = uni.getStorageSync("temp_register_mobile");
|
||||
const password = uni.getStorageSync("temp_register_password");
|
||||
const nickname = uni.getStorageSync("temp_register_nickname");
|
||||
const verify_token = uni.getStorageSync("temp_register_verify_token") || "";
|
||||
const avatar_url = uni.getStorageSync("temp_register_avatar_url") || "https://top-fans-test.oss-cn-shanghai.aliyuncs.com/avatar/13/87/character.png";
|
||||
const star_id = targetStar.star_id;
|
||||
|
||||
console.log("注册信息:", { mobile, password, nickname, star_id, avatar_url });
|
||||
if (!mobile || !password || !nickname || !star_id) {
|
||||
uni.showToast({ title: "注册信息不完整,请重新注册", icon: "none" });
|
||||
uni.removeStorageSync("temp_register_mobile");
|
||||
uni.removeStorageSync("temp_register_password");
|
||||
uni.removeStorageSync("temp_register_nickname");
|
||||
uni.removeStorageSync("temp_register_verify_token");
|
||||
uni.removeStorageSync("temp_register_avatar_url");
|
||||
setTimeout(() => {
|
||||
uni.reLaunch({ url: "/pages/register/register" });
|
||||
}, 1500);
|
||||
@ -231,12 +243,16 @@ const handleNext = async () => {
|
||||
password,
|
||||
star_id,
|
||||
nickname,
|
||||
verify_token,
|
||||
avatar_url,
|
||||
});
|
||||
|
||||
uni.hideLoading();
|
||||
uni.removeStorageSync("temp_register_mobile");
|
||||
uni.removeStorageSync("temp_register_password");
|
||||
uni.removeStorageSync("temp_register_nickname");
|
||||
uni.removeStorageSync("temp_register_verify_token");
|
||||
uni.removeStorageSync("temp_register_avatar_url");
|
||||
uni.setStorageSync("is_new_user", true);
|
||||
// 注册成功后首次进入 square,触发 TopfansWelcome 显示
|
||||
uni.setStorageSync("needs_welcome", true);
|
||||
@ -314,6 +330,22 @@ const handleNext = async () => {
|
||||
z-index: 12;
|
||||
}
|
||||
|
||||
.nav-back {
|
||||
width: 80rpx;
|
||||
height: 80rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: absolute;
|
||||
left: 32rpx;
|
||||
}
|
||||
|
||||
.nav-back-icon {
|
||||
font-size: 48rpx;
|
||||
font-weight: bold;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* 标题胶囊:选择明星 */
|
||||
.card-title-wrapper {
|
||||
position: relative;
|
||||
|
||||
@ -13,6 +13,11 @@
|
||||
|
||||
<!-- 设置卡片 -->
|
||||
<view class="nickname-card">
|
||||
|
||||
<view class="nav-back" @tap="goBack">
|
||||
<text class="nav-back-icon">←</text>
|
||||
</view>
|
||||
|
||||
<!-- 标题 -->
|
||||
<view class="card-title-wrapper">
|
||||
<text class="card-title">设置头像与昵称</text>
|
||||
@ -130,22 +135,8 @@ const userAvatarUrl = ref("");
|
||||
const showAvatarModal = ref(false);
|
||||
const uploadingAvatar = ref(false);
|
||||
|
||||
const goToAuthPage = () => {
|
||||
const hasRegisterDraft = Boolean(uni.getStorageSync("temp_register_mobile"));
|
||||
const authPageUrl = hasRegisterDraft
|
||||
? "/pages/register/register"
|
||||
: "/pages/login/login";
|
||||
uni.reLaunch({ url: authPageUrl });
|
||||
};
|
||||
|
||||
// 返回上一页
|
||||
const goBack = () => {
|
||||
const pages = getCurrentPages();
|
||||
if (pages.length > 1) {
|
||||
uni.navigateBack({ fail: goToAuthPage });
|
||||
return;
|
||||
}
|
||||
goToAuthPage();
|
||||
uni.navigateBack()
|
||||
};
|
||||
|
||||
// 打开头像上传弹窗
|
||||
@ -273,67 +264,15 @@ const handleNext = async () => {
|
||||
return;
|
||||
}
|
||||
|
||||
// 保存昵称和头像到 storage,由 selectRole 统一完成注册
|
||||
uni.setStorageSync("temp_register_nickname", trimmedNickname);
|
||||
const avatar_url = userAvatarUrl.value || "https://top-fans-test.oss-cn-shanghai.aliyuncs.com/avatar/13/87/character.png";
|
||||
uni.setStorageSync("temp_register_avatar_url", avatar_url);
|
||||
|
||||
const mobile = uni.getStorageSync("temp_register_mobile");
|
||||
const password = uni.getStorageSync("temp_register_password");
|
||||
const star_id = 87;
|
||||
const verify_token = uni.getStorageSync("temp_register_verify_token") || "";
|
||||
const avatar_url = userAvatarUrl.value || "";
|
||||
|
||||
if (!mobile || !password || !trimmedNickname || !star_id) {
|
||||
uni.showToast({
|
||||
title: "注册信息不完整,请重新注册",
|
||||
icon: "none",
|
||||
});
|
||||
uni.removeStorageSync("temp_register_mobile");
|
||||
uni.removeStorageSync("temp_register_password");
|
||||
uni.removeStorageSync("temp_register_nickname");
|
||||
uni.removeStorageSync("temp_register_verify_token");
|
||||
setTimeout(() => {
|
||||
uni.reLaunch({ url: "/pages/register/register" });
|
||||
}, 1500);
|
||||
isChecking.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
uni.showLoading({ title: "注册中...", mask: true });
|
||||
|
||||
await store.dispatch("user/register", {
|
||||
mobile,
|
||||
password,
|
||||
star_id,
|
||||
nickname: trimmedNickname,
|
||||
verify_token,
|
||||
avatar_url,
|
||||
});
|
||||
|
||||
uni.hideLoading();
|
||||
uni.removeStorageSync("temp_register_mobile");
|
||||
uni.removeStorageSync("temp_register_password");
|
||||
uni.removeStorageSync("temp_register_nickname");
|
||||
uni.removeStorageSync("temp_register_verify_token");
|
||||
uni.setStorageSync("is_new_user", true);
|
||||
// 注册成功后首次进入 square,触发 TopfansWelcome 显示
|
||||
uni.setStorageSync("needs_welcome", true);
|
||||
uni.reLaunch({ url: "/pages/profile/selectRole" });
|
||||
} catch (error) {
|
||||
uni.hideLoading();
|
||||
if (
|
||||
error.code === 3 ||
|
||||
error.message.includes("昵称") ||
|
||||
error.message.includes("已存在")
|
||||
) {
|
||||
uni.showModal({
|
||||
title: "昵称已存在",
|
||||
content: "该昵称已被使用,请返回修改昵称",
|
||||
showCancel: false,
|
||||
confirmText: "知道了",
|
||||
});
|
||||
} else {
|
||||
errorMessage.value = error.message || "注册失败,请重试";
|
||||
uni.showToast({ title: errorMessage.value, icon: "none" });
|
||||
}
|
||||
errorMessage.value = error.message || "检查昵称失败,请重试";
|
||||
uni.showToast({ title: errorMessage.value, icon: "none" });
|
||||
} finally {
|
||||
isChecking.value = false;
|
||||
}
|
||||
@ -383,6 +322,22 @@ const handleNext = async () => {
|
||||
z-index: 12;
|
||||
}
|
||||
|
||||
.nav-back {
|
||||
width: 80rpx;
|
||||
height: 80rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: absolute;
|
||||
left: 32rpx;
|
||||
}
|
||||
|
||||
.nav-back-icon {
|
||||
font-size: 48rpx;
|
||||
font-weight: bold;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* 标题 */
|
||||
.card-title-wrapper {
|
||||
position: relative;
|
||||
|
||||
@ -4,15 +4,16 @@
|
||||
<view class="register-content">
|
||||
<!-- 头像 -->
|
||||
<view class="avatar-wrapper">
|
||||
<image
|
||||
class="avatar-image"
|
||||
src="/static/login/portal/register-avatar.png"
|
||||
mode="aspectFit"
|
||||
></image>
|
||||
<image class="avatar-image" src="/static/login/portal/register-avatar.png" mode="aspectFit"></image>
|
||||
</view>
|
||||
|
||||
<!-- 注册卡片 -->
|
||||
<view class="register-card">
|
||||
|
||||
<view class="nav-back" @tap="goBack">
|
||||
<text class="nav-back-icon">←</text>
|
||||
</view>
|
||||
|
||||
<!-- 标题 -->
|
||||
<view class="card-title-wrapper">
|
||||
<text class="card-title">手机号注册</text>
|
||||
@ -24,15 +25,8 @@
|
||||
<view class="input-group">
|
||||
<view class="input-outer">
|
||||
<view class="input-inner">
|
||||
<input
|
||||
class="input-field"
|
||||
type="number"
|
||||
v-model="form.phone"
|
||||
placeholder="输入您的手机号"
|
||||
placeholder-class="input-placeholder"
|
||||
maxlength="11"
|
||||
@input="handlePhoneInput"
|
||||
/>
|
||||
<input class="input-field" type="number" v-model="form.phone" placeholder="输入您的手机号"
|
||||
placeholder-class="input-placeholder" maxlength="11" @input="handlePhoneInput" />
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
@ -41,31 +35,16 @@
|
||||
<view class="input-group code-group">
|
||||
<view class="input-outer code-outer">
|
||||
<view class="input-inner code-inner">
|
||||
<input
|
||||
class="input-field"
|
||||
type="number"
|
||||
v-model="form.code"
|
||||
placeholder="输入验证码"
|
||||
placeholder-class="input-placeholder"
|
||||
maxlength="6"
|
||||
:disabled="codeStatus === 'verified'"
|
||||
/>
|
||||
<input class="input-field" type="number" v-model="form.code" placeholder="输入验证码"
|
||||
placeholder-class="input-placeholder" maxlength="6" :disabled="codeStatus === 'verified'" />
|
||||
</view>
|
||||
</view>
|
||||
<view
|
||||
class="send-code-btn"
|
||||
:class="{
|
||||
countdown: codeStatus === 'countdown',
|
||||
verified: codeStatus === 'verified',
|
||||
}"
|
||||
@tap="handleSendCode"
|
||||
>
|
||||
<text v-if="codeStatus === 'unsent' || codeStatus === 'resend'"
|
||||
>发送验证码</text
|
||||
>
|
||||
<text v-else-if="codeStatus === 'countdown'"
|
||||
>{{ countdown }}秒</text
|
||||
>
|
||||
<view class="send-code-btn" :class="{
|
||||
countdown: codeStatus === 'countdown',
|
||||
verified: codeStatus === 'verified',
|
||||
}" @tap="handleSendCode">
|
||||
<text v-if="codeStatus === 'unsent' || codeStatus === 'resend'">发送验证码</text>
|
||||
<text v-else-if="codeStatus === 'countdown'">{{ countdown }}秒</text>
|
||||
<text v-else-if="codeStatus === 'verified'">已验证</text>
|
||||
</view>
|
||||
</view>
|
||||
@ -79,13 +58,8 @@
|
||||
<view class="input-group">
|
||||
<view class="input-outer">
|
||||
<view class="input-inner">
|
||||
<input
|
||||
class="input-field"
|
||||
:type="showPassword ? 'text' : 'password'"
|
||||
v-model="form.password"
|
||||
placeholder="创建密码"
|
||||
placeholder-class="input-placeholder"
|
||||
/>
|
||||
<input class="input-field" :type="showPassword ? 'text' : 'password'" v-model="form.password"
|
||||
placeholder="创建密码" placeholder-class="input-placeholder" />
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
@ -94,13 +68,8 @@
|
||||
<view class="input-group">
|
||||
<view class="input-outer">
|
||||
<view class="input-inner">
|
||||
<input
|
||||
class="input-field"
|
||||
:type="showPassword ? 'text' : 'password'"
|
||||
v-model="confirmPassword"
|
||||
placeholder="确认密码"
|
||||
placeholder-class="input-placeholder"
|
||||
/>
|
||||
<input class="input-field" :type="showPassword ? 'text' : 'password'" v-model="confirmPassword"
|
||||
placeholder="确认密码" placeholder-class="input-placeholder" />
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
@ -150,6 +119,10 @@ const verifyToken = ref("");
|
||||
const countdownTimer = ref(null);
|
||||
const isVerifying = ref(false);
|
||||
|
||||
const goBack = () => {
|
||||
uni.reLaunch({ url: "/pages/login/portal" })
|
||||
};
|
||||
|
||||
// 切换密码显示/隐藏
|
||||
const togglePassword = () => {
|
||||
showPassword.value = !showPassword.value;
|
||||
@ -347,6 +320,22 @@ const handleRegister = async () => {
|
||||
z-index: 12;
|
||||
}
|
||||
|
||||
.nav-back {
|
||||
width: 80rpx;
|
||||
height: 80rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: absolute;
|
||||
left: 32rpx;
|
||||
}
|
||||
|
||||
.nav-back-icon {
|
||||
font-size: 48rpx;
|
||||
font-weight: bold;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* 标题 */
|
||||
.card-title-wrapper {
|
||||
position: relative;
|
||||
@ -400,12 +389,10 @@ const handleRegister = async () => {
|
||||
width: 100%;
|
||||
height: 74rpx;
|
||||
border-radius: 58rpx;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
rgba(255, 222, 8, 0.06) 0%,
|
||||
rgba(252, 100, 102, 0.12) 64%,
|
||||
rgba(244, 88, 104, 0.12) 100%
|
||||
);
|
||||
background: linear-gradient(90deg,
|
||||
rgba(255, 222, 8, 0.06) 0%,
|
||||
rgba(252, 100, 102, 0.12) 64%,
|
||||
rgba(244, 88, 104, 0.12) 100%);
|
||||
box-shadow: 4rpx 4rpx 8rpx 0 rgba(242, 21, 21, 0.47);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@ -457,12 +444,10 @@ const handleRegister = async () => {
|
||||
width: 152rpx;
|
||||
height: 74rpx;
|
||||
border-radius: 36rpx;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
rgba(255, 222, 8, 0.06) 0%,
|
||||
rgba(252, 100, 102, 0.12) 64%,
|
||||
rgba(244, 88, 104, 0.12) 100%
|
||||
);
|
||||
background: linear-gradient(90deg,
|
||||
rgba(255, 222, 8, 0.06) 0%,
|
||||
rgba(252, 100, 102, 0.12) 64%,
|
||||
rgba(244, 88, 104, 0.12) 100%);
|
||||
box-shadow: 4rpx 4rpx 8rpx 0 rgba(242, 21, 21, 0.47);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@ -511,12 +496,10 @@ const handleRegister = async () => {
|
||||
width: 270rpx;
|
||||
height: 90rpx;
|
||||
border-radius: 58rpx;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
rgba(255, 222, 8, 0.28) 0%,
|
||||
rgba(252, 100, 102, 0.58) 64%,
|
||||
rgba(244, 88, 104, 0.58) 100%
|
||||
);
|
||||
background: linear-gradient(90deg,
|
||||
rgba(255, 222, 8, 0.28) 0%,
|
||||
rgba(252, 100, 102, 0.58) 64%,
|
||||
rgba(244, 88, 104, 0.58) 100%);
|
||||
box-shadow: 4rpx 4rpx 8rpx 0 rgba(242, 21, 21, 0.47);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@ -70,6 +70,11 @@ export function useContributionRealtime(activityId, isPageActive) {
|
||||
|
||||
function onWsConnect() {
|
||||
if (usingWS) return
|
||||
// 即使 WS 已连接,如果后端 Pub/Sub 不可用,也不切到 WS,保持轮询
|
||||
if (!socket.isPubSubEnabled()) {
|
||||
console.log('[useContributionRealtime] WS connected but pubsub disabled, keeping polling')
|
||||
return
|
||||
}
|
||||
usingWS = true
|
||||
stopPolling() // 停掉可能的轮询
|
||||
socket.subscribe(activityId.value, ['contributions'])
|
||||
@ -81,9 +86,19 @@ export function useContributionRealtime(activityId, isPageActive) {
|
||||
startPolling() // 降级为轮询
|
||||
}
|
||||
|
||||
// 后端上报 pub/sub 不可用:如果之前已切到 WS,回退到轮询
|
||||
function onPubsubDisabled() {
|
||||
if (!usingWS) return
|
||||
console.warn('[useContributionRealtime] Pub/Sub disabled, falling back to polling')
|
||||
usingWS = false
|
||||
socket.unsubscribe(activityId.value, ['contributions'])
|
||||
startPolling()
|
||||
}
|
||||
|
||||
socket.onContributionsResponse(onWsMessage)
|
||||
socket.on('connect', onWsConnect)
|
||||
socket.on('disconnect', onWsDisconnect)
|
||||
socket.on('pubsub_disabled', onPubsubDisabled)
|
||||
|
||||
onMounted(() => {
|
||||
// 总是调用 connect():SocketManager.connect() 内部会判断 token 是否变化
|
||||
@ -93,11 +108,12 @@ export function useContributionRealtime(activityId, isPageActive) {
|
||||
if (token) {
|
||||
socket.connect(token).catch(err => console.warn('[useContributionRealtime] connect error:', err))
|
||||
}
|
||||
// 同步分支:如果 WS 已连接(单例复用导致 'connect' 事件不会再次触发),
|
||||
// 同步分支:如果 WS 已连接(单例复用导致 'connect' 事件不会再次触发)且 pubsub 可用,
|
||||
// 必须直接调 onWsConnect 停轮询,否则 polling 会一直跑。
|
||||
// 异步分支:WS 还没连上时,先起 polling 兜底;
|
||||
// 等 'connect' 事件触发 onWsConnect 后会停掉轮询。
|
||||
if (socket.isConnected) {
|
||||
// 但如果 pubsub 被禁用,即使 WS 已连接也不切,保持轮询。
|
||||
if (socket.isConnected && socket.isPubSubEnabled()) {
|
||||
onWsConnect()
|
||||
} else {
|
||||
startPolling()
|
||||
@ -108,6 +124,7 @@ export function useContributionRealtime(activityId, isPageActive) {
|
||||
if (usingWS) socket.unsubscribe(activityId.value, ['contributions'])
|
||||
socket.off('connect', onWsConnect)
|
||||
socket.off('disconnect', onWsDisconnect)
|
||||
socket.off('pubsub_disabled', onPubsubDisabled)
|
||||
socket.offContributionsResponse(onWsMessage)
|
||||
stopPolling()
|
||||
resetPolling()
|
||||
|
||||
@ -94,13 +94,27 @@ export function useMessageRealtime(activityId) {
|
||||
if (token) {
|
||||
socket.connect(token).catch(err => console.warn('[useMessageRealtime] connect error:', err))
|
||||
}
|
||||
// 同步分支:如果 WS 已连接(单例复用,另一个 composable 可能先连上了),
|
||||
// subscribe 会直接发送;否则缓存到 _topics,等待 connect 事件触发 resubscribeAll
|
||||
socket.subscribe(activityId.value, ['messages'])
|
||||
socket.onMessagesResponse(onWsMessage)
|
||||
|
||||
// 如果后端 pub/sub 被禁用,WS 订阅无效,需要手动刷新才能看到新消息
|
||||
if (!socket.isPubSubEnabled()) {
|
||||
console.warn('[useMessageRealtime] Pub/Sub disabled, real-time messages unavailable. Pull-to-refresh to see new messages.')
|
||||
}
|
||||
})
|
||||
|
||||
// 后端 pub/sub 状态变化:禁用时提醒用户
|
||||
function onPubsubDisabled() {
|
||||
console.warn('[useMessageRealtime] Pub/Sub became disabled, new messages will not arrive in real-time')
|
||||
}
|
||||
socket.on('pubsub_disabled', onPubsubDisabled)
|
||||
|
||||
onUnmounted(() => {
|
||||
socket.unsubscribe(activityId.value, ['messages'])
|
||||
socket.offMessagesResponse(onWsMessage)
|
||||
socket.off('pubsub_disabled', onPubsubDisabled)
|
||||
})
|
||||
|
||||
return {
|
||||
|
||||
@ -145,7 +145,7 @@ const actions = {
|
||||
|
||||
// 缓存登录手机号
|
||||
const loginMobile = mobile
|
||||
uni.setStorageSync('login_mobile', loginMobile)
|
||||
// uni.setStorageSync('login_mobile', loginMobile)
|
||||
|
||||
uni.setStorageSync('user', JSON.stringify(user))
|
||||
commit('SET_USER_INFO', user)
|
||||
|
||||
@ -90,7 +90,7 @@ export function request(options) {
|
||||
|
||||
// 跳转到登录页
|
||||
uni.reLaunch({
|
||||
url: '/pages/login/login'
|
||||
url: '/pages/login/portal'
|
||||
})
|
||||
|
||||
reject(new Error('登录已过期,请重新登录'))
|
||||
@ -112,7 +112,7 @@ export function request(options) {
|
||||
// 保留错误消息用于显示
|
||||
const errorMsg = res.data.message || '登录已过期,请重新登录'
|
||||
uni.reLaunch({
|
||||
url: '/pages/login/login?error=' + encodeURIComponent(
|
||||
url: '/pages/login/portal?error=' + encodeURIComponent(
|
||||
errorMsg)
|
||||
})
|
||||
|
||||
@ -284,7 +284,7 @@ export function deleteAccountApi() {
|
||||
return mockDeleteAccountApi()
|
||||
}
|
||||
return request({
|
||||
url: '/api/user/delete-account',
|
||||
url: '/api/v1/user/delete-account',
|
||||
method: 'POST'
|
||||
})
|
||||
}
|
||||
|
||||
@ -10,13 +10,25 @@ class GlobalSocketManager {
|
||||
this.sockets = {} // serviceName -> SocketManager
|
||||
this.token = null
|
||||
this.isAllConnected = false
|
||||
this._initialized = false // 防止重复 init 导致监听器累积
|
||||
// 保存监听器引用,用于 cleanup
|
||||
this._aiChatConnectHandler = null
|
||||
this._aiChatErrorHandler = null
|
||||
this._activityConnectHandler = null
|
||||
this._activityErrorHandler = null
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化所有连接
|
||||
* 初始化所有连接(幂等,重复调用会先清理旧监听器再重连)
|
||||
*/
|
||||
init(token) {
|
||||
this.token = token
|
||||
|
||||
// 总是先清理旧监听器再注册新的,防止累积。
|
||||
// 注意:closeAll() 会清空 this.sockets,所以 cleanup 必须通过单例 getter 获取 socket 引用。
|
||||
this._cleanupListeners()
|
||||
this._initialized = true
|
||||
|
||||
this._initAiChat()
|
||||
this._initActivity()
|
||||
// Future: this._initNotification()
|
||||
@ -24,22 +36,49 @@ class GlobalSocketManager {
|
||||
|
||||
async _initAiChat() {
|
||||
const aiChat = getAiChatSocket()
|
||||
aiChat.on('connect', () => console.log('AI Chat connected'))
|
||||
aiChat.on('error', (err) => console.error('AI Chat error:', err))
|
||||
this._aiChatConnectHandler = () => console.log('AI Chat connected')
|
||||
this._aiChatErrorHandler = (err) => console.error('AI Chat error:', err)
|
||||
aiChat.on('connect', this._aiChatConnectHandler)
|
||||
aiChat.on('error', this._aiChatErrorHandler)
|
||||
await aiChat.connect(this.token)
|
||||
this.sockets['ai_chat'] = aiChat
|
||||
}
|
||||
|
||||
async _initActivity() {
|
||||
const activity = getActivitySocket()
|
||||
activity.on('connect', () => console.log('Activity socket connected'))
|
||||
activity.on('error', (err) => console.error('Activity socket error:', err))
|
||||
this._activityConnectHandler = () => console.log('Activity socket connected')
|
||||
this._activityErrorHandler = (err) => console.error('Activity socket error:', err)
|
||||
activity.on('connect', this._activityConnectHandler)
|
||||
activity.on('error', this._activityErrorHandler)
|
||||
if (this.token) {
|
||||
await activity.connect(this.token)
|
||||
}
|
||||
this.sockets['activity'] = activity
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理之前注册的监听器,防止每次 init() 累积。
|
||||
* 通过单例 getter 获取 socket 引用(而非 this.sockets),
|
||||
* 因为 closeAll() 会清空 this.sockets 但单例仍存活。
|
||||
*/
|
||||
_cleanupListeners() {
|
||||
try {
|
||||
const aiChat = getAiChatSocket()
|
||||
if (this._aiChatConnectHandler) {
|
||||
aiChat.off('connect', this._aiChatConnectHandler)
|
||||
aiChat.off('error', this._aiChatErrorHandler)
|
||||
}
|
||||
} catch (e) { /* singleton not yet created, skip */ }
|
||||
|
||||
try {
|
||||
const activity = getActivitySocket()
|
||||
if (this._activityConnectHandler) {
|
||||
activity.off('connect', this._activityConnectHandler)
|
||||
activity.off('error', this._activityErrorHandler)
|
||||
}
|
||||
} catch (e) { /* singleton not yet created, skip */ }
|
||||
}
|
||||
|
||||
getSocket(serviceName) {
|
||||
return this.sockets[serviceName]
|
||||
}
|
||||
@ -47,6 +86,7 @@ class GlobalSocketManager {
|
||||
closeAll() {
|
||||
Object.values(this.sockets).forEach(socket => socket.close())
|
||||
this.sockets = {}
|
||||
this._initialized = false // 关闭后允许下次 init() 重新注册监听器
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -21,6 +21,7 @@ class SocketManager {
|
||||
this.isConnected = false
|
||||
this.isAuthed = false
|
||||
this.isClosing = false // 标记是否主动关闭
|
||||
this.pubsubStatus = 'connected' // 'connected' | 'pubsub_disabled'(后端 auth_response 下发)
|
||||
|
||||
// 事件处理器
|
||||
this.eventHandlers = {
|
||||
@ -28,6 +29,7 @@ class SocketManager {
|
||||
'disconnect': [],
|
||||
'auth_success': [],
|
||||
'auth_fail': [],
|
||||
'pubsub_disabled': [], // 后端上报 pub/sub 不可用
|
||||
'error': [],
|
||||
'message': [] // 通用消息处理
|
||||
}
|
||||
@ -107,8 +109,22 @@ class SocketManager {
|
||||
this._isConnecting = true
|
||||
|
||||
console.log(`[${this.serviceName}] _doConnect called, clearing old socket`)
|
||||
// 清理旧连接
|
||||
// 清除上一次连接的超时定时器,防止跨连接干扰
|
||||
if (this._connectTimeout) {
|
||||
clearTimeout(this._connectTimeout)
|
||||
this._connectTimeout = null
|
||||
}
|
||||
// 清理旧连接:先关闭再丢弃,防止旧 socket 的回调污染状态
|
||||
if (this.socket) {
|
||||
try {
|
||||
if (typeof this.socket.close === 'function') {
|
||||
this.socket.close()
|
||||
} else if (typeof this.socket.complete === 'function') {
|
||||
this.socket.complete()
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn(`[${this.serviceName}] Error closing old socket:`, e)
|
||||
}
|
||||
this.socket = null
|
||||
}
|
||||
this.isConnected = false
|
||||
@ -153,10 +169,22 @@ class SocketManager {
|
||||
const socket = this.socket
|
||||
const self = this
|
||||
|
||||
// 连接超时保护:15s 内未连接成功则重置 _isConnecting,防止永久阻塞
|
||||
// 存储在实例上而非局部变量,防止旧连接的定时器在新连接建立后误触发
|
||||
self._connectTimeout = setTimeout(() => {
|
||||
if (self._isConnecting) {
|
||||
console.warn(`[${self.serviceName}] Connection timeout (15s), resetting _isConnecting`)
|
||||
self._isConnecting = false
|
||||
self._connectTimeout = null
|
||||
self._emit('error', { code: 'CONNECT_TIMEOUT', message: '连接超时' })
|
||||
}
|
||||
}, 15000)
|
||||
|
||||
// 连接打开
|
||||
if (typeof socket.onOpen === 'function') {
|
||||
socket.onOpen(function() {
|
||||
console.log(`[${self.serviceName}] WebSocket connected`)
|
||||
clearTimeout(self._connectTimeout); self._connectTimeout = null
|
||||
self.isConnected = true
|
||||
self._isConnecting = false // 连接成功,允许后续重连
|
||||
// 清除重连计时器
|
||||
@ -171,6 +199,7 @@ class SocketManager {
|
||||
// 标准 WebSocket 风格
|
||||
socket.onopen(function() {
|
||||
console.log(`[${self.serviceName}] WebSocket connected`)
|
||||
clearTimeout(self._connectTimeout); self._connectTimeout = null
|
||||
self.isConnected = true
|
||||
self._isConnecting = false // 连接成功,允许后续重连
|
||||
// 清除重连计时器
|
||||
@ -202,6 +231,7 @@ class SocketManager {
|
||||
if (typeof socket.onClose === 'function') {
|
||||
socket.onClose(function() {
|
||||
console.log(`[${self.serviceName}] WebSocket closed`)
|
||||
clearTimeout(self._connectTimeout); self._connectTimeout = null
|
||||
self._isConnecting = false // 连接关闭,允许后续重连
|
||||
self._cleanup()
|
||||
self._emit('disconnect')
|
||||
@ -210,6 +240,7 @@ class SocketManager {
|
||||
} else if (typeof socket.onclose === 'function') {
|
||||
socket.onclose(function() {
|
||||
console.log(`[${self.serviceName}] WebSocket closed`)
|
||||
clearTimeout(self._connectTimeout); self._connectTimeout = null
|
||||
self._isConnecting = false // 连接关闭,允许后续重连
|
||||
self._cleanup()
|
||||
self._emit('disconnect')
|
||||
@ -218,10 +249,13 @@ class SocketManager {
|
||||
}
|
||||
|
||||
// 连接错误
|
||||
var handleSocketError = function(err) {
|
||||
let handleSocketError = function(err) {
|
||||
console.error(`[${self.serviceName}] WebSocket error:`, err)
|
||||
clearTimeout(self._connectTimeout); self._connectTimeout = null
|
||||
// 重置连接中状态,避免后续 connect() 调用被永久阻塞
|
||||
self._isConnecting = false
|
||||
// 检查是否是鉴权相关的错误(401/403)
|
||||
var errMsg = (err && (err.errMsg || err.message || '')).toLowerCase()
|
||||
let errMsg = (err && (err.errMsg || err.message || '')).toLowerCase()
|
||||
if (errMsg.indexOf('auth') !== -1 || errMsg.indexOf('reject') !== -1 || errMsg.indexOf('401') !== -1) {
|
||||
console.warn(`[${self.serviceName}] Connection rejected (auth failure), clearing token`)
|
||||
self._emit('auth_fail', err)
|
||||
@ -248,6 +282,12 @@ class SocketManager {
|
||||
if (type === 'auth_response') {
|
||||
if (data.success) {
|
||||
this.isAuthed = true
|
||||
// 记录后端上报的 pub/sub 状态,用于判断是否降级到轮询
|
||||
this.pubsubStatus = data.pubsub_status || 'connected'
|
||||
if (this.pubsubStatus === 'pubsub_disabled') {
|
||||
console.warn(`[${this.serviceName}] Pub/Sub disabled by server, real-time push unavailable`)
|
||||
this._emit('pubsub_disabled', data)
|
||||
}
|
||||
this._emit('auth_success', data)
|
||||
this._startHeartbeat()
|
||||
} else {
|
||||
@ -347,8 +387,20 @@ class SocketManager {
|
||||
clearTimeout(this.reconnectTimer)
|
||||
this.reconnectTimer = null
|
||||
}
|
||||
if (this._connectTimeout) {
|
||||
clearTimeout(this._connectTimeout)
|
||||
this._connectTimeout = null
|
||||
}
|
||||
this.isConnected = false
|
||||
this.isAuthed = false
|
||||
this.pubsubStatus = 'connected' // 重置为默认值,等下次 auth_response 更新
|
||||
}
|
||||
|
||||
/**
|
||||
* 后端 Pub/Sub 是否可用(用于判断是否降级到 HTTP 轮询)
|
||||
*/
|
||||
isPubSubEnabled() {
|
||||
return this.pubsubStatus !== 'pubsub_disabled'
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Loading…
Reference in New Issue
Block a user