432 lines
13 KiB
Plaintext
432 lines
13 KiB
Plaintext
import { computed, reactive } from "vue";
|
||
import { SUCCESS_CODE } from "@/constants/codes.constants";
|
||
import {
|
||
DEFAULT_LANG,
|
||
I18N_LANG_LIST_STORAGE_KEY,
|
||
I18N_LANG_MAP_STORAGE_KEY,
|
||
I18N_LANG_STORAGE_KEY,
|
||
} from "@/constants/i18n.constants";
|
||
import {
|
||
getLocalFallback,
|
||
resolveLiteralKey,
|
||
toBundleKey,
|
||
} from "@/constants/i18n.local.constants";
|
||
import { apiI18nLangList, apiI18nQuery, apiI18nSetLang } from "@/servers/i18n";
|
||
import type { I18nLangDto, I18nState } from "@/types/interfaces/i18n";
|
||
import { syncThirdPartyLocales } from "@/utils/i18n-third-party";
|
||
|
||
const parseJSON = <T = any,>(value: string, fallback: T): T => {
|
||
if (!value) return fallback;
|
||
try {
|
||
return JSON.parse(value) as T;
|
||
} catch (error) {
|
||
return fallback;
|
||
}
|
||
};
|
||
|
||
const isH5Platform = (): boolean => {
|
||
// #ifdef H5 || WEB
|
||
return true;
|
||
// #endif
|
||
return false;
|
||
};
|
||
|
||
const isMpWeixinPlatform = (): boolean => {
|
||
// #ifdef MP-WEIXIN
|
||
return true;
|
||
// #endif
|
||
return false;
|
||
};
|
||
|
||
const getBrowserLang = (): string => {
|
||
// #ifdef H5 || WEB
|
||
const navLang = navigator?.language || DEFAULT_LANG;
|
||
return navLang;
|
||
// #endif
|
||
// #ifdef MP-WEIXIN
|
||
try {
|
||
const sysLang = uni.getSystemInfoSync().language;
|
||
if (sysLang) return sysLang;
|
||
} catch (e) {}
|
||
// #endif
|
||
return DEFAULT_LANG;
|
||
};
|
||
|
||
export const normalizeLang = (lang: string): string => {
|
||
const source = (lang || "").trim();
|
||
if (!source) return DEFAULT_LANG;
|
||
|
||
const bundleKey = toBundleKey(source);
|
||
|
||
// 将 bundle key 转换为 PascalCase 显示格式
|
||
if (bundleKey === "zh-cn") return "zh-CN";
|
||
if (bundleKey === "zh-tw") return "zh-TW";
|
||
if (bundleKey === "zh-hk") return "zh-HK";
|
||
if (bundleKey === "en-us") return "en-US";
|
||
|
||
// 未知语言保持原始归一化形式
|
||
return source.replaceAll("_", "-");
|
||
};
|
||
|
||
const getStoredLang = (): string => {
|
||
const cachedLang = uni.getStorageSync(I18N_LANG_STORAGE_KEY);
|
||
if (cachedLang) return normalizeLang(cachedLang);
|
||
return "";
|
||
};
|
||
|
||
const setStoredLang = (lang: string) => {
|
||
uni.setStorageSync(I18N_LANG_STORAGE_KEY, normalizeLang(lang));
|
||
};
|
||
|
||
const setStoredLangMap = (langMap: Record<string, string>) => {
|
||
uni.setStorageSync(I18N_LANG_MAP_STORAGE_KEY, JSON.stringify(langMap || {}));
|
||
};
|
||
|
||
const setStoredLangList = (langList: I18nLangDto[]) => {
|
||
uni.setStorageSync(
|
||
I18N_LANG_LIST_STORAGE_KEY,
|
||
JSON.stringify(langList || []),
|
||
);
|
||
};
|
||
|
||
const getCachedLangMap = (): Record<string, string> => {
|
||
const text = uni.getStorageSync(I18N_LANG_MAP_STORAGE_KEY) || "";
|
||
return parseJSON<Record<string, string>>(text, {});
|
||
};
|
||
|
||
const getCachedLangList = (): I18nLangDto[] => {
|
||
const text = uni.getStorageSync(I18N_LANG_LIST_STORAGE_KEY) || "";
|
||
return parseJSON<I18nLangDto[]>(text, []);
|
||
};
|
||
|
||
const sortLangList = (list: I18nLangDto[]): I18nLangDto[] => {
|
||
const filtered = (list || []).filter((item) => item?.status === 1);
|
||
return filtered.sort((a, b) => {
|
||
if (a.sort !== b.sort) return a.sort - b.sort;
|
||
return a.id - b.id;
|
||
});
|
||
};
|
||
|
||
const getDefaultLangFromList = (list: I18nLangDto[]): string => {
|
||
const defaultItem = list.find((item) => item?.isDefault === 1);
|
||
return normalizeLang(defaultItem?.lang || DEFAULT_LANG);
|
||
};
|
||
|
||
export const i18nState = reactive<I18nState>({
|
||
currentLang: DEFAULT_LANG,
|
||
langMap: {},
|
||
langList: [],
|
||
loaded: false,
|
||
loading: false,
|
||
});
|
||
let loadI18nTask: Promise<void> | null = null;
|
||
let loadLangListTask: Promise<void> | null = null;
|
||
|
||
const TAB_BAR_I18N_ITEMS: Array<{ index: number; key: string }> = [
|
||
{ index: 0, key: "Mobile.Nav.home" },
|
||
{ index: 1, key: "Mobile.Nav.unionRecord" },
|
||
{ index: 2, key: "Mobile.Nav.agent" },
|
||
{ index: 3, key: "Mobile.Nav.app" },
|
||
];
|
||
|
||
const fillTemplate = (
|
||
source: string,
|
||
params: Record<string, string | number> = {},
|
||
): string => {
|
||
let output = source || "";
|
||
Object.keys(params || {}).forEach((key) => {
|
||
const val = String(params[key] ?? "");
|
||
output = output.replace(new RegExp(`\\{${key}\\}`, "g"), val);
|
||
});
|
||
return output;
|
||
};
|
||
|
||
const pickLangForInit = (): string => {
|
||
const cacheLang = getStoredLang();
|
||
const browserLang = normalizeLang(getBrowserLang());
|
||
return normalizeLang(cacheLang || browserLang || DEFAULT_LANG);
|
||
};
|
||
|
||
const fetchLangList = async (): Promise<I18nLangDto[]> => {
|
||
// 允许在 H5 和微信小程序环境调用接口
|
||
if (isH5Platform() || isMpWeixinPlatform()) {
|
||
try {
|
||
const res = await apiI18nLangList();
|
||
if (res?.code === SUCCESS_CODE && Array.isArray(res?.data)) {
|
||
const sortedList = sortLangList(res.data);
|
||
setStoredLangList(sortedList);
|
||
return sortedList;
|
||
}
|
||
} catch (error) {
|
||
console.error("fetchLangList failed:", error);
|
||
}
|
||
}
|
||
return sortLangList(getCachedLangList());
|
||
};
|
||
|
||
const fetchLangMap = async (lang: string = "", active: boolean = false): Promise<Record<string, string>> => {
|
||
// 允许在 H5 和微信小程序环境调用接口
|
||
if (!isH5Platform() && !isMpWeixinPlatform()) {
|
||
return {};
|
||
}
|
||
|
||
// 仅在主动切换 (active=true) 且有有效 lang 时才透传给后端,
|
||
// 否则传空字符串,后端将返回默认翻译而不会修改用户配置。
|
||
const targetLang = (active && lang) ? normalizeLang(lang) : "";
|
||
try {
|
||
// lang 为空时由后端按用户已保存语言/命中逻辑返回对应词典
|
||
const res = await apiI18nQuery(targetLang);
|
||
// 只有成功且有数据才返回数据,其余(失败、异常、data=null)统统返回空对象以触发兜底逻辑
|
||
if (res?.code === SUCCESS_CODE && res?.data) {
|
||
return res.data;
|
||
}
|
||
} catch (error) {
|
||
console.error("fetchLangMap failed:", error);
|
||
}
|
||
return {};
|
||
};
|
||
|
||
export const bootstrapI18nCache = () => {
|
||
const lang = getStoredLang();
|
||
i18nState.currentLang = normalizeLang(lang || pickLangForInit());
|
||
i18nState.langMap = getCachedLangMap();
|
||
i18nState.langList = sortLangList(getCachedLangList());
|
||
i18nState.loaded = Object.keys(i18nState.langMap || {}).length > 0;
|
||
syncThirdPartyLocales(i18nState.currentLang);
|
||
applyTabBarI18n();
|
||
};
|
||
|
||
const resolveMessage = (
|
||
lang: string,
|
||
keyOrText: string,
|
||
fallback: string = "",
|
||
): string => {
|
||
const key = resolveLiteralKey(keyOrText);
|
||
|
||
// 1. 优先尝试从服务端下发的语言包匹配
|
||
const langMap = i18nState.langMap;
|
||
if (langMap != null) {
|
||
const fromServer = langMap[key];
|
||
// 如果 key 存在(即便 value 是空字符串)
|
||
if (fromServer !== undefined) {
|
||
// 如果 value 为空字符串,则返回 key 本身;否则返回翻译值(保持同 PC 端一致)
|
||
return fromServer === "" ? key : fromServer;
|
||
}
|
||
}
|
||
|
||
// 2. 映射未命中或原生端通过本地包兜底
|
||
const currentLangKey = toBundleKey(i18nState.currentLang || DEFAULT_LANG);
|
||
// 如果当前是中文类语言,优先尝试本地中文包兜底(实现原生端默认中文需求)
|
||
if (currentLangKey.startsWith("zh")) {
|
||
const zhFallbackValue = getLocalFallback(currentLangKey, key);
|
||
if (zhFallbackValue) return zhFallbackValue;
|
||
}
|
||
|
||
// 3. 全局英语兜底(仅当非中文且远程失败,或中文本地包仍缺失时)
|
||
const enFallbackValue = getLocalFallback("en-us", key);
|
||
if (enFallbackValue) return enFallbackValue;
|
||
|
||
// 3. 最后兜底返回
|
||
return fallback || keyOrText;
|
||
};
|
||
|
||
export const t = (
|
||
key: string,
|
||
params: Record<string, string | number> = {},
|
||
fallback: string = "",
|
||
): string => {
|
||
const lang = normalizeLang(i18nState.currentLang || DEFAULT_LANG);
|
||
const value = resolveMessage(lang, key, fallback);
|
||
return fillTemplate(value, params);
|
||
};
|
||
|
||
export const translateText = (
|
||
text: string,
|
||
params: Record<string, string | number> = {},
|
||
): string => {
|
||
if (!text) return "";
|
||
return t(text, params, text);
|
||
};
|
||
|
||
export const applyTabBarI18n = () => {
|
||
// #ifndef MP-WEIXIN || H5 || WEB
|
||
return;
|
||
// #endif
|
||
|
||
TAB_BAR_I18N_ITEMS.forEach((item) => {
|
||
try {
|
||
uni.setTabBarItem({
|
||
index: item.index,
|
||
text: t(item.key),
|
||
fail: (err) => {
|
||
// Ignore tabBar sync failures on non-tab pages.
|
||
},
|
||
});
|
||
} catch (error) {
|
||
// Ignore tabBar sync failures on non-tab pages.
|
||
}
|
||
});
|
||
};
|
||
|
||
export const loadI18n = async (active: boolean = false) => {
|
||
if (loadI18nTask) {
|
||
await loadI18nTask;
|
||
return;
|
||
}
|
||
|
||
loadI18nTask = (async () => {
|
||
i18nState.loading = true;
|
||
try {
|
||
const initLang = pickLangForInit();
|
||
let targetLang = initLang;
|
||
|
||
// 如果翻译包为空(接口失败或返回 null),统一强制回退到本地英语态作为安全兜底
|
||
i18nState.currentLang = normalizeLang(targetLang);
|
||
const langMap = await fetchLangMap(targetLang, active);
|
||
|
||
// 如果翻译包为空(接口失败或返回 null),且非中文环境,统一回退到本地英语态作为安全兜底
|
||
if (Object.keys(langMap).length === 0 && !targetLang.toLowerCase().startsWith("zh")) {
|
||
i18nState.currentLang = normalizeLang("en-us");
|
||
i18nState.langMap = {};
|
||
} else {
|
||
i18nState.currentLang = normalizeLang(targetLang);
|
||
i18nState.langMap = langMap;
|
||
}
|
||
|
||
i18nState.loaded = true;
|
||
setStoredLang(i18nState.currentLang);
|
||
setStoredLangMap(i18nState.langMap);
|
||
syncThirdPartyLocales(i18nState.currentLang);
|
||
applyTabBarI18n();
|
||
} finally {
|
||
i18nState.loading = false;
|
||
}
|
||
})();
|
||
|
||
try {
|
||
await loadI18nTask;
|
||
} finally {
|
||
loadI18nTask = null;
|
||
}
|
||
};
|
||
|
||
/**
|
||
* 确保语言列表已加载(按需加载)
|
||
*/
|
||
export const ensureLangList = async () => {
|
||
if (loadLangListTask) {
|
||
await loadLangListTask;
|
||
return;
|
||
}
|
||
|
||
loadLangListTask = (async () => {
|
||
try {
|
||
const list = await fetchLangList();
|
||
i18nState.langList = list;
|
||
} catch (error) {
|
||
console.error("ensureLangList failed:", error);
|
||
}
|
||
})();
|
||
|
||
try {
|
||
await loadLangListTask;
|
||
} finally {
|
||
loadLangListTask = null;
|
||
}
|
||
};
|
||
|
||
export const setLanguage = async (lang: string): Promise<boolean> => {
|
||
const targetLang = normalizeLang(lang);
|
||
if (!targetLang) return false;
|
||
|
||
const previousLang = i18nState.currentLang;
|
||
const previousLangMap = i18nState.langMap;
|
||
|
||
try {
|
||
const queryResult = await apiI18nQuery(targetLang);
|
||
|
||
// 接口请求失败或业务码错误,执行统一英语兜底
|
||
if (!queryResult || queryResult.code !== SUCCESS_CODE || !queryResult.data) {
|
||
i18nState.currentLang = normalizeLang("en-us");
|
||
i18nState.langMap = {};
|
||
} else {
|
||
i18nState.currentLang = targetLang;
|
||
i18nState.langMap = queryResult.data;
|
||
}
|
||
|
||
setStoredLang(i18nState.currentLang);
|
||
setStoredLangMap(i18nState.langMap);
|
||
syncThirdPartyLocales(i18nState.currentLang);
|
||
applyTabBarI18n();
|
||
return true;
|
||
} catch (error) {
|
||
console.error("setLanguage failed, perform English fallback:", error);
|
||
// 接口异常,执行统一英语兜底
|
||
i18nState.currentLang = normalizeLang("en-us");
|
||
i18nState.langMap = {};
|
||
setStoredLang(i18nState.currentLang);
|
||
setStoredLangMap(i18nState.langMap);
|
||
syncThirdPartyLocales(i18nState.currentLang);
|
||
applyTabBarI18n();
|
||
return true; // 即使异常也视为“处理成功(切换到了兜底态)”以避免业务阻塞
|
||
}
|
||
};
|
||
|
||
export const syncLanguageFromUser = async (
|
||
lang: string = "",
|
||
): Promise<void> => {
|
||
const userLang = normalizeLang(lang);
|
||
if (!userLang) return;
|
||
|
||
if (userLang === normalizeLang(i18nState.currentLang)) {
|
||
setStoredLang(userLang);
|
||
if (Object.keys(i18nState.langMap || {}).length === 0) {
|
||
const latestLangMap = await fetchLangMap(userLang, false);
|
||
// 如果翻译包为空且本地已为空,强制开启英语兜底状态
|
||
if (Object.keys(latestLangMap).length === 0) {
|
||
i18nState.currentLang = normalizeLang("en-us");
|
||
i18nState.langMap = {};
|
||
} else {
|
||
i18nState.langMap = latestLangMap;
|
||
}
|
||
setStoredLangMap(i18nState.langMap);
|
||
setStoredLang(i18nState.currentLang);
|
||
syncThirdPartyLocales(i18nState.currentLang);
|
||
applyTabBarI18n();
|
||
}
|
||
return;
|
||
}
|
||
|
||
i18nState.currentLang = userLang;
|
||
setStoredLang(userLang);
|
||
|
||
const latestLangMap = await fetchLangMap(userLang, false);
|
||
// 如果同步中翻译包为空(接口异常等),统一回退到英语
|
||
if (Object.keys(latestLangMap).length === 0) {
|
||
i18nState.currentLang = normalizeLang("en-us");
|
||
i18nState.langMap = {};
|
||
} else {
|
||
i18nState.langMap = latestLangMap;
|
||
}
|
||
|
||
setStoredLangMap(i18nState.langMap);
|
||
setStoredLang(i18nState.currentLang);
|
||
|
||
syncThirdPartyLocales(i18nState.currentLang);
|
||
applyTabBarI18n();
|
||
};
|
||
|
||
export const useI18n = () => {
|
||
return {
|
||
i18nState,
|
||
t,
|
||
translateText,
|
||
currentLang: computed(() => i18nState.currentLang),
|
||
langList: computed(() => sortLangList(i18nState.langList || [])),
|
||
loadI18n,
|
||
ensureLangList,
|
||
setLanguage,
|
||
syncLanguageFromUser,
|
||
};
|
||
};
|