新增 pc-client:畅联 PC 端工程(Electron+React,按确认效果图改造,B-59)

- 基于 openim-electron-demo 改造:工号+密码登录(自研账号服务)、会话列表/聊天窗/通讯录界面重做
- 文字/语音/文件/图片消息、文件拖拽发送、表情面板、一对一语音通话(LiveKit)
- RTC 令牌对接账号服务带鉴权版 /api/rtc_token(Bearer imToken + {room,identity})
- account-service: CORS Allow-Headers 补 authorization(浏览器/渲染进程跨域调 rtc_token 需要)
- 附本地 mock 账号服务(scripts/mock-account-server.js)与截图联调脚本
This commit is contained in:
KIMI
2026-08-09 09:11:06 +08:00
parent b95159f7eb
commit 7ce40e356f
284 changed files with 31599 additions and 7 deletions
@@ -0,0 +1,117 @@
import { Badge } from "antd";
import clsx from "clsx";
import i18n, { t } from "i18next";
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import group_notifications from "@/assets/images/contact/group_notifications.png";
import my_friends from "@/assets/images/contact/my_friends.png";
import my_groups from "@/assets/images/contact/my_groups.png";
import new_friends from "@/assets/images/contact/new_friends.png";
import FlexibleSider from "@/components/FlexibleSider";
import { useContactStore } from "@/store";
const Links = [
{
label: t("placeholder.newFriends"),
icon: new_friends,
path: "/contact/newFriends",
},
{
label: t("placeholder.groupNotification"),
icon: group_notifications,
path: "/contact/groupNotifications",
},
{
label: t("placeholder.myFriend"),
icon: my_friends,
path: "/contact",
},
{
label: t("placeholder.myGroup"),
icon: my_groups,
path: "/contact/myGroups",
},
];
i18n.on("languageChanged", () => {
Links[0].label = t("placeholder.newFriends");
Links[1].label = t("placeholder.groupNotification");
Links[2].label = t("placeholder.myFriend");
Links[3].label = t("placeholder.myGroup");
});
const ContactSider = () => {
const [selectIndex, setSelectIndex] = useState(2);
const unHandleFriendApplicationCount = useContactStore(
(state) => state.unHandleFriendApplicationCount,
);
const unHandleGroupApplicationCount = useContactStore(
(state) => state.unHandleGroupApplicationCount,
);
const navigate = useNavigate();
useEffect(() => {
if (location.hash.includes("/contact/newFriends")) {
setSelectIndex(0);
}
if (location.hash.includes("/contact/groupNotifications")) {
setSelectIndex(1);
}
if (location.hash.includes("/contact/myGroups")) {
setSelectIndex(3);
}
}, []);
const getBadge = (index: number) => {
if (index === 0) {
return unHandleFriendApplicationCount;
}
if (index === 1) {
return unHandleGroupApplicationCount;
}
return 0;
};
return (
<FlexibleSider
needHidden={true}
wrapClassName="flex flex-col border-r border-[var(--gap-text)] bg-white"
>
<div className="app-drag px-4 pt-4">
<div className="pb-3 text-base font-bold">{t("placeholder.contact")}</div>
</div>
<div className="h-full flex-1 overflow-y-auto bg-white">
<ul>
{Links.map((item, index) => {
return (
<li
key={item.path}
className={clsx(
"mx-2 flex cursor-pointer items-center rounded-lg px-3 py-2.5 text-sm",
index === selectIndex
? "bg-[var(--primary-active)]"
: "hover:bg-[#F3F5F7]",
)}
onClick={() => {
setSelectIndex(index);
navigate(String(item.path));
}}
>
<Badge size="small" count={getBadge(index)}>
<img
alt={item.label}
src={item.icon}
className="mr-3 h-12 w-12 rounded-lg"
/>
</Badge>
<div className="text-sm">{item.label}</div>
</li>
);
})}
</ul>
</div>
</FlexibleSider>
);
};
export default ContactSider;
@@ -0,0 +1,116 @@
import { ApplicationHandleResult } from "@openim/wasm-client-sdk";
import { GroupApplicationItem } from "@openim/wasm-client-sdk/lib/types/entity";
import { useCallback } from "react";
import { useTranslation } from "react-i18next";
import { Virtuoso } from "react-virtuoso";
import ApplicationItem, { AccessFunction } from "@/components/ApplicationItem";
import { IMSDK } from "@/layout/MainContentWrap";
import { useUserStore } from "@/store";
import { useContactStore } from "@/store/contact";
import { feedbackToast } from "@/utils/common";
export const GroupNotifications = () => {
const { t } = useTranslation();
const currentUserID = useUserStore((state) => state.selfInfo.userID);
const recvGroupApplicationList = useContactStore(
(state) => state.recvGroupApplicationList,
);
const sendGroupApplicationList = useContactStore(
(state) => state.sendGroupApplicationList,
);
const updateRecvGroupApplication = useContactStore(
(state) => state.updateRecvGroupApplication,
);
const updateSendGroupApplication = useContactStore(
(state) => state.updateSendGroupApplication,
);
const groupApplicationList = sortArray(
recvGroupApplicationList.concat(sendGroupApplicationList),
);
const onAccept = useCallback(
async (application: GroupApplicationItem, isRecv: boolean) => {
try {
await IMSDK.acceptGroupApplication({
groupID: application.groupID,
fromUserID: application.userID,
handleMsg: "",
});
const newApplication = {
...application,
handleResult: ApplicationHandleResult.Agree,
};
if (isRecv) {
updateRecvGroupApplication(newApplication);
} else {
updateSendGroupApplication(newApplication);
}
} catch (error) {
feedbackToast({ error });
}
},
[],
);
const onReject = useCallback(
async (application: GroupApplicationItem, isRecv: boolean) => {
try {
await IMSDK.refuseGroupApplication({
groupID: application.groupID,
fromUserID: application.userID,
handleMsg: "",
});
const newApplication = {
...application,
handleResult: ApplicationHandleResult.Reject,
};
if (isRecv) {
updateRecvGroupApplication(newApplication);
} else {
updateSendGroupApplication(newApplication);
}
} catch (error) {
feedbackToast({ error });
}
},
[],
);
return (
<div className="flex h-full w-full flex-col bg-white">
<p className="m-5.5 text-base font-extrabold">
{t("placeholder.groupNotification")}
</p>
<div className="flex-1 pb-3">
<Virtuoso
className="h-full overflow-x-hidden"
data={groupApplicationList}
itemContent={(_, item) => (
<ApplicationItem
key={`${item.userID}${item.reqTime}`}
source={item}
currentUserID={currentUserID}
onAccept={onAccept as AccessFunction}
onReject={onReject as AccessFunction}
/>
)}
/>
</div>
</div>
);
};
const sortArray = (list: GroupApplicationItem[]) => {
list.sort((a, b) => {
if (a.handleResult === 0 && b.handleResult !== 0) {
return -1;
} else if (b.handleResult === 0 && a.handleResult !== 0) {
return 1;
}
return b.reqTime - a.reqTime;
});
return list;
};
+13
View File
@@ -0,0 +1,13 @@
import { Layout } from "antd";
import { Outlet } from "react-router-dom";
import ContactSider from "@/pages/contact/ContactSider";
export const Contact = () => {
return (
<Layout className="relative z-0 flex-row">
<ContactSider />
<Outlet />
</Layout>
);
};
@@ -0,0 +1,51 @@
import clsx from "clsx";
import {
forwardRef,
ForwardRefRenderFunction,
memo,
useImperativeHandle,
useState,
} from "react";
type IAlphabetIndexProps = {
indexList: string[];
scrollToLetter: (idx: number) => void;
};
const AlphabetIndex: ForwardRefRenderFunction<
{ updateCurrentLetter: (letter: string) => void },
IAlphabetIndexProps
> = ({ indexList, scrollToLetter }, ref) => {
const [currentAlphabet, setCurrentAlphabet] = useState("");
const jumpToLetter = (idx: number, letter: string) => {
scrollToLetter(idx);
setCurrentAlphabet(letter);
};
useImperativeHandle(
ref,
() => ({
updateCurrentLetter: (letter: string) => setCurrentAlphabet(letter),
}),
[],
);
return (
<div className="absolute right-3 top-14 z-10 flex scale-90 flex-col items-center">
{indexList.map((letter, idx) => (
<span
className={clsx("my-0.5 cursor-pointer text-xs text-[var(--sub-text)]", {
"!text-[#0073D9]": currentAlphabet === letter,
})}
key={letter}
onClick={() => jumpToLetter(idx, letter)}
>
{letter}
</span>
))}
</div>
);
};
export default memo(forwardRef(AlphabetIndex));
@@ -0,0 +1,23 @@
import { FriendUserItem } from "@openim/wasm-client-sdk/lib/types/entity";
import OIMAvatar from "@/components/OIMAvatar";
const FriendListItem = ({
friend,
showUserCard,
}: {
friend: FriendUserItem;
showUserCard: (userID: string) => void;
}) => {
return (
<div
className="flex items-center rounded-md px-3.5 pb-3 pt-2.5 transition-colors hover:bg-[var(--primary-active)]"
onClick={() => showUserCard(friend.userID)}
>
<OIMAvatar size={48} src={friend.faceURL} text={friend.remark || friend.nickname} />
<div className="ml-3 truncate text-sm">{friend.remark || friend.nickname}</div>
</div>
);
};
export default FriendListItem;
@@ -0,0 +1,115 @@
import { useRequest } from "ahooks";
import { Empty, Spin } from "antd";
import { useCallback, useEffect, useRef } from "react";
import { useTranslation } from "react-i18next";
import { GroupedVirtuoso, GroupedVirtuosoHandle } from "react-virtuoso";
import { useContactStore } from "@/store";
import { formatContactsByWorker } from "@/utils/contactsFormat";
import { emit } from "@/utils/events";
import AlphabetIndex from "./AlphabetIndex";
import FriendListItem from "./FriendListItem";
export const MyFriends = () => {
const { t } = useTranslation();
const friendList = useContactStore((state) => state.friendList);
const virtuoso = useRef<GroupedVirtuosoHandle>(null);
const alphabetRef = useRef<{ updateCurrentLetter: (letter: string) => void }>(null);
const { data: sectionData, cancel } = useRequest(
() => formatContactsByWorker(friendList),
{
refreshDeps: [friendList],
},
);
useEffect(() => {
return () => {
cancel();
};
}, []);
const scrollToLetter = useCallback(
(idx: number) => {
const prevNum = sectionData?.groupCounts.slice(0, idx).reduce((a, b) => a + b, 0);
console.log(prevNum);
virtuoso.current?.scrollToIndex({
index: prevNum ?? 0,
// behavior: "smooth",
});
},
[sectionData?.groupCounts],
);
const showUserCard = useCallback((userID: string) => {
emit("OPEN_USER_CARD", {
userID,
});
}, []);
const determineCurrentGroup = (startIndex: number) => {
if (!sectionData) return;
let currentItemIndex = 0;
for (
let groupIndex = 0;
groupIndex < sectionData.groupCounts.length;
groupIndex++
) {
const groupItemCount = sectionData.groupCounts[groupIndex];
if (startIndex < currentItemIndex + groupItemCount) {
alphabetRef.current?.updateCurrentLetter(sectionData.indexList[groupIndex]);
break;
}
currentItemIndex += groupItemCount;
}
};
return (
<div className="flex h-full w-full flex-col overflow-hidden bg-white">
<div className="m-5.5 text-base font-extrabold">{t("placeholder.myFriend")}</div>
{!sectionData ? (
<Spin />
) : !sectionData.groupCounts.length ? (
<Empty className="mt-[30%]" image={Empty.PRESENTED_IMAGE_SIMPLE} />
) : (
<div className="ml-4 mt-4 flex-1 overflow-auto pr-4">
<AlphabetIndex
ref={alphabetRef}
indexList={sectionData.indexList}
scrollToLetter={scrollToLetter}
/>
<GroupedVirtuoso
ref={virtuoso}
groupCounts={sectionData.groupCounts}
groupContent={(index) => (
<div>
<div className="bg-white px-3.5 pb-1 text-[13px] text-[#8E9AB0FF]">
{sectionData.indexList[index]}
</div>
<div className="mx-3.5 mb-3 h-px w-full bg-[#E8EAEFFF] bg-white" />
</div>
)}
itemContent={(index) => {
return (
<FriendListItem
key={sectionData.totalList[index].userID}
friend={sectionData.totalList[index]}
showUserCard={showUserCard}
/>
);
}}
rangeChanged={({ startIndex }) => determineCurrentGroup(startIndex)}
className="no-scrollbar h-full overflow-x-hidden"
/>
</div>
)}
</div>
);
};
@@ -0,0 +1,26 @@
import { GroupItem } from "@openim/wasm-client-sdk/lib/types/entity";
import OIMAvatar from "@/components/OIMAvatar";
const GroupListItem = ({
source,
showGroupCard,
}: {
source: GroupItem;
showGroupCard: (group: GroupItem) => void;
}) => {
return (
<div
className="flex flex-row rounded-md px-3.5 py-3 transition-colors hover:bg-[var(--primary-active)]"
onClick={() => showGroupCard(source)}
>
<OIMAvatar size={48} src={source?.faceURL} isgroup />
<div className="ml-3">
<p className="text-sm">{source.groupName}</p>
<p className="text-xs text-[#8E9AB0FF]">{source.memberCount}</p>
</div>
</div>
);
};
export default GroupListItem;
@@ -0,0 +1,77 @@
import { GroupItem } from "@openim/wasm-client-sdk/lib/types/entity";
import { Select } from "antd";
import { useCallback, useState } from "react";
import { useTranslation } from "react-i18next";
import { Virtuoso } from "react-virtuoso";
import { useContactStore, useUserStore } from "@/store";
import { emit } from "@/utils/events";
import GroupListItem from "./GroupListItem";
export enum GroupTypeEnum {
JoinedGroup,
CreatedGroup,
}
export const MyGroups = () => {
const { t } = useTranslation();
const [selectGroup, setSelectGroup] = useState(GroupTypeEnum.CreatedGroup);
const joinedGroupList = useContactStore((state) => state.groupList);
const { userID } = useUserStore((state) => state.selfInfo);
const handleChange = (value: string) => {
setSelectGroup(Number(value));
};
const filterGroup = joinedGroupList.filter((group) => {
if (selectGroup === GroupTypeEnum.JoinedGroup) {
return group.creatorUserID !== userID;
} else if (selectGroup === GroupTypeEnum.CreatedGroup) {
return group.creatorUserID === userID;
}
return false;
});
const showGroupCard = useCallback((group: GroupItem) => {
emit("OPEN_GROUP_CARD", group);
}, []);
return (
<div className="flex h-full w-full flex-col bg-white">
<div className="m-5.5 flex flex-row justify-between">
<p className="text-base font-extrabold">{t("placeholder.myGroup")}</p>
<Select
defaultValue={String(selectGroup)}
popupClassName="p-0"
style={{ width: 200 }}
onChange={handleChange}
options={[
{
value: String(GroupTypeEnum.CreatedGroup),
label: t("placeholder.myCreated"),
},
{
value: String(GroupTypeEnum.JoinedGroup),
label: t("placeholder.myJoined"),
},
]}
/>
</div>
<div className="box-border flex-1 overflow-y-auto px-2 pb-3">
<Virtuoso
className="h-full overflow-x-hidden"
data={filterGroup}
itemContent={(_, group) => (
<GroupListItem
key={group.groupID}
source={group}
showGroupCard={showGroupCard}
/>
)}
/>
</div>
</div>
);
};
@@ -0,0 +1,115 @@
import { ApplicationHandleResult } from "@openim/wasm-client-sdk";
import { FriendApplicationItem } from "@openim/wasm-client-sdk/lib/types/entity";
import { useCallback } from "react";
import { useTranslation } from "react-i18next";
import { Virtuoso } from "react-virtuoso";
import ApplicationItem, { AccessFunction } from "@/components/ApplicationItem";
import { IMSDK } from "@/layout/MainContentWrap";
import { useUserStore } from "@/store";
import { useContactStore } from "@/store/contact";
import { feedbackToast } from "@/utils/common";
import { calcApplicationBadge } from "@/utils/imCommon";
export const NewFriends = () => {
const { t } = useTranslation();
const currentUserID = useUserStore((state) => state.selfInfo.userID);
const recvFriendApplicationList = useContactStore(
(state) => state.recvFriendApplicationList,
);
const sendFriendApplicationList = useContactStore(
(state) => state.sendFriendApplicationList,
);
const updateRecvFriendApplication = useContactStore(
(state) => state.updateRecvFriendApplication,
);
const updateSendFriendApplication = useContactStore(
(state) => state.updateSendFriendApplication,
);
const friendApplicationList = sortArray(
recvFriendApplicationList.concat(sendFriendApplicationList),
);
const onAccept = useCallback(
async (application: FriendApplicationItem, isRecv: boolean) => {
try {
await IMSDK.acceptFriendApplication({
toUserID: application.fromUserID,
handleMsg: "",
});
const newApplication = {
...application,
handleResult: ApplicationHandleResult.Agree,
};
if (isRecv) {
updateRecvFriendApplication(newApplication);
} else {
updateSendFriendApplication(newApplication);
}
} catch (error) {
feedbackToast({ error });
}
},
[],
);
const onReject = useCallback(
async (application: FriendApplicationItem, isRecv: boolean) => {
try {
await IMSDK.refuseFriendApplication({
toUserID: application.fromUserID,
handleMsg: "",
});
const newApplication = {
...application,
handleResult: ApplicationHandleResult.Reject,
};
if (isRecv) {
updateRecvFriendApplication(newApplication);
} else {
updateSendFriendApplication(newApplication);
}
} catch (error) {
feedbackToast({ error });
}
},
[],
);
return (
<div className="flex h-full w-full flex-col bg-white">
<p className="m-5.5 text-base font-extrabold">{t("placeholder.newFriends")}</p>
<div className="flex-1 pb-3">
<Virtuoso
className="h-full overflow-x-hidden"
data={friendApplicationList}
itemContent={(_, item) => (
<ApplicationItem
key={`${
currentUserID === item.fromUserID ? item.toUserID : item.fromUserID
}${item.createTime}`}
source={item}
currentUserID={currentUserID}
onAccept={onAccept as AccessFunction}
onReject={onReject as AccessFunction}
/>
)}
/>
</div>
</div>
);
};
const sortArray = (list: FriendApplicationItem[]) => {
list.sort((a, b) => {
if (a.handleResult === 0 && b.handleResult !== 0) {
return -1;
} else if (b.handleResult === 0 && a.handleResult !== 0) {
return 1;
}
return b.createTime - a.createTime;
});
return list;
};