浏览代码

Feat(frontend): Implement login functionality with Pinia and API connection

0225015 4 周之前
父节点
当前提交
011fa12d0d

+ 82 - 27
tongqu_backend_v2/accounts/models.py

@@ -11,47 +11,94 @@ class CustomUserManager(BaseUserManager):
         """
         创建并保存一个具有给定邮箱、手机号和密码的用户。
         """
+        print(f"--- CustomUserManager: create_user called ---")
+        print(f"Received - Email: {email}, Phone: {phone_number}, Password provided: {'Yes' if password else 'No'}")
+        print(f"Received - Extra fields: {extra_fields}")
+
         if not email:
+            print("Validation Error in create_user: Email is required")
             raise ValueError(_('用户必须有一个邮箱地址'))
         if not phone_number:
+            print("Validation Error in create_user: Phone number is required")
             raise ValueError(_('用户必须有一个手机号'))
 
         email = self.normalize_email(email)
-        # 确保普通用户创建时这些字段为False或默认值
         extra_fields.setdefault('is_staff', False)
         extra_fields.setdefault('is_superuser', False)
-        extra_fields.setdefault('is_active', True) # 普通用户默认激活
+        # is_active 字段在Admin表单中是可编辑的,所以extra_fields中可能已经有它的值
+        # 如果extra_fields中没有is_active,我们再设置默认值
+        if 'is_active' not in extra_fields:
+            extra_fields.setdefault('is_active', True) 
+        
+        print(f"Normalized email: {email}")
+        print(f"Fields for model instance before creation: email={email}, phone_number={phone_number}, extra_fields={extra_fields}")
 
         user = self.model(email=email, phone_number=phone_number, **extra_fields)
-        user.set_password(password) # 处理密码哈希
-        user.save(using=self._db)
+        
+        if password:
+            user.set_password(password)
+            print("Password has been set.")
+        else:
+            # 对于Admin创建,UserCreationForm应该总是提供密码。
+            # 如果通过其他方式调用create_user且没有密码,这里可以记录一个警告或抛出错误。
+            print("WARNING in create_user: Password was not provided. User will not be able to log in with a password.")
+
+
+        try:
+            user.save(using=self._db)
+            print(f"--- User saved successfully with ID: {user.id} ---")
+        except Exception as e:
+            print(f"--- ERROR during user.save() in create_user: {e} ---")
+            raise # 重新抛出异常,以便Django能捕获
+            
         return user
 
     def create_superuser(self, email, phone_number, password=None, **extra_fields):
         """
         创建并保存一个具有给定邮箱、手机号和密码的超级用户。
         """
+        print(f"--- CustomUserManager: create_superuser called ---")
+        print(f"Received - Email: {email}, Phone: {phone_number}, Password provided: {'Yes' if password else 'No'}")
+        print(f"Received - Extra fields for superuser: {extra_fields}")
+
+        # create_superuser 应该确保 is_staff 和 is_superuser 为 True
         extra_fields.setdefault('is_staff', True)
         extra_fields.setdefault('is_superuser', True)
-        extra_fields.setdefault('is_active', True)
+        extra_fields.setdefault('is_active', True) # 超级用户默认激活
 
         if extra_fields.get('is_staff') is not True:
+            print("Error in create_superuser: is_staff must be True.")
             raise ValueError(_('超级用户必须将 is_staff 设置为 True.'))
         if extra_fields.get('is_superuser') is not True:
+            print("Error in create_superuser: is_superuser must be True.")
             raise ValueError(_('超级用户必须将 is_superuser 设置为 True.'))
         
-        if not email:
-            raise ValueError(_('超级用户必须有一个邮箱地址'))
-        if not phone_number:
-            raise ValueError(_('超级用户必须有一个手机号'))
-
-        email = self.normalize_email(email)
-        user = self.model(email=email, phone_number=phone_number, **extra_fields)
-        user.set_password(password)
-        user.save(using=self._db)
+        # 调用 create_user 来完成创建,这样可以复用验证和密码设置逻辑
+        # create_user 内部已经有了打印语句
+        # 注意:create_user 会将 is_staff 和 is_superuser 设为 False(如果它们不在extra_fields中)
+        # 所以我们需要在调用 create_user 后,再把它们强制设为 True。
+        # 或者,修改create_user,如果is_staff/is_superuser在extra_fields中提供了,就不覆盖。
+        # 当前的create_user是 extra_fields.setdefault,所以如果extra_fields已经有is_staff=True,它不会被覆盖。
+        
+        # 我们直接调用 create_user,它会处理大部分逻辑
+        # 然后确保 is_staff 和 is_superuser 属性是 True (虽然 setdefault 应该已经处理了)
+        print(f"Calling create_user from create_superuser with fields: email={email}, phone_number={phone_number}, extra_fields={extra_fields}")
+        user = self.create_user(email, phone_number, password, **extra_fields)
+        
+        # 再次确保超级用户权限 (理论上 setdefault 已经处理,但双重保险)
+        # if not user.is_staff or not user.is_superuser:
+        #     user.is_staff = True
+        #     user.is_superuser = True
+        #     try:
+        #         user.save(using=self._db)
+        #         print(f"--- Superuser (ID: {user.id}) permissions (is_staff, is_superuser) re-saved ---")
+        #     except Exception as e:
+        #         print(f"--- ERROR during superuser permission re-save: {e} ---")
+        #         raise
+        
         return user
 
