feat(pc-client): 第二批登录、会话、聊天与添加同事界面按规范收口

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
编码工程师
2026-08-20 03:51:28 +08:00
co-authored by Cursor multica-agent
parent d1657ea29f
commit a44537da4e
20 changed files with 520 additions and 298 deletions
@@ -50,12 +50,12 @@ const ApplicationItem = ({
const getStatusStr = () => {
if (source.handleResult === ApplicationHandleResult.Agree) {
return "已添加";
return t("application.agreed");
}
if (source.handleResult === ApplicationHandleResult.Reject) {
return "已拒绝";
return t("application.refused");
}
return isRecv ? "待处理" : "等待对方通过";
return isRecv ? t("application.waitingHandle") : t("application.pending");
};
const getAvatarUrl = () => {
@@ -80,9 +80,15 @@ const ApplicationItem = ({
window.userClick(isRecv ? source.fromUserID : source.toUserID);
}, []);
const btnStyle = {
height: 32,
flex: 1,
borderRadius: 8,
};
return (
<Spin spinning={loading}>
<div className="flex min-h-[72px] flex-row items-center justify-between px-4 py-3 transition-colors duration-100 hover:bg-[var(--chat-bg)]">
<div className="flex min-h-[72px] flex-row items-center justify-between px-4 py-3 transition-colors duration-100 ease-out hover:bg-[var(--chat-bg)]">
<div className="mr-3 flex min-w-0 flex-1 flex-row">
<OIMAvatar
size={48}
@@ -109,20 +115,16 @@ const ApplicationItem = ({
{showActionBtn && (
<div className="flex w-[132px] shrink-0 flex-row gap-2">
<Button
size="small"
onClick={() => loadingWrap(false)}
className="!h-8 !flex-1 !rounded-lg"
>
<Button size="small" onClick={() => loadingWrap(false)} style={btnStyle}>
{t("application.refuse")}
</Button>
<Button
size="small"
type="primary"
className="!h-8 !flex-1 !rounded-lg !bg-[var(--primary)]"
style={{ ...btnStyle, backgroundColor: "var(--primary)" }}
onClick={() => loadingWrap(true)}
>
{t("application.agree")}
</Button>
</div>
)}
+16 -1
View File
@@ -113,7 +113,12 @@
"publishFailed": "Failed to publish!",
"notCanAddFriend": "The other party has been set up not to be added as a friend!",
"fileTypeError": "Unsupported file type or folder!",
"inputStaffNo": "请输入工号",
"inputStaffNo": "Please enter staff number",
"staffOrPasswordWrong": "Incorrect staff number or password",
"networkRetry": "Network error, please try again later",
"inputPeerStaffNo": "Please enter the colleague's staff number",
"addColleagueSent": "Request sent, waiting for approval",
"addColleagueFailed": "Failed to send. Check the staff number and network",
"noMicrophone": "No microphone detected, cannot record",
"recordTooShort": "Recording is too short"
},
@@ -188,6 +193,15 @@
"qrCodeLoginTitle": "Please scan the QR code using OpenCorp mobile",
"welcome": "欢迎使用畅联",
"title": "畅联",
"internalComms": "Internal company messaging",
"loggingIn": "Signing in",
"agreeInternal": "By signing in you agree to internal usage rules",
"emptyChat": "Select a conversation on the left to start chatting",
"ungrouped": "Ungrouped",
"colleagueStaffNo": "Staff number",
"verifyMessageOptional": "Verification message (optional)",
"addColleagueDefaultMsg": "Hi, I would like to add you as a colleague",
"sendApplication": "Send request",
"subTitle": "公司内部通讯工具",
"myCreated": "I created",
"myJoined": "I joined",
@@ -468,6 +482,7 @@
"refused": "Refused",
"information": "Verification Information",
"pending": "Pending Verification",
"waitingHandle": "Pending",
"applyToJoin": "Apply to Join",
"applyToAdd": "Apply to Add",
"applyToFriend": "Applied to Add You as a Friend"
+15
View File
@@ -112,6 +112,11 @@
"inputVerifyCode": "请输入您的验证码",
"inputPassword": "请输入您的密码",
"inputStaffNo": "请输入工号",
"staffOrPasswordWrong": "工号或密码不正确",
"networkRetry": "网络异常,请稍后重试",
"inputPeerStaffNo": "请输入对方工号",
"addColleagueSent": "申请已发送,等对方通过",
"addColleagueFailed": "发送失败,请确认工号正确、网络正常",
"sendFreiendRequestSuccess": "发送好友请求成功!",
"registerSuccess": "注册成功!",
"inputInvitationCode": "请输入您的邀请码",
@@ -200,6 +205,15 @@
"qrCodeLoginTitle": "请使用OpenCorp移动端扫描二维码",
"welcome": "欢迎使用畅联",
"title": "畅联",
"internalComms": "企业内部通讯",
"loggingIn": "登录中",
"agreeInternal": "登录即代表同意内部使用规范",
"emptyChat": "从左边选一个会话,开始聊天",
"ungrouped": "未分组",
"colleagueStaffNo": "同事工号",
"verifyMessageOptional": "验证消息(可不填)",
"addColleagueDefaultMsg": "你好,我想加你为同事",
"sendApplication": "发送申请",
"subTitle": "公司内部通讯工具",
"myCreated": "我创建的",
"myJoined": "我加入的",
@@ -480,6 +494,7 @@
"refused": "已拒绝",
"information": "验证信息",
"pending": "等待对方通过",
"waitingHandle": "待处理",
"applyToJoin": "申请加入",
"applyToAdd": "申请添加",
"applyToFriend": "申请添加您为好友"
@@ -2,13 +2,15 @@ import type {
ConversationItem as ConversationItemType,
MessageItem,
} from "@openim/wasm-client-sdk/lib/types/entity";
import { Badge } from "antd";
import { Badge, Dropdown } from "antd";
import clsx from "clsx";
import { t } from "i18next";
import { memo, useMemo } from "react";
import { useNavigate } from "react-router-dom";
import OIMAvatar from "@/components/OIMAvatar";
import { IMSDK } from "@/layout/MainContentWrap";
import { useConversationStore, useUserStore } from "@/store";
import { feedbackToast } from "@/utils/common";
import { formatConversionTime, getConversationContent } from "@/utils/imCommon";
import styles from "./conversation-item.module.scss";
@@ -50,47 +52,90 @@ const ConversationItem = ({ isActive, conversation }: IConversationProps) => {
const latestMessageTime = formatConversionTime(conversation.latestMsgSendTime);
const onMenuClick = async ({ key }: { key: string }) => {
try {
if (key === "pin") {
await IMSDK.pinConversation({
conversationID: conversation.conversationID,
isPinned: !conversation.isPinned,
});
return;
}
if (key === "delete") {
await IMSDK.deleteConversationAndDeleteAllMsg(conversation.conversationID);
if (isActive) {
navigate("/chat");
}
}
} catch (error) {
feedbackToast({
error,
msg:
key === "pin"
? t("toast.pinConversationFailed")
: t("toast.deleteConversationFailed"),
});
}
};
return (
<div
className={clsx(
styles["conversation-item"],
"border border-transparent",
isActive ? `bg-[var(--primary-active)]` : "hover:bg-[var(--chat-bg)]",
)}
onClick={toSpecifiedConversation}
<Dropdown
trigger={["contextMenu"]}
menu={{
items: [
{
key: "pin",
label: conversation.isPinned
? t("placeholder.removeSticky")
: t("placeholder.sticky"),
},
{ key: "delete", label: t("placeholder.delete"), danger: true },
],
onClick: onMenuClick,
}}
>
<OIMAvatar
size={48}
src={conversation.faceURL}
isgroup={Boolean(conversation.groupID)}
text={conversation.showName}
/>
<div
className={clsx(
styles["conversation-item"],
"h-16 border border-transparent transition-colors duration-100",
isActive
? "bg-[var(--primary-active)]"
: "hover:bg-[var(--chat-bg)]",
)}
style={{ transitionTimingFunction: "ease-out" }}
onClick={toSpecifiedConversation}
>
<OIMAvatar
size={44}
src={conversation.faceURL}
isgroup={Boolean(conversation.groupID)}
text={conversation.showName}
/>
<div className="ml-3 flex h-12 flex-1 flex-col justify-between overflow-hidden py-0.5">
<div className="flex items-center justify-between">
<div className="flex-1 truncate text-sm font-medium">
{conversation.showName}
<div className="ml-3 flex min-w-0 flex-1 flex-col justify-center overflow-hidden">
<div className="flex items-center justify-between">
<div className="flex-1 truncate text-base font-bold text-[var(--base-black)]">
{conversation.showName}
</div>
<div className="ml-2 shrink-0 text-xs text-[var(--sub-text)]">
{latestMessageTime}
</div>
</div>
<div className="ml-2 text-xs text-[var(--sub-text)]">{latestMessageTime}</div>
</div>
<div className="flex items-center justify-between">
<div className="flex min-h-[18px] flex-1 items-center overflow-hidden">
<div
className="truncate text-[13px] text-[rgba(81,94,112,0.5)]"
dangerouslySetInnerHTML={{
__html: latestMessageContent,
}}
></div>
<div className="mt-1 flex items-center justify-between">
<div className="flex min-h-[18px] min-w-0 flex-1 items-center overflow-hidden">
<div
className="truncate text-sm text-[var(--sub-text)]"
dangerouslySetInnerHTML={{
__html: latestMessageContent,
}}
></div>
</div>
<Badge className="ml-2 shrink-0" size="small" count={conversation.unreadCount} />
</div>
<Badge
className="ml-2"
size="small"
count={conversation.unreadCount}
/>
</div>
</div>
</div>
</Dropdown>
);
};
@@ -1,16 +1,5 @@
.conversation-item {
@apply relative mx-2 my-0.5 flex cursor-pointer items-center rounded-lg px-3 py-2.5;
&-pined {
&::after {
content: "";
position: absolute;
right: 0;
top: 0;
border: 4px solid;
border-color: var(--primary) var(--primary) transparent transparent;
}
}
@apply relative mx-2 my-0 flex cursor-pointer items-center rounded-lg px-3;
:global(.emojione) {
width: 16px;
@@ -1,7 +1,7 @@
import { SearchOutlined, PlusOutlined } from "@ant-design/icons";
import { Button, Input, Spin } from "antd";
import { t } from "i18next";
import { useMemo, useRef, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { Virtuoso, VirtuosoHandle } from "react-virtuoso";
@@ -11,43 +11,90 @@ import { useConversationStore, useUserStore } from "@/store";
import ConversationItemComp from "./ConversationItem";
// 断网时顶部的深色提示横条
const OfflineBar = () => (
<div>
<div className="flex h-9 items-center bg-[#3a3f47] px-3 text-xs text-white">
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
className="mr-1.5 shrink-0"
const prefersReducedMotion = () =>
typeof window !== "undefined" &&
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const OfflineBar = ({ visible }: { visible: boolean }) => {
const [shown, setShown] = useState(visible);
const [leaving, setLeaving] = useState(false);
useEffect(() => {
if (visible) {
setShown(true);
setLeaving(false);
return;
}
if (!shown) return;
if (prefersReducedMotion()) {
setShown(false);
setLeaving(false);
return;
}
setLeaving(true);
const timer = window.setTimeout(() => {
setShown(false);
setLeaving(false);
}, 300);
return () => window.clearTimeout(timer);
}, [visible, shown]);
if (!shown) return null;
return (
<div
style={{
overflow: "hidden",
maxHeight: leaving ? 0 : 80,
opacity: leaving ? 0 : 1,
transition: leaving ? "max-height 300ms ease-out, opacity 300ms ease-out" : "none",
}}
>
<div
className="flex h-9 items-center px-4 text-sm text-white"
style={{ backgroundColor: "var(--banner-bg)" }}
>
<circle cx="12" cy="12" r="9" stroke="#fff" strokeWidth="2" />
<path
d="M12 7.5v5.5"
stroke="#fff"
strokeWidth="2"
strokeLinecap="round"
/>
<circle cx="12" cy="16.5" r="1.2" fill="#fff" />
</svg>
{t("states.offline")}
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
className="mr-2 shrink-0"
>
<circle cx="12" cy="12" r="9" stroke="#fff" strokeWidth="1.8" />
<path d="M12 7.5v5.5" stroke="#fff" strokeWidth="1.8" strokeLinecap="round" />
<circle cx="12" cy="16.5" r="1.2" fill="#fff" />
</svg>
{t("states.offline")}
</div>
<div className="px-4 py-2 text-xs text-[var(--sub-text)]">
{t("states.offlineTip")}
</div>
</div>
<div className="px-3 py-2 text-xs text-[var(--sub-text)]">
{t("states.offlineTip")}
</div>
</div>
);
};
const LinearChatIcon = () => (
<svg width="64" height="64" viewBox="0 0 24 24" fill="none">
<path
d="M5 6.5h14a1.5 1.5 0 0 1 1.5 1.5v8a1.5 1.5 0 0 1-1.5 1.5h-7.2L7 20.5v-2.5H5A1.5 1.5 0 0 1 3.5 16V8A1.5 1.5 0 0 1 5 6.5Z"
stroke="var(--gap-text)"
strokeWidth="1.6"
strokeLinejoin="round"
/>
</svg>
);
const EmptyIcon = () => (
<svg width="56" height="56" viewBox="0 0 24 24" fill="none">
const LinearFailIcon = () => (
<svg width="64" height="64" viewBox="0 0 24 24" fill="none">
<path
d="M12 3C6.98 3 3 6.58 3 11.1c0 2.5 1.28 4.73 3.28 6.24-.1.84-.46 1.87-1.02 2.66-.15.21.02.5.27.44 1.53-.34 2.92-1.03 3.9-1.73.82.18 1.68.29 2.57.29 5.02 0 9-3.58 9-8.1S17.02 3 12 3Z"
fill="#dcdfe6"
d="M5 6.5h14a1.5 1.5 0 0 1 1.5 1.5v8a1.5 1.5 0 0 1-1.5 1.5h-7.2L7 20.5v-2.5H5A1.5 1.5 0 0 1 3.5 16V8A1.5 1.5 0 0 1 5 6.5Z"
stroke="var(--gap-text)"
strokeWidth="1.6"
strokeLinejoin="round"
/>
<circle cx="8.5" cy="11" r="1.1" fill="#fff" />
<circle cx="12" cy="11" r="1.1" fill="#fff" />
<circle cx="15.5" cy="11" r="1.1" fill="#fff" />
<path d="M12 9v3.5" stroke="var(--warn-text)" strokeWidth="1.8" strokeLinecap="round" />
<circle cx="12" cy="15.2" r="1" fill="var(--warn-text)" />
</svg>
);
@@ -96,15 +143,12 @@ const ConversationSider = () => {
if (syncState === "failed" && conversationList.length === 0) {
return (
<div className="flex flex-1 flex-col items-center justify-center">
<EmptyIcon />
<LinearFailIcon />
<div className="mt-4 text-sm">{t("states.loadFailed")}</div>
<div className="mt-1 text-xs text-[var(--sub-text)]">
{t("states.loadFailedTip")}
</div>
<Button
className="mt-4"
onClick={() => getConversationListByReq(false)}
>
<Button className="mt-4" onClick={() => getConversationListByReq(false)}>
{t("retry")}
</Button>
</div>
@@ -113,7 +157,7 @@ const ConversationSider = () => {
if (conversationList.length === 0) {
return (
<div className="flex flex-1 flex-col items-center justify-center">
<EmptyIcon />
<LinearChatIcon />
<div className="mt-4 text-sm">{t("states.noMessage")}</div>
<div className="mt-1 text-xs text-[var(--sub-text)]">
{t("states.noMessageTip")}
@@ -150,12 +194,13 @@ const ConversationSider = () => {
needHidden={Boolean(conversationID)}
wrapClassName="flex flex-col border-r border-[var(--gap-text)] bg-white"
>
{connectState === "failed" && <OfflineBar />}
<OfflineBar visible={connectState === "failed"} />
<div className="app-drag px-4 pt-4">
<div className="pb-3 text-base font-bold">{t("placeholder.chat")}</div>
<div className="flex items-center gap-1.5">
<Input
className="app-no-drag mb-2 rounded-md border-none !bg-[var(--search-bg)]"
className="app-no-drag mb-2 rounded-md border-none bg-[var(--search-bg)]"
style={{ height: 36, backgroundColor: "var(--search-bg)" }}
prefix={<SearchOutlined className="text-[var(--sub-text)]" rev={undefined} />}
placeholder={t("placeholder.search")}
allowClear
@@ -163,7 +208,7 @@ const ConversationSider = () => {
onChange={(e) => setKeyword(e.target.value)}
/>
<div
className="app-no-drag flex h-9 w-9 shrink-0 cursor-pointer items-center justify-center rounded-lg text-[20px] text-[var(--sub-text)] transition-colors duration-100 hover:bg-[var(--primary-active)] hover:text-[var(--primary)]"
className="app-no-drag flex h-9 w-9 shrink-0 cursor-pointer items-center justify-center rounded-lg text-[20px] text-[var(--sub-text)] transition-colors duration-100 ease-out hover:bg-[var(--primary-active)] hover:text-[var(--primary)]"
title={t("placeholder.addColleague")}
onClick={() => setAddColleagueOpen(true)}
>
+15 -12
View File
@@ -1,23 +1,26 @@
import { Layout } from "antd";
import { useTranslation } from "react-i18next";
const LinearChatIcon = () => (
<svg width="64" height="64" viewBox="0 0 24 24" fill="none">
<path
d="M5 6.5h14a1.5 1.5 0 0 1 1.5 1.5v8a1.5 1.5 0 0 1-1.5 1.5h-7.2L7 20.5v-2.5H5A1.5 1.5 0 0 1 3.5 16V8A1.5 1.5 0 0 1 5 6.5Z"
stroke="var(--gap-text)"
strokeWidth="1.6"
strokeLinejoin="round"
/>
</svg>
);
export const EmptyChat = () => {
const { t } = useTranslation();
return (
<Layout className="no-mobile flex items-center justify-center !bg-[var(--chat-bg)]">
<Layout className="no-mobile flex items-center justify-center bg-[var(--chat-bg)]">
<div className="flex flex-col items-center">
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-[var(--primary)]">
<svg width="34" height="34" viewBox="0 0 24 24" fill="none">
<path
d="M12 3C6.98 3 3 6.58 3 11.1c0 2.5 1.28 4.73 3.28 6.24-.1.84-.46 1.87-1.02 2.66-.15.21.02.5.27.44 1.53-.34 2.92-1.03 3.9-1.73.82.18 1.68.29 2.57.29 5.02 0 9-3.58 9-8.1S17.02 3 12 3Z"
fill="#fff"
/>
</svg>
</div>
<div className="mt-4 text-base font-medium">{t("placeholder.title")}</div>
<div className="mt-1 text-xs text-[var(--sub-text)]">
{t("placeholder.subTitle")}
<LinearChatIcon />
<div className="mt-4 text-base font-medium text-[var(--base-black)]">
{t("placeholder.emptyChat")}
</div>
</div>
</Layout>
@@ -33,7 +33,7 @@ const EmojiPanel = ({ onSelect }: { onSelect: (emoji: string) => void }) => (
{EMOJI_LIST.map((emoji) => (
<div
key={emoji}
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded text-xl hover:bg-[var(--primary-active)]"
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg text-xl hover:bg-[var(--primary-active)]"
onClick={() => onSelect(emoji)}
>
{emoji}
@@ -42,7 +42,7 @@ const EmojiPanel = ({ onSelect }: { onSelect: (emoji: string) => void }) => (
</div>
);
const ChatFooter: ForwardRefRenderFunction<unknown, unknown> = (_, ref) => {
const ChatFooter: ForwardRefRenderFunction<unknown, unknown> = (_, _ref) => {
const [text, setText] = useState("");
const [sending, setSending] = useState(false);
const [emojiVisible, setEmojiVisible] = useState(false);
@@ -141,7 +141,7 @@ const ChatFooter: ForwardRefRenderFunction<unknown, unknown> = (_, ref) => {
};
const toolIconClass =
"cursor-pointer text-xl text-[var(--sub-text)] hover:text-[#515E70]";
"flex h-7 w-7 cursor-pointer items-center justify-center rounded-lg text-[20px] text-[var(--sub-text)] transition-colors duration-100 ease-out hover:bg-[var(--primary-active)] hover:text-[var(--primary)]";
return (
<footer className="relative bg-white">
@@ -203,14 +203,15 @@ const ChatFooter: ForwardRefRenderFunction<unknown, unknown> = (_, ref) => {
<div className="px-4 pt-2">
<Input.TextArea
ref={textareaRef}
className="!resize-none !border-none !shadow-none"
autoSize={{ minRows: 4, maxRows: 8 }}
autoSize={{ minRows: 3, maxRows: 6 }}
placeholder={t("placeholder.sendToName", {
name: currentConversation?.showName ?? "",
})}
value={text}
onChange={(e) => setText(e.target.value)}
onKeyDown={onKeyDown}
bordered={false}
style={{ resize: "none", boxShadow: "none" }}
/>
</div>
@@ -220,11 +221,11 @@ const ChatFooter: ForwardRefRenderFunction<unknown, unknown> = (_, ref) => {
{t("placeholder.enterToSendTip")}
</span>
<Button
className="w-fit px-6"
type="primary"
disabled={!text.trim()}
loading={sending}
onClick={sendText}
style={{ height: 32, borderRadius: 8, paddingInline: 24 }}
>
{t("placeholder.send")}
</Button>
@@ -3,11 +3,10 @@ import { SessionType } from "@openim/wasm-client-sdk";
import { Dropdown, Layout } from "antd";
import { t } from "i18next";
import { memo, useEffect, useRef } from "react";
import { v4 as uuidV4 } from "uuid";
import { OverlayVisibleHandle } from "@/hooks/useOverlayVisible";
import { useConversationStore, useUserStore } from "@/store";
import emitter, { emit } from "@/utils/events";
import { useConversationStore } from "@/store";
import { emit } from "@/utils/events";
import GroupSetting from "../GroupSetting";
import SingleSetting from "../SingleSetting";
@@ -31,42 +30,12 @@ const ChatHeader = () => {
const isSingleSession = currentConversation?.conversationType === SessionType.Single;
const startVoiceCall = () => {
if (!currentConversation?.userID) return;
emitter.emit("OPEN_RTC_MODAL", {
invitation: {
inviterUserID: useUserStore.getState().selfInfo.userID,
inviteeUserIDList: [currentConversation.userID],
groupID: "",
roomID: uuidV4(),
timeout: 60,
mediaType: "audio",
sessionType: SessionType.Single,
platformID: window.electronAPI?.getPlatform() ?? 5,
},
participant: {
userInfo: {
nickname: currentConversation.showName,
userID: currentConversation.userID,
faceURL: currentConversation.faceURL,
ex: "",
},
},
});
};
const menuItems = isSingleSession
? [
{ key: "voiceCall", label: t("placeholder.voiceCall") },
{ key: "viewProfile", label: t("placeholder.viewProfile") },
]
? [{ key: "viewProfile", label: t("placeholder.viewProfile") }]
: [{ key: "groupSetting", label: t("placeholder.groupSetting") }];
const onMenuClick = ({ key }: { key: string }) => {
switch (key) {
case "voiceCall":
startVoiceCall();
break;
case "viewProfile":
emit("OPEN_USER_CARD", {
userID: currentConversation?.userID,
@@ -82,7 +51,10 @@ const ChatHeader = () => {
};
return (
<Layout.Header className="app-drag relative border-b border-b-[var(--gap-text)] !bg-white !px-4">
<Layout.Header
className="app-drag relative border-b border-b-[var(--gap-text)] bg-white px-4"
style={{ height: 56, lineHeight: "56px", paddingInline: 16 }}
>
<div className="flex h-full items-center leading-none">
<div className="flex flex-1 items-center overflow-hidden">
<div className="truncate text-base font-bold">
@@ -95,11 +67,10 @@ const ChatHeader = () => {
placement="bottomRight"
>
<MoreOutlined
className="app-no-drag cursor-pointer p-1 text-lg text-[#515E70] hover:text-[var(--primary)]"
className="app-no-drag cursor-pointer p-1 text-[20px] text-[var(--sub-text)] hover:text-[var(--primary)]"
rev={undefined}
/>
</Dropdown>
{/* 给悬浮窗口控制条留出空间 */}
{Boolean(window.electronAPI) && <div className="w-28 shrink-0" />}
</div>
<SingleSetting ref={singleSettingRef} />
@@ -71,7 +71,7 @@ const FileMessageRender: FC<IMessageItemProps> = ({ message }) => {
return (
<div
className="flex w-60 cursor-pointer items-center rounded-lg bg-white p-3"
className="flex h-16 w-[280px] cursor-pointer items-center rounded-lg bg-white px-3"
onClick={saveFile}
>
<FileIcon />
@@ -10,9 +10,9 @@ const MediaMessageRender: FC<IMessageItemProps> = ({ message }) => {
const imageHeight = message.pictureElem!.sourcePicture.height;
const imageWidth = message.pictureElem!.sourcePicture.width;
const snapshotMaxHeight = message.pictureElem!.snapshotPicture?.height ?? imageHeight;
const minHeight = min(200, imageWidth) * (imageHeight / imageWidth) + 2;
const minHeight = min(240, imageWidth) * (imageHeight / imageWidth) + 2;
const adaptedHight = min(minHeight, snapshotMaxHeight) + 10;
const adaptedWidth = min(imageWidth, 200) + 10;
const adaptedWidth = min(imageWidth, 240) + 10;
const sourceUrl =
message.pictureElem!.snapshotPicture?.url || message.pictureElem!.sourcePicture.url;
@@ -21,10 +21,10 @@ const MediaMessageRender: FC<IMessageItemProps> = ({ message }) => {
return (
<Spin spinning={isSending}>
<div className="relative max-w-[200px]" style={minStyle}>
<div className="relative max-w-[240px]" style={minStyle}>
<Image
rootClassName="message-image cursor-pointer"
className="max-w-[200px] rounded-md"
className="max-w-[240px] rounded-md"
src={sourceUrl}
preview
placeholder={
@@ -9,7 +9,8 @@
}
.bubble {
@apply w-fit rounded-xl p-2.5;
@apply w-fit rounded-lg p-2.5;
max-width: 60%;
word-break: break-word;
background-color: var(--chat-bubble);
}
+35 -21
View File
@@ -2,6 +2,7 @@ import { IMSDK } from "@/layout/MainContentWrap";
import { useContactStore } from "@/store";
import { feedbackToast } from "@/utils/common";
import { Input, Modal } from "antd";
import { t } from "i18next";
import { useEffect, useState } from "react";
interface IAddColleagueProps {
@@ -9,21 +10,16 @@ interface IAddColleagueProps {
onClose: () => void;
}
/**
* 添加同事弹窗(视觉规范 v1 §4.2):
* 工号输入 + 可编辑验证消息 + 发送按钮,五态:默认/校验失败/发送中/成功/失败。
* 成功后刷新申请数,2s 后关闭并提示「申请已发送,等对方通过」。
*/
const AddColleague = ({ open, onClose }: IAddColleagueProps) => {
const [staffNo, setStaffNo] = useState("");
const [verifyMsg, setVerifyMsg] = useState("你好,我想加你为同事");
const [verifyMsg, setVerifyMsg] = useState(t("placeholder.addColleagueDefaultMsg"));
const [error, setError] = useState("");
const [sending, setSending] = useState(false);
useEffect(() => {
if (open) {
setStaffNo("");
setVerifyMsg("你好,我想加你为同事");
setVerifyMsg(t("placeholder.addColleagueDefaultMsg"));
setError("");
setSending(false);
}
@@ -32,21 +28,21 @@ const AddColleague = ({ open, onClose }: IAddColleagueProps) => {
const send = async () => {
const id = staffNo.trim();
if (!id) {
setError("请输入对方工号");
setError(t("toast.inputPeerStaffNo"));
return;
}
setSending(true);
try {
await IMSDK.addFriend({
toUserID: id,
reqMsg: verifyMsg.trim() || "你好,我想加你为同事",
reqMsg: verifyMsg.trim() || t("placeholder.addColleagueDefaultMsg"),
});
useContactStore.getState().getRecvFriendApplicationListByReq();
feedbackToast({ msg: "申请已发送,等对方通过" });
setTimeout(onClose, 500);
feedbackToast({ msg: t("toast.addColleagueSent"), duration: 2 });
window.setTimeout(onClose, 500);
} catch (error) {
setError("发送失败,请确认工号正确、网络正常");
feedbackToast({ error, msg: "发送失败,请确认工号正确、网络正常" });
setError(t("toast.addColleagueFailed"));
feedbackToast({ error, msg: t("toast.addColleagueFailed") });
} finally {
setSending(false);
}
@@ -54,7 +50,7 @@ const AddColleague = ({ open, onClose }: IAddColleagueProps) => {
return (
<Modal
title="添加同事"
title={t("placeholder.addColleague")}
open={open}
onCancel={sending ? undefined : onClose}
footer={null}
@@ -62,37 +58,55 @@ const AddColleague = ({ open, onClose }: IAddColleagueProps) => {
centered
maskClosable={!sending}
destroyOnClose
styles={{
content: { borderRadius: 12 },
}}
>
<div className="pt-2">
<div className="mb-1.5 text-sm text-[var(--base-black)]"></div>
<div className="mb-1.5 text-sm text-[var(--base-black)]">
{t("placeholder.colleagueStaffNo")}
</div>
<Input
value={staffNo}
onChange={(e) => {
setStaffNo(e.target.value);
if (error) setError("");
}}
placeholder="请输入对方工号"
placeholder={t("toast.inputPeerStaffNo")}
status={error ? "error" : undefined}
style={{ height: 36, borderRadius: 8, backgroundColor: "var(--search-bg)" }}
disabled={sending}
onPressEnter={send}
/>
{error && <div className="mt-1 text-xs text-[var(--warn-text)]">{error}</div>}
<div className="mb-1.5 mt-4 text-sm text-[var(--base-black)]"></div>
{error ? (
<div className="mt-1 text-xs text-[var(--warn-text)]">{error}</div>
) : null}
<div className="mb-1.5 mt-4 text-sm text-[var(--base-black)]">
{t("placeholder.verifyMessageOptional")}
</div>
<Input.TextArea
value={verifyMsg}
onChange={(e) => setVerifyMsg(e.target.value)}
placeholder="你好,我想加你为同事"
placeholder={t("placeholder.addColleagueDefaultMsg")}
autoSize={{ minRows: 2, maxRows: 4 }}
disabled={sending}
style={{ borderRadius: 8, backgroundColor: "var(--search-bg)" }}
style={{
height: 72,
minHeight: 72,
borderRadius: 8,
backgroundColor: "var(--search-bg)",
}}
/>
<button
className="mt-5 flex h-9 w-full cursor-pointer items-center justify-center rounded-lg border-none bg-[var(--primary)] text-white disabled:cursor-not-allowed disabled:opacity-60"
disabled={sending}
onClick={send}
>
{sending ? <span className="h-4 w-4 animate-spin rounded-full border-2 border-white/40 border-t-white" /> : "发送申请"}
{sending ? (
<span className="h-4 w-4 animate-spin rounded-full border-2 border-white/40 border-t-white" />
) : (
t("placeholder.sendApplication")
)}
</button>
</div>
</Modal>
@@ -5,10 +5,23 @@ import OIMAvatar from "@/components/OIMAvatar";
const FriendListItem = ({
friend,
showUserCard,
title,
}: {
friend: FriendUserItem;
showUserCard: (userID: string) => void;
title?: string;
}) => {
const job =
title ||
(() => {
try {
const extra = friend.ex ? JSON.parse(friend.ex) : {};
return extra.title || extra.jobTitle || "";
} catch {
return "";
}
})();
return (
<div
className="flex h-14 cursor-pointer items-center rounded-lg px-3.5 transition-colors duration-100 hover:bg-[var(--chat-bg)]"
@@ -19,6 +32,9 @@ const FriendListItem = ({
<div className="truncate text-base font-medium text-[var(--base-black)]">
{friend.remark || friend.nickname}
</div>
{job ? (
<div className="truncate text-sm text-[var(--sub-text)]">{job}</div>
) : null}
</div>
</div>
);
+76 -65
View File
@@ -1,47 +1,85 @@
import { useRequest } from "ahooks";
import { FriendUserItem } from "@openim/wasm-client-sdk/lib/types/entity";
import axios from "axios";
import { Empty, Spin } from "antd";
import { useCallback, useEffect, useRef } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { GroupedVirtuoso, GroupedVirtuosoHandle } from "react-virtuoso";
import { GroupedVirtuoso } from "react-virtuoso";
import { useContactStore } from "@/store";
import { formatContactsByWorker } from "@/utils/contactsFormat";
import { emit } from "@/utils/events";
import { getIMToken } from "@/utils/storage";
import AlphabetIndex from "./AlphabetIndex";
import FriendListItem from "./FriendListItem";
type DirInfo = { title: string; department: string };
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],
},
);
const [dirMap, setDirMap] = useState<Record<string, DirInfo>>({});
const [dirReady, setDirReady] = useState(false);
useEffect(() => {
let cancelled = false;
const loadTitles = async () => {
try {
const token = await getIMToken();
if (!token) {
setDirReady(true);
return;
}
const { data } = await axios.get(
`${import.meta.env.VITE_ACCOUNT_URL}/api/directory`,
{
params: { keyword: "", limit: 100 },
headers: { Authorization: `Bearer ${token}` },
},
);
if (cancelled) return;
if (data?.code === 0) {
const map: Record<string, DirInfo> = {};
for (const item of data.data?.items ?? []) {
if (item.userID) {
map[item.userID] = {
title: item.title || "",
department: item.department || "",
};
}
}
setDirMap(map);
}
} catch {
// 目录不可用时职务行留空、部门归入未分组
} finally {
if (!cancelled) setDirReady(true);
}
};
void loadTitles();
return () => {
cancel();
cancelled = true;
};
}, []);
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 sectionData = useMemo(() => {
const ungrouped = t("placeholder.ungrouped");
const map = new Map<string, FriendUserItem[]>();
for (const friend of friendList) {
const dept = dirMap[friend.userID]?.department?.trim() || ungrouped;
const list = map.get(dept) ?? [];
list.push(friend);
map.set(dept, list);
}
const names = [...map.keys()].sort((a, b) => {
if (a === ungrouped) return 1;
if (b === ungrouped) return -1;
return a.localeCompare(b, "zh");
});
return {
groupCounts: names.map((name) => map.get(name)!.length),
indexList: names,
totalList: names.flatMap((name) => map.get(name)!),
};
}, [friendList, dirMap, t]);
const showUserCard = useCallback((userID: string) => {
emit("OPEN_USER_CARD", {
@@ -49,63 +87,36 @@ export const MyFriends = () => {
});
}, []);
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 ? (
{!dirReady && friendList.length === 0 ? (
<Spin />
) : !sectionData.groupCounts.length ? (
) : !friendList.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-[var(--sub-text)]">
{sectionData.indexList[index]}
</div>
<div className="mx-3.5 mb-3 h-px w-full bg-[var(--gap-text)] bg-white" />
<div
className="flex items-center bg-[var(--chat-bg)] px-3.5 text-xs text-[var(--sub-text)]"
style={{ height: 28 }}
>
{sectionData.indexList[index]}
</div>
)}
itemContent={(index) => {
const friend = sectionData.totalList[index];
return (
<FriendListItem
key={sectionData.totalList[index].userID}
friend={sectionData.totalList[index]}
key={friend.userID}
friend={friend}
title={dirMap[friend.userID]?.title}
showUserCard={showUserCard}
/>
);
}}
rangeChanged={({ startIndex }) => determineCurrentGroup(startIndex)}
className="no-scrollbar h-full overflow-x-hidden"
/>
</div>
+38 -16
View File
@@ -4,7 +4,6 @@ import { t } from "i18next";
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { feedbackToast } from "@/utils/common";
import { setIMProfile } from "@/utils/storage";
interface LoginResult {
@@ -18,7 +17,11 @@ interface LoginResult {
};
}
const LoginForm = () => {
type LoginFormProps = {
onBanner: (text: string) => void;
};
const LoginForm = ({ onBanner }: LoginFormProps) => {
const navigate = useNavigate();
const [loading, setLoading] = useState(false);
@@ -34,17 +37,14 @@ const LoginForm = () => {
},
);
if (res.code !== 0 || !res.data) {
feedbackToast({
msg: res.msg || t("toast.accessFailed"),
error: res.msg || "login failed",
});
onBanner(t("toast.staffOrPasswordWrong"));
return;
}
const { userID, imToken } = res.data;
setIMProfile({ chatToken: imToken, imToken, userID });
navigate("/chat");
} catch (error) {
feedbackToast({ msg: "登录失败,请检查网络后重试", error });
} catch {
onBanner(t("toast.networkRetry"));
} finally {
setLoading(false);
}
@@ -52,22 +52,30 @@ const LoginForm = () => {
return (
<>
<div className="flex flex-row items-center justify-between">
<div className="text-xl font-medium">{t("placeholder.welcome")}</div>
<div className="text-center text-xl font-bold text-[var(--base-black)]">
{t("placeholder.title")}
</div>
<div className="mt-1 text-center text-sm text-[var(--sub-text)]">
{t("placeholder.internalComms")}
</div>
<Form
className="mt-8"
layout="vertical"
onFinish={onFinish}
autoComplete="off"
labelCol={{ prefixCls: "custom-form-item" }}
validateTrigger={["onSubmit"]}
>
<Form.Item
label={t("placeholder.staffNo")}
name="staffNo"
rules={[{ required: true, message: t("toast.inputStaffNo") }]}
>
<Input allowClear placeholder={t("toast.inputStaffNo")} />
<Input
allowClear
placeholder={t("toast.inputStaffNo")}
style={{ height: 40, borderRadius: 8, backgroundColor: "var(--search-bg)" }}
disabled={loading}
/>
</Form.Item>
<Form.Item
@@ -75,14 +83,28 @@ const LoginForm = () => {
name="password"
rules={[{ required: true, message: t("toast.inputPassword") }]}
>
<Input.Password allowClear placeholder={t("toast.inputPassword")} />
<Input.Password
allowClear
placeholder={t("toast.inputPassword")}
style={{ height: 40, borderRadius: 8, backgroundColor: "var(--search-bg)" }}
disabled={loading}
/>
</Form.Item>
<Form.Item className="mb-4 mt-10">
<Button type="primary" htmlType="submit" block loading={loading}>
{t("placeholder.login")}
<Form.Item className="mb-2 mt-8">
<Button
type="primary"
htmlType="submit"
block
loading={loading}
style={{ height: 40, borderRadius: 8 }}
>
{loading ? t("placeholder.loggingIn") : t("placeholder.login")}
</Button>
</Form.Item>
<div className="text-center text-xs text-[var(--sub-text)]">
{t("placeholder.agreeInternal")}
</div>
</Form>
</>
);
@@ -3,6 +3,55 @@
.ant-form-item-label {
padding: 0;
}
.ant-form-item-explain-error {
color: var(--warn-text);
font-size: 12px;
}
.ant-input-status-error,
.ant-input-affix-wrapper-status-error {
border-color: var(--warn-text) !important;
}
}
}
.login-toast {
height: 36px;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
font-size: 14px;
animation: login-toast-in 150ms ease-out;
}
.login-toast[data-phase="out"] {
animation: login-toast-out 200ms ease-out forwards;
}
@keyframes login-toast-in {
from {
opacity: 0;
transform: translateY(-8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes login-toast-out {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.login-toast,
.login-toast[data-phase="out"] {
animation: none;
}
}
+27 -13
View File
@@ -1,7 +1,7 @@
import { t } from "i18next";
import { useCallback, useEffect, useState } from "react";
import { useCopyToClipboard } from "react-use";
import login_bg from "@/assets/images/login/login_bg.png";
import WindowControlBar from "@/components/WindowControlBar";
import { APP_NAME, APP_VERSION, SDK_VERSION } from "@/config";
import { feedbackToast } from "@/utils/common";
@@ -11,6 +11,8 @@ import LoginForm from "./LoginForm";
export const Login = () => {
const [_, copyToClipboard] = useCopyToClipboard();
const [banner, setBanner] = useState<string | null>(null);
const clearBanner = useCallback(() => setBanner(null), []);
const handleCopy = () => {
copyToClipboard(`${`${APP_NAME} ${APP_VERSION}`}/${SDK_VERSION}`);
@@ -19,16 +21,16 @@ export const Login = () => {
return (
<div className="relative flex h-full flex-col">
<div className="app-drag relative h-10 bg-[var(--top-search-bar)]">
<div className="app-drag relative h-10 bg-[var(--primary)]">
<WindowControlBar />
</div>
{banner ? <LoginToastBanner text={banner} onDone={clearBanner} /> : null}
<div className="flex flex-1 items-center justify-center">
<LeftBar />
<div
className={`${styles.login} mr-14 h-[450px] w-[350px] rounded-md bg-white p-11`}
style={{ boxShadow: "0 0 30px rgba(0,0,0,.1)" }}
className={`${styles.login} h-[450px] w-[350px] rounded-xl bg-white p-11`}
style={{ boxShadow: "0 4px 16px rgba(0,0,0,0.08)" }}
>
<LoginForm />
<LoginForm onBanner={setBanner} />
</div>
</div>
<div
@@ -42,14 +44,26 @@ export const Login = () => {
);
};
const LeftBar = () => {
const LoginToastBanner = ({ text, onDone }: { text: string; onDone: () => void }) => {
const [phase, setPhase] = useState<"in" | "out">("in");
useEffect(() => {
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const stay = window.setTimeout(() => setPhase("out"), reduce ? 2000 : 2150);
const done = window.setTimeout(onDone, reduce ? 2000 : 2350);
return () => {
window.clearTimeout(stay);
window.clearTimeout(done);
};
}, [onDone]);
return (
<div className="flex min-h-[420]">
<div className="mr-14 text-center">
<div className="text-2xl">{t("placeholder.title")}</div>
<span className="text-sm text-gray-400">{t("placeholder.subTitle")}</span>
<img src={login_bg} alt="login_bg" />
</div>
<div
className={styles["login-toast"]}
data-phase={phase}
style={{ backgroundColor: "var(--warn-text)" }}
>
{text}
</div>
);
};
+10 -2
View File
@@ -17,8 +17,6 @@
--mask-bg: #000000;
--success: #00b42a;
--warning: #ff7d00;
--top-search-bar: #3b87f5;
--moment-text: #6085b1;
--searchbar-height: 2.5rem;
--call-wait-bg: #f2f8ff;
--radius-control: 8px;
@@ -165,6 +163,16 @@ img {
-webkit-user-drag: none;
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
.custom-form-item > label {
color: var(--sub-text) !important;
margin-bottom: 4px !important;