feat: add subscription billing system with admin management and user purchase flow

Implement a new subscription-based billing model alongside existing metered/per-request billing:

Backend:
- Add subscription plan models (SubscriptionPlan, SubscriptionPlanItem, UserSubscription, etc.)
- Implement CRUD APIs for subscription plan management (admin only)
- Add user subscription queries with support for multiple active/expired subscriptions
- Integrate payment gateways (Stripe, Creem, Epay) for subscription purchases
- Implement pre-consume and post-consume billing logic for subscription quota tracking
- Add billing preference settings (subscription_first, wallet_first, etc.)
- Enhance usage logs with subscription deduction details

Frontend - Admin:
- Add subscription management page with table view and drawer-based edit form
- Match UI/UX style with existing admin pages (redemption codes, users)
- Support enabling/disabling plans, configuring payment IDs, and model quotas
- Add user subscription binding modal in user management

Frontend - Wallet:
- Add subscription plans card with current subscription status display
- Show all subscriptions (active and expired) with remaining days/usage percentage
- Display purchasable plans with pricing cards following SaaS best practices
- Extract purchase modal to separate component matching payment confirm modal style
- Add skeleton loading states with active animation
- Implement billing preference selector in card header
- Handle payment gateway availability based on admin configuration

Frontend - Usage Logs:
- Display subscription deduction details in log entries
- Show step-by-step breakdown of subscription usage (pre-consumed, delta, final, remaining)
- Add subscription deduction tag for subscription-covered requests
This commit is contained in:
t0ng7u
2026-01-30 05:31:10 +08:00
parent c6c12d340f
commit 009910b960
36 changed files with 3872 additions and 181 deletions

View File

