feat: 超级管理员订阅计划页面

This commit is contained in:
2025-12-12 17:34:11 +08:00
parent 0ac6d6e93f
commit c9b65b6090
5 changed files with 688 additions and 0 deletions

View File

@@ -0,0 +1,669 @@
<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: '12rem' } }"
:wrapper-col="{ style: { width: '22rem' } }"
>
<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"
>
<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">
<a-select
v-model:value="searchForm.adminAccId"
allow-clear
show-search
:filter-option="filterOption"
placeholder="Select Account"
style="width: 180px"
:options="allUserList"
></a-select>
</a-form-item>
<a-form-item>
<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">
<div class="table-card__title">Subscription Plan</div>
<a-button type="primary" @click="openCreate">New Subscription Plan</a-button>
</div>
<a-table
:data-source="tableData"
:columns="columns"
:loading="tableLoading"
row-key="id"
: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 === 'organizationId'">
{{ organizationOptions.find(item => item.id === record[column.key]).name }}
</template> -->
<template v-if="column.key === 'status'">
<a-tag :color="statusColorMap[record.status]">
{{ statusLabelMap[record.status] }}
</a-tag>
</template>
<template v-if="column.key === 'adminAccId'">
{{ allUserList.find(item => item.value === record.adminAccId)?.label }}
</template>
<template v-else-if="column.key === 'actions'">
<a-space>
<a @click="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">Delete</a>
</a-popconfirm>
</a-space>
</template>
</template>
</a-table>
</a-card>
<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">
<span>
Name:
<span>*</span>
</span>
<a-input
v-model:value="formState.name"
placeholder="Input the name"
style="width: 250px"
:disabled="isEditMode"
/>
</div>
<div class="admin_state_item">
<span>
Organization:
<span>*</span>
</span>
<a-select
v-model:value="formState.organizationId"
placeholder="Select the organization"
allow-clear
style="width: 250px"
@popupScroll="handleOrganizationScroll"
:disabled="isEditMode"
>
<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>
Admin Account:
<span>*</span>
</span>
<a-select
v-model:value="formState.adminAccId"
placeholder="Select the admin account"
allow-clear
show-search
:filter-option="filterOption"
style="width: 250px"
:options="allUserList"
></a-select>
</div>
<div class="admin_state_item">
<span>
Start Time:
<span>*</span>
</span>
<a-date-picker
v-model:value="formState.currentPeriodStart"
value-format="X"
style="width: 250px"
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"
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>
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>
</div>
<div class="subscriptionPlan_btn admin_page">
<div class="admin_search_item" @click="cancelModal">Close</div>
<div class="admin_search_item" @click="handleSubmit">OK</div>
</div>
</a-modal>
</div>
</template>
<script setup lang="ts">
import { reactive, ref, onMounted, computed } from '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'
type PlanStatus = 'active' | 'paused' | 'ended'
interface SubscriptionPlan {
id: number
name: string
currentPeriodStart: string
currentPeriodEnd: string
organizationId: string
adminAccId: string
status: PlanStatus
creditLimit: number
accountNum?: number
startStamp: number
endStamp: number
}
const searchForm = reactive({
name: '',
startTime: '',
endTime: '',
organizationId: undefined as string | undefined,
adminAccId: undefined as string | undefined,
id: '',
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 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
})
const statusLabelMap: Record<PlanStatus, string> = {
active: 'Active',
paused: 'Paused',
ended: 'Ended'
}
const statusColorMap: Record<PlanStatus, string> = {
active: 'green',
paused: 'orange',
ended: 'red'
}
const columns = [
{ title: 'Name', dataIndex: 'name', key: 'name' },
{ title: 'ID', dataIndex: 'id', key: 'id' },
{ title: 'Organization', dataIndex: 'organizationName', key: 'organizationName' },
{ title: 'Admin Account', dataIndex: 'adminAccId', key: 'adminAccId' },
{ title: 'Account Num', dataIndex: 'accountNum', key: 'accountNum' },
{
title: 'Start Time',
dataIndex: 'currentPeriodStart',
key: 'currentPeriodStart'
},
{
title: 'End Time',
dataIndex: 'currentPeriodEnd',
key: 'currentPeriodEnd'
},
{ title: 'Status', dataIndex: 'status', key: 'status' },
{ title: 'Credit Limit', dataIndex: 'creditLimit', key: 'creditLimit' },
{ title: 'Operations', key: 'actions', width: 160 }
]
onMounted(async () => {
await getOrganizationList()
await handleSearch()
})
const handleFetchTableData = async () => {
tableLoading.value = true
return Https.axiosPost(Https.httpUrls.searchAllSubscribePlan, searchForm)
.then(res => {
tableData.value = res.records.map(item => {
const organization = organizationOptions.value.find(
el => el.id === item.organizationId
) || { name: '' }
return {
...item,
organizationName: organization.name || ''
}
debugger
})
searchForm.total = res.total
})
.finally(() => {
tableLoading.value = false
})
}
const resetFormState = () => {
formState.name = ''
formState.currentPeriodStart = ''
formState.currentPeriodEnd = ''
formState.organizationId = undefined
formState.adminAccId = undefined
formState.creditLimit = null
formState.accountNum = null
}
const handleSearch = () => {
searchForm.page = 1
handleFetchTableData()
}
const handleReset = () => {
searchForm.name = ''
searchForm.startTime = ''
searchForm.endTime = ''
searchForm.organizationId = undefined
searchForm.adminAccId = undefined
searchForm.id = ''
handleSearch()
}
const allUserList = computed(() => {
return store.state.adminPage.allUserList
})
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 = record.currentPeriodStart
formState.currentPeriodEnd = record.currentPeriodEnd
formState.organizationId = record.organizationId
formState.adminAccId = record.adminAccId
formState.creditLimit = record.creditLimit
formState.accountNum = (record as any).accountNum || null
modalVisible.value = true
}
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 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 rv: any = await Https.axiosPost(
Https.httpUrls.queryOrganization,
organizationParams
)
if (rv) {
organizationOptions.value = [...organizationOptions.value, ...(rv.records || [])]
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 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: 20px 24px 32px 0;
.search-card {
margin-bottom: 16px;
}
.table-card {
.table-card__header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
.table-card__title {
font-size: 18px;
font-weight: 500;
}
}
.danger-text {
color: #ff4d4f;
}
}
}
</style>
<style lang="less" scoped>
:deep(.subscriptionPlan_modal) {
.ant-modal-body {
height: calc(65rem * 1.2);
display: flex;
flex-direction: column;
padding: 2.5rem 3rem;
position: relative;
}
}
.subscriptionPlan_modal {
> .admin_state_item {
> span {
width: 15rem;
}
}
.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) {
column-gap: 2rem;
row-gap: 2rem;
.ant-select {
width: 100% !important;
}
}
</style>

