feat(web): package menu

This commit is contained in:
zhaoying
2026-04-17 12:20:15 +08:00
parent e539b3eeb7
commit e15e32cc7b
30 changed files with 790 additions and 148 deletions

View File

@@ -0,0 +1,119 @@
/*
* @Author: ZhaoYing
* @Date: 2026-04-14 12:28:23
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-04-16 17:34:02
*/
import { useState, forwardRef, useImperativeHandle } from 'react';
import { Flex, Tooltip, Divider } from 'antd';
import { useTranslation } from 'react-i18next';
import clsx from 'clsx';
import RbModal from '@/components/RbModal';
import type { Subscription } from './index'
import { billingUnits } from '@/views/Package/constant'
import { useI18n } from '@/store/locale'
import { UnitWrapper } from '@/views/Package'
export interface SubscriptionDetailModalRef {
handleOpen: (subscription: Subscription | null) => void;
}
const SubscriptionDetailModal = forwardRef<SubscriptionDetailModalRef>((_props, ref) => {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const { language } = useI18n()
const [detail, setDetail] = useState<Subscription | null>(null);
const handleOpen = (subscription: Subscription | null) => {
setOpen(true)
setDetail(subscription);
};
const handleCancel = () => {
setOpen(false);
};
useImperativeHandle(ref, () => ({
handleOpen,
}));
const getKeyWithLanguage = (key: string) => {
return (language === 'en' ? `${key}_en` : key) as keyof Subscription['package_plan']
}
return (
<RbModal
title={[t('package.packageDetail'), detail?.package_plan?.[getKeyWithLanguage('name')]].filter(item => item).join(' - ')}
open={open}
onCancel={handleCancel}
footer={null}
>
{/* Header */}
<h3 className="rb:text-[18px] rb:font-bold rb:text-[MiSans-Bold]" style={{ color: detail?.package_plan?.theme_color }}>
{String(detail?.package_plan?.[getKeyWithLanguage('name')] ?? '')}
</h3>
{/* Subtitle */}
<p className="rb:text-[#5B6167] rb:mb-3">
{String(detail?.package_plan?.[getKeyWithLanguage('core_value')] ?? '')}
</p>
{/* Price */}
<div className="rb:h-10">
{detail?.package_plan?.billing_cycle !== 'permanent_free' && <>
<span className="rb:text-[#5B6167] rb:inline-block rb:leading-5 rb:pt-3.25 rb:pb-1.75 rb:mr-1">¥</span>
<span className="rb:text-[28px] rb:text-[MiSans-Bold] rb:font-bold rb:leading-10">{detail?.package_plan?.price}</span>
</>}
{detail?.package_plan?.billing_cycle && (
<span className={clsx({
'rb:text-[28px] rb:text-[MiSans-Bold] rb:font-bold rb:leading-10': detail?.package_plan?.billing_cycle === 'permanent_free',
'rb:text-[#5B6167] rb:inline-block rb:leading-5 rb:pt-3.25 rb:pb-1.75 rb:ml-1': detail?.package_plan?.billing_cycle !== 'permanent_free'
})}>
{detail?.package_plan?.billing_cycle !== 'permanent_free' && ' /'}
{t(`package.${detail?.package_plan?.billing_cycle}`)}
</span>
)}
</div>
<Divider className="rb:my-4" />
{/* Features */}
<Flex gap={12} vertical className="rb:space-y-3 rb:mb-4 rb:h-[calc(100vh-341px)]! rb:overflow-y-auto">
{billingUnits.map(({ key, unit, icon }) => {
const value = detail?.quota[key as keyof Subscription['quota']];
if (value === undefined || value === null) return null;
return (
<UnitWrapper
key={key}
titleKey={key}
value={value}
unit={unit}
icon={icon}
theme_color={detail?.package_plan?.theme_color}
/>
)
})}
{detail?.package_plan?.tech_support && (
<UnitWrapper
titleKey="tech_support"
value={String(detail?.package_plan?.[getKeyWithLanguage('tech_support')] ?? '')}
icon="technical_support"
theme_color={detail?.package_plan?.theme_color}
/>
)}
{detail?.package_plan?.sla_compliance && (
<UnitWrapper
titleKey="sla"
value={String(detail?.package_plan?.[getKeyWithLanguage('sla_compliance')] ?? '')}
icon="sla"
theme_color={detail?.package_plan?.theme_color}
/>
)}
</Flex>
</RbModal>
);
});
export default SubscriptionDetailModal;

View File

