新增 VITE_API_WS_URL 環境變數,更新主程式以引入嚴格模式包裝器,並在登錄頁面中整合觸客寶用戶信息獲取功能,調整請求模組以動態獲取 token2。

This commit is contained in:
超级老白兔
2025-08-18 11:30:56 +08:00
parent 7dd20b0a9b
commit 1b1bd7536d
11 changed files with 825 additions and 11 deletions

View File

@@ -1,5 +1,6 @@
# 基础环境变量示例
VITE_API_BASE_URL=http://www.yishi.com
VITE_API_BASE_URL2=https://kf.quwanzhi.com:9991
VITE_API_WS_URL=wss://kf.quwanzhi.com:9993
# VITE_API_BASE_URL=https://ckbapi.quwanzhi.com
VITE_APP_TITLE=存客宝

View File

@@ -1,5 +1,6 @@
# 基础环境变量示例
VITE_API_BASE_URL=https://ckbapi.quwanzhi.com
VITE_API_BASE_URL2=https://kf.quwanzhi.com:9991
VITE_API_WS_URL=wss://kf.quwanzhi.com:9993
# VITE_API_BASE_URL=http://www.yishi.com
VITE_APP_TITLE=存客宝

View File

@@ -6,7 +6,6 @@ import axios, {
} from "axios";
import { Toast } from "antd-mobile";
import { useUserStore } from "@/store/module/user";
const { token2 } = useUserStore.getState();
const DEFAULT_DEBOUNCE_GAP = 1000;
const debounceMap = new Map<string, number>();
@@ -27,9 +26,12 @@ const instance: AxiosInstance = axios.create({
});
instance.interceptors.request.use((config: any) => {
// 在每次请求时动态获取最新的 token2
const { token2 } = useUserStore.getState();
if (token2) {
config.headers = config.headers || {};
config.headers["Authorization"] = `Bearer ${token2}`;
config.headers["Authorization"] = `bearer ${token2}`;
}
return config;
});

View File

