diff --git a/.env.example b/.env.example index 692eca54..7510d285 100644 --- a/.env.example +++ b/.env.example @@ -21,7 +21,7 @@ JWT_REFRESH_TOKEN_EXPIRE_DAYS=7 # ==================== OpenAI Compatible API ==================== OPENAI_API_KEY= OPENAI_BASE_URL=https://api-inference.modelscope.cn/v1 -OPENAI_MODEL=Qwen/Qwen2.5-72B-Instruct +OPENAI_MODEL=Qwen/Qwen3-235B-A22B # ==================== Server ==================== PORT=8000 diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index cf89cf80..1da07a50 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -16,29 +16,25 @@ ### Self-Check Checklist -> Run `make check` to execute all checks at once. - **Backend (Go)**: - [ ] `go build ./...` passes - [ ] `go vet ./...` passes - [ ] `gofmt` produces no diff -- [ ] `golangci-lint run` passes (or no new issues) **Frontend (Vue)**: - [ ] `npm run lint` passes - [ ] `npm run typecheck` passes -- [ ] `npm run build` succeeds **General**: - [ ] Removed all temporary debug output - [ ] No sensitive data in the code -- [ ] CI checks pass ### Test Steps 1. Pull branch and install dependencies: ```bash - make install + cd backend && go mod download + cd ../frontend && npm install ``` 2. Start the application: ```bash diff --git a/.gitignore b/.gitignore index 76ebaa94..cef5d8dc 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ # ============================================================================= backend/bin/ backend/.cache/ +uploads/ # ============================================================================= # Node.js / Vue Frontend @@ -49,6 +50,15 @@ coverage/ # ============================================================================= archive-legacy-*.tar.gz +# ============================================================================= +# Claude Code / OMC +# ============================================================================= +.claude +.omc/ +CLAUDE.md +PROGRESS.md +SKILL.md + # ============================================================================= # Misc # ============================================================================= @@ -56,4 +66,3 @@ archive-legacy-*.tar.gz *.temp tmp/ temp/ -.claude diff --git a/Dockerfile b/Dockerfile index 4554d0fa..af18972c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ # ---- Stage 1: Build frontend ---- -FROM node:25-alpine AS frontend-builder +FROM node:24-alpine AS frontend-builder WORKDIR /app COPY frontend/package.json frontend/package-lock.json ./ RUN npm ci @@ -9,7 +9,7 @@ ENV VITE_API_BASE_URL=$VITE_API_BASE_URL RUN npm run build # ---- Stage 2: Build backend ---- -FROM golang:1.26-alpine AS backend-builder +FROM golang:1.23-alpine AS backend-builder WORKDIR /app COPY backend/go.mod backend/go.sum ./ RUN go mod download diff --git a/Makefile b/Makefile index 5ebdfd06..0e98499b 100644 --- a/Makefile +++ b/Makefile @@ -4,6 +4,11 @@ docker-up docker-down docker-logs docker-build \ postgres-up postgres-down postgres-logs db-reset deps-lock deps-update clean clean-all help +# Database config (must match dev-setup.sh / .env) +DB_USER ?= momshell +DB_PASS ?= momshell +DB_NAME ?= momshell + # Colors for terminal output CYAN := \033[36m GREEN := \033[32m diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 163c99a0..c466e217 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -64,7 +64,7 @@ func main() { chatClient = openai.NewClient("dummy", cfg.OpenAIBaseURL, cfg.OpenAIModel) } chatService := service.NewChatService(chatClient, chatRepo) - echoService := service.NewEchoService(chatClient, echoRepo) + echoService := service.NewEchoService(chatClient, echoRepo, userRepo) userService := service.NewUserService( db, userRepo, questionRepo, answerRepo, @@ -131,7 +131,8 @@ func createInitialAdmin(cfg *config.Config, userRepo *repository.UserRepo) { Email: cfg.AdminEmail, PasswordHash: hash, Nickname: "Admin", - Role: model.RoleAdmin, + Role: model.RoleMom, + IsAdmin: true, IsActive: true, } diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 4d1dfadd..fb6a0b66 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -33,9 +33,9 @@ type Config struct { } func Load() *Config { - // Load .env file, overriding any existing env vars - // (ensures local .env is the source of truth for dev) + // Load .env file from project root and backend dir (project root takes precedence) _ = godotenv.Overload() + _ = godotenv.Overload("../.env") cfg := &Config{ DatabaseURL: getEnv("DATABASE_URL", "postgres://user:password@localhost:5432/momshell?sslmode=disable"), diff --git a/backend/internal/database/migrate.go b/backend/internal/database/migrate.go index 7f27b6cc..78580b4c 100644 --- a/backend/internal/database/migrate.go +++ b/backend/internal/database/migrate.go @@ -6,7 +6,7 @@ import ( ) func Migrate(db *gorm.DB) error { - return db.AutoMigrate( + if err := db.AutoMigrate( &model.User{}, &model.UserCertification{}, &model.Tag{}, @@ -20,5 +20,15 @@ func Migrate(db *gorm.DB) error { &model.ChatMemory{}, &model.IdentityTag{}, &model.Memoir{}, - ) + ); err != nil { + return err + } + + // Migrate legacy role='admin' users to is_admin flag + db.Model(&model.User{}).Where("role = ?", "admin").Updates(map[string]interface{}{ + "is_admin": true, + "role": "mom", + }) + + return nil } diff --git a/backend/internal/dto/admin.go b/backend/internal/dto/admin.go index 61187d8c..37c9d03c 100644 --- a/backend/internal/dto/admin.go +++ b/backend/internal/dto/admin.go @@ -17,6 +17,7 @@ type AdminUserListItem struct { Email string `json:"email"` Nickname string `json:"nickname"` Role string `json:"role"` + IsAdmin bool `json:"is_admin"` IsActive bool `json:"is_active"` IsBanned bool `json:"is_banned"` IsGuest bool `json:"is_guest"` @@ -31,6 +32,7 @@ type AdminUserDetail struct { Nickname string `json:"nickname"` AvatarURL *string `json:"avatar_url"` Role string `json:"role"` + IsAdmin bool `json:"is_admin"` ShellCode *string `json:"shell_code"` IsGuest bool `json:"is_guest"` IsActive bool `json:"is_active"` @@ -58,6 +60,7 @@ type AdminCreateUser struct { // AdminUserUpdate is the request body for updating a user via admin type AdminUserUpdate struct { Role *string `json:"role"` + IsAdmin *bool `json:"is_admin"` IsActive *bool `json:"is_active"` IsBanned *bool `json:"is_banned"` Nickname *string `json:"nickname"` diff --git a/backend/internal/dto/auth.go b/backend/internal/dto/auth.go index 6f7d870b..2b2e3504 100644 --- a/backend/internal/dto/auth.go +++ b/backend/internal/dto/auth.go @@ -37,6 +37,7 @@ type UserResponse struct { Nickname string `json:"nickname"` AvatarURL *string `json:"avatar_url"` Role string `json:"role"` + IsAdmin bool `json:"is_admin"` IsCertified bool `json:"is_certified"` CertificationTitle *string `json:"certification_title"` BabyBirthDate *time.Time `json:"baby_birth_date"` @@ -63,5 +64,5 @@ type ResetPasswordRequest struct { // UpdateRoleRequest is the request body for updating user role type UpdateRoleRequest struct { - Role string `json:"role" binding:"required,oneof=mom dad family"` + Role string `json:"role" binding:"required,oneof=mom dad"` } diff --git a/backend/internal/dto/common.go b/backend/internal/dto/common.go index 9ac04c49..16cd5069 100644 --- a/backend/internal/dto/common.go +++ b/backend/internal/dto/common.go @@ -52,6 +52,7 @@ type AuthorInfo struct { Nickname string `json:"nickname"` AvatarURL *string `json:"avatar_url"` Role string `json:"role"` + DisplayTag string `json:"display_tag"` IsCertified bool `json:"is_certified"` CertificationTitle *string `json:"certification_title"` } diff --git a/backend/internal/dto/user.go b/backend/internal/dto/user.go index 154546e8..216f2941 100644 --- a/backend/internal/dto/user.go +++ b/backend/internal/dto/user.go @@ -4,15 +4,27 @@ import "time" // UserProfile is the response for user profile type UserProfile struct { - ID string `json:"id"` - Nickname string `json:"nickname"` - Email string `json:"email"` - AvatarURL *string `json:"avatar_url"` - Role string `json:"role"` - IsCertified bool `json:"is_certified"` - CertificationTitle *string `json:"certification_title"` - Stats UserStats `json:"stats"` - CreatedAt time.Time `json:"created_at"` + ID string `json:"id"` + Username string `json:"username"` + Nickname string `json:"nickname"` + Email string `json:"email"` + AvatarURL *string `json:"avatar_url"` + Role string `json:"role"` + IsAdmin bool `json:"is_admin"` + ShellCode *string `json:"shell_code"` + Partner *PartnerInfo `json:"partner"` + IsCertified bool `json:"is_certified"` + CertificationTitle *string `json:"certification_title"` + Stats UserStats `json:"stats"` + CreatedAt time.Time `json:"created_at"` +} + +// PartnerInfo is the partner summary shown in profile +type PartnerInfo struct { + ID string `json:"id"` + Nickname string `json:"nickname"` + AvatarURL *string `json:"avatar_url"` + Role string `json:"role"` } // UserStats holds user statistics @@ -25,8 +37,14 @@ type UserStats struct { // UserProfileUpdate is the request body for updating user profile type UserProfileUpdate struct { + Username *string `json:"username" binding:"omitempty,min=3,max=50"` Nickname *string `json:"nickname" binding:"omitempty,min=1,max=50"` Email *string `json:"email" binding:"omitempty,email"` AvatarURL *string `json:"avatar_url"` - Role *string `json:"role" binding:"omitempty,oneof=mom dad family"` + Role *string `json:"role" binding:"omitempty,oneof=mom dad"` +} + +// BindPartnerRequest is the request body for binding a partner via shell code +type BindPartnerRequest struct { + ShellCode string `json:"shell_code" binding:"required"` } diff --git a/backend/internal/handler/admin.go b/backend/internal/handler/admin.go index 23960be0..33bc6222 100644 --- a/backend/internal/handler/admin.go +++ b/backend/internal/handler/admin.go @@ -7,7 +7,6 @@ import ( "github.com/momshell/backend/internal/admin" "github.com/momshell/backend/internal/dto" "github.com/momshell/backend/internal/middleware" - "github.com/momshell/backend/internal/model" "github.com/momshell/backend/internal/service" ) @@ -37,7 +36,7 @@ func (h *AdminHandler) requireAdmin(c *gin.Context) (string, bool) { return "", false } - if user.Role != model.RoleAdmin { + if !user.IsAdmin { c.JSON(http.StatusForbidden, gin.H{"error": "需要管理员权限"}) return "", false } diff --git a/backend/internal/handler/user.go b/backend/internal/handler/user.go index 1108af8a..cc155791 100644 --- a/backend/internal/handler/user.go +++ b/backend/internal/handler/user.go @@ -1,7 +1,10 @@ package handler import ( + "fmt" "net/http" + "os" + "path/filepath" "github.com/gin-gonic/gin" "github.com/momshell/backend/internal/dto" @@ -9,6 +12,15 @@ import ( "github.com/momshell/backend/internal/service" ) +const maxAvatarSize = 2 << 20 // 2 MB + +var allowedImageTypes = map[string]bool{ + "image/jpeg": true, + "image/png": true, + "image/gif": true, + "image/webp": true, +} + type UserHandler struct { userService *service.UserService } @@ -48,6 +60,114 @@ func (h *UserHandler) UpdateMe(c *gin.Context) { c.JSON(http.StatusOK, profile) } +// POST /api/v1/community/users/me/avatar +func (h *UserHandler) UploadAvatar(c *gin.Context) { + userID := middleware.GetUserID(c) + + file, header, err := c.Request.FormFile("avatar") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "请选择要上传的图片"}) + return + } + defer func() { _ = file.Close() }() + + if header.Size > maxAvatarSize { + c.JSON(http.StatusBadRequest, gin.H{"error": "图片大小不能超过 2MB"}) + return + } + + contentType := header.Header.Get("Content-Type") + if !allowedImageTypes[contentType] { + c.JSON(http.StatusBadRequest, gin.H{"error": "仅支持 JPG、PNG、GIF、WebP 格式"}) + return + } + + ext := ".jpg" + switch contentType { + case "image/png": + ext = ".png" + case "image/gif": + ext = ".gif" + case "image/webp": + ext = ".webp" + } + + uploadDir := "uploads/avatars" + if err := os.MkdirAll(uploadDir, 0o755); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "上传失败"}) + return + } + + filename := userID + ext + savePath := filepath.Join(uploadDir, filename) + + // Remove old avatar files with different extensions + for _, e := range []string{".jpg", ".png", ".gif", ".webp"} { + if e != ext { + _ = os.Remove(filepath.Join(uploadDir, userID+e)) + } + } + + if err := c.SaveUploadedFile(header, savePath); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "上传失败"}) + return + } + + avatarURL := fmt.Sprintf("/uploads/avatars/%s", filename) + // Add cache-busting query param so browser refreshes the image + avatarURLWithBust := fmt.Sprintf("%s?v=%d", avatarURL, header.Size) + + profile, err := h.userService.UpdateProfile(userID, dto.UserProfileUpdate{ + AvatarURL: &avatarURLWithBust, + }) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, profile) +} + +// POST /api/v1/community/users/me/shell-code +func (h *UserHandler) GenerateShellCode(c *gin.Context) { + userID := middleware.GetUserID(c) + profile, err := h.userService.GenerateShellCode(userID) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, profile) +} + +// POST /api/v1/community/users/me/bind +func (h *UserHandler) BindPartner(c *gin.Context) { + userID := middleware.GetUserID(c) + + var req dto.BindPartnerRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + profile, err := h.userService.BindPartner(userID, req.ShellCode) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, profile) +} + +// DELETE /api/v1/community/users/me/bind +func (h *UserHandler) UnbindPartner(c *gin.Context) { + userID := middleware.GetUserID(c) + profile, err := h.userService.UnbindPartner(userID) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, profile) +} + // GET /api/v1/community/users/me/questions func (h *UserHandler) GetMyQuestions(c *gin.Context) { userID := middleware.GetUserID(c) diff --git a/backend/internal/model/user.go b/backend/internal/model/user.go index c9595944..30421659 100644 --- a/backend/internal/model/user.go +++ b/backend/internal/model/user.go @@ -14,11 +14,9 @@ const ( RoleGuest UserRole = "guest" RoleMom UserRole = "mom" RoleDad UserRole = "dad" - RoleFamily UserRole = "family" RoleCertifiedDoctor UserRole = "certified_doctor" RoleCertifiedTherapist UserRole = "certified_therapist" RoleCertifiedNurse UserRole = "certified_nurse" - RoleAdmin UserRole = "admin" RoleAIAssistant UserRole = "ai_assistant" ) @@ -29,9 +27,8 @@ var ProfessionalRoles = map[UserRole]bool{ } var FamilyRoles = map[UserRole]bool{ - RoleMom: true, - RoleDad: true, - RoleFamily: true, + RoleMom: true, + RoleDad: true, } // CertificationStatus enum @@ -55,6 +52,7 @@ type User struct { Role UserRole `gorm:"type:varchar(30);default:'mom'" json:"role"` ShellCode *string `gorm:"type:varchar(8);uniqueIndex" json:"shell_code"` IsGuest bool `gorm:"default:false" json:"is_guest"` + IsAdmin bool `gorm:"default:false" json:"is_admin"` PartnerID *string `gorm:"type:varchar(36)" json:"partner_id"` // Postpartum info diff --git a/backend/internal/repository/echo.go b/backend/internal/repository/echo.go index fd722dde..ffa4ad1b 100644 --- a/backend/internal/repository/echo.go +++ b/backend/internal/repository/echo.go @@ -49,6 +49,19 @@ func (r *EchoRepo) FindMemoirsByUserID(userID string, limit, offset int) ([]mode return memoirs, total, err } +func (r *EchoRepo) FindMemoirsByUserIDs(userIDs []string, limit, offset int) ([]model.Memoir, int64, error) { + query := r.db.Model(&model.Memoir{}).Where("user_id IN ?", userIDs) + + var total int64 + if err := query.Count(&total).Error; err != nil { + return nil, 0, err + } + + var memoirs []model.Memoir + err := query.Order("created_at desc").Offset(offset).Limit(limit).Find(&memoirs).Error + return memoirs, total, err +} + func (r *EchoRepo) CreateMemoir(memoir *model.Memoir) error { return r.db.Create(memoir).Error } diff --git a/backend/internal/repository/user.go b/backend/internal/repository/user.go index fcc2e203..3b74453b 100644 --- a/backend/internal/repository/user.go +++ b/backend/internal/repository/user.go @@ -50,6 +50,27 @@ func (r *UserRepo) ExistsByUsernameOrEmail(username, email string) (bool, error) return count > 0, err } +func (r *UserRepo) ExistsByUsername(username, excludeUserID string) (bool, error) { + var count int64 + err := r.db.Model(&model.User{}).Where("username = ? AND id != ?", username, excludeUserID).Count(&count).Error + return count > 0, err +} + +func (r *UserRepo) ExistsByEmail(email, excludeUserID string) (bool, error) { + var count int64 + err := r.db.Model(&model.User{}).Where("email = ? AND id != ?", email, excludeUserID).Count(&count).Error + return count > 0, err +} + +func (r *UserRepo) FindByShellCode(code string) (*model.User, error) { + var user model.User + err := r.db.Where("shell_code = ?", code).First(&user).Error + if err != nil { + return nil, err + } + return &user, nil +} + func (r *UserRepo) Create(user *model.User) error { return r.db.Create(user).Error } diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index e4f3ecc8..07f068d3 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -26,6 +26,9 @@ func Setup( c.JSON(200, gin.H{"status": "ok"}) }) + // Serve uploaded files + r.Static("/uploads", "./uploads") + // Admin panel (HTML page, no auth required for serving the page) r.GET("/admin", adminHandler.ServeAdminPage) @@ -116,6 +119,10 @@ func Setup( { users.GET("/me", userHandler.GetMe) users.PUT("/me", userHandler.UpdateMe) + users.POST("/me/avatar", userHandler.UploadAvatar) + users.POST("/me/shell-code", userHandler.GenerateShellCode) + users.POST("/me/bind", userHandler.BindPartner) + users.DELETE("/me/bind", userHandler.UnbindPartner) users.GET("/me/questions", userHandler.GetMyQuestions) users.GET("/me/answers", userHandler.GetMyAnswers) } diff --git a/backend/internal/service/admin.go b/backend/internal/service/admin.go index cf75fd14..84e3b12f 100644 --- a/backend/internal/service/admin.go +++ b/backend/internal/service/admin.go @@ -87,6 +87,7 @@ func (s *AdminService) ListUsers(params dto.AdminUserListParams) (*dto.Paginated Email: u.Email, Nickname: u.Nickname, Role: string(u.Role), + IsAdmin: u.IsAdmin, IsActive: u.IsActive, IsBanned: u.IsBanned, IsGuest: u.IsGuest, @@ -112,6 +113,7 @@ func (s *AdminService) GetUser(id string) (*dto.AdminUserDetail, error) { Nickname: user.Nickname, AvatarURL: user.AvatarURL, Role: string(user.Role), + IsAdmin: user.IsAdmin, ShellCode: user.ShellCode, IsGuest: user.IsGuest, IsActive: user.IsActive, @@ -169,8 +171,8 @@ func (s *AdminService) CreateUser(req dto.AdminCreateUser) (*dto.AdminUserDetail func (s *AdminService) UpdateUser(id, adminID string, req dto.AdminUserUpdate) (*dto.AdminUserDetail, error) { if id == adminID { // Prevent self-demotion / self-ban - if req.Role != nil && *req.Role != string(model.RoleAdmin) { - return nil, errors.New("不能降低自己的管理员角色") + if req.IsAdmin != nil && !*req.IsAdmin { + return nil, errors.New("不能取消自己的管理员权限") } if req.IsActive != nil && !*req.IsActive { return nil, errors.New("不能停用自己的账号") @@ -188,6 +190,9 @@ func (s *AdminService) UpdateUser(id, adminID string, req dto.AdminUserUpdate) ( if req.Role != nil { user.Role = model.UserRole(*req.Role) } + if req.IsAdmin != nil { + user.IsAdmin = *req.IsAdmin + } if req.IsActive != nil { user.IsActive = *req.IsActive } diff --git a/backend/internal/service/auth.go b/backend/internal/service/auth.go index b1e1222e..1c215b4b 100644 --- a/backend/internal/service/auth.go +++ b/backend/internal/service/auth.go @@ -159,17 +159,17 @@ func (s *AuthService) UpdateRole(userID, role string) (*dto.UserResponse, error) return nil, errors.New("用户不存在") } - // Check if user is a professional or admin + // Check if user is a professional if model.ProfessionalRoles[user.Role] { return nil, errors.New("认证专业人员不能修改角色") } - if user.Role == model.RoleAdmin { - return nil, errors.New("管理员不能通过此接口修改角色") + if user.PartnerID != nil { + return nil, errors.New("已绑定伴侣,无法更改身份") } newRole := model.UserRole(role) if !model.FamilyRoles[newRole] { - return nil, errors.New("角色只能是: mom, dad, family") + return nil, errors.New("角色只能是: mom, dad") } user.Role = newRole @@ -210,6 +210,7 @@ func (s *AuthService) buildUserResponse(user *model.User) *dto.UserResponse { Nickname: user.Nickname, AvatarURL: user.AvatarURL, Role: string(user.Role), + IsAdmin: user.IsAdmin, BabyBirthDate: user.BabyBirthDate, PostpartumWeeks: user.PostpartumWeeks, CreatedAt: user.CreatedAt, diff --git a/backend/internal/service/community.go b/backend/internal/service/community.go index 2ff15b85..5528b6e1 100644 --- a/backend/internal/service/community.go +++ b/backend/internal/service/community.go @@ -75,9 +75,38 @@ func (s *CommunityService) BuildAuthorInfo(user *model.User) dto.AuthorInfo { } } + // Compute display tag: additional tag takes priority over base tag + info.DisplayTag = displayTag(user) + return info } +// displayTag returns the tag to show in community. +// Additional tags (admin, certified, ai_assistant) override the base tag (溯源者/守护者). +func displayTag(user *model.User) string { + if user.Certification != nil && user.Certification.Status == model.CertApproved { + switch user.Certification.CertificationType { + case model.RoleCertifiedDoctor: + return "认证医师" + case model.RoleCertifiedTherapist: + return "认证心理师" + case model.RoleCertifiedNurse: + return "认证护士" + } + } + if user.IsAdmin { + return "管理员" + } + if user.Role == model.RoleAIAssistant { + return "AI 助手" + } + // Base tag + if user.Role == model.RoleDad { + return "守护者" + } + return "溯源者" +} + func buildTagInfo(tag model.Tag) dto.TagInfo { return dto.TagInfo{ID: tag.ID, Name: tag.Name, Slug: tag.Slug} } @@ -285,7 +314,7 @@ func (s *CommunityService) UpdateQuestion(questionID string, req dto.QuestionUpd return nil, err } - if q.AuthorID != user.ID && user.Role != model.RoleAdmin { + if q.AuthorID != user.ID && !user.IsAdmin { return nil, errors.New("无权修改此问题") } @@ -338,7 +367,7 @@ func (s *CommunityService) DeleteQuestion(questionID string, user *model.User) e return err } - if q.AuthorID != user.ID && user.Role != model.RoleAdmin { + if q.AuthorID != user.ID && !user.IsAdmin { return errors.New("无权删除此问题") } @@ -408,7 +437,7 @@ func (s *CommunityService) CreateAnswer(questionID string, req dto.AnswerCreate, // Check permission for professional channel if q.Channel == model.ChannelProfessional { isAuthor := q.AuthorID == author.ID - if !s.IsCertifiedProfessional(author) && author.Role != model.RoleAdmin && !isAuthor { + if !s.IsCertifiedProfessional(author) && !author.IsAdmin && !isAuthor { return nil, errors.New("专业频道仅限认证专业人士回答") } } @@ -459,7 +488,7 @@ func (s *CommunityService) UpdateAnswer(answerID string, req dto.AnswerUpdate, u return nil, err } - if a.AuthorID != user.ID && user.Role != model.RoleAdmin { + if a.AuthorID != user.ID && !user.IsAdmin { return nil, errors.New("无权修改此回答") } @@ -493,7 +522,7 @@ func (s *CommunityService) DeleteAnswer(answerID string, user *model.User) error isAnswerAuthor := a.AuthorID == user.ID isQuestionAuthor := a.Question.AuthorID == user.ID - isAdmin := user.Role == model.RoleAdmin + isAdmin := user.IsAdmin if !isAnswerAuthor && !isQuestionAuthor && !isAdmin { return errors.New("无权删除此回答") @@ -523,69 +552,26 @@ func (s *CommunityService) GetComments(answerID, currentUserID string) ([]dto.Co likedIDs, _ = s.interactionRepo.FindLikedTargetIDs(currentUserID, "comment", commentIDs) } - // Build map for nested structure - commentMap := make(map[string]*model.Comment) - for i := range comments { - commentMap[comments[i].ID] = &comments[i] - } - - // Find root ancestor - var findRootID func(string) string - findRootID = func(id string) string { - c, ok := commentMap[id] - if !ok || c.ParentID == nil { - return id - } - return findRootID(*c.ParentID) - } - - rootItems := make(map[string]*dto.CommentListItem) + // Build flat list in chronological order, with reply_to_user resolved var result []dto.CommentListItem - - // First pass: root comments - for _, c := range comments { - if c.ParentID == nil { - item := dto.CommentListItem{ - ID: c.ID, - AnswerID: c.AnswerID, - Author: s.BuildAuthorInfo(&c.Author), - Content: c.Content, - ParentID: nil, - LikeCount: c.LikeCount, - IsLiked: likedIDs[c.ID], - CreatedAt: c.CreatedAt, - Replies: []dto.CommentListItem{}, - } - result = append(result, item) - rootItems[c.ID] = &result[len(result)-1] - } - } - - // Second pass: replies for _, c := range comments { - if c.ParentID != nil { - rootID := findRootID(c.ID) - if root, ok := rootItems[rootID]; ok { - var replyToUser *dto.AuthorInfo - if c.ReplyToUser != nil { - info := s.BuildAuthorInfo(c.ReplyToUser) - replyToUser = &info - } - reply := dto.CommentListItem{ - ID: c.ID, - AnswerID: c.AnswerID, - Author: s.BuildAuthorInfo(&c.Author), - Content: c.Content, - ParentID: &rootID, - ReplyToUser: replyToUser, - LikeCount: c.LikeCount, - IsLiked: likedIDs[c.ID], - CreatedAt: c.CreatedAt, - Replies: []dto.CommentListItem{}, - } - root.Replies = append(root.Replies, reply) - } - } + var replyToUser *dto.AuthorInfo + if c.ReplyToUser != nil { + info := s.BuildAuthorInfo(c.ReplyToUser) + replyToUser = &info + } + result = append(result, dto.CommentListItem{ + ID: c.ID, + AnswerID: c.AnswerID, + Author: s.BuildAuthorInfo(&c.Author), + Content: c.Content, + ParentID: c.ParentID, + ReplyToUser: replyToUser, + LikeCount: c.LikeCount, + IsLiked: likedIDs[c.ID], + CreatedAt: c.CreatedAt, + Replies: []dto.CommentListItem{}, + }) } if result == nil { @@ -659,7 +645,7 @@ func (s *CommunityService) DeleteComment(commentID string, user *model.User) err return err } - if comment.AuthorID != user.ID && user.Role != model.RoleAdmin { + if comment.AuthorID != user.ID && !user.IsAdmin { return errors.New("无权删除此评论") } diff --git a/backend/internal/service/echo.go b/backend/internal/service/echo.go index 0c827a45..190dfd24 100644 --- a/backend/internal/service/echo.go +++ b/backend/internal/service/echo.go @@ -17,12 +17,14 @@ import ( type EchoService struct { client *openai.Client echoRepo *repository.EchoRepo + userRepo *repository.UserRepo } -func NewEchoService(client *openai.Client, echoRepo *repository.EchoRepo) *EchoService { +func NewEchoService(client *openai.Client, echoRepo *repository.EchoRepo, userRepo *repository.UserRepo) *EchoService { return &EchoService{ client: client, echoRepo: echoRepo, + userRepo: userRepo, } } @@ -86,7 +88,14 @@ func (s *EchoService) DeleteIdentityTag(userID, tagID string) error { } func (s *EchoService) GetMemoirs(userID string, limit, offset int) (*dto.MemoirListResponse, error) { - memoirs, total, err := s.echoRepo.FindMemoirsByUserID(userID, limit, offset) + // Collect user IDs: self + partner + userIDs := []string{userID} + user, err := s.userRepo.FindByID(userID) + if err == nil && user.PartnerID != nil { + userIDs = append(userIDs, *user.PartnerID) + } + + memoirs, total, err := s.echoRepo.FindMemoirsByUserIDs(userIDs, limit, offset) if err != nil { return nil, err } @@ -219,7 +228,7 @@ func parseMemoirLLMResponse(content string) map[string]interface{} { } } - re2 := regexp.MustCompile(`(?s)\{.*\}`) + re2 := regexp.MustCompile("(?s)\\{.*\\}") if match := re2.FindString(content); match != "" { if err := json.Unmarshal([]byte(match), &result); err == nil { return result diff --git a/backend/internal/service/user.go b/backend/internal/service/user.go index 6d4878ad..6d2b8489 100644 --- a/backend/internal/service/user.go +++ b/backend/internal/service/user.go @@ -1,7 +1,9 @@ package service import ( + "crypto/rand" "errors" + "math/big" "github.com/momshell/backend/internal/dto" "github.com/momshell/backend/internal/model" @@ -52,17 +54,34 @@ func (s *UserService) GetProfile(userID string) (*dto.UserProfile, error) { info := s.community.BuildAuthorInfo(user) - return &dto.UserProfile{ + profile := &dto.UserProfile{ ID: user.ID, + Username: user.Username, Nickname: user.Nickname, Email: user.Email, AvatarURL: user.AvatarURL, Role: string(user.Role), + IsAdmin: user.IsAdmin, + ShellCode: user.ShellCode, IsCertified: info.IsCertified, CertificationTitle: info.CertificationTitle, Stats: *stats, CreatedAt: user.CreatedAt, - }, nil + } + + if user.PartnerID != nil { + partner, err := s.userRepo.FindByID(*user.PartnerID) + if err == nil { + profile.Partner = &dto.PartnerInfo{ + ID: partner.ID, + Nickname: partner.Nickname, + AvatarURL: partner.AvatarURL, + Role: string(partner.Role), + } + } + } + + return profile, nil } func (s *UserService) UpdateProfile(userID string, req dto.UserProfileUpdate) (*dto.UserProfile, error) { @@ -71,12 +90,20 @@ func (s *UserService) UpdateProfile(userID string, req dto.UserProfileUpdate) (* return nil, errors.New("用户不存在") } + if req.Username != nil && *req.Username != user.Username { + exists, _ := s.userRepo.ExistsByUsername(*req.Username, userID) + if exists { + return nil, errors.New("该用户名已被使用") + } + user.Username = *req.Username + } + if req.Nickname != nil { user.Nickname = *req.Nickname } if req.Email != nil && *req.Email != user.Email { - exists, _ := s.userRepo.ExistsByUsernameOrEmail("", *req.Email) + exists, _ := s.userRepo.ExistsByEmail(*req.Email, userID) if exists { return nil, errors.New("该邮箱已被使用") } @@ -90,13 +117,13 @@ func (s *UserService) UpdateProfile(userID string, req dto.UserProfileUpdate) (* if req.Role != nil { newRole := model.UserRole(*req.Role) if !model.FamilyRoles[newRole] { - return nil, errors.New("角色只能是: mom, dad, family") + return nil, errors.New("角色只能是: mom, dad") } if model.ProfessionalRoles[user.Role] { return nil, errors.New("认证专业人员不能修改角色") } - if user.Role == model.RoleAdmin { - return nil, errors.New("管理员不能通过此接口修改角色") + if user.PartnerID != nil { + return nil, errors.New("已绑定伴侣,无法更改身份") } user.Role = newRole } @@ -108,6 +135,134 @@ func (s *UserService) UpdateProfile(userID string, req dto.UserProfileUpdate) (* return s.GetProfile(userID) } +// GenerateShellCode generates a shell code for 溯源者 (mom) to share with their partner. +func (s *UserService) GenerateShellCode(userID string) (*dto.UserProfile, error) { + user, err := s.userRepo.FindByID(userID) + if err != nil { + return nil, errors.New("用户不存在") + } + + if user.Role != model.RoleMom { + return nil, errors.New("只有溯源者可以生成贝壳码") + } + + if user.PartnerID != nil { + return nil, errors.New("已绑定伴侣,无法重新生成贝壳码") + } + + // Generate if not already present + if user.ShellCode == nil { + code, err := generateRandomCode(8) + if err != nil { + return nil, errors.New("生成贝壳码失败") + } + user.ShellCode = &code + if err := s.userRepo.Update(user); err != nil { + return nil, err + } + } + + return s.GetProfile(userID) +} + +// BindPartner binds a 守护者 (dad) to a 溯源者 (mom) via shell code. +func (s *UserService) BindPartner(userID string, shellCode string) (*dto.UserProfile, error) { + user, err := s.userRepo.FindByID(userID) + if err != nil { + return nil, errors.New("用户不存在") + } + + if user.Role != model.RoleDad { + return nil, errors.New("只有守护者可以填写贝壳码") + } + + if user.PartnerID != nil { + return nil, errors.New("您已绑定伴侣") + } + + partner, err := s.userRepo.FindByShellCode(shellCode) + if err != nil { + return nil, errors.New("贝壳码无效") + } + + if partner.Role != model.RoleMom { + return nil, errors.New("贝壳码无效") + } + + if partner.PartnerID != nil { + return nil, errors.New("对方已绑定其他伴侣") + } + + if partner.ID == userID { + return nil, errors.New("不能绑定自己") + } + + // Bind both sides in a transaction + err = s.db.Transaction(func(tx *gorm.DB) error { + if err := tx.Model(&model.User{}).Where("id = ?", user.ID).Update("partner_id", partner.ID).Error; err != nil { + return err + } + if err := tx.Model(&model.User{}).Where("id = ?", partner.ID).Update("partner_id", user.ID).Error; err != nil { + return err + } + return nil + }) + if err != nil { + return nil, errors.New("绑定失败") + } + + return s.GetProfile(userID) +} + +// UnbindPartner removes the partner relationship for the current user. +func (s *UserService) UnbindPartner(userID string) (*dto.UserProfile, error) { + user, err := s.userRepo.FindByID(userID) + if err != nil { + return nil, errors.New("用户不存在") + } + + if user.PartnerID == nil { + return nil, errors.New("您尚未绑定伴侣") + } + + partnerID := *user.PartnerID + + // Unbind both sides, clear shell code + err = s.db.Transaction(func(tx *gorm.DB) error { + if err := tx.Model(&model.User{}).Where("id = ?", userID).Updates(map[string]interface{}{ + "partner_id": nil, + "shell_code": nil, + }).Error; err != nil { + return err + } + if err := tx.Model(&model.User{}).Where("id = ?", partnerID).Updates(map[string]interface{}{ + "partner_id": nil, + "shell_code": nil, + }).Error; err != nil { + return err + } + return nil + }) + if err != nil { + return nil, errors.New("解绑失败") + } + + return s.GetProfile(userID) +} + +func generateRandomCode(length int) (string, error) { + const charset = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" + code := make([]byte, length) + for i := range code { + n, err := rand.Int(rand.Reader, big.NewInt(int64(len(charset)))) + if err != nil { + return "", err + } + code[i] = charset[n.Int64()] + } + return string(code), nil +} + func (s *UserService) GetUserQuestions(userID string, page, pageSize int) (*dto.PaginatedResponse, error) { offset := (page - 1) * pageSize questions, total, err := s.questionRepo.FindByAuthorID(userID, offset, pageSize) diff --git a/backend/pkg/openai/client.go b/backend/pkg/openai/client.go index 8dccdac1..a69be8b5 100644 --- a/backend/pkg/openai/client.go +++ b/backend/pkg/openai/client.go @@ -77,7 +77,7 @@ func (c *Client) Chat(ctx context.Context, messages []Message) (string, error) { if err != nil { return "", fmt.Errorf("openai chat request failed: %w", err) } - defer func() { _ = resp.Body.Close() }() + defer resp.Body.Close() respBody, err := io.ReadAll(resp.Body) if err != nil { diff --git a/backend/uploads/avatars/0ea70a2d-f9ba-4bd9-8615-fbe6d5a8f421.jpg b/backend/uploads/avatars/0ea70a2d-f9ba-4bd9-8615-fbe6d5a8f421.jpg new file mode 100644 index 00000000..97475c83 --- /dev/null +++ b/backend/uploads/avatars/0ea70a2d-f9ba-4bd9-8615-fbe6d5a8f421.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1f62ade99c320ef2cc6b03c1267b3e84e571c7d4b2e576accb438604913b8e5a +size 953068 diff --git a/backend/uploads/avatars/7489176a-d8b7-4cee-a3a3-76c4d2759292.png b/backend/uploads/avatars/7489176a-d8b7-4cee-a3a3-76c4d2759292.png new file mode 100644 index 00000000..df568498 --- /dev/null +++ b/backend/uploads/avatars/7489176a-d8b7-4cee-a3a3-76c4d2759292.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:527e644d661085f0201e58e9b502dae8d9803281358da68d6950a43e89cc8e5f +size 414120 diff --git a/docs/configuration.md b/docs/configuration.md index 5f0661bf..012713e0 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -26,7 +26,7 @@ The setup script auto-generates `JWT_SECRET_KEY`. Change it manually for product |----------|-------------|----------|---------| | `OPENAI_API_KEY` | API key for LLM service | **Yes** | — | | `OPENAI_BASE_URL` | API base URL | No | `https://api-inference.modelscope.cn/v1` | -| `OPENAI_MODEL` | Model name | No | `Qwen/Qwen2.5-72B-Instruct` | +| `OPENAI_MODEL` | Model name | No | `Qwen/Qwen3-235B-A22B` | Any OpenAI-compatible API is supported (ModelScope, OpenAI, local Ollama, etc.). diff --git a/frontend.new/.omc/state/last-tool-error.json b/frontend.new/.omc/state/last-tool-error.json new file mode 100644 index 00000000..50c8bb76 --- /dev/null +++ b/frontend.new/.omc/state/last-tool-error.json @@ -0,0 +1,7 @@ +{ + "tool_name": "Bash", + "tool_input_preview": "{\"command\":\"cd /home/koishi/MomShell && git add frontend.new/src/components/scene/BeachScene.vue frontend.new/src/constants/layers.ts frontend.new/vite.config.ts && git rebase --continue\",\"description...", + "error": "Exit code 1\n[detached HEAD cc7ec54] refactor: restructure beach scene into Vue 3 + Vite + TypeScript\n 5 files changed, 2174 insertions(+), 1221 deletions(-)\n rename frontend.new/assets/{render-sand.js => render-sand.ts} (87%)\nRebasing (16/58)\rAuto-merging frontend.new/src/components/scene/BeachScene.vue\nCONFLICT (content): Merge conflict in frontend.new/src/components/scene/BeachScene.vue\nAuto-merging frontend.new/src/components/scene/SpritesLayer.vue\nCONFLICT (add/add): Merge conflict in fronte...", + "timestamp": "2026-03-07T06:54:53.196Z", + "retry_count": 1 +} \ No newline at end of file diff --git a/frontend/package-lock.json b/frontend/package-lock.json index be25ba62..05e93e56 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -3411,6 +3411,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 28638e87..0d0a36fd 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -4,6 +4,7 @@ + @@ -16,6 +17,7 @@ import LandingOverlay from '@/components/overlay/LandingOverlay.vue' import AuthPanel from '@/components/overlay/AuthPanel.vue' import RoleSelectPanel from '@/components/overlay/RoleSelectPanel.vue' import ProfilePanel from '@/components/overlay/ProfilePanel.vue' +import CarPage from '@/components/overlay/CarPage.vue' import MemoryPanel from '@/components/overlay/MemoryPanel.vue' import CommunityPanel from '@/components/overlay/CommunityPanel.vue' import ChatPanel from '@/components/overlay/ChatPanel.vue' diff --git a/frontend/src/assets/avatar.png b/frontend/src/assets/avatar.png new file mode 100644 index 00000000..e326af56 --- /dev/null +++ b/frontend/src/assets/avatar.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:92fb120be29832ccd0919bebb788d4cb35f214781d0f719854ec43780d67b5a4 +size 1831584 diff --git a/frontend/src/assets/box.png b/frontend/src/assets/box.png new file mode 100644 index 00000000..976a4f2a --- /dev/null +++ b/frontend/src/assets/box.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4c7e03d79777ad46369e787d58d7e6eeaaca63f197995a66b10c8a6bfd17c73b +size 1613025 diff --git a/frontend/src/assets/car-bg.png b/frontend/src/assets/car-bg.png new file mode 100644 index 00000000..267b870b --- /dev/null +++ b/frontend/src/assets/car-bg.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ebbb8b3b97d21376ba1586c49b7eed9f4a35417316044dfdc9e7dc0cc2250c26 +size 2889287 diff --git a/frontend/src/assets/community.png b/frontend/src/assets/community.png new file mode 100644 index 00000000..fc27f4f2 --- /dev/null +++ b/frontend/src/assets/community.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7d9ad2c8122fee65f1381a373b71108bdcc9a1d6733854c61064df432b9e5bb7 +size 1113396 diff --git a/frontend/src/assets/frame.png b/frontend/src/assets/frame.png new file mode 100644 index 00000000..2f829fc0 --- /dev/null +++ b/frontend/src/assets/frame.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5bf896d3c472b6444f808e5e6fd777c038bc244bbe6c866c65e7185e9ff51da0 +size 2177114 diff --git a/frontend/src/components/overlay/CarPage.vue b/frontend/src/components/overlay/CarPage.vue new file mode 100644 index 00000000..cb7d4619 --- /dev/null +++ b/frontend/src/components/overlay/CarPage.vue @@ -0,0 +1,1751 @@ + + + + + diff --git a/frontend/src/components/overlay/ChatPanel.vue b/frontend/src/components/overlay/ChatPanel.vue index e452bd2c..62e9e3b9 100644 --- a/frontend/src/components/overlay/ChatPanel.vue +++ b/frontend/src/components/overlay/ChatPanel.vue @@ -110,9 +110,7 @@ function generateSessionId(): string { if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { return crypto.randomUUID() } - const bytes = new Uint8Array(16) - crypto.getRandomValues(bytes) - return Array.from(bytes, b => b.toString(16).padStart(2, '0')).join('') + return Date.now().toString(36) + Math.random().toString(36).slice(2) } function syncPersistent() { diff --git a/frontend/src/components/overlay/CommunityPanel.vue b/frontend/src/components/overlay/CommunityPanel.vue index ae5706cd..07b26f45 100644 --- a/frontend/src/components/overlay/CommunityPanel.vue +++ b/frontend/src/components/overlay/CommunityPanel.vue @@ -44,12 +44,16 @@ {{ tag.name }}
+ {{ q.author.nickname }} + {{ q.author.display_tag }} · {{ q.answer_count }} 回答 - {{ q.is_liked ? '❤️' : '🤍' }} {{ q.like_count }} + {{ q.like_count }} + + + {{ q.is_collected ? '★' : '☆' }} {{ q.collection_count }} - · 📑 {{ q.collection_count }}
@@ -99,91 +103,87 @@
- -