@@ -2,7 +2,7 @@
* @Author: ZhaoYing
* @Date: 2026-02-02 15:25:31
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-03-27 19:11:43
* @Last Modified time: 2026-04-16 17:35:38
*/
/**
* SiderMenu Component
@@ -18,7 +18,7 @@
* @component
*/
import { useState, useEffect, type FC } from 'react';
import { useState, useEffect, useRef, type FC } from 'react';
import { Menu as AntMenu, Layout, Flex } from 'antd';
import { UserOutlined } from '@ant-design/icons';
import type { MenuProps } from 'antd';
@@ -30,6 +30,9 @@ import { useMenu, type MenuItem } from '@/store/menu';
import styles from './index.module.css'
import logo from '@/assets/images/logo.png'
import { useUser } from '@/store/user';
import { getTenantSubscription } from '@/api/user';
import { useI18n } from '@/store/locale'
import SubscriptionDetailModal, { type SubscriptionDetailModalRef } from './SubscriptionDetailModal'
// Import SVG files
// space
@@ -70,7 +73,51 @@ import pricingActiveIcon from '@/assets/images/menuNew/pricing_active.svg'
import skillsIcon from '@/assets/images/menuNew/skills.svg'
import skillsActiveIcon from '@/assets/images/menuNew/skills_active.svg'
export interface PackagePlan {
id: string
name: string
name_en?: string
version: string
category: string
tier_level: number
price: number
billing_cycle: string
core_value?: string
core_value_en?: string
tech_support?: string
tech_support_en?: string
sla_compliance?: string
sla_compliance_en?: string
page_customization?: string
page_customization_en?: string
theme_color?: string
}
export interface SubscriptionQuota {
app_quota: number
model_quota: number
skill_quota: number
end_user_quota: number
workspace_quota: number
api_ops_rate_limit: number
memory_engine_quota: number
ontology_project_quota: number
knowledge_capacity_quota: number
}
export interface Subscription {
subscription_id: string | null
tenant_id: string
package_plan_id: string
package_version: string
package_plan: PackagePlan
started_at: number | null
expired_at: number | null
status: string
quota: SubscriptionQuota
created_at: number
updated_at: number
}
/** Icon path mapping table for menu items (normal and active states) */
const iconPathMap: Record<string, string> = {
'dashboard': dashboardIcon,
@@ -121,10 +168,12 @@ const Menu: FC<{
const navigate = useNavigate();
const location = useLocation();
const { t } = useTranslation();
const { language } = useI18n()
const [selectedKeys, setSelectedKeys] = useState<string[]>([]);
const { allMenus, collapsed, loadMenus, toggleSider } = useMenu()
const [menus, setMenus] = useState<MenuItem[]>([])
const { user, storageType } = useUser()
const subscriptionDetailRef = useRef<SubscriptionDetailModalRef>(null)
/** Filter menus based on user role and source */
useEffect(() => {
@@ -279,6 +328,25 @@ const Menu: FC<{
localStorage.removeItem('user')
}
const [subscription, setSubscription] = useState<Subscription | null>(null)
useEffect(() => {
if (source === 'manage') {
getTenantSubscription()
.then(res => {
setSubscription(res as Subscription)
})
} else {
setSubscription(null)
}
}, [source])
const getKeyWithLanguage = (key: string) => {
return (language === 'en' ? `${key}_en` : key) as keyof Subscription['package_plan']
}
const handleViewDetail = () => {
subscriptionDetailRef.current?.handleOpen(subscription)
}
return (
<Sider
width={240}
@@ -325,7 +393,8 @@ const Menu: FC<{
inlineIndent={10}
className={clsx("rb:overflow-y-auto", {
'rb:max-h-[calc(100vh-136px)]': user?.is_superuser && source === 'space',
'rb:max-h-[calc(100vh-76px)]': !(user?.is_superuser && source === 'space')
'rb:max-h-[calc(100vh-76px)]': !(user?.is_superuser && source === 'space') && !(source === 'manage' && subscription && !collapsed),
'rb:max-h-[calc(100vh-228px)]': source === 'manage' && subscription && !collapsed,
})}
/>
{/* Return to space button for superusers */}
@@ -341,6 +410,30 @@ const Menu: FC<{
{collapsed ? null : t('common.returnToSpace')}
</Flex>
}
{source === 'manage' && subscription && !collapsed &&
<div className="rb:absolute rb:bottom-3 rb:left-3 rb:right-3 rb:py-3 rb:bg-cover rb:bg-[url('@/assets/images/menuNew/package_bg.png')] rb:overflow-hidden rb:rounded-xl">
<div className="rb:h-4.5 rb:flex-1 rb:truncate rb:px-3 rb:text-[13px] rb:font-medium rb:leading-4.5">{subscription.package_plan?.[getKeyWithLanguage('name')]}</div>
<div className="rb:grid rb:grid-cols-4 rb:mt-4">
{['workspace_quota', 'skill_quota', 'app_quota', 'model_quota'].map(key => (
<div key={key} className="rb:text-center">
<div className="rb:text-[13px] rb:font-[MiSans-Semibold] rb:font-semibold">{subscription.quota?.[key as keyof typeof subscription.quota]}</div>
<div className="rb:mt-1 rb:text-[#5B6167] rb:text-[10px] rb:leading-3.5">{t(`index.${key}`)}</div>
</div>
))}
</div>
<Flex align="center" justify="center" className="rb:mt-4! rb:border rb:p-2! rb:text-[12px] rb:leading-4 rb:mx-3! rb:rounded-lg rb:cursor-pointer"
onClick={handleViewDetail}
>
{t('package.viewDetail')}
<div className="rb:size-4 rb:bg-cover rb:bg-[url('@/assets/images/index/arrow_right_dark.svg')]"></div>
</Flex>
</div>
}
<SubscriptionDetailModal
ref={subscriptionDetailRef}
/>
</Sider>
);
};