新增 有家相册 uni-app 前端(uniapp/youjiala,H5 / 微信小程序 / App 三端)
从旧 WebVue 移植的地基部分: - 登录页(RSA+MD5 密码加密、redirect 回跳、中英文切换)与登录后落地页 - 401 / 403 / 404 错误页 + error-panel 通用外壳(文案走 ErrorPage 语言包) - 登录守卫双链路:uni.addInterceptor 拦站内跳转,页面级 onShow 复核冷启动深链 - 单一请求链路 common/http.interceptor:JWT 唯一来源、401 并发刷新单例、 业务异常识别、HTTP 403 跳无权限页 - H5 干净短地址:initRouter 前向 __uniRoutes 追加克隆记录(router/h5-clean-url) - 路由登记表 router/routes.ts(PAGE_PATHS / pageUrl / PAGE_PERMISSIONS) 与新增页面四步登记约定(src/md/page-routing.md) - i18n:uView Pro 语言包 + 后端词条合并,双取值(后端 zh-cn/en,包名 zh-CN/en-US) - 后端接口层 service/ 与数据模型 data/auto/ 为生成器产物,不手改 不入库的内容: - 摘除脚手架残留的 Harmony 签名密码(manifest.json signingConfigs 与 harmony-configs/build-profile.json5 的 keyPassword / storePassword 已置空) - 忽略 node_modules、dist、unpackage、.hbuilderx、.agents、pnpm-lock.yaml 及根目录临时截图
This commit is contained in:
1 parent
74757f9eb4
commit
6a8258ac4b
274 files changed
+30291
No files matched your search
@@ -0,0 +1,53 @@
|
||||
export const APP_INFO: {
|
||||
name: string;
|
||||
version: string;
|
||||
buildTime: string;
|
||||
platform: string;
|
||||
} = {
|
||||
name: "uViewPro(跨平台UI组件库)",
|
||||
version: "1.0.9",
|
||||
buildTime: "2025-12-23",
|
||||
platform: getPlatform(),
|
||||
};
|
||||
|
||||
export const UVIEW_PRO_INFO = {
|
||||
name: "uview-pro",
|
||||
version: "0.4.7",
|
||||
buildTime: "2025-12-19",
|
||||
};
|
||||
|
||||
function getPlatform() {
|
||||
let platform = uni.getSystemInfoSync().platform;
|
||||
if (platform) {
|
||||
return platform;
|
||||
}
|
||||
// #ifdef H5
|
||||
platform = "H5";
|
||||
// #endif
|
||||
// #ifdef MP-WEIXIN
|
||||
platform = "微信小程序";
|
||||
// #endif
|
||||
// #ifdef MP-ALIPAY
|
||||
platform = "支付宝小程序";
|
||||
// #endif
|
||||
// #ifdef APP-PLUS
|
||||
platform = "App";
|
||||
// #endif
|
||||
// #ifdef APP-HARMONY
|
||||
platform = "HarmonyOS";
|
||||
// #endif
|
||||
platform = "未知平台";
|
||||
return platform;
|
||||
}
|
||||
|
||||
export const ONBOARDING_STORAGE_BASE_KEY = `guide-onboarded-${APP_INFO.version}`;
|
||||
|
||||
export const GUIDE_TABS_KEY = `guide-page-tabs-${APP_INFO.version}`;
|
||||
|
||||
export const GUIDE_FAB_KEY = `guide-page-fab-${APP_INFO.version}`;
|
||||
|
||||
export const GUIDE_TABBAR_KEY = `guide-page-tabbar-${APP_INFO.version}`;
|
||||
|
||||
export const GUIDE_THEME_SWITCHER_KEY = `guide-theme-switcher-${APP_INFO.version}`;
|
||||
|
||||
export const GUIDE_EXPERIENCE_KEY = `guide-experience-entry-${APP_INFO.version}`;
|
||||
@@ -0,0 +1,517 @@
|
||||
import type {
|
||||
RequestConfig,
|
||||
RequestInterceptor,
|
||||
RequestMeta,
|
||||
RequestOptions,
|
||||
} from "uview-pro";
|
||||
import type { JwtInfo } from "@/data/auto/jwtInfo";
|
||||
import { http } from "uview-pro";
|
||||
import { toBackendLocale } from "@/common/locale-const";
|
||||
import { WebSitePolicy } from "@/policy/custom/webSitePolicy";
|
||||
import {
|
||||
currentRealPath,
|
||||
errorPageUrl,
|
||||
pageUrl,
|
||||
resolvePageName,
|
||||
toRealPath,
|
||||
} from "@/router/routes";
|
||||
|
||||
/**
|
||||
* 全项目唯一请求层(基于 uview-pro httpPlugin,main.ts 已注册)
|
||||
* 整合了原 common/request 模块的全部能力:
|
||||
* - JWT 登录态存取 + Authorization 头
|
||||
* - 401 并发刷新令牌单例(refreshPromise)+ _retry 防递归
|
||||
* - 业务异常识别(.NET CloudBuilder 的 ValidatedException / ServiceErrorException)
|
||||
* - meta 驱动的 loading / toast 开关
|
||||
* - uploadFile / downloadFile 文件传输工具
|
||||
*/
|
||||
|
||||
// ==================== 全局配置 ====================
|
||||
|
||||
// 后端地址唯一来源:policy/custom/webSitePolicy(代码生成器维护,改地址只动那里)
|
||||
// 与旧系统 WebVue 同逻辑:开发构建用 WebApiRootDev,发布构建用 WebApiRoot
|
||||
// App 端开发构建(真机/模拟器)连不上宿主机的 localhost,改用 WebApiRootDevApp
|
||||
// 小程序端需在平台配置 https 合法域名;本地 H5 联调如遇 CORS 需后端放行开发端口
|
||||
let devBaseUrl = WebSitePolicy.WebApiRootDev;
|
||||
// #ifdef APP-PLUS
|
||||
devBaseUrl = WebSitePolicy.WebApiRootDevApp;
|
||||
// #endif
|
||||
export const baseUrl = import.meta.env.DEV
|
||||
? devBaseUrl
|
||||
: WebSitePolicy.WebApiRoot;
|
||||
// 登录页真实路径:getCurrentPages() 拿到的 route 永远是 pages.json 里的 path,
|
||||
// 不含 H5 运行时补的短地址别名,故路径比较只能用真实路径,跳转 URL 才走 pageUrl()
|
||||
const loginPage = toRealPath("login");
|
||||
const timeout = 60000;
|
||||
|
||||
// ==================== 登录态(JWT) ====================
|
||||
|
||||
// JwtInfo 使用后端对齐的权威定义(@/data/auto/jwtInfo:AccessToken / RefreshToken / CreatedTime),
|
||||
// 此处再导出,保持 import { JwtInfo } from '@/common/http.interceptor' 的调用方兼容
|
||||
export type { JwtInfo };
|
||||
|
||||
const JWT_STORAGE_KEY = "request-jwt-info";
|
||||
|
||||
/** 读取登录令牌信息 */
|
||||
export function getJwtInfo(): JwtInfo | null {
|
||||
return (uni.getStorageSync(JWT_STORAGE_KEY) as JwtInfo) || null;
|
||||
}
|
||||
|
||||
/** 登录成功后写入令牌(供登录页调用) */
|
||||
export function setLoginJwt(jwt: JwtInfo) {
|
||||
uni.setStorageSync(JWT_STORAGE_KEY, jwt);
|
||||
}
|
||||
|
||||
/** 清除登录态 */
|
||||
export function clearLoginState() {
|
||||
uni.removeStorageSync(JWT_STORAGE_KEY);
|
||||
}
|
||||
|
||||
function getToken(): string {
|
||||
return getJwtInfo()?.AccessToken ?? "";
|
||||
}
|
||||
|
||||
/**
|
||||
* 认证失败统一收尾:必要时清登录态并跳登录页;
|
||||
* 已经在登录页就不再跳,避免死循环
|
||||
*/
|
||||
function goLogin(clearState = true) {
|
||||
if (clearState)
|
||||
clearLoginState();
|
||||
const pages = getCurrentPages();
|
||||
const current = pages[pages.length - 1];
|
||||
const currentPath = current ? `/${current.route}` : "";
|
||||
if (currentPath !== loginPage)
|
||||
uni.reLaunch({ url: pageUrl("login") });
|
||||
}
|
||||
|
||||
/**
|
||||
* 权限不足统一收尾:跳 403 页并把出错地址用 ?from= 带过去。
|
||||
* 这里不复用 router/nav-guard 的 jumpToError:nav-guard 依赖本模块读登录态,
|
||||
* 反向 import 会形成循环依赖;本页已在 403 时不再跳,避免页面自身请求 403 导致来回刷
|
||||
*/
|
||||
function goForbidden() {
|
||||
const real = currentRealPath();
|
||||
const name = resolvePageName(real);
|
||||
if (name === "error403") {
|
||||
return;
|
||||
}
|
||||
// from 是给用户看的地址:H5 用短地址,其它端用真实路径
|
||||
const url = errorPageUrl("error403", name ? pageUrl(name) : real);
|
||||
uni.redirectTo({ url, fail: () => uni.reLaunch({ url }) });
|
||||
}
|
||||
|
||||
// ==================== 401 并发刷新(原 axios 封装的精华设计) ====================
|
||||
|
||||
/**
|
||||
* 正在进行的刷新请求 Promise。
|
||||
* 多个请求同时 401 时共用同一次刷新:后端每次刷新都会换新 RefreshToken,
|
||||
* 各自刷新会让先完成的那个把其它请求携带的旧 RefreshToken 作废,
|
||||
* 结果被判定「无效的令牌,请重新登录」而把人踢下线。
|
||||
*/
|
||||
let refreshPromise: Promise<JwtInfo> | null = null;
|
||||
|
||||
/** 真正发起刷新:直接走 uni.request 绕开 http 单例,防止刷新自身 401 无限递归 */
|
||||
function doRefreshToken(): Promise<JwtInfo> {
|
||||
const data = getJwtInfo();
|
||||
if (!data)
|
||||
return Promise.reject(new Error("无登录态,无法刷新令牌"));
|
||||
|
||||
return new Promise<JwtInfo>((resolve, reject) => {
|
||||
uni.request({
|
||||
url: `${baseUrl}/Auth/RefreshToken`,
|
||||
method: "POST",
|
||||
data,
|
||||
timeout,
|
||||
success: (res) => {
|
||||
if (res.statusCode >= 200 && res.statusCode < 300 && res.data) {
|
||||
const jwt = res.data as JwtInfo;
|
||||
setLoginJwt(jwt);
|
||||
resolve(jwt);
|
||||
}
|
||||
else {
|
||||
// 带上 status:goLogin 据此区分「服务端拒绝」与「网络抖动」
|
||||
const err: any = new Error("刷新令牌失败");
|
||||
err.status = res.statusCode;
|
||||
reject(err);
|
||||
}
|
||||
},
|
||||
fail: (err: any) => reject(err), // 网络类异常无 status,保留登录态
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** 取得「共用的刷新动作」:并发 401 只刷新一次 */
|
||||
function refreshTokenOnce(): Promise<JwtInfo> {
|
||||
if (!refreshPromise) {
|
||||
refreshPromise = doRefreshToken().finally(() => {
|
||||
refreshPromise = null;
|
||||
});
|
||||
}
|
||||
return refreshPromise;
|
||||
}
|
||||
|
||||
// ==================== 提示与业务异常 ====================
|
||||
|
||||
/** 从后端错误体里提取可读文案(兼容 .NET 序列化与常见 {message,msg} 格式) */
|
||||
function extractErrorMessage(data: any): string {
|
||||
if (data == null)
|
||||
return "";
|
||||
if (typeof data === "string")
|
||||
return data;
|
||||
return data.Message ?? data.message ?? data.msg ?? "";
|
||||
}
|
||||
|
||||
/**
|
||||
* 发给后端的 Culture / locale 头统一取值(与旧系统 CulturePolicy 字面一致:zh-cn / en)。
|
||||
* uni.getLocale() 在 H5 常返回 zh-Hans 等 BCP-47 变体,直传会让服务端
|
||||
* ServiceMessageManager.ComposeMessage 查不到词条,抛空 Message 异常,
|
||||
* 前端只能看到兜底文案,故统一归一化(取值定义见 common/locale-const.ts)。
|
||||
*/
|
||||
export function backendLocale(): string {
|
||||
return toBackendLocale(String(uni.getLocale?.() ?? ""));
|
||||
}
|
||||
|
||||
/** 长错误用 modal 完整展示,短错误用 toast */
|
||||
function notifyError(detail: string, fallback = "请求失败") {
|
||||
const content = detail || fallback;
|
||||
if (content.length > 14) {
|
||||
uni.showModal({
|
||||
title: fallback,
|
||||
content: content.slice(0, 200),
|
||||
showCancel: false,
|
||||
});
|
||||
}
|
||||
else {
|
||||
uni.showToast({ title: content, icon: "none" });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 业务异常识别:HTTP 成功但 body 是 .NET 异常对象时命中
|
||||
* @returns 命中返回异常消息(调用方需抛出),未命中返回空串
|
||||
*/
|
||||
function detectBusinessException(data: any): string {
|
||||
if (!data || typeof data !== "object" || !data.SourceCode)
|
||||
return "";
|
||||
const msg = extractErrorMessage(data) || "业务处理失败";
|
||||
if (data.SourceCode === "CloudBuilder.Core.ValidatedException") {
|
||||
uni.showModal({
|
||||
title: "业务校验失败",
|
||||
content: msg.slice(0, 200),
|
||||
showCancel: false,
|
||||
});
|
||||
return msg;
|
||||
}
|
||||
if (data.SourceCode === "CloudBuilder.Core.ServiceErrorException") {
|
||||
notifyError(msg, "服务异常");
|
||||
return msg;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
// ==================== 全局配置(httpPlugin 注册用) ====================
|
||||
|
||||
const httpRequestConfig: RequestConfig = {
|
||||
baseUrl,
|
||||
header: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
timeout,
|
||||
meta: {
|
||||
originalData: true, // 拦截器收到完整响应(含 statusCode/header),自行解包
|
||||
toast: true, // 出错默认提示,单次请求可传 meta.toast=false 关闭
|
||||
loading: false, // 默认不显示 loading,单次请求传 meta.loading=true 开启
|
||||
},
|
||||
};
|
||||
|
||||
// ==================== 请求 / 响应拦截器(httpPlugin 注册用) ====================
|
||||
|
||||
const httpInterceptor: RequestInterceptor = {
|
||||
// 请求拦截器:loading + method 归一化 + Authorization + locale
|
||||
request: (config: RequestOptions) => {
|
||||
const meta: RequestMeta = config.meta || {};
|
||||
if (meta.loading) {
|
||||
uni.showLoading({ title: "加载中...", mask: true });
|
||||
}
|
||||
// 归一化 method 大写:兼容代码生成的 service 层(传小写 "post"/"get",uni.request 要求大写)
|
||||
config.method = String(
|
||||
config.method || "GET",
|
||||
).toUpperCase() as RequestOptions["method"];
|
||||
const token = getToken();
|
||||
if (token) {
|
||||
// ASP.NET Core JWT Bearer 认证要求 Authorization 带 "Bearer " 前缀(旧系统同)
|
||||
config.header.Authorization = token.startsWith("Bearer ")
|
||||
? token
|
||||
: `Bearer ${token}`;
|
||||
}
|
||||
config.header.locale = backendLocale();
|
||||
return config;
|
||||
},
|
||||
|
||||
// 响应拦截器(async:支持 401 等待刷新后重试)
|
||||
response: async (response: any) => {
|
||||
const meta: RequestMeta = response.config?.meta || {};
|
||||
if (meta.loading) {
|
||||
uni.hideLoading();
|
||||
}
|
||||
const { statusCode, data: rawData, errMsg } = response;
|
||||
const config = response.config;
|
||||
|
||||
// 网络层错误
|
||||
if (errMsg && errMsg.includes("Failed to connect")) {
|
||||
meta.toast && notifyError("", "网络错误");
|
||||
throw new Error("网络错误");
|
||||
}
|
||||
if (errMsg && errMsg.includes("request:fail")) {
|
||||
meta.toast && notifyError("", "请求错误:未知");
|
||||
throw new Error("请求错误:未知");
|
||||
}
|
||||
|
||||
// 401 且未重试过:共用刷新 + 重试
|
||||
if (statusCode === 401 && !config._retry) {
|
||||
if (!getToken()) {
|
||||
// 本来就没有登录态,直接去登录页
|
||||
goLogin(true);
|
||||
throw new Error("登录已失效,请重新登录");
|
||||
}
|
||||
// _retry 标记:重试请求再 401 时落入下方非 2xx 分支,防止无限递归
|
||||
config._retry = true;
|
||||
// ① 只把「刷新令牌」放进 try:刷新失败(服务端拒绝)才清登录态跳登录页;
|
||||
// 网络抖动(无 status)保留登录态,避免一次断网把用户踢下线
|
||||
try {
|
||||
await refreshTokenOnce();
|
||||
}
|
||||
catch (refreshError: any) {
|
||||
goLogin(!!(refreshError && refreshError.status));
|
||||
throw refreshError;
|
||||
}
|
||||
// ② 重试放在 try 之外:它自身的失败(500/403 等)只算这次业务请求失败,
|
||||
// 不能当成「刷新失败」,否则会误清已成功续期用户的登录态
|
||||
return await http.request(config);
|
||||
}
|
||||
|
||||
// 204:后端约定「无内容但业务自行处理」,静默透传
|
||||
if (statusCode === 204)
|
||||
return rawData;
|
||||
|
||||
// 请求错误(非 2xx)
|
||||
if (!(statusCode >= 200 && statusCode < 300)) {
|
||||
// 403:权限不足,跳 403 页列出原因(401 走上面的续期 / 回登录页链路,不进错误页)
|
||||
if (statusCode === 403) {
|
||||
goForbidden();
|
||||
}
|
||||
// 异常体优先:.NET 后端常以 500 + JSON 异常返回业务错误
|
||||
const bizMsg = detectBusinessException(rawData);
|
||||
if (bizMsg)
|
||||
throw new Error(bizMsg);
|
||||
const errorMessage = `请求错误[${statusCode}]`;
|
||||
meta.toast && notifyError(extractErrorMessage(rawData), errorMessage);
|
||||
const err: any = new Error(`${errorMessage}:${errMsg ?? ""}`);
|
||||
err.status = statusCode; // 结构化状态码,供调用方按 401/403/500 分支处理
|
||||
err.data = rawData;
|
||||
throw err;
|
||||
}
|
||||
|
||||
// 2xx 但 body 是业务异常
|
||||
const bizMsg = detectBusinessException(rawData);
|
||||
if (bizMsg)
|
||||
throw new Error(bizMsg);
|
||||
|
||||
// 成功:剥壳返回 JSON body(service/* 经 utils/request 直接拿到后端实体对象)
|
||||
return rawData;
|
||||
},
|
||||
};
|
||||
|
||||
// ==================== 文件传输工具(不属于主链路,无拦截器逻辑) ====================
|
||||
|
||||
export interface UploadOptions {
|
||||
url: string;
|
||||
/** 本地文件路径(chooseImage/chooseVideo 等获得) */
|
||||
filePath: string;
|
||||
/** 文件对应的 form-key,后端按此名接收(默认 'file') */
|
||||
name?: string;
|
||||
formData?: Record<string, any>;
|
||||
header?: Record<string, any>;
|
||||
}
|
||||
|
||||
/** 上传文件(uni.uploadFile 封装,自动携带 token,失败 reject 可感知) */
|
||||
export function uploadFile(options: UploadOptions): Promise<any> {
|
||||
const { url, filePath, name = "file", formData, header = {} } = options;
|
||||
const fullUrl = url.indexOf("http") === 0 ? url : baseUrl + url;
|
||||
const token = getToken();
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
uni.uploadFile({
|
||||
url: fullUrl,
|
||||
filePath,
|
||||
name,
|
||||
formData,
|
||||
timeout,
|
||||
header: token ? { Authorization: token, ...header } : header,
|
||||
success: (res) => {
|
||||
if (res.statusCode < 200 || res.statusCode >= 300) {
|
||||
notifyError("", `上传失败[${res.statusCode}]`);
|
||||
reject(new Error(`上传失败[${res.statusCode}]`));
|
||||
return;
|
||||
}
|
||||
let data: any = res.data;
|
||||
try {
|
||||
data = typeof res.data === "string" ? JSON.parse(res.data) : res.data;
|
||||
}
|
||||
catch {
|
||||
/* 非 JSON 响应保持原样 */
|
||||
}
|
||||
const bizMsg = detectBusinessException(data);
|
||||
if (bizMsg)
|
||||
reject(new Error(bizMsg));
|
||||
else resolve(data);
|
||||
},
|
||||
fail: (err: any) => {
|
||||
notifyError(err.errMsg ?? "", "上传失败");
|
||||
reject(err);
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export interface DownloadOptions {
|
||||
url: string;
|
||||
method?: "GET" | "POST";
|
||||
data?: any;
|
||||
/** 下载文件名前缀(自动追加时间戳与扩展名) */
|
||||
filename: string;
|
||||
extension?: string;
|
||||
}
|
||||
|
||||
/** arraybuffer 按 UTF-8 解码(H5 用 TextDecoder,小程序简化实现) */
|
||||
function abToString(buffer: ArrayBuffer): string {
|
||||
if (typeof TextDecoder !== "undefined")
|
||||
return new TextDecoder("utf-8").decode(new Uint8Array(buffer));
|
||||
const bytes = new Uint8Array(buffer);
|
||||
let result = "";
|
||||
for (let i = 0; i < bytes.length;) {
|
||||
const c = bytes[i];
|
||||
if (c < 0x80) {
|
||||
result += String.fromCharCode(c);
|
||||
i += 1;
|
||||
}
|
||||
else if (c < 0xE0) {
|
||||
result += String.fromCharCode(((c & 0x1F) << 6) | (bytes[i + 1] & 0x3F));
|
||||
i += 2;
|
||||
}
|
||||
else {
|
||||
result += String.fromCharCode(
|
||||
((c & 0x0F) << 12)
|
||||
| ((bytes[i + 1] & 0x3F) << 6)
|
||||
| (bytes[i + 2] & 0x3F),
|
||||
);
|
||||
i += 3;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** 生成 yyyyMMddHHmmss 时间戳(零依赖,替代 dayjs) */
|
||||
function timestamp(): string {
|
||||
const d = new Date();
|
||||
const p = (n: number) => String(n).padStart(2, "0");
|
||||
return `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载并打开文件(默认按 Excel 处理)
|
||||
* 关键防御:先探测 content-type——后端报错时返回的是 JSON 而不是文件
|
||||
*/
|
||||
export async function downloadFile(options: DownloadOptions): Promise<void> {
|
||||
const { url, method = "POST", data, filename, extension = "xlsx" } = options;
|
||||
const fullUrl = url.indexOf("http") === 0 ? url : baseUrl + url;
|
||||
const token = getToken();
|
||||
|
||||
const res = await new Promise<UniApp.RequestSuccessCallbackResult>(
|
||||
(resolve, reject) => {
|
||||
uni.request({
|
||||
url: fullUrl,
|
||||
method,
|
||||
data,
|
||||
timeout,
|
||||
responseType: "arraybuffer",
|
||||
header: token ? { Authorization: token } : {},
|
||||
success: resolve,
|
||||
fail: err => reject(new Error(err.errMsg || "下载失败")),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
const header = res.header as Record<string, any>;
|
||||
const contentType = String(
|
||||
header["content-type"] || header.Content_Type || header.ContentType || "",
|
||||
);
|
||||
if (contentType.includes("application/json")) {
|
||||
const text
|
||||
= typeof res.data === "string"
|
||||
? res.data
|
||||
: abToString(res.data as ArrayBuffer);
|
||||
let msg = "下载失败";
|
||||
try {
|
||||
msg = extractErrorMessage(JSON.parse(text)) || msg;
|
||||
}
|
||||
catch {
|
||||
/* 解析失败沿用默认文案 */
|
||||
}
|
||||
notifyError(msg, "下载失败");
|
||||
throw new Error(msg);
|
||||
}
|
||||
if (res.statusCode < 200 || res.statusCode >= 300) {
|
||||
notifyError("", `下载失败[${res.statusCode}]`);
|
||||
throw new Error(`下载失败[${res.statusCode}]`);
|
||||
}
|
||||
|
||||
const fileName = `${filename}${timestamp()}.${extension}`;
|
||||
|
||||
// #ifdef H5
|
||||
// H5:blob + 隐藏 <a> 触发浏览器下载
|
||||
const downloadUrl = URL.createObjectURL(new Blob([res.data as ArrayBuffer]));
|
||||
const link = document.createElement("a");
|
||||
link.href = downloadUrl;
|
||||
link.download = fileName;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
URL.revokeObjectURL(downloadUrl);
|
||||
// #endif
|
||||
|
||||
// #ifndef H5
|
||||
// 小程序/App:写入本地用户目录后调起系统文档预览
|
||||
const fs = uni.getFileSystemManager();
|
||||
// 写入目录按平台区分:微信用 uni.env.USER_DATA_PATH(官方类型未声明故转 any);
|
||||
// App 端无 uni.env,写 plus 私有目录 _doc/(openDocument 同样支持该相对路径)
|
||||
let userDataPath = "";
|
||||
// #ifdef MP-WEIXIN
|
||||
userDataPath = (uni as any).env?.USER_DATA_PATH ?? "";
|
||||
// #endif
|
||||
// #ifdef APP-PLUS
|
||||
userDataPath = "_doc";
|
||||
// #endif
|
||||
const filePath = `${userDataPath}/${fileName}`;
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
fs.writeFile({
|
||||
filePath,
|
||||
data: res.data as ArrayBuffer,
|
||||
success: () => resolve(),
|
||||
fail: (err: any) => reject(new Error(err.errMsg || "写入文件失败")),
|
||||
});
|
||||
});
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
uni.openDocument({
|
||||
filePath,
|
||||
showMenu: true,
|
||||
success: () => resolve(),
|
||||
fail: (err: any) => reject(new Error(err.errMsg || "无法打开文件")),
|
||||
});
|
||||
});
|
||||
// #endif
|
||||
}
|
||||
|
||||
// 导出(main.ts httpPlugin 注册用)
|
||||
export { httpInterceptor, httpRequestConfig };
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* 语言 / Culture 取值:区分「后端 Culture 线值」与「uView Pro 语言包 name」两套
|
||||
*
|
||||
* - 后端(旧 CulturePolicy)严格字面:中文 "zh-cn"、英文 "en"。
|
||||
* 发给后端的 locale 头 / 登录体 Culture 用这套,防止后端精确匹配失败。
|
||||
* - uView Pro 内置语言包 name 固定为 "zh-CN" / "en-US"(大小写敏感)。
|
||||
* 前端自定义语言包的 name 必须与之完全相同,config-provider 才能把自定义文案
|
||||
* 与内置组件文案(picker / calendar / upload …)按 name 合并;
|
||||
* 若写成 "zh-cn" 会被当成另一种语言,导致内置组件文案丢失 + 语言选择器出现重复项。
|
||||
*/
|
||||
|
||||
/** 发给后端的 Culture 线值 */
|
||||
export const BACKEND_LOCALE_ZH = "zh-cn";
|
||||
export const BACKEND_LOCALE_EN = "en";
|
||||
|
||||
/** uView Pro 语言包 name(必须匹配内置大小写,勿改成 zh-cn / en) */
|
||||
export const UI_LOCALE_ZH = "zh-CN";
|
||||
export const UI_LOCALE_EN = "en-US";
|
||||
|
||||
/** 前端默认选中的语言包 name */
|
||||
export const DEFAULT_LOCALE = UI_LOCALE_ZH;
|
||||
|
||||
/** 归一化任意 locale 变体(zh-Hans / zh-CN / en-US …)到后端 Culture 线值 */
|
||||
export function toBackendLocale(value: string): string {
|
||||
const l = String(value || "").toLowerCase();
|
||||
return l.startsWith("en") ? BACKEND_LOCALE_EN : BACKEND_LOCALE_ZH;
|
||||
}
|
||||
|
||||
/** 归一化任意 locale 变体(含 DB 的 Lang)到 uView 语言包 name */
|
||||
export function toUiLocale(value: string): string {
|
||||
const l = String(value || "").toLowerCase();
|
||||
return l.startsWith("en") ? UI_LOCALE_EN : UI_LOCALE_ZH;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
// 跨端密码加密工具(H5 / 小程序 / App 均可用)
|
||||
// 说明:原 jsencrypt 依赖浏览器 window.crypto,小程序/App 无 DOM 会失败;
|
||||
// wxmp-rsa 是 jsencrypt 同源 fork(jsbn 纯 JS 实现,随机数走 Math.random 兜底),
|
||||
// 无任何浏览器全局依赖,API 与 jsencrypt 完全一致,一套代码全端运行。
|
||||
import CryptoJS from "crypto-js";
|
||||
import { JSEncrypt } from "wxmp-rsa";
|
||||
|
||||
/** MD5 摘要(小写 hex),与旧系统 WebVue 的 CryptoJS.MD5(...).toString() 输出一致 */
|
||||
export function md5Hex(text: string): string {
|
||||
return CryptoJS.MD5(text).toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* RSA 公钥加密(PKCS#1 v1.5),返回 base64 密文
|
||||
* @param plaintext 待加密明文
|
||||
* @param publicKeyPem 后端 /Auth/GetRsaPublicKey 返回的 PEM 公钥(无换行的裸格式也可)
|
||||
* @returns 加密失败返回空串,调用方需判空兜底
|
||||
*/
|
||||
export function rsaEncrypt(plaintext: string, publicKeyPem: string): string {
|
||||
const encryptor = new JSEncrypt();
|
||||
encryptor.setPublicKey(publicKeyPem);
|
||||
// encrypt 失败返回 false(非 nullish),用 || 兜底为空串
|
||||
return encryptor.encrypt(plaintext) || "";
|
||||
}
|
||||
|
||||
/**
|
||||
* 登录密码密文:RSA( Guid + MD5(密码) ),算法与旧系统 WebVue 登录完全一致
|
||||
* Guid 同时作为请求体 LoginData.Guid 发送,服务端解密后比对前缀防重放
|
||||
*/
|
||||
export function encryptLoginPassword(
|
||||
password: string,
|
||||
guid: string,
|
||||
publicKeyPem: string,
|
||||
): string {
|
||||
return rsaEncrypt(guid + md5Hex(password), publicKeyPem);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
:deep(.code-text) {
|
||||
font-family: 'Monaco', 'Consolas', monospace;
|
||||
white-space: pre-wrap;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.code-block {
|
||||
background: $u-bg-gray-light;
|
||||
padding: 24rpx;
|
||||
border-radius: 8rpx;
|
||||
margin: 16rpx 0;
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
// 此自定义uView Pro主题生成于2025-11-29 13:18
|
||||
// 地址:https://uviewpro.cn/zh/guide/themeGenerate.html
|
||||
|
||||
export default [
|
||||
// 主题 1: 紫色
|
||||
{
|
||||
name: "purple",
|
||||
label: "霞光紫",
|
||||
color: {
|
||||
primary: "#7c3aed",
|
||||
error: "#f43f5e",
|
||||
warning: "#f59e0b",
|
||||
success: "#10b981",
|
||||
info: "#6b7280",
|
||||
primaryLight: "#f3e8ff",
|
||||
errorLight: "#ffe4e6",
|
||||
warningLight: "#fef3c7",
|
||||
successLight: "#d1fae5",
|
||||
infoLight: "#f3f4f6",
|
||||
primaryDark: "#6d28d9",
|
||||
errorDark: "#e11d48",
|
||||
warningDark: "#d97706",
|
||||
successDark: "#059669",
|
||||
infoDark: "#4b5563",
|
||||
primaryDisabled: "#c4b5fd",
|
||||
errorDisabled: "#fbcfe8",
|
||||
warningDisabled: "#fde68a",
|
||||
successDisabled: "#a7f3d0",
|
||||
infoDisabled: "#d1d5db",
|
||||
},
|
||||
},
|
||||
|
||||
// 主题 2: 绿色
|
||||
{
|
||||
name: "green",
|
||||
label: "清翠绿",
|
||||
color: {
|
||||
primary: "#059669",
|
||||
error: "#dc2626",
|
||||
warning: "#eab308",
|
||||
success: "#16a34a",
|
||||
info: "#78716c",
|
||||
primaryLight: "#ecfdf5",
|
||||
errorLight: "#fee2e2",
|
||||
warningLight: "#fefce8",
|
||||
successLight: "#dcfce7",
|
||||
infoLight: "#fafaf9",
|
||||
primaryDark: "#047857",
|
||||
errorDark: "#b91c1c",
|
||||
warningDark: "#ca8a04",
|
||||
successDark: "#15803d",
|
||||
infoDark: "#57534e",
|
||||
primaryDisabled: "#6ee7b7",
|
||||
errorDisabled: "#fca5a5",
|
||||
warningDisabled: "#facc15",
|
||||
successDisabled: "#86efac",
|
||||
infoDisabled: "#e7e5e4",
|
||||
},
|
||||
},
|
||||
|
||||
// 主题 3: 橙色
|
||||
{
|
||||
name: "orange",
|
||||
label: "暖阳橙",
|
||||
color: {
|
||||
primary: "#f97316",
|
||||
error: "#ef4444",
|
||||
warning: "#fbbf24",
|
||||
success: "#22c55e",
|
||||
info: "#6366f1",
|
||||
primaryLight: "#ffedd5",
|
||||
errorLight: "#fee2e2",
|
||||
warningLight: "#fef3c7",
|
||||
successLight: "#dcfce7",
|
||||
infoLight: "#e0e7ff",
|
||||
primaryDark: "#ea580c",
|
||||
errorDark: "#dc2626",
|
||||
warningDark: "#f59e0b",
|
||||
successDark: "#16a34a",
|
||||
infoDark: "#4f46e5",
|
||||
primaryDisabled: "#fed7aa",
|
||||
errorDisabled: "#fecaca",
|
||||
warningDisabled: "#fde68a",
|
||||
successDisabled: "#86efac",
|
||||
infoDisabled: "#c7d2fe",
|
||||
},
|
||||
},
|
||||
|
||||
// 主题 4: 深蓝 / 黑蓝色系
|
||||
{
|
||||
name: "dark",
|
||||
label: "午夜蓝",
|
||||
color: {
|
||||
primary: "#0b3d91",
|
||||
error: "#ef5350",
|
||||
warning: "#ffa726",
|
||||
success: "#66bb6a",
|
||||
info: "#2196f3",
|
||||
primaryLight: "#081a33",
|
||||
errorLight: "#5e1914",
|
||||
warningLight: "#663c00",
|
||||
successLight: "#1b3a1b",
|
||||
infoLight: "#003d66",
|
||||
primaryDark: "#062a57",
|
||||
errorDark: "#c62828",
|
||||
warningDark: "#e65100",
|
||||
successDark: "#2e7d32",
|
||||
infoDark: "#01579b",
|
||||
primaryDisabled: "#274a7a",
|
||||
errorDisabled: "#ef9a9a",
|
||||
warningDisabled: "#ffb74d",
|
||||
successDisabled: "#81c784",
|
||||
infoDisabled: "#4fc3f7",
|
||||
},
|
||||
},
|
||||
];
|
||||
Reference in new issue
Block a user