fan 5 years ago
parent
commit
387ba9640c
55 changed files with 3816 additions and 960 deletions
  1. 1 10
      .babelrc
  2. 530 25
      examples/App.vue
  3. BIN
      examples/assets/logo.png
  4. 0 114
      examples/components/HelloWorld.vue
  5. 2 2
      examples/main.js
  6. 0 1
      lemon-im
  7. 271 236
      package-lock.json
  8. 11 11
      package.json
  9. 72 0
      packages/components/avatar.vue
  10. 74 0
      packages/components/badge.vue
  11. 59 0
      packages/components/button.vue
  12. 0 43
      packages/components/contact-list/index.vue
  13. 134 0
      packages/components/contact.vue
  14. 295 0
      packages/components/editor.vue
  15. 0 30
      packages/components/group-list/index.vue
  16. 890 0
      packages/components/index.vue
  17. 0 226
      packages/components/lemon/index.vue
  18. 0 29
      packages/components/message-list/index.vue
  19. 0 31
      packages/components/message-view/index.vue
  20. 178 0
      packages/components/message/basic.vue
  21. 27 0
      packages/components/message/event.vue
  22. 59 0
      packages/components/message/file.vue
  23. 30 0
      packages/components/message/image.vue
  24. 35 0
      packages/components/message/text.vue
  25. 142 0
      packages/components/messages.vue
  26. 143 0
      packages/components/popover.vue
  27. 77 0
      packages/components/tabs.vue
  28. 0 24
      packages/element-ui.js
  29. 35 4
      packages/index.js
  30. 17 0
      packages/lastContentRender.js
  31. 5 5
      packages/message-type.txt
  32. 4 0
      packages/mixins/IMUIProxy.js
  33. 0 0
      packages/plugins/index.js
  34. 92 0
      packages/readme.txt
  35. 13 0
      packages/styles/common/animate.styl
  36. 42 0
      packages/styles/common/icons.styl
  37. 0 1
      packages/styles/common/index.scss
  38. 58 0
      packages/styles/common/index.styl
  39. 0 38
      packages/styles/common/normalize.scss
  40. 23 0
      packages/styles/common/normalize.styl
  41. 67 0
      packages/styles/utils/bem.styl
  42. 47 0
      packages/styles/utils/functional.styl
  43. 0 2
      packages/styles/utils/index.scss
  44. 5 0
      packages/styles/utils/index.styl
  45. 0 94
      packages/styles/utils/mixins.scss
  46. 0 30
      packages/styles/utils/var.scss
  47. 26 0
      packages/styles/utils/var.styl
  48. 0 3
      packages/utils/array-intersect.js
  49. 39 0
      packages/utils/cache/memory.js
  50. 16 0
      packages/utils/constant.js
  51. 130 0
      packages/utils/constraint.js
  52. 128 0
      packages/utils/index.js
  53. 37 0
      packages/utils/validate.js
  54. 1 1
      public/index.html
  55. 1 0
      vue.config.js

+ 1 - 10
.babelrc

@@ -7,14 +7,5 @@
         "modules": false
       }
     ]
-  ],
-  "plugins": [
-    [
-      "component",
-      {
-        "libraryName": "element-ui",
-        "styleLibraryName": "theme-chalk"
-      }
-    ]
   ]
-}
+}

+ 530 - 25
examples/App.vue

@@ -1,44 +1,549 @@
 <template>
   <div id="app">
