FEAT => 本次更新项目为:

更新模块完成
This commit is contained in:
超级老白兔
2025-08-05 11:59:18 +08:00
parent 192c8c3c0a
commit 57a32e252d
17 changed files with 646 additions and 190 deletions

View File

@@ -44,25 +44,6 @@
this.iframeUrl = queryString ? `${this.baseUrl}?${queryString}` : this.baseUrl;
},
// 发送消息到 iframe通过URL传参
async sendMessageToIframe() {
const paddingTop = await getTopSafeAreaHeightAsync();
this.messageId++;
const message = {
type: TYPE_EMUE.DATA, // 数据类型0数据交互 1App功能调用
data: {
id: this.messageId,
content: `Hello我是 App 发送的消息 ${this.messageId}`,
timestamp: Date.now(),
paddingTop: paddingTop
}
};
// 将消息添加到URL参数中
this.urlParams.message = encodeURIComponent(JSON.stringify(message));
this.buildIframeUrl();
console.log('[App]SendMessage=>\n' + JSON.stringify(message));
},
// 发送消息到 iframe通过URL传参
async sendBaseConfig() {
const message = {
type: TYPE_EMUE.CONFIG,
@@ -71,6 +52,7 @@
appId: '1234567890',
appName: '存客宝',
appVersion: '1.0.0',
isAppMode:true
}
};
@@ -91,9 +73,26 @@
break;
case TYPE_EMUE.FUNCTION:
console.log('[App]ReceiveMessage=>\n' + JSON.stringify(ResDetail.data));
if (ResDetail.data.action === 'clearCache') {
this.clearCache();
}
break;
}
},
clearCache() {
// 清除 webview 缓存
if (this.$refs.webviewRef) {
// 重新加载 webview
this.$refs.webviewRef.reload();
}
// 清除 webview 缓存数据
uni.clearStorage({
success: () => {
console.log('Webview 缓存已清除');
}
});
}
}
}

View File

@@ -1,9 +1,12 @@
import React from "react";
import AppRouter from "@/router";
import UpdateNotification from "@/components/UpdateNotification";
function App() {
return (
<>
<AppRouter />
<UpdateNotification position="top" autoReload={false} showToast={true} />
</>
);
}

View File

@@ -0,0 +1,180 @@
import React, { useState, useEffect } from "react";
import { Button } from "antd-mobile";
import { updateChecker } from "@/utils/updateChecker";
import {
ReloadOutlined,
CloudDownloadOutlined,
RocketOutlined,
} from "@ant-design/icons";
interface UpdateNotificationProps {
position?: "top" | "bottom";
autoReload?: boolean;
showToast?: boolean;
}
const UpdateNotification: React.FC<UpdateNotificationProps> = ({
position = "top",
autoReload = false,
showToast = true,
}) => {
const [hasUpdate, setHasUpdate] = useState(false);
const [isVisible, setIsVisible] = useState(false);
useEffect(() => {
// 注册更新检测回调
const handleUpdate = (info: { hasUpdate: boolean }) => {
if (info.hasUpdate) {
setHasUpdate(true);
setIsVisible(true);
if (autoReload) {
// 自动刷新
setTimeout(() => {
updateChecker.forceReload();
}, 3000);
}
}
};
updateChecker.onUpdate(handleUpdate);
// 启动更新检测
updateChecker.start();
return () => {
updateChecker.offUpdate(handleUpdate);
updateChecker.stop();
};
}, [autoReload, showToast]);
const handleReload = () => {
updateChecker.forceReload();
};
// if (!isVisible || !hasUpdate) {
// return null;
// }
return (
<div
style={{
position: "fixed",
top: 0,
left: 0,
right: 0,
bottom: 0,
zIndex: 99999,
background: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
color: "white",
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
padding: "20px",
textAlign: "center",
}}
>
{/* 背景装饰 */}
<div
style={{
position: "absolute",
top: "10%",
left: "50%",
transform: "translateX(-50%)",
fontSize: "120px",
opacity: 0.1,
animation: "float 3s ease-in-out infinite",
}}
>
<RocketOutlined />
</div>
{/* 主要内容 */}
<div style={{ position: "relative", zIndex: 1 }}>
{/* 图标 */}
<div
style={{
fontSize: "80px",
marginBottom: "20px",
animation: "pulse 2s ease-in-out infinite",
}}
>
<CloudDownloadOutlined />
</div>
{/* 标题 */}
<div
style={{
fontSize: "28px",
fontWeight: "bold",
marginBottom: "12px",
textShadow: "0 2px 4px rgba(0,0,0,0.3)",
}}
>
</div>
{/* 描述 */}
<div
style={{
fontSize: "16px",
opacity: 0.9,
marginBottom: "40px",
lineHeight: "1.5",
maxWidth: "300px",
}}
>
</div>
{/* 更新按钮 */}
<Button
size="large"
style={{
background: "rgba(255,255,255,0.2)",
border: "2px solid rgba(255,255,255,0.3)",
color: "white",
fontSize: "18px",
fontWeight: "bold",
padding: "12px 40px",
borderRadius: "50px",
backdropFilter: "blur(10px)",
boxShadow: "0 8px 32px rgba(0,0,0,0.3)",
transition: "all 0.3s ease",
}}
onClick={handleReload}
>
<ReloadOutlined style={{ marginRight: "8px" }} />
</Button>
{/* 提示文字 */}
<div
style={{
fontSize: "12px",
opacity: 0.7,
marginTop: "20px",
}}
>
</div>
</div>
{/* 动画样式 */}
<style>
{`
@keyframes float {
0%, 100% { transform: translateX(-50%) translateY(0px); }
50% { transform: translateX(-50%) translateY(-20px); }
}
@keyframes pulse {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.05); }
}
`}
</style>
</div>
);
};
export default UpdateNotification;

