R84 R86 收口用户开通界面与自动化回归
这个提交包含在:
@@ -29,6 +29,119 @@
|
||||
- 数据库/分支:`hyb_oa_manual_trialrun_20260428172231 / 2bce9c9`
|
||||
- 执行人:`Codex Playwright 辅助复核`
|
||||
|
||||
## R79 公网部署态辅助复核(2026-05-04)
|
||||
|
||||
当前已核实部署基线:
|
||||
|
||||
- 公网地址:`https://oa.hyinda.com`
|
||||
- 服务器:`118.89.77.218`
|
||||
- 反向代理:`caddy`
|
||||
- SSH:常规 `deploy`,系统配置 `root`
|
||||
- 部署目录:`/opt/hyb-oa-trialrun`
|
||||
- 远端容器命令:`docker-compose`
|
||||
|
||||
Codex 可代做:
|
||||
|
||||
1. SSH 登录服务器做只读巡检。
|
||||
2. 执行 `deploy/trialrun/deploy.sh`、`/ready`、`smoke-external-deps.js`、`smoke-trialrun-runtime.js`。
|
||||
3. 查看 `Caddyfile`、容器状态、日志、feature flag 与工作区配置。
|
||||
4. 在你提供账号后执行 `smoke-live` 和 Playwright 公网只读复核。
|
||||
|
||||
必须人工配合:
|
||||
|
||||
1. 企业微信后台配置工作台入口与 OAuth 回调。
|
||||
2. 提供独立 smoke 账号凭据,不使用真实业务账号。
|
||||
3. 若需要真实企微绑定、手机扫码、二次验证,必须由你本人完成。
|
||||
|
||||
最小人工操作清单:
|
||||
|
||||
1. 企微后台配置:
|
||||
- 工作台入口 URL:`https://oa.hyinda.com/api/auth/wecom/login?returnTo=%2Fportal`
|
||||
- OAuth 回调 URL:`https://oa.hyinda.com/api/auth/wecom/callback`
|
||||
2. 提供至少两组公网只读 smoke 账号:
|
||||
- `smoke-admin`
|
||||
- `smoke-employee`
|
||||
3. 若公网运行时 smoke 继续提示公告或搜索缺失,用管理员账号进入“功能开关”页,核对:
|
||||
- `portal_home_v1`
|
||||
- `announcements_v1`
|
||||
- `global_search_v1`
|
||||
- `wecom_entry_v1`
|
||||
4. 若你让我继续代跑公网 smoke,只需要把账号标识和密码给我;我会继续完成自动化部分。
|
||||
|
||||
## R80 企微公网启用回填(2026-05-04)
|
||||
|
||||
- 企业微信域名校验文件已可访问:
|
||||
- `https://oa.hyinda.com/WW_verify_StBYfdlboMaIoRRE.txt`
|
||||
- 公网企微入口运行时已启用:
|
||||
- 远端 `.env`:`WECOM_ENABLED=true`、`WECOM_ENTRY_ENABLED=true`、`WECOM_OAUTH_ENABLED=true`
|
||||
- 数据库 `feature_flags`:`wecom_entry_v1=inherit`
|
||||
- 自动化核验已完成:
|
||||
- `GET https://oa.hyinda.com/api/auth/wecom/config` 返回 `status=ready`
|
||||
- `GET https://oa.hyinda.com/api/auth/wecom/login?returnTo=%2Fportal` 返回 `302`,并跳转到 `open.weixin.qq.com`
|
||||
- Playwright 公网登录页快照已确认出现“使用企业微信进入工作台”按钮
|
||||
- Playwright 点击后,桌面浏览器落到企微提示页“请在企业微信客户端打开链接”,符合预期
|
||||
- 截图:`output/playwright/2026-05-04-wecom-login-page.png`
|
||||
|
||||
你现在必须人工完成的动作:
|
||||
|
||||
1. 用手机企业微信或桌面企业微信打开该应用,不要用普通浏览器代替。
|
||||
2. 若首次进入后回到 OA 登录页并带 `wecom=unbound`:
|
||||
- 用已有 OA 账号密码登录
|
||||
- 进入“个人设置”
|
||||
- 执行企微绑定
|
||||
- 绑定成功后,再次从企业微信工作台进入 OA
|
||||
3. 首次人工闭环时,建议只用 1 个员工测试账号,不要直接用高权限真实业务账号。
|
||||
4. 若你完成首次绑定后要我继续代跑公网 smoke,再把该 smoke 账号标识告诉我,我继续补 Playwright / curl 只读核验。
|
||||
|
||||
## R81 企微回调身份取证(2026-05-04)
|
||||
|
||||
- 当前线上已追加最小安全诊断:
|
||||
- 企微回调若缺少 `UserId`,server 会记录:
|
||||
- `requestId`
|
||||
- `mode`
|
||||
- `hasUserId`
|
||||
- `hasOpenId`
|
||||
- `hasDeviceId`
|
||||
- 不记录 token、secret、完整敏感回包。
|
||||
- 当前服务器复核结果:
|
||||
- `https://oa.hyinda.com/ready`:正常
|
||||
- `https://oa.hyinda.com/api/auth/wecom/config`:`status=ready`
|
||||
- `https://oa.hyinda.com/api/auth/wecom/login?returnTo=%2Fportal`:仍返回 `302` 到企微 OAuth
|
||||
|
||||
你现在需要做的人工操作:
|
||||
|
||||
1. 在手机企业微信里进入工作台,打开 OA 应用一次。
|
||||
2. 如果仍然失败,不要反复点,先停在错误页。
|
||||
3. 记录以下任一信息发给我:
|
||||
- 页面里的 `requestId`
|
||||
- 错误截图
|
||||
- 大致时间点(精确到分钟)
|
||||
4. 如果页面重新跳回登录页,也把最终 URL 或截图发给我。
|
||||
|
||||
我收到后会立刻去服务器抓对应日志,判断腾讯这次回包到底是:
|
||||
|
||||
- 有 `OpenId` 但没有 `UserId`
|
||||
- 还是两个字段都没有
|
||||
- 还是另外一种接口异常
|
||||
|
||||
## R83-R86 用户开通与首次绑定收口(2026-05-04)
|
||||
|
||||
- 本轮新增自动化覆盖:
|
||||
- `client/tests/e2e/user-onboarding.spec.ts`
|
||||
- 管理员在“系统用户管理”中创建用户时,不再手填工号和初始密码。
|
||||
- 创建成功后,页面展示系统生成的员工号与一次性口令。
|
||||
- 新用户首次登录后,被强制跳转到“修改密码”页,改密成功再进入 Portal。
|
||||
- `client/tests/e2e/wecom-first-bind.spec.ts`
|
||||
- 企业微信未绑定场景回退到密码登录页后,用户仍可继续进入目标页。
|
||||
- 当前代码口径:
|
||||
- 单个新建用户:系统自动生成员工号,管理员只维护基础资料与角色。
|
||||
- 首次登录:使用一次性口令登录,必须先改密。
|
||||
- 企微首次绑定:服务端已支持“未绑定回退登录后自动补绑定”的闭环,不再要求用户必须手动去“个人设置”补绑定。
|
||||
- 仍需人工完成的最小动作:
|
||||
1. 把本轮代码部署到目标环境。
|
||||
2. 用真实手机企业微信打开 OA 应用一次,确认真实 OAuth 回调、cookie 与企业身份都正常。
|
||||
3. 若仍失败,记录 `requestId`、错误截图和时间点给我,我继续抓线上日志定位。
|
||||
|
||||
## 员工路径
|
||||
|
||||
账号:员工测试账号。
|
||||
|
||||
@@ -117,3 +117,116 @@ R70 初次试运行暂停项已在 2026-04-28 的 R71 定向修复后关闭。
|
||||
- `paymentBatches` 复核路由仍拦截 maker 自审;低风险批次维持 `finance_mgr` 复核、`finance` 执行的边界。
|
||||
- 失败路径单测确认不会错误归档已绑定发票,也不会误入 ERP / 通知 follow-up。
|
||||
- `PaymentBatchesPage` 与 `ApOperationsPage` 没有新增越权入口,按钮仍按 `payment.batch.manage` 与 `risk.review.manage` 分离。
|
||||
|
||||
## R79 公网部署只读巡检
|
||||
|
||||
- 巡检时间:`2026-05-04 13:04 - 13:16 CST`
|
||||
- 公网地址:`https://oa.hyinda.com`
|
||||
- 服务器:`118.89.77.218`
|
||||
- SSH 结论:`deploy` / `root` 可免密登录;`shunli` 当前不可免密登录。
|
||||
- 反向代理:`caddy` active,`/etc/caddy/Caddyfile` 已包含 `oa.hyinda.com -> reverse_proxy 127.0.0.1:3000`。
|
||||
- 部署目录:`/opt/hyb-oa-trialrun`
|
||||
- 部署形态:远端目录不是 git 工作树;当前推荐从本地执行 `deploy/trialrun/deploy.sh`,通过 `rsync` 同步。
|
||||
- 容器编排:服务器当前使用 `docker-compose 1.29.2`,不是 `docker compose`。
|
||||
- 公网探活:
|
||||
- `https://oa.hyinda.com/ready`:`HTTP/2 200`
|
||||
- `https://oa.hyinda.com`:`HTTP/2 200`
|
||||
- 当前公网 `.env`:
|
||||
- `APP_ORIGIN=https://oa.hyinda.com`
|
||||
- `COOKIE_SECURE=true`
|
||||
- `PUBLIC_SECURITY_HARDENING_ENABLED=true`
|
||||
- `WECOM_ENABLED=false`
|
||||
- `WECOM_ENTRY_ENABLED=false`
|
||||
- `WECOM_OAUTH_ENABLED=false`
|
||||
- `OCR_PROVIDER=mock`
|
||||
- 当前公网数据库只读核查:
|
||||
- `workspace_settings.profile = small_team_internal`
|
||||
- `portal_home_v1 = force_on`
|
||||
- `announcements_v1 = force_off`
|
||||
- `global_search_v1 = force_off`
|
||||
- `wecom_entry_v1 = force_off`
|
||||
- 远端 smoke 结果:
|
||||
- `smoke-external-deps.js`:通过,但其通过前提是“企微入口未启用 + OCR 使用 mock provider”。
|
||||
- `smoke-trialrun-runtime.js`:失败,当前阻塞点为 `announcements_v1 => enabled=false reason=flag_force_off envEnabled=true`。
|
||||
- 结论:
|
||||
- 公网 HTTPS 与容器服务本身已通。
|
||||
- 当前还不能把公网部署态视为“外部试运行闭环完成”。
|
||||
- 下一步最小阻塞清单是:独立 smoke 账号、企微后台配置、以及公网 feature flag 基线修正后重跑 live smoke。
|
||||
|
||||
## R80 企微公网启用与只读验收
|
||||
|
||||
- 执行时间:`2026-05-04 16:47 - 17:03 CST`
|
||||
- 服务器动作:
|
||||
- 已把企微校验文件挂到 `https://oa.hyinda.com/WW_verify_StBYfdlboMaIoRRE.txt`
|
||||
- 已在远端 `/opt/hyb-oa-trialrun/deploy/trialrun/.env` 启用:
|
||||
- `WECOM_ENABLED=true`
|
||||
- `WECOM_ENTRY_ENABLED=true`
|
||||
- `WECOM_OAUTH_ENABLED=true`
|
||||
- `WECOM_CORP_ID` / `WECOM_AGENT_ID` / `WECOM_AGENT_SECRET`
|
||||
- `WECOM_REDIRECT_URI=https://oa.hyinda.com/api/auth/wecom/callback`
|
||||
- `WECOM_ALLOWED_RETURN_HOSTS=oa.hyinda.com`
|
||||
- 已把数据库 `wecom_entry_v1` 从 `force_off` 调整为 `inherit`
|
||||
- 已使用 `docker-compose -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/.env up -d --no-deps --force-recreate server` 重建公网 server 容器
|
||||
- curl 验证结果:
|
||||
- `GET /api/auth/wecom/config`:`status=ready`、`entryEnabled=true`、`oauthEnabled=true`
|
||||
- `GET /api/auth/wecom/login?returnTo=%2Fportal`:`HTTP 302`
|
||||
- `Location` 已包含:
|
||||
- `appid=ww6b98ab55e5e8d341`
|
||||
- `agentid=1000002`
|
||||
- `redirect_uri=https://oa.hyinda.com/api/auth/wecom/callback`
|
||||
- Playwright 只读验证:
|
||||
- 登录页已展示“使用企业微信进入工作台”
|
||||
- 浏览器实际请求 `/api/auth/wecom/config` 返回 `200`
|
||||
- 点击按钮后,页面跳转到 `open.weixin.qq.com/...#wechat_redirect`
|
||||
- 桌面浏览器页面提示“请在企业微信客户端打开链接”,符合企微 OAuth 预期
|
||||
- 截图产物:`output/playwright/2026-05-04-wecom-login-page.png`
|
||||
- 当前剩余人工动作:
|
||||
- 必须由你本人使用真实企业微信客户端完成首次进入
|
||||
- 若首次回到 `/login?wecom=unbound`,需先用 OA 账号密码登录并在“个人设置”完成绑定
|
||||
- 绑定成功后,再次从企业微信工作台进入 OA,才算真实企微登录闭环完成
|
||||
- 当前结论:
|
||||
- 服务器侧企微入口、OAuth 跳转、域名校验文件、HTTPS 回调地址均已就绪
|
||||
- 自动化已压到浏览器跳转层;剩余未完成项只剩“真实企微客户端首次绑定闭环”
|
||||
|
||||
## R81 企微回调身份诊断部署
|
||||
|
||||
- 时间:`2026-05-04 17:50 - 17:56 CST`
|
||||
- 背景:
|
||||
- 真实手机企业微信进入 OA 时,服务端收到 `/api/auth/wecom/callback` 回调
|
||||
- 当前失败表现为:`AUTH_003 / missing_user_id`
|
||||
- 你已确认当前企微账号是企业内部成员
|
||||
- 本轮动作:
|
||||
- 为 `missing_user_id` 分支补充最小安全诊断日志
|
||||
- 补 1 条单测覆盖“仅有 OpenId、没有 UserId”场景
|
||||
- 本地校验通过后,已重建公网 `server` 容器
|
||||
- 本地验证结果:
|
||||
- `cd server && node --check src/routes/wecomAuth.js`:通过
|
||||
- `cd server && npm test -- tests/unit/routes/wecomAuth.test.js`:`7 passed`
|
||||
- `cd server && npm run prisma:validate`:通过
|
||||
- 远端验证结果:
|
||||
- `https://oa.hyinda.com/ready`:正常
|
||||
- `https://oa.hyinda.com/api/auth/wecom/config`:`status=ready`
|
||||
- `https://oa.hyinda.com/api/auth/wecom/login?returnTo=%2Fportal`:`302` 到企业微信 OAuth
|
||||
- 当前状态:
|
||||
- 服务器已具备取证能力
|
||||
- 待你用手机企业微信再复现 1 次,我再根据日志判断下一步应是“错误提示优化”还是“绑定模型调整”
|
||||
|
||||
## R83-R86 用户开通与首次绑定收口
|
||||
|
||||
- 时间:`2026-05-04 23:36 - 23:58 CST`
|
||||
- 本轮本地实现:
|
||||
- 单个新建用户改为系统自动生成员工号,不再要求管理员手工输入工号。
|
||||
- 单个新建用户改为后端生成一次性口令,并在成功态只展示一次。
|
||||
- 新用户首次登录后继续沿用强制改密闭环。
|
||||
- 企业微信未绑定回退登录时,服务端已保留短时待绑定上下文,并在账号密码登录成功后自动补绑定。
|
||||
- 本轮新增自动化结果:
|
||||
- `cd server && npm test -- tests/unit/routes/auth.login.test.js tests/unit/routes/wecomAuth.test.js tests/unit/routes/users.write.test.js`
|
||||
- `11 passed`
|
||||
- `cd client && E2E_BASE_URL=http://127.0.0.1:3100 npx playwright test tests/e2e/user-onboarding.spec.ts tests/e2e/wecom-first-bind.spec.ts --workers=1 --reporter=list`
|
||||
- `3 passed`
|
||||
- 自动化已验证链路:
|
||||
- 管理员创建用户 -> 系统返回员工号与一次性口令
|
||||
- 新用户首次登录 -> 强制修改密码 -> 进入 Portal
|
||||
- 企业微信未绑定 -> 回退密码登录 -> 继续进入目标页
|
||||
- 仍需真实人工复核:
|
||||
- 将本轮代码部署到目标环境后,用真实手机企业微信完成 1 次首次进入,确认真实 OAuth 回调、cookie 与企业身份环境下也能自动完成首次绑定闭环。
|
||||
|
||||
+1
-3
@@ -88,16 +88,14 @@ export async function fetchUsers(params: {
|
||||
}
|
||||
|
||||
export async function createUser(payload: {
|
||||
employeeNo: string;
|
||||
name: string;
|
||||
phone: string;
|
||||
email: string;
|
||||
password: string;
|
||||
departmentId?: string;
|
||||
directManagerId?: string | null;
|
||||
roles: string[];
|
||||
}) {
|
||||
return apiRequestData<UserSummary>("/api/users", {
|
||||
return apiRequestData<UserSummary & { oneTimePassword: string }>("/api/users", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
@@ -110,8 +110,8 @@ export function LoginPage() {
|
||||
<h2 className="mt-2 text-xl font-semibold tracking-tight text-slate-900">使用账号密码登录</h2>
|
||||
<p className="mt-2 text-sm leading-6 text-slate-600">
|
||||
{primary
|
||||
? "适合桌面浏览器直接登录;若企业微信入口暂未开放,也可正常使用。"
|
||||
: "企业微信免登失败、未绑定或超时时,可改用账号密码继续进入。"}
|
||||
? "适合桌面浏览器直接登录;若是首次开通,也可使用管理员转交的一次性口令进入。"
|
||||
: "企业微信免登失败、未绑定或超时时,可改用账号密码或一次性口令继续进入。"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -135,7 +135,7 @@ export function LoginPage() {
|
||||
data-testid="login-password"
|
||||
id="login-password"
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
placeholder="请输入密码"
|
||||
placeholder="请输入密码或一次性口令"
|
||||
type="password"
|
||||
value={password}
|
||||
/>
|
||||
|
||||
@@ -112,7 +112,7 @@ export function ProfilePage() {
|
||||
<dd className="mt-1 font-medium text-zinc-900">{me?.name ?? "-"}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-zinc-500">员工号</dt>
|
||||
<dt className="text-zinc-500">员工号(系统生成)</dt>
|
||||
<dd className="mt-1 font-medium text-zinc-900">{me?.employeeNo ?? "-"}</dd>
|
||||
</div>
|
||||
<div>
|
||||
|
||||
@@ -49,7 +49,12 @@ export function UserManagementPage() {
|
||||
const [q, setQ] = useState("");
|
||||
const [page, setPage] = useState(1);
|
||||
const [error, setError] = useState("");
|
||||
const [resetResult, setResetResult] = useState<{ userId: string; password: string } | null>(null);
|
||||
const [credentialResult, setCredentialResult] = useState<{
|
||||
mode: "create" | "reset";
|
||||
userId: string;
|
||||
employeeNo?: string;
|
||||
password: string;
|
||||
} | null>(null);
|
||||
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [importOpen, setImportOpen] = useState(false);
|
||||
@@ -74,10 +79,16 @@ export function UserManagementPage() {
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (payload: CreateUserFormData) => createUser(payload),
|
||||
onSuccess: async () => {
|
||||
onSuccess: async (data) => {
|
||||
await queryClient.invalidateQueries({ queryKey: ["users"] });
|
||||
setCreateOpen(false);
|
||||
setError("");
|
||||
setCredentialResult({
|
||||
mode: "create",
|
||||
userId: data.id,
|
||||
employeeNo: data.employeeNo,
|
||||
password: data.oneTimePassword,
|
||||
});
|
||||
toast.success("用户已创建");
|
||||
},
|
||||
onError: (err) => {
|
||||
@@ -109,7 +120,7 @@ export function UserManagementPage() {
|
||||
const resetMutation = useMutation({
|
||||
mutationFn: async (item: UserSummary) => resetUserPassword(item.id),
|
||||
onSuccess: async (data) => {
|
||||
setResetResult({ userId: data.id, password: data.oneTimePassword });
|
||||
setCredentialResult({ mode: "reset", userId: data.id, password: data.oneTimePassword });
|
||||
await queryClient.invalidateQueries({ queryKey: ["users"] });
|
||||
setError("");
|
||||
toast.success("密码已重置");
|
||||
@@ -142,7 +153,7 @@ export function UserManagementPage() {
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="系统用户管理"
|
||||
subtitle="创建/启用/禁用账号,重置密码并分配角色(仅试运行最小运营)。"
|
||||
subtitle="创建/启用/禁用账号,系统自动生成工号并展示一次性口令,管理员只负责最小开通与角色分配。"
|
||||
actions={
|
||||
<div className="flex gap-2">
|
||||
<UserImportDialog
|
||||
@@ -184,9 +195,21 @@ export function UserManagementPage() {
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{resetResult ? (
|
||||
{credentialResult ? (
|
||||
<p className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-800" data-testid="users-reset-result">
|
||||
已重置密码(user={resetResult.userId}),一次性口令:<span className="font-mono">{resetResult.password}</span>
|
||||
{credentialResult.mode === "create"
|
||||
? (
|
||||
<>
|
||||
已创建用户(user={credentialResult.userId}
|
||||
{credentialResult.employeeNo ? `,工号=${credentialResult.employeeNo}` : ""}
|
||||
),一次性口令:<span className="font-mono">{credentialResult.password}</span>
|
||||
</>
|
||||
)
|
||||
: (
|
||||
<>
|
||||
已重置密码(user={credentialResult.userId}),一次性口令:<span className="font-mono">{credentialResult.password}</span>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
|
||||
@@ -12,11 +12,9 @@ import { Input } from "../../components/ui/input";
|
||||
import { Label } from "../../components/ui/label";
|
||||
|
||||
export interface CreateUserFormData {
|
||||
employeeNo: string;
|
||||
name: string;
|
||||
phone: string;
|
||||
email: string;
|
||||
password: string;
|
||||
departmentId: string | undefined;
|
||||
directManagerId: string | null;
|
||||
roles: string[];
|
||||
@@ -48,16 +46,13 @@ export function CreateUserDialog({
|
||||
managers,
|
||||
roleOptions,
|
||||
}: CreateUserDialogProps) {
|
||||
const [employeeNo, setEmployeeNo] = useState("");
|
||||
const [name, setName] = useState("");
|
||||
const [phone, setPhone] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [departmentId, setDepartmentId] = useState("");
|
||||
const [directManagerId, setDirectManagerId] = useState("");
|
||||
const [createRoles, setCreateRoles] = useState<Set<string>>(() => new Set(["employee"]));
|
||||
|
||||
const trimmedEmployeeNo = employeeNo.trim();
|
||||
const trimmedName = name.trim();
|
||||
const trimmedPhone = phone.trim();
|
||||
const trimmedEmail = email.trim();
|
||||
@@ -65,19 +60,15 @@ export function CreateUserDialog({
|
||||
|
||||
const canSubmit =
|
||||
!isPending &&
|
||||
trimmedEmployeeNo !== "" &&
|
||||
trimmedName !== "" &&
|
||||
trimmedPhone !== "" &&
|
||||
trimmedEmail !== "" &&
|
||||
password !== "" &&
|
||||
rolesArray.length >= 1;
|
||||
|
||||
function resetForm() {
|
||||
setEmployeeNo("");
|
||||
setName("");
|
||||
setPhone("");
|
||||
setEmail("");
|
||||
setPassword("");
|
||||
setDepartmentId("");
|
||||
setDirectManagerId("");
|
||||
setCreateRoles(new Set(["employee"]));
|
||||
@@ -92,11 +83,9 @@ export function CreateUserDialog({
|
||||
|
||||
function handleSubmit() {
|
||||
onSubmit({
|
||||
employeeNo: trimmedEmployeeNo,
|
||||
name: trimmedName,
|
||||
phone: trimmedPhone,
|
||||
email: trimmedEmail,
|
||||
password,
|
||||
departmentId: departmentId || undefined,
|
||||
directManagerId: directManagerId ? directManagerId : null,
|
||||
roles: rolesArray,
|
||||
@@ -113,20 +102,10 @@ export function CreateUserDialog({
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>新建用户</DialogTitle>
|
||||
<DialogDescription>请填写基础信息与初始密码(满足密码策略)。</DialogDescription>
|
||||
<DialogDescription>请填写基础信息。工号与一次性口令将在创建成功后由系统生成并展示一次。</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="employeeNo">工号</Label>
|
||||
<Input
|
||||
id="employeeNo"
|
||||
data-testid="users-create-employeeNo"
|
||||
value={employeeNo}
|
||||
onChange={(e) => setEmployeeNo(e.target.value)}
|
||||
placeholder="E1001"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="name">姓名</Label>
|
||||
<Input
|
||||
@@ -157,17 +136,6 @@ export function CreateUserDialog({
|
||||
placeholder="[email protected]"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="password">初始密码</Label>
|
||||
<Input
|
||||
id="password"
|
||||
data-testid="users-create-password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="至少12位,含大小写与数字"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="departmentId">部门(可选)</Label>
|
||||
|
||||
@@ -0,0 +1,300 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
function envelope(data: unknown) {
|
||||
return {
|
||||
requestId: "mock-request",
|
||||
data,
|
||||
};
|
||||
}
|
||||
|
||||
function mockCommonAuth(page: import("@playwright/test").Page, options: {
|
||||
mustChangePassword?: boolean;
|
||||
meName?: string;
|
||||
meEmployeeNo?: string;
|
||||
}) {
|
||||
const mustChangePassword = options.mustChangePassword === true;
|
||||
const meName = options.meName ?? "Admin";
|
||||
const meEmployeeNo = options.meEmployeeNo ?? "HYD000001";
|
||||
|
||||
return Promise.all([
|
||||
page.route("**/api/auth/sso/providers", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(envelope([])),
|
||||
});
|
||||
}),
|
||||
page.route("**/api/auth/wecom/config", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(
|
||||
envelope({
|
||||
enabled: false,
|
||||
entryEnabled: false,
|
||||
oauthEnabled: false,
|
||||
corpId: "",
|
||||
agentId: "",
|
||||
loginUrl: null,
|
||||
bindEnabled: false,
|
||||
defaultReturnTo: "/portal",
|
||||
status: "disabled",
|
||||
statusReasons: ["disabled"],
|
||||
}),
|
||||
),
|
||||
});
|
||||
}),
|
||||
page.route("**/api/auth/login", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(
|
||||
envelope({
|
||||
accessToken: "test-access-token",
|
||||
expiresIn: 900,
|
||||
mustChangePassword,
|
||||
wecomBindingStatus: null,
|
||||
}),
|
||||
),
|
||||
});
|
||||
}),
|
||||
page.route("**/api/me", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(
|
||||
envelope({
|
||||
id: "employee-admin-1",
|
||||
employeeNo: meEmployeeNo,
|
||||
name: meName,
|
||||
phone: "13800000000",
|
||||
email: "[email protected]",
|
||||
departmentId: "dept-1",
|
||||
directManagerId: null,
|
||||
status: "active",
|
||||
notificationPushEnabled: true,
|
||||
roleCodes: ["admin"],
|
||||
permissions: ["system.users"],
|
||||
profileCompleteness: {
|
||||
hasEmergencyContact: true,
|
||||
hasBankAccount: true,
|
||||
hasAvatar: false,
|
||||
hasIdNumber: false,
|
||||
score: 50,
|
||||
},
|
||||
wecomBinding: null,
|
||||
}),
|
||||
),
|
||||
});
|
||||
}),
|
||||
page.route("**/api/system/navigation", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(
|
||||
envelope({
|
||||
workspace: {
|
||||
id: "workspace-1",
|
||||
persisted: true,
|
||||
profile: "small_team_internal",
|
||||
orgMode: "single_company",
|
||||
primaryEntryMode: "password",
|
||||
notificationStrategy: "in_app",
|
||||
devicePriority: "desktop",
|
||||
moduleVisibility: {},
|
||||
createdAt: null,
|
||||
updatedAt: null,
|
||||
updatedBy: null,
|
||||
},
|
||||
sections: {
|
||||
main: [{ key: "portal", href: "/portal", label: "Portal", icon: "home_app_logo", placement: "main", permissionCodes: [], featureFlagKey: null }],
|
||||
admin: [{ key: "users", href: "/system/users", label: "系统用户", icon: "manage_accounts", placement: "admin", permissionCodes: ["system.users"], featureFlagKey: null }],
|
||||
backup: [],
|
||||
},
|
||||
recommendedRolePresets: [],
|
||||
}),
|
||||
),
|
||||
});
|
||||
}),
|
||||
page.route((url) => url.pathname === "/api/notifications", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(
|
||||
envelope({
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
total: 0,
|
||||
unreadOnly: true,
|
||||
unreadCount: 0,
|
||||
items: [],
|
||||
}),
|
||||
),
|
||||
});
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
test("E2E 用户开通:新建用户时由系统生成工号和一次性口令", async ({ page }) => {
|
||||
let createPayload: Record<string, unknown> | null = null;
|
||||
|
||||
await mockCommonAuth(page, {});
|
||||
await page.route("**/api/users?**", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(
|
||||
envelope({
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
total: 0,
|
||||
items: [],
|
||||
}),
|
||||
),
|
||||
});
|
||||
});
|
||||
await page.route("**/api/departments?includeArchived=0", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(
|
||||
envelope({
|
||||
includeArchived: false,
|
||||
items: [{ id: "dept-1", name: "行政部", parentId: null, archivedAt: null, createdAt: null, updatedAt: null }],
|
||||
tree: [],
|
||||
}),
|
||||
),
|
||||
});
|
||||
});
|
||||
await page.route("**/api/employees?page=1&pageSize=200&status=active", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(
|
||||
envelope({
|
||||
page: 1,
|
||||
pageSize: 200,
|
||||
total: 0,
|
||||
items: [],
|
||||
}),
|
||||
),
|
||||
});
|
||||
});
|
||||
await page.route("**/api/users", async (route) => {
|
||||
if (route.request().method() !== "POST") {
|
||||
await route.fallback();
|
||||
return;
|
||||
}
|
||||
createPayload = route.request().postDataJSON() as Record<string, unknown>;
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(
|
||||
envelope({
|
||||
id: "user-1",
|
||||
employeeNo: "HYD000123",
|
||||
name: "张三",
|
||||
phone: "13800000001",
|
||||
email: "[email protected]",
|
||||
status: "active",
|
||||
departmentId: "dept-1",
|
||||
title: "员工",
|
||||
roleCodes: ["employee"],
|
||||
createdAt: "2026-05-04T12:00:00.000Z",
|
||||
updatedAt: "2026-05-04T12:00:00.000Z",
|
||||
oneTimePassword: "ResetA1abc123def456",
|
||||
}),
|
||||
),
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto("/login?redirect=%2Fsystem%2Fusers");
|
||||
await page.getByTestId("login-identifier").fill("[email protected]");
|
||||
await page.getByTestId("login-password").fill("AdminPassword1234");
|
||||
await page.getByTestId("login-submit").click();
|
||||
await expect(page).toHaveURL(/\/system\/users/);
|
||||
|
||||
await page.getByTestId("users-create-open").click();
|
||||
await expect(page.getByTestId("users-create-employeeNo")).toHaveCount(0);
|
||||
await expect(page.getByTestId("users-create-password")).toHaveCount(0);
|
||||
await page.getByTestId("users-create-name").fill("张三");
|
||||
await page.getByTestId("users-create-phone").fill("13800000001");
|
||||
await page.getByTestId("users-create-email").fill("[email protected]");
|
||||
await page.getByTestId("users-create-submit").click();
|
||||
|
||||
expect(createPayload).toEqual({
|
||||
name: "张三",
|
||||
phone: "13800000001",
|
||||
email: "[email protected]",
|
||||
departmentId: undefined,
|
||||
directManagerId: null,
|
||||
roles: ["employee"],
|
||||
});
|
||||
await expect(page.getByTestId("users-reset-result")).toContainText("工号=HYD000123");
|
||||
await expect(page.getByTestId("users-reset-result")).toContainText("ResetA1abc123def456");
|
||||
});
|
||||
|
||||
test("E2E 首次登录:一次性口令登录后强制改密", async ({ page }) => {
|
||||
let changePasswordPayload: Record<string, unknown> | null = null;
|
||||
|
||||
await mockCommonAuth(page, { mustChangePassword: true, meName: "新员工", meEmployeeNo: "HYD000123" });
|
||||
await page.route("**/api/auth/change-password", async (route) => {
|
||||
changePasswordPayload = route.request().postDataJSON() as Record<string, unknown>;
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(
|
||||
envelope({
|
||||
accessToken: "post-change-token",
|
||||
expiresIn: 900,
|
||||
mustChangePassword: false,
|
||||
}),
|
||||
),
|
||||
});
|
||||
});
|
||||
await page.route("**/api/portal/home", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(
|
||||
envelope({
|
||||
workspace: { id: "workspace-1", persisted: true, profile: "small_team_internal" },
|
||||
greeting: { name: "新员工" },
|
||||
cards: [],
|
||||
pendingTasks: { count: 0, href: "/workflow/tasks" },
|
||||
myApplications: [],
|
||||
riskSummary: null,
|
||||
announcements: [],
|
||||
quickLinks: [],
|
||||
todayPresence: null,
|
||||
teamPresence: null,
|
||||
weeklyReport: null,
|
||||
weeklySummary: null,
|
||||
todoSummary: { openCount: 0, highPriorityCount: 0, href: "/todos" },
|
||||
projectSummary: { myCount: 0, overdueMilestonesCount: 0, highPriorityTaskCount: 0, openRiskCount: 0, followUpDueTodayCount: 0, closurePendingProjectCount: 0, href: "/projects" },
|
||||
peopleLifecycle: null,
|
||||
adoptionSummary: null,
|
||||
recentActivities: [],
|
||||
teamStatusBoard: [],
|
||||
}),
|
||||
),
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto("/login");
|
||||
await page.getByTestId("login-identifier").fill("[email protected]");
|
||||
await page.getByTestId("login-password").fill("ResetA1abc123def456");
|
||||
await page.getByTestId("login-submit").click();
|
||||
await expect(page).toHaveURL(/\/change-password/);
|
||||
|
||||
await page.getByTestId("change-password-old").fill("ResetA1abc123def456");
|
||||
await page.getByTestId("change-password-new").fill("NewPassword1234");
|
||||
await page.getByLabel("确认新密码").fill("NewPassword1234");
|
||||
await page.getByTestId("change-password-submit").click();
|
||||
|
||||
expect(changePasswordPayload).toEqual({
|
||||
oldPassword: "ResetA1abc123def456",
|
||||
newPassword: "NewPassword1234",
|
||||
});
|
||||
await expect(page).toHaveURL(/\/portal/);
|
||||
});
|
||||
@@ -0,0 +1,171 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
function envelope(data: unknown) {
|
||||
return {
|
||||
requestId: "mock-request",
|
||||
data,
|
||||
};
|
||||
}
|
||||
|
||||
test("E2E 企微首次绑定:未绑定回退到密码登录后可继续进入目标页", async ({ page }) => {
|
||||
await page.addInitScript(() => {
|
||||
Object.defineProperty(window.navigator, "userAgent", {
|
||||
configurable: true,
|
||||
value: "Mozilla/5.0 wxwork/4.1.20 MicroMessenger/7.0.1",
|
||||
});
|
||||
});
|
||||
|
||||
await page.route("**/api/auth/sso/providers", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(envelope([])),
|
||||
});
|
||||
});
|
||||
await page.route("**/api/auth/wecom/config", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(
|
||||
envelope({
|
||||
enabled: true,
|
||||
entryEnabled: true,
|
||||
oauthEnabled: true,
|
||||
corpId: "wx-test-corp",
|
||||
agentId: "1000001",
|
||||
loginUrl: "/api/auth/wecom/login?returnTo=%2Fportal",
|
||||
bindEnabled: true,
|
||||
defaultReturnTo: "/portal",
|
||||
status: "ready",
|
||||
statusReasons: [],
|
||||
}),
|
||||
),
|
||||
});
|
||||
});
|
||||
await page.route("**/api/auth/login", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(
|
||||
envelope({
|
||||
accessToken: "wecom-fallback-token",
|
||||
expiresIn: 900,
|
||||
mustChangePassword: false,
|
||||
wecomBindingStatus: "bound",
|
||||
}),
|
||||
),
|
||||
});
|
||||
});
|
||||
await page.route("**/api/me", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(
|
||||
envelope({
|
||||
id: "employee-1",
|
||||
employeeNo: "HYD000123",
|
||||
name: "张三",
|
||||
phone: "13800000001",
|
||||
email: "[email protected]",
|
||||
departmentId: "dept-1",
|
||||
directManagerId: null,
|
||||
status: "active",
|
||||
notificationPushEnabled: true,
|
||||
roleCodes: ["employee"],
|
||||
permissions: [],
|
||||
profileCompleteness: {
|
||||
hasEmergencyContact: true,
|
||||
hasBankAccount: false,
|
||||
hasAvatar: false,
|
||||
hasIdNumber: false,
|
||||
score: 25,
|
||||
},
|
||||
wecomBinding: {
|
||||
externalUserId: "wecom-user-1",
|
||||
createdAt: "2026-05-04T12:00:00.000Z",
|
||||
},
|
||||
}),
|
||||
),
|
||||
});
|
||||
});
|
||||
await page.route("**/api/system/navigation", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(
|
||||
envelope({
|
||||
workspace: {
|
||||
id: "workspace-1",
|
||||
persisted: true,
|
||||
profile: "small_team_internal",
|
||||
orgMode: "single_company",
|
||||
primaryEntryMode: "password",
|
||||
notificationStrategy: "in_app",
|
||||
devicePriority: "desktop",
|
||||
moduleVisibility: {},
|
||||
createdAt: null,
|
||||
updatedAt: null,
|
||||
updatedBy: null,
|
||||
},
|
||||
sections: {
|
||||
main: [{ key: "portal", href: "/portal", label: "Portal", icon: "home_app_logo", placement: "main", permissionCodes: [], featureFlagKey: null }],
|
||||
admin: [],
|
||||
backup: [],
|
||||
},
|
||||
recommendedRolePresets: [],
|
||||
}),
|
||||
),
|
||||
});
|
||||
});
|
||||
await page.route((url) => url.pathname === "/api/notifications", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(
|
||||
envelope({
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
total: 0,
|
||||
unreadOnly: true,
|
||||
unreadCount: 0,
|
||||
items: [],
|
||||
}),
|
||||
),
|
||||
});
|
||||
});
|
||||
await page.route("**/api/portal/home", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(
|
||||
envelope({
|
||||
workspace: { id: "workspace-1", persisted: true, profile: "small_team_internal" },
|
||||
greeting: { name: "张三" },
|
||||
cards: [],
|
||||
pendingTasks: { count: 0, href: "/workflow/tasks" },
|
||||
myApplications: [],
|
||||
riskSummary: null,
|
||||
announcements: [],
|
||||
quickLinks: [],
|
||||
todayPresence: null,
|
||||
teamPresence: null,
|
||||
weeklyReport: null,
|
||||
weeklySummary: null,
|
||||
todoSummary: { openCount: 0, highPriorityCount: 0, href: "/todos" },
|
||||
projectSummary: { myCount: 0, overdueMilestonesCount: 0, highPriorityTaskCount: 0, openRiskCount: 0, followUpDueTodayCount: 0, closurePendingProjectCount: 0, href: "/projects" },
|
||||
peopleLifecycle: null,
|
||||
adoptionSummary: null,
|
||||
recentActivities: [],
|
||||
teamStatusBoard: [],
|
||||
}),
|
||||
),
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto("/login?redirect=%2Fportal&wecom=unbound");
|
||||
await expect(page.getByTestId("login-wecom-notice")).toContainText("当前企业微信账号尚未绑定");
|
||||
await page.getByTestId("login-identifier").fill("[email protected]");
|
||||
await page.getByTestId("login-password").fill("ResetA1abc123def456");
|
||||
await page.getByTestId("login-submit").click();
|
||||
await expect(page).toHaveURL(/\/portal/);
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
"ID","Title","Description","Acceptance","Test_Method","Tools","Dev_Status","Review1_Status","Regression_Status","Files","Dependencies","Notes"
|
||||
"A1","R83-R86 执行合同建档与范围冻结","为员工编号自动生成、轻量创建用户、首登改密闭环、企微首次绑定闭环建立独立执行合同。本轮仅覆盖单个创建用户主链、相关后端接口、前端入口、Playwright 回归与最小手工真机复核;不处理批量导入 employeeNo 选填、不做 hyb/hyd 全局改名、不引入第二套企微业务录入。","CSV 列完整且通过校验;A2-A5 依赖顺序清晰;Notes 明确本轮不处理 R87 导入改造与 R88 生命周期治理。","命令:`python3 /home/shunli/.codex/skills/plan/scripts/validate_issues_csv.py issues/2026-05-04_23-36-10-r83-r86-user-onboarding-simplify.csv`。业务:确认本轮只承接“用户开通与首绑主链优化”,不混入发票、审批或命名重构。","manual","DONE","DONE","DONE","issues/2026-05-04_23-36-10-r83-r86-user-onboarding-simplify.csv","[]","回滚路径:删除本 CSV,不影响运行时。后续 R87 单独承接批量导入 employeeNo 选填与外部编号映射。2026-05-04:合同已写入并通过 validate_issues_csv.py 校验。"
|
||||
"A2","R83 员工编号自动生成基础","在 server 侧把 `employeeNo` 从“单个创建用户必填”改为“缺省时自动生成”,采用数据库统一生成策略;保持 `employee_no` 唯一、业务可见、创建后不可修改;现有查询/展示继续使用该字段。","`POST /api/users` 在未传 `employeeNo` 时可成功创建并返回系统生成的 `employeeNo`;`PATCH/PUT` 路径不可修改既有员工编号;编号生成规则、间隙预期和回滚方式在 Notes/实现中清晰。","命令:`cd server && npm run prisma:validate`;`cd server && node --check src/routes/users.js`;`cd server && npm test -- tests/unit/routes/users*.test.js`。业务:管理员创建用户时不再手填工号,列表与详情页仍能看到新生成工号。","prisma|vitest","DONE","DONE","DONE","server/prisma/schema.prisma|server/prisma/migrations/*|server/src/routes/users.js|server/tests/unit/routes/users*.test.js","[""A1""]","建议采用 PostgreSQL sequence/default 统一发号,允许编号间隙;本轮不改已有历史工号,不改搜索/展示语义。2026-05-04:已补 Prisma default + migration + PATCH 禁改工号;`npm run prisma:validate`、`node --check src/routes/users.js`、`npm test -- tests/unit/routes/users.write.test.js tests/unit/routes/users.import.test.js tests/unit/routes/users.list.test.js` 通过。"
|
||||
"A3","R84 新建用户流程轻量化与一次性口令收口","移除前端“手填工号”和“管理员手填初始密码”交互;单个创建用户时由后端生成一次性口令并仅返回一次,前端在成功态展示“生成工号 + 一次性口令”;保留现有 `must_change_password` 首登改密强制逻辑。","管理员只需填写姓名、手机号、邮箱、部门、直属上级、角色等基础信息即可创建用户;创建成功后能看到一次性口令和生成工号;新用户可用邮箱/手机号 + 一次性口令登录,并被强制跳转修改密码。","命令:`cd server && node --check src/routes/users.js`;`cd client && npm run typecheck`;`cd client && npm run build`;`cd client && npm run test:e2e -- tests/e2e/user-onboarding.spec.ts`。业务:完成“新建用户 -> 拿到一次性口令 -> 新用户首次登录并改密”整链验证。","playwright|vitest","DONE","DONE","DONE","server/src/routes/users.js|client/src/api/users.ts|client/src/pages/user-management/CreateUserDialog.tsx|client/src/pages/UserManagementPage.tsx|client/src/pages/ChangePasswordPage.tsx|client/tests/e2e/user-onboarding.spec.ts","[""A2""]","本轮不接入邮件、短信或企微发码,一次性口令仍由管理员线下转交;重置密码接口保持与创建用户一致的口令语义。2026-05-04:Playwright `user-onboarding.spec.ts` 通过,前端 `npm run build` 通过。"
|
||||
"A4","R85 企微未绑定首次登录闭环","在企微回调识别到未绑定员工时,保留短时待绑定上下文;用户回退到账号密码登录后,系统自动完成绑定或只补一个显式确认动作,然后回到目标页;继续保留个人设置页手动绑定作为兜底。","首次从企微进入且未绑定时,不再要求用户先登录后再手动去个人设置找绑定入口;完成账号密码登录后即可完成首次绑定闭环;第二次从企微进入应可直接进入 OA。","命令:`cd server && node --check src/routes/wecomAuth.js`;`cd server && node --check src/routes/auth.js`;`cd server && npm test -- tests/unit/routes/wecomAuth.test.js`;`cd client && npm run test:e2e -- tests/e2e/wecom-first-bind.spec.ts`。业务:本地用模拟回调覆盖待绑定闭环;线上保留 1 次手机企微真机复核。","playwright|vitest|manual","DONE","DONE","DONE","server/src/routes/wecomAuth.js|server/src/routes/auth.js|client/src/pages/LoginPage.tsx|client/src/lib/wecomEntry.ts|client/src/pages/ProfilePage.tsx|server/tests/unit/routes/wecomAuth.test.js|client/tests/e2e/wecom-first-bind.spec.ts","[""A3""]","保持当前 `/login?wecom=unbound` 回退兜底;若待绑定上下文过期/冲突,仍允许用户从个人设置重新发起绑定;不把审批、报销等业务搬进企微。2026-05-04:已在 callback 未绑定分支写入短时待绑定 cookie,并在密码登录成功后自动 upsert 企微绑定;auth/wecom/users 单测与 Playwright `wecom-first-bind.spec.ts` 通过。"
|
||||
"A5","R86 文案语义与回归自动化收口","统一用户管理、个人资料、登录页中的员工编号语义:强调“系统生成、只读、业务可见”,避免继续暗示管理员手工维护;补齐 Playwright 用例与 trialrun 清单,把剩余必须人工的企微真机步骤单独列出。","相关页面不再出现“新建用户时手填工号/初始密码”的暗示;Playwright 覆盖创建用户、首登改密、密码重置、企微未绑定回退等关键路径;手工清单只剩真实企微手机回调与后台配置核验。","命令:`cd client && npm run typecheck`;`cd client && npm run build`;`cd client && npm run test:e2e -- tests/e2e/user-onboarding.spec.ts tests/e2e/wecom-first-bind.spec.ts`。业务:对照 checklist 确认本轮新增主链除真机企微外均可自动回归。","playwright|manual","DONE","DONE","DONE","client/src/pages/ProfilePage.tsx|client/src/pages/LoginPage.tsx|client/src/pages/UserManagementPage.tsx|artifacts/r70/manual-trialrun-checklist.md|artifacts/r70/manual-trialrun-report.md|client/tests/e2e/user-onboarding.spec.ts|client/tests/e2e/wecom-first-bind.spec.ts","[""A4""]","本轮只收口与用户开通相关的 UI 语义,不顺手调整全站所有 `employeeNo` 展示位;R87 再处理导入模板与外部编号映射。2026-05-04:已补用户管理/登录页/个人页文案与试运行清单更新,新增 Playwright 用例共 3 条并全部通过。"
|
||||
|
在新工单中引用
屏蔽一个用户