R89 A6-A7 移动触控与回归补齐
这个提交包含在:
@@ -142,6 +142,20 @@ Codex 可代做:
|
|||||||
2. 用真实手机企业微信打开 OA 应用一次,确认真实 OAuth 回调、cookie 与企业身份都正常。
|
2. 用真实手机企业微信打开 OA 应用一次,确认真实 OAuth 回调、cookie 与企业身份都正常。
|
||||||
3. 若仍失败,记录 `requestId`、错误截图和时间点给我,我继续抓线上日志定位。
|
3. 若仍失败,记录 `requestId`、错误截图和时间点给我,我继续抓线上日志定位。
|
||||||
|
|
||||||
|
## R89 登录与移动端 UX 自动复核(2026-05-05)
|
||||||
|
|
||||||
|
- 本轮目标:把登录页从说明型入口收敛为操作型入口,并修复手机端菜单/导航不可用的问题。
|
||||||
|
- 本地验证地址:`http://127.0.0.1:5173`
|
||||||
|
- 自动化覆盖:
|
||||||
|
- 企业微信移动 UA 打开 `/login` 时,首屏只突出“进入工作台”,并生成 `returnTo=/mobile` 的企微 OAuth URL。
|
||||||
|
- iPhone 视口密码登录后默认进入 `/mobile`,并完成:首页今日重点 -> 移动审批 -> 票据上传 -> 我的/更多入口。
|
||||||
|
- Pixel 视口进入非 `/mobile` 页面时,顶部菜单抽屉可打开、可关闭。
|
||||||
|
- 回归命令:
|
||||||
|
- `cd client && E2E_BASE_URL=http://127.0.0.1:5173 npx playwright test tests/e2e/r89-mobile-login-navigation.spec.ts --workers=1 --reporter=list`
|
||||||
|
- `cd client && npm run typecheck`
|
||||||
|
- `cd client && npm run build`
|
||||||
|
- 当前结论:R89 移动端自动化关键路径已通过;仍需真实手机企业微信在公网应用中复核一次真实 OAuth、cookie 与移动工作台落点。
|
||||||
|
|
||||||
## 员工路径
|
## 员工路径
|
||||||
|
|
||||||
账号:员工测试账号。
|
账号:员工测试账号。
|
||||||
|
|||||||
@@ -230,3 +230,24 @@ R70 初次试运行暂停项已在 2026-04-28 的 R71 定向修复后关闭。
|
|||||||
- 企业微信未绑定 -> 回退密码登录 -> 继续进入目标页
|
- 企业微信未绑定 -> 回退密码登录 -> 继续进入目标页
|
||||||
- 仍需真实人工复核:
|
- 仍需真实人工复核:
|
||||||
- 将本轮代码部署到目标环境后,用真实手机企业微信完成 1 次首次进入,确认真实 OAuth 回调、cookie 与企业身份环境下也能自动完成首次绑定闭环。
|
- 将本轮代码部署到目标环境后,用真实手机企业微信完成 1 次首次进入,确认真实 OAuth 回调、cookie 与企业身份环境下也能自动完成首次绑定闭环。
|
||||||
|
|
||||||
|
## R89 登录与移动端 UX 收口
|
||||||
|
|
||||||
|
- 时间:`2026-05-05`
|
||||||
|
- 本地自动化环境:`http://127.0.0.1:5173`
|
||||||
|
- 本轮实现结果:
|
||||||
|
- `/mobile` 路由在手机端隐藏桌面 Topbar,使用底部四入口:`首页 / 审批 / 票据 / 我的`。
|
||||||
|
- 普通小屏非 `/mobile` 页面保留顶部菜单抽屉作为兜底,并修复抽屉定位导致的点不开问题。
|
||||||
|
- 移动首页调整为“今天”优先,首屏展示审批待办、未读通知和高频动作。
|
||||||
|
- “我的”页承接低频入口、个人设置、完整员工档案、管理入口和退出登录。
|
||||||
|
- 移动审批和票据上传的主按钮改为更适合手机的触控目标。
|
||||||
|
- 自动化结果:
|
||||||
|
- `cd client && E2E_BASE_URL=http://127.0.0.1:5173 npx playwright test tests/e2e/r89-mobile-login-navigation.spec.ts --workers=1 --reporter=list`
|
||||||
|
- `3 passed`
|
||||||
|
- `cd client && npm run typecheck`
|
||||||
|
- 通过
|
||||||
|
- `cd client && npm run build`
|
||||||
|
- 通过;仍保留既有 Vite circular chunk 警告。
|
||||||
|
- 当前结论:
|
||||||
|
- 本地自动化已覆盖登录极简、企微移动入口、移动底部导航、移动审批、票据上传、“我的/更多”和小屏菜单抽屉。
|
||||||
|
- 真实公网手机企微仍需人工打开一次,确认企业微信真实容器内的 OAuth、cookie 与默认 `/mobile` 落点。
|
||||||
|
|||||||
@@ -230,6 +230,7 @@ export function MobileApprovalsPage() {
|
|||||||
const [offlineDraftSubmitting, setOfflineDraftSubmitting] = useState(false);
|
const [offlineDraftSubmitting, setOfflineDraftSubmitting] = useState(false);
|
||||||
const [isOnline, setIsOnline] = useState(true);
|
const [isOnline, setIsOnline] = useState(true);
|
||||||
const autoSyncRequestedRef = useRef(false);
|
const autoSyncRequestedRef = useRef(false);
|
||||||
|
const detailSectionRef = useRef<HTMLElement | null>(null);
|
||||||
|
|
||||||
const ownerEmployeeId = me?.id ?? null;
|
const ownerEmployeeId = me?.id ?? null;
|
||||||
const canUseRemote = isOnline && Boolean(accessToken);
|
const canUseRemote = isOnline && Boolean(accessToken);
|
||||||
@@ -258,6 +259,21 @@ export function MobileApprovalsPage() {
|
|||||||
setSearchParams(nextParams, { replace: true });
|
setSearchParams(nextParams, { replace: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function scrollDetailIntoViewOnMobile() {
|
||||||
|
if (typeof window === "undefined" || !window.matchMedia("(max-width: 767px)").matches) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.requestAnimationFrame(() => {
|
||||||
|
detailSectionRef.current?.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTaskSelection(taskId: string) {
|
||||||
|
updateTaskSelection(taskId);
|
||||||
|
scrollDetailIntoViewOnMobile();
|
||||||
|
}
|
||||||
|
|
||||||
async function invalidateWorkflowQueries(taskId: string | null = selectedTaskId) {
|
async function invalidateWorkflowQueries(taskId: string | null = selectedTaskId) {
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
queryClient.invalidateQueries({ queryKey: ["mobile", "workflow", "tasks"] }),
|
queryClient.invalidateQueries({ queryKey: ["mobile", "workflow", "tasks"] }),
|
||||||
@@ -781,11 +797,12 @@ export function MobileApprovalsPage() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{taskTab === "pending" ? (
|
{taskTab === "pending" ? (
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="grid gap-2 sm:flex sm:flex-wrap sm:items-center">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
size="sm"
|
size="sm"
|
||||||
variant={batchMode ? "default" : "secondary"}
|
variant={batchMode ? "default" : "secondary"}
|
||||||
|
className="min-h-11 w-full sm:w-auto"
|
||||||
disabled={!filteredTaskCards.length}
|
disabled={!filteredTaskCards.length}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setBatchMode((current) => !current);
|
setBatchMode((current) => !current);
|
||||||
@@ -800,13 +817,14 @@ export function MobileApprovalsPage() {
|
|||||||
type="button"
|
type="button"
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
|
className="min-h-11 w-full sm:w-auto"
|
||||||
disabled={!filteredTaskCards.length}
|
disabled={!filteredTaskCards.length}
|
||||||
onClick={toggleSelectAllVisibleTasks}
|
onClick={toggleSelectAllVisibleTasks}
|
||||||
>
|
>
|
||||||
{allVisibleSelected ? "清空选择" : "全选当前筛选结果"}
|
{allVisibleSelected ? "清空选择" : "全选当前筛选结果"}
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
<span className="text-sm text-zinc-500">
|
<span className="text-sm text-zinc-500 sm:ml-1">
|
||||||
当前结果 {filteredTaskCards.length} 条
|
当前结果 {filteredTaskCards.length} 条
|
||||||
{batchMode ? `,已选 ${selectedBatchTaskIds.length} 条` : ""}
|
{batchMode ? `,已选 ${selectedBatchTaskIds.length} 条` : ""}
|
||||||
</span>
|
</span>
|
||||||
@@ -820,10 +838,11 @@ export function MobileApprovalsPage() {
|
|||||||
<div className="rounded-2xl border border-zinc-200 bg-zinc-50 p-3 text-sm">
|
<div className="rounded-2xl border border-zinc-200 bg-zinc-50 p-3 text-sm">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
<div className="text-zinc-700">已选择 {selectedBatchTaskIds.length} 条待办,可在线串行处理或离线逐条入队。</div>
|
<div className="text-zinc-700">已选择 {selectedBatchTaskIds.length} 条待办,可在线串行处理或离线逐条入队。</div>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="grid w-full gap-2 sm:flex sm:w-auto sm:flex-wrap">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
className="min-h-11 w-full sm:w-auto"
|
||||||
disabled={!selectedBatchTaskIds.length || batchActionMutation.isPending}
|
disabled={!selectedBatchTaskIds.length || batchActionMutation.isPending}
|
||||||
onClick={() => handleBatchAction("approve")}
|
onClick={() => handleBatchAction("approve")}
|
||||||
>
|
>
|
||||||
@@ -833,6 +852,7 @@ export function MobileApprovalsPage() {
|
|||||||
type="button"
|
type="button"
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
|
className="min-h-11 w-full sm:w-auto"
|
||||||
disabled={!selectedBatchTaskIds.length || batchActionMutation.isPending || !batchRejectComment.trim()}
|
disabled={!selectedBatchTaskIds.length || batchActionMutation.isPending || !batchRejectComment.trim()}
|
||||||
onClick={() => handleBatchAction("reject")}
|
onClick={() => handleBatchAction("reject")}
|
||||||
>
|
>
|
||||||
@@ -899,7 +919,7 @@ export function MobileApprovalsPage() {
|
|||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="min-w-0 flex-1 text-left"
|
className="min-h-16 min-w-0 flex-1 text-left"
|
||||||
data-testid={`mobile-approvals-task-${task.id}`}
|
data-testid={`mobile-approvals-task-${task.id}`}
|
||||||
onClick={() => toggleBatchTask(task.id)}
|
onClick={() => toggleBatchTask(task.id)}
|
||||||
>
|
>
|
||||||
@@ -918,11 +938,11 @@ export function MobileApprovalsPage() {
|
|||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={task.id}
|
key={task.id}
|
||||||
className={`w-full rounded-xl border px-3 py-3 text-left ${
|
className={`min-h-20 w-full rounded-xl border px-3 py-3 text-left transition-colors ${
|
||||||
isSelected ? "border-primary bg-primary-50" : "border-zinc-200 bg-white"
|
isSelected ? "border-primary bg-primary-50" : "border-zinc-200 bg-white"
|
||||||
}`}
|
}`}
|
||||||
data-testid={`mobile-approvals-task-${task.id}`}
|
data-testid={`mobile-approvals-task-${task.id}`}
|
||||||
onClick={() => updateTaskSelection(task.id)}
|
onClick={() => handleTaskSelection(task.id)}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
@@ -941,7 +961,7 @@ export function MobileApprovalsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="card-std space-y-3 p-4" data-testid="mobile-approvals-detail">
|
<section ref={detailSectionRef} className="card-std scroll-mt-4 space-y-3 p-4" data-testid="mobile-approvals-detail">
|
||||||
<h3 className="text-base font-semibold text-zinc-900">任务详情与动作</h3>
|
<h3 className="text-base font-semibold text-zinc-900">任务详情与动作</h3>
|
||||||
|
|
||||||
{!selectedTaskId ? <p className="text-sm text-zinc-500">请先选择任务。</p> : null}
|
{!selectedTaskId ? <p className="text-sm text-zinc-500">请先选择任务。</p> : null}
|
||||||
@@ -1021,8 +1041,9 @@ export function MobileApprovalsPage() {
|
|||||||
<p className="text-xs text-zinc-500">离线状态下仅支持通过 / 驳回草稿,退回与转审需联网执行。</p>
|
<p className="text-xs text-zinc-500">离线状态下仅支持通过 / 驳回草稿,退回与转审需联网执行。</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="grid gap-2 sm:grid-cols-3">
|
||||||
<Button
|
<Button
|
||||||
|
className="min-h-12 w-full"
|
||||||
data-testid="mobile-approvals-approve"
|
data-testid="mobile-approvals-approve"
|
||||||
disabled={isActionSubmitting || !selectedTask.allowedActions.includes("approve")}
|
disabled={isActionSubmitting || !selectedTask.allowedActions.includes("approve")}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -1037,6 +1058,7 @@ export function MobileApprovalsPage() {
|
|||||||
{canUseRemote ? "通过" : "离线加入通过草稿"}
|
{canUseRemote ? "通过" : "离线加入通过草稿"}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
|
className="min-h-12 w-full"
|
||||||
data-testid="mobile-approvals-reject"
|
data-testid="mobile-approvals-reject"
|
||||||
disabled={isActionSubmitting || !selectedTask.allowedActions.includes("reject")}
|
disabled={isActionSubmitting || !selectedTask.allowedActions.includes("reject")}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -1052,6 +1074,7 @@ export function MobileApprovalsPage() {
|
|||||||
{canUseRemote ? "驳回" : "离线加入驳回草稿"}
|
{canUseRemote ? "驳回" : "离线加入驳回草稿"}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
|
className="min-h-12 w-full"
|
||||||
data-testid="mobile-approvals-return"
|
data-testid="mobile-approvals-return"
|
||||||
disabled={isActionSubmitting || !canUseRemote || !selectedTask.allowedActions.includes("return")}
|
disabled={isActionSubmitting || !canUseRemote || !selectedTask.allowedActions.includes("return")}
|
||||||
onClick={() => actionMutation.mutate("return")}
|
onClick={() => actionMutation.mutate("return")}
|
||||||
@@ -1063,7 +1086,7 @@ export function MobileApprovalsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{canTransfer ? (
|
{canTransfer ? (
|
||||||
<div className="space-y-2 rounded-lg border border-zinc-200 p-3 text-sm">
|
<div className="space-y-3 rounded-lg border border-zinc-200 bg-white p-3 text-sm">
|
||||||
<p className="font-medium text-zinc-800">转审</p>
|
<p className="font-medium text-zinc-800">转审</p>
|
||||||
<select
|
<select
|
||||||
className="h-10 w-full rounded-lg border border-zinc-300 bg-white px-3 py-2 text-sm"
|
className="h-10 w-full rounded-lg border border-zinc-300 bg-white px-3 py-2 text-sm"
|
||||||
@@ -1088,6 +1111,7 @@ export function MobileApprovalsPage() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
|
className="min-h-12 w-full"
|
||||||
data-testid="mobile-approvals-transfer"
|
data-testid="mobile-approvals-transfer"
|
||||||
disabled={isActionSubmitting || !selectedTask.allowedActions.includes("transfer")}
|
disabled={isActionSubmitting || !selectedTask.allowedActions.includes("transfer")}
|
||||||
onClick={() => actionMutation.mutate("transfer")}
|
onClick={() => actionMutation.mutate("transfer")}
|
||||||
@@ -1111,8 +1135,9 @@ export function MobileApprovalsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{currentQueueItems.length ? (
|
{currentQueueItems.length ? (
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="grid gap-2 sm:flex sm:flex-wrap">
|
||||||
<Button
|
<Button
|
||||||
|
className="min-h-11 w-full sm:w-auto"
|
||||||
data-testid="mobile-approvals-sync-queue"
|
data-testid="mobile-approvals-sync-queue"
|
||||||
disabled={!canUseRemote || syncMutation.isPending || !retryableQueueItems.length}
|
disabled={!canUseRemote || syncMutation.isPending || !retryableQueueItems.length}
|
||||||
onClick={() => syncMutation.mutate(retryableQueueItems)}
|
onClick={() => syncMutation.mutate(retryableQueueItems)}
|
||||||
@@ -1135,12 +1160,13 @@ export function MobileApprovalsPage() {
|
|||||||
<div className="text-zinc-500">状态:{queueStatusLabel(item.status)} · 更新时间:{formatDateTime(item.updatedAt)}</div>
|
<div className="text-zinc-500">状态:{queueStatusLabel(item.status)} · 更新时间:{formatDateTime(item.updatedAt)}</div>
|
||||||
<div className="text-zinc-500">意见:{item.comment ?? "-"}</div>
|
<div className="text-zinc-500">意见:{item.comment ?? "-"}</div>
|
||||||
<div className="text-zinc-500">最近错误:{item.lastError ?? "-"}</div>
|
<div className="text-zinc-500">最近错误:{item.lastError ?? "-"}</div>
|
||||||
<div className="mt-2 flex flex-wrap gap-2">
|
<div className="mt-2 grid gap-2 sm:flex sm:flex-wrap">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
onClick={() => updateTaskSelection(item.taskId)}
|
className="min-h-11 w-full sm:w-auto"
|
||||||
|
onClick={() => handleTaskSelection(item.taskId)}
|
||||||
>
|
>
|
||||||
查看任务
|
查看任务
|
||||||
</Button>
|
</Button>
|
||||||
@@ -1149,6 +1175,7 @@ export function MobileApprovalsPage() {
|
|||||||
type="button"
|
type="button"
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
|
className="min-h-11 w-full sm:w-auto"
|
||||||
disabled={syncMutation.isPending}
|
disabled={syncMutation.isPending}
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
await updateWorkflowActionDraftStatus({
|
await updateWorkflowActionDraftStatus({
|
||||||
@@ -1170,6 +1197,7 @@ export function MobileApprovalsPage() {
|
|||||||
type="button"
|
type="button"
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
|
className="min-h-11 w-full sm:w-auto"
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
await removeWorkflowActionDraft(item.taskId, ownerEmployeeId);
|
await removeWorkflowActionDraft(item.taskId, ownerEmployeeId);
|
||||||
await refreshDraftQueueCache();
|
await refreshDraftQueueCache();
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { uploadInvoice } from "../api/invoicePool";
|
|||||||
import { mapApiErrorMessage } from "../api/errorMessages";
|
import { mapApiErrorMessage } from "../api/errorMessages";
|
||||||
import { isApiError } from "../api/types";
|
import { isApiError } from "../api/types";
|
||||||
import { PageHeader } from "../components/common/PageHeader";
|
import { PageHeader } from "../components/common/PageHeader";
|
||||||
|
import { PageScaffold } from "../components/patterns/PageScaffold";
|
||||||
import { Button } from "../components/ui/button";
|
import { Button } from "../components/ui/button";
|
||||||
import { Spinner } from "../components/ui/spinner";
|
import { Spinner } from "../components/ui/spinner";
|
||||||
import {
|
import {
|
||||||
@@ -125,10 +126,10 @@ export function MobileCapturePage() {
|
|||||||
}, [queuedItems, syncMutation]);
|
}, [queuedItems, syncMutation]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<PageScaffold className="space-y-4">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="移动拍照上传"
|
title="票据上传"
|
||||||
subtitle="支持离线缓存与联网自动重放。"
|
subtitle="拍照或选择发票文件,离线时自动加入待上传队列。"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{error ? (
|
{error ? (
|
||||||
@@ -139,18 +140,30 @@ export function MobileCapturePage() {
|
|||||||
<p className="rounded-lg border border-emerald-200 bg-emerald-50 px-3 py-2 text-sm text-emerald-700">{message}</p>
|
<p className="rounded-lg border border-emerald-200 bg-emerald-50 px-3 py-2 text-sm text-emerald-700">{message}</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div className="card-std space-y-3 p-4">
|
<div className="card-std space-y-4 p-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<h3 className="text-base font-semibold text-zinc-900">拍照/选择文件</h3>
|
<div>
|
||||||
<span className={isOnline ? "text-emerald-600" : "text-orange-600"}>
|
<h3 className="text-base font-semibold text-zinc-900">选择票据</h3>
|
||||||
|
<p className="mt-1 text-sm leading-5 text-zinc-500">支持图片和 PDF,手机会优先打开相机。</p>
|
||||||
|
</div>
|
||||||
|
<span className={isOnline ? "shrink-0 text-sm text-emerald-600" : "shrink-0 text-sm text-orange-600"}>
|
||||||
{isOnline ? "在线" : "离线"}
|
{isOnline ? "在线" : "离线"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<label
|
||||||
|
htmlFor="mobile-capture-file"
|
||||||
|
className="flex min-h-36 cursor-pointer flex-col items-center justify-center rounded-2xl border border-dashed border-zinc-300 bg-zinc-50 px-4 py-6 text-center transition-colors hover:border-zinc-400 hover:bg-white"
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined text-[32px] text-zinc-700">add_photo_alternate</span>
|
||||||
|
<span className="mt-2 text-sm font-semibold text-zinc-900">拍照或选择文件</span>
|
||||||
|
<span className="mt-1 text-xs leading-5 text-zinc-500">JPG、PNG、PDF 均可上传</span>
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
|
id="mobile-capture-file"
|
||||||
accept="image/*,application/pdf"
|
accept="image/*,application/pdf"
|
||||||
capture="environment"
|
capture="environment"
|
||||||
className="w-full text-sm"
|
className="sr-only"
|
||||||
data-testid="mobile-capture-file"
|
data-testid="mobile-capture-file"
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
const file = event.target.files?.[0] ?? null;
|
const file = event.target.files?.[0] ?? null;
|
||||||
@@ -159,12 +172,14 @@ export function MobileCapturePage() {
|
|||||||
type="file"
|
type="file"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="text-sm text-zinc-600">
|
<div className="rounded-xl border border-zinc-200 bg-white px-3 py-3 text-sm text-zinc-600">
|
||||||
当前文件:{selectedFile ? `${selectedFile.name} (${Math.ceil(selectedFile.size / 1024)} KB)` : "未选择"}
|
<span className="font-medium text-zinc-800">当前文件:</span>
|
||||||
|
{selectedFile ? `${selectedFile.name} (${Math.ceil(selectedFile.size / 1024)} KB)` : "未选择"}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="grid gap-2 sm:grid-cols-2">
|
||||||
<Button
|
<Button
|
||||||
|
className="min-h-12 w-full"
|
||||||
data-testid="mobile-capture-upload"
|
data-testid="mobile-capture-upload"
|
||||||
disabled={uploadMutation.isPending || !selectedFile}
|
disabled={uploadMutation.isPending || !selectedFile}
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
@@ -202,6 +217,7 @@ export function MobileCapturePage() {
|
|||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
|
className="min-h-12 w-full"
|
||||||
data-testid="mobile-capture-sync-queue"
|
data-testid="mobile-capture-sync-queue"
|
||||||
disabled={!queuedItems.length || syncMutation.isPending || !isOnline}
|
disabled={!queuedItems.length || syncMutation.isPending || !isOnline}
|
||||||
onClick={() => syncMutation.mutate(queuedItems)}
|
onClick={() => syncMutation.mutate(queuedItems)}
|
||||||
@@ -214,9 +230,9 @@ export function MobileCapturePage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="card-std space-y-3 p-4" data-testid="mobile-capture-queue">
|
<div className="card-std space-y-3 p-4" data-testid="mobile-capture-queue">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<h3 className="text-base font-semibold text-zinc-900">离线上传队列</h3>
|
<h3 className="text-base font-semibold text-zinc-900">离线上传队列</h3>
|
||||||
<span className="text-sm text-zinc-500">{queuedCountLabel}</span>
|
<span className="shrink-0 text-sm text-zinc-500">{queuedCountLabel}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{queueQuery.isLoading ? <div className="flex items-center gap-2 text-sm text-zinc-500"><Spinner size="sm" />加载中...</div> : null}
|
{queueQuery.isLoading ? <div className="flex items-center gap-2 text-sm text-zinc-500"><Spinner size="sm" />加载中...</div> : null}
|
||||||
@@ -226,11 +242,12 @@ export function MobileCapturePage() {
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{queuedItems.map((item) => (
|
{queuedItems.map((item) => (
|
||||||
<div key={item.id} className="rounded-lg border border-zinc-200 px-3 py-2 text-sm">
|
<div key={item.id} className="rounded-xl border border-zinc-200 bg-white px-3 py-3 text-sm">
|
||||||
<div className="font-medium text-zinc-800">{item.fileName}</div>
|
<div className="font-medium text-zinc-800">{item.fileName}</div>
|
||||||
<div className="text-zinc-500">创建时间:{formatTime(item.createdAt)}</div>
|
<div className="text-zinc-500">创建时间:{formatTime(item.createdAt)}</div>
|
||||||
<div className="mt-2">
|
<div className="mt-2">
|
||||||
<Button
|
<Button
|
||||||
|
className="min-h-11 w-full sm:w-auto"
|
||||||
data-testid={`mobile-capture-remove-${item.id}`}
|
data-testid={`mobile-capture-remove-${item.id}`}
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
await removeQueuedInvoiceUpload(item.id);
|
await removeQueuedInvoiceUpload(item.id);
|
||||||
@@ -246,6 +263,6 @@ export function MobileCapturePage() {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</PageScaffold>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,435 @@
|
|||||||
|
import { devices, expect, test, type Page, type Route } from "@playwright/test";
|
||||||
|
|
||||||
|
function browserAgnosticDevice(name: keyof typeof devices) {
|
||||||
|
const device = devices[name];
|
||||||
|
return {
|
||||||
|
viewport: device.viewport,
|
||||||
|
userAgent: device.userAgent,
|
||||||
|
deviceScaleFactor: device.deviceScaleFactor,
|
||||||
|
isMobile: device.isMobile,
|
||||||
|
hasTouch: device.hasTouch,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function envelope(data: unknown) {
|
||||||
|
return {
|
||||||
|
requestId: "mock-r89",
|
||||||
|
data,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fulfillJson(route: Route, data: unknown, status = 200) {
|
||||||
|
await route.fulfill({
|
||||||
|
status,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify(envelope(data)),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const mockMe = {
|
||||||
|
id: "employee-mock-1",
|
||||||
|
employeeNo: "M001",
|
||||||
|
name: "Manager",
|
||||||
|
phone: "13800000000",
|
||||||
|
email: "[email protected]",
|
||||||
|
departmentId: "dept-1",
|
||||||
|
directManagerId: null,
|
||||||
|
status: "active",
|
||||||
|
notificationPushEnabled: true,
|
||||||
|
roleCodes: ["manager"],
|
||||||
|
profileCompleteness: {
|
||||||
|
hasEmergencyContact: true,
|
||||||
|
hasBankAccount: true,
|
||||||
|
hasAvatar: false,
|
||||||
|
hasIdNumber: false,
|
||||||
|
score: 50,
|
||||||
|
},
|
||||||
|
wecomBinding: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const pendingTask = {
|
||||||
|
id: "task-r89-1",
|
||||||
|
instanceId: "instance-r89-1",
|
||||||
|
stepId: "manager_approve",
|
||||||
|
stepName: "经理审批",
|
||||||
|
assigneeId: "employee-mock-1",
|
||||||
|
assigneeRole: "manager",
|
||||||
|
status: "pending",
|
||||||
|
outcome: null,
|
||||||
|
createdAt: "2026-05-05T09:00:00.000Z",
|
||||||
|
completedAt: null,
|
||||||
|
workflowInstance: {
|
||||||
|
entityType: "reimbursement",
|
||||||
|
entityId: "reimbursement-r89-1",
|
||||||
|
currentStatus: "submitted",
|
||||||
|
},
|
||||||
|
allowedActions: ["approve", "reject", "return"],
|
||||||
|
};
|
||||||
|
|
||||||
|
const pendingTaskDetail = {
|
||||||
|
task: {
|
||||||
|
id: pendingTask.id,
|
||||||
|
instanceId: pendingTask.instanceId,
|
||||||
|
stepId: pendingTask.stepId,
|
||||||
|
stepName: pendingTask.stepName,
|
||||||
|
assigneeId: pendingTask.assigneeId,
|
||||||
|
assigneeRole: pendingTask.assigneeRole,
|
||||||
|
status: "pending",
|
||||||
|
outcome: null,
|
||||||
|
comment: null,
|
||||||
|
createdAt: pendingTask.createdAt,
|
||||||
|
completedAt: null,
|
||||||
|
},
|
||||||
|
workflowInstance: {
|
||||||
|
id: pendingTask.instanceId,
|
||||||
|
entityType: "reimbursement",
|
||||||
|
currentStatus: "submitted",
|
||||||
|
currentStepId: pendingTask.stepId,
|
||||||
|
contextData: { standardViolations: [] },
|
||||||
|
startedAt: "2026-05-05T08:50:00.000Z",
|
||||||
|
completedAt: null,
|
||||||
|
},
|
||||||
|
reimbursement: {
|
||||||
|
id: "reimbursement-r89-1",
|
||||||
|
formNumber: "BX-R89-001",
|
||||||
|
status: "submitted",
|
||||||
|
reimbursementDate: "2026-05-05",
|
||||||
|
totalAmount: "1683.00",
|
||||||
|
description: "R89 移动审批验证",
|
||||||
|
owner: { id: "employee-mock-2", employeeNo: "E002", name: "Employee" },
|
||||||
|
submitter: { id: "employee-mock-2", employeeNo: "E002", name: "Employee" },
|
||||||
|
},
|
||||||
|
leaveRequest: null,
|
||||||
|
purchaseRequest: null,
|
||||||
|
travelRequest: null,
|
||||||
|
allowedActions: ["approve", "reject", "return"],
|
||||||
|
history: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
function navigationItem(key: string, href: string, label: string, icon: string, placement = "main") {
|
||||||
|
return { key, href, label, icon, placement, permissionCodes: [], featureFlagKey: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function mockR89Apis(page: Page) {
|
||||||
|
await page.route((url) => url.pathname.startsWith("/api/"), async (route) => {
|
||||||
|
const request = route.request();
|
||||||
|
const url = new URL(request.url());
|
||||||
|
const path = url.pathname;
|
||||||
|
|
||||||
|
if (path === "/api/auth/wecom/login") {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "text/plain",
|
||||||
|
body: "mock wecom login entry",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === "/api/auth/sso/providers") {
|
||||||
|
await fulfillJson(route, []);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === "/api/auth/wecom/config") {
|
||||||
|
await fulfillJson(route, {
|
||||||
|
enabled: true,
|
||||||
|
entryEnabled: true,
|
||||||
|
oauthEnabled: true,
|
||||||
|
corpId: "wx-test-corp",
|
||||||
|
agentId: "1000002",
|
||||||
|
loginUrl: "/api/auth/wecom/login?returnTo=%2Fportal",
|
||||||
|
bindEnabled: true,
|
||||||
|
defaultReturnTo: "/portal",
|
||||||
|
status: "ready",
|
||||||
|
statusReasons: [],
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === "/api/auth/login" || path === "/api/auth/refresh") {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
headers: { "set-cookie": "has_session=1; Path=/; SameSite=Lax" },
|
||||||
|
body: JSON.stringify(envelope({
|
||||||
|
accessToken: "mock-r89-access-token",
|
||||||
|
expiresIn: 900,
|
||||||
|
mustChangePassword: false,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === "/api/auth/logout") {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
headers: { "set-cookie": "has_session=; Path=/; Max-Age=0" },
|
||||||
|
body: JSON.stringify(envelope({})),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === "/api/me") {
|
||||||
|
await fulfillJson(route, mockMe);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === "/api/system/navigation") {
|
||||||
|
await fulfillJson(route, {
|
||||||
|
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: [
|
||||||
|
navigationItem("portal", "/portal", "Portal", "home_app_logo"),
|
||||||
|
navigationItem("team", "/team", "团队", "groups"),
|
||||||
|
navigationItem("leave", "/leave-requests", "请假", "event_available"),
|
||||||
|
navigationItem("expense", "/reimbursements", "报销", "request_quote"),
|
||||||
|
navigationItem("workflow", "/workflow/tasks", "审批待办", "fact_check"),
|
||||||
|
navigationItem("invoice", "/invoice-pool", "票据中心", "receipt_long"),
|
||||||
|
],
|
||||||
|
admin: [],
|
||||||
|
backup: [navigationItem("settings", "/settings/profile", "个人设置", "manage_accounts")],
|
||||||
|
},
|
||||||
|
recommendedRolePresets: [],
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === "/api/presence/me") {
|
||||||
|
await fulfillJson(route, {
|
||||||
|
date: "2026-05-05",
|
||||||
|
item: {
|
||||||
|
id: "presence-r89",
|
||||||
|
employeeId: mockMe.id,
|
||||||
|
date: "2026-05-05",
|
||||||
|
status: "office",
|
||||||
|
timeRange: "full_day",
|
||||||
|
note: null,
|
||||||
|
source: "manual",
|
||||||
|
updatedAt: "2026-05-05T08:30:00.000Z",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === "/api/attendance/me/today") {
|
||||||
|
await fulfillJson(route, {
|
||||||
|
workDate: "2026-05-05",
|
||||||
|
needClockIn: false,
|
||||||
|
exemptionReason: "policy_disabled",
|
||||||
|
item: null,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === "/api/portal/team-status") {
|
||||||
|
await fulfillJson(route, [
|
||||||
|
{
|
||||||
|
employeeId: mockMe.id,
|
||||||
|
name: mockMe.name,
|
||||||
|
avatarUrl: null,
|
||||||
|
presenceStatus: "office",
|
||||||
|
attendanceClockIn: "2026-05-05T00:30:00.000Z",
|
||||||
|
attendanceClockOut: null,
|
||||||
|
onLeave: false,
|
||||||
|
leaveType: null,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === "/api/notifications") {
|
||||||
|
const unreadOnly = url.searchParams.get("unreadOnly") === "1";
|
||||||
|
await fulfillJson(route, {
|
||||||
|
page: 1,
|
||||||
|
pageSize: Number(url.searchParams.get("pageSize") ?? 5),
|
||||||
|
total: unreadOnly ? 1 : 2,
|
||||||
|
unreadOnly,
|
||||||
|
unreadCount: 1,
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: "notification-r89-1",
|
||||||
|
type: "workflow",
|
||||||
|
title: "有新的审批待办",
|
||||||
|
body: "请处理 R89 测试单据",
|
||||||
|
targetType: "workflow_task",
|
||||||
|
targetId: pendingTask.id,
|
||||||
|
readAt: null,
|
||||||
|
createdAt: "2026-05-05T09:10:00.000Z",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === "/api/workflow/tasks") {
|
||||||
|
const status = url.searchParams.get("status") ?? "pending";
|
||||||
|
await fulfillJson(route, {
|
||||||
|
page: 1,
|
||||||
|
pageSize: Number(url.searchParams.get("pageSize") ?? 100),
|
||||||
|
total: status === "pending" ? 1 : 0,
|
||||||
|
items: status === "pending" ? [pendingTask] : [],
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/^\/api\/workflow\/tasks\/[^/]+\/(approve|reject|return|transfer)$/.test(path)) {
|
||||||
|
await fulfillJson(route, { ok: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/^\/api\/workflow\/tasks\/[^/]+$/.test(path)) {
|
||||||
|
await fulfillJson(route, pendingTaskDetail);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === "/api/workflow/approvers") {
|
||||||
|
await fulfillJson(route, { page: 1, pageSize: 100, total: 0, items: [] });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === `/api/employees/${mockMe.id}/profile`) {
|
||||||
|
await fulfillJson(route, {
|
||||||
|
employee: {
|
||||||
|
id: mockMe.id,
|
||||||
|
employeeNo: mockMe.employeeNo,
|
||||||
|
name: mockMe.name,
|
||||||
|
phone: mockMe.phone,
|
||||||
|
email: mockMe.email,
|
||||||
|
status: "active",
|
||||||
|
employmentStatus: "active",
|
||||||
|
departmentId: "dept-1",
|
||||||
|
title: "负责人",
|
||||||
|
directManagerId: null,
|
||||||
|
hireDate: "2026-01-01",
|
||||||
|
probationEndDate: null,
|
||||||
|
lastWorkingDate: null,
|
||||||
|
workLocation: "北京",
|
||||||
|
emergencyContactName: null,
|
||||||
|
emergencyContactPhone: null,
|
||||||
|
notes: null,
|
||||||
|
avatarUrl: null,
|
||||||
|
archivedAt: null,
|
||||||
|
roleCodes: mockMe.roleCodes,
|
||||||
|
department: { id: "dept-1", name: "研发" },
|
||||||
|
directManager: null,
|
||||||
|
},
|
||||||
|
lifecycle: { events: [], checklistTasks: [], blockers: { openAssetCount: 0, pendingWorkflowTaskCount: 0, canCompleteOffboarding: true } },
|
||||||
|
assignedAssets: [],
|
||||||
|
organization: { reportingChain: [], directReports: [] },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === "/api/invoice-pool/upload") {
|
||||||
|
await fulfillJson(route, {
|
||||||
|
invoicePoolId: "invoice-r89-1",
|
||||||
|
status: "pending_ocr",
|
||||||
|
fileSha256: "mock-sha256",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await fulfillJson(route, {});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loginByPassword(page: Page) {
|
||||||
|
await page.goto("/login");
|
||||||
|
await page.getByTestId("login-identifier").fill("[email protected]");
|
||||||
|
await page.getByTestId("login-password").fill("ManagerPassword1234");
|
||||||
|
await page.getByTestId("login-submit").click();
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe("R89 企业微信移动入口", () => {
|
||||||
|
test.use({
|
||||||
|
...browserAgnosticDevice("iPhone 13"),
|
||||||
|
userAgent: "Mozilla/5.0 iPhone wxwork/4.1.20 MicroMessenger/7.0.1",
|
||||||
|
});
|
||||||
|
|
||||||
|
test("企业微信移动场景默认生成移动工作台入口", async ({ page }) => {
|
||||||
|
await mockR89Apis(page);
|
||||||
|
|
||||||
|
await page.goto("/login");
|
||||||
|
await expect(page.getByTestId("login-wecom-primary-panel")).toBeVisible();
|
||||||
|
await expect(page.getByTestId("wecom-entry-primary")).toBeVisible();
|
||||||
|
await expect(page.getByTestId("login-password-primary-panel")).toHaveCount(0);
|
||||||
|
|
||||||
|
await page.getByTestId("wecom-entry-primary").click();
|
||||||
|
await expect(page).toHaveURL(/\/api\/auth\/wecom\/login\?returnTo=%2Fmobile/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe("R89 移动底部导航与核心页面", () => {
|
||||||
|
test.use(browserAgnosticDevice("iPhone 13"));
|
||||||
|
|
||||||
|
test("密码登录后可完成移动首页、审批、票据和我的页关键路径", async ({ page }) => {
|
||||||
|
await mockR89Apis(page);
|
||||||
|
|
||||||
|
await loginByPassword(page);
|
||||||
|
await expect(page).toHaveURL(/\/mobile$/);
|
||||||
|
await expect(page.getByTestId("mobile-today-focus")).toBeVisible();
|
||||||
|
await expect(page.getByTestId("topbar-profile-trigger")).toBeHidden();
|
||||||
|
|
||||||
|
await expect(page.getByTestId("mobile-nav-home")).toBeVisible();
|
||||||
|
await expect(page.getByTestId("mobile-nav-approvals")).toBeVisible();
|
||||||
|
await expect(page.getByTestId("mobile-nav-capture")).toBeVisible();
|
||||||
|
await expect(page.getByTestId("mobile-nav-me")).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByTestId("mobile-nav-approvals").click();
|
||||||
|
await expect(page).toHaveURL(/\/mobile\/approvals/);
|
||||||
|
await expect(page.getByTestId("mobile-approvals-list")).toBeVisible();
|
||||||
|
await page.getByTestId(`mobile-approvals-task-${pendingTask.id}`).click();
|
||||||
|
await expect(page.getByTestId("mobile-approvals-detail")).toBeVisible();
|
||||||
|
await expect(page.getByTestId("mobile-approvals-approve")).toBeVisible();
|
||||||
|
await expect(page.getByTestId("mobile-approvals-reject")).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByTestId("mobile-nav-capture").click();
|
||||||
|
await expect(page).toHaveURL(/\/mobile\/capture/);
|
||||||
|
await expect(page.getByText("拍照或选择文件")).toBeVisible();
|
||||||
|
await page.getByTestId("mobile-capture-file").setInputFiles({
|
||||||
|
name: "r89-invoice.pdf",
|
||||||
|
mimeType: "application/pdf",
|
||||||
|
buffer: Buffer.from("%PDF-1.4\n% R89"),
|
||||||
|
});
|
||||||
|
await expect(page.getByTestId("mobile-capture-upload")).toBeEnabled();
|
||||||
|
|
||||||
|
await page.getByTestId("mobile-nav-me").click();
|
||||||
|
await expect(page).toHaveURL(/\/mobile\/me/);
|
||||||
|
await expect(page.getByTestId("mobile-more-links")).toBeVisible();
|
||||||
|
await expect(page.getByRole("link", { name: /个人设置/ })).toBeVisible();
|
||||||
|
await expect(page.getByRole("button", { name: "退出登录" })).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe("R89 小屏桌面菜单兜底", () => {
|
||||||
|
test.use(browserAgnosticDevice("Pixel 5"));
|
||||||
|
|
||||||
|
test("非移动路由的小屏菜单可以打开并关闭", async ({ page }) => {
|
||||||
|
await mockR89Apis(page);
|
||||||
|
|
||||||
|
await loginByPassword(page);
|
||||||
|
await page.getByTestId("mobile-nav-me").click();
|
||||||
|
await page.getByRole("link", { name: /通知中心/ }).click();
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(/\/notifications/);
|
||||||
|
await page.getByRole("button", { name: "打开菜单" }).click();
|
||||||
|
await expect(page.getByRole("button", { name: "关闭菜单" })).toBeVisible();
|
||||||
|
await expect(page.getByRole("link", { name: "Portal" })).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "关闭菜单" }).click();
|
||||||
|
await expect(page.getByRole("button", { name: "关闭菜单" })).toBeHidden();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
"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 校验。"
|
"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 通过。"
|
"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。"
|
"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;风险中等,涉及全局壳布局。"
|
"A4","移动端导航可靠性修复","修复手机端菜单点不开和桌面菜单压缩到手机的问题;移动端优先使用底部导航和更多页,普通桌面菜单抽屉只作为兜底。","/mobile 下不依赖桌面侧边菜单;底部导航点击稳定;我的/更多页能进入低频模块;非 /mobile 小屏页面的菜单按钮可打开且可关闭。","命令:cd client && npm run typecheck && npm run build。业务:手机宽度 390px 下依次点击首页、审批、报销或票据、我的/更多,均能跳转;菜单抽屉不被遮罩或安全区挡住。","manual|tsc|vite","DONE","DONE","DONE","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;风险中等,涉及全局壳布局。2026-05-05:/mobile 手机端隐藏桌面 Topbar,底部导航补测试标识;Topbar DialogContent 固定左侧抽屉定位;Playwright Pixel 5 小屏非移动路由菜单打开/关闭通过。"
|
||||||
"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,避免新增后端接口。"
|
"A5","移动工作台信息架构重排","把移动首页改为今日任务优先:待审批、待补票/待提交报销、最近通知、快捷动作;弱化桌面 Portal 的模块展示方式。","移动首页首屏能回答今天要处理什么;快捷入口包含发起报销、上传发票、审批待办;管理员入口不出现在普通员工首屏。","命令:cd client && npm run typecheck && npm run build。业务:员工登录 /mobile 后 3 秒内能看到待办与快捷操作;经理能看到审批待办;无权限入口不展示或降级。","manual|tsc|vite","DONE","DONE","DONE","client/src/pages/MobilePortalPage.tsx|client/src/api/portal.ts|client/src/api/workflow.ts|client/src/api/invoicePool.ts","[""A4""]","回滚路径:恢复 MobilePortalPage 原布局;优先复用现有 API,避免新增后端接口。2026-05-05:移动首页首屏新增今日重点、审批待办、未读通知和发起报销/上传票据/处理审批入口;低频入口下沉到我的页;iPhone Playwright 路径通过。"
|
||||||
"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 和业务状态机。"
|
"A6","移动端核心流程触控优化","对审批、发票上传、我的页面做小屏触控收口,避免按钮太小、卡片过密、操作入口被底部导航遮挡。","审批详情主操作在手机上可见可点;发票拍照/上传入口清晰;我的页能找到个人设置、绑定企业微信、退出登录和更多模块。","命令:cd client && npm run typecheck && npm run build。业务:手机宽度 390px 下完成审批查看→同意/拒绝入口可达;进入票据上传;进入个人设置和退出登录。","manual|tsc|vite","DONE","DONE","DONE","client/src/pages/MobileApprovalsPage.tsx|client/src/pages/MobileCapturePage.tsx|client/src/pages/MobileMePage.tsx|client/src/components/mobile/MobileBottomNav.tsx","[""A5""]","回滚路径:恢复相关页面布局;不改变审批 API 和业务状态机。2026-05-05:审批任务点选后手机端滚到详情,审批/驳回/退回按钮改为大触控目标;票据上传改为大选择区和全宽主按钮;我的页补个人设置、档案、更多入口和退出登录。"
|
||||||
"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 与本地登录后移动页。"
|
"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","DONE","DONE","DONE","client/tests/e2e/*mobile*.spec.ts|artifacts/r70/manual-trialrun-checklist.md|artifacts/r70/manual-trialrun-report.md","[""A6""]","回滚路径:移除新增测试与报告回填;若线上企微 OAuth 不能全自动完成,则只自动验证跳转 URL 与本地登录后移动页。2026-05-05:新增 client/tests/e2e/r89-mobile-login-navigation.spec.ts,覆盖企业微信移动入口、iPhone 移动核心路径、Pixel 5 小屏菜单兜底;3 passed;typecheck/build 通过,build 仍有既有 circular chunk 警告。"
|
||||||
|
|||||||
|
在新工单中引用
屏蔽一个用户