【新增】私有证书

This commit is contained in:
cai
2025-09-03 15:15:59 +08:00
parent efd052a297
commit 954cd1638d
442 changed files with 76787 additions and 7483 deletions

View 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;

View 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;

View 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 就绪后启动

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View 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

View 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
}

View 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
}

View 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
}

View 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 {}

View 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 {}

View 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
}

View 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
}

View 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[]
}

View 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
}

View 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
}

View 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
}

View File

@@ -0,0 +1,11 @@
/**
* 域名价格数据结构
*/
export type DomainPrice = {
suffix: string
originalPrice: number
firstYearPrice: number
renewPrice: number
transferPrice: number
isWan?: boolean
}

View File

@@ -0,0 +1,4 @@
export * from './domain'
export * from './api'
export * from './utils'
export * from './template-data-map'

View 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
}

View 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

View File

@@ -0,0 +1,4 @@
declare module 'virtual:uno.css' {
const css: string
export default css
}

View 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
}

View 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;
}
}

View 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

View 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,
}

View File

@@ -0,0 +1,5 @@
export * from '../types/utils'
export * from './core'
export * from './ui'
export * from './date'
export * from './type'

View 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

View 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,
}

View 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()
// ---------------- DropdownClass ----------------
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()
// ---------------- ModalClass ----------------
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()
// ---------------- NotificationClass ----------------
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)
}
}