fix(pc-client): PC 端统一手机端设计 Token 并补提交 src/components(B-52 视觉阻塞修复)

Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
编码工程师
2026-08-16 00:27:52 +08:00
co-authored by multica-agent
parent 9074b5d1d7
commit 2fe8417900
27 changed files with 884 additions and 28 deletions
@@ -0,0 +1,148 @@
import { ApplicationHandleResult } from "@openim/wasm-client-sdk";
import {
FriendApplicationItem,
GroupApplicationItem,
} from "@openim/wasm-client-sdk/lib/types/entity";
import { Button, Spin } from "antd";
import { t } from "i18next";
import { memo, useCallback, useState } from "react";
import arrow from "@/assets/images/contact/arrowTopRight.png";
import OIMAvatar from "@/components/OIMAvatar";
import { IMSDK } from "@/layout/MainContentWrap";
import { emit } from "@/utils/events";
type ApplicationItemSource = FriendApplicationItem & GroupApplicationItem;
export type AccessFunction = (
source: Partial<ApplicationItemSource>,
isRecv: boolean,
) => Promise<void>;
const ApplicationItem = ({
currentUserID,
source,
onAccept,
onReject,
}: {
source: Partial<ApplicationItemSource>;
currentUserID: string;
onAccept: AccessFunction;
onReject: AccessFunction;
}) => {
const [loading, setLoading] = useState(false);
const isRecv = source.userID !== currentUserID && source.fromUserID !== currentUserID;
const isGroup = Boolean(source.groupID);
const showActionBtn = source.handleResult === 0 && isRecv;
const getApplicationDesc = () => {
if (isGroup) {
return t("application.applyToJoin");
}
return isRecv ? t("application.applyToFriend") : t("application.applyToAdd");
};
const getTitle = () => {
if (isGroup) {
return isRecv ? source.nickname : source.groupName;
}
return isRecv ? source.fromNickname : source.toNickname;
};
const getStatusStr = () => {
if (source.handleResult === ApplicationHandleResult.Agree) {
return t("application.agreed");
}
if (source.handleResult === ApplicationHandleResult.Reject) {
return t("application.refused");
}
return t("application.pending");
};
const getAvatarUrl = () => {
if (isGroup) {
return isRecv ? source.userFaceURL : source.groupFaceURL;
}
return isRecv ? source.fromFaceURL : source.toFaceURL;
};
const loadingWrap = async (isAgree: boolean) => {
setLoading(true);
await (isAgree ? onAccept(source, isRecv) : onReject(source, isRecv));
setLoading(false);
};
const tryShowCard = useCallback(async () => {
if (isGroup) {
const { data } = await IMSDK.getSpecifiedGroupsInfo([source.groupID!]);
emit("OPEN_GROUP_CARD", data[0]);
return;
}
window.userClick(isRecv ? source.fromUserID : source.toUserID);
}, []);
return (
<Spin spinning={loading}>
<div className="flex flex-row items-center justify-between p-3.5 transition-colors hover:bg-[var(--primary-active)]">
<div className="flex flex-row">
<OIMAvatar
src={getAvatarUrl()}
text={getTitle()}
isgroup={isGroup && !isRecv}
onClick={tryShowCard}
/>
<div className="ml-3">
<p className="text-sm">{getTitle()}</p>
<p className="pb-2.5 pt-[5px] text-xs ">
{getApplicationDesc()}
{(isGroup || (!isGroup && !isRecv)) && (
<span className="ml-1 text-xs text-[var(--primary)]">
{source.groupName || source.toNickname}
</span>
)}
</p>
<p className="text-xs text-[var(--sub-text)]">
{t("application.information")}:
</p>
<p className="text-xs text-[var(--sub-text)]">{source.reqMsg}</p>
</div>
</div>
{showActionBtn && (
<div className="flex flex-row">
<div className="mr-5.5 h-8 w-[60px]">
<Button
block={true}
size="small"
onClick={() => loadingWrap(false)}
className="!h-full !rounded-md border-2 border-[var(--primary)] text-[var(--primary)]"
>
{t("application.refuse")}
</Button>
</div>
<div className="h-8 w-[60px]">
<Button
block={true}
size="small"
type="primary"
className="!h-full !rounded-md bg-[var(--primary)]"
onClick={() => loadingWrap(true)}
>
{t("application.agree")}
</Button>
</div>
</div>
)}
{!showActionBtn && (
<div className="flex flex-row items-center">
{!isRecv && <img className="mr-2 h-4 w-4" src={arrow} alt="" />}
<p className="text-sm text-[var(--sub-text)]">{getStatusStr()}</p>
</div>
)}
</div>
</Spin>
);
};
export default memo(ApplicationItem);
@@ -0,0 +1,105 @@
.image-inline {
margin: 2px;
img {
aspect-ratio: auto !important;
max-width: 30vw !important;
max-height: 15vh !important;
width: auto !important;
object-fit: contain;
cursor: pointer;
}
}
.ck-content {
padding: 0 18px !important;
border: none !important;
word-break: break-all;
}
.ck-content [draggable="true"] {
pointer-events: none;
}
.ck-content > p {
margin: 10px 0 !important;
}
.ck.ck-toolbar {
border: none !important;
}
.ck-editor__editable {
--ck-inner-shadow: none !important;
--ck-drop-shadow: none !important;
--ck-drop-shadow-active: none !important;
outline: none !important;
}
.ck-editor__editable:focus {
box-shadow: none !important;
}
.ck.ck-editor__editable[role="textbox"]:focus {
border: none;
box-shadow: none !important;
}
.ck-powered-by {
display: none !important;
}
.ck-widget__type-around__button,
.ck-widget__type-around__button_after {
display: none !important;
}
.ck-editor {
flex: 1;
overflow-y: auto;
}
.ck-editor__main,
.ck-content {
height: 100% !important;
}
.ck-image-upload-complete-icon {
display: none !important;
}
.ck-image-upload-complete-icon:after {
display: none !important;
}
.ck-balloon-panel_visible {
border-radius: 10px !important;
border: none !important;
box-shadow: 0 6px 16px 0 rgba(0, 0, 0, 0.08), 0 3px 6px -4px rgba(0, 0, 0, 0.12),
0 9px 28px 8px rgba(0, 0, 0, 0.05) !important;
overflow: hidden !important;
}
.ck-list__item .ck-on {
background-color: #f0f0f0 !important;
}
.ck .ck-widget {
--ck-widget-outline-thickness: 2px !important;
--ck-color-widget-hover-border: transparent !important;
}
.ck .ck-widget.ck-widget_selected {
--ck-widget-outline-thickness: 2px !important;
}
.ck.ck-clipboard-drop-target-line {
--ck-clipboard-drop-target-color: transparent !important;
}
.ck-powered-by-balloon {
z-index: -99;
}
.ck-editor__top {
display: none;
}
+123
View File
@@ -0,0 +1,123 @@
import "./index.scss";
import "ckeditor5/ckeditor5.css";
import { ClassicEditor } from "@ckeditor/ckeditor5-editor-classic";
import { Essentials } from "@ckeditor/ckeditor5-essentials";
import { Paragraph } from "@ckeditor/ckeditor5-paragraph";
import { CKEditor } from "@ckeditor/ckeditor5-react";
import {
forwardRef,
ForwardRefRenderFunction,
memo,
useImperativeHandle,
useRef,
} from "react";
export type CKEditorRef = {
focus: (moveToEnd?: boolean) => void;
};
interface CKEditorProps {
value: string;
placeholder?: string;
onChange?: (value: string) => void;
onEnter?: () => void;
}
export interface EmojiData {
src: string;
alt: string;
}
const keyCodes = {
delete: 46,
backspace: 8,
};
const Index: ForwardRefRenderFunction<CKEditorRef, CKEditorProps> = (
{ value, placeholder, onChange, onEnter },
ref,
) => {
const ckEditor = useRef<ClassicEditor | null>(null);
const focus = (moveToEnd = false) => {
const editor = ckEditor.current;
if (editor) {
const model = editor.model;
const view = editor.editing.view;
const root = model.document.getRoot();
if (moveToEnd && root) {
const range = model.createRange(model.createPositionAt(root, "end"));
model.change((writer) => {
writer.setSelection(range);
});
}
view.focus();
}
};
const listenKeydown = (editor: ClassicEditor) => {
editor.editing.view.document.on(
"keydown",
(evt, data) => {
if (data.keyCode === 13 && !data.shiftKey) {
data.preventDefault();
evt.stop();
onEnter?.();
return;
}
if (data.keyCode === keyCodes.backspace || data.keyCode === keyCodes.delete) {
const selection = editor.model.document.selection;
const hasSelectContent = !editor.model.getSelectedContent(selection).isEmpty;
const hasEditorContent = Boolean(editor.getData());
if (!hasEditorContent) {
return;
}
if (hasSelectContent) return;
}
},
{ priority: "high" },
);
};
useImperativeHandle(
ref,
() => ({
focus,
}),
[],
);
return (
<CKEditor
editor={ClassicEditor}
data={value}
config={{
placeholder,
toolbar: [],
image: {
toolbar: [],
insert: {
type: "inline",
},
},
plugins: [Essentials, Paragraph],
}}
onReady={(editor) => {
ckEditor.current = editor;
listenKeydown(editor);
focus(true);
}}
onChange={(event, editor) => {
const data = editor.getData();
onChange?.(data);
}}
/>
);
};
export default memo(forwardRef(Index));
@@ -0,0 +1,46 @@
export const replaceEmoji2Str = (text: string) => {
const parser = new DOMParser();
const doc = parser.parseFromString(text, "text/html");
const emojiEls: HTMLImageElement[] = Array.from(doc.querySelectorAll(".emojione"));
emojiEls.map((face) => {
// @ts-ignore
const escapedOut = face.outerHTML.replace(/[-/\\^$*+?.()|[\]{}]/g, "\\$&");
text = text.replace(new RegExp(escapedOut, "g"), face.alt);
});
return text;
};
export const getCleanText = (html: string) => {
let text = replaceEmoji2Str(html);
text = text.replace(/<\/p><p>/g, "\n");
text = text.replace(/<br\s*[/]?>/gi, "\n");
text = text.replace(/<[^>]+>/g, "");
text = convertChar(text);
text = decodeHtmlEntities(text);
return text.trim();
};
let textAreaDom: HTMLTextAreaElement | null = null;
const decodeHtmlEntities = (text: string) => {
if (!textAreaDom) {
textAreaDom = document.createElement("textarea");
}
textAreaDom.innerHTML = text;
return textAreaDom.value;
};
export const convertChar = (text: string) => text.replace(/&nbsp;/gi, " ");
export const getCleanTextExceptImg = (html: string) => {
html = replaceEmoji2Str(html);
const regP = /<\/p><p>/g;
html = html.replace(regP, "</p><br><p>");
const regBr = /<br\s*\/?>/gi;
html = html.replace(regBr, "\n");
const regWithoutHtmlExceptImg = /<(?!img\s*\/?)[^>]+>/gi;
return html.replace(regWithoutHtmlExceptImg, "");
};
@@ -0,0 +1,47 @@
import { Modal, ModalProps } from "antd";
import { FC, memo, useRef, useState } from "react";
import type { DraggableData, DraggableEvent } from "react-draggable";
import Draggable from "react-draggable";
interface IDraggableModalWrapProps extends ModalProps {
ignoreClasses?: string;
}
const DraggableModalWrap: FC<IDraggableModalWrapProps> = (props) => {
const [bounds, setBounds] = useState({ left: 0, top: 0, bottom: 0, right: 0 });
const draggleRef = useRef<HTMLDivElement>(null);
const onStart = (_event: DraggableEvent, uiData: DraggableData) => {
const { clientWidth, clientHeight } = window.document.documentElement;
const targetRect = draggleRef.current?.getBoundingClientRect();
if (!targetRect) {
return;
}
setBounds({
left: -targetRect.left + uiData.x,
right: clientWidth - (targetRect.right - uiData.x),
top: -targetRect.top + uiData.y,
bottom: clientHeight - (targetRect.bottom - uiData.y),
});
};
return (
<Modal
{...props}
modalRender={(modal) => (
<Draggable
allowAnyClick
cancel={props.ignoreClasses}
bounds={bounds}
onStart={(event, uiData) => onStart(event, uiData)}
>
<div ref={draggleRef}>{modal}</div>
</Draggable>
)}
>
{props.children}
</Modal>
);
};
export default memo(DraggableModalWrap);
@@ -0,0 +1,101 @@
import { EnterOutlined } from "@ant-design/icons";
import { useClickAway } from "ahooks";
import { Input, InputRef } from "antd";
import clsx from "clsx";
import { FC, useRef, useState } from "react";
import edit_name from "@/assets/images/chatSetting/edit_name.png";
interface IEditableContentProps {
editable?: boolean;
value?: string;
placeholder?: string;
className?: string;
textClassName?: string;
onChange?: (value: string) => Promise<void>;
}
const EditableContent: FC<IEditableContentProps> = ({
editable,
value,
placeholder,
className,
textClassName,
onChange,
}) => {
const wrapRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<InputRef>(null);
const [editState, setEditState] = useState({
isEdit: false,
loading: false,
innerValue: value,
});
useClickAway(() => {
if (editState.isEdit) {
setEditState({
isEdit: false,
loading: false,
innerValue: value,
});
}
}, [wrapRef]);
const toggleEdit = (e: React.MouseEvent<HTMLImageElement, MouseEvent>) => {
e.stopPropagation();
setEditState({
isEdit: true,
loading: false,
innerValue: value === "-" ? "" : value,
});
setTimeout(() => inputRef.current?.focus());
};
const onPressEnter = async (
e: React.KeyboardEvent<HTMLInputElement> & { target: { value: string } },
) => {
setEditState((state) => ({ ...state, loading: true }));
await onChange?.(e.target.value);
setEditState({
isEdit: false,
loading: false,
innerValue: e.target.value,
});
};
return (
<div ref={wrapRef} className={clsx("ml-3 flex items-center", className)}>
{editState.isEdit ? (
<Input
spellCheck={false}
value={editState.innerValue}
placeholder={placeholder}
maxLength={20}
onChange={(e) =>
setEditState((state) => ({ ...state, innerValue: e.target.value }))
}
ref={inputRef}
onPressEnter={onPressEnter}
suffix={<EnterOutlined rev={undefined} />}
/>
) : (
<>
<div className={clsx("mr-1 max-w-[240px] truncate", textClassName)}>
{value}
</div>
{editable && (
<img
className="cursor-pointer"
width={14}
src={edit_name}
alt="edit name"
onClick={toggleEdit}
/>
)}
</>
)}
</div>
);
};
export default EditableContent;
@@ -0,0 +1,37 @@
import { Component, ErrorInfo, ReactNode } from "react";
type ErrorBoundaryProps = {
logTips?: string;
placeholder: ReactNode;
children: ReactNode;
};
type ErrorBoundaryState = {
hasError: boolean;
logTips?: string;
};
class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
constructor(props: ErrorBoundaryProps) {
super(props);
this.state = { hasError: false, logTips: props.logTips };
}
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error(this.state.logTips ?? "ErrorBoundary");
console.error(errorInfo);
console.error(error);
this.setState({ hasError: true });
}
render() {
if (this.state.hasError) {
return this.props.placeholder;
}
return this.props.children;
}
}
export default ErrorBoundary;
@@ -0,0 +1,22 @@
.sider_resize {
width: 300px;
min-width: 240px;
max-width: 45vw;
resize: horizontal;
overflow: scroll;
height: 100%;
opacity: 0;
&::-webkit-scrollbar {
height: calc(100vh - 40px);
}
}
.sider_bar {
position: absolute;
right: 0;
top: 0;
bottom: 0;
border-left: 1px solid var(--gap-text);
pointer-events: none;
}
@@ -0,0 +1,34 @@
import clsx from "clsx";
import * as React from "react";
import styles from "./flexible-sider.module.scss";
const FlexibleSider = ({
needHidden,
children,
wrapClassName,
}: {
needHidden: boolean;
wrapClassName?: string;
children: React.ReactNode;
}) => (
<aside
className={clsx(
"relative bg-white dark:text-white",
{ "max-[600px]:hidden": needHidden },
{ "max-[600px]:!max-w-none max-[600px]:!basis-full": !needHidden },
)}
>
<div
className={`absolute bottom-0 left-0 right-1 top-0 z-10 overflow-hidden ${
wrapClassName ?? ""
}`}
>
{children}
</div>
<div className={styles.sider_resize}></div>
<div className={styles.sider_bar}></div>
</aside>
);
export default FlexibleSider;
@@ -0,0 +1,91 @@
import { Avatar as AntdAvatar, AvatarProps } from "antd";
import clsx from "clsx";
import * as React from "react";
import { useMemo } from "react";
import default_group from "@/assets/images/contact/group.png";
import { avatarList, getDefaultAvatar } from "@/utils/avatar";
const default_avatars = avatarList.map((item) => item.name);
// 头像文字:两个字以内原样显示;中文名取最后一个字,其他取首字母
const getDisplayText = (text?: string) => {
if (!text) return text;
const trimmed = text.trim();
if (trimmed.length <= 2) return trimmed;
if (/^[一-龥]/.test(trimmed)) return trimmed.slice(-1);
return trimmed[0].toUpperCase();
};
interface IOIMAvatarProps extends AvatarProps {
text?: string;
color?: string;
bgColor?: string;
isgroup?: boolean;
isnotification?: boolean;
size?: number;
}
const OIMAvatar: React.FC<IOIMAvatarProps> = (props) => {
const {
src,
text,
size = 42,
color = "#fff",
bgColor = "#3B87F5",
isgroup = false,
isnotification,
} = props;
const [errorHolder, setErrorHolder] = React.useState<string>();
const getAvatarUrl = useMemo(() => {
if (src) {
if (default_avatars.includes(src as string))
return getDefaultAvatar(src as string);
return src;
}
return isgroup ? default_group : undefined;
}, [src, isgroup, isnotification]);
const avatarProps = { ...props, isgroup: undefined, isnotification: undefined };
React.useEffect(() => {
if (!isgroup) {
setErrorHolder(undefined);
}
}, [isgroup]);
const errorHandler = () => {
if (isgroup) {
setErrorHolder(default_group);
}
};
return (
<AntdAvatar
style={{
backgroundColor: bgColor,
minWidth: `${size}px`,
minHeight: `${size}px`,
lineHeight: `${size - 2}px`,
borderRadius: `${Math.round(size * 0.18)}px`,
color,
}}
shape="square"
{...avatarProps}
className={clsx(
{
"cursor-pointer": Boolean(props.onClick),
},
props.className,
)}
src={errorHolder ?? getAvatarUrl}
onError={errorHandler as any}
>
{getDisplayText(text)}
</AntdAvatar>
);
};
export default OIMAvatar;
@@ -0,0 +1,53 @@
import { Switch } from "antd";
import clsx from "clsx";
import { FC, ReactNode, useState } from "react";
interface ISettingRowProps {
title: string;
value?: boolean;
hidden?: boolean;
className?: string;
children?: ReactNode;
tryChange?: (checked: boolean) => Promise<void>;
rowClick?: () => void;
}
const SettingRow: FC<ISettingRowProps> = ({
title,
value,
hidden,
className,
children,
tryChange,
rowClick,
}) => {
const [loading, setLoading] = useState(false);
const onClick = async (checked: boolean) => {
setLoading(true);
await tryChange?.(checked);
setLoading(false);
};
if (hidden) {
return null;
}
return (
<div
className={clsx("flex items-center justify-between p-4", className)}
onClick={rowClick}
>
<div className="font-medium">{title}</div>
{children ?? (
<Switch
className="bg-[var(--sub-text)]"
loading={loading}
checked={value}
onClick={onClick}
/>
)}
</div>
);
};
export default SettingRow;
@@ -0,0 +1,47 @@
import { Platform } from "@openim/wasm-client-sdk";
import { useKeyPress } from "ahooks";
import win_close from "@/assets/images/topSearchBar/win_close.png";
import win_max from "@/assets/images/topSearchBar/win_max.png";
import win_min from "@/assets/images/topSearchBar/win_min.png";
const WindowControlBar = () => {
useKeyPress("esc", () => {
window.electronAPI?.ipcInvoke("minimizeWindow");
});
if (!window.electronAPI || window.electronAPI?.getPlatform() === Platform.MacOSX) {
return null;
}
return (
<div className="absolute right-3 top-3.5 z-[99999999] flex h-fit items-center">
<div
className="app-no-drag flex h-[14px] cursor-pointer items-center"
onClick={() => window.electronAPI?.ipcInvoke("minimizeWindow")}
>
<img
className="app-no-drag cursor-pointer"
width={14}
src={win_min}
alt="win_min"
/>
</div>
<img
className="app-no-drag mx-3 cursor-pointer"
width={13}
src={win_max}
alt="win_max"
onClick={() => window.electronAPI?.ipcInvoke("maxmizeWindow")}
/>
<img
className="app-no-drag cursor-pointer"
width={12}
src={win_close}
alt="win_close"
onClick={() => window.electronAPI?.ipcInvoke("closeWindow")}
/>
</div>
);
};
export default WindowControlBar;