@@ -44,6 +44,7 @@ import Task from './pages/Task';
import ModelPage from './pages/Model';
import ModelDeploymentPage from './pages/ModelDeployment';
import Playground from './pages/Playground';
import Subscription from './pages/Subscription';
import OAuth2Callback from './components/auth/OAuth2Callback';
import PersonalSetting from './components/settings/PersonalSetting';
import Setup from './pages/Setup';
@@ -117,6 +118,14 @@ function App() {
</AdminRoute>
}
/>
<Route
path='/console/subscription'
element={
<AdminRoute>
<Subscription />
</AdminRoute>
}
/>
<Route
path='/console/channel'
element={

View File

@@ -37,6 +37,7 @@ const routerMap = {
redemption: '/console/redemption',
topup: '/console/topup',
user: '/console/user',
subscription: '/console/subscription',
log: '/console/log',
midjourney: '/console/midjourney',
setting: '/console/setting',
@@ -50,7 +51,7 @@ const routerMap = {
personal: '/console/personal',
};
const SiderBar = ({ onNavigate = () => {} }) => {
const SiderBar = ({ onNavigate = () => { } }) => {
const { t } = useTranslation();
const [collapsed, toggleCollapsed] = useSidebarCollapsed();
const {
@@ -152,6 +153,12 @@ const SiderBar = ({ onNavigate = () => {} }) => {
to: '/channel',
className: isAdmin() ? '' : 'tableHiddle',
},
{
text: t('订阅管理'),
itemKey: 'subscription',
to: '/subscription',
className: isAdmin() ? '' : 'tableHiddle',
},
{
text: t('模型管理'),
itemKey: 'models',

View File

@@ -0,0 +1,38 @@
/*
Copyright (C) 2025 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import React from 'react';
import { Button } from '@douyinfe/semi-ui';
const SubscriptionsActions = ({ openCreate, t }) => {
return (
<div className='flex gap-2 w-full md:w-auto'>
<Button
type='primary'
className='w-full md:w-auto'
onClick={openCreate}
size='small'
>
{t('新建套餐')}
</Button>
</div>
);
};
export default SubscriptionsActions;

View File

@@ -0,0 +1,161 @@
/*
Copyright (C) 2025 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import React from 'react';
import { Button, Modal, Space, Tag } from '@douyinfe/semi-ui';
const quotaTypeLabel = (quotaType) => (quotaType === 1 ? '按次' : '按量');
function formatDuration(plan, t) {
if (!plan) return '';
const u = plan.duration_unit || 'month';
if (u === 'custom') {
return `${t('自定义')} ${plan.custom_seconds || 0}s`;
}
const unitMap = {
year: t('年'),
month: t('月'),
day: t('日'),
hour: t('小时'),
};
return `${plan.duration_value || 0}${unitMap[u] || u}`;
}
const renderPlanTitle = (text, record) => {
return (
<div>
<div className='font-medium'>{text}</div>
{record?.plan?.subtitle ? (
<div className='text-xs text-gray-500'>{record.plan.subtitle}</div>
) : null}
</div>
);
};
const renderPrice = (text, record) => {
return `${record?.plan?.currency || 'USD'} ${Number(text || 0).toFixed(2)}`;
};
const renderDuration = (text, record, t) => {
return formatDuration(record?.plan, t);
};
const renderEnabled = (text, record) => {
return text ? (
<Tag color='green' shape='circle'>
启用
</Tag>
) : (
<Tag color='grey' shape='circle'>
禁用
</Tag>
);
};
const renderModels = (text, record, t) => {
const items = record?.items || [];
if (items.length === 0) {
return <div className='text-xs text-gray-500'>{t('无模型')}</div>;
}
return (
<div className='text-xs space-y-1'>
{items.slice(0, 3).map((it, idx) => (
<div key={idx}>
{it.model_name} ({quotaTypeLabel(it.quota_type)}: {it.amount_total})
</div>
))}
{items.length > 3 && (
<div className='text-gray-500'>...{t('共')} {items.length} {t('个模型')}</div>
)}
</div>
);
};
const renderOperations = (text, record, { openEdit, disablePlan, t }) => {
const handleDisable = () => {
Modal.confirm({
title: t('确认禁用'),
content: t('禁用后用户端不再展示,但历史订单不受影响。是否继续?'),
centered: true,
onOk: () => disablePlan(record?.plan?.id),
});
};
return (
<Space>
<Button
type='tertiary'
size='small'
onClick={() => {
openEdit(record);
}}
>
{t('编辑')}
</Button>
<Button type='danger' size='small' onClick={handleDisable}>
{t('禁用')}
</Button>
</Space>
);
};
export const getSubscriptionsColumns = ({ t, openEdit, disablePlan }) => {
return [
{
title: 'ID',
dataIndex: ['plan', 'id'],
width: 80,
},
{
title: t('标题'),
dataIndex: ['plan', 'title'],
render: (text, record) => renderPlanTitle(text, record),
},
{
title: t('价格'),
dataIndex: ['plan', 'price_amount'],
width: 140,
render: (text, record) => renderPrice(text, record),
},
{
title: t('有效期'),
width: 140,
render: (text, record) => renderDuration(text, record, t),
},
{
title: t('状态'),
dataIndex: ['plan', 'enabled'],
width: 90,
render: (text, record) => renderEnabled(text, record),
},
{
title: t('模型权益'),
width: 200,
render: (text, record) => renderModels(text, record, t),
},
{
title: '',
dataIndex: 'operate',
fixed: 'right',
width: 180,
render: (text, record) =>
renderOperations(text, record, { openEdit, disablePlan, t }),
},
];
};

View File

@@ -0,0 +1,44 @@
/*
Copyright (C) 2025 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import React from 'react';
import { Typography } from '@douyinfe/semi-ui';
import { CalendarClock } from 'lucide-react';
import CompactModeToggle from '../../common/ui/CompactModeToggle';
const { Text } = Typography;
const SubscriptionsDescription = ({ compactMode, setCompactMode, t }) => {
return (
<div className='flex flex-col md:flex-row justify-between items-start md:items-center gap-2 w-full'>
<div className='flex items-center text-blue-500'>
<CalendarClock size={16} className='mr-2' />
<Text>{t('订阅管理')}</Text>
</div>
<CompactModeToggle
compactMode={compactMode}
setCompactMode={setCompactMode}
t={t}
/>
</div>
);
};
export default SubscriptionsDescription;

View File

@@ -0,0 +1,84 @@
/*
Copyright (C) 2025 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import React, { useMemo } from 'react';
import { Empty } from '@douyinfe/semi-ui';
import CardTable from '../../common/ui/CardTable';
import {
IllustrationNoResult,
IllustrationNoResultDark,
} from '@douyinfe/semi-illustrations';
import { getSubscriptionsColumns } from './SubscriptionsColumnDefs';
const SubscriptionsTable = (subscriptionsData) => {
const {
plans,
loading,
compactMode,
openEdit,
disablePlan,
t,
} = subscriptionsData;
const columns = useMemo(() => {
return getSubscriptionsColumns({
t,
openEdit,
disablePlan,
});
}, [t, openEdit, disablePlan]);
const tableColumns = useMemo(() => {
return compactMode
? columns.map((col) => {
if (col.dataIndex === 'operate') {
const { fixed, ...rest } = col;
return rest;
}
return col;
})
: columns;
}, [compactMode, columns]);
return (
<CardTable
columns={tableColumns}
dataSource={plans}
scroll={compactMode ? undefined : { x: 'max-content' }}
pagination={false}
hidePagination={true}
loading={loading}
rowKey={(row) => row?.plan?.id}
empty={
<Empty
image={<IllustrationNoResult style={{ width: 150, height: 150 }} />}
darkModeImage={
<IllustrationNoResultDark style={{ width: 150, height: 150 }} />
}
description={t('暂无订阅套餐')}
style={{ padding: 30 }}
/>
}
className='overflow-hidden'
size='middle'
/>
);
};
export default SubscriptionsTable;

View File

@@ -0,0 +1,90 @@
/*
Copyright (C) 2025 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import React from 'react';
import { Banner } from '@douyinfe/semi-ui';
import CardPro from '../../common/ui/CardPro';
import SubscriptionsTable from './SubscriptionsTable';
import SubscriptionsActions from './SubscriptionsActions';
import SubscriptionsDescription from './SubscriptionsDescription';
import AddEditSubscriptionModal from './modals/AddEditSubscriptionModal';
import { useSubscriptionsData } from '../../../hooks/subscriptions/useSubscriptionsData';
const SubscriptionsPage = () => {
const subscriptionsData = useSubscriptionsData();
const {
showEdit,
editingPlan,
sheetPlacement,
closeEdit,
refresh,
openCreate,
compactMode,
setCompactMode,
pricingModels,
t,
} = subscriptionsData;
return (
<>
<AddEditSubscriptionModal
visible={showEdit}
handleClose={closeEdit}
editingPlan={editingPlan}
placement={sheetPlacement}
pricingModels={pricingModels}
refresh={refresh}
t={t}
/>
<CardPro
type='type1'
descriptionArea={
<SubscriptionsDescription
compactMode={compactMode}
setCompactMode={setCompactMode}
t={t}
/>
}
actionsArea={
<div className='flex flex-col md:flex-row justify-between items-start md:items-center gap-2 w-full'>
{/* Mobile: actions first; Desktop: actions left */}
<div className='order-1 md:order-0 w-full md:w-auto'>
<SubscriptionsActions openCreate={openCreate} t={t} />
</div>
<Banner
type='info'
description={t('Stripe/Creem 需在第三方平台创建商品并填入 ID')}
closeIcon={null}
// Mobile: banner below; Desktop: banner right
className='!rounded-lg order-2 md:order-1'
style={{ maxWidth: '100%' }}
/>
</div>
}
t={t}
>
<SubscriptionsTable {...subscriptionsData} />
</CardPro>
</>
);
};
export default SubscriptionsPage;

View File

@@ -0,0 +1,542 @@
/*
Copyright (C) 2025 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import React, { useEffect, useMemo, useState, useRef } from 'react';
import {
Avatar,
Button,
Card,
Col,
Form,
Input,
InputNumber,
Row,
Select,
SideSheet,
Space,
Spin,
Switch,
Table,
Tag,
Typography,
} from '@douyinfe/semi-ui';
import {
IconCalendarClock,
IconClose,
IconCreditCard,
IconSave,
} from '@douyinfe/semi-icons';
import { Trash2, Clock } from 'lucide-react';
import { API, showError, showSuccess } from '../../../../helpers';
import { useIsMobile } from '../../../../hooks/common/useIsMobile';
const { Text, Title } = Typography;
const durationUnitOptions = [
{ value: 'year', label: '年' },
{ value: 'month', label: '月' },
{ value: 'day', label: '日' },
{ value: 'hour', label: '小时' },
{ value: 'custom', label: '自定义(秒)' },
];
const quotaTypeLabel = (quotaType) => (quotaType === 1 ? '按次' : '按量');
const AddEditSubscriptionModal = ({
visible,
handleClose,
editingPlan,
placement = 'left',
pricingModels = [],
refresh,
t,
}) => {
const [loading, setLoading] = useState(false);
const isMobile = useIsMobile();
const formApiRef = useRef(null);
const isEdit = editingPlan?.plan?.id !== undefined;
const formKey = isEdit ? `edit-${editingPlan?.plan?.id}` : 'create';
const getInitValues = () => ({
title: '',
subtitle: '',
price_amount: 0,
currency: 'USD',
duration_unit: 'month',
duration_value: 1,
custom_seconds: 0,
enabled: true,
sort_order: 0,
stripe_price_id: '',
creem_product_id: '',
});
const [items, setItems] = useState([]);
const buildFormValues = () => {
const base = getInitValues();
if (editingPlan?.plan?.id === undefined) return base;
const p = editingPlan.plan || {};
return {
...base,
title: p.title || '',
subtitle: p.subtitle || '',
price_amount: Number(p.price_amount || 0),
currency: p.currency || 'USD',
duration_unit: p.duration_unit || 'month',
duration_value: Number(p.duration_value || 1),
custom_seconds: Number(p.custom_seconds || 0),
enabled: p.enabled !== false,
sort_order: Number(p.sort_order || 0),
stripe_price_id: p.stripe_price_id || '',
creem_product_id: p.creem_product_id || '',
};
};
useEffect(() => {
// 1) always keep items in sync
if (visible && isEdit && editingPlan) {
setItems((editingPlan.items || []).map((it) => ({ ...it })));
} else if (visible && !isEdit) {
setItems([]);
}
}, [visible, editingPlan]);
const modelOptions = useMemo(() => {
return (pricingModels || []).map((m) => ({
label: `${m.model_name} (${quotaTypeLabel(m.quota_type)})`,
value: m.model_name,
quota_type: m.quota_type,
}));
}, [pricingModels]);
const addItem = (modelName) => {
const modelMeta = modelOptions.find((m) => m.value === modelName);
if (!modelMeta) return;
if (items.some((it) => it.model_name === modelName)) {
showError(t('该模型已添加'));
return;
}
setItems([
...items,
{
model_name: modelName,
quota_type: modelMeta.quota_type,
amount_total: 0,
},
]);
};
const updateItem = (idx, patch) => {
const next = [...items];
next[idx] = { ...next[idx], ...patch };
setItems(next);
};
const removeItem = (idx) => {
const next = [...items];
next.splice(idx, 1);
setItems(next);
};
const submit = async (values) => {
if (!values.title || values.title.trim() === '') {
showError(t('套餐标题不能为空'));
return;
}
const cleanedItems = items
.filter((it) => it.model_name && Number(it.amount_total) > 0)
.map((it) => ({
model_name: it.model_name,
quota_type: Number(it.quota_type || 0),
amount_total: Number(it.amount_total),
}));
if (cleanedItems.length === 0) {
showError(t('请至少配置一个模型权益(且数量>0'));
return;
}
setLoading(true);
try {
const payload = {
plan: {
...values,
price_amount: Number(values.price_amount || 0),
duration_value: Number(values.duration_value || 0),
custom_seconds: Number(values.custom_seconds || 0),
sort_order: Number(values.sort_order || 0),
},
items: cleanedItems,
};
if (editingPlan?.plan?.id) {
const res = await API.put(
`/api/subscription/admin/plans/${editingPlan.plan.id}`,
payload,
);
if (res.data?.success) {
showSuccess(t('更新成功'));
handleClose();
refresh?.();
} else {
showError(res.data?.message || t('更新失败'));
}
} else {
const res = await API.post('/api/subscription/admin/plans', payload);
if (res.data?.success) {
showSuccess(t('创建成功'));
handleClose();
refresh?.();
} else {
showError(res.data?.message || t('创建失败'));
}
}
} catch (e) {
showError(t('请求失败'));
} finally {
setLoading(false);
}
};
const itemColumns = [
{
title: t('模型'),
dataIndex: 'model_name',
render: (v, row) => (
<div className='text-sm'>
<div className='font-medium'>{v}</div>
<div className='text-xs text-gray-500'>
{t('计费')}: {quotaTypeLabel(row.quota_type)}
</div>
</div>
),
},
{
title: t('数量'),
dataIndex: 'amount_total',
width: 220,
render: (v, row, idx) => (
<InputNumber
value={Number(v || 0)}
min={0}
precision={0}
onChange={(val) => updateItem(idx, { amount_total: val })}
placeholder={row.quota_type === 1 ? t('次数') : t('额度')}
style={{ width: '100%' }}
/>
),
},
{
title: '',
width: 60,
render: (_, __, idx) => (
<Button
type='danger'
theme='borderless'
icon={<Trash2 size={14} />}
onClick={() => removeItem(idx)}
/>
),
},
];
return (
<>
<SideSheet
placement={placement}
title={
<Space>
{isEdit ? (
<Tag color='blue' shape='circle'>
{t('更新')}
</Tag>
) : (
<Tag color='green' shape='circle'>
{t('新建')}
</Tag>
)}
<Title heading={4} className='m-0'>
{isEdit ? t('更新套餐信息') : t('创建新的订阅套餐')}
</Title>
</Space>
}
bodyStyle={{ padding: '0' }}
visible={visible}
width={isMobile ? '100%' : 700}
footer={
<div className='flex justify-end bg-white'>
<Space>
<Button
theme='solid'
onClick={() => formApiRef.current?.submitForm()}
icon={<IconSave />}
loading={loading}
>
{t('提交')}
</Button>
<Button
theme='light'
type='primary'
onClick={handleClose}
icon={<IconClose />}
>
{t('取消')}
</Button>
</Space>
</div>
}
closeIcon={null}
onCancel={handleClose}
>
<Spin spinning={loading}>
<Form
key={formKey}
initValues={buildFormValues()}
getFormApi={(api) => (formApiRef.current = api)}
onSubmit={submit}
>
{({ values }) => (
<div className='p-2'>
{/* 基本信息 */}
<Card className='!rounded-2xl shadow-sm border-0 mb-4'>
<div className='flex items-center mb-2'>
<Avatar
size='small'
color='blue'
className='mr-2 shadow-md'
>
<IconCalendarClock size={16} />
</Avatar>
<div>
<Text className='text-lg font-medium'>
{t('基本信息')}
</Text>
<div className='text-xs text-gray-600'>
{t('套餐的基本信息和定价')}
</div>
</div>
</div>
<Row gutter={12}>
<Col span={24}>
<Form.Input
field='title'
label={t('套餐标题')}
placeholder={t('例如:基础套餐')}
rules={[{ required: true, message: t('请输入套餐标题') }]}
showClear
/>
</Col>
<Col span={24}>
<Form.Input
field='subtitle'
label={t('套餐副标题')}
placeholder={t('例如:适合轻度使用')}
showClear
/>
</Col>
<Col span={12}>
<Form.InputNumber
field='price_amount'
label={t('实付金额')}
min={0}
precision={2}
rules={[{ required: true, message: t('请输入金额') }]}
style={{ width: '100%' }}
/>
</Col>
<Col span={12}>
<Form.Select
field='currency'
label={t('币种')}
rules={[{ required: true }]}
>
<Select.Option value='USD'>USD</Select.Option>
<Select.Option value='EUR'>EUR</Select.Option>
<Select.Option value='CNY'>CNY</Select.Option>
</Form.Select>
</Col>
<Col span={12}>
<Form.InputNumber
field='sort_order'
label={t('排序')}
precision={0}
style={{ width: '100%' }}
/>
</Col>
<Col span={12}>
<Form.Switch
field='enabled'
label={t('启用状态')}
size='large'
/>
</Col>
</Row>
</Card>
{/* 有效期设置 */}
<Card className='!rounded-2xl shadow-sm border-0 mb-4'>
<div className='flex items-center mb-2'>
<Avatar
size='small'
color='green'
className='mr-2 shadow-md'
>
<Clock size={16} />
</Avatar>
<div>
<Text className='text-lg font-medium'>
{t('有效期设置')}
</Text>
<div className='text-xs text-gray-600'>
{t('配置套餐的有效时长')}
</div>
</div>
</div>
<Row gutter={12}>
<Col span={12}>
<Form.Select
field='duration_unit'
label={t('有效期单位')}
rules={[{ required: true }]}
>
{durationUnitOptions.map((o) => (
<Select.Option key={o.value} value={o.value}>
{o.label}
</Select.Option>
))}
</Form.Select>
</Col>
<Col span={12}>
{values.duration_unit === 'custom' ? (
<Form.InputNumber
field='custom_seconds'
label={t('自定义秒数')}
min={0}
precision={0}
rules={[{ required: true, message: t('请输入秒数') }]}
style={{ width: '100%' }}
/>
) : (
<Form.InputNumber
field='duration_value'
label={t('有效期数值')}
min={1}
precision={0}
rules={[{ required: true, message: t('请输入数值') }]}
style={{ width: '100%' }}
/>
)}
</Col>
</Row>
</Card>
{/* 第三方支付配置 */}
<Card className='!rounded-2xl shadow-sm border-0 mb-4'>
<div className='flex items-center mb-2'>
<Avatar
size='small'
color='purple'
className='mr-2 shadow-md'
>
<IconCreditCard size={16} />
</Avatar>
<div>
<Text className='text-lg font-medium'>
{t('第三方支付配置')}
</Text>
<div className='text-xs text-gray-600'>
{t('Stripe/Creem 商品ID可选')}
</div>
</div>
</div>
<Row gutter={12}>
<Col span={24}>
<Form.Input
field='stripe_price_id'
label='Stripe PriceId'
placeholder='price_...'
showClear
/>
</Col>
<Col span={24}>
<Form.Input
field='creem_product_id'
label='Creem ProductId'
placeholder='prod_...'
showClear
/>
</Col>
</Row>
</Card>
{/* 模型权益 */}
<Card className='!rounded-2xl shadow-sm border-0'>
<div className='flex items-center justify-between mb-3'>
<div>
<Text className='text-lg font-medium'>
{t('模型权益')}
</Text>
<div className='text-xs text-gray-600'>
{t('配置套餐可使用的模型及额度')}
</div>
</div>
<Select
placeholder={t('添加模型')}
style={{ width: 280 }}
filter
onChange={addItem}
>
{modelOptions.map((o) => (
<Select.Option key={o.value} value={o.value}>
{o.label}
</Select.Option>
))}
</Select>
</div>
<Table
columns={itemColumns}
dataSource={items}
pagination={false}
rowKey={(row) => `${row.model_name}-${row.quota_type}`}
empty={
<div className='py-6 text-center text-gray-500'>
{t('尚未添加任何模型')}
</div>
}
/>
</Card>
</div>
)}
</Form>
</Spin>
</SideSheet>
</>
);
};
export default AddEditSubscriptionModal;

