chore: initialize qiming workspace repository
This commit is contained in:
431
qiming-mobile/utils/i18n.uts
Normal file
431
qiming-mobile/utils/i18n.uts
Normal file
@@ -0,0 +1,431 @@
|
||||
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,
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user