Files
koc-loop/app/users-page.tsx
ABAPPLO dd5b603149 feat: 项目总群自动建群+任务负责人后台视角+客户群活码进群+任务编辑
- 项目总群:≥3 人 appchat/create 自动建群(chatid=pgc+projectId),成员 diff 增删同步;DROP project_group_chats;推送改 appchat/send 群发、无群降级逐人
- 任务负责人:tasks.owner_userid(新建/编辑任务通讯录选人),task_set_owner 分配即预建 tgc 任务群发介绍,群主链接只归属负责人
- 任务编辑 task_update:基础字段+截图任务数量/关键词/说明,contents 调平只删 available 尾行
- 删除「一键推送」:task-one-click-push-service 整体移除(recordCardAck 移入 wecom-callback-events)
- 负责人后台视角:users.wecom_userid 绑定,lib/owner-scope 行级过滤(undefined=全量/""=空范围/非空=过滤),~30 action 补 managerGate/taskOwnerGate,owner 登录仅见工作台/任务中心/数据回收/复盘报表
- 任务绑定客户群+进群活码:tasks 增 customer_chat_id/name、join_config_id、join_qr_code 4 列,join_way add/update/get/del(换绑走 update 保 config_id 活码不变),后台「客户群」弹窗,portal 认领后扫码自助进群(满员自动开新群)
- 迁移 0013/0014/0015;催办支持按任务;企微可信域名 IP 验证文件
- 288 测试全绿;真机全链路验收通过(列群/活码四端点/portal 渲染/解绑清空)
2026-09-04 18:03:46 +08:00

