Browse Source

基础信息修改

flower_bs 1 month ago
parent
commit
5e2dcfe455
68 changed files with 1065 additions and 268 deletions
  1. BIN
      media/avatars/0f2c0a3dad2a4522a7caf9e32d5f393c.png
  2. BIN
      media/avatars/1d2382f9bedc4d239d66edec3e5ac7f6.png
  3. BIN
      media/avatars/278e8a08e1c247c28074323bb4be890b.png
  4. BIN
      media/avatars/2807e8915ff54636b8df44d029aaea5b.png
  5. BIN
      media/avatars/36c1eb7c34a04e0a99c9decb2bcafba1.png
  6. BIN
      media/avatars/42d8f7dfadda416487f5b4a8e8db1259.png
  7. BIN
      media/avatars/5a25d20953ce478f8b4e654cef1925b4.png
  8. BIN
      media/avatars/5ce21eda2da24d35b122113a66f6a4fc.png
  9. BIN
      media/avatars/6667b6e321784f829185b58bcba1a9a6.png
  10. BIN
      media/avatars/6d07ded24bbc4bc1bda0cd33ce000808.png
  11. BIN
      media/avatars/6ebb3ae2d54d45a291af3b44d9ec22bd.png
  12. BIN
      media/avatars/71b51cf5699c415d9310c9357c8353b1.png
  13. BIN
      media/avatars/8347229e81c443ec857b85f734140456.png
  14. BIN
      media/avatars/a5b3c26947ff4667ae093a87e7615bd4.png
  15. BIN
      media/avatars/b0dda629b6c547f6a35d1af0bc6219b8.png
  16. BIN
      media/avatars/bd7d8a712e1b41c9a79dc47ef3e3b469.png
  17. BIN
      media/avatars/c270de75f9214c7cbdd3063185bd21f9.png
  18. BIN
      media/avatars/ddfe749db3b048f5ae6a440397ea157f.png
  19. BIN
      media/avatars/e8cf6af5ff7b45379762b9692dc463c8.png
  20. BIN
      media/avatars/eac9a7bd0c414733b66e1a45eeb52fb8.png
  21. BIN
      media/avatars/f1d3a120455b4a8a8b9f79879d74e940.png
  22. BIN
      media/avatars/f2b877c6e063404abf9854653170660a.png
  23. BIN
      media/avatars/f46c84eb2b6b4e5f94ec36457543f58b.png
  24. BIN
      media/avatars/ffceede880e7418cb18560a6db1f18d2.png
  25. BIN
      media/static/img/GreaterWMS.png
  26. BIN
      media/static/img/GreaterWMS_en.png
  27. BIN
      media/static/img/alipay.jpg
  28. BIN
      media/static/img/contact.png
  29. BIN
      media/static/img/dongtai.png
  30. BIN
      media/static/img/dongtai1.png
  31. BIN
      media/static/img/github.png
  32. BIN
      media/static/img/logo.png
  33. BIN
      media/static/img/logout.png
  34. BIN
      media/static/img/mobile_dn.jpg
  35. BIN
      media/static/img/mobile_dn_en.jpg
  36. BIN
      media/static/img/mobile_equ.jpg
  37. BIN
      media/static/img/mobile_equ_en.jpg
  38. BIN
      media/static/img/mobile_splash.jpg
  39. BIN
      media/static/img/money.png
  40. BIN
      media/static/img/photo.png
  41. BIN
      media/static/img/profile.png
  42. BIN
      media/static/img/register.png
  43. BIN
      media/static/img/user.jpg
  44. BIN
      media/static/img/user.png
  45. BIN
      media/static/img/video.png
  46. BIN
      media/static/img/wechat.jpg
  47. 2 1
      requirements.txt
  48. BIN
      static/img/Katie.jpg
  49. 119 78
      templates/src/boot/axios_request.js
  50. 1 1
      templates/src/components/Breadcrumb/index.vue
  51. 88 59
      templates/src/components/ImageCropper/index.vue
  52. 118 0
      templates/src/components/date/index.js
  53. 3 0
      templates/src/store/getters.js
  54. 22 4
      templates/src/store/modules/user.js
  55. 6 2
      templates/src/views/components-demo/avatar-upload.vue
  56. 4 4
      templates/src/views/login/index.vue
  57. 412 13
      templates/src/views/profile/components/Account.vue
  58. 15 12
      templates/src/views/profile/components/UserCard.vue
  59. 19 14
      templates/src/views/profile/index.vue
  60. 3 1
      userprofile/filter.py
  61. 13 6
      userprofile/migrations/0001_initial.py
  62. 0 27
      userprofile/migrations/0002_alter_academicprofile_user_and_more.py
  63. 0 17
      userprofile/migrations/0003_alter_academicprofile_table.py
  64. 34 2
      userprofile/models.py
  65. 24 2
      userprofile/serializers.py
  66. 4 2
      userprofile/urls.py
  67. 166 15
      userprofile/views.py
  68. 12 8
      userregister/views.py

BIN
media/avatars/0f2c0a3dad2a4522a7caf9e32d5f393c.png


BIN
media/avatars/1d2382f9bedc4d239d66edec3e5ac7f6.png


BIN
media/avatars/278e8a08e1c247c28074323bb4be890b.png


BIN
media/avatars/2807e8915ff54636b8df44d029aaea5b.png


BIN
media/avatars/36c1eb7c34a04e0a99c9decb2bcafba1.png


BIN
media/avatars/42d8f7dfadda416487f5b4a8e8db1259.png


BIN
media/avatars/5a25d20953ce478f8b4e654cef1925b4.png


BIN
media/avatars/5ce21eda2da24d35b122113a66f6a4fc.png


BIN
media/avatars/6667b6e321784f829185b58bcba1a9a6.png


BIN
media/avatars/6d07ded24bbc4bc1bda0cd33ce000808.png


BIN
media/avatars/6ebb3ae2d54d45a291af3b44d9ec22bd.png


BIN
media/avatars/71b51cf5699c415d9310c9357c8353b1.png


BIN
media/avatars/8347229e81c443ec857b85f734140456.png


BIN
media/avatars/a5b3c26947ff4667ae093a87e7615bd4.png


BIN
media/avatars/b0dda629b6c547f6a35d1af0bc6219b8.png


BIN
media/avatars/bd7d8a712e1b41c9a79dc47ef3e3b469.png


BIN
media/avatars/c270de75f9214c7cbdd3063185bd21f9.png


BIN
media/avatars/ddfe749db3b048f5ae6a440397ea157f.png


BIN
media/avatars/e8cf6af5ff7b45379762b9692dc463c8.png


BIN
media/avatars/eac9a7bd0c414733b66e1a45eeb52fb8.png


BIN
media/avatars/f1d3a120455b4a8a8b9f79879d74e940.png


BIN
media/avatars/f2b877c6e063404abf9854653170660a.png


BIN
media/avatars/f46c84eb2b6b4e5f94ec36457543f58b.png


BIN
media/avatars/ffceede880e7418cb18560a6db1f18d2.png


BIN
media/static/img/GreaterWMS.png


BIN
media/static/img/GreaterWMS_en.png


BIN
media/static/img/alipay.jpg


BIN
media/static/img/contact.png


BIN
media/static/img/dongtai.png


BIN
media/static/img/dongtai1.png


BIN
media/static/img/github.png


BIN
media/static/img/logo.png


BIN
media/static/img/logout.png


BIN
media/static/img/mobile_dn.jpg


BIN
media/static/img/mobile_dn_en.jpg


BIN
media/static/img/mobile_equ.jpg


BIN
media/static/img/mobile_equ_en.jpg


BIN
media/static/img/mobile_splash.jpg


BIN
media/static/img/money.png


BIN
media/static/img/photo.png


BIN
media/static/img/profile.png


BIN
media/static/img/register.png


BIN
media/static/img/user.jpg


BIN
media/static/img/user.png


BIN
media/static/img/video.png


BIN
media/static/img/wechat.jpg


+ 2 - 1
requirements.txt

@@ -50,4 +50,5 @@ unicodecsv==0.14.1
 uritemplate==4.1.1
 urllib3==1.26.12
 zope.interface==6.0
-psycopg2-binary==2.9.3
+psycopg2-binary==2.9.3
+pillow==10.4.0

BIN
static/img/Katie.jpg


+ 119 - 78
templates/src/boot/axios_request.js

