diff --git a/core/backend/apps/accounts/migrations/0006_teammember_daily_credit_limit_and_more.py b/core/backend/apps/accounts/migrations/0006_teammember_daily_credit_limit_and_more.py new file mode 100644 index 0000000..1a89a51 --- /dev/null +++ b/core/backend/apps/accounts/migrations/0006_teammember_daily_credit_limit_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 5.1.15 on 2026-06-27 10:12 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("accounts", "0005_invitation_kind_alter_invitation_team"), + ] + + operations = [ + migrations.AddField( + model_name="teammember", + name="daily_credit_limit", + field=models.DecimalField(decimal_places=2, default=0, max_digits=12), + ), + migrations.AddField( + model_name="teammember", + name="total_credit_limit", + field=models.DecimalField(decimal_places=2, default=0, max_digits=12), + ), + ] diff --git a/core/backend/apps/accounts/models.py b/core/backend/apps/accounts/models.py index 682a6f3..33677ae 100644 --- a/core/backend/apps/accounts/models.py +++ b/core/backend/apps/accounts/models.py @@ -91,7 +91,10 @@ class TeamMember(TimeStampedModel): user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="team_memberships") role = models.CharField(max_length=24, choices=Role.choices, default=Role.MEMBER) status = models.CharField(max_length=24, choices=Status.choices, default=Status.ACTIVE) + # 三档成员额度(均 0=不限):每日 / 每月 / 累计总额。团队页可设,reserve 时逐档管控(PMC#17/#18)。 + daily_credit_limit = models.DecimalField(max_digits=12, decimal_places=2, default=0) monthly_credit_limit = models.DecimalField(max_digits=12, decimal_places=2, default=0) + total_credit_limit = models.DecimalField(max_digits=12, decimal_places=2, default=0) class Meta: unique_together = [("team", "user")] diff --git a/core/backend/apps/accounts/serializers.py b/core/backend/apps/accounts/serializers.py index 619c32b..55b6048 100644 --- a/core/backend/apps/accounts/serializers.py +++ b/core/backend/apps/accounts/serializers.py @@ -46,16 +46,29 @@ class TeamMemberSerializer(serializers.ModelSerializer): user = UserSerializer(read_only=True) # 本月已消费(自然月,按 CreditLedger CHARGE 流水按人聚合);由 view 经 context 注入 charged_map month_charged = serializers.SerializerMethodField() + # 今日 / 累计已消费,供成员列表把三档额度用量都显示出来(PMC#17/#18) + day_charged = serializers.SerializerMethodField() + total_charged = serializers.SerializerMethodField() class Meta: model = TeamMember - fields = ["id", "team", "user", "role", "status", "monthly_credit_limit", "month_charged"] + fields = [ + "id", "team", "user", "role", "status", + "daily_credit_limit", "monthly_credit_limit", "total_credit_limit", + "month_charged", "day_charged", "total_charged", + ] read_only_fields = ["id", "team", "user", "status"] def get_month_charged(self, obj): charged_map = self.context.get("charged_map") or {} return str(charged_map.get(obj.user_id, 0)) + def get_day_charged(self, obj): + return str((self.context.get("day_charged_map") or {}).get(obj.user_id, 0)) + + def get_total_charged(self, obj): + return str((self.context.get("total_charged_map") or {}).get(obj.user_id, 0)) + class RegisterSerializer(serializers.Serializer): username = serializers.CharField(max_length=150) diff --git a/core/backend/apps/accounts/views.py b/core/backend/apps/accounts/views.py index 94711dd..c040db2 100644 --- a/core/backend/apps/accounts/views.py +++ b/core/backend/apps/accounts/views.py @@ -1,4 +1,5 @@ import uuid +from decimal import Decimal, InvalidOperation from pathlib import Path from django.contrib.auth import authenticate @@ -237,36 +238,53 @@ def can_manage_team(user, team): return bool(member and member.role in {TeamMember.Role.OWNER, TeamMember.Role.ADMIN}) -def _month_charged_by_user(team): - """本团队当前自然月每个成员的消费(CHARGE 流水)合计:{user_id: Decimal}。""" +def _charged_by_user(team, since=None): + """本团队每个成员在 since 之后的消费(CHARGE 流水)合计:{user_id: Decimal}。since=None 即累计全量。""" from django.db.models import Sum - from django.utils import timezone from apps.billing.models import CreditLedger - month_start = timezone.now().replace(day=1, hour=0, minute=0, second=0, microsecond=0) - rows = ( - CreditLedger.objects.filter( - team=team, - ledger_type=CreditLedger.Type.CHARGE, - created_at__gte=month_start, - ) - .values("user_id") - .annotate(total=Sum("amount")) - ) + qs = CreditLedger.objects.filter(team=team, ledger_type=CreditLedger.Type.CHARGE) + if since is not None: + qs = qs.filter(created_at__gte=since) + rows = qs.values("user_id").annotate(total=Sum("amount")) return {row["user_id"]: row["total"] for row in rows if row["user_id"] is not None} +def _month_charged_by_user(team): + """本团队当前自然月每个成员的消费(CHARGE 流水)合计:{user_id: Decimal}。""" + from django.utils import timezone + + month_start = timezone.now().replace(day=1, hour=0, minute=0, second=0, microsecond=0) + return _charged_by_user(team, since=month_start) + + +def _normalize_limit(raw, default=None): + """额度入参归一:前端 -1 / 空 = 不限 → 存 0(0=不限);其余取数值。default 用于 create 缺省。""" + if raw is None or (isinstance(raw, str) and not raw.strip()): + return default + try: + val = Decimal(str(raw)) + except (InvalidOperation, ValueError, TypeError): + return default + return Decimal("0") if val < 0 else val + + @api_view(["GET", "POST"]) @permission_classes([IsAuthenticated]) def team_members(request): team = get_current_team(request.user) if request.method == "GET": + from django.utils import timezone + members = TeamMember.objects.filter(team=team).select_related("user").order_by("created_at") - charged_map = _month_charged_by_user(team) - return Response( - TeamMemberSerializer(members, many=True, context={"charged_map": charged_map}).data - ) + day_start = timezone.now().replace(hour=0, minute=0, second=0, microsecond=0) + ctx = { + "charged_map": _month_charged_by_user(team), + "day_charged_map": _charged_by_user(team, since=day_start), + "total_charged_map": _charged_by_user(team, since=None), + } + return Response(TeamMemberSerializer(members, many=True, context=ctx).data) if not can_manage_team(request.user, team): return Response({"detail": "permission denied"}, status=status.HTTP_403_FORBIDDEN) @@ -291,8 +309,10 @@ def team_members(request): team=team, user=user, role=role, - # 子账号默认月度限额 100(0=不限只留给主账号/超管)—— 避免新成员不设限就无上限地花主账号团队池的钱(PMC#3)。 - monthly_credit_limit=request.data.get("monthly_credit_limit") or request.data.get("monthly") or DEFAULT_MEMBER_MONTHLY_LIMIT, + # 三档额度(0=不限);月度默认 100(0=不限只留给主账号/超管)—— 避免新成员不设限就无上限地花团队池(PMC#3)。 + daily_credit_limit=_normalize_limit(request.data.get("daily_credit_limit") or request.data.get("daily"), default=0), + monthly_credit_limit=_normalize_limit(request.data.get("monthly_credit_limit") or request.data.get("monthly"), default=DEFAULT_MEMBER_MONTHLY_LIMIT), + total_credit_limit=_normalize_limit(request.data.get("total_credit_limit") or request.data.get("total"), default=0), ) return Response(TeamMemberSerializer(member).data, status=status.HTTP_201_CREATED) @@ -362,13 +382,21 @@ def team_member_detail(request, member_id): member.role = normalize_member_role(role) if member.role == TeamMember.Role.OWNER: member.role = TeamMember.Role.ADMIN + update_fields = ["role", "updated_at"] + if "daily_credit_limit" in request.data or "daily" in request.data: + member.daily_credit_limit = _normalize_limit(request.data.get("daily_credit_limit", request.data.get("daily")), default=0) + update_fields.append("daily_credit_limit") if "monthly_credit_limit" in request.data or "monthly" in request.data: - member.monthly_credit_limit = request.data.get("monthly_credit_limit", request.data.get("monthly")) or 0 + member.monthly_credit_limit = _normalize_limit(request.data.get("monthly_credit_limit", request.data.get("monthly")), default=0) + update_fields.append("monthly_credit_limit") + if "total_credit_limit" in request.data or "total" in request.data: + member.total_credit_limit = _normalize_limit(request.data.get("total_credit_limit", request.data.get("total")), default=0) + update_fields.append("total_credit_limit") name = str(request.data.get("name") or "").strip() if name: member.user.first_name = name member.user.save(update_fields=["first_name"]) - member.save(update_fields=["role", "monthly_credit_limit", "updated_at"]) + member.save(update_fields=update_fields) return Response(TeamMemberSerializer(member).data) diff --git a/core/backend/apps/ai/migrations/0021_rename_ai_aitask_team_read_idx_ai_aitask_team_id_d668d5_idx.py b/core/backend/apps/ai/migrations/0021_rename_ai_aitask_team_read_idx_ai_aitask_team_id_d668d5_idx.py new file mode 100644 index 0000000..ed00d14 --- /dev/null +++ b/core/backend/apps/ai/migrations/0021_rename_ai_aitask_team_read_idx_ai_aitask_team_id_d668d5_idx.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.15 on 2026-06-27 10:26 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("ai", "0020_aitask_read_at"), + ] + + operations = [ + migrations.RenameIndex( + model_name="aitask", + new_name="ai_aitask_team_id_d668d5_idx", + old_name="ai_aitask_team_read_idx", + ), + ] diff --git a/core/backend/apps/ai/models.py b/core/backend/apps/ai/models.py index 6e08bf7..2ac436d 100644 --- a/core/backend/apps/ai/models.py +++ b/core/backend/apps/ai/models.py @@ -137,6 +137,9 @@ class AITask(TeamOwnedModel): error_message = models.TextField(blank=True) submitted_at = models.DateTimeField(null=True, blank=True) completed_at = models.DateTimeField(null=True, blank=True) + # YYX#row22:用户「已读」时间。null = 未读 → 用于导航栏「图片生成」未读数字胶囊 + # 与每个商品预览右下角的未读分数。团队级共享(一人看过即全团队已读)。 + read_at = models.DateTimeField(null=True, blank=True) class Meta: indexes = [ @@ -147,6 +150,8 @@ class AITask(TeamOwnedModel): models.Index(fields=["team", "-created_at"]), # 按对话拉历史(图片创作工作室切换会话时回填批次) models.Index(fields=["conversation", "created_at"]), + # YYX#row22:按团队 + 已读状态聚合未读数(导航/商品角标) + models.Index(fields=["team", "read_at"]), ] def __str__(self) -> str: diff --git a/core/backend/apps/ai/services.py b/core/backend/apps/ai/services.py index 0e2ba4d..fd805b9 100644 --- a/core/backend/apps/ai/services.py +++ b/core/backend/apps/ai/services.py @@ -962,9 +962,22 @@ def build_model_tryon_prompt_refs(product, has_model: bool, base_prompt: str = " return " ".join(lines) -def build_platform_cover_prompt_refs(product, has_model: bool, base_prompt: str = "") -> str: +# 平台套图同批多张要「同款商品、不同版式」,否则 N 张文案/构图雷同(PMC#24)。 +# 锁死商品一致性,只让排版/构图/视角/配色基调按张变化。 +_COVER_VARIATIONS = [ + "本张:正面居中主图版式,商品占画面主体,纯净背景,经典电商主图构图", + "本张:换一种排版——商品偏置 + 卖点文案分区,场景化背景,杂志感构图", + "本张:特写细节版式,放大商品材质/做工,近景视角,突出质感", + "本张:生活场景套图版式,商品置于真实使用情境,环境光,氛围感构图", + "本张:多角度组合版式,商品换一个朝向/视角,几何分区背景,现代简约风", + "本张:促销封面版式,留出标题/价签区,高对比配色,强视觉冲击构图", +] + + +def build_platform_cover_prompt_refs(product, has_model: bool, base_prompt: str = "", index: int = 0) -> str: """平台套图 image_edit 提示词(refs 版):参考图1=商品真实主图(锁外形/品牌/配色/Logo), - 有模特时参考图2=出镜模特(锁人脸/身形)。生成电商平台主图 / 封面套图,商品须还原真实包装。""" + 有模特时参考图2=出镜模特(锁人脸/身形)。生成电商平台主图 / 封面套图,商品须还原真实包装。 + `index` 让同批每张的版式/构图/视角不同,避免 N 张雷同(PMC#24)。""" name = (getattr(product, "title", "") or "商品").strip() lines = [f"参考图1是「{name}」的真实商品主图。"] if has_model: @@ -978,12 +991,13 @@ def build_platform_cover_prompt_refs(product, has_model: bool, base_prompt: str "商品的外形、品牌文字、配色、Logo、材质必须与参考图1高度一致,严禁改动或重新设计包装;" "干净背景、电商主图构图、真实质感。" ) + lines.append(_COVER_VARIATIONS[index % len(_COVER_VARIATIONS)] + "。") if base_prompt and base_prompt.strip(): lines.append(base_prompt.strip()) return " ".join(lines) -def build_free_reference_prompt(base_prompt: str, n_refs: int = 1) -> str: +def build_free_reference_prompt(base_prompt: str, n_refs: int = 1, index: int = 0) -> str: """图片创作自由模式 · 带用户上传参考图时的提示词。 纯把用户原话丢给图生图,模型只会松散借个色调、不会真的保留参考图里的主体(背心/商品/人物), 这正是「没参考我上传的素材」的根因。这里显式把参考图钉成「画面主体的唯一依据」,要求严格保留其 @@ -1005,6 +1019,9 @@ def build_free_reference_prompt(base_prompt: str, n_refs: int = 1) -> str: lines.append(f"在此基础上,按用户要求创作:{base}") else: lines.append("在此基础上,生成干净、专业的电商视觉画面。") + # 同批多张要不同构图/视角,否则雷同(PMC#24);主体一致性已在上面钉死,这里只变表现。 + if index > 0: + lines.append(_COVER_VARIATIONS[index % len(_COVER_VARIATIONS)] + ",在保持主体一致的前提下换一种表现。") lines.append("画面真实、构图协调、细节清晰。") return " ".join(lines) @@ -2177,7 +2194,7 @@ def run_standalone_image_task(*, task_id: str) -> None: elif mode == "cover" and product_url: # 平台套图:参考图1=商品真实主图(锁包装一致性),有模特则参考图2=模特(锁人脸/身形) edit_images = [product_url] + ([model_url] if model_url else []) - edit_prompt = build_platform_cover_prompt_refs(product, has_model=bool(model_url), base_prompt=prompt) + edit_prompt = build_platform_cover_prompt_refs(product, has_model=bool(model_url), base_prompt=prompt, index=index) elif bool(payload.get("reference_product")) and product_url: edit_images = [product_url] edit_prompt = build_product_triview_prompt_refs(product, "") @@ -2185,7 +2202,7 @@ def run_standalone_image_task(*, task_id: str) -> None: # 图片创作自由模式:以用户上传的参考图为基底出图。提示词必须显式要求「保留参考图主体」, # 否则图生图只会松散借个色调、不真的还原上传的素材(=用户反馈的「没参考我的图」)。 edit_images = ref_urls - edit_prompt = build_free_reference_prompt(prompt, n_refs=len(ref_urls)) + edit_prompt = build_free_reference_prompt(prompt, n_refs=len(ref_urls), index=index) use_edit = bool(edit_images) try: if use_edit and can_edit: @@ -2201,7 +2218,11 @@ def run_standalone_image_task(*, task_id: str) -> None: vsize = "2304x1728" if payload.get("reference_product") else _ratio_to_volcano_size(str(payload.get("ratio") or "")) response = provider.image_generation(model=model_config.name, endpoint=model_config.endpoint, prompt=edit_prompt, image=edit_images, size=vsize) else: - response = provider.image_generation(model=model_config.name, endpoint=model_config.endpoint, prompt=prompt) + # 纯文生图同批多张要不同构图,否则雷同(PMC#24);index>0 追加换版式指令。 + gen_prompt = prompt + if index > 0: + gen_prompt = f"{prompt}。{_COVER_VARIATIONS[index % len(_COVER_VARIATIONS)]},换一种构图与视角。" + response = provider.image_generation(model=model_config.name, endpoint=model_config.endpoint, prompt=gen_prompt) media = provider.extract_first_media_url(response) with transaction.atomic(): task.status = AITask.Status.SUCCEEDED diff --git a/core/backend/apps/ai/views.py b/core/backend/apps/ai/views.py index 8e703cf..4ec11d4 100644 --- a/core/backend/apps/ai/views.py +++ b/core/backend/apps/ai/views.py @@ -111,6 +111,7 @@ class AITaskViewSet(TeamScopedViewSetMixin, ReadOnlyModelViewSet): queryset = queryset.annotate( rp_batch_id=KeyTextTransform("batch_id", "request_payload"), rp_mode=KeyTextTransform("mode", "request_payload"), + rp_product_id=KeyTextTransform("product_id", "request_payload"), ) raw = self.request.query_params.get("task_type", "").strip() if raw: @@ -119,6 +120,46 @@ class AITaskViewSet(TeamScopedViewSetMixin, ReadOnlyModelViewSet): queryset = queryset.filter(task_type__in=types) return queryset + # YYX#row22:只有「工作台图片生成」(mode∈model/cover/image)才计入未读; + # 脚本/实体抽取/故事板等流水线内部任务不进这块未读统计。 + _GEN_MODES = ("model", "cover", "image") + + def _unread_base(self): + """本团队、属于图片生成、且未读(read_at is null)的任务集合(已 annotate rp_mode/rp_product_id)。""" + return self.get_queryset().filter(rp_mode__in=self._GEN_MODES, read_at__isnull=True) + + @action(detail=False, methods=["get"], url_path="unread") + def unread(self, request): + """未读生成任务汇总:总数(导航胶囊) + 按商品分组(商品预览角标)。""" + rows = self._unread_base().values_list("rp_product_id", flat=True) + total = 0 + by_product: dict[str, int] = {} + for pid in rows: + total += 1 + if pid: + by_product[str(pid)] = by_product.get(str(pid), 0) + 1 + return Response({"total": total, "by_product": by_product}) + + @action(detail=False, methods=["post"], url_path="mark-read") + def mark_read(self, request): + """标记已读 → 清零未读胶囊。 + body 可选:product_id(只清该商品) / batch_id(只清该批) / ids(指定任务); + 都不传 = 把当前团队所有未读生成任务标记已读(进任务中心时调用)。""" + qs = self._unread_base() + product_id = str(request.data.get("product_id") or "").strip() + batch_id = str(request.data.get("batch_id") or "").strip() + ids = request.data.get("ids") or [] + if isinstance(ids, str): + ids = [s for s in ids.split(",") if s.strip()] + if product_id: + qs = qs.filter(rp_product_id=product_id) + if batch_id: + qs = qs.filter(rp_batch_id=batch_id) + if ids: + qs = qs.filter(id__in=[str(i).strip() for i in ids if str(i).strip()]) + updated = qs.update(read_at=timezone.now()) + return Response({"updated": updated}) + class ImageConversationViewSet(TeamScopedViewSetMixin, ModelViewSet): """图片创作工作室的「对话」CRUD。 diff --git a/core/backend/apps/billing/services/ledger.py b/core/backend/apps/billing/services/ledger.py index 3be6156..f4fe99d 100644 --- a/core/backend/apps/billing/services/ledger.py +++ b/core/backend/apps/billing/services/ledger.py @@ -8,30 +8,46 @@ from apps.billing.models import CreditAccount, CreditLedger, CreditReservation, def _enforce_member_monthly_limit(*, team, user, amount: Decimal) -> None: - """成员月度限额管控。团队页可设 monthly_credit_limit(0=不限),此前只存不管——纯装饰。 - 口径与成员列表的 month_charged 一致:自然月内该成员的 CHARGE 流水合计; - 再加上其当前 ACTIVE 预留(在途任务),防止并发把限额冲穿。 - 调用方(reserve_credit)已持有 account 行锁,团队内 reserve 串行化,此检查无竞态。""" + """成员三档限额管控(每日 / 每月 / 累计总额,各 0=不限)。团队页可设,此前只有「月」生效, + 「日」/「总」纯装饰 → 列表显示与配置对不上(PMC#17/#18)。这里逐档真实管控。 + 口径与成员列表一致:某窗口内该成员的 CHARGE 流水合计 + 其当前 ACTIVE 预留(在途任务), + 防止并发把限额冲穿。调用方(reserve_credit)已持有 account 行锁,团队内 reserve 串行化,无竞态。""" if user is None: return member = team.members.filter(user=user).first() if member is None: return - limit = member.monthly_credit_limit or Decimal("0") - if limit <= 0: - return # 0 = 不限额 now = timezone.now() - month_start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0) - charged = ( - CreditLedger.objects.filter(team=team, user=user, ledger_type=CreditLedger.Type.CHARGE, created_at__gte=month_start) - .aggregate(s=Sum("amount"))["s"] or Decimal("0") - ) reserved = ( CreditReservation.objects.filter(team=team, user=user, status=CreditReservation.Status.ACTIVE) .aggregate(s=Sum("amount"))["s"] or Decimal("0") ) - if charged + reserved + amount > limit: - raise ValueError(f"成员本月额度不足:限额 ¥{limit},本月已用 ¥{charged}(另有在途 ¥{reserved})") + + def _charged_since(since): + qs = CreditLedger.objects.filter(team=team, user=user, ledger_type=CreditLedger.Type.CHARGE) + if since is not None: + qs = qs.filter(created_at__gte=since) + return qs.aggregate(s=Sum("amount"))["s"] or Decimal("0") + + day_limit = member.daily_credit_limit or Decimal("0") + if day_limit > 0: + day_start = now.replace(hour=0, minute=0, second=0, microsecond=0) + charged = _charged_since(day_start) + if charged + reserved + amount > day_limit: + raise ValueError(f"成员今日额度不足:每日限额 ¥{day_limit},今日已用 ¥{charged}(另有在途 ¥{reserved})") + + month_limit = member.monthly_credit_limit or Decimal("0") + if month_limit > 0: + month_start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0) + charged = _charged_since(month_start) + if charged + reserved + amount > month_limit: + raise ValueError(f"成员本月额度不足:限额 ¥{month_limit},本月已用 ¥{charged}(另有在途 ¥{reserved})") + + total_limit = member.total_credit_limit or Decimal("0") + if total_limit > 0: + charged = _charged_since(None) + if charged + reserved + amount > total_limit: + raise ValueError(f"成员累计额度不足:总额度 ¥{total_limit},累计已用 ¥{charged}(另有在途 ¥{reserved})") def _enforce_quota_policy(*, team, project, amount: Decimal) -> None: diff --git a/core/backend/apps/billing/tests.py b/core/backend/apps/billing/tests.py index cf79225..4b55f7f 100644 --- a/core/backend/apps/billing/tests.py +++ b/core/backend/apps/billing/tests.py @@ -60,6 +60,46 @@ class CreditLedgerTests(TestCase): self.assertEqual(CreditLedger.objects.filter(ledger_type=CreditLedger.Type.RELEASE).count(), 1) +class MemberLimitTests(TestCase): + """成员三档额度(每日/每月/累计总额,0=不限)在 reserve_credit 时逐档管控(PMC#17/#18)。""" + + def setUp(self): + self.user = User.objects.create_user(username="lim-owner", password="pass12345") + self.team = Team.objects.create(name="Limit Team", owner=self.user) + self.member = TeamMember.objects.create(team=self.team, user=self.user, role=TeamMember.Role.OWNER) + CreditAccount.objects.create(team=self.team, balance=Decimal("1000.0000")) + self.provider = ModelProvider.objects.create(name="volcengine", display_name="Volcano") + self.model = ModelConfig.objects.create( + provider=self.provider, name="m", display_name="M", capability=ModelConfig.Capability.IMAGE, + ) + + def _task(self, key): + return AITask.objects.create( + team=self.team, created_by=self.user, task_type=AITask.Type.PRODUCT_IMAGE, + model_config=self.model, idempotency_key=key, estimated_cost=Decimal("10.0000"), + ) + + def test_daily_limit_blocks_over_spend(self): + self.member.daily_credit_limit = Decimal("15.0000") + self.member.save(update_fields=["daily_credit_limit"]) + # 第一笔 10 预留通过,第二笔 10 超每日 15 → 被拦 + reserve_credit(team=self.team, user=self.user, task=self._task("d1"), amount=Decimal("10.0000")) + with self.assertRaises(ValueError): + reserve_credit(team=self.team, user=self.user, task=self._task("d2"), amount=Decimal("10.0000")) + + def test_total_limit_blocks_over_spend(self): + self.member.total_credit_limit = Decimal("12.0000") + self.member.save(update_fields=["total_credit_limit"]) + reserve_credit(team=self.team, user=self.user, task=self._task("t1"), amount=Decimal("10.0000")) + with self.assertRaises(ValueError): + reserve_credit(team=self.team, user=self.user, task=self._task("t2"), amount=Decimal("10.0000")) + + def test_zero_limit_means_unlimited(self): + # 三档都 0 = 不限:多笔大额预留都不被额度档拦(只受账户余额约束) + for i in range(5): + reserve_credit(team=self.team, user=self.user, task=self._task(f"z{i}"), amount=Decimal("10.0000")) + + class RechargePermissionTests(TestCase): """充值是团队资金操作:仅 owner/admin 可发起,普通成员/访客被 403 拦截。""" diff --git a/core/frontend/src/App.tsx b/core/frontend/src/App.tsx index a3d3030..b15726c 100644 --- a/core/frontend/src/App.tsx +++ b/core/frontend/src/App.tsx @@ -113,6 +113,9 @@ export function App() { const [modelConfigs, setModelConfigs] = useState([]); const [billing, setBilling] = useState(null); const [unreadCount, setUnreadCount] = useState(0); + // YYX#row22:未读生成任务 —— 导航「图片生成」总数 + 每个商品的未读分数(商品角标) + const [aiUnread, setAiUnread] = useState(0); + const [aiUnreadByProduct, setAiUnreadByProduct] = useState>({}); const [dataLoaded, setDataLoaded] = useState(false); // 全局数据(商品/项目)首次加载完成前,列表显示加载态而非空态 const [projectDetail, setProjectDetail] = useState(null); const [projectDetailError, setProjectDetailError] = useState(false); // 进管线拉详情失败:从「永久 loading」改为可重试,避免卡死进不来 @@ -210,6 +213,19 @@ export function App() { if (data) setUnreadCount(data.unread_count); }, []); + // YYX#row22:拉未读生成任务汇总(导航胶囊 + 商品角标) + const reloadAiUnread = useCallback(async () => { + const data = await api.aiTasksUnread().catch(() => null); + if (data) { setAiUnread(data.total); setAiUnreadByProduct(data.by_product || {}); } + }, []); + // 标记已读(全部 / 按商品)→ 乐观清零本地角标,再刷一次真值 + const markAiRead = useCallback(async (productId?: string) => { + if (productId) setAiUnreadByProduct((prev) => { const n = { ...prev }; delete n[productId]; return n; }); + else { setAiUnread(0); setAiUnreadByProduct({}); } + await api.markAiTasksRead(productId ? { product_id: productId } : undefined).catch(() => undefined); + void reloadAiUnread(); + }, [reloadAiUnread]); + // Boot: validate token, hydrate identity + data. useEffect(() => { if (!getToken()) { @@ -300,6 +316,20 @@ export function App() { loadSettingsData(); }, [authed, page, loadSettingsData]); + // YYX#row22:登录后拉一次未读生成任务,并每 30s 静默轮询(生成是慢任务,出图后角标自动亮) + useEffect(() => { + if (!authed || !user) return; + void reloadAiUnread(); + const timer = window.setInterval(() => { void reloadAiUnread(); }, 30000); + return () => window.clearInterval(timer); + }, [authed, user, reloadAiUnread]); + + // YYX#row22:进入「图片生成」任务中心即把全部生成任务标记已读 → 清零导航胶囊 + useEffect(() => { + if (!authed || page !== "assetFactory") return; + void markAiRead(); + }, [authed, page, markAiRead]); + // Load full project detail when entering the pipeline. useEffect(() => { if (!authed || page !== "pipeline" || !activeProjectId) { @@ -843,11 +873,11 @@ export function App() { case "assetFactory": return ; case "imageOptimize": - return navigate("assetFactory")} navigate={navigate} onGenerate={generateImages} onResume={resumeImages} />; + return navigate("assetFactory")} navigate={navigate} onGenerate={generateImages} onResume={resumeImages} />; case "modelPhoto": - return navigate("assetFactory")} navigate={navigate} onGenerate={generateImages} onResume={resumeImages} />; + return navigate("assetFactory")} navigate={navigate} onGenerate={generateImages} onResume={resumeImages} />; case "platformCover": - return navigate("assetFactory")} navigate={navigate} onGenerate={generateImages} onResume={resumeImages} />; + return navigate("assetFactory")} navigate={navigate} onGenerate={generateImages} onResume={resumeImages} />; case "modelPhotoDemoA": return navigate("modelPhoto")} navigate={navigate} />; case "modelPhotoDemoB": @@ -1025,7 +1055,7 @@ export function App() { return (
- navigateAdmin("")} /> + navigateAdmin("")} />
diff --git a/core/frontend/src/ai-tools-page.css b/core/frontend/src/ai-tools-page.css index b50c66d..15a0a59 100644 --- a/core/frontend/src/ai-tools-page.css +++ b/core/frontend/src/ai-tools-page.css @@ -315,12 +315,24 @@ .image-workbench .iw-prod-item:hover { background: var(--black-alpha-4); } .image-workbench .iw-prod-item.active { background: var(--heat-12); border-color: var(--heat-20); } .image-workbench .iw-prod-item .thumb { + position: relative; flex-shrink: 0; width: 36px; height: 36px; border-radius: var(--r-sm); } -.image-workbench .iw-prod-item .thumb.has-real-media { overflow: hidden; background: var(--background-lighter); } -.image-workbench .iw-prod-item .thumb.has-real-media img { width: 100%; height: 100%; object-fit: cover; display: block; } +/* YYX#row22:商品预览右下角 · 未读生成任务分数(H22 示例) */ +.image-workbench .iw-prod-item .thumb .iw-prod-unread { + position: absolute; right: -4px; bottom: -4px; + min-width: 16px; height: 16px; padding: 0 4px; + display: grid; place-items: center; + background: var(--heat); color: var(--accent-white); + font-family: var(--font-mono); font-size: 10px; line-height: 1; font-weight: 600; + border-radius: 999px; border: 2px solid var(--background-base); + z-index: 2; +} +/* 不用 overflow:hidden 裁切(否则切掉右下角未读角标);改由 img 自己圆角 */ +.image-workbench .iw-prod-item .thumb.has-real-media { background: var(--background-lighter); } +.image-workbench .iw-prod-item .thumb.has-real-media img { width: 100%; height: 100%; object-fit: cover; display: block; border-radius: var(--r-sm); } .image-workbench .iw-prod-item .body { flex: 1; min-width: 0; } .image-workbench .iw-prod-item .nm { font-size: 13px; color: var(--accent-black); font-weight: 500; @@ -693,6 +705,23 @@ .image-workbench .iw-filter-opt .ck { color: var(--heat); opacity: 0; flex-shrink: 0; } .image-workbench .iw-filter-opt.on .ck { opacity: 1; } +/* ── YYX#G23:筛选已开启 · 结果区置顶提示栏 ── */ +.image-workbench .iw-filter-notice { + display: flex; align-items: center; gap: 10px; + margin-bottom: 14px; padding: 10px 14px; + background: var(--heat-12); + border: 1px solid var(--heat-20); border-radius: var(--r-md); +} +.image-workbench .iw-filter-notice .ifn-text { + flex: 1; font-size: 13px; color: var(--accent-black); +} +.image-workbench .iw-filter-notice .ifn-clear { + flex-shrink: 0; display: inline-flex; align-items: center; gap: 4px; + border: 0; background: none; cursor: pointer; + font-size: 12px; color: var(--heat); font-family: inherit; +} +.image-workbench .iw-filter-notice .ifn-clear:hover { text-decoration: underline; } + /* ── YYX#18:结果卡精简头部(提示词折叠 + 灰色张数,无容器/引号/标题)── */ .image-workbench .iw-batch-head { padding: 2px 2px 0; margin-bottom: 8px; } .image-workbench .iw-batch-head .ibh-prompt { @@ -933,6 +962,22 @@ 首个头紧跟 iw-preview 顶 padding,无需额外上间距(故只给跟在卡片后的头加 margin)。 */ .image-workbench .gen-batch-card + .iw-pv-h { margin-top: 22px; } +/* ── YYX#row24/row25:去掉结果批次卡的白色容器 / 边框 / 内边距 + 底部分割线 ── + 让图片显示区铺满预览宽度、更大;批次间距由「卡片 → 下一个批次头」拉开。 */ +.image-workbench .gen-batch-card { + background: transparent; + border: 0; + border-radius: 0; + padding: 0; + gap: 12px; +} +.image-workbench .gen-batch-card .gen-batch-actions { + border-top: 0; + padding-top: 0; + margin-top: 8px; +} +.image-workbench .gen-batch-card + .iw-batch-head { margin-top: 22px; } + /* P0③ · 平台套图多平台分组 section 头(平台 logo + 名 + 批次数) */ .image-workbench .iw-cover-group-h { display: flex; align-items: center; gap: 8px; diff --git a/core/frontend/src/api.ts b/core/frontend/src/api.ts index 74eceb5..07ead17 100644 --- a/core/frontend/src/api.ts +++ b/core/frontend/src/api.ts @@ -181,11 +181,13 @@ export const api = { name?: string; email?: string; role?: string; + daily_credit_limit?: number | string; monthly_credit_limit?: number | string; + total_credit_limit?: number | string; }) { return request("/api/auth/team/members/", { method: "POST", body: JSON.stringify(payload) }); }, - updateTeamMember(id: string, payload: { role?: string; monthly_credit_limit?: number | string; name?: string }) { + updateTeamMember(id: string, payload: { role?: string; daily_credit_limit?: number | string; monthly_credit_limit?: number | string; total_credit_limit?: number | string; name?: string }) { return request(`/api/auth/team/members/${id}/`, { method: "PATCH", body: JSON.stringify(payload) }); }, removeTeamMember(id: string) { @@ -589,6 +591,14 @@ export const api = { // 以便 全部/已完成/失败 三个 tab 数按真实总量算。 return request>("/api/ai/tasks/?page_size=200&task_type=person_image,product_image"); }, + // YYX#row22:未读生成任务汇总(导航胶囊总数 + 按商品分组角标) + aiTasksUnread() { + return request<{ total: number; by_product: Record }>("/api/ai/tasks/unread/"); + }, + // YYX#row22:标记已读 → 清零未读胶囊。不传参 = 全部已读;可按 product_id / batch_id / ids 局部清。 + markAiTasksRead(payload?: { product_id?: string; batch_id?: string; ids?: string[] }) { + return request<{ updated: number }>("/api/ai/tasks/mark-read/", { method: "POST", body: JSON.stringify(payload || {}) }); + }, // 异步生图:提交后秒级返回任务列表(慢出图在 worker 跑),再用 generateImageStatus 轮询取结果。 // 带 conversation_id 则归属该对话;不带则后端自动开一条新对话并回传其 id。 submitGenerateImage(payload: { prompt: string; mode?: "image" | "model" | "cover"; count?: number; product_id?: string; reference_product?: boolean; model_id?: string; model_entity_id?: string; ratio?: string; image_model?: string; conversation_id?: string; reference_image_ids?: string[] }) { diff --git a/core/frontend/src/components/app-shell.tsx b/core/frontend/src/components/app-shell.tsx index ef9fbf2..ff03231 100644 --- a/core/frontend/src/components/app-shell.tsx +++ b/core/frontend/src/components/app-shell.tsx @@ -226,7 +226,7 @@ const PAGE_TO_NAV: Partial> = { settingsNotify: "settings" }; -export function Sidebar({ page, navigate, user, team, canManageBilling = true, products, projects, productTotal, projectTotal, logout, onOpenAdmin }: { +export function Sidebar({ page, navigate, user, team, canManageBilling = true, products, projects, productTotal, projectTotal, aiUnread, logout, onOpenAdmin }: { page: Page; navigate: Navigate; user: User; @@ -237,6 +237,8 @@ export function Sidebar({ page, navigate, user, team, canManageBilling = true, p projects: Project[]; productTotal?: number; projectTotal?: number; + // YYX#row22:未读生成任务总数 → 「图片生成」右侧数字胶囊 + aiUnread?: number; // 退出登录。父级(App.tsx)有完整 logout 闭包时传入;未传时用自带兜底 // (best-effort 调登出接口 + 清 token + 跳 /login),保证侧栏账户菜单的"退出"今天就可用。 logout?: () => void; @@ -248,6 +250,8 @@ export function Sidebar({ page, navigate, user, team, canManageBilling = true, p const navItems = NAV.filter((item) => canManageBilling || (item.page !== "team" && item.page !== "account")); // 徽标用后端真实总数(分页 count),不用已加载的页内条数 const badges: Partial> = { products: productTotal ?? products.length, projects: projectTotal ?? projects.length }; + // YYX#row22:有未读生成任务才显示胶囊(0 不显示) + if (aiUnread && aiUnread > 0) badges["asset-factory"] = aiUnread; const avatar = (team?.name || user.username || "A").slice(0, 1).toUpperCase(); // 收窄/展开导航:与设计稿 Shell.toggleSidebarCollapse 一致 —— 切 body.sidebar-collapsed diff --git a/core/frontend/src/components/product-create-drawer.tsx b/core/frontend/src/components/product-create-drawer.tsx index 7797005..e143fb2 100644 --- a/core/frontend/src/components/product-create-drawer.tsx +++ b/core/frontend/src/components/product-create-drawer.tsx @@ -296,7 +296,9 @@ export function ProductCreateDrawer({ open, close, onCreate, onCreated, catOptio ))}
  • + - setBulletDraft(event.target.value)} onKeyDown={(event) => { if (event.key === "Enter") { event.preventDefault(); addBullet(); } }} placeholder="添加新卖点 · 回车确认" /> + setBulletDraft(event.target.value)} onKeyDown={(event) => { if (event.key === "Enter") { event.preventDefault(); addBullet(); } }} placeholder="输入卖点后点「添加」或回车" /> + {/* YYX#G29:回车确认太隐晦 → 补一个显式「添加」按钮 */} +
  • diff --git a/core/frontend/src/product-create-page.css b/core/frontend/src/product-create-page.css index 9674642..7e53707 100644 --- a/core/frontend/src/product-create-page.css +++ b/core/frontend/src/product-create-page.css @@ -437,6 +437,17 @@ } .pc-drawer .form-card .bullet-list .bl-x:hover { color: var(--accent-crimson); background: var(--crimson-bg); } .pc-drawer .form-card .bullet-list .bl-x svg { width: 11px; height: 11px; } +/* YYX#G29:显式「添加」卖点按钮(回车太隐晦)· 二级按钮观感,scoped 不污染全局 */ +.pc-drawer .form-card .bullet-list .bl-add-btn { + flex-shrink: 0; + height: 26px; padding: 0 12px; + background: var(--surface); + border: 1px solid var(--border-faint); border-radius: var(--r-sm); + font-size: 12px; color: var(--heat); font-family: inherit; cursor: pointer; + transition: background var(--t-base), border-color var(--t-base), color var(--t-base); +} +.pc-drawer .form-card .bullet-list .bl-add-btn:hover:not(:disabled) { background: var(--heat-12); border-color: var(--heat-20); } +.pc-drawer .form-card .bullet-list .bl-add-btn:disabled { color: var(--black-alpha-24); cursor: default; } @media (max-width: 900px) { .pc-drawer .drawer-b .pf-upload-row { grid-template-columns: 1fr; } } diff --git a/core/frontend/src/routes/ai-tools.tsx b/core/frontend/src/routes/ai-tools.tsx index 0207601..209c810 100644 --- a/core/frontend/src/routes/ai-tools.tsx +++ b/core/frontend/src/routes/ai-tools.tsx @@ -600,7 +600,9 @@ export function ImageWorkbenchPage({ onGenerate, onResume, initialProductId, - onProductChange + onProductChange, + unreadByProduct, + onProductViewed }: { mode: WorkMode; products: Product[]; @@ -613,11 +615,17 @@ export function ImageWorkbenchPage({ initialProductId?: string; /** 选中商品上抛给 App,持久化进 activeProductId;否则切走再回来选择会被重置回默认第一个商品 */ onProductChange?: (productId: string) => void; + /** YYX#row22:每个商品的未读生成任务数(商品角标) */ + unreadByProduct?: Record; + /** YYX#row22:查看某商品(选中)即标记该商品的生成任务已读 → 清零角标 */ + onProductViewed?: (productId: string) => void; }) { const meta = MODE_META[mode]; const [productId, setProductId] = useState(initialProductId || products[0]?.id || ""); // 选中商品同步到 App(activeProductId),保证切换栏目再回来时初始商品仍是上次选的那个 useEffect(() => { if (productId) onProductChange?.(productId); }, [productId, onProductChange]); + // YYX#row22:选中(查看)某商品即把它的未读生成任务标记已读 → 角标清零 + useEffect(() => { if (productId && (unreadByProduct?.[productId] ?? 0) > 0) onProductViewed?.(productId); }, [productId, unreadByProduct, onProductViewed]); const product = products.find((item) => item.id === productId) || products[0]; // 图片创作(image)默认留空,只靠 placeholder 引导;模特/平台仍预填模板省一步 const [prompt, setPrompt] = useState(mode === "image" ? "" : meta.promptTemplate(products[0]?.title || "商品")); @@ -940,27 +948,48 @@ export function ImageWorkbenchPage({ platformIds?: string[]; /** 图片创作:本批要参考的上传图(含 file 用于上传;已是 asset 的可只给 id) */ refs?: { name: string; url: string; file?: File; assetId?: string }[]; + /** PMC#25:原地重跑——复位这张已有批次卡(清旧结果、重新生成),不新开任务卡 */ + reuseBatchId?: string; + /** PMC#25:单图重跑——把新图追加进这张已有批次卡(不清旧好图、不新开卡) */ + appendToBatchId?: string; }) { - const batchId = `b-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`; - const newBatch: GenBatch = { - id: batchId, - prompt: opts.prompt, - ratio: opts.ratio, - count: opts.count, - status: "generating", - results: [], - adopted: false, - ts: Date.now(), - productId: opts.productId, - productTitle: opts.productTitle, - modelId: opts.modelId, - modelName: opts.modelName, - platformIds: opts.platformIds, - // 批次头回显「参考了哪些图」(只存名+预览,不存 file) - refs: opts.refs?.map((r) => ({ name: r.name, url: r.url })) - }; - setBatches((prev) => [...prev, newBatch]); - // YYX#16:新批次(含重跑/单图再生成)一律追加到末尾,提交即把面板滚到底部新任务处 + // 原地重跑/追加:沿用原批次 id,不再生成新 id、不再新增卡片 + const batchId = opts.reuseBatchId || opts.appendToBatchId || `b-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`; + if (opts.reuseBatchId) { + // 全批重跑:把原卡复位为「生成中」,清掉旧(失败/旧)结果,保留其归属与参数 + setBatches((prev) => { + const next = prev.map((b) => (b.id === batchId ? { ...b, status: "generating" as const, results: [], pendingIds: undefined } : b)); + persistBatches(next); + return next; + }); + } else if (opts.appendToBatchId) { + // 单图重跑:原卡转「生成中」补图,保留已有好图 + setBatches((prev) => { + const next = prev.map((b) => (b.id === batchId ? { ...b, status: "generating" as const } : b)); + persistBatches(next); + return next; + }); + } else { + const newBatch: GenBatch = { + id: batchId, + prompt: opts.prompt, + ratio: opts.ratio, + count: opts.count, + status: "generating", + results: [], + adopted: false, + ts: Date.now(), + productId: opts.productId, + productTitle: opts.productTitle, + modelId: opts.modelId, + modelName: opts.modelName, + platformIds: opts.platformIds, + // 批次头回显「参考了哪些图」(只存名+预览,不存 file) + refs: opts.refs?.map((r) => ({ name: r.name, url: r.url })) + }; + setBatches((prev) => [...prev, newBatch]); + } + // YYX#16:新批次提交即把面板滚到底部新任务处;原地重跑滚到该卡也无妨 window.setTimeout(() => resultsEndRef.current?.scrollIntoView({ behavior: "smooth", block: "end" }), 80); try { // 先把参考图上传成 Asset,拿到 id 列表带给后端 → 生成时真正作多图参考(image_edit)。 @@ -990,13 +1019,24 @@ export function ImageWorkbenchPage({ void loadConversations(false); } setBatches((prev) => { - const next = prev.map((b) => (b.id === batchId ? { ...b, status: result?.assets ? ("done" as const) : ("failed" as const), results: result?.assets || [] } : b)); + const next = prev.map((b) => { + if (b.id !== batchId) return b; + const newAssets = result?.assets || []; + // 单图追加重跑:把新图并进原有好图(按 id 去重),不覆盖;其余路径直接用本批结果 + if (opts.appendToBatchId) { + const seen = new Set(b.results.map((a) => a.id)); + const merged = [...b.results, ...newAssets.filter((a) => !a.id || !seen.has(a.id))]; + return { ...b, status: merged.length ? ("done" as const) : ("failed" as const), results: merged }; + } + return { ...b, status: newAssets.length ? ("done" as const) : ("failed" as const), results: newAssets }; + }); persistBatches(next); return next; }); } catch { setBatches((prev) => { - const next = prev.map((b) => (b.id === batchId ? { ...b, status: "failed" as const } : b)); + // 追加重跑失败:保留原有好图,只把状态落回(有图=done,无图=failed) + const next = prev.map((b) => (b.id === batchId ? { ...b, status: ((opts.appendToBatchId && b.results.length ? "done" : "failed") as GenBatch["status"]) } : b)); persistBatches(next); return next; }); @@ -1047,7 +1087,7 @@ export function ImageWorkbenchPage({ window.setTimeout(() => resultsEndRef.current?.scrollIntoView({ behavior: "smooth", block: "end" }), 80); } - /* 重跑指定批次(行32①②):用该批次的参数新起一个批次追加到列表末尾,原批次保留。 */ + /* 重跑指定批次(PMC#25:在原任务卡上原地重跑,不新开任务卡)。 */ async function rerunBatch(src: GenBatch) { await startBatch({ prompt: src.prompt, @@ -1058,7 +1098,8 @@ export function ImageWorkbenchPage({ productTitle: src.productTitle ?? product?.title, modelId: src.modelId, modelName: src.modelName, - platformIds: src.platformIds + platformIds: src.platformIds, + reuseBatchId: src.id }); } @@ -1126,7 +1167,8 @@ export function ImageWorkbenchPage({ applyLibraryState(batchId, [assetId], next); } - /* 单图「再生成」(§4.18 悬浮①):用该批参数另起一个 count=1 的批次,产出一张新图 */ + /* 单图「再生成」/「重跑这张」(§4.18 悬浮① / YYX#5): + PMC#25——补一张图进原批次卡(不新开任务、不覆盖已有好图)。 */ async function regenSingleImage(src: GenBatch) { await startBatch({ prompt: src.prompt, @@ -1136,7 +1178,8 @@ export function ImageWorkbenchPage({ productTitle: src.productTitle ?? product?.title, modelId: src.modelId, modelName: src.modelName, - platformIds: src.platformIds + platformIds: src.platformIds, + appendToBatchId: src.id }); } @@ -1270,8 +1313,13 @@ export function ImageWorkbenchPage({ - {/* YYX#19:删掉「更多」气泡里的加入资产库(与批次操作行重复),只保留删除 */} + {/* YYX#20 更正:单图「更多」气泡保留「加入资产库」(单图粒度入库),这里不是重复入口; + 重复入口是批次底部那个,已在批次气泡里删掉。 */}
    + - {/* 行32③:批次底部「更多」气泡(删除当前批次 / 加入资产库) */} + {/* YYX#20:批次底部「加入资产库」已是上面的独立按钮,这里气泡只留「删除当前批次」,去掉重复入口 */}
    -
    - +
    + )} {!hasResults ? (
    // EMPTY STATE
    diff --git a/core/frontend/src/routes/team.tsx b/core/frontend/src/routes/team.tsx index d08433b..4d93b09 100644 --- a/core/frontend/src/routes/team.tsx +++ b/core/frontend/src/routes/team.tsx @@ -8,7 +8,8 @@ import { ConfirmModal, TeamModal } from "../components/overlays"; import { Pager } from "../components/pager"; const MEMBERS_PER_PAGE = 10; -const LOGIN_URL = "https://airshelf.com/login"; +// 登录地址必须是用户当前真实访问的域名(与邀请链接同源),写死的 airshelf.com 不存在 → PMC#16 +const LOGIN_URL = `${location.origin}/login`; const LIMIT_PRESETS = [1000, 3000, 5000, 10000, -1]; // 本地随机用户名:前缀 + 4 位数字(与 V1 设计稿一致) @@ -82,8 +83,8 @@ export function TeamPage({ team, user, billing, navigate, onCreateMember: onCrea user: User; billing: BillingSummary | null; navigate: (page: Page) => void; - onCreateMember: (payload: { username: string; password: string; name?: string; role?: string; monthly_credit_limit?: number }) => void | Promise; - onUpdateMember: (id: string, payload: { role?: string; monthly_credit_limit?: number }) => void | Promise; + onCreateMember: (payload: { username: string; password: string; name?: string; role?: string; daily_credit_limit?: number; monthly_credit_limit?: number; total_credit_limit?: number }) => void | Promise; + onUpdateMember: (id: string, payload: { role?: string; daily_credit_limit?: number; monthly_credit_limit?: number; total_credit_limit?: number }) => void | Promise; onRemoveMember: (id: string) => void | Promise; onResetPassword: (id: string, password: string) => void | Promise; onRecharge: (amount: number, bonus: number) => void | Promise; @@ -191,9 +192,10 @@ export function TeamPage({ team, user, billing, navigate, onCreateMember: onCrea function openEdit(member: TeamMember) { setEditTarget(member); setEdRole(member.role === "owner" ? "admin" : member.role || "member"); - setEdDaily(""); + // 回填该成员当前三档真实额度(0=不限 → 输入框留空) + setEdDaily(Number(member.daily_credit_limit || 0) > 0 ? String(Number(member.daily_credit_limit)) : ""); setEdMonthly(Number(member.monthly_credit_limit || 0) > 0 ? String(Number(member.monthly_credit_limit)) : ""); - setEdTotal("-1"); + setEdTotal(Number(member.total_credit_limit || 0) > 0 ? String(Number(member.total_credit_limit)) : ""); } function openLimit() { @@ -209,8 +211,10 @@ export function TeamPage({ team, user, billing, navigate, onCreateMember: onCrea password: cuPass, name: cuName.trim() || undefined, role: cuRole, - // 后端成员额度仅 monthly_credit_limit;每日/总额为展示档,不入参 - monthly_credit_limit: Number(cuMonthly) || 0 + // 三档额度均真实入库:-1/空 = 不限(后端归一成 0)。PMC#17/#18 + daily_credit_limit: cuDaily.trim() === "" ? -1 : Number(cuDaily), + monthly_credit_limit: cuMonthly.trim() === "" ? -1 : Number(cuMonthly), + total_credit_limit: cuTotal.trim() === "" ? -1 : Number(cuTotal) }); // 创建失败(action 报错返回 null)时保持弹窗与已填内容,只成功才关闭并清空 if (!created) return; @@ -228,7 +232,13 @@ export function TeamPage({ team, user, billing, navigate, onCreateMember: onCrea async function submitEdit() { if (!editTarget) return; - await onUpdateMember(editTarget.id, { role: edRole, monthly_credit_limit: Number(edMonthly) || 0 }); + // 三档额度均下发:留空 = 不限(-1,后端归一成 0)。PMC#17/#18 + await onUpdateMember(editTarget.id, { + role: edRole, + daily_credit_limit: edDaily.trim() === "" ? -1 : Number(edDaily), + monthly_credit_limit: edMonthly.trim() === "" ? -1 : Number(edMonthly), + total_credit_limit: edTotal.trim() === "" ? -1 : Number(edTotal) + }); setEditTarget(null); } @@ -375,6 +385,7 @@ export function TeamPage({ team, user, billing, navigate, onCreateMember: onCrea 角色 每日额度 月度额度 + 总额度 当月已用 操作 @@ -387,6 +398,10 @@ export function TeamPage({ team, user, billing, navigate, onCreateMember: onCrea const displayName = rawName && !rawName.includes("@") ? rawName : (email ? email.split("@")[0] : (rawName || "成员")); const showEmail = !!email && email.toLowerCase() !== displayName.toLowerCase(); const role = roleUi(member.role); + const daily = Number(member.daily_credit_limit || 0); + const totalLimit = Number(member.total_credit_limit || 0); + const dayUsed = Number(member.day_charged || 0); + const totalUsed = Number(member.total_charged || 0); const monthly = Number(member.monthly_credit_limit || 0); const memberUsed = Number(member.month_charged || 0); // 月度不限时按团队月限额/余额作分母给参考进度;有消费即显可见细条,避免「用了钱进度条却空白」 @@ -399,8 +414,9 @@ export function TeamPage({ team, user, billing, navigate, onCreateMember: onCrea {displayName.slice(0, 1).toUpperCase()}{displayName}{showEmail && {email}} {role.label} - 不限 + {daily > 0 ? money(daily) : "不限"}{daily > 0 && 今日用 {money(dayUsed)}} {monthly > 0 ? money(monthly) : "不限"} + {totalLimit > 0 ? money(totalLimit) : "不限"}{totalLimit > 0 && 累计用 {money(totalUsed)}}
    {money(memberUsed)} / {monthly > 0 ? `${memberPct.toFixed(0)}%` : "不限"}
    {isOwner ? 不可编辑 diff --git a/core/frontend/src/types.ts b/core/frontend/src/types.ts index 69c254f..c75b56f 100644 --- a/core/frontend/src/types.ts +++ b/core/frontend/src/types.ts @@ -16,8 +16,12 @@ export type TeamMember = { id: string; role: string; status: string; + daily_credit_limit?: string; monthly_credit_limit: string; + total_credit_limit?: string; month_charged?: string; + day_charged?: string; + total_charged?: string; user: User; };