fix: v0.10.1 验收修复 — 重新编辑按钮/Decimal序列化/仪表盘布局/.env.local自动加载
- 视频详情弹窗「重新编辑」按钮改为所有视角可见(admin/团管点击跳转生成页回填数据) - 团队详情月消费限额/加价率支持内联编辑,保存后列表同步刷新 - 修复 Decimal not JSON serializable(审计日志 before/after 字段) - 允许月消费限额输入 -1(不限制),fmtMoney 显示「不限」 - 仪表盘利润卡片移至第二行,团队/用户排行显示有秒数消耗的历史数据 - 资产页视频详情显示参考图片缩略图(reference_urls→references映射) - Toolbar 预估消耗仅在有内容时显示,全部清空与预估文字对齐 - settings.py 自动加载 backend/.env.local(本地开发免手动source) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
9259988094
commit
ef2212e345
@ -586,8 +586,8 @@ def admin_stats_view(request):
|
||||
filter=Q(generation_records__created_at__date__gte=first_of_month),
|
||||
),
|
||||
)
|
||||
.filter(cost_consumed__gt=0)
|
||||
.order_by('-cost_consumed')[:10]
|
||||
.filter(Q(cost_consumed__gt=0) | Q(seconds_consumed__gt=0))
|
||||
.order_by('-cost_consumed', '-seconds_consumed')[:10]
|
||||
)
|
||||
|
||||
# Team consumption ranking this month
|
||||
@ -602,8 +602,8 @@ def admin_stats_view(request):
|
||||
filter=Q(members__generation_records__created_at__date__gte=first_of_month),
|
||||
),
|
||||
)
|
||||
.filter(cost_consumed__gt=0)
|
||||
.order_by('-cost_consumed')
|
||||
.filter(Q(cost_consumed__gt=0) | Q(seconds_consumed__gt=0))
|
||||
.order_by('-cost_consumed', '-seconds_consumed')
|
||||
)
|
||||
|
||||
# Team profit ranking
|
||||
@ -763,7 +763,10 @@ def admin_team_detail_view(request, team_id):
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
# Handle disabled_by based on is_active change
|
||||
before = {f: getattr(team, f) for f in serializer.validated_data}
|
||||
def _json_safe(v):
|
||||
from decimal import Decimal as D
|
||||
return float(v) if isinstance(v, D) else v
|
||||
before = {f: _json_safe(getattr(team, f)) for f in serializer.validated_data}
|
||||
before['disabled_by'] = team.disabled_by
|
||||
for field, value in serializer.validated_data.items():
|
||||
setattr(team, field, value)
|
||||
@ -787,7 +790,7 @@ def admin_team_detail_view(request, team_id):
|
||||
setattr(ac, field, value)
|
||||
ac.save()
|
||||
|
||||
after = {f: getattr(team, f) for f in serializer.validated_data}
|
||||
after = {f: _json_safe(getattr(team, f)) for f in serializer.validated_data}
|
||||
after['disabled_by'] = team.disabled_by
|
||||
log_admin_action(request, 'team_update', 'team', target_id=team.id, target_name=team.name,
|
||||
before=before, after=after)
|
||||
@ -2344,6 +2347,7 @@ def admin_assets_user_videos(request, user_id):
|
||||
'duration': r.duration,
|
||||
'seconds_consumed': r.seconds_consumed,
|
||||
'aspect_ratio': r.aspect_ratio,
|
||||
'reference_urls': r.reference_urls or [],
|
||||
'created_at': r.created_at.isoformat(),
|
||||
})
|
||||
|
||||
@ -2423,6 +2427,7 @@ def team_assets_member_videos(request, member_id):
|
||||
'duration': r.duration,
|
||||
'seconds_consumed': r.seconds_consumed,
|
||||
'aspect_ratio': r.aspect_ratio,
|
||||
'reference_urls': r.reference_urls or [],
|
||||
'created_at': r.created_at.isoformat(),
|
||||
})
|
||||
|
||||
|
||||
@ -6,6 +6,23 @@ from datetime import timedelta
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
|
||||
# 自动加载 .env.local(本地开发用,不进 git)
|
||||
_env_local = BASE_DIR / '.env.local'
|
||||
if _env_local.exists():
|
||||
with open(_env_local, encoding='utf-8') as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if not line or line.startswith('#'):
|
||||
continue
|
||||
# 去掉 export 前缀
|
||||
if line.startswith('export '):
|
||||
line = line[7:]
|
||||
key, _, value = line.partition('=')
|
||||
if key and _ == '=':
|
||||
# 去掉引号
|
||||
value = value.strip().strip('"').strip("'")
|
||||
os.environ.setdefault(key.strip(), value)
|
||||
|
||||
SECRET_KEY = os.environ.get('DJANGO_SECRET_KEY', '')
|
||||
if not SECRET_KEY:
|
||||
import warnings
|
||||
|
||||
@ -229,19 +229,31 @@ export function Toolbar() {
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Spacer — push right group to the end */}
|
||||
<div className={styles.spacer} />
|
||||
|
||||
{/* 全部清空 + 预估消耗:仅有内容时显示 */}
|
||||
{isSubmittable && (
|
||||
<span
|
||||
onClick={() => useInputBarStore.getState().reset()}
|
||||
style={{ fontSize: 12, color: '#8b8ea8', whiteSpace: 'nowrap', userSelect: 'none', cursor: 'pointer', transition: 'filter 0.15s', marginRight: 20, lineHeight: 1 }}
|
||||
onMouseEnter={(e) => { (e.currentTarget as HTMLElement).style.filter = 'brightness(1.4)'; }}
|
||||
onMouseLeave={(e) => { (e.currentTarget as HTMLElement).style.filter = ''; }}
|
||||
>
|
||||
⟲ 全部清空
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Estimated cost */}
|
||||
{tokenPrice > 0 && (
|
||||
{isSubmittable && tokenPrice > 0 && (
|
||||
<span
|
||||
style={{ fontSize: 12, color: '#8b8ea8', whiteSpace: 'nowrap', userSelect: 'none' }}
|
||||
style={{ fontSize: 12, color: '#8b8ea8', whiteSpace: 'nowrap', userSelect: 'none', marginRight: 16, lineHeight: 1 }}
|
||||
title={`预估公式: (宽 x 高 x 24fps x 时长) / 1024 = tokens, tokens x 单价 / 1000000 = 费用`}
|
||||
>
|
||||
预估消耗:{estimatedTokens.toLocaleString()} tokens / ¥{estimatedCost}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Spacer */}
|
||||
<div className={styles.spacer} />
|
||||
|
||||
{/* Send button */}
|
||||
<button
|
||||
className={`${styles.sendBtn} ${isSubmittable ? styles.sendEnabled : styles.sendDisabled}`}
|
||||
|
||||
@ -1,8 +1,10 @@
|
||||
import { useRef, useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import type { GenerationTask } from '../types';
|
||||
import { AmbientBackground } from './AmbientBackground';
|
||||
import { ConfirmModal } from './ConfirmModal';
|
||||
import { ImageLightbox } from './ImageLightbox';
|
||||
import { useInputBarStore } from '../store/inputBar';
|
||||
import styles from './VideoDetailModal.module.css';
|
||||
|
||||
interface Props {
|
||||
@ -18,6 +20,7 @@ interface Props {
|
||||
}
|
||||
|
||||
export function VideoDetailModal({ task, onClose, onReEdit, onRegenerate, onDelete, onPrev, onNext, hasPrev, hasNext }: Props) {
|
||||
const navigate = useNavigate();
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const videoContainerRef = useRef<HTMLDivElement>(null);
|
||||
const videoAreaRef = useRef<HTMLDivElement>(null);
|
||||
@ -199,9 +202,35 @@ export function VideoDetailModal({ task, onClose, onReEdit, onRegenerate, onDele
|
||||
};
|
||||
|
||||
const handleReEdit = () => {
|
||||
if (task && onReEdit) {
|
||||
if (!task) return;
|
||||
if (onReEdit) {
|
||||
onReEdit(task.id);
|
||||
onClose();
|
||||
} else {
|
||||
// Fallback: load task into input bar and navigate to generation page
|
||||
const store = useInputBarStore.getState();
|
||||
store.reset();
|
||||
store.setPrompt(task.prompt || '');
|
||||
if (task.mode) store.setMode(task.mode as 'universal' | 'keyframe');
|
||||
if (task.model) store.setModel(task.model as 'seedance_2.0' | 'seedance_2.0_fast');
|
||||
if (task.aspectRatio) store.setAspectRatio(task.aspectRatio as any);
|
||||
if (task.duration) store.setDuration(task.duration);
|
||||
// Load references from task
|
||||
if (task.references && task.references.length > 0) {
|
||||
const refs = task.references.filter(r => r.previewUrl).map(r => ({
|
||||
id: r.id,
|
||||
file: null as unknown as File,
|
||||
previewUrl: r.previewUrl,
|
||||
type: r.type as 'image' | 'video' | 'audio',
|
||||
label: r.label,
|
||||
tosUrl: r.previewUrl,
|
||||
}));
|
||||
if (refs.length > 0) {
|
||||
useInputBarStore.setState({ references: refs });
|
||||
}
|
||||
}
|
||||
onClose();
|
||||
navigate('/');
|
||||
}
|
||||
};
|
||||
|
||||
@ -467,6 +496,17 @@ export function VideoDetailModal({ task, onClose, onReEdit, onRegenerate, onDele
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Re-edit button above info bar */}
|
||||
<div style={{ padding: '0 20px 12px', borderBottom: '1px solid rgba(255,255,255,0.08)' }}>
|
||||
<button className={styles.cardBtn} onClick={handleReEdit} style={{ width: '100%', justifyContent: 'center' }}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
|
||||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
|
||||
</svg>
|
||||
重新编辑
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Fixed bottom: info bar + actions card */}
|
||||
<div className={styles.infoPanelBottom}>
|
||||
<div className={styles.infoBar}>
|
||||
|
||||
@ -168,7 +168,7 @@ export const adminApi = {
|
||||
getTeamDetail: (teamId: number) =>
|
||||
api.get<TeamDetail>(`/admin/teams/${teamId}`),
|
||||
|
||||
updateTeam: (teamId: number, data: { name?: string; monthly_seconds_limit?: number; daily_member_limit_default?: number; is_active?: boolean; expected_regions?: string; anomaly_config?: Partial<TeamAnomalyConfig> }) =>
|
||||
updateTeam: (teamId: number, data: { name?: string; monthly_seconds_limit?: number; monthly_spending_limit?: number; daily_member_limit_default?: number; is_active?: boolean; expected_regions?: string; anomaly_config?: Partial<TeamAnomalyConfig> }) =>
|
||||
api.put(`/admin/teams/${teamId}`, data),
|
||||
|
||||
topUpTeam: (teamId: number, amount: number) =>
|
||||
|
||||
@ -32,6 +32,13 @@ function VideoThumbnail({ video, onClick }: { video: AssetVideo; onClick: () =>
|
||||
}
|
||||
|
||||
function assetVideoToTask(v: AssetVideo): GenerationTask {
|
||||
const references = (v.reference_urls || []).map((ref, i) => ({
|
||||
id: `ref_${v.task_id}_${i}`,
|
||||
type: (ref.type || 'image') as 'image' | 'video',
|
||||
previewUrl: ref.url,
|
||||
label: ref.label || `素材${i + 1}`,
|
||||
role: ref.role,
|
||||
}));
|
||||
return {
|
||||
id: String(v.id),
|
||||
taskId: v.task_id,
|
||||
@ -41,7 +48,7 @@ function assetVideoToTask(v: AssetVideo): GenerationTask {
|
||||
model: 'seedance_2.0',
|
||||
aspectRatio: (v.aspect_ratio as any) || '16:9',
|
||||
duration: v.duration as any,
|
||||
references: [],
|
||||
references,
|
||||
status: 'completed',
|
||||
progress: 100,
|
||||
resultUrl: v.result_url,
|
||||
|
||||
@ -193,32 +193,7 @@ export function DashboardPage() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className={styles.chartSection}>
|
||||
<h2 className={styles.sectionTitle}>消费趋势(近30天 · 元)</h2>
|
||||
<div className={styles.chartWrapper}>
|
||||
<ReactEChartsCore echarts={echarts} option={trendOption} style={{ height: 320 }} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.chartsRow}>
|
||||
{sortedTeams.length > 0 && (
|
||||
<div className={styles.chartSection}>
|
||||
<h2 className={styles.sectionTitle}>团队消费排行(本月)</h2>
|
||||
<div className={styles.chartWrapper}>
|
||||
<ReactEChartsCore echarts={echarts} option={teamBarOption} style={{ height: Math.max(300, sortedTeams.length * 36) }} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles.chartSection}>
|
||||
<h2 className={styles.sectionTitle}>用户消费排行(Top 10 · 本月)</h2>
|
||||
<div className={styles.chartWrapper}>
|
||||
<ReactEChartsCore echarts={echarts} option={barOption} style={{ height: Math.max(300, sortedUsers.length * 36) }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Profit Section */}
|
||||
{/* Row 2: Profit cards */}
|
||||
<div className={styles.statsGrid}>
|
||||
<div className={styles.statCard}>
|
||||
<div className={styles.statLabel}>总收入</div>
|
||||
@ -238,62 +213,32 @@ export function DashboardPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(stats.team_profit_ranking || []).length > 0 && (() => {
|
||||
const profitRanking = [...(stats.team_profit_ranking || [])].sort((a, b) => a.profit - b.profit);
|
||||
const profitBarOption: echarts.EChartsCoreOption = {
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
axisPointer: { type: 'shadow' },
|
||||
backgroundColor: 'rgba(13, 13, 26, 0.95)',
|
||||
borderColor: 'rgba(255, 255, 255, 0.10)',
|
||||
textStyle: { color: '#f1f0ff', fontSize: 12 },
|
||||
formatter: (params: unknown) => {
|
||||
const p = (params as { name: string; value: number; dataIndex: number }[])[0];
|
||||
const team = profitRanking[p.dataIndex];
|
||||
return `${p.name}<br/>收入: ¥${team.revenue.toFixed(2)}<br/>成本: ¥${team.base_cost.toFixed(2)}<br/>利润: ¥${team.profit.toFixed(2)}<br/>加价率: ${team.markup_percentage}%`;
|
||||
},
|
||||
},
|
||||
grid: { left: 80, right: 40, top: 10, bottom: 20 },
|
||||
xAxis: {
|
||||
type: 'value',
|
||||
axisLabel: { color: '#8b8ea8', fontSize: 11 },
|
||||
splitLine: { lineStyle: { color: 'rgba(255, 255, 255, 0.06)' } },
|
||||
},
|
||||
yAxis: {
|
||||
type: 'category',
|
||||
data: profitRanking.map((t) => t.name),
|
||||
axisLabel: { color: '#8b8ea8', fontSize: 12 },
|
||||
axisLine: { lineStyle: { color: 'rgba(255, 255, 255, 0.08)' } },
|
||||
},
|
||||
series: [{
|
||||
type: 'bar',
|
||||
data: profitRanking.map((t) => t.profit),
|
||||
barWidth: 16,
|
||||
itemStyle: {
|
||||
color: new echarts.graphic.LinearGradient(0, 0, 1, 0, [
|
||||
{ offset: 0, color: '#06d6a0' },
|
||||
{ offset: 1, color: '#00b8e6' },
|
||||
]),
|
||||
borderRadius: [0, 4, 4, 0],
|
||||
},
|
||||
label: {
|
||||
show: true,
|
||||
position: 'right',
|
||||
color: '#8b8ea8',
|
||||
fontSize: 11,
|
||||
formatter: (p: { value: number }) => `¥${p.value.toFixed(2)}`,
|
||||
},
|
||||
}],
|
||||
};
|
||||
return (
|
||||
{/* Row 3: Trend chart (full width) */}
|
||||
<div className={styles.chartSection}>
|
||||
<h2 className={styles.sectionTitle}>消费趋势(近30天 · 元)</h2>
|
||||
<div className={styles.chartWrapper}>
|
||||
<ReactEChartsCore echarts={echarts} option={trendOption} style={{ height: 320 }} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row 4: Team + User ranking (two columns) */}
|
||||
<div className={styles.chartsRow}>
|
||||
{sortedTeams.length > 0 && (
|
||||
<div className={styles.chartSection}>
|
||||
<h2 className={styles.sectionTitle}>团队利润排行</h2>
|
||||
<h2 className={styles.sectionTitle}>团队消费排行(本月)</h2>
|
||||
<div className={styles.chartWrapper}>
|
||||
<ReactEChartsCore echarts={echarts} option={profitBarOption} style={{ height: Math.max(300, profitRanking.length * 36) }} />
|
||||
<ReactEChartsCore echarts={echarts} option={teamBarOption} style={{ height: Math.max(300, sortedTeams.length * 36) }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
)}
|
||||
|
||||
<div className={styles.chartSection}>
|
||||
<h2 className={styles.sectionTitle}>用户消费排行(Top 10 · 本月)</h2>
|
||||
<div className={styles.chartWrapper}>
|
||||
<ReactEChartsCore echarts={echarts} option={barOption} style={{ height: Math.max(300, sortedUsers.length * 36) }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -32,6 +32,13 @@ function VideoThumbnail({ video, onClick }: { video: AssetVideo; onClick: () =>
|
||||
}
|
||||
|
||||
function assetVideoToTask(v: AssetVideo): GenerationTask {
|
||||
const references = (v.reference_urls || []).map((ref, i) => ({
|
||||
id: `ref_${v.task_id}_${i}`,
|
||||
type: (ref.type || 'image') as 'image' | 'video',
|
||||
previewUrl: ref.url,
|
||||
label: ref.label || `素材${i + 1}`,
|
||||
role: ref.role,
|
||||
}));
|
||||
return {
|
||||
id: String(v.id),
|
||||
taskId: v.task_id,
|
||||
@ -41,7 +48,7 @@ function assetVideoToTask(v: AssetVideo): GenerationTask {
|
||||
model: 'seedance_2.0',
|
||||
aspectRatio: (v.aspect_ratio as any) || '16:9',
|
||||
duration: v.duration as any,
|
||||
references: [],
|
||||
references,
|
||||
status: 'completed',
|
||||
progress: 100,
|
||||
resultUrl: v.result_url,
|
||||
|
||||
@ -7,6 +7,7 @@ import { Select } from '../components/Select';
|
||||
import styles from './TeamsPage.module.css';
|
||||
|
||||
function fmtMoney(val: number): string {
|
||||
if (val === -1) return '不限';
|
||||
return '¥' + (val || 0).toFixed(2);
|
||||
}
|
||||
|
||||
@ -80,6 +81,10 @@ export function TeamsPage() {
|
||||
const [learnOpen, setLearnOpen] = useState(false);
|
||||
const [editingRegions, setEditingRegions] = useState(false);
|
||||
const [editRegionsValue, setEditRegionsValue] = useState('');
|
||||
const [editingMonthlyLimit, setEditingMonthlyLimit] = useState(false);
|
||||
const [editMonthlyLimitValue, setEditMonthlyLimitValue] = useState('');
|
||||
const [editingMarkup, setEditingMarkup] = useState(false);
|
||||
const [editMarkupValue, setEditMarkupValue] = useState('');
|
||||
const [editingAnomalyConfig, setEditingAnomalyConfig] = useState(false);
|
||||
const [anomalyConfigDraft, setAnomalyConfigDraft] = useState<Record<string, any>>({});
|
||||
|
||||
@ -430,13 +435,65 @@ export function TeamsPage() {
|
||||
<span className={styles.detailLabel}>累计消费</span>
|
||||
<span className={styles.detailValue}>{fmtMoney(detailTeam.total_spent)}</span>
|
||||
</div>
|
||||
<div className={styles.detailItem}>
|
||||
<span className={styles.detailLabel}>月消费限额</span>
|
||||
<span className={styles.detailValue}>
|
||||
{editingMonthlyLimit ? (
|
||||
<span style={{ display: 'inline-flex', gap: 6, alignItems: 'center', flexWrap: 'nowrap' }}>
|
||||
<input
|
||||
type="number"
|
||||
value={editMonthlyLimitValue}
|
||||
onChange={(e) => setEditMonthlyLimitValue(e.target.value)}
|
||||
style={{ width: 80, padding: '3px 6px', borderRadius: 4, border: '1px solid var(--color-border-card)', background: 'var(--color-bg-page)', color: 'var(--color-text-primary)', fontSize: 13 }}
|
||||
/>
|
||||
<button
|
||||
className={styles.topupBtn}
|
||||
onClick={async () => {
|
||||
const val = Number(editMonthlyLimitValue);
|
||||
if (isNaN(val) || (val < 0 && val !== -1)) { showToast('请输入有效金额,-1为不限制'); return; }
|
||||
try {
|
||||
await adminApi.updateTeam(detailTeam.id, { monthly_spending_limit: val });
|
||||
setDetailTeam({ ...detailTeam, monthly_spending_limit: val });
|
||||
setTeams(teams.map(t => t.id === detailTeam.id ? { ...t, monthly_spending_limit: val } : t));
|
||||
setEditingMonthlyLimit(false);
|
||||
showToast('月消费限额已更新');
|
||||
} catch (e: any) {
|
||||
showToast(e.response?.data?.error || '保存失败');
|
||||
}
|
||||
}}
|
||||
style={{ fontSize: 12, padding: '4px 10px', whiteSpace: 'nowrap' }}
|
||||
>
|
||||
保存
|
||||
</button>
|
||||
<button
|
||||
className={styles.topupBtn}
|
||||
onClick={() => setEditingMonthlyLimit(false)}
|
||||
style={{ fontSize: 12, padding: '4px 10px', whiteSpace: 'nowrap' }}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
{fmtMoney(detailTeam.monthly_spending_limit)}
|
||||
<button
|
||||
className={styles.topupBtn}
|
||||
onClick={() => { setEditingMonthlyLimit(true); setEditMonthlyLimitValue(String(detailTeam.monthly_spending_limit || 0)); }}
|
||||
style={{ fontSize: 12, padding: '4px 10px', marginLeft: 8 }}
|
||||
>
|
||||
编辑
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.detailItem}>
|
||||
<span className={styles.detailLabel}>可用余额</span>
|
||||
<span className={styles.detailValue}>{fmtMoney(detailTeam.available_balance)}</span>
|
||||
</div>
|
||||
<div className={styles.detailItem}>
|
||||
<span className={styles.detailLabel}>月消费限额</span>
|
||||
<span className={styles.detailValue}>{fmtMoney(detailTeam.monthly_spending_limit)}</span>
|
||||
<span className={styles.detailLabel}>冻结金额</span>
|
||||
<span className={styles.detailValue}>{fmtMoney(detailTeam.frozen_amount)}</span>
|
||||
</div>
|
||||
<div className={styles.detailItem}>
|
||||
<span className={styles.detailLabel}>本月消费</span>
|
||||
@ -444,7 +501,56 @@ export function TeamsPage() {
|
||||
</div>
|
||||
<div className={styles.detailItem}>
|
||||
<span className={styles.detailLabel}>加价率</span>
|
||||
<span className={styles.detailValue}>{(detailTeam.markup_percentage || 0)}%</span>
|
||||
<span className={styles.detailValue}>
|
||||
{editingMarkup ? (
|
||||
<span style={{ display: 'inline-flex', gap: 6, alignItems: 'center', flexWrap: 'nowrap' }}>
|
||||
<input
|
||||
type="number"
|
||||
value={editMarkupValue}
|
||||
onChange={(e) => setEditMarkupValue(e.target.value)}
|
||||
style={{ width: 80, padding: '3px 6px', borderRadius: 4, border: '1px solid var(--color-border-card)', background: 'var(--color-bg-page)', color: 'var(--color-text-primary)', fontSize: 13 }}
|
||||
/>
|
||||
<span style={{ fontSize: 13, color: 'var(--color-text-secondary)' }}>%</span>
|
||||
<button
|
||||
className={styles.topupBtn}
|
||||
onClick={async () => {
|
||||
const val = Number(editMarkupValue);
|
||||
if (isNaN(val) || val < 0) { showToast('请输入有效的加价百分比'); return; }
|
||||
try {
|
||||
await adminApi.updateTeam(detailTeam.id, { markup_percentage: val });
|
||||
setDetailTeam({ ...detailTeam, markup_percentage: val });
|
||||
setTeams(teams.map(t => t.id === detailTeam.id ? { ...t, markup_percentage: val } : t));
|
||||
setEditingMarkup(false);
|
||||
showToast('加价率已更新');
|
||||
} catch (e: any) {
|
||||
showToast(e.response?.data?.error || '保存失败');
|
||||
}
|
||||
}}
|
||||
style={{ fontSize: 12, padding: '4px 10px', whiteSpace: 'nowrap' }}
|
||||
>
|
||||
保存
|
||||
</button>
|
||||
<button
|
||||
className={styles.topupBtn}
|
||||
onClick={() => setEditingMarkup(false)}
|
||||
style={{ fontSize: 12, padding: '4px 10px', whiteSpace: 'nowrap' }}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
{(detailTeam.markup_percentage || 0)}%
|
||||
<button
|
||||
className={styles.topupBtn}
|
||||
onClick={() => { setEditingMarkup(true); setEditMarkupValue(String(detailTeam.markup_percentage || 0)); }}
|
||||
style={{ fontSize: 12, padding: '4px 10px', marginLeft: 8 }}
|
||||
>
|
||||
编辑
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.detailItem}>
|
||||
<span className={styles.detailLabel}>成员数</span>
|
||||
@ -497,14 +603,14 @@ export function TeamsPage() {
|
||||
alert(e.response?.data?.error || '保存失败');
|
||||
}
|
||||
}}
|
||||
style={{ fontSize: 12, padding: '4px 12px' }}
|
||||
style={{ fontSize: 12, padding: '4px 10px', whiteSpace: 'nowrap' }}
|
||||
>
|
||||
保存
|
||||
</button>
|
||||
<button
|
||||
className={styles.topupBtn}
|
||||
onClick={() => setEditingRegions(false)}
|
||||
style={{ fontSize: 12, padding: '4px 12px' }}
|
||||
style={{ fontSize: 12, padding: '4px 10px', whiteSpace: 'nowrap' }}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
@ -642,6 +748,7 @@ export function TeamsPage() {
|
||||
<th>角色</th>
|
||||
<th>状态</th>
|
||||
<th>日生成上限</th>
|
||||
<th>月限额(次)</th>
|
||||
<th>今日生成/消费</th>
|
||||
<th>本月生成/消费</th>
|
||||
</tr>
|
||||
@ -667,6 +774,7 @@ export function TeamsPage() {
|
||||
)}
|
||||
</td>
|
||||
<td>{m.daily_generation_limit === -1 ? '不限' : (m.daily_generation_limit || 0) + '次'}</td>
|
||||
<td>{m.monthly_generation_limit === -1 ? '不限' : (m.monthly_generation_limit || 0) + '次'}</td>
|
||||
<td>{(m.generations_today || 0) + '次 / ' + fmtMoney(m.spent_today)}</td>
|
||||
<td>{(m.generations_this_month || 0) + '次 / ' + fmtMoney(m.spent_this_month)}</td>
|
||||
</tr>
|
||||
|
||||
@ -370,6 +370,7 @@ export interface AssetVideo {
|
||||
seconds_consumed: number;
|
||||
cost_amount?: number;
|
||||
aspect_ratio: string;
|
||||
reference_urls?: { url: string; type: string; role: string; label: string }[];
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user