mirror of
https://gitee.com/mirrors/AllinSSL.git
synced 2026-03-13 10:00:53 +08:00
【新增】私有证书
This commit is contained in:
192
frontend/apps/domain-official/src/api/index.ts
Normal file
192
frontend/apps/domain-official/src/api/index.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
/**
|
||||
* @module @api
|
||||
* jQuery Ajax 封装
|
||||
*
|
||||
* - 统一 baseURL、认证头(X-API-Key、X-UID)
|
||||
* - JSON 请求/响应,错误码语义化
|
||||
* - 请求/响应拦截(简单版)
|
||||
* - API 形态参考 apps/official/doc/api.md
|
||||
*/
|
||||
|
||||
import type {
|
||||
ApiSuccess,
|
||||
ApiError,
|
||||
ApiResponse,
|
||||
RequestConfig,
|
||||
ApiClientOptions,
|
||||
} from "../types/api";
|
||||
|
||||
const isDev = (): boolean => process.env.NODE_ENV === "development";
|
||||
|
||||
export class ApiClient {
|
||||
private baseURL: string;
|
||||
private apiKey: string | undefined;
|
||||
private uid: string | undefined;
|
||||
private timeout: number;
|
||||
private withCredentials: boolean;
|
||||
private onRequest?: ApiClientOptions["onRequest"];
|
||||
private onResponse?: ApiClientOptions["onResponse"];
|
||||
private onError?: ApiClientOptions["onError"];
|
||||
|
||||
/**
|
||||
* 构造函数
|
||||
* @param options 客户端选项:`baseURL`/`apiKey`/`uid`/`timeout`/`withCredentials` 以及请求/响应钩子
|
||||
*/
|
||||
constructor(options: ApiClientOptions = {}) {
|
||||
this.baseURL = options.baseURL || isDev() ? "/proxy/api" : "/api";
|
||||
// this.baseURL = "/proxy/api"; //77150
|
||||
this.apiKey = options.apiKey;
|
||||
this.uid = options.uid || "1112";
|
||||
this.timeout = options.timeout ?? 15000;
|
||||
this.withCredentials = !!options.withCredentials;
|
||||
this.onRequest = options.onRequest;
|
||||
this.onResponse = options.onResponse;
|
||||
this.onError = options.onError;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置鉴权信息
|
||||
*/
|
||||
setAuth({ apiKey, uid }: { apiKey?: string; uid?: string }) {
|
||||
if (apiKey !== undefined) this.apiKey = apiKey;
|
||||
if (uid !== undefined) this.uid = uid;
|
||||
}
|
||||
|
||||
/**
|
||||
* 组装请求头,自动附加鉴权信息
|
||||
*/
|
||||
/**
|
||||
* 组装请求头,自动附加鉴权信息
|
||||
*/
|
||||
private buildHeaders(extra?: Record<string, string>) {
|
||||
const headers: Record<string, string> = {
|
||||
...(extra || {}),
|
||||
};
|
||||
if (isDev() && this.uid) headers["X-UID"] = this.uid;
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
||||
/**
|
||||
* 发起请求
|
||||
* - GET: 使用表单编码序列化查询参数
|
||||
* - POST: 使用 JSON 请求体
|
||||
* - 成功:status=true 且 code=0 才 resolve
|
||||
* - 失败:统一规范化为 ApiError 并 reject
|
||||
*/
|
||||
private request<T = any>(config: RequestConfig): Promise<ApiResponse<T>> {
|
||||
const cfg: RequestConfig = {
|
||||
method: "POST",
|
||||
timeout: this.timeout,
|
||||
...config,
|
||||
};
|
||||
const finalCfg = this.onRequest ? this.onRequest({ ...cfg }) || cfg : cfg;
|
||||
const url = `${this.baseURL}${finalCfg.url.startsWith("/") ? "" : "/"}${
|
||||
finalCfg.url
|
||||
}`;
|
||||
const whileList = ["/v1/order/cart/list", "/v1/contact/get_user_detail"];
|
||||
// 白名单不进行登录状态判定
|
||||
const isSkip = whileList.includes(finalCfg.url);
|
||||
return new Promise((resolve, reject) => {
|
||||
const method = (finalCfg.method || "POST").toUpperCase();
|
||||
const isGet = method === "GET";
|
||||
const payload = isGet
|
||||
? finalCfg.data
|
||||
: finalCfg.data
|
||||
? JSON.stringify(finalCfg.data)
|
||||
: undefined;
|
||||
const contentType = isGet
|
||||
? "application/x-www-form-urlencoded; charset=UTF-8"
|
||||
: "application/json";
|
||||
|
||||
(window as any).$.ajax({
|
||||
url,
|
||||
method,
|
||||
data: payload,
|
||||
contentType,
|
||||
dataType: "json",
|
||||
headers: this.buildHeaders(finalCfg.headers),
|
||||
xhrFields: { withCredentials: this.withCredentials },
|
||||
timeout: finalCfg.timeout ?? this.timeout,
|
||||
success: (res: any) => {
|
||||
const processedRes = (
|
||||
this.onResponse ? this.onResponse(res, finalCfg) || res : res
|
||||
) as ApiResponse<T>;
|
||||
const isLoginInvalid =
|
||||
(processedRes as any)?.code === 1002 &&
|
||||
(processedRes as any)?.msg === "身份失效";
|
||||
const ok =
|
||||
(processedRes as any)?.status === true &&
|
||||
(processedRes as any)?.code === 0;
|
||||
if (isLoginInvalid && !isSkip) {
|
||||
setTimeout(() => {
|
||||
location.href = "/login";
|
||||
}, 2000);
|
||||
return reject({
|
||||
status: false,
|
||||
code: 1002,
|
||||
message: "登录状态已失效,页面将在2秒后自动跳转至登录页面",
|
||||
});
|
||||
}
|
||||
if (ok) {
|
||||
resolve(processedRes as ApiSuccess<T>);
|
||||
return;
|
||||
}
|
||||
const apiError: ApiError = {
|
||||
status: false,
|
||||
code: (processedRes as any)?.code ?? -1,
|
||||
message:
|
||||
(processedRes as any)?.message ||
|
||||
(processedRes as any)?.msg ||
|
||||
"请求失败",
|
||||
data: (processedRes as any)?.data,
|
||||
timestamp: (processedRes as any)?.timestamp ?? Date.now(),
|
||||
};
|
||||
const handled = this.onError
|
||||
? this.onError(apiError, finalCfg) || apiError
|
||||
: apiError;
|
||||
reject(handled);
|
||||
},
|
||||
error: (xhr: any, _textStatus: any, errorThrown: any) => {
|
||||
const resp = xhr?.responseJSON;
|
||||
|
||||
const fallback: ApiError = {
|
||||
status: false,
|
||||
code: resp?.code ?? xhr?.status ?? -1,
|
||||
message: resp?.message || resp?.msg || errorThrown || "网络错误",
|
||||
data: resp?.data,
|
||||
timestamp: resp?.timestamp ?? Date.now(),
|
||||
};
|
||||
const processed = this.onError
|
||||
? this.onError(fallback, finalCfg) || fallback
|
||||
: fallback;
|
||||
reject(processed);
|
||||
},
|
||||
} as any);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送 POST 请求
|
||||
* @param url 接口路径(相对 baseURL)
|
||||
* @param data 请求体
|
||||
* @param headers 额外请求头
|
||||
*/
|
||||
post<T = any>(url: string, data?: any, headers?: Record<string, string>) {
|
||||
return this.request<T>({ url, method: "POST", data, headers });
|
||||
}
|
||||
/**
|
||||
* 发送 GET 请求
|
||||
* @param url 接口路径(相对 baseURL)
|
||||
* @param data 查询参数
|
||||
* @param headers 额外请求头
|
||||
*/
|
||||
get<T = any>(url: string, data?: any, headers?: Record<string, string>) {
|
||||
return this.request<T>({ url, method: "GET", data, headers });
|
||||
}
|
||||
}
|
||||
|
||||
// 默认导出单例(可按需)
|
||||
const api = new ApiClient();
|
||||
|
||||
export default api;
|
||||
180
frontend/apps/domain-official/src/api/landing.ts
Normal file
180
frontend/apps/domain-official/src/api/landing.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
import api from "./index";
|
||||
import type { ApiResponse } from "../types/api";
|
||||
|
||||
import type {
|
||||
DomainQueryCheckRequest,
|
||||
DomainQueryCheckResponseData,
|
||||
} from "../types/api-types/domain-query-check";
|
||||
import type {
|
||||
ContactGetUserDetailRequest,
|
||||
ContactGetUserDetailResponseData,
|
||||
} from "../types/api-types/contact-get-user-detail";
|
||||
import type {
|
||||
OrderCartListRequest,
|
||||
OrderCartListResponseData,
|
||||
} from "../types/api-types/order-cart-list";
|
||||
import type {
|
||||
OrderCartAddRequest,
|
||||
OrderCartAddResponseData,
|
||||
} from "../types/api-types/order-cart-add";
|
||||
import type {
|
||||
OrderCartUpdateRequest,
|
||||
OrderCartUpdateResponseData,
|
||||
} from "../types/api-types/order-cart-update";
|
||||
import type {
|
||||
OrderCartRemoveRequest,
|
||||
OrderCartRemoveResponseData,
|
||||
} from "../types/api-types/order-cart-remove";
|
||||
import type {
|
||||
OrderCartClearRequest,
|
||||
OrderCartClearResponseData,
|
||||
} from "../types/api-types/order-cart-clear";
|
||||
import type {
|
||||
OrderCreateRequest,
|
||||
OrderCreateResponseData,
|
||||
} from "../types/api-types/order-create";
|
||||
import type {
|
||||
OrderPaymentStatusRequest,
|
||||
OrderPaymentStatusResponseData,
|
||||
} from "../types/api-types/order-payment-status";
|
||||
import type {
|
||||
OrderDetailRequest,
|
||||
OrderDetailResponseData,
|
||||
} from "../types/api-types/order-detail";
|
||||
|
||||
// 落地页-域名查询
|
||||
export function domainQueryCheck(
|
||||
data: DomainQueryCheckRequest,
|
||||
headers?: Record<string, string>
|
||||
): Promise<ApiResponse<DomainQueryCheckResponseData>> {
|
||||
return api.post<DomainQueryCheckResponseData>(
|
||||
"/v1/domain/query/check",
|
||||
data,
|
||||
headers
|
||||
);
|
||||
}
|
||||
|
||||
// 获取实名信息模板列表
|
||||
export function getContactUserDetail(
|
||||
data: ContactGetUserDetailRequest,
|
||||
headers?: Record<string, string>
|
||||
): Promise<ApiResponse<ContactGetUserDetailResponseData>> {
|
||||
return api.post<ContactGetUserDetailResponseData>(
|
||||
"/v1/contact/get_user_detail",
|
||||
data,
|
||||
headers
|
||||
);
|
||||
}
|
||||
|
||||
// 购物车:获取列表
|
||||
export function getOrderCartList(
|
||||
data: OrderCartListRequest,
|
||||
headers?: Record<string, string>
|
||||
): Promise<ApiResponse<OrderCartListResponseData>> {
|
||||
return api.post<OrderCartListResponseData>(
|
||||
"/v1/order/cart/list",
|
||||
data,
|
||||
headers
|
||||
);
|
||||
}
|
||||
|
||||
// 购物车:添加
|
||||
export function addToCart(
|
||||
data: OrderCartAddRequest,
|
||||
headers?: Record<string, string>
|
||||
): Promise<ApiResponse<OrderCartAddResponseData>> {
|
||||
return api.post<OrderCartAddResponseData>(
|
||||
"/v1/order/cart/add",
|
||||
data,
|
||||
headers
|
||||
);
|
||||
}
|
||||
|
||||
// 购物车:更新
|
||||
export function updateCart(
|
||||
data: OrderCartUpdateRequest,
|
||||
headers?: Record<string, string>
|
||||
): Promise<ApiResponse<OrderCartUpdateResponseData>> {
|
||||
return api.post<OrderCartUpdateResponseData>(
|
||||
"/v1/order/cart/update",
|
||||
data,
|
||||
headers
|
||||
);
|
||||
}
|
||||
|
||||
// 购物车:移除
|
||||
export function removeFromCart(
|
||||
data: OrderCartRemoveRequest,
|
||||
headers?: Record<string, string>
|
||||
): Promise<ApiResponse<OrderCartRemoveResponseData>> {
|
||||
return api.post<OrderCartRemoveResponseData>(
|
||||
"/v1/order/cart/remove",
|
||||
data,
|
||||
headers
|
||||
);
|
||||
}
|
||||
|
||||
// 购物车:清空
|
||||
export function clearCart(
|
||||
data: OrderCartClearRequest,
|
||||
headers?: Record<string, string>
|
||||
): Promise<ApiResponse<OrderCartClearResponseData>> {
|
||||
return api.post<OrderCartClearResponseData>(
|
||||
"/v1/order/cart/clear",
|
||||
data,
|
||||
headers
|
||||
);
|
||||
}
|
||||
|
||||
// 创建订单
|
||||
export function createOrder(
|
||||
data: OrderCreateRequest,
|
||||
headers?: Record<string, string>
|
||||
): Promise<ApiResponse<OrderCreateResponseData>> {
|
||||
return api.post<OrderCreateResponseData>("/v1/order/create", data, headers);
|
||||
}
|
||||
|
||||
// 查询支付状态
|
||||
export function queryPaymentStatus(
|
||||
data: OrderPaymentStatusRequest,
|
||||
headers?: Record<string, string>
|
||||
): Promise<ApiResponse<OrderPaymentStatusResponseData>> {
|
||||
return api.post<OrderPaymentStatusResponseData>(
|
||||
"/v1/order/payment/status",
|
||||
data,
|
||||
headers
|
||||
);
|
||||
}
|
||||
|
||||
// 获取指定订单的详细信息
|
||||
export function getOrderDetail(
|
||||
data: OrderDetailRequest,
|
||||
headers?: Record<string, string>
|
||||
): Promise<ApiResponse<OrderDetailResponseData>> {
|
||||
return api.post<OrderDetailResponseData>("/v1/order/detail", data, headers);
|
||||
}
|
||||
|
||||
/**
|
||||
* WHOIS查询API
|
||||
* @param domain 域名
|
||||
* @returns Promise<ApiResponse<WhoisData>>
|
||||
*/
|
||||
export const queryWhois = (domain: string) => {
|
||||
return api.get(`/whois/query`, { domain });
|
||||
};
|
||||
|
||||
export const landingApi = {
|
||||
domainQueryCheck,
|
||||
getContactUserDetail,
|
||||
getOrderCartList,
|
||||
addToCart,
|
||||
updateCart,
|
||||
removeFromCart,
|
||||
clearCart,
|
||||
createOrder,
|
||||
queryPaymentStatus,
|
||||
getOrderDetail,
|
||||
queryWhois,
|
||||
};
|
||||
|
||||
export default landingApi;
|
||||
486
frontend/apps/domain-official/src/pages/index.ts
Normal file
486
frontend/apps/domain-official/src/pages/index.ts
Normal file
@@ -0,0 +1,486 @@
|
||||
import "virtual:uno.css";
|
||||
import "../styles/index.css";
|
||||
import { renderTemplateList } from "@utils/core";
|
||||
import type { DomainPrice } from "@types";
|
||||
import { NotificationManager } from "@utils";
|
||||
import { bindContactServicePopupClick } from "@utils";
|
||||
|
||||
window.isLoggedIn = localStorage.getItem("isLogin") === "true";
|
||||
|
||||
/**
|
||||
* 域名价格数据 - 纯数据对象f
|
||||
*/
|
||||
const domainPriceData: DomainPrice[] = [
|
||||
{
|
||||
suffix: ".com",
|
||||
originalPrice: 89,
|
||||
firstYearPrice: 54,
|
||||
renewPrice: 79,
|
||||
transferPrice: 79,
|
||||
},
|
||||
{
|
||||
suffix: ".net",
|
||||
originalPrice: 99,
|
||||
firstYearPrice: 86,
|
||||
renewPrice: 89,
|
||||
transferPrice: 89,
|
||||
},
|
||||
{
|
||||
suffix: ".cn",
|
||||
originalPrice: 39,
|
||||
firstYearPrice: 20,
|
||||
renewPrice: 34,
|
||||
transferPrice: 31,
|
||||
},
|
||||
{
|
||||
suffix: ".top",
|
||||
originalPrice: 49,
|
||||
firstYearPrice: 9.9,
|
||||
renewPrice: 31,
|
||||
transferPrice: 31,
|
||||
// isWan: true,
|
||||
},
|
||||
{
|
||||
suffix: ".cyou",
|
||||
originalPrice: 109,
|
||||
firstYearPrice: 9.9,
|
||||
renewPrice: 98,
|
||||
transferPrice: 98,
|
||||
// isWan: true,
|
||||
},
|
||||
{
|
||||
suffix: ".icu",
|
||||
originalPrice: 109,
|
||||
firstYearPrice: 9.9,
|
||||
renewPrice: 98,
|
||||
transferPrice: 98,
|
||||
// isWan: true,
|
||||
},
|
||||
{
|
||||
suffix: ".xyz",
|
||||
originalPrice: 109,
|
||||
firstYearPrice: 9.9,
|
||||
renewPrice: 92,
|
||||
transferPrice: 92,
|
||||
// isWan: true,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* 获取所有域名价格数据
|
||||
* @returns 所有可展示的域名价格数据列表
|
||||
*/
|
||||
const getAllDomains = (): DomainPrice[] => domainPriceData;
|
||||
|
||||
/**
|
||||
* jQuery 工厂(保持与现有页面环境兼容)
|
||||
* @param selector 选择器或元素
|
||||
* @returns jQuery 包装后的对象
|
||||
*/
|
||||
const $ = (selector: any): any => (window as any).jQuery(selector);
|
||||
|
||||
/**
|
||||
* 初始化 FAQ 折叠面板
|
||||
*/
|
||||
const initFaqToggles = (): void => {
|
||||
$(".faq-toggle").on("click", function (this: any) {
|
||||
const $content = $(this).next();
|
||||
const $icon = $(this).find("i");
|
||||
|
||||
$content.toggleClass("hidden");
|
||||
$icon.toggleClass("rotate-180");
|
||||
|
||||
$(".faq-toggle")
|
||||
.not(this)
|
||||
.each(function (this: any) {
|
||||
$(this).next().addClass("hidden");
|
||||
$(this).find("i").removeClass("rotate-180");
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 初始化页面加载动画(进入视口淡入)
|
||||
*/
|
||||
const initPageLoadAnimations = (): void => {
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
entries.forEach((entry) => {
|
||||
if (entry.isIntersecting) {
|
||||
$(entry.target)
|
||||
.addClass("opacity-100 translate-y-0")
|
||||
.removeClass("opacity-0 translate-y-10");
|
||||
}
|
||||
});
|
||||
},
|
||||
{ threshold: 0.1 }
|
||||
);
|
||||
|
||||
$("section").each(function (this: any) {
|
||||
$(this).addClass("transition-all duration-700 opacity-0 translate-y-10");
|
||||
observer.observe(this);
|
||||
});
|
||||
|
||||
$("section:first-of-type")
|
||||
.addClass("opacity-100 translate-y-0")
|
||||
.removeClass("opacity-0 translate-y-10");
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理域名查询动作(按钮或回车触发)
|
||||
*/
|
||||
const handleDomainQuery = (): void => {
|
||||
const $input = $("#domain-query-input");
|
||||
const query = String(($input.val() as string) || "").trim();
|
||||
|
||||
// 校验:不支持中文
|
||||
const hasChinese = /[\u4e00-\u9fa5]/.test(query);
|
||||
if (hasChinese) {
|
||||
// 提示并退出
|
||||
NotificationManager.show({
|
||||
type: "error",
|
||||
message: "不支持中文,请使用英文和数字",
|
||||
});
|
||||
$input.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
// 校验:仅允许字母、数字、连接符(-)和点(.)
|
||||
const hasInvalidChar = /[^a-zA-Z0-9\-.]/.test(query);
|
||||
if (hasInvalidChar) {
|
||||
NotificationManager.show({
|
||||
type: "error",
|
||||
message: "仅支持字母、数字、连接符(-)和点(.)",
|
||||
});
|
||||
$input.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
// 规则:不能以连接符开头
|
||||
if (/^-/.test(query)) {
|
||||
NotificationManager.show({
|
||||
type: "error",
|
||||
message: "不能以连接符(-)开头",
|
||||
});
|
||||
$input.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
// 规则:不能仅由连接符组成(忽略点)
|
||||
const stripped = query.replace(/\./g, "");
|
||||
if (/^-+$/.test(stripped)) {
|
||||
NotificationManager.show({
|
||||
type: "error",
|
||||
message: "不能仅由连接符(-)组成",
|
||||
});
|
||||
$input.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
if (query) {
|
||||
window.location.href = `/new/domain-query-register.html?search=${encodeURIComponent(
|
||||
query
|
||||
)}`; // 跳转到注册页并携带查询词
|
||||
} else {
|
||||
$input.focus().addClass("shake"); // 空值时聚焦并触发轻微抖动提示
|
||||
const timer = window.setTimeout(() => $input.removeClass("shake"), 500); // 500ms 后移除抖动效果
|
||||
// 避免 TS 关于未使用变量的提示
|
||||
void timer;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 更新清空输入按钮显示状态
|
||||
*/
|
||||
const updateClearButtonVisibility = (): void => {
|
||||
const $input = $("#domain-query-input");
|
||||
const $clearBtn = $("#clear-input-button");
|
||||
|
||||
if ($input.val() && $input.data("userTyped") === "true") {
|
||||
$clearBtn.addClass("visible");
|
||||
} else {
|
||||
$clearBtn.removeClass("visible"); // 用户未输入或为空时隐藏清空按钮
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 初始化域名查询相关事件绑定
|
||||
*/
|
||||
const initDomainQueryEvents = (): void => {
|
||||
const $input = $("#domain-query-input");
|
||||
const $clearBtn = $("#clear-input-button");
|
||||
|
||||
$("#domain-query-button").on("click", handleDomainQuery);
|
||||
|
||||
$input.on("keypress", (e: any) => {
|
||||
if (e.key === "Enter") handleDomainQuery();
|
||||
});
|
||||
|
||||
$clearBtn.on("click", () => {
|
||||
$input.val("").focus();
|
||||
updateClearButtonVisibility();
|
||||
});
|
||||
|
||||
$input.on("focus input", function (this: any) {
|
||||
$(this).data("userTyped", "true");
|
||||
updateClearButtonVisibility();
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 初始化移动端注册流程步骤切换器
|
||||
*/
|
||||
const initStepSwitcher = (): void => {
|
||||
const $stepSwitcher = $("#step-switcher");
|
||||
if (!$stepSwitcher.length) return;
|
||||
|
||||
const $stepWrapper = $stepSwitcher.find(".step-wrapper");
|
||||
const $steps = $stepSwitcher.find(".step-content");
|
||||
const $indicators = $stepSwitcher.find(".step-indicator");
|
||||
const $prevBtn = $stepSwitcher.find(".prev-btn");
|
||||
const $nextBtn = $stepSwitcher.find(".next-btn");
|
||||
|
||||
let currentStep = 0;
|
||||
|
||||
/**
|
||||
* 切换到指定步骤
|
||||
* @param stepIndex 步骤索引(0 开始)
|
||||
*/
|
||||
const updateStep = (stepIndex: number): void => {
|
||||
const translateX = -stepIndex * 20; // 每步等宽 20%,通过水平位移切换
|
||||
$stepWrapper.css("transform", `translateX(${translateX}%)`);
|
||||
|
||||
$indicators.each(function (this: any, index: number) {
|
||||
if (index === stepIndex) {
|
||||
$(this).addClass("bg-primary").removeClass("bg-gray-300");
|
||||
} else {
|
||||
$(this).addClass("bg-gray-300").removeClass("bg-primary");
|
||||
}
|
||||
});
|
||||
|
||||
$("#step-counter").text(`${stepIndex + 1} / ${$steps.length}`); // 更新步骤计数器
|
||||
|
||||
$prevBtn
|
||||
.prop("disabled", stepIndex === 0)
|
||||
.toggleClass("opacity-50 cursor-not-allowed", stepIndex === 0); // 首步禁用“上一步”
|
||||
|
||||
$nextBtn
|
||||
.prop("disabled", stepIndex === $steps.length - 1)
|
||||
.toggleClass(
|
||||
"opacity-50 cursor-not-allowed",
|
||||
stepIndex === $steps.length - 1
|
||||
); // 末步禁用“下一步”
|
||||
};
|
||||
|
||||
$prevBtn.on("click", () => {
|
||||
if (currentStep > 0) {
|
||||
currentStep--;
|
||||
updateStep(currentStep);
|
||||
}
|
||||
});
|
||||
|
||||
$nextBtn.on("click", () => {
|
||||
if (currentStep < $steps.length - 1) {
|
||||
currentStep++;
|
||||
updateStep(currentStep);
|
||||
}
|
||||
});
|
||||
|
||||
$indicators.on("click", function (this: any) {
|
||||
const stepIndex = parseInt($(this).data("step"), 10);
|
||||
currentStep = stepIndex;
|
||||
updateStep(currentStep);
|
||||
});
|
||||
|
||||
updateStep(0);
|
||||
};
|
||||
|
||||
/**
|
||||
* 初始化搜索框模拟打字效果(鼠标悬停触发,用户输入后停止)
|
||||
*/
|
||||
const initTypewriterEffect = (): void => {
|
||||
const $input = $("#domain-query-input");
|
||||
const $clearBtn = $("#clear-input-button");
|
||||
if (!$input.length || !$clearBtn.length) return;
|
||||
|
||||
const exampleDomains = [
|
||||
"mycompany.com",
|
||||
"mybrand.cn",
|
||||
"mystore.shop",
|
||||
"myapp.tech",
|
||||
"myservice.net",
|
||||
]; // 轮播展示的示例域名
|
||||
let currentExampleIndex = 0;
|
||||
let currentCharIndex = 0;
|
||||
let isDeleting = false;
|
||||
let isAnimating = false;
|
||||
let typewriterTimer: number | undefined;
|
||||
let mouseHoverTimer: number | undefined;
|
||||
let lastMousePosition = { x: 0, y: 0 };
|
||||
|
||||
/**
|
||||
* 执行一次打字机动画步骤
|
||||
*/
|
||||
const typeWriter = (): void => {
|
||||
if ($input.data("userTyped") === "true") {
|
||||
if (typewriterTimer) window.clearTimeout(typewriterTimer);
|
||||
updateClearButtonVisibility();
|
||||
return;
|
||||
}
|
||||
|
||||
isAnimating = true; // 标记动画进行中
|
||||
$clearBtn.removeClass("visible"); // 避免自动演示时显示清空按钮
|
||||
|
||||
const currentDomain = exampleDomains[currentExampleIndex] as string;
|
||||
|
||||
if (isDeleting) {
|
||||
$input.val(currentDomain.substring(0, currentCharIndex - 1));
|
||||
currentCharIndex--;
|
||||
|
||||
if (currentCharIndex === 0) {
|
||||
isDeleting = false;
|
||||
currentExampleIndex = (currentExampleIndex + 1) % exampleDomains.length;
|
||||
typewriterTimer = window.setTimeout(typeWriter, 1000); // 完整展示后切到下一个示例前停顿
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
$input.val(currentDomain.substring(0, currentCharIndex + 1));
|
||||
currentCharIndex++;
|
||||
|
||||
if (currentCharIndex === currentDomain.length) {
|
||||
typewriterTimer = window.setTimeout(() => {
|
||||
isDeleting = true; // 末尾停顿后开始删除
|
||||
typeWriter();
|
||||
}, 2000);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const delay = isDeleting ? 50 : Math.random() * 100 + 100; // 打字更慢、删除更快
|
||||
typewriterTimer = window.setTimeout(typeWriter, delay);
|
||||
};
|
||||
|
||||
/**
|
||||
* 重置输入框与动画状态
|
||||
*/
|
||||
const resetInputState = (): void => {
|
||||
if (typewriterTimer) window.clearTimeout(typewriterTimer);
|
||||
if (mouseHoverTimer) window.clearTimeout(mouseHoverTimer);
|
||||
|
||||
if (isAnimating && $input.data("userTyped") !== "true") {
|
||||
$input.val(""); // 清空并重置
|
||||
currentCharIndex = 0;
|
||||
isDeleting = false;
|
||||
isAnimating = false;
|
||||
}
|
||||
updateClearButtonVisibility();
|
||||
};
|
||||
|
||||
/**
|
||||
* 开始检测鼠标悬停并延时触发打字机
|
||||
* @param event 鼠标事件
|
||||
*/
|
||||
const startHoverDetection = (event: any): void => {
|
||||
lastMousePosition = { x: event.clientX, y: event.clientY };
|
||||
if (mouseHoverTimer) window.clearTimeout(mouseHoverTimer);
|
||||
if ($input.data("userTyped") === "true") return;
|
||||
mouseHoverTimer = window.setTimeout(typeWriter, 3000);
|
||||
};
|
||||
|
||||
/**
|
||||
* 检查鼠标移动,保持/重启动画
|
||||
* @param event 鼠标事件
|
||||
*/
|
||||
const checkMouseMovement = (event: any): void => {
|
||||
if (
|
||||
event.clientX !== lastMousePosition.x ||
|
||||
event.clientY !== lastMousePosition.y
|
||||
) {
|
||||
lastMousePosition = { x: event.clientX, y: event.clientY };
|
||||
if (isAnimating) resetInputState();
|
||||
startHoverDetection(event);
|
||||
}
|
||||
};
|
||||
|
||||
$(document).on("mousemove", checkMouseMovement);
|
||||
$(document).on("mouseenter", startHoverDetection);
|
||||
startHoverDetection({ clientX: 0, clientY: 0 }); // 初次进入即开始检测
|
||||
};
|
||||
|
||||
/**
|
||||
* 初始化价格表格(基于 HTML 模板渲染)
|
||||
*/
|
||||
const initPriceTable = (): void => {
|
||||
const $tableBody = $("#price-table-body");
|
||||
if (!$tableBody.length) return;
|
||||
const tableHTML = renderTemplateList("tpl-price-row", getAllDomains()); // 使用模板渲染价格行
|
||||
$tableBody.html(tableHTML);
|
||||
};
|
||||
|
||||
/**
|
||||
* 滚动到页面顶部并聚焦搜索框
|
||||
*/
|
||||
const scrollToSearchBox = (): void => {
|
||||
$("html, body").animate({ scrollTop: 0 }, 500); // 平滑滚动到顶部
|
||||
window.setTimeout(() => $("#domain-query-input").focus(), 500); // 滚动后聚焦输入框
|
||||
};
|
||||
|
||||
/**
|
||||
* 初始化底部“回到搜索”按钮
|
||||
*/
|
||||
const initScrollToSearchButton = (): void => {
|
||||
$("#scroll-to-search-btn").on("click", scrollToSearchBox);
|
||||
};
|
||||
|
||||
/**
|
||||
* 初始化"联系客服"二维码悬浮显隐效果
|
||||
*/
|
||||
const initServiceQRCode = (): void => {
|
||||
bindContactServicePopupClick();
|
||||
};
|
||||
|
||||
/**
|
||||
* 初始化购物车按钮显示逻辑
|
||||
*/
|
||||
const initCartButton = (): void => {
|
||||
const $cartButton = $("#cart-button");
|
||||
|
||||
// 检测用户登录状态
|
||||
if ((window as any).isLoggedIn) {
|
||||
$cartButton.show();
|
||||
} else {
|
||||
$cartButton.hide();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 初始化所有 UI 事件与页面效果
|
||||
*/
|
||||
const initUIEvents = (): void => {
|
||||
initFaqToggles();
|
||||
initPageLoadAnimations();
|
||||
initDomainQueryEvents();
|
||||
initStepSwitcher();
|
||||
initTypewriterEffect();
|
||||
initPriceTable();
|
||||
initScrollToSearchButton();
|
||||
initServiceQRCode();
|
||||
initCartButton();
|
||||
};
|
||||
|
||||
/**
|
||||
* 应用初始化(等待 jQuery 可用后初始化 UI)
|
||||
*/
|
||||
const initApp = (): void => {
|
||||
if (typeof (window as any).jQuery === "undefined") {
|
||||
window.setTimeout(initApp, 100); // 依赖 jQuery,未加载则轮询等待
|
||||
return;
|
||||
}
|
||||
initUIEvents();
|
||||
(window as any).scrollToSearchBox = scrollToSearchBox; // 暴露给内联事件或其他脚本调用
|
||||
};
|
||||
|
||||
/**
|
||||
* DOM 加载完成后初始化应用
|
||||
*/
|
||||
$(document).ready(initApp); // DOM 就绪后启动
|
||||
2714
frontend/apps/domain-official/src/pages/registration.ts
Normal file
2714
frontend/apps/domain-official/src/pages/registration.ts
Normal file
File diff suppressed because it is too large
Load Diff
2367
frontend/apps/domain-official/src/styles/index.css
Normal file
2367
frontend/apps/domain-official/src/styles/index.css
Normal file
File diff suppressed because it is too large
Load Diff
96
frontend/apps/domain-official/src/templates/index.ts
Normal file
96
frontend/apps/domain-official/src/templates/index.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* 模板 ID 常量(来自 `domain-registration.html` 中所有 `<script type="text/template">`)
|
||||
*
|
||||
* 使用建议:配合 `@types/template-data-map.d.ts` 的模块增强,
|
||||
* - `renderTemplate(TPL_EMPTY_STATE, { icon: '...', text: '...' })` 将获得强类型校验
|
||||
*/
|
||||
import type { TemplateDataMap } from '../types/template-data-map'
|
||||
|
||||
/** 所有模板 ID 的联合类型 */
|
||||
export type TemplateId = keyof TemplateDataMap
|
||||
|
||||
// 空状态与列表项
|
||||
export const TPL_EMPTY_STATE = 'empty-state-template' as const
|
||||
export const TPL_SEARCH_RESULT_ITEM = 'search-result-item-template' as const
|
||||
export const TPL_VIEW_MORE_BUTTON = 'view-more-button-template' as const
|
||||
|
||||
// 购物车
|
||||
export const TPL_CART_ITEM = 'cart-item-template' as const
|
||||
|
||||
// 模板选项
|
||||
export const TPL_TEMPLATE_OPTION = 'template-option-template' as const
|
||||
export const TPL_TEMPLATE_SELECT_OPTION = 'template-select-option-template' as const
|
||||
|
||||
// 模态框
|
||||
export const TPL_MODAL_CONTAINER = 'modal-container-template' as const
|
||||
export const TPL_MODAL_CONTENT = 'modal-content-template' as const
|
||||
export const TPL_MODAL_BUTTON = 'modal-button-template' as const
|
||||
|
||||
// 通知
|
||||
export const TPL_NOTIFICATION_CONTAINER = 'notification-container-template' as const
|
||||
export const TPL_NOTIFICATION_CONTENT = 'notification-content-template' as const
|
||||
|
||||
// 购买弹窗(域名单项)
|
||||
export const TPL_BUY_MODAL_TEMPLATE_SELECTOR = 'buy-modal-template-selector-template' as const
|
||||
export const TPL_BUY_MODAL_PRICE_INFO = 'buy-modal-price-info-template' as const
|
||||
export const TPL_BUY_MODAL_WARNING = 'buy-modal-warning-template' as const
|
||||
export const TPL_BUY_MODAL_CONTENT = 'buy-modal-content-template' as const
|
||||
|
||||
// 支付弹窗 - 购物车项目与列表
|
||||
export const TPL_PAYMENT_CART_ITEM = 'payment-cart-item-template' as const
|
||||
export const TPL_PAYMENT_MODAL_CART_ITEMS = 'payment-modal-cart-items-template' as const
|
||||
export const TPL_PAYMENT_MODAL_WARNING = 'payment-modal-warning-template' as const
|
||||
|
||||
// 支付方式/底部汇总
|
||||
export const TPL_PAYMENT_METHOD_SELECTOR = 'payment-method-selector-template' as const
|
||||
export const TPL_PAYMENT_MODAL_PAYMENT_SECTION = 'payment-modal-payment-section-template' as const
|
||||
|
||||
// 支付弹窗完整内容
|
||||
export const TPL_PAYMENT_MODAL_CONTENT = 'payment-modal-content-template' as const
|
||||
|
||||
// 支付界面
|
||||
export const TPL_PAYMENT_INTERFACE = 'payment-interface-template' as const
|
||||
|
||||
// 订单与支付结果
|
||||
export const TPL_ORDER_ITEM = 'order-item-template' as const
|
||||
export const TPL_ORDER_SUCCESS = 'order-success-template' as const
|
||||
|
||||
// 确认删除
|
||||
export const TPL_CONFIRM_DELETE_MODAL = 'confirm-delete-modal-template' as const
|
||||
|
||||
// 域名注册协议
|
||||
export const TPL_DOMAIN_AGREEMENT_MODAL = 'domain-agreement-modal-template' as const
|
||||
|
||||
// WHOIS查询
|
||||
export const TPL_WHOIS_MODAL = 'whois-modal-template' as const
|
||||
|
||||
/** 模板常量字典,便于遍历或注入 */
|
||||
export const TEMPLATES = {
|
||||
TPL_EMPTY_STATE,
|
||||
TPL_SEARCH_RESULT_ITEM,
|
||||
TPL_VIEW_MORE_BUTTON,
|
||||
TPL_CART_ITEM,
|
||||
TPL_TEMPLATE_OPTION,
|
||||
TPL_TEMPLATE_SELECT_OPTION,
|
||||
TPL_MODAL_CONTAINER,
|
||||
TPL_MODAL_CONTENT,
|
||||
TPL_MODAL_BUTTON,
|
||||
TPL_NOTIFICATION_CONTAINER,
|
||||
TPL_NOTIFICATION_CONTENT,
|
||||
TPL_BUY_MODAL_TEMPLATE_SELECTOR,
|
||||
TPL_BUY_MODAL_PRICE_INFO,
|
||||
TPL_BUY_MODAL_WARNING,
|
||||
TPL_BUY_MODAL_CONTENT,
|
||||
TPL_PAYMENT_CART_ITEM,
|
||||
TPL_PAYMENT_MODAL_CART_ITEMS,
|
||||
TPL_PAYMENT_MODAL_WARNING,
|
||||
TPL_PAYMENT_METHOD_SELECTOR,
|
||||
TPL_PAYMENT_MODAL_PAYMENT_SECTION,
|
||||
TPL_PAYMENT_MODAL_CONTENT,
|
||||
TPL_PAYMENT_INTERFACE,
|
||||
TPL_ORDER_ITEM,
|
||||
TPL_ORDER_SUCCESS,
|
||||
TPL_CONFIRM_DELETE_MODAL,
|
||||
TPL_DOMAIN_AGREEMENT_MODAL,
|
||||
TPL_WHOIS_MODAL,
|
||||
} as const
|
||||
76
frontend/apps/domain-official/src/types/api-types/contact-get-user-detail.d.ts
vendored
Normal file
76
frontend/apps/domain-official/src/types/api-types/contact-get-user-detail.d.ts
vendored
Normal file
@@ -0,0 +1,76 @@
|
||||
/* Auto-generated by quicktype from API response. Do not edit by hand. */
|
||||
export interface ContactGetUserDetailResponseData {
|
||||
/** 数据 */
|
||||
data: Datum[]
|
||||
/** 页码 */
|
||||
page: string
|
||||
/** 每页数量 */
|
||||
row: string
|
||||
/** 时间 */
|
||||
shift: string
|
||||
}
|
||||
|
||||
export interface Datum {
|
||||
/** 地址(中文) */
|
||||
address: string
|
||||
/** 地址(英文) */
|
||||
address_en: string
|
||||
/** 营业执照 */
|
||||
business_license: string
|
||||
/** 城市 */
|
||||
city: string
|
||||
/** 联系人姓名 */
|
||||
contact_person: string
|
||||
/** 创建时间(Unix 时间戳,秒) */
|
||||
created_at: number
|
||||
/** 邮箱 */
|
||||
email: string
|
||||
/** 审核失败原因 */
|
||||
fail_reason: string
|
||||
/** 模板ID */
|
||||
id: number
|
||||
/** 证件照背面URL */
|
||||
id_image_back: string
|
||||
/** 证件照正面URL */
|
||||
id_image_front: string
|
||||
/** 证件号码 */
|
||||
id_number: string
|
||||
/** 证件类型 */
|
||||
id_type: number
|
||||
/** 是否默认模板:1默认,0非默认 */
|
||||
is_default: number
|
||||
/** 所有者名称(中文) */
|
||||
owner_name: string
|
||||
/** 所有者名称(英文) */
|
||||
owner_name_en: string
|
||||
/** 联系电话 */
|
||||
phone: string
|
||||
/** 邮政编码 */
|
||||
postal_code: string
|
||||
/** 注册者ID(注册局侧) */
|
||||
registrant_id: string
|
||||
/** 模板状态码 */
|
||||
status: number
|
||||
/** 模板名称 */
|
||||
template_name: string
|
||||
/** 模板状态描述 */
|
||||
template_status: string
|
||||
/** 模板类型(个人/企业等) */
|
||||
type: number
|
||||
/** 用户ID */
|
||||
uid: number
|
||||
/** 更新时间(Unix 时间戳,秒) */
|
||||
updated_at: number
|
||||
/** 审核通过时间(Unix 时间戳,秒) */
|
||||
verify_time: number
|
||||
}
|
||||
|
||||
export interface ContactGetUserDetailRequest {
|
||||
/** 页码(可选),用于分页查询,从1开始 */
|
||||
p?: number
|
||||
/** 模板类型(可选) */
|
||||
type?: number
|
||||
/** 每页行数(可选),指定查询结果的数量 */
|
||||
rows?: number
|
||||
status?: number
|
||||
}
|
||||
86
frontend/apps/domain-official/src/types/api-types/domain-query-check.d.ts
vendored
Normal file
86
frontend/apps/domain-official/src/types/api-types/domain-query-check.d.ts
vendored
Normal file
@@ -0,0 +1,86 @@
|
||||
/* Auto-generated by quicktype from API response. Do not edit by hand. */
|
||||
export interface DomainQueryCheckResponseData {
|
||||
/** 数据 */
|
||||
data: Datum[]
|
||||
/** 页码 */
|
||||
page: string
|
||||
/** 每页数量 */
|
||||
row: string
|
||||
/** 时间 */
|
||||
shift: string
|
||||
}
|
||||
|
||||
export interface Datum {
|
||||
/** 可注册 */
|
||||
available: boolean
|
||||
/** 域名 */
|
||||
domain: string
|
||||
/** 价格信息 */
|
||||
price_info: PriceInfo
|
||||
/** 查询时间 */
|
||||
query_time: Date
|
||||
/** 状态 */
|
||||
status: Status
|
||||
/** 状态描述 */
|
||||
status_desc: StatusDesc
|
||||
/** 后缀 */
|
||||
suffix: string
|
||||
}
|
||||
|
||||
export interface PriceInfo {
|
||||
/** 成本价格 */
|
||||
cost_price: number
|
||||
/** 描述 */
|
||||
description: null | string
|
||||
/** 域名类型 */
|
||||
domain_type: number
|
||||
/** 第一年折扣价格 */
|
||||
first_year_discount_price: number
|
||||
/** 第一年价格 */
|
||||
first_year_price: number
|
||||
/** 最大注册年限 */
|
||||
max_register_years: number
|
||||
/** 最低价格限制 */
|
||||
min_price_limit: number
|
||||
/** 最少注册年限 */
|
||||
min_register_years: number
|
||||
/** 推荐类型 */
|
||||
recommend_type: number
|
||||
/** 赎回折扣价格 */
|
||||
redemption_discount_price: number
|
||||
/** 赎回价格 */
|
||||
redemption_price: number
|
||||
/** 续费价格 */
|
||||
renew_price: number
|
||||
/** 续费折扣价格 */
|
||||
renewal_discount_price: number
|
||||
/** 排序顺序 */
|
||||
sort_order: number
|
||||
/** 状态 */
|
||||
status: number
|
||||
/** 后缀 */
|
||||
suffix: string
|
||||
/** 转移折扣价格 */
|
||||
transfer_discount_price: number
|
||||
/** 转移价格 */
|
||||
transfer_price: number
|
||||
}
|
||||
|
||||
/** 状态:registered 表示已注册,available 表示可注册 */
|
||||
export type Status = 'registered' | 'available'
|
||||
|
||||
/** 状态中文描述 */
|
||||
export type StatusDesc = '已注册' | '可注册'
|
||||
|
||||
export interface DomainQueryCheckRequest {
|
||||
/** 要查询的域名,不含后缀的主机名部分 */
|
||||
domain: string
|
||||
/** 页码(可选),用于分页查询,从1开始 */
|
||||
p?: number
|
||||
/** 每页行数(可选),指定查询结果的数量 */
|
||||
rows?: number
|
||||
/** 回调函数名(可选),用于JSONP请求 */
|
||||
callback?: string
|
||||
/** 推荐类型(可选),用于筛选特定类型的推荐域名后缀 */
|
||||
recommend_type?: number
|
||||
}
|
||||
15
frontend/apps/domain-official/src/types/api-types/order-cart-add.d.ts
vendored
Normal file
15
frontend/apps/domain-official/src/types/api-types/order-cart-add.d.ts
vendored
Normal file
@@ -0,0 +1,15 @@
|
||||
/* Auto-generated by quicktype from API response. Do not edit by hand. */
|
||||
export interface OrderCartAddResponseData {}
|
||||
|
||||
export interface OrderCartAddRequest {
|
||||
/** 域名主机名(不含后缀),例如 "example" */
|
||||
domain_name: string
|
||||
/** 域名后缀,例如 ".com"、".net" */
|
||||
suffix: string
|
||||
/** 域名服务类型(可选),如注册/续费/转移等 */
|
||||
type?: number
|
||||
/** 购买年限(可选),单位:年 */
|
||||
years?: number
|
||||
/** 域名ID(可选),当针对已有域名操作时使用 */
|
||||
domain_id?: number
|
||||
}
|
||||
11
frontend/apps/domain-official/src/types/api-types/order-cart-clear.d.ts
vendored
Normal file
11
frontend/apps/domain-official/src/types/api-types/order-cart-clear.d.ts
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
/* Auto-generated by quicktype from API response. Do not edit by hand. */
|
||||
export interface OrderCartClearResponseData {
|
||||
/** 是否清空成功 */
|
||||
success: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空购物车请求参数
|
||||
* 无需额外参数
|
||||
*/
|
||||
export interface OrderCartClearRequest {}
|
||||
54
frontend/apps/domain-official/src/types/api-types/order-cart-list.d.ts
vendored
Normal file
54
frontend/apps/domain-official/src/types/api-types/order-cart-list.d.ts
vendored
Normal file
@@ -0,0 +1,54 @@
|
||||
/* Auto-generated by quicktype from API response. Do not edit by hand. */
|
||||
export interface OrderCartListResponseData {
|
||||
/** 购物车条目列表 */
|
||||
items: Item[]
|
||||
/** 原价合计(未优惠) */
|
||||
original_price: number
|
||||
/** 已选中条目的数量 */
|
||||
selected_count: number
|
||||
/** 已选中条目的价格合计 */
|
||||
selected_price: number
|
||||
/** 购物车总条目数量 */
|
||||
total_count: number
|
||||
/** 购物车总价(可能包含优惠后价格) */
|
||||
total_price: number
|
||||
}
|
||||
|
||||
export interface Item {
|
||||
/** 订单创建时间 */
|
||||
created_at: number
|
||||
/** 域名名称 */
|
||||
domain_name: string
|
||||
/** 域名服务 */
|
||||
domain_service: number
|
||||
/** 域名服务价格 */
|
||||
domain_service_price: number
|
||||
/** 订单过期时间 */
|
||||
expire_time: number
|
||||
/** 完整域名 */
|
||||
full_domain: string
|
||||
/** 订单ID */
|
||||
id: number
|
||||
/** 原价 */
|
||||
original_price: number
|
||||
/** 价格 */
|
||||
price: number
|
||||
/** 实名模板ID */
|
||||
real_name_template_id: null
|
||||
/** 选中状态 */
|
||||
selected: number
|
||||
/** 会话ID */
|
||||
session_id: string
|
||||
/** 来源 */
|
||||
source: null
|
||||
/** 后缀 */
|
||||
suffix: string
|
||||
/** 用户ID */
|
||||
uid: number
|
||||
/** 订单更新时间 */
|
||||
updated_at: number
|
||||
/** 域名年限 */
|
||||
years: number
|
||||
}
|
||||
|
||||
export interface OrderCartListRequest {}
|
||||
12
frontend/apps/domain-official/src/types/api-types/order-cart-remove.d.ts
vendored
Normal file
12
frontend/apps/domain-official/src/types/api-types/order-cart-remove.d.ts
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
/* Auto-generated by quicktype from API response. Do not edit by hand. */
|
||||
export interface OrderCartRemoveResponseData {
|
||||
/** 被移除的购物车条目ID */
|
||||
cart_id: number
|
||||
/** 是否移除成功 */
|
||||
success: boolean
|
||||
}
|
||||
|
||||
export interface OrderCartRemoveRequest {
|
||||
/** 需要移除的购物车条目ID */
|
||||
cart_id: number
|
||||
}
|
||||
11
frontend/apps/domain-official/src/types/api-types/order-cart-update.d.ts
vendored
Normal file
11
frontend/apps/domain-official/src/types/api-types/order-cart-update.d.ts
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
/* Auto-generated by quicktype from API response. Do not edit by hand. */
|
||||
export interface OrderCartUpdateResponseData {}
|
||||
|
||||
export interface OrderCartUpdateRequest {
|
||||
/** 购物车条目ID */
|
||||
cart_id: number
|
||||
/** 购买年限(单位:年) */
|
||||
years: number
|
||||
/** 是否选中(0未选中,1选中) */
|
||||
is_selected: number
|
||||
}
|
||||
34
frontend/apps/domain-official/src/types/api-types/order-create.d.ts
vendored
Normal file
34
frontend/apps/domain-official/src/types/api-types/order-create.d.ts
vendored
Normal file
@@ -0,0 +1,34 @@
|
||||
/* Auto-generated by quicktype from API response. Do not edit by hand. */
|
||||
export interface OrderCreateResponseData {
|
||||
/** 支付宝支付链接 */
|
||||
ali: string
|
||||
/** 订单创建时间 */
|
||||
create_time: string
|
||||
/** 折扣金额 */
|
||||
discount_price: number
|
||||
/** 域名数量 */
|
||||
domain_count: number
|
||||
/** 订单过期时间 */
|
||||
expire_time: string
|
||||
/** 订单ID */
|
||||
order_id: number
|
||||
/** 订单编号 */
|
||||
order_no: string
|
||||
/** 原价 */
|
||||
original_price: number
|
||||
/** 支付链接 */
|
||||
payment_url: string
|
||||
/** 总价 */
|
||||
total_price: number
|
||||
/** 微信支付链接 */
|
||||
wx: string
|
||||
}
|
||||
|
||||
export interface OrderCreateRequest {
|
||||
/** 实名模板ID,用于绑定订单的实名认证信息 */
|
||||
real_name_template_id: number
|
||||
/** 订单类型,例如 1: 注册,2: 续费,3: 转移 */
|
||||
order_type: number
|
||||
/** 购物车条目ID列表,用于生成订单 */
|
||||
cart_ids: number[]
|
||||
}
|
||||
94
frontend/apps/domain-official/src/types/api-types/order-detail.d.ts
vendored
Normal file
94
frontend/apps/domain-official/src/types/api-types/order-detail.d.ts
vendored
Normal file
@@ -0,0 +1,94 @@
|
||||
/* Auto-generated by quicktype from API response. Do not edit by hand. */
|
||||
export interface OrderDetailResponseData {
|
||||
/** 取消原因,未取消时为 null */
|
||||
cancel_reason: string | null
|
||||
/** 取消时间(Unix 时间戳,秒),未取消时为 null */
|
||||
cancel_time: number | null
|
||||
/** 完成时间(Unix 时间戳,秒),未完成时可能为 0 */
|
||||
complete_time: number
|
||||
/** 创建时间(Unix 时间戳,秒) */
|
||||
created_at: number
|
||||
/** 过期时间(Unix 时间戳,秒) */
|
||||
expire_time: number
|
||||
/** 订单ID */
|
||||
id: number
|
||||
/** 订单项列表 */
|
||||
items: OrderItem[]
|
||||
/** 订单号 */
|
||||
order_no: string
|
||||
/** 订单类型 */
|
||||
order_type: number
|
||||
/** 原始价格(字符串表示,单位元) */
|
||||
original_price: string
|
||||
/** 产品类型(例如:domain) */
|
||||
product_type: string
|
||||
/** 退款原因,未退款时为 null */
|
||||
refund_reason: string | null
|
||||
/** 退款时间(Unix 时间戳,秒),未退款时为 null */
|
||||
refund_time: number | null
|
||||
/** 订单来源(例如:website) */
|
||||
source: string
|
||||
/** 订单状态 */
|
||||
status: number
|
||||
/** 订单总金额(字符串表示,单位元) */
|
||||
total_amount: string
|
||||
/** 用户ID */
|
||||
uid: number
|
||||
/** 更新时间(Unix 时间戳,秒) */
|
||||
updated_at: number
|
||||
}
|
||||
|
||||
/** 订单项 */
|
||||
export interface OrderItem {
|
||||
/** 创建时间(Unix 时间戳,秒) */
|
||||
created_at: number
|
||||
/** 折扣后单价(字符串表示,单位元) */
|
||||
discount_price: string
|
||||
/** 域名ID */
|
||||
domain_id: number
|
||||
/** 域名(不含后缀) */
|
||||
domain_name: string
|
||||
/** 附加服务类型 */
|
||||
domain_service: number
|
||||
/** 附加服务价格(单位元) */
|
||||
domain_service_price: number
|
||||
/** 域名类型 */
|
||||
domain_type: number | null
|
||||
/** 错误信息 */
|
||||
error_message: string
|
||||
/** 完整域名(含后缀) */
|
||||
full_domain: string
|
||||
/** 订单项ID */
|
||||
id: number
|
||||
/** 单价(字符串表示,单位元) */
|
||||
one_price: string
|
||||
/** 所属订单ID */
|
||||
order_id: number
|
||||
/** 订单类型 */
|
||||
order_type: number
|
||||
/** 处理时间(Unix 时间戳,秒),未处理时为 null */
|
||||
process_time: number | null
|
||||
/** 实名模板ID */
|
||||
real_name_template_id: number
|
||||
/** 退款金额(字符串表示,单位元) */
|
||||
refund_amount: string
|
||||
/** 子订单号 */
|
||||
son_order_no: string
|
||||
/** 订单项状态 */
|
||||
status: number
|
||||
/** 后缀 */
|
||||
suffix: string
|
||||
/** 订单项总金额(字符串表示,单位元) */
|
||||
total_amount: string
|
||||
/** 用户ID */
|
||||
uid: number
|
||||
/** 更新时间(Unix 时间戳,秒) */
|
||||
updated_at: number
|
||||
/** 注册年限(年) */
|
||||
years: number
|
||||
}
|
||||
|
||||
export interface OrderDetailRequest {
|
||||
/** 订单号 */
|
||||
order_no: string
|
||||
}
|
||||
10
frontend/apps/domain-official/src/types/api-types/order-payment-status.d.ts
vendored
Normal file
10
frontend/apps/domain-official/src/types/api-types/order-payment-status.d.ts
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
/* Auto-generated by quicktype from API response. Do not edit by hand. */
|
||||
export interface OrderPaymentStatusResponseData {
|
||||
/** 订单状态,1:已支付,0:未支付 */
|
||||
status: 1 | 0
|
||||
}
|
||||
|
||||
export interface OrderPaymentStatusRequest {
|
||||
/** 订单号 */
|
||||
order_no: string
|
||||
}
|
||||
88
frontend/apps/domain-official/src/types/api.d.ts
vendored
Normal file
88
frontend/apps/domain-official/src/types/api.d.ts
vendored
Normal file
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* @file API 类型声明
|
||||
* 参考文档:apps/official/doc/api.md
|
||||
*
|
||||
* - 统一返回结构:{ status, code, message, data, timestamp }
|
||||
* - 仅允许方法:GET、POST
|
||||
*/
|
||||
|
||||
/**
|
||||
* 成功响应
|
||||
* @template T 返回数据的类型
|
||||
*/
|
||||
export type ApiSuccess<T = any> = {
|
||||
/** 请求是否成功(恒为 true) */
|
||||
status: true
|
||||
/** 业务状态码(成功为 0) */
|
||||
code: 0
|
||||
/** 描述信息 */
|
||||
message: string
|
||||
/** 业务数据 */
|
||||
data: T
|
||||
/** 服务器时间戳(可选) */
|
||||
timestamp?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 失败响应
|
||||
*/
|
||||
export type ApiError = {
|
||||
/** 请求是否成功(恒为 false) */
|
||||
status: false
|
||||
/** 业务状态码(非 0)或 HTTP 状态码兜底 */
|
||||
code: number
|
||||
/** 错误描述 */
|
||||
message: string
|
||||
/** 失败时的上下文数据(可选) */
|
||||
data?: any
|
||||
/** 服务器时间戳(可选) */
|
||||
timestamp?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一响应类型
|
||||
*/
|
||||
export type ApiResponse<T = any> = ApiSuccess<T> | ApiError
|
||||
|
||||
/**
|
||||
* 允许的 HTTP 方法
|
||||
*/
|
||||
export type HttpMethod = 'GET' | 'POST'
|
||||
|
||||
/**
|
||||
* 通用请求配置
|
||||
*/
|
||||
export interface RequestConfig<TBody = any> {
|
||||
/** 相对路径,例如 /admin/check */
|
||||
url: string
|
||||
/** 请求方法,仅允许 GET / POST */
|
||||
method?: HttpMethod
|
||||
/** 请求体(GET 时将被序列化为查询字符串) */
|
||||
data?: TBody
|
||||
/** 额外请求头(会与鉴权头合并) */
|
||||
headers?: Record<string, string>
|
||||
/** 超时时间(毫秒) */
|
||||
timeout?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 客户端初始化与拦截器配置
|
||||
*/
|
||||
export interface ApiClientOptions {
|
||||
/** 基础路径,默认 /api/v1 */
|
||||
baseURL?: string
|
||||
/** API 密钥(写入请求头 X-API-Key) */
|
||||
apiKey?: string
|
||||
/** 用户 ID(写入请求头 X-UID) */
|
||||
uid?: string
|
||||
/** 跨域时是否携带凭据 */
|
||||
withCredentials?: boolean
|
||||
/** 超时时间(毫秒) */
|
||||
timeout?: number
|
||||
/** 请求拦截 */
|
||||
onRequest?: (cfg: RequestConfig) => RequestConfig | void
|
||||
/** 响应拦截(优先于内置判定) */
|
||||
onResponse?: (res: ApiResponse<any>, cfg: RequestConfig) => ApiResponse<any> | void
|
||||
/** 错误拦截(规范化错误后抛出) */
|
||||
onError?: (err: any, cfg: RequestConfig) => any
|
||||
}
|
||||
11
frontend/apps/domain-official/src/types/domain.d.ts
vendored
Normal file
11
frontend/apps/domain-official/src/types/domain.d.ts
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* 域名价格数据结构
|
||||
*/
|
||||
export type DomainPrice = {
|
||||
suffix: string
|
||||
originalPrice: number
|
||||
firstYearPrice: number
|
||||
renewPrice: number
|
||||
transferPrice: number
|
||||
isWan?: boolean
|
||||
}
|
||||
4
frontend/apps/domain-official/src/types/index.d.ts
vendored
Normal file
4
frontend/apps/domain-official/src/types/index.d.ts
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from './domain'
|
||||
export * from './api'
|
||||
export * from './utils'
|
||||
export * from './template-data-map'
|
||||
276
frontend/apps/domain-official/src/types/template-data-map.d.ts
vendored
Normal file
276
frontend/apps/domain-official/src/types/template-data-map.d.ts
vendored
Normal file
@@ -0,0 +1,276 @@
|
||||
/**
|
||||
* 模板数据映射(基于 domain-registration.html 中的所有 `<script type="text/template">`)
|
||||
*
|
||||
* 目的:
|
||||
* - 提供模板 ID 到其上下文数据类型的强类型关联
|
||||
* - 通过模块增强为 `renderTemplate`/`renderTemplateList` 提供类型提示与校验
|
||||
*/
|
||||
|
||||
// 单项结构声明
|
||||
export interface OrderSuccessDomainItem {
|
||||
domain: string
|
||||
years: number | string
|
||||
templateName: string
|
||||
formattedPrice: string
|
||||
}
|
||||
|
||||
export interface ConfirmDeleteItem {
|
||||
domain: string
|
||||
years: number | string
|
||||
formattedPrice: string
|
||||
}
|
||||
|
||||
interface SearchResultItem {
|
||||
domain: string
|
||||
isLast: boolean
|
||||
isRegistered: boolean
|
||||
statusText: string
|
||||
hasRecommended?: boolean
|
||||
hasPopular: boolean
|
||||
hasDiscount: boolean
|
||||
whoisUrl: string
|
||||
// 价格
|
||||
price: number
|
||||
originalPrice: number
|
||||
formattedPrice: string
|
||||
formattedOriginalPrice: string
|
||||
hasOriginalPrice: boolean
|
||||
// 多年价格展示
|
||||
price3Years: string
|
||||
price5Years: string
|
||||
price10Years: string
|
||||
renewPrice3Years: string
|
||||
renewPrice5Years: string
|
||||
renewPrice10Years: string
|
||||
// 购物车状态
|
||||
isInCart: boolean
|
||||
// 角标
|
||||
discountPercent?: number | string
|
||||
}
|
||||
|
||||
/**
|
||||
* 所有模板 ID 与其数据结构的映射
|
||||
*/
|
||||
export interface TemplateDataMap {
|
||||
// 空状态
|
||||
'empty-state-template': {
|
||||
icon: string
|
||||
text: string
|
||||
hint?: string
|
||||
}
|
||||
|
||||
// 搜索结果项(域名)
|
||||
'search-result-item-template': {
|
||||
list: SearchResultItem[]
|
||||
}
|
||||
|
||||
// 查看更多按钮
|
||||
'view-more-button-template': Record<string, never>
|
||||
|
||||
// 购物车项目
|
||||
'cart-item-template': {
|
||||
index: number
|
||||
domain: string
|
||||
years: number | string
|
||||
formattedOriginalPrice: string
|
||||
formattedPrice: string
|
||||
}
|
||||
|
||||
// 实名模板选项(通用)
|
||||
'template-option-template': {
|
||||
id: string | number
|
||||
name: string
|
||||
desc?: string
|
||||
formattedPrice?: string
|
||||
}
|
||||
|
||||
// 模态框容器与内容
|
||||
'modal-container-template': {
|
||||
id: string
|
||||
className?: string
|
||||
zIndex?: number | string
|
||||
}
|
||||
'modal-content-template': {
|
||||
title?: string
|
||||
closable?: boolean
|
||||
content: string
|
||||
hasButtons?: boolean
|
||||
buttonsHtml?: string
|
||||
sizeClass?: string
|
||||
}
|
||||
'modal-button-template': {
|
||||
id?: string
|
||||
typeClass?: string
|
||||
className?: string
|
||||
text: string
|
||||
onClick?: string
|
||||
}
|
||||
|
||||
// 通知
|
||||
'notification-container-template': {
|
||||
id: string
|
||||
positionClass?: string
|
||||
zIndex?: number | string
|
||||
}
|
||||
'notification-content-template': {
|
||||
bgClass?: string
|
||||
textClass?: string
|
||||
iconClass?: string
|
||||
iconColor?: string
|
||||
title?: string
|
||||
message: string
|
||||
closable?: boolean
|
||||
}
|
||||
|
||||
// 购买弹窗 - 模板选择与价格/提示
|
||||
'buy-modal-template-selector-template': {
|
||||
selectedTemplateName: string
|
||||
}
|
||||
'buy-modal-price-info-template': {
|
||||
formattedOriginalPrice: string
|
||||
hasDiscount?: boolean
|
||||
formattedDiscount?: string
|
||||
formattedPrice: string
|
||||
}
|
||||
'buy-modal-warning-template': Record<string, never>
|
||||
'buy-modal-content-template': {
|
||||
domain: string
|
||||
templateSelectorHtml: string
|
||||
priceInfoHtml: string
|
||||
warningHtml: string
|
||||
}
|
||||
|
||||
// 支付弹窗 - 购物车项与列表
|
||||
'payment-cart-item-template': {
|
||||
index: number
|
||||
selected?: boolean
|
||||
domain: string
|
||||
years: number | string
|
||||
formattedTotalPrice: string
|
||||
formattedUnitPrice: string
|
||||
}
|
||||
'payment-modal-cart-items-template': {
|
||||
totalItems: number
|
||||
cartItemsHtml: string
|
||||
}
|
||||
'payment-modal-warning-template': Record<string, never>
|
||||
|
||||
// 支付方式选择
|
||||
'payment-method-selector-template': {
|
||||
isWechatSelected?: boolean
|
||||
isAlipaySelected?: boolean
|
||||
isBalanceSelected?: boolean
|
||||
accountBalance?: string | number
|
||||
insufficientBalance?: boolean
|
||||
}
|
||||
|
||||
// 支付弹窗底部汇总与模板选择
|
||||
'payment-modal-payment-section-template': {
|
||||
selectedTemplateId?: string | number
|
||||
selectedTemplateName: string
|
||||
formattedOriginalTotal: string
|
||||
formattedDiscount?: string
|
||||
formattedPayableTotal: string
|
||||
}
|
||||
|
||||
// 支付弹窗整体内容
|
||||
'payment-modal-content-template': {
|
||||
warningHtml: string
|
||||
cartItemsHtml: string
|
||||
paymentSectionHtml: string
|
||||
}
|
||||
|
||||
// 支付界面(独立页)
|
||||
'payment-interface-template': {
|
||||
orderItemsHtml: string
|
||||
formattedOriginalTotal: string
|
||||
hasDiscount?: boolean
|
||||
formattedDiscount?: string
|
||||
formattedPayableTotal: string
|
||||
isWechatSelected?: boolean
|
||||
isAlipaySelected?: boolean
|
||||
isBalanceSelected?: boolean
|
||||
insufficientBalance?: boolean
|
||||
}
|
||||
|
||||
// 订单项目(支付界面左侧)
|
||||
'order-item-template': {
|
||||
domain: string
|
||||
years: number | string
|
||||
templateName: string
|
||||
formattedTotalPrice: string
|
||||
}
|
||||
|
||||
// 订单成功页
|
||||
'order-success-template': {
|
||||
orderNumber: string
|
||||
domains: OrderSuccessDomainItem[]
|
||||
formattedOriginalTotal: string
|
||||
hasDiscount?: boolean
|
||||
formattedDiscount?: string
|
||||
formattedPayableTotal: string
|
||||
paymentMethod: string
|
||||
paymentTime: string
|
||||
transactionId: string
|
||||
}
|
||||
|
||||
// 实名模板下拉选项
|
||||
'template-select-option-template': {
|
||||
id: string | number
|
||||
name: string
|
||||
desc?: string
|
||||
selected?: boolean
|
||||
}
|
||||
|
||||
// 确认删除弹窗
|
||||
'confirm-delete-modal-template': {
|
||||
itemCount: number
|
||||
items: ConfirmDeleteItem[]
|
||||
}
|
||||
|
||||
// 域名注册协议模态窗口
|
||||
'domain-agreement-modal-template': Record<string, never>
|
||||
|
||||
// WHOIS查询模态窗口
|
||||
'whois-modal-template': {
|
||||
domain: string
|
||||
isLoading?: boolean
|
||||
hasError?: boolean
|
||||
errorMessage?: string
|
||||
hasData?: boolean
|
||||
domainName?: string
|
||||
status?: string
|
||||
statusClass?: string
|
||||
registrar?: string
|
||||
creationDate?: string
|
||||
expirationDate?: string
|
||||
updatedDate?: string
|
||||
registrant?: string
|
||||
registrarName?: string
|
||||
emails?: string
|
||||
nameServers?: string[]
|
||||
rawData?: string
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------- 模块增强:为 @utils 提供模板 ID 智能提示与数据类型校验 ----------------
|
||||
import type { RenderTemplateInstance } from '@utils'
|
||||
|
||||
declare module '@utils' {
|
||||
/** 根据模板 ID 精确定义 renderTemplate 的数据类型 */
|
||||
// 非响应式
|
||||
export function renderTemplate<K extends keyof TemplateDataMap>(templateId: K, data: TemplateDataMap[K]): string
|
||||
|
||||
// 响应式
|
||||
export function renderTemplate<K extends keyof TemplateDataMap, T extends TemplateDataMap[K]>(
|
||||
templateId: K,
|
||||
data: T,
|
||||
options: { reactive: true }
|
||||
): RenderTemplateInstance<T>
|
||||
|
||||
/** 模板列表渲染 */
|
||||
export function renderTemplateList<K extends keyof TemplateDataMap>(
|
||||
templateId: K,
|
||||
dataArray: Array<TemplateDataMap[K]>
|
||||
): string
|
||||
}
|
||||
92
frontend/apps/domain-official/src/types/templates.ts
Normal file
92
frontend/apps/domain-official/src/types/templates.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* 模板 ID 常量(来自 `domain-registration.html` 中所有 `<script type="text/template">`)
|
||||
*
|
||||
* 使用建议:配合 `@types/template-data-map.d.ts` 的模块增强,
|
||||
* - `renderTemplate(TPL_EMPTY_STATE, { icon: '...', text: '...' })` 将获得强类型校验
|
||||
*/
|
||||
import type { TemplateDataMap } from './template-data-map'
|
||||
|
||||
/**
|
||||
* 所有模板 ID 的联合类型
|
||||
*/
|
||||
export type TemplateId = keyof TemplateDataMap
|
||||
|
||||
// 空状态与列表项
|
||||
export const TPL_EMPTY_STATE = 'empty-state-template' as const
|
||||
export const TPL_SEARCH_RESULT_ITEM = 'search-result-item-template' as const
|
||||
export const TPL_VIEW_MORE_BUTTON = 'view-more-button-template' as const
|
||||
|
||||
// 购物车
|
||||
export const TPL_CART_ITEM = 'cart-item-template' as const
|
||||
|
||||
// 模板选项
|
||||
export const TPL_TEMPLATE_OPTION = 'template-option-template' as const
|
||||
export const TPL_TEMPLATE_SELECT_OPTION = 'template-select-option-template' as const
|
||||
|
||||
// 模态框
|
||||
export const TPL_MODAL_CONTAINER = 'modal-container-template' as const
|
||||
export const TPL_MODAL_CONTENT = 'modal-content-template' as const
|
||||
export const TPL_MODAL_BUTTON = 'modal-button-template' as const
|
||||
|
||||
// 通知
|
||||
export const TPL_NOTIFICATION_CONTAINER = 'notification-container-template' as const
|
||||
export const TPL_NOTIFICATION_CONTENT = 'notification-content-template' as const
|
||||
|
||||
// 购买弹窗(域名单项)
|
||||
export const TPL_BUY_MODAL_TEMPLATE_SELECTOR = 'buy-modal-template-selector-template' as const
|
||||
export const TPL_BUY_MODAL_PRICE_INFO = 'buy-modal-price-info-template' as const
|
||||
export const TPL_BUY_MODAL_WARNING = 'buy-modal-warning-template' as const
|
||||
export const TPL_BUY_MODAL_CONTENT = 'buy-modal-content-template' as const
|
||||
|
||||
// 支付弹窗 - 购物车项目与列表
|
||||
export const TPL_PAYMENT_CART_ITEM = 'payment-cart-item-template' as const
|
||||
export const TPL_PAYMENT_MODAL_CART_ITEMS = 'payment-modal-cart-items-template' as const
|
||||
export const TPL_PAYMENT_MODAL_WARNING = 'payment-modal-warning-template' as const
|
||||
|
||||
// 支付方式/底部汇总
|
||||
export const TPL_PAYMENT_METHOD_SELECTOR = 'payment-method-selector-template' as const
|
||||
export const TPL_PAYMENT_MODAL_PAYMENT_SECTION = 'payment-modal-payment-section-template' as const
|
||||
|
||||
// 支付弹窗完整内容
|
||||
export const TPL_PAYMENT_MODAL_CONTENT = 'payment-modal-content-template' as const
|
||||
|
||||
// 支付界面
|
||||
export const TPL_PAYMENT_INTERFACE = 'payment-interface-template' as const
|
||||
|
||||
// 订单与支付结果
|
||||
export const TPL_ORDER_ITEM = 'order-item-template' as const
|
||||
export const TPL_ORDER_SUCCESS = 'order-success-template' as const
|
||||
|
||||
// 确认删除
|
||||
export const TPL_CONFIRM_DELETE_MODAL = 'confirm-delete-modal-template' as const
|
||||
|
||||
/**
|
||||
* 模板常量字典,便于遍历或注入
|
||||
*/
|
||||
export const TEMPLATES = {
|
||||
TPL_EMPTY_STATE,
|
||||
TPL_SEARCH_RESULT_ITEM,
|
||||
TPL_VIEW_MORE_BUTTON,
|
||||
TPL_CART_ITEM,
|
||||
TPL_TEMPLATE_OPTION,
|
||||
TPL_TEMPLATE_SELECT_OPTION,
|
||||
TPL_MODAL_CONTAINER,
|
||||
TPL_MODAL_CONTENT,
|
||||
TPL_MODAL_BUTTON,
|
||||
TPL_NOTIFICATION_CONTAINER,
|
||||
TPL_NOTIFICATION_CONTENT,
|
||||
TPL_BUY_MODAL_TEMPLATE_SELECTOR,
|
||||
TPL_BUY_MODAL_PRICE_INFO,
|
||||
TPL_BUY_MODAL_WARNING,
|
||||
TPL_BUY_MODAL_CONTENT,
|
||||
TPL_PAYMENT_CART_ITEM,
|
||||
TPL_PAYMENT_MODAL_CART_ITEMS,
|
||||
TPL_PAYMENT_MODAL_WARNING,
|
||||
TPL_PAYMENT_METHOD_SELECTOR,
|
||||
TPL_PAYMENT_MODAL_PAYMENT_SECTION,
|
||||
TPL_PAYMENT_MODAL_CONTENT,
|
||||
TPL_PAYMENT_INTERFACE,
|
||||
TPL_ORDER_ITEM,
|
||||
TPL_ORDER_SUCCESS,
|
||||
TPL_CONFIRM_DELETE_MODAL,
|
||||
} as const
|
||||
4
frontend/apps/domain-official/src/types/uno.d.ts
vendored
Normal file
4
frontend/apps/domain-official/src/types/uno.d.ts
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
declare module 'virtual:uno.css' {
|
||||
const css: string
|
||||
export default css
|
||||
}
|
||||
116
frontend/apps/domain-official/src/types/utils.d.ts
vendored
Normal file
116
frontend/apps/domain-official/src/types/utils.d.ts
vendored
Normal file
@@ -0,0 +1,116 @@
|
||||
// 放置在当前目录的类型声明文件,供 JS/TS 消费者使用
|
||||
|
||||
export type ModalSize = 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '4xl'
|
||||
export type ModalButtonType = 'primary' | 'secondary' | 'danger' | 'default'
|
||||
|
||||
export interface ModalButtonConfig {
|
||||
id?: string
|
||||
text: string
|
||||
type?: ModalButtonType
|
||||
className?: string
|
||||
onClick?: string
|
||||
}
|
||||
|
||||
export interface ModalConfig {
|
||||
id?: string
|
||||
className?: string
|
||||
zIndex?: number
|
||||
title?: string
|
||||
content?: string
|
||||
size?: ModalSize
|
||||
closable?: boolean
|
||||
maskClose?: boolean
|
||||
buttons?: ModalButtonConfig[]
|
||||
onShow?: (id: string) => void
|
||||
onHide?: (id: string) => void
|
||||
}
|
||||
|
||||
export type NotificationType = 'success' | 'error' | 'warning' | 'info' | 'dark'
|
||||
export type NotificationPosition =
|
||||
| 'top-right'
|
||||
| 'top-left'
|
||||
| 'bottom-right'
|
||||
| 'bottom-left'
|
||||
| 'top-center'
|
||||
| 'bottom-center'
|
||||
| 'center'
|
||||
|
||||
export interface NotificationConfig {
|
||||
id?: string
|
||||
position?: NotificationPosition
|
||||
zIndex?: number
|
||||
title?: string
|
||||
message?: string
|
||||
type?: NotificationType
|
||||
closable?: boolean
|
||||
icon?: string
|
||||
duration?: number
|
||||
onShow?: (id: string) => void
|
||||
onHide?: (id: string) => void
|
||||
}
|
||||
|
||||
export type DropdownOptionPrimitive = string | number
|
||||
|
||||
export interface DropdownOptionStruct {
|
||||
value: any
|
||||
label: string
|
||||
disabled?: boolean
|
||||
className?: string
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
export interface DropdownOptionMap {
|
||||
/** 选项值字段名,默认 'value'(对象输入时生效) */
|
||||
value?: string
|
||||
/** 选项显示文本字段名,默认 'label' | 'text'(对象输入时生效) */
|
||||
label?: string
|
||||
/** 是否禁用字段名,默认 'disabled'(对象输入时生效) */
|
||||
disabled?: string
|
||||
/** 自定义样式类字段名,默认 'className'(对象输入时生效) */
|
||||
className?: string
|
||||
}
|
||||
|
||||
export interface DropdownShowConfig<T = any, O extends Record<string, any> = any> {
|
||||
id?: string
|
||||
trigger: any
|
||||
/**
|
||||
* 直接传入 HTML 字符串内容(兼容旧用法)。如传入 `options`,该字段可省略。
|
||||
*/
|
||||
content?: string
|
||||
className?: string
|
||||
zIndex?: number
|
||||
onSelect?: (value: any, text: string, data: T) => void
|
||||
data?: T
|
||||
/**
|
||||
* 动态选项数组:可为原始值(string/number)或对象。
|
||||
* 对象时可通过 `optionMap` 指定字段映射。
|
||||
*/
|
||||
options?: Array<DropdownOptionPrimitive | O>
|
||||
/**
|
||||
* 自定义字段映射(当 `options` 为对象数组时有效)。
|
||||
*/
|
||||
optionMap?: DropdownOptionMap
|
||||
/**
|
||||
* 自定义选项渲染函数。若提供,将用其返回的 HTML 渲染每一项。
|
||||
*/
|
||||
optionRender?: (normalized: DropdownOptionStruct, raw: DropdownOptionPrimitive | O, index: number) => string
|
||||
}
|
||||
|
||||
export type SpinnerType = 'default' | 'dots' | 'pulse'
|
||||
|
||||
export interface OverlayOptions {
|
||||
className?: string
|
||||
backgroundColor?: string
|
||||
zIndex?: number
|
||||
blur?: boolean
|
||||
content?: string | null
|
||||
showSpinner?: boolean
|
||||
spinnerType?: SpinnerType
|
||||
position?: 'fixed' | 'absolute'
|
||||
}
|
||||
|
||||
export interface ContactServicePopupOptions {
|
||||
triggerSelector?: string
|
||||
popupSelector?: string
|
||||
closeOnScroll?: boolean
|
||||
}
|
||||
28
frontend/apps/domain-official/src/types/windows.d.ts
vendored
Normal file
28
frontend/apps/domain-official/src/types/windows.d.ts
vendored
Normal file
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* 窗口相关类型定义
|
||||
*/
|
||||
|
||||
/**
|
||||
* 登录状态类型
|
||||
* @description 表示用户的登录状态
|
||||
*/
|
||||
export type LoginStatus = boolean;
|
||||
|
||||
/**
|
||||
* 简化的 layer 全局对象类型(仅声明 msg 方法,避免 TS 报错)
|
||||
*/
|
||||
export interface LayerGlobal {
|
||||
msg?: (message: string, options?: any) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 窗口全局类型扩展
|
||||
*/
|
||||
declare global {
|
||||
interface Window {
|
||||
/** 用户登录状态 */
|
||||
isLoggedIn?: LoginStatus;
|
||||
/** 可选的第三方弹层库 */
|
||||
layer?: LayerGlobal;
|
||||
}
|
||||
}
|
||||
614
frontend/apps/domain-official/src/utils/core.ts
Normal file
614
frontend/apps/domain-official/src/utils/core.ts
Normal file
@@ -0,0 +1,614 @@
|
||||
/**
|
||||
* 核心工具集合
|
||||
*
|
||||
* 包含:深取值、模板渲染/列表渲染、防抖、价格格式化、URL 查询参数读写、localStorage 包装、
|
||||
* 响应式深代理状态、以及下拉位置计算等纯工具方法。
|
||||
*/
|
||||
|
||||
/**
|
||||
* 深取值:根据以点分隔的路径安全访问对象属性
|
||||
*
|
||||
* @template T
|
||||
* @param obj 输入对象
|
||||
* @param path 使用 `a.b.c` 形式的取值路径
|
||||
* @returns 取到的值或 `undefined`
|
||||
* @example
|
||||
* getDeepValue<number>({ a: { b: 1 } }, 'a.b') // => 1
|
||||
*/
|
||||
export function getDeepValue<T = any>(obj: any, path: string): T | undefined {
|
||||
if (!obj || !path) return undefined
|
||||
return path.split('.').reduce<any>((acc, part) => {
|
||||
if (acc == null) return undefined
|
||||
return (acc as any)[part]
|
||||
}, obj) as T | undefined
|
||||
}
|
||||
|
||||
// ---------------- Template Engine(支持缓存、if/elseif/else、each、嵌套) ----------------
|
||||
|
||||
type TemplateNode =
|
||||
| { type: 'text'; value: string }
|
||||
| { type: 'var'; path: string }
|
||||
| { type: 'if'; branches: Array<{ cond?: string; body: TemplateNode[] }> }
|
||||
| { type: 'each'; list: string; item: string; index: string; body: TemplateNode[] }
|
||||
|
||||
const templateCache: Map<string, { ast: TemplateNode[]; fn: (ctx: any, reactive?: boolean) => string }> = new Map()
|
||||
|
||||
function tokenize(template: string): TemplateNode[] {
|
||||
let i = 0
|
||||
const len = template.length
|
||||
const readUntil = (str: string, start: number) => {
|
||||
const idx = template.indexOf(str, start)
|
||||
return idx === -1 ? len : idx
|
||||
}
|
||||
|
||||
// 将 "#end if" / "#end each" 等结束语法标准化为 "/if" / "/each"
|
||||
const normalizeTag = (raw: string): string => {
|
||||
const t = raw.trim()
|
||||
if (t.startsWith('#end')) {
|
||||
const name = t.slice(4).trim().split(/\s+/)[0] || ''
|
||||
if (name) return `/${name}`
|
||||
return '/end'
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
function parseBlock(endTags: string[]): TemplateNode[] {
|
||||
const nodes: TemplateNode[] = []
|
||||
while (i < len) {
|
||||
const open = template.indexOf('{{', i)
|
||||
if (open === -1) {
|
||||
if (i < len) nodes.push({ type: 'text', value: template.slice(i) })
|
||||
i = len
|
||||
break
|
||||
}
|
||||
if (open > i) nodes.push({ type: 'text', value: template.slice(i, open) })
|
||||
const close = readUntil('}}', open + 2)
|
||||
const tag = template.slice(open + 2, close).trim()
|
||||
const normalizedTag = normalizeTag(tag)
|
||||
i = close + 2
|
||||
|
||||
if (endTags.includes(normalizedTag)) {
|
||||
// 不消费结束标签本身,回退到 '{{' 让上层语义节点消费(支持嵌套)
|
||||
i = open
|
||||
break
|
||||
}
|
||||
|
||||
if (tag.startsWith('#if ')) {
|
||||
const branches: Array<{ cond?: string; body: TemplateNode[] }> = []
|
||||
const firstCond = tag.slice(4).trim()
|
||||
const firstBody = parseBlock(['#elseif', '#else', '/if'])
|
||||
branches.push({ cond: firstCond, body: firstBody })
|
||||
// 消费 elseif/else
|
||||
while (i < len) {
|
||||
const lookOpen = template.indexOf('{{', i)
|
||||
if (lookOpen === -1) break
|
||||
const lookClose = readUntil('}}', lookOpen + 2)
|
||||
const lookTag = template.slice(lookOpen + 2, lookClose).trim()
|
||||
const normalizedLookTag = normalizeTag(lookTag)
|
||||
i = lookClose + 2
|
||||
if (lookTag === '#else') {
|
||||
const elseBody = parseBlock(['/if'])
|
||||
branches.push({ body: elseBody })
|
||||
break
|
||||
}
|
||||
if (lookTag.startsWith('#elseif')) {
|
||||
const cond = lookTag.slice('#elseif'.length).trim()
|
||||
const body = parseBlock(['#elseif', '#else', '/if'])
|
||||
branches.push({ cond, body })
|
||||
continue
|
||||
}
|
||||
if (normalizedLookTag === '/if') break
|
||||
// 如果遇到其他标签,回退(防止解析越界)
|
||||
i = lookOpen
|
||||
break
|
||||
}
|
||||
// 消费紧随其后的结束标签 /if(或 #end if)
|
||||
const afterOpen = template.indexOf('{{', i)
|
||||
if (afterOpen > -1) {
|
||||
const afterClose = readUntil('}}', afterOpen + 2)
|
||||
const afterTag = template.slice(afterOpen + 2, afterClose).trim()
|
||||
if (normalizeTag(afterTag) === '/if') {
|
||||
i = afterClose + 2
|
||||
}
|
||||
}
|
||||
nodes.push({ type: 'if', branches })
|
||||
continue
|
||||
}
|
||||
|
||||
if (tag.startsWith('#each ')) {
|
||||
const expr = tag.slice(6).trim()
|
||||
let list = expr
|
||||
let item = 'item'
|
||||
let index = 'index'
|
||||
const asIdx = expr.indexOf(' as ')
|
||||
if (asIdx > -1) {
|
||||
list = expr.slice(0, asIdx).trim()
|
||||
const rest = expr.slice(asIdx + 4).trim()
|
||||
const [it, idx] = rest.split(',').map(s => s.trim())
|
||||
if (it) item = it
|
||||
if (idx) index = idx
|
||||
}
|
||||
const body = parseBlock(['/each'])
|
||||
// 消费紧随其后的结束标签 /each(或 #end each)
|
||||
const afterOpen = template.indexOf('{{', i)
|
||||
if (afterOpen > -1) {
|
||||
const afterClose = readUntil('}}', afterOpen + 2)
|
||||
const afterTag = template.slice(afterOpen + 2, afterClose).trim()
|
||||
if (normalizeTag(afterTag) === '/each') {
|
||||
i = afterClose + 2
|
||||
}
|
||||
}
|
||||
nodes.push({ type: 'each', list, item, index, body })
|
||||
continue
|
||||
}
|
||||
|
||||
// '#else' 和 '#elseif' 仅用于 if 分支解析,这里回退以便上层 if 解析消费
|
||||
if (tag === '#else' || tag.startsWith('#elseif')) {
|
||||
i = open
|
||||
break
|
||||
}
|
||||
|
||||
nodes.push({ type: 'var', path: tag })
|
||||
}
|
||||
return nodes
|
||||
}
|
||||
|
||||
i = 0
|
||||
return parseBlock([])
|
||||
}
|
||||
|
||||
function renderNodes(nodes: TemplateNode[], ctx: any, reactive = false): string {
|
||||
let out = ''
|
||||
for (const node of nodes) {
|
||||
if (node.type === 'text') {
|
||||
out += node.value
|
||||
} else if (node.type === 'var') {
|
||||
const val = getDeepValue(ctx, node.path.trim())
|
||||
const text = val !== undefined && val !== null ? String(val) : ''
|
||||
out += reactive ? `<span data-t-bind="${node.path.trim()}">${text}</span>` : text
|
||||
} else if (node.type === 'if') {
|
||||
let matched = false
|
||||
for (const br of node.branches) {
|
||||
if (br.cond == null) {
|
||||
if (!matched) out += renderNodes(br.body, ctx, reactive)
|
||||
matched = true
|
||||
break
|
||||
}
|
||||
const condVal = getDeepValue(ctx, br.cond.trim())
|
||||
if (!!condVal) {
|
||||
out += renderNodes(br.body, ctx, reactive)
|
||||
matched = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if (!matched) {
|
||||
// no else body, ignore
|
||||
}
|
||||
} else if (node.type === 'each') {
|
||||
const listVal: any = getDeepValue(ctx, node.list.trim()) || []
|
||||
if (Array.isArray(listVal)) {
|
||||
listVal.forEach((it, idx) => {
|
||||
const childCtx = Object.create(ctx)
|
||||
childCtx[node.item] = it
|
||||
childCtx[node.index] = idx
|
||||
childCtx['this'] = it
|
||||
out += renderNodes(node.body, childCtx, reactive)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function compileTemplate(templateId: string) {
|
||||
const template = (window as any).$(`#${templateId}`).html?.()
|
||||
if (!template) return null
|
||||
const ast = tokenize(template)
|
||||
const fn = (ctx: any, reactive = false) => renderNodes(ast, ctx, reactive)
|
||||
return { ast, fn }
|
||||
}
|
||||
|
||||
export type RenderTemplateInstance<T extends object = any> = {
|
||||
html: string
|
||||
state: T
|
||||
bind: ($root: any) => void
|
||||
destroy: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 模板渲染
|
||||
*
|
||||
* - 非响应模式:返回渲染后的 HTML 字符串
|
||||
* - 响应模式:返回带有 `state` 的实例,通过 `createState` 代理可触发 DOM 中 `data-t-bind` 节点更新
|
||||
*
|
||||
* 使用说明:
|
||||
* 1) 放置模板:`<script type="text/template" id="tpl">姓名:{{user.name}}</script>`
|
||||
* 2) 渲染:`renderTemplate('tpl', { user: { name: '张三' } })`
|
||||
* 3) 响应式:`renderTemplate('tpl', { user: { name: '张三' } }, { reactive: true })`
|
||||
*
|
||||
* @param templateId 模板节点 id(如 `tpl`)
|
||||
* @param data 模板数据对象
|
||||
* @param options 传入 `{ reactive: true }` 开启响应模式
|
||||
* @returns HTML 字符串或带 `state/bind/destroy` 的实例
|
||||
*/
|
||||
export function renderTemplate(templateId: string, data: Record<string, any>): string
|
||||
export function renderTemplate<T extends object = any>(
|
||||
templateId: string,
|
||||
data: T,
|
||||
options: { reactive: true }
|
||||
): RenderTemplateInstance<T>
|
||||
export function renderTemplate<T extends object = any>(
|
||||
templateId: string,
|
||||
data: T,
|
||||
options?: { reactive?: boolean }
|
||||
): string | RenderTemplateInstance<T> {
|
||||
let compiled = templateCache.get(templateId)
|
||||
if (!compiled) {
|
||||
const c = compileTemplate(templateId)
|
||||
if (!c) {
|
||||
console.error(`模板 ${templateId} 不存在`)
|
||||
return '' as any
|
||||
}
|
||||
templateCache.set(templateId, c)
|
||||
compiled = c
|
||||
}
|
||||
const reactive = !!options?.reactive
|
||||
const html = compiled.fn(data, reactive)
|
||||
if (!reactive) return html
|
||||
|
||||
// reactive 模式:基于 Proxy 更新 data-t-bind 节点
|
||||
const subscribers = new Map<string, Set<HTMLElement>>()
|
||||
const instance: RenderTemplateInstance<T> = {
|
||||
html,
|
||||
state: createState(data as any, (path, value) => {
|
||||
const set = subscribers.get(path)
|
||||
if (set) set.forEach(el => (el.textContent = value == null ? '' : String(value)))
|
||||
}) as T,
|
||||
bind: ($root: any) => {
|
||||
const $ = (window as any).$
|
||||
const root = typeof $root === 'string' ? $(String($root)) : $root
|
||||
root.find('[data-t-bind]').each(function (this: HTMLElement) {
|
||||
const key = (this as any).getAttribute('data-t-bind') || ''
|
||||
if (!subscribers.has(key)) subscribers.set(key, new Set())
|
||||
subscribers.get(key)!.add(this)
|
||||
})
|
||||
},
|
||||
destroy: () => subscribers.clear(),
|
||||
}
|
||||
return instance
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量渲染模板列表并拼接为一个 HTML 字符串
|
||||
* @param templateId 模板 id
|
||||
* @param dataArray 数据列表
|
||||
* @returns 合并后的 HTML 字符串
|
||||
*/
|
||||
export function renderTemplateList(templateId: string, dataArray: Array<Record<string, any>>): string {
|
||||
return dataArray.map(data => renderTemplate(templateId, data)).join('')
|
||||
}
|
||||
|
||||
/**
|
||||
* 防抖:返回一个在指定延迟后执行的函数
|
||||
*
|
||||
* @template T
|
||||
* @param func 目标函数
|
||||
* @param delay 毫秒延迟
|
||||
* @returns 包裹后的防抖函数
|
||||
*/
|
||||
export function debounce<T extends (...args: any[]) => any>(func: T, delay: number) {
|
||||
let timeoutId: any
|
||||
return function (this: any, ...args: Parameters<T>) {
|
||||
clearTimeout(timeoutId)
|
||||
timeoutId = setTimeout(() => func.apply(this, args), delay)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 价格格式化
|
||||
* @param price 数值价格
|
||||
* @returns 形如 `¥100`
|
||||
*/
|
||||
export const formatPrice = (price: number): string => `¥${price}`
|
||||
|
||||
/**
|
||||
* 格式化价格为整数
|
||||
* @param price 数值价格
|
||||
* @returns 形如 `¥100`
|
||||
*/
|
||||
export const formatPriceInteger = (price: number): string => `¥${Math.round(price)}`
|
||||
|
||||
/**
|
||||
* 读取 URL 查询参数
|
||||
* @param name 参数名
|
||||
* @returns 参数值或 `null`
|
||||
*/
|
||||
export const getUrlParam = (name: string): string | null => {
|
||||
const urlParams = new URLSearchParams(window.location.search)
|
||||
return urlParams.get(name)
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新 URL 查询参数(存在则设置,不传值则删除),通过 `history.pushState` 无刷更新
|
||||
* @param name 参数名
|
||||
* @param value 参数值,省略时表示删除该参数
|
||||
*/
|
||||
export const updateUrlParam = (name: string, value?: string) => {
|
||||
const url = new URL(window.location.href)
|
||||
if (value) {
|
||||
url.searchParams.set(name, value)
|
||||
} else {
|
||||
url.searchParams.delete(name)
|
||||
}
|
||||
window.history.pushState({}, '', url)
|
||||
}
|
||||
|
||||
/**
|
||||
* 简易 storage:基于 localStorage 的 JSON 包装
|
||||
*/
|
||||
export const storage = {
|
||||
/**
|
||||
* 读取
|
||||
* @param key 键名
|
||||
* @returns 反序列化后的对象或 `null`
|
||||
*/
|
||||
get<T = any>(key: string): T | null {
|
||||
try {
|
||||
const item = localStorage.getItem(key)
|
||||
return item ? (JSON.parse(item) as T) : null
|
||||
} catch (e) {
|
||||
console.error('localStorage读取失败:', e)
|
||||
return null
|
||||
}
|
||||
},
|
||||
/**
|
||||
* 写入
|
||||
* @param key 键名
|
||||
* @param value 任意可序列化值
|
||||
* @returns 是否写入成功
|
||||
*/
|
||||
set<T = any>(key: string, value: T): boolean {
|
||||
try {
|
||||
localStorage.setItem(key, JSON.stringify(value))
|
||||
return true
|
||||
} catch (e) {
|
||||
console.error('localStorage保存失败:', e)
|
||||
return false
|
||||
}
|
||||
},
|
||||
/**
|
||||
* 删除
|
||||
* @param key 键名
|
||||
*/
|
||||
remove(key: string) {
|
||||
localStorage.removeItem(key)
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* 深层响应回调状态:通过 Proxy 递归代理对象,任意层级 set 时回调
|
||||
*
|
||||
* @template T
|
||||
* @param initialState 初始状态对象
|
||||
* @param callback 属性变更回调 `(propertyPath, value, state)`
|
||||
* @returns 代理后的响应式状态对象
|
||||
*/
|
||||
export function createState<T extends object>(
|
||||
initialState: T,
|
||||
callback: (propertyPath: string, value: any, state: T) => void
|
||||
): T {
|
||||
const deepProxy = (target: any, path: Array<string | number>): any => {
|
||||
return new Proxy(target, {
|
||||
get(obj, prop: string | symbol) {
|
||||
const value = Reflect.get(obj, prop)
|
||||
if (typeof value === 'object' && value !== null) {
|
||||
return deepProxy(value, path.concat(prop as string))
|
||||
}
|
||||
return value
|
||||
},
|
||||
set(obj, prop: string | symbol, value: any) {
|
||||
const oldValue = Reflect.get(obj, prop)
|
||||
if (oldValue !== value) {
|
||||
const result = Reflect.set(obj, prop, value)
|
||||
if (result) {
|
||||
const fullPath = path.concat(prop as string).join('.')
|
||||
callback(fullPath, value, initialState)
|
||||
}
|
||||
return result
|
||||
}
|
||||
return true
|
||||
},
|
||||
})
|
||||
}
|
||||
return deepProxy(initialState, [])
|
||||
}
|
||||
|
||||
/**
|
||||
* 基于 Proxy 的响应式 Store:支持订阅监听与计算属性(computed)
|
||||
*
|
||||
* 用法:
|
||||
* const { state, subscribe, getSnapshot } = createStore({ count: 0 }, {
|
||||
* double: s => s.count * 2,
|
||||
* })
|
||||
* const unsub = subscribe((path, value, s) => { console.log(path, value) })
|
||||
* state.count++ // 触发订阅,computed 自动随源值变化
|
||||
*
|
||||
* @template TState extends object
|
||||
* @template TComputed extends Record<string, (s: TState) => any>
|
||||
* @param initialState 初始状态
|
||||
* @param computed 计算属性集合,如 `{ double: s => s.count * 2 }`
|
||||
* @param options 可选项:`persistKey` 持久化键、`debug` 调试日志
|
||||
* @returns `{ state, subscribe, getSnapshot }`
|
||||
*/
|
||||
export function createStore<TState extends object, TComputed extends Record<string, (s: TState) => any> = {}>(
|
||||
initialState: TState,
|
||||
computed?: TComputed,
|
||||
options?: { persistKey?: string; debug?: boolean }
|
||||
): {
|
||||
state: TState & { [K in keyof TComputed]: ReturnType<TComputed[K]> }
|
||||
subscribe: (listener: (path: string, value: any, s: TState) => void) => () => void
|
||||
getSnapshot: () => TState & { [K in keyof TComputed]: ReturnType<TComputed[K]> }
|
||||
} {
|
||||
const listeners = new Set<(path: string, value: any, s: TState) => void>()
|
||||
const persistKey = options?.persistKey
|
||||
const debug = !!options?.debug
|
||||
|
||||
// 恢复持久化
|
||||
if (persistKey) {
|
||||
try {
|
||||
const raw = localStorage.getItem(persistKey)
|
||||
if (raw) Object.assign(initialState as any, JSON.parse(raw))
|
||||
} catch (err) {
|
||||
console.warn('createStore 持久化恢复失败:', err)
|
||||
}
|
||||
}
|
||||
|
||||
// 计算属性容器,惰性求值 + 变更时失效
|
||||
const computedGetters = computed || ({} as TComputed)
|
||||
const computedCache: Partial<Record<keyof TComputed, any>> = {}
|
||||
const invalidateComputed = () => {
|
||||
for (const key in computedCache) delete computedCache[key]
|
||||
}
|
||||
|
||||
const notify = (path: string, value: any, s: TState) => {
|
||||
listeners.forEach(fn => fn(path, value, s))
|
||||
}
|
||||
|
||||
const proxy = createState(initialState, (path, value, s) => {
|
||||
if (debug) console.debug('[store:set]', { path, value })
|
||||
invalidateComputed()
|
||||
notify(path, value, s)
|
||||
if (persistKey) {
|
||||
try {
|
||||
localStorage.setItem(persistKey, JSON.stringify(s))
|
||||
} catch {}
|
||||
}
|
||||
}) as TState
|
||||
|
||||
const withComputed = new Proxy(proxy as any, {
|
||||
get(target, prop: string | symbol, receiver) {
|
||||
if (typeof prop === 'string' && computedGetters && prop in computedGetters) {
|
||||
const key = prop as keyof TComputed
|
||||
if (!(key in computedCache)) {
|
||||
// 计算并缓存
|
||||
computedCache[key] = (computedGetters as any)[prop](target)
|
||||
}
|
||||
return computedCache[key]
|
||||
}
|
||||
return Reflect.get(target, prop, receiver)
|
||||
},
|
||||
}) as TState & { [K in keyof TComputed]: ReturnType<TComputed[K]> }
|
||||
|
||||
return {
|
||||
state: withComputed,
|
||||
subscribe(listener) {
|
||||
listeners.add(listener)
|
||||
return () => listeners.delete(listener)
|
||||
},
|
||||
getSnapshot() {
|
||||
return withComputed
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 下拉定位:根据触发元素与窗口尺寸计算下拉菜单位置与最大高度
|
||||
* @param $trigger 触发元素(jQuery 包装)
|
||||
* @param $dropdown 下拉元素(jQuery 包装)
|
||||
* @returns 定位结果 `{ top, left, maxHeight }`
|
||||
*/
|
||||
export function calculateDropdownPosition(
|
||||
$trigger: any,
|
||||
$dropdown: any
|
||||
): { top: number; left: number; maxHeight: number } {
|
||||
const triggerEl: HTMLElement | undefined = $trigger && $trigger[0]
|
||||
const dropdownEl: HTMLElement | undefined = $dropdown && $dropdown[0]
|
||||
const rect = triggerEl?.getBoundingClientRect?.()
|
||||
const triggerHeight = rect?.height ?? $trigger.outerHeight?.() ?? 0
|
||||
const triggerWidth = rect?.width ?? $trigger.outerWidth?.() ?? 0
|
||||
const dropdownHeight = $dropdown.outerHeight?.() || 200
|
||||
const margin = 10
|
||||
|
||||
// 判断定位上下文:fixed(默认:相对视口)或 absolute(相对滚动容器)
|
||||
let position: 'fixed' | 'absolute' = 'fixed'
|
||||
try {
|
||||
const cs = dropdownEl ? window.getComputedStyle(dropdownEl) : null
|
||||
if (cs && (cs.position as any) === 'absolute') position = 'absolute'
|
||||
} catch {}
|
||||
|
||||
let containerLeft = 0
|
||||
let containerTop = 0
|
||||
let boundaryWidth = (window as any).innerWidth || document.documentElement.clientWidth || document.body.clientWidth
|
||||
let boundaryHeight =
|
||||
(window as any).innerHeight || document.documentElement.clientHeight || document.body.clientHeight
|
||||
let scrollLeft = 0
|
||||
let scrollTop = 0
|
||||
if (position === 'absolute' && dropdownEl) {
|
||||
const container = (dropdownEl.offsetParent as HTMLElement) || dropdownEl.parentElement
|
||||
if (container) {
|
||||
const cRect = container.getBoundingClientRect()
|
||||
containerLeft = cRect.left
|
||||
containerTop = cRect.top
|
||||
boundaryWidth = container.clientWidth
|
||||
boundaryHeight = container.clientHeight
|
||||
scrollLeft = container.scrollLeft
|
||||
scrollTop = container.scrollTop
|
||||
}
|
||||
}
|
||||
// 在确定边界后再计算下拉宽度,避免未定位时 auto 宽度被当作视口宽度
|
||||
let dropdownWidth: number = 0
|
||||
{
|
||||
const measuredByJquery = typeof $dropdown?.outerWidth === 'function' ? Number($dropdown.outerWidth()) : 0
|
||||
const measuredByRect = dropdownEl?.getBoundingClientRect?.().width ?? 0
|
||||
const measured = Number.isFinite(measuredByJquery) && measuredByJquery > 0 ? measuredByJquery : measuredByRect
|
||||
const minWidth = Math.max(80, triggerWidth)
|
||||
const maxAllowed = Math.max(minWidth, boundaryWidth - margin * 2)
|
||||
const isReasonable = Number.isFinite(measured) && measured > 0 && measured < maxAllowed * 0.95
|
||||
dropdownWidth = isReasonable ? measured : Math.min(Math.max(minWidth, 320), maxAllowed)
|
||||
}
|
||||
// 计算横向位置
|
||||
let left = (rect?.left ?? 0) - (position === 'absolute' ? containerLeft : 0) + scrollLeft
|
||||
|
||||
if (left + dropdownWidth > boundaryWidth - margin) {
|
||||
left = boundaryWidth - dropdownWidth - margin
|
||||
}
|
||||
if (left < margin) left = margin
|
||||
|
||||
// 计算纵向位置
|
||||
let top: number = 0
|
||||
let maxHeight: number = 200
|
||||
|
||||
const topEdge = (rect?.top ?? 0) - (position === 'absolute' ? containerTop : 0) + scrollTop
|
||||
const bottomEdge = topEdge + triggerHeight
|
||||
const spaceBelow = boundaryHeight - bottomEdge - margin
|
||||
const spaceAbove = topEdge - margin
|
||||
|
||||
if (spaceBelow >= 100 || spaceBelow >= spaceAbove) {
|
||||
top = bottomEdge + 5
|
||||
maxHeight = spaceBelow - 15
|
||||
} else {
|
||||
top = topEdge - dropdownHeight - 5
|
||||
maxHeight = spaceAbove - 15
|
||||
if (top < margin) {
|
||||
top = margin
|
||||
maxHeight = Math.max(spaceAbove - 15, 100)
|
||||
}
|
||||
}
|
||||
|
||||
return { top, left, maxHeight: Math.max(100, maxHeight) }
|
||||
}
|
||||
|
||||
const Utils = {
|
||||
calculateDropdownPosition,
|
||||
renderTemplate,
|
||||
renderTemplateList,
|
||||
debounce,
|
||||
formatPrice,
|
||||
getUrlParam,
|
||||
updateUrlParam,
|
||||
storage,
|
||||
createState,
|
||||
createStore,
|
||||
}
|
||||
|
||||
export default Utils
|
||||
154
frontend/apps/domain-official/src/utils/date.ts
Normal file
154
frontend/apps/domain-official/src/utils/date.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* 日期工具集合
|
||||
*
|
||||
* 提供常见的日期格式化、解析、计算与校验工具。
|
||||
*/
|
||||
|
||||
export type DateInput = Date | number | string | null | undefined
|
||||
|
||||
function toDate(input: DateInput): Date | null {
|
||||
if (input == null) return null
|
||||
if (input instanceof Date) return isNaN(input.getTime()) ? null : input
|
||||
if (typeof input === 'number') {
|
||||
const d = new Date(input)
|
||||
return isNaN(d.getTime()) ? null : d
|
||||
}
|
||||
if (typeof input === 'string') {
|
||||
// 兼容 ISO 字符串与常见日期格式
|
||||
const ts = Date.parse(input)
|
||||
if (!isNaN(ts)) return new Date(ts)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化日期
|
||||
* @param input 日期对象/时间戳/可解析字符串
|
||||
* @param pattern 格式字符串,如:YYYY-MM-DD HH:mm:ss
|
||||
* @returns 格式化后的日期字符串
|
||||
*/
|
||||
export function formatDate(input: DateInput, pattern = 'YYYY-MM-DD HH:mm:ss'): string {
|
||||
const d = toDate(input)
|
||||
if (!d) return ''
|
||||
const YYYY = String(d.getFullYear())
|
||||
const MM = String(d.getMonth() + 1).padStart(2, '0')
|
||||
const DD = String(d.getDate()).padStart(2, '0')
|
||||
const HH = String(d.getHours()).padStart(2, '0')
|
||||
const mm = String(d.getMinutes()).padStart(2, '0')
|
||||
const ss = String(d.getSeconds()).padStart(2, '0')
|
||||
const SSS = String(d.getMilliseconds()).padStart(3, '0')
|
||||
return pattern
|
||||
.replace(/YYYY/g, YYYY)
|
||||
.replace(/MM/g, MM)
|
||||
.replace(/DD/g, DD)
|
||||
.replace(/HH/g, HH)
|
||||
.replace(/mm/g, mm)
|
||||
.replace(/ss/g, ss)
|
||||
.replace(/SSS/g, SSS)
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回当日 00:00:00 的时间戳(毫秒)
|
||||
* @param input 输入日期(默认:当前日期)
|
||||
*/
|
||||
export function startOfDay(input: DateInput = new Date()): number {
|
||||
const d = toDate(input) || new Date()
|
||||
d.setHours(0, 0, 0, 0)
|
||||
return d.getTime()
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回当日 23:59:59.999 的时间戳(毫秒)
|
||||
* @param input 输入日期(默认:当前日期)
|
||||
*/
|
||||
export function endOfDay(input: DateInput = new Date()): number {
|
||||
const d = toDate(input) || new Date()
|
||||
d.setHours(23, 59, 59, 999)
|
||||
return d.getTime()
|
||||
}
|
||||
|
||||
/**
|
||||
* 在日期上增加/减少天数(负数为减少)
|
||||
* @param input 输入日期
|
||||
* @param days 天数,可为负数
|
||||
* @returns 新的日期对象,非法输入返回 `null`
|
||||
*/
|
||||
export function addDays(input: DateInput, days: number): Date | null {
|
||||
const d = toDate(input)
|
||||
if (!d) return null
|
||||
const copy = new Date(d.getTime())
|
||||
copy.setDate(copy.getDate() + days)
|
||||
return copy
|
||||
}
|
||||
|
||||
/**
|
||||
* 友好相对时间(中文):如 “3分钟前”,“2天后”
|
||||
* @param input 目标时间
|
||||
* @param reference 参考时间(默认:当前时间)
|
||||
* @returns 相对时间中文描述
|
||||
*/
|
||||
export function fromNow(input: DateInput, reference: DateInput = new Date()): string {
|
||||
const d = toDate(input)
|
||||
const r = toDate(reference)
|
||||
if (!d || !r) return ''
|
||||
const diff = d.getTime() - r.getTime()
|
||||
const abs = Math.abs(diff)
|
||||
const future = diff > 0
|
||||
const minute = 60 * 1000
|
||||
const hour = 60 * minute
|
||||
const day = 24 * hour
|
||||
const week = 7 * day
|
||||
const month = 30 * day
|
||||
const year = 365 * day
|
||||
const fmt = (n: number, u: string) => `${n}${u}${future ? '后' : '前'}`
|
||||
if (abs < minute) return '刚刚'
|
||||
if (abs < hour) return fmt(Math.floor(abs / minute), '分钟')
|
||||
if (abs < day) return fmt(Math.floor(abs / hour), '小时')
|
||||
if (abs < week) return fmt(Math.floor(abs / day), '天')
|
||||
if (abs < month) return fmt(Math.floor(abs / week), '周')
|
||||
if (abs < year) return fmt(Math.floor(abs / month), '个月')
|
||||
return fmt(Math.floor(abs / year), '年')
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为有效日期
|
||||
* @param input 待校验值
|
||||
* @returns 是否有效
|
||||
*/
|
||||
export function isValidDate(input: DateInput): boolean {
|
||||
const d = toDate(input)
|
||||
return !!d && !isNaN(d.getTime())
|
||||
}
|
||||
|
||||
/**
|
||||
* 转换为时间戳(毫秒)
|
||||
* @param input 输入日期
|
||||
* @returns 毫秒时间戳,非法输入返回 `null`
|
||||
*/
|
||||
export function toTimestamp(input: DateInput): number | null {
|
||||
const d = toDate(input)
|
||||
return d ? d.getTime() : null
|
||||
}
|
||||
|
||||
/**
|
||||
* 从时间戳构造日期
|
||||
* @param ts 毫秒时间戳或其字符串
|
||||
* @returns Date 对象,非法输入返回 `null`
|
||||
*/
|
||||
export function fromTimestamp(ts: number | string): Date | null {
|
||||
const num = typeof ts === 'string' ? Number(ts) : ts
|
||||
if (isNaN(num)) return null
|
||||
const d = new Date(num)
|
||||
return isNaN(d.getTime()) ? null : d
|
||||
}
|
||||
|
||||
export default {
|
||||
formatDate,
|
||||
startOfDay,
|
||||
endOfDay,
|
||||
addDays,
|
||||
fromNow,
|
||||
isValidDate,
|
||||
toTimestamp,
|
||||
fromTimestamp,
|
||||
}
|
||||
5
frontend/apps/domain-official/src/utils/index.d.ts
vendored
Normal file
5
frontend/apps/domain-official/src/utils/index.d.ts
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
export * from '../types/utils'
|
||||
export * from './core'
|
||||
export * from './ui'
|
||||
export * from './date'
|
||||
export * from './type'
|
||||
73
frontend/apps/domain-official/src/utils/index.ts
Normal file
73
frontend/apps/domain-official/src/utils/index.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* @module @utils
|
||||
* 新的聚合入口,统一导出 `core` 与 `ui` 两类通用工具,降低导入复杂度。
|
||||
*
|
||||
* - 核心方法(Core):数据获取、模版渲染、防抖、价格格式化、URL 参数处理、存储与轻量状态等
|
||||
* - UI 管理(UI):模态框、通知、下拉与遮罩层的展示/管理器
|
||||
*
|
||||
* 导入方式:
|
||||
* ```ts
|
||||
* import { calculateDropdownPosition, ModalManager } from '@utils'
|
||||
* // 或
|
||||
* import Utils from '@utils'
|
||||
* ```
|
||||
*/
|
||||
export * from './core'
|
||||
export * from './ui'
|
||||
export * from './date'
|
||||
export * from './type'
|
||||
|
||||
import {
|
||||
getDeepValue,
|
||||
calculateDropdownPosition,
|
||||
renderTemplate,
|
||||
renderTemplateList,
|
||||
debounce,
|
||||
formatPrice,
|
||||
getUrlParam,
|
||||
updateUrlParam,
|
||||
storage,
|
||||
createState,
|
||||
createStore,
|
||||
} from './core'
|
||||
import * as DateUtils from './date'
|
||||
import * as TypeUtils from './type'
|
||||
|
||||
import { ModalManager, NotificationManager, DropdownManager, OverlayManager } from './ui'
|
||||
|
||||
export {
|
||||
getDeepValue,
|
||||
calculateDropdownPosition,
|
||||
renderTemplate,
|
||||
renderTemplateList,
|
||||
debounce,
|
||||
formatPrice,
|
||||
getUrlParam,
|
||||
updateUrlParam,
|
||||
storage,
|
||||
createState,
|
||||
createStore,
|
||||
ModalManager,
|
||||
NotificationManager,
|
||||
DropdownManager,
|
||||
OverlayManager,
|
||||
DateUtils,
|
||||
TypeUtils,
|
||||
}
|
||||
|
||||
export const Utils = {
|
||||
calculateDropdownPosition,
|
||||
renderTemplate,
|
||||
renderTemplateList,
|
||||
debounce,
|
||||
formatPrice,
|
||||
getUrlParam,
|
||||
updateUrlParam,
|
||||
storage,
|
||||
createState,
|
||||
createStore,
|
||||
DateUtils,
|
||||
TypeUtils,
|
||||
}
|
||||
|
||||
export default Utils
|
||||
114
frontend/apps/domain-official/src/utils/type.ts
Normal file
114
frontend/apps/domain-official/src/utils/type.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* 类型工具集合
|
||||
*
|
||||
* 包含:类型判断、浅深合并、克隆、空值判断等
|
||||
*/
|
||||
|
||||
/** 判断是否为 null 或 undefined */
|
||||
export function isNullish(value: unknown): value is null | undefined {
|
||||
return value === null || value === undefined
|
||||
}
|
||||
|
||||
/** 判断是否为普通对象(原型为 Object.prototype 或 null) */
|
||||
export function isPlainObject(value: unknown): value is Record<string, any> {
|
||||
if (Object.prototype.toString.call(value) !== '[object Object]') return false
|
||||
const proto = Object.getPrototypeOf(value)
|
||||
return proto === null || proto === Object.prototype
|
||||
}
|
||||
|
||||
/** 判断是否为数组 */
|
||||
export function isArray<T = unknown>(value: unknown): value is Array<T> {
|
||||
return Array.isArray(value)
|
||||
}
|
||||
|
||||
/** 判断是否为函数 */
|
||||
export function isFunction<T extends Function = Function>(value: unknown): value is T {
|
||||
return typeof value === 'function'
|
||||
}
|
||||
|
||||
/** 判断是否为字符串 */
|
||||
export function isString(value: unknown): value is string {
|
||||
return typeof value === 'string'
|
||||
}
|
||||
|
||||
/** 判断是否为数字(排除 NaN) */
|
||||
export function isNumber(value: unknown): value is number {
|
||||
return typeof value === 'number' && !isNaN(value)
|
||||
}
|
||||
|
||||
/** 判断是否为布尔 */
|
||||
export function isBoolean(value: unknown): value is boolean {
|
||||
return value === true || value === false
|
||||
}
|
||||
|
||||
/** 判断是否为有效 Date 对象 */
|
||||
export function isDate(value: unknown): value is Date {
|
||||
return value instanceof Date && !isNaN(value.getTime())
|
||||
}
|
||||
|
||||
/**
|
||||
* 浅合并(前者为基础,后者覆盖)
|
||||
* @returns 合并后的新对象
|
||||
*/
|
||||
export function shallowMerge<T extends object, S extends object>(base: T, override: S): T & S {
|
||||
return Object.assign({}, base, override) as T & S
|
||||
}
|
||||
|
||||
/**
|
||||
* 深合并:数组直接替换,对象递归合并
|
||||
* @returns 合并后的新对象
|
||||
*/
|
||||
export function deepMerge<T extends object, S extends object>(target: T, source: S): T & S {
|
||||
const result: any = { ...target }
|
||||
Object.keys(source as any).forEach(key => {
|
||||
const sVal: any = (source as any)[key]
|
||||
const tVal: any = (result as any)[key]
|
||||
if (isPlainObject(tVal) && isPlainObject(sVal)) {
|
||||
;(result as any)[key] = deepMerge(tVal, sVal)
|
||||
} else {
|
||||
;(result as any)[key] = sVal
|
||||
}
|
||||
})
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* 深克隆:处理对象/数组/Date/RegExp/Map/Set
|
||||
* @returns 深拷贝后的新对象
|
||||
*/
|
||||
export function deepClone<T>(input: T): T {
|
||||
if (isNullish(input) || typeof input !== 'object') return input
|
||||
if (isDate(input)) return new Date(input.getTime()) as any
|
||||
if (input instanceof RegExp) return new RegExp(input) as any
|
||||
if (input instanceof Map)
|
||||
return new Map(Array.from(input.entries()).map(([k, v]) => [deepClone(k), deepClone(v)])) as any
|
||||
if (input instanceof Set) return new Set(Array.from(input.values()).map(v => deepClone(v))) as any
|
||||
if (Array.isArray(input)) return input.map(item => deepClone(item)) as any
|
||||
const out: any = {}
|
||||
Object.keys(input as any).forEach(key => {
|
||||
out[key] = deepClone((input as any)[key])
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断对象是否为空(无可枚举属性)
|
||||
*/
|
||||
export function isEmptyObject(value: unknown): value is Record<string, never> {
|
||||
return isPlainObject(value) && Object.keys(value).length === 0
|
||||
}
|
||||
|
||||
export default {
|
||||
isNullish,
|
||||
isPlainObject,
|
||||
isArray,
|
||||
isFunction,
|
||||
isString,
|
||||
isNumber,
|
||||
isBoolean,
|
||||
isDate,
|
||||
shallowMerge,
|
||||
deepMerge,
|
||||
deepClone,
|
||||
isEmptyObject,
|
||||
}
|
||||
830
frontend/apps/domain-official/src/utils/ui.ts
Normal file
830
frontend/apps/domain-official/src/utils/ui.ts
Normal file
@@ -0,0 +1,830 @@
|
||||
/**
|
||||
* UI 管理工具集合
|
||||
*
|
||||
* 提供 Dropdown / Modal / Overlay / Notification 等界面元素的展示与生命周期管理,
|
||||
* 封装创建、定位、过渡动画、事件绑定与批量隐藏等常见逻辑。
|
||||
*
|
||||
* 依赖:`window.$`(jQuery 兼容接口)与模版渲染工具。
|
||||
*/
|
||||
import type {
|
||||
DropdownShowConfig,
|
||||
ModalConfig,
|
||||
NotificationConfig,
|
||||
OverlayOptions,
|
||||
SpinnerType,
|
||||
DropdownOptionStruct,
|
||||
} from '../types/utils'
|
||||
import { calculateDropdownPosition } from './core'
|
||||
import { renderTemplate } from './core'
|
||||
|
||||
// ---------------- 全局层级管理(ZIndexManager) ----------------
|
||||
/**
|
||||
* 全局层级管理器
|
||||
*
|
||||
* - 通过递增 `z-index` 管理多类浮层叠放关系
|
||||
* - `acquire(key)` 获取新层级,`release(key)` 释放
|
||||
*/
|
||||
class ZIndexManagerClass {
|
||||
private base = 50
|
||||
private step = 10
|
||||
private stack: Array<{ key: string; z: number }> = []
|
||||
|
||||
/** 设置基础层级 */
|
||||
setBase(base: number) {
|
||||
this.base = base
|
||||
}
|
||||
/** 设置层级步进 */
|
||||
setStep(step: number) {
|
||||
this.step = step
|
||||
}
|
||||
/**
|
||||
* 申请一个 z-index
|
||||
* @param key 实例标识(如 dropdown-1)
|
||||
* @returns 新的 z-index
|
||||
*/
|
||||
acquire(key: string): number {
|
||||
const last = this.stack.length ? this.stack[this.stack.length - 1] : undefined
|
||||
const top = last ? last.z : this.base
|
||||
const next = top + this.step
|
||||
this.stack.push({ key, z: next })
|
||||
return next
|
||||
}
|
||||
/** 释放某个实例对应的 z-index */
|
||||
release(key: string) {
|
||||
this.stack = this.stack.filter(i => i.key !== key)
|
||||
}
|
||||
/** 查看当前栈顶层级 */
|
||||
peek(): number {
|
||||
const last = this.stack.length ? this.stack[this.stack.length - 1] : undefined
|
||||
return last ? last.z : this.base
|
||||
}
|
||||
/** 清空层级栈 */
|
||||
reset() {
|
||||
this.stack = []
|
||||
}
|
||||
}
|
||||
|
||||
export const ZIndexManager = new ZIndexManagerClass()
|
||||
|
||||
// ---------------- Dropdown(Class) ----------------
|
||||
type ActiveDropdown = { $element: any; $trigger: any; config: DropdownShowConfig }
|
||||
|
||||
/**
|
||||
* 下拉菜单管理器
|
||||
* - show/hide/hideAll/updateContent/getActiveDropdowns
|
||||
*/
|
||||
class DropdownManagerClass {
|
||||
private activeDropdowns: Map<string, ActiveDropdown> = new Map()
|
||||
private dropdownIdCounter = 0
|
||||
|
||||
private createDropdownContainer(config: {
|
||||
id: string
|
||||
className?: string
|
||||
zIndex?: number
|
||||
position?: 'fixed' | 'absolute'
|
||||
appendTo?: any
|
||||
}) {
|
||||
const { id, className = '', zIndex = ZIndexManager.acquire(id), position = 'fixed', appendTo } = config
|
||||
const $dropdown = (window as any).$(
|
||||
`<div id="${id}" class="dropdown-container ${className}" style="position: ${position}; z-index: ${zIndex}; display: none; opacity: 0; transition: opacity 120ms ease-out;"></div>`
|
||||
)
|
||||
const $mount = appendTo ? appendTo : (window as any).$('body')
|
||||
$mount.append($dropdown)
|
||||
return $dropdown
|
||||
}
|
||||
|
||||
private getScrollContainer(element: HTMLElement | null): HTMLElement | null {
|
||||
if (!element) return null
|
||||
let node: HTMLElement | null = element.parentElement
|
||||
while (node && node !== document.body && node !== document.documentElement) {
|
||||
const style = window.getComputedStyle(node)
|
||||
const overflowY = style.overflowY
|
||||
const isScrollable = /auto|scroll|overlay/.test(overflowY)
|
||||
if (isScrollable && node.scrollHeight > node.clientHeight) {
|
||||
// 确保定位上下文
|
||||
if (style.position === 'static') node.style.position = 'relative'
|
||||
return node
|
||||
}
|
||||
node = node.parentElement
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private positionDropdown($dropdown: any, $trigger: any) {
|
||||
const position = calculateDropdownPosition($trigger, $dropdown)
|
||||
$dropdown.css({
|
||||
top: position.top,
|
||||
left: position.left,
|
||||
minWidth: $trigger.outerWidth?.(),
|
||||
maxHeight: position.maxHeight,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示下拉菜单
|
||||
* @returns dropdownId
|
||||
*/
|
||||
show(config: DropdownShowConfig): string {
|
||||
const { trigger, content, className = '', zIndex = 1000, onSelect, data = {} } = config
|
||||
const dropdownId = config.id || `dropdown-${++this.dropdownIdCounter}`
|
||||
|
||||
this.hideAll()
|
||||
if (this.activeDropdowns.has(dropdownId)) this.hide(dropdownId)
|
||||
|
||||
const $trigger = (window as any).$(trigger)
|
||||
const scrollContainer = this.getScrollContainer(($trigger[0] as HTMLElement) || null)
|
||||
const useAbsolute = !!scrollContainer
|
||||
const $dropdown = this.createDropdownContainer({
|
||||
id: dropdownId,
|
||||
className,
|
||||
zIndex,
|
||||
position: useAbsolute ? 'absolute' : 'fixed',
|
||||
appendTo: useAbsolute ? (window as any).$(scrollContainer) : undefined,
|
||||
})
|
||||
|
||||
// 动态渲染:若传入 options,则优先根据 options 渲染内容
|
||||
let finalContent = content || ''
|
||||
if (Array.isArray((config as any).options) && (config as any).options.length > 0) {
|
||||
const { options = [], optionMap, optionRender } = config as any
|
||||
const valueKey = optionMap?.value ?? 'value'
|
||||
const labelKey = optionMap?.label ?? optionMap?.text ?? 'label'
|
||||
const disabledKey = optionMap?.disabled ?? 'disabled'
|
||||
const classKey = optionMap?.className ?? 'className'
|
||||
|
||||
const normalize = (item: any): DropdownOptionStruct => {
|
||||
if (typeof item === 'string' || typeof item === 'number') {
|
||||
return { value: item, label: String(item) }
|
||||
}
|
||||
const value = item?.[valueKey]
|
||||
const label = item?.[labelKey] ?? item?.text ?? item?.label ?? ''
|
||||
const disabled = !!item?.[disabledKey]
|
||||
const className = item?.[classKey]
|
||||
return { value, label, disabled, className, ...item }
|
||||
}
|
||||
|
||||
const htmlList = (options as any[]).map((raw, idx) => {
|
||||
const n = normalize(raw)
|
||||
if (typeof optionRender === 'function') {
|
||||
return optionRender(n, raw, idx)
|
||||
}
|
||||
const disabledAttr = n.disabled ? ' data-disabled="true" aria-disabled="true"' : ''
|
||||
const classAttr = `dropdown-option ${n.className ? n.className : ''}`.trim()
|
||||
return `<div class="${classAttr}" data-value="${n.value}"${disabledAttr}>${n.label}</div>`
|
||||
})
|
||||
finalContent = htmlList.join('')
|
||||
}
|
||||
|
||||
$dropdown.html(finalContent)
|
||||
$dropdown.css('display', 'block')
|
||||
|
||||
this.positionDropdown($dropdown, $trigger)
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
$dropdown.css('opacity', 1)
|
||||
})
|
||||
|
||||
$dropdown.on('click', '.dropdown-option', function (this: any, e: any) {
|
||||
e.stopPropagation()
|
||||
const $self = (window as any).$(this)
|
||||
if ($self.data('disabled') || $self.attr('aria-disabled') === 'true') return
|
||||
const value = $self.data('value')
|
||||
const text = $self.text()
|
||||
if (typeof onSelect === 'function') onSelect(value, text, data as any)
|
||||
DropdownManager.hide(dropdownId)
|
||||
})
|
||||
|
||||
this.activeDropdowns.set(dropdownId, {
|
||||
$element: $dropdown,
|
||||
$trigger: (window as any).$(trigger),
|
||||
config,
|
||||
})
|
||||
;(window as any).$(document).on(`click.dropdown.${dropdownId}`, (e: any) => {
|
||||
const $triggerElement = (window as any).$(trigger)
|
||||
if (
|
||||
!(window as any).$(e.target).closest(`#${dropdownId}`).length &&
|
||||
!$triggerElement.is(e.target) &&
|
||||
!$triggerElement.has(e.target).length
|
||||
) {
|
||||
this.hide(dropdownId)
|
||||
}
|
||||
})
|
||||
|
||||
return dropdownId
|
||||
}
|
||||
|
||||
/** 隐藏指定下拉菜单 */
|
||||
hide(dropdownId: string) {
|
||||
const dropdown = this.activeDropdowns.get(dropdownId)
|
||||
if (!dropdown) return
|
||||
const { $element } = dropdown
|
||||
$element.css('opacity', 0)
|
||||
$element.one('transitionend webkitTransitionEnd oTransitionEnd', function () {
|
||||
$element.remove()
|
||||
})
|
||||
setTimeout(() => {
|
||||
if (this.activeDropdowns.has(dropdownId)) $element.remove()
|
||||
}, 150)
|
||||
this.activeDropdowns.delete(dropdownId)
|
||||
;(window as any).$(document).off(`click.dropdown.${dropdownId}`)
|
||||
ZIndexManager.release(dropdownId)
|
||||
}
|
||||
|
||||
/** 隐藏所有下拉菜单 */
|
||||
hideAll() {
|
||||
this.activeDropdowns.forEach((_dropdown, dropdownId) => this.hide(dropdownId))
|
||||
}
|
||||
|
||||
/** 更新下拉内容并重新定位 */
|
||||
updateContent(dropdownId: string, content: string) {
|
||||
const dropdown = this.activeDropdowns.get(dropdownId)
|
||||
if (!dropdown) return
|
||||
const { $element, $trigger } = dropdown
|
||||
$element.html(content)
|
||||
this.positionDropdown($element, $trigger)
|
||||
}
|
||||
|
||||
/** 获取当前活跃的下拉菜单 id 列表 */
|
||||
getActiveDropdowns(): string[] {
|
||||
return Array.from(this.activeDropdowns.keys())
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DropdownManager
|
||||
*
|
||||
* - show(config): 显示下拉菜单并返回唯一 `dropdownId`
|
||||
* - hide(id): 隐藏指定下拉菜单
|
||||
* - hideAll(): 隐藏当前所有下拉菜单
|
||||
* - updateContent(id, html): 更新内容并重新定位
|
||||
* - getActiveDropdowns(): 获取当前活跃的下拉菜单 id 列表
|
||||
*/
|
||||
export const DropdownManager = new DropdownManagerClass()
|
||||
|
||||
// ---------------- Modal(Class) ----------------
|
||||
type ActiveModal = { $element: any; config: ModalConfig }
|
||||
|
||||
/**
|
||||
* 模态框管理器
|
||||
* - show/hide/hideAll/getActiveModals
|
||||
*/
|
||||
class ModalManagerClass {
|
||||
private activeModals: Map<string, ActiveModal> = new Map()
|
||||
private modalIdCounter = 0
|
||||
|
||||
private createModalContainer(config: { id: string; className?: string; zIndex?: number }) {
|
||||
const { id, className = '', zIndex = ZIndexManager.acquire(id) } = config
|
||||
const modalHtml = renderTemplate('modal-container-template', { id, className, zIndex })
|
||||
const $modal = (window as any).$(modalHtml)
|
||||
;(window as any).$('body').append($modal)
|
||||
return $modal
|
||||
}
|
||||
|
||||
private createModalContent(config: ModalConfig) {
|
||||
const { title = '', content = '', buttons = [], size = 'md', closable = true } = config
|
||||
const sizeClasses: Record<string, string> = {
|
||||
sm: 'max-w-sm',
|
||||
md: 'max-w-md',
|
||||
lg: 'max-w-lg',
|
||||
xl: 'max-w-xl',
|
||||
'2xl': 'max-w-2xl',
|
||||
'4xl': 'max-w-4xl',
|
||||
}
|
||||
const typeClasses: Record<string, string> = {
|
||||
primary: 'bg-primary hover:bg-primary-90 text-white font-bold',
|
||||
secondary: 'border border-gray-200 hover:bg-light text-secondary font-medium',
|
||||
danger: 'bg-red-500 hover:bg-red-600 text-white font-bold',
|
||||
default: 'border border-gray-200 hover:bg-light text-secondary font-medium',
|
||||
}
|
||||
const buttonsHtml = (buttons || [])
|
||||
.map(btn => {
|
||||
const { text, type = 'default', className = '', onClick = '', id = '' } = btn
|
||||
return renderTemplate('modal-button-template', { id, typeClass: typeClasses[type], className, onClick, text })
|
||||
})
|
||||
.join('')
|
||||
|
||||
return renderTemplate('modal-content-template', {
|
||||
sizeClass: sizeClasses[size || 'md'],
|
||||
title,
|
||||
closable,
|
||||
content,
|
||||
hasButtons: (buttons || []).length > 0,
|
||||
buttonsHtml,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示模态框
|
||||
* @returns modalId
|
||||
*/
|
||||
show(config: ModalConfig): string {
|
||||
const modalId = config.id || `modal-${++this.modalIdCounter}`
|
||||
if (this.activeModals.has(modalId)) this.hide(modalId)
|
||||
|
||||
const $modal = this.createModalContainer({
|
||||
id: modalId,
|
||||
className: config.className || '',
|
||||
zIndex: config.zIndex || 50,
|
||||
})
|
||||
const contentHtml = this.createModalContent(config)
|
||||
$modal.html(contentHtml)
|
||||
|
||||
$modal.on('click', '.modal-close', () => this.hide(modalId))
|
||||
$modal.on('click', (e: any) => {
|
||||
if (e.target === $modal[0] && config.maskClose) this.hide(modalId)
|
||||
})
|
||||
|
||||
this.activeModals.set(modalId, { $element: $modal, config })
|
||||
|
||||
$modal.removeClass('hidden').addClass('flex')
|
||||
requestAnimationFrame(() => {
|
||||
$modal.find('.modal-content').removeClass('scale-95 opacity-0').addClass('scale-100 opacity-100')
|
||||
})
|
||||
|
||||
if (typeof config.onShow === 'function') config.onShow(modalId)
|
||||
return modalId
|
||||
}
|
||||
|
||||
/** 隐藏指定模态框 */
|
||||
hide(modalId: string) {
|
||||
const modal = this.activeModals.get(modalId)
|
||||
if (!modal) return
|
||||
const { $element, config } = modal
|
||||
const $modalContent = $element.find('.modal-content')
|
||||
|
||||
$modalContent
|
||||
.one('transitionend webkitTransitionEnd oTransitionEnd', () => {
|
||||
$element.remove()
|
||||
this.activeModals.delete(modalId)
|
||||
ZIndexManager.release(modalId)
|
||||
if (typeof config.onHide === 'function') config.onHide(modalId)
|
||||
})
|
||||
.removeClass('scale-100 opacity-100')
|
||||
.addClass('scale-95 opacity-0')
|
||||
|
||||
setTimeout(() => {
|
||||
if (this.activeModals.has(modalId)) {
|
||||
$element.remove()
|
||||
this.activeModals.delete(modalId)
|
||||
ZIndexManager.release(modalId)
|
||||
if (typeof config.onHide === 'function') config.onHide(modalId)
|
||||
}
|
||||
}, 300)
|
||||
}
|
||||
|
||||
/** 隐藏所有模态框 */
|
||||
hideAll() {
|
||||
this.activeModals.forEach((_modal, modalId) => this.hide(modalId))
|
||||
}
|
||||
|
||||
/** 获取当前活跃的模态框 id 列表 */
|
||||
getActiveModals(): string[] {
|
||||
return Array.from(this.activeModals.keys())
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ModalManager
|
||||
*
|
||||
* - show(config): 显示模态框并返回唯一 `modalId`
|
||||
* - hide(id): 隐藏指定模态框
|
||||
* - hideAll(): 隐藏当前所有模态框
|
||||
* - getActiveModals(): 获取当前活跃的模态框 id 列表
|
||||
*/
|
||||
export const ModalManager = new ModalManagerClass()
|
||||
|
||||
/**
|
||||
* 遮罩层管理器
|
||||
* - 支持全局与视图级遮罩,含加载动画与文案更新
|
||||
*/
|
||||
class OverlayManagerClass {
|
||||
private globalOverlay: { el: any; id: string } | null = null
|
||||
private viewOverlays: Map<string, { overlay: any; originalPosition: string; id: string }> = new Map()
|
||||
private overlayCounter = 0
|
||||
|
||||
private getSpinnerHTML(type: SpinnerType): string {
|
||||
switch (type) {
|
||||
case 'dots':
|
||||
return `<div class="spinner-dots" style="display: flex; gap: 4px;"><div class="dot" style="width: 8px; height: 8px; border-radius: 50%; background-color: currentColor; animation: dotPulse 1.4s infinite ease-in-out both;"></div><div class="dot" style="width: 8px; height: 8px; border-radius: 50%; background-color: currentColor; animation: dotPulse 1.4s infinite ease-in-out both; animation-delay: -0.16s;"></div><div class="dot" style="width: 8px; height: 8px; border-radius: 50%; background-color: currentColor; animation: dotPulse 1.4s infinite ease-in-out both; animation-delay: -0.32s;"></div></div>`
|
||||
case 'pulse':
|
||||
return `<div class="spinner-pulse" style="width: 40px; height: 40px; border-radius: 50%; background-color: currentColor; animation: pulse 1.5s infinite ease-in-out;"></div>`
|
||||
default:
|
||||
return `<div class="spinner-default" style="width: 40px; height: 40px; border: 4px solid rgba(255, 255, 255, 0.3); border-top: 4px solid currentColor; border-radius: 50%; animation: spin 1s linear infinite;"></div>`
|
||||
}
|
||||
}
|
||||
|
||||
private injectStyles() {
|
||||
if ((window as any).$('#overlay-styles').length === 0) {
|
||||
;(window as any).$('head').append(
|
||||
`<style id="overlay-styles">
|
||||
@keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
|
||||
@keyframes dotPulse { 0%, 80%, 100% { transform: scale(0); } 40% { transform: scale(1); } }
|
||||
@keyframes pulse { 0% { transform: scale(0); opacity: 1; } 100% { transform: scale(1); opacity: 0; } }
|
||||
.transition-overlay { user-select: none; pointer-events: auto; }
|
||||
.transition-overlay.fade-in { opacity: 1 !important; }
|
||||
body.overlay-lock-scroll { overflow: hidden !important; }
|
||||
</style>`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private createOverlayElement(options: OverlayOptions = {}) {
|
||||
const {
|
||||
className = '',
|
||||
backgroundColor = 'rgba(0, 0, 0, 0.2)',
|
||||
zIndex,
|
||||
blur = false,
|
||||
content = null,
|
||||
showSpinner = true,
|
||||
spinnerType = 'default',
|
||||
position = 'fixed',
|
||||
} = options
|
||||
const overlayId = `overlay-${++this.overlayCounter}`
|
||||
const finalZ = typeof zIndex === 'number' ? zIndex : ZIndexManager.acquire(overlayId)
|
||||
const $overlay = (window as any).$(
|
||||
`<div id="${overlayId}" class="transition-overlay ${className}" style="position: ${position}; top: 0; left: 0; width: 100%; height: 100%; background-color: ${backgroundColor}; z-index: ${finalZ}; display: flex; align-items: center; justify-content: center; opacity: 0; transition: opacity 0.3s ease-in-out; ${
|
||||
blur ? 'backdrop-filter: blur(4px); -webkit-backdrop-filter: blur(4px);' : ''
|
||||
}"><div class="overlay-content" style="display: flex; flex-direction: column; align-items: center; justify-content: center; color: white; text-align: center; padding: 16px 36px;">${
|
||||
showSpinner ? this.getSpinnerHTML(spinnerType) : ''
|
||||
}${content ? `<div class=\"overlay-message\" style=\"margin-top: 16px; font-size: 16px;\">${content}</div>` : ''}</div></div>`
|
||||
)
|
||||
return { id: overlayId, $overlay }
|
||||
}
|
||||
|
||||
/** 显示全局遮罩层 */
|
||||
showGlobal(options: OverlayOptions = {}) {
|
||||
return new Promise<void>(resolve => {
|
||||
if (this.globalOverlay) this.hideGlobal()
|
||||
this.injectStyles()
|
||||
const defaultOptions: OverlayOptions = {
|
||||
position: 'fixed',
|
||||
content: '加载中...',
|
||||
// 设定一个足够高的默认层级,确保覆盖页面内可能存在的高层级元素(如 9999 的浮层)
|
||||
zIndex: typeof options.zIndex === 'number' ? options.zIndex : 11000,
|
||||
backgroundColor: options.backgroundColor ?? 'rgba(0, 0, 0, 0.4)',
|
||||
...options,
|
||||
}
|
||||
const { id, $overlay } = this.createOverlayElement(defaultOptions)
|
||||
this.globalOverlay = { el: $overlay, id }
|
||||
;(window as any).$('body').append($overlay)
|
||||
;(window as any).$('body').addClass('overlay-lock-scroll')
|
||||
requestAnimationFrame(() => {
|
||||
$overlay.addClass('fade-in')
|
||||
setTimeout(resolve, 300)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/** 隐藏全局遮罩层 */
|
||||
hideGlobal() {
|
||||
return new Promise<void>(resolve => {
|
||||
if (!this.globalOverlay) {
|
||||
resolve()
|
||||
return
|
||||
}
|
||||
const { el, id } = this.globalOverlay
|
||||
el.removeClass('fade-in')
|
||||
setTimeout(() => {
|
||||
if (this.globalOverlay) {
|
||||
el.remove()
|
||||
this.globalOverlay = null
|
||||
ZIndexManager.release(id)
|
||||
;(window as any).$('body').removeClass('overlay-lock-scroll')
|
||||
}
|
||||
resolve()
|
||||
}, 300)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 在指定容器内显示遮罩层
|
||||
* @param target 选择器/元素/jQuery
|
||||
*/
|
||||
showView(target: any, options: OverlayOptions = {}) {
|
||||
return new Promise<void>(resolve => {
|
||||
const $target = typeof target === 'string' ? (window as any).$(target) : target
|
||||
if ($target.length === 0) {
|
||||
console.warn('OverlayManager: 目标容器不存在')
|
||||
resolve()
|
||||
return
|
||||
}
|
||||
const targetId = $target.attr('id') || `target-${Date.now()}`
|
||||
if (!$target.attr('id')) $target.attr('id', targetId)
|
||||
if (this.viewOverlays.has(targetId)) this.hideView(target)
|
||||
this.injectStyles()
|
||||
const originalPosition = $target.css('position')
|
||||
if (originalPosition === 'static') $target.css('position', 'relative')
|
||||
const defaultOptions: OverlayOptions = { position: 'absolute', content: '加载中...', ...options }
|
||||
const { id, $overlay } = this.createOverlayElement(defaultOptions)
|
||||
$target.append($overlay)
|
||||
this.viewOverlays.set(targetId, { overlay: $overlay, originalPosition, id })
|
||||
requestAnimationFrame(() => {
|
||||
$overlay.addClass('fade-in')
|
||||
setTimeout(resolve, 300)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/** 隐藏指定容器内的遮罩层 */
|
||||
hideView(target: any) {
|
||||
return new Promise<void>(resolve => {
|
||||
const $target = typeof target === 'string' ? (window as any).$(target) : target
|
||||
const targetId = $target.attr('id')
|
||||
if (!targetId || !this.viewOverlays.has(targetId)) {
|
||||
resolve()
|
||||
return
|
||||
}
|
||||
const { overlay, originalPosition, id } = this.viewOverlays.get(targetId)!
|
||||
overlay.removeClass('fade-in')
|
||||
setTimeout(() => {
|
||||
overlay.remove()
|
||||
if (originalPosition === 'static') $target.css('position', '')
|
||||
this.viewOverlays.delete(targetId)
|
||||
ZIndexManager.release(id)
|
||||
resolve()
|
||||
}, 300)
|
||||
})
|
||||
}
|
||||
|
||||
/** 隐藏全局与所有视图遮罩层 */
|
||||
hideAll() {
|
||||
const promises: Array<Promise<void>> = []
|
||||
if (this.globalOverlay) promises.push(this.hideGlobal())
|
||||
this.viewOverlays.forEach((_v, targetId) => promises.push(this.hideView(`#${targetId}`)))
|
||||
return Promise.all(promises)
|
||||
}
|
||||
|
||||
/** 更新遮罩层内部的内容文案 */
|
||||
updateContent(content: string, target: any = null) {
|
||||
let $overlay: any
|
||||
if (target) {
|
||||
const $target = typeof target === 'string' ? (window as any).$(target) : target
|
||||
const targetId = $target.attr('id')
|
||||
if (targetId && this.viewOverlays.has(targetId)) $overlay = this.viewOverlays.get(targetId)!.overlay
|
||||
} else {
|
||||
$overlay = this.globalOverlay?.el
|
||||
}
|
||||
if ($overlay) {
|
||||
const $message = $overlay.find('.overlay-message')
|
||||
if ($message.length > 0) {
|
||||
$message.html(content)
|
||||
} else {
|
||||
$overlay
|
||||
.find('.overlay-content')
|
||||
.append(`<div class=\"overlay-message\" style=\"margin-top: 16px; font-size: 16px;\">${content}</div>`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 获取活跃遮罩层统计信息 */
|
||||
getActiveOverlays() {
|
||||
return {
|
||||
global: !!this.globalOverlay,
|
||||
views: Array.from(this.viewOverlays.keys()),
|
||||
total: (this.globalOverlay ? 1 : 0) + this.viewOverlays.size,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* OverlayManager
|
||||
*
|
||||
* - showGlobal(options): 显示全局遮罩层
|
||||
* - hideGlobal(): 隐藏全局遮罩层
|
||||
* - showView(viewSelector, options): 在特定容器(视图)内显示遮罩层
|
||||
* - hideView(viewSelector): 隐藏视图遮罩层
|
||||
* - hideAll(): 隐藏全局与所有视图遮罩层
|
||||
* - updateContent({ target: 'global' | view, content }): 更新遮罩层内部内容
|
||||
* - getActiveOverlays(): 获取活跃遮罩层统计信息
|
||||
*/
|
||||
export const OverlayManager = new OverlayManagerClass()
|
||||
|
||||
// ---------------- Notification(Class) ----------------
|
||||
type ActiveNotification = { $element: any; config: NotificationConfig; timeoutId: any }
|
||||
|
||||
/**
|
||||
* 通知管理器
|
||||
* - 顶部/底部/居中的消息通知
|
||||
*/
|
||||
class NotificationManagerClass {
|
||||
private activeNotifications: Map<string, ActiveNotification> = new Map()
|
||||
private notificationIdCounter = 0
|
||||
|
||||
private createNotificationContainer(config: {
|
||||
id: string
|
||||
position?: NotificationConfig['position']
|
||||
zIndex?: number
|
||||
}) {
|
||||
const { id, position = 'center', zIndex = ZIndexManager.acquire(id) } = config
|
||||
const positionClasses: Record<string, string> = {
|
||||
'top-right': 'top-20 right-6',
|
||||
'top-left': 'top-20 left-6',
|
||||
'bottom-right': 'bottom-6 right-6',
|
||||
'bottom-left': 'bottom-6 left-6',
|
||||
'top-center': 'top-20 left-1/2 transform -translate-x-1/2',
|
||||
'bottom-center': 'bottom-6 left-1/2 transform -translate-x-1/2',
|
||||
center: 'top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2',
|
||||
}
|
||||
const rendered = renderTemplate('notification-container-template', {
|
||||
id,
|
||||
positionClass: positionClasses[position],
|
||||
zIndex,
|
||||
}) as string
|
||||
const notificationHtml =
|
||||
rendered && rendered.trim().length > 0
|
||||
? rendered
|
||||
: `<div id="${id}" class="notification-container fixed ${positionClasses[position]}" style="z-index: ${zIndex}; transition: transform 200ms ease-out, opacity 200ms ease-out; transform: translateY(2rem); opacity: 0;"></div>`
|
||||
const $notification = (window as any).$(notificationHtml)
|
||||
;(window as any).$('body').append($notification)
|
||||
return $notification
|
||||
}
|
||||
|
||||
private createNotificationContent(config: NotificationConfig) {
|
||||
const { title = '', message = '', type = 'info', closable = true, icon = '' } = config
|
||||
const allowedTypes = ['success', 'error', 'warning', 'info', 'dark'] as const
|
||||
type AllowedType = (typeof allowedTypes)[number]
|
||||
const typeStyles: Record<
|
||||
AllowedType,
|
||||
{ bgClass: string; textClass: string; iconClass: string; iconColor: string }
|
||||
> = {
|
||||
success: {
|
||||
bgClass: 'bg-green-500',
|
||||
textClass: 'text-white',
|
||||
iconClass: 'fa-check-circle',
|
||||
iconColor: 'text-white',
|
||||
},
|
||||
error: { bgClass: 'bg-red-500', textClass: 'text-white', iconClass: 'fa-times-circle', iconColor: 'text-white' },
|
||||
warning: {
|
||||
bgClass: 'bg-yellow-500',
|
||||
textClass: 'text-white',
|
||||
iconClass: 'fa-exclamation-triangle',
|
||||
iconColor: 'text-white',
|
||||
},
|
||||
info: { bgClass: 'bg-blue-500', textClass: 'text-white', iconClass: 'fa-info-circle', iconColor: 'text-white' },
|
||||
dark: { bgClass: 'bg-dark', textClass: 'text-white', iconClass: 'fa-check-circle', iconColor: 'text-green-400' },
|
||||
}
|
||||
const isAllowed = (t: any): t is AllowedType => (allowedTypes as readonly string[]).includes(t)
|
||||
const typeKey: AllowedType = isAllowed(type) ? type : 'info'
|
||||
const style = typeStyles[typeKey]
|
||||
const finalIcon = icon || style.iconClass
|
||||
const rendered = renderTemplate('notification-content-template', {
|
||||
bgClass: style.bgClass,
|
||||
textClass: style.textClass,
|
||||
iconClass: finalIcon,
|
||||
iconColor: style.iconColor,
|
||||
title,
|
||||
message,
|
||||
closable,
|
||||
}) as string
|
||||
if (rendered && rendered.trim().length > 0) return rendered
|
||||
return `
|
||||
<div class="notification-content ${style.bgClass} ${style.textClass} rounded shadow-lg p-4 flex items-start gap-3">
|
||||
<i class="fa ${finalIcon} ${style.iconColor}"></i>
|
||||
<div class="flex-1">
|
||||
<div class="font-semibold">${title}</div>
|
||||
<div>${message}</div>
|
||||
</div>
|
||||
${closable ? '<button class="notification-close">×</button>' : ''}
|
||||
</div>`
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示通知
|
||||
* @returns notificationId
|
||||
*/
|
||||
show(config: NotificationConfig): string {
|
||||
const notificationId = config.id || `notification-${++this.notificationIdCounter}`
|
||||
if (this.activeNotifications.has(notificationId)) this.hide(notificationId)
|
||||
const $notification = this.createNotificationContainer({
|
||||
id: notificationId,
|
||||
position: config.position || 'center',
|
||||
zIndex: config.zIndex || 50,
|
||||
})
|
||||
const contentHtml = this.createNotificationContent(config)
|
||||
$notification.html(contentHtml)
|
||||
$notification.on('click', '.notification-close', () => this.hide(notificationId))
|
||||
const notificationData: ActiveNotification = { $element: $notification, config, timeoutId: null }
|
||||
this.activeNotifications.set(notificationId, notificationData)
|
||||
requestAnimationFrame(() => {
|
||||
$notification.removeClass('translate-y-8 opacity-0').addClass('translate-y-0 opacity-100')
|
||||
})
|
||||
const duration = config.duration !== undefined ? config.duration : 3000
|
||||
if (duration > 0) notificationData.timeoutId = setTimeout(() => this.hide(notificationId), duration)
|
||||
if (typeof config.onShow === 'function') config.onShow(notificationId)
|
||||
return notificationId
|
||||
}
|
||||
|
||||
/** 隐藏指定通知 */
|
||||
hide(notificationId: string) {
|
||||
const notification = this.activeNotifications.get(notificationId)
|
||||
if (!notification) return
|
||||
const { $element, config, timeoutId } = notification
|
||||
if (timeoutId) clearTimeout(timeoutId)
|
||||
$element
|
||||
.one('transitionend webkitTransitionEnd oTransitionEnd', () => {
|
||||
$element.remove()
|
||||
this.activeNotifications.delete(notificationId)
|
||||
ZIndexManager.release(notificationId)
|
||||
if (typeof config.onHide === 'function') config.onHide(notificationId)
|
||||
})
|
||||
.removeClass('translate-y-0 opacity-100')
|
||||
.addClass('translate-y-8 opacity-0')
|
||||
setTimeout(() => {
|
||||
if (this.activeNotifications.has(notificationId)) {
|
||||
$element.remove()
|
||||
this.activeNotifications.delete(notificationId)
|
||||
ZIndexManager.release(notificationId)
|
||||
if (typeof config.onHide === 'function') config.onHide(notificationId)
|
||||
}
|
||||
}, 400)
|
||||
}
|
||||
|
||||
/** 隐藏所有通知 */
|
||||
hideAll() {
|
||||
this.activeNotifications.forEach((_n, id) => this.hide(id))
|
||||
}
|
||||
|
||||
/** 获取当前活跃通知 id 列表 */
|
||||
getActiveNotifications(): string[] {
|
||||
return Array.from(this.activeNotifications.keys())
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* NotificationManager
|
||||
*
|
||||
* - show(config): 显示通知并返回唯一 `notificationId`
|
||||
* - hide(id): 隐藏指定通知
|
||||
* - hideAll(): 隐藏当前所有通知
|
||||
* - getActiveNotifications(): 获取活跃通知 id 列表
|
||||
*/
|
||||
export const NotificationManager = new NotificationManagerClass()
|
||||
|
||||
// 统一默认导出(可选)
|
||||
const UI = { DropdownManager, ModalManager, OverlayManager, NotificationManager }
|
||||
export default UI
|
||||
|
||||
export type ContactServicePopupOptions = {
|
||||
triggerSelector?: string
|
||||
popupSelector?: string
|
||||
closeOnScroll?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* 将“联系客服”二维码由 hover 改为点击展示的统一绑定函数
|
||||
* - 默认选择器:`.contact-service-trigger` 内部查找 `.qr-code-popup`
|
||||
* - 行为:点击触发器切换当前弹层显示;点击空白处关闭;可选滚动/缩放时关闭
|
||||
*/
|
||||
export function bindContactServicePopupClick(options: ContactServicePopupOptions = {}): void {
|
||||
const triggerSelector = options.triggerSelector ?? ".contact-service-trigger"
|
||||
const popupSelector = options.popupSelector ?? ".qr-code-popup"
|
||||
const closeOnScroll = options.closeOnScroll ?? false
|
||||
|
||||
const $ = (window as any).$
|
||||
if (!$) return
|
||||
|
||||
const hideAll = () => {
|
||||
$(triggerSelector)
|
||||
.find(popupSelector)
|
||||
.removeClass("opacity-100 visible")
|
||||
.addClass("opacity-0 invisible")
|
||||
}
|
||||
|
||||
// 先解绑旧的命名空间事件,避免重复绑定
|
||||
$(document).off("click.contactServiceToggle")
|
||||
$(document).off("click.contactServiceOutside")
|
||||
$(document).off("click.contactServicePopup")
|
||||
$(window).off("scroll.contactServiceToggle resize.contactServiceToggle")
|
||||
|
||||
// 触发器点击:切换对应弹层
|
||||
$(document).on("click.contactServiceToggle", triggerSelector, function (this: any, e: any) {
|
||||
e?.stopPropagation?.()
|
||||
const $trigger = $(this)
|
||||
const $popup = $trigger.find(popupSelector)
|
||||
const isVisible = $popup.hasClass("opacity-100") && !$popup.hasClass("invisible")
|
||||
hideAll()
|
||||
if (!isVisible) {
|
||||
$popup.removeClass("opacity-0 invisible").addClass("opacity-100 visible")
|
||||
}
|
||||
})
|
||||
|
||||
// 弹层内部点击不冒泡,避免被外层关闭
|
||||
$(document).on(
|
||||
"click.contactServicePopup",
|
||||
`${triggerSelector} ${popupSelector}`,
|
||||
function (this: any, e: any) {
|
||||
e?.stopPropagation?.()
|
||||
}
|
||||
)
|
||||
|
||||
// 点击空白区域关闭
|
||||
$(document).on("click.contactServiceOutside", function () {
|
||||
hideAll()
|
||||
})
|
||||
|
||||
// 滚动/窗口变化时关闭(可选)
|
||||
if (closeOnScroll) {
|
||||
$(window)
|
||||
.on("scroll.contactServiceToggle", hideAll)
|
||||
.on("resize.contactServiceToggle", hideAll)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user