From ef2212e345535680c283f151e366259441d5e189 Mon Sep 17 00:00:00 2001 From: seaislee1209 Date: Fri, 20 Mar 2026 22:38:44 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20v0.10.1=20=E9=AA=8C=E6=94=B6=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=20=E2=80=94=20=E9=87=8D=E6=96=B0=E7=BC=96=E8=BE=91?= =?UTF-8?q?=E6=8C=89=E9=92=AE/Decimal=E5=BA=8F=E5=88=97=E5=8C=96/=E4=BB=AA?= =?UTF-8?q?=E8=A1=A8=E7=9B=98=E5=B8=83=E5=B1=80/.env.local=E8=87=AA?= =?UTF-8?q?=E5=8A=A8=E5=8A=A0=E8=BD=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 视频详情弹窗「重新编辑」按钮改为所有视角可见(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) --- backend/apps/generation/views.py | 17 ++-- backend/config/settings.py | 17 ++++ web/src/components/Toolbar.tsx | 22 ++++- web/src/components/VideoDetailModal.tsx | 42 ++++++++- web/src/lib/api.ts | 2 +- web/src/pages/AdminAssetsPage.tsx | 9 +- web/src/pages/DashboardPage.tsx | 101 +++++--------------- web/src/pages/TeamAssetsPage.tsx | 9 +- web/src/pages/TeamsPage.tsx | 118 +++++++++++++++++++++++- web/src/types/index.ts | 1 + 10 files changed, 240 insertions(+), 98 deletions(-) diff --git a/backend/apps/generation/views.py b/backend/apps/generation/views.py index a4596d8..baa1a22 100644 --- a/backend/apps/generation/views.py +++ b/backend/apps/generation/views.py @@ -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(), }) diff --git a/backend/config/settings.py b/backend/config/settings.py index bbdd003..798d6c7 100644 --- a/backend/config/settings.py +++ b/backend/config/settings.py @@ -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 diff --git a/web/src/components/Toolbar.tsx b/web/src/components/Toolbar.tsx index 398d430..580f304 100644 --- a/web/src/components/Toolbar.tsx +++ b/web/src/components/Toolbar.tsx @@ -229,19 +229,31 @@ export function Toolbar() { )} + {/* Spacer — push right group to the end */} +
+ + {/* 全部清空 + 预估消耗:仅有内容时显示 */} + {isSubmittable && ( + 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 = ''; }} + > + ⟲ 全部清空 + + )} + {/* Estimated cost */} - {tokenPrice > 0 && ( + {isSubmittable && tokenPrice > 0 && ( 预估消耗:{estimatedTokens.toLocaleString()} tokens / ¥{estimatedCost} )} - {/* Spacer */} -
- {/* Send button */}
+ {/* Re-edit button above info bar */} +
+ +
+ {/* Fixed bottom: info bar + actions card */}
diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index ebd13d6..9cbc787 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -168,7 +168,7 @@ export const adminApi = { getTeamDetail: (teamId: number) => api.get(`/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 }) => + 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 }) => api.put(`/admin/teams/${teamId}`, data), topUpTeam: (teamId: number, amount: number) => diff --git a/web/src/pages/AdminAssetsPage.tsx b/web/src/pages/AdminAssetsPage.tsx index 93a70b6..a6197ad 100644 --- a/web/src/pages/AdminAssetsPage.tsx +++ b/web/src/pages/AdminAssetsPage.tsx @@ -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, diff --git a/web/src/pages/DashboardPage.tsx b/web/src/pages/DashboardPage.tsx index b1480a3..036a4b8 100644 --- a/web/src/pages/DashboardPage.tsx +++ b/web/src/pages/DashboardPage.tsx @@ -193,32 +193,7 @@ export function DashboardPage() { ))}
-
-

消费趋势(近30天 · 元)

-
- -
-
- -
- {sortedTeams.length > 0 && ( -
-

团队消费排行(本月)

-
- -
-
- )} - -
-

用户消费排行(Top 10 · 本月)

-
- -
-
-
- - {/* Profit Section */} + {/* Row 2: Profit cards */}
总收入
@@ -238,62 +213,32 @@ export function DashboardPage() {
- {(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}
收入: ¥${team.revenue.toFixed(2)}
成本: ¥${team.base_cost.toFixed(2)}
利润: ¥${team.profit.toFixed(2)}
加价率: ${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) */} +
+

消费趋势(近30天 · 元)

+
+ +
+
+ + {/* Row 4: Team + User ranking (two columns) */} +
+ {sortedTeams.length > 0 && (
-

团队利润排行

+

团队消费排行(本月)

- +
- ); - })()} + )} + +
+

用户消费排行(Top 10 · 本月)

+
+ +
+
+
); } diff --git a/web/src/pages/TeamAssetsPage.tsx b/web/src/pages/TeamAssetsPage.tsx index b7b3d54..4b4072e 100644 --- a/web/src/pages/TeamAssetsPage.tsx +++ b/web/src/pages/TeamAssetsPage.tsx @@ -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, diff --git a/web/src/pages/TeamsPage.tsx b/web/src/pages/TeamsPage.tsx index f1d3a41..2e97e5a 100644 --- a/web/src/pages/TeamsPage.tsx +++ b/web/src/pages/TeamsPage.tsx @@ -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>({}); @@ -430,13 +435,65 @@ export function TeamsPage() { 累计消费 {fmtMoney(detailTeam.total_spent)}
+
+ 月消费限额 + + {editingMonthlyLimit ? ( + + 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 }} + /> + + + + ) : ( + <> + {fmtMoney(detailTeam.monthly_spending_limit)} + + + )} + +
可用余额 {fmtMoney(detailTeam.available_balance)}
- 月消费限额 - {fmtMoney(detailTeam.monthly_spending_limit)} + 冻结金额 + {fmtMoney(detailTeam.frozen_amount)}
本月消费 @@ -444,7 +501,56 @@ export function TeamsPage() {
加价率 - {(detailTeam.markup_percentage || 0)}% + + {editingMarkup ? ( + + 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 }} + /> + % + + + + ) : ( + <> + {(detailTeam.markup_percentage || 0)}% + + + )} +
成员数 @@ -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' }} > 保存 @@ -642,6 +748,7 @@ export function TeamsPage() { 角色 状态 日生成上限 + 月限额(次) 今日生成/消费 本月生成/消费 @@ -667,6 +774,7 @@ export function TeamsPage() { )} {m.daily_generation_limit === -1 ? '不限' : (m.daily_generation_limit || 0) + '次'} + {m.monthly_generation_limit === -1 ? '不限' : (m.monthly_generation_limit || 0) + '次'} {(m.generations_today || 0) + '次 / ' + fmtMoney(m.spent_today)} {(m.generations_this_month || 0) + '次 / ' + fmtMoney(m.spent_this_month)} diff --git a/web/src/types/index.ts b/web/src/types/index.ts index 9985733..7b2cf9d 100644 --- a/web/src/types/index.ts +++ b/web/src/types/index.ts @@ -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; }