Merge branch 'main' of https://gitee.com/wg123/video-gen
This commit is contained in:
Vendored
+108
-108
File diff suppressed because one or more lines are too long
Vendored
+36
-36
@@ -1,37 +1,37 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="zh-CN">
|
<html lang="zh-CN">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<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.googleapis.com" />
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
<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" />
|
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&display=swap" rel="stylesheet" />
|
||||||
<title>后台管理</title>
|
<title>后台管理</title>
|
||||||
<script>
|
<script>
|
||||||
(function() {
|
(function() {
|
||||||
var cached = localStorage.getItem('siteInfo');
|
var cached = localStorage.getItem('siteInfo');
|
||||||
if (cached) {
|
if (cached) {
|
||||||
try {
|
try {
|
||||||
var info = JSON.parse(cached);
|
var info = JSON.parse(cached);
|
||||||
if (info.siteName) {
|
if (info.siteName) {
|
||||||
document.title = info.siteName + ' - 管理后台';
|
document.title = info.siteName + ' - 管理后台';
|
||||||
}
|
}
|
||||||
if (info.siteLogo) {
|
if (info.siteLogo) {
|
||||||
var link = document.querySelector('link[rel="icon"]');
|
var link = document.querySelector('link[rel="icon"]');
|
||||||
if (link) {
|
if (link) {
|
||||||
link.href = info.siteLogo;
|
link.href = info.siteLogo;
|
||||||
link.type = 'image/png';
|
link.type = 'image/png';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
<script type="module" crossorigin src="/assets/index-D7aUeB8G.js"></script>
|
<script type="module" crossorigin src="/assets/index-Ba2VoaZw.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-D7ShJUt4.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-D7ShJUt4.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>␍
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -7,8 +7,9 @@ import AdminAuthoriz from './pages/AdminAuthoriz';
|
|||||||
import AdminConsume from './pages/AdminConsume';
|
import AdminConsume from './pages/AdminConsume';
|
||||||
import AdminLoginPage from './pages/AdminLoginPage';
|
import AdminLoginPage from './pages/AdminLoginPage';
|
||||||
import AdminDashboard from './pages/AdminDashboard';
|
import AdminDashboard from './pages/AdminDashboard';
|
||||||
import AdminPlatform from './pages/Adminplatform';
|
import AdminPlatform from './pages/AdminPlatform';
|
||||||
import AdminUsers from './pages/AdminUsers';
|
import AdminUsers from './pages/AdminUsers';
|
||||||
|
import AdminTeams from './pages/AdminTeams';
|
||||||
import AdminModels from './pages/AdminModels';
|
import AdminModels from './pages/AdminModels';
|
||||||
import AdminSettings from './pages/AdminSettings';
|
import AdminSettings from './pages/AdminSettings';
|
||||||
import AdminNotificationManager from './pages/AdminNotificationManager';
|
import AdminNotificationManager from './pages/AdminNotificationManager';
|
||||||
@@ -79,6 +80,7 @@ const App = () => {
|
|||||||
<Route path="/" element={<ProtectedRoute><AdminLayout /></ProtectedRoute>}>
|
<Route path="/" element={<ProtectedRoute><AdminLayout /></ProtectedRoute>}>
|
||||||
<Route index element={<AdminDashboard />} />
|
<Route index element={<AdminDashboard />} />
|
||||||
<Route path="users" element={<AdminUsers />} />
|
<Route path="users" element={<AdminUsers />} />
|
||||||
|
<Route path="teams" element={<AdminTeams />} />
|
||||||
<Route path="credit-records" element={<AdminCreditRecords />} />
|
<Route path="credit-records" element={<AdminCreditRecords />} />
|
||||||
<Route path="models" element={<AdminModels />} />
|
<Route path="models" element={<AdminModels />} />
|
||||||
<Route path="credit-ratios" element={<AdminCreditRatios />} />
|
<Route path="credit-ratios" element={<AdminCreditRatios />} />
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import type {
|
|||||||
VideoPromptSchemaPreviewPayload, VideoPromptSchemaPreviewOut, VideoPromptSchemaExportOut,
|
VideoPromptSchemaPreviewPayload, VideoPromptSchemaPreviewOut, VideoPromptSchemaExportOut,
|
||||||
AdminCreditRecordListResponse, AdminCreditRecordQueryParams,
|
AdminCreditRecordListResponse, AdminCreditRecordQueryParams,
|
||||||
ResourceCapacityConfigOut, ResourceCapacityConfigPayload, AdminUserResourceCapacityOut,
|
ResourceCapacityConfigOut, ResourceCapacityConfigPayload, AdminUserResourceCapacityOut,
|
||||||
|
AdminTeam, AdminTeamListResponse, AdminTeamOption, AdminTeamPayload, AdminTeamQueryParams,
|
||||||
} from '../types';
|
} from '../types';
|
||||||
|
|
||||||
// ── Auth ──────────────────────────────────────────────────
|
// ── Auth ──────────────────────────────────────────────────
|
||||||
@@ -107,6 +108,7 @@ export async function getAdminUsers(
|
|||||||
search?: string,
|
search?: string,
|
||||||
userType?: string,
|
userType?: string,
|
||||||
frontendUserKind?: string,
|
frontendUserKind?: string,
|
||||||
|
teamId?: string,
|
||||||
): Promise<{ items: AdminUser[]; total: number }> {
|
): Promise<{ items: AdminUser[]; total: number }> {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
params.set('page', String(page));
|
params.set('page', String(page));
|
||||||
@@ -114,6 +116,7 @@ export async function getAdminUsers(
|
|||||||
if (search) params.set('search', search);
|
if (search) params.set('search', search);
|
||||||
if (userType) params.set('user_type', userType);
|
if (userType) params.set('user_type', userType);
|
||||||
if (frontendUserKind) params.set('frontend_user_kind', frontendUserKind);
|
if (frontendUserKind) params.set('frontend_user_kind', frontendUserKind);
|
||||||
|
if (teamId) params.set('team_id', teamId);
|
||||||
return api.get(`/admin/users?${params.toString()}`);
|
return api.get(`/admin/users?${params.toString()}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,6 +124,40 @@ export async function updateFrontendUserKind(userId: string, frontendUserKind: '
|
|||||||
return api.put(`/admin/users/${userId}/frontend-kind`, { frontend_user_kind: frontendUserKind });
|
return api.put(`/admin/users/${userId}/frontend-kind`, { frontend_user_kind: frontendUserKind });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function updateUserTeam(userId: string, teamId: string | null): Promise<AdminUser> {
|
||||||
|
return api.put(`/admin/users/${userId}/team`, { team_id: teamId });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAdminTeams(filters?: AdminTeamQueryParams): Promise<AdminTeamListResponse> {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
setMaybe(params, 'page', filters?.page);
|
||||||
|
setMaybe(params, 'page_size', filters?.pageSize);
|
||||||
|
setMaybe(params, 'keyword', filters?.keyword);
|
||||||
|
setMaybe(params, 'status', filters?.status);
|
||||||
|
const q = params.toString() ? `?${params}` : '';
|
||||||
|
return api.get(`/admin/teams${q}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getTeamOptions(includeDisabled = true): Promise<AdminTeamOption[]> {
|
||||||
|
return api.get(`/admin/teams/options?include_disabled=${includeDisabled ? 'true' : 'false'}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveAdminTeam(team: Partial<AdminTeamPayload> & { id?: string }): Promise<AdminTeam> {
|
||||||
|
const payload = {
|
||||||
|
name: team.name,
|
||||||
|
code: team.code || null,
|
||||||
|
description: team.description || null,
|
||||||
|
status: team.status || 'active',
|
||||||
|
sort_order: team.sort_order ?? 0,
|
||||||
|
};
|
||||||
|
if (team.id) return api.put(`/admin/teams/${team.id}`, payload);
|
||||||
|
return api.post('/admin/teams', payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteAdminTeam(id: string): Promise<void> {
|
||||||
|
await api.delete(`/admin/teams/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
export async function adjustCredits(userId: string, amount: number, description: string): Promise<void> {
|
export async function adjustCredits(userId: string, amount: number, description: string): Promise<void> {
|
||||||
await api.post(`/admin/users/${userId}/credits`, { amount, description });
|
await api.post(`/admin/users/${userId}/credits`, { amount, description });
|
||||||
}
|
}
|
||||||
@@ -225,6 +262,7 @@ export async function getCreditRecords(filters?: AdminCreditRecordQueryParams):
|
|||||||
setMaybe(params, 'user_name', filters?.userName);
|
setMaybe(params, 'user_name', filters?.userName);
|
||||||
setMaybe(params, 'user_type', filters?.userType);
|
setMaybe(params, 'user_type', filters?.userType);
|
||||||
setMaybe(params, 'frontend_user_kind', filters?.frontendUserKind);
|
setMaybe(params, 'frontend_user_kind', filters?.frontendUserKind);
|
||||||
|
setMaybe(params, 'team_id', filters?.teamId);
|
||||||
setMaybe(params, 'record_type', filters?.recordType || filters?.type);
|
setMaybe(params, 'record_type', filters?.recordType || filters?.type);
|
||||||
setMaybe(params, 'credit_subject', filters?.creditSubject);
|
setMaybe(params, 'credit_subject', filters?.creditSubject);
|
||||||
setMaybe(params, 'media_type', filters?.mediaType);
|
setMaybe(params, 'media_type', filters?.mediaType);
|
||||||
|
|||||||
@@ -7,10 +7,12 @@ import {
|
|||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import { exportStyledExcel, type StyledExcelColumn } from '../utils/excelExport';
|
import { exportStyledExcel, type StyledExcelColumn } from '../utils/excelExport';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import { getCreditRecords } from '../api';
|
import { getCreditRecords, getTeamOptions } from '../api';
|
||||||
import type { AdminCreditRecord, AdminCreditRecordQueryParams, AdminCreditRecordSummary } from '../types';
|
import type { AdminCreditRecord, AdminCreditRecordQueryParams, AdminCreditRecordSummary, AdminTeamOption } from '../types';
|
||||||
import { formatDate } from '../utils/formatDate';
|
import { formatDate } from '../utils/formatDate';
|
||||||
|
|
||||||
|
const TEAM_UNASSIGNED_VALUE = '__none__';
|
||||||
|
|
||||||
const DEFAULT_SUMMARY: AdminCreditRecordSummary = {
|
const DEFAULT_SUMMARY: AdminCreditRecordSummary = {
|
||||||
totalRecharge: 0,
|
totalRecharge: 0,
|
||||||
totalConsume: 0,
|
totalConsume: 0,
|
||||||
@@ -157,6 +159,8 @@ const AdminCreditRecords: React.FC = () => {
|
|||||||
const [pageSize, setPageSize] = useState(10);
|
const [pageSize, setPageSize] = useState(10);
|
||||||
|
|
||||||
const [userScope, setUserScope] = useState('');
|
const [userScope, setUserScope] = useState('');
|
||||||
|
const [teamFilter, setTeamFilter] = useState('');
|
||||||
|
const [teamOptions, setTeamOptions] = useState<AdminTeamOption[]>([]);
|
||||||
const [recordType, setRecordType] = useState('');
|
const [recordType, setRecordType] = useState('');
|
||||||
const [creditSubject, setCreditSubject] = useState('');
|
const [creditSubject, setCreditSubject] = useState('');
|
||||||
const [mediaType, setMediaType] = useState('');
|
const [mediaType, setMediaType] = useState('');
|
||||||
@@ -171,6 +175,7 @@ const AdminCreditRecords: React.FC = () => {
|
|||||||
page,
|
page,
|
||||||
pageSize,
|
pageSize,
|
||||||
userName: userNameFilter || undefined,
|
userName: userNameFilter || undefined,
|
||||||
|
teamId: teamFilter || undefined,
|
||||||
recordType: recordType || undefined,
|
recordType: recordType || undefined,
|
||||||
creditSubject: creditSubject || undefined,
|
creditSubject: creditSubject || undefined,
|
||||||
mediaType: mediaType || undefined,
|
mediaType: mediaType || undefined,
|
||||||
@@ -181,7 +186,7 @@ const AdminCreditRecords: React.FC = () => {
|
|||||||
startDate: dateRange[0]?.format('YYYY-MM-DD'),
|
startDate: dateRange[0]?.format('YYYY-MM-DD'),
|
||||||
endDate: dateRange[1]?.format('YYYY-MM-DD'),
|
endDate: dateRange[1]?.format('YYYY-MM-DD'),
|
||||||
...buildScope(userScope),
|
...buildScope(userScope),
|
||||||
}), [page, pageSize, userNameFilter, recordType, creditSubject, mediaType, chargeKind, sourceModule, sourceStepCode, billingScene, dateRange, userScope]);
|
}), [page, pageSize, userNameFilter, teamFilter, recordType, creditSubject, mediaType, chargeKind, sourceModule, sourceStepCode, billingScene, dateRange, userScope]);
|
||||||
|
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -199,8 +204,13 @@ const AdminCreditRecords: React.FC = () => {
|
|||||||
|
|
||||||
useEffect(() => { load(); }, [query]);
|
useEffect(() => { load(); }, [query]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getTeamOptions(true).then(setTeamOptions).catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleReset = () => {
|
const handleReset = () => {
|
||||||
setUserScope('');
|
setUserScope('');
|
||||||
|
setTeamFilter('');
|
||||||
setRecordType('');
|
setRecordType('');
|
||||||
setCreditSubject('');
|
setCreditSubject('');
|
||||||
setMediaType('');
|
setMediaType('');
|
||||||
@@ -237,6 +247,7 @@ const AdminCreditRecords: React.FC = () => {
|
|||||||
{ title: '手机号', minWidth: 13, maxWidth: 18, render: (r) => r.phone || '-' },
|
{ title: '手机号', minWidth: 13, maxWidth: 18, render: (r) => r.phone || '-' },
|
||||||
{ title: '用户类型', maxWidth: 16, render: (r) => r.userTypeLabel || '-' },
|
{ title: '用户类型', maxWidth: 16, render: (r) => r.userTypeLabel || '-' },
|
||||||
{ title: '前台归类', maxWidth: 18, render: (r) => r.frontendUserKindLabel || '-' },
|
{ title: '前台归类', maxWidth: 18, render: (r) => r.frontendUserKindLabel || '-' },
|
||||||
|
{ title: '归属团队', maxWidth: 20, render: (r) => r.teamNameSnapshot || '未分配团队' },
|
||||||
{ title: '流水类型', maxWidth: 14, align: 'center', render: (r) => r.recordTypeLabel || r.type || '-' },
|
{ title: '流水类型', maxWidth: 14, align: 'center', render: (r) => r.recordTypeLabel || r.type || '-' },
|
||||||
{ title: '积分类型', maxWidth: 20, render: (r) => r.creditSubjectLabel || '-' },
|
{ title: '积分类型', maxWidth: 20, render: (r) => r.creditSubjectLabel || '-' },
|
||||||
{ title: '扣费子类', maxWidth: 22, render: (r) => r.chargeKindLabel || '-' },
|
{ title: '扣费子类', maxWidth: 22, render: (r) => r.chargeKindLabel || '-' },
|
||||||
@@ -302,6 +313,7 @@ const AdminCreditRecords: React.FC = () => {
|
|||||||
const columns = [
|
const columns = [
|
||||||
{ title: '用户', dataIndex: 'username', width: 130, fixed: 'left' as const, render: (v: string, r: AdminCreditRecord) => <div><Typography.Text strong>{v || '-'}</Typography.Text><div style={{ fontSize: 12, color: '#94a3b8' }}>{r.phone || '-'}</div></div> },
|
{ title: '用户', dataIndex: 'username', width: 130, fixed: 'left' as const, render: (v: string, r: AdminCreditRecord) => <div><Typography.Text strong>{v || '-'}</Typography.Text><div style={{ fontSize: 12, color: '#94a3b8' }}>{r.phone || '-'}</div></div> },
|
||||||
{ title: '用户类型', dataIndex: 'userTypeLabel', width: 120, render: (_: string, r: AdminCreditRecord) => <Tag color={r.userType === 'admin' ? 'orange' : 'blue'}>{r.userTypeLabel || '-'}</Tag> },
|
{ title: '用户类型', dataIndex: 'userTypeLabel', width: 120, render: (_: string, r: AdminCreditRecord) => <Tag color={r.userType === 'admin' ? 'orange' : 'blue'}>{r.userTypeLabel || '-'}</Tag> },
|
||||||
|
{ title: '归属团队', dataIndex: 'teamNameSnapshot', width: 130, render: (v: string) => v ? <Tag color="blue">{v}</Tag> : <Typography.Text type="secondary">未分配</Typography.Text> },
|
||||||
{ title: '流水类型', dataIndex: 'recordType', width: 100, render: (v: string, r: AdminCreditRecord) => { const cfg = RECORD_TYPE_MAP[v] || { text: r.recordTypeLabel || v || '-', color: 'default', icon: null }; return <Tag color={cfg.color} icon={cfg.icon}>{cfg.text}</Tag>; } },
|
{ title: '流水类型', dataIndex: 'recordType', width: 100, render: (v: string, r: AdminCreditRecord) => { const cfg = RECORD_TYPE_MAP[v] || { text: r.recordTypeLabel || v || '-', color: 'default', icon: null }; return <Tag color={cfg.color} icon={cfg.icon}>{cfg.text}</Tag>; } },
|
||||||
{ title: '积分类型', dataIndex: 'creditSubjectLabel', width: 150, render: (v: string) => <Tag>{v || '-'}</Tag> },
|
{ title: '积分类型', dataIndex: 'creditSubjectLabel', width: 150, render: (v: string) => <Tag>{v || '-'}</Tag> },
|
||||||
{ title: '模块', dataIndex: 'sourceModuleLabel', width: 130, render: (v: string) => v || '-' },
|
{ title: '模块', dataIndex: 'sourceModuleLabel', width: 130, render: (v: string) => v || '-' },
|
||||||
@@ -336,6 +348,16 @@ const AdminCreditRecords: React.FC = () => {
|
|||||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16, flexWrap: 'wrap', gap: 12 }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16, flexWrap: 'wrap', gap: 12 }}>
|
||||||
<Space wrap>
|
<Space wrap>
|
||||||
<Select value={userScope} onChange={(v) => { setPage(1); setUserScope(v); }} style={{ width: 150 }} options={userScopeOptions} />
|
<Select value={userScope} onChange={(v) => { setPage(1); setUserScope(v); }} style={{ width: 150 }} options={userScopeOptions} />
|
||||||
|
<Select
|
||||||
|
value={teamFilter}
|
||||||
|
onChange={(v) => { setPage(1); setTeamFilter(v); }}
|
||||||
|
style={{ width: 170 }}
|
||||||
|
options={[
|
||||||
|
{ value: '', label: '全部团队' },
|
||||||
|
{ value: TEAM_UNASSIGNED_VALUE, label: '未分配团队' },
|
||||||
|
...teamOptions.map(t => ({ value: t.id, label: t.status === 'disabled' ? `${t.name}(禁用)` : t.name })),
|
||||||
|
]}
|
||||||
|
/>
|
||||||
<Select value={recordType} onChange={(v) => { setPage(1); setRecordType(v); }} style={{ width: 130 }} options={recordTypeOptions} />
|
<Select value={recordType} onChange={(v) => { setPage(1); setRecordType(v); }} style={{ width: 130 }} options={recordTypeOptions} />
|
||||||
<Select value={creditSubject} onChange={(v) => { setPage(1); setCreditSubject(v); }} style={{ width: 180 }} options={creditSubjectOptions} />
|
<Select value={creditSubject} onChange={(v) => { setPage(1); setCreditSubject(v); }} style={{ width: 180 }} options={creditSubjectOptions} />
|
||||||
<Select value={mediaType} onChange={(v) => { setPage(1); setMediaType(v); }} style={{ width: 110 }} options={mediaTypeOptions} />
|
<Select value={mediaType} onChange={(v) => { setPage(1); setMediaType(v); }} style={{ width: 110 }} options={mediaTypeOptions} />
|
||||||
@@ -366,7 +388,7 @@ const AdminCreditRecords: React.FC = () => {
|
|||||||
showSizeChanger: true,
|
showSizeChanger: true,
|
||||||
showTotal: (t) => `共 ${t} 条记录`,
|
showTotal: (t) => `共 ${t} 条记录`,
|
||||||
}}
|
}}
|
||||||
scroll={{ x: 1900 }}
|
scroll={{ x: 2050 }}
|
||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,246 @@
|
|||||||
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { Button, Card, Form, Input, InputNumber, message, Modal, Popconfirm, Select, Space, Table, Tag, Typography } from 'antd';
|
||||||
|
import { DeleteOutlined, EditOutlined, PlusOutlined, ReloadOutlined, SearchOutlined, TeamOutlined } from '@ant-design/icons';
|
||||||
|
import { deleteAdminTeam, getAdminTeams, saveAdminTeam } from '../api';
|
||||||
|
import type { AdminTeam } from '../types';
|
||||||
|
import { formatDate } from '../utils/formatDate';
|
||||||
|
|
||||||
|
const statusOptions = [
|
||||||
|
{ value: '', label: '全部状态' },
|
||||||
|
{ value: 'active', label: '启用' },
|
||||||
|
{ value: 'disabled', label: '禁用' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const editStatusOptions = [
|
||||||
|
{ value: 'active', label: '启用' },
|
||||||
|
{ value: 'disabled', label: '禁用' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function statusLabel(status?: string): string {
|
||||||
|
if (status === 'active') return '启用';
|
||||||
|
if (status === 'disabled') return '禁用';
|
||||||
|
return status || '-';
|
||||||
|
}
|
||||||
|
|
||||||
|
const AdminTeams: React.FC = () => {
|
||||||
|
const [items, setItems] = useState<AdminTeam[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [pageSize, setPageSize] = useState(20);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [keyword, setKeyword] = useState('');
|
||||||
|
const [status, setStatus] = useState('');
|
||||||
|
const [modal, setModal] = useState<{ open: boolean; item: AdminTeam | null }>({ open: false, item: null });
|
||||||
|
const [form] = Form.useForm();
|
||||||
|
|
||||||
|
const query = useMemo(() => ({
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
keyword: keyword || undefined,
|
||||||
|
status: status || undefined,
|
||||||
|
}), [page, pageSize, keyword, status]);
|
||||||
|
|
||||||
|
const load = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await getAdminTeams(query);
|
||||||
|
setItems(res.items || []);
|
||||||
|
setTotal(res.total || 0);
|
||||||
|
} catch (e: any) {
|
||||||
|
message.error(e?.message || '加载团队列表失败');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => { load(); }, [query]);
|
||||||
|
|
||||||
|
const openCreate = () => {
|
||||||
|
form.resetFields();
|
||||||
|
form.setFieldsValue({ status: 'active', sortOrder: 0 });
|
||||||
|
setModal({ open: true, item: null });
|
||||||
|
};
|
||||||
|
|
||||||
|
const openEdit = (item: AdminTeam) => {
|
||||||
|
form.setFieldsValue({
|
||||||
|
name: item.name,
|
||||||
|
code: item.code || '',
|
||||||
|
description: item.description || '',
|
||||||
|
status: item.status || 'active',
|
||||||
|
sortOrder: item.sortOrder || 0,
|
||||||
|
});
|
||||||
|
setModal({ open: true, item });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
try {
|
||||||
|
const values = await form.validateFields();
|
||||||
|
setSaving(true);
|
||||||
|
await saveAdminTeam({
|
||||||
|
id: modal.item?.id,
|
||||||
|
name: values.name,
|
||||||
|
code: values.code || null,
|
||||||
|
description: values.description || null,
|
||||||
|
status: values.status || 'active',
|
||||||
|
sort_order: values.sortOrder || 0,
|
||||||
|
});
|
||||||
|
message.success(modal.item ? '团队已更新' : '团队已创建');
|
||||||
|
setModal({ open: false, item: null });
|
||||||
|
form.resetFields();
|
||||||
|
load();
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e?.errorFields) return;
|
||||||
|
message.error(e?.message || '保存失败');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (item: AdminTeam) => {
|
||||||
|
try {
|
||||||
|
await deleteAdminTeam(item.id);
|
||||||
|
message.success('团队已删除');
|
||||||
|
load();
|
||||||
|
} catch (e: any) {
|
||||||
|
message.error(e?.message || '删除失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{
|
||||||
|
title: '团队名称',
|
||||||
|
dataIndex: 'name',
|
||||||
|
width: 220,
|
||||||
|
render: (v: string, r: AdminTeam) => (
|
||||||
|
<Space>
|
||||||
|
<div style={{ width: 32, height: 32, borderRadius: 8, background: 'rgba(99,102,241,0.1)', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#6366f1' }}>
|
||||||
|
<TeamOutlined />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Typography.Text strong>{v}</Typography.Text>
|
||||||
|
<div style={{ fontSize: 12, color: '#94a3b8' }}>{r.code || '-'}</div>
|
||||||
|
</div>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '状态',
|
||||||
|
dataIndex: 'status',
|
||||||
|
width: 100,
|
||||||
|
render: (v: string) => <Tag color={v === 'active' ? 'green' : 'default'}>{statusLabel(v)}</Tag>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '成员数',
|
||||||
|
dataIndex: 'memberCount',
|
||||||
|
width: 100,
|
||||||
|
render: (v: number) => <Typography.Text strong>{Number(v || 0).toLocaleString()}</Typography.Text>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '排序',
|
||||||
|
dataIndex: 'sortOrder',
|
||||||
|
width: 90,
|
||||||
|
render: (v: number) => v ?? 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '备注',
|
||||||
|
dataIndex: 'description',
|
||||||
|
ellipsis: true,
|
||||||
|
render: (v: string) => v || '-',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '创建时间',
|
||||||
|
dataIndex: 'createdAt',
|
||||||
|
width: 160,
|
||||||
|
render: (v: string) => <Typography.Text type="secondary" style={{ fontSize: 12 }}>{formatDate(v)}</Typography.Text>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
key: 'action',
|
||||||
|
width: 160,
|
||||||
|
fixed: 'right' as const,
|
||||||
|
render: (_: any, r: AdminTeam) => (
|
||||||
|
<Space size={4}>
|
||||||
|
<Button type="link" size="small" icon={<EditOutlined />} onClick={() => openEdit(r)}>编辑</Button>
|
||||||
|
<Popconfirm
|
||||||
|
title="确定删除该团队?"
|
||||||
|
description={r.memberCount > 0 ? '该团队下仍有成员,后端会拒绝删除。' : '删除后团队不再出现在设置下拉中。'}
|
||||||
|
onConfirm={() => handleDelete(r)}
|
||||||
|
>
|
||||||
|
<Button type="link" size="small" danger icon={<DeleteOutlined />}>删除</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16, flexWrap: 'wrap', gap: 12 }}>
|
||||||
|
<Space wrap>
|
||||||
|
<Input
|
||||||
|
placeholder="搜索团队名称/编码/备注"
|
||||||
|
prefix={<SearchOutlined style={{ color: '#94a3b8' }} />}
|
||||||
|
value={keyword}
|
||||||
|
onChange={(e) => { setPage(1); setKeyword(e.target.value); }}
|
||||||
|
onPressEnter={load}
|
||||||
|
style={{ width: 260 }}
|
||||||
|
allowClear
|
||||||
|
/>
|
||||||
|
<Select value={status} onChange={(v) => { setPage(1); setStatus(v); }} style={{ width: 130 }} options={statusOptions} />
|
||||||
|
<Button icon={<ReloadOutlined />} onClick={load}>刷新</Button>
|
||||||
|
</Space>
|
||||||
|
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>新增团队</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Table
|
||||||
|
columns={columns}
|
||||||
|
dataSource={items}
|
||||||
|
rowKey="id"
|
||||||
|
loading={loading}
|
||||||
|
pagination={{
|
||||||
|
current: page,
|
||||||
|
pageSize,
|
||||||
|
total,
|
||||||
|
onChange: (p, ps) => { setPage(p); setPageSize(ps); },
|
||||||
|
showSizeChanger: true,
|
||||||
|
showTotal: (t) => `共 ${t} 个团队`,
|
||||||
|
}}
|
||||||
|
scroll={{ x: 1100 }}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title={<Space><TeamOutlined />{modal.item ? '编辑团队' : '新增团队'}</Space>}
|
||||||
|
open={modal.open}
|
||||||
|
confirmLoading={saving}
|
||||||
|
onOk={handleSave}
|
||||||
|
onCancel={() => { setModal({ open: false, item: null }); form.resetFields(); }}
|
||||||
|
okText="保存"
|
||||||
|
cancelText="取消"
|
||||||
|
width={520}
|
||||||
|
>
|
||||||
|
<Form form={form} layout="vertical" style={{ marginTop: 16 }}>
|
||||||
|
<Form.Item name="name" label="团队名称" rules={[{ required: true, message: '请输入团队名称' }]}>
|
||||||
|
<Input maxLength={128} placeholder="请输入团队名称" size="large" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="code" label="团队编码" extra="选填,用于内部标识或后续外部系统对接。">
|
||||||
|
<Input maxLength={64} placeholder="例如 sales_a" size="large" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="status" label="状态" rules={[{ required: true, message: '请选择状态' }]}>
|
||||||
|
<Select size="large" options={editStatusOptions} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="sortOrder" label="排序" initialValue={0}>
|
||||||
|
<InputNumber min={0} max={999999} precision={0} style={{ width: '100%' }} size="large" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="description" label="备注">
|
||||||
|
<Input.TextArea rows={3} maxLength={512} placeholder="选填" />
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AdminTeams;
|
||||||
@@ -3,7 +3,7 @@ import {
|
|||||||
Button, Card, Checkbox, Form, Input, InputNumber, message, Modal, Popconfirm, Progress, Select, Space, Switch, Table, Tabs, Tag, Typography,
|
Button, Card, Checkbox, Form, Input, InputNumber, message, Modal, Popconfirm, Progress, Select, Space, Switch, Table, Tabs, Tag, Typography,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import {
|
import {
|
||||||
UserOutlined, WalletOutlined, SearchOutlined, StopOutlined, CheckCircleOutlined, PlusOutlined, MenuOutlined, LockOutlined, SettingOutlined, SaveOutlined, DatabaseOutlined,
|
UserOutlined, WalletOutlined, SearchOutlined, StopOutlined, CheckCircleOutlined, PlusOutlined, MenuOutlined, LockOutlined, SettingOutlined, SaveOutlined, DatabaseOutlined, TeamOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import {
|
import {
|
||||||
adjustCredits,
|
adjustCredits,
|
||||||
@@ -11,18 +11,22 @@ import {
|
|||||||
deleteUserResourceCapacity,
|
deleteUserResourceCapacity,
|
||||||
getAdminUsers,
|
getAdminUsers,
|
||||||
getMenuConfigs,
|
getMenuConfigs,
|
||||||
|
getTeamOptions,
|
||||||
getSystemConfigs,
|
getSystemConfigs,
|
||||||
getUserResourceCapacity,
|
getUserResourceCapacity,
|
||||||
resetUserPassword,
|
resetUserPassword,
|
||||||
saveUserResourceCapacity,
|
saveUserResourceCapacity,
|
||||||
toggleUserStatus,
|
toggleUserStatus,
|
||||||
updateFrontendUserKind,
|
updateFrontendUserKind,
|
||||||
|
updateUserTeam,
|
||||||
updateSystemConfig,
|
updateSystemConfig,
|
||||||
updateUserMenus,
|
updateUserMenus,
|
||||||
} from '../api';
|
} from '../api';
|
||||||
import type { AdminUser, AdminUserResourceCapacityOut, ResourceCapacityUnit, ResourceCapacityUsage, SystemConfig } from '../types';
|
import type { AdminTeamOption, AdminUser, AdminUserResourceCapacityOut, ResourceCapacityUnit, ResourceCapacityUsage, SystemConfig } from '../types';
|
||||||
import { formatDate } from '../utils/formatDate';
|
import { formatDate } from '../utils/formatDate';
|
||||||
|
|
||||||
|
const TEAM_UNASSIGNED_VALUE = '__none__';
|
||||||
|
|
||||||
const capacityUnitOptions: { value: ResourceCapacityUnit; label: string }[] = [
|
const capacityUnitOptions: { value: ResourceCapacityUnit; label: string }[] = [
|
||||||
{ value: 'MB', label: 'MB(1024 × 1024 字节)' },
|
{ value: 'MB', label: 'MB(1024 × 1024 字节)' },
|
||||||
{ value: 'GB', label: 'GB(1024 × 1024 × 1024 字节)' },
|
{ value: 'GB', label: 'GB(1024 × 1024 × 1024 字节)' },
|
||||||
@@ -56,6 +60,8 @@ const AdminUsers: React.FC = () => {
|
|||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [activeTab, setActiveTab] = useState<string>('frontend');
|
const [activeTab, setActiveTab] = useState<string>('frontend');
|
||||||
const [frontendKindFilter, setFrontendKindFilter] = useState<string>('');
|
const [frontendKindFilter, setFrontendKindFilter] = useState<string>('');
|
||||||
|
const [teamFilter, setTeamFilter] = useState<string>('');
|
||||||
|
const [teamOptions, setTeamOptions] = useState<AdminTeamOption[]>([]);
|
||||||
const [creditModal, setCreditModal] = useState<{ open: boolean; user: AdminUser | null }>({ open: false, user: null });
|
const [creditModal, setCreditModal] = useState<{ open: boolean; user: AdminUser | null }>({ open: false, user: null });
|
||||||
const [createModal, setCreateModal] = useState(false);
|
const [createModal, setCreateModal] = useState(false);
|
||||||
const [createType, setCreateType] = useState<string>('frontend');
|
const [createType, setCreateType] = useState<string>('frontend');
|
||||||
@@ -64,12 +70,15 @@ const AdminUsers: React.FC = () => {
|
|||||||
const [checkedMenus, setCheckedMenus] = useState<string[]>([]);
|
const [checkedMenus, setCheckedMenus] = useState<string[]>([]);
|
||||||
const [resetPwdModal, setResetPwdModal] = useState<{ open: boolean; user: AdminUser | null }>({ open: false, user: null });
|
const [resetPwdModal, setResetPwdModal] = useState<{ open: boolean; user: AdminUser | null }>({ open: false, user: null });
|
||||||
const [capacityModal, setCapacityModal] = useState<{ open: boolean; user: AdminUser | null; detail: AdminUserResourceCapacityOut | null }>({ open: false, user: null, detail: null });
|
const [capacityModal, setCapacityModal] = useState<{ open: boolean; user: AdminUser | null; detail: AdminUserResourceCapacityOut | null }>({ open: false, user: null, detail: null });
|
||||||
|
const [teamModal, setTeamModal] = useState<{ open: boolean; user: AdminUser | null }>({ open: false, user: null });
|
||||||
const [capacityLoading, setCapacityLoading] = useState(false);
|
const [capacityLoading, setCapacityLoading] = useState(false);
|
||||||
const [capacitySaving, setCapacitySaving] = useState(false);
|
const [capacitySaving, setCapacitySaving] = useState(false);
|
||||||
|
const [teamSaving, setTeamSaving] = useState(false);
|
||||||
const [form] = Form.useForm();
|
const [form] = Form.useForm();
|
||||||
const [createForm] = Form.useForm();
|
const [createForm] = Form.useForm();
|
||||||
const [resetPwdForm] = Form.useForm();
|
const [resetPwdForm] = Form.useForm();
|
||||||
const [capacityForm] = Form.useForm();
|
const [capacityForm] = Form.useForm();
|
||||||
|
const [teamForm] = Form.useForm();
|
||||||
|
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [pageSize, setPageSize] = useState(20);
|
const [pageSize, setPageSize] = useState(20);
|
||||||
@@ -82,14 +91,25 @@ const AdminUsers: React.FC = () => {
|
|||||||
const load = async () => {
|
const load = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const data = await getAdminUsers(page, pageSize, search || undefined, activeTab, activeTab === 'frontend' ? (frontendKindFilter || undefined) : undefined);
|
const data = await getAdminUsers(
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
search || undefined,
|
||||||
|
activeTab,
|
||||||
|
activeTab === 'frontend' ? (frontendKindFilter || undefined) : undefined,
|
||||||
|
activeTab === 'frontend' ? (teamFilter || undefined) : undefined,
|
||||||
|
);
|
||||||
setUsers(data.items || []);
|
setUsers(data.items || []);
|
||||||
setTotal(data.total || 0);
|
setTotal(data.total || 0);
|
||||||
} catch { /* auth error handled by client */ }
|
} catch { /* auth error handled by client */ }
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => { load(); }, [page, pageSize, search, activeTab, frontendKindFilter]);
|
useEffect(() => { load(); }, [page, pageSize, search, activeTab, frontendKindFilter, teamFilter]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getTeamOptions(true).then(setTeamOptions).catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadCreditConfigs = async () => {
|
const loadCreditConfigs = async () => {
|
||||||
@@ -246,6 +266,30 @@ const AdminUsers: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const openTeamModal = (user: AdminUser) => {
|
||||||
|
teamForm.setFieldsValue({ teamId: user.teamId || '' });
|
||||||
|
setTeamModal({ open: true, user });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveTeam = async () => {
|
||||||
|
const { user } = teamModal;
|
||||||
|
if (!user) return;
|
||||||
|
try {
|
||||||
|
const values = await teamForm.validateFields();
|
||||||
|
setTeamSaving(true);
|
||||||
|
await updateUserTeam(user.id, values.teamId || null);
|
||||||
|
message.success('用户团队已更新');
|
||||||
|
setTeamModal({ open: false, user: null });
|
||||||
|
teamForm.resetFields();
|
||||||
|
load();
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e?.errorFields) return;
|
||||||
|
message.error(e?.message || '保存团队失败');
|
||||||
|
} finally {
|
||||||
|
setTeamSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const menuGroups = allMenus.filter((m: any) => (m.menu_type ?? m.menuType) === 'group');
|
const menuGroups = allMenus.filter((m: any) => (m.menu_type ?? m.menuType) === 'group');
|
||||||
const menuPages = allMenus.filter((m: any) => (m.menu_type ?? m.menuType) !== 'group');
|
const menuPages = allMenus.filter((m: any) => (m.menu_type ?? m.menuType) !== 'group');
|
||||||
const childMap: Record<string, any[]> = {};
|
const childMap: Record<string, any[]> = {};
|
||||||
@@ -332,6 +376,10 @@ const AdminUsers: React.FC = () => {
|
|||||||
title: '前台归类', dataIndex: 'frontendUserKind', width: 110,
|
title: '前台归类', dataIndex: 'frontendUserKind', width: 110,
|
||||||
render: (v: string) => <Tag color={v === 'internal' ? 'geekblue' : 'default'}>{v === 'internal' ? '内部用户' : '外部用户'}</Tag>,
|
render: (v: string) => <Tag color={v === 'internal' ? 'geekblue' : 'default'}>{v === 'internal' ? '内部用户' : '外部用户'}</Tag>,
|
||||||
}] : []),
|
}] : []),
|
||||||
|
...(!isAdminTab ? [{
|
||||||
|
title: '团队', dataIndex: 'teamName', width: 140,
|
||||||
|
render: (v: string | null | undefined) => v ? <Tag color="blue">{v}</Tag> : <Typography.Text type="secondary">未分配</Typography.Text>,
|
||||||
|
}] : []),
|
||||||
...(!isAdminTab ? [{
|
...(!isAdminTab ? [{
|
||||||
title: '资源容量', dataIndex: 'resourceCapacity', width: 230,
|
title: '资源容量', dataIndex: 'resourceCapacity', width: 230,
|
||||||
render: (capacity: ResourceCapacityUsage | null | undefined) => {
|
render: (capacity: ResourceCapacityUsage | null | undefined) => {
|
||||||
@@ -377,7 +425,7 @@ const AdminUsers: React.FC = () => {
|
|||||||
render: (v: string) => <Typography.Text type="secondary" style={{ fontSize: 12 }}>{formatDate(v)}</Typography.Text>,
|
render: (v: string) => <Typography.Text type="secondary" style={{ fontSize: 12 }}>{formatDate(v)}</Typography.Text>,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '操作', key: 'action', width: 390, fixed: 'right' as const,
|
title: '操作', key: 'action', width: 460, fixed: 'right' as const,
|
||||||
render: (_: any, r: AdminUser) => (
|
render: (_: any, r: AdminUser) => (
|
||||||
<Space size={4} wrap>
|
<Space size={4} wrap>
|
||||||
{!isAdminTab && (
|
{!isAdminTab && (
|
||||||
@@ -392,6 +440,12 @@ const AdminUsers: React.FC = () => {
|
|||||||
容量设置
|
容量设置
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
{!isAdminTab && (
|
||||||
|
<Button type="link" size="small" icon={<TeamOutlined />}
|
||||||
|
onClick={() => openTeamModal(r)}>
|
||||||
|
团队设置
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
{!isAdminTab && r.frontendUserKind !== 'internal' && (
|
{!isAdminTab && r.frontendUserKind !== 'internal' && (
|
||||||
<Button type="link" size="small" onClick={() => handleUpdateFrontendKind(r, 'internal')}>设为内部</Button>
|
<Button type="link" size="small" onClick={() => handleUpdateFrontendKind(r, 'internal')}>设为内部</Button>
|
||||||
)}
|
)}
|
||||||
@@ -500,6 +554,18 @@ const AdminUsers: React.FC = () => {
|
|||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{!isAdminTab && (
|
||||||
|
<Select
|
||||||
|
value={teamFilter}
|
||||||
|
onChange={(v) => { setPage(1); setTeamFilter(v); }}
|
||||||
|
style={{ width: 170 }}
|
||||||
|
options={[
|
||||||
|
{ value: '', label: '全部团队' },
|
||||||
|
{ value: TEAM_UNASSIGNED_VALUE, label: '未分配团队' },
|
||||||
|
...teamOptions.map(t => ({ value: t.id, label: t.status === 'disabled' ? `${t.name}(禁用)` : t.name })),
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<Button type="primary" onClick={handleSearch} style={{ borderRadius: 8 }}>搜索</Button>
|
<Button type="primary" onClick={handleSearch} style={{ borderRadius: 8 }}>搜索</Button>
|
||||||
</div>
|
</div>
|
||||||
<Button type="primary" icon={<PlusOutlined />}
|
<Button type="primary" icon={<PlusOutlined />}
|
||||||
@@ -511,7 +577,7 @@ const AdminUsers: React.FC = () => {
|
|||||||
|
|
||||||
<Tabs
|
<Tabs
|
||||||
activeKey={activeTab}
|
activeKey={activeTab}
|
||||||
onChange={(key) => { setActiveTab(key); setFrontendKindFilter(''); setPage(1); }}
|
onChange={(key) => { setActiveTab(key); setFrontendKindFilter(''); setTeamFilter(''); setPage(1); }}
|
||||||
items={[
|
items={[
|
||||||
{ key: 'frontend', label: '前台用户' },
|
{ key: 'frontend', label: '前台用户' },
|
||||||
{ key: 'admin', label: '后台用户' },
|
{ key: 'admin', label: '后台用户' },
|
||||||
@@ -565,6 +631,31 @@ const AdminUsers: React.FC = () => {
|
|||||||
</Form>
|
</Form>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title={<Space><TeamOutlined />团队设置 - {teamModal.user?.username}</Space>}
|
||||||
|
open={teamModal.open}
|
||||||
|
confirmLoading={teamSaving}
|
||||||
|
onOk={handleSaveTeam}
|
||||||
|
onCancel={() => { setTeamModal({ open: false, user: null }); teamForm.resetFields(); }}
|
||||||
|
okText="保存" cancelText="取消" width={460}
|
||||||
|
>
|
||||||
|
<Form form={teamForm} layout="vertical" style={{ marginTop: 16 }}>
|
||||||
|
<Form.Item name="teamId" label="所属团队" extra="团队归属独立于前台内部/外部归类,不会改变用户内外部设置。">
|
||||||
|
<Select
|
||||||
|
size="large"
|
||||||
|
allowClear
|
||||||
|
placeholder="未分配团队"
|
||||||
|
options={[
|
||||||
|
{ value: '', label: '未分配团队' },
|
||||||
|
...teamOptions
|
||||||
|
.filter(t => t.status === 'active' || t.id === teamModal.user?.teamId)
|
||||||
|
.map(t => ({ value: t.id, label: t.status === 'disabled' ? `${t.name}(禁用)` : t.name })),
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
title={<Space><DatabaseOutlined />容量设置 - {capacityModal.user?.username}</Space>}
|
title={<Space><DatabaseOutlined />容量设置 - {capacityModal.user?.username}</Space>}
|
||||||
open={capacityModal.open}
|
open={capacityModal.open}
|
||||||
|
|||||||
@@ -133,6 +133,48 @@ export interface AdminUserResourceCapacityOut {
|
|||||||
|
|
||||||
// ── Admin Types ──────────────────────────────────────
|
// ── Admin Types ──────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
export type AdminTeamStatus = 'active' | 'disabled' | string;
|
||||||
|
|
||||||
|
export interface AdminTeam {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
code?: string | null;
|
||||||
|
description?: string | null;
|
||||||
|
status: AdminTeamStatus;
|
||||||
|
sortOrder: number;
|
||||||
|
memberCount: number;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminTeamOption {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
code?: string | null;
|
||||||
|
status: AdminTeamStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminTeamListResponse {
|
||||||
|
items: AdminTeam[];
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminTeamQueryParams {
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
keyword?: string;
|
||||||
|
status?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminTeamPayload {
|
||||||
|
name: string;
|
||||||
|
code?: string | null;
|
||||||
|
description?: string | null;
|
||||||
|
status: 'active' | 'disabled';
|
||||||
|
sort_order: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface AdminUser {
|
export interface AdminUser {
|
||||||
id: string;
|
id: string;
|
||||||
username: string;
|
username: string;
|
||||||
@@ -143,6 +185,8 @@ export interface AdminUser {
|
|||||||
isAdmin: boolean;
|
isAdmin: boolean;
|
||||||
userType: string;
|
userType: string;
|
||||||
frontendUserKind: 'internal' | 'external';
|
frontendUserKind: 'internal' | 'external';
|
||||||
|
teamId?: string | null;
|
||||||
|
teamName?: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
lastLoginAt?: string;
|
lastLoginAt?: string;
|
||||||
allowedMenus?: string[] | null;
|
allowedMenus?: string[] | null;
|
||||||
@@ -744,6 +788,8 @@ export interface AdminCreditRecord {
|
|||||||
userTypeLabel?: string;
|
userTypeLabel?: string;
|
||||||
frontendUserKind?: string;
|
frontendUserKind?: string;
|
||||||
frontendUserKindLabel?: string;
|
frontendUserKindLabel?: string;
|
||||||
|
teamIdSnapshot?: string | null;
|
||||||
|
teamNameSnapshot?: string | null;
|
||||||
type: string;
|
type: string;
|
||||||
recordType: string;
|
recordType: string;
|
||||||
recordTypeLabel?: string;
|
recordTypeLabel?: string;
|
||||||
@@ -798,6 +844,7 @@ export interface AdminCreditRecordQueryParams {
|
|||||||
userName?: string;
|
userName?: string;
|
||||||
userType?: string;
|
userType?: string;
|
||||||
frontendUserKind?: string;
|
frontendUserKind?: string;
|
||||||
|
teamId?: string;
|
||||||
recordType?: string;
|
recordType?: string;
|
||||||
type?: string;
|
type?: string;
|
||||||
creditSubject?: string;
|
creditSubject?: string;
|
||||||
|
|||||||
@@ -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/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/adminhotopeningreplicationdetail.tsx","./src/pages/adminhotopeningreplications.tsx","./src/pages/adminimageengines.tsx","./src/pages/adminindustries.tsx","./src/pages/adminlayout.tsx","./src/pages/adminloginpage.tsx","./src/pages/adminmenuconfig.tsx","./src/pages/adminmodels.tsx","./src/pages/adminnotificationmanager.tsx","./src/pages/adminoauthapplist.tsx","./src/pages/adminoperationlogs.tsx","./src/pages/adminpaymentconfig.tsx","./src/pages/adminpaymentstats.tsx","./src/pages/adminrechargepackages.tsx","./src/pages/adminreplicationprojectdetail.tsx","./src/pages/adminsettings.tsx","./src/pages/adminshotreplications.tsx","./src/pages/adminshottasksetdetail.tsx","./src/pages/adminusers.tsx","./src/pages/adminvideoengines.tsx","./src/pages/adminvideopromptschemaconfig.tsx","./src/pages/adminplatform.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/store/index.ts","./src/types/index.ts","./src/types/xlsx-js-style.d.ts","./src/utils/excelexport.ts","./src/utils/formatdate.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/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/adminhotopeningreplicationdetail.tsx","./src/pages/adminhotopeningreplications.tsx","./src/pages/adminimageengines.tsx","./src/pages/adminindustries.tsx","./src/pages/adminlayout.tsx","./src/pages/adminloginpage.tsx","./src/pages/adminmenuconfig.tsx","./src/pages/adminmodels.tsx","./src/pages/adminnotificationmanager.tsx","./src/pages/adminoauthapplist.tsx","./src/pages/adminoperationlogs.tsx","./src/pages/adminpaymentconfig.tsx","./src/pages/adminpaymentstats.tsx","./src/pages/adminplatform.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/adminreplication/components/jsoncollapse.tsx","./src/pages/adminreplication/components/mediapreview.tsx","./src/pages/adminreplication/components/statustag.tsx","./src/pages/adminreplication/components/videopromptschemaviewer.tsx","./src/store/index.ts","./src/types/index.ts","./src/types/xlsx-js-style.d.ts","./src/utils/excelexport.ts","./src/utils/formatdate.ts","./src/utils/videopromptschema.ts"],"version":"6.0.3"}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
"""add team management
|
||||||
|
|
||||||
|
Revision ID: 170005531deb
|
||||||
|
Revises: 78fb32c26a6e
|
||||||
|
Create Date: 2026-06-30 11:07:14.278871
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = '170005531deb'
|
||||||
|
down_revision: Union[str, None] = '78fb32c26a6e'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.create_table('teams',
|
||||||
|
sa.Column('id', sa.String(length=32), nullable=False),
|
||||||
|
sa.Column('name', sa.String(length=128), nullable=False, comment='团队名称'),
|
||||||
|
sa.Column('code', sa.String(length=64), nullable=True, comment='团队编码'),
|
||||||
|
sa.Column('description', sa.String(length=512), nullable=True, comment='团队备注'),
|
||||||
|
sa.Column('status', sa.String(length=16), server_default='active', nullable=False, comment='团队状态:active启用,disabled禁用'),
|
||||||
|
sa.Column('sort_order', sa.Integer(), server_default='0', nullable=False, comment='排序值,越小越靠前'),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_index('ix_teams_code', 'teams', ['code'], unique=False)
|
||||||
|
op.create_index(op.f('ix_teams_deleted_at'), 'teams', ['deleted_at'], unique=False)
|
||||||
|
op.create_index(op.f('ix_teams_name'), 'teams', ['name'], unique=False)
|
||||||
|
op.create_index(op.f('ix_teams_sort_order'), 'teams', ['sort_order'], unique=False)
|
||||||
|
op.create_index(op.f('ix_teams_status'), 'teams', ['status'], unique=False)
|
||||||
|
op.create_index('ix_teams_status_sort', 'teams', ['status', 'sort_order', 'created_at'], unique=False)
|
||||||
|
op.drop_index(op.f('ix_contact_requests_phone'), table_name='contact_requests')
|
||||||
|
op.drop_index(op.f('ix_contact_requests_user_id'), table_name='contact_requests')
|
||||||
|
op.drop_table('contact_requests')
|
||||||
|
op.add_column('credit_records', sa.Column('team_id_snapshot', sa.String(length=32), nullable=True))
|
||||||
|
op.add_column('credit_records', sa.Column('team_name_snapshot', sa.String(length=128), nullable=True))
|
||||||
|
op.drop_index(op.f('ix_credit_records_engine_type'), table_name='credit_records')
|
||||||
|
op.create_index(op.f('ix_credit_records_team_id_snapshot'), 'credit_records', ['team_id_snapshot'], unique=False)
|
||||||
|
op.create_index('ix_credit_records_team_time', 'credit_records', ['team_id_snapshot', 'created_at'], unique=False)
|
||||||
|
op.create_index('ix_credit_records_user_kind_team_time', 'credit_records', ['user_type_snapshot', 'frontend_user_kind_snapshot', 'team_id_snapshot', 'created_at'], unique=False)
|
||||||
|
op.create_index('idx_genrec_project_status', 'generation_records', ['project_id', 'status'], unique=False)
|
||||||
|
op.create_index('idx_genrec_user_status_created', 'generation_records', ['user_id', 'status', 'created_at'], unique=False)
|
||||||
|
op.create_index('idx_menu_target_active_sort', 'menu_configs', ['menu_target', 'is_active', 'sort_order'], unique=False)
|
||||||
|
op.create_index(op.f('ix_menu_configs_parent_id'), 'menu_configs', ['parent_id'], unique=False)
|
||||||
|
op.create_index('idx_oplog_created', 'operation_logs', ['created_at'], unique=False)
|
||||||
|
op.create_index('idx_oplog_user_created', 'operation_logs', ['user_id', 'created_at'], unique=False)
|
||||||
|
op.create_index('idx_payorder_status_created', 'payment_orders', ['status', 'created_at'], unique=False)
|
||||||
|
op.create_index('idx_payorder_user_status_created', 'payment_orders', ['user_id', 'status', 'created_at'], unique=False)
|
||||||
|
op.create_index('idx_upload_oauth_status', 'upload_task', ['oauth_id', 'status'], unique=False)
|
||||||
|
op.create_index('idx_upload_user_status_created', 'upload_task', ['user_id', 'status', 'created_at'], unique=False)
|
||||||
|
op.add_column('users', sa.Column('team_id', sa.String(length=32), nullable=True))
|
||||||
|
op.create_index(op.f('ix_users_team_id'), 'users', ['team_id'], unique=False)
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_index(op.f('ix_users_team_id'), table_name='users')
|
||||||
|
op.drop_column('users', 'team_id')
|
||||||
|
op.drop_index('idx_upload_user_status_created', table_name='upload_task')
|
||||||
|
op.drop_index('idx_upload_oauth_status', table_name='upload_task')
|
||||||
|
op.drop_index('idx_payorder_user_status_created', table_name='payment_orders')
|
||||||
|
op.drop_index('idx_payorder_status_created', table_name='payment_orders')
|
||||||
|
op.drop_index('idx_oplog_user_created', table_name='operation_logs')
|
||||||
|
op.drop_index('idx_oplog_created', table_name='operation_logs')
|
||||||
|
op.drop_index(op.f('ix_menu_configs_parent_id'), table_name='menu_configs')
|
||||||
|
op.drop_index('idx_menu_target_active_sort', table_name='menu_configs')
|
||||||
|
op.drop_index('idx_genrec_user_status_created', table_name='generation_records')
|
||||||
|
op.drop_index('idx_genrec_project_status', table_name='generation_records')
|
||||||
|
op.drop_index('ix_credit_records_user_kind_team_time', table_name='credit_records')
|
||||||
|
op.drop_index('ix_credit_records_team_time', table_name='credit_records')
|
||||||
|
op.drop_index(op.f('ix_credit_records_team_id_snapshot'), table_name='credit_records')
|
||||||
|
op.create_index(op.f('ix_credit_records_engine_type'), 'credit_records', ['engine_type'], unique=False)
|
||||||
|
op.drop_column('credit_records', 'team_name_snapshot')
|
||||||
|
op.drop_column('credit_records', 'team_id_snapshot')
|
||||||
|
op.create_table('contact_requests',
|
||||||
|
sa.Column('id', sa.VARCHAR(length=32), autoincrement=False, nullable=False),
|
||||||
|
sa.Column('user_id', sa.VARCHAR(length=32), autoincrement=False, nullable=False),
|
||||||
|
sa.Column('phone', sa.VARCHAR(length=20), autoincrement=False, nullable=False),
|
||||||
|
sa.Column('company_name', sa.VARCHAR(length=128), autoincrement=False, nullable=False),
|
||||||
|
sa.Column('industry', sa.VARCHAR(length=64), autoincrement=False, nullable=False),
|
||||||
|
sa.Column('name', sa.VARCHAR(length=64), autoincrement=False, nullable=False),
|
||||||
|
sa.Column('message', sa.TEXT(), autoincrement=False, nullable=True),
|
||||||
|
sa.Column('is_handled', sa.BOOLEAN(), autoincrement=False, nullable=False),
|
||||||
|
sa.Column('created_at', postgresql.TIMESTAMP(timezone=True, precision=6), server_default=sa.text('now()'), autoincrement=False, nullable=False),
|
||||||
|
sa.Column('updated_at', postgresql.TIMESTAMP(timezone=True, precision=6), server_default=sa.text('now()'), autoincrement=False, nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['user_id'], ['users.id'], name=op.f('contact_requests_user_id_fkey')),
|
||||||
|
sa.PrimaryKeyConstraint('id', name=op.f('contact_requests_pkey'))
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_contact_requests_user_id'), 'contact_requests', ['user_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_contact_requests_phone'), 'contact_requests', ['phone'], unique=False)
|
||||||
|
op.drop_index('ix_teams_status_sort', table_name='teams')
|
||||||
|
op.drop_index(op.f('ix_teams_status'), table_name='teams')
|
||||||
|
op.drop_index(op.f('ix_teams_sort_order'), table_name='teams')
|
||||||
|
op.drop_index(op.f('ix_teams_name'), table_name='teams')
|
||||||
|
op.drop_index(op.f('ix_teams_deleted_at'), table_name='teams')
|
||||||
|
op.drop_index('ix_teams_code', table_name='teams')
|
||||||
|
op.drop_table('teams')
|
||||||
|
# ### end Alembic commands ###
|
||||||
@@ -2,7 +2,9 @@ from fastapi import APIRouter
|
|||||||
|
|
||||||
from app.api.admin.video_prompt_schema_config import router as video_prompt_schema_config_router
|
from app.api.admin.video_prompt_schema_config import router as video_prompt_schema_config_router
|
||||||
from app.api.admin.resource_capacity import router as resource_capacity_router
|
from app.api.admin.resource_capacity import router as resource_capacity_router
|
||||||
|
from app.api.admin.team import router as team_router
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
router.include_router(video_prompt_schema_config_router)
|
router.include_router(video_prompt_schema_config_router)
|
||||||
router.include_router(resource_capacity_router)
|
router.include_router(resource_capacity_router)
|
||||||
|
router.include_router(team_router)
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Query
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.dependencies import get_admin_user, get_db
|
||||||
|
from app.models.user import User
|
||||||
|
from app.schemas.team import TeamCreate, TeamListOut, TeamOptionOut, TeamOut, TeamUpdate
|
||||||
|
from app.services.operation_log import log_operation
|
||||||
|
from app.services.team_service import create_team, list_team_options, list_teams, soft_delete_team, update_team
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/admin/teams", tags=["admin-teams"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=TeamListOut)
|
||||||
|
async def list_admin_teams(
|
||||||
|
page: int = Query(1, ge=1),
|
||||||
|
page_size: int = Query(20, ge=1, le=500),
|
||||||
|
keyword: str | None = Query(None),
|
||||||
|
status: str | None = Query(None, pattern="^(active|disabled)$"),
|
||||||
|
admin: User = Depends(get_admin_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
return await list_teams(db, page=page, page_size=page_size, keyword=keyword, status=status)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/options", response_model=list[TeamOptionOut])
|
||||||
|
async def list_admin_team_options(
|
||||||
|
include_disabled: bool = Query(True),
|
||||||
|
admin: User = Depends(get_admin_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
return await list_team_options(db, include_disabled=include_disabled)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=TeamOut)
|
||||||
|
async def create_admin_team(
|
||||||
|
req: TeamCreate,
|
||||||
|
admin: User = Depends(get_admin_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
team = await create_team(db, req)
|
||||||
|
await log_operation(
|
||||||
|
db,
|
||||||
|
admin.id,
|
||||||
|
admin.username,
|
||||||
|
f"创建团队 {team.name}",
|
||||||
|
"POST",
|
||||||
|
"/admin/teams",
|
||||||
|
detail=json.dumps({"after": req.model_dump()}, ensure_ascii=False),
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"id": team.id,
|
||||||
|
"name": team.name,
|
||||||
|
"code": team.code,
|
||||||
|
"description": team.description,
|
||||||
|
"status": team.status,
|
||||||
|
"sort_order": team.sort_order,
|
||||||
|
"member_count": 0,
|
||||||
|
"created_at": team.created_at,
|
||||||
|
"updated_at": team.updated_at,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{team_id}", response_model=TeamOut)
|
||||||
|
async def update_admin_team(
|
||||||
|
team_id: str,
|
||||||
|
req: TeamUpdate,
|
||||||
|
admin: User = Depends(get_admin_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
team, before, after = await update_team(db, team_id, req)
|
||||||
|
await log_operation(
|
||||||
|
db,
|
||||||
|
admin.id,
|
||||||
|
admin.username,
|
||||||
|
f"修改团队 {team.name}",
|
||||||
|
"PUT",
|
||||||
|
f"/admin/teams/{team_id}",
|
||||||
|
detail=json.dumps({"before": before, "after": after}, ensure_ascii=False),
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"id": team.id,
|
||||||
|
"name": team.name,
|
||||||
|
"code": team.code,
|
||||||
|
"description": team.description,
|
||||||
|
"status": team.status,
|
||||||
|
"sort_order": team.sort_order,
|
||||||
|
"member_count": 0,
|
||||||
|
"created_at": team.created_at,
|
||||||
|
"updated_at": team.updated_at,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{team_id}")
|
||||||
|
async def delete_admin_team(
|
||||||
|
team_id: str,
|
||||||
|
admin: User = Depends(get_admin_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
team, before = await soft_delete_team(db, team_id)
|
||||||
|
await log_operation(
|
||||||
|
db,
|
||||||
|
admin.id,
|
||||||
|
admin.username,
|
||||||
|
f"删除团队 {team.name}",
|
||||||
|
"DELETE",
|
||||||
|
f"/admin/teams/{team_id}",
|
||||||
|
detail=json.dumps({"before": before, "after": {"deleted_at": str(team.deleted_at)}}, ensure_ascii=False),
|
||||||
|
)
|
||||||
|
return {"message": "ok"}
|
||||||
@@ -23,6 +23,7 @@ from app.models.image_engine import ImageEngine
|
|||||||
from app.models.credit_ratio import CreditRatio
|
from app.models.credit_ratio import CreditRatio
|
||||||
from app.models.operation_log import OperationLog
|
from app.models.operation_log import OperationLog
|
||||||
from app.enums.user import FrontendUserKind, UserType
|
from app.enums.user import FrontendUserKind, UserType
|
||||||
|
from app.enums.team import TEAM_UNASSIGNED_VALUE
|
||||||
from app.schemas.admin import (
|
from app.schemas.admin import (
|
||||||
CreditAdjustRequest,
|
CreditAdjustRequest,
|
||||||
ModelConfigCreate,
|
ModelConfigCreate,
|
||||||
@@ -37,6 +38,7 @@ from app.schemas.admin import (
|
|||||||
UpdateFrontendUserKindRequest,
|
UpdateFrontendUserKindRequest,
|
||||||
OperationLogOut,
|
OperationLogOut,
|
||||||
)
|
)
|
||||||
|
from app.schemas.team import UpdateUserTeamRequest
|
||||||
from app.schemas.industry import IndustryConfigCreate, IndustryConfigOut
|
from app.schemas.industry import IndustryConfigCreate, IndustryConfigOut
|
||||||
from app.schemas.video_engine import VideoEngineCreate, VideoEngineOut
|
from app.schemas.video_engine import VideoEngineCreate, VideoEngineOut
|
||||||
from app.schemas.image_engine import ImageEngineCreate, ImageEngineOut
|
from app.schemas.image_engine import ImageEngineCreate, ImageEngineOut
|
||||||
@@ -50,6 +52,7 @@ from app.services.operation_log import log_operation
|
|||||||
from app.services.resource_signed_url_service import build_resource_signed_url
|
from app.services.resource_signed_url_service import build_resource_signed_url
|
||||||
from app.services.payment import sync_pending_orders, process_refund
|
from app.services.payment import sync_pending_orders, process_refund
|
||||||
from app.services.resource_capacity_service import batch_get_user_resource_capacity_usage, get_user_resource_capacity_usage
|
from app.services.resource_capacity_service import batch_get_user_resource_capacity_usage, get_user_resource_capacity_usage
|
||||||
|
from app.services.team_service import batch_get_team_name_map, set_frontend_user_team
|
||||||
|
|
||||||
from app.services.generation_billing_service import (
|
from app.services.generation_billing_service import (
|
||||||
OWNER_GENERATION_RECORD,
|
OWNER_GENERATION_RECORD,
|
||||||
@@ -89,6 +92,7 @@ async def list_users(
|
|||||||
search: str = Query(""),
|
search: str = Query(""),
|
||||||
user_type: str | None = Query(None, pattern="^(frontend|admin)$"),
|
user_type: str | None = Query(None, pattern="^(frontend|admin)$"),
|
||||||
frontend_user_kind: str | None = Query(None, pattern="^(internal|external)$"),
|
frontend_user_kind: str | None = Query(None, pattern="^(internal|external)$"),
|
||||||
|
team_id: str | None = Query(None),
|
||||||
admin: User = Depends(get_admin_user),
|
admin: User = Depends(get_admin_user),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
@@ -105,16 +109,28 @@ async def list_users(
|
|||||||
if frontend_user_kind:
|
if frontend_user_kind:
|
||||||
query = query.where(User.user_type == UserType.FRONTEND.value, User.frontend_user_kind == frontend_user_kind)
|
query = query.where(User.user_type == UserType.FRONTEND.value, User.frontend_user_kind == frontend_user_kind)
|
||||||
count_query = count_query.where(User.user_type == UserType.FRONTEND.value, User.frontend_user_kind == frontend_user_kind)
|
count_query = count_query.where(User.user_type == UserType.FRONTEND.value, User.frontend_user_kind == frontend_user_kind)
|
||||||
|
if team_id:
|
||||||
|
team_filter = User.team_id.is_(None) if team_id == TEAM_UNASSIGNED_VALUE else (User.team_id == team_id)
|
||||||
|
query = query.where(team_filter)
|
||||||
|
count_query = count_query.where(team_filter)
|
||||||
total = (await db.execute(count_query)).scalar() or 0
|
total = (await db.execute(count_query)).scalar() or 0
|
||||||
result = await db.execute(query.offset((page - 1) * page_size).limit(page_size))
|
result = await db.execute(query.offset((page - 1) * page_size).limit(page_size))
|
||||||
items = result.scalars().all()
|
users = list(result.scalars().all())
|
||||||
capacity_map = await batch_get_user_resource_capacity_usage(db, [u.id for u in items])
|
user_ids = [u.id for u in users]
|
||||||
|
team_ids = [getattr(u, "team_id", None) for u in users if getattr(u, "team_id", None)]
|
||||||
|
capacity_map = await batch_get_user_resource_capacity_usage(db, user_ids)
|
||||||
|
team_name_map = await batch_get_team_name_map(db, team_ids)
|
||||||
return {
|
return {
|
||||||
"items": [
|
"items": [
|
||||||
AdminUserOut.model_validate(u)
|
AdminUserOut.model_validate(user)
|
||||||
.model_copy(update={"resource_capacity": capacity_map.get(u.id)})
|
.model_copy(
|
||||||
|
update={
|
||||||
|
"resource_capacity": capacity_map.get(user.id),
|
||||||
|
"team_name": team_name_map.get(getattr(user, "team_id", None)),
|
||||||
|
}
|
||||||
|
)
|
||||||
.model_dump(mode="json")
|
.model_dump(mode="json")
|
||||||
for u in items
|
for user in users
|
||||||
],
|
],
|
||||||
"total": total,
|
"total": total,
|
||||||
}
|
}
|
||||||
@@ -194,8 +210,12 @@ async def get_user(
|
|||||||
raise HTTPException(status_code=404, detail="用户不存在")
|
raise HTTPException(status_code=404, detail="用户不存在")
|
||||||
user.credits = round(user.credits, 2)
|
user.credits = round(user.credits, 2)
|
||||||
resource_capacity = await get_user_resource_capacity_usage(db, user.id)
|
resource_capacity = await get_user_resource_capacity_usage(db, user.id)
|
||||||
|
team_name_map = await batch_get_team_name_map(db, [getattr(user, "team_id", None)])
|
||||||
return AdminUserOut.model_validate(user).model_copy(
|
return AdminUserOut.model_validate(user).model_copy(
|
||||||
update={"resource_capacity": resource_capacity}
|
update={
|
||||||
|
"resource_capacity": resource_capacity,
|
||||||
|
"team_name": team_name_map.get(getattr(user, "team_id", None)),
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -254,6 +274,29 @@ async def update_user_frontend_kind(
|
|||||||
return user
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/users/{user_id}/team", response_model=AdminUserOut)
|
||||||
|
async def update_user_team(
|
||||||
|
user_id: str,
|
||||||
|
req: UpdateUserTeamRequest,
|
||||||
|
admin: User = Depends(get_admin_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
user, before, after = await set_frontend_user_team(db, user_id=user_id, team_id=req.team_id)
|
||||||
|
await log_operation(
|
||||||
|
db,
|
||||||
|
admin.id,
|
||||||
|
admin.username,
|
||||||
|
f"设置用户团队 {user.username}",
|
||||||
|
"PUT",
|
||||||
|
f"/admin/users/{user_id}/team",
|
||||||
|
detail=json.dumps({"before": before, "after": after}, ensure_ascii=False),
|
||||||
|
)
|
||||||
|
resource_capacity = await get_user_resource_capacity_usage(db, user.id)
|
||||||
|
return AdminUserOut.model_validate(user).model_copy(
|
||||||
|
update={"resource_capacity": resource_capacity, "team_name": after.get("team_name")}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.put("/users/{user_id}/reset-password")
|
@router.put("/users/{user_id}/reset-password")
|
||||||
async def reset_user_password(
|
async def reset_user_password(
|
||||||
user_id: str,
|
user_id: str,
|
||||||
@@ -299,6 +342,7 @@ async def list_credit_records(
|
|||||||
user_name: str | None = Query(None),
|
user_name: str | None = Query(None),
|
||||||
user_type: str | None = Query(None),
|
user_type: str | None = Query(None),
|
||||||
frontend_user_kind: str | None = Query(None),
|
frontend_user_kind: str | None = Query(None),
|
||||||
|
team_id: str | None = Query(None),
|
||||||
record_type: str | None = Query(None),
|
record_type: str | None = Query(None),
|
||||||
type: str | None = Query(None),
|
type: str | None = Query(None),
|
||||||
credit_subject: str | None = Query(None),
|
credit_subject: str | None = Query(None),
|
||||||
@@ -321,6 +365,7 @@ async def list_credit_records(
|
|||||||
user_name=user_name,
|
user_name=user_name,
|
||||||
user_type=user_type,
|
user_type=user_type,
|
||||||
frontend_user_kind=frontend_user_kind,
|
frontend_user_kind=frontend_user_kind,
|
||||||
|
team_id=team_id,
|
||||||
record_type=record_type or type,
|
record_type=record_type or type,
|
||||||
credit_subject=credit_subject,
|
credit_subject=credit_subject,
|
||||||
media_type=media_type,
|
media_type=media_type,
|
||||||
|
|||||||
@@ -11,3 +11,4 @@ from app.enums.generation_status import *
|
|||||||
from app.enums.sms import *
|
from app.enums.sms import *
|
||||||
from app.enums.notification import *
|
from app.enums.notification import *
|
||||||
from app.enums.resource_capacity import *
|
from app.enums.resource_capacity import *
|
||||||
|
from app.enums.team import *
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
|
||||||
|
class TeamStatus(str, Enum):
|
||||||
|
ACTIVE = "active"
|
||||||
|
DISABLED = "disabled"
|
||||||
|
|
||||||
|
|
||||||
|
TEAM_STATUS_LABELS = {
|
||||||
|
TeamStatus.ACTIVE.value: "启用",
|
||||||
|
TeamStatus.DISABLED.value: "禁用",
|
||||||
|
}
|
||||||
|
|
||||||
|
# 前端筛选“未分配团队”时使用的稳定哨兵值,不与真实团队ID混用。
|
||||||
|
TEAM_UNASSIGNED_VALUE = "__none__"
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
from app.models.base import Base, TimestampMixin, SoftDeleteMixin, engine, async_session, init_database, close_database
|
from app.models.base import Base, TimestampMixin, SoftDeleteMixin, engine, async_session, init_database, close_database
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
|
from app.models.team import Team
|
||||||
from app.models.project import Project
|
from app.models.project import Project
|
||||||
from app.models.generation_record import GenerationRecord
|
from app.models.generation_record import GenerationRecord
|
||||||
from app.models.credit_record import CreditRecord
|
from app.models.credit_record import CreditRecord
|
||||||
@@ -33,7 +34,7 @@ from app.models.user_oauth_app import UserOAuthApp
|
|||||||
__all__ = [
|
__all__ = [
|
||||||
"Base", "TimestampMixin", "SoftDeleteMixin", "engine", "async_session",
|
"Base", "TimestampMixin", "SoftDeleteMixin", "engine", "async_session",
|
||||||
"init_database", "close_database",
|
"init_database", "close_database",
|
||||||
"User", "Project", "GenerationRecord", "CreditRecord",
|
"User", "Team", "Project", "GenerationRecord", "CreditRecord",
|
||||||
"ModelConfig", "SystemConfig", "Notification", "PaymentOrder",
|
"ModelConfig", "SystemConfig", "Notification", "PaymentOrder",
|
||||||
"TokenUsage", "IndustryConfig", "VideoEngine", "CreditRatio",
|
"TokenUsage", "IndustryConfig", "VideoEngine", "CreditRatio",
|
||||||
"MenuConfig", "RechargePackage", "OperationLog",
|
"MenuConfig", "RechargePackage", "OperationLog",
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ class CreditRecord(Base, TimestampMixin):
|
|||||||
Index("ix_credit_records_subject_media", "credit_subject", "media_type"),
|
Index("ix_credit_records_subject_media", "credit_subject", "media_type"),
|
||||||
Index("ix_credit_records_source_module_scene", "source_module", "billing_scene"),
|
Index("ix_credit_records_source_module_scene", "source_module", "billing_scene"),
|
||||||
Index("ix_credit_records_user_kind_time", "user_type_snapshot", "frontend_user_kind_snapshot", "created_at"),
|
Index("ix_credit_records_user_kind_time", "user_type_snapshot", "frontend_user_kind_snapshot", "created_at"),
|
||||||
|
Index("ix_credit_records_team_time", "team_id_snapshot", "created_at"),
|
||||||
|
Index("ix_credit_records_user_kind_team_time", "user_type_snapshot", "frontend_user_kind_snapshot", "team_id_snapshot", "created_at"),
|
||||||
)
|
)
|
||||||
|
|
||||||
id: Mapped[str] = mapped_column(String(32), primary_key=True)
|
id: Mapped[str] = mapped_column(String(32), primary_key=True)
|
||||||
@@ -66,3 +68,6 @@ class CreditRecord(Base, TimestampMixin):
|
|||||||
|
|
||||||
user_type_snapshot: Mapped[str | None] = mapped_column(String(16), nullable=True, index=True)
|
user_type_snapshot: Mapped[str | None] = mapped_column(String(16), nullable=True, index=True)
|
||||||
frontend_user_kind_snapshot: Mapped[str | None] = mapped_column(String(16), nullable=True, index=True)
|
frontend_user_kind_snapshot: Mapped[str | None] = mapped_column(String(16), nullable=True, index=True)
|
||||||
|
# 交易流水发生时的团队归属冷备快照;用户后续改团队不影响历史流水展示与筛选。
|
||||||
|
team_id_snapshot: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True)
|
||||||
|
team_name_snapshot: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
from sqlalchemy import Index, Integer, String
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.enums.team import TeamStatus
|
||||||
|
from app.models.base import Base, SoftDeleteMixin, TimestampMixin
|
||||||
|
|
||||||
|
|
||||||
|
class Team(Base, TimestampMixin, SoftDeleteMixin):
|
||||||
|
__tablename__ = "teams"
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_teams_status_sort", "status", "sort_order", "created_at"),
|
||||||
|
Index("ix_teams_code", "code"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String(32), primary_key=True)
|
||||||
|
name: Mapped[str] = mapped_column(String(128), nullable=False, index=True, comment="团队名称")
|
||||||
|
code: Mapped[str | None] = mapped_column(String(64), nullable=True, comment="团队编码")
|
||||||
|
description: Mapped[str | None] = mapped_column(String(512), nullable=True, comment="团队备注")
|
||||||
|
status: Mapped[str] = mapped_column(
|
||||||
|
String(16),
|
||||||
|
default=TeamStatus.ACTIVE.value,
|
||||||
|
server_default=TeamStatus.ACTIVE.value,
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
comment="团队状态:active启用,disabled禁用",
|
||||||
|
)
|
||||||
|
sort_order: Mapped[int] = mapped_column(
|
||||||
|
Integer,
|
||||||
|
default=0,
|
||||||
|
server_default="0",
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
comment="排序值,越小越靠前",
|
||||||
|
)
|
||||||
@@ -29,6 +29,8 @@ class User(Base, TimestampMixin):
|
|||||||
index=True,
|
index=True,
|
||||||
nullable=False,
|
nullable=False,
|
||||||
)
|
)
|
||||||
|
# 当前归属团队,仅前台用户有业务意义;不影响 frontend_user_kind 内部/外部设置。
|
||||||
|
team_id: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True)
|
||||||
last_login_at: Mapped[datetime | None] = mapped_column(
|
last_login_at: Mapped[datetime | None] = mapped_column(
|
||||||
DateTime(timezone=True), nullable=True
|
DateTime(timezone=True), nullable=True
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -52,6 +52,8 @@ class AdminUserOut(BaseModel):
|
|||||||
is_admin: bool
|
is_admin: bool
|
||||||
user_type: str = "frontend"
|
user_type: str = "frontend"
|
||||||
frontend_user_kind: str = "external"
|
frontend_user_kind: str = "external"
|
||||||
|
team_id: str | None = None
|
||||||
|
team_name: str | None = None
|
||||||
created_at: NaiveDatetime
|
created_at: NaiveDatetime
|
||||||
last_login_at: NaiveDatetimeOptional = None
|
last_login_at: NaiveDatetimeOptional = None
|
||||||
allowed_menus: list | None = None
|
allowed_menus: list | None = None
|
||||||
@@ -142,6 +144,8 @@ class AdminCreditRecordOut(BaseModel):
|
|||||||
user_type_label: str | None = None
|
user_type_label: str | None = None
|
||||||
frontend_user_kind: str | None = None
|
frontend_user_kind: str | None = None
|
||||||
frontend_user_kind_label: str | None = None
|
frontend_user_kind_label: str | None = None
|
||||||
|
team_id_snapshot: str | None = None
|
||||||
|
team_name_snapshot: str | None = None
|
||||||
type: str
|
type: str
|
||||||
record_type: str
|
record_type: str
|
||||||
record_type_label: str | None = None
|
record_type_label: str | None = None
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from app.enums.team import TeamStatus
|
||||||
|
from app.schemas.common import NaiveDatetime, NaiveDatetimeOptional
|
||||||
|
|
||||||
|
|
||||||
|
class TeamBase(BaseModel):
|
||||||
|
name: str = Field(..., min_length=1, max_length=128, description="团队名称")
|
||||||
|
code: str | None = Field(None, max_length=64, description="团队编码,可选,建议用于内部标识")
|
||||||
|
description: str | None = Field(None, max_length=512, description="团队备注")
|
||||||
|
status: str = Field(default=TeamStatus.ACTIVE.value, pattern="^(active|disabled)$", description="团队状态:active启用,disabled禁用")
|
||||||
|
sort_order: int = Field(default=0, ge=0, le=999999, description="排序值,越小越靠前")
|
||||||
|
|
||||||
|
|
||||||
|
class TeamCreate(TeamBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class TeamUpdate(BaseModel):
|
||||||
|
name: str = Field(..., min_length=1, max_length=128, description="团队名称")
|
||||||
|
code: str | None = Field(None, max_length=64, description="团队编码,可选,建议用于内部标识")
|
||||||
|
description: str | None = Field(None, max_length=512, description="团队备注")
|
||||||
|
status: str = Field(default=TeamStatus.ACTIVE.value, pattern="^(active|disabled)$", description="团队状态:active启用,disabled禁用")
|
||||||
|
sort_order: int = Field(default=0, ge=0, le=999999, description="排序值,越小越靠前")
|
||||||
|
|
||||||
|
|
||||||
|
class TeamOut(TeamBase):
|
||||||
|
id: str
|
||||||
|
member_count: int = 0
|
||||||
|
created_at: NaiveDatetime
|
||||||
|
updated_at: NaiveDatetimeOptional = None
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
|
class TeamOptionOut(BaseModel):
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
code: str | None = None
|
||||||
|
status: str = TeamStatus.ACTIVE.value
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
|
class TeamListOut(BaseModel):
|
||||||
|
items: list[TeamOut]
|
||||||
|
total: int
|
||||||
|
|
||||||
|
|
||||||
|
class UpdateUserTeamRequest(BaseModel):
|
||||||
|
team_id: str | None = Field(None, description="团队ID;传 null 表示取消团队归属")
|
||||||
@@ -17,6 +17,7 @@ from app.enums.credit_record import (
|
|||||||
CreditRecordSubject,
|
CreditRecordSubject,
|
||||||
)
|
)
|
||||||
from app.enums.user import FRONTEND_USER_KIND_LABELS, USER_TYPE_LABELS, UserType
|
from app.enums.user import FRONTEND_USER_KIND_LABELS, USER_TYPE_LABELS, UserType
|
||||||
|
from app.enums.team import TEAM_UNASSIGNED_VALUE
|
||||||
from app.models.chat_generation_task import ChatGenerationTask
|
from app.models.chat_generation_task import ChatGenerationTask
|
||||||
from app.models.credit_record import CreditRecord
|
from app.models.credit_record import CreditRecord
|
||||||
from app.models.generation_record import GenerationRecord
|
from app.models.generation_record import GenerationRecord
|
||||||
@@ -73,6 +74,7 @@ def _build_filters(
|
|||||||
user_name: str | None = None,
|
user_name: str | None = None,
|
||||||
user_type: str | None = None,
|
user_type: str | None = None,
|
||||||
frontend_user_kind: str | None = None,
|
frontend_user_kind: str | None = None,
|
||||||
|
team_id: str | None = None,
|
||||||
record_type: str | None = None,
|
record_type: str | None = None,
|
||||||
credit_subject: str | None = None,
|
credit_subject: str | None = None,
|
||||||
media_type: str | None = None,
|
media_type: str | None = None,
|
||||||
@@ -94,6 +96,11 @@ def _build_filters(
|
|||||||
if frontend_user_kind:
|
if frontend_user_kind:
|
||||||
filters.append(CreditRecord.frontend_user_kind_snapshot == frontend_user_kind)
|
filters.append(CreditRecord.frontend_user_kind_snapshot == frontend_user_kind)
|
||||||
filters.append(CreditRecord.user_type_snapshot == UserType.FRONTEND.value)
|
filters.append(CreditRecord.user_type_snapshot == UserType.FRONTEND.value)
|
||||||
|
if team_id:
|
||||||
|
if team_id == TEAM_UNASSIGNED_VALUE:
|
||||||
|
filters.append(CreditRecord.team_id_snapshot.is_(None))
|
||||||
|
else:
|
||||||
|
filters.append(CreditRecord.team_id_snapshot == team_id)
|
||||||
if record_type:
|
if record_type:
|
||||||
filters.append(CreditRecord.type == record_type)
|
filters.append(CreditRecord.type == record_type)
|
||||||
if credit_subject:
|
if credit_subject:
|
||||||
@@ -162,6 +169,8 @@ def _record_to_item(record: CreditRecord, user: User | None, deleted_map: dict[t
|
|||||||
"user_type_label": _label(USER_TYPE_LABELS, user_type),
|
"user_type_label": _label(USER_TYPE_LABELS, user_type),
|
||||||
"frontend_user_kind": frontend_kind,
|
"frontend_user_kind": frontend_kind,
|
||||||
"frontend_user_kind_label": _label(FRONTEND_USER_KIND_LABELS, frontend_kind),
|
"frontend_user_kind_label": _label(FRONTEND_USER_KIND_LABELS, frontend_kind),
|
||||||
|
"team_id_snapshot": record.team_id_snapshot,
|
||||||
|
"team_name_snapshot": record.team_name_snapshot,
|
||||||
"type": record.type,
|
"type": record.type,
|
||||||
"record_type": record.type,
|
"record_type": record.type,
|
||||||
"record_type_label": _label(CREDIT_RECORD_TYPE_LABELS, record.type),
|
"record_type_label": _label(CREDIT_RECORD_TYPE_LABELS, record.type),
|
||||||
@@ -213,6 +222,7 @@ async def list_admin_credit_records(
|
|||||||
user_name: str | None = None,
|
user_name: str | None = None,
|
||||||
user_type: str | None = None,
|
user_type: str | None = None,
|
||||||
frontend_user_kind: str | None = None,
|
frontend_user_kind: str | None = None,
|
||||||
|
team_id: str | None = None,
|
||||||
record_type: str | None = None,
|
record_type: str | None = None,
|
||||||
credit_subject: str | None = None,
|
credit_subject: str | None = None,
|
||||||
media_type: str | None = None,
|
media_type: str | None = None,
|
||||||
@@ -230,6 +240,7 @@ async def list_admin_credit_records(
|
|||||||
user_name=user_name,
|
user_name=user_name,
|
||||||
user_type=user_type,
|
user_type=user_type,
|
||||||
frontend_user_kind=frontend_user_kind,
|
frontend_user_kind=frontend_user_kind,
|
||||||
|
team_id=team_id,
|
||||||
record_type=record_type,
|
record_type=record_type,
|
||||||
credit_subject=credit_subject,
|
credit_subject=credit_subject,
|
||||||
media_type=media_type,
|
media_type=media_type,
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ from app.enums.user import FrontendUserKind
|
|||||||
from app.models.image_engine import ImageEngine
|
from app.models.image_engine import ImageEngine
|
||||||
from app.models.model_config import ModelConfig
|
from app.models.model_config import ModelConfig
|
||||||
from app.models.module_generation_step import ModuleGenerationStep
|
from app.models.module_generation_step import ModuleGenerationStep
|
||||||
|
from app.models.team import Team
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.models.video_engine import VideoEngine
|
from app.models.video_engine import VideoEngine
|
||||||
|
|
||||||
@@ -57,6 +58,8 @@ class CreditRecordMeta:
|
|||||||
|
|
||||||
user_type_snapshot: str | None = None
|
user_type_snapshot: str | None = None
|
||||||
frontend_user_kind_snapshot: str | None = None
|
frontend_user_kind_snapshot: str | None = None
|
||||||
|
team_id_snapshot: str | None = None
|
||||||
|
team_name_snapshot: str | None = None
|
||||||
|
|
||||||
def to_record_kwargs(self) -> dict[str, Any]:
|
def to_record_kwargs(self) -> dict[str, Any]:
|
||||||
return {k: v for k, v in asdict(self).items() if v is not None}
|
return {k: v for k, v in asdict(self).items() if v is not None}
|
||||||
@@ -81,6 +84,11 @@ async def with_user_snapshot(db: AsyncSession, meta: CreditRecordMeta, user_id:
|
|||||||
if user:
|
if user:
|
||||||
meta.user_type_snapshot = user.user_type
|
meta.user_type_snapshot = user.user_type
|
||||||
meta.frontend_user_kind_snapshot = _normalize_frontend_kind(getattr(user, "frontend_user_kind", None))
|
meta.frontend_user_kind_snapshot = _normalize_frontend_kind(getattr(user, "frontend_user_kind", None))
|
||||||
|
team_id = getattr(user, "team_id", None)
|
||||||
|
if team_id:
|
||||||
|
meta.team_id_snapshot = team_id
|
||||||
|
team_result = await db.execute(select(Team.name).where(Team.id == team_id).limit(1))
|
||||||
|
meta.team_name_snapshot = team_result.scalar_one_or_none()
|
||||||
return meta
|
return meta
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,259 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from sqlalchemy import and_, func, or_, select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.enums.team import TeamStatus
|
||||||
|
from app.enums.user import UserType
|
||||||
|
from app.models.team import Team
|
||||||
|
from app.models.user import User
|
||||||
|
from app.schemas.team import TeamCreate, TeamUpdate
|
||||||
|
from app.utils.id_gen import generate_id
|
||||||
|
|
||||||
|
|
||||||
|
def _clean_text(value: str | None) -> str | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
value = value.strip()
|
||||||
|
return value or None
|
||||||
|
|
||||||
|
|
||||||
|
def _team_snapshot(team: Team | None) -> dict[str, Any]:
|
||||||
|
if not team:
|
||||||
|
return {"team_id": None, "team_name": None}
|
||||||
|
return {
|
||||||
|
"team_id": team.id,
|
||||||
|
"team_name": getattr(team, "name", None),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _team_out_payload(team: Team, member_count: int = 0) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"id": team.id,
|
||||||
|
"name": team.name,
|
||||||
|
"code": getattr(team, "code", None),
|
||||||
|
"description": getattr(team, "description", None),
|
||||||
|
"status": getattr(team, "status", TeamStatus.ACTIVE.value),
|
||||||
|
"sort_order": getattr(team, "sort_order", 0) or 0,
|
||||||
|
"member_count": int(member_count or 0),
|
||||||
|
"created_at": team.created_at,
|
||||||
|
"updated_at": team.updated_at,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_team(db: AsyncSession, team_id: str, *, include_deleted: bool = False) -> Team | None:
|
||||||
|
query = select(Team).where(Team.id == team_id).limit(1)
|
||||||
|
if not include_deleted:
|
||||||
|
query = query.where(Team.deleted_at.is_(None))
|
||||||
|
result = await db.execute(query)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_team_name(db: AsyncSession, team_id: str | None) -> str | None:
|
||||||
|
if not team_id:
|
||||||
|
return None
|
||||||
|
result = await db.execute(select(Team.name).where(Team.id == team_id).limit(1))
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
async def _assert_unique_team(db: AsyncSession, *, name: str, code: str | None, exclude_id: str | None = None) -> None:
|
||||||
|
conditions = [Team.deleted_at.is_(None)]
|
||||||
|
duplicate_filters = [Team.name == name]
|
||||||
|
if code:
|
||||||
|
duplicate_filters.append(Team.code == code)
|
||||||
|
conditions.append(or_(*duplicate_filters))
|
||||||
|
if exclude_id:
|
||||||
|
conditions.append(Team.id != exclude_id)
|
||||||
|
result = await db.execute(select(Team.id).where(and_(*conditions)).limit(1))
|
||||||
|
if result.scalar_one_or_none():
|
||||||
|
raise HTTPException(status_code=400, detail="团队名称或编码已存在")
|
||||||
|
|
||||||
|
|
||||||
|
async def list_teams(
|
||||||
|
db: AsyncSession,
|
||||||
|
*,
|
||||||
|
page: int = 1,
|
||||||
|
page_size: int = 20,
|
||||||
|
keyword: str | None = None,
|
||||||
|
status: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
page = max(int(page or 1), 1)
|
||||||
|
page_size = min(max(int(page_size or 20), 1), 500)
|
||||||
|
|
||||||
|
filters: list[Any] = [Team.deleted_at.is_(None)]
|
||||||
|
kw = _clean_text(keyword)
|
||||||
|
if kw:
|
||||||
|
like = f"%{kw}%"
|
||||||
|
filters.append(or_(Team.name.ilike(like), Team.code.ilike(like), Team.description.ilike(like)))
|
||||||
|
if status:
|
||||||
|
filters.append(Team.status == status)
|
||||||
|
|
||||||
|
where_clause = and_(*filters)
|
||||||
|
total = (await db.execute(select(func.count(Team.id)).where(where_clause))).scalar() or 0
|
||||||
|
result = await db.execute(
|
||||||
|
select(Team)
|
||||||
|
.where(where_clause)
|
||||||
|
.order_by(Team.sort_order.asc(), Team.created_at.desc(), Team.id.desc())
|
||||||
|
.offset((page - 1) * page_size)
|
||||||
|
.limit(page_size)
|
||||||
|
)
|
||||||
|
teams = list(result.scalars().all())
|
||||||
|
if not teams:
|
||||||
|
return {"items": [], "total": total}
|
||||||
|
|
||||||
|
team_ids = [team.id for team in teams]
|
||||||
|
member_result = await db.execute(
|
||||||
|
select(User.team_id, func.count(User.id))
|
||||||
|
.where(User.user_type == UserType.FRONTEND.value, User.team_id.in_(team_ids))
|
||||||
|
.group_by(User.team_id)
|
||||||
|
)
|
||||||
|
member_map = {row[0]: int(row[1] or 0) for row in member_result.all()}
|
||||||
|
return {
|
||||||
|
"items": [_team_out_payload(team, member_map.get(team.id, 0)) for team in teams],
|
||||||
|
"total": total,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def list_team_options(db: AsyncSession, *, include_disabled: bool = True) -> list[dict[str, Any]]:
|
||||||
|
filters: list[Any] = [Team.deleted_at.is_(None)]
|
||||||
|
if not include_disabled:
|
||||||
|
filters.append(Team.status == TeamStatus.ACTIVE.value)
|
||||||
|
result = await db.execute(
|
||||||
|
select(Team)
|
||||||
|
.where(and_(*filters))
|
||||||
|
.order_by(Team.sort_order.asc(), Team.created_at.desc(), Team.id.desc())
|
||||||
|
)
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": team.id,
|
||||||
|
"name": team.name,
|
||||||
|
"code": getattr(team, "code", None),
|
||||||
|
"status": getattr(team, "status", TeamStatus.ACTIVE.value),
|
||||||
|
}
|
||||||
|
for team in result.scalars().all()
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
async def batch_get_team_name_map(db: AsyncSession, team_ids: list[str] | set[str] | tuple[str, ...]) -> dict[str, str]:
|
||||||
|
"""Batch load team names for list pages. Avoid joining teams in high-frequency user queries."""
|
||||||
|
ids = [team_id for team_id in dict.fromkeys(team_ids or []) if team_id]
|
||||||
|
if not ids:
|
||||||
|
return {}
|
||||||
|
result = await db.execute(
|
||||||
|
select(Team.id, Team.name)
|
||||||
|
.where(Team.id.in_(ids), Team.deleted_at.is_(None))
|
||||||
|
)
|
||||||
|
return {row[0]: row[1] for row in result.all()}
|
||||||
|
|
||||||
|
|
||||||
|
async def create_team(db: AsyncSession, req: TeamCreate) -> Team:
|
||||||
|
name = req.name.strip()
|
||||||
|
code = _clean_text(req.code)
|
||||||
|
await _assert_unique_team(db, name=name, code=code)
|
||||||
|
team = Team(
|
||||||
|
id=generate_id(),
|
||||||
|
name=name,
|
||||||
|
code=code,
|
||||||
|
description=_clean_text(req.description),
|
||||||
|
status=req.status or TeamStatus.ACTIVE.value,
|
||||||
|
sort_order=req.sort_order or 0,
|
||||||
|
)
|
||||||
|
db.add(team)
|
||||||
|
await db.flush()
|
||||||
|
return team
|
||||||
|
|
||||||
|
|
||||||
|
async def update_team(db: AsyncSession, team_id: str, req: TeamUpdate) -> tuple[Team, dict[str, Any], dict[str, Any]]:
|
||||||
|
team = await _get_team(db, team_id)
|
||||||
|
if not team:
|
||||||
|
raise HTTPException(status_code=404, detail="团队不存在")
|
||||||
|
|
||||||
|
before = {
|
||||||
|
"id": team.id,
|
||||||
|
"name": team.name,
|
||||||
|
"code": getattr(team, "code", None),
|
||||||
|
"description": getattr(team, "description", None),
|
||||||
|
"status": getattr(team, "status", TeamStatus.ACTIVE.value),
|
||||||
|
"sort_order": getattr(team, "sort_order", 0) or 0,
|
||||||
|
}
|
||||||
|
name = req.name.strip()
|
||||||
|
code = _clean_text(req.code)
|
||||||
|
await _assert_unique_team(db, name=name, code=code, exclude_id=team_id)
|
||||||
|
|
||||||
|
team.name = name
|
||||||
|
team.code = code
|
||||||
|
team.description = _clean_text(req.description)
|
||||||
|
team.status = req.status or TeamStatus.ACTIVE.value
|
||||||
|
team.sort_order = req.sort_order or 0
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
after = {
|
||||||
|
"id": team.id,
|
||||||
|
"name": team.name,
|
||||||
|
"code": getattr(team, "code", None),
|
||||||
|
"description": getattr(team, "description", None),
|
||||||
|
"status": getattr(team, "status", TeamStatus.ACTIVE.value),
|
||||||
|
"sort_order": getattr(team, "sort_order", 0) or 0,
|
||||||
|
}
|
||||||
|
return team, before, after
|
||||||
|
|
||||||
|
|
||||||
|
async def soft_delete_team(db: AsyncSession, team_id: str) -> tuple[Team, dict[str, Any]]:
|
||||||
|
team = await _get_team(db, team_id)
|
||||||
|
if not team:
|
||||||
|
raise HTTPException(status_code=404, detail="团队不存在")
|
||||||
|
|
||||||
|
member_count = (await db.execute(
|
||||||
|
select(func.count(User.id)).where(
|
||||||
|
User.user_type == UserType.FRONTEND.value,
|
||||||
|
User.team_id == team_id,
|
||||||
|
)
|
||||||
|
)).scalar() or 0
|
||||||
|
if member_count > 0:
|
||||||
|
raise HTTPException(status_code=400, detail="该团队下仍有前台用户,请先迁移或取消团队归属")
|
||||||
|
|
||||||
|
before = {
|
||||||
|
"id": team.id,
|
||||||
|
"name": team.name,
|
||||||
|
"code": getattr(team, "code", None),
|
||||||
|
"status": getattr(team, "status", TeamStatus.ACTIVE.value),
|
||||||
|
"member_count": int(member_count or 0),
|
||||||
|
}
|
||||||
|
team.deleted_at = datetime.now(timezone.utc)
|
||||||
|
await db.flush()
|
||||||
|
return team, before
|
||||||
|
|
||||||
|
|
||||||
|
async def set_frontend_user_team(
|
||||||
|
db: AsyncSession,
|
||||||
|
*,
|
||||||
|
user_id: str,
|
||||||
|
team_id: str | None,
|
||||||
|
) -> tuple[User, dict[str, Any], dict[str, Any]]:
|
||||||
|
result = await db.execute(select(User).where(User.id == user_id).limit(1))
|
||||||
|
user = result.scalar_one_or_none()
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(status_code=404, detail="用户不存在")
|
||||||
|
if user.user_type != UserType.FRONTEND.value:
|
||||||
|
raise HTTPException(status_code=400, detail="仅前台用户支持设置团队")
|
||||||
|
|
||||||
|
old_team_id = getattr(user, "team_id", None)
|
||||||
|
old_team = await _get_team(db, old_team_id, include_deleted=True) if old_team_id else None
|
||||||
|
before = _team_snapshot(old_team)
|
||||||
|
|
||||||
|
new_team: Team | None = None
|
||||||
|
if team_id:
|
||||||
|
new_team = await _get_team(db, team_id)
|
||||||
|
if not new_team:
|
||||||
|
raise HTTPException(status_code=404, detail="团队不存在")
|
||||||
|
if getattr(new_team, "status", TeamStatus.ACTIVE.value) != TeamStatus.ACTIVE.value:
|
||||||
|
raise HTTPException(status_code=400, detail="禁用团队不能设置给用户")
|
||||||
|
|
||||||
|
user.team_id = team_id or None
|
||||||
|
await db.flush()
|
||||||
|
after = _team_snapshot(new_team)
|
||||||
|
return user, before, after
|
||||||
+55
-55
File diff suppressed because one or more lines are too long
Vendored
+36
-36
@@ -1,37 +1,37 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="zh-CN">
|
<html lang="zh-CN">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<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.googleapis.com" />
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
<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" />
|
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&display=swap" rel="stylesheet" />
|
||||||
<title>民众智创</title>
|
<title>民众智创</title>
|
||||||
<script>
|
<script>
|
||||||
(function() {
|
(function() {
|
||||||
var cached = localStorage.getItem('siteInfo');
|
var cached = localStorage.getItem('siteInfo');
|
||||||
if (cached) {
|
if (cached) {
|
||||||
try {
|
try {
|
||||||
var info = JSON.parse(cached);
|
var info = JSON.parse(cached);
|
||||||
if (info.siteName) {
|
if (info.siteName) {
|
||||||
document.title = info.siteName;
|
document.title = info.siteName;
|
||||||
}
|
}
|
||||||
if (info.siteLogo) {
|
if (info.siteLogo) {
|
||||||
var link = document.querySelector('link[rel="icon"]');
|
var link = document.querySelector('link[rel="icon"]');
|
||||||
if (link) {
|
if (link) {
|
||||||
link.href = info.siteLogo;
|
link.href = info.siteLogo;
|
||||||
link.type = 'image/png';
|
link.type = 'image/png';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
<script type="module" crossorigin src="/assets/index-5JgLGsf8.js"></script>
|
<script type="module" crossorigin src="/assets/index-1NS8asx8.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-D3IaMBsq.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-D3IaMBsq.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>␍
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -2,6 +2,9 @@ import React, { useEffect } from 'react';
|
|||||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||||
import { ConfigProvider, App as AntApp, Spin } from 'antd';
|
import { ConfigProvider, App as AntApp, Spin } from 'antd';
|
||||||
import zhCN from 'antd/locale/zh_CN';
|
import zhCN from 'antd/locale/zh_CN';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import 'dayjs/locale/zh-cn';
|
||||||
|
dayjs.locale('zh-cn');
|
||||||
import AppLayout from './components/Layout/AppLayout';
|
import AppLayout from './components/Layout/AppLayout';
|
||||||
import LoginPage from './pages/LoginPage';
|
import LoginPage from './pages/LoginPage';
|
||||||
import ProjectsPage from './pages/ProjectsPage';
|
import ProjectsPage from './pages/ProjectsPage';
|
||||||
|
|||||||
@@ -503,7 +503,6 @@ export async function getDefaultPreTest(): Promise<any> {
|
|||||||
export async function updateHotOpeningVideoPromptSchema(projectId: string, stepId: string, params: { prompt_schema: Record<string, any> }): Promise<any> {
|
export async function updateHotOpeningVideoPromptSchema(projectId: string, stepId: string, params: { prompt_schema: Record<string, any> }): Promise<any> {
|
||||||
return api.put(`/hot-opening-replications/tasks/${projectId}/steps/${stepId}/video-prompt-schema`, params);
|
return api.put(`/hot-opening-replications/tasks/${projectId}/steps/${stepId}/video-prompt-schema`, params);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 镜头复刻
|
// 镜头复刻
|
||||||
export async function createShotReplication(params: any): Promise<any> {
|
export async function createShotReplication(params: any): Promise<any> {
|
||||||
return api.post('/shot-replications/task-sets', params);
|
return api.post('/shot-replications/task-sets', params);
|
||||||
@@ -519,68 +518,50 @@ export async function getShotReplicationList(page: number, page_size: number, ke
|
|||||||
export async function getShotReplicationDetail(taskSetId: string): Promise<any> {
|
export async function getShotReplicationDetail(taskSetId: string): Promise<any> {
|
||||||
return api.get(`/shot-replications/task-sets/${taskSetId}`);
|
return api.get(`/shot-replications/task-sets/${taskSetId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ai拆镜
|
// ai拆镜
|
||||||
export async function createRemoveLens(taskSetId: string, params: any): Promise<any> {
|
export async function createRemoveLens(taskSetId: string, params: any): Promise<any> {
|
||||||
return api.post(`/shot-replications/task-sets/${taskSetId}/split-by-ai`, params);
|
return api.post(`/shot-replications/task-sets/${taskSetId}/split-by-ai`, params);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 手动分割
|
// 手动分割
|
||||||
export async function splitCustom(taskSetId: string, params: any): Promise<any> {
|
export async function splitCustom(taskSetId: string, params: any): Promise<any> {
|
||||||
return api.post(`/shot-replications/task-sets/${taskSetId}/split-custom`, params);
|
return api.post(`/shot-replications/task-sets/${taskSetId}/split-custom`, params);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 拆镜列表
|
// 拆镜列表
|
||||||
export async function Removelist(taskSetId: string): Promise<any> {
|
export async function Removelist(taskSetId: string): Promise<any> {
|
||||||
return api.get(`/shot-replications/task-sets/${taskSetId}/segments`);
|
return api.get(`/shot-replications/task-sets/${taskSetId}/segments`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 生成视频
|
// 生成视频
|
||||||
export async function removeCreate(recordId: string, params: any): Promise<any> {
|
export async function removeCreate(recordId: string, params: any): Promise<any> {
|
||||||
return api.post(`/shot-replications/segments/${recordId}/replication-projects`, params);
|
return api.post(`/shot-replications/segments/${recordId}/replication-projects`, params);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// 获取爆款开头复刻任务详情
|
// 获取爆款开头复刻任务详情
|
||||||
export async function removeDetail(id: string): Promise<any> {
|
export async function removeDetail(id: string): Promise<any> {
|
||||||
return api.get(`/shot-replications/projects/${id}`);
|
return api.get(`/shot-replications/projects/${id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 第一步,生成提示词
|
// 第一步,生成提示词
|
||||||
export async function removeone(projectId: string, stepId: string): Promise<any> {
|
export async function removeone(projectId: string, stepId: string): Promise<any> {
|
||||||
return api.post(`/shot-replications/projects/${projectId}/steps/${stepId}/generate-image-prompt`);
|
return api.post(`/shot-replications/projects/${projectId}/steps/${stepId}/generate-image-prompt`);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 修改图片提示词
|
// 修改图片提示词
|
||||||
export async function updateShotImagePrompt(projectId: string, stepId: string, params: any): Promise<any> {
|
export async function updateShotImagePrompt(projectId: string, stepId: string, params: any): Promise<any> {
|
||||||
return api.put(`/shot-replications/projects/${projectId}/steps/${stepId}/image-prompt`, params);
|
return api.put(`/shot-replications/projects/${projectId}/steps/${stepId}/image-prompt`, params);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 修改视频提示词
|
// 修改视频提示词
|
||||||
export async function updateShotVideoPromptSchema(projectId: string, stepId: string, params: any): Promise<any> {
|
export async function updateShotVideoPromptSchema(projectId: string, stepId: string, params: any): Promise<any> {
|
||||||
return api.put(`/shot-replications/projects/${projectId}/steps/${stepId}/video-prompt-schema`, params);
|
return api.put(`/shot-replications/projects/${projectId}/steps/${stepId}/video-prompt-schema`, params);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 第二步,生成图片
|
// 第二步,生成图片
|
||||||
export async function removetwo(projectId: string, stepId: string ,params: any): Promise<any> {
|
export async function removetwo(projectId: string, stepId: string ,params: any): Promise<any> {
|
||||||
return api.post(`/shot-replications/projects/${projectId}/steps/${stepId}/generate-image`, params);
|
return api.post(`/shot-replications/projects/${projectId}/steps/${stepId}/generate-image`, params);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 第三步,生成视频提示词
|
// 第三步,生成视频提示词
|
||||||
export async function removethree(projectId: string, stepId: string ,params: any): Promise<any> {
|
export async function removethree(projectId: string, stepId: string ,params: any): Promise<any> {
|
||||||
return api.post(`/shot-replications/projects/${projectId}/steps/${stepId}/generate-video-prompt`, params);
|
return api.post(`/shot-replications/projects/${projectId}/steps/${stepId}/generate-video-prompt`, params);
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
// 第四步,生成视频
|
// 第四步,生成视频
|
||||||
export async function removefour(projectId: string, stepId: string ,params: any): Promise<any> {
|
export async function removefour(projectId: string, stepId: string ,params: any): Promise<any> {
|
||||||
return api.post(`/shot-replications/projects/${projectId}/steps/${stepId}/generate-video`, params);
|
return api.post(`/shot-replications/projects/${projectId}/steps/${stepId}/generate-video`, params);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取地区信息
|
// 获取地区信息
|
||||||
export interface GetAreaParams {
|
export interface GetAreaParams {
|
||||||
level?: string;
|
level?: string;
|
||||||
@@ -592,23 +573,21 @@ export async function getArea(params?: GetAreaParams): Promise<any> {
|
|||||||
if (params?.parent_code) query.set('parent_code', params.parent_code);
|
if (params?.parent_code) query.set('parent_code', params.parent_code);
|
||||||
return api.get(`/pre-test-template/getArea?${query.toString()}`);
|
return api.get(`/pre-test-template/getArea?${query.toString()}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Upload Material ───────────────────────────────────────
|
// ── Upload Material ───────────────────────────────────────
|
||||||
|
|
||||||
export interface AsyncBatchUploadTask {
|
export interface AsyncBatchUploadTask {
|
||||||
advertiser_ids: string[];
|
advertiser_ids: string[];
|
||||||
resource_ids: string[];
|
resource_ids: string[];
|
||||||
oauth_id: string;
|
oauth_id: string;
|
||||||
|
is_pre_test: string;
|
||||||
|
pre_test_template: string;
|
||||||
|
source_model: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AsyncBatchUploadParams {
|
export interface AsyncBatchUploadParams {
|
||||||
tasks: AsyncBatchUploadTask[];
|
tasks: AsyncBatchUploadTask[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function asyncBatchUploadMaterial(params: AsyncBatchUploadParams): Promise<any> {
|
export async function asyncBatchUploadMaterial(params: AsyncBatchUploadParams): Promise<any> {
|
||||||
return api.post('/upload-material/async-batch-upload', params);
|
return api.post('/upload-material/async-batch-upload', params);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取上传历史
|
// 获取上传历史
|
||||||
// /api/upload-material/upload-history
|
// /api/upload-material/upload-history
|
||||||
export interface UploadHistoryParams {
|
export interface UploadHistoryParams {
|
||||||
@@ -624,7 +603,6 @@ export async function getUploadHistory(params: UploadHistoryParams): Promise<any
|
|||||||
const query = searchParams.toString();
|
const query = searchParams.toString();
|
||||||
return api.get(`/upload-material/upload-history${query ? `?${query}` : ''}`);
|
return api.get(`/upload-material/upload-history${query ? `?${query}` : ''}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UploadFilenames {
|
export interface UploadFilenames {
|
||||||
source_id: string;
|
source_id: string;
|
||||||
file_name: string;
|
file_name: string;
|
||||||
@@ -636,7 +614,6 @@ export interface UpdateFilenameParams {
|
|||||||
export async function updateFilename(params: UpdateFilenameParams): Promise<any> {
|
export async function updateFilename(params: UpdateFilenameParams): Promise<any> {
|
||||||
return api.post('/upload-material/batch-update-filename', params);
|
return api.post('/upload-material/batch-update-filename', params);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 查询素材消耗列表
|
// 查询素材消耗列表
|
||||||
export interface MaterialConsumpListParams {
|
export interface MaterialConsumpListParams {
|
||||||
advertiser_id?: string;
|
advertiser_id?: string;
|
||||||
@@ -652,7 +629,6 @@ export async function getMaterialConsumpList(params: MaterialConsumpListParams):
|
|||||||
if (params.page_size !== undefined) query.set('page_size', String(params.page_size));
|
if (params.page_size !== undefined) query.set('page_size', String(params.page_size));
|
||||||
return api.get(`/material-consumption/list?${query.toString()}`);
|
return api.get(`/material-consumption/list?${query.toString()}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 拉取消耗
|
// 拉取消耗
|
||||||
export async function syncMaterialConsumption(params: { date: string; advertiser_id?: string }): Promise<any> {
|
export async function syncMaterialConsumption(params: { date: string; advertiser_id?: string }): Promise<any> {
|
||||||
const query = new URLSearchParams();
|
const query = new URLSearchParams();
|
||||||
@@ -660,12 +636,10 @@ export async function syncMaterialConsumption(params: { date: string; advertiser
|
|||||||
if (params.date) query.set('date', params.date);
|
if (params.date) query.set('date', params.date);
|
||||||
return api.get(`/material-consumption/sync?${query.toString()}`);
|
return api.get(`/material-consumption/sync?${query.toString()}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取消耗字段列表
|
// 获取消耗字段列表
|
||||||
export async function getMaterialConsumptionFields(): Promise<any> {
|
export async function getMaterialConsumptionFields(): Promise<any> {
|
||||||
return api.get('/material-consumption/fields');
|
return api.get('/material-consumption/fields');
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Contact ────────────────────────────────────────────────
|
// ── Contact ────────────────────────────────────────────────
|
||||||
export interface ContactRequestParams {
|
export interface ContactRequestParams {
|
||||||
phone: string;
|
phone: string;
|
||||||
@@ -674,11 +648,9 @@ export interface ContactRequestParams {
|
|||||||
name: string;
|
name: string;
|
||||||
message?: string;
|
message?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createContactRequest(params: ContactRequestParams): Promise<any> {
|
export async function createContactRequest(params: ContactRequestParams): Promise<any> {
|
||||||
return api.post('/contact/request', params);
|
return api.post('/contact/request', params);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 查询上传素材列表
|
// 查询上传素材列表
|
||||||
export interface ResourcesMaterialListParams {
|
export interface ResourcesMaterialListParams {
|
||||||
advertiser_id?: string;
|
advertiser_id?: string;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { Button, Table, Modal, App, Input, Pagination, Typography, Space } from 'antd';
|
import { Button, Table, Modal, App, Input, Pagination, Typography, Space } from 'antd';
|
||||||
import { LockOutlined, ArrowLeftOutlined } from '@ant-design/icons';
|
import { LockOutlined, ArrowLeftOutlined } from '@ant-design/icons';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
import { getOAuthAccountList, deleteOAuthAccount, getOpenTypeAll } from '../api';
|
import { getOAuthAccountList, deleteOAuthAccount, getOpenTypeAll } from '../api';
|
||||||
|
|
||||||
const formatDateTime = (dateStr: string) => {
|
const formatDateTime = (dateStr: string) => {
|
||||||
@@ -27,6 +27,7 @@ interface AuthorizationData {
|
|||||||
|
|
||||||
const AuthAccountPage: React.FC = () => {
|
const AuthAccountPage: React.FC = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const [searchParamsUrl] = useSearchParams();
|
||||||
const { message } = App.useApp();
|
const { message } = App.useApp();
|
||||||
const [authorizations, setAuthorizations] = useState<AuthorizationData[]>([]);
|
const [authorizations, setAuthorizations] = useState<AuthorizationData[]>([]);
|
||||||
const [listLoading, setListLoading] = useState(false);
|
const [listLoading, setListLoading] = useState(false);
|
||||||
@@ -41,7 +42,13 @@ const AuthAccountPage: React.FC = () => {
|
|||||||
const [openTypeMap, setOpenTypeMap] = useState<Record<number, string>>({});
|
const [openTypeMap, setOpenTypeMap] = useState<Record<number, string>>({});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadOAuthList();
|
const oauthId = searchParamsUrl.get('oauth_id');
|
||||||
|
if (oauthId) {
|
||||||
|
setSearchParams(prev => ({ ...prev, oauth_id: oauthId }));
|
||||||
|
loadOAuthList(1, 10, { ...searchParams, oauth_id: oauthId });
|
||||||
|
} else {
|
||||||
|
loadOAuthList();
|
||||||
|
}
|
||||||
loadOpenTypeList();
|
loadOpenTypeList();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -245,6 +252,7 @@ const AuthAccountPage: React.FC = () => {
|
|||||||
value={searchParams.advertiser_id}
|
value={searchParams.advertiser_id}
|
||||||
onChange={(e) => setSearchParams(prev => ({ ...prev, advertiser_id: e.target.value }))}
|
onChange={(e) => setSearchParams(prev => ({ ...prev, advertiser_id: e.target.value }))}
|
||||||
style={{ width: 180 }}
|
style={{ width: 180 }}
|
||||||
|
allowClear={true}
|
||||||
onPressEnter={() => { setCurrentPage(1); loadOAuthList(1, pageSize); }}
|
onPressEnter={() => { setCurrentPage(1); loadOAuthList(1, pageSize); }}
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
@@ -252,6 +260,7 @@ const AuthAccountPage: React.FC = () => {
|
|||||||
value={searchParams.oauth_id}
|
value={searchParams.oauth_id}
|
||||||
onChange={(e) => setSearchParams(prev => ({ ...prev, oauth_id: e.target.value }))}
|
onChange={(e) => setSearchParams(prev => ({ ...prev, oauth_id: e.target.value }))}
|
||||||
style={{ width: 180 }}
|
style={{ width: 180 }}
|
||||||
|
allowClear={true}
|
||||||
onPressEnter={() => { setCurrentPage(1); loadOAuthList(1, pageSize); }}
|
onPressEnter={() => { setCurrentPage(1); loadOAuthList(1, pageSize); }}
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
@@ -259,6 +268,7 @@ const AuthAccountPage: React.FC = () => {
|
|||||||
value={searchParams.advertiser_name}
|
value={searchParams.advertiser_name}
|
||||||
onChange={(e) => setSearchParams(prev => ({ ...prev, advertiser_name: e.target.value }))}
|
onChange={(e) => setSearchParams(prev => ({ ...prev, advertiser_name: e.target.value }))}
|
||||||
style={{ width: 180 }}
|
style={{ width: 180 }}
|
||||||
|
allowClear={true}
|
||||||
onPressEnter={() => { setCurrentPage(1); loadOAuthList(1, pageSize); }}
|
onPressEnter={() => { setCurrentPage(1); loadOAuthList(1, pageSize); }}
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -2,15 +2,7 @@ import React, { useEffect, useState } from 'react';
|
|||||||
import { Button, Table, Tag, Modal, Select, App, Input, Pagination, Typography, Space } from 'antd';
|
import { Button, Table, Tag, Modal, Select, App, Input, Pagination, Typography, Space } from 'antd';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { PlusOutlined, LockOutlined } from '@ant-design/icons';
|
import { PlusOutlined, LockOutlined } from '@ant-design/icons';
|
||||||
import { getOAuthList, requestOAuth, getOpenTypeAll } from '../api';
|
import { getOAuthList, requestOAuth, getOpenTypeAll, getDefaultPreTest } from '../api';
|
||||||
|
|
||||||
const PORT_TYPE_MAP: Record<number, string> = {
|
|
||||||
1: '巨量',
|
|
||||||
2: '磁力',
|
|
||||||
3: '巨量星图',
|
|
||||||
4: '服务单',
|
|
||||||
5: '腾讯',
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatDateTime = (dateStr: string) => {
|
const formatDateTime = (dateStr: string) => {
|
||||||
if (!dateStr) return '';
|
if (!dateStr) return '';
|
||||||
@@ -23,7 +15,6 @@ const formatDateTime = (dateStr: string) => {
|
|||||||
const seconds = String(date.getSeconds()).padStart(2, '0');
|
const seconds = String(date.getSeconds()).padStart(2, '0');
|
||||||
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
|
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
// 安全拼接URL,避免双斜杠
|
// 安全拼接URL,避免双斜杠
|
||||||
const buildUrl = (path: string): string => {
|
const buildUrl = (path: string): string => {
|
||||||
const baseUrl = import.meta.env.VITE_API_BASE || "http://localhost:8000";
|
const baseUrl = import.meta.env.VITE_API_BASE || "http://localhost:8000";
|
||||||
@@ -37,7 +28,6 @@ const buildUrl = (path: string): string => {
|
|||||||
const cleanBase = baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl;
|
const cleanBase = baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl;
|
||||||
return `${cleanBase}/${cleanPath}`;
|
return `${cleanBase}/${cleanPath}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
interface AuthorizationData {
|
interface AuthorizationData {
|
||||||
id: string;
|
id: string;
|
||||||
status: string;
|
status: string;
|
||||||
@@ -79,8 +69,17 @@ const AuthorizationPage: React.FC = () => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadOAuthList();
|
loadOAuthList();
|
||||||
loadOpenTypeList();
|
loadOpenTypeList();
|
||||||
|
list();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|
||||||
|
const list = async () => {
|
||||||
|
try {
|
||||||
|
const res = await getDefaultPreTest();
|
||||||
|
console.log(res);
|
||||||
|
} catch (error) {
|
||||||
|
}
|
||||||
|
};
|
||||||
const loadOpenTypeList = async () => {
|
const loadOpenTypeList = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await getOpenTypeAll();
|
const res = await getOpenTypeAll();
|
||||||
@@ -240,9 +239,9 @@ const AuthorizationPage: React.FC = () => {
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '授权过期时间',
|
title: '授权有效时间',
|
||||||
dataIndex: 'accessTokenExpired',
|
dataIndex: 'refreshTokenExpired',
|
||||||
key: 'accessTokenExpired',
|
key: 'refreshTokenExpired',
|
||||||
width: 160,
|
width: 160,
|
||||||
render: (text: string) => <span style={{ color: '#64748b' }}>{formatDateTime(text)}</span>,
|
render: (text: string) => <span style={{ color: '#64748b' }}>{formatDateTime(text)}</span>,
|
||||||
},
|
},
|
||||||
@@ -263,7 +262,7 @@ const AuthorizationPage: React.FC = () => {
|
|||||||
<>
|
<>
|
||||||
<Button
|
<Button
|
||||||
type="link"
|
type="link"
|
||||||
onClick={() => navigate(`/authacc?advertiserId=${record.advertiserId}`)}
|
onClick={() => navigate(`/authacc?oauth_id=${record.id}`)}
|
||||||
style={{ color: '#6366f1', padding: 0 }}
|
style={{ color: '#6366f1', padding: 0 }}
|
||||||
>
|
>
|
||||||
查看授权账户
|
查看授权账户
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { Table, Tag, Button, Pagination, Typography, Modal, Checkbox, Input, DatePicker, App } from 'antd';
|
import { Table, Button, Pagination, Typography, Modal, Checkbox, Input, DatePicker, App } from 'antd';
|
||||||
import { ArrowLeftOutlined, DollarOutlined, SettingOutlined, SyncOutlined } from '@ant-design/icons';
|
import { ArrowLeftOutlined, DollarOutlined, SettingOutlined, SyncOutlined } from '@ant-design/icons';
|
||||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
import { getMaterialConsumpList, getMaterialConsumptionFields, syncMaterialConsumption } from '../api';
|
import { getMaterialConsumpList, getMaterialConsumptionFields, syncMaterialConsumption } from '../api';
|
||||||
|
|||||||
@@ -1287,6 +1287,7 @@ const AIChatPage: React.FC = () => {
|
|||||||
borderRadius: 16, color: '#64748b' }}>{msg.genType === 'image' ? `${msg.imageProportion || ''} · ${msg.imagePx || ''} · ${msg.imageSize || ''}` : `${msg.duration || ''}s · ${msg.aspectRatio || ''} · ${msg.resolution || ''}`}</span>
|
borderRadius: 16, color: '#64748b' }}>{msg.genType === 'image' ? `${msg.imageProportion || ''} · ${msg.imagePx || ''} · ${msg.imageSize || ''}` : `${msg.duration || ''}s · ${msg.aspectRatio || ''} · ${msg.resolution || ''}`}</span>
|
||||||
<span style={{
|
<span style={{
|
||||||
// background: 'rgba(239, 68, 68, 0.08)',
|
// background: 'rgba(239, 68, 68, 0.08)',
|
||||||
|
fontSize: 14,
|
||||||
borderRadius: 16, color: '#ef4444', fontWeight: 500 }}>消耗积分:{msg.creditsCost}</span>
|
borderRadius: 16, color: '#ef4444', fontWeight: 500 }}>消耗积分:{msg.creditsCost}</span>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -14,7 +14,7 @@ import {
|
|||||||
UploadOutlined,
|
UploadOutlined,
|
||||||
|
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import { gethistory, gethistoryItems, getOAuthList, asyncBatchUploadMaterial, updateFilename, getUploadHistory, getAllOAuthAccountList, getOpenTypeAll } from '../api';
|
import { gethistory, gethistoryItems, getOAuthList, asyncBatchUploadMaterial, updateFilename, getUploadHistory, getAllOAuthAccountList, getOpenTypeAll, getPreTestList } from '../api';
|
||||||
|
|
||||||
const { Search } = Input;
|
const { Search } = Input;
|
||||||
const { Text } = Typography;
|
const { Text } = Typography;
|
||||||
@@ -68,6 +68,11 @@ const GeneratedRecord: React.FC = () => {
|
|||||||
const [selectedHistoryAccounts, setSelectedHistoryAccounts] = useState<any[]>([]);
|
const [selectedHistoryAccounts, setSelectedHistoryAccounts] = useState<any[]>([]);
|
||||||
const [openTypeMap, setOpenTypeMap] = useState<Record<number, string>>({});
|
const [openTypeMap, setOpenTypeMap] = useState<Record<number, string>>({});
|
||||||
|
|
||||||
|
const [isPreTest, setIsPreTest] = useState<string>('2');
|
||||||
|
const [preTestTemplate, setPreTestTemplate] = useState<string>('');
|
||||||
|
const [preTestTemplates, setPreTestTemplates] = useState<any[]>([]);
|
||||||
|
const [preTestTemplatesLoading, setPreTestTemplatesLoading] = useState(false);
|
||||||
|
|
||||||
// 推送任务历史弹窗相关状态
|
// 推送任务历史弹窗相关状态
|
||||||
const [uploadHistoryModalVisible, setUploadHistoryModalVisible] = useState(false);
|
const [uploadHistoryModalVisible, setUploadHistoryModalVisible] = useState(false);
|
||||||
const [uploadHistoryList, setUploadHistoryList] = useState<any[]>([]);
|
const [uploadHistoryList, setUploadHistoryList] = useState<any[]>([]);
|
||||||
@@ -669,6 +674,18 @@ const GeneratedRecord: React.FC = () => {
|
|||||||
message.destroy('downloadProgress');
|
message.destroy('downloadProgress');
|
||||||
message.success(`下载完成,共 ${successCount} 个文件`);
|
message.success(`下载完成,共 ${successCount} 个文件`);
|
||||||
};
|
};
|
||||||
|
const loadPreTestTemplates = async () => {
|
||||||
|
setPreTestTemplatesLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await getPreTestList({ page: 1, pageSize: 100 });
|
||||||
|
setPreTestTemplates(res.data?.data || res.data || []);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载前测模板失败:', error);
|
||||||
|
} finally {
|
||||||
|
setPreTestTemplatesLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleBatchUploadSelected = () => {
|
const handleBatchUploadSelected = () => {
|
||||||
if (selectedItems.size === 0) {
|
if (selectedItems.size === 0) {
|
||||||
message.warning('请先选择要推送的媒体');
|
message.warning('请先选择要推送的媒体');
|
||||||
@@ -677,6 +694,9 @@ const GeneratedRecord: React.FC = () => {
|
|||||||
setAccountIdLists([[]]);
|
setAccountIdLists([[]]);
|
||||||
setAccountIdInputs(['']);
|
setAccountIdInputs(['']);
|
||||||
setSelectedOauthItems([undefined]);
|
setSelectedOauthItems([undefined]);
|
||||||
|
setIsPreTest('2');
|
||||||
|
setPreTestTemplate('');
|
||||||
|
loadPreTestTemplates();
|
||||||
setUploadConfigModalVisible(true);
|
setUploadConfigModalVisible(true);
|
||||||
};
|
};
|
||||||
const handleSinglePushToMedia = () => {
|
const handleSinglePushToMedia = () => {
|
||||||
@@ -688,6 +708,9 @@ const GeneratedRecord: React.FC = () => {
|
|||||||
setSelectedOauthItems([undefined]);
|
setSelectedOauthItems([undefined]);
|
||||||
setMaterialFileNames(new Map());
|
setMaterialFileNames(new Map());
|
||||||
setUnifiedFileName('');
|
setUnifiedFileName('');
|
||||||
|
setIsPreTest('2');
|
||||||
|
setPreTestTemplate('');
|
||||||
|
loadPreTestTemplates();
|
||||||
setUploadConfigModalVisible(true);
|
setUploadConfigModalVisible(true);
|
||||||
};
|
};
|
||||||
const loadOAuthList = async (page: number, pageSize: number) => {
|
const loadOAuthList = async (page: number, pageSize: number) => {
|
||||||
@@ -779,6 +802,8 @@ const GeneratedRecord: React.FC = () => {
|
|||||||
let tasks: {
|
let tasks: {
|
||||||
advertiser_ids: string[];
|
advertiser_ids: string[];
|
||||||
resource_ids: string[];
|
resource_ids: string[];
|
||||||
|
is_pre_test: string;
|
||||||
|
pre_test_template: string;
|
||||||
oauth_id: string;
|
oauth_id: string;
|
||||||
source_model: string;
|
source_model: string;
|
||||||
}[] = [];
|
}[] = [];
|
||||||
@@ -815,6 +840,8 @@ const GeneratedRecord: React.FC = () => {
|
|||||||
resource_ids: resourceIds,
|
resource_ids: resourceIds,
|
||||||
oauth_id: oauthItem.value,
|
oauth_id: oauthItem.value,
|
||||||
source_model: sourceModel,
|
source_model: sourceModel,
|
||||||
|
is_pre_test: isPreTest,
|
||||||
|
pre_test_template: preTestTemplate,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -844,6 +871,8 @@ const GeneratedRecord: React.FC = () => {
|
|||||||
resource_ids: resourceIds,
|
resource_ids: resourceIds,
|
||||||
oauth_id: String(account.oauthId),
|
oauth_id: String(account.oauthId),
|
||||||
source_model: sourceModel,
|
source_model: sourceModel,
|
||||||
|
is_pre_test: isPreTest,
|
||||||
|
pre_test_template: preTestTemplate,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -868,6 +897,8 @@ const GeneratedRecord: React.FC = () => {
|
|||||||
setSelectedHistoryAccounts([]);
|
setSelectedHistoryAccounts([]);
|
||||||
setMaterialFileNames(new Map());
|
setMaterialFileNames(new Map());
|
||||||
setUnifiedFileName('');
|
setUnifiedFileName('');
|
||||||
|
setIsPreTest('2');
|
||||||
|
setPreTestTemplate('');
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('批量推送失败:', error);
|
console.error('批量推送失败:', error);
|
||||||
message.error(error.message || '批量推送失败');
|
message.error(error.message || '批量推送失败');
|
||||||
@@ -1202,7 +1233,6 @@ const GeneratedRecord: React.FC = () => {
|
|||||||
}}>
|
}}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||||
<Typography.Text style={{ color: '#94a3b8', fontSize: 14 }}>媒体类型:</Typography.Text>
|
<Typography.Text style={{ color: '#94a3b8', fontSize: 14 }}>媒体类型:</Typography.Text>
|
||||||
|
|
||||||
<Space>
|
<Space>
|
||||||
<Button
|
<Button
|
||||||
type={filterMedia === 'video' ? 'primary' : 'default'}
|
type={filterMedia === 'video' ? 'primary' : 'default'}
|
||||||
@@ -1512,7 +1542,41 @@ const GeneratedRecord: React.FC = () => {
|
|||||||
})()}
|
})()}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: 16, marginBottom: 16 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
<Typography.Text style={{ fontSize: 14, color: '#475569' }}>是否开启前测:</Typography.Text>
|
||||||
|
<Select
|
||||||
|
value={isPreTest}
|
||||||
|
onChange={(value) => {
|
||||||
|
setIsPreTest(value);
|
||||||
|
if (value !== '1') {
|
||||||
|
setPreTestTemplate('');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
style={{ width: 120 }}
|
||||||
|
options={[
|
||||||
|
{ value: '1', label: '是' },
|
||||||
|
{ value: '2', label: '否' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
<Typography.Text style={{ fontSize: 14, color: '#475569' }}>前测模板:</Typography.Text>
|
||||||
|
<Select
|
||||||
|
value={preTestTemplate}
|
||||||
|
onChange={(value) => setPreTestTemplate(value)}
|
||||||
|
style={{ width: 200 }}
|
||||||
|
disabled={isPreTest !== '1'}
|
||||||
|
loading={preTestTemplatesLoading}
|
||||||
|
placeholder={isPreTest !== '1' ? '请先开启前测' : '选择前测模板'}
|
||||||
|
options={preTestTemplates.map((item) => ({
|
||||||
|
value: item.id,
|
||||||
|
label: item.name,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<Tabs
|
<Tabs
|
||||||
size="small"
|
size="small"
|
||||||
activeKey={accountTab}
|
activeKey={accountTab}
|
||||||
@@ -1533,7 +1597,6 @@ const GeneratedRecord: React.FC = () => {
|
|||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{accountTab === 'new' && (
|
{accountTab === 'new' && (
|
||||||
<div>
|
<div>
|
||||||
{selectedOauthItems.map((oauthItem, index) => (
|
{selectedOauthItems.map((oauthItem, index) => (
|
||||||
@@ -1728,7 +1791,6 @@ const GeneratedRecord: React.FC = () => {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{accountTab === 'history' && (
|
{accountTab === 'history' && (
|
||||||
<div style={{ padding: 8 }}>
|
<div style={{ padding: 8 }}>
|
||||||
{historyOAuthLoading ? (
|
{historyOAuthLoading ? (
|
||||||
|
|||||||
+221
-195
@@ -19,13 +19,6 @@ interface PreTestField {
|
|||||||
description?: string;
|
description?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const statusConfig = {
|
|
||||||
completed: { label: '已完成', color: 'success' },
|
|
||||||
pending: { label: '待测试', color: 'default' },
|
|
||||||
testing: { label: '测试中', color: 'processing' },
|
|
||||||
failed: { label: '测试失败', color: 'error' },
|
|
||||||
};
|
|
||||||
|
|
||||||
const PRETEST_FIELDS: PreTestField[] = [
|
const PRETEST_FIELDS: PreTestField[] = [
|
||||||
{ id: 'AD_APP_ACTIVATE', name: 'AD_APP_ACTIVATE', label: '应用-激活', description: '应用激活行为' },
|
{ id: 'AD_APP_ACTIVATE', name: 'AD_APP_ACTIVATE', label: '应用-激活', description: '应用激活行为' },
|
||||||
{ id: 'AD_APP_AUTH', name: 'AD_APP_AUTH', label: '应用-授信', description: '应用授信行为' },
|
{ id: 'AD_APP_AUTH', name: 'AD_APP_AUTH', label: '应用-授信', description: '应用授信行为' },
|
||||||
@@ -129,12 +122,24 @@ const PRETEST_FIELDS: PreTestField[] = [
|
|||||||
{ id: 'QC_PRODUCT_ROI', name: 'QC_PRODUCT_ROI', label: '商品投放-商品支付roi', description: '商品支付roi' },
|
{ id: 'QC_PRODUCT_ROI', name: 'QC_PRODUCT_ROI', label: '商品投放-商品支付roi', description: '商品支付roi' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const formatDateTime = (dateStr: string) => {
|
||||||
|
if (!dateStr) return '';
|
||||||
|
const date = new Date(dateStr);
|
||||||
|
const year = date.getFullYear();
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(date.getDate()).padStart(2, '0');
|
||||||
|
const hours = String(date.getHours()).padStart(2, '0');
|
||||||
|
const minutes = String(date.getMinutes()).padStart(2, '0');
|
||||||
|
const seconds = String(date.getSeconds()).padStart(2, '0');
|
||||||
|
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
|
||||||
|
};
|
||||||
|
|
||||||
const columns = [
|
const columns = [
|
||||||
{
|
{
|
||||||
title: '前测模板ID',
|
title: '前测模板编号',
|
||||||
dataIndex: 'id',
|
dataIndex: 'id',
|
||||||
key: 'id',
|
key: 'id',
|
||||||
width: 110,
|
width: 120,
|
||||||
render: (text: string) => <Typography.Text strong style={{ color: '#1e293b', fontSize: 13 }}>{text}</Typography.Text>,
|
render: (text: string) => <Typography.Text strong style={{ color: '#1e293b', fontSize: 13 }}>{text}</Typography.Text>,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -273,13 +278,13 @@ const columns = [
|
|||||||
dataIndex: 'createdAt',
|
dataIndex: 'createdAt',
|
||||||
key: 'createdAt',
|
key: 'createdAt',
|
||||||
width: 160,
|
width: 160,
|
||||||
render: (text: string) => <Typography.Text style={{ color: '#94a3b8', fontSize: 12 }}>{text}</Typography.Text>,
|
render: (text: string) => <Typography.Text style={{ color: '#94a3b8', fontSize: 12 }}>{formatDateTime(text)}</Typography.Text>,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '操作',
|
title: '操作',
|
||||||
dataIndex: 'operation',
|
dataIndex: 'operation',
|
||||||
|
fixed: 'right' as const,
|
||||||
key: 'operation',
|
key: 'operation',
|
||||||
width: 140,
|
|
||||||
render: (_: any, record: PreTestRecord) => (
|
render: (_: any, record: PreTestRecord) => (
|
||||||
<Space size={4}>
|
<Space size={4}>
|
||||||
<Button
|
<Button
|
||||||
@@ -320,7 +325,6 @@ const PreTest: React.FC = () => {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const res = await getPreTestList({ page: currentPage, pageSize });
|
const res = await getPreTestList({ page: currentPage, pageSize });
|
||||||
console.log(res);
|
|
||||||
setRecords(res.data || []);
|
setRecords(res.data || []);
|
||||||
setTotal(res.pagination?.total || 0);
|
setTotal(res.pagination?.total || 0);
|
||||||
setCurrentPage(res.pagination?.page || 1);
|
setCurrentPage(res.pagination?.page || 1);
|
||||||
@@ -514,122 +518,154 @@ const PreTest: React.FC = () => {
|
|||||||
layout="vertical"
|
layout="vertical"
|
||||||
style={{ marginTop: 16 }}
|
style={{ marginTop: 16 }}
|
||||||
>
|
>
|
||||||
<Form.Item
|
<div style={{ display: 'flex', gap: 16 }}>
|
||||||
name="name"
|
<Form.Item
|
||||||
label="模板名称"
|
style={{ flex: 1 }}
|
||||||
rules={[{ required: true, message: '请输入模板名称' }]}
|
name="name"
|
||||||
>
|
label="模板名称"
|
||||||
<Input
|
rules={[{ required: true, message: '请输入模板名称' }]}
|
||||||
placeholder="请输入模板名称"
|
>
|
||||||
style={{ borderRadius: 8, height: 40 }}
|
<Input
|
||||||
/>
|
placeholder="请输入模板名称"
|
||||||
</Form.Item>
|
style={{ borderRadius: 8, height: 40 }}
|
||||||
<Form.Item
|
/>
|
||||||
name="platform"
|
</Form.Item>
|
||||||
label="平台"
|
<Form.Item
|
||||||
initialValue="AD"
|
style={{ flex: 1 }}
|
||||||
rules={[{ required: true, message: '请选择平台' }]}
|
name="platform"
|
||||||
>
|
label="平台"
|
||||||
<Select
|
initialValue="AD"
|
||||||
placeholder="请选择平台"
|
rules={[{ required: true, message: '请选择平台' }]}
|
||||||
style={{ borderRadius: 8, height: 40 }}
|
>
|
||||||
onChange={(value) => {
|
<Select
|
||||||
setPlatform(value);
|
placeholder="请选择平台"
|
||||||
}}
|
style={{ borderRadius: 8, height: 40 }}
|
||||||
options={[
|
onChange={(value) => {
|
||||||
{ value: 'AD', label: 'AD' },
|
setPlatform(value);
|
||||||
{ value: 'QIANCHUAN', label: '千川' },
|
}}
|
||||||
{ value: 'LOCAL', label: '本地推' },
|
options={[
|
||||||
|
{ value: 'AD', label: 'AD' },
|
||||||
|
{ value: 'QIANCHUAN', label: '千川' },
|
||||||
|
{ value: 'LOCAL', label: '本地推' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</div>
|
||||||
|
{platform !== 'LOCAL' && (
|
||||||
|
<Form.Item
|
||||||
|
name="external_action"
|
||||||
|
label="转化目标"
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
mode="multiple"
|
||||||
|
placeholder="请选择转化目标(可多选)"
|
||||||
|
style={{ borderRadius: 8, minHeight: 40 }}
|
||||||
|
maxTagCount="responsive"
|
||||||
|
options={PRETEST_FIELDS.filter(field => {
|
||||||
|
if (platform === 'AD') {
|
||||||
|
return field.name.startsWith('AD_');
|
||||||
|
} else if (platform === 'QIANCHUAN') {
|
||||||
|
return field.name.startsWith('QC_');
|
||||||
|
} else if (platform === 'LOCAL') {
|
||||||
|
return field.name.startsWith('LOCAL_');
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}).map(field => ({
|
||||||
|
value: field.name,
|
||||||
|
label: (
|
||||||
|
<span>
|
||||||
|
{field.label}
|
||||||
|
{field.description && (
|
||||||
|
<Typography.Text style={{ color: '#94a3b8', fontSize: 12, marginLeft: 4 }}>
|
||||||
|
({field.description})
|
||||||
|
</Typography.Text>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: 16 }}>
|
||||||
|
<Form.Item
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
name="cpa_bid"
|
||||||
|
label="目标转化成本"
|
||||||
|
rules={[
|
||||||
|
{ type: 'number', min: 1, max: 10000, message: '取值范围: 1-10000' },
|
||||||
]}
|
]}
|
||||||
/>
|
getValueFromEvent={(e) => parseFloat(e.target.value) || undefined}
|
||||||
</Form.Item>
|
>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
placeholder="取值范围: 1-10000,精确到小数点后2位"
|
||||||
|
style={{ borderRadius: 8, height: 40 }}
|
||||||
|
min={1}
|
||||||
|
max={10000}
|
||||||
|
step={0.01}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
<Form.Item
|
<Form.Item
|
||||||
name="external_action"
|
style={{ flex: 1 }}
|
||||||
label="转化目标"
|
name="audience_gender"
|
||||||
>
|
label="受众性别"
|
||||||
<Select
|
>
|
||||||
mode="multiple"
|
<Select
|
||||||
placeholder="请选择转化目标(可多选)"
|
placeholder="请选择受众性别"
|
||||||
style={{ borderRadius: 8, minHeight: 40 }}
|
style={{ borderRadius: 8, height: 40 }}
|
||||||
maxTagCount="responsive"
|
options={[
|
||||||
options={PRETEST_FIELDS.filter(field => {
|
{ value: 'ALL', label: '不限' },
|
||||||
if (platform === 'AD') {
|
{ value: 'MALE', label: '男' },
|
||||||
return field.name.startsWith('AD_');
|
{ value: 'FEMALE', label: '女' },
|
||||||
} else if (platform === 'QIANCHUAN') {
|
]}
|
||||||
return field.name.startsWith('QC_');
|
/>
|
||||||
} else if (platform === 'LOCAL') {
|
</Form.Item>
|
||||||
return field.name.startsWith('LOCAL_');
|
</div>
|
||||||
}
|
<div style={{ display: 'flex', gap: 16 }}>
|
||||||
return true;
|
<Form.Item
|
||||||
}).map(field => ({
|
style={{ flex: 1 }}
|
||||||
value: field.name,
|
name="audience_age"
|
||||||
label: (
|
label="受众年龄"
|
||||||
<span>
|
>
|
||||||
{field.label}
|
<Select
|
||||||
{field.description && (
|
mode="multiple"
|
||||||
<Typography.Text style={{ color: '#94a3b8', fontSize: 12, marginLeft: 4 }}>
|
placeholder="请选择受众年龄(可多选)"
|
||||||
({field.description})
|
style={{ borderRadius: 8, minHeight: 40 }}
|
||||||
</Typography.Text>
|
maxTagCount="responsive"
|
||||||
)}
|
options={[
|
||||||
</span>
|
{ value: 'ALL', label: '不限' },
|
||||||
),
|
{ value: '18-23', label: '18-23岁' },
|
||||||
}))}
|
{ value: '24-30', label: '24-30岁' },
|
||||||
/>
|
{ value: '31-40', label: '31-40岁' },
|
||||||
</Form.Item>
|
{ value: '41-49', label: '41-49岁' },
|
||||||
|
{ value: '50', label: '50岁以上' },
|
||||||
<Form.Item
|
]}
|
||||||
name="cpa_bid"
|
/>
|
||||||
label="目标转化成本"
|
</Form.Item>
|
||||||
rules={[
|
<Form.Item
|
||||||
{ type: 'number', min: 1, max: 10000, message: '取值范围: 1-10000' },
|
style={{ flex: 1 }}
|
||||||
]}
|
name="audience_network"
|
||||||
getValueFromEvent={(e) => parseFloat(e.target.value) || undefined}
|
label="网络类型"
|
||||||
>
|
>
|
||||||
<Input
|
<Select
|
||||||
type="number"
|
mode="multiple"
|
||||||
placeholder="取值范围: 1-10000,精确到小数点后2位"
|
placeholder="请选择网络类型(可多选)"
|
||||||
style={{ borderRadius: 8, height: 40 }}
|
style={{ borderRadius: 8, minHeight: 40 }}
|
||||||
min={1}
|
maxTagCount="responsive"
|
||||||
max={10000}
|
options={[
|
||||||
step={0.01}
|
{ value: 'ALL', label: '不限' },
|
||||||
/>
|
{ value: '5G', label: '5G' },
|
||||||
</Form.Item>
|
{ value: '4G', label: '4G' },
|
||||||
|
{ value: '3G', label: '3G' },
|
||||||
<Form.Item
|
{ value: '2G', label: '2G' },
|
||||||
name="audience_gender"
|
{ value: 'WIFI', label: 'wifi' },
|
||||||
label="受众性别"
|
]}
|
||||||
>
|
/>
|
||||||
<Select
|
</Form.Item>
|
||||||
placeholder="请选择受众性别"
|
</div>
|
||||||
style={{ borderRadius: 8, height: 40 }}
|
|
||||||
options={[
|
|
||||||
{ value: 'ALL', label: '不限' },
|
|
||||||
{ value: 'MALE', label: '男' },
|
|
||||||
{ value: 'FEMALE', label: '女' },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</Form.Item>
|
|
||||||
|
|
||||||
<Form.Item
|
|
||||||
name="audience_age"
|
|
||||||
label="受众年龄"
|
|
||||||
>
|
|
||||||
<Select
|
|
||||||
mode="multiple"
|
|
||||||
placeholder="请选择受众年龄(可多选)"
|
|
||||||
style={{ borderRadius: 8, minHeight: 40 }}
|
|
||||||
maxTagCount="responsive"
|
|
||||||
options={[
|
|
||||||
{ value: 'ALL', label: '不限' },
|
|
||||||
{ value: '18-23', label: '18-23岁' },
|
|
||||||
{ value: '24-30', label: '24-30岁' },
|
|
||||||
{ value: '31-40', label: '31-40岁' },
|
|
||||||
{ value: '41-49', label: '41-49岁' },
|
|
||||||
{ value: '50', label: '50岁以上' },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item
|
<Form.Item
|
||||||
name="audience_region"
|
name="audience_region"
|
||||||
label="受众地区"
|
label="受众地区"
|
||||||
@@ -644,53 +680,37 @@ const PreTest: React.FC = () => {
|
|||||||
maxTagCount="responsive"
|
maxTagCount="responsive"
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
<div style={{ display: 'flex', gap: 16 }}>
|
||||||
<Form.Item
|
<Form.Item
|
||||||
name="audience_network"
|
style={{ flex: 1 }}
|
||||||
label="网络类型"
|
name="cus_name"
|
||||||
>
|
label="客户主体名称"
|
||||||
<Select
|
>
|
||||||
mode="multiple"
|
<Input
|
||||||
placeholder="请选择网络类型(可多选)"
|
placeholder="请输入客户主体名称"
|
||||||
style={{ borderRadius: 8, minHeight: 40 }}
|
style={{ borderRadius: 8, height: 40 }}
|
||||||
maxTagCount="responsive"
|
/>
|
||||||
options={[
|
</Form.Item>
|
||||||
{ value: 'ALL', label: '不限' },
|
<Form.Item
|
||||||
{ value: '5G', label: '5G' },
|
style={{ flex: 1 }}
|
||||||
{ value: '4G', label: '4G' },
|
name="pricing_type"
|
||||||
{ value: '3G', label: '3G' },
|
label="出价类型"
|
||||||
{ value: '2G', label: '2G' },
|
>
|
||||||
{ value: 'WIFI', label: 'wifi' },
|
<Select
|
||||||
]}
|
placeholder="请选择出价类型"
|
||||||
/>
|
style={{ borderRadius: 8, height: 40 }}
|
||||||
</Form.Item>
|
options={[
|
||||||
|
{ value: 'OCPC', label: 'OCPC' },
|
||||||
<Form.Item
|
{ value: 'CPA', label: 'CPA' },
|
||||||
name="cus_name"
|
{ value: 'OCPM', label: 'OCPM' },
|
||||||
label="客户主体名称"
|
]}
|
||||||
>
|
/>
|
||||||
<Input
|
</Form.Item>
|
||||||
placeholder="请输入客户主体名称"
|
</div>
|
||||||
style={{ borderRadius: 8, height: 40 }}
|
|
||||||
/>
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item
|
|
||||||
name="pricing_type"
|
|
||||||
label="出价类型"
|
|
||||||
>
|
|
||||||
<Select
|
|
||||||
placeholder="请选择出价类型"
|
|
||||||
style={{ borderRadius: 8, height: 40 }}
|
|
||||||
options={[
|
|
||||||
{ value: 'OCPC', label: 'OCPC' },
|
|
||||||
{ value: 'CPA', label: 'CPA' },
|
|
||||||
{ value: 'OCPM', label: 'OCPM' },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</Form.Item>
|
|
||||||
{platform == 'AD' && (
|
{platform == 'AD' && (
|
||||||
<>
|
<div style={{ display: 'flex', gap: 16 }}>
|
||||||
<Form.Item
|
<Form.Item
|
||||||
|
style={{ flex: 1 }}
|
||||||
name="cost_cap"
|
name="cost_cap"
|
||||||
label="是否最优成本出价"
|
label="是否最优成本出价"
|
||||||
valuePropName="checked"
|
valuePropName="checked"
|
||||||
@@ -706,6 +726,7 @@ const PreTest: React.FC = () => {
|
|||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
<Form.Item
|
<Form.Item
|
||||||
|
style={{ flex: 1 }}
|
||||||
name="target_cost"
|
name="target_cost"
|
||||||
label="是否稳定成本出价"
|
label="是否稳定成本出价"
|
||||||
valuePropName="checked"
|
valuePropName="checked"
|
||||||
@@ -721,6 +742,7 @@ const PreTest: React.FC = () => {
|
|||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
<Form.Item
|
<Form.Item
|
||||||
|
style={{ flex: 1 }}
|
||||||
name="nobid"
|
name="nobid"
|
||||||
label="是否最大转化出价"
|
label="是否最大转化出价"
|
||||||
valuePropName="checked"
|
valuePropName="checked"
|
||||||
@@ -734,38 +756,42 @@ const PreTest: React.FC = () => {
|
|||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</>
|
</div>
|
||||||
)}
|
)}
|
||||||
<Form.Item
|
<div style={{ display: 'flex', gap: 16 }}>
|
||||||
name="cpc_bid"
|
<Form.Item
|
||||||
label="目标点击成本"
|
style={{ flex: 1 }}
|
||||||
getValueFromEvent={(e) => parseFloat(e.target.value) || undefined}
|
name="cpc_bid"
|
||||||
>
|
label="目标点击成本"
|
||||||
<Input
|
getValueFromEvent={(e) => parseFloat(e.target.value) || undefined}
|
||||||
type="number"
|
>
|
||||||
placeholder="请输入目标点击成本"
|
<Input
|
||||||
style={{ borderRadius: 8, height: 40 }}
|
type="number"
|
||||||
/>
|
placeholder="请输入目标点击成本"
|
||||||
</Form.Item>
|
style={{ borderRadius: 8, height: 40 }}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
<Form.Item
|
<Form.Item
|
||||||
name="budget"
|
style={{ flex: 1 }}
|
||||||
label="预算金额"
|
name="budget"
|
||||||
getValueFromEvent={(e) => parseFloat(e.target.value) || undefined}
|
label="预算金额"
|
||||||
>
|
getValueFromEvent={(e) => parseFloat(e.target.value) || undefined}
|
||||||
<Input
|
>
|
||||||
type="number"
|
<Input
|
||||||
placeholder="请输入预算金额"
|
type="number"
|
||||||
style={{ borderRadius: 8, height: 40 }}
|
placeholder="请输入预算金额"
|
||||||
/>
|
style={{ borderRadius: 8, height: 40 }}
|
||||||
</Form.Item>
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</div>
|
||||||
<Form.Item
|
<Form.Item
|
||||||
name="note"
|
name="note"
|
||||||
label="备注"
|
label="备注"
|
||||||
>
|
>
|
||||||
<Input.TextArea
|
<Input.TextArea
|
||||||
placeholder="请输入备注"
|
placeholder="请输入备注"
|
||||||
rows={3}
|
rows={2}
|
||||||
style={{ borderRadius: 8 }}
|
style={{ borderRadius: 8 }}
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|||||||
@@ -471,30 +471,42 @@ export default function VideoFrameExtractor() {
|
|||||||
<Modal
|
<Modal
|
||||||
title={
|
title={
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||||
<div style={{ width: 4, height: 20, background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)', borderRadius: 2 }} />
|
<div style={{ width: 4, height: 20, background: 'linear-gradient(180deg, #6366f1 0%, #8b5cf6 100%)', borderRadius: 2 }} />
|
||||||
<span style={{ fontSize: 16, fontWeight: 600, background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)', WebkitBackgroundClip: 'text', WebkitTextFillColor: 'transparent' }}>创作记录</span>
|
<span style={{
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: 700,
|
||||||
|
background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)',
|
||||||
|
WebkitBackgroundClip: 'text',
|
||||||
|
WebkitTextFillColor: 'transparent',
|
||||||
|
backgroundClip: 'text'
|
||||||
|
}}>
|
||||||
|
创作记录
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
open={isModalOpen}
|
open={isModalOpen}
|
||||||
onCancel={() => setIsModalOpen(false)}
|
onCancel={() => setIsModalOpen(false)}
|
||||||
width={800}
|
width={850}
|
||||||
footer={null}
|
footer={null}
|
||||||
|
style={{ borderRadius: 20 }}
|
||||||
styles={{
|
styles={{
|
||||||
body: { background: 'linear-gradient(180deg, #f8fafc 0%, #eef2ff 100%)' },
|
body: { height: 580, display: 'flex', flexDirection: 'column', padding: 0 },
|
||||||
header: { background: '#fff', borderBottom: '1px solid rgba(99, 102, 241, 0.1)', padding: '20px 24px' },
|
header: { background: 'rgba(255,255,255,0.6)', backdropFilter: 'blur(10px)', borderBottom: '1px solid rgba(99, 102, 241, 0.08)', padding: '16px 24px' },
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: 16, gap: 8 }}>
|
{/* 搜索区域 */}
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'flex-end', margin: '16px 24px', flexShrink: 0 }}>
|
||||||
<Input
|
<Input
|
||||||
placeholder="搜索产品名称"
|
placeholder="搜索产品名称"
|
||||||
value={searchKeyword}
|
value={searchKeyword}
|
||||||
onChange={(e) => setSearchKeyword(e.target.value)}
|
onChange={(e) => setSearchKeyword(e.target.value)}
|
||||||
onPressEnter={handleSearch}
|
onPressEnter={handleSearch}
|
||||||
style={{
|
style={{
|
||||||
width: 200,
|
width: 220,
|
||||||
borderRadius: 10,
|
borderRadius: 10,
|
||||||
border: '1px solid rgba(99, 102, 241, 0.2)',
|
marginRight: 10,
|
||||||
height: 40,
|
border: '1px solid rgba(99, 102, 241, 0.15)',
|
||||||
|
background: 'rgba(255,255,255,0.8)',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
@@ -502,7 +514,6 @@ export default function VideoFrameExtractor() {
|
|||||||
onClick={handleSearch}
|
onClick={handleSearch}
|
||||||
style={{
|
style={{
|
||||||
borderRadius: 10,
|
borderRadius: 10,
|
||||||
height: 40,
|
|
||||||
background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)',
|
background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)',
|
||||||
border: 'none',
|
border: 'none',
|
||||||
}}
|
}}
|
||||||
@@ -511,16 +522,15 @@ export default function VideoFrameExtractor() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div style={{ flex: 1, overflow: 'auto', padding: '0 24px 24px' }}>
|
||||||
style={{
|
<div style={{
|
||||||
background: 'linear-gradient(135deg, rgba(255,255,255,0.9) 0%, rgba(255,255,255,0.7) 100%)',
|
background: 'rgba(255,255,255,0.85)',
|
||||||
backdropFilter: 'blur(20px)',
|
backdropFilter: 'blur(10px)',
|
||||||
borderRadius: 16,
|
borderRadius: 16,
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
border: '1px solid rgba(99, 102, 241, 0.1)',
|
border: '1px solid rgba(99, 102, 241, 0.08)',
|
||||||
boxShadow: '0 8px 32px rgba(99, 102, 241, 0.08)',
|
boxShadow: '0 4px 16px rgba(99, 102, 241, 0.06)',
|
||||||
}}
|
}}>
|
||||||
>
|
|
||||||
<Table
|
<Table
|
||||||
columns={[
|
columns={[
|
||||||
{
|
{
|
||||||
@@ -634,6 +644,7 @@ export default function VideoFrameExtractor() {
|
|||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user