View File

@@ -854,6 +854,7 @@ export default defineComponent({
height: 4rem;
box-sizing: border-box;
line-height: 4rem;
border-radius: 0.6rem;
}
> .payMethod_payAffirm_clause {
text-align: center;

View File

@@ -290,6 +290,13 @@ const routes: Array<RouteRecordRaw> = [
component: () =>
import("@/component/Administrator/organization/organization.vue"),
},
{
path: "subscriptionPlan",
name: "subscriptionPlan",
meta: { enter: 3 },
component: () =>
import("@/component/Administrator/subscriptionPlan.vue"),
},
//企业版教育管理员页面
{
path: "allUserSE",

View File

@@ -177,6 +177,12 @@ const all = (t)=>{
route:'/administrator/organization',
key:'sub13',
isShow:true,
},{
name:'Subscription Plan',
icon:'usetime',
route:'/administrator/subscriptionPlan',
key:'sub14',
isShow:true,
}]
}
const schoolOrEnterprise = (t) =>{

View File

@@ -325,6 +325,11 @@ export const Https = {
deletePromCode:`/api/stripe/deletePromCode`,//删除优惠券
addOrganization:`/api/inquiry/addOrganization`,//添加企业版或者教育版
queryOrganization:`/api/inquiry/queryOrganization`,//查询企业版或者教育版
createSubscribePlan:'/api/subscription_plan/createPlan', // 创建订阅计划
deleteSubscribePlan: '/api/subscription_plan/deletePlan', // 删除订阅计划
updateSubscribePlan: '/api/subscription_plan/updatePlan', // 修改订阅计划
searchAllSubscribePlan: '/api/subscription_plan/searchByPage', // 查询所有订阅计划
switchSubscribePlan:'/api/subscription_plan/switchSubscriptionPlan', // 切换订阅计划
//云生成