Files
qiming/qiming-mobile/utils/i18n.uts

432 lines
13 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
};
};