merge main
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
# VITE_API_BASE=http://192.168.120.17:8000
|
||||
#VITE_API_BASE=https://apiforeign.minzhongzc.com
|
||||
VITE_API_BASE=https://ceshi.apiforeign.minzhongzc.com
|
||||
#VITE_API_BASE=http://localhost:8000
|
||||
VITE_USE_MOCK=false
|
||||
# Encryption disabled for dev — enable in production
|
||||
VITE_ENCRYPTION_KEY=
|
||||
Vendored
+204
-203
File diff suppressed because one or more lines are too long
Vendored
+36
-36
@@ -1,37 +1,37 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&display=swap" rel="stylesheet" />
|
||||
<title>后台管理</title>
|
||||
<script>
|
||||
(function() {
|
||||
var cached = localStorage.getItem('siteInfo');
|
||||
if (cached) {
|
||||
try {
|
||||
var info = JSON.parse(cached);
|
||||
if (info.siteName) {
|
||||
document.title = info.siteName + ' - 管理后台';
|
||||
}
|
||||
if (info.siteLogo) {
|
||||
var link = document.querySelector('link[rel="icon"]');
|
||||
if (link) {
|
||||
link.href = info.siteLogo;
|
||||
link.type = 'image/png';
|
||||
}
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<script type="module" crossorigin src="/assets/index-BZDhy9nW.js"></script>
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&display=swap" rel="stylesheet" />
|
||||
<title>后台管理</title>
|
||||
<script>
|
||||
(function() {
|
||||
var cached = localStorage.getItem('siteInfo');
|
||||
if (cached) {
|
||||
try {
|
||||
var info = JSON.parse(cached);
|
||||
if (info.siteName) {
|
||||
document.title = info.siteName + ' - 管理后台';
|
||||
}
|
||||
if (info.siteLogo) {
|
||||
var link = document.querySelector('link[rel="icon"]');
|
||||
if (link) {
|
||||
link.href = info.siteLogo;
|
||||
link.type = 'image/png';
|
||||
}
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<script type="module" crossorigin src="/assets/index-D-7FxsJd.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-D3fwIbOp.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>␍
|
||||
</body>
|
||||
</html>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -41,6 +41,10 @@ import AdminPreTestTemplates from './pages/AdminPreTestTemplates';
|
||||
import AdminOAuthList from './pages/AdminOAuthList';
|
||||
import AdminMaterialList from './pages/AdminMaterialList';
|
||||
import AdminPrivatePortraitProjects from './pages/AdminPrivatePortraitProjects';
|
||||
import AdminApiKeys from './pages/AdminApiKeys';
|
||||
import AdminApiModelPricings from './pages/AdminApiModelPricings';
|
||||
import AdminApiUsage from './pages/AdminApiUsage';
|
||||
import AdminInvoices from './pages/AdminInvoices';
|
||||
|
||||
import { useAdminStore } from './store';
|
||||
|
||||
@@ -104,6 +108,10 @@ const App = () => {
|
||||
<Route path="settings" element={<AdminSettings />} />
|
||||
<Route path="video-prompt-schema-config" element={<AdminVideoPromptSchemaConfig />} />
|
||||
<Route path="video-upscale" element={<AdminVideoUpscale />} />
|
||||
<Route path="api-keys" element={<AdminApiKeys />} />
|
||||
<Route path="api-model-pricings" element={<AdminApiModelPricings />} />
|
||||
<Route path="api-usage" element={<AdminApiUsage />} />
|
||||
<Route path="invoices" element={<AdminInvoices />} />
|
||||
<Route path="notifications" element={<AdminNotificationManager />} />
|
||||
<Route path="oauthapp-list" element={<AdminOauthAppList />} />
|
||||
<Route path="operation-logs" element={<AdminOperationLogs />} />
|
||||
|
||||
@@ -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<{ siteBannerVersion: number }> {
|
||||
return api.post('/admin/system-configs/banner/reset');
|
||||
}
|
||||
|
||||
export async function getGlobalResourceCapacity(): Promise<ResourceCapacityConfigOut> {
|
||||
return api.get('/admin/resource-capacity/global');
|
||||
}
|
||||
@@ -390,6 +394,131 @@ export async function deleteCreditRatio(id: string): Promise<void> {
|
||||
await api.delete(`/admin/credit-ratios/${id}`);
|
||||
}
|
||||
|
||||
// === API 模型价格管理 ===
|
||||
|
||||
export async function getApiModelPricings(): Promise<any[]> {
|
||||
return api.get('/admin/api-model-pricings');
|
||||
}
|
||||
|
||||
export async function saveApiModelPricing(pricing: any): Promise<any> {
|
||||
if (pricing.id) return api.put(`/admin/api-model-pricings/${pricing.id}`, pricing);
|
||||
return api.post('/admin/api-model-pricings', pricing);
|
||||
}
|
||||
|
||||
export async function deleteApiModelPricing(id: string): Promise<void> {
|
||||
await api.delete(`/admin/api-model-pricings/${id}`);
|
||||
}
|
||||
|
||||
// === API Key 管理 ===
|
||||
|
||||
export async function getApiKeys(params?: {
|
||||
skip?: number;
|
||||
limit?: number;
|
||||
companyName?: string;
|
||||
isActive?: boolean;
|
||||
}): Promise<any> {
|
||||
const query = new URLSearchParams();
|
||||
if (params?.skip !== undefined) query.set('skip', String(params.skip));
|
||||
if (params?.limit !== undefined) query.set('limit', String(params.limit));
|
||||
if (params?.companyName) query.set('company_name', params.companyName);
|
||||
if (params?.isActive !== undefined) query.set('is_active', String(params.isActive));
|
||||
const qs = query.toString();
|
||||
return api.get(`/admin/api-keys${qs ? '?' + qs : ''}`);
|
||||
}
|
||||
|
||||
export async function getApiKeyDetail(id: string): Promise<any> {
|
||||
return api.get(`/admin/api-keys/${id}`);
|
||||
}
|
||||
|
||||
export async function createApiKey(data: any): Promise<any> {
|
||||
return api.post('/admin/api-keys', data);
|
||||
}
|
||||
|
||||
export async function updateApiKey(id: string, data: any): Promise<any> {
|
||||
return api.put(`/admin/api-keys/${id}`, data);
|
||||
}
|
||||
|
||||
export async function deleteApiKey(id: string): Promise<void> {
|
||||
await api.delete(`/admin/api-keys/${id}`);
|
||||
}
|
||||
|
||||
export async function getApiKeyUsage(id: string, days?: number, page?: number, pageSize?: number): Promise<any> {
|
||||
const params = new URLSearchParams();
|
||||
if (days) params.set('days', String(days));
|
||||
if (page) params.set('page', String(page));
|
||||
if (pageSize) params.set('page_size', String(pageSize));
|
||||
const qs = params.toString() ? `?${params.toString()}` : '';
|
||||
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`);
|
||||
}
|
||||
|
||||
export async function saveApiKeyUpscaleConfig(id: string, data: any): Promise<any> {
|
||||
return api.put(`/admin/api-keys/${id}/upscale`, data);
|
||||
}
|
||||
|
||||
// === V3 虚拟素材库配额 ===
|
||||
export async function getApiKeyVpV3Quota(id: string): Promise<any> {
|
||||
return api.get(`/admin/api-keys/${id}/vp-v3-quota`);
|
||||
}
|
||||
|
||||
export async function saveApiKeyVpV3Quota(id: string, data: { projectLimit: number; assetLimit: number; storageMbLimit: number; remark?: string | null }): Promise<any> {
|
||||
return api.post(`/admin/api-keys/${id}/vp-v3-quota`, {
|
||||
project_limit: data.projectLimit,
|
||||
asset_limit: data.assetLimit,
|
||||
storage_mb_limit: data.storageMbLimit,
|
||||
remark: data.remark,
|
||||
});
|
||||
}
|
||||
|
||||
export async function revealApiKey(id: string): Promise<any> {
|
||||
return api.get(`/admin/api-keys/${id}/reveal`);
|
||||
}
|
||||
|
||||
// === 整体消耗列表 ===
|
||||
|
||||
export async function getApiUsageAll(params?: {
|
||||
skip?: number;
|
||||
limit?: number;
|
||||
apiKeyId?: string;
|
||||
genType?: string;
|
||||
status?: string;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
}): Promise<any> {
|
||||
const query = new URLSearchParams();
|
||||
if (params?.skip !== undefined) query.set('skip', String(params.skip));
|
||||
if (params?.limit !== undefined) query.set('limit', String(params.limit));
|
||||
if (params?.apiKeyId) query.set('api_key_id', params.apiKeyId);
|
||||
if (params?.genType) query.set('gen_type', params.genType);
|
||||
if (params?.status) query.set('status', params.status);
|
||||
if (params?.startDate) query.set('start_date', params.startDate);
|
||||
if (params?.endDate) query.set('end_date', params.endDate);
|
||||
const qs = query.toString();
|
||||
return api.get(`/admin/api-keys/usage/all${qs ? '?' + qs : ''}`);
|
||||
}
|
||||
|
||||
export async function getPaymentConfigs(): Promise<any[]> {
|
||||
return api.get('/admin/payment-configs');
|
||||
}
|
||||
@@ -439,6 +568,40 @@ export async function refundPaymentOrder(orderNo: string): Promise<void> {
|
||||
await api.post(`/admin/payment-orders/${orderNo}/refund`);
|
||||
}
|
||||
|
||||
// ── Invoice Management ───────────────────────────────────
|
||||
|
||||
export async function getAdminInvoices(params?: {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
status?: string;
|
||||
phone?: string;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
}): Promise<{ items: any[]; total: number }> {
|
||||
const qs = new URLSearchParams();
|
||||
if (params?.page) qs.set('page', String(params.page));
|
||||
if (params?.pageSize) qs.set('page_size', String(params.pageSize));
|
||||
if (params?.status) qs.set('status', params.status);
|
||||
if (params?.phone) qs.set('phone', params.phone);
|
||||
if (params?.startDate) qs.set('start_date', params.startDate);
|
||||
if (params?.endDate) qs.set('end_date', params.endDate);
|
||||
return api.get(`/admin/invoices?${qs.toString()}`);
|
||||
}
|
||||
|
||||
export async function getAdminInvoiceDetail(id: string): Promise<any> {
|
||||
return api.get(`/admin/invoices/${id}`);
|
||||
}
|
||||
|
||||
export async function updateInvoiceStatus(id: string, data: {
|
||||
status: 'success' | 'failed';
|
||||
failureReason?: string;
|
||||
}): Promise<void> {
|
||||
await api.put(`/admin/invoices/${id}/status`, {
|
||||
status: data.status,
|
||||
failure_reason: data.failureReason,
|
||||
});
|
||||
}
|
||||
|
||||
export async function getAdminNotifications(page = 1, pageSize = 20): Promise<{ total: number; items: any[] }> {
|
||||
const params = new URLSearchParams();
|
||||
params.set('page', String(page));
|
||||
@@ -516,8 +679,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}`);
|
||||
}
|
||||
|
||||
@@ -623,6 +794,8 @@ export async function getAdminGenerationRecords(params?: {
|
||||
status?: string;
|
||||
engineId?: string;
|
||||
includeMediaReferences?: boolean;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}): Promise<{ total: number; items: any[] }> {
|
||||
@@ -633,6 +806,8 @@ export async function getAdminGenerationRecords(params?: {
|
||||
if (params?.includeMediaReferences !== undefined) {
|
||||
q.set('include_media_references', String(params.includeMediaReferences));
|
||||
}
|
||||
if (params?.startDate) q.set('start_date', params.startDate);
|
||||
if (params?.endDate) q.set('end_date', params.endDate);
|
||||
if (params?.page) q.set('page', String(params.page));
|
||||
if (params?.pageSize) q.set('page_size', String(params.pageSize));
|
||||
const qs = q.toString();
|
||||
|
||||
@@ -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;
|
||||
@@ -0,0 +1,760 @@
|
||||
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, Tabs, Tag, Typography,
|
||||
} from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import {
|
||||
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, getOperationLogs,
|
||||
} from '../api';
|
||||
import type { GenerationAiEngineOption } from '../types';
|
||||
|
||||
interface EngineOption {
|
||||
id: string;
|
||||
name: string;
|
||||
modelName: string;
|
||||
genType: 'video' | 'image';
|
||||
}
|
||||
|
||||
interface ApiKey {
|
||||
id: string;
|
||||
companyName: string;
|
||||
apiKeyPrefix: string;
|
||||
description: string | null;
|
||||
callableModels?: Array<{ engineId: string; engineType: string; modelName: string }>;
|
||||
quotaLimit: number | null;
|
||||
quotaCycle: string | null;
|
||||
quotaUsed: number;
|
||||
validFrom: string | null;
|
||||
validUntil: string | null;
|
||||
maxConcurrentVideoTasks: number | null;
|
||||
isActive: boolean;
|
||||
lastUsedAt: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface UpscaleRule {
|
||||
targetResolution: string;
|
||||
providerGenerationResolution: string;
|
||||
processorKey: string;
|
||||
}
|
||||
|
||||
const AdminApiKeys: React.FC = () => {
|
||||
const [keys, setKeys] = useState<ApiKey[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
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[]>([]);
|
||||
const [upscaleEnabled, setUpscaleEnabled] = useState(false);
|
||||
const [deleteSource, setDeleteSource] = useState(false);
|
||||
|
||||
// V3 虚拟素材库配额(编辑时加载)
|
||||
const [vpV3Quota, setVpV3Quota] = useState<{
|
||||
projectLimit: number; assetLimit: number; storageMbLimit: number;
|
||||
projectUsed: number; assetUsed: number; storageMbUsed: number;
|
||||
enabled: boolean; remark?: string | null;
|
||||
}>({
|
||||
projectLimit: 0, assetLimit: 0, storageMbLimit: 0,
|
||||
projectUsed: 0, assetUsed: 0, storageMbUsed: 0,
|
||||
enabled: false, remark: null,
|
||||
});
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [keysData, enginesData] = await Promise.all([
|
||||
getApiKeys({ limit: 100 }),
|
||||
getGenerationAiEngines(),
|
||||
]);
|
||||
setKeys(keysData?.items || keysData || []);
|
||||
setTotal(keysData?.total || (keysData?.length || 0));
|
||||
const allEngines: EngineOption[] = [
|
||||
...(enginesData?.engine?.image || []).map((e: any) => ({
|
||||
id: e.id,
|
||||
name: e.name || e.modelName,
|
||||
modelName: e.modelName,
|
||||
genType: 'image' as const,
|
||||
})),
|
||||
...(enginesData?.engine?.video || []).map((e: any) => ({
|
||||
id: e.id,
|
||||
name: e.name || e.modelName,
|
||||
modelName: e.modelName,
|
||||
genType: 'video' as const,
|
||||
})),
|
||||
];
|
||||
setEngines(allEngines);
|
||||
} catch {
|
||||
message.error('加载失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
const openEdit = async (key: ApiKey | null = null) => {
|
||||
if (key) {
|
||||
// 将 callableModels 转换为引擎 ID 数组用于 Select
|
||||
const selectedEngineIds = (key.callableModels || []).map((m: any) => m.engineId || m.engine_id);
|
||||
form.setFieldsValue({
|
||||
companyName: key.companyName || '',
|
||||
description: key.description || '',
|
||||
quotaLimit: key.quotaLimit || null,
|
||||
quotaCycle: key.quotaCycle || 'monthly',
|
||||
validUntil: key.validUntil ? dayjs(key.validUntil) : null,
|
||||
maxConcurrentVideoTasks: key.maxConcurrentVideoTasks || null,
|
||||
engineIds: selectedEngineIds,
|
||||
});
|
||||
// 并行加载:超分配置 + 虚拟素材库配额
|
||||
await Promise.all([
|
||||
loadUpscaleConfig(key.id),
|
||||
(async () => {
|
||||
try {
|
||||
const quota = await getApiKeyVpV3Quota(key.id);
|
||||
setVpV3Quota({
|
||||
projectLimit: quota?.projectLimit ?? quota?.project_limit ?? 0,
|
||||
assetLimit: quota?.assetLimit ?? quota?.asset_limit ?? 0,
|
||||
storageMbLimit: quota?.storageMbLimit ?? quota?.storage_mb_limit ?? 0,
|
||||
projectUsed: quota?.projectUsed ?? quota?.project_used ?? 0,
|
||||
assetUsed: quota?.assetUsed ?? quota?.asset_used ?? 0,
|
||||
storageMbUsed: quota?.storageMbUsed ?? quota?.storage_mb_used ?? 0,
|
||||
enabled: !!quota?.enabled,
|
||||
remark: quota?.remark ?? null,
|
||||
});
|
||||
} catch {
|
||||
setVpV3Quota({
|
||||
projectLimit: 0, assetLimit: 0, storageMbLimit: 0,
|
||||
projectUsed: 0, assetUsed: 0, storageMbUsed: 0,
|
||||
enabled: false, remark: null,
|
||||
});
|
||||
}
|
||||
})(),
|
||||
]);
|
||||
} else {
|
||||
form.resetFields();
|
||||
form.setFieldsValue({ quotaCycle: 'monthly', quotaLimit: 100, engineIds: [] });
|
||||
setUpscaleEnabled(false);
|
||||
setDeleteSource(false);
|
||||
setUpscaleRules([]);
|
||||
setVpV3Quota({
|
||||
projectLimit: 0, assetLimit: 0, storageMbLimit: 0,
|
||||
projectUsed: 0, assetUsed: 0, storageMbUsed: 0,
|
||||
enabled: false, remark: null,
|
||||
});
|
||||
}
|
||||
setModal({ open: true, key });
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
// 将选中的引擎 ID 转换为 callableModels 格式
|
||||
const callableModels = (values.engineIds || []).map((id: string) => {
|
||||
const engine = engines.find(e => e.id === id);
|
||||
return {
|
||||
engineId: id,
|
||||
engineType: engine?.genType || 'video',
|
||||
modelName: engine?.modelName || '',
|
||||
};
|
||||
});
|
||||
const payload = {
|
||||
companyName: values.companyName,
|
||||
description: values.description || null,
|
||||
quotaLimit: values.quotaLimit || null,
|
||||
quotaCycle: values.quotaCycle || null,
|
||||
validUntil: values.validUntil ? (values.validUntil.toISOString ? values.validUntil.toISOString() : values.validUntil) : null,
|
||||
maxConcurrentVideoTasks: values.maxConcurrentVideoTasks || null,
|
||||
callableModels,
|
||||
};
|
||||
console.log('API Key payload:', JSON.stringify(payload, null, 2));
|
||||
if (modal.key?.id) {
|
||||
await updateApiKey(modal.key.id, payload);
|
||||
} else {
|
||||
const result = await createApiKey(payload);
|
||||
if (result?.apiKey) {
|
||||
Modal.success({
|
||||
title: 'API Key 创建成功',
|
||||
content: (
|
||||
<div>
|
||||
<p>请妥善保存以下 API Key,此信息仅显示一次:</p>
|
||||
<Typography.Paragraph copyable style={{ background: '#f5f5f5', padding: 12, borderRadius: 8, fontFamily: 'monospace' }}>
|
||||
{result.apiKey}
|
||||
</Typography.Paragraph>
|
||||
</div>
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
// 保存超分配置
|
||||
if (modal.key?.id) {
|
||||
await saveUpscaleConfig(modal.key.id);
|
||||
// 保存 V3 虚拟素材库配额(编辑模式才需要,因为新建时还没有 id)
|
||||
try {
|
||||
await saveApiKeyVpV3Quota(modal.key.id, {
|
||||
projectLimit: vpV3Quota.projectLimit || 0,
|
||||
assetLimit: vpV3Quota.assetLimit || 0,
|
||||
storageMbLimit: vpV3Quota.storageMbLimit || 0,
|
||||
remark: vpV3Quota.remark ?? null,
|
||||
});
|
||||
} catch (qErr: any) {
|
||||
message.warning(qErr?.response?.data?.detail || '虚拟素材库配额保存失败');
|
||||
}
|
||||
}
|
||||
|
||||
message.success('保存成功');
|
||||
setModal({ open: false, key: null });
|
||||
form.resetFields();
|
||||
load();
|
||||
} catch (e: any) {
|
||||
if (e?.errorFields) return;
|
||||
message.error('保存失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
try {
|
||||
await deleteApiKey(id);
|
||||
message.success('已删除');
|
||||
load();
|
||||
} catch {
|
||||
message.error('删除失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopyKey = async (key: ApiKey) => {
|
||||
try {
|
||||
const result = await revealApiKey(key.id);
|
||||
const plainKey: string | undefined = result?.apiKey || result?.data?.apiKey;
|
||||
if (!plainKey) {
|
||||
message.error('获取 API Key 失败');
|
||||
return;
|
||||
}
|
||||
// 优先用 Clipboard API,不支持时回退到 execCommand
|
||||
if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') {
|
||||
try {
|
||||
await navigator.clipboard.writeText(plainKey);
|
||||
} catch {
|
||||
fallbackCopy(plainKey);
|
||||
}
|
||||
} else {
|
||||
fallbackCopy(plainKey);
|
||||
}
|
||||
message.success('API Key 已复制到剪贴板');
|
||||
} catch (e: any) {
|
||||
const msg = e?.response?.data?.detail || '复制失败';
|
||||
message.error(msg);
|
||||
}
|
||||
};
|
||||
|
||||
const fallbackCopy = (text: string) => {
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.value = text;
|
||||
textarea.style.position = 'fixed';
|
||||
textarea.style.opacity = '0';
|
||||
document.body.appendChild(textarea);
|
||||
textarea.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(textarea);
|
||||
};
|
||||
|
||||
const loadUsageDetail = async (keyId: string, page = 1, pageSize = 20) => {
|
||||
try {
|
||||
const usage = await getApiKeyUsage(keyId, 30, page, pageSize);
|
||||
setUsageModal(prev => ({ ...prev, usage }));
|
||||
} catch {
|
||||
message.error('加载使用统计失败');
|
||||
}
|
||||
};
|
||||
|
||||
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('加载使用统计失败');
|
||||
}
|
||||
};
|
||||
|
||||
// ── 超分配置处理 ──
|
||||
const handleAddUpscaleRule = () => {
|
||||
setUpscaleRules([...upscaleRules, { targetResolution: '1080p', providerGenerationResolution: '720p', processorKey: 'volc_large_model_v1' }]);
|
||||
};
|
||||
|
||||
const handleRemoveUpscaleRule = (idx: number) => {
|
||||
setUpscaleRules(upscaleRules.filter((_, i) => i !== idx));
|
||||
};
|
||||
|
||||
const handleUpscaleRuleChange = (idx: number, field: keyof UpscaleRule, value: string) => {
|
||||
const newRules = [...upscaleRules];
|
||||
newRules[idx] = { ...newRules[idx], [field]: value };
|
||||
setUpscaleRules(newRules);
|
||||
};
|
||||
|
||||
const loadUpscaleConfig = async (keyId: string) => {
|
||||
try {
|
||||
const config = await getApiKeyUpscaleConfig(keyId);
|
||||
setUpscaleEnabled(config?.data?.enabled || false);
|
||||
setDeleteSource(config?.data?.deleteSourceAfterSuccess || false);
|
||||
setUpscaleRules(config?.data?.rules || []);
|
||||
} catch {
|
||||
setUpscaleEnabled(false);
|
||||
setDeleteSource(false);
|
||||
setUpscaleRules([]);
|
||||
}
|
||||
};
|
||||
|
||||
const saveUpscaleConfig = async (keyId: string) => {
|
||||
try {
|
||||
await saveApiKeyUpscaleConfig(keyId, {
|
||||
data: {
|
||||
enabled: upscaleEnabled,
|
||||
deleteSourceAfterSuccess: deleteSource,
|
||||
rules: upscaleRules,
|
||||
},
|
||||
});
|
||||
message.success('超分配置已保存');
|
||||
} catch {
|
||||
message.error('保存超分配置失败');
|
||||
}
|
||||
};
|
||||
|
||||
const cycleLabel = (cycle: string | null) => {
|
||||
const map: Record<string, string> = { daily: '每日', monthly: '每月', one_time: '一次性' };
|
||||
return cycle ? map[cycle] || cycle : '无限';
|
||||
};
|
||||
|
||||
const columns: ColumnsType<ApiKey> = [
|
||||
{ title: '公司', dataIndex: 'companyName', width: 120, ellipsis: true },
|
||||
{
|
||||
title: 'api-key',
|
||||
dataIndex: 'apiKeyPrefix',
|
||||
width: 180,
|
||||
render: (v: string, r: ApiKey) => (
|
||||
<Space size={4}>
|
||||
<code style={{ background: '#f5f5f5', padding: '2px 6px', borderRadius: 4 }}>{v}****</code>
|
||||
<Button type="link" size="small" icon={<CopyOutlined />} onClick={() => handleCopyKey(r)}></Button>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '配额(元)',
|
||||
dataIndex: 'quotaLimit',
|
||||
width: 130,
|
||||
render: (_v: number, r: ApiKey) => {
|
||||
if (!r.quotaLimit) return <Tag>无限</Tag>;
|
||||
const used = r.quotaUsed || 0;
|
||||
const limit = r.quotaLimit || 1;
|
||||
const pct = Math.min(100, Math.round((used / limit) * 100));
|
||||
return (
|
||||
<div style={{ width: 110 }}>
|
||||
<Progress percent={pct} size="small" format={() => `${used.toFixed(1)}/${limit}`} />
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '周期',
|
||||
dataIndex: 'quotaCycle',
|
||||
width: 70,
|
||||
render: (v: string | null) => <Tag>{cycleLabel(v)}</Tag>,
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'isActive',
|
||||
width: 70,
|
||||
render: (v: boolean) => <Tag color={v ? 'green' : 'default'}>{v ? '启用' : '停用'}</Tag>,
|
||||
},
|
||||
{
|
||||
title: '有效期',
|
||||
dataIndex: 'validUntil',
|
||||
width: 100,
|
||||
render: (v: string | null) => v ? new Date(v).toLocaleDateString() : '永久',
|
||||
},
|
||||
{
|
||||
title: '最后使用',
|
||||
dataIndex: 'lastUsedAt',
|
||||
width: 150,
|
||||
render: (v: string | null) => v ? new Date(v).toLocaleString() : '-',
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
fixed: 'right',
|
||||
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)}>
|
||||
<Button type="link" size="small" danger icon={<DeleteOutlined />}>删除</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Space direction="vertical" size="large" style={{ width: '100%' }}>
|
||||
<Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Space>
|
||||
<div style={{ width: 36, height: 36, borderRadius: 8, background: 'linear-gradient(135deg, #6366f1, #8b5cf6)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<ApiOutlined style={{ color: '#fff', fontSize: 18 }} />
|
||||
</div>
|
||||
<Typography.Text strong style={{ fontSize: 16 }}>API Key 管理</Typography.Text>
|
||||
<Tag color="purple">{total} 个</Tag>
|
||||
</Space>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => openEdit()}>创建 Key</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card variant="outlined" style={{ borderRadius: 12 }}>
|
||||
<Table columns={columns} dataSource={keys} rowKey="id" loading={loading} pagination={false} scroll={{ x: 1100 }} />
|
||||
</Card>
|
||||
|
||||
{/* 创建/编辑弹窗 */}
|
||||
<Modal
|
||||
title={modal.key ? '编辑 API Key' : '创建 API Key'}
|
||||
open={modal.open}
|
||||
onOk={handleSave}
|
||||
onCancel={() => { setModal({ open: false, key: null }); form.resetFields(); }}
|
||||
okText="保存" cancelText="取消" width={760}
|
||||
>
|
||||
<Form form={form} layout="vertical" style={{ marginTop: 16 }}>
|
||||
<Form.Item name="companyName" label="公司名称" rules={[{ required: true, message: '请输入公司名称' }]}>
|
||||
<Input placeholder="公司名称" />
|
||||
</Form.Item>
|
||||
<Form.Item name="description" label="备注">
|
||||
<Input.TextArea placeholder="备注信息" rows={2} />
|
||||
</Form.Item>
|
||||
<div style={{ display: 'flex', gap: 16 }}>
|
||||
<Form.Item name="quotaLimit" label="配额总额(元)" style={{ flex: 1 }}>
|
||||
<InputNumber min={0} step={10} style={{ width: '100%' }} placeholder="留空=无限" />
|
||||
</Form.Item>
|
||||
<Form.Item name="quotaCycle" label="配额周期" style={{ flex: 1 }}>
|
||||
<Select>
|
||||
<Select.Option value="daily">每日</Select.Option>
|
||||
<Select.Option value="monthly">每月</Select.Option>
|
||||
<Select.Option value="one_time">一次性</Select.Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 16 }}>
|
||||
<Form.Item name="validUntil" label="有效期至" style={{ flex: 1 }}>
|
||||
<DatePicker style={{ width: '100%' }} placeholder="留空=永久" />
|
||||
</Form.Item>
|
||||
<Form.Item name="maxConcurrentVideoTasks" label="最大并发视频任务" style={{ flex: 1 }}>
|
||||
<InputNumber min={1} style={{ width: '100%' }} placeholder="留空=无限" />
|
||||
</Form.Item>
|
||||
</div>
|
||||
<Form.Item name="engineIds" label="可调用模型">
|
||||
<Select
|
||||
mode="multiple"
|
||||
placeholder="选择该 Key 可调用的模型(留空=允许所有已定价模型)"
|
||||
allowClear
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
style={{ width: '100%' }}
|
||||
options={(engines || []).map(e => ({
|
||||
label: `[${e.genType === 'video' ? '视频' : '图片'}] ${e.name || e.modelName || e.id}`,
|
||||
value: e.id,
|
||||
}))}
|
||||
notFoundContent={engines.length === 0 ? '暂无可用引擎' : null}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
{/* 超分配置(仅编辑模式显示,新建时没ID) */}
|
||||
{modal.key?.id && (
|
||||
<>
|
||||
<Divider />
|
||||
<Typography.Text strong style={{ fontSize: 14 }}>🎬 超分配置</Typography.Text>
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12 }}>
|
||||
<Typography.Text>启用超分:</Typography.Text>
|
||||
<Switch checked={upscaleEnabled} onChange={setUpscaleEnabled} checkedChildren="启用" unCheckedChildren="关闭" />
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12 }}>
|
||||
<Typography.Text>成功后删除源文件:</Typography.Text>
|
||||
<Switch checked={deleteSource} onChange={setDeleteSource} checkedChildren="是" unCheckedChildren="否" />
|
||||
</div>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>超分规则:</Typography.Text>
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{(upscaleRules || []).map((rule, idx) => (
|
||||
<Card key={idx} size="small" style={{ background: '#f8f9fc' }}>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
<Select
|
||||
value={rule.targetResolution}
|
||||
onChange={v => handleUpscaleRuleChange(idx, 'targetResolution', v)}
|
||||
style={{ width: 100 }}
|
||||
options={['480p', '720p', '1080p', '2K', '4K'].map(r => ({ label: r, value: r }))}
|
||||
/>
|
||||
<span>→</span>
|
||||
<Select
|
||||
value={rule.providerGenerationResolution}
|
||||
onChange={v => handleUpscaleRuleChange(idx, 'providerGenerationResolution', v)}
|
||||
style={{ width: 100 }}
|
||||
options={['480p', '720p', '1080p'].map(r => ({ label: r, value: r }))}
|
||||
/>
|
||||
<Select
|
||||
value={rule.processorKey}
|
||||
onChange={v => handleUpscaleRuleChange(idx, 'processorKey', v)}
|
||||
style={{ width: 140 }}
|
||||
options={[
|
||||
{ label: '本地FFmpeg', value: 'local_ffmpeg_crop_v1' },
|
||||
{ label: '火山标准版', value: 'volc_standard_v1' },
|
||||
{ label: '火山专业版', value: 'volc_professional_v1' },
|
||||
{ label: '火山大模型', value: 'volc_large_model_v1' },
|
||||
]}
|
||||
/>
|
||||
<Popconfirm title="确定删除此规则?" onConfirm={() => handleRemoveUpscaleRule(idx)}>
|
||||
<Button type="link" danger size="small" icon={<DeleteOutlined />} />
|
||||
</Popconfirm>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
<Button type="dashed" size="small" icon={<PlusOutlined />} onClick={handleAddUpscaleRule}>
|
||||
添加规则
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 虚拟素材库配额(仅编辑模式显示,新建时没ID) */}
|
||||
{modal.key?.id && (
|
||||
<>
|
||||
<Divider />
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12 }}>
|
||||
<Typography.Text strong style={{ fontSize: 14 }}>🧩 V3 虚拟素材库配额</Typography.Text>
|
||||
<Tag color={vpV3Quota.enabled ? 'green' : 'default'}>
|
||||
{vpV3Quota.enabled ? '已启用' : '未启用(全0=不可用)'}
|
||||
</Tag>
|
||||
</div>
|
||||
<div style={{ padding: '12px 16px', backgroundColor: '#f6ffed', borderRadius: 8, border: '1px solid #b7eb8f' }}>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12, display: 'block', marginBottom: 12 }}>
|
||||
默认 0 = 该 API Key 不可使用虚拟素材库功能。项目数或素材数任一上限 > 0 即启用(存储空间不再设置上限)。
|
||||
</Typography.Text>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16 }}>
|
||||
<div>
|
||||
<div style={{ marginBottom: 4, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Typography.Text strong>项目数上限</Typography.Text>
|
||||
<Tag color="blue">已使用 {vpV3Quota.projectUsed || 0} / {vpV3Quota.projectLimit || 0}</Tag>
|
||||
</div>
|
||||
<InputNumber
|
||||
min={0}
|
||||
max={10000}
|
||||
style={{ width: '100%' }}
|
||||
value={vpV3Quota.projectLimit}
|
||||
onChange={(v) => setVpV3Quota(q => ({ ...q, projectLimit: Number(v) || 0 }))}
|
||||
addonBefore="上限" addonAfter="个"
|
||||
/>
|
||||
<Progress
|
||||
percent={vpV3Quota.projectLimit > 0 ? Math.min(100, Math.round((vpV3Quota.projectUsed || 0) * 100 / (vpV3Quota.projectLimit || 1))) : 0}
|
||||
size="small"
|
||||
style={{ marginTop: 6 }}
|
||||
strokeColor={vpV3Quota.projectLimit > 0 && (vpV3Quota.projectUsed || 0) >= vpV3Quota.projectLimit ? '#ff4d4f' : '#1677ff'}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ marginBottom: 4, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Typography.Text strong>素材数上限</Typography.Text>
|
||||
<Tag color="blue">已使用 {vpV3Quota.assetUsed || 0} / {vpV3Quota.assetLimit || 0}</Tag>
|
||||
</div>
|
||||
<InputNumber
|
||||
min={0}
|
||||
max={1000000}
|
||||
style={{ width: '100%' }}
|
||||
value={vpV3Quota.assetLimit}
|
||||
onChange={(v) => setVpV3Quota(q => ({ ...q, assetLimit: Number(v) || 0 }))}
|
||||
addonBefore="上限" addonAfter="张"
|
||||
/>
|
||||
<Progress
|
||||
percent={vpV3Quota.assetLimit > 0 ? Math.min(100, Math.round((vpV3Quota.assetUsed || 0) * 100 / (vpV3Quota.assetLimit || 1))) : 0}
|
||||
size="small"
|
||||
style={{ marginTop: 6 }}
|
||||
strokeColor={vpV3Quota.assetLimit > 0 && (vpV3Quota.assetUsed || 0) >= vpV3Quota.assetLimit ? '#ff4d4f' : '#1677ff'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{vpV3Quota.storageMbUsed > 0 && (
|
||||
<div style={{ marginTop: 12, padding: '8px 12px', background: '#f0f5ff', borderRadius: 6, fontSize: 12, color: '#475569' }}>
|
||||
已使用存储空间:<strong style={{ color: '#1e40af' }}>{Number(vpV3Quota.storageMbUsed || 0).toFixed(2)} MB</strong>(无上限限制,仅供参考)
|
||||
</div>
|
||||
)}
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>备注(仅后台可见):</Typography.Text>
|
||||
<Input.TextArea
|
||||
rows={2}
|
||||
maxLength={500}
|
||||
placeholder="可选:配额配置说明"
|
||||
value={vpV3Quota.remark ?? ''}
|
||||
onChange={(e) => setVpV3Quota(q => ({ ...q, remark: e.target.value || null }))}
|
||||
style={{ marginTop: 4 }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
{/* 使用统计 + 配额变更弹窗 */}
|
||||
<Modal
|
||||
title={`API Key 详情 - ${usageModal.key?.companyName || ''}`}
|
||||
open={usageModal.open}
|
||||
onCancel={() => setUsageModal({ open: false, key: null, usage: null })}
|
||||
footer={null} width={720}
|
||||
>
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminApiKeys;
|
||||
@@ -0,0 +1,297 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
Button, Card, Form, InputNumber, message, Modal, Popconfirm, Select, Space, Table, Tag, Typography,
|
||||
} from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import {
|
||||
PlusOutlined, EditOutlined, DeleteOutlined, DollarOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import {
|
||||
getApiModelPricings, saveApiModelPricing, deleteApiModelPricing, getGenerationAiEngines,
|
||||
} from '../api';
|
||||
import type { GenerationAiEngineOption } from '../types';
|
||||
|
||||
type PricingGenType = 'image' | 'video';
|
||||
|
||||
interface ApiModelPricing {
|
||||
id: string;
|
||||
modelConfigId: string;
|
||||
genType: PricingGenType | string;
|
||||
resolution: string;
|
||||
priceRatio: number;
|
||||
basePrice: number;
|
||||
perSecondPrice: number;
|
||||
inputVideoRatio: number;
|
||||
inputVideoBasePrice: number;
|
||||
inputVideoPerSecondPrice: number;
|
||||
inputImageRatio: number;
|
||||
inputImageBasePrice: number;
|
||||
inputImagePerImagePrice: number;
|
||||
}
|
||||
|
||||
const DEFAULT_IMAGE_SIZES = ['2K', '4K'];
|
||||
const DEFAULT_VIDEO_RESOLUTIONS = ['480p', '720p', '1080p'];
|
||||
|
||||
const AdminApiModelPricings: React.FC = () => {
|
||||
const [pricings, setPricings] = useState<ApiModelPricing[]>([]);
|
||||
const [engines, setEngines] = useState<GenerationAiEngineOption[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [modal, setModal] = useState<{ open: boolean; pricing: ApiModelPricing | null }>({ open: false, pricing: null });
|
||||
const [form] = Form.useForm();
|
||||
const genType = Form.useWatch('genType', form) || 'video';
|
||||
const selectedEngineId = Form.useWatch('modelConfigId', form);
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [pricingData, enginesData] = await Promise.all([
|
||||
getApiModelPricings(),
|
||||
getGenerationAiEngines(),
|
||||
]);
|
||||
setPricings(pricingData);
|
||||
|
||||
const imageEngines: GenerationAiEngineOption[] = (enginesData?.engine?.image || []).map(engine => ({
|
||||
...engine,
|
||||
genType: 'image' as const,
|
||||
}));
|
||||
const videoEngines: GenerationAiEngineOption[] = (enginesData?.engine?.video || []).map(engine => ({
|
||||
...engine,
|
||||
genType: 'video' as const,
|
||||
}));
|
||||
setEngines([...imageEngines, ...videoEngines]);
|
||||
} catch {
|
||||
message.error('加载失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
const filteredEngines = engines.filter(e => e.genType === genType);
|
||||
const selectedEngine = engines.find(e => e.id === selectedEngineId);
|
||||
const resolutions: string[] = genType === 'video'
|
||||
? (selectedEngine?.supportedResolutions?.length ? selectedEngine.supportedResolutions : DEFAULT_VIDEO_RESOLUTIONS)
|
||||
: (selectedEngine?.supportedSizes?.length ? Object.keys(selectedEngine.supportedSizes) : DEFAULT_IMAGE_SIZES);
|
||||
|
||||
const openEdit = (pricing: ApiModelPricing | null = null) => {
|
||||
if (pricing) {
|
||||
form.setFieldsValue({
|
||||
modelConfigId: pricing.modelConfigId,
|
||||
genType: pricing.genType,
|
||||
resolution: pricing.resolution,
|
||||
priceRatio: pricing.priceRatio,
|
||||
basePrice: pricing.basePrice,
|
||||
perSecondPrice: pricing.perSecondPrice,
|
||||
inputVideoRatio: pricing.inputVideoRatio,
|
||||
inputVideoBasePrice: pricing.inputVideoBasePrice,
|
||||
inputVideoPerSecondPrice: pricing.inputVideoPerSecondPrice,
|
||||
inputImageRatio: pricing.inputImageRatio,
|
||||
inputImageBasePrice: pricing.inputImageBasePrice,
|
||||
inputImagePerImagePrice: pricing.inputImagePerImagePrice,
|
||||
});
|
||||
} else {
|
||||
form.resetFields();
|
||||
form.setFieldsValue({
|
||||
genType: 'video',
|
||||
priceRatio: 1.0,
|
||||
basePrice: 0.0,
|
||||
perSecondPrice: 0.00,
|
||||
inputVideoRatio: 1.0,
|
||||
inputVideoBasePrice: 0,
|
||||
inputVideoPerSecondPrice: 0,
|
||||
inputImageRatio: 1.0,
|
||||
inputImageBasePrice: 0,
|
||||
inputImagePerImagePrice: 0,
|
||||
});
|
||||
}
|
||||
setModal({ open: true, pricing });
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
const payload = {
|
||||
...(modal.pricing?.id ? { id: modal.pricing.id } : {}),
|
||||
modelConfigId: values.modelConfigId,
|
||||
genType: values.genType,
|
||||
resolution: values.resolution,
|
||||
priceRatio: values.priceRatio,
|
||||
basePrice: values.basePrice,
|
||||
perSecondPrice: values.perSecondPrice || 0,
|
||||
inputVideoRatio: values.inputVideoRatio || 1.0,
|
||||
inputVideoBasePrice: values.inputVideoBasePrice || 0,
|
||||
inputVideoPerSecondPrice: values.inputVideoPerSecondPrice || 0,
|
||||
inputImageRatio: values.inputImageRatio || 1.0,
|
||||
inputImageBasePrice: values.inputImageBasePrice || 0,
|
||||
inputImagePerImagePrice: values.inputImagePerImagePrice || 0,
|
||||
};
|
||||
await saveApiModelPricing(payload);
|
||||
message.success('保存成功');
|
||||
setModal({ open: false, pricing: null });
|
||||
form.resetFields();
|
||||
load();
|
||||
} catch (e: any) {
|
||||
if (e?.errorFields) return;
|
||||
message.error('保存失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
try {
|
||||
await deleteApiModelPricing(id);
|
||||
message.success('已删除');
|
||||
load();
|
||||
} catch {
|
||||
message.error('删除失败');
|
||||
}
|
||||
};
|
||||
|
||||
const columns: ColumnsType<ApiModelPricing> = [
|
||||
{
|
||||
title: '类型',
|
||||
dataIndex: 'genType',
|
||||
width: 80,
|
||||
render: (v: string) => <Tag color={v === 'video' ? 'blue' : 'green'}>{v === 'video' ? '视频' : '图片'}</Tag>,
|
||||
},
|
||||
{
|
||||
title: '引擎',
|
||||
dataIndex: 'modelConfigId',
|
||||
width: 160,
|
||||
render: (v: string) => {
|
||||
const engine = engines.find(e => e.id === v);
|
||||
return engine?.name || v;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '分辨率',
|
||||
dataIndex: 'resolution',
|
||||
width: 80,
|
||||
},
|
||||
{
|
||||
title: '价格系数',
|
||||
dataIndex: 'priceRatio',
|
||||
width: 90,
|
||||
render: (v: number) => <span style={{ color: v >= 2 ? '#f5222d' : v >= 1.5 ? '#faad14' : '#52c41a' }}>{v}</span>,
|
||||
},
|
||||
{
|
||||
title: '基础价格(元)',
|
||||
dataIndex: 'basePrice',
|
||||
width: 110,
|
||||
},
|
||||
{
|
||||
title: '每秒价格(元)',
|
||||
dataIndex: 'perSecondPrice',
|
||||
width: 120,
|
||||
},
|
||||
{
|
||||
title: '传入视频(元)/每秒',
|
||||
dataIndex: 'inputVideoBasePrice',
|
||||
width: 120,
|
||||
},
|
||||
{
|
||||
title: '传入图片(元)/每张',
|
||||
dataIndex: 'inputImageBasePrice',
|
||||
width: 120,
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
fixed: 'right',
|
||||
width: 150,
|
||||
render: (_: any, r: ApiModelPricing) => (
|
||||
<Space>
|
||||
<Button type="link" size="small" icon={<EditOutlined />} onClick={() => openEdit(r)}>编辑</Button>
|
||||
<Popconfirm title="确定删除?" onConfirm={() => handleDelete(r.id)}>
|
||||
<Button type="link" size="small" danger icon={<DeleteOutlined />}>删除</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Space direction="vertical" size="large" style={{ width: '100%' }}>
|
||||
<Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Space>
|
||||
<div style={{ width: 36, height: 36, borderRadius: 8, background: 'linear-gradient(135deg, #6366f1, #8b5cf6)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<DollarOutlined style={{ color: '#fff', fontSize: 18 }} />
|
||||
</div>
|
||||
<Typography.Text strong style={{ fontSize: 16 }}>API 模型价格配置</Typography.Text>
|
||||
<Tag color="purple">{pricings.length} 条</Tag>
|
||||
</Space>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => openEdit()}>添加价格</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card variant="outlined" style={{ borderRadius: 12 }}>
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={pricings}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
pagination={false}
|
||||
scroll={{ x: 1100 }}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
title={modal.pricing ? '编辑价格' : '添加价格'}
|
||||
open={modal.open}
|
||||
onOk={handleSave}
|
||||
onCancel={() => { setModal({ open: false, pricing: null }); form.resetFields(); }}
|
||||
okText="保存" cancelText="取消" width={560}
|
||||
>
|
||||
<Form form={form} layout="vertical" style={{ marginTop: 16 }}>
|
||||
<div style={{ display: 'flex', gap: 16 }}>
|
||||
<Form.Item name="genType" label="引擎类型" rules={[{ required: true }]} style={{ flex: 1 }}>
|
||||
<Select onChange={() => { form.setFieldsValue({ modelConfigId: undefined, resolution: undefined }); }}>
|
||||
<Select.Option value="video">视频</Select.Option>
|
||||
<Select.Option value="image">图片</Select.Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
<Form.Item name="modelConfigId" label="引擎" rules={[{ required: true }]} style={{ flex: 1 }}>
|
||||
<Select placeholder="选择引擎" showSearch optionFilterProp="label">
|
||||
{filteredEngines.map(e => (
|
||||
<Select.Option key={e.id} value={e.id} label={e.name}>{e.name}</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</div>
|
||||
<Form.Item name="resolution" label="分辨率" rules={[{ required: true }]}>
|
||||
<Select placeholder="选择分辨率">
|
||||
{resolutions.map(r => (
|
||||
<Select.Option key={r} value={r}>{r}</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
<div style={{ display: 'flex', gap: 16 }}>
|
||||
<Form.Item name="priceRatio" label="价格系数" rules={[{ required: true }]} style={{ flex: 1 }}>
|
||||
<InputNumber min={0.01} step={0.1} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="basePrice" label="基础价格(元)" rules={[{ required: true }]} style={{ flex: 1 }}>
|
||||
<InputNumber min={0} step={0.1} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</div>
|
||||
{genType === 'video' && (
|
||||
<Form.Item name="perSecondPrice" label="每秒价格(元)">
|
||||
<InputNumber min={0} step={0.01} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
)}
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>传入媒体附加费</Typography.Text>
|
||||
<div style={{ display: 'flex', gap: 16, marginTop: 8 }}>
|
||||
<Form.Item name="inputVideoBasePrice" label="传入视频(元)/每秒" style={{ flex: 1 }}>
|
||||
<InputNumber min={0} step={0.1} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="inputImageBasePrice" label="传入图片(元)/每张" style={{ flex: 1 }}>
|
||||
<InputNumber min={0} step={0.1} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</div>
|
||||
</Form>
|
||||
</Modal>
|
||||
</Space>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminApiModelPricings;
|
||||
@@ -0,0 +1,258 @@
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import {
|
||||
Button, Card, DatePicker, Input, message, Select, Space, Table, Tag, Typography,
|
||||
} from 'antd';
|
||||
import {
|
||||
TableOutlined, ReloadOutlined, SearchOutlined, ExportOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { getApiUsageAll } from '../api';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
interface UsageItem {
|
||||
id: string;
|
||||
apiKeyId: string;
|
||||
companyName: string;
|
||||
apiKeyPrefix: string | null;
|
||||
taskId: string | null;
|
||||
requestType: string;
|
||||
modelName: string;
|
||||
genType: string;
|
||||
creditsCost: number;
|
||||
tokensUsed: number;
|
||||
requestDurationMs: number;
|
||||
duration: number | null;
|
||||
resolution: string | null;
|
||||
status: string;
|
||||
errorMessage: string | null;
|
||||
errorCode: string | null;
|
||||
createdAt: string | null;
|
||||
}
|
||||
|
||||
const AdminApiUsage: React.FC = () => {
|
||||
const [items, setItems] = useState<UsageItem[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(50);
|
||||
const [searchText, setSearchText] = useState('');
|
||||
const [filterGenType, setFilterGenType] = useState<string | undefined>(undefined);
|
||||
const [filterStatus, setFilterStatus] = useState<string | undefined>(undefined);
|
||||
const [dateRange, setDateRange] = useState<[dayjs.Dayjs | null, dayjs.Dayjs | null]>([null, null]);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params: any = {
|
||||
skip: (page - 1) * pageSize,
|
||||
limit: pageSize,
|
||||
};
|
||||
if (filterGenType) params.genType = filterGenType;
|
||||
if (filterStatus) params.status = filterStatus;
|
||||
if (dateRange[0]) params.startDate = dateRange[0].startOf('day').toISOString();
|
||||
if (dateRange[1]) params.endDate = dateRange[1].endOf('day').toISOString();
|
||||
if (searchText.trim()) params.search = searchText.trim();
|
||||
|
||||
const data = await getApiUsageAll(params);
|
||||
setItems(data?.items || []);
|
||||
setTotal(data?.total || 0);
|
||||
} catch {
|
||||
message.error('加载失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [page, pageSize, filterGenType, filterStatus, dateRange, searchText]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const handleSearch = () => {
|
||||
setPage(1);
|
||||
load();
|
||||
};
|
||||
|
||||
// 导出 CSV
|
||||
const handleExport = () => {
|
||||
const headers = ['时间', '公司', 'api-key', '类型', '模型', '时长(秒)', '分辨率', '消耗(元)', 'Token', '耗时(ms)', '状态', '错误信息'];
|
||||
const rows = items.map(item => [
|
||||
item.createdAt ? new Date(item.createdAt).toLocaleString() : '',
|
||||
item.companyName || '',
|
||||
item.apiKeyPrefix || '',
|
||||
item.genType === 'video' ? '视频' : '图片',
|
||||
item.modelName || '',
|
||||
item.duration || '',
|
||||
item.resolution || '',
|
||||
(item.creditsCost || 0).toFixed(2),
|
||||
item.tokensUsed || '',
|
||||
item.requestDurationMs || '',
|
||||
item.status === 'success' ? '成功' : '失败',
|
||||
item.errorMessage || '',
|
||||
]);
|
||||
|
||||
const csvContent = [headers, ...rows]
|
||||
.map(row => row.map(cell => `"${String(cell).replace(/"/g, '""')}"`).join(','))
|
||||
.join('\n');
|
||||
|
||||
const BOM = '';
|
||||
const blob = new Blob([BOM + csvContent], { type: 'text/csv;charset=utf-8;' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `api_usage_${dayjs().format('YYYYMMDD_HHmmss')}.csv`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
message.success('导出成功');
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: '时间',
|
||||
dataIndex: 'createdAt',
|
||||
width: 160,
|
||||
render: (v: string) => v ? new Date(v).toLocaleString() : '-',
|
||||
},
|
||||
{
|
||||
title: '公司',
|
||||
dataIndex: 'companyName',
|
||||
width: 120,
|
||||
render: (v: string) => v || '-',
|
||||
},
|
||||
{
|
||||
title: 'api-key',
|
||||
dataIndex: 'apiKeyPrefix',
|
||||
width: 110,
|
||||
render: (v: string) => v ? <code style={{ background: '#f5f5f5', padding: '2px 6px', borderRadius: 4 }}>{v}</code> : '-',
|
||||
},
|
||||
{
|
||||
title: '类型',
|
||||
dataIndex: 'genType',
|
||||
width: 70,
|
||||
render: (v: string) => <Tag color={v === 'video' ? 'blue' : 'green'}>{v === 'video' ? '视频' : '图片'}</Tag>,
|
||||
},
|
||||
{
|
||||
title: '模型',
|
||||
dataIndex: 'modelName',
|
||||
width: 160,
|
||||
ellipsis: true,
|
||||
},
|
||||
{
|
||||
title: '消耗(元)',
|
||||
dataIndex: 'creditsCost',
|
||||
width: 90,
|
||||
render: (v: number) => <span style={{ color: v > 0 ? '#f5222d' : '#52c41a', fontWeight: 500 }}>{v?.toFixed(2) || '0.00'}</span>,
|
||||
},
|
||||
{
|
||||
title: 'Token',
|
||||
dataIndex: 'tokensUsed',
|
||||
width: 80,
|
||||
render: (v: number) => v || '-',
|
||||
},
|
||||
{
|
||||
title: '耗时(ms)',
|
||||
dataIndex: 'requestDurationMs',
|
||||
width: 90,
|
||||
render: (v: number) => v || '-',
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
width: 80,
|
||||
render: (v: string) => <Tag color={v === 'success' ? 'green' : 'red'}>{v === 'success' ? '成功' : '失败'}</Tag>,
|
||||
},
|
||||
{
|
||||
title: '错误信息',
|
||||
dataIndex: 'errorMessage',
|
||||
width: 200,
|
||||
ellipsis: true,
|
||||
render: (v: string) => v ? <span style={{ color: '#f5222d' }}>{v}</span> : '-',
|
||||
},
|
||||
];
|
||||
|
||||
// 统计
|
||||
const totalCost = items.reduce((sum, item) => sum + (item.creditsCost || 0), 0);
|
||||
const successCount = items.filter(i => i.status === 'success').length;
|
||||
const failedCount = items.filter(i => i.status === 'failed').length;
|
||||
|
||||
return (
|
||||
<Space direction="vertical" size="large" style={{ width: '100%' }}>
|
||||
<Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Space>
|
||||
<div style={{ width: 36, height: 36, borderRadius: 8, background: 'linear-gradient(135deg, #6366f1, #8b5cf6)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<TableOutlined style={{ color: '#fff', fontSize: 18 }} />
|
||||
</div>
|
||||
<Typography.Text strong style={{ fontSize: 16 }}>API 消耗列表</Typography.Text>
|
||||
<Tag color="purple">{total} 条</Tag>
|
||||
</Space>
|
||||
<Space>
|
||||
<Tag color="blue">本页消耗: {totalCost.toFixed(2)} 元</Tag>
|
||||
<Tag color="green">成功: {successCount}</Tag>
|
||||
<Tag color="red">失败: {failedCount}</Tag>
|
||||
<Button icon={<ExportOutlined />} onClick={handleExport}>导出</Button>
|
||||
<Button icon={<ReloadOutlined />} onClick={handleSearch}>刷新</Button>
|
||||
</Space>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* 筛选栏 */}
|
||||
<Card variant="outlined" style={{ borderRadius: 12 }}>
|
||||
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
<Input
|
||||
placeholder="搜索公司名或 Key 前缀"
|
||||
prefix={<SearchOutlined />}
|
||||
value={searchText}
|
||||
onChange={e => setSearchText(e.target.value)}
|
||||
onPressEnter={handleSearch}
|
||||
allowClear
|
||||
style={{ width: 220 }}
|
||||
/>
|
||||
<Select
|
||||
placeholder="类型"
|
||||
value={filterGenType}
|
||||
onChange={v => { setFilterGenType(v); setPage(1); }}
|
||||
allowClear
|
||||
style={{ width: 100 }}
|
||||
>
|
||||
<Select.Option value="video">视频</Select.Option>
|
||||
<Select.Option value="image">图片</Select.Option>
|
||||
</Select>
|
||||
<Select
|
||||
placeholder="状态"
|
||||
value={filterStatus}
|
||||
onChange={v => { setFilterStatus(v); setPage(1); }}
|
||||
allowClear
|
||||
style={{ width: 100 }}
|
||||
>
|
||||
<Select.Option value="success">成功</Select.Option>
|
||||
<Select.Option value="failed">失败</Select.Option>
|
||||
</Select>
|
||||
<DatePicker.RangePicker
|
||||
value={dateRange}
|
||||
onChange={(dates) => { setDateRange(dates as [dayjs.Dayjs | null, dayjs.Dayjs | null]); setPage(1); }}
|
||||
/>
|
||||
<Button type="primary" onClick={handleSearch}>筛选</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card variant="outlined" style={{ borderRadius: 12 }}>
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={items}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
total,
|
||||
onChange: (p, ps) => { setPage(p); setPageSize(ps || 50); },
|
||||
showSizeChanger: true,
|
||||
showTotal: (t) => `共 ${t} 条`,
|
||||
}}
|
||||
scroll={{ x: 1300 }}
|
||||
/>
|
||||
</Card>
|
||||
</Space>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminApiUsage;
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
Button, Card, DatePicker, Input, message, Select, Space, Table, Tag, Typography,
|
||||
} from 'antd';
|
||||
import {
|
||||
ArrowDownOutlined, ArrowUpOutlined, DownloadOutlined, ReloadOutlined, RollbackOutlined, WalletOutlined,
|
||||
ArrowDownOutlined, ArrowUpOutlined, DollarOutlined, DownloadOutlined, ReloadOutlined, RollbackOutlined, WalletOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { exportStyledExcel, type StyledExcelColumn } from '../utils/excelExport';
|
||||
import dayjs from 'dayjs';
|
||||
@@ -17,6 +17,11 @@ const DEFAULT_SUMMARY: AdminCreditRecordSummary = {
|
||||
totalRecharge: 0,
|
||||
totalConsume: 0,
|
||||
totalRefund: 0,
|
||||
totalCharge: 0,
|
||||
totalHold: 0,
|
||||
totalRefundReal: 0,
|
||||
totalHoldRelease: 0,
|
||||
netConsume: 0,
|
||||
transactionCount: 0,
|
||||
generationCount: 0,
|
||||
generationAttemptCount: 0,
|
||||
@@ -318,17 +323,22 @@ const AdminCreditRecords: React.FC = () => {
|
||||
],
|
||||
summaryRows: [
|
||||
['总充值', exportSummary.totalRecharge],
|
||||
['总消费', exportSummary.totalConsume],
|
||||
['总回退', exportSummary.totalRefund],
|
||||
['总消费(真实扣费 + 预扣占用)', exportSummary.totalConsume],
|
||||
[' · 真实扣费(独立统计:type=消费 & action=charge/NULL)', exportSummary.totalCharge],
|
||||
[' · 预扣占用(独立统计:type=消费 & action=hold)', exportSummary.totalHold],
|
||||
['总回退(真实退款 + 预扣释放)', exportSummary.totalRefund],
|
||||
[' · 真实退款(独立统计:type=回退 & action=refund/NULL)', exportSummary.totalRefundReal],
|
||||
[' · 预扣释放(独立统计:type=回退 & action=hold_release)', exportSummary.totalHoldRelease],
|
||||
['净消耗(总消费 − 总回退,≥ 0)', exportSummary.netConsume],
|
||||
['交易笔数', exportSummary.transactionCount],
|
||||
['生成条数', exportSummary.generationCount],
|
||||
['生成尝试次数', exportSummary.generationAttemptCount],
|
||||
['图片生成条数', exportSummary.imageGenerationCount],
|
||||
['视频生成条数', exportSummary.videoGenerationCount],
|
||||
['图片消费积分', exportSummary.imageConsume],
|
||||
['视频消费积分', exportSummary.videoConsume],
|
||||
['提词消费积分', exportSummary.textConsume],
|
||||
['视频分析积分', exportSummary.analysisConsume],
|
||||
['图片消费积分(仅真实扣费)', exportSummary.imageConsume],
|
||||
['视频消费积分(仅真实扣费)', exportSummary.videoConsume],
|
||||
['提词消费积分(仅真实扣费)', exportSummary.textConsume],
|
||||
['视频分析积分(仅真实扣费)', exportSummary.analysisConsume],
|
||||
['总 Token', exportSummary.totalTokens],
|
||||
['输入 Token', exportSummary.inputTokens],
|
||||
['输出 Token', exportSummary.outputTokens],
|
||||
@@ -369,11 +379,64 @@ const AdminCreditRecords: React.FC = () => {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, minmax(0, 1fr))', gap: 16, marginBottom: 16 }}>
|
||||
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}><Space><ArrowUpOutlined style={{ color: '#10b981', fontSize: 22 }} /><div><div style={{ color: '#94a3b8' }}>总充值</div><div style={{ fontSize: 22, fontWeight: 800, color: '#10b981' }}>+{n(summary.totalRecharge)}</div></div></Space></Card>
|
||||
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}><Space><ArrowDownOutlined style={{ color: '#ef4444', fontSize: 22 }} /><div><div style={{ color: '#94a3b8' }}>总消费</div><div style={{ fontSize: 22, fontWeight: 800, color: '#ef4444' }}>-{n(summary.totalConsume)}</div></div></Space></Card>
|
||||
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}><Space><RollbackOutlined style={{ color: '#3b82f6', fontSize: 22 }} /><div><div style={{ color: '#94a3b8' }}>总回退</div><div style={{ fontSize: 22, fontWeight: 800, color: '#3b82f6' }}>+{n(summary.totalRefund)}</div></div></Space></Card>
|
||||
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}><Space><WalletOutlined style={{ color: '#6366f1', fontSize: 22 }} /><div><div style={{ color: '#94a3b8' }}>交易 / 生成</div><div style={{ fontSize: 22, fontWeight: 800 }}>{n(summary.transactionCount)} / {n(summary.generationCount)}</div></div></Space></Card>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(5, minmax(0, 1fr))', gap: 16, marginBottom: 16 }}>
|
||||
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
|
||||
<Space><ArrowUpOutlined style={{ color: '#10b981', fontSize: 22 }} />
|
||||
<div>
|
||||
<div style={{ color: '#94a3b8' }}>总充值</div>
|
||||
<div style={{ fontSize: 22, fontWeight: 800, color: '#10b981' }}>+{n(summary.totalRecharge)}</div>
|
||||
</div>
|
||||
</Space>
|
||||
</Card>
|
||||
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
|
||||
<Space><ArrowDownOutlined style={{ color: '#ef4444', fontSize: 22 }} />
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ color: '#94a3b8' }}>
|
||||
总消费
|
||||
<span style={{ marginLeft: 6, fontSize: 10, color: '#94a3b8' }}>(真实扣费 + 预扣占用)</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 22, fontWeight: 800, color: '#ef4444' }}>-{n(summary.totalConsume)}</div>
|
||||
<div style={{ fontSize: 11, color: '#94a3b8', marginTop: 2 }}>
|
||||
真实 <span style={{ color: '#b91c1c', fontWeight: 600 }}>{n(summary.totalCharge)}</span>
|
||||
<span style={{ margin: '0 4px', color: '#cbd5e1' }}>|</span>
|
||||
预扣 <span style={{ color: '#d97706', fontWeight: 600 }}>{n(summary.totalHold)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Space>
|
||||
</Card>
|
||||
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
|
||||
<Space><RollbackOutlined style={{ color: '#3b82f6', fontSize: 22 }} />
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ color: '#94a3b8' }}>
|
||||
总回退
|
||||
<span style={{ marginLeft: 6, fontSize: 10, color: '#94a3b8' }}>(真实退款 + 预扣释放)</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 22, fontWeight: 800, color: '#3b82f6' }}>+{n(summary.totalRefund)}</div>
|
||||
<div style={{ fontSize: 11, color: '#94a3b8', marginTop: 2 }}>
|
||||
退款 <span style={{ color: '#1d4ed8', fontWeight: 600 }}>{n(summary.totalRefundReal)}</span>
|
||||
<span style={{ margin: '0 4px', color: '#cbd5e1' }}>|</span>
|
||||
释放 <span style={{ color: '#047857', fontWeight: 600 }}>{n(summary.totalHoldRelease)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Space>
|
||||
</Card>
|
||||
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5', background: 'linear-gradient(135deg, #faf5ff 0%, #eef2ff 100%)' }}>
|
||||
<Space><DollarOutlined style={{ color: '#6366f1', fontSize: 22 }} />
|
||||
<div>
|
||||
<div style={{ color: '#6366f1' }}>净消耗(实际用掉)</div>
|
||||
<div style={{ fontSize: 22, fontWeight: 800, color: '#4338ca' }}>{n(summary.netConsume)}</div>
|
||||
<div style={{ fontSize: 11, color: '#818cf8', marginTop: 2 }}>总消费 − 总回退(≥ 0)</div>
|
||||
</div>
|
||||
</Space>
|
||||
</Card>
|
||||
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
|
||||
<Space><WalletOutlined style={{ color: '#6366f1', fontSize: 22 }} />
|
||||
<div>
|
||||
<div style={{ color: '#94a3b8' }}>交易 / 生成</div>
|
||||
<div style={{ fontSize: 22, fontWeight: 800 }}>{n(summary.transactionCount)} / {n(summary.generationCount)}</div>
|
||||
</div>
|
||||
</Space>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, minmax(0, 1fr))', gap: 16, marginBottom: 16 }}>
|
||||
|
||||
@@ -2,6 +2,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
DatePicker,
|
||||
Empty,
|
||||
Input,
|
||||
message,
|
||||
@@ -24,6 +25,7 @@ import {
|
||||
VideoCameraOutlined,
|
||||
FileImageOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import dayjs from 'dayjs';
|
||||
import { getAdminGenerationRecords, getVideoEngines, getImageEngines } from '../api';
|
||||
import type { AdminGenerationRecord, GenerationAIMediaReference } from '../types';
|
||||
import { formatDate } from '../utils/formatDate';
|
||||
@@ -178,6 +180,9 @@ const InfoItem: React.FC<{ label: string; value?: React.ReactNode }> = ({ label,
|
||||
</div>
|
||||
);
|
||||
|
||||
const todayStart = () => dayjs().startOf('day');
|
||||
const todayEnd = () => dayjs().endOf('day');
|
||||
|
||||
const AdminGenerationRecords: React.FC = () => {
|
||||
const [records, setRecords] = useState<AdminGenerationRecord[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
@@ -195,6 +200,10 @@ const AdminGenerationRecords: React.FC = () => {
|
||||
const [videoPlaying, setVideoPlaying] = useState(false);
|
||||
const videoRef = useRef<HTMLVideoElement | null>(null);
|
||||
|
||||
// 时间筛选(默认当天)
|
||||
const [createdRange, setCreatedRange] = useState<any>([todayStart(), todayEnd()]);
|
||||
const [queryCreatedRange, setQueryCreatedRange] = useState<any>([todayStart(), todayEnd()]);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
@@ -203,6 +212,8 @@ const AdminGenerationRecords: React.FC = () => {
|
||||
status: filterStatus || undefined,
|
||||
engineId: filterEngineId || undefined,
|
||||
includeMediaReferences: filterIncludeMedia === '' ? undefined : filterIncludeMedia === 'true',
|
||||
startDate: queryCreatedRange?.[0]?.format?.('YYYY-MM-DD'),
|
||||
endDate: queryCreatedRange?.[1]?.format?.('YYYY-MM-DD'),
|
||||
page,
|
||||
pageSize,
|
||||
});
|
||||
@@ -246,7 +257,7 @@ const AdminGenerationRecords: React.FC = () => {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [filterStatus, filterUserId, filterEngineId, filterIncludeMedia, page, pageSize]);
|
||||
}, [filterStatus, filterUserId, filterEngineId, filterIncludeMedia, page, pageSize, queryCreatedRange]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
@@ -300,6 +311,19 @@ const AdminGenerationRecords: React.FC = () => {
|
||||
}, [preview]);
|
||||
|
||||
const handleSearch = () => {
|
||||
setPage(1);
|
||||
setQueryCreatedRange(createdRange);
|
||||
setReloadKey((v) => v + 1);
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setFilterStatus('');
|
||||
setFilterUserId('');
|
||||
setFilterEngineId('');
|
||||
setFilterIncludeMedia('');
|
||||
const defaultRange = [todayStart(), todayEnd()];
|
||||
setCreatedRange(defaultRange);
|
||||
setQueryCreatedRange(defaultRange);
|
||||
setPage(1);
|
||||
setReloadKey((v) => v + 1);
|
||||
};
|
||||
@@ -870,9 +894,23 @@ const AdminGenerationRecords: React.FC = () => {
|
||||
onPressEnter={handleSearch}
|
||||
allowClear
|
||||
/>
|
||||
<DatePicker.RangePicker
|
||||
value={createdRange}
|
||||
onChange={(dates) => {
|
||||
if (dates && dates[0] && dates[1]) {
|
||||
setCreatedRange([dates[0].startOf('day'), dates[1].endOf('day')]);
|
||||
} else {
|
||||
setCreatedRange(dates);
|
||||
}
|
||||
}}
|
||||
placeholder={['开始日期', '结束日期']}
|
||||
/>
|
||||
<Button type="primary" onClick={handleSearch} style={{ borderRadius: 8 }}>
|
||||
搜索
|
||||
</Button>
|
||||
<Button onClick={handleReset} style={{ borderRadius: 8 }}>
|
||||
重置
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,435 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Table, Button, Tag, Space, Typography, message, Modal, Card, Popconfirm, Empty, Input, DatePicker } from 'antd';
|
||||
import { CheckOutlined, CloseOutlined, EyeOutlined, FileTextOutlined, FilterOutlined } from '@ant-design/icons';
|
||||
import dayjs from 'dayjs';
|
||||
import { getAdminInvoices, getAdminInvoiceDetail, updateInvoiceStatus } from '../api';
|
||||
import { formatDate } from '../utils/formatDate';
|
||||
import type { InvoiceItem, InvoiceDetail } from '../types';
|
||||
|
||||
const AdminInvoices: React.FC = () => {
|
||||
const [data, setData] = useState<InvoiceItem[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(10);
|
||||
const [statusFilter, setStatusFilter] = useState<string | null>(null);
|
||||
const [phoneFilter, setPhoneFilter] = useState<string>('');
|
||||
const [createdRange, setCreatedRange] = useState<any>([dayjs().startOf('day'), dayjs().endOf('day')]);
|
||||
const [queryCreatedRange, setQueryCreatedRange] = useState<any>([dayjs().startOf('day'), dayjs().endOf('day')]);
|
||||
|
||||
// 详情弹窗
|
||||
const [detailModalOpen, setDetailModalOpen] = useState(false);
|
||||
const [detailLoading, setDetailLoading] = useState(false);
|
||||
const [currentDetail, setCurrentDetail] = useState<InvoiceDetail | null>(null);
|
||||
|
||||
// 失败原因弹窗
|
||||
const [failModalOpen, setFailModalOpen] = useState(false);
|
||||
const [failReason, setFailReason] = useState('');
|
||||
const [failTargetId, setFailTargetId] = useState<string | null>(null);
|
||||
const [failSubmitting, setFailSubmitting] = useState(false);
|
||||
|
||||
const fetchData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await getAdminInvoices({
|
||||
page,
|
||||
pageSize,
|
||||
status: statusFilter || undefined,
|
||||
phone: phoneFilter || undefined,
|
||||
startDate: queryCreatedRange?.[0]?.format?.('YYYY-MM-DD'),
|
||||
endDate: queryCreatedRange?.[1]?.format?.('YYYY-MM-DD'),
|
||||
});
|
||||
setData(res.items);
|
||||
setTotal(res.total);
|
||||
} catch (err: any) {
|
||||
message.error(err?.message || '获取失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [page, pageSize, statusFilter, queryCreatedRange]);
|
||||
|
||||
const handleSearch = () => {
|
||||
setPage(1);
|
||||
setQueryCreatedRange(createdRange);
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setStatusFilter(null);
|
||||
setPhoneFilter('');
|
||||
const defaultRange = [dayjs().startOf('day'), dayjs().endOf('day')];
|
||||
setCreatedRange(defaultRange);
|
||||
setQueryCreatedRange(defaultRange);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const handleViewDetail = async (id: string) => {
|
||||
setDetailLoading(true);
|
||||
setDetailModalOpen(true);
|
||||
try {
|
||||
const detail = await getAdminInvoiceDetail(id);
|
||||
setCurrentDetail(detail);
|
||||
} catch (err: any) {
|
||||
message.error(err?.message || '获取详情失败');
|
||||
setDetailModalOpen(false);
|
||||
} finally {
|
||||
setDetailLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMarkSuccess = async (id: string) => {
|
||||
try {
|
||||
await updateInvoiceStatus(id, { status: 'success' });
|
||||
message.success('已标记为开具成功');
|
||||
fetchData();
|
||||
} catch (err: any) {
|
||||
message.error(err?.message || '操作失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenFailModal = (id: string) => {
|
||||
setFailTargetId(id);
|
||||
setFailReason('');
|
||||
setFailModalOpen(true);
|
||||
};
|
||||
|
||||
const handleConfirmFail = async () => {
|
||||
if (!failReason.trim()) {
|
||||
message.warning('请填写失败原因');
|
||||
return;
|
||||
}
|
||||
if (!failTargetId) return;
|
||||
setFailSubmitting(true);
|
||||
try {
|
||||
await updateInvoiceStatus(failTargetId, { status: 'failed', failureReason: failReason.trim() });
|
||||
message.success('已标记为开具失败');
|
||||
setFailModalOpen(false);
|
||||
setFailTargetId(null);
|
||||
setFailReason('');
|
||||
fetchData();
|
||||
} catch (err: any) {
|
||||
message.error(err?.message || '操作失败');
|
||||
}
|
||||
setFailSubmitting(false);
|
||||
};
|
||||
|
||||
const handlePageChange = (p: number, ps: number) => {
|
||||
setPage(p);
|
||||
setPageSize(ps);
|
||||
};
|
||||
|
||||
const statusTag = (status: string) => {
|
||||
const config: Record<string, { color: string; label: string }> = {
|
||||
processing: { color: 'blue', label: '开具中' },
|
||||
success: { color: 'green', label: '已开具' },
|
||||
failed: { color: 'red', label: '已失败' },
|
||||
};
|
||||
const c = config[status] || { color: 'default', label: status };
|
||||
return <Tag color={c.color}>{c.label}</Tag>;
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: '发票编号',
|
||||
dataIndex: 'invoiceNo',
|
||||
key: 'invoiceNo',
|
||||
width: 180,
|
||||
render: (v: string) => <span style={{ fontFamily: 'monospace', fontSize: 13 }}>{v}</span>,
|
||||
},
|
||||
{
|
||||
title: '用户',
|
||||
dataIndex: 'username',
|
||||
key: 'username',
|
||||
width: 100,
|
||||
render: (_: string, record: InvoiceItem) => (
|
||||
<div>
|
||||
<div><Typography.Text strong>{record.username}</Typography.Text></div>
|
||||
<div style={{ fontSize: 12, color: '#94a3b8' }}>{record.phone}</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '抬头类型',
|
||||
dataIndex: 'headerType',
|
||||
key: 'headerType',
|
||||
width: 80,
|
||||
render: (v: string) => (v === 'company' ? '企业' : '个人'),
|
||||
},
|
||||
{
|
||||
title: '抬头名称',
|
||||
dataIndex: 'headerName',
|
||||
key: 'headerName',
|
||||
width: 160,
|
||||
ellipsis: true,
|
||||
},
|
||||
{
|
||||
title: '邮箱',
|
||||
dataIndex: 'email',
|
||||
key: 'email',
|
||||
width: 160,
|
||||
ellipsis: true,
|
||||
},
|
||||
{
|
||||
title: '总金额',
|
||||
dataIndex: 'totalAmount',
|
||||
key: 'totalAmount',
|
||||
width: 100,
|
||||
align: 'right' as const,
|
||||
render: (v: number) => <span style={{ fontWeight: 600 }}>¥{v.toFixed(2)}</span>,
|
||||
},
|
||||
{
|
||||
title: '订单数',
|
||||
dataIndex: 'orderCount',
|
||||
key: 'orderCount',
|
||||
width: 70,
|
||||
align: 'center' as const,
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
width: 90,
|
||||
render: (v: string) => statusTag(v),
|
||||
},
|
||||
{
|
||||
title: '创建时间',
|
||||
dataIndex: 'createdAt',
|
||||
key: 'createdAt',
|
||||
width: 160,
|
||||
render: (v: string) => formatDate(v),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
width: 260,
|
||||
render: (_: unknown, record: InvoiceItem) => (
|
||||
<Space size={4}>
|
||||
<Button type="link" size="small" icon={<EyeOutlined />} onClick={() => handleViewDetail(record.id)}>
|
||||
详情
|
||||
</Button>
|
||||
{record.status === 'processing' && (
|
||||
<>
|
||||
<Button type="link" size="small" icon={<CheckOutlined />} onClick={() => handleMarkSuccess(record.id)}>
|
||||
开具成功
|
||||
</Button>
|
||||
<Button type="link" size="small" danger icon={<CloseOutlined />} onClick={() => handleOpenFailModal(record.id)}>
|
||||
开具失败
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
|
||||
<Space>
|
||||
<FileTextOutlined style={{ fontSize: 18, color: '#6366f1' }} />
|
||||
<Typography.Text strong style={{ fontSize: 16 }}>发票管理</Typography.Text>
|
||||
<Tag color="purple">共 {total} 条记录</Tag>
|
||||
</Space>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<Button
|
||||
type={statusFilter === null ? 'primary' : 'default'}
|
||||
onClick={() => { setStatusFilter(null); setPage(1); }}
|
||||
icon={<FilterOutlined />}
|
||||
size="small"
|
||||
>
|
||||
全部
|
||||
</Button>
|
||||
<Button
|
||||
type={statusFilter === 'processing' ? 'primary' : 'default'}
|
||||
onClick={() => { setStatusFilter('processing'); setPage(1); }}
|
||||
size="small"
|
||||
>
|
||||
开具中
|
||||
</Button>
|
||||
<Button
|
||||
type={statusFilter === 'success' ? 'primary' : 'default'}
|
||||
onClick={() => { setStatusFilter('success'); setPage(1); }}
|
||||
size="small"
|
||||
>
|
||||
已开具
|
||||
</Button>
|
||||
<Button
|
||||
type={statusFilter === 'failed' ? 'primary' : 'default'}
|
||||
onClick={() => { setStatusFilter('failed'); setPage(1); }}
|
||||
size="small"
|
||||
>
|
||||
已失败
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 搜索栏 */}
|
||||
<div style={{ display: 'flex', gap: 12, marginBottom: 16, flexWrap: 'wrap' }}>
|
||||
<Input
|
||||
placeholder="按用户手机号搜索"
|
||||
value={phoneFilter}
|
||||
onChange={(e) => setPhoneFilter(e.target.value)}
|
||||
style={{ width: 200 }}
|
||||
allowClear
|
||||
onPressEnter={handleSearch}
|
||||
/>
|
||||
<DatePicker.RangePicker
|
||||
value={createdRange}
|
||||
onChange={(dates) => {
|
||||
if (dates && dates[0] && dates[1]) {
|
||||
setCreatedRange([dates[0].startOf('day'), dates[1].endOf('day')]);
|
||||
} else {
|
||||
setCreatedRange(dates);
|
||||
}
|
||||
}}
|
||||
placeholder={['开始日期', '结束日期']}
|
||||
/>
|
||||
<Button type="primary" onClick={handleSearch}>搜索</Button>
|
||||
<Button onClick={handleReset}>重置</Button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div style={{ textAlign: 'center', padding: 40 }}>加载中...</div>
|
||||
) : data.length === 0 ? (
|
||||
<Empty description="暂无发票记录" style={{ padding: '40px 0' }} />
|
||||
) : (
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={data}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize: pageSize,
|
||||
total: total,
|
||||
onChange: handlePageChange,
|
||||
showSizeChanger: true,
|
||||
showTotal: (t) => `共 ${t} 条记录`,
|
||||
}}
|
||||
scroll={{ x: 1400 }}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* 详情弹窗 */}
|
||||
<Modal
|
||||
title={<Space><EyeOutlined />发票详情</Space>}
|
||||
open={detailModalOpen}
|
||||
onCancel={() => { setDetailModalOpen(false); setCurrentDetail(null); }}
|
||||
footer={null}
|
||||
width={700}
|
||||
>
|
||||
{detailLoading ? (
|
||||
<div style={{ textAlign: 'center', padding: 40 }}>加载中...</div>
|
||||
) : currentDetail && (
|
||||
<div style={{ padding: 8 }}>
|
||||
{/* 基本信息 */}
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<Typography.Title level={4} style={{ marginBottom: 16 }}>
|
||||
{currentDetail.invoiceNo}
|
||||
<span style={{ marginLeft: 12 }}>{statusTag(currentDetail.status)}</span>
|
||||
</Typography.Title>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '120px 1fr', gap: 12 }}>
|
||||
<Typography.Text style={{ color: '#64748b' }}>用户ID:</Typography.Text>
|
||||
<Typography.Text>{currentDetail.userId}</Typography.Text>
|
||||
<Typography.Text style={{ color: '#64748b' }}>抬头类型:</Typography.Text>
|
||||
<Typography.Text>{currentDetail.headerType === 'company' ? '企业' : '个人'}</Typography.Text>
|
||||
<Typography.Text style={{ color: '#64748b' }}>抬头名称:</Typography.Text>
|
||||
<Typography.Text>{currentDetail.headerName}</Typography.Text>
|
||||
{currentDetail.headerTaxNo && (
|
||||
<>
|
||||
<Typography.Text style={{ color: '#64748b' }}>税号:</Typography.Text>
|
||||
<Typography.Text>{currentDetail.headerTaxNo}</Typography.Text>
|
||||
</>
|
||||
)}
|
||||
<Typography.Text style={{ color: '#64748b' }}>接收邮箱:</Typography.Text>
|
||||
<Typography.Text>{currentDetail.email}</Typography.Text>
|
||||
<Typography.Text style={{ color: '#64748b' }}>总金额:</Typography.Text>
|
||||
<Typography.Text strong style={{ color: '#ef4444' }}>¥{currentDetail.totalAmount.toFixed(2)}</Typography.Text>
|
||||
<Typography.Text style={{ color: '#64748b' }}>创建时间:</Typography.Text>
|
||||
<Typography.Text>{formatDate(currentDetail.createdAt)}</Typography.Text>
|
||||
{currentDetail.issuedAt && (
|
||||
<>
|
||||
<Typography.Text style={{ color: '#64748b' }}>开票时间:</Typography.Text>
|
||||
<Typography.Text>{formatDate(currentDetail.issuedAt)}</Typography.Text>
|
||||
</>
|
||||
)}
|
||||
{currentDetail.failureReason && (
|
||||
<>
|
||||
<Typography.Text style={{ color: '#64748b' }}>失败原因:</Typography.Text>
|
||||
<Typography.Text type="danger">{currentDetail.failureReason}</Typography.Text>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 关联订单 */}
|
||||
{currentDetail.orders && currentDetail.orders.length > 0 && (
|
||||
<div>
|
||||
<Typography.Text strong style={{ display: 'block', marginBottom: 8 }}>
|
||||
关联订单({currentDetail.orders.length} 笔)
|
||||
</Typography.Text>
|
||||
<Table
|
||||
dataSource={currentDetail.orders}
|
||||
columns={[
|
||||
{
|
||||
title: '订单号',
|
||||
dataIndex: 'orderNo',
|
||||
key: 'orderNo',
|
||||
render: (v: string) => <span style={{ fontFamily: 'monospace', fontSize: 12 }}>{v}</span>,
|
||||
},
|
||||
{
|
||||
title: '金额',
|
||||
dataIndex: 'amount',
|
||||
key: 'amount',
|
||||
align: 'right' as const,
|
||||
render: (v: number) => `¥${v.toFixed(2)}`,
|
||||
},
|
||||
{
|
||||
title: '积分',
|
||||
dataIndex: 'credits',
|
||||
key: 'credits',
|
||||
align: 'right' as const,
|
||||
},
|
||||
]}
|
||||
rowKey="id"
|
||||
pagination={false}
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
{/* 失败原因弹窗 */}
|
||||
<Modal
|
||||
title="开具失败"
|
||||
open={failModalOpen}
|
||||
onCancel={() => { setFailModalOpen(false); setFailTargetId(null); setFailReason(''); }}
|
||||
onOk={handleConfirmFail}
|
||||
okText="确认"
|
||||
cancelText="取消"
|
||||
confirmLoading={failSubmitting}
|
||||
>
|
||||
<Typography.Text style={{ display: 'block', marginBottom: 8 }}>
|
||||
请填写失败原因(用户可见):
|
||||
</Typography.Text>
|
||||
<Input.TextArea
|
||||
value={failReason}
|
||||
onChange={(e) => setFailReason(e.target.value)}
|
||||
placeholder="例如:抬头信息有误,请重新提交"
|
||||
rows={3}
|
||||
maxLength={500}
|
||||
showCount
|
||||
/>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminInvoices;
|
||||
@@ -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.siteBannerVersion})`);
|
||||
} 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 }}
|
||||
@@ -267,4 +450,4 @@ const AdminNotificationManager: React.FC = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminNotificationManager;
|
||||
export default AdminNotificationManager;
|
||||
@@ -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 {
|
||||
|
||||
@@ -62,6 +62,32 @@ const AdminSettings: React.FC = () => {
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
|
||||
// 仅当 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',
|
||||
'llm_hold_credits_default',
|
||||
'llm_hold_credits_generation_record_prompt',
|
||||
'llm_hold_credits_module_image_prompt',
|
||||
'llm_hold_credits_module_video_prompt',
|
||||
'llm_hold_credits_shot_video_analysis',
|
||||
];
|
||||
const invalidKey = holdKeys.find((key) => {
|
||||
const numericValue = Number(values[key]);
|
||||
return !Number.isFinite(numericValue) || numericValue <= 0;
|
||||
});
|
||||
if (invalidKey) {
|
||||
message.error('启用 LLM 统一计费时,所有预扣积分必须大于 0');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
for (const config of configs) {
|
||||
const newVal = values[config.key];
|
||||
@@ -174,7 +200,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')),
|
||||
|
||||
@@ -121,7 +121,7 @@ const AdminVideoEngines: React.FC = () => {
|
||||
form.setFieldsValue({
|
||||
isActive: true, priority: 0,
|
||||
multiGenerationEnabled: false, maxGenerationCount: 1,
|
||||
maxDuration: 30,
|
||||
maxDuration: 15,
|
||||
maxImageCount: 2,
|
||||
maxVideoCount: 0,
|
||||
maxAudioCount: 0,
|
||||
@@ -286,7 +286,7 @@ const AdminVideoEngines: React.FC = () => {
|
||||
</Form.Item>
|
||||
<Form.Item name="supportedDurations" label="支持时长(秒)" style={{ flex: 1 }}>
|
||||
<Select mode="multiple" size="large" options={
|
||||
Array.from({ length: 12 }, (_, i) => ({ value: i + 4, label: `${i + 4}秒` }))
|
||||
Array.from({ length: 27 }, (_, i) => ({ value: i + 4, label: `${i + 4}秒` }))
|
||||
} />
|
||||
</Form.Item>
|
||||
</div>
|
||||
@@ -306,14 +306,14 @@ const AdminVideoEngines: React.FC = () => {
|
||||
name="maxAudioCount"
|
||||
label="最大参考音频数"
|
||||
style={{ flex: 1 }}
|
||||
extra="0 表示不支持音频参考,最大 3 段"
|
||||
extra="0 表示不支持音频参考"
|
||||
rules={[
|
||||
{
|
||||
validator: (_, value) => {
|
||||
const n = Number(value ?? 0);
|
||||
if (!Number.isInteger(n) || n < 0 || n > 3) {
|
||||
return Promise.reject(new Error('最大参考音频数必须为 0-3 的整数'));
|
||||
}
|
||||
// if (!Number.isInteger(n) || n < 0 || n > 3) {
|
||||
// return Promise.reject(new Error('最大参考音频数必须为 0-3 的整数'));
|
||||
// }
|
||||
return Promise.resolve();
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -868,16 +868,32 @@ export interface VideoPromptSchemaPreviewOut {
|
||||
|
||||
export interface AdminCreditRecordSummary {
|
||||
totalRecharge: number;
|
||||
/** 总消费(仅 type=consume,不含团队内部转账)= 真实扣费 + 预扣占用 */
|
||||
totalConsume: number;
|
||||
/** 总回退(仅 type=refund)= 真实退款 + 预扣释放 */
|
||||
totalRefund: number;
|
||||
/** 独立统计列:真实扣费 charge(含历史 NULL),对应"筛选类型=消费 & action=charge/NULL"求和 */
|
||||
totalCharge: number;
|
||||
/** 独立统计列:预扣占用 hold */
|
||||
totalHold: number;
|
||||
/** 独立统计列:真实退款 refund(含历史 NULL) */
|
||||
totalRefundReal: number;
|
||||
/** 独立统计列:预扣释放 hold_release(type=refund, action=hold_release) */
|
||||
totalHoldRelease: number;
|
||||
/** 净消耗 = max(totalConsume - totalRefund, 0),即真正"用掉了"的积分 */
|
||||
netConsume: number;
|
||||
transactionCount: number;
|
||||
generationCount: number;
|
||||
generationAttemptCount: number;
|
||||
imageGenerationCount: number;
|
||||
videoGenerationCount: number;
|
||||
/** 子分类消费(图片)仅真实扣费 charge 口径 */
|
||||
imageConsume: number;
|
||||
/** 子分类消费(视频)仅真实扣费 charge 口径 */
|
||||
videoConsume: number;
|
||||
/** 子分类消费(提词)仅真实扣费 charge 口径 */
|
||||
textConsume: number;
|
||||
/** 子分类消费(分析)仅真实扣费 charge 口径 */
|
||||
analysisConsume: number;
|
||||
totalTokens: number;
|
||||
inputTokens: number;
|
||||
@@ -1542,3 +1558,51 @@ export interface LlmBillingExecution {
|
||||
finalErrorMessage?: string | null;
|
||||
calls: LlmCallAttempt[];
|
||||
}
|
||||
// ── Invoice Types ───────────────────────────────────────
|
||||
|
||||
export interface InvoiceItem {
|
||||
id: string;
|
||||
invoiceNo: string;
|
||||
userId: string;
|
||||
username: string;
|
||||
phone: string;
|
||||
headerType: string;
|
||||
headerName: string;
|
||||
email: string;
|
||||
totalAmount: number;
|
||||
totalCredits: number;
|
||||
orderCount: number;
|
||||
status: string;
|
||||
failureReason: string | null;
|
||||
issuedAt: string | null;
|
||||
createdAt: string | null;
|
||||
}
|
||||
|
||||
export interface InvoiceOrder {
|
||||
id: string;
|
||||
orderNo: string;
|
||||
amount: number;
|
||||
credits: number;
|
||||
}
|
||||
|
||||
export interface InvoiceDetail {
|
||||
id: string;
|
||||
invoiceNo: string;
|
||||
userId: string;
|
||||
headerType: string;
|
||||
headerName: string;
|
||||
headerTaxNo: string | null;
|
||||
headerRegisterAddress: string | null;
|
||||
headerRegisterPhone: string | null;
|
||||
headerBankName: string | null;
|
||||
headerBankAccount: string | null;
|
||||
email: string;
|
||||
totalAmount: number;
|
||||
totalCredits: number;
|
||||
status: string;
|
||||
failureReason: string | null;
|
||||
issuedAt: string | null;
|
||||
createdAt: string | null;
|
||||
updatedAt: string | null;
|
||||
orders: InvoiceOrder[];
|
||||
}
|
||||
|
||||
@@ -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/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/admininvoices.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"}
|
||||
Reference in New Issue
Block a user