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:
seaislee1209 2026-03-20 22:38:44 +08:00
parent 9259988094
commit ef2212e345
10 changed files with 240 additions and 98 deletions

View File

@ -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(),
})

View File

@ -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

View File

@ -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 = ''; }}
>
&#x27F2;
</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}`}

View File

@ -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}>

View File

@ -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) =>

View File

@ -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,

View File

@ -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>
);
}

View File

@ -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,

View File

@ -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>

View File

@ -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;
}