422 lines
14 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { FormEvent, useCallback, useEffect, useState } from "react";
import type { AuthUser, UserRole } from "../lib/user-auth";
import { WecomUserPickerModal } from "./wecom-user-picker";
type ManagedUser = {
id: string;
username: string;
role: UserRole;
wecom_userid: string | null;
created_at: string;
updated_at: string;
};
const ROLE_LABEL: Record<UserRole, string> = {
super_admin: "超级管理员",
admin: "管理员",
user: "普通用户",
};
async function userApi(response: Response) {
const result = (await response.json()) as { users?: ManagedUser[]; error?: string };
if (!response.ok) throw new Error(result.error || "操作失败");
return result.users ?? [];
}
export default function UsersPage({ currentUser }: { currentUser: AuthUser }) {
const [users, setUsers] = useState<ManagedUser[]>([]);
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [role, setRole] = useState<"admin" | "user">("user");
const [resetTarget, setResetTarget] = useState<ManagedUser | null>(null);
const [resetPassword, setResetPassword] = useState("");
const [deleteTarget, setDeleteTarget] = useState<ManagedUser | null>(null);
const [createWecom, setCreateWecom] = useState("");
const [createPickerOpen, setCreatePickerOpen] = useState(false);
const [bindTarget, setBindTarget] = useState<ManagedUser | null>(null);
const [message, setMessage] = useState("");
const [working, setWorking] = useState(false);
const loadUsers = useCallback(async () => {
try {
setUsers(await userApi(await fetch("/api/users", { cache: "no-store" })));
} catch (reason) {
setMessage(reason instanceof Error ? reason.message : "加载失败");
}
}, []);
useEffect(() => {
const timer = window.setTimeout(() => {
void loadUsers();
}, 0);
return () => window.clearTimeout(timer);
}, [loadUsers]);
const createUser = async (event: FormEvent) => {
event.preventDefault();
setWorking(true);
setMessage("");
try {
const next = await userApi(
await fetch("/api/users", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
action: "create",
username,
password,
role,
wecomUserid: createWecom,
}),
}),
);
setUsers(next);
setUsername("");
setPassword("");
setRole("user");
setCreateWecom("");
setMessage("账号已创建,可以把账号和密码交给用户登录");
} catch (reason) {
setMessage(reason instanceof Error ? reason.message : "创建失败");
} finally {
setWorking(false);
}
};
const resetUserPassword = async (event: FormEvent) => {
event.preventDefault();
if (!resetTarget) return;
setWorking(true);
setMessage("");
try {
const next = await userApi(
await fetch("/api/users", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
action: "reset_password",
userId: resetTarget.id,
password: resetPassword,
}),
}),
);
setUsers(next);
setResetTarget(null);
setResetPassword("");
setMessage("密码已重置,旧登录状态已退出");
} catch (reason) {
setMessage(reason instanceof Error ? reason.message : "重置失败");
} finally {
setWorking(false);
}
};
const deleteUser = async () => {
if (!deleteTarget) return;
setWorking(true);
setMessage("");
try {
const next = await userApi(
await fetch("/api/users", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
action: "delete",
userId: deleteTarget.id,
}),
}),
);
setUsers(next);
setDeleteTarget(null);
setMessage("账号已删除,该账号所有登录设备均已退出");
} catch (reason) {
setMessage(reason instanceof Error ? reason.message : "删除失败");
} finally {
setWorking(false);
}
};
const bindWecom = async (userId: string, wecomUserid: string) => {
setWorking(true);
setMessage("");
try {
const next = await userApi(
await fetch("/api/users", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "set_wecom", userId, wecomUserid }),
}),
);
setUsers(next);
setBindTarget(null);
setMessage(wecomUserid ? "企微绑定已更新,立即生效" : "已解除企微绑定");
} catch (reason) {
setMessage(reason instanceof Error ? reason.message : "绑定失败");
} finally {
setWorking(false);
}
};
const canReset = (item: ManagedUser) => {
if (currentUser.role === "super_admin") {
return item.role !== "super_admin" || item.id === currentUser.id;
}
return item.role === "user";
};
const canBind = (item: ManagedUser) => {
if (item.role === "super_admin") return false;
return currentUser.role === "super_admin" || item.role === "user";
};
const canDelete = (item: ManagedUser) => {
if (item.id === currentUser.id || item.role === "super_admin") return false;
return currentUser.role === "super_admin" || item.role === "user";
};
return (
<div className="user-management-layout">
<section className="panel user-create-panel">
<div className="panel-heading">
<div>
<h2>新增登录账号</h2>
<p>账号密码由后台统一分配,不开放注册和个人改密。</p>
</div>
</div>
<form
className={`user-create-form ${currentUser.role === "super_admin" ? "with-role" : "without-role"}`}
onSubmit={createUser}
>
<label>
<span>登录账号</span>
<input
value={username}
onChange={(event) => setUsername(event.target.value)}
placeholder="例如:linlin01"
autoComplete="off"
required
/>
</label>
<label>
<span>初始密码</span>
<input
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
placeholder="至少8位"
autoComplete="new-password"
minLength={8}
required
/>
</label>
{currentUser.role === "super_admin" && (
<label className="user-role-field">
<span>角色</span>
<select value={role} onChange={(event) => setRole(event.target.value as "admin" | "user")}>
<option value="user">普通用户</option>
<option value="admin">管理员</option>
</select>
</label>
)}
<label className="user-role-field">
<span>企微 userid(任务负责人,可选)</span>
<input
value={createWecom}
onChange={(event) => setCreateWecom(event.target.value)}
placeholder="绑定后该账号只看自己负责的任务"
autoComplete="off"
/>
<button
type="button"
className="ghost-button"
onClick={() => setCreatePickerOpen(true)}
>
从企微选择
</button>
</label>
<button className="primary-button" disabled={working}>
{working ? "处理中…" : "创建账号"}
</button>
</form>
{message && <p className="user-management-message">{message}</p>}
</section>
<section className="panel user-list-panel">
<div className="panel-heading">
<div>
<h2>账号列表</h2>
<p>共 {users.length} 个后台账号</p>
</div>
</div>
<div className="user-table">
<div className="user-table-head">
<span>登录账号</span><span>角色</span><span>企微绑定</span><span>更新时间</span><span>操作</span>
</div>
{users.map((item) => (
<div className="user-table-row" key={item.id}>
<strong>{item.username}</strong>
<span className={`role-pill ${item.role}`}>{ROLE_LABEL[item.role]}</span>
<span className="user-wecom-cell">
{item.wecom_userid ? (
<code>{item.wecom_userid}</code>
) : (
<span>—</span>
)}
{canBind(item) && (
item.wecom_userid ? (
<>
<button
className="user-action-button"
onClick={() => setBindTarget(item)}
>
换绑
</button>
<button
className="user-action-button danger"
disabled={working}
onClick={() => void bindWecom(item.id, "")}
>
解绑
</button>
</>
) : (
<button
className="user-action-button"
onClick={() => setBindTarget(item)}
>
绑定
</button>
)
)}
</span>
<span>{new Date(item.updated_at).toLocaleDateString("zh-CN", { timeZone: "Asia/Shanghai" })}</span>
<div className="user-row-actions">
{canReset(item) && (
<button
className="user-action-button"
onClick={() => { setResetTarget(item); setResetPassword(""); }}
>
重置密码
</button>
)}
{canDelete(item) && (
<button
className="user-action-button danger"
onClick={() => setDeleteTarget(item)}
>
删除账号
</button>
)}
{!canReset(item) && !canDelete(item) && <span>—</span>}
</div>
</div>
))}
</div>
</section>
{resetTarget && (
<div className="modal-backdrop" role="presentation" onMouseDown={() => setResetTarget(null)}>
<form className="modal-card compact" onSubmit={resetUserPassword} onMouseDown={(event) => event.stopPropagation()}>
<div className="modal-heading">
<div><p className="eyebrow">后台改密</p><h2>重置 {resetTarget.username} 的密码</h2></div>
<button type="button" onClick={() => setResetTarget(null)} aria-label="关闭">×</button>
</div>
<label>
<span>新密码</span>
<input
type="password"
value={resetPassword}
onChange={(event) => setResetPassword(event.target.value)}
minLength={8}
autoComplete="new-password"
required
/>
<small>保存后,该账号已登录的设备会自动退出。</small>
</label>
<div className="modal-actions">
<button type="button" className="ghost-button" onClick={() => setResetTarget(null)}>取消</button>
<button className="primary-button" disabled={working}>确认重置</button>
</div>
</form>
</div>
)}
{deleteTarget && (
<div
className="modal-backdrop"
role="presentation"
onMouseDown={() => { if (!working) setDeleteTarget(null); }}
>
<div
className="modal-card compact user-delete-modal"
role="alertdialog"
aria-modal="true"
aria-labelledby="delete-user-title"
onMouseDown={(event) => event.stopPropagation()}
>
<div className="modal-heading">
<div>
<p className="eyebrow danger">删除账号</p>
<h2 id="delete-user-title">确认删除 {deleteTarget.username}?</h2>
</div>
<button
type="button"
onClick={() => setDeleteTarget(null)}
aria-label="关闭"
disabled={working}
>
×
</button>
</div>
<p className="user-delete-warning">
删除后该账号会立即退出所有设备,且无法再次登录。此操作不可撤销。
</p>
<div className="modal-actions">
<button
type="button"
className="ghost-button"
onClick={() => setDeleteTarget(null)}
disabled={working}
>
取消
</button>
<button
type="button"
className="danger-button"
onClick={() => void deleteUser()}
disabled={working}
>
{working ? "删除中…" : "确认删除"}
</button>
</div>
</div>
</div>
)}
{createPickerOpen && (
<WecomUserPickerModal
mode="single"
selected={createWecom ? [createWecom] : []}
onClose={() => setCreatePickerOpen(false)}
onConfirm={(userids) => {
setCreateWecom(userids[0] ?? "");
setCreatePickerOpen(false);
}}
/>
)}
{bindTarget && (
<WecomUserPickerModal
mode="single"
selected={bindTarget.wecom_userid ? [bindTarget.wecom_userid] : []}
onClose={() => setBindTarget(null)}
onConfirm={(userids) => {
void bindWecom(bindTarget.id, userids[0] ?? "");
}}
/>
)}
</div>
);
}