Browse Source

feat: Add custom service dialogue pages with im. (#156)

* feat: Add session page.

Signed-off-by: IRONICBo <47499836+IRONICBo@users.noreply.github.com>

* feat: Update to OpenIM 3.1.

Signed-off-by: IRONICBo <47499836+IRONICBo@users.noreply.github.com>

* fix: Update sdk params.

Signed-off-by: IRONICBo <47499836+IRONICBo@users.noreply.github.com>

* feat: Interact with OpenIM Server 3.1.

Signed-off-by: IRONICBo <47499836+IRONICBo@users.noreply.github.com>

---------

Signed-off-by: IRONICBo <47499836+IRONICBo@users.noreply.github.com>
main
Asklv GitHub 2 years ago
parent
commit
5b08aa64a4
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
32 changed files with 981 additions and 44 deletions
  1. +2
    -0
      web/.eslintrc.js
  2. +5
    -4
      web/package-lock.json
  3. +2
    -1
      web/package.json
  4. +1
    -0
      web/src/api/request/communityModel.ts
  5. +2
    -2
      web/src/api/request/openimModel.ts
  6. +2
    -0
      web/src/store/index.ts
  7. +7
    -2
      web/src/store/menu/index.ts
  8. +75
    -0
      web/src/store/openim_message/index.ts
  9. +63
    -0
      web/src/store/openim_session/index.ts
  10. +21
    -6
      web/src/store/user/index.ts
  11. +39
    -0
      web/src/style/reset-tdesign.less
  12. +37
    -0
      web/src/utils/common/im.ts
  13. +14
    -0
      web/src/utils/common/time.ts
  14. +1
    -1
      web/src/utils/request/index.ts
  15. +1
    -1
      web/src/views/config/components/BotTable.vue
  16. +4
    -4
      web/src/views/config/components/ConfigForm.vue
  17. +27
    -3
      web/src/views/config/components/ConfigList.vue
  18. +1
    -1
      web/src/views/config/components/GroupTable.vue
  19. +1
    -1
      web/src/views/config/components/KnowledgeBaseTable.vue
  20. +6
    -0
      web/src/views/config/index.less
  21. +1
    -1
      web/src/views/dashboard/components/MemberCard.vue
  22. +5
    -5
      web/src/views/dashboard/index.vue
  23. +13
    -3
      web/src/views/layouts/components/LayoutSideNav.vue
  24. +110
    -0
      web/src/views/layouts/im.ts
  25. +50
    -0
      web/src/views/layouts/index.vue
  26. +4
    -3
      web/src/views/login/components/LoginForm.vue
  27. +2
    -1
      web/src/views/login/components/RegisterForm.vue
  28. +2
    -2
      web/src/views/platform/index.vue
  29. +91
    -0
      web/src/views/session/components/SessionList.vue
  30. +200
    -0
      web/src/views/session/components/SessionWindow.vue
  31. +160
    -0
      web/src/views/session/index.less
  32. +32
    -3
      web/src/views/session/index.vue

+ 2
- 0
web/.eslintrc.js View File

@@ -47,5 +47,7 @@ module.exports = {
'@typescript-eslint/ban-types': 'off',
'vue/multi-word-component-names': 'off',
'vue/comment-directive': 'off',
'no-async-promise-executor': 'off',
'@typescript-eslint/no-non-null-assertion': 'off',
},
};

+ 5
- 4
web/package-lock.json View File

