diff --git a/core/backend/apps/accounts/migrations/0010_team_is_personal.py b/core/backend/apps/accounts/migrations/0010_team_is_personal.py new file mode 100644 index 0000000..bb8ee58 --- /dev/null +++ b/core/backend/apps/accounts/migrations/0010_team_is_personal.py @@ -0,0 +1,16 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("accounts", "0009_team_price_multiplier"), + ] + + operations = [ + migrations.AddField( + model_name="team", + name="is_personal", + field=models.BooleanField(default=False), + ), + ] diff --git a/core/backend/apps/accounts/models.py b/core/backend/apps/accounts/models.py index 0b541ed..20662f6 100644 --- a/core/backend/apps/accounts/models.py +++ b/core/backend/apps/accounts/models.py @@ -40,6 +40,9 @@ class Team(TimeStampedModel): # 视频类按实结算用**下单时的快照系数**(request_payload.price_multiplier),中途改价不影响在途任务。 # 仅平台超管可改(admin 团队定价端点,0.10~10.00 边界)。 price_multiplier = models.DecimalField(max_digits=5, decimal_places=2, default=1) + # 个人团队:后台直建用户时自动配的一人队,只当这个用户的积分钱包(积分账户是 OneToOne 挂 Team 的, + # 所以「给用户发积分」在数据层仍然是给团队发)。团队体系一行没改,只是这类队不进后台团队列表。 + is_personal = models.BooleanField(default=False) def __str__(self) -> str: return self.name diff --git a/core/backend/apps/adminpanel/serializers.py b/core/backend/apps/adminpanel/serializers.py index 6c5fbb3..be96cc2 100644 --- a/core/backend/apps/adminpanel/serializers.py +++ b/core/backend/apps/adminpanel/serializers.py @@ -98,20 +98,57 @@ class AdminTeamMemberSerializer(serializers.ModelSerializer): read_only_fields = fields +def wallet_membership(user): + """用户的钱包归属:第一个 active 成员关系(与 common.api.get_current_team 同口径,消费从这个团队扣)。 + 列表页依赖 prefetch 好的 team_memberships,这里只在内存里挑,不要改成 .filter() 否则每行多一次查询。""" + actives = [m for m in user.team_memberships.all() if m.status == TeamMember.Status.ACTIVE] + return min(actives, key=lambda m: m.created_at) if actives else None + + class AdminUserSerializer(serializers.ModelSerializer): teams = serializers.SerializerMethodField() + # 后台按「用户」视角管积分,但账户实际挂在团队上。这里把钱包团队拍平给前端: + # wallet_shared=True 表示这是个多人共享池(老用户),发积分弹窗要明确提示钱会进整个团队。 + balance = serializers.SerializerMethodField() + wallet_team = serializers.SerializerMethodField() + wallet_team_name = serializers.SerializerMethodField() + wallet_shared = serializers.SerializerMethodField() class Meta: model = User - fields = ["id", "username", "status", "is_platform_admin", "date_joined", "teams"] + fields = [ + "id", "username", "status", "is_platform_admin", "date_joined", "teams", + "balance", "wallet_team", "wallet_team_name", "wallet_shared", + ] read_only_fields = fields def get_teams(self, obj): return [ {"team_id": str(m.team_id), "team_name": m.team.name, "role": m.role} - for m in obj.team_memberships.select_related("team").all() + for m in obj.team_memberships.all() + if not m.team.is_personal # 个人团队是钱包实现细节,不当「所属团队」展示 ] + def get_balance(self, obj): + m = wallet_membership(obj) + acct = getattr(m.team, "credit_account", None) if m else None + return str(acct.balance) if acct is not None else "0" + + def get_wallet_team(self, obj): + m = wallet_membership(obj) + return str(m.team_id) if m else None + + def get_wallet_team_name(self, obj): + m = wallet_membership(obj) + return m.team.name if m else None + + def get_wallet_shared(self, obj): + m = wallet_membership(obj) + if m is None: + return False + count = getattr(m, "team_member_count", None) + return bool((count if count is not None else m.team.members.count()) > 1) + class AdminTaskSerializer(serializers.ModelSerializer): team_name = serializers.CharField(source="team.name", read_only=True, default=None) diff --git a/core/backend/apps/adminpanel/tests.py b/core/backend/apps/adminpanel/tests.py index 3444d35..df41d60 100644 --- a/core/backend/apps/adminpanel/tests.py +++ b/core/backend/apps/adminpanel/tests.py @@ -5,6 +5,7 @@ from rest_framework.test import APIClient from django.test import TestCase from apps.accounts.models import AdminAuditLog, Invitation, Team, TeamMember, User +from apps.billing.models import CreditAccount, CreditLedger class AdminInvitationApiTests(TestCase): @@ -850,3 +851,102 @@ class AdminGovernanceTests(TestCase): self.assertGreaterEqual(checks["teams_without_account"], 1) # GovTeam 无 credit_account self.assertIn("totals", r.data) self.assertGreaterEqual(r.data["totals"]["projects"], 2) + + +class AdminUserProvisioningTests(TestCase): + """后台直建用户 + 按用户发积分。积分账户仍挂 Team(计费链路不变), + 新用户配一个 is_personal 的一人团队当专属钱包;老的多人团队用户发积分则进共享池。""" + + def setUp(self): + self.admin = User.objects.create_user(username="padmin", password="x", is_platform_admin=True) + self.normal = User.objects.create_user(username="normal", password="x") + self.team = Team.objects.create(name="Legacy Team", owner=self.normal) + TeamMember.objects.create(team=self.team, user=self.normal, role=TeamMember.Role.OWNER) + self.mate = User.objects.create_user(username="mate", password="x") + TeamMember.objects.create(team=self.team, user=self.mate, role=TeamMember.Role.MEMBER) + CreditAccount.objects.create(team=self.team, balance=Decimal("100")) + self.ac = APIClient() + self.ac.force_authenticate(self.admin) + self.nc = APIClient() + self.nc.force_authenticate(self.normal) + + def _create(self, **overrides): + payload = {"username": "created-user", "password": "strong-pass-1", "initial_credits": "500"} + payload.update(overrides) + return self.ac.post("/api/admin/users/", payload, format="json") + + def test_create_user_provisions_personal_wallet(self): + r = self._create() + self.assertEqual(r.status_code, 201) + self.assertEqual(r.data["balance"], "500.0000") + self.assertFalse(r.data["wallet_shared"]) + self.assertEqual(r.data["teams"], []) # 个人团队是钱包实现细节,不当「所属团队」露出 + + user = User.objects.get(username="created-user") + team = Team.objects.get(owner=user) + self.assertTrue(team.is_personal) + self.assertEqual(team.credit_account.balance, Decimal("500")) + ledger = CreditLedger.objects.get(team=team, ledger_type=CreditLedger.Type.RECHARGE) + self.assertEqual(ledger.metadata["target_user_id"], str(user.id)) + self.assertTrue(AdminAuditLog.objects.filter(action="user.create").exists()) + + def test_created_user_can_login(self): + self._create() + r = APIClient().post("/api/auth/login/", {"username": "created-user", "password": "strong-pass-1"}, format="json") + self.assertEqual(r.status_code, 200) + + def test_create_user_validation_and_permission(self): + self.assertEqual(self.nc.post("/api/admin/users/", {}, format="json").status_code, 403) + self.assertEqual(self._create(username="").status_code, 400) + self.assertEqual(self._create(password="short").status_code, 400) + self.assertEqual(self._create(initial_credits="-5").status_code, 400) + self.assertEqual(self._create(username="normal").status_code, 400) # 重名 + self.assertEqual(User.objects.filter(username="created-user").count(), 0) + + def test_create_user_without_initial_credits(self): + r = self._create(initial_credits="") + self.assertEqual(r.status_code, 201) + self.assertEqual(r.data["balance"], "0.0000") + team = Team.objects.get(owner__username="created-user") + self.assertFalse(CreditLedger.objects.filter(team=team).exists()) # 0 积分不落空流水 + + def test_adjust_user_credit(self): + user_id = self._create().data["id"] + r = self.ac.post(f"/api/admin/users/{user_id}/credits/", {"amount": "250", "reason": "活动赠送"}, format="json") + self.assertEqual(r.status_code, 201) + self.assertEqual(r.data["balance"], "750.0000") + ledger = CreditLedger.objects.filter(ledger_type=CreditLedger.Type.ADJUSTMENT).latest("created_at") + self.assertEqual(ledger.reason, "活动赠送") + self.assertEqual(ledger.metadata["target_user_id"], user_id) + self.assertTrue(AdminAuditLog.objects.filter(action="user.credit_adjust").exists()) + + # 扣减合法,但不能扣成负数 + self.assertEqual(self.ac.post(f"/api/admin/users/{user_id}/credits/", {"amount": "-50"}, format="json").data["balance"], "700.0000") + self.assertEqual(self.ac.post(f"/api/admin/users/{user_id}/credits/", {"amount": "-9999"}, format="json").status_code, 400) + self.assertEqual(self.ac.post(f"/api/admin/users/{user_id}/credits/", {"amount": "0"}, format="json").status_code, 400) + self.assertEqual(self.nc.post(f"/api/admin/users/{user_id}/credits/", {"amount": "1"}, format="json").status_code, 403) + + def test_legacy_multi_member_user_uses_shared_pool(self): + r = self.ac.get("/api/admin/users/?search=mate") + row = next(u for u in r.data["results"] if u["username"] == "mate") + self.assertEqual(row["balance"], "100.0000") + self.assertTrue(row["wallet_shared"]) + self.assertEqual(row["wallet_team_name"], "Legacy Team") + + self.ac.post(f"/api/admin/users/{row['id']}/credits/", {"amount": "40"}, format="json") + self.team.credit_account.refresh_from_db() + self.assertEqual(self.team.credit_account.balance, Decimal("140")) # 进的是团队共享池 + + def test_platform_admin_without_team_has_no_wallet(self): + r = self.ac.get("/api/admin/users/?search=padmin") + row = next(u for u in r.data["results"] if u["username"] == "padmin") + self.assertIsNone(row["wallet_team"]) + self.assertEqual(row["balance"], "0") + + def test_personal_teams_hidden_from_team_list(self): + self._create() + names = {t["name"] for t in self.ac.get("/api/admin/teams/").data["results"]} + self.assertIn("Legacy Team", names) + self.assertNotIn("created-user", names) + all_names = {t["name"] for t in self.ac.get("/api/admin/teams/?include_personal=1").data["results"]} + self.assertIn("created-user", all_names) diff --git a/core/backend/apps/adminpanel/urls.py b/core/backend/apps/adminpanel/urls.py index fdef78b..cdd4d44 100644 --- a/core/backend/apps/adminpanel/urls.py +++ b/core/backend/apps/adminpanel/urls.py @@ -31,6 +31,7 @@ from .views import ( admin_team_pricing, admin_team_toggle, admin_teams, + admin_user_credits, admin_user_reset_password, admin_user_toggle, admin_users, @@ -47,6 +48,7 @@ urlpatterns = [ path("users/", admin_users, name="admin-users"), path("users//toggle/", admin_user_toggle, name="admin-user-toggle"), path("users//reset-password/", admin_user_reset_password, name="admin-user-reset-password"), + path("users//credits/", admin_user_credits, name="admin-user-credits"), path("quality-words/", admin_quality_words, name="admin-quality-words"), path("quality-words//", admin_quality_word_detail, name="admin-quality-word-detail"), path("prompt-templates/", admin_prompt_templates, name="admin-prompt-templates"), diff --git a/core/backend/apps/adminpanel/views.py b/core/backend/apps/adminpanel/views.py index c8aefd3..bac56a1 100644 --- a/core/backend/apps/adminpanel/views.py +++ b/core/backend/apps/adminpanel/views.py @@ -3,21 +3,23 @@ import logging from decimal import Decimal, ROUND_HALF_UP -from django.db.models import Case, CharField, Count, F, Q, Value, When +from django.core.exceptions import ValidationError as DjangoValidationError +from django.db import transaction +from django.db.models import Case, CharField, Count, F, Prefetch, Q, Value, When from rest_framework import status from rest_framework.authtoken.models import Token from rest_framework.decorators import api_view, permission_classes from rest_framework.response import Response from apps.accounts.audit import log_admin_action -from apps.accounts.models import Invitation, Team, User +from apps.accounts.models import Invitation, Team, TeamMember, User from apps.accounts.permissions import IsPlatformAdmin from apps.accounts.serializers import InvitationSerializer from apps.ai.model_catalog import invalidate_model_catalog_cache from apps.ai.models import AITask, ModelConfig, ModelProvider, PromptTemplate, QualityWord from apps.assets.models import Asset from apps.assets.review import poll_asset_review, submit_asset_for_review -from apps.billing.models import BillingConfig, CreditLedger, QuotaPolicy +from apps.billing.models import BillingConfig, CreditAccount, CreditLedger, QuotaPolicy from apps.billing.pricing import get_billing_config, invalidate_billing_config_cache from apps.billing.services.ledger import adjust_credit from apps.common.pagination import DefaultPagination @@ -63,6 +65,48 @@ def _team_qs(): ) +def _admin_user_qs(): + """用户列表:把成员关系连同团队钱包一次性 prefetch 出来,序列化器才能免查询地拍出余额。 + team_member_count 用来判断钱包是不是多人共享池(决定发积分弹窗的提示文案)。""" + memberships = ( + TeamMember.objects.select_related("team", "team__credit_account") + .annotate(team_member_count=Count("team__members", distinct=True)) + .order_by("created_at") + ) + return User.objects.prefetch_related(Prefetch("team_memberships", queryset=memberships)) + + +def _wallet_team(user): + """用户的钱包团队:第一个 active 成员关系(与 get_current_team 同口径)。无团队返回 None。""" + membership = ( + user.team_memberships.filter(status=TeamMember.Status.ACTIVE) + .select_related("team") + .order_by("created_at") + .first() + ) + return membership.team if membership else None + + +def _parse_points(raw, *, field: str, allow_negative: bool): + """解析后台传来的积分数量。积分全站按整数流通,这里统一 HALF_UP 取整(与计价引擎同口径)。 + 返回 (Decimal, None) 或 (None, Response)。""" + from decimal import InvalidOperation + + try: + value = Decimal(str(raw)) + except (InvalidOperation, TypeError, ValueError): + return None, Response({field: ["数值格式不正确"]}, status=status.HTTP_400_BAD_REQUEST) + # Decimal("NaN")/("Infinity") 构造不抛,进库才炸 500 → 显式拦 + if not value.is_finite(): + return None, Response({field: ["数值格式不正确"]}, status=status.HTTP_400_BAD_REQUEST) + value = value.quantize(Decimal("1"), rounding=ROUND_HALF_UP) + if not allow_negative and value < 0: + return None, Response({field: ["积分不能为负"]}, status=status.HTTP_400_BAD_REQUEST) + if abs(value) > Decimal("10000000"): + return None, Response({field: ["单次不能超过 1,000 万积分"]}, status=status.HTTP_400_BAD_REQUEST) + return value, None + + @api_view(["GET", "POST"]) @permission_classes([IsPlatformAdmin]) def admin_invitations(request): @@ -127,8 +171,11 @@ def admin_revoke_invitation(request, invite_id): @api_view(["GET"]) @permission_classes([IsPlatformAdmin]) def admin_teams(request): - """列所有团队(跨团队,可按 status/search 过滤,分页)。""" + """列所有团队(跨团队,可按 status/search 过滤,分页)。 + 默认隐藏个人团队(后台直建用户的钱包容器,只在用户页以「余额」形态出现),?include_personal=1 可看全。""" qs = _team_qs().order_by("-created_at") + if str(request.query_params.get("include_personal") or "") not in {"1", "true"}: + qs = qs.filter(is_personal=False) st = request.query_params.get("status") if st in dict(Team.Status.choices): qs = qs.filter(status=st) @@ -209,11 +256,14 @@ def admin_team_pricing(request, team_id): return Response(AdminTeamSerializer(_team_qs().get(id=team.id)).data) -@api_view(["GET"]) +@api_view(["GET", "POST"]) @permission_classes([IsPlatformAdmin]) def admin_users(request): - """列所有用户(跨团队,可按 status/search 过滤,分页)。""" - qs = User.objects.prefetch_related("team_memberships__team").order_by("-date_joined") + """GET 列所有用户(可按 status/search 过滤,分页,每行带钱包余额); + POST 平台超管直接开户(不走邀请码),顺带配一个个人团队当钱包。""" + if request.method == "POST": + return _admin_create_user(request) + qs = _admin_user_qs().order_by("-date_joined") st = request.query_params.get("status") if st in dict(User.Status.choices): qs = qs.filter(status=st) @@ -225,6 +275,99 @@ def admin_users(request): return paginator.get_paginated_response(AdminUserSerializer(page, many=True).data) +def _admin_create_user(request): + """后台直建用户:{username, password, initial_credits?}。 + + 积分账户是 OneToOne 挂 Team 的,所以这里给新用户配一个 is_personal 的一人团队当专属钱包 —— + 计费链路(预留/实扣/流水/额度)一行不用改,后台却能按「用户」视角管人和钱。 + 后续要恢复多人协作,把人拉进同一个团队即可,不需要迁数据。""" + username = str(request.data.get("username") or "").strip() + password = str(request.data.get("password") or "").strip() + if not username: + return Response({"username": ["请填写用户名"]}, status=status.HTTP_400_BAD_REQUEST) + if len(username) > 150: + return Response({"username": ["用户名不能超过 150 字"]}, status=status.HTTP_400_BAD_REQUEST) + try: + for validator in User._meta.get_field("username").validators: + validator(username) + except DjangoValidationError as exc: + return Response({"username": list(exc.messages)}, status=status.HTTP_400_BAD_REQUEST) + if User.objects.filter(username=username).exists(): + return Response({"username": ["该用户名已存在"]}, status=status.HTTP_400_BAD_REQUEST) + if len(password) < 8: + return Response({"password": ["密码至少 8 位"]}, status=status.HTTP_400_BAD_REQUEST) + initial, err = _parse_points(request.data.get("initial_credits") or 0, field="initial_credits", allow_negative=False) + if err is not None: + return err + + with transaction.atomic(): + user = User.objects.create_user(username=username, password=password) + team = Team.objects.create(name=username, owner=user, is_personal=True) + TeamMember.objects.create(team=team, user=user, role=TeamMember.Role.OWNER) + CreditAccount.objects.create(team=team, balance=initial) + if initial > 0: + # 开户额度必须进流水,否则这笔钱在账单里查无凭证、无法对账(与注册赠送同口径) + CreditLedger.objects.create( + team=team, + user=request.user, + ledger_type=CreditLedger.Type.RECHARGE, + amount=initial, + balance_after=initial, + reason="后台开户初始积分", + metadata={"kind": "admin_create_user", "target_user_id": str(user.id)}, + ) + + log_admin_action( + request, + "user.create", + target_type="user", + target_id=user.id, + target_name=user.username, + after={"initial_credits": str(initial), "wallet_team": str(team.id)}, + ) + return Response(AdminUserSerializer(_admin_user_qs().get(id=user.id)).data, status=status.HTTP_201_CREATED) + + +@api_view(["POST"]) +@permission_classes([IsPlatformAdmin]) +def admin_user_credits(request, user_id): + """给用户发/扣积分:{amount, reason?}。amount 可正可负,落 ADJUSTMENT 流水。 + + 钱实际进的是这个用户的钱包团队 —— 个人团队即专属钱包;老用户若在多人团队里, + 这笔会进团队共享池(前端弹窗已明确提示,后端流水的 metadata 记下目标用户备查)。""" + user = User.objects.filter(id=user_id).first() + if user is None: + return Response({"detail": "not found"}, status=status.HTTP_404_NOT_FOUND) + team = _wallet_team(user) + if team is None: + return Response({"detail": "该用户没有可用的积分钱包(无生效团队)"}, status=status.HTTP_400_BAD_REQUEST) + amount, err = _parse_points(request.data.get("amount"), field="amount", allow_negative=True) + if err is not None: + return err + if amount == 0: + return Response({"amount": ["调额金额不能为 0"]}, status=status.HTTP_400_BAD_REQUEST) + reason = str(request.data.get("reason") or "").strip() + try: + ledger = adjust_credit( + team=team, + amount=amount, + reason=reason or f"平台给 {user.username} 调额", + operator=request.user, + metadata={"target_user_id": str(user.id), "target_username": user.username}, + ) + except ValueError as exc: + return Response({"detail": str(exc)}, status=status.HTTP_400_BAD_REQUEST) + log_admin_action( + request, + "user.credit_adjust", + target_type="user", + target_id=user.id, + target_name=user.username, + after={"amount": str(amount), "balance_after": str(ledger.balance_after), "team": str(team.id), "reason": reason}, + ) + return Response(AdminUserSerializer(_admin_user_qs().get(id=user.id)).data, status=status.HTTP_201_CREATED) + + @api_view(["POST"]) @permission_classes([IsPlatformAdmin]) def admin_user_toggle(request, user_id): @@ -248,7 +391,7 @@ def admin_user_toggle(request, user_id): before={"status": before}, after={"status": user.status}, ) - return Response(AdminUserSerializer(user).data) + return Response(AdminUserSerializer(_admin_user_qs().get(id=user.id)).data) @api_view(["POST"]) diff --git a/core/backend/apps/billing/services/ledger.py b/core/backend/apps/billing/services/ledger.py index f50024d..75dd9fa 100644 --- a/core/backend/apps/billing/services/ledger.py +++ b/core/backend/apps/billing/services/ledger.py @@ -113,7 +113,7 @@ def _enforce_quota_policy(*, team, project, amount: Decimal) -> None: @transaction.atomic -def adjust_credit(*, team, amount: Decimal, reason: str = "", operator=None) -> CreditLedger: +def adjust_credit(*, team, amount: Decimal, reason: str = "", operator=None, metadata: dict | None = None) -> CreditLedger: """平台超管手动调额(争议 / 补偿)。amount 可正可负;落 ADJUSTMENT 流水并更新余额。 调额后余额不得为负(否则拒绝)。""" account, _ = CreditAccount.objects.select_for_update().get_or_create(team=team) @@ -129,7 +129,7 @@ def adjust_credit(*, team, amount: Decimal, reason: str = "", operator=None) -> amount=amount, balance_after=new_balance, reason=reason or "平台手动调额", - metadata={"kind": "admin_adjust"}, + metadata={"kind": "admin_adjust", **(metadata or {})}, ) diff --git a/core/frontend/src/api.ts b/core/frontend/src/api.ts index 55c1caa..a8fd148 100644 --- a/core/frontend/src/api.ts +++ b/core/frontend/src/api.ts @@ -1216,6 +1216,12 @@ export const adminApi = { const q = qs.toString(); return request>(`/api/admin/users/${q ? `?${q}` : ""}`); }, + createUser(payload: { username: string; password: string; initial_credits?: string }) { + return request("/api/admin/users/", { method: "POST", body: JSON.stringify(payload) }); + }, + adjustUserCredit(id: string, payload: { amount: string; reason?: string }) { + return request(`/api/admin/users/${id}/credits/`, { method: "POST", body: JSON.stringify(payload) }); + }, toggleUser(id: string) { return request(`/api/admin/users/${id}/toggle/`, { method: "POST" }); }, diff --git a/core/frontend/src/design-restraint.css b/core/frontend/src/design-restraint.css index 23df8ed..a978120 100644 --- a/core/frontend/src/design-restraint.css +++ b/core/frontend/src/design-restraint.css @@ -1238,6 +1238,12 @@ body.sidebar-collapsed .user::after { display: none; } .field-label .req { color: var(--accent-crimson); margin-left: 2px; } .field-hint { font-size: 12px; color: var(--black-alpha-48); } .field-hint.is-error { color: var(--accent-crimson); } +/* 标签内联注解(取值范围 / 单位 / 可留空):跟 .req 一样附在 .field-label 里,mono 灰弱化 */ +.field-label .lbl-note { color: var(--black-alpha-48); font-weight: 400; margin-left: 2px; font-family: var(--font-mono); } +/* 数字输入:去掉浏览器上下箭头 + 等宽数字,避免输入时字宽跳动 */ +.num-input::-webkit-outer-spin-button, +.num-input::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; } +.num-input { -moz-appearance: textfield; font-variant-numeric: tabular-nums; } .input, .textarea, .select { height: 36px; padding: 0 14px; diff --git a/core/frontend/src/routes/admin/admin-teams-users.tsx b/core/frontend/src/routes/admin/admin-teams-users.tsx index 6af27cb..7f6a5d9 100644 --- a/core/frontend/src/routes/admin/admin-teams-users.tsx +++ b/core/frontend/src/routes/admin/admin-teams-users.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useState } from "react"; -import { Building, KeyRound, Percent, X } from "lucide-react"; +import { Building, Coins, KeyRound, Percent, UserPlus, X } from "lucide-react"; import { adminApi } from "../../api"; import { Pager } from "../../components/pager"; import { IconKitSvg } from "../../components/IconKitSvg"; @@ -247,6 +247,17 @@ export function AdminUsersPage({ notify }: { notify: Notify }) { const [pwdTarget, setPwdTarget] = useState(null); const [pwd, setPwd] = useState(""); const [saving, setSaving] = useState(false); + // 直接开户:不发邀请码,后端建号时自动配一个个人团队当钱包(团队体系保留,后台只按用户视角管) + const [createOpen, setCreateOpen] = useState(false); + const [newName, setNewName] = useState(""); + const [newPwd, setNewPwd] = useState(""); + const [newCredits, setNewCredits] = useState(""); + const [creating, setCreating] = useState(false); + // 发积分:金额可正可负,落一条调额流水 + const [creditTarget, setCreditTarget] = useState(null); + const [creditAmt, setCreditAmt] = useState(""); + const [creditReason, setCreditReason] = useState(""); + const [creditSaving, setCreditSaving] = useState(false); const load = useCallback(async () => { setLoading(true); @@ -293,12 +304,68 @@ export function AdminUsersPage({ notify }: { notify: Notify }) { } } + function closeCreate() { + setCreateOpen(false); + setNewName(""); + setNewPwd(""); + setNewCredits(""); + } + + async function doCreate() { + if (creating) return; + const name = newName.trim(); + if (!name) { notify("error", "请填写用户名"); return; } + if (newPwd.trim().length < 8) { notify("error", "密码至少 8 位"); return; } + const credits = newCredits.trim(); + if (credits && (!Number.isFinite(Number(credits)) || Number(credits) < 0)) { notify("error", "初始积分需为非负数"); return; } + setCreating(true); + try { + await adminApi.createUser({ username: name, password: newPwd.trim(), initial_credits: credits || "0" }); + notify("success", `已创建用户 ${name}`); + closeCreate(); + setPage(1); + await load(); + } catch (e) { + notify("error", e instanceof Error ? e.message : "创建失败"); + } finally { + setCreating(false); + } + } + + function closeCredit() { + setCreditTarget(null); + setCreditAmt(""); + setCreditReason(""); + } + + async function doCredit() { + if (!creditTarget || creditSaving) return; + const amt = Number(creditAmt); + if (!Number.isFinite(amt) || amt === 0) { notify("error", "请填写非 0 的积分数量"); return; } + setCreditSaving(true); + try { + await adminApi.adjustUserCredit(creditTarget.id, { amount: creditAmt.trim(), reason: creditReason.trim() }); + notify("success", `已为 ${creditTarget.username} ${amt > 0 ? "发放" : "扣减"} ${Math.abs(amt)} 积分`); + closeCredit(); + await load(); + } catch (e) { + notify("error", e instanceof Error ? e.message : "调额失败"); + } finally { + setCreditSaving(false); + } + } + return ( <>