View File

@@ -4,7 +4,13 @@ import Layout from "@/components/Layout/Layout";
import NavCommon from "@/components/NavCommon";
import { Input } from "antd";
import { useNavigate } from "react-router-dom";
import { useSettingsStore } from "@/store/module/settings";
import {
sendMessageToParent,
parseUrlMessage,
Message,
TYPE_EMUE,
} from "@/utils/postApp";
// 声明全局的 uni 对象
declare global {
interface Window {
@@ -12,55 +18,18 @@ declare global {
}
}
interface Message {
type: number; // 数据类型0数据交互 1App功能调用
data: any;
}
const TYPE_EMUE = {
CONNECT: 0,
DATA: 1,
FUNCTION: 2,
CONFIG: 3,
};
const IframeDebugPage: React.FC = () => {
const { setSettings } = useSettingsStore();
const [receivedMessages, setReceivedMessages] = useState<string[]>([]);
const [messageId, setMessageId] = useState(0);
const [inputMessage, setInputMessage] = useState("");
const navigate = useNavigate();
// 解析 URL 参数中的消息
const parseUrlMessage = () => {
const search = window.location.search.substring(1);
let messageParam = null;
if (search) {
const pairs = search.split("&");
for (const pair of pairs) {
const [key, value] = pair.split("=");
if (key === "message" && value) {
messageParam = decodeURIComponent(value);
break;
}
}
parseUrlMessage().then(message => {
if (message) {
handleReceivedMessage(message);
}
if (messageParam) {
try {
const message = JSON.parse(decodeURIComponent(messageParam));
console.log("[存客宝]ReceiveMessage=>\n" + JSON.stringify(message));
handleReceivedMessage(message);
// 清除URL中的message参数
const newUrl =
window.location.pathname +
window.location.search
.replace(/[?&]message=[^&]*/, "")
.replace(/^&/, "?");
window.history.replaceState({}, "", newUrl);
} catch (e) {
console.error("解析URL消息失败:", e);
}
}
};
});
useEffect(() => {
parseUrlMessage();
@@ -71,35 +40,19 @@ const IframeDebugPage: React.FC = () => {
const handleReceivedMessage = (message: Message) => {
const messageText = `[${new Date().toLocaleTimeString()}] 收到: ${JSON.stringify(message)}`;
setReceivedMessages(prev => [...prev, messageText]);
console.log("message.type", message.type);
if ([TYPE_EMUE.CONFIG].includes(message.type)) {
localStorage.setItem("paddingTop", message.data.paddingTop);
localStorage.setItem("isAppMode", "true");
const { paddingTop, appId, appName, appVersion } = message.data;
setSettings({
paddingTop,
appId,
appName,
appVersion,
isAppMode: true,
});
navigate("/");
}
};
// 向 App 发送消息
const sendMessageToParent = (message: Message) => {
if (window.uni && window.uni.postMessage) {
try {
window.uni.postMessage({
data: message,
});
console.log("[存客宝]SendMessage=>\n" + JSON.stringify(message));
} catch (e) {
console.error(
"[存客宝]SendMessage=>\n" + JSON.stringify(message) + "发送失败:",
e,
);
}
} else {
console.error(
"[存客宝]SendMessage=>\n" + JSON.stringify(message) + "无法发送消息",
);
}
};
// 发送自定义消息到 App
const sendCustomMessage = () => {
if (!inputMessage.trim()) return;
@@ -107,17 +60,14 @@ const IframeDebugPage: React.FC = () => {
const newMessageId = messageId + 1;
setMessageId(newMessageId);
const message: Message = {
type: TYPE_EMUE.DATA, // 数据交互
data: {
id: newMessageId,
content: inputMessage,
source: "存客宝消息源",
timestamp: Date.now(),
},
const message = {
id: newMessageId,
content: inputMessage,
source: "存客宝消息源",
timestamp: Date.now(),
};
sendMessageToParent(message);
sendMessageToParent(message, TYPE_EMUE.DATA);
setInputMessage("");
};
@@ -126,33 +76,27 @@ const IframeDebugPage: React.FC = () => {
const newMessageId = messageId + 1;
setMessageId(newMessageId);
const message: Message = {
type: TYPE_EMUE.DATA, // 数据交互
data: {
id: newMessageId,
action: "ping",
content: `存客宝测试消息 ${newMessageId}`,
random: Math.random(),
},
const message = {
id: newMessageId,
action: "ping",
content: `存客宝测试消息 ${newMessageId}`,
random: Math.random(),
};
sendMessageToParent(message);
sendMessageToParent(message, TYPE_EMUE.DATA);
};
// 发送App功能调用消息
const sendAppFunctionCall = () => {
const message: Message = {
type: 1, // App功能调用
data: {
action: "showToast",
params: {
title: "来自H5的功能调用",
icon: "success",
},
const message = {
action: "showToast",
params: {
title: "来自H5的功能调用",
icon: "success",
},
};
sendMessageToParent(message);
sendMessageToParent(message, TYPE_EMUE.FUNCTION);
};
// 清空消息列表

View File

@@ -1,5 +1,5 @@
import React, { useState, useEffect } from "react";
import { useNavigate, useSearchParams } from "react-router-dom";
import { Form, Input, Button, Toast, Checkbox } from "antd-mobile";
import {
EyeInvisibleOutline,
@@ -9,8 +9,6 @@ import {
import { useUserStore } from "@/store/module/user";
import { loginWithPassword, loginWithCode, sendVerificationCode } from "./api";
import style from "./login.module.scss";
import Layout from "@/components/Layout/Layout";
import NavCommon from "@/components/NavCommon";
const Login: React.FC = () => {
const [form] = Form.useForm();
@@ -20,8 +18,6 @@ const Login: React.FC = () => {
const [showPassword, setShowPassword] = useState(false);
const [agreeToTerms, setAgreeToTerms] = useState(false);
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const { login } = useUserStore();
// 倒计时效果
@@ -32,16 +28,6 @@ const Login: React.FC = () => {
}
}, [countdown]);
// 检查URL是否为登录页面
const isLoginPage = (url: string) => {
try {
const urlObj = new URL(url, window.location.origin);
return urlObj.pathname === "/login" || urlObj.pathname.endsWith("/login");
} catch {
return false;
}
};
// 发送验证码
const handleSendVerificationCode = async () => {
const account = form.getFieldValue("account");
@@ -95,24 +81,12 @@ const Login: React.FC = () => {
} else {
response = await loginWithCode(loginParams);
}
console.log(response, "response");
// 获取设备总数
const deviceTotal = response.deviceTotal || 0;
console.log(deviceTotal, "deviceTotal");
// 更新状态管理token会自动存储到localStorage用户信息存储在状态管理中
login(response.token, response.member, deviceTotal);
Toast.show({ content: "登录成功", position: "top" });
// 根据设备数量判断跳转
if (deviceTotal > 0) {
navigate("/");
} else {
// 没有设备,跳转到引导页面
navigate("/guide");
}
} catch (error: any) {
// 错误已在request中处理这里不需要额外处理
} finally {

View File

@@ -18,6 +18,7 @@ import {
getDashboard,
} from "./api";
import style from "./index.module.scss";
import UpdateNotification from "@/components/UpdateNotification";
interface DashboardData {
deviceNum?: number;
@@ -253,6 +254,7 @@ const Home: React.FC = () => {
</div>
</div>
</div>
<UpdateNotification position="top" autoReload={false} showToast={true} />
</Layout>
);
};

View File

@@ -250,7 +250,7 @@ const Devices: React.FC = () => {
</>
}
footer={
<div style={{ padding: 16, textAlign: "center", background: "#fff" }}>
<div className="pagination-container">
<Pagination
current={page}
pageSize={20}

View File

@@ -87,7 +87,7 @@ const About: React.FC = () => {
<div className={style["app-info"]}>
<div className={style["app-logo"]}>
<div className={style["logo-placeholder"]}>
<img src="/public/logo.png" alt="logo" />
<img src="/logo.png" alt="logo" />
</div>
</div>
<div className={style["app-details"]}>

View File

@@ -1,6 +1,6 @@
import React, { useState } from "react";
import { useNavigate } from "react-router-dom";
import { NavBar, List, Switch, Button, Dialog, Toast, Card } from "antd-mobile";
import { List, Switch, Button, Dialog, Toast, Card } from "antd-mobile";
import {
UserOutlined,
SafetyOutlined,
@@ -8,14 +8,16 @@ import {
LogoutOutlined,
SettingOutlined,
LockOutlined,
HeartOutlined,
StarOutlined,
ReloadOutlined,
} from "@ant-design/icons";
import Layout from "@/components/Layout/Layout";
import { useUserStore } from "@/store/module/user";
import { useSettingsStore } from "@/store/module/settings";
import style from "./index.module.scss";
import NavCommon from "@/components/NavCommon";
import { sendMessageToParent, TYPE_EMUE } from "@/utils/postApp";
import { updateChecker } from "@/utils/updateChecker";
interface SettingItem {
id: string;
title: string;
@@ -32,7 +34,7 @@ interface SettingItem {
const Setting: React.FC = () => {
const navigate = useNavigate();
const { user, logout } = useUserStore();
const { settings, updateSetting } = useSettingsStore();
const { settings } = useSettingsStore();
const [showLogoutDialog, setShowLogoutDialog] = useState(false);
const [avatarError, setAvatarError] = useState(false);
@@ -57,13 +59,30 @@ const Setting: React.FC = () => {
Dialog.confirm({
content: "确定要清除缓存吗?这将清除所有本地数据。",
onConfirm: () => {
localStorage.clear();
sessionStorage.clear();
sendMessageToParent(
{
action: "clearCache",
},
TYPE_EMUE.FUNCTION,
);
},
});
};
// 在设置页面添加手动检查更新功能
const handleCheckUpdate = () => {
updateChecker.checkForUpdate().then(result => {
if (result.hasUpdate) {
Toast.show({
content: "缓存已清除",
content: "发现新版本,请刷新页面",
position: "top",
});
},
} else {
Toast.show({
content: "当前已是最新版本",
position: "top",
});
}
});
};
@@ -114,6 +133,15 @@ const Setting: React.FC = () => {
color: "var(--primary-color)",
badge: "2.3MB",
},
{
id: "checkUpdate",
title: "检查更新",
description: "检查应用是否有新版本",
icon: <ReloadOutlined />,
type: "button",
onClick: handleCheckUpdate,
color: "var(--primary-color)",
},
],
},
{
@@ -258,12 +286,14 @@ const Setting: React.FC = () => {
<div className={style["version-info"]}>
<div className={style["version-card"]}>
<div className={style["app-logo"]}>
<img src="/public/logo.png" alt="" />
<img src="/logo.png" alt="" />
</div>
<div className={style["version-details"]}>
<div className={style["app-name"]}></div>
<div className={style["version-text"]}> 3.0.0</div>
<div className={style["build-info"]}>Build 2025-7-30</div>
<div className={style["version-text"]}>
{settings.appVersion}
</div>
<div className={style["build-info"]}>Build 2025-08-04</div>
</div>
</div>
<div className={style["copyright"]}>

View File

@@ -65,23 +65,6 @@ const ScenarioList: React.FC = () => {
const [total, setTotal] = useState(0);
const pageSize = 20;
// 获取渠道中文名称
const getChannelName = (channel: string) => {
const channelMap: Record<string, string> = {
douyin: "抖音直播获客",
kuaishou: "快手直播获客",
xiaohongshu: "小红书种草获客",
weibo: "微博话题获客",
haibao: "海报扫码获客",
phone: "电话号码获客",
gongzhonghao: "公众号引流获客",
weixinqun: "微信群裂变获客",
payment: "付款码获客",
api: "API接口获客",
};
return channelMap[channel] || `${channel}获客`;
};
// 获取计划列表数据
const fetchPlanList = async (page: number, isLoadMore: boolean = false) => {
if (!scenarioId) return;
@@ -409,7 +392,7 @@ const ScenarioList: React.FC = () => {
}
loading={loading}
footer={
<div className={style["pagination-container"]}>
<div className="pagination-container">
<Pagination
total={total}
pageSize={pageSize}

View File

@@ -1,17 +1,11 @@
import { createPersistStore } from "@/store/createPersistStore";
export interface AppSettings {
// 应用设置
language: string;
timezone: string;
// 隐私设置
analyticsEnabled: boolean;
crashReportEnabled: boolean;
// 功能设置
autoSave: boolean;
showTutorial: boolean;
paddingTop: number;
appId: string;
appName: string;
appVersion: string;
isAppMode: boolean;
}
interface SettingsState {
@@ -26,12 +20,11 @@ interface SettingsState {
// 默认设置
const defaultSettings: AppSettings = {
language: "zh-CN",
timezone: "Asia/Shanghai",
analyticsEnabled: true,
crashReportEnabled: true,
autoSave: true,
showTutorial: true,
paddingTop: 0,
appId: "",
appName: "",
appVersion: "",
isAppMode: false,
};
export const useSettingsStore = createPersistStore<SettingsState>(

View File

@@ -1,4 +1,5 @@
import { createPersistStore } from "@/store/createPersistStore";
import { Toast } from "antd-mobile";
export interface User {
id: number;
@@ -60,6 +61,16 @@ export const useUserStore = createPersistStore<UserState>(
deviceTotal: deviceTotal,
};
set({ user, token, isLoggedIn: true });
Toast.show({ content: "登录成功", position: "top" });
// 根据设备数量判断跳转
if (deviceTotal > 0) {
window.location.href = "/";
} else {
// 没有设备,跳转到引导页面
window.location.href = "/guide";
}
},
logout: () => {
// 清除localStorage中的token

View File

@@ -264,3 +264,43 @@ button {
align-items: center;
gap: 6px;
}
.pagination-container {
display: flex;
justify-content: center;
padding: 14px 0;
background: white;
border-radius: 12px;
margin-top: 16px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
:global(.ant-pagination) {
.ant-pagination-item {
border-radius: 6px;
border: 1px solid #d9d9d9;
&:hover {
border-color: var(--primary-color);
}
&.ant-pagination-item-active {
background: var(--primary-color);
border-color: var(--primary-color);
a {
color: white;
}
}
}
.ant-pagination-prev,
.ant-pagination-next {
border-radius: 6px;
border: 1px solid #d9d9d9;
&:hover {
border-color: var(--primary-color);
color: var(--primary-color);
}
}
}
}

View File

@@ -1,5 +1,5 @@
import { Modal } from "antd-mobile";
import { getSetting } from "@/store/module/settings";
/**
* 通用js调用弹窗Promise风格
* @param content 弹窗内容
@@ -49,7 +49,7 @@ export function getSafeAreaHeight() {
// 2. 设备检测
const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent);
const isAndroid = /Android/.test(navigator.userAgent);
const isAppMode = Boolean(localStorage.getItem("isAppMode"));
const isAppMode = getSetting("isAppMode");
if (isIOS && isAppMode) {
// iOS 设备
const isIPhoneX = window.screen.height >= 812;

View File

@@ -0,0 +1,72 @@
export interface Message {
type: number; // 数据类型0数据交互 1App功能调用
data: any;
}
export const TYPE_EMUE = {
CONNECT: 0,
DATA: 1,
FUNCTION: 2,
CONFIG: 3,
};
// 向 App 发送消息
export const sendMessageToParent = (message: any, type: number) => {
const params: Message = {
type: type,
data: message,
};
if (window.uni && window.uni.postMessage) {
try {
window.uni.postMessage({
data: params,
});
console.log("[存客宝]SendMessage=>\n" + JSON.stringify(params));
} catch (e) {
console.error(
"[存客宝]SendMessage=>\n" + JSON.stringify(params) + "发送失败:",
e,
);
}
} else {
console.error(
"[存客宝]SendMessage=>\n" + JSON.stringify(params) + "无法发送消息",
);
}
};
// 解析 URL 参数中的消息
export const parseUrlMessage = (): Promise<any> => {
return new Promise((resolve, reject) => {
const search = window.location.search.substring(1);
let messageParam = null;
if (search) {
const pairs = search.split("&");
for (const pair of pairs) {
const [key, value] = pair.split("=");
if (key === "message" && value) {
messageParam = decodeURIComponent(value);
break;
}
}
}
if (messageParam) {
try {
const message = JSON.parse(decodeURIComponent(messageParam));
console.log("[存客宝]ReceiveMessage=>\n" + JSON.stringify(message));
resolve(message);
// 清除URL中的message参数
const newUrl =
window.location.pathname +
window.location.search
.replace(/[?&]message=[^&]*/, "")
.replace(/^&/, "?");
window.history.replaceState({}, "", newUrl);
} catch (e) {
console.error("解析URL消息失败:", e);
reject(e);
}
}
reject(null);
});
};

View File

@@ -0,0 +1,217 @@
/**
* 应用更新检测工具
*/
interface UpdateInfo {
hasUpdate: boolean;
version?: string;
timestamp?: number;
}
class UpdateChecker {
private currentVersion: string;
private checkInterval: number = 1000; // 1秒检查一次用于测试
private intervalId: NodeJS.Timeout | null = null;
private updateCallbacks: ((info: UpdateInfo) => void)[] = [];
private currentHashes: string[] = [];
constructor() {
// 从package.json获取版本号
this.currentVersion = import.meta.env.VITE_APP_VERSION || "1.0.0";
// 初始化当前哈希值
this.initCurrentHashes();
}
/**
* 初始化当前哈希值
*/
private initCurrentHashes() {
// 从当前页面的资源中提取哈希值
const scripts = document.querySelectorAll("script[src]");
const links = document.querySelectorAll("link[href]");
const scriptHashes = Array.from(scripts)
.map(script => script.getAttribute("src"))
.filter(
src => src && (src.includes("assets/") || src.includes("/assets/")),
)
.map(src => {
// 修改正则表达式,匹配包含字母、数字和下划线的哈希值
const match = src?.match(/[a-zA-Z0-9_-]{8,}/);
return match ? match[0] : "";
})
.filter(hash => hash);
const linkHashes = Array.from(links)
.map(link => link.getAttribute("href"))
.filter(
href => href && (href.includes("assets/") || href.includes("/assets/")),
)
.map(href => {
// 修改正则表达式,匹配包含字母、数字和下划线的哈希值
const match = href?.match(/[a-zA-Z0-9_-]{8,}/);
return match ? match[0] : "";
})
.filter(hash => hash);
this.currentHashes = [...new Set([...scriptHashes, ...linkHashes])];
}
/**
* 开始检测更新
*/
start() {
if (this.intervalId) {
return;
}
// 立即检查一次
this.checkForUpdate();
// 设置定时检查
this.intervalId = setInterval(() => {
this.checkForUpdate();
}, this.checkInterval);
}
/**
* 停止检测更新
*/
stop() {
if (this.intervalId) {
clearInterval(this.intervalId);
this.intervalId = null;
}
}
/**
* 检查更新
*/
async checkForUpdate(): Promise<UpdateInfo> {
try {
// 获取新的manifest文件
let manifestResponse;
let manifestPath = "/.vite/manifest.json";
try {
manifestResponse = await fetch(manifestPath, {
cache: "no-cache",
headers: {
"Cache-Control": "no-cache",
Pragma: "no-cache",
},
});
} catch (error) {
// 如果.vite路径失败尝试根路径
manifestPath = "/manifest.json";
manifestResponse = await fetch(manifestPath, {
cache: "no-cache",
headers: {
"Cache-Control": "no-cache",
Pragma: "no-cache",
},
});
}
if (!manifestResponse.ok) {
return { hasUpdate: false };
}
const manifest = await manifestResponse.json();
// 从Vite manifest中提取文件哈希
const newHashes: string[] = [];
Object.values(manifest).forEach((entry: any) => {
if (entry.file && entry.file.includes("assets/")) {
// console.log("处理manifest entry file:", entry.file);
// 修改正则表达式,匹配包含字母、数字和下划线的哈希值
const match = entry.file.match(/[a-zA-Z0-9_-]{8,}/);
if (match) {
const hash = match[0];
newHashes.push(hash);
}
}
// 也检查CSS文件
if (entry.css) {
entry.css.forEach((cssFile: string) => {
if (cssFile.includes("assets/")) {
// console.log("处理manifest entry css:", cssFile);
// 修改正则表达式,匹配包含字母、数字和下划线的哈希值
const match = cssFile.match(/[a-zA-Z0-9_-]{8,}/);
if (match) {
const hash = match[0];
// console.log("提取的manifest css哈希:", hash);
newHashes.push(hash);
}
}
});
}
});
// 去重新哈希值数组
const uniqueNewHashes = [...new Set(newHashes)];
// 比较哈希值
const hasUpdate = this.compareHashes(this.currentHashes, uniqueNewHashes);
const updateInfo: UpdateInfo = {
hasUpdate,
version: manifest.version || this.currentVersion,
timestamp: Date.now(),
};
// 通知所有回调
this.updateCallbacks.forEach(callback => callback(updateInfo));
return updateInfo;
} catch (error) {
return { hasUpdate: false };
}
}
/**
* 比较哈希值
*/
private compareHashes(current: string[], newHashes: string[]): boolean {
if (current.length !== newHashes.length) {
return true;
}
// 对两个数组进行排序后比较,忽略顺序
const sortedCurrent = [...current].sort();
const sortedNewHashes = [...newHashes].sort();
const hasUpdate = sortedCurrent.some((hash, index) => {
return hash !== sortedNewHashes[index];
});
return hasUpdate;
}
/**
* 注册更新回调
*/
onUpdate(callback: (info: UpdateInfo) => void) {
this.updateCallbacks.push(callback);
}
/**
* 移除更新回调
*/
offUpdate(callback: (info: UpdateInfo) => void) {
const index = this.updateCallbacks.indexOf(callback);
if (index > -1) {
this.updateCallbacks.splice(index, 1);
}
}
/**
* 强制刷新页面
*/
forceReload() {
window.location.reload();
}
}
export const updateChecker = new UpdateChecker();

View File

@@ -38,5 +38,13 @@ export default defineConfig({
minify: "esbuild",
// 启用源码映射(可选,生产环境可以关闭)
sourcemap: false,
// 生成manifest文件
manifest: true,
},
define: {
// 注入版本信息
"import.meta.env.VITE_APP_VERSION": JSON.stringify(
process.env.npm_package_version,
),
},
});