优化后台用户管理和发积分

This commit is contained in:
Azmat@qq.com
2026-09-20 16:30:45 +08:00
parent fb3b2904ae
commit 690eb3d843
12 changed files with 470 additions and 22 deletions
@@ -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),
),
]
+3
View File
@@ -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
+39 -2
View File
@@ -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)
+100
View File
@@ -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)
+2
View File
@@ -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/<uuid:user_id>/toggle/", admin_user_toggle, name="admin-user-toggle"),
path("users/<uuid:user_id>/reset-password/", admin_user_reset_password, name="admin-user-reset-password"),
path("users/<uuid:user_id>/credits/", admin_user_credits, name="admin-user-credits"),
path("quality-words/", admin_quality_words, name="admin-quality-words"),
path("quality-words/<uuid:word_id>/", admin_quality_word_detail, name="admin-quality-word-detail"),
path("prompt-templates/", admin_prompt_templates, name="admin-prompt-templates"),
+151 -8
View File
@@ -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"])
+2 -2
View File
@@ -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 {})},
)
+6
View File
@@ -1216,6 +1216,12 @@ export const adminApi = {
const q = qs.toString();
return request<Paginated<AdminUser>>(`/api/admin/users/${q ? `?${q}` : ""}`);
},
createUser(payload: { username: string; password: string; initial_credits?: string }) {
return request<AdminUser>("/api/admin/users/", { method: "POST", body: JSON.stringify(payload) });
},
adjustUserCredit(id: string, payload: { amount: string; reason?: string }) {
return request<AdminUser>(`/api/admin/users/${id}/credits/`, { method: "POST", body: JSON.stringify(payload) });
},
toggleUser(id: string) {
return request<AdminUser>(`/api/admin/users/${id}/toggle/`, { method: "POST" });
},
+6
View File
@@ -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;
@@ -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<AdminUser | null>(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<AdminUser | null>(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 (
<>
<div className="page-head">
<div>
<h1></h1>
<div className="sub"><span className="mono">{count} </span> · · / </div>
<div className="sub"><span className="mono">{count} </span> · · / / </div>
</div>
<div className="actions">
<button className="btn btn-primary" type="button" onClick={() => setCreateOpen(true)}>
<UserPlus size={15} />
</button>
</div>
</div>
@@ -319,7 +386,7 @@ export function AdminUsersPage({ notify }: { notify: Notify }) {
<div className="admin-table-wrap">
<table className="t admin-table">
<thead>
<tr><th></th><th></th><th></th><th></th><th className="col-actions"></th></tr>
<tr><th></th><th></th><th></th><th></th><th></th><th className="col-actions"></th></tr>
</thead>
<tbody>
{users.map((u) => (
@@ -328,6 +395,9 @@ export function AdminUsersPage({ notify }: { notify: Notify }) {
{u.username}
{u.is_platform_admin && <span className="pill info admin-inline-pill"><span className="dot" /></span>}
</td>
<td className="num mono">
{u.wallet_team ? `${pts(u.balance)} 积分` : <span className="muted"></span>}
</td>
<td>
{u.teams.length === 0
? <span className="muted"></span>
@@ -340,6 +410,7 @@ export function AdminUsersPage({ notify }: { notify: Notify }) {
<span className="muted"></span>
) : (
<>
{u.wallet_team && <button className="btn btn-sm btn-ghost" type="button" onClick={() => setCreditTarget(u)}></button>}
<button className="btn btn-sm btn-ghost" type="button" onClick={() => setPwdTarget(u)}></button>
<button className={`btn btn-sm btn-ghost${u.status === "active" ? " danger" : ""}`} type="button" onClick={() => toggle(u)}>
{u.status === "active" ? "停用" : "启用"}
@@ -355,6 +426,71 @@ export function AdminUsersPage({ notify }: { notify: Notify }) {
</div>
)}
{createOpen && (
<div className="modal-bg show">
<div className="modal" role="dialog" aria-modal="true" aria-label="新建用户">
<span className="corner-tr">+</span><span className="corner-bl">+</span>
<div className="modal-h">
<div className="ic-m"><UserPlus size={16} /></div>
<div className="ti"></div>
<button className="x modal-x" type="button" aria-label="关闭" onClick={closeCreate}><X size={14} /></button>
</div>
<div className="modal-b">
<p className="admin-modal-desc">,,</p>
<div className="field">
<label className="field-label" htmlFor="new-username"> <span className="req">*</span></label>
<input id="new-username" className="input" type="text" placeholder="登录用的用户名" value={newName} onChange={(e) => setNewName(e.target.value)} />
</div>
<div className="field">
<label className="field-label" htmlFor="new-password"> <span className="req">*</span> <span className="lbl-note">( 8 )</span></label>
<input id="new-password" className="input" type="text" placeholder="告知用户后建议其自行修改" value={newPwd} onChange={(e) => setNewPwd(e.target.value)} />
</div>
<div className="field">
<label className="field-label" htmlFor="new-credits"> <span className="lbl-note">(, 0)</span></label>
<input id="new-credits" className="input num-input" type="number" inputMode="numeric" min="0" placeholder="例: 1000" value={newCredits} onChange={(e) => setNewCredits(e.target.value)} />
</div>
</div>
<div className="modal-f">
<button className="btn" type="button" onClick={closeCreate}></button>
<button className="btn btn-primary" type="button" disabled={creating} onClick={() => void doCreate()}>{creating ? "创建中…" : "创建用户"}</button>
</div>
</div>
</div>
)}
{creditTarget && (
<div className="modal-bg show" onClick={(e) => { if (e.target === e.currentTarget) closeCredit(); }}>
<div className="modal" role="dialog" aria-modal="true" aria-label="发积分">
<span className="corner-tr">+</span><span className="corner-bl">+</span>
<div className="modal-h">
<div className="ic-m"><Coins size={16} /></div>
<div className="ti"><span>{creditTarget.username}</span></div>
<button className="x modal-x" type="button" aria-label="关闭" onClick={closeCredit}><X size={14} /></button>
</div>
<div className="modal-b">
<p className="admin-modal-desc">
<b style={{ color: "var(--accent-black)" }}>{pts(creditTarget.balance)} </b>,;
</p>
<div className="field">
<label className="field-label" htmlFor="credit-amount"> <span className="req">*</span> <span className="lbl-note">( / )</span></label>
<input id="credit-amount" className="input num-input" type="number" inputMode="numeric" placeholder="例: 1000 或 -200" value={creditAmt} onChange={(e) => setCreditAmt(e.target.value)} />
{creditTarget.wallet_shared && (
<div className="field-hint"> {creditTarget.wallet_team_name},,使</div>
)}
</div>
<div className="field">
<label className="field-label" htmlFor="credit-reason"> <span className="lbl-note">(,便)</span></label>
<input id="credit-reason" className="input" type="text" placeholder="例: 活动赠送 / 争议补偿" value={creditReason} onChange={(e) => setCreditReason(e.target.value)} />
</div>
</div>
<div className="modal-f">
<button className="btn" type="button" onClick={closeCredit}></button>
<button className="btn btn-primary" type="button" disabled={creditSaving} onClick={() => void doCredit()}>{creditSaving ? "提交中…" : "确认调额"}</button>
</div>
</div>
</div>
)}
{pwdTarget && (
<div className="modal-bg show" onClick={(e) => { if (e.target === e.currentTarget) setPwdTarget(null); }}>
<div className="modal" role="dialog" aria-modal="true" aria-label="重置密码">
+1 -7
View File
@@ -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; }
+5
View File
@@ -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;