View File

@@ -182,6 +182,18 @@ function renderFirstUseTime(type, t) {
}
}
function renderBillingTag(record, t) {
const other = getLogOther(record.other);
if (other?.billing_source === 'subscription') {
return (
<Tag color='green' shape='circle'>
{t('订阅抵扣')}
</Tag>
);
}
return null;
}
function renderModelName(record, copyText, t) {
let other = getLogOther(record.other);
let modelMapped =
@@ -191,7 +203,7 @@ function renderModelName(record, copyText, t) {
if (!modelMapped) {
return renderModelTag(record.model_name, {
onClick: (event) => {
copyText(event, record.model_name).then((r) => {});
copyText(event, record.model_name).then((r) => { });
},
});
} else {
@@ -208,7 +220,7 @@ function renderModelName(record, copyText, t) {
</Typography.Text>
{renderModelTag(record.model_name, {
onClick: (event) => {
copyText(event, record.model_name).then((r) => {});
copyText(event, record.model_name).then((r) => { });
},
})}
</div>
@@ -219,7 +231,7 @@ function renderModelName(record, copyText, t) {
{renderModelTag(other.upstream_model_name, {
onClick: (event) => {
copyText(event, other.upstream_model_name).then(
(r) => {},
(r) => { },
);
},
})}
@@ -230,7 +242,7 @@ function renderModelName(record, copyText, t) {
>
{renderModelTag(record.model_name, {
onClick: (event) => {
copyText(event, record.model_name).then((r) => {});
copyText(event, record.model_name).then((r) => { });
},
suffixIcon: (
<Route
@@ -457,11 +469,20 @@ export const getLogsColumns = ({
title: t('花费'),
dataIndex: 'quota',
render: (text, record, index) => {
return record.type === 0 || record.type === 2 || record.type === 5 ? (
<>{renderQuota(text, 6)}</>
) : (
<></>
);
if (!(record.type === 0 || record.type === 2 || record.type === 5)) {
return <></>;
}
const other = getLogOther(record.other);
const isSubscription = other?.billing_source === 'subscription';
if (isSubscription) {
// Subscription billed: show only tag (no $0), but keep tooltip for equivalent cost.
return (
<Tooltip content={`${t('由订阅抵扣')}${renderQuota(text, 6)}`}>
<span>{renderBillingTag(record, t)}</span>
</Tooltip>
);
}
return <>{renderQuota(text, 6)}</>;
},
},
{
@@ -532,42 +553,42 @@ export const getLogsColumns = ({
return isAdminUser ? (
<Space>
<div>{content}</div>
{affinity ? (
<Tooltip
content={
<div style={{ lineHeight: 1.6 }}>
<Typography.Text strong>{t('渠道亲和性')}</Typography.Text>
<div>
<Typography.Text type='secondary'>
{t('规则')}{affinity.rule_name || '-'}
</Typography.Text>
</div>
<div>
<Typography.Text type='secondary'>
{t('分组')}{affinity.selected_group || '-'}
</Typography.Text>
</div>
<div>
<Typography.Text type='secondary'>
{t('Key')}
{(affinity.key_source || '-') +
':' +
(affinity.key_path || affinity.key_key || '-') +
{affinity ? (
<Tooltip
content={
<div style={{ lineHeight: 1.6 }}>
<Typography.Text strong>{t('渠道亲和性')}</Typography.Text>
<div>
<Typography.Text type='secondary'>
{t('规则')}{affinity.rule_name || '-'}
</Typography.Text>
</div>
<div>
<Typography.Text type='secondary'>
{t('分组')}{affinity.selected_group || '-'}
</Typography.Text>
</div>
<div>
<Typography.Text type='secondary'>
{t('Key')}
{(affinity.key_source || '-') +
':' +
(affinity.key_path || affinity.key_key || '-') +
(affinity.key_fp ? `#${affinity.key_fp}` : '')}
</Typography.Text>
</div>
</div>
}
>
<span>
<Tag className='channel-affinity-tag' color='cyan' shape='circle'>
<span className='channel-affinity-tag-content'>
<IconStarStroked style={{ fontSize: 13 }} />
{t('优选')}
</span>
</Tag>
</span>
</Tooltip>
</div>
}
>
<span>
<Tag className='channel-affinity-tag' color='cyan' shape='circle'>
<span className='channel-affinity-tag-content'>
<IconStarStroked style={{ fontSize: 13 }} />
{t('优选')}
</span>
</Tag>
</span>
</Tooltip>
) : null}
</Space>
) : (
@@ -632,45 +653,49 @@ export const getLogsColumns = ({
let content = other?.claude
? renderModelPriceSimple(
other.model_ratio,
other.model_price,
other.group_ratio,
other?.user_group_ratio,
other.cache_tokens || 0,
other.cache_ratio || 1.0,
other.cache_creation_tokens || 0,
other.cache_creation_ratio || 1.0,
other.cache_creation_tokens_5m || 0,
other.cache_creation_ratio_5m ||
other.cache_creation_ratio ||
1.0,
other.cache_creation_tokens_1h || 0,
other.cache_creation_ratio_1h ||
other.cache_creation_ratio ||
1.0,
false,
1.0,
other?.is_system_prompt_overwritten,
'claude',
)
other.model_ratio,
other.model_price,
other.group_ratio,
other?.user_group_ratio,
other.cache_tokens || 0,
other.cache_ratio || 1.0,
other.cache_creation_tokens || 0,
other.cache_creation_ratio || 1.0,
other.cache_creation_tokens_5m || 0,
other.cache_creation_ratio_5m ||
other.cache_creation_ratio ||
1.0,
other.cache_creation_tokens_1h || 0,
other.cache_creation_ratio_1h ||
other.cache_creation_ratio ||
1.0,
false,
1.0,
other?.is_system_prompt_overwritten,
'claude',
)
: renderModelPriceSimple(
other.model_ratio,
other.model_price,
other.group_ratio,
other?.user_group_ratio,
other.cache_tokens || 0,
other.cache_ratio || 1.0,
0,
1.0,
0,
1.0,
0,
1.0,
false,
1.0,
other?.is_system_prompt_overwritten,
'openai',
);
other.model_ratio,
other.model_price,
other.group_ratio,
other?.user_group_ratio,
other.cache_tokens || 0,
other.cache_ratio || 1.0,
0,
1.0,
0,
1.0,
0,
1.0,
false,
1.0,
other?.is_system_prompt_overwritten,
'openai',
);
// Do not add billing source here; keep details clean.
const summary = [content, text ? `${t('详情')}${text}` : null]
.filter(Boolean)
.join('\n');
return (
<Typography.Paragraph
ellipsis={{
@@ -678,7 +703,7 @@ export const getLogsColumns = ({
}}
style={{ maxWidth: 240, whiteSpace: 'pre-line' }}
>
{content}
{summary}
</Typography.Paragraph>
);
},

View File

@@ -208,6 +208,7 @@ const renderOperations = (
showDeleteModal,
showResetPasskeyModal,
showResetTwoFAModal,
showBindSubscriptionModal,
t,
},
) => {
@@ -216,6 +217,14 @@ const renderOperations = (
}
const moreMenu = [
{
node: 'item',
name: t('绑定订阅套餐'),
onClick: () => showBindSubscriptionModal(record),
},
{
node: 'divider',
},
{
node: 'item',
name: t('重置 Passkey'),
@@ -299,6 +308,7 @@ export const getUsersColumns = ({
showDeleteModal,
showResetPasskeyModal,
showResetTwoFAModal,
showBindSubscriptionModal,
}) => {
return [
{
@@ -355,6 +365,7 @@ export const getUsersColumns = ({
showDeleteModal,
showResetPasskeyModal,
showResetTwoFAModal,
showBindSubscriptionModal,
t,
}),
},

View File

@@ -31,6 +31,7 @@ import EnableDisableUserModal from './modals/EnableDisableUserModal';
import DeleteUserModal from './modals/DeleteUserModal';
import ResetPasskeyModal from './modals/ResetPasskeyModal';
import ResetTwoFAModal from './modals/ResetTwoFAModal';
import BindSubscriptionModal from './modals/BindSubscriptionModal';
const UsersTable = (usersData) => {
const {
@@ -61,6 +62,8 @@ const UsersTable = (usersData) => {
const [enableDisableAction, setEnableDisableAction] = useState('');
const [showResetPasskeyModal, setShowResetPasskeyModal] = useState(false);
const [showResetTwoFAModal, setShowResetTwoFAModal] = useState(false);
const [showBindSubscriptionModal, setShowBindSubscriptionModal] =
useState(false);
// Modal handlers
const showPromoteUserModal = (user) => {
@@ -94,6 +97,11 @@ const UsersTable = (usersData) => {
setShowResetTwoFAModal(true);
};
const showBindSubscriptionUserModal = (user) => {
setModalUser(user);
setShowBindSubscriptionModal(true);
};
// Modal confirm handlers
const handlePromoteConfirm = () => {
manageUser(modalUser.id, 'promote', modalUser);
@@ -132,6 +140,7 @@ const UsersTable = (usersData) => {
showDeleteModal: showDeleteUserModal,
showResetPasskeyModal: showResetPasskeyUserModal,
showResetTwoFAModal: showResetTwoFAUserModal,
showBindSubscriptionModal: showBindSubscriptionUserModal,
});
}, [
t,
@@ -143,6 +152,7 @@ const UsersTable = (usersData) => {
showDeleteUserModal,
showResetPasskeyUserModal,
showResetTwoFAUserModal,
showBindSubscriptionUserModal,
]);
// Handle compact mode by removing fixed positioning
@@ -242,6 +252,14 @@ const UsersTable = (usersData) => {
user={modalUser}
t={t}
/>
<BindSubscriptionModal
visible={showBindSubscriptionModal}
onCancel={() => setShowBindSubscriptionModal(false)}
user={modalUser}
t={t}
onSuccess={() => refresh?.()}
/>
</>
);
};

View File

@@ -0,0 +1,124 @@
/*
Copyright (C) 2025 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import React, { useEffect, useMemo, useState } from 'react';
import { Modal, Select, Space, Typography } from '@douyinfe/semi-ui';
import { API, showError, showSuccess } from '../../../../helpers';
const { Text } = Typography;
const BindSubscriptionModal = ({ visible, onCancel, user, t, onSuccess }) => {
const [loading, setLoading] = useState(false);
const [plans, setPlans] = useState([]);
const [selectedPlanId, setSelectedPlanId] = useState(null);
const loadPlans = async () => {
setLoading(true);
try {
const res = await API.get('/api/subscription/admin/plans');
if (res.data?.success) {
setPlans(res.data.data || []);
} else {
showError(res.data?.message || t('加载失败'));
}
} catch (e) {
showError(t('请求失败'));
} finally {
setLoading(false);
}
};
useEffect(() => {
if (visible) {
setSelectedPlanId(null);
loadPlans();
}
}, [visible]);
const planOptions = useMemo(() => {
return (plans || []).map((p) => ({
label: `${p?.plan?.title || ''} (${p?.plan?.currency || 'USD'} ${Number(p?.plan?.price_amount || 0)})`,
value: p?.plan?.id,
}));
}, [plans]);
const bind = async () => {
if (!user?.id) {
showError(t('用户信息缺失'));
return;
}
if (!selectedPlanId) {
showError(t('请选择订阅套餐'));
return;
}
setLoading(true);
try {
const res = await API.post('/api/subscription/admin/bind', {
user_id: user.id,
plan_id: selectedPlanId,
});
if (res.data?.success) {
showSuccess(t('绑定成功'));
onSuccess?.();
onCancel?.();
} else {
showError(res.data?.message || t('绑定失败'));
}
} catch (e) {
showError(t('请求失败'));
} finally {
setLoading(false);
}
};
return (
<Modal
title={t('绑定订阅套餐')}
visible={visible}
onCancel={onCancel}
onOk={bind}
confirmLoading={loading}
maskClosable={false}
centered
>
<Space vertical style={{ width: '100%' }} spacing='medium'>
<div className='text-sm'>
<Text strong>{t('用户')}</Text>
<Text>{user?.username}</Text>
<Text type='tertiary'> (ID: {user?.id})</Text>
</div>
<Select
placeholder={t('选择订阅套餐')}
optionList={planOptions}
value={selectedPlanId}
onChange={setSelectedPlanId}
loading={loading}
filter
style={{ width: '100%' }}
/>
<div className='text-xs text-gray-500'>
{t('绑定后会立即生成用户订阅(无需支付),有效期按套餐配置计算。')}
</div>
</Space>
</Modal>
);
};
export default BindSubscriptionModal;

View File

@@ -0,0 +1,537 @@
/*
Copyright (C) 2025 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import React, { useMemo, useState } from 'react';
import {
Avatar,
Button,
Card,
Divider,
Select,
Skeleton,
Space,
Tag,
Typography,
} from '@douyinfe/semi-ui';
import { API, showError, showSuccess } from '../../helpers';
import { CalendarClock, Check, Crown, RefreshCw, Sparkles } from 'lucide-react';
import SubscriptionPurchaseModal from './modals/SubscriptionPurchaseModal';
const { Text } = Typography;
// 格式化有效期显示
function formatDuration(plan, t) {
const unit = plan?.duration_unit || 'month';
const value = plan?.duration_value || 1;
const unitLabels = {
year: t('年'),
month: t('个月'),
day: t('天'),
hour: t('小时'),
custom: t('自定义'),
};
if (unit === 'custom') {
const seconds = plan?.custom_seconds || 0;
if (seconds >= 86400) return `${Math.floor(seconds / 86400)} ${t('天')}`;
if (seconds >= 3600) return `${Math.floor(seconds / 3600)} ${t('小时')}`;
return `${seconds} ${t('秒')}`;
}
return `${value} ${unitLabels[unit] || unit}`;
}
// 过滤易支付方式
function getEpayMethods(payMethods = []) {
return (payMethods || []).filter(
(m) => m?.type && m.type !== 'stripe' && m.type !== 'creem',
);
}
// 提交易支付表单
function submitEpayForm({ url, params }) {
const form = document.createElement('form');
form.action = url;
form.method = 'POST';
const isSafari =
navigator.userAgent.indexOf('Safari') > -1 &&
navigator.userAgent.indexOf('Chrome') < 1;
if (!isSafari) form.target = '_blank';
Object.keys(params || {}).forEach((key) => {
const input = document.createElement('input');
input.type = 'hidden';
input.name = key;
input.value = params[key];
form.appendChild(input);
});
document.body.appendChild(form);
form.submit();
document.body.removeChild(form);
}
// 获取货币符号
function getCurrencySymbol(currency) {
const symbols = { USD: '$', EUR: '€', CNY: '¥', GBP: '£', JPY: '¥' };
return symbols[currency] || currency + ' ';
}
const SubscriptionPlansCard = ({
t,
loading = false,
plans = [],
payMethods = [],
enableOnlineTopUp = false,
enableStripeTopUp = false,
enableCreemTopUp = false,
billingPreference,
onChangeBillingPreference,
activeSubscriptions = [],
allSubscriptions = [],
reloadSubscriptionSelf,
}) => {
const [open, setOpen] = useState(false);
const [selectedPlan, setSelectedPlan] = useState(null);
const [paying, setPaying] = useState(false);
const [selectedEpayMethod, setSelectedEpayMethod] = useState('');
const [refreshing, setRefreshing] = useState(false);
const epayMethods = useMemo(() => getEpayMethods(payMethods), [payMethods]);
const openBuy = (p) => {
setSelectedPlan(p);
setSelectedEpayMethod(epayMethods?.[0]?.type || '');
setOpen(true);
};
const closeBuy = () => {
setOpen(false);
setSelectedPlan(null);
setPaying(false);
};
const handleRefresh = async () => {
setRefreshing(true);
try {
await reloadSubscriptionSelf?.();
} finally {
setRefreshing(false);
}
};
const payStripe = async () => {
if (!selectedPlan?.plan?.stripe_price_id) {
showError(t('该套餐未配置 Stripe'));
return;
}
setPaying(true);
try {
const res = await API.post('/api/subscription/stripe/pay', {
plan_id: selectedPlan.plan.id,
});
if (res.data?.message === 'success') {
window.open(res.data.data?.pay_link, '_blank');
showSuccess(t('已打开支付页面'));
closeBuy();
} else {
showError(res.data?.data || res.data?.message || t('支付失败'));
}
} catch (e) {
showError(t('支付请求失败'));
} finally {
setPaying(false);
}
};
const payCreem = async () => {
if (!selectedPlan?.plan?.creem_product_id) {
showError(t('该套餐未配置 Creem'));
return;
}
setPaying(true);
try {
const res = await API.post('/api/subscription/creem/pay', {
plan_id: selectedPlan.plan.id,
});
if (res.data?.message === 'success') {
window.open(res.data.data?.checkout_url, '_blank');
showSuccess(t('已打开支付页面'));
closeBuy();
} else {
showError(res.data?.data || res.data?.message || t('支付失败'));
}
} catch (e) {
showError(t('支付请求失败'));
} finally {
setPaying(false);
}
};
const payEpay = async () => {
if (!selectedEpayMethod) {
showError(t('请选择支付方式'));
return;
}
setPaying(true);
try {
const res = await API.post('/api/subscription/epay/pay', {
plan_id: selectedPlan.plan.id,
payment_method: selectedEpayMethod,
});
if (res.data?.message === 'success') {
submitEpayForm({ url: res.data.url, params: res.data.data });
showSuccess(t('已发起支付'));
closeBuy();
} else {
showError(res.data?.data || res.data?.message || t('支付失败'));
}
} catch (e) {
showError(t('支付请求失败'));
} finally {
setPaying(false);
}
};
// 当前订阅信息 - 支持多个订阅
const hasActiveSubscription = activeSubscriptions.length > 0;
const hasAnySubscription = allSubscriptions.length > 0;
// 计算单个订阅的剩余天数
const getRemainingDays = (sub) => {
if (!sub?.subscription?.end_time) return 0;
const now = Date.now() / 1000;
const remaining = sub.subscription.end_time - now;
return Math.max(0, Math.ceil(remaining / 86400));
};
// 计算单个订阅的使用进度
const getUsagePercent = (sub) => {
const items = sub?.items || [];
if (items.length === 0) return 0;
let totalUsed = 0;
let totalAmount = 0;
items.forEach((it) => {
totalUsed += Number(it.amount_used || 0);
totalAmount += Number(it.amount_total || 0);
});
if (totalAmount === 0) return 0;
return Math.round((totalUsed / totalAmount) * 100);
};
return (
<Card className='!rounded-2xl shadow-sm border-0'>
{/* 卡片头部 */}
<div className='flex items-center justify-between mb-3'>
<div className='flex items-center'>
<Avatar size='small' color='violet' className='mr-3 shadow-md'>
<Crown size={16} />
</Avatar>
<div>
<Text className='text-lg font-medium'>
{t('订阅套餐')}
</Text>
<div className='text-xs'>{t('购买订阅获得模型额度/次数')}</div>
</div>
</div>
{/* 扣费策略 - 右上角 */}
<Select
value={billingPreference}
onChange={onChangeBillingPreference}
size='small'
optionList={[
{ value: 'subscription_first', label: t('优先订阅') },
{ value: 'wallet_first', label: t('优先钱包') },
{ value: 'subscription_only', label: t('仅用订阅') },
{ value: 'wallet_only', label: t('仅用钱包') },
]}
/>
</div>
{loading ? (
<div className='space-y-4'>
{/* 我的订阅骨架屏 */}
<Card className='!rounded-xl w-full' bodyStyle={{ padding: '12px' }}>
<div className='flex items-center justify-between mb-3'>
<Skeleton.Title active style={{ width: 100, height: 20 }} />
<Skeleton.Button active style={{ width: 24, height: 24 }} />
</div>
<div className='space-y-2'>
<Skeleton.Paragraph active rows={2} />
</div>
</Card>
{/* 套餐列表骨架屏 */}
<div className='grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4'>
{[1, 2, 3].map((i) => (
<Card key={i} className='!rounded-xl' bodyStyle={{ padding: 16 }}>
<Skeleton.Title active style={{ width: '60%', height: 24, marginBottom: 8 }} />
<Skeleton.Paragraph active rows={1} style={{ marginBottom: 12 }} />
<div className='text-center py-4'>
<Skeleton.Title active style={{ width: '40%', height: 32, margin: '0 auto' }} />
</div>
<Skeleton.Paragraph active rows={3} style={{ marginTop: 12 }} />
<Skeleton.Button active block style={{ marginTop: 16, height: 32 }} />
</Card>
))}
</div>
</div>
) : (
<Space vertical style={{ width: '100%' }} spacing={8}>
{/* 当前订阅状态 */}
<Card className='!rounded-xl w-full' bodyStyle={{ padding: '12px' }}>
<div className='flex items-center justify-between mb-2'>
<div className='flex items-center gap-2'>
<Text strong>{t('我的订阅')}</Text>
{hasActiveSubscription ? (
<Tag color='green' size='small' shape='circle'>
{activeSubscriptions.length} {t('个生效中')}
</Tag>
) : (
<Tag color='grey' size='small' shape='circle'>{t('无生效')}</Tag>
)}
{allSubscriptions.length > activeSubscriptions.length && (
<Tag color='grey' size='small' shape='circle' type='light'>
{allSubscriptions.length - activeSubscriptions.length} {t('个已过期')}
</Tag>
)}
</div>
<Button
size='small'
theme='borderless'
icon={<RefreshCw size={12} className={refreshing ? 'animate-spin' : ''} />}
onClick={handleRefresh}
loading={refreshing}
/>
</div>
{hasAnySubscription ? (
<div className='space-y-3 max-h-64 overflow-y-auto'>
{allSubscriptions.map((sub, subIndex) => {
const subscription = sub.subscription;
const items = sub.items || [];
const remainDays = getRemainingDays(sub);
const usagePercent = getUsagePercent(sub);
const now = Date.now() / 1000;
const isExpired = (subscription?.end_time || 0) < now;
const isActive = subscription?.status === 'active' && !isExpired;
return (
<div
key={subscription?.id || subIndex}
className={`p-2 rounded-lg ${isActive ? 'bg-green-50' : 'bg-gray-100 opacity-70'}`}
>
{/* 订阅概要 */}
<div className='flex items-center justify-between text-xs mb-2'>
<div className='flex items-center gap-2'>
<span className='font-medium'>
{t('订阅')} #{subscription?.id}
</span>
{isActive ? (
<Tag color='green' size='small' shape='circle'>{t('生效')}</Tag>
) : (
<Tag color='grey' size='small' shape='circle'>{t('已过期')}</Tag>
)}
</div>
{isActive && (
<span className='text-gray-500'>
{t('剩余')} {remainDays} {t('天')} · {t('已用')} {usagePercent}%
</span>
)}
</div>
<div className='text-xs text-gray-500 mb-2'>
{isActive ? t('至') : t('过期于')} {new Date((subscription?.end_time || 0) * 1000).toLocaleString()}
</div>
{/* 权益列表 */}
{items.length > 0 && (
<div className='flex flex-wrap gap-1'>
{items.slice(0, 4).map((it) => {
const used = Number(it.amount_used || 0);
const total = Number(it.amount_total || 0);
const remain = total - used;
const percent = total > 0 ? Math.round((used / total) * 100) : 0;
const label = it.quota_type === 1 ? t('次') : '';
return (
<Tag
key={`${it.id}-${it.model_name}`}
size='small'
color={isActive ? (percent > 80 ? 'red' : 'blue') : 'grey'}
type='light'
shape='circle'
>
{it.model_name}: {remain}{label}
</Tag>
);
})}
{items.length > 4 && (
<Tag size='small' color='grey' type='light' shape='circle'>
+{items.length - 4}
</Tag>
)}
</div>
)}
</div>
);
})}
</div>
) : (
<div className='text-xs text-gray-500'>
{t('购买套餐后即可享受模型权益')}
</div>
)}
</Card>
{/* 可购买套餐 - 标准定价卡片 */}
{plans.length > 0 ? (
<div className='grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4'>
{plans.map((p, index) => {
const plan = p?.plan;
const planItems = p?.items || [];
const currency = getCurrencySymbol(plan?.currency || 'USD');
const price = Number(plan?.price_amount || 0);
const isPopular = index === 0 && plans.length > 1;
return (
<Card
key={plan?.id}
className={`!rounded-xl transition-all hover:shadow-lg ${isPopular ? 'ring-2 ring-purple-500' : ''
}`}
bodyStyle={{ padding: 0 }}
>
<div className='p-4'>
{/* 推荐标签 */}
{isPopular && (
<div className='text-center mb-2'>
<Tag color='purple' shape='circle' size='small'>
<Sparkles size={10} className='mr-1' />
{t('推荐')}
</Tag>
</div>
)}
{/* 套餐名称 */}
<div className='text-center mb-3'>
<Typography.Title
heading={5}
ellipsis={{ rows: 1, showTooltip: true }}
style={{ margin: 0 }}
>
{plan?.title || t('订阅套餐')}
</Typography.Title>
{plan?.subtitle && (
<Text
type='tertiary'
size='small'
ellipsis={{ rows: 1, showTooltip: true }}
style={{ display: 'block' }}
>
{plan.subtitle}
</Text>
)}
</div>
{/* 价格区域 */}
<div className='text-center py-2'>
<div className='flex items-baseline justify-center'>
<span className='text-xl font-bold text-purple-600'>
{currency}
</span>
<span className='text-3xl font-bold text-purple-600'>
{price.toFixed(price % 1 === 0 ? 0 : 2)}
</span>
</div>
<div className='text-sm text-gray-500 mt-1'>
<CalendarClock size={12} className='inline mr-1' />
{formatDuration(plan, t)}
</div>
</div>
<Divider margin={12} />
{/* 权益列表 */}
<div className='space-y-2 mb-4'>
{planItems.slice(0, 5).map((it, idx) => (
<div
key={idx}
className='flex items-center text-sm'
>
<Check
size={14}
className='text-green-500 mr-2 flex-shrink-0'
/>
<span className='truncate flex-1'>{it.model_name}</span>
<Tag size='small' color='blue' shape='circle' type='light'>
{it.amount_total}
{it.quota_type === 1 ? t('次') : ''}
</Tag>
</div>
))}
{planItems.length > 5 && (
<div className='text-xs text-gray-400 text-center'>
+{planItems.length - 5} {t('项更多权益')}
</div>
)}
{planItems.length === 0 && (
<div className='text-xs text-gray-400 text-center py-2'>
{t('暂无权益配置')}
</div>
)}
</div>
{/* 购买按钮 */}
<Button
theme='solid'
type='primary'
block
onClick={() => openBuy(p)}
className={isPopular ? '!bg-purple-600 hover:!bg-purple-700' : ''}
>
{t('立即订阅')}
</Button>
</div>
</Card>
);
})}
</div>
) : (
<div className='text-center text-gray-400 text-sm py-4'>
{t('暂无可购买套餐')}
</div>
)}
</Space>
)}
{/* 购买确认弹窗 */}
<SubscriptionPurchaseModal
t={t}
visible={open}
onCancel={closeBuy}
selectedPlan={selectedPlan}
paying={paying}
selectedEpayMethod={selectedEpayMethod}
setSelectedEpayMethod={setSelectedEpayMethod}
epayMethods={epayMethods}
enableOnlineTopUp={enableOnlineTopUp}
enableStripeTopUp={enableStripeTopUp}
enableCreemTopUp={enableCreemTopUp}
onPayStripe={payStripe}
onPayCreem={payCreem}
onPayEpay={payEpay}
/>
</Card>
);
};
export default SubscriptionPlansCard;

View File

@@ -35,6 +35,7 @@ import { StatusContext } from '../../context/Status';
import RechargeCard from './RechargeCard';
import InvitationCard from './InvitationCard';
import SubscriptionPlansCard from './SubscriptionPlansCard';
import TransferModal from './modals/TransferModal';
import PaymentConfirmModal from './modals/PaymentConfirmModal';
import TopupHistoryModal from './modals/TopupHistoryModal';
@@ -87,6 +88,13 @@ const TopUp = () => {
// 账单Modal状态
const [openHistory, setOpenHistory] = useState(false);
// 订阅相关
const [subscriptionPlans, setSubscriptionPlans] = useState([]);
const [subscriptionLoading, setSubscriptionLoading] = useState(true);
const [billingPreference, setBillingPreference] = useState('subscription_first');
const [activeSubscriptions, setActiveSubscriptions] = useState([]);
const [allSubscriptions, setAllSubscriptions] = useState([]);
// 预设充值额度选项
const [presetAmounts, setPresetAmounts] = useState([]);
const [selectedPreset, setSelectedPreset] = useState(null);
@@ -313,6 +321,53 @@ const TopUp = () => {
}
};
const getSubscriptionPlans = async () => {
setSubscriptionLoading(true);
try {
const res = await API.get('/api/subscription/plans');
if (res.data?.success) {
setSubscriptionPlans(res.data.data || []);
}
} catch (e) {
setSubscriptionPlans([]);
} finally {
setSubscriptionLoading(false);
}
};
const getSubscriptionSelf = async () => {
try {
const res = await API.get('/api/subscription/self');
if (res.data?.success) {
setBillingPreference(res.data.data?.billing_preference || 'subscription_first');
// Active subscriptions
const activeSubs = res.data.data?.subscriptions || [];
setActiveSubscriptions(activeSubs);
// All subscriptions (including expired)
const allSubs = res.data.data?.all_subscriptions || [];
setAllSubscriptions(allSubs);
}
} catch (e) {
// ignore
}
};
const updateBillingPreference = async (pref) => {
setBillingPreference(pref);
try {
const res = await API.put('/api/subscription/self/preference', {
billing_preference: pref,
});
if (res.data?.success) {
showSuccess(t('更新成功'));
} else {
showError(res.data?.message || t('更新失败'));
}
} catch (e) {
showError(t('请求失败'));
}
};
// 获取充值配置信息
const getTopupInfo = async () => {
try {
@@ -479,6 +534,8 @@ const TopUp = () => {
// 在 statusState 可用时获取充值信息
useEffect(() => {
getTopupInfo().then();
getSubscriptionPlans().then();
getSubscriptionSelf().then();
}, []);
useEffect(() => {
@@ -661,60 +718,72 @@ const TopUp = () => {
)}
</Modal>
{/* 用户信息头部 */}
<div className='space-y-6'>
<div className='grid grid-cols-1 lg:grid-cols-12 gap-6'>
{/* 左侧充值区域 */}
<div className='lg:col-span-7 space-y-6 w-full'>
<RechargeCard
t={t}
enableOnlineTopUp={enableOnlineTopUp}
enableStripeTopUp={enableStripeTopUp}
enableCreemTopUp={enableCreemTopUp}
creemProducts={creemProducts}
creemPreTopUp={creemPreTopUp}
presetAmounts={presetAmounts}
selectedPreset={selectedPreset}
selectPresetAmount={selectPresetAmount}
formatLargeNumber={formatLargeNumber}
priceRatio={priceRatio}
topUpCount={topUpCount}
minTopUp={minTopUp}
renderQuotaWithAmount={renderQuotaWithAmount}
getAmount={getAmount}
setTopUpCount={setTopUpCount}
setSelectedPreset={setSelectedPreset}
renderAmount={renderAmount}
amountLoading={amountLoading}
payMethods={payMethods}
preTopUp={preTopUp}
paymentLoading={paymentLoading}
payWay={payWay}
redemptionCode={redemptionCode}
setRedemptionCode={setRedemptionCode}
topUp={topUp}
isSubmitting={isSubmitting}
topUpLink={topUpLink}
openTopUpLink={openTopUpLink}
userState={userState}
renderQuota={renderQuota}
statusLoading={statusLoading}
topupInfo={topupInfo}
onOpenHistory={handleOpenHistory}
/>
</div>
{/* 主布局区域 */}
<div className='grid grid-cols-1 lg:grid-cols-12 gap-6'>
{/* 左侧 - 订阅套餐 */}
<div className='lg:col-span-7'>
<SubscriptionPlansCard
t={t}
loading={subscriptionLoading}
plans={subscriptionPlans}
payMethods={payMethods}
enableOnlineTopUp={enableOnlineTopUp}
enableStripeTopUp={enableStripeTopUp}
enableCreemTopUp={enableCreemTopUp}
billingPreference={billingPreference}
onChangeBillingPreference={updateBillingPreference}
activeSubscriptions={activeSubscriptions}
allSubscriptions={allSubscriptions}
reloadSubscriptionSelf={getSubscriptionSelf}
/>
</div>
{/* 右侧信息区域 */}
<div className='lg:col-span-5'>
<InvitationCard
t={t}
userState={userState}
renderQuota={renderQuota}
setOpenTransfer={setOpenTransfer}
affLink={affLink}
handleAffLinkClick={handleAffLinkClick}
/>
</div>
{/* 右侧 - 账户充值 + 邀请奖励 */}
<div className='lg:col-span-5 flex flex-col gap-6'>
<RechargeCard
t={t}
enableOnlineTopUp={enableOnlineTopUp}
enableStripeTopUp={enableStripeTopUp}
enableCreemTopUp={enableCreemTopUp}
creemProducts={creemProducts}
creemPreTopUp={creemPreTopUp}
presetAmounts={presetAmounts}
selectedPreset={selectedPreset}
selectPresetAmount={selectPresetAmount}
formatLargeNumber={formatLargeNumber}
priceRatio={priceRatio}
topUpCount={topUpCount}
minTopUp={minTopUp}
renderQuotaWithAmount={renderQuotaWithAmount}
getAmount={getAmount}
setTopUpCount={setTopUpCount}
setSelectedPreset={setSelectedPreset}
renderAmount={renderAmount}
amountLoading={amountLoading}
payMethods={payMethods}
preTopUp={preTopUp}
paymentLoading={paymentLoading}
payWay={payWay}
redemptionCode={redemptionCode}
setRedemptionCode={setRedemptionCode}
topUp={topUp}
isSubmitting={isSubmitting}
topUpLink={topUpLink}
openTopUpLink={openTopUpLink}
userState={userState}
renderQuota={renderQuota}
statusLoading={statusLoading}
topupInfo={topupInfo}
onOpenHistory={handleOpenHistory}
/>
<InvitationCard
t={t}
userState={userState}
renderQuota={renderQuota}
setOpenTransfer={setOpenTransfer}
affLink={affLink}
handleAffLinkClick={handleAffLinkClick}
/>
</div>
</div>
</div>

View File

@@ -0,0 +1,236 @@
/*
Copyright (C) 2025 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import React from 'react';
import { Modal, Typography, Card, Tag, Button, Select, Divider } from '@douyinfe/semi-ui';
import { Crown, CalendarClock, Package, Check } from 'lucide-react';
import { SiStripe } from 'react-icons/si';
import { IconCreditCard } from '@douyinfe/semi-icons';
const { Text } = Typography;
// 格式化有效期显示
function formatDuration(plan, t) {
const unit = plan?.duration_unit || 'month';
const value = plan?.duration_value || 1;
const unitLabels = {
year: t('年'),
month: t('个月'),
day: t('天'),
hour: t('小时'),
custom: t('自定义'),
};
if (unit === 'custom') {
const seconds = plan?.custom_seconds || 0;
if (seconds >= 86400) return `${Math.floor(seconds / 86400)} ${t('天')}`;
if (seconds >= 3600) return `${Math.floor(seconds / 3600)} ${t('小时')}`;
return `${seconds} ${t('秒')}`;
}
return `${value} ${unitLabels[unit] || unit}`;
}
// 获取货币符号
function getCurrencySymbol(currency) {
const symbols = { USD: '$', EUR: '€', CNY: '¥', GBP: '£', JPY: '¥' };
return symbols[currency] || currency + ' ';
}
const SubscriptionPurchaseModal = ({
t,
visible,
onCancel,
selectedPlan,
paying,
selectedEpayMethod,
setSelectedEpayMethod,
epayMethods = [],
enableOnlineTopUp = false,
enableStripeTopUp = false,
enableCreemTopUp = false,
onPayStripe,
onPayCreem,
onPayEpay,
}) => {
const plan = selectedPlan?.plan;
const items = selectedPlan?.items || [];
const currency = plan ? getCurrencySymbol(plan.currency || 'USD') : '$';
const price = plan ? Number(plan.price_amount || 0) : 0;
// 只有当管理员开启支付网关 AND 套餐配置了对应的支付ID时才显示
const hasStripe = enableStripeTopUp && !!plan?.stripe_price_id;
const hasCreem = enableCreemTopUp && !!plan?.creem_product_id;
const hasEpay = enableOnlineTopUp && epayMethods.length > 0;
const hasAnyPayment = hasStripe || hasCreem || hasEpay;
return (
<Modal
title={
<div className='flex items-center'>
<Crown className='mr-2' size={18} />
{t('购买订阅套餐')}
</div>
}
visible={visible}
onCancel={onCancel}
footer={null}
maskClosable={false}
size='small'
centered
>
{plan ? (
<div className='space-y-4 pb-10'>
{/* 套餐信息 */}
<Card className='!rounded-xl !border-0 bg-slate-50 dark:bg-slate-800'>
<div className='space-y-3'>
<div className='flex justify-between items-center'>
<Text strong className='text-slate-700 dark:text-slate-200'>
{t('套餐名称')}
</Text>
<Typography.Text
ellipsis={{ rows: 1, showTooltip: true }}
className='text-slate-900 dark:text-slate-100'
style={{ maxWidth: 200 }}
>
{plan.title}
</Typography.Text>
</div>
<div className='flex justify-between items-center'>
<Text strong className='text-slate-700 dark:text-slate-200'>
{t('有效期')}
</Text>
<div className='flex items-center'>
<CalendarClock size={14} className='mr-1 text-slate-500' />
<Text className='text-slate-900 dark:text-slate-100'>
{formatDuration(plan, t)}
</Text>
</div>
</div>
<div className='flex justify-between items-center'>
<Text strong className='text-slate-700 dark:text-slate-200'>
{t('包含权益')}
</Text>
<div className='flex items-center'>
<Package size={14} className='mr-1 text-slate-500' />
<Text className='text-slate-900 dark:text-slate-100'>
{items.length} {t('项')}
</Text>
</div>
</div>
<Divider margin={8} />
<div className='flex justify-between items-center'>
<Text strong className='text-slate-700 dark:text-slate-200'>
{t('应付金额')}
</Text>
<Text strong className='text-xl text-purple-600'>
{currency}{price.toFixed(price % 1 === 0 ? 0 : 2)}
</Text>
</div>
</div>
</Card>
{/* 权益列表 */}
{items.length > 0 && (
<div className='space-y-2'>
<Text size='small' type='tertiary'>{t('权益明细')}</Text>
<div className='flex flex-wrap gap-1'>
{items.slice(0, 6).map((it, idx) => (
<Tag key={idx} size='small' color='blue' type='light' shape='circle'>
<Check size={10} className='mr-1' />
{it.model_name}: {it.amount_total}{it.quota_type === 1 ? t('次') : ''}
</Tag>
))}
{items.length > 6 && (
<Tag size='small' color='grey' type='light' shape='circle'>
+{items.length - 6}
</Tag>
)}
</div>
</div>
)}
{/* 支付方式 */}
{hasAnyPayment ? (
<div className='space-y-3'>
<Text size='small' type='tertiary'>{t('选择支付方式')}</Text>
{/* Stripe / Creem */}
{(hasStripe || hasCreem) && (
<div className='flex gap-2'>
{hasStripe && (
<Button
theme='light'
className='flex-1'
icon={<SiStripe size={14} color='#635BFF' />}
onClick={onPayStripe}
loading={paying}
>
Stripe
</Button>
)}
{hasCreem && (
<Button
theme='light'
className='flex-1'
icon={<IconCreditCard />}
onClick={onPayCreem}
loading={paying}
>
Creem
</Button>
)}
</div>
)}
{/* 易支付 */}
{hasEpay && (
<div className='flex gap-2'>
<Select
value={selectedEpayMethod}
onChange={setSelectedEpayMethod}
style={{ flex: 1 }}
size='default'
placeholder={t('选择支付方式')}
optionList={epayMethods.map((m) => ({
value: m.type,
label: m.name || m.type,
}))}
/>
<Button
theme='solid'
type='primary'
onClick={onPayEpay}
loading={paying}
disabled={!selectedEpayMethod}
>
{t('支付')}
</Button>
</div>
)}
</div>
) : (
<div className='text-gray-500 text-sm p-3 bg-gray-50 rounded-lg border border-dashed border-gray-300 text-center'>
{t('管理员未开启在线支付,请联系管理员配置')}
</div>
)}
</div>
) : null}
</Modal>
);
};
export default SubscriptionPurchaseModal;

View File

@@ -74,6 +74,7 @@ import {
CircleUser,
Package,
Server,
CalendarClock,
} from 'lucide-react';
// 获取侧边栏Lucide图标组件
@@ -117,6 +118,8 @@ export function getLucideIcon(key, selected = false) {
return <Package {...commonProps} color={iconColor} />;
case 'deployment':
return <Server {...commonProps} color={iconColor} />;
case 'subscription':
return <CalendarClock {...commonProps} color={iconColor} />;
case 'setting':
return <Settings {...commonProps} color={iconColor} />;
default:

View File

@@ -51,6 +51,7 @@ export const DEFAULT_ADMIN_CONFIG = {
deployment: true,
redemption: true,
user: true,
subscription: true,
setting: true,
},
};

View File

@@ -0,0 +1,144 @@
/*
Copyright (C) 2025 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { API, showError, showSuccess } from '../../helpers';
import { useTableCompactMode } from '../common/useTableCompactMode';
export const useSubscriptionsData = () => {
const { t } = useTranslation();
const [compactMode, setCompactMode] = useTableCompactMode('subscriptions');
// State management
const [plans, setPlans] = useState([]);
const [loading, setLoading] = useState(true);
const [pricingModels, setPricingModels] = useState([]);
// Drawer states
const [showEdit, setShowEdit] = useState(false);
const [editingPlan, setEditingPlan] = useState(null);
const [sheetPlacement, setSheetPlacement] = useState('left'); // 'left' | 'right'
// Load pricing models for dropdown
const loadModels = async () => {
try {
const res = await API.get('/api/pricing');
if (res.data?.success) {
setPricingModels(res.data.data || []);
}
} catch (e) {
setPricingModels([]);
}
};
// Load subscription plans
const loadPlans = async () => {
setLoading(true);
try {
const res = await API.get('/api/subscription/admin/plans');
if (res.data?.success) {
setPlans(res.data.data || []);
} else {
showError(res.data?.message || t('加载失败'));
}
} catch (e) {
showError(t('请求失败'));
} finally {
setLoading(false);
}
};
// Refresh data
const refresh = async () => {
await loadPlans();
};
// Disable plan
const disablePlan = async (planId) => {
if (!planId) return;
setLoading(true);
try {
const res = await API.delete(`/api/subscription/admin/plans/${planId}`);
if (res.data?.success) {
showSuccess(t('已禁用'));
await loadPlans();
} else {
showError(res.data?.message || t('操作失败'));
}
} catch (e) {
showError(t('请求失败'));
} finally {
setLoading(false);
}
};
// Modal control functions
const closeEdit = () => {
setShowEdit(false);
setEditingPlan(null);
};
const openCreate = () => {
setSheetPlacement('left');
setEditingPlan(null);
setShowEdit(true);
};
const openEdit = (planRecord) => {
setSheetPlacement('right');
setEditingPlan(planRecord);
setShowEdit(true);
};
// Initialize data on component mount
useEffect(() => {
loadModels();
loadPlans();
}, []);
return {
// Data state
plans,
loading,
pricingModels,
// Modal state
showEdit,
editingPlan,
sheetPlacement,
setShowEdit,
setEditingPlan,
// UI state
compactMode,
setCompactMode,
// Actions
loadPlans,
disablePlan,
refresh,
closeEdit,
openCreate,
openEdit,
// Translation
t,
};
};

View File

@@ -364,32 +364,32 @@ export const useLogsData = () => {
key: t('日志详情'),
value: other?.claude
? renderClaudeLogContent(
other?.model_ratio,
other.completion_ratio,
other.model_price,
other.group_ratio,
other?.user_group_ratio,
other.cache_ratio || 1.0,
other.cache_creation_ratio || 1.0,
other.cache_creation_tokens_5m || 0,
other.cache_creation_ratio_5m || other.cache_creation_ratio || 1.0,
other.cache_creation_tokens_1h || 0,
other.cache_creation_ratio_1h || other.cache_creation_ratio || 1.0,
)
other?.model_ratio,
other.completion_ratio,
other.model_price,
other.group_ratio,
other?.user_group_ratio,
other.cache_ratio || 1.0,
other.cache_creation_ratio || 1.0,
other.cache_creation_tokens_5m || 0,
other.cache_creation_ratio_5m || other.cache_creation_ratio || 1.0,
other.cache_creation_tokens_1h || 0,
other.cache_creation_ratio_1h || other.cache_creation_ratio || 1.0,
)
: renderLogContent(
other?.model_ratio,
other.completion_ratio,
other.model_price,
other.group_ratio,
other?.user_group_ratio,
other.cache_ratio || 1.0,
false,
1.0,
other.web_search || false,
other.web_search_call_count || 0,
other.file_search || false,
other.file_search_call_count || 0,
),
other?.model_ratio,
other.completion_ratio,
other.model_price,
other.group_ratio,
other?.user_group_ratio,
other.cache_ratio || 1.0,
false,
1.0,
other.web_search || false,
other.web_search_call_count || 0,
other.file_search || false,
other.file_search_call_count || 0,
),
});
if (logs[i]?.content) {
expandDataLocal.push({
@@ -458,12 +458,12 @@ export const useLogsData = () => {
other.cache_creation_ratio || 1.0,
other.cache_creation_tokens_5m || 0,
other.cache_creation_ratio_5m ||
other.cache_creation_ratio ||
1.0,
other.cache_creation_ratio ||
1.0,
other.cache_creation_tokens_1h || 0,
other.cache_creation_ratio_1h ||
other.cache_creation_ratio ||
1.0,
other.cache_creation_ratio ||
1.0,
);
} else {
content = renderModelPrice(
@@ -510,6 +510,60 @@ export const useLogsData = () => {
value: other.request_path,
});
}
if (other?.billing_source === 'subscription') {
const planId = other?.subscription_plan_id;
const planTitle = other?.subscription_plan_title || '';
const itemId = other?.subscription_item_id;
const quotaType = other?.subscription_quota_type;
const unit = quotaType === 1 ? t('次') : t('额度');
const pre = other?.subscription_pre_consumed ?? 0;
const postDelta = other?.subscription_post_delta ?? 0;
const finalConsumed =
other?.subscription_consumed ?? (quotaType === 1 ? 1 : pre + postDelta);
const remain = other?.subscription_remain;
const total = other?.subscription_total;
// Use multiple Description items to avoid an overlong single line.
if (planId) {
expandDataLocal.push({
key: t('订阅套餐'),
value: `#${planId} ${planTitle}`.trim(),
});
}
if (itemId) {
expandDataLocal.push({
key: t('订阅权益'),
value:
quotaType === 1
? `${t('权益ID')} ${itemId} · ${t('按次')}1 ${t('次')}/${t('请求')}`
: `${t('权益ID')} ${itemId} · ${t('按量')}`,
});
}
const settlementLines = [
`${t('预扣')}${pre} ${unit}`,
quotaType === 0
? `${t('结算差额')}${postDelta > 0 ? '+' : ''}${postDelta} ${unit}`
: null,
`${t('最终抵扣')}${finalConsumed} ${unit}`,
]
.filter(Boolean)
.join('\n');
expandDataLocal.push({
key: t('订阅结算'),
value: <div style={{ whiteSpace: 'pre-line' }}>{settlementLines}</div>,
});
if (remain !== undefined && total !== undefined) {
expandDataLocal.push({
key: t('订阅剩余'),
value: `${remain}/${total} ${unit}`,
});
}
expandDataLocal.push({
key: t('订阅说明'),
value: t(
'token 会按倍率换算成“额度/次数”,请求结束后再做差额结算(补扣/返还)。',
),
});
}
if (isAdminUser) {
expandDataLocal.push({
key: t('请求转换'),
@@ -524,8 +578,8 @@ export const useLogsData = () => {
localCountMode = t('上游返回');
}
expandDataLocal.push({
key: t('计费模式'),
value: localCountMode,
key: t('计费模式'),
value: localCountMode,
});
}
expandDatesLocal[logs[i].key] = expandDataLocal;
@@ -584,7 +638,7 @@ export const useLogsData = () => {
// Page handlers
const handlePageChange = (page) => {
setActivePage(page);
loadLogs(page, pageSize).then((r) => {});
loadLogs(page, pageSize).then((r) => { });
};
const handlePageSizeChange = async (size) => {

View File

@@ -0,0 +1,32 @@
/*
Copyright (C) 2025 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import React from 'react';
import SubscriptionsTable from '../../components/table/subscriptions';
const Subscription = () => {
return (
<div className='mt-[60px] px-2'>
<SubscriptionsTable />
</div>
);
};
export default Subscription;