@@ -12,13 +12,14 @@
"echarts": "^5.4.2",
"less-loader": "^11.1.3",
"lodash": "^4.17.21",
"open-im-sdk-wasm": "^0.1.1",
"open-im-sdk-wasm": "^3.1.0",
"pinia": "^2.1.4",
"pinia-plugin-persistedstate": "^3.1.0",
"qs": "^6.11.2",
"tdesign-icons-vue-next": "^0.1.11",
"tdesign-vue-next": "^1.3.10",
"tvision-color": "^1.6.0",
"uuid": "^9.0.0",
"vite-svg-loader": "^4.0.0",
"vue": "^3.3.4",
"vue-router": "^4.2.2"
@@ -2864,9 +2865,9 @@
}
},
"node_modules/open-im-sdk-wasm": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/open-im-sdk-wasm/-/open-im-sdk-wasm-0.1.1.tgz",
"integrity": "sha512-I12XLvP2YwaPPWUCTaOCkqoRuqEx3A1aMvS2f9ouzCMtiFa6FRDLT7koVIri2+XtM8MmEB1XZAxwt3jYBb22gA==",
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/open-im-sdk-wasm/-/open-im-sdk-wasm-3.1.0.tgz",
"integrity": "sha512-Q1VpRCfV6vUrpZKzL/G351zsLeRxByV6FecKWB4blHzJEhXVH47DxbHjXduOs4MU0IcYYRqXiQseiVLsYHUKkQ==",
"dependencies": {
"@jlongster/sql.js": "^1.6.7",
"absurd-sql": "^0.0.53",


+ 2
- 1
web/package.json View File

@@ -14,13 +14,14 @@
"echarts": "^5.4.2",
"less-loader": "^11.1.3",
"lodash": "^4.17.21",
"open-im-sdk-wasm": "^0.1.1",
"open-im-sdk-wasm": "^3.1.0",
"pinia": "^2.1.4",
"pinia-plugin-persistedstate": "^3.1.0",
"qs": "^6.11.2",
"tdesign-icons-vue-next": "^0.1.11",
"tdesign-vue-next": "^1.3.10",
"tvision-color": "^1.6.0",
"uuid": "^9.0.0",
"vite-svg-loader": "^4.0.0",
"vue": "^3.3.4",
"vue-router": "^4.2.2"


+ 1
- 0
web/src/api/request/communityModel.ts View File

@@ -2,6 +2,7 @@ export interface CommunityInfo {
avatar: string;
name: string;
email: string;
description: string;
}

export type CreateCommunityParam = CommunityInfo;


+ 2
- 2
web/src/api/request/openimModel.ts View File

@@ -1,3 +1,3 @@
import { LoginParam } from '@/utils/open-im-sdk-wasm/types/params';
import { InitAndLoginConfig } from '@/utils/open-im-sdk-wasm/types/params';

export type IMLoginParam = LoginParam;
export type IMLoginParam = InitAndLoginConfig;

+ 2
- 0
web/src/store/index.ts View File

@@ -8,5 +8,7 @@ export { store };

export * from './menu';
export * from './user';
export * from './openim_session';
export * from './openim_message';

export default store;

+ 7
- 2
web/src/store/menu/index.ts View File

@@ -2,6 +2,7 @@ import { defineStore } from 'pinia';
import usePermissionStore from '@/store';
import type { MenuRoute } from '@/types/interface'
import type { MenuInfoResponse } from '@/api/response/menuModel';
import store from "../index";

const MockMenuInfo: MenuInfoResponse[] = [
{
@@ -36,7 +37,7 @@ const MockMenuInfo: MenuInfoResponse[] = [
}
]

export const useMenuStore = defineStore('menu', {
const useStore = defineStore('menu', {
state: () => ({
menu_routes: MockMenuInfo,
}),
@@ -46,4 +47,8 @@ export const useMenuStore = defineStore('menu', {
// TODO: fetch menu info
}
}
});
});

export default function useMenuStore() {
return useStore(store);
}

+ 75
- 0
web/src/store/openim_message/index.ts View File

@@ -0,0 +1,75 @@
import { OpenIM } from '@/api/openim';
import { MessageItem } from "open-im-sdk-wasm/lib/types/entity";
import { GetAdvancedHistoryMsgParams } from "open-im-sdk-wasm/lib/types/params";
import { defineStore } from "pinia";
import store from "../index";

interface StateType {
historyMessageList: MessageItem[];
hasMore: boolean;
}

interface IAdvancedMessageResponse {
lastMinSeq: number;
isEnd: boolean;
messageList: MessageItem[];
}

type GetHistoryMessageListFromReqResp = {
messageIDList: string[];
lastMinSeq: number;
};

const useStore = defineStore("message", {
state: (): StateType => ({
historyMessageList: [],
hasMore: true,
}),
getters: {
storeHistoryMessageList: (state) => state.historyMessageList,
storeHistoryMessageHasMore: (state) => state.hasMore,
},
actions: {
async getHistoryMessageListFromReq(
params: GetAdvancedHistoryMsgParams
): Promise<GetHistoryMessageListFromReqResp> {
const isFirstPage =
params.startClientMsgID === "" || params.lastMinSeq === 0;
try {
const { data } = await OpenIM.getAdvancedHistoryMessageList<IAdvancedMessageResponse>(params);
this.historyMessageList = [
...data.messageList,
...(isFirstPage ? [] : this.historyMessageList),
];
this.hasMore = !data.isEnd && data.messageList.length === 20;
return {
messageIDList: data.messageList.map(
(message: MessageItem) => message.clientMsgID
),
lastMinSeq: data.lastMinSeq,
};
} catch (error) {
console.log("Get history message failed", error);
this.hasMore = false;
return {
messageIDList: [],
lastMinSeq: 0,
};
}
},
pushNewMessage(message: MessageItem) {
this.historyMessageList.push(message);
},
clearHistoryMessage() {
this.historyMessageList = [];
},
resetHistoryMessageList() {
this.historyMessageList = [];
this.hasMore = true;
},
},
});

export default function useMessageStore() {
return useStore(store);
}

+ 63
- 0
web/src/store/openim_session/index.ts View File

@@ -0,0 +1,63 @@
import { OpenIM } from '@/api/openim';
import {
ConversationItem,
} from "@/utils/open-im-sdk-wasm/types/entity";

import { defineStore } from "pinia";
import store from "../index";

interface StateType {
conversationList: ConversationItem[];
currentConversation: ConversationItem;
unReadCount: number;
}

const useStore = defineStore("session", {
state: (): StateType => ({
conversationList: [],
currentConversation: {} as ConversationItem,
unReadCount: 0,
}),
getters: {
storeConversationList: (state) => state.conversationList,
storeCurrentConversation: (state) => state.currentConversation,
storeUnReadCount: (state) => state.unReadCount,
},
actions: {
async getConversationListFromReq(): Promise<boolean> {
try {
const { data } = await OpenIM.getAllConversationList<ConversationItem[]>();
this.conversationList = data;
console.log('getConversationListFromReq', data);
return true;
} catch (error) {
return false;
}
},
async getUnReadCountFromReq() {
const { data } = await OpenIM.getTotalUnreadMsgCount<number>();
this.unReadCount = data;
},
updateUnReadCount(data: number) {
this.unReadCount = data;
},
updateCurrentConversation(item: ConversationItem) {
this.currentConversation = { ...item };
},
updateConversationList(list: ConversationItem[]) {
this.conversationList = [...list];
},
delConversationByCID(conversationID: string) {
const idx = this.conversationList.findIndex(
(cve) => cve.conversationID === conversationID
);
if (idx !== -1) {
this.conversationList.splice(idx, 1);
}
},
},
});

export default function useSessionStore() {
return useStore(store);
}

+ 21
- 6
web/src/store/user/index.ts View File

@@ -7,6 +7,7 @@ import type { GetCommunityInfoResponse } from '@/api/response/communityModel';
import { defineStore } from 'pinia';
import { getMyCommunityInfo } from '@/api/index/community';
import { getMyInfo } from '@/api/index/user';
import store from "../index";

const InitUserInfo: GetUserInfoResponse = {
uuid: '',
@@ -28,14 +29,23 @@ const InitCommunityInfo: GetCommunityInfoResponse = {
};

const InitToken: TokenResponse = {
token: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX3V1aWQiOiI5NmUwNGQ5Mi1mZTkwLTRmMGEtYWU4MC1hZTg4YWRmNWY2ZGIiLCJjb21tdW5pdHlfdXVpZCI6ImNmZjkxMjEwLWE5MGEtNGNjYi04MmI3LWI1MzU2M2E3NzkxOSIsImlzcyI6Im9wZW5rZiIsImV4cCI6MTY5MTMxOTM3OCwibmJmIjoxNjkxMjMyOTc4fQ.PaCXrxjP_WyJSY8yishU5jCouRZ1cgpVrh5OSMVBnh0',
token: '',
expire_time_seconds: 0,
};

export const useUserStore = defineStore('user', {
const InitKFToken: TokenResponse = {
token: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX3V1aWQiOiI1NTUyNDhhMWQxNDA5YTdhYmI1ODMwZmRhZDVkIiwiY29tbXVuaXR5X3V1aWQiOiI4NjNiOTExMjdmNGYwODhlZDQ2MjZjMzU3MTYyIiwiaXNzIjoib3BlbmtmIiwiZXhwIjoxNjkxNzI5MzgwLCJuYmYiOjE2OTE2NDI5ODB9.JomPDoVlrRYHWr03yIWqHbPaIQfuXdACEkhw4Ccwm0c',
expire_time_seconds: 0,
};
const InitIMToken: TokenResponse = {
token: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJVc2VySUQiOiI1NTUyNDhhMWQxNDA5YTdhYmI1ODMwZmRhZDVkIiwiUGxhdGZvcm1JRCI6NSwiZXhwIjoxNjk5NDE4OTgwLCJuYmYiOjE2OTE2NDI2ODAsImlhdCI6MTY5MTY0Mjk4MH0.btYIWiNc21o7sowLddv-6k9qEKQdiWlTFnQraX3eAYE',
expire_time_seconds: 0,
};

const useStore = defineStore('user', {
state: () => ({
kf_token: { ...InitToken },
im_token: { ...InitToken },
kf_token: { ...InitKFToken },
im_token: { ...InitIMToken },
userInfo: { ...InitUserInfo },
communityInfo: { ...InitCommunityInfo },
}),
@@ -50,7 +60,7 @@ export const useUserStore = defineStore('user', {
const [userInfo, communityInfo] = await Promise.all([
getMyInfo()
.then(res => {
console.log('getMyInfo success', res);
// console.log('getMyInfo success', res);
return res;
})
.catch(res => {
@@ -59,7 +69,7 @@ export const useUserStore = defineStore('user', {
}), // if error, set to default value
getMyCommunityInfo()
.then(res => {
console.log('getMyCommunityInfo success', res);
// console.log('getMyCommunityInfo success', res);
return res;
})
.catch(res => {
@@ -78,3 +88,8 @@ export const useUserStore = defineStore('user', {
},
},
});

export default function useUserStore() {
return useStore(store);
}

+ 39
- 0
web/src/style/reset-tdesign.less View File

@@ -1,8 +1,22 @@
// reset for openkf
.t-textarea__inner {
border-radius: var(--td-radius-large);
height: var(--td-comp-size-xxxxxl);
padding: var(--td-comp-paddingTB-s) var(--td-comp-paddingLR-l);
resize: none;
}

.t-menu__logo {
min-height: var(--td-comp-size-xxxxxl);
}

.kf-session-input {
.t-input {
border-radius: var(--td-radius-large);
width: 180px;
}
}

.kf-config-input {
.t-input {
border-radius: var(--td-radius-large);
@@ -10,6 +24,31 @@
}
}

.kf-session-list-card {
margin-bottom: var(--td-comp-margin-m);
.t-card {
border-radius: var(--td-radius-large);
background-color: var(--td-font-white-1);
}

.t-card__header {
padding: var(--td-comp-paddingTB-xs) var(--td-comp-paddingLR-l);
padding-top: var(--td-comp-paddingTB-m);
.t-card__title {
font-weight: 500;
}
}

.t-card__body {
padding: var(--td-comp-paddingTB-xs) var(--td-comp-paddingLR-l);
padding-bottom: var(--td-comp-paddingTB-m);
font-size: var(--td-font-size-body-small);
color: var(--td-text-color-secondary);
}
}

.kf-config-list-card {
margin-bottom: var(--td-comp-margin-m);


+ 37
- 0
web/src/utils/common/im.ts View File

@@ -0,0 +1,37 @@
import {
ConversationItem,
} from "@/utils/open-im-sdk-wasm/types/entity";

export const conversationSort = (conversationList: ConversationItem[]) => {
const arr: string[] = [];
const filterArr = conversationList.filter(
(c) => !arr.includes(c.conversationID) && arr.push(c.conversationID)
);
filterArr.sort((a, b) => {
if (a.isPinned === b.isPinned) {
if (!(a.latestMsgSendTime && b.latestMsgSendTime)) {
return 0;
}
const aCompare =
a.draftTextTime! > a.latestMsgSendTime!
? a.draftTextTime!
: a.latestMsgSendTime!;
const bCompare =
b.draftTextTime! > b.latestMsgSendTime!
? b.draftTextTime!
: b.latestMsgSendTime!;
if (aCompare > bCompare) {
return -1;
} else if (aCompare < bCompare) {
return 1;
} else {
return 0;
}
} else if (a.isPinned && !b.isPinned) {
return -1;
} else {
return 1;
}
});
return filterArr;
};

+ 14
- 0
web/src/utils/common/time.ts View File

@@ -43,4 +43,18 @@ export const getNowDiffDays = (date: string): number => {
const diffTime = Math.abs(new Date(date).getTime() - new Date().getTime());
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
return diffDays;
}

// Return a time from date
// if date is today, return time
// if date is yesterday, return yesterday
// if date is before yesterday, return date
export const getTimeFromDate = (timestamp: number | string): string => {
const dat = new Date(timestamp);
const diffDays = getNowDiffDays(dat.toString());
if (diffDays === 0) {
return dat.getHours() + ':' + dat.getMinutes();
} else {
return dat.getDate() + '/' + (dat.getMonth() + 1) + '/' + dat.getFullYear();
}
}

+ 1
- 1
web/src/utils/request/index.ts View File

@@ -7,7 +7,7 @@ import { ContentTypeEnum } from '@/constants';
import { VAxios } from './axios';
import type { AxiosTransform, CreateAxiosOptions } from './transform';
import { formatRequestDate, joinTimestamp, setObjToUrlParams } from './utils';
import { useUserStore } from '@/store';
import useUserStore from '@/store/user';

// Default API url
const host = import.meta.env.VITE_API_URL;


+ 1
- 1
web/src/views/config/components/BotTable.vue View File

@@ -197,7 +197,7 @@ const COLUMNS: PrimaryTableCol<TableRowData>[] = [
>
<template #avatar="{ row }">
<t-avatar v-if="row.avatar === ''">{{ getAvatarString(row.nickname) }}</t-avatar>
<t-avatar v-else :alt="getAvatarString(row.nickname)" hide-on-load-failed="true" :image="row.avatar" />
<t-avatar v-else :alt="getAvatarString(row.nickname)" :hide-on-load-failed="true" :image="row.avatar" />
</template>
<template #op="slotProps">
<a class="t-button-link" @click="handleClickEdit(slotProps)">Edit</a>


+ 4
- 4
web/src/views/config/components/ConfigForm.vue View File

@@ -92,14 +92,14 @@ const updateMyAndCommunityInfo = async () => {
<div :class="[isDark ? 'dark' : 'light', 'config-form-wrapper']">
<div class="config-header ">
<t-row align="middle">
<t-col span="11">
<t-col :span="11">
<h2 class="welcome">Config</h2>
<h1 class="sub-title">
All configs of your custom service.
</h1>
</t-col>
<t-col span="1">
<t-col :span="1">
<!-- <t-button theme="default" shape="circle" variant="outline" @click="fetchData"><icon name="refresh" /></t-button> -->
<t-tooltip content="Update config info"><t-button theme="default" variant="outline" @click="updateMyAndCommunityInfo">Update</t-button></t-tooltip>
</t-col>
@@ -125,7 +125,7 @@ const updateMyAndCommunityInfo = async () => {
<t-list-item-meta title="Avatar" description="Your avatar" />
<template #action>
<t-avatar v-if="myInfo.avatar === ''">{{ getAvatarString(myInfo.nickname) }}</t-avatar>
<t-avatar v-else :alt="getAvatarString(myInfo.nickname)" hide-on-load-failed="true" :image="myInfo.avatar" />
<t-avatar v-else :alt="getAvatarString(myInfo.nickname)" :hide-on-load-failed="true" :image="myInfo.avatar" />
</template>
</t-list-item>
<t-list-item>
@@ -186,7 +186,7 @@ const updateMyAndCommunityInfo = async () => {
<t-list-item-meta title="Avatar" description="Community avatar" />
<template #action>
<t-avatar v-if="communityInfo.avatar === ''">{{ getAvatarString(communityInfo.name) }}</t-avatar>
<t-avatar v-else :alt="getAvatarString(communityInfo.name)" hide-on-load-failed="true" :image="communityInfo.avatar" />
<t-avatar v-else :alt="getAvatarString(communityInfo.name)" :hide-on-load-failed="true" :image="communityInfo.avatar" />
</template>
</t-list-item>
<t-list-item>


+ 27
- 3
web/src/views/config/components/ConfigList.vue View File

@@ -1,33 +1,54 @@
<script setup lang="ts">
import { ref } from 'vue';

const emit = defineEmits(['change'])
const isDark = ref(false);
const cardList = [
const selectedConfigOrder = ref<number>(1);

interface ConfigCard {
order: number;
title: string;
description: string;
}
const cardList:ConfigCard[] = [
{
order: 1,
title: "System",
description: "Customize your OpenKF system."
},
{
order: 2,
title: "Personal",
description: "Update your personal information."
},
{
order: 3,
title: "Community",
description: "Update your community information."
},
{
order: 4,
title: "Group",
description: "Manage your customer service staff."
},
{
order: 5,
title: "Bot",
description: "Manage LLM Bots."
},
{
order: 6,
title: "KnowledgeBase",
description: "Manage local knowledge base."
},
]

const changeConfig = (config: ConfigCard) => {
// change bind value
selectedConfigOrder.value = config.order;
console.log("Change configCard", config.order)
emit('change', config)
}
</script>

<template>
@@ -38,8 +59,11 @@ const cardList = [
Manage your own custom service config.
</h1>
</div>
<div v-for="item in cardList" :key="item.title" class="kf-config-list-card">
<t-card :title="item.title" hover-shadow >
<div
v-for="item in cardList"
:key="item.title"
:class="[selectedConfigOrder === item.order ? 'kf-selected-card' : '', 'kf-config-list-card']">
<t-card :title="item.title" hover-shadow @click="changeConfig(item)">
{{ item.description }}
<template #actions>
<t-tag


+ 1
- 1
web/src/views/config/components/GroupTable.vue View File

@@ -215,7 +215,7 @@ const COLUMNS: PrimaryTableCol<TableRowData>[] = [
>
<template #avatar="{ row }">
<t-avatar v-if="row.avatar === ''">{{ getAvatarString(row.nickname) }}</t-avatar>
<t-avatar v-else :alt="getAvatarString(row.nickname)" hide-on-load-failed="true" :image="row.avatar" />
<t-avatar v-else :alt="getAvatarString(row.nickname)" :hide-on-load-failed="true" :image="row.avatar" />
</template>
<template #is_enable="{ row }">
<t-tag v-if="row.is_enable === true" theme="success" variant="light"> Enabled </t-tag>


+ 1
- 1
web/src/views/config/components/KnowledgeBaseTable.vue View File

@@ -183,7 +183,7 @@ const COLUMNS: PrimaryTableCol<TableRowData>[] = [
>
<template #avatar="{ row }">
<t-avatar v-if="row.avatar === ''">{{ getAvatarString(row.nickname) }}</t-avatar>
<t-avatar v-else :alt="getAvatarString(row.nickname)" hide-on-load-failed="true" :image="row.avatar" />
<t-avatar v-else :alt="getAvatarString(row.nickname)" :hide-on-load-failed="true" :image="row.avatar" />
</template>
<template #op="slotProps">
<a class="t-button-link" @click="handleClickEdit(slotProps)">Edit</a>


+ 6
- 0
web/src/views/config/index.less View File

@@ -92,4 +92,10 @@
border-radius: var(--td-radius-large);
}
}

.kf-selected-card {
& .t-card--bordered {
border: 2px solid var(--td-brand-color-5);
}
}
// config form end

+ 1
- 1
web/src/views/dashboard/components/MemberCard.vue View File

@@ -60,7 +60,7 @@ onMounted(() => {
>
<template #image>
<t-avatar class="kf-list-item-avatar-size" v-if="item.avatar === ''">{{ getAvatarString(item.nickname) }}</t-avatar>
<t-avatar class="kf-list-item-avatar-size" v-else :alt="getAvatarString(item.nickname)" hide-on-load-failed="true" :image="item.avatar" />
<t-avatar class="kf-list-item-avatar-size" v-else :alt="getAvatarString(item.nickname)" :hide-on-load-failed="true" :image="item.avatar" />
</template>
</t-list-item-meta>
</t-list-item>


+ 5
- 5
web/src/views/dashboard/index.vue View File

@@ -108,7 +108,7 @@ const fetchCommunityData = async () => {
</t-button>
</template>
<t-row class="content" justify="space-between">
<t-col v-for="(item, index) in filteredInfo" :key="index" class="contract" span="3">
<t-col v-for="(item, index) in filteredInfo" :key="index" class="contract" :span="3">
<div class="contract-title">
{{ toUpperCase(index).replaceAll('_', ' ') }}
</div>
@@ -125,7 +125,7 @@ const fetchCommunityData = async () => {
<t-col :flex="1">
<t-card class="user-intro" :bordered="false">
<t-avatar size="80px" v-if="myInfo.avatar === ''">{{ getAvatarString(myInfo.nickname) }}</t-avatar>
<t-avatar size="80px" v-else :alt="getAvatarString(myInfo.nickname)" hide-on-load-failed="true" :image="myInfo.avatar" />
<t-avatar size="80px" v-else :alt="getAvatarString(myInfo.nickname)" :hide-on-load-failed="true" :image="myInfo.avatar" />

<div class="name">{{ myInfo.nickname }}</div>
<div class="position">{{ myInfo.description }}</div>
@@ -140,17 +140,17 @@ const fetchCommunityData = async () => {
</t-button>
</template>
<t-row class="content" :gutters="0">
<t-col span="3">
<t-col :span="3">
<t-button size="large" theme="default" shape="circle" variant="outline" @click="navToGitHub">
<t-icon name="logo-github" />
</t-button>
</t-col>
<t-col span="3">
<t-col :span="3">
<t-button size="large" theme="default" shape="circle" variant="outline" @click="navToDoc">
<t-icon name="root-list" />
</t-button>
</t-col>
<t-col span="3">
<t-col :span="3">
<t-button size="large" theme="default" shape="circle" variant="outline" @click="navToSlack">
<t-icon name="user-talk" />
</t-button>


+ 13
- 3
web/src/views/layouts/components/LayoutSideNav.vue View File

@@ -2,7 +2,9 @@
import { ref } from 'vue';
import { computed, onMounted } from 'vue';
import { useRouter } from 'vue-router';
import { useMenuStore } from '@/store';
import useMenuStore from '@/store/menu';
import { OpenIM } from '@/api/openim';
import { MessagePlugin } from 'tdesign-vue-next';

const router = useRouter();
const menuStore = useMenuStore();
@@ -24,8 +26,16 @@ const changeHandler = (active:string) => {
};


const goHome = () => {
router.push('/home/dashboard');
const goHome = async () => {
try {
await OpenIM.logout();
} catch (e) {
MessagePlugin.error('IM logout failed!');
console.log(e);
} finally {
MessagePlugin.success('Logout successfully!');
router.push('/home/dashboard');
}
};

</script>


+ 110
- 0
web/src/views/layouts/im.ts View File

@@ -0,0 +1,110 @@
import { CbEvents } from "@/utils/open-im-sdk-wasm/constant";
import { OpenIM } from '@/api/openim';
import { onMounted, onUnmounted } from "vue";
import {
ConversationItem,
WSEvent,
MessageItem,
} from "@/utils/open-im-sdk-wasm/types/entity";
import useSessionStore from "@/store/openim_session";
import useMessageStore from "@/store/openim_message";
import { conversationSort } from "@/utils/common/im";
import { NotifyPlugin } from "tdesign-vue-next";

export function useGlobalEvent() {
const sessionStore = useSessionStore();
const messageStore = useMessageStore();


const setIMListener = () => {
console.log("setIMListener");

// message
OpenIM.on(CbEvents.OnRecvNewMessage, newMessageHandler);
OpenIM.on(CbEvents.OnRecvNewMessages, newMessageHandler);

// session
OpenIM.on(CbEvents.OnConversationChanged, conversationChangeHandler)
OpenIM.on(CbEvents.OnNewConversation, newConversationHandler);
OpenIM.on(
CbEvents.OnTotalUnreadMessageCountChanged,
totalUnreadChangeHandler
);

};
const disposeIMListener = () => {
console.log("disposeIMListener");

// message
OpenIM.off(CbEvents.OnRecvNewMessage, newMessageHandler);
OpenIM.off(CbEvents.OnRecvNewMessages, newMessageHandler);

// conversation
OpenIM.off(CbEvents.OnConversationChanged, conversationChangeHandler)
OpenIM.off(CbEvents.OnNewConversation, newConversationHandler);
OpenIM.off(
CbEvents.OnTotalUnreadMessageCountChanged,
totalUnreadChangeHandler
);
};

// message
const newMessageHandler = ({ data }: any) => {
const parsedData = data;

// message array
if (Array.isArray(parsedData)) {
parsedData.map((message) => handleNewMessage(message));
return;
}
handleNewMessage(parsedData);
};
const handleNewMessage = (message: MessageItem) => {
console.log('receve new message', message);
NotifyPlugin.info({
title: 'New Messages: ' + message.senderNickname,
content: message.textElem?.content,
duration: 2000,
placement: 'top-right',
closeBtn: true,
});
messageStore.pushNewMessage(message);
};

// session
const conversationChangeHandler = ({ data }: WSEvent<ConversationItem[]>) => {
let filterArr: ConversationItem[] = [];
const changes: ConversationItem[] = data;
const chids = changes.map((ch) => ch.conversationID);
filterArr = sessionStore.storeConversationList.filter(
(tc) => !chids.includes(tc.conversationID)
);
const idx = changes.findIndex(
(c) =>
c.conversationID ===
sessionStore.storeCurrentConversation.conversationID
);
if (idx !== -1) sessionStore.updateCurrentConversation(changes[idx]);
const result = [...changes, ...filterArr];
sessionStore.updateConversationList(conversationSort(result));
};
const newConversationHandler = ({ data }: WSEvent<ConversationItem[]>) => {
console.log('receve new conversation', data);
const news: ConversationItem[] = data;
const result = [...news, ...sessionStore.storeConversationList];
sessionStore.updateConversationList(conversationSort(result));
};
const totalUnreadChangeHandler = ({ data }: WSEvent<number>) => {
console.log('receve new unread count', data);
sessionStore.updateUnReadCount(data);
};

onMounted(() => {
setIMListener();
sessionStore.getConversationListFromReq();
sessionStore.getUnReadCountFromReq();
});
onUnmounted(() => {
disposeIMListener();
});
}

+ 50
- 0
web/src/views/layouts/index.vue View File

@@ -1,6 +1,56 @@
<script setup lang="ts">
import LayoutContent from './components/LayoutContent.vue';
import LayoutSideNav from './components/LayoutSideNav.vue';
import { useGlobalEvent } from './im';
import { onMounted, ref } from 'vue';
import { OpenIM } from '@/api/openim';
import useUserStore from '@/store/user';
import { IMLoginParam } from '@/api/request/openimModel';
import { OpenIMLoginConfig } from '@/constants';
import { useRouter } from 'vue-router';

useGlobalEvent();
const router = useRouter();
const userStore = useUserStore();

// login again
onMounted(() => {
loginCheck();
});

const loginCheck = () => {
OpenIM.getLoginStatus()
.then(res => {
if (res.data !== 3) {
tryLogin();
}
})
.catch(tryLogin);
};

const tryLogin = async () => {
const IMToken = userStore.im_token;
const KFToken = userStore.kf_token;

const config: IMLoginParam = {
userID: userStore.userInfo.uuid,
token: userStore.im_token.token,
platformID: OpenIMLoginConfig.PlatformID,
apiAddr: OpenIMLoginConfig.APIAddress,
wsAddr: OpenIMLoginConfig.WSAddress,
};

if (IMToken && KFToken) {
OpenIM.login(config)
.then(() => {
console.log("login again success!")
})
.catch(err => {
console.log("login again error", err);
router.push('/login');
});
}
};
</script>

<template>


+ 4
- 3
web/src/views/login/components/LoginForm.vue View File

@@ -8,7 +8,8 @@ import { OpenIM } from '@/api/openim';
import { OpenIMLoginConfig } from '@/constants';
import { IMLoginParam } from '@/api/request/openimModel';
import { ref, reactive } from 'vue';
import { useUserStore, useMenuStore } from '@/store';
import useUserStore from '@/store/user';
import useMenuStore from '@/store/menu';
import { localCache } from '@/utils/common/cache';

const formData = reactive({ email: '', password: '' });
@@ -61,8 +62,8 @@ const onSubmit = async (ctx: SubmitContext) => {
userID: res.uuid,
token: res.im_token.token,
platformID: OpenIMLoginConfig.PlatformID,
apiAddress: OpenIMLoginConfig.APIAddress,
wsAddress: OpenIMLoginConfig.WSAddress,
apiAddr: OpenIMLoginConfig.APIAddress,
wsAddr: OpenIMLoginConfig.WSAddress,
};

let temp_data = res;


+ 2
- 1
web/src/views/login/components/RegisterForm.vue View File

@@ -100,6 +100,7 @@ const onSubmit = (ctx: SubmitContext) => {
let data: RegisterAdminParam = {
code: formData.value.admin.verifyCode,
community_info: {
description: '',
avatar: '',
name: formData.value.community.name,
email: formData.value.community.email,
@@ -122,7 +123,7 @@ const onSubmit = (ctx: SubmitContext) => {
const redirect = route.query.redirect as string;
const redirectUrl = redirect
? decodeURIComponent(redirect)
: '/dashboard';
: '/home/dashboard';
router.push(redirectUrl);
})
.catch(err => {


+ 2
- 2
web/src/views/platform/index.vue View File

@@ -31,14 +31,14 @@ const onCancel = () => {
<div :class="[isDark ? 'dark' : 'light', 'platform-wrapper']">
<div class="platform-header ">
<t-row align="middle">
<t-col span="10">
<t-col :span="10">
<h2 class="welcome">Platform</h2>
<h1 class="sub-title">
Check and use various platform access.
</h1>
</t-col>
<t-col span="2" align="center">
<t-col :span="2" align="center">
<!-- TODO: check OpenKF version and update. -->
<t-tooltip content="Check OpenKF Version [TODO...]" >
<t-button theme="default" variant="outline" @click="checkUpdate">


+ 91
- 0
web/src/views/session/components/SessionList.vue View File

@@ -0,0 +1,91 @@
<script setup lang="ts">
import { reactive, ref, watch } from 'vue';
import { onMounted, computed } from 'vue';
import {
ConversationItem,
MessageItem,
} from "@/utils/open-im-sdk-wasm/types/entity";
import { getTimeFromDate } from '@/utils/common/time';
import useSessionStore from '@/store/openim_session'

const emit = defineEmits(['change'])
const isDark = ref(false);

const selectedSessionId = ref<string>("");
const sessionList = ref<ConversationItem[]>([]);
const sessionStore = useSessionStore();

onMounted(() => {
getAllSession();
});

watch(() => sessionStore.conversationList, (newVal, oldVal) => {
console.log("session changed", newVal, oldVal)
getAllSession();
});

const getAllSession = async () => {
sessionList.value = sessionStore.conversationList;
};

// parseJSON like {textELem: {content: 'xxxx'}}
const parseTextELemInLastMsgJSON = (lastMsg:string) => {
const lastMsgObj = JSON.parse(lastMsg);
const textElem = lastMsgObj.textElem;
return textElem.content;
}

const changeSession = (conversation: ConversationItem) => {
// change bind value
selectedSessionId.value = conversation.conversationID;
console.log("Change conversationID", conversation.conversationID)
emit('change', conversation)
}
</script>

<template>
<div :class="[isDark ? 'dark' : 'light', 'session-list-wrapper']">
<div class="session-title">
<h2 class="welcome">OpenKF session</h2>
<h3 class="sub-title">Start your custom service session.</h3>
</div>
<div
v-for="item in sessionList"
:key="item.conversationID"
:class="[selectedSessionId === item.conversationID ? 'kf-select-session' : '', 'kf-session-list-card']"
>
<t-card :title="item.showName" hover-shadow @click="changeSession(item)">
<t-row justify="space-between">
<t-col :span="6" class="msg-ellipsis">
{{ parseTextELemInLastMsgJSON(item.latestMsg) }}
</t-col>
<t-col :span="3" class="msg-time">
{{ getTimeFromDate(item.latestMsgSendTime) }}
</t-col>
</t-row>
<template #actions>
<t-button
shape="circle"
theme="default"
size="small"
variant="outline"
ghost
><t-icon name="close"
/></t-button>
</template>
</t-card>
</div>
</div>
</template>

<script lang="ts">
export default {
name: 'SessionList',
};
</script>

<style lang="less" scoped>
@import url('../index.less');
</style>

+ 200
- 0
web/src/views/session/components/SessionWindow.vue View File

@@ -0,0 +1,200 @@
<script setup lang="ts">
import { ErrorCodes, reactive, ref, watch, nextTick } from 'vue';
import { MessagePlugin } from 'tdesign-vue-next';
import { Icon } from 'tdesign-icons-vue-next';
import { getMyInfo, updateUserInfo } from '@/api/index/user';
import { getMyCommunityInfo, updateCommunityInfo } from '@/api/index/community';
import { updateStaffEnableStatus, deleteStaff } from '@/api/index/admin';
import { UpdateUserInfoParam } from '@/api/request/userModel';
import { UpdateCommunityParam } from '@/api/request/communityModel';
import { onMounted, computed } from 'vue';
import { getAvatarString } from '@/utils/common/string';
import { OpenIM } from '@/api/openim';
import { ConversationItem, MessageItem } from "@/utils/open-im-sdk-wasm/types/entity";
import { SendMsgParams, GetAdvancedHistoryMsgParams } from "@/utils/open-im-sdk-wasm/types/params";

import { getUserInfo } from '@/api/index/user';
import { GetUserInfoParam } from '@/api/request/userModel';
import { GetUserInfoResponse } from '@/api/response/userModel';
import { CbEvents } from "@/utils/open-im-sdk-wasm/constant";
import { WSEvent } from "@/utils/open-im-sdk-wasm/types/entity";
import useUserStore from '@/store/user';
import useMessageStore from '@/store/openim_message';

const InitUserInfo:GetUserInfoResponse = {
uuid: '',
email: '',
nickname: '',
avatar: '',
description: '',
is_enable: false,
is_admin: false,
created_at: '',
};

const isDark = ref(false);
const messageContent = ref('')
const messages = ref<MessageItem[]>()
const senderInfo = ref(useUserStore().userInfo)
const recvInfo = ref<GetUserInfoResponse>(InitUserInfo)
const messageStore = useMessageStore()

const props = defineProps({
session: Object as () => ConversationItem,
})

const getRecvIdFromSessionId = (sessionId: string) => {
const ids = sessionId.split('_');
return ids[1] === senderInfo.value.uuid ? ids[2] : ids[1];
}

watch(() => props.session, (newVal, oldVal) => {
console.log("session changed", newVal, oldVal)
fetchRecvInfo();
getMsgHistory();
scrollToBottom();
});
watch(() => messageStore, (newVal, oldVal) => {
console.log("messageStore changed", newVal, oldVal)
fetchRecvInfo();
getMsgHistory();
scrollToBottom();
});

const fetchRecvInfo = async () => {
try {
const params: GetUserInfoParam = {
uuid: getRecvIdFromSessionId(props.session?.conversationID || '')
}

const info = await getUserInfo(params);
recvInfo.value = info;
} catch (e) {
console.log(e);
MessagePlugin.error('Fetch recv info error!');
}
}

const sendMessage = async () => {
try {
const msg = await OpenIM.createTextMessage<MessageItem>(messageContent.value)
const options: SendMsgParams = {
recvID: recvInfo.value.uuid,
groupID: "",
message: msg.data,
};

try {
const res = await OpenIM.sendMessage(options);
messageStore.pushNewMessage(msg.data);
clearMsgContent();
console.log(res);
} catch (e) {
console.log(e);
}

} catch (e) {
console.log(e);
MessagePlugin.error('Send message error!');
}
};

const clearMsgContent = () => {
messageContent.value = '';
}

const scrollToBottom = () => {
const child = document.querySelector(".session-body") // 需要滚动的元素
nextTick(() => {
child?.scrollTo({
top: child.scrollHeight ,
})
})
};

// parseJSON like {textELem: {content: 'xxxx'}}
const parseTextELemInMsgJSON = (Msg:string) => {
console.log("Msg", Msg)
const MsgObj = JSON.parse(Msg);
const textElem = MsgObj.textElem;
return textElem.content;
}

const getMsgHistory = async () => {
const options:GetAdvancedHistoryMsgParams = {
conversationID: props.session?.conversationID as string,
startClientMsgID: "",
count: 100, // now only find 100 msgs
lastMinSeq: 0,
};

try {
const { messageIDList } = await messageStore.getHistoryMessageListFromReq(options);
console.log("messageIDList", messageIDList);
messages.value = messageStore.historyMessageList;
} catch (e) {
console.log(e);
}
};

// TODO: Add loading animation
const dataLoading = ref(false);
</script>

<template>
<div :class="[isDark ? 'dark' : 'light', 'session-window-wrapper']">
<div class="session-header ">
<t-row align="middle">
<t-col :span="11">
<h2 class="welcome">{{ recvInfo.nickname }}</h2>
<h1 class="sub-title">
UUID: {{ recvInfo.uuid }} IP: {{ 'xxx' }} Device: {{ 'slack' }}
</h1>
</t-col>

<t-col :span="1">
<t-button theme="default" shape="circle" variant="outline"><icon name="refresh" /></t-button>
<t-tooltip content="Update config info"><t-button theme="default" variant="outline" shape="circle" ><icon name="close" /></t-button></t-tooltip>
</t-col>
</t-row>
</div>

<div class="session-body" ref="sessionBody">
<div v-for="(item, idx) in messages" :key="idx" :class="[item.sendID == senderInfo.uuid ? 'send' : 'recv', 'message-card']">
<div v-if="item.sendID == senderInfo.uuid" class="session-avatar">
<t-avatar v-if="senderInfo.avatar === ''">{{ getAvatarString(senderInfo.nickname!) }}</t-avatar>
<t-avatar v-else :alt="getAvatarString(senderInfo.nickname!)" :hide-on-load-failed="true" :image="senderInfo.avatar" />
</div>
<div v-else class="session-avatar">
<t-avatar v-if="recvInfo.avatar === ''">{{ getAvatarString(recvInfo.nickname!) }}</t-avatar>
<t-avatar v-else :alt="getAvatarString(recvInfo.nickname!)" :hide-on-load-failed="true" :image="recvInfo.avatar" />
</div>
<div class="session-content">
<div class="session-content-text">
<span>{{ item.textElem.content }}</span>
</div>
</div>
</div>
</div>

<div class="session-input">
<t-space size="24px" class="tools">
<t-button shape="round" variant="outline" size="small"><t-icon name="tools" /></t-button>
</t-space>
<div class="inner">
<t-textarea v-model="messageContent" placeholder="Enter your text here..." autofocus />
<t-button class="send-button" @click="sendMessage">Send</t-button>
</div>
</div>
</div>
</template>

<script lang="ts">
export default {
name: 'SessionWindow',
};
</script>

<style lang="less" scoped>
@import url('../index.less');
</style>

+ 160
- 0
web/src/views/session/index.less View File

@@ -0,0 +1,160 @@
.light {
&.session-list-wrapper {
background-color: var(--td-brand-color-1);
}

&.session-window-wrapper {
background-color: var(--td-font-white-1);
}
}

.dark {
&.session-list-wrapper {
background-color: var(--td-bg-color-page);
}

&.session-window-wrapper {
background-color: var(--td-bg-color-page);
}
}

.session-window-wrapper {
height: 100vh;
display: flex;
flex-direction: column;
background-size: cover;
background-position: 100%;
position: relative;
}

.session-header {
position: relative;
padding: var(--td-comp-paddingTB-l) var(--td-comp-paddingLR-xl);
border-bottom: 1px solid var(--td-component-border);

.welcome {
font-size: var(--td-font-size-title-large);
font-weight: 700;
}

.sub-title {
font-size: var(--td-font-size-body-small);
font-weight: 400;
}
}

.session-container {
min-height: 100vh;
}

// session list start
.session-list-wrapper {
min-height: 100vh;
padding: var(--td-size-6);
display: flex;
flex-direction: column;
background-size: cover;
background-position: 100%;
position: relative;
}

.session-title {
position: relative;
padding-top: var(--td-size-7);
padding-bottom: var(--td-size-7);

.welcome {
font-size: var(--td-font-size-title-large);
font-weight: 700;
}

.sub-title {
font-size: var(--td-font-size-body-small);
font-weight: 400;
}
}

.kf-session-list-card {
& .msg-ellipsis {
text-overflow: ellipsis;
overflow: hidden;
color: var(--td-gray-color-6)
}
& .msg-time {
color: var(--td-gray-color-6)
}
}

.kf-selected-session {
& .t-card--bordered {
border: 2px solid var(--td-brand-color-5);
}
}

// session list end

// session window start
.session-body {
flex: 1 1;
padding: 20px;
overflow: auto;

& .message-card {
& .session-avatar {
margin-top: var(--td-comp-margin-s);
}

& .session-content {
margin-top: var(--td-comp-margin-s);
max-width: 60%;
width: fit-content;
padding: var(--td-comp-paddingLR-s) var(--td-comp-paddingTB-l);
border: 1px solid var(--td-component-border);
border-radius: var(--td-radius-large);

& .session-content-text {}
}
}
}

.send {
align-items: flex-end;
display: flex;
flex-direction: column;

& .session-content {
background-color: var(--td-brand-color-1);
}
}

.recv {
& .session-content {
background-color: var(--td-gray-color-1);
}
}

.session-input {
border-top: 1px solid var(--td-component-border);
width: 100%;
padding: var(--td-comp-paddingTB-s) var(--td-comp-paddingLR-l);
position: relative;

& .tools {
margin-bottom: var(--td-comp-margin-s);
}

& .inner {
& .t-textarea__inner {
height: 10vh;
}

& .send-button {
border-radius: var(--td-radius-large);
position: absolute;
right: var(--td-comp-size-s);
bottom: var(--td-comp-size-xxxs);
}
}
}

// session window end

+ 32
- 3
web/src/views/session/index.vue View File

@@ -1,7 +1,36 @@
<script setup lang="ts"></script>
<script setup lang="ts">
import { reactive, ref } from 'vue';
import SessionList from './components/SessionList.vue';
import SessionWindow from './components/SessionWindow.vue';
import {
ConversationItem,
MessageItem,
} from "@/utils/open-im-sdk-wasm/types/entity";

const session = ref<ConversationItem>();

const changeSession = (conversation: ConversationItem) => {
session.value = conversation;
};
</script>

<template>
<div>session</div>
<t-row class="session-container">
<t-col :span="3" style="height: 100vh; overflow: auto;">
<session-list @change="changeSession" />
</t-col>
<t-col :span="9">
<session-window :session="session" style="box-shadow: 0px 1px 2px rgba(0, 0, 0, 0.1)" />
</t-col>
</t-row>
</template>

<style lang="scss" scoped></style>
<script lang="ts">
export default {
name: 'SessionIndex',
};
</script>

<style lang="less" scoped>
@import url('./index.less');
</style>

Loading…
Cancel
Save