@@ -115,28 +115,58 @@ const loadingIndicator = {
 function handleApiError(error) {
   let message = '未知错误'
 
-  if (error.code === 'ECONNABORTED' ||
-      error.message?.includes('timeout') ||
-      error.message === 'Network Error') {
+  if (
+    error.code === 'ECONNABORTED' ||
+    error.message?.includes('timeout') ||
+    error.message === 'Network Error'
+  ) {
     message = '网络连接超时,请检查网络连接'
   } else if (!error.response) {
     message = '服务器无响应,请检查网络连接'
   } else {
     switch (error.response.status) {
-      case 400: message = '请求错误'; break
-      case 401: message = '未授权,请登录'; break
-      case 403: message = '拒绝访问'; break
-      case 404: message = '请求地址出错'; break
-      case 405: message = '不支持的请求方法'; break
-      case 408: message = '请求超时'; break
-      case 409: message = '请求冲突'; break
-      case 410: message = '资源已被删除'; break
-      case 500: message = '服务器内部错误'; break
-      case 501: message = '服务未实现'; break
-      case 502: message = '网关错误'; break
-      case 503: message = '服务不可用'; break
-      case 504: message = '网关超时'; break
-      case 505: message = 'HTTP版本不受支持'; break
+      case 400:
+        message = '请求错误'
+        break
+      case 401:
+        message = '未授权,请登录'
+        break
+      case 403:
+        message = '拒绝访问'
+        break
+      case 404:
+        message = '请求地址出错'
+        break
+      case 405:
+        message = '不支持的请求方法'
+        break
+      case 408:
+        message = '请求超时'
+        break
+      case 409:
+        message = '请求冲突'
+        break
+      case 410:
+        message = '资源已被删除'
+        break
+      case 500:
+        message = '服务器内部错误'
+        break
+      case 501:
+        message = '服务未实现'
+        break
+      case 502:
+        message = '网关错误'
+        break
+      case 503:
+        message = '服务不可用'
+        break
+      case 504:
+        message = '网关超时'
+        break
+      case 505:
+        message = 'HTTP版本不受支持'
+        break
     }
   }
 
@@ -166,7 +196,7 @@ function checkAuth() {
 
 // 请求拦截器 - 认证实例
 axiosInstanceAuth.interceptors.request.use(
-  config => {
+  (config) => {
     if (!checkAuth()) {
       loadingIndicator.hide()
       Bus.$emit('needLogin', true)
@@ -181,28 +211,25 @@ axiosInstanceAuth.interceptors.request.use(
 
     return config
   },
-  error => {
+  (error) => {
     loadingIndicator.hide()
     return Promise.reject(error)
   }
 )
 
 // 响应拦截器 - 认证实例
-axiosInstanceAuth.interceptors.response.use(
-  response => {
-    if (response.data.detail && response.data.detail !== 'success') {
-      showNotification(response.data.detail, 'error')
-    }
+axiosInstanceAuth.interceptors.response.use((response) => {
+  if (response.data.detail && response.data.detail !== 'success') {
+    showNotification(response.data.detail, 'error')
+  }
 
-    loadingIndicator.hide()
-    return response.data
-  },
-  handleApiError
-)
+  loadingIndicator.hide()
+  return response.data
+}, handleApiError)
 
 // 请求拦截器 - 认证扫描实例
 axiosInstanceAuthScan.interceptors.request.use(
-  config => {
+  (config) => {
     if (!checkAuth()) {
       loadingIndicator.hide()
       Bus.$emit('needLogin', true)
@@ -217,24 +244,21 @@ axiosInstanceAuthScan.interceptors.request.use(
 
     return config
   },
-  error => {
+  (error) => {
     loadingIndicator.hide()
     return Promise.reject(error)
   }
 )
 
 // 响应拦截器 - 认证扫描实例
-axiosInstanceAuthScan.interceptors.response.use(
-  response => {
-    loadingIndicator.hide()
-    return response.data
-  },
-  handleApiError
-)
+axiosInstanceAuthScan.interceptors.response.use((response) => {
+  loadingIndicator.hide()
+  return response.data
+}, handleApiError)
 
 // 请求拦截器 - 基础实例
 axiosInstance.interceptors.request.use(
-  config => {
+  (config) => {
     config.headers = {
       ...config.headers,
       language: lang
@@ -246,28 +270,25 @@ axiosInstance.interceptors.request.use(
 
     return config
   },
-  error => {
+  (error) => {
     loadingIndicator.hide()
     return Promise.reject(error)
   }
 )
 
 // 响应拦截器 - 基础实例
-axiosInstance.interceptors.response.use(
-  response => {
-    if (response.data.detail && response.data.detail !== 'success') {
-      showNotification(response.data.detail, 'error')
-    }
+axiosInstance.interceptors.response.use((response) => {
+  if (response.data.detail && response.data.detail !== 'success') {
+    showNotification(response.data.detail, 'error')
+  }
 
-    loadingIndicator.hide()
-    return response.data
-  },
-  handleApiError
-)
+  loadingIndicator.hide()
+  return response.data
+}, handleApiError)
 
 // 请求拦截器 - 版本检查实例
 axiosInstanceVersion.interceptors.request.use(
-  config => {
+  (config) => {
     if (!checkAuth()) {
       loadingIndicator.hide()
       Bus.$emit('needLogin', true)
@@ -280,37 +301,36 @@ axiosInstanceVersion.interceptors.request.use(
 
     return config
   },
-  error => {
+  (error) => {
     loadingIndicator.hide()
     return Promise.reject(error)
   }
 )
 
 // 响应拦截器 - 版本检查实例
-axiosInstanceVersion.interceptors.response.use(
-  response => {
-    loadingIndicator.hide()
-    return response.data
-  },
-  handleApiError
-)
+axiosInstanceVersion.interceptors.response.use((response) => {
+  loadingIndicator.hide()
+  return response.data
+}, handleApiError)
 
 // 请求拦截器 - 文件实例
+// 修改文件上传的拦截器
 axiosFile.interceptors.request.use(
-  config => {
+  (config) => {
     if (!checkAuth()) {
       loadingIndicator.hide()
       Bus.$emit('needLogin', true)
       return Promise.reject(new Error('未登录'))
     }
 
+    // 修改这里:允许自定义Content-Type
+    if (!config.headers['Content-Type']) {
+      config.headers['Content-Type'] = 'application/vnd.ms-excel'
+    }
+
     config.headers = {
       ...config.headers,
-      'Content-Type': 'application/vnd.ms-excel',
-      token: localStorage.getItem('openid'),
-      appid: localStorage.getItem('appid'),
-      operator: localStorage.getItem('login_id'),
-      language: lang
+      token: localStorage.getItem('openid')
     }
 
     if (['post', 'patch', 'put', 'delete'].includes(config.method)) {
@@ -319,24 +339,21 @@ axiosFile.interceptors.request.use(
 
     return config
   },
-  error => {
+  (error) => {
     loadingIndicator.hide()
     return Promise.reject(error)
   }
 )
 
 // 响应拦截器 - 文件实例
-axiosFile.interceptors.response.use(
-  response => {
-    if (response.data.detail && response.data.detail !== 'success') {
-      showNotification(response.data.detail, 'error')
-    }
+axiosFile.interceptors.response.use((response) => {
+  if (response.data.detail && response.data.detail !== 'success') {
+    showNotification(response.data.detail, 'error')
+  }
 
-    loadingIndicator.hide()
-    return response
-  },
-  handleApiError
-)
+  loadingIndicator.hide()
+  return response
+}, handleApiError)
 
 // API 方法
 export const getauth = (url) => axiosInstanceAuth.get(url)
@@ -349,8 +366,31 @@ export const patchauth = (url, data) => axiosInstanceAuth.patch(url, data)
 export const deleteauth = (url) => axiosInstanceAuth.delete(url)
 export const viewPrintAuth = (url) => axiosInstanceAuth.get(url)
 export const scangetauth = (url) => axiosInstanceAuthScan.get(url)
-export const scanpostauth = (url, data) => axiosInstanceAuthScan.post(url, data)
+export const scanpostauth = (url, data) =>
+  axiosInstanceAuthScan.post(url, data)
 export const getfile = (url) => axiosFile.get(url)
+// 修改 postfileauth 函数
+export const postfileauth = (url, data, config = {}) => {
+  // 创建合并配置
+  const mergedConfig = {
+    ...config,
+    headers: {
+      // 保留默认配置
+      token: localStorage.getItem('openid'),
+
+      // 覆盖自定义配置
+      ...(config.headers || {})
+    }
+  }
+
+  // 特殊处理:如果是 FormData,自动设置正确的 Content-Type
+  if (data instanceof FormData) {
+    mergedConfig.headers['Content-Type'] = 'multipart/form-data'
+  }
+
+  console.log('[postfileauth] config', mergedConfig)
+  return axiosFile.post(url, data, mergedConfig)
+}
 
 // 挂载到 Vue 原型
 Vue.prototype.$axios = axios
@@ -369,5 +409,6 @@ export default {
   viewPrintAuth,
   getfile,
   scangetauth,
-  scanpostauth
+  scanpostauth,
+  postfileauth
 }

+ 1 - 1
templates/src/components/Breadcrumb/index.vue

@@ -37,7 +37,7 @@ export default {
       const first = matched[0]
 
       if (!this.isDashboard(first)) {
-        matched = [{ path: '/dashboard', meta: { title: 'Dashboard' }}].concat(matched)
+        matched = [{ path: '/dashboard', meta: { title: '主页' }}].concat(matched)
       }
 
       this.levelList = matched.filter(item => item.meta && item.meta.title && item.meta.breadcrumb !== false)

+ 88 - 59
templates/src/components/ImageCropper/index.vue

@@ -20,8 +20,16 @@
             <i class="vicp-icon1-bottom" />
           </i>
           <span v-show="loading !== 1" class="vicp-hint">{{ lang.hint }}</span>
-          <span v-show="!isSupported" class="vicp-no-supported-hint">{{ lang.noSupported }}</span>
-          <input v-show="false" v-if="step == 1" ref="fileinput" type="file" @change="handleChange">
+          <span v-show="!isSupported" class="vicp-no-supported-hint">{{
+            lang.noSupported
+          }}</span>
+          <input
+            v-show="false"
+            v-if="step == 1"
+            ref="fileinput"
+            type="file"
+            @change="handleChange"
+          >
         </div>
         <div v-show="hasError" class="vicp-error">
           <i class="vicp-icon2" />
@@ -58,8 +66,14 @@
                 @mouseup="createImg"
                 @mouseout="createImg"
               >
-              <div :style="sourceImgShadeStyle" class="vicp-img-shade vicp-img-shade-1" />
-              <div :style="sourceImgShadeStyle" class="vicp-img-shade vicp-img-shade-2" />
+              <div
+                :style="sourceImgShadeStyle"
+                class="vicp-img-shade vicp-img-shade-1"
+              />
+              <div
+                :style="sourceImgShadeStyle"
+                class="vicp-img-shade vicp-img-shade-2"
+              />
             </div>
 
             <div class="vicp-range">
@@ -86,8 +100,16 @@
             </div>
 
             <div v-if="!noRotate" class="vicp-rotate">
-              <i @mousedown="startRotateLeft" @mouseout="endRotate" @mouseup="endRotate">↺</i>
-              <i @mousedown="startRotateRight" @mouseout="endRotate" @mouseup="endRotate">↻</i>
+              <i
+                @mousedown="startRotateLeft"
+                @mouseout="endRotate"
+                @mouseup="endRotate"
+              >↺</i>
+              <i
+                @mousedown="startRotateRight"
+                @mouseout="endRotate"
+                @mouseup="endRotate"
+              >↻</i>
             </div>
           </div>
           <div v-show="true" class="vicp-crop-right">
@@ -96,7 +118,10 @@
                 <img :src="createImgUrl" :style="previewStyle">
                 <span>{{ lang.preview }}</span>
               </div>
-              <div v-if="!noCircle" class="vicp-preview-item vicp-preview-item-circle">
+              <div
+                v-if="!noCircle"
+                class="vicp-preview-item vicp-preview-item-circle"
+              >
                 <img :src="createImgUrl" :style="previewStyle">
                 <span>{{ lang.preview }}</span>
               </div>
@@ -105,15 +130,25 @@
         </div>
         <div class="vicp-operate">
           <a @click="setStep(1)" @mousedown="ripple">{{ lang.btn.back }}</a>
-          <a class="vicp-operate-btn" @click="prepareUpload" @mousedown="ripple">{{ lang.btn.save }}</a>
+          <a
+            class="vicp-operate-btn"
+            @click="prepareUpload"
+            @mousedown="ripple"
+          >{{ lang.btn.save }}</a>
         </div>
       </div>
 
       <div v-if="step == 3" class="vicp-step3">
         <div class="vicp-upload">
-          <span v-show="loading === 1" class="vicp-loading">{{ lang.loading }}</span>
+          <span v-show="loading === 1" class="vicp-loading">{{
+            lang.loading
+          }}</span>
           <div class="vicp-progress-wrap">
-            <span v-show="loading === 1" :style="progressStyle" class="vicp-progress" />
+            <span
+              v-show="loading === 1"
+              :style="progressStyle"
+              class="vicp-progress"
+            />
           </div>
           <div v-show="hasError" class="vicp-error">
             <i class="vicp-icon2" />
@@ -136,7 +171,8 @@
 
 <script>
 'use strict'
-import request from '@/utils/request'
+// import request from '@/utils/request'
+import { postfileauth } from '@/boot/axios_request'
 import language from './utils/language.js'
 import mimes from './utils/mimes.js'
 import data2blob from './utils/data2blob.js'
@@ -479,7 +515,7 @@ export default {
     // 设置图片源
     setSourceImg(file) {
       const fr = new FileReader()
-      fr.onload = e => {
+      fr.onload = (e) => {
         this.sourceImgUrl = fr.result
         this.startCrop()
       }
@@ -671,16 +707,8 @@ export default {
     // 缩放原图
     zoomImg(newRange) {
       const { sourceImgMasking, scale } = this
-      const {
-        maxWidth,
-        maxHeight,
-        minWidth,
-        minHeight,
-        width,
-        height,
-        x,
-        y
-      } = scale
+      const { maxWidth, maxHeight, minWidth, minHeight, width, height, x, y } =
+        scale
       const sim = sourceImgMasking
       // 蒙版宽高
       const sWidth = sim.width
@@ -758,56 +786,57 @@ export default {
       }
     },
     // 上传图片
-    upload() {
-      const {
-        lang,
-        imgFormat,
-        mime,
-        url,
-        params,
-        field,
-        ki,
-        createImgUrl
-      } = this
+    // 修改 upload 方法
+    async upload() {
+      const { imgFormat, mime, url, params, createImgUrl } = this
+
       const fmData = new FormData()
+
+      // 添加裁剪后的图片
       fmData.append(
-        field,
+        'avatar',
         data2blob(createImgUrl, mime),
-        field + '.' + imgFormat
+        `avatar.${imgFormat}`
       )
+
       // 添加其他参数
-      if (typeof params === 'object' && params) {
-        Object.keys(params).forEach(k => {
+      if (params) {
+        Object.keys(params).forEach((k) => {
           fmData.append(k, params[k])
         })
       }
-      // 监听进度回调
-      // const uploadProgress = (event) => {
-      //   if (event.lengthComputable) {
-      //     this.progress = 100 * Math.round(event.loaded) / event.total
-      //   }
-      // }
-      // 上传文件
+
       this.reset()
       this.loading = 1
       this.setStep(3)
-      request({
-        url,
-        method: 'post',
-        data: fmData
-      })
-        .then(resData => {
-          this.loading = 2
-          this.$emit('crop-upload-success', resData.data)
-        })
-        .catch(err => {
-          if (this.value) {
-            this.loading = 3
-            this.hasError = true
-            this.errorMsg = lang.fail
-            this.$emit('crop-upload-fail', err, field, ki)
+
+      try {
+        console.log('url', url)
+        console.log('fmData', fmData)
+
+        const response = await postfileauth(url, fmData, {
+          headers: {
+            'Content-Type': 'multipart/form-data'
           }
         })
+
+        console.log('response', response)
+        this.loading = 2
+
+        // 确保使用后端返回的头像URL
+        if (response && response.data && response.data.avatar) {
+          this.image = response.data.avatar
+          this.$store.commit('user/SET_AVATAR', response.data.avatar)
+        }
+
+        this.$emit('crop-upload-success', response)
+      } catch (err) {
+        console.error('上传错误:', err)
+        this.loading = 3
+        this.hasError = true
+        this.errorMsg = this.lang.fail
+        this.$emit('crop-upload-fail', err)
+      }
     },
     closeHandler(e) {
       if (this.value && (e.key === 'Escape' || e.keyCode === 27)) {

+ 118 - 0
templates/src/components/date/index.js

@@ -0,0 +1,118 @@
+/**
+ * 日期格式化工具
+ * @param {string|Date} date - 日期字符串或Date对象
+ * @param {string} format - 输出格式,支持以下占位符:
+ *   YYYY - 四位年份
+ *   YY - 两位年份
+ *   MM - 两位月份
+ *   DD - 两位日期
+ *   HH - 24小时制小时
+ *   mm - 分钟
+ *   ss - 秒钟
+ *   SSS - 毫秒
+ * @returns {string} 格式化后的日期字符串
+ */
+export function formatDate(date, format = 'YYYY-MM-DD HH:mm') {
+  // 处理空值
+  if (!date) return ''
+
+  // 创建Date对象
+  const dateObj = date instanceof Date ? date : new Date(date)
+
+  // 处理无效日期
+  if (isNaN(dateObj.getTime())) {
+    console.warn('formatDate: Invalid date input, returning empty string')
+    return ''
+  }
+
+  // 填充数字到指定长度
+  const pad = (num, length = 2) => String(num).padStart(length, '0')
+
+  // 提取日期组成部分
+  const year = dateObj.getFullYear()
+  const month = dateObj.getMonth() + 1
+  const day = dateObj.getDate()
+  const hours = dateObj.getHours()
+  const minutes = dateObj.getMinutes()
+  const seconds = dateObj.getSeconds()
+  const milliseconds = dateObj.getMilliseconds()
+
+  // 替换格式占位符
+  return format
+    .replace(/YYYY/g, pad(year, 4))
+    .replace(/YY/g, pad(year % 100, 2))
+    .replace(/MM/g, pad(month))
+    .replace(/DD/g, pad(day))
+    .replace(/HH/g, pad(hours))
+    .replace(/mm/g, pad(minutes))
+    .replace(/ss/g, pad(seconds))
+    .replace(/SSS/g, pad(milliseconds, 3))
+}
+
+/**
+ * 相对时间格式转换
+ * @param {string|Date} date - 日期字符串或Date对象
+ * @returns {string} 相对于当前时间的描述(如"3天前")
+ */
+export function relativeTime(date) {
+  // 处理空值
+  if (!date) return ''
+
+  // 创建Date对象
+  const dateObj = date instanceof Date ? date : new Date(date)
+
+  // 处理无效日期
+  if (isNaN(dateObj.getTime())) {
+    console.warn('relativeTime: Invalid date input, returning empty string')
+    return ''
+  }
+
+  const now = new Date()
+  const diffInSeconds = Math.floor((now - dateObj) / 1000)
+
+  const intervals = {
+    年: 31536000,
+    月: 2592000,
+    天: 86400,
+    小时: 3600,
+    分钟: 60
+  }
+
+  for (const [unit, seconds] of Object.entries(intervals)) {
+    const interval = Math.floor(diffInSeconds / seconds)
+    if (interval >= 1) {
+      return `${interval}${unit}前`
+    }
+  }
+
+  return diffInSeconds < 10 ? '刚刚' : `${diffInSeconds}秒前`
+}
+
+/**
+ * 时长格式化(毫秒转可读格式)
+ * @param {number} milliseconds - 毫秒数
+ * @returns {string} 格式化后的时长(如"01:23:45")
+ */
+export function formatDuration(milliseconds) {
+  if (!milliseconds && milliseconds !== 0) return ''
+
+  const totalSeconds = Math.floor(milliseconds / 1000)
+  const hours = Math.floor(totalSeconds / 3600)
+  const minutes = Math.floor((totalSeconds % 3600) / 60)
+  const seconds = totalSeconds % 60
+
+  return [hours ? pad(hours) : null, pad(minutes), pad(seconds)]
+    .filter(Boolean)
+    .join(':')
+}
+
+// 内部使用的填充函数
+function pad(num) {
+  return num.toString().padStart(2, '0')
+}
+
+// 示例使用
+// console.log(formatDate('2025-08-07T14:33:37.043403')); // 默认格式
+// console.log(formatDate('2025-08-07T14:33:37.043403', 'YYYY年MM月DD日 HH:mm:ss')); // 自定义格式
+// console.log(relativeTime(new Date(Date.now() - 30000))); // 30秒前
+// console.log(formatDuration(12345678)); // 03:25:45

+ 3 - 0
templates/src/store/getters.js

@@ -10,6 +10,9 @@ const getters = {
   name: state => state.user.name,
   introduction: state => state.user.introduction,
   roles: state => state.user.roles,
+  email: state => state.user.email,
+  phone: state => state.user.phone,
+  address: state => state.user.address,
   permission_routes: state => state.permission.routes,
   errorLogs: state => state.errorLog.logs
 }

+ 22 - 4
templates/src/store/modules/user.js

@@ -10,7 +10,10 @@ const state = {
   name: '',
   avatar: '',
   introduction: '',
-  roles: []
+  roles: [],
+  email: '',
+  phone: '',
+  address: ''
 }
 
 const mutations = {
@@ -28,6 +31,15 @@ const mutations = {
   },
   SET_ROLES: (state, roles) => {
     state.roles = roles
+  },
+  SET_EMAIL: (state, email) => {
+    state.email = email
+  },
+  SET_PHONE: (state, phone) => {
+    state.phone = phone
+  },
+  SET_ADDRESS: (state, address) => {
+    state.address = address
   }
 }
 
@@ -82,7 +94,7 @@ const actions = {
             reject('Verification failed, please Login again.')
           }
 
-          const { roles, name, avatar, introduction } = data
+          const { roles, name, avatar_url, introduction, email, phone, address } = data
 
           if (!roles || roles.length <= 0) {
             reject('getInfo: roles must be a non-null array!')
@@ -90,12 +102,18 @@ const actions = {
 
           commit('SET_ROLES', roles)
           commit('SET_NAME', name)
-          commit('SET_AVATAR', avatar)
+          commit('SET_AVATAR', avatar_url)
           commit('SET_INTRODUCTION', introduction)
+          commit('SET_EMAIL', email)
+          commit('SET_PHONE', phone)
+          commit('SET_ADDRESS', address)
           console.log('[user.js]当前的roles', roles)
           console.log('[user.js]当前的name', name)
-          console.log('[user.js]当前的avatar', avatar)
+          console.log('[user.js]当前的avatar', avatar_url)
           console.log('[user.js]当前的introduction', introduction)
+          console.log('[user.js]当前的email', email)
+          console.log('[user.js]当前的phone', phone)
+          console.log('[user.js]当前的address', address)
           resolve(data)
         })
         .catch((error) => {

+ 6 - 2
templates/src/views/components-demo/avatar-upload.vue

@@ -17,7 +17,7 @@
       :width="300"
       :height="300"
       url="https://httpbin.org/post"
-      lang-type="en"
+      lang-type="zh"
       @close="close"
       @crop-upload-success="cropSuccess"
     />
@@ -27,6 +27,7 @@
 <script>
 import ImageCropper from '@/components/ImageCropper'
 import PanThumb from '@/components/PanThumb'
+import { mapGetters } from 'vuex'
 
 export default {
   name: 'AvatarUploadDemo',
@@ -35,9 +36,12 @@ export default {
     return {
       imagecropperShow: false,
       imagecropperKey: 0,
-      image: 'https://wpimg.wallstcn.com/577965b9-bb9e-4e02-9f0c-095b41417191'
+      image: this.$store.getters.avatar
     }
   },
+  computed: {
+    ...mapGetters(['avatar']) // 从 Vuex 获取
+  },
   methods: {
     cropSuccess(resData) {
       this.imagecropperShow = false

+ 4 - 4
templates/src/views/login/index.vue

@@ -201,9 +201,9 @@ export default {
         username: [
           { required: true, message: '请输入用户名', trigger: 'blur' },
           {
-            min: 3,
+            min: 1,
             max: 15,
-            message: '用户名长度在3到15个字符之间',
+            message: '用户名长度在1到15个字符之间',
             trigger: 'blur'
           }
         ],
@@ -225,9 +225,9 @@ export default {
         username: [
           { required: true, message: '请输入用户名', trigger: 'blur' },
           {
-            min: 3,
+            min: 1,
             max: 15,
-            message: '用户名长度在3到15个字符之间',
+            message: '用户名长度在1到15个字符之间',
             trigger: 'blur'
           }
         ],

+ 412 - 13
templates/src/views/profile/components/Account.vue

@@ -1,38 +1,437 @@
 <template>
-  <el-form>
-    <el-form-item label="Name">
-      <el-input v-model.trim="user.name" />
-    </el-form-item>
-    <el-form-item label="Email">
-      <el-input v-model.trim="user.email" />
-    </el-form-item>
-    <el-form-item>
-      <el-button type="primary" @click="submit">Update</el-button>
-    </el-form-item>
-  </el-form>
+  <div>
+    <el-form>
+      <el-row :gutter="20">
+        <!-- 第一列:基本资料 -->
+        <el-col :span="8">
+          <el-card class="form-section">
+            <div slot="header" class="clearfix">
+              <span>基本资料</span>
+            </div>
+            <div class="box-center">
+              <div class="avatar-container box-center">
+                <el-button
+                  type="primary"
+                  icon="el-icon-upload"
+                  class="change-avatar-btn"
+                  @click="imagecropperShow = true"
+                >
+                  更换头像
+                </el-button>
+              </div>
+            </div>
+
+            <el-form-item label="姓名">
+              <el-input v-model.trim="user.name" />
+            </el-form-item>
+
+            <el-form-item label="邮箱">
+              <el-input v-model.trim="user.email" />
+            </el-form-item>
+
+            <el-form-item label="手机号">
+              <el-input v-model.trim="user.phone" />
+            </el-form-item>
+
+            <el-form-item label="地址">
+              <el-input v-model.trim="user.address" type="textarea" :rows="2" />
+            </el-form-item>
+          </el-card>
+        </el-col>
+
+        <!-- 第二列:学术信息 -->
+
+        <el-col :span="8">
+          <el-card class="form-section">
+            <div slot="header" class="clearfix">
+              <span>学术信息</span>
+            </div>
+
+            <el-form-item label="学术身份">
+              <el-select
+                v-model="user.academic_profile.role"
+                placeholder="请选择身份"
+              >
+                <el-option
+                  v-for="role in academicRoles"
+                  :key="role.value"
+                  :label="role.label"
+                  :value="role.value"
+                />
+              </el-select>
+            </el-form-item>
+
+            <el-form-item label="入学年份">
+              <el-input
+                v-model.number="user.academic_profile.enrollment_year"
+                type="number"
+              />
+            </el-form-item>
+
+            <el-form-item label="毕业年份">
+              <el-input
+                v-model.number="user.academic_profile.graduation_year"
+                type="number"
+              />
+            </el-form-item>
+
+            <el-form-item label="院系/研究所">
+              <el-input v-model.trim="user.academic_profile.department" />
+            </el-form-item>
+
+            <el-form-item label="专业方向">
+              <el-input v-model.trim="user.academic_profile.major" />
+            </el-form-item>
+
+            <el-form-item label="研究方向">
+              <el-input
+                v-model.trim="user.academic_profile.research_tags"
+                placeholder="多个方向用逗号分隔 (如:人工智能,教育技术)"
+              />
+            </el-form-item>
+
+            <el-form-item label="技能标签">
+              <el-input
+                v-model.trim="user.academic_profile.skill_tags"
+                placeholder="多个技能用逗号分隔 (如:Python,数据分析)"
+              />
+            </el-form-item>
+          </el-card>
+        </el-col>
+
+        <!-- 第三列:小组信息 -->
+
+        <el-col :span="8">
+          <el-card class="form-section">
+            <div slot="header" class="clearfix">
+              <span>小组信息</span>
+              <el-button
+                style="float: right; padding: 3px 0"
+                type="text"
+                @click="addGroupDialogVisible = true"
+              >
+                加入小组
+              </el-button>
+            </div>
+
+            <div v-if="user.group_memberships && user.group_memberships.length">
+              <div
+                v-for="(membership, index) in user.group_memberships"
+                :key="index"
+                class="group-item"
+              >
+                <div class="group-header">
+                  <span class="group-name">{{ membership.group.name }}</span>
+                  <el-tag
+                    size="mini"
+                    :type="getRoleTagType(membership.role)"
+                    style="margin-left: 10px"
+                  >
+                    {{ getRoleLabel(membership.role) }}
+                  </el-tag>
+                  <el-tag
+                    size="mini"
+                    :type="getStatusTagType(membership.status)"
+                    style="margin-left: 5px"
+                  >
+                    {{ getStatusLabel(membership.status) }}
+                  </el-tag>
+                </div>
+
+                <div class="group-info">
+                  <div>
+                    <i class="el-icon-time" /> 加入时间:
+                    {{ formatDate(membership.joined_at) }}
+                  </div>
+                  <div v-if="membership.left_at">
+                    <i class="el-icon-switch-button" /> 离开时间:
+                    {{ formatDate(membership.left_at) }}
+                  </div>
+                </div>
+
+                <div class="group-actions">
+                  <el-button
+                    v-if="membership.status === 'ACT'"
+                    type="danger"
+                    size="mini"
+                    @click="leaveGroup(membership)"
+                  >
+                    退出小组
+                  </el-button>
+                  <el-button
+                    v-if="membership.status !== 'ACT'"
+                    type="primary"
+                    size="mini"
+                    @click="rejoinGroup(membership)"
+                  >
+                    重新加入
+                  </el-button>
+                </div>
+              </div>
+            </div>
+
+            <div v-else class="empty-groups">
+              <i class="el-icon-info" /> 您目前没有加入任何小组
+            </div>
+          </el-card>
+        </el-col>
+      </el-row>
+
+      <el-form-item>
+        <el-button type="primary" @click="submit">更新全部信息</el-button>
+        <el-button @click="cancel">取消</el-button>
+      </el-form-item>
+    </el-form>
+
+    <!-- 加入小组对话框 -->
+    <el-dialog
+      title="加入小组"
+      :visible.sync="addGroupDialogVisible"
+      width="30%"
+    >
+      <el-form>
+        <el-form-item label="选择小组">
+          <el-select v-model="selectedGroupId" placeholder="请选择小组">
+            <el-option
+              v-for="group in availableGroups"
+              :key="group.group_id"
+              :label="group.name"
+              :value="group.group_id"
+            />
+          </el-select>
+        </el-form-item>
+      </el-form>
+      <span slot="footer" class="dialog-footer">
+        <el-button @click="addGroupDialogVisible = false">取消</el-button>
+        <el-button type="primary" @click="joinGroup">加入</el-button>
+      </span>
+    </el-dialog>
+    <image-cropper
+      v-show="imagecropperShow"
+      :key="imagecropperKey"
+      :width="300"
+      :height="300"
+      url="/user/avatar-upload/"
+      lang-type="zh"
+      @close="close"
+      @crop-upload-success="cropSuccess"
+    />
+  </div>
 </template>
 
 <script>
+import ImageCropper from '@/components/ImageCropper'
+import { formatDate } from '@/components/date'
+
+import { postauth } from '@/boot/axios_request'
+
 export default {
+  components: { ImageCropper },
   props: {
     user: {
       type: Object,
       default: () => {
         return {
           name: '',
-          email: ''
+          email: '',
+          phone: '',
+          address: '',
+          avatar: '',
+          academic_profile: {
+            role: '',
+            enrollment_year: '',
+            graduation_year: '',
+            department: '',
+            major: '',
+            research_tags: '',
+            skill_tags: ''
+          },
+          group_memberships: []
         }
       }
+    },
+    availableGroups: {
+      type: Array,
+      default: () => []
+    }
+  },
+  data() {
+    return {
+      profilepath: 'user/profile/',
+      imagecropperShow: false,
+      imagecropperKey: 0,
+      image: this.$store.getters.avatar,
+      selectedGroupId: null,
+      addGroupDialogVisible: false,
+      academicRoles: [
+        { value: 'UG', label: '本科生' },
+        { value: 'MS', label: '硕士生' },
+        { value: 'PhD', label: '博士生' },
+        { value: 'PD', label: '博士后' },
+        { value: 'FAC', label: '教职工' },
+        { value: 'RES', label: '研究员' }
+      ],
+      memberRoles: {
+        LEAD: { label: '组长', type: 'danger' },
+        DEP: { label: '副组长', type: 'warning' },
+        CORE: { label: '核心成员', type: 'success' },
+        MEM: { label: '普通成员', type: '' },
+        ADV: { label: '指导老师', type: 'info' },
+        OBS: { label: '观察员', type: 'info' }
+      },
+      memberStatus: {
+        ACT: { label: '活跃', type: 'success' },
+        LV: { label: '请假', type: 'warning' },
+        INAC: { label: '不活跃', type: 'danger' },
+        GRAD: { label: '已毕业', type: 'info' },
+        TRF: { label: '已转组', type: 'info' }
+      }
     }
   },
   methods: {
+    formatDate,
     submit() {
+      postauth(this.profilepath, this.user).then((res) => {
+        this.handleSuccess()
+      })
+    },
+    handleSuccess() {
+      this.$store.dispatch('user/getInfo')
       this.$message({
-        message: 'User information has been updated successfully',
+        message: '用户信息已成功更新',
         type: 'success',
         duration: 5 * 1000
       })
+    },
+    cancel() {
+      this.$emit('cancel')
+    },
+    cropSuccess(resData) {
+      this.imagecropperShow = false
+      this.imagecropperKey += 1
+      console.log(resData)
+      this.$store.dispatch('user/getInfo')
+    },
+    close() {
+      this.imagecropperShow = false
+    },
+    getRoleLabel(role) {
+      return this.memberRoles[role]?.label || role
+    },
+    getStatusLabel(status) {
+      return this.memberStatus[status]?.label || status
+    },
+    getRoleTagType(role) {
+      return this.memberRoles[role]?.type || ''
+    },
+    getStatusTagType(status) {
+      return this.memberStatus[status]?.type || ''
+    },
+    joinGroup() {
+      if (!this.selectedGroupId) {
+        this.$message.warning('请选择要加入的小组')
+        return
+      }
+
+      // 这里调用加入小组的API
+      this.$message.success('加入小组请求已发送')
+      this.addGroupDialogVisible = false
+    },
+    leaveGroup(membership) {
+      this.$confirm('确定要退出该小组吗?', '提示', {
+        confirmButtonText: '确定',
+        cancelButtonText: '取消',
+        type: 'warning'
+      }).then(() => {
+        // 这里调用退出小组的API
+        membership.status = 'TRF'
+        membership.left_at = new Date()
+        this.$message.success('已成功退出小组')
+      })
+    },
+    rejoinGroup(membership) {
+      this.$confirm('确定要重新加入该小组吗?', '提示', {
+        confirmButtonText: '确定',
+        cancelButtonText: '取消',
+        type: 'info'
+      }).then(() => {
+        // 这里调用重新加入小组的API
+        membership.status = 'ACT'
+        membership.left_at = null
+        this.$message.success('已重新加入小组')
+      })
     }
   }
 }
 </script>
+
+<style lang="scss" scoped>
+.form-section {
+  margin-bottom: 20px;
+}
+
+.avatar-container {
+  left: 50%;
+  top: 50%;
+  transform: translate(-50%, -50%);
+  position: relative;
+  width: 150px;
+  margin-bottom: 50px;
+}
+
+.change-avatar-btn {
+  position: absolute;
+  bottom: -35px;
+  left: 50%;
+  transform: translateX(-50%);
+  z-index: 2;
+}
+
+.box-center {
+  left: 20% auto;
+  display: table;
+}
+
+.group-item {
+  padding: 15px;
+  margin-bottom: 15px;
+  border: 1px solid #ebeef5;
+  border-radius: 4px;
+  background-color: #f8f9fa;
+
+  .group-header {
+    display: flex;
+    align-items: center;
+    margin-bottom: 10px;
+
+    .group-name {
+      font-weight: bold;
+    }
+  }
+
+  .group-info {
+    color: #606266;
+    font-size: 13px;
+    margin-bottom: 10px;
+
+    div {
+      margin-bottom: 5px;
+    }
+  }
+
+  .group-actions {
+    text-align: right;
+  }
+}
+
+.empty-groups {
+  text-align: center;
+  color: #909399;
+  padding: 20px;
+  font-size: 14px;
+
+  i {
+    margin-right: 5px;
+  }
+}
+</style>

+ 15 - 12
templates/src/views/profile/components/UserCard.vue

@@ -1,25 +1,25 @@
 <template>
   <el-card style="margin-bottom:20px;">
     <div slot="header" class="clearfix">
-      <span>About me</span>
+      <span>个人资料</span>
     </div>
 
     <div class="user-profile">
       <div class="box-center">
-        <pan-thumb :image="user.avatar" :height="'100px'" :width="'100px'" :hoverable="false">
-          <div>Hello</div>
-          {{ user.role }}
+        <pan-thumb :image="avatar" :height="'100px'" :width="'100px'" :hoverable="false">
+          <div>你好</div>
+          {{ roles }}
         </pan-thumb>
       </div>
       <div class="box-center">
-        <div class="user-name text-center">{{ user.name }}</div>
-        <div class="user-role text-center text-muted">{{ user.role | uppercaseFirst }}</div>
+        <div class="user-name text-center">{{ name }}</div>
+        <div class="user-role text-center text-muted">{{ roles }}</div>
       </div>
     </div>
 
     <div class="user-bio">
       <div class="user-education user-bio-section">
-        <div class="user-bio-section-header"><svg-icon icon-class="education" /><span>Education</span></div>
+        <div class="user-bio-section-header"><svg-icon icon-class="education" /><span>教育背景</span></div>
         <div class="user-bio-section-body">
           <div class="text-muted">
             JS in Computer Science from the University of Technology
@@ -28,10 +28,10 @@
       </div>
 
       <div class="user-skills user-bio-section">
-        <div class="user-bio-section-header"><svg-icon icon-class="skill" /><span>Skills</span></div>
+        <div class="user-bio-section-header"><svg-icon icon-class="skill" /><span>技能</span></div>
         <div class="user-bio-section-body">
           <div class="progress-item">
-            <span>Vue</span>
+            <span>研究</span>
             <el-progress :percentage="70" />
           </div>
           <div class="progress-item">
@@ -54,6 +54,7 @@
 
 <script>
 import PanThumb from '@/components/PanThumb'
+import { mapGetters } from 'vuex'
 
 export default {
   components: { PanThumb },
@@ -62,14 +63,16 @@ export default {
       type: Object,
       default: () => {
         return {
-          name: '',
-          email: '',
-          avatar: '',
+
           role: ''
         }
       }
     }
+  },
+  computed: {
+    ...mapGetters(['name', 'avatar', 'roles'])
   }
+
 }
 </script>
 

+ 19 - 14
templates/src/views/profile/index.vue

@@ -2,27 +2,24 @@
   <div class="app-container">
     <div v-if="user">
       <el-row :gutter="20">
-
         <el-col :span="6" :xs="24">
           <user-card :user="user" />
         </el-col>
-
         <el-col :span="18" :xs="24">
           <el-card>
             <el-tabs v-model="activeTab">
+              <el-tab-pane label="用户信息" name="account">
+                <account :user="user" />
+              </el-tab-pane>
               <el-tab-pane label="Activity" name="activity">
                 <activity />
               </el-tab-pane>
               <el-tab-pane label="Timeline" name="timeline">
                 <timeline />
               </el-tab-pane>
-              <el-tab-pane label="Account" name="account">
-                <account :user="user" />
-              </el-tab-pane>
             </el-tabs>
           </el-card>
         </el-col>
-
       </el-row>
     </div>
   </div>
@@ -41,15 +38,11 @@ export default {
   data() {
     return {
       user: {},
-      activeTab: 'activity'
+      activeTab: 'account'
     }
   },
   computed: {
-    ...mapGetters([
-      'name',
-      'avatar',
-      'roles'
-    ])
+    ...mapGetters(['name', 'avatar', 'roles', 'email', 'phone', 'address'])
   },
   created() {
     this.getUser()
@@ -59,8 +52,20 @@ export default {
       this.user = {
         name: this.name,
         role: this.roles.join(' | '),
-        email: 'admin@test.com',
-        avatar: this.avatar
+        email: this.email,
+        phone: this.phone,
+        address: this.address,
+        avatar: this.avatar,
+        academic_profile: {
+          role: '',
+          enrollment_year: '',
+          graduation_year: '',
+          department: '电子科技大学-机械与电气工程学院-机器人感知与信息融合小组',
+          major: '',
+          research_tags: '',
+          skill_tags: ''
+        },
+        group_memberships: []
       }
     }
   }

+ 3 - 1
userprofile/filter.py

@@ -14,7 +14,9 @@ class UsersFilter(FilterSet):
             'developer': ['exact'],
             't_code': ['icontains'],
             'ip': ['icontains'],
-            'avatar': ['icontains'],
+            'email': ['icontains', 'exact'],
+            'phone': ['icontains', 'exact'],
+            'address': ['icontains', 'exact'],
             'create_time': ['exact', 'lt', 'gt'],
             'update_time': ['exact', 'lt', 'gt'],
         }

+ 13 - 6
userprofile/migrations/0001_initial.py

@@ -1,5 +1,6 @@
-# Generated by Django 4.1.2 on 2025-08-05 14:01
+# Generated by Django 4.1.2 on 2025-08-07 22:15
 
+from django.conf import settings
 from django.db import migrations, models
 import django.db.models.deletion
 
@@ -9,6 +10,7 @@ class Migration(migrations.Migration):
     initial = True
 
     dependencies = [
+        migrations.swappable_dependency(settings.AUTH_USER_MODEL),
     ]
 
     operations = [
@@ -19,15 +21,20 @@ class Migration(migrations.Migration):
                 ('name', models.CharField(max_length=80, verbose_name='姓名')),
                 ('openid', models.CharField(max_length=100, unique=True, verbose_name='OPENID')),
                 ('appid', models.CharField(max_length=100, verbose_name='APPID')),
+                ('roles', models.CharField(choices=[('ADM', '管理员'), ('USR', '普通用户'), ('DEV', '开发者')], default='USR', max_length=10, verbose_name='角色')),
                 ('vip', models.PositiveIntegerField(default=1, verbose_name='VIP等级')),
                 ('vip_time', models.DateTimeField(auto_now_add=True, verbose_name='VIP生效时间')),
                 ('is_delete', models.BooleanField(default=False, verbose_name='删除标记')),
                 ('developer', models.BooleanField(default=False, verbose_name='开发者标记')),
                 ('t_code', models.CharField(max_length=100, unique=True, verbose_name='交易验证码')),
                 ('ip', models.GenericIPAddressField(verbose_name='注册IP')),
-                ('avatar', models.CharField(default='/static/img/user.jpg', max_length=200, verbose_name='用户头像')),
+                ('avatar', models.ImageField(blank=True, default='/static/img/user.jpg', null=True, upload_to='avatars/', verbose_name='头像')),
+                ('email', models.EmailField(blank=True, default='email@email.com', max_length=254, null=True, verbose_name='邮箱')),
+                ('phone', models.CharField(blank=True, max_length=20, null=True, verbose_name='手机号')),
+                ('address', models.CharField(blank=True, max_length=200, null=True, verbose_name='地址')),
                 ('create_time', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
                 ('update_time', models.DateTimeField(auto_now=True, verbose_name='更新时间')),
+                ('user_name', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='user_profile', to=settings.AUTH_USER_MODEL, verbose_name='关联用户')),
             ],
             options={
                 'verbose_name': '用户档案',
@@ -39,7 +46,7 @@ class Migration(migrations.Migration):
         migrations.CreateModel(
             name='AcademicProfile',
             fields=[
-                ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, primary_key=True, related_name='academic_profile', serialize=False, to='userprofile.users', verbose_name='关联用户')),
+                ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, primary_key=True, related_name='user_academic_profile', serialize=False, to='userprofile.users', verbose_name='关联用户')),
                 ('role', models.CharField(choices=[('UG', '本科生'), ('MS', '硕士生'), ('PhD', '博士生'), ('PD', '博士后'), ('FAC', '教职工'), ('RES', '研究员')], default='MS', max_length=10, verbose_name='学术身份')),
                 ('enrollment_year', models.PositiveIntegerField(help_text='格式:YYYY(如2023)', verbose_name='入学年份')),
                 ('graduation_year', models.PositiveIntegerField(blank=True, null=True, verbose_name='预计毕业年份')),
@@ -51,7 +58,7 @@ class Migration(migrations.Migration):
             options={
                 'verbose_name': '学术档案',
                 'verbose_name_plural': '学术档案',
-                'db_table': 'academic_profile',
+                'db_table': 'user_academic_profile',
             },
         ),
         migrations.CreateModel(
@@ -67,7 +74,7 @@ class Migration(migrations.Migration):
             options={
                 'verbose_name': '教研小组',
                 'verbose_name_plural': '教研小组',
-                'db_table': 'research_group',
+                'db_table': 'user_research_group',
                 'ordering': ['-created_at'],
             },
         ),
@@ -86,7 +93,7 @@ class Migration(migrations.Migration):
             options={
                 'verbose_name': '小组成员',
                 'verbose_name_plural': '小组成员',
-                'db_table': 'group_membership',
+                'db_table': 'user_group_membership',
                 'ordering': ['-joined_at'],
                 'unique_together': {('user', 'group')},
             },

+ 0 - 27
userprofile/migrations/0002_alter_academicprofile_user_and_more.py

@@ -1,27 +0,0 @@
-# Generated by Django 4.1.2 on 2025-08-05 16:06
-
-from django.db import migrations, models
-import django.db.models.deletion
-
-
-class Migration(migrations.Migration):
-
-    dependencies = [
-        ('userprofile', '0001_initial'),
-    ]
-
-    operations = [
-        migrations.AlterField(
-            model_name='academicprofile',
-            name='user',
-            field=models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, primary_key=True, related_name='user_academic_profile', serialize=False, to='userprofile.users', verbose_name='关联用户'),
-        ),
-        migrations.AlterModelTable(
-            name='groupmembership',
-            table='user_group_membership',
-        ),
-        migrations.AlterModelTable(
-            name='researchgroup',
-            table='user_research_group',
-        ),
-    ]

+ 0 - 17
userprofile/migrations/0003_alter_academicprofile_table.py

@@ -1,17 +0,0 @@
-# Generated by Django 4.1.2 on 2025-08-05 16:08
-
-from django.db import migrations
-
-
-class Migration(migrations.Migration):
-
-    dependencies = [
-        ('userprofile', '0002_alter_academicprofile_user_and_more'),
-    ]
-
-    operations = [
-        migrations.AlterModelTable(
-            name='academicprofile',
-            table='user_academic_profile',
-        ),
-    ]

+ 34 - 2
userprofile/models.py

@@ -1,14 +1,34 @@
 from django.db import models
 from django.utils.translation import gettext_lazy as _
 from django.utils import timezone
+from django.contrib.auth.models import User
 
 class Users(models.Model):
     """核心用户模型 (继承现有结构)"""
+    class UserRole(models.TextChoices):
+        ADMIN = 'ADM', _('管理员')
+        USER = 'USR', _('普通用户')
+        DEVELOPER = 'DEV', _('开发者')
+
+    user_name = models.OneToOneField(
+        User,
+        on_delete=models.CASCADE,
+        primary_key=False,
+        related_name='user_profile',
+        verbose_name='关联用户'
+    )
     user_id = models.AutoField(primary_key=True, verbose_name="用户ID")
     name = models.CharField(max_length=80, verbose_name='姓名')
     openid = models.CharField(max_length=100, unique=True, verbose_name='OPENID')
     appid = models.CharField(max_length=100, verbose_name='APPID')
-    
+
+    roles = models.CharField(    
+
+        max_length=10,
+        choices=UserRole.choices,
+        default=UserRole.USER,
+        verbose_name='角色'
+    )
     # 权限系统
     vip = models.PositiveIntegerField(default=1, verbose_name='VIP等级')
     vip_time = models.DateTimeField(auto_now_add=True, verbose_name='VIP生效时间')
@@ -22,7 +42,12 @@ class Users(models.Model):
     ip = models.GenericIPAddressField(verbose_name='注册IP')
     
     # 基本信息
-    avatar = models.CharField(max_length=200, default='/static/img/user.jpg', verbose_name='用户头像')
+    avatar = models.ImageField(upload_to='avatars/', blank=True, null=True,default='/static/img/user.jpg', verbose_name='头像')
+    email = models.EmailField(unique=False, null=True, blank=True, default='email@email.com', verbose_name='邮箱')
+    phone = models.CharField(max_length=20, null=True, blank=True, verbose_name='手机号')
+    address = models.CharField(max_length=200, null=True, blank=True, verbose_name='地址')
+    
+    # 注册信息
     create_time = models.DateTimeField(auto_now_add=True, verbose_name='创建时间')
     update_time = models.DateTimeField(auto_now=True, verbose_name='更新时间')
 
@@ -34,6 +59,13 @@ class Users(models.Model):
 
     def __str__(self):
         return f"{self.name}(ID:{self.user_id})"
+    # 如果变更名称,同步修改User表中的username
+    def save(self, *args, **kwargs):
+        if self.name != self.user_name.username:
+            self.user_name.username = self.name
+            self.user_name.save()
+        super().save(*args, **kwargs)
+
 
 class AcademicProfile(models.Model):
     """学术信息扩展模型 (与用户一对一关联)"""

+ 24 - 2
userprofile/serializers.py

@@ -1,13 +1,15 @@
 from rest_framework import serializers
 from .models import Users, AcademicProfile, ResearchGroup, GroupMembership
 
+
 class UsersGetSerializer(serializers.ModelSerializer):
    
     roles = serializers.SerializerMethodField()
     introduction = serializers.SerializerMethodField()
+    avatar_url = serializers.SerializerMethodField()
     class Meta:
         model = Users
-        fields = ['user_id','name', 'roles','avatar', 'introduction','create_time', 'update_time']
+        fields = ['user_id','name', 'roles','avatar_url', 'introduction','create_time', 'update_time','email', 'phone', 'address']
 
     def get_roles(self, obj):
 
@@ -15,4 +17,24 @@ class UsersGetSerializer(serializers.ModelSerializer):
         return ['admin']
 
     def get_introduction(self, obj):
-        return 'I am a super administrator'
+        return 'I am a super administrator'
+    
+    def get_avatar_url(self, obj):
+        if obj.avatar:
+            # 返回绝对URL
+            request = self.context.get('request')
+            return request.build_absolute_uri(obj.avatar.url) 
+        return None
+    
+class UsersPostSerializer(serializers.ModelSerializer):
+    class Meta:
+        model = Users
+        fields = ['name', 'email', 'phone', 'address']
+
+
+
+class AcademicProfileGetSerializer(serializers.ModelSerializer):
+    user = UsersGetSerializer(read_only=True)
+    class Meta:
+        model = AcademicProfile
+        fields = ['user', 'degree', 'department', 'university', 'advisor', 'thesis_title', 'thesis_abstract', 'thesis_keywords']

+ 4 - 2
userprofile/urls.py

@@ -3,8 +3,10 @@ from . import views
 
 urlpatterns=[
 
-path('profile/', views.UserprofileViewSet.as_view({'get': 'list'}), name='groups'),
+path('profile/', views.UserprofileViewSet.as_view({'get': 'list','post': 'update'}), name='groups'),
+re_path(r'^profile/(?P<pk>[0-9]+)/$', views.UserprofileViewSet.as_view({'get': 'retrieve', 'post': 'update', 'patch': 'partial_update', 'delete': 'destroy'}), name='groups'),
+
+path('avatar-upload/', views.AvatarUploadView.as_view(), name='groups')
 
-re_path(r'^profile/(?P<pk>[0-9]+)/$', views.UserprofileViewSet.as_view({'get': 'retrieve', 'put': 'update', 'patch': 'partial_update', 'delete': 'destroy'}), name='groups')
 
 ]

+ 166 - 15
userprofile/views.py

@@ -2,11 +2,18 @@ from rest_framework import viewsets
 from utils.page import MyPageNumberPagination
 from rest_framework.filters import OrderingFilter
 from django_filters.rest_framework import DjangoFilterBackend
+from rest_framework.response import Response
+from rest_framework import status
+from rest_framework.parsers import MultiPartParser, FormParser
+from rest_framework.views import APIView
+import os
+import uuid
+from django.conf import settings
 
 from .models import Users, AcademicProfile, ResearchGroup, GroupMembership
 from .filter import UsersFilter, AcademicProfileFilter, ResearchGroupFilter, GroupMembershipFilter
-from .serializers import UsersGetSerializer
-
+from .serializers import UsersGetSerializer,UsersPostSerializer
+from .serializers import AcademicProfileGetSerializer
 
 class UserprofileViewSet(viewsets.ModelViewSet):
     """
@@ -36,11 +43,21 @@ class UserprofileViewSet(viewsets.ModelViewSet):
 
     def get_project(self):
         try:
-            id = self.kwargs.get('pk')
-            return id
+            # 表格通道
+            if 'pk' in self.kwargs:
+                id = self.kwargs.get('pk')
+                return id
+            else:
+            # 用户通道
+                user = self.request.auth
+                if user.roles == 'USR':
+                    return user.user_id
+                else:
+                    return None
         except:
             return None
 
+
     def get_queryset(self):
         project_id = self.get_project()
         if project_id:
@@ -51,28 +68,53 @@ class UserprofileViewSet(viewsets.ModelViewSet):
     def get_serializer_class(self):
         if self.action in ['list','retrieve']:
             return UsersGetSerializer
+        elif self.action in ['create', 'update', 'partial_update']:
+            return UsersPostSerializer
         else:
             return UsersGetSerializer
         
     def create(self, request, *args, **kwargs):
         serializer = self.get_serializer(data=request.data)
         serializer.is_valid(raise_exception=True)
-        self.perform_create(serializer)
+        serializer.save()
+
         headers = self.get_success_headers(serializer.data)
         return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
     
+    # 表格的写法
     def update(self, request, *args, **kwargs):
-        qs = self.get_object()
-        serializer = self.get_serializer(qs, data=request.data, partial=True)
-        serializer.is_valid(raise_exception=True)
-        self.perform_update(serializer)
-        return Response(serializer.data)    
+        if 'pk' in self.kwargs:
+
+            qs = self.get_object()
+            serializer = self.get_serializer(qs, data=request.data, partial=True)
+            serializer.is_valid(raise_exception=True)
+            serializer.save()
+            return Response(serializer.data, status=status.HTTP_200_OK, headers=self.get_success_headers(serializer.data))
+        else:
+            # 没有qs,应该是管理员更新自己的信息
+            qs = request.auth
+            serializer = self.get_serializer(qs, data=request.data, partial=True)
+            serializer.is_valid(raise_exception=True)
+            serializer.save()
+            return Response(serializer.data, status=status.HTTP_200_OK, headers=self.get_success_headers(serializer.data))
+        
+ 
+
     
+    # 
+    # def update(self, request, *args, **kwargs):
+    #     qs = request.auth
+    #     serializer = self.get_serializer(qs, data=request.data, partial=True)
+    #     serializer.is_valid(raise_exception=True)
+    #     serializer.save()
+    #     return Response(serializer.data, status=status.HTTP_200_OK, headers=self.get_success_headers(serializer.data))
+
     def partial_update(self, request, *args, **kwargs):
         qs = self.get_object()
         serializer = self.get_serializer(qs, data=request.data, partial=True)
         serializer.is_valid(raise_exception=True)
-        self.perform_update(serializer)
+        serializer.save()
+
         return Response(serializer.data)
     
     def destroy(self, request, *args, **kwargs):
@@ -81,10 +123,119 @@ class UserprofileViewSet(viewsets.ModelViewSet):
         qs.save()
         return Response(status=status.HTTP_204_NO_CONTENT)
     
-    def perform_create(self, serializer):
-        serializer.save()
+
+    
+class AvatarUploadView(APIView):
+    parser_classes = [MultiPartParser]
     
-    def perform_update(self, serializer):
+    def post(self, request):
+        avatar_file = request.FILES.get('avatar')
+        if not avatar_file:
+            return Response({"error": "未上传头像文件"}, status=400)
+        
+        # 生成唯一文件名
+        ext = os.path.splitext(avatar_file.name)[1]
+        filename = f"{uuid.uuid4().hex}{ext}"
+        
+        # 目标目录
+        avatars_dir = os.path.join(settings.MEDIA_ROOT, 'avatars')
+        
+        # 确保目录存在
+        if not os.path.exists(avatars_dir):
+            os.makedirs(avatars_dir)
+        
+        # 保存文件
+        save_path = os.path.join(avatars_dir, filename)
+        with open(save_path, 'wb+') as destination:
+            for chunk in avatar_file.chunks():
+                destination.write(chunk)
+        
+        # 返回相对路径(前端会拼接完整URL)
+        avatar_url = f"/avatars/{filename}"
+        
+        # 更新用户头像
+        user = request.auth
+        user.avatar = avatar_url
+        user.save()
+        
+        # 传递 request 到序列化器上下文
+        serializer = UsersGetSerializer(user, context={'request': request})
+        return Response(serializer.data)
+
+class AcademicProfileViewSet(viewsets.ModelViewSet):
+    """
+    学术资料视图集
+        retrieve:
+            Response a data list (get)
+
+        list:
+            Response a data list (all)
+
+        create:
+            Create a data line (post)
+
+        delete:
+            Delete a data line (delete)
+
+        partial_update:
+            Partial_update a data (patch:partial_update)
+
+        update:
+            Update a data (put:update)
+    """
+    fliter_backends = (DjangoFilterBackend, OrderingFilter)
+    ordering_fields = ('create_time', 'update_time')
+    filterset_class = AcademicProfileFilter
+    pagination_class = MyPageNumberPagination
+
+    def get_project(self):
+        try:
+            id = self.kwargs.get('pk')
+            return id
+        except:
+            return None
+
+    def get_queryset(self):
+        project_id = self.get_project()
+        if project_id:
+            return AcademicProfile.objects.filter(user_id=project_id)
+        else:
+            return AcademicProfile.objects.all()
+
+    def get_serializer_class(self):
+        if self.action in ['list','retrieve']:
+            return UsersGetSerializer
+        elif self.action in ['create', 'update', 'partial_update']:
+            return UsersPostSerializer
+        else:
+            return UsersGetSerializer
+        
+    def create(self, request, *args, **kwargs):
+        serializer = self.get_serializer(data=request.data)
+        serializer.is_valid(raise_exception=True)
         serializer.save()
+
+        headers = self.get_success_headers(serializer.data)
+        return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
     
- 
+    # 表格的写法
+    # def update(self, request, *args, **kwargs):
+    #     qs = self.get_object()
+    #     serializer = self.get_serializer(qs, data=request.data, partial=True)
+    #     serializer.is_valid(raise_exception=True)
+    #     self.perform_update(serializer)
+    #     return Response(serializer.data)   
+    # 
+    def update(self, request, *args, **kwargs):
+        qs = request.auth
+        serializer = self.get_serializer(qs, data=request.data, partial=True)
+        serializer.is_valid(raise_exception=True)
+        serializer.save()
+        return Response(serializer.data, status=status.HTTP_200_OK, headers=self.get_success_headers(serializer.data))
+
+    def partial_update(self, request, *args, **kwargs):
+        qs = self.get_object()
+        serializer = self.get_serializer(qs, data=request.data, partial=True)
+        serializer.is_valid(raise_exception=True)
+        serializer.save()
+

+ 12 - 8
userregister/views.py

@@ -64,18 +64,22 @@ def register(request, *args, **kwargs):
                             # 后续开发者用户需要手动修改用户信息
                             if Users.objects.filter().count() == 0:
                                 userzl = User.objects.create_user(username='adminzl',password=str(123456))
-                                Users.objects.create(user_id=userzl.id, name='adminzl',
-                                                 openid="adminzl", appid="adminzl",
-                                                 t_code=Md5.md5(str(timezone.now())),
-                                                 developer=1, ip=ip)
+                                Users.objects.create(
+                                                user_name = userzl,
+                                                user_id=userzl.id, name='adminzl',
+                                                openid="adminzl", appid="adminzl",
+                                                t_code=Md5.md5(str(timezone.now())),
+                                                developer=1, ip=ip)
                                 
                             transaction_code = Md5.md5(data['name'])
                             user = User.objects.create_user(username=str(data['name']),
                                                             password=str(data['password1']))
-                            Users.objects.create(user_id=user.id, name=str(data['name']),
-                                                 openid=transaction_code, appid=Md5.md5(data['name'] + '1'),
-                                                 t_code=Md5.md5(str(timezone.now())),
-                                                 developer=1, ip=ip)
+                            Users.objects.create(
+                                                user_name = user,
+                                                user_id=user.id, name=str(data['name']),
+                                                openid=transaction_code, appid=Md5.md5(data['name'] + '1'),
+                                                t_code=Md5.md5(str(timezone.now())),
+                                                developer=1, ip=ip)
                             auth.login(request, user)