@@ -0,0 +1,250 @@
import React, { useEffect, useState } from "react";
import { Button, Card, List, Badge, Toast } from "antd-mobile";
import {
useWebSocketStore,
WebSocketStatus,
WebSocketMessage,
} from "@/store/module/websocket";
/**
* WebSocket使用示例组件
* 展示如何使用WebSocket store进行消息收发
*/
const WebSocketExample: React.FC = () => {
const [messageInput, setMessageInput] = useState("");
// 使用WebSocket store
const {
status,
messages,
unreadCount,
connect,
disconnect,
sendMessage,
sendCommand,
clearMessages,
markAsRead,
reconnect,
} = useWebSocketStore();
// 连接状态显示
const getStatusText = () => {
switch (status) {
case WebSocketStatus.DISCONNECTED:
return "未连接";
case WebSocketStatus.CONNECTING:
return "连接中...";
case WebSocketStatus.CONNECTED:
return "已连接";
case WebSocketStatus.RECONNECTING:
return "重连中...";
case WebSocketStatus.ERROR:
return "连接错误";
default:
return "未知状态";
}
};
// 获取状态颜色
const getStatusColor = () => {
switch (status) {
case WebSocketStatus.CONNECTED:
return "success";
case WebSocketStatus.CONNECTING:
case WebSocketStatus.RECONNECTING:
return "warning";
case WebSocketStatus.ERROR:
return "danger";
default:
return "default";
}
};
// 发送消息
const handleSendMessage = () => {
if (!messageInput.trim()) {
Toast.show({ content: "请输入消息内容", position: "top" });
return;
}
sendMessage({
type: "chat",
content: {
text: messageInput,
timestamp: Date.now(),
},
sender: "user",
receiver: "all",
});
setMessageInput("");
};
// 发送命令
const handleSendCommand = (cmdType: string) => {
sendCommand(cmdType, {
data: "示例数据",
timestamp: Date.now(),
});
};
// 格式化时间
const formatTime = (timestamp: number) => {
return new Date(timestamp).toLocaleTimeString();
};
return (
<div style={{ padding: "16px" }}>
<Card title="WebSocket 连接状态">
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
<Badge color={getStatusColor()}>
<div style={{ width: "8px", height: "8px", borderRadius: "50%" }} />
</Badge>
<span>{getStatusText()}</span>
</div>
<div
style={{
marginTop: "16px",
display: "flex",
gap: "8px",
flexWrap: "wrap",
}}
>
<Button
size="small"
color="primary"
onClick={() =>
connect({
client: "kefu-client",
autoReconnect: true,
})
}
disabled={
status === WebSocketStatus.CONNECTING ||
status === WebSocketStatus.CONNECTED
}
>
</Button>
<Button
size="small"
color="danger"
onClick={disconnect}
disabled={status === WebSocketStatus.DISCONNECTED}
>
</Button>
<Button
size="small"
color="warning"
onClick={reconnect}
disabled={status === WebSocketStatus.CONNECTED}
>
</Button>
</div>
</Card>
<Card
title={`消息列表 ${unreadCount > 0 ? `(${unreadCount} 条未读)` : ""}`}
extra={
<div style={{ display: "flex", gap: "8px" }}>
<Button size="small" onClick={markAsRead}>
</Button>
<Button size="small" onClick={clearMessages}>
</Button>
</div>
}
style={{ marginTop: "16px" }}
>
<List style={{ maxHeight: "300px", overflowY: "auto" }}>
{messages.length === 0 ? (
<List.Item></List.Item>
) : (
messages.map((message: WebSocketMessage) => (
<List.Item key={message.id}>
<div style={{ fontSize: "12px", color: "#666" }}>
{formatTime(message.timestamp)} - {message.type}
</div>
<div style={{ marginTop: "4px" }}>
{typeof message.content === "string"
? message.content
: JSON.stringify(message.content, null, 2)}
</div>
</List.Item>
))
)}
</List>
</Card>
<Card title="发送消息" style={{ marginTop: "16px" }}>
<div style={{ display: "flex", gap: "8px", marginBottom: "12px" }}>
<input
type="text"
value={messageInput}
onChange={e => setMessageInput(e.target.value)}
placeholder="输入消息内容"
style={{
flex: 1,
padding: "8px 12px",
border: "1px solid #d9d9d9",
borderRadius: "4px",
fontSize: "14px",
}}
onKeyPress={e => e.key === "Enter" && handleSendMessage()}
/>
<Button
color="primary"
onClick={handleSendMessage}
disabled={status !== WebSocketStatus.CONNECTED}
>
</Button>
</div>
<div style={{ display: "flex", gap: "8px", flexWrap: "wrap" }}>
<Button
size="small"
onClick={() => handleSendCommand("CmdHeartbeat")}
disabled={status !== WebSocketStatus.CONNECTED}
>
</Button>
<Button
size="small"
onClick={() => handleSendCommand("CmdGetStatus")}
disabled={status !== WebSocketStatus.CONNECTED}
>
</Button>
<Button
size="small"
onClick={() => handleSendCommand("CmdSignIn")}
disabled={status !== WebSocketStatus.CONNECTED}
>
</Button>
</div>
</Card>
<Card title="使用说明" style={{ marginTop: "16px" }}>
<div style={{ fontSize: "14px", lineHeight: "1.6", color: "#666" }}>
<p>1. "连接"WebSocket连接</p>
<p>2. </p>
<p>3. </p>
<p>4. </p>
<p>5. </p>
</div>
</Card>
</div>
);
};
export default WebSocketExample;

View File

@@ -2,7 +2,15 @@ import React from "react";
import { createRoot } from "react-dom/client";
import App from "./App";
import "./styles/global.scss";
// 引入错误处理器来抑制findDOMNode警告
import "./utils/errorHandler";
import StrictModeWrapper from "./components/StrictModeWrapper";
// import VConsole from "vconsole";
// new VConsole();
const root = createRoot(document.getElementById("root")!);
root.render(<App />);
root.render(
<StrictModeWrapper enableStrictMode={false}>
<App />
</StrictModeWrapper>
);

View File