-    <lemon-im v-bind="IMData"
-              @pull-friends-message="pullFriendsMessage"></lemon-im>
+    <lemon-imui
+      :user="user"
+      class="imui-center"
+      ref="IMUI"
+      @change-menu="handleChangeMenu"
+      @change-contact="handleChangeContact"
+      @pull-messages="handlePullMessages"
+      @send="handleSend"
+    >
+      <template #cover>
+        <h1 style="text-indent:20px">自定义封面内容</h1>
+      </template>
+      <!-- <template #contact-info="contact">
+        <span style="color:blue">contact-info {{ contact }}</span>
+      </template> -->
+      <!--
+      <template #drawer="contact">
+        <h1>自定义抽屉内容</h1>
+        <p>{{ contact }}</p>
+      </template>
+      -->
+      <template #contact-title="contact">
+        <span>{{ contact.displayName }}</span>
+        <small class="more" @click="changeDrawer(contact)">&#8230;</small>
+      </template>
+    </lemon-imui>
   </div>
 </template>
 
 <script>
 export default {
   name: "app",
-  data () {
-    this.friendData = {
-      "id": 1,
-      "diaplayName": "贤心",
-      "avatarPath": "a.jpg",
-      "sign": "这些都是测试数据,实际使用请严格按照该格式返回"
-    }
+  data() {
     return {
-      IMData: {
-        friends: [],
-        groups: [],
+      user: {
+        id: "superadmin",
+        displayName: "IMUI super",
+        avatar:
+          "https://ss3.bdstatic.com/70cFv8Sh_Q1YnxGkpoWK1HF6hhy/it/u=4085009425,1005454674&fm=26&gp=0.jpg"
       }
     };
   },
-  mounted: function () {
-    setInterval(() => {
-      this.IMData.friends.push({ ...this.friendData })
-      this.friendData.id++
-    }, 1000)
+  mounted() {
+    const contactData1 = {
+      id: "1",
+      displayName: "工作协作群",
+      avatar:
+        "https://img.ivsky.com/img/tupian/li/201903/24/richu_riluo-015.jpg",
+      type: "single",
+      index: "A",
+      unread: 0,
+      lastSendTime: 1566047865417,
+      lastContent: "2"
+    };
+    const contactData2 = {
+      id: "2",
+      displayName: "马林",
+      avatar: "https://img.ivsky.com/img/tupian/li/201902/27/yanjing_meinv.jpg",
+      type: "single",
+      index: "B",
+      click(next) {
+        next();
+      },
+      renderContainer: () => {
+        return <h1 style="text-indent:20px">自定义页面</h1>;
+      },
+      lastSendTime: 1345209465000,
+      lastContent: "12312",
+      unread: 2
+    };
+    const contactData3 = {
+      id: "3",
+      displayName: "范君",
+      avatar:
+        "https://img.ivsky.com/img/tupian/li/201903/21/huahuan_xiaonvhai.jpg",
+      type: "many",
+      index: "C",
+      lastSendTime: 3
+    };
+
+    const { IMUI } = this.$refs;
+
+    setTimeout(() => {
+      //IMUI.openDrawer();
+      // IMUI.openDrawer(() => {
+      //   return [
+      //     <h1>123</h1>,
+      //     <h1>123</h1>,
+      //     <h1>123</h1>,
+      //     <h1>123</h1>,
+      //     <h1>123</h1>,
+      //     <h1>123</h1>,
+      //     <h1>123</h1>,
+      //     <h1>123</h1>,
+      //     <h1>123</h1>,
+      //     <h1>123</h1>,
+      //     <h1>123</h1>,
+      //     <h1>123</h1>,
+      //     <h1>123</h1>,
+      //     <h1>123</h1>
+      //   ];
+      // });
+      // setTimeout(() => {
+      //   IMUI.openDrawer(() => {
+      //     return <h1>124563</h1>;
+      //   });
+      // }, 2000);
+    }, 2000);
+    //[contactData1, contactData2, contactData3]
+    let data = [
+      { ...contactData1 },
+      { ...contactData2 },
+      { ...contactData3 }
+      //...Array(100).fill(contactData1)
+    ];
+
+    IMUI.initContacts(data);
+    IMUI.initEmoji([
+      {
+        label: "表情",
+        children: [
+          {
+            name: "1f600",
+            title: "微笑",
+            src: "https://twemoji.maxcdn.com/2/72x72/1f600.png"
+          },
+          {
+            name: "1f62c",
+            title: "微笑",
+            src: "https://twemoji.maxcdn.com/2/72x72/1f62c.png"
+          },
+          {
+            name: "1f601",
+            title: "微笑",
+            src: "https://twemoji.maxcdn.com/2/72x72/1f601.png"
+          },
+          {
+            name: "1f602",
+            title: "微笑",
+            src: "https://twemoji.maxcdn.com/2/72x72/1f602.png"
+          },
+          {
+            name: "1f923",
+            title: "微笑",
+            src: "https://twemoji.maxcdn.com/2/72x72/1f923.png"
+          },
+          {
+            name: "1f973",
+            title: "微笑",
+            src: "https://twemoji.maxcdn.com/2/72x72/1f973.png"
+          },
+          {
+            name: "1f603",
+            title: "微笑",
+            src: "https://twemoji.maxcdn.com/2/72x72/1f603.png"
+          },
+          {
+            name: "1f604",
+            title: "微笑",
+            src: "https://twemoji.maxcdn.com/2/72x72/1f604.png"
+          },
+          {
+            name: "1f605",
+            title: "微笑",
+            src: "https://twemoji.maxcdn.com/2/72x72/1f605.png"
+          },
+          {
+            name: "1f606",
+            title: "微笑",
+            src: "https://twemoji.maxcdn.com/2/72x72/1f606.png"
+          },
+          {
+            name: "1f607",
+            title: "微笑",
+            src: "https://twemoji.maxcdn.com/2/72x72/1f607.png"
+          },
+          {
+            name: "1f609",
+            title: "微笑",
+            src: "https://twemoji.maxcdn.com/2/72x72/1f609.png"
+          },
+          {
+            name: "1f60a",
+            title: "微笑",
+            src: "https://twemoji.maxcdn.com/2/72x72/1f60a.png"
+          },
+          {
+            name: "1f642",
+            title: "微笑",
+            src: "https://twemoji.maxcdn.com/2/72x72/1f642.png"
+          },
+          {
+            name: "1f643",
+            title: "微笑",
+            src: "https://twemoji.maxcdn.com/2/72x72/1f643.png"
+          },
+          {
+            name: "1263a",
+            title: "微笑",
+            src: "https://twemoji.maxcdn.com/2/72x72/263a.png"
+          },
+          {
+            name: "1f60b",
+            title: "微笑",
+            src: "https://twemoji.maxcdn.com/2/72x72/1f60b.png"
+          },
+          {
+            name: "1f60c",
+            title: "微笑",
+            src: "https://twemoji.maxcdn.com/2/72x72/1f60c.png"
+          },
+          {
+            name: "1f60d",
+            title: "微笑",
+            src: "https://twemoji.maxcdn.com/2/72x72/1f60d.png"
+          },
+          {
+            name: "1f970",
+            title: "微笑",
+            src: "https://twemoji.maxcdn.com/2/72x72/1f970.png"
+          },
+          {
+            name: "1f618",
+            title: "微笑",
+            src: "https://twemoji.maxcdn.com/2/72x72/1f618.png"
+          },
+          {
+            name: "1f617",
+            title: "微笑",
+            src: "https://twemoji.maxcdn.com/2/72x72/1f617.png"
+          },
+          {
+            name: "1f619",
+            title: "微笑",
+            src: "https://twemoji.maxcdn.com/2/72x72/1f619.png"
+          },
+          {
+            name: "1f61a",
+            title: "微笑",
+            src: "https://twemoji.maxcdn.com/2/72x72/1f61a.png"
+          },
+          {
+            name: "1f61c",
+            title: "微笑",
+            src: "https://twemoji.maxcdn.com/2/72x72/1f61c.png"
+          },
+          {
+            name: "1f92a",
+            title: "微笑",
+            src: "https://twemoji.maxcdn.com/2/72x72/1f92a.png"
+          },
+          {
+            name: "1f928",
+            title: "微笑",
+            src: "https://twemoji.maxcdn.com/2/72x72/1f928.png"
+          },
+          {
+            name: "1f9d0",
+            title: "微笑",
+            src: "https://twemoji.maxcdn.com/2/72x72/1f9d0.png"
+          },
+          {
+            name: "1f61d",
+            title: "微笑",
+            src: "https://twemoji.maxcdn.com/2/72x72/1f61d.png"
+          },
+          {
+            name: "1f61b",
+            title: "微笑",
+            src: "https://twemoji.maxcdn.com/2/72x72/1f61b.png"
+          },
+          {
+            name: "1f911",
+            title: "微笑",
+            src: "https://twemoji.maxcdn.com/2/72x72/1f911.png"
+          },
+          {
+            name: "1f913",
+            title: "微笑",
+            src: "https://twemoji.maxcdn.com/2/72x72/1f913.png"
+          },
+          {
+            name: "1f60e",
+            title: "微笑",
+            src: "https://twemoji.maxcdn.com/2/72x72/1f60e.png"
+          },
+          {
+            name: "1f929",
+            title: "微笑",
+            src: "https://twemoji.maxcdn.com/2/72x72/1f929.png"
+          },
+          {
+            name: "1f921",
+            title: "微笑",
+            src: "https://twemoji.maxcdn.com/2/72x72/1f921.png"
+          },
+          {
+            name: "1f920",
+            title: "微笑",
+            src: "https://twemoji.maxcdn.com/2/72x72/1f920.png"
+          },
+          {
+            name: "1f917",
+            title: "微笑",
+            src: "https://twemoji.maxcdn.com/2/72x72/1f917.png"
+          },
+          {
+            name: "1f60f",
+            title: "微笑",
+            src: "https://twemoji.maxcdn.com/2/72x72/1f60f.png"
+          },
+          {
+            name: "1f636",
+            title: "微笑",
+            src: "https://twemoji.maxcdn.com/2/72x72/1f636.png"
+          },
+          {
+            name: "1f610",
+            title: "微笑",
+            src: "https://twemoji.maxcdn.com/2/72x72/1f610.png"
+          },
+          {
+            name: "1f611",
+            title: "微笑",
+            src: "https://twemoji.maxcdn.com/2/72x72/1f611.png"
+          },
+          {
+            name: "1f612",
+            title: "微笑",
+            src: "https://twemoji.maxcdn.com/2/72x72/1f612.png"
+          },
+          {
+            name: "1f644",
+            title: "微笑",
+            src: "https://twemoji.maxcdn.com/2/72x72/1f644.png"
+          },
+          {
+            name: "1f914",
+            title: "微笑",
+            src: "https://twemoji.maxcdn.com/2/72x72/1f914.png"
+          },
+          {
+            name: "1f925",
+            title: "微笑",
+            src: "https://twemoji.maxcdn.com/2/72x72/1f925.png"
+          },
+          {
+            name: "1f92d",
+            title: "微笑",
+            src: "https://twemoji.maxcdn.com/2/72x72/1f92d.png"
+          },
+          {
+            name: "1f92b",
+            title: "微笑",
+            src: "https://twemoji.maxcdn.com/2/72x72/1f92b.png"
+          },
+          {
+            name: "1f92c",
+            title: "微笑",
+            src: "https://twemoji.maxcdn.com/2/72x72/1f92c.png"
+          },
+          {
+            name: "1f92f",
+            title: "微笑",
+            src: "https://twemoji.maxcdn.com/2/72x72/1f92f.png"
+          },
+          {
+            name: "1f633",
+            title: "微笑",
+            src: "https://twemoji.maxcdn.com/2/72x72/1f633.png"
+          },
+          {
+            name: "1f61e",
+            title: "微笑",
+            src: "https://twemoji.maxcdn.com/2/72x72/1f61e.png"
+          },
+          {
+            name: "1f61f",
+            title: "微笑",
+            src: "https://twemoji.maxcdn.com/2/72x72/1f61f.png"
+          },
+          {
+            name: "1f620",
+            title: "微笑",
+            src: "https://twemoji.maxcdn.com/2/72x72/1f620.png"
+          },
+          {
+            name: "1f621",
+            title: "微笑",
+            src: "https://twemoji.maxcdn.com/2/72x72/1f621.png"
+          }
+        ]
+      },
+      {
+        label: "武器",
+        children: [
+          {
+            name: "wx",
+            src: "http://"
+          }
+        ]
+      }
+    ]);
+
+    setTimeout(() => {
+      IMUI.updateContact("3", {
+        unread: 100,
+        //displayName: "123",
+        lastSendTime: 3,
+        lastContent: "你好123"
+      });
+    }, 2000);
   },
   methods: {
-    pullFriendsMessage (data, resolve, reject) {
-      resolve([{ ...this.friendData }])
-      //resolve([...this.friendData.id])
-      this.friendData.id++
+    changeDrawer(contact) {
+      this.$refs.IMUI.changeDrawer(() => {
+        return [<h2>自定义抽屉</h2>, contact.displayName];
+      });
     },
-    pullGroups () {
-
-    }
+    handleChangeContact(contact) {
+      this.$refs.IMUI.updateContact(contact.id, {
+        //displayName: "123",
+        unread: 0
+      });
+      this.$refs.IMUI.closeDrawer();
+    },
+    handleSend(message, next, file) {
+      setTimeout(() => {
+        next();
+      }, 1000);
+    },
+    handlePullMessages(contact, next) {
+      const messages = [
+        {
+          id: "8ad7e98e-5225-4892-8131-4b2ee7797599",
+          type: "text",
+          status: "succeed",
+          sendTime: 1564926674646,
+          fromContactId: "superadmin",
+          fromUser: {
+            id: "hehe",
+            displayName: "I KNOEW",
+            avatar:
+              "https://ss3.bdstatic.com/70cFv8Sh_Q1YnxGkpoWK1HF6hhy/it/u=4085009425,1005454674&fm=26&gp=0.jpg"
+          },
+          content: "测试消息哦..."
+        },
+        {
+          id: "8ad7e98e-5225-4892-8131-4b2ee7797599",
+          type: "text",
+          status: "succeed",
+          sendTime: 1564926674646,
+          fromContactId: "superadmin",
+          fromUser: {
+            id: "superadmin",
+            displayName: "超级飞机",
+            avatar:
+              "https://ss3.bdstatic.com/70cFv8Sh_Q1YnxGkpoWK1HF6hhy/it/u=4085009425,1005454674&fm=26&gp=0.jpg"
+          },
+          content: "测试消息哦..."
+        },
+        {
+          id: "8ad7e98e-5225-4892-8131-4b2ee7797599",
+          type: "text",
+          status: "succeed",
+          sendTime: 1564926674646,
+          fromContactId: "superadmin",
+          fromUser: {
+            id: "hehe",
+            displayName: "I KNOEW",
+            avatar:
+              "https://ss3.bdstatic.com/70cFv8Sh_Q1YnxGkpoWK1HF6hhy/it/u=4085009425,1005454674&fm=26&gp=0.jpg"
+          },
+          content: "测试消息哦..."
+        },
+        {
+          id: "8ad7e98e-5225-4892-8131-4b2ee7797599",
+          type: "text",
+          status: "succeed",
+          sendTime: 1564926674646,
+          fromContactId: "superadmin",
+          fromUser: {
+            id: "superadmin",
+            displayName: "超级飞机",
+            avatar:
+              "https://ss3.bdstatic.com/70cFv8Sh_Q1YnxGkpoWK1HF6hhy/it/u=4085009425,1005454674&fm=26&gp=0.jpg"
+          },
+          content: "测试消息哦..."
+        },
+        {
+          id: "8ad7e98e-5225-4892-8131-4b2ee7797599",
+          type: "text",
+          status: "succeed",
+          sendTime: 1564926674646,
+          fromContactId: "superadmin",
+          fromUser: {
+            id: "hehe",
+            displayName: "I KNOEW",
+            avatar:
+              "https://ss3.bdstatic.com/70cFv8Sh_Q1YnxGkpoWK1HF6hhy/it/u=4085009425,1005454674&fm=26&gp=0.jpg"
+          },
+          content: "测试消息哦..."
+        },
+        {
+          id: "8ad7e98e-5225-4892-8131-4b2ee7797599",
+          type: "text",
+          status: "succeed",
+          sendTime: 1564926674646,
+          fromContactId: "superadmin",
+          fromUser: {
+            id: "superadmin",
+            displayName: "超级飞机",
+            avatar:
+              "https://ss3.bdstatic.com/70cFv8Sh_Q1YnxGkpoWK1HF6hhy/it/u=4085009425,1005454674&fm=26&gp=0.jpg"
+          },
+          content: "测试消息哦..."
+        }
+      ];
+      next(messages);
+    },
+    handleChangeMenu() {},
+    openCustomContainer() {}
   }
 };
 </script>
 
-<style lang="sass"></style>
+<style lang="stylus">
+body
+  background #384558 !important
+.imui-center
+  position absolute
+  top 50%
+  left 50%
+  transform translate(-50%,-50%)
+.more
+  font-size 32px
+  line-height 18px
+  height 32px
+  position absolute
+  top 6px
+  right 14px
+  cursor pointer
+  user-select none
+  color #999
+  &:active
+    color #000
+</style>

BIN
examples/assets/logo.png


+ 0 - 114
examples/components/HelloWorld.vue

@@ -1,114 +0,0 @@
-<template>
-  <div class="hello">
-    <h1>{{ msg }}</h1>
-    <p>
-      For a guide and recipes on how to configure / customize this project,<br />
-      check out the
-      <a href="https://cli.vuejs.org" target="_blank" rel="noopener"
-        >vue-cli documentation</a
-      >.
-    </p>
-    <h3>Installed CLI Plugins</h3>
-    <ul>
-      <li>
-        <a
-          href="https://github.com/vuejs/vue-cli/tree/dev/packages/%40vue/cli-plugin-babel"
-          target="_blank"
-          rel="noopener"
-          >babel</a
-        >
-      </li>
-      <li>
-        <a
-          href="https://github.com/vuejs/vue-cli/tree/dev/packages/%40vue/cli-plugin-eslint"
-          target="_blank"
-          rel="noopener"
-          >eslint</a
-        >
-      </li>
-    </ul>
-    <h3>Essential Links</h3>
-    <ul>
-      <li>
-        <a href="https://vuejs.org" target="_blank" rel="noopener">Core Docs</a>
-      </li>
-      <li>
-        <a href="https://forum.vuejs.org" target="_blank" rel="noopener"
-          >Forum</a
-        >
-      </li>
-      <li>
-        <a href="https://chat.vuejs.org" target="_blank" rel="noopener"
-          >Community Chat</a
-        >
-      </li>
-      <li>
-        <a href="https://twitter.com/vuejs" target="_blank" rel="noopener"
-          >Twitter</a
-        >
-      </li>
-      <li>
-        <a href="https://news.vuejs.org" target="_blank" rel="noopener">News</a>
-      </li>
-    </ul>
-    <h3>Ecosystem</h3>
-    <ul>
-      <li>
-        <a href="https://router.vuejs.org" target="_blank" rel="noopener"
-          >vue-router</a
-        >
-      </li>
-      <li>
-        <a href="https://vuex.vuejs.org" target="_blank" rel="noopener">vuex</a>
-      </li>
-      <li>
-        <a
-          href="https://github.com/vuejs/vue-devtools#vue-devtools"
-          target="_blank"
-          rel="noopener"
-          >vue-devtools</a
-        >
-      </li>
-      <li>
-        <a href="https://vue-loader.vuejs.org" target="_blank" rel="noopener"
-          >vue-loader</a
-        >
-      </li>
-      <li>
-        <a
-          href="https://github.com/vuejs/awesome-vue"
-          target="_blank"
-          rel="noopener"
-          >awesome-vue</a
-        >
-      </li>
-    </ul>
-  </div>
-</template>
-
-<script>
-export default {
-  name: "HelloWorld",
-  props: {
-    msg: String
-  }
-};
-</script>
-
-<!-- Add "scoped" attribute to limit CSS to this component only -->
-<style scoped lang="scss">
-h3 {
-  margin: 40px 0 0;
-}
-ul {
-  list-style-type: none;
-  padding: 0;
-}
-li {
-  display: inline-block;
-  margin: 0 10px;
-}
-a {
-  color: #42b983;
-}
-</style>

+ 2 - 2
examples/main.js

@@ -1,7 +1,7 @@
 import Vue from "vue";
 import App from "./App.vue";
-import LemonIM from "../packages";
-Vue.use(LemonIM);
+import LemonIMUI from "../packages";
+Vue.use(LemonIMUI);
 
 Vue.config.productionTip = false;
 

+ 0 - 1
lemon-im

@@ -1 +0,0 @@
-Subproject commit ad3c67e0d2afb6c81e2797350ec0468f4ad67d1d

File diff suppressed because it is too large
+ 271 - 236
package-lock.json


+ 11 - 11
package.json

@@ -1,14 +1,13 @@
 {
-  "name": "lemon-im",
-  "version": "0.1.0",
-  "private": true,
+  "name": "lemon-imui",
+  "version": "1.0.2",
+  "main": "dist/index.umd.min.js",
   "scripts": {
     "serve": "vue-cli-service serve",
-    "build": "vue-cli-service build",
+    "build": "vue-cli-service build --target lib --name index packages/index.js",
     "lint": "vue-cli-service lint"
   },
   "dependencies": {
-    "element-ui": "^2.8.2",
     "vue": "^2.6.10"
   },
   "devDependencies": {
@@ -16,12 +15,13 @@
     "@vue/cli-plugin-eslint": "^3.6.0",
     "@vue/cli-service": "^3.6.0",
     "@vue/eslint-config-prettier": "^4.0.1",
-    "babel-eslint": "^10.0.1",
-    "babel-plugin-component": "^1.1.1",
+    "babel-eslint": "^10.0.2",
     "eslint": "^5.16.0",
-    "eslint-plugin-vue": "^5.0.0",
-    "node-sass": "^4.9.0",
-    "sass-loader": "^7.1.0",
+    "eslint-plugin-vue": "^5.2.3",
+    "stylus": "^0.54.5",
+    "stylus-loader": "^3.0.2",
     "vue-template-compiler": "^2.5.21"
-  }
+  },
+  "author": "fanjun",
+  "license": "MIT"
 }

+ 72 - 0
packages/components/avatar.vue

@@ -0,0 +1,72 @@
+<script>
+export default {
+  name: "LemonAvatar",
+  props: {
+    src: String,
+    icon: {
+      type: String,
+      default: "lemon-icon-people"
+    },
+    size: {
+      type: Number,
+      default: 32
+    }
+  },
+  data() {
+    return {
+      imageFinishLoad: true
+    };
+  },
+  render() {
+    return (
+      <span
+        style={this.style}
+        class="lemon-avatar"
+        on-click={e => this.$emit("click", e)}
+      >
+        {this.imageFinishLoad && <i class={this.icon} />}
+        <img src={this.src} onLoad={this._handleLoad} />
+      </span>
+    );
+  },
+  computed: {
+    style() {
+      const size = `${this.size}px`;
+      return {
+        width: size,
+        height: size,
+        lineHeight: size,
+        fontSize: `${this.size / 2}px`
+      };
+    }
+  },
+  methods: {
+    _handleLoad() {
+      this.imageFinishLoad = false;
+    }
+  }
+};
+</script>
+<style lang="stylus">
+@import '~styles/utils/index'
++b(lemon-avatar)
+  font-variant tabular-nums
+  line-height 1.5
+  box-sizing border-box
+  margin 0
+  padding 0
+  list-style none
+  display inline-block
+  text-align center
+  background #ccc
+  color rgba(255,255,255,0.7)
+  white-space nowrap
+  position relative
+  overflow hidden
+  vertical-align middle
+  border-radius 4px
+  img
+    width 100%
+    height 100%
+    display block
+</style>

+ 74 - 0
packages/components/badge.vue

@@ -0,0 +1,74 @@
+<script>
+export default {
+  name: "LemonBadge",
+  props: {
+    count: [Number, Boolean],
+    overflowCount: {
+      type: Number,
+      default: 99
+    }
+  },
+  render() {
+    return (
+      <span class="lemon-badge">
+        {this.$slots.default}
+        {this.count !== 0 && this.count !== undefined && (
+          <span
+            class={[
+              "lemon-badge__label",
+              this.isDot && "lemon-badge__label--dot"
+            ]}
+          >
+            {this.label}
+          </span>
+        )}
+      </span>
+    );
+  },
+  computed: {
+    isDot() {
+      return this.count === true;
+    },
+    label() {
+      if (this.isDot) return "";
+      return this.count > this.overflowCount
+        ? `${this.overflowCount}+`
+        : this.count;
+    }
+  },
+  methods: {}
+};
+</script>
+<style lang="stylus">
+@import '~styles/utils/index'
++b(lemon-badge)
+  position relative
+  display inline-block
+  +e(label)
+    border-radius 10px
+    background #f5222d
+    color #fff
+    text-align center
+    font-size 12px
+    font-weight normal
+    white-space nowrap
+    box-shadow 0 0 0 1px #fff
+    z-index 10
+    position absolute
+    transform  translateX(50%)
+    transform-origin  100%
+    display inline-block
+    padding 0 4px
+    height 18px
+    line-height 17px
+    min-width 10px
+    top -4px
+    right 6px
+    +m(dot)
+      width 10px
+      height 10px
+      min-width auto
+      padding 0
+      top -3px
+      right 2px
+</style>

+ 59 - 0
packages/components/button.vue

@@ -0,0 +1,59 @@
+<script>
+export default {
+  name: "LemonButton",
+  props: {
+    disabled: Boolean
+  },
+  render() {
+    return (
+      <button
+        class="lemon-button"
+        disabled={this.disabled}
+        type="button"
+        on-click={this._handleClick}
+      >
+        {this.$slots.default}
+      </button>
+    );
+  },
+  methods: {
+    _handleClick(e) {
+      this.$emit("click", e);
+    }
+  }
+};
+</script>
+<style lang="stylus">
+@import '~styles/utils/index'
++b(lemon-button)
+  outline none
+  line-height 1.499
+  display inline-block
+  font-weight 400
+  text-align center
+  touch-action manipulation
+  cursor pointer
+  background-image none
+  border 1px solid #ddd
+  box-sizing border-box
+  white-space nowrap
+  padding 0 15px
+  font-size 14px
+  border-radius 4px
+  height 32px
+  user-select none
+  transition all 0.3s cubic-bezier(0.645, 0.045, 0.355, 1)
+  color rgba(0, 0, 0, 0.65)
+  background-color #fff
+  box-shadow 0 2px 0 rgba(0, 0, 0, 0.015)
+  text-shadow 0 -1px 0 rgba(0, 0, 0, 0.12)
+  &:hover:not([disabled])
+    border-color #666
+    color #333
+  &:active
+    background-color #ddd
+  &[disabled]
+    cursor not-allowed
+    color #aaa
+    background #eee
+</style>

+ 0 - 43
packages/components/contact-list/index.vue

@@ -1,43 +0,0 @@
-<template>
-  <div class='lemon-contact-list'>
-    <div class="lemon-contact-item"
-         v-for="item in control.friends"
-         :key="item.id"
-         @click="control._changeMessageView(item)">
-      {{item.diaplayName}}
-    </div>
-
-  </div>
-</template>
-
-<script>
-export default {
-  name: 'ContactList',
-  inject: ["control"],
-  data () {
-    return {
-
-    };
-  },
-  computed: {},
-  watch: {},
-  methods: {
-
-  },
-  created () {
-
-  },
-  mounted () {
-  },
-}
-</script>
-<style lang='scss'>
-@import '~styles/utils/index';
-@include b(contact-item) {
-  padding: 10px 15px;
-  cursor: pointer;
-  &:hover {
-    background: #000;
-  }
-}
-</style>

+ 134 - 0
packages/components/contact.vue

@@ -0,0 +1,134 @@
+<script>
+import { isString, isToday } from "utils/validate";
+import { timeFormat } from "utils";
+export default {
+  name: "LemonContact",
+  components: {},
+  data() {
+    return {};
+  },
+  props: {
+    contact: Object,
+    simple: Boolean,
+    timeFormat: {
+      type: Function,
+      default(val) {
+        return timeFormat(val, isToday(val) ? "h:i" : "y/m/d");
+      }
+    }
+  },
+  render() {
+    const { contact } = this;
+    return (
+      <div
+        class={["lemon-contact", { "lemon-contact--name-center": this.simple }]}
+        on-click={e => this._handleClick(e, contact)}
+      >
+        <lemon-badge
+          count={!this.simple ? contact.unread : 0}
+          class="lemon-contact__avatar"
+          native-on-click={e => this._handleBubbleClick(e, contact)}
+        >
+          <lemon-avatar
+            size={40}
+            native-on-click={e => this._handleAvatarClick(e, contact)}
+            src={contact.avatar}
+          />
+        </lemon-badge>
+        <div class="lemon-contact__inner">
+          <p class="lemon-contact__label">
+            <span class="lemon-contact__name">{contact.displayName}</span>
+            {!this.simple && (
+              <span class="lemon-contact__time">
+                {this.timeFormat(contact.lastSendTime)}
+              </span>
+            )}
+          </p>
+          {!this.simple && (
+            <p class="lemon-contact__content">
+              {isString(contact.lastContent) ? (
+                <span domProps={{ innerHTML: contact.lastContent }} />
+              ) : (
+                contact.lastContent
+              )}
+            </p>
+          )}
+        </div>
+      </div>
+    );
+  },
+  created() {},
+  mounted() {},
+  computed: {},
+  watch: {},
+  methods: {
+    _handleClick(e, data) {
+      this.$emit("click", data);
+    },
+    _handleAvatarClick(e, data) {
+      e.stopPropagation();
+      this.$emit("avatar-click", data);
+    },
+    _handleBubbleClick(e, data) {
+      e.stopPropagation();
+      this.$emit("bubble-click", data);
+    }
+  }
+};
+</script>
+<style lang="stylus">
+@import '~styles/utils/index'
++b(lemon-contact)
+  padding 10px 14px
+  cursor pointer
+  user-select none
+  box-sizing border-box
+  overflow hidden
+  background #efefef
+  p
+    margin 0
+  +m(active)
+    background #bebdbd
+  &:hover:not(.lemon-contact--active)
+    background #e3e3e3
+    .el-badge__content
+      border-color #ddd
+  +e(avatar)
+    float left
+    margin-right 10px
+    img
+      display block
+    .ant-badge-count
+      display inline-block
+      padding 0 4px
+      height 18px
+      line-height 18px
+      min-width 18px
+      top -4px
+      right 7px
+  +e(label)
+    display flex
+  +e(time)
+    font-size 12px
+    line-height 18px
+    padding-left 6px
+    color #999
+    white-space nowrap
+  +e(name)
+    display block
+    width 100%
+    ellipsis()
+  +e(content)
+    font-size 12px
+    color #999
+    ellipsis()
+    img
+      height 14px
+      display inline-block
+      vertical-align middle
+      margin 0 1px
+  +m(name-center)
+    +e(label)
+      padding-bottom 0
+      line-height 38px
+</style>

+ 295 - 0
packages/components/editor.vue

@@ -0,0 +1,295 @@
+<script>
+import { toEmojiName } from "utils";
+const exec = (val, command = "insertHTML") => {
+  document.execCommand(command, false, val);
+};
+const selection = window.getSelection();
+let lastSelectionRange;
+let emojiData = [];
+export default {
+  name: "LemonEditor",
+  components: {},
+  props: {},
+  data() {
+    return {
+      submitDisabled: true,
+      accept: ""
+    };
+  },
+  created() {},
+  mounted() {
+    //this.$refs.fileInput.addEventListener("change", this._handleChangeFile);
+  },
+  computed: {},
+  watch: {},
+  render() {
+    //<a-popover trigger="click" overlay-class-name="lemon-editor__emoji">
+    return (
+      <div class="lemon-editor">
+        <input
+          style="display:none"
+          type="file"
+          multiple="multiple"
+          ref="fileInput"
+          accept={this.accept}
+          onChange={this._handleChangeFile}
+        />
+        <div class="lemon-editor__tool">
+          {emojiData.length > 0 && (
+            <lemon-popover class="lemon-editor__emoji">
+              <template slot="content">{this._renderEmojiTabs()}</template>
+              <div class="lemon-editor__tool-item">
+                <i class="lemon-icon-emoji" />
+              </div>
+            </lemon-popover>
+          )}
+          <div
+            class="lemon-editor__tool-item"
+            on-click={() => this._handleSelectFile("*")}
+          >
+            <i class="lemon-icon-folder" />
+          </div>
+          <div
+            class="lemon-editor__tool-item"
+            on-click={() => this._handleSelectFile("image/*")}
+          >
+            <i class="lemon-icon-image" />
+          </div>
+        </div>
+        <div class="lemon-editor__inner">
+          <div
+            class="lemon-editor__input"
+            ref="textarea"
+            contenteditable="true"
+            on-keyup={this._handleKeyup}
+            on-keydown={this._handleKeydown}
+            on-paste={this._handlePaste}
+            on-click={this._handleClick}
+            on-input={this._handleInput}
+            spellcheck="false"
+          />
+        </div>
+        <div class="lemon-editor__footer">
+          <div class="lemon-editor__tip">使用 ctrl + enter 快捷发送消息</div>
+          <div class="lemon-editor__submit">
+            <lemon-button
+              disabled={this.submitDisabled}
+              on-click={this._handleSend}
+            >
+              发 送
+            </lemon-button>
+          </div>
+        </div>
+      </div>
+    );
+  },
+  methods: {
+    _saveLastRange() {
+      lastSelectionRange = selection.getRangeAt(0);
+    },
+    _focusLastRange() {
+      this.$refs.textarea.focus();
+      if (lastSelectionRange) {
+        selection.removeAllRanges();
+        selection.addRange(lastSelectionRange);
+      }
+    },
+    _handleClick() {
+      this._saveLastRange();
+    },
+    _handleInput() {
+      this._checkSubmitDisabled();
+    },
+    _renderEmojiTabs() {
+      const renderImageGrid = items => {
+        return items.map(item => (
+          <img
+            src={item.src}
+            title={item.title}
+            class="lemon-editor__emoji-item"
+            on-click={() => this._handleSelectEmoji(item)}
+          />
+        ));
+      };
+      if (emojiData[0].label) {
+        const nodes = emojiData.map((item, index) => {
+          return (
+            <div slot="tab-pane" index={index} tab={item.label}>
+              {renderImageGrid(item.children)}
+              {renderImageGrid(item.children)}
+            </div>
+          );
+        });
+        return <lemon-tabs style="width: 412px">{nodes}</lemon-tabs>;
+      } else {
+        return (
+          <div class="lemon-tabs-content" style="width:406px">
+            {renderImageGrid(emojiData)}
+          </div>
+        );
+      }
+    },
+    _handleSelectEmoji(item) {
+      this._focusLastRange();
+      exec(`<img emoji-name="${item.name}" src="${item.src}"></img>`);
+      this._saveLastRange();
+    },
+    async _handleSelectFile(accept) {
+      this.accept = accept;
+      await this.$nextTick();
+      this.$refs.fileInput.click();
+    },
+    _handlePaste(e) {
+      e.preventDefault();
+      const { clipboardData } = e;
+      const text = clipboardData.getData("text");
+      exec(text, "insertText");
+      // Array.from(clipboardData.items).forEach(item => {
+      //   console.log(item.type);
+      // });
+      //e.target.innerText = text;
+    },
+    _handleKeyup(e) {
+      this._saveLastRange();
+      //this._checkSubmitDisabled();
+    },
+    _handleKeydown(e) {
+      const { keyCode } = e;
+      if (keyCode == 13) {
+        // e.preventDefault();
+        // document.execCommand("defaultParagraphSeparator", false, false);
+        // exec("<br>");
+      }
+    },
+    getFormatValue() {
+      return toEmojiName(
+        this.$refs.textarea.innerHTML
+          .replace(/<br>|<\/br>/, "")
+          .replace(/<div>|<p>/g, "\r\n")
+          .replace(/<\/div>|<\/p>/g, "")
+      );
+    },
+    _checkSubmitDisabled() {
+      this.submitDisabled = !this.$refs.textarea.innerHTML.trim();
+    },
+    _handleSend(e) {
+      const text = this.getFormatValue();
+      this.$emit("send", text);
+      this.clear();
+      this._checkSubmitDisabled();
+    },
+    _handleChangeFile(e) {
+      const { fileInput } = this.$refs;
+      Array.from(fileInput.files).forEach(file => {
+        this.$emit("upload", file);
+      });
+      fileInput.value = "";
+    },
+    clear() {
+      this.$refs.textarea.innerHTML = "";
+    },
+    initEmoji(data) {
+      emojiData = data;
+      this.$forceUpdate();
+      // this.emoji = [
+      //   {
+      //     label: "表情",
+      //     name: "face",
+      //     data: [
+      //       {
+      //         name: "wx",
+      //         src: "微笑"
+      //       }
+      //     ]
+      //   },
+      //   {
+      //     label: "武器",
+      //     name: "wa",
+      //     data: [
+      //       {
+      //         name: "wx",
+      //         src: "微笑"
+      //       }
+      //     ]
+      //   }
+      // ];
+    }
+  }
+};
+</script>
+<style lang="stylus">
+@import '~styles/utils/index'
+gap = 10px;
++b(lemon-editor)
+  height 200px
+  flex-column()
+  +e(tool)
+    display flex
+    height 40px
+    align-items center
+    padding-left 5px
+  +e(tool-item)
+    cursor pointer
+    padding 4px gap
+    height 28px
+    color #999
+    transition all ease .3s
+    [class^='lemon-icon-']
+      line-height 26px
+      font-size 22px
+    &:hover
+      color #333
+  +e(inner)
+    flex 1
+    overflow-x hidden
+    overflow-y auto
+    scrollbar-light()
+  +e(input)
+    height 100%
+    box-sizing border-box
+    border none
+    outline none
+    padding 0 gap
+    scrollbar-light()
+    p,div
+      margin 0
+    img
+      height 20px
+      padding 0 2px
+      pointer-events none
+      vertical-align middle
+  +e(footer)
+    display flex
+    height 52px
+    justify-content flex-end
+    padding 0 gap
+    align-items center
+  +e(tip)
+    margin-right 10px
+    font-size 12px
+    color #999
+    user-select none
+  +e(emoji)
+    user-select none
+    .lemon-popover
+      background #f6f6f6
+    .lemon-popover__content
+      padding 0
+    .lemon-popover__arrow
+      background #f6f6f6
+    .lemon-tabs-content
+      box-sizing border-box
+      padding 8px
+      height 200px
+      overflow-x hidden
+      overflow-y auto
+      scrollbar-light()
+      margin-bottom 8px
+  +e(emoji-item)
+    cursor pointer
+    width 22px
+    padding 4px
+    border-radius 4px
+    &:hover
+      background #e9e9e9
+</style>

+ 0 - 30
packages/components/group-list/index.vue

@@ -1,30 +0,0 @@
-<template>
-  <div class=''>
-    LemonGroupList
-  </div>
-</template>
-
-<script>
-export default {
-  name: 'GroupList',
-  components: {},
-  data () {
-    return {
-
-    };
-  },
-  computed: {},
-  watch: {},
-  methods: {
-
-  },
-  created () {
-
-  },
-  mounted () {
-
-  },
-}
-</script>
-<style lang='scss'>
-</style>

+ 890 - 0
packages/components/index.vue

@@ -0,0 +1,890 @@
+<script>
+import { useScopedSlot, fastDone, generateUUID } from "utils";
+import { isFunction, isString, isEmpty } from "utils/validate";
+import {
+  DEFAULT_MENUS,
+  DEFAULT_MENU_LASTMESSAGES,
+  DEFAULT_MENU_CONTACTS
+} from "utils/constant";
+import lastContentRender from "../lastContentRender";
+
+import MemoryCache from "utils/cache/memory";
+
+const CacheContactContainer = new MemoryCache();
+const CacheMenuContainer = new MemoryCache();
+const CacheMessageLoaded = new MemoryCache();
+
+import {
+  //constraintContactMessages,
+  constraintContact
+  //constraintMessage
+} from "utils/constraint";
+const messages = {};
+const emojiMap = {};
+let renderDrawerContent = () => {};
+
+export default {
+  name: "LemonImui",
+  provide() {
+    return {
+      IMUI: this
+    };
+  },
+  props: {
+    /**
+     * 消息时间格式化规则
+     */
+    messageTimeFormat: Function,
+    /**
+     * 联系人最新消息时间格式化规则
+     */
+    contactTimeFormat: Function,
+    /**
+     * 初始化时是否隐藏抽屉
+     */
+    hideDrawer: {
+      type: Boolean,
+      default: true
+    },
+    /**
+     * 初始化时是否隐藏导航按钮上的头像
+     */
+    hideMenuAvatar: Boolean,
+    user: {
+      type: Object,
+      default: () => {
+        return {};
+      }
+    }
+  },
+  data() {
+    return {
+      drawerVisible: !this.hideDrawer,
+      currentContactId: "",
+      activeSidebar: DEFAULT_MENU_LASTMESSAGES,
+      contacts: [],
+      menus: []
+    };
+  },
+
+  render() {
+    return this._renderWrapper([
+      this._renderMenu(),
+      this._renderSidebarMessage(),
+      this._renderSidebarContact(),
+      this._renderContainer(),
+      this._renderDrawer()
+    ]);
+  },
+  created() {
+    this.initMenus();
+  },
+  async mounted() {
+    await this.$nextTick();
+  },
+  computed: {
+    currentMessages() {
+      return messages[this.currentContactId] || [];
+    },
+    currentContact() {
+      return this.contacts.find(item => item.id == this.currentContactId) || {};
+    },
+    currentMenu() {
+      return this.menus.find(item => item.name == this.activeSidebar) || {};
+    },
+    currentIsDefSidebar() {
+      return DEFAULT_MENUS.includes(this.activeSidebar);
+    },
+    lastMessages() {
+      const data = this.contacts.filter(item => !isEmpty(item.lastContent));
+      data.sort((a1, a2) => {
+        return a2.lastSendTime - a1.lastSendTime;
+      });
+      return data;
+    }
+  },
+  watch: {
+    activeSidebar() {}
+  },
+  methods: {
+    _menuIsContacts() {
+      return this.activeSidebar == DEFAULT_MENU_CONTACTS;
+    },
+    _menuIsMessages() {
+      return this.activeSidebar == DEFAULT_MENU_LASTMESSAGES;
+    },
+    _createMessage(message) {
+      return {
+        ...{
+          id: generateUUID(),
+          type: "text",
+          status: "going",
+          sendTime: new Date().getTime(),
+          toContactId: this.currentContactId,
+          fromUser: {
+            ...this.user
+          }
+        },
+        ...message
+      };
+      // const message = {
+      //   id: "123",
+      //   status: "succeed",
+      //   type: "image",
+      //   sendTime: 12312312312,
+      //   content: "asdas",
+      //   fromContactId: "123",
+      //   fromUser: { id: "123", displayName: "123", avatar: "123",}
+      // }
+    },
+    // _setDefMessages(id) {
+    //   //this.messages[id] = this.messages[id] || [];
+    //   if (!messages[id]) {
+    //     this.$set(messages, id, []);
+    //   }
+    // },
+    appendMessage(message, contactId = this.currentContactId) {
+      this._addMessage(message, contactId, 1);
+      this.messageViewToBottom();
+    },
+    _emitSend(message, next, file) {
+      this.$emit(
+        "send",
+        message,
+        (replaceMessage = { status: "succeed" }) => {
+          next();
+          message = Object.assign(message, replaceMessage);
+          this.forceUpdateMessage(message.id);
+        },
+        file
+      );
+    },
+    _handleSend(text) {
+      const message = this._createMessage({ content: text });
+      this.appendMessage(message);
+      this._emitSend(message, () => {
+        this.updateContact(message.toContactId, {
+          lastContent: lastContentRender[message.type].call(this, message),
+          lastSendTime: message.sendTime
+        });
+      });
+    },
+    _handleUpload(file) {
+      const imageTypes = ["image/gif", "image/jpeg", "image/png"];
+      let joinMessage;
+      if (imageTypes.includes(file.type)) {
+        joinMessage = {
+          type: "image",
+          content: URL.createObjectURL(file)
+        };
+      } else {
+        joinMessage = {
+          type: "file",
+          fileSize: file.size,
+          fileName: file.name,
+          content: ""
+        };
+      }
+      const message = this._createMessage(joinMessage);
+      this.appendMessage(message);
+      this._emitSend(
+        message,
+        () => {
+          this.updateContact(message.toContactId, {
+            lastContent: lastContentRender[message.type].call(this, message),
+            lastSendTime: message.sendTime
+          });
+        },
+        file
+      );
+    },
+    _handleReachTop(next) {
+      // const messages = {
+      //   id: "8ad7e98e-5225-4892-8131-4b2ee7797599",
+      //   type: "text",
+      //   status: "succeed",
+      //   sendTime: 1564926674646,
+      //   fromContactId: "superadmin",
+      //   fromUser: {
+      //     id: "hehe",
+      //     displayName: "I KNOEW",
+      //     avatar:
+      //       "https://ss3.bdstatic.com/70cFv8Sh_Q1YnxGkpoWK1HF6hhy/it/u=4085009425,1005454674&fm=26&gp=0.jpg"
+      //   },
+      //   content: "测试消息哦..."
+      // };
+      this.$emit(
+        "pull-messages",
+        this.currentContact,
+        (messages, isEnd = false) => {
+          this._addMessage(
+            Array(10).fill(messages[1]),
+            this.currentContactId,
+            0
+          );
+          CacheMessageLoaded.set(this.currentContactId, isEnd);
+          next(isEnd);
+        }
+      );
+      // setTimeout(() => {
+      //   CacheMessageLoaded.set(this.currentContactId, isEnd);
+      // }, 2000);
+    },
+    clearCacheContainer(name) {
+      CacheContactContainer.remove(name);
+      CacheMenuContainer.remove(name);
+    },
+    _renderWrapper(children) {
+      return (
+        <div
+          class={[
+            "lemon-wrapper",
+            this.drawerVisible && "lemon-wrapper--drawer-show"
+          ]}
+        >
+          {children}
+        </div>
+      );
+    },
+    _renderMenu() {
+      const menuItem = this._renderMenuItem();
+      return (
+        <div class="lemon-menu">
+          {this.hideMenuAvatar == false && (
+            <lemon-avatar
+              on-click={e => {
+                console.log("menu avatar click");
+              }}
+              class="lemon-menu__avatar"
+              src="https://ss3.bdstatic.com/70cFv8Sh_Q1YnxGkpoWK1HF6hhy/it/u=400062461,2874561526&fm=26&gp=0.jpg"
+            />
+          )}
+          {menuItem.top}
+          {this.$slots.menu}
+          <div class="lemon-menu__bottom">
+            {this.$slots["menu-bottom"]}
+            {menuItem.bottom}
+          </div>
+        </div>
+      );
+    },
+    _renderMenuAvatar() {
+      return;
+    },
+    _renderMenuItem() {
+      const top = [];
+      const bottom = [];
+      this.menus.forEach(item => {
+        const { name, title, unread, render, click } = item;
+        const node = (
+          <div
+            class={[
+              "lemon-menu__item",
+              { "lemon-menu__item--active": this.activeSidebar == name }
+            ]}
+            on-click={() => {
+              fastDone(click, () => {
+                if (name) this.changeMenu(name);
+              });
+            }}
+            title={title}
+          >
+            <lemon-badge count={unread}>{render(item)}</lemon-badge>
+          </div>
+        );
+        item.isBottom === true ? bottom.push(node) : top.push(node);
+      });
+      return {
+        top,
+        bottom
+      };
+    },
+    _renderSidebarMessage() {
+      return this._renderSidebar(
+        this.lastMessages.map(contact => {
+          return this._renderContact(
+            {
+              contact,
+              timeFormat: this.contactTimeFormat
+            },
+            () => this.changeContact(contact.id)
+          );
+        }),
+        DEFAULT_MENU_LASTMESSAGES
+      );
+    },
+    _renderContact(props, onClick) {
+      const {
+        click: customClick,
+        renderContainer,
+        id: contactId
+      } = props.contact;
+      const click = () => {
+        fastDone(customClick, () => {
+          onClick();
+          this._customContainerReady(
+            renderContainer,
+            CacheContactContainer,
+            contactId
+          );
+        });
+      };
+      return (
+        <lemon-contact
+          class={{
+            "lemon-contact--active": this.currentContactId == props.contact.id
+          }}
+          props={props}
+          on-click={click}
+        />
+      );
+    },
+    _renderSidebarContact() {
+      let prevIndex;
+      return this._renderSidebar(
+        this.contacts.map(contact => {
+          contact.index = contact.index.replace(/\[[0-9]*\]/, "");
+          const node = [
+            contact.index !== prevIndex && (
+              <p class="lemon-sidebar__label">{contact.index}</p>
+            ),
+            this._renderContact(
+              {
+                contact: contact,
+                simple: true
+              },
+              () => this.changeContact(contact.id)
+            )
+          ];
+          prevIndex = contact.index;
+          return node;
+        }),
+        DEFAULT_MENU_CONTACTS
+      );
+    },
+    _renderSidebar(children, name) {
+      return (
+        <div class="lemon-sidebar" v-show={this.activeSidebar == name}>
+          {children}
+        </div>
+      );
+    },
+    _renderDrawer() {
+      return this._menuIsMessages() && this.currentContactId ? (
+        <div class="lemon-drawer">
+          {renderDrawerContent()}
+          {useScopedSlot(this.$scopedSlots.drawer, "", this.currentContact)}
+        </div>
+      ) : (
+        ""
+      );
+    },
+    _isContactContainerCache(name) {
+      return name.startsWith("contact#");
+    },
+    _renderContainer() {
+      const nodes = [];
+      const cls = "lemon-container";
+      const curact = this.currentContact;
+      let defIsShow = true;
+      for (const name in CacheContactContainer.get()) {
+        const show = curact.id == name && this.currentIsDefSidebar;
+        defIsShow = !show;
+        nodes.push(
+          <div class={cls} v-show={show}>
+            {CacheContactContainer.get(name)}
+          </div>
+        );
+      }
+      for (const name in CacheMenuContainer.get()) {
+        nodes.push(
+          <div
+            class={cls}
+            v-show={this.activeSidebar == name && !this.currentIsDefSidebar}
+          >
+            {CacheMenuContainer.get(name)}
+          </div>
+        );
+      }
+
+      nodes.push(
+        <div
+          class={cls}
+          v-show={this._menuIsMessages() && defIsShow && curact.id}
+        >
+          <div class="lemon-container__title">
+            <div class="lemon-container__displayname">
+              {useScopedSlot(
+                this.$scopedSlots["contact-title"],
+                curact.displayName,
+                curact
+              )}
+            </div>
+          </div>
+          <lemon-messages
+            ref="messages"
+            time-format={this.messageTimeFormat}
+            reverse-user-id={this.user.id}
+            on-reach-top={this._handleReachTop}
+            messages={this.currentMessages}
+          />
+          <lemon-editor
+            ref="editor"
+            onSend={this._handleSend}
+            onUpload={this._handleUpload}
+          />
+        </div>
+      );
+      nodes.push(
+        <div class={cls} v-show={!curact.id}>
+          {this.$slots.cover}
+        </div>
+      );
+      nodes.push(
+        <div
+          class={cls}
+          v-show={this._menuIsContacts() && defIsShow && curact.id}
+        >
+          {useScopedSlot(
+            this.$scopedSlots["contact-info"],
+            <div class="lemon-contact-info">
+              <lemon-avatar src={curact.avatar} size={90} />
+              <h4>{curact.displayName}</h4>
+              <lemon-button
+                on-click={() => {
+                  this.changeContact(curact.id, DEFAULT_MENU_LASTMESSAGES);
+                }}
+              >
+                {" "}
+                发送消息{" "}
+              </lemon-button>
+            </div>,
+            curact
+          )}
+        </div>
+      );
+      return nodes;
+    },
+    _addContact(data, t) {
+      const type = {
+        0: "unshift",
+        1: "push"
+      }[t];
+      constraintContact(data);
+      //this.contacts[type](cloneDeep(data));
+      this.contacts[type](data);
+    },
+    _addMessage(data, contactId, t) {
+      const type = {
+        0: "unshift",
+        1: "push"
+      }[t];
+      if (!Array.isArray(data)) data = [data];
+      messages[contactId] = messages[contactId] || [];
+      messages[contactId][type](...data);
+      //console.log(messages[contactId]);
+      this.forceUpdateMessage();
+    },
+    /**
+     * 设置最新消息DOM
+     * @param {String} messageType 消息类型
+     * @param {Function} render 返回消息 vnode
+     */
+    setLastContentRender(messageType, render) {
+      lastContentRender[messageType] = render;
+    },
+    /**
+     * 将字符串内的 EmojiItem.name 替换为 img
+     * @param {String} str 被替换的字符串
+     * @return {String} 替换后的字符串
+     */
+    replaceEmojiName(str) {
+      return str.replace(/\[!(\w+)\]/gi, (str, match) => {
+        const file = match;
+        return emojiMap[file]
+          ? `<img src="${emojiMap[file]}" />`
+          : `[!${match}]`;
+      });
+    },
+    /**
+     * 将当前聊天窗口滚动到底部
+     */
+    messageViewToBottom() {
+      this.$refs.messages.scrollToBottom();
+    },
+    /**
+     * 改变聊天对象
+     * @param contactId 联系人 id
+     */
+    changeContact(contactId, menuName) {
+      if (this.currentContactId == contactId) {
+        this.currentContactId = undefined;
+      }
+
+      if (menuName) {
+        this.changeMenu(menuName);
+      }
+      this.currentContactId = contactId;
+      this.$emit("change-contact", this.currentContact);
+      if (isFunction(this.currentContact.renderContainer)) {
+        return;
+      }
+      if (this._menuIsMessages()) {
+        if (!CacheMessageLoaded.has(contactId)) {
+          this.$refs.messages.resetLoadState();
+        }
+        if (!messages[contactId]) {
+          this.$emit(
+            "pull-messages",
+            this.currentContact,
+            (messages, isEnd) => {
+              this._addMessage(messages, contactId, 0);
+              this.messageViewToBottom();
+            }
+          );
+        } else {
+          this.messageViewToBottom();
+        }
+      }
+    },
+    /**
+     * 删除一条聊天消息
+     * @param messageId 消息 id
+     * @param contactId 联系人 id
+     */
+    removeMessage(messageId, contactId) {
+      const index = this.findMessageIndexById(messageId, contactId);
+      if (index !== -1) {
+        messages[contactId].splice(index, 1);
+        this.forceUpdateMessage();
+      }
+    },
+    /**
+     * 修改聊天一条聊天消息
+     * @param {Message} data 根据 data.id 查找聊天消息并覆盖传入的值
+     * @param contactId 联系人 id
+     */
+    updateMessage(messageId, contactId, data) {
+      const index = this.findMessageIndexById(messageId, contactId);
+      if (index !== -1) {
+        messages[contactId][index] = {
+          ...messages[contactId][index],
+          ...data
+        };
+        this.forceUpdateMessage(messageId);
+      }
+    },
+    /**
+     * 手动更新对话消息
+     * @param {String} messageId 消息ID,如果为空则更新当前聊天窗口的所有消息
+     */
+    forceUpdateMessage(messageId) {
+      if (!messageId) {
+        this.$refs.messages.$forceUpdate();
+      } else {
+        const components = this.$refs.messages.$refs.message;
+        if (components) {
+          const messageComponent = components.find(
+            com => com.$attrs.message.id == messageId
+          );
+          if (messageComponent) messageComponent.$forceUpdate();
+        }
+      }
+    },
+    _customContainerReady(render, cacheDrive, key) {
+      if (isFunction(render) && !cacheDrive.has(key)) {
+        cacheDrive.set(key, render.call(this));
+      }
+    },
+    /**
+     * 切换左侧按钮
+     * @param {String} name 按钮 name
+     */
+    changeMenu(name) {
+      this.$emit("change-menu", name);
+      this.activeSidebar = name;
+      const { renderContainer } = this.currentMenu;
+      this._customContainerReady(renderContainer, CacheMenuContainer, name);
+    },
+    /**
+     * 初始化编辑框的 Emoji 表情列表,是 Lemon-editor.initEmoji 的代理方法
+     * @param {Array<Emoji,EmojiItem>} data emoji 数据
+     * Emoji = {label: 表情,children: [{name: wx,title: 微笑,src: url}]} 分组
+     * EmojiItem = {name: wx,title: 微笑,src: url} 无分组
+     */
+    initEmoji(data) {
+      this.$refs.editor.initEmoji(data);
+      if (data[0].label) {
+        data = data.flatMap(item => item.children);
+      }
+      data.forEach(({ name, src }) => (emojiMap[name] = src));
+    },
+    /**
+     * 初始化左侧按钮
+     * @param {Array<Menu>} data 按钮数据
+     */
+    initMenus(data) {
+      const defaultMenus = [
+        {
+          name: DEFAULT_MENU_LASTMESSAGES,
+          title: "聊天",
+          unread: 0,
+          click: null,
+          render: menu => {
+            return <i class="lemon-icon-message" />;
+          },
+          isBottom: false
+        },
+        {
+          name: DEFAULT_MENU_CONTACTS,
+          title: "通讯录",
+          unread: 0,
+          click: null,
+          render: menu => {
+            return <i class="lemon-icon-addressbook" />;
+          },
+          isBottom: false
+        }
+      ];
+      let menus = [];
+      if (Array.isArray(data)) {
+        const indexMap = {
+          lastMessages: 0,
+          contacts: 1
+        };
+        const indexKeys = Object.keys(indexMap);
+        menus = data.map(item => {
+          if (indexKeys.includes(item.name)) {
+            return {
+              ...defaultMenus[indexMap[item.name]],
+              ...item,
+              ...{ renderContainer: null }
+            };
+          }
+          return item;
+        });
+      } else {
+        menus = defaultMenus;
+      }
+      this.menus = menus;
+    },
+    /**
+     * 初始化联系人数据
+     * @param {Array<Contact>} data 联系人列表
+     */
+    initContacts(data) {
+      this.contacts.push(...data);
+      this.sortContacts();
+    },
+    /**
+     * 使用 联系人的 index 值进行排序
+     */
+    sortContacts() {
+      this.contacts.sort((a, b) => {
+        return a.index.localeCompare(b.index);
+      });
+    },
+    /**
+     * 修改联系人数据
+     * @param {Contact} data 修改的数据,根据 data.id 查找联系人并覆盖传入的值
+     */
+    updateContact(contactId, data) {
+      delete data.id;
+      delete data.toContactId;
+
+      const index = this.findContactIndexById(contactId);
+      if (index !== -1) {
+        const { unread } = data;
+        if (isString(unread)) {
+          if (unread.indexOf("+") === 0 || unread.indexOf("-") === 0) {
+            data.unread =
+              parseInt(unread) + parseInt(this.contacts[index].unread);
+          }
+        }
+        this.$set(this.contacts, index, {
+          ...this.contacts[index],
+          ...data
+        });
+      }
+    },
+    /**
+     * 根据 id 查找联系人的索引
+     * @param contactId 联系人 id
+     * @return {Number} 联系人索引,未找到返回 -1
+     */
+    findContactIndexById(contactId) {
+      return this.contacts.findIndex(item => item.id == contactId);
+    },
+    findMessageIndexById(messageId, contactId) {
+      const msg = messages[contactId];
+      if (isEmpty(msg)) {
+        return -1;
+      }
+      return msg.findIndex(item => item.id == messageId);
+    },
+    findMessageById(messageId, contactId) {
+      const index = this.findMessageIndexById(messageId, contactId);
+      if (index !== -1) return messages[contactId][index];
+    },
+    /**
+     * 返回所有联系人
+     * @return {Array<Contact>}
+     */
+    getContacts() {
+      return this.contacts;
+    },
+    /**
+     * 返回所有消息
+     * @return {Object<Contact.id,Message>}
+     */
+    getMessages() {
+      return messages;
+    },
+    // appendContact(data) {
+    //   this._addContact(data, 0);
+    // },
+    // prependContact(data) {
+    //   this._addContact(data, 1);
+    // },
+    // addContactMessage(data) {
+    //   this._addContact(data, 0);
+    // },
+    // prependContactMessage(data) {
+    //   this._addContact(data, 1);
+    // },
+    // appendMessage(data) {},
+    // prependMessage(data) {},
+    // removeContact(contactId) {},
+    // removeContactMessage(contactId) {},
+    // removeContactAll(contactId) {},
+    /**
+     * 将自定义的HTML显示在主窗口内
+     */
+    openrenderContainer(vnode) {
+      //renderContainerQueue[this.activeSidebar] = vnode;
+      //this.$slots._renderContainer = vnode;
+    },
+    changeDrawer(render) {
+      this.drawerVisible = !this.drawerVisible;
+      if (this.drawerVisible == true) this.openDrawer(render);
+    },
+    openDrawer(render) {
+      renderDrawerContent = render || new Function();
+      this.drawerVisible = true;
+    },
+    closeDrawer() {
+      this.drawerVisible = false;
+    }
+  }
+};
+</script>
+<style lang="stylus">
+wrapper-width = 850px
+drawer-width = 200px
+bezier = cubic-bezier(0.645, 0.045, 0.355, 1)
+@import '~styles/utils/index'
+
++b(lemon-wrapper)
+  width wrapper-width
+  height 580px
+  display flex
+  font-size 14px
+  border-radius 5px
+  //mask-image radial-gradient(circle, white 100%, black 100%)
+  background #efefef
+  transition all .4s bezier
+  border-radius 4px
+  p
+    margin 0
+  img
+    vertical-align middle
+    border-style none
++b(lemon-menu)
+  flex-column()
+  align-items center
+  width 60px
+  background #1d232a
+  padding 15px 0
+  position relative
+  user-select none
+  +e(bottom)
+    flex-column()
+    position absolute
+    bottom 0
+  +e(avatar)
+    margin-bottom 20px
+    cursor pointer
+  +e(item)
+    color #999
+    cursor pointer
+    padding 14px 10px
+    max-width 100%
+    +m(active)
+      color #0fd547
+    &:hover:not(.lemon-menu__item--active)
+      color #eee
+    word-break()
+    > *
+      font-size 24px
+    .ant-badge-count
+      display inline-block
+      padding 0 4px
+      height 18px
+      line-height 16px
+      min-width 18px
+    .ant-badge-count
+    .ant-badge-dot
+      box-shadow 0 0 0 1px #1d232a
++b(lemon-sidebar)
+  width 250px
+  background #efefef
+  overflow-y auto
+  scrollbar-light()
+  +e(label)
+    padding 6px 14px 6px 14px
+    color #666
+    font-size 12px
+    margin 0
+  +b(lemon-contact--active)
+    background #d9d9d9
++b(lemon-container)
+  flex 1
+  flex-column()
+  background #f4f4f4
+  word-break()
+  position relative
+  z-index 2
+  +e(title)
+    padding 15px 15px
+  +e(displayname)
+    font-size 16px
++b(lemon-messages)
+  flex 1
+  height auto
++b(lemon-drawer)
+  position absolute
+  top 0
+  right 0
+  overflow hidden
+  width drawer-width
+  background #f4f4f4
+  transition width .4s bezier
+  height 100%
+  z-index 1
+  //border-left 1px solid #e9e9e9
+  box-sizing border-box
++b(lemon-wrapper)
+  +m(drawer-show)
+    +b(lemon-drawer)
+      right -200px
++b(lemon-contact-info)
+  flex-column()
+  justify-content center
+  align-items center
+  height 100%
+  h4
+    font-size 16px
+    font-weight normal
+    margin 10px 0 20px 0
+    user-select none
+</style>

+ 0 - 226
packages/components/lemon/index.vue

@@ -1,226 +0,0 @@
-<template>
-  <el-container class="lemon-container lemon-container--center"
-                ref="container">
-    <el-aside class="lemon-sidebar"
-              width="240px">
-      <ul class="lemon-tab">
-        <li v-for="item in tabList"
-            :key="item.name"
-            :tab-name="item.name"
-            :class="['lemon-tab__item', item.name == currentTab && 'lemon-tab__item--active']"
-            @click="tabChange(item.name)">
-          <span :class="item.icon"></span>
-        </li>
-      </ul>
-      <div class="lemon-tabview">
-        <div class="lemon-tabview__item"
-             v-for="item in tabList"
-             v-show="item.name == currentTab"
-             :key="item.name"
-             :tabview-name="item.name">
-          <component :is="item.componentName"
-                     @changeMessageView="_changeMessageView"></component>
-        </div>
-      </div>
-    </el-aside>
-    <el-container class="lemon-main">
-      <el-header class="lemon-header"
-                 height="48px">
-        宜宾劲越二手车市场(上江北) (500)
-      </el-header>
-      <el-main>
-        <lemon-message-view></lemon-message-view>
-      </el-main>
-      <el-main>工具欄</el-main>
-    </el-container>
-  </el-container>
-</template>
-
-<script>
-import LemonContactList from '../contact-list'
-import LemonGroupList from '../group-list'
-import LemonMessageList from '../message-list'
-import LemonMessageView from '../message-view'
-const components = {
-  LemonContactList,
-  LemonGroupList,
-  LemonMessageList,
-  LemonMessageView
-}
-
-export default {
-  name: 'LemonIm',
-  components,
-  provide () {
-    return {
-      control: this
-    }
-  },
-  props: {
-    friends: {
-      type: Array,
-      default: () => []
-    },
-    groups: {
-      type: Array,
-      default: () => []
-    }
-  },
-  data () {
-    this.tabList = [{
-      name: 'message',
-      icon: 'el-icon-s-comment',
-      componentName: 'lemon-message-list',
-    }, {
-      name: 'contact',
-      icon: 'el-icon-user-solid',
-      componentName: 'lemon-contact-list',
-    }, {
-      name: 'group',
-      icon: 'el-icon-message-solid',
-      componentName: 'lemon-group-list',
-    }]
-    return {
-      //当前会话对象的ID 根据 currentTab
-      currentTab: 'contact',
-      //当前聊天用户ID 根据聊天类型不一样  代表用户ID 群组ID 讨论组ID
-      currentMessageId: null,
-      //聊天视图是否加载中
-      messageViewloading: true,
-      //群聊天记录 groupId => [message]
-      groupMessageData: {
-
-      },
-      //好友聊天记录 friendId => [message]
-      friendMessageData: {
-
-      }
-    };
-  },
-  created () {
-  },
-  mounted () {
-    this._initialize()
-  },
-  computed: {
-    //聊天窗口中的数据
-    messageViewData () {
-      return this.friendMessageData[this.currentMessageId]
-    }
-  },
-  watch: {},
-  methods: {
-    //左侧选项卡切换
-    tabChange (name) {
-      this.currentTab = name;
-      this.findNode(`.lemon-tabview`).scrollTop = '0px'
-    },
-    //打开一个群组聊天窗口
-    openGroupMessageView (group) {
-      this._changeMessageViewComplete(group.id)
-    },
-    //打开普通聊天窗口
-    openFriendMessageView (friend) {
-      if (!this.friendMessageData[friend.id]) {
-        this.$emit('pull-friends-message', friend, (newData) => {
-          this._changeMessageViewComplete(friend.id)
-          this.friendMessageData[friend.id] = [...newData]
-        })
-      } else {
-        this._changeMessageViewComplete(friend.id)
-      }
-    },
-    _changeMessageViewComplete (id) {
-      this.messageViewloading = false
-      this.currentMessageId = id
-    },
-    _changeMessageView (item) {
-      this.openFriendMessageView(item)
-      /**
-      const resolve = (newData) => {
-        this.messageViewloading = false
-        this.
-      }
-      const reject = () => {
-        this.messageViewloading = false
-      }
-      this.$emit('pullFriendsMessage', item, {
-        resolve,
-        reject
-      })
-       */
-    },
-    _openMessageView () {
-
-    },
-    findNode (query) {
-      return this.$refs.container.$el.querySelector(query)
-    },
-    _initialize () {
-
-    },
-    //将左侧的滚动条重置到顶部
-    _tabviewScrollToTop () {
-
-    },
-  }
-}
-</script>
-<style lang='scss'>
-@import '~styles/utils/index';
-body {
-  background: #8d9198;
-}
-
-@include b(container) {
-  width: 900px;
-  height: 700px;
-  @include m(center) {
-    @include position-center(fixed);
-  }
-}
-@include b(sidebar) {
-  background: #1f252d;
-  display: flex;
-  flex-direction: column;
-  color: #fff;
-  overflow: hidden;
-  .el-tabs--card {
-    .el-tabs__nav,
-    .el-tabs__item,
-    .el-tabs__header {
-      border: none;
-    }
-  }
-}
-@include b(tab) {
-  display: flex;
-  width: 100%;
-  @include e(item) {
-    cursor: pointer;
-    line-height: 56px;
-    text-align: center;
-    flex: 1;
-    transition: all ease-in-out 0.3s;
-    font-size: 22px;
-    color: #6d6d6d;
-    @include m(active) {
-      color: #11d207;
-    }
-  }
-}
-@include b(tabview) {
-  flex: 1;
-  overflow-y: auto;
-  @include scrollbar-dark();
-
-  @include e(item) {
-  }
-}
-@include b(main) {
-  background: #eceef1;
-}
-@include b(header) {
-  line-height: 48px;
-}
-</style>

+ 0 - 29
packages/components/message-list/index.vue

@@ -1,29 +0,0 @@
-<template>
-  <div class='lemon-message-list'>
-
-  </div>
-</template>
-
-<script>
-export default {
-  name: 'MessageList',
-  inject: ["control"],
-  data () {
-    return {
-
-    };
-  },
-  created () {
-
-  },
-  mounted () {
-  },
-  computed: {},
-  watch: {},
-  methods: {
-
-  }
-}
-</script>
-<style lang='scss'>
-</style>

+ 0 - 31
packages/components/message-view/index.vue

@@ -1,31 +0,0 @@
-<template>
-  <div class='lemon-message-view'
-       v-loading="control.messageViewloading"
-       element-loading-background="transparent">
-    {{ control.messageViewData }}
-  </div>
-</template>
-
-<script>
-export default {
-  name: 'MessageView',
-  components: {},
-  inject: ["control"],
-  created () {
-
-  },
-  mounted () {
-  },
-  computed: {},
-  watch: {},
-  methods: {
-
-  }
-}
-</script>
-<style lang='scss'>
-@import '~styles/utils/index';
-@include b(message-view) {
-  height: 100%;
-}
-</style>

+ 178 - 0
packages/components/message/basic.vue

@@ -0,0 +1,178 @@
+<script>
+export default {
+  name: "lemonMessageBasic",
+  props: {
+    message: {
+      type: Object,
+      default: () => {
+        return {};
+      }
+    },
+    timeFormat: {
+      type: Function,
+      default: () => ""
+    },
+    reverse: Boolean,
+    hiddenTitle: Boolean
+  },
+  data() {
+    return {};
+  },
+  render() {
+    const { fromUser, status, sendTime } = this.message;
+    return (
+      <div
+        class={[
+          "lemon-message",
+          {
+            "lemon-message--reverse": this.reverse,
+            "lemon-message--hidden-title": this.hiddenTitle
+          }
+        ]}
+      >
+        <div class="lemon-message__avatar">
+          <lemon-avatar
+            size={36}
+            shape="square"
+            src={fromUser.avatar}
+            on-click={() => {
+              console.log("message avatar click");
+            }}
+          />
+        </div>
+        <div class="lemon-message__inner">
+          <div class="lemon-message__title">
+            <span
+              on-click={() => {
+                console.log("message displayname click");
+              }}
+            >
+              {fromUser.displayName}
+            </span>
+            <span class="lemon-message__time">{this.timeFormat(sendTime)}</span>
+          </div>
+          <div
+            class="lemon-message__content"
+            on-click={() => {
+              console.log("message content click");
+            }}
+          >
+            {this.useScopedSlots("content", this.message)}
+          </div>
+          <div class="lemon-message__status">{this._renderStatue(status)}</div>
+        </div>
+      </div>
+    );
+  },
+  created() {},
+  mounted() {},
+  computed: {},
+  watch: {},
+  methods: {
+    _renderStatue(status) {
+      if (status == "going") {
+        return <i class="lemon-icon-loading lemonani-spin" />;
+      } else if (status == "failed") {
+        return (
+          <i
+            class="lemon-icon-prompt"
+            title="重发消息"
+            style={{
+              color: "#ff2525",
+              cursor: "pointer"
+            }}
+          />
+        );
+      }
+      return;
+    },
+    useScopedSlots(name, params, defVnode = "", context = this) {
+      return context.$scopedSlots[name]
+        ? context.$scopedSlots[name](params)
+        : defVnode;
+    }
+  }
+};
+</script>
+<style lang="stylus">
+@import '~styles/utils/index'
+arrow()
+  content ' '
+  position absolute
+  top 6px
+  width 0
+  height 0
+  border 4px solid transparent
++b(lemon-message)
+  display flex
+  padding 10px 0
+  +e(time)
+    color #bbb
+    padding 0 4px
+  +e(inner)
+    position relative
+  +e(avatar)
+    padding-right 10px
+    user-select none
+    .lemon-avatar
+      cursor pointer
+  +e(title)
+    display flex
+    font-size 12px
+    line-height 14px
+    padding-bottom 6px
+    user-select none
+    color #999
+  +e(content)
+    font-size 14px
+    line-height 20px
+    padding 8px 10px
+    background #fff
+    border-radius 4px
+    position relative
+    margin 0 46px 0 0
+    img
+    video
+      background #e9e9e9
+      height 100px
+    &:before
+      arrow()
+      left -4px
+      border-left none
+      border-right-color #fff
+  +e(status)
+    position absolute
+    top 23px
+    right 20px
+    color #aaa
+    font-size 20px
+  +m(reverse)
+    flex-direction row-reverse
+    +e(title)
+      flex-direction row-reverse
+    +e(status)
+      left 20px
+      right auto
+    +e(content)
+      background #35d863
+      margin 0 0 0 46px
+      &:before
+        arrow()
+        left auto
+        right -4px
+        border-right none
+        border-left-color #35d863
+    +e(title)
+      text-align right
+    +e(avatar)
+      padding-right 0
+      padding-left 10px
+  +m(hidden-title)
+    +e(status)
+      top 7px
+    +e(title)
+      display none
+    +e(content)
+      &:before
+        top 14px
+</style>

+ 27 - 0
packages/components/message/event.vue

@@ -0,0 +1,27 @@
+<script>
+export default {
+  name: "lemonMessageEvent",
+  inheritAttrs: false,
+  render() {
+    const { content } = this.$attrs.message;
+    return (
+      <div class="lemon-message lemon-message-event">
+        <span class="lemon-message-event__content">{content}</span>
+      </div>
+    );
+  }
+};
+</script>
+<style lang="stylus">
+@import '~styles/utils/index'
++b(lemon-message-event)
+  +e(content)
+    user-select none
+    display inline-block
+    background #e9e9e9
+    color #aaa
+    font-size 12px
+    margin 0 auto
+    padding 5px 10px
+    border-radius 4px
+</style>

+ 59 - 0
packages/components/message/file.vue

@@ -0,0 +1,59 @@
+<script>
+import { formatByte } from "utils";
+export default {
+  name: "lemonMessageFile",
+  inheritAttrs: false,
+  render() {
+    return (
+      <lemon-message-basic
+        class="lemon-message-file"
+        props={{ ...this.$attrs }}
+        scopedSlots={{
+          content: props => [
+            <div class="lemon-message-file__inner">
+              <p class="lemon-message-file__name">{props.fileName}</p>
+              <p class="lemon-message-file__byte">
+                {formatByte(props.fileSize)}
+              </p>
+            </div>,
+            <div class="lemon-message-file__sfx">
+              <i class="lemon-icon-attah" />
+            </div>
+          ]
+        }}
+      />
+    );
+  }
+};
+</script>
+<style lang="stylus">
+@import '~styles/utils/index'
++b(lemon-message-file)
+  +b(lemon-message)
+    +e(content)
+      display flex
+      cursor pointer
+      width 200px
+      background #fff
+      padding 12px 18px
+      overflow hidden
+      p
+        margin 0
+  +e(tip)
+    display none
+  +e(inner)
+    flex 1
+  +e(name)
+    font-size 14px
+  +e(byte)
+    font-size 12px
+    color #aaa
+  +e(sfx)
+    display flex
+    align-items center
+    justify-content center
+    font-weight bold
+    user-select none
+    font-size 34px
+    color #ccc
+</style>

+ 30 - 0
packages/components/message/image.vue

@@ -0,0 +1,30 @@
+<script>
+export default {
+  name: "lemonMessageImage",
+  inheritAttrs: false,
+  render() {
+    return (
+      <lemon-message-basic
+        class="lemon-message-image"
+        props={{ ...this.$attrs }}
+        scopedSlots={{
+          content: props => <img src={props.content} />
+        }}
+      />
+    );
+  }
+};
+</script>
+<style lang="stylus">
+@import '~styles/utils/index'
++b(lemon-message-image)
+  +b(lemon-message)
+    +e(content)
+      padding 0
+      cursor pointer
+      overflow hidden
+      img
+        max-width 100%
+        min-width 100px
+        display block
+</style>

+ 35 - 0
packages/components/message/text.vue

@@ -0,0 +1,35 @@
+<script>
+import IMUIProxy from "mixins/IMUIProxy";
+export default {
+  name: "lemonMessageText",
+  inheritAttrs: false,
+  mixins: [IMUIProxy],
+  render() {
+    return (
+      <lemon-message-basic
+        class="lemon-message-text"
+        props={{ ...this.$attrs }}
+        scopedSlots={{
+          content: props => {
+            const content = this.IMUI.replaceEmojiName(props.content);
+            return <span domProps={{ innerHTML: content }} />;
+          }
+        }}
+      />
+    );
+  }
+};
+</script>
+<style lang="stylus">
+@import '~styles/utils/index'
++b(lemon-message-text)
+  +b(lemon-message)
+    +e(content)
+      img
+        width 18px
+        height 18px
+        display inline-block
+        background transparent
+        padding 0 2px
+        vertical-align middle
+</style>

+ 142 - 0
packages/components/messages.vue

@@ -0,0 +1,142 @@
+<script>
+import { hoursTimeFormat } from "utils";
+export default {
+  name: "LemonMessages",
+  components: {},
+  props: {
+    reverseUserId: String,
+    timeRange: {
+      type: Number,
+      default: 1
+    },
+    timeFormat: {
+      type: Function,
+      default(val) {
+        return hoursTimeFormat(val);
+      }
+    },
+    messages: {
+      type: Array,
+      default: () => []
+    }
+  },
+  data() {
+    return {
+      loading: false,
+      loadend: false
+    };
+  },
+  render() {
+    return (
+      <div class="lemon-messages" ref="wrap" on-scroll={this._handleScroll}>
+        <div
+          class={[
+            "lemon-messages__load",
+            `lemon-messages__load--${this.loadend ? "end" : "ing"}`
+          ]}
+        >
+          {this.loadend ? this._renderLoadEnd() : this._renderLoading()}
+        </div>
+        {this.messages.map((message, index) => {
+          const node = [];
+          const tagName = `lemon-message-${message.type}`;
+          const prev = this.messages[index - 1];
+          if (
+            prev &&
+            this.msecRange &&
+            message.sendTime - prev.sendTime > this.msecRange
+          ) {
+            node.push(
+              <lemon-message-event
+                attrs={{
+                  message: {
+                    id: "__time__",
+                    type: "event",
+                    content: this.timeFormat(message.sendTime)
+                  }
+                }}
+              />
+            );
+          }
+          node.push(
+            <tagName
+              ref="message"
+              refInFor={true}
+              attrs={{
+                timeFormat: this.msecRange > 0 ? () => {} : this.timeFormat,
+                message: message,
+                reverse: this.reverseUserId == message.fromUser.id,
+                hiddenTitle: false
+              }}
+            />
+          );
+          return node;
+        })}
+      </div>
+    );
+  },
+  computed: {
+    msecRange() {
+      return this.timeRange * 1000 * 60;
+    }
+  },
+  watch: {},
+  methods: {
+    _renderLoading() {
+      return <i class="lemon-icon-loading lemonani-spin" />;
+    },
+    _renderLoadEnd() {
+      return <span>暂无消息</span>;
+    },
+    resetLoadState() {
+      this.loading = false;
+      this.loadend = false;
+    },
+    async _handleScroll(e) {
+      const { target } = e;
+      if (
+        target.scrollTop == 0 &&
+        this.loading == false &&
+        this.loadend == false
+      ) {
+        this.loading = true;
+        await this.$nextTick();
+        const hst = target.scrollHeight;
+
+        this.$emit("reach-top", async isEnd => {
+          await this.$nextTick();
+          target.scrollTop = target.scrollHeight - hst;
+          this.loading = false;
+          this.loadend = !!isEnd;
+        });
+      }
+    },
+    async scrollToBottom() {
+      await this.$nextTick();
+      const { wrap } = this.$refs;
+      if (wrap) wrap.scrollTop = wrap.scrollHeight;
+    }
+  },
+  created() {},
+  mounted() {}
+};
+</script>
+<style lang="stylus">
+@import '~styles/utils/index'
++b(lemon-messages)
+  height 400px
+  overflow-x hidden
+  overflow-y auto
+  scrollbar-light()
+  padding 10px 15px
+  +e(time)
+    text-align center
+    font-size 12px
+  +e(load)
+    font-size 12px
+    text-align center
+    color #999
+    line-height 30px
+    +m(ing)
+      font-size 22px
+</style>

+ 143 - 0
packages/components/popover.vue

@@ -0,0 +1,143 @@
+<script>
+const popoverCloseQueue = [];
+const popoverCloseAll = () => popoverCloseQueue.forEach(callback => callback());
+const triggerEvents = {
+  hover(el) {},
+  focus(el) {
+    el.addEventListener("focus", e => {
+      this.changeVisible();
+    });
+    el.addEventListener("blur", e => {
+      this.changeVisible();
+    });
+  },
+  click(el) {
+    el.addEventListener("click", e => {
+      e.stopPropagation();
+      this.changeVisible();
+    });
+  },
+  contextmenu(el) {
+    el.addEventListener("contextmenu", e => {
+      e.preventDefault();
+      this.changeVisible();
+    });
+  }
+};
+export default {
+  name: "LemonPopover",
+  props: {
+    trigger: {
+      type: String,
+      default: "click",
+      validator(val) {
+        return Object.keys(triggerEvents).includes(val);
+      }
+    }
+  },
+  data() {
+    return {
+      popoverStyle: {},
+      visible: false
+    };
+  },
+  created() {
+    document.addEventListener("click", this._documentClickEvent);
+    popoverCloseQueue.push(this.close);
+  },
+  mounted() {
+    triggerEvents[this.trigger].call(this, this.$slots.default[0].elm);
+  },
+  render() {
+    return (
+      <span style="position:relative">
+        <transition name="slide-top">
+          {this.visible && (
+            <div
+              class="lemon-popover"
+              ref="popover"
+              style={this.popoverStyle}
+              on-click={e => e.stopPropagation()}
+            >
+              <div class="lemon-popover__title" />
+              <div class="lemon-popover__content">{this.$slots.content}</div>
+              <div class="lemon-popover__arrow" />
+            </div>
+          )}
+        </transition>
+        {this.$slots.default}
+      </span>
+    );
+  },
+  destroyed() {
+    document.removeEventListener("click", this._documentClickEvent);
+  },
+  computed: {},
+  watch: {
+    async visible(val) {
+      if (val) {
+        await this.$nextTick();
+        const defaultEl = this.$slots.default[0].elm;
+        const contentEl = this.$refs.popover;
+
+        this.popoverStyle = {
+          top: `-${contentEl.offsetHeight + 10}px`,
+          left: `${defaultEl.offsetWidth / 2 - contentEl.offsetWidth / 2}px`
+        };
+      }
+    }
+  },
+  methods: {
+    _documentClickEvent(e) {
+      e.stopPropagation();
+      if (this.visible) this.close();
+    },
+    changeVisible() {
+      this.visible ? this.close() : this.open();
+    },
+    open() {
+      popoverCloseAll();
+      this.visible = true;
+    },
+    close() {
+      this.visible = false;
+    }
+  }
+};
+</script>
+<style lang="stylus">
+@import '~styles/utils/index'
++b(lemon-popover)
+  border 1px solid #eee
+  border-radius 4px
+  font-size 14px
+  font-variant tabular-nums
+  line-height 1.5
+  color rgba(0, 0, 0, 0.65)
+  z-index 10
+  background-color #fff
+  border-radius 4px
+  box-shadow 0 2px 8px rgba(0, 0, 0, 0.15)
+  position absolute
+  transform-origin 50% 150%
+  +e(content)
+    padding 15px
+    box-sizing border-box
+    position relative
+    z-index 1
+  +e(arrow)
+    left 50%
+    transform translateX(-50%) rotate(45deg)
+    position absolute
+    z-index 0
+    bottom -4px
+    box-shadow 3px 3px 7px rgba(0, 0, 0, 0.07)
+    width 8px
+    height 8px
+    background #fff
+.slide-top-leave-active ,.slide-top-enter-active
+  transition all .3s cubic-bezier(0.645, 0.045, 0.355, 1)
+.slide-top-enter, .slide-top-leave-to
+  transform translateY(-10px) scale(.8)
+  opacity 0
+</style>

+ 77 - 0
packages/components/tabs.vue

@@ -0,0 +1,77 @@
+<script>
+export default {
+  name: "LemonTabs",
+  props: {
+    activeIndex: String
+  },
+  data() {
+    return {
+      active: this.activeIndex
+    };
+  },
+  mounted() {
+    if (!this.active) {
+      this.active = this.$slots["tab-pane"][0].data.attrs.index;
+    }
+  },
+  render() {
+    const pane = [];
+    const nav = [];
+    this.$slots["tab-pane"].map(vnode => {
+      const { tab, index } = vnode.data.attrs;
+      pane.push(
+        <div class="lemon-tabs-content__pane" v-show={this.active == index}>
+          {vnode}
+        </div>
+      );
+      nav.push(
+        <div
+          class={[
+            "lemon-tabs-nav__item",
+            this.active == index && "lemon-tabs-nav__item--active"
+          ]}
+          on-click={() => this._handleNavClick(index)}
+        >
+          {tab}
+        </div>
+      );
+    });
+    return (
+      <div class="lemon-tabs">
+        <div class="lemon-tabs-content">{pane}</div>
+        <div class="lemon-tabs-nav">{nav}</div>
+      </div>
+    );
+  },
+  methods: {
+    _handleNavClick(index) {
+      this.active = index;
+    }
+  }
+};
+</script>
+<style lang="stylus">
+@import '~styles/utils/index'
+pane-color = #f6f6f6
++b(lemon-tabs)
+  background pane-color
++b(lemon-tabs-content)
+  width 100%
+  height 100%
+  padding 15px
+  +e(pane)
+    //scrollbar-light()
+    //overflow-y auto
+    height 100%
+    width 100%
++b(lemon-tabs-nav)
+  display flex
+  background #eee
+  +e(item)
+    line-height 38px
+    padding 0 15px
+    cursor pointer
+    transition all .3s cubic-bezier(0.645, 0.045, 0.355, 1)
+    +m(active)
+      background pane-color
+</style>

+ 0 - 24
packages/element-ui.js

@@ -1,24 +0,0 @@
-import Vue from "vue";
-
-import {
-  Button,
-  Icon,
-  Row,
-  Col,
-  Container,
-  Header,
-  Main,
-  Aside,
-  Loading
-} from "element-ui";
-Vue.use(Button);
-Vue.use(Icon);
-Vue.use(Row);
-Vue.use(Col);
-Vue.use(Container);
-Vue.use(Header);
-Vue.use(Main);
-Vue.use(Aside);
-Vue.use(Loading.directive);
-
-Vue.prototype.$loading = Loading.service;

+ 35 - 4
packages/index.js

@@ -1,8 +1,39 @@
-import "./styles/common/index.scss";
-import "./element-ui";
-import Lemon from "./components/lemon";
+import "./plugins";
+//import "./element-ui";
+
+import LemonTabs from "./components/tabs";
+import LemonPopover from "./components/popover";
+import LemonButton from "./components/button";
+import LemonBadge from "./components/badge";
+import LemonAvatar from "./components/avatar";
+import LemonContact from "./components/contact";
+import LemonEditor from "./components/editor";
+import LemonMessages from "./components/messages";
+import LemonMessageBasic from "./components/message/basic";
+import LemonMessageText from "./components/message/text";
+import lemonMessageImage from "./components/message/image";
+import lemonMessageFile from "./components/message/file";
+import lemonMessageEvent from "./components/message/event";
+
+import LemonIMUI from "./components/index";
+import "./styles/common/index.styl";
 const version = "0.1";
-const components = [Lemon];
+const components = [
+  LemonIMUI,
+  LemonContact,
+  LemonMessages,
+  LemonEditor,
+  LemonAvatar,
+  LemonBadge,
+  LemonButton,
+  LemonPopover,
+  LemonTabs,
+  LemonMessageBasic,
+  LemonMessageText,
+  lemonMessageImage,
+  lemonMessageFile,
+  lemonMessageEvent,
+];
 const install = (Vue, opts = {}) => {
   components.forEach(component => {
     Vue.component(component.name, component);

+ 17 - 0
packages/lastContentRender.js

@@ -0,0 +1,17 @@
+export default {
+  voice(message) {
+    return "[语音]";
+  },
+  file(message) {
+    return "[文件]";
+  },
+  video(message) {
+    return "[视频]";
+  },
+  image(message) {
+    return "[图片]";
+  },
+  text(message) {
+    return this.replaceEmojiName(message.content);
+  }
+};

+ 5 - 5
packages/message-type.txt

@@ -3,7 +3,7 @@
  textMessage = {
     msgId: "msgid",
     status: "send_going",
-    msgType: "text",
+    type: "text",
     isOutgoing: true,
     text: "text",
     fromUser: {},
@@ -12,7 +12,7 @@
 
 imageMessage = {
     msgId: "msgid",
-    msgType: "image",
+    type: "image",
     isOutGoing: true,
     mediaPath: "image path",
     fromUser: {},
@@ -22,7 +22,7 @@ imageMessage = {
 videoMessage = {  // video message
     msgId: "msgid",
     status: "send_failed",
-    msgType: "video",
+    type: "video",
     isOutGoing: true,
     druation: number,
     mediaPath: "voice path",
@@ -31,7 +31,7 @@ videoMessage = {  // video message
 }
 customMessage = {  // custom message
     msgId: "msgid",
-    msgType: "custom",
+    type: "custom",
     status: "send_failed",
     isOutgoing: true,
     contentSize: {height: 100, width: 100},
@@ -41,7 +41,7 @@ customMessage = {  // custom message
 }
 eventMessage = {  // event message
     msgId: "msgid",
-    msgType: "event",
+    type: "event",
     text: "the event text"
 }
 

+ 4 - 0
packages/mixins/IMUIProxy.js

@@ -0,0 +1,4 @@
+export default {
+  inject: ["IMUI"],
+  methods: {}
+};

+ 0 - 0
packages/plugins/index.js


+ 92 - 0
packages/readme.txt

@@ -0,0 +1,92 @@
+Data
+  # 联系人数据
+  contact:{
+    // 联系人唯一ID
+    id: "1",
+    // 名字
+    displayName: "范佳奕",
+    // 头像
+    avatar:
+      "https://ss3.bdstatic.com/70cFv8Sh_Q1YnxGkpoWK1HF6hhy/it/u=4259300811,497831842&fm=26&gp=0.jpg",
+    // 类型 single | many 
+    type: "single",
+    // 联系人索引, 默认按照 localeCompare 排序, 指定顺序 '[1]群组'
+    index: "B",
+    // 最近一条消息
+    message: {
+      // 未读数量 , true 显示红点
+      unread: 0,
+      // 发送时间
+      sendTime: 1,
+      // 内容
+      content: "2",
+      // 类型 voice | file | video | image | text
+      type: "image"
+    }
+  }
+  # 消息数据
+  message:{
+    // 消息唯一ID
+    id: "123",
+    // 消息状态  going | succeed | failed
+    status: "succeed",
+    // 消息类型 voice | file | video | image | text
+    type: "image",
+    // 发送时间
+    sendTime: 12312312312,
+    // 消息内容 | 消息链接
+    content: "asdas",
+    // 接收方 ID  单人聊天(联系人ID) | 多人聊天(群ID)
+    toContactId: "123",
+    // type = file 文件大小
+    fileSize: 1231,
+    // type = file 文件名称
+    fileName: 'asdasd.doc'
+    // 发送消息的联系人
+    fromUser: { id: "123", displayName: "123", avatar: "123", type: "single" }
+  }
+  # 左侧按钮配置
+  menu:[{
+    # 指定 menu 唯一名称,保留字段 contacts(通讯录) lastMessages(最近消息)
+    name: "123",
+    # 鼠标停留显示文字
+    title: "通讯录",
+    # 标记未读数, 数字显示具体值, true 显示红点
+    unread: 0,
+    # 按钮的DOM元素
+    render: menu => {
+      return <span>T</span>;
+    },
+    # 点击按钮打开的页面,如果 name 为保留字段,该属性失效
+    renderContainer: () => {
+      return <lemon-contact contact={this.lastMessages[0]} />;
+    },
+    # 按钮点击事件,会阻止默认的行为,调用 next() 进入下一步
+    click: next => {
+      next();
+    },
+    # 是否为底部按钮
+    isBottom: false
+  }]
+  emoji:[{
+    label: "表情",
+    children: [
+      {
+        name: "1f600",
+        title: "微笑",
+        src: "https://twemoji.maxcdn.com/2/72x72/1f600.png"
+      },
+    ]
+  }]
+
+Methods
+  # 初始化左侧 menu 数据
+  initMenus(data:[menu])
+  # 初始化联系人数据
+  initContacts(data:[contact])
+  # 修改联系人数据
+  updateContact(contactId:联系人ID,data:联系人数据,会与现有的数据合并)
+  # 切换 menu 
+  changeMenu(menuName)
+  # 打开一个聊天窗口 传入 contactId 或 contact(如果当前不存在这个联系人,根据此数据新建一个)
+  openMessage(contactId | contact)

+ 13 - 0
packages/styles/common/animate.styl

@@ -0,0 +1,13 @@
+
+.lemonani-spin
+  display inline-block
+  animation lemonani-spin 1s infinite
+@keyframes lemonani-spin{
+  0%{
+    transform rotate(0deg)
+  }
+  100%{
+    transform rotate(360deg)
+  }
+}
+

+ 42 - 0
packages/styles/common/icons.styl

@@ -0,0 +1,42 @@
+@font-face {
+  font-family: 'lemon-icons'; 
+  src: url('//at.alicdn.com/t/font_1312162_neqltsj20an.eot');
+  src: url('//at.alicdn.com/t/font_1312162_neqltsj20an.eot?#iefix') format('embedded-opentype'),
+  url('//at.alicdn.com/t/font_1312162_neqltsj20an.woff2') format('woff2'),
+  url('//at.alicdn.com/t/font_1312162_neqltsj20an.woff') format('woff'),
+  url('//at.alicdn.com/t/font_1312162_neqltsj20an.ttf') format('truetype'),
+  url('//at.alicdn.com/t/font_1312162_neqltsj20an.svg#iconfont') format('svg');
+
+}
+[class^='lemon-icon-'],
+[class*=' lemon-icon-']
+  font-family lemon-icons !important
+  speak none
+  font-style normal
+  font-weight 400
+  font-variant normal
+  text-transform none
+  line-height 1
+  vertical-align baseline
+  display inline-block
+
+.lemon-icon-loading:before
+  content '\e633'
+.lemon-icon-prompt:before
+  content '\e71b'
+.lemon-icon-message:before
+  content '\e84a'
+.lemon-icon-emoji:before
+  content '\e6f6'
+.lemon-icon-attah:before
+  content '\e7e1'
+.lemon-icon-image:before
+  content '\e7de'
+.lemon-icon-folder:before
+  content '\e7d1'
+.lemon-icon-people:before
+  content '\e715'
+.lemon-icon-group:before
+  content '\e6ff'
+.lemon-icon-addressbook:before
+  content '\e6e2'

+ 0 - 1
packages/styles/common/index.scss

@@ -1 +0,0 @@
-@import './normalize';

+ 58 - 0
packages/styles/common/index.styl

@@ -0,0 +1,58 @@
+//@import './normalize';
+@import './animate';
+@import './icons';
+  
+// .lemon-tabs.ant-tabs-small.ant-tabs-card 
+//   .ant-tabs-card-bar 
+//   .ant-tabs-nav-container
+//     height 30px
+//   .ant-tabs-tab
+//     line-height 28px
+//     font-size 12px
+//   .ant-tabs-content
+//     .ant-tabs-tabpane
+//       padding 10px
+    
+
+// .lemon-tabs.ant-tabs-card
+//   background #eee
+//   overflow hidden
+//   >.ant-tabs-content
+//     height 120px
+//     margin-top -16px    
+//     background #f6f6f6
+//     >.ant-tabs-tabpane
+//       background #f6f6f6
+//       padding 16px
+//   >.ant-tabs-bar
+//     border-color #f6f6f6
+//     user-select none
+//     .ant-tabs-tab
+//       border-color transparent
+//       background transparent
+//       border none
+//       border-radius 0
+//       margin 0
+//     .ant-tabs-tab-active
+//       border-color #f6f6f6
+//       background #f6f6f6
+//   .ant-tabs-tab-arrow-show
+//     top -2px
+.lemon-tabs.ant-tabs-card
+  background #eee
+  border-radius 4px
+  overflow hidden
+  .ant-tabs-content
+    background #f6f6f6
+  .ant-tabs-bottom-bar
+    margin-top 0
+    border 0
+  .ant-tabs-card-bar
+    .ant-tabs-tab
+      border-color transparent
+      background transparent
+      border none
+      border-radius 0
+      margin-right 0
+    .ant-tabs-tab-active
+      background #f6f6f6

+ 0 - 38
packages/styles/common/normalize.scss

@@ -1,38 +0,0 @@
-/**
- * 基本样式入口
- */
-
-html {
-  -webkit-tap-highlight-color: transparent;
-}
-
-body {
-  margin: 0;
-}
-
-a {
-  text-decoration: none;
-}
-
-a,
-input,
-button,
-textarea {
-  &:focus {
-    outline: none;
-  }
-}
-
-ol,
-ul {
-  margin: 0;
-  padding: 0;
-  list-style: none;
-}
-
-input,
-button,
-textarea {
-  font: inherit;
-  color: inherit;
-}

+ 23 - 0
packages/styles/common/normalize.styl

@@ -0,0 +1,23 @@
+html 
+  -webkit-tap-highlight-color transparent
+body 
+  margin 0
+a 
+  text-decoration none
+a
+input
+button
+textarea 
+  &:focus 
+    outline none
+ol
+ul 
+  margin 0
+  padding 0
+  list-style none
+input
+button
+textarea 
+  font inherit
+  color inherit
+

+ 67 - 0
packages/styles/utils/bem.styl

@@ -0,0 +1,67 @@
+// -----------------------------------------------------------------------------
+// bem-sugar.styl --- Bem mixins for stylus language
+//
+// Copyright (c) 2017 Ilya Obuhov
+//
+// Author: Ilya Obuhov <iobuhov.mail@gmail.com>
+// URL: https://github.com/iobuhov/stylus-bem-sugar
+
+
+e-prefix    ?= '__'
+m-prefix    ?= '--'
+m-delimiter ?= '_'
+group-store = ()
+
+str()
+  join('', arguments)
+
+b(name)
+  .{name}
+    {block}
+
+group()
+  caller = called-from[0]
+  level = length(called-from) + 1
+  elements = group-store[level]
+  selector = ()
+  parent = null
+  {join(',', elements)}
+    {block}
+  group-store[level] = null
+
+m(mod, val=null)
+  val    = val && m-delimiter + val
+  mod    = m-prefix + mod
+  mod    = val ? mod + val : mod
+  caller = called-from[0]
+  if caller in ('group')
+    level = length(called-from)
+    mod = str('&', mod)
+    if group-store[level] == null
+      group-store[level] = mod
+    else
+      push(group-store[level], mod)
+  &{mod}
+    {block}
+
+e(element)
+  element = e-prefix + element
+  caller  = called-from[0]
+  gcaller = called-from[1]
+  if caller in ('group')
+    level = length(called-from)
+    if gcaller in ('e' 'm')
+      element = str('& ^[0]', element)
+    else
+      element = str('^[0]', element)
+    if group-store[level] == null
+      group-store[level] = element
+    else
+      push(group-store[level], element)
+  else
+    if caller in ('e' 'm')
+      & ^[0]{element}
+        {block}
+    else
+      &{element}
+        {block}

+ 47 - 0
packages/styles/utils/functional.styl

@@ -0,0 +1,47 @@
+flex-column()
+  display flex
+  flex-direction column
+  
+scrollbar-theme($color=#1f252d, $background=#6d6d6d)
+  &::-webkit-scrollbar
+    width 5px
+    height 5px
+  
+  &::-webkit-scrollbar-track-piece
+    background-color $background
+  
+  &::-webkit-scrollbar-thumb:vertical
+    height 5px
+    background-color $color
+  
+  &::-webkit-scrollbar-thumb:horizontal 
+    width 5px
+    background-color $background
+
+scrollbar-dark() 
+  scrollbar-theme()
+
+scrollbar-light() 
+  scrollbar-theme(#aaa, transparent)
+
+
+vertical-center()
+  &::after
+    display inline-block
+    content ''
+    height 100%
+    vertical-align middle
+
+position-center($type fixed) 
+  position $type
+  top 50%
+  left 50%
+  transform translate(-50%, -50%)
+ellipsis()
+  text-overflow ellipsis
+  overflow hidden
+  white-space nowrap
+word-break()
+  word-break break-all
+  word-wrap break-word
+  white-space pre-wrap

+ 0 - 2
packages/styles/utils/index.scss

@@ -1,2 +0,0 @@
-@import './mixins';
-@import './var';

+ 5 - 0
packages/styles/utils/index.styl

@@ -0,0 +1,5 @@
+@import './functional';
+@import './bem';
+@import './var';
+
+

+ 0 - 94
packages/styles/utils/mixins.scss

@@ -1,94 +0,0 @@
-//BEM
-$fi-namespace: 'lemon';
-
-@mixin b($block) {
-  $selector: $fi-namespace + '-' + $block;
-  .#{$selector} {
-    @content;
-  }
-}
-@mixin e($element) {
-  $selector: '__' + $element;
-  &#{$selector} {
-    @content;
-  }
-}
-@mixin m($modifier) {
-  $selector: '--' + $modifier;
-  &#{$selector} {
-    @content;
-  }
-}
-
-@mixin bem($block, $element: false, $modifier: false) {
-  $selector: '.';
-  @if $block {
-    $selector: $fi-namespace + '-' + $block;
-  }
-  @if $element {
-    $selector: $selector + '__' + $element;
-  }
-  @if $modifier {
-    $selector: $selector + '--' + $modifier;
-  }
-  & .#{$selector} {
-    @content;
-  }
-}
-
-@mixin user-select($value) {
-  -moz-user-select: $value;
-  -webkit-user-select: $value;
-  -ms-user-select: $value;
-}
-
-@mixin scrollbar-theme($color: #1f252d, $background: #6d6d6d) {
-  &::-webkit-scrollbar {
-    width: 5px;
-    height: 5px;
-  }
-  &::-webkit-scrollbar-track-piece {
-    background-color: $background;
-  }
-  &::-webkit-scrollbar-thumb:vertical {
-    height: 5px;
-    background-color: $color;
-  }
-  &::-webkit-scrollbar-thumb:horizontal {
-    width: 5px;
-    background-color: $background;
-  }
-}
-@mixin scrollbar-dark() {
-  @include scrollbar-theme();
-}
-@mixin scrollbar-light() {
-  @include scrollbar-theme(#aaa, #fff);
-}
-
-@mixin vertical-center {
-  $selector: &;
-  @at-root {
-    #{$selector}::after {
-      display: inline-block;
-      content: '';
-      height: 100%;
-      vertical-align: middle;
-    }
-  }
-}
-@mixin position-center($type: fixed) {
-  position: $type;
-  top: 50%;
-  left: 50%;
-  transform: translate(-50%, -50%);
-}
-
-@mixin ellipsis {
-  overflow: hidden;
-  text-overflow: ellipsis;
-  white-space: nowrap;
-}
-
-@mixin arrow {
-}

+ 0 - 30
packages/styles/utils/var.scss

@@ -1,30 +0,0 @@
-//$color-primary: #2977fa;
-$color-primary: #1bc213;
-$color-light: #fff;
-/* 头像 */
-$avatar-size: 45px;
-$avatar-radius: 50%;
-
-/** 标题 */
-$title-background: $color-primary;
-$title-color: $color-light;
-$title-height: 44px;
-/* 气泡 */
-$bubble-background: $color-primary;
-$bubble-color: $color-light;
-$bubble-radius: 12px;
-
-$bubble-self-background: #e7ebef;
-$bubble-self-color: #606d84;
-
-/* 输入框 */
-$editor-textarea-height: 40px;
-$editor-textarea-radius: 5px;
-
-$editor-submit-disable-color: #bcbcbc;
-$editor-submit-disable-background: #ebebeb;
-$editor-submit-radius: $editor-textarea-radius;
-/*
-$bubble-self-background: #e7ebef;
-$bubble-self-color: #3a465d;
-*/

+ 26 - 0
packages/styles/utils/var.styl

@@ -0,0 +1,26 @@
+//color-primary #2977fa
+color-primary = #1bc213
+color-light = #fff
+/* 头像 */
+avatar-size = 45px
+avatar-radius = 50%
+
+/** 标题 */
+title-background = color-primary
+title-color = color-light
+title-height = 44px
+/* 气泡 */
+bubble-background = color-primary
+bubble-color = color-light
+bubble-radius = 12px
+
+bubble-self-background = #e7ebef
+bubble-self-color = #606d84
+
+/* 输入框 */
+editor-textarea-height = 40px
+editor-textarea-radius = 5px
+
+editor-submit-disable-color = #bcbcbc
+editor-submit-disable-background = #ebebeb
+editor-submit-radius = editor-textarea-radius

+ 0 - 3
packages/utils/array-intersect.js

@@ -1,3 +0,0 @@
-export default function(a, b) {
-  return a.filter(x => b.includes(x));
-}

+ 39 - 0
packages/utils/cache/memory.js

@@ -0,0 +1,39 @@
+export default class MemoryCache {
+  constructor() {
+    this.table = {};
+  }
+  get(key) {
+    return key ? this.table[key] : this.table;
+  }
+  set(key, val) {
+    this.table[key] = val;
+  }
+  // setOnly(key, val) {
+  //   if (!this.has(key)) this.set(key, val);
+  // }
+  remove(key) {
+    if (key) {
+      delete this.table[key];
+    } else {
+      this.table = {};
+    }
+  }
+  has(key) {
+    return !!this.table[key];
+  }
+}
+// export default {
+//   data: {},
+//   get(name) {
+//     console.log(this.data);
+//   }
+// };
+// class MemoryCache {
+//   constructor() {
+//     super();
+//   }
+//   get($name) {
+//     console.log(1);
+//   }
+// }
+// export default MemoryCache;

+ 16 - 0
packages/utils/constant.js

@@ -0,0 +1,16 @@
+export const EMIT_AVATAR_CLICK = "avatar-click";
+
+export const DEFAULT_MENU_LASTMESSAGES = "lastMessages";
+export const DEFAULT_MENU_CONTACTS = "contacts";
+export const DEFAULT_MENUS = [DEFAULT_MENU_LASTMESSAGES, DEFAULT_MENU_CONTACTS];
+/**
+ * 聊天消息类型
+ */
+export const MESSAGE_TYPE = ["voice", "file", "video", "image", "text"];
+
+/**
+ * 聊天消息状态
+ */
+export const MESSAGE_STATUS = ["going", "succeed", "failed"];
+
+export const CONTACT_TYPE = ["many", "single"];

+ 130 - 0
packages/utils/constraint.js

@@ -0,0 +1,130 @@
+import { MESSAGE_TYPE, MESSAGE_STATUS, CONTACT_TYPE } from "utils/constant";
+import { error } from "utils";
+import { isPlainObject } from "utils/validate";
+
+const constraintContactBasic = data =>
+  constraintObject(data, {
+    id: true,
+    displayName: true,
+    avatar: true,
+    type: {
+      required: true,
+      has: CONTACT_TYPE
+    }
+  });
+const constraintMessageBasic = data =>
+  constraintObject(data, {
+    content: true,
+    sendTime: true,
+    type: {
+      required: true,
+      has: MESSAGE_TYPE
+    }
+  });
+
+// constraintContact({
+//   id: "123",
+//   displayName: "123asd",
+//   avatar: "123",
+//   type: "single",
+//   message: {
+//     unread: 0,
+//     sendTime: 12312312,
+//     content: "12312312",
+//     type: "image"
+//   }
+// });
+
+constraintContact({
+  id: "123",
+  displayName: "123asd",
+  avatar: "123",
+  type: "single",
+  unread: 0,
+  lastSendTime: "",
+  subText: "12312312"
+  // message: {
+  //   unread: 0,
+  //   sendTime: 12312312,
+  //   content: "12312312",
+  //   type: "image"
+  // }
+});
+
+// constraintRecentContact({
+//   fromContactId: 0,
+//   unread: 0,
+//   sendTime: 12312312,
+//   content: "12312312"
+// });
+
+constraintMessage({
+  id: "123",
+  status: "succeed",
+  type: "image",
+  sendTime: 12312312312,
+  content: "asdas",
+  fromContactId: "123",
+  fromUser: { id: "123", displayName: "123", avatar: "123", type: "single" }
+});
+export function constraintObject(data, options) {
+  if (!data || !isPlainObject(data)) {
+    error("argument must be an object");
+  }
+  Object.keys(options).forEach(k => {
+    const option = options[k];
+    const val = data[k];
+    if ((option === true || option.required === true) && val === undefined) {
+      error(`"${k}" cannot be "${val}" `);
+    } else if (option.has && !option.has.includes(val)) {
+      error(
+        `"${k}" cannot be "${val}",can only be the following data "${
+          option.has
+        }"`
+      );
+    }
+  });
+  return true;
+}
+
+// export function constraintRecentContact(data) {
+//   constraintContact(data);
+//   constraintMessageBasic(data.message);
+//   constraintObject(data, {
+//     unread: true
+//   });
+// }
+export function constraintContact(data) {
+  constraintContactBasic(data);
+  // constraintObject(data, {
+  //   unread: true,
+  //   lastSendTime: true,
+  //   lastContent: true
+  // });
+}
+export function constraintMessage(data) {
+  constraintObject(data, {
+    status: {
+      required: true,
+      has: MESSAGE_STATUS
+    },
+    fromContactId: true
+  });
+  constraintMessageBasic(data);
+  constraintContactBasic(data.fromUser);
+  let options = {};
+  switch (data.type) {
+    case "file":
+      options = {
+        fileSize: true,
+        fileName: true
+      };
+      break;
+    case "text":
+      options = {
+        text: true
+      };
+      break;
+  }
+  constraintObject(data, options);
+}

+ 128 - 0
packages/utils/index.js

@@ -0,0 +1,128 @@
+import { isPlainObject, isFunction } from "utils/validate";
+/**
+ * 使用某个组件上的作用域插槽
+ * @param {VueComponent} inject
+ * @param {String} slotName
+ * @param {Node} defaultElement
+ * @param {Object} props
+ */
+export function useScopedSlot(slot, def, props) {
+  return slot ? slot(props) : def;
+}
+export function padZero(val) {
+  return val < 10 ? `0${val}` : val;
+}
+export function hoursTimeFormat(t) {
+  const date = new Date(t);
+  const nowDate = new Date();
+  const Y = t => {
+    return t.getFullYear();
+  };
+  const MD = t => {
+    return `${t.getMonth() + 1}-${t.getDate()}`;
+  };
+  const dateY = Y(date);
+  const nowDateY = Y(nowDate);
+
+  let format;
+  if (dateY !== nowDateY) {
+    format = "y年m月d日 h:i";
+  } else if (`${dateY}-${MD(date)}` === `${nowDateY}-${MD(nowDate)}`) {
+    format = "h:i";
+  } else {
+    format = "m月d日 h:i";
+  }
+  return timeFormat(t, format);
+}
+export function timeFormat(t, format) {
+  if (!format) format = "y-m-d h:i:s";
+  if (t) t = new Date(t);
+  else t = new Date();
+  const formatArr = [
+    t.getFullYear().toString(),
+    padZero((t.getMonth() + 1).toString()),
+    padZero(t.getDate().toString()),
+    padZero(t.getHours().toString()),
+    padZero(t.getMinutes().toString()),
+    padZero(t.getSeconds().toString())
+  ];
+  const reg = "ymdhis";
+  for (let i = 0; i < formatArr.length; i++) {
+    format = format.replace(reg.charAt(i), formatArr[i]);
+  }
+  return format;
+}
+
+export function fastDone(event, callback) {
+  if (isFunction(event)) {
+    event(() => {
+      callback();
+    });
+  } else {
+    callback();
+  }
+}
+/**
+ * 获取数组相交的值组成新数组
+ * @param {Array} a
+ * @param {Array} b
+ */
+export function arrayIntersect(a, b) {
+  return a.filter(x => b.includes(x));
+}
+
+export function error(text) {
+  throw new Error(text);
+}
+export function cloneDeep(obj) {
+  const newobj = { ...obj };
+  for (const key in newobj) {
+    const val = newobj[key];
+    if (isPlainObject(val)) {
+      newobj[key] = cloneDeep(val);
+    }
+  }
+  return newobj;
+}
+
+export function mergeDeep(o1, o2) {
+  for (const key in o2) {
+    if (isPlainObject(o1[key])) {
+      o1[key] = mergeDeep(o1[key], o2[key]);
+    } else {
+      o1[key] = o2[key];
+    }
+  }
+  return o1;
+}
+
+export function toEmojiName(str) {
+  return str.replace(/<img emoji-name=\"([^\"]*?)\" [^>]*>/gi, "[!$1]");
+}
+export function formatByte(value) {
+  if (null == value || value == "") {
+    return "0 Bytes";
+  }
+  var unitArr = ["B", "K", "M", "G", "T", "P", "E", "Z", "Y"];
+  var index = 0;
+  var srcsize = parseFloat(value);
+  index = Math.floor(Math.log(srcsize) / Math.log(1024));
+  var size = srcsize / Math.pow(1024, index);
+  size = parseFloat(size.toFixed(2));
+  return size + unitArr[index];
+}
+
+export function generateUUID() {
+  var d = new Date().getTime();
+  if (window.performance && typeof window.performance.now === "function") {
+    d += performance.now(); //use high-precision timer if available
+  }
+  var uuid = "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, function(
+    c
+  ) {
+    var r = (d + Math.random() * 16) % 16 | 0;
+    d = Math.floor(d / 16);
+    return (c == "x" ? r : (r & 0x3) | 0x8).toString(16);
+  });
+  return uuid;
+}

+ 37 - 0
packages/utils/validate.js

@@ -0,0 +1,37 @@
+export function isPlainObject(obj) {
+  return Object.prototype.toString.call(obj) === "[object Object]";
+}
+export function isString(str) {
+  return typeof str == "string";
+}
+export function isToday(time) {
+  return new Date().getTime() - time < 86400000;
+}
+export function isEmpty(obj) {
+  if (!obj) return true;
+  if (Array.isArray(obj) && obj.length == 0) return true;
+  if (isPlainObject(obj) && Object.values(obj).length == 0) return true;
+  return false;
+}
+export function isUrl(str) {
+  const reg =
+    "^((https|http|ftp|rtsp|mms)?://)" +
+    "?(([0-9a-z_!~*'().&=+$%-]+: )?[0-9a-z_!~*'().&=+$%-]+@)?" + //ftp的user@
+    "(([0-9]{1,3}.){3}[0-9]{1,3}" + // IP形式的URL- 199.194.52.184
+    "|" + // 允许IP和DOMAIN(域名)
+    "([0-9a-z_!~*'()-]+.)*" + // 域名- www.
+    "([0-9a-z][0-9a-z-]{0,61})?[0-9a-z]." + // 二级域名
+    "[a-z]{2,6})" + // first level domain- .com or .museum
+    "(:[0-9]{1,4})?" + // 端口- :80
+    "((/?)|" + // 如果没有文件名,则不需要斜杠
+    "(/[0-9a-z_!~*'().;?:@&=+$,%#-]+)+/?)$";
+  return new RegExp(reg).test(str) ? true : false;
+}
+
+export function isFunction(val) {
+  return val && typeof val === "function";
+}
+
+export function isEng(val) {
+  return /^[A-Za-z]+$/.test(val);
+}

+ 1 - 1
public/index.html

@@ -8,7 +8,7 @@
       content="width=device-width, initial-scale=1.0,maximum-scale=1.0, user-scalable=no"
     />
     <link rel="icon" href="<%= BASE_URL %>favicon.ico" />
-    <title>在线客服</title>
+    <title>Lemon-IMUI</title>
   </head>
   <body>
     <noscript>

+ 1 - 0
vue.config.js

@@ -10,6 +10,7 @@ module.exports = {
       filename: "index.html"
     }
   },
+  productionSourceMap:false,
   configureWebpack: {
     resolve: {
       alias: {

Some files were not shown because too many files changed in this diff