Files
aida_front/src/component/Administrator/subscriptionPlan.vue

1537 lines
37 KiB
Vue
Raw Normal View History

<template>
2026-04-28 14:25:17 +08:00
<div class="subscription-plan">
<a-card class="search-card" :bordered="false">
<a-form
class="search-form"
layout="inline"
:model="searchForm"
:label-col="{ style: { width: '14rem' } }"
:wrapper-col="{ style: { width: '20rem' } }"
>
<a-form-item label="ID">
<a-input v-model:value="searchForm.id" allow-clear placeholder="Input the id" />
</a-form-item>
<!-- <a-form-item label="Name">
<a-input
v-model:value="searchForm.name"
allow-clear
placeholder="Input the name"
/>
</a-form-item> -->
2026-04-28 14:25:17 +08:00
<a-form-item label="Time Range">
<a-range-picker
v-model:value="searchForm.dateRange"
value-format="X"
allow-clear
/>
</a-form-item>
<a-form-item label="Organization">
<a-select
v-model:value="searchForm.organizationId"
allow-clear
placeholder="Select Organization"
style="width: 180px"
@popupScroll="handleOrganizationScroll"
@select="handleOrganizationSelect"
@change="handleOrganizationChange"
>
<a-select-option value="ADD_ORGANIZATION" class="add-organization-option">
+ Create Organization
</a-select-option>
<a-select-option
v-for="item in organizationOptions"
:key="item.id"
:value="item.id"
>
{{ item.name }}
</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="Admin Account">
<SelectUser v-model="searchForm.adminAccId" labelKey="email" />
</a-form-item>
<a-form-item label="Status">
<a-select
v-model:value="searchForm.status"
mode="multiple"
allow-clear
placeholder="Select Status"
style="width: 220px"
:options="statusOption"
/>
</a-form-item>
<a-form-item label="Country or Region">
<a-select
v-model:value="searchForm.countryOrRegion"
:allowClear="true"
show-search
style="width: 250px"
:filter-option="filterOption"
placeholder="Select the country or region"
max-tag-count="responsive"
:options="countryList"
/>
</a-form-item>
<a-form-item class="search-form__actions">
<a-space>
<a-button type="primary" @click="handleSearch">Search</a-button>
<a-button @click="handleReset">Reset</a-button>
</a-space>
</a-form-item>
</a-form>
</a-card>
<a-card class="table-card" :bordered="false">
<div class="table-card__header">
<a-button type="primary" @click="openCreate">New Subscription Plan</a-button>
</div>
<div ref="historyTable" class="table-wrapper">
<a-table
:data-source="tableData"
:columns="columns"
:loading="tableLoading"
:bordered="false"
row-key="id"
:customRow="customPlanRow"
@change="changePage"
@resizeColumn="handleResizeColumn"
:scroll="{ y: historyTableHeight }"
:pagination="{
showSizeChanger: true,
current: searchForm.page,
pageSize: searchForm.size,
total: searchForm.total,
showQuickJumper: true,
bordered: false
}"
>
<template #bodyCell="{ column, record }">
<template
v-if="
column.key === 'currentPeriodStart' ||
column.key === 'currentPeriodEnd'
"
>
{{ formatTime(record[column.key], "YYYY-MM-DD hh:mm:ss") }}
</template>
<template v-if="column.key === 'status'">
<a-tag :color="getStatusColor(record.status)">
{{ record.status }}
</a-tag>
</template>
<template v-else-if="column.key === 'actions'">
<a-space class="plan-row-actions" @click.stop>
<a @click.stop="openEdit(record)">Edit</a>
<a-popconfirm
title="Confirm to delete this subscription plan?"
ok-text="Confirm"
cancel-text="Cancel"
@confirm="removePlan(record.id)"
>
<a class="danger-text" @click.stop>Delete</a>
</a-popconfirm>
</a-space>
</template>
</template>
</a-table>
</div>
</a-card>
<a-modal
v-model:visible="userInfoModalVisible"
title="User Info"
width="80%"
:footer="null"
:destroy-on-close="true"
class="user-info-modal"
>
<a-table
:columns="userInfoColumns"
:data-source="userInfoList"
:loading="userInfoLoading"
:pagination="{
showSizeChanger: true,
current: userInfoPage.current,
pageSize: userInfoPage.size,
total: userInfoPage.total,
showQuickJumper: true,
bordered: false
}"
row-key="id"
:scroll="{ x: 1280, y: 420 }"
@change="changeUserInfoPage"
>
<template #bodyCell="{ column, record }">
<template
v-if="
column.key === 'validStartTime' ||
column.key === 'validEndTime' ||
column.key === 'createDate'
"
>
{{ formatUserTime(record[column.key]) }}
</template>
<template v-else-if="column.key === 'systemUser'">
{{ getSystemUserLabel(record.systemUser) }}
</template>
<template v-else-if="column.key === 'isBeginner'">
{{ record.isBeginner == 1 ? "Yes" : "No" }}
</template>
</template>
</a-table>
</a-modal>
<div class="subscriptionPlanModal" ref="subscriptionPlanModal"></div>
<a-modal
class="subscriptionPlan_modal generalModel"
v-model:visible="modalVisible"
:footer="null"
:get-container="() => $refs.subscriptionPlanModal"
width="50%"
:maskClosable="false"
:centered="true"
:closable="false"
:mask="true"
wrapClassName="#app"
:keyboard="false"
destroy-on-close
>
<div class="generalModel_btn">
<div class="generalModel_closeIcon" @click.stop="cancelModal">
<svg
width="100%"
height="100%"
viewBox="0 0 46 46"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<circle cx="23" cy="23" r="23" fill="#000" fill-opacity="0.3" />
<rect
x="32.5063"
y="12"
width="3"
height="29"
rx="1.5"
transform="rotate(45 32.5063 12)"
fill="white"
/>
<rect
x="34.6274"
y="32.5059"
width="3"
height="29"
rx="1.5"
transform="rotate(135 34.6274 32.5059)"
fill="white"
/>
</svg>
</div>
</div>
<div class="modal_title_text">
<div>{{ modalTitle }}</div>
</div>
<div class="subscriptionPlan_center admin_page">
<div class="form_content">
<div class="admin_state_item" v-if="!isEditMode">
<span>
Name:
<span>*</span>
</span>
<a-input
v-model:value="formState.name"
placeholder="Input the name"
style="width: 250px"
/>
</div>
<div class="admin_state_item" v-if="!isEditMode">
<span>
Organization:
<span>*</span>
</span>
<a-select
v-model:value="formState.organizationId"
placeholder="Select the organization"
allow-clear
style="width: 250px"
@popupScroll="handleOrganizationScroll"
@select="handleOrganizationSelect"
@change="handleOrganizationChange"
>
<a-select-option
value="ADD_ORGANIZATION"
class="add-organization-option"
>
+ Create Organization
</a-select-option>
<a-select-option
v-for="item in organizationOptions"
:key="item.id"
:value="item.id"
>
{{ item.name }}
</a-select-option>
</a-select>
</div>
<div class="admin_state_item">
<span>
Country Or Region:
<span>*</span>
</span>
<a-select
v-model:value="formState.countryOrRegion"
placeholder="Select the country or region"
allow-clear
show-search
:filter-option="filterOption"
style="width: 250px"
:options="countryList"
></a-select>
</div>
<div class="admin_state_item">
<span>
Admin Account:
<span>*</span>
</span>
<SelectUser ref="userRef" v-model="formState.adminAccId" labelKey="email" />
</div>
<div class="admin_state_item">
<span>
Start Time:
<span>*</span>
</span>
<a-date-picker
v-model:value="formState.currentPeriodStart"
value-format="X"
:disabled="isEditMode && formState.status !== 'PENDING'"
style="width: 250px"
:disabledDate="disabledDate"
:show-time="{ format: 'HH:mm' }"
class="range_picker"
placeholder="Select the start time"
>
<template #suffixIcon>
<span class="icon iconfont range_picker_icon icon-rili"></span>
</template>
</a-date-picker>
</div>
<div class="admin_state_item">
<span>
End Time:
<span>*</span>
</span>
<a-date-picker
v-model:value="formState.currentPeriodEnd"
value-format="X"
:show-time="{ format: 'HH:mm' }"
:disabledDate="disableEndDate"
:disabledTime="disableEndTime"
:show-now="!isEditMode"
style="width: 250px"
class="range_picker"
placeholder="Select the end time"
>
<template #suffixIcon>
<span class="icon iconfont range_picker_icon icon-rili"></span>
</template>
</a-date-picker>
</div>
<div class="admin_state_item">
<span>
Sub-Account Num:
<span>*</span>
</span>
<a-input-number
v-model:value="formState.accountNum"
:min="0"
style="width: 250px"
placeholder="Input the account number"
/>
</div>
<div class="admin_state_item">
<span>
Credit Limit:
<span>*</span>
</span>
<a-input-number
v-model:value="formState.creditLimit"
:min="0"
style="width: 250px"
placeholder="Input the credit limit"
/>
</div>
<div class="admin_state_item" v-if="!isEditMode">
<span>Status:</span>
<a-select
v-model:value="formState.status"
placeholder="Select status"
allow-clear
style="width: 250px"
:options="statusOption"
/>
</div>
</div>
</div>
<div class="subscriptionPlan_btn admin_page">
<div class="admin_search_item" @click="cancelModal">Close</div>
<div class="admin_search_item" @click="handleSubmitDebounced">OK</div>
</div>
</a-modal>
<div class="organizationModal" ref="organizationModal"></div>
<a-modal
class="organization_modal"
v-model:visible="organizationModalVisible"
:footer="null"
:get-container="() => $refs.organizationModal"
:maskClosable="false"
:centered="true"
:mask="true"
wrapClassName="#app"
:keyboard="false"
destroy-on-close
>
<div class="modal_title_text">
<div>Create Organization</div>
</div>
<div class="subscriptionPlan_center admin_page">
<div class="form_content">
<div class="admin_state_item">
<span>
Name:
<span>*</span>
</span>
<a-input
v-model:value="organizationForm.name"
placeholder="Input the name"
style="width: 250px"
/>
</div>
<div class="admin_state_item">
<span>
Type:
<span>*</span>
</span>
<a-select
v-model:value="organizationForm.type"
placeholder="Select type"
style="width: 250px"
>
<a-select-option value="Enterprise">Enterprise</a-select-option>
<a-select-option value="Education">Education</a-select-option>
</a-select>
</div>
</div>
</div>
<div class="organization_footer">
<div class="footer_btn ant-btn ant-btn-primary" @click="cancelOrganizationModal">
Close
</div>
<div class="footer_btn ant-btn ant-btn-primary" @click="handleCreateOrganization">
OK
</div>
</div>
</a-modal>
</div>
</template>
<script setup lang="ts">
2026-04-28 15:05:02 +08:00
import {
reactive,
ref,
onMounted,
onBeforeUnmount,
onActivated,
computed,
nextTick,
useTemplateRef
} from "vue"
import SelectUser from "@/component/common/SelectUser.vue"
import { message } from "ant-design-vue"
import { Https } from "@/tool/https"
import { formatTime } from "@/tool/util"
import store from "@/store"
import type { FormInstance, Rule } from "ant-design-vue/es/form"
import { debounce } from "lodash-es"
import dayjs, { Dayjs } from "dayjs"
type PlanStatus = "PENDING" | "ACTIVE" | "EXPIRED"
interface SubscriptionPlan {
id: number
userId?: string | number
name: string
currentPeriodStart: string
currentPeriodEnd: string
organizationId: string
adminAccId: string
adminAccEmail?: string
status: PlanStatus
creditLimit: number
accountNum?: number
startStamp: number
endStamp: number
2026-04-28 14:25:17 +08:00
}
2026-04-28 15:05:02 +08:00
interface UserInfoRecord {
id: number
userEmail?: string
userName?: string
language?: string
validStartTime?: number | string
validEndTime?: number | string
country?: string
createDate?: number | string
isBeginner?: number
browserIdentifiers?: string
credits?: number
systemUser?: number | string
}
const countryList = ref([])
const userRef = ref(null)
const searchForm = reactive({
name: "",
startTime: "",
endTime: "",
organizationId: undefined as string | undefined,
adminAccId: undefined as string | undefined,
status: [] as PlanStatus[] | [],
id: "",
countryOrRegion: null,
page: 1,
size: 10,
total: 0
})
const toSeconds = (dateStr: string) => Math.floor(new Date(dateStr).getTime() / 1000)
const tableData = ref<SubscriptionPlan[]>([])
const tableLoading = ref(false)
const userInfoModalVisible = ref(false)
const userInfoLoading = ref(false)
const userInfoList = ref<UserInfoRecord[]>([])
const currentUserInfoPlanId = ref<string | number | null>(null)
const userInfoPage = reactive({
current: 1,
size: 10,
total: 0
})
const modalVisible = ref(false)
const confirmLoading = ref(false)
const modalTitle = ref("New Subscription Plan")
const isEditMode = ref(false)
const formState = reactive({
name: "",
currentPeriodStart: "",
currentPeriodEnd: "",
organizationId: undefined as string | undefined,
adminAccId: undefined as string | undefined,
creditLimit: null as number | null,
accountNum: null as number | null,
status: undefined as PlanStatus | undefined,
countryOrRegion: null as string | null
})
const organizationModalVisible = ref(false)
const organizationForm = reactive({
name: "",
type: undefined as string | undefined
})
const statusLabelMap: Record<PlanStatus, string> = {
PENDING: "Pending",
ACTIVE: "Active",
EXPIRED: "Expired"
}
const statusColorMap: Record<PlanStatus, string> = {
PENDING: "blue",
ACTIVE: "green",
EXPIRED: "red"
}
const statusOption = ref([
{
label: "Pending",
value: "PENDING"
},
{
label: "Active",
value: "ACTIVE"
},
{
label: "Expired",
value: "EXPIRED"
}
])
const disabledDate = (current: Dayjs) => {
return current && current < dayjs().subtract(1, "days").endOf("day")
}
2026-04-28 15:05:02 +08:00
const disableEndDate = (current: Dayjs) => {
if (isEditMode.value) {
const specificTime = dayjs(formState.currentPeriodEnd)
return current && current < dayjs(formState.currentPeriodEnd * 1000).startOf("day")
}
return disabledDate(current)
2026-04-28 14:25:17 +08:00
}
2026-04-28 15:05:02 +08:00
const range = (start: number, end: number) => {
const result = []
for (let i = start; i < end; i++) {
result.push(i)
}
return result
2026-04-28 14:25:17 +08:00
}
2026-04-28 15:05:02 +08:00
const disableEndTime = (date) => {
if (!formState.currentPeriodEnd || !isEditMode.value)
return {
disabledHours: () => [],
disabledMinutes: () => [],
disabledSeconds: () => []
}
const specificTime = dayjs.unix(formState.currentPeriodEnd)
if (date && date.isSame(specificTime, "day")) {
// 如果是指定日期当天,禁用时间戳之前的时间
const hour = specificTime.hour()
const minute = specificTime.minute()
const second = specificTime.second()
return {
disabledHours: () => Array.from({ length: hour }, (_, i) => i), // 禁用小时之前
disabledMinutes: (selectedHour) => {
if (selectedHour === hour) {
return Array.from({ length: minute }, (_, i) => i) // 同小时,禁用分钟之前
}
return []
},
disabledSeconds: (selectedHour, selectedMinute) => {
if (selectedHour === hour && selectedMinute === minute) {
return Array.from({ length: second }, (_, i) => i) // 同小时分钟,禁用秒之前
}
return []
}
}
}
2026-04-28 14:25:17 +08:00
return {
disabledHours: () => [],
disabledMinutes: () => [],
disabledSeconds: () => []
}
2026-04-28 15:05:02 +08:00
}
2026-04-28 14:25:17 +08:00
2026-04-28 15:05:02 +08:00
const normalizeStatus = (status?: string): PlanStatus | undefined => {
if (!status) return undefined
const upper = status.toUpperCase() as PlanStatus
return upper
}
const getStatusColor = (status?: string) =>
statusColorMap[normalizeStatus(status) as PlanStatus] || "default"
2026-04-28 14:25:17 +08:00
2026-04-28 15:05:02 +08:00
const columns = [
{ title: "Name", dataIndex: "name", key: "name", align: "center", width: 180 },
{ title: "ID", dataIndex: "id", key: "id", align: "center", width: 80 },
{
title: "Organization",
dataIndex: "organizationName",
key: "organizationName",
align: "center",
width: 180
},
{
title: "Admin Account",
dataIndex: "adminAccEmail",
key: "adminAccEmail",
align: "center",
width: 180,
ellipsis: true
},
{
title: "Sub-Account Num",
dataIndex: "accountNum",
key: "accountNum",
align: "center",
width: 120,
ellipsis: true
},
{
title: "Country or Region",
dataIndex: "countryOrRegion",
key: "countryOrRegion",
align: "center",
width: 120,
ellipsis: true
},
{
title: "Start Time",
dataIndex: "currentPeriodStart",
key: "currentPeriodStart",
align: "center",
width: 200
},
{
title: "End Time",
dataIndex: "currentPeriodEnd",
key: "currentPeriodEnd",
align: "center",
width: 200
},
{ title: "Status", dataIndex: "status", key: "status", align: "center", width: 100 },
{
title: "Credit Limit",
dataIndex: "creditLimit",
key: "creditLimit",
align: "center",
width: 120
},
{ title: "Operations", key: "actions", width: 160, align: "center", fixed: "right" }
]
2026-04-28 14:25:17 +08:00
2026-04-28 15:05:02 +08:00
const userInfoColumns = [
{ title: "User Id", dataIndex: "id", key: "id", align: "center", width: 100 },
{
title: "Email",
dataIndex: "userEmail",
key: "userEmail",
align: "center",
width: 200,
ellipsis: true
},
{
title: "User Name",
dataIndex: "userName",
key: "userName",
align: "center",
width: 150,
ellipsis: true
},
{
title: "Valid Start Time",
dataIndex: "validStartTime",
key: "validStartTime",
align: "center",
width: 200
},
{
title: "Valid End Time",
dataIndex: "validEndTime",
key: "validEndTime",
align: "center",
width: 200
},
{
title: "Country or Region",
dataIndex: "country",
key: "country",
align: "center",
width: 180,
ellipsis: true
},
{
title: "Credits",
dataIndex: "credits",
key: "credits",
align: "center",
width: 100
},
{
title: "User Type",
dataIndex: "systemUser",
key: "systemUser",
align: "center",
width: 120
2026-04-28 14:25:17 +08:00
}
2026-04-28 15:05:02 +08:00
]
const systemUserLabelMap: Record<string, string> = {
"0": "visitor",
"1": "yearly",
"2": "monthly",
"3": "trial",
"4": "userInEvent",
"7": "Edu Admin"
2026-04-28 14:25:17 +08:00
}
2026-04-28 15:05:02 +08:00
const getSystemUserLabel = (systemUser?: number | string) => {
if (systemUser === undefined || systemUser === null) return ""
return systemUserLabelMap[String(systemUser)] || String(systemUser)
2026-04-28 14:25:17 +08:00
}
2026-04-28 15:05:02 +08:00
const formatUserTime = (value?: number | string) => {
if (!value) return ""
if (typeof value === "number") {
return formatTime(value / 1000, "YYYY-MM-DD hh:mm:ss")
}
if (/^\d+$/.test(value)) {
return formatTime(Number(value) / 1000, "YYYY-MM-DD hh:mm:ss")
}
return value
2026-04-28 14:25:17 +08:00
}
2026-04-28 15:05:02 +08:00
const normalizeUserInfoList = (res: any): UserInfoRecord[] => {
if (Array.isArray(res)) return res
if (Array.isArray(res?.records)) return res.records
if (res) return [res]
return []
2026-04-28 14:25:17 +08:00
}
2026-04-28 15:05:02 +08:00
const buildUserInfoParams = (id: string | number) => ({
endTime: "",
startTime: "",
size: userInfoPage.size,
page: userInfoPage.current,
systemUser: "",
country: "",
email: "",
userType: "",
ids: [],
occupation: "",
order: "",
orderBy: "",
userName: "",
subscriptionPlanId: id
})
const fetchUserInfo = async () => {
if (currentUserInfoPlanId.value === null) return
userInfoLoading.value = true
userInfoList.value = []
try {
const res = await Https.axiosPost(
Https.httpUrls.getUserInfo,
buildUserInfoParams(currentUserInfoPlanId.value)
)
const records = normalizeUserInfoList(res)
userInfoList.value = records
userInfoPage.total = Number(res?.total ?? records.length)
} catch (error: any) {
message.error(error.message || "Failed to load user info")
console.error(error)
} finally {
userInfoLoading.value = false
}
2026-04-28 14:25:17 +08:00
}
2026-04-28 15:05:02 +08:00
const openUserInfo = async (record: SubscriptionPlan) => {
console.log(record)
// debugger
currentUserInfoPlanId.value = record.id
userInfoPage.current = 1
userInfoModalVisible.value = true
await fetchUserInfo()
2026-04-28 14:25:17 +08:00
}
2026-04-28 15:05:02 +08:00
const changeUserInfoPage = (pagination: any) => {
userInfoPage.current = pagination.current
userInfoPage.size = pagination.pageSize
fetchUserInfo()
}
const customPlanRow = (record: SubscriptionPlan) => {
return {
onClick: (event: MouseEvent) => {
const target = event.target as HTMLElement | null
if (target?.closest(".plan-row-actions")) return
openUserInfo(record)
}
2026-04-28 14:25:17 +08:00
}
}
2026-04-28 15:05:02 +08:00
const historyTable = ref<HTMLElement | null>(null)
const historyTableHeight = ref(0)
const minTableBodyHeight = 120
let tableResizeObserver: ResizeObserver | null = null
let tableResizeTimer: ReturnType<typeof window.setTimeout> | null = null
const handleResizeColumn = (w: any, col: any) => {
col.width = w
2026-04-28 14:25:17 +08:00
}
2026-04-28 15:05:02 +08:00
const getElementOuterHeight = (element: Element | null) => {
if (!element) return 0
const htmlElement = element as HTMLElement
const style = window.getComputedStyle(htmlElement)
return (
htmlElement.offsetHeight +
Number.parseFloat(style.marginTop || "0") +
Number.parseFloat(style.marginBottom || "0")
)
}
const calculateTableHeight = () => {
if (tableResizeTimer) {
window.clearTimeout(tableResizeTimer)
}
tableResizeTimer = window.setTimeout(() => {
nextTick(() => {
const tableWrapper = historyTable.value
if (!tableWrapper) {
tableResizeTimer = null
return
}
const tableHead = tableWrapper.querySelector(".ant-table-thead") ?? null
const pagination = tableWrapper.querySelector(".ant-pagination") ?? null
const tableHeadHeight = getElementOuterHeight(tableHead) || 55
const paginationHeight = getElementOuterHeight(pagination) || 48
const reservedHeight = tableHeadHeight + paginationHeight + 8
historyTableHeight.value = Math.max(
minTableBodyHeight,
tableWrapper.clientHeight - reservedHeight
)
2026-04-28 14:25:17 +08:00
tableResizeTimer = null
2026-04-28 15:05:02 +08:00
})
}, 50)
}
2026-04-28 14:25:17 +08:00
2026-04-28 15:05:02 +08:00
const handleResize = () => {
calculateTableHeight()
}
2026-04-28 14:25:17 +08:00
2026-04-28 15:05:02 +08:00
const setupTableResizeObserver = () => {
if (!historyTable.value || typeof ResizeObserver === "undefined") return
tableResizeObserver?.disconnect()
tableResizeObserver = new ResizeObserver(() => {
calculateTableHeight()
2026-04-28 14:25:17 +08:00
})
2026-04-28 15:05:02 +08:00
tableResizeObserver.observe(historyTable.value)
const searchCard = historyTable.value
.closest(".subscription-plan")
?.querySelector(".search-card")
if (searchCard) {
tableResizeObserver.observe(searchCard)
}
}
2026-04-28 15:05:02 +08:00
onMounted(async () => {
await getOrganizationList()
await handleSearch()
calculateTableHeight()
setupTableResizeObserver()
window.addEventListener("resize", handleResize)
const list = sessionStorage.getItem("allCountry")
countryList.value = list ? JSON.parse(list) : []
})
2026-04-28 14:25:17 +08:00
2026-04-28 15:05:02 +08:00
onActivated(() => {
2026-04-28 14:25:17 +08:00
calculateTableHeight()
})
2026-04-28 15:05:02 +08:00
onBeforeUnmount(() => {
window.removeEventListener("resize", handleResize)
tableResizeObserver?.disconnect()
if (tableResizeTimer) {
window.clearTimeout(tableResizeTimer)
tableResizeTimer = null
}
})
const handleFetchTableData = async () => {
tableLoading.value = true
return Https.axiosPost(Https.httpUrls.searchAllSubscribePlan, searchForm)
.then((res) => {
tableData.value = res.records
searchForm.total = res.total
})
.finally(() => {
tableLoading.value = false
calculateTableHeight()
})
2026-04-28 14:25:17 +08:00
}
2026-04-28 15:05:02 +08:00
const resetFormState = () => {
formState.name = ""
formState.currentPeriodStart = ""
formState.currentPeriodEnd = ""
formState.organizationId = undefined
formState.adminAccId = undefined
formState.creditLimit = null
formState.accountNum = null
formState.status = undefined
formState.countryOrRegion = null
2026-04-28 14:25:17 +08:00
}
2026-04-28 15:05:02 +08:00
const changePage = (pagination: any) => {
searchForm.page = pagination.current
searchForm.size = pagination.pageSize
handleFetchTableData()
}
const handleSearch = () => {
searchForm.page = 1
handleFetchTableData()
}
const handleReset = () => {
searchForm.name = ""
searchForm.startTime = ""
searchForm.endTime = ""
searchForm.organizationId = undefined
searchForm.adminAccId = undefined
searchForm.status = []
searchForm.id = ""
searchForm.countryOrRegion = null
handleSearch()
}
const openCreate = () => {
modalTitle.value = "New Subscription Plan"
isEditMode.value = false
resetFormState()
modalVisible.value = true
}
const openEdit = (record: SubscriptionPlan) => {
modalTitle.value = "Edit Subscription Plan"
isEditMode.value = true
formState.name = record.name
formState.currentPeriodStart = String(record.currentPeriodStart)
formState.currentPeriodEnd = String(record.currentPeriodEnd)
formState.organizationId = record.organizationId
formState.adminAccId = record.adminAccId
formState.creditLimit = record.creditLimit
formState.accountNum = (record as any).accountNum || null
formState.status = record.status
formState.id = record.id
formState.countryOrRegion = (record as any).countryOrRegion || null
// 检查组织ID是否在已加载的组织列表中如果不在则添加临时项
if (record.organizationId) {
const orgExists = organizationOptions.value.some(
(org: any) =>
org.id === record.organizationId ||
String(org.id) === String(record.organizationId)
)
if (!orgExists) {
const orgName = (record as any).organizationName
if (orgName) {
organizationOptions.value = [
{
id: record.organizationId,
name: orgName
},
...organizationOptions.value
]
}
2026-04-28 14:25:17 +08:00
}
}
2026-04-28 15:05:02 +08:00
modalVisible.value = true
if (record.adminAccId) {
nextTick(() => {
userRef.value.patchList({
label: record.name,
value: record.adminAccId,
email: record.adminAccEmail
})
2026-04-28 14:25:17 +08:00
})
2026-04-28 15:05:02 +08:00
}
2026-04-28 14:25:17 +08:00
}
2026-04-28 15:05:02 +08:00
const validateForm = (): boolean => {
interface FieldRule {
value: any
message: string
checkNull?: boolean
}
2026-04-28 14:25:17 +08:00
2026-04-28 15:05:02 +08:00
const requiredFields: FieldRule[] = [
{ value: formState.currentPeriodStart, message: "Please select the start time" },
{ value: formState.currentPeriodEnd, message: "Please select the end time" },
{ value: formState.adminAccId, message: "Please select the admin account" },
{
value: formState.creditLimit,
message: "Please input credit limit",
checkNull: true
},
{
value: formState.accountNum,
message: "Please input account number",
checkNull: true
}
]
if (!isEditMode.value) {
requiredFields.push(
{ value: formState.name, message: "Please input the name" },
{ value: formState.organizationId, message: "Please select organization" }
)
2026-04-28 14:25:17 +08:00
}
2026-04-28 15:05:02 +08:00
for (const field of requiredFields) {
const isEmpty = field.checkNull
? field.value === null || field.value === undefined
: !field.value
if (isEmpty) {
message.warning(field.message)
return false
}
}
return true
2026-04-28 14:25:17 +08:00
}
2026-04-28 15:05:02 +08:00
const handleSubmit = async () => {
if (!validateForm()) return
confirmLoading.value = true
const params = {
...formState,
currentPeriodStart: Number(formState.currentPeriodStart),
currentPeriodEnd: Number(formState.currentPeriodEnd)
}
let res = null
try {
if (isEditMode.value) {
res = await Https.axiosPost(Https.httpUrls.updateSubscribePlan, params)
} else {
res = await Https.axiosPost(Https.httpUrls.createSubscribePlan, params)
}
message.success(
`${isEditMode.value ? "Subscription plan updated" : "Subscription plan created"}`
)
} catch (error: any) {
message.error(error.message)
console.error(error)
} finally {
confirmLoading.value = false
modalVisible.value = false
resetFormState()
isEditMode.value = false
handleSearch()
2026-04-28 14:25:17 +08:00
}
}
2026-04-28 15:05:02 +08:00
// 防抖包装,避免重复点击
const handleSubmitDebounced = debounce(handleSubmit, 500, {
leading: true,
trailing: false
})
2026-04-28 15:05:02 +08:00
const cancelModal = () => {
2026-04-28 14:25:17 +08:00
modalVisible.value = false
resetFormState()
isEditMode.value = false
}
2026-04-28 15:05:02 +08:00
const removePlan = (id: number) => {
tableLoading.value = true
Https.axiosGet(Https.httpUrls.deleteSubscribePlan, { params: { id } })
.then((res) => {
message.success("Subscription plan deleted")
handleReset()
})
.catch((error: any) => {
message.error(error.message)
console.error(error)
2026-04-28 14:25:17 +08:00
})
2026-04-28 15:05:02 +08:00
.finally(() => {
tableLoading.value = false
})
}
const organizationOptions = ref([])
const organizationParams = reactive({
page: 1,
size: 10,
total: 0
})
const organizationLoading = ref(false)
const getOrganizationList = async (isLoadMore = false) => {
if (organizationLoading.value) return
if (isLoadMore) {
const loaded = organizationParams.page * organizationParams.size
if (organizationParams.total && loaded >= organizationParams.total) return
organizationParams.page += 1
} else {
organizationParams.page = 1
organizationOptions.value = []
}
organizationLoading.value = true
try {
const { total, ...requestParams } = organizationParams
const rv: any = await Https.axiosPost(Https.httpUrls.queryOrganization, requestParams)
if (rv) {
const newRecords = rv.records || []
// 遍历新数据,如果已存在则覆盖,不存在则追加
newRecords.forEach((newOrg: any) => {
const newOrgId = String(newOrg.id)
const existingIndex = organizationOptions.value.findIndex(
(org: any) => String(org.id) === newOrgId
)
if (existingIndex !== -1) {
// 如果已存在,用新数据覆盖旧项
organizationOptions.value[existingIndex] = newOrg
} else {
// 如果不存在,追加到末尾
organizationOptions.value.push(newOrg)
}
})
organizationParams.total = rv.total || 0
}
} finally {
organizationLoading.value = false
2026-04-28 14:25:17 +08:00
}
}
2026-04-28 15:05:02 +08:00
const handleOrganizationScroll = (e: any) => {
const target = e?.target
if (!target) return
const nearBottom = target.scrollTop + target.clientHeight >= target.scrollHeight - 20
if (nearBottom) {
getOrganizationList(true)
}
2026-04-28 14:25:17 +08:00
}
2026-04-28 15:05:02 +08:00
const handleOrganizationSelect = (value: string) => {
if (value === "ADD_ORGANIZATION") {
// 打开添加组织弹窗
organizationModalVisible.value = true
// 使用nextTick确保值被重置使其不被选中
nextTick(() => {
if (searchForm.organizationId === "ADD_ORGANIZATION") {
searchForm.organizationId = undefined
}
if (formState.organizationId === "ADD_ORGANIZATION") {
formState.organizationId = undefined
}
})
}
2026-04-28 14:25:17 +08:00
}
2026-04-28 15:05:02 +08:00
const handleOrganizationChange = (value: string) => {
// 如果change事件触发时值是"添加组织",立即重置
if (value === "ADD_ORGANIZATION") {
nextTick(() => {
if (searchForm.organizationId === "ADD_ORGANIZATION") {
searchForm.organizationId = undefined
}
if (formState.organizationId === "ADD_ORGANIZATION") {
formState.organizationId = undefined
}
})
}
2026-04-28 14:25:17 +08:00
}
2026-04-28 15:05:02 +08:00
const cancelOrganizationModal = () => {
organizationModalVisible.value = false
organizationForm.name = ""
organizationForm.type = undefined
2026-04-28 14:25:17 +08:00
}
2026-04-28 15:05:02 +08:00
const handleCreateOrganization = async () => {
if (!organizationForm.name || !organizationForm.type) {
message.warning("Please fill in name and type")
return
}
try {
const res: any = await Https.axiosGet(Https.httpUrls.addOrganization, {
params: {
name: organizationForm.name,
type: organizationForm.type
}
})
message.success("Organization created successfully")
cancelOrganizationModal()
// 刷新组织列表
await getOrganizationList()
// 如果是在编辑/新建弹窗中,自动选择新创建的组织
if (modalVisible.value) {
const newOrgId = res?.id || res?.data?.id || res
if (newOrgId) {
formState.organizationId = String(newOrgId)
}
2026-04-28 14:25:17 +08:00
}
2026-04-28 15:05:02 +08:00
} catch (error: any) {
message.error(error.message || "Failed to create organization")
console.error(error)
2026-04-28 14:25:17 +08:00
}
}
2025-12-15 10:39:19 +08:00
2026-04-28 15:05:02 +08:00
const filterOption = (input: string, option: any) => {
const label = option?.label ?? option?.children ?? option?.key?.label ?? ""
return String(label).toLowerCase().includes(input.toLowerCase())
}
</script>
<style lang="less" scoped>
2026-04-28 15:05:02 +08:00
.subscription-plan {
padding: 2rem 2.4rem 0 0;
2026-04-28 14:25:17 +08:00
display: flex;
2026-04-28 15:05:02 +08:00
height: 100%;
2026-04-28 14:25:17 +08:00
min-height: 0;
2026-04-28 15:05:02 +08:00
flex-direction: column;
2026-04-28 14:25:17 +08:00
2026-04-28 15:05:02 +08:00
.search-card {
margin-bottom: 1.6rem;
2026-04-28 14:25:17 +08:00
flex-shrink: 0;
}
2026-04-28 15:05:02 +08:00
.table-card {
2026-04-28 14:25:17 +08:00
flex: 1;
2026-04-28 15:05:02 +08:00
display: flex;
flex-direction: column;
2026-04-28 14:25:17 +08:00
overflow: hidden;
min-height: 0;
2026-04-28 15:05:02 +08:00
:deep(.ant-card-body) {
flex: 1;
2026-04-28 14:25:17 +08:00
display: flex;
flex-direction: column;
overflow: hidden;
2026-04-28 15:05:02 +08:00
padding: 2.4rem 2.4rem 0;
min-height: 0;
}
.table-card__header {
display: flex;
justify-content: flex-end;
margin-bottom: 2.6rem;
flex-shrink: 0;
2026-04-28 14:25:17 +08:00
}
2026-04-28 15:05:02 +08:00
.table-wrapper {
2026-04-28 14:25:17 +08:00
flex: 1;
overflow: hidden;
2026-04-28 15:05:02 +08:00
min-height: 0;
:deep(.ant-table-wrapper),
:deep(.ant-spin-nested-loading),
:deep(.ant-spin-container) {
height: 100%;
}
:deep(.ant-spin-container) {
display: flex;
flex-direction: column;
overflow: hidden;
}
:deep(.ant-table) {
flex: 1;
min-height: 0;
overflow: hidden;
}
:deep(.ant-table-container) {
height: 100%;
display: flex;
flex-direction: column;
}
:deep(.ant-table-content) {
height: 100%;
}
:deep(.ant-table-body) {
overflow-y: auto !important;
}
:deep(.ant-pagination) {
flex-shrink: 0;
}
2026-04-28 14:25:17 +08:00
}
2026-04-28 15:05:02 +08:00
.danger-text {
color: #ff4d4f;
}
:deep(.ant-table-cell::before) {
display: none;
2026-04-28 14:25:17 +08:00
}
2026-04-28 15:05:02 +08:00
:deep(.ant-table-thead > tr > th) {
border-bottom: none;
}
:deep(.ant-table-tbody > tr > td) {
border: none;
2026-04-28 14:25:17 +08:00
}
2026-04-28 15:05:02 +08:00
:deep(.ant-table-tbody > tr:hover > td) {
background: rgb(202, 202, 202);
2026-04-28 14:25:17 +08:00
}
2026-04-28 15:05:02 +08:00
:deep(.ant-table-tbody > tr) {
cursor: pointer;
}
:deep(.plan-row-actions) {
cursor: default;
2026-04-28 14:25:17 +08:00
}
}
2026-04-28 15:05:02 +08:00
}
2026-04-28 14:25:17 +08:00
2026-04-28 15:05:02 +08:00
.user-info-modal {
:deep(.ant-modal-body) {
padding: 2.4rem;
2026-04-28 14:25:17 +08:00
}
2026-04-28 15:05:02 +08:00
}
:deep(.subscriptionPlan_modal) {
.ant-modal-body {
// height: calc(65rem * 1.2);
display: flex;
flex-direction: column;
padding: 2.5rem 3rem;
position: relative;
2026-04-28 14:25:17 +08:00
}
2026-04-28 15:05:02 +08:00
}
2026-04-28 14:25:17 +08:00
2026-04-28 15:05:02 +08:00
.subscriptionPlan_modal {
.form_content {
width: 100%;
display: flex;
flex-wrap: wrap;
row-gap: 2rem;
2026-04-28 14:25:17 +08:00
}
2026-04-28 15:05:02 +08:00
.admin_state_item {
> span {
width: 17rem !important;
}
2026-04-28 14:25:17 +08:00
}
2026-04-28 15:05:02 +08:00
.modal_title_text {
margin-bottom: 2rem;
flex-shrink: 0;
2026-04-28 14:25:17 +08:00
}
2026-04-28 15:05:02 +08:00
.subscriptionPlan_center {
flex: 1;
overflow-y: auto;
display: flex;
flex-direction: row;
flex-wrap: wrap;
align-items: center;
justify-content: center;
padding: 2rem 0;
2026-04-28 14:25:17 +08:00
}
2026-04-28 15:05:02 +08:00
.subscriptionPlan_btn {
display: flex;
flex-direction: row;
height: auto;
justify-content: flex-end;
padding: 1.5rem 0 0 0;
margin-top: auto;
flex-shrink: 0;
.admin_search_item {
margin-bottom: 0;
}
2026-04-28 14:25:17 +08:00
}
}
2026-04-28 15:05:02 +08:00
:deep(.search-form) {
--search-label-width: 14rem;
--search-control-width: 20rem;
align-items: flex-start;
column-gap: 1.8rem;
row-gap: 1.8rem;
2026-04-28 15:05:02 +08:00
.ant-form-item {
min-width: calc(var(--search-label-width) + var(--search-control-width));
margin-right: 0;
2026-04-28 14:25:17 +08:00
margin-bottom: 0;
}
2026-04-28 15:05:02 +08:00
.ant-form-item-label {
flex: 0 0 var(--search-label-width);
max-width: var(--search-label-width);
overflow: visible;
2026-04-28 14:25:17 +08:00
white-space: nowrap;
2026-04-28 15:05:02 +08:00
> label {
white-space: nowrap;
}
2026-04-28 14:25:17 +08:00
}
2026-04-28 15:05:02 +08:00
.ant-form-item-control {
flex: 0 0 var(--search-control-width);
max-width: var(--search-control-width);
}
2026-04-28 14:25:17 +08:00
2026-04-28 15:05:02 +08:00
.ant-input,
.ant-input-affix-wrapper,
.ant-picker,
.ant-select {
width: 100% !important;
}
2026-04-28 14:25:17 +08:00
2026-04-28 15:05:02 +08:00
.search-form__actions {
min-width: auto;
2026-04-28 14:25:17 +08:00
2026-04-28 15:05:02 +08:00
.ant-form-item-control {
flex: 0 0 auto;
max-width: none;
}
2026-04-28 14:25:17 +08:00
}
}
2026-04-28 15:05:02 +08:00
@media (min-width: 1600px) {
:deep(.search-form) {
--search-control-width: 22rem;
}
2026-04-28 14:25:17 +08:00
}
2026-04-28 15:05:02 +08:00
@media (max-width: 760px) {
:deep(.search-form) {
.ant-form-item {
flex: 1 1 100%;
min-width: 100%;
}
2026-04-28 14:25:17 +08:00
2026-04-28 15:05:02 +08:00
.ant-form-item-control {
flex: 1 1 auto;
max-width: calc(100% - var(--search-label-width));
}
2026-04-28 14:25:17 +08:00
}
}
2026-04-28 15:05:02 +08:00
:deep(.ant-select-dropdown) {
.add-organization-option {
color: #1890ff !important;
font-weight: 600;
background-color: #f0f7ff !important;
border-bottom: 1px solid #e6f4ff;
margin-bottom: 4px;
padding-bottom: 4px;
&:hover {
background-color: #e6f4ff !important;
}
2026-04-28 14:25:17 +08:00
}
}
2025-12-15 10:39:19 +08:00
2026-04-28 15:05:02 +08:00
:deep(.organization_modal) {
.ant-modal-body {
height: auto;
min-height: 300px;
display: flex;
flex-direction: column;
padding: 2rem 2.5rem;
position: relative;
}
2026-04-28 14:25:17 +08:00
2026-04-28 15:05:02 +08:00
.subscriptionPlan_center {
flex: 0 0 auto;
overflow: visible;
padding: 1rem 0;
min-height: auto;
}
2026-04-28 14:25:17 +08:00
2026-04-28 15:05:02 +08:00
.modal_title_text {
margin-bottom: 1.5rem;
}
2026-04-28 14:25:17 +08:00
}
2026-04-28 15:05:02 +08:00
.organization_footer {
display: flex;
justify-content: flex-end;
column-gap: 3rem;
.footer_btn {
border-radius: 3.3rem;
}
2026-04-28 14:25:17 +08:00
}
</style>