Files
aida_front/src/component/Administrator/subscriptionPlan.vue
2026-04-28 15:05:02 +08:00

1537 lines
37 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<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> -->
<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">
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
}
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")
}
const disableEndDate = (current: Dayjs) => {
if (isEditMode.value) {
const specificTime = dayjs(formState.currentPeriodEnd)
return current && current < dayjs(formState.currentPeriodEnd * 1000).startOf("day")
}
return disabledDate(current)
}
const range = (start: number, end: number) => {
const result = []
for (let i = start; i < end; i++) {
result.push(i)
}
return result
}
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 []
}
}
}
return {
disabledHours: () => [],
disabledMinutes: () => [],
disabledSeconds: () => []
}
}
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"
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" }
]
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
}
]
const systemUserLabelMap: Record<string, string> = {
"0": "visitor",
"1": "yearly",
"2": "monthly",
"3": "trial",
"4": "userInEvent",
"7": "Edu Admin"
}
const getSystemUserLabel = (systemUser?: number | string) => {
if (systemUser === undefined || systemUser === null) return ""
return systemUserLabelMap[String(systemUser)] || String(systemUser)
}
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
}
const normalizeUserInfoList = (res: any): UserInfoRecord[] => {
if (Array.isArray(res)) return res
if (Array.isArray(res?.records)) return res.records
if (res) return [res]
return []
}
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
}
}
const openUserInfo = async (record: SubscriptionPlan) => {
console.log(record)
// debugger
currentUserInfoPlanId.value = record.id
userInfoPage.current = 1
userInfoModalVisible.value = true
await fetchUserInfo()
}
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)
}
}
}
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
}
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
)
tableResizeTimer = null
})
}, 50)
}
const handleResize = () => {
calculateTableHeight()
}
const setupTableResizeObserver = () => {
if (!historyTable.value || typeof ResizeObserver === "undefined") return
tableResizeObserver?.disconnect()
tableResizeObserver = new ResizeObserver(() => {
calculateTableHeight()
})
tableResizeObserver.observe(historyTable.value)
const searchCard = historyTable.value
.closest(".subscription-plan")
?.querySelector(".search-card")
if (searchCard) {
tableResizeObserver.observe(searchCard)
}
}
onMounted(async () => {
await getOrganizationList()
await handleSearch()
calculateTableHeight()
setupTableResizeObserver()
window.addEventListener("resize", handleResize)
const list = sessionStorage.getItem("allCountry")
countryList.value = list ? JSON.parse(list) : []
})
onActivated(() => {
calculateTableHeight()
})
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()
})
}
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
}
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
]
}
}
}
modalVisible.value = true
if (record.adminAccId) {
nextTick(() => {
userRef.value.patchList({
label: record.name,
value: record.adminAccId,
email: record.adminAccEmail
})
})
}
}
const validateForm = (): boolean => {
interface FieldRule {
value: any
message: string
checkNull?: boolean
}
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" }
)
}
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
}
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()
}
}
// 防抖包装,避免重复点击
const handleSubmitDebounced = debounce(handleSubmit, 500, {
leading: true,
trailing: false
})
const cancelModal = () => {
modalVisible.value = false
resetFormState()
isEditMode.value = false
}
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)
})
.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
}
}
const handleOrganizationScroll = (e: any) => {
const target = e?.target
if (!target) return
const nearBottom = target.scrollTop + target.clientHeight >= target.scrollHeight - 20
if (nearBottom) {
getOrganizationList(true)
}
}
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
}
})
}
}
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
}
})
}
}
const cancelOrganizationModal = () => {
organizationModalVisible.value = false
organizationForm.name = ""
organizationForm.type = undefined
}
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)
}
}
} catch (error: any) {
message.error(error.message || "Failed to create organization")
console.error(error)
}
}
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>
.subscription-plan {
padding: 2rem 2.4rem 0 0;
display: flex;
height: 100%;
min-height: 0;
flex-direction: column;
.search-card {
margin-bottom: 1.6rem;
flex-shrink: 0;
}
.table-card {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
min-height: 0;
:deep(.ant-card-body) {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
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;
}
.table-wrapper {
flex: 1;
overflow: hidden;
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;
}
}
.danger-text {
color: #ff4d4f;
}
:deep(.ant-table-cell::before) {
display: none;
}
:deep(.ant-table-thead > tr > th) {
border-bottom: none;
}
:deep(.ant-table-tbody > tr > td) {
border: none;
}
:deep(.ant-table-tbody > tr:hover > td) {
background: rgb(202, 202, 202);
}
:deep(.ant-table-tbody > tr) {
cursor: pointer;
}
:deep(.plan-row-actions) {
cursor: default;
}
}
}
.user-info-modal {
:deep(.ant-modal-body) {
padding: 2.4rem;
}
}
:deep(.subscriptionPlan_modal) {
.ant-modal-body {
// height: calc(65rem * 1.2);
display: flex;
flex-direction: column;
padding: 2.5rem 3rem;
position: relative;
}
}
.subscriptionPlan_modal {
.form_content {
width: 100%;
display: flex;
flex-wrap: wrap;
row-gap: 2rem;
}
.admin_state_item {
> span {
width: 17rem !important;
}
}
.modal_title_text {
margin-bottom: 2rem;
flex-shrink: 0;
}
.subscriptionPlan_center {
flex: 1;
overflow-y: auto;
display: flex;
flex-direction: row;
flex-wrap: wrap;
align-items: center;
justify-content: center;
padding: 2rem 0;
}
.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;
}
}
}
:deep(.search-form) {
--search-label-width: 14rem;
--search-control-width: 20rem;
align-items: flex-start;
column-gap: 1.8rem;
row-gap: 1.8rem;
.ant-form-item {
min-width: calc(var(--search-label-width) + var(--search-control-width));
margin-right: 0;
margin-bottom: 0;
}
.ant-form-item-label {
flex: 0 0 var(--search-label-width);
max-width: var(--search-label-width);
overflow: visible;
white-space: nowrap;
> label {
white-space: nowrap;
}
}
.ant-form-item-control {
flex: 0 0 var(--search-control-width);
max-width: var(--search-control-width);
}
.ant-input,
.ant-input-affix-wrapper,
.ant-picker,
.ant-select {
width: 100% !important;
}
.search-form__actions {
min-width: auto;
.ant-form-item-control {
flex: 0 0 auto;
max-width: none;
}
}
}
@media (min-width: 1600px) {
:deep(.search-form) {
--search-control-width: 22rem;
}
}
@media (max-width: 760px) {
:deep(.search-form) {
.ant-form-item {
flex: 1 1 100%;
min-width: 100%;
}
.ant-form-item-control {
flex: 1 1 auto;
max-width: calc(100% - var(--search-label-width));
}
}
}
: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;
}
}
}
:deep(.organization_modal) {
.ant-modal-body {
height: auto;
min-height: 300px;
display: flex;
flex-direction: column;
padding: 2rem 2.5rem;
position: relative;
}
.subscriptionPlan_center {
flex: 0 0 auto;
overflow: visible;
padding: 1rem 0;
min-height: auto;
}
.modal_title_text {
margin-bottom: 1.5rem;
}
}
.organization_footer {
display: flex;
justify-content: flex-end;
column-gap: 3rem;
.footer_btn {
border-radius: 3.3rem;
}
}
</style>