From bf6deac6d0ce70b1e71172e9d089e97caa11cd21 Mon Sep 17 00:00:00 2001 From: shunli Date: Tue, 5 May 2026 18:23:19 +0800 Subject: [PATCH] =?UTF-8?q?R89=20=E6=9E=81=E7=AE=80=E5=8C=96=E7=99=BB?= =?UTF-8?q?=E5=BD=95=E4=B8=8E=E7=A7=BB=E5=8A=A8=E5=85=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/layouts/AppShellLayout.tsx | 7 +- client/src/layouts/AuthLayout.tsx | 31 +--- client/src/lib/runtimeContext.ts | 16 ++ client/src/pages/LoginPage.tsx | 154 ++++++++---------- client/src/router/guards.tsx | 3 +- client/tests/e2e/visual-shell.spec.ts | 2 +- ...026-05-05_17-35-00-r89-mobile-login-ux.csv | 8 + 7 files changed, 105 insertions(+), 116 deletions(-) create mode 100644 issues/2026-05-05_17-35-00-r89-mobile-login-ux.csv diff --git a/client/src/layouts/AppShellLayout.tsx b/client/src/layouts/AppShellLayout.tsx index c0ecf43a..6282f364 100644 --- a/client/src/layouts/AppShellLayout.tsx +++ b/client/src/layouts/AppShellLayout.tsx @@ -1,11 +1,12 @@ import { useEffect } from "react"; -import { Outlet, useLocation } from "react-router-dom"; +import { Navigate, Outlet, useLocation } from "react-router-dom"; import { ErrorBoundary } from "../components/common/ErrorBoundary"; import { MobileBottomNav } from "../components/mobile/MobileBottomNav"; import { Sidebar } from "../components/navigation/Sidebar"; import { Topbar } from "../components/navigation/Topbar"; import { resolvePageMeta } from "../features/navigation/schema"; import { writeRecentVisit } from "../lib/recentVisits"; +import { shouldPreferMobileWorkplace } from "../lib/runtimeContext"; export function AppShellLayout() { const location = useLocation(); @@ -19,6 +20,10 @@ export function AppShellLayout() { writeRecentVisit(pathname, resolvePageMeta(pathname).title); }, [location.pathname]); + if (location.pathname === "/portal" && shouldPreferMobileWorkplace()) { + return ; + } + return (
diff --git a/client/src/layouts/AuthLayout.tsx b/client/src/layouts/AuthLayout.tsx index ab73004c..014261f8 100644 --- a/client/src/layouts/AuthLayout.tsx +++ b/client/src/layouts/AuthLayout.tsx @@ -3,33 +3,18 @@ import { AppBrand } from "../components/shell/AppBrand"; export function AuthLayout() { return ( -
-
-
+
+
+
-
-
-
统一入口
-

让员工先找到今天要做什么

-

- 企业微信负责进入和提醒,真正的审批、项目、周报与留痕都在 OA 内完成。 -

-
- -
-
-
主路径稳定
-
Portal、团队、请假、报销、资产、周报、待办、公告、项目保持稳定结构。
-
-
-
入口可回退
-
企业微信免登失败时仍可使用账号密码,不让员工猜测下一步。
-
-
+
+
统一入口
+

海音达 OA

+

打开后直接处理今天的审批、报销和团队事项。

-
+
diff --git a/client/src/lib/runtimeContext.ts b/client/src/lib/runtimeContext.ts index 87f0569a..57b0298a 100644 --- a/client/src/lib/runtimeContext.ts +++ b/client/src/lib/runtimeContext.ts @@ -20,3 +20,19 @@ export function isWecomWebView(userAgent = readUserAgent()) { export function isEnterpriseMessengerContainer(userAgent = readUserAgent()) { return ENTERPRISE_IM_PATTERN.test(userAgent); } + +export function isTouchSmallScreen() { + if (!canUseWindow()) { + return false; + } + + return window.navigator.maxTouchPoints > 0 && matchesMedia("(max-width: 768px)"); +} + +export function shouldPreferMobileWorkplace() { + return isEnterpriseMessengerContainer() || isTouchSmallScreen(); +} + +export function resolveDefaultWorkspacePath() { + return shouldPreferMobileWorkplace() ? "/mobile" : "/portal"; +} diff --git a/client/src/pages/LoginPage.tsx b/client/src/pages/LoginPage.tsx index c6ee9505..47d60304 100644 --- a/client/src/pages/LoginPage.tsx +++ b/client/src/pages/LoginPage.tsx @@ -9,19 +9,18 @@ import { getDeviceFingerprint } from "../auth/deviceFingerprint"; import { Button } from "../components/ui/button"; import { Input } from "../components/ui/input"; import { Label } from "../components/ui/label"; -import { isWecomWebView } from "../lib/runtimeContext"; +import { isWecomWebView, resolveDefaultWorkspacePath } from "../lib/runtimeContext"; import { buildWecomLoginUrl, resolveWecomBindNotice, - resolveWecomEntryStatus, resolveWecomLoginNotice, type WecomNotice, } from "../lib/wecomEntry"; -function resolveRedirect(searchParams: URLSearchParams) { +function resolveRedirect(searchParams: URLSearchParams, fallback: string) { const redirect = searchParams.get("redirect"); if (!redirect || !redirect.startsWith("/") || redirect.startsWith("//")) { - return "/portal"; + return fallback; } return redirect; } @@ -45,9 +44,11 @@ export function LoginPage() { const [password, setPassword] = useState(""); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(""); + const [passwordFallbackOpen, setPasswordFallbackOpen] = useState(false); - const redirectTo = resolveRedirect(searchParams); const isWecomContext = isWecomWebView(); + const defaultWorkspacePath = resolveDefaultWorkspacePath(); + const redirectTo = resolveRedirect(searchParams, defaultWorkspacePath); const ssoProvidersQuery = useQuery({ queryKey: ["auth", "sso-providers"], @@ -63,14 +64,15 @@ export function LoginPage() { }); const wecomConfig = wecomConfigQuery.data; - const wecomStatus = resolveWecomEntryStatus(wecomConfig); - const wecomEntry = wecomConfig?.entryEnabled ? wecomConfig : null; - const primaryMode = isWecomContext && wecomEntry?.loginUrl ? "wecom" : "password"; + const wecomEntry = wecomConfig?.entryEnabled && wecomConfig.loginUrl ? wecomConfig : null; const wecomNotice = useMemo( () => resolveWecomBindNotice(searchParams.get("wecomBind")) ?? resolveWecomLoginNotice(searchParams.get("wecom")), [searchParams], ); + const showWecomPrimary = isWecomContext; + const showPasswordPanel = !showWecomPrimary || Boolean(wecomNotice) || passwordFallbackOpen || (showWecomPrimary && wecomConfig && !wecomEntry); + async function handleSubmit(event: React.FormEvent) { event.preventDefault(); setSubmitting(true); @@ -87,7 +89,7 @@ export function LoginPage() { if (isApiError(err)) { setError(mapApiErrorMessage(err.code, err.details, err.message)); } else if (err instanceof TypeError) { - setError("无法连接后端服务,请确认 server 已启动(http://127.0.0.1:3000)。"); + setError("无法连接后端服务,请稍后再试或联系管理员。"); } else { setError("登录失败,请稍后重试。"); } @@ -103,19 +105,9 @@ export function LoginPage() { window.location.assign(buildWecomLoginUrl(wecomEntry.loginUrl, redirectTo, getDeviceFingerprint())); } - function renderPasswordPanel(testId: string, primary: boolean) { + function renderPasswordPanel(testId: string) { return ( -
-
-
{primary ? "推荐入口" : "回退入口"}
-

使用账号密码登录

-

- {primary - ? "适合桌面浏览器直接登录;若是首次开通,也可使用管理员转交的一次性口令进入。" - : "企业微信免登失败、未绑定或超时时,可改用账号密码或一次性口令继续进入。"} -

-
- +
@@ -136,7 +128,7 @@ export function LoginPage() { data-testid="login-password" id="login-password" onChange={(event) => setPassword(event.target.value)} - placeholder="请输入密码或一次性口令" + placeholder="请输入密码" type="password" value={password} /> @@ -144,8 +136,8 @@ export function LoginPage() { {error ?
{error}
: null} -
@@ -153,21 +145,16 @@ export function LoginPage() { } return ( -
-
+
+
统一入口
-

登录海音达工作台

-

- 企业微信负责进入与提醒,真正的审批、项目、周报和团队留痕都在海音达工作台内完成。 +

海音达 OA

+

+ {showWecomPrimary ? "从企业微信进入,直接打开移动工作台。" : "使用账号密码进入工作台。"}

-
- {isWecomContext - ? "当前在企业微信中打开,推荐直接使用企业微信进入工作台;失败时可随时回退到账号密码登录。" - : "当前为浏览器场景,推荐直接使用账号密码登录;企业微信入口会作为次级入口提供。"} -
-
+
{wecomNotice ? (
{wecomNotice.title}
@@ -175,63 +162,50 @@ export function LoginPage() {
) : null} -
- {primaryMode === "wecom" && wecomEntry?.loginUrl ? ( -
-
推荐入口
-

使用企业微信进入工作台

-

当前是企业微信场景,优先使用免登入口;若未绑定或回调失效,仍可切换为账号密码登录。

- -
- ) : null} - - {renderPasswordPanel( - primaryMode === "password" ? "login-password-primary-panel" : "login-password-secondary-panel", - primaryMode === "password", - )} - - {((primaryMode !== "wecom" && wecomEntry?.loginUrl) || oidcProvider) ? ( -
-
补充入口
-

其他进入方式

-

入口失败时请优先选择可理解、可回退的方式继续进入。

-
- {primaryMode !== "wecom" && wecomEntry?.loginUrl ? ( - - ) : null} - - {oidcProvider ? ( - - ) : null} + {showWecomPrimary ? ( +
+ + {wecomConfig && !wecomEntry ? ( +
+ 企业微信入口暂不可用,请使用账号密码登录。
-
- ) : null} -
+ ) : null} + {!showPasswordPanel ? ( + + ) : null} +
+ ) : null} -
-
企业微信入口状态:{wecomStatus.label}
-
{wecomStatus.description}
- {wecomStatus.reasonLabels.length ? ( -
    - {wecomStatus.reasonLabels.map((label) => ( -
  • {label}
  • - ))} -
- ) : null} -
+ {showPasswordPanel + ? renderPasswordPanel(showWecomPrimary ? "login-password-secondary-panel" : "login-password-primary-panel") + : null} + + {!showWecomPrimary && wecomEntry ? ( + + ) : null} + + {!showWecomPrimary && oidcProvider ? ( + + ) : null}
); diff --git a/client/src/router/guards.tsx b/client/src/router/guards.tsx index 3bad4758..c8912c8f 100644 --- a/client/src/router/guards.tsx +++ b/client/src/router/guards.tsx @@ -2,6 +2,7 @@ import type { PropsWithChildren } from "react"; import { Navigate, useLocation } from "react-router-dom"; import { useAuth } from "../auth/AuthContext"; import { hasAnyPermission } from "../auth/permissions"; +import { resolveDefaultWorkspacePath } from "../lib/runtimeContext"; import { isOfflineSnapshotAllowedPath } from "../pwa"; function FullPageLoading() { @@ -55,7 +56,7 @@ export function GuestOnly({ children }: PropsWithChildren) { if (mustChangePassword) { return ; } - return ; + return ; } return <>{children}; diff --git a/client/tests/e2e/visual-shell.spec.ts b/client/tests/e2e/visual-shell.spec.ts index 919cd8d7..4a2d9f84 100644 --- a/client/tests/e2e/visual-shell.spec.ts +++ b/client/tests/e2e/visual-shell.spec.ts @@ -14,7 +14,7 @@ test.describe("visual shell", () => { await page.setViewportSize({ width: 1440, height: 1024 }); await page.goto("/login"); - await expect(page.getByRole("heading", { name: "登录海音达工作台" })).toBeVisible(); + await expect(page.getByRole("heading", { name: "海音达 OA" })).toBeVisible(); await expect(page.getByText("统一入口").first()).toBeVisible(); await expect(page.getByTestId("login-password-primary-panel")).toBeVisible(); diff --git a/issues/2026-05-05_17-35-00-r89-mobile-login-ux.csv b/issues/2026-05-05_17-35-00-r89-mobile-login-ux.csv new file mode 100644 index 00000000..eab58bc1 --- /dev/null +++ b/issues/2026-05-05_17-35-00-r89-mobile-login-ux.csv @@ -0,0 +1,8 @@ +"ID","Title","Description","Acceptance","Test_Method","Tools","Dev_Status","Review1_Status","Regression_Status","Files","Dependencies","Notes" +"A1","R89 执行合同与体验基线冻结","建立登录极简化与移动端可用性改造合同,冻结本轮原则:企业微信优先、移动端任务优先、管理员低频入口后置、不引入新 UI 框架。","CSV 通过校验;A2-A7 依赖清晰;明确本轮不改业务权限模型、不做 Prisma 迁移、不替换设计系统。","命令:python3 /home/shunli/.codex/skills/plan/scripts/validate_issues_csv.py issues/2026-05-05_17-35-00-r89-mobile-login-ux.csv。业务:确认本轮覆盖登录→移动首页→移动导航→核心移动流程→回归证据。","manual","DONE","DONE","DONE","issues/2026-05-05_17-35-00-r89-mobile-login-ux.csv","[]","回滚路径:删除本 CSV;实现阶段每个子项独立提交。2026-05-05:CSV 已写入并通过 validate_issues_csv.py 校验。" +"A2","登录页极简化","重构 AuthLayout 与 LoginPage,把登录页从说明型页面改为操作型页面;企业微信内只突出进入工作台,账号密码作为回退;普通浏览器只突出账号密码登录。","企业微信 WebView 中首屏只出现品牌、主按钮、必要错误提示和账号密码回退;普通浏览器首屏只出现账号密码表单和次级企微入口;不再默认展示企微配置状态和大段说明。","命令:cd client && npm run typecheck && npm run build。业务:PC 打开 /login 可账号密码登录;企业微信场景点击主按钮进入企微 OAuth;未绑定/失败时错误提示能指导下一步。","manual|tsc|vite","DONE","DONE","DONE","client/src/layouts/AuthLayout.tsx|client/src/pages/LoginPage.tsx|client/src/lib/wecomEntry.ts","[""A1""]","回滚路径:还原 AuthLayout/LoginPage 改动;风险低,仅影响入口文案与布局。2026-05-05:登录页已极简化,桌面显示账号密码+企微次级入口,企微 WebView 显示单一进入工作台主按钮;typecheck/build 与 focused wecom E2E 通过。" +"A3","企业微信与手机默认进入移动工作台","将手机/企业微信登录后的默认落点从桌面 Portal 调整为 /mobile;PC 仍默认 /portal;保留 redirect 参数的安全校验。","企业微信应用主页或登录按钮默认 returnTo=/mobile;手机访问 /portal 时可引导到 /mobile;PC 登录不受影响。","命令:cd client && npm run typecheck && npm run build。业务:手机企微进入后落在移动首页;PC 浏览器登录后仍进入 /portal;手工确认企微后台应用主页需改为 https://oa.hyinda.com/api/auth/wecom/login?returnTo=%2Fmobile。","manual|tsc|vite","DONE","DONE","DONE","client/src/pages/LoginPage.tsx|client/src/router/guards.tsx|client/src/layouts/AppShellLayout.tsx|client/src/lib/runtimeContext.ts","[""A2""]","回滚路径:恢复默认 returnTo=/portal;注意需同步企业微信后台应用主页。2026-05-05:新增移动工作台偏好判断;企微/小屏默认 /mobile,PC 默认 /portal;Playwright 辅助检查确认企微登录 URL 使用 returnTo=/mobile。" +"A4","移动端导航可靠性修复","修复手机端菜单点不开和桌面菜单压缩到手机的问题;移动端优先使用底部导航和更多页,普通桌面菜单抽屉只作为兜底。","/mobile 下不依赖桌面侧边菜单;底部导航点击稳定;我的/更多页能进入低频模块;非 /mobile 小屏页面的菜单按钮可打开且可关闭。","命令:cd client && npm run typecheck && npm run build。业务:手机宽度 390px 下依次点击首页、审批、报销或票据、我的/更多,均能跳转;菜单抽屉不被遮罩或安全区挡住。","manual|tsc|vite","TODO","TODO","TODO","client/src/layouts/AppShellLayout.tsx|client/src/components/navigation/Topbar.tsx|client/src/components/mobile/MobileBottomNav.tsx|client/src/features/mobile/navigation.ts|client/src/pages/MobileMePage.tsx|client/src/router/index.tsx","[""A3""]","回滚路径:恢复原 Topbar/Dialog 与 MobileBottomNav;风险中等,涉及全局壳布局。" +"A5","移动工作台信息架构重排","把移动首页改为今日任务优先:待审批、待补票/待提交报销、最近通知、快捷动作;弱化桌面 Portal 的模块展示方式。","移动首页首屏能回答今天要处理什么;快捷入口包含发起报销、上传发票、审批待办;管理员入口不出现在普通员工首屏。","命令:cd client && npm run typecheck && npm run build。业务:员工登录 /mobile 后 3 秒内能看到待办与快捷操作;经理能看到审批待办;无权限入口不展示或降级。","manual|tsc|vite","TODO","TODO","TODO","client/src/pages/MobilePortalPage.tsx|client/src/api/portal.ts|client/src/api/workflow.ts|client/src/api/invoicePool.ts","[""A4""]","回滚路径:恢复 MobilePortalPage 原布局;优先复用现有 API,避免新增后端接口。" +"A6","移动端核心流程触控优化","对审批、发票上传、我的页面做小屏触控收口,避免按钮太小、卡片过密、操作入口被底部导航遮挡。","审批详情主操作在手机上可见可点;发票拍照/上传入口清晰;我的页能找到个人设置、绑定企业微信、退出登录和更多模块。","命令:cd client && npm run typecheck && npm run build。业务:手机宽度 390px 下完成审批查看→同意/拒绝入口可达;进入票据上传;进入个人设置和退出登录。","manual|tsc|vite","TODO","TODO","TODO","client/src/pages/MobileApprovalsPage.tsx|client/src/pages/MobileCapturePage.tsx|client/src/pages/MobileMePage.tsx|client/src/components/mobile/MobileBottomNav.tsx","[""A5""]","回滚路径:恢复相关页面布局;不改变审批 API 和业务状态机。" +"A7","移动端 Playwright 回归补齐","新增或扩展 Playwright 移动视口回归,覆盖登录页极简、移动导航、移动首页、审批入口、发票上传入口和我的页。","CI/本地可复现移动端关键路径;至少覆盖 iPhone/Android 尺寸各一组;报告回填 manual trialrun artifacts。","命令:cd client && npm run test:e2e -- --grep mobile 或指定新增 spec;cd client && npm run typecheck && npm run build。业务:自动化能发现菜单点不开、底部导航不可达、登录页主要入口缺失等问题。","playwright|tsc|vite","TODO","TODO","TODO","client/tests/e2e/*mobile*.spec.ts|artifacts/r70/manual-trialrun-checklist.md|artifacts/r70/manual-trialrun-report.md","[""A6""]","回滚路径:移除新增测试与报告回填;若线上企微 OAuth 不能全自动完成,则只自动验证跳转 URL 与本地登录后移动页。"