{{ selectedDetail.title }}

-

{{ selectedDetail.author.nickname }}

-
- {{ tag.name }} -
-
{{ selectedDetail.content }}
+
+ +

{{ selectedDetail.title }}

+
+ + {{ selectedDetail.author.nickname }} + {{ selectedDetail.author.display_tag }} +
+
+ {{ tag.name }} +
+
{{ selectedDetail.content }}
-
- - -
+
+ + +
-
-

回答 ({{ selectedDetail.answer_count }})

-
加载中...
-
-

- {{ a.author.nickname }} - 专业 -

-

{{ a.content }}

-
- - -
+
+

回答 ({{ selectedDetail.answer_count }})

+
加载中...
+
+
+ + {{ a.author.nickname }} + {{ a.author.display_tag }} +
+

{{ a.content }}

+
+ + +
-
-
加载中...
-
- - +
+ {{ commentTarget.nickname ? `回复 @${commentTarget.nickname}` : '写评论' }} + +
+
+ + +
@@ -192,9 +192,10 @@ diff --git a/frontend/src/composables/useParallax.ts b/frontend/src/composables/useParallax.ts index 413eeefb..18e44c89 100644 --- a/frontend/src/composables/useParallax.ts +++ b/frontend/src/composables/useParallax.ts @@ -7,6 +7,8 @@ export interface ParallaxContext { registerLayer: (el: HTMLElement, speed: number) => void startLoop: () => void hideHint: () => void + wasDrag: () => boolean + scrollTo: (offset: number) => void } export const PARALLAX_KEY: InjectionKey = Symbol('parallax') @@ -19,6 +21,8 @@ export function useParallax() { const keys = reactive({ ArrowLeft: false, ArrowRight: false }) let dragging = false + let totalDragDistance = 0 + const DRAG_THRESHOLD = 5 interface LayerMeta { el: HTMLElement @@ -96,16 +100,24 @@ export function useParallax() { let offsetAtDragStart = 0 function onMouseDown(e: MouseEvent) { + const target = e.target as HTMLElement + if (target.closest('button, input, textarea, select, a, [contenteditable]')) { + return + } dragging = true + totalDragDistance = 0 dragStartX = e.clientX offsetAtDragStart = targetOffset.value - document.body.classList.add('dragging') startLoop() hideHint() } function onMouseMove(e: MouseEvent) { if (!dragging) return const dx = e.clientX - dragStartX + totalDragDistance += Math.abs(e.movementX) + if (totalDragDistance > DRAG_THRESHOLD && !document.body.classList.contains('dragging')) { + document.body.classList.add('dragging') + } targetOffset.value = offsetAtDragStart - dx * 1.8 } function onMouseUp() { @@ -167,14 +179,27 @@ export function useParallax() { document.removeEventListener('touchmove', onTouchMove) }) + function wasDrag(): boolean { + return totalDragDistance > DRAG_THRESHOLD + } + + function scrollTo(offset: number) { + targetOffset.value = Math.max(-maxOffset.value, Math.min(maxOffset.value, offset)) + // Jump halfway to target so the animation feels faster + currentOffset.value += (targetOffset.value - currentOffset.value) * 0.5 + startLoop() + } + const ctx: ParallaxContext = { currentOffset, registerLayer, startLoop, hideHint, + wasDrag, + scrollTo, } provide(PARALLAX_KEY, ctx) - return { currentOffset, hintHidden, registerLayer, startLoop, hideHint } + return { currentOffset, hintHidden, registerLayer, startLoop, hideHint, scrollTo } } diff --git a/frontend/src/constants/sprites.ts b/frontend/src/constants/sprites.ts index 513c8af0..46653c43 100644 --- a/frontend/src/constants/sprites.ts +++ b/frontend/src/constants/sprites.ts @@ -14,6 +14,7 @@ import barImg from '@/assets/bar.png' import stoneImg from '@/assets/stone.png' import crabImg from '@/assets/crab.png' import shellImg from '@/assets/shell.png' +import communityImg from '@/assets/community.png' export const SPRITES: SpriteData[] = [ @@ -26,8 +27,9 @@ export const SPRITES: SpriteData[] = [ { id: 'crab', src: crabImg, left: '60%', top: '12%', width: '6vw' }, { id: 'shell1', src: shellImg, left: '48%', top: '45%', width: '6vw', rotate: 15 }, - { id: 'shell2', src: shellImg, left: '56%', top: '60%', width: '5.5vw', rotate: -10, scaleX: -1 }, { id: 'shell3', src: shellImg, left: '50%', top: '75%', width: '5vw', rotate: 45, scaleY: 1 }, { id: 'shell4', src: shellImg, left: '52.5%', top: '48%', width: '5.2vw', rotate: 30, scaleX: -1, scaleY: -1 }, + { id: 'community', src: communityImg, left: '54%', top: '15%', width: '45vw' }, + ] diff --git a/frontend/src/lib/api/community.ts b/frontend/src/lib/api/community.ts index e512863e..407cea27 100644 --- a/frontend/src/lib/api/community.ts +++ b/frontend/src/lib/api/community.ts @@ -7,6 +7,7 @@ export interface Author { nickname: string avatar_url: string | null role: string + display_tag: string is_certified: boolean certification_title: string | null } diff --git a/frontend/src/lib/api/user.ts b/frontend/src/lib/api/user.ts index ac95ed31..742b105f 100644 --- a/frontend/src/lib/api/user.ts +++ b/frontend/src/lib/api/user.ts @@ -1,12 +1,23 @@ import apiClient from '@/lib/apiClient' import type { PaginatedResponse } from './community' +export interface PartnerInfo { + id: string + nickname: string + avatar_url: string | null + role: string +} + export interface UserProfile { id: string + username: string nickname: string email: string avatar_url: string | null role: string + is_admin: boolean + shell_code: string | null + partner: PartnerInfo | null is_certified: boolean certification_title: string | null stats: UserStats @@ -55,6 +66,7 @@ export function getUserProfile(): Promise { } export function updateUserProfile(data: { + username?: string nickname?: string email?: string avatar_url?: string @@ -89,3 +101,29 @@ export function changePassword(data: { .post('/api/v1/auth/change-password', data) .then((r) => r.data) } + +export function uploadAvatar(file: File): Promise { + const form = new FormData() + form.append('avatar', file) + return apiClient + .post('/api/v1/community/users/me/avatar', form) + .then((r) => r.data) +} + +export function generateShellCode(): Promise { + return apiClient + .post('/api/v1/community/users/me/shell-code') + .then((r) => r.data) +} + +export function bindPartner(shellCode: string): Promise { + return apiClient + .post('/api/v1/community/users/me/bind', { shell_code: shellCode }) + .then((r) => r.data) +} + +export function unbindPartner(): Promise { + return apiClient + .delete('/api/v1/community/users/me/bind') + .then((r) => r.data) +} diff --git a/frontend/src/lib/apiClient.ts b/frontend/src/lib/apiClient.ts index 3b89630d..a80d25ee 100644 --- a/frontend/src/lib/apiClient.ts +++ b/frontend/src/lib/apiClient.ts @@ -11,7 +11,7 @@ import { apiRefresh, } from "./auth"; -const API_BASE = import.meta.env.VITE_API_BASE_URL || "http://localhost:8000"; +const API_BASE = import.meta.env.VITE_API_BASE_URL || ""; const apiClient: AxiosInstance = axios.create({ baseURL: API_BASE, @@ -39,6 +39,10 @@ apiClient.interceptors.request.use( config.headers.Authorization = `Bearer ${token}`; config.headers["X-Access-Token"] = token; } + // Let browser set Content-Type with boundary for FormData + if (config.data instanceof FormData) { + delete config.headers["Content-Type"]; + } return config; }, (error) => Promise.reject(error), @@ -100,18 +104,19 @@ export default apiClient; export function getErrorMessage(error: unknown): string { if (axios.isAxiosError(error)) { - const axiosError = error as AxiosError<{ - detail?: string | Array<{ msg?: string }> | { message?: string }; - }>; - const detail = axiosError.response?.data?.detail; + const data = error.response?.data as Record | undefined; + // Gin backend: {"error": "..."} + if (data && typeof data.error === "string") return data.error; + // FastAPI-style: {"detail": "..."} + const detail = data?.detail; if (typeof detail === "string") return detail; if (Array.isArray(detail) && detail.length > 0) { - return detail.map((e) => e.msg || "").join("; "); + return detail.map((e: { msg?: string }) => e.msg || "").join("; "); } if (detail && typeof detail === "object" && "message" in detail) { return (detail as { message?: string }).message || "请求失败"; } - return axiosError.message || "请求失败"; + return error.message || "请求失败"; } if (error instanceof Error) return error.message; return "未知错误"; diff --git a/frontend/src/lib/auth.ts b/frontend/src/lib/auth.ts index 66b21db8..7f0d786a 100644 --- a/frontend/src/lib/auth.ts +++ b/frontend/src/lib/auth.ts @@ -1,4 +1,4 @@ -const API_BASE = import.meta.env.VITE_API_BASE_URL || "http://localhost:8000"; +const API_BASE = import.meta.env.VITE_API_BASE_URL || ""; const AUTH_API = `${API_BASE}/api/v1/auth`; export interface User { @@ -8,6 +8,7 @@ export interface User { nickname: string; avatar_url: string | null; role: string; + is_admin: boolean; is_certified: boolean; certification_title: string | null; baby_birth_date: string | null; @@ -27,7 +28,7 @@ export interface RegisterParams { email: string; password: string; nickname: string; - role?: "mom" | "dad" | "family"; + role?: "mom" | "dad"; } export interface LoginParams { @@ -48,13 +49,17 @@ async function fetchJson(url: string, init?: RequestInit): Promise { headers: mergedHeaders, }); if (!response.ok) { - const err = await response.json().catch(() => ({ detail: "请求失败" })); + const err = await response.json().catch(() => ({})); + // Gin backend returns { "error": "..." } + if (typeof err.error === "string") { + throw new Error(err.error); + } + // FastAPI-style { "detail": "..." } const detail = err.detail; if (typeof detail === "string") { throw new Error(detail); } if (Array.isArray(detail) && detail.length > 0) { - // FastAPI 422 validation errors: [{loc, msg, type}, ...] throw new Error( detail.map((e: { msg?: string }) => e.msg || "").join("; "), ); @@ -97,7 +102,7 @@ export function apiGetMe(accessToken: string): Promise { export function apiSetRole( accessToken: string, - role: "mom" | "dad" | "family", + role: "mom" | "dad", ): Promise { return fetchJson(`${AUTH_API}/me/role`, { method: "PATCH", diff --git a/frontend/src/stores/auth.ts b/frontend/src/stores/auth.ts index 9c8d3afa..8052acf9 100644 --- a/frontend/src/stores/auth.ts +++ b/frontend/src/stores/auth.ts @@ -54,7 +54,7 @@ export const useAuthStore = defineStore("auth", () => { return newUser; } - async function setRole(role: "mom" | "dad" | "family") { + async function setRole(role: "mom" | "dad") { if (!accessToken.value) throw new Error("Not authenticated"); const updatedUser = await apiSetRole(accessToken.value, role); user.value = updatedUser; diff --git a/frontend/src/stores/ui.ts b/frontend/src/stores/ui.ts index 39e94cd8..3aa2bea0 100644 --- a/frontend/src/stores/ui.ts +++ b/frontend/src/stores/ui.ts @@ -2,7 +2,7 @@ import { defineStore } from 'pinia' import { ref } from 'vue' import { useAuthStore } from './auth' -export type PanelName = 'auth' | 'role' | 'profile' | 'memory' | 'community' | 'chat' | null +export type PanelName = 'auth' | 'role' | 'profile' | 'memory' | 'community' | 'chat' | 'car' | null export type AuthMode = 'login' | 'register' | 'guest' export const useUiStore = defineStore('ui', () => { @@ -28,7 +28,7 @@ export const useUiStore = defineStore('ui', () => { } /** Open a feature panel, but intercept if guest → force auth */ - function openFeature(panel: 'profile' | 'memory' | 'community' | 'chat') { + function openFeature(panel: 'car' | 'profile' | 'memory' | 'community' | 'chat') { const auth = useAuthStore() if (!auth.isAuthenticated && !auth.isGuest) { openAuth() diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 18315c3c..2c84cd82 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -8,5 +8,17 @@ export default defineConfig({ alias: { '@': fileURLToPath(new URL('./src', import.meta.url)), } - } + }, + server: { + proxy: { + '/api': { + target: 'http://localhost:8000', + changeOrigin: true, + }, + '/uploads': { + target: 'http://localhost:8000', + changeOrigin: true, + }, + }, + }, }) diff --git a/scripts/dev-setup.sh b/scripts/dev-setup.sh index 21172aff..ab59ebc5 100755 --- a/scripts/dev-setup.sh +++ b/scripts/dev-setup.sh @@ -261,7 +261,7 @@ else ask "OPENAI_BASE_URL" "https://api-inference.modelscope.cn/v1" OPENAI_BASE_URL="$REPLY" - ask "OPENAI_MODEL" "Qwen/Qwen2.5-72B-Instruct" + ask "OPENAI_MODEL" "Qwen/Qwen3-235B-A22B" OPENAI_MODEL="$REPLY" # Server