video-shuoshan/web/src/components/LoginModal.tsx
seaislee1209 9259988094 feat: v0.10.0 计费体系重构 — 秒数→金额+次数,token追踪,利润分析
## 计费体系
- 团队额度从秒数改为金额(余额/冻结/月消费上限)
- 用户限额从秒数改为次数(每日50次/每月1500次)
- 新增 billing.py 工具模块(分辨率→像素映射 + token/费用计算)
- 扣费流程:预扣制→冻结制(提交冻结预估金额,完成按实际tokens扣费,失败释放)
- 允许小额透支(实际费用超预估时余额可变负)
- 团队加价比例(markup_percentage),创建团队时必填

## Token 追踪
- GenerationRecord 新增 tokens_consumed/cost_amount/base_cost_amount
- 任务完成时从 Seedance API usage.total_tokens 获取精确值
- 生成页显示预估消耗(tokens + 金额),按团队售价计算

## 管理后台
- 仪表盘新增利润分析板块(总收入/成本/利润/利润率 + 团队利润排行)
- 消费记录新增 Tokens/售价/成本/利润列
- 团队管理:充值改为充金额,新增加价比例设置
- 系统设置:默认限额改为次数,新增基础token单价配置

## Bug 修复
- 登录弹窗:拖选输入框内容不再误关闭(onClick→mousedown+mouseup)
- 视频详情弹窗:遮罩层覆盖全视口(left:76px→0),admin/团管侧栏不再露出

## UI 增强
- 图片大图预览:上传区和视频详情弹窗的图片支持点击查看大图(ImageLightbox)
- 移除 adaptive 比例和智能时长选项,确保 token 预估可精确计算
- 视频详情弹窗显示实际消耗 tokens 和费用

## 前端全量更新
- 所有页面秒数显示替换为金额(元)和次数(次)
- TypeScript 类型全量更新
- API 调用参数同步更新

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 20:32:12 +08:00

96 lines
3.2 KiB
TypeScript

import { useState, useCallback } from 'react';
import { useAuthStore } from '../store/auth';
import logoImg from '../assets/logo_32.png';
import styles from './LoginModal.module.css';
interface Props {
isOpen: boolean;
onClose: () => void;
onSuccess: () => void;
}
export function LoginModal({ isOpen, onClose, onSuccess }: Props) {
const login = useAuthStore((s) => s.login);
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const handleSubmit = useCallback(async (e: React.FormEvent) => {
e.preventDefault();
setError('');
if (!username.trim()) { setError('请输入用户名或邮箱'); return; }
if (password.length < 6) { setError('密码至少6位'); return; }
setLoading(true);
try {
await login(username, password);
onSuccess();
} catch (err: any) {
const msg = err.response?.data?.message || err.response?.data?.error || '登录失败,请重试';
setError(msg);
} finally {
setLoading(false);
}
}, [username, password, login, onSuccess]);
if (!isOpen) return null;
return (
<div className={styles.overlay}
onMouseDown={(e) => { if (e.target === e.currentTarget) (e.currentTarget as HTMLElement).dataset.mouseDownOnOverlay = 'true'; }}
onMouseUp={(e) => {
if ((e.currentTarget as HTMLElement).dataset.mouseDownOnOverlay === 'true' && e.target === e.currentTarget) onClose();
(e.currentTarget as HTMLElement).dataset.mouseDownOnOverlay = '';
}}
>
<div className={styles.panel}>
<button className={styles.closeBtn} onClick={onClose} aria-label="关闭">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M18 6L6 18M6 6l12 12" />
</svg>
</button>
<div className={styles.header}>
<img src={logoImg} alt="" className={styles.headerLogo} />
<span className={styles.headerTitle}>Air Drama</span>
</div>
<form onSubmit={handleSubmit} className={styles.form}>
<div className={styles.field}>
<label className={styles.label}> / </label>
<input
type="text"
className={styles.input}
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="请输入用户名或邮箱"
autoFocus
/>
</div>
<div className={styles.field}>
<label className={styles.label}></label>
<input
type="password"
className={styles.input}
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="请输入密码"
/>
</div>
{error && <div className={styles.error}>{error}</div>}
<button type="submit" className={styles.submitBtn} disabled={loading}>
{loading ? '登录中...' : '登录'}
</button>
<p className={styles.hint}></p>
</form>
</div>
</div>
);
}