@@ -7,11 +7,14 @@ import {
UserOutline,
} from "antd-mobile-icons";
import { useUserStore } from "@/store/module/user";
import { useCkChatStore } from "@/store/module/ckchat";
import { useWebSocketStore } from "@/store/module/websocket";
import {
loginWithPassword,
loginWithCode,
sendVerificationCode,
loginWithToken,
getChuKeBaoUserInfo,
} from "./api";
import style from "./login.module.scss";
@@ -24,6 +27,7 @@ const Login: React.FC = () => {
const [agreeToTerms, setAgreeToTerms] = useState(false);
const { login, login2 } = useUserStore();
const { setUserInfo, getAccountId } = useCkChatStore();
// 倒计时效果
useEffect(() => {
@@ -71,15 +75,26 @@ const Login: React.FC = () => {
Toast.show({ content: "请同意用户协议和隐私政策", position: "top" });
return;
}
setLoading(true);
getToken(values)
.then(() => {
getToken2();
})
.finally(() => {
setLoading(false);
getToken(values).then(() => {
getChuKeBaoUserInfo().then(res => {
setUserInfo(res);
getToken2().then(Token => {
// // 使用WebSocket store连接
// const { connect } = useWebSocketStore.getState();
// connect({
// accessToken: Token,
// accountId: getAccountId()?.toString() || "",
// client: "kefu-client",
// autoReconnect: true,
// reconnectInterval: 3000,
// maxReconnectAttempts: 5,
// });
});
});
setLoading(false);
});
};
const getToken = (values: any) => {
return new Promise((resolve, reject) => {
// 添加typeId参数
@@ -118,7 +133,7 @@ const Login: React.FC = () => {
const response = loginWithToken(params);
response.then(res => {
login2(res.access_token);
resolve(res);
resolve(res.access_token);
});
response.catch(err => {
reject(err);

View File

@@ -24,6 +24,12 @@ export function logout() {
export function getUserInfo() {
return request("/v1/auth/user-info", {}, "GET");
}
// ==================================================================
// 触客宝接口; 2025年8月16日 17:19:15
// 开发yongpxu
// ==================================================================
//触客宝登陆
export function loginWithToken(params: any) {
return request2(
@@ -38,3 +44,8 @@ export function loginWithToken(params: any) {
1000,
);
}
// 获取触客宝用户信息
export function getChuKeBaoUserInfo() {
return request2("/api/account/self", {}, "GET");
}

View File

@@ -2,11 +2,13 @@
export * from "./module/user";
export * from "./module/app";
export * from "./module/settings";
export * from "./module/websocket";
// 导入store实例
import { useUserStore } from "./module/user";
import { useAppStore } from "./module/app";
import { useSettingsStore } from "./module/settings";
import { useWebSocketStore } from "./module/websocket";
// 导出持久化store创建函数
export {
@@ -32,6 +34,7 @@ export interface StoreState {
user: ReturnType<typeof useUserStore.getState>;
app: ReturnType<typeof useAppStore.getState>;
settings: ReturnType<typeof useSettingsStore.getState>;
websocket: ReturnType<typeof useWebSocketStore.getState>;
}
// 便利的store访问函数
@@ -39,12 +42,14 @@ export const getStores = (): StoreState => ({
user: useUserStore.getState(),
app: useAppStore.getState(),
settings: useSettingsStore.getState(),
websocket: useWebSocketStore.getState(),
});
// 获取特定store状态
export const getUserStore = () => useUserStore.getState();
export const getAppStore = () => useAppStore.getState();
export const getSettingsStore = () => useSettingsStore.getState();
export const getWebSocketStore = () => useWebSocketStore.getState();
// 清除所有持久化数据(使用工具函数)
export const clearAllPersistedData = clearAllData;
@@ -56,6 +61,7 @@ export const getPersistKeys = () => Object.values(PERSIST_KEYS);
export const subscribeToUserStore = useUserStore.subscribe;
export const subscribeToAppStore = useAppStore.subscribe;
export const subscribeToSettingsStore = useSettingsStore.subscribe;
export const subscribeToWebSocketStore = useWebSocketStore.subscribe;
// 组合订阅函数
export const subscribeToAllStores = (callback: (state: StoreState) => void) => {
@@ -68,10 +74,14 @@ export const subscribeToAllStores = (callback: (state: StoreState) => void) => {
const unsubscribeSettings = useSettingsStore.subscribe(() => {
callback(getStores());
});
const unsubscribeWebSocket = useWebSocketStore.subscribe(() => {
callback(getStores());
});
return () => {
unsubscribeUser();
unsubscribeApp();
unsubscribeSettings();
unsubscribeWebSocket();
};
};

View File

@@ -0,0 +1,51 @@
// 账户信息接口
export interface CkAccount {
id: number;
realName: string;
nickname: string | null;
memo: string | null;
avatar: string;
userName: string;
secret: string;
accountType: number;
departmentId: number;
useGoogleSecretKey: boolean;
hasVerifyGoogleSecret: boolean;
}
// 权限片段接口
export interface PrivilegeFrag {
// 根据实际数据结构补充
[key: string]: any;
}
// 租户信息接口
export interface CkTenant {
id: number;
name: string;
guid: string;
thirdParty: string | null;
tenantType: number;
deployName: string;
}
// 触客宝用户信息接口
export interface CkUserInfo {
account: CkAccount;
privilegeFrags: PrivilegeFrag[];
tenant: CkTenant;
}
// 状态接口
export interface CkChatState {
userInfo: CkUserInfo | null;
isLoggedIn: boolean;
setUserInfo: (userInfo: CkUserInfo) => void;
clearUserInfo: () => void;
updateAccount: (account: Partial<CkAccount>) => void;
updateTenant: (tenant: Partial<CkTenant>) => void;
getAccountId: () => number | null;
getTenantId: () => number | null;
getAccountName: () => string | null;
getTenantName: () => string | null;
}

View File

@@ -0,0 +1,89 @@
import { createPersistStore } from "@/store/createPersistStore";
import { CkChatState, CkUserInfo, CkAccount, CkTenant } from "./ckchat.data";
export const useCkChatStore = createPersistStore<CkChatState>(
set => ({
userInfo: null,
isLoggedIn: false,
// 设置用户信息
setUserInfo: (userInfo: CkUserInfo) => {
set({ userInfo, isLoggedIn: true });
},
// 清除用户信息
clearUserInfo: () => {
set({ userInfo: null, isLoggedIn: false });
},
// 更新账户信息
updateAccount: (account: Partial<CkAccount>) => {
set(state => ({
userInfo: state.userInfo
? {
...state.userInfo,
account: { ...state.userInfo.account, ...account },
}
: null,
}));
},
// 更新租户信息
updateTenant: (tenant: Partial<CkTenant>) => {
set(state => ({
userInfo: state.userInfo
? {
...state.userInfo,
tenant: { ...state.userInfo.tenant, ...tenant },
}
: null,
}));
},
// 获取账户ID
getAccountId: () => {
const state = useCkChatStore.getState();
return state.userInfo?.account?.id || null;
},
// 获取租户ID
getTenantId: () => {
const state = useCkChatStore.getState();
return state.userInfo?.tenant?.id || null;
},
// 获取账户名称
getAccountName: () => {
const state = useCkChatStore.getState();
return (
state.userInfo?.account?.realName ||
state.userInfo?.account?.userName ||
null
);
},
// 获取租户名称
getTenantName: () => {
const state = useCkChatStore.getState();
return state.userInfo?.tenant?.name || null;
},
}),
{
name: "ckchat-store",
partialize: state => ({
userInfo: state.userInfo,
isLoggedIn: state.isLoggedIn,
}),
onRehydrateStorage: () => state => {
// console.log("CkChat store hydrated:", state);
},
},
);
// 导出便捷的获取方法
export const getCkAccountId = () => useCkChatStore.getState().getAccountId();
export const getCkTenantId = () => useCkChatStore.getState().getTenantId();
export const getCkAccountName = () =>
useCkChatStore.getState().getAccountName();
export const getCkTenantName = () => useCkChatStore.getState().getTenantName();

View File

@@ -0,0 +1,376 @@
import { createPersistStore } from "@/store/createPersistStore";
import { Toast } from "antd-mobile";
import { useUserStore } from "./user";
// WebSocket消息类型
export interface WebSocketMessage {
id: string;
type: string;
content: any;
timestamp: number;
sender?: string;
receiver?: string;
}
// WebSocket连接状态
export enum WebSocketStatus {
DISCONNECTED = "disconnected",
CONNECTING = "connecting",
CONNECTED = "connected",
RECONNECTING = "reconnecting",
ERROR = "error",
}
// WebSocket配置
interface WebSocketConfig {
url: string;
client: string;
accountId: string;
accessToken: string;
autoReconnect: boolean;
reconnectInterval: number;
maxReconnectAttempts: number;
}
interface WebSocketState {
// 连接状态
status: WebSocketStatus;
ws: WebSocket | null;
// 配置信息
config: WebSocketConfig | null;
// 消息相关
messages: WebSocketMessage[];
unreadCount: number;
// 重连相关
reconnectAttempts: number;
reconnectTimer: NodeJS.Timeout | null;
// 方法
connect: (config: Partial<WebSocketConfig>) => void;
disconnect: () => void;
sendMessage: (message: Omit<WebSocketMessage, "id" | "timestamp">) => void;
sendCommand: (cmdType: string, data?: any) => void;
clearMessages: () => void;
markAsRead: () => void;
reconnect: () => void;
// 内部方法
_handleOpen: () => void;
_handleMessage: (event: MessageEvent) => void;
_handleClose: (event: CloseEvent) => void;
_handleError: (event: Event) => void;
_startReconnectTimer: () => void;
_stopReconnectTimer: () => void;
}
// 默认配置
const DEFAULT_CONFIG: WebSocketConfig = {
url: (import.meta as any).env?.VITE_API_WS_URL || "ws://localhost:8080",
client: "kefu-client",
accountId: "",
accessToken: "",
autoReconnect: true,
reconnectInterval: 3000,
maxReconnectAttempts: 5,
};
export const useWebSocketStore = createPersistStore<WebSocketState>(
(set, get) => ({
status: WebSocketStatus.DISCONNECTED,
ws: null,
config: null,
messages: [],
unreadCount: 0,
reconnectAttempts: 0,
reconnectTimer: null,
// 连接WebSocket
connect: (config: Partial<WebSocketConfig>) => {
const currentState = get();
// 如果已经连接,先断开
if (currentState.ws) {
currentState.disconnect();
}
// 合并配置
const fullConfig: WebSocketConfig = {
...DEFAULT_CONFIG,
...config,
};
// 获取用户信息
const { token, token2, user } = useUserStore.getState();
const accessToken = fullConfig.accessToken || token2 || token;
if (!accessToken) {
Toast.show({ content: "未找到有效的访问令牌", position: "top" });
return;
}
// 构建WebSocket URL
const params = {
client: fullConfig.client,
accountId: fullConfig.accountId || user?.s2_accountId || "",
accessToken: accessToken,
t: Date.now().toString(),
};
const wsUrl =
fullConfig.url + "?" + new URLSearchParams(params).toString();
set({
status: WebSocketStatus.CONNECTING,
config: fullConfig,
});
try {
const ws = new WebSocket(wsUrl);
// 绑定事件处理器
ws.onopen = () => get()._handleOpen();
ws.onmessage = event => get()._handleMessage(event);
ws.onclose = event => get()._handleClose(event);
ws.onerror = event => get()._handleError(event);
set({ ws });
console.log("WebSocket连接创建成功", wsUrl);
} catch (error) {
console.error("WebSocket连接失败:", error);
set({ status: WebSocketStatus.ERROR });
Toast.show({ content: "WebSocket连接失败", position: "top" });
}
},
// 断开连接
disconnect: () => {
const currentState = get();
if (currentState.ws) {
currentState.ws.close();
}
currentState._stopReconnectTimer();
set({
status: WebSocketStatus.DISCONNECTED,
ws: null,
reconnectAttempts: 0,
});
console.log("WebSocket连接已断开");
},
// 发送消息
sendMessage: (message: Omit<WebSocketMessage, "id" | "timestamp">) => {
const currentState = get();
if (
currentState.status !== WebSocketStatus.CONNECTED ||
!currentState.ws
) {
Toast.show({ content: "WebSocket未连接", position: "top" });
return;
}
const fullMessage: WebSocketMessage = {
...message,
id: Date.now().toString(),
timestamp: Date.now(),
};
try {
currentState.ws.send(JSON.stringify(fullMessage));
console.log("消息发送成功:", fullMessage);
} catch (error) {
console.error("消息发送失败:", error);
Toast.show({ content: "消息发送失败", position: "top" });
}
},
// 发送命令
sendCommand: (cmdType: string, data?: any) => {
const currentState = get();
if (
currentState.status !== WebSocketStatus.CONNECTED ||
!currentState.ws
) {
Toast.show({ content: "WebSocket未连接", position: "top" });
return;
}
const { user } = useUserStore.getState();
const { token, token2 } = useUserStore.getState();
const accessToken = token2 || token;
const command = {
accessToken: accessToken,
accountId: user?.s2_accountId,
client: currentState.config?.client || "kefu-client",
cmdType: cmdType,
seq: Date.now(),
...data,
};
try {
currentState.ws.send(JSON.stringify(command));
console.log("命令发送成功:", command);
} catch (error) {
console.error("命令发送失败:", error);
Toast.show({ content: "命令发送失败", position: "top" });
}
},
// 清除消息
clearMessages: () => {
set({ messages: [], unreadCount: 0 });
},
// 标记为已读
markAsRead: () => {
set({ unreadCount: 0 });
},
// 重连
reconnect: () => {
const currentState = get();
if (currentState.config) {
currentState.connect(currentState.config);
}
},
// 内部方法:处理连接打开
_handleOpen: () => {
const currentState = get();
set({
status: WebSocketStatus.CONNECTED,
reconnectAttempts: 0,
});
console.log("WebSocket连接成功");
// 发送登录命令
if (currentState.config) {
currentState.sendCommand("CmdSignIn");
}
Toast.show({ content: "WebSocket连接成功", position: "top" });
},
// 内部方法:处理消息接收
_handleMessage: (event: MessageEvent) => {
try {
const data = JSON.parse(event.data);
console.log("收到WebSocket消息:", data);
const currentState = get();
const newMessage: WebSocketMessage = {
id: Date.now().toString(),
type: data.type || "message",
content: data,
timestamp: Date.now(),
sender: data.sender,
receiver: data.receiver,
};
set({
messages: [...currentState.messages, newMessage],
unreadCount: currentState.unreadCount + 1,
});
// 可以在这里添加消息处理逻辑
// 比如播放提示音、显示通知等
} catch (error) {
console.error("解析WebSocket消息失败:", error);
}
},
// 内部方法:处理连接关闭
_handleClose: (event: CloseEvent) => {
const currentState = get();
console.log("WebSocket连接关闭:", event.code, event.reason);
set({
status: WebSocketStatus.DISCONNECTED,
ws: null,
});
// 自动重连逻辑
if (
currentState.config?.autoReconnect &&
currentState.reconnectAttempts <
(currentState.config?.maxReconnectAttempts || 5)
) {
currentState._startReconnectTimer();
}
},
// 内部方法:处理连接错误
_handleError: (event: Event) => {
console.error("WebSocket连接错误:", event);
set({ status: WebSocketStatus.ERROR });
Toast.show({ content: "WebSocket连接错误", position: "top" });
},
// 内部方法:启动重连定时器
_startReconnectTimer: () => {
const currentState = get();
currentState._stopReconnectTimer();
set({
status: WebSocketStatus.RECONNECTING,
reconnectAttempts: currentState.reconnectAttempts + 1,
});
const timer = setTimeout(() => {
console.log(
`尝试重连 (${currentState.reconnectAttempts + 1}/${currentState.config?.maxReconnectAttempts})`,
);
currentState.reconnect();
}, currentState.config?.reconnectInterval || 3000);
set({ reconnectTimer: timer });
},
// 内部方法:停止重连定时器
_stopReconnectTimer: () => {
const currentState = get();
if (currentState.reconnectTimer) {
clearTimeout(currentState.reconnectTimer);
set({ reconnectTimer: null });
}
},
}),
{
name: "websocket-store",
partialize: state => ({
// 只持久化必要的状态不持久化WebSocket实例
status: state.status,
config: state.config,
messages: state.messages.slice(-100), // 只保留最近100条消息
unreadCount: state.unreadCount,
reconnectAttempts: state.reconnectAttempts,
}),
onRehydrateStorage: () => state => {
// 页面刷新后,如果之前是连接状态,尝试重新连接
if (state && state.status === WebSocketStatus.CONNECTED && state.config) {
// 延迟一下再重连,确保页面完全加载
setTimeout(() => {
state.connect(state.config);
}, 1000);
}
},
},
);