Browse Source

Merge branch 'master' into lhd

natasha 1 week ago
parent
commit
2e8b23dd07

+ 8 - 0
src/api/list.js

@@ -162,3 +162,11 @@ export function PageQueryPinyinCorrectionListAdmin(data) {
 export function GetAuditorList_Select(data) {
   return http.post(`${process.env.VUE_APP_EepServer}?MethodName=book_audit_manager-GetAuditorList_Select`, data);
 }
+
+/**
+ * @description 分页查询我的消息
+ * @param {object} data
+ */
+export function PageQueryMyMessage(data) {
+  return http.post(`${process.env.VUE_APP_EepServer}?MethodName=page_query-PageQueryMyMessage`, data);
+}

+ 28 - 0
src/api/project.js

@@ -345,3 +345,31 @@ export function ProjectRequestOperate(data) {
 export function ProjectAuditOperate(data) {
   return http.post(`${process.env.VUE_APP_EepServer}?MethodName=project_manager-ProjectAuditOperate`, data);
 }
+
+/**
+ * @description 邀请人员加入项目
+ * @param {object} data
+ * @param {string} data.user_id_list 用户ID列表
+ * @param {string} data.project_id 项目ID
+ */
+export function InvitePersonJoinProject(data) {
+  return http.post(`${process.env.VUE_APP_EepServer}?MethodName=project_manager-InvitePersonJoinProject`, data);
+}
+
+/**
+ * @description 获取项目邀请人员列表
+ * @param {object} data
+ * @param {string} data.project_id 项目ID
+ */
+export function GetProjectInvitePersonList(data) {
+  return http.post(`${process.env.VUE_APP_EepServer}?MethodName=project_manager-GetProjectInvitePersonList`, data);
+}
+
+/**
+ * @description 删除项目邀请人员
+ * @param {object} data
+ * @param {string} data.id 邀请ID
+ */
+export function DeleteProjectInvitePerson(data) {
+  return http.post(`${process.env.VUE_APP_EepServer}?MethodName=project_manager-DeleteProjectInvitePerson`, data);
+}

+ 19 - 0
src/api/user.js

@@ -47,3 +47,22 @@ export function getSysConfigMailbox() {
 export function setSysConfigMailbox(data) {
   return http.post(`${process.env.VUE_APP_EepServer}?MethodName=sys_config_manager-SetSysConfig_Mailbox`, data);
 }
+
+/**
+ * @description 回复消息
+ * @param {object} data
+ * @param {string} data.id
+ * @param {1|2} data.reply_result 1-同意 2-不同意
+ */
+export function ReplyMessage(data) {
+  return http.post(`${process.env.VUE_APP_EepServer}?MethodName=message_manager-ReplyMessage`, data);
+}
+
+/**
+ * @description 得到我的消息个数
+ * @param {object} data
+ * @param {-1|0|1|2} data.reply_result 回复结果 -1【全部】0【未回复】1【同意】2【不同意】
+ */
+export function GetMyMessageCount(data) {
+  return http.post(`${process.env.VUE_APP_EepServer}?MethodName=message_manager-GetMyMessageCount`, data);
+}

+ 7 - 1
src/components/PaginationPage.vue

@@ -30,6 +30,10 @@ export default {
       type: Number,
       default: 7,
     },
+    isInit: {
+      type: Boolean,
+      default: true,
+    },
   },
   data() {
     return {
@@ -38,7 +42,9 @@ export default {
     };
   },
   created() {
-    this.getList();
+    if (this.isInit) {
+      this.getList();
+    }
   },
   methods: {
     changePage(number) {

+ 177 - 129
src/components/PinyinText.vue

@@ -33,7 +33,7 @@
                   }"
                 >
                   <span class="pinyin" :style="getPinyinStyle(word)"> {{ getCharPinyin(word, cIndex) }}</span>
-                  <span class="py-char" :style="textStyle(word)">{{ convertText(char) }}</span>
+                  <span class="py-char" :style="getCharStyle(word, block, cIndex)">{{ convertText(char) }}</span>
                 </span>
               </span>
             </span>
@@ -231,143 +231,24 @@ export default {
       let oldIndex = -1;
       let paragraphIndex = 0;
       const tagStack = [];
+
       for (const item of this.richTextList) {
         oldIndex += 1;
 
         if (item.text && typeof item.text === 'string' && item.text.includes('<img')) {
-          // 处理图片
-          const imgMatch = item.text.match(/<img\s+([^>]*)\/?\s*>/i);
-          if (imgMatch) {
-            const attrs = imgMatch[1];
-            const srcMatch = attrs.match(/src=["']([^"']*)["']/i);
-            const altMatch = attrs.match(/alt=["']([^"']*)["']/i);
-            const widthMatch = attrs.match(/width=["']?(\d+)["']?/i);
-            const heightMatch = attrs.match(/height=["']?(\d+)["']?/i);
-            const styleMatch = attrs.match(/style=["']([^"']*)["']/i);
-
-            // 获取父容器样式(从 tagStack 中累积)
-            const containerStyleObj = {};
-            tagStack.forEach((tagItem) => {
-              if (tagItem.style) {
-                tagItem.style.split(';').forEach((rule) => {
-                  if (rule.trim()) {
-                    const [prop, value] = rule.split(':').map((s) => s.trim());
-                    if (prop && value) {
-                      const camelProp = prop.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
-                      containerStyleObj[camelProp] = value;
-                    }
-                  }
-                });
-              }
-            });
-
-            // 解析图片自身样式
-            let imageStyleObj = {};
-            if (styleMatch) {
-              const styleStr = styleMatch[1];
-              styleStr.split(';').forEach((rule) => {
-                if (rule.trim()) {
-                  const [prop, value] = rule.split(':').map((s) => s.trim());
-                  if (prop && value) {
-                    const camelProp = prop.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
-                    imageStyleObj[camelProp] = value;
-                  }
-                }
-              });
-            }
-
-            blocks.push({
-              type: 'image',
-              src: srcMatch ? srcMatch[1] : '',
-              alt: altMatch ? altMatch[1] : '',
-              width: widthMatch ? widthMatch[1] : null,
-              height: heightMatch ? heightMatch[1] : null,
-              containerStyle: containerStyleObj,
-              imageStyle: imageStyleObj,
-            });
-          }
+          blocks.push(this.parseImageBlock(item, tagStack));
         } else if (item.is_style === 'true' || item.is_style === true) {
-          const tagText = item.text || '';
-          // 正则匹配闭标签:匹配 </tagname> 格式
-          const closeTagMatch = tagText.match(/^<\/(\w+)>$/);
-          if (closeTagMatch) {
-            // 闭标签:从栈顶开始查找最近的同名标签并移除(LIFO 原则)
-            const tagName = closeTagMatch[1];
-            this.removeTagFromStack(tagStack, tagName);
-          } else {
-            // 开标签:解析标签名和样式并压栈
-            const openTagMatch = tagText.match(/^<(\w+)([^>]*)>$/);
-            if (openTagMatch) {
-              const tagName = openTagMatch[1];
-              const attrs = openTagMatch[2];
-              // 解析 style 属性
-              const styleMatch = attrs.match(/style=["']([^"']*)["']/);
-              let style = styleMatch ? styleMatch[1] : null;
-
-              // 将无 style 属性的标签转换为对应的 CSS 样式
-              if (!style) {
-                const tagStyleMap = {
-                  strong: 'font-weight: bold',
-                  b: 'font-weight: bold',
-                  em: 'font-style: italic',
-                  i: 'font-style: italic',
-                  u: 'text-decoration: underline',
-                  s: 'text-decoration: line-through',
-                  del: 'text-decoration: line-through',
-                  sub: 'vertical-align: sub',
-                  sup: 'vertical-align: super',
-                };
-                style = tagStyleMap[tagName] || null;
-              }
-
-              tagStack.push({
-                tag: tagName,
-                style,
-              });
-            }
-          }
+          this.handleStyleTag(item, tagStack);
         } else if (item.text === '\n') {
-          // 处理换行符
-          blocks.push({
-            type: 'newline',
-          });
+          blocks.push({ type: 'newline' });
           paragraphIndex += 1;
         } else if (item.word_list && item.word_list.length > 0) {
-          // 处理文字内容:将当前标签栈中的样式应用到文字块
-          // 合并当前所有打开标签的样式
-          let combinedStyle = '';
-          tagStack.forEach((tagItem) => {
-            if (tagItem.style) {
-              combinedStyle += `${tagItem.style};`;
-            }
-          });
-
-          // 将样式字符串转换为对象格式
-          let styleObj = null;
-          if (combinedStyle) {
-            styleObj = {};
-            combinedStyle.split(';').forEach((rule) => {
-              if (rule.trim()) {
-                const [prop, value] = rule.split(':').map((s) => s.trim());
-                if (prop && value) {
-                  // 将驼峰命名转为 kebab-case(如 backgroundColor -> background-color)
-                  const camelProp = prop.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
-                  styleObj[camelProp] = value;
-                }
-              }
-            });
-          }
-
-          blocks.push({
-            type: 'text',
-            word_list: item.word_list,
-            index: textBlockIndex++,
-            oldIndex,
-            paragraphIndex,
-            styleObj, // 应用累积的样式对象
-          });
+          const hasEmphasisDot = this.checkHasEmphasisDot(tagStack);
+          blocks.push(this.createTextBlock(item, tagStack, textBlockIndex, oldIndex, paragraphIndex, hasEmphasisDot));
+          textBlockIndex += 1;
         }
       }
+
       return blocks;
     },
   },
@@ -380,8 +261,170 @@ export default {
       deep: true,
     },
   },
-
   methods: {
+    // 检查标签栈中是否有着重点样式
+    checkHasEmphasisDot(tagStack) {
+      return tagStack.some((tagItem) => {
+        if (!tagItem.className) return false;
+        return tagItem.className.includes('rich-text-emphasis-dot');
+      });
+    },
+
+    // 解析图片块
+    parseImageBlock(item, tagStack) {
+      const imgMatch = item.text.match(/<img\s+([^>]*)\/?\s*>/i);
+      if (!imgMatch) return null;
+
+      const attrs = imgMatch[1];
+      const srcMatch = attrs.match(/src=["']([^"']*)["']/i);
+      const altMatch = attrs.match(/alt=["']([^"']*)["']/i);
+      const widthMatch = attrs.match(/width=["']?(\d+)["']?/i);
+      const heightMatch = attrs.match(/height=["']?(\d+)["']?/i);
+      const styleMatch = attrs.match(/style=["']([^"']*)["']/i);
+
+      const containerStyleObj = {};
+      tagStack.forEach((tagItem) => {
+        if (tagItem.style) {
+          this.mergeStyleString(containerStyleObj, tagItem.style);
+        }
+      });
+
+      const imageStyleObj = {};
+      if (styleMatch) {
+        this.mergeStyleString(imageStyleObj, styleMatch[1]);
+      }
+
+      return {
+        type: 'image',
+        src: srcMatch ? srcMatch[1] : '',
+        alt: altMatch ? altMatch[1] : '',
+        width: widthMatch ? widthMatch[1] : null,
+        height: heightMatch ? heightMatch[1] : null,
+        containerStyle: containerStyleObj,
+        imageStyle: imageStyleObj,
+      };
+    },
+
+    // 合并样式字符串到对象
+    mergeStyleString(styleObj, styleStr) {
+      styleStr.split(';').forEach((rule) => {
+        if (rule.trim()) {
+          const [prop, value] = rule.split(':').map((s) => s.trim());
+          if (prop && value) {
+            const camelProp = prop.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
+            styleObj[camelProp] = value;
+          }
+        }
+      });
+    },
+
+    // 处理样式标签
+    handleStyleTag(item, tagStack) {
+      const tagText = item.text || '';
+      const closeTagMatch = tagText.match(/^<\/(\w+)>$/);
+
+      if (closeTagMatch) {
+        const tagName = closeTagMatch[1];
+        this.removeTagFromStack(tagStack, tagName);
+      } else {
+        const openTagMatch = tagText.match(/^<(\w+)([^>]*)>$/);
+        if (openTagMatch) {
+          const tagName = openTagMatch[1];
+          const attrs = openTagMatch[2];
+          const { style, className } = this.extractTagInfo(attrs, tagName);
+          tagStack.push({ tag: tagName, style, className });
+        }
+      }
+    },
+
+    // 提取标签信息(样式和类名)
+    extractTagInfo(attrs, tagName) {
+      const styleMatch = attrs.match(/style=["']([^"']*)["']/);
+      let style = styleMatch ? styleMatch[1] : null;
+
+      const classMatch = attrs.match(/class=["']([^"']*)["']/);
+      const className = classMatch ? classMatch[1] : '';
+
+      if (!style) {
+        style = this.getDefaultTagStyle(tagName);
+      }
+
+      return { style, className };
+    },
+
+    // 获取标签默认样式
+    getDefaultTagStyle(tagName) {
+      const tagStyleMap = {
+        strong: 'font-weight: bold',
+        b: 'font-weight: bold',
+        em: 'font-style: italic',
+        i: 'font-style: italic',
+        u: 'text-decoration: underline',
+        s: 'text-decoration: line-through',
+        del: 'text-decoration: line-through',
+        sub: 'vertical-align: sub',
+        sup: 'vertical-align: super',
+      };
+      return tagStyleMap[tagName] || null;
+    },
+
+    // 创建文本块
+    createTextBlock(item, tagStack, textBlockIndex, oldIndex, paragraphIndex, hasEmphasisDot) {
+      const combinedStyle = tagStack
+        .filter((tagItem) => tagItem.style)
+        .map((tagItem) => tagItem.style)
+        .join(';');
+
+      const styleObj = combinedStyle ? this.parseStyleToObject(combinedStyle) : null;
+
+      return {
+        type: 'text',
+        word_list: item.word_list,
+        index: textBlockIndex,
+        oldIndex,
+        paragraphIndex,
+        styleObj,
+        hasEmphasisDot,
+      };
+    },
+
+    // 解析样式字符串为对象
+    parseStyleToObject(styleStr) {
+      const styleObj = {};
+      if (!styleStr) return styleObj;
+
+      this.mergeStyleString(styleObj, styleStr);
+      return styleObj;
+    },
+
+    // 获取单个字符的样式(包括着重点)
+    getCharStyle(word, block, charIndex) {
+      const baseStyle = { ...word.activeTextStyle };
+      baseStyle['font-size'] = baseStyle.fontSize;
+      baseStyle['font-family'] = baseStyle.fontFamily;
+
+      if (this.isAllSetting) {
+        baseStyle['font-size'] = this.fontSize;
+        baseStyle['font-family'] = this.fontFamily;
+        this.isAllSetting = false;
+      }
+
+      // 如果该文字块有着重点标记,应用到字符上
+      if (block.hasEmphasisDot) {
+        const letterSpacing = block.styleObj?.letterSpacing || '0';
+        const letterSpacingValue = parseFloat(letterSpacing) || 0;
+
+        baseStyle['border-bottom'] = 'none';
+        baseStyle['background-image'] = 'radial-gradient(circle at center, currentColor 0.15em, transparent 0.16em)';
+        baseStyle['background-repeat'] = 'repeat-x';
+        baseStyle['background-position'] = `${letterSpacingValue * -0.5}em 100%`;
+        baseStyle['background-size'] = `${1 + letterSpacingValue}em 0.3em`;
+        baseStyle['padding-bottom'] = '0.3em';
+        baseStyle['display'] = 'inline';
+      }
+
+      return baseStyle;
+    },
     // 兼容历史数据
     getPinyinText(item) {
       return this.checkShowPinyin(item.showPinyin) ? item.pinyin.replace(/\s+/g, '') : '\u200B';
@@ -391,6 +434,11 @@ export default {
       if (!this.checkShowPinyin(word.showPinyin)) {
         return '\u200B';
       }
+      // 优先使用新的 pinyin_list 字段(与字符一一对应)
+      if (word.pinyin_list && Array.isArray(word.pinyin_list)) {
+        return word.pinyin_list[charIndex] || '\u200B';
+      }
+      // 兼容旧数据:使用 pinyin 字段
       const pinyinList = word.pinyin ? word.pinyin.trim().split(/\s+/) : [];
       return pinyinList[charIndex] || '\u200B';
     },

+ 6 - 4
src/components/RichText.vue

@@ -159,15 +159,16 @@ export default {
             line-height: 1.2 !important; /* 避免行高影响 */
           }
           .rich-text-emphasis-dot {
+           --letter-spacing-value: attr(data-letter-spacing number, 0);
             border-bottom: none;
             background-image: radial-gradient(
               circle at center,
               currentColor 0.15em,  /* 圆点大小相对于字体 */
               transparent 0.16em
             );
-            background-size: 1em 0.3em; /* 间距相对于字体大小,高度相对字体 */
+            background-size: calc((1 + var(--letter-spacing-value)) * 1em) 0.3em;
             background-repeat: repeat-x;
-            background-position: 0 100%;
+            background-position: calc(var(--letter-spacing-value) * -0.5em) 100%;
             padding-bottom: 0.3em; /* 间距也相对于字体 */
             display: inline;
           }
@@ -222,7 +223,8 @@ export default {
               const formatName = `letterSpacing${config}_em`;
               editor.formatter.register(formatName, {
                 inline: 'span',
-                styles: { 'letter-spacing': `${config}em` },
+                styles: { 'letter-spacing': `${config}em`},
+                attributes: { 'data-letter-spacing': `${config}` },
                 wrapper: true,
                 remove_similar: true,
               });
@@ -261,7 +263,7 @@ export default {
             }
             this.editorIsInited = true;
           });
-
+          
           // 自定义行高下拉(因为没有内置 lineheight 插件)
           editor.ui.registry.addMenuButton('lineheight', {
             text: '行高',

+ 156 - 0
src/layouts/components/MyMessage.vue

@@ -0,0 +1,156 @@
+<template>
+  <el-dialog
+    :visible="visible"
+    title="我的消息"
+    class="my-message"
+    width="1000px"
+    :close-on-click-modal="false"
+    :append-to-body="true"
+    @close="dialogClose"
+  >
+    <div class="query-criteria">
+      <span class="criteria-label">回复状态</span>
+      <el-select v-model="replyResult" placeholder="请选择回复状态">
+        <el-option v-for="(label, value) in replyResultMap" :key="value" :label="label" :value="value" />
+      </el-select>
+
+      <span class="query-button">
+        <el-button type="primary" @click="pageQueryMyMessage">查询</el-button>
+      </span>
+    </div>
+
+    <el-table ref="user" :data="messageList" height="440">
+      <el-table-column label="序号" width="55" header-align="center" align="center">
+        <template slot-scope="scope">
+          {{ scope.$index + 1 }}
+        </template>
+      </el-table-column>
+      <el-table-column label="类型" width="110" header-align="center" align="center">
+        <template slot-scope="{ row }">
+          {{ messageTypeMap[row.type] || '未知类型' }}
+        </template>
+      </el-table-column>
+      <el-table-column prop="message" label="消息" width="120" header-align="center" />
+      <el-table-column prop="create_time" label="邀请时间" width="160" header-align="center" align="center" />
+      <el-table-column prop="creator_name" label="邀请人" width="100" header-align="center" align="center" />
+      <el-table-column label="回复结果" width="80" header-align="center" align="center">
+        <template slot-scope="{ row }">
+          {{ replyResultMap[row.reply_result] || '未知状态' }}
+        </template>
+      </el-table-column>
+      <el-table-column prop="reply_time" label="回复时间" header-align="center" align="center" />
+
+      <el-table-column fixed="right" label="操作" width="100" header-align="center" align="center">
+        <template v-if="row.type === 0" slot-scope="{ row }">
+          <span v-if="row.type === 0" class="link" @click="replyMessage(row.id, 1)">同意</span>
+          <span style="margin: 0 6px">|</span>
+          <span class="link" @click="replyMessage(row.id, 2)">拒绝</span>
+        </template>
+      </el-table-column>
+    </el-table>
+    <PaginationPage :total="total" @getList="pageQueryMyMessage" />
+  </el-dialog>
+</template>
+
+<script>
+import { PageQueryMyMessage } from '@/api/list';
+import { ReplyMessage } from '@/api/user';
+
+import PaginationPage from '@/components/PaginationPage.vue';
+
+export default {
+  name: 'MyMessage',
+  components: {
+    PaginationPage,
+  },
+  props: {
+    visible: {
+      type: Boolean,
+      default: false,
+    },
+  },
+  data() {
+    return {
+      // 消息列表数据
+      messageList: [],
+      replyResult: '0', // 默认选择未回复
+      replyResultMap: {
+        '-1': '全部',
+        0: '未回复',
+        1: '同意',
+        2: '不同意',
+      },
+      total: 0, // 消息总数
+      page_capacity: 10,
+      cur_page: 1,
+      messageTypeMap: {
+        0: '邀请加入项目',
+      },
+    };
+  },
+  methods: {
+    pageQueryMyMessage(data) {
+      const params = {
+        reply_result: this.replyResult,
+        page_capacity: this.page_capacity,
+        cur_page: this.cur_page,
+        ...data,
+      };
+      PageQueryMyMessage(params).then(({ message_list, total, cur_page }) => {
+        this.messageList = message_list || [];
+        this.total = total || 0;
+        this.page_capacity = data?.page_capacity || this.page_capacity;
+        this.cur_page = cur_page;
+      });
+    },
+    replyMessage(id, reply_result) {
+      ReplyMessage({
+        id,
+        reply_result,
+      })
+        .then(() => {
+          this.$message.success('回复成功');
+          this.pageQueryMyMessage();
+        })
+        .catch(() => {});
+    },
+    dialogClose() {
+      this.$emit('update:visible', false);
+    },
+  },
+};
+</script>
+
+<style lang="scss" scoped>
+.my-message {
+  .query-criteria {
+    display: flex;
+    column-gap: 8px;
+    align-items: center;
+    margin-bottom: 12px;
+
+    .criteria-label {
+      white-space: nowrap;
+    }
+
+    .el-input {
+      width: 200px;
+    }
+
+    .el-select {
+      width: 100px;
+    }
+
+    :deep .el-input__inner {
+      background-color: #fff;
+      border-color: #dcdcdc;
+    }
+
+    .query-button {
+      display: flex;
+      flex: 1;
+      justify-content: flex-end;
+    }
+  }
+}
+</style>

+ 71 - 5
src/layouts/default/header/index.vue

@@ -22,27 +22,40 @@
     <!-- 用户头像和用户名 -->
     <el-dropdown v-else trigger="click" class="user">
       <span class="el-dropdown-link">
-        <img
-          class="avatar"
-          :src="token.image_url ? token.image_url : require('@/assets/header/avatar-default.png')"
-          alt="head portrait"
-        />
+        <span class="avatar-wrap">
+          <img
+            class="avatar"
+            :src="token.image_url ? token.image_url : require('@/assets/header/avatar-default.png')"
+            alt="head portrait"
+          />
+          <span v-if="messageCount > 0" class="message-badge">{{ displayMessageCount }}</span>
+        </span>
         <span class="real_name">{{ token.user_real_name }}</span>
       </span>
       <el-dropdown-menu slot="dropdown" class="user-menu">
+        <el-dropdown-item @click.native="showMessageList">
+          <i class="el-icon-message"></i><span>我的消息</span>
+        </el-dropdown-item>
         <el-dropdown-item @click.native="logout">
           <img :src="require('@/assets/header/exit.png')" /><span>退出登录</span>
         </el-dropdown-item>
       </el-dropdown-menu>
     </el-dropdown>
+    <MyMessage :visible.sync="visibleMessage" />
   </header>
 </template>
 
 <script>
 import { getToken } from '@/utils/auth';
+import { GetMyMessageCount } from '@/api/user';
+
+import MyMessage from '@/layouts/components/MyMessage.vue';
 
 export default {
   name: 'LayoutHeader',
+  components: {
+    MyMessage,
+  },
   data() {
     const token = getToken();
     const popedomCodeList = this.$store.state.user?.popedom_code_list || [];
@@ -78,12 +91,27 @@ export default {
       activePro: '',
       LoginNavIndex: 0,
       projectList: projectListMap[userType]?.filter((item) => item.isShow !== false) || projectListMap.USER,
+      messageCount: 0,
+      visibleMessage: false,
     };
   },
+  computed: {
+    displayMessageCount() {
+      return this.messageCount > 99 ? '99+' : this.messageCount;
+    },
+  },
+  watch: {
+    visibleMessage(newVal) {
+      if (!newVal) {
+        this.getMyMessageCount();
+      }
+    },
+  },
   created() {
     let path = this.$route.path.replace(/^\//, '');
     this.activePro = path;
     this.LoginNavIndex = this.projectList.findIndex((item) => item.key === path);
+    this.getMyMessageCount();
   },
   methods: {
     logout() {
@@ -100,6 +128,14 @@ export default {
       this.activePro = key;
       this.$router.push({ path: `/${key}` });
     },
+    getMyMessageCount() {
+      GetMyMessageCount({ reply_result: 0 }).then(({ count }) => {
+        this.messageCount = count || 0;
+      });
+    },
+    showMessageList() {
+      this.visibleMessage = true;
+    },
   },
 };
 </script>
@@ -165,12 +201,37 @@ export default {
       display: flex;
       align-items: center;
 
+      .avatar-wrap {
+        position: relative;
+        display: inline-flex;
+      }
+
       .avatar {
         width: 32px;
         height: 32px;
         border-radius: 50%;
       }
 
+      .message-badge {
+        position: absolute;
+        top: -6px;
+        right: -10px;
+        box-sizing: border-box;
+        display: inline-flex;
+        align-items: center;
+        justify-content: center;
+        min-width: 18px;
+        height: 18px;
+        padding: 0 5px;
+        font-size: 12px;
+        font-weight: 600;
+        line-height: 1;
+        color: #fff;
+        background: #f56c6c;
+        border: 1px solid #fff;
+        border-radius: 999px;
+      }
+
       .real_name {
         display: inline-block;
         padding-left: 10px;
@@ -193,6 +254,11 @@ export default {
     font-size: 16px;
     color: #000;
 
+    i {
+      margin-right: 10px;
+      font-size: 20px;
+    }
+
     img {
       width: 24px;
       height: 24px;

+ 68 - 6
src/layouts/home/header/index.vue

@@ -11,42 +11,79 @@
     <!-- 用户头像和用户名 -->
     <el-dropdown v-else trigger="click" class="user">
       <span class="el-dropdown-link">
-        <img
-          class="avatar"
-          :src="token.image_url ? token.image_url : require('@/assets/header/avatar-default.png')"
-          alt="head portrait"
-        />
+        <span class="avatar-wrap">
+          <img
+            class="avatar"
+            :src="token.image_url ? token.image_url : require('@/assets/header/avatar-default.png')"
+            alt="head portrait"
+          />
+          <span v-if="messageCount > 0" class="message-badge">{{ displayMessageCount }}</span>
+        </span>
         <span class="real_name">{{ token.user_real_name }}</span>
       </span>
       <el-dropdown-menu slot="dropdown" class="user-menu">
         <el-dropdown-item @click.native="$router.push('/personal_center')">
           <img :src="require('@/assets/header/personal-center.png')" /><span>个人中心</span>
         </el-dropdown-item>
+        <el-dropdown-item @click.native="showMessageList">
+          <i class="el-icon-message"></i><span>我的消息</span>
+        </el-dropdown-item>
         <el-dropdown-item @click.native="logout">
           <img :src="require('@/assets/header/exit.png')" /><span>退出登录</span>
         </el-dropdown-item>
       </el-dropdown-menu>
     </el-dropdown>
+    <MyMessage :visible.sync="visibleMessage" />
   </header>
 </template>
 
 <script>
 import { getToken } from '@/utils/auth';
+import { GetMyMessageCount } from '@/api/user';
+
+import MyMessage from '@/layouts/components/MyMessage.vue';
 
 export default {
   name: 'HomeHeader',
+  components: {
+    MyMessage,
+  },
   data() {
     const token = getToken();
     return {
       token: this.$store.state.user || token,
+      messageCount: 0,
+      visibleMessage: false,
     };
   },
-  created() {},
+  computed: {
+    displayMessageCount() {
+      return this.messageCount > 99 ? '99+' : this.messageCount;
+    },
+  },
+  watch: {
+    visibleMessage(newVal) {
+      if (!newVal) {
+        this.getMyMessageCount();
+      }
+    },
+  },
+  created() {
+    this.getMyMessageCount();
+  },
   methods: {
     logout() {
       this.$store.dispatch('user/signOut');
       this.$router.push('/login');
     },
+    getMyMessageCount() {
+      GetMyMessageCount({ reply_result: 0 }).then(({ count }) => {
+        this.messageCount = count || 0;
+      });
+    },
+    showMessageList() {
+      this.visibleMessage = true;
+    },
   },
 };
 </script>
@@ -91,12 +128,37 @@ export default {
       display: flex;
       align-items: center;
 
+      .avatar-wrap {
+        position: relative;
+        display: inline-flex;
+      }
+
       .avatar {
         width: 32px;
         height: 32px;
         border-radius: 50%;
       }
 
+      .message-badge {
+        position: absolute;
+        top: -6px;
+        right: -10px;
+        box-sizing: border-box;
+        display: inline-flex;
+        align-items: center;
+        justify-content: center;
+        min-width: 18px;
+        height: 18px;
+        padding: 0 5px;
+        font-size: 12px;
+        font-weight: 600;
+        line-height: 1;
+        color: #fff;
+        background: #f56c6c;
+        border: 1px solid #fff;
+        border-radius: 999px;
+      }
+
       .real_name {
         display: inline-block;
         padding-left: 10px;

+ 7 - 4
src/styles/index.scss

@@ -95,11 +95,14 @@ ul {
 /* 富文本着重点 */
 .rich-text-emphasis-dot {
   display: inline;
-  padding-bottom: 0.2em; /* 间距也相对于字体 */
-  background-image: radial-gradient(circle at center, currentColor 0.15em, /* 圆点大小相对于字体 */ transparent 0.16em);
+  padding-bottom: 0.2em;
+
+  --letter-spacing-value: attr(data-letter-spacing number, 0);
+
+  background-image: radial-gradient(circle at center, currentColor 0.15em, transparent 0.16em);
   background-repeat: repeat-x;
-  background-position: 0 100%;
-  background-size: 1em 0.3em; /* 间距相对于字体大小,高度相对字体 */
+  background-position: calc(var(--letter-spacing-value) * -0.5em) 100%;
+  background-size: calc((1 + var(--letter-spacing-value)) * 1em) 0.3em;
   border-bottom: none;
 }
 

+ 1 - 0
src/views/book/courseware/create/components/base/rich_text/RichText.vue

@@ -3,6 +3,7 @@
     <template #content>
       <div :style="{ width: data.note_list.length > 0 ? '' : '100%' }">
         <RichText
+          v-if="property.isGetContent"
           ref="richText"
           v-model="data.content"
           :is-view-note="true"

+ 5 - 4
src/views/create_project/selectProjectMembers.vue

@@ -11,7 +11,7 @@
       <span class="criteria-label">真实姓名</span>
       <el-input v-model="real_name" placeholder="请输入姓名" @change="getUserList" />
       <span>所属机构</span>
-      <el-select v-model="org_id_list" placeholder="请选择机构" multiple @change="getUserList">
+      <el-select v-model="org_id" placeholder="请选择机构" disabled @change="getUserList">
         <el-option v-for="item in org_list" :key="item.id" :label="item.name" :value="item.id" />
       </el-select>
       <span class="query-button">
@@ -66,6 +66,7 @@
 <script>
 import { queryUserList } from '@/api/list';
 import { orgIndexList } from '@/api/user';
+import { getToken } from '@/utils/auth';
 
 import PaginationPage from '@/components/PaginationPage.vue';
 
@@ -93,10 +94,11 @@ export default {
     },
   },
   data() {
+    const token = getToken();
     return {
+      org_id: token.org_id,
       real_name: '', // 真实姓名
       org_list: [], // 机构列表
-      org_id_list: [], // 机构id列表
       user_list: [], // 用户列表
       total: 0,
       page_capacity: 10,
@@ -127,7 +129,7 @@ export default {
     getUserList(data) {
       const params = {
         real_name: this.real_name,
-        org_id_list: this.org_id_list,
+        org_id_list: [this.org_id],
         page_capacity: this.page_capacity,
         cur_page: this.cur_page,
         org_manager_status: -1,
@@ -168,7 +170,6 @@ export default {
     dialogClose() {
       this.$emit('update:visible', false);
       this.real_name = '';
-      this.org_id_list = [];
       this.selectedUsers = [];
       this.$refs.user.clearSelection();
     },

+ 140 - 0
src/views/personal_workbench/project/ProjectInfoManage.vue

@@ -157,6 +157,36 @@
           <span>{{ project.create_time }}</span>
         </div>
       </div>
+
+      <!-- 项目邀请成员 -->
+      <div class="project-invite-members">
+        <div class="invite-list-top">
+          <span class="title">项目邀请成员</span>
+          <span class="link" @click="visibleInvitePerson = true">邀请其它机构成员</span>
+        </div>
+        <div class="invite-list-header">
+          <span>序号</span>
+          <span>姓名</span>
+          <span>所属机构</span>
+          <span>邀请时间</span>
+          <span>回复结果</span>
+          <span>回复时间</span>
+          <span>操作</span>
+        </div>
+        <div class="invite-list">
+          <div v-for="(person, index) in invitePersonList" :key="index" class="invite-list-item">
+            <span>{{ index + 1 }}</span>
+            <span>{{ person.user_real_name }}</span>
+            <span>{{ person.user_org_name }}</span>
+            <span>{{ person.create_time }}</span>
+            <span>{{ replyResultMap[person.reply_result] }}</span>
+            <span>{{ person.reply_time }}</span>
+            <span>
+              <span class="link" @click="deleteProjectInvitePerson(person.id)">删除</span>
+            </span>
+          </div>
+        </div>
+      </div>
     </main>
 
     <SelectMembers
@@ -173,6 +203,7 @@
       :value="curFieldValue"
       @updateProjectFieldValue="updateProjectFieldValue"
     />
+    <InvitePerson :visible.sync="visibleInvitePerson" @invitePerson="handleInvitePerson" />
   </div>
 </template>
 
@@ -183,6 +214,9 @@ import {
   SetProjectLeader,
   UpdateProjectFieldValue,
   SetProjectLabel,
+  InvitePersonJoinProject,
+  GetProjectInvitePersonList,
+  DeleteProjectInvitePerson,
 } from '@/api/project';
 import { GetUserList_ID } from '@/api/user';
 
@@ -190,6 +224,7 @@ import SelectMembers from '@/views/create_project/selectProjectMembers.vue';
 import MenuPage from '@/views/personal_workbench/common/menu.vue';
 import UpdateProjectField from './components/UpdateProjectField.vue';
 import ProjectMenu from '@/views/project_manage/common/ProjectMenu.vue';
+import InvitePerson from './components/InvitePerson.vue';
 
 export default {
   name: 'ProjectInfoManage',
@@ -198,6 +233,7 @@ export default {
     MenuPage,
     UpdateProjectField,
     ProjectMenu,
+    InvitePerson,
   },
   data() {
     return {
@@ -239,10 +275,14 @@ export default {
       visibleUpdateField: false,
       curField: '',
       curFieldValue: '',
+      invitePersonList: [], // 项目邀请成员列表
+      replyResultMap: ['未回复', '同意', '不同意'], // 回复结果映射关系
+      visibleInvitePerson: false, // 是否显示邀请成员对话框
     };
   },
   created() {
     this.getProjectInfo();
+    this.getProjectInvitePersonList();
   },
   methods: {
     getProjectInfo() {
@@ -354,6 +394,34 @@ export default {
         this.getProjectInfo();
       });
     },
+    getProjectInvitePersonList() {
+      GetProjectInvitePersonList({ project_id: this.id }).then(({ invite_person_list }) => {
+        this.invitePersonList = invite_person_list;
+      });
+    },
+    /**
+     * 删除项目邀请成员
+     * @param {number} id - 邀请ID
+     */
+    deleteProjectInvitePerson(id) {
+      DeleteProjectInvitePerson({ id }).then(() => {
+        this.$message.success('邀请人删除成功');
+        this.getProjectInvitePersonList();
+      });
+    },
+    /**
+     * 处理邀请成员加入项目
+     * @param {string[]} user_id_list - 邀请的用户ID列表
+     */
+    handleInvitePerson(user_id_list) {
+      InvitePersonJoinProject({
+        project_id: this.id,
+        user_id_list,
+      }).then(() => {
+        this.$message.success('邀请发送成功');
+        this.getProjectInvitePersonList();
+      });
+    },
   },
 };
 </script>
@@ -462,5 +530,77 @@ export default {
       grid-template-columns: 120px 1fr;
     }
   }
+
+  .project-invite-members {
+    display: flex;
+    flex-direction: column;
+    margin-top: 24px;
+
+    .invite-list-top {
+      display: flex;
+      align-items: center;
+      justify-content: space-between;
+      padding: 8px 12px;
+      background-color: $fill-color;
+      border-bottom: $border;
+    }
+
+    @mixin column-width {
+      span {
+        padding: 8px 12px;
+
+        &:first-child {
+          width: 55px;
+        }
+
+        &:nth-child(2) {
+          width: 140px;
+        }
+
+        &:nth-child(3) {
+          width: 260px;
+        }
+
+        &:nth-child(4) {
+          width: 200px;
+        }
+
+        &:nth-child(5) {
+          width: 100px;
+        }
+
+        &:nth-child(6) {
+          width: 200px;
+        }
+
+        &:last-child {
+          flex: 1;
+        }
+      }
+    }
+
+    .invite-list-header {
+      display: flex;
+      font-size: 14px;
+      font-weight: bold;
+      text-align: center;
+      background-color: $main-background-color;
+      border-bottom: $border;
+
+      @include column-width;
+    }
+
+    .invite-list {
+      .invite-list-item {
+        display: flex;
+        align-items: center;
+        justify-content: center;
+        text-align: center;
+        border-bottom: $border;
+
+        @include column-width;
+      }
+    }
+  }
 }
 </style>

+ 150 - 0
src/views/personal_workbench/project/components/InvitePerson.vue

@@ -0,0 +1,150 @@
+<template>
+  <el-dialog
+    :visible="visible"
+    title="邀请其它机构成员"
+    class="invite-person"
+    width="900px"
+    :close-on-click-modal="false"
+    @close="dialogClose"
+  >
+    <div class="query-criteria">
+      <span class="criteria-label">真实姓名</span>
+      <el-input v-model="real_name" placeholder="请输入姓名" @change="getUserList" />
+      <span>所属机构</span>
+      <el-select v-model="org_id_list" placeholder="请选择机构" multiple @change="getUserList">
+        <el-option v-for="item in org_list" :key="item.id" :label="item.name" :value="item.id" />
+      </el-select>
+      <span class="query-button">
+        <el-button type="primary" @click="getUserList">查询</el-button>
+      </span>
+    </div>
+
+    <el-table ref="user" :data="user_list" height="440">
+      <el-table-column type="selection" width="55" align="center" header-align="center" class-name="index-column" />
+      <el-table-column prop="real_name" label="真实姓名" width="140" header-align="center" />
+      <el-table-column prop="user_name" label="用户名" width="140" header-align="center" />
+      <el-table-column prop="email" label="邮箱" width="200" header-align="center" />
+      <el-table-column prop="phone" label="电话" width="120" header-align="center" />
+      <el-table-column prop="org_name" label="所属机构" header-align="center" />
+    </el-table>
+    <PaginationPage :total="total" :is-init="false" @getList="getUserList" />
+
+    <div slot="footer">
+      <el-button @click="dialogClose">取消</el-button>
+      <el-button type="primary" @click="confirm">确定</el-button>
+    </div>
+  </el-dialog>
+</template>
+
+<script>
+import { queryUserList } from '@/api/list';
+import { orgIndexList } from '@/api/user';
+import { getToken } from '@/utils/auth';
+
+import PaginationPage from '@/components/PaginationPage.vue';
+
+export default {
+  name: 'InvitePerson',
+  components: {
+    PaginationPage,
+  },
+  props: {
+    visible: {
+      type: Boolean,
+      required: true,
+    },
+  },
+  data() {
+    const token = getToken();
+    return {
+      org_id: token.org_id,
+      real_name: '', // 真实姓名
+      org_list: [], // 机构列表
+      org_id_list: [], // 选择的机构ID列表
+      user_list: [], // 用户列表
+      total: 0,
+      page_capacity: 10,
+      cur_page: 1,
+    };
+  },
+  created() {
+    this.getOrgIndexList();
+  },
+  methods: {
+    getUserList(data) {
+      if (this.org_id_list.length === 0) {
+        this.$message.warning('请至少选择一个机构');
+        return;
+      }
+      const params = {
+        real_name: this.real_name,
+        org_id_list: this.org_id_list,
+        page_capacity: this.page_capacity,
+        cur_page: this.cur_page,
+        org_manager_status: -1,
+      };
+      queryUserList({ ...params, ...data }).then(({ user_list, total_count, cur_page }) => {
+        this.user_list = user_list;
+        this.total = total_count;
+        this.page_capacity = data?.page_capacity || this.page_capacity;
+        this.cur_page = cur_page;
+      });
+    },
+    getOrgIndexList() {
+      orgIndexList().then(({ org_list }) => {
+        this.org_list = org_list.filter((org) => org.id !== this.org_id);
+      });
+    },
+    dialogClose() {
+      this.$emit('update:visible', false);
+      this.$refs.user.clearSelection();
+    },
+    confirm() {
+      const selectedUsers = this.$refs.user.selection;
+      if (selectedUsers.length === 0) {
+        this.$message.warning('请至少选择一名成员');
+        return;
+      }
+      this.$emit(
+        'invitePerson',
+        selectedUsers.map((user) => user.id),
+      );
+      this.dialogClose();
+    },
+  },
+};
+</script>
+
+<style lang="scss" scoped>
+.invite-person {
+  .query-criteria {
+    display: flex;
+    column-gap: 8px;
+    align-items: center;
+    margin-bottom: 12px;
+
+    .criteria-label {
+      white-space: nowrap;
+    }
+
+    .el-input {
+      width: 200px;
+    }
+
+    .el-select {
+      width: 360px;
+    }
+
+    :deep .el-input__inner {
+      background-color: #fff;
+      border-color: #dcdcdc;
+    }
+
+    .query-button {
+      display: flex;
+      flex: 1;
+      justify-content: flex-end;
+    }
+  }
+}
+</style>