1、增加消息推送的前台横幅显示
2、增加apikey的整体配额增加和修改记录显示
This commit is contained in:
@@ -241,6 +241,10 @@ export async function createSystemConfig(key: string, value: string, description
|
||||
return api.post('/admin/system-configs', { key, value, description });
|
||||
}
|
||||
|
||||
export async function resetActivityBanner(): Promise<{ site_banner_version: number }> {
|
||||
return api.post('/admin/system-configs/banner/reset');
|
||||
}
|
||||
|
||||
export async function getGlobalResourceCapacity(): Promise<ResourceCapacityConfigOut> {
|
||||
return api.get('/admin/resource-capacity/global');
|
||||
}
|
||||
@@ -447,6 +451,25 @@ export async function getApiKeyUsage(id: string, days?: number, page?: number, p
|
||||
return api.get(`/admin/api-keys/${id}/usage${qs}`);
|
||||
}
|
||||
|
||||
export async function adjustApiKeyQuota(
|
||||
id: string,
|
||||
data: {
|
||||
action: 'adjust' | 'reset_usage' | 'set_limit' | 'change_cycle';
|
||||
quotaLimitDelta?: number;
|
||||
quotaLimit?: number | null;
|
||||
quotaCycle?: string | null;
|
||||
reason?: string | null;
|
||||
},
|
||||
): Promise<any> {
|
||||
return api.post(`/admin/api-keys/${id}/quota-adjust`, {
|
||||
action: data.action,
|
||||
quota_limit_delta: data.quotaLimitDelta,
|
||||
quota_limit: data.quotaLimit,
|
||||
quota_cycle: data.quotaCycle,
|
||||
reason: data.reason,
|
||||
});
|
||||
}
|
||||
|
||||
export async function getApiKeyUpscaleConfig(id: string): Promise<any> {
|
||||
return api.get(`/admin/api-keys/${id}/upscale`);
|
||||
}
|
||||
@@ -622,8 +645,16 @@ export async function deleteRechargePackage(id: string): Promise<void> {
|
||||
|
||||
// ── Operation Logs ──────────────────────────────────────
|
||||
|
||||
export async function getOperationLogs(page?: number): Promise<{ total: number; items: any[] }> {
|
||||
const q = page ? `?page=${page}` : '';
|
||||
export async function getOperationLogs(params?: {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
action?: string;
|
||||
}): Promise<{ total: number; items: any[] }> {
|
||||
const sp = new URLSearchParams();
|
||||
if (params?.page) sp.set('page', String(params.page));
|
||||
if (params?.pageSize) sp.set('page_size', String(params.pageSize));
|
||||
if (params?.action) sp.set('action', params.action);
|
||||
const q = sp.toString() ? `?${sp.toString()}` : '';
|
||||
return api.get(`/admin/operation-logs${q}`);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Modal, Radio, InputNumber, Input, Select, Space, Typography, Tag, Divider, message,
|
||||
} from 'antd';
|
||||
import { adjustApiKeyQuota } from '../api';
|
||||
|
||||
interface QuotaAdjustModalProps {
|
||||
open: boolean;
|
||||
keyId: string;
|
||||
companyName: string;
|
||||
quotaLimit: number | null;
|
||||
quotaUsed: number;
|
||||
quotaCycle: string | null;
|
||||
onCancel: () => void;
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
const QuotaAdjustModal: React.FC<QuotaAdjustModalProps> = ({
|
||||
open, keyId, companyName, quotaLimit, quotaUsed, quotaCycle, onCancel, onSuccess,
|
||||
}) => {
|
||||
const [action, setAction] = useState<'adjust' | 'reset_usage' | 'set_limit' | 'change_cycle'>('adjust');
|
||||
const [delta, setDelta] = useState<number>(0);
|
||||
const [newLimit, setNewLimit] = useState<number | null>(quotaLimit);
|
||||
const [newCycle, setNewCycle] = useState<string | null>(quotaCycle);
|
||||
const [reason, setReason] = useState<string>('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const cycleLabel = (cycle: string | null) => {
|
||||
const map: Record<string, string> = { daily: '每日', monthly: '每月', one_time: '一次性' };
|
||||
return cycle ? map[cycle] || cycle : '无限';
|
||||
};
|
||||
|
||||
const handleOk = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const payload: any = { action, reason: reason || undefined };
|
||||
if (action === 'adjust') payload.quotaLimitDelta = delta;
|
||||
if (action === 'set_limit') payload.quotaLimit = newLimit;
|
||||
if (action === 'change_cycle') payload.quotaCycle = newCycle;
|
||||
|
||||
await adjustApiKeyQuota(keyId, payload);
|
||||
message.success('配额调整成功');
|
||||
onSuccess();
|
||||
} catch (e: any) {
|
||||
message.error(e?.response?.data?.detail || '调整失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setAction('adjust');
|
||||
setDelta(0);
|
||||
setNewLimit(quotaLimit);
|
||||
setNewCycle(quotaCycle);
|
||||
setReason('');
|
||||
onCancel();
|
||||
};
|
||||
|
||||
// 预览计算
|
||||
const previewLimit = action === 'adjust'
|
||||
? round((quotaLimit || 0) + delta)
|
||||
: action === 'set_limit'
|
||||
? newLimit
|
||||
: quotaLimit;
|
||||
|
||||
function round(n: number) {
|
||||
return Math.round(n * 100) / 100;
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="调整配额"
|
||||
open={open}
|
||||
onOk={handleOk}
|
||||
onCancel={handleCancel}
|
||||
okText="确认调整"
|
||||
cancelText="取消"
|
||||
confirmLoading={loading}
|
||||
width={480}
|
||||
>
|
||||
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
|
||||
<div>
|
||||
<Typography.Text type="secondary">公司:</Typography.Text>
|
||||
<Typography.Text strong>{companyName}</Typography.Text>
|
||||
</div>
|
||||
<div>
|
||||
<Typography.Text type="secondary">当前:</Typography.Text>
|
||||
<Tag color="blue">已用 {quotaUsed.toFixed(2)} 元</Tag>
|
||||
<Tag color="green">限额 {quotaLimit != null ? `${quotaLimit.toFixed(2)} 元` : '无限'}</Tag>
|
||||
<Tag color="purple">{cycleLabel(quotaCycle)}</Tag>
|
||||
</div>
|
||||
|
||||
<Divider style={{ margin: '8px 0' }} />
|
||||
|
||||
<Radio.Group value={action} onChange={e => setAction(e.target.value)} style={{ width: '100%' }}>
|
||||
<Space direction="vertical" size={12} style={{ width: '100%' }}>
|
||||
<Radio value="adjust">
|
||||
<Space>
|
||||
<Typography.Text>增加总额</Typography.Text>
|
||||
{action === 'adjust' && (
|
||||
<InputNumber
|
||||
min={0}
|
||||
step={10}
|
||||
value={delta}
|
||||
onChange={v => setDelta(v || 0)}
|
||||
addonAfter="元"
|
||||
style={{ width: 160 }}
|
||||
/>
|
||||
)}
|
||||
</Space>
|
||||
</Radio>
|
||||
|
||||
<Radio value="reset_usage">
|
||||
<Space>
|
||||
<Typography.Text>重置已用</Typography.Text>
|
||||
{action === 'reset_usage' && (
|
||||
<Typography.Text type="secondary">
|
||||
({quotaUsed.toFixed(2)} → 0.00 元)
|
||||
</Typography.Text>
|
||||
)}
|
||||
</Space>
|
||||
</Radio>
|
||||
|
||||
<Radio value="set_limit">
|
||||
<Space>
|
||||
<Typography.Text>设置限额</Typography.Text>
|
||||
{action === 'set_limit' && (
|
||||
<>
|
||||
<InputNumber
|
||||
min={0}
|
||||
step={10}
|
||||
value={newLimit}
|
||||
onChange={setNewLimit}
|
||||
addonAfter="元"
|
||||
placeholder="留空=无限"
|
||||
style={{ width: 160 }}
|
||||
/>
|
||||
<Typography.Text type="secondary">
|
||||
(当前:{quotaLimit != null ? `${quotaLimit.toFixed(2)} 元` : '无限'})
|
||||
</Typography.Text>
|
||||
</>
|
||||
)}
|
||||
</Space>
|
||||
</Radio>
|
||||
|
||||
<Radio value="change_cycle">
|
||||
<Space>
|
||||
<Typography.Text>修改周期</Typography.Text>
|
||||
{action === 'change_cycle' && (
|
||||
<Select
|
||||
value={newCycle}
|
||||
onChange={setNewCycle}
|
||||
allowClear
|
||||
placeholder="选择周期"
|
||||
style={{ width: 140 }}
|
||||
options={[
|
||||
{ label: '每日', value: 'daily' },
|
||||
{ label: '每月', value: 'monthly' },
|
||||
{ label: '一次性', value: 'one_time' },
|
||||
{ label: '无限', value: null },
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</Space>
|
||||
</Radio>
|
||||
</Space>
|
||||
</Radio.Group>
|
||||
|
||||
{action === 'adjust' && delta > 0 && (
|
||||
<div style={{ padding: '8px 12px', background: '#f0f5ff', borderRadius: 6, fontSize: 13 }}>
|
||||
调整后总额:<strong style={{ color: '#1677ff' }}>{previewLimit != null ? `${previewLimit.toFixed(2)} 元` : '无限'}</strong>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>调整原因(可选)</Typography.Text>
|
||||
<Input.TextArea
|
||||
value={reason}
|
||||
onChange={e => setReason(e.target.value)}
|
||||
placeholder="请输入调整原因..."
|
||||
rows={2}
|
||||
maxLength={500}
|
||||
style={{ marginTop: 4 }}
|
||||
/>
|
||||
</div>
|
||||
</Space>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default QuotaAdjustModal;
|
||||
@@ -1,15 +1,16 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import dayjs from 'dayjs';
|
||||
import {
|
||||
Button, Card, DatePicker, Divider, Form, Input, InputNumber, message, Modal, Popconfirm, Progress, Select, Space, Switch, Table, Tag, Typography,
|
||||
Button, Card, DatePicker, Divider, Form, Input, InputNumber, message, Modal, Popconfirm, Progress, Select, Space, Switch, Table, Tabs, Tag, Typography,
|
||||
} from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import {
|
||||
PlusOutlined, EditOutlined, DeleteOutlined, ApiOutlined, EyeOutlined, KeyOutlined, CopyOutlined,
|
||||
PlusOutlined, EditOutlined, DeleteOutlined, ApiOutlined, EyeOutlined, KeyOutlined, CopyOutlined, DollarOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import QuotaAdjustModal from '../components/QuotaAdjustModal';
|
||||
import {
|
||||
getApiKeys, createApiKey, updateApiKey, deleteApiKey, getApiKeyUsage, getGenerationAiEngines, revealApiKey, getApiKeyUpscaleConfig, saveApiKeyUpscaleConfig,
|
||||
getApiKeyVpV3Quota, saveApiKeyVpV3Quota,
|
||||
getApiKeyVpV3Quota, saveApiKeyVpV3Quota, getOperationLogs,
|
||||
} from '../api';
|
||||
import type { GenerationAiEngineOption } from '../types';
|
||||
|
||||
@@ -49,6 +50,8 @@ const AdminApiKeys: React.FC = () => {
|
||||
const [total, setTotal] = useState(0);
|
||||
const [modal, setModal] = useState<{ open: boolean; key: ApiKey | null }>({ open: false, key: null });
|
||||
const [usageModal, setUsageModal] = useState<{ open: boolean; key: ApiKey | null; usage: any }>({ open: false, key: null, usage: null });
|
||||
const [quotaModal, setQuotaModal] = useState<{ open: boolean; key: ApiKey | null }>({ open: false, key: null });
|
||||
const [quotaLogs, setQuotaLogs] = useState<{ items: any[]; total: number; page: number; loading: boolean }>({ items: [], total: 0, page: 1, loading: false });
|
||||
const [form] = Form.useForm();
|
||||
const [engines, setEngines] = useState<EngineOption[]>([]);
|
||||
const [upscaleRules, setUpscaleRules] = useState<UpscaleRule[]>([]);
|
||||
@@ -273,10 +276,24 @@ const AdminApiKeys: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const loadQuotaLogs = async (keyId: string, page = 1) => {
|
||||
setQuotaLogs(prev => ({ ...prev, loading: true }));
|
||||
try {
|
||||
// 从 operation_logs 中筛选 quota_adjust:* 且 path 包含该 keyId 的记录
|
||||
const data = await getOperationLogs({ page, pageSize: 20, action: 'quota_adjust' });
|
||||
const filtered = (data?.items || []).filter((item: any) => item.path?.includes(keyId));
|
||||
setQuotaLogs({ items: filtered, total: filtered.length, page, loading: false });
|
||||
} catch {
|
||||
message.error('加载配额变更记录失败');
|
||||
setQuotaLogs(prev => ({ ...prev, loading: false }));
|
||||
}
|
||||
};
|
||||
|
||||
const viewUsage = async (key: ApiKey) => {
|
||||
try {
|
||||
const usage = await getApiKeyUsage(key.id, 30, 1, 20);
|
||||
setUsageModal({ open: true, key, usage });
|
||||
loadQuotaLogs(key.id, 1);
|
||||
} catch {
|
||||
message.error('加载使用统计失败');
|
||||
}
|
||||
@@ -390,6 +407,7 @@ const AdminApiKeys: React.FC = () => {
|
||||
width: 260,
|
||||
render: (_: any, r: ApiKey) => (
|
||||
<Space size={0}>
|
||||
<Button type="link" size="small" icon={<DollarOutlined />} onClick={() => setQuotaModal({ open: true, key: r })}>配额</Button>
|
||||
<Button type="link" size="small" icon={<EyeOutlined />} onClick={() => viewUsage(r)}>统计</Button>
|
||||
<Button type="link" size="small" icon={<EditOutlined />} onClick={() => openEdit(r)}>编辑</Button>
|
||||
<Popconfirm title="确定删除?" onConfirm={() => handleDelete(r.id)}>
|
||||
@@ -607,47 +625,134 @@ const AdminApiKeys: React.FC = () => {
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
{/* 使用统计弹窗 */}
|
||||
{/* 使用统计 + 配额变更弹窗 */}
|
||||
<Modal
|
||||
title={`使用统计 - ${usageModal.key?.companyName || ''}`}
|
||||
title={`API Key 详情 - ${usageModal.key?.companyName || ''}`}
|
||||
open={usageModal.open}
|
||||
onCancel={() => setUsageModal({ open: false, key: null, usage: null })}
|
||||
footer={null} width={640}
|
||||
footer={null} width={720}
|
||||
>
|
||||
{usageModal.usage && (
|
||||
<div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 16, marginBottom: 24 }}>
|
||||
<Card><Typography.Text type="secondary">总请求数</Typography.Text><Typography.Title level={3} style={{ margin: 0 }}>{usageModal.usage.totalRequests}</Typography.Title></Card>
|
||||
<Card><Typography.Text type="secondary">总消耗(元)</Typography.Text><Typography.Title level={3} style={{ margin: 0 }}>{usageModal.usage.totalCreditsCost?.toFixed(2)}</Typography.Title></Card>
|
||||
<Card><Typography.Text type="secondary">成功率</Typography.Text><Typography.Title level={3} style={{ margin: 0 }}>{usageModal.usage.totalRequests ? ((usageModal.usage.successCount / usageModal.usage.totalRequests) * 100).toFixed(1) : 0}%</Typography.Title></Card>
|
||||
</div>
|
||||
<Table
|
||||
columns={[
|
||||
{ title: '时间', dataIndex: 'createdAt', width: 160, render: (v: string) => v ? new Date(v).toLocaleString() : '-' },
|
||||
{
|
||||
title: '类型', dataIndex: 'genType', width: 70,
|
||||
render: (v: string) => <Tag color={v === 'video' ? 'blue' : 'green'}>{v === 'video' ? '视频' : '图片'}</Tag>,
|
||||
},
|
||||
{ title: '模型', dataIndex: 'modelName', width: 140, ellipsis: true },
|
||||
{ title: '消耗(元)', dataIndex: 'creditsCost', width: 90, render: (v: number) => v?.toFixed(2) || '0.00' },
|
||||
{ title: '状态', dataIndex: 'status', width: 70, render: (v: string) => <Tag color={v === 'success' ? 'green' : 'red'}>{v === 'success' ? '成功' : '失败'}</Tag> },
|
||||
]}
|
||||
dataSource={usageModal.usage.items || []}
|
||||
rowKey="id"
|
||||
pagination={{
|
||||
current: usageModal.usage.page || 1,
|
||||
pageSize: usageModal.usage.pageSize || 20,
|
||||
total: usageModal.usage.total || 0,
|
||||
onChange: (p, ps) => loadUsageDetail(usageModal.key?.id || '', p, ps || 20),
|
||||
showSizeChanger: true,
|
||||
showTotal: (t) => `共 ${t} 条`,
|
||||
size: 'small',
|
||||
}}
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<Tabs
|
||||
defaultActiveKey="usage"
|
||||
items={[
|
||||
{
|
||||
key: 'usage',
|
||||
label: '使用统计',
|
||||
children: usageModal.usage && (
|
||||
<div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 16, marginBottom: 24 }}>
|
||||
<Card><Typography.Text type="secondary">总请求数</Typography.Text><Typography.Title level={3} style={{ margin: 0 }}>{usageModal.usage.totalRequests}</Typography.Title></Card>
|
||||
<Card><Typography.Text type="secondary">总消耗(元)</Typography.Text><Typography.Title level={3} style={{ margin: 0 }}>{usageModal.usage.totalCreditsCost?.toFixed(2)}</Typography.Title></Card>
|
||||
<Card><Typography.Text type="secondary">成功率</Typography.Text><Typography.Title level={3} style={{ margin: 0 }}>{usageModal.usage.totalRequests ? ((usageModal.usage.successCount / usageModal.usage.totalRequests) * 100).toFixed(1) : 0}%</Typography.Title></Card>
|
||||
</div>
|
||||
<Table
|
||||
columns={[
|
||||
{ title: '时间', dataIndex: 'createdAt', width: 160, render: (v: string) => v ? new Date(v).toLocaleString() : '-' },
|
||||
{
|
||||
title: '类型', dataIndex: 'genType', width: 70,
|
||||
render: (v: string) => <Tag color={v === 'video' ? 'blue' : 'green'}>{v === 'video' ? '视频' : '图片'}</Tag>,
|
||||
},
|
||||
{ title: '模型', dataIndex: 'modelName', width: 140, ellipsis: true },
|
||||
{ title: '消耗(元)', dataIndex: 'creditsCost', width: 90, render: (v: number) => v?.toFixed(2) || '0.00' },
|
||||
{ title: '状态', dataIndex: 'status', width: 70, render: (v: string) => <Tag color={v === 'success' ? 'green' : 'red'}>{v === 'success' ? '成功' : '失败'}</Tag> },
|
||||
]}
|
||||
dataSource={usageModal.usage.items || []}
|
||||
rowKey="id"
|
||||
pagination={{
|
||||
current: usageModal.usage.page || 1,
|
||||
pageSize: usageModal.usage.pageSize || 20,
|
||||
total: usageModal.usage.total || 0,
|
||||
onChange: (p, ps) => loadUsageDetail(usageModal.key?.id || '', p, ps || 20),
|
||||
showSizeChanger: true,
|
||||
showTotal: (t) => `共 ${t} 条`,
|
||||
size: 'small',
|
||||
}}
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'quota',
|
||||
label: '配额变更',
|
||||
children: (
|
||||
<div>
|
||||
<Table
|
||||
columns={[
|
||||
{ title: '时间', dataIndex: 'createdAt', width: 160, render: (v: string) => v ? new Date(v).toLocaleString() : '-' },
|
||||
{ title: '管理员', dataIndex: 'username', width: 100, ellipsis: true },
|
||||
{
|
||||
title: '操作', dataIndex: 'action', width: 110,
|
||||
render: (v: string) => {
|
||||
const sub = v?.split(':')[1] || v;
|
||||
const label: Record<string, string> = { adjust: '增加总额', reset_usage: '重置已用', set_limit: '设置限额', change_cycle: '修改周期' };
|
||||
return <Tag color="blue">{label[sub] || v}</Tag>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '变更详情', dataIndex: 'detail', width: 220,
|
||||
ellipsis: true,
|
||||
render: (v: string, row: any) => {
|
||||
let detail: any = v;
|
||||
if (typeof v === 'string') {
|
||||
try { detail = JSON.parse(v); } catch { return v || '-'; }
|
||||
}
|
||||
if (!detail || typeof detail !== 'object') return '-';
|
||||
const parts: string[] = [];
|
||||
if (detail.old_limit != null || detail.new_limit != null) {
|
||||
parts.push(`限额: ${detail.old_limit != null ? detail.old_limit.toFixed(2) : '-'} → ${detail.new_limit != null ? detail.new_limit.toFixed(2) : '无限'}`);
|
||||
}
|
||||
if (detail.old_used != null && detail.new_used != null && detail.old_used !== detail.new_used) {
|
||||
parts.push(`已用: ${detail.old_used.toFixed(2)} → ${detail.new_used.toFixed(2)}`);
|
||||
}
|
||||
if (detail.old_cycle != null || detail.new_cycle != null) {
|
||||
if (detail.old_cycle !== detail.new_cycle) {
|
||||
parts.push(`周期: ${cycleLabel(detail.old_cycle)} → ${cycleLabel(detail.new_cycle)}`);
|
||||
}
|
||||
}
|
||||
return parts.length > 0 ? <span style={{ fontSize: 12 }}>{parts.join(' | ')}</span> : '-';
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '原因', dataIndex: 'detail', width: 120,
|
||||
ellipsis: true,
|
||||
render: (v: string) => {
|
||||
let detail: any = v;
|
||||
if (typeof v === 'string') {
|
||||
try { detail = JSON.parse(v); } catch { /* */ }
|
||||
}
|
||||
return detail?.reason || '-';
|
||||
},
|
||||
},
|
||||
]}
|
||||
dataSource={quotaLogs.items}
|
||||
rowKey={(r, i) => r.id || r.createdAt || i}
|
||||
loading={quotaLogs.loading}
|
||||
pagination={false}
|
||||
size="small"
|
||||
scroll={{ x: 700 }}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
{/* 配额调整弹窗 */}
|
||||
<QuotaAdjustModal
|
||||
open={quotaModal.open}
|
||||
keyId={quotaModal.key?.id || ''}
|
||||
companyName={quotaModal.key?.companyName || ''}
|
||||
quotaLimit={quotaModal.key?.quotaLimit ?? null}
|
||||
quotaUsed={quotaModal.key?.quotaUsed || 0}
|
||||
quotaCycle={quotaModal.key?.quotaCycle || null}
|
||||
onCancel={() => setQuotaModal({ open: false, key: null })}
|
||||
onSuccess={() => {
|
||||
setQuotaModal({ open: false, key: null });
|
||||
load();
|
||||
}}
|
||||
/>
|
||||
</Space>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
Button, Card, Form, Input, Modal, Popconfirm, Select, Space, Table, Tag, Typography, message, Empty,
|
||||
Button, Card, Form, Input, Modal, Popconfirm, Select, Space, Table, Tag, Typography, message, Empty, Tabs,
|
||||
} from 'antd';
|
||||
import {
|
||||
BellOutlined, PlusOutlined, DeleteOutlined, SendOutlined, EyeOutlined, TeamOutlined,
|
||||
BellOutlined, PlusOutlined, DeleteOutlined, SendOutlined, EyeOutlined, TeamOutlined, NotificationOutlined, SaveOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import ReactQuill from 'react-quill-new';
|
||||
import 'react-quill-new/dist/quill.snow.css';
|
||||
import { getAdminNotifications, createAdminNotification, deleteAdminNotification, getAdminUsers, getNotificationReadUsers } from '../api';
|
||||
import { getAdminNotifications, createAdminNotification, deleteAdminNotification, getAdminUsers, getNotificationReadUsers, getSystemConfigs, updateSystemConfig, createSystemConfig, resetActivityBanner } from '../api';
|
||||
import { formatDate } from '../utils/formatDate';
|
||||
|
||||
interface NotificationRecord {
|
||||
@@ -31,6 +31,19 @@ interface ReadUser {
|
||||
readAt: string;
|
||||
}
|
||||
|
||||
// 富文本编辑器工具栏配置(含颜色选择)
|
||||
const editorModules = {
|
||||
toolbar: [
|
||||
[{ header: [1, 2, 3, false] }],
|
||||
[{ color: [] }, { background: [] }],
|
||||
['bold', 'italic', 'underline', 'strike'],
|
||||
[{ list: 'ordered' }, { list: 'bullet' }],
|
||||
[{ align: [] }],
|
||||
['link', 'image'],
|
||||
['clean'],
|
||||
],
|
||||
};
|
||||
|
||||
const AdminNotificationManager: React.FC = () => {
|
||||
const [notifications, setNotifications] = useState<NotificationRecord[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -43,6 +56,11 @@ const AdminNotificationManager: React.FC = () => {
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(10);
|
||||
const [total, setTotal] = useState(0);
|
||||
// Banner state
|
||||
const [bannerContent, setBannerContent] = useState('');
|
||||
const [bannerConfigId, setBannerConfigId] = useState<string | null>(null);
|
||||
const [bannerSaving, setBannerSaving] = useState(false);
|
||||
const [bannerLoading, setBannerLoading] = useState(false);
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
@@ -71,8 +89,29 @@ const AdminNotificationManager: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const loadBanner = async () => {
|
||||
setBannerLoading(true);
|
||||
try {
|
||||
const configs = await getSystemConfigs();
|
||||
const banner = configs.find((c: any) => c.key === 'site_banner');
|
||||
if (banner) {
|
||||
setBannerContent(banner.value || '');
|
||||
setBannerConfigId(banner.id);
|
||||
} else {
|
||||
setBannerContent('');
|
||||
setBannerConfigId(null);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setBannerLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => { load(); }, [page, pageSize]);
|
||||
|
||||
useEffect(() => { loadBanner(); }, []);
|
||||
|
||||
const handlePageChange = (p: number, ps: number) => {
|
||||
setPage(p);
|
||||
setPageSize(ps);
|
||||
@@ -117,6 +156,39 @@ const AdminNotificationManager: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveBanner = async () => {
|
||||
const content = bannerContent.trim();
|
||||
if (!content) {
|
||||
message.error('请输入横幅内容');
|
||||
return;
|
||||
}
|
||||
setBannerSaving(true);
|
||||
try {
|
||||
if (bannerConfigId) {
|
||||
await updateSystemConfig(bannerConfigId, content);
|
||||
} else {
|
||||
const res = await createSystemConfig('site_banner', content, '全局活动通知横幅内容');
|
||||
setBannerConfigId(res.id);
|
||||
}
|
||||
// 内容变更后自动递增版本号,让所有用户重新看到横幅
|
||||
try { await resetActivityBanner(); } catch { /* ignore */ }
|
||||
message.success('活动横幅已保存,所有用户将重新看到该横幅');
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '保存失败');
|
||||
} finally {
|
||||
setBannerSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleResetBanner = async () => {
|
||||
try {
|
||||
const res = await resetActivityBanner();
|
||||
message.success(`横幅已重新展示(版本 → ${res.site_banner_version})`);
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '操作失败');
|
||||
}
|
||||
};
|
||||
|
||||
const getTypeColor = (type: string) => {
|
||||
switch (type) {
|
||||
case 'system': return 'blue';
|
||||
@@ -167,37 +239,148 @@ const AdminNotificationManager: React.FC = () => {
|
||||
},
|
||||
];
|
||||
|
||||
const tabItems = [
|
||||
{
|
||||
key: 'notifications',
|
||||
label: (
|
||||
<span><BellOutlined style={{ marginRight: 6 }} />消息推送</span>
|
||||
),
|
||||
children: (
|
||||
<Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
|
||||
<Space>
|
||||
<BellOutlined style={{ fontSize: 18, color: '#6366f1' }} />
|
||||
<Typography.Text strong style={{ fontSize: 16 }}>消息推送管理</Typography.Text>
|
||||
<Tag color="purple">共 {total} 条消息</Tag>
|
||||
</Space>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => setModalOpen(true)}
|
||||
style={{ borderRadius: 8 }}>
|
||||
发送新消息
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={notifications}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize: pageSize,
|
||||
total: total,
|
||||
onChange: handlePageChange,
|
||||
showSizeChanger: true,
|
||||
showTotal: (t) => `共 ${t} 条消息`,
|
||||
}}
|
||||
scroll={{ x: 900 }}
|
||||
/>
|
||||
</Card>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'banner',
|
||||
label: (
|
||||
<span><NotificationOutlined style={{ marginRight: 6 }} />活动横幅</span>
|
||||
),
|
||||
children: (
|
||||
<Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 20 }}>
|
||||
<div style={{
|
||||
width: 40, height: 40, borderRadius: 10,
|
||||
background: 'rgba(99,102,241,0.08)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: 18, color: '#6366f1',
|
||||
}}>
|
||||
<NotificationOutlined />
|
||||
</div>
|
||||
<div>
|
||||
<Typography.Text strong style={{ fontSize: 16 }}>全局活动横幅设置</Typography.Text>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 13, display: 'block' }}>
|
||||
设置后将在用户前台页面顶部显示活动通知横幅,支持富文本格式
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<Typography.Text strong style={{ display: 'block', marginBottom: 8 }}>横幅内容</Typography.Text>
|
||||
{bannerLoading ? (
|
||||
<div style={{ padding: '40px 0', textAlign: 'center', color: '#94a3b8' }}>加载中...</div>
|
||||
) : (
|
||||
<ReactQuill
|
||||
theme="snow"
|
||||
value={bannerContent}
|
||||
onChange={setBannerContent}
|
||||
modules={editorModules}
|
||||
placeholder="请输入横幅内容(支持富文本:加粗、变色、链接等)"
|
||||
style={{ height: 200, marginBottom: 48 }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 12 }}>
|
||||
<Button
|
||||
icon={<NotificationOutlined />}
|
||||
onClick={handleResetBanner}
|
||||
size="large"
|
||||
style={{ borderRadius: 8, minWidth: 160 }}
|
||||
>
|
||||
重新展示横幅
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<SaveOutlined />}
|
||||
onClick={handleSaveBanner}
|
||||
loading={bannerSaving}
|
||||
size="large"
|
||||
style={{ borderRadius: 8, minWidth: 140 }}
|
||||
>
|
||||
保存横幅
|
||||
</Button>
|
||||
</div>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12, display: 'block', marginTop: 8 }}>
|
||||
💡 点击「重新展示横幅」将强制所有已关闭横幅的用户再次看到;修改内容并保存也会自动重新展示。
|
||||
</Typography.Text>
|
||||
|
||||
{/* 预览区域 */}
|
||||
{bannerContent && (
|
||||
<div style={{ marginTop: 24 }}>
|
||||
<Typography.Text strong style={{ display: 'block', marginBottom: 8 }}>前台预览</Typography.Text>
|
||||
<div style={{
|
||||
borderRadius: 12,
|
||||
overflow: 'hidden',
|
||||
background: 'linear-gradient(135deg, #f3e8ff 0%, #ede9fe 50%, #e0e7ff 100%)',
|
||||
border: '1px solid rgba(139, 92, 246, 0.15)',
|
||||
padding: '10px 16px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 10,
|
||||
}}>
|
||||
<div style={{
|
||||
width: 28, height: 28, borderRadius: 8,
|
||||
background: 'rgba(139, 92, 246, 0.12)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
}}>
|
||||
<NotificationOutlined style={{ color: '#7c3aed', fontSize: 14 }} />
|
||||
</div>
|
||||
<div style={{
|
||||
color: '#5b21b6',
|
||||
fontSize: 14,
|
||||
fontWeight: 500,
|
||||
lineHeight: 1.5,
|
||||
flex: 1,
|
||||
}} dangerouslySetInnerHTML={{ __html: bannerContent }} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
|
||||
<Space>
|
||||
<BellOutlined style={{ fontSize: 18, color: '#6366f1' }} />
|
||||
<Typography.Text strong style={{ fontSize: 16 }}>消息推送管理</Typography.Text>
|
||||
<Tag color="purple">共 {total} 条消息</Tag>
|
||||
</Space>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => setModalOpen(true)}
|
||||
style={{ borderRadius: 8 }}>
|
||||
发送新消息
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={notifications}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize: pageSize,
|
||||
total: total,
|
||||
onChange: handlePageChange,
|
||||
showSizeChanger: true,
|
||||
showTotal: (t) => `共 ${t} 条消息`,
|
||||
}}
|
||||
scroll={{ x: 900 }}
|
||||
/>
|
||||
</Card>
|
||||
<Tabs items={tabItems} defaultActiveKey="notifications" />
|
||||
|
||||
{/* Send Notification Modal */}
|
||||
<Modal
|
||||
@@ -214,7 +397,7 @@ const AdminNotificationManager: React.FC = () => {
|
||||
</Form.Item>
|
||||
<Form.Item name="content" label="消息内容"
|
||||
rules={[{ required: true, validator: (_, v) => v && v !== '<p><br></p>' ? Promise.resolve() : Promise.reject('请输入内容') }]}>
|
||||
<ReactQuill theme="snow" placeholder="请输入消息内容(支持富文本:加粗、斜体、颜色、链接等)" style={{ height: 180, marginBottom: 40 }} />
|
||||
<ReactQuill theme="snow" modules={editorModules} placeholder="请输入消息内容(支持富文本:加粗、斜体、颜色、链接等)" style={{ height: 180, marginBottom: 40 }} />
|
||||
</Form.Item>
|
||||
<div style={{ display: 'flex', gap: 16 }}>
|
||||
<Form.Item name="type" label="消息类型" style={{ flex: 1 }}
|
||||
|
||||
@@ -31,7 +31,7 @@ const AdminOperationLogs: React.FC = () => {
|
||||
const load = async (p?: number) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await getOperationLogs(p || page);
|
||||
const res = await getOperationLogs({ page: p || page });
|
||||
setLogs(res.items || []);
|
||||
setTotal(res.total || 0);
|
||||
} catch {
|
||||
|
||||
@@ -70,9 +70,12 @@ const AdminSettings: React.FC = () => {
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
const llmBillingEnabled = !['0', 'false', 'no', 'off', 'disabled'].includes(
|
||||
String(values.llm_billing_enabled ?? 'true').trim().toLowerCase(),
|
||||
);
|
||||
// 仅当 llm_billing_enabled 字段在当前标签页渲染时,才校验预扣积分
|
||||
const llmBillingEnabled = values.llm_billing_enabled !== undefined && values.llm_billing_enabled !== null
|
||||
? !['0', 'false', 'no', 'off', 'disabled'].includes(
|
||||
String(values.llm_billing_enabled).trim().toLowerCase(),
|
||||
)
|
||||
: false;
|
||||
if (llmBillingEnabled) {
|
||||
const holdKeys = [
|
||||
'optimize_hold_credits',
|
||||
@@ -249,7 +252,7 @@ const AdminSettings: React.FC = () => {
|
||||
};
|
||||
|
||||
const groupedConfigs: Record<string, SystemConfig[]> = {
|
||||
'站点信息': configs.filter(c => c.key.startsWith('site_')),
|
||||
'站点信息': configs.filter(c => c.key.startsWith('site_') && c.key !== 'site_banner'),
|
||||
'协议配置': configs.filter(c => c.key === 'user_agreement_privacy_url'),
|
||||
'SEO 设置': configs.filter(c => c.key.startsWith('seo_')),
|
||||
'用户积分配置': configs.filter(c => c.key.startsWith('user_') && c.key.includes('credits')),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { Col, Form, Input, InputNumber, Radio, Row, Select, Slider, Switch } from 'antd';
|
||||
import { Col, ColorPicker, Form, Input, InputNumber, Radio, Row, Select, Slider, Switch } from 'antd';
|
||||
import type { HomeMaterialMediaType, HomeMaterialWatermark, HomeMaterialWatermarkConfig } from '../../types';
|
||||
import WatermarkPreview from './WatermarkPreview';
|
||||
|
||||
@@ -87,7 +87,15 @@ const WatermarkEditor: React.FC<WatermarkEditorProps> = ({ value, onChange, wate
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item label="文字颜色" required>
|
||||
<Input value={textWatermark.color} onChange={(e) => patchText({ color: e.target.value || '#ffffff' })} placeholder="#ffffff" />
|
||||
<ColorPicker
|
||||
value={textWatermark.color}
|
||||
onChange={(_, hex) => patchText({ color: hex || '#ffffff' })}
|
||||
showText
|
||||
presets={[{
|
||||
label: '推荐',
|
||||
colors: ['#ffffff', '#000000', '#ff4d4f', '#1677ff', '#52c41a', '#faad14', '#722ed1', '#eb2f96'],
|
||||
}]}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"root":["./src/app.tsx","./src/env.d.ts","./src/main.tsx","./src/api/client.ts","./src/api/crypto.ts","./src/api/index.ts","./src/components/preresultdisplay.tsx","./src/components/generation/generationtaskresourcegrid.tsx","./src/pages/adminapikeys.tsx","./src/pages/adminapimodelpricings.tsx","./src/pages/adminapiusage.tsx","./src/pages/adminauthoriz.tsx","./src/pages/adminconsume.tsx","./src/pages/admincontactrequests.tsx","./src/pages/admincreditratios.tsx","./src/pages/admincreditrecords.tsx","./src/pages/admindashboard.tsx","./src/pages/admingenerationairecords.tsx","./src/pages/admingenerationrecords.tsx","./src/pages/adminhomematerials.tsx","./src/pages/adminhotopeningreplicationdetail.tsx","./src/pages/adminhotopeningreplications.tsx","./src/pages/adminimageengines.tsx","./src/pages/adminindustries.tsx","./src/pages/adminlayout.tsx","./src/pages/adminloginpage.tsx","./src/pages/adminmateriallist.tsx","./src/pages/adminmenuconfig.tsx","./src/pages/adminmodels.tsx","./src/pages/adminnotificationmanager.tsx","./src/pages/adminoauthlist.tsx","./src/pages/adminoauthapplist.tsx","./src/pages/adminoperationlogs.tsx","./src/pages/adminpaymentconfig.tsx","./src/pages/adminpaymentstats.tsx","./src/pages/adminplatform.tsx","./src/pages/adminpretesttemplates.tsx","./src/pages/adminprivateportraitprojects.tsx","./src/pages/adminrechargepackages.tsx","./src/pages/adminreplicationprojectdetail.tsx","./src/pages/adminsettings.tsx","./src/pages/adminshotreplications.tsx","./src/pages/adminshottasksetdetail.tsx","./src/pages/adminteams.tsx","./src/pages/adminusers.tsx","./src/pages/adminvideoengines.tsx","./src/pages/adminvideopromptschemaconfig.tsx","./src/pages/adminvideoupscale.tsx","./src/pages/adminreplication/components/jsoncollapse.tsx","./src/pages/adminreplication/components/mediapreview.tsx","./src/pages/adminreplication/components/statustag.tsx","./src/pages/adminreplication/components/videopromptschemaviewer.tsx","./src/pages/homematerials/homematerialassettable.tsx","./src/pages/homematerials/homematerialcategorypanel.tsx","./src/pages/homematerials/homematerialuploadmodal.tsx","./src/pages/homematerials/mediareferenceseditor.tsx","./src/pages/homematerials/watermarkeditor.tsx","./src/pages/homematerials/watermarklibrarymodal.tsx","./src/pages/homematerials/watermarkpreview.tsx","./src/store/index.ts","./src/types/index.ts","./src/types/xlsx-js-style.d.ts","./src/utils/clipboard.ts","./src/utils/excelexport.ts","./src/utils/formatdate.ts","./src/utils/generationtaskstatus.ts","./src/utils/resourceurl.ts","./src/utils/shotreplicatestatus.ts","./src/utils/videopromptschema.ts"],"version":"6.0.3"}
|
||||
{"root":["./src/app.tsx","./src/env.d.ts","./src/main.tsx","./src/api/client.ts","./src/api/crypto.ts","./src/api/index.ts","./src/components/preresultdisplay.tsx","./src/components/quotaadjustmodal.tsx","./src/components/generation/generationtaskresourcegrid.tsx","./src/pages/adminapikeys.tsx","./src/pages/adminapimodelpricings.tsx","./src/pages/adminapiusage.tsx","./src/pages/adminauthoriz.tsx","./src/pages/adminconsume.tsx","./src/pages/admincontactrequests.tsx","./src/pages/admincreditratios.tsx","./src/pages/admincreditrecords.tsx","./src/pages/admindashboard.tsx","./src/pages/admingenerationairecords.tsx","./src/pages/admingenerationrecords.tsx","./src/pages/adminhomematerials.tsx","./src/pages/adminhotopeningreplicationdetail.tsx","./src/pages/adminhotopeningreplications.tsx","./src/pages/adminimageengines.tsx","./src/pages/adminindustries.tsx","./src/pages/adminlayout.tsx","./src/pages/adminloginpage.tsx","./src/pages/adminmateriallist.tsx","./src/pages/adminmenuconfig.tsx","./src/pages/adminmodels.tsx","./src/pages/adminnotificationmanager.tsx","./src/pages/adminoauthlist.tsx","./src/pages/adminoauthapplist.tsx","./src/pages/adminoperationlogs.tsx","./src/pages/adminpaymentconfig.tsx","./src/pages/adminpaymentstats.tsx","./src/pages/adminplatform.tsx","./src/pages/adminpretesttemplates.tsx","./src/pages/adminprivateportraitprojects.tsx","./src/pages/adminrechargepackages.tsx","./src/pages/adminreplicationprojectdetail.tsx","./src/pages/adminsettings.tsx","./src/pages/adminshotreplications.tsx","./src/pages/adminshottasksetdetail.tsx","./src/pages/adminteams.tsx","./src/pages/adminusers.tsx","./src/pages/adminvideoengines.tsx","./src/pages/adminvideopromptschemaconfig.tsx","./src/pages/adminvideoupscale.tsx","./src/pages/adminreplication/components/jsoncollapse.tsx","./src/pages/adminreplication/components/mediapreview.tsx","./src/pages/adminreplication/components/statustag.tsx","./src/pages/adminreplication/components/videopromptschemaviewer.tsx","./src/pages/homematerials/homematerialassettable.tsx","./src/pages/homematerials/homematerialcategorypanel.tsx","./src/pages/homematerials/homematerialuploadmodal.tsx","./src/pages/homematerials/mediareferenceseditor.tsx","./src/pages/homematerials/watermarkeditor.tsx","./src/pages/homematerials/watermarklibrarymodal.tsx","./src/pages/homematerials/watermarkpreview.tsx","./src/store/index.ts","./src/types/index.ts","./src/types/xlsx-js-style.d.ts","./src/utils/clipboard.ts","./src/utils/excelexport.ts","./src/utils/formatdate.ts","./src/utils/generationtaskstatus.ts","./src/utils/resourceurl.ts","./src/utils/shotreplicatestatus.ts","./src/utils/videopromptschema.ts"],"version":"6.0.3"}
|
||||
@@ -17,6 +17,7 @@ from app.schemas.admin_api.api_key import (
|
||||
ApiKeyCreateResponse,
|
||||
ApiKeyListItem,
|
||||
ApiKeyListOut,
|
||||
ApiKeyQuotaAdjustRequest,
|
||||
ApiKeyRevealResponse,
|
||||
ApiKeyResponse,
|
||||
ApiKeyUpdateRequest,
|
||||
@@ -388,3 +389,47 @@ async def list_all_usage(
|
||||
"total": total,
|
||||
"items": items,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/{key_id}/quota-adjust", response_model=ApiKeyListItem, summary="调整 API Key 配额")
|
||||
async def quota_adjust(
|
||||
req: ApiKeyQuotaAdjustRequest,
|
||||
key_id: str = Path(..., description="API Key ID"),
|
||||
admin: User = Depends(get_admin_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> ApiKeyListItem:
|
||||
"""调整 API Key 配额(增加总额/重置已用/设置限额/修改周期)。"""
|
||||
key = await key_service.get_api_key(db, key_id)
|
||||
if not key:
|
||||
raise HTTPException(status_code=404, detail="API Key 不存在")
|
||||
|
||||
key, changes = await key_service.adjust_quota(
|
||||
db,
|
||||
key,
|
||||
action=req.action,
|
||||
quota_limit_delta=req.quota_limit_delta,
|
||||
quota_limit=req.quota_limit,
|
||||
quota_cycle=req.quota_cycle,
|
||||
)
|
||||
|
||||
# 审计日志
|
||||
try:
|
||||
from app.services.operation_log import log_operation
|
||||
await log_operation(
|
||||
db=db,
|
||||
user_id=str(admin.id),
|
||||
username=str(admin.username),
|
||||
action=f"quota_adjust:{req.action}",
|
||||
method="POST",
|
||||
path=f"/admin/api-keys/{key_id}/quota-adjust",
|
||||
detail=json.dumps(
|
||||
{**changes, "reason": req.reason},
|
||||
ensure_ascii=False,
|
||||
default=str,
|
||||
),
|
||||
)
|
||||
except Exception as log_exc:
|
||||
logger.warning("配额调整审计日志记录失败: %s", log_exc)
|
||||
|
||||
await db.commit()
|
||||
return _key_to_list_item(key)
|
||||
|
||||
@@ -1696,17 +1696,62 @@ async def update_system_config(
|
||||
return config
|
||||
|
||||
|
||||
@router.post("/system-configs/banner/reset", summary="重置活动横幅展示")
|
||||
async def reset_banner(
|
||||
admin: User = Depends(get_admin_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""递增 site_banner_version,使所有用户再次看到横幅。"""
|
||||
from app.utils.id_gen import generate_id
|
||||
result = await db.execute(select(SystemConfig).where(SystemConfig.key == "site_banner_version").limit(1))
|
||||
config = result.scalar_one_or_none()
|
||||
new_version = 1
|
||||
if config:
|
||||
try:
|
||||
new_version = int(config.value or 0) + 1
|
||||
except ValueError:
|
||||
new_version = 1
|
||||
config.value = str(new_version)
|
||||
else:
|
||||
config = SystemConfig(
|
||||
id=generate_id(),
|
||||
key="site_banner_version",
|
||||
value=str(new_version),
|
||||
description="活动横幅版本号,递增后所有用户重新看到横幅",
|
||||
)
|
||||
db.add(config)
|
||||
await db.flush()
|
||||
await log_operation(
|
||||
db,
|
||||
admin.id,
|
||||
admin.username,
|
||||
f"重置活动横幅 (版本 → {new_version})",
|
||||
"POST",
|
||||
"/admin/system-configs/banner/reset",
|
||||
detail=json.dumps({"new_version": new_version}),
|
||||
)
|
||||
await db.commit()
|
||||
await invalidate_system_config_cache(["site_banner_version"])
|
||||
return {"site_banner_version": new_version}
|
||||
|
||||
|
||||
# ── Operation Logs ──────────────────────────────────────
|
||||
|
||||
@router.get("/operation-logs")
|
||||
async def list_operation_logs(
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=500),
|
||||
action: str | None = Query(None, description="按 action 过滤(前缀匹配)"),
|
||||
admin: User = Depends(get_admin_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
query = select(OperationLog).order_by(OperationLog.created_at.desc())
|
||||
count_query = select(func.count(OperationLog.id))
|
||||
|
||||
if action:
|
||||
query = query.where(OperationLog.action.like(f"{action}%"))
|
||||
count_query = count_query.where(OperationLog.action.like(f"{action}%"))
|
||||
|
||||
total = (await db.execute(count_query)).scalar() or 0
|
||||
result = await db.execute(query.offset((page - 1) * page_size).limit(page_size))
|
||||
items = result.scalars().all()
|
||||
|
||||
@@ -342,7 +342,7 @@ async def get_site_info(db: AsyncSession = Depends(get_db)):
|
||||
"""Public endpoint returning site name, logo, agreement and copyright info."""
|
||||
result = await db.execute(
|
||||
select(SystemConfig).where(SystemConfig.key.in_([
|
||||
"site_name", "site_logo", "user_agreement_privacy_url", "site_copyright", "operation_manual", "login_bg_video", "optimize_hold_credits"
|
||||
"site_name", "site_logo", "user_agreement_privacy_url", "site_copyright", "operation_manual", "login_bg_video", "optimize_hold_credits", "site_banner", "site_banner_version"
|
||||
]))
|
||||
)
|
||||
configs = result.scalars().all()
|
||||
@@ -367,6 +367,8 @@ async def get_site_info(db: AsyncSession = Depends(get_db)):
|
||||
"operation_manual": info.get("operation_manual", ""),
|
||||
"login_bg_video": to_full_url(info.get("login_bg_video")) if info.get("login_bg_video") else "",
|
||||
"optimize_hold_credits": int(info.get("optimize_hold_credits") or 5),
|
||||
"site_banner": info.get("site_banner", ""),
|
||||
"site_banner_version": int(info.get("site_banner_version") or 0),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -133,6 +133,22 @@ class ApiKeyListItem(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class ApiKeyQuotaAdjustRequest(BaseModel):
|
||||
"""配额调整请求。支持 camelCase 和 snake_case 两种字段名。"""
|
||||
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
action: str = Field(
|
||||
...,
|
||||
pattern=r"^(adjust|reset_usage|set_limit|change_cycle)$",
|
||||
description="adjust=增加总额 | reset_usage=重置已用 | set_limit=设置限额 | change_cycle=修改周期",
|
||||
)
|
||||
quota_limit_delta: float | None = Field(None, ge=0, description="增加总额时的增量", alias="quotaLimitDelta")
|
||||
quota_limit: float | None = Field(None, description="设置新限额时的值(NULL=无限)", alias="quotaLimit")
|
||||
quota_cycle: str | None = Field(None, description="修改周期时的值", alias="quotaCycle")
|
||||
reason: str | None = Field(None, max_length=500, description="调整原因/备注")
|
||||
|
||||
|
||||
class ApiKeyListOut(BaseModel):
|
||||
"""API Key 列表响应。"""
|
||||
|
||||
|
||||
@@ -114,6 +114,50 @@ async def update_api_key(db: AsyncSession, key: ApiKey, **kwargs) -> ApiKey:
|
||||
return key
|
||||
|
||||
|
||||
async def adjust_quota(
|
||||
db: AsyncSession,
|
||||
key: ApiKey,
|
||||
action: str,
|
||||
quota_limit_delta: float | None = None,
|
||||
quota_limit: float | None = None,
|
||||
quota_cycle: str | None = None,
|
||||
) -> tuple[ApiKey, dict]:
|
||||
"""调整 API Key 配额。
|
||||
|
||||
返回 (更新后的 key, 变更详情 dict)。
|
||||
|
||||
action:
|
||||
- adjust: 增加总额,quota_limit_delta 累加到当前 quota_limit
|
||||
- reset_usage: 重置 quota_used 为 0
|
||||
- set_limit: 直接设置 quota_limit
|
||||
- change_cycle: 修改 quota_cycle
|
||||
"""
|
||||
old_limit = key.quota_limit
|
||||
old_used = key.quota_used
|
||||
old_cycle = key.quota_cycle
|
||||
|
||||
if action == "adjust":
|
||||
delta = quota_limit_delta or 0
|
||||
key.quota_limit = round((key.quota_limit or 0) + delta, 2)
|
||||
elif action == "reset_usage":
|
||||
key.quota_used = 0.0
|
||||
elif action == "set_limit":
|
||||
key.quota_limit = quota_limit # 允许设为 None(无限)
|
||||
elif action == "change_cycle":
|
||||
key.quota_cycle = quota_cycle # 允许设为 None(无限)
|
||||
else:
|
||||
raise ValueError(f"未知的调整操作: {action}")
|
||||
|
||||
await db.flush()
|
||||
|
||||
changes = {
|
||||
"old_limit": old_limit, "new_limit": key.quota_limit,
|
||||
"old_used": old_used, "new_used": key.quota_used,
|
||||
"old_cycle": old_cycle, "new_cycle": key.quota_cycle,
|
||||
}
|
||||
return key, changes
|
||||
|
||||
|
||||
async def delete_api_key(db: AsyncSession, key: ApiKey) -> None:
|
||||
"""软删除 API Key。"""
|
||||
key.deleted_at = datetime.now(timezone.utc)
|
||||
|
||||
@@ -301,7 +301,7 @@ export async function verifyCaptcha(captchaId: string, x: number): Promise<strin
|
||||
return res.token;
|
||||
}
|
||||
// ── Site Info ─────────────────────────────────────────────
|
||||
export async function getSiteInfo(): Promise<{ siteName: string; siteLogo: string; userAgreementPrivacyUrl: string; siteCopyright: string; operationManual: string; loginBgVideo: string; optimizeHoldCredits?: number }> {
|
||||
export async function getSiteInfo(): Promise<{ siteName: string; siteLogo: string; userAgreementPrivacyUrl: string; siteCopyright: string; operationManual: string; loginBgVideo: string; optimizeHoldCredits?: number; siteBanner?: string; siteBannerVersion?: number }> {
|
||||
if (USE_MOCK) return { siteName: '智创', siteLogo: '', userAgreementPrivacyUrl: '', siteCopyright: '© 2026 智创 版权所有', operationManual: '', loginBgVideo: '' };
|
||||
return api.get('/auth/site-info', false);
|
||||
}
|
||||
@@ -351,10 +351,42 @@ export async function getAdminStats(): Promise<AdminStats> {
|
||||
if (USE_MOCK) return mock.mockGetAdminStats();
|
||||
return api.get('/admin/stats');
|
||||
}
|
||||
export async function getAdminUsers(search?: string): Promise<AdminUser[]> {
|
||||
if (USE_MOCK) return mock.mockGetAdminUsers(search);
|
||||
const q = search ? `?search=${encodeURIComponent(search)}` : '';
|
||||
return api.get(`/admin/users${q}`);
|
||||
export async function getAdminUsers(page = 1, pageSize = 1000, search?: string): Promise<{ items: AdminUser[]; total: number }> {
|
||||
if (USE_MOCK) {
|
||||
const items = await mock.mockGetAdminUsers(search);
|
||||
return { items, total: items.length };
|
||||
}
|
||||
const params = new URLSearchParams();
|
||||
params.set('page', String(page));
|
||||
params.set('page_size', String(pageSize));
|
||||
if (search) params.set('search', search);
|
||||
return api.get(`/admin/users?${params.toString()}`);
|
||||
}
|
||||
|
||||
export async function getAdminNotifications(page = 1, pageSize = 20): Promise<{ items: any[]; total: number }> {
|
||||
if (USE_MOCK) {
|
||||
const items = await mock.mockGetAdminNotifications();
|
||||
return { items, total: items.length };
|
||||
}
|
||||
const params = new URLSearchParams();
|
||||
params.set('page', String(page));
|
||||
params.set('page_size', String(pageSize));
|
||||
return api.get(`/admin/notifications?${params.toString()}`);
|
||||
}
|
||||
|
||||
export async function createAdminNotification(params: { title: string; content: string; type?: string; target_user_id?: string }): Promise<void> {
|
||||
if (USE_MOCK) return;
|
||||
await api.post('/admin/notifications', params);
|
||||
}
|
||||
|
||||
export async function deleteAdminNotification(id: string): Promise<void> {
|
||||
if (USE_MOCK) return;
|
||||
await api.delete(`/admin/notifications/${id}`);
|
||||
}
|
||||
|
||||
export async function getNotificationReadUsers(notificationId: string): Promise<{ items: any[] }> {
|
||||
if (USE_MOCK) return { items: [] };
|
||||
return api.get(`/admin/notifications/${notificationId}/read-users`);
|
||||
}
|
||||
export async function adjustCredits(userId: string, amount: number, description: string): Promise<void> {
|
||||
if (USE_MOCK) return mock.mockAdjustCredits(userId, amount, description);
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { CloseOutlined, NotificationOutlined } from '@ant-design/icons';
|
||||
import { getSiteInfo } from '../../api';
|
||||
|
||||
const STORAGE_KEY = 'dismissed_activity_banner_version';
|
||||
|
||||
interface ActivityBannerProps {
|
||||
onVisibilityChange?: (visible: boolean) => void;
|
||||
}
|
||||
|
||||
const ActivityBanner: React.FC<ActivityBannerProps> = ({ onVisibilityChange }) => {
|
||||
const [bannerContent, setBannerContent] = useState('');
|
||||
const [bannerVersion, setBannerVersion] = useState(0);
|
||||
const [dismissed, setDismissed] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
getSiteInfo().then(info => {
|
||||
const content = info.siteBanner || '';
|
||||
const version = info.siteBannerVersion || 0;
|
||||
setBannerVersion(version);
|
||||
if (content) {
|
||||
// 检查用户关闭的版本号是否与当前一致
|
||||
const dismissedVersion = Number(localStorage.getItem(STORAGE_KEY) || 0);
|
||||
const shouldShow = dismissedVersion < version;
|
||||
setBannerContent(content);
|
||||
setDismissed(!shouldShow);
|
||||
onVisibilityChange?.(shouldShow);
|
||||
} else {
|
||||
setDismissed(true);
|
||||
onVisibilityChange?.(false);
|
||||
}
|
||||
}).catch(() => {
|
||||
setDismissed(true);
|
||||
onVisibilityChange?.(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleClose = () => {
|
||||
localStorage.setItem(STORAGE_KEY, String(bannerVersion));
|
||||
setDismissed(true);
|
||||
onVisibilityChange?.(false);
|
||||
};
|
||||
|
||||
if (dismissed || !bannerContent) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
background: 'linear-gradient(135deg, #f3e8ff 0%, #ede9fe 50%, #e0e7ff 100%)',
|
||||
borderBottom: '1px solid rgba(139, 92, 246, 0.15)',
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '10px 24px',
|
||||
position: 'relative',
|
||||
maxWidth: 1400,
|
||||
margin: '0 auto',
|
||||
width: '100%',
|
||||
boxSizing: 'border-box',
|
||||
}}>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 10,
|
||||
}}>
|
||||
<div style={{
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: 8,
|
||||
background: 'rgba(139, 92, 246, 0.12)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
}}>
|
||||
<NotificationOutlined style={{ color: '#7c3aed', fontSize: 14 }} />
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
color: '#5b21b6',
|
||||
fontSize: 14,
|
||||
fontWeight: 500,
|
||||
lineHeight: 1.5,
|
||||
textAlign: 'center',
|
||||
}}
|
||||
dangerouslySetInnerHTML={{ __html: bannerContent }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleClose}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: 24,
|
||||
top: '50%',
|
||||
transform: 'translateY(-50%)',
|
||||
width: 26,
|
||||
height: 26,
|
||||
borderRadius: '50%',
|
||||
background: 'rgba(139, 92, 246, 0.1)',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
transition: 'all 0.2s ease',
|
||||
outline: 'none',
|
||||
}}
|
||||
onMouseEnter={e => {
|
||||
e.currentTarget.style.background = 'rgba(139, 92, 246, 0.2)';
|
||||
}}
|
||||
onMouseLeave={e => {
|
||||
e.currentTarget.style.background = 'rgba(139, 92, 246, 0.1)';
|
||||
}}
|
||||
>
|
||||
<CloseOutlined style={{ color: '#7c3aed', fontSize: 11 }} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ActivityBanner;
|
||||
@@ -67,6 +67,7 @@ import { Outlet, useNavigate, useLocation } from 'react-router-dom';
|
||||
import { useAuthStore } from '../../store/useAuthStore';
|
||||
import { getMenuConfigs, getRechargePackages, getPaymentMethods, createRechargeOrder, getPaymentOrder, cancelPaymentOrder, getSiteInfo, getUnreadCount, createContactRequest, getUser, changePassword, changeUsername } from '../../api';
|
||||
import NotificationPopup from '../NotificationPopup';
|
||||
import ActivityBanner from './ActivityBanner';
|
||||
import './AppLayout.css';
|
||||
import bg1 from '../../assets/bg1.png';
|
||||
|
||||
@@ -437,6 +438,7 @@ const AppLayout: React.FC = () => {
|
||||
const countdownTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const currentOrderNoRef = useRef<string | null>(null);
|
||||
const [enabledMethods, setEnabledMethods] = useState<{ alipay: boolean; wechat: boolean }>({ alipay: false, wechat: false });
|
||||
const [bannerVisible, setBannerVisible] = useState(false);
|
||||
|
||||
// 资源存储容量(从 getUser().resource_capacity 获取)
|
||||
const [resourceCapacity, setResourceCapacity] = useState<{
|
||||
@@ -913,15 +915,15 @@ const AppLayout: React.FC = () => {
|
||||
return (
|
||||
<Layout style={{
|
||||
minHeight: '100vh',
|
||||
|
||||
}}>
|
||||
<ActivityBanner onVisibilityChange={(v) => setBannerVisible(v)} />
|
||||
<div className="desktop-sidebar" style={{
|
||||
width: sidebarW, position: 'fixed', left: 16, top: 16, bottom: 16, zIndex: 100,
|
||||
width: sidebarW, position: 'fixed', left: 16, top: bannerVisible ? 56 : 16, bottom: 16, zIndex: 100,
|
||||
background: 'linear-gradient(180deg, #ffffff 0%, #f8fafc 100%)',
|
||||
borderRadius: '20px',
|
||||
boxShadow: '0 4px 32px rgba(0, 0, 0, 0.06), 0 1px 8px rgba(0, 0, 0, 0.04)',
|
||||
display: 'flex', flexDirection: 'column',
|
||||
transition: 'width 0.25s ease, left 0.25s ease',
|
||||
transition: 'top 0.2s ease, width 0.25s ease, left 0.25s ease',
|
||||
overflow: 'hidden',
|
||||
border: '1px solid rgba(0, 0, 0, 0.06)',
|
||||
}}>
|
||||
@@ -1137,7 +1139,7 @@ const AppLayout: React.FC = () => {
|
||||
<div className="desktop-content" style={{
|
||||
marginLeft: sidebarW + 28,
|
||||
marginRight: 12,
|
||||
marginTop: 16,
|
||||
marginTop: 8,
|
||||
marginBottom: 16,
|
||||
flex: 1,
|
||||
minHeight: 'calc(100vh - 32px)',
|
||||
|
||||
Reference in New Issue
Block a user