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
@@ -1,36 +1,37 @@
|
|||||||
# PC 端可改边界封存(docs/acceptance)
|
# PC 端可改边界封存(docs/acceptance)
|
||||||
|
|
||||||
> 只读核对,本卡不改 PC 界面。依据架构方案 B-90 的 PC 目录边界,对当前 PC 工程做只读核对,封存后续允许改动目录。
|
> 依据架构方案 B-90 的 PC 目录边界。B-185 第二批只改界面层,通信核心仍冻结。
|
||||||
|
|
||||||
## 核对结论(2026-08-19,只读)
|
## 不可动(通信核心,冻结)
|
||||||
|
|
||||||
PC 工程 `pc-client/` 当前核心边界确认如下:
|
|
||||||
|
|
||||||
### 不可动(通信核心,冻结)
|
|
||||||
|
|
||||||
| 目录/文件 | 内容 | 理由 |
|
| 目录/文件 | 内容 | 理由 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `pc-client/electron/` | 主进程(`main/`)、预加载(`preload/`)、SDK 资产与打包核心 | 进程/打包核心,改动即破坏客户端稳定性 |
|
| `pc-client/electron/` | 主进程、预加载、SDK 资产与打包核心 | 进程/打包核心。特批仅 `windowManage.ts` 的 `minWidth=1080`、`minHeight=680` |
|
||||||
| `pc-client/src/store/` | 会话(`conversation.ts`)、联系人(`contact.ts`)、用户(`user.ts`)状态 | 会话/联系人/用户状态唯一来源 |
|
| `pc-client/src/store/` | 会话 / 联系人 / 用户状态 | 状态唯一来源 |
|
||||||
| `pc-client/src/layout/MainContentWrap.tsx` | SDK 初始化与连接(`@openim/electron-client-sdk` 实例化、`IMSDK`、登录检查) | SDK 初始化与连接入口 |
|
| `pc-client/src/layout/MainContentWrap.tsx` | SDK 初始化与连接 | SDK 入口 |
|
||||||
| `pc-client/src/pages/chat/queryChat/ChatFooter/useSendMessage.ts` | 消息发送(`IMSDK.sendMessage`) | 消息发送核心逻辑 |
|
| `pc-client/src/pages/chat/queryChat/ChatFooter/useSendMessage.ts` | 消息发送 | 发送核心 |
|
||||||
| `pc-client/src/pages/chat/queryChat/useHistoryMessageList.tsx` | 历史消息(`IMSDK.getAdvancedHistoryMessageList`) | 历史消息核心逻辑 |
|
| `pc-client/src/pages/chat/queryChat/useHistoryMessageList.tsx` | 历史消息 | 历史核心 |
|
||||||
| SDK 版本 | `@openim/electron-client-sdk ^3.8.3-patch.10`、`@openim/wasm-client-sdk ^3.8.3-patch.10`(`package.json`) | 锁定版本,不做无收益升级 |
|
| SDK 版本 | `@openim/electron-client-sdk ^3.8.3-patch.10`、`@openim/wasm-client-sdk ^3.8.3-patch.10` | 锁定版本 |
|
||||||
|
|
||||||
### 可改(界面层,后续重整范围)
|
## 可改(界面层)
|
||||||
|
|
||||||
| 目录 | 内容 |
|
| 目录 | 内容 |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `pc-client/src/layout/LeftNavBar/` | 导航外观 |
|
| `pc-client/src/layout/LeftNavBar/` | 导航外观 |
|
||||||
| `pc-client/src/pages/contact/**` | 联系人展示与入口 |
|
| `pc-client/src/pages/login/**` | 登录窗展示(校验三态、窗内顶栏提示;不改登录接口) |
|
||||||
|
| `pc-client/src/pages/chat/**` | 会话栏、空会话页、聊天窗**展示**(顶栏/气泡/输入区样式、右键置顶删除、大图预览样式) |
|
||||||
|
| `pc-client/src/pages/contact/**` | 联系人展示、职务行、部门分组 |
|
||||||
|
| `pc-client/src/pages/common/AddColleague.tsx` | 添加同事弹窗五态与文案 |
|
||||||
| `pc-client/src/components/**` | 通用 UI 组件 |
|
| `pc-client/src/components/**` | 通用 UI 组件 |
|
||||||
| `pc-client/src/styles/` | 统一视觉 Token 唯一来源(依据视觉规范 v2) |
|
| `pc-client/src/styles/` | 统一视觉 Token 唯一来源 |
|
||||||
|
| `pc-client/src/i18n/resources/` | 界面文案 |
|
||||||
|
|
||||||
### 明确禁止
|
聊天目录中下列文件仍冻结,即使位于 `pages/chat/**`:
|
||||||
|
|
||||||
- 不建设 PC 语音通话:不提供发起/接听/来电弹窗;对手机发来的通话自定义消息静默过滤,不显示原始 JSON。
|
- `queryChat/ChatFooter/useSendMessage.ts`
|
||||||
- 不改 `electron/`、`src/store/`、SDK 初始化、消息发送与历史逻辑。
|
- `queryChat/useHistoryMessageList.tsx`
|
||||||
|
|
||||||
## 回退与复核
|
## 明确禁止
|
||||||
|
|
||||||
- 本卡未改动任何 PC 文件(只读核对);上述边界已记录,作为后续 PC 界面重整(阶段 4)的唯一改动态范围。
|
- 不建设 PC 语音通话:不提供发起/接听/来电入口;本批已从聊天顶栏去掉「语音通话」。RTC 死代码按 B-90 保留以便回滚。
|
||||||
|
- 不改 `electron/`(窗口下限两常量除外)、`src/store/`、SDK 初始化、消息发送与历史逻辑。
|
||||||
|
|||||||
@@ -50,12 +50,12 @@ const ApplicationItem = ({
|
|||||||
|
|
||||||
const getStatusStr = () => {
|
const getStatusStr = () => {
|
||||||
if (source.handleResult === ApplicationHandleResult.Agree) {
|
if (source.handleResult === ApplicationHandleResult.Agree) {
|
||||||
return "已添加";
|
return t("application.agreed");
|
||||||
}
|
}
|
||||||
if (source.handleResult === ApplicationHandleResult.Reject) {
|
if (source.handleResult === ApplicationHandleResult.Reject) {
|
||||||
return "已拒绝";
|
return t("application.refused");
|
||||||
}
|
}
|
||||||
return isRecv ? "待处理" : "等待对方通过";
|
return isRecv ? t("application.waitingHandle") : t("application.pending");
|
||||||
};
|
};
|
||||||
|
|
||||||
const getAvatarUrl = () => {
|
const getAvatarUrl = () => {
|
||||||
@@ -80,9 +80,15 @@ const ApplicationItem = ({
|
|||||||
window.userClick(isRecv ? source.fromUserID : source.toUserID);
|
window.userClick(isRecv ? source.fromUserID : source.toUserID);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const btnStyle = {
|
||||||
|
height: 32,
|
||||||
|
flex: 1,
|
||||||
|
borderRadius: 8,
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Spin spinning={loading}>
|
<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">
|
<div className="mr-3 flex min-w-0 flex-1 flex-row">
|
||||||
<OIMAvatar
|
<OIMAvatar
|
||||||
size={48}
|
size={48}
|
||||||
@@ -109,20 +115,16 @@ const ApplicationItem = ({
|
|||||||
|
|
||||||
{showActionBtn && (
|
{showActionBtn && (
|
||||||
<div className="flex w-[132px] shrink-0 flex-row gap-2">
|
<div className="flex w-[132px] shrink-0 flex-row gap-2">
|
||||||
<Button
|
<Button size="small" onClick={() => loadingWrap(false)} style={btnStyle}>
|
||||||
size="small"
|
{t("application.refuse")}
|
||||||
onClick={() => loadingWrap(false)}
|
|
||||||
className="!h-8 !flex-1 !rounded-lg"
|
|
||||||
>
|
|
||||||
拒绝
|
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
size="small"
|
size="small"
|
||||||
type="primary"
|
type="primary"
|
||||||
className="!h-8 !flex-1 !rounded-lg !bg-[var(--primary)]"
|
style={{ ...btnStyle, backgroundColor: "var(--primary)" }}
|
||||||
onClick={() => loadingWrap(true)}
|
onClick={() => loadingWrap(true)}
|
||||||
>
|
>
|
||||||
接受
|
{t("application.agree")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -113,7 +113,12 @@
|
|||||||
"publishFailed": "Failed to publish!",
|
"publishFailed": "Failed to publish!",
|
||||||
"notCanAddFriend": "The other party has been set up not to be added as a friend!",
|
"notCanAddFriend": "The other party has been set up not to be added as a friend!",
|
||||||
"fileTypeError": "Unsupported file type or folder!",
|
"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",
|
"noMicrophone": "No microphone detected, cannot record",
|
||||||
"recordTooShort": "Recording is too short"
|
"recordTooShort": "Recording is too short"
|
||||||
},
|
},
|
||||||
@@ -188,6 +193,15 @@
|
|||||||
"qrCodeLoginTitle": "Please scan the QR code using OpenCorp mobile",
|
"qrCodeLoginTitle": "Please scan the QR code using OpenCorp mobile",
|
||||||
"welcome": "欢迎使用畅联",
|
"welcome": "欢迎使用畅联",
|
||||||
"title": "畅联",
|
"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": "公司内部通讯工具",
|
"subTitle": "公司内部通讯工具",
|
||||||
"myCreated": "I created",
|
"myCreated": "I created",
|
||||||
"myJoined": "I joined",
|
"myJoined": "I joined",
|
||||||
@@ -468,6 +482,7 @@
|
|||||||
"refused": "Refused",
|
"refused": "Refused",
|
||||||
"information": "Verification Information",
|
"information": "Verification Information",
|
||||||
"pending": "Pending Verification",
|
"pending": "Pending Verification",
|
||||||
|
"waitingHandle": "Pending",
|
||||||
"applyToJoin": "Apply to Join",
|
"applyToJoin": "Apply to Join",
|
||||||
"applyToAdd": "Apply to Add",
|
"applyToAdd": "Apply to Add",
|
||||||
"applyToFriend": "Applied to Add You as a Friend"
|
"applyToFriend": "Applied to Add You as a Friend"
|
||||||
|
|||||||
@@ -112,6 +112,11 @@
|
|||||||
"inputVerifyCode": "请输入您的验证码",
|
"inputVerifyCode": "请输入您的验证码",
|
||||||
"inputPassword": "请输入您的密码",
|
"inputPassword": "请输入您的密码",
|
||||||
"inputStaffNo": "请输入工号",
|
"inputStaffNo": "请输入工号",
|
||||||
|
"staffOrPasswordWrong": "工号或密码不正确",
|
||||||
|
"networkRetry": "网络异常,请稍后重试",
|
||||||
|
"inputPeerStaffNo": "请输入对方工号",
|
||||||
|
"addColleagueSent": "申请已发送,等对方通过",
|
||||||
|
"addColleagueFailed": "发送失败,请确认工号正确、网络正常",
|
||||||
"sendFreiendRequestSuccess": "发送好友请求成功!",
|
"sendFreiendRequestSuccess": "发送好友请求成功!",
|
||||||
"registerSuccess": "注册成功!",
|
"registerSuccess": "注册成功!",
|
||||||
"inputInvitationCode": "请输入您的邀请码",
|
"inputInvitationCode": "请输入您的邀请码",
|
||||||
@@ -200,6 +205,15 @@
|
|||||||
"qrCodeLoginTitle": "请使用OpenCorp移动端扫描二维码",
|
"qrCodeLoginTitle": "请使用OpenCorp移动端扫描二维码",
|
||||||
"welcome": "欢迎使用畅联",
|
"welcome": "欢迎使用畅联",
|
||||||
"title": "畅联",
|
"title": "畅联",
|
||||||
|
"internalComms": "企业内部通讯",
|
||||||
|
"loggingIn": "登录中",
|
||||||
|
"agreeInternal": "登录即代表同意内部使用规范",
|
||||||
|
"emptyChat": "从左边选一个会话,开始聊天",
|
||||||
|
"ungrouped": "未分组",
|
||||||
|
"colleagueStaffNo": "同事工号",
|
||||||
|
"verifyMessageOptional": "验证消息(可不填)",
|
||||||
|
"addColleagueDefaultMsg": "你好,我想加你为同事",
|
||||||
|
"sendApplication": "发送申请",
|
||||||
"subTitle": "公司内部通讯工具",
|
"subTitle": "公司内部通讯工具",
|
||||||
"myCreated": "我创建的",
|
"myCreated": "我创建的",
|
||||||
"myJoined": "我加入的",
|
"myJoined": "我加入的",
|
||||||
@@ -480,6 +494,7 @@
|
|||||||
"refused": "已拒绝",
|
"refused": "已拒绝",
|
||||||
"information": "验证信息",
|
"information": "验证信息",
|
||||||
"pending": "等待对方通过",
|
"pending": "等待对方通过",
|
||||||
|
"waitingHandle": "待处理",
|
||||||
"applyToJoin": "申请加入",
|
"applyToJoin": "申请加入",
|
||||||
"applyToAdd": "申请添加",
|
"applyToAdd": "申请添加",
|
||||||
"applyToFriend": "申请添加您为好友"
|
"applyToFriend": "申请添加您为好友"
|
||||||
|
|||||||
@@ -2,13 +2,15 @@ import type {
|
|||||||
ConversationItem as ConversationItemType,
|
ConversationItem as ConversationItemType,
|
||||||
MessageItem,
|
MessageItem,
|
||||||
} from "@openim/wasm-client-sdk/lib/types/entity";
|
} from "@openim/wasm-client-sdk/lib/types/entity";
|
||||||
import { Badge } from "antd";
|
import { Badge, Dropdown } from "antd";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { t } from "i18next";
|
import { t } from "i18next";
|
||||||
import { memo, useMemo } from "react";
|
import { memo, useMemo } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import OIMAvatar from "@/components/OIMAvatar";
|
import OIMAvatar from "@/components/OIMAvatar";
|
||||||
|
import { IMSDK } from "@/layout/MainContentWrap";
|
||||||
import { useConversationStore, useUserStore } from "@/store";
|
import { useConversationStore, useUserStore } from "@/store";
|
||||||
|
import { feedbackToast } from "@/utils/common";
|
||||||
import { formatConversionTime, getConversationContent } from "@/utils/imCommon";
|
import { formatConversionTime, getConversationContent } from "@/utils/imCommon";
|
||||||
|
|
||||||
import styles from "./conversation-item.module.scss";
|
import styles from "./conversation-item.module.scss";
|
||||||
@@ -50,47 +52,90 @@ const ConversationItem = ({ isActive, conversation }: IConversationProps) => {
|
|||||||
|
|
||||||
const latestMessageTime = formatConversionTime(conversation.latestMsgSendTime);
|
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 (
|
return (
|
||||||
|
<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,
|
||||||
|
}}
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
className={clsx(
|
className={clsx(
|
||||||
styles["conversation-item"],
|
styles["conversation-item"],
|
||||||
"border border-transparent",
|
"h-16 border border-transparent transition-colors duration-100",
|
||||||
isActive ? `bg-[var(--primary-active)]` : "hover:bg-[var(--chat-bg)]",
|
isActive
|
||||||
|
? "bg-[var(--primary-active)]"
|
||||||
|
: "hover:bg-[var(--chat-bg)]",
|
||||||
)}
|
)}
|
||||||
|
style={{ transitionTimingFunction: "ease-out" }}
|
||||||
onClick={toSpecifiedConversation}
|
onClick={toSpecifiedConversation}
|
||||||
>
|
>
|
||||||
<OIMAvatar
|
<OIMAvatar
|
||||||
size={48}
|
size={44}
|
||||||
src={conversation.faceURL}
|
src={conversation.faceURL}
|
||||||
isgroup={Boolean(conversation.groupID)}
|
isgroup={Boolean(conversation.groupID)}
|
||||||
text={conversation.showName}
|
text={conversation.showName}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="ml-3 flex h-12 flex-1 flex-col justify-between overflow-hidden py-0.5">
|
<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 items-center justify-between">
|
||||||
<div className="flex-1 truncate text-sm font-medium">
|
<div className="flex-1 truncate text-base font-bold text-[var(--base-black)]">
|
||||||
{conversation.showName}
|
{conversation.showName}
|
||||||
</div>
|
</div>
|
||||||
<div className="ml-2 text-xs text-[var(--sub-text)]">{latestMessageTime}</div>
|
<div className="ml-2 shrink-0 text-xs text-[var(--sub-text)]">
|
||||||
|
{latestMessageTime}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="mt-1 flex items-center justify-between">
|
||||||
<div className="flex min-h-[18px] flex-1 items-center overflow-hidden">
|
<div className="flex min-h-[18px] min-w-0 flex-1 items-center overflow-hidden">
|
||||||
<div
|
<div
|
||||||
className="truncate text-[13px] text-[rgba(81,94,112,0.5)]"
|
className="truncate text-sm text-[var(--sub-text)]"
|
||||||
dangerouslySetInnerHTML={{
|
dangerouslySetInnerHTML={{
|
||||||
__html: latestMessageContent,
|
__html: latestMessageContent,
|
||||||
}}
|
}}
|
||||||
></div>
|
></div>
|
||||||
</div>
|
</div>
|
||||||
<Badge
|
<Badge className="ml-2 shrink-0" size="small" count={conversation.unreadCount} />
|
||||||
className="ml-2"
|
|
||||||
size="small"
|
|
||||||
count={conversation.unreadCount}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</Dropdown>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,16 +1,5 @@
|
|||||||
.conversation-item {
|
.conversation-item {
|
||||||
@apply relative mx-2 my-0.5 flex cursor-pointer items-center rounded-lg px-3 py-2.5;
|
@apply relative mx-2 my-0 flex cursor-pointer items-center rounded-lg px-3;
|
||||||
|
|
||||||
&-pined {
|
|
||||||
&::after {
|
|
||||||
content: "";
|
|
||||||
position: absolute;
|
|
||||||
right: 0;
|
|
||||||
top: 0;
|
|
||||||
border: 4px solid;
|
|
||||||
border-color: var(--primary) var(--primary) transparent transparent;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
:global(.emojione) {
|
:global(.emojione) {
|
||||||
width: 16px;
|
width: 16px;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { SearchOutlined, PlusOutlined } from "@ant-design/icons";
|
import { SearchOutlined, PlusOutlined } from "@ant-design/icons";
|
||||||
import { Button, Input, Spin } from "antd";
|
import { Button, Input, Spin } from "antd";
|
||||||
import { t } from "i18next";
|
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 { useNavigate, useParams } from "react-router-dom";
|
||||||
import { Virtuoso, VirtuosoHandle } from "react-virtuoso";
|
import { Virtuoso, VirtuosoHandle } from "react-virtuoso";
|
||||||
|
|
||||||
@@ -11,43 +11,90 @@ import { useConversationStore, useUserStore } from "@/store";
|
|||||||
|
|
||||||
import ConversationItemComp from "./ConversationItem";
|
import ConversationItemComp from "./ConversationItem";
|
||||||
|
|
||||||
// 断网时顶部的深色提示横条
|
const prefersReducedMotion = () =>
|
||||||
const OfflineBar = () => (
|
typeof window !== "undefined" &&
|
||||||
<div>
|
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||||
<div className="flex h-9 items-center bg-[#3a3f47] px-3 text-xs text-white">
|
|
||||||
|
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)" }}
|
||||||
|
>
|
||||||
<svg
|
<svg
|
||||||
width="14"
|
width="16"
|
||||||
height="14"
|
height="16"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
fill="none"
|
fill="none"
|
||||||
className="mr-1.5 shrink-0"
|
className="mr-2 shrink-0"
|
||||||
>
|
>
|
||||||
<circle cx="12" cy="12" r="9" stroke="#fff" strokeWidth="2" />
|
<circle cx="12" cy="12" r="9" stroke="#fff" strokeWidth="1.8" />
|
||||||
<path
|
<path d="M12 7.5v5.5" stroke="#fff" strokeWidth="1.8" strokeLinecap="round" />
|
||||||
d="M12 7.5v5.5"
|
|
||||||
stroke="#fff"
|
|
||||||
strokeWidth="2"
|
|
||||||
strokeLinecap="round"
|
|
||||||
/>
|
|
||||||
<circle cx="12" cy="16.5" r="1.2" fill="#fff" />
|
<circle cx="12" cy="16.5" r="1.2" fill="#fff" />
|
||||||
</svg>
|
</svg>
|
||||||
{t("states.offline")}
|
{t("states.offline")}
|
||||||
</div>
|
</div>
|
||||||
<div className="px-3 py-2 text-xs text-[var(--sub-text)]">
|
<div className="px-4 py-2 text-xs text-[var(--sub-text)]">
|
||||||
{t("states.offlineTip")}
|
{t("states.offlineTip")}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const EmptyIcon = () => (
|
const LinearChatIcon = () => (
|
||||||
<svg width="56" height="56" viewBox="0 0 24 24" fill="none">
|
<svg width="64" height="64" viewBox="0 0 24 24" fill="none">
|
||||||
<path
|
<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"
|
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"
|
||||||
fill="#dcdfe6"
|
stroke="var(--gap-text)"
|
||||||
|
strokeWidth="1.6"
|
||||||
|
strokeLinejoin="round"
|
||||||
/>
|
/>
|
||||||
<circle cx="8.5" cy="11" r="1.1" fill="#fff" />
|
</svg>
|
||||||
<circle cx="12" cy="11" r="1.1" fill="#fff" />
|
);
|
||||||
<circle cx="15.5" cy="11" r="1.1" fill="#fff" />
|
|
||||||
|
const LinearFailIcon = () => (
|
||||||
|
<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"
|
||||||
|
/>
|
||||||
|
<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>
|
</svg>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -96,15 +143,12 @@ const ConversationSider = () => {
|
|||||||
if (syncState === "failed" && conversationList.length === 0) {
|
if (syncState === "failed" && conversationList.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-1 flex-col items-center justify-center">
|
<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-4 text-sm">{t("states.loadFailed")}</div>
|
||||||
<div className="mt-1 text-xs text-[var(--sub-text)]">
|
<div className="mt-1 text-xs text-[var(--sub-text)]">
|
||||||
{t("states.loadFailedTip")}
|
{t("states.loadFailedTip")}
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button className="mt-4" onClick={() => getConversationListByReq(false)}>
|
||||||
className="mt-4"
|
|
||||||
onClick={() => getConversationListByReq(false)}
|
|
||||||
>
|
|
||||||
{t("retry")}
|
{t("retry")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -113,7 +157,7 @@ const ConversationSider = () => {
|
|||||||
if (conversationList.length === 0) {
|
if (conversationList.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-1 flex-col items-center justify-center">
|
<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-4 text-sm">{t("states.noMessage")}</div>
|
||||||
<div className="mt-1 text-xs text-[var(--sub-text)]">
|
<div className="mt-1 text-xs text-[var(--sub-text)]">
|
||||||
{t("states.noMessageTip")}
|
{t("states.noMessageTip")}
|
||||||
@@ -150,12 +194,13 @@ const ConversationSider = () => {
|
|||||||
needHidden={Boolean(conversationID)}
|
needHidden={Boolean(conversationID)}
|
||||||
wrapClassName="flex flex-col border-r border-[var(--gap-text)] bg-white"
|
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="app-drag px-4 pt-4">
|
||||||
<div className="pb-3 text-base font-bold">{t("placeholder.chat")}</div>
|
<div className="pb-3 text-base font-bold">{t("placeholder.chat")}</div>
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<Input
|
<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} />}
|
prefix={<SearchOutlined className="text-[var(--sub-text)]" rev={undefined} />}
|
||||||
placeholder={t("placeholder.search")}
|
placeholder={t("placeholder.search")}
|
||||||
allowClear
|
allowClear
|
||||||
@@ -163,7 +208,7 @@ const ConversationSider = () => {
|
|||||||
onChange={(e) => setKeyword(e.target.value)}
|
onChange={(e) => setKeyword(e.target.value)}
|
||||||
/>
|
/>
|
||||||
<div
|
<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")}
|
title={t("placeholder.addColleague")}
|
||||||
onClick={() => setAddColleagueOpen(true)}
|
onClick={() => setAddColleagueOpen(true)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,23 +1,26 @@
|
|||||||
import { Layout } from "antd";
|
import { Layout } from "antd";
|
||||||
import { useTranslation } from "react-i18next";
|
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 = () => {
|
export const EmptyChat = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
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 flex-col items-center">
|
||||||
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-[var(--primary)]">
|
<LinearChatIcon />
|
||||||
<svg width="34" height="34" viewBox="0 0 24 24" fill="none">
|
<div className="mt-4 text-base font-medium text-[var(--base-black)]">
|
||||||
<path
|
{t("placeholder.emptyChat")}
|
||||||
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")}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Layout>
|
</Layout>
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ const EmojiPanel = ({ onSelect }: { onSelect: (emoji: string) => void }) => (
|
|||||||
{EMOJI_LIST.map((emoji) => (
|
{EMOJI_LIST.map((emoji) => (
|
||||||
<div
|
<div
|
||||||
key={emoji}
|
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)}
|
onClick={() => onSelect(emoji)}
|
||||||
>
|
>
|
||||||
{emoji}
|
{emoji}
|
||||||
@@ -42,7 +42,7 @@ const EmojiPanel = ({ onSelect }: { onSelect: (emoji: string) => void }) => (
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
const ChatFooter: ForwardRefRenderFunction<unknown, unknown> = (_, ref) => {
|
const ChatFooter: ForwardRefRenderFunction<unknown, unknown> = (_, _ref) => {
|
||||||
const [text, setText] = useState("");
|
const [text, setText] = useState("");
|
||||||
const [sending, setSending] = useState(false);
|
const [sending, setSending] = useState(false);
|
||||||
const [emojiVisible, setEmojiVisible] = useState(false);
|
const [emojiVisible, setEmojiVisible] = useState(false);
|
||||||
@@ -141,7 +141,7 @@ const ChatFooter: ForwardRefRenderFunction<unknown, unknown> = (_, ref) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const toolIconClass =
|
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 (
|
return (
|
||||||
<footer className="relative bg-white">
|
<footer className="relative bg-white">
|
||||||
@@ -203,14 +203,15 @@ const ChatFooter: ForwardRefRenderFunction<unknown, unknown> = (_, ref) => {
|
|||||||
<div className="px-4 pt-2">
|
<div className="px-4 pt-2">
|
||||||
<Input.TextArea
|
<Input.TextArea
|
||||||
ref={textareaRef}
|
ref={textareaRef}
|
||||||
className="!resize-none !border-none !shadow-none"
|
autoSize={{ minRows: 3, maxRows: 6 }}
|
||||||
autoSize={{ minRows: 4, maxRows: 8 }}
|
|
||||||
placeholder={t("placeholder.sendToName", {
|
placeholder={t("placeholder.sendToName", {
|
||||||
name: currentConversation?.showName ?? "",
|
name: currentConversation?.showName ?? "",
|
||||||
})}
|
})}
|
||||||
value={text}
|
value={text}
|
||||||
onChange={(e) => setText(e.target.value)}
|
onChange={(e) => setText(e.target.value)}
|
||||||
onKeyDown={onKeyDown}
|
onKeyDown={onKeyDown}
|
||||||
|
bordered={false}
|
||||||
|
style={{ resize: "none", boxShadow: "none" }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -220,11 +221,11 @@ const ChatFooter: ForwardRefRenderFunction<unknown, unknown> = (_, ref) => {
|
|||||||
{t("placeholder.enterToSendTip")}
|
{t("placeholder.enterToSendTip")}
|
||||||
</span>
|
</span>
|
||||||
<Button
|
<Button
|
||||||
className="w-fit px-6"
|
|
||||||
type="primary"
|
type="primary"
|
||||||
disabled={!text.trim()}
|
disabled={!text.trim()}
|
||||||
loading={sending}
|
loading={sending}
|
||||||
onClick={sendText}
|
onClick={sendText}
|
||||||
|
style={{ height: 32, borderRadius: 8, paddingInline: 24 }}
|
||||||
>
|
>
|
||||||
{t("placeholder.send")}
|
{t("placeholder.send")}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -3,11 +3,10 @@ import { SessionType } from "@openim/wasm-client-sdk";
|
|||||||
import { Dropdown, Layout } from "antd";
|
import { Dropdown, Layout } from "antd";
|
||||||
import { t } from "i18next";
|
import { t } from "i18next";
|
||||||
import { memo, useEffect, useRef } from "react";
|
import { memo, useEffect, useRef } from "react";
|
||||||
import { v4 as uuidV4 } from "uuid";
|
|
||||||
|
|
||||||
import { OverlayVisibleHandle } from "@/hooks/useOverlayVisible";
|
import { OverlayVisibleHandle } from "@/hooks/useOverlayVisible";
|
||||||
import { useConversationStore, useUserStore } from "@/store";
|
import { useConversationStore } from "@/store";
|
||||||
import emitter, { emit } from "@/utils/events";
|
import { emit } from "@/utils/events";
|
||||||
|
|
||||||
import GroupSetting from "../GroupSetting";
|
import GroupSetting from "../GroupSetting";
|
||||||
import SingleSetting from "../SingleSetting";
|
import SingleSetting from "../SingleSetting";
|
||||||
@@ -31,42 +30,12 @@ const ChatHeader = () => {
|
|||||||
|
|
||||||
const isSingleSession = currentConversation?.conversationType === SessionType.Single;
|
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
|
const menuItems = isSingleSession
|
||||||
? [
|
? [{ key: "viewProfile", label: t("placeholder.viewProfile") }]
|
||||||
{ key: "voiceCall", label: t("placeholder.voiceCall") },
|
|
||||||
{ key: "viewProfile", label: t("placeholder.viewProfile") },
|
|
||||||
]
|
|
||||||
: [{ key: "groupSetting", label: t("placeholder.groupSetting") }];
|
: [{ key: "groupSetting", label: t("placeholder.groupSetting") }];
|
||||||
|
|
||||||
const onMenuClick = ({ key }: { key: string }) => {
|
const onMenuClick = ({ key }: { key: string }) => {
|
||||||
switch (key) {
|
switch (key) {
|
||||||
case "voiceCall":
|
|
||||||
startVoiceCall();
|
|
||||||
break;
|
|
||||||
case "viewProfile":
|
case "viewProfile":
|
||||||
emit("OPEN_USER_CARD", {
|
emit("OPEN_USER_CARD", {
|
||||||
userID: currentConversation?.userID,
|
userID: currentConversation?.userID,
|
||||||
@@ -82,7 +51,10 @@ const ChatHeader = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
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 h-full items-center leading-none">
|
||||||
<div className="flex flex-1 items-center overflow-hidden">
|
<div className="flex flex-1 items-center overflow-hidden">
|
||||||
<div className="truncate text-base font-bold">
|
<div className="truncate text-base font-bold">
|
||||||
@@ -95,11 +67,10 @@ const ChatHeader = () => {
|
|||||||
placement="bottomRight"
|
placement="bottomRight"
|
||||||
>
|
>
|
||||||
<MoreOutlined
|
<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}
|
rev={undefined}
|
||||||
/>
|
/>
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
{/* 给悬浮窗口控制条留出空间 */}
|
|
||||||
{Boolean(window.electronAPI) && <div className="w-28 shrink-0" />}
|
{Boolean(window.electronAPI) && <div className="w-28 shrink-0" />}
|
||||||
</div>
|
</div>
|
||||||
<SingleSetting ref={singleSettingRef} />
|
<SingleSetting ref={singleSettingRef} />
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ const FileMessageRender: FC<IMessageItemProps> = ({ message }) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<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}
|
onClick={saveFile}
|
||||||
>
|
>
|
||||||
<FileIcon />
|
<FileIcon />
|
||||||
|
|||||||
@@ -10,9 +10,9 @@ const MediaMessageRender: FC<IMessageItemProps> = ({ message }) => {
|
|||||||
const imageHeight = message.pictureElem!.sourcePicture.height;
|
const imageHeight = message.pictureElem!.sourcePicture.height;
|
||||||
const imageWidth = message.pictureElem!.sourcePicture.width;
|
const imageWidth = message.pictureElem!.sourcePicture.width;
|
||||||
const snapshotMaxHeight = message.pictureElem!.snapshotPicture?.height ?? imageHeight;
|
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 adaptedHight = min(minHeight, snapshotMaxHeight) + 10;
|
||||||
const adaptedWidth = min(imageWidth, 200) + 10;
|
const adaptedWidth = min(imageWidth, 240) + 10;
|
||||||
|
|
||||||
const sourceUrl =
|
const sourceUrl =
|
||||||
message.pictureElem!.snapshotPicture?.url || message.pictureElem!.sourcePicture.url;
|
message.pictureElem!.snapshotPicture?.url || message.pictureElem!.sourcePicture.url;
|
||||||
@@ -21,10 +21,10 @@ const MediaMessageRender: FC<IMessageItemProps> = ({ message }) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Spin spinning={isSending}>
|
<Spin spinning={isSending}>
|
||||||
<div className="relative max-w-[200px]" style={minStyle}>
|
<div className="relative max-w-[240px]" style={minStyle}>
|
||||||
<Image
|
<Image
|
||||||
rootClassName="message-image cursor-pointer"
|
rootClassName="message-image cursor-pointer"
|
||||||
className="max-w-[200px] rounded-md"
|
className="max-w-[240px] rounded-md"
|
||||||
src={sourceUrl}
|
src={sourceUrl}
|
||||||
preview
|
preview
|
||||||
placeholder={
|
placeholder={
|
||||||
|
|||||||
@@ -9,7 +9,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.bubble {
|
.bubble {
|
||||||
@apply w-fit rounded-xl p-2.5;
|
@apply w-fit rounded-lg p-2.5;
|
||||||
|
max-width: 60%;
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
background-color: var(--chat-bubble);
|
background-color: var(--chat-bubble);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { IMSDK } from "@/layout/MainContentWrap";
|
|||||||
import { useContactStore } from "@/store";
|
import { useContactStore } from "@/store";
|
||||||
import { feedbackToast } from "@/utils/common";
|
import { feedbackToast } from "@/utils/common";
|
||||||
import { Input, Modal } from "antd";
|
import { Input, Modal } from "antd";
|
||||||
|
import { t } from "i18next";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
interface IAddColleagueProps {
|
interface IAddColleagueProps {
|
||||||
@@ -9,21 +10,16 @@ interface IAddColleagueProps {
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 添加同事弹窗(视觉规范 v1 §4.2):
|
|
||||||
* 工号输入 + 可编辑验证消息 + 发送按钮,五态:默认/校验失败/发送中/成功/失败。
|
|
||||||
* 成功后刷新申请数,2s 后关闭并提示「申请已发送,等对方通过」。
|
|
||||||
*/
|
|
||||||
const AddColleague = ({ open, onClose }: IAddColleagueProps) => {
|
const AddColleague = ({ open, onClose }: IAddColleagueProps) => {
|
||||||
const [staffNo, setStaffNo] = useState("");
|
const [staffNo, setStaffNo] = useState("");
|
||||||
const [verifyMsg, setVerifyMsg] = useState("你好,我想加你为同事");
|
const [verifyMsg, setVerifyMsg] = useState(t("placeholder.addColleagueDefaultMsg"));
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const [sending, setSending] = useState(false);
|
const [sending, setSending] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open) {
|
if (open) {
|
||||||
setStaffNo("");
|
setStaffNo("");
|
||||||
setVerifyMsg("你好,我想加你为同事");
|
setVerifyMsg(t("placeholder.addColleagueDefaultMsg"));
|
||||||
setError("");
|
setError("");
|
||||||
setSending(false);
|
setSending(false);
|
||||||
}
|
}
|
||||||
@@ -32,21 +28,21 @@ const AddColleague = ({ open, onClose }: IAddColleagueProps) => {
|
|||||||
const send = async () => {
|
const send = async () => {
|
||||||
const id = staffNo.trim();
|
const id = staffNo.trim();
|
||||||
if (!id) {
|
if (!id) {
|
||||||
setError("请输入对方工号");
|
setError(t("toast.inputPeerStaffNo"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setSending(true);
|
setSending(true);
|
||||||
try {
|
try {
|
||||||
await IMSDK.addFriend({
|
await IMSDK.addFriend({
|
||||||
toUserID: id,
|
toUserID: id,
|
||||||
reqMsg: verifyMsg.trim() || "你好,我想加你为同事",
|
reqMsg: verifyMsg.trim() || t("placeholder.addColleagueDefaultMsg"),
|
||||||
});
|
});
|
||||||
useContactStore.getState().getRecvFriendApplicationListByReq();
|
useContactStore.getState().getRecvFriendApplicationListByReq();
|
||||||
feedbackToast({ msg: "申请已发送,等对方通过" });
|
feedbackToast({ msg: t("toast.addColleagueSent"), duration: 2 });
|
||||||
setTimeout(onClose, 500);
|
window.setTimeout(onClose, 500);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setError("发送失败,请确认工号正确、网络正常");
|
setError(t("toast.addColleagueFailed"));
|
||||||
feedbackToast({ error, msg: "发送失败,请确认工号正确、网络正常" });
|
feedbackToast({ error, msg: t("toast.addColleagueFailed") });
|
||||||
} finally {
|
} finally {
|
||||||
setSending(false);
|
setSending(false);
|
||||||
}
|
}
|
||||||
@@ -54,7 +50,7 @@ const AddColleague = ({ open, onClose }: IAddColleagueProps) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
title="添加同事"
|
title={t("placeholder.addColleague")}
|
||||||
open={open}
|
open={open}
|
||||||
onCancel={sending ? undefined : onClose}
|
onCancel={sending ? undefined : onClose}
|
||||||
footer={null}
|
footer={null}
|
||||||
@@ -62,37 +58,55 @@ const AddColleague = ({ open, onClose }: IAddColleagueProps) => {
|
|||||||
centered
|
centered
|
||||||
maskClosable={!sending}
|
maskClosable={!sending}
|
||||||
destroyOnClose
|
destroyOnClose
|
||||||
|
styles={{
|
||||||
|
content: { borderRadius: 12 },
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div className="pt-2">
|
<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
|
<Input
|
||||||
value={staffNo}
|
value={staffNo}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setStaffNo(e.target.value);
|
setStaffNo(e.target.value);
|
||||||
if (error) setError("");
|
if (error) setError("");
|
||||||
}}
|
}}
|
||||||
placeholder="请输入对方工号"
|
placeholder={t("toast.inputPeerStaffNo")}
|
||||||
status={error ? "error" : undefined}
|
status={error ? "error" : undefined}
|
||||||
style={{ height: 36, borderRadius: 8, backgroundColor: "var(--search-bg)" }}
|
style={{ height: 36, borderRadius: 8, backgroundColor: "var(--search-bg)" }}
|
||||||
disabled={sending}
|
disabled={sending}
|
||||||
onPressEnter={send}
|
onPressEnter={send}
|
||||||
/>
|
/>
|
||||||
{error && <div className="mt-1 text-xs text-[var(--warn-text)]">{error}</div>}
|
{error ? (
|
||||||
<div className="mb-1.5 mt-4 text-sm text-[var(--base-black)]">验证消息(可不填)</div>
|
<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
|
<Input.TextArea
|
||||||
value={verifyMsg}
|
value={verifyMsg}
|
||||||
onChange={(e) => setVerifyMsg(e.target.value)}
|
onChange={(e) => setVerifyMsg(e.target.value)}
|
||||||
placeholder="你好,我想加你为同事"
|
placeholder={t("placeholder.addColleagueDefaultMsg")}
|
||||||
autoSize={{ minRows: 2, maxRows: 4 }}
|
autoSize={{ minRows: 2, maxRows: 4 }}
|
||||||
disabled={sending}
|
disabled={sending}
|
||||||
style={{ borderRadius: 8, backgroundColor: "var(--search-bg)" }}
|
style={{
|
||||||
|
height: 72,
|
||||||
|
minHeight: 72,
|
||||||
|
borderRadius: 8,
|
||||||
|
backgroundColor: "var(--search-bg)",
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<button
|
<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"
|
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}
|
disabled={sending}
|
||||||
onClick={send}
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|||||||
@@ -5,10 +5,23 @@ import OIMAvatar from "@/components/OIMAvatar";
|
|||||||
const FriendListItem = ({
|
const FriendListItem = ({
|
||||||
friend,
|
friend,
|
||||||
showUserCard,
|
showUserCard,
|
||||||
|
title,
|
||||||
}: {
|
}: {
|
||||||
friend: FriendUserItem;
|
friend: FriendUserItem;
|
||||||
showUserCard: (userID: string) => void;
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
className="flex h-14 cursor-pointer items-center rounded-lg px-3.5 transition-colors duration-100 hover:bg-[var(--chat-bg)]"
|
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)]">
|
<div className="truncate text-base font-medium text-[var(--base-black)]">
|
||||||
{friend.remark || friend.nickname}
|
{friend.remark || friend.nickname}
|
||||||
</div>
|
</div>
|
||||||
|
{job ? (
|
||||||
|
<div className="truncate text-sm text-[var(--sub-text)]">{job}</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</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 { Empty, Spin } from "antd";
|
||||||
import { useCallback, useEffect, useRef } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { GroupedVirtuoso, GroupedVirtuosoHandle } from "react-virtuoso";
|
import { GroupedVirtuoso } from "react-virtuoso";
|
||||||
|
|
||||||
import { useContactStore } from "@/store";
|
import { useContactStore } from "@/store";
|
||||||
import { formatContactsByWorker } from "@/utils/contactsFormat";
|
|
||||||
import { emit } from "@/utils/events";
|
import { emit } from "@/utils/events";
|
||||||
|
import { getIMToken } from "@/utils/storage";
|
||||||
|
|
||||||
import AlphabetIndex from "./AlphabetIndex";
|
|
||||||
import FriendListItem from "./FriendListItem";
|
import FriendListItem from "./FriendListItem";
|
||||||
|
|
||||||
|
type DirInfo = { title: string; department: string };
|
||||||
|
|
||||||
export const MyFriends = () => {
|
export const MyFriends = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const friendList = useContactStore((state) => state.friendList);
|
const friendList = useContactStore((state) => state.friendList);
|
||||||
const virtuoso = useRef<GroupedVirtuosoHandle>(null);
|
const [dirMap, setDirMap] = useState<Record<string, DirInfo>>({});
|
||||||
const alphabetRef = useRef<{ updateCurrentLetter: (letter: string) => void }>(null);
|
const [dirReady, setDirReady] = useState(false);
|
||||||
|
|
||||||
const { data: sectionData, cancel } = useRequest(
|
|
||||||
() => formatContactsByWorker(friendList),
|
|
||||||
{
|
|
||||||
refreshDeps: [friendList],
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
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 () => {
|
return () => {
|
||||||
cancel();
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const scrollToLetter = useCallback(
|
const sectionData = useMemo(() => {
|
||||||
(idx: number) => {
|
const ungrouped = t("placeholder.ungrouped");
|
||||||
const prevNum = sectionData?.groupCounts.slice(0, idx).reduce((a, b) => a + b, 0);
|
const map = new Map<string, FriendUserItem[]>();
|
||||||
console.log(prevNum);
|
for (const friend of friendList) {
|
||||||
|
const dept = dirMap[friend.userID]?.department?.trim() || ungrouped;
|
||||||
virtuoso.current?.scrollToIndex({
|
const list = map.get(dept) ?? [];
|
||||||
index: prevNum ?? 0,
|
list.push(friend);
|
||||||
// behavior: "smooth",
|
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 {
|
||||||
[sectionData?.groupCounts],
|
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) => {
|
const showUserCard = useCallback((userID: string) => {
|
||||||
emit("OPEN_USER_CARD", {
|
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 (
|
return (
|
||||||
<div className="flex h-full w-full flex-col overflow-hidden bg-white">
|
<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>
|
<div className="m-5.5 text-base font-extrabold">{t("placeholder.myFriend")}</div>
|
||||||
{!sectionData ? (
|
{!dirReady && friendList.length === 0 ? (
|
||||||
<Spin />
|
<Spin />
|
||||||
) : !sectionData.groupCounts.length ? (
|
) : !friendList.length ? (
|
||||||
<Empty className="mt-[30%]" image={Empty.PRESENTED_IMAGE_SIMPLE} />
|
<Empty className="mt-[30%]" image={Empty.PRESENTED_IMAGE_SIMPLE} />
|
||||||
) : (
|
) : (
|
||||||
<div className="ml-4 mt-4 flex-1 overflow-auto pr-4">
|
<div className="ml-4 mt-4 flex-1 overflow-auto pr-4">
|
||||||
<AlphabetIndex
|
|
||||||
ref={alphabetRef}
|
|
||||||
indexList={sectionData.indexList}
|
|
||||||
scrollToLetter={scrollToLetter}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<GroupedVirtuoso
|
<GroupedVirtuoso
|
||||||
ref={virtuoso}
|
|
||||||
groupCounts={sectionData.groupCounts}
|
groupCounts={sectionData.groupCounts}
|
||||||
groupContent={(index) => (
|
groupContent={(index) => (
|
||||||
<div>
|
<div
|
||||||
<div className="bg-white px-3.5 pb-1 text-[13px] text-[var(--sub-text)]">
|
className="flex items-center bg-[var(--chat-bg)] px-3.5 text-xs text-[var(--sub-text)]"
|
||||||
|
style={{ height: 28 }}
|
||||||
|
>
|
||||||
{sectionData.indexList[index]}
|
{sectionData.indexList[index]}
|
||||||
</div>
|
</div>
|
||||||
<div className="mx-3.5 mb-3 h-px w-full bg-[var(--gap-text)] bg-white" />
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
itemContent={(index) => {
|
itemContent={(index) => {
|
||||||
|
const friend = sectionData.totalList[index];
|
||||||
return (
|
return (
|
||||||
<FriendListItem
|
<FriendListItem
|
||||||
key={sectionData.totalList[index].userID}
|
key={friend.userID}
|
||||||
friend={sectionData.totalList[index]}
|
friend={friend}
|
||||||
|
title={dirMap[friend.userID]?.title}
|
||||||
showUserCard={showUserCard}
|
showUserCard={showUserCard}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
rangeChanged={({ startIndex }) => determineCurrentGroup(startIndex)}
|
|
||||||
className="no-scrollbar h-full overflow-x-hidden"
|
className="no-scrollbar h-full overflow-x-hidden"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { t } from "i18next";
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
import { feedbackToast } from "@/utils/common";
|
|
||||||
import { setIMProfile } from "@/utils/storage";
|
import { setIMProfile } from "@/utils/storage";
|
||||||
|
|
||||||
interface LoginResult {
|
interface LoginResult {
|
||||||
@@ -18,7 +17,11 @@ interface LoginResult {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const LoginForm = () => {
|
type LoginFormProps = {
|
||||||
|
onBanner: (text: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const LoginForm = ({ onBanner }: LoginFormProps) => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
@@ -34,17 +37,14 @@ const LoginForm = () => {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
if (res.code !== 0 || !res.data) {
|
if (res.code !== 0 || !res.data) {
|
||||||
feedbackToast({
|
onBanner(t("toast.staffOrPasswordWrong"));
|
||||||
msg: res.msg || t("toast.accessFailed"),
|
|
||||||
error: res.msg || "login failed",
|
|
||||||
});
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const { userID, imToken } = res.data;
|
const { userID, imToken } = res.data;
|
||||||
setIMProfile({ chatToken: imToken, imToken, userID });
|
setIMProfile({ chatToken: imToken, imToken, userID });
|
||||||
navigate("/chat");
|
navigate("/chat");
|
||||||
} catch (error) {
|
} catch {
|
||||||
feedbackToast({ msg: "登录失败,请检查网络后重试", error });
|
onBanner(t("toast.networkRetry"));
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -52,22 +52,30 @@ const LoginForm = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex flex-row items-center justify-between">
|
<div className="text-center text-xl font-bold text-[var(--base-black)]">
|
||||||
<div className="text-xl font-medium">{t("placeholder.welcome")}</div>
|
{t("placeholder.title")}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-center text-sm text-[var(--sub-text)]">
|
||||||
|
{t("placeholder.internalComms")}
|
||||||
</div>
|
</div>
|
||||||
<Form
|
<Form
|
||||||
className="mt-8"
|
className="mt-8"
|
||||||
layout="vertical"
|
layout="vertical"
|
||||||
onFinish={onFinish}
|
onFinish={onFinish}
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
labelCol={{ prefixCls: "custom-form-item" }}
|
validateTrigger={["onSubmit"]}
|
||||||
>
|
>
|
||||||
<Form.Item
|
<Form.Item
|
||||||
label={t("placeholder.staffNo")}
|
label={t("placeholder.staffNo")}
|
||||||
name="staffNo"
|
name="staffNo"
|
||||||
rules={[{ required: true, message: t("toast.inputStaffNo") }]}
|
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>
|
||||||
|
|
||||||
<Form.Item
|
<Form.Item
|
||||||
@@ -75,14 +83,28 @@ const LoginForm = () => {
|
|||||||
name="password"
|
name="password"
|
||||||
rules={[{ required: true, message: t("toast.inputPassword") }]}
|
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>
|
||||||
|
|
||||||
<Form.Item className="mb-4 mt-10">
|
<Form.Item className="mb-2 mt-8">
|
||||||
<Button type="primary" htmlType="submit" block loading={loading}>
|
<Button
|
||||||
{t("placeholder.login")}
|
type="primary"
|
||||||
|
htmlType="submit"
|
||||||
|
block
|
||||||
|
loading={loading}
|
||||||
|
style={{ height: 40, borderRadius: 8 }}
|
||||||
|
>
|
||||||
|
{loading ? t("placeholder.loggingIn") : t("placeholder.login")}
|
||||||
</Button>
|
</Button>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
<div className="text-center text-xs text-[var(--sub-text)]">
|
||||||
|
{t("placeholder.agreeInternal")}
|
||||||
|
</div>
|
||||||
</Form>
|
</Form>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,6 +3,55 @@
|
|||||||
.ant-form-item-label {
|
.ant-form-item-label {
|
||||||
padding: 0;
|
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 { t } from "i18next";
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { useCopyToClipboard } from "react-use";
|
import { useCopyToClipboard } from "react-use";
|
||||||
|
|
||||||
import login_bg from "@/assets/images/login/login_bg.png";
|
|
||||||
import WindowControlBar from "@/components/WindowControlBar";
|
import WindowControlBar from "@/components/WindowControlBar";
|
||||||
import { APP_NAME, APP_VERSION, SDK_VERSION } from "@/config";
|
import { APP_NAME, APP_VERSION, SDK_VERSION } from "@/config";
|
||||||
import { feedbackToast } from "@/utils/common";
|
import { feedbackToast } from "@/utils/common";
|
||||||
@@ -11,6 +11,8 @@ import LoginForm from "./LoginForm";
|
|||||||
|
|
||||||
export const Login = () => {
|
export const Login = () => {
|
||||||
const [_, copyToClipboard] = useCopyToClipboard();
|
const [_, copyToClipboard] = useCopyToClipboard();
|
||||||
|
const [banner, setBanner] = useState<string | null>(null);
|
||||||
|
const clearBanner = useCallback(() => setBanner(null), []);
|
||||||
|
|
||||||
const handleCopy = () => {
|
const handleCopy = () => {
|
||||||
copyToClipboard(`${`${APP_NAME} ${APP_VERSION}`}/${SDK_VERSION}`);
|
copyToClipboard(`${`${APP_NAME} ${APP_VERSION}`}/${SDK_VERSION}`);
|
||||||
@@ -19,16 +21,16 @@ export const Login = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative flex h-full flex-col">
|
<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 />
|
<WindowControlBar />
|
||||||
</div>
|
</div>
|
||||||
|
{banner ? <LoginToastBanner text={banner} onDone={clearBanner} /> : null}
|
||||||
<div className="flex flex-1 items-center justify-center">
|
<div className="flex flex-1 items-center justify-center">
|
||||||
<LeftBar />
|
|
||||||
<div
|
<div
|
||||||
className={`${styles.login} mr-14 h-[450px] w-[350px] rounded-md bg-white p-11`}
|
className={`${styles.login} h-[450px] w-[350px] rounded-xl bg-white p-11`}
|
||||||
style={{ boxShadow: "0 0 30px rgba(0,0,0,.1)" }}
|
style={{ boxShadow: "0 4px 16px rgba(0,0,0,0.08)" }}
|
||||||
>
|
>
|
||||||
<LoginForm />
|
<LoginForm onBanner={setBanner} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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 (
|
return (
|
||||||
<div className="flex min-h-[420]">
|
<div
|
||||||
<div className="mr-14 text-center">
|
className={styles["login-toast"]}
|
||||||
<div className="text-2xl">{t("placeholder.title")}</div>
|
data-phase={phase}
|
||||||
<span className="text-sm text-gray-400">{t("placeholder.subTitle")}</span>
|
style={{ backgroundColor: "var(--warn-text)" }}
|
||||||
<img src={login_bg} alt="login_bg" />
|
>
|
||||||
</div>
|
{text}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -17,8 +17,6 @@
|
|||||||
--mask-bg: #000000;
|
--mask-bg: #000000;
|
||||||
--success: #00b42a;
|
--success: #00b42a;
|
||||||
--warning: #ff7d00;
|
--warning: #ff7d00;
|
||||||
--top-search-bar: #3b87f5;
|
|
||||||
--moment-text: #6085b1;
|
|
||||||
--searchbar-height: 2.5rem;
|
--searchbar-height: 2.5rem;
|
||||||
--call-wait-bg: #f2f8ff;
|
--call-wait-bg: #f2f8ff;
|
||||||
--radius-control: 8px;
|
--radius-control: 8px;
|
||||||
@@ -165,6 +163,16 @@ img {
|
|||||||
-webkit-user-drag: none;
|
-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 {
|
.custom-form-item > label {
|
||||||
color: var(--sub-text) !important;
|
color: var(--sub-text) !important;
|
||||||
margin-bottom: 4px !important;
|
margin-bottom: 4px !important;
|
||||||
|
|||||||
Reference in New Issue
Block a user