feat: allow admins to bind member phone numbers

Members page previously only displayed phone status; add bind/replace UI and admin member update phone support for login and sync identity.
This commit is contained in:
whm
2026-08-05 14:41:27 +08:00
parent cb56e6847e
commit 3c9fe46359
4 changed files with 119 additions and 9 deletions

View File

@@ -2,12 +2,14 @@ package handler
import ( import (
"encoding/json" "encoding/json"
"fmt"
"net/http" "net/http"
"strconv" "strconv"
"aijianzhan/platform/internal/authx" "aijianzhan/platform/internal/authx"
"aijianzhan/platform/internal/logic/applogic" "aijianzhan/platform/internal/logic/applogic"
"aijianzhan/platform/internal/svc" "aijianzhan/platform/internal/svc"
"aijianzhan/platform/internal/userstore"
"github.com/zeromicro/go-zero/rest/httpx" "github.com/zeromicro/go-zero/rest/httpx"
"github.com/zeromicro/go-zero/rest/pathvar" "github.com/zeromicro/go-zero/rest/pathvar"
@@ -296,20 +298,40 @@ func memberUpdateHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
Role string `json:"role"` Role string `json:"role"`
OrgUnitID int64 `json:"org_unit_id"` OrgUnitID int64 `json:"org_unit_id"`
Status string `json:"status"` Status string `json:"status"`
Phone *string `json:"phone"` // 传则绑定/更换;传 "" 解绑
} }
if err := json.NewDecoder(r.Body).Decode(&body); err != nil { if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
authx.WriteError(w, http.StatusBadRequest, err.Error()) authx.WriteError(w, http.StatusBadRequest, err.Error())
return return
} }
l := applogic.NewMemberLogic(applogic.NewAuthLogic(r.Context(), svcCtx)) l := applogic.NewMemberLogic(applogic.NewAuthLogic(r.Context(), svcCtx))
u, err := l.Update(id, body.Role, body.OrgUnitID, body.Status) var u *userstore.User
var err error
if body.Phone != nil {
u, err = l.SetPhone(id, *body.Phone)
if err != nil { if err != nil {
authx.WriteError(w, http.StatusBadRequest, err.Error()) authx.WriteError(w, http.StatusBadRequest, err.Error())
return return
} }
_ = svcCtx.Audit.Log(r.Context(), authx.TenantID(r.Context()), authx.UserID(r.Context()),
"member.bind_phone", fmt.Sprintf("成员 #%d phone=%s", id, u.Phone))
}
// 仅改手机时不必强制带 role/status若其它字段有值则再 Update
if body.Role != "" || body.Status != "" || body.OrgUnitID != 0 || body.Phone == nil {
u, err = l.Update(id, body.Role, body.OrgUnitID, body.Status)
if err != nil {
authx.WriteError(w, http.StatusBadRequest, err.Error())
return
}
}
if u == nil {
authx.WriteError(w, http.StatusBadRequest, "nothing to update")
return
}
httpx.OkJson(w, map[string]any{ httpx.OkJson(w, map[string]any{
"user_id": u.UserID, "user_id": u.UserID,
"username": u.Username, "username": u.Username,
"phone": u.Phone,
"display_name": u.DisplayName, "display_name": u.DisplayName,
"role": u.Role, "role": u.Role,
"org_unit_id": u.OrgUnitID, "org_unit_id": u.OrgUnitID,

View File

@@ -64,3 +64,19 @@ func (l *MemberLogic) Update(userID int64, role string, orgUnitID int64, status
} }
return l.svcCtx.Users.UpdateMember(l.ctx, tid, userID, role, orgUnitID, status) return l.svcCtx.Users.UpdateMember(l.ctx, tid, userID, role, orgUnitID, status)
} }
// SetPhone 管理员为本公司成员绑定/更换/清空手机号(空字符串解绑)。
func (l *MemberLogic) SetPhone(userID int64, phone string) (*userstore.User, error) {
tid := authx.TenantID(l.ctx)
if tid <= 0 {
return nil, fmt.Errorf("missing tenant")
}
u, err := l.svcCtx.Users.GetByID(l.ctx, userID)
if err != nil {
return nil, err
}
if u.TenantID != tid {
return nil, fmt.Errorf("成员不属于本公司")
}
return l.svcCtx.Users.BindPhone(l.ctx, userID, phone)
}

View File

@@ -38,6 +38,9 @@ export function MembersPage(props: {
const [orgs, setOrgs] = useState<OrgUnit[]>([]); const [orgs, setOrgs] = useState<OrgUnit[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [phoneOpen, setPhoneOpen] = useState(false);
const [phoneTarget, setPhoneTarget] = useState<Member | null>(null);
const [phoneValue, setPhoneValue] = useState("");
const [form] = Form.useForm<{ const [form] = Form.useForm<{
password?: string; password?: string;
display_name?: string; display_name?: string;
@@ -120,6 +123,7 @@ export function MembersPage(props: {
> >
<Typography.Paragraph type="secondary" style={{ marginTop: 0 }}> <Typography.Paragraph type="secondary" style={{ marginTop: 0 }}>
/
{session.role === "超级管理员" || session.role === "platform_admin" {session.role === "超级管理员" || session.role === "platform_admin"
? " 超管代管修改时会要求两次确认。" ? " 超管代管修改时会要求两次确认。"
: ""} : ""}
@@ -134,8 +138,28 @@ export function MembersPage(props: {
{ {
title: "手机号", title: "手机号",
dataIndex: "phone", dataIndex: "phone",
width: 120, width: 200,
render: (v: string) => v || <Typography.Text type="secondary"></Typography.Text>, render: (v: string, r: Member) => (
<Space size={4}>
{v ? (
<Typography.Text copyable>{v}</Typography.Text>
) : (
<Typography.Text type="secondary"></Typography.Text>
)}
<Button
type="link"
size="small"
style={{ padding: 0 }}
onClick={() => {
setPhoneTarget(r);
setPhoneValue(v || "");
setPhoneOpen(true);
}}
>
{v ? "更换" : "绑定"}
</Button>
</Space>
),
}, },
{ title: "显示名", dataIndex: "display_name", width: 120 }, { title: "显示名", dataIndex: "display_name", width: 120 },
{ {
@@ -204,6 +228,54 @@ export function MembersPage(props: {
]} ]}
/> />
<Modal
title={phoneTarget?.phone ? "更换手机号" : "绑定手机号"}
open={phoneOpen}
onCancel={() => {
setPhoneOpen(false);
setPhoneTarget(null);
}}
onOk={async () => {
if (!phoneTarget) return;
const trimmed = phoneValue.trim();
const ok = await confirmCompanyWrite(
modal,
session,
trimmed ? "绑定/更换成员手机号" : "解绑成员手机号",
`成员:${phoneTarget.display_name || phoneTarget.username}#${phoneTarget.user_id})→ ${trimmed || "(空)"}`
);
if (!ok) return;
setBusy(true);
try {
await updateMember(session, phoneTarget.user_id, { phone: trimmed });
setInfo(trimmed ? `已绑定 ${trimmed}` : "已解绑手机号");
setPhoneOpen(false);
setPhoneTarget(null);
await refresh();
} catch (e: any) {
message.error(e.message || String(e));
} finally {
setBusy(false);
}
}}
confirmLoading={busy}
destroyOnHidden
okText="保存"
>
<Typography.Paragraph type="secondary">
线{" "}
<Typography.Text code>13531041944</Typography.Text>
13531041945
</Typography.Paragraph>
<Input
value={phoneValue}
onChange={(e) => setPhoneValue(e.target.value)}
placeholder="例13531041944"
maxLength={20}
allowClear
/>
</Modal>
<Modal <Modal
title="新建成员" title="新建成员"
open={open} open={open}

View File

@@ -1557,7 +1557,7 @@ export async function bindPhone(
export async function updateMember( export async function updateMember(
session: Session, session: Session,
id: number, id: number,
body: { role?: string; org_unit_id?: number; status?: string } body: { role?: string; org_unit_id?: number; status?: string; phone?: string }
) { ) {
const res = await apiFetch(`/api/v1/admin/members/${id}`, { const res = await apiFetch(`/api/v1/admin/members/${id}`, {
method: "PUT", method: "PUT",