1、增加消息推送的前台横幅显示

2、增加apikey的整体配额增加和修改记录显示
This commit is contained in:
2026-08-07 11:35:41 +08:00
parent f5d7cfadb4
commit 9538f6157d
16 changed files with 929 additions and 93 deletions
+33 -2
View File
@@ -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;
+113 -8
View File
@@ -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,14 +625,20 @@ 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 && (
<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>
@@ -646,8 +670,89 @@ const AdminApiKeys: React.FC = () => {
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,8 +239,13 @@ const AdminNotificationManager: React.FC = () => {
},
];
return (
<div>
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>
@@ -198,6 +275,112 @@ const AdminNotificationManager: React.FC = () => {
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>
<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 {
+7 -4
View File
@@ -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
View File
@@ -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)
+45
View File
@@ -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()
+3 -1
View File
@@ -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)
+37 -5
View File
@@ -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)',