用户

-
{count} 个用户 · 跨团队 · 启停 / 强制改密
+
{count} 个用户 · 直接开户 · 发积分 / 启停 / 强制改密
+
+
+
@@ -319,7 +386,7 @@ export function AdminUsersPage({ notify }: { notify: Notify }) {
- + {users.map((u) => ( @@ -328,6 +395,9 @@ export function AdminUsersPage({ notify }: { notify: Notify }) { {u.username} {u.is_platform_admin && 超管} +
用户名所属团队状态注册时间操作
用户名积分余额所属团队状态注册时间操作
+ {u.wallet_team ? `${pts(u.balance)} 积分` : } + {u.teams.length === 0 ? @@ -340,6 +410,7 @@ export function AdminUsersPage({ notify }: { notify: Notify }) { ) : ( <> + {u.wallet_team && } + +
+

直接开户,不用发邀请码。建好即可用该用户名密码登录,并拥有一个独立的积分钱包。

+
+ + setNewName(e.target.value)} /> +
+
+ + setNewPwd(e.target.value)} /> +
+
+ + setNewCredits(e.target.value)} /> +
+
+
+ + +
+ + + )} + + {creditTarget && ( +
{ if (e.target === e.currentTarget) closeCredit(); }}> +
+ ++ +
+
+
发积分{creditTarget.username}
+ +
+
+

+ 当前余额 {pts(creditTarget.balance)} 积分。填正数为发放,负数为扣减;扣减后余额不得为负。会落一条调额流水。 +

+
+ + setCreditAmt(e.target.value)} /> + {creditTarget.wallet_shared && ( +
⚠ 该用户属于多人团队「{creditTarget.wallet_team_name}」,这笔积分会进团队共享池,同队成员都能使用。
+ )} +
+
+ + setCreditReason(e.target.value)} /> +
+
+
+ + +
+
+
+ )} + {pwdTarget && (
{ if (e.target === e.currentTarget) setPwdTarget(null); }}>
diff --git a/core/frontend/src/team-page.css b/core/frontend/src/team-page.css index ce4cb1e..db59443 100644 --- a/core/frontend/src/team-page.css +++ b/core/frontend/src/team-page.css @@ -389,19 +389,13 @@ 绝不复用 styles.css 旧暖米调色板的同名类(.role-choice/.cred-card 等)。 ═══════════════════════════════════════════════════════════════════ */ -/* 共用:字段必填星标 / 标签副注 */ -.invite-modal .field-label .req { color: var(--accent-crimson); margin-left: 2px; } -.invite-modal .field-label .lbl-note { color: var(--black-alpha-48); font-weight: 400; margin-left: 2px; font-family: var(--font-mono); } +/* .req / .lbl-note / .num-input 已提到 design-restraint.css 作共享类,这里不再重写 */ /* 共用:输入框 + 行内随机生成按钮 */ .invite-modal .input-with-gen { display: flex; gap: 8px; } .invite-modal .input-with-gen .input { flex: 1; min-width: 0; } .invite-modal .input-with-gen .btn-sm { height: 38px; padding: 0 12px; flex: 0 0 auto; } .invite-modal .pw-input { letter-spacing: .04em; } -/* 数字输入去掉 spinner(对齐设计稿) */ -.invite-modal .num-input::-webkit-outer-spin-button, -.invite-modal .num-input::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; } -.invite-modal .num-input { -moz-appearance: textfield; font-variant-numeric: tabular-nums; } /* ─── 角色双卡(创建账户 / 编辑成员共用)─── */ .invite-modal .role-choices { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; } diff --git a/core/frontend/src/types.ts b/core/frontend/src/types.ts index d21360d..f47952f 100644 --- a/core/frontend/src/types.ts +++ b/core/frontend/src/types.ts @@ -83,6 +83,11 @@ export type AdminUser = { is_platform_admin: boolean; date_joined: string; teams: { team_id: string; team_name: string; role: string }[]; + // 钱包:积分账户挂在团队上,后台按用户视角拍平。wallet_shared=true 表示这是多人共享池 + balance: string; + wallet_team: string | null; + wallet_team_name: string | null; + wallet_shared: boolean; }; export type AdminQualityWord = { id: string;