【修复】申请配置证书CA列表,授权api新增新增btdomain

1.1.0
cai 2025-09-17 14:54:50 +08:00
parent 51548f6788
commit b91fd107ee
147 changed files with 19178 additions and 2123 deletions

File diff suppressed because one or more lines are too long

View File

@ -103,10 +103,9 @@ export default defineComponent({
language="custom-logs"
trim={false}
fontSize={14}
lineHeight={1.5}
class="h-full" // NLog 充满 NSpin
style={{
// height: '500px', // 改为 flex 布局后,由父容器控制高度
lineHeight: '1.5rem',
border: '1px solid var(--n-border-color)',
borderRadius: 'var(--n-border-radius)', // 使用 Naive UI 变量
padding: '10px',

View File

@ -1,5 +1,11 @@
.flowContainer {
@apply flex relative box-border w-full h-[calc(100vh-19rem)] overflow-x-auto overflow-y-auto p-[1rem] justify-center;
@apply flex relative box-border w-full h-[calc(100vh-19rem)] overflow-hidden p-[1rem] justify-center;
user-select: none; /* 防止拖拽时选中文本 */
cursor: grab; /* 默认鼠标样式 */
}
.flowContainer:active {
cursor: grabbing; /* 拖拽时的鼠标样式 */
}
.flowProcess {
@ -7,7 +13,7 @@
}
.flowZoom {
@apply flex fixed items-center justify-between h-[4rem] w-[12.5rem] bottom-[4rem] z-[99];
@apply flex fixed items-center justify-between h-[4rem] w-[16rem] bottom-[6rem] z-[99];
}
.flowZoomIcon {

View File

@ -1,5 +1,5 @@
import { NButton, NIcon, NInput } from 'naive-ui'
import { SaveOutlined, ArrowLeftOutlined } from '@vicons/antd'
import { SaveOutlined, ArrowLeftOutlined, ReloadOutlined } from "@vicons/antd";
import { $t } from '@locales/index'
import SvgIcon from '@components/SvgIcon'
@ -14,47 +14,188 @@ import type { FlowNode, FlowNodeProps } from './types'
import { useThemeCssVar } from '@baota/naive-ui/theme'
export default defineComponent({
name: 'FlowChart',
props: {
isEdit: {
type: Boolean,
default: false,
},
type: {
type: String as PropType<'quick' | 'advanced'>,
default: 'quick',
},
node: {
type: Object as PropType<FlowNode>,
default: () => ({}),
},
// 任务节点列表
taskComponents: {
type: Object as PropType<Record<string, Component>>,
default: () => ({}),
},
},
setup(props: FlowNodeProps, { slots }) {
const cssVars = useThemeCssVar([
'borderColor',
'dividerColor',
'textColor1',
'textColor2',
'primaryColor',
'primaryColorHover',
'bodyColor',
])
const { flowData, selectedNodeId, flowZoom, resetFlowData } = useStore()
const { initData, handleSaveConfig, handleZoom, goBack } = useController({
type: props?.type,
node: props?.node,
isEdit: props?.isEdit,
})
// 提供任务节点组件映射给后代组件使用
provide('taskComponents', props.taskComponents)
onMounted(initData)
onUnmounted(resetFlowData)
return () => (
name: "FlowChart",
props: {
isEdit: {
type: Boolean,
default: false,
},
type: {
type: String as PropType<"quick" | "advanced">,
default: "quick",
},
node: {
type: Object as PropType<FlowNode>,
default: () => ({}),
},
// 任务节点列表
taskComponents: {
type: Object as PropType<Record<string, Component>>,
default: () => ({}),
},
},
setup(props: FlowNodeProps, { slots }) {
const cssVars = useThemeCssVar([
"borderColor",
"dividerColor",
"textColor1",
"textColor2",
"primaryColor",
"primaryColorHover",
"bodyColor",
]);
const { flowData, selectedNodeId, flowZoom, resetFlowData, setZoomValue } =
useStore();
const { initData, handleSaveConfig, handleZoom, goBack } = useController({
type: props?.type,
node: props?.node,
isEdit: props?.isEdit,
});
// 拖拽状态管理
const dragState = reactive({
isDragging: false,
startX: 0,
startY: 0,
offsetX: 0,
offsetY: 0,
});
// 画布容器引用
const canvasRef = ref<HTMLElement | null>(null);
// 画布样式通过transform实现拖拽
const canvasStyle = computed(() => ({
transform: `translate(${dragState.offsetX}px, ${dragState.offsetY}px)`,
}));
// 鼠标按下事件
const handleMouseDown = (e: MouseEvent) => {
// 只有左键点击才触发拖拽
if (e.button !== 0) return;
dragState.isDragging = true;
dragState.startX = e.clientX;
dragState.startY = e.clientY;
// 添加鼠标样式反馈
document.body.style.cursor = "grabbing";
};
// 鼠标移动事件
const handleMouseMove = (e: MouseEvent) => {
if (!dragState.isDragging || !isComponentMounted.value) return;
try {
// 计算位移差
const deltaX = e.clientX - dragState.startX;
const deltaY = e.clientY - dragState.startY;
// 更新偏移量
dragState.offsetX += deltaX;
dragState.offsetY += deltaY;
// 更新起始位置(用于连续计算)
dragState.startX = e.clientX;
dragState.startY = e.clientY;
} catch (error) {
console.warn("拖拽移动处理出错:", error);
}
};
// 鼠标抬起事件
const handleMouseUp = () => {
if (dragState.isDragging) {
dragState.isDragging = false;
document.body.style.cursor = ""; // 恢复鼠标样式
}
};
// 重置缩放和位置
const handleReset = () => {
try {
// 重置缩放为100%
setZoomValue(100);
// 重置拖拽位置
dragState.offsetX = 0;
dragState.offsetY = 0;
} catch (error) {
console.warn("重置处理出错:", error);
}
};
// 滚轮缩放事件(带节流优化)
let wheelTimeout: number | null = null;
const isComponentMounted = ref(true);
const handleWheel = (e: WheelEvent) => {
// 阻止默认滚动行为
e.preventDefault();
// 如果组件已卸载,不处理事件
if (!isComponentMounted.value) {
return;
}
// 节流处理,避免频繁触发
if (wheelTimeout) {
clearTimeout(wheelTimeout);
}
wheelTimeout = window.setTimeout(() => {
// 再次检查组件是否仍然挂载
if (!isComponentMounted.value) {
return;
}
try {
const delta = e.deltaY > 0 ? -10 : 10; // 向下滚动缩小,向上滚动放大
const newZoom = Math.max(50, Math.min(300, flowZoom.value + delta));
if (newZoom !== flowZoom.value) {
setZoomValue(newZoom); // 直接设置缩放值
}
} catch (error) {
console.warn("滚轮缩放处理出错:", error);
}
}, 16);
};
// 提供任务节点组件映射给后代组件使用
provide("taskComponents", props.taskComponents);
onMounted(() => {
initData();
// 绑定全局事件
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
// 绑定滚轮事件到画布容器
if (canvasRef.value) {
canvasRef.value.addEventListener("wheel", handleWheel, {
passive: false,
});
}
});
onUnmounted(() => {
// 标记组件已卸载
isComponentMounted.value = false;
resetFlowData();
// 解绑事件,避免内存泄漏
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
// 解绑滚轮事件
if (canvasRef.value) {
canvasRef.value.removeEventListener("wheel", handleWheel);
}
// 清理定时器
if (wheelTimeout) {
clearTimeout(wheelTimeout);
wheelTimeout = null;
}
});
return () => (
<div class="flex flex-col w-full h-full" style={cssVars.value}>
<div class="w-full h-[6rem] px-[2rem] mb-[2rem] rounded-lg flex items-center gap-2 justify-between">
<div class="flex items-center">
@ -85,13 +226,25 @@ export default defineComponent({
</NButton>
</div>
</div>
<div class={styles.flowContainer}>
<div
class={styles.flowContainer}
ref={canvasRef}
onMousedown={handleMouseDown}
onMouseleave={handleMouseUp}
>
{/* 左侧流程容器 */}
<div class="flex min-w-0">
{/* 流程容器*/}
<div
class={styles.flowProcess}
style={{ transform: `scale(${flowZoom.value / 100})` }}
style={{
transform: `scale(${flowZoom.value / 100}) ${
canvasStyle.value.transform
}`,
transition: dragState.isDragging
? "none"
: "transform 0.05s ease-out",
}}
>
{/* 渲染流程节点 */}
<NodeWrap node={flowData.value.childNode} />
@ -117,10 +270,19 @@ export default defineComponent({
color="#5a5e66"
/>
</div>
<div
class={styles.flowZoomIcon}
onClick={handleReset}
title="重置视图"
>
<NIcon size="16" color="#5a5e66">
<ReloadOutlined />
</NIcon>
</div>
</div>
{/* 保留原有插槽 */}
{slots.default?.()}
</div>
);
},
})
},
});

View File

@ -20,29 +20,29 @@ import { $t } from '@locales/index'
*
*/
export const useFlowStore = defineStore('flow-store', () => {
const flowData = ref<FlowNode>({
id: '',
name: '',
childNode: {
id: 'start-1',
name: '开始',
type: 'start',
config: {
exec_type: 'manual',
},
childNode: null,
},
}) // 流程图数据
const flowZoom = ref(100) // 流程图缩放比例
const advancedOptions = ref(false) // 高级选项
const addNodeSelectList = ref<NodeSelect[]>([]) // 添加节点选项列表
const excludeNodeSelectList = ref<NodeNum[]>([]) // 排除的节点选项列表
const addNodeBtnRef = ref<HTMLElement | null>(null) // 添加节点按钮
const addNodeSelectRef = ref<HTMLElement | null>(null) // 添加节点选择框
const addNodeSelectPostion = ref<number | null>(null) // 添加节点选择框位置
const selectedNodeId = ref<string | null>(null) // 当前选中的节点ID
const isRefreshNode = ref<string | null>(null) // 是否刷新节点
const startNodeSavedByUser = ref<boolean>(false); // 开始节点是否已被用户手动保存过
const flowData = ref<FlowNode>({
id: "",
name: "",
childNode: {
id: "start-1",
name: "开始",
type: "start",
config: {
exec_type: "manual",
},
childNode: null,
},
}); // 流程图数据
const flowZoom = ref(100); // 流程图缩放比例
const advancedOptions = ref(false); // 高级选项
const addNodeSelectList = ref<NodeSelect[]>([]); // 添加节点选项列表
const excludeNodeSelectList = ref<NodeNum[]>([]); // 排除的节点选项列表
const addNodeBtnRef = ref<HTMLElement | null>(null); // 添加节点按钮
const addNodeSelectRef = ref<HTMLElement | null>(null); // 添加节点选择框
const addNodeSelectPostion = ref<number | null>(null); // 添加节点选择框位置
const selectedNodeId = ref<string | null>(null); // 当前选中的节点ID
const isRefreshNode = ref<string | null>(null); // 是否刷新节点
const startNodeSavedByUser = ref<boolean>(false); // 开始节点是否已被用户手动保存过
// 计算添加节点选项列表,排除的节点选项列表
const nodeSelectList = computed(() => {
@ -606,16 +606,30 @@ export const useFlowStore = defineStore('flow-store', () => {
/**
*
*
* @param {number} type - 1 2
* @param {number} type - 1 2 0.5-3.0
*/
const setflowZoom = (type: number) => {
if (type === 1 && flowZoom.value > 50) {
// 如果传入的是小数0.5-3.0),直接设置缩放值
if (type < 1) {
const zoomValue = Math.round(type * 100);
flowZoom.value = Math.max(50, Math.min(300, zoomValue));
} else if (type === 1 && flowZoom.value > 50) {
// 缩小
flowZoom.value -= 10;
} else if (type === 2 && flowZoom.value < 300) {
// 放大
flowZoom.value += 10;
}
};
/**
*
* @param {number} zoomValue - 50-300
*/
const setZoomValue = (zoomValue: number) => {
flowZoom.value = Math.max(50, Math.min(300, zoomValue));
};
/**
*
* @param {boolean} saved -
@ -632,7 +646,7 @@ export const useFlowStore = defineStore('flow-store', () => {
startNodeSavedByUser.value = false;
};
return {
return {
// 数据
flowData, // 流程图数据
flowZoom, // 流程图缩放比例
@ -649,6 +663,7 @@ export const useFlowStore = defineStore('flow-store', () => {
getResultData, // 获取流程图数据
updateFlowData, // 更新流程图数据
setflowZoom, // 设置流程图缩放比例
setZoomValue, // 直接设置缩放值(用于滚轮缩放)
setStartNodeSavedByUser, // 设置开始节点已被用户保存的状态
resetStartNodeSavedState, // 重置开始节点保存状态

View File

@ -271,6 +271,13 @@ export const ApiProjectConfig: Record<string, ApiProjectType> = {
hostRelated: { default: { name: "Spaceship" } },
sort: 32,
},
btdomain: {
name: "BTDomain",
icon: "btdomain",
type: ["dns"],
hostRelated: { default: { name: "BTDomain" } },
sort: 33,
},
plugin: {
name: "插件",
icon: "plugin",

View File

@ -65,6 +65,7 @@ export interface AddAccessParams<
| ConstellixAccessConfig
| WebhookAccessConfig
| SpaceshipAccessConfig
| BTDomainAccessConfig
> {
name: string;
type: string;
@ -102,6 +103,7 @@ export interface UpdateAccessParams<
| ConstellixAccessConfig
| WebhookAccessConfig
| SpaceshipAccessConfig
| BTDomainAccessConfig
> extends AddAccessParams<T> {
id: string;
}
@ -322,6 +324,12 @@ export interface SpaceshipAccessConfig {
api_secret: string;
}
export interface BTDomainAccessConfig {
access_key: string;
secret_key: string;
account_id: string;
}
/** 删除授权请求参数 */
export interface DeleteAccessParams {
id: string

View File

@ -55,6 +55,7 @@ import type {
ConstellixAccessConfig,
WebhookAccessConfig,
SpaceshipAccessConfig,
BTDomainAccessConfig,
} from "@/types/access";
import type { VNode, Ref } from "vue";
import { testAccess, getPlugins } from "@/api/access";
@ -494,7 +495,7 @@ export const useApiFormController = (
value: string,
callback: (error?: Error) => void
) => {
if (!value.length) {
if (!value || !value.length) {
const mapTips = {
cloudflare: $t("t_0_1747042966820"),
btpanel: $t("t_1_1747042969705"),
@ -523,6 +524,25 @@ export const useApiFormController = (
const mapTips = {
godaddy: $t("t_1_1747984133312"),
spaceship: "请输入 Spaceship API Secret",
btdomain: "请输入 BTDomain Secret Key",
};
return callback(
new Error(mapTips[param.value.type as keyof typeof mapTips])
);
}
callback();
},
},
account_id: {
trigger: "input",
validator: (
rule: FormItemRule,
value: string,
callback: (error?: Error) => void
) => {
if (!value) {
const mapTips = {
btdomain: "请输入 BTDomain Account ID",
};
return callback(
new Error(mapTips[param.value.type as keyof typeof mapTips])
@ -621,6 +641,7 @@ export const useApiFormController = (
volcengine: $t("t_3_1747365600828"),
qiniu: $t("t_3_1747984134586"),
doge: $t("t_0_1750320239265"),
btdomain: "请输入 BTDomain Access Key",
};
return callback(
new Error(mapTips[param.value.type as keyof typeof mapTips])
@ -636,7 +657,7 @@ export const useApiFormController = (
value: string,
callback: (error?: Error) => void
) => {
if (!value.length) {
if (!value || !value.length) {
const mapTips = {
tencentcloud: $t("t_2_1747042967277"),
huawei: $t("t_3_1747042967608"),
@ -644,6 +665,7 @@ export const useApiFormController = (
volcengine: $t("t_4_1747365600137"),
doge: $t("t_1_1750320241427"),
constellix: "请输入Secret Key",
btdomain: "请输入 BTDomain Secret Key",
};
return callback(
new Error(mapTips[param.value.type as keyof typeof mapTips])
@ -1230,6 +1252,21 @@ export const useApiFormController = (
})
);
break;
case "btdomain":
items.push(
useFormInput("Access Key", "config.access_key", {
allowInput: noSideSpace,
}),
useFormInput("Secret Key", "config.secret_key", {
type: "password",
showPasswordOn: "click",
allowInput: noSideSpace,
}),
useFormInput("Account ID", "config.account_id", {
allowInput: noSideSpace,
})
);
break;
case "plugin":
items.push(
useFormCustom(() => {
@ -1478,7 +1515,14 @@ export const useApiFormController = (
api_key: "",
api_secret: "",
} as SpaceshipAccessConfig;
break;
break;
case "btdomain":
param.value.config = {
access_key: "",
secret_key: "",
account_id: "",
} as BTDomainAccessConfig;
break;
case "plugin":
param.value.config = {
name: pluginList.value[0]?.value || "",

View File

@ -77,7 +77,9 @@ export default defineComponent({
const caOptions = ref<
Array<{ label: string; value: string; icon: string }>
>([]);
const emailOptions = ref<string[]>([]);
const emailOptions = ref<
Array<{ label: string; value: string; id: number; email: string }>
>([]);
const isLoadingCA = ref(false);
const isLoadingEmails = ref(false);
const showEmailDropdown = ref(false);
@ -88,7 +90,7 @@ export default defineComponent({
isLoadingCA.value = true;
try {
const { data } = await getEabList({
ca: param.value.ca,
ca: "",
p: 1,
limit: 1000,
}).fetch();
@ -144,7 +146,14 @@ export default defineComponent({
try {
const { data } = await getEabList({ ca, p: 1, limit: 1000 }).fetch();
emailOptions.value =
data?.map((item) => item.email).filter(Boolean) || [];
data
?.map((item) => ({
label: item.email,
value: `${item.id}`, // 使用 id 作为 value 确保唯一性
id: item.id,
email: item.email,
}))
.filter((item) => item.email) || [];
// 检查是否为编辑模式且有外部传入的邮箱
if (isEdit.value && routeEmail.value) {
@ -154,15 +163,19 @@ export default defineComponent({
// 非编辑模式:保持原有逻辑
if (!emailOptions.value.length) {
param.value.email = "";
param.value.eabId = "";
} else {
// 如果邮箱数组有内容,自动填充第一个邮箱地址
// 移除 !param.value.email 条件让切换CA时总是更新为第一个选项
if (emailOptions.value[0]) {
param.value.email = emailOptions.value[0].email;
param.value.eabId = emailOptions.value[0].id.toString();
}
}
// 如果邮箱数组有内容且当前邮箱为空,自动填充第一个邮箱地址
if (
emailOptions.value.length > 0 &&
emailOptions.value[0] &&
!param.value.email
) {
param.value.email = emailOptions.value[0];
}
}
if (example.value) {
example.value.restoreValidation();
}
} catch (error) {
console.error("加载邮件选项失败:", error);
@ -239,17 +252,35 @@ export default defineComponent({
// 创建邮箱下拉选项
const emailDropdownOptions = computed(() => {
return emailOptions.value.map((email) => ({
label: email,
key: email,
return emailOptions.value.map((item) => ({
label: item.email,
key: item.email,
}));
});
// 计算输入框宽度用于下拉菜单
const inputWidth = computed(() => {
if (emailInputRef.value?.$el) {
return emailInputRef.value.$el.offsetWidth;
}
return 0;
});
// 判断是否需要输入框letsencrypt、buypass、zerossl
const shouldUseInputForEmail = computed(() => {
return ["letsencrypt", "buypass", "zerossl"].includes(param.value.ca);
});
// 计算当前选中的邮箱选项的 value用于 NSelect
const currentEmailValue = computed(() => {
if (!param.value.eabId) return null;
// 优先使用 eabId 来查找匹配的选项
const matchedOption = emailOptions.value.find(
(item) => item.id.toString() === param.value.eabId
);
return matchedOption ? matchedOption.value : null;
});
// 表单渲染配置
const config = computed(() => {
// 基本选项
@ -343,7 +374,20 @@ export default defineComponent({
options={emailDropdownOptions.value}
onSelect={handleSelectEmail}
placement="bottom-start"
style="width: 100%"
menu-props={() => ({
style: {
width: `${inputWidth.value}px`,
maxHeight: "40rem",
overflowY: "auto",
},
})}
node-props={(option: any) => ({
style: {
padding: "8px 12px",
cursor: "pointer",
},
class: "hover:bg-gray-50",
})}
>
<NInput
ref={emailInputRef}
@ -358,16 +402,26 @@ export default defineComponent({
</NDropdown>
) : (
<NSelect
v-model:value={param.value.email}
options={emailOptions.value.map((email) => ({
label: email,
value: email,
}))}
value={currentEmailValue.value}
options={emailOptions.value}
placeholder={$t("t_2_1748052862259")}
clearable
filterable
loading={isLoadingEmails.value}
class="w-full"
onUpdateValue={(value: string) => {
// 根据选择的 id 找到对应的邮箱地址和 eabId
const selectedOption = emailOptions.value.find(
(item) => item.value === value
);
if (selectedOption) {
param.value.email = selectedOption.email;
param.value.eabId = selectedOption.id.toString();
} else {
param.value.email = value;
param.value.eabId = "";
}
}}
/>
)}
</NFormItem>
@ -545,6 +599,7 @@ export default defineComponent({
} else {
emailOptions.value = [];
param.value.email = "";
param.value.eabId = "";
showEmailDropdown.value = false;
}
}
@ -564,14 +619,14 @@ export default defineComponent({
advancedOptions.value = false;
await loadCAOptions();
// 如果当前已经有CA值主动加载对应的邮件选项
if (param.value.ca) {
await loadEmailOptions(param.value.ca);
}
// 如果是编辑模式且有外部传入的邮箱,直接设置邮箱值
if (isEdit.value && routeEmail.value) {
param.value.email = routeEmail.value;
} else {
// 非编辑模式如果当前已经有CA值主动加载对应的邮件选项
if (param.value.ca) {
await loadEmailOptions(param.value.ca);
}
}
// 移除重复调用,让 watch 监听器处理 CA 值变化

View File

@ -1,4 +1,4 @@
import { defineComponent, ref, computed, watch } from 'vue';
import { defineComponent, ref, computed, watch } from "vue";
import {
NForm,
NFormItem,
@ -127,7 +127,7 @@ export default defineComponent({
const selectedRootCa = rootCaList.value.find(
(ca) => ca.id.toString() === newRootId
);
if (selectedRootCa) {
if (selectedRootCa && selectedRootCa.algorithm) {
addForm.value.algorithm = selectedRootCa.algorithm;
if (selectedRootCa.algorithm === "ecdsa") {
addForm.value.key_length = "256";
@ -269,45 +269,43 @@ export default defineComponent({
</div>
</NDivider>
</div>
{showAdvancedConfig.value && (
<div class="space-y-4 mt-4">
<NFormItem label="组织(O)">
<NInput
v-model:value={addForm.value.o}
placeholder="请输入组织名称"
/>
</NFormItem>
<div class="mt-4" v-show={showAdvancedConfig.value}>
<NFormItem label="组织(O)">
<NInput
v-model:value={addForm.value.o}
placeholder="请输入组织名称"
/>
</NFormItem>
<NFormItem label="国家(C)" path="c" required>
<NSelect
v-model:value={addForm.value.c}
options={countryOptions}
placeholder="请选择国家"
/>
</NFormItem>
<NFormItem label="国家(C)" path="c" required>
<NSelect
v-model:value={addForm.value.c}
options={countryOptions}
placeholder="请选择国家"
/>
</NFormItem>
<NFormItem label="组织单位(OU)">
<NInput
v-model:value={addForm.value.ou}
placeholder="请输入组织单位"
/>
</NFormItem>
<NFormItem label="组织单位(OU)">
<NInput
v-model:value={addForm.value.ou}
placeholder="请输入组织单位"
/>
</NFormItem>
<NFormItem label="省份">
<NInput
v-model:value={addForm.value.province}
placeholder="请输入省份"
/>
</NFormItem>
<NFormItem label="省份">
<NInput
v-model:value={addForm.value.province}
placeholder="请输入省份"
/>
</NFormItem>
<NFormItem label="城市">
<NInput
v-model:value={addForm.value.locality}
placeholder="请输入城市"
/>
</NFormItem>
</div>
)}
<NFormItem label="城市">
<NInput
v-model:value={addForm.value.locality}
placeholder="请输入城市"
/>
</NFormItem>
</div>
</div>
<div class="flex justify-end gap-3 mt-6">
<NButton onClick={handleCancel}></NButton>

View File

@ -251,8 +251,10 @@ export const useController = () => {
if (data.value?.status === true) {
rootCaList.value = data.value.data;
if (createType.value === 'intermediate' && rootCaList.value.length > 0 && rootCaList.value[0]) {
addForm.value.root_id = rootCaList.value[0].id.toString();
}
addForm.value.root_id = rootCaList.value[0].id.toString();
addForm.value.algorithm = rootCaList.value[0].algorithm;
addForm.value.key_length = rootCaList.value[0].key_length.toString();
}
}
} catch (error) {
console.error('获取根证书列表失败:', error);

View File

@ -1,5 +1,5 @@
<!doctype html>
<html lang="en">
<html lang="zh">
<head>
<meta charset="UTF-8" />

View File

@ -0,0 +1,49 @@
/**
* @fileoverview API API
* @description API
*/
import { useApi } from '@api/index'
import type { ApiResponse } from '@/types/api'
import type { CreateApiKeyRequest, CreateApiKeyResponse, ApiKeyListRequest, ApiKeyListResponse, UpdateApiKeyRequest, UpdateApiKeyResponse, RegenerateApiKeyRequest, RegenerateApiKeyResponse, DeleteApiKeyRequest, DeleteApiKeyResponse } from '@/types/api-key'
/**
* @description API
* @param {CreateApiKeyRequest} params IP
* @returns {useAxiosReturn<ApiResponse<CreateApiKeyResponse>, CreateApiKeyRequest>}
*/
export const createApi = (params: CreateApiKeyRequest) =>
useApi<ApiResponse<CreateApiKeyResponse>, CreateApiKeyRequest>('/v1/user/apiconfig/create', params)
/**
* @description API
* @param {ApiKeyListRequest} params
* @returns {useAxiosReturn<ApiResponse<ApiKeyListResponse>, ApiKeyListRequest>}
*/
export const getApiKeyList = (params: ApiKeyListRequest) =>
useApi<ApiResponse<ApiKeyListResponse>, ApiKeyListRequest>('/v1/user/apiconfig/list', params)
/**
* @description API
* @param {UpdateApiKeyRequest} params IDIP
* @returns {useAxiosReturn<ApiResponse<UpdateApiKeyResponse>, UpdateApiKeyRequest>}
*/
export const updateApiKey = (params: UpdateApiKeyRequest) =>
useApi<ApiResponse<UpdateApiKeyResponse>, UpdateApiKeyRequest>('/v1/user/apiconfig/update', params)
/**
* @description API
* @param {RegenerateApiKeyRequest} params ID
* @returns {useAxiosReturn<ApiResponse<RegenerateApiKeyResponse>, RegenerateApiKeyRequest>}
*/
export const regenerateApiKey = (params: RegenerateApiKeyRequest) =>
useApi<ApiResponse<RegenerateApiKeyResponse>, RegenerateApiKeyRequest>('/v1/user/apiconfig/regenerate', params)
/**
* @description API
* @param {DeleteApiKeyRequest} params ID
* @returns {useAxiosReturn<ApiResponse<DeleteApiKeyResponse>, DeleteApiKeyRequest>}
*/
export const deleteApiKey = (params: DeleteApiKeyRequest) =>
useApi<ApiResponse<DeleteApiKeyResponse>, DeleteApiKeyRequest>('/v1/user/apiconfig/delete', params)

View File

@ -18,6 +18,9 @@ import type {
DomainPriceQueryRequest,
DomainPriceQueryResponse,
DomainAutoRenewRequest,
DomainRealNameUpdateRequest,
PrivacyRequest,
PrivacyPriceRequest,
} from '@/types/domain'
import type {
DomainTransferListRequest,
@ -47,7 +50,14 @@ export const fetchDomainDetail = (params: DomainDetailRequest) =>
useApi<DomainDetailResponse, DomainDetailRequest>(
"/v1/domain/manage/detail",
params,
);
);
/**
* @description
* @param {DomainRealNameUpdateRequest} params IDid
* @returns {useAxiosReturn<ApiResponse, DomainRealNameUpdateRequest>}
*/
export const updateDomainRealName = (params: DomainRealNameUpdateRequest) =>
useApi<ApiResponse, DomainRealNameUpdateRequest>('/v1/domain/manage/update_real_name_tpl', params)
/**
* @description DNS
@ -107,4 +117,50 @@ export const fetchDomainAutoRenew = (params: DomainAutoRenewRequest) =>
// 下载域名证书
export const downloadDomainCertificate = (params: { domain_id: number }) =>
useApi<ApiResponse, { domain_id: number }>('/v1/domain/manage/get_cert', params)
useApi<ApiResponse, { domain_id: number }>('/v1/domain/manage/get_cert', params)
// 域名隐私保护
export const privacyOrder = (params: PrivacyRequest) => useApi<ApiResponse, PrivacyRequest>('/v1/order/privacy', params)
// 隐私保护价格查询
export const privacyPrice = (params: PrivacyPriceRequest) =>
useApi<ApiResponse, PrivacyPriceRequest>('/v1/domain/privacy/price', params)
// 添加DNSSEC DS记录
export const addDnssecDsRecord = (params: {
domain_id: string
key_tag: number
alg: number
digest_type: number
digest: string
}) =>
useApi<ApiResponse, typeof params>('/v1/dns/dnssec/add_ds', params)
// 获取DNSSEC DS记录列表
export const getDnssecDsList = (params: {
domain_id: string
}) =>
useApi<{
code: number
data: Array<{
alg: number
created_at: number
digest: string
digest_type: number
id: number
key_tag: number
status: number
updated_at: number
}>
msg: string
status: boolean
}, typeof params>('/v1/dns/dnssec/get_ds_list', params)
// 删除DNSSEC DS记录
export const deleteDnssecDsRecord = (params: {
ds_id: number
}) =>
useApi<ApiResponse, typeof params>('/v1/dns/dnssec/del_ds', params)
export const syncDnssecDsRecord = (params: { domain_id: string }) =>
useApi<ApiResponse, typeof params>('/v1/dns/dnssec/sync_ds', params)

View File

@ -0,0 +1,37 @@
import { useApi } from '@api/index'
import type { ApiResponse } from '@/types/api'
/** 接口返回的 data 结构(成功时) */
export interface OperateLogData {
created_at: string
level: string
log_id: number
message: string
module: string
remote_addr: string
request_method: string
request_url: string
}
export interface OperateLogListData {
data: OperateLogData[]
count: number
row: string
}
export type OperateLogResponse = ApiResponse<OperateLogListData>
// 操作日志查询参数接口
export interface OperateLogRequest {
/** 页码,从 1 开始 */
p?: number
/** 每页条数 */
rows?: number
/** 关键字搜索,作用于日志 */
keyword?: string
}
/**
* @description
*/
export const getOperateLog = (params: OperateLogRequest) =>
useApi<OperateLogResponse, OperateLogRequest>('/v1/user/get_log', params)

View File

@ -0,0 +1,51 @@
/**
* @fileoverview API
* @description
*/
import { useApi } from '@api/index'
import type { ApiResponse } from '@/types/api'
import type { ResolveListRequest, ResolveItem } from '@/views/domain-resolve/types'
import type {
GetDomainInfoRequest,
DomainInfoData,
AddExternalDomainRequest,
AddExternalDomainResponse,
RemoveDomainRequest,
RemoveDomainResponse,
ResolveListResponse,
CheckDomainStatusRequest,
CheckDomainStatusResponse
} from '@/types/resolve'
/**
* @description
* @param {ResolveListRequest} params
* @returns {useAxiosReturn<ApiResponse<ResolveListResponse>, ResolveListRequest>}
*/
export const getResolveList = (params: ResolveListRequest) =>
useApi<ApiResponse<ResolveListResponse>, ResolveListRequest>('/v1/dns/manage/list_domains', params)
/**
* @description
* @param {AddExternalDomainRequest} params
* @returns {useAxiosReturn<ApiResponse<AddExternalDomainResponse>, AddExternalDomainRequest>}
*/
export const addExternalDomain = (params: AddExternalDomainRequest) =>
useApi<ApiResponse<AddExternalDomainResponse>, AddExternalDomainRequest>('/v1/dns/manage/add_external_domain', params)
/**
* @description
* @param {RemoveDomainRequest} params ID
* @returns {useAxiosReturn<ApiResponse<RemoveDomainResponse>, RemoveDomainRequest>}
*/
export const removeDomain = (params: RemoveDomainRequest) =>
useApi<ApiResponse<RemoveDomainResponse>, RemoveDomainRequest>('/v1/dns/manage/remove_domain', params)
/**
* @description
* @param {CheckDomainStatusRequest} params ID
* @returns {useAxiosReturn<ApiResponse<CheckDomainStatusResponse>, CheckDomainStatusRequest>}
*/
export const checkDomainStatus = (params: CheckDomainStatusRequest) =>
useApi<ApiResponse<CheckDomainStatusResponse>, CheckDomainStatusRequest>('/v1/dns/manage/check_domain_status', params)

View File

@ -141,6 +141,14 @@ export default defineComponent({
{/* 桌面端右侧:功能按钮 */}
<div class="flex items-center gap-4">
<a
class="text-[#20a53a] hover:text-[#20a53a]-800 text-sm no-underline cursor-pointer"
href="https://qm.qq.com/q/fxbto4wZkk"
target="_blank"
rel="noopener noreferrer"
>
QQ
</a>
<NButton ghost onClick={() => handleBackToOfficial(true)}>
</NButton>

View File

@ -20,7 +20,9 @@ import {
ChromeReaderModeTwotone,
ShoppingCartOutlined,
RepeatOutlined,
InsertDriveFileOutlined,
} from '@vicons/material'
import { ShieldCheckmarkOutline, GlobeOutline } from '@vicons/ionicons5'
import type { MenuOption } from 'naive-ui'
@ -42,6 +44,9 @@ const iconMap: Record<string, any> = {
'shopping-cart': ShoppingCartOutlined,
wallet: MoneyRound,
transfer: RepeatOutlined,
security: ShieldCheckmarkOutline,
resolve: GlobeOutline,
operation: InsertDriveFileOutlined,
}
/**

View File

@ -17,6 +17,9 @@ const RealNameTemplate = () => import('../views/real-name/index')
const RealNameCenter = () => import('../views/real-name-center/index')
const RechargeManage = () => import('../views/recharge/index')
const OrderList = () => import('../views/order/index')
const DomainSecurity = () => import('../views/domain-security/index')
const DomainResolve = () => import('../views/domain-resolve/index')
const OperationLog = () => import('../views/operation-log/index')
// 主布局组件
const MainLayout = () => import('@components/layout/index')
@ -92,7 +95,27 @@ const routes: RouteRecordRaw[] = [
},
],
},
{
path: 'domain-resolve',
name: 'DomainResolve',
component: DomainResolve,
meta: {
title: '域名解析',
icon: 'resolve',
},
children: [
{
path: 'detail/:id',
name: 'DomainResolveDetail',
component: () => import('../views/domain-resolve/components/DomainResolveDetail'),
meta: {
title: '域名解析详情',
icon: 'resolve',
hideInMenu: true,
},
},
],
},
{
path: 'real-name',
name: 'RealNameTemplate',
@ -131,6 +154,24 @@ const routes: RouteRecordRaw[] = [
icon: 'user-check',
},
},
{
path: 'domain-security',
name: 'DomainSecurity',
component: DomainSecurity,
meta: {
title: '域名安全',
icon: 'security',
},
},
{
path: 'operation-log',
name: 'OperationLog',
component: OperationLog,
meta: {
title: '操作日志',
icon: 'operation',
},
},
],
},
]

View File

@ -0,0 +1,130 @@
/**
* @fileoverview API
* @description API
*/
// API密钥创建请求参数
export interface CreateApiKeyRequest {
/** 密钥名称 */
name: string
/** IP白名单数组 */
ip_whitelist?: string[]
}
// API密钥创建响应数据
export interface CreateApiKeyResponse {
/** API密钥ID */
id: number
/** 密钥名称 */
name: string
/** Access Key */
access_key: string
/** Account ID */
account_id: string
/** Secret Key */
secret_key: string
/** IP白名单 */
ip_whitelist?: string[]
/** 状态1-启用0-禁用 */
status: number
/** 创建时间戳 */
created_at: number
}
// API密钥列表查询请求参数
export interface ApiKeyListRequest {
/** 页码 */
p?: number
/** 每页数量 */
rows?: number
/** 关键词搜索 */
keyword?: string
/** 状态筛选1-启用0-禁用 */
status?: number | string
}
// API密钥列表项
export interface ApiKeyItem {
/** API密钥ID */
id: number
/** 密钥名称 */
name: string
/** Access Key */
access_key: string
/** Secret Key */
secret_key: string
/** Account ID */
account_id: string
/** IP白名单 */
ip_whitelist?: string[]
/** 状态1-启用0-禁用 */
status: number
/** 状态文本 */
status_text?: string
/** 最后使用时间 - 字符串格式 */
last_used_at?: string | null
/** 最后使用IP */
last_used_ip?: string
/** 创建时间 */
created_at: string
/** 更新时间 */
updated_at: string
/** UID */
uid?: number
}
// API密钥列表响应数据
export interface ApiKeyListResponse {
/** 数据列表 */
data: ApiKeyItem[]
/** 总数 */
total: number
}
// API密钥删除请求参数
export interface DeleteApiKeyRequest {
/** API密钥ID */
config_id: number
}
// API密钥更新请求参数
export interface UpdateApiKeyRequest {
/** API密钥ID */
config_id: number
/** 密钥名称 */
name: string
/** 状态1-启用0-禁用 */
status: number
/** IP白名单数组 */
ip_whitelist?: string[]
}
// API密钥更新响应数据
export interface UpdateApiKeyResponse {
/** 更新结果消息 */
message?: string
}
// API密钥重新生成请求参数
export interface RegenerateApiKeyRequest {
/** API密钥ID */
config_id: number
}
// API密钥重新生成响应数据
export interface RegenerateApiKeyResponse {
/** Access Key */
access_key: string
/** Account ID */
account_id: string
/** Secret Key */
secret_key: string
/** 重新生成结果消息 */
message?: string
}
// API密钥删除响应数据
export interface DeleteApiKeyResponse {
/** 删除结果消息 */
message?: string
}

View File

@ -81,94 +81,104 @@ export interface DnsRecord {
*
*/
export interface GetDnsRecordListRequest {
/** 域名ID */
domain_id: number;
/** 搜索关键字字段 */
searchKey?: string;
/** 搜索值 */
searchValue?: string;
/** 页码 */
p?: number;
/** 每页条数 */
row?: number;
/** 域名ID */
domain_id: number
/** 域名类型1=宝塔内部域名2=外部域名 */
domain_type?: number
/** 搜索关键字字段 */
searchKey?: string
/** 搜索值 */
searchValue?: string
/** 页码 */
p?: number
/** 每页条数 */
row?: number
}
/**
*
*/
export interface GetDnsRecordListResponse {
/** 总记录数 */
count: number;
/** 当前页码 */
page: number;
/** 每页条数 */
row: number;
/** 记录列表 */
data: DnsRecordItem[];
/** 总记录数 */
count: number
/** 当前页码 */
page: number
/** 每页条数 */
row: number
/** 记录列表 */
data: DnsRecordItem[]
}
/**
*
*/
export interface CreateDnsRecordRequest {
/** 域名ID */
domain_id: number;
/** 解析值 */
value: string;
/** 主机记录 */
record: string;
/** 记录类型 */
type: string;
/** MX优先级 */
mx: number;
/** 生存时间 */
ttl: number;
/** 备注信息 */
remark: string;
/** 线路ID */
viewId: number;
/** 域名ID */
domain_id: number
/** 域名类型1=宝塔内部域名2=外部域名 */
domain_type?: number
/** 解析值 */
value: string
/** 主机记录 */
record: string
/** 记录类型 */
type: string
/** MX优先级 */
mx: number
/** 生存时间 */
ttl: number
/** 备注信息 */
remark: string
/** 线路ID */
viewId: number
}
/**
*
*/
export interface DeleteDnsRecordRequest {
/** 记录ID */
record_id: number | string;
/** 域名ID */
domain_id: number;
/** 记录ID */
record_id: number | string
/** 域名ID */
domain_id: number
/** 域名类型1=宝塔内部域名2=外部域名 */
domain_type?: number
}
/**
*
*/
export interface UpdateDnsRecordRequest {
/** 记录ID */
record_id: number | string;
/** 域名ID */
domain_id: number;
/** 主机记录 */
record?: string;
/** 解析值 */
value?: string;
/** 记录类型 */
type?: string;
/** MX优先级 */
mx?: number;
/** 生存时间 */
ttl?: number;
/** 备注信息 */
remark?: string;
/** 线路ID */
viewId?: number;
/** 记录ID */
record_id: number | string
/** 域名ID */
domain_id: number
/** 域名类型1=宝塔内部域名2=外部域名 */
domain_type?: number
/** 主机记录 */
record?: string
/** 解析值 */
value?: string
/** 记录类型 */
type?: string
/** MX优先级 */
mx?: number
/** 生存时间 */
ttl?: number
/** 备注信息 */
remark?: string
/** 线路ID */
viewId?: number
}
/**
* /
*/
export interface ToggleDnsRecordRequest {
/** 记录ID */
record_id: number | string;
/** 域名ID */
domain_id: number;
/** 记录ID */
record_id: number | string
/** 域名ID */
domain_id: number
/** 域名类型1=宝塔内部域名2=外部域名 */
domain_type?: number
}

View File

@ -70,6 +70,8 @@ export type DomainListResponse = ApiResponse<DomainListData>
export interface DomainDetailRequest {
/** 域名 ID */
domain_id: number
/** 域名类型1=宝塔内部域名2=外部域名 */
domain_type?: number
}
/**
@ -182,6 +184,8 @@ export interface DomainInfo {
updated_at: number
/** 是否自动续费1 是0 否) */
auto_renew: number
/** 隐私保护0 否1 是) */
privacy: number
}
/**
@ -244,6 +248,7 @@ export interface RealNameInfo {
verify_time: string
}
/**
*
*/
@ -254,6 +259,10 @@ export interface DomainDetailData {
domain_info: DomainInfo
/** 实名模板信息 */
real_name_info: RealNameInfo
/** 实名信息更新状态 */
real_name_update_info: RealNameInfo
/** 隐私保护信息 */
privacy_info: PrivacyInfo
}
/**
@ -317,6 +326,15 @@ export interface SetDomainSecurityData {
type: 'update' | 'transfer'
}
export interface PrivacyInfo {
/** 邮箱 */
email: string
/** 结束时间 */
end_time: number
/** 开始时间 */
start_time: number
}
export type SetDomainSecurityResponse = ApiResponse<SetDomainSecurityData>
// 手动刷新域名注册状态
@ -397,4 +415,46 @@ export type DomainPriceQueryResponse = ApiResponse<DomainPriceQueryData>;
export type DomainAutoRenewRequest = {
domain_id: number
status: 0 | 1
}
export interface DomainRealNameUpdateRequest {
/** 域名 ID */
domain_id: number
/** 新的实名模板 ID */
new_registrant_id: string
}
export interface PrivacyRequest {
/** 1:新购 2:续费 */
type: number
/** 域名 */
domain: string
/** 年份 */
year: number
/** 邮箱 */
email?: string
}
/** 保护隐私下单返回数据 */
export interface PrivacyData {
create_time: string
discount_price: number
domain_count: number
expire_time: string
order_id: number
order_no: string
original_price: number
payment_url: string
total_price: number
ali: string // 支付宝二维码或链接
wx: string // 微信二维码或链接
}
export type PrivacyResponse = ApiResponse<PrivacyRequest>
export interface PrivacyPriceRequest {
/** 1:新购 2:续费 */
type: number
/** 年份 */
year: number
}

View File

@ -221,7 +221,7 @@ export type ContactDetailResponse = ApiResponse<{
*
*/
export interface DelUserDetailRequest {
/** 注册者标识 ID */
registrant_id: string
/** 实名模板 ID */
id: number
}
export type DelUserDetailResponse = ApiResponse<{}>

View File

@ -0,0 +1,108 @@
/**
* @fileoverview
* @description TypeScript
*/
import type { ResolveItem } from '@/views/domain-resolve/types'
/**
*
*/
export interface GetDomainInfoRequest {
/** 域名ID */
domain_id: number
/** 域名类型1=宝塔内部域名2=外部域名 */
domain_type?: number
}
/**
*
*/
export interface DomainInfoData {
/** 域名ID */
id: number
/** 完整域名 */
full_domain: string
/** 域名类型 */
domain_type: number
/** 创建时间 */
created_at: string
/** 备注 */
remark?: string
}
/**
*
*/
export interface AddExternalDomainRequest {
/** 域名 */
full_domain: string
/** 备注 */
remark?: string
}
/**
*
*/
export interface AddExternalDomainResponse {
/** 域名ID */
id: number
/** 完整域名 */
full_domain: string
/** 域名类型 */
domain_type: number
/** 创建时间 */
created_at: string
/** 备注 */
remark?: string
}
/**
*
*/
export interface RemoveDomainRequest {
/** 域名ID */
domain_id: number
}
/**
*
*/
export interface RemoveDomainResponse {
/** 删除结果 */
success: boolean
/** 消息 */
message?: string
}
/**
*
*/
export interface CheckDomainStatusRequest {
/** 域名ID */
domain_id: number
/** 域名类型1=宝塔内部域名2=外部域名 */
domain_type?: number
}
/**
*
*/
export interface CheckDomainStatusResponse {
/** 检测结果 */
success: boolean
/** 域名状态0=未设置1=已生效2=未生效 */
ns_status: number
/** 消息 */
message?: string
}
/**
*
*/
export interface ResolveListResponse {
/** 数据列表 */
data: ResolveItem[]
/** 总数 */
total: number
}

View File

@ -325,7 +325,7 @@ export default defineComponent({
</NButton>
<div class="flex items-center justify-end gap-4">
<div class="text-lg font-bold text-[#f0a020]">
¥{Number(cartListInfo.value.total_price || 0).toFixed(0)}
¥{cartListInfo.value.total_price}
</div>
<NButton type="success" onClick={handleCheckout}>

View File

@ -22,14 +22,14 @@ import {
NButtonGroup,
} from 'naive-ui'
import { formatDate } from '@baota/utils/date'
import { useMessage } from '@baota/naive-ui/hooks'
import { useMessage, useModal } from '@baota/naive-ui/hooks'
import { useError } from '@baota/hooks/error'
import { updateDomainDnsServers, setDomainSecurity,fetchDomainAutoRenew,downloadDomainCertificate } from '@/api/domain'
import { domainUtils } from '../config'
import type { DomainInfo } from '@/types/domain'
import type { DomainInfo,PrivacyInfo } from '@/types/domain'
import { useApp } from '@/components/layout/useStore'
import { AddOutline, RemoveOutline } from '@vicons/ionicons5'
import { useDomainDetailState } from '../useStore'
/**
*
@ -41,6 +41,10 @@ export default defineComponent({
type: Object as PropType<DomainInfo | null>,
default: null,
},
privacyInfo: {
type: Object as PropType<PrivacyInfo | null>,
default: null,
},
loading: {
type: Boolean,
default: false,
@ -55,6 +59,8 @@ export default defineComponent({
const { handleError } = useError()
const { isMobile } = useApp()
const { openPrivacyDialog } = useDomainDetailState()
// DNS服务器设置相关状态
const showDnsModal = ref(false)
const dnsForm = ref({
@ -101,7 +107,7 @@ export default defineComponent({
key,
label: `NS服务器${i}`,
value: String(dnsForm.value[key] || ''),
required: i <= 2 // 前两个必填
required: i <= 2, // 前两个必填
})
}
return fields
@ -112,12 +118,12 @@ export default defineComponent({
return [
{
label: 'NS服务器1:',
value: props.domainInfo?.ns1 || ''
value: props.domainInfo?.ns1 || '',
},
{
label: 'NS服务器2:',
value: props.domainInfo?.ns2 || ''
}
value: props.domainInfo?.ns2 || '',
},
]
}
@ -126,11 +132,7 @@ export default defineComponent({
<div>
{getDefaultDnsFields().map((field, index) => (
<NFormItem key={index} label={field.label}>
<NInput
value={field.value}
readonly
placeholder={field.value || '暂无'}
/>
<NInput value={field.value} readonly placeholder={field.value || '暂无'} />
</NFormItem>
))}
</div>
@ -180,12 +182,17 @@ export default defineComponent({
ns6: props.domainInfo.ns6 || '',
domain_id: props.domainInfo.id,
}
// 计算当前需要显示的字段数量
const filledCount = [
dnsForm.value.ns1, dnsForm.value.ns2, dnsForm.value.ns3,
dnsForm.value.ns4, dnsForm.value.ns5, dnsForm.value.ns6
].findLastIndex(v => v && v.trim()) + 1
const filledCount =
[
dnsForm.value.ns1,
dnsForm.value.ns2,
dnsForm.value.ns3,
dnsForm.value.ns4,
dnsForm.value.ns5,
dnsForm.value.ns6,
].findLastIndex((v) => v && v.trim()) + 1
visibleDnsCount.value = Math.max(filledCount, 2)
}
}
@ -193,7 +200,12 @@ export default defineComponent({
// 打开DNS设置弹窗
const openDnsModal = () => {
if (props.domainInfo) {
dnsMode.value = 'default' // 默认打开默认模式
//如果ns数量大于2则默认打开自定义模式
if (props.domainInfo?.ns3) {
switchToCustomMode()
} else {
dnsMode.value = 'default' // 默认打开默认模式
}
showDnsModal.value = true
}
}
@ -205,20 +217,24 @@ export default defineComponent({
message.error('NS服务器1和NS服务器2为必填项')
return
}
// 顺序校验:不能跳跃填写
const dnsValues = [
dnsForm.value.ns1, dnsForm.value.ns2, dnsForm.value.ns3,
dnsForm.value.ns4, dnsForm.value.ns5, dnsForm.value.ns6
dnsForm.value.ns1,
dnsForm.value.ns2,
dnsForm.value.ns3,
dnsForm.value.ns4,
dnsForm.value.ns5,
dnsForm.value.ns6,
]
for (let i = 0; i < dnsValues.length - 1; i++) {
if (!dnsValues[i] && dnsValues[i + 1]) {
message.error(`请按顺序填写DNS服务器NS服务器${i + 1}为空但NS服务器${i + 2}有值`)
return
}
}
// DNS格式校验
const dnsRegex = /^[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?)*$/
for (let i = 0; i < dnsValues.length; i++) {
@ -273,17 +289,41 @@ export default defineComponent({
}
// 自动续费
const toggleAutoRenew = async () => {
const { fetch, message,data } = fetchDomainAutoRenew({
const { fetch, message, data } = fetchDomainAutoRenew({
domain_id: props.domainInfo!.id,
status: props.domainInfo?.auto_renew === 1 ? 0 : 1,
})
message.value = true
await fetch()
if (data.value.status) {
props.onRefresh?.()
}
}
// 刷新数据
function refreshFetch() {
props.onRefresh?.()
}
/**
*
* @param row
*/
function openCnDomainPrivacyModal(row: DomainInfo | null) {
openPrivacyDialog.value = useModal({
title: '.CN/.中国专属域名隐私保护',
area: '520px',
component: () => import('./PrivacyProtection'),
componentProps: {
domain: row,
privacy: props.privacyInfo,
refresh: refreshFetch, // 传递列表刷新函数
onClose: () => {
openPrivacyDialog.value?.close()
},
},
footer: false,
})
}
// 切换安全设置
const toggleSecurity = async (type: 'dns_lock' | 'transfer_lock' | 'update_lock', currentStatus: number) => {
@ -362,7 +402,9 @@ export default defineComponent({
</div>
)
}
const isPrivacy = computed(() => {
return !!props.domainInfo?.privacy
})
return () => (
<div class="py-2">
<NGrid cols="1 m:2" xGap="16" yGap="16" responsive="screen">
@ -416,6 +458,26 @@ export default defineComponent({
{renderInfoItem('到期时间', formatDate(props.domainInfo?.expire_time))}
{renderInfoItem('创建时间', formatDate(props.domainInfo?.created_at || 0))}
{renderInfoItem('更新时间', formatDate(props.domainInfo?.updated_at || 0))}
{(props.domainInfo?.suffix === 'cn' || props.domainInfo?.suffix === '中国') && (
<div class="mb-2 flex items-center h-10 hover:bg-gray-100">
<div class="text-gray-500 font-bold w-25 mr-5 ml-1"></div>
<div>
<NTag type={isPrivacy.value ? 'success' : 'warning'} bordered={false}>
{isPrivacy.value
? `已开启(到期:${formatDate(props.privacyInfo?.end_time, 'yyyy-MM-dd')})`
: '未开启'}
</NTag>
<NButton
type="primary"
size="small"
class="ml-2"
onClick={() => props.domainInfo && openCnDomainPrivacyModal(props.domainInfo)}
>
{isPrivacy.value ? '延续隐私保护' : '.CN/.中国专属域名隐私保护'}
</NButton>
</div>
</div>
)}
</NCard>
</NGridItem>
@ -424,10 +486,10 @@ export default defineComponent({
<NCard title="DNS信息" header-style="font-size:16px;font-weight:500" class="h-[280px]">
{renderInfoItem('NS服务器1', props.domainInfo?.ns1)}
{renderInfoItem('NS服务器2', props.domainInfo?.ns2)}
{renderInfoItem('DNS状态', null, 'tag', {
{/* {renderInfoItem('DNS', null, 'tag', {
type: props.domainInfo?.ns_status === 1 ? 'success' : 'warning',
text: props.domainInfo?.ns_status === 1 ? '正常' : '异常',
})}
text: props.domainInfo?.ns_status === 1 ? '正常' : '未生效',
})} */}
<NButton size="small" ghost class="mt-4" onClick={openDnsModal} disabled={props.loading}>
DNS
</NButton>

View File

@ -808,20 +808,6 @@ export default defineComponent({
}
}
/**
* @description
* @param field
* @param recordId ID
* @returns
*/
const handleAnalysisSearch = () => {
if (isAdding.value || isEditing.value !== '') {
message.warning('正在编辑/添加记录,请先保存后搜索')
return
}
fetchRecords()
}
/**
* - TSX
* @param rowData

View File

@ -62,9 +62,6 @@ export function useDnsAnalysisController(props: DnsAnalysisControllerProps) {
const editingRecord = ref<DnsRecordItem | null>();
const isEditing = ref<string>("");
const isAdding = ref<boolean>(false);
// dns解析弹窗
const dnsAnalysisDialog = ref<boolean>(false);
// 新记录表单
const newRecord = reactive<DnsRecordForm>({

View File

@ -0,0 +1,107 @@
/**
* DNSSEC
* DNSSECDS
*/
import { defineComponent, PropType } from 'vue'
import {
NButton,
NDataTable,
NSpace,
NText,
NAlert,
} from 'naive-ui'
import { AddOutline, SyncOutline } from '@vicons/ionicons5'
import { useController } from './useController'
import { useDnssecManagementState } from './useStore'
import type { DnssecManagementProps } from './types.d'
export default defineComponent({
name: 'DnssecManagement',
props: {
domainId: {
type: Number,
required: true,
},
domainName: {
type: String,
required: true,
},
visible: {
type: Boolean,
required: true,
},
onClose: {
type: Function as PropType<() => void>,
required: true,
},
},
setup(props: DnssecManagementProps) {
// 获取store状态
const store = useDnssecManagementState()
// 获取控制器
const {
loading,
records,
recordsCount,
canAddMore,
hasRecords,
isMaxRecords,
DnssecTable,
handleAddRecord,
handleDeleteRecord,
handleSyncRecords,
} = useController(props)
return () => (
<div class="space-y-4">
{/* 操作按钮 */}
<div class="flex justify-between items-center">
<NSpace>
<NButton type="primary" onClick={handleAddRecord} disabled={!canAddMore} class="!px-4">
DS
</NButton>
<NButton
type="primary"
ghost
onClick={async () => {
await handleSyncRecords()
}}
disabled={store.syncLoading.value}
loading={store.syncLoading.value}
class="!px-4"
>
DS
</NButton>
</NSpace>
</div>
{/* DS记录表格 */}
{hasRecords ? (
<DnssecTable loading={loading.value} maxHeight="300px" scrollX="800px" class="border rounded" />
) : (
<div class="text-center py-8 text-gray-500">
<div class="text-lg mb-2">DS</div>
<div class="text-sm">"添加DS记录"</div>
</div>
)}
{/* 使用说明 */}
<NAlert>
<div class="space-y-2">
<div class="font-medium text-sm">DNSSEC使</div>
<div class="text-sm space-y-1">
<div>
1.
DNSSECDNS
</div>
<div>2.DNSDNSSEC使DNSSECDNS</div>
<div>3. 8DS</div>
</div>
</div>
</NAlert>
</div>
)
},
})

View File

@ -0,0 +1,83 @@
/**
* DNSSEC
*/
/**
* DNSSEC DS
*/
export interface DnssecRecord {
/** 记录ID */
id: number
/** 密钥标签 */
keyTag: number
/** 加密算法 */
algorithm: number
/** 摘要类型 */
digestType: number
/** 摘要值 */
digest: string
/** 创建时间 */
createdAt?: string
/** 更新时间 */
updatedAt?: string
}
/**
* DNSSECProps
*/
export interface DnssecManagementProps {
/** 域名ID */
domainId: number
/** 域名名称 */
domainName: string
/** 是否显示模态框 */
visible: boolean
/** 关闭回调 */
onClose: () => void
}
/**
* DS
*/
export interface AddDnssecRecordForm {
/** 域名ID */
domainId: number
/** 密钥标签 */
keyTag: number | null
/** 加密算法 */
algorithm: number
/** 摘要类型 */
digestType: number
/** 摘要值 */
digest: string
}
/**
* DS
*/
export interface SyncDnssecRecordsRequest {
/** 域名ID */
domainId: number
}
/**
* DS
*/
export interface DeleteDnssecRecordRequest {
/** 记录ID */
recordId: number
/** 域名ID */
domainId: number
}
/**
*
*/
export interface DnssecTableColumn {
title: string
key: string
width?: number
align?: 'left' | 'center' | 'right'
ellipsis?: { tooltip: boolean }
render?: (row: DnssecRecord) => any
}

View File

@ -0,0 +1,369 @@
/**
* DNSSEC
*
*/
import { ref, computed, watch, defineComponent } from 'vue'
import { NButton, NIcon, NForm, NFormItem, NInput, NSelect, NGrid, NGridItem } from 'naive-ui'
import { AddOutline, SyncOutline, TrashOutline } from '@vicons/ionicons5'
import { useDnssecManagementState } from './useStore'
import { useModal, useModalHooks, useMessage, useForm, useFormHooks, useTable, useLoadingMask } from '@baota/naive-ui/hooks'
import { useDialog } from 'naive-ui'
import { useError } from '@baota/hooks/error'
import type { DnssecRecord, DnssecManagementProps, DnssecTableColumn } from './types.d'
/**
* DS
*/
const createAddDnssecRecordModal = (store: any, domainId: number, onRefresh: () => Promise<void>) => defineComponent({
name: 'AddDnssecRecordModal',
setup() {
const { close } = useModalHooks()
const closeModal = close()
const message = useMessage()
const { handleError } = useError()
const { useFormInput, useFormSelect } = useFormHooks()
const algorithmOptions = [
{ label: 'RSA/MD5', value: 1 },
{ label: 'Diffie-Hellman', value: 2 },
{ label: 'DSA/SHA-1', value: 3 },
{ label: 'RSA/SHA-1', value: 5 },
{ label: 'DSA-NSEC3-SHA1', value: 6 },
{ label: 'RSASHA1-NSEC3-SHA1', value: 7 },
{ label: 'RSA/SHA-256', value: 8 },
{ label: 'RSA/SHA-512', value: 10 },
{ label: 'GOST R 34.10-2001', value: 12 },
{ label: 'ECDSA Curve P-256 with SHA-256', value: 13 },
{ label: 'ECDSA Curve P-384 with SHA-384', value: 14 },
{ label: 'Ed25519', value: 15 },
{ label: 'Ed448', value: 16 },
{ label: 'Reserved for Indirect Keys', value: 252 },
{ label: 'private algorithm', value: 253 },
{ label: 'private algorithm OID', value: 254 },
]
const digestTypeOptions = [
{ label: 'SHA-1', value: 1 },
{ label: 'SHA-256', value: 2 },
{ label: 'GOST R 34.11-94', value: 3 },
{ label: 'SHA-384', value: 4 },
]
// 表单配置
const formConfig = [
useFormInput(
'密钥标签',
'keyTag',
{
placeholder: '请输入密钥标签 (0-65535)',
clearable: true,
},
{
required: true,
showFeedback: true,
labelWidth: '80px',
rule: {
required: true,
trigger: ['input', 'blur'],
validator: (rule: any, value: any) => {
if (!value || value === '') {
return new Error('请输入密钥标签')
}
if (!/^\d+$/.test(value)) {
return new Error('密钥标签格式错误')
}
const num = Number(value)
if (isNaN(num) || !Number.isInteger(num)) {
return new Error('密钥标签必须是整数')
}
if (num < 0 || num > 65535) {
return new Error('密钥标签必须是0-65535之间的整数')
}
return true
},
},
},
),
useFormSelect(
'加密算法',
'algorithm',
algorithmOptions,
{
placeholder: '请选择加密算法',
},
{
required: true,
showFeedback: true,
labelWidth: '80px',
},
),
useFormSelect(
'摘要类型',
'digestType',
digestTypeOptions,
{
placeholder: '请选择摘要类型',
},
{
required: true,
showFeedback: true,
labelWidth: '80px',
},
),
useFormInput(
'摘要',
'digest',
{
placeholder: '请输入摘要内容 (十六进制字符串)',
type: 'textarea',
autosize: { minRows: 3, maxRows: 6 },
},
{
required: true,
showFeedback: true,
labelWidth: '80px',
rule: {
required: true,
message: '请填写摘要内容',
trigger: ['blur'],
},
},
),
{
type: 'custom' as const,
render: () => (
<div class="flex justify-end gap-2 mt-6">
<NButton onClick={closeModal}></NButton>
<NButton
type="primary"
disabled={formLoading.value}
onClick={async () => {
try {
await submitForm()
} catch (error) {
console.error('表单提交失败:', error)
}
}}
>
</NButton>
</div>
),
},
]
// 表单实例
const { component: FormComponent, fetch: submitForm, loading: formLoading } = useForm({
config: formConfig,
defaultValue: {
keyTag: '',
algorithm: 8, // RSA/SHA-256
digestType: 2, // SHA-256
digest: '',
},
request: async (formData: any) => {
try {
const submitData = {
...formData,
keyTag: Number(formData.keyTag),
domainId: domainId
}
const result = await store.addDnssecRecord(submitData)
if (result.success) {
await onRefresh()
closeModal()
}
} catch (error) {
handleError(error)
}
},
})
return () => (
<div class="max-w-2xl">
<FormComponent />
</div>
)
},
})
/**
* DNSSEC
*/
export function useController(props: DnssecManagementProps) {
const store = useDnssecManagementState()
const { handleError } = useError()
const dialog = useDialog()
/**
*
*/
const createColumns = [
{
title: '密钥标签',
key: 'keyTag',
width: 100,
align: 'center',
render: (row: DnssecRecord) => row.keyTag.toString(),
},
{
title: '加密算法',
key: 'algorithm',
width: 150,
align: 'center',
render: (row: DnssecRecord) => {
const algorithmMap: Record<number, string> = {
1: 'RSA/MD5',
2: 'Diffie-Hellman',
3: 'DSA/SHA-1',
5: 'RSA/SHA-1',
6: 'DSA-NSEC3-SHA1',
7: 'RSASHA1-NSEC3-SHA1',
8: 'RSA/SHA-256',
10: 'RSA/SHA-512',
12: 'GOST R 34.10-2001',
13: 'ECDSA Curve P-256 with SHA-256',
14: 'ECDSA Curve P-384 with SHA-384',
15: 'Ed25519',
16: 'Ed448',
252: 'Reserved for Indirect Keys',
253: 'private algorithm',
254: 'private algorithm OID',
}
return algorithmMap[row.algorithm] || `算法 ${row.algorithm}`
},
},
{
title: '摘要类型',
key: 'digestType',
width: 120,
align: 'center',
render: (row: DnssecRecord) => {
const digestTypeMap: Record<number, string> = {
1: 'SHA-1',
2: 'SHA-256',
3: 'GOST R 34.11-94',
4: 'SHA-384',
}
return digestTypeMap[row.digestType] || `类型 ${row.digestType}`
},
},
{
title: '摘要',
key: 'digest',
width: 300,
ellipsis: { tooltip: true },
},
{
title: '操作',
key: 'actions',
width: 100,
align: 'center',
render: (row: DnssecRecord) => (
<NButton
size="small"
type="error"
ghost
class="!px-2"
onClick={() => handleDeleteRecord(row.id)}
>
</NButton>
),
},
]
// 表格实例
const {
TableComponent: DnssecTable,
loading,
fetch: fetchDnssecRecords,
data: tableData,
} = useTable({
config: createColumns as any,
request: async (params: any) => {
const result = await store.fetchDnssecRecords({ domainId: params.domainId || props.domainId })
return result as any
},
defaultValue: { domainId: props.domainId },
})
// 计算属性
const hasRecords = computed(() => {
const data = tableData.value as any
return data?.list?.length > 0 || data?.length > 0
})
const isMaxRecords = computed(() => {
const data = tableData.value as any
const length = data?.list?.length || data?.length || 0
return length >= 8
})
const recordsCount = computed(() => {
const data = tableData.value as any
return data?.list?.length || data?.length || 0
})
// 事件处理
const handleAddRecord = () => {
const AddDnssecRecordModalComponent = createAddDnssecRecordModal(store, props.domainId, fetchDnssecRecords)
useModal({
title: '添加DS记录',
area: '600px',
component: AddDnssecRecordModalComponent,
footer: false,
})
}
const handleDeleteRecord = async (recordId: number) => {
dialog.warning({
title: '确认删除',
content: '确定要删除这条DS记录吗删除后无法恢复。',
positiveText: '删除',
negativeText: '取消',
onPositiveClick: async () => {
await store.deleteDnssecRecord(recordId)
await fetchDnssecRecords()
},
})
}
const handleSyncRecords = async () => {
try {
await store.syncDnssecRecords({ domainId: props.domainId })
await fetchDnssecRecords()
} catch (error) {
handleError(error)
}
}
// 监听模态框显示状态
watch(
() => props.visible,
(visible) => {
if (visible) {
fetchDnssecRecords()
}
},
{ immediate: true }
)
return {
// 状态
loading,
records: tableData,
recordsCount,
canAddMore: computed(() => !isMaxRecords.value),
hasRecords,
isMaxRecords,
// 表格组件
DnssecTable,
// 事件处理
handleAddRecord,
handleDeleteRecord,
handleSyncRecords,
}
}

View File

@ -0,0 +1,184 @@
/**
* DNSSEC
* DNSSEC
*/
import { ref, computed } from 'vue'
import { defineStore } from 'pinia'
import { storeToRefs } from 'pinia'
import { useMessage, useLoadingMask } from '@baota/naive-ui/hooks'
import { useError } from '@baota/hooks/error'
import { addDnssecDsRecord, getDnssecDsList, deleteDnssecDsRecord, syncDnssecDsRecord } from '@/api/domain'
import type { DnssecRecord, AddDnssecRecordForm, SyncDnssecRecordsRequest, DeleteDnssecRecordRequest } from './types.d'
/**
* DNSSECStore
*/
export const useDnssecManagementStore = defineStore('dnssecManagement', () => {
const { handleError } = useError()
// 状态
const loading = ref(false)
const syncLoading = ref(false)
const records = ref<DnssecRecord[]>([])
const currentDomainId = ref<number | null>(null)
// 计算属性
const recordsCount = computed(() => records.value.length)
const canAddMore = computed(() => recordsCount.value < 8) // 最多8条记录
/**
* ID
*/
const setCurrentDomainId = (domainId: number) => {
currentDomainId.value = domainId
}
/**
* DNSSECuseTable使
*/
const fetchDnssecRecords = async (params: { domainId: number } = { domainId: 0 }) => {
try {
loading.value = true
setCurrentDomainId(params.domainId)
const { fetch, data } = getDnssecDsList({ domain_id: params.domainId.toString() })
await fetch()
if (data.value?.status) {
const apiData = data.value.data || []
const mappedData = apiData.map((item: any) => ({
id: item.id,
keyTag: item.key_tag,
algorithm: item.alg,
digestType: item.digest_type,
digest: item.digest,
}))
records.value = mappedData
return {
list: mappedData,
total: mappedData.length,
}
}
} catch (error) {
handleError(error)
} finally {
loading.value = false
}
}
/**
* DS
*/
const addDnssecRecord = async (formData: AddDnssecRecordForm): Promise<{ success: boolean }> => {
try {
loading.value = true
const { fetch, data, message } = addDnssecDsRecord({
domain_id: formData.domainId.toString(),
key_tag: formData.keyTag!,
alg: formData.algorithm,
digest_type: formData.digestType,
digest: formData.digest
})
message.value = true
await fetch()
if (data.value?.status) {
return { success: true }
} else {
throw new Error(data.value?.msg || '添加DS记录失败')
}
} catch (error) {
handleError(error)
return { success: false }
} finally {
loading.value = false
}
}
/**
* DS
*/
const deleteDnssecRecord = async (recordId: number): Promise<void> => {
const { open: openLoad, close: closeLoad } = useLoadingMask({ text: '正在删除DS记录请稍后...', zIndex: 9999 })
openLoad()
try {
const { fetch, data, message } = deleteDnssecDsRecord({ ds_id: recordId })
message.value = true
await fetch()
} catch (error) {
handleError(error)
} finally {
closeLoad()
}
}
/**
* DS
*/
const syncDnssecRecords = async (params: SyncDnssecRecordsRequest): Promise<void> => {
try {
syncLoading.value = true
const { fetch, data, message } = syncDnssecDsRecord({ domain_id: params.domainId.toString() })
message.value = true
await fetch()
} catch (error) {
handleError(error)
} finally {
syncLoading.value = false
}
}
/**
*
*/
const clearRecords = () => {
records.value = []
currentDomainId.value = null
}
return {
// 状态
loading,
syncLoading,
records,
currentDomainId,
// 计算属性
recordsCount,
canAddMore,
// 方法
setCurrentDomainId,
fetchDnssecRecords,
addDnssecRecord,
deleteDnssecRecord,
syncDnssecRecords,
clearRecords,
}
})
/**
* Store
*/
export const useDnssecManagementState = () => {
const store = useDnssecManagementStore()
const { loading, syncLoading, records, currentDomainId, recordsCount, canAddMore } = storeToRefs(store)
return {
// 状态
loading,
syncLoading,
records,
currentDomainId,
// 计算属性
recordsCount,
canAddMore,
// 方法
setCurrentDomainId: store.setCurrentDomainId,
fetchDnssecRecords: store.fetchDnssecRecords,
addDnssecRecord: store.addDnssecRecord,
deleteDnssecRecord: store.deleteDnssecRecord,
syncDnssecRecords: store.syncDnssecRecords,
clearRecords: store.clearRecords,
}
}

View File

@ -0,0 +1,244 @@
/**
*
* .CN/
*/
import { defineComponent, computed, type PropType } from 'vue'
import {
NSteps,
NStep,
NAlert,
NSpace,
NButton,
NSelect,
NInput,
NRadioGroup,
NRadioButton,
NQrCode,
NFlex,
NGrid,
NGridItem,
NTag
} from 'naive-ui'
import { usePrivacyProtectionController } from './useController'
import { useRechargeState } from "@/views/recharge/useStore"
import type { DomainInfo, PrivacyInfo } from '@/types/domain'
export default defineComponent({
name: 'PrivacyProtectionModal',
props: {
domain: {
type: Object as PropType<DomainInfo>,
required: true,
},
privacy: {
type: Object as PropType<PrivacyInfo>,
default: null,
},
refresh: {
type: Function as PropType<() => void>,
required: true,
},
onClose: {
type: Function as PropType<() => void>,
required: false,
},
},
setup(props) {
const recharge = useRechargeState()
const {
protectionForm,
currentStep,
orderInfo,
priceLoading,
priceError,
paymentMethod,
orderCreating,
orderCreated,
qrCodeUrl,
handleNext,
handleCancel,
handleBalancePayment,
switchPaymentMethod,
} = usePrivacyProtectionController(props)
const canPayByBalance = computed(
() => Number(recharge.overview.value?.balance || 0) >= Number(orderInfo.value?.data?.total_price || 0),
)
return () => (
<div class="privacy-protection-modal">
{/* 步骤指示器 */}
<NSteps current={currentStep.value} class="mb-6">
<NStep title="确认保护信息" />
<NStep title="选择支付方式" />
</NSteps>
{/* 步骤1确认保护信息 */}
{currentStep.value === 1 && (
<div class="step-content">
<NAlert type="info" class="mb-6">
.CN/.
</NAlert>
<div class="form-container space-y-4 mb-8">
{/* 域名 */}
<div class="form-item flex items-center">
<label class="form-label w-24 text-gray-700"></label>
<span class="form-value text-gray-900 font-medium">{protectionForm.value.domain}</span>
</div>
{/* 隐私保护时长 */}
<div class="form-item flex items-center">
<label class="form-label w-24 text-gray-700"></label>
<div class="form-control">
<NSelect
v-model:value={protectionForm.value.protectionTime}
style={{ width: '120px' }}
options={[
{ label: '1年', value: 1 },
{ label: '2年', value: 2 },
{ label: '3年', value: 3 },
{ label: '5年', value: 5 },
{ label: '10年', value: 10 },
]}
/>
</div>
</div>
{/* 联系邮箱 */}
<div class="form-item flex items-center">
<label class="form-label w-24 text-gray-700"></label>
<div class="form-control">
<NInput
v-model:value={protectionForm.value.contactEmail}
placeholder="请输入联系邮箱(可选)"
style={{ width: '320px' }}
/>
</div>
</div>
{/* 保护价格 */}
<div class="form-item flex items-center">
<label class="form-label w-24 text-gray-700"></label>
<span class="form-value text-red-500 font-bold text-xl">
{priceLoading.value
? '--'
: priceError.value
? '--'
: protectionForm.value.price > 0
? `¥${protectionForm.value.price}`
: '--'}
</span>
</div>
</div>
{/* 只在步骤一显示底部操作按钮 */}
<div class="flex justify-end">
<NSpace>
<NButton onClick={handleCancel}></NButton>
<NButton
type="primary"
onClick={handleNext}
disabled={priceLoading.value || orderCreating.value}
loading={orderCreating.value}
>
{orderCreating.value ? '正在创建订单...' : '下一步'}
</NButton>
</NSpace>
</div>
</div>
)}
{/* 步骤2选择支付方式 */}
{currentStep.value === 2 && (
<div class="step-content">
<NGrid cols="1" xGap={12} yGap={20}>
{/* 订单确认信息 */}
<NGridItem>
<div class="order-summary mb-6">
<div class="order-info-list space-y-3">
<div class="order-item flex justify-between items-center">
<span class="order-label text-gray-700"></span>
<span class="order-value text-gray-900 font-medium">{protectionForm.value.domain}</span>
</div>
<div class="order-item flex justify-between items-center">
<span class="order-label text-gray-700"></span>
<span class="order-value text-gray-900">{protectionForm.value.protectionTime}</span>
</div>
<div class="order-item flex justify-between items-center">
<span class="order-label text-gray-700"></span>
<span class="order-value text-gray-900">{protectionForm.value.contactEmail || '未填写'}</span>
</div>
<div class="order-item flex justify-between items-center">
<span class="order-label text-gray-700"></span>
<div class="flex items-center">
{/* 原价 */}
<span class="text-gray-500 text-sm mr-2" style={{ textDecoration: 'line-through' }}>¥{orderInfo.value?.data?.original_price}</span>
<span class="order-value text-red-500 font-bold text-lg">
¥
{orderCreated.value && orderInfo.value?.data?.total_price
? orderInfo.value.data.total_price
: protectionForm.value.price}
</span>
</div>
</div>
</div>
</div>
</NGridItem>
{/* 支付方式选择 */}
<NGridItem class="flex items-center">
<strong class="text-sm mr-4"></strong>
<NRadioGroup value={paymentMethod.value} size="small" onUpdateValue={switchPaymentMethod}>
<NRadioButton value="wechat"></NRadioButton>
<NRadioButton value="alipay"></NRadioButton>
<NRadioButton value="balance"></NRadioButton>
</NRadioGroup>
</NGridItem>
{/* 支付内容展示 */}
<NGridItem>
{orderCreating.value ? (
<NFlex vertical align="center" class="py-8">
<div class="text-gray-600">...</div>
</NFlex>
) : !orderCreated.value ? (
<NFlex vertical align="center" class="py-8">
<div class="text-red-500"></div>
</NFlex>
) : paymentMethod.value !== 'balance' ? (
<NFlex vertical align="center">
<NQrCode value={qrCodeUrl.value || ''} size={180} />
<div class="text-xs text-gray-600">
使{paymentMethod.value === 'wechat' ? '微信' : '支付宝'}
</div>
</NFlex>
) : (
<NFlex vertical align="center" class="py-6">
<NTag type={canPayByBalance.value ? 'success' : 'warning'} bordered={false}>
¥{Number(recharge.overview.value?.balance || 0).toFixed(2)} / ¥
{Number(orderInfo.value?.data?.total_price || protectionForm.value.price).toFixed(2)}
</NTag>
<NButton
type="primary"
size="large"
disabled={!canPayByBalance.value}
onClick={handleBalancePayment}
class="mt-4"
>
</NButton>
</NFlex>
)}
</NGridItem>
</NGrid>
{/* 步骤二没有底部操作按钮 */}
</div>
)}
</div>
)
},
})

View File

@ -0,0 +1,53 @@
/**
*
*/
import type { DomainItem } from '@/types/domain'
/**
*
*/
export interface PrivacyProtectionModalProps {
/** 域名信息 */
domain: DomainItem
/** 刷新回调函数 */
refresh: () => void
}
/**
*
*/
export interface PrivacyProtectionFormData {
/** 域名 */
domain: string
/** 保护时长(年) */
protectionTime: number
/** 联系邮箱 */
contactEmail: string
/** 保护价格 */
price: number
/** 支付方式 */
paymentMethod: 'wechat' | 'alipay' | 'balance'
}
/**
*
*/
export type PrivacyProtectionStep = 1 | 2
/**
*
*/
export interface PaymentMethodOption {
label: string
value: 'wechat' | 'alipay' | 'balance'
description?: string
}
/**
*
*/
export interface ProtectionTimeOption {
label: string
value: number
}

View File

@ -0,0 +1,208 @@
/**
*
*
*/
import { onMounted, onUnmounted, watch } from 'vue'
import { useMessage } from '@baota/naive-ui/hooks'
import { usePrivacyProtectionState } from './useStore'
import { buyByBalance } from '@/api/order'
import type { DomainInfo, PrivacyInfo } from '@/types/domain'
import { useRechargeController } from '@/views/recharge/useController'
interface PrivacyProtectionProps {
domain: DomainInfo
privacy: PrivacyInfo
refresh: () => void
onClose?: () => void
}
/**
*
*/
export function usePrivacyProtectionController(props: PrivacyProtectionProps) {
const message = useMessage()
const { loadAccountBalance } = useRechargeController()
// 获取状态管理
const {
currentProps,
protectionForm,
currentStep,
orderLoading,
orderInfo,
priceLoading,
priceError,
paymentMethod,
orderCreating,
orderCreated,
qrCodeUrl,
isPolling,
paymentSuccess,
queryPrivacyPrice,
createPrivacyOrder,
switchPaymentMethod,
startPaymentPolling,
stopPaymentPolling,
resetForm,
} = usePrivacyProtectionState()
// 1: 新购 2: 续费
const privacyQueryType = ref(1)
// 初始化表单数据
onMounted(async () => {
protectionForm.value.domain = props.domain.full_domain
protectionForm.value.contactEmail = props.privacy?.email || ''
currentProps.value = props.domain
privacyQueryType.value = props.privacy === null ? 1 : 2
// 查询默认价格1年新购
await queryPrivacyPrice(privacyQueryType.value, 1)
})
// 组件卸载时重置表单和停止轮询
onUnmounted(() => {
stopPaymentPolling()
resetForm()
})
// 监听保护时长变化,重新查询价格
watch(() => protectionForm.value.protectionTime, async (newYear) => {
if (newYear) {
await queryPrivacyPrice(privacyQueryType.value, newYear) // 1表示新购
}
})
// 监听步骤和支付方式变化,控制轮询
watch([currentStep, paymentMethod, orderCreated], () => {
const orderNo = orderInfo.value?.data?.order_no
if (currentStep.value === 2 && orderCreated.value && orderNo) {
if (paymentMethod.value !== 'balance') {
// 扫码支付时启动轮询
startPaymentPolling(orderNo)
} else {
// 余额支付时停止轮询
stopPaymentPolling()
}
} else {
// 不在支付步骤时停止轮询
stopPaymentPolling()
}
})
// 监听支付成功状态变化
watch(paymentSuccess, (success) => {
if (success && currentStep.value === 2) {
message.success('支付成功!隐私保护已开启')
props.refresh?.() // 刷新域名信息
props.onClose?.() // 关闭弹窗
}
})
/**
*
*/
const handleNext = async () => {
// 邮箱不为空,检查是否符合邮箱格式
if (protectionForm.value.contactEmail) {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
if (!emailRegex.test(protectionForm.value.contactEmail)) {
message.error('邮箱格式不正确')
return
}
}
// 获取账户余额
await loadAccountBalance()
// 创建隐私保护订单
await createPrivacyOrder()
// 订单创建成功后,切换到步骤二
currentStep.value = 2
}
/**
*
*/
const handleBack = () => {
currentStep.value = 1
}
/**
*
*/
const handleCancel = () => {
resetForm()
// 触发关闭弹窗
props.onClose?.()
}
/**
*
*/
const handleSwitchPaymentMethod = (method: 'wechat' | 'alipay' | 'balance') => {
switchPaymentMethod(method)
const orderNo = orderInfo.value?.data?.order_no
if (orderNo && currentStep.value === 2 && orderCreated.value) {
if (method === 'balance') {
stopPaymentPolling()
} else if (!isPolling.value) {
startPaymentPolling(orderNo)
}
}
}
/**
*
*/
const handleBalancePayment = async () => {
const orderNo = orderInfo.value?.data?.order_no
if (!orderNo) {
message.error('订单号不存在')
return
}
try {
// 这里调用余额支付API
// 参考续费模块的 payRenewByBalance 实现
const { fetch, data } = await buyByBalance({ order_no: orderNo })
await fetch()
const response = data.value
// 检查支付结果
if (response?.status) {
paymentSuccess.value = true
} else {
message.error(response?.msg || '支付失败,请重试')
}
} catch (error) {
message.error('支付失败,请重试')
}
}
return {
// 状态
protectionForm,
currentStep,
orderLoading,
orderInfo,
priceLoading,
priceError,
paymentMethod,
orderCreating,
orderCreated,
qrCodeUrl,
isPolling,
// 方法
handleNext,
handleBack,
handleCancel,
handleBalancePayment,
switchPaymentMethod: handleSwitchPaymentMethod,
resetForm,
}
}

View File

@ -0,0 +1,298 @@
/**
*
* API
*/
import { ref, reactive, computed } from 'vue'
import { defineStore, storeToRefs } from 'pinia'
import { useError } from '@baota/hooks/error'
import { privacyOrder, privacyPrice } from '@/api/domain'
import { queryPaymentStatus } from '@/api/order'
import type { PrivacyRequest, PrivacyPriceRequest } from '@/types/domain'
import type { ApiResponse } from '@/types/api'
import type { DomainInfo } from '@/types/domain'
const { handleError } = useError()
/** 轮询定时器 */
let paymentPollTimer: any = null
/**
*
*/
interface PrivacyProtectionForm {
/** 域名 */
domain: string
/** 保护时长(年) */
protectionTime: number
/** 联系邮箱 */
contactEmail: string
/** 保护价格 */
price: number
}
/**
* Store
*/
export const usePrivacyProtectionStore = defineStore('privacy-protection-store', () => {
// -------------------- 状态定义 --------------------
/** 表单数据 */
const protectionForm = reactive<PrivacyProtectionForm>({
domain: '',
protectionTime: 1,
contactEmail: '',
price: 0,
})
/** 当前row */
const currentProps = ref<DomainInfo | null>(null)
/** 当前步骤 */
const currentStep = ref<1 | 2>(1)
/** 订单状态 */
const orderLoading = ref(false)
const orderInfo = ref<ApiResponse | null>(null)
/** 价格查询状态 */
const priceLoading = ref(false)
const priceError = ref(false)
/** 支付方式 */
const paymentMethod = ref<'wechat' | 'alipay' | 'balance'>('wechat')
/** 订单创建状态 */
const orderCreating = ref(false)
const orderCreated = ref(false)
/** 轮询状态 */
const isPolling = ref(false)
/** 支付成功状态 */
const paymentSuccess = ref(false)
// -------------------- 计算属性 --------------------
/** 获取二维码链接 */
const qrCodeUrl = computed(() => {
if (!orderInfo.value?.data) return ''
return paymentMethod.value === 'wechat'
? orderInfo.value.data.wx
: orderInfo.value.data.ali
})
// -------------------- 方法定义 --------------------
/**
*
*/
const queryPrivacyPrice = async (type: number = 1, year: number = 1): Promise<void> => {
try {
priceLoading.value = true
priceError.value = false
const params: PrivacyPriceRequest = {
type, // 1:新购 2:续费
year,
}
const { fetch, data } = privacyPrice(params)
await fetch()
// 假设 API 返回的数据结构中包含价格信息
const responseData = data.value?.data
if (responseData && typeof responseData.price === 'number') {
protectionForm.price = responseData.price
} else {
// 如果没有返回价格或格式不正确,设置错误状态
priceError.value = true
protectionForm.price = 0
}
} catch (error) {
priceError.value = true
protectionForm.price = 0
handleError(error)
} finally {
priceLoading.value = false
}
}
/**
*
*/
const createPrivacyOrder = async (): Promise<void> => {
try {
orderCreating.value = true
orderCreated.value = false
console.log(currentProps.value, '--')
const params: PrivacyRequest = {
type: currentProps.value?.privacy === 1? 2:1, // 1:新购 2:续费
domain: protectionForm.domain,
year: protectionForm.protectionTime,
email: protectionForm.contactEmail || undefined,
}
const { fetch, data } = privacyOrder(params)
await fetch()
// 检查API返回状态
if (data.value?.status === false) {
// 如果状态为false抛出包含具体错误信息的错误
const errorMsg = data.value?.msg || '订单创建失败'
throw new Error(errorMsg)
}
orderInfo.value = data.value
orderCreated.value = true
} catch (error) {
handleError(error)
throw error
} finally {
orderCreating.value = false
}
}
/**
*
*/
const switchPaymentMethod = (method: 'wechat' | 'alipay' | 'balance') => {
paymentMethod.value = method
}
/**
*
*/
const resetForm = () => {
currentStep.value = 1
orderInfo.value = null
priceError.value = false
priceLoading.value = false
orderCreating.value = false
orderCreated.value = false
paymentMethod.value = 'wechat'
paymentSuccess.value = false
stopPaymentPolling()
Object.assign(protectionForm, {
domain: '',
protectionTime: 1,
contactEmail: '',
price: 0,
})
}
/**
*
* @param years
*/
const calculatePrice = (years: number): number => {
// 这里可以根据实际的定价策略来计算
// 暂时使用固定价格 30元/年
return years * 30
}
/**
*
* @param years
*/
const updateProtectionTime = (years: number) => {
protectionForm.protectionTime = years
protectionForm.price = calculatePrice(years)
}
/**
*
*/
const queryPrivacyPaymentStatus = async (orderNo: string): Promise<boolean> => {
try {
const { fetch, data } = queryPaymentStatus({ order_no: orderNo })
await fetch()
return Number(data.value?.data?.status ?? 0) === 1
} catch (error) {
console.error('查询支付状态失败:', error)
return false
}
}
/**
*
*/
const startPaymentPolling = (orderNo: string) => {
if (!orderNo || isPolling.value) return
stopPaymentPolling()
isPolling.value = true
let count = 0
const maxCount = 60 // 最多轮询60次3分钟
paymentPollTimer = setInterval(async () => {
count++
try {
const isPaid = await queryPrivacyPaymentStatus(orderNo)
if (isPaid) {
paymentSuccess.value = true
stopPaymentPolling()
return
}
if (count >= maxCount) {
stopPaymentPolling()
}
} catch (error) {
if (count >= maxCount) {
stopPaymentPolling()
}
}
}, 3000) // 每3秒查询一次
}
/**
*
*/
const stopPaymentPolling = () => {
if (paymentPollTimer) {
clearInterval(paymentPollTimer)
paymentPollTimer = null
}
isPolling.value = false
}
// 返回状态和方法
return {
// 状态
protectionForm,
currentStep,
orderLoading,
orderInfo,
priceLoading,
priceError,
paymentMethod,
orderCreating,
orderCreated,
isPolling,
paymentSuccess,
currentProps,
// 计算属性
qrCodeUrl,
// 方法
queryPrivacyPrice,
createPrivacyOrder,
switchPaymentMethod,
resetForm,
calculatePrice,
updateProtectionTime,
startPaymentPolling,
stopPaymentPolling,
}
})
/**
* Store
*/
export const usePrivacyProtectionState = () => {
const store = usePrivacyProtectionStore()
return {
...store,
...storeToRefs(store),
}
}

View File

@ -4,8 +4,8 @@
*/
import { defineComponent, ref, computed } from 'vue'
import { NCard, NGrid, NGridItem, NText, NSkeleton, NAlert, NIcon, NFlex } from 'naive-ui'
import { CheckCircle, AlertTriangle, XCircle } from 'lucide-vue-next'
import { NCard, NGrid, NGridItem, NText, NSkeleton, NAlert, NIcon, NFlex, NButton, NDivider } from 'naive-ui'
import { CheckCircle, AlertTriangle, XCircle, RefreshCw } from 'lucide-vue-next'
import { formatDate } from '@baota/utils/date'
import { useController } from '../useController'
@ -53,7 +53,13 @@ export default defineComponent({
setup(props) {
// 显示警告提示
const showAlert = ref(true)
const { domainInfo, realNameInfo, loading } = useController(props.domainId)
const { domainInfo, realNameInfo, loading, realNameInfoUpdating, openTemplateChangeModal, refreshDomainInfo } = useController(props.domainId)
// 计算加载状态
const isRealNameUpdating = computed(() => {
return realNameInfoUpdating.value !== null &&
typeof realNameInfoUpdating.value === 'object';
})
// 计算实名状态配置
const realNameStatus = computed(() => {
@ -133,6 +139,39 @@ export default defineComponent({
{renderInfoItem('联系人', realNameInfo.value?.contact_person)}
</NGridItem>
</NGrid>
<NDivider />
<div class="flex items-center">
<NButton
type="primary"
loading={isRealNameUpdating.value}
disabled={isRealNameUpdating.value}
onClick={openTemplateChangeModal}
>
</NButton>
{isRealNameUpdating.value && (
<>
<div class="text-[#f0a020] text-sm ml-4">1-30</div>
<NButton
text
size="small"
class="ml-3"
loading={loading.value}
onClick={refreshDomainInfo}
v-slots={{
icon: () => (
<NIcon size={16}>
<RefreshCw />
</NIcon>
),
}}
>
</NButton>
</>
)}
</div>
</NCard>
{/* 认证状态说明 */}

View File

@ -0,0 +1,268 @@
/**
*
*
*/
import { defineComponent, ref, computed, type PropType } from 'vue';
import { NAlert, NCard, NSelect, NButton, NFlex, NGrid, NGridItem, NSpin, NTag, NIcon } from 'naive-ui'
import { useDomainDetailState } from '../useStore';
import { useMessage } from '@baota/naive-ui/hooks';
import type { DomainInfo, RealNameInfo } from '@/types/domain';
import type { ContactTemplateItem } from '@/types/real-name';
import { updateDomainRealName } from '@/api/domain'
import { maskUtils } from '@/views/real-name/useStore'
import { useModal } from '@baota/naive-ui/hooks'
import DomainRegistrationForm from '@/views/real-name/components/DomainRegistrationForm/index'
import { RefreshFilled } from '@vicons/material'
export default defineComponent({
name: 'RealNameTemplateChangeDialog',
props: {
domainId: {
type: Number,
required: true
},
domainInfo: {
type: Object as PropType<DomainInfo | null>,
default: null
},
currentTemplate: {
type: Object as PropType<RealNameInfo | null>,
default: null
},
refresh: {
type: Function as PropType<() => Promise<void>>,
required: true
},
},
setup(props) {
const { realNameTemplates, realNameTemplatesLoading, openTemplateChangeDialog, fetchRealNameTemplateList } =
useDomainDetailState()
const message = useMessage()
const selectedTemplateId = ref<string>('')
const submitting = ref(false)
// 模板选项(排除当前模板)
const templateOptions = computed(() => {
return realNameTemplates.value
.filter((template) => template.registrant_id !== props.currentTemplate?.registrant_id)
.map((template) => ({
label: `${template.template_name || template.owner_name}${template.type === 1 ? '个人实名认证' : '企业实名认证'}`,
value: template.registrant_id,
}))
})
// 获取选中的模板详情
const selectedTemplateDetail = computed(() => {
if (!selectedTemplateId.value) return null
return realNameTemplates.value.find((template) => template.registrant_id === selectedTemplateId.value)
})
// 选中实名模板
const handleSelectRealName = (val: string) => {
if (val === '-1') openCreateRealNameModal()
else selectedTemplateId.value = val
}
/** 创建实名模板窗口(步骤一入口中的快捷按钮) */
const openCreateRealNameModal = () => {
const modal = useModal({
title: '创建实名模板',
area: '1000px',
component: DomainRegistrationForm,
componentProps: {
mode: 'add',
refresh: async () => {
await fetchRealNameTemplateList()
},
},
footer: false,
})
return modal
}
// 提交更换
const handleSubmit = async () => {
if (!selectedTemplateId.value) {
message.warning('请选择新的实名模板')
return
}
try {
submitting.value = true
const { fetch, message, data } = updateDomainRealName({
domain_id: props.domainId,
new_registrant_id: selectedTemplateId.value,
})
message.value = true
await fetch()
if (data.value.status) {
await props.refresh()
openTemplateChangeDialog.value?.close()
}
} catch (error) {
// 错误已在 store 中处理
} finally {
submitting.value = false
}
}
// 取消操作
const handleCancel = () => {
openTemplateChangeDialog.value?.close()
}
// 渲染模板详细信息的通用函数
const renderTemplateDetail = (template: RealNameInfo | ContactTemplateItem | null, title: string) => {
if (!template) return null
// 统一数据格式处理
const templateData = {
id: (template as ContactTemplateItem).id || (template as RealNameInfo).registrant_id || '-',
name:
(template as ContactTemplateItem).template_name ||
(template as ContactTemplateItem).owner_name ||
(template as RealNameInfo).owner_name ||
'-',
type: template.type === 1 ? '个人实名认证' : '企业实名认证',
idNumber: (template as ContactTemplateItem).id_number || (template as RealNameInfo).id_number || '-',
owner: (template as ContactTemplateItem).owner_name || (template as RealNameInfo).owner_name || '-',
email: template.email || '-',
phone: (template as ContactTemplateItem).phone || (template as RealNameInfo).contact_person || '-',
status:
(template as ContactTemplateItem).status === 2
? '已审核'
: (template as RealNameInfo).status === 2
? '已通过'
: '审核中',
}
return (
<NCard class="mb-4" size="small">
<div class="font-bold mb-2">{title}</div>
<NGrid cols="2" xGap="16" yGap="12">
<NGridItem>
<div class="flex items-center">
<span class="text-gray-600 min-w-20">ID:</span>
<span class="ml-2">{templateData.id}</span>
</div>
</NGridItem>
<NGridItem>
<div class="flex items-center">
<span class="text-gray-600 min-w-20">:</span>
<span class="ml-2">{templateData.name}</span>
</div>
</NGridItem>
<NGridItem>
<div class="flex items-center">
<span class="text-gray-600 min-w-20">:</span>
<span class="ml-2">{templateData.type}</span>
</div>
</NGridItem>
<NGridItem>
<div class="flex items-center">
<span class="text-gray-600 min-w-20">:</span>
<span class="ml-2">{maskUtils.maskCertificateNumber(templateData.idNumber)}</span>
</div>
</NGridItem>
<NGridItem>
<div class="flex items-center">
<span class="text-gray-600 min-w-20">:</span>
<span class="ml-2">{templateData.owner}</span>
</div>
</NGridItem>
<NGridItem>
<div class="flex items-center">
<span class="text-gray-600 min-w-20">:</span>
<span class="ml-2">{templateData.email}</span>
</div>
</NGridItem>
<NGridItem>
<div class="flex items-center">
<span class="text-gray-600 min-w-20">:</span>
<span class="ml-2">{templateData.phone}</span>
</div>
</NGridItem>
<NGridItem>
<div class="flex items-center">
<span class="text-gray-600 min-w-20">:</span>
<NTag type="success" size="small" class="ml-2">
{templateData.status}
</NTag>
</div>
</NGridItem>
</NGrid>
</NCard>
)
}
return () => (
<>
{/* 蓝色提示条 */}
<NAlert type="info" class="mb-6" showIcon>
1-30
</NAlert>
{/* 标题说明 */}
<div class="mb-4">
<div class="font-bold"></div>
<div class="text-sm text-gray-600">使</div>
</div>
{/* 当前使用模板 */}
{renderTemplateDetail(props.currentTemplate, '当前使用模板')}
{/* 备选信息模板选择器 */}
<div class="mb-4">
<div class="mb-2 flex items-center justify-between">
<div>
<span class="text-gray-400">使</span>
</div>
<NButton ghost text size="tiny" onClick={() => fetchRealNameTemplateList()}>
{{
default: () => (
<>
<NIcon class="flex cursor-pointer ml-1" size={16}>
<RefreshFilled />
</NIcon>
<span></span>
</>
),
}}
</NButton>
</div>
<NSpin show={realNameTemplatesLoading.value}>
<NSelect
value={selectedTemplateId.value}
onUpdateValue={(val) => handleSelectRealName(val)}
options={[...templateOptions.value, { label: '创建实名模板…', value: '-1' }]}
placeholder="请选择需要替换的实名模板"
disabled={templateOptions.value.length === 0}
clearable
/>
</NSpin>
</div>
{/* 新选择的模板详细信息 */}
{selectedTemplateDetail.value && renderTemplateDetail(selectedTemplateDetail.value, '新选择的模板详细信息')}
{/* 操作按钮 */}
<NFlex justify="end" size="medium" class="mt-6">
<NButton size="large" onClick={handleCancel}>
</NButton>
<NButton
type="primary"
size="large"
loading={submitting.value}
disabled={!selectedTemplateId.value}
onClick={handleSubmit}
>
</NButton>
</NFlex>
</>
)
},
});

View File

@ -0,0 +1,255 @@
/**
*
*
*/
import { defineComponent, ref, PropType, computed } from 'vue'
import { NCard, NGrid, NGridItem, NSwitch, NButton, NText, NIcon, NTag } from 'naive-ui'
import { ShieldCheckmarkOutline, SettingsOutline } from '@vicons/ionicons5'
import { useApp } from '@/components/layout/useStore'
import { setDomainSecurity } from '@/api/domain'
import { useMessage, useModal } from '@baota/naive-ui/hooks'
import { formatDate } from '@baota/utils/date'
import { useError } from '@baota/hooks/error'
import { useDomainDetailState } from '../useStore'
import DnssecManagement from './DnssecMgt'
import type { DomainInfo, PrivacyInfo } from '@/types/domain'
interface SecurityProps {
domainId: number
domainInfo?: DomainInfo | null
privacyInfo?: PrivacyInfo | null
loading?: boolean
onRefresh?: () => void
}
export default defineComponent({
name: 'DomainSecurity',
props: {
domainId: {
type: Number,
required: true,
},
domainInfo: {
type: Object as PropType<DomainInfo | null>,
default: null,
},
privacyInfo: {
type: Object as PropType<PrivacyInfo | null>,
default: null,
},
loading: {
type: Boolean,
default: false,
},
onRefresh: {
type: Function as PropType<() => void>,
default: () => {},
},
},
setup(props: SecurityProps) {
const { isMobile } = useApp()
const { handleError } = useError()
const { openPrivacyDialog } = useDomainDetailState()
// 域名锁定状态
const transferLock = ref(props.domainInfo?.transfer_lock === 1)
const updateLock = ref(props.domainInfo?.update_lock === 1)
// DNSSEC状态
const dnssecEnabled = ref(false)
// 加载状态
const transferLockLoading = ref(false)
const updateLockLoading = ref(false)
// 隐私保护状态
const isPrivacy = computed(() => {
return !!props.domainInfo?.privacy
})
// 切换禁止转移锁
const handleTransferLockChange = async (value: boolean) => {
try {
transferLockLoading.value = true
const {
fetch,
message: apiMessage,
data,
} = setDomainSecurity({
domain_id: props.domainId,
type: 'transfer',
status: value ? 1 : 0,
})
apiMessage.value = true
await fetch()
if (data.value?.status) {
transferLock.value = value
const message = useMessage()
message.success(value ? '禁止转移锁已开启' : '禁止转移锁已关闭')
props.onRefresh?.()
}
} catch (error) {
handleError(error)
} finally {
transferLockLoading.value = false
}
}
// 切换禁止更新锁
const handleUpdateLockChange = async (value: boolean) => {
try {
updateLockLoading.value = true
const {
fetch,
message: apiMessage,
data,
} = setDomainSecurity({
domain_id: props.domainId,
type: 'update',
status: value ? 1 : 0,
})
apiMessage.value = true
await fetch()
if (data.value?.status) {
updateLock.value = value
const message = useMessage()
message.success(value ? '禁止更新锁已开启' : '禁止更新锁已关闭')
props.onRefresh?.()
}
} catch (error) {
handleError(error)
} finally {
updateLockLoading.value = false
}
}
// 管理DNSSEC
const handleManageDnssec = () => {
useModal({
title: 'DNSSEC管理',
area: '900px',
component: DnssecManagement,
componentProps: {
domainId: props.domainId,
domainName: props.domainInfo?.full_domain || '',
visible: true,
onClose: () => {},
},
footer: false,
})
}
// 打开域名隐私保护弹窗
const openCnDomainPrivacyModal = () => {
if (props.domainInfo) {
openPrivacyDialog.value = useModal({
title: '.CN/.中国专属域名隐私保护',
area: '600px',
component: () => import('./PrivacyProtection'),
componentProps: {
domain: props.domainInfo,
privacy: props.privacyInfo,
refresh: props.onRefresh,
onClose: () => {
openPrivacyDialog.value?.close()
},
},
footer: false,
})
}
}
// 渲染信息项
const renderInfoItem = (label: string, value: any, valueType: 'text' | 'switch' = 'text', config?: any) => {
if (props.loading) {
return (
<div class="mb-2 flex items-center justify-between h-10">
<div class="text-gray-500 font-bold w-25 mr-5 ml-1">{label}</div>
<div class="w-8 h-4 bg-gray-200 rounded animate-pulse"></div>
</div>
)
}
if (valueType === 'switch') {
return (
<div class="mb-2 flex items-center justify-between h-10 hover:bg-gray-100">
<div class="text-gray-500 font-bold w-25 mr-5 ml-1">{label}</div>
<NSwitch
value={value}
onUpdateValue={config?.onChange}
loading={config?.loading}
disabled={props.loading}
/>
</div>
)
}
return (
<div class="mb-2 flex items-center justify-between h-10 hover:bg-gray-100">
<div class="text-gray-500 font-bold w-25 mr-5 ml-1">{label}</div>
<div>{value || '-'}</div>
</div>
)
}
return () => (
<div class="py-2">
<NGrid cols="1 m:2" xGap="16" yGap="16" responsive="screen">
{/* 域名锁定 */}
<NGridItem>
<NCard title="域名锁定" header-style="font-size:16px;font-weight:500" class="h-full">
{renderInfoItem('禁止转移锁', transferLock.value, 'switch', {
onChange: handleTransferLockChange,
loading: transferLockLoading.value,
})}
<div class="pl-2 mb-4 text-sm text-gray-600"></div>
{renderInfoItem('禁止更新锁', updateLock.value, 'switch', {
onChange: handleUpdateLockChange,
loading: updateLockLoading.value,
})}
<div class="pl-2 text-sm text-gray-600"></div>
</NCard>
</NGridItem>
{/* 安全服务 */}
<NGridItem>
<NCard title="安全服务" header-style="font-size:16px;font-weight:500" class="h-full">
<div class="mb-2 flex items-center justify-between h-10 hover:bg-gray-100">
<div class="text-gray-500 font-bold w-25 mr-5 ml-1">DNSSEC</div>
<NButton type="primary" size="small" onClick={handleManageDnssec} disabled={props.loading}>
DNSSEC
</NButton>
</div>
<div class="pl-2 mb-4 text-sm text-gray-600">DNSDNS</div>
{/* 隐私保护 - 仅对.cn和.中国域名显示 */}
{(props.domainInfo?.suffix === 'cn' || props.domainInfo?.suffix === '中国') && (
<>
<div class="mb-2 flex items-center justify-between h-10 hover:bg-gray-100">
<div class="text-gray-500 font-bold w-40 mr-5 ml-1 whitespace-nowrap">
.CN/.
</div>
<div class="flex items-center gap-2">
<NTag type={isPrivacy.value ? 'success' : 'warning'} bordered={false}>
{isPrivacy.value
? `已开启(到期:${formatDate(props.privacyInfo?.end_time, 'yyyy-MM-dd')})`
: '未开启'}
</NTag>
<NButton type="primary" size="small" onClick={openCnDomainPrivacyModal} disabled={props.loading}>
{isPrivacy.value ? '延续隐私保护' : '开启保护'}
</NButton>
</div>
</div>
<div class="pl-2 mb-4 text-sm text-gray-600"></div>
</>
)}
</NCard>
</NGridItem>
</NGrid>
</div>
)
},
})

View File

@ -21,12 +21,12 @@ export default defineComponent({
const router = useRouter()
const BaseInfo = defineAsyncComponent(() => import('./components/BaseInfo'))
const RealName = defineAsyncComponent(() => import('./components/RealName'))
const DnsAnalysis = defineAsyncComponent(() => import('./components/DnsAnalysis/index'))
const Security = defineAsyncComponent(() => import('./components/security'))
// 获取域名ID从路由参数中获取
const domainId = route.params.id as string
// 获取控制器
const { loading, domainInfo, activeTab, refreshDomainInfo, switchTab } = useController(domainId)
const { loading, domainInfo,privacyInfo, activeTab, refreshDomainInfo, switchTab } = useController(domainId)
return () => (
<div class="domain-detail-container">
@ -57,13 +57,24 @@ export default defineComponent({
class="mb-4"
>
<NTabPane name="base" tab="基本信息">
<BaseInfo domainInfo={domainInfo.value} loading={loading.value} onRefresh={refreshDomainInfo} />
<BaseInfo
domainInfo={domainInfo.value}
privacyInfo={privacyInfo.value}
loading={loading.value}
onRefresh={refreshDomainInfo}
/>
</NTabPane>
<NTabPane name="realName" tab="实名认证">
<RealName domainId={Number(domainId)} />
</NTabPane>
<NTabPane name="analysis" tab="域名解析">
<DnsAnalysis domainId={Number(domainId)} />
<NTabPane name="security" tab="域名安全">
<Security
domainId={Number(domainId)}
domainInfo={domainInfo.value}
privacyInfo={privacyInfo.value}
loading={loading.value}
onRefresh={refreshDomainInfo}
/>
</NTabPane>
</NTabs>
</NCard>

View File

@ -12,7 +12,7 @@ import type {
/**
*
*/
export type DomainDetailTabKey = "base" | "realName" | "analysis" | "logs";
export type DomainDetailTabKey = 'base' | 'realName' | 'security' | 'analysis' | 'logs'
/**
* DNS

View File

@ -5,57 +5,99 @@
import { ref, onMounted } from "vue";
import { useRoute } from "vue-router";
// import { useMessage } from "naive-ui";
// import { useDialog } from "@baota/naive-ui/hooks";
import { useModal } from "@baota/naive-ui/hooks";
import { useDomainDetailState } from "./useStore";
import { domainUtils } from "./config";
import type { DomainDetailTabKey } from "./types.d";
// 动态导入组件以避免循环依赖问题
// import RealNameTemplateChangeDialog from "./components/RealNameTemplateChangeDialog";
/**
*
* @param domainId ID
*/
export function useController(domainId: string | number) {
// 获取状态管理
const { loading, domainInfo, fetchDomainInfo, realNameInfo } =
useDomainDetailState();
const route = useRoute();
// 获取状态管理
const {
loading,
domainInfo,
privacyInfo,
fetchDomainInfo,
realNameInfo,
realNameInfoUpdating,
openTemplateChangeDialog,
openPrivacyDialog,
fetchRealNameTemplateList,
} = useDomainDetailState()
const route = useRoute()
// 当前激活的标签页
const activeTab = ref<DomainDetailTabKey>("base");
// 当前激活的标签页
const activeTab = ref<DomainDetailTabKey>('base')
/**
*
*/
const refreshDomainInfo = () => {
fetchDomainInfo(domainId);
};
/**
*
*
*/
const refreshDomainInfo = async () => {
try {
await fetchDomainInfo(domainId)
} catch (error) {
// 错误已在store中处理
}
}
/**
*
* @param tab
*/
const switchTab = (tab: DomainDetailTabKey) => {
activeTab.value = tab;
};
/**
*
* @param tab
*/
const switchTab = (tab: DomainDetailTabKey) => {
activeTab.value = tab
}
// 组件挂载时获取域名详情
onMounted(() => {
if (route.query.tabs)
activeTab.value = route.query.tabs as DomainDetailTabKey;
fetchDomainInfo(domainId);
});
/**
*
*/
const openTemplateChangeModal = async () => {
// 先加载实名模板列表
await fetchRealNameTemplateList()
return {
// 状态
loading,
domainInfo,
realNameInfo,
activeTab,
switchTab,
// 方法
refreshDomainInfo,
// 工具
domainUtils,
};
// 遵循 real-name 模式,将 useModal 结果赋值给 store 中的 ref
openTemplateChangeDialog.value = useModal({
title: '更换实名模板',
area: '650px',
component: () => import('./components/RealNameTemplateChangeDialog'),
componentProps: {
domainId: Number(domainId),
domainInfo: domainInfo.value,
currentTemplate: realNameInfo.value,
refresh: async () => {
// 刷新域名信息
await fetchDomainInfo(domainId)
},
},
footer: false,
})
}
// 组件挂载时获取域名详情
onMounted(() => {
if (route.query.tabs) activeTab.value = route.query.tabs as DomainDetailTabKey
fetchDomainInfo(domainId)
})
return {
// 状态
loading,
domainInfo,
privacyInfo,
realNameInfo,
realNameInfoUpdating,
activeTab,
switchTab,
// 方法
refreshDomainInfo,
openTemplateChangeModal,
// 工具
domainUtils,
}
}

View File

@ -4,9 +4,11 @@
*/
import { fetchDomainDetail } from "@/api/domain";
import { fetchContactUserDetail } from "@/api/real-name";
import { useError } from "@baota/hooks/error";
import type { DomainInfo, RealNameInfo } from "@/types/domain";
import type { DomainInfo, RealNameInfo, PrivacyInfo } from '@/types/domain'
import type { ContactTemplateItem } from "@/types/real-name";
const { handleError } = useError();
@ -14,49 +16,93 @@ const { handleError } = useError();
* Store
*/
export const useDomainDetailStore = defineStore("domain-detail-store", () => {
// -------------------- 状态定义 --------------------
// -------------------- 状态定义 --------------------
/** 页面加载状态 */
const loading = ref(false);
/** 页面加载状态 */
const loading = ref(false)
/** 域名详情信息 */
const domainInfo = ref<DomainInfo | null>(null);
/** 域名详情信息 */
const domainInfo = ref<DomainInfo | null>(null)
/** 实名认证信息 */
const realNameInfo = ref<RealNameInfo | null>(null);
/** 实名认证信息 */
const realNameInfo = ref<RealNameInfo | null>(null)
// -------------------- 方法定义 --------------------
/** 实名信息更新状态 */
const realNameInfoUpdating = ref<RealNameInfo | null>(null)
/**
*
* @param domainId ID
*/
const fetchDomainInfo = async (domainId: number | string) => {
try {
const { fetch, data } = fetchDomainDetail({
domain_id: Number(domainId),
});
await fetch();
const { status, data: rdata } = data.value;
if (status) {
domainInfo.value = rdata.domain_info;
realNameInfo.value = rdata.real_name_info;
}
} catch (error) {
handleError(error);
return null;
}
};
/** 实名模板更换弹窗控制 */
const openTemplateChangeDialog = ref()
return {
// 状态
loading,
domainInfo,
realNameInfo,
/** 实名模板相关状态 */
const realNameTemplates = ref<ContactTemplateItem[]>([])
const realNameTemplatesLoading = ref(false)
// 方法
fetchDomainInfo,
};
/** 隐私保护弹窗控制 */
const openPrivacyDialog = ref()
/** 隐私保护信息 */
const privacyInfo = ref<PrivacyInfo | null>(null)
// -------------------- 方法定义 --------------------
/**
*
* @param domainId ID
*/
const fetchDomainInfo = async (domainId: number | string) => {
try {
const { fetch, data } = fetchDomainDetail({
domain_id: Number(domainId),
})
await fetch()
const { status, data: rdata } = data.value
if (status) {
domainInfo.value = rdata.domain_info
realNameInfo.value = rdata.real_name_info
realNameInfoUpdating.value = rdata.real_name_update_info
privacyInfo.value = rdata.privacy_info
}
} catch (error) {
handleError(error)
return null
}
}
/**
*
*/
const fetchRealNameTemplateList = async () => {
try {
realNameTemplatesLoading.value = true
const { fetch, data } = fetchContactUserDetail({ p: 1, rows: 50, status: 2 })
await fetch()
const payload = data.value as any
const list = (payload?.msg?.data || payload?.data?.data || []) as ContactTemplateItem[]
realNameTemplates.value = Array.isArray(list) ? list : []
} catch (error) {
handleError(error)
realNameTemplates.value = []
} finally {
realNameTemplatesLoading.value = false
}
}
return {
// 状态
loading,
domainInfo,
privacyInfo,
realNameInfo,
realNameInfoUpdating,
openTemplateChangeDialog,
realNameTemplates,
realNameTemplatesLoading,
openPrivacyDialog,
// 方法
fetchDomainInfo,
fetchRealNameTemplateList,
}
});
export const useDomainDetailState = () => {

View File

@ -0,0 +1,416 @@
import { defineComponent, ref, type PropType, reactive, computed, watch } from 'vue'
import {
NForm,
NFormItem,
NInput,
NSelect,
NButton,
NInputNumber,
NSpace,
NCascader,
useMessage,
type FormInst,
type FormRules,
type SelectOption,
NFlex,
NIcon,
} from 'naive-ui'
import { DnsRecordForm } from '../../types'
import { useModalHooks } from '@baota/naive-ui/hooks'
import { useDnsAnalysisStore } from './useStore'
import { useApp } from '@/components/layout/useStore'
// 获取Store
const {
createRecord,
updateRecord,
} = useDnsAnalysisStore();
/**
*
*/
export default defineComponent({
name: 'DnsAnalysisForm',
props: {
/** 是否显示表单 */
visible: {
type: Boolean,
default: false,
},
/** 表单模式add-新增, edit-编辑 */
mode: {
type: String as PropType<'add' | 'edit'>,
default: 'add',
},
/** 初始表单数据 */
initialData: {
type: Object as () => Partial<DnsRecordForm & { domain_id: number }>,
default: () => ({}),
},
recordTypesOptions: {
type: Array as PropType<SelectOption[]>,
default: () => [],
},
viewsOptions: {
type: Array as PropType<SelectOption[]>,
default: () => [],
},
/** 刷新数据函数 */
refresh: {
type: Function as PropType<() => Promise<void>>,
default: () => Promise.resolve(),
},
},
setup(props) {
// const { handleFormSubmit } = useController(props)
// 表单引用和消息实例
const message = useMessage()
const { close } = useModalHooks()
const { isMobile } = useApp()
const formRef = ref<FormInst | null>(null)
const handleCloseModal = close() // 构建关闭方式
// 表单数据
const formData = reactive<DnsRecordForm & { domain_id: number }>({
// 基础信息
domain_id: 0,
record_id: '',
record: '',
type: '',
value: '',
mx: 0,
ttl: 0,
remark: '',
viewId: 0,
...props.initialData,
})
// 当前选中的字段,用于切换下方帮助信息
const activeField = ref<string>('record')
// 表单验证规则
const rules: FormRules = {
record: { required: true, message: '请输入主机记录' },
value: { required: true, message: '请输入记录值' },
mx: { required: true, message: '请输入MX值' },
ttl: { required: true, message: '请输入TTL值' },
remark: { required: true, message: '请输入备注' },
}
/**
*
*/
const handleSubmit = async () => {
try {
await formRef.value?.validate()
const submitData = { ...formData }
if (props.mode === 'add') {
await createRecord(submitData)
handleCloseModal()
props.refresh()
} else {
await updateRecord({
record_id: submitData.record_id as string,
...submitData,
})
handleCloseModal()
props.refresh()
}
} catch (error) {
console.error('表单验证失败:', error)
message.error('请检查表单填写是否正确')
}
}
// -------- 快填面板(移动端)逻辑 --------
// const isMobileDevice = computed(() => typeof window !== 'undefined' && window.innerWidth <= 740)
// // 由可用帮助项决定步骤(不包含备注)
// const baseStepFields: Array<keyof DnsRecordForm> = ['record', 'type', 'value', 'ttl']
// const steps = computed(() => {
// // 仅保留在帮助集中定义的字段
// const allowed = baseStepFields.filter((f) => !!getFieldHelpInfo(String(f), formData.type || 'A'))
// // MX 类型时插入 mx
// if ((formData.type || 'A') === 'MX') {
// allowed.push('mx')
// }
// return allowed
// })
// const currentStepIndex = ref<number>(0)
// const currentStepField = computed(() => steps.value[currentStepIndex.value])
// // 面板显隐
// const showQuickFill = ref(true)
// // 当前步骤选中的建议索引
// const selectedSuggestionIndex = ref<number | null>(null)
// // 记录值步骤的提示文本
// const valueStepHint = ref<string>('')
// // 进入"记录值"步骤时自动生成提示
// watch(currentStepField, (field) => {
// if (field === 'value') {
// valueStepHint.value = getValueStepHint(formData.type || 'A')
// } else {
// valueStepHint.value = ''
// }
// })
// const goPrev = () => {
// currentStepIndex.value = Math.max(0, currentStepIndex.value - 1)
// activeField.value = (currentStepField.value as string) || 'record'
// selectedSuggestionIndex.value = null
// valueStepHint.value = ''
// }
// const goNext = () => {
// currentStepIndex.value = Math.min(steps.value.length - 1, currentStepIndex.value + 1)
// activeField.value = (currentStepField.value as string) || 'record'
// selectedSuggestionIndex.value = null
// valueStepHint.value = ''
// }
// const ensureValueThenNext = () => {
// const field = currentStepField.value
// if (!field) return
// const val = (formData as any)[field]
// if (field === 'mx' || field === 'ttl') {
// if (val === null || val === undefined) return
// } else if (!val) {
// return
// }
// goNext()
// }
// // 生成"记录值"步骤的类型提示
// const getValueStepHint = (type: string): string => {
// switch (String(type || 'A').toUpperCase()) {
// case 'MX':
// return '您选择的是 MX 记录,请在此填写邮件服务器的域名或 IP 地址,一般由邮件注册商提供,域名结尾"."表示根域,系统默认自动添加'
// case 'A':
// return '当前为 A 记录,请填写域名指向一个 IPv4 地址,例如 8.8.8.8'
// case 'AAAA':
// return '当前为 AAAA 记录,请填写域名指向一个 IPv6 地址,如 ff06:0:0:0:0:0:0:c3'
// case 'CNAME':
// return '当前为 CNAME 记录,请域名指向的另一个域名地址,不可填写 IP 地址'
// case 'TXT':
// return '当前为 TXT 记录,请填写文本内容,常用于验证/配置'
// case 'NS':
// return '当前为 NS 记录,请填写权威 DNS 服务器域名'
// default:
// return '请根据记录类型填写对应格式的记录值'
// }
// }
// // 建议项生成
// type Suggestion = { label: string; value: any; desc?: string }
// const getTypeSuggestions = (): Suggestion[] => {
// const opts = (props.recordTypesOptions || []) as any[]
// return opts.map((o) => ({ label: String(o.label ?? o.value), value: o.value, desc: o.desc }))
// }
// const getValueSuggestions = (): Suggestion[] => {
// const help = getFieldHelpInfo('value', formData.type || 'A')
// return (help.examples || []).map((e: any) => ({ label: e.value, value: e.value, desc: e.desc }))
// }
// const getRecordSuggestions = (): Suggestion[] => {
// const help = getFieldHelpInfo('record', formData.type || 'A')
// return (help.examples || []).map((e: any) => ({ label: e.value, value: e.value, desc: e.desc }))
// }
// const getTtlSuggestions = (): Suggestion[] => [300, 600, 1200].map((v) => ({ label: String(v), value: v }))
// const getMxSuggestions = (): Suggestion[] => [5, 10, 20].map((v) => ({ label: String(v), value: v }))
// const getRemarkSuggestions = (): Suggestion[] => []
// const suggestions = computed<Suggestion[]>(() => {
// switch (currentStepField.value) {
// case 'record':
// return getRecordSuggestions()
// case 'type':
// return getTypeSuggestions()
// case 'value':
// return getValueSuggestions()
// case 'ttl':
// return getTtlSuggestions()
// case 'mx':
// return getMxSuggestions()
// case 'remark':
// return getRemarkSuggestions()
// default:
// return []
// }
// })
// const applySuggestion = (s: Suggestion, idx?: number) => {
// const field = currentStepField.value
// if (!field) return
// // 切换选中态(瞬时视觉反馈)
// if (typeof idx === 'number') {
// selectedSuggestionIndex.value = idx
// }
// // 在"记录值"步骤,不直接填充,仅展示类型提示
// if (field === 'value') {
// valueStepHint.value = getValueStepHint(formData.type || 'A')
// return
// }
// ;(formData as any)[field] = s.value
// // 立即进入下一步
// goNext()
// }
// const stepTitleMap: Record<string, string> = {
// record: '主机记录',
// type: '记录类型',
// value: '记录值',
// viewId: '线路类型',
// ttl: 'TTL',
// mx: 'MX/权重',
// remark: '备注',
// }
return () => (
<div class="flex flex-col gap-3">
{/* 上层:表单 */}
<NForm
ref={formRef}
model={formData}
rules={rules}
labelPlacement={isMobile.value ? 'top' : 'left'}
labelWidth="120px"
requireMarkPlacement="right-hanging"
>
{/* 两列布局表单 */}
<NFormItem label="主机记录">
<NInput
value={formData.record}
onFocus={() => (activeField.value = 'record')}
onUpdateValue={(v: string) => (formData.record = v)}
placeholder="@ / www / *"
/>
</NFormItem>
<NFormItem label="记录类型">
<NSelect
value={formData.type}
options={props.recordTypesOptions as any}
onFocus={() => (activeField.value = 'type')}
onUpdateValue={(v: any) => (formData.type = v)}
/>
</NFormItem>
<NFormItem label="线路类型">
<NCascader
value={formData.viewId}
options={props.viewsOptions as any}
onFocus={() => (activeField.value = 'viewId')}
onUpdateValue={(v: any) => (formData.viewId = v)}
/>
</NFormItem>
<NFormItem label="记录值">
<NInput
value={formData.value}
onFocus={() => (activeField.value = 'value')}
onUpdateValue={(v: string) => (formData.value = v)}
placeholder="目标IP或域名"
/>
</NFormItem>
<NSpace>
<NFormItem label="TTL">
<NInputNumber
value={formData.ttl}
min={1}
onFocus={() => (activeField.value = 'ttl')}
onUpdateValue={(v: any) => (formData.ttl = Number(v) || 1)}
/>
</NFormItem>
<NFormItem label="MX(可选)">
<NInputNumber
value={formData.mx}
min={0}
max={100}
onFocus={() => (activeField.value = 'mx')}
onUpdateValue={(v: any) => (formData.mx = Number(v) || 0)}
/>
</NFormItem>
</NSpace>
<NFormItem label="备注(可选)">
<NInput value={formData.remark} onUpdateValue={(v: string) => (formData.remark = v)} />
</NFormItem>
{/* 表单操作按钮 */}
<NFormItem>
<NFlex class="mt-4 w-full" justify="end">
<NButton onClick={handleCloseModal}></NButton>
<NButton type="primary" onClick={handleSubmit}>
</NButton>
</NFlex>
</NFormItem>
</NForm>
{/* 移动端快填面板 */}
{/*isMobileDevice.value && showQuickFill.value && (
<div class="fixed left-0 right-0 bottom-0 z-50 bg-white border-t border-gray-200 p-3 shadow-[0_-4px_12px_rgba(0,0,0,0.06)]">
<div class="flex items-center justify-between mb-2">
<div class="text-[12px] text-gray-500">
{currentStepIndex.value + 1}/{steps.value.length}
</div>
<div class="flex items-center gap-2">
<NButton size="small" tertiary onClick={goPrev} disabled={currentStepIndex.value === 0}>
</NButton>
<NButton
size="small"
tertiary
onClick={goNext}
disabled={currentStepIndex.value >= steps.value.length - 1}
>
</NButton>
<NButton size="small" quaternary onClick={() => (showQuickFill.value = false)} aria-label="关闭快填">
<NIcon size={16}>
<CloseOutline />
</NIcon>
</NButton>
</div>
</div>
<div class="text-[13px] font-medium text-slate-800 mb-2">
{stepTitleMap[currentStepField.value as string] || '当前字段'}
</div>
<div class="text-[12px] text-gray-600 mb-2 line-clamp-2">
{(getFieldHelpInfo(String(currentStepField.value || ''), formData.type || 'A') as any)?.desc ||
(getFieldHelpInfo(String(currentStepField.value || ''), formData.type || 'A') as any)?.description}
</div>
{currentStepField.value === 'value' && valueStepHint.value ? (
<div class="text-[12px] text-gray-600 mt-2">
<span class="px-3 py-2 bg-red-50 border-l-4 border-red-500 rounded text-[11px] text-slate-800 block">
<span class="font-medium"></span>
{valueStepHint.value}
</span>
</div>
) : (
suggestions.value.length > 0 && (
<div class="max-h-[160px] overflow-y-auto flex flex-col gap-1 mb-2 pr-1">
{suggestions.value.map((s, i) => (
<div key={i}>
<NButton
size="small"
quaternary
onClick={() => applySuggestion(s, i)}
class={
(selectedSuggestionIndex.value === i ? 'bg-blue-50 border border-blue-200 ' : '') +
'!text-[12px] !px-2 !py-1 bg-gray-50 hover:bg-gray-100 w-full justify-start'
}
>
<span>{s.label}</span>
{s.desc && <span class="ml-1 text-[11px] text-gray-500">- {s.desc}</span>}
</NButton>
</div>
))}
</div>
)
)}
<NFlex justify="end" class="mt-2">
<NButton size="small" onClick={ensureValueThenNext} type="primary">
{currentStepIndex.value >= steps.value.length - 1 ? '保存' : '填入并下一步'}
</NButton>
</NFlex>
</div>
)*/}
</div>
)
},
})

View File

@ -0,0 +1,336 @@
/**
* @fileoverview DNS -
* @description UI
*/
import { ref, reactive, computed, watch, onMounted } from "vue";
import { useDnsAnalysisStore } from "./useStore";
import type { DnsRecordItem } from "@/types/domain";
import type {
// GetDnsRecordListRequest,
UpdateDnsRecordRequest,
DeleteDnsRecordRequest,
CreateDnsRecordRequest,
ToggleDnsRecordRequest,
} from "@/types/dns";
// import type { SelectOption } from "naive-ui";
import { DnsRecordForm } from "../../types";
import { useModal, useMessage, useForm, useFormHooks } from '@baota/naive-ui/hooks'
import DnsAnalysisForm from './form'
import { NButton, NSpace } from 'naive-ui'
import { useApp } from '@/components/layout/useStore'
/**
* DNS
*/
interface DnsAnalysisControllerProps {
/** 域名ID */
domainId: number;
/** 域名类型1=宝塔内部域名2=外部域名 */
domainType?: number;
}
// 获取移动端状态
const { isMobile } = useApp()
/**
* DNS
* @param domainId ID
* @param domainType 1=2=
* @returns
*/
export function useDnsAnalysisController(domainId: number, domainType: number = 1) {
// 获取Store
const {
isLoading,
dnsRecords,
hasRecords,
pagination,
recordCount,
viewsOptions,
recordListParams,
searchKeyOptions,
recordTypesOptions,
fetchRecords,
fetchViews,
fetchRecordTypes,
createRecord,
updateRecord,
deleteRecord,
pauseRecord,
startRecord,
setCurrentDomainId,
setCurrentDomainType,
} = useDnsAnalysisStore();
// 本地UI状态
const editingRecord = ref<DnsRecordItem | null>();
const isEditing = ref<string>("");
const isAdding = ref<boolean>(false);
// dns解析弹窗
const dnsAnalysisDialog = ref<boolean>(false);
// 新记录表单
const newRecord = reactive<DnsRecordForm>({
record_id: "",
record: "",
type: "A",
value: "",
mx: 1,
ttl: 600,
remark: "",
viewId: 0,
});
/**
*
*/
const cancelAdding = (): void => {
isAdding.value = false;
};
/**
* DNS
*/
const handleCreateRecord = async (): Promise<boolean> => {
const params: CreateDnsRecordRequest = {
domain_id: domainId,
record: newRecord.record,
type: newRecord.type,
value: newRecord.value,
ttl: newRecord.ttl,
mx: newRecord.mx,
remark: newRecord.remark,
viewId: newRecord.viewId,
};
try {
await createRecord(params);
isAdding.value = false;
return true;
} catch (error) {
return false;
}
};
/**
* DNS
*/
const handleUpdateRecord = async (): Promise<boolean> => {
const params: UpdateDnsRecordRequest = {
domain_id: domainId,
record_id: newRecord.record_id || 0,
record: newRecord.record,
type: newRecord.type,
value: newRecord.value,
ttl: newRecord.ttl,
mx: newRecord.mx,
remark: newRecord.remark,
viewId: newRecord.viewId,
};
try {
await updateRecord(params);
isEditing.value = "";
return true;
} catch (error) {
return false;
}
};
/**
* DNS
* @param recordId ID
*/
const handlDeleteRecord = async (recordId: number | string) => {
const params: DeleteDnsRecordRequest = {
record_id: recordId,
domain_id: domainId,
};
await deleteRecord(params);
};
/**
* DNS
* @param recordId ID
*/
const handlPauseRecord = async (recordId: number | string) => {
const params: ToggleDnsRecordRequest = {
record_id: recordId,
domain_id: domainId,
};
await pauseRecord(params);
};
/**
* DNS
* @param recordId ID
*/
const handlStartRecord = async (recordId: number | string) => {
const params: ToggleDnsRecordRequest = {
record_id: recordId,
domain_id: domainId,
};
await startRecord(params);
};
/**
* /
* @param record
*/
const toggleRecordStatus = async (record: DnsRecordItem) => {
// 根据当前状态决定是启用还是暂停
// state: 0-启用, 1-暂停
if (record.state === 0) {
await handlPauseRecord(record.record_id);
} else {
await handlStartRecord(record.record_id);
}
};
/**
* dns/
*/
function openDnsAnalysisDialog(mode: 'add' | 'edit' = 'add', record?: DnsRecordItem) {
if (!isMobile.value) return
// 组装初始数据
const initialData: Partial<DnsRecordForm & { domain_id: number }> = mode === 'edit' && record
? {
record_id: String(record.record_id || 0),
record: record.record,
type: record.type as any,
value: record.value,
ttl: Number((record as any).TTL ?? 600),
mx: Number((record as any).MX ?? 0),
remark: record.remark,
viewId: Number((record as any).viewID ?? 0),
}
: {
record_id: '',
record: '',
type: 'A',
value: '',
ttl: 600,
mx: 1,
remark: '',
viewId: 0,
}
initialData['domain_id'] = domainId
useModal({
title: mode === 'add' ? '新增DNS记录' : '编辑DNS记录',
area: ['100%','100%'],
component: DnsAnalysisForm,
componentProps: {
mode,
initialData,
recordTypesOptions: recordTypesOptions.value,
viewsOptions: viewsOptions.value,
refresh: fetchRecords,
},
footer: false,
})
};
// -------------------- 搜索表单useForm --------------------
const { useFormInput, useFormSelect } = useFormHooks()
const formConfig = () => [
useFormSelect(
'',
'searchKey',
searchKeyOptions.value,
{
placeholder: '请选择查询字段',
class: 'w-30',
disabled: isAdding.value || isEditing.value !== '',
},
{ showLabel: false, showFeedback: false },
),
useFormInput(
'',
'searchValue',
{
placeholder: '请输入查询的字段内容',
clearable: true,
disabled: isAdding.value || isEditing.value !== '',
},
{ showLabel: false, showFeedback: false },
),
{
type: 'custom' as const,
render: () => (
<NSpace>
<NButton type="primary" onClick={() => formFetchSearch()}>
</NButton>
</NSpace>
),
},
]
async function handleFormSearch(values: { searchKey?: string | number; searchValue?: string }) {
recordListParams.value.searchKey = values?.searchKey as any
recordListParams.value.searchValue = values?.searchValue || ''
recordListParams.value.p = 1
await fetchRecords()
}
const { component: FilterForm, fetch: formFetchSearch } = useForm({
config: formConfig(),
defaultValue: {
searchKey: recordListParams.value.searchKey,
searchValue: recordListParams.value.searchValue,
},
request: handleFormSearch,
})
// 组件挂载时获取记录
onMounted(async () => {
if (domainId) {
setCurrentDomainId(domainId);
setCurrentDomainType(domainType);
await fetchRecords(); // 先获取记录
await fetchRecordTypes(); // 再获取记录类型
await fetchViews(); // 最后获取线路
}
});
// 返回暴露的状态和方法
return {
// 从Store获取的状态
isLoading,
dnsRecords,
hasRecords,
pagination,
recordListParams,
searchKeyOptions,
recordCount,
viewsOptions,
recordTypesOptions,
isMobile,
// 本地UI状态
newRecord,
editingRecord,
isEditing,
isAdding,
// 方法
fetchRecords,
fetchViews,
fetchRecordTypes,
handleCreateRecord,
handleUpdateRecord,
handlDeleteRecord,
handlPauseRecord,
handlStartRecord,
toggleRecordStatus,
// startAdding,
cancelAdding,
openDnsAnalysisDialog,
// 表单
FilterForm,
formFetchSearch,
}
}

View File

@ -0,0 +1,342 @@
/**
* @fileoverview DNS -
* @description 使PiniaDNS
*/
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { storeToRefs } from 'pinia'
import {
getDnsRecordList,
createDnsRecord,
updateDnsRecord,
deleteDnsRecord,
getViews,
getRecordTypeList,
pauseDnsRecord,
startDnsRecord,
} from '@/api/dns'
import type {
GetDnsRecordListRequest,
UpdateDnsRecordRequest,
DeleteDnsRecordRequest,
CreateDnsRecordRequest,
ToggleDnsRecordRequest,
DnsView,
DnsRecordType,
} from '@/types/dns'
import type { DnsRecordItem } from '@/types/domain'
import { useError } from '@baota/hooks/error'
import { CascaderOption, PaginationProps, SelectOption } from 'naive-ui'
// 错误处理
const { handleError } = useError()
/**
* DNS
*/
export const useDnsAnalysisStare = defineStore('dnsAnalysis', () => {
// 状态定义
const dnsRecords = ref<DnsRecordItem[]>([]) /** 解析记录列表 */
const currentDomainType = ref<number>(1) /** 当前域名类型1=宝塔内部域名2=外部域名 */
const recordListParams = ref<GetDnsRecordListRequest>({
domain_id: 0,
domain_type: 1,
searchKey: 'record',
searchValue: '',
row: 10,
p: 1,
})
const searchKeyOptions = ref<SelectOption[]>([
{
label: '主机记录',
value: 'record',
},
{
label: '解析值',
value: 'value',
},
{
label: '记录类型',
value: 'type',
},
{
label: '备注',
value: 'remark',
},
])
const isLoading = ref<boolean>(false) /** 加载状态 */
const currentDomainId = ref<number>(0) // 当前域名ID
// Cascader组件选项类型
const viewsOptions = ref<CascaderOption[]>([])
const recordTypesOptions = ref<SelectOption[]>([])
// 分页配置
const pagination = ref<PaginationProps>({
page: 1,
itemCount: 0,
showSizePicker: true,
pageSizes: [10, 20, 50, 100],
prefix: ({ itemCount }) => `${itemCount}`,
})
// 计算属性
const hasRecords = computed(() => dnsRecords.value.length > 0)
const recordCount = computed(() => dnsRecords.value.length)
/**
* ID
* @param id ID
*/
const setCurrentDomainId = (id: number): void => {
currentDomainId.value = id
}
/**
*
* @param type 1=2=
*/
const setCurrentDomainType = (type: number): void => {
currentDomainType.value = type
recordListParams.value.domain_type = type
}
/**
* DNS
* @param params
*/
const fetchRecords = async (params?: GetDnsRecordListRequest) => {
isLoading.value = true
try {
const { fetch, data } = getDnsRecordList({
...recordListParams.value,
...(params || {}),
...{
domain_id: currentDomainId.value,
domain_type: currentDomainType.value,
},
})
await fetch()
const {
data: { data: recordList, count },
} = data.value
dnsRecords.value = recordList || []
pagination.value.itemCount = count
} catch (err) {
handleError(err)
dnsRecords.value = []
} finally {
isLoading.value = false
}
}
/**
* 线
* @description Cascader
*/
const fetchViews = async (): Promise<void> => {
isLoading.value = true
try {
const { fetch, data } = getViews()
await fetch()
const { data: viewList } = data.value
// 转换为Cascader组件所需的数据格式
const transformToCascaderOptions = (viewsOptions: DnsView[]): CascaderOption[] => {
return viewsOptions.map((view) => ({
label: view.name,
value: view.viewId,
disabled: !view.free,
children: view.children && view.children.length > 0 ? transformToCascaderOptions(view.children) : undefined,
}))
}
viewsOptions.value = transformToCascaderOptions(viewList) || []
} catch (err) {
handleError(err)
viewsOptions.value = []
} finally {
isLoading.value = false
}
}
/**
*
*/
const fetchRecordTypes = async (): Promise<void> => {
isLoading.value = true
try {
const { fetch, data } = getRecordTypeList()
await fetch()
const { data: typeList } = data.value
recordTypesOptions.value =
typeList.map((item: DnsRecordType) => ({
label: item.type,
value: item.type,
desc: item.desc,
})) || []
console.log(recordTypesOptions.value)
} catch (err) {
handleError(err)
recordTypesOptions.value = []
} finally {
isLoading.value = false
}
}
/**
* DNS
* @param params
* @returns
*/
const createRecord = async (params: CreateDnsRecordRequest) => {
isLoading.value = true
try {
const { fetch, data, message } = createDnsRecord({
...params,
domain_type: currentDomainType.value,
})
message.value = true
await fetch()
return data.value
} catch (err) {
handleError(err)
return false
} finally {
isLoading.value = false
}
}
/**
* DNS
* @param params
* @returns
*/
const updateRecord = async (params: UpdateDnsRecordRequest) => {
isLoading.value = true
try {
const { fetch, data, message } = updateDnsRecord({
...params,
domain_type: currentDomainType.value,
})
message.value = true
await fetch()
return data.value
} catch (err) {
handleError(err)
return false
} finally {
isLoading.value = false
}
}
/**
* DNS
* @param params
* @returns
*/
const deleteRecord = async (params: DeleteDnsRecordRequest) => {
isLoading.value = true
try {
const { fetch, data, message } = deleteDnsRecord({
...params,
domain_type: currentDomainType.value,
})
message.value = true
await fetch()
return data.value
} catch (err) {
handleError(err)
return false
} finally {
isLoading.value = false
}
}
/**
* DNS
* @param params
* @returns
*/
const pauseRecord = async (params: ToggleDnsRecordRequest) => {
isLoading.value = true
try {
const { fetch, data, message } = pauseDnsRecord({
...params,
domain_type: currentDomainType.value,
})
message.value = true
await fetch()
return data.value
} catch (err) {
handleError(err)
return false
} finally {
isLoading.value = false
}
}
/**
* DNS
* @param params
* @returns
*/
const startRecord = async (params: ToggleDnsRecordRequest) => {
isLoading.value = true
try {
const { fetch, data, message } = startDnsRecord({
...params,
domain_type: currentDomainType.value,
})
message.value = true
await fetch()
return data.value
} catch (err) {
handleError(err)
return false
} finally {
isLoading.value = false
}
}
// 返回暴露的状态和方法
return {
// 状态
dnsRecords,
isLoading,
pagination,
recordListParams,
searchKeyOptions,
viewsOptions,
recordTypesOptions,
currentDomainId,
// 计算属性
hasRecords,
recordCount,
// 操作方法
fetchRecords,
fetchViews,
fetchRecordTypes,
createRecord,
updateRecord,
deleteRecord,
pauseRecord,
startRecord,
setCurrentDomainId,
setCurrentDomainType,
}
})
/**
* DNSStore
* @returns Store
*/
export const useDnsAnalysisStore = () => {
const store = useDnsAnalysisStare()
return {
...store,
...storeToRefs(store),
}
}

View File

@ -0,0 +1,98 @@
/**
*
* DNS
*/
import { defineComponent, onMounted, ref, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { NCard, NButton, NIcon, NFlex, NText, NSpin } from 'naive-ui'
import { ArrowBackOutline, ReloadOutline } from '@vicons/ionicons5'
import { useMessage } from '@baota/naive-ui/hooks'
import { useError } from '@baota/hooks/error'
import { useDomainResolveState } from '../useStore'
// 引入DNS解析组件
import DnsAnalysisComponent from './DnsAnalysis/index'
/**
*
*/
export default defineComponent({
name: 'DomainResolveDetail',
setup() {
const route = useRoute()
const router = useRouter()
const message = useMessage()
const { handleError } = useError()
const { selectedDomainInfo, clearSelectedDomainInfo } = useDomainResolveState()
// 获取域名ID参数
const domainId = Number(route.params.id)
// 跨组件初始化流程
// 1. 判断 route.query.domain_type 是否存在有的话就按照传的来获取值没用就默认1
const domainType = computed(() => {
if (route.query.domain_type) {
return Number(route.query.domain_type)
}
// 如果是从内部跳转使用Store中的信息
if (selectedDomainInfo.value) {
return selectedDomainInfo.value.domain_type
}
return 1 // 默认值
})
// 2. 判断 route.query.domain_name 是否存在,有的话就获取值,没用的话默认空
const domainName = computed(() => {
if (route.query.domain_name) {
return route.query.domain_name as string
}
// 如果是从内部跳转使用Store中的信息
if (selectedDomainInfo.value) {
return selectedDomainInfo.value.name
}
return ''
})
// 返回到域名解析列表页面
const handleBack = () => {
// 清除Store中的选中域名信息
clearSelectedDomainInfo()
router.push('/domain-resolve')
}
return () => (
<div class="domain-resolve-detail-container">
{/* 返回导航区 */}
<div class="mb-4 flex items-center">
<NButton
circle
size="medium"
onClick={handleBack}
class="hover:bg-gray-100 transition-colors"
>
<NIcon size="16">
<ArrowBackOutline />
</NIcon>
</NButton>
<NFlex align="center" class="ml-4">
<NText class="text-lg font-semibold text-gray-900"></NText>
{domainName.value && (
<>
<NText class="text-gray-400">-</NText>
<NText class="text-sm text-gray-500">{domainName.value}</NText>
</>
)}
</NFlex>
</div>
{/* DNS解析记录内容区 */}
<NCard class="card-shadow" bordered={false}>
{domainId > 0 && (
<DnsAnalysisComponent domainId={domainId} domainType={domainType.value} />
)}
</NCard>
</div>
)
},
})

View File

@ -0,0 +1,126 @@
/**
*
*
*/
import { defineComponent, onMounted, ref } from 'vue'
import { NCard, NFlex, NButton, NDivider, NIcon } from 'naive-ui'
import { CloseOutline, SearchOutline } from '@vicons/ionicons5'
import { useRoute } from 'vue-router'
import { useController } from './useController'
/**
*
*/
export default defineComponent({
name: 'DomainResolve',
setup() {
const route = useRoute()
const { loading, isMobile, ResolveTable, ResolveTablePage, ResolveCardList, tableData, FilterForm, formFetchSearch, handleShowAddDomainModal, domainName } =
useController()
// 移动端搜索表单显示状态
const showSearchForm = ref(false)
// 判断是否为子路由(详情页)
const isDetailPage = () => route.matched.some(record => record.name === 'DomainResolveDetail')
// 切换搜索表单显示状态
const toggleSearchForm = () => {
showSearchForm.value = !showSearchForm.value
}
// 渲染筛选搜索区域
const renderFilterSection = () => (
<NCard class="card-shadow" bordered={false}>
{isMobile.value ? (
<NFlex vertical class="w-full" size="medium">
<NFlex justify="space-between" align="center">
<NButton type="primary" onClick={handleShowAddDomainModal}>
</NButton>
<NButton onClick={toggleSearchForm} class="search-toggle-btn">
{showSearchForm.value ? (
<>
<NIcon size="18">
<CloseOutline />
</NIcon>
<span></span>
</>
) : (
<>
<NIcon size="18">
<SearchOutline />
</NIcon>
<span></span>
</>
)}
</NButton>
</NFlex>
{showSearchForm.value && (
<>
<NDivider class="!my-2" dashed />
<div class="mobile-search-form">
<FilterForm inline={false} />
</div>
</>
)}
</NFlex>
) : (
<NFlex justify="space-between" align="center" class="w-full">
<NFlex class="w-full !flex-row !flex-wrap" size="medium">
<NButton type="primary" onClick={handleShowAddDomainModal}>
</NButton>
<FilterForm class="flex-1 justify-end" inline={true} />
</NFlex>
</NFlex>
)}
</NCard>
)
// 渲染解析记录列表
const renderResolveList = () => (
<>
{isMobile.value ? (
<NFlex vertical>
<ResolveCardList data={tableData.value?.list || []} loading={loading.value} class="mb-4" />
<NFlex justify="center">
<ResolveTablePage />
</NFlex>
</NFlex>
) : (
<NCard class="card-shadow" bordered={false}>
<ResolveTable loading={loading.value} class="mb-4" />
<NFlex justify="end">
<ResolveTablePage />
</NFlex>
</NCard>
)}
</>
)
onMounted(() => {
// 只在列表页面时执行数据加载
if (!isDetailPage()) {
formFetchSearch()
}
})
// 主渲染
return () => (
<div class="flex flex-col gap-[16px]">
{isDetailPage() ? (
// 显示子路由内容(详情页)
<router-view />
) : (
// 显示列表页内容
<>
{renderFilterSection()}
{renderResolveList()}
</>
)}
</div>
)
},
})

View File

@ -0,0 +1,134 @@
/**
*
*
*/
/**
* NTag使
*/
export type TagType = "default" | "success" | "warning" | "error" | "info";
/**
* NS
*/
export enum NsStatus {
/** 未设置 */
NotSet = 0,
/** 已生效 */
Active = 1,
/** 未生效 */
Pending = 2,
}
/**
*
*/
export enum DomainType {
/** 平台注册域名 */
Platform = 1,
/** 外部添加域名 */
External = 2,
}
/**
*
*/
export enum DomainSource {
/** 平台注册 */
Platform = "platform",
/** 外部添加 */
External = "external",
}
/**
*
*/
export interface ResolveItem {
/** 创建时间 */
created_at: string;
/** 51DNS中的域名ID0表示未在51DNS中创建 */
dns_id: number;
/** 域名类型1=平台注册域名2=外部添加域名 */
domain_type: number;
/** 完整域名 */
full_domain: string;
/** 最后DNS状态检测时间 */
last_check_time: string | null;
/** 本地数据库中的域名ID */
local_id: number;
/** NS状态0=未设置1=已生效2=未生效 */
ns_status: number;
/** DNS解析记录数量 */
record_count: number;
/** 备注信息 */
remark: string;
/** 域名来源platform=平台注册external=外部添加 */
source: string;
}
/**
*
*/
export interface ResolveListRequest {
/** 页码 */
p?: number;
/** 每页数量 */
rows?: number;
/** 搜索关键词 */
keyword?: string;
/** 状态筛选 */
status?: number | string;
}
/**
*
*/
export interface StatusOption {
/** 选项文本 */
label: string;
/** 选项值 */
value: number | string;
}
/**
*
*/
export interface ResolveStatusConfig {
/** 标签类型 */
type: TagType;
/** 显示文本 */
text: string;
/** 显示颜色 */
color: string;
}
/**
*
*/
export interface DomainAddForm {
/** 域名 */
domain: string;
/** 备注 */
remark: string;
}
/**
* DNS
*/
export interface DnsRecordForm {
record_id?: string | number;
/** 主机记录 */
record: string;
/** 记录类型 */
type: string;
/** 记录值 */
value: string;
/** MX值 */
mx: number;
/** TTL值 */
ttl: number;
/** 备注 */
remark: string;
/** 线路ID */
viewId: number;
}

View File

@ -0,0 +1,819 @@
/**
*
*
*/
import { ref, reactive, onMounted, watch, computed, defineComponent } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import {
NButton,
NTag,
NSpace,
NCard,
NFlex,
NAlert,
NText,
NIcon,
NForm,
NFormItem,
NInput,
useDialog,
} from 'naive-ui'
import { CheckmarkOutline } from '@vicons/ionicons5'
import { formatDate } from '@baota/utils/date'
import { useDomainResolveState } from './useStore'
import {
useTable,
useForm,
useFormHooks,
useModal,
useModalHooks,
useMessage,
useLoadingMask,
} from '@baota/naive-ui/hooks'
import { useApp } from '@/components/layout/useStore'
import { addExternalDomain, removeDomain } from '@/api/resolve'
import { useError } from '@baota/hooks/error'
const message = useMessage()
const { handleError } = useError()
import type { ResolveListRequest, ResolveItem, DomainAddForm } from './types.d'
import { TableColumns } from 'naive-ui/es/data-table/src/interface'
import type { FormInst, FormRules } from 'naive-ui'
// 定义标签类型
type TagType = 'default' | 'success' | 'warning' | 'error' | 'info'
/**
* DNS
*/
const createDnsSetupModal = (fetchResolve: () => Promise<void>) =>
defineComponent({
name: 'DnsSetupModal',
props: {
domain: {
type: String,
required: true,
},
domainId: {
type: Number,
required: true,
},
domainType: {
type: Number,
required: true,
},
lastCheckTime: {
type: String,
default: null,
},
},
setup(props) {
const { close } = useModalHooks()
const closeModal = close()
const message = useMessage()
const { checkDomainStatusApi } = useDomainResolveState()
const redetectLoading = ref(false)
// 计算是否在半小时内检测过
const isRedetectDisabled = computed(() => {
if (!props.lastCheckTime) {
return false // 如果没有检测时间,说明没有检测过,可以检测
}
try {
// 解析时间字符串,支持多种格式
let lastCheckTime: number
// 尝试直接解析
const directParse = new Date(props.lastCheckTime).getTime()
if (!isNaN(directParse)) {
lastCheckTime = directParse
} else {
// 如果直接解析失败,尝试手动解析 "2025-09-12 16:49:37" 格式
const timeStr = props.lastCheckTime.replace(/-/g, '/') // 将 - 替换为 / 以提高兼容性
lastCheckTime = new Date(timeStr).getTime()
}
const currentTime = new Date().getTime()
const halfHour = 30 * 60 * 1000 // 30分钟的毫秒数
// 检查时间是否有效
if (isNaN(lastCheckTime)) {
console.warn('Invalid lastCheckTime format:', props.lastCheckTime)
return false
}
const timeDiff = currentTime - lastCheckTime
console.log('Time calculation:', {
lastCheckTime: props.lastCheckTime,
lastCheckTimeMs: lastCheckTime,
lastCheckTimeDate: new Date(lastCheckTime).toLocaleString(),
currentTimeMs: currentTime,
currentTimeDate: new Date(currentTime).toLocaleString(),
timeDiffMs: timeDiff,
timeDiffMinutes: Math.round(timeDiff / (60 * 1000)),
halfHourMs: halfHour,
halfHourMinutes: 30,
isDisabled: timeDiff < halfHour,
})
return timeDiff < halfHour
} catch (error) {
console.error('Error parsing lastCheckTime:', error)
return false
}
})
// 计算剩余时间(分钟)
const remainingMinutes = computed(() => {
if (!props.lastCheckTime || !isRedetectDisabled.value) {
return 0
}
try {
const lastCheckTime = new Date(props.lastCheckTime).getTime()
const currentTime = new Date().getTime()
const halfHour = 30 * 60 * 1000
const timeDiff = currentTime - lastCheckTime
const remaining = halfHour - timeDiff
const minutes = Math.ceil(remaining / (60 * 1000)) // 转换为分钟并向上取整
console.log('Remaining time calculation:', {
remainingMs: remaining,
remainingMinutes: minutes,
})
return Math.max(0, minutes) // 确保不返回负数
} catch (error) {
console.error('Error calculating remaining time:', error)
return 0
}
})
const handleRedetect = async () => {
try {
redetectLoading.value = true
const result = await checkDomainStatusApi(props.domainId, props.domainType)
if (!result?.status) {
return message.error(result?.msg || '检测失败,请稍后重试')
}
await fetchResolve()
closeModal()
} catch (error) {
message.error(error as string)
} finally {
redetectLoading.value = false
}
}
return () => (
<div class="max-w-2xl">
<div class="space-y-6">
<NAlert type="warning" showIcon>
<div class="flex items-center gap-2">
<span class="font-medium"></span>
</div>
<div class="mt-2 text-sm">
<span class="font-bold">{props.domain}</span> DNS
</div>
</NAlert>
<NAlert type="info" showIcon>
<div class="font-medium mb-3">DNS</div>
<div class="text-sm space-y-2">
<div class="flex items-start gap-2">
<span class="text-blue-600 font-medium min-w-[20px]">1.</span>
<span></span>
</div>
<div class="flex items-start gap-2">
<span class="text-blue-600 font-medium min-w-[20px]">2.</span>
<span>DNS</span>
</div>
<div class="flex items-start gap-2">
<span class="text-blue-600 font-medium min-w-[20px]">3.</span>
<div class="flex-1">
<div class="mb-2"></div>
<div class="bg-gray-50 p-3 rounded-md font-mono text-sm space-y-1">
<div class="text-gray-600">ns1.baotadns.com</div>
<div class="text-gray-600">ns2.baotadns.com</div>
</div>
</div>
</div>
<div class="flex items-start gap-2">
<span class="text-blue-600 font-medium min-w-[20px]">4.</span>
<span>24-48</span>
</div>
<div class="flex items-start gap-2">
<span class="text-blue-600 font-medium min-w-[20px]">5.</span>
<span></span>
</div>
</div>
</NAlert>
<NAlert type="success" showIcon>
<div class="font-medium mb-2"></div>
<div class="flex items-start gap-2 text-sm">
<NIcon class="text-green-500 mt-0.5" size="16">
<CheckmarkOutline />
</NIcon>
<span>DNS"重新检测"</span>
</div>
</NAlert>
</div>
<div class="flex justify-end gap-3 mt-4 pt-4 border-t border-gray-100">
<NButton onClick={closeModal} disabled={redetectLoading.value}>
</NButton>
<NButton
type="primary"
loading={redetectLoading.value}
disabled={redetectLoading.value || isRedetectDisabled.value}
onClick={handleRedetect}
>
{redetectLoading.value
? '检测中...'
: isRedetectDisabled.value
? `重新检测(${remainingMinutes.value}分钟后可用)`
: '重新检测'}
</NButton>
</div>
</div>
)
},
})
/**
*
*/
export function useController() {
const router = useRouter()
const route = useRoute()
// 获取从URL传递的域名名称
const domainName = ref<string>((route.query.domain_name as string) || '')
const {
fetchResolveListData,
filterFormData,
statusOptions,
NS_STATUS_CONFIG,
NS_STATUS_MAP,
DOMAIN_TYPE_CONFIG,
DOMAIN_TYPE_MAP,
setSelectedDomainInfo,
} = useDomainResolveState()
const { useFormInput, useFormSelect } = useFormHooks()
const dialog = useDialog()
// 获取移动端状态
const { isMobile } = useApp()
// -------------------- 事件处理方法 --------------------
/**
*
*/
const addDomainFormConfig = [
useFormInput(
'域名',
'domain',
{
placeholder: '请输入域名example.com',
clearable: true,
},
{
required: true,
showFeedback: true,
labelWidth: '80px',
rule: {
required: true,
message: '请输入域名',
trigger: ['blur', 'input'],
},
},
),
useFormInput(
'备注',
'remark',
{
placeholder: '请输入备注信息(可选)',
clearable: true,
},
{
required: false,
showFeedback: false,
labelWidth: '80px',
},
),
]
/**
*
*/
async function handleAddDomainSubmit(data: DomainAddForm): Promise<void> {
const { open: openLoad, close: closeLoad } = useLoadingMask({ text: '正在添加域名,请稍后...' })
openLoad()
try {
const {
fetch,
message,
data: addDomainData,
} = addExternalDomain({
full_domain: data.domain.trim(),
remark: data.remark?.trim(),
})
message.value = true
await fetch()
if (!addDomainData.value?.status) {
useMessage().error(addDomainData.value?.msg || '添加域名失败')
return
}
await fetchResolve()
closeAddDomainModal()
} catch (error) {
handleError(error)
} finally {
closeLoad()
}
}
/**
*
*/
const handleResolve = (row: ResolveItem) => {
setSelectedDomainInfo({
id: row.local_id,
name: row.full_domain,
domain_type: row.domain_type,
})
router.push({
path: `/domain-resolve/detail/${row.local_id}`,
query: {
domain_name: row.full_domain,
domain_type: row.domain_type,
},
})
}
/**
*
*/
const handleDelete = (row: ResolveItem) => {
dialog.warning({
title: '确认删除',
content: `确定要删除域名"${row.full_domain}"吗?删除后无法恢复。`,
positiveText: '删除',
negativeText: '取消',
onPositiveClick: async () => {
const { open: openLoad, close: closeLoad } = useLoadingMask({ text: '正在删除域名,请稍后...' })
openLoad()
try {
const { fetch, message } = removeDomain({
domain_id: row.local_id,
})
message.value = true
await fetch()
await fetchResolve()
} catch (error) {
handleError(error)
} finally {
closeLoad()
}
},
})
}
/**
*
*/
const handleStatusClick = (row: ResolveItem) => {
if (row.ns_status === 2) {
// 未生效状态
handleShowDnsModal(row.full_domain?.trim(), row.local_id, row.domain_type, row.last_check_time || undefined)
}
}
/**
* DNS
*/
const handleShowDnsModal = (domain: string, domainId: number, domainType: number, lastCheckTime?: string) => {
const DnsSetupModalComponent = createDnsSetupModal(fetchResolve)
useModal({
title: 'DNS设置',
area: '600px',
component: DnsSetupModalComponent,
componentProps: {
domain,
domainId,
domainType,
lastCheckTime,
},
footer: false,
})
}
/**
*
*/
const handleShowAddDomainModal = () => {
addDomainModalRef.value = useModal({
title: '添加域名',
area: '500px',
component: AddDomainFormWrapper,
footer: false,
})
}
/**
*
*/
const formConfig = () => [
// useFormSelect(
// '',
// 'status',
// statusOptions.value,
// {
// placeholder: '全部状态',
// class: 'w-28',
// },
// { showLabel: false, showFeedback: false },
// ),
useFormInput(
'',
'keyword',
{
placeholder: '请输入域名',
clearable: true,
class: 'w-64',
},
{ showLabel: false, showFeedback: false },
),
{
type: 'custom' as const,
render: () => (
<NSpace>
<NButton type="primary" onClick={() => formFetchSearch()}>
</NButton>
</NSpace>
),
},
]
/**
*
*/
const createColumns = [
{
title: '域名',
key: 'full_domain',
width: 200,
ellipsis: { tooltip: true },
render: (row: ResolveItem) => (
<div class="flex items-center gap-2">
<span class="i-mdi-earth text-emerald-500" />
<div class="font-medium">{row.full_domain?.trim()}</div>
</div>
),
},
{
title: 'NS状态',
key: 'ns_status',
width: 100,
render: (row: ResolveItem) => {
const isPending = row.ns_status === 2
return isPending ? (
<NButton
text
type="warning"
size="small"
onClick={() =>
handleShowDnsModal(
row.full_domain?.trim(),
row.local_id,
row.domain_type,
row.last_check_time || undefined,
)
}
class="!p-0 !h-auto"
>
<NTag type="warning" bordered={false} size="small" class="cursor-pointer">
{getNsStatusText(row.ns_status)}
</NTag>
</NButton>
) : (
<NTag type={getNsStatusType(row.ns_status)} bordered={false} size="small">
{getNsStatusText(row.ns_status)}
</NTag>
)
},
},
{
title: '购买来源',
key: 'domain_type',
width: 120,
render: (row: ResolveItem) => {
const typeKey = DOMAIN_TYPE_MAP[row.domain_type] || 'platform'
const config = DOMAIN_TYPE_CONFIG[typeKey]
return <span class="text-gray-700">{config.text}</span>
},
},
{
title: '解析记录数',
key: 'record_count',
width: 120,
align: 'center',
render: (row: ResolveItem) => <span class="font-medium">{row.record_count}</span>,
},
{
title: '备注',
key: 'remark',
width: 150,
ellipsis: { tooltip: true },
render: (row: ResolveItem) => <span class="text-gray-600">{row.remark || '-'}</span>,
},
{
title: '创建时间',
key: 'created_at',
width: 150,
render: (row: ResolveItem) => {
const date = new Date(row.created_at)
return formatDate(date.getTime(), 'yyyy-MM-dd HH:mm')
},
},
{
title: '操作',
key: 'actions',
width: 150,
align: 'right',
fixed: 'right',
render: (row: ResolveItem) => (
<NSpace justify="end">
<NButton size="small" type="primary" ghost onClick={() => handleResolve(row)}>
</NButton>
{row.domain_type === 2 && (
<NButton size="small" type="error" ghost onClick={() => handleDelete(row)}>
</NButton>
)}
</NSpace>
),
},
] as TableColumns<ResolveItem>
/**
*
*/
const ResolveCardList = defineComponent({
name: 'ResolveCardList',
props: {
data: {
type: Array as PropType<ResolveItem[]>,
default: () => [],
},
loading: {
type: Boolean,
default: false,
},
},
setup(props) {
return () => (
<NFlex vertical size="medium">
{props.data.map((item: ResolveItem) => (
<NCard key={item.local_id} class="card-shadow" bordered={false}>
<NFlex vertical size="small">
<NFlex align="center" justify="space-between">
<NFlex align="center" size="small">
<div>
<div class="font-medium text-base">{item.full_domain?.trim()}</div>
<div class="text-sm text-gray-500">{item.remark || '-'}</div>
</div>
</NFlex>
<NFlex align="center" size="small">
{item.ns_status === 2 ? (
<NButton
text
type="warning"
size="small"
onClick={() =>
handleShowDnsModal(
item.full_domain?.trim(),
item.local_id,
item.domain_type,
item.last_check_time || undefined,
)
}
class="!p-0 !h-auto"
>
<NTag type="warning" bordered={false} size="small" class="cursor-pointer">
{getNsStatusText(item.ns_status)}
</NTag>
</NButton>
) : (
<NTag type={getNsStatusType(item.ns_status)} bordered={false} size="small">
{getNsStatusText(item.ns_status)}
</NTag>
)}
</NFlex>
</NFlex>
<NFlex justify="space-between" class="text-sm">
<span class="text-gray-500"></span>
<span class="font-medium text-blue-600">{item.record_count}</span>
</NFlex>
<NFlex justify="space-between" class="text-sm text-gray-600">
<div>
<span class="text-gray-500"></span>
{formatDate(new Date(item.created_at).getTime(), 'yyyy-MM-dd')}
</div>
<div>
<span class="text-gray-500"></span>
{DOMAIN_TYPE_CONFIG[DOMAIN_TYPE_MAP[item.domain_type] || 'platform']?.text}
</div>
</NFlex>
<NFlex justify="end" size="small">
<NButton size="small" type="primary" ghost onClick={() => handleResolve(item)}>
</NButton>
{item.domain_type === 2 && (
<NButton size="small" type="error" ghost onClick={() => handleDelete(item)}>
</NButton>
)}
</NFlex>
</NFlex>
</NCard>
))}
</NFlex>
)
},
})
// 表格实例
const {
TableComponent: ResolveTable,
PageComponent: ResolveTablePage,
loading,
param,
fetch: fetchResolve,
data: tableData,
} = useTable<ResolveItem, ResolveListRequest>({
config: createColumns,
request: fetchResolveListData,
defaultValue: filterFormData,
alias: {
page: 'p',
pageSize: 'rows',
},
watchValue: ['p', 'rows', 'status'],
})
const { component: FilterForm } = useForm<ResolveListRequest>({
config: formConfig(),
defaultValue: filterFormData,
})
const { component: AddDomainForm, fetch: submitAddDomain } = useForm<DomainAddForm>({
config: addDomainFormConfig,
defaultValue: { domain: '', remark: '' },
request: handleAddDomainSubmit,
})
const addDomainModalRef = ref<any>(null)
/**
*
*/
const closeAddDomainModal = () => {
if (addDomainModalRef.value) {
addDomainModalRef.value.close()
addDomainModalRef.value = null
}
}
/**
* DNS
*/
const AddDomainFormWrapper = defineComponent({
name: 'AddDomainFormWrapper',
setup() {
return () => (
<div class="max-w-2xl">
<AddDomainForm />
<NAlert type="info" showIcon class="mt-4">
<div class="font-medium mb-2">DNS</div>
<div class="text-sm mb-3">DNS</div>
<div class="bg-gray-50 p-3 rounded-md font-mono text-sm space-y-1">
<div class="text-gray-600">ns1.baotadns.com</div>
<div class="text-gray-600">ns2.baotadns.com</div>
</div>
<div class="mt-3 text-xs text-gray-500">
DNS24-48
</div>
</NAlert>
<div class="flex justify-end gap-3 mt-4 pt-4 border-t border-gray-100">
<NButton onClick={closeAddDomainModal}></NButton>
<NButton type="primary" onClick={() => submitAddDomain()}>
</NButton>
</div>
</div>
)
},
})
const formFetchSearch = async () => {
await fetchResolve()
}
// 监听路由从其他页面返回时刷新列表
watch(
() => route.path,
(newPath, oldPath) => {
// 当路由从详情页返回到列表页时,刷新数据
if (newPath === '/domain-resolve' && oldPath?.includes('/domain-resolve/detail/')) {
fetchResolve()
}
},
{ immediate: false },
)
// -------------------- 映射工具方法 --------------------
/**
* NS
*/
const getNsStatusText = (status: number | undefined): string => {
const statusKey = String(status ?? 0) as unknown as keyof typeof NS_STATUS_MAP
const key = NS_STATUS_MAP[statusKey] || 'notSet'
return NS_STATUS_CONFIG[key]?.text || '未知'
}
/**
* NS
*/
const getNsStatusType = (status: number | undefined): TagType => {
const statusKey = String(status ?? 0) as unknown as keyof typeof NS_STATUS_MAP
const key = NS_STATUS_MAP[statusKey] || 'notSet'
return NS_STATUS_CONFIG[key]?.type || 'default'
}
// -------------------- 事件处理 --------------------
/**
*
*/
function formDataReset() {
filterFormData.value = { keyword: '', status: '' }
param.value.keyword = ''
param.value.status = ''
param.value.p = 1
fetchResolve()
}
return {
// 状态
loading,
isMobile,
tableData,
domainName,
// 表格
ResolveTable,
ResolveTablePage,
// 移动端卡片
ResolveCardList,
// 表单
FilterForm,
formFetchSearch,
formDataReset,
// 事件处理
handleResolve,
handleDelete,
handleStatusClick,
// 模态框
handleShowAddDomainModal,
handleShowDnsModal,
// 工具方法
getNsStatusText,
getNsStatusType,
// 数据刷新
fetchResolve,
}
}

View File

@ -0,0 +1,209 @@
/**
*
*
*/
import { ref } from 'vue'
import { defineStore, storeToRefs } from 'pinia'
import { useMessage } from '@baota/naive-ui/hooks'
import { useError } from '@baota/hooks/error'
import { getResolveList, checkDomainStatus } from '@/api/resolve'
import type { TableResponse } from '@baota/naive-ui/types/table'
import type { ResolveItem, ResolveListRequest, StatusOption } from './types.d'
/** NS状态映射配置后端数值 -> 前端联合类型) */
const NS_STATUS_MAP: Record<number, "notSet" | "active" | "pending"> = {
0: "notSet", // 未设置
1: "active", // 已生效
2: "pending", // 未生效
} as const
/** NS状态显示配置 */
const NS_STATUS_CONFIG = {
notSet: {
type: "default" as const,
text: "未设置",
color: "#909399", // 灰色
},
active: {
type: "success" as const,
text: "已生效",
color: "#18a058", // 绿色
},
pending: {
type: "warning" as const,
text: "未生效",
color: "#f0a020", // 橙色
},
} as const
/** 域名类型映射配置 */
const DOMAIN_TYPE_MAP: Record<number, "platform" | "external"> = {
1: "platform",
2: "external",
} as const
/** 域名类型显示配置 */
const DOMAIN_TYPE_CONFIG = {
platform: {
type: 'info' as const,
text: '堡塔',
},
external: {
type: 'warning' as const,
text: '外部',
},
} as const
const message = useMessage()
const { handleError } = useError()
/**
* Store
*/
export const useDomainResolveStore = defineStore('domain-resolve-store', () => {
// -------------------- 状态定义 --------------------
/** 页面加载状态 */
const loading = ref(false)
/** NS状态选项 */
const statusOptions = ref<StatusOption[]>([
{ label: '全部状态', value: '' },
{ label: '已生效', value: 1 },
{ label: '未生效', value: 2 },
{ label: '未设置', value: 0 },
])
/** 筛选表单数据 */
const filterFormData = ref<ResolveListRequest>({
p: 1,
rows: 10,
keyword: '',
status: '',
})
/** 选中的域名信息(用于内部跳转) */
const selectedDomainInfo = ref<{
id: number
name: string
domain_type: number
} | null>(null)
// -------------------- 方法定义 --------------------
/**
*
* @param params
*/
const fetchResolveListData = async <T = ResolveItem>(
params: ResolveListRequest = {}
): Promise<TableResponse<T>> => {
try {
loading.value = true
// 处理参数只有status有值时才传递该参数
const requestParams = { ...params }
if (requestParams.status === '' || requestParams.status === undefined) {
delete requestParams.status
}
const { fetch, data } = getResolveList(requestParams)
await fetch()
if (data.value?.status) {
const responseData = data.value?.data
return {
list: responseData.data as T[],
total: responseData.total,
}
}
} catch (error) {
handleError(error)
message.error('加载解析记录列表失败')
} finally {
loading.value = false
}
return { list: [] as T[], total: 0 }
}
/**
*
* @param info
*/
const setSelectedDomainInfo = (info: { id: number; name: string; domain_type: number }) => {
selectedDomainInfo.value = info
}
/**
*
*/
const clearSelectedDomainInfo = () => {
selectedDomainInfo.value = null
}
/**
*
* @param domainId ID
* @param domainType
*/
const checkDomainStatusApi = async (domainId: number, domainType?: number) => {
try {
const { fetch, data, message } = checkDomainStatus({
domain_id: domainId,
domain_type: domainType
})
message.value = true
await fetch()
if (data.value?.status) {
return data.value
}
} catch (error) {
handleError(error)
}
}
// 返回状态和方法
return {
// 状态
loading,
statusOptions,
filterFormData,
selectedDomainInfo,
// 方法
fetchResolveListData,
setSelectedDomainInfo,
clearSelectedDomainInfo,
checkDomainStatusApi,
// 常量
NS_STATUS_CONFIG,
NS_STATUS_MAP,
DOMAIN_TYPE_CONFIG,
DOMAIN_TYPE_MAP,
}
})
/**
* Store
*/
export const useDomainResolveState = () => {
const store = useDomainResolveStore()
const storeRefs = storeToRefs(store)
return {
// 响应式状态
...storeRefs,
// 方法
fetchResolveListData: store.fetchResolveListData,
setSelectedDomainInfo: store.setSelectedDomainInfo,
clearSelectedDomainInfo: store.clearSelectedDomainInfo,
checkDomainStatusApi: store.checkDomainStatusApi,
// 常量
NS_STATUS_CONFIG: store.NS_STATUS_CONFIG,
NS_STATUS_MAP: store.NS_STATUS_MAP,
DOMAIN_TYPE_CONFIG: store.DOMAIN_TYPE_CONFIG,
DOMAIN_TYPE_MAP: store.DOMAIN_TYPE_MAP,
}
}

View File

@ -0,0 +1,117 @@
/**
*
* API
*/
import { defineComponent, onMounted, ref } from 'vue'
import { NCard, NFlex, NButton, NDivider, NIcon } from 'naive-ui'
import { CloseOutline, SearchOutline } from '@vicons/ionicons5'
import { useController } from './useController'
/**
*
*/
export default defineComponent({
name: 'DomainSecurity',
setup() {
const {
loading,
isMobile,
ApiKeyTable,
ApiKeyTablePage,
ApiKeyCardList,
tableData,
FilterForm,
formFetchSearch,
handleCreate,
} = useController()
// 移动端搜索表单显示状态
const showSearchForm = ref(false)
// 切换搜索表单显示状态
const toggleSearchForm = () => {
showSearchForm.value = !showSearchForm.value
}
// 渲染筛选搜索区域
const renderFilterSection = () => (
<NCard class="card-shadow" bordered={false}>
{isMobile.value ? (
<NFlex vertical class="w-full" size="medium">
<NFlex justify="space-between" align="center">
<NButton type="primary" onClick={handleCreate}>
API
</NButton>
<NButton onClick={toggleSearchForm} class="search-toggle-btn">
{showSearchForm.value ? (
<>
<NIcon size="18">
<CloseOutline />
</NIcon>
<span></span>
</>
) : (
<>
<NIcon size="18">
<SearchOutline />
</NIcon>
<span></span>
</>
)}
</NButton>
</NFlex>
{showSearchForm.value && (
<>
<NDivider class="!my-2" dashed />
<div class="mobile-search-form">
<FilterForm inline={false} />
</div>
</>
)}
</NFlex>
) : (
<NFlex justify="space-between" align="center" class="w-full">
<NFlex class="w-full !flex-row !flex-wrap" size="medium">
<NButton type="primary" onClick={handleCreate}>
API
</NButton>
<FilterForm class="flex-1 justify-end" inline={true} />
</NFlex>
</NFlex>
)}
</NCard>
)
// 渲染API密钥列表
const renderApiKeyList = () => (
<>
{isMobile.value ? (
<NFlex vertical>
<ApiKeyCardList data={tableData.value?.list || []} loading={loading.value} class="mb-4" />
<NFlex justify="center">
<ApiKeyTablePage />
</NFlex>
</NFlex>
) : (
<NCard class="card-shadow" bordered={false}>
<ApiKeyTable loading={loading.value} class="mb-4" />
<NFlex justify="end">
<ApiKeyTablePage />
</NFlex>
</NCard>
)}
</>
)
onMounted(() => formFetchSearch())
// 主渲染
return () => (
<div class="flex flex-col gap-[16px]">
{renderFilterSection()}
{renderApiKeyList()}
</div>
)
},
})

View File

@ -0,0 +1,62 @@
/**
*
*/
/** API密钥项 */
export interface ApiKeyItem {
/** 密钥ID */
id: number
/** 密钥名称 */
name: string
/** Access Key */
access_key: string
/** Secret Key */
secret_key: string
/** Account ID */
account_id: string
/** 状态0-禁用1-启用 */
status: 0 | 1
/** 状态文本 */
status_text?: string
/** 最后调用时间 - 字符串格式 */
last_used_at?: string | null
/** 最后调用IP */
last_used_ip?: string
/** IP白名单 */
ip_whitelist?: string[]
/** 创建时间(字符串格式) */
created_at: string
/** 更新时间(字符串格式) */
updated_at: string
/** UID */
uid?: number
}
/** API密钥列表请求参数 */
export interface ApiKeyListRequest {
/** 页码,从 1 开始 */
p?: number
/** 每页条数 */
rows?: number
/** 关键字搜索 */
keyword?: string
/** 状态过滤:不传-全部0-禁用1-启用 */
status?: number | string
}
/** 状态选项 */
export interface StatusOption {
label: string
value: number | string
}
/** API密钥创建/更新请求 */
export interface ApiKeyFormData {
/** 密钥名称 */
name: string
/** IP白名单可选 */
ip_whitelist?: string
/** 状态 */
status: 0 | 1
}

View File

@ -0,0 +1,636 @@
/**
*
*
*/
import { defineComponent, ref, computed, type PropType } from 'vue'
import { NButton, NSpace, NCard, NFlex, NSwitch, useDialog, NAlert, NText, NIcon } from 'naive-ui'
import { CopyOutline, CheckmarkOutline } from '@vicons/ionicons5'
import { formatDate } from '@baota/utils/date'
import { useDomainSecurityState } from './useStore'
import { useTable, useForm, useFormHooks, useModal, useLoadingMask } from '@baota/naive-ui/hooks'
import { useApp } from '@/components/layout/useStore'
import type { ApiKeyListRequest, ApiKeyItem, ApiKeyFormData } from './types.d'
import { TableColumns } from 'naive-ui/es/data-table/src/interface'
/**
*
*/
export function useController() {
// 获取状态管理
const {
fetchApiKeyListData,
filterFormData,
statusOptions,
formData,
isEditing,
editingId,
resetFormData,
setEditingData,
createApiKey,
updateApiKey,
toggleApiKeyStatus,
regenerateApiKey,
deleteApiKey,
} = useDomainSecurityState()
const { useFormInput, useFormSelect, useFormSwitch, useFormHelp } = useFormHooks()
const dialog = useDialog()
// 获取移动端状态
const { isMobile } = useApp()
/**
*
*/
const filterFormConfig = () => [
useFormSelect(
'',
'status',
statusOptions.value,
{
class: 'w-28',
},
{ showLabel: false, showFeedback: false }
),
useFormInput(
'',
'keyword',
{
placeholder: '请输入名称',
clearable: true,
class: 'w-64',
},
{ showLabel: false, showFeedback: false }
),
{
type: 'custom' as const,
render: () => (
<NSpace>
<NButton type="primary" onClick={() => formFetchSearch()}>
</NButton>
</NSpace>
),
},
]
/**
* /
*/
const modalFormConfig = computed(() => {
const config: any[] = [
useFormInput(
'密钥名称',
'name',
{
placeholder: '请输入密钥名称',
clearable: true,
},
{
required: true,
showFeedback: true,
labelWidth: '90px',
rule: {
required: true,
message: '请输入密钥名称',
trigger: ['blur', 'input'],
},
},
),
useFormInput(
'IP白名单',
'ip_whitelist',
{
placeholder: '请输入IP地址每行一个IP',
clearable: true,
type: 'textarea',
autosize: { minRows: 3, maxRows: 6 },
},
{
required: false,
labelWidth: '90px',
},
),
]
// 只在编辑时显示状态字段
if (isEditing.value) {
config.push(
useFormSwitch(
'状态',
'status',
{
checkedValue: 1,
uncheckedValue: 0,
},
{
required: false,
showFeedback: false,
labelWidth: '90px',
},
),
)
}
// 添加按钮区域
config.push(
useFormHelp(
[
{
content: '为了安全建议设置IP白名单。留空表示不限制IP访问。每行一个IP地址。',
},
],
{
listStyle: 'none',
class: 'text-[13px] text-gray-500 !pl-[65px] !leading-[1.6] mt-2 mb-4 ml-0',
} as any,
),
{
type: 'custom' as const,
render: () => (
<NFlex justify="end" size="medium" class="mt-4">
<NButton onClick={() => closeModal()}></NButton>
<NButton
type="primary"
disabled={formLoading.value}
onClick={async () => {
try {
await submitForm()
} catch (error) {
console.error('表单提交失败:', error)
}
}}
>
{isEditing.value ? '更新' : '创建'}
</NButton>
</NFlex>
),
},
)
return config
})
/**
*
*/
const createColumns = [
{
title: '名称',
key: 'name',
width: 200,
ellipsis: { tooltip: true },
render: (row: ApiKeyItem) => (
<div class="flex items-center gap-2">
<div class="flex flex-col">
<div class="font-medium">{row.name}</div>
</div>
</div>
),
},
{
title: '状态',
key: 'status',
width: 100,
render: (row: ApiKeyItem) => (
<NSwitch
value={row.status === 1}
checkedValue={true}
uncheckedValue={false}
onUpdateValue={async (value: boolean) => {
const newStatus = value ? 1 : 0
try {
await toggleApiKeyStatus(row.id, newStatus)
await fetchApiKeys()
} catch (error) {
console.error('切换状态失败:', error)
}
}}
/>
),
},
{
title: '最后调用',
key: 'last_used_at',
width: 180,
render: (row: ApiKeyItem) => {
if (row.last_used_at && row.last_used_at !== null) {
const date = new Date(row.last_used_at)
if (!isNaN(date.getTime())) {
return formatDate(date.getTime(), 'yyyy-MM-dd HH:mm:ss')
}
}
return '-'
},
},
{
title: 'IP白名单',
key: 'ip_whitelist',
width: 200,
ellipsis: { tooltip: true },
render: (row: ApiKeyItem) => (row.ip_whitelist?.length ? row.ip_whitelist.join(', ') : '-'),
},
{
title: '操作',
key: 'actions',
width: 220,
align: 'right',
fixed: 'right',
render: (row: ApiKeyItem) => (
<NSpace justify="end" size="small">
<NButton size="small" type="primary" ghost onClick={() => handleEdit(row)}>
</NButton>
<NButton size="small" type="primary" ghost onClick={() => handleRegenerate(row)}>
</NButton>
<NButton size="small" type="error" ghost onClick={() => handleDelete(row.id)}>
</NButton>
</NSpace>
),
},
] as TableColumns<ApiKeyItem>
/**
*
*/
const ApiKeyCardList = defineComponent({
name: 'ApiKeyCardList',
props: {
data: {
type: Array as PropType<ApiKeyItem[]>,
default: () => [],
},
loading: {
type: Boolean,
default: false,
},
},
setup(props) {
return () => (
<NFlex vertical size="medium">
{props.data.map((item: ApiKeyItem) => (
<NCard key={item.id} class="card-shadow" bordered={false}>
<NFlex vertical size="small">
<NFlex align="center" justify="space-between">
<div class="font-medium text-base">{item.name}</div>
<NSwitch
value={item.status === 1}
checkedValue={true}
uncheckedValue={false}
onUpdateValue={async (value: boolean) => {
const newStatus = value ? 1 : 0
try {
await toggleApiKeyStatus(item.id, newStatus)
await fetchApiKeys()
} catch (error) {
console.error('切换状态失败:', error)
}
}}
/>
</NFlex>
{item.ip_whitelist && item.ip_whitelist.length > 0 && (
<div class="text-sm">
<span class="text-gray-500">IP</span>
<span class="text-gray-700">
{item.ip_whitelist.join(', ')}
</span>
</div>
)}
<div class="text-sm text-gray-600">
<span class="text-gray-500"></span>
{item.last_used_at && item.last_used_at !== null
? (() => {
const date = new Date(item.last_used_at)
return !isNaN(date.getTime()) ? formatDate(date.getTime(), 'yyyy-MM-dd HH:mm:ss') : '-'
})()
: '-'
}
</div>
<NFlex justify="end" size="small">
<NButton size="small" type="primary" ghost onClick={() => handleEdit(item)}>
</NButton>
<NButton size="small" type="primary" ghost onClick={() => handleRegenerate(item)}>
</NButton>
<NButton size="small" type="error" ghost onClick={() => handleDelete(item.id)}>
</NButton>
</NFlex>
</NFlex>
</NCard>
))}
</NFlex>
)
},
})
// 表格实例
const {
TableComponent: ApiKeyTable,
PageComponent: ApiKeyTablePage,
loading,
fetch: fetchApiKeys,
data: tableData,
} = useTable<ApiKeyItem, ApiKeyListRequest>({
config: createColumns,
request: fetchApiKeyListData,
defaultValue: filterFormData,
alias: {
page: 'p',
pageSize: 'rows',
},
watchValue: ['p', 'rows', 'status'],
})
// 筛选表单实例
const { component: FilterForm, fetch: formFetchSearch } = useForm<ApiKeyListRequest>({
config: filterFormConfig(),
defaultValue: filterFormData,
request: handleFormSearch,
})
// 创建/编辑表单实例
const { component: ModalForm, fetch: submitForm, loading: formLoading } = useForm<ApiKeyFormData>({
config: modalFormConfig,
defaultValue: formData,
request: handleFormSubmit,
})
// 模态框引用
const currentModal = ref<any>(null)
/**
* API
*/
const ApiKeyInfoModal = defineComponent({
name: 'ApiKeyInfoModal',
props: {
data: {
type: Object as PropType<{
access_key: string
account_id: string
secret_key: string
}>,
required: true
}
},
setup(props) {
const copiedFields = ref<Set<string>>(new Set())
const copyToClipboard = async (text: string, field: string) => {
try {
await navigator.clipboard.writeText(text)
copiedFields.value.add(field)
setTimeout(() => {
copiedFields.value.delete(field)
}, 2000)
} catch (error) {
console.error('复制失败:', error)
}
}
const copyAllInfo = async () => {
const allInfo = `Access Key: ${props.data.access_key}\nAccount ID: ${props.data.account_id}\nSecret Key: ${props.data.secret_key}`
try {
await navigator.clipboard.writeText(allInfo)
copiedFields.value.add('all')
setTimeout(() => {
copiedFields.value.delete('all')
}, 2000)
} catch (error) {
console.error('复制失败:', error)
}
}
const keyItems = [
{ label: 'Access Key', key: 'access_key', value: props.data.access_key },
{ label: 'Account ID', key: 'account_id', value: props.data.account_id },
{ label: 'Secret Key', key: 'secret_key', value: props.data.secret_key },
]
const renderCopyButton = (key: string, value: string) => (
<NButton
size="small"
type={copiedFields.value.has(key) ? 'success' : 'default'}
onClick={() => copyToClipboard(value, key)}
>
<NIcon class="mr-1">
{copiedFields.value.has(key) ? <CheckmarkOutline /> : <CopyOutline />}
</NIcon>
{copiedFields.value.has(key) ? '已复制' : '复制'}
</NButton>
)
return () => (
<div class="p-4">
<NAlert type="warning" class="mb-4" showIcon>
API
</NAlert>
<div class="space-y-4">
{keyItems.map((item) => (
<div key={item.key} class="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
<div class="flex-1">
<div class="text-sm font-medium text-gray-700 mb-1">{item.label}:</div>
<NText code class="text-sm select-all">
{item.value}
</NText>
</div>
{renderCopyButton(item.key, item.value)}
</div>
))}
</div>
<NFlex justify="center" class="mt-6">
<NButton type="primary" size="large" onClick={copyAllInfo}>
<NIcon class="mr-1">{copiedFields.value.has('all') ? <CheckmarkOutline /> : <CopyOutline />}</NIcon>
{copiedFields.value.has('all') ? '全部已复制' : '复制全部信息'}
</NButton>
</NFlex>
</div>
)
}
})
/**
*
*/
const closeModal = () => {
if (currentModal.value) {
currentModal.value.close()
currentModal.value = null
}
resetFormData()
}
// -------------------- 事件处理 --------------------
/**
*
*/
async function handleFormSearch(formData: ApiKeyListRequest) {
await fetchApiKeys()
}
/**
*
*/
async function handleFormSubmit(data: ApiKeyFormData): Promise<void> {
const { open: openLoad, close: closeLoad } = useLoadingMask({ text: `正在${isEditing.value ? '更新' : '创建'}API密钥请稍后...` })
openLoad()
try {
if (isEditing.value && editingId.value) {
await updateApiKey(editingId.value, data)
await fetchApiKeys()
closeModal()
} else {
const result = await createApiKey(data)
if (result.success) {
await fetchApiKeys()
closeModal()
if (result.keyInfo) {
useModal({
title: 'API密钥创建成功',
area: '600px',
component: <ApiKeyInfoModal data={result.keyInfo} />,
footer: false,
maskClosable: false,
closable: true,
})
}
}
}
} catch (error) {
console.error('表单提交失败:', error)
} finally {
closeLoad()
}
}
/**
*
*/
function handleCreate() {
resetFormData()
currentModal.value = useModal({
title: '创建API密钥',
area: '500px',
component: <ModalForm />,
footer: false,
onClose: () => {
resetFormData()
currentModal.value = null
},
})
}
/**
*
*/
function handleEdit(item: ApiKeyItem) {
setEditingData(item)
currentModal.value = useModal({
title: '编辑API密钥',
area: '500px',
component: <ModalForm />,
footer: false,
onClose: () => {
resetFormData()
currentModal.value = null
},
})
}
/**
*
*/
function handleRegenerate(item: ApiKeyItem) {
dialog.warning({
title: '确认重新生成',
content: `确定要重新生成"${item.name}"的API密钥吗重新生成后原密钥将失效请及时更新您的应用配置。`,
positiveText: '重新生成',
negativeText: '取消',
onPositiveClick: async () => {
const { open: openLoad, close: closeLoad } = useLoadingMask({ text: '正在重新生成API密钥请稍后...' })
openLoad()
try {
const result = await regenerateApiKey(item.id)
if (result.success) {
await fetchApiKeys()
if (result.keyInfo) {
useModal({
title: 'API密钥重新生成成功',
area: '600px',
component: <ApiKeyInfoModal data={result.keyInfo} />,
footer: false,
maskClosable: false,
closable: true,
})
}
}
} catch (error) {
console.error('重新生成API密钥失败:', error)
} finally {
closeLoad()
}
},
})
}
/**
*
*/
function handleDelete(id: number) {
dialog.warning({
title: '确认删除',
content: '确定要删除这个API密钥吗删除后无法恢复。',
positiveText: '删除',
negativeText: '取消',
onPositiveClick: async () => {
const { open: openLoad, close: closeLoad } = useLoadingMask({ text: '正在删除API密钥请稍后...' })
openLoad()
try {
await deleteApiKey(id)
await fetchApiKeys()
} catch (error) {
console.error('删除API密钥失败:', error)
} finally {
closeLoad()
}
},
})
}
return {
// 状态
loading,
isMobile,
// 表格
ApiKeyTable,
ApiKeyTablePage,
tableData,
// 移动端卡片
ApiKeyCardList,
// 表单
FilterForm,
formFetchSearch,
// 事件处理
handleCreate,
handleEdit,
handleRegenerate,
handleDelete,
}
}

View File

@ -0,0 +1,268 @@
/**
*
* API
*/
import { ref } from 'vue'
import { defineStore, storeToRefs } from 'pinia'
import { useMessage } from '@baota/naive-ui/hooks'
import { useError } from '@baota/hooks/error'
import { createApi, getApiKeyList, updateApiKey as updateApiKeyApi, regenerateApiKey as regenerateApiKeyApi, deleteApiKey as deleteApiKeyApi } from '@/api/api'
import type { TableResponse } from '@baota/naive-ui/types/table'
import type { ApiKeyItem, ApiKeyListRequest, ApiKeyFormData, StatusOption } from './types.d'
const message = useMessage()
const { handleError } = useError()
/**
* Store
*/
export const useDomainSecurityStore = defineStore('domain-security-store', () => {
// -------------------- 状态定义 --------------------
/** 页面加载状态 */
const loading = ref(false)
/** 状态选项 */
const statusOptions = ref<StatusOption[]>([
{ label: '全部状态', value: '' },
{ label: '启用', value: 1 },
{ label: '禁用', value: 0 },
])
/** 筛选表单数据 */
const filterFormData = ref<ApiKeyListRequest>({
p: 1,
rows: 10,
keyword: '',
status: '',
})
/** 表单数据(创建/编辑) */
const formData = ref<ApiKeyFormData>({
name: '',
ip_whitelist: '',
status: 1,
})
/** 编辑状态 */
const isEditing = ref(false)
const editingId = ref<number | null>(null)
// -------------------- 方法定义 --------------------
/**
* API
*/
const fetchApiKeyListData = async <T = ApiKeyItem>(
params: ApiKeyListRequest = {}
): Promise<TableResponse<T>> => {
try {
loading.value = true
const requestParams = { ...params }
if (requestParams.status === '' || requestParams.status === undefined) {
delete requestParams.status
}
const { fetch, data } = getApiKeyList(requestParams)
await fetch()
if (data.value?.status) {
const responseData = data.value?.data
return {
list: responseData.data as T[],
total: responseData.total,
}
}
} catch (error) {
handleError(error)
} finally {
loading.value = false
}
return { list: [] as T[], total: 0 }
}
/**
*
*/
const resetFormData = () => {
formData.value = {
name: '',
ip_whitelist: '',
status: 1,
}
isEditing.value = false
editingId.value = null
}
/**
*
*/
const setEditingData = (item: ApiKeyItem) => {
isEditing.value = true
editingId.value = item.id
formData.value = {
name: item.name,
ip_whitelist: item.ip_whitelist?.join('\n') || '',
status: item.status,
}
}
/**
* API
*/
const createApiKey = async (data: ApiKeyFormData): Promise<{ success: boolean; keyInfo?: { access_key: string; account_id: string; secret_key: string } }> => {
try {
loading.value = true
const ipWhitelist = data.ip_whitelist
? data.ip_whitelist.trim().split(/\n/).map(ip => ip.trim()).filter(ip => ip.length > 0)
: []
const createParams = {
name: data.name,
ip_whitelist: ipWhitelist
}
const { fetch, data: apiResponse, message } = createApi(createParams)
message.value = true
await fetch()
if (apiResponse.value?.status) {
const responseData = apiResponse.value?.data
return {
success: true,
keyInfo: responseData ? {
access_key: responseData.access_key,
account_id: responseData.account_id,
secret_key: responseData.secret_key
} : undefined
}
}
} catch (error) {
handleError(error)
} finally {
loading.value = false
}
return { success: false }
}
/**
* API
*/
const updateApiKey = async (id: number, data: ApiKeyFormData): Promise<void> => {
try {
const ipWhitelist = data.ip_whitelist
? data.ip_whitelist.trim().split(/\n/).map(ip => ip.trim()).filter(ip => ip.length > 0)
: []
const updateParams = {
config_id: id,
name: data.name,
status: data.status,
ip_whitelist: ipWhitelist
}
const { fetch, message } = updateApiKeyApi(updateParams)
message.value = true
await fetch()
} catch (error) {
handleError(error)
}
}
/**
* API
*/
const toggleApiKeyStatus = async (config_id: number, status: number): Promise<void> => {
try {
const { fetch, message } = updateApiKeyApi({
config_id,
status
} as any)
message.value = true
await fetch()
} catch (error) {
handleError(error)
}
}
/**
* API
*/
const regenerateApiKeyAction = async (config_id: number): Promise<{ success: boolean; keyInfo?: { access_key: string; account_id: string; secret_key: string } }> => {
try {
const { fetch, data, message } = regenerateApiKeyApi({ config_id })
message.value = true
await fetch()
if (data.value?.status) {
const responseData = data.value?.data
return {
success: true,
keyInfo: responseData ? {
access_key: responseData.access_key,
account_id: responseData.account_id,
secret_key: responseData.secret_key
} : undefined
}
}
} catch (error) {
handleError(error)
}
return { success: false }
}
/**
* API
*/
const deleteApiKey = async (config_id: number): Promise<void> => {
try {
loading.value = true
const { fetch, message } = deleteApiKeyApi({ config_id })
message.value = true
await fetch()
} catch (error) {
handleError(error)
} finally {
loading.value = false
}
}
// 返回状态和方法
return {
// 状态
loading,
statusOptions,
filterFormData,
formData,
isEditing,
editingId,
// 方法
fetchApiKeyListData,
resetFormData,
setEditingData,
createApiKey,
updateApiKey,
toggleApiKeyStatus,
regenerateApiKey: regenerateApiKeyAction,
deleteApiKey,
}
})
/**
* Store
*/
export const useDomainSecurityState = () => {
const store = useDomainSecurityStore()
const storeRefs = storeToRefs(store)
return {
// 响应式状态
...storeRefs,
// 方法
fetchApiKeyListData: store.fetchApiKeyListData,
resetFormData: store.resetFormData,
setEditingData: store.setEditingData,
createApiKey: store.createApiKey,
updateApiKey: store.updateApiKey,
toggleApiKeyStatus: store.toggleApiKeyStatus,
regenerateApiKey: store.regenerateApiKey,
deleteApiKey: store.deleteApiKey,
}
}

View File

@ -1,15 +1,94 @@
import { defineComponent, computed } from 'vue'
import { NAlert, NButton, NFlex, NGrid, NGridItem, NQrCode, NRadioButton, NRadioGroup, NSelect, NTag, NSteps, NStep } from 'naive-ui'
import { defineComponent, computed, ref } from 'vue'
import { NAlert, NButton, NFlex, NGrid, NGridItem, NQrCode, NRadioButton, NRadioGroup, NSelect, NTag, NSteps, NStep, NInputNumber, NSpace } from 'naive-ui'
import { useDomainState } from '../useStore'
import { useController } from '../useController'
import { formatDate } from '@baota/utils/date'
export default defineComponent({
name: 'RenewDialog',
props: { YEAR_OPTIONS: { type: Array as unknown as () => number[], default: () => [1,2,3,5,10] } },
props: { YEAR_OPTIONS: { type: Array as unknown as () => number[], default: () => [1,2,3,5] } },
setup(props) {
const state = useDomainState()
const { changeRenewYear, switchRenewChannel, closeRenewModal, payRenewByBalance } = useController()
// 自定义年限状态管理
const customYear = ref<number | null>(null)
const selectedYearType = ref<string | number>(state.renewSelectedYear.value)
const isQueryingPrice = ref(false) // 防止重复查询的标志位
// 生成下拉框选项(预设年份 + 自定义)
const yearSelectOptions = [
...props.YEAR_OPTIONS.map(year => ({
label: `${year}`,
value: year
})),
{ label: '自定义', value: 'custom' }
]
// 当前是否为自定义模式
const isCustomMode = computed(() => selectedYearType.value === 'custom')
// 验证自定义年份
const isValidCustomYear = computed(() => {
return customYear.value !== null &&
customYear.value >= 1 &&
customYear.value <= 10 &&
Number.isInteger(customYear.value)
})
// 处理下拉框选择变化
const onYearSelectChange = (value: string | number) => {
selectedYearType.value = value
if (typeof value === 'number') {
// 选择预设年份
customYear.value = null
changeRenewYear(value)
} else if (value === 'custom') {
// 选择自定义模式
if (customYear.value && isValidCustomYear.value) {
changeRenewYear(customYear.value)
}
}
}
// 查询价格的核心逻辑
const queryCustomPrice = async () => {
if (customYear.value !== null && isValidCustomYear.value && !isQueryingPrice.value) {
isQueryingPrice.value = true
try {
await changeRenewYear(customYear.value)
} finally {
// 短暂延迟后重置标志位,防止快速重复调用
setTimeout(() => {
isQueryingPrice.value = false
}, 100)
}
}
}
// 处理自定义年限输入
const onCustomYearChange = (value: number | null) => {
customYear.value = value
// 输入过程中不立即查询价格,避免频繁调用接口
}
// 处理自定义输入框失焦
const onCustomYearBlur = () => {
// 只在失焦时查询一次价格
void queryCustomPrice()
}
// 处理回车键确认
const onCustomYearKeyup = (e: KeyboardEvent) => {
if (e.key === 'Enter') {
// 先查询价格
void queryCustomPrice()
// 然后让输入框失去焦点,但防重复调用机制会阻止重复查询
;(e.target as HTMLInputElement)?.blur()
}
}
const qrLink = computed(() => state.renewPayChannel.value === 'wechat' ? state.renewOrderInfo.value?.wx : state.renewOrderInfo.value?.ali)
const canPayByBalance = computed(() => Number(state.renewBalanceAvailable.value || 0) >= Number(state.renewOrderInfo.value?.total_price || 0))
@ -44,12 +123,46 @@ export default defineComponent({
</div>
<div class="text-gray-500"></div>
<div class="col-span-2">
<NSpace>
{/* 年限选择下拉框 */}
<NSelect
value={state.renewSelectedYear.value}
onUpdateValue={(v) => changeRenewYear(Number(v))}
options={props.YEAR_OPTIONS.map((y) => ({ label: `${y}`, value: y }))}
value={selectedYearType.value}
onUpdateValue={onYearSelectChange}
options={yearSelectOptions}
loading={state.renewLoading.value}
/>
style={{ width: '100px' }}
placeholder="选择年限"
/>
{/* 自定义年限输入框 - 同层显示 */}
{isCustomMode.value && (
<div style={{ display: 'inline-flex', alignItems: 'center' }}>
<NInputNumber
value={customYear.value}
onUpdateValue={onCustomYearChange}
onBlur={onCustomYearBlur}
onFocus={(e: FocusEvent) => (e.target as HTMLInputElement).select()}
placeholder="1-10年"
min={1}
max={10}
precision={0}
style={{ width: '120px' }}
status={customYear.value !== null && !isValidCustomYear.value ? 'error' : undefined}
inputProps={{
onKeyup: onCustomYearKeyup
}}
/>
<span style={{ marginLeft: '4px', fontSize: '14px', color: '#666' }}></span>
</div>
)}
</NSpace>
{/* 错误提示 */}
{isCustomMode.value && customYear.value !== null && !isValidCustomYear.value && (
<div style={{ color: '#d03050', fontSize: '12px', marginTop: '8px' }}>
1-10
</div>
)}
</div>
<div class="text-gray-500"></div>
<div class="col-span-2 text-red-500 font-medium">¥{priceToShow.value.toFixed(2)}</div>

View File

@ -101,7 +101,6 @@ export default defineComponent({
</>
)
onMounted(() => formFetchSearch)
// 主渲染
return () => (
<div class="flex flex-col gap-[16px]">

View File

@ -42,7 +42,7 @@ export function useController() {
const { useFormInput, useFormSelect } = useFormHooks()
// 获取移动端状态
const { isMobile } = useApp()
const uMessage = useMessage()
function computeNewExpire(years: number) {
@ -283,7 +283,7 @@ export function useController() {
PageComponent: DomainTablePage,
loading,
param,
fetch:fetchDomain,
fetch: fetchDomain,
data: tableData,
} = useTable<DomainItem, DomainListRequest>({
config: createColumns,
@ -293,7 +293,7 @@ export function useController() {
page: 'p',
pageSize: 'rows',
},
watchValue: ['p', 'rows', 'keyword', 'status', 'suffix'],
watchValue: ['p', 'rows', 'status', 'suffix'],
})
// 表单实例
@ -356,12 +356,18 @@ export function useController() {
*
*/
function handleDns(row: DomainItem) {
router.push(`/domain/detail/${row.id}?tabs=analysis`)
// router.push(`/domain/detail/${row.id}?tabs=analysis`)
router.push(`/domain-resolve/detail/${row.id}?domain_name=${row.full_domain}`)
}
// 续费入口
async function ensureRenewBalance() {
try { await loadAccountBalance(); useDomainState().setRenewBalance(Number(recharge.overview.value?.balance || 0)); } catch { useDomainState().setRenewBalance(0) }
try {
await loadAccountBalance()
useDomainState().setRenewBalance(Number(recharge.overview.value?.balance || 0))
} catch {
useDomainState().setRenewBalance(0)
}
}
function openRenewModal(row: DomainItem) {
const state = useDomainState()
@ -372,7 +378,7 @@ export function useController() {
title: '域名续费',
area: '520px',
component: RenewDialog,
componentProps: { YEAR_OPTIONS: [1, 2, 3, 5, 10] },
componentProps: { YEAR_OPTIONS: [1, 2, 3, 5] },
footer: false,
onClose: () => {
closeRenewModal()
@ -383,16 +389,16 @@ export function useController() {
computeNewExpire(state.renewSelectedYear.value)
}
async function doRenew(domain: string, year: number) {
async function doRenew(domain: string, year: number) {
const state = useDomainState()
state.setRenewLoading(true)
try {
const payload: RenewRequest = { domain_list: [{ domain, year, domain_service: 0 }] }
const { fetch,data } = renewOrder(payload)
const { fetch, data } = renewOrder(payload)
await fetch()
if (!data.value?.status) {
uMessage.error(data.value?.msg || '续费失败')
return false;
return false
}
state.setRenewOrderInfo((data.value.data as RenewData) || null)
state.setRenewStep(2)
@ -519,7 +525,9 @@ export function useController() {
}
}
function handleRenew(row: DomainItem) { openRenewModal(row) }
function handleRenew(row: DomainItem) {
openRenewModal(row)
}
/**
*

View File

@ -0,0 +1,58 @@
import { defineComponent, onMounted } from 'vue'
import { NCard, NFlex, NPageHeader, NSpin } from 'naive-ui'
import { useController } from './useController'
export default defineComponent({
name: 'OperationLog',
props: {},
setup() {
const {
// 表格相关
OperateLogTable,
OperateLogTablePage,
OperateLogCardList,
loading,
tableData,
// 表单相关
FilterForm,
// 状态
isMobile,
} = useController()
return () => (
<div class="flex flex-col gap-[16px]">
{/* 筛选表单 */}
<NCard bordered={false} class="card-shadow">
<NFlex class="w-full !flex-row !flex-wrap" size="medium">
<FilterForm class="flex-1 justify-end" inline={true} />
</NFlex>
</NCard>
{/* 数据展示区域 */}
<NCard bordered={false} class="card-shadow">
<NSpin show={loading.value}>
{isMobile.value ? (
/* 移动端卡片列表 */
<NFlex vertical size="medium">
<OperateLogCardList data={tableData.value?.list || []} loading={loading.value} />
<NFlex justify="center">
<OperateLogTablePage />
</NFlex>
</NFlex>
) : (
/* 桌面端表格 */
<NFlex vertical size="medium">
<OperateLogTable />
<NFlex justify="end">
<OperateLogTablePage />
</NFlex>
</NFlex>
)}
</NSpin>
</NCard>
</div>
)
},
})

View File

@ -0,0 +1,278 @@
/**
*
*
*/
import { defineComponent, type PropType } from 'vue'
import { NButton, NTag, NSpace, NCard, NFlex, NTooltip } from 'naive-ui'
import { formatDate } from '@baota/utils/date'
import { useTable, useForm, useFormHooks, useMessage } from '@baota/naive-ui/hooks'
import { useError } from '@baota/hooks/error'
import { getOperateLog } from '@/api/operate-log'
import type { OperateLogData, OperateLogRequest } from '@/api/operate-log'
import { useApp } from '@/components/layout/useStore'
import { TableColumns } from 'naive-ui/es/data-table/src/interface'
// 定义标签类型
type TagType = 'default' | 'success' | 'warning' | 'error' | 'info'
/**
*
*/
function getLogLevelType(level: string): TagType {
switch (level?.toLowerCase()) {
case 'info':
return 'info'
case 'warning':
return 'warning'
case 'error':
return 'error'
case 'debug':
return 'default'
default:
return 'default'
}
}
/**
*
*/
export function useController() {
// 获取移动端状态
const { isMobile } = useApp()
const { useFormInput } = useFormHooks()
const message = useMessage()
const { handleError } = useError()
// 默认查询参数
const defaultParams = ref<OperateLogRequest>({
keyword: '',
p: 1,
rows: 10,
})
/**
*
*/
const formConfig = () => [
useFormInput(
'',
'keyword',
{
placeholder: '请输入操作内容',
clearable: true,
},
{ showLabel: false, showFeedback: false },
),
{
type: 'custom' as const,
render: () => (
<NSpace>
<NButton type="primary" onClick={() => formFetchSearch()}>
</NButton>
</NSpace>
),
},
]
/**
*
*/
const createColumns = [
{
title: '日志ID',
key: 'log_id',
width: 60,
},
{
title: '级别',
key: 'level',
width: 60,
render: (row: OperateLogData) => (
<NTag type={getLogLevelType(row.level)} bordered={false} size="small">
{row.level?.toUpperCase() || 'INFO'}
</NTag>
),
},
{
title: '客户端IP',
key: 'remote_addr',
width: 140,
render: (row: OperateLogData) => <span class="text-gray-600 ">{row.remote_addr || '-'}</span>,
},
{
title: '模块',
key: 'module',
width: 120,
render: (row: OperateLogData) => <span class="text-gray-600">{row.module || '-'}</span>,
},
{
title: '请求方法',
key: 'request_method',
width: 100,
render: (row: OperateLogData) => (
<NTag type="info" bordered={false} size="small">
{row.request_method || '-'}
</NTag>
),
},
{
title: '请求URL',
key: 'request_url',
width: 200,
render: (row: OperateLogData) => <span class="text-gray-600 ">{row.request_url || '-'}</span>,
},
{
title: '操作内容',
key: 'message',
width: 400,
render: (row: OperateLogData) => <div class="text-sm">{row.message || '-'}</div>,
},
{
title: '创建时间',
key: 'created_at',
width: 180,
align: 'right',
fixed: 'right',
render: (row: OperateLogData) => (
<span class="text-gray-600 text-sm">{formatDate(row.created_at, 'yyyy-MM-dd HH:mm:ss')}</span>
),
},
] as TableColumns<OperateLogData>
/**
*
*/
const OperateLogCardList = defineComponent({
name: 'OperateLogCardList',
props: {
data: {
type: Array as PropType<OperateLogData[]>,
default: () => [],
},
loading: {
type: Boolean,
default: false,
},
},
setup(props) {
return () => (
<NFlex vertical size="medium">
{props.data.map((item: OperateLogData) => (
<NCard key={item.log_id} class="card-shadow" bordered={false}>
<NFlex vertical size="small">
{/* 日志级别和模块 */}
<NFlex align="center" justify="space-between">
<NFlex align="center" size="small">
<NTag type={getLogLevelType(item.level)} bordered={false} size="small">
{item.level?.toUpperCase() || 'INFO'}
</NTag>
{item.module && <span class="text-gray-600 text-sm">{item.module}</span>}
</NFlex>
<span class="text-gray-500">{formatDate(item.created_at, 'MM-dd HH:mm')}</span>
</NFlex>
{/* 操作内容 */}
<div class="text-sm text-gray-800">{item.message || '-'}</div>
{/* 请求信息 */}
<NFlex justify="space-between" class="text-gray-500">
<div>
<span class="text-gray-400"></span>
<NTag type="info" bordered={false} size="tiny">
{item.request_method || '-'}
</NTag>
</div>
<div>
<span class="text-gray-400">IP</span>
<span>{item.remote_addr || '-'}</span>
</div>
</NFlex>
{/* 请求地址 */}
{item.request_url && (
<div class="text-gray-500 bg-gray-50 p-2 rounded">{item.request_url}</div>
)}
</NFlex>
</NCard>
))}
</NFlex>
)
},
})
/**
*
* @param params
*/
const fetchOperationListData = async <T = OperateLogData,>(params: OperateLogRequest = {}) => {
try {
loading.value = true
const { data } = await getOperateLog(params).fetch()
return { list: data?.data as T[], total: data?.count || 0 }
} catch (error) {
handleError(error)
message.error('加载域名列表失败')
return { list: [] as T[], total: 0 }
} finally {
loading.value = false
}
}
// 表格实例
const {
TableComponent: OperateLogTable,
PageComponent: OperateLogTablePage,
loading,
param,
fetch: fetchOperateLog,
data: tableData,
} = useTable<OperateLogData, OperateLogRequest>({
config: createColumns,
request: fetchOperationListData,
defaultValue: defaultParams,
alias: {
page: 'p',
pageSize: 'rows',
},
watchValue: ['p', 'rows'],
})
// 表单实例
const { component: FilterForm, fetch: formFetchSearch } = useForm<OperateLogRequest>({
config: formConfig(),
defaultValue: defaultParams,
request: handleFormSearch,
})
/**
*
*/
async function handleFormSearch() {
await fetchOperateLog()
}
// 组件挂载时获取路由参数
onMounted(async () => {
await fetchOperateLog()
})
return {
// 表格相关
OperateLogTable,
OperateLogTablePage,
OperateLogCardList,
loading,
tableData,
fetchOperateLog,
// 表单相关
FilterForm,
formFetchSearch,
// 状态
isMobile,
}
}

View File

@ -218,10 +218,7 @@ export function useController() {
{/* 订单号和状态 */}
<NFlex align="center" justify="space-between">
<NFlex align="center" size="small">
<div>
<div class="font-medium text-base">{item.son_order_no}</div>
<div class="text-xs text-gray-500">{getOrderTypeText(item.order_type)}</div>
</div>
<div class="text-base font-medium ">{item.full_domain}</div>
</NFlex>
<NTag type={getOrderStatusType(item.status)} bordered={false} size="small">
{getOrderStatusText(item.status)}
@ -230,17 +227,17 @@ export function useController() {
{/* 域名信息 */}
<NFlex align="center" justify="space-between">
<div class="text-sm text-gray-600">
<span class="text-gray-500"></span>
{item.full_domain}
<div class="text-xs text-gray-500">
<div>{getOrderTypeText(item.order_type)}</div>
<div>{item.son_order_no}</div>
</div>
<div class="amount-text">{formatCurrency(parseFloat(item.total_amount))}</div>
</NFlex>
{/* 时间信息 */}
<NFlex justify="space-between" class="time-text">
<div>
<span class="text-gray-500"></span>
<div class="text-xs text-gray-500">
<span></span>
{formatRelativeTime(item.created_at)}
</div>
</NFlex>

View File

@ -42,7 +42,11 @@ const ORDER_TYPE = {
/** 域名续费 */
RENEW: 1,
/** 域名转入 */
TRANSFER: 2,
TRANSFER: 2,
/** 购买隐私保护 */
PURCHASE_PRIVACY: 3,
/** 续费隐私保护 */
RENEW_PRIVACY: 4,
};
/**
@ -84,6 +88,8 @@ const ORDER_TYPE_MAP = {
[ORDER_TYPE.REGISTER]: { text: "域名注册", color: "#1890ff" },
[ORDER_TYPE.RENEW]: { text: "域名续费", color: "#52c41a" },
[ORDER_TYPE.TRANSFER]: { text: "域名转入", color: "#722ed1" },
[ORDER_TYPE.PURCHASE_PRIVACY]: { text: "购买隐私保护", color: "#faad14" },
[ORDER_TYPE.RENEW_PRIVACY]: { text: "续费隐私保护", color: "#fff2f0" },
unknown: { text: "未知类型", color: "#666666" },
};
@ -106,7 +112,9 @@ const TYPE_OPTIONS = [
{ label: "全部类型", value: -1 },
{ label: "域名注册", value: ORDER_TYPE.REGISTER },
{ label: "域名续费", value: ORDER_TYPE.RENEW },
{ label: "域名转入", value: ORDER_TYPE.TRANSFER },
{ label: "域名转入", value: ORDER_TYPE.TRANSFER },
{ label: "购买隐私保护", value: ORDER_TYPE.PURCHASE_PRIVACY },
{ label: "续费隐私保护", value: ORDER_TYPE.RENEW_PRIVACY },
];
/**

View File

@ -154,6 +154,7 @@ export default defineComponent({
if (newType !== oldType) {
// 个人:身份证,企业:营业执照
formData.id_type = newType === 1 ? 1 : 2
formData.id_number = ''
// 清空文件列表
formData.id_image_front = []
@ -445,6 +446,7 @@ export default defineComponent({
setBase64: (base64: string) => (uploadStates.frontImageBase64 = base64),
setPath: (path: string) => (uploadStates.frontImagePath = path),
onSuccess: (rdata: any) => {
if (formData.type === 2) return
const { idnum } = rdata.data || {}
formData.id_number = idnum || ''
message.success('已自动填充身份证信息')

View File

@ -170,7 +170,7 @@ export function useController() {
{/* <NButton size="small" onClick={() => handleEditTemplate(row)}>
</NButton> */}
<NButton size="small" type="error" ghost onClick={() => handleDeleteTemplate(row.registrant_id)}>
<NButton size="small" type="error" ghost onClick={() => handleDeleteTemplate(row.id)}>
</NButton>
</NFlex>
@ -329,9 +329,9 @@ export function useController() {
/**
*
* @param registrantId ID
* @param id id
*/
async function handleDeleteTemplate(registrantId: string) {
async function handleDeleteTemplate(id: number) {
useDialog({
type: 'warning',
title: '确认删除',
@ -341,7 +341,7 @@ export function useController() {
negativeText: '取消',
onPositiveClick: async () => {
try {
await deleteTemplateById(registrantId)
await deleteTemplateById(id)
fetchTable()
} catch (error) {
console.error('删除模板失败:', error)
@ -467,7 +467,7 @@ export function useController() {
{/* 操作按钮 */}
<NFlex size="small">
<NButton size="small" type="error" ghost onClick={() => handleDeleteTemplate(item.registrant_id)}>
<NButton size="small" type="error" ghost onClick={() => handleDeleteTemplate(item.id)}>
</NButton>
</NFlex>

View File

@ -189,11 +189,11 @@ export const useRealNameStore = defineStore('real-name-store', () => {
/**
*
* @param registrantId ID
* @param id id
*/
const deleteTemplateById = async (registrant_id: string) => {
const deleteTemplateById = async (id: number) => {
try {
const { data, fetch, message } = deleteUserDetail({ registrant_id })
const { data, fetch, message } = deleteUserDetail({ id })
message.value = true
await fetch()
return data

View File

@ -1,5 +1,5 @@
import { defineComponent, ref, watch, onMounted, computed } from "vue";
import { NForm, NFormItem, NRadioGroup, NRadioButton, NSpace, NAlert, NSelect, NQrCode } from 'naive-ui'
import { NForm, NFormItem, NSelect, NSpace, NAlert, NInputNumber, NQrCode, NRadioGroup, NRadioButton } from 'naive-ui'
import { useRechargeState } from "../useStore";
import { useRechargeController } from "../useController";
@ -17,11 +17,41 @@ export default defineComponent({
const { createPayload, setCreatePayload, qrWxUrl, qrAliUrl } = useRechargeState();
const { createRechargeOrder } = useRechargeController();
const presets = [2000, 3000, 5000];
const amountOptions = presets.map((p) => ({ label: `${p}`, value: p }));
// 预设金额配置
const presets = [200, 500, 1000, 2000, 5000];
const updating = ref(false);
const customAmount = ref<number | null>(null);
const selectedAmountType = ref<string | number>(2000); // 当前选中的下拉框值
// 生成下拉框选项
const amountOptions = [
...presets.map(amount => ({ label: `${amount}`, value: amount })),
{ label: '自定义', value: 'custom' }
];
// 当前是否为自定义模式
const isCustomMode = computed(() => selectedAmountType.value === 'custom');
// 验证自定义金额
const isValidCustomAmount = computed(() => {
return customAmount.value !== null &&
customAmount.value >= 200 &&
Number.isInteger(customAmount.value);
});
// 当前是否可以发起请求
const canCreateOrder = computed(() => {
if (!isCustomMode.value) {
return true;
}
return isValidCustomAmount.value;
});
const requestOrder = async () => {
if (!canCreateOrder.value) {
return;
}
try {
updating.value = true;
await createRechargeOrder();
@ -33,16 +63,66 @@ export default defineComponent({
const triggerOrder = debounce(requestOrder, 300);
onMounted(async () => {
setCreatePayload({ amount: 2000, channel: 'wechat' });
// 默认选中2000元
selectedAmountType.value = 2000;
setCreatePayload({
amount: 2000,
channel: 'wechat',
amountType: 'preset'
});
await requestOrder();
});
// 监听金额变化,只在验证通过时触发
watch(
[() => createPayload.value.amount],
() => triggerOrder(),
[() => createPayload.value.amount, () => canCreateOrder.value],
([newAmount, canCreate]) => {
if (canCreate) {
triggerOrder();
}
},
);
const onAmountChange = (v: number) => setCreatePayload({ ...createPayload.value, amount: Number(v || 0) });
// 处理下拉框选择变化
const onSelectChange = (value: string | number) => {
selectedAmountType.value = value;
if (typeof value === 'number') {
// 选择预设金额
customAmount.value = null;
setCreatePayload({
...createPayload.value,
amount: value,
amountType: 'preset'
});
} else if (value === 'custom') {
// 选择自定义模式但不立即更新payload
// 等待用户输入有效金额
}
};
// 处理自定义金额输入
const onCustomAmountChange = (value: number | null) => {
customAmount.value = value;
// 实时更新payload如果有效
if (value !== null && isCustomMode.value) {
setCreatePayload({
...createPayload.value,
amount: value,
amountType: 'custom'
});
}
};
// 处理自定义输入框失焦
const onCustomInputBlur = () => {
if (isCustomMode.value && isValidCustomAmount.value) {
// 验证通过,触发接口请求
triggerOrder();
}
};
const onChannelChange = (v: 'wechat' | 'alipay') => setCreatePayload({ ...createPayload.value, channel: v });
const qrLink = computed(() => (createPayload.value.channel === 'wechat' ? qrWxUrl.value : qrAliUrl.value) || '');
@ -54,28 +134,62 @@ export default defineComponent({
</NAlert>
<NForm labelPlacement="left" class="p-4">
<NFormItem label="充值金额">
<NSelect
style={{ width: '180px' }}
value={createPayload.value.amount}
options={amountOptions}
onUpdateValue={(v) => onAmountChange(Number(v || 0))}
/>
<NSpace>
{/* 金额选择下拉框 */}
<NSelect
value={selectedAmountType.value}
options={amountOptions}
onUpdateValue={onSelectChange}
style={{ width: '120px' }}
placeholder="选择金额"
/>
{/* 自定义金额输入框 - 仅在选择自定义时显示 */}
{isCustomMode.value && (
<div style={{ display: 'inline-flex', alignItems: 'center' }}>
<NInputNumber
value={customAmount.value}
onUpdateValue={onCustomAmountChange}
onBlur={onCustomInputBlur}
onFocus={(e: FocusEvent) => (e.target as HTMLInputElement).select()}
placeholder="最低200元"
min={200}
precision={0}
style={{ width: '140px' }}
status={customAmount.value !== null && !isValidCustomAmount.value ? 'error' : undefined}
/>
<span style={{ marginLeft: '4px', fontSize: '14px', color: '#666' }}></span>
</div>
)}
</NSpace>
{/* 错误提示 */}
{isCustomMode.value && customAmount.value !== null && !isValidCustomAmount.value && (
<div style={{ color: '#d03050', fontSize: '12px', marginTop: '8px' }}>
200
</div>
)}
</NFormItem>
<NFormItem label="充值方式">
<NRadioGroup value={createPayload.value.channel} onUpdateValue={(v) => onChannelChange(v as any)}>
<NSpace>
<NRadioButton value="wechat"></NRadioButton>
<NRadioButton value="alipay"></NRadioButton>
</NSpace>
<NRadioGroup
value={createPayload.value.channel}
onUpdateValue={(v) => onChannelChange(v as 'wechat' | 'alipay')}
>
<NRadioButton value="wechat"></NRadioButton>
<NRadioButton value="alipay"></NRadioButton>
</NRadioGroup>
</NFormItem>
<NFormItem label="">
<div style="width: 256px; height: 256px; display:flex; align-items:center; justify-content:center; border: 1px dashed #ececec;">
{qrLink.value ? (
<NQrCode value={qrLink.value} size={220} />
) : (
<span>{updating.value ? '生成中...' : '请选择金额与方式'}</span>
)}
<div class="flex flex-1 justify-center">
<div class="border-1 border-gray-300">
{qrLink.value ? (
<NQrCode value={qrLink.value} size={180} />
) : (
<span>
{updating.value ? '生成中...' : !canCreateOrder.value ? '请输入有效的充值金额' : '请选择金额与方式'}
</span>
)}
</div>
</div>
</NFormItem>
</NForm>

View File

@ -31,6 +31,8 @@ export interface RechargeRecord {
export interface CreateRechargePayload {
amount: number; // 元
channel: 'wechat' | 'alipay';
amountType?: 'preset' | 'custom'; // 金额类型:预设或自定义
customAmount?: number; // 自定义金额
}
export interface RechargeState {

View File

@ -34,7 +34,7 @@ export const useRechargeController = () => {
const openRechargeModal = () => {
openRechargeDialog.value = useModal({
title: '账户充值',
area: '530px',
area: '520px',
component: RechargeDialogContent,
componentProps: {},
footer: false,

View File

@ -0,0 +1,125 @@
import { defineComponent, ref } from 'vue'
import { NCard, NFlex, NButton, NDivider, NIcon, NAlert } from 'naive-ui'
import { CloseOutline, SearchOutline } from '@vicons/ionicons5'
import { useController } from './useController'
import { useApp } from '@/components/layout/useStore'
export default defineComponent({
name: 'DomainTransferView',
setup() {
const { loading, TableComponent, PageComponent, FilterForm, TransferCardList, tableData, openTransferDialog } =
useController()
const { isMobile } = useApp()
// 移动端搜索表单显示状态
const showSearchForm = ref(false)
const toggleSearchForm = () => {
showSearchForm.value = !showSearchForm.value
}
const renderFilterSection = () => (
<>
{isMobile.value ? (
<NFlex vertical class="w-full" size="medium">
<NFlex justify="space-between" align="center">
<NButton type="primary" onClick={() => openTransferDialog()}>
</NButton>
<NButton onClick={toggleSearchForm} class="search-toggle-btn">
{showSearchForm.value ? (
<>
<NIcon size="18">
<CloseOutline />
</NIcon>
<span></span>
</>
) : (
<>
<NIcon size="18">
<SearchOutline />
</NIcon>
<span></span>
</>
)}
</NButton>
</NFlex>
{showSearchForm.value && (
<>
<NDivider class="!my-2" dashed />
<div class="mobile-search-form">
<FilterForm inline={false} />
</div>
</>
)}
</NFlex>
) : (
<NFlex justify="space-between" align="center" class="w-full">
<NFlex class="w-full !flex-row !flex-wrap" size="medium">
<div>
<NButton type="primary" onClick={() => openTransferDialog()}>
</NButton>
<a
class="ml-4 text-[#20a53a] decoration-none hover:text-[#20a53a]-800 text-sm cursor-pointer"
href="https://docs.bt.cn/domain/user-guide/domain-transfer-in"
target="_blank"
rel="noopener noreferrer"
>
</a>
</div>
<FilterForm class="flex-1 justify-end" inline={true} />
</NFlex>
</NFlex>
)}
</>
)
const renderList = () => (
<>
{isMobile.value ? (
<NFlex vertical>
<>
<TransferCardList data={tableData.value?.list || []} loading={loading.value} />
<NFlex justify="center">
<PageComponent />
</NFlex>
</>
</NFlex>
) : (
<>
<TableComponent max-height={`calc(100vh - 560px)`} class="mb-4" />
<NFlex justify="end">
<PageComponent />
</NFlex>
</>
)}
</>
)
return () => (
<div class="flex flex-col justify-between min-h-[calc(100vh-160px)]">
<div class="flex flex-col gap-[16px] p-4">
{renderFilterSection()}
{renderList()}
</div>
<NAlert title="重要提示" type="info" class="mt-4">
<div class="mt-2">
<ul class="list-disc text-sm text-gray-700 leading-relaxed">
<li>
10()45
</li>
<li>ICANN 60</li>
<li>
</li>
<li>
3~7
</li>
</ul>
</div>
</NAlert>
</div>
)
},
})

View File

@ -0,0 +1,446 @@
import { ref } from 'vue'
import { NFlex, NCard, NTag, NButton, NSpace, NTooltip, NIcon } from 'naive-ui'
import { useTable, useForm, useFormHooks } from '@baota/naive-ui/hooks'
import { useTransferJoinState } from './useStore'
import type { DomainTransferItem, DomainTransferListRequest } from '@/types/transfer'
import type { DataTableColumns } from 'naive-ui'
import type { ContactTemplateItem } from '@/types/real-name'
import { formatDate } from '@baota/utils/date'
import { createTransferOrder, cancelTransfer } from '@/api/transfer'
import { fetchContactUserDetail } from '@/api/real-name'
import { useMessage } from '@baota/naive-ui/hooks'
import { useRechargeState } from '@/views/recharge/useStore'
import { useRechargeController } from '@/views/recharge/useController'
import { useModal, useDialog } from '@baota/naive-ui/hooks'
import TransferDialog from '../TransferDialog'
import TransferDetailsDialog from '../TransferDetailsDialog'
import DomainRegistrationForm from '@/views/real-name/components/DomainRegistrationForm/index'
import { queryDomainPrice } from '@/api/domain'
import { InformationCircleOutline } from '@vicons/ionicons5'
const STATUS_TYPE: Record<number, 'default' | 'success' | 'warning' | 'error' | 'info'> = {
0: 'warning', // 申请已提交
1: 'error', // 申请失败
2: 'default', // 取消转入
3: 'error', // 转入失败
4: 'success', // 转入成功
}
const openTransferRef = ref()
const openTransferDetailsRef = ref()
export function useController() {
const { fetchTransferListData, filterFormData } = useTransferJoinState()
const store = useTransferJoinState()
const { useFormInput } = useFormHooks()
const recharge = useRechargeState()
const { loadAccountBalance } = useRechargeController()
const message = useMessage()
/**
*
* @param order
*/
async function handleCancelTransfer(record_id: number) {
useDialog({
type: 'warning',
title: '确认取消',
area: '40',
content: '确定要取消此转入申请吗?此操作不可恢复。',
positiveText: '确定',
negativeText: '取消',
onPositiveClick: async () => {
try {
const { fetch, message } = cancelTransfer({ record_id })
message.value = true
await fetch()
await gFetch()
} catch (error) {
console.error('删除模板失败:', error)
}
},
})
}
// 打开转入对话框(可选预填)
async function openTransferDetails(row: DomainTransferItem) {
openTransferDetailsRef.value = useModal({
title: '失败详情',
area: '600px',
component: TransferDetailsDialog,
componentProps: {
record: row,
close: () => {
openTransferDetailsRef.value?.close()
},
},
footer: false,
})
}
const columns: DataTableColumns<DomainTransferItem> = [
{ title: '域名', key: 'domain', width: 260 },
{
title: '状态',
key: 'status_text',
width: 140,
render: (r: DomainTransferItem) => {
const tagElement = (
<NTag type={STATUS_TYPE[r.status] || 'default'} bordered={false} size="small">
{r.status_text}
</NTag>
)
if (r.status === 0) {
return (
<div class="flex items-center">
{tagElement}
<NTooltip trigger="click">
{{
default: () => (
<span>
<a
class="text-[#20a53a] hover:text-[#20a53a]-800 text-sm underline cursor-pointer ml-1"
href="https://docs.bt.cn/domain/user-guide/domain-transfer-in"
target="_blank"
rel="noopener noreferrer"
>
</a>
</span>
),
trigger: () => (
<NIcon class="flex justify-center cursor-pointer ml-1">
<InformationCircleOutline />
</NIcon>
),
}}
</NTooltip>
</div>
)
}
return tagElement
},
},
{
title: '转入提交时间',
key: 'created_at',
width: 180,
render: (r: DomainTransferItem) => formatDate(r?.created_at || 0, 'yyyy-MM-dd'),
},
{
title: '转入流程结束时间',
key: 'complete_time',
width: 180,
render: (r: DomainTransferItem) => formatDate(r?.complete_time || 0, 'yyyy-MM-dd'),
},
{
title: '操作',
key: 'actions',
width: 120,
align: 'right',
fixed: 'right',
render: (r: DomainTransferItem) => (
<NSpace justify="end">
{r.status === 0 && (
<NButton size="small" ghost onClick={() => handleCancelTransfer(r.id)}>
</NButton>
)}
{(r.status === 1 || r.status === 3) && (
<NButton size="small" ghost onClick={() => openTransferDetails(r)}>
</NButton>
)}
</NSpace>
),
},
]
/**
*
*/
const TransferCardList = defineComponent({
name: 'TransferCardList',
props: {
data: {
type: Array as PropType<DomainTransferItem[]>,
default: () => [],
},
loading: {
type: Boolean,
default: false,
},
},
setup(props) {
return () => (
<NFlex vertical size="medium">
{props.data.map((item: DomainTransferItem) => (
<NCard key={item.id} class="card-shadow" bordered={false}>
<NFlex vertical size="small">
{/* 域名信息 */}
<NFlex align="center" justify="space-between">
<NFlex align="center" size="small">
<div>
<div class="font-medium text-base">{item.domain}</div>
</div>
</NFlex>
<NFlex align="center" size="small">
<NTag type={STATUS_TYPE[item.status] || 'default'} bordered={false} size="small">
{item.status_text}
</NTag>
</NFlex>
</NFlex>
</NFlex>
{/* 时间信息 */}
<NFlex class="text-sm text-gray-600">
<div>
<span class="text-gray-500"></span>
{formatDate(item?.created_at || 0, 'yyyy-MM-dd')}
</div>
</NFlex>
<NFlex class="text-sm text-gray-600">
<div>
<span class="text-gray-500"></span>
{formatDate(item?.complete_time || 0, 'yyyy-MM-dd')}
</div>
</NFlex>
{/* 操作按钮 */}
<NFlex justify="end" size="small">
{item.status === 0 && (
<NButton size="small" ghost onClick={() => handleCancelTransfer(item.id)}>
</NButton>
)}
{(item.status === 1 || item.status === 3) && (
<NButton size="small" ghost onClick={() => openTransferDetails(item)}>
</NButton>
)}
</NFlex>
</NCard>
))}
</NFlex>
)
},
})
const {
TableComponent,
PageComponent,
data: tableData,
loading,
fetch: gFetch,
param,
} = useTable<DomainTransferItem, DomainTransferListRequest>({
config: columns,
request: fetchTransferListData,
defaultValue: filterFormData,
alias: { page: 'p', pageSize: 'rows' },
watchValue: ['p', 'rows'],
})
// 顶部搜索
async function handleSearch() {
await gFetch()
}
// 打开转入对话框(可选预填)
async function openTransferDialog(preset?: { domain?: string }) {
// 先重置,避免覆盖随后加载的实名模板 options
store.resetDialog()
await loadRealNameOptions()
if (preset?.domain) store.rows.value = [{ domain: preset.domain, transfer_code: '' }]
openTransferRef.value = useModal({
title: '域名转入',
area: '720px',
component: TransferDialog,
componentProps: {
refresh: gFetch,
close: () => openTransferRef.value?.close?.(),
},
footer: false,
})
}
function closeTransferDialog() {
openTransferRef.value?.close?.()
}
// 查询域名(仅校验非空 → Step2
async function checkDomains() {
const rows = store.rows.value.map((r) => ({
domain: r.domain.trim().toLowerCase(),
transfer_code: r.transfer_code.trim(),
}))
const hasMissingCode = rows.some((r) => r.domain && !r.transfer_code)
if (hasMissingCode) {
message.warning('转移码不能为空')
return
}
const valid = rows.filter((r) => r.domain && r.transfer_code)
if (valid.length === 0) {
message.warning('请填写至少一条域名与转移码')
return
}
const seen = new Set<string>()
for (const r of valid) {
if (seen.has(r.domain)) {
message.warning('域名已存在')
return
}
seen.add(r.domain)
}
store.setStep(2)
await loadTransferPrice(valid.map((v) => v.domain))
}
async function loadTransferPrice(domains: string[]) {
try {
store.setTransferPriceLoading(true)
const { fetch, data } = queryDomainPrice({ domain: domains.join(','), year: 1, type: 'transfer' } as any)
await fetch()
const results = (data.value as any)?.data?.results || []
const list = Array.isArray(results)
? results.map((it: any) => ({
domain: String(it.domain || ''),
price: Number(it.price || 0),
error: String(it.error || ''),
}))
: []
console.log(list, '--')
store.setTransferPrice(list)
} catch {
store.setTransferPrice([])
} finally {
store.setTransferPriceLoading(false)
}
}
/** 选择实名模板 */
const handleSelectRealName = (val: number) => {
if (val === -1) openCreateRealNameModal()
else store.setSelectedRealNameId(val)
}
/** 创建实名模板窗口(步骤一入口中的快捷按钮) */
const openCreateRealNameModal = () => {
const modal = useModal({
title: '创建实名模板',
area: '1000px',
component: DomainRegistrationForm,
componentProps: {
mode: 'add',
refresh: async () => {
await loadRealNameOptions()
},
},
footer: false,
})
return modal
}
// 优化:加载实名模板并写入 Store失败兜底
async function loadRealNameOptions() {
try {
const { fetch: dFetch, data: dData } = fetchContactUserDetail({ p: 1, rows: 50, status: 2 })
await dFetch()
const payload = dData.value as any
const list: ContactTemplateItem[] = ((payload && payload.msg && payload.msg.data) ||
(payload && payload.data && payload.data.data) ||
[]) as ContactTemplateItem[]
const options = Array.isArray(list)
? list.map((it) => ({ label: it.template_name || it.owner_name || String(it.id), value: it.id }))
: []
store.setRealNameOptions(options)
// 默认选中第一个
store.setSelectedRealNameId(options[0]?.value || null)
} catch {
message.error('加载实名模板失败')
store.setRealNameOptions([])
return []
}
}
// Step2 -> Step3 创建订单
async function createOrder() {
if (!store.selectedTemplateId.value) {
message.warning('请选择实名模板')
return
}
if (!store.agree.value) {
message.warning('请勾选并同意相关协议')
return
}
const payload = {
domain_list: store.rows.value
.map((r) => ({ domain: r.domain.trim(), transfer_code: r.transfer_code.trim() }))
.filter((r) => r.domain && r.transfer_code),
real_name_template_id: store.selectedTemplateId.value as number,
}
const { fetch, data } = createTransferOrder(payload)
await fetch()
if (!data.value?.status) {
message.error(data.value?.msg || '创建订单失败')
return
}
store.orderInfo.value = data.value.data as any
store.setStep(3)
await ensureBalance()
}
// 余额获取
async function ensureBalance() {
try {
await loadAccountBalance()
store.balanceAvailable.value = Number(recharge.overview.value?.balance || 0)
} catch {
store.balanceAvailable.value = 0
}
}
/** 表单配置(列表页顶部搜索) */
const formConfig = () => [
useFormInput(
'',
'keyword',
{ placeholder: '搜索域名', clearable: true, class: 'w-64' },
{ showLabel: false, showFeedback: false },
),
{
type: 'custom' as const,
render: () => (
<NSpace>
<NButton type="primary" onClick={() => handleSearch()}>
</NButton>
</NSpace>
),
},
]
const { component: FilterForm, fetch: formFetchSearch } = useForm<DomainTransferListRequest>({
config: formConfig(),
defaultValue: filterFormData,
request: handleSearch,
})
onMounted(gFetch)
return {
TableComponent,
PageComponent,
loading,
tableData,
FilterForm,
formFetchSearch,
TransferCardList,
// 页面动作
handleSearch,
openTransferDialog,
// 对话框动作
checkDomains,
loadTransferPrice,
createOrder,
handleSelectRealName,
loadRealNameOptions,
closeTransferDialog,
}
}

View File

@ -0,0 +1,173 @@
import { ref, computed } from 'vue'
import { defineStore, storeToRefs } from 'pinia'
import { useMessage } from '@baota/naive-ui/hooks'
import { useError } from '@baota/hooks/error'
import { fetchDomainTransferList } from '@/api/domain'
import type { TableResponse } from '@baota/naive-ui/types/table'
import type { DomainTransferItem, DomainTransferListRequest } from '@/types/transfer'
const message = useMessage()
export const useTransferJoinStore = defineStore('domain-join-store', () => {
const loading = ref(false)
const filterFormData = ref<DomainTransferListRequest>({ p: 1, rows: 10 })
const { handleError } = useError()
// 对话框状态
const transferStep = ref<1 | 2 | 3>(1)
const rows = ref<Array<{ domain: string; transfer_code: string }>>([{ domain: '', transfer_code: '' }])
const selectedTemplateId = ref<number | null>(null)
const agree = ref<boolean>(false)
const payChannel = ref<'balance' | 'wechat' | 'alipay'>('wechat')
const orderInfo = ref<{ order_no: string; total_price: number; wx: string; ali: string } | null>(null)
const balanceAvailable = ref<number>(0)
const realNameOptions = ref<Array<{ label: string; value: number }>>([])
// 单个域名模式的验证状态
const rowValidation = ref<Array<{
domainError: string
transferCodeError: string
}>>([])
// 表单是否有效的计算属性
const isFormValid = computed(() => {
if (rows.value.length === 0) return false
// 检查是否有非空的域名和转移码,且没有验证错误
const hasValidRows = rows.value.some(row => row.domain.trim() && row.transfer_code.trim())
if (!hasValidRows) return false
const hasErrors = rowValidation.value.some((validation, index) => {
if (index >= rows.value.length) return false
const row = rows.value[index]
if (!row) return false
return (row.domain.trim() && validation.domainError) ||
(row.transfer_code.trim() && validation.transferCodeError)
})
return !hasErrors
})
// 价格查询
const transferPriceLoading = ref(false)
const transferPriceList = ref<Array<{ domain: string; price: number; error?: string }>>([])
const transferPriceTotal = ref<number>(0)
const setRealNameOptions = (ops: Array<{ label: string; value: number }>) => {
realNameOptions.value = ops || []
}
const setTransferPrice = (list: Array<{ domain: string; price: number; error?: string }>) => {
transferPriceList.value = Array.isArray(list) ? list : []
transferPriceTotal.value = transferPriceList.value.reduce((s, it) => s + Number(it.price || 0), 0)
}
const setTransferPriceLoading = (v: boolean) => (transferPriceLoading.value = !!v)
const setStep = (s: 1 | 2 | 3) => (transferStep.value = s)
const setSelectedRealNameId = (id: number | null) => (selectedTemplateId.value = id)
const addRow = () => rows.value.push({ domain: '', transfer_code: '' })
const removeRow = (idx: number) => { if (rows.value.length > 1) rows.value.splice(idx, 1) }
const setRowField = (idx: number, key: 'domain' | 'transfer_code', val: string) => {
if (!rows.value[idx]) return
rows.value[idx][key] = val
}
const resetDialog = () => {
transferStep.value = 1
rows.value = [{ domain: '', transfer_code: '' }]
selectedTemplateId.value = null
agree.value = false
payChannel.value = 'wechat'
orderInfo.value = null
realNameOptions.value = []
transferPriceLoading.value = false
transferPriceList.value = []
transferPriceTotal.value = 0
// 重置验证状态
rowValidation.value = []
}
// 验证方法
const validateDomain = (domain: string): string => {
if (!domain.trim()) return '域名不能为空'
const domainRegex = /^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?)*\.[a-zA-Z]{2,}$/
return domainRegex.test(domain.trim()) ? '' : '域名格式不正确请输入有效的域名格式example.com'
}
const validateTransferCode = (code: string): string => {
if (!code.trim()) return '转移码不能为空'
if (code.trim().length < 6) return '转移码长度不能少于6位'
return ''
}
// 验证单个输入行
const validateRow = (index: number, field: 'domain' | 'transfer_code', value: string) => {
// 确保验证数组有足够的长度
while (rowValidation.value.length <= index) {
rowValidation.value.push({ domainError: '', transferCodeError: '' })
}
const validation = rowValidation.value[index]
if (!validation) return
if (field === 'domain') {
validation.domainError = validateDomain(value)
} else {
validation.transferCodeError = validateTransferCode(value)
}
}
const fetchTransferListData = async <T = DomainTransferItem,>(
params: DomainTransferListRequest = {},
): Promise<TableResponse<T>> => {
try {
loading.value = true
const { fetch, data } = fetchDomainTransferList(params)
await fetch()
const payload = data.value?.data
return { list: (payload?.list as unknown as T[]) || [], total: Number(payload?.total || 0) }
} catch (e) {
handleError(e)
message.error('加载转入列表失败')
return { list: [] as unknown as T[], total: 0 }
} finally {
loading.value = false
}
}
return {
loading,
filterFormData,
fetchTransferListData,
// 对话框状态与方法
transferStep,
rows,
selectedTemplateId,
setSelectedRealNameId,
agree,
payChannel,
orderInfo,
balanceAvailable,
realNameOptions,
transferPriceLoading,
transferPriceList,
transferPriceTotal,
setRealNameOptions,
setTransferPrice,
setTransferPriceLoading,
setStep,
addRow,
removeRow,
setRowField,
resetDialog,
// 验证相关
rowValidation,
isFormValid,
validateDomain,
validateTransferCode,
validateRow,
}
})
export const useTransferJoinState = () => {
const store = useTransferJoinStore()
return { ...store, ...storeToRefs(store) }
}

View File

@ -1,7 +1,7 @@
import { defineComponent, computed, onUnmounted } from 'vue'
import { NButton, NFlex, NGrid, NGridItem, NInput, NInputGroup, NSteps, NStep, NSelect, NCheckbox, NRadioButton, NRadioGroup, NTag, NQrCode, NDynamicInput, NAlert, NTooltip, NIcon } from 'naive-ui'
import { useTransferState } from '../useStore'
import { useController } from '../useController'
import { useTransferJoinState } from './JoinIn/useStore'
import { useController } from './JoinIn/useController'
import { queryPaymentStatus, buyByBalance } from '@/api/order'
import { useMessage } from '@baota/naive-ui/hooks'
import { InformationCircleOutline } from '@vicons/ionicons5'
@ -22,7 +22,7 @@ export default defineComponent({
},
},
setup(props) {
const state = useTransferState()
const state = useTransferJoinState()
const { checkDomains, createOrder, handleSelectRealName, loadRealNameOptions } = useController()
const message = useMessage()
const qrLink = computed(() => state.payChannel.value === 'wechat' ? state.orderInfo.value?.wx : state.orderInfo.value?.ali)

View File

@ -1,119 +1,41 @@
import { defineComponent, ref } from 'vue'
import { NCard, NFlex, NButton, NDivider, NIcon, NAlert } from 'naive-ui'
import { CloseOutline, SearchOutline } from '@vicons/ionicons5'
/**
*
*/
import { defineComponent, defineAsyncComponent } from 'vue'
import { NTabs, NTabPane, NCard } from 'naive-ui'
import { useController } from './useController'
import { useApp } from '@/components/layout/useStore'
import type{ DomainTransferTabKey } from './types'
/**
*
*/
export default defineComponent({
name: 'DomainTransferView',
setup() {
const { TableComponent, PageComponent, FilterForm, openTransferDialog, formFetchSearch } = useController()
const { isMobile } = useApp()
const JoinIn = defineAsyncComponent(() => import('./components/JoinIn'))
// 移动端搜索表单显示状态
const showSearchForm = ref(false)
const toggleSearchForm = () => {
showSearchForm.value = !showSearchForm.value
}
// 获取控制器
const { loading, activeTab, switchTab } = useController()
const renderFilterSection = () => (
<NCard class="card-shadow" bordered={false}>
{isMobile.value ? (
<NFlex vertical class="w-full" size="medium">
<NFlex justify="space-between" align="center">
<NButton type="primary" onClick={() => openTransferDialog()}>
</NButton>
<NButton onClick={toggleSearchForm} class="search-toggle-btn">
{showSearchForm.value ? (
<>
<NIcon size="18">
<CloseOutline />
</NIcon>
<span></span>
</>
) : (
<>
<NIcon size="18">
<SearchOutline />
</NIcon>
<span></span>
</>
)}
</NButton>
</NFlex>
{showSearchForm.value && (
<>
<NDivider class="!my-2" dashed />
<div class="mobile-search-form">
<FilterForm inline={false} />
</div>
</>
)}
</NFlex>
) : (
<NFlex justify="space-between" align="center" class="w-full">
<NFlex class="w-full !flex-row !flex-wrap" size="medium">
<div>
<NButton type="primary" onClick={() => openTransferDialog()}>
</NButton>
<a
class="ml-4 text-[#20a53a] decoration-none hover:text-[#20a53a]-800 text-sm cursor-pointer"
href="https://docs.bt.cn/domain/user-guide/domain-transfer-in"
target="_blank"
rel="noopener noreferrer"
>
</a>
</div>
<FilterForm class="flex-1 justify-end" inline={true} />
</NFlex>
</NFlex>
)}
</NCard>
)
const renderList = () => (
<>
{isMobile.value ? (
<NFlex vertical>
<NCard class="card-shadow" bordered={false}>
<TableComponent />
<NFlex justify="center">
<PageComponent />
</NFlex>
</NCard>
</NFlex>
) : (
<NCard class="card-shadow" bordered={false}>
<TableComponent max-height={`calc(100vh - 560px)`} class="mb-4" />
<NFlex justify="end">
<PageComponent />
</NFlex>
</NCard>
)}
</>
)
onMounted(() => {formFetchSearch()})
return () => (
<div class="flex flex-col justify-between min-h-[calc(100vh-160px)]">
<div class="flex flex-col gap-[16px]">
{renderFilterSection()}
{renderList()}
</div>
<NAlert title="重要提示" type="info" class="mt-4">
<div class="mt-2">
<ul class="list-disc text-sm text-gray-700 leading-relaxed">
<li>10()45</li>
<li>ICANN 60</li>
<li></li>
<li> 3~7</li>
</ul>
</div>
</NAlert>
<div class="domain-transfer-container">
<NCard class="card-shadow" bordered={false}>
{/* 标签页导航区 */}
<NTabs
value={activeTab.value}
onUpdateValue={(val: DomainTransferTabKey) => switchTab(val)}
type="line"
animated
class="mb-4"
>
<NTabPane name="join" tab="域名转入">
<JoinIn/>
</NTabPane>
<NTabPane name="level" tab="域名转出">
</NTabPane>
</NTabs>
</NCard>
</div>
)
},
})
})

View File

@ -0,0 +1,4 @@
/**
*
*/
export type DomainTransferTabKey = 'join' | 'level'

View File

@ -1,361 +1,43 @@
import { ref } from 'vue'
import { NTag, NButton, NSpace, NTooltip, NIcon } from 'naive-ui'
import { useTable, useForm, useFormHooks } from '@baota/naive-ui/hooks'
import { useTransferState } from './useStore'
import type { DomainTransferItem, DomainTransferListRequest } from '@/types/transfer'
import type { DataTableColumns } from 'naive-ui'
import type { ContactTemplateItem } from '@/types/real-name'
import { formatDate } from '@baota/utils/date'
import { createTransferOrder, cancelTransfer } from '@/api/transfer'
import { fetchContactUserDetail } from '@/api/real-name'
import { useMessage } from '@baota/naive-ui/hooks'
import { useRechargeState } from '@/views/recharge/useStore'
import { useRechargeController } from '@/views/recharge/useController'
import { useModal, useDialog } from '@baota/naive-ui/hooks'
import TransferDialog from './components/TransferDialog'
import TransferDetailsDialog from './components/TransferDetailsDialog'
import DomainRegistrationForm from '@/views/real-name/components/DomainRegistrationForm/index'
import { queryDomainPrice } from '@/api/domain'
import { InformationCircleOutline } from '@vicons/ionicons5'
/**
*
*
*/
const STATUS_TYPE: Record<number, 'default' | 'success' | 'warning' | 'error' | 'info'> = {
0: 'warning', // 申请已提交
1: 'error', // 申请失败
2: 'default', // 取消转入
3: 'error', // 转入失败
4: 'success', // 转入成功
}
import { ref, onMounted } from 'vue'
import { useDomainTransferStore } from './useStore'
import type { DomainTransferTabKey } from './types.d'
const openTransferRef = ref()
const openTransferDetailsRef = ref()
/**
*
*/
export function useController() {
const { fetchTransferListData, filterFormData } = useTransferState()
const store = useTransferState()
const { useFormInput } = useFormHooks()
const recharge = useRechargeState()
const { loadAccountBalance } = useRechargeController()
const message = useMessage()
// 获取状态管理
const { loading } = useDomainTransferStore()
// 当前激活的标签页
const activeTab = ref<DomainTransferTabKey>('join')
/**
*
* @param order
*
* @param tab
*/
async function handleCancelTransfer(record_id: number) {
useDialog({
type: 'warning',
title: '确认取消',
area: '40',
content: '确定要取消此转入申请吗?此操作不可恢复。',
positiveText: '确定',
negativeText: '取消',
onPositiveClick: async () => {
try {
const { fetch, message } = cancelTransfer({ record_id })
message.value = true
await fetch()
await gFetch()
} catch (error) {
console.error('删除模板失败:', error)
}
},
})
}
// 打开转入对话框(可选预填)
async function openTransferDetails(row: DomainTransferItem) {
openTransferDetailsRef.value = useModal({
title: '失败详情',
area: '600px',
component: TransferDetailsDialog,
componentProps: {
record: row,
close: () => {
openTransferDetailsRef.value?.close()
},
},
footer: false,
})
const switchTab = (tab: DomainTransferTabKey) => {
activeTab.value = tab
}
const columns: DataTableColumns<DomainTransferItem> = [
{ title: '域名', key: 'domain', width: 260 },
{
title: '状态',
key: 'status_text',
width: 140,
render: (r: DomainTransferItem) => {
const tagElement = (
<NTag type={STATUS_TYPE[r.status] || 'default'} bordered={false} size="small">
{r.status_text}
</NTag>
)
if (r.status === 0) {
return (
<div class="flex items-center">
{tagElement}
<NTooltip trigger="click">
{{
default: () => (
<span>
<a
class="text-[#20a53a] hover:text-[#20a53a]-800 text-sm underline cursor-pointer ml-1"
href="https://docs.bt.cn/domain/user-guide/domain-transfer-in"
target="_blank"
rel="noopener noreferrer"
>
</a>
</span>
),
trigger: () => (
<NIcon class="flex justify-center cursor-pointer ml-1">
<InformationCircleOutline />
</NIcon>
),
}}
</NTooltip>
</div>
)
}
return tagElement
},
},
{
title: '转入提交时间',
key: 'created_at',
render: (r: DomainTransferItem) => formatDate(r?.created_at || 0, 'yyyy-MM-dd'),
},
{
title: '转入流程结束时间',
key: 'complete_time',
render: (r: DomainTransferItem) => formatDate(r?.complete_time || 0, 'yyyy-MM-dd'),
},
{
title: '操作',
key: 'actions',
width: 120,
align: 'right',
fixed: 'right',
render: (r: DomainTransferItem) => (
<NSpace justify="end">
{r.status === 0 && (
<NButton size="small" ghost onClick={() => handleCancelTransfer(r.id)}>
</NButton>
)}
{(r.status === 1 || r.status === 3) && (
<NButton size="small" ghost onClick={() => openTransferDetails(r)}>
</NButton>
)}
</NSpace>
),
},
]
const {
TableComponent,
PageComponent,
data,
loading,
fetch: gFetch,
param,
} = useTable<DomainTransferItem, DomainTransferListRequest>({
config: columns,
request: fetchTransferListData,
defaultValue: filterFormData,
alias: { page: 'p', pageSize: 'rows' },
watchValue: ['p', 'rows', 'keyword'],
// 组件挂载时获取域名详情
onMounted(() => {
})
// 顶部搜索
async function handleSearch() {
await gFetch()
}
// 打开转入对话框(可选预填)
async function openTransferDialog(preset?: { domain?: string }) {
// 先重置,避免覆盖随后加载的实名模板 options
store.resetDialog()
await loadRealNameOptions()
if (preset?.domain) store.rows.value = [{ domain: preset.domain, transfer_code: '' }]
openTransferRef.value = useModal({
title: '域名转入',
area: '720px',
component: TransferDialog,
componentProps: {
refresh: gFetch,
close: () => openTransferRef.value?.close?.(),
},
footer: false,
})
}
function closeTransferDialog() {
openTransferRef.value?.close?.()
}
// 查询域名(仅校验非空 → Step2
async function checkDomains() {
const rows = store.rows.value.map(r => ({ domain: r.domain.trim().toLowerCase(), transfer_code: r.transfer_code.trim() }))
const hasMissingCode = rows.some(r => r.domain && !r.transfer_code)
if (hasMissingCode) { message.warning('转移码不能为空'); return }
const valid = rows.filter(r => r.domain && r.transfer_code)
if (valid.length === 0) { message.warning('请填写至少一条域名与转移码'); return }
const seen = new Set<string>()
for (const r of valid) { if (seen.has(r.domain)) { message.warning('域名已存在'); return } seen.add(r.domain) }
store.setStep(2)
await loadTransferPrice(valid.map(v => v.domain))
}
async function loadTransferPrice(domains: string[]) {
try {
store.setTransferPriceLoading(true)
const { fetch, data } = queryDomainPrice({ domain: domains.join(','), year: 1, type: 'transfer' } as any)
await fetch()
const results = (data.value as any)?.data?.results || []
const list = Array.isArray(results)
? results.map((it: any) => ({ domain: String(it.domain || ''), price: Number(it.price || 0), error: String(it.error || '') }))
: []
console.log(list,'--');
store.setTransferPrice(list)
} catch {
store.setTransferPrice([])
} finally {
store.setTransferPriceLoading(false)
}
}
/** 选择实名模板 */
const handleSelectRealName = (val: number) => {
if (val === -1) openCreateRealNameModal()
else store.setSelectedRealNameId(val)
}
/** 创建实名模板窗口(步骤一入口中的快捷按钮) */
const openCreateRealNameModal = () => {
const modal = useModal({
title: '创建实名模板',
area: '1000px',
component: DomainRegistrationForm,
componentProps: {
mode: 'add',
refresh: async () => {
await loadRealNameOptions()
},
},
footer: false,
})
return modal
}
// 优化:加载实名模板并写入 Store失败兜底
async function loadRealNameOptions() {
try {
const { fetch: dFetch, data: dData } = fetchContactUserDetail({ p: 1, rows: 50,status:2 })
await dFetch()
const payload = dData.value as any
const list: ContactTemplateItem[] = ((payload && payload.msg && payload.msg.data) ||
(payload && payload.data && payload.data.data) ||
[]) as ContactTemplateItem[]
const options = Array.isArray(list)
? list.map((it) => ({ label: it.template_name || it.owner_name || String(it.id), value: it.id }))
: []
store.setRealNameOptions(options)
// 默认选中第一个
store.setSelectedRealNameId(options[0]?.value || null)
} catch {
message.error('加载实名模板失败')
store.setRealNameOptions([])
return []
}
}
// Step2 -> Step3 创建订单
async function createOrder() {
if (!store.selectedTemplateId.value) {
message.warning('请选择实名模板')
return
}
if (!store.agree.value) {
message.warning('请勾选并同意相关协议')
return
}
const payload = {
domain_list: store.rows.value
.map((r) => ({ domain: r.domain.trim(), transfer_code: r.transfer_code.trim() }))
.filter((r) => r.domain && r.transfer_code),
real_name_template_id: store.selectedTemplateId.value as number,
}
const { fetch, data } = createTransferOrder(payload)
await fetch()
if (!data.value?.status) {
message.error(data.value?.msg || '创建订单失败')
return
}
store.orderInfo.value = data.value.data as any
store.setStep(3)
await ensureBalance()
}
// 余额获取
async function ensureBalance() {
try {
await loadAccountBalance()
store.balanceAvailable.value = Number(recharge.overview.value?.balance || 0)
} catch {
store.balanceAvailable.value = 0
}
}
/** 表单配置(列表页顶部搜索) */
const formConfig = () => [
useFormInput(
'',
'keyword',
{ placeholder: '搜索域名', clearable: true, class: 'w-64' },
{ showLabel: false, showFeedback: false },
),
{
type: 'custom' as const,
render: () => (
<NSpace>
<NButton type="primary" onClick={() => handleSearch()}>
</NButton>
</NSpace>
),
},
]
const { component: FilterForm, fetch: formFetchSearch } = useForm<DomainTransferListRequest>({
config: formConfig(),
defaultValue: filterFormData,
request: handleSearch,
})
return {
TableComponent,
PageComponent,
// 状态
loading,
data,
FilterForm,
formFetchSearch,
// 页面动作
handleSearch,
openTransferDialog,
// 对话框动作
checkDomains,
loadTransferPrice,
createOrder,
handleSelectRealName,
loadRealNameOptions,
closeTransferDialog,
activeTab,
switchTab,
// 方法
// 工具
}
}
}

View File

@ -1,173 +1,32 @@
import { ref, computed } from 'vue'
import { defineStore, storeToRefs } from 'pinia'
import { useMessage } from '@baota/naive-ui/hooks'
import { useError } from '@baota/hooks/error'
import { fetchDomainTransferList } from '@/api/domain'
import type { TableResponse } from '@baota/naive-ui/types/table'
import type { DomainTransferItem, DomainTransferListRequest } from '@/types/transfer'
/**
*
*
*/
const message = useMessage()
import { useError } from "@baota/hooks/error";
export const useTransferStore = defineStore('domain-transfer-store', () => {
const { handleError } = useError();
/**
* Store
*/
export const useDomainTransferStore = defineStore("domain-transfer-store", () => {
// -------------------- 状态定义 --------------------
/** 页面加载状态 */
const loading = ref(false)
const filterFormData = ref<DomainTransferListRequest>({ p: 1, rows: 10 })
const { handleError } = useError()
// 对话框状态
const transferStep = ref<1 | 2 | 3>(1)
const rows = ref<Array<{ domain: string; transfer_code: string }>>([{ domain: '', transfer_code: '' }])
const selectedTemplateId = ref<number | null>(null)
const agree = ref<boolean>(false)
const payChannel = ref<'balance' | 'wechat' | 'alipay'>('wechat')
const orderInfo = ref<{ order_no: string; total_price: number; wx: string; ali: string } | null>(null)
const balanceAvailable = ref<number>(0)
const realNameOptions = ref<Array<{ label: string; value: number }>>([])
// 单个域名模式的验证状态
const rowValidation = ref<Array<{
domainError: string
transferCodeError: string
}>>([])
// 表单是否有效的计算属性
const isFormValid = computed(() => {
if (rows.value.length === 0) return false
// 检查是否有非空的域名和转移码,且没有验证错误
const hasValidRows = rows.value.some(row => row.domain.trim() && row.transfer_code.trim())
if (!hasValidRows) return false
const hasErrors = rowValidation.value.some((validation, index) => {
if (index >= rows.value.length) return false
const row = rows.value[index]
if (!row) return false
return (row.domain.trim() && validation.domainError) ||
(row.transfer_code.trim() && validation.transferCodeError)
})
return !hasErrors
})
// 价格查询
const transferPriceLoading = ref(false)
const transferPriceList = ref<Array<{ domain: string; price: number; error?: string }>>([])
const transferPriceTotal = ref<number>(0)
const setRealNameOptions = (ops: Array<{ label: string; value: number }>) => {
realNameOptions.value = ops || []
}
const setTransferPrice = (list: Array<{ domain: string; price: number; error?: string }>) => {
transferPriceList.value = Array.isArray(list) ? list : []
transferPriceTotal.value = transferPriceList.value.reduce((s, it) => s + Number(it.price || 0), 0)
}
const setTransferPriceLoading = (v: boolean) => (transferPriceLoading.value = !!v)
const setStep = (s: 1 | 2 | 3) => (transferStep.value = s)
const setSelectedRealNameId = (id: number | null) => (selectedTemplateId.value = id)
const addRow = () => rows.value.push({ domain: '', transfer_code: '' })
const removeRow = (idx: number) => { if (rows.value.length > 1) rows.value.splice(idx, 1) }
const setRowField = (idx: number, key: 'domain' | 'transfer_code', val: string) => {
if (!rows.value[idx]) return
rows.value[idx][key] = val
}
const resetDialog = () => {
transferStep.value = 1
rows.value = [{ domain: '', transfer_code: '' }]
selectedTemplateId.value = null
agree.value = false
payChannel.value = 'wechat'
orderInfo.value = null
realNameOptions.value = []
transferPriceLoading.value = false
transferPriceList.value = []
transferPriceTotal.value = 0
// 重置验证状态
rowValidation.value = []
}
// 验证方法
const validateDomain = (domain: string): string => {
if (!domain.trim()) return '域名不能为空'
const domainRegex = /^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?)*\.[a-zA-Z]{2,}$/
return domainRegex.test(domain.trim()) ? '' : '域名格式不正确请输入有效的域名格式example.com'
}
const validateTransferCode = (code: string): string => {
if (!code.trim()) return '转移码不能为空'
if (code.trim().length < 6) return '转移码长度不能少于6位'
return ''
}
// 验证单个输入行
const validateRow = (index: number, field: 'domain' | 'transfer_code', value: string) => {
// 确保验证数组有足够的长度
while (rowValidation.value.length <= index) {
rowValidation.value.push({ domainError: '', transferCodeError: '' })
}
const validation = rowValidation.value[index]
if (!validation) return
if (field === 'domain') {
validation.domainError = validateDomain(value)
} else {
validation.transferCodeError = validateTransferCode(value)
}
}
const fetchTransferListData = async <T = DomainTransferItem,>(
params: DomainTransferListRequest = {},
): Promise<TableResponse<T>> => {
try {
loading.value = true
const { fetch, data } = fetchDomainTransferList(params)
await fetch()
const payload = data.value?.data
return { list: (payload?.list as unknown as T[]) || [], total: Number(payload?.total || 0) }
} catch (e) {
handleError(e)
message.error('加载转入列表失败')
return { list: [] as unknown as T[], total: 0 }
} finally {
loading.value = false
}
}
// -------------------- 方法定义 --------------------
return {
// 状态
loading,
filterFormData,
fetchTransferListData,
// 对话框状态与方法
transferStep,
rows,
selectedTemplateId,
setSelectedRealNameId,
agree,
payChannel,
orderInfo,
balanceAvailable,
realNameOptions,
transferPriceLoading,
transferPriceList,
transferPriceTotal,
setRealNameOptions,
setTransferPrice,
setTransferPriceLoading,
setStep,
addRow,
removeRow,
setRowField,
resetDialog,
// 验证相关
rowValidation,
isFormValid,
validateDomain,
validateTransferCode,
validateRow,
// 方法
}
})
});
export const useTransferState = () => {
const store = useTransferStore()
return { ...store, ...storeToRefs(store) }
}
export const useDomainDetailState = () => {
const store = useDomainTransferStore()
return { ...store, ...storeToRefs(store) };
};

View File

@ -29,5 +29,5 @@
"src/**/*.js",
"src/**/*.jsx",
"src/**/*.d.ts"
]
, "src/views/domain-security/index.tsx" ]
}

View File

@ -144,16 +144,16 @@ export default defineConfig({
},
fontSize: {
// Naive UI 兼容的字体大小
xs: ['0.75rem', { lineHeight: '1rem' }],
sm: ['0.875rem', { lineHeight: '1.25rem' }],
base: ['1rem', { lineHeight: '1.5rem' }],
lg: ['1.125rem', { lineHeight: '1.75rem' }],
xl: ['1.25rem', { lineHeight: '1.75rem' }],
'2xl': ['1.5rem', { lineHeight: '2rem' }],
'3xl': ['1.875rem', { lineHeight: '2.25rem' }],
'4xl': ['2.25rem', { lineHeight: '2.5rem' }],
'5xl': ['3rem', { lineHeight: '1' }],
'6xl': ['3.75rem', { lineHeight: '1' }],
xs: ['0.75rem', { 'line-height': '1rem' }],
sm: ['0.875rem', { 'line-height': '1.25rem' }],
base: ['1rem', { 'line-height': '1.5rem' }],
lg: ['1.125rem', { 'line-height': '1.75rem' }],
xl: ['1.25rem', { 'line-height': '1.75rem' }],
'2xl': ['1.5rem', { 'line-height': '2rem' }],
'3xl': ['1.875rem', { 'line-height': '2.25rem' }],
'4xl': ['2.25rem', { 'line-height': '2.5rem' }],
'5xl': ['3rem', { 'line-height': '1' }],
'6xl': ['3.75rem', { 'line-height': '1' }],
},
spacing: {
// Naive UI 兼容的间距系统

View File

@ -28,6 +28,21 @@
<div class="w-full lg:w-3/4">
<!-- 域名结果列表 -->
<section class="bg-white rounded-sm shadow-sm">
<!-- Tab切换区域 - 紧凑版 -->
<div class="domain-search-tabs">
<div class="tab-buttons flex">
<button id="tab-normal" class="tab-btn active">
域名注册
</button>
<button id="tab-ai" class="tab-btn">
<i class="fa fa-search"></i>AI域名推荐
<span class="new-badge">new</span>
</button>
</div>
</div>
<!-- 域名注册搜索面板 -->
<div id="normal-search-panel" class="search-panel">
<div class="flex w-full mb-2">
<!-- 搜索框和按钮 -->
<div id="search-section" class="flex flex-col sm:flex-row gap-3 w-full">
@ -37,6 +52,54 @@
<i class="fa fa-times-circle clear-input-button visible" id="clear-input-button" title="清空输入"></i>
</div>
<button class="primary-action-button text-lg">重新查询</button>
</div>
</div>
</div>
<!-- AI推荐搜索面板 -->
<div id="ai-search-panel" class="search-panel hidden">
<!-- AI推荐搜索框和按钮 -->
<div class="flex w-full mb-2">
<!-- 整体渐变容器 -->
<div class="ai-search-container">
<div class="ai-search-inner">
<form autocomplete="off">
<div id="ai-search-section" class="flex flex-col lg:flex-row gap-3 w-full">
<!-- 品牌名称输入框 -->
<div class="ai-input-group flex-1">
<i class="ai-input-icon fa fa-building"></i>
<input
type="text"
id="brand-name-input"
class="ai-search-input"
placeholder="请输入品牌/公司/个人/产品名称"
autocomplete="off"
spellcheck="false"
>
</div>
<!-- 行业信息输入框 -->
<div class="ai-input-group flex-1">
<i class="ai-input-icon fa fa-industry"></i>
<input
type="text"
id="industry-input"
class="ai-search-input"
placeholder="请输入行业信息"
autocomplete="off"
spellcheck="false"
>
</div>
<!-- AI推荐按钮 -->
<button type="button" id="ai-recommend-btn" class="ai-transparent-button">
<i class="ai-button-icon fa fa-search"></i>
<span class="ai-button-text">AI推荐</span>
</button>
</div>
</form>
</div>
</div>
</div>
</div>
<!-- 筛选器 -->
@ -185,7 +248,9 @@
{{#end if}}
<div class="flex items-center justify-between p-4 gap-3">
<div class="flex items-center">
<div class="font-bold text-sm mr-4 {{#if item.isRegistered}}line-through text-gray-400{{#end if}}">{{item.domain}}</div>
<div class="text-sm mr-4">
<div class="font-bold {{#if item.isRegistered}}line-through text-gray-400{{#end if}}">
{{item.domain}}
{{#if item.isRegistered}}
<div class="status-badge text-gray-600 border-gray-800">{{item.statusText}}</div>
{{#end if}}
@ -201,6 +266,14 @@
{{#if item.hasNew}}
<div class="status-badge status-badge-new">新人特惠</div>
{{#end if}}
</div>
{{#if item.hasAiMeaning}}
<!-- AI推荐域名意义解释 -->
<div class="domain-meaning text-gray-400">
AI解读{{item.meaning}}
</div>
{{#end if}}
</div>
</div>
{{#if item.isRegistered}}
<!-- 已注册显示查看WHOIS链接 -->
@ -212,13 +285,11 @@
<div class="flex items-center gap-6 {{#if item.isRegistered}}!hidden{{/if}}">
<div class="text-right relative price-tooltip-container">
<div class="flex items-center justify-end price-trigger cursor-pointer">
{{#if item.hasOriginalPrice}}
<span class="text-secondary-60 line-through mr-2 text-xs">{{item.formattedOriginalPrice}}</span>
{{#end if}}
<span class="text-sm text-orange-500">{{item.formattedPrice}}元/首年</span>
<i class="fa fa-caret-down text-gray-400 ml-1 hover:text-primary transition-colors"></i>
</div>
<div class="text-xs text-secondary-60 mr-4">续费{{item.formattedOriginalPrice}}/年</div>
<div class="text-xs text-secondary-60 mr-4">续费{{item.renewPrice}}/年</div>
<!-- 价格悬浮窗 -->
<div class="price-tooltip absolute right-0 top-full bg-white rounded-lg shadow-xl border px-4 py-4 opacity-0 invisible transition-all duration-200 ease-out transform translate-y-1" style="z-index: 9999">
@ -324,7 +395,7 @@
<!-- Modal内容模板 -->
<script type="text/template" id="modal-content-template">
<div class="bg-white rounded-sm {{sizeClass}} w-full transform transition-all scale-95 opacity-0 modal-content">
<div class="bg-white rounded-sm {{sizeClass}} w-full transform transition-all scale-95">
<div class="flex justify-between items-center p-5 border-b border-gray-100">
<h3 class="text-xl font-bold text-dark">{{title}}</h3>
{{#if closable}}
@ -980,150 +1051,6 @@
</div>
</script>
<!-- WHOIS查询模态窗口 -->
<script type="text/template" id="whois-modal-template">
<div class="whois-modal">
<div class="flex flex-col">
{{#if isLoading}}
<div class="text-center py-12">
<div class="inline-block animate-spin rounded-full h-12 w-12 border-b-2 border-primary mb-4"></div>
<p class="text-gray-600">正在查询WHOIS信息...</p>
</div>
{{#end if}}
{{#if hasError}}
<div class="text-center py-12">
<div class="text-red-500 text-6xl mb-4">
<i class="fa fa-exclamation-triangle"></i>
</div>
<h4 class="text-xl font-semibold text-gray-800 mb-2">查询失败</h4>
<p class="text-gray-600">{{errorMessage}}</p>
</div>
{{#end if}}
{{#if hasData}}
<!-- Tab 切换导航 -->
<div class="whois-tab-container flex">
<button class="whois-tab-btn active" data-tab="raw">
<i class="fa fa-code mr-2"></i>原始数据
</button>
<button class="whois-tab-btn" data-tab="optimized">
<i class="fa fa-eye mr-2"></i>优化视图
</button>
</div>
<!-- 原始数据视图 -->
<div id="whois-raw-view" class="whois-tab-content active">
<div class="bg-gray-900 text-green-400 p-4 font-mono text-sm overflow-auto h-140">
<pre class="whitespace-pre-wrap">{{rawData}}</pre>
</div>
</div>
<!-- 优化视图内容 -->
<div id="whois-optimized-view" class="whois-tab-content hidden">
<!-- 域名概览卡片 -->
<div class="whois-overview-card">
<div class="whois-domain-header">
<h3 class="whois-domain-name">{{domainName}}</h3>
{{#if status}}
<span class="whois-status-badge {{statusClass}}">{{status}}</span>
{{#end if}}
</div>
{{#if registrar}}
<div class="whois-registrar-info">
<i class="fa fa-building"></i>
<span>注册商:{{registrar}}</span>
</div>
{{#end if}}
</div>
<!-- 信息卡片网格 -->
<div class="whois-info-grid">
<!-- 时间信息卡片 -->
<div class="whois-info-card">
<div class="whois-card-header">
<i class="fa fa-clock-o whois-card-icon"></i>
<h4 class="whois-card-title">时间信息</h4>
</div>
<div class="whois-card-content">
{{#if creationDate}}
<div class="whois-info-item">
<span class="whois-info-label">注册时间</span>
<span class="whois-info-value">{{creationDate}}</span>
</div>
{{#end if}}
{{#if expirationDate}}
<div class="whois-info-item">
<span class="whois-info-label">到期时间</span>
<span class="whois-info-value">{{expirationDate}}</span>
</div>
{{#end if}}
{{#if updatedDate}}
<div class="whois-info-item">
<span class="whois-info-label">更新时间</span>
<span class="whois-info-value">{{updatedDate}}</span>
</div>
{{#end if}}
</div>
</div>
<!-- 注册信息卡片 -->
<div class="whois-info-card">
<div class="whois-card-header">
<i class="fa fa-user whois-card-icon"></i>
<h4 class="whois-card-title">注册信息</h4>
</div>
<div class="whois-card-content">
{{#if registrant}}
<div class="whois-info-item">
<span class="whois-info-label">注册人</span>
<span class="whois-info-value">{{registrant}}</span>
</div>
{{#end if}}
{{#if registrarName}}
<div class="whois-info-item">
<span class="whois-info-label">注册商</span>
<span class="whois-info-value">{{registrarName}}</span>
</div>
{{#end if}}
{{#if emails}}
<div class="whois-info-item">
<span class="whois-info-label">联系邮箱</span>
<span class="whois-info-value">{{emails}}</span>
</div>
{{#end if}}
</div>
</div>
</div>
<!-- DNS信息卡片 -->
{{#if nameServers}}
<div class="whois-info-card whois-dns-card">
<div class="whois-card-header">
<i class="fa fa-server whois-card-icon"></i>
<h4 class="whois-card-title">DNS信息</h4>
</div>
<div class="whois-card-content">
<div class="whois-info-item">
<span class="whois-info-label">域名服务器</span>
<div class="whois-dns-servers">
{{#each nameServers as server,index}}
<span class="whois-dns-server">{{server}}</span>
{{#end each}}
</div>
</div>
</div>
</div>
{{#end if}}
</div>
</div>
{{#end if}}
</div>
</div>
</script>
<!-- 域名注册协议模态窗口 -->
<script type="text/template" id="domain-agreement-modal-template">
<div class="domain-agreement-modal">

View File

@ -0,0 +1,82 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>域名注册信息 (WHOIS) 查询 - 堡塔域名注册</title>
<link href="/font-awesome.min.css" rel="stylesheet" />
<link href="src/styles/whois.css" rel="stylesheet" />
</head>
<body>
<div class="font-sans bg-white text-secondary">
<!-- 搜索区域 -->
<section class="pt-28 pb-20 md:pt-24 md:pb-28 bg-gradient-to-b">
<div class="container mx-auto px-4">
<div class="mx-auto text-center">
<h1 class="text-clamp font-bold leading-tight text-dark mb-6 animate-fade-in">
堡塔 <span class="text-primary">(WHOIS)</span>查询工具
</h1>
<p class="text-lg text-secondary-80 mb-10 max-w-2xl mx-auto">
查询任意域名的注册信息、所有者、到期时间等详细信息
</p>
<!-- 搜索框 -->
<div class="max-w-3xl mx-auto">
<div class="flex flex-col sm:flex-row gap-4">
<div class="input-container">
<input type="text" placeholder="请输入要查询的域名example.com"
class="search-input" id="whois-domain-input" />
<i class="fa fa-times-circle clear-input-button" id="whois-clear-input" title="清空输入"></i>
</div>
<div class="flex gap-4">
<button id="whois-search-btn"
class="primary-action-button text-lg hover:shadow-primary-30 hover:-translate-y-1">
<i class="fa fa-search mr-2"></i>立即查询
</button>
</div>
</div>
<div class="mt-2 text-left"><a class="text-primary hover:text-primary" style="text-decoration: none;" href="/new/domain-register">域名注册 | .com低至53.9, .cn低至19.9元</a></div>
</div>
</div>
</div>
</section>
<!-- 结果展示区域 -->
<section class="py-20 bg-gray-50 hidden" id="whois-results-section">
<div class="container mx-auto px-4">
<div id="whois-content-area">
<!-- 动态内容将在这里渲染 -->
</div>
</div>
</section>
<!-- 加载状态 -->
<section class="py-20 bg-gray-50 hidden" id="whois-loading-section">
<div class="container mx-auto px-4">
<div class="text-center py-12">
<div class="inline-block animate-spin rounded-full w-12 h-12 border-b-2 border-primary mb-4"></div>
<p class="text-gray-600 text-lg">正在查询 WHOIS 信息...</p>
<p class="text-gray-500 text-sm mt-2">请稍等,通常需要几秒钟时间</p>
</div>
</div>
</section>
<!-- 错误状态 -->
<section class="py-20 bg-gray-50 hidden" id="whois-error-section">
<div class="container mx-auto px-4">
<div class="text-center py-12 max-w-2xl mx-auto">
<h4 class="text-xl font-semibold text-gray-800 mb-2">查询失败</h4>
<p class="text-gray-600 mb-6" id="whois-error-message">域名查询出现问题,请稍后重试</p>
</div>
</div>
</section>
</div>
<!-- JavaScript -->
<script type="text/javascript" src="/jquery-3.6.0.min.js"></script>
<script type="module" src="src/pages/whois.ts"></script>
</body>
</html>

View File

@ -14,19 +14,35 @@
<div class="container mx-auto px-4">
<div class=" mx-auto text-center">
<h1 class="text-[clamp(2rem,5vw,3.5rem)] font-bold leading-tight text-dark mb-6 animate-fade-in">
堡塔<span class="text-primary">域名注册</span>重磅上线!<br />一站式搞定建站访问
低价域名,就选<span class="text-primary">宝塔域名</span>
</h1>
<p class="text-[clamp(1rem,2vw,1.25rem)] text-secondary-80 mb-10 max-w-2xl mx-auto">
.com低至53.9元,.cn低至19.9元部分后缀新人9.9元注册
</p>
<!-- 搜索框和按钮 -->
<div id="search-section" class="flex flex-col sm:flex-row max-w-3xl mx-auto gap-4 pb-20">
<!-- Tab功能区域 -->
<div class="max-w-3xl mx-auto" style="padding-bottom: 8px;">
<!-- Tab切换按钮 -->
<div class="domain-search-tabs">
<div class="tab-buttons flex">
<button id="tab-normal-index" class="tab-btn active">
域名注册
</button>
<button id="tab-ai-index" class="tab-btn">
<i class="fa fa-search"></i>AI域名推荐
<span class="new-badge">new</span>
</button>
</div>
</div>
<!-- 域名注册面板 -->
<div id="normal-search-panel-index" class="search-panel">
<div id="search-section" class="flex flex-col sm:flex-row gap-4">
<div class="input-container">
<input type="text" placeholder="输入您想注册的域名yourbrand" class="search-input" id="domain-query-input" />
<i class="fa fa-times-circle clear-input-button" id="clear-input-button" title="清空输入"></i>
<div class="mt-2 text-left"><a class="text-primary hover:text-primary" style="text-decoration: none;" href="/domain/domain/transfer">域名转入 .cn地址29.9元</a></div>
<div class="mt-2 text-left"><a class="text-primary hover:text-primary" style="text-decoration: none;" href="/domain/domain/transfer">域名转入 .cn低至29.9元</a></div>
</div>
<div class="flex gap-4">
<div class="flex gap-4 relative">
<a id="domain-query-button"
class="primary-action-button text-lg hover:shadow-primary-30 hover:-translate-y-1">立即查询</a>
<a id="cart-button"
@ -35,6 +51,55 @@
<i class="fa fa-shopping-cart mr-2"></i>
<span>购物车</span>
</a>
<a class="absolute btlink" href="https://qm.qq.com/q/fxbto4wZkk" target="_blank" rel="noopener" style="text-decoration: none;bottom: 0;right: 0;">加入QQ群</a>
</div>
</div>
</div>
<!-- AI推荐面板 -->
<div id="ai-search-panel-index" class="search-panel hidden">
<div class="flex w-full">
<!-- 整体渐变容器 -->
<div class="ai-search-container">
<div class="ai-search-inner">
<form autocomplete="off">
<div class="flex flex-col lg:flex-row gap-3 w-full">
<!-- 品牌名称输入框 -->
<div class="ai-input-group flex-1">
<i class="ai-input-icon fa fa-building"></i>
<input
type="text"
id="brand-name-input-index"
class="ai-search-input"
placeholder="请输入品牌/公司/个人/产品名称"
autocomplete="off"
spellcheck="false"
>
</div>
<!-- 行业信息输入框 -->
<div class="ai-input-group flex-1">
<i class="ai-input-icon fa fa-search"></i>
<input
type="text"
id="industry-input-index"
class="ai-search-input"
placeholder="请输入行业信息"
autocomplete="off"
spellcheck="false"
>
</div>
<!-- AI推荐按钮 -->
<button type="button" id="ai-recommend-btn-index" class="ai-transparent-button">
<i class="ai-button-icon fa fa-search"></i>
<span class="ai-button-text">AI推荐</span>
</button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
@ -53,7 +118,7 @@
</div>
<div class="rule-item flex items-center gap-2">
<i class="fa fa-ban text-primary"></i>
<span>每个账号仅限参与1次</span>
<span>每个账号一个实名仅限参与1次</span>
</div>
<div class="rule-item flex items-center gap-2">
<i class="fa fa-ticket text-primary"></i>
@ -73,7 +138,7 @@
</div>
<div class="product-content">
<h3 class="product-name">域名注册 0.01元秒杀</h3>
<p class="product-desc">.top/.icu/.xyz/.cyou后缀每日限量100个名额每日上午10:00准时开枪</p>
<p class="product-desc">.icu/.xyz/.cyou后缀每日限量100个名额每日上午10:00准时开枪</p>
</div>
</div>
@ -141,55 +206,111 @@
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 组队领取top优惠券区域 -->
<div id="team-coupon" class="activity-container act-super_discount pb-[100px]" style="scroll-margin-top: 100px;">
<div class="title-container">
<div class="title-container-title">组队领取0.01元.top及超低价.com/.cn购买资格</div>
<div class="title-container-desc">
满4人即可享受超低价首年购数量有限先到先得数量领完则活动截止
</div>
</div>
<div class="container">
<div class="activity-simulated-table opt-super_discount w-container">
<div class="table-header">
<div class="table-header-item">
<div class="header-tr">
<div
class="table-header-th table-left border-left-[1px] border-top-[1px] bg-white">
</div>
<div
class="table-header-th !h-[60px] bg-[#E9F6EB] border-left-[2px] border-top-[2px] bg-white">
<div class="title-desc">超值优惠</div>
<div class="title">
<span>队长达标价格(首年)</span>
<span class="tips">最划算</span>
</div>
</div>
<div
class="table-header-th !h-[60px] bg-[#E9F6EB] border-left-[2px] border-top-[2px] bg-white">
<div class="title">
<span>队员达标价格(首年)</span>
</div>
</div>
<div class="table-header-th border-top-[1px] bg-white">
<div class="table-daily-price">
<div class="title">日常价格 (首年)</div>
</div>
</div>
</div>
</div>
</div>
<div class="table-body">
</div>
</div>
<a href="javascript:void(0)" id="initiate-team-btn" class="initiate-team-btn !w-[400px]" style="margin-top:20px">
<div class="team-icon"></div>
<span>发起组队</span>
</a>
<div class="action-activity-desc">
* 优惠资格有效期4人组队成功后开始计时72小时内有效逾期未使用则自动失效可再次组队领取。
<br>
* 使用限制每个用户实名只能使用1次优惠资格不可折现、不可转赠。
</div>
</div>
</div>
<!-- 价格表格 -->
<!-- 价格表格 -->
<div class="mx-auto max-w-[900px]">
<div class="text-center mb-8">
<h2 class="text-clamp font-bold text-dark mb-4" style="margin-top: 80px">域名价格一览表</h2>
<div class="text-center mb-8">
<h2 class="text-clamp font-bold text-dark mb-4" style="margin-top: 120px">域名价格一览表</h2>
<div class="text-secondary-80 max-w-4xl mx-auto px-4">
透明的价格体系,无隐藏费用,让您明明白白消费,更多服务请<a class="border-b border-dashed border-primary text-secondary-80" href="https://qm.qq.com/q/fxbto4wZkk" target="_blank" rel="noopener" style="text-decoration: none;">加入QQ群</a>或者<span
class="contact-service-trigger cursor-pointer border-b border-dashed border-primary relative">请联系客服咨询
<!-- 二维码悬浮层 -->
<div
class="qr-code-popup absolute bottom-full left-1/2 transform -translate-x-1/2 mb-2 opacity-0 invisible transition-all duration-300 z-50">
<div class="bg-white rounded-lg shadow-xl p-4 border border-gray-200">
<div class="text-center mb-2">
<p class="text-sm font-medium text-gray-700">扫码联系客服</p>
</div>
<!-- 二维码SVG -->
<img src="https://www.bt.cn/Public/new/images/wechat-qrcode.png" alt="二维码"
class="w-24 h-24 mx-auto" />
<div class="text-center mt-2">
<p class="text-xs text-gray-500">微信客服</p>
</div>
<!-- 小箭头 -->
<div
class="absolute top-full left-1/2 transform -translate-x-1/2 w-0 h-0 border-l-4 border-r-4 border-t-4 border-transparent border-t-white">
</div>
<!-- 二维码悬浮层 -->
<div
class="qr-code-popup absolute bottom-full left-1/2 transform -translate-x-1/2 mb-2 opacity-0 invisible transition-all duration-300 z-50">
<div class="bg-white rounded-lg shadow-xl p-4 border border-gray-200">
<div class="text-center mb-2">
<p class="text-sm font-medium text-gray-700">扫码联系客服</p>
</div>
<!-- 二维码SVG -->
<img src="https://www.bt.cn/Public/new/images/wechat-qrcode.png" alt="二维码"
class="w-24 h-24 mx-auto" />
<div class="text-center mt-2">
<p class="text-xs text-gray-500">微信客服</p>
</div>
<!-- 小箭头 -->
<div
class="absolute top-full left-1/2 transform -translate-x-1/2 w-0 h-0 border-l-4 border-r-4 border-t-4 border-transparent border-t-white">
</div>
</div>
</span>
</div>
</div>
<div class="overflow-x-auto px-4 md:px-0">
<!-- 现代化价格表格 -->
<div class="modern-price-table bg-white overflow-hidden shadow-lg">
<!-- 表头 -->
<div class="price-table-header bg-gradient-to-r from-primary to-green-600 text-white py-4 px-4">
<div class="grid grid-cols-4 gap-4 text-center">
<div class="text-sm md:text-base font-semibold">域名后缀</div>
<div class="text-sm md:text-base font-semibold">首年价格</div>
<div class="text-sm md:text-base font-semibold">续费价格</div>
<div class="text-sm md:text-base font-semibold">转入续费</div>
</div>
</div>
<!-- 表格内容 -->
<div class="price-table-body" id="price-table-body">
<!-- 动态生成的价格表格内容 -->
</span>
</div>
</div>
<div class="overflow-x-auto px-4 md:px-0">
<!-- 现代化价格表格 -->
<div class="modern-price-table bg-white overflow-hidden shadow-lg">
<!-- 表头 -->
<div class="price-table-header bg-gradient-to-r from-primary to-green-600 text-white py-4 px-4">
<div class="grid grid-cols-4 gap-4 text-center">
<div class="text-sm md:text-base font-semibold">域名后缀</div>
<div class="text-sm md:text-base font-semibold">首年价格</div>
<div class="text-sm md:text-base font-semibold">续费价格</div>
<div class="text-sm md:text-base font-semibold">转入续费</div>
</div>
</div>
<!-- 表格内容 -->
<div class="price-table-body" id="price-table-body">
<!-- 动态生成的价格表格内容 -->
</div>
</div>
</div>
@ -590,10 +711,170 @@
</div>
</div>
</script>
<script type="text/template" id="team-rule-modal-template">
<div>
<div class="rule-item">
<div class="flex items-center gap-x-3 mb-4">
<img src="/static/new/images/724/user-team.svg" />
<h3 class="text-2xl font-bold">组队规则</h3>
</div>
<div class="pl-12 text-[#666] leading-loose">
<p><span class="font-bold text-black">组队时间:</span>即日起 - 9月30日逾期未完成组队视为自动放弃参与资格</p>
<p><span class="font-bold text-black">活动对象:</span>所有宝塔平台注册用户均可参与本次组队活动</p>
<p><span class="font-bold text-black">发起组队:</span>用户发起组队,无限制条件,新老用户均可参加,完成指定人数,领取对应的优惠资格。.top后缀4.9元组队活动与0.01元组队活动仅可参与一个,如需更换请
<span class="cursor-pointer border-b border-dashed border-primary relative rule-customer">
联系客服
</span></p>
<p><span
class="font-bold text-black">组队邀请:</span>发起者通过活动页面生成专属邀请链接或二维码,邀请好友加入组队,被邀请用户需在活动时间内点击链接或扫描二维码并完成平台注册(已注册用户直接加入),才算成功加入组队。
</p>
<p><span
class="font-bold text-black">组队成功:</span>达到所需组队人数后,系统自动判定组队成功,若在活动时间截止时,组队人数未达标,则该组队失败,队员无法获得优惠资格。
</p>
</div>
</div>
<div class="rule-item" style="margin-top: 15px;">
<div class="flex items-center gap-x-3 mb-4">
<img src="/static/new/images/724/coupon.svg" />
<h3 class="text-2xl font-bold">优惠使用规则</h3>
</div>
<div class="pl-12 text-[#666] leading-loose">
<p><span class="font-bold text-black">优惠资格有效期:</span>4人组队成功后开始计时72小时内有效逾期未使用则自动失效可再次组队领取。</p>
<p><span class="font-bold text-black">使用限制:</span>0.01元购买资格仅用于.xyz/.icu/.cyou后缀且仅可以使用1次优惠资格不可折现、不可转赠。
</p>
</div>
</div>
</div>
</script>
<script type="text/template" id="team-modal-template">
<div class="team-modal-container">
<div class="team-modal-header">
<h2 class="team-modal-title">确认发起组队</h2>
<!-- <p class="team-modal-subtitle">组队成功后将根据最终人数自动发放对应的购买资格</p> -->
</div>
<div class="team-modal-details">
<!-- <h4>优惠详情</h4> -->
<div class="team-modal-table">
<div class="team-modal-table-header">
<div class="team-modal-table-cell" style="text-align: left;">参与活动的域名后缀</div>
<div class="team-modal-table-cell">队长价格 (首年)</div>
<div class="team-modal-table-cell">队员价格 (首年)</div>
</div>
<div class="team-modal-table-body">
</div>
</div>
</div>
<div class="team-modal-notes">
<h4>重要说明</h4>
<ol>
<li>每个账户只能参与1次组队参与过队长/队员的,无法再次参与其他组队。</li>
<li>4人组队成功后开始计时72小时内有效逾期未使用则自动失效可再次组队领取。</li>
<li>每个用户实名只能使用1次优惠资格不可折现、不可转赠。</li>
</ol>
</div>
<div class="team-modal-footer">
<button class="btn btn-cancel">取消</button>
<button class="btn btn-confirm">确认发起</button>
</div>
</div>
</script>
<script type="text/template" id="my-team-modal-template">
<div class="my-team-container">
<div class="team-modal-header">
<h2 class="team-modal-title">我的队伍 (1人)</h2>
<p class="team-modal-subtitle">继续邀请好友, 获得购买资格哦</p>
</div>
<div class="my-team-body">
<div class="current-tier-section">
<div class="tier-content-wrapper">
<div class="tier-card">
<div class="tier-info">
<p id="my-team-milestone-info">当前 <strong>1</strong> 人,再邀请 <strong>2</strong> 人即可达标 <strong>【3人组队目标】</strong><span class="btn-quit refresh-team-icon"></span></p>
<p class="tier-description" id="my-team-milestone-desc">3人组队目标将享受单域名78元优惠, 通配符588元优惠</p>
</div>
<!-- The inline styles for the progress bar are kept here as they are dynamically updated by JavaScript -->
<div class="tier-progress-bar" id="my-team-progress-bar">
<div class="progress-line" style="width: 0%;"></div>
<div class="progress-milestone active" style="left: 10px" data-member="1">
<div class="progress-milestone-icon no-icon"></div>
<span>1人</span>
</div>
<div class="progress-milestone" style="left: 33.3%;" data-member="3">
<div class="progress-milestone-icon">
<img src="/static/new/images/724/discount.svg" alt="gift">
</div>
<span>2人</span>
</div>
<div class="progress-milestone" style="left: 66.6%;" data-member="5">
<div class="progress-milestone-icon">
<img src="/static/new/images/724/discount.svg" alt="gift">
</div>
<span>3人</span>
</div>
<div class="progress-milestone" style="right:-20px" data-member="10">
<div class="progress-milestone-icon">
<img src="/static/new/images/724/discount.svg" alt="gift">
</div>
<span>4人</span>
</div>
</div>
<div class="tier-members" id="my-team-members">
<div class="member-item">
<div class="member-avatar leader">
<img src="/static/new/images/724/avatar.svg" alt="队长">
</div>
<span class="member-label">队长</span>
</div>
<div class="member-item add-member-item">
<div class="add-member"></div>
<span class="member-label">添加成员</span>
</div>
</div>
</div>
<div class="tier-status" id="my-team-status">
<div class="status-icon"></div>
<span>未达标</span>
<p>继续邀请好友加入</p>
</div>
</div>
</div>
<div class="team-modal-body my-team-share-section">
<div id="claimed-coupons-section" class="claimed-coupons-section">
<h4>已解锁的购买资格(已自动发放)</h4>
<ul id="claimed-coupons-list" class="coupon-list"></ul>
</div>
<div id="my-team-qr-section" class="team-qr-code-section">
<h4>微信扫码参与组队</h4>
<div id="my-team-qr-code"></div>
<p>使用微信扫码上方二维码即可快速加入队伍</p>
</div>
</div>
</div>
<div class="team-modal-footer">
<!-- <button class="btn btn-quit">退出组队</button> -->
<button class="btn btn-invite">邀请好友加入</button>
</div>
</div>
</script>
</div>
<!-- 组队模态框 -->
<div id="custom-modal" class="modal-overlay">
<div class="modal-content">
<button class="modal-close" type="button" title="关闭"></button>
<div class="modal-body">
</div>
</div>
</div>
<!-- JavaScript -->
<script type="text/javascript" src="/jquery-3.6.0.min.js"></script>
<script type="text/javascript" src="https://www.bt.cn/Public/js/qrcode.min.js"></script>
<script type="text/javascript" src="https://www.bt.cn/Public/js/clipboard.min.js?2.2"></script>
<script type="module" src="src/pages/index.ts"></script>
</body>

View File

@ -4,6 +4,8 @@ import type { ApiResponse } from "../types/api";
import type {
DomainQueryCheckRequest,
DomainQueryCheckResponseData,
AiDomainQueryCheckRequest,
AiDomainQueryCheckResponseData
} from "../types/api-types/domain-query-check";
import type {
ContactGetUserDetailRequest,
@ -56,6 +58,17 @@ export function domainQueryCheck(
headers
);
}
// AI -域名查询
export function aiDomainQueryCheck(
data: AiDomainQueryCheckRequest,
headers?: Record<string, string>,
): Promise<ApiResponse<AiDomainQueryCheckResponseData>> {
return api.post<AiDomainQueryCheckResponseData>(
"/v1/domain/recommend/recommend_domains",
data,
headers,
);
}
// 获取实名信息模板列表
export function getContactUserDetail(

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,455 @@
/**
* @fileoverview WHOIS
* @description WHOIS API
*/
import { queryWhois } from "@api/landing";
// 安全的jQuery访问函数
function safe$() {
return (window as any).$ as any;
}
// 页面状态管理
let currentDomain = '';
let isInitialized = false;
/**
*
*/
function init() {
if (isInitialized) {
return;
}
bindEvents();
handleUrlParams();
isInitialized = true;
}
/**
*
*/
function bindEvents() {
const $ = safe$();
if (!$) return;
// 搜索按钮点击事件
$(document).on('click', '#whois-search-btn', handleSearch);
// 输入框回车事件
$(document).on('keypress', '#whois-domain-input', (e: any) => {
if (e.which === 13) {
handleSearch();
}
});
// 清空输入按钮
$(document).on('click', '#whois-clear-input', () => {
$('#whois-domain-input').val('').trigger('input');
});
// 输入框变化事件
$(document).on('input', '#whois-domain-input', (e: any) => {
const value = $(e.target).val() as string;
const $clearBtn = $('#whois-clear-input');
if (value && value.trim()) {
$clearBtn.addClass('visible');
} else {
$clearBtn.removeClass('visible');
}
});
}
/**
* URL
*/
function handleUrlParams() {
const urlParams = new URLSearchParams(window.location.search);
const domain = urlParams.get('domain');
const $ = safe$();
if (!$) return;
if (domain) {
$('#whois-domain-input').val(domain).trigger('input');
performSearch(domain);
}
}
/**
*
*/
function isValidDomain(domain: string): boolean {
const domainRegex = /^[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?)*$/;
return domainRegex.test(domain.trim());
}
/**
*
*/
function handleSearch() {
const $ = safe$();
if (!$) return;
const domain = ($('#whois-domain-input').val() as string).trim();
if (!domain) {
alert('请输入要查询的域名');
return;
}
if (!isValidDomain(domain)) {
alert('请输入正确的域名格式');
return;
}
performSearch(domain);
}
/**
* WHOIS
*/
async function performSearch(domain: string) {
currentDomain = domain;
showLoading();
try {
const response = await queryWhois(domain);
// const response = {
// code: 0,
// status: true,
// msg: "查询成功",
// data: {
// address: [
// "3th Floor, BangNing Technology Park, 2 YuHua Avenue",
// "Yuhuatai District",
// "Nanjing Jiangsu",
// "China",
// "3th Floor, BangNing Technology Park, 2 YuHua Avenue",
// "Yuhuatai District",
// "Nanjing Jiangsu",
// "China",
// "3th Floor, BangNing Technology Park, 2 YuHua Avenue",
// "Yuhuatai District",
// "Nanjing Jiangsu",
// "China",
// ],
// city: "",
// country: "CN",
// creation_date: "2014-07-24 10:45:35",
// dnssec: "signed",
// domain_name: "top",
// emails: ["support@nic.top", "tech@nic.top"],
// expiration_date: "",
// name: "Sven Chen",
// name_servers: [
// "A.ZDNSCLOUD.CN 203.99.24.1",
// "B.ZDNSCLOUD.CN 203.99.25.1",
// "C.ZDNSCLOUD.COM 203.99.26.1",
// "D.ZDNSCLOUD.COM 203.99.27.1",
// "F.ZDNSCLOUD.CN 116.169.54.111",
// "G.ZDNSCLOUD.COM 223.72.199.37",
// "I.ZDNSCLOUD.CN 2401:8d00:1:0:0:0:0:1",
// "J.ZDNSCLOUD.COM 2401:8d00:2:0:0:0:0:1",
// ],
// org: "Jiangsu Bangning Science & technology Co.,Ltd.",
// phone: [
// "+86 18936016161",
// "Fax: +86 2586883476",
// "+86 15895978960",
// "Fax: +86 02586883476",
// ],
// referral_url: "whois.nic.top",
// registrant_postal_code: "",
// registrar: "",
// registrar_url: "",
// reseller: null,
// state: "",
// status: "ACTIVE",
// updated_date: "2025-07-29 10:45:35",
// whois_server: "whois.nic.top",
// rawData:
// "% IANA WHOIS server\n% for more information on IANA, visit http://www.iana.org\n% This query returned 1 object\n\nrefer: whois.nic.top\n\ndomain: TOP\n\norganisation: .TOP Registry\naddress: 3th Floor, BangNing Technology Park, 2 YuHua Avenue\naddress: Yuhuatai District\naddress: Nanjing Jiangsu\naddress: China\n\ncontact: administrative\nname: Sven Chen\norganisation: Jiangsu Bangning Science & technology Co.,Ltd.\naddress: 3th Floor, BangNing Technology Park, 2 YuHua Avenue\naddress: Yuhuatai District\naddress: Nanjing Jiangsu\naddress: China\nphone: +86 18936016161\nfax-no: +86 2586883476\ne-mail: support@nic.top\n\ncontact: technical\nname: YiFeng Shen\norganisation: Jiangsu Bangning Science & technology Co.,Ltd.\naddress: 3th Floor, BangNing Technology Park, 2 YuHua Avenue\naddress: Yuhuatai District\naddress: Nanjing Jiangsu\naddress: China\nphone: +86 15895978960\nfax-no: +86 02586883476\ne-mail: tech@nic.top\n\nnserver: A.ZDNSCLOUD.CN 203.99.24.1\nnserver: B.ZDNSCLOUD.CN 203.99.25.1\nnserver: C.ZDNSCLOUD.COM 203.99.26.1\nnserver: D.ZDNSCLOUD.COM 203.99.27.1\nnserver: F.ZDNSCLOUD.CN 116.169.54.111\nnserver: G.ZDNSCLOUD.COM 223.72.199.37\nnserver: I.ZDNSCLOUD.CN 2401:8d00:1:0:0:0:0:1\nnserver: J.ZDNSCLOUD.COM 2401:8d00:2:0:0:0:0:1\nds-rdata: 26780 8 2 5d6e7869ee8e3b536a617de89482ddd1dcb9db9dbb1ac33d6ed351e2ca095b1b\n\nwhois: whois.nic.top\n\nstatus: ACTIVE\nremarks: Registration information: http://www.nic.top\n\ncreated: 2014-07-24\nchanged: 2025-07-29\nsource: IANA",
// },
// };
if (response.status && response.data) {
renderResults(domain, response.data);
showResults();
updatePageState(domain);
} else {
throw new Error('查询失败');
}
} catch (error: any) {
console.error('WHOIS查询失败:', error);
showError(error.message || '查询失败,请稍后重试');
}
}
/**
*
*/
function renderResults(domain: string, data: any) {
console.log(domain,'-21212');
const $ = safe$();
if (!$) return;
const currentTime = new Date().toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
});
const resultsHtml = generateSimpleLayout(domain, data, currentTime);
$('#whois-content-area').html(resultsHtml);
}
/**
*
*/
function generateSimpleLayout(domain: string, data: any, currentTime: string): string {
const domainName = domain;
return `
<div class="whois-simple-layout">
<!-- -->
<div class="whois-header">
<h2 class="whois-title">
<span class="title-decoration"></span>
<span class="domain-name">${domainName}</span>
</h2>
<div class="whois-meta">
<span>${currentTime}</span>
<a href="#" class="refresh-link" onclick="performSearch('${domain}')"></a>
</div>
</div>
<!-- -->
<div class="whois-table">
${generateWhoisRows(data)}
</div>
<!-- -->
<div class="whois-raw-data-section">
<div class="raw-data-content">
<div class="raw-data-title">
<span class="title-decoration"></span>
<span>${domainName} WHOIS</span>
</div>
<div class="raw-data-box">${formatRawData(data.rawData || '暂无原始数据')}</div>
</div>
</div>
</div>
`;
}
/**
* WHOIS
*/
function generateWhoisRows(data: any): string {
// 处理邮箱数据
const emails = Array.isArray(data.emails)
? data.emails.join(", ")
: data.emails || "请联系当前域名注册商获取";
// 处理域名服务器数据
const nameServers = Array.isArray(data.name_servers)
? data.name_servers
: data.name_servers ? [data.name_servers] : [];
// 处理状态
const status = data.status || "未知状态";
let statusBadgeClass = "status-unknown";
if (status.includes("ACTIVE") || status.includes("ok")) {
statusBadgeClass = "status-active";
} else if (status.includes("Transfer")) {
statusBadgeClass = "status-transfer";
}
const rows = [
{
labelCn: "域名所有者",
labelEn: "Registrant",
value: data.name || "请联系当前域名注册商获取",
},
{
labelCn: "所有者邮箱",
labelEn: "Registrant Email",
value: emails,
},
{
labelCn: "注册商",
labelEn: "Registrar",
value:
data.registrar ||
data.org ||
"Guizhou Zhongyu Zhike Network Technology Co., Ltd.",
},
{
labelCn: "注册时间",
labelEn: "Registration Date",
value:
formatDateTime(data.creation_date) || "2020-10-24 16:00:12 (北京时间)",
},
{
labelCn: "到期时间",
labelEn: "Expiration Date",
value:
formatDateTime(data.expiration_date) ||
"2026-10-24 16:00:12 (北京时间)",
extraInfo: data.expiration_date
? `
<div style="margin-top: 8px; font-size: 12px; color: #666; line-height: 1.4;">
· <br>
· 使
</div>
`
: "",
},
{
labelCn: "域名状态",
labelEn: "Domain Status",
value: `
<div class="whois-status-value">
<span>${status}</span>
<span class="whois-status-badge-simple ${statusBadgeClass}">(${status === 'ACTIVE'?'正常':status})</span>
</div>
`,
},
];
// 如果有DNS服务器添加DNS信息行
if (nameServers.length > 0) {
rows.push({
labelCn: 'DNS 服务器',
labelEn: 'Name Server',
value: `
<div class="whois-value-multiple">
${nameServers.map((server: string) => `<div class="whois-dns-item">${server}</div>`).join('')}
</div>
`
});
}
return rows.map(row => `
<div class="whois-row">
<div class="whois-label">
<div class="label-cn">${row.labelCn}</div>
<div class="label-en">${row.labelEn}</div>
</div>
<div class="whois-value">
${row.value}
${(row as any).extraInfo || ''}
</div>
</div>
`).join('');
}
/**
*
*/
function formatDateTime(dateString: string | null | undefined): string | null {
if (!dateString) return null;
try {
const date = new Date(dateString);
return date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
timeZoneName: 'short'
}).replace('GMT+8', '北京时间');
} catch {
return dateString;
}
}
/**
*
*/
function formatRawData(rawData: string): string {
if (!rawData || rawData === '暂无原始数据') {
return rawData;
}
// 确保原始数据保持原有格式,只是清理多余的空行
return rawData.trim();
}
/**
*
*/
function showLoading() {
const $ = safe$();
if (!$) return;
$('#whois-results-section').addClass('hidden');
$('#whois-error-section').addClass('hidden');
$('#whois-loading-section').removeClass('hidden');
}
/**
*
*/
function showResults() {
const $ = safe$();
if (!$) return;
$('#whois-loading-section').addClass('hidden');
$('#whois-error-section').addClass('hidden');
$('#whois-results-section').removeClass('hidden');
}
/**
*
*/
function showError(message: string) {
const $ = safe$();
if (!$) return;
$('#whois-error-message').text(message);
$('#whois-loading-section').addClass('hidden');
$('#whois-results-section').addClass('hidden');
$('#whois-error-section').removeClass('hidden');
}
/**
* URL
*/
function updatePageState(domain: string) {
document.title = `${domain} - WHOIS 查询结果 - 堡塔域名注册`;
const newUrl = `${window.location.pathname}?domain=${encodeURIComponent(domain)}`;
window.history.replaceState({ domain }, '', newUrl);
}
/**
*
*/
(window as any).performSearch = performSearch;
// 页面初始化逻辑
function safeInit() {
// 确保DOM和jQuery都已准备就绪
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
// DOM已经准备就绪
if (safe$()) {
// jQuery也已加载
init();
} else {
// 等待jQuery加载
window.addEventListener('load', init);
}
}
}
// 开始初始化
safeInit();

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,860 @@
/**
* WHOIS
*
*/
/* =========================== */
/* CSS变量定义 (复用主项目变量) */
/* =========================== */
:root {
/* 主色调 */
--primary: #20a53a;
--secondary: #363636;
--light: #fff;
--dark: #1a1a1a;
--success: #00b42a;
--danger: #f53f3f;
/* 透明度变体 */
--primary-5: rgba(32, 165, 58, 0.05);
--primary-10: rgba(32, 165, 58, 0.1);
--primary-90: rgba(32, 165, 58, 0.9);
--secondary-40: rgba(54, 54, 54, 0.4);
--secondary-70: rgba(54, 54, 54, 0.7);
--secondary-80: rgba(54, 54, 54, 0.8);
}
/* =========================== */
/* 基础样式重置 */
/* =========================== */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Inter', system-ui, sans-serif;
background-color: var(--light);
color: var(--secondary);
line-height: 1.6;
}
button {
outline: none;
background-color: transparent;
border: none;
}
/* =========================== */
/* 布局工具类 */
/* =========================== */
.flex { display: flex; }
.flex-col { flex-direction: column; }
.flex-row { flex-direction: row; }
.items-center { align-items: center; }
.justify-center { justify-content: center; }
.justify-between { justify-content: space-between; }
.text-center { text-align: center; }
.text-left { text-align: left; }
.text-right { text-align: right; }
.relative { position: relative; }
.absolute { position: absolute; }
.inline-block { display: inline-block; }
.block { display: block; }
.hidden { display: none !important; }
.w-full { width: 100%; }
.h-full { height: 100%; }
.max-w-2xl { max-width: 42rem; }
.mx-auto { margin-left: auto; margin-right: auto; }
.gap-4 { gap: 1rem; }
/* =========================== */
/* 间距工具类 */
/* =========================== */
.py-12 { padding-top: 3rem; padding-bottom: 3rem; }
.py-20 { padding-top: 5rem; padding-bottom: 5rem; }
.px-4 { padding-left: 1rem; padding-right: 1rem; }
.p-4 { padding: 1rem; }
.mb-2 { margin-bottom: 0.5rem; }
.mb-4 { margin-bottom: 1rem; }
.mb-6 { margin-bottom: 1.5rem; }
.mb-10 { margin-bottom: 2.5rem; }
.mt-2 { margin-top: 0.5rem; }
.mt-6 { margin-top: 1.5rem; }
.mt-8 { margin-top: 2rem; }
.mr-2 { margin-right: 0.5rem; }
.ml-auto { margin-left: auto; }
/* =========================== */
/* 字体大小 */
/* =========================== */
.text-sm { font-size: 0.875rem; line-height: 1.25rem; }
.text-base { font-size: 1rem; line-height: 1.5rem; }
.text-lg { font-size: 1.125rem; line-height: 1.75rem; }
.text-xl { font-size: 1.25rem; line-height: 1.75rem; }
.text-2xl { font-size: 1.5rem; line-height: 2rem; }
.text-6xl { font-size: 3.75rem; line-height: 1; }
.text-clamp { font-size: clamp(1.5rem, 3vw, 2.5rem); }
/* =========================== */
/* 字体权重 */
/* =========================== */
.font-medium { font-weight: 500; }
.font-semibold { font-weight: 600; }
.font-bold { font-weight: 700; }
.font-mono { font-family: Monaco, Menlo, 'Ubuntu Mono', monospace; }
/* =========================== */
/* 颜色工具类 */
/* =========================== */
.text-primary { color: var(--primary); }
.text-dark { color: var(--dark); }
.text-secondary { color: var(--secondary); }
.text-secondary-70 { color: var(--secondary-70); }
.text-secondary-80 { color: var(--secondary-80); }
.text-white { color: white; }
.text-gray-500 { color: #6b7280; }
.text-gray-600 { color: #4b5563; }
.text-gray-700 { color: #374151; }
.text-gray-800 { color: #1f2937; }
.text-red-500 { color: #ef4444; }
.text-green-400 { color: #4ade80; }
.bg-white { background-color: white; }
.bg-gray-50 { background-color: #f9fafb; }
.bg-gray-100 { background-color: #f3f4f6; }
.bg-gray-800 { background-color: #1f2937; }
.bg-gray-900 { background-color: #111827; }
.bg-green-100 { background-color: #dcfce7; }
.bg-green-800 { background-color: #166534; }
.bg-yellow-100 { background-color: #fef3c7; }
.bg-yellow-800 { color: #ffffff; background-color: #92400e; }
/* =========================== */
/* 边框和圆角 */
/* =========================== */
.border { border-width: 1px; }
.border-b { border-bottom-width: 1px; }
.border-gray-200 { border-color: #e5e7eb; }
.border-dashed { border-style: dashed; }
.border-primary { border-color: var(--primary); }
.border-b-2 { border-bottom-width: 2px; }
.border-2 { border-width: 2px; }
.rounded { border-radius: 0.25rem; }
.rounded-lg { border-radius: 0.5rem; }
.rounded-xl { border-radius: 0.75rem; }
.rounded-full { border-radius: 9999px; }
/* =========================== */
/* 阴影 */
/* =========================== */
.shadow-lg { box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05); }
.shadow-xl { box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04); }
/* =========================== */
/* 过渡和动画 */
/* =========================== */
.transition-all { transition: all 0.15s ease-in-out; }
.transition-transform { transition: transform 0.15s ease-in-out; }
.duration-300 { transition-duration: 300ms; }
.transform { transform: translateZ(0); }
.-translate-y-1 { transform: translateY(-0.25rem); }
/* 旋转动画 */
.animate-spin {
animation: spin 1s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
/* 淡入动画 */
.animate-fade-in {
animation: fade-in 0.4s cubic-bezier(0.4, 0, 0.2, 1);
}
@keyframes fade-in {
0% {
opacity: 0;
transform: translateY(10px);
}
100% {
opacity: 1;
transform: translateY(0);
}
}
/* =========================== */
/* 尺寸工具类 */
/* =========================== */
.w-12 { width: 3rem; }
.h-12 { height: 3rem; }
.max-h-96 { max-height: 24rem; }
/* =========================== */
/* 其他工具类 */
/* =========================== */
.cursor-pointer { cursor: pointer; }
.whitespace-pre-wrap { white-space: pre-wrap; }
.overflow-auto { overflow: auto; }
.font-sans { font-family: 'Inter', system-ui, sans-serif; }
.leading-tight { line-height: 1.25; }
/* =========================== */
/* 特殊背景样式 */
/* =========================== */
.bg-gradient-to-b {
background: linear-gradient(to bottom, #f8fafc, #ffffff);
}
/* =========================== */
/* 边框颜色 */
/* =========================== */
.border-primary { border-color: var(--primary); }
/* =========================== */
/* hover效果 */
/* =========================== */
.hover\:text-primary:hover { color: var(--primary); }
.hover\:shadow-primary-30:hover { box-shadow: 0 0 0 1px rgba(32, 165, 58, 0.3); }
/* =========================== */
/* 响应式工具类 */
/* =========================== */
@media (min-width: 640px) {
.sm\:flex-row { flex-direction: row; }
}
@media (min-width: 768px) {
.md\:pt-24 { padding-top: 6rem; }
.md\:pb-28 { padding-bottom: 7rem; }
}
/* =========================== */
/* 复用主项目的组件样式 */
/* =========================== */
/* 容器 */
.container {
max-width: 1200px;
margin: 0 auto;
padding: 0 1rem;
}
/* 搜索输入框 */
.search-input {
width: 100%;
padding: 0.75rem 1rem;
border-radius: 0.125rem;
border: 1px solid var(--primary);
font-size: 1rem;
line-height: 1.75rem;
outline: none;
transition: all 0.15s ease-in-out;
}
.search-input:focus {
border-color: var(--primary);
box-shadow: 0 0 0 3px var(--primary-10);
}
.input-container {
position: relative;
flex: 1;
}
.clear-input-button {
font-size: 1.6rem;
position: absolute;
right: 1rem;
top: 14.5px;
color: #9ca3af;
cursor: pointer;
opacity: 0;
visibility: hidden;
transition: all 0.15s ease-in-out;
}
.clear-input-button:hover {
color: #6b7280;
}
.clear-input-button.visible {
opacity: 1;
visibility: visible;
}
/* 主要操作按钮 */
.primary-action-button {
display: inline-block;
cursor: pointer;
background-color: var(--primary);
color: white;
font-weight: 700;
padding: 0.75rem 1.5rem;
border-radius: 0.125rem;
transition: all 0.15s ease-in-out;
box-shadow: 0 4px 6px -2px rgba(0, 0, 0, 0.08), 0 2px 4px -2px rgba(0, 0, 0, 0.04);
height: 54px;
}
.primary-action-button:hover {
background-color: var(--primary-90);
transform: translateY(-1px);
}
/* 操作按钮 */
.action-button {
border: 1px solid var(--primary);
background-color: white;
color: var(--primary);
padding: 0.375rem 0.75rem;
border-radius: 0.125rem;
font-size: 0.875rem;
cursor: pointer;
transition: all 0.15s ease-in-out;
text-decoration: none;
display: inline-flex;
align-items: center;
}
.action-button:hover {
background-color: var(--primary-5);
border-color: var(--primary);
color: var(--primary);
}
/* 悬停提升效果 */
.hover-lift {
transition:
transform 0.2s cubic-bezier(0.4, 0, 0.2, 1),
box-shadow 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
.hover-lift:hover {
transform: translateY(-0.125rem);
}
/* =========================== */
/* WHOIS页面专用组件样式 */
/* =========================== */
/* 简洁表格式布局 */
.whois-simple-layout {
margin: 0 auto;
background: white;
padding: 30px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
}
.whois-header {
margin-bottom: 10px;
}
.whois-title {
display: flex;
align-items: center;
font-size: 16px;
color: #333;
margin: 0 0 10px 0;
line-height: 1.4;
}
.title-decoration {
width: 4px;
height: 24px;
background: var(--primary);
margin-right: 12px;
flex-shrink: 0;
}
.domain-name {
color: var(--primary);
}
.whois-meta {
display: flex;
align-items: center;
gap: 16px;
font-size: 14px;
color: #666;
margin-bottom: 20px;
}
.refresh-link {
color: var(--primary);
text-decoration: none;
font-weight: 500;
}
.refresh-link:hover {
text-decoration: underline;
}
.whois-table {
width: 100%;
}
.whois-row {
display: flex;
min-height: 45px;
padding: 0 0 20px;
align-items: flex-start;
}
.whois-row:last-child {
border-bottom: none;
}
.whois-label {
width: 160px;
flex-shrink: 0;
text-align: right;
}
.label-cn {
display: block;
font-size: 14px;
font-weight: 500;
color: #333;
margin-bottom: 2px;
}
.label-en {
display: block;
font-size: 12px;
color: #999;
font-style: italic;
}
.whois-value {
flex: 1;
color: #333;
font-size: 14px;
line-height: 1.5;
word-break: break-all;
padding-left: 60px;
}
.whois-value-multiple {
display: flex;
flex-direction: column;
gap: 6px;
}
.whois-dns-item {
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
background: #f8f9fa;
padding: 4px 8px;
border-radius: 2px;
font-size: 13px;
color: #495057;
border: 1px solid #e9ecef;
}
.whois-status-value {
display: inline-flex;
align-items: center;
gap: 8px;
}
.whois-status-badge-simple {
display: inline-block;
padding: 2px 8px;
border-radius: 12px;
font-size: 12px;
font-weight: 500;
background: #e8f5e8;
color: #2d5a2d;
border: 1px solid #c3e6c3;
}
.whois-status-badge-simple.status-active {
background: #e8f5e8;
color: #2d5a2d;
border-color: #c3e6c3;
}
.whois-status-badge-simple.status-transfer {
background: #fff3cd;
color: #856404;
border-color: #ffeaa7;
}
.whois-status-badge-simple.status-unknown {
background: #e2e3e5;
color: #495057;
border-color: #ced4da;
}
/* 原始数据区域 */
.whois-raw-data-section {
margin-top: 30px;
border-top: 1px solid #e9ecef;
padding-top: 20px;
}
.raw-data-toggle {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 0;
cursor: pointer;
font-weight: 500;
color: #495057;
border: none;
background: none;
width: 100%;
text-align: left;
margin-bottom: 10px;
}
.raw-data-toggle:hover {
color: var(--primary);
}
.raw-data-toggle i {
transition: transform 0.3s ease;
}
.raw-data-toggle.expanded i {
transform: rotate(180deg);
}
.raw-data-content {
display: block; /* 默认展开 */
margin-top: 0;
}
.raw-data-content.hide {
display: none;
}
.raw-data-box {
background: #f8f9fa;
color: #495057;
padding: 20px;
border: 1px solid #e9ecef;
font-family: 'Inter', system-ui, sans-serif;
font-size: 13px;
line-height: 1.6;
white-space: pre-wrap;
word-break: break-word;
}
.raw-data-title {
font-size: 16px;
font-weight: 600;
color: #333;
margin-bottom: 15px;
display: flex;
align-items: center;
gap: 8px;
}
.raw-data-title .title-decoration {
width: 4px;
height: 20px;
background: var(--primary);
}
/* 原始数据滚动条样式 */
.raw-data-box::-webkit-scrollbar {
width: 8px;
}
.raw-data-box::-webkit-scrollbar-track {
background: #f1f3f4;
border-radius: 4px;
}
.raw-data-box::-webkit-scrollbar-thumb {
background: #c1c8cd;
border-radius: 4px;
}
.raw-data-box::-webkit-scrollbar-thumb:hover {
background: #a8b2ba;
}
/* 原始数据文字样式优化 */
.raw-data-box {
tab-size: 4;
}
/* 原始数据字段高亮 */
.raw-data-box {
color: #374151;
}
/* 优化行高和间距 */
.raw-data-content {
transition: all 0.3s ease-in-out;
}
.raw-data-content.hide {
opacity: 0;
max-height: 0;
overflow: hidden;
margin-top: -10px;
}
/* 切换按钮样式优化 */
.raw-data-toggle {
transition: all 0.2s ease;
}
.raw-data-toggle:hover {
background-color: rgba(32, 165, 58, 0.05);
border-radius: 4px;
}
/* WHOIS概览卡片 - 保持兼容性,但隐藏 */
.whois-overview-card {
display: none;
}
.whois-overview-card:hover {
display: none;
}
.whois-domain-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
flex-wrap: wrap;
gap: 12px;
}
.whois-domain-name {
font-size: 24px;
font-weight: 700;
color: #1e293b;
margin: 0;
line-height: 1.2;
word-break: break-all;
}
.whois-status-badge {
display: inline-flex;
align-items: center;
padding: 6px 12px;
border-radius: 20px;
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
border: 1px solid;
}
.whois-registrar-info {
display: flex;
align-items: center;
gap: 8px;
color: #64748b;
font-size: 14px;
font-weight: 500;
}
.whois-registrar-info i {
color: var(--primary);
font-size: 16px;
}
/* WHOIS信息网格 */
.whois-info-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
gap: 20px;
margin-bottom: 20px;
}
.whois-info-card {
background: #ffffff;
border: 1px solid #e2e8f0;
border-radius: 0.5rem;
overflow: hidden;
transition: all 0.3s ease;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
}
.whois-card-header {
background: linear-gradient(135deg, var(--primary-5) 0%, #f8fafc 100%);
padding: 16px 20px;
border-bottom: 1px solid #e2e8f0;
display: flex;
align-items: center;
gap: 12px;
}
.whois-card-icon {
color: var(--primary);
font-size: 18px;
width: 20px;
text-align: center;
}
.whois-card-title {
font-size: 16px;
font-weight: 600;
color: #1e293b;
margin: 0;
line-height: 1.3;
}
.whois-card-content {
padding: 20px;
}
.whois-info-item {
display: flex;
justify-content: space-between;
align-items: flex-start;
padding: 12px 0;
border-bottom: 1px solid #f1f5f9;
gap: 16px;
}
.whois-info-item:last-child {
border-bottom: none;
padding-bottom: 0;
}
.whois-info-item:first-child {
padding-top: 0;
}
.whois-info-label {
font-size: 14px;
font-weight: 500;
color: #64748b;
min-width: 80px;
flex-shrink: 0;
}
.whois-info-value {
font-size: 14px;
font-weight: 400;
color: #1e293b;
text-align: right;
word-break: break-all;
line-height: 1.4;
}
/* DNS服务器特殊样式 */
.whois-dns-card {
grid-column: 1 / -1;
}
.whois-dns-servers {
display: flex;
flex-direction: column;
gap: 8px;
align-items: flex-end;
}
.whois-dns-server {
display: inline-block;
background: #f1f5f9;
color: #475569;
padding: 6px 12px;
border-radius: 6px;
font-size: 13px;
font-weight: 500;
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
border: 1px solid #e2e8f0;
transition: all 0.2s ease;
}
.whois-dns-server:hover {
background: var(--primary-5);
border-color: var(--primary);
color: var(--primary);
}
/* =========================== */
/* 响应式设计 */
/* =========================== */
@media (max-width: 768px) {
.whois-simple-layout {
padding: 20px 16px;
margin: 0 16px;
}
.whois-title {
font-size: 18px;
}
.title-decoration {
height: 20px;
margin-right: 8px;
}
.whois-meta {
flex-direction: column;
align-items: flex-start;
gap: 8px;
}
.whois-value-multiple {
gap: 4px;
}
.whois-dns-item {
font-size: 12px;
padding: 3px 6px;
}
.whois-label{
width: 85px;
}
.whois-value{
padding-left: 10px;
}
}
@media (max-width: 480px) {
.whois-simple-layout {
padding: 16px 12px;
margin: 0 8px;
}
.whois-title {
font-size: 16px;
}
.title-decoration {
width: 3px;
height: 18px;
}
}
/* 旧版响应式样式 - 保持兼容性 */
@media (max-width: 768px) {
.whois-info-grid {
grid-template-columns: 1fr;
}
.whois-domain-header {
flex-direction: column;
align-items: flex-start;
}
.whois-domain-name {
font-size: 20px;
}
.whois-info-item {
flex-direction: column;
align-items: flex-start;
gap: 4px;
}
.whois-info-value {
text-align: left;
}
.whois-dns-servers {
align-items: flex-start;
}
}

View File

@ -61,8 +61,6 @@ 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 = {
@ -92,5 +90,4 @@ export const TEMPLATES = {
TPL_ORDER_SUCCESS,
TPL_CONFIRM_DELETE_MODAL,
TPL_DOMAIN_AGREEMENT_MODAL,
TPL_WHOIS_MODAL,
} as const

View File

@ -84,3 +84,21 @@ export interface DomainQueryCheckRequest {
/** 推荐类型(可选),用于筛选特定类型的推荐域名后缀 */
recommend_type?: number
}
export interface AiDomainQueryCheckRequest {
/** 品牌名称 */
brand?: string;
/** 所属行业 */
industry?: string
}
export interface AiDomainQueryCheckResponseData {
/** 数据 */
data: AiDatum[];
}
// 继承Datum基础上增加meaning字段
export interface AiDatum extends Datum {
/** 含义 */
meaning: string;
}

View File

@ -35,7 +35,7 @@ export default defineConfig({
build: {
minify: "terser", // 混淆器terser构建后文件体积更小
sourcemap: false,
cssCodeSplit: false, // 不分割css代码
cssCodeSplit: true, // 不分割css代码
reportCompressedSize: false, // 不统计gzip压缩后的文件大小
chunkSizeWarningLimit: 800, // 警告阈值
assetsInlineLimit: 2048, // 小于2kb的资源内联
@ -51,6 +51,7 @@ export default defineConfig({
input: {
main: resolve(__dirname, "index.html"),
registration: resolve(__dirname, "domain-query-register.html"),
whois: resolve(__dirname, "domain-whois.html"),
},
strictDeprecations: true, // 严格弃用
output: {

View File

@ -1,10 +1,231 @@
import fs from "fs";
import path from "path";
import { glob } from "glob";
import os from "os";
// 全局随机参数变量,在插件初始化时设置
let randomParam = null;
// Windows兼容性常量
const WINDOWS_RESERVED_NAMES = [
"CON",
"PRN",
"AUX",
"NUL",
"COM1",
"COM2",
"COM3",
"COM4",
"COM5",
"COM6",
"COM7",
"COM8",
"COM9",
"LPT1",
"LPT2",
"LPT3",
"LPT4",
"LPT5",
"LPT6",
"LPT7",
"LPT8",
"LPT9",
];
const WINDOWS_INVALID_CHARS = /[<>:"|?*\x00-\x1f]/g;
const MAX_PATH_LENGTH = 260; // Windows MAX_PATH限制
const MAX_FILENAME_LENGTH = 255; // 大多数文件系统的文件名长度限制
/**
* Windows兼容性工具函数
*/
/**
* 检查是否为Windows系统
* @returns {boolean} 是否为Windows
*/
function isWindows() {
return os.platform() === "win32";
}
/**
* 规范化Windows文件名
* @param {string} filename - 原始文件名
* @returns {string} 规范化后的文件名
*/
function normalizeWindowsFilename(filename) {
if (!isWindows()) {
return filename;
}
// 移除无效字符
let normalized = filename.replace(WINDOWS_INVALID_CHARS, "_");
// 移除尾部的点和空格
normalized = normalized.replace(/[. ]+$/, "");
// 检查保留名称
const baseName = normalized.split(".")[0].toUpperCase();
if (WINDOWS_RESERVED_NAMES.includes(baseName)) {
normalized = `_${normalized}`;
}
// 限制文件名长度
if (normalized.length > MAX_FILENAME_LENGTH) {
const ext = path.extname(normalized);
const nameWithoutExt = path.basename(normalized, ext);
const maxNameLength = MAX_FILENAME_LENGTH - ext.length;
normalized = nameWithoutExt.substring(0, maxNameLength) + ext;
}
return normalized;
}
/**
* 检查路径长度是否超出Windows限制
* @param {string} filePath - 文件路径
* @returns {boolean} 是否超出限制
*/
function isPathTooLong(filePath) {
return isWindows() && filePath.length > MAX_PATH_LENGTH;
}
/**
* 生成Windows兼容的安全时间戳
* @returns {string} 安全的时间戳字符串
*/
function generateSafeTimestamp() {
const now = new Date();
const timestamp = now
.toISOString()
.replace(/[:.]/g, "-")
.replace("T", "_")
.substring(0, 19); // 移除毫秒部分
return normalizeWindowsFilename(timestamp);
}
/**
* 规范化glob模式以确保Windows兼容性
* @param {string} pattern - glob模式
* @returns {string} 规范化后的glob模式
*/
function normalizeGlobPattern(pattern) {
if (!pattern) return pattern;
// 在Windows系统中确保glob模式使用正斜杠
// glob库在Windows中也期望使用正斜杠作为路径分隔符
if (isWindows()) {
return pattern.replace(/\\/g, "/");
}
return pattern;
}
/**
* 创建Windows兼容的glob选项
* @param {Object} baseOptions - 基础选项
* @returns {Object} 兼容的glob选项
*/
function createWindowsCompatibleGlobOptions(baseOptions = {}) {
const options = { ...baseOptions };
if (isWindows()) {
// Windows特定的glob选项
options.windowsPathsNoEscape = true;
options.nonull = false; // 避免返回无匹配的模式
options.dot = options.dot !== false; // 默认包含点文件
}
return options;
}
/**
* 规范化目录路径用于glob搜索
* @param {string} dirPath - 目录路径
* @returns {string} 规范化后的目录路径
*/
function normalizeGlobDirectory(dirPath) {
if (!dirPath) return dirPath;
// 规范化路径
let normalized = path.normalize(dirPath);
// 在Windows中将反斜杠转换为正斜杠用于glob
if (isWindows()) {
normalized = normalized.replace(/\\/g, "/");
}
return normalized;
}
/**
* 规范化文件路径确保Windows兼容性
* @param {string} filePath - 原始文件路径
* @returns {string} 规范化后的路径
*/
function normalizeFilePath(filePath) {
if (!filePath) return filePath;
// 使用path.normalize处理路径分隔符
let normalized = path.normalize(filePath);
if (isWindows()) {
// 处理文件名部分
const dir = path.dirname(normalized);
const filename = path.basename(normalized);
const normalizedFilename = normalizeWindowsFilename(filename);
normalized = path.join(dir, normalizedFilename);
}
return normalized;
}
/**
* 验证文件路径的Windows兼容性
* @param {string} filePath - 文件路径
* @param {Object} options - 配置选项
* @returns {Object} 验证结果 {valid: boolean, error?: string, normalized?: string}
*/
function validateFilePath(filePath, options = {}) {
const result = { valid: true };
try {
// 规范化路径
const normalized = normalizeFilePath(filePath);
result.normalized = normalized;
// 检查路径长度
if (isPathTooLong(normalized)) {
result.valid = false;
result.error = `路径长度超出限制 (${normalized.length} > ${MAX_PATH_LENGTH})`;
return result;
}
// 检查文件名
const filename = path.basename(normalized);
if (isWindows() && WINDOWS_INVALID_CHARS.test(filename)) {
result.valid = false;
result.error = `文件名包含Windows不支持的字符: ${filename}`;
return result;
}
// 检查保留名称
const baseName = path
.basename(filename, path.extname(filename))
.toUpperCase();
if (isWindows() && WINDOWS_RESERVED_NAMES.includes(baseName)) {
result.valid = false;
result.error = `文件名使用了Windows保留名称: ${baseName}`;
return result;
}
} catch (error) {
result.valid = false;
result.error = `路径验证失败: ${error.message}`;
}
return result;
}
/**
* 生成随机数参数
* @param {Object} options - 配置选项
@ -132,8 +353,6 @@ function processFileContent(content, options = {}) {
}v=${randomParam}${suffix}`
: match;
}
console.log(randomParam, "时间戳");
// console.log(match, prefix, filePath, suffix);
return `${prefix}${filePath}${
filePath.includes("?") ? "&" : "?"
}v=${randomParam}${suffix}`;
@ -197,9 +416,9 @@ function processSingleFile(filePath, options = {}) {
if (content !== processedContent) {
fs.writeFileSync(filePath, processedContent, "utf8");
if (options.logger) {
options.logger(`✅ 已处理: ${filePath}`);
}
// if (options.logger) {
// options.logger(`✅ 已处理: ${filePath}`);
// }
return true;
} else {
if (options.logger) {
@ -249,9 +468,23 @@ function processBatchFiles(directory, options = {}) {
? directory
: path.resolve(directory);
// 验证目录路径的Windows兼容性
const pathValidation = validateFilePath(absoluteDir);
if (!pathValidation.valid) {
const error = `目录路径不兼容: ${pathValidation.error}`;
if (mergedOptions.logger) {
mergedOptions.logger(`${error}`);
}
stats.errors.push({ file: "DIRECTORY_VALIDATION", error });
return stats;
}
// 使用规范化后的路径
const normalizedDir = pathValidation.normalized || absoluteDir;
// 检查目录是否存在
if (!fs.existsSync(absoluteDir)) {
const error = `目录不存在: ${absoluteDir}`;
if (!fs.existsSync(normalizedDir)) {
const error = `目录不存在: ${normalizedDir}`;
if (mergedOptions.logger) {
mergedOptions.logger(`${error}`);
}
@ -260,7 +493,7 @@ function processBatchFiles(directory, options = {}) {
}
if (mergedOptions.logger) {
mergedOptions.logger(`🔍 扫描目录: ${absoluteDir}`);
mergedOptions.logger(`🔍 扫描目录: ${normalizedDir}`);
}
try {
@ -268,30 +501,66 @@ function processBatchFiles(directory, options = {}) {
let allFiles = [];
mergedOptions.patterns.forEach((pattern) => {
const searchPattern = path.join(absoluteDir, pattern);
const ignorePatterns = mergedOptions.ignore.map((ig) =>
path.join(absoluteDir, ig)
);
// 规范化glob模式以确保Windows兼容性
const normalizedPattern = normalizeGlobPattern(pattern);
const globDir = normalizeGlobDirectory(normalizedDir);
const searchPattern = path.posix.join(globDir, normalizedPattern);
const ignorePatterns = mergedOptions.ignore.map((ig) => {
const normalizedIgnore = normalizeGlobPattern(ig);
return path.posix.join(globDir, normalizedIgnore);
});
try {
const files = glob.sync(searchPattern, {
const globOptions = createWindowsCompatibleGlobOptions({
ignore: ignorePatterns,
absolute: true,
nodir: true, // 只返回文件,不包括目录
});
if (mergedOptions.logger && files.length > 0) {
// Windows调试信息
if (isWindows() && mergedOptions.logger) {
mergedOptions.logger(`🔍 Windows模式 - 搜索模式: ${searchPattern}`);
mergedOptions.logger(
`📄 找到 ${files.length} 个匹配文件 (${pattern})`
`🔍 Windows模式 - 忽略模式: ${ignorePatterns.join(", ")}`
);
}
const files = glob.sync(searchPattern, globOptions);
if (mergedOptions.logger) {
if (files.length > 0) {
mergedOptions.logger(
`📄 找到 ${files.length} 个匹配文件 (${pattern})`
);
} else {
mergedOptions.logger(
`⚠️ 模式 "${pattern}" 未找到匹配文件${
isWindows() ? " (Windows系统)" : ""
}`
);
}
}
allFiles.push(...files);
} catch (globError) {
const error = `模式匹配失败 (${pattern}): ${globError.message}`;
stats.errors.push({ file: pattern, error });
const error = `模式匹配失败 (${pattern}): ${globError.message}${
isWindows() ? " [Windows系统]" : ""
}`;
stats.errors.push({
file: pattern,
error,
platform: isWindows() ? "Windows" : "Unix",
searchPattern,
ignorePatterns,
});
if (mergedOptions.logger) {
mergedOptions.logger(`⚠️ ${error}`);
mergedOptions.logger(`${error}`);
if (isWindows()) {
mergedOptions.logger(`🔍 调试信息 - 搜索路径: ${searchPattern}`);
mergedOptions.logger(
`🔍 调试信息 - 目录存在: ${fs.existsSync(normalizedDir)}`
);
}
}
}
});
@ -315,12 +584,27 @@ function processBatchFiles(directory, options = {}) {
if (mergedOptions.logger) {
mergedOptions.logger(
`\n[${index + 1}/${stats.totalCount}] 处理: ${path.relative(
absoluteDir,
normalizedDir,
file
)}`
);
}
// 验证单个文件路径
const fileValidation = validateFilePath(file);
if (!fileValidation.valid) {
stats.failedCount++;
stats.errors.push({
file: path.relative(normalizedDir, file),
error: `文件路径验证失败: ${fileValidation.error}`,
});
if (mergedOptions.logger) {
mergedOptions.logger(`❌ 跳过无效文件: ${fileValidation.error}`);
}
return; // 跳过此文件
}
const normalizedFile = fileValidation.normalized || file;
let retries = 0;
let success = false;
@ -330,7 +614,7 @@ function processBatchFiles(directory, options = {}) {
if (mergedOptions.createBackup) {
// 使用安全处理函数
const result = processSingleFileSafe(file, mergedOptions);
const result = processSingleFileSafe(normalizedFile, mergedOptions);
if (result.success) {
success = true;
@ -347,7 +631,7 @@ function processBatchFiles(directory, options = {}) {
}
} else {
// 使用原有的处理函数
const modified = processSingleFile(file, mergedOptions);
const modified = processSingleFile(normalizedFile, mergedOptions);
success = true;
if (modified) {
@ -525,6 +809,7 @@ export default function randomCachePlugin(userOptions = {}) {
}
},
// 编辑结束
buildEnd() {
if (options.enableLog) {
logger("✨ Random Cache Plugin 处理完成");
@ -543,11 +828,20 @@ function createBackup(filePath, options = {}) {
try {
const backupDir =
options.backupDir || path.join(path.dirname(filePath), ".backup");
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
const timestamp = generateSafeTimestamp();
const fileName = path.basename(filePath);
const backupFileName = `${fileName}.${timestamp}.backup`;
const backupFileName = normalizeWindowsFilename(
`${fileName}.${timestamp}.backup`
);
const backupPath = path.join(backupDir, backupFileName);
// 检查路径长度
if (isPathTooLong(backupPath)) {
throw new Error(
`备份路径过长 (${backupPath.length} > ${MAX_PATH_LENGTH}): ${backupPath}`
);
}
// 确保备份目录存在
if (!fs.existsSync(backupDir)) {
fs.mkdirSync(backupDir, { recursive: true });
@ -725,24 +1019,76 @@ function batchReplaceWithRandomCache(target, options = {}) {
? target
: path.resolve(target);
if (!fs.existsSync(absoluteDir)) {
throw new Error(`目标目录不存在: ${absoluteDir}`);
// 验证目录路径的Windows兼容性
const pathValidation = validateFilePath(absoluteDir);
if (!pathValidation.valid) {
throw new Error(`目标目录路径不兼容: ${pathValidation.error}`);
}
logger(`🔍 扫描目录: ${absoluteDir}`);
const normalizedDir = pathValidation.normalized || absoluteDir;
if (!fs.existsSync(normalizedDir)) {
throw new Error(`目标目录不存在: ${normalizedDir}`);
}
logger(`🔍 扫描目录: ${normalizedDir}`);
mergedOptions.patterns.forEach((pattern) => {
const searchPattern = path.join(absoluteDir, pattern);
const ignorePatterns = mergedOptions.ignore.map((ig) =>
path.join(absoluteDir, ig)
);
// 规范化glob模式以确保Windows兼容性
const normalizedPattern = normalizeGlobPattern(pattern);
const globDir = normalizeGlobDirectory(normalizedDir);
const searchPattern = path.posix.join(globDir, normalizedPattern);
const ignorePatterns = mergedOptions.ignore.map((ig) => {
const normalizedIgnore = normalizeGlobPattern(ig);
return path.posix.join(globDir, normalizedIgnore);
});
const files = glob.sync(searchPattern, {
const globOptions = createWindowsCompatibleGlobOptions({
ignore: ignorePatterns,
absolute: true,
});
filesToProcess.push(...files);
// Windows调试信息
if (isWindows() && mergedOptions.enableLog) {
logger(`🔍 Windows模式 - 搜索模式: ${searchPattern}`);
logger(`🔍 Windows模式 - 忽略模式: ${ignorePatterns.join(", ")}`);
}
try {
const files = glob.sync(searchPattern, globOptions);
if (mergedOptions.enableLog) {
if (files.length > 0) {
logger(`📄 模式 "${pattern}" 找到 ${files.length} 个文件`);
} else {
logger(
`⚠️ 模式 "${pattern}" 未找到匹配文件${
isWindows() ? " (Windows系统)" : ""
}`
);
}
}
filesToProcess.push(...files);
} catch (globError) {
const error = `模式匹配失败 (${pattern}): ${globError.message}${
isWindows() ? " [Windows系统]" : ""
}`;
stats.errors.push({
file: pattern,
error,
platform: isWindows() ? "Windows" : "Unix",
searchPattern,
ignorePatterns,
});
if (mergedOptions.enableLog) {
logger(`${error}`);
if (isWindows()) {
logger(`🔍 调试信息 - 搜索路径: ${searchPattern}`);
logger(`🔍 调试信息 - 目录存在: ${fs.existsSync(normalizedDir)}`);
}
}
}
});
// 去重
@ -824,4 +1170,14 @@ export {
restoreFromBackup,
processSingleFileSafe,
batchReplaceWithRandomCache,
// Windows兼容性工具函数
isWindows,
normalizeWindowsFilename,
normalizeFilePath,
validateFilePath,
isPathTooLong,
generateSafeTimestamp,
normalizeGlobPattern,
createWindowsCompatibleGlobOptions,
normalizeGlobDirectory,
};

File diff suppressed because it is too large Load Diff

View File

@ -5,8 +5,8 @@
<link rel="icon" href="./favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>AllinSSL</title>
<script type="module" crossorigin src="./static/js/main-BVxorWyM.js"></script>
<link rel="stylesheet" crossorigin href="./static/css/style-Cb9FPhWh.css">
<script type="module" crossorigin src="./static/js/main-Dxgnl0V0.js"></script>
<link rel="stylesheet" crossorigin href="./static/css/style-C8g9wpmL.css">
</head>
<body>
<div id="app"></div>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1 +0,0 @@
import{u as t}from"./index-PcCxPxDa.js";import{d as s,c as o}from"./main-BVxorWyM.js";import"./useStore-CPojdwSJ.js";import"./index-yTfFo-nL.js";import"./access-tNugO07J.js";import"./index-BhUxUanl.js";import"./index-D7kMPrCO.js";import"./throttle-C61W3BCh.js";import"./DownloadOutline-B1jf4tcR.js";import"./data-BmphZhVh.js";import"./index-D_D2Mlc-.js";import"./business-DFqzW9Fz.js";import"./index-leYjYcVi.js";import"./text-D7JJPoiP.js";import"./Flex-B1kHIVtt.js";const e=s({name:"CAManageForm",props:{isEdit:{type:Boolean,default:!1},editId:{type:String,default:""}},setup(s){const{CAForm:e}=t(s);return()=>o(e,{labelPlacement:"top"},null)}});export{e as default};

Some files were not shown because too many files have changed in this diff Show More