R89 极简化登录与移动入口

这个提交包含在:
2026-05-05 18:23:19 +08:00
父节点 4363232fd9
当前提交 bf6deac6d0
共修改 7 个文件,包含 105 行新增和 116 行删除
+6 -1
查看文件
@@ -1,11 +1,12 @@
import { useEffect } from "react"; 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 { ErrorBoundary } from "../components/common/ErrorBoundary";
import { MobileBottomNav } from "../components/mobile/MobileBottomNav"; import { MobileBottomNav } from "../components/mobile/MobileBottomNav";
import { Sidebar } from "../components/navigation/Sidebar"; import { Sidebar } from "../components/navigation/Sidebar";
import { Topbar } from "../components/navigation/Topbar"; import { Topbar } from "../components/navigation/Topbar";
import { resolvePageMeta } from "../features/navigation/schema"; import { resolvePageMeta } from "../features/navigation/schema";
import { writeRecentVisit } from "../lib/recentVisits"; import { writeRecentVisit } from "../lib/recentVisits";
import { shouldPreferMobileWorkplace } from "../lib/runtimeContext";
export function AppShellLayout() { export function AppShellLayout() {
const location = useLocation(); const location = useLocation();
@@ -19,6 +20,10 @@ export function AppShellLayout() {
writeRecentVisit(pathname, resolvePageMeta(pathname).title); writeRecentVisit(pathname, resolvePageMeta(pathname).title);
}, [location.pathname]); }, [location.pathname]);
if (location.pathname === "/portal" && shouldPreferMobileWorkplace()) {
return <Navigate to="/mobile" replace />;
}
return ( return (
<div className="relative flex min-h-screen overflow-hidden bg-[var(--color-bg-canvas)]"> <div className="relative flex min-h-screen overflow-hidden bg-[var(--color-bg-canvas)]">
<div className="pointer-events-none fixed inset-0 -z-10 bg-[radial-gradient(circle_at_12%_8%,rgba(0,57,138,0.10),transparent_28rem),radial-gradient(circle_at_90%_12%,rgba(19,168,184,0.12),transparent_32rem)]" /> <div className="pointer-events-none fixed inset-0 -z-10 bg-[radial-gradient(circle_at_12%_8%,rgba(0,57,138,0.10),transparent_28rem),radial-gradient(circle_at_90%_12%,rgba(19,168,184,0.12),transparent_32rem)]" />
+8 -23
查看文件
@@ -3,33 +3,18 @@ import { AppBrand } from "../components/shell/AppBrand";
export function AuthLayout() { export function AuthLayout() {
return ( return (
<div className="min-h-screen bg-[radial-gradient(circle_at_14%_0%,rgba(0,57,138,0.12),transparent_32rem),radial-gradient(circle_at_86%_8%,rgba(19,168,184,0.12),transparent_30rem),linear-gradient(180deg,#ffffff_0%,#f5f7f8_42%,#eef2f6_100%)] px-6 py-10 lg:px-10 lg:py-14"> <div className="min-h-screen bg-slate-50 px-4 py-8 sm:px-6 lg:px-10">
<div className="mx-auto grid min-h-[calc(100vh-5rem)] max-w-6xl items-center gap-8 lg:grid-cols-[minmax(0,1.05fr),minmax(420px,0.95fr)]"> <div className="mx-auto flex min-h-[calc(100vh-4rem)] max-w-5xl items-center justify-center">
<section className="hidden overflow-hidden rounded-[2rem] border border-slate-200 bg-white/82 p-10 shadow-sm backdrop-blur lg:block"> <section className="hidden w-full max-w-sm lg:block">
<AppBrand /> <AppBrand />
<div className="mt-8 space-y-6"> <div className="mt-8">
<div> <div className="text-xs font-bold uppercase tracking-[0.2em] text-primary">统一入口</div>
<div className="text-xs font-bold uppercase tracking-[0.2em] text-primary">统一入口</div> <h1 className="mt-3 text-4xl font-semibold tracking-tight text-slate-900">海音达 OA</h1>
<h1 className="mt-2 text-4xl font-semibold tracking-tight text-slate-900">让员工先找到今天要做什么</h1> <p className="mt-4 text-base leading-7 text-slate-600">打开后直接处理今天的审批、报销和团队事项。</p>
<p className="mt-3 max-w-xl text-base leading-7 text-slate-600">
企业微信负责进入和提醒,真正的审批、项目、周报与留痕都在 OA 内完成。
</p>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="surface-muted px-5 py-5">
<div className="text-sm font-semibold text-slate-900">主路径稳定</div>
<div className="mt-2 text-sm leading-6 text-slate-600">Portal、团队、请假、报销、资产、周报、待办、公告、项目保持稳定结构。</div>
</div>
<div className="surface-muted px-5 py-5">
<div className="text-sm font-semibold text-slate-900">入口可回退</div>
<div className="mt-2 text-sm leading-6 text-slate-600">企业微信免登失败时仍可使用账号密码,不让员工猜测下一步。</div>
</div>
</div>
</div> </div>
</section> </section>
<section className="mx-auto w-full max-w-xl"> <section className="w-full max-w-md lg:ml-20">
<Outlet /> <Outlet />
</section> </section>
</div> </div>
+16
查看文件
@@ -20,3 +20,19 @@ export function isWecomWebView(userAgent = readUserAgent()) {
export function isEnterpriseMessengerContainer(userAgent = readUserAgent()) { export function isEnterpriseMessengerContainer(userAgent = readUserAgent()) {
return ENTERPRISE_IM_PATTERN.test(userAgent); 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";
}
+64 -90
查看文件
@@ -9,19 +9,18 @@ import { getDeviceFingerprint } from "../auth/deviceFingerprint";
import { Button } from "../components/ui/button"; import { Button } from "../components/ui/button";
import { Input } from "../components/ui/input"; import { Input } from "../components/ui/input";
import { Label } from "../components/ui/label"; import { Label } from "../components/ui/label";
import { isWecomWebView } from "../lib/runtimeContext"; import { isWecomWebView, resolveDefaultWorkspacePath } from "../lib/runtimeContext";
import { import {
buildWecomLoginUrl, buildWecomLoginUrl,
resolveWecomBindNotice, resolveWecomBindNotice,
resolveWecomEntryStatus,
resolveWecomLoginNotice, resolveWecomLoginNotice,
type WecomNotice, type WecomNotice,
} from "../lib/wecomEntry"; } from "../lib/wecomEntry";
function resolveRedirect(searchParams: URLSearchParams) { function resolveRedirect(searchParams: URLSearchParams, fallback: string) {
const redirect = searchParams.get("redirect"); const redirect = searchParams.get("redirect");
if (!redirect || !redirect.startsWith("/") || redirect.startsWith("//")) { if (!redirect || !redirect.startsWith("/") || redirect.startsWith("//")) {
return "/portal"; return fallback;
} }
return redirect; return redirect;
} }
@@ -45,9 +44,11 @@ export function LoginPage() {
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState(""); const [error, setError] = useState("");
const [passwordFallbackOpen, setPasswordFallbackOpen] = useState(false);
const redirectTo = resolveRedirect(searchParams);
const isWecomContext = isWecomWebView(); const isWecomContext = isWecomWebView();
const defaultWorkspacePath = resolveDefaultWorkspacePath();
const redirectTo = resolveRedirect(searchParams, defaultWorkspacePath);
const ssoProvidersQuery = useQuery({ const ssoProvidersQuery = useQuery({
queryKey: ["auth", "sso-providers"], queryKey: ["auth", "sso-providers"],
@@ -63,14 +64,15 @@ export function LoginPage() {
}); });
const wecomConfig = wecomConfigQuery.data; const wecomConfig = wecomConfigQuery.data;
const wecomStatus = resolveWecomEntryStatus(wecomConfig); const wecomEntry = wecomConfig?.entryEnabled && wecomConfig.loginUrl ? wecomConfig : null;
const wecomEntry = wecomConfig?.entryEnabled ? wecomConfig : null;
const primaryMode = isWecomContext && wecomEntry?.loginUrl ? "wecom" : "password";
const wecomNotice = useMemo( const wecomNotice = useMemo(
() => resolveWecomBindNotice(searchParams.get("wecomBind")) ?? resolveWecomLoginNotice(searchParams.get("wecom")), () => resolveWecomBindNotice(searchParams.get("wecomBind")) ?? resolveWecomLoginNotice(searchParams.get("wecom")),
[searchParams], [searchParams],
); );
const showWecomPrimary = isWecomContext;
const showPasswordPanel = !showWecomPrimary || Boolean(wecomNotice) || passwordFallbackOpen || (showWecomPrimary && wecomConfig && !wecomEntry);
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) { async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault(); event.preventDefault();
setSubmitting(true); setSubmitting(true);
@@ -87,7 +89,7 @@ export function LoginPage() {
if (isApiError(err)) { if (isApiError(err)) {
setError(mapApiErrorMessage(err.code, err.details, err.message)); setError(mapApiErrorMessage(err.code, err.details, err.message));
} else if (err instanceof TypeError) { } else if (err instanceof TypeError) {
setError("无法连接后端服务,请确认 server 已启动(http://127.0.0.1:3000)。"); setError("无法连接后端服务,请稍后再试或联系管理员。");
} else { } else {
setError("登录失败,请稍后重试。"); setError("登录失败,请稍后重试。");
} }
@@ -103,19 +105,9 @@ export function LoginPage() {
window.location.assign(buildWecomLoginUrl(wecomEntry.loginUrl, redirectTo, getDeviceFingerprint())); window.location.assign(buildWecomLoginUrl(wecomEntry.loginUrl, redirectTo, getDeviceFingerprint()));
} }
function renderPasswordPanel(testId: string, primary: boolean) { function renderPasswordPanel(testId: string) {
return ( return (
<div className="space-y-4 rounded-[1.5rem] border border-slate-200 bg-white p-6 shadow-sm" data-testid={testId}> <div className="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm" data-testid={testId}>
<div>
<div className="text-xs font-semibold uppercase tracking-[0.18em] text-slate-400">{primary ? "推荐入口" : "回退入口"}</div>
<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>
<form className="space-y-4" onSubmit={handleSubmit}> <form className="space-y-4" onSubmit={handleSubmit}>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="login-identifier">账号</Label> <Label htmlFor="login-identifier">账号</Label>
@@ -136,7 +128,7 @@ export function LoginPage() {
data-testid="login-password" data-testid="login-password"
id="login-password" id="login-password"
onChange={(event) => setPassword(event.target.value)} onChange={(event) => setPassword(event.target.value)}
placeholder="请输入密码或一次性口令" placeholder="请输入密码"
type="password" type="password"
value={password} value={password}
/> />
@@ -144,8 +136,8 @@ export function LoginPage() {
{error ? <div className="rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-600">{error}</div> : null} {error ? <div className="rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-600">{error}</div> : null}
<Button data-testid="login-submit" className="w-full" disabled={submitting} type="submit" variant={primary ? "default" : "secondary"}> <Button data-testid="login-submit" className="w-full" disabled={submitting} type="submit">
{submitting ? "登录中..." : "登录并进入工作台"} {submitting ? "登录中..." : "登录"}
</Button> </Button>
</form> </form>
</div> </div>
@@ -153,21 +145,16 @@ export function LoginPage() {
} }
return ( return (
<div className="card-std overflow-hidden border-slate-200 p-0 shadow-md"> <div className="rounded-3xl border border-slate-200 bg-white p-6 shadow-sm sm:p-7">
<div className="border-b border-slate-200 bg-[radial-gradient(circle_at_8%_0%,rgba(0,57,138,0.12),transparent_16rem),linear-gradient(135deg,#ffffff,#e6ecf4)] px-6 py-6 sm:px-8 sm:py-7"> <div className="space-y-2">
<div className="text-xs font-bold uppercase tracking-[0.2em] text-primary">统一入口</div> <div className="text-xs font-bold uppercase tracking-[0.2em] text-primary">统一入口</div>
<h1 className="mt-2 text-3xl font-semibold tracking-tight text-slate-900">登录海音达工作台</h1> <h1 className="text-2xl font-semibold tracking-tight text-slate-900">海音达 OA</h1>
<p className="mt-3 max-w-2xl text-sm leading-6 text-slate-600"> <p className="text-sm leading-6 text-slate-600">
企业微信负责进入与提醒,真正的审批、项目、周报和团队留痕都在海音达工作台内完成。 {showWecomPrimary ? "从企业微信进入,直接打开移动工作台。" : "使用账号密码进入工作台。"}
</p> </p>
<div className="mt-4 rounded-2xl border border-slate-200 bg-white/90 px-4 py-3 text-sm text-slate-600">
{isWecomContext
? "当前在企业微信中打开,推荐直接使用企业微信进入工作台;失败时可随时回退到账号密码登录。"
: "当前为浏览器场景,推荐直接使用账号密码登录;企业微信入口会作为次级入口提供。"}
</div>
</div> </div>
<div className="space-y-5 px-6 py-6 sm:px-8 sm:py-7"> <div className="mt-6 space-y-4">
{wecomNotice ? ( {wecomNotice ? (
<div className={`rounded-2xl border px-4 py-3 text-sm ${noticeClasses(wecomNotice.tone)}`} data-testid="login-wecom-notice"> <div className={`rounded-2xl border px-4 py-3 text-sm ${noticeClasses(wecomNotice.tone)}`} data-testid="login-wecom-notice">
<div className="font-medium">{wecomNotice.title}</div> <div className="font-medium">{wecomNotice.title}</div>
@@ -175,63 +162,50 @@ export function LoginPage() {
</div> </div>
) : null} ) : null}
<div className="grid gap-4"> {showWecomPrimary ? (
{primaryMode === "wecom" && wecomEntry?.loginUrl ? ( <div className="rounded-2xl border border-primary/20 bg-primary/5 p-5" data-testid="login-wecom-primary-panel">
<div className="rounded-[1.5rem] border border-primary/20 bg-primary/5 p-6" data-testid="login-wecom-primary-panel"> <Button className="w-full" data-testid="wecom-entry-primary" disabled={!wecomEntry} type="button" onClick={handleWecomLogin}>
<div className="text-xs font-semibold uppercase tracking-[0.18em] text-primary">推荐入口</div> {wecomConfigQuery.isLoading ? "正在准备入口..." : "进入工作台"}
<h2 className="mt-2 text-xl font-semibold tracking-tight text-slate-900">使用企业微信进入工作台</h2> </Button>
<p className="mt-2 text-sm leading-6 text-slate-600">当前是企业微信场景,优先使用免登入口;若未绑定或回调失效,仍可切换为账号密码登录。</p> {wecomConfig && !wecomEntry ? (
<Button className="mt-5 w-full" data-testid="wecom-entry-primary" type="button" onClick={handleWecomLogin}> <div className="mt-3 rounded-xl border border-amber-200 bg-amber-50 px-3 py-2 text-sm leading-6 text-amber-700">
使用企业微信进入工作台 企业微信入口暂不可用,请使用账号密码登录。
</Button>
</div>
) : null}
{renderPasswordPanel(
primaryMode === "password" ? "login-password-primary-panel" : "login-password-secondary-panel",
primaryMode === "password",
)}
{((primaryMode !== "wecom" && wecomEntry?.loginUrl) || oidcProvider) ? (
<div className="rounded-[1.5rem] border border-slate-200 bg-slate-50/70 p-6">
<div className="text-xs font-semibold uppercase tracking-[0.18em] text-slate-400">补充入口</div>
<h2 className="mt-2 text-lg font-semibold text-slate-900">其他进入方式</h2>
<p className="mt-2 text-sm leading-6 text-slate-600">入口失败时请优先选择可理解、可回退的方式继续进入。</p>
<div className="mt-4 grid gap-3">
{primaryMode !== "wecom" && wecomEntry?.loginUrl ? (
<Button className="w-full" data-testid="wecom-entry-secondary" type="button" variant="secondary" onClick={handleWecomLogin}>
使用企业微信进入工作台
</Button>
) : null}
{oidcProvider ? (
<Button
className="w-full"
type="button"
variant="secondary"
onClick={() => {
window.location.assign(`${oidcProvider.loginUrl}?returnTo=${encodeURIComponent(redirectTo)}`);
}}
>
使用{oidcProvider.label}
</Button>
) : null}
</div> </div>
</div> ) : null}
) : null} {!showPasswordPanel ? (
</div> <button
type="button"
className="mt-4 w-full text-sm font-medium text-primary"
onClick={() => setPasswordFallbackOpen(true)}
>
使用账号密码登录
</button>
) : null}
</div>
) : null}
<div className="rounded-2xl border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-600"> {showPasswordPanel
<div className="font-medium text-slate-900">企业微信入口状态:{wecomStatus.label}</div> ? renderPasswordPanel(showWecomPrimary ? "login-password-secondary-panel" : "login-password-primary-panel")
<div className="mt-1 leading-6">{wecomStatus.description}</div> : null}
{wecomStatus.reasonLabels.length ? (
<ul className="mt-2 list-disc space-y-1 pl-5 text-xs text-slate-500"> {!showWecomPrimary && wecomEntry ? (
{wecomStatus.reasonLabels.map((label) => ( <Button className="w-full" data-testid="wecom-entry-secondary" type="button" variant="secondary" onClick={handleWecomLogin}>
<li key={label}>{label}</li> 企业微信登录
))} </Button>
</ul> ) : null}
) : null}
</div> {!showWecomPrimary && oidcProvider ? (
<Button
className="w-full"
type="button"
variant="secondary"
onClick={() => {
window.location.assign(`${oidcProvider.loginUrl}?returnTo=${encodeURIComponent(redirectTo)}`);
}}
>
使用{oidcProvider.label}
</Button>
) : null}
</div> </div>
</div> </div>
); );
+2 -1
查看文件
@@ -2,6 +2,7 @@ import type { PropsWithChildren } from "react";
import { Navigate, useLocation } from "react-router-dom"; import { Navigate, useLocation } from "react-router-dom";
import { useAuth } from "../auth/AuthContext"; import { useAuth } from "../auth/AuthContext";
import { hasAnyPermission } from "../auth/permissions"; import { hasAnyPermission } from "../auth/permissions";
import { resolveDefaultWorkspacePath } from "../lib/runtimeContext";
import { isOfflineSnapshotAllowedPath } from "../pwa"; import { isOfflineSnapshotAllowedPath } from "../pwa";
function FullPageLoading() { function FullPageLoading() {
@@ -55,7 +56,7 @@ export function GuestOnly({ children }: PropsWithChildren) {
if (mustChangePassword) { if (mustChangePassword) {
return <Navigate to="/change-password" replace />; return <Navigate to="/change-password" replace />;
} }
return <Navigate to="/dashboard" replace />; return <Navigate to={resolveDefaultWorkspacePath()} replace />;
} }
return <>{children}</>; return <>{children}</>;
+1 -1
查看文件
@@ -14,7 +14,7 @@ test.describe("visual shell", () => {
await page.setViewportSize({ width: 1440, height: 1024 }); await page.setViewportSize({ width: 1440, height: 1024 });
await page.goto("/login"); 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.getByText("统一入口").first()).toBeVisible();
await expect(page.getByTestId("login-password-primary-panel")).toBeVisible(); await expect(page.getByTestId("login-password-primary-panel")).toBeVisible();
@@ -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 与本地登录后移动页。"
1 ID Title Description Acceptance Test_Method Tools Dev_Status Review1_Status Regression_Status Files Dependencies Notes
2 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 校验。
3 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 通过。
4 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。
5 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;风险中等,涉及全局壳布局。
6 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,避免新增后端接口。
7 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 和业务状态机。
8 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 与本地登录后移动页。