feat(pc-client): 第二批登录、会话、聊天与添加同事界面按规范收口
Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
co-authored by
Cursor
multica-agent
parent
d1657ea29f
commit
a44537da4e
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)}
|
||||
>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user