-class InterestTag(models.Model): # 将 InterestTag 定义在 CustomUser 之前,或者在 ManyToManyField 中使用字符串引用
+class InterestTag(models.Model):
     name = models.CharField(
         _('标签名称'), 
         max_length=50, 
@@ -72,7 +119,7 @@ class CustomUser(AbstractBaseUser, PermissionsMixin):
     email = models.EmailField(_('邮箱地址'), unique=True)
     phone_number = models.CharField(_('手机号'), max_length=20, unique=True)
     nickname = models.CharField(_('昵称'), max_length=100, blank=True)
-    avatar = models.ImageField(_('头像'), upload_to='avatars/', null=True, blank=True) # 需要 Pillow 包
+    avatar = models.ImageField(_('头像'), upload_to='avatars/', null=True, blank=True)
     bio = models.TextField(_('简介'), blank=True)
     school = models.CharField(_('学校'), max_length=100, blank=True)
 
@@ -83,7 +130,7 @@ class CustomUser(AbstractBaseUser, PermissionsMixin):
     )
     is_active = models.BooleanField(
         _('激活状态'),
-        default=True,
+        default=True, # 默认用户是激活的
         help_text=_(
             '指明用户是否被认为是活跃的。'
             '取消选择此项而不是删除账户。'
@@ -91,24 +138,32 @@ class CustomUser(AbstractBaseUser, PermissionsMixin):
     )
     date_joined = models.DateTimeField(_('注册日期'), default=timezone.now)
 
-    # 用户的兴趣标签 (多对多关系)
     interests = models.ManyToManyField(
-        'InterestTag',       # 使用字符串形式引用 InterestTag 模型 (或者将InterestTag定义在CustomUser之前)
+        'InterestTag',
         verbose_name=_('兴趣标签'),
-        blank=True,        # 允许用户没有任何兴趣标签
-        related_name='users_with_interest' # 明确的 related_name
+        blank=True,
+        related_name='users_with_interest'
     )
 
     USERNAME_FIELD = 'email'
-    REQUIRED_FIELDS = ['phone_number', 'nickname'] # 创建超级用户时需要的额外字段
+    REQUIRED_FIELDS = ['phone_number', 'nickname']
 
-    objects = CustomUserManager() # 关联自定义的 Manager
+    objects = CustomUserManager()
+
+    @property
+    def display_name(self):
+        return self.nickname if self.nickname else self.email
 
     def __str__(self):
         return self.email
-    @property # 作为一个属性来访问
-    def display_name(self): # 或者你喜欢的名字,比如 nickname_or_email
-        return self.nickname if self.nickname else self.email
+    
     class Meta:
         verbose_name = _('用户')
-        verbose_name_plural = _('用户们')
+        verbose_name_plural = _('用户们')
+
+    # 如果有自定义的 save 方法,确保它调用了 super().save()
+    # def save(self, *args, **kwargs):
+    #     print("--- CustomUser save() called ---")
+    #     # some custom logic
+    #     super().save(*args, **kwargs) # 确保调用父类的save
+    #     print("--- CustomUser save() finished ---")

+ 90 - 90
tongqu_backend_v2/config/settings.py

@@ -9,10 +9,10 @@ https://docs.djangoproject.com/en/5.2/topics/settings/
 For the full list of settings and their values, see
 https://docs.djangoproject.com/en/5.2/ref/settings/
 """
+
 import os
-from pathlib import Path # Path 通常也在顶部
 from pathlib import Path
-from datetime import timedelta # 确保这行在 settings.py 文件顶部
+from datetime import timedelta # 确保导入timedelta
 
 # Build paths inside the project like this: BASE_DIR / 'subdir'.
 BASE_DIR = Path(__file__).resolve().parent.parent
@@ -39,20 +39,27 @@ INSTALLED_APPS = [
     'django.contrib.sessions',
     'django.contrib.messages',
     'django.contrib.staticfiles',
+
+    # Third-party apps
     'rest_framework',
     'rest_framework_simplejwt',
-    'accounts', 
-     'groups.apps.GroupsConfig', # <--- 确保是这个,并且没有拼写错误
+    'corsheaders',  # <<<< 1. 添加 corsheaders 到 INSTALLED_APPS
+
+    # Your apps
+    # 推荐使用完整的AppConfig路径
+    'accounts.apps.AccountsConfig', # 假设你的 accounts/apps.py 中有 AccountsConfig
+    'groups.apps.GroupsConfig',   # 假设你的 groups/apps.py 中有 GroupsConfig
 ]
 
 MIDDLEWARE = [
-    "django.middleware.security.SecurityMiddleware",
-    "django.contrib.sessions.middleware.SessionMiddleware",
-    "django.middleware.common.CommonMiddleware",
-    "django.middleware.csrf.CsrfViewMiddleware",
-    "django.contrib.auth.middleware.AuthenticationMiddleware",
-    "django.contrib.messages.middleware.MessageMiddleware",
-    "django.middleware.clickjacking.XFrameOptionsMiddleware",
+    'corsheaders.middleware.CorsMiddleware',  # <<<< 2. 添加 CorsMiddleware, 确保在 CommonMiddleware 之前
+    'django.middleware.security.SecurityMiddleware',
+    'django.contrib.sessions.middleware.SessionMiddleware',
+    'django.middleware.common.CommonMiddleware',
+    'django.middleware.csrf.CsrfViewMiddleware',
+    'django.contrib.auth.middleware.AuthenticationMiddleware',
+    'django.contrib.messages.middleware.MessageMiddleware',
+    'django.middleware.clickjacking.XFrameOptionsMiddleware',
 ]
 
 ROOT_URLCONF = "config.urls"
@@ -60,10 +67,11 @@ ROOT_URLCONF = "config.urls"
 TEMPLATES = [
     {
         "BACKEND": "django.template.backends.django.DjangoTemplates",
-        "DIRS": [],
+        "DIRS": [], # 如果你有项目级的模板文件夹,可以添加到这里
         "APP_DIRS": True,
         "OPTIONS": {
             "context_processors": [
+                "django.template.context_processors.debug", # 之前这里没有 debug,加上比较好
                 "django.template.context_processors.request",
                 "django.contrib.auth.context_processors.auth",
                 "django.contrib.messages.context_processors.messages",
@@ -81,12 +89,11 @@ WSGI_APPLICATION = "config.wsgi.application"
 DATABASES = {
     'default': {
         'ENGINE': 'django.db.backends.postgresql',
-        'NAME': 'tongqu_v2_db',         # 你创建的数据库名
-        'USER': 'postgres',             # 你的PostgreSQL用户名 (如果是默认超级用户)
-                                        # 或者 'tongqu_v2_user' (如果你创建了专用用户)
-        'PASSWORD': '20030316', # 你的PostgreSQL密码
-        'HOST': 'localhost',            # 或者 '127.0.0.1'
-        'PORT': '5432',                 # PostgreSQL默认端口
+        'NAME': 'tongqu_v2_db',
+        'USER': 'postgres',
+        'PASSWORD': '20030316', # 请确保这是你正确的PostgreSQL密码
+        'HOST': 'localhost',
+        'PORT': '5432',
     }
 }
 
@@ -94,18 +101,10 @@ DATABASES = {
 # https://docs.djangoproject.com/en/5.2/ref/settings/#auth-password-validators
 
 AUTH_PASSWORD_VALIDATORS = [
-    {
-        "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator",
-    },
-    {
-        "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator",
-    },
-    {
-        "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator",
-    },
-    {
-        "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator",
-    },
+    {"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator",},
+    {"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator",},
+    {"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator",},
+    {"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator",},
 ]
 
 
@@ -113,11 +112,8 @@ AUTH_PASSWORD_VALIDATORS = [
 # https://docs.djangoproject.com/en/5.2/topics/i18n/
 
 LANGUAGE_CODE = 'zh-hans'
-
 TIME_ZONE = 'Asia/Shanghai'
-
 USE_I18N = True
-
 USE_TZ = True
 
 
@@ -125,77 +121,81 @@ USE_TZ = True
 # https://docs.djangoproject.com/en/5.2/howto/static-files/
 
 STATIC_URL = 'static/'
-
-# Django 在开发时查找静态文件的目录列表
 STATICFILES_DIRS = [
-    os.path.join(BASE_DIR, 'static'), # 我们将在项目根目录创建这个 'static' 文件夹
+    os.path.join(BASE_DIR, 'static'), # 项目级static文件夹 (你需要在 BASE_DIR 下创建这个文件夹)
 ]
-
 # `collectstatic` 命令收集静态文件到这个目录,主要用于生产环境
 STATIC_ROOT = os.path.join(BASE_DIR, 'staticfiles_collected')
 
+
+# Media files (User uploaded content)
+MEDIA_URL = '/media/'
+MEDIA_ROOT = os.path.join(BASE_DIR, 'media') # (你需要在 BASE_DIR 下创建这个文件夹)
+
+
 # Default primary key field type
 # https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field
 
 DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
-STATIC_ROOT = os.path.join(BASE_DIR, 'staticfiles_collected') # 运行 collectstatic 时文件的存放位置
-STATICFILES_DIRS = [
-    os.path.join(BASE_DIR, 'static'), # 项目级static文件夹
-]
-MEDIA_URL = '/media/'
-MEDIA_ROOT = os.path.join(BASE_DIR, 'media')
+
+# Custom User Model
 AUTH_USER_MODEL = 'accounts.CustomUser'
-from datetime import timedelta # 确保在文件顶部导入
 
+
+# Django REST framework settings
 REST_FRAMEWORK = {
     'DEFAULT_AUTHENTICATION_CLASSES': (
-        'rest_framework_simplejwt.authentication.JWTAuthentication', # 使用JWT进行API认证
-        # 如果你希望在浏览器中测试API时也能使用Django的session认证(比如DRF的可浏览API),可以取消下面这行的注释
-        # 'rest_framework.authentication.SessionAuthentication',
+        'rest_framework_simplejwt.authentication.JWTAuthentication',
     ),
     'DEFAULT_PERMISSION_CLASSES': (
-        'rest_framework.permissions.IsAuthenticated', # 默认情况下,所有API端点都需要用户进行身份验证
-                                                      # 除非在视图中明确设置了 permission_classes = [permissions.AllowAny] (例如注册和登录接口)
-    ),
-    # 你可以根据需要添加其他全局DRF设置,例如:
-    # 'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination',
-    # 'PAGE_SIZE': 10, # 每页返回10条数据
-    # 'DEFAULT_RENDERER_CLASSES': ( # API的默认输出格式
-    #     'rest_framework.renderers.JSONRenderer',
-    #     # 'rest_framework.renderers.BrowsableAPIRenderer', # 如果你想在浏览器中看到可浏览的API界面 (通常DEBUG=True时自动启用)
-    # ),
-    # 'DEFAULT_PARSER_CLASSES': ( # API接受的输入格式
-    #     'rest_framework.parsers.JSONParser',
-    #     'rest_framework.parsers.FormParser',
-    #     'rest_framework.parsers.MultiPartParser', # 支持文件上传
-    # )
+        'rest_framework.permissions.IsAuthenticated',
+    )
+    # 其他DRF设置...
 }
+
+# Simple JWT settings
 SIMPLE_JWT = {
-    'ACCESS_TOKEN_LIFETIME': timedelta(minutes=60),     # Access Token 的有效期,例如 60 分钟
-    'REFRESH_TOKEN_LIFETIME': timedelta(days=1),        # Refresh Token 的有效期,例如 1 天
-    'ROTATE_REFRESH_TOKENS': False,                     # 如果为True, 每次刷新token时,旧的refresh token会失效,返回一个新的
-    'BLACKLIST_AFTER_ROTATION': True,                   # 和上面配合使用
-    'UPDATE_LAST_LOGIN': True,                          # 成功获取token后是否更新用户的 last_login 字段
-
-    'ALGORITHM': 'HS256',
-    'SIGNING_KEY': SECRET_KEY, # 使用Django的 SECRET_KEY 来签名JWT
-    'VERIFYING_KEY': None,
-    'AUDIENCE': None,
-    'ISSUER': None,
-    'JWK_URL': None,
-    'LEEWAY': 0,
-
-    'AUTH_HEADER_TYPES': ('Bearer',), # 期望在Authorization头中看到的类型,例如 "Bearer <token>"
-    'AUTH_HEADER_NAME': 'HTTP_AUTHORIZATION',
-    'USER_ID_FIELD': 'id',                  # 你的 CustomUser 模型中用作主键的字段名
-    'USER_ID_CLAIM': 'user_id',             # JWT payload 中代表用户ID的claim名称
-
-    'AUTH_TOKEN_CLASSES': ('rest_framework_simplejwt.tokens.AccessToken',),
-    'TOKEN_TYPE_CLAIM': 'token_type',
-    'TOKEN_USER_CLASS': 'rest_framework_simplejwt.models.TokenUser',
-
-    'JTI_CLAIM': 'jti',
-
-    # 如果你想自定义token payload中包含的内容,可以使用下面这个(需要写自定义的Token序列化器)
-    # 'TOKEN_OBTAIN_SERIALIZER': 'your_app.serializers.MyTokenObtainPairSerializer',
-}
+    'ACCESS_TOKEN_LIFETIME': timedelta(minutes=60),
+    'REFRESH_TOKEN_LIFETIME': timedelta(days=1),
+    'UPDATE_LAST_LOGIN': True,
+    'AUTH_HEADER_TYPES': ('Bearer',),
+    'USER_ID_FIELD': 'id',
+    'USER_ID_CLAIM': 'user_id',
+    'SIGNING_KEY': SECRET_KEY, # 使用Django的SECRET_KEY
+    # 其他SimpleJWT设置...
+}
+
+# CORS (Cross-Origin Resource Sharing) settings <<<< 3. 添加CORS配置块
+CORS_ALLOWED_ORIGINS = [
+    "http://localhost:5173",  # 你Vite前端开发服务器的地址
+    "http://127.0.0.1:5173", # 有时也需要加上这个
+    # "https://yourfrontenddomain.com", # 生产环境前端域名
+]
+
+# 或者,仅在绝对的开发环境中,如果你想允许所有来源 (非常不推荐用于生产)
+# CORS_ALLOW_ALL_ORIGINS = True # 如果使用这个,上面的 CORS_ALLOWED_ORIGINS 会被忽略
+
+# 如果前端需要发送凭据 (如cookies或HTTP认证头) 进行跨域请求
+# 并且你的axios配置了 withCredentials: true
+# CORS_ALLOW_CREDENTIALS = True
+
+CORS_ALLOW_METHODS = [
+    "DELETE",
+    "GET",
+    "OPTIONS",
+    "PATCH",
+    "POST",
+    "PUT",
+]
+
+CORS_ALLOW_HEADERS = [
+    "accept",
+    "accept-encoding",
+    "authorization", # 允许 Authorization 头
+    "content-type",
+    "dnt",
+    "origin",
+    "user-agent",
+    "x-csrftoken",
+    "x-requested-with",
+]

二进制
tongqu_backend_v2/media/group_covers/QQ图片20221017114231.jpg


+ 439 - 1
tongqu_frontend/package-lock.json

@@ -8,7 +8,10 @@
       "name": "tongqu_frontend",
       "version": "0.0.0",
       "dependencies": {
-        "vue": "^3.5.13"
+        "axios": "^1.9.0",
+        "pinia": "^3.0.3",
+        "vue": "^3.5.13",
+        "vue-router": "^4.5.1"
       },
       "devDependencies": {
         "@vitejs/plugin-vue": "^5.2.3",
@@ -843,6 +846,39 @@
         "@vue/shared": "3.5.16"
       }
     },
+    "node_modules/@vue/devtools-api": {
+      "version": "7.7.6",
+      "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-7.7.6.tgz",
+      "integrity": "sha512-b2Xx0KvXZObePpXPYHvBRRJLDQn5nhKjXh7vUhMEtWxz1AYNFOVIsh5+HLP8xDGL7sy+Q7hXeUxPHB/KgbtsPw==",
+      "license": "MIT",
+      "dependencies": {
+        "@vue/devtools-kit": "^7.7.6"
+      }
+    },
+    "node_modules/@vue/devtools-kit": {
+      "version": "7.7.6",
+      "resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-7.7.6.tgz",
+      "integrity": "sha512-geu7ds7tem2Y7Wz+WgbnbZ6T5eadOvozHZ23Atk/8tksHMFOFylKi1xgGlQlVn0wlkEf4hu+vd5ctj1G4kFtwA==",
+      "license": "MIT",
+      "dependencies": {
+        "@vue/devtools-shared": "^7.7.6",
+        "birpc": "^2.3.0",
+        "hookable": "^5.5.3",
+        "mitt": "^3.0.1",
+        "perfect-debounce": "^1.0.0",
+        "speakingurl": "^14.0.1",
+        "superjson": "^2.2.2"
+      }
+    },
+    "node_modules/@vue/devtools-shared": {
+      "version": "7.7.6",
+      "resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-7.7.6.tgz",
+      "integrity": "sha512-yFEgJZ/WblEsojQQceuyK6FzpFDx4kqrz2ohInxNj5/DnhoX023upTv4OD6lNPLAA5LLkbwPVb10o/7b+Y4FVA==",
+      "license": "MIT",
+      "dependencies": {
+        "rfdc": "^1.4.1"
+      }
+    },
     "node_modules/@vue/reactivity": {
       "version": "3.5.16",
       "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.16.tgz",
@@ -893,12 +929,101 @@
       "integrity": "sha512-c/0fWy3Jw6Z8L9FmTyYfkpM5zklnqqa9+a6dz3DvONRKW2NEbh46BP0FHuLFSWi2TnQEtp91Z6zOWNrU6QiyPg==",
       "license": "MIT"
     },
+    "node_modules/asynckit": {
+      "version": "0.4.0",
+      "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
+      "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
+      "license": "MIT"
+    },
+    "node_modules/axios": {
+      "version": "1.9.0",
+      "resolved": "https://registry.npmjs.org/axios/-/axios-1.9.0.tgz",
+      "integrity": "sha512-re4CqKTJaURpzbLHtIi6XpDv20/CnpXOtjRY5/CU32L8gU8ek9UIivcfvSWvmKEngmVbrUtPpdDwWDWL7DNHvg==",
+      "license": "MIT",
+      "dependencies": {
+        "follow-redirects": "^1.15.6",
+        "form-data": "^4.0.0",
+        "proxy-from-env": "^1.1.0"
+      }
+    },
+    "node_modules/birpc": {
+      "version": "2.3.0",
+      "resolved": "https://registry.npmjs.org/birpc/-/birpc-2.3.0.tgz",
+      "integrity": "sha512-ijbtkn/F3Pvzb6jHypHRyve2QApOCZDR25D/VnkY2G/lBNcXCTsnsCxgY4k4PkVB7zfwzYbY3O9Lcqe3xufS5g==",
+      "license": "MIT",
+      "funding": {
+        "url": "https://github.com/sponsors/antfu"
+      }
+    },
+    "node_modules/call-bind-apply-helpers": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
+      "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0",
+        "function-bind": "^1.1.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/combined-stream": {
+      "version": "1.0.8",
+      "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
+      "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
+      "license": "MIT",
+      "dependencies": {
+        "delayed-stream": "~1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/copy-anything": {
+      "version": "3.0.5",
+      "resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-3.0.5.tgz",
+      "integrity": "sha512-yCEafptTtb4bk7GLEQoM8KVJpxAfdBJYaXyzQEgQQQgYrZiDp8SJmGKlYza6CYjEDNstAdNdKA3UuoULlEbS6w==",
+      "license": "MIT",
+      "dependencies": {
+        "is-what": "^4.1.8"
+      },
+      "engines": {
+        "node": ">=12.13"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/mesqueeb"
+      }
+    },
     "node_modules/csstype": {
       "version": "3.1.3",
       "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
       "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
       "license": "MIT"
     },
+    "node_modules/delayed-stream": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
+      "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.4.0"
+      }
+    },
+    "node_modules/dunder-proto": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
+      "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bind-apply-helpers": "^1.0.1",
+        "es-errors": "^1.3.0",
+        "gopd": "^1.2.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
     "node_modules/entities": {
       "version": "4.5.0",
       "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
@@ -911,6 +1036,51 @@
         "url": "https://github.com/fb55/entities?sponsor=1"
       }
     },
+    "node_modules/es-define-property": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
+      "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/es-errors": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
+      "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/es-object-atoms": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
+      "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/es-set-tostringtag": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
+      "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0",
+        "get-intrinsic": "^1.2.6",
+        "has-tostringtag": "^1.0.2",
+        "hasown": "^2.0.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
     "node_modules/esbuild": {
       "version": "0.25.5",
       "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.5.tgz",
@@ -973,6 +1143,42 @@
         }
       }
     },
+    "node_modules/follow-redirects": {
+      "version": "1.15.9",
+      "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz",
+      "integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==",
+      "funding": [
+        {
+          "type": "individual",
+          "url": "https://github.com/sponsors/RubenVerborgh"
+        }
+      ],
+      "license": "MIT",
+      "engines": {
+        "node": ">=4.0"
+      },
+      "peerDependenciesMeta": {
+        "debug": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/form-data": {
+      "version": "4.0.3",
+      "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.3.tgz",
+      "integrity": "sha512-qsITQPfmvMOSAdeyZ+12I1c+CKSstAFAwu+97zrnWAbIr5u8wfsExUzCesVLC8NgHuRUqNN4Zy6UPWUTRGslcA==",
+      "license": "MIT",
+      "dependencies": {
+        "asynckit": "^0.4.0",
+        "combined-stream": "^1.0.8",
+        "es-set-tostringtag": "^2.1.0",
+        "hasown": "^2.0.2",
+        "mime-types": "^2.1.12"
+      },
+      "engines": {
+        "node": ">= 6"
+      }
+    },
     "node_modules/fsevents": {
       "version": "2.3.3",
       "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -988,6 +1194,121 @@
         "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
       }
     },
+    "node_modules/function-bind": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
+      "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
+      "license": "MIT",
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/get-intrinsic": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
+      "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bind-apply-helpers": "^1.0.2",
+        "es-define-property": "^1.0.1",
+        "es-errors": "^1.3.0",
+        "es-object-atoms": "^1.1.1",
+        "function-bind": "^1.1.2",
+        "get-proto": "^1.0.1",
+        "gopd": "^1.2.0",
+        "has-symbols": "^1.1.0",
+        "hasown": "^2.0.2",
+        "math-intrinsics": "^1.1.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/get-proto": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
+      "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
+      "license": "MIT",
+      "dependencies": {
+        "dunder-proto": "^1.0.1",
+        "es-object-atoms": "^1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/gopd": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
+      "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/has-symbols": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
+      "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/has-tostringtag": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
+      "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
+      "license": "MIT",
+      "dependencies": {
+        "has-symbols": "^1.0.3"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/hasown": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
+      "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
+      "license": "MIT",
+      "dependencies": {
+        "function-bind": "^1.1.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/hookable": {
+      "version": "5.5.3",
+      "resolved": "https://registry.npmjs.org/hookable/-/hookable-5.5.3.tgz",
+      "integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==",
+      "license": "MIT"
+    },
+    "node_modules/is-what": {
+      "version": "4.1.16",
+      "resolved": "https://registry.npmjs.org/is-what/-/is-what-4.1.16.tgz",
+      "integrity": "sha512-ZhMwEosbFJkA0YhFnNDgTM4ZxDRsS6HqTo7qsZM08fehyRYIYa0yHu5R6mgo1n/8MgaPBXiPimPD77baVFYg+A==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=12.13"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/mesqueeb"
+      }
+    },
     "node_modules/magic-string": {
       "version": "0.30.17",
       "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz",
@@ -997,6 +1318,42 @@
         "@jridgewell/sourcemap-codec": "^1.5.0"
       }
     },
+    "node_modules/math-intrinsics": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
+      "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/mime-db": {
+      "version": "1.52.0",
+      "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
+      "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/mime-types": {
+      "version": "2.1.35",
+      "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
+      "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
+      "license": "MIT",
+      "dependencies": {
+        "mime-db": "1.52.0"
+      },
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/mitt": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz",
+      "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==",
+      "license": "MIT"
+    },
     "node_modules/nanoid": {
       "version": "3.3.11",
       "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
@@ -1015,6 +1372,12 @@
         "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
       }
     },
+    "node_modules/perfect-debounce": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz",
+      "integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==",
+      "license": "MIT"
+    },
     "node_modules/picocolors": {
       "version": "1.1.1",
       "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -1034,6 +1397,27 @@
         "url": "https://github.com/sponsors/jonschlinkert"
       }
     },
+    "node_modules/pinia": {
+      "version": "3.0.3",
+      "resolved": "https://registry.npmjs.org/pinia/-/pinia-3.0.3.tgz",
+      "integrity": "sha512-ttXO/InUULUXkMHpTdp9Fj4hLpD/2AoJdmAbAeW2yu1iy1k+pkFekQXw5VpC0/5p51IOR/jDaDRfRWRnMMsGOA==",
+      "license": "MIT",
+      "dependencies": {
+        "@vue/devtools-api": "^7.7.2"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/posva"
+      },
+      "peerDependencies": {
+        "typescript": ">=4.4.4",
+        "vue": "^2.7.0 || ^3.5.11"
+      },
+      "peerDependenciesMeta": {
+        "typescript": {
+          "optional": true
+        }
+      }
+    },
     "node_modules/postcss": {
       "version": "8.5.4",
       "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.4.tgz",
@@ -1062,6 +1446,18 @@
         "node": "^10 || ^12 || >=14"
       }
     },
+    "node_modules/proxy-from-env": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
+      "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
+      "license": "MIT"
+    },
+    "node_modules/rfdc": {
+      "version": "1.4.1",
+      "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz",
+      "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==",
+      "license": "MIT"
+    },
     "node_modules/rollup": {
       "version": "4.41.1",
       "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.41.1.tgz",
@@ -1111,6 +1507,27 @@
         "node": ">=0.10.0"
       }
     },
+    "node_modules/speakingurl": {
+      "version": "14.0.1",
+      "resolved": "https://registry.npmjs.org/speakingurl/-/speakingurl-14.0.1.tgz",
+      "integrity": "sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==",
+      "license": "BSD-3-Clause",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/superjson": {
+      "version": "2.2.2",
+      "resolved": "https://registry.npmjs.org/superjson/-/superjson-2.2.2.tgz",
+      "integrity": "sha512-5JRxVqC8I8NuOUjzBbvVJAKNM8qoVuH0O77h4WInc/qC2q5IreqKxYwgkga3PfA22OayK2ikceb/B26dztPl+Q==",
+      "license": "MIT",
+      "dependencies": {
+        "copy-anything": "^3.0.2"
+      },
+      "engines": {
+        "node": ">=16"
+      }
+    },
     "node_modules/tinyglobby": {
       "version": "0.2.14",
       "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz",
@@ -1223,6 +1640,27 @@
           "optional": true
         }
       }
+    },
+    "node_modules/vue-router": {
+      "version": "4.5.1",
+      "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.5.1.tgz",
+      "integrity": "sha512-ogAF3P97NPm8fJsE4by9dwSYtDwXIY1nFY9T6DyQnGHd1E2Da94w9JIolpe42LJGIl0DwOHBi8TcRPlPGwbTtw==",
+      "license": "MIT",
+      "dependencies": {
+        "@vue/devtools-api": "^6.6.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/posva"
+      },
+      "peerDependencies": {
+        "vue": "^3.2.0"
+      }
+    },
+    "node_modules/vue-router/node_modules/@vue/devtools-api": {
+      "version": "6.6.4",
+      "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz",
+      "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==",
+      "license": "MIT"
     }
   }
 }

+ 4 - 1
tongqu_frontend/package.json

@@ -9,7 +9,10 @@
     "preview": "vite preview"
   },
   "dependencies": {
-    "vue": "^3.5.13"
+    "axios": "^1.9.0",
+    "pinia": "^3.0.3",
+    "vue": "^3.5.13",
+    "vue-router": "^4.5.1"
   },
   "devDependencies": {
     "@vitejs/plugin-vue": "^5.2.3",

+ 69 - 25
tongqu_frontend/src/App.vue

@@ -1,30 +1,74 @@
-<script setup>
-import HelloWorld from './components/HelloWorld.vue'
-</script>
-
 <template>
-  <div>
-    <a href="https://vite.dev" target="_blank">
-      <img src="/vite.svg" class="logo" alt="Vite logo" />
-    </a>
-    <a href="https://vuejs.org/" target="_blank">
-      <img src="./assets/vue.svg" class="logo vue" alt="Vue logo" />
-    </a>
+  <div id="app-container">
+    <header>
+      <nav>
+        <div class="nav-left">
+          <router-link to="/">首页</router-link>
+          <router-link :to="{ name: 'GroupList' }">兴趣小组</router-link>
+          <router-link :to="{ name: 'UserList' }">用户列表</router-link>
+          <router-link :to="{ name: 'Recommendations' }">为你推荐</router-link>
+        </div>
+        
+        <div class="nav-right">
+          <template v-if="authStore.isAuthenticated">
+            <span v-if="authStore.currentUser" class="welcome-message">
+              欢迎, {{ authStore.currentUser.nickname || authStore.currentUser.email }}
+            </span>
+            <router-link :to="{ name: 'UserProfile' }" class="nav-item">我的资料</router-link>
+            <button @click="handleLogout" class="nav-button">退出登录</button>
+          </template>
+          <template v-else>
+            <router-link to="/login" class="nav-item">登录</router-link>
+            <router-link :to="{ name: 'UserRegister' }" class="nav-item">注册</router-link>
+          </template>
+        </div>
+      </nav>
+    </header>
+    <main>
+      <router-view :key="$route.fullPath" />
+    </main>
+    <footer>
+      <p>© 2025 同趣U</p>
+    </footer>
   </div>
-  <HelloWorld msg="Vite + Vue" />
 </template>
 
+<script setup>
+// import { onMounted } from 'vue'; // onMounted不再需要立即调用tryAutoLogin
+import { useAuthStore } from './store/auth';
+import { useRouter } from 'vue-router';
+
+const authStore = useAuthStore();
+const router = useRouter();
+
+const handleLogout = () => {
+  authStore.logout();
+  // authStore.logout 内部会尝试跳转,这里可以不重复跳转,
+  // 但为确保,或者如果logout不包含跳转逻辑,则需要router.push
+  // router.push('/login').catch(()=>{}); 
+};
+
+// tryAutoLogin 的逻辑移到了路由守卫 (router.beforeEach) 中,
+// 或者可以在 Pinia store 初始化时或应用创建时更早地触发。
+// App.vue的onMounted中不再主动调用,以避免与路由守卫的逻辑冲突或重复。
+// onMounted(() => {
+//   authStore.tryAutoLogin(); 
+// });
+</script>
+
 <style scoped>
-.logo {
-  height: 6em;
-  padding: 1.5em;
-  will-change: filter;
-  transition: filter 300ms;
-}
-.logo:hover {
-  filter: drop-shadow(0 0 2em #646cffaa);
-}
-.logo.vue:hover {
-  filter: drop-shadow(0 0 2em #42b883aa);
-}
-</style>
+/* 样式与之前 App.vue 的版本保持一致 */
+#app-container { font-family: Avenir, Helvetica, Arial, sans-serif; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; text-align: center; color: #2c3e50; display: flex; flex-direction: column; min-height: 100vh; }
+header { width: 100%; background-color: #fff; padding: 0; box-shadow: 0 2px 4px rgba(0,0,0,0.08); position: sticky; top: 0; z-index: 1000; border-bottom: 1px solid #e9ecef; }
+nav { max-width: 1200px; margin: 0 auto; padding: 12px 20px; display: flex; justify-content: space-between; align-items: center; height: 60px; box-sizing: border-box; }
+.nav-left, .nav-right { display: flex; align-items: center; gap: 10px; }
+nav a, nav .nav-button { font-weight: 500; color: #495057; text-decoration: none; padding: 8px 12px; border-radius: 5px; transition: color 0.2s, background-color 0.2s; margin: 0; }
+nav .nav-button { background: none; border: none; cursor: pointer; font-family: inherit; font-size: inherit; }
+nav a:hover, nav .nav-button:hover { color: #007bff; background-color: #f0f2f5; }
+nav a.router-link-exact-active { color: #007bff; background-color: #e7f3ff; }
+.welcome-message { font-size: 0.9em; color: #6c757d; padding-right: 10px; }
+main { flex-grow: 1; padding: 25px 20px; width: 100%; max-width: 1200px; margin: 0 auto; box-sizing: border-box; }
+footer { width: 100%; padding: 25px 0; font-size: 0.9em; color: #6c757d; background-color: #f8f9fa; border-top: 1px solid #e9ecef; }
+nav > .nav-left > *:not(:last-child)::after,
+nav > .nav-right > *:not(:last-child)::after { /* content: "|"; margin-left: 10px; color: #ced4da; */ }
+</style>

+ 10 - 2
tongqu_frontend/src/main.js

@@ -1,5 +1,13 @@
 import { createApp } from 'vue'
-import './style.css'
+import { createPinia } from 'pinia'
 import App from './App.vue'
+import router from './router'
+import './style.css' // 确保这个文件存在或注释掉
 
-createApp(App).mount('#app')
+const app = createApp(App)
+const pinia = createPinia()
+
+app.use(router)
+app.use(pinia)
+
+app.mount('#app')

+ 80 - 0
tongqu_frontend/src/router/index.js

@@ -0,0 +1,80 @@
+import { createRouter, createWebHistory } from 'vue-router';
+import { useAuthStore } from '../store/auth';
+
+// 视图组件导入 (确保所有引用的视图文件都存在于 src/views/ 目录下)
+import HomeView from '../views/HomeView.vue';
+import LoginView from '../views/LoginView.vue';
+import RegisterView from '../views/RegisterView.vue';
+import UserProfileView from '../views/UserProfileView.vue';
+import GroupListView from '../views/GroupListView.vue';
+import CreateGroupView from '../views/CreateGroupView.vue';
+import GroupDetailView from '../views/GroupDetailView.vue';
+import PostDetailView from '../views/PostDetailView.vue';
+import UserListView from '../views/UserListView.vue'; // 如果你有这个独立的UserList页面
+import RecommendationsView from '../views/RecommendationsView.vue';
+
+// 占位组件
+const PlaceholderView = { 
+  template: '<div><h2 style="text-align:center; margin-top: 50px;">页面开发中...</h2><p style="text-align:center;"><router-link to="/">返回首页</router-link></p></div>' 
+};
+
+
+const routes = [
+    { path: '/', name: 'Home', component: HomeView },
+    { 
+      path: '/login', name: 'Login', component: LoginView,
+      beforeEnter: (to, from, next) => {
+          const authStore = useAuthStore();
+          if (authStore.isAuthenticated) next({ name: 'Home' });
+          else next();
+      } 
+    },
+    { 
+      path: '/register', name: 'UserRegister', component: RegisterView,
+      beforeEnter: (to, from, next) => {
+          const authStore = useAuthStore();
+          if (authStore.isAuthenticated) next({ name: 'Home' });
+          else next();
+      }
+    },
+    { path: '/profile', name: 'UserProfile', component: UserProfileView, meta: { requiresAuth: true } },
+    { path: '/users/:id(\\d+)', name: 'UserProfileById', component: UserProfileView, props: true, meta: { requiresAuth: true } },
+    { path: '/users', name: 'UserList', component: UserListView, meta: { requiresAuth: true } }, // 或 RecommendationsView
+    { path: '/recommendations', name: 'Recommendations', component: RecommendationsView, meta: { requiresAuth: true } },
+    { path: '/groups', name: 'GroupList', component: GroupListView, meta: { requiresAuth: true } },
+    { path: '/groups/create', name: 'CreateGroup', component: CreateGroupView, meta: { requiresAuth: true } },
+    { path: '/groups/:id(\\d+)', name: 'GroupDetail', component: GroupDetailView, props: true, meta: { requiresAuth: true } },
+    { path: '/posts/:id(\\d+)', name: 'PostDetail', component: PostDetailView, meta: { requiresAuth: true }, props: true },
+];
+
+const router = createRouter({
+    history: createWebHistory(import.meta.env.BASE_URL),
+    routes,
+    scrollBehavior: () => ({ top: 0 })
+});
+
+router.beforeEach(async (to, from, next) => {
+    console.log(`Router: Navigating from ${from.name || 'start'} to ${to.name || to.path}`);
+    const authStore = useAuthStore();
+
+    // 确保Pinia store在路由守卫中可用
+    // 如果accessToken存在但用户信息不存在,尝试自动登录(获取用户信息)
+    // 这个操作应该在判断 requiresAuth 之前,以确保 isAuthenticated 状态准确
+    if (authStore.accessToken && !authStore.currentUser && !authStore.isLoadingProfile) {
+        console.log("Router Guard: Token exists, user missing. Attempting auto-login via fetchUserProfile.");
+        await authStore.fetchUserProfile(); // 等待用户信息获取完毕
+    }
+
+    if (to.meta.requiresAuth && !authStore.isAuthenticated) {
+        console.log(`Router Guard: Route ${String(to.name) || to.path} requires auth, user not authenticated. Redirecting to Login.`);
+        next({ 
+            name: 'Login', 
+            query: { redirect: to.fullPath }
+        });
+    } else {
+        console.log(`Router Guard: Navigation to ${String(to.name) || to.path} allowed.`);
+        next();
+    }
+});
+
+export default router;

+ 73 - 0
tongqu_frontend/src/services/api.js

@@ -0,0 +1,73 @@
+import axios from 'axios';
+import { useAuthStore } from '../store/auth'; // 导入Auth Store
+import router from '../router'; // 导入router实例,用于可能的编程式导航
+
+const apiClient = axios.create({
+  baseURL: 'http://127.0.0.1:8000/api/v1/', // 你的后端API基础URL
+  headers: {
+    'Content-Type': 'application/json',
+  }
+});
+
+// 请求拦截器: 在每个请求发送前执行
+apiClient.interceptors.request.use(
+  (config) => {
+    const authStore = useAuthStore(); // 在拦截器内部获取store实例
+    const token = authStore.accessToken;
+    if (token) {
+      config.headers.Authorization = `Bearer ${token}`;
+    }
+    return config;
+  },
+  (error) => {
+    return Promise.reject(error);
+  }
+);
+
+// 响应拦截器: 在收到响应后执行
+apiClient.interceptors.response.use(
+  (response) => {
+    // 对响应数据做点什么
+    return response;
+  },
+  async (error) => {
+    const originalRequest = error.config;
+    const authStore = useAuthStore();
+
+    // 如果是401错误 (Unauthorized),并且不是因为正在刷新token而产生的错误,并且有refresh token
+    if (error.response && error.response.status === 401 && !originalRequest._retry && authStore.refreshToken) {
+      originalRequest._retry = true; // 标记为已重试,避免无限循环
+
+      try {
+        console.log('Access token expired or invalid, attempting to refresh...');
+        // 注意: 刷新token的请求不应该使用会被拦截的 apiClient 实例,以避免循环拦截
+        // 应该使用一个原始的 axios 实例或专门配置的不带拦截器的实例
+        const rs = await axios.post('http://127.0.0.1:8000/api/v1/accounts/token/refresh/', {
+          refresh: authStore.refreshToken,
+        });
+
+        const newAccessToken = rs.data.access;
+        authStore.setAccessToken(newAccessToken); // 假设store有setAccessToken action
+
+        // 更新原始请求的 Authorization header
+        originalRequest.headers['Authorization'] = `Bearer ${newAccessToken}`;
+        
+        console.log('Token refreshed successfully, retrying original request.');
+        return apiClient(originalRequest); // 用新的token重试原始请求
+      } catch (refreshError) {
+        console.error('Unable to refresh token:', refreshError.response ? refreshError.response.data : refreshError.message);
+        authStore.logout(); // 刷新失败,登出用户
+        router.push('/login').catch(() => {}); // 跳转到登录页,添加catch避免重复导航错误
+        return Promise.reject(refreshError);
+      }
+    } else if (error.response && error.response.status === 401 && authStore.isAuthenticated) {
+        // 如果是401但没有refresh token,或者刷新也失败了,直接登出
+        console.log('401 Unauthorized, logging out user.');
+        authStore.logout();
+        router.push('/login').catch(() => {});
+    }
+    return Promise.reject(error);
+  }
+);
+
+export default apiClient;

+ 125 - 0
tongqu_frontend/src/store/auth.js

@@ -0,0 +1,125 @@
+import { defineStore } from 'pinia';
+import apiClient from '../services/api';
+import router from '../router';
+
+export const useAuthStore = defineStore('auth', {
+  state: () => ({
+    accessToken: localStorage.getItem('accessToken') || null,
+    refreshToken: localStorage.getItem('refreshToken') || null,
+    user: localStorage.getItem('user') ? JSON.parse(localStorage.getItem('user')) : null,
+    loginError: null,
+    isLoadingProfile: false, // 新增:控制profile加载状态
+  }),
+
+  getters: {
+    isAuthenticated: (state) => !!state.accessToken,
+    currentUser: (state) => state.user,
+    getLoginError: (state) => state.loginError,
+  },
+
+  actions: {
+    setAccessToken(token) {
+        this.accessToken = token;
+        localStorage.setItem('accessToken', token);
+        console.log("AuthStore: Access token set.");
+    },
+    setUser(userData) {
+        this.user = userData;
+        localStorage.setItem('user', JSON.stringify(userData));
+        console.log("AuthStore: User data set:", userData);
+    },
+    async login(credentials) {
+      console.log("AuthStore: Attempting login...", credentials.email);
+      this.loginError = null;
+      try {
+        const response = await apiClient.post('accounts/login/', {
+          email: credentials.email,
+          password: credentials.password,
+        });
+        console.log("AuthStore: Login API call successful.", response.data);
+
+        this.setAccessToken(response.data.access);
+        this.refreshToken = response.data.refresh;
+        localStorage.setItem('refreshToken', this.refreshToken);
+        
+        await this.fetchUserProfile(); // 登录成功后获取用户资料
+        console.log("AuthStore: Login and profile fetch complete.");
+        return true;
+      } catch (error) {
+        const errorMessage = error.response?.data?.detail || 
+                             (typeof error.response?.data === 'object' ? Object.values(error.response.data).flat().join(' ') : null) ||
+                             error.message || // 从error对象本身取message
+                             '登录失败,请检查您的凭据或网络连接。';
+        this.loginError = errorMessage;
+        console.error('AuthStore: Login action error:', errorMessage, error.response?.data);
+        this.clearAuthData(); // 登录失败时清除认证数据
+        throw new Error(errorMessage);
+      }
+    },
+
+    async fetchUserProfile() {
+      if (!this.accessToken) {
+        console.log("AuthStore: No access token, cannot fetch user profile.");
+        this.user = null; // 确保用户数据也清空
+        localStorage.removeItem('user');
+        return null;
+      }
+      if (this.isLoadingProfile) {
+        console.log("AuthStore: Profile fetch already in progress.");
+        return this.user; // 如果正在加载,返回当前user (可能是null)
+      }
+      console.log("AuthStore: Fetching user profile...");
+      this.isLoadingProfile = true;
+      try {
+        const response = await apiClient.get('accounts/profile/');
+        this.setUser(response.data);
+        this.isLoadingProfile = false;
+        return this.user;
+      } catch (error) {
+        console.error('AuthStore: Failed to fetch user profile:', error.response?.data || error.message);
+        this.isLoadingProfile = false;
+        // 响应拦截器会处理401并尝试登出
+        // 如果不是401,这里也清除本地用户数据以反映状态
+        if (!(error.response && error.response.status === 401)) {
+            this.user = null;
+            localStorage.removeItem('user');
+        }
+        // 不再在这里主动调用logout,让响应拦截器或组件决定
+        return null;
+      }
+    },
+    clearAuthData() {
+        this.accessToken = null;
+        this.refreshToken = null;
+        this.user = null;
+        localStorage.removeItem('accessToken');
+        localStorage.removeItem('refreshToken');
+        localStorage.removeItem('user');
+        this.loginError = null;
+        console.log("AuthStore: Auth data cleared.");
+    },
+    logout() {
+      console.log("AuthStore: Logging out.");
+      this.clearAuthData();
+      // 登出后,如果当前路由需要认证,跳转到登录页
+      // 这个检查也可以放在App.vue的watch中,或者router.beforeEach中(但要避免循环)
+      if (router.currentRoute.value.meta.requiresAuth && router.currentRoute.value.name !== 'Login') {
+        router.push({ name: 'Login' }).catch(()=>{});
+      }
+    },
+
+    async tryAutoLogin() {
+      console.log("AuthStore: tryAutoLogin called.");
+      if (this.accessToken && !this.user) { // 有token但无用户信息
+        console.log("AuthStore: Token found, user data missing. Attempting to fetch profile.");
+        return await this.fetchUserProfile(); // 返回promise,让调用者知道操作是否完成
+      } else if (this.accessToken && this.user) {
+        console.log("AuthStore: User already in session from localStorage.");
+        return this.user; // 已有用户信息
+      } else {
+        console.log("AuthStore: No active session for auto-login.");
+        return null; // 无会话
+      }
+    }
+  },
+});

+ 295 - 0
tongqu_frontend/src/views/CreateGroupView.vue

@@ -0,0 +1,295 @@
+<template>
+  <div class="create-group-container">
+    <h2>创建新的兴趣小组</h2>
+    <form @submit.prevent="handleCreateGroup" class="create-group-form">
+      <div class="form-group">
+        <label for="group-name">小组名称:</label>
+        <input type="text" id="group-name" v-model="groupData.name" required maxlength="100" />
+        <small v-if="errors.name" class="error-text">{{ errors.name.join(', ') }}</small>
+      </div>
+
+      <div class="form-group">
+        <label for="group-description">小组描述 (可选):</label>
+        <textarea id="group-description" v-model="groupData.description"></textarea>
+        <small v-if="errors.description" class="error-text">{{ errors.description.join(', ') }}</small>
+      </div>
+
+      <div class="form-group">
+        <label for="group-cover">小组封面 (可选):</label>
+        <input type="file" id="group-cover" @change="handleCoverImageChange" accept="image/*" />
+        <div v-if="coverPreviewUrl" class="cover-preview-container">
+          <p>封面预览:</p>
+          <img :src="coverPreviewUrl" alt="Cover Preview" class="cover-preview-small">
+        </div>
+        <small v-if="errors.cover_image" class="error-text">{{ errors.cover_image.join(', ') }}</small>
+      </div>
+
+      <div class="form-group">
+        <label>选择兴趣标签 (可选):</label>
+        <div v-if="availableTags.length > 0" class="tags-selection">
+          <div v-for="tag in availableTags" :key="tag.id" class="tag-item">
+            <input type="checkbox" :id="'tag-create-' + tag.id" :value="tag.id" v-model="groupData.tags" />
+            <label :for="'tag-create-' + tag.id">{{ tag.name }}</label>
+          </div>
+        </div>
+        <p v-else-if="isLoadingTags" class="loading-message-small">正在加载可选标签...</p>
+        <p v-else>暂无可选标签。</p>
+        <small v-if="errors.tags" class="error-text">{{ errors.tags.join(', ') }}</small>
+      </div>
+      
+      <!-- (可选) 如果有小组类型选择
+      <div class="form-group">
+        <label for="group-type">小组类型:</label>
+        <select id="group-type" v-model="groupData.group_type">
+          <option value="public">公开小组</option>
+          <option value="private">私密小组</option>
+        </select>
+      </div>
+      -->
+
+      <div class="form-actions">
+        <button type="submit" :disabled="isSubmitting">
+          {{ isSubmitting ? '创建中...' : '创建小组' }}
+        </button>
+        <router-link :to="{ name: 'GroupList' }" class="cancel-button">取消</router-link>
+      </div>
+      <p v-if="submitError" class="error-message">{{ submitError }}</p>
+      <p v-if="successMessage" class="success-message">{{ successMessage }}</p>
+    </form>
+  </div>
+</template>
+
+<script setup>
+import { reactive, ref, onMounted } from 'vue';
+import apiClient from '../services/api';
+import { useRouter } from 'vue-router';
+import { useAuthStore } from '../store/auth';
+
+const groupData = reactive({
+  name: '',
+  description: '',
+  cover_image: null, // File object
+  tags: [],          // Array of selected tag IDs
+  // group_type: 'public', // 如果有小组类型
+});
+const coverPreviewUrl = ref(null);
+
+const availableTags = ref([]);
+const isLoadingTags = ref(false);
+
+const isSubmitting = ref(false);
+const submitError = ref(''); // 用于提交时的整体错误
+const errors = ref({}); // 用于存储字段特定的错误
+const successMessage = ref('');
+
+const router = useRouter();
+const authStore = useAuthStore(); // 用于确保用户已登录 (通过路由守卫)
+
+const fetchAvailableTags = async () => {
+  isLoadingTags.value = true;
+  try {
+    const response = await apiClient.get('accounts/tags/'); // API端点获取标签列表
+    availableTags.value = response.data.results || response.data;
+  } catch (error) {
+    console.error('获取兴趣标签失败:', error);
+    submitError.value = '无法加载可选兴趣标签,但您仍可创建小组。';
+  } finally {
+    isLoadingTags.value = false;
+  }
+};
+
+const handleCoverImageChange = (event) => {
+  const file = event.target.files[0];
+  if (file) {
+    groupData.cover_image = file;
+    const reader = new FileReader();
+    reader.onload = (e) => {
+      coverPreviewUrl.value = e.target.result;
+    };
+    reader.readAsDataURL(file);
+  } else {
+    groupData.cover_image = null;
+    coverPreviewUrl.value = null;
+  }
+};
+
+const handleCreateGroup = async () => {
+  isSubmitting.value = true;
+  submitError.value = '';
+  errors.value = {};
+  successMessage.value = '';
+
+  const submissionData = new FormData();
+  submissionData.append('name', groupData.name);
+  if (groupData.description) submissionData.append('description', groupData.description);
+  if (groupData.cover_image) submissionData.append('cover_image', groupData.cover_image);
+  
+  if (groupData.tags && groupData.tags.length > 0) {
+    groupData.tags.forEach(tagId => {
+      submissionData.append('tags', String(tagId));
+    });
+  }
+  // if (groupData.group_type) submissionData.append('group_type', groupData.group_type);
+
+  try {
+    // 后端 GroupViewSet 的 perform_create 会自动将 request.user 设为 creator
+    const response = await apiClient.post('groups/', submissionData); // POST 到 /api/v1/groups/
+    
+    console.log('小组创建成功:', response.data);
+    successMessage.value = `小组 "${response.data.name}" 创建成功!即将跳转到小组列表...`;
+    
+    // 清空表单
+    Object.keys(groupData).forEach(key => {
+        if (key === 'tags') groupData[key] = [];
+        else if (key === 'cover_image') groupData[key] = null;
+        // else if (key === 'group_type') groupData[key] = 'public';
+        else groupData[key] = '';
+    });
+    coverPreviewUrl.value = null;
+
+    setTimeout(() => {
+      router.push({ name: 'GroupList' }); // 或者跳转到新创建的小组详情页: router.push({ name: 'GroupDetail', params: { id: response.data.id } });
+    }, 2000);
+
+  } catch (error) {
+    console.error('创建小组失败:', error.response ? error.response.data : error.message);
+    if (error.response && error.response.data) {
+      if (typeof error.response.data === 'object' && !error.response.data.detail) {
+        // 将字段错误放到 errors ref 中
+        errors.value = error.response.data;
+        submitError.value = '请更正表单中的错误。';
+      } else {
+        submitError.value = error.response.data.detail || '创建小组失败,请稍后重试。';
+      }
+    } else {
+      submitError.value = '创建小组时发生网络错误或服务器无响应。';
+    }
+  } finally {
+    isSubmitting.value = false;
+  }
+};
+
+onMounted(() => {
+  fetchAvailableTags(); // 获取可选标签
+});
+</script>
+
+<style scoped>
+.create-group-container {
+  max-width: 700px;
+  margin: 30px auto;
+  padding: 30px;
+  background-color: #fff;
+  border-radius: 8px;
+  box-shadow: 0 4px 12px rgba(0,0,0,0.1);
+}
+.create-group-container h2 {
+    text-align: center;
+    margin-bottom: 25px;
+    color: #333;
+}
+.create-group-form .form-group {
+  margin-bottom: 20px;
+}
+.create-group-form label {
+  display: block;
+  margin-bottom: 6px;
+  font-weight: 600; /* slightly bolder */
+  color: #454545;
+}
+.create-group-form input[type="text"],
+.create-group-form textarea,
+.create-group-form select {
+  width: 100%;
+  padding: 10px;
+  border: 1px solid #ccc;
+  border-radius: 4px;
+  box-sizing: border-box;
+  font-size: 1em;
+}
+.create-group-form input[type="file"] {
+    padding: 7px;
+}
+.create-group-form textarea {
+  min-height: 100px;
+  resize: vertical;
+}
+.cover-preview-container {
+  margin-top: 10px;
+}
+.cover-preview-small {
+  max-width: 200px; /* 预览图大小 */
+  max-height: 150px;
+  border-radius: 4px;
+  object-fit: contain;
+  border: 1px solid #ddd;
+}
+.tags-selection {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 10px 15px;
+  padding: 10px;
+  border: 1px solid #eee;
+  border-radius: 4px;
+  margin-top: 5px;
+}
+.tag-item {
+  display: flex;
+  align-items: center;
+}
+.tag-item input[type="checkbox"] {
+  margin-right: 6px;
+  transform: scale(1.1);
+  cursor: pointer;
+}
+.tag-item label {
+  font-weight: normal;
+  cursor: pointer;
+  margin-bottom: 0; /* 修正label的margin */
+}
+.form-actions {
+  margin-top: 30px;
+  display: flex;
+  gap: 15px;
+  justify-content: flex-start;
+}
+.form-actions button, .form-actions .cancel-button {
+  padding: 10px 25px;
+  border: none;
+  border-radius: 5px;
+  cursor: pointer;
+  font-size: 1em;
+  font-weight: 500;
+}
+.form-actions button[type="submit"] {
+  background-color: #28a745; /* 绿色 */
+  color: white;
+}
+.form-actions button[type="submit"]:disabled {
+  background-color: #aaa;
+}
+.form-actions .cancel-button {
+  background-color: #f0f0f0;
+  color: #555;
+  border: 1px solid #ccc;
+  text-decoration: none; /* 如果是router-link */
+  line-height: normal; /* 对齐按钮 */
+  display: inline-flex; /* 对齐按钮 */
+  align-items: center; /* 对齐按钮 */
+}
+.error-message, .success-message {
+  padding: 10px;
+  margin-top: 15px;
+  border-radius: 4px;
+  font-size: 0.9em;
+}
+.error-message { background-color: #f8d7da; color: #721c24; border: 1px solid #f5c6cb;}
+.error-text { /* 字段下方的小错误提示 */
+    font-size: 0.8em;
+    color: #dc3545;
+    display: block;
+    margin-top: 3px;
+}
+.success-message { background-color: #d4edda; color: #155724; border: 1px solid #c3e6cb;}
+.loading-message-small { font-size: 0.9em; color: #777; }
+</style>

+ 502 - 0
tongqu_frontend/src/views/GroupDetailView.vue

@@ -0,0 +1,502 @@
+<template>
+  <div class="group-detail-container">
+    <div v-if="isLoadingGroup" class="loading-message">正在加载小组信息...</div>
+    <div v-if="fetchGroupError" class="error-message">{{ fetchGroupError }}</div>
+<div v-if="group" class="group-header">
+  <img v-if="group.cover_image_url" :src="group.cover_image_url" :alt="group.name + '封面'" class="group-detail-cover">
+  <div v-else class="group-detail-cover-placeholder">{{ group.name ? group.name.charAt(0).toUpperCase() : 'G' }}</div>
+  <div class="group-info">
+    <h1>{{ group.name }}</h1>
+    <p class="group-description-detail">{{ group.description }}</p>
+    <div class="group-meta-detail">
+      <span>创建者: {{ group.creator ? group.creator.nickname : '未知' }}</span>
+      <span>成员: {{ group.members_count }}</span>
+      <span v-if="group.tags_details && group.tags_details.length > 0">
+        标签: {{ group.tags_details.map(t => t.name).join(', ') }}
+      </span>
+    </div>
+    <div class="group-actions" v-if="authStore.isAuthenticated">
+      <button 
+        @click="toggleJoinLeaveGroup" 
+        :disabled="isJoiningOrLeaving"
+        :class="{'button-leave': isMember, 'button-join': !isMember}"
+      >
+        {{ isJoiningOrLeaving ? '处理中...' : (isMember ? '退出小组' : '加入小组') }}
+      </button>
+      <!-- 未来可以添加编辑小组按钮 (如果当前用户是创建者/管理员) -->
+    </div>
+    <p v-if="joinLeaveError" class="error-message small-error">{{ joinLeaveError }}</p>
+    <p v-if="joinLeaveSuccess" class="success-message small-success">{{ joinLeaveSuccess }}</p>
+  </div>
+</div>
+
+<hr class="divider" v-if="group">
+
+<div v-if="group" class="posts-section">
+  <h3>小组内帖子/动态</h3>
+  
+  <!-- 创建新帖子的表单/入口 (仅对小组成员显示) -->
+  <div v-if="isMember && authStore.isAuthenticated" class="create-post-section">
+    <h4>发布新动态</h4>
+    <form @submit.prevent="handleCreatePost" class="create-post-form">
+      <div class="form-group">
+        <label for="post-title">标题 (可选):</label>
+        <input type="text" id="post-title" v-model="newPost.title" maxlength="200">
+      </div>
+      <div class="form-group">
+        <label for="post-content">内容:</label>
+        <textarea id="post-content" v-model="newPost.content" required></textarea>
+      </div>
+      <!-- (可选) 帖子图片上传 -->
+      <!-- <div class="form-group">
+        <label for="post-image">图片 (可选):</label>
+        <input type="file" id="post-image" @change="handlePostImageChange" accept="image/*">
+      </div> -->
+      <button type="submit" :disabled="isSubmittingPost">
+        {{ isSubmittingPost ? '发布中...' : '发布动态' }}
+      </button>
+      <p v-if="createPostError" class="error-message small-error">{{ createPostError }}</p>
+    </form>
+  </div>
+  <p v-else-if="!isMember && authStore.isAuthenticated && group" class="info-message">
+    加入小组后才能发布动态。
+  </p>
+
+
+  <div v-if="isLoadingPosts" class="loading-message">正在加载帖子...</div>
+  <div v-if="fetchPostsError" class="error-message">{{ fetchPostsError }}</div>
+  
+  <div v-if="posts.length > 0" class="post-list">
+    <div v-for="post in posts" :key="post.id" class="post-item">
+      <router-link :to="{ name: 'PostDetail', params: { id: post.id } }" class="post-link">
+        <div class="post-header">
+          <img v-if="post.author && post.author.avatar_url" :src="post.author.avatar_url" alt="author avatar" class="post-author-avatar">
+          <div v-else class="post-author-avatar-placeholder">{{ post.author ? post.author.nickname.charAt(0) : 'U' }}</div>
+          <div class="post-author-info">
+            <span class="post-author-name">{{ post.author ? post.author.nickname : '匿名用户' }}</span>
+            <span class="post-created-at">{{ formatDateTime(post.created_at) }}</span>
+          </div>
+        </div>
+        <h4 class="post-title">{{ post.title || '动态分享' }}</h4>
+        <p class="post-content-preview">{{ truncateText(post.content, 150) }}</p>
+        <div class="post-meta">
+          <span>赞: {{ post.likes_count }}</span>
+          <span>评论: {{ post.comments_count || 0 }}</span> <!-- 假设PostSerializer未来会加comments_count -->
+        </div>
+      </router-link>
+    </div>
+  </div>
+  <p v-else-if="!isLoadingPosts && !fetchPostsError" class="info-message">
+    这个小组还没有任何动态,快来发布第一条吧!
+  </p>
+  <!-- (可选) 帖子列表的分页 -->
+</div>
+
+<div v-if="!group && !isLoadingGroup && !fetchGroupError" class="not-found-message">
+    <p>无法找到该小组,或者小组不存在。</p>
+    <router-link to="/groups">返回小组列表</router-link>
+</div>
+Use code with caution.
+  </div>
+</template>
+<script setup>
+import { ref, reactive, onMounted, computed, watch } from 'vue';
+import apiClient from '../services/api';
+import { useAuthStore } from '../store/auth';
+import { useRoute, useRouter } from 'vue-router'; // useRoute 获取当前路由信息
+
+const group = ref(null);
+const isLoadingGroup = ref(true);
+const fetchGroupError = ref('');
+
+const posts = ref([]);
+const isLoadingPosts = ref(false);
+const fetchPostsError = ref('');
+
+const authStore = useAuthStore();
+const route = useRoute(); // 获取当前路由对象
+const router = useRouter(); // 用于编程式导航
+
+const groupId = computed(() => route.params.id); // 从路由参数获取小组ID
+
+const isMember = ref(false); // 当前用户是否是小组成员
+const isJoiningOrLeaving = ref(false);
+const joinLeaveError = ref('');
+const joinLeaveSuccess = ref('');
+
+const newPost = reactive({
+  title: '',
+  content: '',
+  // image: null,
+});
+const isSubmittingPost = ref(false);
+const createPostError = ref('');
+
+// 获取小组详情
+const fetchGroupDetail = async () => {
+  isLoadingGroup.value = true;
+  fetchGroupError.value = '';
+  try {
+    const response = await apiClient.get(`groups/${groupId.value}/`);
+    group.value = response.data;
+    checkMembership(); // 获取小组详情后检查成员状态
+  } catch (error) {
+    console.error('获取小组详情失败:', error);
+    fetchGroupError.value = '无法加载小组信息。';
+     if (error.response && error.response.status === 404) {
+        fetchGroupError.value = '该小组不存在或已被删除。';
+    }
+  } finally {
+    isLoadingGroup.value = false;
+  }
+};
+
+// 获取小组内帖子列表
+const fetchGroupPosts = async () => {
+  if (!groupId.value) return;
+  isLoadingPosts.value = true;
+  fetchPostsError.value = '';
+  try {
+    // 使用我们之前在GroupViewSet中定义的action: groups/<pk>/posts/
+    const response = await apiClient.get(`groups/${groupId.value}/posts/`);
+    posts.value = response.data.results || response.data; // 处理分页或直接列表
+  } catch (error) {
+    console.error('获取小组帖子失败:', error);
+    fetchPostsError.value = '无法加载小组动态。';
+  } finally {
+    isLoadingPosts.value = false;
+  }
+};
+
+// 检查当前用户是否是小组成员
+const checkMembership = async () => {
+    if (!authStore.isAuthenticated || !group.value) {
+        isMember.value = false;
+        return;
+    }
+    try {
+        // 可以通过比较group.members中的ID,或者调用一个专门的API检查成员资格
+        // 简单起见,如果后端GroupSerializer返回了members ID列表 (我们目前返回members_count)
+        // 或者,更可靠的是,当用户加入/退出时维护一个本地状态或重新获取用户加入的小组列表
+        // 为了演示,我们假设需要一个方式来判断。
+        // 实际项目中,可以在获取小组详情时,后端API返回一个字段 is_current_user_member
+        // 或者前端在获取 "我的小组" 列表后存起来对比。
+        // 这里我们模拟一个简单的检查,假设group对象中有一个members ID列表 (实际上我们只有members_count)
+        // 因此,更准确的做法是调用 "my_groups" API 然后检查,或者后端API直接返回这个信息
+        // **为了简化,我们先假设如果用户是创建者,他就是成员;否则需要加入。**
+        // **更完善的逻辑是,当用户加入/退出小组时,更新 isMember 的状态。**
+        if (group.value.creator && group.value.creator.id === authStore.currentUser?.id) {
+            isMember.value = true; // 创建者默认是成员
+        } else {
+            // 需要更可靠的方式来判断成员资格,例如查看 authStore.currentUser.joined_groups
+            // 或者在小组详情API中,后端返回一个字段告知当前用户是否是成员。
+            // 我们先假设一个简单的占位逻辑,或者在用户点击加入/退出后更新isMember
+            // 调用 "my_groups" 然后检查
+            if (authStore.currentUser) {
+                const myGroupsResponse = await apiClient.get('groups/my-groups/');
+                const myGroupIds = (myGroupsResponse.data.results || myGroupsResponse.data).map(g => g.id);
+                isMember.value = myGroupIds.includes(parseInt(groupId.value));
+            } else {
+                isMember.value = false;
+            }
+        }
+    } catch(e) {
+        console.error("Check membership error", e);
+        isMember.value = false; // 出错则认为不是成员
+    }
+};
+
+// 加入/退出小组的逻辑
+const toggleJoinLeaveGroup = async () => {
+  if (!authStore.isAuthenticated) {
+    router.push('/login');
+    return;
+  }
+  isJoiningOrLeaving.value = true;
+  joinLeaveError.value = '';
+  joinLeaveSuccess.value = '';
+  const actionUrl = isMember.value ? `groups/${groupId.value}/leave_group/` : `groups/${groupId.value}/join_group/`;
+  try {
+    await apiClient.post(actionUrl);
+    isMember.value = !isMember.value; // 切换成员状态
+    joinLeaveSuccess.value = isMember.value ? '成功加入小组!' : '已成功退出小组。';
+    // 成功后应该更新小组成员数量 (group.value.members_count)
+    // 最好的方式是重新获取小组详情,或者后端API返回更新后的小组数据
+    fetchGroupDetail(); // 重新获取小组信息以更新成员数
+  } catch (error) {
+    console.error('加入/退出小组失败:', error.response?.data?.detail || error.message);
+    joinLeaveError.value = error.response?.data?.detail || '操作失败,请重试。';
+  } finally {
+    isJoiningOrLeaving.value = false;
+    setTimeout(() => { // 消息显示一会儿后消失
+        joinLeaveSuccess.value = '';
+        joinLeaveError.value = '';
+    }, 3000);
+  }
+};
+
+// 创建新帖子的逻辑
+const handleCreatePost = async () => {
+  if (!newPost.content.trim()) {
+    createPostError.value = "帖子内容不能为空。";
+    return;
+  }
+  isSubmittingPost.value = true;
+  createPostError.value = '';
+  try {
+    // 使用 GroupViewSet 中的 create_group_post action
+    // URL: groups/<group_pk>/create-post/
+    const response = await apiClient.post(`groups/${groupId.value}/create-post/`, {
+      title: newPost.title,
+      content: newPost.content,
+      // image: newPost.image, // 如果有图片上传
+    });
+    posts.value.unshift(response.data); // 将新帖子添加到列表顶部
+    newPost.title = ''; // 清空表单
+    newPost.content = '';
+    // newPost.image = null;
+  } catch (error) {
+    console.error('发布帖子失败:', error.response?.data || error.message);
+    createPostError.value = error.response?.data?.detail || Object.values(error.response?.data || {}).flat().join('; ') || '发布失败,请重试。';
+  } finally {
+    isSubmittingPost.value = false;
+  }
+};
+
+// 辅助函数
+const truncateText = (text, maxLength) => {
+  if (!text) return '';
+  if (text.length <= maxLength) return text;
+  return text.substring(0, maxLength) + '...';
+};
+
+const formatDateTime = (dateTimeString) => {
+  if (!dateTimeString) return '';
+  const options = { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' };
+  return new Date(dateTimeString).toLocaleDateString(undefined, options);
+};
+
+// 组件挂载时获取数据
+onMounted(async () => {
+  await fetchGroupDetail(); // 先获取小组详情
+  if (group.value) { // 只有小组存在才获取帖子和检查成员
+    await fetchGroupPosts();
+    // checkMembership(); // fetchGroupDetail 内部会调用 checkMembership
+  }
+});
+
+// 监听路由参数变化,如果小组ID变了,重新加载数据
+watch(() => route.params.id, (newId, oldId) => {
+  if (newId && newId !== oldId) {
+    fetchGroupDetail();
+    fetchGroupPosts();
+  }
+});
+// 监听登录状态变化,如果用户登录/登出,重新检查成员资格
+watch(() => authStore.isAuthenticated, (isAuth) => {
+    if (group.value) { // 只有在小组数据已加载时才重新检查
+        checkMembership();
+    }
+});
+
+</script>
+<style scoped>
+.group-detail-container {
+  max-width: 800px;
+  margin: 20px auto;
+  padding: 20px;
+}
+.group-header {
+  display: flex;
+  align-items: flex-start; /* 顶部对齐 */
+  margin-bottom: 30px;
+  padding-bottom: 20px;
+  border-bottom: 1px solid #eee;
+}
+.group-detail-cover, .group-detail-cover-placeholder {
+  width: 150px;
+  height: 150px;
+  object-fit: cover;
+  border-radius: 8px;
+  margin-right: 20px;
+  flex-shrink: 0; /* 防止图片被压缩 */
+  background-color: #f0f2f5;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  font-size: 3em;
+  color: #bdc3c7;
+}
+.group-info {
+  text-align: left;
+  flex-grow: 1;
+}
+.group-info h1 {
+  margin-top: 0;
+  margin-bottom: 10px;
+  font-size: 2em;
+  color: #333;
+}
+.group-description-detail {
+  font-size: 1em;
+  color: #555;
+  line-height: 1.6;
+  margin-bottom: 15px;
+}
+.group-meta-detail {
+  font-size: 0.9em;
+  color: #777;
+}
+.group-meta-detail span {
+  margin-right: 15px;
+}
+.group-actions {
+  margin-top: 15px;
+}
+.group-actions button {
+  padding: 8px 15px;
+  border: none;
+  border-radius: 4px;
+  cursor: pointer;
+  font-weight: 500;
+}
+.button-join { background-color: #28a745; color: white; }
+.button-join:hover { background-color: #218838; }
+.button-leave { background-color: #dc3545; color: white; }
+.button-leave:hover { background-color: #c82333; }
+.button-leave:disabled, .button-join:disabled { background-color: #ccc; }
+
+.divider {
+    margin: 30px 0;
+    border: 0;
+    border-top: 1px solid #eee;
+}
+
+.posts-section h3 {
+  font-size: 1.5em;
+  margin-bottom: 20px;
+  color: #333;
+}
+.create-post-section {
+  background-color: #f9f9f9;
+  padding: 20px;
+  border-radius: 8px;
+  margin-bottom: 30px;
+  border: 1px solid #e7e7e7;
+}
+.create-post-section h4 {
+  margin-top: 0;
+  margin-bottom: 15px;
+}
+.create-post-form .form-group {
+  margin-bottom: 15px;
+}
+.create-post-form label {
+  display: block;
+  margin-bottom: 5px;
+  font-weight: 500;
+}
+.create-post-form input[type="text"],
+.create-post-form textarea {
+  width: 100%;
+  padding: 10px;
+  border: 1px solid #ccc;
+  border-radius: 4px;
+  box-sizing: border-box;
+}
+.create-post-form textarea {
+  min-height: 80px;
+  resize: vertical;
+}
+.create-post-form button[type="submit"] {
+  padding: 10px 20px;
+  background-color: #007bff;
+  color: white;
+  border: none;
+  border-radius: 4px;
+  cursor: pointer;
+}
+.create-post-form button:disabled {
+    background-color: #aaa;
+}
+
+.post-list {
+  margin-top: 20px;
+}
+.post-item {
+  background-color: #fff;
+  border: 1px solid #e7e7e7;
+  border-radius: 6px;
+  margin-bottom: 15px;
+  padding: 15px;
+  transition: box-shadow 0.2s;
+}
+.post-item:hover {
+    box-shadow: 0 2px 8px rgba(0,0,0,0.08);
+}
+.post-link {
+    text-decoration: none;
+    color: inherit;
+}
+.post-header {
+  display: flex;
+  align-items: center;
+  margin-bottom: 10px;
+}
+.post-author-avatar, .post-author-avatar-placeholder {
+  width: 36px;
+  height: 36px;
+  border-radius: 50%;
+  margin-right: 10px;
+  object-fit: cover;
+  background-color: #f0f0f0;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  font-weight: bold;
+  color: #777;
+}
+.post-author-info {
+  display: flex;
+  flex-direction: column;
+  align-items: flex-start;
+}
+.post-author-name {
+  font-weight: 600;
+  font-size: 0.95em;
+}
+.post-created-at {
+  font-size: 0.8em;
+  color: #777;
+}
+.post-title {
+  font-size: 1.15em;
+  font-weight: 600;
+  margin-bottom: 8px;
+  color: #333;
+}
+.post-content-preview {
+  font-size: 0.95em;
+  color: #444;
+  line-height: 1.6;
+  white-space: pre-line; /* 保留换行 */
+}
+.post-meta {
+  margin-top: 10px;
+  font-size: 0.85em;
+  color: #888;
+}
+.post-meta span {
+  margin-right: 15px;
+}
+.info-message, .not-found-message {
+  text-align: center;
+  padding: 20px;
+  color: #666;
+}
+.error-message.small-error, .success-message.small-success {
+  font-size: 0.85em;
+  padding: 8px;
+  margin-top: 10px;
+}
+</style>

+ 334 - 0
tongqu_frontend/src/views/GroupListView.vue

@@ -0,0 +1,334 @@
+<template>
+  <div class="group-list-container">
+    <div class="header-bar">
+      <h2>兴趣小组</h2>
+      <router-link v-if="authStore.isAuthenticated" :to="{ name: 'CreateGroup' }" class="button-create-group">
+        <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-plus-circle-fill" viewBox="0 0 16 16">
+          <path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0M8.5 4.5a.5.5 0 0 0-1 0v3h-3a.5.5 0 0 0 0 1h3v3a.5.5 0 0 0 1 0v-3h3a.5.5 0 0 0 0-1h-3z"/>
+        </svg>
+        创建小组
+      </router-link>
+    </div>
+
+    <div v-if="isLoading" class="loading-message">正在加载小组列表...</div>
+    <div v-if="fetchError" class="error-message">{{ fetchError }}</div>
+    
+    <div v-if="!isLoading && groups.length === 0 && !fetchError" class="empty-state">
+      <p>目前还没有任何小组。</p>
+      <p v-if="authStore.isAuthenticated">成为第一个<router-link :to="{ name: 'CreateGroup' }">创建小组</router-link>的人吧!</p>
+      <p v-else>请<router-link :to="{ name: 'Login' }">登录</router-link>后创建或加入小组。</p>
+    </div>
+
+    <div v-if="groups.length > 0" class="groups-grid">
+      <div v-for="group in groups" :key="group.id" class="group-card">
+        <router-link :to="{ name: 'GroupDetail', params: { id: group.id } }" class="group-link">
+          <div class="group-cover-wrapper">
+            <img 
+              v-if="group.cover_image_url" 
+              :src="group.cover_image_url" 
+              :alt="group.name + '封面'" 
+              class="group-cover"
+              @error="setDefaultCover"
+            />
+            <div v-else class="group-cover-placeholder">
+              <span>{{ group.name ? group.name.charAt(0).toUpperCase() : 'G' }}</span>
+            </div>
+          </div>
+          <div class="group-card-content">
+            <h3 class="group-name">{{ group.name }}</h3>
+            <p class="group-description">{{ truncateText(group.description, 80) }}</p>
+            <div class="group-meta">
+              <span class="members-count">
+                <svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" fill="currentColor" class="bi bi-people-fill" viewBox="0 0 16 16">
+                  <path d="M7 14s-1 0-1-1 1-4 5-4 5 3 5 4-1 1-1 1zm4-6a3 3 0 1 0 0-6 3 3 0 0 0 0 6m-5.784 6A2.24 2.24 0 0 1 5 13c0-1.355.68-2.75 1.936-3.72A6.3 6.3 0 0 0 5 9c-4 0-5 3-5 4s1 1 1 1zM4.5 8a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5"/>
+                </svg>
+                {{ group.members_count }} 成员
+              </span>
+              <span v-if="group.tags_details && group.tags_details.length > 0" class="tags-preview">
+                {{ group.tags_details.slice(0, 1).map(t => t.name).join('') }}
+                {{ group.tags_details.length > 1 ? '...' : '' }}
+              </span>
+            </div>
+          </div>
+        </router-link>
+      </div>
+    </div>
+    
+    <div v-if="pagination.totalPages > 1 && groups.length > 0" class="pagination-controls">
+      <button @click="fetchGroups(pagination.currentPage - 1)" :disabled="!pagination.previous || isLoading">上一页</button>
+      <span>第 {{ pagination.currentPage }} / {{ pagination.totalPages }} 页 (共 {{ pagination.count }} 条)</span>
+      <button @click="fetchGroups(pagination.currentPage + 1)" :disabled="!pagination.next || isLoading">下一页</button>
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { ref, onMounted, reactive, computed } from 'vue';
+import apiClient from '../services/api'; // 确保路径正确
+import { useAuthStore } from '../store/auth'; // 确保路径正确
+import { useRouter } from 'vue-router'; // 如果需要编程式导航
+
+const groups = ref([]);
+const isLoading = ref(true);
+const fetchError = ref('');
+const authStore = useAuthStore();
+const router = useRouter(); // 如果要用router.push等
+
+const pagination = reactive({
+    currentPage: 1,
+    totalPages: 1,
+    next: null,
+    previous: null,
+    count: 0,
+    pageSize: 10, // 假设每页10条,与后端分页设置匹配
+});
+
+const fetchGroups = async (page = 1) => {
+  isLoading.value = true;
+  fetchError.value = '';
+  try {
+    const response = await apiClient.get('groups/', {
+      params: { page: page }
+    });
+    
+    if (response.data && typeof response.data.count !== 'undefined') { // 检查是否是DRF分页结构
+        groups.value = response.data.results;
+        pagination.count = response.data.count;
+        pagination.next = response.data.next;
+        pagination.previous = response.data.previous;
+        pagination.currentPage = page;
+        // 从返回的results长度或预设值来确定pageSize
+        const resultsLength = response.data.results ? response.data.results.length : 0;
+        if (resultsLength > 0 && pagination.count > 0) {
+             // 如果后端返回了数据,可以尝试从 next/previous URL中推断或使用实际返回数量
+             // 但更可靠的是后端API能明确告知每页数量,或前端与后端约定好
+             // 这里我们假设如果第一页有数据,那么pageSize就是第一页数据的长度,否则用默认值
+            pagination.pageSize = resultsLength;
+        } else if (pagination.count === 0) {
+            pagination.pageSize = 10; // Fallback
+        }
+        // 如果 pageSize 能被确定,则totalPages可以这样算
+        pagination.totalPages = Math.ceil(pagination.count / pagination.pageSize) || 1;
+    } else { // 如果不是标准分页结构,直接赋值 (例如后端未配置分页)
+        groups.value = Array.isArray(response.data) ? response.data : [];
+        pagination.count = groups.value.length;
+        pagination.totalPages = 1;
+        pagination.currentPage = 1;
+        pagination.next = null;
+        pagination.previous = null;
+    }
+    
+  } catch (error) {
+    console.error('获取小组列表失败:', error.response || error);
+    fetchError.value = '无法加载小组列表,请稍后重试。';
+    if (error.response && error.response.status === 401 && authStore.isAuthenticated) {
+        fetchError.value = '会话可能已过期,请尝试重新登录。';
+        // 可以在这里触发登出或跳转
+        // authStore.logout();
+        // router.push('/login');
+    }
+  } finally {
+    isLoading.value = false;
+  }
+};
+
+const truncateText = (text, maxLength) => {
+  if (!text) return '';
+  if (text.length <= maxLength) return text;
+  return text.substring(0, maxLength) + '...';
+};
+
+const setDefaultCover = (event) => {
+  // 当图片加载失败时,可以设置一个默认图片或隐藏图片元素
+  // 这里我们让它显示占位符的背景色(通过CSS实现)
+  // event.target.style.display = 'none'; // 隐藏损坏的图片
+  // 或者 event.target.src = '/path/to/default-cover.png'; // 设置默认图片
+  // 为简单起见,如果图片加载失败,CSS中的占位符会因img标签存在而不显示,可以调整逻辑
+  const wrapper = event.target.parentElement;
+  if (wrapper && wrapper.classList.contains('group-cover-wrapper')) {
+    const placeholder = wrapper.querySelector('.group-cover-placeholder');
+    if (placeholder) placeholder.style.display = 'flex'; // 显示占位符
+    event.target.style.display = 'none'; // 隐藏加载失败的img
+  }
+};
+
+onMounted(() => {
+  fetchGroups(); // 获取第一页数据
+});
+</script>
+
+<style scoped>
+.group-list-container {
+  max-width: 1200px; /* 稍微加宽以容纳更多卡片 */
+  margin: 20px auto;
+  padding: 20px;
+}
+.header-bar {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 20px;
+  padding-bottom: 10px;
+  border-bottom: 1px solid #eee;
+}
+.header-bar h2 {
+  margin: 0;
+  font-size: 1.8em;
+}
+.button-create-group {
+  display: inline-flex; /* 改为inline-flex使图标和文字在同一行且垂直居中 */
+  align-items: center;
+  padding: 8px 15px;
+  background-color: #28a745;
+  color: white;
+  text-decoration: none;
+  border-radius: 5px;
+  font-size: 0.95em;
+  transition: background-color 0.3s;
+}
+.button-create-group svg {
+  margin-right: 6px; /* 图标和文字间距 */
+}
+.button-create-group:hover {
+  background-color: #218838;
+}
+
+.groups-grid {
+  display: grid;
+  grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); /* 调整卡片最小宽度 */
+  gap: 25px; /* 调整间距 */
+}
+.group-card {
+  border: 1px solid #e0e0e0;
+  border-radius: 8px;
+  background-color: #fff;
+  transition: transform 0.2s ease-in-out, box-shadow 0.2s ease-in-out;
+  overflow: hidden; /* 确保内容不超过卡片 */
+}
+.group-card:hover {
+  transform: translateY(-5px);
+  box-shadow: 0 8px 16px rgba(0, 0, 0, 0.1);
+}
+.group-link {
+  text-decoration: none;
+  color: inherit;
+  display: flex; /* 改为flex布局使内容垂直排列 */
+  flex-direction: column;
+  height: 100%; /* 使链接填满卡片 */
+}
+.group-cover-wrapper {
+  width: 100%;
+  padding-top: 56.25%; /* 16:9 aspect ratio */
+  position: relative;
+  background-color: #f0f2f5; /* Placeholder background */
+}
+.group-cover {
+  position: absolute;
+  top: 0;
+  left: 0;
+  width: 100%;
+  height: 100%;
+  object-fit: cover; /* 图片裁剪以填充 */
+}
+.group-cover-placeholder {
+  position: absolute;
+  top: 0;
+  left: 0;
+  width: 100%;
+  height: 100%;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  font-size: 2em;
+  color: #bdc3c7;
+  background-color: #ecf0f1;
+  user-select: none;
+}
+.group-card-content {
+  padding: 15px;
+  flex-grow: 1; /* 使内容区填满剩余空间 */
+  display: flex;
+  flex-direction: column;
+}
+.group-name {
+  font-size: 1.25em;
+  font-weight: 600; /* 稍微加粗 */
+  margin-top: 0;
+  margin-bottom: 8px;
+  color: #2c3e50;
+}
+.group-description {
+  font-size: 0.9em;
+  color: #555; /* 深一点的灰色 */
+  line-height: 1.5;
+  margin-bottom: 12px;
+  flex-grow: 1; /* 使描述填满可用空间,为元数据留出底部空间 */
+  /* 多行省略实现 (需要固定高度或行数) */
+  display: -webkit-box;
+  -webkit-line-clamp: 3; /* 显示3行 */
+  -webkit-box-orient: vertical;  
+  overflow: hidden;
+  text-overflow: ellipsis;
+  min-height: 4.5em; /* 0.9em * 1.5 * 3行 */
+}
+.group-meta {
+  font-size: 0.8em;
+  color: #7f8c8d; /* 稍浅的灰色 */
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-top: auto; /* 将元数据推到卡片底部 */
+  border-top: 1px solid #f0f0f0; /* 分隔线 */
+  padding-top: 10px;
+}
+.members-count {
+  display: inline-flex;
+  align-items: center;
+}
+.members-count svg {
+  margin-right: 4px;
+}
+.tags-preview {
+  max-width: 50%; /* 限制标签预览宽度 */
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  text-align: right;
+}
+.loading-message, .error-message, .empty-state {
+  text-align: center;
+  padding: 40px 20px;
+  color: #555;
+}
+.empty-state p {
+  margin-bottom: 10px;
+}
+.empty-state a {
+  color: #007bff;
+  font-weight: bold;
+}
+.pagination-controls {
+  margin-top: 30px;
+  text-align: center;
+}
+.pagination-controls button {
+  margin: 0 8px;
+  padding: 8px 16px;
+  cursor: pointer;
+  border: 1px solid #ddd;
+  background-color: #fff;
+  border-radius: 4px;
+  transition: background-color 0.2s;
+}
+.pagination-controls button:disabled {
+  opacity: 0.5;
+  cursor: not-allowed;
+}
+.pagination-controls button:hover:not(:disabled) {
+  background-color: #f0f0f0;
+}
+.pagination-controls span {
+  margin: 0 10px;
+  color: #555;
+}
+</style>

+ 15 - 0
tongqu_frontend/src/views/HomeView.vue

@@ -0,0 +1,15 @@
+<template>
+  <div>
+    <h1>欢迎来到同趣U!</h1>
+    <p>这是一个基于兴趣的社交平台。</p>
+    <router-link to="/login">去登录</router-link>
+  </div>
+</template>
+
+<script setup>
+// 暂时不需要脚本
+</script>
+
+<style scoped>
+/* 可以添加一些样式 */
+</style>

+ 120 - 0
tongqu_frontend/src/views/LoginView.vue

@@ -0,0 +1,120 @@
+<template>
+  <div class="login-container">
+    <h2>用户登录</h2>
+    <form @submit.prevent="handleLogin" class="login-form">
+      <div class="form-group">
+        <label for="email">邮箱:</label>
+        <input type="email" id="email" v-model="email" required placeholder="请输入邮箱地址" />
+      </div>
+      <div class="form-group">
+        <label for="password">密码:</label>
+        <input type="password" id="password" v-model="password" required placeholder="请输入密码" />
+      </div>
+      <button type="submit" :disabled="isLoading">
+        {{ isLoading ? '登录中...' : '登录' }}
+      </button>
+      <p v-if="errorMessage" class="error-message">{{ errorMessage }}</p>
+      <!-- 显示来自 store 的登录错误 -->
+      <p v-if="authStore.getLoginError && !errorMessage" class="error-message">{{ authStore.getLoginError }}</p>
+    </form>
+    <p class="register-link">
+      还没有账号? <router-link to="/register">立即注册</router-link>
+    </p>
+  </div>
+</template>
+<script setup>
+import { ref } from 'vue';
+import { useRouter } from 'vue-router';
+import { useAuthStore } from '../store/auth'; // 导入Auth Store
+
+const email = ref('');
+const password = ref('');
+const errorMessage = ref(''); // 用于显示组件级别的即时错误
+const isLoading = ref(false);
+
+const router = useRouter();
+const authStore = useAuthStore(); // 获取Auth Store实例
+
+const handleLogin = async () => {
+  console.log("handleLogin function CALLED!");
+  console.log("Initial isLoading state in handleLogin:", isLoading.value);
+  isLoading.value = true;
+  errorMessage.value = ''; // 清除组件级别的错误
+  authStore.loginError = null; // 清除 store 中的旧错误
+
+  try {
+    await authStore.login({ email: email.value, password: password.value });
+    console.log('登录成功 (通过Store)');
+    // alert('登录成功!'); // 可以用更友好的方式提示,或者直接跳转
+    router.push('/'); // 登录成功后跳转到首页
+  } catch (error) {
+    // authStore.login action 内部已经处理了 loginError 的设置
+    // 如果 authStore.login action 抛出错误,它会被这里捕获
+    // error.message 应该就是 authStore.loginError 的值
+    errorMessage.value = error.message || '登录失败,发生了未知错误。';
+    console.error('Login component error:', error);
+  } finally {
+    isLoading.value = false;
+  }
+};
+</script>
+<style scoped>
+.login-container {
+  max-width: 400px;
+  margin: 50px auto;
+  padding: 20px;
+  border: 1px solid #ddd;
+  border-radius: 8px;
+  box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
+}
+.login-form .form-group {
+  margin-bottom: 15px;
+  text-align: left;
+}
+.login-form label {
+  display: block;
+  margin-bottom: 5px;
+  font-weight: bold;
+}
+.login-form input[type="email"],
+.login-form input[type="password"] {
+  width: calc(100% - 22px);
+  padding: 10px;
+  border: 1px solid #ccc;
+  border-radius: 4px;
+  font-size: 1em;
+}
+.login-form button {
+  width: 100%;
+  padding: 10px;
+  background-color: #42b983;
+  color: white;
+  border: none;
+  border-radius: 4px;
+  cursor: pointer;
+  font-size: 1em;
+  transition: background-color 0.3s;
+}
+.login-form button:disabled {
+  background-color: #aaa;
+  cursor: not-allowed;
+}
+.login-form button:hover:not(:disabled) {
+  background-color: #36a374;
+}
+.error-message {
+  color: red;
+  margin-top: 15px;
+}
+.register-link {
+  margin-top: 20px;
+  font-size: 0.9em;
+}
+.register-link a {
+  color: #42b983;
+  text-decoration: none;
+}
+.register-link a:hover {
+  text-decoration: underline;
+}
+</style>

+ 572 - 0
tongqu_frontend/src/views/PostDetailView.vue

@@ -0,0 +1,572 @@
+<template>
+  <div class="post-detail-container">
+    <div v-if="isLoadingPost && !post" class="loading-message">正在加载帖子详情...</div>
+    <div v-if="fetchPostError" class="error-message">{{ fetchPostError }}</div>
+
+    <div v-if="post" class="post-content-area">
+      <!-- 返回小组链接 -->
+      <div class="back-to-group" v-if="post.group">
+        <router-link :to="{ name: 'GroupDetail', params: { id: post.group.id } }">
+          « 返回小组: {{ post.group.name }}
+        </router-link>
+      </div>
+
+      <h1 class="post-detail-title">{{ post.title || '动态详情' }}</h1>
+      <div class="post-detail-meta">
+        <div class="author-info">
+          <img 
+            v-if="post.author && post.author.avatar_url" 
+            :src="post.author.avatar_url" 
+            alt="author avatar" 
+            class="author-avatar-small"
+          />
+          <div v-else class="author-avatar-placeholder-small">{{ post.author ? post.author.nickname.charAt(0) : 'U' }}</div>
+          <span class="author-name">{{ post.author ? post.author.nickname : '匿名用户' }}</span>
+        </div>
+        <span class="timestamp">发布于: {{ formatDateTime(post.created_at) }}</span>
+        <span v-if="post.updated_at && post.updated_at !== post.created_at" class="timestamp">
+          (编辑于: {{ formatDateTime(post.updated_at) }})
+        </span>
+      </div>
+      
+      <div class="post-detail-body" v-html="formatPostContent(post.content)"></div>
+
+      <!-- 点赞区域 -->
+      <div class="post-actions" v-if="authStore.isAuthenticated">
+        <button 
+          @click="toggleLikePost" 
+          :disabled="isLikingPost"
+          :class="{'liked': post.is_liked_by_current_user}" 
+          class="like-button"
+        >
+          <svg v-if="!post.is_liked_by_current_user" xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-heart" viewBox="0 0 16 16">
+            <path d="m8 2.748-.717-.737C5.6.281 2.514.878 1.4 3.053c-.523 1.023-.641 2.5.314 4.385.92 1.815 2.834 3.989 6.286 6.357 3.452-2.368 5.365-4.542 6.286-6.357.955-1.886.838-3.362.314-4.385C13.486.878 10.4.28 8.717 2.01zM8 15C-7.333 4.868 3.279-3.04 7.824 1.143q.09.083.176.171a3 3 0 0 1 .176-.17C12.72-3.042 23.333 4.867 8 15"/>
+          </svg>
+          <svg v-else xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-heart-fill" viewBox="0 0 16 16">
+            <path fill-rule="evenodd" d="M8 1.314C12.438-3.248 23.534 4.735 8 15-7.534 4.736 3.562-3.248 8 1.314"/>
+          </svg>
+          {{ post.is_liked_by_current_user ? '已赞' : '赞' }} ({{ post.likes_count }})
+        </button>
+        <!-- 编辑/删除按钮 (仅作者可见) -->
+        <div v-if="authStore.currentUser && post.author && authStore.currentUser.id === post.author.id" class="author-actions">
+            <button @click="startEditPostMode" class="button-edit">编辑帖子</button>
+            <button @click="confirmDeletePost" class="button-delete">删除帖子</button>
+        </div>
+      </div>
+      <p v-if="likeError" class="error-message small-error">{{ likeError }}</p>
+    </div>
+
+    <!-- 编辑帖子表单 (条件渲染) -->
+    <div v-if="isEditingPost" class="edit-post-form">
+        <h3>编辑帖子</h3>
+        <form @submit.prevent="handleUpdatePost">
+            <div class="form-group">
+                <label for="edit-post-title">标题 (可选):</label>
+                <input type="text" id="edit-post-title" v-model="editablePost.title" maxlength="200">
+            </div>
+            <div class="form-group">
+                <label for="edit-post-content">内容:</label>
+                <textarea id="edit-post-content" v-model="editablePost.content" required></textarea>
+            </div>
+            <div class="form-actions">
+                <button type="submit" :disabled="isSubmittingUpdate">
+                    {{ isSubmittingUpdate ? '更新中...' : '保存更改' }}
+                </button>
+                <button type="button" @click="cancelEditPostMode" class="cancel-button">取消编辑</button>
+            </div>
+            <p v-if="updatePostError" class="error-message small-error">{{ updatePostError }}</p>
+        </form>
+    </div>
+
+
+    <hr class="divider" v-if="post">
+
+    <!-- 评论区 -->
+    <div v-if="post" class="comments-section">
+      <h3>评论 ({{ comments.length }})</h3>
+      
+      <!-- 发表评论表单 (仅登录用户可见) -->
+      <form v-if="authStore.isAuthenticated" @submit.prevent="handleAddComment" class="add-comment-form">
+        <div class="form-group">
+          <textarea v-model="newCommentContent" placeholder="写下你的评论..." required></textarea>
+        </div>
+        <button type="submit" :disabled="isSubmittingComment">
+          {{ isSubmittingComment ? '发表中...' : '发表评论' }}
+        </button>
+        <p v-if="addCommentError" class="error-message small-error">{{ addCommentError }}</p>
+      </form>
+      <p v-else class="info-message">
+        <router-link :to="{ name: 'Login', query: { redirect: route.fullPath } }">登录</router-link>后才能发表评论。
+      </p>
+
+      <div v-if="isLoadingComments" class="loading-message">正在加载评论...</div>
+      <div v-if="fetchCommentsError" class="error-message">{{ fetchCommentsError }}</div>
+      
+      <div v-if="comments.length > 0" class="comment-list">
+        <div v-for="comment in comments" :key="comment.id" class="comment-item">
+          <div class="comment-header">
+             <img 
+                v-if="comment.author && comment.author.avatar_url" 
+                :src="comment.author.avatar_url" 
+                alt="author avatar" 
+                class="comment-author-avatar"
+              />
+              <div v-else class="comment-author-avatar-placeholder">{{ comment.author ? comment.author.nickname.charAt(0) : 'U' }}</div>
+              <div class="comment-author-info">
+                <span class="comment-author-name">{{ comment.author ? comment.author.nickname : '匿名用户' }}</span>
+                <span class="comment-created-at">{{ formatDateTime(comment.created_at) }}</span>
+              </div>
+          </div>
+          <p class="comment-content">{{ comment.content }}</p>
+          <!-- (可选) 评论的点赞、回复、编辑、删除按钮 -->
+        </div>
+      </div>
+      <p v-else-if="!isLoadingComments && !fetchCommentsError" class="info-message">暂无评论,快来抢沙发吧!</p>
+    </div>
+
+    <div v-if="!post && !isLoadingPost && !fetchPostError" class="not-found-message">
+        <p>无法找到该帖子,或者帖子不存在。</p>
+        <router-link :to="{ name: 'Home' }">返回首页</router-link> <!-- 或者返回小组列表 -->
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { ref, reactive, onMounted, computed, watch } from 'vue';
+import apiClient from '../services/api';
+import { useAuthStore } from '../store/auth';
+import { useRoute, useRouter } from 'vue-router';
+
+const post = ref(null);
+const isLoadingPost = ref(true);
+const fetchPostError = ref('');
+
+const comments = ref([]);
+const isLoadingComments = ref(false);
+const fetchCommentsError = ref('');
+
+const newCommentContent = ref('');
+const isSubmittingComment = ref(false);
+const addCommentError = ref('');
+
+const isLikingPost = ref(false);
+const likeError = ref('');
+
+const isEditingPost = ref(false);
+const editablePost = reactive({ title: '', content: '' });
+const isSubmittingUpdate = ref(false);
+const updatePostError = ref('');
+
+
+const authStore = useAuthStore();
+const route = useRoute();
+const router = useRouter();
+
+const postId = computed(() => route.params.id);
+
+// 获取帖子详情
+const fetchPostDetail = async () => {
+  isLoadingPost.value = true;
+  fetchPostError.value = '';
+  try {
+    const response = await apiClient.get(`posts/${postId.value}/`); // 使用PostViewSet的retrieve
+    post.value = response.data;
+  } catch (error) {
+    console.error('获取帖子详情失败:', error);
+    fetchPostError.value = '无法加载帖子详情。';
+    if (error.response && error.response.status === 404) {
+        fetchPostError.value = '该帖子不存在或已被删除。';
+    }
+  } finally {
+    isLoadingPost.value = false;
+  }
+};
+
+// 获取帖子评论列表
+const fetchPostComments = async () => {
+  if (!postId.value) return;
+  isLoadingComments.value = true;
+  fetchCommentsError.value = '';
+  try {
+    // 使用PostViewSet中定义的action: posts/<pk>/comment-list/
+    const response = await apiClient.get(`posts/${postId.value}/comment-list/`);
+    comments.value = response.data.results || response.data; // 处理分页或直接列表
+  } catch (error) {
+    console.error('获取帖子评论失败:', error);
+    fetchCommentsError.value = '无法加载评论。';
+  } finally {
+    isLoadingComments.value = false;
+  }
+};
+
+// 添加评论
+const handleAddComment = async () => {
+  if (!newCommentContent.value.trim()) {
+    addCommentError.value = '评论内容不能为空。';
+    return;
+  }
+  if (!authStore.isAuthenticated) {
+    addCommentError.value = '请先登录再发表评论。';
+    return;
+  }
+  isSubmittingComment.value = true;
+  addCommentError.value = '';
+  try {
+    // 使用PostViewSet中定义的action: posts/<pk>/comments/ (POST方法)
+    const response = await apiClient.post(`posts/${postId.value}/comments/`, {
+      content: newCommentContent.value,
+    });
+    comments.value.push(response.data); // 将新评论添加到列表底部
+    newCommentContent.value = ''; // 清空输入框
+  } catch (error) {
+    console.error('发表评论失败:', error.response?.data || error.message);
+    addCommentError.value = error.response?.data?.detail || Object.values(error.response?.data || {}).flat().join('; ') || '发表评论失败,请重试。';
+  } finally {
+    isSubmittingComment.value = false;
+  }
+};
+
+// 点赞/取消点赞帖子
+const toggleLikePost = async () => {
+    if (!authStore.isAuthenticated || !post.value) return;
+    isLikingPost.value = true;
+    likeError.value = '';
+    const actionUrl = post.value.is_liked_by_current_user ? 
+                      `posts/${post.value.id}/unlike/` : 
+                      `posts/${post.value.id}/like/`;
+    try {
+        const response = await apiClient.post(actionUrl); // 点赞/取消点赞都是POST
+        // 后端返回了更新后的post对象,直接用它更新本地的post ref
+        post.value = response.data; 
+    } catch (error) {
+        console.error('点赞/取消点赞失败:', error.response?.data || error.message);
+        likeError.value = error.response?.data?.detail || '操作失败,请重试。';
+    } finally {
+        isLikingPost.value = false;
+    }
+};
+
+// 编辑帖子相关
+const startEditPostMode = () => {
+    if (post.value) {
+        editablePost.title = post.value.title || '';
+        editablePost.content = post.value.content || '';
+        isEditingPost.value = true;
+        updatePostError.value = '';
+    }
+};
+const cancelEditPostMode = () => {
+    isEditingPost.value = false;
+};
+const handleUpdatePost = async () => {
+    if (!editablePost.content.trim()) {
+        updatePostError.value = "帖子内容不能为空。";
+        return;
+    }
+    isSubmittingUpdate.value = true;
+    updatePostError.value = '';
+    try {
+        const payload = {
+            content: editablePost.content
+        };
+        if (editablePost.title !== (post.value?.title || '')) { // 只有标题改变了才发送
+            payload.title = editablePost.title;
+        }
+        // 如果有图片上传,也需要用 FormData
+        const response = await apiClient.patch(`posts/${postId.value}/`, payload);
+        post.value = response.data; // 更新本地显示的帖子数据
+        isEditingPost.value = false;
+        alert('帖子更新成功!');
+    } catch (error) {
+        console.error('更新帖子失败:', error.response?.data || error.message);
+        updatePostError.value = error.response?.data?.detail || Object.values(error.response?.data || {}).flat().join('; ') || '更新失败。';
+    } finally {
+        isSubmittingUpdate.value = false;
+    }
+};
+
+// 删除帖子相关
+const confirmDeletePost = async () => {
+    if (post.value && window.confirm(`您确定要删除帖子 "${post.value.title || '该动态'}" 吗?此操作无法撤销。`)) {
+        try {
+            await apiClient.delete(`posts/${postId.value}/`);
+            alert('帖子删除成功!');
+            // 删除成功后跳转回小组详情页或首页
+            if (post.value.group) {
+                router.push({ name: 'GroupDetail', params: { id: post.value.group.id }});
+            } else {
+                router.push({ name: 'Home' });
+            }
+        } catch (error) {
+            console.error('删除帖子失败:', error.response?.data || error.message);
+            alert(error.response?.data?.detail || '删除失败,请重试。');
+        }
+    }
+};
+
+
+// 辅助函数
+const formatDateTime = (dateTimeString) => {
+  if (!dateTimeString) return '';
+  const options = { year: 'numeric', month: 'long', day: 'numeric', hour: '2-digit', minute: '2-digit' };
+  return new Date(dateTimeString).toLocaleDateString('zh-CN', options); // 使用中文区域设置
+};
+
+const formatPostContent = (content) => {
+    if (!content) return '';
+    // 简单地替换换行符为 <br> 以在HTML中显示换行
+    // 更复杂的可以使用Markdown解析库
+    return content.replace(/\n/g, '<br>');
+};
+
+
+// 组件挂载时获取数据
+onMounted(async () => {
+  await fetchPostDetail();
+  if (post.value) { // 只有帖子存在才获取评论
+    await fetchPostComments();
+  }
+});
+
+// 监听路由参数变化,如果帖子ID变了,重新加载数据
+watch(() => route.params.id, (newId, oldId) => {
+  if (newId && newId !== oldId && route.name === 'PostDetail') { // 确保是当前路由
+    fetchPostDetail();
+    fetchPostComments();
+  }
+});
+</script>
+
+<style scoped>
+.post-detail-container {
+  max-width: 800px;
+  margin: 20px auto;
+  padding: 25px;
+  background-color: #fff;
+  border-radius: 8px;
+  box-shadow: 0 2px 10px rgba(0,0,0,0.05);
+}
+.loading-message, .error-message, .not-found-message, .info-message {
+  text-align: center;
+  padding: 20px;
+  margin-bottom: 20px;
+  border-radius: 4px;
+}
+.error-message { background-color: #f8d7da; color: #721c24; }
+.loading-message, .info-message { color: #555; }
+
+.back-to-group {
+    margin-bottom: 20px;
+    font-size: 0.95em;
+}
+.back-to-group a {
+    color: #007bff;
+    text-decoration: none;
+}
+.back-to-group a:hover {
+    text-decoration: underline;
+}
+
+.post-content-area, .comments-section, .edit-post-form {
+  text-align: left;
+}
+.post-detail-title {
+  font-size: 2em;
+  margin-bottom: 10px;
+  color: #333;
+  word-wrap: break-word;
+}
+.post-detail-meta {
+  display: flex;
+  align-items: center;
+  gap: 15px; /* 间距 */
+  font-size: 0.9em;
+  color: #666;
+  margin-bottom: 25px;
+  flex-wrap: wrap; /* 换行 */
+}
+.author-info {
+    display: flex;
+    align-items: center;
+}
+.author-avatar-small, .author-avatar-placeholder-small {
+  width: 32px;
+  height: 32px;
+  border-radius: 50%;
+  margin-right: 8px;
+  object-fit: cover;
+  background-color: #f0f0f0;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  font-weight: bold;
+  color: #777;
+}
+.author-name {
+  font-weight: 600;
+}
+.timestamp {
+    white-space: nowrap;
+}
+.post-detail-body {
+  font-size: 1.05em;
+  line-height: 1.7;
+  color: #333;
+  white-space: pre-wrap; /* 保留文本中的换行和空格 */
+  word-wrap: break-word;
+  margin-bottom: 30px;
+}
+.post-actions {
+    display: flex;
+    align-items: center;
+    gap: 15px;
+    margin-bottom: 20px;
+    padding-top: 15px;
+    border-top: 1px solid #eee;
+}
+.like-button {
+    display: inline-flex;
+    align-items: center;
+    padding: 8px 15px;
+    border: 1px solid #ccc;
+    background-color: #f8f8f8;
+    color: #555;
+    border-radius: 20px; /* 圆角按钮 */
+    cursor: pointer;
+    font-size: 0.9em;
+    transition: background-color 0.2s, color 0.2s;
+}
+.like-button svg {
+    margin-right: 6px;
+}
+.like-button.liked {
+    background-color: #ffe0e6; /* 点赞后的背景色 */
+    color: #e50040; /* 点赞后的颜色 (例如红色) */
+    border-color: #ffc2ce;
+}
+.like-button:hover:not(.liked) {
+    background-color: #eee;
+}
+.author-actions button {
+    font-size: 0.85em;
+    padding: 6px 10px;
+    margin-left: 10px;
+    border-radius: 4px;
+    cursor: pointer;
+}
+.button-edit { background-color: #ffc107; color: #333; border: none;}
+.button-delete { background-color: #dc3545; color: white; border: none;}
+
+.divider { margin: 30px 0; border: 0; border-top: 1px solid #eee; }
+
+.comments-section h3 {
+  font-size: 1.4em;
+  margin-bottom: 20px;
+  padding-bottom: 10px;
+  border-bottom: 1px solid #eee;
+}
+.add-comment-form {
+  margin-bottom: 30px;
+}
+.add-comment-form .form-group {
+  margin-bottom: 10px;
+}
+.add-comment-form textarea {
+  width: 100%;
+  min-height: 70px;
+  padding: 10px;
+  border: 1px solid #ccc;
+  border-radius: 4px;
+  box-sizing: border-box;
+  resize: vertical;
+}
+.add-comment-form button[type="submit"] {
+  padding: 8px 18px;
+  background-color: #007bff;
+  color: white;
+  border: none;
+  border-radius: 4px;
+  cursor: pointer;
+}
+.add-comment-form button:disabled {
+    background-color: #aaa;
+}
+
+.comment-list {
+  margin-top: 20px;
+}
+.comment-item {
+  padding: 15px;
+  margin-bottom: 15px;
+  background-color: #f9f9f9;
+  border: 1px solid #e7e7e7;
+  border-radius: 6px;
+}
+.comment-header {
+  display: flex;
+  align-items: center;
+  margin-bottom: 8px;
+}
+.comment-author-avatar, .comment-author-avatar-placeholder {
+  width: 30px;
+  height: 30px;
+  border-radius: 50%;
+  margin-right: 10px;
+  object-fit: cover;
+  background-color: #e0e0e0;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  font-size: 0.9em;
+  font-weight: bold;
+  color: #777;
+}
+.comment-author-info {
+  display: flex;
+  flex-direction: column;
+  align-items: flex-start;
+}
+.comment-author-name {
+  font-weight: 600;
+  font-size: 0.9em;
+}
+.comment-created-at {
+  font-size: 0.75em;
+  color: #888;
+}
+.comment-content {
+  font-size: 0.95em;
+  line-height: 1.6;
+  color: #444;
+  white-space: pre-line;
+}
+.error-message.small-error {
+  font-size: 0.85em;
+  padding: 8px;
+  margin-top: 10px;
+}
+.edit-post-form {
+    margin-top: 30px;
+    padding: 20px;
+    background-color: #f9f9f9;
+    border: 1px solid #e7e7e7;
+    border-radius: 8px;
+}
+.edit-post-form h3 {
+    margin-top: 0;
+    margin-bottom: 20px;
+}
+.edit-post-form .form-group { margin-bottom: 15px; }
+.edit-post-form label { display: block; margin-bottom: 5px; font-weight: 500; }
+.edit-post-form input[type="text"], .edit-post-form textarea {
+    width: 100%;
+    padding: 10px;
+    border: 1px solid #ccc;
+    border-radius: 4px;
+    box-sizing: border-box;
+}
+.edit-post-form textarea { min-height: 100px; resize: vertical; }
+.edit-post-form .form-actions { margin-top: 20px; display: flex; gap: 10px; }
+.edit-post-form .form-actions button { padding: 8px 15px; }
+.edit-post-form .cancel-button { background-color: #eee; color: #333; }
+</style>

+ 302 - 0
tongqu_frontend/src/views/RecommendationsView.vue

@@ -0,0 +1,302 @@
+<template>
+  <div class="recommendations-container page-container">
+    <div class="header-bar">
+      <h2>为你推荐用户</h2>
+      <p class="header-subtitle">根据你的兴趣,我们为你推荐了这些用户:</p>
+    </div>
+
+    <div v-if="isLoading" class="loading-message">
+      <div class="spinner"></div>
+      正在加载推荐...
+    </div>
+    <div v-if="fetchError" class="error-message">
+      <p>{{ fetchError }}</p>
+      <button @click="fetchRecommendedUsers" class="retry-button">重试</button>
+    </div>
+    
+    <div v-if="!isLoading && recommendedUsers.length === 0 && !fetchError" class="empty-state">
+      <svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" fill="currentColor" class="bi bi-emoji-frown" viewBox="0 0 16 16" style="margin-bottom: 15px; color: #6c757d;">
+        <path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14m0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16"/>
+        <path d="M4.285 12.433a.5.5 0 0 0 .683-.183A3.5 3.5 0 0 1 8 10.5a3.5 3.5 0 0 1 3.032 1.75.5.5 0 0 0 .866-.5A4.5 4.5 0 0 0 8 9.5a4.5 4.5 0 0 0-3.898 2.25.5.5 0 0 0 .183.683M7 6.5C7 7.328 6.552 8 6 8s-1-.672-1-1.5S5.448 5 6 5s1 .672 1 1.5m4 0c0 .828-.448 1.5-1 1.5s-1-.672-1-1.5S9.448 5 10 5s1 .672 1 1.5"/>
+      </svg>
+      <p>暂时没有合适的推荐。</p>
+      <p>可以尝试<router-link :to="{ name: 'UserProfile' }">完善你的兴趣标签</router-link>,帮助我们更好地为你匹配!</p>
+    </div>
+
+    <div v-if="recommendedUsers.length > 0" class="users-grid">
+      <div v-for="user in recommendedUsers" :key="user.id" class="user-card">
+        <router-link :to="{ name: 'UserProfileById', params: { id: user.id } }" class="user-link">
+          <div class="user-avatar-wrapper">
+            <img 
+              v-if="user.avatar_url" 
+              :src="user.avatar_url" 
+              :alt="(user.nickname || user.email) + '头像'" 
+              class="user-avatar"
+              @error="setDefaultAvatar"
+            />
+            <div v-else class="user-avatar-placeholder">
+              <span>{{ user.nickname ? user.nickname.charAt(0).toUpperCase() : (user.email ? user.email.charAt(0).toUpperCase() : 'U') }}</span>
+            </div>
+          </div>
+          <div class="user-card-content">
+            <h3 class="user-nickname">{{ user.nickname || user.email }}</h3>
+            <p v-if="user.school" class="user-school">{{ user.school }}</p>
+            <div v-if="user.common_interests_tags && user.common_interests_tags.length > 0" class="common-tags-section">
+              <p class="common-tags-title">共同兴趣:</p>
+              <div class="common-tags-preview">
+                <span v-for="tag in user.common_interests_tags.slice(0, 3)" :key="tag.id" class="tag-chip">
+                  {{ tag.name }}
+                </span>
+                <span v-if="user.common_interests_tags.length > 3" class="tag-chip more-tags">...</span>
+              </div>
+            </div>
+             <p v-else class="no-common-tags">暂无明显共同兴趣</p>
+          </div>
+        </router-link>
+      </div>
+    </div>
+    
+    <!-- 推荐API通常不直接支持标准分页,除非后端特别实现 -->
+    <!-- 
+    <div v-if="pagination.totalPages > 1 && recommendedUsers.length > 0" class="pagination-controls">
+      <button @click="fetchRecommendedUsers(pagination.currentPage - 1)" :disabled="!pagination.previous || isLoading">上一页</button>
+      <span>第 {{ pagination.currentPage }} / {{ pagination.totalPages }}</span>
+      <button @click="fetchRecommendedUsers(pagination.currentPage + 1)" :disabled="!pagination.next || isLoading">下一页</button>
+    </div>
+    -->
+  </div>
+</template>
+
+<script setup>
+import { ref, onMounted, reactive } from 'vue';
+import apiClient from '../services/api'; // 确保路径正确
+import { useAuthStore } from '../store/auth'; // 确保路径正确
+import { useRouter } from 'vue-router'; // 如果需要跳转
+
+const recommendedUsers = ref([]);
+const isLoading = ref(true);
+const fetchError = ref('');
+const authStore = useAuthStore();
+const router = useRouter();
+
+// 推荐API可能没有标准分页,所以pagination对象可能不完全适用
+const pagination = reactive({
+    currentPage: 1,
+    // totalPages: 1, // 这些可能不需要了,取决于API
+    // next: null,
+    // previous: null,
+    // count: 0,
+});
+
+const fetchRecommendedUsers = async () => {
+  isLoading.value = true;
+  fetchError.value = '';
+  recommendedUsers.value = []; // 清空旧数据
+
+  if (!authStore.isAuthenticated) {
+    fetchError.value = '请先登录以获取个性化用户推荐。';
+    isLoading.value = false;
+    // 可以在这里引导用户去登录,或者依赖路由守卫
+    // router.push({name: 'Login', query: {redirect: router.currentRoute.value.fullPath }});
+    return;
+  }
+
+  try {
+    // 调用我们后端已经测试通过的推荐API
+    const response = await apiClient.get('accounts/users/recommendations/'); 
+
+    // 推荐API返回的是一个直接的用户对象列表,每个用户对象包含了 common_interests_tags
+    if (Array.isArray(response.data)) {
+        recommendedUsers.value = response.data;
+    } else {
+        console.warn("推荐API返回数据格式非预期 (期望数组):", response.data);
+        recommendedUsers.value = []; // 确保是数组
+        fetchError.value = '获取推荐用户失败,数据格式不正确。';
+    }
+    
+  } catch (error) {
+    console.error('获取推荐用户列表失败:', error.response || error);
+    if (error.response && error.response.status === 401) { // 未认证或Token过期
+        fetchError.value = '会话已过期或无效,请重新登录以获取推荐。';
+        // 触发登出并跳转到登录页是更完整的处理
+        // authStore.logout();
+        // router.push('/login');
+    } else {
+        fetchError.value = '无法加载推荐用户,请稍后重试。';
+    }
+  } finally {
+    isLoading.value = false;
+  }
+};
+
+const setDefaultAvatar = (event) => {
+  // 尝试获取父级的占位符并显示
+  const wrapper = event.target.closest('.user-avatar-wrapper');
+  if(wrapper) {
+    const placeholder = wrapper.querySelector('.user-avatar-placeholder');
+    if (placeholder) placeholder.style.display = 'flex';
+  }
+  event.target.style.display = 'none'; // 隐藏加载失败的img
+};
+
+onMounted(() => {
+  fetchRecommendedUsers();
+});
+</script>
+
+<style scoped>
+.page-container {
+  padding-bottom: 40px; /* 为页脚留出空间 */
+}
+.recommendations-container {
+  max-width: 1000px;
+  margin: 20px auto;
+  padding: 20px;
+}
+.header-bar {
+  text-align: center;
+  margin-bottom: 30px;
+}
+.header-bar h2 {
+  font-size: 2em;
+  color: #333;
+  margin-bottom: 5px;
+}
+.header-subtitle {
+  font-size: 1em;
+  color: #666;
+}
+
+.users-grid {
+  display: grid;
+  grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); /* 调整卡片最小/最大宽度 */
+  gap: 25px;
+}
+.user-card {
+  border: 1px solid #e9ecef; /* 更淡的边框 */
+  border-radius: 10px;
+  background-color: #ffffff;
+  padding: 20px;
+  text-align: center;
+  transition: transform 0.25s ease-in-out, box-shadow 0.25s ease-in-out;
+  display: flex;
+  flex-direction: column; /* 确保内容垂直排列 */
+}
+.user-card:hover {
+  transform: translateY(-6px);
+  box-shadow: 0 10px 25px rgba(0, 20, 60, 0.1); /* 更明显的阴影 */
+}
+.user-link {
+  text-decoration: none;
+  color: inherit;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  flex-grow: 1; /* 使链接填满卡片 */
+}
+.user-avatar-wrapper {
+  margin-bottom: 15px;
+}
+.user-avatar, .user-avatar-placeholder {
+  width: 100px; /* 稍大一点的头像 */
+  height: 100px;
+  border-radius: 50%;
+  object-fit: cover;
+  background-color: #f8f9fa;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  font-size: 2.8em;
+  font-weight: 300; /* 稍细的占位符文字 */
+  color: #adb5bd;
+  border: 3px solid #fff;
+  box-shadow: 0 2px 5px rgba(0,0,0,0.08);
+}
+.user-card-content {
+  text-align: center;
+  flex-grow: 1; /* 确保内容区也参与flex布局 */
+}
+.user-nickname {
+  font-size: 1.2em;
+  font-weight: 600;
+  margin-bottom: 5px;
+  color: #212529;
+}
+.user-school {
+  font-size: 0.9em;
+  color: #6c757d;
+  margin-bottom: 10px;
+}
+.common-tags-section {
+  margin-top: 10px;
+}
+.common-tags-title {
+  font-size: 0.8em;
+  color: #495057;
+  margin-bottom: 5px;
+  font-weight: 500;
+}
+.common-tags-preview {
+  display: flex;
+  flex-wrap: wrap;
+  justify-content: center;
+  gap: 6px;
+}
+.tag-chip {
+  background-color: #007bff; /* 蓝色标签 */
+  color: white;
+  font-size: 0.75em;
+  padding: 4px 10px;
+  border-radius: 15px;
+  white-space: nowrap;
+}
+.tag-chip.more-tags {
+    background-color: #6c757d; /* 灰色 '...' */
+}
+.no-common-tags {
+    font-size: 0.8em;
+    color: #adb5bd;
+    margin-top: 10px;
+    font-style: italic;
+}
+
+.loading-message, .error-message, .empty-state {
+  text-align: center;
+  padding: 50px 20px;
+  color: #6c757d;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+}
+.empty-state p {
+  margin-bottom: 8px;
+}
+.empty-state a {
+  color: #007bff;
+  font-weight: 500;
+}
+.spinner {
+  border: 4px solid #f3f3f3; /* Light grey */
+  border-top: 4px solid #3498db; /* Blue */
+  border-radius: 50%;
+  width: 30px;
+  height: 30px;
+  animation: spin 1s linear infinite;
+  margin-bottom: 15px;
+}
+@keyframes spin {
+  0% { transform: rotate(0deg); }
+  100% { transform: rotate(360deg); }
+}
+.retry-button {
+    margin-top: 15px;
+    padding: 8px 16px;
+    background-color: #007bff;
+    color: white;
+    border: none;
+    border-radius: 4px;
+    cursor: pointer;
+}
+/* 移除分页控件的样式,因为推荐API可能不分页 */
+</style>

+ 129 - 0
tongqu_frontend/src/views/RegisterView.vue

@@ -0,0 +1,129 @@
+<template>
+  <div class="register-container">
+    <h2>用户注册</h2>
+    <form @submit.prevent="handleRegister" class="register-form">
+      <div class="form-group">
+        <label for="email">邮箱:</label>
+        <input type="email" id="email" v-model="formData.email" required />
+      </div>
+      <div class="form-group">
+        <label for="phone_number">手机号:</label>
+        <input type="tel" id="phone_number" v-model="formData.phone_number" required />
+      </div>
+      <div class="form-group">
+        <label for="nickname">昵称:</label>
+        <input type="text" id="nickname" v-model="formData.nickname" required />
+      </div>
+      <div class="form-group">
+        <label for="password">密码:</label>
+        <input type="password" id="password" v-model="formData.password" required />
+      </div>
+      <div class="form-group">
+        <label for="password2">确认密码:</label>
+        <input type="password" id="password2" v-model="formData.password2" required />
+      </div>
+      <div class="form-group">
+        <label for="school">学校 (可选):</label>
+        <input type="text" id="school" v-model="formData.school" />
+      </div>
+      <div class="form-group">
+        <label for="bio">简介 (可选):</label>
+        <textarea id="bio" v-model="formData.bio"></textarea>
+      </div>
+      <div class="form-group">
+        <label for="avatar">头像 (可选):</label>
+        <input type="file" id="avatar" @change="handleFileChange" accept="image/*" />
+      </div>
+      <button type="submit" :disabled="isLoading">
+        {{ isLoading ? '注册中...' : '注册' }}
+      </button>
+      <p v-if="errorMessage" class="error-message">{{ errorMessage }}</p>
+      <p v-if="successMessage" class="success-message">{{ successMessage }}</p>
+    </form>
+    <p class="login-link">
+      已有账号? <router-link to="/login">立即登录</router-link>
+    </p>
+  </div>
+</template>
+<script setup>
+import { reactive, ref } from 'vue';
+// import apiClient from '../services/api'; // 现在注册逻辑在store中
+import { useRouter } from 'vue-router';
+import { useAuthStore } from '../store/auth';
+
+const formData = reactive({
+  email: '',
+  phone_number: '',
+  nickname: '',
+  password: '',
+  password2: '',
+  school: '',
+  bio: '',
+  avatar: null,
+});
+
+const errorMessage = ref('');
+const successMessage = ref('');
+const isLoading = ref(false);
+const router = useRouter();
+const authStore = useAuthStore();
+
+const handleFileChange = (event) => {
+  const file = event.target.files[0];
+  formData.avatar = file || null;
+};
+
+const handleRegister = async () => {
+  isLoading.value = true;
+  errorMessage.value = '';
+  successMessage.value = '';
+  authStore.loginError = null; // 清除 store 中的错误
+
+  const submissionData = new FormData();
+  Object.keys(formData).forEach(key => {
+    if (formData[key] !== null && formData[key] !== '') { // 只添加有值的字段
+      submissionData.append(key, formData[key]);
+    }
+  });
+  // 如果密码为空,后端会验证,这里确保至少传了
+  if (!formData.password) submissionData.append('password', '');
+  if (!formData.password2) submissionData.append('password2', '');
+
+
+  try {
+    await authStore.register(submissionData); // 调用store的register action
+
+    successMessage.value = '注册成功!已自动登录,即将跳转到首页...';
+    setTimeout(() => {
+      router.push('/');
+    }, 2000);
+
+  } catch (error) {
+    errorMessage.value = error.message || '注册失败,发生了未知错误。';
+    console.error('Register component error:', error);
+  } finally {
+    isLoading.value = false;
+  }
+};
+</script>
+<style scoped>
+/* 样式与LoginView类似,你可以复用或自定义 */
+.register-container { max-width: 450px; margin: 30px auto; padding: 25px; border: 1px solid #ddd; border-radius: 8px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); }
+.register-form .form-group { margin-bottom: 12px; text-align: left; }
+.register-form label { display: block; margin-bottom: 4px; font-weight: bold; font-size: 0.9em; }
+.register-form input[type="email"],
+.register-form input[type="tel"],
+.register-form input[type="text"],
+.register-form input[type="password"],
+.register-form textarea { width: calc(100% - 22px); padding: 9px; border: 1px solid #ccc; border-radius: 4px; font-size: 0.95em; }
+.register-form input[type="file"] { font-size: 0.9em; }
+.register-form textarea { resize: vertical; min-height: 60px; }
+.register-form button { width: 100%; padding: 10px; background-color: #5cb85c; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 1em; transition: background-color 0.3s; margin-top: 10px; }
+.register-form button:disabled { background-color: #aaa; cursor: not-allowed; }
+.register-form button:hover:not(:disabled) { background-color: #4cae4c; }
+.error-message { color: red; margin-top: 10px; font-size: 0.9em; }
+.success-message { color: green; margin-top: 10px; font-size: 0.9em; }
+.login-link { margin-top: 15px; font-size: 0.9em; }
+.login-link a { color: #42b983; text-decoration: none; }
+.login-link a:hover { text-decoration: underline; }
+</style>

+ 181 - 0
tongqu_frontend/src/views/UserListView.vue

@@ -0,0 +1,181 @@
+<template>
+  <div class="user-list-container page-container">
+    <div class="header-bar">
+      <h2>发现用户 (推荐)</h2> <!-- 标题注明是推荐 -->
+    </div>
+
+    <div v-if="isLoading" class="loading-message">正在加载推荐用户...</div>
+    <div v-if="fetchError" class="error-message">{{ fetchError }}</div>
+    
+    <div v-if="!isLoading && users.length === 0 && !fetchError" class="empty-state">
+      <p>目前没有用户推荐给您。</p>
+      <p>尝试添加更多兴趣标签,或者稍后再来看看!</p>
+    </div>
+
+    <div v-if="users.length > 0" class="users-grid">
+      <div v-for="user_profile in users" :key="user_profile.id" class="user-card">
+        <router-link :to="{ name: 'UserProfileById', params: { id: user_profile.id } }" class="user-link">
+          <div class="user-avatar-wrapper">
+            <img 
+              v-if="user_profile.avatar_url" 
+              :src="user_profile.avatar_url" 
+              :alt="(user_profile.nickname || user_profile.email) + '头像'" 
+              class="user-avatar"
+              @error="setDefaultAvatar"
+            />
+            <div v-else class="user-avatar-placeholder">
+              <span>{{ user_profile.nickname ? user_profile.nickname.charAt(0).toUpperCase() : (user_profile.email ? user_profile.email.charAt(0).toUpperCase() : 'U') }}</span>
+            </div>
+          </div>
+          <div class="user-card-content">
+            <h3 class="user-nickname">{{ user_profile.nickname || user_profile.email }}</h3>
+            <p v-if="user_profile.school" class="user-school">{{ user_profile.school }}</p>
+            <!-- 显示共同兴趣标签 -->
+            <div v-if="user_profile.common_interests_tags && user_profile.common_interests_tags.length > 0" class="common-tags-preview">
+              <span v-for="tag in user_profile.common_interests_tags.slice(0,2)" :key="tag.id" class="tag-chip">
+                {{ tag.name }}
+              </span>
+              <span v-if="user_profile.common_interests_tags.length > 2" class="tag-chip">...</span>
+            </div>
+          </div>
+        </router-link>
+      </div>
+    </div>
+    
+    <!-- 推荐API通常不直接支持标准分页,除非后端特别实现 -->
+    <!-- 所以我们暂时移除分页控件,或者根据推荐API的实际返回来调整 -->
+    <!-- 
+    <div v-if="pagination.totalPages > 1 && users.length > 0" class="pagination-controls">
+      <button @click="fetchRecommendedUsers(pagination.currentPage - 1)" :disabled="!pagination.previous || isLoading">上一页</button>
+      <span>第 {{ pagination.currentPage }} / {{ pagination.totalPages }} 页 (共 {{ pagination.count }} 用户)</span>
+      <button @click="fetchRecommendedUsers(pagination.currentPage + 1)" :disabled="!pagination.next || isLoading">下一页</button>
+    </div>
+    -->
+  </div>
+</template>
+
+<script setup>
+import { ref, onMounted, reactive } from 'vue';
+import apiClient from '../services/api';
+import { useAuthStore } from '../store/auth';
+
+const users = ref([]); // 将存储推荐的用户列表
+const isLoading = ref(true);
+const fetchError = ref('');
+const authStore = useAuthStore(); // 推荐API通常需要认证
+
+// 推荐API可能没有标准分页,所以这里的pagination意义不大,除非后端特别实现
+const pagination = reactive({
+    currentPage: 1,
+    totalPages: 1,
+    next: null,
+    previous: null,
+    count: 0,
+});
+
+const fetchRecommendedUsers = async (page = 1) => { // page参数可能对推荐API无效
+  isLoading.value = true;
+  fetchError.value = '';
+  try {
+    // 使用推荐API
+    // 推荐API (/api/v1/accounts/users/recommendations/) 通常返回的是一个直接的列表,而不是分页对象
+    const response = await apiClient.get('accounts/users/recommendations/'); 
+
+    // 推荐API的响应结构可能直接是用户列表数组
+    // 并且它应该已经排除了当前用户
+    if (Array.isArray(response.data)) {
+        users.value = response.data;
+        pagination.count = response.data.length;
+        pagination.totalPages = 1; // 假设推荐列表不分页或一次性返回
+        pagination.currentPage = 1;
+        pagination.next = null;
+        pagination.previous = null;
+    } else if (response.data && typeof response.data.count !== 'undefined' && Array.isArray(response.data.results)) {
+        // 如果推荐API也返回了分页结构
+        users.value = response.data.results;
+        pagination.count = response.data.count;
+        pagination.next = response.data.next;
+        pagination.previous = response.data.previous;
+        pagination.currentPage = page;
+        const resultsLength = users.value.length;
+        const pageSize = resultsLength > 0 ? resultsLength : (pagination.count > 0 ? pagination.count : 10);
+        pagination.totalPages = Math.max(1, Math.ceil(pagination.count / pageSize));
+    } else {
+        console.warn("Unexpected response structure from recommendations API:", response.data);
+        users.value = [];
+        fetchError.value = '获取推荐用户失败,数据格式不正确。';
+    }
+    
+  } catch (error) {
+    console.error('获取推荐用户列表失败:', error.response || error);
+    fetchError.value = '无法加载推荐用户。';
+    if (error.response && error.response.status === 401) {
+        fetchError.value = '请先登录以获取用户推荐。';
+        // 可以考虑跳转到登录页
+        // authStore.logout();
+        // router.push('/login');
+    }
+  } finally {
+    isLoading.value = false;
+  }
+};
+
+// const truncateText = (text, maxLength) => { // 如果需要截断bio,取消注释
+//   if (!text) return '';
+//   if (text.length <= maxLength) return text;
+//   return text.substring(0, maxLength) + '...';
+// };
+
+const setDefaultAvatar = (event) => {
+  const placeholder = event.target.parentElement.querySelector('.user-avatar-placeholder');
+  if (placeholder) placeholder.style.display = 'flex';
+  event.target.style.display = 'none';
+};
+
+onMounted(() => {
+  if (authStore.isAuthenticated) { // 推荐API通常需要登录
+    fetchRecommendedUsers();
+  } else {
+    fetchError.value = '请先登录以查看用户推荐。';
+    isLoading.value = false;
+    // 可以考虑如果未登录则不加载此页面,或显示登录提示
+  }
+});
+</script>
+
+<style scoped>
+.user-list-container { max-width: 1000px; margin: 20px auto; padding: 20px; }
+.header-bar { display: flex; justify-content: space-between; align-items: center; margin-bottom: 25px; padding-bottom: 15px; border-bottom: 1px solid #e9ecef; }
+.header-bar h2 { margin: 0; font-size: 2em; color: #343a40; }
+.users-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 25px; }
+.user-card { border: 1px solid #dee2e6; border-radius: 8px; background-color: #fff; padding: 20px; text-align: center; transition: all 0.3s ease-in-out; display: flex; flex-direction: column; }
+.user-card:hover { transform: translateY(-5px); box-shadow: 0 8px 20px rgba(0,0,0,0.08); }
+.user-link { text-decoration: none; color: inherit; display: flex; flex-direction: column; align-items: center; flex-grow: 1; }
+.user-avatar-wrapper { margin-bottom: 12px; }
+.user-avatar, .user-avatar-placeholder { width: 90px; height: 90px; border-radius: 50%; object-fit: cover; margin: 0 auto; background-color: #f1f3f5; display: flex; align-items: center; justify-content: center; font-size: 2.5em; color: #ced4da; border: 2px solid #fff; box-shadow: 0 1px 3px rgba(0,0,0,0.1); }
+.user-card-content { text-align: center; flex-grow: 1; display: flex; flex-direction: column; justify-content: space-between; }
+.user-nickname { font-size: 1.15em; font-weight: 600; margin-bottom: 5px; color: #212529; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
+.user-school { font-size: 0.85em; color: #6c757d; margin-bottom: 8px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
+.common-tags-preview {
+    margin-top: 8px;
+    display: flex;
+    flex-wrap: wrap;
+    justify-content: center;
+    gap: 5px;
+}
+.tag-chip {
+    background-color: #e9ecef;
+    color: #495057;
+    font-size: 0.75em;
+    padding: 3px 8px;
+    border-radius: 12px;
+}
+.loading-message, .error-message, .empty-state { text-align: center; padding: 40px 20px; color: #555; }
+.empty-state a { color: #007bff; font-weight: bold; }
+.pagination-controls { margin-top: 30px; text-align: center; }
+.pagination-controls button { margin: 0 8px; padding: 8px 16px; cursor: pointer; border: 1px solid #ddd; background-color: #fff; border-radius: 4px; transition: background-color 0.2s; }
+.pagination-controls button:disabled { opacity: 0.6; cursor: not-allowed; }
+.pagination-controls button:hover:not(:disabled) { background-color: #e9ecef; }
+.pagination-controls span { margin: 0 10px; color: #495057; }
+.page-container { padding-bottom: 40px; }
+</style>

+ 521 - 0
tongqu_frontend/src/views/UserProfileView.vue

@@ -0,0 +1,521 @@
+<template>
+  <div class="profile-container">
+    <h2>我的资料</h2>
+    <div v-if="isLoading && !profileData && !fetchError" class="loading-message">正在加载用户资料...</div>
+    <div v-if="fetchError && !profileData" class="error-message">{{ fetchError }}</div>
+    
+    <div v-if="profileData && !isEditing" class="profile-details">
+      <div class="back-link" v-if="!isOwnProfile && viewingUserId">
+        <router-link :to="{ name: 'UserList' }">« 返回用户列表</router-link> <!-- 假设有UserList路由 -->
+      </div>
+      <div class="avatar-section">
+        <img v-if="profileData.avatar_url" :src="profileData.avatar_url" alt="User Avatar" class="avatar-preview">
+        <div v-else class="avatar-placeholder">无头像</div>
+      </div>
+      <h2 class="profile-nickname">{{ profileData.nickname || profileData.email }}</h2>
+      
+      <div class="profile-stats" v-if="!isOwnProfile"> <!-- 仅在查看他人资料时显示关注信息 -->
+        <span><strong>{{ profileData.followers_count || 0 }}</strong> 关注者</span>
+        <span><strong>{{ profileData.following_count || 0 }}</strong> 正在关注</span>
+      </div>
+
+      <p><strong>邮箱:</strong> {{ profileData.email }}</p>
+      <p><strong>手机号:</strong> {{ profileData.phone_number }}</p>
+      <p v-if="profileData.school"><strong>学校:</strong> {{ profileData.school }}</p>
+      <p v-if="profileData.bio"><strong>简介:</strong> {{ profileData.bio }}</p>
+      
+      <p><strong>兴趣标签:</strong></p>
+      <ul v-if="profileInterestsDetails.length > 0" class="interest-tags">
+        <li v-for="tag in profileInterestsDetails" :key="tag.id">
+          {{ tag.name }}
+        </li>
+      </ul>
+      <p v-else>{{ isOwnProfile ? '您' : '该用户' }}还没有选择兴趣标签。</p>
+
+      <div class="profile-actions">
+        <button v-if="isOwnProfile" @click="startEditMode" class="button-primary edit-profile-button">编辑我的资料</button>
+        <button 
+          v-if="!isOwnProfile && authStore.isAuthenticated" 
+          @click="toggleFollowUser" 
+          :disabled="isProcessingFollow"
+          :class="{
+            'button-follow': !isCurrentlyFollowed, 
+            'button-unfollow': isCurrentlyFollowed
+          }"
+        >
+          {{ isProcessingFollow ? '处理中...' : (isCurrentlyFollowed ? '取消关注' : '关注TA') }}
+        </button>
+      </div>
+      <p v-if="followError" class="error-message small-error">{{ followError }}</p>
+    </div>
+
+    <form v-if="isOwnProfile && isEditing" @submit.prevent="handleProfileUpdate" class="profile-form">
+      <h3>编辑我的资料</h3>
+      <div class="form-group">
+        <label for="edit-nickname">昵称:</label>
+        <input type="text" id="edit-nickname" v-model="editableUser.nickname" />
+      </div>
+      <div class="form-group">
+        <label for="edit-school">学校:</label>
+        <input type="text" id="edit-school" v-model="editableUser.school" />
+      </div>
+      <div class="form-group">
+        <label for="edit-bio">简介:</label>
+        <textarea id="edit-bio" v-model="editableUser.bio"></textarea>
+      </div>
+      <div class="form-group">
+        <label for="edit-avatar">更换头像 (可选):</label>
+        <input type="file" id="edit-avatar" @change="handleAvatarFileChange" accept="image/*" />
+        <div v-if="avatarPreviewUrl" class="avatar-preview-container">
+          <p>新头像预览:</p>
+          <img :src="avatarPreviewUrl" alt="New Avatar Preview" class="avatar-preview-small">
+        </div>
+         <div v-else-if="profileData && profileData.avatar_url && !editableUser.avatar" class="avatar-preview-container">
+          <p>当前头像:</p>
+          <img :src="profileData.avatar_url" alt="Current Avatar" class="avatar-preview-small">
+        </div>
+      </div>
+      <div class="form-group">
+        <label>兴趣标签:</label>
+        <div v-if="availableTags.length > 0" class="tags-selection">
+          <div v-for="tag in availableTags" :key="tag.id" class="tag-item">
+            <input type="checkbox" :id="'tag-edit-' + tag.id" :value="tag.id" v-model="editableUser.interests" />
+            <label :for="'tag-edit-' + tag.id">{{ tag.name }}</label>
+          </div>
+        </div>
+        <p v-else-if="isLoadingTags" class="loading-message-small">正在加载可选标签...</p>
+        <p v-else>暂无可选标签。</p>
+      </div>
+
+      <div class="form-actions">
+        <button type="submit" :disabled="isUpdating">
+          {{ isUpdating ? '更新中...' : '保存更改' }}
+        </button>
+        <button type="button" @click="cancelEditMode" class="cancel-button">取消</button>
+      </div>
+       <p v-if="updateErrorMessage" class="error-message">{{ updateErrorMessage }}</p>
+       <p v-if="updateSuccessMessage" class="success-message">{{ updateSuccessMessage }}</p>
+    </form>
+     <div v-if="!profileData && !isLoading && !fetchError" class="not-found-message">
+        <p>无法找到该用户,或者用户不存在。</p>
+        <router-link :to="{ name: 'UserList' }">返回用户列表</router-link> <!-- 假设有UserList路由 -->
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { ref, reactive, onMounted, computed, watch } from 'vue';
+import apiClient from '../services/api';
+import { useAuthStore } from '../store/auth';
+import { useRoute, useRouter } from 'vue-router';
+
+const authStore = useAuthStore();
+const route = useRoute();
+const router = useRouter();
+
+const profileData = ref(null); // 存储当前正在查看的用户的资料
+const isLoading = ref(true);
+const fetchError = ref('');
+
+const isEditing = ref(false);
+const editableUser = reactive({
+  nickname: '',
+  school: '',
+  bio: '',
+  avatar: null, 
+  interests: [], 
+});
+const avatarPreviewUrl = ref(null);
+
+const availableTags = ref([]);
+const isLoadingTags = ref(false);
+
+const isUpdating = ref(false);
+const updateErrorMessage = ref('');
+const updateSuccessMessage = ref('');
+
+// 关注相关状态
+const isCurrentlyFollowed = ref(false); // 当前登录用户是否关注了profileData对应的用户
+const isProcessingFollow = ref(false);
+const followError = ref('');
+
+// 当前查看的profile的用户ID
+const viewingUserId = computed(() => route.params.id ? parseInt(route.params.id) : null);
+// 是否是查看自己的profile
+const isOwnProfile = computed(() => {
+    if (!authStore.isAuthenticated || !authStore.currentUser) return false;
+    // 如果路由有ID,则比较ID;如果没有ID (即访问 /profile),则认为是自己的
+    return viewingUserId.value ? viewingUserId.value === authStore.currentUser.id : true;
+});
+
+
+const profileInterestsDetails = computed(() => {
+  if (profileData.value && profileData.value.interests && Array.isArray(profileData.value.interests) && availableTags.value.length > 0) {
+    return profileData.value.interests
+      .map(tagId => availableTags.value.find(t => t.id === tagId))
+      .filter(tag => tag !== undefined);
+  }
+  return [];
+});
+
+const fetchAvailableTags = async () => {
+  isLoadingTags.value = true;
+  try {
+    const response = await apiClient.get('accounts/tags/');
+    availableTags.value = response.data.results || response.data; 
+  } catch (error) {
+    console.error('获取兴趣标签失败:', error);
+    // fetchError.value = '无法加载可选兴趣标签。'; // 这个错误可能覆盖主profile的加载错误
+  } finally {
+    isLoadingTags.value = false;
+  }
+};
+
+const fetchUserProfileData = async (userIdToFetch) => {
+  isLoading.value = true;
+  fetchError.value = '';
+  profileData.value = null; // 清空旧数据
+  isCurrentlyFollowed.value = false; // 重置关注状态
+
+  try {
+    let url;
+    if (userIdToFetch && userIdToFetch !== authStore.currentUser?.id) { // 查看他人
+        url = `accounts/users/${userIdToFetch}/profile/`; // 假设获取他人profile的API
+    } else if (authStore.isAuthenticated) { // 查看自己
+        url = `accounts/profile/`;
+    } else { // 未登录不能查看profile
+        fetchError.value = '请先登录查看用户资料。';
+        isLoading.value = false;
+        if (route.name !== 'Login') router.push({ name: 'Login' }); // 如果不在登录页,则跳转
+        return;
+    }
+    
+    const response = await apiClient.get(url);
+    profileData.value = response.data;
+    
+    if (isOwnProfile.value && authStore.currentUser?.id === profileData.value.id) {
+        authStore.user = profileData.value; 
+        localStorage.setItem('user', JSON.stringify(profileData.value));
+    }
+
+    // 更新关注按钮状态 (假设后端返回了 is_followed_by_current_user)
+    if (profileData.value && typeof profileData.value.is_followed_by_current_user === 'boolean') {
+        isCurrentlyFollowed.value = profileData.value.is_followed_by_current_user;
+    } else if (!isOwnProfile.value && authStore.isAuthenticated) {
+        isCurrentlyFollowed.value = false; // 如果API没返回,先假设未关注
+    }
+
+  } catch (error) {
+    console.error(`获取用户ID ${userIdToFetch} 资料失败:`, error.response || error);
+    if (error.response && error.response.status === 404) {
+        fetchError.value = '未找到该用户。';
+    } else if (error.response && error.response.status === 401) {
+        fetchError.value = '请先登录。';
+        authStore.logout(); // 清除可能无效的token
+        router.push({ name: 'Login', query: { redirect: route.fullPath } });
+    } else {
+        fetchError.value = '无法加载用户资料,请稍后重试。';
+    }
+  } finally {
+    isLoading.value = false;
+  }
+};
+
+
+const startEditMode = () => {
+  if (profileData.value && isOwnProfile.value) {
+    editableUser.nickname = profileData.value.nickname || '';
+    editableUser.school = profileData.value.school || '';
+    editableUser.bio = profileData.value.bio || '';
+    editableUser.avatar = null;
+    avatarPreviewUrl.value = null;
+    editableUser.interests = profileData.value.interests && Array.isArray(profileData.value.interests) ? [...profileData.value.interests] : [];
+    isEditing.value = true;
+    updateErrorMessage.value = '';
+    updateSuccessMessage.value = '';
+  }
+};
+
+const cancelEditMode = () => {
+  isEditing.value = false;
+  avatarPreviewUrl.value = null;
+  editableUser.avatar = null;
+};
+
+const handleAvatarFileChange = (event) => {
+  const file = event.target.files[0];
+  if (file) {
+    editableUser.avatar = file;
+    const reader = new FileReader();
+    reader.onload = (e) => {
+      avatarPreviewUrl.value = e.target.result;
+    };
+    reader.readAsDataURL(file);
+  } else {
+    editableUser.avatar = null;
+    avatarPreviewUrl.value = null;
+  }
+};
+
+const handleProfileUpdate = async () => {
+  if (!isOwnProfile.value) return; // 安全检查
+
+  isUpdating.value = true;
+  updateErrorMessage.value = '';
+  updateSuccessMessage.value = '';
+
+  const formDataToSubmit = new FormData();
+  let hasActualChanges = false; // 标记是否有实际更改
+
+  // 比较并添加已更改的字段
+  if (editableUser.nickname !== (profileData.value?.nickname || '')) {
+    formDataToSubmit.append('nickname', editableUser.nickname);
+    hasActualChanges = true;
+  }
+  if (editableUser.school !== (profileData.value?.school || '')) {
+    formDataToSubmit.append('school', editableUser.school);
+    hasActualChanges = true;
+  }
+  if (editableUser.bio !== (profileData.value?.bio || '')) {
+    formDataToSubmit.append('bio', editableUser.bio);
+    hasActualChanges = true;
+  }
+  if (editableUser.avatar) {
+    formDataToSubmit.append('avatar', editableUser.avatar);
+    hasActualChanges = true;
+  }
+  
+  const originalInterestsStr = profileData.value?.interests?.slice().sort().join(',') || '';
+  const newInterestsStr = editableUser.interests?.slice().sort().join(',') || '';
+
+  if (originalInterestsStr !== newInterestsStr) {
+    hasActualChanges = true;
+    // 对于FormData,如果interests是空数组,DRF的PrimaryKeyRelatedField(many=True, allow_empty=True)
+    // 在解析时,如果请求中没有名为'interests'的参数,它会认为这个字段没有被发送,
+    // PATCH操作时就不会修改它。如果想清空,客户端必须发送一个能被解析为空列表的'interests'参数。
+    // 一种hacky方式是发送一个空的'interests'参数 `formDataToSubmit.append('interests', '');`
+    // 然后后端序列化器需要特殊处理这个空字符串为清空操作。
+    // 更标准的做法是,如果前端要清空,就发送一个空的列表。
+    // 当 editableUser.interests 是 [] 时,下面的 forEach 不会执行。
+    if (editableUser.interests && Array.isArray(editableUser.interests)) {
+        if (editableUser.interests.length > 0) {
+            editableUser.interests.forEach(tagId => {
+                formDataToSubmit.append('interests', String(tagId));
+            });
+        } else {
+            // 如果列表为空,并且我们想明确告诉后端清空,可以发送一个特殊的空值
+            // 但这依赖于后端API对空M2M字段的处理。
+            // UserProfileSerializer的update方法中,如果interests_data是[],会执行set([])
+            // 所以我们需要确保后端能收到空列表。对于FormData,这意味着不发送任何interests参数,
+            // 或者发送一个空的interests参数,例如`formDataToSubmit.append('interests', '');`
+            // 这需要后端序列化器能识别这个空字符串为清空。
+            // 最简单的方式:如果interests为空,就不发送。这意味着不能通过此接口清空。
+            // **修改:如果想清空,我们必须发送一个'interests'参数,使其在validated_data中出现。**
+            // **即使是空列表,也要确保'interests'这个key在。对于FormData,发送一个空值的同名参数。**
+            formDataToSubmit.append('interests', ''); // 发送一个空参数表示意图清空或设置为空列表
+                                                      // 后端 ListField (PrimaryKeyRelatedField是其子类)
+                                                      // 在处理表单数据时,如果参数值是空字符串,
+                                                      // 并且 allow_empty=True,它会将其视为空列表。
+        }
+    }
+  }
+
+  // 如果没有任何实际更改的字段被添加到FormData,则不发送请求
+  let formHasData = false;
+  for (let pair of formDataToSubmit.entries()) { // entries() 返回 [key, value] 对
+      formHasData = true;
+      break;
+  }
+
+  if (!formHasData && !hasActualChanges) { // 双重检查,确保真的有东西要更新
+    updateSuccessMessage.value = '没有需要更新的内容。';
+    isEditing.value = false;
+    isUpdating.value = false;
+    return;
+  }
+
+  try {
+    const response = await apiClient.patch('accounts/profile/', formDataToSubmit);
+    console.log('资料更新成功:', response.data);
+    updateSuccessMessage.value = '资料更新成功!';
+    authStore.user = response.data;
+    profileData.value = { ...response.data }; // 更新页面显示的profileData
+    localStorage.setItem('user', JSON.stringify(response.data));
+    isEditing.value = false;
+    setTimeout(() => updateSuccessMessage.value = '', 3000); // 3秒后清除成功消息
+  } catch (error) {
+    console.error('资料更新失败:', error.response ? error.response.data : error.message);
+    updateErrorMessage.value = error.response?.data ? 
+        (Object.values(error.response.data).flat().join('; ') || '资料更新失败,请检查您的输入。')
+        : '更新时发生网络错误或服务器无响应。';
+  } finally {
+    isUpdating.value = false;
+  }
+};
+
+const toggleFollowUser = async () => {
+    if (!authStore.isAuthenticated || isOwnProfile.value || !profileData.value) return;
+    isProcessingFollow.value = true;
+    followError.value = '';
+    try {
+        // 后端API应该是 POST /api/v1/accounts/users/<id_to_follow_or_unfollow>/toggle-follow/
+        // 并且后端应该返回更新后的目标用户信息,包含新的 followers_count 和 is_followed_by_current_user
+        const response = await apiClient.post(`accounts/users/${viewingUserId.value}/toggle-follow/`);
+        profileData.value = response.data; // 用后端返回的完整数据更新profileData
+        isCurrentlyFollowed.value = profileData.value.is_followed_by_current_user; // 更新关注状态
+    } catch (error) {
+        console.error('关注/取消关注失败:', error);
+        followError.value = error.response?.data?.detail || '操作失败,请重试。';
+    } finally {
+        isProcessingFollow.value = false;
+    }
+};
+
+
+// 初始化加载逻辑
+const loadInitialData = async () => {
+    isLoading.value = true;
+    fetchError.value = '';
+    await fetchAvailableTags(); // 先加载可选标签
+
+    const idToFetch = viewingUserId.value || authStore.currentUser?.id;
+
+    if (idToFetch) {
+        await fetchUserProfileData(idToFetch);
+    } else if (route.meta.requiresAuth) { // 如果是受保护路由但无法确定用户ID (例如直接访问/profile但未登录)
+        fetchError.value = "请先登录以查看此页面。";
+        // 路由守卫应该已经处理了跳转,但以防万一
+        if (authStore.isAuthenticated) { // 已认证但currentUser还未加载
+             await authStore.fetchUserProfile(); // 尝试加载当前用户信息
+             if (authStore.currentUser) {
+                 await fetchUserProfileData(authStore.currentUser.id);
+             } else {
+                 fetchError.value = "无法加载您的用户资料,请尝试重新登录。";
+             }
+        } else if (route.name !== 'Login') {
+            router.push({ name: 'Login', query: { redirect: route.fullPath }});
+        }
+    } else {
+        fetchError.value = "无法确定要加载的用户资料。";
+    }
+    isLoading.value = false;
+};
+
+onMounted(loadInitialData);
+
+watch(() => route.params.id, (newId) => {
+  // 当路由参数变化时 (例如从一个用户资料页跳转到另一个)
+  const idToFetch = newId ? parseInt(newId) : (isOwnProfile.value ? authStore.currentUser?.id : null);
+  if (idToFetch && idToFetch !== profileData.value?.id) { // 只有当ID真的变了才重新加载
+    loadInitialData(); // 重新加载所有数据
+  } else if (!newId && route.name === 'UserProfile' && authStore.currentUser && profileData.value?.id !== authStore.currentUser.id) {
+    // 从他人profile页导航到自己的/profile页
+    loadInitialData();
+  }
+}, { immediate: true }); // immediate: true 确保组件挂载时如果已有params.id就立即执行一次
+
+watch(() => authStore.currentUser, (newUser, oldUser) => {
+    // 当Pinia store中的currentUser变化时(例如登录、登出、或token刷新后重新获取了profile)
+    if (isOwnProfile.value && newUser && (!oldUser || newUser.id !== oldUser.id || JSON.stringify(newUser) !== JSON.stringify(profileData.value))) {
+        // 如果是自己的profile页,并且store中的用户数据更新了,同步到profileData
+        // (避免在编辑模式下覆盖用户输入)
+        if (!isEditing.value) {
+            profileData.value = { ...newUser };
+        }
+    } else if (isOwnProfile.value && !newUser && oldUser) {
+        // 用户登出了,但还在自己的profile页(虽然路由守卫应该会跳转)
+        profileData.value = null;
+        fetchError.value = "您已退出登录。";
+    }
+}, { deep: true });
+
+</script>
+
+<style scoped>
+/* 样式与之前版本类似,你可以根据需要调整 */
+.profile-container { max-width: 700px; margin: 30px auto; padding: 20px; background-color: #fff; border-radius: 12px; box-shadow: 0 6px 20px rgba(0,0,0,0.08); }
+.loading-message { text-align: center; padding: 20px; color: #555; }
+.loading-message-small { font-size: 0.9em; color: #777; }
+.error-message { background-color: #ffebee; color: #c62828; padding: 12px; margin-bottom: 15px; border-radius: 4px; font-size: 0.9em; border: 1px solid #ef9a9a; }
+.success-message { background-color: #e8f5e9; color: #2e7d32; padding: 12px; margin-bottom: 15px; border-radius: 4px; font-size: 0.9em; border: 1px solid #a5d6a7;}
+
+.profile-details { text-align: left; padding: 10px; }
+.back-link { margin-bottom: 20px; font-size: 0.9em; }
+.back-link a { color: #007bff; text-decoration: none; }
+.back-link a:hover { text-decoration: underline; }
+
+.profile-details .avatar-section { text-align: center; margin-bottom: 20px; }
+.profile-details h2.profile-nickname { text-align: center; font-size: 1.8em; margin-bottom: 10px; color: #333;}
+.profile-details p { margin: 12px 0; line-height: 1.7; color: #454545; }
+.profile-details strong { display: inline-block; min-width: 80px; color: #333; font-weight: 600; }
+.avatar-preview { width: 140px; height: 140px; border-radius: 50%; object-fit: cover; border: 4px solid #fff; box-shadow: 0 4px 8px rgba(0,0,0,0.15); }
+.avatar-placeholder { width: 140px; height: 140px; border-radius: 50%; background-color: #e9ecef; display: flex; align-items: center; justify-content: center; color: #adb5bd; border: 4px solid #fff; box-shadow: 0 4px 8px rgba(0,0,0,0.1); font-size: 2.5em; font-weight: bold;}
+.interest-tags { list-style: none; padding: 0; display: flex; flex-wrap: wrap; gap: 10px; margin-top: 8px;}
+.interest-tags li { background-color: #e0e0e0; color: #333; padding: 5px 12px; border-radius: 15px; font-size: 0.9em; }
+.edit-profile-button { /* 重命名以区分 */
+    display: inline-block; /* 改为inline-block以便与其他按钮并排 */
+    padding: 10px 20px;
+    background-color: #6c757d; /* 中性色 */
+    color: white;
+    border: none;
+    border-radius: 5px;
+    cursor: pointer;
+    font-size: 0.95em;
+    margin-top: 10px;
+}
+.edit-profile-button:hover { background-color: #5a6268; }
+
+.profile-stats { display: flex; gap: 25px; margin: 20px 0; justify-content: center; font-size: 0.95em; color: #555; padding-bottom: 20px; border-bottom: 1px solid #eee;}
+.profile-stats span { text-align: center;}
+.profile-stats span strong { font-size: 1.4em; color: #333; display: block; margin-bottom: 3px; }
+
+.profile-actions { margin-top: 20px; text-align: center; }
+.profile-actions button {
+    margin: 0 8px;
+    padding: 10px 20px;
+    border-radius: 5px;
+    font-weight: 500;
+    cursor: pointer;
+    transition: background-color 0.2s, color 0.2s, border-color 0.2s;
+}
+.button-follow { background-color: #007bff; color: white; border: 1px solid #007bff;}
+.button-follow:hover { background-color: #0056b3; border-color: #0056b3;}
+.button-unfollow { background-color: #f8f9fa; color: #343a40; border: 1px solid #ced4da;}
+.button-unfollow:hover { background-color: #e2e6ea; border-color: #b1bac1;}
+.button-follow:disabled, .button-unfollow:disabled { opacity: 0.7; cursor: not-allowed; }
+
+
+.profile-form h3 { margin-top: 0; margin-bottom: 25px; border-bottom: 1px solid #eee; padding-bottom: 10px; color: #333; }
+.profile-form .form-group { margin-bottom: 20px; text-align: left; }
+.profile-form label { display: block; margin-bottom: 8px; font-weight: 600; color: #495057; }
+.profile-form input[type="text"],
+.profile-form input[type="file"],
+.profile-form textarea {
+  width: 100%;
+  padding: 12px;
+  border: 1px solid #ced4da;
+  border-radius: 4px;
+  box-sizing: border-box;
+  font-size: 1em;
+  transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;
+}
+.profile-form input:focus, .profile-form textarea:focus {
+    border-color: #80bdff;
+    outline: 0;
+    box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.25);
+}
+.profile-form input[type="file"] { padding: 9px; }
+.profile-form textarea { min-height: 100px; resize: vertical; }
+.avatar-preview-container { margin-top: 10px; text-align: left; }
+.avatar-preview-small { width: 80px; height: 80px; border-radius: 50%; object-fit: cover; border: 1px solid #ddd; margin-top: 5px; display: block; }
+.tags-selection { display: flex; flex-wrap: wrap; gap: 10px 15px; padding: 10px; border: 1px solid #eee; border-radius: 4px; margin-top: 5px; background-color: #fdfdfd;}
+.tag-item { display: flex; align-items: center; }
+.tag-item input[type="checkbox"] { margin-right: 6px; transform: scale(1.1); cursor: pointer; }
+.tag-item label { font-weight: normal; cursor: pointer; color: #333; }
+.form-actions { margin-top: 30px; display: flex; gap: 10px; justify-content: flex-start; }
+.form-actions button { padding: 10px 20px; border: none; border-radius: 5px; cursor: pointer; font-size: 1em; font-weight: 500; }
+.form-actions button[type="submit"] { background-color: #28a745; color: white; }
+.form-actions button[type="submit"]:disabled { background-color: #aaa; }
+.form-actions .cancel-button { background-color: #6c757d; color: white; }
+.form-actions .cancel-button:hover { background-color: #5a6268; }
+</style>